很多人学C语言,学到指针就开始打退堂鼓。我记得当年第一次看到int (*p)[5]这种声明,第一反应是想把教材扔了,心想这是人写的玩意吗。后来在嵌入式项目里被段错误折磨了整整一周,才慢慢摸到指针的门道。等真的搞懂之后回头看,指针其实不复杂,只是很多资料把它讲得太绕了。
这篇文章我想换一种方式,从“指针到底是什么”出发,一路聊到const修饰、数组与指针的纠缠、函数指针、结构体指针,再延伸到C++的智能指针和空指针排查。我把这些年实际写代码时踩过的坑、总结的规律全部掰开揉碎讲清楚,希望你看完能少走一些弯路。无论你是刚接触C语言的学生,还是工作中被指针问题搞到头大的开发者,这篇文章应该都能给你一些启发。
1. 指针的本质:它就是一个装着地址的变量
1.1 为什么初学者总觉得指针难
指针难学,主要原因不是指针本身复杂,而是很多教材一开始就把“指针”“地址”“变量”“类型”这些概念搅在一起讲,学生还没有建立起内存模型,就开始背语法,越背越糊涂。
其实你只需要记住一句话:指针就是一个普通变量,只不过这个变量里面存的东西比较特殊——它存的是内存地址。就好比你家的门牌号。你要找一个朋友,不需要把朋友整个搬过来,只需要知道门牌号,然后按地址去找人。指针变量就是这个写着门牌号的小纸条。
搞懂这个模型之后,很多概念就通了:
int a = 10;是在内存某处划分了4个字节,把这4个字节的内存块命名为a,里面放了数值10。int *p = &a;是又划分了一块内存,命名为p,里面放的是a的起始地址。- 通过
*p解引用,就是“根据纸条上的门牌号,找到那个房间,把里面的东西拿出来”。
1.2 指针的类型到底在约束什么
很多初学者会问一个非常合理的问题:既然指针变量里存的不都是地址吗,地址不就是一个数字吗,为什么还要区分int *和char *?
这个问题的答案是:指针的类型决定了你拿到这个地址之后,能从这个地址开始操作多少个字节,以及如何解释这些字节。
在64位系统里,所有指针变量本身的大小都是8个字节(32位系统是4个字节),因为地址就是64位数字。你可以用sizeof(int *)和sizeof(char *)验证,结果都是8。但是,p + 1移动了多少个字节,完全取决于指针指向的类型。
int *p指向一个int(假设4字节),那么p + 1实际上是从当前地址往后跳4个字节,指向下一个int的起始位置。char *q指向一个char(1字节),那么q + 1只往后跳1个字节。- 如果有一个
struct Student *stuPtr,stuPtr + 1跳过的就是整个struct Student结构体的大小。
这个特性叫做“指针算术”,它之所以成立,正是因为编译器知道指针的类型。也因此,void *这种无类型指针没法直接做加减运算——编译器不知道一个单位是多长,自然没法帮你计算。
1.3 取地址、解引用与指针运算的真实含义
&和*是两个最基础的操作符,但很多人只是死记硬背“&是取地址,*是解引用”,没有真正理解它们在底层做了什么。
&a本质上是取变量a的起始地址,这个操作的结果是一个指向a的指针。*p本质上是告诉编译器:从p中存放的地址开始,按照p指向的类型大小,读取(或写入)这块内存。
举个例子,假设定义:
int a = 10; int *p = &a; *p = 20;执行完*p = 20;之后,a的值变成20。因为p存放的是a的地址,*p就是a这个内存块的别名。*p和a在编译后几乎等价,只不过一个是直接寻址,一个是间接寻址。
这里有个非常实用的点:当你要写一个函数去修改某个变量的值,必须把这个变量的地址传进去,也就是传指针(C++里叫传引用)。因为函数参数是值传递,你如果直接传a,函数内部拿到的是a的副本,你怎么改都改不到原来的a。传&a进去,函数内部用*p = xxx操作,改的才是原来的那块内存。这个道理在写链表、树的插入删除时特别重要。
2. 指针与const相爱相杀:顶层const与底层const的赋值规则
2.1 顶层const和底层const到底怎么区分
热搜词里有“顶层指针和底层指针可以相互赋值吗”,这其实是C++里一个非常经典的问题,也是很多面试官喜欢挖坑的地方。要搞清楚这个问题,必须先分清两个概念:
- 顶层const(top-level const):修饰的是指针变量本身,即指针自身不能被修改,但它指向的数据可以被修改。写法是
int *const p。 - 底层const(low-level const):修饰的是指针指向的数据,即指针可以变,但指向的数据不能通过这个指针修改。写法是
const int *p。
最直观的记忆方式是看const在*的左边还是右边:
| 声明 | const在*哪边 | 到底是什么 | 能改指针吗 | 能改指向的数据吗 |
|---|---|---|---|---|
const int *p | 左边 | 指向const int的指针 | 能 | 不能 |
int *const p | 右边 | 指向int的const指针 | 不能 | 能 |
const int *const p | 两边都有 | 指向const int的const指针 | 不能 | 不能 |
2.2 为什么底层const不能随便赋值
C++有一条赋值规则:只有底层const相同或更严格的类型之间才能进行赋值,直接或间接地去掉底层const是不允许的。
这句话比较抽象,我们用代码说话:
int a = 5; const int ca = 10; const int *p = &a; // 合法:int* 可以隐式转成 const int*,加约束是允许的 int *q = &ca; // 非法:const int* 不能隐式转成 int*,去约束是禁止的 *q = 20; // 如果允许,就等于可以随意修改一个const变量的值这就是为什么int *可以赋值给const int *,但反过来不行。加约束是安全的,去约束是危险的。
那顶层const呢?int *const p表示指针本身不能变,但它指向的数据可以变。在赋值场景里,如果没有涉及指针的指针,顶层const通常不会带来太大困扰:
int *const p = &a; int *q = p; // 合法,因为p本身不可变这个属性不会传递给q但是一旦涉及二级指针,情况就复杂了。比如:
const int ci = 10; int *p; const int **cpp = &p; // 假设这样合法 *cpp = &ci; // 现在p指向了ci *p = 100; // 通过p修改了const变量,直接爆炸所以C++标准禁止int **隐式转换为const int **,目的就是堵死这条能绕过const约束的路。如果你真的需要这种转换,只能用const_cast强制去掉const,但强制转换意味着你要自己为安全性负责,实际工程里能不用尽量不用。
2.3 实际开发中的典型误用
我在代码评审里见过很多次这样的错误:函数声明为int *getData(),返回的其实是内部一个const int []数组的地址。调用方拿到int *之后直接改数据,导致逻辑混乱。
正确的做法是:如果被指向的数据不应该被外部修改,就老老实实把函数返回值声明成const int *。这样调用方一看到类型就知道不能乱改,编译器也会帮你守住这条防线。
另外,类的const成员函数里经常用到const T *型的成员变量。写get方法时返回const T *,而不是T *,是一个比较稳妥的默认选择,除非你确实需要返回可修改的内部状态。
3. 指针与数组的纠缠:从数组名到指针数组
3.1 数组名真的是指针吗
有太多人把“数组名就是指针”挂在嘴边,严格来说这是错的。数组名在绝大多数表达式中会“退化”成指向首元素的指针,但数组名本身并不是指针。
可以用sizeof验证:
int arr[5]; printf("%zu\n", sizeof(arr)); // 20(假设int为4字节),这是整个数组的大小 printf("%zu\n", sizeof(int *)); // 8(64位系统),这是指针的大小如果arr是指针,sizeof(arr)应该等于8,但它实际上等于20。这就说明arr并不是一个指针变量,编译器把arr当成“含有5个int的数组”这个整体。
但当你写int *p = arr;时,arr退化成指针,等价于int *p = &arr[0];。这里发生了隐式转换:数组名在表达式里直接作为首元素地址使用。
还有一处很容易搞混的是&arr和arr:
arr的类型是int *,指向首元素。&arr的类型是int (*)[5],指向整个数组。
虽然这两个值打印出来通常是相同的地址,但它们的类型不同。arr + 1跳过一个int(4字节),而&arr + 1会跳过整个数组(20字节),指向数组末尾之后的那块内存。这在做缓冲区边界计算时非常容易踩坑,用过一次就忘不了。
3.2 指针数组与数组指针:从声明语法开始
“指针数组”和“数组指针”名字只差一个字,含义却完全不同:
- 指针数组:一个数组,数组里每个元素都是指针。声明是
int *arr[3]。拆解:arr[3]先表示一个有3个元素的数组,int *说明元素类型是“指向int的指针”。 - 数组指针:一个指针,指向数组。声明是
int (*arr)[3]。拆解:*arr说明arr是一个指针,[3]说明它指向的是一个有3个int的数组。
怎么记?看*和[]谁先和变量名结合。*arr[3]里,arr[3]先结合,所以本质是数组;(*arr)[3]里,*arr先结合,所以本质是指针。
写代码时如果不确定,可以用typedef来降低心智负担:
typedef int IntArray3[3]; IntArray3 *p; // p是一个数组指针,指向包含3个int的数组3.3 字符串数组指针的实战细节
热搜词“指针数组存放字符串”是C语言中很常见的操作。比如:
char *fruits[] = {"apple", "banana", "cherry"};这里fruits是一个数组,每个元素是一个char *,分别指向一个字符串字面量。注意:字符串字面量在C/C++中是不可修改的,但这里的类型是char *,如果要严格的C++写法应该用const char *fruits[]。
用这种方式存储字符串比二维数组char fruits[3][20]更省内存,因为每个字符串可以按自己的实际长度分配。代价是字符串数据散落在内存各处,多一次间接访问。如果字符串数量固定且内容较短,char fruits[3][20]反而更紧凑,缓存更友好。
我做过一个嵌入式项目,需要解析几百条命令列表,每条命令是一个字符串。最初用二维数组,每条命令定长32字节,浪费了不少Flash。后来改成const char *cmdTable[],直接每行一个命令字面量,内存立刻降了一截,而且代码还好读。对这种场景,指针数组几乎是唯一合理的选择。
3.4 void*万能指针的边界
void *是一种特殊的指针,它可以接收任意类型的指针值,所以常被用来写通用接口,比如memcpy、malloc的参数和返回值就是void *。
但万能的代价是失去类型信息:
void *不能直接解引用,因为编译器不知道该读几个字节。void *不能直接做指针算术,理由还是同一个。void *必须强制转换成具体类型后才能使用。
比如回调函数、通用容器,经常会把数据以void *传进来,然后在回调内部转成具体类型。这是C语言实现泛型的一种粗糙手段。收到void *时心里要清楚,它只是一个“未贴标签的地址”,你用的时候要自己确保类型转换是正确的,否则解引用时就会出现未定义行为。
4. 函数指针与指针函数:别再把它们搞混
4.1 指针函数:返回值是指针的函数
“指针函数”听起来很玄,但它的本质就是一个普通函数,只是返回值是指针。
int *createIntArray(int n) { int *p = (int *)malloc(n * sizeof(int)); return p; }这就是一个指针函数,返回int *。写起来很简单,但里面有两个潜藏风险:
- 返回指向栈上局部变量的指针:函数结束,栈内存就失效了,返回的指针是悬空指针。
- 返回后忘了释放:堆内存泄漏。
比较好的做法是:谁申请,谁释放;返回的是堆内存,调用方就要负责free。如果是函数内部维护的静态数组,返回指针是可以的,因为静态区的生命周期是整个程序运行期间,但多线程并发时需要注意可重入问题。
4.2 函数指针:指向函数的指针
函数指针是指向“函数”的指针变量。C语言中函数名在表达式里会自动转换为函数指针,就和数组名在表达式里转换为首元素地址一样。
声明一个函数指针的方法是:把函数声明中的函数名替换成(*指针名)。
int add(int a, int b) { return a + b; } int (*funcPtr)(int, int); // funcPtr是一个函数指针,指向形如int(int,int)的函数 funcPtr = add; // add退化成函数指针 int result = funcPtr(3, 4); // 调用,result = 7理解这句话非常关键:int (*funcPtr)(int, int)读作:funcPtr是一个指针,指向一个带两个int参数、返回int的函数。
用typedef可以大幅提高可读性:
typedef int (*BinaryOp)(int, int); BinaryOp op = add; int result = op(3, 4);4.3 回调函数的实战应用
函数指针最典型的应用是回调函数。比如C标准库里的qsort就接收一个比较函数指针:
int compareInt(const void *a, const void *b) { int x = *(const int *)a; int y = *(const int *)b; return (x > y) - (x < y); } int arr[] = {5, 3, 8, 1}; qsort(arr, 4, sizeof(int), compareInt);qsort内部不知道你想要升序还是降序,也不知道元素如何比较,它把一个“如何比较”的问题委托给调用者。这种通过函数指针实现解耦的套路,在事件系统、状态机、插件机制里使用频率极高。
实际工作中,有些同学会把函数指针声明写得很复杂,比如返回函数指针的函数。这种“返回函数指针的函数”乍一看很吓人,比如:
int (*getOp(char opType))(int, int);意思是getOp接收一个char参数,返回一个函数指针,这个函数指针指向一个接收两个int、返回int的函数。这种写法可读性很差,我一般会拆成typedef来写,不然过两周自己都不认识。
5. 结构体指针与链表实操
5.1 结构体指针的访问方式
结构体指针在C语言里几乎是绕不开的。定义一个结构体依然不复杂:
typedef struct Node { int data; struct Node *next; } Node;这里有一个非常关键的细节:Node结构体里有指向同类型Node的指针next,这就生成了一个自引用结构,链表的基石就靠这个。
结构体指针访问成员用->操作符:
Node *p = /* ... */; p->data = 10; p->next = NULL;p->data等价于(*p).data。->就是一个语法糖,让你不用写那一堆括号。
5.2 结构体指针与内存管理
结构体指针在堆上分配时,最典型的错误是忘记初始化。malloc出来的内存内容是未定义的,所以分配之后必须立即给每个成员赋值:
Node *node = (Node *)malloc(sizeof(Node)); if (node == NULL) { // 处理分配失败 return; } node->data = 0; node->next = NULL;你对malloc返回的指针做任何假设之前,都必须先判空。malloc可能返回NULL,代表分配失败。这种判断少一次,程序崩溃的概率就高一分。
另一个常见问题是只释放了链表头,后面的节点全部泄漏。正确做法是遍历链表逐个释放:
void freeList(Node *head) { while (head != NULL) { Node *tmp = head; head = head->next; free(tmp); } }5.3 使用结构体指针实现链表
链表的插入逻辑最能体现结构体指针的价值。以在头部插入为例:
Node *insertHead(Node *head, int data) { Node *newNode = (Node *)malloc(sizeof(Node)); if (newNode == NULL) { return head; } newNode->data = data; newNode->next = head; return newNode; }注意这里为什么要把新头返回,而不是在函数内部修改head?因为head是局部变量,你在函数里修改它,外界看不到。要修改指向头部的指针本身,必须传入Node **,也就是指针的指针。很多刚入门的朋友在这个地方卡很久:
void insertHead(Node **pHead, int data) { Node *newNode = (Node *)malloc(sizeof(Node)); if (newNode == NULL) { return; } newNode->data = data; newNode->next = *pHead; *pHead = newNode; }第二种写法能真正把新节点链到外部头指针上,原因就是pHead本身指向外部的head变量,*pHead就是head本身,修改*pHead等于修改外部变量。指针的指针在链表、树的插入删除里非常常见,理解它的核心就是“我要修改指向某块内存的指针变量本身,而不只是这块内存的内容”。
6. C++智能指针:从裸指针到自动管理
6.1 为什么要有智能指针
C语言里所有内存都要手动管理,malloc配free,稍不注意就是泄漏、悬空、双重释放。C++引入的智能指针,核心思想是RAII:把资源的生命周期绑定到对象的生命周期上,对象析构时自动释放资源。
你可以把智能指针理解成一个带自动打扫功能的盒子。你把裸指针放进盒子,盒子析构的时候,会自动帮你delete。这样即使中间抛出异常或提前return,盒子也会在栈展开时被销毁,资源自然就被释放了。
6.2 unique_ptr、shared_ptr、weak_ptr怎么选
C++11之后主要用三种智能指针:
unique_ptr:独占所有权,同一时刻只能有一个unique_ptr指向某个对象,不能拷贝,只能std::move转移。shared_ptr:共享所有权,内部维护引用计数,最后一个shared_ptr析构时释放对象。weak_ptr:不增加引用计数的“观察者”,用来打破shared_ptr之间的循环引用。
选型建议很明确:默认用unique_ptr,它的开销为零(相对裸指针),语义清晰。只有在确实需要共享所有权时才用shared_ptr。shared_ptr的引用计数是原子操作,在多线程场景下会有一定开销,而且一旦形成循环引用,两个对象都无法释放,内存泄漏得悄无声息。
举个例子,定义两个互相引用的类型:
struct B; struct A { std::shared_ptr<B> b; ~A() { std::cout << "destroy A" << std::endl; } }; struct B { std::shared_ptr<A> a; ~B() { std::cout << "destroy B" << std::endl; } };如果让a->b = b;和b->a = a;,这两个对象的引用计数会互相牵制,永远不为0,析构函数不会被调用,内存就泄漏了。把其中一方的shared_ptr改成weak_ptr就能打破这个环:weak_ptr不增加引用计数,析构时只需要执行lock()临时获得一个shared_ptr来访问对方。
6.3 智能指针实现原理的启发
理解智能指针的实现能帮你更好地使用它。一个最简的unique_ptr模型大概是:
template <typename T> class MyUniquePtr { public: explicit MyUniquePtr(T *ptr = nullptr) : ptr_(ptr) {} ~MyUniquePtr() { delete ptr_; } MyUniquePtr(const MyUniquePtr &) = delete; MyUniquePtr &operator=(const MyUniquePtr &) = delete; MyUniquePtr(MyUniquePtr &&other) noexcept : ptr_(other.ptr_) { other.ptr_ = nullptr; } T *operator->() const { return ptr_; } T &operator*() const { return *ptr_; } private: T *ptr_; };核心就三件事:构造时接管指针,析构时释放,禁止拷贝但允许移动。理解了这一点,你就知道为什么智能指针不能随便把一个裸指针包两次,为什么不能用智能指针管理栈上对象(析构时会delete栈地址),为什么shared_ptr内部要有一个控制块。这些不是“规则背诵”,全是实现机制自然推出来的结论。
7. 空指针与无效指针:一套实战排查方法论
7.1 空指针的常见来源
空指针是开发中最常见的崩溃原因。常见的空指针来源有这么几类:
- 未初始化的局部指针,里面是随机值,不是NULL但越解引用越崩。
malloc或new之后没判空,继续用返回的指针。- 调用库函数返回NULL但没有检查,典型如
fopen失败返回NULL。 - 结构体里某个指针字段没有赋值,默认是垃圾值,解引用时踩到非法内存。
这里有个很反直觉的点:很多崩溃并不是发生在“解引用NULL”那一步,而是发生在“解引用一个已经失效的悬空指针”。NULL的解引用通常会直接段错误,崩溃位置清晰;悬空指针则可能读取到残留数据,表现诡异,一会儿正常一会儿不正常,这种问题排查成本极高。
7.2 一个问题排查实录:Timer执行查询报空指针
热搜里有“timer执行查询是报空指针”,这让我想起一次真实的排查经历。当时一个定时器回调里执行SQL查询,偶尔会崩,报错指向某行查询判断逻辑,但代码里明明写了判空。
后来逐步排查才发现:定时器回调通过一个全局指针访问数据库连接对象,而这个全局指针在程序退出时已经被另一个模块设置为nullptr了。定时器关闭和连接释放的先后顺序存在竞态,偶发地出现“定时器还没来得及关闭,连接对象已经释放”的情况。
排查过程大概是这样的:
- 看崩溃信息:先看core dump或日志里崩在哪一行。
- 加日志:在回调函数开头打印指针地址和连接状态,观察崩溃前的最后一次正常状态。
- 查生命周期:找出谁在什么时候设置了这个指针,谁在什么时候释放了它,对比两者时序。
- 加保护:把定时器回调里的裸指针改成
shared_ptr,或者在关闭定时器之后再释放连接,保证回调执行期间对象一定存活。
这其实就是空指针和悬空指针问题的共性套路:空指针问题往往是“没检查”;悬空指针问题往往是“生命周期没对齐”。
7.3 高效排查工具与手段
在Linux下,排查空指针和越界访问,我一般按这个顺序来:
gdb直接跑,崩溃后输入bt看调用栈。调用栈会告诉你执行路径,但不会直接告诉你是哪个逻辑错了。AddressSanitizer(ASan)是一个极其好用的内存错误检测工具。编译时加-fsanitize=address,运行时会捕获堆越界、栈越界、use-after-free(释放后使用)等问题,并给出精确的分配和释放堆栈。遇到诡异的内存崩溃,我第一反应就是开ASan。- 加上
-Wall -Wextra编译警告,很多“可疑的地方”编译器早就想告诉你了,只是你一直选择忽略。
另外还有一个容易被忽略的技巧:给指针设置“哨兵值”。比如在结构体里约定指针初始化为NULL,释放后立即置为NULL,这样即使解引用也会稳定地崩在判空逻辑上,而不是无规律地崩在某个意想不到的位置。
7.4 如何从根源上减少空指针问题
排查再快,也不如从源头减少这类问题。这些年我积累了几条比较实用的经验:
- 指针能不用就不用。能用引用的地方优先用引用(C++),能封装成智能指针就封。
- 函数入参如果是只读的,声明成
const,编译器会帮你拦住很多误改。 - 统一初始化习惯:定义指针变量时立即初始化,要么指向有效对象,要么
nullptr。不写“以后再用”的裸声明。 - 释放指针后立即置为
nullptr,避免悬空指针。 - 对每个可能返回NULL的调用,先判空再使用,不要图省事。
这些规则听起来很基础,但工程里大部分内存问题都源于没有坚持这些基础规则。
最后再分享一个小技巧
我在实际项目里经常用打印地址来验证指针逻辑。写代码时如果对某段指针操作不确定,就直接打印相关指针的地址值,比如:
printf("head=%p, node=%p, next=%p\n", (void *)head, (void *)node, (void *)node->next);通过观察这些值的变化,你能非常直观地看到指针如何指向、如何跳转,比盯着代码猜快得多。这个方法我用了很多年,每次帮别人排指针相关的问题,第一步都是让他把指针值打出来看看。
指针这个东西,说到底就是一个“门牌号”加上一套“规则”。门牌号本身很简单,规则才是关键:类型告诉你怎么解引用,const告诉你哪些能改哪些不能改,生命周期决定指针指向的内存是否仍然有效。把这四条规则刻在脑子里,再复杂的指针代码也吓不到你。