上回书说到
我们刚刚才把程序的机器表示做了个初探,这回我们来讲讲如何理解一个汇编语言程序的其它语句。
由于这次基本上都是各种指令的介绍,基本也忙不着小娜了。
数学运算
正儿八经的x86-64架构下的汇编数学运算包括:
孤儿指令
lea(q) src dest:加载有效地址,实际上就是mov指令的变体,用于从内存读数据到寄存器,但实际原理不同于mov的数据转移,而是“地址复制”,即将源数据的引用地址转移到目的上。就有点类似于你在写C时这样做:
int *a, *b;
&a = b;
实际上就等价于leaq %rbx %rax。但有点不同的是,leaq也可以负责简单算式的复制,比方说如果我们把上一条指令改成leaq 3(%rbx,%rbx,3) %rax,那么rax被赋予的实际数值就是/(3b+3/)。
一元操作
inc dest:对目标+1
dec dest:对目标-1
neg dest:对目标取负
not dest:对目标取补码
二元操作
add src dest:目的=目的+源
sub src dest:目的=目的-源
imul src dest:目的=目的*源
xor src dest:目的=目的 异或 源
or src dest:目的=目的 或 源
and src dest:目的=目的 与 源
移位操作
sal k dest:算术左移k位
shl k dest:逻辑左移k位(左移都是低位补0所以效果一样)
sar k dest:算术右移k位(空位补符号位)
shr k dest:逻辑右移k位(空位补0)
特殊操作
imul(q) src (dest):两个8字节数的有符号全乘法,对于积,rdx存放高64位,rax存放低64位
mul(q) src (dest):两个8字节数的无符号全乘法,同上
cqto:转换为16字节数
idiv(q) src:16字节数的有符号整除,rdx存放高64位,rax存放低64位,商转移至rax,余数转移至rdx
div(q) src:无符号整除,同上
逻辑控制
到上面那一块为止,我们还停留在整串代码直线运行的情况。但是,如果你看过ida就会发现,绝大多数的函数的逻辑都是树形结构。构成这些树形分支的,就是一连串的逻辑控制。逻辑控制是啥呢?回去看看你的if、else啥的,那就是了。
在汇编中自然也有这些:
条件码
其实本质上,机器级别的代码控制都是硬件控制。就比方说我们的减法,就是拿算术逻辑单元ALU,去读两个寄存器的值进行运算的。而这个逻辑单元不但会运算,还会用计算结果去设置条件码寄存器:
条件码寄存器由CPU维护,长度为单个位,描述最近的算术或逻辑操作的属性。在每次产生数据操作经过ALU时,这些寄存器的值会被重新设置。具体类别如下:
CF:进位标志,判断最近一个操作是否产生数据最高位进位,进位置1否则置0,常用于判断数据是否溢出
ZF:零标志,判断最近一个操作的结果是否等于0,等于0则置1,否则置0
SF:零标志,判断最近一个操作的结果是否小于0,小于0则置1,否则置0
OF:溢出标志,判断最近一个操作是否使有符号数产生 正溢出 / 负溢出,产生则置1,否则置0
AF:辅助进位标志,判断最近一个字节操作是否发生低字节向高字节进位或借位,或者低四位向高四位进位或借位,发生置1,否则置0。
PF:奇偶标志,记录最近一个操作的结果的二进制形式为1的位,若数量为偶数置1,否则置0
DF:方向标志,决定串操作执行时有关寄存器发生调整的方向
IF:中断标志,决定CPU是否相应CPU外部的可屏蔽中断发生的中断请求(但无法阻止不可屏蔽的外部请求和所有的内部请求,中断相关知识可以参考Ann_xia66的博客),置1时可响应,置0时不可响应。
TF:追踪标志,置1时程序单步步过,置0时程序正常运转(反汇编软件会不会就是写入了修改TF的shell来实现单步步过)
因此,一些运算会导致条件码寄存器的既定变更。例如异或操作必定会令CF和ZF置0、加/减法必定会导致CF和OF的变更,etc.
逻辑判断
需要用到条件码的不只是运算指令,还有比较指令cmp系列和test系列,以及set指令系。
cmp系
大致格式为cmp dest src,根据两个操作数的差来设置ZF和CF条件码,但是不设置非条件码寄存器。
目的操作数 < 源操作数时,ZF置0,CF置1。
目的操作数 > 源操作数时,ZF置0,CF置0。
目的操作数 = 源操作数时,ZF置1,CF置0。
test系
大致格式为test dest src,同理,根据两个操作数与的结果来设置条件码。
目的操作数<源操作数时,ZF置0,CF置1。
目的操作数<源操作数时,ZF置0,CF置1。
目的操作数<源操作数时,ZF置0,CF置1。
set系
set指令通过条件码的不同组合来设置对象的最低位。具体操作如下:
sete/setz dest:ZF若为1则将目标寄存器低位置1,否则置0,多用于判断相等或零条件
setne/setnz dest:与上述相反,判断不等/非零条件sets dest:SF若为1则将目标寄存器低位置1,否则置0,多用于判断负数
setns dest:与上述相反,判断非负setg/setnle dest:条件为
(SF ^ OF) & ~ZF,判断大于(SF ^ OF),判断大于等于
setge/setnl dest:条件为
setl/setnge dest:条件为SF ^ OF,判断小于
setle/setng dest:条件为(SF ^ OF) | ZF,判断小于等于seta/setnbe dest:条件为
CF & ~ZF,判断无符号大于CF,判断无符号大于等于
setae/setnb dest:条件为
setb/setnae dest:条件为CF,判断无符号小于
setbe/setna dest:条件为CF | ZF,判断无符号小于等于
所以一组完整的判断指令应该是cmp/test指令和set指令、mov指令等的结合,以实现不同情况下的特殊判断。比方说我们要汇编实现一个比较函数,判断两个数是否相等:
int comp(data_t a, data_t b)
我们需要使用如下代码:
cmpq %rsi %rdi //比较A和B
sete %al //然后查看ZF是否为0,若不为0则相等,al置1
movzbl %al, %eax //判定结果存入零拓展的a寄存器,高字节清零
跳转指令
不过,光有逻辑指令可完不成实现复杂功能的逻辑判断,我们还需要跳转指令jmp系列,通过调用条件码来进行指令分支。
基本格式形似jmp dest,跳转到目标地址进行接下来的运作。如果需要调用寄存器值进行间接跳转,还需要在目标前加上“*”标记,这样就可以读取目标寄存器中的值作为地址进行跳转。除此之外,还有各种逻辑跳转,具体操作如下:
jmp dest:直接跳转
je/jz dest:条件为ZF,相等时跳转
jne/jnz dest:条件为ZF,不等时跳转SF,非负时跳转
js dest:条件为SF,为负时跳转
jns dest:条件为jg/jnle dest:条件为
(SF ^ OF) & ~ZF,大于时跳转(SF ^ OF),大于等于时跳转
jge/jnl dest:条件为
jl/jnge dest:条件为SF ^ OF,小于时跳转
jle/jng dest:条件为(SF ^ OF) | ZF,小于等于时跳转ja/jnbe dest:条件为
CF & ~ZF,无符号大于时跳转CF,无符号大于等于时跳转
jae/jnb dest:条件为
jb/jnae dest:条件为CF,无符号小于时跳转
jbe/jna dest:条件为CF | ZF,无符号小于等于时跳转
所以说,我们要实现两个数是否相等的判定,也可以用汇编这么写
cmpq %rsi %rdi
je L1.
movzbl %al 1
retn
L1.
movzbl %al 0
retn
不过看上去有点麻烦。如果感兴趣,可以自己先用C写一个简单条件判断,再IDA打开看一眼。
除此之外,如果学好了C,各位应该也知道,逻辑表达式也可以作为值被赋到变量上。比方说,判断两个数相等,我们能这么写:
int comp(data_t a, data_t b){
if(a==b)return 1;
return 0;
}
也可以这么写:
int comp(data_t a, data_t b){
int result = (a==b);
if(result)return 1;
return 0;
}
(各位压行人轻点喷,这段代码只是方便理解)
同样地,除了逻辑跳转,汇编语言也自带逻辑赋值。具体操作如下:
cmove/cmovz src dest:条件为ZF,相等时赋值
cmovne/cmovnz src dest:条件为ZF,不等时赋值SF,非负时赋值
cmovs src dest:条件为SF,为负时赋值
cmovns src dest:条件为cmovg/cmovnle src dest:条件为
(SF ^ OF) & ~ZF,大于时赋值(SF ^ OF),大于等于时赋值
cmovge/cmovnl src dest:条件为
cmovl/cmovnge src dest:条件为SF ^ OF,小于时赋值
cmovle/cmovng src dest:条件为(SF ^ OF) | ZF,小于等于时赋值cmova/cmovnbe src dest:条件为
CF & ~ZF,无符号大于时赋值CF,无符号大于等于时赋值
cmovae/cmovnb src dest:条件为
cmovb/cmovnae src dest:条件为CF,无符号小于时赋值
cmovbe/cmovna src dest:条件为CF | ZF,无符号小于等于时赋值
逻辑控制的应用
不过,逻辑控制码这一堆玩意,能实现的东西不只是if else分支结构而已。比如说简单的while循环结构,你可以将一个寄存器设置为循环变量,再通过cmp一个立即数或者另外被赋值的寄存器来设置循环上限,jmp来进行程序的回环。比方说我们想实现一个朴素的2的N次幂,那么实现出来应该是类似如下的结构:
(input in %rax)
mov $1 %rdi
mov %rax %rsi
……
L2.
imul $2 %rdi
sub $1 %rsi
cmp $1 %rsi
jg .L2
retn
用阳间的高级语言翻译一下就是这样:
int func(int round){
int i = round, res = 1;
while(i){
res *= 2;
i--;
}
return res;
}
这么一看,在某些地方,汇编语言确实比高级语言精简,但高级语言胜在视觉上的强逻辑性,以及长篇代码以至于整个项目的可读性,毕竟逆向手都明白,看汇编都够痛苦了,用汇编去堆屎山不是更加难于登天。
运用类似的方法,你可以构造出for、do-while、switch等结构,这里就不一一演示了,各位感兴趣可以自行编译一个程序试试。
非常好逻辑控制,但是函数呢?
可是函数间的跳转,相对于函数内部各种要素的转移就显得有些麻烦。我们首先得理解函数这一概念的底层逻辑:
过程与栈调用
过程是对软件中被分割的不同功能块的一种抽象,它提供一种封装代码的方式,用一组指定参数和一个可选的返回值实现某种功能。简单来说,如果把代码比作一个农业合作社,过程就是将一个代码中的一个功能打包成一个工作小组,你可以安排给这个工作小组以它能够完成的特定任务,也就是参数传递(传参),然后等待这个小组执行完任务,向你汇报结果或者自行消化,也就是返回值。
所以,你可以想到,C、python之类语言的函数是过程,abap的子例程是一种过程,甚至连java的类也可以近似看作过程。
在计算机中,正在执行的过程的数据是集中在栈里的。一个函数未被调用的时候,该函数及其调用链上下一级函数,也就是被它调用的函数,都处于挂起状态,当我们调用这个函数的时候,与函数相关的数据倘若光靠寄存器没办法存完,我们就需要调用栈空间,这部分空间被称为栈帧。
举个例子,当你需要依靠C语言程序加密长文本,并且这个长文本好巧不巧是个局部变量,你不可能指望十几个寄存器能放下一整篇《傲慢与偏见》,于是函数只能开帧。
具体而言,栈中存放的不只是你正在执行的函数的帧,还有被这个函数调用的函数的帧。帧中的内容包括各种直接数据和各种调用地址,尤其是函数的返回地址,这决定了如果这个函数是“被调用者”,那么在它结束流程后,“调用者”应该从何处继续它的任务。
由于函数操作的复杂性,一般的push指令肯定是完成不了相应任务了,我们只好使用转移控制指令:
转移控制
转移控制指令一般如下:
call dest:调用目标地址的函数
ret(n):从过程调用中返回
指令call执行时,将目标函数第一个指令存入程序指令寄存器rip,并将返回地址压栈,函数开始执行。函数调用执行完毕之后,执行retn指令,返回地址出栈并被存入rip中,函数返回,继续执行先前位置之后的操作。
至于传参的过程,传入的前六个参数可以使用%rdi、%rdx、%rsi、%rcx、%r8、%r9寄存器(后缀视操作数大小而定),之后的数据就要依靠栈寄存器来界定其指针位置。寄存器之后的后续数据所占用的栈空间视其数据类型和系统操作数而定。比方说你开了一个int那地址只会每隔一个数据+4,但long就是+8,etc。
此外,如果一个局部变量使用地址运算符“&”,那么栈上也必须强行为它开一个地址。
转移控制的典型案例在《深入理解计算机系统》原书有提及,本文不再赘述。
寄存器中的局部存储空间
不过,既然传参要用到寄存器,那么就不可避免的会有一个问题:寄存器是所有函数的公共资源,就像农业生产合作社里工具和劳动者的关系一样。只不过,合作社有使用工具的规章制度,寄存器的调用也有被刻入CPU的惯例。
这个惯例规定,rbx、rbp和r12r15寄存器作为被调用者保存寄存器,当函数被调用时,这些寄存器的值在调用过程开始被保存在栈中,在调用过程结束时出栈,恢复到原本的寄存器中,函数调用前后保持不变;rdi、rsi、rdx、rcx、rax、r8r11作为调用者保存寄存器,这些寄存器的值在调用开始前就被预先保存,在调用完成后恢复到寄存器中。
比方说,我们再回到2的n次幂那段代码上:
(input in %rax)
mov $1 %rdi
mov %rax %rsi
……
L2.
imul $2 %rdi
sub $1 %rsi
cmp $1 %rsi
jg .L2
retn
如果我们要正儿八经地按照系统惯例实现,那么,这段代码大抵是这样的:
(input in %rax)
push %rdi
push %rsi
mov $1 %rdi
mov %rax %rsi
call func_2mul
mov %rdi %rbx
pop %rsi
pop %rdi
……
func_2mul:
L2.
imul $2 %rdi
sub $1 %rsi
cmp $1 %rsi
jg .L2
retn
endp
如上,因为我们使用rdi和rsi这两个寄存器,所以一般而言,我们需要在调用函数前就将它们的数据入栈存储,在调用完毕后恢复。
其它的案例在原书中以及各路dalao的资料里有体现,这里按下不表,仅作抛砖引玉。
除此之外,函数也可以通过自己call自己实现递归调用,原理与函数内部的循环语句类似:
rfact:
push ……
sub $1 %rsi
cmp $1 %rsi
jg .L3
lea ……
call rfact
.L3
pop ……
ret
如上,我们浅浅学习了一下关于机器码实现运算和逻辑控制、函数调用等操作。可能量有点大,但是忍一下,大的还在后头呢——