
平时编译 C 程序我们往往只写一条命令gcc hello.c-ohello几秒之后可执行文件就生成了。但这条命令背后到底发生了什么头文件去了哪里.o为什么不能直接运行printf的实现什么时候进入程序动态链接和静态链接又差在哪为了把这些问题串起来我用一个简单的hello.c做了完整实验分别保留预处理文件、汇编文件和目标文件再借助objdump、readelf、ldd观察符号、重定位和 ELF 布局。这套过程不只适用于本机 GCC。以后换成 ARM 交叉编译器编译链路仍然相同只是目标架构和使用的库发生了变化。一条命令背后的四个阶段从 C 源文件到可执行程序通常会经过四个阶段hello.c --预处理-- hello.i --编译-- hello.s --汇编-- hello.o --链接-- hello阶段GCC 参数输出主要工作预处理-Ehello.i展开头文件和宏处理条件编译编译-Shello.s语法、语义分析生成汇编代码汇编-chello.o生成机器码和可重定位 ELF链接无停止参数hello解析符号、重定位并生成可执行文件这里有个很容易写错的地方汇编后停止使用的是小写-c。大写-C是预处理选项通常和-E搭配用来保留注释。准备一段测试代码为了方便观察库函数符号测试程序保留了printf、atoi和exit等调用#includestdio.h#includestdlib.hintmain(intargc,char*argv[]){if(argc!2){printf(Usage: %s number\n,argv[0]);exit(EXIT_FAILURE);}printf(program: %s, input: %s\n,argv[0],argv[1]);intnumberatoi(argv[1]);switch(number){case0:printf(h\n);break;case1:printf(e\n);break;case2:printf(l\n);break;case3:printf(l\n);break;case4:printf(o\n);break;default:printf(hello\n);break;}returnEXIT_SUCCESS;}atoi()无法可靠判断转换失败这里只是为了观察编译和链接过程。正式项目更适合使用strtol()并检查非法字符、溢出和数值范围。把四个阶段分别执行gcc-Ehello.c-ohello.i gcc-S-g-O0hello.i-ohello.s gcc-chello.s-ohello.o gcc hello.o-ohello-g用来保留调试信息方便后面混合显示源代码和汇编-O0关闭优化让生成结果更接近源代码结构。第一站预处理文件为什么这么大gcc-Ehello.c-ohello.i预处理器主要完成下面几件事展开#include引入的头文件替换宏定义处理#if、#ifdef等条件编译默认删除注释插入行标记保留内容对应的源文件和行号例如#12hello.c它告诉后续编译阶段接下来的内容与hello.c第 12 行相关。实际行标记还可能包含其他标志用来表示进入或离开头文件等状态。比较文件大小和行数stathello.c hello.iwc-lhello.c hello.i实验中几十行的源文件经过预处理后增长到近两千行。真正让文件膨胀的不是业务代码而是stdio.h、stdlib.h以及它们递归包含的类型、声明和宏。这也是hello.i最实用的用途当宏展开或条件编译不符合预期时直接查看预处理结果比盯着源文件猜更有效。第二站C 代码如何变成汇编gcc-S-g-O0hello.i-ohello.s编译阶段会完成词法分析、语法分析、语义检查、中间表示生成以及目标代码生成。开启优化后编译器还会对中间表示进行优化。这一阶段输出的hello.s已经是面向目标 CPU 的汇编代码但还不是机器码。外部函数也不会简单地在这个阶段“复制进来”。对于printf、atoi这类普通外部调用编译器主要生成调用指令和符号引用真正的实现由链接阶段处理。想观察优化的影响可以生成两份汇编文件gcc-S-O0hello.c-ohello_O0.s gcc-S-O2hello.c-ohello_O2.sdiff-uhello_O0.s hello_O2.s有些系统上编译器甚至会把简单的printf(h\n)优化成puts(h)。因此符号表里出现puts并不一定意味着源代码直接调用了它。第三站hello.o为什么还不能运行gcc-chello.s-ohello.o汇编器会把汇编助记符编码成 CPU 机器指令同时生成符号表和重定位信息。这时的hello.o已经包含机器码但它属于“可重定位目标文件”还不能直接执行。原因主要有两个printf、atoi等外部符号的最终位置还没有确定文件还没有形成操作系统可直接装载的完整程序映像hello.o也不是一段没有结构的二进制数据而是一个 ELF 文件通常包含ELF 文件头.text代码节.rodata只读数据节.data和.bss数据节符号表重定位节使用-g时生成的调试信息这里需要区分两个概念section通常译为“节”主要服务于编译和链接segment通常译为“段”主要描述程序如何装载到内存目标文件主要关注节可执行文件被内核加载时程序头表中的段更关键。用objdump拆开目标文件先看反汇编objdump-dhello.o同时显示源代码和汇编objdump-Shello.oobjdump -S需要目标文件包含调试信息否则通常只能看到反汇编无法正确对应源代码。继续查看符号表和重定位objdump-thello.o objdump-rhello.o如果想看更完整的 ELF 元数据可以使用readelf-hhello.o readelf-Shello.o readelf-sWhello.o readelf-rWhello.o第四站链接器如何把零散内容拼成程序gcc hello.o-ohello链接阶段主要完成四件事符号解析把未定义符号和目标文件或库中的定义对应起来。合并输入文件中的同类节并确定整体布局。根据最终布局修正代码和数据里的地址引用。生成 ELF 头和程序头表让内核与动态加载器知道如何装载程序。使用gcc链接时输入不只有hello.o和 C 标准库。GCC 还会自动加入启动文件、运行时支持库和默认链接选项。想看它实际调用了哪些工具和文件可以执行gcc-vhello.o-ohello输出里常见的crt1.o、crti.o、crtn.o属于 C 运行时启动文件。它们负责在进入main前后完成必要的初始化和收尾工作。动态链接和静态链接体积差多少普通命令默认通常生成动态链接程序gcc hello.o-ohello ldd helloldd可以看到libc.so.6和动态加载器等依赖。括号中的十六进制数是本次检查时观察到的映射地址启用 ASLR 后可能在不同运行间变化。再生成一个静态链接版本gcc hello.o-static-ohello_staticls-lhhello hello_static ldd hello_static实验中的动态版本只有几 KB静态版本接近 1 MB体积差距非常明显。原因在于动态链接程序主要记录共享库依赖运行时再由加载器映射库静态链接会把需要的库代码复制进可执行文件不过静态链接不等于“复制到任何同架构 Linux 都能运行”。程序仍然受到 CPU 指令集、内核 ABI、系统调用、设备文件和运行配置等条件约束。符号表里那些0到底代表什么objdump-thello.o objdump-thello在hello.o中可以看到两类典型符号printf、exit、atoi等显示为*UND*说明它们是未定义外部符号值通常为 0main已经定义在.text中它的值表示相对于所在节起点的偏移因此不能简单地说“.o里的符号地址全是 0”。更准确的理解是可重定位文件中的已定义符号通常保存节内偏移未定义外部符号等待链接器解析链接完成后已定义符号才会获得最终链接布局中的虚拟地址使用下面的命令可以更清楚地查看符号类型、绑定方式和节索引readelf-sWhello.oobjdump -r没输出就没有重定位了吗objdump-rhello.o objdump-rhellohello.o中能看到针对外部函数和只读数据的重定位条目。链接完成后objdump -r hello可能没有输出这表示普通静态重定位已经被链接器处理不能据此断言可执行文件完全没有重定位。动态链接程序通常仍然包含 GOT、PLT 和共享库相关的动态重定位。应该继续检查readelf-rWhello objdump-Rhello现代 Linux 发行版还可能默认生成 PIE也就是位置无关可执行文件。它在装载时会根据实际基址完成相应的动态重定位filehello readelf-hhello|grepType不同 GCC 和发行版的默认设置可能不同因此实验结果不必和其他机器逐字一致重点是看清 ELF 类型和工具输出。readelf常用命令速查观察目标命令ELF 类型、架构和入口地址readelf -h hello节头表观察链接视图readelf -S hello程序头表观察装载视图readelf -l hello符号表readelf -sW hello重定位表readelf -rW hello动态段和共享库依赖readelf -d hello反汇编代码objdump -d hello.o动态重定位objdump -R hello动态库依赖ldd helloreadelf -h会输出整个 ELF 文件头其中包含入口地址而不是只显示入口地址。这次实验真正看懂了什么走完这套流程后gcc hello.c -o hello就不再是一条黑盒命令了hello.i告诉我们头文件和宏在预处理阶段被展开hello.s展示编译器为目标 CPU 生成的汇编代码hello.o已经包含机器码但仍保留未解析符号和重定位信息链接器负责解析符号、安排地址并生成可装载的 ELF动态链接和静态链接的差别不只是文件大小还涉及部署和运行环境objdump更适合观察指令readelf更适合查看 ELF 元数据这也是学习交叉编译前很值得做的一次实验。把gcc换成 ARM 交叉编译器后四个阶段没有变但生成的机器码、ELF 架构、动态加载器和链接库都会换成目标板对应的版本。以后再遇到undefined reference、Exec format error、动态库找不到或者程序明明存在却无法运行就可以先判断问题发生在编译、链接还是装载阶段而不是只盯着最后一条报错。