c指针杂谈
一、基础概念
1 变量
关于C语言的变量,在我的另一篇文章redis源码分析第一章有介绍,由于本文的主要内容是指针,因此对于C语言的基本数据类型,请参考上述链接文章中的内容提前了解。
2 C语言程序内存布局
了解C程序的内存布局,有助于从宏观上理解变量和常量在内存中的存储。
C程序的典型内存表示包括以下部分,分别是STACK(栈段),HEAP(堆段),BSS(以符号开头的块),DS(数据段)和TEXT(文本段):
1 | High Addresses ---> .----------------------. |
栈段
- 它位于较高的地址,与堆段的增长和收缩方向正好相反。
- 函数的局部变量存在于栈上
- 调用函数时,将在栈中创建一个栈帧。
- 每个函数都有一个栈帧。
- 栈帧包含函数的局部变量参数和返回值。
- 栈包含一个LIFO结构。函数变量在调用时被压入栈,返回时将函数变量从栈弹出。
- SP(栈指针)寄存器跟踪栈的顶部。
堆段
- 用于在运行时分配内存。
- 由内存管理函数(如malloc、calloc、free等)管理的堆区域,这些函数可以在内部使用brk和sbrk系统调用来调整其大小。
- 堆区域由进程中的所有共享库和动态加载的模块共享。
- 它在堆栈的相反方向上增长和收缩。
BSS:未初始化的数据块
- 包含所有未初始化的全局和静态变量。
- 此段中的所有变量都由零或者空指针初始化。
- 程序加载器在加载程序时为BSS节分配内存。
DS:初始化的数据块
- 包含显式初始化的全局变量和静态变量。
- 此段的大小由程序源代码中值的大小决定,在运行时不会更改。
- 它具有读写权限,因此可以在运行时更改此段的变量值。
- 该段可进一步分为初始化只读区和初始化读写区。
TEXT
- 该段包含已编译程序的二进制文件。
- 该段是一个只读段,用于防止程序被意外修改。
- 该段是可共享的,因此对于文本编辑器等频繁执行的程序,内存中只需要一个副本。
3 变量在内存中的存储
当定义一个新的变量后,会在内存中开辟一块空间进行保存,根据变量的类型不同,所占用的内存空间也不同。如:
1 | int a=144; |
整数144需要储存在内存中,我们将存储在内存中这一块内存叫做对象,对象可以储存一个或多个值,它占用真实的物理内存。
上述语句中,我们通过声明一个变量a的标识符来引用特定对象的内容,该声明也提供了存储在对象中的值。
在内存层面:
1 | 地址 值 |
由于int
类型占用4字节内存,假设为它分配的起始地址为0x600,那么它所占用的内存空间为0x600~0x603这四个字节,它的值为0x00000090。
4 指针与普通变量
我们可以通过指针操作普通变量:
1 | int main(){ |
声明一个叫做ptr
的指针变量,该指针变量指向int
类型a
,a
是内存中的值144的引用,因此,我们通过*
操作符,即*ptr
,可以获取到ptr
所指向的a
所引用的值144。
5 指针与数组
对于一个数组:
1 | int a[3]={1,2,3}; |
数组名a
本质上就是该数组首元素的地址,也就是说,a
和&a[0]
都表示数组首元素的内存地址,通过如下程序验证:
1 | int main(){ |
可以看出二者的地址相同。对于这两个内存地址来说,它们都是常量,或者叫做值,即在程序运行过程中不会改变。可以通过将它们赋值给指针变量,然后通过修改指针变量来操作数组:
1 | int main(){ |
在这里,我们通过让指针变量p
指向a
的首元素的内存地址,然后通过对p
进行加法操作来获取数组中的元素。由此可见对于指针变量的加1
并不是单纯的值加1
,而是增加一个存储单元,对于数组而言,这意味着p
加1
后的地址是数组中下一个元素的地址,而不是下一个字节的地址。距离下一个存储单元是多少字节呢?这就是为什么必须声明指针所指向对象类型的原因之一,由于p
指向的是一个int
类型变量,我们就知道对p+1
就相当于p+1*sizeof(int)
,即p+4
字节。
数组与指针的关系很密切,实际上C语言标准在描述数组表示法时确实借助了指针。
在上述代码中:
1 | a+3==&a[3]; |
可见C语言的灵活性。
在这里要注意,不要混淆
*(a+2)和*a+2
,*
的优先级要高于+
,因此,*a+2
相当于*(a)+2
在这里,顺带提两个问题,见如下代码:
1 | int main(){ |
请问,在上述代码中:
- 两个printf的输出结果是否相同?
a
和&a
是否等价?- 如果等价,为什么等价;如果不等价,区别在哪里?
实际上,二者是有区别的。对于a
,如上文所述,它实际上是a
数组中首元素的地址,可以表示为&a[0]
,由于a[0]
是int
类型,因此可以反推出&a[0]
的类型为int *
,即指向整型的指针。
对于&a
来说,它实际上表示的是一个具有三个整型元素的数组的指针,即int (*)[3]
,但是此时&a
与a
的值是相同的。我们通过下面的例子来验证这一点:
1 | int main() { |
输出的结果如下(数组内存的分配在每次运行时不一样):
1 | 2076001088 a,相当于&a[0],即a数组中首元素的地址 |
可以看出a
与a+1
、a+1
与a+2
、a+2
与a+3
都相差4字节,这是由于数组中的数据类型为int
的缘故。
而&a
与&a+1
相差12字节,这是因为此时的数据类型为int (*)[3]
,最小单位是三个整型的数组,占用的内存为3*sizeof(int)
。
这就是前面提到指针加1的特殊之处,加1实际上需要根据指针对应的数据类型计算实际的内存地址。因此,可以声明如下指针代替&a
:
1 | int (*p)[3] = &a; |
6 指针与函数
在C语言中,函数也是一种类型,可以定义指向函数的指针。我们知道,指针变量的内存单元存放一个地址值,而函数指针存放的就是函数的入口地址(位于TEXT
段,见上文:TEXT)。下面看一个简单的例子:
1 | int add(int a,int b){ |
这里的int (*fp)(int,int)
声明了一个变量fp
为指针类型,它的右侧是一个函数声明,这个函数的参数为int,int
,返回值为int
,而add
正好符合这个声明,因此fp
可以指向add
。
与数组类似,函数变量在右值时自动转换成函数指针类型,所以可以直接赋给fp
,当然,你也可以显式使用:int (*fp)(int,int)=&add
,把函数add
先取地址再赋给fp
,就不需要自动类型转换了。
可以直接通过函数指针调用函数,如fp(2,3)
,也可以先用*fp
取出它所指的函数类型,再调用函数,即(*fp)(2,3)
。实际上函数调用运算符()
要求操作数是函数指针,所以fp(2,3)
是最直接的写法,而add(2,3)
或(*fp)(2,3)
则是把函数类型自动转换成函数指针然后做函数调用。
这里再简单介绍下void *
指针,void *
指针表示可以指向任何数据类型的通用指针,任意其它类型的指针也可以转换为通用指针。void *
指针与其它类型的指针之间可以隐式转换,而不必进行类型转换。void *
指针不能直接解引用,必须先转换成别的类型的指针再解引用。
C标准库函数malloc
就是这样的例子,它用于在堆空间动态分配内存,底层通过brk
系统调用向操作系统申请内存,它的函数原型如下:
1 | void *malloc(size_t size); |
这里的malloc
的参数size
表示要分配的字节数,如果分配失败则返回NULL
。由于malloc
函数不知道用户拿到这块内存要存放什么类型的数据,所以返回通用指针void *
,用户程序可以转换成其它类型的指针再访问这块内存。malloc
函数保证它返回的指针所指向的地址满足系统的对齐要求,例如在32位平台上返回的指针一定对齐到4字节边界,以保证用户程序把它转换成任何类型的指针都能使用。
同样,当void *
指针作为函数的参数时,表示该参数可以接受任意类型的输入指针。比如free
的函数原型:
1 | void free(void *ptr); |
由于事先不知道用户传入什么类型的指针来释放,所以定义为通用指针void *
,以接受任意类型的输入指针。
二、进阶攻略
1 右左法则
即使是新手程序员阅读简单的C声明也没有困难,例如:
1 | int foo[5]; // foo是一个拥有5个int类型的数组 |
但是随着声明的涉及越来越多,要确切地知道您在看什么就越来越困难了。尤其是在编程或者阅读其他人代码的过程中,你可能会遇到很多复杂的指针声明,比如:
1 | char *(*(**foo[][8])())[]; // 什么鬼?? |
右左法则应运而生,它用于帮助程序员理解复杂的C声明。(尽管如此,还是不建议在实际编码中书写特别复杂的声明,因为这些代码很难以理解)
基本类型和派生类型
除了一个变量名之外,声明还由一种基本类型和零个或多个派生类型组成,理解它们之间的区别至关重要。
基本类型如下:
1 | char signed char unsigned char |
一个声明只能有一种基本类型,它总是在表达式的最左边。
派生类型有三个:
*
,指向…的指针[]
,数组()
,函数返回
声明优先级
和运算符优先级一样,声明同样遵守优先级顺序。
数组[]
和函数返回()
类型运算符的优先级高于指针*
,因此,有如下的解析规则:
-
始终以变量名开头
foo 是...
-
始终以基本类型结束
foo 是... int类型
-
中间部分是最复杂的部分,但可以用下面的规则来总结
go right when you can, go left when you must
,即尽可能地向右,当不能向右时,调转方向向左。不能向右的情况出现在:
- 当遇到分组括号
()
时 - 当遇到语句结尾
;
时
- 当遇到分组括号
-
为了符合中文阅读习惯,有时需要调整语句顺序
实践
我们通过一个简单的例子来应用上述规则
1 | long **foo[7]; |
我们从变量名开始,遵循右左法则来分析。用红色显示注意力的焦点,用删除线表示已完成的部分:
-
以标识符为开头,以基础类型结尾
long **foo[7];
foo是...long类型
-
尽可能向右
long**foo[7];foo是长度为7的数组...long类型
-
已走到右边的尽头,开始向左
long**foo[7];foo是长度为7的数组,数组元素为指向long类型的指针
-
向左
long**foo[7];foo是长度为7的数组,数组元素为指向long类型的指针的指针
对于更复杂的例子:
1 | char *(*(**foo [][8])())[] |
foo是一个以长度为8的数组为元素的数组,每个元素都是函数指针的指针,这个函数返回指向char的指针的数组的指针
2 开外挂
这么麻烦的事情,还是交给:https://cdecl.org/ 来做吧:(