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

一、先把“合法边界”画清楚
C 标准对指针算术的允许范围超级克制,归纳成四条硬规则(牢记!):
- 同一数组对象内的位移 若 T *p 指向数组 T arr[N] 的某个元素,则 p + k、p – 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] - 指针相减(差值) 只有当两个指针都指向同一数组对象的元素或其一过界位置时,p – q 才有定义,结果类型是 ptrdiff_t(有符号)。 ptrdiff_t d = &a[4] – &a[1]; // d == 3
- 比较运算(<, <=, >, >=) 只有同一数组对象内的指针比较才有定义(==/!= 允许跨对象比较是否相等)。跨对象做大小比较是 UB。
- 位移单位是“元素”而非字节 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; // 越界
}
begin 与 end 都源自同一数组(或同一次 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;
}
合法点:p 与 e 源自同一数组;p != e 比较有定义;++p 不越过 e;e 不解引用。
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。
- “整数化后位移再转回指针”:实现定义 + 破坏“指针来源”,优化器可能据此做出你想不到的变换。
- 一过界位置解引用:再强调一次,这在调试里很隐蔽,线上就是炸点。
七、工程级“越界防护”清单(实操为王)
- 接口设计:所有与缓冲区相关的函数,用 (指针,长度) 或 span 传参;禁止只传裸指针。
- 半开区间:统一采用 [begin, end) 遍历模式。
- 类型纪律:差值用 ptrdiff_t,计数/长度用 size_t,严禁混算导致的符号转换。
- 溢出守门:分配前做乘法溢出检查;复制/拼接前做加法溢出检查(__builtin_add_overflow)。
- 边界先行:在形成指针之前完成合法性判断;不要先算再补救。
- 工具链加持:调试构建启用 AddressSanitizer/UBSan;开启尽可能多的编译器警告(-Wall -Wextra -Wconversion 等)。
- 单元测试:对边界条件(空、单元素、正好满、差 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;
}
指针算术仍只在同一缓冲区内发生,并且所有跨尾首的处理都先以长度计算铺平,再做位移。
九、速查表:十条指针算术铁律
- 只在同一数组对象内做位移/比较/相减。
- 一过界位置只可比较/回退,不可解引用。
- p + k 的单位是 sizeof(*p),不是字节。
- void* 不做算术,字节位移请用 unsigned char*。
- 指针差值类型用 ptrdiff_t,长度用 size_t。
- 先判断范围,再形成指针;不要“先算后猜”。
- 分配/复制前做整型溢出检查。
- 禁止跨对象指针比较大小或相减。
- 不要把指针当整数做算术后再转回。
- 统一半开区间 [begin, end);把“长度”沿调用链传递。
结语
C 给了我们一把能直刺底层的刀。想要既快又稳,就得在标准允许的窄轨里奔跑:把边界装进接口,把检查放在位移之前,把风控交给类型与工具链。做到这些,指针算术不仅不会“越界”,反而会成为你写高性能、可维护 C 代码的底气。

收藏了,感谢分享