目录
很多教材讲指针的时候都会说:指针是存地址的变量。
可是指针存的地址到底指的是什么?指针解引用又是什么意思?指针运算的步长是多少?
能把指针讲得通俗易懂的教材并不多,比如我上学时用的教材就是号称国内”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 标准把指针类型归为派生类型:它是从所指向的类型派生出来的,而指针里存的值就是所指对象或函数的地址。
按指向的目标分,常见的写法有下面几种:
int *p:对象指针,指向一个对象,槽里装的是那个对象的地址arr/&arr[0]:数组元素指针,数组名在表达式里衰减成指向首元素的指针int *pa[3]:指针数组,本质是一个数组,元素是 3 个指针int (*p)[3]:数组指针,本质是一个指针,指向整个数组int **pp:多级指针,指向指针的指针void *vp:泛型指针,指向类型未知的对象,读写数据前要先转换成具体类型的指针void (*fp)(void):函数指针,指向代码段的入口地址NULL:空指针常量,赋给指针后得到空指针,不指向任何对象或函数
区分这些类型,实际意义有以下两点:
- 声明决定了指针加 1 时地址前进多少字节;
- 它有几颗星,决定了要顺着地址解引用几次,才能拿到最终的数据。
下面按这个分类逐一探索其本质。
类型一:对象指针,指向一个具体的对象
对象指针是最基础的一类。
例如 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]。
从这个最小例子里可以看到以下两点事实:
- 指针变量和普通变量一样要占内存,最根本的区别在于槽里装的内容。在本文的测试环境(64bit)上,指针占 8 字节,int 占 4 字节。
- 被指向的普通变量装的是数据本身,比如 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
解引用就体现在最后两条指令上:
- 前一条把 p 槽里存的地址(也就是
&a)取进 rax。 - 后一条
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 里:
p_int++给地址加 4,p_char++只给地址加 1。- C 标准规定,指针加整数 N 是把指针向前移动 N 个元素,移动的字节数等于 N 乘以所指类型的大小。
本文的测试环境下 int 占 4 字节、char 占 1 字节。标准并不强制 int 必须是 4 字节,步长一律按所指类型的大小(sizeof(*p))来理解。
上述汇编代码展示的内存布局里的 -16、-20 只是当前作者测试环境下的排法,如果更换编译器版本此数值可能会改变。
回看整个类型一。指针和普通的 int 一样,自己占一个槽,也能被赋值、被读取。两者最根本的不同是,指针槽里存的数被当成地址来用。
从本文的例子里能看到,类型信息决定了三件事:
- 能不能解引用。只有指针才能用 * 解引用。解引用时,编译器先把指针槽里的地址读进寄存器,再生成
[rax]这种按地址访存的指令。 - 解引用时一次读写几个字节。这由所指类型决定,比如
int *按 4 字节(DWORD)读写,int **按 8 字节(QWORD)读写。 - 指针加 1 时地址前进几个字节。这也由所指类型决定,比如
int *加 4,char *加 1。
取地址则和类型无关。本文例子里不管取什么类型变量的地址,用的都是 lea。
图 1:p 的 8 字节槽里装的是 a 的地址
类型二:数组元素指针,数组名衰减出来的产物
常挂在嘴边的一句话是”数组名是指向首元素的常量指针”,这话并不严谨。
C 标准里数组是一个独立的派生类型,但它并不是指针类型。只是在大多数表达式里,数组类型的左值会按规则衰减成指向首元素的指针。
但在以下几种使用场景下不会衰减成指向首元素的指针:
- 数组是 sizeof 的操作数
- 数组是取地址运算符 & 的操作数
- 作为数组初始化器的字符串字面量。
因此 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 会生成三条指令,对应两次解引用:
- 先从 pp 自己的槽
-8[rbp]读出p的地址-24[rbp] - 再顺着这个地址读出
p槽里存的&a - 最后才把 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 告警。