第13章:静态分析基础

静态分析是程序行为分析的重要支柱,它在不执行程序的情况下推理程序的可能行为。与前面章节介绍的动态profiling技术相比,静态分析提供了一种互补的视角:动态分析告诉我们程序实际做了什么,而静态分析告诉我们程序可能做什么。本章将介绍静态分析的基础概念和核心数据结构,包括控制流图、支配关系、SSA形式等,这些是理解后续高级静态分析技术的基石。

静态分析的本质与挑战

静态分析的核心任务是在编译时推理程序的运行时行为。这种"预测"面临着根本性的理论限制:莱斯定理告诉我们,任何关于程序行为的非平凡性质都是不可判定的。因此,实用的静态分析必须在精确性、可靠性和可扩展性之间寻求平衡。

精确性与可靠性的权衡

  • 过近似(Over-approximation):保守分析,确保包含所有可能行为,可能产生误报(false positive)
  • 欠近似(Under-approximation):乐观分析,只报告确定的行为,可能产生漏报(false negative)
  • 实用策略:大多数编译优化使用过近似确保正确性,而bug查找工具可能使用欠近似减少误报

分析的可扩展性挑战

  • 状态空间爆炸:程序路径数量随分支呈指数增长
  • 过程间分析:函数调用创造了上下文敏感性需求
  • 内存建模:指针和动态内存使分析复杂化
  • 并发程序:线程交错产生组合爆炸

静态分析的分类维度

理解静态分析技术需要从多个维度进行分类:

流敏感性(Flow Sensitivity)

  • 流敏感分析:考虑语句执行顺序,为每个程序点维护不同的分析状态
  • 流不敏感分析:忽略语句顺序,整个程序使用单一分析状态
  • 路径敏感分析:区分不同执行路径,最精确但开销最大

上下文敏感性(Context Sensitivity)

  • 上下文敏感:区分不同调用点的函数调用
  • 上下文不敏感:合并所有调用点的信息
  • k-CFA:维护k层调用栈信息的有限上下文敏感

堆抽象策略

  • 基于分配点:相同分配点的对象合并为一个抽象对象
  • 基于类型:相同类型的对象共享抽象
  • 基于形状:使用形状图表示堆结构

分析范围

  • 过程内分析(Intraprocedural):单个函数内部
  • 过程间分析(Interprocedural):跨函数边界
  • 全程序分析(Whole-program):考虑整个程序

静态分析基础设施

现代编译器中的静态分析建立在一系列基础设施之上:

中间表示(IR)选择

  • 三地址码:简单,便于分析,常用于优化
  • 控制流图:显式表示控制流,本章重点
  • SSA形式:简化数据流分析,现代编译器标配
  • 抽象语法树:保留源码结构,适合源码级分析

符号信息管理

  • 符号表:变量、函数、类型信息的中心存储
  • 类型系统:类型信息指导分析精度
  • 调试信息:源码位置映射,错误报告需要

分析框架组件

  • 工作列表算法:管理分析的迭代过程
  • 传递函数:描述程序语句的语义效果
  • 汇合操作:合并不同路径的分析信息
  • 加宽操作:确保分析终止的抽象技术

控制流图构建

控制流图(Control Flow Graph, CFG)是静态分析的基础数据结构,它将程序表示为一个有向图,其中节点代表基本块,边代表控制流转移。CFG提供了程序结构的抽象视图,隐藏了不影响控制流的细节,使得分析算法可以高效地推理程序行为。

CFG的形式化定义

控制流图G = (N, E, entry, exit)是一个有向图,其中:

  • N:基本块节点集合
  • E ⊆ N × N:控制流边集合
  • entry ∈ N:唯一入口节点
  • exit ∈ N:唯一出口节点(可能是虚拟的)

CFG的性质

  • 可达性:从entry可达所有节点(除了死代码)
  • 终止性:所有节点都可达exit(除了无限循环)
  • 确定性:每个节点的后继由其最后一条指令决定

基本块识别

基本块是最大的顺序执行指令序列,具有以下特性:

  • 只有一个入口点(第一条指令)
  • 只有一个出口点(最后一条指令)
  • 内部没有分支或跳转目标

基本块识别算法通过扫描指令序列,识别以下位置作为基本块边界:

  1. 程序入口点
  2. 分支指令的目标地址
  3. 紧跟在分支指令后的地址

领导者(Leader)识别:基本块的第一条指令称为领导者。识别算法的核心是找出所有领导者:

  • 第一条指令是领导者
  • 任何条件或无条件跳转的目标指令是领导者
  • 紧跟在条件跳转后的指令是领导者

基本块构建过程

  1. 扫描所有指令,标记领导者
  2. 对每个领导者,其基本块包含从该领导者到下一个领导者之前(或程序结束)的所有指令
  3. 建立基本块之间的前驱后继关系

特殊情况处理

  • 空基本块:某些控制流结构可能产生空基本块,通常可以优化掉
  • 单指令基本块:只包含一条跳转指令的基本块
  • Fall-through块:没有显式跳转,自然流向下一个基本块

基本块的内部结构

虽然基本块内部是顺序执行的,但仍需要仔细设计其表示:

指令链表示

  • 双向链表:支持高效的插入、删除和遍历
  • 指令索引:快速定位特定指令
  • 定义-使用信息:每条指令维护其定义和使用的变量集合

基本块属性

  • 活跃信息:入口和出口的活跃变量集
  • 支配信息:支配者和被支配者集合
  • 循环信息:所属循环和嵌套深度
  • 频率信息:执行频率估计(用于优化)

基本块优化

  • 死代码消除:删除无用指令
  • 局部CSE:消除块内公共子表达式
  • 指令调度:重排指令提高性能
  • 窥孔优化:局部模式匹配和替换

控制流边类型

CFG中的边表示不同类型的控制流转移,每种边类型都有其特定的语义和分析含义:

顺序流(Fall-through):基本块按照程序顺序执行到下一个基本块。

  • 特征:隐式控制流,无需显式跳转指令
  • 优化机会:可以合并相邻基本块
  • 布局影响:代码布局时优先保持fall-through减少跳转

条件分支(Conditional Branch):根据条件选择不同的执行路径,通常产生两条出边。

  • 分支预测:使用profile信息标注分支概率
  • 分支简化:常量条件可以转换为无条件跳转
  • 关键边:从多后继节点到多前驱节点的边需要特殊处理

无条件跳转(Unconditional Jump):直接跳转到目标基本块。

  • 跳转优化:跳转到跳转可以简化
  • 死代码识别:跳转后的代码可能不可达
  • 尾调用优化:特殊形式的无条件跳转

函数调用(Function Call):调用其他函数,涉及interprocedural分析时需要特殊处理。

  • 调用图构建:需要解析可能的调用目标
  • 参数传递:建模参数和返回值的数据流
  • 异常传播:被调函数可能抛出异常

异常边(Exception Edge):表示可能的异常抛出路径。

  • 隐式异常:除零、空指针等运行时异常
  • 显式异常:throw语句产生的异常
  • 精度权衡:精确异常分析代价高昂

间接跳转(Indirect Jump):跳转目标在运行时确定。

  • 跳转表:switch语句的常见实现
  • 函数指针:需要指针分析确定目标
  • 虚函数调用:需要类型分析确定目标集

返回边(Return Edge):函数返回到调用点。

  • 多返回点:一个函数可能有多个return语句
  • 异常返回:通过异常机制返回
  • 尾调用:优化后可能没有显式返回

CFG表示方法

CFG可以使用多种数据结构表示:

邻接表:每个基本块维护其后继列表,空间效率高,适合稀疏图。

  • 优点:空间复杂度O(V+E),添加边操作高效
  • 缺点:判断两节点是否相邻需要O(degree)时间
  • 适用场景:大多数实际程序的CFG是稀疏的

邻接矩阵:使用二维数组表示边关系,访问效率高但空间开销大。

  • 优点:O(1)时间判断连接关系,矩阵运算友好
  • 缺点:空间复杂度O(V²),不适合大型程序
  • 适用场景:小型函数的密集控制流图

边列表:显式存储所有边,便于遍历所有控制流转移。

  • 优点:遍历所有边最高效,便于边属性存储
  • 缺点:查找特定节点的邻居效率低
  • 适用场景:需要频繁遍历所有边的算法

混合表示:实际编译器often使用混合方案:

  • 基本块对象包含前驱和后继指针列表
  • 边对象存储额外属性(如分支概率)
  • 全局边列表用于特定遍历需求

特殊控制流处理

现代编程语言包含多种复杂控制流结构,每种都需要特殊的CFG建模策略:

Switch语句:可能产生多个分支,需要创建到每个case标签的边。

  • 密集switch:使用跳转表实现,所有case共享一个间接跳转
  • 跳转表索引计算:table[expr - min_case]
  • 边界检查:确保索引在有效范围内
  • CFG建模:从switch节点到每个case的直接边
  • 稀疏switch:编译为if-else链,每个比较产生一个分支
  • 二分查找优化:对case值排序减少比较次数
  • 决策树生成:平衡比较次数和代码大小
  • CFG复杂度:O(log n)层的二叉树结构
  • 分析策略:保守假设所有case都可达,或使用值范围分析优化
  • 常量传播:确定switch表达式值时简化CFG
  • 不可达case消除:基于类型或范围分析
  • Default处理:确保完整的case覆盖

异常处理:try-catch块需要添加从可能抛出异常的指令到catch块的边。

  • 显式异常:throw语句产生确定的异常边
  • 异常对象创建点
  • 栈展开路径
  • Catch匹配顺序
  • 隐式异常:数组访问、空指针解引用等可能抛出异常
  • 异常点识别:哪些操作可能失败
  • 精度选择:全部建模vs仅关键异常
  • 路径爆炸控制:合并相似异常路径
  • 异常传播:需要考虑异常在调用链中的传播路径
  • 函数异常规范:noexcept/throws声明
  • 跨函数边界:调用点的异常处理
  • 异常类型层次:catch的多态匹配
  • Finally块:无论是否异常都执行,需要特殊的CFG建模
  • 多入口:正常路径和异常路径
  • 多出口:继续传播异常或正常返回
  • 嵌套finally:正确的执行顺序

函数指针:间接调用需要进行指针分析确定可能的目标。

  • 类型分析:基于函数签名缩小可能目标集合
  • 函数类型兼容性检查
  • 参数和返回值匹配
  • 调用约定一致性
  • 流敏感分析:追踪函数指针的赋值来源
  • 定义-使用链跟踪
  • 路径敏感的指针值
  • 上下文敏感的分析
  • 保守处理:无法确定时假设可调用任何类型兼容的函数
  • 函数逃逸分析
  • 外部函数处理
  • 动态加载考虑

虚函数调用:需要类层次分析确定可能的调用目标。

  • CHA(Class Hierarchy Analysis):基于类继承关系的快速分析
  • 构建完整类层次图
  • 查找所有可能的重写
  • 保守但快速
  • RTA(Rapid Type Analysis):只考虑实际创建的类型
  • 收集所有new/malloc点
  • 过滤未实例化的类
  • 更精确的调用目标
  • 流敏感分析:精确追踪对象类型信息
  • 对象创建点追踪
  • 类型测试和转换
  • 虚表指针分析

协程和生成器

  • Yield点:既是退出点也是重入点
  • 保存点:局部变量和执行位置
  • 恢复点:从yield后继续
  • CFG分割:yield将函数分成多段
  • 状态机转换:协程可建模为状态机,每个yield对应一个状态
  • 状态变量:记录当前执行位置
  • 状态转换:switch-case实现跳转
  • 局部变量持久化:跨yield保持值
  • 多入口函数:resume操作需要特殊的CFG入口处理
  • 入口分发:根据状态跳转到正确位置
  • 参数传递:yield值和resume参数
  • 终止处理:生成器耗尽的边

循环结构的特殊形式

  • Do-while循环:至少执行一次的语义
  • 循环体在条件检查前
  • 回边从条件检查到循环头
  • For-each循环:迭代器模式的语法糖
  • 迭代器初始化
  • hasNext/next调用序列
  • 隐式异常处理
  • 无限循环:显式的无出口循环
  • 可达性分析特例
  • 优化机会:循环不变量外提
  • 终止分析:证明循环终止

支配关系与循环识别

支配关系是理解程序结构的关键概念,对于优化和程序理解都至关重要。支配关系揭示了程序中的必经路径,这些信息对于代码移动、冗余消除和并行化等优化至关重要。

支配关系定义

在CFG中,如果从入口节点到节点n的每条路径都必须经过节点d,则称d支配n(d dominates n)。支配关系具有以下性质:

  • 自反性:每个节点支配自己
  • 传递性:如果a支配b,b支配c,则a支配c
  • 反对称性:如果a支配b且b支配a,则a=b

严格支配(Strict Dominance):如果d支配n且d≠n,则d严格支配n。这个概念在构建支配树时很重要。

支配者集合:节点n的支配者集合dom(n)包含所有支配n的节点。计算公式:

  • dom(entry) = {entry}
  • dom(n) = {n} ∪ (∩{dom(p) | p是n的前驱})

支配关系的应用

  • 不变代码外提:循环不变量必须在循环的支配节点定义
  • SSA构建:φ函数放置位置由支配边界决定
  • 死代码消除:如果定义点不支配任何使用点,则可能是死代码

支配关系的高效计算

计算支配关系的朴素算法是迭代求解数据流方程,但这可能需要O(n³)时间。现代编译器使用更高效的算法:

Cooper-Harvey-Kennedy算法

  • 简单直观的实现
  • 实践中接近线性时间
  • 适合教学和小规模程序

支配关系的增量维护: 当CFG发生变化时,增量更新支配关系比完全重算更高效:

  • 边添加:可能减少某些节点的支配者
  • 边删除:可能增加某些节点的支配者
  • 节点添加/删除:局部重算受影响区域

立即支配者

节点n的立即支配者(immediate dominator)是除n外支配n的所有节点中最接近n的节点。立即支配关系构成一棵树,称为支配树。

支配树性质

  • 每个节点(除入口外)有唯一的立即支配者
  • 支配树的祖先-后代关系对应CFG中的支配关系
  • 支配树的深度影响许多算法的效率

支配树的构建通常使用Lengauer-Tarjan算法,其时间复杂度接近线性。算法核心思想是:

  1. 对CFG进行深度优先遍历,建立DFS树
  2. 使用半支配者(semi-dominator)概念简化计算
  3. 通过路径压缩优化查找效率

Lengauer-Tarjan算法详解

  • 半支配者定义:sdom(w) = min{v | 存在路径v=v0→v1→...→vk=w,其中对于1≤i≤k-1,DFS序号(vi)>DFS序号(w)}
  • 计算过程: 1. DFS编号:对CFG进行DFS,为每个节点分配序号 2. 半支配者计算:按DFS逆序处理节点 3. 隐式评估:使用路径压缩加速祖先查询 4. 显式计算:从半支配者推导立即支配者

支配边界(Dominance Frontier): 节点n的支配边界DF(n)是所有这样的节点y:n支配y的某个前驱但不严格支配y。形式化定义: DF(n) = {y | ∃p∈pred(y), n支配p且n不严格支配y}

支配边界的计算对SSA构建至关重要,φ函数需要放置在变量定义点的支配边界上。

自然循环识别

循环是程序中的重要结构,识别循环对于优化至关重要。自然循环的定义基于支配关系:

回边(Back Edge):从节点n到节点h的边,其中h支配n。

自然循环:由回边n→h定义的循环包含h(循环头)以及所有能够到达n而不经过h的节点。

循环识别算法:

  1. 计算支配关系
  2. 识别所有回边
  3. 对每个回边,计算其对应的自然循环

循环嵌套分析

程序中的循环经常嵌套出现,理解嵌套关系对于循环优化很重要:

循环嵌套深度:节点所属的最内层循环的嵌套层次。

  • 深度0:不在任何循环中
  • 深度k:在k层嵌套循环中
  • 用于指导寄存器分配和指令调度优先级

循环森林:将所有循环组织成森林结构,父子关系表示嵌套关系。

  • 根节点:最外层循环
  • 叶节点:最内层循环
  • 兄弟关系:同层次的独立循环

循环属性分析

  • 循环不变量:在循环执行过程中值不变的表达式
  • 归纳变量:每次迭代以固定步长变化的变量
  • 循环界限:循环执行次数的上下界估计

不可归约循环:某些由goto语句产生的循环结构不满足自然循环定义,需要特殊处理。

  • 特征:多个入口节点,没有唯一的循环头
  • 检测:强连通分量中没有节点支配所有其他节点
  • 处理方法
  • 节点分裂:复制多入口节点
  • T1-T2变换:系统化的图变换方法
  • 直接分析:某些算法可以处理不可归约图

循环优化机会识别

  • 完美嵌套:内层循环体只包含循环语句
  • 循环融合候选:相邻且迭代空间相同的循环
  • 循环分裂点:具有不同数据访问模式的语句组

SSA形式与use-def链

静态单赋值(Static Single Assignment, SSA)形式是现代编译器广泛使用的中间表示,它简化了许多分析和优化。

SSA的核心特性

在SSA形式中,每个变量只被赋值一次。这带来以下优势:

  • use-def关系变得显式且唯一
  • 简化了数据流分析
  • 便于实现各种优化

SSA对优化的影响

  1. 常量传播:直接替换使用点,无需考虑中间重定义
  2. 死代码消除:没有使用者的定义可直接删除
  3. 值编号:相同计算的识别变得简单
  4. 代码移动:依赖关系明确,便于安全地移动指令

SSA的变体

  • 剪枝的SSA:只在真正需要的地方插入φ函数
  • 静态单信息(SSI):每个使用点也只有一个版本
  • 门控SSA:添加路径条件信息

φ函数的作用

当控制流汇合时,需要φ函数来选择正确的变量版本。φ函数是一个虚拟操作,根据控制流的来源选择相应的值。

例如,在if-then-else结构后:

x3 = φ(x1, x2)

表示如果从then分支来,x3取x1的值;如果从else分支来,x3取x2的值。

φ函数的语义

  • 参数顺序:φ函数参数与CFG前驱一一对应
  • 执行时机:概念上在基本块开始处同时执行
  • 活跃性:只有实际执行的路径对应的参数才被选择

φ函数的优化

  • 冗余φ消除:所有参数相同的φ可以删除
  • 死φ消除:结果未被使用的φ可以删除
  • φ合并:连续的φ操作可能被合并

φ函数的实现

  • 在代码生成阶段需要消除φ函数
  • 通常通过插入拷贝指令实现
  • 需要处理φ函数之间的依赖关系

SSA构建算法

SSA构建分为两个主要步骤:

φ函数插入:使用支配边界(dominance frontier)确定需要插入φ函数的位置。

迭代支配边界算法

  1. 对每个定义变量v的节点n: - 将n加入工作列表W - 记录n为v的定义节点
  2. 当W非空时: - 取出节点n - 对DF(n)中的每个节点y:
    • 如果y未为v插入过φ函数:
    • 在y插入v的φ函数
    • 如果y不是v的原始定义点,将y加入W

变量重命名:对每个变量的定义和使用进行重命名,确保每个定义都有唯一的名字。

重命名算法

  1. 初始化: - 为每个变量维护一个版本栈 - 为每个变量维护一个计数器
  2. DFS遍历支配树: - 进入节点时:
    • 处理φ函数:创建新版本并压栈
    • 处理普通赋值:创建新版本并压栈
    • 处理变量使用:使用栈顶版本
    • 离开节点时:
    • 弹出该节点压入的所有版本

剪枝优化

  • 活跃变量分析:只为汇合点活跃的变量插入φ
  • 半剪枝:结合活跃信息和支配边界
  • 最小φ插入:只在真正需要的位置插入

use-def链构建

在SSA形式下,use-def链的构建变得简单:

  • 每个使用点直接指向唯一的定义
  • def-use链通过遍历所有使用点构建
  • 支持高效的死代码消除和常量传播

数据结构设计

  • Use节点:记录使用位置和指向定义的指针
  • Def节点:记录定义位置和使用者列表
  • 双向链接:便于快速查询和更新

SSA下的传统分析

  1. 可达定义:在SSA中退化为use-def链
  2. 活跃变量:直接遍历def-use链
  3. 可用表达式:基于值编号的高效实现

SSA的退出

  • 关键边分裂:在前驱块末尾插入拷贝
  • 并行拷贝序列化:处理φ函数间的循环依赖
  • 合并干扰:减少额外的拷贝操作

基本块与程序点

程序点是静态分析中的基本概念,表示程序执行过程中的特定位置。

程序点粒度

程序点可以有不同的粒度级别:

指令级:每条指令前后都是程序点,提供最精确的分析。

  • 优点:精度最高,可以捕捉细微的状态变化
  • 缺点:分析复杂度高,状态空间巨大
  • 适用:安全关键代码、精确错误定位

基本块级:只在基本块边界设置程序点,降低分析复杂度。

  • 优点:平衡精度和效率,广泛使用
  • 缺点:无法捕捉块内中间状态
  • 适用:大多数数据流分析问题

过程级:在函数调用边界设置程序点,用于过程间分析。

  • 优点:可扩展性好,适合大型程序
  • 缺点:精度损失严重
  • 适用:模块化分析、接口检查

混合粒度

  • 热点代码使用细粒度
  • 冷代码使用粗粒度
  • 动态调整分析精度

基本块内分析

基本块内部的分析相对简单,因为执行是顺序的:

  • 可以使用简单的符号执行
  • 传递函数通常是多个指令效果的组合
  • 不需要考虑控制流合并

传递函数组合: 对于基本块B = [s1, s2, ..., sn],其传递函数: F_B = F_sn ∘ F_{sn-1} ∘ ... ∘ F_s2 ∘ F_s1

块内优化机会

  1. 窗口优化:局部观察相邻指令
  2. 代数简化:利用代数恒等式
  3. 强度削减:替换昂贵操作
  4. 指令选择:选择最优指令序列

特殊指令处理

  • 内存操作:考虑别名关系
  • 函数调用:处理副作用
  • 浮点运算:考虑精度和舍入

程序点标记

为了高效索引和引用,程序点需要系统的标记方案:

  • 使用基本块ID和块内偏移的组合
  • 为特殊位置(如函数入口/出口)预留特殊ID
  • 支持快速的前驱/后继查询

标记方案设计

  1. 分层标记: - 高位:基本块ID - 低位:块内偏移 - 特殊位:ENTRY=0, EXIT=MAX

  2. 索引结构: - 基本块到程序点范围的映射 - 程序点到指令的映射 - 前驱/后继关系缓存

  3. 压缩表示: - 对于稀疏程序点使用压缩表示 - 区间编码优化存储 - 位向量表示程序点集合

块间信息传递

分析信息在基本块间的传递遵循数据流方程:

  • OUT[B] = gen[B] ∪ (IN[B] - kill[B])
  • IN[B] = ∪(OUT[P]) for all predecessors P

传递函数需要正确处理:

  • φ函数的语义
  • 异常边的特殊情况
  • 函数调用的影响

数据流方程分类

  1. 前向分析: - 信息沿控制流方向传播 - 例子:可达定义、可用表达式 - 方程:IN[B] = ∪{OUT[P] | P ∈ pred(B)}

  2. 后向分析: - 信息逆控制流方向传播 - 例子:活跃变量、很忙表达式 - 方程:OUT[B] = ∪{IN[S] | S ∈ succ(B)}

  3. 双向分析: - 结合前向和后向信息 - 例子:部分冗余消除

汇合操作的选择

  • 并集(∪):Must分析,保守假设
  • 交集(∩):May分析,乐观假设
  • 自定义:基于格理论的一般化

特殊情况处理

  1. 异常边: - 可能不执行完整基本块 - 需要特殊的kill/gen集合

  2. 函数调用: - 保守假设:杀死所有可能修改的值 - 过程间分析:使用摘要信息

  3. 循环入口: - 迭代求解不动点 - 加速收敛技术

本章小结

本章介绍了静态分析的基础概念和核心数据结构:

  1. 控制流图(CFG):程序的图表示,包含基本块识别、边类型分类、特殊控制流处理等关键技术。CFG是几乎所有静态分析的基础。

  2. 支配关系:描述了CFG中节点间的必经关系,支配树的构建使用Lengauer-Tarjan算法实现近线性时间复杂度。支配边界概念是SSA构建的关键。

  3. 循环识别:基于回边和支配关系识别自然循环,循环嵌套分析帮助理解程序结构,为循环优化提供基础。

  4. SSA形式:每个变量只赋值一次的程序表示,通过φ函数处理控制流汇合,简化了use-def链的构建和许多优化算法。

  5. 程序点:分析的基本单位,可以有不同粒度。基本块内分析相对简单,块间传递需要处理数据流方程。

这些概念相互关联:CFG提供程序结构,支配关系帮助理解控制依赖,SSA简化数据依赖分析,程序点定义分析精度。掌握这些基础对于理解后续的数据流分析、abstract interpretation等高级技术至关重要。

练习题

基础题

练习 13.1:基本块识别 给定以下伪代码,识别所有基本块并画出CFG:

1:  x = 10
2:  if x > 5:
3:    y = x * 2
4:    if y > 15:
5:      z = y - 10
6:    else:
7:      z = y + 10
8:  else:
9:    z = x
10: print(z)

Hint:先找出所有分支指令和跳转目标,然后确定基本块边界。

参考答案

基本块划分:

  • B1: 行1-2(到if判断)
  • B2: 行3-4(then分支开始到内层if)
  • B3: 行5(内层then分支)
  • B4: 行7(内层else分支)
  • B5: 行9(外层else分支)
  • B6: 行10(最终汇合点)

控制流边:

  • B1 → B2(条件true)
  • B1 → B5(条件false)
  • B2 → B3(内层条件true)
  • B2 → B4(内层条件false)
  • B3 → B6
  • B4 → B6
  • B5 → B6

练习 13.2:支配关系计算 对于练习13.1中的CFG,计算: a) 每个节点的支配者集合 b) 每个节点的立即支配者 c) 画出支配树

Hint:从入口节点开始,考虑到达每个节点的所有路径。

参考答案

a) 支配者集合:

  • dom(B1) = {B1}
  • dom(B2) = {B1, B2}
  • dom(B3) = {B1, B2, B3}
  • dom(B4) = {B1, B2, B4}
  • dom(B5) = {B1, B5}
  • dom(B6) = {B1, B6}

b) 立即支配者:

  • idom(B2) = B1
  • idom(B3) = B2
  • idom(B4) = B2
  • idom(B5) = B1
  • idom(B6) = B1

c) 支配树:

    B1
   /|\\
  B2 B5 B6
 / \
B3  B4

练习 13.3:SSA形式转换 将以下代码片段转换为SSA形式:

x = 5
y = x + 1
if condition:
    x = y * 2
    y = 10
else:
    y = x - 1
z = x + y

Hint:首先识别需要φ函数的位置,然后进行变量重命名。

参考答案

SSA形式:

x1 = 5
y1 = x1 + 1
if condition:
    x2 = y1 * 2
    y2 = 10
else:
    y3 = x1 - 1
x3 = φ(x2, x1)  // 从if分支来取x2,从else分支来取x1
y4 = φ(y2, y3)  // 从if分支来取y2,从else分支来取y3
z1 = x3 + y4

注意x在else分支中没有被重新定义,所以φ函数选择x1。

挑战题

练习 13.4:循环识别算法 设计一个算法,给定CFG和支配关系,识别所有自然循环。你的算法应该: a) 找出所有回边 b) 对每个回边计算对应的循环体 c) 处理嵌套循环

Hint:回边是从被支配者到支配者的边。循环体包含所有能到达回边源而不经过循环头的节点。

参考答案

算法步骤:

  1. 识别回边:对每条边(n→h),如果h支配n,则是回边
  2. 对每个回边(n→h)计算循环: - 初始化循环体L = {h, n} - 工作列表W = {n} - while W不空:
    • 取出节点m
    • 对m的每个前驱p:
    • 如果p不在L中,加入L和W
  3. 循环嵌套:如果循环L1的头支配循环L2的头,且L2⊆L1,则L2嵌套在L1中

时间复杂度:O(E×N),其中E是边数,N是节点数。

练习 13.5:SSA破坏性更新处理 考虑数组和对象字段的SSA表示。如何处理以下情况:

a[i] = 10
x = a[j]
a[k] = 20
y = a[j]

其中i, j, k的关系在编译时未知。

Hint:考虑使用特殊的函数来建模数组更新和访问。

参考答案

使用以下方案处理数组SSA:

  1. 引入两个特殊函数: - update(array, index, value):返回更新后的数组 - access(array, index):返回数组元素

  2. 转换后的SSA形式:

a1 = update(a0, i, 10)
x1 = access(a1, j)
a2 = update(a1, k, 20)
y1 = access(a2, j)
  1. 别名分析: - 如果能证明i≠j且k≠j,则x1=10,y1=10 - 如果能证明k=j,则y1=20 - 否则需要保守假设y1可能是10或20

  2. 优化机会: - 冗余数组访问消除 - 数组拷贝传播 - 基于别名分析的常量传播

练习 13.6:不可归约CFG处理 某些CFG包含不可归约的控制流(例如由goto产生)。设计一种方法将不可归约CFG转换为可归约形式。

Hint:考虑节点分裂或添加预处理节点。

参考答案

处理不可归约CFG的方法:

  1. 检测不可归约性: - 构建支配树 - 检查是否存在交叉边(既不是树边也不是回边的边)

  2. 节点分裂法: - 识别多入口强连通分量 - 对多入口节点进行分裂,为每个入口创建副本 - 更新边关系,使每个副本只有一个入口

  3. DJ图方法: - 构建DJ图(Dominator-Join图) - 使用DJ图指导节点分裂,最小化分裂次数

  4. 实际考虑: - 节点分裂会增加代码大小 - 可能影响某些优化的效果 - 某些分析可以直接处理不可归约CFG

  5. 示例转换: 原始不可归约循环可能因为有多个入口,分裂后每个入口对应一个循环副本,形成可归约结构。

练习 13.7:增量式SSA维护 当CFG发生局部修改(如添加/删除基本块)时,如何高效地更新SSA形式而不完全重建?

Hint:考虑受影响的支配边界和φ函数位置。

参考答案

增量SSA更新策略:

  1. CFG修改类型: - 边添加/删除 - 基本块添加/删除 - 基本块内指令修改

  2. 支配关系更新: - 使用增量支配树算法 - 只重新计算受影响的节点 - 维护支配边界的增量更新

  3. φ函数调整: - 边添加:可能需要添加新的φ函数操作数 - 边删除:移除对应的φ函数操作数 - 新汇合点:插入必要的φ函数

  4. 变量重命名: - 维护定义栈的快照 - 从修改点开始局部重命名 - 使用时间戳避免全局遍历

  5. 优化机会: - 缓存支配边界计算 - 延迟φ函数清理 - 批量处理多个修改

  6. 正确性保证: - 验证use-def链完整性 - 确保φ函数参数与前驱匹配 - 检查支配性质保持

常见陷阱与错误

CFG构建陷阱

异常边遗漏:忘记为可能抛出异常的指令添加到异常处理器的边,导致CFG不完整。

间接跳转处理:函数指针、虚函数调用、switch语句的跳转表可能有多个目标,需要保守处理或进行额外分析。

库函数假设:错误假设标准库函数不会抛出异常或修改全局状态。

支配关系错误

入口节点遗漏:某些分析可能有多个入口(如异常处理器),需要添加虚拟入口节点。

不可达代码:不可达代码没有支配者,需要特殊处理或预先删除。

增量更新错误:CFG修改后没有正确更新支配关系,导致后续分析错误。

SSA构建问题

φ函数过度插入:在不必要的位置插入φ函数,增加程序复杂度。可以使用剪枝算法优化。

临界边处理:从有多个后继的节点到有多个前驱的节点的边(临界边)需要分裂以正确放置φ函数。

内存SSA错误:对内存操作的SSA建模不当,忽略了别名关系。

循环分析陷阱

不可归约循环:假设所有循环都是自然循环,遇到goto形成的复杂控制流时分析失败。

循环出口识别:多出口循环的处理,需要正确识别所有离开循环的边。

无限循环处理:某些循环可能没有出口,需要特殊标记。

最佳实践检查清单

CFG设计审查

  • [ ] CFG是否完整包含所有可能的控制流路径?
  • [ ] 异常边是否正确建模?
  • [ ] 函数调用和返回是否正确处理?
  • [ ] 是否有效处理了间接控制流?
  • [ ] 基本块划分是否最大化了块大小?

支配分析质量

  • [ ] 支配树构建算法是否使用了高效实现(如Lengauer-Tarjan)?
  • [ ] 支配边界计算是否正确且高效?
  • [ ] 是否缓存了支配信息以避免重复计算?
  • [ ] 增量更新策略是否正确维护了支配性质?

SSA形式规范

  • [ ] 每个变量是否确实只被赋值一次?
  • [ ] φ函数参数是否与CFG前驱一一对应?
  • [ ] 是否正确处理了未初始化变量?
  • [ ] SSA形式是否是最简的(剪枝的)?
  • [ ] 是否有从SSA形式退出的策略?

循环优化准备

  • [ ] 所有循环是否被正确识别和分类?
  • [ ] 循环嵌套关系是否正确?
  • [ ] 循环不变量是否被识别?
  • [ ] 循环边界和步长是否可分析?
  • [ ] 是否识别了循环携带依赖?

性能考虑

  • [ ] 数据结构选择是否适合程序规模?
  • [ ] 是否实现了必要的缓存机制?
  • [ ] 大型程序的可扩展性如何?
  • [ ] 是否有内存使用的上限控制?
  • [ ] 增量分析是否真正比完全重分析更高效?