本文更新于 2018.10.18

数据移动

MOV, LEA

注解

内存移动的限制: 不支持”mov 内存地址, 内存地址”形式, 也不支持 “mov 段寄存器 段寄存器”形式

算术运算

ADD, MUL, 有符号/无符号

位操作

移位

SHL SHR SAL SAR ROL ROR

位运算

AND OR XOR NOT

Big Endian与Little Endian

BSWAP XCHG

控制流

控制流由比较指令, 跳转指令和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:

    大专栏  语言基础span>        |   | 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