🔥 星光编译者 · 个人主页
📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》
🌄 向云端飞扬,编译属于自己的代码星河。
☕ 写在开篇
你好,这里是 星光编译者。
这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。
比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。
如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。

🔥 本文定位 :接续上篇的地址、解引用与
const,把指针放进数组遍历、字符串计数和函数修改实参的真实场景,同时建立边界与生存期意识。💡 学习目标 :掌握指针加减、相减与关系比较的合法范围;识别未初始化、越界和悬空指针;会正确使用
assert;能实现my_strlen和交换函数。📌 阅读说明 :根据课程 PDF《第11讲:深入理解指针(1)》第 5~8 节整理。本文用 C11 规则补充数组边界、空指针、
ptrdiff_t与参数传递的准确含义。
文章目录
- 一、指针加减整数:沿着数组移动
- 二、指针相减与关系比较
- 三、野指针:值存在,目标却不可合法访问
- 四、如何系统规避无效指针
- [五、assert 断言:把程序员假设写进代码](#五、assert 断言:把程序员假设写进代码)
- [六、模拟 strlen:用指针遍历字符串](#六、模拟 strlen:用指针遍历字符串)
- [七、传值与"传址":为什么第一个 Swap 失败](#七、传值与“传址”:为什么第一个 Swap 失败)
- 八、常见误区、高频问答与练习
- 总结
一、指针加减整数:沿着数组移动
数组元素在内存中连续排列。当 pointer 指向某个数组元素时,pointer + 1 指向下一个元素,pointer - 1 指向上一个元素。移动单位是"元素",底层字节跨度由所指类型决定。
c
#include <stddef.h>
#include <stdio.h>
int main(void)
{
int numbers[] = {1, 2, 3, 4, 5};
size_t length = sizeof(numbers) / sizeof(numbers[0]);
int *pointer = numbers;
for (size_t index = 0; index < length; ++index)
{
printf("%d ", *(pointer + index));
}
putchar('\n');
return 0;
}
numbers 在这个表达式语境中转换为指向首元素的指针,因此:
text
numbers + index
&numbers[index]
*(numbers + index)
numbers[index]
上下两组分别指向同一个元素、表示同一个元素。

1.1 "尾后指针"可以比较,不能解引用
语言规则允许得到"最后一个元素之后"的指针,它常用作循环终点:
c
int *current = numbers;
int *end = numbers + length;
while (current < end)
{
printf("%d ", *current);
++current;
}
end 可以作为边界值参与运算和比较,但 *end 越过了数组对象,不能解引用。再向后计算 end + 1 也超出了这套允许的边界。
二、指针相减与关系比较
2.1 指针相减得到元素间隔
当两个指针都指向同一个数组对象的元素,或其中一个是尾后指针时,可以相减。结果类型是 ptrdiff_t,表示两者之间相差多少个元素。
c
#include <stddef.h>
#include <stdio.h>
size_t my_strlen_by_difference(const char *text)
{
const char *end = text;
while (*end != '\0')
{
++end;
}
return (size_t)(end - text);
}
int main(void)
{
ptrdiff_t distance;
int data[10] = {0};
distance = &data[8] - &data[2];
printf("%td\n", distance); /* 6 */
return 0;
}

不能把两个无关对象的指针直接相减,然后将运行时恰好出现的数值解释成有意义距离。
2.2 关系比较也有边界
对同一数组内的元素指针,<、<=、>、>= 可以表示元素先后位置,因此上面的 current < end 是常见写法。对无关对象的指针做这类关系比较,不能当成可移植的 C 语言顺序。
三、野指针:值存在,目标却不可合法访问
"野指针"是教学中对一类无效指针的形象称呼:指针变量里看似存着某个值,但该值没有指向当前可合法访问的目标。解引用它会导致未定义行为。
3.1 成因一:未初始化
c
int *pointer;
*pointer = 20; /* 未定义行为 */
自动存储期的局部指针若没有初始化,其值不确定。"编译通过"不等于"可以访问"。
3.2 成因二:越过数组边界
c
int values[10] = {0};
int *pointer = values;
for (int index = 0; index <= 10; ++index)
{
*pointer++ = index; /* index == 10 时越界写入 */
}
长度为 10 的数组有效下标是 0~9。values + 10 可以形成,但不能被解引用。正确循环条件应是 index < 10。
3.3 成因三:所指对象生存期结束
c
int *bad_address(void)
{
int local = 100;
return &local;
}
local 的生存期在函数返回时结束。地址数值即使还被保存,也不再指向一个生存期有效的 int 对象。后续解引用这个指针是未定义行为。

重要理解 :无效指针不一定等于
NULL。悬空指针和未初始化指针往往是非空值,所以单独检查pointer != NULL不能证明它一定有效。
3.4 为什么越界或悬空后有时"看起来正常"
未定义行为不等于"程序必然当场崩溃"。某次运行时,越界位置恰好是可读写页面,或已失效的栈空间暂时还没有被覆盖,程序可能偶然打印出"对的"结果。换一个优化级别、增加一条日志、更换编译器或再运行一次,现象就可能改变。
所以,判断代码是否正确要依据语言规则和对象边界,不是依据"我试了一次没崩"。开发阶段可以结合编译器警告、调试器以及地址消毒器等工具尽早暴露问题,但工具不代替正确的边界设计。
四、如何系统规避无效指针
4.1 定义时就给出明确状态
c
int number = 10;
int *ready = &number;
int *not_ready = NULL;
NULL 是可用于指针的空指针常量宏。空指针表示"没有指向有效对象",不能解引用。不要把它简化成"所有平台上地址位图必然全是 0"。
4.2 把长度与指针一起传递
一个普通指针自身不记录目标数组的长度。如果函数要遍历数组,应同时接收元素数量,或接收明确的起止指针。
c
void print_array(const int *values, size_t length)
{
for (size_t index = 0; index < length; ++index)
{
printf("%d ", values[index]);
}
}
4.3 对生存期和所有权做出约定
指针由谁创建,所指对象何时失效,函数能否保存这个指针,这些问题要比"它是不是非空"更早解决。将不再使用的指针设为 NULL 可以帮助表达状态,但它不会自动修复其他仍指向同一无效对象的别名指针。

五、assert 断言:把程序员假设写进代码
<assert.h> 提供 assert 宏。它用来验证"按程序设计,这个条件本应为真"。
c
#include <assert.h>
int first_value(const int *values, size_t length)
{
assert(values != NULL);
assert(length > 0U);
return values[0];
}
条件非零时,程序继续;条件为零时,断言会输出失败表达式、文件与位置等诊断信息,然后异常终止程序。

5.1 NDEBUG 会禁用断言
如果在包含 <assert.h> 之前定义 NDEBUG,assert 会被禁用。构建系统也常通过编译选项在特定配置中定义该宏。
c
#define NDEBUG
#include <assert.h>
因此不要在 assert 表达式里放业务必需的副作用:
c
/* 不要这样写:禁用断言时 index++ 也消失 */
assert(index++ < length);
assert 适合查程序员错误与内部不变式,不应代替对用户输入、文件失败、网络错误或内存分配失败的正常错误处理。
5.2 什么时候用 assert,什么时候用 if
可以用一个简单问题区分:这个条件失败,是否属于程序正常需要处理的情况?
- 如果是用户可能输入错误数据、文件可能不存在、资源可能不足,应使用
if返回错误或执行恢复逻辑; - 如果调用者违反了内部函数的必备前置条件,或算法的不变式被破坏,
assert可以尽早暴露编程错误。
有些公共接口会先用 if 处理不可信输入,内部已验证的实现层再用 assert 检查程序员假设。两者职责不同,并不冲突。
六、模拟 strlen:用指针遍历字符串
strlen 统计字符串中终止空字符 '\0' 之前的字符数,返回类型是 size_t。参数写为 const char *,因为计数不需要修改字符串。
c
#include <assert.h>
#include <stddef.h>
size_t my_strlen(const char *text)
{
size_t length = 0U;
assert(text != NULL);
while (text[length] != '\0')
{
++length;
}
return length;
}
也可以让指针本身向后走:
c
size_t my_strlen_pointer(const char *text)
{
const char *start = text;
assert(text != NULL);
while (*text != '\0')
{
++text;
}
return (size_t)(text - start);
}

这段代码的前提不只是 text != NULL,还要求 text 指向一个最终可以遇到 '\0' 的合法字符序列。如果传入的字节区间没有终止符,函数会不断越界读取,行为未定义。
七、传值与"传址":为什么第一个 Swap 失败
先看一个不能修改调用者变量的版本:
c
void swap_values(int left, int right)
{
int temporary = left;
left = right;
right = temporary;
}
调用 swap_values(first, second) 时,形参 left 和 right 是独立对象,只接收实参的值。函数内交换的是两份副本,不会更改 first 与 second。
想让函数定位调用者的对象,就把两个地址作为参数值传入:
c
#include <assert.h>
#include <stdio.h>
void swap_in_place(int *left, int *right)
{
int temporary;
assert(left != NULL);
assert(right != NULL);
temporary = *left;
*left = *right;
*right = temporary;
}
int main(void)
{
int first = 10;
int second = 20;
swap_in_place(&first, &second);
printf("first=%d, second=%d\n", first, second);
return 0;
}

教学中常把第二种写法称为"传址调用",这个说法有助于区分用法,但要理解其语言本质:**C 的函数参数始终按值传递;这里被复制给形参的值,恰好是地址。**两个形参指针通过解引用,才能修改调用者的对象。
如果调用 swap_in_place(&first, &first),两个形参会指向同一个对象。对上面的交换算法而言,结果仍然是 first 保持原值;但在更复杂的接口中,多个指针参数是否允许指向重叠对象,应该成为明确的函数契约。
八、常见误区、高频问答与练习
8.1 常见误区
| 误区 | 正确理解 |
|---|---|
| 指针只要能加减就可以在内存中任意走 | 指针算术受同一数组对象及尾后位置的边界约束 |
尾后指针是合法指针,所以可以 *end |
可以形成和比较,不能解引用 |
| 非空指针就一定有效 | 它可能未初始化、越界或已悬空 |
把指针设为 NULL 就修复了所有别名 |
只改了这一个指针变量 |
assert 可以代替所有错误处理 |
它主要检查程序员假设,并可被 NDEBUG 禁用 |
| C 同时支持传值和引用传递 | C 只按值传参;"传址"是把地址作为值传入 |
8.2 高频问答
Q1:为什么 pointer + 1 不是固定加 1 字节?
因为指针算术用来定位同类型数组元素。对 T *pointer,加 1 表示前进一个 T 元素。
Q2:为什么指针差用 %td 输出?
因为两个指针相减的结果类型是 ptrdiff_t,printf 中与它对应的有符号十进制转换是 %td。
Q3:指针设为 NULL 后是不是就不会出错?
不是。它将一种难识别的无效状态变成易于检查的空状态,但解引用空指针同样是未定义行为,而且其他别名指针不会自动更新。
Q4:为什么 strlen 不把结尾 '\0' 计入长度?
因为终止空字符是 C 字符串的边界标记,strlen 定义的就是该标记之前的字符数。
8.3 动手练习
- 用指针起止区间的方式求一个
int数组的和。 - 跟踪
int values[4] = {10, 20, 30, 40};中values + 4,说明它能做什么、不能做什么。 - 改正
for (i = 0; i <= length; ++i) values[i] = 0;。 - 写一个
set_zero(int *value),通过指针将调用者的整数设为0。 - 解释为什么不应写
assert(read_next() != EOF);,如果read_next()还承担了必须执行的读取操作。
总结
指针真正难的地方不是 * 和 & 两个符号,而是始终同时跟踪类型、边界与生存期。指针加减要沿同一数组移动,指针相减得到元素间隔,尾后指针可以当终点却不能解引用。面对无效指针,要从初始化、边界、生存期和接口契约上预防,而不是等崩溃后再猜。
assert 能把内部假设显式化,my_strlen 展示了指针遍历,swap_in_place 则说明了函数为何能通过地址修改调用者对象。但不要忘记:C 仍然是按值传参,只是这次被复制的值是一个指针。