如果问题陈述表明当你被调用时指针已经在 SI 和 DI 中,你不应该破坏它们。
16 位代码通常不遵守所有函数的单一调用约定,并且在寄存器中传递(前几个)args 通常是好的(更少的指令,并避免存储/重新加载)。 32 位 x86 调用约定通常使用堆栈参数,但这已经过时了。 Windows x64 和 Linux/Mac x86-64 System V ABI / 调用约定都使用寄存器参数。
不过,问题陈述并未提及计数。 因此,您正在为以零字节终止的字符串实现strcmp,而不是为已知长度的内存块实现memcmp。您不能使用单个rep 指令,因为您需要检查不相等和字符串结尾。 如果您只是传递一些较大的尺寸,并且字符串 相等,repe cmpsb 将继续通过终止符。
如果您知道
either 字符串的长度,则
repe cmpsb 可用。例如在 CX 中取一个长度 arg 以避免在两个字符串中都超过终止符的问题。
但就性能而言,repe cmpsb 无论如何都不是很快(例如,在 Skylake 与 Ryzen 上,每次比较需要 2 到 3 个周期。甚至在 Bulldozer 系列上每次比较需要 4 个周期)。只有 rep movs 和 rep stos 在现代 CPU 上是高效的,具有一次复制或存储 16(或 32 或 64)字节的优化微码。
在内存中存储字符串有 2 个主要约定:显式长度字符串(指针 + 长度),如 C++ std::string,以及 隐式长度字符串有一个指针,字符串的结尾由哨兵/终止符标记。 (例如使用 0 字节的 C char*,或使用 '$' 作为终止符的 DOS 字符串打印函数。)
一个有用的观察是您只需要检查 一个 字符串中的终止符。如果另一个字符串有终止符而这个没有,那将是不匹配的。
因此,您希望将一个字节从一个字符串加载到寄存器中,并检查它的终止符和另一个字符串的内存。
(如果您需要实际使用 ES:DI 而不是仅使用默认 DS 段库的 DI,则可以使用 cmp al, [es: bx + di](NASM 语法,根据需要进行调整,例如 cmp al, es: [bx + di] I想想)。可能是你打算使用lodsb和scasb的问题,因为scasb uses ES:DI。)
;; inputs: str1 pointer in DI, str2 pointer in SI
;; outputs: BX = mismatch index, or one-past-the-terminators.
;; FLAGS: ZF=1 for equal strings (je), ZF=0 for mismatch (jne)
;; clobbers: AL (holds str1's terminator or mismatching byte on return)
strcmp:
xor bx, bx
.innerloop: ; do {
mov al, [si + bx] ; load a source byte
cmp al, [di + bx] ; check it against the other string
jne .mismatch ; if (str1[i] != str2[i]) break;
inc bx ; index++
test al, al ; check for 0. Use cmp al, '$' for a $ terminator
jnz .innerloop ; }while(str1[i] != terminator);
; fall through (ZF=1) means we found the terminator
; in str1 *and* str2 at the same position, thus they match
.mismatch: ; we jump here with ZF=0 on mismatch
; sete al ; optionally create an integer in AL from FLAGS
ret
用法:将指针放入SI/DI,call strcmp/je match,因为匹配/不匹配状态为FLAGS。如果要将条件转为整数,386及以后的CPU允许sete al根据equals条件(ZF==1)在AL中创建0或1。
使用sub al, [mem] 而不是cmp al, [mem],我们会得到al = str1[i] - str2[i],只有当字符串匹配时才给我们一个0。如果您的字符串仅包含 0..127 的 ASCII 值,则不会导致有符号溢出,因此您可以将其用作有符号返回值,该值实际上告诉您哪个字符串在另一个之前/之后排序。 (但如果字符串中可能有高 ASCII 128..255 字节,我们需要零或符号扩展为 16 位 first 以避免像 ( unsigned)5 - (unsigned)254 = (signed)+7,因为 8 位环绕。
当然,有了我们的FLAGS返回值,调用者已经可以使用ja或jb(无符号比较结果),或者jg/jl如果他们想处理字符串为持有signed char。无论输入字节的范围如何,这都有效。
或者内联这个循环,以便jne mismatch 直接跳转到有用的地方。
16-bit addressing modes are limited,但 BX 可以是基数,SI 和 DI 都可以是索引。我使用了索引增量而不是inc si 和inc di。使用lodsb 也是一种选择,甚至可能使用scasb 将其与其他字符串进行比较。 (然后检查终结符。)
性能
在某些现代 x86 CPU 上,索引寻址模式可能会更慢,但这确实可以节省循环中的指令(因此它适用于代码大小很重要的真正 8086)。虽然要真正调整 8086,但我认为 lodsb / scasb 将是您最好的选择,替换 mov 负载和 cmp al, [mem],以及 inc bx。如果您的调用约定不能保证这一点,请记住在循环外使用cld。
如果您关心现代 x86,请使用 movzx eax, byte [si+bx] 打破对 EAX 旧值的错误依赖,用于不单独重命名部分寄存器的 CPU。 (如果你使用sub al, [str2],打破错误的 dep 尤其重要,因为这会将它变成一个通过 EAX 的 2 周期循环承载的依赖链,在除 PPro 之外的 CPU 上通过 Sandybridge。IvyBridge 和更高版本不会单独重命名 AL EAX,所以mov al, [mem] 是一个微融合加载+合并uop。)
如果cmp al,[bx+di] 微熔断负载,并用jne 宏熔断到一个比较和分支微指令中,则整个循环在 Haswell 上总共可能只有 4 微指令,并且每个循环可以运行 1 次迭代大输入时钟。最后的分支错误预测将使小输入性能更差,除非每次分支都以足够小的输入进行。见https://agner.org/optimize/。最近的 Intel 和 AMD 可以每个时钟执行 2 次加载。
展开可以摊销inc bx 的成本,但仅此而已。在循环内有一个采用 + 未采用的分支,当前的 CPU 每次迭代的运行速度都不能超过 1 个周期。 (有关 do{}while 循环结构的更多信息,请参阅 Why are loops always compiled into "do...while" style (tail jump)?)。为了更快,我们需要一次检查多个字节。
与使用 SSE2 的每 1 或 2 个周期 16 个字节相比,即使是 1 个字节/周期也非常慢(使用一些巧妙的技巧来避免读取可能出现故障的内存)。
有关使用 x86 SIMD 进行字符串比较以及 glibc 的 SSE2 和后来优化的字符串函数的更多信息,请参阅 https://www.strchr.com/strcmp_and_strlen_using_sse_4.2。
GNU libc's 后备标量 strcmp 实现看起来不错(从 AT&T 转换为 Intel 语法,但保留了 C 预处理器宏和其他内容。L() 制作了一个本地标签)。
它仅在 SSE2 或更高版本不可用时使用。有用于检查整个 32 位寄存器的任何零字节的 bithacks,即使没有 SIMD,这也可以让你走得更快,但对齐是一个问题。 (如果终止符可以在任何地方,则在一次加载多个字节时必须小心,不要从您不确定的任何内存页面中读取确定至少包含 1 个字节的有效数据,否则您可能会出错。)
strcmp:
mov ecx,DWORD PTR [esp+0x4]
mov edx,DWORD PTR [esp+0x8] # load pointer args
L(oop): mov al,BYTE PTR [ecx] # movzx eax, byte ptr [ecx] would be avoid a false dep
cmp al,BYTE PTR [edx]
jne L(neq)
inc ecx
inc edx
test al, al
jnz L(oop)
xorl eax, eax
/* when strings are equal, pointers rest one beyond
the end of the NUL terminators. */
ret
L(neq): mov eax, 1
mov ecx, -1
cmovb eax, ecx
ret