assembly_code
本文参考了阮一峰汇编语言入门教程
引:
1 | gcc -Og -s hello.c |
实际上-Og只是为了让机器在产生汇编和机器代码时不要过于优化导致代码严重变形,便于理解
工程中追求性能时常用 -O1 或 -O2
一、汇编是什么
cpu执行的指令是二进制的,称作操作码(opcode)
编译器的作用就是将高级语言程序翻译成一条条操作码
二进制对人类是不可读的,所以产生汇编语言
汇编语言是二进制指令的文本形式,和其是一一对应的关系.比如,加法指令
00000011
写成汇编语言就是 ADD。只要还原成二进制,汇编语言就可以被 CPU 直接执行,所以它是最底层的低级语言。
二、汇编的来历
为解决二进制指令的可读性问题,产生了文本指令,执行时需要把文本指令翻译为二进制,这被称为assembling。所以就产生了assembly code(汇编语言)
目前主流的是x86汇编语言,即Intel公司的cpu所使用。
三、寄存器
CPU 本身只负责运算,不负责储存数据。数据一般都储存在内存之中,CPU 要用的时候就去内存读写数据。但是,CPU 的运算速度远高于内存的读写速度,为了避免被拖慢,CPU 都自带一级缓存和二级缓存。基本上,CPU 缓存可以看作是读写速度较快的内存。
但是,CPU 缓存还是不够快,另外数据在缓存里面的地址是不固定的,CPU 每次读写都要寻址也会拖慢速度。因此,除了缓存之外,CPU 还自带了寄存器(register),用来储存最常用的数据。也就是说,那些最频繁读写的数据(比如循环变量),都会放在寄存器里面,CPU 优先读写寄存器,再由寄存器跟内存交换数据。
寄存器不依靠地址区分数据,而依靠名称。每一个寄存器都有自己的名称,我们告诉 CPU 去具体的哪一个寄存器拿数据,这样的速度是最快的。有人比喻寄存器是 CPU 的零级缓存。
四、寄存器的种类
早期的 x86 CPU 只有8个寄存器,而且每个都有不同的用途。现在的寄存器已经有100多个了,都变成通用寄存器,不特别指定用途了,但是早期寄存器的名字都被保存了下来。
- EAX
- EBX
- ECX
- EDX
- EDI
- ESI
- EBP
- ESP
且现在的机器都是64位的了,上述E–>R
上面这8个寄存器之中,前面七个都是通用的。ESP 寄存器有特定用途,保存当前 Stack 的地址(详见下一节)。
我们常常看到 32位 CPU、64位 CPU 这样的名称,其实指的就是寄存器的大小。32 位 CPU 的寄存器大小就是4个字节。
五、内存模型:堆(Heap)
寄存器只能存放很少量的数据,大多数时候,CPU 要指挥寄存器,直接跟内存交换数据。所以,除了寄存器,还必须了解内存怎么储存数据。
程序运行的时候,操作系统会给它分配一段内存,用来储存程序和运行产生的数据。这段内存有起始地址和结束地址,比如从0x1000
到0x8000
,起始地址是较小的那个地址,结束地址是较大的那个地址。
程序运行过程中,对于动态的内存占用请求(比如新建对象,或者使用malloc
命令),系统就会从预先分配好的那段内存之中,划出一部分给用户,具体规则是从起始地址开始划分(实际上,起始地址会有一段静态数据,这里忽略)。举例来说,用户要求得到10个字节内存,那么从起始地址0x1000
开始给他分配,一直分配到地址0x100A
,如果再要求得到22个字节,那么就分配到0x1020
。
这种因为用户主动请求而划分出来的内存区域,叫做 Heap(堆)。它由起始地址开始,从低位(地址)向高位(地址)增长。Heap 的一个重要特点就是不会自动消失,必须手动释放,或者由垃圾回收机制来回收。
六、内存模型:栈(Stack)
简单说,Stack 是由于函数运行而临时占用的内存区域。结束后会自动回收··
Stack 是由内存区域的结束地址开始,从高位(地址)向低位(地址)分配。比如,内存区域的结束地址是0x8000
,第一帧假定是16字节,那么下一次分配的地址就会从0x7FF0
开始;第二帧假定需要64字节,那么地址就会移动到0x7FB0
。
七、cpu指令
实例
1 | // file:example.c |
gcc将这个程序转成汇编语言
1 | gcc -S example.c |
上面的命令执行以后,会生成一个文本文件example.s
,里面就是汇编语言,包含了几十行指令。这么说吧,一个高级语言的简单操作,底层可能由几个,甚至几十个 CPU 指令构成。CPU 依次执行这些指令,完成这一步操作。
example.s
经过简化以后,大概是下面的样子。
1 | _add_a_and_b: |
可以看到,原程序的两个函数add_a_and_b
和main
,对应两个标签_add_a_and_b
和_main
。每个标签里面是该函数所转成的 CPU 运行流程。
每一行就是 CPU 执行的一次操作。它又分成两部分,就以其中一行为例。
1 | push %ebx |
这一行里面,push
是 CPU 指令,%ebx
是该指令要用到的运算子。一个 CPU 指令可以有零个到多个运算子。
push指令
根据约定,程序从_main
标签开始执行,这时会在 Stack 上为main
建立一个帧,并将 Stack 所指向的地址,写入 ESP 寄存器。后面如果有数据要写入main
这个帧,就会写在 ESP 寄存器所保存的地址。
然后,开始执行第一行代码。
1 | push 3 |
push
指令用于将运算子放入 Stack,这里就是将3
写入main
这个帧。
虽然看上去很简单,push
指令其实有一个前置操作。它会先取出 ESP 寄存器里面的地址,将其减去4个字节,然后将新地址写入 ESP 寄存器。使用减法是因为 Stack 从高位向低位发展,4个字节则是因为3
的类型是int
,占用4个字节。得到新地址以后, 3 就会写入这个地址开始的四个字节。
1 | push 2 |
第二行也是一样,push
指令将2
写入main
这个帧,位置紧贴着前面写入的3
。这时,ESP 寄存器会再减去 4个字节(累计减去8)。
call指令
第三行的 call 的指令用来调用函数
1 | call _add_a_and_b |
上面的代码表示调用add_a_and_b
函数。这时,程序就会去找_add_a_and_b
标签,并为该函数建立一个新的帧。
下面就开始执行_add_a_and_b
的代码。
1 | push %ebx |
这一行表示将 EBX 寄存器里面的值,写入_add_a_and_b
这个帧。这是因为后面要用到这个寄存器,就先把里面的值取出来,用完后再写回去。
这时,push
指令会再将 ESP 寄存器里面的地址减去4个字节(累计减去12)。
mov 指令
mov
指令用于将一个值写入某个寄存器。
1 | mov %eax, [%esp+8] |
这一行代码表示,先将 ESP 寄存器里面的地址加上8个字节,得到一个新的地址,然后按照这个地址在 Stack 取出数据。根据前面的步骤,可以推算出这里取出的是2
,再将2
写入 EAX 寄存器。
下一行代码也是干同样的事情。
1 | mov %ebx, [%esp+12] |
上面的代码将 ESP 寄存器的值加12个字节,再按照这个地址在 Stack 取出数据,这次取出的是3
,将其写入 EBX 寄存器。
add指令
add
指令用于将两个运算子相加,并将结果写入第一个运算子。
1 | add %eax, %ebx |
上面的代码将 EAX 寄存器的值(即2)加上 EBX 寄存器的值(即3),得到结果5,再将这个结果写入第一个运算子 EAX 寄存器。
pop指令
pop
指令用于取出 Stack 最近一个写入的值(即最低位地址的值),并将这个值写入运算子指定的位置
1 | pop %ebx |
上面的代码表示,取出 Stack 最近写入的值(即 EBX 寄存器的原始值),再将这个值写回 EBX 寄存器(因为加法已经做完了,EBX 寄存器用不到了)。
注意,pop
指令还会将 ESP 寄存器里面的地址加4,即回收4个字节。
ret指令
ret指令用终止当前函数的执行, 将运行权交还给上层函数。也就是,当前函数的帧将被回收。
1 | ret |
可以看到,该指令没有运算子
随着add_a_and_b函数终止执行,系统就回到刚才main函数中断的地方,继续往下执行。
1 | add %esp, 8 |
上面的代码表示,将ESP寄存器里面的地址,手动加上8个字节,再写回ESP寄存器。这是因为ESP寄存器的是Stack的写入开始地址,前面的pop
操作已经回收了4个字节,这里再回收8个字节,等于全部回收。
1 | ret |
最后,main
函数运行结束,ret
指令退出程序执行。