不安分的指针



基本篇


什么是指针?


当提到指针,学过编程的我们第一反应:就是它让我从C入门到放弃…..说句实话,笔者在接触Linux之前,对它的态度亦是敬而远之,诸如”Segmentation fault”,”memory leak”这些令人抓狂的log是一切痛苦的根源,一个问题就能让初学C的我尝试解决大半天。但了解嵌入式相关的底层架构之后,笔者还是不由感叹,指针对于C来说,对于内存的访问确实高效!

在Google的搜索列表中,关于”C”, “指针”的结果总是与原理相关,偶然发现一个有趣的问题:指针为什么是C语言的精髓?

笔者借鉴以前在知乎上看到的一句话,非常值得思考:指针不是什么精髓,它只是一扇门,推开它后,就是一个世界

指针的概念

指针是C/C++中用来指向内存地址的类型

注意:这句话的两个关键词:指向、类型。

Q1: 为什么不说指针就是内存地址,而要多一个指向

笔者认为“指针就是内存地址”的观点并不严谨,不然为何不称之为地址?
这里,指向表明它是一个过程,包含两个动作:

  1. 根据地址索引找到内存块中的栈/堆区;

  2. 访问该栈/堆区,进行数据的读/写;

Q2: 它属于基本数据类型吗?

指针不属于基本数据类型,C11将它专门划分为:**指针类型**。

指针的定义

  • 指针*p:表示内存地址中的的数据;

  • 指针变量p:表示内存地址;

我们可以从下图形象地理解指针:

poniter

图中紫色地址保存的是指针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
}

与值传递对比,实参和形参都有不同。实参中,&代表取地址符,即把mn的地址作为参数传给形参,注意:形参本身获取的是地址,等同于:

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;

文章作者: Twyz.Liu
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Twyz.Liu !
  目录