2. 现有架构分析:梳理调用链,识别核心模块

做重构之前,我最怕的一件事就是「凭感觉动手」。你想想看,一个跑了两三年的嵌入式 GUI 项目,代码少说也有几万行。如果不先把现有架构摸清楚,直接上手改,大概率会改出一堆新 bug。

这一章,我们就来干一件脏活累活——把当前 GUI 代码的调用链从头到尾捋一遍。我会带着你识别出四个核心模块:显示驱动、事件处理、控件库、业务逻辑。然后,咱们再画一张耦合关系图,看看这些模块到底是怎么纠缠在一起的。

2.1 从 main 函数开始,追踪一条完整的调用链

我个人习惯,拿到一个旧项目,第一件事就是打开 main.c。别小看这个入口文件,它往往暴露了整个系统的设计思路。

假设我们有一个典型的嵌入式 GUI 项目,它的 main 函数大概长这样:

int main(void)
{
    // 1. 硬件初始化
    LCD_Init();
    Touch_Init();

    // 2. 创建主界面
    Page_CreateMain();

    // 3. 进入事件循环
    while (1) {
        TouchEvent_t evt;
        if (Touch_GetEvent(&evt)) {
            // 4. 查找当前焦点控件
            Widget_t *w = Widget_GetFocused();
            if (w && w->onEvent) {
                // 5. 调用控件的回调函数
                w->onEvent(w, &evt);
            }
        }
        // 6. 刷新显示
        LCD_Refresh();
    }
}

这段代码看起来挺规整的,对吧?但实际项目中,事情远没有这么简单。我在一个工业仪表项目里遇到过类似的结构,当时为了加一个「长按确认」的功能,发现事件处理函数里嵌套了五层 if-else,还夹杂着直接操作 LCD 寄存器的代码。嗯,这就是耦合的典型症状。

我们来拆解一下这条调用链:

  1. 硬件初始化:LCD_Init() 和 Touch_Init() 直接操作寄存器或 HAL 库。
  2. 界面创建:Page_CreateMain() 内部会调用控件库的 API 来创建按钮、标签等。
  3. 事件获取:Touch_GetEvent() 从触摸驱动读取原始坐标,然后做校准和映射。
  4. 控件查找:Widget_GetFocused() 遍历控件链表,找到当前获得焦点的控件。
  5. 回调执行:w->onEvent() 是业务逻辑的入口,比如点击按钮后切换页面。
  6. 显示刷新:LCD_Refresh() 把显存内容刷到屏幕上。

你看,从硬件到业务逻辑,一条线就串起来了。但问题在于,这条线上每个环节都可能「越界」。

2.2 识别四个核心模块

根据上面的调用链,我们可以把整个 GUI 系统拆成四个模块。我习惯用一张表来定义它们的职责边界:

模块名称 核心职责 典型文件/接口
显示驱动 像素操作、画点、画线、刷屏、显存管理 lcd_drv.c, lcd_hal.h, framebuffer.c
事件处理 触摸/按键采集、坐标校准、事件分发 touch_drv.c, event_queue.c, input_manager.c
控件库 控件创建、布局、绘制、事件回调注册 widget.c, button.c, label.c, list.c
业务逻辑 页面切换、数据绑定、用户交互响应 page_main.c, page_setting.c, app_logic.c

这里有个关键点:模块的命名不重要,重要的是它的依赖方向。理想情况下,依赖应该从上往下:业务逻辑 → 控件库 → 显示驱动/事件处理。但现实往往是反过来的。

我的经验:判断一个模块是否「干净」,有一个简单的方法——看它有没有包含其他模块的头文件。如果 lcd_drv.c 里 #include 了 "widget.h",那基本可以断定耦合已经发生了。

2.3 绘制现有架构的耦合关系图

光说不够,咱们直接画图。下面这张 SVG 图,是我根据一个真实项目的反编译结果绘制的。它展示了模块之间的实际依赖关系,箭头方向表示「调用了」或「依赖了」。

现有架构耦合关系图(实际依赖方向) 业务逻辑 控件库 显示驱动 事件处理 创建控件 绘制像素 注册回调 反向依赖:驱动直接调用控件刷新 反向依赖:事件处理直接调用页面切换 ⚠ 问题点: 橙色虚线表示「反向依赖」——底层模块直接调用了上层模块的接口,这是架构腐化的典型信号。

这张图里,实线箭头是「正常依赖」,虚线箭头是「反向依赖」。你仔细看,显示驱动和事件处理这两个底层模块,居然直接调用了控件库和业务逻辑的接口。为什么会这样?

我在一个智能家居面板项目里就踩过这个坑。当时触摸驱动里直接调用了 Page_GoToHome(),理由是「双击返回主页」。听起来挺方便,但后来要换一个不带双击功能的触摸芯片,整个事件处理逻辑都得重写。说白了,这就是底层模块「越权」了。

2.4 耦合的具体表现与危害

光看图可能还不够直观。我们来看看代码层面,耦合到底长什么样。

典型耦合代码示例(反面教材)

// lcd_drv.c —— 显示驱动模块
#include "widget.h"   // 居然包含了控件库的头文件

void LCD_DrawPixel(int x, int y, uint16_t color)
{
    // 正常的像素绘制...
    // 但这里有一段「特殊逻辑」:
    if (x > 100 && y > 50) {
        // 直接调用控件库的刷新函数
        Widget_RefreshAll();  // ← 耦合点
    }
}

这段代码的问题在哪?显示驱动的职责是「画像素」,它不应该知道控件库的存在。一旦 Widget_RefreshAll() 的实现变了,或者控件库的接口改了,显示驱动也得跟着改。这就是典型的「牵一发动全身」。

我整理了几种常见的耦合模式,你可以对照自己的项目看看:

耦合类型 表现 危害
头文件耦合 底层模块 #include 了上层模块的头文件 编译依赖链变长,改一个头文件导致全量重编
回调耦合 驱动层直接注册业务层的回调函数 业务逻辑变更时,驱动层代码也要修改
数据耦合 多个模块共享全局变量或结构体 数据被意外修改,定位 bug 非常困难
控制耦合 一个模块通过标志位控制另一个模块的行为 代码阅读性差,维护成本高
我曾经踩过的坑:在一个车载仪表项目中,显示驱动和业务逻辑通过一个全局的 g_ui_state 结构体共享数据。结果有一次,业务逻辑改了结构体成员的顺序,显示驱动没同步更新,导致整个仪表盘花屏。排查了整整两天才找到原因。从那以后,我对全局变量深恶痛绝。

2.5 小结:我们为什么要做这件事?

梳理现有架构,不是为了批评前任写的代码烂。说实话,我自己早期写的代码也好不到哪去。嵌入式项目往往时间紧、任务重,大家优先保证功能跑通,架构什么的,能往后放就往后放。

但问题是,这种「先跑起来再说」的做法,会在项目迭代到第三、第四个版本时集中爆发。你会发现加一个新功能越来越慢,改一个 bug 可能引入三个新 bug。这时候再回头重构,成本已经很高了。

所以,我们现在做的这件事——梳理调用链、识别模块、绘制耦合图——就是在为后续的重构打地基。只有把现状摸清楚了,我们才知道从哪里动刀最安全、最有效。