汇编入门知识

汇编语言

汇编语言是二进制指令的文本形式,与指令是一一对应的关系。比如,加法指令00000011写成汇编语言就是 ADD。只要还原成二进制,汇编语言就可以被 CPU 直接执行,所以它是最底层的低级语言。

寄存器

早期的 x86 CPU 只有8个寄存器,而且每个都有不同的用途。现在的寄存器已经有100多个了,都变成通用寄存器,不特别指定用途了,但是早期寄存器的名字都被保存了下来。

  • EAX
  • EBX
  • ECX
  • EDX
  • EDI
  • ESI
  • EBP
  • ESP

ESP 寄存器有特定用途,保存当前 Stack 的地址

CPU 指令

一个实例

了解寄存器和内存模型以后,就可以来看汇编语言到底是什么了。下面是一个简单的程序example.c

 int add_a_and_b(int a, int b) {
    return a + b;
 }
 
 int main() {
    return add_a_and_b(2, 3);
 }

gcc 将这个程序转成汇编语言。

 $ gcc -S example.c

上面的命令执行以后,会生成一个文本文件example.s,里面就是汇编语言,包含了几十行指令。这么说吧,一个高级语言的简单操作,底层可能由几个,甚至几十个 CPU 指令构成。CPU 依次执行这些指令,完成这一步操作。

example.s经过简化以后,大概是下面的样子。

 _add_a_and_b:
    push   %ebx
    mov    %eax, [%esp+8] 
    mov    %ebx, [%esp+12]
    add    %eax, %ebx 
    pop    %ebx 
    ret  
 
 _main:
    push   3
    push   2
    call   _add_a_and_b 
    add    %esp, 8
    ret

可以看到,原程序的两个函数add_a_and_bmain,对应两个标签_add_a_and_b_main。每个标签里面是该函数所转成的 CPU 运行流程。

每一行就是 CPU 执行的一次操作。它又分成两部分,就以其中一行为例。

 push   %ebx

这一行里面,push是 CPU 指令,%ebx是该指令要用到的运算子。一个 CPU 指令可以有零个到多个运算子。

push 指令

根据约定,程序从_main标签开始执行,这时会在 Stack 上为main建立一个帧,并将 Stack 所指向的地址,写入 ESP 寄存器。后面如果有数据要写入main这个帧,就会写在 ESP 寄存器所保存的地址。

然后,开始执行第一行代码。

 push   3

push指令用于将运算子放入 Stack,这里就是将3写入main这个帧。

虽然看上去很简单,push指令其实有一个前置操作。它会先取出 ESP 寄存器里面的地址,将其减去4个字节,然后将新地址写入 ESP 寄存器。使用减法是因为 Stack 从高位向低位发展,4个字节则是因为3的类型是int,占用4个字节。得到新地址以后, 3 就会写入这个地址开始的四个字节。

 push   2

第二行也是一样,push指令将2写入main这个帧,位置紧贴着前面写入的3。这时,ESP 寄存器会再减去 4个字节(累计减去8)。

call 指令

第三行的call指令用来调用函数。

 call   _add_a_and_b

上面的代码表示调用add_a_and_b函数。这时,程序就会去找_add_a_and_b标签,并为该函数建立一个新的帧。

下面就开始执行_add_a_and_b的代码。

 push   %ebx

这一行表示将 EBX 寄存器里面的值,写入_add_a_and_b这个帧。这是因为后面要用到这个寄存器,就先把里面的值取出来,用完后再写回去。

这时,push指令会再将 ESP 寄存器里面的地址减去4个字节(累计减去12)。

mov 指令

mov指令用于将一个值写入某个寄存器。

mov    %eax, [%esp+8] 

这一行代码表示,先将 ESP 寄存器里面的地址加上8个字节,得到一个新的地址,然后按照这个地址在 Stack 取出数据。根据前面的步骤,可以推算出这里取出的是2,再将2写入 EAX 寄存器。

下一行代码也是干同样的事情。

mov    %ebx, [%esp+12] 

上面的代码将 ESP 寄存器的值加12个字节,再按照这个地址在 Stack 取出数据,这次取出的是3,将其写入 EBX 寄存器。

add 指令

add指令用于将两个运算子相加,并将结果写入第一个运算子。

add    %eax, %ebx

上面的代码将 EAX 寄存器的值(即2)加上 EBX 寄存器的值(即3),得到结果5,再将这个结果写入第一个运算子 EAX 寄存器。

pop 指令

pop指令用于取出 Stack 最近一个写入的值(即最低位地址的值),并将这个值写入运算子指定的位置。

pop    %ebx

上面的代码表示,取出 Stack 最近写入的值(即 EBX 寄存器的原始值),再将这个值写回 EBX 寄存器(因为加法已经做完了,EBX 寄存器用不到了)。

注意,pop指令还会将 ESP 寄存器里面的地址加4,即回收4个字节。

ret 指令

ret指令用于终止当前函数的执行,将运行权交还给上层函数。也就是,当前函数的帧将被回收。

ret

可以看到,该指令没有运算子。

随着add_a_and_b函数终止执行,系统就回到刚才main函数中断的地方,继续往下执行。

add    %esp, 8 

上面的代码表示,将 ESP 寄存器里面的地址,手动加上8个字节,再写回 ESP 寄存器。这是因为 ESP 寄存器的是 Stack 的写入开始地址,前面的pop操作已经回收了4个字节,这里再回收8个字节,等于全部回收。

ret

最后,main函数运行结束,ret指令退出程序执行。

实例二

假设我们现在有一个要求,把1和2相加,然后把结果放到内存里面,最后再把内存里的结果取出来。

那么按理说,我们就应该这么写代码:

global main

main:
    mov ebx, 1
    mov ecx, 2
    add ebx, ecx

    mov [0x233], ebx
    mov eax, [0x233]

    ret

好了,编译运行,假如程序是danteng,那么运行结果应该是这样:

$ ./danteng ; echo $?
3

实际上,并不能行。程序挂了,没有输出我们想要的结果。

这里有另外一个问题,那就是“我们的程序运行在一个受管控的环境下,是不能随便读写内存的”。这里需要特殊处理一下。

程序应该改成这样才行:

global main

main:
    mov ebx, 1
    mov ecx, 2
    add ebx, ecx

    mov [sui_bian_xie], ebx
    mov eax, [sui_bian_xie]

    ret

section .data
sui_bian_xie   dw    0

这下运行,我们得到了结果:

$ ./danteng ; echo $?
3

有了程序,咱们来梳理一下每一条语句的功能:

mov ebx, 1                   ; 将ebx赋值为1
mov ecx, 2                   ; 将ecx赋值为2
add ebx, ecx                 ; ebx = ebx + ecx

mov [sui_bian_xie], ebx      ; 将ebx的值保存起来
mov eax, [sui_bian_xie]      ; 将刚才保存的值重新读取出	来,放到eax中

ret                          ; 返回,整个程序最后的返回值,就是eax中的值

好了,到这里想必你基本也明白是怎么一回事了,有几点需要专门注意的:

  • 程序返回时eax寄存器的值,便是整个程序退出后的返回值,这是当下我们使用的这个环境里的一个约定,我们遵守便是

与前面那个崩溃的程序相比,后者有一些微小的变化,还多了两行代码

section .data
sui_bian_xie   dw    0

第一行表示接下来的内容经过编译后,会放到可执行文件的数据区域,同时也会随着程序启动的时候,分配对应的内存。

第二行就是描述真实的数据的关键所在里,这一行的意思是开辟一块16位的空间,并且里面用0填充。这里的dw(define word)就表示16位(dd指define double word,定义了32bits的空间),前面那个sui_bian_xie的意思就是空间名,这个sui_bian_xie会在编译时被编译器处理成一个具体的地址。

jmp指令

eip寄存器存储下一条指令的位置

跳转指令,能修改eip,类似于C语言中的goto语句

global main

main:
    mov eax, 1
    mov ebx, 2

    jmp gun_kai

    add eax, ebx
gun_kai:
    ret
if的实现
int main() {
    int a = 50;
    if( a > 10 ) {
        a = a - 10;
    }
    return a;
}

对应的汇编程序

global main

main:
    mov eax, 50
    cmp eax, 10                         ; 对eax和10进行比较
    jle xiaoyu_dengyu_shi            ; 小于或等于的时候跳转
    sub eax, 10
xiaoyu_dengyu_shi:
    ret
  • 第一条,cmp指令,专门用来对两个数进行比较
  • 第二条,条件跳转指令,当前面的比较结果为“小于或等于”的时候就跳转,否则不跳转
ja 大于时跳转
jae 大于等于
jb 小于
jbe 小于等于
je 相等
jna 不大于
jnae 不大于或者等于
jnb 不小于
jnbe 不小于或等于
jne 不等于
jg 大于(有符号)
jge 大于等于(有符号)
jl 小于(有符号)
jle 小于等于(有符号)
jng 不大于(有符号)
jnge 不大于等于(有符号)
jnl 不小于
jnle 不小于等于
jns 无符号
jnz 非零
js 如果带符号
jz 如果为零
  • a: above
  • e: equal
  • b: below
  • n: not
  • g: greater
  • l: lower
  • s: signed
  • z: zero
if else的实现
int main() {
    register int grade = 80;
    register int level;
    if ( grade >= 85 ){
        level = 1;
    } else if ( grade >= 70 ) {
        level = 2;
    } else if ( grade >= 60 ) {
        level = 3;
    } else {
        level = 4;
    }
    return level;
}

反汇编得到

Dump of assembler code for function main:
   0x080483ed < +0>:    push   ebp
   0x080483ee < +1>:    mov    ebp,esp
   0x080483f0 < +3>:    push   ebx
   0x080483f1 < +4>:    mov    ebx,0x50
   0x080483f6 < +9>:    cmp    ebx,0x54
   0x080483f9 <+12>:    jle    0x8048402 <main+21>
   0x080483fb <+14>:    mov    ebx,0x1
   0x08048400 <+19>:    jmp    0x804841f <main+50>
   0x08048402 <+21>:    cmp    ebx,0x45
   0x08048405 <+24>:    jle    0x804840e <main+33>
   0x08048407 <+26>:    mov    ebx,0x2
   0x0804840c <+31>:    jmp    0x804841f <main+50>
   0x0804840e <+33>:    cmp    ebx,0x3b
   0x08048411 <+36>:    jle    0x804841a <main+45>
   0x08048413 <+38>:    mov    ebx,0x3
   0x08048418 <+43>:    jmp    0x804841f <main+50>
   0x0804841a <+45>:    mov    ebx,0x4
   0x0804841f <+50>:    mov    eax,ebx
   0x08048421 <+52>:    pop    ebx
   0x08048422 <+53>:    pop    ebp
   0x08048423 <+54>:    ret  
状态寄存器

eflags

作用就是记住一些特殊的CPU状态,比如前一次运算的结果是正还是负、计算过程有没有发生进位、计算结果是不是零等信息,而后续的跳转指令,就是根据eflags寄存器中的状态,来决定是否要进行跳转的。

循环

while循环
int sum = 0;
int i = 1;
while( i <= 10 ) {
    sum = sum + i;
    i = i + 1;
}
global main

main:
    mov eax, 0
    mov ebx, 1
_start:
    cmp ebx, 10
    jg _end_of_block

    add eax, ebx
    add ebx, 1
    jmp _start

_end_of_block:
    ret

函数调用

call指令

  • 本质上也是跳转,但是跳到目标位置之前,需要保存“现在在哪里”的这个信息,也就是eip
  • 整个过程由一条指令call完成
  • 后面可以用ret指令跳转回来
  • call指令保存eip的地方叫做栈,在内存里,ret指令执行的时候是直接取出栈中保存的eip值,并恢复回去达到返回的效果
global main

eax_plus_1s:
    add eax, 1
    ret

ebx_plus_1s:
    add ebx, 1
    ret

main:
    mov eax, 0
    mov ebx, 0
    call eax_plus_1s
    call eax_plus_1s
    call ebx_plus_1s
    add eax, ebx
    ret

运行程序,得到结果:3

push和pop指令

int fibo(int n) {
    if(n == 1 || n == 2) {
        return 1;
    }
    return fibo(n - 1) + fibo(n - 2);
}
 fibo:
     cmp eax, 1
     je _get_out
     cmp eax, 2
     je _get_out
   
     mov edx, eax
     sub eax, 1
     call fibo
     mov ebx, eax
   
     mov eax, edx
     sub eax, 2
     call fibo
     mov ecx, eax
   
     mov eax, ebx
     add eax, ecx
     ret
   
 _get_out:
     mov eax, 1
     ret

上述汇编是错误的,原因是CPU中的寄存器是全局可见的。所以使用寄存器,实际上就是在使用一个像全局变量一样的东西。递归下层对寄存器的修改改变了上层函数中寄存器(存储变量)的值。

push,将当前的寄存器保存起来
pop,将堆栈中保存的寄存器恢复回来

修改为以下

 fibo:
 global main
 
 fibo:
     cmp eax, 1
     je _get_out
     cmp eax, 2
     je _get_out
   
     push ebx
     push ecx
     push edx
   
     mov edx, eax
     sub eax, 1
     call fibo
     mov ebx, eax
   
     mov eax, edx
     sub eax, 2
     call fibo
     mov ecx, eax
   
     mov eax, ebx
     add eax, ecx
   
     pop edx
     pop ecx
     pop ebx
   
     ret
   
 _get_out:
     mov eax, 1
     ret
 
 main:
     mov eax, 7
     call fibo
     ret
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值