第二章:代码坏味道识别——你的代码正在发出求救信号
说实话,我见过太多嵌入式项目死在维护阶段。不是功能做不出来,而是代码烂到没人敢改。你想想看,一个函数上千行,一个文件上万行,改一个 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 坏味道识别清单:快速自查
最后,我整理了一份自查清单。每次代码审查的时候,我都会拿它过一遍:
- 函数是否超过 30 行? → 考虑拆分
- 类是否超过 200 行? → 考虑拆类
- 是否有复制粘贴的代码? → 提取公共函数
- 修改一个模块是否需要改其他模块? → 检查耦合
- 变量/函数名是否一看就懂? → 重命名
- 代码里是否有未解释的常数? → 定义宏或枚举