【C语言】《从零攻克 C 语言指针上篇:内存地址、指针类型、const 指针、野指针、传参调用全梳理》 本文系统梳理 C 语言指针全套知识点从底层内存与编址概念入手讲解取地址、*解引用操作符分析指针类型的意义、指针算术运算区分各类const修饰指针的差异剖析野指针产生原因与规避手段最后结合 assert 断言、模拟实现 strlen重点对比传值调用与传址调用搞懂为什么函数想要修改外部变量需要传递地址。一. 内存和地址1.1 内存在讲内存和地址之前我们想有个⽣活中的案例假设有⼀栋宿舍楼把你放在楼⾥楼上有100个房间但是房间没有编号你的⼀个朋友来找你玩如果想找到你就得挨个房⼦去找这样效率很低但是我们如果根据楼层和楼层的房间的情况给每个房间编上号如⼀楼101102103... ⼆楼201202203... ...有了房间号如果你的朋友得到房间号就可以快速的找房间找到你。如果把上⾯的例⼦对照到计算机中⼜是怎么样呢我们知道计算机上CPU中央处理器在处理数据的时候需要的数据是在内存中读取的处理后的数据也会放回内存中那我们买电脑的时候电脑上内存是 8GB/16GB/32GB 等那这些内存空间如何⾼效的管理呢其实也是把内存划分为⼀个个的内存单元每个内存单元的⼤⼩取1个字节。计算机中常⻅的单位补充⼀个⽐特位可以存储⼀个2进制的位1或者0。其中每个内存单元相当于⼀个学⽣宿舍⼀个字节空间⾥⾯能放8个⽐特位就好⽐同学们住的⼋⼈间每个⼈是⼀个⽐特位。每个内存单元也都有⼀个编号这个编号就相当于宿舍房间的⻔牌号有了这个内存单元的编号CPU就可以快速找到⼀个内存空间。⽣活中我们把⻔牌号也叫地址在计算机中我们把内存单元的编号也称为地址。C语⾔中给地址起了新的名字叫指针。bit - ⽐特位 Byte - 字节 KB MB GB TB PB 1Byte 8bit 1KB 1024Byte 1MB 1024KB 1GB 1024MB 1TB 1024GB 1PB 1024TB所以我们可以理解为内存单元的编号 地址 指针。1.2 究竟该如何理解编址CPU访问内存中的某个字节空间必须知道这个字节空间在内存的什么位置⽽因为内存中字节很多所以需要给内存进⾏编址(就如同宿舍很多需要给宿舍编号⼀样)。计算机中的编址并不是把每个字节的地址记录下来⽽是通过硬件设计完成的。钢琴、吉他 上⾯没有写上“剁、来、咪、发、唆、拉、西”这样的信息但演奏者照样能够准确找到每⼀个琴弦的每⼀个位置这是为何因为制造商已经在乐器硬件层⾯上设计好了并且所有的演奏者都知道。本质是⼀种约定出来的共识⾸先必须理解计算机内是有很多的硬件单元⽽硬件单元是要互相协同⼯作的。所谓的协同⾄少相互之间要能够进⾏数据传递。但是硬件与硬件之间是互相独⽴的那么如何通信呢答案很简单⽤线连起来。⽽CPU和内存之间也是有⼤量的数据交互的所以两者必须也⽤线连起来。不过我们今天关⼼⼀组线叫做地址总线。硬件编址也是如此我们可以简单理解32位机器有32根地址总线每根线只有两态表⽰0,1【电脉冲有⽆】那么⼀根线就能表⽰2种含义2根线就能表⽰4种含义依次类推。32根地址线就能表⽰2^32种含义每⼀种含义都代表⼀个地址。地址信息被下达给内存在内存上就可以找到该地址对应的数据将数据在通过数据总线传⼊CPU内寄存器。总线类型作用方向常见位宽图片中的示例补充说明地址总线CPU 发出内存地址选择要读/写的存储单元单向CPU → 内存16位、32位、64位0 1 1 0 1 06位示例地址位数决定CPU可寻址的内存空间如32位可寻址4GB数据总线在 CPU 和内存之间传输实际的数据双向CPU ↔ 内存8位、16位、32位、64位图片中未标具体数值位宽决定每次传输的数据量影响CPU与内存的通信效率控制总线传输控制信号协调读写操作双向/单向混合部分CPU发出部分设备回应通常为几条独立的信号线非固定位宽图中标有“控制总线”常见信号•读/写R/W决定是读内存还是写内存•时钟CLK同步操作节奏•中断请求IRQ外设通知CPU•总线请求/响应用于DMA等CPU先通过地址总线告诉内存“我要操作哪个位置”再通过控制总线告诉内存“我要读还是写”然后通过数据总线传输数据读时内存给CPU写时CPU给内存这些线就是CPU和内存之间的电线每一条线在同一时刻只能传一个0或1多条线合起来就能传一个二进制数比如地址011010就是6条线同时传6个bit二. 指针变量和地址2.1 取地址操作符理解了内存和地址的关系我们再回到C语⾔在C语⾔中创建变量其实就是向内存申请空间⽐如#include stdio.h int main() { int a 10; return 0; }⽐如上述的代码就是创建了整型变量a内存中申请4个字节⽤于存放整数10其中每个字节都有地址上图中4个字节的地址分别是那我们如何能得到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 指针变量和解引⽤操作符*1. 指针变量那我们通过取地址操作符()拿到的地址是⼀个数值⽐如之前的0x006FFD70这个数值有时候也是需要存储起来⽅便后期再使⽤的那我们把这样的地址值存放在哪⾥呢答案是指针变量中。输入pa和a是一个意思结果都会显示如下指针变量也是⼀种变量这种变量就是⽤来存放地址的存放在指针变量中的值都会理解为地址。2.如何拆解指针类型我们看到pa的类型是 int* 我们该如何理解指针的类型呢int a 10; int * pa a;这⾥pa左边写的是 int* * 是在说明pa是指针变量⽽前⾯的 int 是在说明pa指向的是整型(int)类型的对象。那如果有⼀个char类型的变量chch的地址要放在什么类型的指针变量中呢char ch w; pc ch;什么类型的变量就用什么类型的指针去存它的地址。ch是char类型所以用char*类型的指针变量pc来存放ch3.解引⽤操作符我们将地址保存起来未来是要使⽤的那怎么使⽤呢在现实⽣活中我们使⽤地址要找到⼀个房间在房间⾥可以拿去或者存放物品。C语⾔中其实也是⼀样的我们只要拿到了地址指针就可以通过地址指针找到地址指针指向的对象这⾥必须学习⼀个操作符叫解引⽤操作符(*)。上⾯代码中就使⽤了解引⽤操作符*pa 的意思就是通过pa中存放的地址找到指向的空间*pa其实就是a变量了所以*pa 0这个操作符是把a改成了0.有同学肯定在想这⾥如果⽬的就是把a改成0的话写成 a 0; 不就完了为啥⾮要使⽤指针呢其实这⾥是把a的修改交给了pa来操作这样对a的修改就多了⼀种的途径写代码就会更加灵活。2.3 指针变量的⼤⼩前⾯的内容我们了解到32位机器假设有32根地址总线每根地址线出来的电信号转换成数字信号后是1或者0那我们把32根地址线产⽣的2进制序列当做⼀个地址那么⼀个地址就是32个bit位需要4个字节才能存储。如果指针变量是⽤来存放地址的那么指针变的⼤⼩就得是4个字节的空间才可以。同理64位机器假设有64根地址线⼀个地址就是64个⼆进制位组成的⼆进制序列存储起来就需要8个字节的空间指针变量的⼤⼩就是8个字节。结论32位平台下地址是32个bit位指针变量⼤⼩是4个字节64位平台下地址是64个bit位指针变量⼤⼩是8个字节注意指针变量的⼤⼩和类型是⽆关的只要指针类型的变量在相同的平台下⼤⼩都是相同的三. 指针变量类型的意义指针变量的⼤⼩和类型⽆关只要是指针变量在同⼀个平台下⼤⼩都是⼀样的为什么还要有各种各样的指针类型呢其实指针类型是有特殊意义的我们接下来继续学习。3.1 指针的解引⽤对⽐下⾯2段代码主要在调试时观察内存的变化。//代码1 #include stdio.h int main() { int n 0x11223344; int *pi n; *pi 0; return 0; }//代码2 #include stdio.h int main() { int n 0x11223344; char *pc (char *)n; *pc 0; return 0; }调试我们可以看到代码1会将n的4个字节全部改为0但是代码2只是将n的第⼀个字节改为0。结论指针的类型决定了对指针解引⽤的时候有多⼤的权限⼀次能操作⼏个字节。⽐如 char* 的指针解引⽤就只能访问⼀个字节⽽ int* 的指针的解引⽤就能访问四个字节。指针类型决定了指针进行解引用操作符的时候访问几个字节也就是决定指针的权限3.2 指针-整数先看⼀段代码调试观察地址的变化。我们可以看出char* 类型的指针变量1跳过1个字节 int* 类型的指针变量1跳过了4个字节。这就是指针变量的类型差异带来的变化。指针1其实跳过1个指针指向的元素。指针可以1那也可以-1。结论指针的类型决定了指针向前或者向后⾛⼀步有多⼤距离。3.3 void* 指针在指针类型中有⼀种特殊的类型是 void * 类型的可以理解为⽆具体类型的指针或者叫泛型指针这种类型的指针可以⽤来接受任意类型地址。但是也有局限性void* 类型的指针不能直接进⾏指针的-整数和解引⽤的运算。代码如下#include stdio.h int main() { int a 10; int* pa a; char* pc a; return 0; }使⽤void*类型的指针接收地址这⾥我们可以看到 void* 类型的指针可以接收不同类型的地址但是⽆法直接进⾏指针运算。那么 void* 类型的指针到底有什么⽤呢⼀般 void* 类型的指针是使⽤在函数参数的部分⽤来接收不同类型数据的地址这样的设计可以实现泛型编程的效果四. const 修饰指针4.1 const修饰变量变量是可以修改的如果把变量的地址交给⼀个指针变量通过指针变量的也可以修改这个变量。但是如果我们希望⼀个变量加上⼀些限制不能被修改怎么做呢这就是const的作⽤。代码如下#include stdio.h int main() { int m 0; m 20;//m是可以修改的 const int n 0; n 20;//n是不能被修改的 return 0; }上述代码中n是不能被修改的其实n本质是变量只不过被const修饰后在语法上加了限制只要我们在代码中对n就⾏修改就不符合语法规则就报错致使没法直接修改n但是如果我们绕过n使⽤n的地址去修改n就能做到了虽然这样做是在打破语法规则我们可以看到这⾥⼀个确实修改了但是我们还是要思考⼀下为什么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指向n *p 20; // ok通过p修改n的值n变成20 p m; // ok改变p的指向让p指向m // 结论无const限制指针指向的值和指针本身都可以修改 } //代码2 - 测试const放在*的左边情况const int* p void test2() { int n 10; int m 20; const int* p n; // p指向n但p认为指向的是常量 //*p 20; // 错误不能通过p修改指向的值n的值 p m; // ok可以改变p的指向让p指向m // 结论const在*左边 -- 指向的值不能改但指针本身可以改 } //代码3 - 测试const放在*的右边情况int* const p void test3() { int n 10; int m 20; int* const p n; // p指向n且p本身是常量不能再指向别人 *p 20; // ok可以通过p修改n的值 //p m; // 错误不能改变p的指向p永远指向n // 结论const在*右边 -- 指针本身不能改但指向的值可以改 } //代码4 - 测试*的左右两边都有const void test4() { int n 10; int m 20; const int* const p n; // p指向n且p本身是常量指向的也是常量 //*p 20; // 错误不能通过p修改指向的值 //p m; // 错误不能改变p的指向 // 结论两边都有const -- 指向的值不能改指针本身也不能改双重锁定 } int main() { test1(); // 测试无const //test2(); // 测试const在*左边 //test3(); // 测试const在*右边 //test4(); // 测试*左右都有const return 0; }结论const修饰指针变量的时候const如果放在* 的左边修饰的是指针指向的内容保证指针指向的内容不能通过指针来改变。但是指针变量本⾝的内容可变。const如果放在* 的右边修饰的是指针变量本⾝保证了指针变量的内容不能修改但是指针指向的内容可以通过指针改变。记法const在*左边-- 管的是指向的内容值不能改const在*右边-- 管的是指针本身指向不能改五. 指针运算指针的基本运算有三种分别是指针- 整数指针-指针指针的关系运算5.1 指针- 整数因为数组在内存中是连续存放的只要知道第⼀个元素的地址顺藤摸⽠就能找到后⾯的所有元素。int arr[10] {1,2,3,4,5,6,7,8,9,10};p i不是地址加数字而是跳过i个int的长度i * 4字节精准定位到第i个元素的地址因为指针类型决定了步长。5.2 指针-指针p经过while循环从a一直跑到\0后面p - s就是p和s之间相隔的字符个数3个正好是字符串abc的长度。5.3 指针的关系运算p arr sz比较的是两个指针的地址大小只要p的地址还没超过数组末尾循环就继续。六. 野指针概念 野指针就是指针指向的位置是不可知的随机的、不正确的、没有明确限制的6.1 野指针成因1. 指针未初始化2. 指针越界访问#include stdio.h int main() { int arr[10] { 0 }; int* p arr[0]; int i 0; for (i 0; i 11; i) { //当指针指向的范围超出数组arr的范围时p就是野指针 *(p) i; } return 0; }3. 指针指向的空间释放#include stdio.h int* test() { int n 100; return n; } int main() { int* p test(); printf(%d\n, *p); return 0; }test()函数结束后局部变量n占用的内存被系统回收了但p还拿着这块内存的地址去访问这叫悬垂指针。能运行是因为操作系统和CPU只检查地址是否在进程内存范围内不检查这块内存是否还在使用中能输出100是因为栈数据没被清零残留的旧值还在。但这叫运气不叫正确6.2 如何规避野指针1.指针初始化如果明确知道指针指向哪⾥就直接赋值地址如果不知道指针应该指向哪⾥可以给指针赋值NULL.NULL 是C语⾔中定义的⼀个标识符常量值是00也是地址这个地址是⽆法使⽤的读写该地址会报错。初始化如下2. 小心指针越界指针只能访问已申请的内存空间超出范围即为越界访问属于未定义行为。常见于数组下标越界、动态内存超限访问。预防方法循环条件用而非malloc后记录申请大小字符串保证\0结尾。3. 指针不再使用时及时置NULL使用前检查有效性指针指向的内存被释放或失效后立即置为NULL避免悬垂指针。每次解引用前先检查if (p ! NULL)可有效防止空指针崩溃和野指针破坏数据。当指针变量指向⼀块区域的时候我们可以通过指针访问该区域后期不再使⽤这个指针访问空间的时候我们可以把该指针置为NULL。因为约定俗成的⼀个规则就是只要是NULL指针就不去访问同时使⽤指针之前可以判断指针是否为NULL。我们可以把野指针想象成野狗野狗放任不管是⾮常危险的所以我们可以找⼀棵树把野狗拴起来就相对安全了给指针变量及时赋值为NULL其实就类似把野狗栓起来就是把野指针暂时管理起来。不过野狗即使拴起来我们也要绕着⾛不能去挑逗野狗有点危险对于指针也是在使⽤之前我们也要判断是否为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已经越界了可以把p置为NULL p NULL; //下次使⽤的时候判断p不为NULL的时候再使⽤ //... p arr[0];//重新让p获得地址 if (p ! NULL) //判断 { //... } return 0; }4.避免返回局部变量的地址#include stdio.h int* getNumber() { int n 100; // 局部变量在栈上 return n; // 返回局部变量的地址 } int main() { int* p getNumber(); printf(%d\n, *p); // 可能输出100但这是运气 return 0; }七. assert 断⾔assert.h 头⽂件定义了宏 assert() ⽤于在运⾏时确保程序符合指定条件如果不符合就报错终⽌运⾏。这个宏常常被称为“断⾔”。assert() 宏接受⼀个表达式作为参数。如果该表达式为真返回值⾮零 assert() 不会产⽣任何作⽤程序继续运⾏。如果该表达式为假返回值为零 assert() 就会报错在标准错误流 stderr 中写⼊⼀条错误信息显⽰没有通过的表达式以及包含这个表达式的⽂件名和⾏号。assert() 的使⽤对程序员是⾮常友好的使⽤ assert() 有⼏个好处它不仅能⾃动标识⽂件和出问题的⾏号还有⼀种⽆需更改代码就能开启或关闭 assert() 的机制。如果已经确认程序没有问题不需要再做断⾔就在 #include assert.h 语句的前⾯定义⼀个宏 NDEBUG 。#define NDEBUG #include assert.h然后重新编译程序编译器就会禁⽤⽂件中所有的 assert() 语句。如果程序⼜出现问题可以移除这条 #define NDEBUG 指令或者把它注释掉再次编译这样就重新启⽤了 assert() 语句。assert() 的缺点是因为引⼊了额外的检查增加了程序的运⾏时间。⼀般我们可以在 Debug 中使⽤在 Release 版本中选择禁⽤ assert 就⾏在 VS 这样的集成开发环境中在 Release 版本中直接就是优化掉了。这样在debug版本写有利于程序员排查问题在 Release 版本不影响⽤⼾使⽤时程序的效率。八. 指针的使⽤和传址调⽤8.1 strlen的模拟实现库函数strlen的功能是求字符串⻓度统计的是字符串中 \0 之前的字符的个数。函数原型如下size_t strlen ( const char * str );参数str接收⼀个字符串的起始地址然后开始统计字符串中 \0 之前的字符个数最终返回⻓度。如果要模拟实现只要从起始地址开始向后逐个字符的遍历只要不是 \0 字符计数器就1这样直到 \0 就停⽌。#includestdio.h #includestring.h #includeassert.h int my_strlen(const char* str) { int count 0; assert(str); while (*str) { count; str; } return count; } int main() { int len my_strlen(abcdef); printf(%d\n, len); return 0; }8.2 传值调⽤和传址调⽤例如写⼀个函数交换两个整型变量的值⼀番思考后我们可能写出这样的代码#define _CRT_SECURE_NO_WARNINGS #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和ba的地址是0x00cffdd0b的地址是0x00cffdc4在调⽤Swap1函数时将a和b传递给了Swap1函数在Swap1函数内部创建了形参x和y接收a和b的值但是x的地址是0x00cffcecy的地址是0x00cffcf0x和y确实接收到了a和b的值不过x的地址和a的地址不⼀样y的地址和b的地址不⼀样相当于x和y是独⽴的空间那么在Swap1函数内部交换x和y的值⾃然不会影响a和b当Swap1函数调⽤结束后回到main函数a和b的没法交换。Swap1函数在使⽤的时候是把变量本⾝直接传递给了函数这种调⽤函数的⽅式我们之前在函数的时候就知道了这种叫传值调⽤。结论实参传递给形参的时候形参会单独创建⼀份临时空间来接收实参对形参的修改不影响实参。所以Swap1是失败的了那怎么办呢我们现在要解决的就是当调⽤Swap函数的时候Swap函数内部操作的就是main函数中的a和b直接将a和b的值交换了。那么就可以使⽤指针了在main函数中将a和b的地址传递给Swap函数Swap函数⾥边通过地址间接的操作main函数中的a和b并达到交换的效果就好了.#define _CRT_SECURE_NO_WARNINGS #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; }我们可以看到实现成Swap2的⽅式顺利完成了任务这⾥调⽤Swap2函数的时候是将变量的地址传递给了函数这种函数调⽤⽅式叫传址调⽤。传址调⽤可以让函数和主调函数之间建⽴真正的联系在函数内部可以修改主调函数中的变量所以未来函数中只是需要主调函数中的变量值来实现计算就可以采⽤传值调⽤。如果函数内部要修改主调函数中的变量的值就需要传址调⽤。