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 *首元素地址
&arrint (*)[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];
    }
}

这相当于向编译器承诺 outab 不重叠。如果调用时传入重叠区域,结果不再可靠。

普通代码不要为了显得“高级”加 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未定义行为

学习路径