第三章
程序的机器级表示
content
3.2程序编码
linux> gcc -Og -o p p1.c p2.c
编译选项 -Og 告诉编译器使用会生成符合原始C代码整体结构的机器代码的优化等级。(太高的优化等级会使代码严重变形,与源代码关系不易理解)
3.2.1 机器级代码
计算机系统使用了多种不同形式的抽象,利用更简单的抽象模型来隐藏实现的细节。对于机器级编程来说,有两种抽象尤为重要。第一种:指令集体系结构或指令集架构(Instruction Set Architecture,ISA)来定义机器级程序格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。第二种:机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。
1) 程序计数器(通常称为“PC”,在 x86-64 中用 %rip 表示)给出将要执行的下一条指令内存中的地址。
2) 整数寄存器文件包含16个命名的位置,分别存储64位的值。
3) 条件码寄存器保存着最近执行的算术或者逻辑指令的状态信息。
4) 一组向量寄存器可以存放一个或多个整数或浮点数值。
程序内存包含:程序的可执行机器代码,操作系统需要的一些信息,用来管理过程调用和返回的运行时栈,以及用户分配的内存块(比如说用 malloc 库函数分配的)。
一条机器指令只执行一个非常基本的操作。
3.2.2 代码示例
写一个C代码文件 mstore.c , 包含如下的函数定义:
long mult2(long,long);
void multstore(long x,long y,long*dest){
long t = mult2(x,y);
*dest = t;
}
在命令行上使用“-S”选项,就能看到编译器产生的汇编代码,查看 mstroe.s 即可。
Linux> gcc -Og -S mstore.c
Linux> cat ./mstore.s
内容如下:
其中有一段代码是这样的:
multstore:
pushq %rbx
movq %rdx, %rbx
call mult2
movq %rax, (%rbx)
popq %rbx
ret
以上从 multstore 之后开始的每一条代码都对应一条机器指令。比如, pushq 表示将寄存器 %rbx 的内容压入栈。
可以使用”-C”选项对其编译并汇编,如:
Linux> gcc -Og -c mstore.c
Linux>objdump -d mstore.o
机器代码就是左侧的数字
53 48 89 d3 e8 00 00 00 00 48 89 03 5b c3
右边就是等价的汇编语言。
一些机器代码和它的反汇编表示的特性:
1) x86-64 的指令长度从 1 到 15 个字节不等。
2) 设计指令格式的方式是,从某个给定位置开始,可以将字节唯一地解码成机器指令。
3) 反汇编器指数基于机器代码文件中的字节序列来确定汇编代码。它不需要访问该程序的源代码或汇编代码。
4) 反汇编其使用的指令命名规则与GCC生成的汇编代码使用的有些细微的差别。
# include <stdio.h>
void multstore(long , long , long * );
int main(){
long d;
multstore(2 , 3 , &d);
printf("2 * 3 --> %ld/n" , d);
return 0; } long mult2(long a , long b){ long s = a * b ;
return s ; }
同样的方法生成机器代码
Linux>gcc -Og -o prog main.c mstore.c
Linux>objdump -d prog
反汇编器会抽取出各种代码序列,其中包含下面这段:
几乎与前面的 mstroe.c 产生的代码一样。
3.2.3 关于格式的注释
GCC 产生的汇编代码有点难读。所有以 “ . ” 开头的焊都是指导汇编器和链接器工作的伪指令。(可以忽略)
带注释的示例:
两种方法在C语言中插入汇编代码。
一种是用汇编代码编写整个函数,在连接阶段把它们和C函数组合起来。
二是利用GCC的支持,直接在C程序中嵌入汇编代码。
具体为:
3.3 数据格式
3.4 访问信息
一个 x86-64 的中央处理器(CPU)包含一组16个存储64位值的通用目的寄存器。这些寄存器;用来存储整数数据和指针。
对于生成1字节和2字节数字的指令会保持剩下的字节不变;生成4字节数字的指令会把高位4个字节置为0。
3.4.1 操作数指示符
大多数指令有一个或多个操作数(operand),指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。各种不同的操作数的可能性被分为三种类型。第一种是立即数(immediate);第二种是寄存器(register);第三种是内存引用。
有多种不同的寻址模式。
3.4.2 数据传送指令
使用最频繁的指令是将数据从一个位置复制到另一个位置的指令。
最简单的数据传送指令——MOV类。
MOV指令示例:
移动填充指令如下:
扩展示例:
3.4.3 数据传送示例
3.4.4 压入和弹出栈数据
将一个四字值压入栈中,首先要将栈指针减8,然后将值写到新的栈顶地址。例如:
pushq %rbp
等价于
Decrement stack pointer
subq $8, %rsp
Store %rbp on stack
movq %rbp, (%rsp)
具体过程如下:
3.5 算术和逻辑操作
3.5.1 加载有效地址
加载有效地址(load effective address)指令 leaq 实际上是 movq 指令的变形。它的指令形式是从内存读数据到寄存器,但实际上它根本就没有引用内存,而是将有效地址写入到目的操作数。
3.5.2 一元和二元操作
3.5.3 移位操作
左移指令有两个名字: SAL 和 SHL(二者效果一样,都是将右边补上 0 )。右移指令也有两个: SAR 执行算术右移(左边填上符号位), 而 SHR执行逻辑移位(填上 0 )。
3.5.4 讨论
大多数指令既可以用于无符号运算,也可以用于补码运算。只有右移操作要求区分有符号和无符号数。
示例:
tips:
xorq %rdx, %rdx
是将 %rdx 置为 0 的有效办法,只占3个字节。
3.5.5 特殊的算术操作
imulq 指令有两种不同形式。其中一种是一个“双操作数”乘法指令,另一种“单操作数”乘法指令(包含无符号乘法(mulq)和补码乘法(imulq))。
这两条指令都要求一个参数必须在寄存器 %rax 中,而另一个作为指令的源操作数给出。乘积存放在寄存器 %rdx(高64位)和 %rax(低64位)中。
示例:
除法和取模操作。有符号除法指令 idivl 将寄存器 %rdx(高64位)和%rax(低64位)中的 128 位数作为除数,而除数作为指令的操作数给猪。指令将商存储在寄存器 %rax 中,将余数存储在寄存器 %rdx 中(取模运算结果)。
特殊点:对于 64 位除数来说,这个值通常存放在 %rax 中, %rdx 的位一个设置全为 0 (无符号运算)或者 %rax 的符号位(有符号运算)(可以用一个无操作数指令 cqto 来完成,该指令隐含读出 %rax 的符号位并将结果复制到 %rdx 的所有位)。
示例:
3.6 控制(control)
条件码(condition code)寄存器。
CF(carry flag): 进位标志。最近的操作使最高位产生了进位。可用来检查无符号操作的溢出。
ZF(zero flag):零标志。最近的操作得出的结果为零。
SF(signed flag):符号标志。最近的操作得到的结果为负数。
OF(overflow flag):溢出标志。最近的操作导致一个补码溢出——正溢出或负溢出。
leaq 指令不会改变任何条件码,因为它是用来进行地址计算的。
3.6.2 访问条件码
条件码通常不会直接读取,常用的使用方法有三种:
1) 可以根据条件码的某种组合,将一个字节设置为 0 或者 1;
2)可以条件跳转到程序的某个其他的部分;
3)可以有条件地传送数据。
3.6.3 跳转指令
3.6.4 跳转指令的编码
3.6.5 用条件控制来实现条件分析
将条件表达式和语句从C语言翻译成机器代码,最常用的方式就结合有条件和无条件跳转。
C语言中的 if-else 语句的通用形式模版如下:
if (test-expr)
then-statement
else
else-statement
对应汇编实现通常会使用下面这种形式。以C语言语法来描述控制流:
t = test-expr;
if (!t)
goto false;
then-statement;
goto done;
false:
else-statement;
done:
3.6.6 用条件传送来实现条件分支
实现条件操作的传统方法是通过使用控制的条件转移(条件满足,执行A;否则执行B)。
一种替代的策略是使用数据的条件转移(在一定条件下,事先把条件结果列出来,根据条件直接选取数据结果来执行下一路径)。
示例(ret 返回的参数存放在 %rax 中):
分支预测错误的处罚
条件传送指令
如果条件分支中任意一个可能产生错误条件或者副作用,将会导致非法的行为。
3.6.7 循环
do-while 循环
while 循环
示例:
for 循环
for 循环的通用格式:
for (init-expr; test-expr; update-expr)
body-statement
等价于:
init-expr:
while(test-expr){
body-statement
update-expr;
}
跳转到中间的策略 goto 代码:
init-expr;
goto test;
loop:
body-statement;
update-expr;
test:
t = test-expt;
if(t)
goto loop;
而 guarded-do 策略得到:
init-expr;
t = test-expr;
if(!t)
goto done;
loop:
body-statement;
t = test-expt;
if(t)
goto loop;
done:
综上所述,C语言中三种形式的所有循环——do-while、while、和for都可以用一种简单的策略来翻译,产生包含一个或多个条件分支的代码。控制的条件转移提供将循环翻译成机器代码的基本机制。
3.6.8 switch 语句
switch(开关)语句可以根据一个整数索引值进行多重分支(multiway branching)。
跳转表是一个数组,表项 i 是一个代码段的地址,这个代码段实现当开关索引值等于 i 时程序应该采取的动作。
示例:
跳转表
3.7 过程
过程是软件中的一种很重要的抽象。它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。
3.7.1 运行时栈
C语言使用栈数据结构提供的后进先出的内存管理原则。
3.7.2 转移控制
将控制从函数P转移到函数Q只需要简单地把程序计数器(PC)设置为Q的代码的起始位置。
call 指令有一个目标,即指明被调用过程起始的指令地址。
示例:
3.7.3 数据传送
过程调用还可能包括把数据作为参数传递,而从过程返回还有可能包括返回一个值。
超出6个参数传递的例子:
3.7.4 栈上的局部存储
局部数据必须存放在内存中,常见的情况有:
1) 寄存器不足够存放所有的本地数据;
2) 对一个局部变量使用地址运算符 ’&‘ ,因此必须能够为它产生一个地址;
3) 某些局部变量是数据或结构,因此必须能够通过数据或结构引用被访问到。
3.7.5 寄存器中的局部存储空间
寄存器是唯一被所有过程共享的资源。
据惯例,寄存器 %rbx %rbp 和 %r12~%r15 被划分为被调用者保存寄存器。所有其他的寄存器,除了栈指针 %rsp, 都分类为调用者保存寄存器。
示例:
3.7.6 递归过程
示例:
3.8 数组分配和访问
C语言的数组是一种将标量数据聚集成更大数据类型的方式。
3.8.1 基本原则
对于数据类型 T 和整型常数 N
,声明如下:
T A[N]
;
示例:
3.8.2 指针运算
C语言允许对指针进行运算,而计算出来的值会根据该指针引用的数据类型的大小进行伸缩。
单操作数操作符&
和*
可以产生指针和间接引用指针。
3.8.3 嵌套的数组
当我们创建数组的数组时,数组分配和引用的一般原则也是成立的。例如:
int A[5][3]
等价于
typedef int row3_t[3];
row3_t A[5];
公式一:
示例:
3.8.4 定长数组
C语言编译器能够优化定长多为数组上的操作代码。
假设定义如下数组:
#define N 16
.typedef int fix_matrix[N][N];
示例:
3.8.5 变长数组
假设变长数组的声明如下:
int A[
expr1][
expr2]
它可以作为一个局部变量,也可以作为一个函数的参数;通常对表达式expr1和expr2求值来确定数组的维度。例如访问 n x n
数组的元素 i , j ,可以写如下函数:
int var_ele(long n, int A[n][n], long i, long j){
return A[i][j];
}
参数 n 必须在参数 A[n][n] 之前。
如下代码,计算两个 n x n 矩阵 A 和 B 乘积的元素 i, k。
汇编代码如下
Registers: n in %rdi, Arow in %rsi, Bptr in %rcx
4n in %r9, result in %eax, j in %edx
.L24:
movl, (%rsi, %rdx, 4), %r8d ;Read Arow[j]
imull (%rcx), %r8d ;Multiply by *Bptr
addl %r8d, %eax ;Add to result
addq $1, %rdx ;j++
addq %r9, %rcx ;Bptr += n
cmpq %rdi, %rcx ;Compare j:n
jne .L24
3.9 异质的数据结构
C语言提供了两种将不同类型的对象组合到一起创建数据类型的机制:
结构(structure),用关键字struct来声明,将多个对象集合到一个单位中;
联合(union),用关键子union来声明,允许用几种不同的类型来引用一个对象。
3.9.1 结构
示例:
另外,我们可以在一条语句中既声明变量又初始化它的手段:
struct rect r = {0, 0, 10, 20, 0xFF00FF};
返回长方形面积的示例:
long area(struct rect *rp)
{
return (*rp).width * (*rp).height;
}
rp -> width
等价于 (*rp).width
。
3.9.2 联合
联合允许以多种类型来引用一个对象。
考虑如下声明:
struct S3
{
char c;
int i[2];
double v;
}
union U3
{
char c;
int i[2];
double v;
}
可以看出 类型 union U3 *
的指针 p
,p->c
、p->i[0]
和p->v
引用的都是数据结构的起始位置。一个联合的总的大小等于它最大字段的大小。
3.9.3 数据对齐
许多计算机系统对基本数据类型的合法地址做出了一些限制,要求某种类型对象的地址必须是某个值K(通常是2、4或8的倍数)。这种对齐限制简化了形成处理器和内存系统之间接口的硬件设计。
K | 类型 |
---|---|
1 | char |
2 | short |
4 | int, float |
8 | long, double,chat* |
例如跳转表的汇编声明包含下列指令:
.align 8
这就保证了它后面的数据的起始地址是 8 的倍数。
示例:
蓝色部分为为对齐而填充的地址空间。
3.10 在机器级程序中将控制与数据结合起来
3.10.1 理解指针
指针以一种统一方式,对不同数据结构中的元素产生引用。
1) 每个指针都对应一个类型。(指针类型不是机器代码中的一部分;只是C语言提供的一种抽象,帮助程序员避免寻址错误。)
2) 每个指针都有一个值。这个值是某个指定类型的对象的地址。特殊的 NULL(0)值代表该指针没有指向任何地方。
3) 指针用‘&’运算符创建。
4) * 操作符是用来间接引用指针 。
5) 数组与指针紧密联系。一个数组的名字可以像指针变量一样引用(但是不能修改)。如,a[3]
等价于*(a+3)
。
数组引用和指针运算都需要用对象大小对偏移量进行伸缩。
6) 将指针从一种类型强制转换成另一种类型,只改变它的类型,而不改变它的值。
例如:
char *p = 'c' ;
(int *)p + 7 的结果为 c + 28;
(int *)(p + 7) 的结果为 c + 7。
(强制类型转换的优先级高于加法)
7) 指针也可以指向函数。这提供了一个很强大的存储和像代码传递引用的功能,这些引用可以被程序的某个其他部分调用。
例如:
int fun(int x, int *p);
/** 声明指针 fp ,将它赋值为这个函数 */
int (*fp)(int, int *);
fp = fun;
/** 函数调用 */
int y = 1;
int result = fp(3, &y);
函数指针的值是该函数机器代码表示中第一条指令的地址。
3.10.2 应用:使用 GDB 调试器
先运行OBJDUMP 来获得程序的反汇编版本。如下命令行来启动 GDB:
linux> gdb prog
通常的方法是在程序感兴趣的地方附近设置断点。
3.10.3 内存越界引用和缓冲区溢出
缓冲区溢出(buffer overflow)。在栈中分配某个字符数组来保存字符串,但是字符串的长度超出了为数组分配的空间。
示例:
echo
对应的汇编:
越界会破坏的信息:
缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数。这是一种最常见的通过计算机网络攻击系统安全的方法。
3.10.4 对抗缓冲区溢出攻击
1. 栈随机化
为了在系统中插入攻击代码,攻击者既要插入代码,也要插入指向这段代码的指针,这个指针也是攻击字符串的一部分。产生这个指针需要知道这个字符串放置的栈地址。
栈随机化的思想使得栈的位置在程序每次运行时都有变化。这类技术称为地址空间布局随机化(Address-Space Layout Randomization),简称ASLR。
通常攻击者使用”空操作雪橇(nop sled)“,使程序”滑过“目标序列,即在实际攻击代码前插入一段很长的nop
(读作“no op”
,no operation
的缩写)指令。
2. 栈破坏检测
计算机的第二道防线是能够检测到何时栈已经被破坏。
GCC提供一种栈保护者机制,来检测缓冲区越界。其思想是在栈帧中任何局部缓冲区与栈状态之间存储一个特殊的金丝雀(canary),也称为哨兵值(guard value),是在程序每次运行时随机产生的。
3. 限制可执行代码区域
最后一招是消除攻击者向系统插入可执行代码的能力。
3.10.5 支持变长栈帧
前面所讲的各种函数的机器级代码,都有一个共同点,即编译器能够预先确定需要为栈帧分配多少空间。
下面示例为局部存储是变长的。
%rbp称为帧指针(frame pointer)(有时称为基址帧(base pointer);
leave
指令将栈帧指针恢复到它之前的值(第20行)。等价于:
movq %rbp, %rsp ;Set stack pointer to begining of frame
popq %rbp ;Restore saved %rbp and set stack ptr to end of caller's frame
练习及答案(红色部分):
根据图3-43和图3-44填写下表(要细心啊,我算了好久,答案对不上,最后面发现算错了):
n | s1 | s2 | p | e1 | e2 |
---|---|---|---|---|---|
5 | 2065 | 2017 |
2024 |
1 |
7 |
6 | 2064 | 2000 |
2000 |
16 |
0 |
3.11 浮点代码
处理器的浮点体系结构包括多个方面,会影响对浮点数据操作的程序如何被映射到机器上,包括:
1) 如何存储和访问浮点数据。通常是通过某种寄存器方式来完成。
2) 对浮点数据操作的指令。
3) 想函数传递浮点数参数和从函数返回浮点数结构的规则。
4) 函数调用过程保持寄存器的规则——例如,一些寄存器被指定为调用者保存
,而其他的被指定为被调用者保存
。
AVX
浮点体系结构允许数据存储在16
个YMM
寄存器中,它们的名字为%ymm0~%ymm15
。每个YMM
寄存器都是256
位(32
位)。
3.11.1 浮点传送和转换操作
引用内存的指令是标量
指令,意味着它们只对单个而不是一组封装好的数据值进行操作。
GCC
只用标量传送操作从内存传送数据到XMM
寄存器或从XMM
寄存器传送数据到内存。
示例:
把浮点值转换成整数时,指令会执行截断(truncation)
,把值指向0
进行舍入,这是C
和大多数其他编程语言的要求。
图3-48
中的指令把整数转换成浮点数。第一个操作数读自于内存或一个通用目的寄存器。第二个操作数只会影响结果的高位字节
。
例如如下指令:
vcvtsi2sdq %rax, %xmm1, %xmm1
该指令从寄存器%rax
读出一个长整数,把它转换成数据类型double
,并把结果存放进XMM
寄存器%xmm1
的低字节中。
假设%xmm0
的低位4字节保存着一个单精度值,使用如下指令:
vcvtss2sd %xmm0, %xmm0, %xmm0
把它转换成一个双精度值,并将结果存储在寄存器%xmm0
的低8字节。
GCC
生成的代码:
Conversion from single to double precision
1 vunpcklps %xmm0, %xmm0, %xmm0 `Replace first vector element`
2 vcvtps2pd %xmm0, %xmm0 `Convert two element to double`
vunpcklps
指令通常用来交叉放置来自两个XMM
寄存器的值,把它们存储到第三个寄存器中(例如:源R1 = [s3, s2, s1, s0]
, 源R2 = [d3, d2, d1, d0]
,那么目的寄存器R3 = [s1, d1, s0, d0]
。)。
对于双精度转换为单精度,·GCC
会产生类似的代码:
Conversion from double to single precsion
1 vmovddup %xmm0, %xmm0 `Replicate first vector element`
2 vcvtpd2psx %xmm0, %xmm0 `Convert two vector elements to single`
假设这些指令开始执行前寄存器%xmm0
保存着两个双精度值[x1, x2]
。然后vmovddup
指令把它设置为[x0, x0]
。vcvtpd2psx
指令把这两个值转换成单精度,再存放到该寄存器的低位一般中,并将高位一半置0,得到结果[0.0, 0.0, x0, x0]
。
fcvt
的所有参数都是通过通用寄存器传递的,因为它们既不是整数也不是指针。
浮点传送和转换操作指令汇总
指令 | 源1 | 源2 | 目的 | 描述 |
---|---|---|---|---|
vmovss |
M32 |
NULL |
X |
传送单精度数 |
vmovss |
X |
NULL |
M32 |
传送单精度数 |
vmovsd |
M64 |
NULL |
X |
传送双精度数 |
vmovsd |
X |
NULL |
M64 |
传送双精度数 |
vmovaps |
X |
NULL |
X |
传送 对齐的封装好的单精度数 |
vmovapd |
X |
NULL |
X |
传送 对齐的封装好的双精度数 |
vcvttss2si |
X/M32 |
NULL |
R32 |
用截断的方法把单精度数转换成整数 |
vcvttsd2si |
X/M64 |
NULL |
R32 |
用截断的方法把双精度数转换成整数 |
vcvttss2siq |
X/M32 |
NULL |
R64 |
用截断的方法把单精度数转换成四字整数 |
vcvttsd2siq |
X/M64 |
NULL |
R64 |
用截断的方法把双精度数转换成四字整数 |
vcvtsi2ss |
M32/R32 |
X |
X |
把整数转换成单精度数 |
vcvtsi2sd |
M32/R32 |
X |
X |
把整数转换成双精度数 |
vcvtsi2ssq |
M64/R64 |
X |
X |
把四字整数转换成单精度数 |
vcvtsi2sdq |
M64/R64 |
X |
X |
把四字整数转换成双精度数 |
vcvtps2pd |
X1 |
NULL |
X2 |
**把 X1中两个低位单精度值扩展成 X2中的两个双精度值 |
vunpcklps |
X1 |
X2 |
X3 |
交叉放置 X1和 X2的值存储到 X3中 |
注:NULL
表示没有该源。
3.11.2 过程中的浮点代码
在x86-64中,XMM
寄存器用来向函数传递浮点参数,以及从函数返回浮点值。
1) XMM
寄存器%xmm0~%xmm7
最多可以传递8个浮点参数(额外的可以通过栈传递)。
2) 函数使用寄存器xmm0
来返回浮点值。
3) 所有的XMM
寄存器都是调用者保存的。被调用者可以不用保存就覆盖这些寄存器中任一个。
示例:
3.11.3 浮点运算操作
图3-49
描述了一组执行算术运算的标量AVX2
浮点指令。
3.11.4 定义和使用浮点常数
和整数运算操作不同,AVX
浮点操作不能以立即数值作为操作数。
示例(`需重点理解
):
解析:3435973837(0xcccccccd)
,1073532108(0x3ffccccc)
,从高位字节,可以抽取0x3ff(1023)
,减去偏移量2^(k-1)-1=2^(11-1)-1=1023
,得到指数0
。小数字段为0xccccccccccccd(3602879701896397)
,然后除以2^(52)=4503599627370496
,最后得到小数0.8
,加上隐含的1
,得到1.8
,最后得数为1.8*2^0=1.8
。
3.11.5 在浮点代码中使用位级操作
单精度 | 双精度 | 效果 | 描述 |
---|---|---|---|
vxorps |
vorps |
D <- S2^S1 |
位级异或(EXCLUSIVE-OR ) |
vandps |
andpd |
D <- S2&S1 |
位级与(AND ) |
图3-50 对封装数据的位级操作(这些指令对一个XMM寄存器中的所有128
位进行布尔操作)
3.11.6 浮点比较操作
AVX
提供了两条比较浮点值的指令(类似与CMP
指令,但是操作数顺序相反):
指令 | 基于 | 描述 |
---|---|---|
ucomiss S1, S2 |
S2-S1 |
比较单精度值 |
ucomisd S1, S2 |
S2-S1 |
比较双精度值 |
浮点比较指令会设置三个条件码:零标志位 ZF
、进位标志位CF
和奇偶标记位PF
。
当任一操作数为NaN
时,就会出现*无序的情况。可以通过奇偶标志位发现这种情况。通常jp(jump on parity)
指令是条件跳转,条件就是浮点比较得到一个无序的结果。
示例:
解析如下图