程序的机器级表示 miniWiki

1. 历史视角

Intel

Intel 长期主导(笔记本、台式机、服务器)处理器市场。

里程碑产品:

名称 时间 主频(MHz) 技术要点
8086 1978 5~10 16-bit
i386 1985 16~33 32-bit
Pentium 4E 2004 2800~3800 64-bit、超线程
Core 2 2006 1060~3500 多核
Core i7 2008 1700~3900 四核、超线程
  • i386 引入了 IA32 (Intel Architecture 32-bit) 指令集。
  • Pentium 4E 采用了 EM64T (Extended Memory 64 Technology) 指令集(基本等价于 x86-64 指令集)。
  • 使用这些处理器的计算机都属于 CISC (Complex Instruction Set Computer),其性能通常不如 RISC (Reduced Instruction Set Computer),但功能及通用性远胜后者。

AMD

AMD (Advanced Micro Devices) 是 Intel 的主要竞争对手。

  • 性能略逊于 Intel,但价格有优势。
  • 在 64-bit 处理器的研发上,采取了渐进式策略,推出了 x86-64 指令集。

Moore’s Law

Gordon Moore (1965) 历史统计值
The number of transistors per chip would double every year for the next 10 years. 处理器上的晶体管数量平均每 18 个月翻一番。

2. 程序编码

2.1. 机器级代码

英文名 中文名 含义
ISA (Instruction Set Architecture) 指令集架构 机器码格式及行为的定义
Micro-architecture 微架构 ISA 的物理实现(微电路)
Machine Code 机器码 0/1 序列表示的指令序列
Assembly Code 汇编码 用汇编语言表示的指令序列
Source Code 源代码 用高级语言表示的指令序列

汇编码可见(源代码不可见)的信息:

  • 程序计数器 (Program Counter):下一条指令的地址(在 x86-64 中由 %rip 保存)。
  • 寄存器 (Register):位于 CPU 内部的临时数据存储器(顶级缓存)。
  • 条件码 (Condition Code):存储最近一条指令的状态,用于条件分支。
  • 内存 (Memory):可按字节寻址的(抽象)数组,用于存放数据及指令。

源代码可见(汇编码不可见)的信息:

  • 变量名
  • 聚合数据结构
  • 不同类型的指针
  • 指针与整数的区别

汇编码的可读性介于机器码与源代码之间:

  • 汇编语言是指令集的文字表示,因此首先由机器(处理器架构)决定:
  • 即使在同一台机器上,汇编码也可以有不同风格
  • 高级语言隔离了这种机器相关性,因而具有跨平台性。编译器负责将同一份高级语言代码在不同机器上转换为相应的汇编码。

对日常使用高级语言的程序员而言,学习汇编语言主要是为了而不是汇编码:

  • 理解编译优化,分析代码效率。
  • 理解、分析代码的运行期行为。
  • 发现、修复系统程序的安全漏洞。

2.2. 示例代码

/* hello.c */
#include <stdio.h>
int main() {
  printf("hello\n");
  return 0;
}
工具 命令 输出
工具链 (Tool Chain) cc -o hello hello.c 可执行文件 hello
预处理器 (Preprocessor) cc -E hello.c > hello.i 含库函数的源代码
编译器 (Compiler) cc -S hello.i 汇编文件 hello.s
汇编器 (Assembler) as -o hello.o hello.s 目标文件 hello.o
链接器 (Linker) ld -o hello hello.o -lc 可执行文件 hello
反汇编器 (Disassembler) objdump -d hello > hello.d 由机器码反推出的汇编码

⚠️ 如果用 gcc 编译,可加编译选项 -Og 使机器码源代码具有大致相同的结构。

汇编文件 hello.s 的内容大致如下(可能因 操作系统、编译器 不同而存在差异),其中以 . 开头的行是用于引导 汇编器、链接器 的指令,人工解读时可忽略:

_main:                                  # @main
	.cfi_startproc
# %bb.0:
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset %rbp, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register %rbp
	leaq	L_str(%rip), %rdi
	callq	_puts
	xorl	%eax, %eax
	popq	%rbp
	retq

目标文件 hello.o 及可执行文件 hello 的反汇编结果大致如下(可能因 操作系统、编译器 不同而存在差异):

; objdump -d hello.o
0000000000000000 _main:
       0: 55                            pushq   %rbp
       1: 48 89 e5                      movq    %rsp, %rbp
       4: 48 8d 3d 09 00 00 00          leaq    9(%rip), %rdi
       b: e8 00 00 00 00                callq   0 <_main+0x10>
      10: 31 c0                         xorl    %eax, %eax
      12: 5d                            popq    %rbp
      13: c3                            retq
; objdump -d hello
0000000100000f70 _main:
100000f70: 55                           pushq   %rbp
100000f71: 48 89 e5                     movq    %rsp, %rbp
100000f74: 48 8d 3d 2b 00 00 00         leaq    43(%rip), %rdi
100000f7b: e8 04 00 00 00               callq   4 <dyld_stub_binder+0x100000f84>
100000f80: 31 c0                        xorl    %eax, %eax
100000f82: 5d                           popq    %rbp
100000f83: c3                           retq

反汇编也可以在调试器 (debugger) 中完成:

gdb hello  # 进入调试环境,引导符变为 (gdb)
(gdb) disassemble main  # 定位到 main() 函数,输出其汇编码

输出结果(可能因 操作系统、编译器、调试器 不同而存在差异)如下:

Dump of assembler code for function main:
   0x0000000100000f70 <+0>:     push   %rbp
   0x0000000100000f71 <+1>:     mov    %rsp,%rbp
   0x0000000100000f74 <+4>:     lea    0x2b(%rip),%rdi
   0x0000000100000f7b <+11>:    callq  0x100000f84
   0x0000000100000f80 <+16>:    xor    %eax,%eax
   0x0000000100000f82 <+18>:    pop    %rbp
   0x0000000100000f83 <+19>:    retq   
End of assembler dump.

2.3. 汇编码格式

  • 函数名下方的每一行分别对应一条指令 (instruction)

    • 形如 100000f700x0000000100000f60 的 64-bit 16 进制整数,表示各条指令的首地址。对比 hello.ohello 的反汇编结果可见,一些函数的地址会被链接器 (linker) 修改。
  • 首地址后面的若干 16 进制整数(每 8 bits 一组,即每组 1 字节),表示该行指令的机器码,由此可以算出各条指令的长度(字节数)。
    • <+n> 表示当前指令相对于函数入口(以字节为单位)的偏移量 (offset)。由相邻两行的偏移量之差也可以算出前一行指令的机器码长度。
  • 指令的机器码长度与其使用频率大致成反比(类似于 Hoffman 编码),最长 15 字节,最短 1 字节。

  • 形如英文单词的 mov 等符号表示指令名,用于

    • 对寄存器、内存中的数据作算术运算。
    • 在寄存器、内存间传递数据(双向读写)。
    • 无条件跳转、条件分支。
  • % 起始的 %rsp 等符号表示寄存器名,用于存储临时数据:

    • 整数:长度为 1、2、4、8 字节,用于存储整数或地址。
    • 浮点数:长度为 4、8、10 字节,用于存储浮点数。
    • 没有聚合类型(数组、结构体)。

⚠️ 指令的完整含义是由指令名运算数所构成的整体,但有时也将指令名简称为指令

3. 数据格式

指令后缀

后缀 名称 长度(bit) C 语言类型
b byte(字节) 8 char
w word(单词) 16 short
l (long) double word(二倍词) 32 int
q quad word(四倍词) 64 longvoid*
s single precision(单精度) 32 float
l (long) double precision(双精度) 64 double

4. 访问信息

整型寄存器

64 bits 后 32 bits 后 16 bits 后 8 bits 字面含义 实际含义
%rax %eax %ax %al Accumulate 返回值
%rbx %ebx %bx %bl Base 被调函数保存
%rcx %ecx %cx %cl Counter 第 4 个实参
%rdx %edx %dx %dl Data 第 3 个实参
%rsi %esi %si %sil Source Index 第 2 个实参
%rdi %edi %di %dil Destination Index 第 1 个实参
%rbp %ebp %bp %bpl Base Pointer 被调函数保存
%rsp %esp %sp %spl Stack Pointer 函数调用栈顶
%r8 %r8d   %r8b   第 5 个实参
%r9 %r9d   %r9b   第 6 个实参
%r10 %r10d   %r10b   主调函数保存
%r11 %r11d   %r11b   主调函数保存
%r12 %r12d   %r12b   被调函数保存
%r13 %r13d   %r13b   被调函数保存
%r14 %r14d   %r14b   被调函数保存
%r15 %r15d   %r15b   被调函数保存

每个 64-bit 寄存器的后 4、2、1 字节都可以被当作短寄存器来访问,并且约定

  • 修改后 2 字节的指令不会修改前 6 字节
  • 修改后 4 字节的指令会将前 4 字节置零。

4.1. 运算数

指令的运算数 (operand) 是指位于指令名后方、以逗号分隔的表达式,可分为以下三类:

表达式类型 格式 含义
即时数 (Immediate) $ 起始的整数值 整型常量
寄存器 (Register) % 起始的寄存器 局部变量
内存 (Memory) 形如 D(B, I, S) 内存地址

其中 D(B, I, S) 表示按 R[B] + S*R[I] + D 算出的地址值(除 lea 外,均读取 M[R[B] + S*R[I] + D] 的值),各符号含义如下:

符号 名称 解释
M 内存 (Memory) 一个以 64-bit 整数为索引的数组
R 寄存器 (Register) 一个以寄存器名为索引的数组
B 基础 (Base) 可以是 16 个整型寄存器之一
I 索引 (Index) 可以是除 %rsp 外的 15 个整型寄存器之一
S 比例 (Scale) 可以是 1248 之一
D 位移 (Displacement) 可以是 1、2、4 字节整数

其中 R[B]R[I]SD 均以 0 为缺省值。

4.2. 移动数据

movq source, target
movl source, target
movw source, target
movb source, target
  • sourcetarget 为该指令的运算数,且只能有一个为内存地址

  • mov 后面的 q 表示 target 的大小为一个 quad word;其他后缀的含义见《指令后缀》。

  • mov 的一个重要变种是 movabs,二者的区别在于

    • movqsource即时数,则只能是 32-bit 带符号整数,其符号位将被填入 target 的前 32 bits;若要阻止填充,则应使用 movl 等。
    • movabsqsource即时数,则可以是 64-bit 整数,此时 target 必须是寄存器
  • mov 还有两类用于短整数长整数扩展的变种:

    movz s, t  # t = ZeroExtend(s)
      movzbw s, t
      movzbl s, t
      movzwl s, t
      movzbq s, t  # 通常被 movzbl s, t 代替,理由同 movzlq s, t
      movzwq s, t
    # movzlq s, t  # 该指令不存在,其语义可通过 movl s, t 实现,这是因为:
                   # 生成 32-bit 结果的指令,会将前 32 bits 置零。
    movs s, t  # t = SignExtend(s)
      movsbw s, t
      movsbl s, t
      movswl s, t
      movsbq s, t
      movswq s, t
      movslq s, t
    
  • cltqmovslq %eax, %rax 的简写(机器码更短)。
  • 以下示例体现了这几个版本的区别:

    movabsq $0x0011223344556677, %rax  # R[rax] = 0x0011223344556677
    movb    $-1,                 %al   # R[rax] = 0x00112233445566FF
    movw    $-1,                 %ax   # R[rax] = 0x001122334455FFFF
    movl    $-1,                 %eax  # R[rax] = 0x00000000FFFFFFFF ⚠️
    movq    $-1,                 %rax  # R[rax] = 0xFFFFFFFFFFFFFFFF
    movabsq $0x0011223344556677, %rax  # R[rax] = 0x0011223344556677
    movb    $0xAA,               %dl   # R[rdx] = 0x??????????????AA
    movb    %dl,                 %al   # R[rax] = 0x00112233445566AA
    movsbq  %dl,                 %rax  # R[rax] = 0xFFFFFFFFFFFFFFAA ⚠️
    movzbq  %dl,                 %rax  # R[rax] = 0x00000000000000AA ⚠️
    

4.3. 交换数据

交换数据没有专门的指令,但可以通过一组 mov 来实现:

 void swap(long* px, long* py) {
   long tx = *px;
   long ty = *py;
   *px = ty;
   *py = tx;
 }
movq    (%rdi), %rax
movq    (%rsi), %rdx
movq    %rdx, (%rdi)
movq    %rax, (%rsi)
ret

4.4. 压栈出栈

x86-64 规定:栈顶字节的地址保存在寄存器 %rsp 中(即 R[rsp] 的值),并且小于栈内其他字节的地址(向生长)。

指令 含义 语义
pushq s push quad word R[rsp] -= 8; M[R[rsp]] = s;
popq t pop quad word t = M[R[rsp]]; R[rsp] += 8;

5. 算术及逻辑运算

5.1. 加载有效地址

leaq source, target
  • lea加载有效地址 (Load Effective Address) 的首字母构成,对应于 C 语言的取地址运算(例如 p = &x[i])。

  • source 只能是内存地址

  • target 只能是寄存器,用于存储 source 所表示的内存地址,但不访问该地址(不取出 M[R[target]] 的值)。

  • 该指令速度极快,常被用来分解算术运算,例如:

    long m12(long x) { return x * 12; }
    

    通常被编译为

    leaq    (%rdi,%rdi,2), %rax  # 相当于 t = x + 2 * x
    salq    $2, %rax             # 相当于 t *= 2
    ret
    

5.2. 一元运算

唯一的运算数既是 source 又是 target:

指令 含义 语义
inc d increment d++
dec d decrement d--
neg d negate d = -d
not d complement d = ~d

5.2. 二元运算

第一个运算数是 source,第二个运算数既是 source 又是 target:

指令 含义 语义
add s, t add t += s
sub s, t subtract t -= s
imul s, t signed multiply t *= s
xor s, t exclusive or t ^= s
or s, t bitwise or t |= s
and s, t bitwise and t &= s

5.3. 移位运算

指令 含义 语义 移出的空位
shl k, t shift logically to the left t <<= k 在右端,补 0
sal k, t shift arithmeticly to the left t <<= k 在右端,补 0
shr k, t shift logically to the right t >>= k 在左端,补 0
sar k, t shift arithmeticly to the right t >>= k 在左端,补符号位

其中 k 为移动的位数,可以是

  • 即时数,且为 1 时可省略(退化为一元运算)。
  • 寄存器,且只能是 %cl 这个特殊的寄存器:
    • 后缀为 b 的版本,取 %cl 的后 3 bits 所表示的值,至多移动 7 bits。
    • 后缀为 w 的版本,取 %cl 的后 4 bits 所表示的值,至多移动 15 bits。
    • 后缀为 l 的版本,取 %cl 的后 5 bits 所表示的值,至多移动 31 bits。
    • 后缀为 q 的版本,取 %cl 的后 6 bits 所表示的值,至多移动 63 bits。

5.5. 特殊算术运算

x86-64 还提供了一些针对(Intel 称之为八倍词 (oct word) 的)128-bit 整数的算术运算指令:

  • 作为运算数的 128-bit 整数用 R[rdx]:R[rax] 表示,冒号前、后的两个 64-bit 寄存器分别表示其前、后 64 bits。
  • 一元乘法:
    • imulq s 为带符号乘法,mulq s 为无符号乘法。
    • 语义为 R[rdx]:R[rax] = s * R[rax]
  • 一元除法:
    • idivq s 为带符号除法,divq s 为无符号除法。
    • R[rdx]:R[rax]被除数 (dividend),以 s除数 (divisor)
    • 所得的商 (quotient) 存入 %rax余数 (remainder) 存入 %rdx
    • ⚠️ 不存在二元除法指令。
  • cqto 用于构造带符号除法的被除数
    • 指令名取自 convert quad-word to oct-word 的首字母。
    • 语义为 R[rdx]:R[rax] = SignExtend(R[rax])

汇编码风格

  ATT 风格 Intel 风格
使用者 ATT, CS:APP3e Intel, Microsoft
指令名 movq 去掉 q,即 mov
操作对象 movq s, t 逆序,即 mov t, s
即时数 $0x0 去掉 $,即 0x0
寄存器 %rsp 去掉 %,即 rsp
地址值 D(B, I, S) [B + I*S + D]
数据长度 movb (%rbx), %al mov al, BYTE PTR [rbx]
注释起始符 # ;
代码块标记 gas nasm

可见 Intel 风格ATT 风格更清晰、易读。除此之外,各种代码高亮插件对 Intel 风格的支持也普遍做得更好,因此推荐使用这种风格。

GCC、GDB、OBJDUMP 等工具默认选择 ATT 风格,可通过以下设置切换为 Intel 风格:

gcc -S -masm=intel hello.c # -o hello.s
nasm -f elf64      hello.s # -o hello.o
objdump -d -x86-asm-syntax=intel hello.o
(gdb)           set            disassembly-flavor intel
(lldb) settings set target.x86-disassembly-flavor intel

6. 控制

6.1. 条件码

CPU 用一组名为条件码 (condition code) 的寄存器记录最近一条指令的状态。基于这些状态,可以实现条件跳转条件分支循环语句等语义。

最常用的条件码有如下几个:

符号 名称 含义
CF Carry Flag 最近一条指令触发无符号溢出
ZF Zero Flag 最近一条指令获得零值
SF Sign Flag 最近一条指令获得负值
OF Overflow Flag 最近一条指令触发带符号溢出

修改条件码可以被视为相关指令的副作用。除算术及逻辑运算指令,以下指令(这里省略指令后缀)也会修改条件码:

  • cmp s, t 根据 sub s, t 的结果设置条件码(但不执行 sub 指令)。
  • test s, t 根据 and s, t 的结果设置条件码(但不执行 and 指令)。

⚠️ 一些(不显然的)约定:

  • leaq 指令不改变条件码。
  • 逻辑运算将 CFOF 设为 0
  • 移位运算将 CF 设为最后一个被移出的 bit,而 OF 仅在移动 1 bit 时会被修改。
  • 自增自减将 OFZF 设为 1,并保持 CF 不变。(原因较复杂)

6.2. 读取条件码

高级语言代码经常将逻辑表达式的结果用整数(01)表示,这在汇编码中是通过 set 系列指令来实现的:

指令 后缀含义 语义
set[e|z] t equal | zero t = ZF
sets t signed (negative) t = SF
setg t geater (signed >) t = ~(SF ^ OF) & ~ZF
setl t less (signed <) t = SF ^ OF
seta t above (unsigned >) t = ~CF & ~ZF
setb t below (unsigned <) t = CF

表中语义一列的关键在于 setlsetb 这两行:

  • setl 用于带符号小于,有两种情形:
    • 上一条指令得到一个负数(SF == 1)且没有溢出(OF == 0)。
    • 上一条指令得到一个正数(SF == 0)且向下溢出(OF == 1)。
  • setb 用于无符号小于,只有一种情形:
    • 上一条指令向下溢出(CF == 1)。

表中只列出了几个有代表性的指令,遇到其他指令可以根据以下规则猜出其语义:

  • 后缀前端的 n 表示 not,后端的 e 表示 or equal,因此 setnle 就表示 (set when) not less or equal,类似的指令可按此规则解读。
  • 某些指令具有同义词(例如 setgsetnle),它们具有相同的机器码。编译器、反汇编器在生成汇编码时,从同义词中任选其一。

以上指令根据(前一条 cmptest 指令的)比较结果,将单字节的 t 设为 01;为了获得 32-bit 或 64-bit 的 01,通常需配合 movzbqxorl 将更高位置零:

int greater(long x, long y) { return x > y; }
cmpq    %rsi, %rdi
setg    %al         # 将 R[rax] 的末 1 字节设为 0 或 1
movzbl  %al, %eax   # 将 R[rax] 的首 7 字节设为 0
xorl    %eax, %eax  # 将 R[rax] 的全 8 字节设为 0
cmpq    %rsi, %rdi
setg    %al         # 将 R[rax] 的末 1 字节设为 0 或 1

6.3. 跳转指令

跳转 (Jump) 指令的基本形式为 j_ target,用于跳转到 target 所表示的指令。其中

  • 【无条件跳转】指令名为 jmp,对应于 C 语言中 goto 语句。
  • 【有条件跳转】指令名为 j 加后缀,后缀的含义参见 set 一节。

6.5./6.6. 条件分支

C 语言中有三种表达条件分支 (conditional branch) 的方式:

/* if-else */
long absdiff(long x, long y) {
  long d;
  if (x > y) d = x-y;
  else       d = y-x;
  return d;
}
/* ?: operator */
long absdiff(long x, long y) {
  long d;
  d = x > y ? x-y : y-x;
  return d;
}
/* goto */
long absdiff(long x, long y) {
  long d;
  if (x <= y) goto Else;
  d = x-y;
  goto Done;
Else:
  d = y-x;
Done:
  return d;
}

要得到与源代码结构最接近的汇编码,需降低优化等级:

# gcc -Og -S -fno-if-conversion
_absdiff:
        movq    %rdi, %rax  # d = x
        cmpq    %rsi, %rdi
        jle     L2          # x <= y
        subq    %rsi, %rax  # d -= y
        ret
L2:
        subq    %rdi, %rsi  # y -= x
        movq    %rsi, %rax  # d = y
        ret

提高优化等级,编译器会在两个分支都安全计算量都很小的情况下,先计算两个分支,再作比较,最后利用条件移动 (conditional move) 指令 cmov 完成选择:

# gcc -O1 -S
_absdiff:
        movq    %rdi, %rdx  # c = x
        subq    %rsi, %rdx  # c -= y
        movq    %rsi, %rax  # d = y
        subq    %rdi, %rax  # d -= x
        cmpq    %rsi, %rdi
        cmovg   %rdx, %rax  # x > y ? d = c : ;
        ret

6.7. 循环语句

C 语言中有三种(不用 goto)表达循环 (loop) 的方式:

  • do-while 语句:

    /* do-while */
    long pcount(unsigned long x) {
      long count = 0;
      do {
        count += x & 0x1;
        x >>= 1;
      } while (x);
      return count;
    }
    

    gcc -Og -S 编译得如下汇编码:

    pcount:
            movl    $0, %eax    # count = 0
    L2:  # loop:
            movq    %rdi, %rdx  # t = x
            andl    $1, %edx    # t &= 0x1
            addq    %rdx, %rax  # count += t
            shrq    %rdi        # x >>= 1
         # test:
            jne     L2          # while (x)
         # done:
            ret
    
  • while 语句:

    long pcount(unsigned long x) {
      long count = 0;
      while (x) {
        count += x & 0x1;
        x >>= 1;
      }
      return count;
    }
    

    gcc -Og -S 编译得如下汇编码:

    _pcount:
            movl    $0, %eax    # count = 0
    L2:  # test:
            testq   %rdi, %rdi  # while (x)
            je      L4
         # loop:
            movq    %rdi, %rdx  
            andl    $1, %edx
            addq    %rdx, %rax
            shrq    %rdi
            jmp     L2
    L4:  # done:
            ret
    

    此版本在进入循环前先做了一次检测,若恰有 x == 0 则会带来性能提升。

  • for 语句:

    #define SIZE 8*sizeof(unsigned long)
    long pcount(unsigned long x) {
      long count = 0;
      for (int i = 0; i != SIZE; ++i) {
        count += (x >> i) & 0x1;
      }
      return count;
    }
    

    gcc -O2 -S 编译得如下汇编码:

    _pcount:
         # init:
            xorl    %ecx, %ecx  # i = 0
            xorl    %r8d, %r8d  # count = 0
    L2:  # loop:
            movq    %rdi, %rax  # t = x
            shrq    %cl, %rax   # t >>= i
            addl    $1, %ecx    # t &= 0x1
            andl    $1, %eax    # ++i
            addq    %rax, %r8   # count += t
         # test:
            cmpl    $64, %ecx   # i != SIZE
            jne     L2
         # done: 
            movq    %r8, %rax
            ret
    

    编译器知道计数器 i 的初值与终值,故首次检测被跳过。

6.8. 选择语句

long choose(long x, long y, long z) {
  long w = 1; 
  switch(x) {
    case 1:
      w = y * z;
      break;
    case 2:
      w = y / z;
      /* Fall Through */
    case 3:
      w += z;
      break;
    case 5:
    case 6:
      w -= z;
      break;
    default:
      w = 2;
  }
  return w;
}

gcc -Og -S 编译得如下汇编码:

_choose:
        movq    %rdx, %rcx  # z_copy = z
        cmpq    $3, %rdi
        je      L8          # x == 3
        jg      L3          # x > 3
     # x < 3
        cmpq    $1, %rdi
        je      L4          # x == 1
        cmpq    $2, %rdi    # x == 2
        jne     L11
     # case 2:
        movq    %rsi, %rax  # y_copy = y
        cqto                # R[rdx]:R[rax] = SignExtend(y_copy)
        idivq   %rcx        # R[rax] = y_copy / z_copy
        jmp     L2          # Fall into case 3
L11: # default:
        movl    $2, %eax    # w = 2
        ret
L3:  # x > 3
        subq    $5, %rdi    # x -= 5
        cmpq    $1, %rdi
        ja      L12         # x > 1 (case 4, 7, 8, ...)
     # case 6:
        movl    $1, %eax    # w = 1
        subq    %rdx, %rax  # w -= z
        ret
L4:  # case 1:
        movq    %rdx, %rax
        imulq   %rsi, %rax
        ret
L8:  # init:
        movl    $1, %eax    # w = 1
L2:  # case 3:
        addq    %rcx, %rax  # w += z_copy
        ret
L12: # default:
        movl    $2, %eax    # w = 2
        ret
  • 教材中的示例采用了两次跳转:先跳进表中读取标签,再跳到标签所指位置。
  • 编译器可能会打乱各种情形的顺序。
  • 若分支总数 $N$ 较大,则用 switch 可以减少判断次数:
    • 若情形分布较密集,则编译器会为每一种情形各生成一个标签,故 switch 语句只需要 $\Theta(1) $ 次判断。
    • 若情形分布较稀疏,则编译器会生成一棵平衡搜索树,故 switch 语句至多需要 $\Theta(\log N)$ 次判断。
    • 与之等价的 if-else 语句可能(例如 default 情形)需要 $\Theta(N)$ 次判断。

7. 函数

函数 (function) 又称过程 (procedure)方法 (method)子例程 (subroutine)句柄 (handler),是模块化编程的基础:每个函数都是一个生产或加工数据的功能模块。几乎所有高级语言都提供了这种机制,并且各种语言用于定义函数的语法都大同小异。这是因为它们几乎都采用了同一种机器级实现,后者正是本节的内容。

7.1. 运行期栈

若函数 Q 被函数 P 调用,则 PQ 分别被称为主调者 (caller)被调者(callee)。函数调用正是通过控制 (control)数据 (data) 在二者之间相互传递 (pass) 来实现的:

  • 传递控制】Caller 利用 call 指令将控制转移给 Callee;Callee 运行结束后,利用 ret 指令将控制交还给 Caller。
  • 传递数据】Caller 将第一个整型输入值存入 %rdi、将第二个整型输入值存入 %rsi、……,供 Callee 读取;Callee 将整型返回值存入 %rax 中,供 Caller 读取。
  • 局部存储】在 Callee 运行的那段时间,Caller 处于冻结状态,其状态(局部变量的值、下一条指令的地址、……)被保存在寄存器或内存中。

尽管某些局部变量可以只在寄存器中度过其生存期,但与内存相比,寄存器所能容纳的数据量非常有限,因此后者才是更一般的局部存储机制。

每个函数在内存中都拥有一段被称为帧 (frame) 的连续地址空间,其分配与释放遵循栈 (stack)后进先出 (LIFO) 规则,因此这种内存管理机制(或这段内存空间)很自然地被称为运行期栈 (run-time stack)。栈顶地址

  • 始于 0x7FFFFFFFFFFF 并随着栈内数据的增加而减小。
  • 保存在 %rsp 中(注意与 %rip 区分,后者为即将被执行的那条指令的地址)。

7.2. 传递控制

假设有如下两个函数:

long mult2(long a, long b) {
  long s = a * b;
  return s;
}
void multstore(long x, long y, long *dest) {
  long t = mult2(x, y);
  *dest = t;
}

编译(所得可执行文件的反汇编)结果为

0000000100000f5c _mult2:
100000f5c: 48 89 f8                     movq    %rdi, %rax
100000f5f: 48 0f af c6                  imulq   %rsi, %rax
100000f63: c3                           retq

0000000100000f64 _multstore:
100000f64: 53                           pushq   %rbx
100000f65: 48 89 d3                     movq    %rdx, %rbx
100000f68: e8 ef ff ff ff               callq   -17 <_mult2>
100000f6d: 48 89 03                     movq    %rax, (%rbx)
100000f70: 5b                           popq    %rbx
100000f71: c3                           retq 

其中

  • call 指令依次完成以下两步:
    • 将下一条指令的地址(此处为 0x100000f6d)压入运行期栈。
    • %rip 设为被调函数的首地址(此处为 0x100000f5c,它与返回地址 0x100000f6d 相距 -17-0x11),即向被调函数移交控制权。
  • ret 指令依次完成以下两步:
    • 从运行期栈弹出返回地址(此处为 0x100000f6d)。
    • %rip 设为上述返回地址,即向主调函数交还控制权。

7.3. 传递数据

  • 整型(含指针型)数据:
    • 整型返回值通过 %rax 传递。
    • 前六个整型实参通过寄存器(直接)传递,对应关系参见《整型寄存器》。
    • 更多的整型实参通过 Caller 的帧(间接)传递。
  • 浮点型数据:
    • 前几个浮点型实参通过浮点型寄存器(直接)传递,原理与整型数据类似。
    • 更多的浮点型实参通过 Caller 的帧(间接)传递。
  • 数组
    • 通过传地址实现。

7.4./7.5. 局部存储

函数可以在自己的栈内保存以下数据:

  • 局部变量:寄存器无法容纳的局部变量,以及数组异质数据结构
  • 返回地址:见传递控制
  • 寄存器值:
    • 被调函数保存的寄存器:一个函数在使用此类寄存器前,需将它们的值存储到自己的帧内;在移交控制权前,需将这些寄存器恢复为使用前的状态。整型寄存器中的 %rbx%rbp%r12%r13%r14%r15 均属于此类。
    • 主调函数保存的寄存器:一个函数在调用其他函数(含递归调用自身)前,需将(自己用到的)此类寄存器的值存储到自己的帧内。用于传递数据的寄存器都属于这一类;完整列表见《整型寄存器》。

参考以下示例:

long incr(long *p, long val) {
  long x = *p;
  long y = x + val;
  *p = y;
  return x;
}
_incr:
        movq    (%rdi), %rax  # incr 的局部变量在寄存器内度过其生存期:
        addq    %rax, %rsi    # x 位于 %rax 中,y 位于 %rsi 中。
        movq    %rsi, (%rdi)
        ret
long call_incr() {
  long v1 = 15213;
  long v2 = incr(&v1, 3000);
  return v1+v2;
}
_call_incr:
        subq    $24, %rsp        # 分配 call_incr 的帧
        movq    $15213, 8(%rsp)  # 将局部变量 v1 存储到帧内
        leaq    8(%rsp), %rdi    # 构造传给 incr 的第一个实参
        movl    $3000, %esi      # 构造传给 incr 的第二个实参
        call    _incr						 # 返回时 %rax 存储了 v2 的值
        addq    8(%rsp), %rax    # v2 += v1
        addq    $24, %rsp        # 释放 call_incr 的帧
        ret
long call_incr2(long x) {
  long v1 = 15213;
  long v2 = incr(&v1, 3000);
  return x+v2;
}
int main(int argc, char* argv[]) {
  call_incr2(argc);
  return 0;
}
_call_incr2:
        pushq   %rbx             # call_incr2 是 main 的被调函数
        subq    $16, %rsp        #
        movq    %rdi, %rbx       # 将 call_incr2 的第一个实参存入 %rdx
        movq    $15213, 8(%rsp)  #
        leaq    8(%rsp), %rdi    # 将 incr 的第一个实参存入 %rdi
        movl    $3000, %esi      #
        call    _incr            #
        addq    %rbx, %rax       # v2 += x
        addq    $16, %rsp        #
        popq    %rbx             # 还原 %rbx 的值
        ret

7.6. 递归函数

#include <stdlib.h>
#include <stdio.h>
unsigned long factorial(unsigned n) {
  return n <= 1 ? 1 : n * factorial(n-1);
}
int main(int argc, char* argv[]) {
  unsigned int n = atoi(argv[1]);
  printf("%d! == %ld\n", n, factorial(n));
}
_factorial:
        cmpl    $1, %edi
        ja      L8
        movl    $1, %eax
        ret
L8:
        pushq   %rbx
        movl    %edi, %ebx
        subl    $1, %edi    # n - 1
        call    _factorial  # f(n-1)
        imulq   %rbx, %rax  # n * f(n-1)
        popq    %rbx
        ret

8. 数组

8.1. 基本原则

数组 (array) **是最简单的聚合 (aggregate)** 类型:

  • 它是以同种类型的对象为成员的容器,因此是一种均质的 (homogeneous) 数据类型。
  • 所有成员在(虚拟)内存中连续分布。
  • 通过索引 (index) 访问每个成员所需的时间大致相同。

几乎所有高级编程语言都有数组类型,其中以 C 语言的数组语法(如指针算术)最能体现数组的机器级表示。在 C 代码中,以 a 为变量名、含 NT 型(可以是复合类型)对象的数组(通常)以如下方式声明:

T a[N]
  • 该声明既可以单独作为一条语句(以 ; 结尾),又可以出现在函数形参列表中。
  • 数组 a 的类型为 T[N],成员个数 N 也是数组类型的一部分。
  • 每个成员的大小为 sizeof(T) 字节,故整个数组的大小为 N * sizeof(T) 字节。
  • 数组名 a 可以当做指向 a[0] 的指针使用,这是 T[N]T* 的隐式类型转换。

8.2. 指针算术

声明 An *AnAn[0] **AnAn[0][0]
char A1[9] char[9] char 不合法
char* A2[9] (char*)[9] char* char
char (*A3)[9] char(*)[9] char[9] char
char* (A4[9]) (char*)[9] char* char
char** A5 char** char* char

8.3. 嵌套数组

在 C 代码中,含 RC 列(共 R * C 个成员)的二维数组(通常)以如下方式声明:

T a[R][C]
  • 整个数组的大小为 R * C * sizeof(T) 字节。

  • 成员排列遵循行优先 (row-major) 规则:
    • 每一行的 C 个成员连续分布,即 a[i][j] 位于 a[i][j-1]a[i][j+1] 之间。
    • i 行的 C 个成员位于 a[i-1][C-1]a[i+1][0] 之间。
  • 按上述规则,类型为 T[R][C] 的二维数组可以被看作T[C] 型(一维)数组为成员的一维数组,即
    typedef T Row[C];
    Row a[R];  /* 等价于 T a[R][C] */
    
  • 更一般的:类型为 T[D1][D2]⋯[Dn]n 维数组可以被看作T[D2]⋯[Dn] 型((n-1)-维)数组为成员的一维数组,即
    typedef T Element[D2]⋯[Dn];
    Element a[D1];  /* 等价于 T a[D1][D2]⋯[Dn] */
    

8.4. 长度固定的数组

在 ISO C99 引入长度可变的数组之前,用于声明多维数组 T[D1][D2]⋯[Dn] 的整数(除 D1 可以省略外)必须在编译期确定。

#define N 4
long get_element(long a[N][N], long i, long j) {
  return a[i][j];
}
_get_element:  # R[rdi] = a, R[rsi] = i, R[rdx] = j
        salq    $5, %rsi             # R[rsi] = 8 * N * i
        addq    %rsi, %rdi           # R[rdi] = a + 8*N*i
        movq    (%rdi,%rdx,8), %rax  # M[a + 8*N*i + 8*j]
        ret

其中 8 * N 的值可在编译期确定为 32,因此(即使优化等级很低,也)可以被优化为(比乘法更快的)移位运算。

8.5. 长度可变的数组

ISO C99 引入了长度可变的数组。

long get_element(long n,/* 必须紧跟在 n 之后 */long a[n][n],
                 long i, long j) {
  return a[i][j];
}
_get_element:  # R[rdi] = n, R[rsi] = a, R[rdx] = i, R[rcx] = j
        imulq   %rdi, %rdx           # R[rdx] = n * i
        leaq    (%rsi,%rdx,8), %rax  # R[rax] = a + 8*n*i
        movl    (%rax,%rcx,8), %rax  # R[rax] = M[a + 8*n*i + 8*j]
        ret

其中 8 * n 的值无法在编译期确定,故无法像长度固定的数组那样用移位运算代替。

9. 异质数据结构

9.1. struct

结构体 (struct) 是一种异质的 (heterogeneous) 数据类型:

  • 它(通常)是以不同类型的对象为成员的容器。
  • 各成员在(虚拟)内存中按声明的顺序分布,但不一定连续分布。
  • 通过名称 (name) 访问每个成员所需的时间大致相同。

在 C 代码中,结构体(类型)用 struct 关键词来定义:

struct node_t {
  int a[4];
  long i;
  struct node_t *next;
};

struct 含三个成员(类型为 int[4] 的数组、类型为 long 的整数、类型为 struct node_t * 的指针),各成员在(64-bit 系统)内存中的分布如下:

| a[0]  | a[1]  | a[2]  | a[3]  |       i       |     next      |
 ^       ^       ^       ^       ^               ^               ^
+0      +4      +8      +12     +16             +24             +32

编译所得的汇编码中看不到成员名称,访问成员的操作全部被转化为地址偏移操作:

void set_val(struct node_t *node, int val) {
  while (node) {
    long i = node->i;
    node->a[i] = val;
    node = node->next;
  }
}
_set_val:  # R[rdi] = node, R[rsi] = val
L2:  # loop:
        testq   %rdi, %rdi           # node == 0?
        je      L4
        movq    16(%rdi), %rax       # i = node->i
        movl    %esi, (%rdi,%rax,4)  # node->a[i] = val
        movq    24(%rdi), %rdi       # node = node->next
        jmp     L2
L4:  # node == 0
        ret

以上成员无缝分布struct 并不是普遍的,更一般的 struct 需按数据对齐规则安排成员。

9.2. union

C 语言中的 unionstruct 有相同的语法 (syntax),但有不同的语义 (semantics) 及相应的机器级表示:

  • union 的所有成员共享同一段内存空间。
  • 整个 union 的长度 $\ge$ 最大成员的长度。

⚠️ 该机制弱化了编译器的类型检查功能,很容易导致错误,应尽量少用。

union 可用于定义相似的数据类型。若某种二叉树的

  • 叶结点(只)含两个 double 成员
  • 非叶结点(只)含两个指针成员

则结点类型有两种典型的定义方式:

struct node_s {
  struct {
    struct node_s *left;
    struct node_s *right;
  } children;
  double data[2];
};
union node_u {
  struct {
    union node_u *left;
    union node_u *right;
  } children;
  double data[2];
};

前者需要 32 字节,后者只需要 16 字节;但后者无法通过成员判断其是否为叶结点(前者可由 leftright 是否为空判断),为此需引入额外的成员:

typedef enum { N_LEAF, N_INTERNAL } nodetype_t;
struct node_t {
  union {
    struct {
      struct node_t *left;
      struct node_t *right;
    } internal;
    double data[2];
  } info;
  nodetype_t type;
};

该方案每个结点的大小为 16 + 4 + 4 == 24 字节,其中最后 4 个字节不存储数据,仅用于数据对齐

union 的另一个用处是获取其他类型的字节表示:

#include <stdlib.h>
#include <stdio.h>
unsigned long double2bits(double d) {
  union {
    double d;
    unsigned long u;
  } temp;
  temp.d = d;
  return temp.u;
}
int main(int argc, char* argv[]) {
  double x = atof(argv[1]);
  printf("%g's byte representation is\n", x);
  unsigned long l = double2bits(x);
  int shift = 64;
  for (int i = 0; i != sizeof(unsigned long); ++i) {
    // for each byte:
    printf(" ");
    for (int j = 0; j != 8; ++j) {
      printf("%ld", l >> (--shift) & 1);
    }
  }
  assert(shift == 0);
  printf("\n");
}

这种用法可以被用来检测系统的字节顺序 (byte order)

  • 最重要的 (the most significant) 字节的地址最小,则为大端 (big endian) 系统。
  • 最次要的 (the least significant) 字节的地址最小,则为小端 (little endian) 系统。
#include <stdio.h>
int main() {
  union {
    unsigned char c[8];
    unsigned short s[4];
    unsigned int i[2];
    unsigned long l[1];
  } x;
  for (int j = 0; j < 8; j++) {
    x.c[j] = 0xf0 + j;
  }
  printf(" char[0, 8) == [0x%x, 0x%x, 0x%x, 0x%x, 0x%x, 0x%x, 0x%x, 0x%x]\n",
         x.c[0], x.c[1], x.c[2], x.c[3], x.c[4], x.c[5], x.c[6], x.c[7]);
  printf("short[0, 4) == [0x%x, 0x%x, 0x%x, 0x%x]\n",
         x.s[0], x.s[1], x.s[2], x.s[3]);
  printf("  int[0, 2) == [0x%x, 0x%x]\n", x.i[0], x.i[1]);
  printf(" long[0, 1) == [0x%lx]\n", x.l[0]);
}
# on big endian system:
 char[0, 8) == [0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7]
short[0, 4) == [0xf0f1, 0xf2f3, 0xf4f5, 0xf6f7]
  int[0, 2) == [0xf0f1f2f3, 0xf4f5f6f7]
 long[0, 1) == [0xf0f1f2f3f4f5f6f7]
# on little endian system:
 char[0, 8) == [0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7]
short[0, 4) == [0xf1f0, 0xf3f2, 0xf5f4, 0xf7f6]
  int[0, 2) == [0xf3f2f1f0, 0xf7f6f5f4]
 long[0, 1) == [0xf7f6f5f4f3f2f1f0]

9.3. 数据对齐

一般的 struct 按以下规则对齐 (align) 成员:

  • 各成员按声明的顺序分布。
  • 大小为 K 字节的初等(非聚合类型)成员,其首地址必须是 K 的整数倍。
  • 整个 struct首地址长度必须是其最大初等成员大小的整数倍。

因此整个 struct 的大小可能大于其全部成员大小之和。例如:

struct X1 {
  short s;
  int i[2];
  double d;
};

各成员在内存中的分布为:

|s  |###|i[0]   |i[1]   |#######|d              |
 ^   ^   ^       ^       ^       ^               ^
+0  +2  +4      +8      +12     +16             +24

其中 [2,4)[12,16) 这两段内存不存储数据,而只是用来满足 i[0]d 的对齐要求。重新安排成员顺序:

struct X2 {
  double d;
  int i[2];
  short s;
};

则各成员在内存中的分布为:

|d              |i[0]   |i[1]   |s  |###########|
 ^               ^       ^       ^   ^           ^
+0              +8      +12     +16 +18         +24

其中 [18,24) 不存储数据,只是用来满足整个 struct 的对齐要求。

为节省存储空间,可采取贪心策略 (greedy strategy),即越长的成员越靠前声明。例如:

struct Y1 {
  short s1;
  double d;
  short s2;
};
struct Y2 {
  double d;
  short s1;
  short s2;
};

二者的成员在内存中的分布分别为:

|s1 |###########|d              |s2 |###########|
 ^   ^           ^               ^   ^           ^
+0  +2          +8              +16 +18         +24

|d              |s1 |s2 |#######|
 ^               ^   ^   ^       ^
+0              +8  +10 +12     +16

10. 结合控制与数据

10.1. 理解指针

C 语言的指针 (pointer) 是对(虚拟)内存读写操作的一种抽象。

指针的类型与值

  • 每个指针都有一个与之关联的类型,表示所指对象的类型。
    • 类型为 T* 的指针 指向 类型为 T 的对象。
    • 类型为 void* 的指针可以指向任意类型的对象,使用前必须被显式或隐式地转换 (cast) 为具体类型。
  • 每个指针都有一个值 (value),表示所指对象在(虚拟)内存中的位置(地址)。
    • 未初始化的指针可能有任意值。
    • 值为 NULL0 的指针不指向任何对象。
  • 对一个指针进行(显式或隐式)类型转换,只改变它的类型,不改变它的值。
    • 在相应的汇编码中,没有指针类型转换指令,但可以从指针加减法的放大倍数上看出影响。

与指针有关的运算符

  • 取地址运算符 & 作用在左值表达式 (lvalue expression) 上,得到一个地址值。
    • 左值表达式是指可以出现在赋值运算符左侧的表达式。
    • 在相应的汇编码中,表现为 lea 指令。
  • 解引用运算符 * 作用在指针上,得到所指对象的一个左值引用 (lvalue reference)
    • 在相应的汇编码中,表现为内存型运算数

用作指针的数组或函数

  • 数组与指针关系紧密。
    • 数组名可以被当作指针使用。
    • 数组表达式 a[i] 与指针表达式 *(a+i) 完全等价。
  • 指针可以指向函数
    • 函数类型由其形参类型及返回类型决定。
    • 函数名可以用来给这种类型的指针赋值。

10.2. 使用 GDB 调试器

见《断点调试》。

10.3. 越界访问与缓冲区溢出

C 标准库的文件读写字符串处理模块提供了一些不安全的字符串接口(函数或预定义宏):

#include <stdio.h>
char* gets(char* dest)
#include <string.h>
char* stpcpy(char* dest, const char* src);
char* strcat(char* s1, const char* s2);
int sprintf(char* dest, const char* format/* 含 %s */, ...);

这些接口不检查(dests1 所指的)目标空间是否足够大,使用不当会造成缓冲区溢出,曾经是系统安全漏洞的一大来源。

例如以下从 stdin 读取任意长度字符串的接口:

char *gets(char *dest) { /* Declared in header <stdio.h> */
  int c;
  char *curr = dest;
  while ((c = getchar()) != '\n' && c != EOF)
    *curr++ = c;
  if (c == EOF && curr == dest) /* No characters read */
    return NULL;
  *curr++ = '\0'; /* Terminate string */
  return dest;
}

典型使用场景:

void echo() { /* Read input line and write it back */
  char buffer[8]; /* Too small! */
  gets(buffer);
  puts(buffer);
}

编译时加上 -fno-stack-protector 选项,得以下汇编码:

echo:
  subq	$24, %rsp
  movq	%rsp, %rdi
  call	gets
  movq	%rsp, %rdi
  call	puts
  addq	$24, %rsp
  ret
  • 第一条指令在运行期栈内为函数 echo 分配了长度为 24 字节的帧。执行这条指令后,函数 echo 的返回地址位于 R[rsp]+24 为首的 8 字节中。
  • 第二、三条指令说明 buffer 位于 R[rsp]R[rsp]+7 这 8 字节中,而 R[rsp]+8R[rsp]+23 这段空间没有被用到。
  • gets 无法判断输入是否过长,有可能发生越界:
    • 若有效输入不多于 7 个字符,则一切正常。
    • 若有效输入介于 8 到 23 个字符之间,则 echo 帧内的剩余空间会被污染,但 echo 的返回地址未被修改,故 echo 还能正确返回。
    • 若有效输入不少于 24 个字符,则 echo 的返回地址被破坏,故 echo 无法正确返回。

👉 使用含长度检查的接口可避免上述缓冲区溢出:

#include <stdio.h>
char* fgets(char* dest, int n, FILE* stream);
#include <string.h>
char* stpncpy(char* dest, const char* src, size_t n);
char* strncat(char* s1, const char* s2, size_t n);
int snprintf(char* dest, size_t n, const char* format/* 含 %s */, ...);

10.4. 阻断缓冲区溢出攻击

缓冲区溢出可能被用来实施攻击:

  • 以字符串形式输入一段可执行程序(例如启动命令行终端)的编码。
  • 利用缓冲区溢出,将原来的返回地址篡改为上述可执行代码段的起始地址。

地址空间布局随机化

攻击者要成功篡改返回地址,需掌握目标系统的运行期栈信息 —— 这在早期并不困难,攻击者只要能获得目标系统的软件版本,就能在本地推算出这一信息。

栈随机化 (stack randomization) 技术(在创建新进程时)令运行期栈的起始位置随机变化,因此可以在一定程度上避免上述漏洞。现代 Linux 默认采用这项技术,在 Linux 系统上运行如下程序即可看出效果:

/* stack_demo.c */
int main() {
  long local;
  printf("local at %p\n", &local);
  return 0;
}
$ ./stack_demo
local at 0x7ffee86e4820
$ ./stack_demo
local at 0x7ffee22bd820
$ ./stack_demo
local at 0x7ffeeda9f820

更一般的,有地址空间布局随机化 (address-space layout randomization, ASLR) 技术 —— 它为整个地址空间的所有组成片段(程序代码、库代码、运行期栈、全局变量、堆)都引入了随机性,从而进一步增加了攻击者推算出所需地址的难度。

然而,该技术并不能彻底解决缓冲区溢出攻击。nop 滑板 (nop sled) 就是一种常见的破解方案:

  • 在攻击代码前插入一段 nop 序列,即以 nop 指令(意为 no operation)的编码(例如 0x90)不断重复而构成的序列。
  • 只要(被篡改的)返回地址指向这段 nop 序列中的任何一个 nop 指令,则程序控制权将滑行 (slide) 至攻击代码。
  • 若运行期栈的起始地址有 $2^{N}$ 种概率相同的情形,且 nop 序列的长度为 $2^{L}$ 字节(单次攻击覆盖 $2^{L}$ 种情形),则每 $2^{N-L}$ 次攻击即可期望命中 $1$ 次。

栈保护器(金丝雀)

过去的煤矿曾利用金丝雀 (canary) 是否存活来检测巷道内的危险气体是否超标。

仍以函数 echo() 为例,编译时不加 -fno-stack-protector 选项,得以下汇编码:

echo:
  subq	$24, %rsp
  movq  %fs:40, %rax  # 从内存中取出 canary
  movq  %rax, 8(%rsp) # 存放在 buffer 后面
  xorl  %eax, %eax
  movq	%rsp, %rdi
  call	gets
  movq	%rsp, %rdi
  call	puts
  movq  8(%rsp), %rax # 将 buffer 后的 canary 取出
  xorq  %fs:40, %rax  # 与内存中的原始值作比较
  je    .L9
  call  __stack_chk_fail
.L9:
  addq	$24, %rsp
  ret

其中 %fs:40 可以理解为从内存中读取的随机值(几乎不可能被攻击者猜到)。该机制只引入了一点点(读取、比较 canary 的)性能开销,便(几乎)能阻断所有缓冲区溢出攻击。

限制可执行代码的地址范围

虚拟内存空间在逻辑上被划分为若干页面 (page),每页含 2048 或 4096 字节。多数系统支持为各页赋予不同的访问权限:

  • 三种权限:可读 (Readable)可写 (Writable)可执行 (eXecutable)
  • 过去的 x86 架构将 R 与 X 用同一个权限位(类似于条件码)表示,故无法区分一段字节是可读数据还是可执行代码。
  • 现代的 64-bit 处理器均引入了名为 NX 的权限位,用于表示当前页不可执行 (Not eXecutable, NX)。只要栈空间所在页被标记了 NX 权限,植入其中的攻击代码便无法被执行。

此技术没有额外的性能损失(优于“金丝雀”),但无法抵御 ROP (Return Oriented Programming) 攻击 —— 此技术利用可执行指令片段拼接出攻击指令,详见《Attack Lab》。

10.5. 支持长度可变的帧

使用库函数 void* alloca(size_t n)长度可变的数组,都有可能使所在帧的长度可变(无法在编译期确定)。

long vframe(long n, long idx, long *q) {
  long i;
  long *p[n];
  p[0] = &i;
  for (i = 1; i < n; i++)
    p[i] = q;
  return *p[idx];
}

其中

  • 局部变量 i 要有地址,故必须存储在内存(栈)中。
  • 局部数组 p 的长度 n 到运行期才能确定,故其所在帧的长度可变。

长度可变的帧依然遵循运行期栈的一般规则:

  • 栈顶地址存储在 %rsp 中,且随着栈内数据的增加而减小。
  • 借助 sub 指令,将 %rsp 减小为新值,实现帧空间的分配。
  • 借助 mov 指令,将 %rsp 重置为旧值,实现帧空间的释放。

故函数 vframe() 的入口与出口分别被编译为

pushq %rbp # 使用 R[rbp] 前需先保存其旧值
movq %rsp, %rbp # 用 R[rbp] 保存 R[rsp] 的旧值
# ...
leave # 恢复 R[rbp] 及 R[rsp]
ret

其中 leave 是以下两条指令的简写:

movq %rbp, %rsp # 释放当前帧
popq %rbp # 恢复 R[rbp] 的旧值

紧接在入口之后的是分配局部变量 i 及数组 p 的指令:

subq $16, %rsp  # 为 i 分配空间,不小于 8 字节
leaq 22(,%rdi,8), %rax # 长度至少为 8 * n 字节
andq $-16, %rax # 用 0xfff0 将 R[rax] 过滤为 16 的整数倍
subq %rax, %rsp # 为 p 分配空间

其中 R[rax] == 8 * n + (n % 2 ? 8 : 16) 不小于 8 * n 且为 16 的整数倍。

紧随其后的是循环初始化指令:

leaq 7(%rsp), %rax   # R[rax] = R[rsp] + 7
shrq $3, %rax        # R[rax] /= 8
leaq 0(,%rax,8), %r8 # R[r8 ] = &p[0]
movq %r8, %rcx       # R[rcx] = &p[0]

此时 R[r8]R[rcp] 均存储了数组 p 的首地址,其值不小于 R[rsp] 且为 8 的整数倍,这体现了(长度为 8 字节的)指针型数组成员的数据对齐规则。

11. 浮点代码

SIMD (Single Instruction Multiple Data)

ISA Years Register
MMX (MultiMedia eXtensions) 1997 (Pentium P5) 64-bit MM
SSE (Streaming Simd Extensions) 1999 (Pentium 3) 128-bit XMM
SSE2 2000 (Pentium 4) 128-bit XMM
AVX (Advanced Vector Extensions) 2008 (Sandy Bridge) 256-bit YMM
AVX2 2013 (Core i7 Haswell) 256-bit YMM

GCC 生成 AVX2 代码需开启 -mavx2 选项。

11.1. 浮点移动及转换

后缀中的 sd 分别表示单精度 (Single-precision)双精度 (Double-precision)

移动:

  • vmovs[sd] 用于内存与寄存器之间的移动,其中紧跟在 mov 后面的 s 表示标量 (scalar) 操作。
  • vmovap[sd] 用于寄存器之间的移动,其中 ap 分别表示对齐 (aligned)打包 (Packed)

整型与浮点型的转换:

  • vcvtts[sd]2si(q?)convert with truncation [single double] precision float to (quad word) integer.
  • vcvtsi2s[sd](q?)convert with truncation (quad word) integer to [single double] precision float.
    • 这是一组三元指令,e.g. vcvtsi2sdq %rax, %xmm1, %xmm1

浮点型之间的转换:

# single to double
vunpcklps  %xmm0, %xmm0, %xmm0  # Replicate first vector element
vcvtps2pd  %xmm0, %xmm0         # Convert two vector elements to double
# double to single
vmovddup   %xmm0, %xmm0  # Replicate first vector element
vcvtpd2psx %xmm0, %xmm0  # Convert two vector elements to single

11.2. 函数中的浮点代码

XMM 寄存器是同名 YMM 寄存器的低 128 bits。

XMM 寄存器分工:

  • 浮点型返回值由 %xmm0 传递。
  • %xmm0%xmm7 负责传递前 8 个浮点型实参。
  • 所有 XMM 寄存器都是主调函数保存的,被调函数可直接使用。

11.3. 浮点算术运算

vOPs[sd]  S1, S2, D  # OP = add|sub|mul|div|max|min
sqrts[sd] S1,     D

11.4. 浮点型常量

AVX 指令不支持浮点型即时数 (immediate),故浮点型常量需借由寄存器间接表示:

# double cel2fahr(double cel) { return 1.8 * cel + 32.0; }
# cel in %xmm0
cel2fahr:
  vmulsd .LC2(%rip), %xmm0, %xmm0  # * 1.8
  vaddsd .LC3(%rip), %xmm0, %xmm0  # + 32.0
  ret
.LC2:
  .long 3435973837  #  Low-order 4 bytes of 1.8
  .long 1073532108  # High-order 4 bytes of 1.8
.LC3:
  .long 0           #  Low-order 4 bytes of 32.0
  .long 1077936128  # High-order 4 bytes of 32.0

11.5. 浮点按位运算

vOPp[sd] S1, S2, D  # OP = xor|and

11.6. 浮点比较运算

vucomis[sd] S1, S2  # S2 - S1
  • 若二者皆非 NaN,则与无符号整型的比较指令类似。
  • 若存在一个 NaN,则 CF = ZF = PF = 0,其中最后一个符号名为 Parity Flag,相应地有 jp 指令。

11.7. 浮点代码小结

AVX2 可以在打包的 (packed) 数据上完成并行计算。 GCC 提供了一些 C 语言扩展,用于支持向量数据运算。