9、汇编器原理:从汇编代码到机器码,重定位信息的生成

好,咱们今天聊聊汇编器。很多人觉得汇编器就是个翻译工具,把助记符转成二进制,没啥技术含量。说实话,我早年也这么想,直到有一次调试一个嵌入式bootloader,发现程序死活跳转不到正确地址……查了两天,最后发现是汇编器生成的重定位表出了问题。从那以后,我再也不敢小看这个“翻译官”了。

9.1 汇编器的核心工作:两趟扫描

汇编器怎么工作的?说白了,它得把咱们写的mov eax, 0x1234这种人类友好的指令,变成CPU能吃的二进制。但这里有个坑——你写代码时经常要引用一个还没定义的符号,比如jmp loop_start,而loop_start在后面的代码里才定义。

这就麻烦了。汇编器读到jmp指令时,它还不知道目标地址是多少。怎么办?

解决方案就是——两趟扫描

  • 第一趟:扫描整个源文件,记录每个标签(label)的地址偏移量,构建符号表。这时候不生成机器码,只算位置。
  • 第二趟:再次扫描,这次有了符号表,可以填充所有地址了。遇到无法确定的地址,就生成一个重定位条目,留给链接器去填。

关键点:第一趟扫描时,汇编器会维护一个位置计数器(Location Counter, LC)。每处理一条指令,LC就增加该指令的长度。所有标签的值,其实就是当前LC的值。

举个例子,我写过一段x86汇编:

section .text
global _start

_start:
    mov eax, 1      ; LC=0, 指令长度5字节
    mov ebx, 42     ; LC=5, 指令长度5字节
    int 0x80        ; LC=10, 指令长度2字节

loop_start:         ; 标签loop_start = LC=12
    dec ebx         ; LC=12
    jnz loop_start  ; LC=14, 这里需要回跳

第一趟扫描后,符号表里就有loop_start = 12。第二趟扫描时,jnz loop_start就能算出偏移量:12 - 18 = -6(因为jnz指令本身占2字节,下一条指令地址是18)。

9.2 机器码生成:指令编码的细节

指令编码这事儿,不同架构差别很大。x86是可变长度指令,从1字节到15字节都有。ARM一开始是固定4字节,后来Thumb指令集又搞了2字节混合。我个人习惯把指令编码分成三部分:

字段 说明 示例(x86)
操作码(opcode) 指令的核心功能编码 0xB8 表示 mov eax, imm32
操作数(operands) 寄存器、内存地址、立即数 0x2A000000 表示立即数42
前缀/后缀 修改指令行为(如REP、LOCK) 0xF3 表示REP前缀

嗯,这里要注意:操作数的编码方式决定了指令长度。比如mov eax, 42编码为B8 2A 00 00 00,共5字节。而mov eax, ebx编码为89 D8,只有2字节。汇编器必须根据操作数类型选择正确的编码格式。

避坑指南:我曾经在写一个反汇编器时,发现x86的mov指令有超过10种编码方式。比如mov eax, [ebx]mov [ebx], eax,操作码完全不同。汇编器必须根据源操作数和目的操作数的类型(寄存器、内存、立即数)来选择正确的opcode。选错了,CPU就会解码成完全不同的指令。

9.3 重定位信息的生成

这才是汇编器最精妙的部分。你想想看,汇编器生成的.o文件(目标文件)里,有些地址是没法确定的——比如你调用了外部函数printf,这个函数在别的.c文件里,汇编器根本不知道它的地址。

这时候,汇编器会做两件事:

  1. 在机器码中填入一个占位值(通常是0或者当前地址的偏移)
  2. 生成一个重定位条目,告诉链接器:“嘿,这个位置需要修正,修正方式是这样的……”

重定位条目通常包含以下信息:

字段 含义
偏移量(offset) 需要修改的位置在目标文件中的偏移
符号索引(symbol index) 引用的是哪个符号(比如printf)
重定位类型(type) 如何计算最终地址(绝对地址、相对偏移等)
加数(addend) 一个额外的修正值(有些格式用)

举个例子,在x86_64的ELF文件中,call printf指令会生成一个R_X86_64_PLT32类型的重定位条目。链接器在链接时,会计算printf的最终地址,然后修正这个call指令中的偏移量。

重要提醒:重定位类型非常关键。选错了类型,程序就会崩溃。比如x86_64下,R_X86_64_32用于32位绝对地址,但如果你要引用的符号在64位地址空间(比如高于4GB),这个重定位就会失败。链接器会报错:relocation truncated to fit。我遇到过好几次这种问题,最后发现是编译时忘了加-fPIC选项。

9.4 符号表与段表的管理

汇编器除了生成机器码,还要维护两个重要的表:符号表段表

符号表记录了所有全局和局部符号的信息:

  • 符号名称(比如mainprintf
  • 符号值(地址或偏移量)
  • 符号类型(函数、变量、节区等)
  • 绑定属性(全局、局部、弱符号)
  • 所在段(.text.data等)

段表则描述了目标文件中各个段的布局:

  • 段名称(.text.data.bss
  • 段在文件中的偏移和大小
  • 段的属性(可读、可写、可执行)
  • 段的对齐要求

这两个表是链接器工作的基础。没有它们,链接器就像瞎子摸象,根本不知道哪些符号需要解析,哪些段需要合并。

9.5 知识体系总览

下面这张图,是我梳理的汇编器核心工作流程。你可以看到,从源文件到目标文件,中间经历了符号解析、指令编码、重定位生成三个关键步骤。

汇编器核心工作流程 汇编源文件 (.s) 第一趟扫描:构建符号表 记录标签地址,维护位置计数器 第二趟扫描:生成机器码 指令编码,填充已知地址 有未确定 地址? 生成重定位条目 填入占位值 直接输出机器码 地址已确定 目标文件 (.o) + 重定位表

9.6 实际项目中的经验

最后分享一点实战经验。我在做嵌入式系统时,经常需要手写启动代码(startup code)。这种代码通常是用汇编写的,而且需要精确控制每个段的布局。

有一次,我写了一个中断向量表,用.org伪指令强制对齐到特定地址。结果汇编器生成的.o文件里,重定位信息全乱了。后来发现,.org会改变位置计数器的值,但汇编器在生成重定位条目时,没有考虑到这种“跳跃”。

解决方案是:在.org之后,手动插入一个.align指令,或者用.space填充到目标地址。这样汇编器就能正确计算偏移了。

个人建议:如果你在写链接脚本(linker script),一定要理解汇编器生成的重定位信息。链接脚本里的.(点号)代表当前地址,它和汇编器的位置计数器是两回事。我曾经把这两个概念搞混,结果链接出来的程序一运行就崩溃。调试了整整一个下午,才发现是地址计算错了。

好了,关于汇编器原理,咱们就聊到这儿。记住一句话:汇编器不是简单的翻译工具,它是编译器和链接器之间的桥梁。理解了重定位信息的生成,你才能真正理解链接器在做什么。


公众号:蓝海资料掘金营,微信deep3321