从 main() 的反汇编入手程序运行逻辑

本博客已经迁移到星潮星屿
本文通过分析 32 位 helloworld.c 程序的反汇编代码学习程序是如何被计算机运行起来的

编译得到的各阶段文件

我们需要知道 gcc 是将程序分为多个阶段编译出来的,对于一个 hello.c 文件,它的各阶段文件可能是:

hello.c   # 源文件
hello.i   # 展开预编译语句之后的源代码文件
hello.S   # 由源代码文件生成的汇编代码文件
hello.o   # 编译汇编代码文件生成的可重定向目标程序
hello     # 链接 hello.o 和其他动态库等而生成的可执行目标程序

20250929-addd699deec6d42f

反汇编可执行程序

objdump 工具可以进行反编译:

# 反编译可执行目标程序
objdump -S -M intel,i386 hello
# 反编译可重定位目标程序
objdump -S -M intel,i386 hello.o

20250929-2aad849d02235873

可以发现 hello.o 反编译得到的汇编代码是没有 .init 段的,而 hello 反编译得到的汇编代码是有 .init 段的,同时 hello.text 段中有 C 程序提供的开始执行的入口 _start() 函数,因此 hello 程序是可以运行的。虽然这种分析有些本末倒置的意味,但这可以帮助我们确认 hello 确实是可以运行起来的。

实际上 hello.o 就是源程序经过预编译展开后编译的得到的结果,而 hello 则是编译器为 hello.o 添加了一些必要的运行用的入口点等内容,从而使得操作系统能够让 CPU 正确的运行该程序。

gdb 调试程序

我们结合 gdb 程序,验证反编译的得到的汇编代码中 main 函数的部分:

image-20251008205018385

使用 layout asmgdb 中显示汇编代码:

image-20251008205202178

与我们使用 objdump 得到的是基本一致的(objdump 加入了显示源代码和使用 INTEL 语法的选项):

image-20251008205332763

接下来,我们启动程序,为了验证 main() 函数的所有汇编指令,我们使用 starti 命令,然后给 main() 的第一条汇编指令处打断点,接着 continue:

image-20251008211009954

观察一下 espebp 寄存器的状态:

image-20251008211053709

然后我们运行一条指令 lea 0x4(%esp),%ecx 即将 esp + 4 之后的值赋值给 ecx,这条命令的意义是拿到参数 argc 的地址,而 +4 越过的 4 个字节实际上是函数运行完毕后的返回地址。

image-20251008214858985

接下来让 esp 寄存器 & 0xffff fff0,也就是说将 exp 的低四位清空了,对于栈来说就是将指针向下移动了,询问 AI 得知其意义是为了将栈指针十六字节对齐,以确保 SSE/SIMD 命令能正常执行。

接着将地址为 ecx - 4 的数据(注意不是 ecx - 4 而是该地址储存的数据)推入栈中,这一步操作的意义是为了在 main() 运行完毕之后恢复栈指针,而之所以进行了 +4-4 的操作,是因为在语义上我们要让 ecx 为参数的起始地址,方便后续对参数的访问。

之后将 ebp 推入栈中,ebp 是一个函数用来访问 局部变量函数参数 等的基址,要将调用 main() 函数时的 ebp 保存在栈中,然后再将为当前函数设置新的 ebp 值。在这里将 esp 赋值给 ebp 就是设置 ebp 了。

接着再 push 保存 ecxebxecxargc 参数的位置(需要注意的是,这和其他函数的调用不同,其他函数的调用是从右往左入栈的,而 main 函数的调用不是), 而 ebx 暂时没有查到其用处,或许是 _start() 需要保持的一个值。

之后 esp - 16,这一步是为了给局部变量预留空间,因为后面对局部变量的操作都是通过 ebp 以及 mov 操作,所以栈指针可以直接移动,在这个过程中,还应当保持栈的 16 字节对齐。

IA-32 上,需要保证函数入口 ESP 是 4 的倍数。当使用 SSE(如 movaps)时,GCC 会在调用前保证 16 字节对齐。

之后的调用则是为了将 eip 寄存器的值赋值给 eax,因为 eip 是不可直接读取的。

接着从 ebp - 0x14 写入 0x3 (变量 x), 向 ebp - 0x10 写入 0x5 (变量 y)。我们可以在这个地方查看一下内存中的值:

20251008-50117f9f8445caca

再将两者的和写入 edp - 0xc (变量 z),其具体步骤是先将 x 和 y 的值先分别写入 ecxedx 寄存器中,然后将 ecx 加给 edx,最后将 edx 写入 edp - 0xc:

20251008-b9b45463c24b1392

之后开始了准备并调用 printf 的工作,注意到栈指针减小了 8 个字节,这意味着后面一定会 push 两次,而后面的代码也印证了我们的推断。这样的设计都是为了保持栈对齐。

z 推入栈中,因为我们需要打印它的值,所以依据 call 以及 C 的规范(cdecl),我们需要将参数 push 到栈中。到这个时候,我们已经向栈 pushold_esp 的值, old_ebp, old_ebx, old_ecx, var z, var y, var x,我们可以检查一下,由于栈是从高地址向低地址生长的,所以我们可以方便的使用 gdb 提供的命令直接打印整个栈(使用一个较大的 size 来确保整个栈被打印出来):

20251009-a6e8811863702ba8

可以发现所有数据都按照预期打印出来了,包括前面 push 的寄存器的值,比如 old_ebp 处的值确实是 0xf7ffcca0 (将上图中的数据按照小端序读出来)。

接下来我们看到将 eax - 0x1fec 处的值 push 进去了,我们可以在 objdump 反编译出来的代码中找找这个数据:

20251009-48d69b8a08ad46b7

可以看到字符串前面有 8.,所以偏移应该是 0x8,然后我们看看运行时虚拟内存的地址分布:

20251009-b76f614963d05067

是从 0x56557000 开始的,所以这个字符串的起始位置大概是 0x56557008

20251009-b395e0647022b1c3

通过计算 eax 也可以得到这个结果,我们打印看看:

20251009-95764c9767e9dfd8

符合预期,所以我们就知道这里的代码就是将 printf 需要的格式化字符串的起始地址 push 到栈里。

接着通过 call printf 函数来打印 z,当然,再次之前我们已经按照规范将需要参数写入到对应的寄存器或栈的相应位置。

20251009-b9a941acac83afc3

最后移动栈顶指针“清除”为了调用 printf 传入的局部变量。

下面开始返回了,将 eax 置空,将 ebp - 0x8 赋值给 esp “清除” main() 函数内的所有局部变量,然后弹出 ecx, ebx, ebp 从而恢复为调用 main 时的值。之后重置 esp。最后成功返回。

相信大家借此能对程序的运行有更多的理解。

完结撒花🎉🎉🎉

posted @ 2025-10-24 14:31  EOF_break  阅读(2)  评论(0)    收藏  举报