技术

ICS 与 8086 复习 12:条件判断与循环的机器表示

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 和边界最后一轮分别代入,比凭印象记忆模板更可靠。

自测与简答

  1. 在 cmpl %edx,%eax 之后,jbe L 表示哪一类比较?
  2. 为什么 SF=1 不能单独证明有符号 A 小于 B?
  3. 一个循环在进入循环体前先跳到测试点,能否据此唯一判断源码用了 while?

答案:① 按无符号数解释的 EAX≤EDX。② 有符号减法可能溢出,须结合 OF;小于条件为 SF≠OF。③ 不能;不同源码结构和优化可能得到相同控制流,应恢复等价逻辑。

资料对应

主要依据《3.程序转换与指令系统-V1.0.pptx》第 85—96 页,以及《4.程序的机器级表示-V1.0.pptx》第 48—54 页。比较算例与循环跟踪为重新设计;零长度题保留课件问题并补充了类型和语言语义条件。

系列导航

CONTINUE / PATH

从这里继续

顺着当前内容继续,或者换到另一个板块。

讨论这篇文章

关于《ICS 与 8086 复习 12:条件判断与循环的机器表示》的补充、不同意见或延伸话题,都可以留在这里。

滚动到这里时自动载入留言。