Retep

简单(陋)的x86汇编语言教程


啥是汇编语言?

汇编语言其实不算是某一个语言,而是一种”human readable machine code”。比起高级语言更接近底层的机器语言,所以也更加复杂和冗长。汇编语言是直接传给cpu的指令,所以与cpu的架构非常相关。

汇编语言有以下几个特征:

  1. 和寄存器交互。每个寄存器有不同的目的,但存储的长度是相同的,也和cpu架构相关,32-bit的机器,寄存器就是32-bit长的。
  2. 和栈交互。栈是一个LIFO(last in, first out)的数据结构。在内存中,其实就看作一个数组,因为可以访问任意位置(random access)。栈的主要实现是通过一个栈顶指针(存储在寄存器esp中)。pop和push就以这个指针为参考。

常量类型

db: 1 byte
dw: 2 byte
dd: 4 byte

x86汇编中有三种可以声明的常量类型(或者只有我那么叫?),他们的主要区别在于长度不同。

  • db: 1 字节, 包括十六进制数(hexidecimal, e.g. 0xff), 字符('s'), 1个字节能表示的整形(100)
  • dw: 2 字节,更大的整型
  • dd: 4 字节,更更大的整型

基本语法

以操作符开头,后面是涉及到的运算单位,用逗号分隔。

operation [operands, ...]

🌰 :

mv  ebx, 123    ; ebx = 123
mv  ebx, ecx    ; ebx = ecx
add ebx, ecx    ; ebx += ecx
mul ebx         ; eax *= ebx 注意,乘法除法默认在eax上操作

寄存器与栈

一些重要的特殊功能寄存器。

EIP

在计算机中,cpu读取的每一条指令,其实也是存储在内存中的数据(这种指令和数据的纠葛不清也带来了很多安全问题,比如buffer overflow,但这些就跑远了)。cpu通过读取EIP所储存的内存地址中的数据来知道下一条指令是什么,同样也可以通过覆写EIP来实现跳转。跳转可以应用在许多场景中,如果是向前跳转就是分支结构(if),向后跳转就是循环(while),向某个地址跳转也可以是函数调用等等。下面是个简单的例子:

_start:
    ...// first execute some code here
    jmp skip
    ...// never gets executed

skip:
    ...// second execute some code here

栈与ESP

如上文所说,栈就是一块有LIFO性质的内存。ESP寄存器储存是栈顶的位置,所以pop相当于把ESP位置上的内容取出,然后ESP += 4, 即往下移动4个byte。push同理。

push 100    ; [100][][][][] stack top
              ^(esp)

push 302    ; [100][302][][][]
                    ^(esp)

sub esp 4   ; 先向上移动esp,再给esp所在位置赋值,和push是等价的
mov [esp], dword 345; [100][302][345][][]
                                 ^(esp)

pop eax     ;  [100][302][345][][]
                     ^(esp)
            ; 把栈顶元素储存到eax中。此时其实只移动了esp,栈的元素没有变化

mov eax, dword [esp]; 这两个操作和pop等价
add esp, 4

函数调用

函数调用和栈与ESP,EIP都息息相关。首先函数调用使用的是一个新的操作:call。它做的事情包括:

  1. 把当前EIP的值(即这个命令的地址)push到栈中
  2. 执行一个jump,覆写EIP为跳转到函数的地址
_start:
    ...         ; things before function
    call func   ; call function
    ...         ; things after function

func:
    ...         ; things in the function
    pop eax     ; get the return address from the stack
    jump eax    ; jump back to the caller

我们最后通过popjump操作来获取并跳转到返回的地址。也可以使用ret来代替这两个指令。注意到,这意味着在函数执行结束后,我们一定要保证栈顶元素就是返回地址,否则就不知道jump到哪里去了。一个普遍的做法是使用”base pointer”, 即EBP,来保证ret的是正确地址。EBP保存着在进入函数时的栈顶元素(即返回地址)。

func:
    mov ebp, esp    ; 在进入函数时,把栈顶元素存到ebp中
    ...             ; do whatever
    mov esp, ebp    ; 即将整个函数调用栈都清空,保证栈顶时返回地址
    ret             ; ret一定合法

但这个操作还有一个问题:函数中又调用了函数,比如这种执行顺序, 会使得外层函数的EBP被内层函数覆盖掉,没法返回正确的地址。

mov ebp, esp    ; function 1 called
call func2      ; function 1 call function 2
mov ebp, esp    ; function 2 called
...             ; function 2 execute
mov esp, ebp    ; function 2 returns
ret             ;
mov esp, ebp    ; function 1 returns, but ebp has already been overwritten

简单的处理办法是,把EBP也给push到栈上,然后在函数返回时pop。

push ebp        ;   储存着caller的返回地址,这样就保证不会因为覆盖ebp而丢掉
mov ebp, esp    ;   常规操作,保存当前函数的返回地址
...             ;   do whatever
mov esp, ebp    ;   清空栈
pop ebp         ;   还原caller的ebp
ret

对应到栈的情况就是

push ebp        ;   [return_address][previous_ebp][][][]    ebp=previous_ebp
mov ebp, esp    ;   ebp updated
...             ;   [return_address][previous_ebp][stuffs][][]
mov esp, ebp    ;   [return_address][previous_ebp][][][]
pop ebp         ;   [return_address][][][][]    ebp=previous_ebp       
ret             ;

mov esp, ebp; pop ebp也可以用leave代替。

函数传参和返回

函数的caller在调用函数之前,可以把参数push到栈上。所以栈的最终构成是这样的:

栈顶(ESP)
函数的本地变量 (callee push)
previous EBP(即caller的返回地址, callee push)
return address (caller push)
函数参数 (caller push)

一个简单的例子, 考虑只有一个参数(4 byte)的函数

caller:
    push 1          ; argument is 1
    call func

func:
    push ebp        ;   push previous_ebp
    mov ebp, esp    ;
    mov eax, [ebp+8];   获取参数
                    ;   +8是因为要越过previous_ebp和caller push的return address
                    ;   如果是第n个参数,就是 epb+8+4*(n-1)

其它的操作符介绍

其实主要想讲LEA。LEA(Load Effective Address)看字面意思比较难以捉摸。

The LEA instruction loads an address. If you have some varaible, you can load the address of it into a register and manipulate the data indirectly with the register as a pointer. LEA does not change any flags.

简单来说就是把一个变量的地址存到寄存器中,可以通过这个寄存器来间接修改这个变量的值。不就是指针嘛!LEA把变量的地址存到寄存器,MOV把变量的数据存到寄存器。

语法: lea dst, src 例子:

variable db 123

_start:
lea eax, variable       ;       把variable的地址存到eax中
mov byte ptr [eax], 345 ;       通过eax修改variable

代码分析示例

看看这段代码做了啥事

push    ebp                     ; 进入函数
mov     ebp, esp                ;
push    ebx                     ; ebx push到栈上
sub     esp, 0x8                ; esp -= 8,相当于开了8个字节的buffer
push    dword ptr [ebp + 0x8]   ; ebp + 0x8 就是第一个参数的位置,push到栈上,作为strcpy函数参数
lea     edx=>local_10, [ebp + -0xc]; 把ebp - 12的地址存到edx中,注意ebp - 12就是buffer的开始
push    edx                     ; edx push到栈上,作为strcpy函数参数
mov     ebx, eax                ;
call    strcpy
...

所以上述操作就是

void func(char *param){
    char local_10[8];
    strcpy(local_10, param);
}