在嵌入式开发中,我们经常遇到一些"独一份"的资源:系统的配置参数(Config)、日志管理器(Logger)、或者某个特定的硬件控制器(如电源管理 IC)。
如何保证这些资源全局唯一、初始化顺序正确,且不被随意篡改?这就是单例模式的战场。
专栏导读 :C 语言里没有 private,也没有 class。初学者往往直接扔出一个全局变量 extern MyConfig g_config; 供全村人读写。这种"裸奔"的代码是 Bug 的温床。本篇教你用 C 语言实现"带访问控制"的单例,并引入"懒加载"技术加速系统启动。
1. 场景还原 (The Pain)
假设你正在写一个 日志系统 (Logger),底层走 UART 输出。
菜鸟的写法:全局变量满天飞
// logger.h
typedef struct {
UART_HandleTypeDef* huart;
uint32_t baud_rate;
bool is_initialized;
} Logger;
// 这是一个灾难:全局暴露,任何人都能改
extern Logger g_logger;
// main.c
void main() {
// 痛点1:初始化地狱
// 必须先初始化 UART,再初始化 Logger,顺序错了就 HardFault
MX_USART1_UART_Init();
g_logger.huart = &huart1;
g_logger.is_initialized = true;
// ... 业务逻辑 ...
}
// task.c
void User_Task() {
// 痛点2:安全性缺失
// 某个实习生不小心写了这句,整个日志系统波特率被改了
g_logger.baud_rate = 9600;
Log_Print("Hello");
}
架构师的审视
-
全局污染 :
g_logger暴露在全局命名空间,谁都能改它的成员变量。这违反了"最少知识原则"。 -
启动慢 :如果在
main()里把 WiFi、蓝牙、LCD、文件系统全部初始化一遍,设备上电可能要黑屏 3 秒。其实很多功能(比如文件系统)可能很久之后才用到。 -
初始化依赖 :如果
SysConfig依赖FlashDriver,而FlashDriver依赖SPI,在main函数里手动排列Init()顺序不仅繁琐,而且容易出错。
2. 模式图解 (The Concept)
单例模式在 C 语言中的核心在于:利用 .c 文件的 static 作用域模拟"私有成员",只暴露函数接口。
-
Lazy Loading (懒汉式) :只有当第一次调用
Get_Instance()时,才去初始化硬件。 -
Encapsulation (封装):外部拿不到结构体实体,只能拿到指针(句柄)。
3. 代码实战 (The Code)
我们来实现一个具备懒加载功能的日志管理器。
3.1 头文件 (Interface)
注意:头文件里不要暴露结构体的具体成员!这叫"不透明指针" (Opaque Pointer) 设计。
// logger.h
#ifndef LOGGER_H
#define LOGGER_H
// 声明一个不完整的类型,外部只能持有它的指针,不能访问成员
typedef struct Logger_t Logger;
// 唯一获取实例的接口
Logger* Logger_GetInstance(void);
// 业务接口
void Logger_Log(Logger* self, const char* msg);
#endif
3.2 实现文件 (Implementation)
这里用到了 C 语言的一个经典技巧:函数内的静态变量 或 文件域的静态变量。
// logger.c
#include "logger.h"
#include <stdio.h>
#include <stdbool.h>
// 1. 真正的结构体定义藏在这里
struct Logger_t {
bool initialized;
uint32_t error_count;
// 假设依赖底层的 UART 句柄
void* hw_handle;
};
// 2. 静态实例,分配在 .bss 段,外部不可见
static struct Logger_t s_logger_instance = {
.initialized = false,
.error_count = 0,
.hw_handle = NULL
};
// 内部私有函数:初始化硬件
static void LowLevel_Init(void) {
printf("[System] Powering on UART Hardware...\n");
// 模拟硬件寄存器配置
// s_logger_instance.hw_handle = HAL_UART_Init(...);
}
// 3. 核心:懒汉式单例获取器
Logger* Logger_GetInstance(void) {
// 关键点:检查是否已初始化
// 只有第一次调用本函数时,才会执行硬件初始化
if (!s_logger_instance.initialized) {
// 临界区保护(防止多任务并发初始化)
// ENTER_CRITICAL();
if (!s_logger_instance.initialized) { // Double-Check
LowLevel_Init();
s_logger_instance.initialized = true;
}
// EXIT_CRITICAL();
}
return &s_logger_instance;
}
// 业务方法
void Logger_Log(Logger* self, const char* msg) {
// 这是一个好的防御性编程习惯
if (self != &s_logger_instance || !self->initialized) {
return;
}
// 实际发送逻辑
printf("[Log] %s (Errors: %d)\n", msg, self->error_count);
}
3.3 怎么用?(Usage)
你会发现 main 函数变得异常清爽。
// main.c
#include "logger.h"
void main() {
printf("System Booting...\n");
// 注意:这里没有调用 Logger_Init()!
// 此时 UART 硬件还没上电,节省功耗,启动极快。
// ... 执行其他初始化 ...
// 第一次用到日志,UART 才会被初始化
Logger* log = Logger_GetInstance();
Logger_Log(log, "First Message");
// 第二次用到,直接返回已存在的实例,开销几乎为 0
Logger_Log(Logger_GetInstance(), "Second Message");
}
4. 内存与性能分析 (The Cost)
空间开销
-
几乎为零 。我们只是把原本的全局变量加了个
static限制,没有额外的堆内存分配。 -
相比 C++ 的单例(可能涉及虚表、new 操作),C 语言这种静态内存分配是最安全的。
时间开销 (The Trade-off)
-
懒汉式的代价 :每次调用
GetInstance都要做一个if (!initialized)的布尔判断。 -
分析:这个分支判断在现代 MCU 流水线上几乎可以忽略不计(单周期)。
-
收益:
-
启动加速:分散了启动时的 CPU 负载。
-
解耦顺序:你再也不用关心是先 Init A 还是先 Init B,谁被用到谁就自动 Init。
-
5. 变种与延伸 (The Evolution)
5.1 饿汉式 (Eager Initialization)
如果你的系统对确定性要求极高(不允许第一次调用时产生不可预知的硬件初始化延迟),或者必须在中断里使用单例(中断里不能做耗时的 Init),则应改用"饿汉式"。
- 做法 :保留
GetInstance接口,但在main的最开始显式调用一次Logger_GetInstance(),强制完成初始化。
5.2 硬件寄存器的"天然单例"
在嵌入式中,硬件本身就是单例。我们经常把硬件寄存器映射为结构体:
// 这也是一种单例模式的变体
#define UART1_BASE 0x40004000
#define UART1 ((UART_TypeDef *) UART1_BASE)
// 我们可以用单例模式封装它,增加互斥锁
Logger* UART1_Manager_Get(void) {
static Logger s_mgr;
if (!s_mgr.init) {
s_mgr.base = UART1; // 绑定硬件地址
s_mgr.lock = Mutex_Create();
s_mgr.init = true;
}
return &s_mgr;
}
5.3 多例模式 (Multiton)
如果你有 3 个串口(UART1, UART2, UART3),怎么复用代码?
-
做法 :
GetInstance(int id)。 -
内部维护一个
static Logger instances[3];数组。 -
根据 ID 返回对应的指针。这在写驱动库(Driver Library)时非常常见。