ICS 与 8086 复习 12:条件判断与循环的机器表示
看到 cmp 后面跟着 ja 或 jg,能否立即说明两者比较的是什么?这比背诵“跳转指令很多”更有用。机器级分支的关键在于:前一条指令怎样产生标志,后一条指令怎样解释标志,以及这些位串对应什么数据类型。
本文使用 IA32、AT&T 语法。代码是说明控制流的片段;若恢复成 C,还必须检查类型、边界以及运算是否有定义。
CMP 是一次不保存结果的减法
AT&T 写法 cmpl %ebx,%eax 按 EAX−EBX 设置条件标志,不改写 EAX 和 EBX。testl %eax,%eax 则按位与并丢弃结果,常用来判断 EAX 是否为零或最高位是否为 1。
四个常见标志的含义如下:ZF 表示结果是否为零;SF 是结果最高位;CF 在减法中表示无符号借位;OF 表示按有符号解释时是否溢出。SF 不能单独代替有符号大小关系,因为减法可能发生有符号溢出。
将 CMP 的数学顺序先写在纸上,再读跳转,可以避免把整个条件反过来。
同一组标志能有两种大小解释
考虑下面的 8 位比较:
# 初值:AL=80H,BL=01H
cmpb %bl,%al
逐步计算如下:
80H − 01H = 7FH(保留8位)
ZF=0:结果不是0
SF=0:7FH最高位为0
CF=0:按无符号数,128−1不借位
OF=1:按有符号数,−128−1超出8位范围
若把操作数看成无符号数,128 大于 1,因此 JA 应跳转;其条件 CF=0 且 ZF=0 成立。若看成补码有符号数,−128 小于 1,因此 JL 应跳转;其条件 SF≠OF 也成立。
两条跳转都可以满足,原因是它们解释的是不同的数值语义。加减器只产生一组位和标志,程序选择的跳转指令体现了比较类型。
条件表要按有无符号分组
以下按 CMP 的目的操作数 A 与源操作数 B 比较来理解:
| 关系 | 无符号跳转及条件 | 有符号跳转及条件 |
|---|---|---|
| A=B | JE,ZF=1 |
JE,ZF=1 |
| A≠B | JNE,ZF=0 |
JNE,ZF=0 |
| A>B | JA,CF=0 且 ZF=0 |
JG,ZF=0 且 SF=OF |
| A≥B | JAE,CF=0 |
JGE,SF=OF |
| A<B | JB,CF=1 |
JL,SF≠OF |
| A≤B | JBE,CF=1 或 ZF=1 |
JLE,ZF=1 或 SF≠OF |
这是指令自身的条件定义,采用 Intel Jcc 手册中的规范形式。编译器有时用相反条件跳过一个基本块,例如 C 中写 if(a>b),汇编可能先用 jle 跳到另一分支;不能只看跳转名称,就断定源代码原样写了哪个关系。
SETcc 把条件结果写成 0 或 1,CMOVcc 有条件地传送数据。所以一个 C 选择表达式并不保证出现条件跳转。
读循环先画出入口 回边和出口
以下片段计算 0+1+…+(n−1),假定输入 n 在 EDX,且 0≤n≤10000,结果在 EAX。这个范围保证对应的 int 累加不溢出。
xorl %eax,%eax # s=0
xorl %ecx,%ecx # i=0
.Ltest:
cmpl %edx,%ecx # i-n
jge .Ldone # i>=n时结束
addl %ecx,%eax # s+=i
addl $1,%ecx # ++i
jmp .Ltest
.Ldone:
入口是初始化;.Ltest 是循环条件;jmp .Ltest 是回边;.Ldone 是出口。给定 n=4,每次进入循环体之前的 (i,s) 为 (0,0)、(1,0)、(2,1)、(3,3),最后 i=4 时退出,EAX 为 6。
对应 C 结构可写成:
int s = 0;
for (int i = 0; i < n; ++i)
s += i;
先找控制流,再给寄存器命名,比逐行翻译更容易看出整体含义。do…while 至少先执行一次循环体;while 可以一次也不执行。不过编译优化能改变基本块排布,单靠标签的物理顺序无法唯一确定源代码使用了哪种循环语法。
len 为零时为什么容易出错
课件中的典型问题是:
for (int i = 0; i <= len - 1; ++i)
sum += a[i]; /* len 是 unsigned */
假定 unsigned 为 32 位,len=0 时 len−1 按无符号规则得到 UINT_MAX。比较中 i 也要按相应无符号类型参与,第一轮 0<=UINT_MAX 成立,原本应为空的区间却被当成了巨大区间。
更清晰的写法是让索引与长度使用相容的无符号类型,并直接比较上界:
for (size_t i = 0; i < len; ++i)
sum += a[i];
这修正了零长度时的循环条件;仍须保证 a 对应至少 len 个元素,且 sum 的累加结果可表示。程序越界以后,C 不保证“总会在某一轮访问异常”;运行现象不能代替语言规则。C11 草案区分了无符号模运算与未定义行为。
switch 和逆向题的边界
连续、较密集的 case 可能通过跳转表实现:先判断索引是否落在范围内,再根据表项间接跳转。不连续的分支也可能被编译成比较链或树形分支。看到间接跳转,应该继续查表地址、索引缩放和默认分支,不能默认每个 case 都有一条 cmp。
恢复 C 代码时,目标是表达等价行为,不是猜回原作者逐字写过的源码。应保留数据类型、循环边界和数组步长;若类型判断错了,一个看似正确的 < 就可能对应错误的 JL/JB。
短路求值和标志改写的易错点
if (p != NULL && *p > 0) 的第二个条件只在第一个为真时才会求值。反汇编中可能先测试 p,为空则直接跳过解引用;只有非空路径才执行内存读取。恢复源码时不能随意把 && 改成 &,也不能因为两个表达式都“检查条件”就认为访问次序不重要。指针非空还不保证有效,原题若讨论安全性,仍要确认对象是否存在和可访问。
跳转也不会永久记住先前的比较结果。例如下面片段中的 jz 查看 addl 产生的 ZF,而非最初 cmpl 的 ZF:
cmpl %ebx,%eax
addl $1,%ecx
jz .Lzero
这是读交错指令时很常见的漏项。先在每条指令旁标出“是否改标志”,再连接比较与跳转,能够避免把无关寄存器的运算误解释成源代码条件。
对跳转表还可以做一个具体索引检查。若连续分支覆盖 10—13,代码可能先令 k=a−10,再按无符号数比较 k 是否大于 3。不在范围内则去默认分支;在范围内才使用第 k 个表项。无符号比较还能同时排除原 a 小于 10 的情形,因为相减后的位串会落到很大的无符号值。但这描述的是给定机器指令的控制方法,恢复成 C 时最好使用不会引入溢出问题的显式范围条件。
最后检查出口含不包含边界:i<n 对应最后访问 n−1,i<=n 还会访问 n。只改一个跳转条件,就可能从正确循环变成越界循环。考场上把 n=0、n=1 和边界最后一轮分别代入,比凭印象记忆模板更可靠。
自测与简答
- 在
cmpl %edx,%eax之后,jbe L表示哪一类比较? - 为什么
SF=1不能单独证明有符号 A 小于 B? - 一个循环在进入循环体前先跳到测试点,能否据此唯一判断源码用了
while?
答案:① 按无符号数解释的 EAX≤EDX。② 有符号减法可能溢出,须结合 OF;小于条件为 SF≠OF。③ 不能;不同源码结构和优化可能得到相同控制流,应恢复等价逻辑。
资料对应
主要依据《3.程序转换与指令系统-V1.0.pptx》第 85—96 页,以及《4.程序的机器级表示-V1.0.pptx》第 48—54 页。比较算例与循环跟踪为重新设计;零长度题保留课件问题并补充了类型和语言语义条件。
讨论这篇文章
关于《ICS 与 8086 复习 12:条件判断与循环的机器表示》的补充、不同意见或延伸话题,都可以留在这里。
滚动到这里时自动载入留言。