背景
本文档用于指导后续模块开发、重构和代码审查。目标是在 C 语言工程中落地面向对象设计思想,通过 struct 嵌入、ops 函数表、container_of、board 层资源绑定和父类句柄向上转型,让应用层不直接依赖具体硬件、具体子类或 STM32 HAL。
核心原则很简单:应用层只表达业务,硬件访问收口到 BSP 边界,设备对象通过父类接口向上提供能力。
核心目标:
- 应用层只表达业务,不认识硬件细节。
- 设备子类只实现自身能力,不负责板级资源选择。
- board 层只负责对象实例化、硬件资源绑定和向上转型。
- BSP 层是工程中唯一直接封装 HAL 外设访问的底层边界。
- 公共头文件不泄露不必要的下层头文件。
当前工程层级
当前工程按以下目录组织:
App/ 应用层:任务、业务流程、业务调用
BSP/ 板级层和 BSP 层:硬件资源绑定、HAL 封装
Components/ 抽象父类层:base 类型、ops 类型、统一接口、通用工具
Services/ 具体实现层:子类、服务封装
Core/ CubeMX 生成层:main、gpio、usart、tim、FreeRTOS 入口
Drivers/ ST HAL/CMSIS 驱动
Middlewares/ FreeRTOS 等第三方中间件
推荐的依赖方向:
App
-> Services service API / BSP board API / Components base API
BSP board
-> Services concrete child + Core generated handles
Services child
-> Components base + BSP hardware API
Components base
-> C standard fixed-width types only
BSP hardware API
-> Core generated headers + HAL
Core / Drivers / Middlewares
-> 厂商和中间件代码,业务代码不反向修改其架构
禁止反向依赖和跨层偷调。
四层 OOP 模块模型
对一个设备类功能模块,例如 LED、Log、Sensor、Motor,优先使用以下四层模型。
BSP 层
职责:
- 封装 HAL 或寄存器级硬件访问。
- 将厂商 API 收拢到少量项目 API 中。
- 不表达业务含义,不保存业务状态。
文件位置:
BSP/Inc/bsp_xxx.h
BSP/Src/bsp_xxx.c
规则:
bsp_xxx.h不应包含gpio.h、usart.h等 CubeMX 头,除非公共 API 必须暴露对应类型。bsp_xxx.c可以包含 CubeMX 头和调用 HAL。- BSP API 必须使用
bsp_前缀,例如bsp_gpio_set_pin()。 - 指针参数使用前必须检查
NULL。 - 如果调用阻塞式 HAL API,必须知道阻塞点,并将阻塞限制在 BSP 边界内。
示例:
void bsp_gpio_set_pin(void *gpio_port, uint16_t gpio_pin);
void bsp_gpio_reset_pin(void *gpio_port, uint16_t gpio_pin);
base 父类层
职责:
- 定义抽象父类结构体。
- 定义
ops函数表类型。 - 提供统一公共接口。
- 做公共参数防御。
文件位置:
Components/Inc/xxx_base.h
Components/Src/xxx_base.c
规则:
- 不包含 BSP、HAL、CubeMX、具体子类头。
ops指针必须是const struct xxx_ops *。- 父类统一接口只通过
ops调用子类实现。 - 父类接口不访问具体子类字段。
- 必填虚函数必须检查是否为
NULL。
示例:
struct led_base;
typedef void (*led_on_func)(struct led_base *led);
typedef void (*led_off_func)(struct led_base *led);
struct led_ops {
led_on_func led_on;
led_off_func led_off;
};
struct led_base {
const struct led_ops *ops;
const char *name;
};
子类层
职责:
- 定义具体设备类型。
- 将父类作为结构体成员嵌入。
- 实现父类
ops表。 - 在子类 init 中调用父类 init,完成 ops 绑定。
- 通过 BSP 或 platform API 操作硬件。
文件位置:
Services/Inc/xxx_child.h
Services/Src/xxx_child.c
规则:
- 子类头可以包含父类头。
- 子类头不要包含 BSP/HAL/CubeMX 头,除非公共结构体无法避免。
- 子类
.c可以包含 BSP 头。 - 子类 ops 表必须是
static const。 - ops 函数只在本文件使用时必须是
static。 - 从父类指针恢复子类指针时使用
container_of。
示例:
struct gpio_led {
struct led_base base;
void *gpio_port;
uint16_t gpio_pin;
bool is_active_high;
};
ops 实现示例:
static const struct led_ops gpio_led_ops = {
.led_on = gpio_led_on,
.led_off = gpio_led_off
};
board 层
职责:
- 创建具体子类对象实例。
- 绑定真实硬件资源,例如
GPIOC、GPIO_PIN_8、huart1。 - 完成子类初始化。
- 将子类对象向上转型为父类句柄。
- 向应用层提供父类句柄 getter。
文件位置:
BSP/Inc/board_xxx.h
BSP/Src/board_xxx.c
规则:
board_xxx.h只暴露初始化函数和父类句柄 getter。board_xxx.h不暴露具体子类头、BSP 头、CubeMX 头。board_xxx.c可以包含子类头和 CubeMX 生成头。- board 层变量必须是
static,例如s_led1。 - getter 返回父类指针,不返回子类指针。
示例:
void board_led_init(void);
struct led_base *board_led_get_led1(void);
App 层使用规则
App 层只允许表达业务流程。
允许:
led_base_on(board_led_get_led1());
log_service_print(board_log_get_uart(), "Hello\r\n");
vTaskDelay(pdMS_TO_TICKS(APP_HEARTBEAT_PERIOD_MS));
禁止:
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_8, GPIO_PIN_SET);
HAL_UART_Transmit(&huart1, data, len, timeout);
#include "gpio.h"
#include "usart.h"
#include "gpio_led.h"
App 层可以包含:
- 自己的
app_xxx.h - 需要调用的服务 API,例如
log_service.h - board 句柄 API,例如
board_led.h - base 统一接口,例如
led_base.h - RTOS API,例如
FreeRTOS.h、task.h
App 层不应包含:
gpio.husart.hstm32xxxx_hal_xxx.h- 具体子类头,例如
gpio_led.h、uart_log.h - BSP 硬件操作头,例如
bsp_gpio.h、bsp_uart.h
include 规则
头文件包含遵循“谁使用,谁包含;能前向声明就前向声明”的原则。
公共头文件必须自包含,但不应泄露实现细节。
推荐:
#include "led_base.h"
void board_led_init(void);
struct led_base *board_led_get_led1(void);
不推荐:
#include "gpio_led.h"
#include "bsp_gpio.h"
#include "gpio.h"
.c 文件可以包含完成实现所需的下层头:
#include "board_led.h"
#include "gpio_led.h"
#include "gpio.h"
board 实现文件知道硬件资源,这是允许的;board 头文件不应让应用层也知道这些硬件资源。
初始化顺序
当前工程初始化顺序:
main()
-> MX_GPIO_Init()
-> MX_TIM1_Init()
-> MX_USART1_UART_Init()
-> MX_FREERTOS_Init()
-> default task
-> app_init()
-> board_log_init()
-> board_led_init()
-> xTaskCreate(app_heartbeat_task)
-> xTaskCreate(app_log_task)
规则:
- CubeMX 外设初始化必须早于 board 层绑定。
- board init 必须早于 App 任务使用 getter。
- getter 可能返回
NULL,上层统一接口必须能防御。 - 新增设备时,必须在
app_init()或系统初始化阶段调用对应board_xxx_init()。
新增设备模块步骤
以新增 sensor 为例。
- 建父类:
Components/Inc/sensor_base.h
Components/Src/sensor_base.c
内容包括:
struct sensor_basestruct sensor_opssensor_base_init()sensor_base_read()
- 建子类:
Services/Inc/ntc_sensor.h
Services/Src/ntc_sensor.c
内容包括:
struct ntc_sensor { struct sensor_base base; ... }static const struct sensor_ops ntc_sensor_opsntc_sensor_init()- 子类内部通过 BSP API 访问硬件
- 建 BSP:
BSP/Inc/bsp_adc.h
BSP/Src/bsp_adc.c
内容包括:
- ADC 读取封装
- HAL 调用只放在
.c文件
- 建 board:
BSP/Inc/board_sensor.h
BSP/Src/board_sensor.c
内容包括:
static struct ntc_sensor s_xxxboard_sensor_init()struct sensor_base *board_sensor_get_xxx(void)
- App 使用:
sensor_base_read(board_sensor_get_xxx(), &value);
App 不直接 include ntc_sensor.h,不直接调用 bsp_adc_read()。
命名与修饰符规则
模块命名:
base 层:xxx_base.h / xxx_base.c
子类层:具体类型名,例如 gpio_led.h / uart_log.h
BSP 层:bsp_xxx.h / bsp_xxx.c
board 层:board_xxx.h / board_xxx.c
App 层:app_xxx.h / app_xxx.c
符号命名:
- 公共函数使用模块前缀,例如
led_base_on()。 - BSP 函数使用
bsp_前缀,例如bsp_uart_tx()。 - board 函数使用
board_前缀,例如board_led_get_led1()。 - 文件内变量使用
static和s_前缀,例如s_led1。 - ops 表使用
static const,例如gpio_led_ops。 - 布尔字段使用
is_、has_、can_前缀。
必须使用 const 的场景:
- ops 表指针:
const struct xxx_ops *ops - ops 表实例:
static const struct xxx_ops xxx_ops - 不修改输入数据的参数:
const uint8_t *data - 不修改字符串的参数:
const char *name
必须使用 static 的场景:
- 子类 ops 函数。
- 子类 ops 表。
- board 层对象实例。
- board 层保存的父类句柄。
- 文件内 helper 函数。
禁止把内部实现符号暴露为全局符号。
container_of 使用规则
当父类统一接口调用到子类 ops 后,子类函数拿到的是父类指针。需要恢复子类对象时,必须使用 container_of。
统一使用:
#include "container_of.h"
示例:
static void gpio_led_on(struct led_base *base)
{
struct gpio_led *led = NULL;
if (base == NULL) {
return;
}
led = container_of(base, struct gpio_led, base);
...
}
规则:
- 不在 App 层使用
container_of。 - 不在 board 层为了访问子类字段而下转型。
- 只在子类内部从父类指针恢复子类指针。
- 使用前先检查父类指针是否为
NULL。
硬件访问规则
允许直接调用 HAL 的位置:
Core/生成代码。Drivers/厂商代码。BSP/Src/bsp_xxx.c。BSP/Src/board_xxx.c中绑定硬件资源时使用 CubeMX 句柄和宏。
不允许直接调用 HAL 的位置:
App/Components/Services/,除非该文件明确属于 BSP/platform 后端。
阻塞 API 规则:
- 任何
HAL_xxx_Transmit()、HAL_xxx_Receive()、Flash 擦写、I2C/SPI 轮询事务都应视为可能阻塞。 - 阻塞调用必须被限制在 BSP 或驱动内部工作线程中。
- App 层不得在业务路径中直接发起阻塞硬件操作。
- 如果后续采用事件驱动或 RTC 状态机,硬件调用链必须改成异步 DMA/IT + 队列 + 回调。
服务层规则
Services/ 中可以放两类代码:
- 具体子类,例如
gpio_led.c、uart_log.c。 - 面向 App 的服务封装,例如
log_service.c。
服务封装规则:
- 可以调用 base 统一接口。
- 不直接调用 HAL。
- 不直接包含具体硬件头。
- 对外接口表达业务含义,例如
log_service_print()。
代码审查检查清单
提交或合入前,逐项检查:
- App 层是否没有
HAL_、GPIO_PIN_、UART_HandleTypeDef。 - App 层是否没有包含
gpio.h、usart.h、具体子类头、BSP 硬件头。 Components是否没有包含 BSP/HAL/CubeMX 头。board_xxx.h是否只暴露 init 和父类 getter。board_xxx.c是否负责子类实例化和硬件资源绑定。- 子类结构体是否嵌入父类。
- 子类是否通过
static const ops注册虚函数表。 - 子类 ops 函数是否是
static。 - 子类是否通过
container_of恢复自身对象。 - BSP 头是否避免暴露 CubeMX 头。
- BSP
.c是否检查指针参数。 - 非公共函数和变量是否全部
static。 - 不修改的指针参数是否使用
const。 - 魔法数字是否提成宏。
- Keil rebuild 是否
0 Error(s), 0 Warning(s)。
推荐检索命令
检查 App 和 Components 是否误碰硬件:
rg -n 'HAL_|GPIO[A-Z]|GPIO_PIN_|UART_HandleTypeDef|#include "gpio.h"|#include "usart.h"' App Components
检查公共头是否泄露实现:
rg -n '#include "gpio_led.h"|#include "uart_log.h"|#include "bsp_gpio.h"|#include "bsp_uart.h"|#include "gpio.h"|#include "usart.h"' BSP\Inc Services\Inc Components\Inc App\Inc
检查是否仍有非 static 的内部符号:
rg -n '^void gpio_led_|^struct led_ops .*ops|^struct gpio_led ' Services BSP Components
Keil 全量重编译:
& 'D:\Keil5\UV4\UV4.exe' -r 'MDK-ARM\opp_rtos_stm32.uvprojx' -t 'opp_rtos_stm32'
常见反例
App 直接操作硬件
错误:
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_8, GPIO_PIN_SET);
正确:
led_base_on(board_led_get_led1());
board 头暴露子类
错误:
#include "gpio_led.h"
#include "gpio.h"
正确:
#include "led_base.h"
子类 ops 非 const
错误:
struct led_ops gpio_led_ops = {
...
};
正确:
static const struct led_ops gpio_led_ops = {
...
};
强制类型转换代替 container_of
错误:
struct uart_log_backend *backend = (struct uart_log_backend *)base;
正确:
struct uart_log_backend *backend =
container_of(base, struct uart_log_backend, base);
合规基线
当前工程的合规基线:
App/不直接调用 HAL。Components/不依赖 BSP/HAL/CubeMX。BSP/Inc/board_led.h和BSP/Inc/board_log.h不暴露具体子类头。Services/Src/gpio_led.c和Services/Src/uart_log.c使用container_of恢复子类对象。- ops 表使用
static const。 - Keil 全量 rebuild 通过
0 Error(s), 0 Warning(s)。
后续新增模块必须保持以上基线。
结论
- 分层边界:App 表达业务,Services 表达服务语义,Components 提供抽象接口,BSP/board 收口硬件资源与 HAL 访问。
- OOP 机制:父类
ops表、子类嵌入父类、static const虚函数表和container_of是核心骨架。 - 交付底线:公共头不泄露实现、阻塞硬件调用不进入业务路径、提交前用检索命令和 Keil rebuild 做合规验证。
工程分层的价值不是让目录更多,而是让硬件变化、业务变化和公共接口变化各自停在正确的边界内。