2.3 从源码到可执行:汇编与链接全程


2.3 从源码到可执行:汇编与链接全程

本节摘要:源码变成可执行文件要过两道工序:汇编器把助记符翻成机器码,但把"跨文件引用"留成待补的重定位记录;链接器合并各文件的段、解析符号、按最终地址回填所有空缺。本节用两个源文件的迷你工程逐级展示中间产物——重定位表长什么样、回填前后机器码差在哪,并对比静态与动态链接的取舍。读完你能解释"编译能过、链接报错"和"链接能过、运行崩溃"分别卡在哪道工序。

从源码到可执行文件的路上发生了什么

先立一个贯穿本节的问题:两个源文件,A 调用 B 里的函数,汇编 A 的时候 B 还没参与进来,A 里的 call 指令该填什么地址?答案是"先填零,记账,事后补"。这笔账叫重定位表,事后补的工序叫链接。理解了"先记账后付款",整条构建管线就没有黑箱了:

管线上有个值得先交代的岔口:链接可以发生在程序交付前(静态链接,全部回填进可执行文件),也可以推迟到每次运行时(动态链接,只留"欠条",由装载器配合共享库现补)。本节主走静态路线把机制看透,岔路的取舍放在本节末尾对比。

一、制造一笔"待补账款"

造一个最小的双文件工程。util.asm 提供一个翻倍函数,main.asm 调用它并以返回值作为退出码:

; ---- util.asm:函数提供方 ---- section .text global double_it double_it: lea rax, [rdi + rdi] ; 返回 rdi 的两倍 ret
; ---- main.asm:函数使用方 ---- section .text global _start extern double_it ; 声明:这个符号在别处 _start: mov edi, 21 ; 参数:21 call double_it ; 期待返回 42 mov edi, eax mov eax, 60 ; 退出系统调用 syscall

分别汇编后,先看 main.o 的反汇编——注意 call 的机器码:

$ nasm -f elf64 main.asm -o main.o $ objdump -d main.o 0000000000000000 <_start>: 0: bf 15 00 00 00 mov edi,0x15 5: e8 00 00 00 00 callq 6 a: 89 c7 mov edi,eax c: b8 3c 00 00 00 mov eax,0x3c 11: 0f 05 syscall $ objdump -r main.o RELOCATION RECORDS FOR [.text]: OFFSET TYPE VALUE 000000000006 R_X86_64_PLT32 double_it - 4

破案了:e8 00 00 00 00 是"操作码 e8 加四个零"——call 的地址部分被刻意填成零,这不是错误而是占位。旁边那张重定位记录才是重点:OFFSET 6 表示"本段偏移 6 字节处(紧跟 e8 之后)需要补",VALUE 给出补什么(double_it),TYPE 说明怎么补(按相对地址、扣除指令长度 4 字节)。汇编器的"记账"动作至此全部落实在纸面上。

二、链接器三步与回填现场

链接器接手后走三步。第一步合并同类段:所有文件的 .text 拼成一大段、.data 拼成一大段,并给每个段分配最终虚地址。第二步符号解析:把各文件的"定义清单"与"引用清单"对账,每个 extern 都必须找到唯一的定义,找不到就是你熟悉的 undefined reference,找到两份就是 duplicate symbol。第三步重定位:按账本逐笔回填。链接并复查:

$ ld -o demo main.o util.o $ objdump -d demo 0000000000401000 <_start>: 401000: bf 15 00 00 00 mov edi,0x15 401005: e8 0a 00 00 00 callq 401014 <double_it> 40100a: 89 c7 mov edi,eax ... 0000000000401014 <double_it>: 401014: 48 8d 04 3f lea rax,[rdi+rdi*1] 401018: c3 ret

对照回填前后的同一字节串:原来 e8 00 00 00 00 现在是 e8 0a 00 00 00。账是这样算的——call 的目标是"下一条指令地址加立即数":0x40100a 加 0x0a 等于 0x401014,恰好是 double_it 的最终地址。为什么用相对地址而不是绝对地址?因为相对编址让代码段可以整体搬到任意虚拟地址照常运行(位置无关的雏形),绝对地址则会把程序钉死在一个位置。TYPE 里那个减 4 的修正,正是"立即数从指令末尾起算"的宽度校正。

三步走完,账本清零:可执行文件里不再有重定位记录(静态链接模式下),每个字节都落定了。用 objdump -h demo 还能看到各段的最终虚地址与文件偏移——这份"段地址清单"在 4.3 节排错、第 7 章逆向里都会反复查阅。

三、静态与动态:把链接推迟的代价与收益

动态链接是"记账模式"的加强版:call 不再直接回填函数地址,而是回填成"去查程序链接表(PLT)"的跳板,装载时由动态链接器把真正的共享库地址补进跳板。两种模式的取舍值得列清楚:

维度 静态链接 动态链接
产物体积 大(库代码全量并入) 小(库共享一份)
启动速度 快(无运行时解析) 略慢(装载期补账)
库升级 需重新链接发布 换库文件即可
部署依赖 零依赖,单文件即走 依赖目标机的库版本
典型阵地 容器镜像、嵌入式、恶意样本分析 桌面与服务端常规发行

选型口诀:交付物要"拎包即走"选静态,要"升级不重发"选动态。安全分析的视角补一句:静态链接的二进制把所有代码摊在你面前,反汇编完整但体积吓人;动态链接的二进制小巧,但真实行为要到运行时才凑齐——第 6 章调试器之所以重要,一半原因在此。

⚠️ 常见坑:链接报 undefined reference 时去反复检查语法。语法错误汇编器早就拦下了;能走到链接这一步说明翻译无误,问题一定出在"账对不上"——要么忘了提供定义所在的目标文件/库,要么 C 与汇编之间符号名修饰(下划线、名字粉碎)不一致。查账顺序:先 nm 看定义方有没有把符号导出(global),再看引用方拼写与命令行里的库顺序。

💡 关键直觉:重定位表的每一行都是一笔"地址期货"——汇编期签合同、链接期交割、动态链接甚至允许运行时交割。整个链接过程就是一次集中清算,理解了清算规则,链接器报的每一条错都能翻译成人话。

本节要点回顾

  • 汇编不解决跨文件引用:占位零加重定位记录是标准姿势,"先记账后付款"。
  • 链接三步:合并段、解析符号、回填地址;报错分别对应账本缺失(未定义)与账本冲突(重复定义)。
  • 相对编址的动机:位置无关,代码段可整体搬迁;立即数从指令末尾起算故有减 4 校正。
  • 静态与动态的取舍轴:体积、启动、升级、依赖,按交付形态选边。

语法、骨架、管线三关全过。下一章往骨架里填血肉:那些天天打照面的指令,各自的真实脾气是什么。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U