2.一套可落地的 STM32 六层架构:基础工程框架搭建

1. 本篇目标

上一篇文章《MCU项目软件分层设计规范--以 MCU 项目为例》介绍了六层架构(app / service / device / bsp / platform / component)的设计原则和各层职责。但有了规范之后,最常遇到的第一个问题是:

"规范我知道了,但第一行代码从哪里开始?"

本篇的目标就是回答这个问题------把一套分层规范,落地成一个可编译、可下载、可验证的 STM32 基础工程框架。

读完本篇后,你将掌握:

  • 如何在 STM32 工程中创建并组织 app / service / device / bsp / platform / component 六层目录
  • 各层目录的职责边界是什么、什么代码该放哪一层
  • 如何设计工程的统一初始化入口,避免初始化顺序混乱
  • 如何定义模块的统一接口风格(命名、返回值、错误码)
  • 如何搭建公共基础设施(错误码、日志宏、断言、版本信息)
  • 如何验证工程已正确启动

本篇以 STM32F429 + RT-Thread 为例实现。如果你使用 FreeRTOS 或裸机,分层思路和目录结构完全一致,只需调整构建方式和启动流程即可------文中会标注适配点。


2. 本篇完成后的工程效果

本篇完成后,你的工程将具备以下能力:

能力 说明
MCU 正常启动 系统时钟、外设时钟已配置
串口终端可用 可通过串口查看启动日志
工程骨架完整 六层目录结构清晰,每层存在骨架源文件
统一初始化流程 各层按正确顺序初始化,并输出初始化结果
工程信息输出 启动时显示工程名称、版本号、构建时间

串口启动日志示例:

复制代码
  ============================================
        STM32F429 六层架构工程框架
  ============================================
  MCU   : STM32F429IGT6  @  180MHz
  OS    : RT-Thread 3.1.3
  Build : 2026-07-24  20:00:00
  Ver   : 0.1.0
  --------------------------------------------
  Layers : app  service  device  bsp  platform
           component (cross-layer)
  ============================================

LOG[I]: 系统就绪.

CMD>

3. 前置条件与硬件连接

硬件

项目 说明
开发板 STM32F429 正点原子开发板(或其他 STM32 开发板)
调试器 J-Link / ST-Link / DAP-Link
串口线 USB 转 TTL(连接 USART1)

不同开发板的引脚定义不同,请根据板级原理图调整 USART 引脚配置。

软件

项目 推荐 替代方案
IDE / 构建工具 Keil MDK IAR / STM32CubeIDE / GCC + Makefile
初始化代码生成 STM32CubeMX 手动配置寄存器
操作系统 RT-Thread 3.1.3 FreeRTOS / 裸机(分层思路一致)
串口工具 PuTTY / MobaXterm / SSCOM 任何支持 115200 的终端

本篇不需要连接的外部硬件

  • 不需要接传感器
  • 不需要接 LED 或按键(即使板载)
  • 只需要:开发板供电 + 调试器下载 + 串口线查看日志

4. 本篇涉及的架构与模块关系

v0.1 工程的整体分层结构

复制代码
┌──────────────────────────────────────────────────────┐
│                     application                      │
│                    (app/)                            │
│                业务编排、状态机、产品逻辑              │
├──────────────────────────────────────────────────────┤
│                       service                        │
│                    (service/)                        │
│           日志服务、参数管理、协议处理、告警服务         │
├──────────────────────────────────────────────────────┤
│                       device                         │
│                    (device/)                         │
│           LED、按键、传感器、Flash 等设备抽象接口       │
├──────────────────────────────────────────────────────┤
│                    bsp                               │
│                    (bsp/)                            │
│         板级资源适配:引脚定义、外设实例、板级初始化     │
├──────────────────────────────────────────────────────┤
│                 platform (底层平台)                   │
│            STM32 HAL / CMSIS / RTOS Kernel           │
└──────────────────────────────────────────────────────┘
         ┌──────────────────────────────┐
         │       component              │  ← 横向复用
         │  环形缓冲区、CRC、滤波算法等   │
         └──────────────────────────────┘

关键设计要点:

  1. component/是横向复用层------可被 app / service / device / bsp 任意一层使用,但不依赖它们
  2. 调用方向是单向的:app → service/device → bsp → platform
  3. 严禁越层访问:app 不直接调 HAL,device 不跳过 bsp 访问寄存器
  4. v0.1 阶段各层只有骨架文件,不实现具体外设功能------目的是先建好"代码该放哪"的框架

调用关系 vs 初始化关系

这是最容易混淆的一点。运行期的调用关系是单向依赖,但初始化的编排顺序是另一回事------它由统一的启动入口控制,而不是让各层自行拉依赖。

复制代码
【初始化顺序】
main / 系统入口
    ↓
bsp_init()          ------ 板级资源先就绪
    ↓
device_init()       ------ 设备层依赖板级资源
    ↓
service_init()      ------ 服务层可能依赖设备
    ↓
app_init()          ------ 业务层依赖所有下层

【运行期调用】
app  →  service / device  →  bsp  →  platform

5. 设计思路:为什么这样分层

5.1 为什么不让所有代码都塞进一个目录

很多 STM32 工程刚创建时,所有用户代码都放在 Src/User/ 目录下------外设初始化、设备驱动、业务逻辑混在一起。

随着项目增长,一个文件从 200 行膨胀到 2000 行,问题开始暴露:

  • 换一颗传感器,要全文搜索"哪个函数在管这个"
  • 板级引脚一改,不敢确定还有没有其他地方用到了旧的 GPIO
  • 新人接手,打开工程不知道从哪里下手

分层的本质不是让目录好看,而是让"因不同原因变化"的代码相互隔离。

**【设计决策】为什么要有 bsp 这一层,而不是直接在 CubeMX 生成的 gpio.c 里写?

一种常见的做法是:在 CubeMX 生成的 gpio.c 里直接添加 bsp_gpio_write()bsp_gpio_read() 等函数。看起来省了一层目录,但有两个问题:

  • 会被覆盖。 gpio.c 属于 CubeMX 生成范围,重新生成代码时手动添加的内容可能丢失。依赖它等于把项目代码放在"别人的领地"里。
  • 职责混在一起。 gpio.c 的职责是 GPIO 外设的初始化和 HAL 配置(时钟使能、引脚模式、速度、上下拉等)。如果再往里面塞有效电平反转、引脚枚举、板级映射表,一个文件就同时管了"平台初始化"和"板级适配"两件事。

bsp 是"属于项目自己"的板级封装层,不属于 CubeMX 生成范围。

  • CubeMX 生成的 gpio.c → 管"这个芯片的 GPIO 怎么初始化"

  • 项目自己的 bsp_gpio.c → 管"这块板子的 GPIO 怎么用"(哪个引脚接 LED、有效电平是高还是低、引脚枚举定义)

分清楚这两件事,换板子时只需要改 bsp 层,不需要碰 CubeMX 重新生成。

5.2 六层各自隔离什么变化

隔离的变化 典型原因
app 产品业务 需求变更、控制策略调整
service 通用能力实现 日志格式变更、协议版本升级
device 具体器件型号 传感器换型号、Flash 换品牌
bsp 板级资源差异 引脚重分配、板卡版本变更
platform 芯片平台、HAL 库版本 换 MCU 系列、HAL 升级
component(横向) 基础算法、数据结构 环形缓冲区、CRC、滤波算法等

当这些变化发生时,理想情况下只需修改一个层内的一到两个文件,其他层完全不受影响。

5.3 初始化为什么需要统一编排

没有统一入口的多层架构,最常见的崩溃方式就是初始化顺序问题:

  • 模块 A 依赖模块 B,但 B 还没初始化
  • 某个外设在 main 里初始化,另一个在模块内部自动初始化,顺序不可控
  • 换个板子,莫名其妙的初始化失败

本篇的做法:统一初始化编排------所有层级的初始化由 main() 中的初始化序列唯一决定。 各层不自行启动初始化,而是通过各层的 xxx_framework_init() 注册到统一入口中按顺序执行。

【设计决策】为什么 v0.1 不直接用 RT-Thread 的自动初始化宏?

RT-Thread 提供了 INIT_BOARD_EXPORT(fn)INIT_DEVICE_EXPORT(fn)INIT_APP_EXPORT(fn) 等宏,能让函数在系统启动的不同阶段自动按优先级执行。这很强大,但 v0.1 阶段刻意没有使用。

角度 自动初始化宏 手动编排(本文方案)
可见性 初始化函数分散在各模块中,不能找到完整的初始化链 main 中一目了然,按顺序列出
顺序控制 通过宏优先级(BOARD < DEVICE < APP)控制,精细调整需要改宏定义 代码顺序就是初始化顺序
错误处理 初始化失败的处理逻辑分散在各自的函数中 失败处理统一写在 main 里
教学价值 对不熟悉 RT-Thread 的读者造成额外认知负担 初始化流程显式表达,零隐藏

本篇的服务对象是"从规范到落地"的读者,核心目标是让读者看清楚分层和初始化的完整脉络。 当读者通过 v0.1-v0.5 几个版本熟悉了这套框架后,后续可以自然过渡到自动初始化宏来减少样板代码。但第一步,让每件事都显式可见。

5.4 反面案例:一个不分层工程的演化实录

理论讲完了,来看一个真实场景------一个不分层的 STM32 工程,从 500 行到 5000 行经历了什么。

v0.1(500行):一个人,跑得挺快

老板说:"做一个温湿度监控器,读个传感器,串口打印就行。"

开发者打开 CubeMX,生成工程,把所有代码写进 main.c

c 复制代码
int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_USART1_UART_Init();
    MX_I2C1_Init();

    while (1) {
        /* 读温湿度传感器 */
        uint8_t data[6];
        HAL_I2C_Mem_Read(&hi2c1, 0x5C<<1, 0x00, 1, data, 6, 100);

        /* 换算 */
        float temp = ((data[0]<<8)|data[1]) * 0.01f;
        float hum  = ((data[3]<<8)|data[4]) * 0.01f;

        /* 输出 */
        printf("Temp: %.1f, Hum: %.1f\r\n", temp, hum);
        HAL_Delay(1000);
    }
}

这个阶段没有任何问题。代码短、逻辑直、一个人维护。不分层是对的。

v1.0(1500行):加了几个功能,开始吃力

需求增加:OLED 显示、按键切换显示模式、蜂鸣器高温告警。

main.c 里塞了:I2C 读传感器、OLED 驱动、按键扫描、蜂鸣器控制、显示逻辑、告警阈值判断。

文件从 200 行变成 800 行。开发者觉得有点乱了,但还能忍。

隐患开始萌芽: 传感器初始化、OLED 初始化、蜂鸣器初始化散落在 main 函数的不同位置。改 OLED 显示格式时,不小心动到了传感器读取的延时。

v2.0(3000行):换传感器,噩梦开始

产品升级:温湿度传感器从 SHT30 换成 SHT40,I2C 地址变了、数据格式变了、初始化序列变了。

开发者开始改代码------却发现:

  • main.c 里有三处 I2C 读取(传感器、OLED、后续加的 EEPROM),但只有传感器需要改
  • OLED 驱动里也有一份 I2C 读取,但代码和传感器的 I2C 读取混在同一个函数里
  • 某个 .c 文件里还藏了一份传感器的初始化代码,改漏了
  • 改了之后,OLED 不亮了------因为 OLED 初始化和传感器初始化共用了一个延时顺序

开发者花了三天才改完,还引入了一个新 Bug: EEPROM 写入偶尔失败------因为 I2C 总线时钟频率被传感器的配置改动了。

这就是"变化穿透"的典型症状: 本应只影响 device 层的传感器变更,穿透到了 OLED 显示和 EEPROM 存储。

v3.0(5000行):没人敢动了

又加了几个功能:Wi-Fi 上报、Modbus 协议、参数配置、历史告警记录。

工程目录变成了这样:

复制代码
Core/
├── Inc/       ← 40+ 个头文件,谁包含谁已无法追踪
├── Src/       ← 30+ 个 .c 文件,按功能命名但职责交叉
│   ├── main.c
│   ├── sensor.c        ← 既有 I2C 读写,又有数据处理
│   ├── oled.c          ← 既有显示驱动,又有菜单逻辑
│   ├── wifi.c          ← 既有 AT 指令解析,又有业务上报逻辑
│   ├── modbus.c        ← 既有协议栈,又有命令处理
│   ├── param.c         ← 既有存储读写,又有参数业务
│   ├── alarm.c         ← 既有告警判断,又有声光控制
│   └── ...             ← 新增文件不知道该放哪里
├── Startup/
└── ...

此时的典型状态:

  • 改一个功能要改 3-5 个文件,因为功能代码穿透到了多个文件
  • 不知道哪些代码可以删,怕删了其他地方还在用
  • 全局变量满天飞 ,模块之间通过 extern 互相访问
  • 初始化顺序看运气,加了新模块就在 main 里找个位置塞进去
  • 新同事来了看了一周,说"我还是先看别处吧"

这个项目不是不能跑,是不能维护。

如果从一开始就分层...
复制代码
传感器从 SHT30 换成 SHT40 → 只需改 device/sht30.c → 替换为 device/sht40.c
OLED 显示格式变化 → 只需改 service/display.c
I2C 总线频率调整 → 只需改 bsp/i2c_cfg.h
Wi-Fi 上报协议变化 → 只需改 service/upload.c

每一类变化,都被拦在了它应该在的那一层。

这个案例告诉我们什么?
项目规模 不分层的状态 分层后的状态
500 行 没问题,不需要分层 分层略显多余
1500 行 开始混乱,但还能忍 各层骨架已就绪,有序
3000 行 换器件需要改 3-5 处 换器件只需改 1 个文件
5000 行 没人敢改代码 新模块按层添加,风险可控

分层的真正价值不在项目刚开始的时候体现,而是在项目翻过 3000 行那道坎时才显现。

所以不是"大项目才需要分层",而是"等到了大项目再想分层,已经来不及了"。

6. 关键接口设计

6.1 统一命名规范

类别 命名示例 说明
初始化函数 xxx_init(void) 返回 int(0=成功,负值=错误码)
去初始化函数 xxx_deinit(void) 返回 int
获取数据 xxx_get() / xxx_read() 通过参数或返回值传递
设置状态 xxx_set() / xxx_write() / xxx_control() 返回操作结果
模块句柄类型 xxx_t / xxx_handle_t led_tuart_handle_t
宏/常量 XXX_MAX_COUNT 全大写蛇形

6.2 统一返回值与错误码

c 复制代码
/* component/inc/component_def.h */
#ifndef __COMPONENT_DEF_H__
#define __COMPONENT_DEF_H__

/* 通用返回值 */
#define ERR_NONE          0
#define ERR_ERROR       (-1)
#define ERR_PARAM      (-2)   /* 参数无效 */
#define ERR_MEM      (-3)   /* 内存不足 */
#define ERR_TIMEOUT    (-4)   /* 超时 */
#define ERR_BUSY       (-5)   /* 资源忙 */
#define ERR_UNSUPPORT  (-6)   /* 不支持 */
#define ERR_NODEV      (-7)   /* 设备不存在 */
#define ERR_IO         (-8)   /* IO 错误 */

/* 断言宏 */
#define APP_ASSERT(expr)                                        \
    do {                                                        \
        if (!(expr)) {                                          \
            log_printf("ASSERT: %s (%s:%d)\n",                  \
                       #expr, __FILE__, __LINE__);              \
            while (1);                                          \
        }                                                       \
    } while (0)

/* 简易日志宏(后续可替换为完整日志服务) */
#define LOG_I(fmt, ...)  printf("LOG[I]: " fmt "\r\n", ##__VA_ARGS__)
#define LOG_W(fmt, ...)  printf("LOG[W]: " fmt "\r\n", ##__VA_ARGS__)
#define LOG_E(fmt, ...)  printf("LOG[E]: " fmt "\r\n", ##__VA_ARGS__)

/* 版本信息 */
#define APP_VERSION_MAJOR    0
#define APP_VERSION_MINOR    1
#define APP_VERSION_PATCH    0
#define APP_VERSION_STR      "0.1.0"

#endif

为什么错误码用负值?

  • 正数或 0 表示成功状态,负值表示不同错误原因
  • 统一 ret == ERR_NONE 判断,而不是混用 ret >= 0ret == 0
  • 预留正数用于扩展状态码

【设计决策】为什么不直接复用 HAL 的返回值或 RT-Thread 的 rt_err_t?

方案 问题
直接用 HAL 返回值 HAL 的状态码是 HAL_OK=0HAL_ERROR=1HAL_BUSY=2......正数表示错误,和 POSIX 风格(负值表示错误)相反。跨层传递时,上层容易混淆"返回 1 到底是成功还是失败"
直接用 rt_err_t 与 RT-Thread 强耦合。如果后续切换到 FreeRTOS 或裸机,整个错误码体系需要重改
自定义 ERR_xxx 与平台无关、语义清晰、可在任何环境下统一使用

结论:自定义错误码确实多了一层映射成本,但它使整个框架与平台无关。 当底层需要透传 HAL 错误时,在 bsp 层做一次映射即可------这恰恰是 bsp 层的职责。

6.3 各层初始化接口

每个层暴露一个统一的框架初始化函数,由 main 统一调用:

c 复制代码
/* bsp/inc/bsp_framework.h */
#ifndef __BSP_FRAMEWORK_H__
#define __BSP_FRAMEWORK_H__

#include "component_def.h"
int bsp_framework_init(void);

#endif
c 复制代码
/* device/inc/device_framework.h */
#ifndef __DEVICE_FRAMEWORK_H__
#define __DEVICE_FRAMEWORK_H__

#include "component_def.h"
int device_framework_init(void);

#endif

同理,service 和 app 层也有对应的 service_framework_init()app_framework_init()


7. 核心实现过程

7.1 第一步:创建基础 STM32 工程

使用 CubeMX 生成基础工程:

  1. 打开 CubeMX,选择芯片(本篇以 STM32F429IGTx 为例)
  2. 配置 RCC(HSE 外部晶振)
  3. 配置 USART1(PA9-TX, PA10-RX, 115200-8N1)
  4. 配置时钟树(主频 180MHz)
  5. 生成 Keil MDK 工程(或选其他工具链)

裸机方案: 到此为止即可,不集成 RTOS,直接用 CubeMX 生成的 HAL 工程为基础。

FreeRTOS 方案: CubeMX 可直接配置 FreeRTOS,生成后工程结构和本篇一致。

RT-Thread 方案: 在 CubeMX 工程基础上集成 RT-Thread,或直接使用 RT-Thread Studio 创建。

7.2 第二步:创建分层目录结构(最关键的一步)

在工程中(通常是 User/applications/ 目录下)创建以下完整目录树:

复制代码
User/
├── app/
│   ├── inc/
│   │   ├── app_framework.h
│   │   └── app_cfg.h
│   └── src/
│       └── app_framework.c
├── service/
│   ├── inc/
│   │   └── service_framework.h
│   └── src/
│       └── service_framework.c
├── device/
│   ├── inc/
│   │   └── device_framework.h
│   └── src/
│       └── device_framework.c
├── bsp/
│   ├── inc/
│   │   └── bsp_framework.h
│   └── src/
│       └── bsp_framework.c
└── component/
    ├── inc/
    │   └── component_def.h
    └── src/

实际创建目录如下图所示:

各目录说明:

目录/文件 职责 v0.1 阶段
app/ 产品业务逻辑 仅骨架,打印启动信息
service/ 通用服务能力 仅骨架,空初始化
device/ 外部设备抽象 仅骨架,空初始化
bsp/ 板级资源适配 实现 GPIO 基本初始化
component/ 跨层复用基础设施 错误码、日志宏、断言、版本
readme.txt 项目说明书------移植要点、版本迭代记录、注意事项 初始版本说明

关于 readme.txt:不是代码文件,而是伴随工程的说明书。每次版本迭代、平台移植时的注意事项、模块依赖关系、配置变更等,都应记录在此文件中。后续每完成一篇,同步更新 readme.txt,让工程始终保持可追溯的状态。
关于子目录划分: 当前 v0.1 各层内部使用平铺结构(所有 .c 文件直接放在 app/src/service/src/ 下),这是为了降低入门门槛,让你先看清"层"的边界。但项目一旦复杂起来,平铺结构很快会成为新的混乱源:

  • app/src/ 里同时放了命令处理、状态机、告警逻辑......几十个文件混在一起,找东西靠搜
  • service/src/ 里日志、参数、协议栈的文件混排,新增一个服务不知道应该放哪
  • component/ 里环形缓冲区、CRC、PID 的文件交叉引用关系模糊

分层的下一步,就是在每层内部再按模块划分子目录。 举几个典型例子:

复制代码
app/                          service/                      component/
├── cmd_handler/              ├── log_service/              ├── ringbuffer/
│   ├── cmd_handler.h         │   ├── log_service.h         │   ├── ringbuffer.h
│   ├── cmd_handler.c         │   ├── log_service.c         │   └── ringbuffer.c
│   └── cmd_table.c           │   └── log_cfg.h             ├── crc/
├── state_machine/            ├── param_service/            │   ├── crc.h
│   ├── state_machine.h       │   ├── param_service.h       │   └── crc.c
│   ├── state_machine.c       │   ├── param_storage.c       ├── pid/
│   └── state_def.h           │   └── param_default.h       │   ├── pid.h
└── monitor/                  └── protocol/                 │   └── pid.c
    ├── monitor.h                 ├── protocol.h            └── fsm/
    ├── monitor.c                 ├── modbus.c                  ├── fsm.h
    └── alarm.c                   └── parser.c                 └── fsm.c

每层内部划分子目录的判断标准:

条件 说明 示例
文件数量 >= 3 .h + .c + _cfg.h 凑够 3 个,就应该独立成子目录 pid.hpid.cpid_cfg.h → 独立 pid/
模块有独立配置 需要单独的配置头文件或参数定义 log_cfg.h → 日志服务独立成目录
被多个上层模块引用 多个 app 模块都引用同一个 service 模块 协议栈被命令处理和上报模块共用
预期会持续扩展 当前虽小但后续大概率会增加文件 状态机未来会增加状态定义文件

设备层(device/)和板级支持层(bsp/)的子目录划分逻辑略有不同,通常按外设类型或器件型号划分:

复制代码
device/                       bsp/
├── sensor/                   ├── gpio/
├── flash/                    ├── uart/
├── display/                  ├── spi/
└── motor/                    └── i2c/

一条基本原则: 不要在 v0.1 就过度划分子目录。建议的做法是------先用平铺结构把模块跑通,当某个目录下的文件超过 5-8 个时,再按模块拆分子目录。过早优化和从不优化,同样有害。

7.3 第三步:将目录加入构建

Keil MDK 方式:

  1. 在 Project 窗口右键 → Manage Project Items

  2. 添加 Groups:app、service、device、bsp、component、readme

  3. 将各目录下的 .c 文件以及readme.txt添加到对应 Group

  4. 添加头文件路径到 C/C++ 编译器选项:

    复制代码
    User/app/inc
    User/service/inc
    User/device/inc
    User/bsp/inc
    User/component/inc

实际工程目录如下图所示:

7.4 第四步:实现各层骨架文件

bsp_framework.c ------ 板级初始化入口:

c 复制代码
#include "bsp_framework.h"
#include "component_def.h"

static int _bsp_gpio_init(void)
{
    /* GPIO 引脚配置(下一篇 GPIO 篇详细实现) */
    /* v0.1 阶段返回 OK,保留接口位置 */
    return ERR_NONE;
}

int bsp_framework_init(void)
{
    int ret;

    ret = _bsp_gpio_init();
    if (ret != ERR_NONE) {
        LOG_E("bsp gpio init failed");
        return ret;
    }

    return ERR_NONE;
}

device_framework.c ------ 设备层初始化入口:

c 复制代码
#include "device_framework.h"
#include "component_def.h"

/* 设备注册表:后续每增加一个设备,在此添加一条记录 */
static const struct {
    const char *name;
    int (*init)(void);
} _device_table[] = {
    /* 后续添加:{ "led", dev_led_init }, */
    /* 后续添加:{ "key", dev_key_init }, */
};

int device_framework_init(void)
{
    int i;
    int ret;
    int count = sizeof(_device_table) / sizeof(_device_table[0]);

    if (count == 0) {
        LOG_I("device init       ... SKIP (no devices configured)");
        return ERR_NONE;
    }

    for (i = 0; i < count; i++) {
        ret = _device_table[i].init();
        if (ret != ERR_NONE) {
            LOG_W("device '%s' init failed: %d", _device_table[i].name, ret);
        } else {
            LOG_I("device '%s' init ok", _device_table[i].name);
        }
    }

    return ERR_NONE;
}

service_framework.c ------ 服务层初始化入口:

c 复制代码
#include "service_framework.h"
#include "component_def.h"

static const struct {
    const char *name;
    int (*init)(void);
} _service_table[] = {
    /* 后续添加:{ "log",   srv_log_init   }, */
    /* 后续添加:{ "alarm", srv_alarm_init }, */
};

int service_framework_init(void)
{
    int i;
    int ret;
    int count = sizeof(_service_table) / sizeof(_service_table[0]);

    if (count == 0) {
        LOG_I("service init      ... SKIP (no services configured)");
        return ERR_NONE;
    }

    for (i = 0; i < count; i++) {
        ret = _service_table[i].init();
        if (ret != ERR_NONE) {
            LOG_W("service '%s' init failed: %d", _service_table[i].name, ret);
        } else {
            LOG_I("service '%s' init ok", _service_table[i].name);
        }
    }

    return ERR_NONE;
}

app_framework.c ------ 应用层入口:

c 复制代码
#include "app_framework.h"
#include "component_def.h"

int app_framework_init(void)
{
    /*
     * 应用层初始化:
     * - 创建应用主线程
     * - 注册命令处理函数
     * - 启动业务状态机
     *
     * v0.1 阶段仅打印启动信息
     */
    LOG_I("app init          ... OK");
    return ERR_NONE;
}

这三个骨架文件的设计模式是一致的:用静态表格管理模块列表,逐项初始化。 后续每增加一个设备或服务,只需在表格中添加一行,不需要修改初始化流程代码。

数组注册表模式说明

device 层和 service 层采用同一种初始化管理方式------数组注册表

c 复制代码
/* 典型结构 */
static const struct {
    const char *name;       /* 模块名称(仅用于日志输出) */
    int (*init)(void);      /* 初始化函数指针 */
} device_table[] = {
    { "led",   dev_led_init   },
    { "key",   dev_key_init   },
    { "sht30", dev_sht30_init },
};

工作流程:

复制代码
device_framework_init()
    ↓
遍历 device_table[]
    ├─→ 调用 dev_led_init()   → 成功则打印 [OK],失败则打印 [WARN]
    ├─→ 调用 dev_key_init()   → 同上
    └─→ 调用 dev_sht30_init() → 同上
    ↓
返回上层(main)

关键设计点:

特性 说明
初始化顺序 数组从上到下的顺序就是执行顺序
添加新设备 只在数组中加一行 { "name", func },框架代码不动
删除设备 去掉数组中的对应行
单个失败影响 某个设备 init 失败,不影响其他设备继续初始化

为什么 bsp 层不用这个模式?

因为 bsp 层的初始化项有严格的先后依赖------必须先建映射表,再做硬件初始化,最后启动 DMA。数组无法表达这种顺序约束,所以 bsp_framework.c 采用显式顺序调用

c 复制代码
int bsp_framework_init(void)
{
    bsp_uart_init();           /* 先建映射表 */
    MX_USART1_UART_Init();     /* 再初始化硬件 */
    bsp_uart_dma_init(...);      /* 最后启动 DMA */
    return 0;
}

一条规则区分:

初始化方式 判断依据
bsp 顺序调用 有硬件依赖顺序,必须先 A 再 B
device / service 数组注册表 模块之间无依赖,先初始化谁都一样

如果以后某两个设备之间产生了依赖(比如传感器 B 依赖传感器 A 的供电 GPIO),那就说明它们不适合同时放在数组里------需要拆出来用顺序调用,或者加一个依赖标记字段。

【设计决策】为什么用静态注册表,而不是动态注册(如链表注册)?

方案 优点 缺点
静态表格(本文方案) 零运行时开销、初始化顺序一目了然、不依赖堆 增加模块需要改表格代码
动态链表注册 模块可独立注册、支持动态加载 需要自旋锁保护、依赖堆、运行时开销、调试困难
链接器段收集(如 RT-Thread 的 INIT_xxx_EXPORT 自动收集模块、分散注册 顺序控制复杂、初始化失败难追踪

v0.1 阶段选择静态表格的原因:

  • 嵌入式 MCU 项目中模块数量通常在 10-30 个,静态表格完全够用
  • 初始化顺序在代码层面显式表达,读者一眼就能看懂"先初始化谁、再初始化谁"
  • 不需要堆内存、不需要锁,裸机环境下最可靠

什么时候应该考虑换成动态注册? 当项目支持内核模块动态加载(如需要运行时插拔设备),或模块数量超过 50 个且按需加载时,静态表格会变成维护负担------但 v0.1 远未到那个阶段。

7.5 第五步:统一初始化入口与系统启动

裸机方案(main.c):

c 复制代码
#include "main.h"
#include "bsp_framework.h"
#include "device_framework.h"
#include "service_framework.h"
#include "app_framework.h"
#include "component_def.h"

void SystemClock_Config(void);

static void show_board_info(void)
{
	printf("\r\n");
	printf("============================================\r\n");
	printf("  STM32F429 六层架构工程框架\r\n");
	printf("\r\n");
	printf("  MCU   : STM32F429IGT6  @  180MHz\r\n");
	printf("  RTOS  : RT-Thread %s\r\n", RT_VERSION);
	printf("  Build : %s  %s\r\n", __DATE__, __TIME__);
	printf("  Ver   : %s\r\n", APP_VERSION_STR);
	printf("============================================\r\n");
	printf("\r\n");
}

int main(void)
{
	HAL_Init();

	SystemClock_Config();

	/* ===== 统一初始化编排 ===== */

	if (bsp_framework_init() != 0)      /* bsp 层初始化 */
	{
		LOG_E("bsp init failed, system halted");
		while (1);
	}
	
	if (bsp_framework_init() != ERR_NONE) {
        LOG_E("bsp init failed, system halted");
        while (1);
    }

    if (device_framework_init() != ERR_NONE) {
        LOG_W("device init failed, continuing...");
    }

    if (service_framework_init() != ERR_NONE) {
        LOG_W("service init failed, continuing...");
    }

    if (app_framework_init() != ERR_NONE) {
        LOG_E("app init failed, system halted");
        while (1);
    }

	show_board_info();
	
	LOG_I("system ready.");

	while (1)
	{
		/*实现其它功能*/
	}
}

【设计决策】为什么 bsp/app 初始化失败要停系统,而 device/service 失败可以继续?

这是对"系统完整性"的分级判断:

  • bsp 是根基。 板级资源(时钟、GPIO、中断)初始化失败,后续所有代码都不可信。继续运行只会产生更诡异的 Bug。停系统是最安全的选择。

  • app 是业务终点。 如果业务层启动失败,系统即使底层跑得再好,对用户也没有意义。停系统,让开发者通过调试器定位问题。

  • device 是外设。 一个温湿度传感器初始化失败,不代表系统不能继续运行------至少串口日志还能输出、按键还能响应。记录告警,继续运行。

  • service 是服务能力。 日志服务初始化失败很严重,但系统不应因此完全死掉------至少故障本身还能被其他方式报告出去。记录告警,继续运行。

这个策略不是绝对的。如果你的产品中"某个设备"是核心能力(比如无人机的 IMU),那它的初始化失败就应该停系统。本篇的策略是通用推荐,具体项目应根据业务关键性调整。

RT-Thread 方案(main.c):

c 复制代码
#include "bsp_framework.h"
#include "device_framework.h"
#include "service_framework.h"
#include "app_framework.h"
#include "component_def.h"
#include "rtthread.h"

void SystemClock_Config(void);

static void show_board_info(void)
{
	rt_kprintf("\r\n");
	rt_kprintf("============================================\r\n");
	rt_kprintf("  STM32F429 六层架构工程框架\r\n");
	rt_kprintf("\r\n");
	rt_kprintf("  MCU   : STM32F429IGT6  @  180MHz\r\n");
	rt_kprintf("  RTOS  : RT-Thread %s\r\n", RT_VERSION);
	rt_kprintf("  Build : %s  %s\r\n", __DATE__, __TIME__);
	rt_kprintf("  Ver   : %s\r\n", APP_VERSION_STR);
	rt_kprintf("============================================\r\n");
	rt_kprintf("\r\n");
}


/**
  * @brief  The application entry point.
  * @retval int
  */
int main(void)
{
	HAL_Init();

	SystemClock_Config();

	/* ===== 统一初始化编排 ===== */

	if (bsp_framework_init() != 0)      /* bsp 层初始化 */
	{
		LOG_E("bsp init failed, system halted");
		while (1);
	}
	
	if (bsp_framework_init() != ERR_NONE) {
        LOG_E("bsp init failed, system halted");
        while (1);
    }

    if (device_framework_init() != ERR_NONE) {
        LOG_W("device init failed, continuing...");
    }

    if (service_framework_init() != ERR_NONE) {
        LOG_W("service init failed, continuing...");
    }

    if (app_framework_init() != ERR_NONE) {
        LOG_E("app init failed, system halted");
        while (1);
    }

	show_board_info();
	
	LOG_I("system ready.");

	while (1)
	{
		rt_thread_mdelay(10);
	}
}

FreeRTOS 方案:

将 main 中的 while(1) 改为创建启动线程,后续业务在线程中运行:

c 复制代码
void app_entry_task(void *param)
{
    /* 初始化流程与上面一致 */
    /* ... */
    while (1) {
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_USART1_UART_Init();

    xTaskCreate(app_entry_task, "app_entry", 512, NULL, 3, NULL);
    vTaskStartScheduler();

    while (1);
}

9. 编译、下载与功能验证

9.1 编译

确保 0 Error、0 Warning。

9.2 下载

通过调试器(ST-Link / J-Link)下载固件到开发板。

9.3 运行验证

打开串口终端(115200-8N1),按下复位键,能看到如下图所示的打印信息:

9.4 功能验证清单

验证项 预期结果 通过
MCU 正常启动 系统无异常复位
串口 board_info 输出 显示工程名称、版本、构建时间
各层初始化日志 bsp/device/service/app 均正常初始化
系统稳定 串口不卡死,无异常输出

10. 常见问题与避坑

Q1:编译报错 "undefined symbol"

原因: 头文件路径未添加到编译器搜索路径,或源文件未添加到工程。

解决: 检查各层 inc/ 目录是否已添加至编译器的包含路径。Keil 在 Options → C/C++ → Include Paths 中配置。

Q2:串口没有输出

排查步骤:

  1. 检查 USART 引脚是否正确(参考板级原理图)
  2. 检查串口工具波特率是否与代码一致
  3. 检查 CubeMX 中 USART 的 GPIO 配置(复用功能、速度)
  4. 检查是否调用了串口初始化函数

Q3:初始化顺序错了导致启动失败

原则:

  • bsp 必须最先初始化------板级资源是所有外设的基础
  • app 必须最后初始化------业务依赖所有下层能力
  • device / service 初始化失败不应阻塞系统启动------某个传感器坏了不应让整个系统不可用

Q4:不用 RT-Thread,这个框架还有意义吗?

完全有意义。 本篇的六层目录结构、接口规范、初始化编排方式、错误码体系------所有这些都与操作系统无关。裸机和 FreeRTOS 下完全可以使用同一套框架。

区别仅在于:

  • 串口输出函数需要自己实现(用 HAL)
  • 命令交互需要自己实现(用串口中断 + 命令表)
  • 线程管理在裸机下变为超级循环或状态机

Q5:我的芯片不是 STM32F429,能参考吗?

能。本文的六层架构与芯片型号无关。只需替换:

  • platform 层:替换为对应 MCU 的 HAL/LL 库
  • bsp 层:根据芯片引脚和外设资源重新配置
  • 上层(device / service / app):完全不用改

这就是分层带来的迁移价值。


11. 本篇小结

本篇完成的工作:

维度 成果
工程结构 建立 app / service / device / bsp / component 五层子目录
构建配置 将所有目录加入构建系统
初始化流程 实现从 bsp → device → service → app 的统一初始化编排
接口规范 统一命名风格、返回值约定、错误码体系
公共组件 component_def.h(错误码、日志宏、断言、版本定义)
验证能力 串口输出启动日志,version 命令可用(RT-Thread)
版本标记 v0.1:工程骨架可运行

你现在手里的,是一个空的、但架构完整的工程框架

它现在不控制任何外设,但每一层都留好了接口和位置。后续每增加一个模块,你都知道:

  • 头文件放哪里
  • 源文件放哪里
  • 初始化函数怎么写、注册到哪里
  • 接口风格是什么

这就是工程框架的价值------不是替你写完代码,而是让后续每一行代码都有它该去的地方。


12. 下一篇预告

下一篇将从 v0.1 骨架出发,实现 GPIO 外设的完整分层封装(LED + 按键),包括:

  • bsp 层定义引脚、端口、有效电平

  • device 层提供 dev_led_set()dev_key_read() 等设备语义接口

  • service 层实现按键消抖、状态指示灯策略

  • app 层演示:按键切换系统模式,LED 显示当前状态

你将看到"分层"从理论到代码的完整转化过程,而且------这一次用的是 GPIO 这个最简单的例子,让你在没有任何外设驱动负担的情况下,彻底理解每层的职责划分。


本篇工程版本:v0.1

下一篇:GPIO 分层封装------LED 与按键的事件驱动

相关推荐
红尘伴蝶舞1 小时前
H5游戏多进程隔离:如何解决Android内存激增与进程冻结问题
架构·app·android studio
BigGayGod1 小时前
IR + VM:让 LLM 生成的控制逻辑安全运行在超低端 MCU 上
架构
张忠琳2 小时前
【NVIDIA】NVIDIA k8s-device-plugin v0.19.3 资源管理器模块深度分析之三
云原生·容器·架构·kubernetes·nvidia
sramdram2 小时前
高速低功耗stt-mram工业级存储方案
嵌入式硬件·mram·stt-mram·低功耗stt-mram
小小工匠2 小时前
Skill - 把无限画布装进 Codex:Cowart 的架构拆解与实践指南
架构·cowart
roman_日积跬步-终至千里2 小时前
【从零开始学架构】DDIA 精要:一套关于“数据系统架构”的分层理论
架构
亲爱的马哥2 小时前
Vue3 + Element Plus 低代码表单设计器架构拆解与私有化落地实践
低代码·架构·敏捷流程
躺不平的理查德3 小时前
STM32 上拉和下拉
stm32·单片机·嵌入式硬件
LONGZETECH3 小时前
工业实训仿真设计实践:电机拆装软件的 DAG 流程建模、工具精度分级与数据体系搭建
大数据·算法·unity·架构·汽车