2. MCU与中间件基础

做嵌入式开发久了,你会发现一个规律:代码的腐烂速度,往往比硬件老化快得多。我刚入行那会儿,接手过一个智能家居项目。LED 控制、按键扫描、串口通信全揉在一个文件里,足足 3000 多行。改一个呼吸灯效果,得翻遍整个文件,生怕动到别的地方。后来我花了整整两周重构,才把项目从崩溃边缘拉回来。

那次经历让我彻底明白——模块化设计不是花架子,是保命符。这一章,我们就来聊聊怎么把模块化思想落地到 MCU 开发中。

为什么需要分层架构?

你想想看,一个典型的 MCU 项目,通常包含这些要素:

  • 操作 GPIO 点亮 LED
  • 通过定时器产生 PWM
  • 用 SPI/I2C 读写传感器
  • 处理中断和任务调度

如果所有代码都堆在一起,会是什么后果?

我曾经见过一个项目:更换 MCU 型号时,因为 GPIO 操作和业务逻辑深度耦合,整个应用层代码几乎要重写。项目经理当场崩溃——那可是一万多行代码啊。

分层架构的核心思路很简单:把不同职责的代码放到不同层级。我习惯分成三层:

层级 职责 典型内容
应用层 业务逻辑、状态机、用户交互 LED 闪烁模式、按键响应逻辑
中间件层 通用功能模块、协议封装 LED 驱动接口、定时器抽象
硬件抽象层 (HAL) MCU 外设操作、寄存器读写 GPIO 置位/复位、定时器配置

这样做的好处很明显:换 MCU 时,只需要改 HAL 层。应用层和中间件层基本不用动。我在 STM32 和 GD32 之间移植过项目,只花了半天就搞定了——因为底层接口长得一模一样。

模块解耦:让每个模块只做一件事

解耦这个词听起来高大上,说白了就是:别让 A 模块直接操作 B 模块的内部变量。

举个例子。一个常见的 LED 驱动,很多人会这样写:

// 不好的写法:直接操作 GPIO 寄存器
void led_on(void) {
    GPIOA->BSRR = (1 << 5);  // PA5 置高
}

void led_off(void) {
    GPIOA->BRR = (1 << 5);   // PA5 置低
}

这段代码有什么问题?它把硬件细节暴露给了调用者。如果哪天换了 PCB,LED 从 PA5 改到 PB3,你得改所有调用 led_on/led_off 的地方。

我建议的做法是:引入一个配置层。

// led_cfg.h - 硬件配置
#define LED_PORT   GPIOA
#define LED_PIN    GPIO_PIN_5

// led_drv.h - 驱动接口
void led_init(void);
void led_set(uint8_t state);
void led_toggle(void);

// led_drv.c - 驱动实现
#include "led_cfg.h"
#include "hal_gpio.h"

void led_init(void) {
    hal_gpio_init(LED_PORT, LED_PIN, GPIO_MODE_OUTPUT);
}

void led_set(uint8_t state) {
    if (state) {
        hal_gpio_write(LED_PORT, LED_PIN, 1);
    } else {
        hal_gpio_write(LED_PORT, LED_PIN, 0);
    }
}

这样改完之后,应用层只调用 led_set(1) 和 led_set(0),根本不知道底层用的是哪个引脚。换硬件?改一下 led_cfg.h 就行。

一个小技巧:我习惯把配置信息单独放在一个头文件里,不跟驱动实现混在一起。这样硬件工程师改板子时,直接扔给我一个配置头文件就行,代码完全不用动。

接口设计原则:稳定比什么都重要

接口是模块之间的契约。一旦定下来,尽量不要改。我在项目中总结了几条原则:

  • 参数尽量简单:能用基本类型就别用结构体。比如 led_set(uint8_t state),比 led_set(led_state_t *p_state) 好得多。
  • 返回值要明确:成功返回 0,失败返回负值。别搞什么 0 表示成功、1 表示失败——那会让调用者晕头转向。
  • 命名要自解释:led_init、led_set、led_toggle,一看就知道干什么。别用 led_func1、led_proc 这种名字。

举个例子,一个设计良好的定时器接口:

// timer_if.h - 定时器抽象接口
typedef void (*timer_callback_t)(void *arg);

int timer_init(uint32_t period_ms, timer_callback_t cb, void *arg);
int timer_start(void);
int timer_stop(void);
int timer_set_period(uint32_t period_ms);

这个接口有什么好处?它不依赖任何具体的定时器硬件。底层可以用 SysTick,也可以用 TIM2,甚至可以用一个软件定时器。只要接口不变,上层代码纹丝不动。

依赖倒置:让高层不依赖底层细节

依赖倒置原则(DIP)听起来很玄乎,其实就一句话:高层模块不应该依赖低层模块,两者都应该依赖抽象。

怎么理解?看个例子。假设你的应用层需要读取温度传感器:

// 不好的写法:应用层直接调用传感器驱动
void app_temperature_task(void) {
    int temp = ds18b20_read_temperature();
    // 处理温度数据...
}

如果哪天换了传感器,从 DS18B20 换成 SHT30,你得改应用层代码。这违反了开闭原则。

用依赖倒置的思路,应该这样设计:

// sensor_if.h - 传感器抽象接口
typedef struct {
    int (*init)(void);
    int (*read)(int *value);
    int (*deinit)(void);
} sensor_t;

// app_temperature.c - 应用层
void app_temperature_task(sensor_t *sensor) {
    int temp;
    if (sensor->read(&temp) == 0) {
        // 处理温度数据...
    }
}

这样,应用层只依赖 sensor_t 这个抽象接口,不依赖具体的传感器型号。换传感器时,只需要提供一个新的 sensor_t 实例。

我在项目中用过这个模式:一个产品需要支持 3 种不同的温湿度传感器,分别来自不同供应商。我定义了一个统一的 sensor_if.h 接口,每种传感器写一个适配层。应用层代码一行没改,就完成了多传感器兼容。

实战案例:LED 驱动模块化重构

说了这么多理论,我们来动真格的。下面是一个完整的 LED 驱动模块化重构案例。

原始代码(混乱版):

// main.c - 所有代码揉在一起
#include "stm32f1xx.h"

void delay_ms(uint32_t ms) {
    for (uint32_t i = 0; i < ms * 1000; i++);
}

int main(void) {
    RCC->APB2ENR |= (1 << 2);  // 使能 GPIOA 时钟
    GPIOA->CRL &= ~(0xF << 20);
    GPIOA->CRL |= (0x2 << 20); // PA5 推挽输出

    while (1) {
        GPIOA->BSRR = (1 << 5);  // 亮
        delay_ms(500);
        GPIOA->BRR = (1 << 5);   // 灭
        delay_ms(500);
    }
}

这段代码的问题很明显:硬件操作、延时逻辑、主循环全混在一起。换 MCU 得重写整个文件。

重构后的模块化版本:

第一步:定义硬件抽象层接口。

// hal_gpio.h
#ifndef HAL_GPIO_H
#define HAL_GPIO_H

#include <stdint.h>

typedef enum {
    HAL_GPIO_MODE_INPUT,
    HAL_GPIO_MODE_OUTPUT,
    HAL_GPIO_MODE_AF,
    HAL_GPIO_MODE_ANALOG
} hal_gpio_mode_t;

void hal_gpio_init(uint32_t port, uint32_t pin, hal_gpio_mode_t mode);
void hal_gpio_write(uint32_t port, uint32_t pin, uint8_t value);
uint8_t hal_gpio_read(uint32_t port, uint32_t pin);

#endif

第二步:实现 LED 驱动模块。

// led_drv.h
#ifndef LED_DRV_H
#define LED_DRV_H

#include <stdint.h>

void led_init(void);
void led_on(void);
void led_off(void);
void led_toggle(void);

#endif

// led_drv.c
#include "led_drv.h"
#include "led_cfg.h"
#include "hal_gpio.h"

void led_init(void) {
    hal_gpio_init(LED_PORT, LED_PIN, HAL_GPIO_MODE_OUTPUT);
    led_off();
}

void led_on(void) {
    hal_gpio_write(LED_PORT, LED_PIN, LED_ON_LEVEL);
}

void led_off(void) {
    hal_gpio_write(LED_PORT, LED_PIN, LED_OFF_LEVEL);
}

void led_toggle(void) {
    uint8_t state = hal_gpio_read(LED_PORT, LED_PIN);
    hal_gpio_write(LED_PORT, LED_PIN, !state);
}

第三步:配置信息独立出来。

// led_cfg.h
#ifndef LED_CFG_H
#define LED_CFG_H

#include "hal_gpio.h"

#define LED_PORT    GPIOA_BASE
#define LED_PIN     5
#define LED_ON_LEVEL    1   // 高电平点亮
#define LED_OFF_LEVEL   0

#endif

第四步:应用层只调用 LED 驱动接口。

// app_led.c
#include "led_drv.h"
#include "app_timer.h"

static void led_blink_callback(void *arg) {
    led_toggle();
}

void app_led_start_blink(uint32_t period_ms) {
    led_init();
    timer_start(period_ms, led_blink_callback, NULL);
}

重构之后,每个模块的职责非常清晰:

  • hal_gpio:封装 GPIO 寄存器操作,与 MCU 型号绑定
  • led_drv:提供 LED 控制接口,不关心硬件细节
  • led_cfg:存放硬件配置,改板子时只需改这里
  • app_led:实现业务逻辑,比如闪烁、呼吸灯效果

换 MCU 时,只需要重写 hal_gpio 层。其他代码完全不用动。我在 STM32F103 和 GD32F303 之间做过移植,只改了 hal_gpio.c 一个文件,总共不到 100 行代码。

注意:模块化不是越细越好。我见过有人把 LED 驱动拆成 5 个文件,结果光包含头文件就绕晕了。我的经验是:一个模块一个 .c 文件,一个 .h 文件,功能内聚,接口简洁。

本章知识结构

下面这张图总结了模块化设计的核心逻辑:

模块化设计核心结构 应用层 中间件层 硬件抽象层 (HAL) MCU 硬件 关键原则 • 单向依赖:上层 调用下层接口 • 接口稳定:抽象 接口不轻易修改 • 配置分离:硬件 参数独立成文件 • 模块解耦:每个 模块只做一件事 • 依赖倒置:高层 依赖抽象接口 依赖方向

这张图展示了模块化设计的核心思想:上层依赖下层,但下层不依赖上层。每一层只通过接口与相邻层通信,互不干扰。