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 的输出,并能把报错信息映射回具体阶段。这正是面试官区分“背过八股”和“真正做过工程”的分水岭。