目录

很多教材讲指针的时候都会说:指针是存地址的变量。

可是指针存的地址到底指的是什么?指针解引用又是什么意思?指针运算的步长是多少?

能把指针讲得通俗易懂的教材并不多,比如我上学时用的教材就是号称国内”C语言之父”的《C程序设计》,但是在这本书上我只记住了”指针是存地址的变量”,至于到底什么是指针,什么是地址,我当时并没有搞明白。

现在回过头来看,指针本身并不复杂,用类比就能讲清楚,比如:可以把内存想成一栋大楼,每个字节都有唯一门牌号,这个门牌号就是地址。普通变量存的是房间里住的人,指针存的是一张写着门牌号的纸条。

C 最初是为编写 Unix 这类系统程序而设计的,很多写法和机器指令对应得很直接。熟悉 C 与汇编的人读到 C 代码时,脑子里往往能同步浮现出对应的汇编指令。

本文要弄清楚的,是指针在机器指令层面到底表达什么。所以本文用汇编来分析 C 代码的底层实现。

文中汇编都是 GCC 13.3.0(x86_64-linux-gnu)在 -O0 -masm=intel -fverbose-asm -fno-stack-protector 下编译的真实产物,但只节选相关函数主体,略去 .cfi、endbr64 这类指令。指令和偏移都照产物原样,没有手工改动。汇编块里的 # 注释是我为了对照 C 源码加的。

指针家族:按指向的目标分类

C 里的指针不是只有一种,它具体是什么类型,要看它指向什么。

C 标准把指针类型归为派生类型:它是从所指向的类型派生出来的,而指针里存的值就是所指对象或函数的地址。

按指向的目标分,常见的写法有下面几种:

  1. int *p:对象指针,指向一个对象,槽里装的是那个对象的地址
  2. arr / &arr[0]:数组元素指针,数组名在表达式里衰减成指向首元素的指针
  3. int *pa[3]:指针数组,本质是一个数组,元素是 3 个指针
  4. int (*p)[3]:数组指针,本质是一个指针,指向整个数组
  5. int **pp:多级指针,指向指针的指针
  6. void *vp:泛型指针,指向类型未知的对象,读写数据前要先转换成具体类型的指针
  7. void (*fp)(void):函数指针,指向代码段的入口地址
  8. NULL:空指针常量,赋给指针后得到空指针,不指向任何对象或函数

区分这些类型,实际意义有以下两点:

  1. 声明决定了指针加 1 时地址前进多少字节;
  2. 它有几颗星,决定了要顺着地址解引用几次,才能拿到最终的数据。

下面按这个分类逐一探索其本质。

类型一:对象指针,指向一个具体的对象

对象指针是最基础的一类。

例如 int *p 里的 p 是一个独立的对象,它自己也要占一块内存,而这块内存里放的是另一个对象的地址。

先看一个最小的例子。

// 定义一个 int 变量,再让指针保存它的地址
int main(void)
{
    int  a = 10;
    int* p = &a;
    return 0;
}
main:
push    rbp
mov     rbp, rsp

# 本例布局(从 rbp 往下排):
#   rbp-8 .. rbp-1  → p(指针,8 字节)→ 记作 -8[rbp]
#   rbp-12 .. rbp-9 → a(int,4 字节)→ 记作 -12[rbp]
mov     DWORD PTR -12[rbp], 10

# lea 只算地址、不访存:rax = &a
lea     rax, -12[rbp]

# 把 &a 写进 p 的槽(QWORD = 8 字节)
mov     QWORD PTR -8[rbp], rax
mov     eax, 0
pop     rbp
ret

这段函数体里,与取地址对应的是 lea 和 mov 两条指令。

lea 是 Load Effective Address,只做地址运算、不访问内存,编译器用它为 &a 把 a 的栈地址 -12[rbp] 装进 rax;随后的 mov 再把这个地址原样写进 p 所在的槽 -8[rbp]。用开头的类比来说就是:a 是房间里住的人(值 10),p 是写着 a 门牌号的纸条,p 这个 8 字节的槽里装的是 -12[rbp] 这个地址,而不是 10 这个值。

-8[rbp] 与 [rbp-8] 是同一个地址的两种写法,区别只在 Intel 语法里位移写在前还是写在后。这份产物里指针槽从 rbp 往下第一个 8 字节档开始排,int 只要 4 字节、按 4 字节对齐,落到更低的 -12[rbp]。

从这个最小例子里可以看到以下两点事实:

  1. 指针变量和普通变量一样要占内存,最根本的区别在于槽里装的内容。在本文的测试环境(64bit)上,指针占 8 字节,int 占 4 字节。
  2. 被指向的普通变量装的是数据本身,比如 a 装的就是 10。

给指针赋值,只是把它要指向的地址存进自己的槽;指针真正派上用场是在解引用的时候。在表达式里,放在指针前面的星号 * 是用来解引用的,这个运算符叫间接寻址运算符。声明里的 *,比如 int *p 里的那个,只表示 p 是一个指针,不是解引用。C 标准说,对指针取 * 得到的东西就是它所指向的那个对象。沿用前面的类比:你拿着写有门牌号的纸条,得照着门牌号找到对应的房间,才能拜访房间里的人。

参考以下代码进行理解。

int main(void)
{
    int  a = 10;
    int* p = &a;
    *p     = 20;
    return 0;
}
# 槽位与上一例相同:-12[rbp] = a,-8[rbp] = p
mov     DWORD PTR -12[rbp], 10
lea     rax, -12[rbp]
mov     QWORD PTR -8[rbp], rax

# 先从 p 的槽里读出地址,rax 里现在是 &a
mov     rax, QWORD PTR -8[rbp]

# [rax] 是按寄存器里的地址访存,不是 -N[rbp]
mov     DWORD PTR [rax], 20

解引用就体现在最后两条指令上:

  1. 前一条把 p 槽里存的地址(也就是 &a)取进 rax。
  2. 后一条 mov DWORD PTR [rax], 20 把 20 写进 rax 指向的地方。

对比前面给 a 赋值的那一行,两边改的都是同一块内存 -12[rbp],可见 *p = 20 与 a = 20 动的是同一个地方,解引用无非就是按地址访问。

还是那句类比的描述:纸条上并没有住人,它只是告诉你人住在哪间房。

顺着赋值再往下探索一层:int 指针加一,和 char 指针加一,地址涨的步数一样吗?直觉上可能以为都是加一,但汇编给出的答案却是否定的。

直接阅读代码,加深理解。

int main(void)
{
    int   a      = 0;
    int*  p_int  = &a;
    char* p_char = (char*)&a;
    p_int++;
    p_char++;
    return 0;
}
# 本例两个指针 + 一个 int:
#   -8[rbp]  → p_int(8 字节,紧挨 rbp)
#   -16[rbp] → p_char(8 字节指针,按 8 字节对齐落在这一档)
#   -20[rbp] → a(4 字节)
mov     DWORD PTR -20[rbp], 0
lea     rax, -20[rbp]
mov     QWORD PTR -8[rbp], rax
lea     rax, -20[rbp]
mov     QWORD PTR -16[rbp], rax

# p_int++:地址 +4(sizeof(int))
add     QWORD PTR -8[rbp], 4

# p_char++:地址 +1(sizeof(char))
add     QWORD PTR -16[rbp], 1

答案就藏在最后两条 add 里:

  1. p_int++ 给地址加 4,p_char++ 只给地址加 1。
  2. C 标准规定,指针加整数 N 是把指针向前移动 N 个元素,移动的字节数等于 N 乘以所指类型的大小。

本文的测试环境下 int 占 4 字节、char 占 1 字节。标准并不强制 int 必须是 4 字节,步长一律按所指类型的大小(sizeof(*p))来理解。

上述汇编代码展示的内存布局里的 -16、-20 只是当前作者测试环境下的排法,如果更换编译器版本此数值可能会改变。

回看整个类型一。指针和普通的 int 一样,自己占一个槽,也能被赋值、被读取。两者最根本的不同是,指针槽里存的数被当成地址来用。

从本文的例子里能看到,类型信息决定了三件事:

  1. 能不能解引用。只有指针才能用 * 解引用。解引用时,编译器先把指针槽里的地址读进寄存器,再生成 [rax] 这种按地址访存的指令。
  2. 解引用时一次读写几个字节。这由所指类型决定,比如 int * 按 4 字节(DWORD)读写,int ** 按 8 字节(QWORD)读写。
  3. 指针加 1 时地址前进几个字节。这也由所指类型决定,比如 int * 加 4,char * 加 1。

取地址则和类型无关。本文例子里不管取什么类型变量的地址,用的都是 lea。

图 1:p 的 8 字节槽里装的是 a 的地址

类型二:数组元素指针,数组名衰减出来的产物

常挂在嘴边的一句话是”数组名是指向首元素的常量指针”,这话并不严谨。

C 标准里数组是一个独立的派生类型,但它并不是指针类型。只是在大多数表达式里,数组类型的左值会按规则衰减成指向首元素的指针。

但在以下几种使用场景下不会衰减成指向首元素的指针:

  1. 数组是 sizeof 的操作数
  2. 数组是取地址运算符 & 的操作数
  3. 作为数组初始化器的字符串字面量。

因此 sizeof(arr) 得到的是整个数组的字节数,&arr 的类型是 int (*)[3] 而不是 int **,这正说明数组本身不是指针。

实测也支持这一点:给数组名做自增,GCC 直接报 error: lvalue required as increment operand,编译失败。数组名不是一个存放地址的槽,它是一次衰减运算的输入。

衰减一旦发生,数组访问就退化成指针算术。C 标准写得很明白,E1[E2] 与 *((E1)+(E2)) 含义相同。那 arr[1] 和 *(arr + 1) 为什么是一回事?把生成的汇编摆在一起对照,答案就一目了然。

int main(void)
{
    int arr[3] = {1, 2, 3};
    int x      = arr[1];
    int y      = *(arr + 1);
    return 0;
}
# arr[0] → -20[rbp],arr[1] → -16[rbp],arr[2] → -12[rbp]
# 三个元素首尾相接,相邻差 4 = sizeof(int)
mov     DWORD PTR -20[rbp], 1
mov     DWORD PTR -16[rbp], 2
mov     DWORD PTR -12[rbp], 3

# 读 arr[1]:首元素地址 + 1 × 4 → -16[rbp]
mov     eax, DWORD PTR -16[rbp]
mov     DWORD PTR -4[rbp], eax

# 读 *(arr + 1):同一地址 -16[rbp]
mov     eax, DWORD PTR -16[rbp]
mov     DWORD PTR -8[rbp], eax

从汇编能看出,三个元素在内存里紧挨着排列,彼此相隔 4 字节;而 arr[1] 和 *(arr + 1) 这两条读指令访问的都是 -16[rbp],是同一个地址,和图 2 一致。这说明数组下标并不是指针算术之外另搞的一套计算规则,它实际上算的就是数组首地址加上索引乘以元素大小。

图 2:arr[1] 与 *(arr + 1) 落到同一地址

数组元素指针和对象指针装的都是地址,在机器层面没有区别。要注意的是数组名本身。arr 直接写在表达式里时,会衰减成首元素的地址,这个值和 &arr[0] 相同。它是编译器算出来的一个值,本身不占槽。把它赋给一个指针变量,比如 int *q = arr;,这个值才会被存进 q 的 8 字节槽。另外,衰减的结果只指向首元素,不指向整个数组。

类型三:指针数组与数组指针,括号决定谁是主体

int *pa[3] 和 int (*p)[3] 看着只差一对括号,意思却完全不同。原因在于声明里的运算符有优先级:[] 比 * 先和变量名结合,所以 int *pa[3] 先结合成数组,元素是 3 个 int *,整个 pa 占 3 × 8 字节;而加了括号的 int (*p)[3] 让 * 先结合,p 就成了指向 int[3] 的指针,本身只占 8 字节。

先来探秘指针数组。

int main(void)
{
    int  a[3]  = {1, 2, 3};
    int *pa[3] = {&a[0], &a[1], &a[2]};
    int  x     = *pa[1];
    return 0;
}
# a[0] → -16[rbp],a[1] → -12[rbp],a[2] → -8[rbp]
mov     DWORD PTR -16[rbp], 1
mov     DWORD PTR -12[rbp], 2
mov     DWORD PTR -8[rbp], 3

# pa 是 3 个指针的数组,每个元素 8 字节:
#   pa[0] → -48[rbp],pa[1] → -40[rbp],pa[2] → -32[rbp]
lea     rax, -16[rbp]
mov     QWORD PTR -48[rbp], rax
# pa[1] = &a[1]:&a[0] + 4
lea     rax, -16[rbp]
add     rax, 4
mov     QWORD PTR -40[rbp], rax
# pa[2] = &a[2]:&a[0] + 8
lea     rax, -16[rbp]
add     rax, 8
mov     QWORD PTR -32[rbp], rax

# *pa[1]:先取 pa[1] 里存的地址,再按这个地址访存
mov     rax, QWORD PTR -40[rbp]
mov     eax, DWORD PTR [rax]
mov     DWORD PTR -4[rbp], eax

这段汇编清楚地展示了 pa 的布局:三个元素各占 8 字节,pa[0] 在 -48[rbp],相邻元素相差 8 字节,不像 int 数组那样相差 4 字节。而 *pa[1] 要分两步完成:先从 pa[1] 的槽 -40[rbp] 里读出 a[1] 的地址,再按这个地址访存,读出 4 字节。

接着再探秘数组指针。

int main(void)
{
    int  a[3] = {1, 2, 3};
    int (*p)[3] = &a;
    int  y = (*p)[2];
    int  z = *(*p + 1);
    return 0;
}
# a[0] → -28[rbp],a[1] → -24[rbp],a[2] → -20[rbp]
mov     DWORD PTR -28[rbp], 1
mov     DWORD PTR -24[rbp], 2
mov     DWORD PTR -20[rbp], 3

# p = &a:取的是整个数组的地址,数值与 &a[0] 相同
lea     rax, -28[rbp]
mov     QWORD PTR -8[rbp], rax

# (*p)[2]:先取 p 里的地址,再 +8(2 × sizeof(int))访存
mov     rax, QWORD PTR -8[rbp]
mov     eax, DWORD PTR 8[rax]
mov     DWORD PTR -12[rbp], eax

# *(*p + 1):*p 衰减成 &a[0],+1 走 4 字节
mov     rax, QWORD PTR -8[rbp]
mov     eax, DWORD PTR 4[rax]
mov     DWORD PTR -16[rbp], eax

有一处细节值得注意:&a 和 &a[0] 数值一样(这里都是 -28[rbp]),但类型却不同,前者是 int (*)[3],后者则是 int *。

这个类型差别,在上面两个例子的地址计算里看不出来。(*p)[2] 和 *(*p + 1) 都要先经过 *p。*p 就是整个数组 a,它在表达式里又会衰减成 &a[0],类型是 int *。所以下标每加 1 走 4 字节,[2] 是 +8,+ 1 是 +4。int (*)[3] 这个类型真正影响地址计算,是在给 p 本身加 1 的时候。p 加 1 一步走 12 字节,也就是跨过整个数组。

下面把指针数组衰减出来的 int ** 和数组指针放在同一段代码里,比一比它们加 1 各走几个字节。

int main(void)
{
    int   a[3]  = {1, 2, 3};
    int  *pa[3] = {&a[0], &a[1], &a[2]};

    int **q = pa;
    int (*p)[3] = &a;

    q++;
    p++;

    return 0;
}
# a[0] → -28[rbp],a[1] → -24[rbp],a[2] → -20[rbp]
mov     DWORD PTR -28[rbp], 1
mov     DWORD PTR -24[rbp], 2
mov     DWORD PTR -20[rbp], 3

# pa[0] → -64[rbp],pa[1] → -56[rbp],pa[2] → -48[rbp]
lea     rax, -28[rbp]
mov     QWORD PTR -64[rbp], rax
lea     rax, -28[rbp]
add     rax, 4
mov     QWORD PTR -56[rbp], rax
lea     rax, -28[rbp]
add     rax, 8
mov     QWORD PTR -48[rbp], rax

# q = pa:pa 衰减成指向 pa[0](int *)的指针,即 int **
lea     rax, -64[rbp]
mov     QWORD PTR -8[rbp], rax

# p = &a:指向整个数组
lea     rax, -28[rbp]
mov     QWORD PTR -16[rbp], rax

# q++:所指类型是 int *,8 字节
add     QWORD PTR -8[rbp], 8

# p++:所指类型是 int[3],12 字节
add     QWORD PTR -16[rbp], 12

通过上述的代码可以印证我们前文的结论:q++ 是加 8,p++ 是加 12。q 的所指类型是 int *(8 字节),p 的所指类型是 int[3](12 字节),步长还是由所指类型的大小决定的。

图 3:指针数组与数组指针,+1 走的步数不同

指针数组是个容器,装 3 个指针;数组指针是个指针,指向一整块数组。两者 +1 差 4 字节,根源就在所指类型的不同。

类型四:多级指针,指向指针的指针

指针变量本身也是一个对象,也占着内存里的一个槽,所以也能对它取地址。

int **pp 装的正是这个地址,不是 a 的地址,而是 p 这个指针变量自己的地址。

int main(void)
{
    int   a  = 10;
    int  *p  = &a;
    int **pp = &p;
    **pp = 20;
    return 0;
}
# 布局:a → -12[rbp],p → -24[rbp],pp → -8[rbp]
mov     DWORD PTR -12[rbp], 10

# p = &a
lea     rax, -12[rbp]
mov     QWORD PTR -24[rbp], rax

# pp = &p
lea     rax, -24[rbp]
mov     QWORD PTR -8[rbp], rax

# **pp = 20:第一次取地址拿到 &a,第二次才写到 a
mov     rax, QWORD PTR -8[rbp]
mov     rax, QWORD PTR [rax]
mov     DWORD PTR [rax], 20

**pp = 20 会生成三条指令,对应两次解引用:

  1. 先从 pp 自己的槽 -8[rbp] 读出 p 的地址 -24[rbp]
  2. 再顺着这个地址读出 p 槽里存的 &a
  3. 最后才把 20 写进 -12[rbp]

指针每多一层间接,访存就得多走一步。pp 自己依然是一个 8 字节的槽,和 int *p 在机器层面没有区别,真正的差别只在于它指向的那个东西本身还是个指针,因此必须写两个 * 才能访问到真正的数据。

类型五:泛型指针 void *,只保留地址的类型

void 是一种不完整类型,编译器无法补全它,所以不能用来定义变量。void * 在编译期不带所指类型的信息,C 标准规定它能和任意对象指针互相转换。也正因为它不知道指向的是什么类型,所以不能通过 void * 直接读写数据,也不能对它做指针算术。要读写数据,必须先把它转换成具体类型的指针。

阅读下面的代码,探索其底层原理。

int main(void)
{
    int   a  = 10;
    void *vp = &a;
    int   x  = *(int *)vp;
    return 0;
}
# 布局:a → -16[rbp],vp → -8[rbp],x → -12[rbp]
mov     DWORD PTR -16[rbp], 10

# vp = &a:lea 只算地址,与类型无关
lea     rax, -16[rbp]
mov     QWORD PTR -8[rbp], rax

# 转成 int * 之后才能按 int 读 4 字节
mov     rax, QWORD PTR -8[rbp]
mov     eax, DWORD PTR [rax]
mov     DWORD PTR -12[rbp], eax

汇编也印证了这一点:vp = &a 用的还是 lea,说明取地址和所指类型无关。

类型真正起作用是在读取的时候:先把指针转成 int *,编译器才知道要按 4 字节读,于是生成 mov eax, DWORD PTR [rax]。

那如果偏要对 void * 做算术,编译器又会怎么处理呢?再看一个例子。

int main(void)
{
    int   a = 10;
    void *vp = &a;
    vp = vp + 1;
    return 0;
}
# 布局:a → -12[rbp],vp → -8[rbp]
mov     DWORD PTR -12[rbp], 10
lea     rax, -12[rbp]
mov     QWORD PTR -8[rbp], rax

# GNU 扩展把 void 当 1 字节,所以 +1 就是地址 +1
add     QWORD PTR -8[rbp], 1

GCC 作为 GNU 扩展允许 void * 算术,把 void 的大小当作 1 字节,因此 +1 就是地址加 1。在 -std=c17 -pedantic -Wall 下会报 warning: pointer of type 'void *' used in arithmetic [-Wpointer-arith],且 sizeof(void) 求值为 1,这是 GNU 扩展,不属于标准 C 的规定。

void * 与普通对象指针一样占 8 字节的槽。差别只在编译期:void * 丢掉了类型信息,编译器没法通过它读写数据,也算不出指针算术的步长。所以标准不允许对 void * 做算术,gcc 只能靠 GNU 扩展,按 void 占 1 字节来算出步长。

顺带说一下空指针。在 C 里,值为 0 的整数常量(比如 0、0L)是空指针常量,把它转成 void * 写成 (void *)0,也是空指针常量。把空指针常量赋给指针,得到的就是空指针。空指针不等于任何对象或函数的地址,意思就是”哪儿也不指”。

C 里常用的 NULL 就是一个空指针常量。GCC 的 <stddef.h> 在 C 里把它定义成 ((void *)0)。

看一下汇编:int *np = NULL; 编译出来是 mov QWORD PTR -8[rbp], 0,也就是往指针的 8 字节槽里写一个 0。x86-64 的 ABI 规定,空指针的值就是 0。

对空指针解引用是未定义行为。在本文的测试机上,程序会直接因为段错误退出。用前面的类比来说,空指针就像一张写着”0 号房”的纸条,而这栋楼约定 0 号房永远不住人,照着它去找,是找不到人的。

类型六:函数指针,指向代码段的入口

指针还能指向代码吗?能。

函数也住在内存里,只不过它住在代码段,入口同样有地址。

C 标准规定,函数指示符(函数名)在绝大多数表达式里会转成指向该函数的指针,所以 func 和 &func 是一回事,赋值给函数指针时写哪个都行。调用函数指针,就是跳到那个地址去执行。

阅读下面的代码,探索函数指针的真相。

// 函数指针的赋值与调用(现代写法:参数列表写 void)
void func(void) {}

int main(void)
{
    void (*p_func)(void) = func;
    p_func();
    return 0;
}
# -8[rbp]:唯一的局部指针 p_func
# main 里有 call,不是叶子函数,局部槽要真分栈
sub     rsp, 16

# 取函数入口地址(RIP 相对寻址,本文的 GCC 默认开启 PIE)
lea     rax, func[rip]
mov     QWORD PTR -8[rbp], rax

# 从槽里取出函数地址,再跳过去执行
mov     rax, QWORD PTR -8[rbp]
call    rax
mov     eax, 0

# leave 等价于 mov rsp, rbp; pop rbp
leave
ret

lea rax, func[rip] 把函数的入口地址装进 rax。这条指令用的是 RIP 相对寻址:在位置无关代码(PIE)里,函数符号的地址不写死在指令里,而是用下一条指令的地址(RIP)加上链接时定好的偏移算出来的,所以程序被加载到内存的哪个基址都能算对。后面的 call rax 则跳到 rax 里存的那个地址去执行。

开头的 sub rsp, 16 是在给局部变量腾栈空间。这里有个背景:叶子函数(不再调用别的函数)可以把局部变量放在 rsp 往下 128 字节的 red zone 里,不必去动栈指针;但 main 里调用了别的函数,它就不是叶子函数了,所以必须真的用这条 sub 往栈上分配出那块空间。

关掉 PIE(加 -fno-pie)时,同一段代码会变成 mov QWORD PTR [rbp-8], OFFSET FLAT:func,也就是把函数地址当成立即数直接写进指针槽。

示例里写的是 void (*p_func)(void) 而不是 void (*p_func)(),差别就在那对括号。到 C17 为止,空括号不算函数原型。函数原型就是写明了参数个数和类型的函数声明。没有原型,C 标准就不要求编译器检查调用时传了几个参数。所以 void f(); f(1); 在本文的 GCC 上能直接编译通过,连警告都没有。这种写法已被标准标记为过时(obsolescent)。从 C23 开始,空括号和 (void) 的意思相同,都表示没有参数。用 -std=c2x 按 C23 编译,同样的代码会直接报错。C++ 是另一套规则:void f() 就表示没有参数,别把两套规则混为一谈。

函数指针也占 8 字节的槽,槽里装的也是地址,只是这个地址指向代码段。通过它调用函数时,CPU 先从槽里取出这个地址,再跳到那里去执行函数,而不是把那里的内容当数据读进来。写成 (*p_func)() 和写成 p_func() 效果一样,编译出来都是同一条 call rax。

总结

把前面六个小节讲到的指针放在一起看,可以归纳出三条共同的规律:

结论一:不管哪一种指针变量,在本文的 x86-64 SysV 平台下都只占 8 字节的槽,槽里装的是一个地址。这个地址可能指向一个对象,指向数组里的某个元素,指向整个数组,指向另一个指针,也可能指向函数的入口。空指针是个特例,它的槽里装的是 0,表示哪儿也不指。指针数组不在这一条里,它是装着几个指针的数组。

结论二:解引用就是顺着地址一层层往下走,要走几层,看指针上有几颗星。*p 走一层就到了对象,**pp 走两层。函数指针不一样,通过它调用函数时,CPU 是跳到代码段去执行。

结论三:指针加 1 一次走几个字节,只由它指向的类型决定,跟指针自己占几个字节没有关系。int * 加一走 4 字节,int ** 加一走 8 字节,int (*)[3] 加一走 12 字节。

参考资料

[1] ISO/IEC 9899:2018 (C17), §6.2.5 ¶20 与 §6.7.6.1:指针类型是一种派生类型,其对象的值提供对被引用实体的引用。
[2] ISO/IEC 9899:2018, §6.5.3.2 ¶4:一元 * 运算符的结果是其操作数所指向的对象或函数(间接寻址)。
[3] ISO/IEC 9899:2018, §6.5.6 ¶7–8:指针与整数相加,结果按元素大小缩放。可形成指向数组末尾之后一个元素的指针,但解引用或越界为未定义行为。
[4] ISO/IEC 9899:2018, §6.5.2.1 ¶2:E1[E2] 与 *((E1)+(E2)) 含义相同。
[5] ISO/IEC 9899:2018, §6.3.2.1 ¶3–4:数组类型的左值在大多数表达式中衰减为指向首元素的指针(sizeof、& 操作数除外,作为数组初始化器的字符串字面量亦不衰减)。函数指示符转换为指向该函数的指针。
[6] Intel 64 and IA-32 Architectures Software Developer’s Manual, Vol. 2A:LEA(Load Effective Address)计算有效地址而不访问内存。CALL 将控制转移到操作数给出的地址。
[7] System V Application Binary Interface,AMD64 Architecture Processor Supplement(x86-64-psABI-2023-09-26):§3.1.2 数据表示(LP64 下对象指针与函数指针均为 8 字节、按 8 字节对齐);§3.2.1 寄存器(rbp 可选作帧基址);§3.2.2 栈帧(栈向低地址生长、调用点 16 字节对齐、rsp 之下 128 字节红区、叶子函数可整帧用红区)。
[8] GCC 在线文档:‑masm=intel(输出 Intel 语法)、‑fverbose-asm(汇编中附源行注释)、‑fno-stack-protector(关闭栈保护以聚焦指针机制)。
[9] ISO/IEC 9899:2018 (C17), §6.2.5 ¶19:void 类型的值集为空,是不能被补全的不完整类型。
[10] ISO/IEC 9899:2018 (C17), §6.3.2.3 ¶1、¶3:指向 void 的指针可与指向任何对象类型的指针互相转换;空指针常量与空指针。
[11] ISO/IEC 9899:2018 (C17), §6.7.6 ¶1、§6.7.6.1 ¶1 与 §6.7.6.2 ¶3:声明符的语法,以及指针声明符、数组声明符的含义,决定 int *pa[3] 是指针数组、int (*p)[3] 是数组指针。
[12] GCC 在线文档(C 扩展):对 void 指针与函数指针做加减法是 GNU 扩展,把 void 与函数的大小当作 1 字节处理,可用 -Wpointer-arith 告警。