/notes/mcu/embedded-c-oop-layering-guide

Firmware//28 分钟

嵌入式 C OOP 分层指导书:App、BSP、Components 与 Services 的边界

把 C 语言面向对象分层、BSP 边界、board 资源绑定、父类 ops 表和代码审查清单整理成一份可直接执行的工程规范。

嵌入式 COOPSTM32BSP架构分层

背景

本文档用于指导后续模块开发、重构和代码审查。目标是在 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.husart.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 层

职责:

  • 创建具体子类对象实例。
  • 绑定真实硬件资源,例如 GPIOCGPIO_PIN_8huart1
  • 完成子类初始化。
  • 将子类对象向上转型为父类句柄。
  • 向应用层提供父类句柄 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.htask.h

App 层不应包含:

  • gpio.h
  • usart.h
  • stm32xxxx_hal_xxx.h
  • 具体子类头,例如 gpio_led.huart_log.h
  • BSP 硬件操作头,例如 bsp_gpio.hbsp_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 为例。

  1. 建父类:
Components/Inc/sensor_base.h
Components/Src/sensor_base.c

内容包括:

  • struct sensor_base
  • struct sensor_ops
  • sensor_base_init()
  • sensor_base_read()
  1. 建子类:
Services/Inc/ntc_sensor.h
Services/Src/ntc_sensor.c

内容包括:

  • struct ntc_sensor { struct sensor_base base; ... }
  • static const struct sensor_ops ntc_sensor_ops
  • ntc_sensor_init()
  • 子类内部通过 BSP API 访问硬件
  1. 建 BSP:
BSP/Inc/bsp_adc.h
BSP/Src/bsp_adc.c

内容包括:

  • ADC 读取封装
  • HAL 调用只放在 .c 文件
  1. 建 board:
BSP/Inc/board_sensor.h
BSP/Src/board_sensor.c

内容包括:

  • static struct ntc_sensor s_xxx
  • board_sensor_init()
  • struct sensor_base *board_sensor_get_xxx(void)
  1. 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()
  • 文件内变量使用 statics_ 前缀,例如 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/ 中可以放两类代码:

  1. 具体子类,例如 gpio_led.cuart_log.c
  2. 面向 App 的服务封装,例如 log_service.c

服务封装规则:

  • 可以调用 base 统一接口。
  • 不直接调用 HAL。
  • 不直接包含具体硬件头。
  • 对外接口表达业务含义,例如 log_service_print()

代码审查检查清单

提交或合入前,逐项检查:

  • App 层是否没有 HAL_GPIO_PIN_UART_HandleTypeDef
  • App 层是否没有包含 gpio.husart.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.hBSP/Inc/board_log.h 不暴露具体子类头。
  • Services/Src/gpio_led.cServices/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 做合规验证。

工程分层的价值不是让目录更多,而是让硬件变化、业务变化和公共接口变化各自停在正确的边界内。