ARTICLE · 1206375
源码到可执行程序
从源码到可执行文件,中间隔着一条流水线。这一篇带你亲手走一遍,让你知道每一站把什么变成了什么。
示例
// add.c#include<stdio.h>intadd(int a, int b){int ret = a + b;return ret;}intmain(void){int r = add(1, 2);printf("1 + 2 = %d\n", r);return 0;}
第1步:预处理
gcc -E add.c -o add.iwc -l add.c add.i
输出大概:
12 add.c825 add.i837 total
add.c 12 行,add.i 800 多行。多出来的全是从 stdio.h 展开的内容——#include 那一行被替换成了几百行头文件代码。你的 add.i 具体多少行取决于 glibc 版本,看量级就行。
预处理只做一件事:文本替换。#include 把文件内容贴进来,#define 把宏替换掉。它不懂运算、不懂类型。
第2步:编译
gcc -S add.i -o add.s打开 add.s,add 函数长这样(x86-64,-O0,简化过):
add:pushq %rbpmovq %rsp, %rbpmovl %edi, -20(%rbp) ; a 存入栈movl %esi, -24(%rbp) ; b 存入栈movl -20(%rbp), %edx ; edx = amovl -24(%rbp), %eax ; eax = baddl %edx, %eax ; eax = a + bmovl %eax, -4(%rbp) ; ret = a + bmovl -4(%rbp), %eax ; return retpopq %rbpret
一个 + 号变成八九行。a、b、ret 这些名字也没了,只剩内存地址操作。
第3步:汇编
gcc -c add.s -o add.ofile add.o
file 输出 ELF 64-bit LSB relocatable。"relocatable" 意思是:里面还有洞没填,比如 printf 的地址还不知道,等着链接器处理。
第4步:链接
gcc add.o -o add./add
链接器把 .o 文件和库拼起来,把"名字"换成"地址"——printf 换成 libc 里的地址,add 换成可执行文件里的地址。
gcc -E | |||
gcc -S | |||
gcc -c | |||
gcc |
日常敲的 gcc add.c -o add 是一口气跑完这四步。拆开只是为了看见中间发生了什么。
每一步都是一次信息损失。 你写的 int、a、ret、add 这些名字,到机器码那一层基本都不存在了。
机器码到底长什么样
前面一直说"机器码",但还没看过。反汇编 add.o:
objdump -d add.o0000000000000000 <add>:0: f3 0f 1e fa endbr644: 55 push %rbp5: 48 89 e5 mov %rsp,%rbp8: 89 7d ec mov %edi,-0x14(%rbp)b: 89 75 e8 mov %esi,-0x18(%rbp)e: 8b 55 ec mov -0x14(%rbp),%edx11: 8b 45 e8 mov -0x18(%rbp),%eax14: 01 d0 add %edx,%eax16: 89 45 fc mov %eax,-0x4(%rbp)19: 8b 45 fc mov -0x4(%rbp),%eax1c: 5d pop %rbp1d: c3 ret
中间那一列(55、48 89 e5)就是真正的机器码,右边是 objdump 翻译出来的汇编。它们一一对应:55 就是 push %rbp。-0x14(%rbp) 就是上面的 -20(%rbp),十六进制 0x14 等于十进制 20。
CPU 执行的是中间那一列,右边那一列是给人看的。
链接改变了什么
objdump -d add | grep -A 20 "<main>:"0000000000001167 <main>:1167: f3 0f 1e fa endbr64116b: 55 push %rbp116c: 48 89 e5 mov %rsp,%rbp116f: 48 83 ec 10 sub $0x10,%rsp1173: be 02 00 00 00 mov $0x2,%esi1178: bf 01 00 00 00 mov $0x1,%edi117d: e8 c7 ff ff ff call 1149 <add>1182: 89 45 fc mov %eax,-0x4(%rbp)1185: 8b 45 fc mov -0x4(%rbp),%eax1188: 89 c6 mov %eax,%esi
add 自己的汇编几乎没变,但地址变了——在 add.o 里它从 0 开始,因为目标文件还没链接,符号是相对地址;在 add 里它变成了一个真实地址。main 里 call add 的目标也变成了这个真实地址,call printf 指向 libc。
链接器干的事就是:把"名字"换成"地址"。 前面第 4 步说"填洞",现在你亲眼看到了。
readelf:文件不是一整块
readelf -h add # 文件头:ELF64、x86-64、可执行、入口地址readelf -S add # 段表:.text .data .bss .rodatareadelf -s add # 符号表:add、main、printf 各自对应一个地址
.text放机器指令,.rodata放只读数据。分段是因为权限不同——代码段改不了,数据段能改。符号表就是"名字 → 地址"的映射。 strip删的就是它,删完之后bt只能看到地址,看不到函数名。这三条命令的价值不是让你读懂 ELF,而是给"信息损失""链接填地址"这些抽象说法找实物证据。
O0 和 O2 编出来的不一样
gcc -S -O0 add.c -o add_O0.sgcc -S -O2 add.c -o add_O2.s
-O2 的 add:
add:lea eax, [rdi+rsi]ret
两条。-O0 是八九条。
同一个函数,只是优化等级不同。-O0 里 ret 这个变量被老老实实存到了 -4(%rbp),-O2 里它根本不存在——编译器发现它只是个中间值,直接优化掉了。-O0 那些 pushq、movq、popq 大部分跟运算无关,是为了让调试器看清每个变量。
你写的代码和实际执行的代码,不是一一对应的。 这就是最小例子。















































































