☰
x86_64汇编中的字符串比较:指令选择、边界陷阱与性能实测
2026/10/14 1:13:29 网站建设 项目流程

1. 为什么字符串比较会成为x86_64汇编里的一个专门话题

1.1 字符串在汇编层面的真面目

很多人从C、Python转过来学汇编,第一次接触字符串都会懵一会儿。高级语言里字符串是个"类型",有长度、有方法、有边界检查;但在x86_64汇编里,字符串根本不存在——你面对的只是一块连续的内存字节,加上一个约定。最常见的约定有两种:一种是以0x00字节结尾(NUL终止符,C语言风格),另一种是在头部或单独位置存长度(Pascal风格、或者现代语言里的胖指针)。Linux系统调用和标准C库都采用NUL终止风格,所以我们在Linux x86_64下写汇编,几乎永远在和"一串以0结尾的字节"打交道。

字符串比较的本质也由此变得极其朴素:从第一个字节开始,一对一对地取值,比较是否相等,直到发现不同,或者遇到终止符。整个逻辑用高级语言写就是一层for循环,但落到汇编层,你面对的是三个问题:取哪个地址的字节、比完怎么跳转、用什么条件码判断结果。这三个问题看似基础,实际上把寻址方式、状态标志、分支结构、甚至调用约定全都串起来了。我个人认为,字符串比较是整个x86_64汇编从"会写指令"跨到"会写函数"的一道门槛。

1.2 哪些场景真的需要你手写字符串比较

先说个实在话:如果你在正常的Linux用户态程序里写业务逻辑,标准C库的strcmp已经做得又快又稳,你用汇编重写一遍多半是负优化。但至少有三类场景,你必须自己动手:

第一,无libc环境。比如内核模块、引导程序、某些沙箱或瘦身容器,整个环境里没有标准库,你需要自己实现字符串比较,甚至自己实现memcpy、strlen。

第二,学习目的。理解字符串比较的底层过程,能帮你建立起"内存布局决定算法效率"的直觉。以后看高级语言标准库的实现、看反汇编、做性能分析,都会有完全不同的洞察。

第三,定制化需求。比如你不只想要"相等/不相等",还想一次调用同时拿到"是否相等、谁大谁小、第一个不同位置在哪、比较了几个字节"这些信息,标准库的返回协议满足不了,这时候手写一个专用比较函数就是合理选择。

1.3 系列进行到第24部分的知识基线

这个系列写到这一篇,前面已经把寄存器、内存寻址、栈帧、系统调用都铺开了。到这里我默认你已经知道:rdi/rsi/rdx是传参寄存器,rax传返回值,syscall的调用约定,以及NASM的基本语法。这篇的重点不是再重复基础,而是把"字符串比较"这个具体主题拆透——指令怎么选、循环怎么写、边界怎么防、性能怎么取舍。

如果你前面跟得比较紧,看到这篇标题应该立刻想到一个问题:字符串比较不就是一个cmp指令加一个je/jne的事情吗?逐个字节比,遇到NUL结束。听起来简单,但你真去写,马上会碰到方向标志、重复前缀、页边界、大小写折叠之类缠在一起的问题。这篇文章就是把这些东西一个一个理清楚。

2. 比较的三种规模:什么情况下可以一次读8个字节

2.1 字节级、字级、四字级的比较方式

字符串比较最直观的实现是逐字节循环。每次读一个字节,比较,跳转。用NASM写出来就是这样:

; 输入: rdi = s1, rsi = s2 ; 输出: eax = 0 表示相等 strcmp_byte: xor eax, eax .next: movzx eax, byte [rdi] movzx ecx, byte [rsi] cmp al, cl jne .diff test al, al jz .equal inc rdi inc rsi jmp .next .diff: sub eax, ecx ret .equal: xor eax, eax ret

逐字节永远正确,但性能一般。CPU每次只拿到一个字节,循环本身的跳转开销、比较开销、取指开销都要摊在单字节上。于是自然想到:能不能一次比较2字节、4字节、8字节?x86_64上当然可以。x86的全部比较指令都支持操作数宽度为8位、16位、32位、64位,而且内存操作数可以直接作为cmp的源:

mov rax, [rdi] mov rbx, [rsi] cmp rax, rbx

这一下子就能比较8个字节是否完全一致。如果不相等,再通过低位逐字节定位差异位置。看起来很简单,但这里面藏着两个致命前提。

2.2 宽比较的两个隐前提:页边界与NUL填充

第一个前提是"能安全地读8个字节"。x86的内存保护粒度是页,默认4KB。假设一个字符串的最后一个字节恰好落在某个页的末尾,比如地址0x400fff处,你想读它和它后面的7个字节,CPU会尝试访问0x401000到0x401006,这已经属于下一页。如果下一页没有被映射,直接触发段错误。这不是理论推演,是实战中非常容易踩的坑。判断方法也简单:地址的低12位(addr & 0xfff)如果大于0xff8,就说明越过了页边界,不能安全做8字节读取。

第二个前提是"字符串后面跟着的垃圾字节不能影响判断"。如果两个字符串前8字节相同,但一个在第3字节就遇到NUL,另一个是完整8字节内容,宽比较会认为它们相同或乱判——因为你把NUL后面的内容也读进来了。所以用宽比较处理NUL结尾字符串时,常见做法是:把缓冲区预先清零,或者读到NUL后立即停止后续宽读。标准C库的strcmp之所以要处理那么多种头情况,就是因为这两个前提在真实场景里并不总是成立。

2.3 x86小端序给字符串比较的一个隐藏加成

还有个细节值得展开。x86是 little-endian,低地址字节存放在数值的低位。字符串字典序比较时,先比较低地址字节;整数大小比较时,先影响结果的是低位字节。这两种顺序在小端下正好一致。

这意味着什么呢?如果你把两个字符串各自的前8字节作为uint64加载,然后直接比较整数大小,得到的相对大小关系和逐字节的字典序关系是相同的——前提是两边都没有读到NUL之后的垃圾数据、或者垃圾数据被清成0。这是一个不太好直觉化的知识点,很多人会误以为"字节串比较不能当作整数比较,因为顺序反了",其实在x86小端下顺序没有反,反而省事。

但必须强调:宽比较适合"判断两个字符串是否有可能不同"的高性能快速通道。想要得到精确的strcmp语义,还是要在宽比较不相等时回退到逐字节,或者在NUL终止时做一个收尾判断。我在后面第6部分会专门对比这几种写法的取舍。

3. 重复前缀与CMPSB:官方串比较指令的真实用法

3.1 CMPSB到底干了什么

有cmp你为什么还要单独的cmpsb?这是很多人第一次看到串指令时的疑问。cmp只做比较、不进地址,真正的循环还是要你自己写,每一步手动调整指针。而cmpsb是"比较字符串字节"的专用指令:它隐含地比较[rsi]和[rdi]两个内存字节,然后根据方向标志DF的值自动递增或递减rsi和rdi。

用cmpsb写循环,循环体就从"取字节、比较、指针加一"三条指令压缩成一条指令。然后它还有一个更值钱的伙伴——重复前缀。你可以在cmpsb前面加repe或repne:

cld mov rcx, rdx repe cmpsb

repe的意思是:只要zf等于1(上一次比较结果是相等)并且rcx不为0,就继续执行cmpsb。rcx每执行一次自动减一。这段代码做的事情是:从rdi和rsi指向的位置开始,最多比较rdx个字节,一旦发现第一个不同的字节就立刻停止。停止之后,zf标志会告诉你到底是"比较完了,全都相等",还是"中途发现了不同"。

3.2 REPE和REPNE:一对容易弄反的孪兄弟

repe的全称是"repeat while equal",等则继续,不等则停。它对应的助记符还有repz,同一个东西。另一边的repne是"repeat while not equal",不等则继续,等则停。后者通常用于找两个序列的第一个相同点,使用频率低很多,但在串比较这个主题下经常被混淆。

我用一个朴素类比帮助记忆:repe像两个人在同一段路上并排走,步调一致就继续走,一旦发现双方踩的位置不一样就停下来喊"出事了";repne则相反,两个人明明踩的位置不一样却偏要接着走,直到踩到相同的位置才停。你写定长缓冲区比较、二进制数据比对,要的是repe;写扫描器、找相同标记,可能才轮到repne。这个区别,实战中真的有人写反,一跑发现条件完全颠倒。

3.3 DF方向标志:一个被严重低估的"全局状态"

cmpsb自动增减指针,方向由DF标志控制。cld把DF清零,指针递增,这是绝大多数场景要的行为,因为你通常是从低地址往高地址比较。std把DF置1,指针递减,适用于从尾部往前处理的内存操作。

DF标志最坑的地方在于,它是一个全局状态。你的函数可能假设进入时DF一定是0,但如果某个库函数或你自己写的其它模块内部用了std却忘了恢复,等你再调cmpsb时,rsi和rdi会往反方向走。这不是理论风险,是实打实踩过的坑——调试时发现rsi越走越小,字符全部比对失败,查了很久才意识到是DF被改了。

所以我现在写任何用到串指令的函数,第一步固定写cld。这不算性能损耗,顶多几个周期,但能省掉无数排查时间。反过来,如果你在函数里用了std,记得在返回前cld恢复现场,或者按调用约定明确说明DF状态由谁负责。

3.4 REPE CMPSB最适合定长字段比较

有了上面的基础,可以给一个真实应用场景:定长字段比较。比如网络协议头、二进制文件头、数据库里的定长记录,两个字段固定是n个字节,不需要处理NUL终止。此时repe cmpsb是教科书级别的方案:

; 比较两个定长缓冲区 ; rdi = buf1, rsi = buf2, rdx = 长度 ; 返回: eax = 0 相等,非0 不相同 compare_fixed: cld mov rcx, rdx repe cmpsb je .all_equal mov eax, 1 ret .all_equal: xor eax, eax ret

这段代码简洁、可读性强、bug面小。内存里密布的"硬件优化"会让repe/repne在多数情况下表现不错,至少不会比手写循环差到哪儿去。

但要注意,NUL终止的字符串用repe cmpsb并不舒服,因为你不知道长度。当然可以先写一个strlen算出长度再比较,但那等于多扫一遍内存,性能反而不如老老实实逐字节边查NUL边比较。这也是为什么repe cmpsb在教材里很风光,在实际的strcmp实现里却不常作为主角——长度信息是它的命门。

4. 从零写一个可用strcmp子程序:完整NASM代码与逐行拆解

4.1 数据段布局与RIP相对寻址

写一个能跑的程序,先从数据段开始。我们定义两个字符串,再准备两条输出消息,程序比较完字符串后把结果打印到终端。

section .data str1 db "hello asm", 0 str2 db "hello asm", 0 msg_equal db "strings are equal", 10 len_equal equ $ - msg_equal msg_diff db "strings differ", 10 len_diff equ $ - msg_diff section .text global _start

64位模式下访问数据段,最稳的姿势是RIP相对寻址。在NASM里写成lea rdi, [rel str1],意思是把str1相对于当前指令位置的偏移算出来,加载到rdi里。为什么不用mov rdi, str1?因为64位模式下立即数装入64位寄存器,如果是符号地址,汇编器需要生成32位绝对地址的重定位,这在某些目标格式下并不总是可用。lea rel是64位Linux下最通用的做法。

我也顺便强调一下数据段里那些字符串末尾的0。这个0字节是NUL终止符,没有它,你的字符串就不知道在哪里结束。更隐蔽的问题是,如果str1后面紧跟着其它数据,没有终止符时字符串会"串"到下一个标签去,比较结果全乱。所以每定义一个字符串,养成写db "...", 0的习惯。

4.2 子程序与主程序的完整结构

下面这个是完整可编译可运行的版本,我一边写代码一边把关键点揉碎了讲:

section .text global _start _start: lea rdi, [rel str1] lea rsi, [rel str2] call strcmp_custom test rax, rax jz .print_equal mov eax, 1 mov edi, 1 lea rsi, [rel msg_diff] mov edx, len_diff syscall jmp .exit .print_equal: mov eax, 1 mov edi, 1 lea rsi, [rel msg_equal] mov edx, len_equal syscall .exit: mov eax, 60 xor edi, edi syscall ; 函数:strcmp_custom ; 参数:rdi = s1 地址, rsi = s2 地址 ; 返回:rax = 0 表示相等,非0表示第一个不同字节的差值 strcmp_custom: xor eax, eax .next: movzx eax, byte [rdi] movzx ecx, byte [rsi] cmp al, cl jne .diff test al, al jz .equal inc rdi inc rsi jmp .next .diff: sub eax, ecx ret .equal: xor eax, eax ret

主程序用write系统调用打印结果。这里有一个细节:mov edx, len_diff,len是一个编译期常量(equ伪指令),它是常数,所以用立即数mov没问题。eax里放系统调用号1,edi里放文件描述符1表示标准输出,rsi指向缓冲区,edx放长度,然后syscall。

4.3 逐行讲解子程序的核心决策

strcmp_custom这个函数虽然只有十几行,但每一行都是经过考虑的。第一个决策:用movzx而不是mov al。mov al, [rdi]只写入al这8位,eax的高位会残留上一次比较循环里的数据。后果是什么?最后.diff里算sub eax, ecx时,高位垃圾会导致返回一个完全不可预期的巨大数值。movzx eax, byte [rdi]会把高位清零,保证eax的值就是一个字节的0到255。这一步是整个函数正确性的根基。

第二个决策:循环体内先cmp、后test。顺序是:先取s1的字节到eax,再取s2的字节到ecx,比较它们是否相等。如果不等,说明已经找到了分叉点,直接跳.diff算差值。如果相等,这时再检查al是否为0——因为cmp相等,所以如果al是0,cl也一定是0,说明两个串同时走到了末尾,返回相等。这个顺序把"两者都到结尾"和"两者在同一个字符上相等但还没结束"两种情形统一处理了,代码才能这么短。

第三个决策:返回sub eax, ecx而不是返回一个固定的"相等/不相等"标志。这其实是对标标准C库的strcmp语义。返回差值的好处是,调用方不仅知道两个串是否相等,还知道哪一个更大,直接用于排序、二分查找都行。代价是,当比较字符为大写字母和对应小写字母这类差值较小时,返回值并不直观,但反正调用方只看符号。

4.4 编译运行与验证边界情况

使用下面的命令编译这段代码(假设文件名为strcmp_demo.asm):

nasm -f elf64 strcmp_demo.asm -o strcmp_demo.o ld strcmp_demo.o -o strcmp_demo ./strcmp_demo

输出应该是strings are equal。然后你把str2改成hello world再编译运行,输出变成strings differ。

到这里还只是"能跑",距离"能用"差的是边界测试。我最建议你动手做的几组:两个空字符串比较;一个空串和一个任意字符串比较;两个只有最后一个字符不同的长字符串;一个包含NUL嵌入(如果用普通字符串很难,可以在数据段手工定义db "ab", 0, "cd", 0)的比较。每次改完数据段重新编译,用gdb或直接观察返回值,你会发现很多边界逻辑在写的时候想不到,一测就现原形。

5. 五个实战中最容易翻车的细节:越界、大小写、方向、寻址、调试

5.1 越界读取:比你想的更容易触发

字符串比较函数最经典的bug就是越界读。逐字节循环版本如果遇到一个没有正确NUL终止的缓冲区,它会把后续内存一直读下去,可能读到不可映射页,程序直接段错误。更阴险的是它不一定立刻崩,而是读到一大段垃圾之后才崩溃,让人误以为是其它问题。

我记得自己写过一个类似的函数,拿它去比较网络包里的字段,包结构里有一个字段没有NUL,结果比较函数在缓冲区尾部之外继续扫了十几个字节,返回了一个随机"不相等",整整调试了一个晚上。从那之后我的习惯是:凡是函数接受外部传入的字符串,一定要明确它是不是NUL终止的;如果是定长字段,就必须显式传长度,不能赌后面的内存一定是0。

5.2 方向标志被污染的完整排查链路

有段时间我写了一个用到cmpsb的模块,单独测结果总是对的,但接入整个程序后就出现诡异现象:比较结果偶尔正确、偶尔完全颠倒。我一开始怀疑寄存器冲突,把cmp周围所有用到的寄存器都在入口保存、出口恢复了,问题依旧。

后来我打开gdb,单步进cmpsb,观察rsi和rdi的变化,才发现地址竟然在递减。那一刻我意识到是DF标志的问题——程序其它地方有代码执行过std,之后没有清回0。更糟的是,那条路径是个冷门错误处理分支,平时根本走不到,只有特定输入才会触发,所以我前面怎么复现都是时好时坏。

排查过程是这样的:先在可疑函数入口打印eflags的DF位,用gdb的p $eflags观察位10(DF是bit10,值为0x400)。如果发现DF是1,用cld清掉再跑一次,问题消失,就基本锁定了。修复只要在函数开头强制cld,同时找到那个写std的分支,在它返回前cld。

这件事给我的教训是:数据段、寄存器、内存要操心,CPU内部那些看不见的状态标志更要操心。尤其是DF,它不属于常规的"被调用者保存寄存器"范畴,很多约定文档根本不提它,但它在串指令里就是方向盘的转向轴。

5.3 大小写敏感性与字符折叠策略

如果你的字符串是比较命令行参数、配置文件关键字,用户输入--Verbose还是--verbose,你要不要区分?这没有绝对答案,但一旦决定不区分大小写,你就得小心处理。

最朴素的大写转小写操作是利用ASCII的排列规律:大写字母A到Z对应0x41到0x5A,对应小写字母是0x61到0x7A,正好差0x20。所以只针对英文字母,判断落在al >= 'A'且al <= 'Z'之间就加0x20。

; 入: al = 字符 ; 出: al = 小写形式(仅处理英文字母) to_lower: cmp al, 'A' jb .done cmp al, 'Z' ja .done add al, 0x20 .done: ret

这个实现的局限是只处理ASCII英文。如果你要比较的是带重音的字符,比如é、ü,或者非拉丁文,那就不是简单加0x20能解决的了。真正的通用做法是查表或者做Unicode规范化,这远超"字符串比较"本身的话题。但实战中,命令行参数和配置项通常都是ASCII,这个小函数够用。

5.4 立即数、寄存器、内存操作数的混用陷阱

字符串比较的代码里经常出现cmp al, cl这种寄存器比较,也经常出现cmp byte [rdi], cl这种内存和寄存器比较。新手容易写错的是把一个立即数当字节用:cmp al, 0没问题,但cmp dword [rdi], 'A'就值得警惕,这里'A'是一个32位立即数0x00000041,会被当作4字节来比较。如果你想比较单个字节,应该写cmp byte [rdi], 'A'。

还有一个和NASM相关的细节:movzx eax, byte [rdi]里那个byte修饰符不能省,否则汇编器不知道操作数宽度。反过来,如果你写mov al, [rdi],NASM能通过al推断是1字节。这类宽度问题看起来小,但编译报错时信息往往不够直观,我建议写代码时把操作数宽度修饰符都显式写出来,能省很多编译期烦恼。

5.5 gdb调试串比较的实用姿势

调试字符串比较,我常用的gdb命令组合就几条。在_start处打断点,运行后用info registers rdi rsi看地址;用x/s $rdi和x/s $rsi直接按字符串格式打印内存内容;想逐条指令看循环走向,用stepi单步;想观察DF标志变化,p $eflags配合十六进制输出判断bit10。

(gdb) break strcmp_custom (gdb) run (gdb) info registers rdi rsi (gdb) x/s $rdi (gdb) x/s $rsi (gdb) stepi

有一次我发现函数返回值和手动目测不一致,就是用x/8bx $rdi查看前8个原始字节,才发现数据段里字符串后面跟了一个看不见的0d 0a(回车换行),导致看起来相等的两个串实际并不相等。这种只有靠原始内存视图才能发现的脏数据问题,高级语言里几乎不会碰到,汇编里天天见。

6. 性能横向对比:手写循环、REPE CMPSB、C库strcmp、宽加载与SIMD

6.1 先别急着选型,你的场景是什么

讨论性能前要先把应用场景分出来。字符串比较的性能瓶颈高度依赖两个因素:字符串长度分布、以及比较通常在"相等"路径上多走几步还是"不等"路径上很快返回。比如解析命令行参数,大多数比较在第二个字符就不等,函数应该尽快返回负值;而校验高一致性数据,比如判断两个文件内容是否相同,多数情况下要么很快不等,要么一路比到底才相等。

另一个因素是调用环境中是否有标准库。无libc环境根本没得选,你只能在手写循环、重复前缀、宽加载和SIMD之间选。有标准库时,C库strcmp往往是首选,因为它针对真实硬件做了大量优化。我们这里把它作为性能标尺,而不是直接劝你复用。

6.2 各方案的优劣分析

逐字节手写循环的优点是简单、正确性容易保证、对任意字节数据都安全,缺点是每字节都要经过一次分支预测和指针调整。现代分支预测器面对"长串相等"模式时表现尚可,但面对"随机位置出现不等"的长字符串,预测失败惩罚很可观。

REPE CMPSB的优点是代码量少,CPU内部微码可以把它优化成一个专用的比较小循环,在某些处理器上表现比跳转循环好。但它前面说的"必须有长度"这个限制,决定了它更适合定长二进制数据。另外,RCX递减和ZF检查是微码内部完成的,实际上仍然存在循环开销,不要以为它一条指令就魔法般地零成本了。

宽加载(一次读8字节)是很多手写优化函数的招数。它把循环次数除以8,瓶颈从"比较+跳转"变成"加载到寄存器",只要构造得当,吞吐很高。但前面第2部分讲了,它必须处理页边界和NUL填充,代码复杂度直线上升。

SIMD方向,SSE2的pcmpeqb加pmovmskb可以一次性比较16字节,比宽加载更进一步。SSE4.2加入了专门的字符串指令,比如pcmpistri这种,一条指令内部帮你处理NUL终止和长度限制。听起来很美,但真实世界里的表现很微妙:某些CPU架构上这些指令有较高的延迟,短字符串场景反而比朴素宽加载更慢;虚拟机和部分低功耗环境里更是重灾区。我见过一个项目,把字符串比较从手写循环换成SSE4.2指令后,在短字符串热点路径上反而慢了百分之二三十。

方案适用场景典型优点典型缺点代码复杂度
逐字节循环超短串、边界逻辑复杂简单可靠、任意字节安全吞吐低、分支多低
REPE CMPSB定长二进制字段、协议头代码少、语义清晰需要长度、循环开销仍在低
宽加载8/16字节长字符串、无libc环境吞吐高、控制灵活要处理页边界和NUL填充中高
SIMD字符串指令批量扫描、特殊场景单指令处理16字节延迟高、平台差异大高
C库strcmp常规用户态业务优化成熟、开箱即用有调用约定开销、不可定制无

6.3 没有万能答案,但有一个合理默认路径

我的默认策略是这样的:用户态普通程序,直接用C库strcmp,先跑起来再说。如果profiler告诉我字符串比较确实是热点,再动手优化。此时先看数据特征:如果字符串长度普遍在16字节以内,我会试宽加载方案,一次8字节,读之前检查页边界;如果普遍很长,我会试SIMD的比较思路,但只在真实目标硬件上验证;如果是定长二进制字段,直接repe cmpsb。优化完务必用真实数据做A/B对比,不要只看循环次数,要看端到端耗时。

这里我特别想提醒一点:优化字符串比较时,分支预测结果的影响往往大于指令吞吐。两个字符串在前几个字节就出现差异,你的代码是不是能快速跳出?如果每个循环都在做无谓的跳转,任何聪明指令都救不回来。宽加载方案之所以常常赢,很大程度上是因为它把"快速发现不匹配"变成了两个8字节整数比较,一次判断覆盖8个字符,分支次数降低了8倍。

6.4 性能测试的地基:先测了再迷信

要验证对比结论,自己写一个计时工具并不复杂。Linux下可以用clock_gettime搭配CLOCK_MONOTONIC,或者干脆用rdtsc读时间戳计数器。注意三件事:第一,先预热,让分支预测器进入稳定状态,否则第一个循环的数据是失真的;第二,构造多组长度分布的数据,不能只测单一长度;第三,避免编译器优化掉比较结果,通常是把比较结果累加到一个全局变量里,最后打印。这些方法虽然简单,但能避免很多"优化了个寂寞"的尴尬。

7. 把字符串比较模块化:前缀匹配与大小写折叠两个实用扩展

7.1 大小写不敏感比较:在线折叠还是先折叠后比

有时候你需要对两个字符串做不区分大小写的比较,但我建议不要把"不区分大小写"直接写进strcmp_custom里。保持基础比较函数纯粹,在此基础上包一层转换逻辑,是更清晰的工程方案。

在线折叠的意思是在比较循环里,每取出一个字符就先fold成小写,再比较。改动很小,只要在movzx之后调用前面写的to_lower子程序。但注意,函数调用本身有开销,每字节都call一次to_lower,性能损耗不小。解法是内联,把折叠逻辑直接展开在循环体里,或者用宏。另一个方案是预先把两个字符串各自拷贝一份并转成小写,再调用普通的strcmp_custom。这个方案需要额外内存和两次遍历,适合字符串被反复比较的场景,比如哈希表的键值统一小写化。

实战中我更倾向在线折叠加内联,因为短字符串比较本身就快,再加上几条比较指令无伤大雅,却能省去拷贝管理。代码风格上,把它做成独立的strcmp_case_insensitive函数,内部循环里嵌几行转换逻辑,避免污染主函数。

7.2 前缀匹配:比"完全相等"更常用的判断

配置文件、命令行、路由匹配里,前缀匹配比完全相等出现得更多。比如判断一个字符串是不是以http://开头,你只需要比较前7个字符就够了。把它实现成strncmp_custom,参数里多一个最大比较长度rdx,循环里加一个长度递减判断:

; 比较两个字符串的前 n 个字符,忽略 NUL 之前的部分 ; 参数: rdi = s1, rsi = s2, rdx = 最大比较长度 ; 返回: eax = 0 表示前缀相同 strncmp_custom: xor eax, eax test rdx, rdx jz .equal .next: movzx eax, byte [rdi] movzx ecx, byte [rsi] cmp al, cl jne .diff test al, al jz .equal ; 一方先到NUL,前缀到此为止,视为相同 inc rdi inc rsi dec rdx jnz .next jmp .equal .diff: sub eax, ecx ret .equal: xor eax, eax ret

这个函数处理了一个关键语义:两种串都可能在n字节之内遇到NUL,此时比较应该终止并视为相同——因为用户要的是"前缀匹配",而不是"前n字节严格相等"。很多人写strncmp时漏了这个分支,结果比较http://a和http://b时,因为后者在第7个字符是b,应该不同;但如果比较abc和abcde且n=5,标准strncmp的语义是相等,因为前者在第三个字符就结束了。前缀匹配要的就是这个效果。这个边界逻辑建议你单独写测试用例验证,它很容易错。

7.3 一个能同时返回多种信息的内聚函数

如果你经常需要"比较+定位",可以设计一个更内聚的函数,一次调用同时返回是否相等、差值符号、第一个不同位置等信息。思路是保留一个r8寄存器做位置计数器,每次循环递增,遇到差异时把当前计数值写入调用者传过来的缓冲区地址。代价是参数和返回结构复杂,但在无libc的内核模块里,这种自包含工具函数能省掉很多胶水代码。

我的建议是基础版本保持标准strcmp的简单接口,需要额外信息再单独写专用函数,不要为了功能丰富而牺牲可读性。汇编代码的可维护性本身就差,函数越专一,边界越清晰,越不容易出bug。

7.4 把这些扩展放进你的个人工具库里

如果你在学习汇编的过程中已经积累了类似strlen、memcpy之类的函数,建议把字符串比较相关的几个函数统一放在一个文件里,每个函数头部写清楚参数、返回值、哪些寄存器会被修改、CF/DF等标志位状态,以及调用适用的场景。这个习惯最初看起来繁琐,但当你第三周回来看自己写的汇编时,它就是救命注释。

拿我自己的体会来说,每次写这类工具函数,我都会规定一个简单的原则:入口没有意外,出口没有惊喜。入口没有意外是进函数第一件事就cld;出口没有惊喜是保证返回前DF为0、通用寄存器按调用约定该恢复的恢复。只要这个原则坚持住,字符串比较这种小函数就能成为后续所有汇编模块的稳定地基。

真正想把字符串比较玩转,建议你把本章的strcmp_custom、7.1的case_insensitive、7.2的strncmp_custom都亲手敲一遍,再分别用"相等、不等、一方是空串、一方是另一方的子串、超过页边界"这几组数据去测。等你把这几条边界跑明白,x86_64汇编里跟字符串打交道最核心的状态控制、内存读取和分支逻辑就都过关了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询