嵌入式里的单例:别只盯着“对象只有一个”,先搞清楚“资源到底归谁管”

很多人第一次接触单例模式,记住的往往是这样一套写法:

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 MHz

IMU 使用:

SPI Mode 3
8 MHz

Display 使用:

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 Timer

2. 谁拥有它?

最好能明确回答:

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()

先问一句:

这个资源,到底归谁管?

这个问题想明白了,后面的设计通常也就顺了。

标签:嵌入式, 单例模式

你的评论