前情回顾:解锁C语言魔法符号!探秘操作符的奇妙世界-CSDN博客
一、内存和地址
1、内存
我们知道计算机上CPU(中央处理器)在处理数据的时候,需要的数据是在内存中读取的,处理后的数据也会放回内存中,那我们买电脑的时候,电脑上内存是8GB/16GB/32GB等,那这些内存空间如何高效的管理呢?
其实也是把内存划分为一个个的内存单元,每个内存单元的大小取1个字节。
计算机中常见的单位(补充):一个比特位可以存储⼀个2进制的位1或者0
一个字节空间可以存放八个比特位,每个内存单元都有一个编号,有了这个内存单元的编号,CPU就可以快速找到一个空间内存。在计算机中我们把内存单元的编号也称为地址,C语言中给地址起了一个新的名字:指针。
所以我们可以直接理解为:内存单元编号=地址=指针。
2、编址
先为大家展示一幅图:
在计算机系统中,CPU(中央处理器)与内存之间的交互至关重要。看这张图,左边是 CPU,右边是内存 。连接它们的是地址总线、数据总线和控制总线。
地址总线尤为关键,它负责传输内存地址。图中地址总线上的 0 和 1 组合,就像给内存单元的 “门牌号” 。通过这些 0 和 1 不同的二进制组合,CPU 能够精准定位内存中的特定存储单元。这种给内存单元分配唯一 “门牌号”(二进制地址编码)的方式,就是编址。编址让 CPU 能准确地对内存进行读写操作,就好比我们通过门牌号能准确找到对应房屋一样 。 而数据总线用于传输数据,控制总线用于传输控制信号,共同协作完成 CPU 与内存之间的信息交互。
计算机中的编址,并不是把每个字节的地址记录下来,而是通过硬件设计完成的。 钢琴、吉他上面没有写上“剁、来、咪、发、 唆、拉、西”这样的信息,但演奏者照样能够准确找到每一个琴弦的每一个位置,这是为何?
因为制造商已经在乐器硬件层面上设计好了,并且所有的演奏者都知道。本质是一种约定出来的共 识! 硬件编址也是如此我们可以简单理解,32位机器有32根地址总线, 每根线只有两态,表示0,1【电脉冲有无】,那么一根线,就能表示2种含义,2根线就能表示4种含义,依次类推。32根地址线,就能表示2^32种含义,每一种含义都代表一个地址。 地址信息被下达给内存,在内存上,就可以找到该地址对应的数据,将数据在通过数据总线传入CPU内寄存器。
二、指针变量和地址
#include<stdio.h>
int main()
{
int a = 10;
return 0;
}
这是一段再简单不过的代码了,这串代码要实现的就是创建一个整型变量a并给他赋值为10。
通过上述内容的学习那我们思考一下变量创建的本质是什么?
其应该是在内存中申请了一块空间,用来存放数据。
向内存申请四个字节的空间,用来储存10,空间的名字叫a,这个a的名字不是给编译器、计算机看的,而是给程序员自己看的。
1、&(取地址操作符)
理解了内存和地址的关系,我们回到C语言中,在C语言中创建变量其实就是向内存申请空间,我们还拿上次的例子来为大家说明:
我为大家写了一些注释,这张图中我们可以清晰看到a在内存中的存放方式,a原本的2进制形式是00000000 00000000 00000000 00001010,利用上一篇博客我们学到进制转换我们将其转换为16进制形式便可以得出: 0x 00 00 00 0a(1010实质就是a),此时大家可能已经有点初见端倪了,我们还可以将地址转换为一列再次来看:
上述的代码就是创建了整型变量a,内存中申请4个字节,用于存放整数10,其中每个字节都有地址,上图中4个字节的地址分别是:0x00000077AA34FD24、0x00000077AA34FD25、0x00000077AA34FD26、0x00000077AA34FD27。
那我们如何能够得到a的地址呢?这时候我们就需要学习一个操作符--&(取地址操作符)。
这个打印的结果也是16进制的,我们发现结构与上次不一样,那是不是我们就错了呢,当然不是,上面我们就说过,向内存申请四个字节的空间,用来储存10,但是空间具体在哪是不知道的,我们给他们放在一张图中,可以看的清晰:
我们可以发现&a取出的是a所占字节中地址较小的字节的地址。
虽然整型变量占四个字节,但是从上面的例子,我们可以明白:我们只要知道第一个字节的地址,顺藤摸瓜访问到四个字节的数据也是可行的。
2、指针变量
<1>、引入
通过上面的学习,那我们通过取地址操作符(&)拿到的地址是一个数值,这个数值有时候也是需要存储起来,方便后期再使用的,那我们把这样的地址值存放在哪里呢?答案是:指针变量中。
现在我们给出一串代码:
#include<stdio.h>
int main()
{
int a = 10;
int* p = &a;//取出a的地址并存储到p中
//名字叫p,类型叫int*
return 0;
}
p被称为指针变量,理解为:存放指针的变量。
指针变量也是一种变量,这种变量是用来存放地址的,存放在指针变量中的值都会被理解为地址。
<2>、如何拆解指针类型
我们看到上述代码中,p的类型是int*,*就在说明p是指针变量,而前面的int是在说明p指向的是整型(int)类型的对象。下面我们用图片为大家更清楚的展示:
<3>、*--解引用操作符(间接访问操作符)
我们将地址保存起来,未来是要使用的,那怎么使用呢? 在现实生活中,我们使用地址要找到一个房间,在房间里可以拿去或者存放物品。
C语言中其实也是一样的,我们只要拿到了地址(指针),就可以通过地址(指针)找到地址(指针)指向的对象,这里必须学习一个操作符叫解引用操作符(*)。
#include<stdio.h>
int main()
{
int a = 100;
int* p = &a;
*p = 0;
return 0;
}
上述代码中就使用了解引用操作符,*p的意思就是通过p中存放的地址,找到指向的空间,*p其实就是a变量了;所以*p=0,这个操作符是把a改成了0。
这里是把a的修改交给了p来操作,这样对a的修改,就多了一种的途径,写代码就会更加灵活。
这里也为小白提供一个快速理解的小tip:
这是一个非常简单粗暴的理解,如果是新手小白可以先尝试这样记住,后续再慢慢究其本源。
3、指针变量的大小
前面的内容我们了解到,32位机器假设有32根地址总线,每根地址线出来的电信号转换成数字信号后是1或者0,那我们把32根地址线产生的2进制序列当做一个地址,那么一个地址就是32个bit位,需要4个字节才能存储。 如果指针变量是用来存放地址的,那么指针变量的大小就得是4个字节的空间才可以。同理64位机器,假设有64根地址线,一个地址就是64个二进制位组成的二进制序列,存储起来就需要8个字节的空间,指针变量的大小就是8个字节。
上述代码是在X86环境的输出结果,我们再换到X86环境来看看结果:
我们这时候就可以发现三个结论:
1、32位平台下的地址是32个bit位,指针变量的大小是4个字节。
2、64位平台下的地址是64个bit位,指针变量的大小是8个字节。
3、指针变量的大小和类型是无关的,只要指针类型的变量,在相同平台下,大小都是相同的。
三、指针变量类型的意义
1、指针的解引用
//代码1
#include<stdio.h>
int main()
{
int n = 0x11223344;
int* pc = &n;
*pc = 0;
return 0;
}
//代码2
#include<stdio.h>
int main()
{
int n = 0x11223344;
char* pc = (char*)&n;
*pc = 0;
return 0;
}
我们对上述俩段代码分别调试便可以看到:代码1会将n的四个字节全部改为0;但是代码2只是将n的第一个字节改为0。
代码1:
代码2:
于是我们便可以得到结论:指针的类型决定了对一个指针解引用的时候有多大权限(一次能操作几个字节)。
比如:char* 的指针解引用就只能访问一个字节,而int* 的指针的解引用就能访问四个字节。
2、指针+-整数
从这个标题就可以看出来我们要干嘛,理论满分,直接实践!!!
我们可以看出pa与pa+1差的值是4,而恰恰一个整型又占四个字节;pc与pc+1差的值是1,而恰恰一个字符类型占1个字节。这是巧合吗?当然不是啦!
我们可以看出,char* 类型的指针变量+1跳过1个字节,int* 类型的指针变量+1跳过了4个字节。这就是指针变量的类型差异带来的变化。指针+1,其实跳过1个指针指向的元素。指针可以+1,那也可以-1。
结论:指针的类型决定了指针向前或者向后走一步的距离有多大。
3、void* 指针
在我们所了解的指针类型中有一种特殊的类型是void* 类型,可以理解为无具体类型的指针(也叫泛型指针),这种类型的指针可以用来接收任意类型的地址。但也有局限性,void* 类型的指针不能直接进行指针的+-整数和解引用的运算。
#include<stdio.h>
int main()
{
int a = 10;
int* pa = &a;
char* pc = &a;
return 0;
}
将上述代码放到VS2022中进行编译便会出现以下的警告:
在上面的代码中,将一个int类型的变量的地址赋值给一个char* 类型的指针变量。编译器会发出警告,是因为类型不兼容。而使用void* 类型就不会有这样的问题。
结论:void* 类型的指针可以接受不同类型的地址,但是无法直接进行指针运算。
那们void*类型的指针到底有什么用呢?
一般void* 类型的指针是使用在函数参数的部分,用来接收不同类型数据的地址,这样的设计可以 实现泛型编程的效果。(大家先了解,后续还会进行讲解)
四、指针运算
1、指针+-整数
我们知道数组在内存中是连续存放的,只要知道第一个元素的地址,就可以顺藤摸瓜找到后面的所有元素:
我们首先思考一下原来我们是怎么打印数组的元素的,我们会采取循环的方式如下图:
这样的方式非常的通俗易上手,但是我们既然学了指针,那就要思考有没有别的方式呢?毋庸置疑,肯定是有的。下面给出一种新的方法:
我们利用上述学的知识找到arr[0]的地址,然后不断让p++,这里的p+1就相当于他向前走了四个字节。(指针变量存储的是内存地址。对于指向数组元素的指针(这里 p 指向 arr 数组首元素),p++ 会使指针指向下一个数组元素的内存地址。在 C 语言中,指针自增的步长取决于它所指向的数据类型。这里 p 是 int * 类型指针,指向 int 型数据,int 在常见系统中占 4 字节(32 位系统)或 8 字节(64 位系统),p++ 会让指针的地址值增加相应字节数(如 4 字节或 8 字节 ),指向下一个 int 元素。在代码的 for 循环中,每次执行 p++,p 就会从指向当前数组元素变为指向下一个数组元素。循环首次执行时,p 指向 arr[0] ,执行一次 p++ 后,p 指向 arr[1],再执行一次 p++,p 指向 arr[2],依此类推,从而实现通过指针遍历数组元素,配合printf("%d ",*p) 语句依次输出数组每个元素的值。)
我们还可以将代码进一步优化:
当执行 pc + i
时(pc
是 int*
类型指针 ),它并不是简单地将指针 pc
存储的地址值加上整数 i
,而是加上 i * sizeof(int)
。在常见系统中,int
类型一般占 4 字节(32 位系统 )或 8 字节(64 位系统 )。例如,假设 pc
初始指向的内存地址为 0x100
,i
的值为 1
,那么 pc + 1
得到的地址不是 0x101
,而是 0x100 + 4
(如果 int
占 4 字节 )即 0x104
,正好指向下一个 int
型数组元素的存储位置。
在这段代码里,pc
初始指向数组 arr
的首元素(&arr[0]
)。通过 for
循环,i
从 0
开始递增 ,每次循环 pc + i
会依次指向数组 arr
的各个元素:
当 i = 0
时,pc + 0
还是指向 arr[0]
,*(pc + 0)
就等同于 *pc
,取出 arr[0]
的值。
当 i = 1
时,pc + 1
指向 arr[1]
的内存位置,*(pc + 1)
取出 arr[1]
的值。
以此类推,随着 i
的递增,pc + i
能遍历整个数组,配合 printf
函数输出数组的每个元素值。 这是一种利用指针算术运算来遍历数组元素的常见且高效的方式。
其实pc的本质就是&arr[0]。
那我们根据上述知识现在思考一下如果我们要逆向打印数组的内容该怎么操作呢?
这么一看指针是不是就显得非常简单啦。我们首先取出的是数组最后一个元素的地址,然后不断向前推进即可。
下面我们再给出一个字符数组的例子,我们再次来会会我们的老朋友“hello world”:
#include<stdio.h>
int main()
{
char arr[] = "hello world";
char* pc = &arr[0];
while (*pc != '\0');
{
printf("%c", *pc);
pc++;
}
return 0;
}
这里的‘\0’可以省略,因为\0的ASCII的值本身就为0。
2、指针-指针
我们先说结论:| 指针-指针 |=俩个指针之间的元素个数。
上面所述的是指针-指针的绝对值,那要是没有绝对值符号又会是什么呢?我们用代码测试看看呢:
我们可以看出也可以得到负数。从上述的例子我们也能看出这个结论也是有前提的:俩个指针指向了同一块空间,否则不能相减。
3、举一反三
<1>、strlen函数
我们知道用strlen函数可以求一个字符串\0之前的字符个数(打印时候使用%zu),使用方法如下:
使用函数的方法我们可以很快的求出字符串的字符个数,那我们可不可以不使用指针来实现这一操作呢?当然是可以的。
<2>、自定义strlen函数
在讲解这部分知识之前我们要明白:数组名其实也是首元素地址。也就是说arr==&arr[0]。
这串代码很好的帮我们解决了这个问题,但是我们还会想到既然指针-指针得到的是俩指针之间的元素个数,我们可不可以用指针-指针的方式来实现呢?这当然是可以的。
<3>、指针-指针实现strlen
pc - pa 实则就是&arr[9] - &arr[0]。
4、指针的关系运算
我们直接用代码来看:
五、const修饰指针
1、引入:const修饰变量
这里我们来学习一个新的关键字--const,我们直接来说const的作用:用于限定一个变量为只读,即不能通过直接修改该变量来改变其他变量。
我们直接来看代码:
我们可以看到代码发生了错误,const是常属性的意思(即不能改变的意思),这里的n本质上还是变量,只是const在语法层面上限制了n的修改,致使我们没法直接修改n(n是变常量)。
但是如果我们绕过n,使用n的地址,去修改n又会发生什么呢?
从这段代码中我们可以虽然看到n被修改了,但是这样做实则是打破语法规则的。我们可以理解为const限制了n但是&n依旧可以修改。通过上述的例子的学习我们便可以引出:const修饰指针变量。
2、引出:const修饰指针变量
一般来说const修饰指针变量可以放在 * 左边也可以放在 * 号右边,这俩边的意义是不一样的。
<1>、const修饰 * 左边
我们举个例子来看看:
从上述例子我们可以发现const限制的是 * pc
结论:const修饰指针变量的时候const可以放在*左边,const限制的是pc指向对象,也就是*pc不能给修改,但是pc不受限制,也就是指针变量可以改变指向
<2>、const修饰 * 右边
我们还是举例子来看:
从上述例子我们可以发现const限制的是 pc
结论:const修饰指针变量的时候const可以放在*右边,const限制的是pc,也就是pc的指向对象不能改变了,但是*pc不受限制,也就是说pc指向的内容,可以通过pc来改变。
<3>、小结
下面为大家给出代码可以自行在电脑上进行测试:
#include <stdio.h>
//代码1 - 测试⽆const修饰的情况
void test1()
{
int n = 10;
int m = 20;
int* p = &n;
*p = 20;//ok?
p = &m; //ok?
}
//代码2 - 测试const放在*的左边情况
void test2()
{
int n = 10;
int m = 20;
const int* p = &n;
*p = 20;//ok?
p = &m; //ok?
}
//代码3 - 测试const放在*的右边情况
void test3()
{
int n = 10;
int m = 20;
int * const p = &n;
*p = 20; //ok?
p = &m; //ok?
}
//代码4 - 测试*的左右两边都有const
void test4()
{
int n = 10;
int m = 20;
int const * const p = &n;
*p = 20; //ok?
p = &m; //ok?
}
int main()
{
//测试⽆const修饰的情况
test1();
//测试const放在*的左边情况
test2();
//测试const放在*的右边情况
test3();
//测试*的左右两边都有const
test4();
return 0;
}
结论:const修饰指针变量的时候:
const如果放在*的左边,修饰的是指针指向的内容,保证指针指向的内容不能通过指针来改变。 但是指针变量本身的内容可变。
const如果放在*的右边,修饰的是指针变量本身,保证了指针变量的内容不能修改,但是指针指 向的内容,可以通过指针改变。
六、野指针
概念:野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)
说的通俗一点就是指针指向的空间是不属于当前程序的。
1、野指针成因
<1>、指针未初始化
这里我们可以看出代码发出了警告,是因为pc是局部变量,局部变量指针未初始化,默认是随机值。
<2>、指针越界访问
循环到11次时候就造成了越界访问。
<3>、指针指向的空间释放
我们首先来看一串代码:
#include <stdio.h>
int* test()
{
int n = 100;
return &n;
}
int main()
{
int* p = test();
printf("%d\n", *p);
return 0;
}
这段代码是野指针成因中 “函数返回局部变量地址” 的典型例子,test 函数返回了局部变量 n 的地址(&n) 。当 test 函数执行完,n 的生命周期结束,其占用的内存被系统收回。但 main 函数中 p 却接收了这个地址,此时 p 指向的内存已无效,p 就成了野指针。后续对 *p 解引用操作时,访问的是不确定甚至已被重新分配的内存区域,可能得到错误数据,或引发程序崩溃、未定义行为等。
我们将这段代码运行起来:
我们发现代码的运行结果就是我们所想的,但其实这是巧合。我们对代码稍加修改:
我们发现其实他应该是个随机值。
2、如何规避野指针
<1>、指针初始化
如果明确知道指针指向哪里就直接赋值地址,如果不知道指针应该指向哪里,可以给指针赋值NULL。NULL 是C语言中定义的⼀个标识符常量,值是0,0也是地址,这个地址是无法使用的,读写该地址会报错。我们只需记住:NULL是空指针。
初始化如下:
<2>、小心指针越界
一个程序向内存申请了哪些空间,通过指针也就只能访问哪些空间,不能超出范围访问,超出了就是越界访问。
<3>、指针变量不再使用时,及时置NULL,指针使用之前检查有效性
“当指针变量指向一块区域的时候,我们可以通过指针访问该区域,后期不再使用这个指针访问空间的时候,我们可以把该指针置为NULL。因为约定俗成的一个规则就是:只要是NULL指针就不去访问, 同时使用指针之前可以判断指针是否为NULL。我们可以把野指针想象成野狗,野狗放任不管是非常危险的,所以我们可以找⼀棵树把野狗拴起来, 就相对安全了,给指针变量及时赋值为NULL,其实就类似把野狗栓起来,就是把野指针暂时管理起来。 不过野狗即使拴起来我们也要绕着走,不能去挑逗野狗,有点危险;对于指针也是,在使用之前,我们也要判断是否为NULL,看看是不是被拴起来的野狗,如果是不能直接使用,如果不是我们再去使用。”如下图所示:
<4>、避免返回局部变量的地址
造成野指针的第3个例子,不要返回局部变量的地址。
七、assert断言
assert.h 头文件定义了宏 assert( ) ,用于在运行时确保程序符合指定条件,如果不符合,就报错终止运行。这个宏常常被称为“断言”。
assert(p != NULL);
上面代码在程序运行到这一行语句时,验证变量 p 是否等于 NULL 。如果确实不等于 NULL ,程序继续运行,否则就会终止运行,并且给出报错信息提示。assert( ) 宏接受一个表达式作为参数。如果该表达式为真(返回值非零),assert( ) 不会产生任何作用,程序继续运行。如果该表达式为假(返回值为零),assert( )就会报错,在标准错误流stderr中写入一条错误信息,显示没有通过的表达式,以及包含这个表达式的文件名和行号。
assert( )的使用对程序员是非常友好的,使用assert( )有几个好处:它不仅能自动标识头文件和出问题的行号,还有一种无需更改代码就能开启或关闭assert( )的机制。如果已经确认程序没有问 题,不需要再做断言,就在#include语句的前面,定义一个宏NDEBUG。
#define NDEBUG
#include <assert.h>
然后,重新编译程序,编译器就会禁用文件中所有的assert( )语句。如果程序又出现问题,可以移 除这条#define NDEBUG指令(或者把它注释掉),再次编译,这样就重新启用了assert( )语句。
assert( )的缺点是,因为引入了额外的检查,增加了程序的运行时间。
一般我们可以在 Debug 中使用,在 Release 版本中选择禁用 assert 就行,在 VS 这样的集成开 发环境中,在 Release 版本中,直接就是优化掉了。这样在debug版本写有利于程序员排查问题, 在 Release 版本不影响用户使用时程序的效率。
八、strlen函数的模拟实现
前面我们已经为大家讲解过相同的内容,通过上面assert的学习我们现在带来一个更加完善的版本。前面所学的:
完善之后:
- 增加断言(
assert
):使用assert(pc != NULL)
来检查传入的指针 pc 是否为NULL
。如果 pc 是NULL
,程序会在运行时终止并给出错误提示,能快速定位问题,避免后续对空指针进行无意义操作而导致程序崩溃等严重后果 。而第一段代码未对传入指针是否有效进行检查,若传入空指针,程序会因访问空指针出现未定义行为。 - 使用
const
修饰指针:const char* pc
表明函数不会修改 pc 所指向的字符串内容,从语法层面禁止了在函数内部意外修改字符串的操作,增强了代码的安全性和稳定性。若在函数内不小心有修改字符串的代码,编译器会报错。而第一段代码没有这种限制,可能会误操作修改传入的字符串。
九、传值调用与传址调用
写一个函数,交换两个整型变量的值。
1、传值调用
我们经过前面的学习会不假思索的写出这样的代码:
我们发现这段代码并没有按照我们的预期运行,这是为什么呢?我们进行调试来看看。
(这里不会调试的朋友们可以看看我写的另一篇博客:C语言特别篇--数组与函数的实践 + 函数递归-CSDN博客)
我们发现在main函数内部,创建了a和b,a的地址是0x0000001e9baff954,b的地址是0x0000001e9baff974,在调用swap函数时,将a和b传递给了swap函数,在swap函数内部创建了形参x和y接收a和b的值,但是x的地址是0x0000001e9baff930,y的地址是0x0000001e9baff938,x和y确实接收到了a和b的值,不过x的地址和a的地址不一样,y的地址和b的地址不一样,相当于x和y是独立的空间,那么在swap函数内部交换x和y的值,自然不会影响a和b,当swap函数调用结束后回到main函数,a和b的没法交换。swap函数在使用的时候,是把变量本身直接传递给了函数,这种调用函数的方式我们之前在函数的时候就知道了,这种叫传值调用。
结论:实参传递给形参的时候,形参会单独创建一份临时空间来接收实参,对形参的修改不影响实 参。
2、传址调用
我们现在要解决的就是当调用swap函数的时候,swap函数内部操作的就是main函数中的a和b,直接将a和b的值交换了。那么就可以使用指针了,在main函数中将a和b的地址传递给swap函数,swap函数里边通过地址间接的操作main函数中的a和b,并达到交换的效果就好了。
我们可以看到swap的方式,顺利完成了任务,这里调用swap函数的时候是将变量的地址传递给了函数,这种函数调用方式叫:传址调用。
传址调用,可以让函数和主调函数之间建立真正的联系,在函数内部可以修改主调函数中的变量;所以未来函数中只是需要主调函数中的变量值来实现计算,就可以采用传值调用。如果函数内部要修改主调函数中的变量的值,就需要传址调用。