C foundations / Lesson 09

找到数据的位置,
再安全地访问它。

指针让函数和数组共享对对象的访问。本课从一个已经熟悉的整数开始,逐步解释取地址、解引用、空指针、作用范围和数组边界。

09 / 指针与内存地址建议先完成数组和函数先画图,再运行
01 / 目标

先建立三件事之间的关系

第 06 课知道函数参数按值传递;第 07 课把数组和长度交给函数;第 08 课在 GDB 中查看过数组。本课将它们连接起来。完成后,你应当能做到:

  • 区分变量保存的值、对象的地址、保存地址的指针变量。
  • 解释 & 如何取地址、* 如何访问有效指针所指对象。
  • 追踪函数如何通过地址修改调用者对象,并知道参数本身仍按值传递。
  • 识别空指针、失效对象、数组越界和 const 的限制。
本课产出

运行 lesson09.c 并画出 sample、p 与数组的关系;无需复制或记住程序运行时显示的任何地址。

02 / 概念

一个对象、它的值和它的位置

先看 int sample = 12;。sample 是一个 int 对象的名字,表达式 sample 可以读取它当前保存的整数。表达式 &sample 得到指向该对象的地址;把地址保存在另一变量 p 中,就能通过 p 找到原对象。

int sample = 12;
int *p = &sample;

sample 对象:值 12     ←───── p 保存的指针指向这里
                               p 本身也是一个独立变量

这是理解关系的示意图,不代表真实的内存排列。sample 的值是 12;p 的值是指向 sample 的指针;*p 访问所指的 sample,所以在这个时刻读到 12。

先预测:若通过 *p 把值改为 15,再直接读取 sample,会看到多少?先画关系图,再看主例程。
03 / 实验

运行一个有确定输出的指针程序

新建 lesson09.c,先预测输出,再输入并编译。为避免把地址的具体表示误当成固定值,本程序只打印对象中的数据。

lesson09.c完整程序 · C11
#include <stdio.h>
#include <stddef.h>

void add_offset(int *value, int delta)
{
    if (value != NULL) {
        *value = *value + delta;
    }
}

int main(void)
{
    int sample = 12;
    int *p = &sample;
    int readings[] = {12, 15, 18};
    size_t count = sizeof readings / sizeof readings[0];
    int *empty = NULL;

    printf("before=%d\n", *p);
    add_offset(p, 3);
    printf("after=%d sample=%d\n", *p, sample);
    printf("first=%d second=%d count=%zu\n",
           *readings, *(readings + 1), count);

    if (empty == NULL) {
        puts("no reading");
    }
    return 0;
}
Windows · MSYS2 UCRT64

编译并运行

gcc -std=c11 -Wall -Wextra lesson09.c -o lesson09.exe
./lesson09.exe
Ubuntu

编译并运行

gcc -std=c11 -Wall -Wextra lesson09.c -o lesson09
./lesson09

预期输出:

before=12
after=15 sample=15
first=12 second=15 count=3
no reading

add_offset 的算术部分依赖调用者保证结果在 int 范围内;此例的 12 + 3 可表示。函数中的空指针检查只排除空指针,非空指针还必须指向有效、可写的 int 对象。

04 / 追踪

逐步解释为什么两个 15 指向同一对象

  1. 01
    创建对象

    sample 初始化为 12;p 初始化为 &sample,于是指向 sample。

  2. 02
    读取对象

    表达式 *p 访问 p 所指对象,第一行输出 12。

  3. 03
    调用函数

    add_offset(p, 3) 把指针值传给形参 value,它和 p 都指向同一个 sample;函数用 *value 写回 15。

  4. 04
    回到 main

    直接读 sample,或通过 *p 读,访问的都是同一个对象,因此均为 15。

  5. 05
    读取数组

    *readings 是第一项 12,*(readings + 1) 是第二项 15;数组有 3 项。

  6. 06
    检查空指针

    empty 被初始化为 NULL;条件成立后输出 no reading,没有对它解引用。

05 / 语法

& 与 * 各做什么?

写法怎么读本例的含义
int sample = 12;定义一个 int 对象对象里的值是 12
&sample取 sample 的地址得到一个指向 sample 的 int * 值
int *p = &sample;声明并初始化指针 pp 保存指向 int 对象的指针值
*p解引用 p访问 p 所指的 int 对象
&p取指针变量 p 自己的地址类型为 int **,与 p 的值不是同一个概念

声明中的 * 是类型声明的一部分;表达式中的 *p 是解引用。解引用前必须确保指针指向仍在生命周期内、类型和对齐要求符合访问的对象。int *p, q; 只把 p 声明成指针,q 是 int;初学时建议每行只声明一个变量。

若需要观察本机地址,可在 p 有效时使用 printf("%p\n", (void *)p);。%p 的实参要求 void *,因此显式转换。输出格式和值由实现和运行环境决定;地址不是教材中的固定数字,也不是可以照抄给 STM32 寄存器使用的地址。

06 / 函数

指针参数怎样让函数修改原对象?

在 add_offset(p, 3) 中,p 的指针值被复制给形参 value。这仍然是按值传递:函数没有拿到调用者 p 变量本身,而是得到一个同样指向 sample 的指针值。

/* 已定义 int sample = 12; */
add_offset(&sample, 3);
/* 若满足函数约定,sample 现在为 15。 */

*value = *value + delta; 的右侧先通过 value 读取所指对象,计算后把结果写到同一个对象。若函数只写 value = NULL;,它改变的是自己的指针形参,不会让调用者的 p 自动变成 NULL。

契约很重要:若传入非空指针,调用者必须提供有效、可写且类型匹配的对象,并确保结果不溢出。不能把“传了一个地址”当成对任何内存都可读写的许可。

07 / 空指针

NULL 表示暂时不指向对象

int *empty = NULL; 将指针初始化为空指针。NULL 可从 <stddef.h> 获得。比较 empty == NULL 可以判断它是否为空;不要对空指针解引用。

int *empty = NULL;
if (empty != NULL) {
    /* 只有在另行确认有效且可读后才访问 *empty。 */
}

非空不等于有效:指针可能指向已经结束生命周期的对象、越界位置,或者不适合当前访问的对象。未初始化的局部指针也没有可供安全使用的已知值,先赋予有效对象地址或 NULL,再检查和使用。

不要依赖崩溃定位:解引用空指针或其他无效指针会产生未定义行为;程序可能崩溃,也可能表现得不同。不要通过实际运行错误例子判断是否安全。
08 / 生命周期

对象不再存在,旧地址不能继续访问

在函数或普通块中声明的自动存储期局部变量,只在其对应的执行期间存在。例如某个函数创建局部 int local,函数返回后该对象的生命周期结束;不能返回它的地址,再让调用者通过该地址读取或修改。

正确的替代方式取决于任务:若只要一个整数,让函数直接返回整数;若要修改调用者的对象,由调用者创建对象并传入有效地址。动态分配的对象则需明确所有权,释放后不能再通过原指针访问;动态内存的具体用法留给后续专题。

数组也有生命周期。函数接收到的数组首元素地址并不拥有数组;函数调用期间和之后,是否仍能访问,要看原数组对象是否仍然存在。

09 / 数组

数组、首元素与长度必须一起看

主例程的 readings 是 3 元素数组。数组在大多数表达式中会转换为指向首元素的指针,因此 *readings 与 readings[0] 在这个例子中访问同一元素;*(readings + 1) 与 readings[1] 访问同一元素。

readings + 1 向后移动一个 int 元素的位置,而不是固定增加一个字节。允许形成指向同一数组最后一个元素之后一个位置的指针,用作边界比较,但不能解引用那个位置;越过允许范围的指针运算也不应进行。遍历时保留明确的元素数。

int readings[] = {12, 15, 18};
size_t count = sizeof readings / sizeof readings[0];
for (size_t i = 0; i < count; i++) {
    printf("%d\n", readings[i]);
}

这里 sizeof readings 在实际数组所在作用域求整个数组大小;函数的数组形式参数会调整为指针,不能在函数内照此算出原数组长度。传入指针与长度时,调用者必须保证该长度对应真实可访问元素数。指针类型和数组类型相关,但两者不是同一种类型。

10 / 只读约束

const 限制的是哪一部分?

int sample = 12;
int other = 20;
const int *view = &sample; /* 不能通过 view 修改所指 int */
int *const fixed = &sample; /* fixed 指向的位置不能重新赋值 */

view = &other;  /* 可以:view 本身不是 const */
*fixed = 15;    /* 可以:sample 本身可修改 */

对 const int *view,不能写 *view = 15;;这并不自动使原来的 sample 本身变成常量,若它不是 const,仍可通过其他合法方式修改。对 int *const fixed,不能写 fixed = &other;,但可通过它修改有效且可写的对象。

若对象本身被定义为 const,不要通过强制转换移除限制后写入它,这会导致未定义行为。把只读输入声明为 const int * 能使函数的意图更清楚,但不能替代长度与生命周期检查。

11 / 嵌入式

从主机程序走向外设,需要哪些新知识?

在电脑上,指针指向进程可以使用的对象;在 STM32 中,软件也会接触由芯片文档规定的内存映射外设。具体地址、寄存器宽度、访问权限、时钟与读写顺序都要看目标芯片手册和对应的设备头文件。

外设寄存器常涉及 volatile,它影响编译器如何处理对该对象的访问;它本身不提供线程或中断同步,也不自动保证某个地址可访问。将整数转换成指针的行为依赖实现,不能把本机打印的地址搬到开发板,也不能只凭一个指针就随意访问硬件。

本课边界

先把普通对象、数组和指针的关系解释清楚;外设寄存器和同步问题留到具体 STM32 芯片与工具链的课程中验证。

12 / 排错

出错时从对象和指针两边查

现象检查下一步
原变量没有变化函数是否只给自己的指针参数重新赋值?区分 value = ... 与 *value = ...,确认调用者传入有效可写对象。
地址看起来与教程不同是否把某次运行的地址当成固定结果?只验证指向关系和值;不依赖地址数字。
编译器提示指针类型不匹配对象类型是否与形参所需指针类型一致?核对声明和函数原型,先理解诊断,不盲目强制转换。
数组读取结果不可靠长度是否正确,指针与对象是否仍有效?检查调用约定、边界与生命周期;不要通过访问越界进行试验。
13 / 练习

先画箭头,再对照答案

01 / 解引用

变量被改成多少?

已执行 int x = 7; int *p = &x; *p = 10;。现在读 x 与 *p 分别是多少?

查看参考答案

都是 10。p 指向 x,*p = 10 修改了 x 本身。

02 / 按值传递

修改形参还是修改对象?

一个函数收到指向 x 的指针形参 p。比较在函数中执行 p = NULL; 与对有效可写对象执行 *p = 10;。

查看参考答案

前者只改变函数自己的指针形参;后者通过指针修改 x。前后两种操作是分别讨论,不能在把 p 设为 NULL 后继续解引用它。

03 / 数组边界

第三项和尾后位置

int a[] = {4, 6, 8}; 中,*(a + 2) 是多少?a + 3 能否解引用?

查看参考答案

第一问为 8。a + 3 指向数组尾后一个位置,可用于合法的边界比较,但不能解引用。

04 / 生命周期

函数返回后还可用吗?

函数把一个自动局部整数的地址返回给调用者;函数返回后能否通过这个地址读取该整数?

查看参考答案

不能。局部对象生命周期已结束。可直接返回整数值,或由调用者准备对象并传入地址。

05 / const

限制哪一部分?

const int *view = &x; 中,*view = 3; 和 view = &y; 哪个违反声明?假设 x 和 y 都是有效的 int 对象。

查看参考答案

通过 view 给 *view 赋值违反只读约束;给指针变量 view 重新赋一个兼容的地址是允许的。

完成标准

能说清指向谁、何时有效

  1. 不用看答案画出 sample 与 p 的关系,并追踪函数修改前后的值。
  2. 能区分指针本身被重新赋值和通过指针修改对象。
  3. 能说明非空检查不代表对象一定有效,函数返回后不能访问已结束的局部对象。
  4. 能结合数组长度解释下标和尾后位置,并读懂两种 const 声明。