循环遍历NASM中的数组

时间:2015-07-14 11:16:10

标签: arrays linux assembly nasm x86-64

我想学习汇编编程以编写快速高效的代码。 我怎么能偶然发现一个我无法解决的问题。

我想循环遍历双字数组,并添加如下组件:

%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系统

1 个答案:

答案 0 :(得分:3)

我不确定您的代码打印错误号码的原因。可能是一个你应该使用调试器跟踪的某个地方。带有layout asmlayout reg的gdb应该会有所帮助。实际上,我认为你已经超越阵列的末尾了。那里可能是-1,你将它添加到你的累加器。

如果你的最终目标是快速写作&有效的代码,你应该看看我最近添加到https://stackoverflow.com/tags/x86/info的一些链接。 ESP。 Agner Fog的优化指南非常适合帮助您了解当今机器的高效运行情况,以及无法解决的问题。例如leave较短,但与mov rsp, rbp / pop rbp取2相比需要3 uops。或者只省略帧指针。 (这些天gd默认为-fomit-frame-pointer为amd64。)乱用rbp只是浪费指令并花费你注册,特别是。在ASM中值得写作的函数中(即通常所有东西都存在于寄存器中,而你不会调用其他函数)。

"正常"这样做的方法是在asm中编写你的函数,从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编译,也不必更改ASM以在不同的寄存器中查找其args。 (SysV调用约定比Win64更好:寄存器中的args更多,允许使用所有向量寄存器而不保存它们。)确保你有一个足够新的gcc,它具有https://gcc.gnu.org/bugzilla/show_bug.cgi?id=66275的修复但是。

另一种方法是使用一些汇编程序宏来%define某些寄存器名称,这样就可以为Windows或SysV ABI组装相同的源代码。或者在常规版本之前有一个Windows入口点,它使用一些MOV指令将args放在函数的其余部分所期望的寄存器中。但这显然效率较低。

了解asm中的函数调用是否有用,但通常自己编写它们是浪费时间。完成的例程将返回结果(在寄存器或内存中),而不是打印它。你的print_int等例程非常低效。 (按下/弹出每个被调用者保存的寄存器,即使你不使用它们,也可以多次调用printf而不是使用以\n结尾的单个格式字符串。)我知道你没有声明< em>这个代码很有效,而且你只是在学习。您可能已经知道这不是非常紧密的代码。 :P

我的观点是,编译器在大多数情况下都非常擅长工作。花时间编写asm仅用于代码的热门部分:通常只是一个循环,有时包括它周围的设置/清理代码。

所以,关于你的循环

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

Never use the loop instruction。它解码为7 uops,而宏观融合的比较和分支则为1。 loop的最大吞吐量为每5个周期一个(英特尔Sandybridge / Haswell及更高版本)。相比之下,dec ecx / jnz lpcmp rbx, array_end / jb lp会让您的循环在每个循环中以一次迭代运行。

由于您使用的是单寄存器寻址模式,因此使用add rdx, [rbx]也会比单独的mov - 加载效率更高。 (使用索引寻址模式since they can only micro-fuse in the decoders / uop-cache, not in the rest of the pipeline, on Intel SnB-family是一个更复杂的权衡。在这种情况下,add rdx, [rbx+rsi]或其他东西会在Haswell及其后的东西上保持微融合。

当手动编写asm时,如果方便的话,可以通过在rdi中保留rsi和dest指针中的源指针来帮助自己。 movs insn以这种方式隐式使用它们,这就是他们将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个周期进行1次迭代。前端可以在一个周期内发出所有4个uop,但执行单元无法在没有Haswell的第4个ALU端口的情况下处理add和宏融合cmp/jb。每次迭代展开4 paddd可以为Sandybridge提供帮助,也可能对Haswell有所帮助。

使用AVX2 vpadd ymm1, [32+rsi],您可以获得两倍的吞吐量(如果数据在缓存中,否则您仍然会遇到内存瓶颈)。要对256b向量执行水平求和,请从vextracti128 xmm1, ymm0, 1 / vpaddd xmm0, xmm0,xmm1开始,然后它与SSE大小写相同。请参阅this answer for more details about efficient shuffles for horizontal ops