跳转至

架构、数据与数值表示

阅读汇编前,需要先理解 CPU 看见的不是“变量”和“对象”,而是有限宽度的位、地址和指令。高级语言赋予数据类型,指令则只规定按多宽、以何种方式解释这些位。

从源码到运行

一个典型 C 程序经历以下阶段:

C 源码 -> 编译器 -> 汇编/目标文件 -> 链接器 -> ELF -> 加载器 -> 进程
- 编译器把高级语言语义转换成目标架构指令。 - 汇编器把助记符编码成机器码并生成目标文件。 - 链接器合并目标文件,解析符号和重定位,连接库。 - 操作系统加载器映射 ELF 段,准备栈和入口地址。 - CPU 从 rip 指向的位置取指、译码并执行。 汇编语言不是跨架构的统一语言。x86-64、AArch64、RISC-V 的寄存器和编码不同;本章只讨论 x86-64。

ISA、微架构与 ABI

这三个概念经常混淆: - ISA(指令集架构)规定软件可见的指令、寄存器、地址和异常行为。 - 微架构**是某款 CPU 实现 ISA 的内部方式,例如流水线、缓存和乱序执行。 - **ABI(应用二进制接口)规定函数参数、寄存器保存、数据布局和目标文件等软件约定。 两款处理器可以实现同一 x86-64 ISA,却有不同性能;两个系统可以都运行 x86-64,却因 ABI 不同而不能直接混用目标文件。

位、字节和常见宽度

  • 1 bit 是一个二进制位,值为 0 或 1。
  • 1 byte 通常是 8 bit。
  • x86 术语中的 word 是 16 bit,doubleword 是 32 bit,quadword 是 64 bit。
  • x86-64 的“64 位”主要表示通用寄存器宽度和地址模型,不代表所有数据都是 64 位。 | 名称 | 位数 | 字节数 | Intel 常见标记 | |---|---:|---:|---| | byte | 8 | 1 | byte ptr | | word | 16 | 2 | word ptr | | dword | 32 | 4 | dword ptr | | qword | 64 | 8 | qword ptr | 例如 mov eax, dword ptr [rdi] 从内存读取 4 字节,而不是 8 字节。

二进制、十进制和十六进制

二进制适合描述位,十六进制适合紧凑显示机器数据。每个十六进制数字恰好对应 4 bit:

0b1010 1100 = 0xac = 172
常见前缀: - 二进制:0b1010 - 十六进制:0x2a - 十进制:42 进制只改变书写方式,不改变底层位模式。42、0x2a 和 0b101010 是同一个数值。 快速换算时可按四位分组:
1101 0110 0011 1001
   d    6    3    9  -> 0xd639

无符号整数

一个 n 位无符号整数的范围是:

0 到 2^n - 1
8 位无符号整数范围为 0 到 255。固定宽度运算溢出后保留低 n 位:
0xff + 1 = 0x00    ; 按 8 位计算
CPU 会通过进位标志 CF 记录无符号意义上的进位或借位,但是否把它当作错误由程序决定。

二进制补码与有符号整数

x86 使用二进制补码表示有符号整数。n 位补码范围是:

-2^(n-1) 到 2^(n-1)-1
求一个正数的相反数,可以按固定宽度执行“逐位取反再加一”:
  5 = 0000 0101
 ~5 = 1111 1010
 +1 = 1111 1011 = -5(8 位补码)
同一位模式取决于解释方式:
0xff = 255(8 位无符号)
0xff = -1 (8 位有符号补码)
加法器不需要知道数据是否有符号;区别主要体现在扩展、除法和条件判断中。

溢出:CF 与 OF

CF 和 OF 表达不同问题: - CF:无符号运算是否产生最高位之外的进位或借位。 - OF:有符号结果是否超出当前宽度可表示范围。 8 位示例:

0xff + 1    -> 0x00,CF=1,OF=0
0x7f + 1    -> 0x80,CF=0,OF=1
因此后续条件跳转必须匹配数据语义,不能把 ja 和 jg 随意互换。

零扩展与符号扩展

把较窄整数放入较宽位置时,必须决定高位如何补齐: - 零扩展在高位补 0,适合无符号值。 - 符号扩展复制原符号位,保持有符号值不变。

movzx eax, byte ptr [rdi] ; 0xff -> 255
movsx eax, byte ptr [rdi] ; 0xff -> -1
movsxd rax, eax           ; 32 位有符号扩展到 64 位
写入任意 32 位通用寄存器会自动把对应 64 位寄存器高 32 位清零,这是一条重要的 x86-64 规则。

小端序

x86-64 使用小端序:多字节值的最低有效字节放在最低地址。 把 0x12345678 以 32 位整数写到地址 0x1000: | 地址 | 字节 | |---|---| | 0x1000 | 78 | | 0x1001 | 56 | | 0x1002 | 34 | | 0x1003 | 12 | 调试器若按字节查看会显示 78 56 34 12,按 32 位整数查看仍是 0x12345678。端序改变字节排列,不改变单个字节内的位顺序。 端序对字符串通常不明显,因为字符数组按地址顺序存放;它主要影响整数、指针和网络协议字段的解释。

位运算

常见位运算直接作用于位模式: | 运算 | 作用 | 常见用途 | |---|---|---| | and | 两位都为 1 才为 1 | 掩码、清位、对齐 | | or | 任一位为 1 即为 1 | 置位、合并标志 | | xor | 两位不同为 1 | 翻转位、清零寄存器 | | not | 每一位取反 | 构造补码 | | shl | 逻辑左移 | 乘以 2 的幂、移位 | | shr | 逻辑右移,高位补 0 | 无符号除以 2 的幂 | | sar | 算术右移,复制符号位 | 有符号缩放近似 | xor eax, eax 常用于把 eax 置零,并同时清零 rax 高 32 位。

字符、字符串与布尔值

字符在内存中也是整数编码。ASCII 中:

'A' = 65 = 0x41
'0' = 48 = 0x30
'\n' = 10 = 0x0a
C 字符串是以 0x00 结尾的字节序列。机器指令不会自动知道数组长度;若代码依赖终止符,就必须保证终止符存在且访问不越界。 C 的布尔判断通常把 0 视为假、非 0 视为真,但比较指令常通过 setcc 生成规范的 0 或 1。

浮点数概览

常见 float 和 double 使用 IEEE 754 表示,包含符号、指数和尾数。它们不是补码整数,不能直接用整数比较规则解释。 x86-64 现代代码通常使用 XMM 寄存器和 SSE/AVX 指令:

addss xmm0, xmm1 ; 单精度标量加法
addsd xmm0, xmm1 ; 双精度标量加法
浮点数存在 NaN、正负无穷和舍入误差。反汇编中看到 ucomiss/ucomisd 时,应额外考虑无序比较和 NaN。

指针与地址

指针是一个被程序解释为地址的整数位模式。在常见 x86-64 用户态进程中,指针占 8 字节,但并非全部 64 位组合都是有效规范地址。 要区分三个概念: - 地址:某个虚拟内存位置的编号。 - 地址中保存的值:需要访问内存才能得到。 - 符号:链接器或调试信息为地址提供的名称。

lea rax, [rip+message] ; 取得 message 的地址
mov eax, [rip+value]   ; 读取 value 地址处的 4 字节

对齐

若对象地址是其对齐值的整数倍,就称对象对齐。例如 8 字节对象常按 8 字节边界放置。 对齐可以改善访问效率,也是 ABI 对结构体和栈布局的一部分。编译器可能插入填充字节:

struct S {
    char tag;
    int value;
};
value 往往位于偏移 4,结构体大小往往是 8,而不是 5。逆向时不能仅把字段大小相加推断布局。

机器码与变长指令

x86-64 指令长度可变,通常为 1 到 15 字节。前缀、操作码、ModR/M、SIB、位移和立即数共同构成编码。 这意味着: - 指令地址不会固定增加 4。 - 从错误字节位置开始反汇编可能得到另一串看似有效的指令。 - 数据与代码混合时,线性扫描可能误判。 - 正确的控制流和入口点对反汇编非常重要。 初学阶段不必手工记忆编码,但应知道反汇编器是在解释字节,不是在恢复源码。

C 与汇编的数据宽度

在常见 Linux x86-64 LP64 模型中: | C 类型 | 常见宽度 | |---|---:| | char | 1 字节 | | short | 2 字节 | | int | 4 字节 | | long | 8 字节 | | 指针 | 8 字节 | Windows x64 使用 LLP64:long 仍是 4 字节,指针是 8 字节。跨平台逆向时,这是结构体和 API 参数分析的重要差异。

分析检查表

看到一个立即数或内存值时,依次确认: 1. 它的操作宽度是多少? 2. 它被当作有符号、无符号、指针还是浮点数? 3. 是否发生截断、零扩展或符号扩展? 4. 内存字节是否按小端序重组? 5. 运算后代码检查的是 CF、OF、ZF 还是其他标志? 6. 数据布局是否包含对齐和填充? 下一篇把这些位和值放入实际运行位置:寄存器、内存与栈。