V2.0
王道C++班级参考资料<br />——<br />C语言部分卷4指针<br/>节1指针基础<br/><br/>最新版本V2.0
<br>王道C++团队<br/>COPYRIGHT ⓒ 2021-2024. 王道版权所有概述前置知识点内存地址的概念地址值32位系统平台64位系统平台小端存储大端存储法指针和指针变量的概念图解指针变量(非常重要)指针变量的使用指针变量的声明指针变量的初始化野指针和空指针& 和 * 运算符基本数据类型的指针作为实参传递THE END
Gn!
指针是C语言当中,最复杂、功能最强大、最重要、最常用的语法特性之一。当然,指针也是最令C语言初学者恐惧的语言特性(甚至没有之一)。
但不管怎么样,鉴于指针的核心地位及其复杂性,我们决定将其分为三个章节进行详细介绍:首先是“指针基础”,随后探讨“指针与数组”,并在最后深入到“指针的高级应用”。
在这之间,我们还会穿插讲一下C语言非常常用的字符串、结构体等语法特性,而之所以穿插,当然是因为它们和指针关系非常紧密。
Gn!
为了更好的理解和学习指针,我们需要先对下面的基础概念知识进行复习巩固。
Gn!
相信每一位对C语言有一丢丢了解的同学,都听说过"内存地址"的概念。那么什么是内存地址呢?
什么是内存地址?
内存地址是(虚拟)内存空间中某个位置的唯一性标识。由于现代计算机的最小寻址单位是8位1个字节,所以我们可以直接认为,内存地址就是虚拟内存空间中某1个字节区域的唯一性标识。
和内存地址相对应的还有一个非常重要的概念:变量地址
什么是变量地址?
变量地址:变量地址就是变量所占内存空间的第一个字节的内存地址。
比如一个32位(4字节)的整数变量,那么这个变量的地址指的是这4字节中的第一个字节的地址。
C语言提供了专门的取地址运算符"&",它一般用于和一个变量名结合,如"&a",用于取变量a的内存地址。当然此时你得到的就是变量a的第一个字节的内存地址。
Gn!
内存地址是虚拟内存空间中某1个字节区域的唯一性标识,为了直观地描述这些地址,我们使用了"地址值"这个概念。在大多数情况下,"地址"和"地址值"可以被视为同一概念。
高地址和低地址的概念
在描述虚拟内存地址时,我们可以把虚拟内存空间想象成一个多单元(多字节)组成的数组,每个单元都有其唯一标号"索引",这个"索引值"就是其地址值。
所以地址值的取值范围就是[0, 最大地址]。这样我们就得到了"低地址"和"高地址"两个不同的概念:
低地址:位于内存取值范围的较低端,即接近0的地址。
高地址:相对于低地址而言,高地址指的是内存范围中接近最大地址的部分。
例如,假设有一个内存范围从地址0x1000到地址0x2000:
0x1000就是这个范围的低地址。
0x2000就是这个范围的高地址。
明确高、低地址的概念十分重要,比如:
我们描述虚拟内存空间,当我们说从"低地址到高地址",意味着从代码段到内核虚拟内存这样的内存排布。
堆(heap)通常从低地址开始向高地址增长,栈(stack)则从高地址开始向低地址增长。
那么对于一个具体的平台而言,虚拟内存空间的最大地址是什么呢?
实际上,虚拟内存空间的最大地址通常由平台的地址位数决定。目前主流的平台有两种:
32位系统平台
64位系统平台
Gn!
32位的系统架构:
地址总位数是32位,虚拟内存空间中存在232个可能的地址,也就是对应232个字节(4GB)的虚拟内存空间。这232个可能中:
最小的可能(低地址),所有位都是0,即00000000 00000000 00000000 00000000,即十六进制的0x00000000。
最大的可能(高地址),所有位都是1,即11111111 11111111 11111111 11111111,即十六进制的0xFFFFFFFF。
我们普遍使用十六进制来表示地址值,32位平台的地址值范围是,从0x00000000到0xFFFFFFFF,这个十六进制数的每一个取值就代表内存中的一个字节内存区域。
Gn!
64位的系统架构:
地址总位数是64位,虚拟内存空间中存在264个可能的地址,对应着极大的虚拟地址空间(理论上有16 EB,也就是224TB)。
然而,现代64位架构和操作系统并没有利用所有64位进行寻址,主要是由于:
当前的硬件无法支持如此大的物理内存。
也没有应用需要如此大的内存空间。
现代的64位操作系统,大多只实际使用48位来进行虚拟内存空间寻址。
注:
在现代的编程生产环境中,64位平台可能是更常见的选择,但在学习过程中,为了便于大家理解虚拟内存,简化地址值,我们会选择将代码运行在32位平台。
Gn!
搞清楚上述概念后,现在我们可以把虚拟内存空间简化看成一个数组,而地址值就是这个数组的索引下标,如下图所示:
如果你理解了上图,那么我就要提出一个新的问题了:
"数组"中要存储元素数据,每个存储单元是1个字节,需要存储1个字节的数据,那么要如何存储呢?
比如一个占32位(4个字节)的整型数据变量
int num = 10;
,在虚拟内存空间中该如何存储呢?我们都知道计算机中存储整数,采用的是有符号数补码的形式存储,变量num用补码形式表示是:
00000000 00000000 00000000 00001010
那么虚拟内存空间中存储num,就是按顺序从低地址到高地址存储这个补码吗?
当然不是,Intel、AMD等主流32、64位架构的CPU,在存储数据时,选择将此数据的最低有效位字节存储在低内存地址上,即"小端存储法"。
在采用小端存储时,1个字节的低有效位被存储在低地址上,也就是说num是按照下列格式从低地址到高地址存储的:
00001010 00000000 00000000 00000000(数据的低有效位存储在低地址端)
如果画图来描述的话就是:
此图描述了一个int类型变量:
它占用4个字节的内存空间,地址范围是0x004ffd6c ~ 0x004ffd6f
此变量的地址是0x004ffd6c
利用4个字节的存储空间,来存储10这个整数值,低地址存10(0x0A),其余全部存0。
Gn!
与小端存储法相对应的,就有了大端存储法。所谓大端存储法,指的是在存储数据时,选择将此数据的最低有效位字节存储在高内存地址上。
大端存储法则比较符合直觉,直接将数据按照高有效位到低有效位,存储在低地址到高地址当中。
大端存储法最常见的场景就是:网络传输数据时,使用大端序列来进行数据传输。
还有一些文件的格式也会采用大端序列来进行数据存储,比如某些图片或音频文件格式。
Gn!
理解上面知识点后,我们就可以引出指针的相关概念了。下面我们首先辨析一下
指针和指针变量
这一对很容易混淆的概念。在C语言中,指针和指针变量是截然不同的两个概念,下面我们详细讲解一下这两个概念以及它们的区别:
指针:
指针本质上就是一个地址,它指向虚拟内存空间的某个位置。
通常,合法的指针应当是一个变量地址,这样就可以通过该指针访问、操作此变量。
指针变量:
指针变量是一个存储指针(即内存地址值)的变量。
指针变量本身就是一个普通的变量,它在内存中也有自己的内存地址,此变量的值是其它变量的地址。
32位平台的指针变量占4个字节内存空间,64位平台的指针变量占用8个字节内存空间。
总之,一句话解释它们的区别:
指针就是地址,而指针变量是存储这个地址的变量。
你可以将指针想象为一个书的页码,而指针变量则是你手上的书签,你用它来记住那个页码。
在日常编程实践中,我们经常说“通过一个指针来操作该变量”,这里的“指针”实际上指的是“指针变量”。因为我们实际上是通过指针变量这个中介,来间接引用或操作另一个变量的内容。
但为了方便和简洁,人们通常会省略“变量”这个词,直接说“指针”。
一般来说,只要在具体的上下文中清楚其意义,在实际操作时确实不需要那么扣概念,将"指针"和"指针变量"混为一谈问题也不大。(但是在学习时,我们还是希望大家能理清这些概念的区别。)
Gn!
指针变量是一个存储了其余变量地址的变量,比如下图就描述了一个指针变量通过存储一个int变量地址指向了该int变量:
在这个图中:
int变量
num = 100;
:
它的地址是
0x004ffd6c
,二进制表示就是0000 0000 0100 1111 1111 1101 0110 1100
(32位)它存储的十进制整数100,由于小端存储法,在低地址上存储的是最低有效位。
指针变量
*p = &num
:
它的地址是
0x0097f960
它存储的就是变量num的地址值,由于小端存储法,从低地址到高地址存储的数据是
0110 1100 1111 1101 0100 1111 0000 0000
以上。
Gn!
前置知识点都搞清楚后,我们就可以开始从语法层面上接触学习指针了。下面我们就来学习一下指针的相关语法。
Gn!
一个指针变量的声明方式非常简单,语法如下:
xxxxxxxxxx
11数据类型 *指针名;
其中:
数据类型,表示此指针所指向的数据的数据类型。比如写int,就表示该指针指向一个int类型变量。
"*"不论在格式上紧跟数据类型,还是紧跟指针名,在语法上都是一样的,都是与指针名绑定的。也就是说:
指针变量声明的两种方式
xxxxxxxxxx
21int* p1; // 不推荐的方式
2int *p2; // 推荐的方式
这两种方式虽然在格式上不同,但语法上却都是表示一个"指向int类型变量的指针"。
为了体现格式和语法上的一致性,避免产生歧义,指针变量声明的语法,请在任何时候都将"*"紧贴指针名!!
指针名就是一个变量名,一个普通的标识符,星号不是指针名的一部分!!比如上述两个声明中,p1和p2就是指针名。
举例:
指针变量声明-代码示例
xxxxxxxxxx
51int *p; // 声明一个指向整型的指针变量p
2char *ch; // 声明一个指向字符的指针变量ch
3double *d; // 声明一个指向双精度浮点数的指针变量d
4int *p1, *p2; // 声明两个指向整型的指针变量p1和p2
5int* p3, p4; // 避免采取这种声明方式,因为这里只有p3是指针变量,p4是普通整数变量。容易产生歧义!!
实际上学习到这里,你可能就已经会感概:C语言的变量声明风格未免太奇葩了,那么为什么呢?
为什么C语言变量的声明风格如此奇葩?
一般而言,编程语言的变量声明都会采用:"数据类型 变量名;" 的格式。C语言虽然也不例外,但某些声明风格却显得非常特别,比如:
一些更好的声明风格展示-演示代码
xxxxxxxxxx
21int[5] arr; // 如果这样声明数组,int[5]是数据类型,arr是数组名。但C语言不支持此风格
2int* p; // 如果这样声明指针变量,int* 表示数据类型,p是指针名。但C语言更推荐*紧跟指针名
C语言这样的变量声明设计,主要有以下考虑:
C语言的设计哲学追求简洁、灵活。
C语言诞生时计算机的内存空间比较拮据,C语言的设计考虑了减小源文件大小。因此这样看似奇怪的声明语法,使得C语言可以一行声明多个类型不一致的变量。如:
1int a, *p, arr[5]; // 三个变量,类型都不一致。分别是整型a,int指针类型p以及int数组类型arr
然而,在现代编程实践中,源文件大小已不再是主要关注点,简洁也不再是程序员的主要目标。我们更重视代码的可读性和维护性,尤其在团队开发中。
为了更好的代码质量,现代编程中关于变量声明的建议如下:
尽量每行只声明一个变量,除非多个变量类型一致,作用相似。
建议将 * 紧跟变量名,如 "int *ptr;",以明确此变量为指针类型变量。
为变量提供有意义的注释,特别是当变量的名字无法完全描述其用途时。
使用描述性和有意义的变量名。
总之,虽然C语言的标准语法,为我们提供了很大的灵活性,但在现代编程实践中,清晰性和可读性是更关键的。
Gn!
如果一个指针变量定义在局部是局部变量,那么它不具有自动的初始化机制。此时一个仅具有声明的指针,可能会指向一个随机的内存地址,谁也无法确定该指针究竟指向何处。
这种指针就是C语言中大名鼎鼎(恶名昭著?)的"野指针"。使用野指针会引发未定义行为,所以指针变量在使用之前必须初始化。
指针变量的初始化在C语言中主要有以下几种常见形式:
初始化为某个变量的地址(使用取地址运算符&):
xxxxxxxxxx
11int *p = #
初始化为NULL: 明确将指针变量赋值为字面值NULL,是指针变量非常重要、非常常见的初始化形式之一!
NULL是指针类型的一种特殊字面值,在多数编译器平台上它相当于地址值0,而这个地址是不允许普通进程访问的。所以使用空指针操作内存地址,在大多数现代编译器平台上都会导致程序崩溃。
xxxxxxxxxx
11int *p = NULL;
当然,使用NULL字面值需要包含
<stddef.h>
<stdlib.h>
<stdio.h>
三个头文件其中之一。
初始化为另一个指针的值:
xxxxxxxxxx
21int *p1 = #
2int *p2 = p1; // p1,p2都指向同一个对象
第一种做法是比较常见,第二种做法在无法确定指针变量该指向什么地址时使用,第三种是用另一个指针给指针变量赋值,这样两个指针会指向同一个地址。
Gn!
野指针和空指针都是C语言中关于指针的核心概念。尽管它们都与指针有关,但两者有着本质的区别。以下是对这两个概念的详解及它们之间的差异:
空指针 (Null Pointer):
空指针是一个明确赋值为NULL的指针变量。
空指针指向的地址是普通进程无法访问的,因此任何试图通过空指针访问或修改数据的操作,都将引发未定义行为。在多数现代编译器平台中,一般都会导致程序崩溃。
空指针为指针变量明确提供了一个"无效的、不可用"的标志,只要将指针变量设置为NULL,该指针必然不可用于操作内存。
将指针初始化为空指针,是一种防范野指针的好办法,因为访问空指针虽然也是未定义行为,但现代编译器平台基本都选择程序崩溃作为后果。因为空指针可以视为指针不可用的一种明确标志。
野指针 (Dangling or Wild Pointer):
野指针是一个指向未知内存区域的指针。目前而言,有两种比较常见的情况:
未初始化的指针。此时指针指向一块随机的未知区域。
直接用一个整数初始化的指针。对于一般应用程序而言,这样的指针也被视为野指针。
....
野指针是很危险的,因为无法确定它指向的内存区域中存储了什么。操作这样的指针,可能引发不可预见的后果,如数据损坏或程序崩溃乃至于其它未定义行为。
总之,为了程序的安全和稳定,你应该养成以下习惯:
总是初始化指针变量。如果实在不知道指针初始化为什么地址,不妨初始化为NULL。
在使用指针操作内存之前,如果不确定是否为空指针,应进行判NULL处理,以避免程序崩溃。
一个指针指向的内存区域如果已经被释放销毁了,那么应该及时将指针设置为NULL,避免野指针。(在堆动态内存分配时使用)
Gn!
为了更加有效地操作和管理内存中的地址,C语言引入了指针概念,并为其提供了两个专用运算符:
"&"取地址运算符
为了获取变量的地址,C语言设计了"&"取地址运算符,它一般读作"取地址 / address of"。取地址运算符最常见的用途是为指针变量赋值,使指针变量指向某个特定的内存地址(一般是指向一个变量)。
例如:
取地址运算符给指针变量赋值-示例代码
xxxxxxxxxx
21int x = 10;
2int *p = &x; // 指针p指向变量x的内存地址
"*"解引用运算符
当我们已有一个指针,并想要访问或修改它所指向的对象时,C语言提供了"*"解引用运算符,它的读作比较多,你可以读作"解引用 / 取值 / dereference / value of"。通过解引用运算符,我们可以间接地操作指针所指向的对象。
例如:
解引用运算符操作指针指向的对象-示例代码
xxxxxxxxxx
51int x = 10;
2int *p = &x; // 指针p指向的对象就是变量x
3
4*p = 100; // 利用指针p解引用将变量x赋值为100
5printf("%d\n", *p); // 实际就是打印此时变量x的取值
程序执行输出的结果是:
100
不难发现:
当一个指针变量p指向一个变量 x,通过使用解引用运算符 * 与 p(即 *p),我们可以访问和操作存储在变量 x 中的值。这意味着,通过 *p,我们可以间接地读取或修改 x 的内容。
它们之间的区别是:
直接用变量名 x :直接访问变量x,只读取内存一次
用解引用指针变量 *p : 间接访问变量x,需要读取内存两次,先找到指针变量,再通过指针变量中存储的地址找到变量x
总的来说,取地址和解引用运算符经常被联合使用,从而实现了通过指针的各种间接操作。这为C语言提供了强大的灵活性和效率。
实际上,我们可以把"&" 和 "*"两个运算符看成一对逆运算的运算符。
注意事项:
利用取地址运算符给指针变量赋值时,要注意类型匹配。例如,一个int类型的变量的地址只能赋值给一个 "int* 类型"的指针变量。
不要尝试解引用一个未初始化的指针变量(野指针),这会导致未定义行为。
不要尝试解引用一个空指针,这会导致程序崩溃。实际开发中,除非你十分确信指针已经正常初始化且不是一个空指针,那么解引用指针前,应该对指针做判NULL处理。
基于以上注意事项,C程序员在处理指针类型时,往往会有一些固定的惯用法,参考下列代码:
对可能为空指针的指针做判NULL处理
xxxxxxxxxx
2412
3int main() {
4int* p = NULL; // 初始化为NULL的指针
5
6/*
7* ....
8* 这里有一些代码,可能会使得指针p指向一个实际的对象,而不是空指针
9* 所以指针p是否为空指针是不确定的
10* 在这种情况下,判NULL处理就是必然的
11* ....
12*/
13
14// 在解引用之前检查p是否为NULL
15if (p != NULL) {
16*p = 100; // 不是空指针,正常解引用,做一些操作
17printf("指针p指向的对象的值是 : %d\n", *p);
18}
19else {
20printf("指针p是一个空指针,无法解引用.\n");
21}
22
23return 0;
24}
以上。
C语言中的“对象”和"解引用"的概念(了解)
"对象"和"引用"都是C++中非常重要的概念,为了避免大家后续学习产生困惑。我们这里要专门讲一下C语言当中的这两个概念,这不是重点,大家了解一下就可以了。
我们先来讲对象:
在讨论解引用运算符时,我们引进了“对象”这一概念,并特别强调了“指针所指向的对象”。
那么什么是对象呢?
在C语言的语境中,术语“对象”被用来指代存储数据的一片内存区域。也就说:
指针指向的对象,就是指针指向的一片存储数据的内存区域。
从这个角度出发,基本数据类型、结构体、数组等变量都可以被称为对象。
比如,当我们有一个int*类型的指针指向一个int类型的变量时,我们可以认为这个指针指向了一个“对象”。
C语言中的对象和C++中的对象,是完全不同的两个概念,大家要注意区分。
再来看看解引用:
相信有些同学,在学习"解引用运算符*"时,可能会产生这样的疑问:
引用是什么?
什么是解引用?
首先,C语言是没有引用这个概念的,引用是C++中的概念。
所以这里的引用,其实就是汉语字面意义上的引用,表示对内存地址的"引用"。一个指针变量存储某个内存地址时,我们就可以说该指针“引用”了一个内存地址。
这样,“解引用”就容易理解了:
解引用操作就是从指针所引用的地址中取出或访问该地址上的值。
所以为了便于理解,大家也可以把"解引用运算符"称为"取值运算符"。(但解引用是更通用的称呼)
Gn!
在C语言中,参数传递的机制是值传递的,这意味着在一般情况下,我们无法通过函数修改实参的值。
但如果我就想要修改实参的取值呢?C语言提供了解决方案——将指针变量作为实参传递。
在这里,我们先考虑基本数据类型的指针作为实参传递。
首先看一段代码:
交换两个实参变量的取值-代码示例
xxxxxxxxxx
141void swap_value(int a, int b) {
2int temp = a;
3a = b;
4b = temp;
5}
6
7int main(void) {
8int a = 1;
9int b = 2;
10printf("调用交换函数之前,实参a = %d , b = %d\n", a, b);
11swap_value(a, b);
12printf("调用交换函数之后,实参a = %d , b = %d\n", a, b);
13return 0;
14}
这样一段代码,交换两个基本数据类型实参的取值,能够成功吗?
显然是不能成功的,因为C语言是值传递的,函数得到的只是实参的拷贝,在函数体内部交换的也只是实参的拷贝。
为了真正交换两个实参的值,我们可以将存储实参变量地址的指针传递给函数。这样,函数内部就可以使用这些地址来直接访问和修改实参。
代码示例如下:
利用指针通过函数交换实参变量的取值-代码示例
xxxxxxxxxx
191// int* 指针类型作为形参,表示调用函数需要传入指针变量,也就是需要传入地址
2void swap_poniter(int* a, int* b) {
3int temp = *a;
4*a = *b;
5*b = temp;
6}
7
8int main(void) {
9int a = 1;
10int b = 2;
11int *p_a = &a, *p_b = &b;
12printf("调用交换函数之前,实参a = %d , b = %d\n", a, b);
13swap_poniter(p_a, p_b);
14// 等价于
15//swap_poniter(&a, &b);
16printf("调用交换函数之前,实参a = %d , b = %d\n", a, b);
17
18return 0;
19}
这个交换就是可以成功的,这是因为通过传递指针,将a和b变量的地址传给了函数,这样函数体内部就可以跨栈帧修改局部变量。
注意,虽然这种方式使得局部变量可以被跨函数栈修改了,但这并不是函数得到了实参本身,不影响C语言的值传递机制。这只不过是函数得到了实参指针变量的拷贝,而拷贝指针和原指针指向同一个对象。
如下图所示:
将基本数据类型指针作为一个参数传递,实际上并不新鲜,因为在scanf函数中我们早就用过了。比如:
scanf函数键盘录入需要一个指针传参-代码示例
xxxxxxxxxx
81int i;
2int *p = &i;
3scanf("%d", &i);
4// 或者
5scanf("%d", p);
6// 下面两个写法都是错误的
7// scanf("%d", i);
8// scanf("%d", &p);
Rd!
小tips:
除了基本数据类型,C语言还允许数组作为实参传递。因为它比较特殊且和指针密切相关,我们留到下一小节"数组和指针"讲解。
同时,C语言也允许指针作为函数的返回值,同样与数组密切相关,所以也放在"数组和指针"中讲解。