2. 架构评估:如何给现有代码做“体检”?——圈复杂度、扇入扇出、依赖关系图等量化指标

接手一个老项目,第一件事是什么?

我个人的习惯是——先别急着改代码。你得先搞清楚,这堆代码到底“健康不健康”。就像人去医院,不能直接开刀,得先做个体检。血压、心率、血常规,一套下来,问题在哪就清楚了。

代码也一样。圈复杂度、扇入扇出、依赖关系图,这些就是代码的“体检指标”。今天我们就聊聊,怎么用这些量化工具,给现有嵌入式代码做一次全面诊断。

2.1 圈复杂度:你的函数是不是“一团乱麻”?

圈复杂度(Cyclomatic Complexity,CC)是最常用的指标。它衡量的是函数中独立路径的数量。说白了,就是你的函数里有多少个“岔路口”。

计算公式很简单:

CC = E - N + 2P

其中 E 是边数,N 是节点数,P 是连通分量数。对于单个函数,P 通常为 1,所以简化为:

CC = 判断节点数 + 1

举个例子:

// 圈复杂度 = 1
void led_on(void) {
    GPIO_SetBits(GPIOB, GPIO_Pin_0);
}

// 圈复杂度 = 2
void led_ctrl(uint8_t state) {
    if (state) {          // 1个判断
        GPIO_SetBits(GPIOB, GPIO_Pin_0);
    } else {
        GPIO_ResetBits(GPIOB, GPIO_Pin_0);
    }
}

// 圈复杂度 = 4
int process_data(uint8_t *buf, uint16_t len) {
    if (buf == NULL) return -1;      // 判断1
    if (len == 0) return -2;         // 判断2
    for (int i = 0; i < len; i++) {  // 判断3(循环条件)
        if (buf[i] > 0x7F) {         // 判断4
            buf[i] = 0x7F;
        }
    }
    return 0;
}

经验阈值:

  • CC ≤ 10:健康,可维护性好
  • 10 < CC ≤ 20:需要关注,建议重构
  • CC > 20:危险信号,必须拆分

我在项目中遇到过最夸张的一个函数,圈复杂度高达 87。那是一个处理串口命令的函数,里面嵌套了 6 层 if-else,还有 3 个 switch-case。改一个 bug 要花半天,因为根本看不清逻辑流向。后来我把它拆成了 12 个小函数,每个 CC 都控制在 8 以下,测试覆盖率从 30% 直接升到了 85%。

小技巧:用 gcc -fcomplexity 或者 pmccabe 工具,可以一键扫描整个工程。别手算,太累。

2.2 扇入与扇出:谁在依赖谁?

扇入(Fan-in)和扇出(Fan-out)描述的是模块之间的调用关系。

  • 扇出:一个函数调用了多少个其他函数。扇出高,说明这个函数“管得宽”。
  • 扇入:有多少个函数调用了这个函数。扇入高,说明这个函数“被依赖得多”。

你想想看,如果一个底层驱动函数被 50 个地方调用(高扇入),你改它的时候就得小心翼翼,因为牵一发而动全身。反过来,如果一个函数扇出超过 10,它很可能在做太多事情,违背了“单一职责原则”。

指标 理想范围 风险信号
扇出 3 ~ 7 > 10:函数职责过重
扇入 2 ~ 10 > 20:底层模块耦合过高

避坑指南:我曾经在一个通信协议栈里,发现一个叫 crc_check() 的函数扇入高达 43。每次修改 CRC 算法,都要重新编译半个工程。后来我把它做成了弱符号(weak symbol),允许用户重写,扇入问题就解决了。

2.3 依赖关系图:把“蜘蛛网”画出来

圈复杂度和扇入扇出都是“点”上的指标。但代码的耦合问题,往往是“面”上的。依赖关系图(Dependency Graph)能帮你把整个模块的调用关系可视化。

我习惯用 doxygen + graphviz 自动生成调用图。生成之后,重点关注三类“坏味道”:

  1. 循环依赖:A 调用 B,B 调用 C,C 又调用 A。这是最严重的架构问题,会导致编译死循环、测试困难。
  2. 上帝模块:某个模块被几乎所有模块依赖。比如一个“全局工具函数库”,里面塞了字符串处理、CRC 计算、日志打印、定时器操作……这种模块必须拆分。
  3. 孤岛模块:没有任何模块依赖它,它也不依赖别人。这种模块要么是废弃代码,要么是设计过度抽象。

我的经验法则:

  • 依赖图应该是一棵“树”,而不是一张“网”
  • 层与层之间只能单向依赖(上层依赖下层)
  • 同层模块之间尽量不互相调用
依赖关系图示例:健康架构 vs 问题架构 ✅ 健康架构(树形) 应用层 (app_task) 业务模块A 业务模块B 驱动接口层 硬件抽象层 ❌ 问题架构(网状) 模块A 模块B 模块C 模块D 全局工具库 📌 关键观察: 1. 左侧:依赖是单向的,层次清晰。修改驱动层不影响应用层。 2. 右侧:模块A→B→C→A形成循环依赖。全局工具库被所有模块依赖。 3. 循环依赖是架构重构中必须优先解决的“癌症”。

2.4 实操:给代码做一次“体检报告”

光讲理论不行,我们来看看实际怎么操作。我一般按以下步骤来:

  1. 工具扫描:用 lizard 或 cppcheck 跑一遍全工程,生成圈复杂度和扇入扇出数据。
  2. 生成依赖图:用 doxygen + graphviz 生成调用关系图。
  3. 标记问题点:把 CC > 15、扇出 > 10、存在循环依赖的模块标记出来。
  4. 制定重构优先级:
    • P0(立即处理):循环依赖、CC > 30
    • P1(本周处理):CC 20~30、扇出 > 15
    • P2(本月处理):CC 10~20、扇入 > 20

工具推荐:

  • lizard:轻量级,支持 C/C++,直接输出圈复杂度和 NLOC(非注释行数)
  • cppcheck:静态分析,能检测出潜在的逻辑问题
  • doxygen + graphviz:生成调用图,可视化依赖关系

2.5 别忘了“人肉检查”

量化指标是工具,但不能完全替代人的判断。我记得有一次,工具报告一个函数的圈复杂度只有 8,看起来很健康。但我读代码时发现,这个函数里有一个 200 行的 switch-case,每个 case 都调用了不同的外部函数。虽然路径不多,但逻辑耦合非常严重。这种“假健康”的情况,工具是看不出来的。

所以我的建议是:量化指标做初筛,人工审查做确认。两者结合,才能给出准确的架构诊断。

最后提醒一句:别为了追求指标好看而盲目重构。圈复杂度从 15 降到 8,但如果引入了新的 bug,那就得不偿失了。重构前,先写好单元测试,确保行为不变。