C语言梦开始的地方9:初识指针
文章目录
- C语言梦开始的地方9:初识指针
- 内存和地址
- 指针变量和地址
- 取地址
- 定义指针变量
- 通过指针访问数据
- 指针变量的大小
- 指针变量类型的意义
- 指针的解引用
- 指针 + - 整数
- void* 指针
- const修饰指针
- const 修饰变量
- const 修饰指针变量
- 指针运算
- 指针 - 指针
- 指针的关系运算
- 野指针
- 野指针成因
- 规避野指针
- assert断言
- 指针的使用和传址调用
- 传值调用和传址调用
C语言梦开始的地方9:初识指针
上一章我们了解了 static 和 extern,知道了如何控制变量的"可见范围"和"生命周期"。
从本章开始,我们将正式踏入C语言最核心、最强大、也最让初学者头疼的领域——指针。
有人说指针是C语言的灵魂,也有人说指针是C语言最难啃的骨头。但无论如何,指针就是你通往C语言高手之路的必经关卡。
内存和地址
在了解指针之前我们先要了解内存和地址,这样有助于我们对指针的了解。
内存是什么?地址又是什么?
内存:是计算机中用于存储程序和数据的硬件,可以看作一个巨大的字节数组。每个字节都有一个唯一的编号,这个编号就是该字节的内存地址。
- 地址从0开始,按字节递增(32位系统地址范围0x00000000 ~ 0xFFFFFFFF,64位更大)。
- 每个变量在编译后都会分配一块内存空间,其首字节的地址被称为该变量的地址。
- 通过地址,CPU可以找到并操作内存中的具体数据。
类比:内存就像一条街道,每个门牌号(地址)对应一个住户(字节),而变量可能占用连续几个门牌号(如int占4个字节)。
补充一个计算机的小知识:bit - 比特位 1Byte = 8bit Byte - 字节 1KB = 1024Byte KB 1MB = 1024KB MB 1GB = 1024MB GB 1TB = 1024GB TB 1PB = 1024TB PBbit是计算机最小单位,Byte是计算机基本单位 其中,每个内存单元,相当于一个学生宿舍,一个字节空间里面能放8个比特位,就好比同学们住的八人间,每个人是一个比特位。
每个内存单元也都有一个编号(这个编号就相当于宿舍房间的门牌号),有了这个内存单元的编号,CPU就可以快速找到一个内存空间。
生活中我们把门牌号也叫地址,在计算机中我们把内存单元的编号也称为地址。C语言中给地址起了新的名字叫:指针
所以我们可以理解为:
内存单元的编号 == 地址 == 指针
指针变量和地址
取地址
上面我们说到:内存单元的编号 == 地址 == 指针。
那么问题来了:指针既然是一个编号(地址),我们怎么把它存起来?
答案是:用指针变量来存。
指针变量:就是用来存放地址(指针)的变量。它本身也是一个变量,只不过它存的值比较特殊——存的是另一个变量的地址。
那么C语言中如何去地址呢?
C语言中使用&运算符(取地址运算符)来获取一个变量地址。
#include<stdio.h>intmain(){inta=10;// 打印变量 a 的值printf("a 的值:%d\n",a);// 打印变量 a 的地址printf("a 的地址:%p\n",&a);return0;}
每次运行时,这个地址编号可能不同(因为操作系统分配的内存位置不固定),但有一点是确定的:变量 a 确实有一个唯一的地址编号。
定义指针变量
既然 &a 拿到了 a 的地址,我们得找个变量把它存起来,这就是指针变量。
定义指针变量的语法格式:
类型* 指针变量名;比如我们要存 int 类型变量的地址,就定义一个 int* 类型的指针变量:
intmain(){inta=10;int*p=&a;printf("a 的地址:%p\n",&a);printf("p 中存的值: %p\n",p);return0;}程序运行后我们发现p存储的值和a的地址是一样的
注意:int* p 中的 * 表示 p 是一个指针变量,int 表示 p 指向的是一个 int 类型的变量。后面我们会详细讲为什么指针还要分类型。
通过指针访问数据
单纯拿到地址还不够,我们还得能通过地址找到那个变量,拿到里面的值。这就要用到*运算符(解引用运算符,也叫间接访问运算符)。
#include<stdio.h>intmain(){inta=10;int*p=&a;// 通过 p 拿到 a 的值printf("%d\n",*p);// 通过 p 修改 a 的值*p=20;printf("%d\n",a);// 输出 20return0;}这里*p的意思找到p所指向的地址中对应的那个变量,就相当于变相的操作那个变量。
需注意的是如果指针指向的是常量就不能去随意操作,因为常量本身不能修改能修改的就不叫常量。
指针变量的大小
指针变量存的是地址,那它占多少字节呢?
这完全取决你的电脑是多少位,一般电脑是32位和64位(64位兼容32位,32位不兼容64位)
| 系统 | 地址总线位数 | 地址范围 | 指针大小 |
|---|---|---|---|
| 32位系统 | 32位 | 0 ~ 2³²-1 | 4 个字节 |
| 64位系统 | 64 | 0 ~ 2⁶⁴-1 | 8个字节 |
为什么?因为指针存的就是地址编号,地址编号的范围决定了需要用多少位来表示。32位系统地址编号最大到 2³²-1,需要 32 个 bit 来存,也就是 4 个字节。
#include<stdio.h>intmain(){printf("%zd\n",sizeof(char*));printf("%zd\n",sizeof(short*));printf("%zd\n",sizeof(int*));printf("%zd\n",sizeof(double*));return0;}注意:指针变量的大小和类型是无关的,只要指针类型的变量,在相同的平台下,大小都是相同的
32位平台就是4字节,64位平台就是8字节
指针变量类型的意义
指针变量的大小和类型无关,只要是指针变量,在同一个平台下,大小都是一样的,为什么还要有各种各样的指针类型呢?
其实指针类型是有特殊意义的,我们继续了解指针变量类型的意义
指针的解引用
我们先看下面两个代码:
调试我们可以看到,代码1会将a的4个字节全部改为0,但是代码2只是将a的第一个字节改为0。
所以指针的类型决定了,对指针解引用的时候有多大的权限(一次能操作几个字节)。
比如:char* 的指针解引用就只能访问一个字节,而 int* 的指针的解引用就能访问四个字节。
指针 + - 整数
指针的 + - 整数当同指针类型时+/- 1时等价于 一维数组的+/- 1,当非同类型即强制类型转换时比如原本是int类型的指针变量 强制转换成char类型的指针变量就会从原来的+1跳过4个字节变成跳过1个字节。
#include<stdio.h>intmain(){intarr[5]={1,2,3,4,5};int*p=arr;// 在C语言中,数组名在表达式中通常被隐式转换为指向其首元素的指针。printf("*(p+1):%d\n",*(p+1));char*c=(char*)arr;printf("*(c+1):%d\n",*(c+1));return0;}*(p + 1) 是等价于数组的++,–的所以打印结果为2
- c 指向 arr 的首地址(即第一个字节)。
- 假设系统为小端序(x86 常用),arr[0] = 1 在内存中存储为 01 00 00 00(低字节在前)。
c+1 指向第二个字节,该字节值为 0。 - 解引用得到 char 类型的 0,打印为 %d 时提升为 int,所以输出 0。
大端是 00 00 00 01所以那怕是大端序也是这样的。
那么这里又衍生出了一个新的知识点,小端和大端序。
什么是小端序什么是大端序呢?
小端和大端序单纯是计算机一种读取内存的方式,小端:低地址存 小(低)字节;大端:低地址存 大(高)字节。
那么怎么区分当前系统是大端还是小端呢?
很简单就像上一个例子一样我们给一个int num = 1; 强制转换成char* 打印出1就是小端0就是大端。
注意:指针加减整数常用于数组元素的遍历,注意不要越界。
#include<stdio.h>intmain(){intnum=1;char*pc=(char*)#printf("%d\n",*pc);return0;}void* 指针
void我们知道是无返回值的而void* 是一种特殊的指针类型,称为“无类型指针”或“泛型指针”。它可以接收任意类型的地址,但不能直接解引用,也不能直接进行加减整数运算(因为不知道步长)。
void* 我们可以认为是一种垃圾桶,不管什么类型的指针变量它都可以接受。这也就是为什么很多C语言标准库中的函数返回值是void的原因了当你不知道或者你要这个能接受大部分类型时就可以给void
#include<stdio.h>intmain(){inta=10;doubleb=3.14;void*vp=&a;// 合法vp=&b;// 同样合法// printf("%d\n", *vp); // 错误!void* 不能直接解引用// vp = vp + 1; // 错误!void* 不能直接加减整数// 必须强制转换为具体类型后再使用int*ip=(int*)vp;printf("%d\n",*ip);// 若 vp 实际指向 double,强制转为 int* 再解引用是未定义行为,应确保类型匹配。return0;}const修饰指针
const 修饰的变量,其值不能被程序直接修改。它更像一个“契约”:告诉编译器和程序员,这个变量是只读的,如果代码试图去修改它,编译器会报错。
const 修饰变量
const 修饰的变量称为常变量,其值不能被修改(但可以通过指针等方式间接尝试修改,这是未定义行为)。
intmain(){constintnum=10;num=20;// 错误!num 是只读的return0;}注意:const修饰的变量是常变量或者说是只读变量,不是常量不能用于数组上。但是有程序可以通过是因为c99有一个新的语法叫变长数组,但是这样num就失去了初始化只能通过数组的输入来赋值。
const 修饰指针变量
指向常量的指针:
const int *p;或int const *p;- 指针指向的数据是只读的,不能通过
*p修改它。 - 指针本身可以指向其他变量。
- 指针指向的数据是只读的,不能通过
常量指针:
int * const p;- 指针本身的值(地址)是只读的,不能再指向其他地方。
- 可以通过
*p修改指向的数据。
指向常量的常量指针:
const int * const p;- 指针指向的数据和指针本身都不可修改。
怎么了解呢?你就认为 *p 和 p是前后桌关系const就是老师,老师在p后面时p敢和明目张胆的搞小动作吗?很明显不敢,那么这也是为什么 *p 可以修改因为不在我后面我肯定敢搞小动作
同理当const *p在 *p后面 *p敢搞小动作吗?那也肯定不敢啊。p同上。
那么两个const不就是两个老师分别看着 *p 和 p吗?
#include<stdio.h>intmain(){inta=10,b=20;constint*p1=&a;// *p1 = 30; // 错误,不能通过 p1 修改 ap1=&b;// 正确int*constp2=&a;*p2=30;// 正确// p2 = &b; // 错误,p2 不能指向别处constint*constp3=&a;// *p3 = 30; // 错误// p3 = &b; // 错误}指针运算
指针 - 指针
当两个指针指向同一数组(或同一块连续内存)时,指针相减的结果是两个地址之间元素的个数(类型由指针类型决定),其类型为 ptrdiff_t(定义在 <stddef.h> 中,通常为有符号整数)。
#include<stdio.h>intmain(){intarr[5]={1,2,3,4,5};int*p=&arr[1];int*q=&arr[4];// 类似sizeof计算数组大小intdiff=q-p;// diff = 3,因为 arr[4] 与 arr[1] 相隔 3 个 intprintf("元素个数差:%d\n",diff);return0;}若两个指针指向不同数组(或不同 malloc 块),相减是未定义行为,即使它们地址数值上的差值有意义,也不应依赖。
指针的关系运算
指针可以进行关系运算(>, >=, <, <=, ==, !=),但仅当两个指针指向同一数组(或同一块连续内存)的元素或其末尾之后的一个位置时,比较结果才有意义。
#include<stdio.h>intmain(){intarr[5]={1,2,3,4,5};int*p=arr;int*q=&arr[4];while(p<=q){// 合法且安全,因为 p 和 q 指向同一数组printf("%d ",*p);p++;}}那么我们再看下面代码:
#include<stdio.h>intmain(){charstr[]="hello";// 唯一的数组char*p1=str;// 第一个指针指向该数组char*p2=str;// 第二个指针也指向该数组(同一个地址)if(p1==p2){printf("p1 和 p2 指向同一个地址,条件成立!\n");printf("p1 = %p, p2 = %p\n",p1,p2);}else{printf("条件不成立\n");}// 为了对照,再回顾一下两个不同数组的情况charstr3[]="hello";charstr4[]="hello";if(str3==str4){printf("str3 和 str4 地址相同\n");}else{printf("str3 和 str4 是两个不同的数组,地址不同: %p vs %p\n",str3,str4);}return0;}这里p1 和 p2相等因为指向同一块内存,str3 和 str4不相等因为指向不同的内存。我们可以认为p1 和 p2 是克隆关系,他们别无二致;str3 和 str4是双胞胎单纯只是长大一样,但是他们的习惯和性格基本是不一样的。
野指针
野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)。就像路边的野狗野猫被咬了只能自认倒霉,但是如果是有主人的就可以通过法律维权获取赔偿
野指针成因
- 未初始化:局部指针变量未赋初值,其值是随机的。
- 越界访问:指针操作超出了合法内存范围(如数组越界)。
- 释放/销毁后未置空:free§ 或函数返回局部变量的地址后,指针仍然指向已失效的内存。
int*p;// 未初始化,野指针*p=10;// 危险intarr[3];int*q=arr+3;// 指向末尾之后,可以比较但不能解引用*q=100;// 危险,越界访问int*func(){intx=10;return&x;// 返回局部变量地址,函数返回后 x 失效}int*r=func();*r=20;// 危险,悬挂指针规避野指针
- 声明指针时初始化为 NULL。
- 遍历结束后指针越界,立即置 NULL 避免悬空;
- 确保指针指向有效内存后再使用。
- free( p ) 后立即将 p = NULL;。
- 不要返回局部变量的地址。
- 对数组下标进行边界检查。
- 使用静态/动态分析工具(如 Valgrind、AddressSanitizer)检测野指针。
#include<stdio.h>intmain(){intarr[10]={1,2,3,4,5,6,7,8,9,10};int*p=&arr[0];inti=0;for(i=0;i<10;i++){*(p++)=i;}// 此时p已经越界了,可以把p置为NULLp=NULL;// 下次使用的时候,判断p不为NULL的时候再使用p=&arr[0];// 重新让p获得地址if(p!=NULL)// 判断{//...}return0;}assert断言
assert 是 <assert.h> 提供的宏,用于在运行时检查条件是否为真。若条件为假,程序终止并输出错误信息(文件名、行号、表达式)。
细心的就会发现assert和if是一样的,那么为什么C语言要提供assert呢?
assert是直接中止程序并且告诉程序员在那个文件的哪一行好查找;
if是报错加程序员自己给的exit信息。
它两最大的区别就是assert是一种更严厉的父母当孩子犯错后就是通过严厉的惩罚让孩子记住这个错误,if是一种温柔的父母当孩子犯错了只会跟孩子说哪里出错了不会惩罚,但是有的人就说了有的时候使用if程序不也退出了。这是因为if本身不能终止程序,终止程序的是程序员的后续操作与if操作无关。
#include<assert.h>#include<stdio.h>intdivide(inta,intb){assert(b!=0);// 若 b==0,程序崩溃returna/b;}注意事项:
- assert 适用于调试阶段,检查程序内部逻辑错误(如指针不应为 NULL)。
- 不要将带有副作用的表达式放入 assert,因为定义了 NDEBUG 宏后,assert 会被展开为空,副作用将消失。
- 发布版本(release)可通过 #define NDEBUG 关闭所有 assert。
指针的使用和传址调用
传值调用和传址调用
C语言函数参数默认是传值调用,即函数内操作的是实参的副本,修改形参不会影响实参。
voidswap_fail(intx,inty){inttmp=x;x=y;y=tmp;}intmain(){inta=3,b=5;swap_fail(a,b);printf("%d %d\n",a,b);// 仍然输出 3 5}总结:实参传递给形参的时候,形参会单独创建一份临时空间来接收实参,对形参的修改不影响实参
那么传址调用又是什么呢?
传址调用:通过传递指针,函数内解引用修改实参的值,从而实现“改变外部变量”。
voidswap_success(int*px,int*py){inttmp=*px;*px=*py;*py=tmp;}intmain(){inta=3,b=5;swap_success(&a,&b);printf("%d %d\n",a,b);// 输出 5 3}传址调用的本质也是传值——传递的是指针变量的值(地址),只不过通过地址间接操作了外部变量。
传址调用等价于int a = 10; int *p = &a; *p = 20; 这种在同一个作用域操作,而传址调用就是不在同一个作用域里面操作
传址调用,可以让函数和主调函数之间建立真正的联系,在函数内部可以修改主调函数中的变量;所以未来函数中只是需要主调函数中的变量值来实现计算,就可以采用传值调用。如果函数内部要修改主调函数中的变量的值,就需要传址调用。
完