machine code中的条件控制control flow和switch语句,循环Loop

上篇写了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之类的小寄存器)。

  1. CF(Carry Flag):进位标志,用于无符号数运算。
  2. SF(Sign Flag):符号标志,用于有符号数运算。
  3. ZF(Zero Flag):零标志,表示运算结果是否为零。
  4. OF(Overflow Flag):溢出标志,用于有符号数运算。

在 GDB 调试器中,这些标志位会被打印为一个名为 "eflags" 的寄存器,比如示:

eflags 0x246 [ PF ZF IF ] Z set, CSO clear

在进行算术运算时,条件码通常会被隐式设置(作为运算的附加结果)。以 addq Src, Dest 操作为例,该操作执行加法运算:t = a + b。在这个过程中,条件码会根据以下规则自动set:

  1. CF(Carry Flag):当最高有效位产生进位时set(表示无符号溢出)。
  2. ZF(Zero Flag):当 t == 0 时set。
  3. SF(Sign Flag):当 t < 0 时set(表示结果为负数)。
  4. OF(Overflow Flag):当发生补码(有符号)溢出时set。这种情况包括:
    • 当 a > 0b > 0 且 t < 0 时。
    • 当 a < 0b < 0 且 t >= 0 时。

比较指令(cmp)和 测试指令(test)

可以注意到,在一开始的示例代码中,je上面还有一个testl命令。这里就要引出cmp和test。

cmp 指令用于比较两个操作数 a 和 b。其执行过程如下:

  1. 计算 b - a(与 sub 指令相同)。
  2. 根据结果设置条件码,但不改变操作数 b 的值。

test 指令用于测试两个操作数 a 和 b。其执行过程如下:

  1. 计算 b & a(与 and 指令相同)。
  2. 根据结果设置条件码(仅设置 SF 和 ZF),但不改变操作数 b 的值。

jX Instructions跳转指令

终于来到je了!je其实是jX中的一种,其实就是根据前面cmp或者test等指令的结果,进行下一步操作。jX指令通常用于实现条件分支、循环等控制结构。例如:

  1. je:当零标志(ZF)设置时跳转。
  2. jne:当零标志(ZF)未设置时跳转。
  3. jg:当有符号数比较结果大于时跳转。
  4. jl:当有符号数比较结果小于时跳转。

setX指令

setX和jX的逻辑就很像了。SetX 指令允许根据条件码的组合来设置目标寄存器的低字节(低 8 位)为 0 或 1。SetX 指令不会改变目标寄存器的其余字节。例如:

  1. sete:当零标志(ZF)设置时,将目标寄存器的低字节设置为 1,否则设置为 0。
  2. setne:当零标志(ZF)未设置时,将目标寄存器的低字节设置为 1,否则设置为 0。
  3. setg:当有符号数比较结果大于时,将目标寄存器的低字节设置为 1,否则设置为 0。
  4. 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估计都是一样的。不再细写~

猜你喜欢

转载自blog.csdn.net/weixin_44492824/article/details/131385740