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

C/C++指针进阶:内存模型、二级指针、函数指针与智能指针

发布时间:2026/9/29 1:30:17

资讯中心
01
ARTICLE

C/C++指针进阶:内存模型、二级指针、函数指针与智能指针

C/C++指针进阶:内存模型、二级指针、函数指针与智能指针
第一次被指针劝退大概是在看完教材上那句指针就是地址之后——字都认识合起来就是不明白。后来工作里带过几届新人我发现问题从来不在指针难而在于大多数教程一上来就讲语法却没把内存这张地图先铺开。你看不懂指针本质上是脑子里没有内存是按字节编号的连续空间这个画面。这篇我就按带人的路子来从内存模型一路讲到二级指针、函数指针、const 顶层底层、C 智能指针最后再聊聊快慢指针这种跨语言套路和崩溃现场的排查手法。c语言指针、指针变量、指针数组、函数指针、智能指针、指针进阶这些点都会覆盖到适合刚接触 C/C 的同学也适合写了几年但一直对指针半懂不懂、想系统补一遍的人。读完之后至少再看到int (*p)[10]这种声明你不会再靠猜。1. 先把指针从神坛上拽下来它就是个存地址的变量1.1 内存是一排带编号的储物柜你把内存想成一栋巨大的公寓楼每个房间大小固定是一个字节8 位门牌号就是地址。楼里房间挨着编号0、1、2、3 一路排下去。当你写int a 10;编译器就在这栋楼里挑四个连续房间因为 32 位 int 占 4 字节给你放 10 这个数假设是从 1000 号房间开始那么 a 的地址就是 1000。指针做的事情特别朴素它自己也是个变量也要占房间只不过它房间里放的不是业务数据而是别人的门牌号。int *p a;这句话翻译成人话就是给我找一个房间叫 p把 1000 这个门牌号写进去。这里有个很多人第一次听会愣住的点——指针变量本身也有地址p是合法的取出来的是 p 自己那个房间的编号。这个性质是后面二级指针能成立的全部基础。提示理解指针最快的办法不是背语法而是每次都画一张小图左边一列写变量名右边写地址中间画箭头。新手卡住的时候八成是脑子里缺了这张图。1.2 指针的三要素类型、值、指向一个指针有三个东西要分清楚混在一起就是灾难的开始。指针的类型决定了两件事一是解引用时从哪个地址开始读几个字节二是p1到底加几。指针的值就是一个整数编号本身没什么神秘的。指针指向的那块内存是另一个独立的东西它有自己的内容。举个例子int *p和char *q如果值都是 1000那*p会从 1000 开始读 4 个字节按 int 解释而*q只读 1 个字节按字符解释。同一把钥匙开的门不一样这就是类型的作用。至于p1int 指针会跳到 1004char 指针只会跳到 1001。很多初学时的诡异 bug比如数组遍历少走一半根子就在这儿你以为加的是 1其实编译器帮你加的是sizeof(类型)。1.3 和 * 这对运算符到底在干什么叫取地址运算符它的输入是一个左值能放在赋值号左边的东西输出是这个左值所在内存的地址结果类型是指向该类型的指针。*在作为一元运算符出现时叫解引用输入是一个指针输出是它指向的那块内存本身而且这个结果是左值可以写。所以*p 20;是合法的它的意思是把 20 写到 p 指向的那块内存里去。这里有个新手常犯的混淆*在声明里和表达式里是两个身份。int *p;里的*只是声明语法的一部分表示 p 是指针不是解引用操作。*p 20;里的*才是真·解引用。同一个符号两种角色教材上经常一笔带过但这就是很多人读不懂复杂声明的源头。记住一条声明看类型表达式做运算。2. 声明、初始化、赋值三个最容易翻车的动作2.1 为什么写成 intp 而不是 intp这两种写法编译器都认但含义上有个陷阱。看这段int *p, q;如果你脑子里把int*当成一个整体类型你会以为 p 和 q 都是指针。实际上 q 是货真价实的 int只有 p 是指针。因为*是绑定到变量名上的不是绑定到 int 上的。所以我的习惯是永远写成int *p;并且一行只声明一个变量。这个习惯不花一分钱但能省掉无数次的误读。同理int *p, *q;才是两个指针。很多老代码里能看到int* p;这种风格语法没问题只是容易在连续声明时误导人团队里统一一种写法就好。2.2 野指针、空指针、悬垂指针三兄弟别搞混野指针是指针变量定义了但没初始化它里面的值是从栈上继承来的垃圾数据可能指向任何地方。你去解引用它就是在往一个随机房间里扔数据程序大概率直接崩运气不好则是悄悄改坏了别的变量等到很久以后才出问题——这种最难查。空指针是明确赋值为 NULLC或 nullptrC的指针表示我现在不指向任何有效对象。它的价值在于可检查if (p ! NULL)之后再解引用是每个合格 C 程序员的本能。悬垂指针最阴险指针本来指得好好的指向的内存被释放了free或delete但指针变量本身没被清空还留着那个已经失效的地址。这时候再解引用行为未定义。int *p (int *)malloc(sizeof(int)); free(p); *p 10; /* 悬垂指针危险 */ p NULL; /* 释放后立刻置空是好习惯 */注意释放内存后立刻把指针置 NULL这一行代码几乎零成本但能拦下大量半夜被叫起来排查的崩溃。2.3 赋值时类型必须匹配吗C 语言对指针赋值管得比较松int *给char *赋值很多编译器只给个警告直接放行。这不是好事。类型不匹配意味着解引用时读的字节数不对读出来的是垃圾。C 则严格得多不允许隐式互转除非用reinterpret_cast明确表示我知道我在干什么。唯一被允许的例外是void *它能接任何对象指针也能赋给任何对象指针这个后面第 6 章细说。另外把整数直接赋给指针比如int *p 1000;在 C 里是警告在 C 里直接编译不过——因为地址是运行时才知道的东西硬编码地址除了在嵌入式的寄存器操作场景基本没有正当理由。3. 指针运算与数组为什么 p[i] 和 *(pi) 是一回事3.1 数组名退化这条规则先说结论在大多数表达式里数组名会被自动转换成指向首元素的指针。也就是说arr出现在表达式里时等价于arr[0]类型是int *。这不是编译器偷懒而是 C 语言从 B 语言继承下来的设计目的是让数组和指针能共用一套下标语法。但有三个例外你必须记住否则迟早翻车。第一sizeof(arr)里数组名不退化得到的是整个数组的字节数而sizeof(p)p 是指针得到的永远是指针本身的大小64 位系统上是 8。第二arr里不退化它得到的是指向整个数组的指针类型是int (*)[N]跟arr[0]完全不是一回事。第三用字符串字面量初始化字符数组时也不退化。这三个例外是面试高频考点也是实际调 bug 时最容易误判的地方比如你把数组传进函数函数里再sizeof一下永远得到 8因为传进去的早就不是数组了。3.2 从定义推导 p[i] 的等价形式C 标准里说得很直白E1[E2]等价于*((E1)(E2))。所以arr[i] /* 等价于 *(arr i) */ *(arr i) /* 等价于 *(i arr) */ i[arr] /* 居然也是合法的 */最后那个i[arr]我第一次见时以为是段子试着编译了一下居然跑通了。原因就是加法满足交换律*(iarr)和*(arri)完全一样。当然实际项目里没人这么写但你理解了这一层就再也不会把p[i]当成某种魔法语法了——它从头到尾都只是先算地址再解引用。3.3 指针减法算的是元素个数不是字节数两个同类型的指针相减结果是它们之间相隔多少个元素不是多少字节。这是标准规定的行为编译器会自动除以sizeof(类型)。int arr[5] {1, 2, 3, 4, 5}; int *begin arr; int *end arr 5; printf(%ld\n, end - begin); /* 输出 5不是 20 */这个特性最大的用处是遍历和求长度。但前提是两个指针必须指向同一块连续内存否则结果是未定义的。我见过有人拿两个不同数组的指针相减想算总跨度那是纯粹的想当然。另外提醒一句end指向的是数组最后一个元素的下一个位置这个位置可以算、可以比较但绝对不能解引用这是很多人写循环时多走一步导致崩溃的原因。4. 指针数组 vs 数组指针一字之差内存布局差出一个世界4.1 优先级决定了这两个东西的本质中文里指针数组和数组指针只差一个字英文原文反而更清楚array of pointers 和 pointer to array。判断方法靠运算符优先级——[]的优先级高于*所以int *a[10]; /* 指针数组a 是数组10 个元素每个是 int* */ int (*b)[10]; /* 数组指针b 是指针指向一个含 10 个 int 的数组 */判断窍门是先看变量名和谁结合。int *a[10]里a 先和[]结合所以 a 是数组元素类型是int *。int (*b)[10]里括号改变了顺序b 先和*结合所以 b 是指针指向的类型是int[10]。内存布局差别很大a是 10 个指针并排64 位系统上占 80 字节不算指向的内容b只是 1 个指针占 8 字节。sizeof(a)是 80sizeof(b)是 8一行代码就能验证你的理解对不对。4.2 指针数组存放字符串的正确姿势指针数组最经典的用途就是存一组字符串char *names[] {Tom, Jerry, Spike}; printf(%s\n, names[1]); /* 输出 Jerry */这里每个元素是一个char *指向各自字符串字面量的首字符。要注意的是字符串字面量存在只读段names[0][0] X;这种修改行为是未定义的在多数平台上会直接段错误。如果你确实要改得用二维数组char names[][10] {...}把内容拷到可写内存里。顺带提一句char *names[]和char names[][10]这两种写法在函数参数里长得像但语义差别很大前者是不定长的指针数组后者是每行固定 10 字节的二维数组。传参时如果搞混函数内部按错的方式访问读出来的就是乱码。4.3 二维数组传参为什么必须写数组指针这是新手最常遇到的编译错误之一。看这段void print(int arr[][4], int rows); /* 正确 */ void print(int **arr, int rows); /* 错误类型不兼容 */为什么第二种不行因为二维数组int a[3][4]在内存里是 12 个 int 连续排列a[0]和a[1]相差 16 字节。传参时数组名退化a变成指向int[4]的指针也就是int (*)[4]。而int **是指向 int 指针的指针它的语义是先取一个指针再跳到那个地址去跟连续内存的布局完全对不上。所以编译器会报类型不匹配。正确的思维模型是二维数组的第二维长度是类型的一部分必须写进参数里编译器靠它来计算行与行之间的跳跃步长。5. 二级指针与函数传参为什么函数里改不了外面的指针5.1 值传递的本质形参永远是实参的副本C 语言只有值传递这句话很多人背过但没真正消化。函数调用时实参的值被拷贝一份给形参之后函数里动的全是副本跟外面半毛钱关系没有。void change(int *p) { p NULL; /* 只改了副本 */ } int main() { int a 1; int *q a; change(q); /* q 依然指向 a不是 NULL */ }如果你想让函数改变外面的q就得把q的地址传进去也就是传q类型是int **void change(int **p) { *p NULL; /* 通过二级指针改到了一级指针本身 */ }理解这条的关键是先分清楚你想改的是指针指向的内容还是指针本身。改内容一级指针够用改指针本身让它指向别处或置空必须二级指针。5.2 什么时候真的需要二级指针最典型的是函数内部申请内存并返回给调用者int alloc_buffer(char **buf, int size) { *buf (char *)malloc(size); if (*buf NULL) return -1; return 0; }如果只传char *buf函数里malloc的结果赋给了副本函数一返回外面拿到的还是原来的 NULL而且那块内存还泄漏了。这个 bug 在实际项目里非常常见尤其在封装 API 的时候。另一个场景是操作指针数组比如给一组字符串排序排序过程中要交换指针本身这时形参通常写成char **。5.3 二级指针和指针数组的配合char **argv这种写法你肯定见过它就是二级指针指向指针数组的典型。char *argv[]在函数参数里等价于char **argv因为数组名退化。所以argv[0]是一个char *argv[0][0]是一个 char。理解这一层之后你会发现多级指针并不玄学几级指针就代表你要跳几次才能到真正的数据。int ***p就是跳三次。实际工作中超过三级基本就该重构了可读性会崩。6. 结构体指针、函数指针与 void* 万能指针6.1 结构体指针与箭头运算符结构体指针用起来比普通指针多一个语法糖。(*p).name写起来太啰嗦于是有了p-name两者完全等价。注意-左边必须是指针.左边必须是结构体本身这俩搞混是编译期错误容易发现不算坑。真正容易出问题的是结构体的内存布局。编译器为了对齐会在成员之间插入填充字节所以sizeof(struct)常常大于各成员大小之和。64 位下这个结构体通常占 16 字节而不是 12struct S { char c; /* 1 字节后面填 3 字节 */ int i; /* 4 字节 */ char d; /* 1 字节后面填 3 字节 */ };如果你把结构体指针强转成char *然后逐字节读读出来的填充位是随机值拿去做哈希或者比较会得到不稳定结果。要比较就用逐成员比较别图省事用memcmp。6.2 函数指针和指针函数读法决定用法名字只差一个字含义天差地别int *f(int); /* 指针函数返回 int* 的函数 */ int (*f)(int); /* 函数指针指向接收 int 返回 int的函数 */判断方法和数组那一章一样看变量名先和谁结合。int *f(int)里 f 先和(int)结合说明 f 是函数返回值是 int*。int (*f)(int)里 f 先和*结合说明 f 是指针。函数指针的实用价值在于把行为当参数传。比如写一个通用排序void sort(int *arr, int n, int (*cmp)(int, int));调用者传不同的比较函数就得到升序或降序。这是回调机制在 C 里的实现方式Qt 的信号槽、Linux 内核的各种 ops 结构体本质都是这个思路。函数指针还有个省事的写法叫 typedeftypedef int (*CmpFunc)(int, int); void sort(int *arr, int n, CmpFunc cmp);可读性立刻上一个台阶强烈建议在项目里这么写。6.3 void* 的能耐和边界void *是通用指针任何对象指针都能隐式转成它它也能隐式转回去C 里C 转回来要显式转换。malloc返回的就是void *标准库的qsort、memcpy也用它来保持通用性。但它有个硬边界不能解引用不能做指针运算。因为编译器不知道它指向的东西有多大*p和p1都无从下手。要用必须先转成具体类型。这个限制其实是保护逼你在解引用前想清楚类型减少误用。7. const 与顶层/底层指针类型系统里的隐藏关卡7.1 从右往左读的声明法则C 的声明读取有个经典口诀从变量名出发先向右看再向左看遇到括号就跳出去。拿几个例子练一遍声明读法const int *pp 是指针指向 const intint * const pp 是 const 指针指向 intconst int * const pp 是 const 指针指向 const intint (*p)[10]p 是指针指向含 10 个 int 的数组int *p[10]p 是数组含 10 个指向 int 的指针这张表建议存下来。判断 const 到底修饰谁就看 const 在*的左边还是右边左边修饰指向的内容右边修饰指针本身。7.2 顶层 const 与底层 const 的分界这是 C 里的概念但理解它对写 C 也有帮助。顶层 const表示指针自己不能被改底层 const表示指针指向的对象不能被改。int a 1; int *const p1 a; /* 顶层 constp1 不能改指向 */ const int *p2 a; /* 底层 const*p2 不能改 */ const int *const p3 a; /* 两个都有 */有个直观的记法看*和 const 的相对位置。const 在*右边紧挨变量名是顶层的const 在*左边挨着类型是底层的。7.3 顶层指针和底层指针可以互相赋值吗这个问题是 C Primer 里的经典习题答案是赋值时顶层 const 会被忽略底层 const 必须兼容。规则拆开看两条第一拷贝一个对象时顶层 const 不影响赋值。const int ci 3; int i ci;合法因为拷贝的是值ci 本身的 const 属性丢了也没关系。第二赋值双方必须具有相同的底层 const 资格或者能进行加 const的转换int *转const int *是允许的反过来不行否则就能绕过 const 保护去改只读对象了。所以int *p a; const int *q p; /* 合法加 const */ int *r q; /* 非法去 const 需要 const_cast */ int *const s p; const int *t s; /* 合法s 的顶层 const 被忽略剩下的 int* 转 const int* 也合法 */一句话总结顶层随便底层不许松。底层 const 只能收紧不能放松顶层 const 赋来赋去无所谓。理解这条之后你再看编译器那些关于 discard qualifiers 的报错就知道它在说什么了。8. C 智能指针给裸指针套上自动挡8.1 unique_ptr 的独占语义C11 引入智能指针核心目标就是治忘记 delete和异常路径下漏 delete这两个老毛病。unique_ptr表示独占所有权同一时刻只能有一个 unique_ptr 指向那个对象。它不能被拷贝只能被移动。std::unique_ptrint up(new int(5)); std::unique_ptrint up2 std::move(up); /* up 变成 nullptr */它的大小和裸指针一样没有额外开销性能上完全不用担心。函数返回 unique_ptr 是安全的编译器会用移动语义把所有权交出去。类里面当成员使用时记得实现移动构造和移动赋值否则编译器会因为无法拷贝而删掉这些函数。8.2 shared_ptr 的引用计数机制shared_ptr允许多个指针共享同一个对象内部维护一个引用计数每多一个拷贝就加一析构一个就减一减到零时释放对象。引用计数存在堆上的一个控制块里所以 shared_ptr 的大小是裸指针的两倍。它的坑主要有两个。一是循环引用A 持有 B 的 shared_ptrB 又持有 A 的 shared_ptr两边计数都不归零内存永远不释放。二是线程安全的理解偏差引用计数的增减是原子的但对象本身的读写不是多线程下仍需自己加锁。8.3 weak_ptr 和循环引用的破解weak_ptr不增加引用计数只是弱引用对象。用之前要通过lock()提升成 shared_ptr提升失败说明对象已经没了。用它来打破循环引用是最标准的做法struct Node { std::shared_ptrNode next; std::weak_ptrNode prev; /* 反向指针用 weak避免循环 */ };8.4 手写一个简化版智能指针想真正理解引用计数自己写一遍是最快的。下面这个只保留核心逻辑template typename T class SimplePtr { T *ptr; int *count; public: explicit SimplePtr(T *p nullptr) : ptr(p), count(new int(1)) {} SimplePtr(const SimplePtr o) : ptr(o.ptr), count(o.count) { (*count); } ~SimplePtr() { if (--(*count) 0) { delete ptr; delete count; } } SimplePtr operator(const SimplePtr o) { if (this ! o) { if (--(*count) 0) { delete ptr; delete count; } ptr o.ptr; count o.count; (*count); } return *this; } T operator*() const { return *ptr; } T *operator-() const { return ptr; } };写完你会发现所谓的智能其实就是把 delete 放进析构函数让编译器帮你调用。真正麻烦的不是计数而是异常安全、自赋值、循环引用这些边界情况。这也是为什么生产环境建议直接用标准库而不是手搓。9. 快慢指针从 C 到 JS 都用得上的通用套路9.1 有序数组原地去重的原理快慢指针是双指针技巧的一种在链表判环、找中点、数组去重里都出现。核心思想是让两个指针以不同速度或不同职责前进用较少的一次遍历完成本需要额外空间的任务。以有序数组原地去重为例数组已经排好序重复元素必然相邻所以可以这样设计慢指针 slow 指向已处理区间的最后一个有效元素快指针 fast 负责往前探路。function removeDuplicates(nums) { if (nums.length 0) return 0; let slow 0; for (let fast 1; fast nums.length; fast) { if (nums[fast] ! nums[slow]) { slow; nums[slow] nums[fast]; } } return slow 1; }时间 O(n)空间 O(1)不需要额外数组。这套写法的关键点在于slow只在发现新值时才前进所以它始终停在最后一个不重复元素的位置返回值自然就是slow 1。9.2 边界条件与常见错误这段代码虽然短但有三个地方容易写错。第一空数组必须提前返回否则nums[0]直接越界。第二循环从 1 开始而不是 0因为第 0 个元素天然是已确认有效的从 0 开始会让 slow 和 fast 重合逻辑混乱。第三返回的是长度不是下标很多人习惯性返回 slow结果少算一个。如果题目放宽成允许最多重复两次思路几乎不变只是判断条件从和 slow 位置比较改成和 slow-1 位置比较再配一个计数器。这类题之所以反复出现在面试里考的不是记忆力而是你对两个指针各自代表什么语义的清晰程度。10. 崩溃现场排查空指针和无效指针的定位手法10.1 报错信息怎么读实际工作里指针相关的报错名称五花八门先要把它们分清楚。C/C 里最常见的是段错误Segmentation fault本质是访问了没有权限的地址通常是解引用了空指针或者已经释放的内存。Java 里对应的是 NullPointerException说的是对象引用是 null 却在调方法虽然语言不同排查思路完全一样——顺着调用栈往上找看这个引用是在哪一步变成 null 的。还有一个容易混淆的情况搜索无效指针这个词你可能撞到 EDA 工具比如 Cadence 的error orcap-5004: error initializing com property pages的报错那跟代码里的指针没有任何关系往往是软件组件注册或者运行环境的问题重装组件或者重置配置才是正解。别看到指针两个字就往内存模型上想先确认报错来自哪一层。10.2 常见崩溃场景对照表现象可能原因排查手段立刻段错误解引用空指针或野指针崩溃前加 NULL 检查用 gdb 看 backtrace随机位置崩溃缓冲区越界写坏了邻居内存用 AddressSanitizer 编译运行释放时崩溃double free 或释放了非堆地址检查是否重复 free是否释放栈上变量长时间运行后才崩悬垂指针或内存碎片Valgrind 查内存泄漏与非法访问多线程下偶发指针共享未加锁用 TSan 检测数据竞争排查指针问题的核心习惯是在怀疑的地方之前加断言把问题往前逼。比如每个解引用前加assert(p ! NULL)崩溃点立刻前移到真正发生问题的地方而不是等到几百行之后才炸。AddressSanitizer 和 Valgrind 这类工具能直接告诉你哪一行越界、哪一块内存被谁释放过比盯着代码空想快十倍。我自己踩过最深的一个坑是在一个长生命周期对象里缓存了另一个对象的裸指针短生命周期对象先析构了之后某次定时任务触发时访问那个指针就崩了。这类生命周期不对齐的问题用智能指针基本能避免但前提是设计阶段就想清楚谁拥有谁。指针本身不难难的是它牵扯到的所有权和生命周期这也是我这些年体会最深的一点语法一天能学会内存思维得靠一个个真实的 bug 喂出来。
02
RELATED NEWS

相关资讯

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

03
WHY YAOTU

想打造同款高转化官网?

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

◈

场景化定制

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

◐

营销型架构

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

▲

全周期服务

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

免费获取你的建站方案

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