王道C++班级参考资料
——
C语言部分卷4指针
节1指针基础

最新版本V2.0
王道C++团队
COPYRIGHT ⓒ 2021-2024. 王道版权所有

概述

Gn!

指针是C语言当中,最复杂、功能最强大、最重要、最常用的语法特性之一。当然,指针也是最令C语言初学者恐惧的语言特性(甚至没有之一)。

但不管怎么样,鉴于指针的核心地位及其复杂性,我们决定将其分为三个章节进行详细介绍:首先是“指针基础”,随后探讨“指针与数组”,并在最后深入到“指针的高级应用”。

在这之间,我们还会穿插讲一下C语言非常常用的字符串、结构体等语法特性,而之所以穿插,当然是因为它们和指针关系非常紧密。

前置知识点

Gn!

为了更好的理解和学习指针,我们需要先对下面的基础概念知识进行复习巩固。

内存地址的概念

Gn!

相信每一位对C语言有一丢丢了解的同学,都听说过"内存地址"的概念。那么什么是内存地址呢?

什么是内存地址?

内存地址是(虚拟)内存空间中某个位置的唯一性标识。由于现代计算机的最小寻址单位是8位1个字节,所以我们可以直接认为,内存地址就是虚拟内存空间中某1个字节区域的唯一性标识。

和内存地址相对应的还有一个非常重要的概念:变量地址

什么是变量地址?

变量地址:变量地址就是变量所占内存空间的第一个字节的内存地址。

比如一个32位(4字节)的整数变量,那么这个变量的地址指的是这4字节中的第一个字节的地址。

C语言提供了专门的取地址运算符"&",它一般用于和一个变量名结合,如"&a",用于取变量a的内存地址。当然此时你得到的就是变量a的第一个字节的内存地址。

地址值

Gn!

内存地址是虚拟内存空间中某1个字节区域的唯一性标识,为了直观地描述这些地址,我们使用了"地址值"这个概念。在大多数情况下,"地址"和"地址值"可以被视为同一概念。

高地址和低地址的概念

在描述虚拟内存地址时,我们可以把虚拟内存空间想象成一个多单元(多字节)组成的数组,每个单元都有其唯一标号"索引",这个"索引值"就是其地址值。

所以地址值的取值范围就是[0, 最大地址]。这样我们就得到了"低地址"和"高地址"两个不同的概念:

  1. 低地址:位于内存取值范围的较低端,即接近0的地址。

  2. 高地址:相对于低地址而言,高地址指的是内存范围中接近最大地址的部分。

例如,假设有一个内存范围从地址0x1000到地址0x2000:

  1. 0x1000就是这个范围的低地址。

  2. 0x2000就是这个范围的高地址。

明确高、低地址的概念十分重要,比如:

  1. 我们描述虚拟内存空间,当我们说从"低地址到高地址",意味着从代码段到内核虚拟内存这样的内存排布。

  2. 堆(heap)通常从低地址开始向高地址增长,栈(stack)则从高地址开始向低地址增长。

那么对于一个具体的平台而言,虚拟内存空间的最大地址是什么呢?

实际上,虚拟内存空间的最大地址通常由平台的地址位数决定。目前主流的平台有两种:

  1. 32位系统平台

  2. 64位系统平台

32位系统平台

Gn!

32位的系统架构:

地址总位数是32位,虚拟内存空间中存在232个可能的地址,也就是对应232个字节(4GB)的虚拟内存空间。这232个可能中:

  1. 最小的可能(低地址),所有位都是0,即00000000 00000000 00000000 00000000,即十六进制的0x00000000。

  2. 最大的可能(高地址),所有位都是1,即11111111 11111111 11111111 11111111,即十六进制的0xFFFFFFFF。

我们普遍使用十六进制来表示地址值,32位平台的地址值范围是,从0x00000000到0xFFFFFFFF,这个十六进制数的每一个取值就代表内存中的一个字节内存区域。

64位系统平台

Gn!

64位的系统架构:

地址总位数是64位,虚拟内存空间中存在264个可能的地址,对应着极大的虚拟地址空间(理论上有16 EB,也就是224TB)。

然而,现代64位架构和操作系统并没有利用所有64位进行寻址,主要是由于:

  1. 当前的硬件无法支持如此大的物理内存。

  2. 也没有应用需要如此大的内存空间。

现代的64位操作系统,大多只实际使用48位来进行虚拟内存空间寻址。

注:

在现代的编程生产环境中,64位平台可能是更常见的选择,但在学习过程中,为了便于大家理解虚拟内存,简化地址值,我们会选择将代码运行在32位平台。

小端存储

Gn!

搞清楚上述概念后,现在我们可以把虚拟内存空间简化看成一个数组,而地址值就是这个数组的索引下标,如下图所示:

32位虚拟内存空间数组-简化图

如果你理解了上图,那么我就要提出一个新的问题了:

"数组"中要存储元素数据,每个存储单元是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类型变量:

  1. 它占用4个字节的内存空间,地址范围是0x004ffd6c ~ 0x004ffd6f

  2. 此变量的地址是0x004ffd6c

  3. 利用4个字节的存储空间,来存储10这个整数值,低地址存10(0x0A),其余全部存0。

大端存储法

Gn!

与小端存储法相对应的,就有了大端存储法。所谓大端存储法,指的是在存储数据时,选择将此数据的最低有效位字节存储在高内存地址上。

大端存储法则比较符合直觉,直接将数据按照高有效位到低有效位,存储在低地址到高地址当中。

大端存储法最常见的场景就是:网络传输数据时,使用大端序列来进行数据传输。

还有一些文件的格式也会采用大端序列来进行数据存储,比如某些图片或音频文件格式。

指针和指针变量的概念

Gn!

理解上面知识点后,我们就可以引出指针的相关概念了。下面我们首先辨析一下指针和指针变量这一对很容易混淆的概念。

在C语言中,指针和指针变量是截然不同的两个概念,下面我们详细讲解一下这两个概念以及它们的区别:

  1. 指针:

    1. 指针本质上就是一个地址,它指向虚拟内存空间的某个位置。

    2. 通常,合法的指针应当是一个变量地址,这样就可以通过该指针访问、操作此变量。

  2. 指针变量:

    1. 指针变量是一个存储指针(即内存地址值)的变量。

    2. 指针变量本身就是一个普通的变量,它在内存中也有自己的内存地址,此变量的值是其它变量的地址。

    3. 32位平台的指针变量占4个字节内存空间,64位平台的指针变量占用8个字节内存空间。

总之,一句话解释它们的区别:

指针就是地址,而指针变量是存储这个地址的变量。

你可以将指针想象为一个书的页码,而指针变量则是你手上的书签,你用它来记住那个页码。

在日常编程实践中,我们经常说“通过一个指针来操作该变量”,这里的“指针”实际上指的是“指针变量”。因为我们实际上是通过指针变量这个中介,来间接引用或操作另一个变量的内容。

但为了方便和简洁,人们通常会省略“变量”这个词,直接说“指针”。

一般来说,只要在具体的上下文中清楚其意义,在实际操作时确实不需要那么扣概念,将"指针"和"指针变量"混为一谈问题也不大。(但是在学习时,我们还是希望大家能理清这些概念的区别。)

图解指针变量(非常重要)

Gn!

指针变量是一个存储了其余变量地址的变量,比如下图就描述了一个指针变量通过存储一个int变量地址指向了该int变量:

指针变量-示意图

在这个图中:

int变量num = 100;

  1. 它的地址是0x004ffd6c,二进制表示就是0000 0000 0100 1111 1111 1101 0110 1100(32位)

  2. 它存储的十进制整数100,由于小端存储法,在低地址上存储的是最低有效位。

指针变量*p = &num

  1. 它的地址是0x0097f960

  2. 它存储的就是变量num的地址值,由于小端存储法,从低地址到高地址存储的数据是0110 1100 1111 1101 0100 1111 0000 0000

以上。

指针变量的使用

Gn!

前置知识点都搞清楚后,我们就可以开始从语法层面上接触学习指针了。下面我们就来学习一下指针的相关语法。

指针变量的声明

Gn!

一个指针变量的声明方式非常简单,语法如下:

其中:

  1. 数据类型,表示此指针所指向的数据的数据类型。比如写int,就表示该指针指向一个int类型变量。

  2. "*"不论在格式上紧跟数据类型,还是紧跟指针名,在语法上都是一样的,都是与指针名绑定的。也就是说:

    指针变量声明的两种方式

    这两种方式虽然在格式上不同,但语法上却都是表示一个"指向int类型变量的指针"。

    为了体现格式和语法上的一致性,避免产生歧义,指针变量声明的语法,请在任何时候都将"*"紧贴指针名!!

  3. 指针名就是一个变量名,一个普通的标识符,星号不是指针名的一部分!!比如上述两个声明中,p1和p2就是指针名。

举例:

指针变量声明-代码示例

实际上学习到这里,你可能就已经会感概:C语言的变量声明风格未免太奇葩了,那么为什么呢?

为什么C语言变量的声明风格如此奇葩?

一般而言,编程语言的变量声明都会采用:"数据类型 变量名;" 的格式。C语言虽然也不例外,但某些声明风格却显得非常特别,比如:

一些更好的声明风格展示-演示代码

C语言这样的变量声明设计,主要有以下考虑:

  1. C语言的设计哲学追求简洁、灵活。

  2. C语言诞生时计算机的内存空间比较拮据,C语言的设计考虑了减小源文件大小。因此这样看似奇怪的声明语法,使得C语言可以一行声明多个类型不一致的变量。如:

然而,在现代编程实践中,源文件大小已不再是主要关注点,简洁也不再是程序员的主要目标。我们更重视代码的可读性和维护性,尤其在团队开发中。

为了更好的代码质量,现代编程中关于变量声明的建议如下:

  1. 尽量每行只声明一个变量,除非多个变量类型一致,作用相似。

  2. 建议将 * 紧跟变量名,如 "int *ptr;",以明确此变量为指针类型变量。

  3. 为变量提供有意义的注释,特别是当变量的名字无法完全描述其用途时。

  4. 使用描述性和有意义的变量名。

总之,虽然C语言的标准语法,为我们提供了很大的灵活性,但在现代编程实践中,清晰性和可读性是更关键的。

指针变量的初始化

Gn!

如果一个指针变量定义在局部是局部变量,那么它不具有自动的初始化机制。此时一个仅具有声明的指针,可能会指向一个随机的内存地址,谁也无法确定该指针究竟指向何处。

这种指针就是C语言中大名鼎鼎(恶名昭著?)的"野指针"使用野指针会引发未定义行为,所以指针变量在使用之前必须初始化。

指针变量的初始化在C语言中主要有以下几种常见形式:

  1. 初始化为某个变量的地址(使用取地址运算符&):

  2. 初始化为NULL 明确将指针变量赋值为字面值NULL,是指针变量非常重要、非常常见的初始化形式之一!

    NULL是指针类型的一种特殊字面值,在多数编译器平台上它相当于地址值0,而这个地址是不允许普通进程访问的。所以使用空指针操作内存地址,在大多数现代编译器平台上都会导致程序崩溃。

    当然,使用NULL字面值需要包含

    <stddef.h>

    <stdlib.h>

    <stdio.h>

    三个头文件其中之一。

  3. 初始化为另一个指针的值

第一种做法是比较常见,第二种做法在无法确定指针变量该指向什么地址时使用,第三种是用另一个指针给指针变量赋值,这样两个指针会指向同一个地址。

野指针和空指针

Gn!

野指针和空指针都是C语言中关于指针的核心概念。尽管它们都与指针有关,但两者有着本质的区别。以下是对这两个概念的详解及它们之间的差异:

  1. 空指针 (Null Pointer):

    1. 空指针是一个明确赋值为NULL的指针变量。

    2. 空指针指向的地址是普通进程无法访问的,因此任何试图通过空指针访问或修改数据的操作,都将引发未定义行为。在多数现代编译器平台中,一般都会导致程序崩溃。

    3. 空指针为指针变量明确提供了一个"无效的、不可用"的标志,只要将指针变量设置为NULL,该指针必然不可用于操作内存。

    4. 将指针初始化为空指针,是一种防范野指针的好办法,因为访问空指针虽然也是未定义行为,但现代编译器平台基本都选择程序崩溃作为后果。因为空指针可以视为指针不可用的一种明确标志。

  2. 野指针 (Dangling or Wild Pointer):

    1. 野指针是一个指向未知内存区域的指针。目前而言,有两种比较常见的情况:

      • 未初始化的指针。此时指针指向一块随机的未知区域。

      • 直接用一个整数初始化的指针。对于一般应用程序而言,这样的指针也被视为野指针。

      • ....

    2. 野指针是很危险的,因为无法确定它指向的内存区域中存储了什么。操作这样的指针,可能引发不可预见的后果,如数据损坏或程序崩溃乃至于其它未定义行为。

总之,为了程序的安全和稳定,你应该养成以下习惯:

  1. 总是初始化指针变量。如果实在不知道指针初始化为什么地址,不妨初始化为NULL。

  2. 在使用指针操作内存之前,如果不确定是否为空指针,应进行判NULL处理,以避免程序崩溃。

  3. 一个指针指向的内存区域如果已经被释放销毁了,那么应该及时将指针设置为NULL,避免野指针。(在堆动态内存分配时使用)

& 和 * 运算符

Gn!

为了更加有效地操作和管理内存中的地址,C语言引入了指针概念,并为其提供了两个专用运算符:

"&"取地址运算符

为了获取变量的地址,C语言设计了"&"取地址运算符,它一般读作"取地址 / address of"。取地址运算符最常见的用途是为指针变量赋值,使指针变量指向某个特定的内存地址(一般是指向一个变量)。

例如:

取地址运算符给指针变量赋值-示例代码

"*"解引用运算符

当我们已有一个指针,并想要访问或修改它所指向的对象时,C语言提供了"*"解引用运算符,它的读作比较多,你可以读作"解引用 / 取值 / dereference / value of"。通过解引用运算符,我们可以间接地操作指针所指向的对象。

例如:

解引用运算符操作指针指向的对象-示例代码

程序执行输出的结果是:

100

不难发现:

当一个指针变量p指向一个变量 x,通过使用解引用运算符 * 与 p(即 *p),我们可以访问和操作存储在变量 x 中的值。这意味着,通过 *p,我们可以间接地读取或修改 x 的内容。

它们之间的区别是:

直接用变量名 x :直接访问变量x,只读取内存一次

用解引用指针变量 *p : 间接访问变量x,需要读取内存两次,先找到指针变量,再通过指针变量中存储的地址找到变量x

总的来说,取地址和解引用运算符经常被联合使用,从而实现了通过指针的各种间接操作。这为C语言提供了强大的灵活性和效率。

实际上,我们可以把"&" 和 "*"两个运算符看成一对逆运算的运算符。

注意事项:

  1. 利用取地址运算符给指针变量赋值时,要注意类型匹配。例如,一个int类型的变量的地址只能赋值给一个 "int* 类型"的指针变量。

  2. 不要尝试解引用一个未初始化的指针变量(野指针),这会导致未定义行为。

  3. 不要尝试解引用一个空指针,这会导致程序崩溃。实际开发中,除非你十分确信指针已经正常初始化且不是一个空指针,那么解引用指针前,应该对指针做判NULL处理。

基于以上注意事项,C程序员在处理指针类型时,往往会有一些固定的惯用法,参考下列代码:

对可能为空指针的指针做判NULL处理

以上。

C语言中的“对象”和"解引用"的概念(了解)

"对象"和"引用"都是C++中非常重要的概念,为了避免大家后续学习产生困惑。我们这里要专门讲一下C语言当中的这两个概念,这不是重点,大家了解一下就可以了。

我们先来讲对象:

在讨论解引用运算符时,我们引进了“对象”这一概念,并特别强调了“指针所指向的对象”

那么什么是对象呢?

在C语言的语境中,术语“对象”被用来指代存储数据的一片内存区域。也就说:

  1. 指针指向的对象,就是指针指向的一片存储数据的内存区域。

  2. 从这个角度出发,基本数据类型、结构体、数组等变量都可以被称为对象。

比如,当我们有一个int*类型的指针指向一个int类型的变量时,我们可以认为这个指针指向了一个“对象”。

C语言中的对象和C++中的对象,是完全不同的两个概念,大家要注意区分。

再来看看解引用:

相信有些同学,在学习"解引用运算符*"时,可能会产生这样的疑问:

  1. 引用是什么?

  2. 什么是解引用?

首先,C语言是没有引用这个概念的,引用是C++中的概念。

所以这里的引用,其实就是汉语字面意义上的引用,表示对内存地址的"引用"。一个指针变量存储某个内存地址时,我们就可以说该指针“引用”了一个内存地址。

这样,“解引用”就容易理解了:

解引用操作就是从指针所引用的地址中取出或访问该地址上的值。

所以为了便于理解,大家也可以把"解引用运算符"称为"取值运算符"。(但解引用是更通用的称呼)

基本数据类型的指针作为实参传递

Gn!

在C语言中,参数传递的机制是值传递的,这意味着在一般情况下,我们无法通过函数修改实参的值。

但如果我就想要修改实参的取值呢?C语言提供了解决方案——将指针变量作为实参传递。

在这里,我们先考虑基本数据类型的指针作为实参传递。

首先看一段代码:

交换两个实参变量的取值-代码示例

这样一段代码,交换两个基本数据类型实参的取值,能够成功吗?

显然是不能成功的,因为C语言是值传递的,函数得到的只是实参的拷贝,在函数体内部交换的也只是实参的拷贝。

为了真正交换两个实参的值,我们可以将存储实参变量地址的指针传递给函数。这样,函数内部就可以使用这些地址来直接访问和修改实参。

代码示例如下:

利用指针通过函数交换实参变量的取值-代码示例

这个交换就是可以成功的,这是因为通过传递指针,将a和b变量的地址传给了函数,这样函数体内部就可以跨栈帧修改局部变量。

注意,虽然这种方式使得局部变量可以被跨函数栈修改了,但这并不是函数得到了实参本身,不影响C语言的值传递机制。这只不过是函数得到了实参指针变量的拷贝,而拷贝指针和原指针指向同一个对象。

如下图所示:

指针做实参仍然是值传递的-示意图

将基本数据类型指针作为一个参数传递,实际上并不新鲜,因为在scanf函数中我们早就用过了。比如:

scanf函数键盘录入需要一个指针传参-代码示例

Rd!

小tips:

除了基本数据类型,C语言还允许数组作为实参传递。因为它比较特殊且和指针密切相关,我们留到下一小节"数组和指针"讲解。

同时,C语言也允许指针作为函数的返回值,同样与数组密切相关,所以也放在"数组和指针"中讲解。

THE END