C 语言指针细节
内容简介
本篇专门放 C 指针里“工程实践不一定常写,但非常有助于理解”的细节:数组退化、arr 和 &arr 的类型差异、尾后指针、复杂声明、指针和整数、对齐、严格别名、函数指针和对象指针的边界等。
这些内容的价值是建立准确的底层模型。日常工程接口应该优先看 CandCpp指针进阶,把代码写得直接、可维护,而不是故意展示复杂指针技巧。
1. 指针不是地址数字的简单别名
指针保存对象地址,但 C 语言里的指针还带着类型信息。类型决定了解引用方式、指针运算步长和别名规则。
int value = 0x11223344;
int *p = &value;
unsigned char *bytes = (unsigned char *)&value;
printf("%d\n", *p);
printf("%02x\n", bytes[0]);int * 访问的是一个 int 对象,unsigned char * 可以按字节查看对象表示。字节顺序取决于平台大小端,所以这个例子适合理解内存表示,不适合写可移植业务逻辑。
2. 安全解引用需要满足多个条件
一个指针能被安全解引用,至少要同时满足:
- 不是空指针。
- 指向一个生命周期仍然有效的对象。
- 指向的地址满足目标类型的对齐要求。
- 访问类型和对象真实类型兼容。
- 没有越过数组对象的有效范围。
只检查 p != NULL 远远不够。
int *p = NULL;
if (p != NULL) {
printf("%d\n", *p);
}上面只解决了空指针问题,不能解决悬空指针、越界指针、错误类型转换等问题。
3. 数组名退化为首元素指针
在大多数表达式中,数组名会转换为指向首元素的指针。
int arr[4] = {1, 2, 3, 4};
int *p = arr; // 等价于 int *p = &arr[0];但数组对象本身不是指针变量。数组名不能被重新赋值:
int a[3] = {1, 2, 3};
int b[3] = {4, 5, 6};
// a = b; // 错误:数组名不是可赋值的指针变量不发生退化的常见场景
sizeof(arr):得到整个数组大小。&arr:得到整个数组的地址。- 字符串字面量初始化字符数组时。
char text[] = "abc"; // 拷贝字符到数组,包含结尾 '\0'
const char *p = "abc"; // p 指向字符串字面量text 是可写数组,p 指向的字符串字面量通常不可修改。
4. arr、&arr[0] 和 &arr
这三个表达式的数值地址经常看起来一样,但类型不同。
int arr[4] = {1, 2, 3, 4};
printf("%p\n", (void *)arr);
printf("%p\n", (void *)&arr[0]);
printf("%p\n", (void *)&arr);对照表:
| 表达式 | 类型 | 含义 |
|---|---|---|
arr | 大多数表达式中退化为 int * | 首元素地址 |
&arr[0] | int * | 首元素地址 |
&arr | int (*)[4] | 整个数组的地址 |
sizeof(arr) | size_t | 整个数组字节数 |
sizeof(&arr) | size_t | 指针本身字节数 |
差异在指针运算时最明显:
int *p1 = arr + 1; // 移动到 arr[1]
int (*p2)[4] = &arr + 1; // 移动到整个 arr 之后arr + 1 跨过一个 int,&arr + 1 跨过一个 int[4]。
5. 函数参数中的数组形参
函数参数位置的数组声明会调整为指针声明。
void f1(int arr[]);
void f2(int arr[10]);
void f3(int *arr);这三个声明在参数位置基本等价。arr[10] 不会让编译器自动保证调用者真的传入 10 个元素。
void fill10(int arr[10])
{
for (int i = 0; i < 10; ++i) {
arr[i] = i;
}
}
int small[3];
fill10(small); // 可能编译通过,但会越界写这就是为什么工程接口通常写成 int *data, size_t count。
sizeof 在函数内外含义不同
void print_count_bad(int arr[])
{
printf("%zu\n", sizeof(arr) / sizeof(arr[0])); // 错误:arr 是指针
}
int main(void)
{
int arr[4] = {1, 2, 3, 4};
printf("%zu\n", sizeof(arr) / sizeof(arr[0])); // 4
print_count_bad(arr); // 错误结果
return 0;
}同样写 sizeof(arr),在数组定义处得到数组大小,在函数形参里得到指针大小。
6. 尾后指针
C 允许得到数组最后一个元素之后的位置,也就是尾后指针。
int arr[3] = {10, 20, 30};
int *begin = arr;
int *end = arr + 3;
for (int *p = begin; p != end; ++p) {
printf("%d\n", *p);
}end 可以用于比较,不能解引用。
printf("%d\n", *end); // 错误:尾后指针不能解引用,属于未定义行为尾后指针有助于理解 C++ 迭代器的 [begin, end) 区间模型。
7. 指针运算只在同一数组对象内有意义
int a[3] = {1, 2, 3};
int b[3] = {4, 5, 6};
int *p = &a[0];
int *q = &a[2];
printf("%td\n", q - p); // 合法:同一个数组内不要比较或相减两个无关对象的指针:
// printf("%td\n", &b[0] - &a[0]); // 不应这样写即使某个平台上能打印出一个数字,也不代表这是可移植、受语言规则保护的行为。
8. p++、(*p)++ 和 *p++
这组表达式很适合理解优先级,但不建议在业务代码里堆复杂写法。
int arr[3] = {10, 20, 30};
int *p = arr;
printf("%d\n", *p++); // 打印 10,然后 p 指向 arr[1]等价理解:
printf("%d\n", *p);
p = p + 1;其他常见形式:
(*p)++; // 修改 p 指向的值
p++; // 修改 p 自己,让它指向下一个元素
++(*p); // 先对 *p 加 1,再使用新值工程实践里优先拆成多行,让意图清楚。
9. 复杂声明阅读
C 声明可以从变量名开始,按优先级向外读。
int *a[3]; // a 是数组,里面有 3 个 int*
int (*b)[3]; // b 是指针,指向 int[3]
int (*f)(int); // f 是函数指针,指向参数为 int、返回 int 的函数再复杂一些:
int (*table[4])(int, int);读法:table 是一个数组,数组有 4 个元素,每个元素是函数指针,指向参数为 (int, int)、返回 int 的函数。
示例:
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int (*table[2])(int, int) = {add, sub};
printf("%d\n", table[0](3, 2));
printf("%d\n", table[1](3, 2));这种写法适合理解函数指针表。真实项目中可以用 typedef 改善可读性:
typedef int (*BinaryOp)(int, int);
BinaryOp table[2] = {add, sub};10. void* 不能直接做指针运算和解引用
标准 C 中,void* 不知道目标对象大小,因此不能直接解引用,也不能按元素做指针运算。
void *raw = NULL;
// *raw; // 错误
// raw + 1; // 标准 C 中错误需要先转换为具体类型:
int value = 10;
void *raw = &value;
int *p = raw;
printf("%d\n", *p);如果要按字节处理,转换成 unsigned char *:
void dump_bytes(const void *object, size_t size)
{
const unsigned char *bytes = object;
for (size_t i = 0; i < size; ++i) {
printf("%02x ", bytes[i]);
}
puts("");
}按字节查看对象表示是学习底层很有用的技巧,但业务逻辑不要依赖具体字节布局,除非协议、文件格式或硬件接口明确要求。
11. 指针和整数转换
不要把指针随便塞进 int。指针大小可能大于 int。
如果确实要保存指针的整数表示,使用 <stdint.h> 里的 uintptr_t,并且只在确实需要底层处理时使用。
#include <stdint.h>
int value = 10;
uintptr_t raw = (uintptr_t)&value;
int *p = (int *)raw;
printf("%d\n", *p);这种写法常见于底层调试、哈希、内存映射或嵌入式寄存器地址处理。普通业务代码不应依赖它。
12. 对齐要求
不同类型可能有不同对齐要求。把一个未正确对齐的地址强转成 int * 并解引用,可能导致未定义行为。
unsigned char buffer[sizeof(int) + 1];
int *p = (int *)(buffer + 1);
// *p = 10; // 可能未对齐,不能这样写如果需要在字节缓冲区和对象之间转换,优先用 memcpy:
int value = 0;
unsigned char buffer[sizeof(value)];
memcpy(buffer, &value, sizeof(value));
memcpy(&value, buffer, sizeof(value));memcpy 让你处理的是对象表示,而不是制造一个可能不满足类型规则的指针。
13. 严格别名规则
C 编译器通常假设不同不兼容类型的指针不会指向同一个对象,这有利于优化。错误地用不兼容类型访问同一对象,可能产生未定义行为。
float f = 1.0f;
int *p = (int *)&f;
// printf("%d\n", *p); // 不推荐:违反别名规则的典型例子如果只是想观察 float 的字节表示,用 memcpy:
float f = 1.0f;
unsigned char bytes[sizeof(f)];
memcpy(bytes, &f, sizeof(f));unsigned char * 可以查看对象表示,这是 C 里少数明确允许的底层观察方式之一。
14. 函数指针和对象指针不是一类东西
对象指针指向数据对象,函数指针指向函数。不要把它们当成同一种地址随便互转。
int add(int a, int b)
{
return a + b;
}
int (*fp)(int, int) = add;
printf("%d\n", fp(1, 2));下面这种思路不应写入可移植 C 代码:
// void *raw = (void *)add; // 不可移植
// int (*fp)(int, int) = raw; // 不可移植有些平台上函数地址和对象地址模型不同。操作系统动态加载接口可能会涉及类似转换,但那属于平台 API 约定,不是普通 C 语言模型。
15. restrict 是给优化器的承诺
restrict 表示在这个指针的生命周期内,对应对象主要通过这个指针访问。它可以帮助编译器优化,但写错会造成未定义行为。
void add_arrays(size_t n, int *restrict out,
const int *restrict a,
const int *restrict b)
{
for (size_t i = 0; i < n; ++i) {
out[i] = a[i] + b[i];
}
}这相当于向编译器承诺 out、a、b 不重叠。如果调用时传入重叠区域,结果不再可靠。
普通代码不要为了显得“高级”加 restrict。只有在性能热点、接口边界清楚、确实能保证不重叠时再用。
16. 灵活数组成员
结构体末尾可以放一个不指定长度的数组成员,用来表达“头部 + 变长数据”的布局。
typedef struct Packet {
size_t size;
unsigned char data[];
} Packet;
Packet *create_packet(size_t size)
{
Packet *packet = malloc(sizeof(*packet) + size);
if (packet == NULL) {
return NULL;
}
packet->size = size;
return packet;
}这种写法在协议包、文件格式、内核和嵌入式代码里常见。它有助于理解 C 如何表达变长对象,但日常业务里要谨慎使用,避免越界和所有权混乱。
17. 有助于理解但不建议炫技的写法
用下标反过来访问
int arr[3] = {10, 20, 30};
printf("%d\n", arr[1]);
printf("%d\n", 1[arr]); // 也能工作,但不要这样写业务代码原因是 arr[i] 本质上等价于 *(arr + i),而 i[arr] 等价于 *(i + arr)。
这个例子适合理解下标运算,不适合真实项目。
故意写复杂自增
while (*p != '\0') {
putchar(*p++);
}这类写法在 C 代码中很常见,能看懂即可。新代码如果团队成员不熟,写成下面这样更直观:
while (*p != '\0') {
putchar(*p);
++p;
}最佳理解方式
- 先区分对象、指针变量、地址值和表达式类型。
- 看到数组名时,先判断它是否发生了退化。
- 看到指针运算时,先问它移动的是几个“元素”,不是几个字节。
- 看到强制转换时,先问是否满足对齐、生命周期和别名规则。
- 看到复杂声明时,从变量名开始读,必要时用
typedef拆开。
常见错误
int *p;
*p = 10; // 错误:未初始化指针int *bad(void)
{
int local = 10;
return &local; // 错误:返回局部变量地址
}int arr[3] = {1, 2, 3};
int *end = arr + 3;
printf("%d\n", *end); // 错误:尾后指针不能解引用,属于未定义行为void clear(int buffer[16])
{
memset(buffer, 0, sizeof(buffer)); // 错误:buffer 是指针
}int matrix[3][4];
int **p = (int **)matrix; // 错误理解:二维数组不是 int**注意事项
- 指针细节帮助你理解 C,不代表工程代码应该写得复杂。
- 数组和指针关系很近,但数组对象不是指针变量。
- 对象指针和函数指针不要互相强转。
- 指针比较和相减通常只在同一个数组对象内有意义。
unsigned char *可以观察对象表示,但不要把观察结果误当成跨平台业务规则。- 需要精确判断时,优先查 C 标准、cppreference 或项目编码规范。
- 数组越界、尾后指针解引用这类问题的机制解释见 未定义行为。
相关:CandCpp指针、CandCpp指针进阶、C语言类型转换规则、C语言MallocFree、未定义行为