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 就别裸奔,需要全局唯一实例时上单例模式。这个选择路径很清晰。

知识体系图

下面这张图总结了全局变量管理的核心思路:

全局变量管理策略 全局状态需求 裸全局变量 getter/setter 封装 单例模式 缺点 • 命名空间污染 • 依赖关系不透明 • 多线程不安全 特点 • 变量隐藏,只暴露接口 • 可加校验、日志、锁 • 适合中等规模项目 • 修改不影响外部调用 特点 • 全局唯一实例 • 懒加载,按需初始化 • 适合大型项目 • 可 mock,方便测试 推荐路径: 局部变量 → getter/setter → 单例模式

避坑指南

最后分享几个我踩过的坑:

  • 别在头文件里定义全局变量int g_flag; 写在 .h 文件里,多个 .c 文件包含后,链接器会报重复定义。正确做法是在 .c 里定义,在 .h 里用 extern 声明。
  • 初始化顺序问题:两个全局变量互相依赖?比如 A 的初始化需要 B 的值。这种代码我见过,调试起来非常痛苦。解决办法是用函数初始化,明确控制顺序。
  • 中断里别用复杂的 getter/setter:如果 getter 里加了锁,中断里调用可能导致死锁。我一般会在中断里用原子操作,或者干脆不用全局变量。

一个小技巧:如果你实在要用全局变量,给它加个前缀。比如项目名缩写 g_s_。这样搜索时一目了然,别人一看就知道这是个全局变量。

嗯,全局变量这个话题就聊到这儿。记住一句话:共享状态是万恶之源。能藏就藏,能封装就封装。你的代码会感谢你的。


公众号:蓝海资料掘金营,微信deep3321