【C 语言】深入理解指针(1·下):指针运算、野指针、assert 与传址实战

🔥 星光编译者 · 个人主页

📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》

🌄 向云端飞扬,编译属于自己的代码星河。


☕ 写在开篇

  你好,这里是 星光编译者。

  这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。

  比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。

  如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。


🔥 本文定位 :接续上篇的地址、解引用与 const,把指针放进数组遍历、字符串计数和函数修改实参的真实场景,同时建立边界与生存期意识。

💡 学习目标 :掌握指针加减、相减与关系比较的合法范围;识别未初始化、越界和悬空指针;会正确使用 assert;能实现 my_strlen 和交换函数。

📌 阅读说明 :根据课程 PDF《第11讲:深入理解指针(1)》第 5~8 节整理。本文用 C11 规则补充数组边界、空指针、ptrdiff_t 与参数传递的准确含义。


文章目录


一、指针加减整数:沿着数组移动

数组元素在内存中连续排列。当 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 动手练习

  1. 用指针起止区间的方式求一个 int 数组的和。
  2. 跟踪 int values[4] = {10, 20, 30, 40}; 中 values + 4,说明它能做什么、不能做什么。
  3. 改正 for (i = 0; i <= length; ++i) values[i] = 0;。
  4. 写一个 set_zero(int *value),通过指针将调用者的整数设为 0。
  5. 解释为什么不应写 assert(read_next() != EOF);,如果 read_next() 还承担了必须执行的读取操作。

总结

指针真正难的地方不是 * 和 & 两个符号,而是始终同时跟踪类型、边界与生存期。指针加减要沿同一数组移动,指针相减得到元素间隔,尾后指针可以当终点却不能解引用。面对无效指针,要从初始化、边界、生存期和接口契约上预防,而不是等崩溃后再猜。

assert 能把内部假设显式化,my_strlen 展示了指针遍历,swap_in_place 则说明了函数为何能通过地址修改调用者对象。但不要忘记:C 仍然是按值传参,只是这次被复制的值是一个指针。

相关推荐
实心儿儿1 小时前
Qt — Qt 事件
开发语言·qt
周杰偷奶茶2 小时前
【Java】数据类型与变量
java·开发语言
code斗2 小时前
Java数据结构:堆详解
java·开发语言·数据结构
重生之小比特2 小时前
【C++进阶】map和set
java·开发语言·c++
lie..2 小时前
30天从零开始学AI应用开发(Day 12):像产品一样稳:流式输出、报错重试、花钱控制
开发语言·人工智能·php
傻啦嘿哟2 小时前
Python的默认参数把我坑惨了,原来写[]和写None的区别这么大
开发语言·python·机器学习
需要8262 小时前
分布式事务:Seata AT/TCC/SAGA 的取舍与踩坑
java·spring boot·分布式·spring·spring cloud
晨小安3 小时前
ThreadLocal及其内存泄漏问题深度解析
java·jvm·tomcat·哈希表
阿钱真强道3 小时前
20 嵌入式操作系统 | ubus:把自己的程序状态暴露出去
c语言·进程通信·ubus·ubusd·blobmsg