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、滤波算法等 │
└──────────────────────────────┘
关键设计要点:
- component/是横向复用层------可被 app / service / device / bsp 任意一层使用,但不依赖它们
- 调用方向是单向的:app → service/device → bsp → platform
- 严禁越层访问:app 不直接调 HAL,device 不跳过 bsp 访问寄存器
- 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_t、uart_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 >= 0或ret == 0 - 预留正数用于扩展状态码
【设计决策】为什么不直接复用 HAL 的返回值或 RT-Thread 的 rt_err_t?
| 方案 | 问题 |
|---|---|
| 直接用 HAL 返回值 | HAL 的状态码是 HAL_OK=0、HAL_ERROR=1、HAL_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 生成基础工程:
- 打开 CubeMX,选择芯片(本篇以 STM32F429IGTx 为例)
- 配置 RCC(HSE 外部晶振)
- 配置 USART1(PA9-TX, PA10-RX, 115200-8N1)
- 配置时钟树(主频 180MHz)
- 生成 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.h、pid.c、pid_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 方式:
-
在 Project 窗口右键 → Manage Project Items
-
添加 Groups:app、service、device、bsp、component、readme
-
将各目录下的
.c文件以及readme.txt添加到对应 Group -
添加头文件路径到 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:串口没有输出
排查步骤:
- 检查 USART 引脚是否正确(参考板级原理图)
- 检查串口工具波特率是否与代码一致
- 检查 CubeMX 中 USART 的 GPIO 配置(复用功能、速度)
- 检查是否调用了串口初始化函数
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 与按键的事件驱动