想象你是一位新上任的餐厅后厨经理,面前摆着一份厚厚的菜谱——这就是程序员常说的“源码”。光看文字,你能一眼看出哪道菜先做、哪道菜后做、盐从哪里取、又要加进哪口锅吗?很难。所以有经验的经理会先请人画三张图纸:第一张是厨房布局图,标出冰箱、灶台、烤箱的位置与嵌套关系;第二张是出菜流程图,画清从接单到装盘的顺序,以及“如果客人忌辣就走清淡路线”这类分支;第三张是食材追踪表,记录每一份食材从入库、加工到上桌的每一站。 这三张图纸,对应程序分析中的三个核心概念:AST 抽象语法树、CFG 控制流图和 DFG 数据流图。它们分别回答:代码“长什么样”、“怎么执行”、“数据怎么流动”。本文就用这个厨房比喻,带你从零看懂这三张图,并了解 AI 如何借助它们理解代码、查找缺陷甚至挖掘漏洞。

岗位关联

掌握这三种图结构,是你进入代码智能领域的“三张入场券”——它串起了语法分析、程序分析、图神经网络与各类工程工具,也是 AI 程序员与普通开发者拉开差距的分水岭。

目录
  1. 可视化演示
  2. AST抽象语法树
  3. CFG控制流图
  4. DFG数据流图
  5. 三图构建方法
  6. 应用场景
  7. 图神经网络
  8. 验证与部署
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示AST抽象语法树节点层级结构、CFG控制流图基本块分支路径、DFG数据流图定义使用依赖及三图派生关联与跨图融合,含流动波形背景动画。

本节配有交互动效(进入视口后自动加载,约 3KB)。

AST抽象语法树

新经理拿到菜谱后,第一件事不是读文字,而是请人画出厨房布局图:灶台在哪个区域、烤箱属于哪个工作站、备菜区和出菜区如何嵌套。这张图有意忽略菜谱上的装饰性文字和多余空格,只保留“谁包含谁”的结构。AST 抽象语法树就是代码的厨房布局图——它按语法规则把源码组织成一棵层次分明的树。

编译器在做这件事时分成两步:先做词法分析,把字符流切成一个个 token(关键字、变量名、运算符),再做语法分析,按文法规则将 token 拼成一棵树。树的内部节点是“表达式”“赋值语句”“条件语句”这样的语法结构,叶子节点则是变量名、数字字面量等最小的单元。比如 a + b * c,因为乘法优先级高于加法,* 会先组成 b * c 子树,+ 成为整棵树的根节点。

AST 丢弃了空格、换行、多余括号等与语义无关的格式噪声——括号只影响树的形状,本身不出现在树中。这一特性让 AST 成为编译器前端、IDE 语法高亮、代码格式化工具共用的基础结构,也是后续构建 CFG 和 DFG 的起点。

有了这张布局图,不管菜谱多厚,你都能迅速看清哪些“设备”先组合、哪些后组合。对程序员而言,读 AST 就是戴上“结构眼镜”看代码,复杂逻辑瞬间变成一棵可以遍历的树。

E→E+T∣T\displaystyle E \to E + T \mid T
这条文法规则描述了表达式如何递归地组合:一个表达式可以展开成“另一个表达式加一个项”,也可以直接就是一个项。AST 的每一棵子树都对应这样一条展开规则。
  1. 非终结符 EE 代表“表达式”,对应 AST 中的内部节点,它可以继续向下展开成更具体的结构。
    E\displaystyle E
  2. 加法展开规则E 展开为 E + T,表示一个表达式由“表达式”和“项”通过加号组合,对应 AST 中 + 节点下挂着两个子树。
    E→E+T\displaystyle E \to E + T
  3. 项的进一步展开T 表示“项”,继续展开为 T × F,体现乘法优先级高于加法——乘号先结合成子树。
    T→T×F\displaystyle T \to T \times F
  4. 叶子节点落地F 最终展开为变量标识符或数字字面量,对应 AST 中不可再分的叶子节点。
    F→id∣num\displaystyle F \to \text{id} \mid \text{num}

CFG控制流图

布局图告诉你厨房里有哪些设备,却没告诉你一道菜如何在各设备间流转。CFG 控制流图是后厨的出菜流程图:从接单开始,先备菜还是先热锅,条件分支怎么走,循环回去要重复几次,最终在哪里装盘出餐。它回答的问题是“程序实际往哪儿走”。

构建 CFG 时,分析器先把代码切成基本块——一段“单入口、单出口、中间没有跳转”的连续语句;再把块与块之间的跳转画成有向边。一个 if-else 语句会让某一块分裂出两条出边,一条指向 then 分支,一条指向 else 分支;一个 while 循环则会让某个块的出边绕回它自己,形成环。

有了基本块和边,CFG 变成一张标准的图结构。分析者可以在上面做路径可达性分析(某段代码是否永远走不到)、死代码检测、分支覆盖率计算,甚至判断程序会不会陷入死循环。编译器优化中的“常量传播”“公共子表达式消除”也都以 CFG 为舞台。

出菜流程图让学徒一眼看清整条动线,CFG 则让程序分析器可以回答“执行到底会不会走到这一行”。在你接手陌生代码时,CFG 就是那张帮你避开“死胡同”的动线图。

CFG=(V,E,ventry,vexit)\displaystyle \text{CFG} = (V, E, v_{\text{entry}}, v_{\text{exit}})
CFG 是一个四元组:基本块集合 V、跳转边集合 E、唯一入口 v_entry 和唯一出口 v_exit。边表示“执行权的转移”,而不是数据的流动。
  1. 顶点集合 VV 是所有基本块的集合,每个基本块是一段顺序执行的连续语句,内部不存在跳转。
    V={v1,v2,…,vn}\displaystyle V = \{v_1, v_2, \dots, v_n\}
  2. 边集合 E每条有向边 e=(u,v) 表示执行完基本块 u 后,控制流可能进入基本块 v。
    E⊆V×V\displaystyle E \subseteq V \times V
  3. 入口节点v_entry 是函数的唯一入口,对应函数被调用时执行的第一段代码。
    ventry∈V\displaystyle v_{\text{entry}} \in V
  4. 出口节点v_exit 是程序的正常结束位置,所有 return 路径都会在图中汇聚到这里。
    vexit∈V\displaystyle v_{\text{exit}} \in V

DFG数据流图

前两张图看完,你还想知道:盐从哪个架子被取走、加进了哪口锅、最终装进哪道菜?DFG 数据流图就是这份食材追踪表,它不关心执行顺序,只关心数据从哪里来、到哪里去。

DFG 的节点是变量的“定义点”和“使用点”:在某个语句中给变量赋值,就是一个定义点;在另一个语句中读取这个变量,就是一个使用点。只要定义点的值有可能流到使用点,就在两者之间连一条有向边。比如 c = a + b; d = c; e = a:变量 c 在第二行被 d 使用,变量 a 的定义同时被 c 和 e 两处消费。

这种“定值-引用”信息威力巨大:如果一个变量的定义没有任何使用点,说明这个赋值是死代码,可以安全删除;如果从“用户输入”到一个“危险操作”之间存在一条 DFG 路径,说明可能存在安全漏洞。数据依赖分析因此成为编译优化与安全审计的共同底座。

有了食材追踪表,一旦某批食材“变质”(比如来自用户的恶意输入),你就能顺着表一路查到所有被污染的菜品。这正是漏洞挖掘中污点分析的基本思路。

DU(x)={(u,v)∣x∈def(u),x∈use(v),u⇝v}\displaystyle \text{DU}(x) = \{(u, v) \mid x \in \text{def}(u), x \in \text{use}(v), u \leadsto v\}
DU 链(定值-引用链)是一组有向路径:变量 x 在 u 处被定义,在 v 处被读取,且存在一条从 u 到 v 的可行执行路径,路径上 x 没有被重新赋值。
  1. 定义点 def(u)变量 x 在语句 u 中被赋值,u 就是 x 的一个“定义点”——食材在这里从“无”变“有”。
    x∈def(u)\displaystyle x \in \text{def}(u)
  2. 使用点 use(v)变量 x 在语句 v 中被读取,v 称为“使用点”——食材在这里被取用。
    x∈use(v)\displaystyle x \in \text{use}(v)
  3. 可行路径连接只有从 u 到 v 存在一条执行路径且 x 未被中途重赋值时,才生成一条定值-引用边。
    u⇝v 且路径上无其他 def(x)\displaystyle u \leadsto v \text{ 且路径上无其他 def}(x)

三图构建方法

三张图纸不是凭空画出来的,而是同一位“老厨师”按固定流程依次绘制。这条流水线就是编译器与代码分析工具的标准工作流程,每一步的输出恰好是下一步的输入。

第一步词法分析把源代码字符流切成 token 序列,相当于把菜谱拆成“食材”和“动作”的清单;第二步语法分析按文法规则把 token 组装成 AST,相当于根据清单画出厨房布局图;第三步控制流分析遍历 AST,把顺序语句切分为基本块并用跳转边相连,得到 CFG;第四步数据流分析在 CFG 上扫描每个变量的定值与引用,生成 DFG。

实际工程中,LLVM、GCC 等编译器内部都维护着这样的多级中间表示:源码先变成 AST(或更底层的中间码),再生成 CFG 供优化器使用,数据流分析则按需在 CFG 上跑固定点迭代算法。库如 Python 的 ast 模块、JavaScript 的 Babel,都能一键输出 AST,而 CFG/DFG 通常由静态分析框架(如 CodeQL、Soot)进一步加工而来。

这位“老厨师”就是编译器的化身。理解了这套流水线,你就能明白为什么 IDE 能在你打字的同时给出语法错误提示——它一直在后台默默绘制这三张图。

源码→词法分析tokens→语法分析AST→控制流分析CFG→数据流分析DFG\displaystyle \text{源码} \xrightarrow{\text{词法分析}} \text{tokens} \xrightarrow{\text{语法分析}} \text{AST} \xrightarrow{\text{控制流分析}} \text{CFG} \xrightarrow{\text{数据流分析}} \text{DFG}
这是一条单向流水线:字符流 → token → AST → CFG → DFG。每往前一步,分析维度就增加一层——从“是什么”到“怎么走”再到“数据怎么流”。
  1. 词法分析把源码的字符流切成具有类型标注的 token 序列,空白符和注释在这一步被丢弃。
    源码→词法分析tokens\displaystyle \text{源码} \xrightarrow{\text{词法分析}} \text{tokens}
  2. 语法分析按文法规则把 token 递归组合成树状结构,生成 AST。
    tokens→语法分析AST\displaystyle \text{tokens} \xrightarrow{\text{语法分析}} \text{AST}
  3. 控制流分析遍历 AST,将顺序语句切分为基本块,把 if/while 等结构转换为跳转边,得到 CFG。
    AST→控制流分析CFG\displaystyle \text{AST} \xrightarrow{\text{控制流分析}} \text{CFG}
  4. 数据流分析在 CFG 上扫描每个变量的定值和引用位置,连接出数据依赖边,生成 DFG。
    CFG→数据流分析DFG\displaystyle \text{CFG} \xrightarrow{\text{数据流分析}} \text{DFG}

应用场景

图纸画好不是挂在墙上看的,而是用来做“后厨审计”的。三张图各有分工,组合起来能覆盖代码智能的大部分关键场景。

代码理解是最直接的场景:接手陌生项目时,AST 帮你快速浏览模块的层级结构,CFG 帮你梳理一个函数从进入到返回的所有执行路径,DFG 帮你定位某个变量的值究竟来自何处。缺陷检测则更依赖后两张图:在 CFG 上做可达性分析,能找出永远不会执行的代码块;在 DFG 上检查定值-引用链,能发现从未被读取的赋值、空指针解引用、资源泄漏等问题。

漏洞挖掘把 DFG 用到极致:把“用户输入”标记为污染源,沿着数据依赖边追踪到危险函数(如 SQL 拼接、命令执行),一旦路径存在就有漏洞风险,这就是业界常说的污点分析。此外,语义搜索与克隆检测把两段代码分别建模成图,再按结构相似度打分——比纯文本比对更能识别“写法不同但功能相同”的代码。

后厨审计越早发现隐患,修复成本越低。三张图纸就是代码世界的安全监控系统——从“看得懂”到“查得出”,再到“抓得准”。

sim(G1,G2)=∣E1∩E2∣∣E1∪E2∣\displaystyle \text{sim}(G_1, G_2) = \frac{|E_1 \cap E_2|}{|E_1 \cup E_2|}
这是 Jaccard 相似度:两段代码的图共享的边越多,结构就越接近。共享边数除以并集边数,得到 0 到 1 之间的相似度。
  1. 公共边数|E1 ∩ E2| 表示两张图中同时出现的有向边数量,公共边越多,说明两段代码的执行骨架越一致。
    ∣E1∩E2∣\displaystyle |E_1 \cap E_2|
  2. 并集边数|E1 ∪ E2| 表示两图所有边的去重总数,作为归一化分母,避免图越大分数越占便宜。
    ∣E1∪E2∣\displaystyle |E_1 \cup E_2|
  3. 相似度取值两者之比落在 [0,1],越接近 1 表示结构越相似,常用于代码查重与检索排序。
    sim∈[0,1]\displaystyle \text{sim} \in [0, 1]

图神经网络

传统分析靠人工规则,而现代 AI 想让模型自己“看图学艺”,像一位新厨师大量观摩后厨图纸后逐渐形成直觉。图神经网络(GNN)就是让机器学会读图的模型家族。

GNN 把图的每个节点表示成一个向量(嵌入),通过消息传递机制迭代更新:每轮中,每个节点收集邻居节点的向量,加权聚合后与自己上一轮的向量合并,再过激活函数得到新向量。经过若干轮迭代,每个节点的向量就“吸收”了周围结构的信息——+ 节点知道自己连接着两个变量,if 节点知道自己通向两个分支。

更前沿的做法是跨图融合:把同一份代码的 AST、CFG、DFG 放进同一个模型,让三类图的信息互相传递,模型同时感知语法结构、执行顺序和数据依赖。在缺陷预测、代码克隆检测、代码摘要生成等任务上,这种多图融合模型明显超过纯文本模型(比如只看源码字符串的 LSTM)。需要注意的是:GNN 的输入是图,不是文本,所以前置的图构建环节质量直接决定模型上限。

新厨师不需要背下每本菜谱,只需把图纸内化成脑中的连接模式。GNN 做的正是把图结构变成可计算的向量,让机器“看一眼图就懂代码”。

hv(t+1)=σ(Wselfhv(t)+∑u∈N(v)Wagghu(t))\displaystyle h_v^{(t+1)} = \sigma\left( W_{\text{self}} h_v^{(t)} + \sum_{u \in \mathcal{N}(v)} W_{\text{agg}} h_u^{(t)} \right)
这是 GNN 的核心更新公式:节点 v 在第 t+1 轮的向量,等于自己上一轮的向量加上所有邻居向量加权求和的结果,再经过激活函数。
  1. 初始向量每个节点 v 拥有一个初始嵌入 h_v^(0),通常由节点类型(“if 节点”“赋值节点”等)经编码得到。
    hv(0)\displaystyle h_v^{(0)}
  2. 邻居集合N(v) 是与 v 直接相连的所有节点,消息传递的第一层只收集一跳邻居。
    N(v)={u∣(u,v)∈E}\displaystyle \mathcal{N}(v) = \{u \mid (u, v) \in E\}
  3. 聚合与更新邻居向量经权重矩阵 W_agg 变换后求和,与本身上一轮向量经 W_self 变换后相加,再过激活函数得到新表示。
    hv(t+1)=σ(Wselfhv(t)+∑u∈N(v)Wagghu(t))\displaystyle h_v^{(t+1)} = \sigma\left( W_{\text{self}} h_v^{(t)} + \sum_{u \in \mathcal{N}(v)} W_{\text{agg}} h_u^{(t)} \right)
  4. 多轮迭代重复 T 轮后,每个节点的向量包含 T 跳邻域的结构信息,送入分类器即可完成缺陷预测等任务。
    H(T)={hv(T)}v∈V\displaystyle H^{(T)} = \{h_v^{(T)}\}_{v \in V}

验证与部署

新厨师学成后能不能上岗?要先试菜、测时间、收集客人反馈,再决定是否转正。AI 代码分析系统从论文到生产线,同样要走完“验证 → 评估 → 部署”这条路。

可行性验证是第一步:在小规模数据集上检验图构建流程是否跑得通、GNN 是否学得动。这个阶段最常踩的坑是“图建得慢、模型训不动”,所以要先用小模型快速迭代。随后进入性能评估:用精确率、召回率、F1 等指标衡量检测准确度,同时统计图构建耗时、显存占用、单次推理延迟这些工程指标。

落地形态多种多样:可以是 IDE 插件(写代码时实时提示)、CI 流水线(每次提交自动扫描)、或者仓库级批量扫描服务。部署后还要建立回归测试集,防止模型更新后“修复一个缺陷、引入十个误报”。

试菜通过、客人满意,新厨师才算正式上岗。AI 代码分析工具也是如此——从实验到上线,是一段用数据说话、用指标验收的完整旅程。

F1=2×P×RP+R\displaystyle \text{F1} = \frac{2 \times P \times R}{P + R}
F1 分数是精确率 P 与召回率 R 的调和平均。当两个指标相互牵制时,F1 能综合衡量模型“抓得准”与“抓得全”的平衡。
  1. 精确率 P预测为缺陷的样本中真正是缺陷的比例,衡量“报出来的问题是不是真问题”。
    P=TPTP+FP\displaystyle P = \frac{TP}{TP + FP}
  2. 召回率 R真实缺陷中被模型找出的比例,衡量“真问题被漏掉了多少”。
    R=TPTP+FN\displaystyle R = \frac{TP}{TP + FN}
  3. F1 调和平均P 和 R 冲突时(调高阈值 P 升 R 降),F1 给出综合得分,只有两者都高时 F1 才高。
    F1=2×P×RP+R\displaystyle \text{F1} = \frac{2 \times P \times R}{P + R}

AI 应用场景

智能缺陷检测系统

阶段:代码理解 → 多图构建 → GNN 推理 → 持续集成反馈

解决的问题:传统 lint 工具靠人工规则只能识别已知模式,遇到空指针解引用、资源泄漏这种跨语句、跨函数的缺陷就束手无策。AI 将代码转成 AST、CFG、DFG 三图融合的结构,把缺陷检测转化为图分类问题,让模型自己学习“什么样的图形结构意味着有 bug”,大幅降低漏报。

具体案例:某互联网公司支付团队在 CI 流水线接入基于 CodeQL + GNN 的缺陷扫描服务。每次提交代码,系统自动构建三图并预测高危缺陷,上线三个月后缺陷漏报率降低约 40%,单条缺陷的平均确认时间从 40 分钟缩短到 6 分钟。

漏洞挖掘与污点分析

阶段:DFG 数据流建模 → 污点传播追踪 → 风险链排序

解决的问题:安全工程师手动追踪“用户输入 → 危险函数”的路径极其耗时,且容易遗漏间接调用。基于 DFG 的污点分析自动沿着数据依赖边传播污染标记;再用 GNN 学习“哪种传播链在真实漏洞中最常见”,对告警做优先级排序,减少安全团队的无效工作量。

具体案例:某安全公司定期扫描开源项目,用 DFG 追踪 request.getParameter 到 SQL 执行处的污染链,再用图分类模型过滤低风险路径,每周产出一份可直接分发给开发者的漏洞报告,误报率比纯规则方案降低一半以上。

代码语义搜索与克隆检测

阶段:AST+CFG 图嵌入 → 向量检索 → 相似度排序

解决的问题:传统代码搜索靠关键词匹配,找不到“写法不同但作用相同”的代码。把 AST 与 CFG 融合后做图嵌入,语义相近的代码会在向量空间里彼此靠近,实现真正的结构级搜索。

具体案例:某大型软件企业搭建内部代码检索服务,开发者贴入一段代码,系统返回仓库中功能相似的实现,帮助团队复用成熟逻辑、发现重复代码。上线后代码重复率下降约 18%,新项目的人均开发效率提升明显。

Python 演示

python91 行
# -*- coding: utf-8 -*-"""用三个小型实现理解 AST/CFG/DFG 的核心思想""" class Node:    # AST 节点: 一个结构名 + 若干子节点    def __init__(self, name, *kids):        self.name = name        self.kids = kids  def parse_expr(tokens):    # 递归下降解析表达式并生成 AST    pos = 0     def expr():        nonlocal pos        node = term()        while pos < len(tokens) and tokens[pos] == '+':            pos += 1            node = Node('+', node, term())        return node     def term():        nonlocal pos        node = factor()        while pos < len(tokens) and tokens[pos] == '*':            pos += 1            node = Node('*', node, factor())        return node     def factor():        nonlocal pos        t = tokens[pos]        pos += 1        return Node('id' if t.isalpha() else 'num', t)     return expr()  def build_cfg():    # 返回基本块列表和跳转边列表    blocks = ['entry', 'cond: a>0', 'then: c=a+b',              'else: c=a*b', 'merge: return c', 'exit']    edges = [(0, 1), (1, 2), (1, 3), (2, 4), (3, 4), (4, 5)]    return blocks, edges  def build_dfg(assignments):    # assignments: [(变量, 依赖元组), ...]    # 返回 (上游行号, 下游行号, 依赖变量, 目标变量)    last_def = {}    edges = []    for line_no, (var, deps) in enumerate(assignments):        for dep in deps:            if dep in last_def:                edges.append((last_def[dep], line_no, dep, var))        last_def[var] = line_no    return edges  def dump(node, depth=0):    # 用缩进打印一棵 AST    out = '  ' * depth + node.name    if len(node.kids) == 1 and isinstance(node.kids[0], str):        out += ' (' + node.kids[0] + ')'    print(out)    for kid in node.kids:        if isinstance(kid, Node):            dump(kid, depth + 1)  def main():    print('==== AST: a+b*c ====')    ast_tree = parse_expr(list('a+b*c'))    dump(ast_tree)     print('\n==== CFG: if-else ====')    blocks, edges = build_cfg()    for i, blk in enumerate(blocks):        print('块%d: %s' % (i, blk))    print('跳转边:', edges)     print('\n==== DFG: a=x; c=a+b; d=c; e=a ====')    assigns = [('a', ('x',)), ('c', ('a', 'b')),               ('d', ('c',)), ('e', ('a',))]    for up, down, dep, var in build_dfg(assigns):        print('行%d 定义 %s -> 行%d 使用 (%s 依赖)' % (up, dep, down, var))  if __name__ == '__main__':    main()
目的:用三个轻量实现分别演示 AST 的递归下降构建、CFG 的基本块与跳转边、DFG 的定值-引用链提取,帮你把抽象概念落到可运行的代码上。
  1. class Node:定义通用的语法树节点,名字表示结构类型,kids 存放子节点。
  2. def parse_expr(tokens):递归下降解析器,用 expr/term/factor 三层函数体现运算符优先级。
  3. while pos < len(tokens) and tokens[pos] == '+':循环处理多个加号,构造左结合的加法子树。
  4. factor()` 返回 `Node('id' , t)标识符变成叶子节点,缩进打印时显示具体变量名。
  5. def build_cfg():用硬编码的基本块和边表演示 if-else 的控制流结构。
  6. edges = [(0, 1), (1, 2), (1, 3), ...]第 1 块 if 指向 2(then)和 3(else)两条出边,正是分支的图表达。
  7. def build_dfg(assignments):维护 last_def 字典记录每个变量最近一次定义的行号。
  8. if dep in last_def:只有当依赖变量存在“活跃定义”时才生成数据依赖边。
  9. last_def[var] = line_no更新当前变量的定义点,供后续语句引用。
  10. if len(node.kids) == 1 and isinstance(node.kids[0], str):叶子节点(只有一个字符串孩子)打印为 id (a) 的形式。

常见误区

误区“AST 就是把源码按照缩进整理成一棵树,跟 IDE 左侧的文件夹导航差不多,只是换了个展示方式而已。”

正解AST 是按形式文法生成的抽象结构,只保留语法本质——表达式如何嵌套、运算符优先级如何体现、语句如何组合——并丢弃空格、注释等与语义无关的符号。缩进在 Python 里虽是语法的一部分,但 AST 会把缩进结构化为显式的子树关系,而不是“长得像缩进”的树。文件夹导航是“文件系统视图”,AST 是“语言结构视图”,两者层次完全不同。

为何常见:IDE 的大纲视图、代码折叠功能给人“按结构排布代码”的第一印象,容易让初学者误以为 AST 只是对源码的视觉重组。

误区“CFG 不就是把 if/else 画成好看的流程图吗?我直接在源码里看分支不就行了,何必多此一举?”

正解CFG 的价值在于把控制流变成可计算的图结构:基本块单入口单出口,边代表跳转。有了它,编译器才能做路径可达性、死代码检测、分支覆盖率计算;AI 模型才能判断“这段代码到底有没有执行死路”。肉眼只能看到局部分支,而 CFG 把全局路径分析变成了图算法问题,这是任何静态分析框架的基石。

为何常见:初学者只看到 CFG 的“可视化”表面,看不到它作为中间表达被下游分析引擎大量消费的事实。

误区“CFG 和 DFG 都是图,节点差不多,边也差不多,应该可以互换吧?反正都是代码的一种图形化表示。”

正解两者的边在语义上完全不同。CFG 的边表示“执行权转移”——下一步执行哪个基本块;DFG 的边表示“数据依赖”——这个值从哪个定义点来。同一个变量赋值既会出现在 CFG 的某个基本块里,也可能成为 DFG 的一个定义节点;但 CFG 边不代表任何数据流动,DFG 边也不代表执行顺序。它们是正交的两个分析维度,缺一不可。

为何常见:两者都以有向图形式呈现,且构建流程有先后关系(DFG 往往基于 CFG 计算),初学者很容易把“长得像”当成“一回事”。

误区“把代码当作文本直接丢给大语言模型不就行了?它什么都能懂,为什么还要费劲去建图?”

正解文本序列是一维的,而代码的结构——谁嵌套在谁里、谁跳转到谁、谁依赖谁——本质上是多维的。GNN 在图结构上做消息传递,天然利用这些结构约束;语言模型则需要从大量文本中隐式“猜测”结构,在长代码、深层依赖上容易丢上下文。实际工业方案常把两者结合:用 LLM 生成初步解释,用 GNN + 图结构做精确的缺陷定位。结构信息依然是专业分析器的“骨相”。

为何常见:大模型时代“万物皆文本”的直觉很强,容易让人忽略结构敏感任务中显式图带来的稳定归纳偏置。

面试题

Q1. 在 AST 抽象语法树中,叶子节点通常代表什么?

单选题 · easy

  • A.运算符与关键字
  • B.变量与字面量节点← 正确答案
  • C.基本块与跳转边
  • D.数据流与依赖链

答案:B。AST 的叶子对应语法上不可再分的单元——变量名、数字、字符串等字面量。运算符如 +、* 是内部节点,因为它们还要连接两个子树;基本块属于 CFG 的概念,依赖链属于 DFG 的概念,与 AST 叶子无关,就像厨房布局图的“抽屉”是最小的存放单元一样。

Q2. 下列哪个图最能直接回答“这段代码中哪些行永远不会被执行”?

单选题 · easy

  • A.抽象语法树的完整结构
  • B.词法符号表的字典
  • C.控制流图的可达性分析← 正确答案
  • D.类型推导树的全部叶节点

答案:C。不可达代码检测本质上是在 CFG 上做可达性分析——从入口节点出发,能遍历到的节点就是可执行的,遍历不到的就是死代码。AST 只描述语法结构,词法符号表只记录 token,类型推导树关注类型关系,它们都无法直接回答“会不会执行到”,如同只看菜谱的食材清单无法知道哪道工序永远不会被客人点到。

Q3. 在控制流图 CFG 中,一个“基本块”的定义是什么?

单选题 · mid

  • A.能并行执行的语句集合
  • B.单入口单出口的连续语句块← 正确答案
  • C.包含循环结构的代码段
  • D.整个函数体的完整代码

答案:B。基本块是 CFG 的最小单元:一段连续语句,只有一个入口和一个出口,内部不存在任何跳转或分支目标。循环结构由多个基本块配合回边表达,函数体则包含多个基本块。它就像后厨出菜流程中“连续完成、中间不停顿”的一道工序。

Q4. 构建 DFG 时,一条定值-引用边的生成条件是什么?

单选题 · mid

  • A.变量的定义点与使用点之间有边← 正确答案
  • B.变量在任何位置被读取过
  • C.两个基本块在源码中相邻
  • D.函数在同一代码块调用两次

答案:A。DFG 的边表示“变量 x 在 u 处定义,在 v 处被读取,且存在一条从 u 到 v 的可行路径且路径上 x 未被重赋值”。只有“被读取过”还不够,必须存在数据传播路径;相邻基本块与函数调用次数都不产生数据依赖,就像食材必须真实传到下一道工序才算“流动”。

Q5. 对于表达式 a + b * c,在对应的 AST 中下列说法正确的是?

单选题 · mid

  • A.乘号比加号更接近根节点
  • B.加号比乘号更接近根节点← 正确答案
  • C.两者距离根节点相同
  • D.取决于变量的定义位置

答案:B。由于乘法优先级高于加法,b * c 先结合成子树,+ 成为整棵树的根节点,* 节点是根的孩子,距离根更远。操作数优先级越高,在 AST 中结合得越紧密、位置越靠下,这与变量的定义位置无关,如同油烟重先爆炒、装盘最后完成。

Q6. 用图神经网络 GNN 处理代码图时,消息传递机制的主要作用是什么?

单选题 · hard

  • A.把全部源代码直接替换成嵌入
  • B.聚合邻居信息迭代更新节点向量← 正确答案
  • C.消除图中多余的控制流边
  • D.降低图的节点总数便于计算

答案:B。GNN 的核心是消息传递:每轮让每个节点收集邻居节点的向量,加权聚合后与自身向量合并,再经激活函数更新。迭代多轮后,节点向量蕴含了其邻域结构信息。它不替换源码、不消除边、也不减少节点,而是让信息在图上“流动”,正如厨师之间的低声沟通让整个后厨的配合越来越默契。

Q7. 相比只用 AST 建模,跨图融合(同时使用 AST、CFG、DFG)的主要收益有哪些?

单选题 · multiple

  • A.同时捕获语法、执行与数据依赖← 正确答案
  • B.图规模变得更小而更易训练
  • C.不再需要词法分析阶段
  • D.提供更丰富的代码语义特征← 正确答案

答案:AD。三种图各看一个维度:AST 给语法结构,CFG 给执行顺序,DFG 给数据流动,融合后模型能得到更完整的代码语义,这正是选项 A 和 D 正确的原因。但融合并不会让图变小,多图建模往往会增加节点与边的总量;词法分析仍然是构建 AST 的前置步骤,无法省去。三图融合更像把布局图、流程图、食材表一起摆在桌上,信息更全面而不是更少。

Q8. 以下哪一项是 CFG 的直接应用?

单选题 · mid

  • A.变量类型自动推导
  • B.语法错误定位
  • C.不可达代码检测← 正确答案
  • D.token 类别标注

答案:C。不可达代码检测依赖 CFG 的可达性分析:从入口节点遍历图,遍历不到的基本块就是死代码。变量类型推导主要在类型系统与 AST 上进行,语法错误定位发生在语法分析阶段,token 标注是词法分析的任务。把“哪块代码永远走不到”这个问题放到出菜流程图上一看便知。

Q9. 关于 DFG 与污点分析的关系,下列说法正确的是?

单选题 · hard

  • A.污点传播沿数据流边进行← 正确答案
  • B.污点分析可以脱离 CFG 独立完成
  • C.数据依赖边不能跨函数传播
  • D.污点源一定是变量定义处

答案:A。污点分析正是沿着 DFG 的数据依赖边,把“污点”从污染源传播到危险函数;而要精确计算数据依赖,必须先有 CFG 提供的路径信息,所以“脱离 CFG”不准确。过程间分析中数据依赖边可以跨函数传递(通过调用关系),污点源既可以是变量定义,也可以是函数参数、I/O 入口等。因此只有 A 正确。

Q10. 三图构建的正确流水线顺序是?

单选题 · easy

  • A.词法分析 → 语法分析 → 控制流分析 → 数据流分析← 正确答案
  • B.语法分析 → 词法分析 → 数据流分析 → 控制流分析
  • C.词法分析 → 控制流分析 → 语法分析 → 数据流分析
  • D.数据流分析 → 控制流分析 → 语法分析 → 词法分析

答案:A。标准流程是先词法分析切出 token,再语法分析生成 AST,然后做控制流分析得到 CFG,最后在 CFG 基础上进行数据流分析得到 DFG。每一步依赖上一步的输出,顺序不可颠倒。这就像后厨必须先备菜、再按流程炒制、最后才能追踪每样食材去了哪道菜。