第 2 章 程序如何真正运行
从
.c到内存中奔跑的指令流,中间每一步都有产物、都有坑,也都是面试追问的落点。
学习目标
读完本章,你应当能够:
- 复述「预处理 → 编译 → 汇编 → 链接 → 加载」五阶段的输入、输出与典型报错,并说明链接错误与编译错误的本质区别。
- 区分 ELF 的段(segment)与节(section),用
readelf的三个视图读出同一文件的三种信息。 - 推导 PLT/GOT 在首次调用与后续调用中的指令流差异,解释延迟绑定为什么能省启动时间。
- 用
LD_PRELOAD在不改源码的前提下替换一个 libc 函数,并说明它被忽略的所有条件。 - 画出 x86-64 虚拟地址空间布局图(含增长方向),推导任意结构体的
sizeof,并解释 ASLR 能防住什么、防不住什么。 - 分解一次函数调用的指令开销,判断内联在什么情况下反而更慢。
前置知识检查
| 你需要知道 | 在哪一节补齐 |
|---|---|
| 寄存器、cache line、主存延迟量级 | 见第 1 章 1.1 节 |
| 分页、页表、缺页中断的完整机制 | 见第 8 章 8.2 节(本章只用到「加载后地址空间被分成 4 KB 页」这一结论) |
用户态/内核态与 execve;进程与 COW | 见第 9 章 9.3 节、第 4 章 4.2 节 |
2.1 从源码到进程:五阶段链路
面试里最常被问的一句是「gcc main.c 到底做了什么」。标准答案需要能说清五个阶段的产物形态,因为面试官接下来一定会挑一个阶段追问细节。
| 阶段 | 工具(gcc 内部) | 输入 | 输出 | 该阶段典型错误 |
|---|---|---|---|---|
| 预处理 | cpp | .c + #include 的头 | 纯 C 的 .i | 头文件找不到;宏展开成非预期文本后才报语法错 |
| 编译 | cc1 | .i | 汇编 .s | 语法/类型错误、未声明标识符 |
| 汇编 | as | .s | 可重定位目标文件 .o(ELF relocatable) | 汇编语法错、指令不支持 |
| 链接 | ld(gcc 驱动 collect2) | 多个 .o + .a/.so | 可执行文件(ELF executable)或 .so | undefined reference、multiple definition |
| 加载 | 内核 + ld.so | 可执行文件 | 进程的虚拟地址空间 | No such file、cannot open shared object |
链接与编译错误的本质区别:编译错误是「这个文件内部自相矛盾」,链接错误是「文件之间对不上账」。所以编译错误的修复范围通常在本文件,链接错误必须跨文件核对符号的定义与可见性。别忘了:链接器不检查逻辑,它只检查符号表能不能配平。
| |
gcc -E、-S、-c 可以分别停在三个阶段;readelf -h/-l/-S/-d 则分别看 ELF 头、段视图、节视图与动态段,这组开关是观察整条链路最省事的入口。
| |
.o 是可重定位目标文件:地址栏全是待填占位符,所以链接期只做两件事——按名字把符号配平、把占位符换成最终地址。
2.2 ELF 文件结构:段与节的双视图
同一个 ELF 文件,链接器按「节(section)」看,加载器按「段(segment)」看。两者的分工是:
- 节(section):给链接器用的逻辑分区,粒度细,带名字(
.text、.data、.debug_info),用于重定位、符号解析、调试。节头表(section header table)在运行时根本不需要。 - 段(segment):给内核/加载器用的物理分区,粒度粗,由程序头表(program header table)描述,属性只有三种:
R、W、X。内核按段一次性mmap出对应保护位。 - 段是节的「打包结果」:多个权限相同的节被合并成一个
PT_LOAD段。
| 常用节 | 内容 | 是否占文件空间 | 权限 | 归入的典型段 |
|---|---|---|---|---|
.text | 机器指令 | 是 | R+X | 代码段 |
.rodata | 字符串字面量、const 全局量 | 是 | R | 代码段(同页只读) |
.data | 已初始化的非零全局/静态变量 | 是 | R+W | 数据段 |
.bss | 未初始化或初始化为零的全局/静态变量 | 否(只记大小) | R+W | 数据段(内核给匿名零页) |
.dynsym / .dynstr | 动态符号表与名字串,供 ld.so 用 | 是 | R | 动态段 |
.rela.plt / .rela.dyn / .plt / .got | 重定位表、跳转桩、全局偏移表 | 是 | R(.got 运行时需可写) | 代码段/数据段 |
.bss 不占文件空间是必须记住的算例级结论:一个 100 MB 的零初始化全局数组让可执行文件几乎不增长,但进程一启动 RSS 就会涨——内核对 .bss 做的是「映射一段匿名零页」,写哪一页才分配哪一页的物理内存。
| |
2.3 静态链接与动态链接的取舍
两种链接方式回答的问题不同:静态链接在链接期就把符号地址钉死;动态链接只把「需要哪个库、哪个符号」记进 .dynamic 段,地址留到加载期甚至首次调用时才填。注意表中「多进程内存占用」一行:共享的只是只读代码段与 .rodata 的物理页,.data/.bss 每个进程仍各一份(写时复制,见第 4 章 4.2 节),所以动态链接省的是物理内存而不是虚拟内存。
| 维度 | 静态链接(.a) | 动态链接(.so) |
|---|---|---|
| 符号地址确定时机 | 链接期(绝对或相对地址写死) | 加载期 + 首次调用(重定位/延迟绑定) |
| 可执行文件体积 | 大(每个用到的函数都复制进来) | 小(只留桩与符号名) |
| 多进程内存占用 | N 个进程 = N 份代码页 | 只读代码段物理页共享,N 个进程 1 份 |
| 库升级与部署 | 单文件无依赖地狱,但升级必须重新链接 | 需 LD_LIBRARY_PATH/ldconfig/镜像配合,但 ABI 兼容时换库即生效(libc 漏洞修复不必重编译) |
| 启动速度 | 快(无解析、无重定位) | 慢(ld.so 解析依赖 + 重定位),可被延迟绑定 + prelink/-z now 抵消 |
| 运行时调用开销 | 0(直接 call) | 首次调用贵;后续多一次 jmp [GOT] 间接跳转 |
2.3.1 PLT/GOT 与延迟绑定
位置无关代码(position-independent code, PIC)的核心矛盾是:代码要能映射到任意基址就不能含绝对地址,但访问外部函数/全局变量又必须知道地址。解法是「代码不存地址,而是去一张表中查地址」——那张表就是全局偏移表(global offset table, GOT),PLT 则是「查表 + 跳转」的跳转桩。
| |
延迟绑定为什么值得:libc 导出上千个符号,一个程序平均只用几十个。若加载期全部解析,每次启动都要做上千次符号查找与重定位;延迟绑定把绝大部分成本推给了「永远不执行的代码路径」。
警告
典型错误回答:「PLT 是程序的跳转表,GOT 存的是函数地址」。这只说对一半。必须点出初始状态下 GOT 存的是 PLT 内部地址而不是真实函数地址,以及 _dl_runtime_resolve 的**回填(写 GOT)**动作——这也正是 GOT 必须可写、从而成为攻击面(GOT 覆写劫持)的原因。
2.4 动态链接器 ld.so 与 LD_PRELOAD
动态链接器(PT_INTERP 段指定的 /lib64/ld-linux-x86-64.so.2)在内核映完可执行文件之后、main 之前运行:读 .dynamic 收集 NEEDED 依赖并递归装载、按重定位表填 GOT、调用各库的 init_array,最后把控制权交给 _start。注意入口点是 _start 而非 main——_start 备好 argc/argv/envp 后调用 __libc_start_main,由后者再调 main,并在 main 返回后走 exit。
LD_PRELOAD 的机制非常朴素:它把指定的共享库插到符号搜索顺序的最前面,于是同名符号被「劫持」——因为动态链接器在解析符号时,谁先出现用谁。
| 搜索顺序(从先到后) | 来源 | 可控性 |
|---|---|---|
| 1 | LD_PRELOAD 指定的库 | 环境变量,仅非 setuid 程序生效 |
| 2 | 可执行文件自身的 DT_RPATH(旧机制,默认先于 LD_LIBRARY_PATH) | 链接时 -rpath 写进 ELF |
| 3 | LD_LIBRARY_PATH | 环境变量,仅非 setuid 生效 |
| 4 | DT_RUNPATH(新机制,默认后于 LD_LIBRARY_PATH) | 链接时 -Wl,-rpath |
| 5 | /etc/ld.so.cache(由 ldconfig 生成) | 系统库主路径 |
| 6 | /lib、/usr/lib(及架构目录) | 兜底 |
| |
编译与使用:gcc -shared -fPIC -o mymalloc.so mymalloc.c -ldl,然后 LD_PRELOAD=./mymalloc.so ./app。
编译期还有一批「同类不同层」的手段,面试常用来做区分题:
| 手段 | 生效阶段 | 能否拦截直接调用 | 典型用途 |
|---|---|---|---|
LD_PRELOAD | 加载/运行时 | 是(通过 PLT 的调用) | 函数打桩、故障注入、内存检测 |
--wrap(-Wl,--wrap=malloc) | 链接期 | 是,且改写符号引用 | 单元测试替换依赖 |
宏 / #define | 预处理期 | 只对本文件 | 调试开关 |
ptrace / eBPF uprobe | 运行时插桩 | 可拦不可静态解析的调用 | 无侵入观测 |
提示
记忆锚点:LD_PRELOAD 能生效的前提是调用走 PLT。若函数被内联、或用 -fno-plt/-static 链接、或是程序内部的 static 函数、或直接发 syscall() 绕过 libc 包装,它都拦不住。
2.5 加载后的虚拟地址空间布局与 ASLR
| |
几个必须能算的细节:栈默认 8 MB(ulimit -s 默认 8192 KB),溢出护栏外即 SIGSEGV;malloc 分界线约 128 KB(glibc M_MMAP_THRESHOLD),小于它走 brk/堆管理、大于它直接 mmap,所以大块释放能立刻归还内核而小块释放通常留在堆里。三者方向相反是为了每段只需一个单向指针(rsp、mmap_base、program break)就能管理,并天然把冲突概率降到最低。
地址空间布局随机化(address space layout randomization, ASLR)随机化的对象不止栈:
| 随机化对象 | 随机粒度 | 关闭后现象 |
|---|---|---|
| 栈基址 | 页(4 KB)对齐,随机位数约 22 位 | 每次运行 &local 完全相同 |
| mmap 基址(含共享库) | 页对齐,约 28 位随机 | libc 基址固定 |
| 可执行文件自身 | 仅 PIE(-pie -fPIE)生效,否则固定在 0x400000 | 代码地址稳定可预测 |
随机化位数决定绕过成本:2^28 ≈ 2.7 × 10⁸ 的猜测空间配合「猜错就崩溃」的约束,使盲打不现实;但一旦通过信息泄露拿到一个模块基址,同模块内所有偏移都是固定的——这就是「ASLR 只能提高门槛、不能修复漏洞」的准确表述。两个高频答崩点:把 ASLR 说成「随机化了地址空间的内容」(它只随机化各段基址),以及把它与「栈不可执行(NX)」混为一谈(前者防「跳到已知地址」,后者防「跳到栈上自己写的代码」,现代利用需同时绕过两者,ROP 就是典型组合技)。
2.6 栈帧与调用约定
x86-64 System V ABI(Linux、macOS、BSD 使用;Windows x64 是另一套)规定参数传递与寄存器保存责任:
| 用途 | 寄存器 | 保存责任 |
|---|---|---|
| 第 1~6 个整型/指针参数 | rdi、rsi、rdx、rcx、r8、r9 | 调用者保存(caller-saved) |
| 第 7 个及以后参数 | 通过栈传递,从右向左压栈 | 调用者 |
| 浮点参数 | xmm0~xmm7 | 调用者保存 |
| 返回值 | rax(整型)、rdx:rax(128 位)、xmm0(浮点) | — |
| 栈指针 / 帧指针 | rsp / rbp | rsp 由 ABI 强制维护 |
| 被调用者必须原样恢复 | rbp、rbx、r12~r15 | 被调用者保存(callee-saved) |
警告
反直觉处:rbx/r12~r15 想用就必须先 push 再 pop,而「保存责任」决定了哪些寄存器可以放心在函数内改,这也是手写汇编最容易出错的地方。
| |
缓冲区溢出的经典利用路径就写在这张图里:局部数组在 rbp-16 这类低地址处,向高地址方向(越界写)连续覆盖时,先越过其他局部变量,再覆盖 saved rbp,最后命中 rbp+8 的返回地址。于是 ret 弹出的是攻击者写入的地址,控制流被劫持。
三道防线分别打断这条链的不同环节:
| 防线 | 打断哪一步 | 被绕过的条件 |
|---|---|---|
栈保护 -fstack-protector-strong(canary) | 在返回地址前放哨兵值,覆盖它即在 ret 前 __stack_chk_fail 终止 | 泄露 canary,或利用不经过该函数的写原语 |
NX / -z noexecstack | ret 跳过去后栈不可执行,注入的 shellcode 变数据 | 转向已有的可执行片段(ROP/JOP) |
| ASLR / PIE | 使「跳到哪」不可预测 | 信息泄露 + 相对偏移固定 |
影子栈(-fsanitize=shadow-call-stack、Intel CET) | 返回地址另存一份,ret 时校验 | 目前最难绕过的一类 |
2.7 大小端:判定、网络字节序与踩坑
端序(endianness)只影响多字节标量的字节排列,单字节与字节数组不受影响。判定方法只需一次类型双关:
| |
x86 与 ARM 小端(ARM 可切换为双端 bi-endian),PowerPC 历史上常用大端,网络协议统一规定大端。为什么网络字节序 = 大端?两个理由:一是 RFC 1700 等规范成型时,多数参与制定标准的机器(如 PDP-10、早期 MIPS 工作站)是大端,规定大端能避免多数设备做转换;二是大端在抓包与规范书写上更可读——字段按字节顺序读出来就是数值本身,写规范和读实现都不易出错(RFC 793 中 TCP 头的多字节字段均明确按网络字节序)。这是「规范选择」而非「工程必然」:它不改变正确性,只改变转换发生在谁那里。
| 踩坑场景 | 现象 | 正解 |
|---|---|---|
| socket 收发端口/长度 | 小端机器上端口号变成 0x1F90 → 0x901F | htons/htonl/ntohs/ntohl 成对使用 |
直接 write 结构体或手搓 memcpy | 跨架构读回后字段全错,x86 通过而 ARM 大端失败 | 逐字段序列化并统一转大端,或用 ntohl 后再解析 |
| 位域 / 哈希按整数读 | 位序与端序相关且布局未标准化;不同架构摘要不同 | 位域改显式掩码与移位;摘要按规范声明的字序实现 |
Java DataOutputStream 与 C 互操作 | Java 规定大端,C 侧默认小端 | 两侧显式转换,别依赖默认 |
提示
一句话记忆:网络是大端,x86 是小端,Java 的 DataInput/OutputStream 也是大端(JLS 未见规定,但 java.io 文档明确写出),所以「Java 写、C 读」是最常见的转换漏点。
2.8 结构体内存对齐与填充
对齐规则只有两条,但足以推出所有 sizeof:成员偏移必须是 min(成员自身对齐, 编译指示对齐) 的整数倍,不满足则在成员前面插入填充;结构体总大小必须是「最大成员对齐」的整数倍,不满足则在尾部插入填充(保证数组场景下每个元素都对齐)。
| |
struct A 与 struct B 字段完全相同、只换了顺序,体积从 12 降到 8——成员排序就是零成本的优化,把大对齐类型排在前面、同类型聚在一起通常就能消掉大部分填充。
| 类型(x86-64 System V) | 大小 | 对齐 |
|---|---|---|
char / _Bool / short | 1 / 1 / 2 | 1 / 1 / 2 |
int / float | 4 | 4 |
long / double / 指针 | 8 | 8 |
long double / __int128 | 16 | 16 |
为什么对齐能提升性能:CPU 访存通路按「字/行」组织。一次对齐的 8 字节访问恰好落在一个 cache line 内,只需一次总线事务;跨行访问要拆成两次并触发两次 cache 查找;某些架构(SPARC、部分 ARM 模式)对未对齐访问直接抛异常(SIGBUS),由内核软件模拟完成,代价是几十到上百周期。此外原子指令(lock cmpxchg、std::atomic)要求对齐,未对齐的原子操作在 x86 上不再是原子的。
__attribute__((packed)) 的代价必须说清:
| 收益 | 代价 |
|---|---|
| 序列化协议/硬件寄存器布局能一字节不差地对齐规范 | 成员可能未对齐:x86 上生成额外指令拆访问,ARM 上可能 SIGBUS |
| 显著省内存(网络包头、内核结构) | 不能对该结构成员取地址交给需要对齐的代码(&s.b 传给 long* 是 UB) |
减少一次字段搬运的 memcpy,但编译器无法再用向量指令批量访问 |
警告
常见错误回答:「packed 只是让结构体更紧凑」。真正的坑是它把「未对齐访问」这件 UB 事显式引入代码:在 ARM 上取 packed 成员的地址再解引用可能直接 SIGBUS。回答时必须带上代价。
2.9 函数调用开销的分解与内联的取舍
一次函数调用的开销可以拆成可列举的几项,这也是「为什么热点函数要内联」的完整论证:
| 开销项 | 具体内容 | 量级 |
|---|---|---|
| 传参 | 前 6 个整型参数 mov 进寄存器;超过 6 个要写栈 | 数个周期 |
| 控制转移 | call 压返回地址 + 跳转;返回时 ret(现代 CPU 有返回地址栈预测) | 1~3 周期 |
| 寄存器保存/恢复 | 用到的 callee-saved 寄存器 push/pop | 每对约 1 周期 |
| 栈帧建立 | push rbp; mov rbp,rsp; sub rsp,N(-O2 常省略 rbp,用 rsp 直接寻址) | 2~3 周期 |
尾部的 leave/ret | 与上面对称 | 2~3 周期 |
| 间接调用(虚函数/函数指针) | 无法静态解析目标,依赖间接分支预测(BTB) | 预测命中时约 1 周期,预测失败约 5 ns |
| 优化被阻断 | 调用是编译器的优化屏障:常量无法跨调用传播、无法做死代码消除 | 往往是真正的大头 |
所以「内联的收益」很少来自省掉的 call/ret(合计约 1% 量级的收益),而是来自跨函数优化被打开:常量折叠、把参数变成编译期已知、把返回值的分支一并优化掉、为调用点特化生成无分支代码。典型例子是 qsort 的比较函数——函数指针导致每次比较都间接调用且无法内联,这正是在性能敏感路径上改用「特化排序 + 模板/泛型」的动力。
内联的代价同样要能说清:代码膨胀会挤占指令缓存(L1i 典型 32 KB),并在指令 TLB 上产生压力;同时放大编译时间与调试难度(栈回溯断链、gdb 里看不到内联前的帧)。现代编译器的内联阈值是启发式的(函数体大小、调用点热度、-O2/-O3、inline 关键字只是提示),并非「写了 inline 就一定内联」。
| |
本章小结
| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
| 五阶段链路与双视图 | 产物依次是 .i/.s/.o/ELF/地址空间;节给链接器、段给加载器(只有 R/W/X) | 把链接错误当编译错误找本地文件 |
.bss | 不占文件空间,加载时映射匿名零页,物理页按需分配;packed 则相反地引入未对齐访问 | 只答"未初始化的变量"、或以为 packed 无代价 |
| PLT/GOT | GOT 初始存 PLT 桩地址,_dl_runtime_resolve 首次调用后回填真实地址 | 答"GOT 存函数地址" |
LD_PRELOAD | 插到符号搜索最前,靠 PLT 劫持;setuid 程序忽略 | 忘了"内联/静态链接/直接 syscall 拦不住" |
| 地址空间与 ASLR | 栈与 mmap 向下、堆向上;ASLR 只随机各段基址 | 说"随机化了内容"或"栈向高地址增长" |
| 调用约定 | 6 个整型参数走寄存器,rbx/r12~r15 由被调用者保存 | 记混 caller/callee-saved |
| 栈溢出 | 越界写命中 rbp+8 的返回地址;canary/NX/ASLR/影子栈各打断一环 | 把 ASLR 当"修复漏洞" |
对齐与 packed | 偏移按 min(成员对齐, 编译指示) 对齐,总大小按最大对齐补齐 | 以为 sizeof 等于字段之和 |
| 内联收益 | 主要来自跨函数优化,不是省 call/ret;代价是 icache 压力 | 答"省了函数调用所以快一倍" |
自测题
Q1(计算)在 x86-64 上,struct S { char a; double b; char c; int d; }; 的 sizeof(struct S) 是多少?把成员重排后最小能到多少?
答案与解析
原顺序:a 在 0;b 对齐 8,故从偏移 8 开始(1~7 是填充);c 在 16;d 对齐 4,从 20 开始,共到 24;最大对齐是 8 且 24 已对齐,sizeof = 24。重排为 { double b; int d; char a; char c; } 后:b 在 0、d 在 8、a 在 12、c 在 13,共 14 字节,向上补到 8 的倍数,最小 16。
错在哪:忘记尾部填充,或以为「把 c 挪到 d 后面就够」。面试官会追问 32 位 x86 上 double 对齐是 4(System V i386 ABI),以及「要 memcpy 到网络的报文头时你怎么设计」——答案是按对齐声明字段 + 显式序列化,而不是用 packed 硬压。
Q2(推导)延迟绑定到底省了什么?请说明 printf 第一次调用与第二次调用在指令层面差多少,以及 -z now 换来什么?
答案与解析
第一次:call printf@plt → jmp *GOT[printf] 落到 PLT 内部桩 → push $index; jmp .plt[0] → _dl_runtime_resolve 在 .dynsym/.dynstr 里按名字查找、算出真实地址、回填 GOT[printf]、再跳进 libc。第二次:call printf@plt → jmp *GOT[printf] 直接进 libc,只多一次间接跳转。省的是加载期的符号解析总量:libc 导出上千符号而程序通常只用几十个,未使用符号的解析成本被彻底消除。
-z now(配合 -z relro 即 full RELRO)在加载时一次解析全部符号并填好 GOT,再把 GOT 页设为只读:收益是启动后无运行时解析、且免疫 GOT 覆写劫持;代价是启动变慢,对长跑服务通常划算。错在哪:只答「第一次慢第二次快」而不说回填动作,或以为 -z now 会「禁用动态链接」。追问方向:「为什么 GOT 必须可写?」——延迟绑定要回填,这正是它成为攻击面的原因。
Q3(设计)线上服务只能改环境变量、不能改代码,你要统计某个进程里所有 malloc 的调用次数和大小分布。怎么做,有哪些失效情形?
答案与解析
写一个 malloc 拦截库:内部用 dlsym(RTLD_NEXT, "malloc") 取真实实现并转发,再用 LD_PRELOAD 挂上去。记录必须用异步信号安全的 write 或线程本地缓冲,否则 printf 自身会再触发 malloc 造成递归。失效情形(答出这些才是加分项):调用被内联或用 -fno-plt 编译;目标静态链接(-static)或被 -Bsymbolic 绑定;程序直接发 syscall(SYS_brk/mmap) 绕过 libc;目标是 setuid/setgid 进程(ld.so 忽略 LD_PRELOAD 以防提权);多线程下必须用线程本地状态,否则要引入锁与竞态。
Q4(判断)「.bss 段不占文件空间,所以一个 100 MB 的零初始化全局数组几乎不增加可执行文件大小,进程启动后也不占用物理内存。」这句话对吗?
答案与解析
前半句对,后半句错。.bss 在文件里只记录「大小」这一项,所以 ELF 文件大小几乎不变(避免用零填充文件正是 ELF 的设计取舍)。但进程启动后内核给 .bss 映射一段匿名零页:虚拟地址空间立刻按 100 MB 记账,物理内存按需分配,只有真正写到某页才分配该页物理页(只读零页被所有进程共享)。所以「RSS 不涨」只在从不访问这段内存时成立;一旦 memset 全写,100 MB 实打实被占用。
错在哪:混淆「映射」与「分配物理页」,或以为 .bss 会懒加载到「完全不占虚拟地址空间」。追问方向:「.bss 与 malloc 大块内存谁更快?」——都靠按需分页,.bss 省掉一次 mmap 与 brk 管理,但同样有首次写入的缺页中断(见第 8 章 8.3 节)。
Q5(推导)一个热点小函数被强制内联后,程序反而变慢。请给出至少三条可能的机制解释。
答案与解析
一是指令缓存压力:内联把同一份指令复制到多个调用点,热点循环的代码体积超出 L1i(典型 32 KB)或 iTLB 覆盖范围,取指命中率下降,收益被抵消。二是分支预测劣化:被内联的函数原本有稳定的 call/ret 模式,由返回地址栈(RSB)廉价预测;内联后代码膨胀改变了基本块的位置与对齐,间接跳转与循环分支的预测可能变差。三是寄存器压力上升:函数体在多个上下文重复,活跃变量变多导致 spill(溢出到栈)与访存增多;内联进冷路径后,膨胀还会影响外层循环的展开决策。
错在哪:只答「内联会增加代码体积,所以有代价」而不说清代价通过哪条硬件机制转化为变慢。面试官会追问「你怎么验证」——在完整负载下用 perf stat -e L1-icache-load-misses,branch-misses 对比前后,并用 -fno-inline-functions 做对照,而不是用单点微基准外推。
面试高频问题与陷阱
| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应本章小节 |
|---|---|---|---|
gcc main.c 这条命令背后依次发生了什么?每一步的产物是什么? | 工具链全链路 | 答不完整或只说「编译成可执行文件」;加分点是能说出 .o 里已经没有「函数名调用」只有「符号引用 + 重定位项」 | 2.1 |
为什么 .bss 段不占可执行文件空间,但进程启动后内存却会涨? | ELF 与加载 | 答「因为未初始化」就停了;必须讲到匿名零页映射 + 按需分页 + 只读零页共享 | 2.2 |
| 动态链接比静态链接快还是慢? | 链接取舍 | 答「动态链接快因为共享库」混淆了体积与启动速度;正确说法是运行时省内存、启动更慢、后续调用多一次间接跳转 | 2.3 |
延迟绑定是怎么实现的?第一次调用 printf 时发生了什么? | PLT/GOT | 答「GOT 里存函数地址」是最典型的错误,初始值其实是 PLT 桩地址,靠 _dl_runtime_resolve 回填 | 2.3.1 |
| 不改任何一行代码,怎么替换一个库函数?有哪些情况会失败? | 动态链接器与插桩 | 只说 LD_PRELOAD 不提 RTLD_NEXT(会递归死循环)、不提 setuid 被忽略、不提内联与静态链接拦不住 | 2.4 |
| ASLR 是干什么的?它能防住栈溢出吗? | 安全机制边界 | 答「随机化内存布局所以防住了」是错的(只能提高门槛);要区分 ASLR 与 NX、canary 各自打断的环节,并说清「一个基址泄露即全失效」 | 2.5、2.6 |
| 函数参数是怎么传递的?哪些寄存器由被调用者负责保存? | 调用约定 | 记不住 rdi/rsi/rdx/rcx/r8/r9 顺序,或把 rbx/r12~r15 说成调用者保存 | 2.6 |
| 栈溢出是怎么劫持控制流的?现在为什么很难利用了? | 安全 + 栈帧 | 只说「覆盖返回地址」;加分点是给出 rbp+8 的位置推导,并按 canary/NX/ASLR/PIE 分别说明各自打断哪一步 | 2.6 |
| 网络字节序为什么规定为大端? | 端序 | 答「大端更快」没有依据;正确角度是规范成型时的历史选择 + 大端在抓包与规范书写上的可读性,并强调转换发生在收发边界 | 2.7 |
这个结构体的 sizeof 是多少?为什么不是字段大小之和? | 内存对齐 | 只背对齐规则不会算;要能现场列出每个成员的偏移、算出填充、并验证总大小是最大对齐的倍数 | 2.8 |
__attribute__((packed)) 有什么风险? | 对齐与 UB | 答「更省内存」;必须补上未对齐访问的性能损失、ARM 上的 SIGBUS、以及取成员地址传出去是 UB | 2.8 |
| 内联函数为什么能提升性能?什么情况下反而更慢? | 编译优化 | 答「省了函数调用开销」是把小头当大头(真正收益是跨函数优化);必须能给出 icache 与寄存器压力导致的劣化 | 2.9 |
延伸阅读
- System V Application Binary Interface, AMD64 Architecture Processor Supplement(第 3 章「Low Level System Information」,参数传递与栈帧的规范原文)。
- Tool Interface Standard (TIS) Executable and Linking Format (ELF) Specification, Version 1.2(段与节的权威定义,
PT_LOAD/p_flags)。 man 5 elf、man 8 ld.so、man 1 ldconfig、man 1 readelf(Linux man-pages,符号搜索顺序与LD_PRELOAD的官方描述)。- Ulrich Drepper, How To Write Shared Libraries(PIC、GOT/PLT、符号介入与重定位开销的经典长文)。
- John R. Levine, Linkers and Loaders, Morgan Kaufmann(第 5~6 章讲重定位与符号解析,第 8 章讲共享库)。
- Randal E. Bryant & David R. O’Hallaron, Computer Systems: A Programmer’s Perspective(第 3 版第 7 章「Linking」、第 3 章「Machine-Level Representation of Programs」);RFC 793(TCP 多字节字段均按网络字节序定义)。