ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

汇编语言分支结构程序设计:标志位、条件转移与跳转表

汇编语言分支结构程序设计:标志位、条件转移与跳转表

汇编语言的分支结构程序设计,是很多人学完寻址方式、数据传送之后撞上的第一道真墙。前面写顺序程序,指令一条接一条往下走,寄存器算对就完事;一旦要"根据情况走不同的路",问题就全冒出来了——标志位到底看哪一个、有符号数和无符号数为什么跳转结果不一样、跳转目标明明写了却跑飞、条件转移突然报"跳转超限"。这篇文章就把分支结构程序设计这件事从头拆一遍:它解决的是让 CPU 在运行时根据数据做决策的问题,适合正在啃 16 位或 32 位汇编的在校生、准备做底层逆向或者嵌入式入门的开发者,也适合那些写惯了高级语言、想搞清楚if到底被编译成什么样的朋友。下面的内容里,指令周期、跳转偏移量、标志位生成规则都会给到具体数字和推导过程,代码按主流 16 位汇编器(MASM/TASM 那一类)的语法风格给,思路在 32 位、64 位下完全通用。

1. 分支结构程序设计的整体思路拆解

1.1 为什么 CPU 层面只有"跳"没有"如果"

高级语言里的if、switch、while,在汇编层面全部塌缩成两种东西:影响标志位的运算和条件转移指令。这个设计不是偷懒,是硬件成本决定的。CPU 的取指部件只会做一件事——按顺序把下一条指令地址送进地址总线。想让它"拐弯",就必须显式改写指令指针(16 位下的 IP,32 位下的 EIP)。所以分支的程序设计思路永远是固定的三步:先做一次"试探性运算"把数据之间的比较结果烙进标志寄存器,再用条件转移指令读这些标志位决定跳不跳,最后用标签把各条路径的汇合点标出来。

理解了这个三段式,你就明白为什么汇编里没有"如果"这个词——条件判断不是被执行的,是被"算"出来的。CMP AX, 10这个动作本身不产生任何结果寄存器,它做的就是一次减法,把差值的性质(是否为零、是否负数、是否溢出、有无借位)写进标志寄存器。后面的JE、JG只是读这些位而已。也正因为判断结果放在全局共享的标志寄存器里,汇编才比高级语言脆弱得多:任何一条夹在中间、会修改标志的指令,都能把之前辛苦算出来的判断结果冲掉。这是分支程序出 bug 最常见的根源,后面第 6 节会专门讲。

1.2 分支布局的两种写法及其取舍

同一个if-else,汇编里至少有两种排布。第一种是"条件成立就往下走,不成立跳到 else":

CMP AX, 0 JLE ELSE_BRANCH ; 不满足则跳出 MOV BX, 1 ; then 块 JMP END_IF ELSE_BRANCH: MOV BX, -1 ; else 块 END_IF:

第二种是"条件不成立就直接落进 else",把 then 块放到条件跳转之后:

CMP AX, 0 JG THEN_BRANCH MOV BX, -1 ; else 块 JMP END_IF THEN_BRANCH: MOV BX, 1 ; then 块 END_IF:

单看指令条数,两者一样。真正的差别在执行路径的连续性。第一种写法里,条件成立时执行的是一条紧接一条的连续指令,取指部件不用改道;第二种写法里,最常走的那条路要跳一次。在做性能敏感的内层循环时,我会把概率最高的分支放在不跳转的落空路径上,这样绝大多数时候取指是顺流的,跳转带来的流水线清空次数最少。这个习惯在高级语言里叫"分支预测友好写法",落到汇编就是标签摆放的位置问题,零成本,改动量极小,收益却很实在。

1.3 三条路径以上的结构该不该用跳转表

两个分支的if-else用CMP + Jcc天经地义。但遇到"根据一个 0 到 7 的编号分八种处理"这种结构,级联比较就要写八组CMP + Jcc,代码长、执行时间还随编号线性增长——第 7 号分支要白白走完前 7 次比较。这时候跳转表就是更合理的选型:

MOV BX, OFFSET JUMP_TABLE MOV AL, SELECT_INDEX MOV AH, 0 SHL AX, 1 ; 每项占 2 字节,索引乘以 2 ADD BX, AX JMP WORD PTR [BX] JUMP_TABLE DW CASE_0, CASE_1, CASE_2, CASE_3

参数推导过程值得说一下:16 位模式下每个标号地址占一个字(2 字节),所以索引要左移 1 位;如果表里放的是双字(32 位模式下的偏移量),就左移 2 位。用SHL而不是MUL,是因为移位一条指令、2 到 4 个周期就够,而MUL在早期 x86 上要十几个周期,还要占 AX 和 DX,成本差了一个数量级。

但跳转表不是万能的。它的代价在于:表项要在数据区或代码区占一块固定内存,而且表项里存的是绝对地址,程序一重定位表就全废了(需要重新计算或在加载时修正)。分支数量少于 4 个的时候,级联比较的代码更短、更好读,跳转表反而是过度设计。我一般按这个经验线来切:分支数 ≥ 5 且索引连续、范围可控,上跳转表;分支数 ≤ 4 或者索引稀疏(比如只有 1、10、100 三个值),老老实实写级联比较。

2. 标志寄存器:分支判断的真正依据

2.1 四个必须记牢的标志位

条件转移指令看的不是一个寄存器,而是一组独立的位。最核心的是四个:

  • ZF(零标志):运算结果为零则置 1。判断相等、判断计数器减到零都靠它。
  • SF(符号标志):取运算结果的最高位,1 表示"负数"(严格说是最高位为 1)。有符号数的大小判断离不开它。
  • CF(进位标志):无符号运算的进位或借位。无符号比较的大小关系全靠它。
  • OF(溢出标志):有符号运算结果超出表示范围时置 1。它和 SF 配合才能正确判断有符号数的大小。

举个例子说明为什么单看 SF 不够:8 位有符号数里,127 + 1结果是-128,最高位是 1,SF 置 1 看起来"是负数",但实际发生了溢出,真实结果正数方向越界了。所以判断有符号大小时,必须把 SF 和 OF 做异或,也就是JL/JGE这类指令内部真正做的事。这也是为什么JG和JA在机器码层面是不同的条件码——它们读的标志位组合根本不一样。

2.2 CMP 和 TEST:两个"不写回"的比较工具

CMP和TEST是分支程序里出现频率最高的两条指令,它们的共同点是只更新标志位,不改变任何操作数。

CMP dest, src内部做的是dest - src,结果扔掉,只留标志。所以CMP AX, BX之后:

  • AX == BX则 ZF=1
  • AX > BX(无符号)则 CF=0 且 ZF=0
  • AX < BX(无符号)则 CF=1

TEST dest, src内部做的是dest AND src,同样只留标志。它的典型用途是位测试,而不是数值比较:

TEST AL, 01H JZ IS_EVEN ; 最低位为 0,是偶数

注意:CMP之后如果非要插指令,只允许插MOV、LEA、PUSH、POP、NOP这类不碰标志位的指令。任何ADD、SUB、INC、DEC、AND、OR、XOR、SHL都会重写标志,把前面的判断结果毁掉。

顺便说一个容易被忽略的细节:INC和DEC会修改 ZF、SF、OF,但唯独不动 CF。这是硬件有意为之——循环里经常用INC改指针,同时还想保留之前的进位状态做多字节加法,如果INC把 CF 冲了,多精度运算就没法写了。所以看到"循环里加了个计数器,后面判断进位却出错",八成不是INC的锅,而是别的指令。

2.3 有符号与无符号:一道必须划清的分水岭

这是分支程序里翻车率最高的地方。看这段代码:

MOV AX, -1 CMP AX, 1 JA BIGGER

表面上-1小于1,不该跳。但-1在 16 位里的位模式是0FFFFH,按无符号解释是 65535,远大于 1,所以JA会跳。是JA错了吗?不是,是用错了条件码。要按有符号语义比较,必须用JG:

比较语义大于大于等于小于小于等于等于不等于
无符号JAJAEJBJBEJEJNE
有符号JGJGEJLJLEJEJNE
快捷记忆A = AboveAEB = BelowBEZF=1ZF=0

我自己的记法是把条件码分成两族:A 族(Above/Below)只认 CF 和 ZF,B 族(Greater/Less)还认 SF 和 OF。写代码前先问自己一句"这个数是分数、编号、地址、长度,还是温度、差值、坐标偏移"。前者用无符号族,后者用有符号族。成绩、年龄、内存长度、数组下标——全部无符号;温差、位移量、账户余额、数轴坐标——全部有符号。这个判断做好了,能省掉一大半调试时间。

还有几个不常用但关键时候能救命的:JC/JNC直接看进位、JO/JNO直接看溢出、JS/JNS直接看符号位、JP/JNP看奇偶标志(低字节中 1 的个数是否为偶)。求绝对值的时候JNS就比JGE更贴切,因为它直接对应"符号位没置起来,说明本来就是非负数"这个语义。

3. 条件转移指令族的选型与跳转范围

3.1 指令选型的三个判断维度

选条件转移指令,我通常按三步走。

第一维是数据的符号属性,前面已经讲透。第二维是边界包含不包含——是"大于"还是"大于等于"。这个看似微不足道,却是 off-by-one 错误的温床。判成绩等级的时候我坚持写JAE(大于等于 90 归为 A),而不是JA 89这种写法,因为前者一眼能看出边界值,后者还要在脑子里做一次加一运算,评审代码的人容易看错。第三维是能不能合并判断。比如"大于 0 且小于 100"这种区间判断,正常要两条CMP,但如果区间恰好关于 0 对称,可以用一次TEST配合减法压缩指令数。这类优化等第 7 节再细说。

另外提醒一句:JE和JZ是同一条机器指令的两个助记符,汇编器生成的操作码完全一样,JNE和JNZ同理。写成JE是强调"相等",写成JZ是强调"结果为零",纯看语境。我个人的习惯是数值比较用JE,位测试和循环计数用JZ,读代码的人不用猜。

3.2 跳转超限:从 rel8 到 rel32 的妥协

这是新手最容易被报错卡住的地方。8086 的条件转移指令只有短转移一种形式,操作数是一个 8 位有符号相对偏移,取值范围是 -128 到 +127 字节。也就是说,条件跳转的目标标签必须在当前指令之后 127 字节以内,或者之前 128 字节以内。一旦中间隔的代码稍长,汇编器直接报"jump out of range"。

解决办法是标准的"反向跳转"技巧——把Jcc 目标改写成"条件不成立时跳过一条JMP":

CMP AL, 0 JNE SKIP_NEAR ; 条件反过来说,只跳 2 字节 JMP FAR_TARGET ; 这条 JMP 没有范围限制 SKIP_NEAR:

原理很直白:JNE SKIP_NEAR只需要跨过紧邻的那条JMP,偏移量是个位数,绝对不越界;而JMP是无条件转移,在 8086 上支持近转移(16 位偏移)甚至远转移(段+偏移),随便跳多远都行。代价是多一条指令、多一次取指,但在绝大多数场合完全可以接受。

到了 386 以后的 32 位模式,条件转移指令本身支持 rel32 近转移,范围约 ±2GB,正常情况下根本不会超限。不过跳转表里的JMP WORD PTR [BX]这类间接转移依然要注意段内段外的问题。写代码的时候养成"条件跳转只跨短距离,长距离交给JMP"的习惯,代码搬到哪个平台都不会出问题。

3.3 无条件转移在分支里的双重身份

初学的时候会觉得JMP是"跳转",跟条件判断没关系。实际上在分支结构里,JMP承担的是路径汇合的职责。每个if-else的 then 块执行完,必须有一条JMP越过 else 块回到汇合点,否则执行流会顺着掉进 else 块里,把两边的逻辑都跑一遍——这是"结果莫名其妙被覆盖"最经典的原因。

CMP AL, 'A' JNE NOT_A MOV DL, 1 JMP DONE ; 必须越过 NOT_A 块 NOT_A: MOV DL, 0 DONE: ; 汇合点

反过来,如果在一个多分支结构里,最后一条分支本身就是"以上都不满足时的兜底",那就不需要再给它加JMP,因为代码后面已经没有别的分支块了。这种"省掉最后一条跳转"是基本优化,很多教材的例题都这么写。但它带来一个副作用:后面再加分支的时候容易忘了补JMP,然后逻辑就串了。我的习惯是把汇合标签的位置写死、把兜底分支放在最后,并且加注释说明"此处省略尾部跳转",避免自己或同事将来踩坑。

4. 从高级语言结构到汇编落地的映射

4.1 用 CMP + Jcc 拼出一个完整的 if-else

先看最简单的单分支。高级语言的if (x > 0) y = 1;,落到汇编是:

CMP AX, 0 JLE SKIP_SET ; 不满足就跳过赋值 MOV BX, 1 SKIP_SET:

注意这里省掉了JMP——因为条件不成立时本来就要落到标号位置。如果写成"条件成立跳到标签",代码会更长:

CMP AX, 0 JG DO_SET JMP SKIP_SET DO_SET: MOV BX, 1 SKIP_SET:

两条指令 vs 一条,性能差了一倍。所以单分支的标准写法永远是"条件取反,跳过 then 块"。这个思路要刻进肌肉记忆里。

双分支的结构前面第 1.2 节给过两种布局,这里补一个实操细节:then 块和 else 块里如果都有跳转到别处的指令(比如RET或者JMP LOOP_END),那么尾部那条JMP就可以省掉,因为执行流根本走不到它下面。但我在写代码时依然倾向保留它,一来凑齐对称结构方便阅读,二来将来往块里加代码不会突然出问题,JMP的开销一次也就几纳秒,不值得为它牺牲可维护性。

4.2 多分支的两种实现路径

switch-case落地到汇编有两种主流形态。级联比较适合小规模、稀疏索引:

GET_GRADE PROC CMP AL, 90 JAE GRADE_A CMP AL, 80 JAE GRADE_B CMP AL, 70 JAE GRADE_C CMP AL, 60 JAE GRADE_D JMP GRADE_E GRADE_A: MOV DL, 'A' RET GRADE_B: MOV DL, 'B' RET GET_GRADE ENDP

这段代码的正确性依赖一个隐含前提:前面的比较已经排除了更大的区间。执行到CMP AL, 80时,AL 必定小于 90,所以只需要判断>= 80一个方向。这种"漏斗式收缩"是级联比较的核心技巧,判断条件能省掉一半。每一级CMP + JAE在 8086 上大约占 4 到 5 个时钟周期,四级平均要花十几到二十个周期。

跳转表则是 O(1) 直达:算出索引、偏移、查表、间接跳转,四步走完,跟分支数量无关。但要注意索引越界防护。用户传进来的索引万一超出表长,JMP WORD PTR [BX]会跳到一个完全无关的地址,直接跑飞。稳妥做法是查表前先做一次范围检查:

CMP AL, MAX_CASE JA INVALID_INDEX ; 无符号比较,一次挡住负数和大数 ; 查表...

这里必须用JA而不是JG,因为索引是无符号语义,而且用无符号比较恰好能用一个条件挡住"小于 0"和"超过上限"两种情况——负数在无符号解释下是极大的数,自然被JA拦下。这是一个很漂亮的技巧,值得记住。

4.3 循环本身就是一种分支

循环在汇编里从来不是一条指令,它就是"分支 + 跳回去"。while结构天生对应"先判断后进入":

WHILE_TOP: CMP CX, 0 JLE WHILE_END ; 循环体 DEC CX JMP WHILE_TOP WHILE_END:

而do-while对应"先执行后判断",代码可以少一条JMP,因为判断在尾部:

DO_TOP: ; 循环体 DEC CX JNZ DO_TOP

注意这里用DEC CX配合JNZ,而不是LOOP指令。LOOP虽然只有一条指令、代码更短,但它隐含DEC CX并且不影响任何标志位,功能单一,没法在计数之外附带别的条件判断。而且在现代处理器上LOOP已被证实是执行较慢的指令(每次要多次更新 ECX,微码实现),从 P6 架构开始官方就不推荐在性能敏感处使用。我写循环一律用DEC + JNZ,除非是纯粹凑代码长度的场合。

循环里最容易出错的是边界走向。用JAE还是JA,直接决定循环执行 N 次还是 N+1 次。我的固定检查方法是:拿最小的边界值(比如 N=1、N=0)在纸上手推一遍,数一数循环体的执行次数,对上了再往代码里写。

5. 完整实操:一个综合分支程序的落地过程

5.1 需求拆解与结构设计

动手写一个能覆盖各类型分支的综合程序:读入一个 0 到 100 的分数,输出等级 A/B/C/D/E;再根据用户选择的菜单项,分别演示奇偶判断、绝对值计算、求一组数的最大值。这样一次把无符号级联、位测试、有符号条件、循环分支全用上。

结构上我把它分成四块:主循环负责显示菜单并读入选项,一个菜单跳转表负责分发;三个子过程各自封装具体判断逻辑;最后统一回到主循环,直到用户选退出项。

选这个结构的理由有三点。第一,用跳转表做菜单分发,能在最多十几个选项时保持 O(1) 跳转,代码也不至于膨胀成一屏CMP。第二,把每个判断逻辑封装成过程,CMP和Jcc都被关在过程内部,标志位污染的范围被限制住,不容易出交叉干扰。第三,主循环用do-while,代码天然少一条JMP。

5.2 关键代码与逐段注释

菜单分发这块:

MAIN_LOOP: CALL SHOW_MENU CALL READ_KEY ; 结果在 AL,'1'~'4' CMP AL, '4' JE EXIT_PROG ; 先挡退出项 SUB AL, '1' ; 字符转索引:'1' -> 0 CMP AL, 2 ; 有效索引 0/1/2 JA BAD_CHOICE MOV AH, 0 SHL AX, 1 ; 索引乘 2,DW 表项 MOV BX, OFFSET MENU_TABLE ADD BX, AX JMP WORD PTR [BX] MENU_TABLE DW CASE_GRADE, CASE_PARITY, CASE_ABS

几个细节值得抠。SUB AL, '1'这一步把 ASCII 码转成从 0 开始的索引,是查表能成立的前提;如果直接拿'1'去查表,索引就偏了 49。CMP AL, 2用JA而不是JG,前面已经解释过——一个条件同时挡住下溢和上溢。SHL AX, 1之后AX的值域是 0 到 4,加上表基地址,正好落在表的前 6 字节内,不会越界。

注意:JMP WORD PTR [BX]里的WORD PTR不能省。汇编器看到JMP [BX]时无法判断你要读的是字节、字还是双字,会直接报"操作数大小不确定"。这个错误新手几乎人人踩过一次。

奇偶判断这块,用TEST而不是CMP:

CASE_PARITY: CALL READ_NUM ; 返回 AX TEST AX, 1 JZ PARITY_EVEN MOV DL, 'O' JMP PARITY_SHOW PARITY_EVEN: MOV DL, 'E' PARITY_SHOW: CALL SHOW_CHAR JMP MAIN_LOOP

TEST AX, 1只影响标志位、不碰AX,比AND AX, 0之类的写法安全得多——后者会把原值破坏掉。判断奇偶只需要看最低位,TEST是最贴切的表达。

绝对值这块,用JNS:

CASE_ABS: CALL READ_SIGNED ; 返回带符号数 MOV BX, AX JNS ABS_READY ; 符号位为 0,本身就是非负数 NEG BX ; 否则取负 ABS_READY: ; BX 中即为绝对值

JNS直接读 SF,语义上比JGE更准——我们要问的是"符号位是不是 0",而不是"是不是大于等于 0"。虽然两者在这里效果相同,但边界情况(比如等于 0)下的直觉不同,用JNS更容易看懂。另外要留意NEG的溢出特例:8 位有符号的-128取负之后还是-128(因为+128表示不出来),这是数学上的固有限制,程序里要么加边界检查,要么提前声明输入范围。

求最大值的循环,把移位的比较放进循环体:

CASE_MAX: MOV CX, COUNT JCXZ MAX_DONE MOV AX, [SI] ; 首个元素作为当前最大 DEC CX ADD SI, 2 MAX_LOOP: CMP AX, [SI] JAE MAX_NEXT ; 无符号比较,当前值不小于新值则跳过 MOV AX, [SI] MAX_NEXT: ADD SI, 2 DEC CX JNZ MAX_LOOP MAX_DONE:

循环开始前用JCXZ挡住计数为零的情况,这是防止空数组进入循环的标配。CMP AX, [SI]直接拿寄存器和内存比较,省掉一次装载;JAE表示"当前最大值大于等于新元素就跳过更新",把赋值路径压在了条件不成立的落空位置上,符合前面讲的布局原则。

5.3 调试与验证手段

分支程序光靠肉眼读代码很难验证,必须配合单步执行。我常用的手法有三种。

第一种是关键CMP之后打断点。在 DOS 调试器里,把光标停在CMP下一条指令处执行,然后查看标志寄存器的 ZF/SF/CF/OF,跟预期的位组合对照。如果预期 ZF=1 实际是 0,说明参与比较的两个数跟你想的不一样,先去查寄存器里的实际值,别急着改跳转指令。

第二种是给每个分支入口打标记。在每个分支的第一条指令处显示一个不同的字符,跑一遍看输出了哪个字符,就能确认走的是哪条路。这个方法土,但对多层嵌套分支特别有效,比一层层单步快得多。

第三种是构造边界用例。分数给 0、59、60、89、90、100 六个值各跑一遍,其中 59 和 60、89 和 90 是重点,专门用来抓JA写成JAE的错误。循环那块给 N=0、N=1、N=2 三个规模,专抓循环次数差一。这个用例集我基本是固定下来的,每写一个新的分支程序就套一遍,五分钟能筛掉绝大部分低级错误。

6. 常见问题与排查技巧实录

6.1 问题速查表

现象最可能的原因排查与解决
分支条件明明成立却不跳CMP与Jcc之间插了改标志的指令逐条检查中间指令,MOV/LEA/PUSH/POP安全,其余全部重排
负数比较结果完全反了有符号数用了 A 族条件码(JA/JB)换成JG/JL/JGE/JLE,或统一改用无符号语义
汇编报 "jump out of range"条件跳转偏移超出 -128~+127反向写条件,用Jcc跳过一条JMP完成远距离转移
跳转表跳飞索引未做范围检查、表项未加OFFSET、偏移倍数算错补CMP index, MAX+JA防护;确认表项是 DW 还是 DD,SHL位移数对应 1 或 2
执行完 then 块又跑了 else 块then 块尾部漏了JMP在汇合标签前补JMP,或确认块内已有RET/JMP出口
循环多跑一次或少跑一次边界条件的方向写反,或初值/终值对齐错误用 N=0、N=1 手推执行次数,核对CMP与DEC的先后顺序
菜单输入任何键都走同一个分支字符未转索引,或索引偏移量算重检查SUB AL, '1'是否存在,检查SHL的位移数

6.2 三个反复踩到的坑

第一个坑:INC之后标志位变了,JC失效。写过一段多字节加法,处理完低位用INC SI推进指针,紧接着想用JC判断进位继续处理高位,结果永远不跳。查了半天才想起来INC虽然不动 CF,但它的定位是"指针推进",而我实际关心的 CF 是前面ADC产生的——问题出在我把INC插在了ADC和JC之间,虽然INC本身不毁 CF,但中间的其它指令(当时是一句MOV加一句ADD用于调整索引)把 CF 冲掉了。教训是:从产生标志的指令到读取标志的指令之间,只放绝对必要的指令,并且每条都要确认它不碰标志。

第二个坑:字符比较用了数值比较的条件码。判断用户输入是否在'0'到'9'之间,我写了:

CMP AL, '0' JL NOT_DIGIT CMP AL, '9' JG NOT_DIGIT

看着没问题,实际在输入低值控制字符时会失效。ASCII 码是 0 到 127 的正数,本应该用无符号的JB/JA。用JL/JG在正常字符范围内碰巧能工作,一旦遇到高位为 1 的字节(比如某些扩展字符),判断就崩了。字符、字节、位模式这类数据,一律用无符号条件码,这是硬规则。

第三个坑:跳转表项忘了OFFSET。第一次写跳转表,表定义成DW CASE_0, CASE_1,结果跳过去执行的是CASE_0处那几个字节的内容,而不是跳向那个地址。原因是汇编器对不同上下文里同一个符号的解释不同,某些语法下它会取地址处的值而不是地址本身。显式写成DW OFFSET CASE_0, OFFSET CASE_1,语义就明确了。这个问题在有段前缀、有NEAR/FAR混用的工程里尤其隐蔽,因为编译不报错,只是运行结果诡异。

7. 分支程序的性能与可读性取舍

7.1 让分支对流水线更友好

现代处理器都有分支预测单元,跳转指令本身的开销已经很小,真正贵的是预测失败——一旦猜错,流水线里预取的十几条指令全部作废,代价在十几个周期量级。汇编层面能做的优化有两条。

第一条是把热路径放在落空位置。前面提过的布局选择,本质就是减少实际发生的跳转次数。如果某个分支占运行时间的 90%,就让它顺着往下流,把冷路径用Jcc跳走。

第二条是减少分支总数。比如"判断 x 是否在 0 到 100 之间",常规两条CMP:

CMP AX, 0 JB OUT_OF_RANGE CMP AX, 100 JA OUT_OF_RANGE

但从 0 开始的下界,其实可以借无符号比较一次搞定——无符号数永远"大于等于 0",所以只要判断上界,同时负数在无符号解释下会被JA自动拦下:

CMP AX, 100 JA OUT_OF_RANGE

一条CMP加一条Jcc替代了四条指令,前提是能确认AX是无符号语义。这种"用无符号比较同时挡负数"的技巧,在数组下标越界检查里几乎是标配。

7.2 无分支写法的适用边界

386 之后引入了SETcc(按条件设置字节)和CMOVcc(按条件传送),可以把简单分支变成数据流操作:

; 有分支:小于 0 则取负 TEST AX, AX JNS ABS_OK NEG AX ABS_OK: ; 无分支(32 位 + CMOV 风格,用算术代替) MOV BX, AX NEG BX TEST AX, AX CMOVS AX, BX ; 符号位为 1 时把 BX 传过来

无分支写法的好处是没有跳转、不会被预测失败惩罚,坏处是指令条数变多,而且CMOVcc的两个操作数都要先算出来,其中一路的结果是被丢弃的。在分支高度不可预测(比如乱序数据)时它明显更快;在分支几乎总是一致的(比如全是正数)时,普通跳转反而更省。

我自己的取舍标准是:分支可预测性差且分支体只有一两条指令,考虑无分支;分支体较长、或者有函数调用、内存写入,老老实实用跳转,因为把长代码块无分支化基本不可能,强行做只会让代码不可读。教学场景和一般工程场景,优先保证可读性,性能优化留到实测出瓶颈再动手。

7.3 可读性上我愿意多花的那些成本

分支程序的维护成本极高,因为跳转关系是隐式的——你看不到"作用域",只能靠标签名和缩进。所以有些看似冗余的写法,我一直坚持保留。

标签命名必须带语义,L1、L2、NEXT这类名字在超过两层嵌套之后就是灾难。我的命名格式是"结构_含义":IF_NEG、ELSE_EMPTY、END_SUM、LOOP_SCAN、CASE_DEFAULT,一眼能看出这属于哪个结构、干什么用。多分支的每一块前后加一行注释标注它处理的范围,比如; --- 60 <= score < 70 ---,将来改边界时不会误伤邻块。嵌套超过三层就抽成子过程,用CALL/RET把跳转关系隔离开,虽然多几次压栈弹栈,但调试时间能省好几倍。

最后一条经验是关于标志位污染的防御性写法。在关键判断前后各留一两条NOP或者空白行分割,让"产生标志"和"读取标志"在视觉上连成一组。写的时候可能觉得多余,可是等你两周后回来改代码,这两行空白能救你一次。

我在实际带人做汇编练习时发现,分支结构出错的比例远高于顺序结构,但错误类型高度集中——无非是标志位被冲、符号语义用错、跳转范围不够、尾部漏跳这四类。把这四类的排查动作固化成肌肉记忆,写分支程序的效率会有质的变化。另一个体会是:写完一段分支,别急着跑,先在纸上把每条路径的入口条件、出口状态、汇合点列一遍。汇编没有编译器帮你检查可达性,这份人工清单就是你的类型系统和静态分析。

RELATED READING

延伸阅读

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