阅读陌生的 C 项目时, 逐个跳转函数可以看清细节, 但不容易把握整体关系。GNU cflow 从 C 源码提取静态调用关系, 帮助回答:
- 调用了谁? 从入口函数展开调用树。
- 被谁调用? 反向查看调用者。
- 如何直观看图? 生成 DOT, 在 VSCode 中预览调用关系。
它适合辅助阅读代码, 不能代替运行时跟踪。
1. 安装工具
以下命令在 Ubuntu/Debian 或 WSL 的 Linux 终端中执行:
sh
sudo apt update
sudo apt install cflow
cflow --version
本文使用 cflow 原生 DOT 输出, 实测环境为 GNU cflow 1.7、Graphviz 2.43.0 (Ubuntu 22.04)。可通过 cflow --help 确认 --format 支持 dot。
2. 用一个例子看调用树
将下面的代码保存为 main.c:
c
#include <stdio.h>
void printf_hello(void)
{
printf("Hello!\n");
}
void printf_world(void)
{
printf("World!\n");
}
void printf_helloworld(void)
{
printf_hello();
printf_world();
}
int main(void)
{
printf_hello();
printf("****************************\n");
printf_world();
printf("****************************\n");
printf_helloworld();
return 0;
}
从 main 开始分析:
sh
cflow -T -m main main.c
执行结果如下:
text
+-main() <int main (void) at main.c:19>
+-printf_hello() <void printf_hello (void) at main.c:3>
| \-printf()
+-printf()
+-printf_world() <void printf_world (void) at main.c:8>
| \-printf()
\-printf_helloworld() <void printf_helloworld (void) at main.c:13>
+-printf_hello() <void printf_hello (void) at main.c:3>
| \-printf()
\-printf_world() <void printf_world (void) at main.c:8>
\-printf()
- 缩进和树形符号: 表示调用层级。
- 尖括号中的信息 : 函数签名及定义位置, 例如
main.c:19表示第 19 行。 - 只有
printf(): 找到了调用, 但输入源码中没有它的定义。
常用选项:
| 选项 | 用途 |
|---|---|
-T |
用树状线条显示文本输出 |
-m 函数名 |
指定正向分析的入口函数 |
-d 3 |
限制遍历深度; DOT 输出可能保留边界节点指向下一层的边 |
-n |
为输出行编号 |
-r |
输出反向关系, 查看函数的调用者 |
-f dot |
输出 Graphviz DOT 格式 |
例如, cflow -r main.c 可以查看这个文件中各函数的调用者。
3. 生成 DOT 并用 VSCode 预览
sh
cflow -f dot -m main main.c > out.dot
生成的 out.dot 是图形描述文件, 接下来用 VSCode 插件打开。
安装 VSCode 预览插件
- 按
Ctrl + Shift + X打开扩展面板。 - 搜索并安装 Graphviz Interactive Preview, 发布者为
tintinweb。 - 打开生成的
out.dot文件。 - 按
Ctrl + Shift + P, 搜索Graphviz Preview, 执行Preview Graphviz / Dot (beside)在右侧打开预览。
也可以点击编辑器右上角的预览按钮, 或在编辑区右键选择同一预览命令。
| 操作 | 用途 |
|---|---|
| 缩放、平移 | 查看较大的调用图 |
| 搜索节点 | 快速定位函数 |
| 点击节点 | 高亮相关调用边; 按 Esc 取消选择 |
| 导出 SVG | 保存可放大的矢量图 |
图太大时, 缩小入口和文件范围, 或用 -d 限制深度。
下面展示该 DOT 文件的渲染结果, 便于对照节点和调用边; 插件中的布局可能因渲染版本而异:

- 箭头方向: 调用者 → 被调用者。
- 共享节点: 多处调用同一函数时, 图中可以汇聚到一个节点。
- 库函数 :
printf的实现在库中, 本例不会展开其内部实现。
4. 工程示例: 串口命令处理
以一个简化的串口温度查询工程为例, 从 app_poll() 查看跨文件调用关系。
| 文件 | 职责 |
|---|---|
src/main.c |
读取命令并交给协议模块 |
src/protocol.c |
解析 GET_TEMP, 调用采集和发送接口 |
src/sensor.c |
提供温度读取接口 |
src/uart.c |
提供串口接收和发送接口 |
include/app.h |
声明模块接口 |
完整示例源码见下方。串口接收和温度采集使用主机桩函数, 便于直接编译运行; 接入硬件时替换为实际驱动。
在示例工程根目录执行:
sh
cflow --cpp -I include -m app_poll -d 3 -f dot src/main.c src/protocol.c src/sensor.c src/uart.c > uart.dot
在 VSCode 中打开 uart.dot, 执行同一预览命令。该 DOT 的渲染结果如下, 节点包含函数签名、文件名和行号:

app_poll()调用uart_read_line()和protocol_handle()。protocol_handle()调用命令解析、温度读取和串口发送接口。- 深度边界 : 本次
-d 3 -f dot输出仍包含uart_write_temperature → printf, 因此图中会出现下一层的printf节点。 - 文件归属: 源文件和行号显示在节点内, 原生输出没有按文件分组的外框。
串口工程完整源码
按以下路径保存 5 个文件, 即可执行上面的分析和编译命令。
include/app.h
c
#ifndef APP_H
#define APP_H
void app_poll(void);
const char *uart_read_line(void);
void uart_write_temperature(int value);
void protocol_handle(const char *line);
int parse_command(const char *line);
int sensor_read_temperature(void);
#endif
src/main.c
c
#include "app.h"
void app_poll(void)
{
const char *line = uart_read_line();
if (line != 0) {
protocol_handle(line);
}
}
int main(void)
{
app_poll();
return 0;
}
src/protocol.c
c
#include "app.h"
int parse_command(const char *line)
{
const char expected[] = "GET_TEMP";
unsigned int i = 0;
while (expected[i] != '\0' && line[i] == expected[i]) {
++i;
}
return expected[i] == '\0' && line[i] == '\0';
}
void protocol_handle(const char *line)
{
if (parse_command(line)) {
int temperature = sensor_read_temperature();
uart_write_temperature(temperature);
}
}
src/sensor.c
c
#include "app.h"
/* Host stub: replace with a real sensor driver. */
int sensor_read_temperature(void)
{
return 25;
}
src/uart.c
c
#include "app.h"
#include <stdio.h>
/* Host stubs: replace with board UART access in a real project. */
const char *uart_read_line(void)
{
return "GET_TEMP";
}
void uart_write_temperature(int value)
{
printf("TEMP:%d\n", value);
}
验证主机示例:
sh
gcc -std=c11 -Wall -Wextra -Werror -I include src/main.c src/protocol.c src/sensor.c src/uart.c -o uart_demo
./uart_demo
输出为 TEMP:25。实际工程有条件编译时, 在 cflow 命令中补上与构建一致的 -D 宏名=值; 头文件通过 -I 和 #include 引入。
本次启用 --cpp 时, cflow 对系统 stdio.h 中的部分扩展声明报了重定义警告, 但命令返回成功。已逐项核对本例的 6 条调用边; 其他工程出现警告时也应检查解析结果。
使用时注意:
- 分析范围: 未传入的源文件不会展开实现, 应包含相关模块, 避免一次扫描无关目录。
- 预处理配置: 条件编译会影响结果, 宏和头文件路径应与目标配置一致。
- 静态分析限制: 函数指针、回调注册、汇编和编译器扩展可能导致调用关系缺失或解析不准确。
- 阅读方式: 图表示源码中的调用关系, 不代表某次运行的执行顺序、调用次数或耗时。
先从一个入口看清主线, 再结合源码确认关键分支和回调, 通常比直接生成整个工程的大图更有效。
需要自动扫描目录、按源文件分组时, 可阅读配套的 cflow 进阶: 结合 tree2dotx。
参考资料
本节 main.c 完整源码已列于正文。
补充: 用命令行导出图片
需要批量导出或在脚本中生成图片时, 安装 Graphviz:
sh
sudo apt install graphviz
dot -V
在 DOT 文件所在目录执行:
sh
dot -Tpng out.dot -o out.png
dot -Tsvg out.dot -o out.svg
工程示例将 out.dot 替换为 uart.dot, 并修改输出文件名。本文配图使用 Graphviz 2.43.0 生成。