一、基础概念

1 变量

关于C语言的变量,在我的另一篇文章redis源码分析第一章有介绍,由于本文的主要内容是指针,因此对于C语言的基本数据类型,请参考上述链接文章中的内容提前了解。

2 C语言程序内存布局

了解C程序的内存布局,有助于从宏观上理解变量和常量在内存中的存储。

C程序的典型内存表示包括以下部分,分别是STACK(栈段),HEAP(堆段),BSS(以符号开头的块),DS(数据段)和TEXT(文本段):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
High Addresses ---> .----------------------.
| Environment |
|----------------------|
| | Functions and variable are declared
| STACK | on the stack.
base pointer -> | - - - - - - - - - - -|
| | |
| v |
: :
. . The stack grows down into unused space
. Empty . while the heap grows up.
. .
. . (other memory maps do occur here, such
. . as dynamic libraries, and different memory
: : allocate)
| ^ |
| | |
brk point -> | - - - - - - - - - - -| Dynamic memory is declared on the heap
| HEAP |
| |
|----------------------|
| BSS | Uninitialized data (BSS)
|----------------------|
| Data | Initialized data (DS)
|----------------------|
| Text | Binary code
Low Addresses ----> '----------------------'

栈段

  • 它位于较高的地址,与堆段的增长和收缩方向正好相反。
  • 函数的局部变量存在于栈上
  • 调用函数时,将在栈中创建一个栈帧。
  • 每个函数都有一个栈帧。
  • 栈帧包含函数的局部变量参数和返回值。
  • 栈包含一个LIFO结构。函数变量在调用时被压入栈,返回时将函数变量从栈弹出。
  • SP(栈指针)寄存器跟踪栈的顶部。

堆段

  • 用于在运行时分配内存。
  • 由内存管理函数(如malloc、calloc、free等)管理的堆区域,这些函数可以在内部使用brk和sbrk系统调用来调整其大小。
  • 堆区域由进程中的所有共享库和动态加载的模块共享。
  • 它在堆栈的相反方向上增长和收缩。

BSS:未初始化的数据块

  • 包含所有未初始化的全局和静态变量。
  • 此段中的所有变量都由零或者空指针初始化。
  • 程序加载器在加载程序时为BSS节分配内存。

DS:初始化的数据块

  • 包含显式初始化的全局变量和静态变量。
  • 此段的大小由程序源代码中值的大小决定,在运行时不会更改。
  • 它具有读写权限,因此可以在运行时更改此段的变量值。
  • 该段可进一步分为初始化只读区和初始化读写区。

TEXT

  • 该段包含已编译程序的二进制文件。
  • 该段是一个只读段,用于防止程序被意外修改。
  • 该段是可共享的,因此对于文本编辑器等频繁执行的程序,内存中只需要一个副本。

3 变量在内存中的存储

当定义一个新的变量后,会在内存中开辟一块空间进行保存,根据变量的类型不同,所占用的内存空间也不同。如:

1
int a=144;

整数144需要储存在内存中,我们将存储在内存中这一块内存叫做对象,对象可以储存一个或多个值,它占用真实的物理内存。

上述语句中,我们通过声明一个变量a的标识符来引用特定对象的内容,该声明也提供了存储在对象中的值。

在内存层面:

1
2
3
4
5
 地址	 值
0x600 90
0x601 00
0x602 00
0x603 00

由于int类型占用4字节内存,假设为它分配的起始地址为0x600,那么它所占用的内存空间为0x600~0x603这四个字节,它的值为0x00000090。

4 指针与普通变量

我们可以通过指针操作普通变量:

1
2
3
4
5
6
int main(){
int a=144;
int *ptr = &a;
printf("%d",*ptr);
return 0;
}

声明一个叫做ptr的指针变量,该指针变量指向int类型aa是内存中的值144的引用,因此,我们通过*操作符,即*ptr,可以获取到ptr所指向的a所引用的值144。

5 指针与数组

对于一个数组:

1
int a[3]={1,2,3};

数组名a本质上就是该数组首元素的地址,也就是说,a&a[0]都表示数组首元素的内存地址,通过如下程序验证:

1
2
3
4
5
6
int main(){
int a[3]={1,4,6};
printf("%p\n",a); // %p以16进制显示指针的值
printf("%p\n",&a[0]);
return 0;
}

可以看出二者的地址相同。对于这两个内存地址来说,它们都是常量,或者叫做值,即在程序运行过程中不会改变。可以通过将它们赋值给指针变量,然后通过修改指针变量来操作数组:

1
2
3
4
5
6
7
int main(){
int a[3]={1,4,6};
int *p = a;
printf("%d\n",*(p+1));
printf("%d\n",*(p+2));
return 0;
}

在这里,我们通过让指针变量p指向a的首元素的内存地址,然后通过对p进行加法操作来获取数组中的元素。由此可见对于指针变量的加1并不是单纯的值加1,而是增加一个存储单元,对于数组而言,这意味着p1后的地址是数组中下一个元素的地址,而不是下一个字节的地址。距离下一个存储单元是多少字节呢?这就是为什么必须声明指针所指向对象类型的原因之一,由于p指向的是一个int类型变量,我们就知道对p+1就相当于p+1*sizeof(int),即p+4字节。

数组与指针的关系很密切,实际上C语言标准在描述数组表示法时确实借助了指针。

在上述代码中:

1
2
a+3==&a[3];
*(a+2)==a[2];

可见C语言的灵活性。

在这里要注意,不要混淆*(a+2)和*a+2*的优先级要高于+,因此,*a+2相当于*(a)+2

在这里,顺带提两个问题,见如下代码:

1
2
3
4
5
6
int main(){
int a[3]={1,4,6};
printf("%d\n",a);
printf("%d\n",&a);
return 0;
}

请问,在上述代码中:

  1. 两个printf的输出结果是否相同?
  2. a&a是否等价?
  3. 如果等价,为什么等价;如果不等价,区别在哪里?

实际上,二者是有区别的。对于a,如上文所述,它实际上是a数组中首元素的地址,可以表示为&a[0],由于a[0]int类型,因此可以反推出&a[0]的类型为int *,即指向整型的指针。

对于&a来说,它实际上表示的是一个具有三个整型元素的数组的指针,即int (*)[3],但是此时&aa的值是相同的。我们通过下面的例子来验证这一点:

1
2
3
4
5
6
7
8
9
10
int main() {

int a[3] = {1, 2, 3};
printf("%d\n", a);
printf("%d\n", a + 1);
printf("%d\n", a + 2);
printf("%d\n", a + 3);
printf("%d\n", &a);
printf("%d\n", &a + 1);
}

输出的结果如下(数组内存的分配在每次运行时不一样):

1
2
3
4
5
6
2076001088	a,相当于&a[0],即a数组中首元素的地址
2076001092 a+1,相当于&a[1],即a数组中第二个元素的地址
2076001096 a+2,相当于&a[2],即a数组中第三个元素的地址
2076001100 a+3,相当于&a[3],即a数组中第四个元素的地址
2076001088 &a,是具有三个整型元素的数组的指针,它的首地址与a数组首元素地址是相同的
2076001100 &a+1,是具有三个整型元素的数组的指针的下一个元素的地址

可以看出aa+1a+1a+2a+2a+3都相差4字节,这是由于数组中的数据类型为int的缘故。

&a&a+1相差12字节,这是因为此时的数据类型为int (*)[3],最小单位是三个整型的数组,占用的内存为3*sizeof(int)

这就是前面提到指针加1的特殊之处,加1实际上需要根据指针对应的数据类型计算实际的内存地址。因此,可以声明如下指针代替&a

1
2
3
4
int (*p)[3] = &a;
// 这样声明后,此时下述操作是等价的:
&a==p;
&a+1==p;

6 指针与函数

在C语言中,函数也是一种类型,可以定义指向函数的指针。我们知道,指针变量的内存单元存放一个地址值,而函数指针存放的就是函数的入口地址(位于TEXT段,见上文:TEXT)。下面看一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
int add(int a,int b){
return a+b;
}

int main(void)
{ int sum;
int (*fp)(int,int) = add;

sum=fp(2,3);
printf("result:%d\n",sum);
return 0;
}

这里的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
2
3
int foo[5];  // foo是一个拥有5个int类型的数组
char *foo; // foo是一个指向char类型的指针
double foo(); // foo是一个返回类型为double的函数

但是随着声明的涉及越来越多,要确切地知道您在看什么就越来越困难了。尤其是在编程或者阅读其他人代码的过程中,你可能会遇到很多复杂的指针声明,比如:

1
char *(*(**foo[][8])())[];  // 什么鬼??

右左法则应运而生,它用于帮助程序员理解复杂的C声明。(尽管如此,还是不建议在实际编码中书写特别复杂的声明,因为这些代码很难以理解)

基本类型和派生类型

除了一个变量名之外,声明还由一种基本类型和零个或多个派生类型组成,理解它们之间的区别至关重要。

基本类型如下:

1
2
3
4
5
6
7
char	signed char	unsigned char	 
short unsigned short
int unsigned int
long unsigned long
float double void
long long unsigned long long long double
struct union enum

一个声明只能有一种基本类型,它总是在表达式的最左边。

派生类型有三个:

  • *,指向…的指针
  • [],数组
  • (),函数返回

声明优先级

和运算符优先级一样,声明同样遵守优先级顺序。

数组[]和函数返回()类型运算符的优先级高于指针*,因此,有如下的解析规则:

  • 始终以变量名开头

    foo 是...

  • 始终以基本类型结束

    foo 是... int类型

  • 中间部分是最复杂的部分,但可以用下面的规则来总结

    go right when you can, go left when you must,即尽可能地向右,当不能向右时,调转方向向左。

    不能向右的情况出现在:

    • 当遇到分组括号()
    • 当遇到语句结尾;
  • 为了符合中文阅读习惯,有时需要调整语句顺序

实践

我们通过一个简单的例子来应用上述规则

1
long **foo[7];

我们从变量名开始,遵循右左法则来分析。用红色显示注意力的焦点,用删除线表示已完成的部分:

  1. 以标识符为开头,以基础类型结尾

    long **foo[7];

    foo是...long类型

  2. 尽可能向右

    long **foo[7];

    foo是长度为7的数组...long类型

  3. 已走到右边的尽头,开始向左

    long **foo[7];

    foo是长度为7的数组,数组元素为指向long类型的指针

  4. 向左

    long **foo[7];

    foo是长度为7的数组,数组元素为指向long类型的指针的指针

对于更复杂的例子:

1
char *(*(**foo [][8])())[]

foo是一个以长度为8的数组为元素的数组,每个元素都是函数指针的指针,这个函数返回指向char的指针的数组的指针

2 开外挂

这么麻烦的事情,还是交给:https://cdecl.org/ 来做吧:(