嵌入式 C 的单例模式:把“全局唯一”写得更稳
在嵌入式项目里有些东西天生就只能有一个看门狗、RTC、系统时钟、调试串口、日志器、系统配置管理器、CRC 模块……这些模块如果随手用全局变量堆起来早晚会遇到初始化顺序混乱、到处可写难排查、ISR/任务并发冲撞等问题。 单例模式的目标很简单把“全局唯一”的资源做成“受控的全局”。唯一入口、明确初始化、约束访问让系统更稳定、可维护。什么时候用单例更合适硬件资源物理唯一WDT、RTC、SysTick、调试 UART 等。全局服务日志器、系统配置/参数加载、告警/事件上报、电源管理。必须集中控制的状态机系统模式管理、升级流程、故障保护。不适用需要多个实例并行的模块多路 UART/I2C/SPI 等。这类优先“管理器 多实例”别硬做成单例。在 C 里怎么落地没有类就别纠结“面向对象”本质是“受控的全局对象 受控的访问入口”。两种常见写法:饿汉式推荐静态对象常驻启动阶段 init() 一次。简单、可预期、无动态内存最适合嵌入式。懒汉式首次使用时再初始化。路径更复杂需要考虑并发和中断安全不建议在 ISR 首次调用。实践偏好大多数底层单例用饿汉式系统早期就完成初始化上层服务要懒加载时注意并发保护和边界。设计要点一个小模型内部状态static 模块内可见全局不导出可写句柄。外部入口只提供函数或只读接口表函数指针集合。初始化幂等init() 可多次调用真正只生效一次。并发与 ISR明确哪些 API 可在中断里用必要时提供 FromISR 版本。示例一饿汉式单例裸机/RTOS 通用以“日志器”举例底层用一个 UART 发送。避免动态内存init() 幂等可选关键段保护。// log.h#ifndefLOG_H#defineLOG_H#includestdint.h#includestddef.hvoidLog_Init(void);// 幂等voidLog_Write(constchar*s);// 线程安全与否看实现voidLog_WriteHex(constvoid*buf,size_tlen);// 可选对外只暴露只读接口减少可写全局的暴露typedefstruct{void(*write)(constchar*);void(*writeHex)(constvoid*,size_t);}LogIface;constLogIface*Log_Get(void);#endif// log.c#includelog.h// -- 与平台相关的 HAL示意根据项目填充 --staticvoidhal_uart_init(void){// 配置波特率、GPIO、多路复用等}staticvoidhal_uart_write_blocking(constchar*s){// 逐字节写 TX 寄存器并等待发送完成}// -- 关键段裸机/RTOS 适配 --staticinlinevoidcrit_enter(void){// 裸机__disable_irq(); RTOStaskENTER_CRITICAL();}staticinlinevoidcrit_exit(void){// 裸机__enable_irq(); RTOStaskEXIT_CRITICAL();}// -- 单例本体 --typedefstruct{volatileintinitialized;// 0 未初始化1 已初始化}Log_t;staticLog_t g_log{0};// 饿汉式静态存储期对象voidLog_Init(void){if(g_log.initialized)return;crit_enter();if(!g_log.initialized){// 双检降低竞争开销hal_uart_init();g_log.initialized1;}crit_exit();}voidLog_Write(constchar*s){if(!g_log.initialized){// 团队约定静默丢弃或自动初始化择一Log_Init();}hal_uart_write_blocking(s);}voidLog_WriteHex(constvoid*buf,size_tlen){staticconstchar hex[]0123456789ABCDEF;constuint8_t*p(constuint8_t*)buf;charout[3]{0};for(size_ti0;ilen;i){out[0]hex[(p[i]4)0xF];out[1]hex[p[i]0xF];hal_uart_write_blocking(out);hal_uart_write_blocking( );}}staticconstLogIface kIface{.writeLog_Write,.writeHexLog_WriteHex,};constLogIface*Log_Get(void){Log_Init();returnkIface;}用法示意intmain(void){// 其他板级初始化...Log_Init();// 明确初始化点Log_Write(boot ok\r\n);constLogIface*logLog_Get();log-write(run...\r\n);return0;}要点g_log 不在头文件暴露外部只能走 API。初始化幂等担心首次路径里关中断可在系统早期显式 Log_Init()。裸机关键段保持很短RTOS 更推荐上电阶段完成初始化。示例二懒汉式 FreeRTOS 串行化发送多个任务写日志时希望串口访问串行化可以加互斥量。注意ISR 不要拿互斥需提供 FromISR 路径或在 ISR 里仅入队。// log_rtos.c片段#includeFreeRTOS.h#includesemphr.hexternvoidhal_uart_init(void);externvoidhal_uart_write_blocking(constchar*s);typedefstruct{volatileintinitialized;}Log_t;staticLog_t g_log{0};staticSemaphoreHandle_t s_uartMtx;// 只保护发送不保护初始化voidLog_Init(void){if(g_log.initialized)return;taskENTER_CRITICAL();if(!g_log.initialized){hal_uart_init();s_uartMtxxSemaphoreCreateMutex();// 生产环境建议断言或降级处理创建失败g_log.initialized1;}taskEXIT_CRITICAL();}voidLog_Write(constchar*s){if(!g_log.initialized)Log_Init();if(s_uartMtx){if(xSemaphoreTake(s_uartMtx,portMAX_DELAY)pdTRUE){hal_uart_write_blocking(s);xSemaphoreGive(s_uartMtx);}}else{hal_uart_write_blocking(s);}}// 如需 ISR 使用// 1) 提供 Log_WriteFromISR避免互斥入队到环形缓冲// 2) 由后台任务取出串行打印。并发与中断安全这几件事初始化尽量别出现在 ISR。需要 ISR 使用的服务系统早期就初始化好。ISR 不拿互斥。RTOS 场景用 FromISR 入队任务侧消费。± 裸机关键段要短不在关键段内做阻塞外设操作。多个单例的依赖要写清初始化顺序时钟 - UART - 日志器不要靠“碰运气”。常见坑与规避动态内存尽量避免在底层单例里 malloc防碎片、可预测。隐式全局不要把可写结构体放头文件对外暴露函数或只读接口表。可测试性接口用函数指针表或弱符号单测时替换 HAL。可能扩容若未来可能多路别把单例写死。做成“管理器 默认实例0”迁移更顺滑。一个可复用的小模板// singleton_template.h#ifndefSINGLETON_TEMPLATE_H#defineSINGLETON_TEMPLATE_Htypedefstruct{int(*init)(void);void(*doWork)(intarg);}ServiceIface;voidService_Init(void);constServiceIface*Service_Get(void);#endif// singleton_template.c#includesingleton_template.h// 平台相关依赖示意staticinthal_dep_init(void){return0;}staticvoidhal_dep_work(intarg){(void)arg;}typedefstruct{volatileintinitialized;}Service_t;staticService_t g_service{0};voidService_Init(void){if(g_service.initialized)return;// 可选关键段// crit_enter();if(!g_service.initialized){(void)hal_dep_init();g_service.initialized1;}// crit_exit();}staticvoidService_DoWork(intarg){if(!g_service.initialized)Service_Init();hal_dep_work(arg);}staticconstServiceIface kSvc{.initService_Init,.doWorkService_DoWork,};constServiceIface*Service_Get(void){Service_Init();returnkSvc;}落地方法把 HAL 部分换成你的外设/服务实现把关键段宏替换为项目里的裸机/RTOS 实现即可。何时不该用单例需要多个实例多路外设、多连接、多会话。强依赖测试隔离、并发扩展、热插拔等“生命周期复杂”的模块。业务对象如每个连接/会话应有清晰的创建/销毁接口。小结单例在嵌入式 C 的价值是把“全局唯一”变成“可控可测”唯一入口、明确初始化、约束访问。优先用饿汉式 静态分配初始化提前到系统早期。明确线程/中断边界任务用互斥ISR 走无锁路径或队列。预留测试替换点别把可写全局暴露到头文件。把上面的模板拷进项目替换 HAL 细节就能快速做出一个“顺手、稳定”的单例模块后续有多实例需求时也能平滑升级到“管理器 多实例”。