第二章:代码坏味道识别——你的代码正在发出求救信号

说实话,我见过太多嵌入式项目死在维护阶段。不是功能做不出来,而是代码烂到没人敢改。你想想看,一个函数上千行,一个文件上万行,改一个 bug 冒出三个新 bug——这种项目我接手过不止一次。

这一章,我们就来聊聊怎么识别这些“坏味道”。说白了,就是给你的代码做一次体检。我个人的习惯是,每次重构前先花半天时间做“坏味道扫描”,这比直接动手改代码要高效得多。

核心观点:坏味道不是 bug,但它会孕育 bug。识别坏味道,是重构的第一步,也是最关键的一步。

代码坏味道识别 · 六大核心维度 代码坏味道 ① 过长函数 ② 过大类 ③ 重复代码 ④ 过度耦合 ⑤ 命名混乱 ⑥ 魔法数字 💡 这六种坏味道往往同时出现。一个过长函数里大概率藏着魔法数字, 一个过大类通常伴随着过度耦合。识别时要有全局视角。

2.1 过长函数:一个函数做了太多事

我见过最夸张的一个函数,足足 800 行。它负责什么?初始化、数据采集、协议解析、状态机切换、异常处理——全塞在一起。你问我怎么知道的?因为我就是那个接手的人。

判断标准其实很简单:如果一个函数你无法在一屏内看完,它就太长了。我个人习惯是,函数超过 30 行就要开始警惕,超过 50 行必须拆分。

我的经验法则:一个函数只做一件事。如果你不能用一句话说清楚这个函数在干什么,那它一定做了太多事。

来看一个典型的坏味道例子:

// 坏味道:一个函数做了三件事
void process_sensor_data(void) {
    // 1. 读取原始数据
    uint32_t raw = read_adc(ADC_CHANNEL_1);
    
    // 2. 数据转换 + 滤波
    float voltage = (float)raw * 3.3f / 4096.0f;
    if (voltage < 0.1f) voltage = 0.0f;
    if (voltage > 3.2f) voltage = 3.2f;
    float filtered = voltage * 0.7f + last_voltage * 0.3f;
    last_voltage = filtered;
    
    // 3. 阈值判断 + 报警
    if (filtered > 2.8f) {
        set_alarm(ALARM_HIGH);
        led_on(LED_RED);
    } else if (filtered < 0.5f) {
        set_alarm(ALARM_LOW);
        led_on(LED_YELLOW);
    }
}

你看,这个函数里混杂了硬件读取、数学运算、业务逻辑。改任何一个部分都可能影响其他部分。正确的做法是拆成三个独立函数:

// 重构后:每个函数职责清晰
float read_sensor_voltage(void) {
    uint32_t raw = read_adc(ADC_CHANNEL_1);
    return (float)raw * 3.3f / 4096.0f;
}

float apply_lowpass_filter(float input) {
    return input * 0.7f + last_voltage * 0.3f;
}

void check_alarm_thresholds(float voltage) {
    if (voltage > 2.8f) {
        set_alarm(ALARM_HIGH);
    } else if (voltage < 0.5f) {
        set_alarm(ALARM_LOW);
    }
}

2.2 过大类:一个类承担了太多责任

过大类和过长函数是亲兄弟。一个类如果包含了完全不相关的功能,那它就是“上帝类”。我在一个工控项目里见过一个类,它既管串口通信,又管 UI 显示,还管数据存储——整整 3000 行。

怎么判断?看这个类的成员变量和方法的关联度。如果一半方法只用到了三分之一成员变量,那这个类就该拆了。

判断指标 健康 警告 危险
类行数 < 200 200 - 500 > 500
方法数量 < 10 10 - 20 > 20
成员变量数 < 8 8 - 15 > 15
功能领域数 1 2 ≥ 3

我曾经踩过的坑:有一个驱动类,里面混了 I2C 和 SPI 两种总线的操作。后来换芯片,I2C 时序变了,我改的时候不小心动了 SPI 的部分,结果两个外设都挂了。从那以后,我坚持一个类只封装一种协议。

2.3 重复代码:复制粘贴的代价

重复代码是嵌入式开发里最常见的坏味道。为什么?因为赶进度的时候,Ctrl+C、Ctrl+V 最快。但你要知道,每一处重复都是一颗定时炸弹——改了一处忘了改另一处,bug 就来了。

我见过最离谱的,一个项目里同样的 CRC 校验算法写了 7 遍,每遍还有细微差别。后来发现通信偶尔出错,查了三天,原来是其中一版把多项式系数写错了。

重复代码的三种常见形式:

  • 完全重复:一模一样的代码块出现多次。直接提取成函数。
  • 结构重复:逻辑结构相同,只是参数不同。用函数参数化解决。
  • 功能重复:实现相同功能但写法不同。统一实现,消除冗余。
// 坏味道:重复代码
void init_uart1(void) {
    RCC->APB2ENR |= (1 << 14);  // 使能 USART1 时钟
    GPIOA->CRH &= 0xFFFFFF00;
    GPIOA->CRH |= 0x000000B0;   // 配置 TX/RX 引脚
    USART1->BRR = 0x1D4C;       // 设置波特率 115200
    USART1->CR1 |= 0x200C;      // 使能 USART
}

void init_uart2(void) {
    RCC->APB1ENR |= (1 << 17);  // 使能 USART2 时钟
    GPIOA->CRL &= 0xFFFF00FF;
    GPIOA->CRL |= 0x0000B000;   // 配置 TX/RX 引脚
    USART2->BRR = 0x1D4C;       // 设置波特率 115200
    USART2->CR1 |= 0x200C;      // 使能 USART
}

重构后,用一个参数化的函数搞定:

// 重构后:参数化消除重复
typedef struct {
    USART_TypeDef *usart;
    uint32_t       clock_enable_bit;
    GPIO_TypeDef  *gpio;
    uint32_t       crl_mask, crl_value;
} UART_Config;

void init_uart(const UART_Config *cfg) {
    // 使能时钟
    if ((uint32_t)cfg->usart == USART1_BASE)
        RCC->APB2ENR |= cfg->clock_enable_bit;
    else
        RCC->APB1ENR |= cfg->clock_enable_bit;
    
    // 配置 GPIO
    cfg->gpio->CRL &= cfg->crl_mask;
    cfg->gpio->CRL |= cfg->crl_value;
    
    // 设置波特率并使能
    cfg->usart->BRR = 0x1D4C;
    cfg->usart->CR1 |= 0x200C;
}

2.4 过度耦合:牵一发而动全身

过度耦合是嵌入式软件里最隐蔽的坏味道。它不像重复代码那么明显,但破坏力更大。说白了,就是模块之间你中有我、我中有你,改一个地方要连带改一堆地方。

我参与过一个智能家居项目,业务逻辑层直接调用了 HAL 层的寄存器操作。后来换了一款 MCU,所有业务代码都得重写——因为耦合太深了。

判断过度耦合的几个信号:

  • 修改一个模块,需要同时修改 3 个以上其他模块
  • 模块 A 直接访问模块 B 的内部数据结构
  • 全局变量满天飞,到处都是 extern
  • 单元测试写不了,因为依赖太多

解耦的核心原则:高层模块不依赖低层模块,两者都依赖抽象接口。这就是依赖倒置原则。在嵌入式里,具体做法就是定义清晰的接口层,业务代码只跟接口打交道。

2.5 命名混乱:代码是最好的文档?别骗自己了

“代码是最好的文档”——这句话我信,但前提是命名要清晰。我见过变量名叫 a、b、c 的,也见过 temp1、temp2、temp3 的。你想想看,三个月后你自己回来看,还记得 temp2 是干什么的吗?

命名混乱的典型表现:

坏命名 问题 好命名
int a, b, c; 无意义缩写 int sensor_raw, filtered_value;
void do_stuff(void) 动词模糊 void calibrate_sensor(void)
#define FLAG 0x01 无上下文 #define FLAG_SENSOR_READY 0x01
int flag1, flag2; 数字后缀 int is_sensor_ready, is_tx_complete;

我的命名习惯:变量名用名词,函数名用动词+名词,宏定义全大写加前缀。看一眼名字就知道它是什么、干什么用。别怕名字长,长名字比烂名字好一万倍。

2.6 魔法数字:藏在代码里的神秘常数

魔法数字,就是代码里直接出现的、没有解释的数值。比如 if (x > 42),42 是什么?没人知道。我见过一个通信协议栈,里面到处都是 0x55、0xAA、0x7E,后来查文档才知道是帧头帧尾标志。

魔法数字的问题在于:

  • 可读性差:别人看不懂这个数字的含义
  • 可维护性差:要改的时候得全文搜索,还容易漏
  • 容易出错:同一个数字在不同地方可能代表不同含义
// 坏味道:魔法数字
void configure_timer(void) {
    TIM2->PSC = 7199;      // 这是啥?
    TIM2->ARR = 999;       // 这又是啥?
    TIM2->CR1 |= 0x01;     // 0x01 代表什么?
}

// 重构后:用宏定义消除魔法数字
#define TIMER_CLOCK_HZ     72000000
#define TIMER_TARGET_HZ    10000
#define TIMER_PSC_VALUE    (TIMER_CLOCK_HZ / TIMER_TARGET_HZ - 1)
#define TIMER_ARR_VALUE    999
#define TIMER_CR1_ENABLE   ((uint16_t)0x01)

void configure_timer(void) {
    TIM2->PSC = TIMER_PSC_VALUE;
    TIM2->ARR = TIMER_ARR_VALUE;
    TIM2->CR1 |= TIMER_CR1_ENABLE;
}

我曾经犯过的错:有一个延时函数里直接写了 for (i = 0; i < 100000; i++)。后来换了更高主频的芯片,所有延时都变短了。因为那个 100000 是魔法数字,没人知道它是根据 72MHz 算出来的。从那以后,所有延时我都用宏定义,并且注释写明计算依据。

2.7 坏味道识别清单:快速自查

最后,我整理了一份自查清单。每次代码审查的时候,我都会拿它过一遍:

  1. 函数是否超过 30 行? → 考虑拆分
  2. 类是否超过 200 行? → 考虑拆类
  3. 是否有复制粘贴的代码? → 提取公共函数
  4. 修改一个模块是否需要改其他模块? → 检查耦合
  5. 变量/函数名是否一看就懂? → 重命名
  6. 代码里是否有未解释的常数? → 定义宏或枚举