上篇写了machine code基本知识概念,这篇再总结一下其中的流控制、条件判断,循环等实现。
一段machine code引出
在machine code中,通场使用jmp指令来跳转到某个代码块。比如一个机器码可能长这样:
decision:
subq $8, %rsp
testl %edi, %edi
je .L2
call op1
jmp .L1
.L2:
call op2
.L1:
addq $8, %rsp
ret
随着逐行执行,一旦碰到了jmp指令,就可以直接到对应的代码块而跳过中间的部分,就像C语言中的GOTO一样。
等等,那je是啥?在讲je之前,先写一下条件码 Condition Codes.
Condition Codes
条件码(Condition Codes)是一组用于表示操作结果状态的标志位。这些标志位通常存储在单比特寄存器中(可以认为是额外于之前介绍的%rax,%rbx之类的小寄存器)。
- CF(Carry Flag):进位标志,用于无符号数运算。
- SF(Sign Flag):符号标志,用于有符号数运算。
- ZF(Zero Flag):零标志,表示运算结果是否为零。
- OF(Overflow Flag):溢出标志,用于有符号数运算。
在 GDB 调试器中,这些标志位会被打印为一个名为 "eflags" 的寄存器,比如示:
eflags 0x246 [ PF ZF IF ] Z set, CSO clear
在进行算术运算时,条件码通常会被隐式设置(作为运算的附加结果)。以 addq Src, Dest
操作为例,该操作执行加法运算:t = a + b
。在这个过程中,条件码会根据以下规则自动set:
- CF(Carry Flag):当最高有效位产生进位时set(表示无符号溢出)。
- ZF(Zero Flag):当
t == 0
时set。 - SF(Sign Flag):当
t < 0
时set(表示结果为负数)。 - OF(Overflow Flag):当发生补码(有符号)溢出时set。这种情况包括:
- 当
a > 0
、b > 0
且t < 0
时。 - 当
a < 0
、b < 0
且t >= 0
时。
- 当
比较指令(cmp)和 测试指令(test)
可以注意到,在一开始的示例代码中,je上面还有一个testl命令。这里就要引出cmp和test。
cmp
指令用于比较两个操作数 a
和 b
。其执行过程如下:
- 计算
b - a
(与sub
指令相同)。 - 根据结果设置条件码,但不改变操作数
b
的值。
test
指令用于测试两个操作数 a
和 b
。其执行过程如下:
- 计算
b & a
(与and
指令相同)。 - 根据结果设置条件码(仅设置 SF 和 ZF),但不改变操作数
b
的值。
jX Instructions跳转指令
终于来到je了!je其实是jX中的一种,其实就是根据前面cmp或者test等指令的结果,进行下一步操作。jX指令通常用于实现条件分支、循环等控制结构。例如:
je
:当零标志(ZF)设置时跳转。jne
:当零标志(ZF)未设置时跳转。jg
:当有符号数比较结果大于时跳转。jl
:当有符号数比较结果小于时跳转。
setX指令
setX和jX的逻辑就很像了。SetX 指令允许根据条件码的组合来设置目标寄存器的低字节(低 8 位)为 0 或 1。SetX 指令不会改变目标寄存器的其余字节。例如:
sete
:当零标志(ZF)设置时,将目标寄存器的低字节设置为 1,否则设置为 0。setne
:当零标志(ZF)未设置时,将目标寄存器的低字节设置为 1,否则设置为 0。setg
:当有符号数比较结果大于时,将目标寄存器的低字节设置为 1,否则设置为 0。setl
:当有符号数比较结果小于时,将目标寄存器的低字节设置为 1,否则设置为 0。
比如假设我们要比较两个整数(存储在寄存器 %rax
和 %rbx
中)是否相等,并将结果存储在寄存器 %rcx
的低字节中(即 1
表示相等,0
表示不相等)。就可以这样:
cmp %rax, %rbx ; 比较 %rax 和 %rbx 的值
sete %cl ; 如果它们相等(即零标志 ZF 设置),则将 %rcx 的低字节(%cl)设置为 1,否则设置为 0
稍微有点不同的是setX后面要多个参数,比如目标寄存器(如 %cl
和 %bl
)。setX的参数永远是这些低位寄存器(%al, %r8b, etc.)。低位寄存器我们之前提到过,其实就是正常寄存器中的低位部分自己的名字。比如%al其实就是寄存器%rax的最后一个字节。%eax其实就是%rax的后4个字节。寄存器位数不同,应用操作命令也要小心,当然也有专门的命令用来匹配不同位数的寄存器,比如movzbl:
movzbl %al, %eax
movzbl
是一个 x86 汇编指令,全称为 "Move with Zero-Extend Byte to Long"。该指令用于将一个字节(8 位)的数据从源操作数移动到目的操作数,并将其零扩展为长字(32 位)或四字(64 位,取决于操作数的大小)。
好了跑题了,回归正轨。
一个条件判断代码块的有趣例子:
long absdiff (long x, long y) {
long result;
if (x > y)
result = x-y;
else
result = y-x;
return result;
}
machine code:
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
乍一看好像有点奇怪,怎么没有先cmp x y 然后根据结果jump呢?咋一个jump都没有。这是因为这里使用了条件移动指令Conditional Move Instructions,提前计算好了两种情况下的值,然后用cmovle来完成了同样需求。条件移动指令的主要优势在于它们不会破坏指令流中的顺序执行。在现代处理器的流水线架构中,分支指令(如跳转)可能会导致流水线中的指令顺序中断,从而降低性能。条件移动指令不需要控制转移,因此在处理器流水线中更高效。至于movle中的le是啥意思,就不用多说了吧,和上面jX,setX都一样。
当然条件移动指令并非在所有情况下都是最佳选择。条件移动指令要求在执行之前计算出两个值。这意味着如果计算成本较高,条件移动指令可能并不是最佳选择;再比如计算存在风险或者计算会对修改全局变量等。以下是一些不适合条件移动指令的代码示例。
val = Test(x) ? Hard1(x) : Hard2(x); //计算量大
val = p ? *p : 0; //风险计算
val = Test(x) ? FunctionWithSideEffect1(x) : FunctionWithSideEffect2(x); //造成不必要的执行
switch语句和循环Loop
最后再讲一下switch和loop循环。下面是一个switch语句例子:
long switch_eg(long x, long y, long z)
{
long w = 1;
switch(x) {
// case statements...
}
return w;
}
machine code如下:
switch_eg:
movq %rdx, %rcx
cmpq $6, %rdi
ja .L8
jmp *.L4(,%rdi,8)
在上面的汇编代码中,我们可以看到两种跳转指令:直接跳转ja .L8和间接跳转jmp
*.L4(,%rdi,8)。直接跳转我们已经很熟悉了,间接跳转就是机器对于switch语句中不同代码块的地址生成的一个jump table,这些代码块的地址往往是连起来的,通过从一个起始位置加偏移的方式去跳转。比如在这类,这个jump table跳转表如下:
.section .rodata
.align 8
.L4:
.quad .L8
.quad .L3
.quad .L5
.quad .L9
.quad .L8
.quad .L7
.quad .L7
在这个例子中,跳转表的起始地址是.L4
。由于表中的每个地址都需要8字节,我们需要将x
(存储在%rdi
中)乘以8来获取正确的偏移量。然后,从.L4
开始,加上偏移量x*8
,得到实际的跳转目标。
在switch_eg
函数中,间接跳转用于根据x
的值选择相应的case
分支。间接跳转仅在0 ≤ x ≤ 6的范围内有效,因为跳转表.L4
仅包含7个目标地址(对应x
取值为0到6的情况)。对于x
大于6的情况,程序会执行默认分支,即直接跳转到.L8(对应 ja .L8)
。
当然,至于为什么L8对应x=0,L3对应x=1之类的,可以根据L8的machine code和原代码比较得出。。
Loops循环
循环在c语言中的写法有很多种,但不管是while-do,do-while,for等,转换成machine code后都是差不多的。因为不管是哪种循环,都是具有“init”初始化,“条件”,“主体”,“更新”那么几部分,同样的一种逻辑你用for,while,甚至是goto语句写,出来的machine code估计都是一样的。不再细写~