Debug一個C語言加法程序

以下內容作者原創(chuàng),歡迎指出錯誤,轉載請注明出處~

  • Debug環(huán)境:ubuntu 17.10
  • Debug工具:GCC (Ubuntu 7.2.0-8ubuntu3.2) 7.2.0


    目錄

[TOC]

加法程序源代碼

//add.c
#include <stdio.h>
int main()
{
    int a,b,c;
    a=1;
    b=2;
    c=a+b;
    printf("%d",c);
}

調試過程

首先在ubuntu下建一個文件夾,然后使用新建一個add.c的文件(不必使用CB那些,直接用vim和gedit也可以),里面填上我們的代碼。
>注意:要是你創(chuàng)建不了文件的話很可能是你的權限不足,直接使用 **chmod 777 add**就行(add是我文件夾的名稱)

這里我解釋下,其實我們的源程序在變成可執(zhí)行程序的時候,需要經(jīng)過預處理(Processing)、編譯(Compilation)、匯編(Assembly)、鏈接(Linking)四個階段。

預處理(Processing)

ubuntu比較簡單是因為GCC直接集成在了系統(tǒng)的環(huán)境變量里,比較方便(好吧,我承認其實就是因為我懶,不想去配置windows下的環(huán)境變量)。在剛才新建的文件夾里打開Terminal,獲得根權限,然后執(zhí)行gcc -E add.c -o add.processing 解釋下這段命令,gcc - E表示預處理,o是output,后面是生成預處理中間文件的名稱。執(zhí)行完此命令后你可以在add文件夾下找到一個名叫add.processing的文件,打開這個文件你可以看到原來幾行的文件變成了一大堆看不懂的東西,emmmmm……

typedef unsigned char __u_char;
typedef unsigned short int __u_short;
typedef unsigned int __u_int;
typedef unsigned long int __u_long;


typedef signed char __int8_t;
typedef unsigned char __uint8_t;
typedef signed short int __int16_t;
typedef unsigned short int __uint16_t;
typedef signed int __int32_t;
typedef unsigned int __uint32_t;

typedef signed long int __int64_t;
typedef unsigned long int __uint64_t;
……

 char* _IO_read_ptr;
 char* _IO_read_end;
 char* _IO_read_base;
 char* _IO_write_base;
 char* _IO_write_ptr;
 char* _IO_write_end;
 char* _IO_buf_base;
 char* _IO_buf_end;

 char *_IO_save_base;
 char *_IO_backup_base;
 char *_IO_save_end;

 struct _IO_marker *_markers;

 struct _IO_FILE *_chain;
 ……
int main()
{
    int a,b,c;
    a=1;
    b=2;
    c=a+b;
    printf("%d",c);
}

這里粘出來了部分代碼,從這里我們可以看到,預處理加上了一宏定義,然后定義了一大堆char的指針,對比了下源程序,我發(fā)現(xiàn)源程序里面的include不見了,再看了下本地stdio.h的內容,猜測了下是不是他把stdio的內容拷進去了?于是找了下google,度娘的解釋是(不要問我為什么google看度娘,扎心):

  1. 將源文件中以”include”格式包含的文件復制到編譯的源文件中。
  2. 用實際值替換用“#define”定義的字符串。
  3. 根據(jù)“#if”后面的條件決定需要編譯的代碼。

看了下還是挺為自己的機智所折服的哈哈哈,然后其實還進行了一些條件編譯,使得預處理器按照不同的條件去編譯,從而得到不同的目標代碼(不是一個源程序嗎,那應該執(zhí)行的結果是一樣的啊,為什么還要生成不同的目標代碼呢,是因為編譯環(huán)境的影響嗎)。貌似這樣就可以解釋為什么C語言允許頭文件相互引用了,因為一旦兩者相互引用,在復制生成的時候就會像遞歸一樣重復生成,后果不堪設想。這段字剛打完,我發(fā)現(xiàn)我錯了,還是有多個頭文件相互引用的情況,那這種情況怎么處理呢?后來發(fā)現(xiàn)原來還有條件編譯這種東西(Cpp學過,忘了),通過#ifndef這些條件編譯語句可以達到我們想要的效果。

編譯(Compilation)

這里我們執(zhí)行編譯命令:gcc -S add.c -o add.compilation(其實這里我就有點迷了,為什么我如果用預處理過后的文件就回報warning說:linker input file unused because linking not done?難道我直接執(zhí)行gcc -S默認會執(zhí)行gcc -E?)。執(zhí)行過后我們就會看到文件夾下多出來一個add.compilation的文件,打開后一看傻眼了:

    .file   "add.c"
    .section    .rodata
.LC0:
    .string "%d"
    .text
    .globl  main
    .type   main, @function
main:
.LFB0:
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    subq    $16, %rsp
    movl    $1, -12(%rbp)
    movl    $2, -8(%rbp)
    movl    -12(%rbp), %edx
    movl    -8(%rbp), %eax
    addl    %edx, %eax
    movl    %eax, -4(%rbp)
    movl    -4(%rbp), %eax
    movl    %eax, %esi
    leaq    .LC0(%rip), %rdi
    movl    $0, %eax
    call    printf@PLT
    movl    $0, %eax
    leave
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size   main, .-main
    .ident  "GCC: (Ubuntu 7.2.0-8ubuntu3.2) 7.2.0"
    .section    .note.GNU-stack,"",@progbits

這個難道就是傳說中的匯編指令?分析一波,從上往下看:

.file

就是我們的文件

.section

匯編程序中以.開頭的名稱并不是指令的助記符,不會被翻譯成機器指令,而是給匯編器一些特殊指示,稱為匯編指示(Assembler Directive)或偽操作(Pseudo-operation),由于它不是真正的指令所以加個“偽”字。.section指示把代碼劃分成若干個段(Section),程序被操作系統(tǒng)加載執(zhí)行時,每個段被加載到不同的地址,操作系統(tǒng)對不同的頁面設置不同的讀、寫、執(zhí)行權限。.rodata段保存程序的數(shù)據(jù),是只讀的,相當于C程序的全局變量。本程序中沒有定義數(shù)據(jù),所以.data段是空的。

.LC0

這里理解起來就比較吃力了,(問了下學過匯編的大佬,他竟然說我這不是匯編,WTF別嚇我,他又說可能是平臺的原因)據(jù)說這個.LC0是一個標簽,說白了就是一個地址,但是后面那一坨又是些什么鬼東西,看了下,首先這個string就沒怎么搞懂,.text又沒搞懂,但是剛才看了一篇博客說的.text是保存代碼的只讀可執(zhí)行區(qū)段,那就先假裝是嘛。global就是從全局函數(shù)?以此類推type就說明這個main是一個函數(shù)。
然后就是這個LFB0,這個東西寫在main后面,說明就是main的地址標簽。學到老活到老。cfi_def_cfa cfi_endproc cfi_startproc的命令,這些前面都有個關鍵字cfi 是Call Frame infromation的意思。.cfi_startproc 用在每個函數(shù)的開始,用于初始化一些內部數(shù)據(jù)結構,而.cfi_endproc 在函數(shù)結束的時候使用與.cfi_startproc相配套使用。

pushq %rbp

這個我先上一張圖


寄存器

就是j將rsp(堆?;羔槪簵#琾ushq 指令將rsp寄存器的值減去一個指針長度,在64-bits機器上即8byte,然后將 rbp寄存器的值寫入到rsp指向的地址處。

.cfi_def_cfa_offset 16

該指令表示: 此處距離CFA地址為16字節(jié),這里的此處就是指的是前面提到的rbp。
這個CFA我又專門去查了下:

CFA(Canonical Frame Address)是標準框架地址, 它被定義為在前一個調用框架中調用當前函數(shù)時的棧頂指針。

.cfi_offset 6, -16

把第6號寄存器[5]原先的值保存在距離CFA -16的位置。

movq %rsp, %rbp

這條指令是賦值語句,把后面的值賦給前面,即把 %rbp賦給 %rsp,現(xiàn)在兩個寄存器處在同一個位置,我覺得這里有必要上一張圖(網(wǎng)上偷看的):

高地址
   |   | 返回地址  |
   |   +----------+
   |   | 舊的ebp   |
低地址  +----------+ <--- %rsp(%rbp) 

這里你可能有點迷,為什么要這么做,這兩步操作是個規(guī)范化步驟, 叫做前序(prologue), 它有兩個作用 :

  • 標記一個新的調用框架。保存前一個函數(shù)的調用框架的基址(舊的rbp), 使rbp指向當前函數(shù)的調用框架基址。

  • 在函數(shù)的執(zhí)行過程中, 函數(shù)的局部變量將會是在返回地址之下的區(qū)域開辟空間來存放, 由于rbp是固定的, 可以用它作標桿, 標示參數(shù)與局部變量的位置。比如可能第一個參數(shù)位于%rbp + 8, 第二個參數(shù)位于%rbp + 12。也正是這個原因, 參數(shù)采用從右到左傳遞, 對實現(xiàn)可變參數(shù)有利: 通過%rbp + 8獲取第一個參數(shù)后, 可從中獲知參數(shù)個數(shù), 然后, 依次偏移, 即可獲取各個參數(shù)。

.cfi_def_cfa_register 6:

這條指令是位于movq %rsp, %rbp之后。意思是: 從這里開始, 使用rbp作為計算CFA的基址寄存器(前面用的是rsp)。

subq $16, %rsp

$:代表當前指令的地址
sub指令表示第二個參數(shù)的值減去第一個參數(shù),這里表示將rsp減去16,即將基地址下移16個字節(jié),就是為局部變量申請內存空間, 開辟了16字節(jié)是因為GCC的棧上默認對齊是16字節(jié),這個是查的GCC文檔。

movl $1, -12(%rbp) movl $2, -8(%rbp)

前面說了,%rbp是被調用者保護,保持不變,但是我們可以通過它來訪問變量。這里將$1(就是我們賦給a的1)尋址,數(shù)字->寄存器,現(xiàn)在指令棧的狀態(tài)就是:

高地址
   |   | 返回地址   |
   |   +----------+
   |   | 舊的ebp   |
   |   +----------+<--- %rsp(%rbp) 
   |   |          |
   |   +----------+
   |   |   b的值   |
   |   +----------+ <--- %rbp-8
   |   |   a的值   |
低地址  +----------+ <--- %rbp-12 
movl -12(%rbp), %edx movl -8(%rbp), %eax

這兩句就是將我們a,b的值賦給返回值和參數(shù)。

addl %edx, %eax

這個就比較簡單了,將我們上面賦好的值相加,表示將這兩個地址里面的值送入寄存器,將結果保存在#eax里。

movl %eax, -4(%rbp) movl -4(%rbp), %eax

這一段看了很久還是很迷啊先賦值,然后再賦回來是什么意思

高地址
   |   | 返回地址   |
   |   +----------+
   |   | 舊的ebp   |
   |   +----------+<--- %rsp(%rbp) 
   |   |          |
   |   +----------+ <--- %rbp-4
   |   |   b的值   |
   |   +----------+ <--- %rbp-8
   |   |   a的值   |
低地址  +----------+ <--- %rbp-12 
movl %eax, %esi

這里再把 %eax 賦給參數(shù)%esi

leaq .LC0(%rip), %rdi

lea是load effective address, 加載有效地址,可以將有效地址傳送到指定的的寄存器,其效果等同與C語言的&。但是這里我胖虎就不太理解了,這里前面沒有向這兩個空間寫入地址,對他們的相互賦值有意義嗎

movl $0, %eax

這個返回值?返回0,這個是哪來的???

call printf@PLT

函數(shù)調用,調用的是printf方法。

leave

這個指令叫尾聲,說道這個名詞你就會想到前面的前序,是的,這兩者的剛好相反,其實他就相當于這兩條語句:

movl %rbp, %rsp
pop %rbp
.cfi_def_cfa 7, 8

位于leave語句之后,現(xiàn)在重新定義CFA, 它的值是第7號寄存器(esp)所指位置加8字節(jié)。

ret

返回值返回

.LFE0:

它后面就是一些信息記錄了,比如編譯器版本……

  • CFI: 調用框架指令,,CFI全稱是Call Frame Instrctions, 即調用框架指令。CFI提供的調用框架信息, 為實現(xiàn)堆棧回繞(stack unwiding)或異常處理(exception handling)提供了方便, 它在匯編指令中插入指令符(directive), 以生成DWARF[3]可用的堆棧回繞信息。這里列有gas(GNU Assembler)支持的CFI指令符。

  • CFA(Canonical Frame Address)是標準框架地址, 它被定義為在前一個調用框架中調用當前函數(shù)時的棧頂指針。

匯編(Assembly)

匯編就是將匯編代碼轉換為機器可以執(zhí)行的指令。在匯編過程中,只有很少的信息丟失了,因此我們可以有反匯編器(dis-assembler)。反編譯器不存在的原因是編譯過程中丟失了高級語言的語法結構信息,局部變量的名字也被替換成了偏移量,因此程序一旦被編譯為二進制碼,就無法被還原成源代碼了。
執(zhí)行匯編命令:gcc -c add.c -o add.assembly

7f45 4c46 0201 0100 0000 0000 0000 0000
0100 3e00 0100 0000 0000 0000 0000 0000
0000 0000 0000 0000 e002 0000 0000 0000
0000 0000 4000 0000 0000 4000 0d00 0c00
5548 89e5 4883 ec10 c745 f401 0000 00c7
45f8 0200 0000 8b55 f48b 45f8 01d0 8945
fc8b 45fc 89c6 488d 3d00 0000 00b8 0000
0000 e800 0000 00b8 0000 0000 c9c3 2564
0000 4743 433a 2028 5562 756e 7475 2037
2e32 2e30 2d38 7562 756e 7475 332e 3229
2037 2e32 2e30 0000 1400 0000 0000 0000
017a 5200 0178 1001 1b0c 0708 9001 0000
1c00 0000 1c00 0000 0000 0000 3e00 0000
0041 0e10 8602 430d 0679 0c07 0800 0000
0000 0000 0000 0000 0000 0000 0000 0000
0000 0000 0000 0000 0100 0000 0400 f1ff
0000 0000 0000 0000 0000 0000 0000 0000
0000 0000 0300 0100 0000 0000 0000 0000
0000 0000 0000 0000 0000 0000 0300 0300
0000 0000 0000 0000 0000 0000 0000 0000
0000 0000 0300 0400 0000 0000 0000 0000
0000 0000 0000 0000 0000 0000 0300 0500
0000 0000 0000 0000 0000 0000 0000 0000
0000 0000 0300 0700 0000 0000 0000 0000
0000 0000 0000 0000 0000 0000 0300 0800
0000 0000 0000 0000 0000 0000 0000 0000
0000 0000 0300 0600 0000 0000 0000 0000
0000 0000 0000 0000 0700 0000 1200 0100
0000 0000 0000 0000 3e00 0000 0000 0000
0c00 0000 1000 0000 0000 0000 0000 0000
0000 0000 0000 0000 2200 0000 1000 0000
0000 0000 0000 0000 0000 0000 0000 0000
0061 6464 2e63 006d 6169 6e00 5f47 4c4f
4241 4c5f 4f46 4653 4554 5f54 4142 4c45
……

這是一堆什么鬼,但是我們可以猜測,這就是機器碼。

鏈接(Linking)

未經(jīng)鏈接的目標碼(匯編成的文件)是不可執(zhí)行的。鏈接就是在不同的模塊間對符號進行重定位(relocation)。早在使用機器語言在穿孔紙帶上寫程序時,人們無法忍受手工修改模塊間跳轉地址的麻煩,于是就有了符號表和根據(jù)符號表做重定位的鏈接器。因此,鏈接器的歷史比匯編器還要長。執(zhí)行語句nm add.assembly,nm可以方便地查看目標文件中的符號(函數(shù)、變量),其中 U 表示 undefined(未定義)。

                 U _GLOBAL_OFFSET_TABLE_
0000000000000000 T main
                 U printf

后記

做到這其實基本上整個匯編過程就走了一遍了,但是其中還是有很多小問題亟待解決,很多代碼還是不太明白。里面的疑惑還是很多沒解決。但是通過本次嘗試還是基本了解了在硬件層面上的那些指令棧的內容。放一張圖片來皮一下:


x86 寄存器

注意

  1. 其實GCC在生成文件的時候不像我這么隨意,后綴名其實是遵循一些規(guī)則的:

gcc所遵循的部分約定規(guī)則:
.c為后綴的文件,C語言源代碼文件;
.a為后綴的文件,是由目標文件構成的檔案庫文件;
.C,.cc或.cxx 為后綴的文件,是C++源代碼文件且必須要經(jīng)過預處理;
.h為后綴的文件,是程序所包含的頭文件;
.i 為后綴的文件,是C源代碼文件且不應該對其執(zhí)行預處理;
.ii為后綴的文件,是C++源代碼文件且不應該對其執(zhí)行預處理;
.m為后綴的文件,是Objective-C源代碼文件;
.mm為后綴的文件,是Objective-C++源代碼文件;
.o為后綴的文件,是編譯后的目標文件;
.s為后綴的文件,是匯編語言源代碼文件;
.S為后綴的文件,是經(jīng)過預編譯的匯編語言源代碼文件。

  1. 由于編譯環(huán)境的不同,會產(chǎn)生一些小的差異

參考資料

  1. 編譯:一個 C 程序的藝術之旅
  2. 百度百科:GCC
  3. 通過反匯編一個簡單的C程序,分析匯編代碼理解計算機是如何工作的
  4. x86匯編程序基礎
  5. cpp文件編譯生成的匯編文件里語句的作用
  6. GCC文檔
  7. 終極參考X86匯編調用框架淺析與CFI簡介
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容