14、全局变量与模块化:全局变量的危害、使用getter/setter、单例模式
说实话,全局变量这个话题,我每次带新人时都要专门拿出来讲一讲。为什么呢?因为太多人觉得「全局变量多方便啊,哪里都能用」。嗯,这话没错,但代价是什么?代价就是你的代码会变成一团乱麻。
我在一个车载项目里就吃过这个亏。当时有个全局变量 g_system_state,十几个模块都在读写它。结果有一天,某个中断服务程序改了它的值,主循环里的逻辑全乱了。查了两天才定位到问题。从那以后,我对全局变量就特别敏感。
全局变量的三大危害
先说说全局变量到底「坏」在哪。不是不能用,是得知道它为什么危险。
- 命名空间污染:你定义了一个
count,别人也可能想用count。链接时冲突了,改名字?改一个还好,改几十个试试? - 依赖关系不透明:函数 A 改了全局变量,函数 B 依赖它。你改了 A 的逻辑,B 莫名其妙就崩了。这种「隐形耦合」最要命。
- 多线程/中断不安全:全局变量不加锁,在多线程环境里就是定时炸弹。中断里改一下,主循环里读到的值可能已经变了。
核心原则:全局变量本质上就是「共享状态」。共享状态越多,代码的复杂度呈指数级增长。能不用就不用,实在要用,也得管好它。
用 getter/setter 封装全局变量
那如果确实需要一个全局状态呢?比如系统运行模式、设备温度阈值。我的做法是:不直接暴露变量,而是通过函数接口访问。
说白了,就是把全局变量藏起来。外部模块只能通过 getter 读、setter 写。这样你可以在 setter 里加校验、加日志、加锁。
/* system_mode.h */
#ifndef SYSTEM_MODE_H
#define SYSTEM_MODE_H
typedef enum {
MODE_IDLE,
MODE_RUNNING,
MODE_SLEEP,
MODE_ERROR
} system_mode_t;
/* 只暴露函数接口,不暴露变量 */
system_mode_t system_mode_get(void);
void system_mode_set(system_mode_t mode);
#endif
/* system_mode.c */
#include "system_mode.h"
/* 全局变量藏在 .c 文件里,外部看不见 */
static system_mode_t g_mode = MODE_IDLE;
system_mode_t system_mode_get(void) {
return g_mode;
}
void system_mode_set(system_mode_t mode) {
/* 这里可以加校验逻辑 */
if (mode > MODE_ERROR) {
return; /* 非法值,直接忽略 */
}
/* 也可以加日志 */
// log_info("system mode changed to %d", mode);
g_mode = mode;
}
我的习惯:所有全局变量都加上 static,只在本文件可见。然后通过函数接口暴露。这样即使以后要加锁、加回调,也只需要改这一个文件。
单例模式:更优雅的全局状态管理
有时候,一个模块在整个系统里只需要一个实例。比如硬件抽象层、日志系统、配置管理器。这时候就可以用单例模式。
单例模式说白了就是:保证一个类只有一个实例,并提供一个全局访问点。在 C 语言里实现起来也很简单。
/* logger.h */
#ifndef LOGGER_H
#define LOGGER_H
typedef struct logger_s logger_t;
/* 获取单例实例 */
logger_t* logger_instance(void);
/* 接口函数 */
void logger_log(logger_t* self, const char* msg);
void logger_set_level(logger_t* self, int level);
#endif
/* logger.c */
#include "logger.h"
#include <stdio.h>
struct logger_s {
int level;
/* 其他状态 */
};
/* 静态实例,只在本文件可见 */
static logger_t s_instance = {0};
/* 初始化标志 */
static int s_initialized = 0;
logger_t* logger_instance(void) {
if (!s_initialized) {
/* 第一次调用时初始化 */
s_instance.level = 3;
s_initialized = 1;
}
return &s_instance;
}
void logger_log(logger_t* self, const char* msg) {
if (self && msg) {
printf("[LOG] %s\n", msg);
}
}
void logger_set_level(logger_t* self, int level) {
if (self) {
self->level = level;
}
}
注意:单例模式在多线程环境下需要加锁。我一般会在 logger_instance() 里加一个互斥锁,防止两个线程同时初始化。不过嵌入式里如果只有一个核心,很多时候可以简化处理。
全局变量 vs 单例模式:怎么选?
你可能会问:单例模式不也是全局变量吗?本质上确实有点像,但区别在于:
| 对比项 | 裸全局变量 | getter/setter 封装 | 单例模式 |
|---|---|---|---|
| 访问控制 | 无 | 有(函数接口) | 有(函数接口) |
| 初始化时机 | 编译时 | 编译时 | 第一次使用时(懒加载) |
| 扩展性 | 差 | 中(可加校验) | 好(可继承、可替换) |
| 测试难度 | 高 | 中 | 低(可 mock) |
| 典型场景 | 小项目、快速原型 | 中等规模项目 | 大型项目、框架设计 |
我个人建议:能用局部变量就别用全局变量,能用 getter/setter 就别裸奔,需要全局唯一实例时上单例模式。这个选择路径很清晰。
知识体系图
下面这张图总结了全局变量管理的核心思路:
避坑指南
最后分享几个我踩过的坑:
- 别在头文件里定义全局变量:
int g_flag;写在 .h 文件里,多个 .c 文件包含后,链接器会报重复定义。正确做法是在 .c 里定义,在 .h 里用extern声明。 - 初始化顺序问题:两个全局变量互相依赖?比如 A 的初始化需要 B 的值。这种代码我见过,调试起来非常痛苦。解决办法是用函数初始化,明确控制顺序。
- 中断里别用复杂的 getter/setter:如果 getter 里加了锁,中断里调用可能导致死锁。我一般会在中断里用原子操作,或者干脆不用全局变量。
一个小技巧:如果你实在要用全局变量,给它加个前缀。比如项目名缩写 g_ 或 s_。这样搜索时一目了然,别人一看就知道这是个全局变量。
嗯,全局变量这个话题就聊到这儿。记住一句话:共享状态是万恶之源。能藏就藏,能封装就封装。你的代码会感谢你的。
公众号:蓝海资料掘金营,微信deep3321