字符串扫描是汇编里一个特别容易被忽略、但实际写起来特别有味道的话题。前二十四部分我们都在跟寄存器、寻址方式、算术逻辑指令打交道,到了字符串处理这里,很多人会突然觉得“前面学的那些东西怎么不太够用了”。尤其是写解析器、写网络协议、做文本处理工具的时候,最绕不开的动作就是从头到尾过一遍连续字节——也就是字符串扫描。这次我用 Linux x86_64 汇编把这块讲透,从最基础的手写遍历,到 REPE/REPNE 前缀配合 SCAS 系列指令,再延伸到 QWORD 对齐扫描和 SIMD 优化思路,顺带把我实际调试中踩过的几个坑也一并交代清楚。
先说清楚一个前提:在 x86-64 的视角里,“字符串”并不神秘,就是内存里一段连续的字节,通常末尾用 0(也就是\0)标记结束。所谓扫描,核心就三件事:知道起始地址、知道往哪个方向走、知道什么时候该停下来。这三件事分别由RDI、方向标志DF、以及RCX配合标志寄存器完成。真正干活的是SCASB/SCASW/SCASD/SCASQ这一族指令,它们负责“比较一下当前字节、然后自动把指针往前推”,配合REP/REPE/REPNE前缀,一行指令就能完成“边比较边走”的整套循环。这篇文章适合已经熟悉 mov、add、cmp、jmp 这些基础指令,准备深入字符串处理的读者,也适合那些想搞明白编译器为什么能这么高效处理字符串的人。
1. 为什么字符串扫描要单开一篇:三个隐含要素与SCAS指令
1.1 扫描的本质:从RDI出发,沿DF方向,依RCX收口
理解扫描之前,先想一个问题:strlen("hello")在 C 语言里就一行,但底层到底发生了什么?它要把'h'、'e'、'l'、'l'、'o'逐个读一遍,然后遇见末尾的0才停下。换成汇编,这就是一个典型的扫描循环。
这个循环有三个变量参与:
- 起始地址:在 x86-64 的串操作指令里,这个地址约定放在
RDI。64 位模式下 C 语言函数调用也遵循同一个约定,函数第一个指针参数就传RDI,这不是巧合,System V ABI 特意保留了这一点。 - 方向:CPU 会根据
RFLAGS里的DF标志决定RDI是递增还是递减。DF=0时从低地址往高地址扫,这是标准情况,几乎所有高级语言字符串函数都按这个方向。DF=1时从高地址往低地址扫,日常很少用,但确实存在。 - 停止条件:要么扫够指定次数,也就是
RCX寄存器减到 0;要么扫描过程中发现了某个条件成立,由标志寄存器ZF来汇报。
这三个变量,加上一个目标值,就构成了扫描的全部。我经常跟人打比方:这就像在书架上翻一本特定封面的书,你站在起点(RDI),规定只能从左往右翻(DF),记着自己最多翻多少个格子(RCX),目标封面长什么样(AL/AX/EAX/RAX)是事先定好的。每翻一格就看一眼,找到就停,翻完规定数量也停。
1.2 SCASB到底做了什么:比较、改RDI、留标志
SCAS是 "Scan String" 的缩写,它的行为只有一个核心动作:拿累加器的低端值与RDI指向的内存字节做比较,然后自动修改RDI。不同宽度对应不同版本:
| 指令 | 比较对象 | RDI 修改量 |
|---|---|---|
SCASB | AL与[RDI]对比 1 字节 | RDI ± 1 |
SCASW | AX与[RDI]对比 2 字节 | RDI ± 2 |
SCASD | EAX与[RDI]对比 4 字节 | RDI ± 4 |
SCASQ | RAX与[RDI]对比 8 字节 | RDI ± 8 |
注意,SCAS本身不会保存一份 RDI 的原始值。也就是说,比较结束后,你如果想知道“刚才比较的到底是哪个地址”,得自己提前把RDI存一份。这是一个新手极其容易忽略的细节。
比较结果跟CMP指令一样,会写入RFLAGS:相等时ZF=1,不等时ZF=0。至于RDI是加几还是减几,完全看DF。如果在DF=1的情况下执行,RDI会往低地址方向移动。后面我会专门说到这个标志被改掉之后的惨状。
1.3 四个宽度变体怎么选
初学阶段最常见的是SCASB,因为普通 ASCII 字符串就是一个字节一个字符。但你在面试题或真实代码里也会遇到SCASW、SCASD、SCASQ,它们通常用来扫描定长元素,比如:
SCASW:扫描 UTF-16 字符串里的某个宽字符,或者扫描两字节整数数组。SCASD:找 4 字节对齐的魔数。SCASQ:在 8 字节指针数组里找某个指针,或者在哈希数组里找某个 8 字节 key。
一句话:先想清楚你要找的元素宽度,再决定用哪个变体。扫描 ASCII 字符串,开篇只要把SCASB练熟,后面宽字符场景自然能顺延过去。
2. 从零写strlen:手写循环与REPNE SCASB全对比
2.1 手写版本:把每一步都写在明面上
很多人一上来就直接上REP前缀,其实不好。建议先老老实实写一个手写循环,把“读字节、判断是否为 0、指针前进、重复执行”这四步拆开,这样后面看REP版本时才有对照物。
下面这段用 NASM 语法写一个最简单的strlen:
; size_t loop_strlen(const char *s) ; rdi = s global loop_strlen section .text loop_strlen: xor ecx, ecx ; 计数清零 .loop: mov al, [rdi + rcx] ; 读当前字节 test al, al ; 判断是否为 0 jz .done inc rcx jmp .loop .done: mov rax, rcx ret注意这里我用了[rdi + rcx]而不是直接inc rdi,目的是让RDI始终保持起始地址,最后用RCX当长度返回,逻辑更清爽。如果你想把RDI本身当作游标,也能写,只是最后要用sub rax, rdi之类的指令把差值算回来。
这段代码的问题非常直观:每个字节都要执行“读一次、比较一次、跳转一次”,循环展开优化不好的话,分支预测压力也大。但它胜在逻辑简单,是理解一切字符串扫描的锚点。如果你在手写循环上还觉得容易绕,建议先在调试器里单步走三遍,把每一步寄存器变化看清楚,再看下面的REP版本。
2.2 换成REPNE SCASB:代码一下瘦到四行
REP前缀的核心思想是:把“判断计数是否结束 + 是否需要继续循环”这两件事交给 CPU 硬件去处理。REPNE SCASB的完整语义是:只要RCX != 0且ZF=0(上一次比较结果不相等),就继续执行SCASB;一旦RCX=0或者ZF=1(找到了目标字节),就停下来。
用这个指令实现strlen非常经典:
; size_t my_strlen(const char *s) global my_strlen section .text my_strlen: push rdi ; 保存字符串起始地址 mov rcx, -1 ; RCX 初始化为无符号最大值 xor eax, eax ; AL = 0,要找 NUL cld ; 保证 DF=0,让 RDI 递增 repne scasb ; 一直扫描,直到遇到 AL 或 RCX 归零 ; 到这里 RDI 指向 NUL 之后的下一个字节 pop rdi ; 取回起始地址 sub rax, rdi ; 这里不对!见下方修正 ret等等,上面这段是错的,我故意把错误写出来说明一个常见失误:pop rdi之后RDI已经变回起始地址,此时再用RAX减RDI算的是“结束地址减起始地址”吗?不对,因为结束地址在pop前已经被弄丢了。正确写法是先把结束地址保存到别的寄存器,或者干脆用RCX的剩余值反推。
正确版本可以这样写:
; size_t my_strlen(const char *s) global my_strlen section .text my_strlen: mov r11, rdi ; 用 r11 保存起始地址 mov rcx, -1 xor eax, eax cld repne scasb mov rax, rdi sub rax, r11 ; 结束地址 - 起始地址,得到包含 NUL 的总步数 dec rax ; 去掉 NUL 本身,就是字符串长度 retr11是 caller-saved 临时寄存器,在这里随便用,不需要恢复。如果你不想引入r11,还有一种更巧妙的算法,直接利用RCX的剩余值,二补数计算后一步到位:
my_strlen: mov rcx, -1 xor eax, eax cld repne scasb not rcx ; 此时 rcx = 0 - 剩余值 = 已比较的字节数 dec rcx ; 减去 NUL mov rax, rcx ret为什么能这么做?因为RCX初始值是0xFFFFFFFFFFFFFFFF,也就是 -1。每次SCASB执行后RCX减 1。假设一共比较了n个字节,其中最后一个字节是 NUL,那么循环结束后RCX = -1 - n。取反~(-1 - n)在二进制层面等于n,再减 1 得到n - 1,正好是字符串长度。这个技巧不用保存RDI,代码更短,很多官方库的实现都长这样。
2.3 用RCX剩余值算长度:两种经典写法
我把两种写法放到一起对比,方便你以后在项目里直接抄:
| 写法 | 思路 | 代码量 |
|---|---|---|
| 地址差法 | 保存起始 RDI,结束后与结束地址做差 | 直观,需要多用一个寄存器 |
| RCX 反推法 | not rcx; dec rcx | 极简,符合 REP 指令本身的计数逻辑 |
地址差法的好处是容易读懂,出问题好排查。RCX 反推法好处是没有额外保存操作,执行路径更短。我个人在第 25 部分这个阶段更建议你把地址差法先吃透,因为它的每一步都能用 GDB 看到:起始 RDI、结束 RDI、差值一算就明白。等你熟练了再换 RCX 反推法。
还有一点,如果repne scasb是因为RCX减到 0 而停下来的,说明从头到尾都没找到 NUL。此时字符串很可能不是以 NUL 结尾,这种输入本身就不符合strlen的前提。不过RCX初始是 -1,意味着最多能扫 2^64-1 个字节,实际上遇到不可读内存会先崩溃,所以通常不必担心这个边界。后面我会专门讲这个“没扫到会怎样”的问题。
2.4 跑一个完整样例:汇编函数加C调用
只看代码容易飘,建议直接跑起来。把上面的my_strlen保存在strlen.asm:
global my_strlen section .text ; size_t my_strlen(const char *s) my_strlen: mov r11, rdi mov rcx, -1 xor eax, eax cld repne scasb mov rax, rdi sub rax, r11 dec rax ret然后写个 C 文件验证:
#include <stdio.h> #include <string.h> extern size_t my_strlen(const char *s); int main(void) { const char *test = "hello, world of asm"; size_t len = my_strlen(test); size_t expected = strlen(test); printf("asm strlen = %zu, c strlen = %zu\n", len, expected); return 0; }编译命令:
nasm -f elf64 strlen.asm -o strlen.o gcc -no-pie main.c strlen.o -o demo ./demo如果你在链接阶段遇到什么奇怪的 PIC 重定位错误,加上-no-pie一般能解决,这是 64 位 Linux 上 NASM 和 GCC 联编最常见的兼容问题。输出应该是两个长度一致。第一次跑通这个例子,你对SCASB的所有疑惑都会落地。
3. 字符查找:REPNE SCASB定位分隔符的实战路径
3.1 一个需求:在"key=value"里找到'='
只看strlen还不够,字符串扫描更常见的场景是“定位某个字符”。比如你要解析一行配置key=value;other=1,第一步就是找到那个=号的位置,然后左边是键名,右边是值。用 C 语言你会写strchr(s, '='),在汇编里对应的就是REPNE SCASB。
为什么这里也是REPNE而不是REPE?注意区分:
REPE/REPZ:相等(ZF=1)时继续。适合“连续找相同字节”的场景,比如跳过一串连续的同一个字符。REPNE/REPNZ:不等(ZF=0)时继续。只要发现相等就停。适合“找第一个匹配字节”。
我们的目标是找到第一个等于=的字节,所以用REPNE SCASB。
3.2 strchr的完整实现与未命中判断
实现strchr最关键的一点:REPNE SCASB结束后,RDI指向的是匹配字节的下一个位置,不是匹配字节本身。因为SCASB的固定动作是“比较完就改 RDI”,它不会因为命中而停下来不去移动指针。你要拿返回指针的话,必须自己减回去。
请看标准实现:
; char *my_strchr(const char *s, int c) ; rdi = s, esi = c global my_strchr section .text my_strchr: mov rcx, -1 ; 无限扫描直到命中或崩溃(正常字符串以 NUL 结尾) movzx eax, sil ; AL = c,把 int 低 8 位放进去 cld repne scasb test rcx, rcx jz .not_found ; RCX 变 0,说明扫描完 2^64-1 个字节也没命中,不可能是因为命中停止 dec rdi ; 指回命中字节 mov rax, rdi ret .not_found: xor eax, eax ; 返回 NULL ret这里test rcx, rcx; jz .not_found就是判断停止原因的。因为RCX初始化成 -1,一个普通字符串最多几十上百个字节,理论上不可能把RCX减到 0,所以只要停下来了,几乎一定是“命中了”。但如果RCX确实减到 0,说明没找到。对于正常 NUL 结尾字符串,少写这个判断也不会出大事,但严谨起见建议保留。
有人可能会问:我要找的字符就是\0怎么办?按这个实现,AL=0,repne scasb会一路扫到字符串末尾的 NUL,然后停在 NUL 的下一个地址,RDI-1就指向 NUL 本身。这个行为跟标准 C 库的strchr(s, '\0')完全一致,会返回指向终止符的指针,不需要特判。
3.3 扫描结果和后续解析怎么衔接
定位到=之后,接下来的操作通常是:键名是[起始地址, 分隔符地址)这个区间,值从分隔符地址 + 1开始。在实际代码里,我会把“找分隔符”和“算长度”结合起来:
- 先调
my_strchr(s, '=')拿到p。 - 如果
p == NULL,说明这行配置格式不对。 - 键名长度是
p - s,可以用一个memcpy或直接按需读取。 - 值部分以
p + 1作为起始地址,再去扫描是否还有;。
这里用到的思路上一章已经给你打底了:RDI会停在匹配字节的下一位,这一点既是坑也是特性。如果你愿意,拿到p之后,甚至可以把p + 1再塞回RDI,继续用REPNE SCASB找下一个分隔符。串行处理一整行文本就是这样一个循环。
我自己的一个小习惯是:扫描到分隔符以后,先在调试器里确认一遍RDI到底指向哪,特别是第一次从 C 语言世界进入汇编世界时。百分之九十的“偏移一位”bug 都出在这。
3.4 宽字符与非ASCII场景下的SCASW
如果字符串是 UTF-16 编码,一个字符占两个字节,结尾标记是双字节的 0。这时候你应该用SCASW,比较对象是AX。做法跟SCASB完全对称:
; 假设要查找宽字符 0x003D,也就是 '=' mov ax, 0x003D mov rcx, -1 cld repne scasw ; 找到后 rdI -= 2 指回命中位置同理,如果是在 4 字节整数数组里找某个固定值,用SCASD;在 8 字节指针数组里找指针,用SCASQ。变体一变,RDI 每次移动的步长也跟着变,返回指针时减回的长度也要对应变化。这是很多人从SCASB切到SCASQ时最容易算错的地方。
4. 扫描性能进阶:QWORD对齐、SSE2与REP的取舍
4.1 SCASQ:只适合等宽扫描,不适合NUL字符串
到了第 25 部分,该聊点性能了。初学者最容易产生的联想是:“既然SCASQ一次比较 8 字节,那我用它来扫字符串找 NUL,岂不是比SCASB快 8 倍?”
结论是:不行,至少在“直接比较全零 QWORD”这种朴素思路上行不通。原因很简单:RAX=0与某个 8 字节块比较,只有块内 8 个字节全部为 0 时,ZF才会置 1。如果一个 8 字节块中间某个位置已经出现了 NUL,但后面还有非零字节,这整个块不等于 0,repne scasq会无视它继续往下扫,直接错过真正的 NUL。
那 QWORD 级扫描在字符串场景里就没用了吗?有,但需要用“判断一个 8 字节块内是否存在某个字节为 0”的位运算技巧,而非直接块比较。这是 glibc 等库里常用的一招,核心公式是利用“每个字节借位”的规律:
; 假设 [rdi] 里的 8 字节已经加载到 r10 mov rax, r10 mov r11, 0x0101010101010101 sub rax, r11 ; 每个字节减 0x01 not r10 ; 原值按位取反 and rax, r10 mov r11, 0x8080808080808080 test rax, r11 ; 检查高位是否有借位残留 jnz .has_zero ; 非零说明 8 字节里至少有一个字节是 0这个公式看起来神秘,原理是:当一个字节从0x00减0x01时会产生借位,减完之后高位与原来的取反值做AND,就会暴露出0x80这个标记。只要检测到任何字节的高位变成 1,就能推断那里原来是 0。你可以先用字节循环扫到 8 字节对齐,然后用这个公式一蹦 8 个字节快速跳过无零区间,遇到含零区间再回头用字节精确定位。这套组合是传统高性能strlen的核心套路。
至于SCASQ本身,更适合的场景是“在定长元素数组里找一个固定值”。比如在网络程序里,你有一个 8 字节的会话 ID 数组,想查找某个 ID 是否出现,这时候repne scasq就非常自然,一次比较一个完整元素,不会出现中途错过的问题。
4.2 SIMD时代:一次比较16字节的做法
位运算公式证明了人类可以靠技巧把字符串扫描做到每周期 8 字节,但现代 CPU 上更香的做法是 SIMD。不用学很深,掌握基本思路就可以:用一条指令把 16 字节读进来,再用一条指令把这 16 字节和 0 比较,最后用一个 mask 位运算快速定位到具体位置。
SSE2 思路的骨架是这样:
; rdi 指向待扫描区域 movdqu xmm0, [rdi] ; 一次载入 16 字节,不做对齐要求 pxor xmm1, xmm1 ; xmm1 = 0 pcmpeqb xmm1, xmm0 ; 每个字节与 0 比较,相等位置得到 0xFF pmovmskb eax, xmm1 ; 取 16 个字节的最高位,压缩成 16 位 mask test eax, eax jnz .hit_zeropmovmskb是理解 SIMD 字符串扫描的关键指令:它把向量寄存器里每个字节的最高位移出来,组成一个整数 mask。如果eax非零,说明某个字节是 0。要定位第一个 0 的位置,用bsf eax, eax(也可以用tzcnt)数一下从低位开始的零位数量,就能知道 NUL 在第几个字节,配合RDI算出精确地址。
这套做法在长字符串上的优势非常大,一次处理 16 字节,而且没有分支跳转,CPU 流水线压力小。如果要继续优化到 AVX2,把movdqu xmm0, [rdi]换成vmovdqu ymm0, [rdi],一次 32 字节,比较指令用vpcmpeqb,mask 提取用vpmovmskb。原理完全一致。
4.3 实际性能体验与选择建议
我给一个非常主观但实用的选择顺序:
- 字符串很短(几十字节以内):用
repne scasb完全够,代码短、易读、不易出错,性能差距根本体现不出来。 - 字符串中等(几百到几千字节):可以先用“字节对齐 + QWORD 位运算跳跃”的方式,这是纯整数指令,不涉及 SIMD 状态,跨平台省心。
- 超长字符串(几 KB 以上):上 SSE2/AVX2 才是正确姿势,一次 16/32 字节,吞吐量惊人。
盯着性能数字之前先问一句:你的程序真的把时间花在字符串扫描上了吗?我见过不少为了省几个周期去写复杂 SIMD,结果数据量只有 20 字节的场景,收益为负,调试成本翻倍。性能优化要放在正确的数据规模上。第 25 部分这个阶段,能把repne scasb写对、能理解 SIMD 思路,已经是非常扎实的功底了。
5. 踩坑实录:RDI归位、DF方向与RCX截断
5.1 坑一:RDI停在匹配字节的下一位
这是我见过最多、也是自己第一次写strchr时踩过的坑。因为SCAS的语义是“比较完就修改 RDI”,所以当repne scasb停下时,RDI指向的是匹配字节的下一个字节,而不是匹配字节本身。
看一个具体例子。字符串是"cat\0",从RDI = 'c'开始执行repne scasb,依次会扫过'c'、'a'、't'、'\0'。当AL=0时,停在'\0'之后,RDI已经越过了字符串末尾。如果你直接拿RDI当结果返回,要么多出一个字节,要么在解析时索引偏一位。
解决方案很固定:找到字符,就dec rdi/sub rdi, 步长;如果用的是SCASQ,就sub rdi, 8。以后写任何基于SCAS的查找函数,第一件事就是把“指针归位”写在注释里,防止自己半路忘了。
5.2 坑二:外部代码改掉方向标志,扫描倒着走
DF标志平时很容易被忽略,因为绝大部分时间它都是 0。但只要你链接的库、或者自己写的某段内联汇编里执行过std(set direction flag, DF=1),那么之后所有SCASB都会从高地址往低地址扫。字符串扫描会瞬间变成“从末尾倒着走”,找=可能找到一个跟预期完全无关的位置,或者永远找不到,甚至出现“明明字符串很短,却像死循环一样在乱走”的诡异现象。
这种坑最难受的地方是:代码本身完全不变,换个环境或者换一组调用顺序就出问题。我的对策有两个:
- 在自己写的字符串扫描函数入口处显式执行
cld,把 DF 强行置 0,确保函数不依赖外部状态。 - 如果实在担心影响调用者,可以用
pushfq/popfq保存并恢复整个RFLAGS,但这种做法开销略大,一般函数不需要。
网上有人觉得每次cld是多余开销,但在绝大多数代码里,多执行一条cld远比你排查一个方向错误便宜得多。
5.3 坑三:RCX写死成缓冲区长度却期望能完整扫描
REP前缀的循环停止条件之一是RCX == 0。很多人写完repne scasb,把RCX设置成缓冲区大小,比如mov rcx, 64,然后觉得“字符串肯定在 64 字节内”。如果字符串真的在 64 字节内,那没问题;但如果字符串长度刚好超过 64,或者缓冲区里没有 NUL,repne scasb会扫完 64 字节后因为RCX=0停止,此时ZF=0,返回的结果是一个“没找到”的茫然状态。
如果你的语义是“只扫描缓冲区的前 N 字节”,那RCX=N是对的;如果你的语义是“扫描到 NUL 为止,缓冲区保证有 NUL”,那RCX应该给一个足够大的值,比如0x7FFFFFFF甚至 -1。一定要想清楚你是按长度扫描还是按内容扫描。我见过一个解析二进制的同事把RCX设成包长,结果包中间出现0时表现正常,包尾出现0时却报告找不到,就是因为计数先归零了。
5.4 坑四:没有NUL结尾的字符串会扫到哪去
这个问题虽然前面提过,但值得单独再强调一次。repne scasb并不知道内存边界在哪里,它只会傻乎乎地一个字节一个字节往下读,直到RCX耗尽。如果你把RCX初始化为 -1,而字符串又恰好没有 NUL,那么程序会一直读下去,直到翻过当前内存页,踩到不可读的下一页,触发段错误。在调试器里看到的现象就是“从字符串地址附近一路走到不知道什么地方,然后SIGSEGV”。
所以,凡是反复强调“调用者必须保证字符串以 NUL 结尾”的函数,不是矫情,是真的会崩。如果你在写一个解析函数,输入来自不可信的缓冲区,一定要先明确缓冲区长度,用RCX = 长度上限的方式约束扫描范围,而不是无条件信任\0存在。等RCX耗尽时,再用ZF判断“到底找到了没”。
5.5 配合调试器观察寄存器的方法
掌握了寄存器状态,字符串扫描问题就解决了一大半。我用 GDB 时常用这几条命令:
break my_strchr:在函数入口断点,看参数是否传对,rdi应该是字符串起始地址,esi应该是待查找字符。info registers rdi rcx rflags:执行repne scasb前后各看一次,对比RDI变化量,确认方向对不对。p $eflags & (1 << 10):打印 DF 位,是 0 还是 1,一目了然。x/8bx $rdi-1:找到后看RDI-1这个地址附近的内容,确认是不是你要找的字符。
我曾经靠这套命令五分钟定位了一个“偏移一位”的 bug:第一次执行repne scasb后,RDI指向=的下一位,当时info registers一看就知道问题出在忘记dec rdi。如果没有调试器,这种肉眼很难从代码里扫出来。
6. 这些指令在现代工具链里去哪了
6.1 编译器与C库还在用它们吗
聊完底层,你可能会问:现在的编译器还会生成repne scasb吗?答案是会。GCC 在优化字符串相关代码时,对于简单的strlen调用,经常直接生成“mov rcx, -1; repne scasb; not rcx; dec rcx”这种序列。你反汇编一个实际程序,会看到跟我们在第 2 章写的那段非常像。
但也要知道,现代glibc的strlen不是这么简单的,它通常走汇编优化版本,结合 SSE2/AVX2 一次处理 16/32 字节,还会做页边界判断。这些优化版本的核心思想,正是我们第 4 章讲的“先跳过无零大块,再精确到字节”。所以你在学完REPNE SCASB之后再回头看那些库源码,不会觉得是天书,只是多了很多边界处理。
这说明什么?说明SCAS系列指令并没有被时代淘汰,它们仍然是最底层的字符串扫描原语。只是“上层的具体套路”在进化,从逐字节慢慢进化到按块跳跃,再进化到 SIMD 并行。底层这些寄存器、标志、方向概念,无论怎么进化都绕不开。
6.2 用REP指令做内存区域扫描的拓展
SCAS能扫描,CMPS能比较两块内存,LODS能加载,STOS能写入,MOVS能移动。这四个串操作指令加上REP前缀,实际上能组合出非常多的查表、搬运、填充逻辑。比如:
- 用
repe cmpsb比较两个字符串是否相等,直到出现差异; - 用
rep stosb快速填充一大块内存为某个值; - 用
rep movsb做内存拷贝。
这些操作和字符串扫描共用了同一套“RDI/RSI/RCX/DF”体系。因此,一旦你把第 25 部分的SCASB吃透,后面学CMPS和STOS会非常顺,几乎是同一套肌肉记忆。
6.3 收个尾:我的几个固定习惯
最后分享几个我写 Linux x86_64 汇编字符串代码时固定保留的习惯,也算给这个主题做个实际收尾。
第一,扫描函数入口加cld。不管环境看起来多么干净,一条指令的成本远低于排查方向问题的成本。
第二,保存起始RDI或起始RSI时,优先选 caller-saved 临时寄存器,不要在函数里压栈push rdi又忘记配对pop。函数返回时只保证RAX是结果,其他寄存器状态尽量跟调用前一致即可。
第三,把汇编函数用 C 语言包一层再测试。直接写main去调汇编,一旦出段错误,你都不知道是被调用方破坏栈还是扫描逻辑出错。我会先写一个纯 C 的main,把my_strlen/my_strchr的返回值跟标准库的strlen/strchr逐一对比,全部一致之后再上汇编层面的优化。
第四,写注释时标明“RDI 此时指向哪”。字符串扫描函数里的每个关键步骤都伴随RDI移动,注释里写清楚“此刻 RDI 指向 NUL 之后的地址”“此刻 RDI 指向命中字符本身”,能省下大量反复查看调试器的时间。这个习惯从第 25 部分开始养成,后面做复杂协议解析时你会感谢自己。