CSAPP导读第3章 程序的机器级表示

我们知道,计算机只认识0和1,因此在计算机发展的早期,我们只有二进制程序可用,对于程序员而言,能够使用的只有0和1,这样去编写程序是十分痛苦的。后来,出现了汇编语言,使得程序的编写变得方便了许多,使用了人们能看懂的一些文本来代替01指令。再后来,又出现了很多高级语言,使得程序的编写更加简单了。

但是,对于计算机而言,只能够读懂01的特性还是没有变,因此我们编写的C语言也好,或者汇编代码也好,计算机都是读不懂的。于是,我们就需要一些助手——编译器汇编器,我们可以通过使用编译器将高级语言编译成汇编代码,然后再使用汇编器将汇编代码转化为机器真正可以读懂的机器语言。

虽然我们现在不怎么使用汇编语言来写程序了,但是我们还是需要掌握汇编语言的,这是因为高级语言会帮我们隐藏掉很多程序执行的细节,很多时候,机器真正执行的内容和我们编写的高级语言程序会有一些出入,因为编译器会试图进行优化,而通过阅读汇编代码,我们才能理解编译器的优化,分析代码的效率如何。

我们后面的讲解都将基于x86-64,这是现在最常见的处理器的机器语言。这是Intel公司提出的,在讲解这种汇编语言之前,我们先回顾一下Intel处理器的发展历史。

3.1 历史观点

Intel处理器系列俗称x86,至于为什么取这个名字,是因为Intel自第一代8086微处理器(16位)以来,后面经过了80286、i386直到i486,都是以86结尾的,因此称之为x86。

处理器年份晶体管数量
8086197829K
802861982134K
i3861985275K
i48619891.2M
Pentium19933.1M
Pentium Pro19955.5M
Pentium/MMX19974.5M
Pentium II19977M
Pentium III19998.2M
Pentium 4200042M
Pentium 4E2004125M
core 22006291M
core i72008781M

这些处理器的设计都是向后兼容的,也就是说较早版本上编译的代码也可以在新的机器上运行。Intel处理器系列有几个名字,包括IA32(Intel Architecture 32bit),也就是Intel 32位体系结构,而x86-64就是IA32的64位扩展,也直接叫x86。

3.2 程序编码

假设我们现在有2个C程序p1.c和p2.c,我们应该如何将他们转换成机器语言呢?

如果在Unix(Linux)上,我们需要使用gcc编译器,这是Linux默认的编译器。在命令行中键入以下内容:

gcc –Og p1.c p2.c -o p

其中,gcc就是我们使用的编译器,然后后面的的-Og是指优化等级。优化等级有-O1-O2-O3等等,比如-O1在不影响编译速度的前提下,尽量采用一些优化算法降低代码大小和可执行代码的运行速度,-O2会牺牲部分编译速度,除了执行-O1所执行的所有优化之外,还会采用几乎所有的目标配置支持的优化算法,用以提高目标代码的运行速度,而这里用到的-Og会精心挑选部分与-g选项不冲突的优化选项,当然就能提供合理的优化水平,同时产生较好的可调试信息和对语言标准的遵循程度。

这里使用-Og是为了让编译出来的汇编代码能够尽量符合C代码的整体结构,而使用较高的优化等级会使得编译出来的代码和原来的长得大相径庭,不适合我们学习。

实际上,在上面这一行命令执行时,会经历几个步骤:

  1. C预处理器会对代码进行扩展,插入include的文件以及所有的宏。
  2. 编译器将C程序编译成汇编程序p1.s和p2.s
  3. 汇编器将汇编代码转化成二进制目标文件p1.o和p2.o
  4. 最后,经过链接器将静态库链接进程序,形成了可执行程序p
img

3.2.1 机器级代码

计算机系统采用多种抽象来隐藏细节,有两种抽象对于机器级编程尤其重要。

一、指令集架构(Instruction Set Architecture)

ISA定义了机器级程序的格式和行为,定义了处理器状态、指令的格式和每条指令对处理器状态的影响。大多数的ISA将程序的行为描述成指令按顺序执行,但是实际上,处理器的硬件是并发执行多条指令的,而且硬件要复杂很多。

二、虚拟地址

机器级程序使用的内存地址是虚拟内存地址,从程序员的角度看,就好像一个很大的数组,实际上这是操作系统软件和硬件存储器结合的结果。很多时候,虚拟内存要比真实的存储器大的多,比如真实的内存可能只有16个G(或更大),而虚拟出来的内存可能是16EB( 2 64 2^{64} 264字节)。

在机器层面进行编程,可以看到对C语言程序员隐藏的内容:

  • 程序计数器(PC):用来存储下一条指令在内存中的地址。在x86中用%rip表示
  • 整数寄存器文件:用寄存器进行数据存储可以减少内存访问。
  • 条件码寄存器:存储最近一次的算术逻辑运算结果的状态信息,比如结果为0,结果为负,溢出等等,这些信息会被用在条件跳转,比如等于0则跳转。
  • 一组向量寄存器:存放一个或多个整数或浮点数的值。

对于C语言,有许多的数据类型,比如结构体、数组等,但是对于机器代码而言不进行区分,都是一组连续的字节,汇编代码也不会区分有符号和无符号整数、指针类型等等。

程序的内存包含:代码、数据和栈。在进行过程调用时,需要栈来保存信息和恢复。

一条机器指令只能做一些简单的事情,比如将寄存器的值传送到另一个寄存器,或者将寄存器中的值相加等等。因此,编译器必须去安排指令的执行序列。

3.2.2 代码示例

假设我们有这样一个C程序代码mstore.c

long mult2(long x, long y); 

void multstore(long x, long y, 
              long *dest)
{
    long t = mult2(x, y);
    *dest = t;
}

我们使用下面的命令编译这段代码:

gcc -Og -S mstore.c

-S告诉编译器只需要得到汇编代码即可,不需要进一步产生中间目标文件和链接。

-S Compile only; do not assemble or link.

然后我们会得到mstore.s,我们去除其中一些不重要的信息,会得到如下的结果:

multstore:
	pushq	%rbx
	movq	%rdx, %rbx
	call	mult2@PLT
	movq	%rax, (%rbx)
	popq	%rbx
	ret

上面的代码中,第一行是函数名,然后函数的具体汇编指令前面都有缩进,这些指令我们后面都会讲到。

如果我们使用-c,那么就会对该代码进行汇编:

gcc -Og -c mstore.c

汇编会产生mstore.o这一中间目标文件,目标文件都是二进制的,无法直接进行查看,但是我们可以使用反汇编器(disassembler)进行反汇编,反汇编也就是将二进制的代码转化为汇编,这是一种逆向工程技术。linux中我们可以使用带-d参数的objdump命令:

objdump -d mstore.o

-d, --disassemble Display assembler contents of executable sections

这样,就可以得到汇编代码的格式:

Disassembly of section .text:

0000000000000000 <multstore>:
   0:	f3 0f 1e fa          	endbr64 
   4:	53                   	push   %rbx
   5:	48 89 d3             	mov    %rdx,%rbx
   8:	e8 00 00 00 00       	callq  d <multstore+0xd>
   d:	48 89 03             	mov    %rax,(%rbx)
  10:	5b                   	pop    %rbx
  11:	c3                   	retq  

可能和书上的不大一样,这是因为使用的是最新的编译器版本gcc 9,但是差距不大。

左边第一列是对于程序首地址的偏移量,比如第一条指令就是0个字节的偏移,下一条就是4个字节的偏移,以此类推。第二列是几个为一组的十六进制数,是指令的机器表示,每一组对应右边的一条指令。

我们也可以看到,指令的长度是不同的,有1个字节的,也有3个字节的,这是因为x86指令长度从1-15个字节不等,简单的和常用的都长度较短。反汇编器是通过字节序列反解析出指令的,并不需要源程序,每一个指令都会对应一个唯一的操作码,通过操作码可以确定指令类型。同时,反汇编器会省略很多指令结尾的’q’,这些其实要与不要区别不大,只是指出操作数据的大小,比如是64位的’q’(8字节)还是32位的’l’(4字节)等等。

如果想要得到可执行代码,那么就需要链接,同时需要在这一组目标文件中包含一个main函数,假设main.c如下:

#include <stdio.h>

void multstore(long, long, long*);

int main(){
	long d;
	multstore(2,3,&d);
	printf("2 * 3 --> %ld\n", d);
	return 0;
}

long mult2(long a, long b){
	long s = a*b;
	return s;
}

我们使用如下命令来生成可执行文件prog:

gcc -Og -o prog main.c mstore.c

然后我们反汇编来看一下:

00000000000011d5 <multstore>:
    11d5:	f3 0f 1e fa          	endbr64 
    11d9:	53                   	push   %rbx
    11da:	48 89 d3             	mov    %rdx,%rbx
    11dd:	e8 e7 ff ff ff       	callq  11c9 <mult2>
    11e2:	48 89 03             	mov    %rax,(%rbx)
    11e5:	5b                   	pop    %rbx
    11e6:	c3                   	retq   
    11e7:	66 0f 1f 84 00 00 00 	nopw   0x0(%rax,%rax,1)
    11ee:	00 00 

可以看到不同之处就在于地址不同了,同时函数调用的地址被链接器填上了,链接器的任务就是找到匹配的函数的可执行代码。

   8:	e8 00 00 00 00       	callq  d <multstore+0xd>
  11dd:	e8 e7 ff ff ff       	callq  11c9 <mult2>

可能还会多几个nop指令,这是空指令,并不做事情,只是为了让代码变为16字节,这样会更加方便存储。

3.2.3 关于格式的注解

mstore.s的内容如下:

.LFB0:
	.cfi_startproc
	endbr64
	pushq	%rbx
	.cfi_def_cfa_offset 16
	.cfi_offset 3, -16
	movq	%rdx, %rbx
	call	mult2
	movq	%rax, (%rbx)
	popq	%rbx
	.cfi_def_cfa_offset 8
	ret
	.cfi_endproc

可能会看到一些以小数点开头的语句,这些都是指导汇编器和编译器工作的伪指令,忽略即可。

3.3 数据格式

由于Intel是从16位发展成32位、64位的,所以将16位称之为一个(word),而32位就称为双字(double word),64位就称之为四字(quad word)。下图描述了C语言中各种数据类型对应到x86中的表示:

image-20220116115008885

上图中,char*为四字是因为地址是64位的(64位机器)。而对于浮点数,有和C语言对应的单精度和双精度类型。可能有细心的同学会发现,双精度和双字的汇编后缀都是"l",这不会产生歧义么,实际上因为两者用的指令是不同的,寄存器也是不同的,所以不会导致歧义。

3.4 访问信息

我们知道,CPU中都会含有一些寄存器,x86-64中包含16个可以存储64位数据的通用寄存器,可以用这些寄存器来存储整型数据或者指针。我们来看一下这16个寄存器:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-03fVM81f-1642522225399)(https://gitee.com/howard-jiahao/pic-bed/raw/master/img/image-20220116120406226.png#pic_center#pic_center)]

图 x86-64的16个寄存器

可能会觉得有一点奇怪,为什么前面的8个都是用字母,而后面用了数字呢,这是历史遗留问题,前面的8个实际上是8086的8个寄存器,从%rax到%rsp,起初他们只有16位,后面才变成了32位和64位。这8个寄存器一开始是根据用处来命名的,而后面的8个是扩展到x86-64之后才有的,采用了新的命名规范。

image-20220116193517945
图 IA32寄存器

前八个寄存器的命名在早期是有特殊的含义的,比如%rax的a是指accumulate也就是累加,%rcx的c表示counter也就是计数,但是在之后,这些寄存器就没有什么特定的含义了,都是通用的,除了有一个比较特殊——%rsp,%rsp代表了栈指针(stack pointer),这个寄存器是被指定用来指明运行时栈的位置(指向栈顶)。最后一个是基址寄存器,通常是给操作系统用的,比如分段的时候我们需要将虚拟地址映射到物理地址,就需要这样一个寄存器。

我们也会看到后面有跟着%e开头的,和%ax这些,这都是什么呢?可以看到,%eax实际上是%rax的低32位,而%ax是%rax的低16位,%al是%rax的低8位。因此,这些符号是为了能让我们更好地引用寄存器的低字节

还有最后一个问题,有的寄存器后面写着第x个参数,这是什么意思呢?我们在进行过程调用的时候(比如想象一下你在C语言中写了一个函数,然后在main函数里面调用它),通常会传递一些参数,比如func(a,b),那么在机器级,这些参数是如何进行传递的,实际上就是在设计时指定了一些寄存器,然后过程调用会知道要从哪些寄存器中取自己需要的参数,比如从%rdi取第一个参数,而x86-64提供了6个参数寄存器用来进行参数传递(如果多于6个的话一般还可以使用栈进行传递)。同时,函数需要返回值吧,于是指定了返回值放在%rax中

3.4.1 操作数指示符

操作数分为3类:

  • 立即数:就是一个常数数值,一般可以直接放在指令中,当然这样就会使得指令的长度变长。
    立即数在x86中使用一个$符号开头,后面跟上一个常数值,可以是正数也可以是负数,例如$-577$0x1F
  • 寄存器:用寄存器的名字进行引用,从寄存器中取操作数
    我们直接通过寄存器的名字对寄存器进行引用,比如%rax,指的就是获得寄存器%rax中的值。
  • 内存引用:通过内存地址,从内存中取操作数
    如果不加上$,只有一个数字,那么表示的就是从该内存地址处获得值,比如0x104,代表的就是从内存地址0x104处获得值。

当然,上面这些是不够用的,因此存在着更多的寻址模式,将上面这些进行组合,得到一些更为复杂的寻址方式:

image-20220116123227621

图 x86操作数格式和寻址方式

举几个例子:

  • (%rax):先从%rax中获得值,把这个值作为内存地址的引用(有效地址Effective Address),从该地址中再取到真正的操作数。这种寻址被称为寄存器间接寻址
  • 4(%rax):和上面一样,先从%rax中获得值,但是这个就不是有效地址了,因为还要加上前面的偏移4,于是有效地址EA应该是%rax的值+4。再通过有效地址获得真正的操作数,这个被叫做偏移寻址
  • 9(%rax,%rdx):%rax中的值加上%rdx中的值再加上9作为有效地址,然后再根据该地址在内存中找到操作数。
  • 0xFC(,%rcx,4):括号中的第三个位置是用来放大的,比如我们需要得到数组的第几个元素,一般就可以用这种方式,将数组元素的大小放在第三个,然后下标放在第二个,第一个就是数组的首地址。这个实际上就等价于有效地址等于 0 x F C + 4 × % r c x 0xFC+4\times \%rcx 0xFC+4×%rcx

3.4.2 数据传送指令

最常用的指令就是将一个数据从一处传送到另一处,比如从寄存器复制到另一个寄存器,或者从寄存器复制到一个内存地址中。对于上面的三种操作数位置,理论上排列组合一下应该有9种,但是实际上不可能从常量到常量、从内存到常量、从寄存器到常量,或者从内存到内存另一块区域(这是因为必须要先从内存中到寄存器,然后再从寄存器到内存),所以总共只有5种:

image-20220116155507172
图 数据传送类型

我们使用mov类指令进行数据传送,mov类是带后缀的,有b(1字节 8位)、w(1字 16位)、l(2字 32位)、q(四字 64位),寄存器的大小必须和这些后缀相同,比如不能引用了寄存器的低16位,但是使用q(64位)。

image-20220116155910614

图3-4 mov指令后缀及功能

但是,有的时候我们可以用movz或者movs指令将小的源复制到大的目的,比如从16位的复制到64位的,movz(zero)表示,高位用0填充,movs(sign)则是用符号位填充。指令后面2个字母都是大小的标志,第一个是源,第二个是目的。

image-20220116160710345

图3-5 零扩展数据传输传送指令

image-20220116160724636

图3-6 符号扩展数据传送指令

可以看到图3-5中没有movlq,这是因为可以直接用movl,而目的为寄存器,这样会把高4字节设置为0。图3-6中有一个cltq,实际上就是把%eax符号扩展到%rax,这样设置指令更加紧凑。

3.4.3 数据传送示例

下面有这样一段C程序,作用就是将两个内存块中的值进行交换。

void swap(long *xp, long *yp) 
{
  long t0 = *xp;
  long t1 = *yp;
  *xp = t1;
  *yp = t0;
}

将上面的代码进行编译,可以得到如下的汇编程序:

swap:
   movq    (%rdi), %rax
   movq    (%rsi), %rdx
   movq    %rdx, (%rdi)
   movq    %rax, (%rsi)
   ret

解释一下,我们知道第一个函数参数会放在%rdi中,第二个会被放在%rsi中,因此,指针xp会在%rdi,而指针yp会在%rsi中,那么我们首先将这两个的值从内存放入两个寄存器%rax(值为xp指针指向的值)和%rdx(值为yp指针指向的值)中(2、3行)。然后,我们将原来yp的值(在%rdx)中复制给xp指向的内存(xp指针在%rdi中),同理xp也是一样(5,6行)。最后ret返回即可。

3.4.4 压栈和出栈

栈是一种先进后出的数据结构,我们用push操作往栈里压入数据,然后用pop操作把栈中的数据弹出来,栈需要一个栈顶指针,这个指针指向最近压入栈的数据(栈顶),在x86-64中,我们用%rsp保存栈顶指针。x86中的push和pop指令如下:

image-20220116163046876

图3-8 入栈和出栈指令

可以看到,对于push只需要指定一个源(S),因为目的是隐含的栈顶,同样,pop也只需要一个目的。从上面的效果一栏中也可以看出,x86的栈是向低地址增长的,因为压栈pushq之后,栈顶指针减去了8,所以栈顶实际上在低地址。下面看这样一个例子来理解一下压栈和出栈:

image-20220116163811934

图3-9 压栈出栈操作

可以看到压入一个0x123之后,栈顶从0x108变成了0x100,也就是减小了8个字节。而在pop出栈顶之后,就会增加8个字节,回到0x108。

3.4.5 算数逻辑操作

整数的算数逻辑操作指令主要包括了下图列出来的这些:

image-20220116164033960
图3-10 整数算数操作

可以将这些指令分成4组:加载有效地址一元操作二元操作移位操作

3.5.1 加载有效地址

我们使用leaq(load effective address)来加载有效地址,听上去好像是从内存加载操作数,但实际上并不是,它真的只是得到了一个有效地址而已,所以本质上和mov指令没有太大的区别。从C语言的角度看,它仅仅是产生一个指针,如&a。所以,有的时候它会被编译器用来做一些简单的算数运算。

比如:leaq 7(%rdx,%rdx,4),%rax实际上就是 % r a x = % r d x + 4 × % r d x + 7 = 5 × % r d x + 7 \%rax=\%rdx+4\times \%rdx+7=5\times \%rdx+7 %rax=%rdx+4×%rdx+7=5×%rdx+7,而%rax中的值也就是我们需要的有效地址。实际上,你也不一定需要把它认为是地址,编译器就是这么干的,直接把它当作是一个算数操作,得到了%rax中的值作为运算的结果罢了。

3.5.2 一元和二元操作

所谓一元操作,也就是说指令只包含了一个操作数(既是源也是目的),二元操作也就是有两个操作数(第二个操作数既是源也是目的)。

听上去有点迷,实际上一元操作就类似于C语言里的i++,这种自增自减的运算。而二元操作九类似于C语言里的i+=1,i既是源操作数(因为它参与运算),也是目的操作数(因为结果也是i)。

一元操作有:

  • inc:increase,自增
  • dec:decrease,自减
  • neg:negative,取负
  • not:取补

二元操作有:

  • add 加法
  • sub 减法
  • imul integer multiply 整数乘法
  • xor 异或
  • or
  • and

3.5.3 移位操作

移位操作之前讲过,有3种——左移、算数右移和逻辑右移(逻辑左移和算数左移是一样的)。移的位数可以放在2个地方,一种是放在指令中,比如SAR k,D也就是把D 算数右移(shift arithmetic right) k位。还有一种选择比较特殊,可以把移的位数放在%cl(8位)中,这是规定的。之前也说过,如果移位的数量超过了本身的长度实际上就会取低位,也就是取模的操作,比如%cl中是0xFFsalb只会看低3位(因为最多只能移动7位),而salw只会看低4位(因为最多只能移15位)。

3.5.4 讨论

综合上面讲的这些,我们来看一个小例子:

long arith(long x, long y, long z)
{
  long t1 = x+y;
  long t2 = z+t1;
  long t3 = x+4;
  long t4 = y * 48;
  long t5 = t3 + t4;
  long rval = t2 * t5;
  return rval;
}

上面的程序会被编译成如下的汇编代码:

arith:
  leaq    (%rdi,%rsi), %rax   # t1
  addq    %rdx, %rax          # t2
  leaq    (%rsi,%rsi,2), %rdx
  salq    $4, %rdx            # t4
  leaq    4(%rdi,%rdx), %rcx  # t5
  imulq   %rcx, %rax          # rval
  ret

第2行中表示了t1=x+y,将结果t1存储在了%rax中,第3行中重复使用了%rax,用来存储t2的值。

第4行中使用了leaq来进行简单的运算, % r d x = % r s i + 2 % r s i = 3 % r s i \%rdx=\%rsi+2\%rsi=3\%rsi %rdx=%rsi+2%rsi=3%rsi。然后使用这个结果,在第5行中算数左移4位,于是就乘以了16,这样得到了48y。

第6行中又使用了leaq,用4作为偏移,计算 % r c x = % r d i + % r d x + 4 \%rcx=\%rdi+\%rdx+4 %rcx=%rdi+%rdx+4,也就是 t 5 = t 3 + t 4 t5=t3+t4 t5=t3+t4

最后用乘法指令imulq将乘积放入%rax(作为返回值)。

3.5.5 特殊的算术操作

有一些特殊的算数操作如下:

image-20220116191930828

图3-12 特殊的算术操作

其中,全乘法说的是能够将64位的数乘以64位的数,然后存在一个128位的空间中,由于一个寄存器只有64位的空间,那么只能够用2个寄存器拼在一起得到128位的空间。

之前也有见过imulq命令,在之前我们讲的时候imulq是一个二元操作,而这里是一个一元操作,实际上汇编器能够根据指令中操作数的数量来知道用的是哪个命令,因此并不会冲突。

3.6 控制

在之前,我们的程序都是按代码顺序执行的,但是实际我们需要一些条件语句,比如什么条件满足时才执行,有的时候我们也需要重复语句多次,也就是循环。所谓控制也就是如何控制机器级别指令的执行顺序,以及如何使用这些技术来实现基本条件语句循环switch语句

3.6.1 条件码

x86系列的处理器中维护了一些标志位,我们称为条件码(condition code)寄存器。条件码寄存器是用来做什么的呢?比如我们现在有一个运算,它的结果可能会存在一些状态信息,比如结果得到了0、结果为负数、结果溢出,而我们正是需要这样的结果状态来进行判断然后进行对应的操作,这些状态信息也就是我们所说的条件码寄存器需要保存的。换句话说,这些一位的条件码是条件指令运作的基础

条件码不是能够直接设置的,而是通过一些指令进行设置。

  • CFCarry Flag:比如将2个64位的整数相加,实际上他们有可能产生65位的结果,如果发生了,那么就产生了进位。(无符号的溢出)
  • ZFZero Flag:如果刚计算完的值为0,那么就会被置位
  • SFSign Flag:如果得到负数则置位
  • OFOverflow Flag:有符号数溢出的位

只有算数操作才能够改变条件码,leaq不会,尽管GCC经常会使用它来进行运算。

CMP指令(比较指令)基本和SUB一样,唯一不一样的就在于,SUB会将结果放到目的中,而CMP不会,它只是做减法,然后会根据结果改变条件码。

为什么这么设计呢?实际上,我们在进行学习算数的时候就知道,如果一个数减去另一个数的结果大于0,也就是前者大于后者,反之同理。

cmpq b,a 实际上就是根据a-b的值(注意是a-b,顺序不要反),来设置条件码:

  • CF 被置位如果最高位进位了
  • ZF 被置位如果a == b
  • SF 被置位如果(a-b) < 0 (as signed)
  • OF 被置位如果有符号溢出(正溢出和负溢出)
    (a>0 && b<0 && (a-b)<0) || (a<0 && b>0 && (a-b)>0)

还有一个指令叫做test,实际上做的是与操作,比如test a,b就是a&b。典型用法是使用test %rax,%rax来判断正负。

test和cmp都可以加上后缀b、w、l、q,表示测试的是字节、字、32位还是64位。

image-20220117234342595
图3-13 比较和测试指令

3.6.2 访问条件码

上面说了如何设置条件位,那么如何去读这些条件码呢?

实际上,你也可以直接读这些条件码寄存器去得到值,但是一般不会这么做。我们使用set指令来读条件码。

set实际上做的是将单个寄存器的单个字节(目的是低位单字节寄存器)设置为1或者0。对于16个寄存器,实际上都可以直接将其最低位字节设置为0或者1,而不会影响其他位。

image-20220117235857878
图3-14 SET指令

比如,使用sete就是相等(也就是ZF为1的时候)的时候进行设置为1,使用setg也就是大于的时候设置为1。

注意,setl中的l是lower,而不是像之前一样代表long。

接下来看一个例子:

int gt (long x, long y)
{
  return x > y;
}

上面这段C程序的汇编语言如下,功能是比较x,y,如果大于则返回1:

 	cmpq   %rsi, %rdi   # Compare x:y
	setg   %al          # Set when >
	movzbl %al, %eax    # Zero rest of %rax
	ret

%rdi中的是第一个参数,也就是x,%rsi中也就是x,第一条实际上做了x-y,然后如果大于的话则设置%al为1,注意%al%rax的低8位。然后我们将%al扩充到64位返回,这里用了一个小tip,我们看到实际用的是%eax,而不是%rax,这是因为x86在使用movl设置32位的时候,会把64位的高4字节设置为0。

3.6.3 跳转指令

在正常情况下,指令是按照顺序执行的,但是我们可以使用跳转指令使其跳到某一位置执行,通常,我们可以使用标签(label)来指定跳转的位置,这种方式叫做直接跳转,例如如下的代码。

	jmp .L1
	movq (%rax),%rdx
.L1:
	popq %rdx

在x86中,如果你看到一个名称后跟上一个冒号,那么冒号左边的就是一个标签,比如上面代码中的.L1就是一个标签,我们可以利用这个标签进行跳转。

当生成目标代码文件的时候,汇编器会确定这些标签的地址然后填入。

同时,跳转还分为两种:

  • 无条件跳转:直接跳到指定位置执行,比如上面所示的代码就是一种无条件跳转。
  • 有条件跳转:只有条件码满足条件时才进行跳转,比如当最近的一次运算结果为0时,我们进行跳转,那么就使用je,我们经常会碰到这种情况,因为我们在使用循环时,如果控制变量等于某个值的时候,我们就会跳转到循环外,而不是继续循环,于是就可以先用cmp命令,然后再je

image-20220118194528380

图3-15 jump指令

3.6.4 跳转指令的编码

对于跳转指令而言,无论是直接跳转还是间接跳转,都是需要在指令中加上一个跳转的位置,我们刚才也说过,在生成目标代码的时候,会把这个位置给替换成真实的地址,那么我们接下来看一下在目标代码中,跳转指令是如何编码这样一个跳转位置的。

一种很直观的思考就是,直接把地址写进去呗,但是这样会造成一些问题,比如程序如果在内存中移动了,那这个地址不就没有用了么,这就需要重新进行编译然后生成新的目标代码了。而使用的较多的是一种**PC相对(PC—Relative)**的方式。

也就是说,跳转的地址是相对于PC所在的位置的,给出的是相对PC的偏移量。比如PC在0x8,而我们需要跳转的程序在0x3,我们就可以在跳转指令中使用-5作为相对地址进行编码。

举个例子:

	movq %rdi,%rax
	jmp .L2
.L3:
	sarq %rax
.L2:
	testq %rax,%rax
	jg .L3
	rep;ret

用到了rep指令,这里的rep指令可以理解为一种空指令,没有什么作用,只是为了能够让处理器正确地预测ret跳转的目的地,所以不必深究。

将产生的目标文件反汇编,如下:

image-20220118201247255

我们观察第2条跳转指令,它的十六进制格式为eb 03,其中第二个字节就是跳转的位置,我们可以看到是0x3,而我们实际要跳转的是0x8这个位置,这里用的就是相对PC的编码,因为jmp指令执行的时候,PC是在下一条指令处的,也就是0x5这个位置,所以我们只需要加上0x3的偏移就可以跳转了。

同理,第5条指令也是一样,第二个字节的f8就是-8,也就是0xd-0x8=0x5,所以指令是jg 5

我们进行链接,然后再进行反汇编如下:

image-20220118201656150

可以看到已经将地址已经被重定位为真实的内存地址,而且可以发现指令的编码是不需要改变的。

3.6.5 用条件控制来实现条件分支

实际上,C语言中有一种语句叫做goto,一般不推荐使用,但是它的控制和汇编代码的条件转移十分相似。

例如我们有这样一段正常的代码,实际上就是得到两数之差的绝对值:

long absdiff(long x, long y)
{
    long result;
    if (x > y)
        result = x-y;
    else
        result = y-x;
    return result;
}

然后我们使用goto语句改写一下:

long absdiff_j(long x, long y)
{
    long result;
    int ntest = x <= y;
    if (ntest) goto Else;
    result = x-y;
    goto Done;
 Else:
    result = y-x;
 Done:
    return result;
}

再看看它的汇编形式:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TvDx8BR1-1642522225404)(https://gitee.com/howard-jiahao/pic-bed/raw/master/img/image-20220118203412423.png#pic_center)]

从控制流的角度来看,这两个代码基本上是一样的。

3.6.6 用条件传送来实现条件分支

条件传送,和set指令有些相似,也就是根据条件码部分来判断是否要进行数据传送,使用的是cmov(conditional move),比如当相等的时候进行条件传送,也就是cmove

现代处理器会使用一种特殊的技术,叫做流水线(pipeline),它的名字就是取自工厂流水线,在CPU中,也就是说当你执行一条指令的时候,下一条指令的一部分会被执行,下下一条指令的一部分也会被执行,这样就提高了并行的程序。

这里不对流水线做过多解释,这里有作者写的一篇关于流水线的博客https://blog.csdn.net/weixin_47524903/article/details/122401973,感兴趣的可以看一看。

但是条件转移会破坏流水线的运作,于是我们会把两个条件的结果都计算一遍,然后再根据跳转选择其中的一条。这里也就用到了cmov

比如还是之前的程序,我们汇编变成如下这个样子,也就是把x-y和y-x都计算了,然后再根据条件,选择其中一个结果返回:

absdiff:
   movq    %rdi, %rax  # x
   subq    %rsi, %rax  # result = x-y
   movq    %rsi, %rdx
   subq    %rdi, %rdx  # eval = y-x
   cmpq    %rsi, %rdi  # x:y
   cmovle  %rdx, %rax  # if <=, result = eval
   ret

但是,使用cmov也会有一些负面影响:

  • 只有当计算较为简单时,才用cmov进行优化,如果两条分支都较为复杂,那么使用cmov反而不好
  • 对于某个分支而言,计算它可能没有什么用,只是浪费时间。
  • 两个分支可能会存在关联性,比如val = x > 0 ? x*=7 : x+=3;,如果两个都进行计算就会出现错误。

3.6.7 循环

循环有如下的三种,我们会分别介绍:

  • do-while
  • while
  • for

do-while可能用的是最少的,但是实际上它是最容易实现的

一、do-while

如果用C的goto来实现,则如下面的代码:

loop:
  Body
  if (Test)
    goto loop

实际上就是先循环体,然后进行测试,如果测试成功,那么跳回到loop再继续循环。

举个例子,比如我们有这样一个C程序的goto版本:

long pcount_goto (unsigned long x) {
  long result = 0;
 loop:
  result += x & 0x1;
  x >>= 1;
  if(x) goto loop;
  return result;
}

那么会发现,汇编的版本也类似:

	 movl    $0, %eax		#  result = 0
.L2:			# loop:
   movq    %rdi, %rdx	
   andl    $1, %edx		#  t = x & 0x1
   addq    %rdx, %rax	#  result += t
   shrq    %rdi		#  x >>= 1
   jne     .L2		#  if (x) goto loop
   rep; ret

二、while

while和do-while的区别就在于do-while第一次不进行测试,所以总会执行一遍循环体,而while在开始就测试,如果不满足就跳出,不执行。

while的实现有2种方式,第一种方式就是先跳到了do-while的中间,然后进行测试。

C的goto版本如下:

  goto test;
loop:
  Body
test:
  if (Test)
    goto loop;
done:

我们还是用pcount这个程序,那么while就是下面这种实现:

long pcount_goto_jtm(unsigned long x) {
  long result = 0;
  goto test;
 loop:
  result += x & 0x1;
  x >>= 1;
 test:
  if(x) goto loop;
  return result;
}

第二种实现方式比较传统,就是一开始进行判断,如果不满足直接goto跳出,满足那么进入到和do-while相同的语句块中。

  if (!Test)
    goto done;
loop:
  Body
  if (Test)
    goto loop;
done:

pcount的第二种while实现如下:

long pcount_goto_dw(unsigned long x) {
  long result = 0;
  if (!x) goto done;
 loop:
  result += x & 0x1;
  x >>= 1;
  if(x) goto loop;
 done:
  return result;
}

三、for

for循环实际上包含了4个部分,例如一个C语言的for循环for(int i = 0;i < 5;i++){body},包括了初始化(int i = 0),测试(i < 5),更新(i++)和循环体

如果用while循环来表示for循环,那么就是先进行初始化,然后是while循环,在while循环体的最后加上更新操作。

Init;
while (Test ) {
    Body
    Update;
}

还是之前的例子,我们使用for循环(用while实现for)实现:

long pcount_for_while(unsigned long x)
{
  size_t i;
  long result = 0;
  i = 0;
  while (i < WSIZE)
  {
    unsigned bit = 
      (x >> i) & 0x1;
    result += bit;
    i++;
  }
  return result;
}

然后我们用goto替代:

long pcount_for_goto_dw(unsigned long x) {
  size_t i;
  long result = 0;
  i = 0;
  if (!(i < WSIZE))
    goto done;
 loop:
  {
    unsigned bit = 
      (x >> i) & 0x1;
    result += bit;
  }
  i++;
  if (i < WSIZE)
    goto loop;
 done:
  return result;
}

如果使用了-O1优化级别,那么第一次的判断很有可能不需要了,编译器会将其舍弃。

3.6.8 switch语句

使用switch语句可以实现多分支的跳转,使用跳转表这种数据结构实现switch十分高效,跳转表是一种数组,它的数组项都是一段代码的首地址,我们可以索引到首地址然后跳转对应的内存地址。

image-20220118212831856
图 跳转表

C语言的一个switch语句示例如下:

long switch_eg(long x, long y, long z)
{
    long w = 1;
    switch(x) {
    case 1:
        w = y*z;
        break;
    case 2:
        w = y/z;
        /* Fall Through */
    case 3:
        w += z;
        break;
    case 5:
    case 6:
        w -= z;
        break;
    default:
        w = 2;
    }
    return w;
}

如果不使用break,比如case2中,就会继续往下执行。

如果没有匹配的case,那么就会执行default。

我们使用上面所说的跳转表来实现如下的标准switch语句,那么只需要一句goto *JTab[x];即可。

switch(x) {
  case val_0:
    Block 0
  case val_1:
    Block 1
    • • •
  case val_n-1:
    Block n–1
}

我们以上面的switch_eg程序为例,汇编代码如下:

switch_eg:
    movq    %rdx, %rcx
    cmpq    $6, %rdi   # x:6
    ja      .L8
    jmp     *.L4(,%rdi,8)

注:第4行的ja(jump above)是无符号的大于,也就是说,如果是负数,那么会变成一个很大的正数,所以负数也会进行跳转,见3.63节图3-15 jump指令

如果%rdi比6大(或者为负数)的话,那么就是默认情况,直接跳到.L8(默认代码块),否则,就根据跳转表,这里使用的是间接跳转,并且使用了比例变址寻址,有效地址为.L4+8%rdi,8代表8字节也就是64位(地址的位数),以%rdi(也就是x)作为跳转表的索引,跳转表见下图。

image-20220118220001992

图 switch_eg程序的跳转表

跳转表是编译器生成的,可以看到x=0就是默认的情况,然后可能会发现里面有2个.L7,这是因为case5里面没有break,所以5,6是同一代码块。

代码块.L3(对应case1)的内容如下:

.L3:
   movq    %rsi, %rax  # y
   imulq   %rdx, %rax  # y*z
   ret

case2和case3之间会连着执行,于是在case2中会直接跳转到case3的代码块,事实上,这里编译器进行了一些优化,延迟了w的赋值,只有等case3中用到了w的值才赋值为1。

image-20220118221014464

对应的汇编代码如下:

.L5:                  # Case 2
   movq    %rsi, %rax
   cqto
   idivq   %rcx       #  y/z
   jmp     .L6        #  goto merge
.L9:                  # Case 3
   movl    $1, %eax   #  w = 1
.L6:                  # merge:
   addq    %rcx, %rax #  w += z
   ret

或许会有这样的问题:

  • 如果最小值为1,最大值为10000,那么会建立10000个跳转表的项么?实际上,编译器可能会将它转为if-else来处理
  • 如果有负数呢,这样就无法使用跳转表了么?编译器可能会加上一些偏置,让它从0开始。

使用了跳转表就可以达到 O ( 1 ) O(1) O(1)的复杂度,如果线性查找就是 O ( n ) O(n) O(n),而使用二分查找的思想可以优化到 O ( l o g n ) O(logn) O(logn)

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值