尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

C/C++指针本质与实战:从内存地址到智能指针,彻底攻克复杂声明与空指针

发布时间:2026/9/17 4:22:19

资讯中心
01
ARTICLE

C/C++指针本质与实战:从内存地址到智能指针,彻底攻克复杂声明与空指针

C/C++指针本质与实战:从内存地址到智能指针,彻底攻克复杂声明与空指针
很多人学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指向一个char1字节那么q 1只往后跳1个字节。如果有一个struct Student *stuPtrstuPtr 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里一个非常经典的问题也是很多面试官喜欢挖坑的地方。要搞清楚这个问题必须先分清两个概念顶层consttop-level const修饰的是指针变量本身即指针自身不能被修改但它指向的数据可以被修改。写法是int *const p。底层constlow-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 *)); // 864位系统这是指针的大小如果arr是指针sizeof(arr)应该等于8但它实际上等于20。这就说明arr并不是一个指针变量编译器把arr当成“含有5个int的数组”这个整体。但当你写int *p arr;时arr退化成指针等价于int *p arr[0];。这里发生了隐式转换数组名在表达式里直接作为首元素地址使用。还有一处很容易搞混的是arr和arrarr的类型是int *指向首元素。arr的类型是int (*)[5]指向整个数组。虽然这两个值打印出来通常是相同的地址但它们的类型不同。arr 1跳过一个int4字节而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怎么选C11之后主要用三种智能指针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_ptrB b; ~A() { std::cout destroy A std::endl; } }; struct B { std::shared_ptrA 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看调用栈。调用栈会告诉你执行路径但不会直接告诉你是哪个逻辑错了。AddressSanitizerASan是一个极其好用的内存错误检测工具。编译时加-fsanitizeaddress运行时会捕获堆越界、栈越界、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告诉你哪些能改哪些不能改生命周期决定指针指向的内存是否仍然有效。把这四条规则刻在脑子里再复杂的指针代码也吓不到你。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。