本节摘要:ARM 的条件机制建立在四个标志位上——N(负)、Z(零)、C(进位)、V(溢出),由带
s后缀的运算刷新,由b.cond与csel家族消费。本节把四个标志逐个用实验点亮,把if与while手工翻译成汇编,并给出条件选择指令消分支的实例与适用边界。
控制结构是程序的骨架。这一节的目标是让你彻底摆脱"背模板"的状态:看到任何条件结构,都能还原成"置标志、查标志、执行或跳过"三步。为此先补一个来自第 3 章的伏笔——cmp 的真身是丢弃结果的 subs,它的唯一产出就是标志位。标志位是本章的主角,四个都要点灯。
标志位的定义手册上都有,但"定义背得出、用起来糊涂"是常态。办法只有一个:每个标志做一次最小实验,观察它的置位条件。实验统一用 cmp 设置标志、用 csinc 之类读标志,也可以直接在调试器里看 nzcv 寄存器(第 2 章实验里见过 cpsr 的 0x60000000)。四组输入与观察结果:
cmp x0, x1 之后: x0 = 3, x1 = 5 -> N=1 差值为负 x0 = 5, x1 = 5 -> Z=1 差值为零 x0 = -1, x1 = 1 -> C=1 无符号比较中减出了借位的反面:-1 按无符号是最大数 x0 = 最小负数减1 -> V=1 有符号溢出,结果符号与数学事实相反
四个标志合起来回答两个问题:大小关系(N 与 Z 处理相等性,C 处理无符号,V 处理有符号溢出)与精度事故(C 与 V 是两位报警员)。由此推出一个新手必踩的坑:有符号与无符号要用不同的条件码。b.lt 是有符号小于,b.lo 是无符号低于——对同样的两个寄存器值,两者可能给出相反的答案,因为判断依据分别是 V 与 N 的组合、以及 C。把 unsigned 的循环变量配上 b.lt 判断,编译器不报错、逻辑却悄悄变形,这种 bug 一旦上线极难追。对策:写汇编判条件前先问自己"这里的数有没有符号",再选条件码。
工具齐了,翻译一段典型的 C:
long clamp(long v) { // 把 v 限制在 0 到 100 if (v < 0) v = 0; if (v > 100) v = 100; return v; }
分支版翻译,规矩是"比较、条件跳过、修正":
clamp: cmp x0, #0 b.lt .Lzero // 有符号小于就走修正支路 cmp x0, #100 b.le .Ldone // 不超过 100 就直接收工 mov x0, #100 b .Ldone .Lzero: mov x0, #0 .Ldone: ret
同一逻辑用条件选择指令重写,分支消失:
clamp_csel: cmp x0, #0 csel x8, xzr, x0, lt // 小于则取 0,否则取 x0 原值 cmp x8, #100 csel x0, x8, #100, le // 不超过 100 保留,否则取 100 ret
csel 的语义一句话:四个候选里按条件挑一个放进目的寄存器,分支预测器彻底失业,流水线无需猜测。注意第二例其实是伪代码式的简写——csel 的候选必须是寄存器,常量 100 要先搬进寄存器,真实产物会多一条 mov。这也是 csel 的适用边界:分支体只差一个值时它最优雅,分支体各有一堆动作时老老实实用 b.cond。还有一个隐蔽点:csel 不刷标志,连续串联使用时标志来自最后一次带 s 的运算,串多了可读性下降,该注释注释。
循环的翻译只有两个真正的变体:先判断(while 型,循环开头 b 到判断处)与先干活(do-while 型,判断在循环尾部,天然少跳一次)。编译器默认把能改的都改成 do-while 型,因为尾部判断省一条跳转。数一遍第 1 章那个累加循环:cbnz 在尾部、循环体直接开工——正是 do-while 型,外加入口处 cbz 兜底空数组。把两种写法都写出来并数指令:
// while 型:多一条开头的跳转 b .Lcheck .Lbody: sub x1, x1, #1 .Lcheck: cbnz x1, .Lbody // do-while 型:尾部判断,短循环的最优解 .Lbody2: sub x1, x1, #1 cbnz x1, .Lbody2
实测收尾。把两种版本分别编进小函数,在模拟器里各跑一亿次迭代计时(用 cntvct_el0 计时器,读取方式见第 6 章):do-while 型稳定快出一条指令的份额,小循环体里差距放大明显。再做一个变式验证编译器的口味:把 C 循环分别写成先判断与后判断两种源码,-O2 下反汇编——你会发现产物几乎一样,编译器早已替你做了这层归一。所以这条优化知识对你的意义不是"手写时选 do-while",而是读反汇编时认得出它是 do-while,别把尾部判断误读成逻辑错误。
条件家族还有一位常客值得单独介绍:tbz 与 tbnz(测位零即跳、测位非零即跳)。标志位管理是它的主场——硬件寄存器轮询、状态位检查,一句 tbz w0, #3, .Lnotready 直接问"第 3 位是不是 0",省掉移位加判零的组合。读设备驱动反汇编时它的出现频率极高,外设状态轮询几乎都长这样。
把一个真实状态机翻译成汇编,能同时检验本节全部工具。以一个三态的按键状态机为例:空闲态检测到电平变化进消抖态、消抖态计时满进确认态、确认态报告后回空闲。C 里是 switch 加状态变量,汇编里典型产物是"跳转表 + 各态代码块 + 尾部公共出口":状态变量先进 cmp 加 b.eq 链(或编译器生成的查表分支),每态体内用 cbz 处理提前转移,公共出口做状态变量更新。观察要点:编译器在状态少时用比较链、状态多且密集时用跳转表——判断依据是"比较链的期望比较次数"与"一次间接跳转"的成本权衡。读懂这个权衡,你就明白为什么同一份状态机源码在两个工程里反汇编形状迥异,而且都对。
b.cond 或 csel)、执行或跳过——一切条件结构的底层。lt、le、gt、ge,无符号用 lo、ls、hi、hs,选错不报错只出错。csel 的边界:分支体只差一个值时消分支最优雅,候选必须是寄存器。条件结构玩转了。下一节进入本章重头戏:函数调用约定与栈帧手推——把一次调用从第一条指令推到最后一条。