夜雨聆风学习资料网

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.c  825 add.i  837 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   %rbp    movq    %rsp, %rbp    movl    %edi, -20(%rbp)     ; a 存入栈    movl    %esi, -24(%rbp)     ; b 存入栈    movl    -20(%rbp), %edx     ; edx = a    movl    -24(%rbp), %eax     ; eax = b    addl    %edx, %eax          ; eax = a + b    movl    %eax, -4(%rbp)      ; ret = a + b    movl    -4(%rbp), %eax      ; return ret    popq    %rbp    ret

一个 + 号变成八九行。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
C 源码 → 展开的 C 源码
文本替换
编译
gcc -S
C 源码 → 汇编
翻译 + 优化
汇编
gcc -c
汇编 → 目标文件
逐条翻译
链接
gcc
目标文件 + 库 → 可执行文件
拼接 + 填地址

日常敲的 gcc add.c -o add 是一口气跑完这四步。拆开只是为了看见中间发生了什么。

每一步都是一次信息损失。 你写的 int、a、ret、add 这些名字,到机器码那一层基本都不存在了。

机器码到底长什么样

前面一直说"机器码",但还没看过。反汇编 add.o:

objdump -d add.o
0000000000000000 <add>:   0:   f3 0f 1e fa             endbr64   4:   55                      push   %rbp   5:   48 89 e5                mov    %rsp,%rbp   8:   89 7d ec                mov    %edi,-0x14(%rbp)   b:   89 75 e8                mov    %esi,-0x18(%rbp)   e:   8b 55 ec                mov    -0x14(%rbp),%edx  11:   8b 45 e8                mov    -0x18(%rbp),%eax  14:   01 d0                   add    %edx,%eax  16:   89 45 fc                mov    %eax,-0x4(%rbp)  19:   8b 45 fc                mov    -0x4(%rbp),%eax  1c:   5d                      pop    %rbp  1d:   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             endbr64    116b:       55                      push   %rbp    116c:       48 89 e5                mov    %rsp,%rbp    116f:       48 83 ec 10             sub    $0x10,%rsp    1173:       be 02 00 00 00          mov    $0x2,%esi    1178:       bf 01 00 00 00          mov    $0x1,%edi    117d:       e8 c7 ff ff ff          call   1149 <add>    1182:       89 45 fc                mov    %eax,-0x4(%rbp)    1185:       8b 45 fc                mov    -0x4(%rbp),%eax    1188:       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 大部分跟运算无关,是为了让调试器看清每个变量。

你写的代码和实际执行的代码,不是一一对应的。 这就是最小例子。

相关学习资料