以下内容作者原创,欢迎指出错误,转载请注明出处~
Debug环境:ubuntu 17.10
Debug工具:GCC (Ubuntu 7.2.0-8ubuntu3.2) 7.2.0
目录
[TOC]
加法程序源代码
//add.c
#include
int main()
{
int a,b,c;
a=1;
b=2;
c=a+b;
printf("%d",c);
}
调试过程
首先在ubuntu下建一个文件夹,然后使用新建一个add.c的文件(不必使用CB那些,直接用vim和gedit也可以),里面填上我们的代码。
>注意:要是你创建不了文件的话很可能是你的权限不足,直接使用 **chmod 777 add**就行(add是我文件夹的名称)
这里我解释下,其实我们的源程序在变成可执行程序的时候,需要经过预处理(Processing)、编译(Compilation)、汇编(Assembly)、链接(Linking)四个阶段。
预处理(Processing)
ubuntu比较简单是因为GCC直接集成在了系统的环境变量里,比较方便(好吧,我承认其实就是因为我懒,不想去配置windows下的环境变量)。在刚才新建的文件夹里打开Terminal,获得根权限,然后执行gcc -E add.c -o add.processing 解释下这段命令,gcc - E表示预处理,o是output,后面是生成预处理中间文件的名称。执行完此命令后你可以在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的指针,对比了下源程序,我发现源程序里面的include不见了,再看了下本地stdio.h的内容,猜测了下是不是他把stdio的内容拷进去了?于是找了下google,度娘的解释是(不要问我为什么google看度娘,扎心):
将源文件中以”include”格式包含的文件复制到编译的源文件中。
用实际值替换用“#define”定义的字符串。
根据“#if”后面的条件决定需要编译的代码。
看了下还是挺为自己的机智所折服的哈哈哈,然后其实还进行了一些条件编译,使得预处理器按照不同的条件去编译,从而得到不同的目标代码(不是一个源程序吗,那应该执行的结果是一样的啊,为什么还要生成不同的目标代码呢,是因为编译环境的影响吗)。貌似这样就可以解释为什么C语言允许头文件相互引用了,因为一旦两者相互引用,在复制生成的时候就会像递归一样重复生成,后果不堪设想。这段字刚打完,我发现我错了,还是有多个头文件相互引用的情况,那这种情况怎么处理呢?后来发现原来还有条件编译这种东西(Cpp学过,忘了),通过#ifndef这些条件编译语句可以达到我们想要的效果。
编译(Compilation)
这里我们执行编译命令:gcc -S add.c -o add.compilation(其实这里我就有点迷了,为什么我如果用预处理过后的文件就回报warning说:linker input file unused because linking not done?难道我直接执行gcc -S默认会执行gcc -E?)。执行过后我们就会看到文件夹下多出来一个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),程序被操作系统加载执行时,每个段被加载到不同的地址,操作系统对不同的页面设置不同的读、写、执行权限。.rodata段保存程序的数据,是只读的,相当于C程序的全局变量。本程序中没有定义数据,所以.data段是空的。
.LC0
这里理解起来就比较吃力了,(问了下学过汇编的大佬,他竟然说我这不是汇编,WTF别吓我,他又说可能是平台的原因)据说这个.LC0是一个标签,说白了就是一个地址,但是后面那一坨又是些什么鬼东西,看了下,首先这个string就没怎么搞懂,.text又没搞懂,但是刚才看了一篇博客说的.text是保存代码的只读可执行区段,那就先假装是嘛。global就是从全局函数?以此类推type就说明这个main是一个函数。
然后就是这个LFB0,这个东西写在main后面,说明就是main的地址标签。学到老活到老。cfi_def_cfa cfi_endproc cfi_startproc的命令,这些前面都有个关键字cfi 是Call Frame infromation的意思。.cfi_startproc 用在每个函数的开始,用于初始化一些内部数据结构,而.cfi_endproc 在函数结束的时候使用与.cfi_startproc相配套使用。
pushq %rbp
这个我先上一张图
寄存器
就是j将rsp(堆栈基指针)压栈,pushq 指令将rsp寄存器的值减去一个指针长度,在64-bits机器上即8byte,然后将 rbp寄存器的值写入到rsp指向的地址处。
.cfi_def_cfa_offset 16
该指令表示: 此处距离CFA地址为16字节,这里的此处就是指的是前面提到的rbp。
这个CFA我又专门去查了下:
CFA(Canonical Frame Address)是标准框架地址, 它被定义为在前一个调用框架中调用当前函数时的栈顶指针。
.cfi_offset 6, -16
把第6号寄存器[5]原先的值保存在距离CFA -16的位置。
movq %rsp, %rbp
这条指令是赋值语句,把后面的值赋给前面,即把 %rbp赋给 %rsp,现在两个寄存器处在同一个位置,我觉得这里有必要上一张图(网上偷看的):
高地址
| | 返回地址 |
| +----------+
| | 旧的ebp |
低地址 +----------+
这里你可能有点迷,为什么要这么做,这两步操作是个规范化步骤, 叫做前序(prologue), 它有两个作用 :
标记一个新的调用框架。保存前一个函数的调用框架的基址(旧的rbp), 使rbp指向当前函数的调用框架基址。
在函数的执行过程中, 函数的局部变量将会是在返回地址之下的区域开辟空间来存放, 由于rbp是固定的, 可以用它作标杆, 标示参数与局部变量的位置。比如可能第一个参数位于%rbp + 8, 第二个参数位于%rbp + 12。也正是这个原因, 参数采用从右到左传递, 对实现可变参数有利: 通过%rbp + 8获取第一个参数后, 可从中获知参数个数, 然后, 依次偏移, 即可获取各个参数。
.cfi_def_cfa_register 6:
这条指令是位于movq %rsp, %rbp之后。意思是: 从这里开始, 使用rbp作为计算CFA的基址寄存器(前面用的是rsp)。
subq $16, %rsp
$:代表当前指令的地址
sub指令表示第二个参数的值减去第一个参数,这里表示将rsp减去16,即将基地址下移16个字节,就是为局部变量申请内存空间, 开辟了16字节是因为GCC的栈上默认对齐是16字节,这个是查的GCC文档。
movl $1, -12(%rbp) movl $2, -8(%rbp)
前面说了,%rbp是被调用者保护,保持不变,但是我们可以通过它来访问变量。这里将$1(就是我们赋给a的1)寻址,数字->寄存器,现在指令栈的状态就是:
高地址
| | 返回地址 |
| +----------+
| | 旧的ebp |
| +----------+
| | |
| +----------+
| | b的值 |
| +----------+
| | a的值 |
低地址 +----------+
movl -12(%rbp), %edx movl -8(%rbp), %eax
这两句就是将我们a,b的值赋給返回值和参数。
addl %edx, %eax
这个就比较简单了,将我们上面赋好的值相加,表示将这两个地址里面的值送入寄存器,将结果保存在#eax里。
movl %eax, -4(%rbp) movl -4(%rbp), %eax
这一段看了很久还是很迷啊先赋值,然后再赋回来是什么意思
高地址
| | 返回地址 |
| +----------+
| | 旧的ebp |
| +----------+
| | |
| +----------+
| | b的值 |
| +----------+
| | a的值 |
低地址 +----------+
movl %eax, %esi
这里再把 %eax 赋給参数%esi
leaq .LC0(%rip), %rdi
lea是load effective address, 加载有效地址,可以将有效地址传送到指定的的寄存器,其效果等同与C语言的&。但是这里我胖虎就不太理解了,这里前面没有向这两个空间写入地址,对他们的相互赋值有意义吗
movl $0, %eax
这个返回值?返回0,这个是哪来的啊?
call printf@PLT
函数调用,调用的是printf方法。
leave
这个指令叫尾声,说道这个名词你就会想到前面的前序,是的,这两者的刚好相反,其实他就相当于这两条语句:
movl %rbp, %rsp
pop %rbp
.cfi_def_cfa 7, 8
位于leave语句之后,现在重新定义CFA, 它的值是第7号寄存器(esp)所指位置加8字节。
ret
返回值返回
.LFE0:
它后面就是一些信息记录了,比如编译器版本……
CFI: 调用框架指令,,CFI全称是Call Frame Instrctions, 即调用框架指令。CFI提供的调用框架信息, 为实现堆栈回绕(stack unwiding)或异常处理(exception handling)提供了方便, 它在汇编指令中插入指令符(directive), 以生成DWARF[3]可用的堆栈回绕信息。这里列有gas(GNU Assembler)支持的CFI指令符。
CFA(Canonical Frame Address)是标准框架地址, 它被定义为在前一个调用框架中调用当前函数时的栈顶指针。
汇编(Assembly)
汇编就是将汇编代码转换为机器可以执行的指令。在汇编过程中,只有很少的信息丢失了,因此我们可以有反汇编器(dis-assembler)。反编译器不存在的原因是编译过程中丢失了高级语言的语法结构信息,局部变量的名字也被替换成了偏移量,因此程序一旦被编译为二进制码,就无法被还原成源代码了。
执行汇编命令: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)
未经链接的目标码(汇编成的文件)是不可执行的。链接就是在不同的模块间对符号进行重定位(relocation)。早在使用机器语言在穿孔纸带上写程序时,人们无法忍受手工修改模块间跳转地址的麻烦,于是就有了符号表和根据符号表做重定位的链接器。因此,链接器的历史比汇编器还要长。执行语句nm add.assembly,nm可以方便地查看目标文件中的符号(函数、变量),其中 U 表示 undefined(未定义)。
U _GLOBAL_OFFSET_TABLE_
0000000000000000 T main
U printf
后记
做到这其实基本上整个汇编过程就走了一遍了,但是其中还是有很多小问题亟待解决,很多代码还是不太明白。里面的疑惑还是很多没解决。但是通过本次尝试还是基本了解了在硬件层面上的那些指令栈的内容。放一张图片来皮一下:
x86 寄存器
注意
其实GCC在生成文件的时候不像我这么随意,后缀名其实是遵循一些规则的:
gcc所遵循的部分约定规则:
.c为后缀的文件,C语言源代码文件;
.a为后缀的文件,是由目标文件构成的档案库文件;
.C,.cc或.cxx 为后缀的文件,是C++源代码文件且必须要经过预处理;
.h为后缀的文件,是程序所包含的头文件;
.i 为后缀的文件,是C源代码文件且不应该对其执行预处理;
.ii为后缀的文件,是C++源代码文件且不应该对其执行预处理;
.m为后缀的文件,是Objective-C源代码文件;
.mm为后缀的文件,是Objective-C++源代码文件;
.o为后缀的文件,是编译后的目标文件;
.s为后缀的文件,是汇编语言源代码文件;
.S为后缀的文件,是经过预编译的汇编语言源代码文件。
由于编译环境的不同,会产生一些小的差异
参考资料