字符串比较,听起来像是在 C 语言里调一个strcmp就完事的东西。但一旦切到 Linux x86_64 汇编,这个问题瞬间变得有意思起来——没有现成的函数,没有高层的抽象,你手头只有寄存器、内存地址和一条条指令。这已经是这个汇编系列的第 24 部分,前面我们聊过数据搬运、分支跳转、内存寻址的一些玩法,今天专门把字符串比较这件事拎出来聊透:从最核心的REPE CMPSB指令原理,到可以拿出去用的比较函数,再到调试和分析结果。
这篇文章适合两类人。一类是刚开始接触 x86_64 汇编、想在字符串场景里把RSI / RDI / RCX用熟练的读者;另一类是写过些 C 或者别的语言、想搞清楚strcmp背后到底发生了什么的人。我不会只贴代码,而是会把每一步为什么这么设计、CPU 到底在执行什么讲明白,这样你写完代码后不是会背了,而是真懂了。
1. 比较之前先想清楚:x86_64 下字符串比较的思路
1.1 为什么不能在汇编里“直接比较字符串”
很多从 C 转过来的人,下意识会觉得可以像cmp rax, rbx那样把两个字符串丢进去比。这个直觉在汇编语言里完全行不通,原因很简单:CPU 的CMP指令比较的是寄存器或内存中固定长度的数值,它不知道“字符串”这个概念,更不知道字符串的终止符在哪里。
字符串本质上是一个变长的字节序列,哪怕你用db "hello", 0在数据段定义好了,CPU 看到的仍然只是一串地址和字节。要判断两个字符串相等不相等,必须先确定一个比较范围,然后在这个范围内逐字节或者逐多字节地比较,一旦发现不同就得停下来。
所以字符串比较的底层思路很直接:它不是一个比较操作,而是一个“带条件的循环”。先比较一个单元,如果相等就继续下一个,直到遇到不同或者到达指定长度为止。高级语言里一个strcmp隐藏了这一切,汇编里你得亲手把它们写出来。
1.2 寄存器分工:RSI 和 RDI 是主角
x86_64 的字符串指令有一套自己的约定,其中最核心的就是RSI和RDI。RSI是源索引寄存器,RDI是目的索引寄存器,在字符串操作里分别指向源字符串和目标字符串。
这个设计不是随便定的。早期 x86 指令集里,字符串操作指令会默认使用DS:SI和ES:DI这两组段寄存器加偏移寄存器,后来到了 64 位模式下,段寄存器的作用被大大弱化,但RSI和RDI的名字和分工保留了下来。所以你在写字符串比较相关代码时,RSI指向第一个字符串、RDI指向第二个字符串,这算是 x86 家族从 16 位时代一路传下来的“行业惯例”。
除了这两个,RCX一般充当计数器。在 x86 指令集里,很多带REP前缀的指令都默认用RCX作为循环次数,CMPS系列也不例外。放多少进RCX,就决定了最多比较多少个字节。
1.3 返回值设计的讲究
写字符串比较函数前,先想清楚返回值。C 语言里strcmp返回的规则是:如果两个字符串相同返回 0,如果第一个字符串按字典序小于第二个,返回负数;大于则返回正数。
在汇编里实现时,我习惯返回三态:0表示相等,1表示第一个大于第二个,-1表示第一个小于第二个。这样设计的好处是调用方判断起来非常轻松——test rax, rax再配合jz/js/jg就能覆盖所有分支。
这不只是给 C 写接口时需要,哪怕你后面要写汇编调用汇编,一个统一、简单的返回约定也能大大减少“这个函数到底返回了啥”的疑惑。接下来一整章都在围绕这个约定展开。
2. 核心指令 REPE CMPSB:原理和执行细节
2.1 CMPSB 到底干了几件事
CMPSB是CMPS(compare string)指令家族的字节版本。它执行时做两件事:第一,把RSI指向的字节和RDI指向的字节做减法比较,结果反映在EFLAGS寄存器的ZF / SF / CF等标志位上;第二,自动修改RSI和RDI,如果方向标志DF为 0,则两者各加 1;如果DF为 1,则两者各减 1。
很多人第一次看会懵:为什么指令还要自动改指针?这正是字符串指令的核心理念。CPU 设计者希望用一条指令完成“比较一个单元 + 移动指针”的组合操作,这样配合REP前缀,就能写出非常紧凑的循环。
CMPSB是 8 位的,还有CMPSW比较 16 位、CMPSD比较 32 位、CMPSQ比较 64 位。它们的逻辑一模一样,只是每次比较的步长不同。字节这种东西用得最多,因为它最贴近字符串的本质——按字节逐个比。
2.2 REPE 前缀的真正含义
单独的CMPSB只比较一个字节,这不满足字符串比较的需求。真正的核心是REPE CMPSB。REPE(repeat while equal)的意思是:只要上一次比较结果相等,就继续执行CMPSB。
把它展开成伪代码,大概是这个样子:
while (rcx != 0) { temp = *(rdi) - *(rsi); rsi += 1; rdi += 1; rcx -= 1; if (temp != 0) break; }这里有个关键点:REPE的循环条件有两个,一个是RCX不为 0,另一个是标志位ZF = 1。两个条件同时满足才继续循环,任何一条不满足就停下。所以两条字符串在某个位置出现第一个不同字节后,循环会立即终止。
与之对应的是REPNE CMPSB,意思是只要不相等就一直比较,一旦相等就停。这个语义常用于找两个字符串的第一个相同字节,相当于一个“按字节找匹配”的工具。写字符串比较用REPE,写匹配搜索用REPNE,别搞反。
2.3 方向标志 DF 和 CLD
方向标志位DF决定字符串指令每步是正向走还是反向走。DF = 0表示地址增加,也就是从低地址往高地址比;DF = 1表示地址减少,从高往低比。
在绝大多数字符串比较场景里,我们都希望正向比较,所以进入函数第一件事通常是执行CLD指令,把DF清零。如果不做这一步,万一调用方之前执行过STD设置了DF = 1,你的比较方向就反了,轻则结果错误,重则访问到完全错误的内存区域。
这是个非常隐蔽的坑,因为DF是全局状态,它不归某个函数私有。被调函数可能改了它,你的函数读的时候就是脏的。所以“进函数先CLD”是个好习惯,成本很低,收益很高。
2.4 一个完整的执行追踪示例
假设要比较两个长度为 5 的字符串"hello"和"hello",RSI指向第一个字符串开头,RDI指向第二个字符串开头,RCX = 5,DF = 0。
执行REPE CMPSB的过程如下:
| 迭代次数 | RSI 指向 | RDI 指向 | 比较结果 | ZF | RCX |
|---|---|---|---|---|---|
| 第1次 | 'h' | 'h' | 相等 | 1 | 4 |
| 第2次 | 'e' | 'e' | 相等 | 1 | 3 |
| 第3次 | 'l' | 'l' | 相等 | 1 | 2 |
| 第4次 | 'l' | 'l' | 相等 | 1 | 1 |
| 第5次 | 'o' | 'o' | 相等 | 1 | 0 |
结束时RCX = 0,ZF = 1,说明全部相等,函数应该返回 0。
再看一个不等的例子:比较"abc"和"abd",长度还是 3。前两次比较都相等,第三次比较'c'和'd'时,减法结果是0x63 - 0x64 = -1,ZF被清零,循环立即停止。此时RCX = 0,RSI和RDI分别指向第三次比较的字节之后的位置。
这里有个容易误解的地方:REPE停止后,RCX变成 0 不是因为比较完了,而是因为不相等触发了break。但如果你只看RCX,会误判成“全部比较完了”。正确的判断方式是看ZF:ZF = 1才代表到最后一次比较依然相等。这个细节在写返回逻辑时非常关键。
3. 写一个可复用的 compare 函数
3.1 参数与调用约定
x86_64 System V 调用约定下,函数的前三个参数分别放在RDI、RSI、RDX。我们按这个约定来设计函数接口:
long compare_len(const char *str1, const char *str2, long len);str1放入RDIstr2放入RSIlen放入RDX
返回值放在RAX:0表示相等,1表示str1 > str2,-1表示str1 < str2。
使用 NASM 语法,因为它的可读性比 GAS 的 AT&T 风格高不少,尤其是字节、字、双字、四字的操作符后缀一眼就能看懂。
3.2 定长比较版本:用 REPE CMPSB 实现
; long compare_len(const char *str1, const char *str2, long len) section .text global compare_len compare_len: push rbp mov rbp, rsp cld mov rcx, rdx ; rcx = len,交给重复前缀使用 test rcx, rcx jz .equal ; 长度为 0,直接相等 repe cmpsb ; 逐字节比较,ZF=0 或 rcx=0 时停止 jne .not_equal mov eax, 0 ; 全部相等 pop rbp ret .not_equal: dec rsi dec rdi mov al, [rsi] mov dl, [rdi] ; 修正反向比较:al 是 str2 的字节,dl 是 str1 的字节 ; 实际上我们应当比较 str1 和 str2,所以要用 dl - al cmp dl, al jb .less ; str1 更小 mov eax, 1 pop rbp ret .less: mov eax, -1 pop rbp ret .equal: mov eax, 0 pop rbp ret这段代码有几个点要说清楚。
首先是test rcx, rcx; jz .equal。如果长度参数传了 0,REPE CMPSB根本不会执行任何一次比较,此时ZF保持进入函数前的旧值,完全不可靠,所以必须先这一步做保护。
然后是jne .not_equal。前面说过,REPE停下来有两种情况:RCX耗尽或者ZF = 0。JNE判断的是ZF = 0,此时确确实实是找到了不等的字节。
最后的比较要注意寄存器内容。REPE CMPSB结束后,RSI / RDI指向第一次比较出不等字节的下一个地址,所以要先dec回到真正不等的字节。接着用mov al, [rsi]取到的是str2的字节,mov dl, [rdi]取到的是str1的字节,所以后面用cmp dl, al才能得到正确方向的结果。我第一次写的时候直接cmp al, dl,结果相等的判断没错,大小关系的返回值整个反了。
3.3 处理 C 字符串变长版本
定长版本虽然干净,但现实中更常见的是遇到以\0结尾的 C 字符串。这种情况下你不知道长度,必须自己判断。最简单可靠的思路是逐字节扫描,同时比较,直到某个字符串出现\0。
这个版本不需要REPE CMPSB,但它同样值得掌握,因为很多场景下你要比较的两个字符串长度可能不一样:
; long compare_cstr(const char *str1, const char *str2) section .text global compare_cstr compare_cstr: push rbp mov rbp, rsp .loop: mov al, [rdi] ; str1 当前字节 mov bl, [rsi] ; str2 当前字节 cmp al, bl jne .diff test al, al ; 如果 al 是 0,说明两个字符串同时到结尾 jz .equal inc rdi inc rsi jmp .loop .diff: cmp al, bl jb .less mov eax, 1 pop rbp ret .less: mov eax, -1 pop rbp ret .equal: mov eax, 0 pop rbp ret这个版本的逻辑比较直观:每次都取两个字符串当前字节,不相等就分大小,相等且为 0 就说明到末尾且完全一致。唯一的小技巧是用test al, al判断\0,这比cmp al, 0少一个立即数编码,稍微省一点字节。
3.4 用 C 写测试入口并编译运行
直接用汇编写一个main再调syscall太繁琐,我习惯写一个 C 文件来测试汇编函数,这样打印结果方便得多:
#include <stdio.h> long compare_len(const char *str1, const char *str2, long len); long compare_cstr(const char *str1, const char *str2); int main() { const char *s1 = "hello"; const char *s2 = "hello"; const char *s3 = "help"; printf("compare_len(s1, s2, 5) = %ld\n", compare_len(s1, s2, 5)); printf("compare_len(s1, s3, 5) = %ld\n", compare_len(s1, s3, 5)); printf("compare_cstr(s1, s2) = %ld\n", compare_cstr(s1, s2)); printf("compare_cstr(s1, s3) = %ld\n", compare_cstr(s1, s3)); return 0; }编译命令如下:
nasm -f elf64 compare.asm -o compare.o gcc -no-pie main.c compare.o -o compare_test ./compare_test这里-no-pie很有必要。NASM 生成的 ELF64 目标文件默认不是位置无关代码,而现代 Linux 的 gcc 默认会生成 PIE 可执行文件。链接时如果编译器发现非 PIC 对象和 PIE 不兼容,会报类似relocation R_X86_64_32S against .text的错误。加-no-pie可以绕开这个坑,也是最省事的方式。
实测输出应该是:
compare_len(s1, s2, 5) = 0 compare_len(s1, s3, 5) = -1 compare_cstr(s1, s2) = 0 compare_cstr(s1, s3) = -1其中"hello"和"help"在前三个字符hel相等,第四个字符'l'与'p'不同,'l'小于'p',所以结果是-1,符合字典序判断。
4. 实操中的坑:大小写、空串与边界
4.1 大小写不敏感比较怎么处理
在某些场景里,比如解析命令行选项或者做协议头匹配,你可能希望比较时忽略大小写。REPE CMPSB是对字节直接比较的,它本身不关心字符大小写,所以得靠你自己预处理。
思路是:要么在比较前把两个字符串都统一转成大写或者小写,要么在逐字节循环里把待比较字节先归一化再比较。前者适合每次比较都大量重复使用的场景,后者适合单次比较。我比较推荐在逐字节循环里做,因为省一次额外遍历。
一个简单技巧是:判定一个字节在'A'到'Z'范围内,就给它加上0x20,把它变成对应的小写字母。比如'A'是0x41,'a'是0x61,加0x20正好转换过来。注意一定要先判定字母范围,否则像数字、符号之类的字节也会被错误地改动。
这段逻辑可以插在compare_cstr的mov al, [rdi]和mov bl, [rsi]之后,比较之前。它会牺牲一点性能,但胜在代码简单,不会被边界问题绊倒。
4.2 空字符串和长度 0 的情况
空字符串是最容易被忽略的输入。定长版本里,若len = 0,我已经加了test rcx, rcx; jz .equal的保护。那变长版本呢?假设str1是空串,str2是非空串,compare_cstr会走到mov al, [rdi],得到al = 0,然后取到bl = 非0,直接走jne .diff,cmp al, bl时0小于非零,所以返回-1,完全正确。如果两个都是空串,第一次比较就都是 0,走.equal,也没有问题。
所以变长版本天然处理好了空串。定长版本需要手动保护,原因在于REPE CMPSB执行 0 次时ZF是一个历史值,不能作为判断依据。这一点非常反直觉,容易栽跟头。
4.3 指针越界访问问题
定长比较最大的隐患是:你给的len大于某个字符串实际分配的内存。REPE CMPSB只会傻乎乎地按RCX的次数继续比较,不会帮你检查数组边界,也不会管哪个字节是\0。
比如char s[5] = "abc",你传len = 100进去,它就会去读取s[5]之后的内存。运气好读到你不想读的垃圾值,但结果还可能碰巧相等;运气不好直接SEGFAULT。
解决方式有三个层面。第一,调用方保证len是实际分配的较小值;第二,用compare_cstr这种遇到\0就停的版本;第三,如果非要安全,在函数内部对两个字符串做逐字节访问前,检查地址有效性。最后这种在普通应用环境里不现实,系统级编程反而能接受,所以归根结底还是要靠调用方保证。
这也是为什么我强烈建议:能用compare_cstr的场合别用compare_len。定长版本主要用于比较固定结构的二进制字段,而不是文本字符串。
4.4 UTF-8 字符串比较的警告
如果你处理的是 UTF-8 编码的文本,事情会复杂一个量级。UTF-8 是多字节编码,一个汉字占 3 个字节,直接按字节比较得到的是字节序,不是字典序。
按照 Unicode 的排序规则,很多语言的词典序和字节序并不一致。汇编里的字符串比较指令只能保证“按内存字节值排序”,它不理解字符边界,更不理解 collation order。所以如果你在写一个需要处理多语言文本的软件,稳妥的做法是先把字符串转换成统一的编码形态(比如 UTF-32),再做归一化处理,然后再比较。
想用汇编直接比较 UTF-8 字符串且得到符合人类直觉的顺序,这事最好交给现成的国际化排序库。汇编擅长的是底层和稳定,不是把所有逻辑都扛在自己肩上。
5. 性能优化思路:从 REPE 到 SSE
5.1 REPE CMPSB 的开销在哪里
REPE CMPSB虽然指令很紧凑,但它本质上还是一个字节一个字节地串行执行。每比较一个字节,需要取两个操作数、做一次减法、更新指针、递减计数器、判断两个条件,这是一笔不小的开销。
在字符串很短(比如几十个字节)时,这个开销完全无所谓。但如果你在比较用户上传的大文件指纹、资产列表、巨型配置等动辄几 MB 的字符串,逐字节比较的性能就会成为瓶颈。我曾经在某个配置热加载模块里测过,几 MB 的字符串用REPE CMPSB裸比较,占整个模块 CPU 时间的比例高得吓人。
优化方向很简单:让一次指令比较更多的字节。
5.2 用 SSE 指令做 16 字节并行比较
x86_64 下有一整套 SSE 指令可以用来做批量字节比较。核心思路是:把 16 个字节一次性加载到XMM寄存器里,然后并行地比较这 16 个字节,再生成一个位掩码来表示哪些字节相等。
大概流程是这样:
; rsi = str1, rdi = str2, rcx = 长度(至少 16) .loop_sse: movdqu xmm0, [rsi] movdqu xmm1, [rdi] pcmpeqb xmm0, xmm1 ; 每个字节相等则结果为 0xFF,否则 0x00 pmovmskb eax, xmm0 ; 把 16 个字节的最高位移入 eax 的 16 个位 cmp eax, 0xFFFF ; 全部相等时掩码是 16 个 1 jne .found_diff add rsi, 16 add rdi, 16 sub rcx, 16 cmp rcx, 16 jae .loop_ssePMOVMSKB是这里的关键指令,它把XMM0中每个字节的最高位提取出来,组成一个 16 位的整数。由于PCMEQEB的结果要么是0xFF要么是0x00,最高位恰好能代表“相等”还是“不等”。
这个方案一次处理 16 字节,理论上比逐字节循环快不少。因为每 16 个字节只要一条比较指令加上一条位掩码指令,循环开销被摊薄了。现代 CPU 的向量单元带宽很高,实测长字符串性能往往能有几倍的提升。
5.3 余数处理和是否值得优化
SSE 优化的难点在于余数。如果字符串长度不是 16 的倍数,最后剩下来的部分要么切回逐字节循环,要么用掩码加载的技巧只把多余字节读进低位的部分。简单一点,我会在主体循环结束后,用普通的rep cmpsb处理末尾余数:
test rcx, rcx jz .equal_sse repe cmpsb jne .not_equal_sse这样主体用 SSE、尾部用逐字节,既保证了正确性,又避免了为了尾部字节写太多复杂逻辑。
但要说句实在话:如果你只是比较几个配置项字符串,完全没必要上 SSE。它引入的复杂度和可读性损失远大于收益。只有当你确认字符串平均长度达到 KB 级别,并且比较发生在热路径上,才值得动手。否则,REPE CMPSB的正确性和简洁性能帮你省下大量调试时间。
6. 常见问题与排查技巧实录
6.1 结果异常排查速查表
我根据平时调试经验整理过一张排查表,很多问题都出在这几个地方:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 长度相同但返回 1 / -1 颠倒 | 恢复不等的字节后cmp方向写反 | 检查dec rsi / dec rdi后读取的顺序 |
| 不管输入什么都返回 0 | jne分支没写对,走到了相等分支 | 用 gdb 检查ZF标志位 |
| 比较不到末尾,提前返回 | RCX初始值给错,或DF = 1导致反向跑 | 打印RCX,确认没有忘写CLD |
| 返回很大的整数 | 返回值没清高 32 位,RAX高位置脏 | 统一用mov eax, 0 / 1 / -1 |
| 崩溃在指令处 | 指针越界或者字符串不是\0结尾 | 用 gdb 检查RDI / RSI指向的内存 |
| 汇编链接时 relocation 报错 | NASM 生成非 PIC 对象,但 gcc 默认链接 PIE | 编译命令加-no-pie |
其中高 32 位脏那个问题,新写汇编的人很容易踩。x86_64 有个不成文的规则:写 32 位寄存器(比如eax)时会自动把rax的高 32 位清零,所以统一用mov eax, 1比mov rax, 1更省心。别一会儿用rax一会儿用eax,不然返回值会被残留位污染。
6.2 用 GDB 查看内存和标志位
调试汇编字符串比较,最常用的工具就是gdb。建议在启动时加上-g -f dwarf重新编译汇编文件,这样符号信息更完整:
nasm -f elf64 -g -F dwarf compare.asm -o compare.o然后在比较函数处打断点:
break compare_len run info registers rsi rdi rcx x/16bx $rdi stepiinfo registers rsi rdi rcx能看到当前指针位置和剩余计数。x/16bx $rdi按十六进制打印RDI指向地址开始的 16 个字节,方便你核对数据和预期是否一致。执行stepi一步步过后,再观察info registers eflags里的ZF位,就能确认REPE到底为什么停下。
这个流程熟练以后,字符串比较相关的 bug 基本都能在几分钟内定位,不用靠猜。
6.3 一些独家实操心得
写汇编字符串比较这一年多,我最大的体会是:代码本身并不难,难的是你肯不肯把每条指令的副作用的账算清楚。
比如说CMPSB自动修改RSI和RDI,看起来省事,但你要时刻记得它在REPE结束后停在了哪个位置。恢复不等字节的位置时,是先dec还是后inc,完全取决于你对指令行为的理解。这种细节不看文档是写不对的,调试时也最容易耗时间。
再者,调用约定不只是 C 语言的事。你写的汇编函数一旦要跟保存寄存器的规则打交道,就必须遵守“被调用者保存RBP、返回前恢复RSP”等约定。我见过不少朋友在汇编里写push rbx之后忘了pop rbx,结果返回后主函数寄存器全乱套,程序行为完全不可预测。
最后想说的是,字符串比较虽然是基础操作,但它串联了内存寻址、标志位、循环指令、调用约定和调试方法这几个核心主题。把这篇文章里的代码亲手敲一遍、跑一遍、改几个参数再跑一遍,你对 x86_64 汇编的理解会扎实很多。下次再遇到要写strcmp、strcasecmp或者自定义比较器的时候,你就能直接下手,而不是翻文档翻半天。