写汇编的人都知道字符串处理是这门语言最带劲的领域之一。而SCASB/SCASW这两条指令配合REPNE/REPE重复前缀能让你只用一条指令就把“在内存里找个字符 / 找个值 / 找到某个不匹配的位置”这种循环给干完。说实话当年我第一次在调试器里看到repne scasb一步到位时确实愣了一下——原来底层搜索循环可以这么写不用手写cmp/inc/jne那一串。这篇文章我想把SCAS指令家族和REPNE/REPE前缀彻底讲透包括它们到底在干什么、终止条件怎么判断、方向标志DF是怎么坑人的、以及几个可以直接抄的实战例程。适合正在学汇编的中级读者写引导代码、内核模块、嵌入式程序的开发者还有那些天天跟二进制打交道、需要在反汇编里快速看懂字符串扫描逻辑的人。1. 指令全景SCAS到底在做什么1.1 隐式操作数模型AL/AX/EAX与ES:DISCAS的全称是 Scan String直译就是“扫描字符串”。它的核心动作非常朴素拿一个寄存器里的值和内存里的一个单元做比较比较完以后自动把指针往前或往后挪一下。这个“寄存器里的值”不是随便哪个寄存器都行而是被指令隐式锁定的——SCASB用ALSCASW用AXSCASD用EAX64位模式下SCASQ用RAX。也就是说你想在内存里搜一个字节先把要搜的那个字节放进AL然后执行SCASBCPU 就拿AL去和内存单元玩一次“虚拟比较”。内存地址从哪来从ES:DI来。这是 x86 字符串指令家族的一个历史遗留MOVS、CMPS、SCAS这些指令的默认段是ES而不是更常见的DS。16位实模式下这是个知名大坑很多人写引导程序时忘了给ES赋值结果SCAS扫描的地址和想的不一样数据乱七八糟。在32位平坦模型下ES通常和DS指向同一个基址侥幸没事但如果你写的是实模式代码或者涉及段切换的代码这个细节能救命。提示SCAS的内存地址是隐式的不存在“把内存地址写在操作数里”这种用法。你看到的scasb后面没有任何操作数因为AL、ES:DI、CX全是隐含的。不了解这一点第一次看反汇编会觉得很困惑。每次SCAS执行完DI或RDI会自动递增或递减方向和方向标志DF有关DF0时递增DF1时递减。这点后面单独讲它是最容易翻车的地方之一。1.2 REP前缀硬件级的循环控制一个人干活的SCASB只能比较一个单元谁会只比一次就完事所以SCAS指令几乎总是配着重复前缀一起出现。REPNE和REPE就是两个硬件循环前缀它们修饰在SCAS前面让 CPU 自动把“比较 指针移动 计数器递减 判断是否继续”这一整套流程循环执行直到满足终止条件。用 C 语言伪代码看REPNE SCASB相当于do { cx--; compare AL with byte at [rdi]; update flags; rdi; } while (cx ! 0 ZF 0);REPE SCASB则是do { cx--; compare AL with byte at [rdi]; update flags; rdi; } while (cx ! 0 ZF 1);注意这个do...while的结构它意味着每轮迭代至少执行一次比较前提是初始CX ! 0然后才看条件。前缀是硬件级别的循环不是软件模拟的所以没有分支预测失败的问题代码也极其紧凑一条指令解决整个扫描循环。这在追求极致体积的引导扇区代码里特别有吸引力。顺带一提REPNE和REPNZ是同一个前缀的不同写法REPE和REPZ也是。反汇编器里看到repnz scasb别觉得陌生它和repne scasb完全等价。机器码层面更好记F2是REPNEF3是REPE同时也是普通REP的机器码。所以如果有人在代码里写REP SCASBCPU 实际会按REPE SCASB来解释。2. 指令细节拆解2.1 SCASB/SCASW/SCASD操作码与标志位SCASB的机器码是AESCASW和SCASD共用机器码AF靠操作数宽度前缀来区分。在16位模式下AF就是SCASW需要SCASD得加66H前缀在32位模式下AF默认是SCASD需要SCASW得加66H前缀。64位模式下加REX.W前缀就是SCASQ用RAX去比较。SCAS更新哪些标志位它本质上是CMP AL/AX/EAX, [ES:DI]所以CMP能更新的它都能更新AF、CF、OF、PF、SF、ZF。大多数场景下你只会关心ZF这是SCAS指令最核心的输出如果搜索值等于内存单元的值ZF1否则ZF0。严格来说CF也可以用来判断大小关系比如SCASW比较后可以用JC/JNC判断内存单元是否小于AX但实际工程里这么用的人很少。因为字符串扫描场景里找相等/找不等是绝对主流大小关系一般不在这里做。记住这一点就行不要被标志位表吓到。还有一点很多人忽略SCAS只读内存、不写内存。它不像STOS那样会把寄存器的值写进[RDI]。这对“只扫描、不改原始数据”的需求来说是个优点你在缓冲区里找分隔符时不用担心数据被破坏。2.2 REPE与REPNE两个容易搞反的孪生前缀这是整个主题里最容易栽跟头的地方。REPNE的全称是 Repeat While Not Equal意思是“只要不相等就继续重复”。所以它什么时候停当发现相等的时候停也就是ZF1时停。REPE的全称是 Repeat While Equal意思是“只要相等就继续重复”。所以它什么时候停当发现不相等的时候停也就是ZF0时停。听起来很简单但英文缩写极其迷惑。很多人第一次看到REPNE会直觉地理解成“重复直到不相等为止”——这个理解正好反了。REPNE是要在不相等时继续重复相等反而是它的终点。前缀等价别名语义通俗解释典型用途REPNE SCASBREPNZ SCASBZF0时继续ZF1时停止不相等就继续跑找到相等就停找第一个匹配字节REPE SCASBREPZ SCASBZF1时继续ZF0时停止相等就继续跑找到不等就停找第一个不匹配字节所以记忆口诀应该是看到NE就在心里默念“not equal 时 continue”什么时候 equal 了什么时候 stop看到E就是“equal 时 continue”什么时候 not equal 了什么时候 stop。还有一个几乎所有人都会踩的坑REPNE SCASB停止后RDI指向的是匹配位置的下一个单元而不是匹配位置本身。因为SCAS每执行一次都会先移动指针CPU 再检查ZF决定是否停止。如果你想拿匹配位置的地址或偏移需要根据方向做dec rdiDF0时或inc rdiDF1时。这个细节在写实际代码时特别关键差了 1 个字节就是完全不同的结果。2.3 方向标志DFRDI往哪走由它说了算DF方向标志平时不怎么被人注意但只要涉及字符串指令它就变成决定性因素。CLD清DF0RDI每次递增STD置DF1RDI每次递减。实际开发里 95% 的字符串扫描都是从低地址往高地址扫的所以几乎所有代码你都会看到开头先写一句cld。这不是强迫症是真的必要——DF是全局状态不归SCAS管也不归函数局部作用域管。万一之前某个模块或者某个库函数执行过STD忘了恢复你后面用CLD开始的字符串指令全部反向扫描轻则结果错误重则直接访问非预期区域。我见过一个真实的排查经历某个驱动加载后后续所有REPNE SCASB都扫不到目标字符在调试器里单步看执行轨迹才发现是前面某个服务例程里STD之后没有CLD把方向标志留在了 1。后来大家约定凡是使用SCAS/STOS/MOVS/CMPS/LODS的函数入口第一件事就是把方向清成自己需要的样子哪怕你怀疑当前可能是 0也要显式设置。这个习惯能省掉一整类玄学Bug。另外方向标志还影响你计算位置的方式。DF0扫描停止后RDI指向“匹配位置 1”你算位置要decDF1扫描停止后方向反过来RDI指向“匹配位置 - 1”。如果你用DF1从高地址往低地址找某个哨兵值别忘了你的“下一个单元”是更低的地址。3. 实操案例字符串处理实战3.1 查找第一个特定字符这是最经典的场景在缓冲区里找换行符\n。64位 NASM 写法; 输入: RDI 缓冲区起始地址 ; RCX 缓冲区长度 ; 输出: ZF1 表示找到RDI-1 指向换行符 ; ZF0 表示没找到 cld mov al, 0x0A ; 搜换行符 repne scasb jne .not_found ; ZF0 没找到 dec rdi ; RDI 回退一格指向真正的换行符 sub rdi, buf ; 计算相对偏移 .not_found: ...这里有个非常实用的细节repne scasb找到时ZF1没找到时由于CX耗尽ZF0。所以判断成败直接用jne/je就行不需要额外检查RCX。这一点其实很关键很多人一开始会把RCX 0当判断条件反而出错下面第 4 部分会细讲。sub rdi, buf是在dec rdi之后做的因为RDI当前指向匹配字节的后一格先回退再减起始地址得到的偏移才准确。3.2 跳过连续空格定位第一个非空白现在换个思路用REPE SCASB。需求是字符串开头有一串连续空格我想跳过它们找到第一个非空格字符的地址。; 输入: RDI 待解析字符串 ; RCX 最大扫描长度 ; 输出: RDI-1 第一个非空格字符的地址假设确实存在非空格 cld mov al, repe scasb ; 只要还是空格就继续遇到非空格就停 dec rdi ; 停在非空格后一格回退得到非空格地址逻辑推理是这样的REPE SCASB每轮比较AL和当前RDI指向的字节相等则继续不相等则停。所以停止时刚刚比较的那个字节一定是第一个非空格RDI此时指向它的后一格dec rdi正好回到它身上。要注意边界情况如果整个字符串全是空格RCX会一路减到 0循环因RCX0结束此时ZF1RDI指向缓冲区末尾的后一格dec rdi会得到最后一个空格的位置而不是非空格。所以在真实代码里跳完空格后通常还要再判断一下是哪种结束方式——是“遇到非空格”还是“扫描完所有长度”repe scasb je .all_spaces ; ZF1 全是空格 dec rdi ; 非空格字符地址 RDI - 1这个je判断的是ZF因为REPE在“比较相等”的迭代结束后ZF1在“比较不等”的迭代结束后ZF0。如果是因为不等停下ZF0如果是RCX耗尽但最后一次比较仍然相等ZF1。两句话的事写起来却很容易漏。3.3 手写strlen哨兵思路与边界控制很多人以为求字符串长度就是repne scasb找AL0这个想法对但细节值得展开。一个工程上很稳的写法是配合RCX -1技巧; 输入: RDI 以0结尾的字符串 ; 输出: RAX 字符串长度不含结尾0 strlen_rep: mov rbx, rdi ; 保存起始地址 xor eax, eax mov al, 0 ; 搜索值 0 mov rcx, -1 ; RCX 0xFFFFFFFFFFFFFFFF cld repne scasb ; 扫描到0为止 sub rdi, rbx ; RDI - 起始地址 包括0在内的字节数 lea rax, [rdi - 1] ; 减掉0本身得到长度 ret这里把RCX设成最大值-1即无符号数 18446744073709551615是因为我们不知道字符串有多长但确信它不可能超过这个天文数字。REPNE会一路扫下去直到某个字节等于 0触发ZF1停止此时RCX绝不可能是 0除非字符串长度恰好超过 2^64-1这在物理上不现实。有一点必须注明这个实现假设字符串必定以 0 结尾而且你确信不会越过任何不可读内存。SCAS没有边界检查能力完全靠RCX兜底。如果你处理的是可能没有终止符的缓冲区千万别直接复用这个写法否则REPNE会一路扫到RCX0才停中途如果访问了没有映射的页面就是访问违规。3.4 指令家族图谱SCAS与其他字符串指令的关系SCAS不是孤立的它属于 x86 字符串指令五兄弟MOVS、CMPS、SCAS、LODS、STOS。把它们放一起对比才能看清SCAS的定位指令数据流向典型用途MOVS内存 - 内存块拷贝CMPS内存 vs 内存两块内存比较SCAS寄存器 vs 内存在内存中搜索某个值LODS内存 - 寄存器把内存读进累加器STOS寄存器 - 内存把累加器写入内存它们有个共同点都隐式使用RDI或ESI/EDI组合作为内存指针都受DF方向标志控制都能配合重复前缀自动循环。区别只在数据流方向。SCAS常常和STOS配合使用实现“查找-替换”模式用REPNE SCASB找到目标字符后RDI指向匹配位置 1此时想写回新值得先dec rdi回退到匹配位置再用STOSB写入并让指针继续前进。这个组合在文本过滤、协议解析、简单字符串替换里非常常见。4. 常见问题与排查技巧4.1 五个经典翻车现场第一个翻车现场忘了CLD。DF1时SCAS反向扫描你从低地址往高地址找人结果它反着跑怎么都找不到程序行为像抽风一样。排查方法是在调试器里看DF标志位一眼就能确认。第二个翻车现场REPNE和REPE用反。想找匹配字符却写成REPE SCASB结果是遇到第一个不匹配就停——如果缓冲区首字节恰好不是目标值第一轮就停下来看起来像“目标就在开头”的错觉实际上完全错了。写代码前先默念一遍那两个do...while比背缩写靠谱得多。第三个翻车现场用CX判断成败。记住了判断成败看ZF不是看RCX。有个极其隐蔽的情况当目标字符恰好出现在可扫描范围的最后一个位置时REPNE会在最后一次比较后把RCX减成 0此时能观察到RCX0且ZF1。如果你用RCX0当作“没找到”就会把这种“踩线命中”误判成失败。所以正确姿势永远是先看ZF再用RCX辅助分析。第四个翻车现场实模式忘设ES。16位代码里SCAS用的是ES:DI不是DS:DI。如果你的ES和DS不一致扫描的就是天知道哪里的内存。平坦模型下ES和DS通常重合这个问题就不那么明显但一旦你切换到分段模式或者用FS/GS做特殊寻址就要重新警惕。第五个翻车现场操作数宽度与搜索值不匹配。SCASW搜索值是整个AX不是ALSCASD搜索值是整个EAX。有人想把AL和某个 16 位内存单元的第一个字节比较却用了SCASW结果比较的是AX对内存字逻辑直接错。确定搜索宽度时记得同步确认AL/AX/EAX哪个寄存器被填了正确的值。4.2 判定结果速查表我整理了一张速查表平时写代码拿不准就对着看场景正确写法正常停止原因结束标志指针含义找第一个等于 XREPNE SCASBZF1找到相等找到时ZF1没找到时ZF0RDI指向匹配单元后一格找第一个不等于 XREPE SCASBZF0找到不等找到时ZF0全等时ZF1RDI指向不匹配单元后一格确认整串是否全等于 XREPE SCASBRCX0或ZF0全等结束时ZF1RDI指向末尾后一格配一张REPNE SCASB状态流转扫描未完 值相等ZF1→ 停止结果找到。扫描未完 值不相等ZF0→ 继续。计数器耗尽RCX0 最后值不相等ZF0→ 停止结果没找到。计数器耗尽RCX0 最后值相等ZF1→ 停止结果找是找到了但位置在扫描终点也就是“踩线命中”。这张表被我在调试器边上贴了很久。写代码一时爽排查两行泪不如动笔前把状态推演一遍。4.3 几句调试建议用调试器单步的时候建议直接在RDI、RCX、ZF三列上做观察。不同调试器对REP前缀单步的处理不一样有的会把整个循环当成一条指令直接跑完有的支持“单步进循环内部”。如果真想看内部迭代过程可以先把RCX设成一个很小的值比如 3再重新执行缓冲区里的代码这样几步之内就能看到完整的状态变化。另一个常用技巧在SCAS前后各记录一次RDI相减等于扫描过的字节数。这个值配合ZF就能反推扫描结果。尤其是在确认“为什么找不到”时先检查扫描方向和长度是否合理再检查AL里的搜索值是不是被后来的代码覆盖了——搜索值藏在隐式寄存器里不像显式操作数那样一眼可见很容易被忽略。我在实际使用中的一点体会这种东西光看手册很容易云里雾里真正上手跑两遍才记得牢。我的个人习惯是不管写哪段字符串处理代码入口第一行永远是CLD哪怕我接下来根本用不到方向位凡是用重复前缀一定会先在心里把“结束时的ZF和RDI位置”写清楚甚至画一张三行的小表格。很多人觉得这是多余的仪式感但被那个“最后一个匹配位置恰好是终点”的边界问题坑过一次之后你就会明白为什么老手总爱强调状态推演。另外一点经验是REPNE SCASB并不是任何场景下的性能最优解。极端短字符串比如十几个字节以内一个普通的手写循环或者查表法往往更快因为重复前缀内部有额外的状态检查开销但长字符串扫描时它的紧凑指令流和硬件循环优势就体现出来了。做底层优化的时候最好拿着真实数据量测一下别凭感觉选指令。SCAS系列和REP前缀组合起来写出的代码短小精悍、含义清晰调试时只要记住它“只移动指针、不修改数据”这个底层行为很多疑惑会自然解开。