"> "> 计算机组成原理-程序的机器级表示 | Yufei Luo's Blog

计算机组成原理-程序的机器级表示

寄存器

  • 通用寄存器:X64将x86的8个通用寄存器扩展为64位,并且增加8个新的64位寄存器。64位寄存器命名以“r”开始,例如:eax扩展为64位就是rax,8个新的64位寄存器命名为r8到r15。每个寄存器的低32位,16位,8位可以作为操作数直接寻址,这包括向esi这样的寄存器,以前他的低8位不可以直接寻址。下表说明了64位通用寄存器的低位部分在汇编语言中的命名。

    除了r8~r15这8个寄存器只能供64位程序使用外,其余8个可以只使用低32字节以兼容32位程序。生成1字节和2字节数字的指令会保持剩下的字节不变;生成4字节数字的指令会把高位4个字节置为0。

64-bit register Lower 32 bits Lower 16 bits Lower 8 bits 用途
rax eax ax al 返回值
rbx ebx bx bl 被调用者保存
rcx ecx cx cl 第4个参数
rdx edx dx dl 第3个参数
rsi esi si sil 第2个参数
rdi edi di dil 第1个参数
rbp ebp bp bpl 被调用者保存
rsp esp sp spl 栈指针
r8 r8d r8w r8b 第5个参数
r9 r9d r9w r9b 第6个参数
r10 r10d r10w r10b 调用者保存
r11 r11d r11w r11b 调用者保存
r12 r12d r12w r12b 被调用者保存
r13 r13d r13w r13b 被调用者保存
r14 r14d r14w r14b 被调用者保存
r15 r15d r15w r15b 被调用者保存
  • 8个64位媒体和浮点指针寄存器MMX0/FPR0-MMX7/FPR7

  • 16个256位媒体寄存器YMM0-YMM15(低128位为XMM0-XMM15)

  • 指令指针寄存器rip(低32位为eip)

  • 标志寄存器rflags(高32位为0,低32位Eflags与32位处理器相同)

    (1) 状态标志位

    • CF:进位标志位,当前加减运算时是否往前有进位;如果有进位则无符号运算结果溢出;
    • ZF:零标志位,结果是0,ZF位是1;
    • SF:符号标志位,最高位为1则SF=1;
    • PF:奇偶标志位,最低字节中1的个数为奇数PF=0;
    • OF:溢出标志位,有符号数运算结果是否有溢出;当加数、被加数、和的最高位都相等则OF=0;当加数、被加数最高位相等但与结果最高位不等,则OF=1;当被加数与加数符号位不等时,OF=0;(溢出表示有符号数运算结果超出范围,进位表示无符号数运算结果超出范围

    (2) 控制标志位

    • 方向标志位DF:用汇编语言可以设置其值,控制数组操作的方向;
    • 中断允许标志位IF:是否允许外源设备产生中断请求暂时终止CPU运行,当IF=1时,那按鼠标按键盘CPU可以响应,否则不行;最简单的模拟机器死机,就可以用CLI(不允许外源设备中断),STI(允许);
    • 陷阱标志位TF(Trag Flag):开发VS等调试软件才会用到;
  • 调试寄存器DR0-DR7

  • 浮点寄存器ST0-ST7

  • 段寄存器:

    • CS:代码段的段选择符,代码段保存正在执行的指令。处理器从代码段读取指令时,使用有CS寄存器中的段选择符与EIP寄存器联合构成的逻辑地址。EIP保存要执行的下一条指令在代码段中的偏移量。CS寄存器不能有应用程序显式地的加载。相反,可以通过某些指令或处理器内部操作隐式地加载。这些指令/内部操作,例如过程调用,中断处理,或者任务切换,用于改变程序的执行流,从而导致更新CS寄存器。

    • DS/ES/FS/GS:指向四个数据段。多个数据段的存在允许高效地且安全地访问不同的数据结构类型。

    • SS:栈段的段选择符,这里栈段用于存储程序/任务/当前正在执行的处理器程序的栈帧。所有的栈操作都使用SS栈段寄存器来定位栈段。与CS代码段寄存器不同,SS寄存器可以显式地加载,这样就允许应用程序建立多个栈段,并在这些段间切换。

      在64位模式下,处理器把CS/DS/ES/SS的段基都当作0,忽略与之关联的段描述符中的段基地址,相当于分段机制被禁用。这样就为代码/数据/栈创建了平坦的地址空间。但是FS/GS段寄存器是例外。在计算线性地址时,这两个段寄存器可能被用作额外的基址寄存器(当寻址局部数据或寻址某些操作系统数据结构时)。

数据传输

数据格式

C语言数据格式,对应大小,及其汇编代码后缀如下表所示。GCC生成的汇编代码会有一个字符的后缀,代表操作数的大小。 C声明|Intel数据类型|汇编代码后缀|大小(字节) :-:| :-:| :-:| :-: char|字节|b|1 short|字|w|2 int|双字|l|4 long|四字|q|8 char*|四字(64位,如果32位为双字)|q|8 float|单精度|s|4 double|双精度|l|8

操作数指示符

在下面的表格中,$Imm表示立即数,\(r_a\)表示某个寄存器,\(R[r_a]\)表示引用某个寄存器的值,M[Addr]表示引用内存的数值,Addr有多种不同的寻址模式,允许不同形式的内存引用。\(r_b\) 表示基址寄存器,\(r_i\) 表示变址寄存器,s表示比例因子,s必须为1,2,4或8 类型|格式|操作数值|名称 :-:|:-:|:-:|:-: 立即数|$Imm|Imm|立即数寻址 寄存器|\(r_a\)|\(R[r_a]\)|寄存器寻址 存储器|Imm|M[Imm]|绝对寻址 存储器|\((r_a)\)|\(M[R[r_a]]\)|间接寻址 存储器|\(Imm(r_b)\)|\(M[Imm+R[r_b]]\)|(基址+ 偏移量)寻址 存储器|\((r_b, r_i)\)|\(M[R[r_b]+R[r_i]]\)|变址寻址 存储器|\(Imm(r_b, r_i)\)|\(M[Imm+R[r_b]+R[r_i]]\)|变址寻址 存储器|\((, r_i, s)\)|\(M[R[r_i]*s]\)|比例变址寻址 存储器|\(Imm(, r_i, s)\)|\(M[Imm+R[r_i]*s]\)|比例变址寻址 存储器|\((r_b, r_i, s)\)|\(M[R[r_b]+R[r_i]*s]\)|比例变址寻址 存储器|\(Imm(r_b, r_i, s)\)|\(M[Imm+R[r_b]+R[r_i]*s]\)|比例变址寻址

在32位模式下寻址时,如果基址寄存器是%ebp或%esp,默认的段寄存器为ss(对应于栈操作);否则默认的段寄存器为ds(注意32位下访问存储单元时,没有16位下寄存器的使用限制)。如果显式指定段寄存器,则使用指定的段寄存器。

如果是浮点数,操作数不能是立即数,编译器需要为所有的浮点数常量分配并初始化存储空间,然后代码把这些值从内存读入。

数据传送指令

整数

  1. 不做任何变化的数据传递

    指令 功能
    mov S, D 将S的值赋给D

    根据操作数大小不同分为movb,movw,movl,movq

    S为一个立即数,存储在寄存器或者内存中

    D为一个位置,是寄存器或内存

    x86-64的限制:传送指令的两个操作数不能都指向内存位置,需要借助寄存器

  2. 零扩展数据传送

    指令 功能
    movz S, R 将S的内容通过零扩展赋给R

    根据操作数大小分为movzbw, movzbl, movzwl, movzbq, movzwq

  3. 符号扩展数据传送

    指令 功能
    movs S, R 将S的内容通过符号扩展赋给R

    根据操作数大小分为movsbw, movsbl, movswl, movsbq, movswq, movslq

    cltq:直接将%eax寄存器中的数字做符号扩展赋给%rax

  4. 压入和弹出栈数据

    指令 功能
    pushq S 将数据压入栈中,实际效果等同于R[%rsp]←R[%rsp]-8, M[R[%rsp]]←S
    popq D 将栈顶数据弹出,实际效果等同于D←M[R[%rsp]],R[%rsp]←R[%rsp]+8

    注意栈是从上往下增长的!

浮点数

  1. 浮点传送指令

    指令 目的 描述
    vmovss M32 X 传送单精度数
    vmovss X Mn 传送单精度数
    vmovsd M64 X 传送双精度数
    vmovsd X M64 传送双精度数
    vmovaps X X 传送对齐的封装好的单精度数
    vmovapd X X 传送对齐的封装好的双精度数

    表中X表示XMM寄存器,M表示内存

  2. 浮点转换指令

    指令 目的 描述
    vcvttss2si X/M32 R32 用截断的方法把单精度数转换成整数
    vcvttsd2si X/M64 R32 用截断的方法把双精度数转换成整数
    vcvttss2siq X/M32 R64 用截断的方法把单精度数转换成四字整数
    vcvttsd2siq X/M64 R64 用截断的方法把双精度数转换成四字整数
    指令 源1 源2 目的 描述
    vcvtsi2ss M32/R32 X X 把整数转换成单精度数
    vcvtsi2sd M32/R32 X X 把整数转换成双精度数
    vcvtsi2ssq M64/R64 X X 把四字整数转换成单精度数
    vcvtsi2sdq M64/R64 X X 把四字整数转换成双精度数

    在三操作数浮点转换指令中,第二个操作数可以忽略。在最常见的使用场景中,第二个源和目的操作数都是一样的。

控制语句

条件码

常用的条件码包括:

  • CF:进位标志,最近的操作使最高位产生进位。可用来检测无符号操作的溢出
  • ZF:零标志。最近的操作结果为0
  • SF:符号标志,最近操作得到结果为负数
  • OF:溢出标志,最近的操作导致补码溢出

条件码的设置

整数

除了leaq指令,所有整数的算数和逻辑操作都会设置条件码

还有两类指令,只设置条件码,但是却不修改任何其他寄存器(二者分别对应了字节,字,双字和四字的四类指令):

指令 基于
cmp S, T 基于T-S进行比较(即二者的大小关系为T ? S)
test S, T 基于S&T进行比较

浮点数

指令 基于 描述
ucomiss S1, S2 \(S_2-S_1\) 比较单精度值
ucomisd S1, S2 \(S_2-S_1\) 比较双精度值

浮点数在比较之后,条件码的设置条件如下表所示:

顺序S2:S1 CF ZF PF
无序的(S1和S2其中一个为NaN) 1 1 1
S2<S1 1 0 0
S2=S1 0 1 0
S2>S1 0 0 0

条件码的访问

条件码通常不会直接读取,常用的使用方法包括:

  1. 根据条件码的某些组合,将一个字节设置为0或1
  2. 条件跳转到程序的某个其他部分
  3. 有条件地传送数据

上述第一条对应了set这一类指令,set指令的目的操作数是低位单字节寄存器元素之一,或是一个字节的内存位置,指令会将这个字节设置为0或1

指令 同义名 效果 设置条件
sete D setz D←ZF 相等/零
setne D setnz D←~ZF 相等/零
sets D D←SF 负数
setns D D←~SF 非负数
setg D setnle D←~(SF^OF)&~ZF 有符号大于
setge D setnl D←~(SF^OF) 有符号大于等于
setl D setnge D←SF^OF 有符号小于
setle D setng D←(SF^OF)|ZF 有符号小于等于
seta D setnbe D←~CF&~ZF 无符号大于(超过)
setae D setnb D←~CF 无符号大于等于
setb D setnae D←CF 无符号小于(低于)
setbe D setna D←CF|ZF 无符号小于等于

表中效果一栏的助记可以用cmp S, T,即T-S的结果来理解:

  • 当S和T为无符号数时(无符号数的减法也是通过对减数求补码相加,结果按照无符号数来进行解释,参见https://www.zhihu.com/question/51493338):
    • T==S时ZF=1
    • 如果T<S,则运算结果会发生溢出,CF会被设置为1
    • 反之则CF为0
  • 当S和T为有符号数时
    • T==S时ZF=1
    • 如果T<S,可能OF=0&SF=1(未溢出),也可能OF=1&SF=0(发生溢出)
    • 如果T>S,可能OF=0&SF=0(未溢出),也可能OF=1&SF=1(发生溢出)

跳转指令

跳转指令会导致执行切换到程序中一个全新的位置。在汇编代码中,跳转目的地通常用一个标号指明。跳转指令包含无条件跳转和有条件跳转两类

跳转目标分为直接和间接两种,直接跳转是直接给出一个标号作为跳转目标,间接跳转是从寄存器或者内存中读取跳转位置

常见的跳转指令有:

指令 同义名 跳转条件 描述
jmp Label 1 直接跳转
jmp *Operand 1 间接跳转
je Label jz ZF 相等/零
jne Label jnz ~ZF 不相等/非零
js Label SF 负数
jns Label ~SF 非负数
jg Label jnle ~(SF^OF)&~ZF 有符号大于
jge Label jnl ~(SF^OF) 有符号大于等于
jl Label jnge SF^OF 有符号小于
jle Label jng (SF^OF)|ZF 有符号小于等于
ja Label jnbe ~CF&~ZF 无符号大于
jae Label jnb ~CF 无符号大于等于
jb Label jnae CF 无符号小于
jbe Label jna CF|ZF 无符号小于等于

这些转移指令中,又分为长转移和短转移:短转移使用short关键字,转移范围被限制在当前指令的-128~127字节范围内;长转移对转移范围无限制。因此,短转移的机器码长度也相应会比长转移要短。

条件传送

指令 同义名 跳转条件 描述
cmove S, R cmovz ZF 相等/零
cmovne S, R cmovnz ~ZF 不相等/非零
cmovs S, R SF 负数
cmovns S, R ~SF 非负数
cmovg S, R cmovnle ~(SF^OF)&~ZF 有符号大于
cmovge S, R comvnl ~(SF^OF) 有符号大于等于
cmovl S, R cmovnge SF^OF 有符号小于
cmovle S, R cmovng (SF^OF)|ZF 有符号小于等于
cmova S, R cmovnbe ~CF&~ZF 无符号大于
cmovae S, R cmovnb ~CF 无符号大于等于
cmovb S, R cmovnae CF 无符号小于
cmovbe S, R cmovna CF|ZF 无符号小于等于

程序实例

条件分支

对于C++中的if-else条件分支语句,汇编语言中的实现通常类似于下面形式的伪代码:

1
2
3
4
5
6
7
8
	t = test-expr;
if (!t)
goto false;
then-statement
goto done;
false:
else-statement
done:

例如,我们在C++中写这样的主函数:

1
2
3
4
5
6
7
8
9
int main()
{
int a = 5;
int b = 4;
int c;
if (a > b) c = 1;
else c = 0;
return 0;
}

在编译之后反汇编,得到的汇编代码如下(删除了一些参数入栈出栈的操作):

1
2
3
4
5
6
7
8
9
00007FF65585173C | mov     dword ptr ss:[rbp+4],5                                 
00007FF655851743 | mov dword ptr ss:[rbp+24],4
00007FF65585174A | mov eax,dword ptr ss:[rbp+24]
00007FF65585174D | cmp dword ptr ss:[rbp+4],eax
00007FF655851750 | jle consoleapplication3.7FF65585175B
00007FF655851752 | mov dword ptr ss:[rbp+44],1
00007FF655851759 | jmp consoleapplication3.7FF655851762
00007FF65585175B | mov dword ptr ss:[rbp+44],0
00007FF655851762 | xor eax,eax

对于一些比较特殊的条件分支,可能会使用数据的条件传送策略,这种方法计算一个条件操作的两种结果,然后再根据条件是否满足从中选取一个。在一些受限制的情况下,这种策略可行。条件传送指令更加符合现代处理器流水线操作的性能特性。

循环

在C++中,循环语句有3种关键词:do while,while和for 。我们使用这三种不同的关键词实现同样的功能,并对它们产生的汇编语言代码进行比较

do while

1
2
3
4
5
6
7
8
9
10
11
int main()
{
int sum = 0;
int i = 0;
do
{
sum += i;
i++;
} while (i<100);
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
00007FF663CF1559 | mov     dword ptr ss:[rbp],0         
00007FF663CF1560 | mov dword ptr ss:[rbp+4],0
00007FF663CF1567 | mov eax,dword ptr ss:[rbp+4]
00007FF663CF156A | mov ecx,dword ptr ss:[rbp]
00007FF663CF156D | add ecx,eax
00007FF663CF156F | mov eax,ecx
00007FF663CF1571 | mov dword ptr ss:[rbp],eax
00007FF663CF1574 | mov eax,dword ptr ss:[rbp+4]
00007FF663CF1577 | inc eax
00007FF663CF1579 | mov dword ptr ss:[rbp+4],eax
00007FF663CF1580 | jl consoleapplication3.7FF663CF1567

从中可以看出,do-while语句产生的汇编代码大致相当于是如下的C++伪代码:

1
2
3
4
5
loop:
body-statement
t = test-expr;
if (t)
goto loop;

while

1
2
3
4
5
6
7
8
9
10
11
int main()
{
int sum = 0;
int i = 0;
while (i<100)
{
sum += i;
i++;
}
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
00007FF69E9E1559	mov dword ptr ss:[rbp],0
00007FF69E9E1560 mov dword ptr ss:[rbp+4],0
00007FF69E9E1567 cmp dword ptr ss:[rbp+4],64
00007FF69E9E156B jge consoleapplication3.7FF69E9E1584
00007FF69E9E156D mov eax,dword ptr ss:[rbp+4]
00007FF69E9E1570 mov ecx,dword ptr ss:[rbp]
00007FF69E9E1573 add ecx,eax
00007FF69E9E1575 mov eax,ecx
00007FF69E9E1577 mov dword ptr ss:[rbp],eax
00007FF69E9E157A mov eax,dword ptr ss:[rbp+4]
00007FF69E9E157D inc eax
00007FF69E9E157F mov dword ptr ss:[rbp+4],eax
00007FF69E9E1582 jmp consoleapplication3.7FF69E9E1567
00007FF69E9E1584 xor eax,eax
00007FF69E9E1586 lea rsp,qword ptr ss:[rbp+50]
00007FF69E9E158A pop rbp
00007FF69E9E158B ret

此时产生的汇编代码大致等同于如下的伪代码:

1
2
3
4
5
6
7
8
9
t = test-expr;
if (!t)
goto done;
loop:
body-statement
t = test-expr;
if (t)
goto loop;
done:

for

1
2
3
4
5
6
7
8
9
int main()
{
int sum = 0;
for(int i=0;i<100;i++)
{
sum += i;
}
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
00007FF7D1FE1559	mov dword ptr ss:[rbp],0
00007FF7D1FE1560 mov dword ptr ss:[rbp+4],0
00007FF7D1FE1567 jmp consoleapplication3.7FF7D1FE1571
00007FF7D1FE1569 mov eax,dword ptr ss:[rbp+4]
00007FF7D1FE156C inc eax
00007FF7D1FE156E mov dword ptr ss:[rbp+4],eax
00007FF7D1FE1571 cmp dword ptr ss:[rbp+4],64
00007FF7D1FE1575 jge consoleapplication3.7FF7D1FE1586
00007FF7D1FE1577 mov eax,dword ptr ss:[rbp+4]
00007FF7D1FE157A mov ecx,dword ptr ss:[rbp]
00007FF7D1FE157D add ecx,eax
00007FF7D1FE157F mov eax,ecx
00007FF7D1FE1581 mov dword ptr ss:[rbp],eax
00007FF7D1FE1584 jmp consoleapplication3.7FF7D1FE1569
00007FF7D1FE1586 xor eax,eax
00007FF7D1FE1588 lea rsp,qword ptr ss:[rbp+50]
00007FF7D1FE158C pop rbp
00007FF7D1FE158D ret

此时产生的汇编语言大致等同于以下的伪代码:

1
2
3
4
5
6
7
8
9
10
11
	init-expr;
goto test;
loop:
update-expr;
test:
t=test-expr;
if(!t)
goto done;
body-statement;
goto loop;
done:

通过比较do while,while和for循环产生的汇编指令,我们发现它们都是通过条件测试和跳转指令组合起来实现循环效果,但是实现方法略有差异。

此外,while和for循环在编译时如果使用不同的优化等级时,可能会产生不同的汇编指令。参见1

switch语句

以如下的switch语句为例,编译后再反汇编得到汇编代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int main()
{
int a = 5;
int b = 1;
switch (a)
{
case 1:
b += 2;
break;
case 2:
b += a;
case 3:
b += 5;
break;
case 100:
b -= 10;
break;
default:
break;
}
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
00007FF6EC601559	mov dword ptr ss:[rbp],5
00007FF6EC601560 mov dword ptr ss:[rbp+4],1
00007FF6EC601567 mov eax,dword ptr ss:[rbp]
00007FF6EC60156A mov dword ptr ss:[rbp+48],eax
00007FF6EC60156D cmp dword ptr ss:[rbp+48],1
00007FF6EC601571 je consoleapplication3.7FF6EC601587
00007FF6EC601573 cmp dword ptr ss:[rbp+48],2
00007FF6EC601577 je consoleapplication3.7FF6EC601592
00007FF6EC601579 cmp dword ptr ss:[rbp+48],3
00007FF6EC60157D je consoleapplication3.7FF6EC60159F
00007FF6EC60157F cmp dword ptr ss:[rbp+48],64
00007FF6EC601583 je consoleapplication3.7FF6EC6015AA
00007FF6EC601585 jmp consoleapplication3.7FF6EC6015B3
00007FF6EC601587 mov eax,dword ptr ss:[rbp+4]
00007FF6EC60158A add eax,2
00007FF6EC60158D mov dword ptr ss:[rbp+4],eax
00007FF6EC601590 jmp consoleapplication3.7FF6EC6015B3
00007FF6EC601592 mov eax,dword ptr ss:[rbp]
00007FF6EC601595 mov ecx,dword ptr ss:[rbp+4]
00007FF6EC601598 add ecx,eax
00007FF6EC60159A mov eax,ecx
00007FF6EC60159C mov dword ptr ss:[rbp+4],eax
00007FF6EC60159F mov eax,dword ptr ss:[rbp+4]
00007FF6EC6015A2 add eax,5
00007FF6EC6015A5 mov dword ptr ss:[rbp+4],eax
00007FF6EC6015A8 jmp consoleapplication3.7FF6EC6015B3
00007FF6EC6015AA mov eax,dword ptr ss:[rbp+4]
00007FF6EC6015AD sub eax,A
00007FF6EC6015B0 mov dword ptr ss:[rbp+4],eax
00007FF6EC6015B3 xor eax,eax
00007FF6EC6015B5 lea rsp,qword ptr ss:[rbp+50]
00007FF6EC6015B9 pop rbp
00007FF6EC6015BA ret

从中我们发现,此时switch语句的汇编实现是通过一系列的比较与跳转指令实现的。

当case值的间隔比较小的时候,汇编也可能会采用case表的方式实现switch语句。

过程

概述

过程是软件中一种很重要的抽象。它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。然后,可以在程序中不同的地方调用这个函数。

假设过程P调用过程Q,Q执行完成后返回P,这些动作包括下面的一个或多个机制:

  • 传递控制:从P进入过程Q的时候,程序计数器需要设置为Q代码的起始地址,然后返回时将程序计数器设置为P中进入Q的下一条指令
  • 传递数据:P向Q提供参数,Q向P返回值
  • 分配和释放内存:在执行Q时,Q为局部变量分配空间,而在返回时必须释放这些空间。

在汇编中,转移控制使用如下两个指令:

1
2
call	Label 	//过程调用
ret //从过程调用中返回

运行时栈

C语言过程调用机制的一个关键特性在于使用了栈数据结构提供的后进先出的内存管理原则。在过程P调用过程Q的例子中,当Q在执行时,P以及所有在向上追溯到P的调用链中的过程,都是暂时被挂起的。当Q运行时,它只需要为局部变量分配新的存储空间,或者设置到另一个过程的调用。另一方面,当Q返回时,任何它所分配的局部存储空间都可以被释放。

因此,程序可以用栈来管理它的过程所需要的存储空间,栈和程序寄存器存放着传递控制和数据、分配内存所需要的信息。当P调用Q时,控制和数据信息添加到栈尾。当P返回时,这些信息会释放掉。

栈帧结构如下图所示:

栈结构

x86-64 过程能够递归地调用它们自身。每个过程调用在栈中都有它自己的私有空间,因此多个未完成调用的局部变量不会相互影响。此外,栈的原则很自然地就提供了适当的策略,当过程被调用时分配局部存储,当返回时释放存储。这种实现函数调用和返回的方法甚至对更复杂的情况也适用,包括相互递归调用。

如果一些函数需要的局部存储是变长的,则使用寄存器%rbp作为帧指针(基指针),从而在栈上灵活地分配空间。

栈上的局部存储

在一些情况下,局部数据必须存放在内存中,如:

  • 寄存器不足够存放本地数据
  • 对一个局部变量使用地址运算符 &
  • 某些局部变量是数组或结构

过程调用中的寄存器使用

大部分过程间的数据传送是通过寄存器的,当过程P调用过程Q时,P的代码必须首先把参数复制到适当的寄存器中。类似地,当Q返回到P时,P的代码可以访问寄存器%rax中的返回值。

在x86-64中,通过寄存器可以最多传递6个参数,而且这些寄存器的使用是有顺序的。多于6个的参数通过栈来传递。

操作数大小(位) 1 2 3 4 5 6
64 %rdi %rsi %rdx %rcx %r8 %r9
32 %edi %esi %edx %ecx %r8d %r9d
16 %di %si %dx %cx %r8w %r9w
8 %dil %sil %dl %cl %r8b %r9b

被调用者保存寄存器:%rbx, %rbp, %r12~%r15,当过程P调用过程Q时,Q必须保存这些寄存器的值。过程Q保存一个寄存器的值不变,要么就是根本不去改变它,要么就是把原始值压人栈中,改变寄存器的值,然后在返回前从栈中弹出旧值。

调用者保存寄存器,除了%rbx, %rbp, %r12~%r15和%rsp之外所有其他的寄存器,在过程P调用过程Q之后,Q可以随意修改这些寄存器,因此P需要在调用Q之前妥善处理这些寄存器中的值。

对于任何大于8字节,或者不是1、2、4、8字节的参数,都通过地址传递的方式来传递参数。

过程中的浮点代码

XMM寄存器用于向函数传递浮点参数,以及从函数返回浮点值,需要满足如下规则:

  • XMM寄存器%xmm0~%xmm7最多可以传递8个浮点参数,按照0~7的顺序使用,额外的浮点参数通过栈传递
  • 函数使用寄存器%xmm0来返回浮点值
  • 所有的XMM寄存器都是调用者保存的,被调用者可以任意使用
  • 注意指向浮点数指针使用通用寄存器传递

数据结构的汇编代码

数组

对于简单的一位数组\(T~A[N]\) ,起始位置为\(x_{A}\)。这一声明在内存中分配了一段连续区域,同时引入标识符\(A\),可以用来作为指向\(x_{A}\)的指针,数组元素便被存放在\(x_{A}+L\cdot i~(0\le i\le N-1)\)的位置。

假设T为int类型,要访问\(A[i]\),假设%rdx存放的是\(A\)的地址,%rcx存放的是\(i\)的值,则对应的汇编代码为movl (%rdx,%rcx,4),%eax。这一表达式等同于* (A+i)

多维数组可看作是多个一维数组的嵌套,对于数组T D[R][C],数组元素D[i][j]的内存地址为\(\& D[i][j]=x_{D}+L(C\cdot i+j)\). 假设从数组int A[5][3]中取元素A[i][j],其对应的汇编代码如下:

1
2
3
4
A in %rdi, i in %rsi, and j in %rdx
leaq (%rsi,%rsi,2),%rax //Compute 3i
leaq (%rdi,%rax,4),%rax //Compute xA+12i
movl (%rax,%rdx,4),%eax //Read from M[xA+12i+4j]

对于定长数组,编译器可能会对数组的操作自动做一些优化,从而减少数组索引时的乘法运算

异质数据结构

结构体

类似于数组的实现,结构的所有组成部分都存放在内存中一段连续的区域内,而指向结构的指针就是结构第一个字节的地址。编译器维护关于每个结构类型的信息,指示每个字段(field)的字节偏移。要产生一个指向结构内部对象的指针,我们只需将结构的地址加上该字段的偏移量。

联合

联合提供了一种方式,能够规避C 语言的类型系统,允许以多种类型来引用一个对象。联合声明的语法与结构的语法一样,只不过语义相差比较大。它们是用不同的字段来引用相同的内存块。

对于联合类型的指针,引用的都是数据结构的起始指针,同时一个联合总的大小等于它最大字段的大小。

数据对齐

许多计算机系统对基本数据类型的合法地址做出了一些限制,要求某种类型对象的地址必须是某个值K( 通常是2、4 或8)的倍数。这种对齐限制简化了形成处理器和内存系统之间接口的硬件设计。

无论数据是否对齐,X86-64硬件都能正确工作。不过,Intel还是建议要对齐数据以提高内存系统的性能。对齐原则是任何K字节的基本对象的地址必须是K的倍数。但是对于多媒体操作的SSE指令需要对齐操作,否则无法执行。

数学运算

整数

算数和逻辑操作

  1. 加载有效地址

    leaq:mov指令的变形,它的指令形式是从内存读取数据到寄存器。但是它实际上并没有引用内存,而是将有效地址写入到操作数,可以理解为C++中的&操作

    可以使用leaq指令完成一些简单的算术操作

  2. 一元和二元操作

    常见的一元操作:

    指令 操作
    inc D 自增1
    dec D 自减1
    neg D 自身取负
    not D 自身取非

    常见的二元操作:

    操作 描述
    add S, D D←D+S
    sub S, D D←D-S
    imul S, D D←D*S
    xor S, D D←D^S
    and S, D D←D&S
    or S, D D←D
  3. 移位操作

    操作 描述
    SAL k, D D←D<<k 算数左移
    SHL k, D D←D<<k 逻辑左移
    SAR k, D D←D>>(A)k 算数右移
    SHR k, D D←D>>(L)k 逻辑右移
  4. 特殊的算数操作

    操作 描述
    imulq S 有符号全乘法 R[%rdx]:R[%rax]←S*R[%rax] (%rdx存放高64位,%rax存放低64位)
    mulq S 无符号全乘法 R[%rdx]:R[%rax]←S*R[%rax] (%rdx存放高64位,%rax存放低64位)
    clto R[%rdx]:R[%rax]←符号扩展R[%rax],将其转换为8位
    idivq S 有符号除法 R[%rdx]←R[%rdx]:R[%rax] mod S, R[%rax]←R[%rdx]:R[%rax] / S
    divq S 无符号除法 R[%rdx]←R[%rdx]:R[%rax] mod S, R[%rax]←R[%rdx]:R[%rax] / S

在生成汇编语言时,一些编译器会使用lea和左移/右移指令对算数操作做优化;同时一些除法操作可能会使用magic number将其转换为乘法和移位等操作的组合。

浮点数

浮点运算操作

下表中的每条指令都有一个或两个源操作数\(S_1\)\(S_2\) 和目的操作数\(D\). \(S_1\)可以是一个XMM寄存器或内存位置,\(S_2\)\(D\)必须是XMM寄存器,结果存放在目的寄存器中。对应于每一种计算都有单精度和双精度两种指令。

单精度 双精度 效果 描述
vaddss vaddsd \(D\leftarrow S_2+S_1\) 浮点数加
vsubss vsubsd \(D\leftarrow S_2-S_1\) 浮点数减
vmulss vmulsd \(D\leftarrow S_2\cdot S_1\) 浮点数乘
vdivss vdivsd \(D\leftarrow S_2/S_1\) 浮点数除
vmaxss vmaxsd \(D\leftarrow max(S_2,S_1)\) 浮点数最大值
vminss vminsd \(D\leftarrow min(S_2,S_1)\) 浮点数最小值
sqrtss sqrtsd \(D\leftarrow \sqrt{S_1}\) 浮点数平方根

浮点位级操作

单精度 双精度 效果 描述
vxorps vorpd \(D \leftarrow S_{2}~xor~ S_1\) 位级异或(EXCLUSIVE-OR)
vandps andpd \(D \leftarrow S_2\&S_1\) 位级与(AND)

其他

指针

  • 每个指针都对应于一个数据类型:这一类型表面了指针指向的对象

  • 每个指针都有一个值:这个值是某个指定类型对象的内存地址

  • 指针用&运算符创建:这一运算符可以用于任何的左值(出现于赋值语句左侧)类型数据

  • *操作符用于间接引用指针:通过内存引用,存储到指针指向的地址,或是从指针指向的地址读取

  • 数组与指针紧密联系

  • 将指针进行强制类型转换,只改变它的类型,不改变它的值

  • 指针可以指向函数,函数指针的值是这一函数机器代码中第一条指令的地址

内存越界引用与缓冲区溢出

C对于数组引用不进行任何边界检查,而且局部变量和状态信息(例如保存的寄存器值和返回地址)都存放在栈中。

数组越界的操作被称为缓冲区溢出,对越界的数组元素的写操作会破坏存储在栈中的状态信息。

此外,缓冲区溢出可能会使程序执行它本来不愿意执行的函数,这会导致系统的安全受到影响

对抗缓冲区溢出攻击

  • 栈随机化:栈的位置在每次程序运行时都发生变化。在linux系统中,它已经变成了标准行为,属于地址空间布局随机化技术中的一种。地址空间布局随机化使得每次运行时程序的不同部分都会被加载到内存的不同区域。
  • 栈破坏检测:在栈帧中的局部缓冲区与栈状态之间存入一个特殊的哨兵值,这个值是程序每次运行时随机产生的。如果这个值改变则程序异常终止。
  • 限制可执行代码区域:一些硬件和系统会为内存区域提供保护措施。

64位与32位汇编的不同

寄存器的不同

64位有16个寄存器,32位只有8个。但是32位前8个都有不同的命名,分别是e _ ,而64位前8个使用了r代替e,也就是r _。e开头的寄存器命名依然可以直接运用于相应寄存器的低32位。而剩下的寄存器名则是从r8 - r15,其低位分别用d,w,b指定长度。

过程(函数)调用的不同

  1. 参数通过寄存器传递(见前文)

  2. callq 在栈里存放一个8位的返回地址

  3. 许多函数不再有栈帧,只有无法将所有本地变量放在寄存器里的才会在栈上分配空间。

  4. 函数可以获取到栈至多128字节的空间。这样函数就可以在不更改栈指针的情况下在栈上存储信息(也就是说,可以提前用rsp以下的128字节空间,这段空间被称为red zone,在x86-64里,时刻可用)

  5. 不再有栈帧指针。现在栈的位置和栈指针相关。大多数函数在调用的一开始就分配全部所需栈空间,之后保持栈指针不改变。

  6. 一些寄存器被设计成为被调用者-存储的寄存器。这些必须在需要改变他们值的时候存储他们并且之后恢复他们。 ## 参数传递的不同

  7. 6个寄存器用来传递参数(见前文)。

  8. 剩下的寄存器按照之前的方式传递(不过是与rsp相关了,ebp不再作为栈帧指针,并且从rsp开始第7个参数,rsp+8开始第8个,以此类推)。

  9. 调用时,rsp向下移动8位(存入返回地址),寄存器参数无影响,第7个及之后的参数现在则是从rsp+8开始第7个,rsp+16开始第8个,以此类推。 ## 栈帧的不同 很多情况下不再需要栈帧,比如在没有调用别的函数,且寄存器足以存储参数,那么就只需要存储返回地址即可。 需要栈帧的情况:

  10. 本地变量太多,寄存器不够

  11. 一些本地变量是数组或结构体

  12. 函数使用了取地址操作符来计算一个本地变量的地址

  13. 函数必须用栈传送一些参数给另外一个函数

函数需要保存一些由被调用者存储的寄存器的状态(以便于恢复),但是现在的栈帧经常是固定大小的,在函数调用的最开始就被设定,在整个调用期间,栈顶指针保持不变,这样就可以通过对其再加上偏移量来对相应的值进行操作,于是EBP就不再需要作为栈帧指针了。虽然很多时候我们认为没有“栈帧”,但是每次函数调用都一定有一个返回地址被压栈,我们可以也认为这一个地址就是一个“栈帧”,因为它也保存了调用者的状态。

备注

本文的汇编代码格式为==ATT格式==,在一些地方也会看到intel格式的汇编代码,二者的主要区别包括:

  • Intel代码省略了指示大小的后缀,ATT语法中内存操作数的长度(宽度)由操作码最后一个字符来确定。Intel语法则通过在内存操作数前使用前缀‘byte ptr’,‘word ptr’,‘dword ptr’和'qword ptr'来达到同样的目的。

  • Intel代码省略了寄存器名字前面的‘%’符号,ATT格式中寄存器操作数名前要加字符百分号'%'。例如intel格式用的是esp,而ATT格式为%esp。

  • Intel立即数(immediate),也就是常数值,不使用前缀’$’;ATT语法中立即数前面加一个字符'$'。如,intel格式的十进制数123,而ATT格式为$123;intel格式的十六进制数123h,ATT格式为是$0x123;

  • Intel指令一般使用从右到左的顺序,而ATT使用从左到右,如Intel是 mov dst, src,而ATT是 mov src, dst

  • ATT格式中,绝对跳转/调用操作数前面要加星号'*',而Intel汇编语法没有这一限制。

  • AT&T汇编器不提供对多代码段程序的支持

  • Intel代码与ATT代码用不同的方式来描述寄存器中的位置,具体为:

    Intel: segreg:[base+index*scale+disp] ATT:%segreg:disp(base,index,scale)

参考

[1] 深入理解计算机系统

[2] 王爽 汇编语言

[3] 64位和32位寄存器与汇编的比较

[4] [IATT与Intel汇编代码格式][https://blog.csdn.net/goodcrony/article/details/92794938]