语言基础¶
本文更新于 2018.10.18
算术运算¶
ADD, MUL, 有符号/无符号
控制流¶
控制流由比较指令, 跳转指令和EFLAGS寄存器完成.
常用比较指令:
- TEST a, b 计算a+b, 并根据结果设置SF,ZF,PF
- CMP a, b 计算a-b, 并根据结果设置EFLAGS
常用跳转指令(以无符号数比较为例):
指令 | 操作数a, b的关系 | EFLAGS情况 |
---|---|---|
JMP | 无条件跳转 | 不关心 |
JZ/JE | a==b | ZF=1 |
JNZ/JNE | a!=b | ZF=0 |
JB/JNAE | a<b | CF=1 |
JBE/JNA | a<=b | CF=1 || ZF=1 |
JA/JNBE | a>b | CF=0 && ZF=1 |
JAE/JNB | a>=b | CF=0 |
注解
助记法: B=Below,小于, A=Above,大于, E=Equal,等于, N=Not, Z=Zero, AE=Above & Equal, NBE=Not(Below & Equal)
举例演示:
a | b | ZF | CF |
---|---|---|---|
7 | 8 | 0 | 1 |
8 | 8 | 1 | 0 |
9 | 8 | 0 | 0 |
if/else¶
相关汇编代码见 https://github.com/zzqcn/storage/blob/master/code/asm/ifelse.asm
C代码:
if(eax > 8)
ebx = 1;
else
ebx = 0;
对应的汇编代码:
cmp eax, 8
jbe then_block ; 如果 eax <= 8, 则跳到分支then_block
mov ebx, 1
jmp end_block ; 无条件跳到结束
then_block:
mov ebx, 0
end_block:
switch/case¶
// TODO
for/while¶
LOOP指令使EAX自减, 并当EAX!=0时, 跳到指定标号.
下面以整数1到10求和的代码来说明一下汇编代码的循环结构. 相关代码见 https://github.com/zzqcn/storage/blob/master/code/asm/loop.asm
for循环的C代码:
int ecx = 0;
int ebx = 0;
for(ecx=10; ecx!=0; ecx--)
ebx += ecx;
对应的一种汇编写法:
mov ecx, 10
mov ebx, 0
loop_start:
add ebx, ecx
loop loop_start ; ecx--; if(ecx != 0) goto loop_start
while循环的C代码:
int ecx = 10;
int ebx = 0;
while(ecx != 0) {
sum += ecx;
ecx--;
}
对应的一种汇编写法:
mov ecx, 10
mov ebx, 0
while:
cmp ecx, 0 ; test (ecx-0)
jz end_while ; if(ecx == 0) goto end_while
add ebx, ecx
dec ecx ; ecx--
jmp while ; goto while
end_while:
do/while循环的C代码:
int ecx = 10;
int ebx = 0;
do {
ebx += ecx;
ecx--;
} while(ecx != 0);
对应的汇编代码:
mov ecx, 10
mov ebx, 0
do:
add ebx, ecx
dec ecx ; ecx--
jnz do ; if(ecx != 0) goto do
栈操作与函数调用¶
栈¶
SS段寄存器指定包含堆栈的段(通常它与储存数据的段是一样). ESP寄存器包含将要移除出栈数据的地址, 这个数据也被称为栈顶, 数据只能以双字(dword)的形式入栈. PUSH指令将数据入栈, POP指令将数据出栈, 这两个指令都会改变ESP寄存器的值.
以下通过调试来观察PUSH/POP指令对ESP的影响, 汇编源码:
push dword 1
push dword 2
push dword 3
pop eax
pop ebx
pop ecx
gdb调试:
(gdb) si
0x08048945 in asm_main ()
3: x/i $pc
=> 0x8048945 <asm_main+5>: push 0x1
2: $esp = (void *) 0xffffd368
(gdb) si
0x08048947 in asm_main ()
3: x/i $pc
=> 0x8048947 <asm_main+7>: push 0x2
2: $esp = (void *) 0xffffd364
(gdb) si
0x08048949 in asm_main ()
3: x/i $pc
=> 0x8048949 <asm_main+9>: push 0x3
2: $esp = (void *) 0xffffd360
(gdb) si
0x0804894b in asm_main ()
3: x/i $pc
=> 0x804894b <asm_main+11>: pop eax
2: $esp = (void *) 0xffffd35c
(gdb) si
0x0804894c in asm_main ()
3: x/i $pc
=> 0x804894c <asm_main+12>: pop ebx
2: $esp = (void *) 0xffffd360
(gdb) si
0x0804894d in asm_main ()
3: x/i $pc
=> 0x804894d <asm_main+13>: pop ecx
2: $esp = (void *) 0xffffd364
(gdb) si
0x0804894e in asm_main ()
3: x/i $pc
=> 0x804894e <asm_main+14>: popa
2: $esp = (void *) 0xffffd368
由调试记录可见每条指令执行后ESP的变化:
初始状态:
ESP --> | | ffffd368H
push dword 1:
| | ffffd368H ESP --> | 1 | ffffd364H
push dword 2:
| | ffffd368H | 1 | ffffd364H ESP --> | 2 | ffffd360H
push dword 3:
| | ffffd368H | 1 | ffffd364H | 2 | ffffd360H ESP --> | 3 | ffffd35cH
pop eax:
| | ffffd368H | 1 | ffffd364H ESP --> | 2 | ffffd360H | 3 | ffffd35cH
pop ebx:
| | ffffd368H ESP --> | 1 | ffffd364H | 2 | ffffd360H | 3 | ffffd35cH
pop ecx:
ESP --> | | ffffd368H | 1 | ffffd364H | 2 | ffffd360H | 3 | ffffd35cH
CALL和RET¶
使用CALL和RET指令可以方便地实现子程序, 它也是高级语言(如C)中实现函数的基础. CALL将下一条指令的地址入栈, 并跳到指定标号处执行代码; RET出栈一个地址, 并跳到这个地址处代码.
调用约定¶
高级语言对于如何传递函数参数, 如何处理返回值等的标准称为调用约定. C语言常见的调用约定有CDECL, STDCALL, THISCALL和FASTCALL. C调用约定要求调用方在调用函数后清除参数, 而Pascal调用约定则要求被调用的函数自己来清除参数. 不同的C调用约定对于参数如何传递, 以及传递的顺序都有不同的要求, 见下表:
// TODO
子程序¶
由于CALL将下一条指令地址入栈, 因此在子程序中直接用POP来出栈参数有所不便, 它会先弹出下一条指令地址. 另外, 把参数出栈保存在寄存器中也不方便, 不如留在栈中通过间接寻址访问, 如[ESP+8].
回想之前的讨论, 由于任何入栈出栈操作会改变ESP的值, 所以使用ESP+偏移来进行间接寻址不方便, 这时可以使用EBP寄存器, 此寄存器用于做为间接寻址的基址. C调用约定要求函数需要首先将EBP入栈, 然后将ESP的值赋给EBP, 这样一来, 在子程序里的栈操作就只会改变ESP而不会改变EBP. 在子程序结束, 需要将先前保存的EBP出栈, 恢复旧栈. 这个逻辑用代码表示如下:
sub_program:
push ebp ; ebp旧值入栈
mov ebp, esp ; $ebp = $esp
; 子程序代码部分
pop ebp ; ebp出栈, $ebp=ebp旧值
ret
使用这种方式后, 就可以在子程序中使用[EBP+12]来间接寻址参数, 如下所示:
EBP+8 --> | 参数区 |
EBP+4 --> | 下一指令地址 |
EBP --> | EBP旧值 |
在子程序结束前, 弹出EBP旧值, RET指令也弹出CALL指令入栈的下一指令地址, 此时栈的情况如下:
ESP+8 --> | 参数区 |
ESP --> | 下一指令地址 |
由于C调用约定要求调用方来清除参数, 因此需要使用ADD/POP等指令来改变ESP的值. 假设函数参数为2个, 则需要将栈顶后退2个dword共8字节:
push dword 1 ; 入栈参数1
push dword 2 ; 入栈参数2
call sub_program
add esp, 8 ; 清理子程序参数, 恢复栈顶
示例1¶
以下举例说明如何使用CALL, RET和栈来实现子程序调用, 在此示例中, 不使用特定的某一种C调用约定, 而是将参数按顺序入栈.
示例的C语言表示如下:
int sub_program(int a, int b) {
return a+b;
}
int main() {
int a = 2;
int b = 5;
sub_program(a, b);
}
汇编代码(部分):
asm_main:
enter 0,0 ; setup routine
pusha
push dword 2 ; 入栈参数1
push dword 5 ; 入栈参数2
call sub_program ; 调用子程序
add esp, 8 ; 清除子程序参数, 恢复栈顶
popa
mov eax, 0 ; return back to C
leave
ret
sub_program:
push ebp
mov ebp, esp
mov eax, 0
add eax, [ebp+12] ; 间接寻址参数1
add eax, [ebp+8] ; 间接寻址参数2
pop ebp
ret
可使用gdb进行调试分析, 这里不赘述.
局部变量¶
子程序的局部变量也储存在栈上, 位置在EBP之后, 可以把ESP减去局部变量所占空间大小来预备内存, 然后使用[EBP-偏移值]来间接寻址局部变量, 最后在函数返回前恢复ESP的值.
sub_program:
push ebp ; ebp旧值入栈
mov ebp, esp ; $ebp = $esp
sub esp, 4 ; 预留4字节的栈空间用于局部变量
mov dword[ebp-4], 10 ; 子程序代码部分, 间接寻址局部变量
mov esp, ebp ; 恢复esp
pop ebp ; ebp出栈, $ebp=ebp旧值
ret
示例2¶
以下举例说明如何用栈实现局部变量, 在此示例中, 不使用特定的某一种C调用约定, 而是将参数按顺序入栈.
示例的C语言表示如下:
int sub_program(int a, int b) {
int c = 10;
return (a+b)*c;
}
汇编代码(部分):
sub_program:
push ebp
mov ebp, esp
sub esp, 4 ; 预留4字节的栈空间用于局部变量
mov dword[ebp-4], 10 ; 局部变量int c = 10
mov eax, 0
add eax, [ebp+12] ; 间接寻址参数1
add eax, [ebp+8] ; 间接寻址参数2
mul dword[ebp-4] ; eax = eax*c
mov esp, ebp ; 恢复esp
pop ebp
ret
ENTER和LEAVE¶
以上的子程序中, 开始部分和结束部分的代码相对固定, 可以用ENTER和LEAVE指令来简化. ENTER有两个立即数操作数, 对于C调用约定, 第2个操作数总为0, 第1个是局部变量所需字节数. LEAVE没有操作数, 位于RET之前. 使用这2个指令后的子程序骨架代码如下:
sub_program:
enter 4, 0
mov dword[ebp-4], 10 ; 子程序代码部分, 间接寻址局部变量
leave
ret