嵌入式里的单例:别只盯着“对象只有一个”,先搞清楚“资源到底归谁管”
嵌入式里的单例:别只盯着“对象只有一个”,先搞清楚“资源到底归谁管”
很多人第一次接触单例模式,记住的往往是这样一套写法:
class Singleton {
public:
static Singleton& instance()
{
static Singleton obj;
return obj;
}
private:
Singleton() = default;
};然后脑子里很自然地形成一个等式:
单例模式 = 一个类只能创建一个对象这个理解不能说错,但如果放到嵌入式开发里,就有点停留在表面了。
因为嵌入式系统面对的不是纯粹的软件对象,而是 UART、SPI、I2C、DMA、Flash、ADC、PWM 这些真实存在的硬件资源。
所以我更愿意这样理解单例:
嵌入式里的单例,不要从“对象只能有一个”出发,而要从“资源只能有一个 Owner”出发。
这句话听起来有点抽象,下面用一个 UART 的例子把它拆开。
先来看一个很常见的场景
假设 MCU 上只有一个 USART1。
现在系统里有两个模块需要使用它:
Logger ----\
---> USART1
Modem ----/Logger 用它打印日志,Modem 用它发送通信数据。
如果按照传统单例模式的思路,我们可能会把 UART 驱动写成这样:
class Uart1 {
public:
static Uart1& instance()
{
static Uart1 uart;
return uart;
}
void send(const uint8_t* data, size_t len);
private:
Uart1() = default;
};然后 Logger 这么用:
void Logger::write(const char* msg)
{
Uart1::instance().send(
reinterpret_cast<const uint8_t*>(msg),
strlen(msg));
}Modem 也这么用:
void Modem::sendPacket(const uint8_t* data, size_t len)
{
Uart1::instance().send(data, len);
}看起来没什么问题。
整个系统里确实只有一个 Uart1 对象。
Uart1 对象数量 = 1如果只看设计模式的定义,这个 Singleton 已经实现成功了。
但问题真的解决了吗?
不一定。
一个对象,不代表一个 Owner
假设 send() 底层使用 DMA。
Logger 先发送一条日志:
Logger
↓
Uart1::send()
↓
配置 DMA
buffer = "motor error..."
length = 14
↓
DMA 开始发送DMA 还没有发送结束,Modem 又过来了:
Modem
↓
Uart1::send()
↓
重新配置 DMA
buffer = packet
length = 64最后串口输出就有可能乱掉。
原本希望发送:
motor error...
AA 55 01 02 03 04 ...实际可能变成:
motor er AA 55 01 02 03 ...或者出现:
DMA 状态异常
UART Busy
日志缺失
协议数据损坏
发送缓存被覆盖这个时候有一个很关键的问题:
明明整个程序里只有一个 Uart1 对象,为什么还是会出问题?因为:
对象只有一个
≠
Owner 只有一个虽然软件里只有一份 Uart1,但是实际上:
Logger:我可以直接操作 USART1
Modem:我也可以直接操作 USART1两个模块都认为自己有权控制这个硬件。
系统实际变成了这样:
USART1
/ \
Logger Modem
↑ ↑
Owner? Owner?真正的问题,不是创建了几个 C++ 对象。
而是:
同一个物理资源出现了多个控制者。
嵌入式里真正应该先问的问题
如果 MCU 上真的只有一个 USART1,那么软件设计首先要回答的,不应该是:
Uart 类要不要写成 Singleton?而应该是:
USART1 到底归谁管?也就是:
谁才是这个资源唯一的 Owner?
这才是比“对象有几个”更重要的问题。
换一种设计:给 UART 一个真正的 Owner
假设我们规定:
UartTxTask是 USART1 唯一的管理者。
系统结构改成:
Logger ──────┐
│
Modem ──────┼──> TX Queue ──> UartTxTask ──> USART1
│
Shell ──────┘这时候 Logger 不再直接调用:
HAL_UART_Transmit_DMA(...);Modem 也不能直接配置 USART。
它们只能提交“发送请求”。
例如定义一个消息:
struct TxMessage {
const uint8_t* data;
size_t len;
};Logger 做的事情变成:
void Logger::write(const char* msg)
{
TxMessage message {
reinterpret_cast<const uint8_t*>(msg),
strlen(msg)
};
txQueue.push(message);
}Modem 同样只是提交数据:
void Modem::sendPacket(const uint8_t* data, size_t len)
{
TxMessage message {
data,
len
};
txQueue.push(message);
}真正操作硬件的是 UART Task:
void uartTxTask()
{
while (true)
{
TxMessage message = txQueue.pop();
uart.send(message.data, message.len);
waitTxComplete();
}
}于是整个数据路径就非常清楚:
Logger
│
│
Modem ───────> Queue ───────> UartTxTask ───────> USART1
│
│
Shell只有 UartTxTask 知道怎么操作:
UART 寄存器
DMA
中断
TX 状态机
发送完成事件这时才真正形成了:
USART1
↓
唯一控制入口
↓
UartTxTask
↓
Single Owner有意思的地方来了:这时候甚至不需要 Singleton
UART Task 内部完全可以只是一个普通对象:
Uart uart1(USART1);然后:
void uartTxTask()
{
while (true)
{
TxMessage message = txQueue.pop();
uart1.send(message.data, message.len);
waitTxComplete();
}
}注意,这里没有:
Uart::getInstance()也没有:
private:
Uart();更没有什么双重检查锁、懒加载。
它就是一个普通对象。
但是整个系统依然只有一个:
Uart uart1更重要的是:
只有一个模块有权操作它所以系统仍然非常清晰。
这就是一个很重要的区别:
只有一个实例,不等于必须使用单例模式。
Singleton 和 Single Owner 到底有什么区别?
可以把两种设计放在一起看。
方案一:对象是单例,但谁都可以操作
Logger ─────┐
│
Modem ──────┼──> Uart::instance() ──> USART1
│
Shell ──────┘对象确实只有一个:
Uart 对象 = 1但是资源的实际控制者有多个:
Logger
Modem
Shell每个模块都可以随时调用 UART。
于是你还要继续考虑:
谁先调用?
谁后调用?
DMA 正在忙怎么办?
是否需要 mutex?
ISR 会不会同时访问?
发送缓存归谁维护?
状态机由谁维护?系统仍然很复杂。
方案二:普通对象,但资源只有一个 Owner
Logger ───┐
│
Modem ────┼──> Queue ──> UartTask ──> Uart uart1 ──> USART1
│
Shell ────┘这里没有使用 Singleton Pattern。
但是:
USART1 Owner = UartTask所有访问都经过它。
结果反而更加稳定。
所以可以简单理解成:
Singleton
关注的是:
“对象到底有几个?”
Single Owner
关注的是:
“这个资源到底谁说了算?”而在嵌入式系统里,后一个问题通常更加重要。
为什么这件事在嵌入式里特别重要?
因为嵌入式系统里的很多对象,其实都对应真实资源。
例如:
USART1
SPI1
I2C2
ADC1
DMA1
Flash
Watchdog
RTC
PWM Timer
CAN Controller这些资源有一个共同特点:
它们不是凭空创建的软件概念,而是真实存在于芯片里的硬件。
比如芯片上只有一个:
USART1那么它对应的:
寄存器
DMA
中断
发送状态
接收状态
buffer
callback本质上都应该有明确的归属。
如果多个模块分别维护一份状态:
Module A:
uart_busy = true
tx_buffer = buffer_a而另外一个模块认为:
Module B:
uart_busy = false
tx_buffer = buffer_b马上就会出现一个问题:
到底谁的状态才是真的?
硬件只有一份:
USART1软件却出现两套认知:
A 认为 UART 正在忙
B 认为 UART 空闲这就是非常典型的状态分裂。
单例真正有价值的地方是什么?
如果只从代码形式看,单例好像只是:
限制对象创建数量但如果再往下一层看,它真正有价值的地方其实是:
多个访问者
↓
统一入口
↓
统一状态
↓
共享资源放到嵌入式里,可以理解成:
多个业务模块
↓
资源管理者
↓
UART / SPI / Flash / DMA所以真正值得学习的,并不是:
private 构造函数怎么写也不是:
getInstance() 怎么实现而是:
资源的所有权应该怎么划分再举一个 SPI 的例子
很多 MCU 上,一个 SPI 总线会挂多个设备:
SPI1
│
┌───────┼────────┐
│ │ │
Flash IMU Display假设:
Flash 使用:
SPI Mode 0
10 MHzIMU 使用:
SPI Mode 3
8 MHzDisplay 使用:
SPI Mode 0
20 MHz如果三个模块都可以直接操作 SPI:
FlashDriver ──> SPI1
ImuDriver ──> SPI1
Display ──> SPI1很容易出现这样的情况:
IMU 刚刚把 SPI 配置成 Mode 3
↓
Display 把 SPI 改成 Mode 0
↓
IMU 继续读取数据这时候 IMU 读到的数据自然可能完全错误。
所以 SPI 真正需要解决的问题,也不是:
Spi 类到底是不是 Singleton而是:
SPI1 总线到底归谁管更合理的结构应该是:
Flash ────┐
│
IMU ──────┼──> SpiBus ───> SPI1
│
Display ──┘每个设备只需要告诉 SpiBus:
我要访问哪个设备
我要什么 SPI Mode
我要什么速度
我要发送什么数据真正负责切换:
CS
Clock
Mode
DMA
Transfer State的只有 SpiBus。
这样 SPI 总线才真正只有一个 Owner。
嵌入式里的“只有一个”,其实至少有三层含义
很多时候,我们会笼统地说:
这个东西只能有一个但实际上,这里面至少包含三件不同的事情。
第一层:对象只有一个
例如:
Uart object = 1这是传统 Singleton Pattern 最关心的问题。
第二层:状态只有一份
例如 UART 的:
tx_busy
rx_busy
tx_buffer
rx_buffer
current_baudrate
dma_state这些状态最好只有一个可信来源。
也就是:
Single Source of Truth否则不同模块各维护一套状态,很容易互相矛盾。
第三层:控制者只有一个
只有一个模块真正决定:
什么时候启动 DMA
什么时候停止 DMA
什么时候修改波特率
什么时候清状态位
什么时候切换 SPI Mode
什么时候修改硬件寄存器这就是:
Single Owner在嵌入式开发中:
状态只有一份、控制者只有一个,通常比“对象只有一个”更加重要。
一个很实用的判断方法
以后看到一个硬件模块,先不要急着问:
这个类是不是应该做成 Singleton?先问下面几个问题。
1. 这个资源到底是什么?
先找到真正需要管理的东西。
例如:
USART1
SPI1
Flash
DMA Channel
Watchdog
ADC
PWM Timer2. 谁拥有它?
最好能明确回答:
UartTask owns USART1或者:
SpiBus owns SPI1而不是:
大家都可以用如果你回答不出“谁拥有它”,通常意味着资源边界还没有设计清楚。
3. 谁允许直接修改它的状态?
例如:
Logger 不能直接启动 UART DMA
Modem 不能直接改 UART 寄存器
Shell 不能直接修改发送状态它们只能通过 Owner。
4. 其他模块通过什么方式访问它?
可以是:
普通函数调用
消息队列
事件
Command
Request / Response
IPC具体使用哪种方式并不是重点。
重点是:
访问路径应该明确5. 资源状态存在哪里?
例如:
class UartManager {
private:
bool txBusy;
uint8_t* txBuffer;
size_t txLength;
};最好只有一个地方维护这些状态。
否则非常容易出现:
A 认为 Busy
B 认为 Idle这样的状态分裂。
一个很容易踩的坑:Singleton 不等于线程安全
很多人可能会觉得:
我都已经把 UART 写成 Singleton 了,
应该不会冲突了吧?其实完全不是一回事。
Singleton 最多只能帮你保证:
对象数量 = 1它不能保证:
Task A
Task B
ISR不会同时访问这个对象。
也就是说:
Singleton
≠
Thread Safe进一步说:
Singleton
≠
Resource Safe你仍然可能需要:
mutex
critical section
queue
event
atomic
interrupt protection所以在 RTOS 环境中,我通常更喜欢这样的设计:
多个 Task
↓
Message Queue
↓
唯一 Owner Task
↓
Hardware而不是:
多个 Task
↓
Singleton
↓
mutex
↓
Hardware两种方式都能实现。
但前一种通常更容易把:
硬件状态
DMA 状态
发送队列
错误处理集中在一个地方。
“只有一个实例”和“必须使用单例模式”是两回事
例如:
Uart uart1(USART1);
Logger logger(uart1);
Modem modem(uart1);系统里依然只有一个:
uart1但是 Uart 这个类型本身并没有被设计成 Singleton。
以后如果硬件增加 UART,可以直接:
Uart uart1(USART1);
Uart uart2(USART2);
Uart uart3(USART3);然后:
Logger logger(uart1);
Gnss gnss(uart2);
Modem modem(uart3);整个结构依然成立。
这通常比:
Uart::instance()灵活得多。
因为今天的板子可能只有:
USART1下一版硬件可能就变成:
USART1 -> Debug
USART2 -> GNSS
USART3 -> LTE如果一开始就把:
Uart = Singleton写死了,后面扩展就会比较麻烦。
所以设计时一定要区分:
当前系统只有一个实例和:
这个类型从设计上永远只能有一个实例这完全是两回事。
哪些东西比较适合真正做成 Singleton?
嵌入式里有一些东西确实天然接近 Singleton。
比如:
SystemClockManager
WatchdogManager
ResetManager
BootManager
SystemTime因为这些资源往往具有这样的特点:
物理上天然唯一
+
生命周期基本等于系统生命周期
+
未来几乎没有多实例需求
+
状态需要全局保持一致这时候使用 Singleton 并没有什么问题。
哪些东西不要轻易写成 Singleton?
下面这些东西最好多想一下:
UART
SPI
I2C
CAN
Sensor
Protocol
CommunicationChannel
BufferPool
StorageBackend因为它们非常容易从:
一个变成:
多个比如今天:
一个 IMU下一代产品可能变成:
主 IMU
+
备份 IMU又比如今天:
CAN1下一代产品可能变成:
CAN1
CAN2如果代码里到处都是:
CanBus::instance()后期扩展就会比较痛苦。
回到最开始那句话
现在再来看:
嵌入式里的单例,不要从“对象只能有一个”出发,而要从“资源只能有一个 Owner”出发。
它真正想表达的并不是:
Singleton 不好也不是:
嵌入式不要使用单例而是在提醒我们:
不要把注意力只停留在设计模式的代码形式上。
真正应该思考的是:
这个硬件资源是谁的?
谁维护它的状态?
谁能够修改它?
其他模块通过什么方式访问它?
发生并发时由谁协调?当这些问题都回答清楚之后:
要不要使用 Singleton反而只是一个实现细节。
可以把整个思路压缩成一句话:
Singleton 管的是对象数量,Single Owner 管的是资源控制权。
而在嵌入式系统里:
真正危险的通常不是多创建了一个对象,而是同一个硬件资源出现了多个控制者。
所以下次再碰到:
UART
SPI
DMA
Flash
ADC
PWM
Watchdog先不要急着写:
XXX::instance()先问一句:
这个资源,到底归谁管?
这个问题想明白了,后面的设计通常也就顺了。