【C语言·027】指针算术运算的合法操作与越界检测

内容分享2天前发布
0 1 0

在 C 里,“指针 + 算术”是把刀磨得最锋利的一刻:用得好,行云流水;用不好,轻则错数据,重则 UB(Undefined Behavior,未定义行为)当场引爆。本文不谈花哨技巧,只把两件事讲透:什么算术是“标准允许的合法操作”,以及如何在工程里把“越界”掐死在发生前


【C语言·027】指针算术运算的合法操作与越界检测

一、先把“合法边界”画清楚

C 标准对指针算术的允许范围超级克制,归纳成四条硬规则(牢记!):

  1. 同一数组对象内的位移T *p 指向数组 T arr[N] 的某个元素,则 p + kp – k 合法的充要条件是:结果指向 arr[0]…arr[N] 的任意一个位置。注意末端是“N 的那个一过界位置(one-past-end)”,它只可参与比较/再位移回合法区间,不可解引用 int a[5];
    int *p = &a[0];
    int *q = p + 5; // 合法:一过界
    // *q; // 非法:一过界位置不可解引用
    int *r = q – 5; // 合法:回到 a[0]
  2. 指针相减(差值) 只有当两个指针都指向同一数组对象的元素或其一过界位置时,p – q 才有定义,结果类型是 ptrdiff_t(有符号)。 ptrdiff_t d = &a[4] – &a[1]; // d == 3
  3. 比较运算(<, <=, >, >= 只有同一数组对象内的指针比较才有定义(==/!= 允许跨对象比较是否相等)。跨对象做大小比较是 UB。
  4. 位移单位是“元素”而非字节 p + k 实际移动的地址是 k * sizeof(*p) 个字节。对 void* 进行算术在 ISO C 中是不合法(GCC 把它当字节指针的扩展,不可移植)。

总结:**“同一数组对象 + 一过界位置 + 不解引用”**是三大关键词。


二、最易踩的坑清单

  • 跨对象指针相减/比较:两个不同数组或两个独立 malloc 块的指针对比,UB。
  • 一过界位置解引用p == arr + N 合法,但 *p 非法。
  • void* 算术(非标准):可移植代码里不要做。
  • 索引与尺寸溢出i * sizeof(T) 在 32 位或大 i 时可能溢出,导致分配过小,后续写入越界(安全漏洞高发点)。
  • 把指针当整数做算术:把指针转成整数加减后再转回,属于实现定义/可能触发“指针来源(provenance)”优化假设,别这么做。

三、把“越界检测”放在算术之前

标准 C 不提供“查询这个指针是否越界”的内建 API,因此正确的方法是:在生成指针以前,通过“长度 + 下标/步长”做边界验证。写库代码时,把“起点 + 长度”的信息组合包装传递,是最稳的工程做法。

3.1 半开区间:用 [begin, end) 管控游标

typedef struct {
    unsigned char *begin;
    unsigned char *end;   // 指向一过界位置
} span_u8;

static inline int span_empty(span_u8 s) { return s.begin == s.end; }

static inline unsigned char* span_at(span_u8 s, size_t i) {
    // 在产生指针前检查
    if (s.begin + i < s.end) return s.begin + i;
    return NULL; // 越界
}

beginend 都源自同一数组(或同一次 malloc),因此比较、相减完全有定义。注意:s.begin + i < s.end 这类比较是合法的,由于仍在同一个数组对象范围内。

3.2 统一“索引合法性”守则

#define INDEX_IN_RANGE(idx, len) ((idx) < (len))

int get(const int *arr, size_t len, size_t i, int *out) {
    if (!INDEX_IN_RANGE(i, len)) return -1;
    *out = arr[i];
    return 0;
}

先判断,再形成指针/解引用,这是避免 UB 的唯一正确姿势。

3.3 指针差值做边界(仅限同对象)

int within(const int *p, const int *base, size_t len) {
    // base 与 p 必须来自同一数组对象
    ptrdiff_t d = p - base; // 有定义
    return d >= 0 && (size_t)d < len;
}

四、“安全算术”的几个实用模式

4.1 用 ptrdiff_t 承接差值,别混 unsigned

ptrdiff_t n = end - begin; // 有符号差值
for (ptrdiff_t i = 0; i < n; ++i) { /* ... */ }

常常见到的 bug 是把 end – begin 强转到 size_t,负值被当成极大正数,循环直接越界狂奔。

4.2 分配前做“乘法溢出检查”

void* xcalloc(size_t n, size_t elem) {
    size_t bytes;
    if (__builtin_mul_overflow(n, elem, &bytes)) return NULL; // 或自定义错误路径
    return malloc(bytes);
}

许多内存安全漏洞都源于 n * sizeof(T) 溢出后分配过小。

4.3 以“指针对 + 长度”取代裸指针

别把 T* 单独往下传;传 T* data, size_t len 或封装 span<T>(见上)。这会迫使调用链在每次位移之前进行检查。

4.4 “一过界哨兵”写法更自然

遍历用半开区间天然不越界:

for (int *p = a, *e = a + N; p != e; ++p) {
    // p 永远不等于 e 时解引用
}

五、哪些“指针算术”是合法且高效的?

5.1 遍历数组/切片

size_t count_zero(const unsigned char *b, size_t n) {
    const unsigned char *p = b, *e = b + n; // e 是一过界
    size_t c = 0;
    for (; p != e; ++p)
        c += (*p == 0);
    return c;
}

合法点:pe 源自同一数组;p != e 比较有定义;++p 不越过 ee 不解引用。

5.2 两指针相减求“元素距离”

ptrdiff_t distance(const int *first, const int *last) {
    // 仅当二者来自同一数组/块时调用
    return last - first; // 单位:元素数
}

5.3 结构体数组的步进

typedef struct { int id; double score; } Node;
for (Node *p = nodes, *e = nodes + n; p != e; ++p) {
    // p+1 实际移动 sizeof(Node) 字节
}

5.4 与 memcpy/memmove 的配合(以字节为单位)

unsigned char *dst = buf;
const unsigned char *src = buf + 16;
size_t len = 32;
// 先检查:src >= buf && src + len <= buf + cap
memmove(dst, src, len);

把缓冲区统一视为 unsigned char* 再做字节级移动最清晰。检查在前,移动在后。


六、哪些“不合法/不提议”的姿势要避开?

  • (void*) 上做 +/-:非标准,可移植性差。用 unsigned char* 做字节位移。
  • 不同 malloc 块之间的指针相减/比较大小:UB。
  • “整数化后位移再转回指针”:实现定义 + 破坏“指针来源”,优化器可能据此做出你想不到的变换。
  • 一过界位置解引用:再强调一次,这在调试里很隐蔽,线上就是炸点。

七、工程级“越界防护”清单(实操为王)

  1. 接口设计:所有与缓冲区相关的函数,用 (指针,长度)span 传参;禁止只传裸指针。
  2. 半开区间:统一采用 [begin, end) 遍历模式。
  3. 类型纪律:差值用 ptrdiff_t,计数/长度用 size_t,严禁混算导致的符号转换。
  4. 溢出守门:分配前做乘法溢出检查;复制/拼接前做加法溢出检查(__builtin_add_overflow)。
  5. 边界先行:在形成指针之前完成合法性判断;不要先算再补救。
  6. 工具链加持:调试构建启用 AddressSanitizer/UBSan;开启尽可能多的编译器警告(-Wall -Wextra -Wconversion 等)。
  7. 单元测试:对边界条件(空、单元素、正好满、差 1、极大值)做系统化测试。

八、两个高价值范例

8.1 安全切片:从字节流中取固定长度“视图”

typedef struct { const unsigned char *ptr; size_t len; } slice_u8;

int slice_sub(slice_u8 s, size_t off, size_t n, slice_u8 *out) {
    size_t end;
    if (__builtin_add_overflow(off, n, &end)) return -1;        // off+n 溢出
    if (end > s.len) return -1;                                 // 越界
    out->ptr = s.ptr + off;                                     // 目前安全
    out->len = n;
    return 0;
}

关键:越界检查发生在位移之前;ptr + off 只在已知 off <= len 条件下进行。

8.2 环形缓冲区的合法步进

typedef struct {
    unsigned char *buf;
    size_t cap, head, tail; // [head, tail) 半开区间(按模 cap)
} ring_t;

size_t ring_write(ring_t *r, const unsigned char *data, size_t n) {
    size_t space = (r->head + r->cap - r->tail) % r->cap;
    if (n > space) n = space; // 截断写入
    size_t first = (r->cap - r->tail % r->cap);
    size_t chunk = n < first ? n : first;
    memcpy(r->buf + (r->tail % r->cap), data, chunk);
    memcpy(r->buf, data + chunk, n - chunk);
    r->tail = (r->tail + n) % r->cap;
    return n;
}

指针算术仍只在同一缓冲区内发生,并且所有跨尾首的处理都先以长度计算铺平,再做位移。


九、速查表:十条指针算术铁律

  1. 只在同一数组对象内做位移/比较/相减。
  2. 一过界位置只可比较/回退,不可解引用
  3. p + k 的单位是 sizeof(*p),不是字节。
  4. void* 不做算术,字节位移请用 unsigned char*
  5. 指针差值类型用 ptrdiff_t,长度用 size_t
  6. 先判断范围,再形成指针;不要“先算后猜”。
  7. 分配/复制前做整型溢出检查。
  8. 禁止跨对象指针比较大小或相减。
  9. 不要把指针当整数做算术后再转回。
  10. 统一半开区间 [begin, end);把“长度”沿调用链传递。

结语

C 给了我们一把能直刺底层的刀。想要既快又稳,就得在标准允许的窄轨里奔跑:把边界装进接口,把检查放在位移之前,把风控交给类型与工具链。做到这些,指针算术不仅不会“越界”,反而会成为你写高性能、可维护 C 代码的底气。

© 版权声明

相关文章

1 条评论

  • 头像
    兵者集团 读者

    收藏了,感谢分享

    无记录
    回复