跳转到内容

从源码到可执行文件

执行 gcc hello.c -o hello 之后就能得到一个可以直接运行的程序,这个过程看起来是一步完成的,但实际上 gcc 只是一个统一的入口命令,背后依次调用了四个独立的工具阶段。理解这四个阶段,有助于看懂后续和编译报错、链接错误相关的问题具体出在哪一层。

  1. 预处理(Preprocessing):处理源代码中以 # 开头的预处理指令,例如把 #include 指定的头文件内容原样展开插入,把 #define 定义的宏替换为对应的值。这一步的输出仍然是文本形式的代码,只是已经不包含任何预处理指令
  2. 编译(Compilation):把预处理后的代码翻译成汇编语言(Assembly),这是一种和具体 CPU 架构强相关、但仍然是人类可读的中间表示
  3. 汇编(Assembly):把汇编代码翻译成目标文件(Object File,.o 后缀),内容是机器码,但还不能直接运行——其中对外部函数、外部变量的引用还只是占位符,没有被填上具体地址
  4. 链接(Linking):把一个或多个目标文件、以及它们依赖的库文件组合在一起,解析并填上所有跨文件的函数和变量引用的实际地址,最终生成一个可以直接运行的可执行文件
Terminal window
gcc -E hello.c -o hello.i # 只执行预处理,输出展开后的源码
gcc -S hello.i -o hello.s # 只执行编译,输出汇编代码
gcc -c hello.s -o hello.o # 只执行汇编,输出目标文件
gcc hello.o -o hello # 只执行链接,输出最终可执行文件

直接执行 gcc hello.c -o hello,等价于依次自动完成这四步,中间产物不会保留在磁盘上。分步执行主要用于排查具体是哪个阶段出了问题——例如“语法错误”通常发生在编译阶段,而“未定义的引用”(undefined reference)这类报错,则发生在最后的链接阶段,二者对应的修复思路完全不同。

#include 引入的头文件(.h 文件)里,通常只包含函数和变量的声明(Declaration,说明这个函数存在、参数和返回值类型是什么),而不包含具体实现(Definition,函数体内部真正执行的代码)。预处理阶段只是把这些声明原样插入源码,让编译器知道“这个函数确实存在,可以先接受对它的调用”;真正的实现代码在别的源文件里被单独编译成目标文件,二者要靠最后的链接阶段才能对应起来。

后续接触到的构建工具(如下一节要讲的 Make)、静态库与动态库的区别、跨平台交叉编译这些话题,本质上都是在这四个阶段的基础上做进一步的组织和优化。不理解这条基础流程,遇到相关问题时很容易只停留在“报错了、不知道从哪查起”的层面。

这个主题是构建可靠 Linux 开发流程的重要组成部分。对它有清晰的理解,会让后续任务变得更容易,因为你能更快地判断某一步是否缺失或使用不当。

  • 在安全的测试环境中尝试这里展示的命令和配置。
  • 比较这些概念在不同发行版或工具之间的适用差异。
  • 记下一次实践中哪些步骤成功,哪些失败,这样下次遇到问题时更容易定位。
  • 将这篇文章与系列中相关内容结合起来,加深整体理解。
  • 跳过验证步骤,默认系统已经配置正确。
  • 不对命令中的路径、软件包名称或工具版本进行适配,直接复制粘贴执行。
  • 把这个主题当成孤立的技巧,而不是整个开发流程的一部分。

一个常见的诊断思路是让编译器停在中间阶段,查看生成的结果。例如,如果在编译阶段看到语法错误,可以先运行 gcc -E hello.c -o hello.i,检查宏展开后的源码是否出现了意外的 token;如果是链接错误,则可以用 readelf -s hello.onm 检查目标文件是否包含预期的符号。

  • -Wall -Wextra 开启更多警告,提前发现潜在问题。
  • -g 保留调试信息,让运行时调试成为可能。
  • -O0 关闭优化,调试时代码行号和变量值更可预测。
  • -fverbose-asmgcc -S 一起使用时可以在汇编输出中显示注释。

把源码、汇编、目标文件和链接阶段的关系搞清楚,能让构建失败不再那么神秘。如果你能把错误信息对应到这四个阶段中的一个,就能选择正确的修复方式,而不是盲目猜测。