循环语句的机器级表示
机器一级同样没有 while 和 for。它只有”往回跳”这一个动作,所以三种循环最终都塌缩到同一个骨架上:
do-while 是这个骨架的直接形态,while 和 for 都要先被改写成 do-while。这一节的全部内容就是这个改写过程,以及改写留下的痕迹——那些痕迹正是读汇编题时反推源码的线索。
机制
do-while:最基本的形态
do { <body> } while (cond);.Lbody:
<body>
cmp ...
jcc .Lbody ; ★ 唯一一条转移指令,而且是后向的
.Ldone:
整个循环只有一条转移指令,位于底部,方向向后。 这是最省的形态——没有一条多余的跳转。
while:两种翻译,取决于优化级别
while (cond) { <body> }(1)跳到中间(jump-to-middle),-O0 的做法:
jmp .Ltest ; ★ 先跳到判断
.Lbody:
<body>
.Ltest:
cmp ...
jcc .Lbody
结构最直白,代价是入口处多一条无条件跳转。
(2)守卫式(guarded-do),-O1 及以上的做法:
cmp ...
jncc .Ldone ; ★ 守卫:一次都不该执行就直接跳出
.Lbody:
<body>
cmp ...
jcc .Lbody ; ★ 循环内只剩这一条转移
.Ldone:
做法是”先判一次,然后整体当作 do-while”。 判断代码被复制了一份,换来循环体内只剩一条后向分支。
为什么值得复制一份代码? 因为循环体要跑
| 循环内的转移指令 | 执行 | |
|---|---|---|
| 跳到中间 | 1 条(底部条件跳) | |
| 守卫式 | 1 条 |
看起来一样——差别在别处:跳到中间的版本每次进入循环都要先执行那条 jmp .Ltest,而且判断代码在循环体之后、与循环体不连续,取指的局部性略差。真正的收益在分支预测:分支预测器对后向分支默认预测”跳转发生”(因为后向分支绝大多数是循环),守卫式的结构让这条预测在
for:等价于 while,但 continue 的落点不同
for (init; cond; update) { <body> }等价改写:
init;
while (cond) { <body>; update; }这个等价有且只有一处例外,而它恰恰是考点:continue。
- 在
for里,continue要执行update再回去判断 - 在按上式改写的
while里,continue会跳过update直接判断 → 死循环
所以编译器翻译 for 时,continue 的跳转目标是 update 那一段的标号,不是 .Ltest:
.Lbody:
<body>
... jmp .Lupdate ; ★ continue 跳到这里,不是 .Ltest
.Lupdate:
<update>
.Ltest:
cmp ...
jcc .Lbody
读汇编时看到循环底部有两个相邻标号(一个给 continue、一个给正常流程),基本可以断定源码是 for 而不是 while。
数组遍历:为什么汇编里常常找不到 i
for (i = 0; i < n; i++) s += a[i];直译的话,每轮都要算一次地址
lea edx, [eax + ecx*4] ; edx = &a[n],终止地址,循环前算一次
.Lbody:
add esi, [eax] ; s += *p
add eax, 4 ; ★ p += 4,不再有乘法
cmp eax, edx ; ★ 比的是指针,不是 i
jb .Lbody
结果是:i 这个变量在机器码里根本不存在。 循环变量被替换成了一个指针,终止条件被替换成了指针与终止地址的比较。
这是读汇编真题的一道坎——找不到计数器不要慌,看哪个寄存器在每轮固定加一个常量(4、8),那个常量就是数组元素的字节数,那个寄存器就是 i 的化身。
层次辨析:循环给的是哪一种局部性
上面这段循环同时体现了两种局部性,而且来源完全不同,不要接反(3.5.1):
局部性 来源 在这段代码里 时间局部性 循环回跳——同一批指令被反复执行 循环体那四条指令、变量 s和指针p空间局部性 顺序访问——地址连续往下走 a[0], a[1], a[2], …依次访问循环回跳给时间局部性,顺序访问给空间局部性。 一个常见的说法是”循环体时间局部性好是因为里面没有跳转”——这是反的:正因为有那条回跳,同一段代码才会被反复执行;真要一路直线执行下去,时间局部性反而为零。
loop 指令:一条”看起来该赢”的 CISC 指令
x86 提供了一条专用循环指令:
mov ecx, n
.Lbody:
<body>
loop .Lbody ; ecx--; if (ecx != 0) jump
一条指令干了三件事:减 1、判零、条件跳转。代码密度明显更好。
但现代编译器几乎从不生成它。 原因是它在多数微架构上被实现得比等价的 dec ecx; jnz .Lbody 更慢(微码实现、且隐式改 ECX 妨碍寄存器分配),而后者虽然多一条指令,却能和别的指令并行发射。
这是 CISC 的一个负面例证,正好和 4.3.1 里 lea 那个正面例证配成一对:复杂指令不是必然赢,也不是必然输——判据是”它有没有被硬件真正优待”。这条会在 4.4.3用性能公式重新表述一遍。
边界
三种循环在机器级没有本质区别,全部是”条件后向跳转”。 判断题若说”for 循环在机器级有专门的指令支持”,错——loop 指令存在,但它不是 for 的翻译,编译器也基本不用。
do-while 至少执行一次,while 可能一次都不执行——这个差别在机器级体现为”有没有那个守卫”。 反推源码时,循环体前面有一条条件跳出,就是 while/for;没有,就是 do-while。
循环展开会让循环体里出现多份相同结构的代码。 编译器把 4 轮合成 1 轮以减少分支和判断,读汇编时看到”同一组操作重复了几次、指针一次加 16”,不要以为源码写了四遍。
continue 在 for 和 while 里的落点不同,这是 for 不能被机械改写成 while 的唯一原因。 反过来,break 在两者里没有区别,都是跳到 .Ldone。
归纳变量替换之后,循环次数的边界条件容易被看错。 cmp eax, edx; jb .Lbody 里 edx 是 &a[n](越界的那一个),所以用的是 jb(严格小于)——若看成 &a[n-1] 就会把循环次数少算一次。
统考的循环题常常和数组、指针一起出,考的是”这段汇编对应的 C 代码循环了多少次、访问了哪些元素”。先找那个每轮固定增量的寄存器,再看终止比较的对象,两步就能定位。
对照速查
| 源码 | 机器级骨架 | 循环内转移指令 | 特征 |
|---|---|---|---|
do-while | 体 → 判 → 后向跳 | 1 条 | 入口无守卫 |
while(-O0) | jmp 到判断 → 体 → 判 → 跳 | 1 条 | 入口有一条无条件 jmp |
while(-O1+) | 守卫 → 体 → 判 → 跳 | 1 条 | 入口有一条条件跳出 |
for | 同 while,但多一个 update 标号 | 1 条 | 底部两个相邻标号 |
| 判断 | 对错 |
|---|---|
| 循环回跳提供时间局部性 | ✅ |
| 循环体时间局部性好是因为没有跳转 | ❌(反了) |
| 数组顺序遍历提供空间局部性 | ✅ |
for 和 while 可以机械互换 | ❌(continue 的落点不同) |
break 在 for 和 while 里翻译不同 | ❌(相同) |
| 汇编里找不到循环变量说明代码错了 | ❌(归纳变量替换) |
loop 指令比 dec+jnz 快 | ❌(多数微架构上更慢) |
do-while 在机器级最省 | ✅(唯一一条转移指令) |
考点
- 三种循环统一到 “循环体 → 比较 → 条件后向跳转”;
do-while是基本形态 while两种翻译:跳到中间(-O0)与守卫式(-O1+);守卫的有无是反推do-while/while的判据for与while的唯一实质差别是continue的落点(跳update而非test)- 归纳变量替换:
i被替换成指针,终止条件变成指针比较;每轮固定增量 = 元素字节数 - 循环回跳给时间局部性,顺序访问给空间局部性——不要接反
loop指令存在但编译器不用,是 CISC 的负面例证
链接
- 🏠 返回总览:计算机组成原理第 4 章:指令系统总览
- ⬅️ 上一节:4.3.2 选择语句的机器级表示
- ➡️ 下一节:4.3.4 过程调用的机器级表示
- 🔗 3.5.1 程序访问的局部性原理(两种局部性的来源)
- 🔗 5.5 指令流水线(本地资料)(后向分支的预测)
- 🔗 4.4.3 CISC 和 RISC 的比较(
loop与lea是一对反例) - 📖 名词库:第 4 章名词库