循环与条件

让我们考虑一个稍微复杂一点的例子:

loop:
add edx, DWORD PTR [rax]
add rax, 4
cmp rax, rcx
jne loop

它会对32位整数数组进行求和,就像简单的for循环一样。

循环体是add edx, DWORD PTR [rax]:该指令根据迭代器rax加载数据,并将其累加到累加器edx。接下来,我们通过add rax, 4将迭代器向前移动4个字节。然后,一件稍微复杂一些的事情发生了。

跳转

汇编语言没有高级语言所具有的if-s、for-s、函数或其他流控制结构。它所拥有的是goto,在低级编程世界中,它的另一个名字跳转(Jump)更为出名。

跳转将指令指针移动到由其操作数指定的位置。这个位置可以是内存中的绝对地址,相对于当前地址的相对地址,甚至可以是在运行时计算的地址。为了避免直接管理这些地址的麻烦,你可以用后面跟着:的字符串来标记任何指令,然后将此字符串用作标签,当转换为机器代码时,该标签将被该指令的相对地址所取代。

标签可以是任何字符串,但编译器没有创造性,在为标签选择名称时,通常只使用源代码中的行号和函数名。

无条件跳转jmp只能用于实现while(true)类型的循环或将程序的几个部分拼在一起。使用一系列条件跳转来实现实际的流控制。

在软件角度看,条件跳转的工作方式是:条件比较在某个地方被计算为bool值,并作为操作数传递给条件跳转。但在硬件中并不是这样实现的,条件运算会使用一个特殊的FLAGS寄存器,该寄存器首先需要通过执行某种检查的指令来填充。

在我们的示例中,cmp rax, rcx将迭代器rax与数组结束指针rcx进行比较。这会更新FLAGS寄存器,然后该寄存器可以被用于jne loopjne指令在该寄存器查找一个特定的位,该位表示这两个值是否相等,基于此位,决定是跳回到开始,还是继续到下一条指令,从而跳出循环。

循环展开

你可能已经注意到了,上面循环的例子中,处理单个元素会有很多额外开销。在每个周期中,只执行了一条有用的指令,其他3条指令都在递增迭代器,并判断循环终止条件。

我们可以做的是通过将迭代操作分组来展开循环——相当于用C语言写这样的东西:

for (int i = 0; i < n; i += 4) {
s += a[i];
s += a[i + 1];
s += a[i + 2];
s += a[i + 3];
}

在汇编中,它看起来是这样的:

loop:
add edx, [rax]
add edx, [rax+4]
add edx, [rax+8]
add edx, [rax+12]
add rax, 16
cmp rax, rsi
jne loop

现在我们只需要为每4条有效指令提供3条循环控制指令(从1/4到4/7的效率提升),并且循环展开可以一直做,直到额外开销几乎减少为零。

在实践中,展开循环并不总是能提高效率,因为现代处理器实际上并不是一个接一个地执行指令,而是维护一个挂起指令的队列,这样两个独立的操作就可以同时执行,而无需等待彼此完成。

我们的例子也是如此:展开后的真正加速不会是四倍,因为递增计数器和循环结束检查的操作独立于循环体,可以调度成与循环体并行运行。但在某些情况下,要求编译器做循环展开可能仍然是有益的。

另一种方法

不必显式地使用cmp或类似的指令来进行条件跳转。许多其他指令也会读取或修改FLAGS寄存器,有时使能可选的异常检查也会修改FLAGS寄存器。

例如,add会设置多个标志,表示结果是否为零、是否为负、是否发生上溢或下溢等。利用这种机制,编译器通常会生成这样的循环汇编代码:

    mov  rax, -100  ; replace 100 with the array size
loop:
add edx, DWORD PTR [rax + 100 + rcx]
add rax, 4
jnz loop ; checks if the result is zero

这段代码对一般人来说可读性稍差,但它在循环部分减少了一条指令,这可能有助于提升性能。