汇编一个简单的 C 语言程序并分析其汇编指令执行过程

1、C语言文件

int g(int x)
{
    return x + 3;
}
int f(int x)
{
    return g(x);
}
int main(void)
{
    return f(8) + 1;
}

2、反汇编

如果想把 main.c 编译成一个汇编代码,那么可以使用如下命令:
gcc –S –o main.s main.c –m32
上述命令产生一个以“.s”作为扩展名的汇编代码文件 main.s。需要注意的是,“实验楼”环境是 64 位的, 32 位和 64 位汇编程序会有些差异。本书以 32 位 x86 为例,上述 gcc命令中的“-m32”选项即用来产生 32 位汇编代码。这时打开 main.s,会发现这个文件是 main.c 生成的,但 main.s 汇编文件还有一些“.cfi_”打头的字符串以及其他以“.”打头的字符串,这些都是编译器在链接阶段所需的辅助信息,如下完整的 main.s 汇编代码读起来会有点让人不知所措。

.file "main.c"
.text
.globl g
.type g, @function
g:
.LFB0:
.cfi_startproc
pushl %ebp
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
movl %esp, %ebp
.cfi_def_cfa_register 5
movl 8(%ebp), %eax
addl $3, %eax
popl %ebp
.cfi_restore 5
.cfi_def_cfa 4, 4
ret
.cfi_endproc
.LFE0:
.size g, .-g
.globl f
.type f, @function

f:
.LFB1:
.cfi_startproc
pushl %ebp
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
movl %esp, %ebp
.cfi_def_cfa_register 5
subl $4, %esp
movl 8(%ebp), %eax
movl %eax, (%esp)
call g
leave
.cfi_restore 5
.cfi_def_cfa 4, 4
ret
.cfi_endproc
.LFE1:
.size f, .-f
.globl main
.type main, @function

main:
.LFB2:
.cfi_startproc
pushl %ebp
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
movl %esp, %ebp
.cfi_def_cfa_register 5
subl $4, %esp
movl $8, (%esp)
call f
addl $1, %eax
leave
.cfi_restore 5
.cfi_def_cfa 4, 4
ret
.cfi_endproc
.LFE2:
.size main, .-main
.ident "GCC: (Ubuntu 4.8.4-2ubuntu1~14.04.3) 4.8.4"
.section .note.GNU-stack,"",@progbits

由于我们的任务是分析汇编代码,因此可以把 main.s 简化一下,所有以“.”打头的字符串(都是编译器在链接阶段所需辅助信息)不会实际执行,可以都删掉。在 VIM 中,通g/\.s*/d命令即可删除所有以“.”打头的字符串,就获得了“干净”的汇编代码,这样如下的代码看起来就比较亲切了。

g:
pushl %ebp
movl %esp, %ebp
movl 8(%ebp), %eax
addl $3, %eax
popl %ebp
ret

f:
pushl %ebp
movl %esp, %ebp
subl $4, %esp
movl 8(%ebp), %eax
movl %eax, (%esp)
call g
leave
ret

main:
pushl %ebp
movl %esp, %ebp
subl $4, %esp
movl $8, (%esp)
call f
addl $1, %eax1.3 
leave
ret

接下来分析上述“干净”的汇编代码。可以看到,上述代码对应 3 个函数: main 函数、f 函数和 g 函数。很明显,将 C 语言代码和汇编代码对照起来,可以看到每个函数对应的汇编代码。阅读 C 语言代码时一般是从 main 函数开始读,其实阅读汇编代码也是一样的。C 语言代码中的 main 函数只有一行代码:“return f(8) + 1;”。

int main(void)
{
    return f(8) + 1;
}

f 函数也只有一条代码:“return g(x);”。其中的参数 x 是 8,因此, f(8)返回的是 g(8)。

int f(int x)
{
    return g(x);
}

g 函数也只有一条代码:“return x+3;”。其中的参数 x 是 8, g(8)返回的是 8+3。那么最终 main 函数返回的是 8+3+1=12。图 1-14 中的 a.out 执行结果的返回值 12 就是这么来的。C 语言代码比较容易读懂,因为其更接近自然语言,但汇编语言就比较难懂一些,因为其更接近机器语言。机器语言完全是二进制的,理解起来比较困难,汇编代码基本上是机器代码的简单翻译和对照。我们看这么简单的一个 C 语言程序 main.c 在机器上是如何执行的。本章前面大多都介绍过汇编文件 main.s 中的这些汇编指令,读者应该已经知道它们大概的功能和用途了。 main.s 中新出现的汇编指令是 leave 指令。 enter 和 leave 这对指令可以理解为宏指令了,其中 leave 指令用来撤销函数堆栈,等价于下面两条指令:

movl %ebp,%esp
popl %ebp

另外, enter 指令用来建立函数堆栈,等价于下面两条指令:

pushl %ebp
movl %esp, %ebp

enter 指令的作用就是再堆起一个空栈,后面介绍函数调用堆栈时会进行详细介绍。

而leave 指令就是撤销这个堆栈,和 enter 指令的作用正好相反。讲解完这个陌生的 leave 指令,

下面我们可以完整地分析一下 main.s 中的汇编代码了。EIP 寄存器是指向代码段中的一条条指令,即main.s 中的汇编指令,从“main:”开始,它会自加一,调用 call 指令时它会修改 EIP 寄存器。 EBP 寄存器和 ESP 寄存器也特别重要,这两个寄存器总是指向一个堆栈, EBP 指向栈底,而 ESP 指向栈顶。注意,栈底是一个相对的栈底,每个函数都有自己的函数堆栈和基地址。另外, EAX 寄存器用于暂存一些数值,函数的返回值默认使用 EAX 寄存器存储并返回给上一级调用函数。下面来具体分析删除所有以“.”打头的字符串之后的 main.s 中的汇编代码。最初程序从 main 函数开始执行,即 EIP 寄存器指向“main:”下面的第一条汇编指令。为了简化,使用如下汇编代码的行号作为 EIP 寄存器的值,来表示 EIP 寄存器指向行号对应汇编指令。

1 g:
2 pushl %ebp
3 movl %esp, %ebp
4 movl 8(%ebp), %eax
5 addl $3, %eax
6 popl %ebp
7 ret

8 f:
9 pushl %ebp
10 movl %esp, %ebp
11 subl $4, %esp
12 movl 8(%ebp), %eax
13 movl %eax, (%esp)
14 call g
15 leave
16 ret

17 main:
18 pushl %ebp
19 movl %esp, %ebp
20 subl $4, %esp
21 movl $8, (%esp)
22 call f
23 addl $1, %eax
24 leave
25 ret

3、汇编分析

代码在执行过程中,堆栈空间和相应的 EBP/ESP 寄存器会不断变化。首先假定堆栈为空栈的情况下 EBP 和 ESP 寄存器都指向栈底,为了简化起见,我们为栈空间的存储单元进行标号,压栈时标号加 1,出栈时标号减 1,这样更清晰一点。需要注意的是, x86 体系结构栈地址是向下增长的(地址减小),但这里只是为了便于知道堆栈存储单元的个数大小,栈空间的存储单元标号是逐渐增大的。如图 1-15 所示,右侧的数字表示内存地址, EBP 和ESP 寄存器都指向栈底,即指向一个 4 字节存储单元的下边缘 2000 的位置,指 2000~2003这 4 个字节,也就是标号为 0 的存储单元,依此类推,标号 1 的存储单元为 1996~1999 这4 个字节。

Snipaste_2020-05-17_12-37-53.png

程序从 main 函数开始执行,即上述代码的第 18 行,也就是“main:”下面的第一条汇编指令“pushl %ebp”,这是开始执行的第一条指令,这条指令的作用实际上就是把 EBP 寄存器的值(可以理解为标号 0,实际上是图 1-15 中的地址 2000)压栈, pushl 指令的功能是先把 ESP 寄存器指向标号 1 的位置,即标号加 1 或地址减 4(向下移动 4 个字节),然后将 EBP 寄存器的值标号 0(地址 2000)放到堆栈标号 1 的位置。

开始执行上一条指令时, EIP 寄存器已经自动加 1 指向了上述代码第 19 行语句“movl %esp,%ebp”,是将 EBP 寄存器也指向标号 1 的位置,这条语句只修改了 EBP 寄存器,栈空间的内容并没有变化。第 18 行和第 19 行语句是建立 main 函数自己的函数调用堆栈空间。

开始执行上一条指令时, EIP 寄存器已经自动加 1 指向了上述代码的第 20 行“subl $4,%esp”,把 ESP 寄存器减 4,实际上是 ESP 寄存器向下移动一个标号,指向标号 2 的位置。这条语句只修改了 ESP 寄存器,栈空间的内容并没有变化。

开始执行上一条指令时, EIP 寄存器已经自动加 1 指向了上述代码的第 21 行“movl$8,(%esp)”,把立即数 8 放入 ESP 寄存器指向的标号 2 位置,也就是第 20 行代码预留出来的标号 2 的位置。这条语句的 EBP 和 ESP 寄存器没有变化,栈空间发生了变化。第 20 和 21行语句是在为接下来调用 f 函数做准备,即压栈 f 函数所需的参数。

开始执行上一条指令时; EIP 寄存器已经自动加 1 指向了上述代码的第 22 行指令“call f”, call 指令我们仔细分析过,第 22 行指令相当于如下两条伪指令:

pushl %eip(*)
movl f %eip(*)

第 22 行语句“call f”开始执行时, EIP 寄存器已经自加 1 指向了下一条指令,即上述代码的第 23 行语句,实际上把 EIP 寄存器的值(行号为 23 的指令地址,我们用行号 23 表示)放到了栈空间标号 3 的位置。因为压栈前 ESP 寄存器的值是标号 2,压栈时 ESP 寄存器先减 4 个字节,即指向下一个位置标号 3,然后将 EIP 寄存器的行号 23 入栈到栈空间标号 3 的位置。接着将 f 函数的第一条指令的行号 9 放入 EIP 寄存器,这样 EIP 寄存器指向了 f 函数。这条语句既改变了栈空间,又改变了 ESP 寄存器,更重要的是它改变了 EIP 寄
存器。读者会发现原来 EIP 寄存器自加 1 指令是按顺序执行的,现在 EIP 寄存器跳转到了f 函数的位置。

接着开始执行 f 函数。首先执行第 9 行语句“pushl %ebp”,把 ESP 寄存器的值向下移一位到标号 4,然后把 EBP 寄存器的值标号 1 放到栈空间标号 4 的位置。

第 10 行语句“movl %esp, %ebp”是让 EBP 寄存器也和 ESP 寄存器一样指向栈空间标号 4 的位置。

读者可能会发现,第 9 行和第 10 行语句与第 18 行和第 19 行语句完全相同,而且 g 函数的开头两行也是这两条语句。总结一下:所有函数的头两条指令用于初始化函数自己的函数调用堆栈空间。

第 11 行语句要把 ESP 寄存器减 4,即指向下一个位置栈空间的标号 5,实际上就是为入栈留出一个存储单元的空间。

第 12 行语句通过 EBP 寄存器变址寻址: EBP 寄存器的值加 8,当前 EBP 寄存器指向标号 4 的位置,加 8 即再向上移动两个存储单元加两个标号的位置,实际所指向的位置就是堆栈空间中标号 2 的位置。如上所述,标号 2 的位置存储的是立即数 8,那么这一条语句的作用就是把立即数 8 放到了 EAX 寄存器中。

第 13 行语句是把 EAX 寄存器中存储的立即数 8 放到 ESP 寄存器现在所指的位置,即第 11 行语句预留出来的栈空间标号 5 的位置。第 11~13 行语句等价于“pushl $8”或“pushl 8(%ebp)”,实际上是将函数 f 的参数取出来,主要目的是为调用函数 g 做好参数入栈的准备。

第 14 行语句是“call g”,与上文中调用函数 f 类似,将 ESP 寄存器指向堆栈空间标号6 的位置,把 EIP 寄存器的内容行号 15 放到堆栈空间标号 6 的位置,然后把 EIP 寄存器指向函数 g 的第一条指令,即上述代码的第 2 行。

接下来执行函数 g,与执行函数 f 或函数 main 的开头完全相同。第 2 行语句就是先把EBP 寄存器存储的标号 4 压栈,存到堆栈空间标号 7 的位置,此时 ESP 寄存器为堆栈空间标号 7。

接下来的第 3 行语句让 EBP 寄存器也和 ESP 寄存器一样指向当前堆栈栈顶,即堆栈空间标号 7 的位置,这样就为函数 g 建立了一个逻辑上独立的函数调用堆栈空间

第 4 行语句“movl 8(%ebp), %eax”通过使用 EBP 寄存器变址寻址, EBP 寄存器加 8,也就是在当前 EBP 寄存器指向的栈空间标号 7 的位置基础上向上移动两个存储单元指向标号 5,然后把标号 5 的内容(也就是立即数 8)放到 EAX 寄存器中。实际上,这一步是将函数 g 的参数取出来。

第 5 行语句是把立即数 3 加到 EAX 寄存器里,就是 8+3, EAX 寄存器为 11。

这时 EBP 和 ESP 寄存器都指向标号 7, EAX 寄存器为 11, EIP 寄存器为代码行号 6,函数调用堆栈空间如图 1-16 所示。 EBP 或 ESP+栈空间的标号表示存储的是某个时刻的EBP 或 ESP 寄存器的值, EIP+代码行号表示存储的是某个时刻的 EIP 寄存器的值。

Snipaste_2020-05-17_12-42-06.png

第 6 行和第 7 行语句的作用是拆除 g 函数调用堆栈,并返回到调用函数 g 的位置。第6 行语句“popl %ebp”实际上就是把标号 7 的内容(也就是标号 4)放回 EBP 寄存器,也就是恢复函数 f 的函数调用堆栈基址 EBP 寄存器,效果是 EBP 寄存器又指向原来标号 4位置,同时 ESP 寄存器也要加 4 个字节指向标号 6 的位置。

第 7 行语句“ret”实际上就是“popl %eip”,把 ESP 寄存器所指向的栈空间存储单元标号 6 的内容(行号 15 即代码第 15 行的地址)放到 EIP 寄存器中,同时 ESP 寄存器加 4个字节指向标号 5 的位置,也就是现在 EIP 寄存器指向代码第 15 行的位置。

这时开始执行第 15 行语句“leave”,如上所述, leave 指令用来撤销函数堆栈,等价于下面两条指令:

movl %ebp,%esp
popl %ebp

结果是把 EBP 寄存器的内容标号 4 放到了 ESP 寄存器中,也就是 ESP 寄存器也指向标号 4。然后,“popl %ebp”语句把标号 4 的内容(也就是标号 1)放回 EBP 寄存器,实际上是把 EBP 寄存器指向标号 1 的置,同时 ESP 寄存器加 4 个字节指向标号 3 的位置。

第 16 行语句“ret”是把 ESP 寄存器所指向的标号 3 的位置的内容(行号 23 即代码第23 行指令的地址)放到 EIP 寄存器中,同时 ESP 寄存器加 4 个字节指向标号 2 的位置,也就是现在 EIP 指向第 23 行的位置。

第 23 行语句“addl$1, %eax”是把 EAX 寄存器加立即数 1,也就是 11+1,此时 EAX寄存器的值为 12。 EAX 寄存器是默认存储函数返回值的寄存器。

第 24 行语句“leave”撤销函数 main 的堆栈,把 EBP 和 ESP 寄存器都指向栈空间标号 1 的位置,同时把栈空间标号 1 存储的内容标号 0 放到 EBP 寄存器, EBP 寄存器就指向了标号 0 的位置,同时 esp 加 4 个字节,也指向标号 0 的位置。这时堆栈空间回到了 main 函数开始执行之初的状态, EBP 和 ESP 寄存器也都恢复到开始执行之初的状态指向标号 0。这样通过函数调用堆栈框架暂存函数的上下文状态信息,整个程序的执行过程变成了一个指令流,从 CPU 中“流”了一遍,最终栈空间又恢复到空栈状态。

Last modification:May 17th, 2020 at 12:50 pm
如果觉得我的文章对你有用,请随意赞赏

Leave a Comment

One comment

  1. kkwb Google Chrome 78.0.3904.108 Windows 7 中国 广西 桂林

    免费试用快递单号 免费试用空包单号网试用www.uudanhaowang.com