2. 从源码到可执行文件

面试官问“一个 .cpp 文件怎么变成可执行程序”,真正想听的往往不是四阶段的名字,而是你对每个阶段“输入什么、输出什么、出错时表现成什么样”的掌握程度。下面按预处理、编译、汇编、链接的顺序展开,并在每个阶段挂上高频追问。

一、预处理:文本层面的替换与展开

预处理阶段处理的是以 # 开头的指令,输入是源文件与头文件,输出是单个“翻译单元”的纯文本。它不理解类型、作用域,只做文本操作。核心动作有四类:宏展开、文件包含、条件编译、删除注释。

// 查看预处理结果(gcc/clang 通用)
g++ -E main.cpp -o main.i
// 只做宏展开、不展开 include,便于定位宏问题
g++ -E -P main.cpp

高频追问一:宏与 inline 函数、const 的区别?宏是文本替换,没有类型检查,参数会被多次求值。例如 #define SQUARE(x) ((x)*(x)),调用 SQUARE(i++) 会自增两次。inline 函数有类型检查、有作用域,调试信息完整。面试中常被要求写出“安全的宏”,要点是:参数和整体都加括号、避免副作用、必要时用 do{...}while(0) 包裹多语句。

高频追问二:头文件为什么需要 include guard 或 #pragma once?因为 #include 是文本插入,同一头文件被重复包含会导致重复定义。include guard 靠宏名唯一性保证只展开一次;#pragma once 由编译器按文件路径去重,写法更简洁但非标准。

高频追问三:头文件里能不能定义变量或函数?能定义 inline 函数、类定义、模板、const 常量;不能定义普通全局变量或非 inline 函数,否则多个翻译单元链接时会报“multiple definition”。这正是“声明放头文件、定义放源文件”原则的由来。

二、编译:从翻译单元到汇编

编译阶段对预处理后的 .i 文件做词法、语法、语义分析,生成中间表示并优化,最终输出汇编代码 .s。这一步是类型检查、重载解析、模板实例化的主战场。

g++ -S main.i -o main.s      # 生成汇编
g++ -S -O2 main.cpp -o main.s # 带优化

高频追问:编译期能发现哪些错误?语法错误、类型不匹配、未声明标识符、模板实例化失败、访问权限违规。而“未定义引用”属于链接期错误,这个边界要分清——面试中常用来区分候选人是否真正理解阶段划分。

另一个常考点是符号表。编译后每个目标文件都带一张符号表,记录本文件定义和引用的符号。可以用 nm 查看:

g++ -c main.cpp -o main.o
nm main.o
# 输出示例(T=已定义代码段,U=未定义引用,D/B=数据段)
# 0000000000000000 T main
#                  U _Z3addii

其中 U 表示该符号在本文件被引用但未定义,需要链接器去别处找;T 表示已定义。C++ 为了支持重载,会对函数名做名字修饰(name mangling),add(int,int) 变成 _Z3addii。这也解释了为什么 C 与 C++ 混合编程要用 extern "C"——它关闭名字修饰,让链接器能按 C 的符号名找到函数。

三、汇编:汇编代码到机器码

汇编器把 .s 翻译成机器指令,输出可重定位目标文件 .o。此时文件里已经是二进制机器码,但地址尚未最终确定,因此保留了重定位信息。

g++ -c main.s -o main.o
objdump -d main.o   # 反汇编查看机器码

高频追问:什么是重定位?目标文件里对“外部符号”或“跨段符号”的引用,地址在汇编时无法确定,只能先留占位并记录一条重定位条目,说明“这个位置需要填入某符号的最终地址”。链接器在合并所有段、确定各符号最终地址后,回填这些位置。用 readelf -r main.o 可以看到重定位表,例如 R_X86_64_PC32、R_X86_64_PLT32 等类型。

面试中常被追问:为什么局部变量不需要重定位?因为局部变量在栈上,通过栈帧指针加偏移访问,偏移在编译期就确定,与最终加载地址无关。需要重定位的是全局变量、函数调用、静态数据等跨模块引用。

四、链接:符号解析与地址分配

链接器把多个 .o 和库合并成一个可执行文件,做两件事:符号解析(每个未定义符号找到唯一确定义)和重定位(回填地址)。

g++ main.o add.o -o app
ldd app        # 查看依赖的动态库
readelf -d app # 查看动态段信息

高频追问一:静态库与动态库的区别?静态库(.a)在链接时把用到的目标文件整体复制进可执行文件,程序体积大、启动快、部署独立;动态库(.so)在链接时只记录依赖,运行时由动态链接器加载,多程序共享同一份代码、便于升级,但存在版本兼容问题。

高频追问二:静态库链接顺序为什么重要?链接器从左到右扫描,遇到静态库时只提取“当前未定义符号”对应的成员。若库 A 依赖库 B,A 必须写在 B 前面,否则 B 中的符号在扫描 B 时还没被引用,会被丢弃,最终报未定义引用。这是实际工程中非常常见的坑。

高频追问三:动态库的符号什么时候解析?默认是延迟绑定(lazy binding),函数第一次调用时才通过 PLT/GOT 解析真实地址,可用 LD_BIND_NOW=1 强制立即绑定。这也解释了为什么动态库缺失时,程序可能启动正常、调用到某函数才崩溃。

五、把四阶段串成一条排查链

面试中更高级的追问是“报错时你怎么定位”。可以按阶段建立判断依据:

报错信息所属阶段典型原因
error: 'xxx' was not declared编译缺头文件、拼写错误、作用域问题
error: redefinition of 'xxx'编译/链接头文件缺 include guard、头文件里定义了变量
undefined reference to 'xxx'链接未链接目标文件/库、库顺序错误、extern "C" 缺失
multiple definition of 'xxx'链接全局变量或非 inline 函数在头文件中定义
cannot open shared object file运行动态库路径未配置、LD_LIBRARY_PATH 缺失

把这张表记牢,面试时遇到“编译过了但链接失败”“链接过了但运行找不到库”这类问题,就能迅速定位到具体阶段,而不是笼统地说“环境问题”。

六、可直接使用的排查清单

下面这份清单可在实际调试时逐条核对,覆盖从源码到可执行文件的关键检查点:

  • 宏展开异常:用 g++ -E -P 查看展开结果,确认参数是否被多次求值。
  • 头文件重复包含:检查是否有 include guard 或 #pragma once。
  • 符号未定义:用 nm 确认符号是 U 还是 T,U 说明需要外部提供。
  • 名字修饰问题:C/C++ 混编时确认接口是否用 extern "C" 包裹。
  • 重定位异常:用 readelf -r 查看重定位条目,确认目标符号是否存在。
  • 静态库顺序:被依赖的库放在依赖者之后。
  • 动态库加载失败:用 ldd 检查依赖,必要时设置 LD_LIBRARY_PATH 或写入 rpath。

掌握这四个阶段的关键,不在于背下流程名称,而在于能说清每一步的输入输出、能读懂 nm、readelf、ldd 的输出,并能把报错信息映射回具体阶段。这正是面试官区分“背过八股”和“真正做过工程”的分水岭。