嵌入式软件如何做组件化?让代码真正做到"拿来就用"
- 2026-10-23 11:05:00
嵌入式软件如何做组件化?让代码真正做到"拿来就用"
从一个让人沮丧的场景开始
上一个项目做了一个环形缓冲区,这个项目又要用,把代码复制过来,改了改里面的类型名,跑了跑,不稳定。
排查半天,发现原来的版本有个隐藏假设:缓冲区大小必须是2的幂次,因为里面用了位掩码取模。这个项目的大小不是2的幂次,取模逻辑就出错了。
这种痛苦有一个名字:代码复用失败。
不是因为代码写得不好,而是代码写的时候没有想过"被别人用",更没有想过"被自己一年后用"。
组件化到底是什么
"组件化"这个词在嵌入式领域被说得很多,但真正理解它的人不多。
组件化不是"把代码分成多个文件",也不是"建多个文件夹"。文件夹多了只是代码的物理组织,不是组件化。
组件化的核心是一个判断:这块代码,在不了解它内部实现的情况下,能不能被正确使用?
能,就是组件。不能,就是代码片段。
这个判断里有三个关键词:
"不了解内部实现" — 使用者不需要看源码就知道怎么用。接口说明就足够了。
"被正确使用" — 不会因为使用方式稍有差异就出 bug。边界情况有处理,错误有提示。
"能" — 不是"理论上能",是"把头文件和源文件拿过去,编译通过,就可以用"。
做到这三点,才算组件。
为什么嵌入式组件化特别难
软件工程里的组件化理论很多,但照搬到嵌入式项目上往往不顺。原因有几个:
硬件依赖无处不在。
一个UART发送函数,调的可能是 USART1->DR = byte。这行代码在 STM32F4 上有效,在 NRF52 上没有。
资源约束改变设计。
PC上一个缓冲区大一点无所谓,嵌入式上缓冲区大小可能是系统能不能跑起来的关键。组件如果内部写死了缓冲区大小,到了另一个项目就要改源码。
没有标准的运行环境。
PC上有操作系统、有标准库、有文件系统。嵌入式项目可能有 RTOS、可能裸机、可能根本没有 printf。一个组件如果隐式依赖了某个运行环境,换个项目就不能用。
这三个问题,分别对应着组件化设计里的三个核心概念:硬件抽象、参数化、依赖显式化。
第一个核心:把硬件依赖收进一个地方
错误的做法:
// ring_buffer.c
voidRingBuffer_Flush(RingBuffer_t *rb) {
while (!RingBuffer_IsEmpty(rb)) {
uint8_t byte = RingBuffer_Read(rb);
while (!(USART1->SR & USART_SR_TXE)); // 直接操作寄存器
USART1->DR = byte;
}
}这个函数把环形缓冲区的逻辑和 UART 硬件绑在了一起。拿到另一个项目,要改 USART1、要改 SR、要改 DR,要改很多地方。
正确的做法:缓冲区组件只管数据,不管数据怎么发出去。
// ring_buffer.h — 纯数据结构,无硬件依赖
typedefstruct {
uint8_t *buffer;
uint16_t size;
volatileuint16_t head;
volatileuint16_t tail;
} RingBuffer_t;
voidRingBuffer_Init(RingBuffer_t *rb, uint8_t *buf, uint16_t size);
boolRingBuffer_Write(RingBuffer_t *rb, uint8_t byte);
boolRingBuffer_Read(RingBuffer_t *rb, uint8_t *out);
boolRingBuffer_IsEmpty(const RingBuffer_t *rb);
boolRingBuffer_IsFull(const RingBuffer_t *rb);
uint16_tRingBuffer_Count(const RingBuffer_t *rb);硬件操作放在调用者那里:
// uart.c — 知道硬件,调用缓冲区组件
#include"ring_buffer.h"
staticuint8_t tx_buf_data[256];
static RingBuffer_t tx_buf;
voidUART_Init(void) {
RingBuffer_Init(&tx_buf, tx_buf_data, sizeof(tx_buf_data));
// 初始化 USART1...
}
voidUART_Write(uint8_t byte) {
RingBuffer_Write(&tx_buf, byte);
USART1->CR1 |= USART_CR1_TXEIE; // 触发发送中断
}
voidUSART1_IRQHandler(void) {
if (USART1->SR & USART_SR_TXE) {
uint8_t byte;
if (RingBuffer_Read(&tx_buf, &byte)) {
USART1->DR = byte;
} else {
USART1->CR1 &= ~USART_CR1_TXEIE;
}
}
}现在 ring_buffer.c/.h 可以原封不动地用在任何项目里,它不知道也不关心 UART 是什么。
第二个核心:参数化,不要写死
写死的东西,就是以后要改源码的地方。
常见的写死方式:
// 写法一:魔数
#define BUFFER_SIZE 256
// 写法二:类型写死
staticuint32_t data[100];
// 写法三:行为写死
voidTimer_Init(void) {
// 写死100ms
TIM2->ARR = 8000 - 1;
TIM2->PSC = 1000 - 1;
}不是说这些写法错,而是这些写法降低了组件的可复用性。
参数化的几种方式:
方式一:通过初始化函数传入
// 缓冲区大小由调用者决定
voidRingBuffer_Init(RingBuffer_t *rb, uint8_t *buf, uint16_t size) {
rb->buffer = buf;
rb->size = size;
rb->head = 0;
rb->tail = 0;
}
// 调用者根据自己的情况决定大小
uint8_t small_buf[64];
RingBuffer_t rb1;
RingBuffer_Init(&rb1, small_buf, sizeof(small_buf));
uint8_t large_buf[1024];
RingBuffer_t rb2;
RingBuffer_Init(&rb2, large_buf, sizeof(large_buf));方式二:通过配置结构体传入
当参数较多时,一个配置结构体比一堆参数更清晰,也更容易扩展:
typedefstruct {
uint32_t baudrate;
uint8_t data_bits; // 7 or 8
uint8_t stop_bits; // 1 or 2
bool parity_en;
uint8_t parity_mode; // 0=even, 1=odd
} UART_Config_t;
voidUART_Init(const UART_Config_t *config);好处:新增一个参数,不需要修改函数签名,只在结构体里加一个字段。
方式三:编译期参数(适合资源受限)
有些参数在编译期确定更合适,比如最大连接数、缓冲池大小:
// config.h — 每个项目可以有自己的配置
#ifndef UART_TX_BUF_SIZE
#define UART_TX_BUF_SIZE 256
#endif
#ifndef UART_RX_BUF_SIZE
#define UART_RX_BUF_SIZE 128
#endif这样用 ifndef 保护,默认值在组件内部,项目如果需要改,在自己的 config.h 里覆盖就行,不需要改组件源码。
第三个核心:依赖显式化
最隐蔽的组件问题,是隐式依赖。
隐式依赖就是:这个组件能正常工作,需要某个前提,但这个前提没有被清楚地表达出来。
最常见的例子:
// log.c
voidLog_Write(constchar *msg) {
printf(msg); // 隐式依赖:printf 必须可用
}在有 stdio 的环境里,这没问题。在裸机项目里,printf 可能没有实现,或者实现了但是没有 stdout。把这个组件拿去用,编译报错,或者运行时没有任何输出,你不知道为什么。
用依赖注入暴露隐式依赖:
// log.h
typedefvoid(*Log_OutputFn)(constchar *str);
typedefstruct {
Log_OutputFn output_fn; // 输出函数,由使用者提供
uint8_t min_level; // 最小日志级别
} Log_Config_t;
voidLog_Init(const Log_Config_t *config);
voidLog_Write(uint8_t level, constchar *fmt, ...);// 在有串口的项目里
voidmy_uart_output(constchar *str) {
UART_SendString(str);
}
Log_Config_t log_cfg = {
.output_fn = my_uart_output,
.min_level = LOG_LEVEL_DEBUG,
};
Log_Init(&log_cfg);
// 在有 printf 的项目里
Log_Config_t log_cfg = {
.output_fn = puts,
.min_level = LOG_LEVEL_INFO,
};
Log_Init(&log_cfg);
// 在不需要日志的场景里
Log_Config_t log_cfg = {
.output_fn = NULL, // 传 NULL,Log_Write 内部检查,什么都不输出
.min_level = LOG_LEVEL_ERROR,
};
Log_Init(&log_cfg);现在 log.c/.h 没有任何隐式依赖,它所需要的所有东西都通过初始化参数显式传入。拿到任何项目都能用。
一个完整的组件长什么样
以一个软件定时器组件为例,把上面三个核心都用进来:
// soft_timer.h
#ifndef SOFT_TIMER_H
#define SOFT_TIMER_H
#include<stdint.h>
#include<stdbool.h>
// ---- 类型定义 ----
typedefvoid(*SoftTimer_Callback)(void *user_data);
typedefenum {
SOFT_TIMER_ONE_SHOT, // 只触发一次
SOFT_TIMER_PERIODIC, // 周期触发
} SoftTimer_Mode_t;
typedefstruct {
uint32_t period_ms;
SoftTimer_Mode_t mode;
SoftTimer_Callback callback;
void *user_data; // 传给回调的用户数据,组件不关心内容
} SoftTimer_Config_t;
// 不透明句柄,使用者不需要知道内部结构
typedefstructSoftTimer_sSoftTimer_t;
// ---- 接口 ----
/**
* 初始化软件定时器子系统
* @param get_tick_fn 获取当前时间戳(ms)的函数,由使用者提供
* @param max_timers 最多同时管理几个定时器(由使用者决定,内存由组件分配或由使用者提供)
*/
voidSoftTimer_SystemInit(uint32_t (*get_tick_fn)(void), uint8_t max_timers);
/**
* 创建一个软件定时器
* @return 定时器句柄,失败返回 NULL
*/
SoftTimer_t *SoftTimer_Create(const SoftTimer_Config_t *config);
voidSoftTimer_Start(SoftTimer_t *timer);
voidSoftTimer_Stop(SoftTimer_t *timer);
voidSoftTimer_Reset(SoftTimer_t *timer);
boolSoftTimer_IsRunning(const SoftTimer_t *timer);
voidSoftTimer_Destroy(SoftTimer_t *timer);
/**
* 必须在主循环或定时任务中定期调用,驱动定时器工作
*/
voidSoftTimer_Tick(void);
#endif// soft_timer.c
#include"soft_timer.h"
#include<string.h>
#include<stdlib.h>
// ---- 内部结构,使用者不可见 ----
structSoftTimer_s {
SoftTimer_Config_t config;
uint32_t last_tick;
bool running;
bool in_use;
};
// 编译期参数:如果项目没定义,用默认值
#ifndef SOFT_TIMER_MAX_COUNT
#define SOFT_TIMER_MAX_COUNT 16
#endif
staticstructSoftTimer_stimer_pool[SOFT_TIMER_MAX_COUNT];
staticuint32_t(*s_get_tick)(void) = NULL;
staticuint8_t s_max_timers = 0;
voidSoftTimer_SystemInit(uint32_t (*get_tick_fn)(void), uint8_t max_timers) {
s_get_tick = get_tick_fn;
s_max_timers = (max_timers > SOFT_TIMER_MAX_COUNT)
? SOFT_TIMER_MAX_COUNT
: max_timers;
memset(timer_pool, 0, sizeof(timer_pool));
}
SoftTimer_t *SoftTimer_Create(const SoftTimer_Config_t *config) {
if (!config || !s_get_tick) returnNULL;
for (uint8_t i = 0; i < s_max_timers; i++) {
if (!timer_pool[i].in_use) {
timer_pool[i].config = *config;
timer_pool[i].running = false;
timer_pool[i].in_use = true;
return &timer_pool[i];
}
}
returnNULL; // 池子满了
}
voidSoftTimer_Start(SoftTimer_t *timer) {
if (!timer || !timer->in_use) return;
timer->last_tick = s_get_tick();
timer->running = true;
}
voidSoftTimer_Stop(SoftTimer_t *timer) {
if (!timer) return;
timer->running = false;
}
voidSoftTimer_Reset(SoftTimer_t *timer) {
if (!timer) return;
timer->last_tick = s_get_tick();
}
boolSoftTimer_IsRunning(const SoftTimer_t *timer) {
return timer && timer->running;
}
voidSoftTimer_Destroy(SoftTimer_t *timer) {
if (!timer) return;
timer->running = false;
timer->in_use = false;
}
voidSoftTimer_Tick(void) {
if (!s_get_tick) return;
uint32_t now = s_get_tick();
for (uint8_t i = 0; i < s_max_timers; i++) {
SoftTimer_t *t = &timer_pool[i];
if (!t->in_use || !t->running) continue;
uint32_t elapsed = now - t->last_tick; // 处理了溢出
if (elapsed >= t->config.period_ms) {
t->last_tick = now;
if (t->config.mode == SOFT_TIMER_ONE_SHOT) {
t->running = false;
}
if (t->config.callback) {
t->config.callback(t->config.user_data);
}
}
}
}使用者怎么用:
// 项目 A(STM32,FreeRTOS)
#include"soft_timer.h"
staticvoidon_blink(void *data) {
LED_Toggle();
}
voidApp_Init(void) {
// 注入获取时间的函数,FreeRTOS 版本
SoftTimer_SystemInit(xTaskGetTickCount, 8);
SoftTimer_Config_t cfg = {
.period_ms = 500,
.mode = SOFT_TIMER_PERIODIC,
.callback = on_blink,
.user_data = NULL,
};
SoftTimer_t *blink_timer = SoftTimer_Create(&cfg);
SoftTimer_Start(blink_timer);
}
voidApp_Loop(void) {
SoftTimer_Tick(); // 在主循环里调用
}// 项目 B(NRF52,裸机,自己的 tick 计数)
externvolatileuint32_t g_tick_ms;
uint32_tget_my_tick(void) {
return g_tick_ms;
}
voidApp_Init(void) {
// 注入自己的时间函数
SoftTimer_SystemInit(get_my_tick, 4); // 最多4个定时器,省内存
// ...
}这个组件从 STM32 搬到 NRF52,改动:零。连 #include 都不用改。
组件的"合同":头文件即文档
一个好的头文件,是组件的使用说明书。看完头文件,不看源码,就能正确使用。
头文件里要写清楚的:
前置条件(precondition):
/**
* 读取一个字节
* @pre 必须先调用 RingBuffer_Init
* @pre 调用前确认 !RingBuffer_IsEmpty(rb),否则返回值无意义
*/
boolRingBuffer_Read(RingBuffer_t *rb, uint8_t *out);行为定义:
/**
* 写入一个字节
* @return true 写入成功
* @return false 缓冲区已满,字节被丢弃
* @note 此函数可以在中断中调用(线程安全)
*/
boolRingBuffer_Write(RingBuffer_t *rb, uint8_t byte);线程安全说明(嵌入式里极其重要):
/**
* @note 单生产者、单消费者场景下线程安全(ISR写,主循环读)
* @note 多生产者或多消费者场景下需要外部加锁
*/很多组件 bug 来自于"以为它是线程安全的,但其实不是"或者"以为不能在中断里用,其实可以"。这两件事必须在头文件里说清楚。
常见的假组件
有些代码看起来是组件,其实不是。识别它们:
假组件一:头文件里有全局变量
// 不好的 flash.h
externuint8_t flash_write_buf[256]; // 暴露内部状态
externbool flash_busy; // 使用者不应该直接访问组件的内部状态不应该暴露。如果需要查询状态,提供查询函数。
假组件二:初始化函数调用了其他模块
// 不好的 display.c
voidDisplay_Init(void) {
SPI_Init(); // 假设 SPI 还没初始化
GPIO_Init_LCD(); // 假设 GPIO 还没初始化
delay_ms(100); // 假设 delay_ms 可用
}初始化函数里调用其他模块的初始化,制造了隐式的初始化顺序依赖。这个组件用到其他项目里,必须保证 Display_Init 被调用之前那些前置初始化都完成了,但头文件里没有说这件事,很容易出问题。
正确做法:Display_Init 只做自己的事,需要的外部资源通过参数传进来,或者用文档说明前置条件。
假组件三:只能有一个实例
// 不好的 uart.c
staticuint8_t rx_buffer[128]; // 写死了只支持一个 UART
voidUART_Init(uint32_t baudrate) { /* ... */ }
voidUART_Send(uint8_t *data, uint16_t len) { /* ... */ }如果你需要两个 UART 怎么办?要复制一份 uart.c、改个名字。这不是组件,是一份源码拷贝。
正确做法:实例作为参数传入。
// 好的设计
typedefstruct {
USART_TypeDef *uart_reg; // 哪个 UART
uint8_t *rx_buf;
uint16_t rx_buf_size;
// ...
} UART_Handle_t;
voidUART_Init(UART_Handle_t *handle, const UART_Config_t *config);
voidUART_Send(UART_Handle_t *handle, constuint8_t *data, uint16_t len);
// 使用
UART_Handle_t uart1, uart2;
UART_Init(&uart1, &uart1_config);
UART_Init(&uart2, &uart2_config);组件测试
真正可复用的组件,应该可以在没有目标硬件的情况下测试。
上面的软件定时器组件,可以完全在 PC 上测试:
// test_soft_timer.c(在 PC 上运行)
#include"soft_timer.h"
#include<stdio.h>
#include<assert.h>
// 模拟时间
staticuint32_t fake_tick = 0;
uint32_tget_fake_tick(void) { return fake_tick; }
intmain(void) {
SoftTimer_SystemInit(get_fake_tick, 4);
int fire_count = 0;
SoftTimer_Config_t cfg = {
.period_ms = 100,
.mode = SOFT_TIMER_PERIODIC,
.callback = [](void *d) { (*(int*)d)++; },
.user_data = &fire_count,
};
SoftTimer_t *t = SoftTimer_Create(&cfg);
SoftTimer_Start(t);
// 模拟时间推进
for (int i = 0; i < 350; i++) {
fake_tick++;
SoftTimer_Tick();
}
assert(fire_count == 3); // 100ms、200ms、300ms 各触发一次
printf("Test passed! fire_count = %d\n", fire_count);
return0;
}能在 PC 上跑测试,说明这个组件:
• 没有硬件依赖(硬件依赖都被注入进来了) • 逻辑是独立的 • 真的可以在任何环境里用
不能在 PC 上测试的组件,大概率有隐式硬件依赖还没清理干净。
最后说一件事
很多工程师做到上面这些之后,会陷入另一个极端:过度设计。
为了"可复用",把一个简单的功能拆成三层抽象,加了七八个回调,配置结构体有二十个字段。最后这个"组件",比直接写寄存器要难用十倍。
好的组件设计有一个朴素的检验标准:
把这个组件拿给另一个工程师用,他看了头文件,花多长时间能写出第一个能跑的用例?
如果超过30分钟,设计可能太复杂了。
如果5分钟内能跑起来,剩下的学习靠深入看文档和源码,这个组件的接口设计是成功的。
组件化的目标不是"让代码看起来很专业",是"让别人(和一年后的你自己)能快速用起来,用对,用出预期的结果"。
做到这三点,才叫"拿来就用"。