汇编
程序编码
gcc 输出汇编代码
// 输出一个a.s汇编文件
gcc -Og -S mstore.c
-Og 以较原始C语言组织优化代码,一般调试检查时添加这个选项,高的优化会破坏原来的代码结构
-S 生成汇编代码
2.可以使用objdump和gdb等工具调试查看二进制的执行程序的汇编代码
objdump -d mstore.o
c语言和汇编联合使用的方法(动手试一下):
1.编写完整的函数,放在独立的汇编代码文件中,利用汇编器和链接器合并起来。
2.利用gcc的内联编程特性,利用asm的伪代码在c语言中插入简短的汇编代码。
数据格式
汇编语言后缀的含义
寄存器
8086时16位系统有8个寄存器%ax到%sp, IA32架构从16位寄存器拓展到32位寄存器,标志为%eax到%esp,x86-64位系统有%rax到%rsp。除此以外还额外添加了%r8到%r15。
比较特殊的寄存器是%rsp,用来指明运行栈的结束位置。
操作数的格式
操作数表示执行操作中要使用的源数据值和放置结果的目标地址。分为三类:立即值($0x108)、寄存器(%rax)和内存引用(M[address]的形式)。
mov操作
S(source)操作是一个立即数,要保存在寄存器或者内存中。D(Destination)操作表示一个寄存器或内存。后缀b、w、l、q是字节大小的不同,数据类型对应第一张表。
movl指令会把高四位字节置为0。因为x86会把任何寄存器生成32位值的高四位重置为0。
movabsq S值必须是64位数值,D必须是寄存器。
mov操作的五种组合
x86规定mov操作不同内存到内存,所以要把一个值从内存复制到另一个内存,需要分两步,先加载到一个寄存器,再写入到目标内存。
movz操作
movz(zero-extending)操作会把寄存器和内存为S, 寄存器位D。同时把目的的剩余字节填充为0。
每个movz操作后面有两个字符,第一个字符代表源(S)的数据大小,第二个代表目标(D)的数据大小。
不需要movzlq命令,32位值转移到64位时用到了前面movl命令。
movs操作
movs(sign-extending)和movz操作类似,不同是会把目标地址的剩余字节填充为符号位。
cltq命令只作用于%eax和%rax寄存器,%eax的32位数值拓展到%rax64位数值时也会进行符号位的拓展。
访问信息
指针的示例
通常变量的值直接保存在寄存器中,如这里x就是%rax。
在C语言中指针就是地址,这里*xp就是(%rdi)。
压栈和出栈的指令
等效入栈pushq %rbp的命令
等效出栈popq %rax的命令
直接使用pushq和popq只需要1字节,使用等效的汇编指令要8字节。
压栈出栈过程
执行了pushq %rax 和popq %rdx,出栈后0x100还是保持着0x123的值,直到另外的入栈操作覆盖。
算数和逻辑操作
整数和逻辑操作指令
指令分为四类:加载有效地址、一元操作、二元操作和移位。
除了leaq,都有b、w、l、q不同数据大小的变种。
加载有效地址
leaq(load effective address)是movq的变形。leaq有两个功能,一是从内存读数据到寄存器(不是内存引用,而是往目的寄存器写入有效地址)。二是描述普通的算术操作。
leaq执行加法和有限形式乘法的例子:
long scale(long x, long y, long z) {
long t = x + 4 * y + 12 * z;
return t;
}
编译成汇编代码:
scale:
leaq (%rdi,%rsi,4), %rax t = x + 4*y
leaq (%rdx,%rdx,2), %rdx z = z + 2*z
leaq (%rax,%rdx,4), %rax t = t + 3*z
ret
一元操作和二元操作
一元操作,只有一个操作数,即使源又是目的,可以是内存和寄存器。
二元操作,两个操作数。第一个操作数是立即数,内存或寄存器。第二个操作数即是源又是目的,可以是内存或寄存器。
例子:
| 地址 | 值 |
|---|---|
| 0x100 | 0xFF |
| 0x108 | 0xAB |
| 0x110 | 0x13 |
| 0x118 | 0x11 |
| 寄存器 | 值 |
|---|---|
| %rax | 0x100 |
| %rcx | 0x1 |
| %rdx | 0x3 |
根据上述寄存器和内存的值,填写下面指令的目的地址和值
| 指令 | 目的 | 值 |
|---|---|---|
| addq %rcx, (%rax) | 0x100 | 0xFF + 0x1 = 0x100 |
| subq %rdx, 8(%rax) | 0x100 + 0x8 = 0x108 | 0xAB - 0x3 = 0xA8 |
| imuq $16, (%rax,%rdx,8) | (0x3 << 3) + 0x100 = 0x118 | 0x11 * 16 = 0x11 << 4 = 0x110 |
| incq 16(%rax) | 0x100 + 0x10 = 0x110 | 0x13 + 1 = 0x14 |
| decq %rcx | %rcx | 0x1 - 1 = 0x0 |
| subq %rdx,%rax | %rax | 0x100 - 0x3 = 0x100 +0x1fd = 0xfd |
移位操作
左移操作有SAL和SHL,两者相同,左移后会填充0;
右移操作有逻辑右移SHR,算术右移SAR,算式右移填充符号位。
移位操作第一项是移位量,第二项是移位的数。移位量可以是立即数,也可以读取单字节寄存器%cl。目的操作数是寄存器或内存。
%cl寄存器最大值是255,所以%cl寄存器移位最大值是255。
特殊算数操作
imulq有两种形式,一种是单操作数,一种是双操作数。
双操作数时将两个64位操作数相乘产生另一个64位操作数(进行截取)。
单操作数时,imulq时补码乘法,mulq时无符号乘法。默认一个数存在寄存器%rax中,另一个数通过源操作数给出。相乘后结果的高64位存在%rdx,低64位存在%rax中。
例子:
#include <inttypes.h>
typedef unsigned __int128 uint128_t;
void store_uprod(uint128_t *dest, uint64_t, uint64_t y) {
*dest = x * (uint128_t) y;
}
编译成汇编后:
'dest in %rdi, x in %rsi, y in %rdx'
store_uprod:
movq %rsi, %rax 复制x到被乘数
mulq %rdx 乘以y
movq %rax, (%rdi) 把低八位保存在dest
movq %rdx, 8(rdi) 把高八位保存在dest+8
ret
idivl除法也是单操作数,128位的被除数的高64位存在%rdx,低64位存在%rax。除数作为源操作数给出。
结果的商存在%rax,余数存在%rdx。结果是64位时,无符号运算%rdx保存全0,有符号运算%rdx保存符号位。
读取%rax的符号位到%rdx的所有位,这个操作可以用cqto指令,这个指令不需要操作数。
计算两个64位有符号数的商和余数:
void remdiv(long x, long y, long *qp, long *rp) {
long q= x / y;
long r = x % y;
*qp = q;
*rp = r;
}
编译成汇编代码后:
'x in %rdi, y in %rsi, qp in %rdx, rp in %rcx'
remdiv:
movq %rdx, %r8 复制qp到另一个寄存器,因为被除数要用到%rdx
movq %rdi, %rax 把x的低8位移到被除数
cqto 把符号位放到高8位
idivq %rsi 除以y
movq %rax, (%r8) 保存商到qp
movq %rdx, (%rcx) 保存余数到rp
ret
控制(跳转、条件、循环)
汇编的条件判断实现有两种指令,cmp和test
cmp和sub操作差不多,但是只操作条件码,不保存具体的值。
test和and操作差不多,差别也是只操作条件码。
条件码
CF: 进位标志。产生了进位,可以用来检查无符号操作的溢出
ZF: 零标志。最近的操作得出的结果是0
SF: 符号标志。最近的操作得出的结果是负数
OF: 溢出标志。最近的操作导致一个补码溢出
leaq不会改变条件码,因为是进行地址计算的。xor不使用进位和溢出标志。移位操作,进位标志会设置为最后一个移除的位。inc和dec指令会设置溢出和零标志,不会使用进位标志。
访问条件码
条件码不会直接读取使用,一般的用途有三种:
- 根据条件码的组合设置单字节0或1
- 根据条件跳转到程序的其他部分
- 可以有条件地传送数据
set指令可以根据条件码组合设置单字节的值。
'a in %rdi, b in %rsi'
comp:
cmpq %rsi, %rdi a-b
setl %al 是否小于,设置%eax值0或1
movzbl %al, %eax 把%eax的高位都置为0
ret
使用cmp比较时,无符号运算使用进位标志和零标志。有符号运算使用溢出标志和符号标志。
跳转指令
跳转指令使程序跳转到其他的位置。jmp有直接跳转和间接跳转两种。间接跳转从寄存器和内存读取跳转目标。除了jmp还有一些其他的条件跳转指令,条件跳转指令只能是直接跳转。
直接跳转:
movq $0,%rax
jmp .L1 直接跳转到.L1标签处
movq (%rax), %rdx
.L1
popq %rdx
间接跳转:
jmp *%rax 以寄存器的值为目标
jmp (%rax) 以寄存器的值作为读取地址,从地址读取跳转目标
跳转地址计算:
4004d3: eb 03 jmp 4004d8
4004d5 48 d1 f8 sar %rax
反汇编后,第一个指令目标编码为0x03, 加上下一条指令地址0x4004d5, 所以跳转地址是0x4004d8。
rep指令通常会和ret指令组合能避免ret指令成为条件跳转指令目标。
条件控制实现条件分支
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:
例子:
用条件传送实现条件分支
条件传送指令:
使用条件传送指令将c语言的if-else代码翻译成:
v = then-expr;
ve = else-expr;
t = test-expr;
if (!t) v = ve;
条件传送方法通常要比条件控制方法性能要好。
它会计算好两种判断的结果,最后再根据条件选择满足条件的一个。
因为保证了指令的流水执行,减少了机器分支预测逻辑失败的开销。
但是假如then-exor和else-expr都需要大量的计算,那编译器要考虑浪费的计算和分支预测错误造成的消耗那个更大,通常gcc更多使用条件控制转移。
例子:
循环
do-while
loop:
body-statement
t = test-expr;
if (t)
goto loop;
实现n!的例子:
while
两种翻译方法: 跳转到中间(jump to middle)和guarded-do。
第一种gcc使用-Og选项编译,第二种使用-O1。
- 跳转到中间
goto test;
loop:
body-statement;
test:
t = test-expr;
if (t)
goto loop;
实现n!的例子:
- guarded_do 使初始条件不成立就跳过循环,把代码转换成do-while循环
t = test-expr;
if (!t)
goto done;
loop:
body-statement
t = test-exprl
if (t)
goto loop;
done;
实现n!的例子:
for
for循环格式
for (init-expr; test-expr; update-expr)
body-statement
会转换成while格式
init-expr:
while (test-expr) {
body-statement
update-expr;
}
就是在while的跳转到中间或guarded_do方法前加上init-expr语句。
switch
多重条件分支通过跳转表(jump table)实现,跳转表是一个数组,表项 i 是一个代码段的地址,这个代码段实现开关(case)索引值等于 i 时程序应该采取的动作。当开关(case)数量比较多(比如4个以上),并且值的范围跨度比较小,就会使用跳转表。
switch 语句示例以及翻译到扩展的 C 语言。 该翻译给出了跳转表 jt 的结构, 以及如何访问它。作为对 C 语言的扩展,GCC 支持这样的表。这里&&表示指向代码的指针(区别于指向数据值的指针&)。
通过n - 100把判断的取值范围缩小在 0 ~ 6之间
jmp 指令的操作数有前缀 *,表明这是一个间接跳转,操作数指定一个内存位置,索引由寄存S%rsi 给出
汇编代码的跳转表地址编码关系如上图, 这个关系记录在.rodata(read-only data)的只读文件中。在文件中有
如.L3、.L8这样的7个跳转编码地址,每个地址是8字节。
过程(函数)
过程是用一组指定的参数和一个可选的返回结果,实现某一个功能。过程有多种形式:如函数、方法、子例程、处理函数。
过程有三个特性:
传递控制:P 调用 Q 时,程序计算器能记录 Q 的起始位置,返回P时记录调用 Q 的后面那条指令。
传递数据:P 能传递给 Q 多个参数,Q 能给 P 返回一个值。
分配和释放内存: 调用 Q 时能分配存储空间,返回 P 时能释放分配给 Q 的存储空间。
运行时的栈
x86-64过程需要的存储空间超过寄存器的储存大小时,会在栈上分配空间,这部分叫做栈帧(stack fram)
栈帧用于传递参数、储存返回信息、保存寄存器、保存局部变量。结构如下图:
大多数过程的栈帧是定长的,在调用时初始化分配好了,有一些过程需要变长的帧(使用了动态分配malloc等)。
过程 P 调用 Q 时通过寄存器最多能传递6个整数值(整数和指针),更多的参数需要在自己的栈帧中存储好这些参数。
转移控制(调用)
call指令会调用过程,把返回地址(P调用Q的下一条指令地址)压入栈中,并让PC设置为过程的起始地址。
ret指令将返回地址从栈中弹出,并让pc设置为弹出的返回地址。
call指令能直接调用,也能间接调用。间接调用是 * 后面加操作数指示符。
例子:
数据传送(传参)
过程调用中传递6个整数参数的寄存器:
超过6个参数的,需要栈帧存储,所有数据大小都向8的倍数对齐。
例子:
最后两个参数a4和 *a4p在栈顶+8和+16的位置。a4变量是char类型只有1字节,在栈帧结构如下:
栈上的局部存储
栈帧使用局部存储的几种情况:
- 寄存器的数量不够存储
- 有局部变量使用的地址运算符'&'
- 某些特殊的局部变量,数组或结构
例子:
栈帧的结构图:
寄存器的局部存储
我们要确保被调用者(Q)不会覆盖调用者(P)稍后会使用的寄存器。如P的参数是x(%rdi),调用者的参数也是x,我们需要先用一个寄存器保存x的值,以免x的值被调用者修改。
寄存器%rbx、%rbp和%r12 ~ %r15都是被调用者保存寄存器。被调用函数中,要么不修改这些寄存器的值,要么在栈上保存原始值,修改了寄存器的值后,再从栈上读取恢复为原始值(栈帧结构上有标号seved registers)。
除了被调用者保存寄存器和%rsp, 其余分类为调用者保存寄存器。被调用者过程可以随意使用。
例子:
递归过程
每个被调用过程都有自己的私有空间和状态信息。
实现递归的例子:
每次调用的参数n - 1,保存在寄存器%rbx, 通过栈和寄存器的结合实现了每次调用函数的私有状态。同时每个返回结果都保存在寄存器%rax。
数组分配和访问
数组是一个连续区域,可以通过地址跳转访问。
数组访问如下:
嵌套数组的访问公式如下:
L为数据类型大小,C为列的元素个数
异常的数据结构
结构
结构声明:
struct rec {
int i;
int j;
int a[2];
int *p;
}
产生的偏移如图:
假如定义一个rec* r变量,获取r->j的值
r 在%rdi寄存器
movl 4(%rdi), %eax
联合
联合(union)的声明语法和结构(struct)一样。我们预先知道数据结构中有两个不同字段是互斥的,就可以将这两个字段声明为一个联合来减少内存的分配。
如定义一颗二叉树,只有叶子节点会存一个double数组值,而内节点都没有数据。那么声明如下:
union node_u {
struct {
union node_u *left;
union node_u *right;
} internal;
double data[3];
}
这里internal为16字节,data为24字节。所以每个节点只需要24字节,要么存储internal,要么存储data。
数据对齐
数据对齐能提高内存系统的性能。
不同类型的对象访问的地址为K的倍数,K的数值如下:
如定义了一个结构如下:
struct S1 {
int i;
char c;
int j;
}
那么产生的偏移结构如下:
S1的大小为4、1、4字节,为了使访问j满足4字节对齐,在c和j之间插入了3字节的间隙(不使用的内存)。
在机器级程序中将控制和数据结合起来
使用gdb
gdb命令如下:
内存越界和缓冲区溢出
向缓存区写入溢出的内容会造成缓冲区溢出(buffer overflow),一般如数组的越界写入。
假如使用指针修改buf数组的值时,超过了数组范围,首先会修改到未使用的栈空间,然后修改到返回地址,最抠修改到调用者栈帧的内容。
有些攻击者可以利用缓冲区溢出的漏洞,修改掉返回地址的值,跳转到设计好的地址,执行攻击代码。
对抗缓冲区溢出攻击
有三种方法: 栈随机化、栈破坏检测、限制可执行的代码区域
栈随机化
攻击者攻击时需要知道攻击代码在栈中的位置,最早时候栈开始位置是固定的,攻击者确定一种常见的web服务器,就能对这类web服务器都实施攻击。
栈随机化使程序开始的栈空间地址每次都不一样,Linux系统中栈随机化成为标准行为,是地址空间布局随机化技术(Address-Space Layout Randomization)的一种。
攻击者后面使用蛮力来破解栈随机化,使用nop指令,这个指令使程序计数器加一。攻击者在这段序列(空操作雪橇——nop sled)中猜中某个地址,就能计算出攻击代码地址。
32位系统的随机化范围大小约 , 64位系统范围大小约 。建立一段256 ( )字节的nop sled,枚举 (32 768),就可以破解 方的随机化(32位系统)
栈破坏检测
在任意缓冲区于栈状态之间存储一段特殊的金丝雀值(canary)。最后返回结果时,检查金丝雀值被修改过。
限制可执行代码区域
内存引入了 NX(Not-exceute) 位,能够在栈上标记代码是否能够被执行。如知道用户输入是字符串类型,不是代码,就可以限制保存输入的区域不可执行。
浮点代码
这部分内容基于 AVX2 指令,gcc通过参数-mavx2产生AVX2代码。
浮点数使用的寄存器
浮点传送与转换操作
浮点数传送的命令
其中 v 是 AVX 指令的前缀,ss 表示 scalar single-precision,sd 表示 scalar double-precision,a 表示 aligned,ps 表示 packed single-precision,pd 表示 packed double-precision。也就是说,s 结尾的用于 float,d 结尾的用于 double。
浮点数转换为整数命令
转换为整数时会进行截断,值会向 0 舍入。
其中 cvttss2si 的意思是: cvt -> convert, t -> (with) truncation, ss -> scalar single-precision, 2 -> to, si -> signed integer。
ss 用于 float,sd 用于 double;结尾为 q 的用来转成 64 位整数。
整数转换为浮点数指令
这些指令有三个操作数,第二个操作数会影响高位保存结果。因为我们通常使用 xmm 寄存器(128位),所以第二个第三个操作数保持一致。
浮点的精度转换
单精度转换为双精度:
vcvtss2sd %xmm, %xmm, %xmm
等同于:
vunpcklps %xmm, %xmm, %xmm
vcvtps2pd %xmm, %xmm
双精度转换为单精度:
vcvtsd2ss %xmm, %xmm, %xmm
等同于:
vmovddup %xmm, %xmm
vcvtpd2psx %xmm, %xmm
浮点数操作指令
运算指令
第一个操作数 S1 是 XMM 寄存器或者内存地址,第二个操作数和目的操作数是 XMM 寄存器。
浮点数的常量
AVX 浮点操作不能使用立即数,要分配常量空间进行存储。
'temp in %xmm0'
cel2fahar:
vmulsd .LC2(%rip), %xmm0, %xmm0 1.8 * temp
.LC2:
.long 3435973837
.long 1073532108
低四字节为 3435973937 (0xcccccccd), 高四字节为 107352108 (0x3ffccccc)。
浮点公式 v = * M * 。
0x3ff 看出来是规范化浮点数,所以有:
s = 0 (最高位是0)
e = 0x3ff = 1023
bias = = 1023
M = 1 + f = 1 + 0.8 ( 0xcccccccccccccd ) = 1.8
所以v = 1.8
浮点数的位级操作
位级指令
条件码结果
假如有一个参数(S1 或 S2)是NaN,比较就没有意义。这时候进位(CF)和零位(ZF)都是1。特别地会使用奇偶位(PF)来快速识别这种情况。
参考资料
《Computer Systems A Programmer’s Perspective》 —— Randal E. Bryant、David R. O’Hallaron