80x86汇编与C语言-2 (续)

官方信息技术老师·12 页·深入(追求细节与边界)·0 次浏览·3 天前
调用约定栈帧布局寄存器映射编译过程

80x86汇编与C语言-2 (续)

看清C到汇编的每一步翻译,搞懂调用约定、栈帧布局与变量映射

按 空格/→ 演示下一步

1 / 12 页

全部页面点击任意一页,跳回舞台从这页播放

调用约定栈帧布局寄存器映射编译过程

80x86汇编与C语言-2 (续)

看清C到汇编的每一步翻译,搞懂调用约定、栈帧布局与变量映射

1第 1 页 · 80x86汇编与C语言-2 (续)

条件移动指令的体系结构背

上一节我们看到 C 里的 if-else 普遍翻成 cmp + jcc,控制流分叉决定走哪条路。但 x86 还藏着另一类指令:不动跳转,直接「条件搬运」数据——这就是 CMOV 家族。

CMOV 定义
条件满足时把源操作数拷到目的寄存器,否则空操作,且不修改 EFLAGS
硬件出处
1995 年 P6 微架构(Pentium Pro)首次加入 IA-32 扩展指令集
设计动机
深流水线分支预测失败要付出十几个周期惩罚,cmov 用数据流替代控制流
典型场景
编译器把 if (cond) a = b 这类简单赋值翻为 cmov 而非 jcc 跳转链
适用边界
仅当分支难预测、两路代价相近时收益明显,否则可能反而更慢
两条都铺好的公路对应 →条件移动 cmov

跳转像红绿灯分流车流,cmov 像两辆车同时发车,乘客坐两辆,最后只让对的那辆下客

cmovcc: if (CC) dstsrc\text{cmovcc: if (CC) } dst \leftarrow src
2第 2 页 · 条件移动指令的体系结构背

条件移动指令的体系结构背

上一页算过分支预测失败的代价:十几到二十个周期白白浪费。硬件工程师的思路是——既然预测难,能不能干脆别猜,把两条路都算出来,再用条件选一个?

条件移动指令
依据EFLAGS标志位,从两源操作数中二选一写入目的,不改变EIP
P6微架构首发
1995年Pentium Pro开始支持CMOV系列指令
双路径并行求值
处理器同时执行两条候选路径,结果由多路选择器按标志位切换
适用与禁忌
仅适合简单条件赋值;含副作用、循环或内存别名时禁用
餐厅同时备两道菜对应 →条件移动指令

厨师并行做A和B,客人最终决定要哪个,直接端对的,不回厨房重做

3第 3 页 · 条件移动指令的体系结构背

循环的汇编语言表示-1

CMOV 是 CPU 对 'if-else' 的直接翻译。那 for/while 这种 '重复执行' 呢?答案更古老——依然靠 CMP 加 JMP。

条件跳转
CMP 比较后用 Jcc 决定走向:满足条件跳走,不满足顺序往下
回跳形成循环
JMP 跳回前方的标签地址,CPU 就开始重复执行同一段代码
计数器模式
用寄存器计数;x86 有专用 LOOP:ECX 自减,非零则跳
C 与汇编的映射
for(init;cond;update) → 初始化 + 条件检查 + 循环体 + 更新 + 无条件回跳
田径场跑步对应 →汇编循环

跑到终点线就停,没到就再跑一圈——和 CMP+Jcc 回跳一模一样

4第 4 页 · 循环的汇编语言表示-1

循环的汇编语言表示-2

上一页看到 while 是「先测试再执行」的标准翻译。但 C 还有 do-while 这种先斩后奏的,以及 for 这种自带初始化的。当分支多到十几个,编译器搬出另一套武器——跳转表。

do-while 翻译
先执行循环体,末尾做条件测试;满足则跳回顶部继续
for 循环翻译
初始化放在循环外,递增与测试都在底部;结构上等价于 while
switch 跳转表
分支密集时用间接跳转按索引直达目标,O(1) 替代串行比较
循环展开
编译器把循环体复制多份,减少跳转次数,提升流水线吞吐
酒店楼层指示牌对应 →switch 跳转表

查楼层号直接跳过去,不用逐层比对房间号

jmp[table+index8]jmp *[table + index \cdot 8]
5第 5 页 · 循环的汇编语言表示-2

循环的汇编语言表示-3

前两页看到 while、for、do-while 都被翻译成条件跳转,每次迭代都要「判断+跳转」。这一页看编译器如何用循环展开摊薄这些开销。

基本循环开销
每次迭代都有比较与跳转指令;分支预测失败时要清空流水线
循环展开
把连续 k 次迭代合并为一个代码块,分摊跳转开销
边界处理
总迭代数未必整除展开因子,剩余迭代需要单独的尾巴循环
Duff's 设备
把 switch-case 与循环交织,一个代码块同时处理主体与尾巴
工厂批量装车对应 →循环展开

一次装多件 vs 一次处理多个元素,摊薄每次的开销

分支数=nk\text{分支数}=\lceil\frac{n}{k}\rceil
6第 6 页 · 循环的汇编语言表示-3

循环表示的体系结构背景

前面几页我们用条件跳转拼出了三种循环。x86 其实藏了一条专用 LOOP 指令,但它在现代代码里几乎绝迹——这条指令的命运,折射出 CPU 微架构二十年来的演进方向。

LOOP 指令族
LOOP/LOOPE/LOOPNE 自减 ECX 并按 ZF 决定跳转,x86 原生循环原语
微架构代价
现代 CPU 解码 LOOP 比 DEC+JNZ 更慢,且强制占用 ECX,编译器基本弃用
后向分支预测
CPU 为「地址变小」的循环回跳维护专用预测表,命中率可达 95% 以上
循环展开
编译器把循环体复制多份拼成直线代码,用掉回跳以减少分支开销
老式缝纫机的脚踏板对应 →LOOP 专用指令

硬件一键完成'减一+判断+跳转',但电动缝纫机靠微处理器分步控制反而更快

7第 7 页 · 循环表示的体系结构背景

Switch的汇编语言表示-1

前面循环讲的是「跳回去反复执行」。switch 是另一种控制流:判断一次,跳到 N 个分支中的某一个。case 密集时编译器有个核心武器——跳转表,本页拆给你看。

多路分支
switch 一次判定跳到任意多个标号之一,非二选一
跳转表策略
case 密集时编译器用一张表取代决策树
跳转表本质
下标=case值减最小值,元素=对应代码段地址
策略边界
case 稀疏或跨度大时退化为 if-else 链或二分查找
大楼楼层指引牌对应 →跳转表

楼层号是下标,牌上写的房间号是目标地址,查一次直达

target=jtbase+4×(valuelowest_case)target = jt_base + 4 \times (\text{value} - \text{lowest\_case})
8第 8 页 · Switch的汇编语言表示-1

Switch的汇编语言表示-2

Switch的汇编语言表示-2:定义、要点与典型应用

Switch的汇编语言表示-2
Switch的汇编语言表示-2:定义、要点与典型应用
9第 9 页 · Switch的汇编语言表示-2

Switch的汇编语言表示-3

上页讲密集case用跳表。但工程里case经常是稀疏的——比如错误码1、404、500——这种时候GCC怎么决定?

范围-密度权衡
跳表大小等于 max-min+1,case稀疏时空耗巨大;GCC退回if-else或二分搜索
决策启发式
case多且范围相对小→跳表;case少或范围巨大→if-else;中间地带→二分查找
二分搜索折中
对N个case做log₂N次比较,N越大越划算,处理中等稀疏度
偏移压缩
可先 sub min 压缩范围再跳表,但额外减法指令也有开销,要权衡
酒店分配房号对应 →case值与跳表项

3位客人住1、404、500号;做1~500索引表浪费,不如逐个问

跳表空间=R+1vsif-else比较次数=N\text{跳表空间} = R+1 \quad\text{vs}\quad \text{if-else比较次数} = N
10第 10 页 · Switch的汇编语言表示-3

本节要点

  • 分支预测失误代价高,cmov正是为此设计
  • while/for/do-while编译后形态趋同
  • Switch跳表用连续存储实现O(1)定位
  • 稀疏case用决策树,密集case用跳表
  • 汇编是编译器的策略选择,非唯一翻译
延伸主题:函数调用的栈帧与调用约定结构体与数组的汇编布局缓冲区溢出攻击原理
11第 11 页 · 本节要点

课后思考

先独立思考再看参考答案,三题层层递进:回顾、应用、边界。

1为什么条件移动指令cmov不允许两个操作数都是立即数?这暴露了什么硬件约束?

参考答案cmov依赖标志位寄存器判断条件。两个立即数做算术后旧标志位已被改写,cmov无法据此判断;必须先算出结果再cmov选择,硬件就无法直接处理两个立即数的情形。

2跳转表实现switch时,若case值极稀疏,编译器会退回什么方案?为什么?

参考答案退回一连串if-else比较。跳转表大小与case取值范围成正比,稀疏时表大到浪费缓存;case数少时分支预测友好、代码更紧凑,编译器倾向于顺序比较。

3用条件移动实现abs函数看似优雅,编译器在什么情况下会放弃它、退回分支?

参考答案当分支预测准确率高时,普通分支代价极低;而cmov无条件执行两条路径,性能反而更差。此时cmov的恒定开销超过分支的偶发错误开销,编译器退回分支。

12第 12 页 · 课后思考