Skip to content

指针详解

指针 | Linux C编程一站式学习

Pointer declaration | cppreference

const type qualifier | cppreference

Abstract

指针是保存地址的变量. 类型决定解引用时读多少字节, 以及指针算术按几个元素步进.

声明不初始化会得到野指针; 用 NULL 表示"暂时无有效对象". 函数想改调用方的对象就传对象地址; 想改调用方的指针就传指针的地址 (T **).

基础语法, 数组衰减, 字符串与 NULL 哨兵见 C Pointers, Arrays, and Strings. 栈上局部变量何时不能把地址传出去见 C Memory Management.

约定

  • 定义指针应立刻给初值, 或写成 NULL. 未初始化就 *p 是undefined behavior.

  • 同一语句里每个指针都要有自己的 *: 写 int *p, *q;, 不要写成 int* p, q;.

  • const 写在 * 左边约束所指对象, 写在 * 右边约束指针本身. 字符串字面量用 const char * 声明.

  • 形参里的 T a[] 和 T *a 对编译器等价, 都是指针. 需要长度时另传一个参数.

  • int *a[10] 是指针数组; int (*a)[10] 是指向数组的指针. [] 比 * 更紧, 靠括号改结合顺序.

  • 不要返回指向本函数局部变量的指针. 尝试访问栈帧弹出后那块栈内存会触发未定义行为.

基本概念

Pointer Syntax | C Pointers, Arrays, and Strings

int i;
int *pi = &i;
char c;
char *pc = &c;

& 是取地址运算符, 操作数必须是左值. * 是间接寻址 / 解引用, 操作数必须是指针. 若 E 可做左值, *&E 与 E 等价; 若 E 是指针, &*E 与 E 等价.

指针本身也占存储. 32 位平台常见 4 字节, 64 位常见 8 字节, 所占空间与编译平台的数据模型相关, 与所指类型无关.

同类型指针可以互相赋值, 本质是复制地址. int * 与 char * 不能直接互赋; 强转后数值相同, 但解引用宽度不同, 容易越界读写.

NULL 与 void *

Garbage Addresses and NULL Pointers | C Pointers, Arrays, and Strings

野指针指向不确定地址, 出错时机随机, 难查. 空指针把地址写成 0:

#define NULL ((void *)0)  /* <stddef.h> */
int *p = NULL;

常见实现里地址 0 附近不会映射有效数据, 解引用立刻段错误, 比野指针更容易暴露.

void * 是通用对象指针: 与其他对象指针之间可隐式转换. 不能定义 void 变量, 也不能直接 *pv, 必须先转成具体类型再解引用. 接口里常用它表示"任意对象的地址", 例如 malloc 的返回类型.

指针参数与返回值

Double Pointers | C Pointers, Arrays, and Strings

C 传参是值传递. 形参拿到的是实参的副本. 若要把调用方的 int 换掉, 传的是它的地址:

int *swap(int *px, int *py)
{
    int temp = *px;
    *px = *py;
    *py = temp;
    return px;  /* 返回的也是地址值的副本 */
}

int i = 10, j = 20;
int *p = swap(&i, &j);  /* *p 即 i */

swap(&i, &j) 相当于用 &i, &j 初始化形参 px, py. 函数里写 *px 就是在改 main 里的 i.

若函数要改的是调用方手里的那个指针本身, 形参类型则升一级成 T **.

返回指针时, 所指对象的生命周期必须覆盖调用方后续的使用. 返回局部数组地址会变成悬空指针:

char *bad(void)
{
    char buf[32];
    return buf;  /* 返回后栈帧已释放 */
}

指针与数组

Arrays | C Pointers, Arrays, and Strings

int a[10];
int *pa = a;  /* 等价于 &a[0], 数组名做右值时衰减为首元素指针 */
pa++;         /* 前进 sizeof(int) 字节, 指向 a[1] */

下标是语法糖: E1[E2] 等价于 *((E1)+(E2)). 因此 a[2], pa[2], *(a+2), *(pa+2) 同一套机制. 指针算术按元素个数步进, 不是按字节.

同一数组内的指针可以比较和相减 (结果是元素个数差). 两个指针相加无意义, 且ISO/IEC标准不允许.

数组名做左值时仍表示整块数组, 不能 a++ 或 a = pa. 但可以取 &a, 类型是指向整个数组的指针, 见下文.

形参里下面的三种写法在编译器看来是等价的, 都是指针:

void f(int a[10]);
void f(int a[]);
void f(int *a);

写成 [] 只是提示读者"这里通常是一段元素的首地址". 长度不会跟着传进去, 需要就另加参数, 比如在排序算法中常见的声明写法中形参都是数组指针+长度的形式 sort(int arr[], int len).

指针与 const

从右往左读, 或看 const 相对 * 的位置:

写法 可改指针 a 可改 *a
const int *a / int const *a 是 否
int * const a 否 是
int const * const a 否 否

非 const 对象的地址可以隐式赋给 const T * (只读视图加严). 反过来不行, 否则可能透过非 const 指针改掉只读对象.

字符串字面量通常在只读段. 应用 const char * 承接:

const char *p = "abcd";  /* 推荐 */

下面的写法在编译时可能可以通过:

char *q = "abcd";

但是在后续的开发中有可能会碰到如下几个问题:

  1. 尝试写入 *q 会触发未定义行为 若执行 *q = 'A'(或 q[0] = 'A')等于改只读数据. C 标准不规定此时必须怎样, 属于undefined behavior: 程序可能立刻崩溃, 也可能"看起来没事".

  2. 可能段错误 典型实现里字面量在 .rodata, 尝试写入它会触发操作系统的保护机制(如segmentation fault, 段错误).

可变副本应使用字符数组进行初始化: char buf[] = "abcd";.

字面量与字符数组的区别参考笔记C Strings | C Pointers, Arrays, and Strings.

指针与结构体

Struct Pointers | C Pointers, Arrays, and Strings

struct unit {
    char c;
    int num;
};
struct unit u;
struct unit *p = &u;

(*p).c = 'x';
p->num = 1;  /* 等价于 (*p).num */

. 作用于结构体值, -> 是"先解引用再取成员". 结构体往往较大, 接口里多传指针避免整份拷贝.

二级指针与指针数组

Double Pointers | C Pointers, Arrays, and Strings

int i;
int *pi = &i;
int **ppi = &pi;  /* *ppi 是 pi, **ppi 是 i */

指针数组: 每个元素都是指针.

int *a[10];
int **pa = a;  /* pa[i] 与 a[i] 同一元素, 类型是 int * */

main 的 argv 在原型里写成 char *argv[], 对编译器就是 char **argv: 指向"一串 char *"的首元素, 每个 char * 指向一个参数字符串, 末尾常有 NULL 哨兵.

int main(int argc, char *argv[])
{
    int i;
    for (i = 0; argv[i] != NULL; i++)
        printf("argv[%d]=%s\n", i, argv[i]);
    return 0;
}

argv[0] 是程序名 (或符号链接名). 习题里三种 const 与二级指针的组合:

  • const char **p: **p 只读

  • char * const *p: 中间那一层指针只读 (*p 不能改指向)

  • char ** const p: 最外层指针本身只读

指向数组的指针与多维数组

int *a[10];      /* 10 个 int *, 指针数组 */
int (*pa)[10];   /* 一个指针, 指向"10 个 int 的数组" */

用 typedef 拆开记:

typedef int *t;
t a[10];           /* 同 int *a[10] */

typedef int t[10];
t *pa;             /* 同 int (*pa)[10] */

&a 与 &a[0] 数值常相同, 类型不同: 前者是 int (*)[N], 后者是 int *. 步进时 pa++ 跳过整行 N * sizeof(int) 字节.

二维数组 int a[5][10] 的元素类型是 int [10], 所以指向一行的指针类型是 int (*)[10]:

int a[5][10];
int (*pa)[10] = &a[0];  /* 也可写成 a, 衰减成指向首行的指针 */
/* pa[1][2] 与 a[1][2] 同一元素 */

函数指针

函数名做右值时衰减成函数指针. 函数指针保存的是入口地址 (汇编码中通常在 .text 段).

void say_hello(const char *str)
{
    printf("Hello %s\n", str);
}

void (*f)(const char *) = say_hello;  /* 也可写 &say_hello */
f("Guys");      /* 直接用指针调用 */
(*f)("Guys");   /* 先解引用再调用, 效果相同 */

typedef 能把声明拆开:

typedef int F(void);   /* 函数类型: 无参, 返回 int */
F *fp;                 /* 等价于 int (*fp)(void); */

函数不能返回函数类型或数组类型, 但可以返回函数指针.

按"类型标签"选实现时, 可用函数指针表代替一长串 if / switch, 让每种表示各自一组小函数, 通过表项分派. 这是低耦合的一种写法.

不完全类型与复杂声明

C 的类型大致分成函数类型, 对象类型, 不完全类型. 指针属于标量, 可当布尔值使用: NULL 为假, 非空为真.

不完全类型指暂时不知道空间占用大小的类型, 例如 struct s;, char str[];.

同一对象可经多次声明合成完全类型. 结构体里可以放指向尚未定义完整的结构体的指针, 因为指针大小已知; 但不能嵌套一个尚未完整的结构体对象本身 (因为可能会导致无穷大).

struct node {
    char data[6];
    struct node *next;  /* 处理到这里时 struct node 仍不完全, 指针却已完整 */
};

熟悉数据结构的话, 这种写法应该不陌生. 这就是链表, 树等结构的基本单元, next == NULL 表示末端.

复杂声明按"标识符起, 先看右边再看左边, 括号优先"拆, 或用 typedef 分层:

/* signal(2) 风格 */
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

/* 再拆一例: fp 是指针 → 指向"参数 void *, 返回指向 int[10] 的指针"的函数 */
int (*(*fp)(void *))[10];

三条骨架:

  • T *p — 指针

  • T a[] — 数组 (形参例外, 变成指针)

  • T1 f(T2, ...) — 函数

与 C++ 的衔接

C 的原始指针规则在 C++ 里仍然成立. 迭代器接口模仿指针的 *, ++, !=; 所有权则更多交给引用与智能指针.