在NASM中循环遍历数组

3
我希望学习汇编编程,以编写快速高效的代码。然而,我遇到了一个无法解决的问题。
我想循环遍历双字数组,并按以下方式添加其组件:
%include "asm_io.inc"  
%macro prologue 0
    push    rbp
    mov     rbp,rsp
    push    rbx
    push    r12
    push    r13
    push    r14
    push    r15
%endmacro
%macro epilogue 0
    pop     r15
    pop     r14
    pop     r13
    pop     r12
    pop     rbx
    leave
    ret
%endmacro

segment .data
string1 db  "result: ",0
array   dd  1, 2, 3, 4, 5

segment .bss


segment .text
global  sum

sum:
    prologue

    mov  rdi, string1
    call print_string

    mov  rbx, array
    mov  rdx, 0
    mov  ecx, 5

lp:
    mov  rax, [rbx]
    add  rdx, rax
    add  rbx, 4
    loop lp

    mov  rdi, rdx
    call print_int
    call print_nl

epilogue

Sum被一个简单的C驱动程序调用。函数print_string、print_int和print_nl如下所示:

section .rodata
int_format  db  "%i",0
string_format db "%s",0

section .text
global  print_string, print_nl, print_int, read_int
extern printf, scanf, putchar

print_string:
    prologue
    ; string address has to be passed in rdi
    mov     rsi,rdi
    mov     rdi,dword string_format
    xor     rax,rax
    call    printf
    epilogue

print_nl:
    prologue
    mov     rdi,0xA
    xor     rax,rax
    call    putchar
    epilogue

print_int:
    prologue
    ;integer arg is in rdi
    mov     rsi, rdi
    mov     rdi, dword int_format
    xor     rax,rax
    call    printf
    epilogue

在将数组中所有元素相加后打印结果时,它会显示“结果:14”,而不是15。我尝试了几种组合的元素,似乎我的循环总是跳过数组的第一个元素。有人能告诉我为什么循环会跳过第一个元素吗?

编辑

我忘记提到我正在使用x86_64 Linux系统。


你的数组是dwords,但你正在添加qwords。切换到eax - Frank Kotler
@FrankKotler 谢谢。它可以工作,但是我不应该也将 rbxrdx 改为 ebxedx 吗? - muXXmit2X
不是 rbx - 那是一个地址,需要 64 位。将 rdx 更改为 edx 应该没问题。 - Frank Kotler
1个回答

3
我不确定为什么你的代码打印出了错误的数字。可能是某个地方出现了偏差,你应该使用调试器进行跟踪。gdb与layout asmlayout reg应该有所帮助。实际上,我认为你超出了数组的末尾。那里可能有一个-1,而你正在将其添加到累加器中。
如果你的最终目标是编写快速高效的代码,你应该查看一些我最近添加到https://stackoverflow.com/tags/x86/info的链接。特别是Agner Fog的优化指南非常适合帮助你了解在今天的机器上运行得高效和不高效的内容。例如,leave更短,但需要3个uop,而mov rsp,rbp / pop rbp只需要2个。或者只需省略框架指针。(gcc默认在amd64上使用-fomit-frame-pointer这几天)。在函数中搞乱rbp只会浪费指令并且让你损失一个寄存器,特别是在值得用汇编语言编写的函数中(即通常所有内容都在寄存器中,并且你不会调用其他函数)。
“正常”的做法是用汇编语言编写函数,从C中调用它以获取结果,然后使用C打印输出。如果您希望您的代码可移植到Windows上,可以使用类似于...的东西。
#define SYSV_ABI __attribute__((sysv_abi))
int SYSV_ABI myfunc(void* dst, const void* src, size_t size, const uint32_t* LH);

即使您为Windows编译,也不必更改汇编语言以查找其参数在不同寄存器中的位置。(SysV调用约定比Win64更好:在寄存器中有更多的参数,并且所有矢量寄存器都允许在不保存它们的情况下使用。)确保您拥有足够新的gcc,其中包含https://gcc.gnu.org/bugzilla/show_bug.cgi?id=66275的修复程序。

另一种选择是使用一些汇编宏来%define一些寄存器名称,以便您可以将相同的源代码汇编为Windows或SysV ABIs。 或者在常规入口点之前具有Windows入口点,该入口点使用一些MOV指令将参数放置在函数其余部分期望的寄存器中。 但这显然效率较低。


了解函数调用在汇编中的样子是有用的,但自己写函数通常是浪费时间的。你的完成例程只会返回结果(在寄存器或内存中),而不是打印它。你的print_int等例程效率非常低下。(每次调用保存和恢复所有被调用者保存的寄存器,即使你没有使用它们,并且多次调用printf而不是使用以\n结尾的单个格式字符串。)我知道你没有声称这段代码很高效,而且你只是在学习。你可能已经意识到这不是非常紧凑的代码。:P
我的观点是,编译器通常非常擅长它们的工作。把时间花在为代码的热点部分编写汇编代码上:通常只是一个循环,有时包括它周围的设置/清理代码。
所以,继续你的循环:
lp:
    mov  rax, [rbx]
    add  rdx, rax
    add  rbx, 4
    loop lp

永远不要使用loop指令。它解码为7个uops,而宏融合的比较和分支只有1个uop。 loop的最大吞吐量为每5个周期一次(Intel Sandybridge / Haswell及更高版本)。相比之下,dec ecx / jnz lpcmp rbx,array_end / jb lp会让您的循环以每个周期一次的速度运行。

由于您正在使用单寄存器寻址模式,因此使用add rdx,[rbx]也比单独的mov-load更有效率。(对于索引寻址模式,由于它们只能在解码器/ uop-cache中微融合,在Intel SnB系列的其余部分中不能进行融合,因此这是一个更复杂的权衡。在这种情况下,add rdx,[rbx + rsi]或其他内容将在Haswell及更高版本上保持微融合)。

当手写汇编时,如果方便的话,请将源指针保存在rsi中,将目标指针保存在rdi中。 movs指令隐式地使用它们,这就是为什么它们被命名为sidi的原因。但是,不要仅仅因为寄存器名称而使用额外的mov指令。如果想要更好的可读性,请使用带有良好编译器的C语言。

;;; This loop probably has lots of off-by-one errors
;;; and doesn't handle array-length being odd
mov rsi, array
lea rdx, [rsi + array_length*4]  ; if len is really a compile-time constant, get your assembler to generate it for you.
mov eax, [rsi]   ; load first element
mov ebx, [rsi+4] ; load 2nd element
add rsi, 8       ; eliminate this insn by loading array+8 in the first place earlier
; TODO: handle length < 4

ALIGN 16
.loop:
    add eax, [    rsi]
    add ebx, [4 + rsi]
    add rsi, 8
    cmp rsi, rdx
    jb .loop         ;  loop while rsi is Below one-past-the-end
;  TODO: handle odd-length
add eax, ebx
ret
不要在没有调试的情况下使用这段代码。gdb(使用layout asmlayout reg)并不差,而且在每个Linux发行版中都可以使用。
如果您的数组始终是非常短的编译时常量长度,只需完全展开循环即可。否则,像这样使用两个累加器的方法可以让两个加法并行进行。(Intel和AMD CPU具有两个加载端口,因此它们可以每个时钟周期从内存中支持两个加法。Haswell具有4个执行端口,可处理标量整数操作,因此它可以以1个迭代/时钟周期执行此循环。以前的Intel CPU可以每个时钟周期发出4个uop,但执行端口会落后于它们的速度。最小化循环开销的展开将有所帮助。)
所有这些技术(尤其是多个累加器)同样适用于向量指令。
segment .rodata         ; read-only data
ALIGN 16
array:  times 64    dd  1, 2, 3, 4, 5
array_bytes equ $-array
string1 db  "result: ",0

segment .text
; TODO: scalar loop until rsi is aligned
; TODO: handle length < 64 bytes
lea rsi, [array + 32]
lea rdx, [rsi - 32 + array_bytes]  ;  array_length could be a register (or 4*a register, if it's a count).
; lea rdx, [array + array_bytes] ; This way would be lower latency, but more insn bytes, when "array" is a symbol, not a register.  We don't need rdx until later.
movdqu xmm0, [rsi - 32]   ; load first element
movdqu xmm1, [rsi - 16] ; load 2nd element
; note the more-efficient loop setup that doesn't need an add rsi, 32.

ALIGN 16
.loop:
    paddd  xmm0, [     rsi]   ; add packed dwords
    paddd  xmm1, [16 + rsi]
    add rsi, 32
    cmp rsi, rdx
    jb .loop         ;  loop: 4 fused-domain uops
paddd   xmm0, xmm1
phaddd  xmm0, xmm0     ; horizontal add: SSSE3 phaddd is simple but not optimal.  Better to pshufd/paddd
phaddd  xmm0, xmm0
movd    eax, xmm0
;  TODO: scalar cleanup loop
ret

再次强调,这段代码可能存在错误,并且不能处理对齐和长度的一般情况。它是展开的,因此每个迭代都会处理两个*四个打包的整数= 32字节的输入数据。

在Haswell上,每个周期应该运行一次迭代,否则在SnB / IvB上每1.333个周期运行一次迭代。前端可以在一个周期内发出所有4个uops,但执行单元无法跟上没有Haswell的第四个ALU端口来处理add和宏合并的cmp/jb。将展开到每个迭代的4个paddd将为Sandybridge做到这一点,并可能对Haswell也有所帮助。

使用AVX2 vpadd ymm1,[32 + rsi],您可以获得双倍的吞吐量(如果数据在缓存中,否则仍然会受到内存瓶颈的影响)。要对256b向量进行水平求和,请从vextracti128 xmm1,ymm0,1 / vpaddd xmm0,xmm0,xmm1开始,然后与SSE情况相同。有关水平操作的有效洗牌的更多细节,请参见此答案


首先:非常感谢 :)。我知道打印函数并不是很高效。我只是按照一个教程中的方法来使用它们。稍后,这个函数应该处理从C驱动程序传递过来的更大的数组。我也考虑了循环展开来加快循环速度,但向量化整个过程对我来说仍然似乎相当复杂。 - muXXmit2X
如果你正在学习汇编语言,那就先从简单的标量版本开始入手。但是要考虑如何使用一条指令并行执行四个32位加法。如果你要写汇编代码,使用向量指令通常是当今的主要原因。否则,你可能会得到更好的结果,只需使用可以自动矢量化的编译器即可(如果你感兴趣,可以查看汇编输出)。编写源代码时,使用几个累加器,以向编译器提示有多个依赖链在运行;这对于具有现代宽CPU的短循环至关重要。 - Peter Cordes
一定要使用调试器逐步执行您的代码。这将会非常有帮助。 - Peter Cordes
我还有一个关于条件跳转的问题。所以你比较了存储在rdx和rsi中的地址,如果rdx小于rsi中的地址,则应设置CF标志并使jb跳回.loop,对吗?那么难道不应该是cmp rsi,rdx,所以当rsi小于rdx时跳回.loop? - muXXmit2X
在Intel语法中,cmp rdx, rsi 根据 rdx - rsi 设置标志位(但不像 sub 一样更新 rdx)。我更习惯AT&T语法,其中操作数的顺序相反,但我认为我是正确的。你试过用调试器吗?如果我真的搞错了,请告诉我,因为这是有可能的。:P - Peter Cordes
我刚试了一下,使用 cmp rdx, rcx 只循环了一次。将其更改为 cmp rsi, rdx 后,它完美地工作了 ;) - muXXmit2X

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接