4、驱动分层架构:嵌入式驱动软件的分层设计思想

做嵌入式开发这些年,我见过太多人一上来就写驱动,把寄存器操作、业务逻辑、中断处理全塞在一个文件里。刚开始跑得挺好,等产品迭代到第二版、第三版,那代码简直没法看。说白了,驱动软件也需要「分而治之」——这就是分层架构的价值。

今天咱们聊聊NB-IoT驱动里的分层设计。我个人习惯把驱动软件分成四层:HAL层、驱动层、适配层、应用层。每一层各司其职,层与层之间通过接口通信。你想想看,这样做的好处是什么?

4.1 为什么需要分层?

先讲个我踩过的坑。几年前做一款NB-IoT模组驱动,当时赶工期,我把AT指令解析、串口收发、数据上报全写在一个模块里。结果呢?换了个模组型号,整个驱动几乎重写。老板问我为什么改个模组要两周,我都不好意思说代码耦合太严重。

分层架构要解决的核心问题有三个:

  • 解耦:硬件变了,上层代码不用动
  • 复用:同一套驱动代码,可以跑在不同芯片上
  • 可测试:每一层都能单独测试,不用等硬件就绪

核心原则:上层依赖下层接口,下层不依赖上层实现。说白了,就是「单向依赖」。

4.2 四层架构详解

4.2.1 HAL层(硬件抽象层)

HAL层是最贴近硬件的。它封装了MCU的外设寄存器操作,比如GPIO的拉高拉低、UART的收发、SPI的读写。这一层不关心业务逻辑,只做一件事:把硬件操作抽象成函数接口。

举个例子,我写过的一个HAL层接口:

/* hal_uart.h */
typedef struct {
    uint32_t baudrate;
    uint8_t data_bits;
    uint8_t stop_bits;
    uint8_t parity;
} hal_uart_config_t;

int32_t hal_uart_init(hal_uart_config_t *config);
int32_t hal_uart_send(uint8_t *data, uint32_t len);
int32_t hal_uart_recv(uint8_t *data, uint32_t timeout_ms);

你看,这里没有NB-IoT的任何影子。它就是纯粹的串口操作。换MCU?只需要重写这个HAL层,上面的代码纹丝不动。

我的习惯:HAL层的函数命名统一用 hal_ 前缀,这样一眼就能看出是硬件抽象接口。

4.2.2 驱动层(设备驱动层)

驱动层在HAL层之上。它负责管理具体的硬件设备,比如NB-IoT模组、传感器、LCD屏幕。这一层知道设备的工作流程,但不知道业务逻辑。

以NB-IoT模组为例,驱动层要处理:

  • 模组的初始化流程(上电、复位、等待就绪)
  • AT指令的封装与解析
  • 数据收发状态机
  • 异常处理与重试机制
/* nbiot_drv.h */
typedef struct {
    uint8_t imei[16];
    uint8_t imsi[16];
    uint8_t signal_quality;
    uint8_t network_status;
} nbiot_device_info_t;

int32_t nbiot_drv_init(void);
int32_t nbiot_drv_connect(void);
int32_t nbiot_drv_send_data(uint8_t *data, uint32_t len);
int32_t nbiot_drv_recv_data(uint8_t *buf, uint32_t *len);

嗯,这里要注意:驱动层不要直接操作寄存器。它应该调用HAL层的接口。比如发送AT指令,驱动层调用 hal_uart_send(),而不是直接写UART数据寄存器。

4.2.3 适配层(板级适配层)

适配层是我个人比较看重的一层。很多工程师会忽略它,但我在项目中吃过亏之后,就再也不敢省了。

适配层解决什么问题?同一个驱动,在不同板子上可能引脚不同、时钟不同、电源控制不同。适配层就是把这些「板级差异」抽出来。

/* board_adapter.h */
typedef struct {
    uint8_t uart_port;          /* 串口号 */
    uint8_t reset_pin;          /* 复位引脚 */
    uint8_t power_pin;          /* 电源控制引脚 */
    uint8_t wakeup_pin;         /* 唤醒引脚 */
    uint32_t clock_source;      /* 时钟源 */
} nbiot_board_config_t;

int32_t board_power_on(void);
int32_t board_power_off(void);
int32_t board_reset_device(void);
nbiot_board_config_t *board_get_config(void);

适配层的价值在哪?我举个例子。之前一个项目,NB-IoT模组从BC95换成BC35,驱动层代码几乎没动,只改了适配层的引脚配置和电源时序。一天就搞定了。

避坑指南:我曾经把板级配置直接写在驱动层里,结果换板子时改得焦头烂额。后来我强制要求:所有跟硬件引脚、板级参数相关的东西,必须放在适配层。

4.2.4 应用层(业务逻辑层)

应用层是最上层的。它不关心底层用的是什么MCU、什么模组,只关心业务逻辑:什么时候上报数据、数据怎么打包、要不要重传、怎么处理服务器下发的指令。

/* app_nbiot.h */
typedef struct {
    uint8_t temperature[4];
    uint8_t humidity[4];
    uint8_t pressure[4];
} sensor_data_t;

int32_t app_report_sensor_data(sensor_data_t *data);
int32_t app_handle_server_cmd(uint8_t *cmd, uint32_t len);
void app_main_loop(void);

应用层调用驱动层的接口,驱动层调用HAL层的接口,适配层提供板级配置。这就是完整的调用链。

4.3 模块划分原则

分层架构搭好了,模块怎么切?我总结了几条原则:

  1. 单一职责:一个模块只做一件事。比如AT指令解析模块,就别掺和数据处理。
  2. 接口稳定:层与层之间的接口一旦确定,尽量少改。我习惯先定义好接口头文件,再写实现。
  3. 依赖倒置:上层定义接口,下层实现接口。这样上层不依赖下层具体实现。
  4. 可配置性:通过宏定义或配置文件,让模块可以灵活裁剪。比如有些产品不需要OTA,那OTA模块就可以不编译。

我的经验:模块划分时,先画依赖图。如果发现A模块依赖B模块,B模块又依赖A模块,那就是设计有问题。赶紧拆。

4.4 实际项目中的分层示例

最后给个完整的例子。假设我们要做一个NB-IoT温湿度采集器,分层结构是这样的:

层级 模块 职责
应用层 app_main.c 业务逻辑:定时采集、上报、处理指令
驱动层 nbiot_drv.c NB-IoT模组驱动:AT指令、连接管理
驱动层 sht30_drv.c 温湿度传感器驱动:I2C读写、数据转换
适配层 board_adapter.c 板级配置:引脚映射、电源控制
HAL层 hal_uart.c 串口硬件抽象
HAL层 hal_i2c.c I2C硬件抽象
HAL层 hal_gpio.c GPIO硬件抽象

你看,每一层职责清晰,模块之间通过接口通信。换MCU?改HAL层。换模组?改驱动层。换板子?改适配层。业务逻辑?基本不用动。

这就是分层架构的魅力。说白了,好的架构设计,就是让你在项目迭代时少掉头发。我这些年带团队,一直强调:先搭架构,再写代码。架构稳了,后面的事就顺了。