结构体与联合体的安全编程:防止缓冲区溢出、检查指针有效性、避免未初始化访问

说实话,C语言里结构体和联合体用起来很爽,但坑也不少。我见过太多线上事故,最后定位下来就是结构体指针乱飞、缓冲区写穿、或者访问了没初始化的内存。今天咱们就聊聊怎么把这些坑填平。

缓冲区溢出:结构体里的隐形杀手

先说说缓冲区溢出。你想想看,结构体里如果有个字符数组,比如 char name[32],你往里写数据的时候,一旦超过32个字节,后面的内存就被污染了。我在项目中遇到过,一个同事用 strcpy 直接往结构体成员里拷数据,结果把相邻的成员给覆盖了,查了两天才找到原因。

⚠️ 危险代码示例:
struct User {
    char name[16];
    int  age;
};

void set_name(struct User *u, const char *input) {
    strcpy(u->name, input);  // 如果input超过15个字符,age就被覆盖了
}

正确的做法是什么?用 strncpy 或者 snprintf,明确指定最大长度。我个人习惯用 snprintf,因为它会自动加 '\0',不会像 strncpy 那样留下隐患。

✅ 安全写法:
void set_name(struct User *u, const char *input) {
    if (!u || !input) return;
    u->name[sizeof(u->name) - 1] = '\0';
    snprintf(u->name, sizeof(u->name), "%s", input);
}

这里有个细节:snprintf 的第二个参数是缓冲区总大小,它会自动截断并保证以 '\0' 结尾。嗯,这个习惯我保持了十几年,从来没出过问题。

指针有效性检查:别让空指针炸了你的程序

结构体指针的空值检查,说起来简单,做起来容易忘。我曾经在凌晨三点被电话叫醒,就是因为一个结构体指针传了 NULL,程序直接段错误崩溃了。

我的原则是:每个传入的结构体指针,在使用前都必须检查。别嫌麻烦,这是保命的。

💡 检查模式:
void process_user(struct User *u) {
    if (NULL == u) {
        // 记录日志,或者返回错误码
        return;
    }
    // 安全使用 u
}

你可能会问:检查了就能保证安全吗?不一定。指针有效不只是非空,还要确保它指向的内存是合法的。比如野指针,它非空但指向了已经释放的内存。这种情况怎么防?

我个人习惯的做法是:谁分配谁释放,释放后立即置 NULL。这样后续使用前检查 NULL 就能发现问题。

struct User *u = malloc(sizeof(struct User));
// ... 使用 u ...
free(u);
u = NULL;  // 释放后立即置空

避免未初始化访问:联合体尤其要小心

结构体成员如果没初始化,读出来的值是随机的。联合体更麻烦,因为所有成员共享同一块内存,你往一个成员里写了数据,从另一个成员读,结果完全不可控。

我记得有一次,一个同事用联合体做协议解析,往 uint32_t 成员里写了一个值,然后从 uint8_t[4] 数组里读字节。结果因为大小端问题,解析出来的数据全是反的。这就是典型的未初始化访问——不是没写数据,而是没搞清楚当前哪个成员是活跃的。

⚠️ 联合体常见错误:
union Data {
    uint32_t word;
    uint8_t  bytes[4];
};

union Data d;
d.word = 0x12345678;
// 直接读 d.bytes[0] 可能不是 0x12,取决于大小端

怎么避免?我的建议是:联合体一定要配合枚举或标志位使用,明确当前哪个成员有效。

✅ 安全模式:
struct Packet {
    enum { TYPE_INT, TYPE_FLOAT, TYPE_BYTES } type;
    union {
        int32_t ival;
        float   fval;
        uint8_t bval[4];
    } data;
};

void process_packet(struct Packet *p) {
    if (!p) return;
    switch (p->type) {
        case TYPE_INT:
            // 安全访问 p->data.ival
            break;
        case TYPE_FLOAT:
            // 安全访问 p->data.fval
            break;
        // ...
    }
}

结构体初始化也一样。我建议用 = {0} 或者 = {0} 来清零所有成员,别偷懒只初始化一部分。

struct User u = {0};  // 所有成员初始化为0
// 或者用 memset
struct User u;
memset(&u, 0, sizeof(u));

知识体系总览

下面这张图总结了结构体与联合体安全编程的核心要点,你可以把它当作一个检查清单。

结构体与联合体安全编程核心要点 缓冲区溢出防护 指针有效性检查 未初始化访问避免 具体措施 • 使用 snprintf 替代 strcpy • 明确缓冲区大小 • 检查写入长度 具体措施 • 每次使用前判 NULL • 释放后立即置 NULL • 避免野指针 具体措施 • 结构体用 {0} 初始化 • 联合体配合标志位 • 明确活跃成员 核心原则:防御性编程,不信任任何外部输入 养成习惯:分配 → 初始化 → 检查 → 使用 → 释放 → 置空

实战中的避坑指南

最后,我把自己踩过的坑总结成几条原则,你写代码的时候可以对照着检查:

  • 字符串操作永远用带长度限制的函数——snprintfstrncpystrncat,别用 strcpysprintf
  • 结构体指针传进来先判空——哪怕你觉得不可能为空,也要检查。这是习惯问题。
  • 联合体一定要有类型标识——别裸用联合体,配合枚举或者标志位,明确当前哪个成员有效。
  • 结构体分配后立即初始化——用 = {0} 或者 memset,别留死角。
  • 释放内存后立即置 NULL——这样后续误用能立刻崩溃,而不是产生诡异 bug。

我曾经在一个嵌入式项目里,因为结构体中的一个 char[8] 字段没做长度检查,导致堆栈被破坏,系统跑飞。从那以后,我写结构体相关的代码,都会多花两分钟做边界检查和初始化。这两分钟,能省下后面两天的调试时间。

💡 我的个人习惯:每次写完结构体操作函数,我都会问自己三个问题:指针检查了吗?缓冲区够大吗?所有成员都初始化了吗?三个问题都回答「是」,才提交代码。

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