这篇文章从汇编语言的角度来探究机器到底如何执行一条指令。

汇编语言

汇编语言(英语:assembly language)是一种用于电子计算机、微处理器、微控制器,或其他可编程器件的低级语言。在不同的设备中,汇编语言对应着不同的机器语言指令集。一种汇编语言专用于某种计算机系统结构,而不像许多高级语言,可以在不同系统平台之间移植。

这是维基百科上给出的定义。

说白了汇编语言是对计算机直接认识的字节序列的一种低级可读性表示。

指令和数据从哪儿来

我们知道机器不认识源代码,无论是C语言还是Java还是Go语言,计算机统统无感,它只认识字节序列,比如下面就是一段机器可以认识并执行的指令(16进制表示):

55 89 e5 8b 45 ……

一下是一段比较完整代码的机器字节序列和汇编语言代码的对照表示(16进制)。

Offset Bytes 汇编语言表示
0 55 push %ebp
1 89 e5 mov %esp,%ebp
3 8b 45 0c mov 0xc(%ebp),%eax
6 03 45 08 add 0x8(%ebp),%eax
9 01 05 00 00 00 00 add %eax,0x0
f 5d pop %ebp
10 c3 ret

Offset 一列代表字节序列的相对地址偏移,Bytes 一列代表机器真正执行的字节序列。

以 IA-32 为例,它认识的指令长度从1到15字节不等,字节和机器指令是一一对应的,比如只有 push %ebp 这个指令是以字节 55 开头的。

后面我们还会提到其他的指令,这些指令可以被机器直接识别,所有机器可以识别的指令构成的集合即为指令集

光有了指令还不行,我们知道现代的计算机架构是基于存储的,CPU 知道了自己要执行什么指令,接下来就要知道我从哪儿取数据,对数据进行操作完之后要放到哪儿。

用计算机语言说就是源操作数和目的操作数是什么。

那么指令到哪儿取?操作数又从哪儿来?

下面的一张图展示了CPU、寄存器和内存三者之间的关系。

寄存器内存

CPU访问内存需要通过两步来进行:1.内存到寄存器 2.CPU访问寄存器中的内容。

也就是说CPU不能直接访问内存中的数据

为什么不直接从内存中取呢?

原因很简单:CPU运算能力太快,而从内存中取指令或者数据匹配不上它惊人的运算速度。所以在 CPU 中集成了寄存器来存放数据或者指令,现代的CPU通过不断的发展,中间又加了缓存,一级缓存不够,再加二级缓存,二级缓存不够,再加三级缓存。缓存中如果有数据则可以省去从内存中获取数据或者指令的过程。

那么寄存器长什么样呢?

一个 IA-32 CPU包含一组 8 个存储 32 位值的寄存器。

这些寄存器用来存储整数数据和指针。可以看到这些寄存器数量很少,也很小,因为CPU 的资源非常宝贵。

寄存器

字节操作指令可以独立读写前 4 个寄存器的 2 个低位字节。

CPU 如何取指令呢?

这里使用到了一个寄存器叫程序计数器(PC),在该寄存器中存储的是将要执行的下一条指令在寄存器的地址。

CPU 就像一个不知疲倦的士兵一样,永远充满活力地在那儿不停的获取命令、执行命令。

在IA-32中,通常使用 %eip 表示该寄存器。

现在有了指令了,那么操作数从哪儿来?

操作数形式可以分为三种:

  1. 立即数,也就是常数值,可以将32位的立即数直接放置到一个寄存器中

  2. 寄存器,表示某个寄存器的内容。

  3. 存储器引用。

    会根据计算出来的地址访问某个存储器的位置。

也就是说,寄存器中存放的可能是一个数值,直接提供给CPU进行运算,也可能是内存中的一个地址,CPU把内存中这个地址的数据读到寄存器中,然后再从寄存器中取到数据进行计算。

所以这就涉及到了不同的寻址方式。

下面表格列出了常用的几种寻址方式,Ea 表示任意寄存器a,R[Ea]表示该寄存器的值,M[Addr]表示对存储器中地址 Addr 开始的b个字节的引用。

类型 格式 操作数值 名称
立即数 $Imm Imm 立即数寻址
寄存器 Ea R[Ea] 寄存器寻址
存储器 Imm M[Imm] 绝对寻址
存储器 (Ea) M[R[Ea]] 间接寻址
存储器 Imm(Eb) M[Imm+R[Eb]] 基址+偏移量寻址
存储器 (Ea,Eb) M[R[Ea]+R[Eb] 变址寻址
存储器 Imm(Ea,Eb) M[Imm+R[Ea]+R[Eb]] 变址寻址
存储器 (,Ea,s) M[R[Ea]*s] 比例变址寻址
存储器 Imm(,Ea,s) M[Imm+R[Ea]*s] 比例变址寻址
存储器 (Ea,Eb,s) M[R[Ea]+R[Eb]*s] 比例变址寻址
存储器 Imm(Ea,Eb,s) M[Imm + R[Ea]+R[Eb]*s] 比例变址寻址

到这里,我们已经知道了CPU从哪儿取指令,从哪儿取数据或者地址执行。

常用指令介绍

接下来我们简单介绍几个最常用的指令。

  1. 数据传送指令

    顾名思义,就是将数据从一个地方放到另外一个地方。

    在汇编语言中,使用的是 MOV 指令,这是一类指令,使用方式就是

    MOV S(源操作数), D(目的操作数)。

    当然 MOV 类指令细分还有 movb,movw,movl等等,此处暂不深究。

    需要注意的是:S 和 D 不能同时为存储器(前面讲过,CPU不能直接从存储器获取数据)

  2. 加载有效地址指令

    LEAL 指令。

    使用方式为:

    LEAL S, D

    其效果就相当于将 &S (S的地址)存入D中。

    除此之外,它还有另外一种作用:

    看下面一个例子

    LEAL 7(%EDX,%EDX,4) %EAX

    如果%EDX存放的值为 x,那么这条指令的作用就是将 %EAX 寄存器的值设置为 5x+7.(对照上面的寻址方式表)

  3. DEC/INC 指令

    使用方式

    INC D

    DEC D

    作用就是将操作数D加一或减一。

    ADD/SUB指令也同理。

  4. IMUL 指令

    使用方式如下:

    IMUL S, D

    该语句的作用就是将 D*S 的结果存入 D 中。

  5. SAL/SAR 指令

    移位指令。SAL左移,SAR 右移。

还有一些其它指令这里不详细列出。

下面通过一个具体的例子来看下,CPU如何通过对寄存器的指令操作,实现一段代码的。

1
2
3
4
5
6
int exchange(int *xp, int y)
{
int x = *xp;
*xp = y;
return x;
}

这段代码的汇编语言版本如下:

1
2
3
4
5
# xp 在 %ebp+8 的地址偏移上,y 在%ebp+12的地址偏移上
movl 8(%ebp),%edx # 获取 xp指向的地址的值,即 *xp
movl (%edx),%eax # 将 *xp 的值赋给 x,用于返回(一般用%eax存储函数的返回值)
movl 12(%ebp),%ecx # 获取y
movl %ecx,(%edx) #将y 的值赋给 *xp

可以看到,CPU 通过执行 movl 指令实现了上面的一个函数。

以上函数的实现逻辑非常简单,一路顺序执行下来,那么如果程序中有 if-else或者 for 循环等逻辑呢?

程序如何实现控制和循环

条件码寄存器

这就需要一类特殊的寄存器了,名曰条件码寄存器(Condition Code register)。

这个存储器中存放的就是最近一次的算术或者逻辑运算的属性,CPU 通过检测该寄存器,就可以知道上一次执行的结果。

相当于一个 flag 标志。CPU 通过检测该 flag,来决定执行什么逻辑。

这些 flag 有:

  • CF:进位标志。最近操作使最高位产生了进位。
  • ZF:零标志。最近的操作得出的结果为0。
  • SF:符号标志。最近操作得到的结果为负数。
  • OF:溢出标志。最近的操作导致一个补码溢出——正溢出或者负溢出。

比如 CMP 指令,该指令用来比较两个操作数。它会根据两个操作数之差来设置条件码寄存器的值,TEST指令或根据两个操作数之和来设置条件码寄存器的值。

跳转指令

除了上面的寄存器之外,跳转指令是另外一个依赖的东西。跳转指令实现的是让CPU执行新地址上的一条指令。

1
2
3
4
5
    movl $0,%eax
jmp .L1
movl (%eax),%edx
.L1:
popl %edx

在上面的汇编程序中,指令 jmp 会导致程序跳过 movl 指令,从 popl 指令开始执行。

当然这个是无条件跳转,而下面表格中几条指令实现的是有条件跳转:

指令 同义指令 跳转条件 描述
je jz ZF 相等/零
js -SF 负数
jg jnle -(SF^OF) & -ZF 大于
jl jnge SF^OF 小于
…… …… …… ……

上表中的跳转条件一列检查的显然是前面提到的条件码寄存器中的值。

下面是有条件跳转的一个例子:

1
2
3
4
5
6
7
8
9
10
	jle	.L2
.L5:
movl %edx,%eax
sarl %eax
subl %eax,%edx
leal (%edx,%edx,2),%edx
testl %edx,%edx
jg .L5
.L2:
movl %edx,%eax

无论是无条件跳转还是有条件跳转,可以看到最终跳转指令是利用一个 label 作为跳转标签的,那么在CPU执行中是怎么知道 label 下一行指令所在地址呢?

这就需要另外一个寄存器了:程序计数器PC(Program Counter)。

可能大家会想,PC中存放的一定是下一条指令的地址,以上面程序为例,jle 进行跳转时,PC中存放的是第10行movl指令的地址。

实际情况却不是这样。

PC中不是要跳转到的目标指令的地址,而是紧跟在跳转指令后面那条指令地址

听起来有点绕,举个例子:

上面那段代码反汇编的版本如下:

1
2
3
4
5
6
7
8
0x08:	7e	0d				jle 17 <silly+0x17>
0x0a: 89 d0 mov %edx,%eax
0x0c: d1 f8 sar %eax
0x0e: 29 c2 sub %eax,%edx
0x10: 8d 14 52 lea (%edx,%edx,2),%edx
0x13: 85 d2 test %edx,%edx
0x15: 7f f3 jg a<silly+0x0a>
0x17: 89 d0 mov %edx,%eax

通过上述对比我们可以看出,在第1行的跳转指令的地址为 0x0d,此时 PC 中存储的是第2行指令的地址 0x0a,于是我们通过 0x0d + 0x0a = 0x17。结果就是第 8 行要跳转到的指令地址。

同理,在第 7 行中跳转指令的地址为 0xf3,PC中存储是第 8 行指令的地址 0x17,于是我们通过 0xf3 + 0x17 = 0x0a。结果就是第 2 行的指令地址。

程序执行流中的控制和循环都是以条件码寄存器和跳转指令以及PC寄存器为基石的。

控制和循环

控制

下面一段代码是一个控制的例子。

1
2
3
4
5
6
7
int absdiff(int x, int y)
{
if(x < y)
return y - x;
else
return x - y;
}

转换成一个接近汇编语言版本的 goto 版本如下:

1
2
3
4
5
6
7
8
9
10
11
12
int gotodiff(int x, int y)    
{
int result;
if(x >= y)
goto x_ge_y;
result = y - x;
goto done;
x_ge_y:
result = x - y;
done:
return result;
}

汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
# x at %ebp+8
# y at %ebp+12
movl 8(%ebp),%edx ## get x
movl 12(%ebp),%eax ## get y
cmpl %eax,%edx ## y - x
jge .L2 ## if y >= x
subl %edx,%eax ## y - x
jmp .L3
.L2:
subl %eax,%edx ## x - y
movl %edx,%eax ## put result into %eax to return
.L3:

可以看出来,代码过程中通过执行 sub 指令以及 jmp 有条件跳转指令实现了不同分支控制。最终结果都放在 %eax 准备返回。

循环

循环说起来就是复杂一点的if-else分支控制。

此处以最典型的 while 循环进行介绍。

while 语句的通用形式如下:

1
2
3
4
while (test-expr)
{
body-statement;
}

转换成 goto 版本为:

1
2
3
4
5
6
7
8
9
t = test-expr;
if(!t)
goto done;
loop:
body-statement;
t = test-expr;
if(t)
goto loop;
done:

可以看出:代码全程使用 ifgoto 组合来实现 while 循环。

(全文完)

参考资料

  1. 《深入理解计算机系统(第二版)》