
一步一步看懂栈的变化——从反汇编一个简单的C程序出发Linux内核分析实验一一、实验目的通过反汇编一个简单的 C 程序观察高级语言在 32 位 X86 机器上是如何落地的函数调用是怎么发生的、参数怎么传、返回值怎么带回来、堆栈在每个时刻长什么样进而回答一个问题——计算机是如何工作的。二、实验环境VirtualBox 虚拟机Ubuntu 22.04.3 LTS宿主 64 位程序按 32 位编译gcc (Ubuntu 11.4.0)-m32需安装 gcc-multilibgdb 12.1三、实验代码与编译按实验要求修改讲义中的数字以防雷同x 3改为x 13f(8)改为f(42)。// main.cintg(intx){returnx13;}intf(intx){returng(x);}intmain(void){returnf(42)1;}用gcc -S -o main.s main.c -m32生成 32 位汇编。编译后链接运行./main; echo $?输出 56 42131整条调用链工作正常****main.s 节选如下删去了与栈分析无关的.cfi_*调试伪指令另外每次pushl %ebp; movl %esp,%ebp之后都有call __x86.get_pc_thunk.axaddl $_GLOBAL_OFFSET_TABLE_,%eax这是 Ubuntu 默认开启 PIC位置无关代码的固定搭档call 进 thunk、thunk 用movl (%esp),%eax取出返回地址再ret回来一压一弹对栈的净影响为零下面的分析直接跳过它们g: pushl %ebp movl %esp, %ebp movl 8(%ebp), %eax addl $13, %eax popl %ebp ret f: pushl %ebp movl %esp, %ebp pushl 8(%ebp) call g addl $4, %esp leave ret main: pushl %ebp movl %esp, %ebp pushl $42 call f addl $4, %esp addl $1, %eax leave ret四、三个函数分别干了什么先明确几条关键指令的语义后面全靠它们指令等价效果作用pushl srcesp-4; [esp]src压栈存旧ebp、压参数popl dstdst[esp]; esp4出栈movl %esp,%ebpebpesp立起新栈帧的“地基”call fesp-4; [esp]eip; eipf压入返回地址再跳走reteip[esp]; esp4弹出返回地址跳回去leaveespebp; popl %ebp拆掉自己的栈帧、恢复调用者的g(x)建帧后用8(%ebp)取参数——ebp指向保存的旧 ebp4是返回地址8正是第一个参数加 13 后结果放eax32 位约定返回值放 eaxpopl %ebp; ret返回。f(x)建帧后把8(%ebp)即 42又压了一遍当作 g 的参数call g回来后addl $4,%esp把转压的参数作废leave; ret返回。f 全程没碰 eaxg 的返回值 55 就这样原样透传。mainpushl $42、call f、回来addl $1,%eax、leave; ret。值得注意的是 main 也是被调用的——gdb 里能看到它的返回地址指向 C 运行库__libc_start_call_main说明栈在 main 执行前就存在了。五、堆栈变化的完整过程核心用 gdb 在*main、*f、*g三个函数入口*表示停在第一条指令跳过 gdb 默认的“跳过函数序言”行为下断点抓到的真实现场说明栈地址受地址随机化和环境变量影响每次运行都不同以自己一次完整运行为准下面所有地址都取自上图这一次真实运行。从 main 入口开始逐条指令跟踪括号内为实测值main 入口前 esp0xffffda5c栈顶是 0xf7d9e519 —— C 运行库压好的返回地址 pushl %ebp esp→0xffffda58调用者的 ebp(0xf7ffd020) 入栈 movl %esp,%ebp ebp0xffffda58 —— main 的栈帧立起来了 pushl $42 esp→0xffffda54参数 42 入栈 call f esp→0xffffda50返回地址 0x565561d0 入栈eip 跳到 f ── 进入 f ── pushl %ebp esp→0xffffda4c保存 main 的 ebp(0xffffda58) movl %esp,%ebp ebp0xffffda4c —— f 的栈帧 pushl 8(%ebp) esp→0xffffda48把参数 42 转压给 g call g esp→0xffffda44返回地址 0x565561b7 入栈eip 跳到 g ── 进入 g ── pushl %ebp esp→0xffffda40保存 f 的 ebp(0xffffda4c) movl %esp,%ebp ebp0xffffda40 —— g 的栈帧栈到达最深处 movl 8(%ebp),%eax eax[0xffffda48]42 addl $13,%eax eax55 popl %ebp esp→0xffffda44ebp 恢复为 f 的 0xffffda4c ret eip 回到 fesp→0xffffda48 ── 回到 f ── addl $4,%esp esp→0xffffda4c作废转压的参数 leave espebp0xffffda4cpop 后 ebp0xffffda58、esp0xffffda50 ret eip 回到 mainesp→0xffffda54 ── 回到 main ── addl $4,%esp esp→0xffffda58 addl $1,%eax eax55156 —— 最终返回值 leave、ret 一路拆帧回到 C 运行库进程退出码 56g 栈帧建立后那一瞬间整条调用链的栈是这样的栈向低地址生长地址 内容 含义 0xffffda5c │ 0xf7d9e519 │ main 的返回地址C 运行库 0xffffda58 │ 0xf7ffd020 │ 保存的调用者的 ebp ←── main 的 ebp 指这里 0xffffda54 │ 0x0000002a │ main 传给 f 的参数 42 0xffffda50 │ 0x565561d0 │ call f 压入的返回地址 0xffffda4c │ 0xffffda58 │ f 保存的 main 的 ebp ←── f 的 ebp 指这里 0xffffda48 │ 0x0000002a │ f 转压给 g 的参数 42 0xffffda44 │ 0x565561b7 │ call g 压入的返回地址 0xffffda40 │ 0xffffda4c │ g 保存的 f 的 ebp ←── g 的 ebp/esp 指这里栈最深处可以看得很清楚三个栈帧像地层一样叠着每一层只记三样东西——上一层的帧基址ebp 链、返回地址、参数。ebp是每层帧的“门牌号”顺着保存的旧 ebp 一路能爬回最外层这也是调试器 backtrace 的原理。六、总结计算机是如何工作的做完这个实验我对“计算机是如何工作的”最大的感受是CPU 其实只会一件极简单的事——取指、执行。它永远从 eip 指向的地址取一条指令执行然后指向下一条。看起来复杂的函数调用和返回在机器层面不过是围绕 eip 和栈的几个约定动作call是“记住回头路再跳”ret是“沿原路回去”jmp是“不回头直接跳”。所谓函数嵌套调用落到内存上就是一层层叠起来的栈帧所谓return就是恢复上一层的 ebp、esp把 eip 弹回调用处返回值约定放在 eax 里带回去。所以我的理解可以浓缩成一句话存储程序计算机在“取指—执行”的循环中运转高级语言的一切结构最终都化作对 eip 的改写和对栈的记录。顺序执行是 eip 自增着走分支和循环是条件跳转改 eip函数调用是 call/ret 配合栈保存现场。再往上操作系统内核里的中断、异常、进程切换本质上也只是把“保存现场—恢复现场”这套动作做得更系统而已。理解了单靠 eip栈就能搭出整个函数调用世界再去看内核代码时很多“魔法”就都有迹可循了。附本实验完整命令gcc-S-omain.s main.c-m32# 生成 32 位汇编gcc-g-omain main.c-m32# 带调试信息编译链接gdb-q-batch-exbreak *main-exbreak *f-exbreak *g-exrun\-exinfo registers eip esp ebp-exx/6wx$esp\-excontinue-exinfo registers eip esp ebp-exx/6wx$esp\-excontinue-exinfo registers eip esp ebp-exx/6wx$esp\-exx/1dw$esp4-exfinish-exp/d$eax./main