第 2 篇:单例模式 (Singleton) 与 懒汉式硬件初始化

在嵌入式开发中,我们经常遇到一些"独一份"的资源:系统的配置参数(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");

}

架构师的审视

  1. 全局污染g_logger 暴露在全局命名空间,谁都能改它的成员变量。这违反了"最少知识原则"。

  2. 启动慢 :如果在 main() 里把 WiFi、蓝牙、LCD、文件系统全部初始化一遍,设备上电可能要黑屏 3 秒。其实很多功能(比如文件系统)可能很久之后才用到。

  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 流水线上几乎可以忽略不计(单周期)。

  • 收益

    1. 启动加速:分散了启动时的 CPU 负载。

    2. 解耦顺序:你再也不用关心是先 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)时非常常见。

相关推荐
321.。2 小时前
从 0 到 1 实现 Linux 下的线程安全阻塞队列:基于 RAII 与条件变量
linux·开发语言·c++·学习·中间件
疯狂的喵2 小时前
实时信号处理库
开发语言·c++·算法
程序员清洒2 小时前
Flutter for OpenHarmony:Stack 与 Positioned — 层叠布局
开发语言·flutter·华为·鸿蒙
what丶k2 小时前
深入理解Java NIO:从原理到实战的全方位解析
java·开发语言·nio
EndingCoder2 小时前
高级项目:构建一个 CLI 工具
大数据·开发语言·前端·javascript·elasticsearch·搜索引擎·typescript
xianrenli382 小时前
python版本配置
开发语言·python
PfCoder2 小时前
C# 中的定时器 System.Threading.Timer用法
开发语言·c#
血小板要健康2 小时前
笔试面经2(上)(纸质版)
java·开发语言
缺点内向2 小时前
Word 自动化处理:如何用 C# 让指定段落“隐身”?
开发语言·c#·自动化·word·.net