编译并运行
gcc -std=c11 -Wall -Wextra lesson09.c -o lesson09.exe
./lesson09.exeC foundations / Lesson 09
指针让函数和数组共享对对象的访问。本课从一个已经熟悉的整数开始,逐步解释取地址、解引用、空指针、作用范围和数组边界。
第 06 课知道函数参数按值传递;第 07 课把数组和长度交给函数;第 08 课在 GDB 中查看过数组。本课将它们连接起来。完成后,你应当能做到:
& 如何取地址、* 如何访问有效指针所指对象。const 的限制。运行 lesson09.c 并画出 sample、p 与数组的关系;无需复制或记住程序运行时显示的任何地址。
先看 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,会看到多少?先画关系图,再看主例程。新建 lesson09.c,先预测输出,再输入并编译。为避免把地址的具体表示误当成固定值,本程序只打印对象中的数据。
#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;
}gcc -std=c11 -Wall -Wextra lesson09.c -o lesson09.exe
./lesson09.exegcc -std=c11 -Wall -Wextra lesson09.c -o lesson09
./lesson09预期输出:
before=12
after=15 sample=15
first=12 second=15 count=3
no readingadd_offset 的算术部分依赖调用者保证结果在 int 范围内;此例的 12 + 3 可表示。函数中的空指针检查只排除空指针,非空指针还必须指向有效、可写的 int 对象。
sample 初始化为 12;p 初始化为 &sample,于是指向 sample。
表达式 *p 访问 p 所指对象,第一行输出 12。
add_offset(p, 3) 把指针值传给形参 value,它和 p 都指向同一个 sample;函数用 *value 写回 15。
直接读 sample,或通过 *p 读,访问的都是同一个对象,因此均为 15。
*readings 是第一项 12,*(readings + 1) 是第二项 15;数组有 3 项。
empty 被初始化为 NULL;条件成立后输出 no reading,没有对它解引用。
& 与 * 各做什么?| 写法 | 怎么读 | 本例的含义 |
|---|---|---|
int sample = 12; | 定义一个 int 对象 | 对象里的值是 12 |
&sample | 取 sample 的地址 | 得到一个指向 sample 的 int * 值 |
int *p = &sample; | 声明并初始化指针 p | p 保存指向 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 寄存器使用的地址。
在 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。
契约很重要:若传入非空指针,调用者必须提供有效、可写且类型匹配的对象,并确保结果不溢出。不能把“传了一个地址”当成对任何内存都可读写的许可。
int *empty = NULL; 将指针初始化为空指针。NULL 可从 <stddef.h> 获得。比较 empty == NULL 可以判断它是否为空;不要对空指针解引用。
int *empty = NULL;
if (empty != NULL) {
/* 只有在另行确认有效且可读后才访问 *empty。 */
}非空不等于有效:指针可能指向已经结束生命周期的对象、越界位置,或者不适合当前访问的对象。未初始化的局部指针也没有可供安全使用的已知值,先赋予有效对象地址或 NULL,再检查和使用。
在函数或普通块中声明的自动存储期局部变量,只在其对应的执行期间存在。例如某个函数创建局部 int local,函数返回后该对象的生命周期结束;不能返回它的地址,再让调用者通过该地址读取或修改。
正确的替代方式取决于任务:若只要一个整数,让函数直接返回整数;若要修改调用者的对象,由调用者创建对象并传入有效地址。动态分配的对象则需明确所有权,释放后不能再通过原指针访问;动态内存的具体用法留给后续专题。
数组也有生命周期。函数接收到的数组首元素地址并不拥有数组;函数调用期间和之后,是否仍能访问,要看原数组对象是否仍然存在。
主例程的 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 在实际数组所在作用域求整个数组大小;函数的数组形式参数会调整为指针,不能在函数内照此算出原数组长度。传入指针与长度时,调用者必须保证该长度对应真实可访问元素数。指针类型和数组类型相关,但两者不是同一种类型。
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 * 能使函数的意图更清楚,但不能替代长度与生命周期检查。
在电脑上,指针指向进程可以使用的对象;在 STM32 中,软件也会接触由芯片文档规定的内存映射外设。具体地址、寄存器宽度、访问权限、时钟与读写顺序都要看目标芯片手册和对应的设备头文件。
外设寄存器常涉及 volatile,它影响编译器如何处理对该对象的访问;它本身不提供线程或中断同步,也不自动保证某个地址可访问。将整数转换成指针的行为依赖实现,不能把本机打印的地址搬到开发板,也不能只凭一个指针就随意访问硬件。
先把普通对象、数组和指针的关系解释清楚;外设寄存器和同步问题留到具体 STM32 芯片与工具链的课程中验证。
| 现象 | 检查 | 下一步 |
|---|---|---|
| 原变量没有变化 | 函数是否只给自己的指针参数重新赋值? | 区分 value = ... 与 *value = ...,确认调用者传入有效可写对象。 |
| 地址看起来与教程不同 | 是否把某次运行的地址当成固定结果? | 只验证指向关系和值;不依赖地址数字。 |
| 编译器提示指针类型不匹配 | 对象类型是否与形参所需指针类型一致? | 核对声明和函数原型,先理解诊断,不盲目强制转换。 |
| 数组读取结果不可靠 | 长度是否正确,指针与对象是否仍有效? | 检查调用约定、边界与生命周期;不要通过访问越界进行试验。 |
已执行 int x = 7; int *p = &x; *p = 10;。现在读 x 与 *p 分别是多少?
都是 10。p 指向 x,*p = 10 修改了 x 本身。
一个函数收到指向 x 的指针形参 p。比较在函数中执行 p = NULL; 与对有效可写对象执行 *p = 10;。
前者只改变函数自己的指针形参;后者通过指针修改 x。前后两种操作是分别讨论,不能在把 p 设为 NULL 后继续解引用它。
int a[] = {4, 6, 8}; 中,*(a + 2) 是多少?a + 3 能否解引用?
第一问为 8。a + 3 指向数组尾后一个位置,可用于合法的边界比较,但不能解引用。
函数把一个自动局部整数的地址返回给调用者;函数返回后能否通过这个地址读取该整数?
不能。局部对象生命周期已结束。可直接返回整数值,或由调用者准备对象并传入地址。
const int *view = &x; 中,*view = 3; 和 view = &y; 哪个违反声明?假设 x 和 y 都是有效的 int 对象。
通过 view 给 *view 赋值违反只读约束;给指针变量 view 重新赋一个兼容的地址是允许的。