指针的前世今生

在如今国内大多数院校用来入门的编程语言中,C语言无疑是当之无愧的霸主。一本谭浩强的《C程序设计》就让一大批新手困在i++ + ++i + i++的泥潭里纠结,而C语言的灵魂,也是最“臭名昭著”的概念——指针就更让人困惑了。错误使用指针会造成一堆空指针,野指针,给程序制造了一堆BUG。那么为了给诸位搭建一个指针的学习框架,规避指针造成的BUG,今天就让我们深入了解指针,了解它的前世今生。


1. 什么是指针?

维基百科对指针的介绍如下:

在计算机科学中,指针(英语:Pointer),是编程语言中的一类数据类型及其对象或变量,用来表示或存储一个存储器地址,这个地址的值直接指向(points to)存在该地址的对象的值。

看不懂?不要紧,《计算机组成原理》能帮你。当然,我也没深入了解计组,毕竟我不是计科院出身,只是业余学习网络安全的时候对此有一些微不足道的了解。以下,我将对内存的部分做一个“摘要”:

1.1. 为什么需要内存?

首先,我们要了解一个概念:CPU缓存读写速度 > 内存读写速度 > 硬盘读写速度。所以为了加快程序执行速度,在启动时会把程序的二进制文件从硬盘中读取到内存中,并且给程序内的变量和数据分配不同的内存空间,譬如堆区、栈区、数据区等。具体情况可以参考知乎文章《什么是堆?什么是栈?他们之间有什么区别和联系?》。这就解释了为什么要使用内存,以及为什么同时运行很多应用需要大内存。

程序在内存里

当然,在操作系统中如果消耗的内存超过了物理内存大小,则会把硬盘划分的一块空间作为虚拟内存(譬如LInux系统的swap分区),将一些短期不用的内存数据转移到虚拟内存中。那么代价是什么呢?前面提到,硬盘读写速度低于内存,低多少呢?有的人可能没概念,举个例子(以下均为典型值):家用机械硬盘的读写速度在 100MB/s - 300MB/s、家用SATA固态的读写速度在 300MB/s - 600MB/s、家用PCIe 3.0 NVMe固态的读写速度在 1000MB/s - 3000MB/s、目前最新的PCIe 4.0 NVMe固态的读写速度在 5000MB/s - 7000MB/s。而已经普及的DDR4内存条的读写速度是多少呢?单通道内存在 25GB/s,如果组成双通道就是 50GB/s,更遑论还有四通道,甚至DDR5已经面世销售了。另外提一嘴,CPU一般有三级缓存,其中最慢的第三级缓存读写也有 200GB/s所以如果内存使用超限了,部分内存数据转入虚拟内存,会严重影响程序运行速度。以上也解释了为何常用软件推荐放入固态中,因为读写更快有助于缩短加载和数据交互时间,不过实际上也看程序的优化情况。

那么内存是可以无限拓展的吗?非也。可以参考内存芯片生产厂商英睿达的文章《32/64位系统支持多大内存》,32位系统最大支持4GB内存,超出的部分无法识别;64位系统理论上最大支持16384PB内存,但是实际上由于CPU最大寻址空间仅为1TB、操作系统、主板硬件等各方面的限制,实际可使用的内存远低于这个数值,另外大容量内存的高昂售价也成为了一道门槛。

经过以上的介绍,相信你对宏观上的内存已经有了初步的了解,那么在微观的电路层面,内存又是什么样子呢?

1.2. 内存的本质

RAM 存储器可以进一步分为静态随机存取存储器(SRAM)和动态随机存取存储器(DRAM)两大类。

以上信息来自维基百科《随机存取存储器》,而单片机里常见的SRAM和电脑里的DRAM就是我们日常生活中能看到的内存了。

国产长鑫内存颗粒

如《内存是怎么制作的?》一文所述,其本质就是一个个晶体管和电容组成的阵列,通过晶体管控制电容充放电实现1/0的转换,大量的这种结构封装后就成了我们在上图看到的那种内存颗粒(即下文提到的Chip)。

当然,以上的内容不是本次重点,故不深入。参考这两篇文章《内存条的物理结构分析【转载】》、《内存条物理结构分析》所述:

从内存控制器到内存颗粒内部逻辑,笼统上讲从大到小为: Channel(插槽) -> DIMM(内存条) -> Rank(面) -> Chip(内存颗粒) -> Bank(颗粒层) -> Cell(存储单元,row/column由行列两个数值决定)

我们可以把DIMM作为一个内存条实体,我们知道一个内存条会有两个面,高端的内存条,两个面都有内存颗粒。所以我们把每个面叫做一个Rank,也就是说一个内存条会存在Rank0和Rank1。

拿Rank0举例,上面有8个黑色颗粒,我们把每个黑色颗粒叫做chip。再向微观走,就是一个chip里面会有8个bank。每个bank就是数据存储的实体,这些bank就相当于一个二维矩阵,只要声明了column和row就可以从每个bank中取出8bit的数据。

现在我们可以从物理层面找到我们所需的数据;那么在系统和程序中,我们该如何找到内存中指定的数据呢?答案很简单,映射,在知乎的《内存是怎么映射到物理地址空间的?内存是连续分布的吗?》一文中对物理地址的映射做了一个简单的介绍。对于嵌入式开发者应该很熟悉“映射”这个概念,毕竟不论是RAM还是ROM,都是通过映射被我们的程序所调用,而映射后的地址,可以认为就是指针所需要以及使用的值。当然,实际在操作系统中还有进程、堆、栈之类的因素影响,对物理意义的内存硬件进行了更高一层的抽象和封装,所以程序实际使用的指针和物理地址的映射不能完全等同

1.3. 指针是什么?

经过以上内容的拓展,想必至此你已对内存有了初步的了解,大概有了一个知识框架,那么接下来我们据此对指针做一个概括和总结:

指针就是对物理内存地址的封装,为程序员调用内存提供了一个中间层,而无需再去考虑硬件层面的问题。让代码具有更好的可阅读性的同时,不至于有太多性能损失。

Linux奉行“一切皆文件”的设计哲学,不论软件数据还是外部硬件,在系统中一律视为文件进行操作,我称其泛文件;指针也有这种风格,将一切变量、数据等都视为内存中的地址,构成了泛地址理念。而泛地址理念赋予指针的权能,将在之后的部分显露冰山一角。

1.4. 拓展

这里附赠几篇在搜索过程中偶然发现的不错的资料,尤其是前两篇文章。通读文章,即可大致了解为什么有的语言不需要编译,可以跨平台,但是效率降低;而有的语言不可以跨平台,需要编译后运行,但是效率更高。还可以了解CPU执行程序的本质。在此列出希望能加深诸位对于程序运行本质的理解:

实在不愿意花时间读我也可以做一个总结:

  • CPU是如何执行程序的 CPU执行程序主要步骤为:取指 -> 译码 -> 执行。C、C++要想代码运行起来,有一个编译的步骤,而编译就是将代码翻译成汇编指令。CPU的取指阶段就是从读入内存的程序文件内取出汇编指令;译码过程将取出的汇编指令翻译成CPU真正能执行的由 0 和 1 组成的机器语言;最后执行这些指令。还有其他步骤,这里不再赘述,循环这个流程,依次执行程序内的指令,最终实现程序想表达的效果。由于CPU的架构不同,所以指令集不同,对应着不同的汇编语言和不同的机器码。故而程序不能直接跨平台运行,需要交叉编译。

  • 编译型语言跟解释型语言的区别 C、C++经过编译后才能运行,而且在不同的系统和平台上还要再次编译才能运行;而诸如Python、Java之类的语言,号称能实现良好的跨平台特性,那么是怎么做到的呢?很简单,抽象和封装。在硬件架构、操作系统之上再封装一层解释器,这个解释器在不同平台上有各自的实现,提供了相同的功能,所以写出来的代码在不同的平台都能完成预期的功能。然而没有什么东西是完美的,你所享受的一切早已暗中标好价格。C、C++等编译型语言的翻译过程在编译时就完成了,所以运行时具有更高的效率;而Python和Java之流的解释型语言,由于在运行时需要实时翻译,所以效率有所下降 (虽然大多数时候程序员才是程序的瓶颈)

最后再给一个视频,从汇编的角度了解《CPU眼里的:指针 | 万物皆“指针”

2. 指针怎么用

网络上随处可见指针的教程,本节仅简要讲述指针的用法,如欲深入参考下面两篇文章足矣:

2.1. 指针入门

指针是什么?首先,它是一个值,这个值代表一个内存地址,因此指针相当于指向某个内存地址的路标。

声明指针的格式:

1
2
3
4
//数据类型* 变量名;
int* int_ptr;
char* char_ptr;
float* float_ptr;

其中变量名int_ptr是一个内存地址;而由于int_ptr未进行初始化也没有指向有效地址,此时它是一个野指针*int_ptr是该地址存放的垃圾值。

如果同一行声明两个指针变量,那么需要写成下面这样:

1
2
3
4
5
// 正确
int * foo, * bar;

// 错误
int* foo, bar;

指针也可以指向指针,此时格式如下:

1
int** foo;

2.2. 运算符

2.2.1. *运算符

NULL在 C 语言中是一个常量,表示地址为0的内存空间,这个地址是无法使用的,读写该地址会报错。 为了防止读写未初始化的指针变量,可以养成习惯,将未初始化的指针变量设为NULL

*这个符号除了表示指针以外,还可以作为运算符,用来取出指针变量所指向的内存地址里面的值

1
2
3
4
int* int_ptr = NULL;
int x = 1;
int_ptr = &x;
cout << *int_ptr << endl;

上面的代码中,第1行声明了一个指针变量int_ptr,并初始化为NULL空指针,从而避免其成为野指针;第2行声明整型变量x并定义值为1;第3行令指针变量int_ptr指向整型变量x的地址;第4行用*运算符取出此时int_ptr指向的地址存放的值,并打印输出,结果为1

2.2.2. &运算符

&运算符用来取出一个变量所在的内存地址。

1
2
int x = 1;
printf("x的内存地址是:%p\n", &x);

上面示例中,x是一个整数变量,&x就是x的值所在的内存地址。printf()%p是内存地址的占位符,可以打印出内存地址。

2.2.3. 辨析

&运算符与*运算符互为逆运算,下面的表达式总是成立。

1
2
3
int i = 1;

if (i == *(&i)) // 正确

2.3. 指针进阶

接下来,指针会向你展示它通过泛地址获得的莫大权能。

2.3.1. 低内存消耗传输大变量

假设你从内存中一次性读取了一个1GB的文件,哪么存放这些数据的变量也会消耗1GB的内存。姑且不论申请堆区内存和读取文件的部分,假如你需要写一个函数处理这些文件,如果不用指针的话,调用函数时会在内存中再拷贝一份数据成为函数内的临时变量,而这份拷贝同样会消耗1GB内存。如此庞大的内存消耗,作为存放在栈区的临时变量无疑会塞满栈区,并且因为塞不下而导致程序出错;哪怕没有出错,这么庞大的资源消耗也表明这是一个毫无优化的程序,用户体验极差。

虽然实际工程中会通过分块读写文件以降低资源消耗,但不可否认,没了指针哪怕分块读写都会消耗双倍内存。使用指针作为函数参数,可以让函数直接操作指针地址存放的数据,这种参数传递方式会在第三节进行介绍,名为指针传递

2.3.2. 函数指针

将一切变量、数据等都视为内存中的地址。

在第一节第三小节提到,指针将一切内容都视作内存中的地址,我们可以利用指针的这种特性控制所需要的一切,甚至包括函数!此内容我已在过去的一篇博客中有过讲解,详见《什么是函数指针》。

2.3.3. const修饰指针

上面告诉我们,连函数都能成为指针,何况其他变量、数据之类的,甚至我们结合结构体和函数指针,能在C语言中实现伪面向对象!指针如此强大,必然需要手段加以约束,而const关键字就是其中之一。

它是定义只读变量的关键字。

**只读表示该变量不允许修改;变量表示其依然是变量,不算常量。**这是C对于const关键字的解释,而C++会将其视作常量。 详细内容参阅《C语言const详解》、《C语言const的用法详解》、《C++ const 关键字小结》。

const离变量名近就是用来修饰指针变量的,离变量名远就是用来修饰指针指向的数据,如果近的和远的都有,那么就同时修饰指针变量以及它指向的数据。

const修饰指针有三种效果:指向的地址可变,但该地址存放的内容不可变;地址不可变但内容可变;地址和内容都不可变。详见《const修饰指针的三种效果》。

2.4. 小结

指针的使用及其灵活,本节仅展现一二。但相信经过本节的阅读,你对指针的认识已经不算浅薄了,那么下节将讲述为什么我们需要指针。

3. 为什么需要指针

在电子设备诞生的伊始,设备的内存极小,哪怕是到了现在,一枚ESP32-C3单片机的内存也才内置不过400 KB SRAM。当时的程序员们可以使用汇编直接控制寄存器,可是后来当内存越来越大,直接控制寄存器变得越来越麻烦,于是封装了“指针”这个抽象的概念用以进行间接寻址。指针让程序员得以从硬件中解放出来,却没有剥夺程序员们灵活自由控制内存的权力。

但是如前文所说的那句话“你所享受的一切早已暗中标好价格”,对于一个愚蠢的开发者而言,掌握如此大的权力,可能会让他有意无意犯下更严重的错误,譬如**用指针在堆内申请内存却忘了释放,造成内存泄漏;访问野指针造成预料之外的行为;访问空指针导致程序报错;**等等。因此有的语言宣传没有指针,并且作为优点,这也是有其考量的。毕竟对于部分程序员而言,专注于业务代码的开发,而不是纠结于内存的垃圾回收,是更舒心的。所以C++也紧随潮流添加了 智能指针(现代 C++) 用于帮助确保程序不会出现内存和资源泄漏。

在现代 c + + 编程中,标准库包含 智能指针,这些指针用于帮助确保程序不会出现内存和资源泄漏,并具有异常安全。

以上谈到了C++的智能指针,那么就顺便介绍一下其实现原理吧。类在初始化时会调用构造函数分配资源,在对象生命周期结束时会调用析构函数释放资源。用这个机制去使用指针,我们就只需要关注内存的申请,内存的释放则由程序自动完成,确保了指针使用的安全可靠和异常安全。具体实现细节可以参考此文:《智能指针的C++实现》。

我们又谈到了生命周期,那么接下来一并讲述,并且给出一个展示指针作用的例子——交换两个变量的值。另外在此之前需要先了解两个概念形参实参

  • 形参(形式参数): 在函数声明和定义的时候使用,作用是告诉程序:“嘿,我需要一些参数,数量和类型如下xxxx,在函数中要这么用xxxx”。形参没有实际数据,更多是作为占位符存在,只能等到函数被调用时接收传递进来的数据。其只有参数的形式,并不参与实际的功能,故名形式参数,简称形参
  • 实参(实际参数): 在调用函数时赋给函数的包含实际数据的参数,会被函数内部的代码使用。故名实际参数,简称实参

形参和实参的功能是传递数据,发生函数调用时,实参的值会传递给形参。欲深入了解详见此文《C语言形参和实参的区别》。

3.1. 值传递

首先看看没有指针的话,这个函数会发生什么:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <iostream>
using namespace std;

void swap(int x, int y) {
    int temp;
    temp = x;
    x = y;
    y = temp;
}

int main() {
    int a = 1;
    int b = 2;
    cout << a << '\t' << b << endl;
    swap(a, b);
    cout << a << '\t' << b << endl;
    return 0;
}

其输出为:

1
2
1       2
1       2

数据完全没有变化,这是因为在函数体的代码块内声明的变量,其生命周期和作用域仅为该代码块,函数运行完毕,作用域消失,生命周期结束。

这种传递函数参数的方式就是值传递在值传递中形参是实参的拷贝,改变形参的值并不会影响外部实参的值。从被调用函数的角度来说,值传递是单向的(实参->形参),参数的值只能传入,不能传出。当函数内部需要修改参数,并且不希望这个改变影响调用者时,采用值传递

此时如果该函数有指针类型的返回值,那么该指针的地址是随机的,但是该地址存放的值是确定的,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
using namespace std;

int* swap(int x, int y) {
    int temp;
    temp = x;
    x = y;
    y = temp;
    return &temp;
}

int main() {
    int a = 1;
    int b = 2;
    cout << a << '\t' << b << endl;
    cout << *swap(a, b) << endl;
    cout << a << '\t' << b << endl;
    return 0;
}

其输出为:

1
2
3
1       2
1
1       2

虽然乍一看输出没什么问题,但在任何时候都不要使用一个不受控制的指针。可以使用全局变量替换temp,也有一个更好的办法是使用static关键字修饰temp变量。关于作用域生命周期的细节在此就不做深入了,想要了解更详细的内容前往此文《详解C++作用域与生命周期》。

3.2. 指针传递

既然以上那个函数未能完成使命,接下来就轮到指针出马了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <iostream>
using namespace std;

void swap(int* x, int* y) {
    int temp;
    temp = *x;
    *x = *y;
    *y = temp;
}

int main() {
    int a = 1;
    int b = 2;
    cout << a << '\t' << b << endl;
    swap(&a, &b);
    cout << a << '\t' << b << endl;
    return 0;
}

其输出为:

1
2
1       2
2       1

可以看到,这次成功交换了两个变量的值,而函数的形式没变,只是将参数改为指针便有了如此效果。诚然,哪怕没有指针也能实现,但是无疑要比这种办法更复杂且不直观。当然,你也可以尝试不用指针去实现这个功能,比较它们的异同。

指针传递中,传入函数的实参是指针变量。该指针指向函数外部存放实参数据的地址,在函数内对形参的改变也必然导致外部实参的改变。完全无视函数作用域内的变量其生命周期只持续到函数运行结束的规则,能够从内部影响到外部,指针的威力由此可见一斑。而这只是指针威能的冰山一角。

3.3. 引用传递

引用是C++对C的一个重要补充,也是包括Java在内的其他语言所具有的一种传参方式。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <iostream>
using namespace std;

void swap(int& x, int&y) {
    int temp;
    temp = x;
    x = y;
    y = temp;
}

int main() {
    int a = 1;
    int b = 2;
    cout << a << '\t' << b << endl;
    swap(a, b);
    cout << a << '\t' << b << endl;
    return 0;
}

其输出为:

1
2
1       2
2       1

本来引用传递并不应该归于指针这一章进行叙述,但是既然写到传参了,且引用传递我个人认为和指针传递一样,在底层都是基于地址操作数据,所以在此也讲了。本节的内容大多援引自《值传递,指针传递,引用传递》,在其引用传递一节提到:

这里的&不是取地址符号,而是引用符号,引用是C++对C的一个重要补充。

但是私以为把&看作取址符更方便我们理解代码,所以之后将按照我的理解进行叙述。关于取值符*和取址符&的信息已在上一节详细讲述,在这里简单的顾名思义把其看作具有取出指定地址存放的数值取出存放指定数值的地址功能的运算符即可。

在前面的指针传递中,调用函数需要传递指针类型实参,而在引用传递中直接传入数据即可。这是因为在指针传递中函数需要通过指针作为桥梁操作函数外部的实参;而在引用传递中,通过取址符&直接取出存放实参的地址,并操作该地址存放的数据。相比指针传递,引用传递在调用时更方便,在实际工程中从二者选择合适的即可。

3.4. 引用传递与指针传递的异同

  • 相同点:都是和地址有关的概念。

  • 不同点

    • 指针是一个实体(替身);引用只是一个别名(本体的另一个名字);
    • 引用只能在定义时被初始化一次,之后不可改变,即“从一而终”;指针可以修改,即“见异思迁”;
    • 引用不能为空(有本体,才有别名);指针可以为空;
    • sizeof引用,得到的是所指向变量的大小;sizeof指针,得到的是指针的大小;
    • 指针++,是指指针的地址自增;引用++是指所指变量自增;
    • 引用是类型安全的,引用过程会进行类型检查;指针不会进行安全检查。

3.5. 小结

借用知乎博主invalid s在《为什么说指针是 C 语言的精髓?》中的回答作一个小结:

no pains, no gains

C是工程师为自己设计的语言。它是为那些对机器了如指掌的专家设计的。C的设计者并不认为需要对工程师做任何约束,因为他们知道自己在干什么。

指针是C、C++提供给工程师的一把锋利的刀,用的好伤敌,用不好伤己。指针,或者说C和C++,给了程序员莫大的权力和信任,但不是每个程序员都担得起这份信任,因为它太强大以至难以驾驭,对于程序员的要求居高不下。

C语言设计之初就不是为了占领一切领域,它只是在尽量贴近机器的基础上,实现操控方便的目标。它遵循泛地址理念,通过指针将一切元素通过内存地址联系起来。它极其自由灵活,不独断专权,哪怕为此付出不安全的代价;它太基础,一切都要从头做起,使得开发C程序代价高昂。

天下没有免费的午餐,你所享受的一切早已暗中标好价格。C语言有各种缺点,但有的场合非他不可,纵使也有其他语言效率接近C语言,纵使其他语言开发效率更高,纵使其他语言以安全性著称。指针不是C语言的全部,但无疑是这门老掉牙的语言几十年来经久不衰,甚至位居编程语言排行榜之首的最大功臣之一。

4. 总结(嘀咕)

这篇应该是我这段时间写的最长的博客了,一万多字花费将近一周,耽误不少事,短期停笔不作其他打算了。目前我学习嵌入式开发的总时长大约也就两三个月不到,在此之前,我连C51、引脚、高低电平的定义都不清楚,但是得益于我对计组的了解,我能够快速上手嵌入式开发。

知其然更要知其所以然,有时候将一个事物看作黑盒可以加快工作速度,但拆开黑盒细细剖析本质,如此方能长久。这些内容我以前了解,但未曾总结,在此作文以冀开拓诸君视野。孔子云:“举一隅不以三隅反,则不复也。”希望诸君不要止步于此,举一反三获得更多收获,共勉!

引用

感谢以下资料的撰写者,智慧因交流而熠熠生辉: