ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

指针、链表、缓冲区:一条主线看懂C++内存管理

指针、链表、缓冲区:一条主线看懂C++内存管理 搜索“指针”这个词你的搜索结果里至少有一半是Windows鼠标设置。再搜“缓冲区”又会冒出“体育休闲服务设施缓冲区”这种城市规划概念。但如果你正在学C我想说的完全是另一回事今天要讲的这三个概念——指针、链表、缓冲区——是无数人学C时卡壳的地方而它们其实是同一条主线上的三个节点这条主线叫“内存”。这是C系列的第三讲。前两讲已经把变量、函数、结构体和类的基础过了一遍这一篇专门处理三个彼此纠缠、又共同围绕内存运转的核心概念。学完这一篇你不仅能写出能跑的链表程序还能看懂那个经典的Windows报错“系统在此应用程序中检测到基于堆栈的缓冲区溢出”背后到底发生了什么。1. 为什么把指针、链表和缓冲区放在同一讲因为它们是内存的三种形态大多数教程把这些知识点拆开讲指针放在第二章链表放在数据结构课缓冲区溢出放在安全课。拆开本身没问题但初学者很容易学完指针不会用链表学完链表不知道自己写的正是缓冲区操作。放在一起讲是因为它们在真实代码里永远是纠缠在一起的。1.1 三个概念的共同主线一张内存图先建立一个最简单的内存心智模型。一个C程序跑起来之后内存大致分成几个区域代码段存放编译后的机器指令只读。数据段存放全局变量和静态变量。栈区函数调用时分配局部变量、保存返回地址的地方通常从高地址向低地址增长。堆区程序运行时通过new或malloc动态申请的内存从低地址向高地址增长。指针是什么指针是一个变量它存的是另一个变量的地址——本质上是“内存的门牌号”。链表是什么链表是在堆区动态分配的一串节点每个节点里有一个数据成员和一个指向下一个节点的指针成员。缓冲区是什么缓冲区就是一片预留好的连续内存可能分配在栈上比如局部数组也可能分配在堆上比如new出来的数组或std::vector的底层存储。现在你能看到它们的关系了链表必须用指针把节点串起来链表的节点内容可能要写到缓冲区里而缓冲区溢出漏洞的根源往往就是某个指针越界往缓冲区边界之外多写了几字节。这三样东西共用一条主线内存地址的分配、使用与释放。我见过很多新人单独问“指针是什么”能答上来单独问“链表特点”也能背出来但一进入实际程序就乱了。原因就是他们脑子里这些概念是孤立的。这讲的核心目标就是帮你把这三个概念拼成一张完整的内存网络。1.2 这一讲在C系列里的定位前面两讲覆盖了基本语法、分支循环、函数传参和类的封装。如果你已经能写出类但开始对“为什么这里要用Node*而不用Node”感到困惑对“字符串数组初始化”和“多维数组中指针的用法”有点头晕那么这一讲正好对症。这一讲之后你会具备三样能力能看懂任何涉及内存地址的报错信息而不是看到“空指针”“缓冲区溢出”就靠猜。能独立实现单链表、循环链表的基本操作并理解插入删除时每个指针更新的原因。能理解缓冲区是什么、为什么需要它、怎么避免越界写入。这三样能力是后面学树、图、STL源码、网络编程、游戏引擎的底层基础设施。说白了这篇内容学扎实了后面就是水到渠成的事。2. 指针概念地址、解引用和那些长相相似的名字2.1 变量的三层信息变量名、地址、值很多教材一上来就讲指针语法却忽略了一个关键认知一个普通的变量其实包含三层信息——变量名、内存地址、存储的值。int x 42;这行代码在栈上分配了4个字节int的大小把42写进去。编译器维护了一张符号表把x映射到那块栈内存的首地址。那个首地址在典型的x64程序里看起来像0x7ffeefbff5ac这样的十六进制数。你可以用取地址运算符拿到它int* p x; // p 里存放的是 x 的内存地址这里p的类型是int*读作“指向int的指针”。当你写出p x时意思就是这个指针变量p现在指向x。解引用运算符*则正好相反它根据指针里保存的地址去访问那块内存int y *p; // 读出 p 指向的内存里的值y 等于 42 *p 100; // 通过指针直接修改 x因为 p 指向的就是 x 的内存生活类比内存像一栋公寓楼地址就是门牌号。指针变量p是一个写着门牌号的小纸条。x是查出x住在哪个房间*p是拿着门牌号去那个房间开门拿东西或放东西。这一节还要建立一个重要习惯指针也可能什么都不指向。C11以后用nullptr表示空指针不要再写NULL或者0了。int* p nullptr; // p 明确表示“我目前没有指向任何对象” if (p ! nullptr) { // 只有确认非空才能安全解引用 }新手最容易犯的错误是声明指针之后忘记初始化就直接使用。未初始化的指针里是随机值解引用它就是在访问一块不确定的内存轻则读到垃圾数据重则直接崩溃。我给新人的规矩很简单指针声明时必须初始化要么给它一个合法地址要么给nullptr。2.2 指针的运算与数组退化数组和指针这一段我看过太多人在这上面栽跟头。先看一个基础事实int arr[5] {1, 2, 3, 4, 5}; int* p arr;这里数组名arr是什么类型它既不是int类型也不是真正的int[5]类型。在绝大多数表达式中数组名会“退化”为指向首元素的指针也就是int*类型。所以p arr等价于p arr[0]。那么指针的加减法是怎么回事p 1指向的不是下一个字节而是下一个元素std::cout *(p 2); // 输出3等价于 arr[2]原因是指针算术以“所指向类型的大小”为步长。对于int指针步长是4字节对于double指针步长是8字节。所以p1实际上在地址数值上加了4而不是1。理解这一点后面看多维数组会省很多力气。还有一个经典区分sizeof运算符在这两个对象上的表现完全不同int arr[5] {1, 2, 3, 4, 5}; int* p arr; std::cout sizeof(arr); // 20整个数组的大小 std::cout sizeof(p); // 8指针变量本身的大小x64平台为什么面试官喜欢问这个因为当数组作为函数参数传递时看起来传的是数组实际传的是指针。函数内部对参数做sizeof永远得到指针大小而不是数组大小。这也是为什么C风格函数必须额外传一个长度参数否则函数根本不知道自己收到了几个元素。多维数组的指针关系也常被问到。int arr[3][4]的类型是int(*)[4]也就是指向“含4个int的一维数组”的指针。换句话说二维数组名在退化时保留的是第二维的信息丢失的是第一维的信息。对于新手我建议先不要死记类型而是打开调试器观察arr、arr[0]、arr[0][0]的值你会发现它们三个的数值有时一样但类型和步长完全不同。2.3 指针、引用和值传递三种传参方式的本质区别“C 引用 指针 和 值传递”这个搜索词能上热搜说明这个问题确实困住了大量初学者。我用一个经典的交换函数来讲清楚。void swapValue(int a, int b) { int tmp a; a b; b tmp; } void swapPointer(int* a, int* b) { int tmp *a; *a *b; *b tmp; } void swapReference(int a, int b) { int tmp a; a b; b tmp; }第一种写法传的是值的拷贝。函数内部修改a、b跟调用方的原变量没有任何关系所以它交换失败。第二种写法传的是地址的拷贝。虽然指针本身是按值传递的但通过解引用操作的是原变量的内存所以能完成交换。第三种写法引用是C引入的语法。引用本质上是原变量的别名函数形参里的int a直接绑定调用方的变量。这里连拷贝都不发生操作的就是原变量。用一个表格来看清三者的差别传参方式是否发生数据拷贝能否修改原值形参能否为空典型场景值传递是否不涉及基本类型、小对象指针传递是拷贝地址是可以C风格代码、可为空参数引用传递否是不可以C风格、必须绑定有效对象实践建议现代C里能写引用就优先写引用因为引用语法更安全没有空引用的问题。指针多用在两种场景一种是你确实需要“可能为空”的参数另一种是你正在写C接口兼容层或数据结构内部实现比如链表节点之间的连接。2.4 指针数组 vs 数组指针、二级指针、函数指针——拆开名字看这一组概念是名词题的常客我直接按初学者最容易混淆的名词顺序拆指针数组int* arr[5]。先看arr它有5个元素每个元素类型是int*。所以它本质是“存着5个指针的数组”。一个很实用的例子是const char* names[3] {Alice, Bob, Cathy};names是一个指针数组3个元素分别指向三个字符串字面量。数组指针int (ptr)[5]。这里ptr先和结合所以ptr是一个指针它指向的类型是int[5]即“一个含5个int的数组”。它通常用来指向二维数组的一行。区别的唯一方法是看优先级[]的优先级高于*所以int* arr[5]里arr首先是个数组而int (ptr)[5]用小括号强行让ptr先和结合所以它首先是个指针。二级指针int** pp。拆开看pp是一个指针它指向的类型是int*即指向一个指针。什么时候需要它当函数需要修改调用方的指针时就要传入指针的指针。void initNode(Node** ppHead) { *ppHead new Node(); }如果不加那一层间接函数内部修改的只是指针的拷贝调用方的指针不会变化。函数指针和指针函数是另一个经典陷阱。函数指针是“指向函数的指针”比如int (fp)(int, int)它可以用来做回调、做策略切换。指针函数则是“返回值为指针的函数”比如intgetArray()这只是一个普通函数的返回类型描述和指针概念没有本质联系。顺带提一句文件指针FILE* fp是C标准库读取文件时返回的句柄它也是一个指针但已经被标准库封装成不完全透明的结构体指针。你不需要直接解引用它只需要把它传给fread、fclose这些函数。最后补一句现代C的建议裸指针管理内存太容易出问题C11以后提供了unique_ptr和shared_ptr。它们用起来和裸指针很类似但析构时自动释放内存。后面第5章我会继续展开这个话题。3. 链表指针的第一个实战现场3.1 结构体加指针链表节点是怎么定义出来的链表的核心是节点。一个节点既要存数据又要知道下一个节点在哪所以定义天然用结构体加指针struct Node { int data; Node* next; };这里的next是一个指向另一个Node的指针。这就是为什么要把指针学扎实才能学链表——没有next成员节点就是孤岛链不起来。创建最简单的三个节点并连接Node* head new Node{1, nullptr}; head-next new Node{2, nullptr}; head-next-next new Node{3, nullptr};head-next的含义是先解引用head指针取到那个Node对象再访问它的next成员。它等价于(*head).next但写箭头更直观。千万注意head-next本身也是一个指针所以可以继续用-往下走。为什么链表能动态增长数组的大小在编译时必须确定或者运行时一次性分配固定大小。链表每个节点都是独立new出来的想加一个节点就new一个Node想删一个就delete掉完全不需要一次性申请一片连续的大内存。这里放一个对比表方便你理解数组和链表各自的定位操作数组链表随机访问第i个元素O(1)O(n)在头部插入O(n)要搬移O(1)在尾部插入O(1)有扩容余量时O(n)无尾指针在已知节点后插入O(1)有容量O(1)内存布局连续、紧凑不连续、有指针开销STL里的std::list就是对这种结构的封装。我不是说STL不需要学而是你先亲手用指针实现一遍链表才能真正理解list的迭代器为什么是这样设计的以及为什么链表的插入操作不会让其他迭代器失效。3.2 带头结点与不带头结点的取舍单链表有两种常见实现方式一种是直接用head指针指向第一个数据节点另一种是加一个“头结点”也叫虚拟头结点dummy node这个节点不存有效数据它的next才指向第一个数据节点。不带头结点的写法是数据结构教材里的经典因为它更接近“链表本身”的定义空表就是head nullptr。但它在代码上有个痛点——插入和删除第一个节点时必须修改head本身// 不带头结点头插 Node* newNode new Node{val, head}; head newNode; // 必须让 head 指向新节点因为在函数里修改调用方的head你需要传Node**也就是二级指针void insertHead(Node** ppHead, int val) { Node* newNode new Node{val, *ppHead}; *ppHead newNode; }而带头结点的版本就优雅得多。head永远指向那个dummy节点它不随任何插入删除而改变数据节点只在dummy之后Node* head new Node{0, nullptr}; // dummy 节点data 字段用不到 // 头插 Node* newNode new Node{val, head-next}; head-next newNode;两种做法对比一下对比点不带头结点带头结点空表状态head nullptrhead-next nullptr头插入/删除需要修改head要传二级指针不需要修改head遍历起点headhead-next代码统一性首节点特殊处理多所有位置逻辑统一面试手写链表题时很多题目默认要求不带头结点因为题目更考察边界条件。工程实现、做项目时我几乎一律带头结点因为它让代码逻辑统一减少一半的边界判断错误。3.3 插入、删除、遍历边界条件决定正确性链表的全部基本功可以压缩成三个操作插入、删除、遍历。我直接给出一个带头结点的完整实现你可以在编译器里跑起来。#include iostream struct Node { int data; Node* next; }; // 在链表的第 pos 个位置插入 valpos 从 0 开始 void insert(Node* head, int pos, int val) { Node* p head; for (int i 0; i pos p-next ! nullptr; i) { p p-next; } Node* newNode new Node{val, p-next}; p-next newNode; } // 删除第 pos 个节点 void remove(Node* head, int pos) { Node* p head; for (int i 0; i pos p-next ! nullptr; i) { p p-next; } if (p-next ! nullptr) { Node* toDelete p-next; p-next toDelete-next; delete toDelete; } } void printList(Node* head) { for (Node* p head-next; p ! nullptr; p p-next) { std::cout p-data ; } std::cout std::endl; }注意几个边界插入的关键是“先连后断”。必须先让newNode-next指向p-next再让p-next指向newNode。如果顺序反了先改p-next就会丢掉原来后面的整个链表。删除的关键是“保存待删节点”。如果直接写p-next p-next-next后续就无法delete那个脱离链表的节点了造成内存泄漏。遍历的关键是“不要移动头指针”。很多人图方便直接写while(head ! nullptr) { head head-next; }。如果head是dummy节点或真正的头节点这么一玩链表头就丢了后面再也无法从头开始。正确做法是用一个局部变量p充当游标。数据结构课里“单链表的基本操作实验”通常就是这三段代码加上释放全部节点。建议你亲手补一个freeList函数并且用调试器单步跟一遍观察每个指针的变化这比抄十遍都管用。3.4 循环单链表从 nullptr 终止变成 head 终止循环单链表和普通单链表的唯一区别是最后一个节点的next重新指向了头结点形成一个环。Node* head new Node{1, nullptr}; Node* second new Node{2, nullptr}; Node* third new Node{3, nullptr}; head-next second; second-next third; third-next head; // 关键形成环遍历终止条件随之改变。普通链表判断p ! nullptr循环链表判断p ! headNode* p head-next; while (p ! head) { std::cout p-data ; p p-next; }小心如果判断条件写错遍历会无限循环。写循环链表代码第一件事就是想清楚终止条件。循环链表一个非常经典的应用是约瑟夫环问题——一群人围成一圈报数报到特定数字的人出列直到剩下最后一个。这个问题的数据结构原型就是循环链表。另一个常见场景是操作系统的轮转调度、游戏里循环播放的动画帧列表。你想用C写个小游戏的话循环链表是处理“一轮一轮循环”的逻辑时很趁手的结构。释放循环链表时还有一个坑直接从头指针遍历delete会因为环的存在永远走不到nullptr。正确做法是先把环断开比如先保存head-next再把尾节点的next置成nullptr然后按普通链表方式释放。3.5 单链表逆序三步迭代法链表题里出镜率最高的大概就是逆序了。不带头结点的单链表逆序核心思路是维护三个指针prev前一个节点、cur当前节点、next下一个节点。Node* reverse(Node* head) { Node* prev nullptr; Node* cur head; while (cur ! nullptr) { Node* next cur-next; // 第一步先保存下一个节点 cur-next prev; // 第二步把当前节点的指针指向前一个 prev cur; // 第三步prev 向前移动 cur next; // 第四步cur 向前移动 } return prev; // 循环结束时 prev 是新的头 }为什么第一步必须先保存next因为第二步修改了cur-next之后原来的cur-next就丢了如果不提前保存后面的节点一个也找不回来。这是第一次写链表逆序最容易踩的坑。这段算法的复杂度是O(n)时间、O(1)额外空间已经是最优。递归写法也能实现Node* reverseRecursive(Node* node) { if (node nullptr || node-next nullptr) return node; Node* newHead reverseRecursive(node-next); node-next-next node; node-next nullptr; return newHead; }递归写法理解起来更绕但如果你想在看源码时不吃力建议花时间把递归版本也讲一遍给自己听特别是node-next-next node这一步它实现的是“把下一个节点的指针反转”。4. 缓冲区连续内存、越界写入与溢出防护4.1 缓冲区是什么为什么到处都要缓冲缓冲区就是一段固定大小的连续内存用来临时存放数据等待后续处理。它在不同场景有不同名字数组是缓冲区std::string内部的存储是缓冲区网络协议栈里接收数据的包缓冲是缓冲区。为什么需要缓冲区根本原因是数据生产和数据消费的速度不匹配。一个经典的类比是水库河流生产者的水量波动很大下游消费者需要稳定的水流。水库缓冲区把瞬时的大水量暂存起来再按下游能接受的速度放水。程序里的例子printf并不是每输出一个字符都立刻写屏幕而是先把内容攒到缓冲区里攒满一行或遇到换行再一次性写。文件操作也一样每次读写一个字节的性能很差操作系统和标准库都会用缓冲区把读写聚合成大批量。网络收包更是如此网卡收到的数据先放入内核缓冲区应用层再从缓冲区读走。工程里的“写入内存缓冲区”这个说法往往就是在说把数据先拷贝到一段预留的连续内存里等待后续处理或传输。工控场景里西门子CP341这类通信模块也有自己的缓冲区用于临时存放串口收到的一包一包数据应用层程序按帧前去取。这些场景的共同点是没有缓冲区高速、突发的数据就会丢失。顺带澄清一点搜索词里还有个“体育休闲服务设施缓冲区”那个是城市规划层面的概念讲的是公园、体育设施周边需要保留的隔离和过渡区域跟程序里的缓冲区没有任何关系。4.2 栈缓冲区溢出一次越界写引发的连锁反应缓冲区溢出的本质是向一块缓冲区里写入了超出其容量的数据多出来的数据越过了边界覆盖了相邻内存。最出名、也最容易被初学者遇到的是栈缓冲区溢出。看一下这个典型的脆弱函数void vulnerable(const char* input) { char buf[8]; // 栈上分配 8 字节 strcpy(buf, input); // 危险input 长度未知 }函数调用时栈上会依次排布函数参数、返回地址、局部变量等。buf在这8字节的正后方从地址角度看越界方向正好朝着返回地址所在的方向。如果input的内容超过8字节strcpy会继续往后写一直到覆盖掉保存在栈上的返回地址。等函数执行完毕CPU要恢复返回地址并跳回去继续执行时发现这个地址已经被你输入的数据改写了——轻则程序崩溃重则被恶意构造的数据劫持到攻击者指定的位置执行代码。“系统在此应用程序中检测到基于堆栈的缓冲区溢出”这句话我在很多论坛上都看到过有人以为电脑中了病毒有人认为是操作系统坏了。其实它真正含义是程序栈上的某些关键数据通常是一个叫做安全Cookie的随机值在函数返回前被检测到被改动说明发生过越界写入。这是Visual C编译器的/GS安全选项在做检查是系统帮你拦住了一次灾难而不是系统本身出了毛病。像explorer.exe有时也弹这个错往往是因为第三方软件或者驱动注入了一段代码里面的越界写把资源管理器的栈破坏了。这在老版本的Windows环境里尤其常见也是为什么微软一直提醒要安装最新的VC运行时库更新。4.3 危险函数与今天的正确写法老C标准库里有一批臭名昭著的“不检查长度”的函数。gets、strcpy、sprintf、scanf配合%s时都不会自动判断目标缓冲区是否能装下输入数据。这是大量安全漏洞的根源。现在正确的做法分几个层次第一层能用标准库容器尽量用容器void safe(const std::string input) { std::string buf input; // 自动按需扩容不会溢出 }第二层必须用C风格数组时明确限制写入长度void safe(const char* input) { char buf[8]; strncpy(buf, input, sizeof(buf) - 1); buf[sizeof(buf) - 1] \0; // 手动保证结尾有终止符 }第三层使用带边界检查的格式化函数char buf[32]; snprintf(buf, sizeof(buf), id%d, user_id);memset、memcpy这类内存操作函数也存在同样的问题任何涉及“向一块内存写入数据”的调用动手之前先问自己三个问题目标缓冲区多大我准备写多少这两个数字是否匹配安全意识的起点就是对“越界”这两个字保持敏感。一个缓冲区越界表面上是程序崩溃实际上往往是安全漏洞的根源。攻击者利用越界写入篡改变量、覆盖返回地址甚至提权执行代码都是从这个点开始的。写代码时多写一个边界检查比事后用杀毒软件扫一百遍都管用。4.4 环形缓冲区与工控场景里的通信缓冲环形缓冲区是缓冲区的一种特殊形态。它把一段连续内存的首尾逻辑相接维护两个指针写指针写入位置和读指针读取位置。写入时数据往写指针处放写指针后移读取时从读指针处取读指针后移。到达数组末尾就绕回开头所以叫“环形”。为什么需要环形因为数据是持续产生、持续消费的如果只用线性缓冲区要么频繁搬移数据要么不断申请释放内存。环形结构让内存反复循环利用特别适合生产者-消费者模型。例如音频采集线程持续写入音频帧播放线程同时从中读取环形缓冲就是中间的转运站。网络协议处理、内核日志、消息队列也大量使用这种结构。理解环形缓冲的关键是两个指针和一个空/满判断。判断空和满不能只看读写指针是否相等因为相等既可能是空也可能是满一般通过额外计数器或者保留一个空位来区分。工控场景里CP341作为西门子的串口通信处理器内部也有缓冲区。当串口线上到达一包数据CP341先把整包存入自己的接收缓冲区处理器通过程序去取。这种机制保证了数据不会因为处理器没及时处理而丢失。写工控上位机时如果发现“收包偶尔不完整”或“乱码”先去查通信参数的缓冲区大小设置是值得优先考虑的方向。5. 三者在真实工程中的协同内存生命周期、绑定写入与空指针排查5.1 链表的堆内存生命周期动态分配、释放与智能指针链表节点的new在堆上分配堆本质上是一个巨大的、由运行时管理的缓冲区池。你需要一片内存时就去申请用完后必须归还。手动管理生命周期很容易犯错Node* p new Node{1, nullptr}; // 忘了 delete p // 内存泄漏程序长期运行后内存越占越多Node* p new Node{1, nullptr}; delete p; // 之后继续用 p 就变成了悬空指针 std::cout p-data; // 未定义行为悬空指针是野指针的主要来源之一。指针指向的内存已经被释放了但指针变量里还残留着那个地址。此刻那块内存可能已经被其他数据占用继续解引用就会读到不可预期的值。我见过好些“线上程序偶尔崩溃”的现场排查到最后是一个delete之后没置空、又在别处解引用的悬空指针。习惯性在delete之后立刻置空delete p; p nullptr;如果整个代码里用智能指针这个问题能被从根上规避。用std::unique_ptr定义的链表节点长这样struct Node { int data; std::unique_ptrNode next; };节点析构时unique_ptr的析构会递归释放整条链表不用你手动delete。这带出一个新手经常忽略的事实链表的动态内存管理和缓冲区、指针直接相关。new分配的内存就是缓冲区指针管理不当时缓冲区就会泄漏或悬空。5.2 数据库写入里的指针与缓冲区以 taos_stmt_prepare 为例现代C工程里指针和缓冲区并不只存在于教科书链表里。数据库写入就是一个活生生的例子。以TDengine的C绑定为例你启动c绑定写入数据库时会接触到taos_stmt_prepare这组预处理语句API。简单的流程是这样的TAOS_STMT* stmt taos_stmt_init(conn); const char* sql INSERT INTO meters VALUES (?, ?, ?); taos_stmt_prepare(stmt, sql, strlen(sql)); // 准备模板 // 用参数绑定把C变量的地址交给预处理语句 taos_stmt_bind_param(stmt, params); // params 是一个结构体数组 taos_stmt_execute(stmt); // 执行 taos_stmt_close(stmt); // 清理注意bind_param这一步它接收的不是数据的拷贝而是数据的指针。底层引擎拿到指针之后把数据内容复制到它自己内部管理的参数缓冲区里等execut时统一写入。这里用到了两个本篇的核心概念指针告诉引擎“数据在哪”缓冲区告诉引擎“数据暂存在哪”。为什么用预处理语句一是性能好SQL模板可以重复使用二是参数和SQL分离能防止注入攻击。组装params结构体时如果缓冲区大小估算错误、字段偏移算错同样会造成越界写。所以数据库绑定的底层仍然是“指针指向正确的地址缓冲区尺寸匹配字段长度”这条铁律。5.3 空指针、野指针和越界访问的排查链路实际开发中最常遇到的报错就是“空指针”和“越界访问”。拿一个常见场景举例定时器执行查询时报空指针。我的排查链路永远是这三步第一步检查函数的返回值。很多API返回空指针表示失败。比如数据库连接失败、查询结果为空、文件打开失败都会返回nullptr。在空指针上调用方法就崩了。所以调用任何可能返回指针的API之后第一行就判断是否为nullptr。TAOS_RES* result taos_query(conn, sql); if (result nullptr) { std::cerr 查询失败: taos_errstr(conn) std::endl; return; }第二步检查对象的生命周期。这是最隐蔽的问题。一个对象已经被delete或离开了作用域但另一个地方还持有指向它的指针。定时器回调里常见的空指针很多是回调触发时对象已经被释放了。解决思路是让持有方使用shared_ptr或者注册回调时把对象的弱引用传过去。第三步检查指针初始化。有些代码在全局变量里声明了指针使用时却忘记赋值甚至初始值还是零。这就回到2.1节的老规矩指针声明必须初始化。排查时善用调试器在崩溃处查看指针值是不是0x0在watch窗口里展开指针看看data字段是否合理。越界访问的另一个隐蔽表现是某个变量莫名其妙变了明明代码里没有写它——那大概率是旁边的数组越界把这块内存覆盖了。6. 用vscode搭好环境把链表实打实跑一遍6.1 配置编译器与 tasks.json最小可编译闭环光看不练指针永远学不透。我建议用vscode配一套最轻量的C环境。步骤很简单第一步安装MinGW-w64把bin目录加入系统PATH。装好后在终端敲g --version确认可以找到编译器。注意选择g而不是gcc因为我们写的是C代码要用C编译器来参与链接。第二步在vscode里按CtrlShiftP打开“C/C: 编辑配置(JSON)”确认编译器路径正确。第三步配置构建任务。在.vscode/tasks.json里写入{ version: 2.0.0, tasks: [ { label: C 编译, type: cppbuild, command: g, args: [ -g, ${file}, -o, ${fileDirname}/${fileBasenameNoExtension}.exe ], group: build, problemMatcher: [$gcc] } ] }这个配置的意思是用g编译当前打开的文件加-g参数以便生成调试信息输出到当前目录下同名exe。之后按CtrlShiftB就能一键编译。很多人会遇到编译环境相关的报错比如提示找不到libgcc_s或libstdc。这是MinGW的运行时库问题把MinGW的bin目录加到PATH里即可。另外Visual C Redistributable是另一回事它主要是给用Visual Studio编译出来的程序提供运行时组件。如果你装了VS但用vscodeMinGW写代码两者不要混淆。路径里不要有中文和空格否则一系列工具链工具都会莫名出错先把这一条注意了能省掉很多麻烦。6.2 launch.json 断点调试把链表内部结构看清楚编译能过只是第一步真正理解链表必须用调试器单步看指针。按F5新建调试配置在launch.json里填入{ version: 0.2.0, configurations: [ { name: C 调试, type: cppdbg, request: launch, program: ${fileDirname}/${fileBasenameNoExtension}.exe, args: [], stopAtEntry: false, cwd: ${fileDirname}, environment: [], externalConsole: false, MIMode: gdb, setupCommands: [ { description: 启用 gdb 美化打印, text: -enable-pretty-printing, ignoreFailures: true } ], preLaunchTask: C 编译 } ] }在创建链表的代码行上打一个断点F5启动调试然后不断按F10单步同时在“监视”面板里添加head指针。你看到的不是一堆抽象概念而是一串真实存在的地址head是一个十六进制数值展开后里面有data和nextnext展开后又是一个节点next的next又指向下一个。这个画面比背十遍“next是指向下一个节点的指针”都管用。把第3章的插入、删除函数也逐行跟一遍。你会亲眼看到插入时new节点拿到了一块新地址删除时delete之后那块内存被回收。内存这件事在纸上画是想象在调试器里看是实证。如果后续你想做C小游戏这类项目这个调试环境会一直派上用场。链表写熟练之后事件队列、游戏对象管理你都会下意识地想到它。我自己的习惯是给新人布置的第一道链表训练题永远不是背代码而是这三件事把带头结点和不带头结点各实现一遍用调试器单步走完插入和删除全过程画一张纸上的内存图标出每一步之后每个指针的指向。做完这三件事后面学树、图、网络缓冲区这些高层次内容速度会快得让你意外。指针的卡壳从来不是智商问题只是少了一次真正盯着内存地址变化的机会。
返回列表