回忆一下汇编的世界(1)

回忆一下汇编程序的编写,最后用两个小例子,结束回忆的第一章。

一台计算机

远离实际的业务场景,如果我们仅仅考虑一个计算过程,那么计算机还是挺简单的。

简单聊聊图灵机和现代计算机

恰好最近在读SICP,写了点scheme,看了看turing machine的paper,感觉对计算机的理解又深入了那么一点点,简单来说turing machine是一个一直处理一条打孔纸带,一直到停机,在处理的过程中,会产生中间状态,并能根据纸带和中间状态决定下一次的操作,所以这个计算机的模型,也是利用存储加上指令来决定下一个状态,模拟了一个计算过程,计算从初始状态通过一个一个的中间状态到达了最后的终止状态

那么在turing machine里面抽象了整个计算的过程,而真正的CPU构造,就下面这五个部分了:

  • 寄存器
  • ALU
  • 数据总线
  • 指令计数器
  • 程序计数器

我们先想一下,我们日常写代码的时候,一般有哪几种结构呢?

  • 线性结构
  • 分支结构
  • 循环结构

其实也就这三种代码结构了,线性结构就是正常的几行代码的书写方式,而分支结构也就是if-else了,循环结构其实包含了递归和递推,简单来说就是for循环这样子。但是因为我们的程序最后一定会被编译成一个类似于纸带一样的线性结构,那么如何在这个线性结构上跳转呢,又如何在线性结构上利用跳转完成循环呢?

上面这个问题,图灵老爷子已经给出了他的turing machine模型,也就是本节开头的一句话利用存储加上指令来决定下一个状态,模拟了一个计算过程,计算从初始状态通过一个一个的中间状态到达了最后的终止状态.

汇编

图灵老爷子做的抽象实在太数学系了,但是还好还是有人把他的抽象弄成了计算机,只不过这个时候的计算机执行的程序和盲文一样,就是传说中的打孔纸带,一堆1010一样的二进制,那个时候的程序员可以想象在用一个针戳一个纸带,然后在机器上跑一波,虽然就是计算一个简单的加减乘除,但是还是好麻烦哦,人类永远是懒惰的,所以,后面就有人发明了汇编这个东西。

最开始学习汇编的时候,还是学习操作系统的时候学习bootloader的时候看过这个东西,一直没有用汇编写什么东西,但是这个东西貌似在工业界用的还比较多的,因为工业界的语言写完了以后编译完了以后,就会变成一条超级长的打孔纸带,然后跑在CPU的某个核心上,然后呢,其实如果有接口,可以直接通过汇编来执行,实现某些性能敏感的操作,或者不那么好搞的操作。

  • golang 获取 goroutine的ID
  • Java ASM 代理
  • gcc 搞出来的内存屏障

反正以上应该都属于汇编在工业界中的一些小应用,当然在JVM上跑的也是指令,所以基本也可以认为是汇编在搞事情。那现在就开始捣腾点纯正的ATT汇编吧。

关于组成

其实想学会汇编,真的挺依赖计算机组成的,主要依赖计算机体系结构,当然如果要跑在linux上来做系统调用的汇编代码,可能还要了解一下linux的ABI规范,不过这份文章也不是讲计算机组成的,但是组成又还挺重要,所以简单说一下吧。

一个简单的计算机其实只需要内存和cpu就足够了,让通过一些电路连线将两个东西链接到一起,其实就能做一些简单的计算了,这里就不考虑底层的I/O的情况了,就做简单的计算。

那么现代的计算机体系,基本都是冯老爷子捣腾出来的那个架构的扩展,也就是以内存为中心的体系结构,那么这种架构呢,其实很像turing machine的具象版本,通过寄存器来保存中间状态,通过内存寻址来实现纸带的移动,那么在X86的架构机器上,这些东西都是什么呢?

Registers

x86的体系结构上,寄存器属于CPU的片内组成部分,这些寄存器在每个核上都是一样的,一般分为:

  • 通用寄存器(EAX,EBX,ECX,EDX)
  • 地址寄存器 (ESP,EBP)
  • 索引寄存器 (ESI,EDI)
  • 标志寄存器 (Eflags)

而汇编中的指令其实就是在操作这些寄存器,当然还有下面的内存操作。

Addressing

这个就是寻址了,通过寻址,我们可以很快地找到某个内存块,然后在这个内存块上做一些增删改查的操作,比如

1
mov string_addr(,%ecx,1), %eax

上面一句就是将string_addr中的以%ecx为索引取出1个字节的数据,放到%eax寄存器中。

在所有的汇编语法中,寻址都是几种方式

  • 直接寻址(mov ADDRESS, %eax) eax= &ADDRESS
  • 索引寻址(mov str(,%ecx,1), %eax) eax = str[ecx*1]
  • 间接寻址(mov (%ecx), %eax) eax = *ecx
  • 基址寻址(mov 4(%ebx), %eax) eax = *(ebx + 4)
  • 立即寻址(mov $1, %eax) eax = 1
  • 寄存器寻址(mov %ecx, %eax) eax = ecx

对着每种寻址的方式后的C代码,大概就知道是怎么进行寻址了。

当然啦,其中的基址寻址方式遵循这样的一个公式 offset(basement, index, factor) 这个公式中,计算地址的公式 basement + offset + index * factor,四个变量的含义从左到右分别是,偏移量,基址寄存器,索引寄存器,比例因子,如果写汇编的时候某个部分不填写,默认的就是0。

关于函数

如果没有函数,那么整个计算机的世界还挺糟糕的,在汇编里这个事情更加的明显,没有函数我们要重复地写很多逻辑,这就非常糟糕了,那么在汇编里函数怎么实现呢?考虑一下turing machine模型,函数就是纸带上的某一部分,那么这个函数执行完了就需要跳回到调用这个函数的位置的下一行执行,那么这个过程就好像是纸带在滚动,如果有这个概念就好理解多了。

这里就介绍一些C语言的汇编调用规则吧,其实也可以算是ABI的一部分了。

还记得之前学C语言的时候老师们一直在说什么保存现场然后执行函数内指令,执行完成之后恢复现场,返回值这个时候一定是放在了某个寄存器里面,供调用函数的代码来拿。

CALL 指令

既然是调用函数,那么就使用CALL指令就好了,根据上面说的,要保存现场,那么CALL还有调用之前的操作应该就是保存现场了。

考虑这样一段C代码

1
2
a = function(1);
printf("%d", a);

这样的一段C代码看起来调用了一个函数,然后将结果存储在a里面,那么 可以描述成这样的一段伪代码

1
2
3
4
pushl $1 # 将参数压入栈,等待function进行获取参数
call function # 调用function
movl %eax, a # 将结果放在a变量里
printf (a) # 伪指令,假设我们有这么一个指令吧

如果我们的function在C语言中这么定义的话

1
2
3
int function(int v){
return v + 1;
}

假设我们没有函数调用的规定,简单地使用宏替换的思路,那么整个代码会变成这个样子

1
2
3
4
5
pushl $1 # 将参数压入栈,等待function进行获取参数
pop %eax # 把参数拿出来
addl $1, %eax # 执行计算
movl %eax, a # 将结果放在a变量里
printf (a) # 伪指令,假设我们有这么一个指令吧

这样看起来就简单多了,但是现实总是没有这么好,因为我们的汇编代码会在一个文件内,但是并不支持展开,那么怎么办呢,让我们回想一下turing machine ,我们只要能让我们的纸带来回移动就好了。如何移动呢?这里就展开了我们的函数调用规则(C语言)

  • 将当前环境中的寄存器变量压入栈(保存现场,保存当前代码的局部变量)
  • 将调用参数逆序地压入栈中 (C语言的调用规范)
  • 使用call指令调用函数

那么这里的call指令做了两件事:

  • 将下一条指令的地址压入了栈中 (上面代码中的 printf指令)
  • 修改寄存器%eip为函数中第一条指令的地址 (也就是 return v + 1 这条语句的地址上)

那么在函数function 内部应该首先做什么呢,首先我们要保存当前的%ebp寄存器的地址,也就是我们需要保存现场的另外一个部分(保存基址),然后我们把当前%esp的地址给%ebp,因为这样我们就可以通过%ebp来进行基址寻址了,这样我们才能拿到我们的参数,然后我们要恢复%esp到调用call指令时压入栈的那个%esp的值。

RET 指令

RET指令主要工作就是直接把当前的%esp指针指向的位置的值,拿出来放到%eip中,这样就能进行下一步的操作了,但是在函数返回的时候,我们其实做了三件事

  • 将结果放入%eax
  • 恢复栈指针和%ebp
  • 调用ret指令

一个函数实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
.section .data
.section .text
.global _start
_start:
pushl $3
pushl $2
call power
addl $8, %esp # 恢复esp到调用之前的状态,因为调用之前push了两个结果,所以倒退回去8个字节
movl %eax, %ebx # 设置返回值为函数执行后的值
movl $1, %eax
int $0x80

.type power @function # 汇编指令,告诉汇编器这个power是一个函数地址
power:
pushl %ebp # 保存当前的基地址
movl %esp, %ebp # 让我们的ebp保存的是当前的栈指针地址,但是返回地址在这个地址之前4个字节
subl $4, %esp # 相当于开辟了一个局部变量存储中间值
movl 8(%ebp), %ebx
movl 12(%ebp), %ecx
movl %ebx, -4(%ebp)

power_loop:
cmp $1, %ecx
je power_exit
movl -4(%ebp), %eax
imull %ebx, %eax # 进行一次幂运算
movl %eax, -4(%ebp)
decl %ecx # 减少计算次数
jmp power_loop

power_exit:
movl -4(%ebp), %eax # 保存返回值
movl %ebp, %esp # 恢复栈指针
popl %ebp # 恢复基址,这次弹出也就将我们的esp成功恢复到了那个有返回地址的槽了
ret # 调用ret

两个例子

查找最大值

给一个列表查找其中最大数字,首先看看C语言版本的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main(){
int[] data_items = {3, 67, 34, 222, 45, 75, 54, 34, 44, 33, 22, 11, 66,0};
int eax, ebx;
int idx = 1;
eax = data_items[0];
ebx = eax;
for(;eax != 0;){
eax = data_items[idx];
idx++;
if (eax < ebx)coninue;
ebx = eax;
}
return ebx;
}

再来看看汇编版本的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
.section .data
data_items:
.long 3, 67, 34, 222, 45, 75, 54, 34, 44, 33, 22, 11, 66,0
.section .text
.global _start
_start:
movl $0, %edi
movl data_items(, %edi, 4), %eax # data_items 内存储的是32bit字节的数据,也就是4位,所以factor是4
movl %eax, %ebx

start_loop:
cmpl $0, %eax # 检测是否到达了末尾,因为最后一个数字是0
je loop_exit
incl $edi
movl data_items(, %edi, 4), %eax
cmpl %ebx, %eax
jle start_loop
movl %eax, %ebx
jmp start_loop

loop_exit:
movl $1, %eax
int $0x80

计算阶乘

C语言代码

1
2
3
4
5
6
7
8
int factorial(int n){
if(n == 1) return n;
return n * factorial(n-1)
}

int main(){
return factorial(3);
}

汇编语言的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
.section .data
.section .text
.global _start
_start: # 上面C代码的main函数
pushl $3
call factorial
addl $4, %esp
movl %eax, %ebx
movl $1, %eax
int $0x80

.type factorial @function
factorial:
pushl %ebp
movl %esp, %ebp
movl 8(%ebp), %eax # 获取参数
cmpl $1, %eax
je end_factorial
decl %eax
pushl %eax
call factorial
movl 8(%ebp), %ebx # 获取上一个%eax 也就是n
imull %ebx, %eax # 计算 n * (n-1)

end_fatorial:
movl %ebp, %esp
popl %ebp
ret