C语言指针的详细讲解应用(江科大)
一、指针简介

指针与底层硬件联系紧密,这个底层硬件其实就是内存,也就是说我们只要讨论指针,我们就会讨论内存这些东西;因为指针就是一种地址,使用指针可以操作数据的地址,因为指针代表这种地址,所以它必须要和内存实际存储的数据联系起来,我们如何去操作这个地址;因为我们的这个存储器,它有两大部分:一个就是数据,另外一个就是地址;所以如果我们把地址也操作起来的话,我们就会更加灵活的去使用我们这个数据,所以指针就是与这个底层内存和这个地址紧密联系的一种C语言的操作方式。
它可以实现数据的间接访问,那如果实现数据的间接访问呢?我们平常都是定义一个变量,然后我们用变量的名字就可以直接应用这个变量;
我们还有另外一个应用方式就是使用地址简介访问,我们可以把这个变量的地址拿出来,然后再通过指针变量去寻找这个地址下的变量,实现一个间接的寻找这个变量的过程,这就是数据的间接访问;也许你会问了,这好好的一个变量,我定义完之后,为啥不用它名字直接访问呢?我非要用间接访问,这不没事找事吗?但是在某些应用场景下,这个间接访问可以起到很大的作用,那具体什么作用呢,到下面的应用会给出具体例子来讲解。
所以我们就知道了指针就是地址,指针离不开地址,它可以实现数据的间接访问。
二、计算机存储机制

分析指针之前呢?我们知道指针和内存是离不开的,所以我们需要了解一下内存,看一下计算机是如何存储我们的数据的,它内存是怎么样定义的。
1、内存模式图
右边的图就是我们实际的一个内存的一个模式图,在我们计算机的内存里,我们通常把内存分配成一个线性的空间,然后在每个区域都是以字节为单位的,那比如说图上的0x78这一个字节,数据以字节为单位这样线性的分配下去,然后每个字节都会对应一个地址,如果你字节不对应地址,那就访问不到你了,那这个内存就没有意义了,所以为了访问这个数据,每个字节都会有它独一无二的地址,这个地址是从0一直往下分配的,也有可能不是从0;总之这是一个线性的分配区域,给它编码一个地址,比如说右边这个内存,假设它是从0x4000开始分配的,它就会线性的依次往下分配,然后分配一个足够大的内存,来给我们这个操作。
2、内存的分配方式
我们C语言中平常定义一个变量,它完成了哪些操作呢?比如我们定义了一个整形的int型变量,int a =0x12345678,这个是a的数据,在计算机系统里int代表的是一个四个字节的数据,也就是说一个int型数据,它需要用四个字节,总共4*8=32,32个二进制来表示,那它如何存在我们的内存里面呢?比如说这个int a它就是这样存的,因为它要跨域四个字节的长度,所以它一个int型变量就跨越了四个地址,然后它内部是怎么分配的呢?最低的这两个78是一个字节,被分配到了第一个位置,然后56分配到了第二个位置,34分配到了第三个位置,12被分配到了第四个位置,那这种就是一种跨字节的分配方式,这种分配方式把这个数据的小端,右边的78是小端,12是大端;把小端放在内存地址的低位,就是小端在前,叫作小端模式;那就还有一种是大端模式,将12放到前面的大端模式;但现在的计算机普遍都是小端模式分配的。
那我们有了这种分配方式之后,我们就可以把跨字节的数据给它分配到这内存里面,就是上图所示。
还有就是我们定义了一个short b =0x5A6B;就是这个short型数据占两个字节,一个0x5A,一个0x5B,它也是同样是以小端分配方式来进入到内存的;比如说在这里0x6B就存在前面,0x5A就放到后面了,它就会重新分配一个区域,这就是存的这个b这个变量。
3、数组的内存存储方式
那数组char c[] ={0x33,0x34,0x35};,那数组是怎么存的呢?数组就不是反着来存的了,数组是每个数据按顺序存的,比如说第0个数据0x33,它就再找一块空的内存中,然后0x33就是存在第一个顺序上的,因为char型是一个字节的,所以0x33,0x34,0x35就会被分配一个线性空间,数组在内存中必须分配一个连续的线性空间,它中间不会说我隔几个其他的数据,它必须是连续的,这个是char型一个字节的数组。
那我们的short两个字节的数组short d[] ={0x5A6B,0x5A6B}该如何存进内存呢?在内存中,这每一数组中的每一个项目都跨字节的话,那么它里面的这两个字节是以小端存储的,然后数组的整体是按顺序存储的,比如5A6B,那它存的就是首先把第0项数组的第1项数据6B先存上,然后5A;然后就是第1项数组的第1项6B,再就是5A了;就是6B->5A->6B->5A的顺序存储的
总结数组的内存存储得分是否数据类型是char型的一个字节,还是short之后的两个字节的数据;如果是一个字节的数组就直接是按照顺序的方式进行存储;那如果是两个或者两个以上字节的数组,那数组内每一项的数据都是以小端的方式存储,然后数组的整体是按顺序来存储的。
我们后续分析指针都需要画这个内存图进行分析指针数据是怎么样读取和存储的。
三、定义指针

1、指针变量
指针也是一种变量,它本身和我们其他的数据类型,int型,short型这些变量的基本形式是一样的,它也可以用来存储一个数,用来读取一个数,它就是个变量,所以我们直接把指针叫作指针变量;但是这个指针变量和普通的数据它是有区别的,比如说int型纯整数,float型纯小数,那我们指针类型却存的是其他数据单元的首地址,它不存具体数据,它存的是首地址,即指针存的是地址。
2、其他数据单元
那其他数据单元代表什么呢?比如说其他的变量,其他的数组,其他的结构体,甚至是其他的函数;因为这些东西都是一种数据,既然是数据,我们就可以指向它,用指针变量来存他们的首地址。(即指针可以指向数据的首地址)
3、为什么指针存的是首地址呢
但为什么存首地址呢?我们刚才分析这个int型变量是跨越四个字节的,它对应的地址也是有四个,如果我们把地址全部存下来,那根本没法存,如果有一个数组有100项来跨越,那就没法存了,所以说我们指针变量内存的都是首地址,也就是第一个地址;如果我们指针指向int a的话,那这个指针的这个数据内存就是4000,如果b的话,那就是4004,如果是c的话,那就是4006。
4、指针的指向和空指针
若指针存放了某个数据单元的首地址,则这个指针指向了这个数据单元,如果说一个int型指针等于4000,那我们就说它指向了a这个变量;
若指针存放的值是零,那我们就说这个指针为空指针,比如说你定义一个指针,它里面存的指针变量数据是0,那它就是空指针,如果我们对这个指针进行应用的话,就取指针内存的话是取不到的话就会报错,
5、如何定义一个指针变量
我们根据指针指向的一个变量的不同,对应的都有一个指向该数据类型的指针,比如一个char型或者unsigned char型,就是直接在这个原来的数据类型后面加个星号*即可,(unsigned) char *,这个星号就是一种标识符,代表它是一种指针的类型,就说明是指向char型的指针变量;其他数据类型也是如此定义的。
注意指针的内存字节大小是随系统的变化而变化的,也就是说指针变量的位宽和你的系统这个位宽是一样的,为什么是这样的呢?我们刚才也说了指针是用来存放某个数据单元的首地址,指针变量只要够存,能存得下,我们就定义这些。
四、指针的操作

若已定义int a和int *p这两个,则对指针p就会有如上图的那些操作方式;
第一个操作方式就是取地址,这里面这个取地址严格意义上来说不是对指针变量进行操作的,它是对一个变量进行取地址操作,这个取地址的符号就是一个&号,这个&号还有一个按位与,比如说前面一个变量,后面一个变量,我们用这一个&号把它们连接起来,表示把这两个变量进行按位与进行运算;但在指针这里却不是按位与运算,因为在一个变量之前,左边没有变量的话,就是取地址符。
第二个操作方式就是取内容,就和地址相反它,就*p,那这个*号又和我们刚才说的*也不一样,如果*连接两个变量就表示乘;如果它出现在一个变量类型后面,就只作为一种符号标识,标识具体是哪种数据类型;如果它出现在一个变量的前面,而且这个*前面没有这个变量类型,只出现这个变量前面,那这个就是取内容的意思了,就是把p指向的内容给取出来了。所以说这个*号兼顾三个功能,得具体分析是哪一种功能。

第三个操作方式就是指针操作地址的加减运算,常见的p++代表的是使指针向下移动1个数据宽度和p--代表的是使指针向上移动1个数据宽度;p=p+5就是使指针向下移动5个数据宽度;p=p-5就是使指针向上移动5个数据宽度。这个数据宽度指的是数据类型字节的宽度大小,比如int型就是四个字节,那p++就是直接加4。
注意:对于单独的变量,我们不能使用++或者--进行操作,因为单独一个变量,指向下一个或者上一个变量都是未知的,会导致程序找不到内容而报错;因此++,--一般都是应用在数组里面的,我们让指针指向一个数组的首地址,而数组后面的数据是我们定义好的,是已知的,这样让它++,--才有意义。
五、数组与指针

1、数组与指针的关系
其实数组也是指针的一种表达形式,数组本身就是指针,其数组名即为指向该数据类型的指针,数组的定义等效于申请内存、定义指针和初始化。
2、数组等效于指针的操作
第一步、申请内存,我们在内存中找出连续的三个没有使用过的空间,我们把这三个空间申请下来;
第二步、定义一个char *c,新定义一个指针变量,c就执行我们数组的首地址,就是0x4000;
第三步、初始化数组数据,我们把刚申请过来的内存填充完。
以上的操作和我们定义一个指针操作一模一样,实际上指针就是数组,数组就是指针的另外一种模式,它俩是一模一样的,甚至可以混用,比如你用数组的方法定义,然后用指针的方法区引用,或者用指针的方法定义引用或者用数组的方法去引用都可以。
同样,引用也是可以用指针进行互换的,比如说利用数组下标引用数组数据也等效于指针取内容,那数组取下标是不是就是指针取内容,c[0]就等效于*c;c[1]就等效于*(c+1);c[2]就等效于*(c+2);


a也是一个指针,然后p和a是一样的,可以直接将p改成a,然后运行的结果也是一模一样的。改为int型,结果也是一样的。这样就知道为什么我们int型指针加1的时候,它的实际地址要加4呢?就是因为我们用数组的思维区理解它,它每个变量跳的是4个,如下图所示

补充:void *就是一个指针类型,表示指向一个void型,为什么指向void型呢?void型是空型,这是因为它返回的时候不知道你要用什么;当你不知道这个指针需要对接到什么指针的时候,可以用void *代替。
六、注意事项

第一条、在对指针取内容之前,一定要确保指针指在了合法的位置,否则将导致程序出现不可预知的错误(指针越界),如果你的指针指向一个单独的变量,你再让这个指针++或者--,它就会指向了一个非法的区域,这个区域不知道是啥,如果你再引用它的内容,就会出错;所以指针++或--一般应用在数组上,也要确保下面有加的东西;数组有个越界其实和指针指在了非法位置是一样的性质,也可以叫作指针越界。还有一个就是你定义了*p,但不对它赋初值,那指针也是不确定的,也会报错。
第二条是同级指针之间才能相互赋值,跨级赋值将会导致编译器报错或警告,但有时候也需要我们跨级赋值,所以这里有时候是报错,有时候是警告,警告就是告诉你注意一下你是不是真的要非法跨级赋值,正常情况下,我们都是同级指针之间相互赋值;上面的图就是说我们可以将变量当做零级指针,就指针最终取出内容就是变量;然后普通指针就是一级指针,指针再取地址就是二级指针,然后你对它相互赋值的话,一定要确保它俩是同一级的;比如你想把一个变量赋值给一个指针,那你要对这个变量进行取地址,因为变量取地址它就是指针,然后变量取地址才能给指针赋值,保证同级;如果你指针赋值给有变量的话,你最好加一个指针取内容,加* 号这样才能赋值,同理二级指针也是一样的。
七、指针的应用

1、传递参数
使用指针可以对我们函数进行一个地址传递,我们平常的正常的传递是值传递。
地址传递和值传递的区别:
地址传递的两条优点与弊端
地址传递的第一条优点:就是使用指针传递大容量的参数,主函数和子函数使用的是同一套数据,避免了参数传递过程中的数据复制,提高了运行效率,减少了内存占用;
这是一个很常用的一个应用场景。例如下面的这个寻找最大数组里的值Max是多少,只是新创建了一个8字节(64位系统的int*是8个字节)的指针内存开销,然后还是直接访问的主函数定义的数组(指针),所以子函数和主函数是共用一个数组的,主函数访问的是它,子函数通过指针访问的间接访问的也是它,所以它俩共用一个数据,这就避免了数据的复制,子函数结束了之后,它作为一个局部变量将被销毁,也就是这个指针变量被销毁了,然后返回主函数,主函数继续用它的这个数组。
弊端就是不安全,为了安全,你可以在指针加个const常量字符修饰之后,那么在子函数中这个array只能被读,不能被写;如果你试图在子函数中修改这个值的话,那程序就会报错,编译器会报错提示说你连接了一个ready only只读的属性,只是一种保护措施,保护子函数不要乱动主函数的东西,这样调用的话就确保子函数不会修改主函数的东西了,这是用指针来传递一个地址。这就是指针间接应用数据的一个作用,这个作用占了很大的比例。
地址传递第二条优点:就是使用指针传递输出参数,利用主函数和子函数使用同一套数据的特性,实现数据的返回,可实现多返回值函数的设计。
参数都是输入的,作为输入变量来输入的,但使用指针还可以实现一个功能输出函数,第一条好处就会避免同时使用一套数据,但第二条好处就会利用同一套数据的特性实现数据的返回,C语言设计有个弊端就是它的返回值只能有一个数据,当我想返回多个数的时候又该如何实现呢?如下图所示:

上图解析:我们看一下Max是怎么样实现返回值的,首先定义这个Max,这主函数的Max是int类型的,因此会被分配4个字节的空间,地址是如何分配的,然后Max没有输出值,所以里面的变量是随机的xx,然后我们调用一下这个FindMaxAndCount这个函数,把Max的地址和Count的地址给复制进去,这个形参定义在子函数上面是个指针类型的int *max,这个是64位系统的,因此是8个字节的max,因此变量需要加个取地址符才能做到同级赋值,因此是&Max的地址赋值给到了这个小写max指针变量,所以这个地址将会被复制到指针变量里面去,这个就表示它的地址,然后当我们需要应用这个变量的时候,比如说我给*max赋值,因为max是指针变量,所以需要*max取出内容,*max是多少呢,这里我给子函数的*max赋值就相当于给主函数的Max赋值,当我们这个子函数返回之后,它返回这个局部变量,这个指针被销毁,主函数的Max等于多少呢?就等于1,因为你将子函数的*max给了一个1;就是说你再子函数获取到的值是多少,那你主函数的值就是多少,而且你使用了两个指针变量进行改值,那就可以返回两个函数值给到主函数使用,多个指针变量就可以返回多少个返回值。特点就是你再子函数中更改这个传递的指针变量,就是返回值,本质上应用的是同一个变量。
值传递的优点与弊端
值传递的优点就是:安全隔离了主函数和子函数的数据,就是我主函数的值不会因为你子函数的改变而改变,因为子函数会复制一遍变量数据到子函数里面去执行,并不会影响我原来主函数的值,保证了我主函数的值的安全,你子函数只调用一次就会销毁了。
弊端是:值传递会有一个复制的过程,当数据变多和复杂了之后,这个复制就会影响效率和占用内存了。
2、传递返回值
有时候我们也可以看到,它的返回值也是一个指针类型的变量,那这有什么作用呢?
将模块内的公有部分返回,让主函数持有模块的“句柄”(把手的意思),便于程序对指定对象的操作。

假如上面的是时钟模块的封装,封装函数一般都使用指针进行地址传递去间接访问数据,而不使用全局变量的方式,这样会不利于模块的封装;那时钟呢就会有一个int Time,假设这是个时钟模块,里面有个数组int Time[]={23,59,55},注释线上的部分是一个单独的模块封装起来的,这个模块里面有一个数组叫作Time数组,而我主函数想要读取一下Time数组该怎么办呢?是不是我们就要用指针作为返回值,然后持有这个Time的句柄,句柄就是把手的意思,我只要捏住了这个把手,我就可以操作你;在里面写个访问的函数,一般我们不直接把这个变量公开数据,变成大家共同访问的哈,这样不利于封装,所以我们会用一个函数简介的访问,写个函数GetTime,里面没有参数,但它的返回值是一个int型数组,所以就是int *GetTime(void);你要把它看作整体,返回值是int型,然后再里面return Time,这样就可以简接访问了,甚至你可以在这里加一些操作,比如进行合法性判断,或者是对权限进行访问,为了封装,然后假设直接无条件访问Time。
注意事项:你千万别把一个局部变量的指针给返回了,比如上面的Time数组是局部变量,而不是全局变量,然后你把它的地址给返回过去了,就会出问题;因为局部变量在函数结束之后,局部变量将被销毁,即使它还是原来大家内存,但是它被销毁了,你这样再把它的地址给返回出去,主函数虽然能够应用这个内容,但其实这个内容被销毁了,这个指针指的是一个无效的位置,这样数据就会出错了。而这个模块类的全局变量是不会被销毁的,所以这样返回才是可以的。
C语言操作文件系统的综合例子(传递参数的两个功能(地址传递实现大量数据的传输和实现多多返回值的应用)和传递返回值的应用)可以看一下江科大的原视频([C语言]指针的详解与应用),这里就不写出来了,感兴趣的小伙伴自行观看吧。
3、直接访问物理地址下的数据(特殊应用)
第一条是:访问硬件指定内存下的数据,如设备ID号等
这就是单片机里面也会应用的,我们就把这个51单片机的ID号给读出来,访问ID号需要参考一下数据手册,这里有每个单片机具有全球唯一身份证号码(ID号),



使用这个功能还可以实现一个程序的在线更新,你写个程序,然后从串口或者无线模块获取数据,然后用指针把它写入到你的程序存储器里面,这样就实现了程序的自我更新,
第二条是:将复杂格式的数据转换为字节,方便通信与存储
这个功能也是与外面硬件相关的,如果你编电脑的软件,一般不会遇到这个问题,比如说存储器,做这个单片机,做无线模块有时候会遇到这个问题;我们来模拟一下,比如说一个复杂格式数据float型,假设一个float num=12.345;比如说你单片机运行一个程序,但是我想要调试,有时调试需要把这个程序发送到电脑上来,通过串口或者无线模块把它发送电脑,但是串口和无线模块有个必然,就是它只能一个字节一个字节的发,那这个float型它究竟怎么发呢?最常见的就是把做这个数据乘于1000扩大1000倍,变成12345的整数,然后再拆开发送过去,但是这样费时费力,而且精度也不高。那我们如何用指针把这个复杂格式数据发送出去呢?
步骤:
获取float变量的地址,并将其转换为指向字节(unsigned char)的指针。
通过这个指针,我们可以访问float的每一个字节(通常float为4个字节)。
根据你的系统架构的字节序(大端或小端),决定发送字节的顺序。
注意:在通信双方需要约定字节序,否则接收方可能无法正确解析。
#include <stdio.h>// 发送端代码
void send_float_via_uart(float num) {// 将float指针转换为unsigned char指针(字节指针)unsigned char *byte_ptr = (unsigned char *)#// 发送每个字节for(int i = 0; i < sizeof(float); i++) {// 模拟通过串口发送字节uart_send_byte(byte_ptr[i]); // 实际硬件调用printf("发送字节 %d: 0x%02X\n", i, byte_ptr[i]);}
}// 接收端代码
float receive_float_via_uart(void) {float num;unsigned char *byte_ptr = (unsigned char *)#// 接收每个字节for(int i = 0; i < sizeof(float); i++) {byte_ptr[i] = uart_receive_byte(); // 实际硬件调用}return num;
}总结:一般用的指针进行传递参数和传递返回值的用法比较多,你只要把这些学会了,你碰到其他的指针都会有些理解的;但是指针毕竟是C语言重要的部分,还有更多超额的更多高级的用法,比如函数指针、二级指针、结构体指针;或者你学到C++还有内核对象的指针,或者利用指针实现链表、实现动态多态性等这些高级应用。
