6、数组与指针安全:数组越界检查、指针运算边界控制、柔性数组成员、指针类型转换安全

数组和指针,是C语言里最锋利的两把刀。用好了,效率极高;用不好,割伤自己。我在嵌入式项目里见过太多因为数组越界或指针乱飞导致的诡异bug——系统跑着跑着就挂了,查几天都找不到原因。说白了,这类问题就是内存访问越了界,把不该踩的地方踩坏了。

这一章,我把自己多年踩坑和填坑的经验整理出来。咱们一个一个说清楚。

6.1 数组越界检查:别让下标飞出去

C语言不会帮你检查数组下标是否合法。你写 arr[100],它就去访问那个地址,不管那个地址是不是你的。这就是越界的根源。

⚠️ 核心原则: 数组下标永远要在 [0, size-1] 范围内。任何越界访问都是未定义行为,后果不可预测。

我在项目中遇到过这样一个案例:一个通信协议栈的接收缓冲区定义成 uint8_t buf[64],但某次收到一包65字节的数据,代码直接 buf[len] = data 写进去。结果呢?紧挨着buf后面的一个标志变量被覆盖了,整个状态机乱掉,系统死锁。查了两天才定位到问题。

怎么防?我建议养成三个习惯:

  • 每次访问数组前,先判断下标范围。 别嫌麻烦,这是最直接的防线。
  • 使用带边界检查的封装函数。 比如自己写一个 safe_array_get(),内部做越界判断。
  • 启用编译器边界检查选项。 比如GCC的 -fsanitize=address,调试阶段很有用。
✅ 推荐做法: 定义一个宏来做边界检查,既清晰又高效。
#define ARRAY_SIZE(arr) (sizeof(arr) / sizeof((arr)[0]))

#define ARRAY_AT(arr, idx) \
    ({ \
        typeof(arr) _arr = (arr); \
        size_t _idx = (idx); \
        if (_idx >= ARRAY_SIZE(_arr)) { \
            /* 记录错误日志,或者触发断言 */ \
            assert(!"Array index out of bounds"); \
            return 0; /* 或者返回错误码 */ \
        } \
        _arr[_idx]; \
    })

你想想看,这个宏在调试阶段能帮你抓住几乎所有越界访问。发布版本里可以关掉断言,但保留日志记录。

6.2 指针运算边界控制:别让指针跑偏

指针加减运算,本质上就是地址的移动。但移动多少、移动到哪,全凭你控制。一旦算错,后果跟数组越界一样严重。

我记得有一次调试一个内存池分配器,发现释放内存时总是崩溃。查到最后,原来是指针偏移算错了——ptr + sizeof(MyStruct) 写成了 ptr + sizeof(MyStruct*)。一个字节没对齐,整个链表就乱了。

💡 个人经验: 指针运算时,永远明确两个问题——当前指针指向什么类型?移动后指向的地址是否还在合法范围内?

我总结了几条指针运算的边界控制原则:

  1. 只对指向数组元素的指针做加减运算。 指向单个变量的指针,别乱加。
  2. 运算结果必须在数组范围内。 包括指向数组最后一个元素的下一个位置(one-past-the-end)是允许的,但不能解引用。
  3. 两个指针相减,必须指向同一个数组。 否则结果是未定义的。
  4. 不要对空指针或野指针做运算。 这是常识,但总有人犯。
// 安全的指针运算示例
int arr[10];
int *p = arr;
int *end = arr + 10;  // one-past-the-end,合法

while (p < end) {
    *p = 0;   // 安全,因为 p 始终在 [arr, arr+9] 范围内
    p++;
}

// 危险的做法
int *q = arr + 5;
int *r = arr + 20;  // 越界!未定义行为
int diff = r - q;   // 虽然能算出15,但 r 本身已经非法
⚠️ 特别注意: 指针比较时,只有指向同一个数组(或同一个对象+末尾下一个位置)的指针才能用 <、<=、>、>= 比较。其他情况只能用 == 和 !=。

6.3 柔性数组成员:结构体里的动态尾巴

柔性数组成员是C99引入的特性。它允许结构体的最后一个成员是一个未指定大小的数组。这样,你可以动态分配一块内存,结构体头部固定,尾部跟着一个可变长度的数组。

说实话,这个特性在嵌入式里特别实用。比如你要定义一个变长数据包,用柔性数组成员就比用指针优雅得多——内存连续,一次分配,释放也简单。

// 柔性数组成员定义
typedef struct {
    uint32_t length;
    uint8_t data[];   // 柔性数组成员,不占结构体大小
} Packet;

// 使用方式
size_t payload_len = 100;
Packet *pkt = malloc(sizeof(Packet) + payload_len);
if (pkt) {
    pkt->length = payload_len;
    memcpy(pkt->data, source_data, payload_len);
    // 使用...
    free(pkt);
}
✅ 优点: 内存连续、一次分配/释放、访问效率高、没有额外的指针开销。

我在项目中用柔性数组成员重构过一个旧的通信协议栈。原来是用 uint8_t *data 指针加单独分配的方式,结果内存碎片严重,还容易漏释放。改成柔性数组成员后,代码简洁了,内存管理也清晰了。

不过要注意几个坑:

  • 柔性数组成员必须是结构体的最后一个成员。
  • 不能有多个柔性数组成员。 一个结构体只能有一个。
  • sizeof 不包含柔性数组的大小。 分配时要手动加上。
  • 不能作为数组元素或结构体成员嵌套。 只能动态分配。

6.4 指针类型转换安全:别让类型系统形同虚设

C语言允许你随意把一种指针类型转换成另一种。但这不代表你应该这么做。类型转换的本质是告诉编译器:「我知道我在做什么,你别管。」——但很多时候,你其实不知道。

我见过最典型的错误,是把 uint32_t * 强制转换成 uint8_t *,然后按字节访问。这本身没问题,但如果你反过来——把 uint8_t * 强转成 uint32_t *,然后解引用——就可能触发对齐违例。在ARM Cortex-M上,这会导致硬件异常。

⚠️ 对齐规则: 访问一个N字节的数据,它的地址必须是N的倍数。比如访问 uint32_t,地址必须是4的倍数。

怎么保证安全?我建议遵循这几条:

  1. 尽量用联合体代替指针类型转换。 联合体在C99里是类型双关的合法方式。
  2. 如果必须用指针转换,先检查对齐。 可以用 ((uintptr_t)ptr % alignof(T)) == 0 来判断。
  3. 避免将 const 指针转换成非 const 指针。 这等于撕掉了只读保护。
  4. 函数指针转换要格外小心。 调用约定和参数类型不匹配会直接崩溃。
// 安全的类型双关:使用联合体
typedef union {
    uint32_t u32;
    uint8_t  u8[4];
} U32_to_U8;

U32_to_U8 val;
val.u32 = 0x12345678;
// 现在 val.u8[0], val.u8[1], ... 可以安全访问

// 不安全的做法:直接强转指针
uint32_t data = 0x12345678;
uint8_t *p = (uint8_t *)&data;  // 这没问题,因为 uint32_t 对齐更严格
uint32_t *q = (uint32_t *)p;    // 如果 p 不是4字节对齐,这里就危险了
💡 我的习惯: 在嵌入式代码里,我几乎不用指针类型转换来处理数据。需要解析协议时,我用 memcpy 或者联合体。虽然多写几行,但安全得多。

知识体系总览

下面这张图把本章的核心知识点串起来了。你可以看到,数组和指针的安全问题,最终都归结到边界控制类型安全这两个维度上。

数组与指针安全 数组越界检查 • 下标范围判断 • 封装安全访问函数 • 启用编译器检查选项 指针运算边界控制 • 只对数组元素运算 • 结果必须在范围内 • 同数组指针才能比较 柔性数组成员 动态尾巴,内存连续,一次分配 指针类型转换安全 用联合体代替强转,注意对齐 核心:边界控制 + 类型安全

嗯,这一章的内容就这些。数组和指针的安全问题,说白了就是管好你的内存边界,别让类型系统形同虚设。我在项目里吃过不少亏,希望你能从这些经验里少走弯路。