引言“指针”是 C 语言为我们提供的最为强大的武器之一。借助指针我们可以更加灵活地使用应用程序所享有的内存。不同于 Python、Java 等语言C 语言为我们提供了这样一种能力可以让程序员根据需要主动选择使用“按值传递”或“按指针传递”这两种不同的数据引用方式。通常按值传递会涉及原始数据的复制过程因此在某些情况下可能会引入额外的性能开销。而按指针传递则使程序内存中的“数据共享”成为了可能。这一讲就让我们来一起看下在 C 语言中指针都有哪些使用方式以及在语法背后这些方式都是如何通过机器指令来实现的。指针的基本使用使用 C 语法定义变量时通过为类型说明符添加额外的 “ * ” 符号我们可以定义一个指向该类型数据的指针。不仅如此通过添加额外的 const 关键字我们还能够限制使用该指针变量时所能够进行的操作类型。比如在下面这个例子中我们便定义了这样的一个指针。通过添加 const 关键字编译器限制了对指针 npA 的使用使得它自身无法被重新赋值并且也无法通过它来修改所指向的数据。指针不仅在 C 语言中的使用方式很简单它在机器指令层面的实现也十分简单。还记得我们之前介绍过的取地址 “” 与解引用 “*” 运算符吗通过使用这两个运算符我们便能够完成对指针的最基本也是最重要的两个操作即取值与赋值。观察上图中红框与蓝框内的 C 代码与汇编代码我们来快速复习一下相关内容。取地址运算符可以用来获取内存中某个数据的所在地址该过程一般会通过红框内的 lea 指令来实现而解引用的过程正与此相反。如右侧蓝框内的第二行代码所示直接通过 mov 指令我们便可以按照所指向数据类型的固定大小这里为 DWORD即 32 位来与对应内存地址上存放的数据值进行交互。指针与数组除了我们显式定义的各类指针变量外指针与数组也有着千丝万缕的联系。数组是一块连续存放有相同类型数据的内存区域。在 C 语言中数组有不同的使用方式有些使用方式可能导致其被退化dacay为相应的指针类型。我们来看下面这个例子。从上图左侧红框内的 C 代码中可以看到我们在主函数内定义了一个包含有 4 个整型元素的数组 arr。在默认情况下数组中的元素会以相邻的方式分配在连续的栈内存中。从右侧红框内的汇编代码中我们可以验证这一点。紧接着通过调用名为 sum 的函数我们可以求得数组内所有元素的累加和。该函数共接收两个参数第一个为目标数组第二个为该数组包含的元素个数。这里我们直接将 arr 作为第一个参数传入。而此时通过 sizeof 运算符我们也能够在编译时得到有关数组 arr 的大小信息并动态计算出数组中元素的个数。但当数组 arr 作为实参被传入函数 sum 后事情发生了变化。从上图右侧蓝框内的汇编代码中可以看出函数被调用前rdi 寄存器内存放的是 rbp-16也就是数组 arr 首个元素对应地址的值。因此传递给函数 sum 的第一个参数实际上为一个指向 int 类型的指针而有关数组 arr 的大小和类型的信息在此时已经全部丢失。对于这种情况我们一般称其为“数组的退化”即数组类型退化为指针类型。指针的其他运算在 C 语言中除了可以对指针进行基本的解引用、赋值甚至再次取地址的操作外我们还可以对它进行算数与关系运算。但需要注意的是指针的这两种运算不同于一般的数值类型。比如对指针进行加法运算就并不是将加数直接累加在对应的地址值上这么简单。你也可以再回顾一下上面讲解指针和数组时的示例代码从函数 sum 的实现中可以看到我们对退化指针 arr 的算数运算过程。算数运算总的来看我们可以对指针类型进行这样几种算数运算单个指针与另一个整数相加 / 相减单个指针自增 / 自减两个指针求差。指针在进行算数运算后不能将其指向的、以固定长度字节作为整体的数据值“拆分”。因此当我们对指针进行加法、减法、递增、递减运算时编译器实际上是以当前指针所指向值对应的某个固定长度为单位对指针中存放的地址值进行相应调整的。同样对于指针之间的求差操作求得的也并不是两个地址值之间以字节为单位的差而是用这个差值除以上面提到的固定长度所得到的结果。下面让我们通过一个例子来看看编译器是如何在背后处理针对指针的算数运算的。这里我介绍的是“单个指针与另一个整数相加”这种场景。由于其他指针算数运算的过程与此基本类似相信理解了这一种另外几种你也能融会贯通。这里在 main 函数的开始我们定义了一个名为 arr 的具有 2 行 3 列共 6 个元素的二维数组。从右侧对应的汇编代码中可以看到这个数组内部的数据是以地址连续的方式被存放在栈内存中的。对于这个存储方式你可以将其理解为编译器对 C 代码中的多维数组进行的扁平化flatten处理。在接下来的 C 代码中我们通过指针的方式获取并打印了位于数组 arr 中两个不同位置上的值。其中蓝框内的表达式首先对 arr 进行了加一操作然后返回了对这个经过“累加”后的地址进行两次解引用的结果值。从右侧相应的汇编代码中可以看到对 arr 的加一操作导致 rax 寄存器中的值被增加 12。而该寄存器中原先存放有数组中第 1 行第 1 列元素对应的地址值因此在经过计算后我们得到了一个指向元素 4 的“二级指针”。也就是说对变量 arr 进行加一操作导致指向二维数组首元素的指针向栈中的高地址方向移动了 12 个字节。之所以会有这样的变化是因为 arr 在这里直接指向的数据实际上是二维数组中每一个包含有 3 个整型元素的一维数组。而每一个一维数组的大小都为固定的 12 字节。因此当对 arr 进行算数运算时编译器便会以它所指向的一维数组的大小为单位来进行地址值上的调整。同样地对于黄框内的第二次数组元素访问由于 *arr 作为一级指针经过了一次解引用直接指向的是二维数组内某个一维数组中的整型元素因此对它进行加法运算将会以 4 字节作为单位来进行地址上的调整。这里我给你留一个小问题按照类似的计算方式你能否直接推算出下面这行语句在执行后的输出结果欢迎在评论区留下你的答案。printf(%d\n, *(*(arr 1) 1)); // ?最后需要注意的是指针的算数运算在绝大多数情况下都只适用于数组相关的指针。而在其他场景中即使程序可以正常编译运行但由于标准中可能并未要求编译器的具体求值规则因此其行为是未定义的程序的运行结果无法得到保障。关系运算除了算数运算外同一类型的不同指针之间还可以进行关系运算。我已经在 03 讲 中介绍了关系运算符的机器指令实现方式。在大多数情况下编译器会配合使用 cmp 与 setg 等指令来判断关系运算符两侧操作数的大小并根据判断结果进行相应的置位与复位操作最终返回 0 或 1 作为结果。而对于指针之间的关系运算来说其实现方式也是如此。但需要注意一点虽然在机器指令层面指针的关系运算实际上是对指针内部所存放的地址值进行的大小判断但从 C 语法的角度来看具有实际意义的指针关系运算仅有为数不多的几种情况你可以点击这个链接来详细了解。除此之外其他使用方式均会产生未定义行为UB。堆内存指针在我之前介绍的例子中指针仅引用了位于栈内存中的数据。但实际上指针还有另一个更重要的作用那就是给予了我们灵活操控堆内存中数据的能力。堆同栈类似也是位于进程 VAS 中的一段专门用于存放数据的内存空间。栈中的数据随着函数的调用与返回会被程序自动释放而堆则有所不同。在堆中进行数据分配需要借助特定的操作系统调用函数并且被分配内存中的数据不会随着程序的运行而自动清除。因此当这些数据不再被程序使用时便需要显式地调用相应的系统函数来将其释放。幸运的是C 标准库中已经为我们封装好了这样的一些函数。借助它们我们可以方便地申请与释放堆内存并享受堆分配算法带来的性能保障。这里我先带你回顾一下这些函数的使用方式而它们的具体内容我会在 15 讲 中再为你详细介绍。通过下面这段示例代码我们可以快速回顾一下标准库函数 malloc 与 free 的使用方式。对于其中的关键语句你可以参考它们上方的注释。#include stdio.h #include stdlib.h #include string.h #define N 5 int main(void) { int arr[] { 1, 2, 3, 4, 5 }; // 分配用于存放 N 个整数的堆内存 int* p (int*) malloc(sizeof(int) * N); // 将数组 arr 中的元素复制到分配的堆内存中 memcpy(p, arr, sizeof(int) * N); for (int i 0; i N; i) { // 通过指针遍历堆空间中的数据 printf(%d\n, *(p i)); } // 释放先前分配的堆空间让操作系统可以回收内存 free(p); return 0; }在 VAS 中堆内存的位置处于栈内存的“下方”即低地址方向。与栈内存相反的是堆内存的占用区域将随着程序的不断使用从低地址向高地址逐渐增长如下图所示看到这里你可能会有这样的疑问我平时写的 C 程序只需要临时变量就够用了这些变量的值会被分配在栈内存中那我们为什么还需要堆呢因为栈上的数据在函数返回时就会被释放因此我们只能通过不断拷贝的方式保持其“存活”。而全局变量和静态变量的生存期虽然与整个程序保持一致但也并没有办法在程序的运行过程中动态生成且缺乏一定表现力。而堆内存则可以很好地解决这些问题。存放在其内部的数据能够由程序动态地创建而且可以保持与程序相同的最大生存期。不仅如此和全局变量、静态变量这两种将值完全暴露给所有程序代码使用的方式相比使用堆内存可以将数据的使用限制在其所需要的最小范围内这无疑加强了程序对内存资源的精细化管理程度。使用指针的注意事项借助指针我们可以灵活地使用程序存放在堆内存与栈内存中的数据但不当的指针使用方式也可能会导致程序出现难以调试、甚至是难以复现的 BUG。其中你需要特别注意避免下面这些操作因为它们会导致程序出现无法预测的未定义行为解引用未初始化的指针函数返回指向其内部局部变量的指针非指向同一数组内元素的两个指针之间的减法操作……除此之外对堆指针进行有效的生命周期管理也是我们在构建程序时需要注意的问题。由于同一个堆指针可能会在程序的不同函数中被使用因此就要特别注意我们应该通过 free 函数及时清理堆内存以防止内存泄露同时又不应该去释放一块已经被释放过的堆内存重复释放会产生异常。总结好了讲到这里今天的内容也就基本结束了。最后我来给你总结一下。这一讲我主要介绍了 C 语言中有关指针的一些话题包括指针在 C 语言中的基本使用方式、指针与数组的关系、指针的算数与关系运算以及它们在机器指令层面的实现细节。同时我还介绍了堆内存指针并和你简单探讨了在使用 C 指针时需要注意的一些问题。在 C 代码中通过添加特定的 “ * ” 符号我们可以声明所定义变量为一个指针类型。而与指针有关的两个常用操作符为取地址操作符 “” 与解引用操作符 “ * ”它们一般可以通过 lea 指令与 mov 指令来实现。指针与数组也有着密不可分的联系。在某些特定的使用方式下编译器会将数组类型退化为指针类型导致其丧失了有关数组的类型与大小等信息。除此之外指针类型还可以参与算数与关系运算。其中算数运算主要涉及指针与整数的加 / 减运算、指针的自增 / 自减运算以及两个同类型指针间的求差运算。而关系运算则同数值类型保持一致。但需要注意的是标准中仅规定了上述运算类型对于指针的有限使用方式而规定之外的使用方式则属于未定义行为。同时我还介绍了可以引用堆上数据的指针。堆是除栈之外的又一个重要的数据存放“容器”。相较于栈上数据以及全局变量或静态变量中的数据位于堆中的数据具有更加灵活的生存期并且能够在程序运行过程中动态生成。最后我总结了在使用 C 指针时需要注意的问题。对指针的不当使用会使程序产生标准中未定义的行为。对于堆指针来说除了未定义操作外没有及时对相关资源进行清理或重复清理都会导致程序的运行产生异常。而这些都是我们在设计 C 程序结构时需要特别注意的问题。毕竟再强大的武器也是一把双刃剑。