如何在汇编中遍历字符串直到遇到空字符?(strlen循环)

3

我现在正在学习如何遍历一个字符串。如果代码看起来不太合理,那是因为我可能对某些信息的解释有误。最糟糕的情况就是,我其实并不知道自己在做什么。

strlen:

pushq %rbx
movq %rsi, %rbx


loop:
    cmp $0x00, (%rdi, %rbx)
    je end
    inc %rbx
    jmp loop

end:
    movq %rbx, %rax
    popq %rbx
    ret

PS:我的标题看起来像一个老年人第二次使用电脑尝试搜索“如何进入google.com”,我是个超级新手,正在尝试学习一些汇编知识。我正在尝试为自己实现strlen函数。


1
看看这个链接,它会帮助你理解 https://stackoverflow.com/a/40647017/10927635 - vishal
cmp指令与立即数操作数一起使用时,需要将立即数放在第一个(源)操作数位置。此外,在循环中可以直接使用RDI作为指针,或选择一些调用破坏的寄存器,这样就不必保存/恢复RBX寄存器了。 - Peter Cordes
在这种情况下使用%rbx是正确的吗?使用%ebx呢?我知道这只是一种惯例,但为了清晰起见。 - Block o Butter
1
为什么在这种情况下rax和rdi的作用相同?Why does rax and rdi work the same in this situation?使用NASM语法实现了一个工作的strlen。这不是“魔法”,只需增加指针即可。是的,RBX可以使用inc %rbx,但是使用RAX可以让您在不必保存/恢复RBX的情况下完成相同的操作。否则,您不应该使用32位寄存器来存储指针。 - Peter Cordes
当我执行 inc %rbx 时,我是在增加1个字节吗?因此,如果我执行 cmp $0x00, (%rdi, % rbx),它应该将指针与 null 进行比较,对吗?据我所知,在 AT&T 语法中,(%rdi, %rbx) 相当于 (rdi + rbx)。 - Block o Butter
RDI和RBX都保存指针。将它们相加并不能得到有效的地址!我没有找到任何一个strlen实现的SO Q&A,它不会以某种重大方式失败(例如,在循环内部有2个分支),因此我写了一个答案。 - Peter Cordes
1个回答

3
你只需使用inc %rbx来增加指针值。(%rbx)会对该寄存器进行解引用,使用其值作为内存地址。在x86上,每个字节都有自己的地址(这个属性称为“按字节寻址”),并且地址仅是适合寄存器的整数。
ASCII字符串中的字符都是1个字节宽,所以将指针增加1就可以移到ASCII字符串中的下一个字符。(在UTF-8中包含1..127范围外代码点的字符的一般情况下,这不成立,但ASCII是UTF-8的子集。)
术语:ASCII代码0称为NUL(一个L),而不是NULL。在C中,NULL是一个指针概念。 C风格的隐式长度字符串可以描述为0终止或NUL终止,但“null-terminated”的使用术语是错误的。
你应该选择一个不需要推/弹回函数的寄存器(调用破坏的寄存器)。由于代码不生成任何函数调用,因此没有必要将归纳变量保留在保存调用的寄存器中。
我在其他SO Q&A中没有找到一个好的简单示例。它们要么在循环内有2个分支(包括一个无条件jmp),就像我在评论中链接的那个,要么浪费指令递增指针和计数器。在循环内使用索引寻址模式并不可怕,但在某些CPU上效率较低,因此我仍然建议在循环后执行指针递增->从结束-开始减去。 这是我编写的一个最小strlen示例,它只检查1个字节(缓慢而简单)。我保持了循环本身的简洁,这在我看来是编写循环的好方法的合理示例。通常,使代码紧凑可以更容易地以asm形式理解函数。(给它取一个不同于strlen的名称,这样您就可以测试它而不需要gcc -fno-builtin-strlen或其他内容。)
.globl simple_strlen
simple_strlen:
    lea     -1(%rdi), %rax     # p = start-1 to counteract the first inc
 .Lloop:                       # do {
    inc     %rax                  # ++p
    cmpb    $0, (%rax)
    jne     .Lloop             # }while(*p != 0);
                           # RAX points at the terminating 0 byte = one-past-end of the real data
    sub     %rdi, %rax     # return length = end - start
    ret
strlen的返回值是0字节的数组索引,也就是数据长度(不包括终止符)。
如果你手动内联这个函数(因为只有3条指令的循环),你通常只需要终止符的指针,所以你不必费心去做减法,只需在循环结束时使用RAX即可。
避免第一次加载之前的LEA/INC操作(在第一个cmp之前花费了2个周期的延迟)可以通过削离第一次迭代或通过jmp在inc后进入循环。参见为什么循环总是被编译成“do...while”样式(尾调用)?
在cmp/jcc之间使用LEA增加指针(例如cmp;lea1(%rax),%rax;jne)可能会更糟,因为它会破坏cmp/jcc的宏融合,使其成为单个微操作。(实际上,类似Skylake这样的英特尔CPU上根本不会发生cmp $imm,(%reg)/jcc的宏融合。但是,cmp会融合存储器操作数。也许AMD会融合cmp/jcc。)此外,循环结束时,RAX会比预期的高1。
所以(在英特尔Sandybridge系列上),将一个字节转换为%ecx并将其扩展为零的movzx(又名movzbl),以及将其作为循环条件进行测试test %ecx,%ecx/jnz,效率与LEA相同。但代码更大。
大多数CPU将每个时钟周期运行我的循环一次。通过一些循环展开,我们可以接近每个时钟周期检查2个字节(仍然只是单独检查每个字节)。
对于大型字符串,逐个字节检查比使用SSE2慢约16倍。如果你不追求代码大小和简易性,请参见为什么这段代码启用优化后会慢6.5倍?,其中介绍了一种使用XMM寄存器的简单SSE2 strlen。 SSE2是x86-64的基线,因此应该在需要手动编写汇编程序的情况下始终使用它以提高速度。

关于你更新的问题,使用从为什么在这种情况下rax和rdi起着相同的作用?中实现有缺陷的端口。

RDI和RBX都保存指针。将它们加在一起不会得到有效的地址!在尝试端口的代码中,RCX(索引)在循环之前被初始化为零。但是,你没有使用xor %ebx,%ebx,而是使用了mov %rdi,%rbx。在单步执行代码时使用调试器来检查寄存器的值。


谢谢!你的解释很清楚易懂。 - Block o Butter

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