基本篇
什么是指针?
当提到指针,学过编程的我们第一反应:就是它让我从C入门到放弃…..说句实话,笔者在接触Linux之前,对它的态度亦是敬而远之,诸如”Segmentation fault”,”memory leak”这些令人抓狂的log是一切痛苦的根源,一个问题就能让初学C的我尝试解决大半天。但了解嵌入式相关的底层架构之后,笔者还是不由感叹,指针对于C来说,对于内存的访问确实高效!
在Google的搜索列表中,关于”C”, “指针”的结果总是与原理相关,偶然发现一个有趣的问题:指针为什么是C语言的精髓?
笔者借鉴以前在知乎上看到的一句话,非常值得思考:指针不是什么精髓,它只是一扇门,推开它后,就是一个世界。
指针的概念
指针是C/C++中用来指向内存地址的类型。
注意:这句话的两个关键词:指向、类型。
Q1: 为什么不说指针就是内存地址,而要多一个指向?
笔者认为“指针就是内存地址”的观点并不严谨,不然为何不称之为地址?
这里,指向表明它是一个过程,包含两个动作:
根据地址索引找到内存块中的栈/堆区;
访问该栈/堆区,进行数据的读/写;
Q2: 它属于基本数据类型吗?
指针不属于基本数据类型,C11将它专门划分为:**指针类型**。
指针的定义
指针
*p:表示内存地址中的的数据;指针变量
p:表示内存地址;
我们可以从下图形象地理解指针:

图中紫色地址保存的是指针p,红色地址保存的是变量var。而指针p本身的值就是var的地址,故表示:指针p指向变量var。
指针的赋值方式有两种操作(本质相同):
int *p = a; // `指针p`指向`变量a` (实质指向地址)
int p = &a; // 把`变量a`的地址给`指针变量p`
注意:这里int是指针的基类型。
Q3:基类型是干什么的?
基类型包含基本数据类型、结构体等,它的作用是声明该指针在读写数据时的地址区域。我们知道,程序通过指针的地址从内存中读取数据,但是读多少呢?这就需要对指针指定一个基类型,用来告诉程序”读取多少数据“。下面举例指针在字符串中的应用,这里我把基类型称之为步长。
char s[] = "Jenny is walking her dog.";
char *p = s;
printf("s: %s\n", p); // %s从字符串的起始地址开始读,直到遇到'\0'
for(int i = 0; i < strlen(s); i++){
printf("%c", *p); // *p从内存中获取字符,%c直接读*p
p++; // p每次走的步长即为基类型(char)的内存大小
}
通过上面的例子,相信读者对基类型能有基本的理解。
阐明了指针如何读/写其他数据的方式,那指针本身又是如何存储的呢?
Q4:指针本身分配内存吗?大小是多少?
值得一提的是,指针本身的数据类型为unsigned int,通过格式化中的转义字符可以验证。
char *p = str;
printf("%d", p); // 十进制—> 1651581740
printf("%p", p); // 16进制—> 0x7ffd6271232c
指针在程序段中表示内存地址,无论我们定义的基类型是char还是struct,指针本身所占的内存空间(具体大小参考下表)都是不变的,但这不代表它是基本数据类型。
指针所占内存空间取决于CPU的寻址位数。
| CPU寻址位数 | 指针占内存空间 |
|---|---|
| 16 | 2 bytes |
| 32 | 4 bytes |
| 64 | 8 bytes |
为什么需要指针?
学习了指针的基本概念,或许有朋友会疑惑:指针并不友好,我们为什么需要它呢?你看Python、Java这些高级语言都没有指针,对于内存管理机制不是也很好吗?
对比高级语言
其实Java、Python(包括C++)中也有类似指针一样访问内存的机制——引用。有引用就一定有对象,对象可以是任何实体类型(类、数组、函数等),其本质上也是指向内存地址,但请注意:引用只是一个傀儡,它不能被改变,只能被调用。具体可以从指针与引用的区别中体会:
- 引用必须有初始化,不分配内存空间,指针未必有初始化,但分配内存空间;
- 引用的对象不能为空,指针可以为空;
- 引用的对象不能改变,指针初始化后还能改变;
- “sizeof(引用)”=对象的大小,”sizeof(指针)”=指针本身大小;
- 引用保证了程序的类型(内存)安全;
这里额外提一下最后一点:引用在初始化时多了类型检查以保证内存安全,因此引用对象在调用时很少会出现”内存泄漏”等错误。
上面这些都是引用和指针的基本区别,其实第一点”不分配内存空间”就能推理出下面3点。从这些区别可以直观地感受到引用的局限性,相比之下,指针灵活很多,用户可以直接进行内存的管理分配,这就是为什么我们在嵌入式编程中(C)中需要指针。
指针的作用
笔者认为指针是非常重要的,单凭一句话不足以说服大家。所以,我们来探讨一下指针的作用:
1. 高效地共享内存数据
在小规模的程序中,数据的共享可以通过赋值(复制)来实现。但是当代码量达到一定的规模时,这样的方式会占用大量的内存,比如我们传递结构体、链表、三维数组等。有了指针,我们可以直接把内存地址告诉程序段,实现数据的共享,既节省了内存的消耗,又提高了运行效率。
2. 动态地分配内存
普遍情况下,程序在声明变量时都是显式地分配内存(静态内存分配)。而当程序无法判断所声明对象需要多少内存时,就需要用到指针,实现在运行时分配内存(动态内存分配)。注意:
- 静态内存分配:编译时分配,存放于**栈区**。对象—> 全局(静态)变量、局部静态变量;
- 动态内存分配:运行时分配,存放于**堆区**。对象—> 局部变量、动态内存分配(malloc)变量;
3. 字符串处理
在Java中,几乎所有的字符串的处理都可调用其库中的工具类,而在C/C++中,常见的字符串处理函数常并不多,如:
strlen(s); // 注意s为指针的话,此函数无效
strncpy(target, src, sizeof(src)); // 字符串的赋值
strncmp(str1, str2, len); // 字符串的比较
snprintf(s, sizeof(s), format ... ); // 字符串的格式化赋值
当我们需要对字符串作其他处理,就必须用到指针。通过指针访问内存区,我们可以直接在每个步长中处理单个字符(char),也可以进行遍历。例如,我们常用的链表结构,也是依赖于指针的帮助,无非是处理的步长从单个字符到单个链(结构体):
p++; // 字符串中的遍历方式
LNode_x = LNode_x->next; // 单链表中的遍历方式
4. 函数返回多个值
C语言规定:一个函数只能有一个返回值,我们只能通过return返回一个变量。
但是我们可以通过指针+结构体返回多个变量:
typedef struct Person{
int age;
char *name;
int gender;
}*Prs;
Prs *create_Per()
{
Prs *p = NULL;
p->name = (char*)malloc(sizeof(char) * 8);
... // 赋值
return p;
}
其实这里的多个变量,也可以理解为一个变量,只是我们定义了一个对象把多个变量封装起来。
在基本篇中,我们着重讲述了指针的几个基本问题:what, how, why。而作为C语言的开发人员,仅仅知晓这些肯定是完全不够的,更多的细节问题还需要读者们在平时写代码中自行总结和发现。记住:编程的学习一定是在发现问题中得到成长。
进阶篇
在接下来的进阶篇中,笔者将根据过往的经验总结一些指针在实际应用中的问题。
指针的操作
关于指针的操作,除了最基本的赋值与调用,在实际情况中还有许多相关的操作。例如,在函数传参中的调用、指针与指针变量的自增运算等。在阅读之前,笔者再次强调,指针是内存的访问,我们脑中必须要有内存单元的概念。
函数传参中的指针引用
首先,我们要清楚C语言关于的函数参数的一些概念:
- 形参(形式参数):函数定义中,它所声明的参数变量(没有实际数据);
void TakeParm(char *source, int num, char target);
- 实参(实际参数):函数被调用时,它在当前程序段中调用的其他实体变量;
TakeParm(str, 3, chr);
当函数被调用时,程序会给形参分配相应类型大小的内存,实参的值传递给形参。注意传参是赋值、单向的,因此形参的值不会影响实参的值。但请特别注意:当函数调用结束时,会立即释放形参的所分配的内存单元。
C语言的函数传参有两种方式:值传递、地址传递。
C++的函数传参有3种方式:值传递、地址传递、引用传递。
值传递
该传参方式是把变量、常量、数组作为函数参数,本质上是把实参的值复制到形参的存储单元。这意味着形参和实参在不同内存单元中存储相同的值,故称之为”值传递”。
void Sub(int a, int b)
{
int c = a;
a = b;
b = c;
printf("a=%d b=%d\n", a, b); // a=4 b=9
}
int main()
{
int m = 9, n = 4;
Sub(m, n);
printf("m=%d n=%d\n", m, n); // a=9 b=4
}
上面的例子中,我们验证了值传递的本质:值的复制。不过,我们也发现了形参在函数中能够改变值,但在函数调用完后,两个值又“恢复原样”了。前面说过,由于传参的单向性,形参是不会影响到实参的值,因此m, n两个值压根就没有变动。那么如何让形参在函数调用时影响到实参?
地址传递
顾名思义,地址传递的意思是把地址作为传参,这就需要用到指针。严格来说,地址传递的对象是数组/指针,参数是数组的首地址/指针的值。因此,实参到形参传递的是地址,他们访问的便是相同的内存空间。
void Sub(int *a, int *b)
{
int c = *a;
*a = *b;
*b = c;
printf("a=%d b=%d\n", *a, *b); // a=4 b=9
}
int main()
{
int m = 9, n = 4;
Sub(&m, &n);
printf("m=%d n=%d\n", m, n); // a=4 b=9
}
与值传递对比,实参和形参都有不同。实参中,&代表取地址符,即把m和n的地址作为参数传给形参,注意:形参本身获取的是地址,等同于:
a = &m;
b = &n;
这样,指针变量a, b是m, n的地址,则对指针*a, *b的操作也就是对m, n变量的操作。所以,在函数调用结束后,实参得到了改变。
引用传递
再次强调,C语言没有引用类型。关于引用,我们在对比高级语言中介绍了它的特点(不多赘述),它的取地址符&。引用传递的本质其实也是通过指针来实现的,以指针的方式实现值传递。
void Sub(int &a, int &b) // 形参的格式与值传递不同
{
int c = a;
a = b;
b = c;
printf("a=%d b=%d\n", a, b); // a=4 b=9
}
int main()
{
int m = 9, n = 4;
Sub(m, n);
printf("m=%d n=%d\n", m, n); // a=9 b=4
}
上面的例子,如果使用gcc编译一定会报错,而使用g++会执行通过。
了解原理之后,我们做一个总结:
- 如果形参和实参都是基本数据类型,就是值传递;
- 如果实参是形参的指针,就是地址传递;
- 如果形参为引用类型
&x,就是引用类型;
*(p++) or (*p++) ?
关于指针的操作,前面提到过还有自增/减的操作。在基本变量中,a++等于a=a+1,那么,在指针里,*p++等于*p=*p+1吗?
正常情况下,++优先级高于*。
指针自增操作:
| 表达式 | 含义 |
|---|---|
| *p++ / *(p++) | ++在后,故先取值*p,再把指针变量p++(地址增加) |
| (*p)++ | *p为整体,故先取值*p,再使*p的值++ |
| *++p / *(++p) | ++在前,故先把指针变量p++,再取值*p |
| ++*p | *p为整体,故先使*p的值++,再取*p的值 |
注意:第一行中的*p++,*与p是分离的。
可能有朋友看到这么绕的表达式,觉得脑子都要搞糊涂了….其实我们通过理解,根本不需要去记这些表达式的含义:
- 当指针被括号括起来时,我们认为
(*p)就是一个变量,把它当做a++处理就完事了; - 当指针没有被括起来(
*与p分离)时,我们认为p++就是地址的自增操作,然后再通过*取值;
带着这样的思路,我们再回过头看这张表,是不是瞬间觉得简单易懂?
指针的状态
有朋友要问了,指针的状态不就是:NULL、存在地址和释放吗?这没啥好讨论的吧。如果是简单的使用,就记住这3个状态是没问题的,但既然是讲指针,笔者还是希望把深层的原理阐明清楚。
NULL和空指针
Q5:你能区分NULL指针、空指针、零指针吗?
先把一些相关的概念问题罗列一下:
- void*类型指针:通用变体型指针,可以不加转换直接赋值给其他指针;
- 空指针常量:C标准中,值为0的整型常量/强制转为
void类型的表达式; - 空指针:
p=0,不指向任何实际对象/函数的指针; - NULL指针:标准的宏定义,用来表示空指针常量;
#define NULL ((void*) 0)
- 零指针:
*p=0,值为0的指针,没有存储任何地址的指针,类型可以是*void,*int,*char等;
综上,我们总结了一些平时几乎没有见到的各种指针状态,从应用角度来说,这些思维的区别几乎可以省略,因为它们都有一个特征:都可以转化为*void类型,说明了它们本身的存在是不依赖于实际对象。强调一下,空指针包括NULL指针和零指针。
野指针
- 野指针:未初始化的指针;
- 悬垂指针:从栈释放/删除一个对象后,指针仍然会指向该地址;
在C语言中,指针是允许不被初始化,但是往往这样开放的规则会带来极大的危害。所有未初始化的指针都可以被称为野指针,直到该指针被赋予一个实际对象的内存地址。而在指针的释放机制中,free(p)释放的是指针指向的那一段内存空间,并非指针本身,如果悬垂指针在后续继续被调用,那它就是一个指向垃圾内存的指针,这样的指针是很容易使程序运行致崩溃。
对于上述这些情况,我们能够最恰当的解决方式是全部赋予空指针常量:
char *p = NULL;
...
free(p);
p = NULL;