ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SCAS与REPNE/REPE前缀全解:汇编字符串扫描的核心机制

SCAS与REPNE/REPE前缀全解:汇编字符串扫描的核心机制

写汇编的人都知道,字符串处理是这门语言最带劲的领域之一。而SCASB/SCASW这两条指令,配合REPNE/REPE重复前缀,能让你只用一条指令就把“在内存里找个字符 / 找个值 / 找到某个不匹配的位置”这种循环给干完。说实话,当年我第一次在调试器里看到repne scasb一步到位时,确实愣了一下——原来底层搜索循环可以这么写,不用手写cmp/inc/jne那一串。

这篇文章我想把SCAS指令家族和REPNE/REPE前缀彻底讲透,包括它们到底在干什么、终止条件怎么判断、方向标志DF是怎么坑人的、以及几个可以直接抄的实战例程。适合正在学汇编的中级读者,写引导代码、内核模块、嵌入式程序的开发者,还有那些天天跟二进制打交道、需要在反汇编里快速看懂字符串扫描逻辑的人。

1. 指令全景:SCAS到底在做什么

1.1 隐式操作数模型:AL/AX/EAX与ES:DI

SCAS的全称是 Scan String,直译就是“扫描字符串”。它的核心动作非常朴素:拿一个寄存器里的值,和内存里的一个单元做比较,比较完以后自动把指针往前或往后挪一下。这个“寄存器里的值”不是随便哪个寄存器都行,而是被指令隐式锁定的——SCASB用AL,SCASW用AX,SCASD用EAX,64位模式下SCASQ用RAX。也就是说,你想在内存里搜一个字节,先把要搜的那个字节放进AL,然后执行SCASB,CPU 就拿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有关:DF=0时递增,DF=1时递减。这点后面单独讲,它是最容易翻车的地方之一。

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是REPNE,F3是REPE,同时也是普通REP的机器码。所以如果有人在代码里写REP SCASB,CPU 实际会按REPE SCASB来解释。

2. 指令细节拆解

2.1 SCASB/SCASW/SCASD操作码与标志位

SCASB的机器码是AE,SCASW和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指令最核心的输出:如果搜索值等于内存单元的值,ZF=1;否则ZF=0。

严格来说CF也可以用来判断大小关系,比如SCASW比较后可以用JC/JNC判断内存单元是否小于AX,但实际工程里这么用的人很少。因为字符串扫描场景里,找相等/找不等是绝对主流,大小关系一般不在这里做。记住这一点就行,不要被标志位表吓到。

还有一点很多人忽略:SCAS只读内存、不写内存。它不像STOS那样会把寄存器的值写进[RDI]。这对“只扫描、不改原始数据”的需求来说是个优点,你在缓冲区里找分隔符时,不用担心数据被破坏。

2.2 REPE与REPNE:两个容易搞反的孪生前缀

这是整个主题里最容易栽跟头的地方。

  • REPNE的全称是 Repeat While Not Equal,意思是“只要不相等,就继续重复”。所以它什么时候停?当发现相等的时候停,也就是ZF=1时停。
  • REPE的全称是 Repeat While Equal,意思是“只要相等,就继续重复”。所以它什么时候停?当发现不相等的时候停,也就是ZF=0时停。

听起来很简单,但英文缩写极其迷惑。很多人第一次看到REPNE会直觉地理解成“重复,直到不相等为止”——这个理解正好反了。REPNE是要在不相等时继续重复,相等反而是它的终点。

前缀等价别名语义通俗解释典型用途
REPNE SCASBREPNZ SCASBZF=0时继续,ZF=1时停止不相等就继续跑,找到相等就停找第一个匹配字节
REPE SCASBREPZ SCASBZF=1时继续,ZF=0时停止相等就继续跑,找到不等就停找第一个不匹配字节

所以记忆口诀应该是:看到NE,就在心里默念“not equal 时 continue”,什么时候 equal 了什么时候 stop;看到E,就是“equal 时 continue”,什么时候 not equal 了什么时候 stop。

还有一个几乎所有人都会踩的坑:REPNE SCASB停止后,RDI指向的是匹配位置的下一个单元,而不是匹配位置本身。因为SCAS每执行一次都会先移动指针,CPU 再检查ZF决定是否停止。如果你想拿匹配位置的地址或偏移,需要根据方向做dec rdi(DF=0时)或inc rdi(DF=1时)。这个细节在写实际代码时特别关键,差了 1 个字节就是完全不同的结果。

2.3 方向标志DF:RDI往哪走由它说了算

DF方向标志平时不怎么被人注意,但只要涉及字符串指令它就变成决定性因素。CLD清DF=0,RDI每次递增;STD置DF=1,RDI每次递减。

实际开发里 95% 的字符串扫描都是从低地址往高地址扫的,所以几乎所有代码你都会看到开头先写一句cld。这不是强迫症,是真的必要——DF是全局状态,不归SCAS管,也不归函数局部作用域管。万一之前某个模块或者某个库函数执行过STD忘了恢复,你后面用CLD开始的字符串指令全部反向扫描,轻则结果错误,重则直接访问非预期区域。

我见过一个真实的排查经历:某个驱动加载后,后续所有REPNE SCASB都扫不到目标字符,在调试器里单步看执行轨迹,才发现是前面某个服务例程里STD之后没有CLD,把方向标志留在了 1。后来大家约定:凡是使用SCAS/STOS/MOVS/CMPS/LODS的函数,入口第一件事就是把方向清成自己需要的样子,哪怕你怀疑当前可能是 0,也要显式设置。这个习惯能省掉一整类玄学Bug。

另外,方向标志还影响你计算位置的方式。DF=0扫描停止后,RDI指向“匹配位置 + 1”,你算位置要dec;DF=1扫描停止后,方向反过来,RDI指向“匹配位置 - 1”。如果你用DF=1从高地址往低地址找某个哨兵值,别忘了你的“下一个单元”是更低的地址。

3. 实操案例:字符串处理实战

3.1 查找第一个特定字符

这是最经典的场景:在缓冲区里找换行符\n。64位 NASM 写法:

; 输入: RDI = 缓冲区起始地址 ; RCX = 缓冲区长度 ; 输出: ZF=1 表示找到,RDI-1 指向换行符 ; ZF=0 表示没找到 cld mov al, 0x0A ; 搜换行符 repne scasb jne .not_found ; ZF=0 => 没找到 dec rdi ; RDI 回退一格,指向真正的换行符 sub rdi, buf ; 计算相对偏移 .not_found: ...

这里有个非常实用的细节:repne scasb找到时ZF=1,没找到时由于CX耗尽,ZF=0。所以判断成败直接用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,循环因RCX=0结束,此时ZF=1,RDI指向缓冲区末尾的后一格,dec rdi会得到最后一个空格的位置而不是非空格。所以在真实代码里,跳完空格后通常还要再判断一下是哪种结束方式——是“遇到非空格”还是“扫描完所有长度”:

repe scasb je .all_spaces ; ZF=1 => 全是空格 dec rdi ; 非空格字符地址 = RDI - 1

这个je判断的是ZF,因为REPE在“比较相等”的迭代结束后ZF=1,在“比较不等”的迭代结束后ZF=0。如果是因为不等停下,ZF=0;如果是RCX耗尽但最后一次比较仍然相等,ZF=1。两句话的事,写起来却很容易漏。

3.3 手写strlen:哨兵思路与边界控制

很多人以为求字符串长度就是repne scasb找AL=0,这个想法对,但细节值得展开。一个工程上很稳的写法是配合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,触发ZF=1停止,此时RCX绝不可能是 0(除非字符串长度恰好超过 2^64-1,这在物理上不现实)。

有一点必须注明:这个实现假设字符串必定以 0 结尾,而且你确信不会越过任何不可读内存。SCAS没有边界检查能力,完全靠RCX兜底。如果你处理的是可能没有终止符的缓冲区,千万别直接复用这个写法,否则REPNE会一路扫到RCX=0才停,中途如果访问了没有映射的页面,就是访问违规。

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。DF=1时SCAS反向扫描,你从低地址往高地址找人,结果它反着跑,怎么都找不到,程序行为像抽风一样。排查方法是在调试器里看DF标志位,一眼就能确认。

第二个翻车现场:REPNE和REPE用反。想找匹配字符,却写成REPE SCASB,结果是遇到第一个不匹配就停——如果缓冲区首字节恰好不是目标值,第一轮就停下来,看起来像“目标就在开头”的错觉,实际上完全错了。写代码前先默念一遍那两个do...while,比背缩写靠谱得多。

第三个翻车现场:用CX判断成败。记住了,判断成败看ZF,不是看RCX。有个极其隐蔽的情况:当目标字符恰好出现在可扫描范围的最后一个位置时,REPNE会在最后一次比较后把RCX减成 0,此时能观察到RCX=0且ZF=1。如果你用RCX==0当作“没找到”,就会把这种“踩线命中”误判成失败。所以正确姿势永远是:先看ZF,再用RCX辅助分析。

第四个翻车现场:实模式忘设ES。16位代码里SCAS用的是ES:DI,不是DS:DI。如果你的ES和DS不一致,扫描的就是天知道哪里的内存。平坦模型下ES和DS通常重合,这个问题就不那么明显,但一旦你切换到分段模式或者用FS/GS做特殊寻址,就要重新警惕。

第五个翻车现场:操作数宽度与搜索值不匹配。SCASW搜索值是整个AX,不是AL;SCASD搜索值是整个EAX。有人想把AL和某个 16 位内存单元的第一个字节比较,却用了SCASW,结果比较的是AX对内存字,逻辑直接错。确定搜索宽度时,记得同步确认AL/AX/EAX哪个寄存器被填了正确的值。

4.2 判定结果速查表

我整理了一张速查表,平时写代码拿不准就对着看:

场景正确写法正常停止原因结束标志指针含义
找第一个等于 XREPNE SCASBZF=1(找到相等)找到时ZF=1;没找到时ZF=0RDI指向匹配单元后一格
找第一个不等于 XREPE SCASBZF=0(找到不等)找到时ZF=0;全等时ZF=1RDI指向不匹配单元后一格
确认整串是否全等于 XREPE SCASBRCX=0或ZF=0全等结束时ZF=1RDI指向末尾后一格

配一张REPNE SCASB状态流转:

  • 扫描未完 + 值相等(ZF=1)→ 停止,结果:找到。
  • 扫描未完 + 值不相等(ZF=0)→ 继续。
  • 计数器耗尽(RCX=0)+ 最后值不相等(ZF=0)→ 停止,结果:没找到。
  • 计数器耗尽(RCX=0)+ 最后值相等(ZF=1)→ 停止,结果:找是找到了,但位置在扫描终点,也就是“踩线命中”。

这张表被我在调试器边上贴了很久。写代码一时爽,排查两行泪,不如动笔前把状态推演一遍。

4.3 几句调试建议

用调试器单步的时候,建议直接在RDI、RCX、ZF三列上做观察。不同调试器对REP前缀单步的处理不一样,有的会把整个循环当成一条指令直接跑完,有的支持“单步进循环内部”。如果真想看内部迭代过程,可以先把RCX设成一个很小的值,比如 3,再重新执行缓冲区里的代码,这样几步之内就能看到完整的状态变化。

另一个常用技巧:在SCAS前后各记录一次RDI,相减等于扫描过的字节数。这个值配合ZF就能反推扫描结果。尤其是在确认“为什么找不到”时,先检查扫描方向和长度是否合理,再检查AL里的搜索值是不是被后来的代码覆盖了——搜索值藏在隐式寄存器里,不像显式操作数那样一眼可见,很容易被忽略。

我在实际使用中的一点体会

这种东西,光看手册很容易云里雾里,真正上手跑两遍才记得牢。我的个人习惯是:不管写哪段字符串处理代码,入口第一行永远是CLD,哪怕我接下来根本用不到方向位;凡是用重复前缀,一定会先在心里把“结束时的ZF和RDI位置”写清楚,甚至画一张三行的小表格。很多人觉得这是多余的仪式感,但被那个“最后一个匹配位置恰好是终点”的边界问题坑过一次之后,你就会明白为什么老手总爱强调状态推演。

另外一点经验是,REPNE SCASB并不是任何场景下的性能最优解。极端短字符串(比如十几个字节以内),一个普通的手写循环或者查表法往往更快,因为重复前缀内部有额外的状态检查开销;但长字符串扫描时,它的紧凑指令流和硬件循环优势就体现出来了。做底层优化的时候,最好拿着真实数据量测一下,别凭感觉选指令。SCAS系列和REP前缀组合起来,写出的代码短小精悍、含义清晰,调试时只要记住它“只移动指针、不修改数据”这个底层行为,很多疑惑会自然解开。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进