文章目录
写在前面:从腾讯实习回来之后,就感觉到自己的知识体系过于散乱。于是萌生了写一个自己的操作系统这样的心思,此为系列第一章,主要是讲解一些汇编知识的,内容大多从CSAPP中也可以获得。
本篇内容主要讲解汇编指令:跳转、条件以及循环指令
跳转指令
之前简单介绍了直线代码的指令情况。而jump
指令可以改变一组机器代码指令的执行顺序,jump
指令控制应该被传递到某个其他部分,可能是依赖于某个测试结果。而测试就是等会要介绍的条件指令,现在先讲跳转指令。
直接跳转指令
在汇编代码中,跳转目标用符号标号书写。其一般采用PC相对寻址,也就是将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差值作为编码。另外一种则是给出“绝对”地址,用4字节直接指定目标。
而其中最常用的就是jmp指令,它可以直接跳转,即跳转目标是作为指令的一部分编码的;也可以是间接跳转,即跳转目标是从寄存器或内存位置读出的,而间接跳转的写法是*
后面跟一个操作指示符。例如:
jmp *%rax //用寄存器%rax中的值作为跳转目标
jmp *(%rax) //以%rax的值作为读地址,从内存中读出了跳转目标
下面是一个相对寻址的例子:
movq %rdi, %rax
jmp .L2
.L3:
sarq %rax
.L2:
jmp .L3
rep; ret
其反汇编版本如下:
0: 48 89 f8 mov %rdi, %rax //%rax = %rdi
3: eb 03 jmp 8 <loop+0x8> //跳转到标号为 8 那一行,03+5 = 8
5: 48 d1 f8 sar %rax //%rax 右移三位
8: 7f f5 jmp 5<loop+0x5> //跳转到标号为 5,f5+a --> -5+a = 5
a: f3 c3 repz retq
注:
标号 3 是标号 0 那一行有三个字节,所以 0+3 = 3
有条件跳转 if-else
刚刚说的都是无条件跳转,现在讲讲有条件跳转,也就是C语言中的if-else
在汇编层面的实现。
条件跳转指令根据条件码(等下讲)的某种组合,或者跳转,或者继续执行序列代码的另一条指令。下表是一些常用的条件跳转指令:
注:
我们对jle
这个命令来解析,其实它就是jmp less equal,意味小于等于。这样就很好记忆了。
对于刚刚的例子来说:
movq %rdi, %rax
jmp .L2
.L3:
sarq %rax
.L2:
testq %rax, %rax //测试%rax
jg .L3 //g是greater,意思是 %rax > 0时,跳转到L3
rep; ret
跳转指令 练习题
练习题 答案
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cbRaV0sc-1631876322318)(https://img- blog.csdnimg.cn/6b709d76aa884de7a8c8beab9720a611.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5rKh5pyJ5a-56LGh55qE6YeO5oyH6ZKIX2NvcHk=,size_20,color_FFFFFF,t_70,g_se,x_16)]
条件指令
条件码
除了整数寄存器之外,CPU还维护着一组单个位的条件码寄存器,他们描述了最近的算数或逻辑操作的属性,可以检测这些寄存器来执行条件分支指令。常用的条件码有:
CF:进位标志 //最近的操作使最高位产生了进位,可用来检查无符号操作的溢出
ZF:零标志 //最近的操作得出的结果为 0
SF:符号标志 //最近的操作得到的结果为负数
OF:溢出标志 //最近的操作导致一个补码溢出(正溢出或负溢出)
注:
其实计算机中并没有单个位的寄存器,其都被存储在flags
这个寄存器中。这个寄存器在实模式下面是16位的,在32位保护模式下面其会被扩展,被称为eflags
寄存器。
比如说,假如我们用ADD
操作完成表达式 t = a + b
,这里变量a、b、t 都是整型。然后,根据下面的 C表达式来设置条件码:
CF (unsigned)t < (unsigned)a //无符号溢出
ZF (t == 0) //零
SF (t < 0) //负数
OF (a < 0 == b < 0)&&(t < 0 != a < 0) //有符号溢出
eflags寄存器的布局
eflags作为flag的扩展,其必然是兼容flags的。下图是eflags中各个条件码的情况:
除了刚刚我们介绍的四种常用的条件码,以下几个也是值得了解到的:
- PF:parity flag,即奇偶位
- AF:auxiliary carry flag,即辅助进位标志,用于记录运算结果低4位的进、借情况,如果有进或借位,其为1
- TF:trap flag,即单步工作标志位,此步若为1,表示CPU进入单步运行方式,这也是gdb实现调试的核心原理
- IF:interrupt flag,即中断标志位。若其为1,表示中断开启,CPU可以响应外部可屏蔽中断;若为0,表示中断关闭,CPU不再相应中断,这就是中断实现的底层原理
- DF:direction flag,即方向标志位。为字符串操作提供方向用
- OF:overflow flag,即类型溢出位。用来表示计算的结果是否超过了数据类型可表示的范围。
- IOPL:input output privilege level,即系统调用标志位,它用来标志当前CPU执行的特权级权限
条件码的设置
还记得之前汇编 —— 算术和逻辑操作中我们提到的那些指令吗?其中除了leaq
指令之外,其他指令都会设置条件码!比如说:
对于逻辑操作,例如xor,进位标志和溢出标志会被置为0
对于移位操作,进位标志将设置为最后一个移出的位,溢出标志会被设置位0
对于inc、dec命令会设置溢出和零标志,但是不改变进位标志
当然除了这些命令之外,CMP
和TEST
类的指令也会设置条件码,但是不改变其他寄存器。
注:
CMP
指令根据两个操作数之差设置条件码,除了只设置条件码而不更新目的寄存器之外,CMP
指令和SUB
指令的行为是一样的
TEST
指令的行为与AND
指令一样,除了它们只设置条件码而不设置内存值,,而且TEST
的比较对象是和0
进行比较
条件码访问
条件码通常不能够直接读取,常用读取条件码的方法有三个:
- 根据条件码的某种组合,将一个字节设置为0或者1
- 可以根据条件跳转到程序的某个其他的部分
- 可以有条件地传送数据
对于第一种方式,常用的是set
指令,一条set
指令的目的操作数是低位单字节寄存器元素之一,或者一个字节的内存位置,指令会将这个字节设置位 0 或者 1 。
我们看一下下面这个例子:
//int comp(data_t a, data_t b)
//a in %rdi, b in %rsi
comp :
cmpq %rsi,%rdi //比较 a 和 b
setl %al // a < b
movzbl %al,%eax //结果零扩展传送至 %eax
ret
注:
这里我们看到这里比较小于用的是SF 和 OF异或^
,这是怎么做到的呢?
SF 表示的是负数,OF表示的是符号溢出
那么当 SF = OF,那么异或的结果表示为 1,表示大于,为什么呢?
首先看第一种 SF = OF的情况:
SF = 0,OF = 0。表示 a-b >0 且结果没有溢出,那么很自然的
SF = 1,OF = 1。表示 a-b < 0且结果溢出了,在有溢出的情况下,小于的情况应该是大于
那么对于SF != OF的情况也很容易推断出它是表示小于的情况了
条件码 练习题
练习题答案
条件控制指令
将条件表达式和语句从C语言翻译成机器代码,最常用的方式是结合有条件和无条件跳转。那么是怎么去实现的呢?
我们看一下以下的C代码:
long lt_cnt = 0;
long ge_cnt = 0;
long absdiff_se(long x,long y)
{
long result;
if(x<y)
{
lt_cnt++;
result = y - x;
}
else
{
ge_cnt++;
result = x - y;
}
return result;
}
下面是这个代码的反汇编版本:
//long absdiff_se(long x,long y)
//x in %rdi, y in %rsi
absdiff_se:
cmpq %rsi, %rdi //比较 x 和 y
jge .L2 //如果 x>=y,跳转到 L2
addq $1, lt_cnt(%rip) //lt_cnt++
movq %rsi, %rax //%rax = y
subq %rdi, %rax //%rax = y - x
ret //return %rax
.L2:
addq $1,ge_cnt(%rip) //ge_cnt++
movq %rdi, %rax //%rax = x
subq %rsi, %rax //%rax = x - y
ret //return %rax
条件控制指令 练习题
练习题答案
条件传送指令
实现条件操作的传统方法是通过是刚刚讲到的条件控制指令,另一种替代的策略则是使用数据的条件转移:这种方法计算一个条件操作的两种结果,然后再根据条件是否满足从中选择一个。
以以下的C代码为例:
long absdiff(long x, long y)
{
long result;
if (x < y)
{
result = y - x;
}
else
{
result = x - y;
}
return result;
}
其反汇编代码如下:
//long absdiff(long x, long y)
//x in %rdi, y in %rsi
absdiff :
movq %rsi,%rax //%rax = y
subq %rdi,%rax //%rax = %rax - x = y-x
movq %rdi, %rdx //%rdx = x
subq %rsi,%rdx //%rax = %rdx - y = x-y
cmpq %rsi, %rdi //比较 x y
cmovge %rdx,%rax //如果 x>=y,%rax = %rdx
ret //return %rax
CPU流水线
也许有同学有疑惑了,这个指令不仅计算了y-x
,还计算了x-y
,而原来只需要计算两个中的一个,这样做有什么好处吗?
这是因为处理器一般会采用流水线来获得高性能。那么什么是流水线呢?
在流水线中,一条指令的处理要经过一系列的阶段,每个阶段执行所需操作的一小部分。
这种方法通过重叠连续指令的步骤来获得高性能。例如,在获取一条指令的同时,执行它前面一条指令的算数运算。要做到这一点,要求能够实现确定要执行的指令序列,这样才能保持流水线中充满了待执行的指令。
那么这又和条件传送指令有什么关系呢?
在执行条件跳转的时候,只有当分支求值完成之后,才能够知道分支往哪儿走,处理器才能够去预读指令。一般来说,处理器会采用非常精密的分支预测逻辑来猜测每条跳转指令是否会执行,一般有90%以上的准确率,流水线中会充满待执行的指令。另一方面,错误预测的一个跳转要求处理器丢掉它为跳转该指令后所有指令已做的工作,然后在开始用从正确位置处起始的指令去填充流水线,一般来说,这会浪费大概15~30个CPU时钟周期,导致性能严重下降。
条件传送指令 练习题
练习题答案
循环指令
C语言中主要提供了三种循环结垢:do-while
、while
、for
。虽然汇编中是没有提供直接循环指令,但是可以用条件测试和跳转组合实现循环的效果。
do-while示例
比如说我们下面的示例,用do-while
实现计算n!
的计算:
long fact_do(long n)
{
long result = 1;
do{
result *= n;
n = n - 1;
} while (n > 1);
return result;
}
其反汇编版本如下:
//n in %rdi
fact_do:
movl $1, %eax //%rax = 1
.L2:
imulq %rdi, %rax //%rax = %rax * n
subq $1, %rdi //n = n - 1
cmpq $1, %rdi //比较 1和 n
jg .L2 //如果n > 1
rep; ret
练习题
练习题答案
while 循环
刚刚我们在do-while
循环的之后,可以看到do-while
基本是执行到末尾之后,再跳转到开头的L2
去实现循环的。而while
则是一种跳转到中间的方法,那么他是怎么跳转到中间的呢?
我们看一下下面这个例子:
long fact_while(long n)
{
long result = 1;
while(n > 1)
{
result *= n;
n = n - 1;
}
return result;
}
其反汇编版本如下:
//long fact_while(long n)
//n in %rdi
fact_while:
movl $1, %eax //%rax = 1
jmp .L5 //无条件跳转到L5
L6:
imu1q %rdi, %rax //%rax = %rax * n
subq $1, %rdi //%rdi = %rdi - 1 = n - 1
.L5:
cmpq $1, %rdi //比较1 和 n
jg .L6 //如果 n > 1,跳转到 L6
rep; ret
可以看到这里先到L5
之后判断条件会跳转到中间标记L6
while 练习题
练习题答案
for 循环
本质上for
循环和while
并没有什么太大的不同,我们看一下具体的例子吧:
long fact_for_while(long n)
{
long i = 2;
long result = 1;
while (i <= n)
{
result *= i;
i++;
}
return result;
}
我们看一下反汇编版本:
// n in %rdi
fact_for:
movl $1, %eax //%rax = 1
movl $2, %edx //%rdx = 2
jmp .L8 //无条件跳转到L8
.L9:
imulq %rdx, %rax //%rax = %rax * %rdx
addq $1, %rdx //%rdx = %rdx + 1
.L8:
cmpq %rdi, %rdx //比较 %rax 和 n
jle .L9 //如果 n <= %rax,转到L9
rep;ret
for 练习题
练习题答案
switch指令
switch
可以根据一个整数索引值进行多重分支,其通过关键数据结构跳转表实现。
跳转表是一个数组,表项 i 是一个代码段的地址,类似于虚函数表。
注:
跟大量的if-else
相比,跳转表的优点是执行开关语句的时间与开关数量无关,当开关情况数量比较多(4个以上),且值跨度比较小时就会使用跳转表。
我们现在看一个例子看一下switch
的汇编实现:
void switch_eg(long x,long n,long *dest)
{
long val = x;
switch(n)
{
case 100:
val *= 13;
break;
case 102:
cal += 10;
case 103:
val += 11;
break;
case 104:
case 106:
val *= val;
break;
default:
val = 0;
}
*dest = val;
}
其反汇编:
//void switch_eg(long x,long n,long *dest)
//x in %rdi, n in %rsi, dest in %rdx
switch_eg:
subq $100, %rsi //%rsi = n - 100
cmpq $6, %rsi //比较 %rsi和 6
ja .L8 //如果 %rsi<6,就转到 L8
jmp *.L4(,%rsi,8) //绝对跳转到跳转表,根据 %rsi的值选择跳转
.L3:
leaq (%rdi, %rdi,2), %rax //%rax = 3x
leaq (%rdi, %rax,4), %rdi //%rdi = 13x
jmp .L2 //无条件跳转到L2
.L5:
addq $10, %rdi //%rdi += 10
.L6:
addq $11, %rdi //%rdi += 11
jmp .L2 //无条件跳转到 L2
.L7 :
imulq %rdi, %rdi //%rdi = %rdi*%rdi
jmp . L2 //无条件跳转到 L2
.L8:
movl $O, %edi //%rdi = 0
L2:
movq %rdi, (%rdx) //*dest = %rdi
ret
可以看到其中jmp *.L4(,%rsi,8)
命令跳转到跳转表中了,那么这个表中的内容又是什么呢?
section .rodata
.align 8 //8字节对齐
.L4:
.quad .L3 //当输入为 0时跳转到 L3
.quad .L8 //当输入为 1时跳转到 L8
.quad .L5
.quad .L6
.quad .L7
.quad .L8
.quad .L7
参考文献
[1] 深入理解计算机系统 第三章 程序的机器级表示