C语言指针详解:从“内存地址“到“传址调用“,一次把指针吃透 个人主页https://blog.csdn.net/2501_93119704?spm1000.2115.3001.5343 欢迎大佬们互三写在前面指针不是靠背星号学会的学 C 语言到指针这一章几乎是所有人的劝退点。很多人背了一堆int *p、a、*p的写法可真到自己写的时候还是搞不清什么时候加*、什么时候加、const到底修饰的是谁。其实指针的难难在它背后藏着一个内存模型——你只要想明白内存是怎么编址的、地址怎么存起来、拿到地址又能干什么这三件事指针反而比位运算还直白。这篇复盘我按先搞懂地址、再认识指针变量、最后落到野指针和传址调用的顺序把这一讲重新捋了一遍配上图争取一次讲透。一、内存和地址1.1 内存每个单元一个门牌号先讲个生活中的例子一栋宿舍楼有 100 个房间但如果房间没有编号你朋友想找你只能挨个房间去敲门效率极低。可一旦给房间编上号——一楼101、102、103…、二楼201、202…——朋友拿到房间号就能一下找到你。计算机里也是这么干的。CPU 处理数据需要从内存里读、算完再写回内存。内存怎么高效管理也是把内存划分成一个个内存单元每个单元大小取 1 个字节。先补一个单位换算后面算指针大小时要用1Byte(字节) 8bit(比特位) 1KB 1024 Byte 1MB 1024 KB 1GB 1024 MB 1TB 1024 GB 1PB 1024 TB一个内存单元1 字节里能放 8 个比特位就像一间八人间宿舍每个比特位住一位同学。每个内存单元也都有一个编号——这个编号在计算机里就叫地址。 一句话记住内存单元的编号 地址 指针。C 语言里指针这个词本质就是地址。1.2 究竟该如何理解编址CPU 要访问内存里的某个字节必须知道它在哪。但内存里字节那么多不可能把每个字节的地址都记在纸上而是靠硬件设计完成编址的。就像钢琴、吉他上并没有真的写着哆、来、咪但演奏者照样能准确找到每个音因为制造时就在硬件层面约定好了所有演奏者都知道。编址也是一种约定出来的共识。具体到硬件CPU 和内存之间有大量数据交互靠一组线连起来其中那组专门传地址的线叫地址总线。32 位机器有 32 根地址线每根线只有两态0/1对应电脉冲有无。一根线表示 2 种含义2 根线表示 4 种依次类推32 根就能表示2^32种含义每一种都代表一个地址。地址信息下达给内存内存找到该地址对应的数据再通过数据总线送进 CPU 的寄存器。二、指针变量和地址2.1 取地址操作符理解了内存和地址回到 C 语言。在 C 里创建变量就是向内存申请空间#include stdio.h int main() { int a 10; return 0; }上面这行int a 10;创建了整型变量a向内存申请了4 个字节来放 10。这 4 个字节每个都有地址比如0x006FFD70 0x006FFD71 0x006FFD72 0x006FFD73那怎么拿到a的地址用一个操作符取地址操作符#include stdio.h int main() { int a 10; a; // 取出 a 的地址 printf(%p\n, a); return 0; }按上面的画图例子会打印出006FFD70。注意a取出的是 a 所占 4 个字节中地址较小的那个字节的地址。虽然整型占 4 个字节但只要知道了第一个字节的地址顺藤摸瓜就能访问到后面 4 个字节。2.2 指针变量和解引用操作符*2.2.1 指针变量a拿到的地址是一个数值比如0x006FFD70这个数值有时候也要存起来方便以后用。存哪指针变量。#include stdio.h int main() { int a 10; int * pa a; // 取出 a 的地址存到指针变量 pa 中 return 0; }指针变量也是一种变量只不过它里面存的是地址而且存进去的值都会被当成地址来理解。2.2.2 如何拆解指针类型看到pa的类型是int*怎么理解int a 10; int * pa a;pa左边写的是int *其中*在说明pa 是指针变量前面的int在说明pa 指向的是整型(int)类型的对象。那如果有个 char 类型的变量它的地址该放什么类型char ch w; char * pc ch; // pc 的类型是 char*2.2.3 解引用操作符*地址存起来是为了用。现实里拿到门牌号就能进房间、放东西或取东西C 里拿到地址指针也能通过它找到指向的对象——这里要用解引用操作符*。#include stdio.h int main() { int a 100; int* pa a; *pa 0; // 通过 pa 存的地址找到 a把 a 改成 0 return 0; }第 7 行的*pa就是通过 pa 里存的地址找到它指向的空间所以*pa其实就是a变量*pa 0就是把 a 改成了 0。有同学会想目的就是把 a 改成 0直接写a 0不就完了何必用指针——因为这里把修改 a这件事交给了 pa 来做对 a 的修改就多了一条途径代码更灵活。到后面传址调用你就彻底明白了。IMG22.3 指针变量的大小前面说过32 位机器有 32 根地址线一个地址就是 32 个 bit 位需要4 个字节存64 位机器一个地址是 64 位需要8 个字节存。指针变量是存地址的所以32 位平台下指针变量大小是4 字节64 位平台下指针变量大小是8 字节。#include stdio.h // 指针变量的大小取决于地址的大小 // 32位平台地址是 32 个 bit4 字节 // 64位平台地址是 64 个 bit8 字节 int main() { printf(%zd\n, sizeof(char *)); printf(%zd\n, sizeof(short *)); printf(%zd\n, sizeof(int *)); printf(%zd\n, sizeof(double *)); return 0; } 关键结论指针变量的大小和类型无关。只要是指针变量在同一个平台下大小都相同char*、int*、double*一样大。三、指针变量类型的意义既然大小和类型无关那为什么还要有各种指针类型因为指针类型决定了它怎么用。看下面两点。3.1 指针的解引用对比两段代码调试时观察内存变化// 代码1 #include stdio.h int main() { int n 0x11223344; int *pi n; *pi 0; // 把 n 的 4 个字节全改成 0 return 0; } // 代码2 #include stdio.h int main() { int n 0x11223344; char *pc (char *)n; *pc 0; // 只把 n 的第一个字节改成 0 return 0; }调试可以看到代码 1 把n的 4 个字节全部改成了 0代码 2 只把n的第一个字节改成了 0。 结论指针的类型决定了解引用时有多大的权限一次能操作几个字节。char*解引用只能访问 1 个字节int*解引用能访问 4 个字节。IMG33.2 指针 ± 整数再看一段代码调试观察地址变化#include stdio.h int main() { int n 10; char *pc (char*)n; int *pi n; printf(%p\n, n); printf(%p\n, pc); printf(%p\n, pc 1); printf(%p\n, pi); printf(%p\n, pi 1); return 0; }运行结果能看出char*类型的指针1只跳过1 个字节int*类型的指针1跳过4 个字节。 结论指针的类型决定了指针向前/向后走一步有多大距离。指针1其实是跳过1 个它指向的元素。能1也就能-1。3.3 void* 指针指针里有个特殊类型void*可以理解为无具体类型的指针泛型指针它能用来接收任意类型的地址。但有个局限void*不能直接做±整数和解引用运算。比如把一个 int 的地址赋给char*编译器会报类型不兼容的警告#include stdio.h int main() { int a 10; int* pa a; char* pc a; // ⚠️ 类型不兼容编译器警告 return 0; }而用void*接收就没问题#include stdio.h int main() { int a 10; void* pa a; // ✅ 任意类型地址都能接 void* pc a; *pa 10; // ❌ 不能直接解引用 *pc 0; // ❌ 不能直接解引用 return 0; }那void*有什么用一般用在函数参数部分接收不同类型数据的地址实现泛型编程——一个函数处理多种类型。这在后面的《深入理解指针(4)》里会展开。四、const 修饰指针4.1 const 修饰变量变量本来能改但如果希望一个变量加上限制、不能被修改就用const。#include stdio.h int main() { int m 0; m 20; // m 可以修改 const int n 0; n 20; // ❌ n 被 const 修饰不能直接修改 return 0; }n本质还是变量只是被const修饰后语法上加了限制——你一旦直接改它就报错。但绕过 n、用 n 的地址去改 n却能做到虽然这是在打破语法规则#include stdio.h int main() { const int n 0; printf(n %d\n, n); int* p n; *p 20; // 通过指针偷偷改了 n printf(n %d\n, n); return 0; }n 被 const 修饰目的就是不能被改。如果 p 拿到 n 的地址就能改 n那 const 就形同虚设了。所以接下来让 p 拿到 n 的地址也改不了 n。4.2 const 修饰指针变量const 修饰指针变量放在*的左边还是右边意义完全不一样int * p; // 没有 const 修饰 int const * p; // const 放在 * 的左边 int * const p; // const 放在 * 的右边用四段代码逐个测试改指向的内容和改指针本身哪个能行#include stdio.h // 代码1无 const 修饰 void test1() { int n 10; int m 20; int *p n; *p 20; // ✅ 能改内容 p m; // ✅ 能改指向 } // 代码2const 在 * 的左边 void test2() { int n 10; int m 20; const int* p n; *p 20; // ❌ 不能改内容 p m; // ✅ 能改指向 } // 代码3const 在 * 的右边 void test3() { int n 10; int m 20; int * const p n; *p 20; // ✅ 能改内容 p m; // ❌ 不能改指向 } // 代码4* 左右两边都有 const void test4() { int n 10; int m 20; int const * const p n; *p 20; // ❌ 不能改内容 p m; // ❌ 不能改指向 } 结论背熟这一句就行 -const 在*左边修饰指针指向的内容内容不能通过指针改但指针变量本身能改。 -const 在*右边修饰指针变量本身指针不能再指向别处但指向的内容能改。IMG4五、指针运算指针的三种基本运算指针 ± 整数、指针 - 指针、指针的关系运算。5.1 指针 ± 整数因为数组在内存里是连续存放的只要知道第一个元素的地址顺藤摸瓜就能找到后面所有元素。#include stdio.h int main() { int arr[10] {1,2,3,4,5,6,7,8,9,10}; int *p arr[0]; int i 0; int sz sizeof(arr)/sizeof(arr[0]); for(i 0; i sz; i) { printf(%d , *(pi)); // pi 就是指针 整数 } return 0; }5.2 指针 - 指针两个指针相减得到的是它们之间元素的个数前提指向同一块连续空间。最经典的例子——自己实现strlen#include stdio.h int my_strlen(char *s) { char *p s; while(*p ! \0) p; return p - s; // 指针减指针 字符串长度 } int main() { printf(%d\n, my_strlen(abc)); // 3 return 0; }5.3 指针的关系运算指针也能比大小前提同样是指向同一块空间常用在遍历里#include stdio.h int main() { int arr[10] {1,2,3,4,5,6,7,8,9,10}; int *p arr[0]; int sz sizeof(arr)/sizeof(arr[0]); while(p arr sz) // 指针的大小比较 { printf(%d , *p); p; } return 0; }六、野指针概念野指针就是指针指向的位置是不可知的随机的、不正确的、没有明确限制的。6.1 野指针成因1. 指针未初始化#include stdio.h int main() { int *p; // 局部变量指针没初始化默认是随机值 *p 20; // ⚠️ 往随机地址写数据危险 return 0; }2. 指针越界访问#include stdio.h int main() { int arr[10] {0}; int *p arr[0]; int i 0; for(i 0; i 11; i) { // 当 p 指向超出数组 arr 范围时p 就是野指针 *(p) i; } return 0; }3. 指针指向的空间已释放#include stdio.h int* test() { int n 100; return n; // ⚠️ 返回局部变量的地址函数结束 n 就没了 } int main() { int* p test(); printf(%d\n, *p); // ⚠️ 访问已释放的空间 return 0; }6.2 如何规避野指针① 指针初始化明确指向哪就直接赋地址不知道指向哪就赋NULL。NULL是 C 里定义的一个标识符常量值是 0。0 也是地址但这个地址无法使用读写会报错。#ifdef __cplusplus #define NULL 0 #else #define NULL ((void *)0) #endif#include stdio.h int main() { int num 10; int* p1 num; // 知道指向哪直接赋地址 int* p2 NULL; // 不知道指向哪赋 NULL return 0; }② 小心指针越界申请了哪些空间就只能访问哪些超范围就是越界。③ 不用的指针及时置 NULL用之前检查有效性int main() { int arr[10] {1,2,3,4,5,6,7,8,9,10}; int *p arr[0]; int i 0; for(i 0; i 10; i) *(p) i; p NULL; // 用完及时置 NULL p arr[0]; // 重新让 p 获得地址 if(p ! NULL) // 使用前先判断是否为 NULL { // ... } return 0; }可以把野指针想象成野狗野狗放任不管很危险找棵树拴起来就相对安全了——给指针及时赋NULL就是把野狗拴起来。不过即使拴着也得绕着走、别挑逗用指针前判断是否为 NULL是拴着的野狗就别直接用不是再用。④ 避免返回局部变量的地址就像 6.1 的第 3 个例子别把局部变量的地址 return 出去。七、assert 断言assert.h头文件定义了宏assert()用于运行时确保程序符合指定条件不符合就报错终止。这个宏常叫断言。assert(p ! NULL);程序运行到这一行时验证p是否等于NULL不等于程序继续等于就终止运行并给报错信息。assert(表达式)表达式为真非 0什么都不做为假0就报错往 stderr 写一条信息显示没通过的表达式、所在文件名和行号。好处自动标识文件和行号还能不删代码就开关断言——在#include assert.h前面加#define NDEBUG重新编译就禁用所有 assert想再启用就删掉这行。#define NDEBUG #include assert.hassert 的缺点是引入了额外检查、增加运行时间。所以一般Debug 里用Release 里禁VS 里 Release 会直接优化掉。八、指针的使用和传址调用8.1 strlen 的模拟实现库函数strlen求字符串长度统计的是\0之前的字符个数size_t strlen(const char *str);参数str接收字符串起始地址从起始地址向后逐个字符遍历不是\0计数器就 1。自己模拟实现#include stdio.h #include assert.h int my_strlen(const char *str) { int count 0; assert(str); // 先断言 str 不为空 while(*str) { count; str; } return count; } int main() { int len my_strlen(abcdef); printf(%d\n, len); // 6 return 0; }8.2 传值调用和传址调用学指针是为了用指针解决问题。那什么问题非指针不可——写一个函数交换两个整型变量的值。先凭直觉写一版#include stdio.h void Swap1(int x, int y) { int tmp x; x y; y tmp; } int main() { int a 0; int b 0; scanf(%d %d, a, b); printf(交换前a%d b%d\n, a, b); Swap1(a, b); printf(交换后a%d b%d\n, a, b); return 0; }运行发现根本没交换。为什么调试看地址main 里创建了 a 和 b比如 a 的地址0x00cffdd0、b 的地址0x00cffdc4调用Swap1时把 a、b 的值传给了形参 x、yx 的地址是0x00cffcec、y 是0x00cffcf0x、y 确实收到了 a、b 的值但 x 的地址和 a 不一样y 和 b 不一样——x、y 是独立的空间在函数里交换 x、y自然影响不到 a、b。这种把变量本身直接传给函数的方式叫传值调用。结论实参传给形参时形参会单独创建一份临时空间来接收实参对形参的修改不影响实参。所以 Swap1 失败了。那怎么办——让 Swap 函数内部直接操作 main 里的 a 和 b。做法把 a、b 的地址传给 Swap函数里通过地址间接操作 a、b#include stdio.h void Swap2(int* px, int* py) { int tmp 0; tmp *px; *px *py; *py tmp; } int main() { int a 0; int b 0; scanf(%d %d, a, b); printf(交换前a%d b%d\n, a, b); Swap2(a, b); // 传地址 printf(交换后a%d b%d\n, a, b); return 0; }这样顺利完成任务。把变量的地址传给函数的方式叫传址调用。IMG5 一句话选型函数只需要主调函数的值来算用传值调用函数内部要修改主调函数里的变量就必须传址调用。总结最后把这一讲的骨架拎出来地址内存按 1 字节一个单元编址每个单元的编号就是地址地址 指针。指针变量取地址、*解引用int *p里*说明 p 是指针、int说明指向整型指针变量大小只取决于平台32 位 4 字节 / 64 位 8 字节与类型无关。类型的意义指针类型决定了解引用权限一次操作几个字节和走一步的距离1 跳过几个字节void*能接任意地址但不能直接运算。const在*左边修饰内容不能改内容在*右边修饰指针本身不能改指向。野指针未初始化、越界、指向已释放空间是三大成因应对——初始化、不越界、用完置 NULL、别返回局部变量地址。传址调用函数要改主调函数的变量就传地址进去通过解引用间接修改。一句话收尾指针的难全在地址二字把内存模型想通了*和就不再绕。拓展学习二级指针 / 指针数组 / 数组指针《深入理解指针(2)(3)》会继续讲函数指针与回调函数void* 实现泛型编程《深入理解指针(4)》里用函数参数收任意类型地址上一讲C语言操作符详解取地址、*解引用就是在那篇里埋的伏笔