要想学好C语言,作为灵魂的指针那是必须要掌握的,而要想搞定指针,就不得不讲一下内存和地址之间的关系
内存和地址
计算机上的CPU(中央处理器)在处理数据的时候,需要的数据是在内存中读取的,处理后的数据也会放回内存中!那么内存空间又是如何管理和使用的呢?
其实内存空间也是划分为一个一个的内存单元的,每个内存单元的大小取一个字节!
补充(计算机中的单位)
计算机中的单位大致有以下几种:
bit(比特位)、Byte(字节)、KB、MB、GB、TB、PB
他们的转换关系为:
1Byte = 8bit
1KB = 1024Byte
1MB = 1024KB
1GB = 1024MB
1TB = 1024GB
1PB = 1024TB
其中一个比特位(bit)的大小是存放一个二进制位的0或1所占空间的大小。
每个内存单元的大小是1个字节,即8个比特位,而且每个内存单元都有一个编号,有了这个编号,CPU就可以快速的找到这个内存空间。
在计算机中,我们把内存单元的编号也叫地址,而在C语言中,地址也叫指针。
如何编址?
因为CPU访问内存的某块空间时是通过地址来完成的,所以需要对内存进行编址。计算机中的编址,是硬件在设计的时候就完成的,跟钢琴的键位差不多,所以并不是把每一个内存的地址都给记录下来。
那具体是如何编址的呢?
首先补充一点:CPU计算机内部是由很多硬件单元相互协同工作的,而硬件与硬件又是相互独立的,要把这些硬件相互联系起来,就需要用线(就是物理意义上的线)连起来。那么内存和CPU之间也是通过这些线来完成大量的数据交互的,硬件编址要用到其中一组线,叫地址总线。
假设32位机器有32根地址总线,每根地址总线都有两种状态表示0和1(电脉冲的有无),那么一根线就能表示两种含义,两根就能表示4中含义,32根地址线就能表示2^32中含义,每一种含义代表一种地址。
地址具体如何使用?
首先再补充两点
1:CPU与内存或其他器件之间的数据传输是通过数据总线来进行的
2:CPU对外部器件的控制是通过控制总线进行的
假设CPU要对内存进行一次读操作
首先控制总线先发一条读的命令(R),当地址总线传过来一个信息的时候,把地址信息下达给内存,在内存上,就可以找到该地址对应的那块空间,然后将里面的数据通过数据总线传入CPU内的寄存器,这样就完成了一次读操作。
注:找内存单元是地址来做的,地址总线是用来传递地址信息的。
指针变量和地址
变量创建的本质
我们在写代码的时候会创建一些变量,而创建变量的本质其实是向内存申请了一块空间
int n = 5;
因为int类型的变量所占空间的大小是4个字节,所以上面这句代码就是向内存申请了4个字节的空间,存放5这个数字,而这4个字节每一个都有一个地址
需要注意的是:变量名其实是给我们看的,编译器是通过地址来找内存单元的
取地址操作符&
这个操作符可以取出变量在内存中的地址,例如:
#include <stdio.h>
int main()
{
int n = 8;
int * p = &n;//p是指针变量,类型是int*
printf("%p\n", p);//%p是用来打印n的地址的
return 0;
}
在这个代码中,定义了一个int类型的变量n,值是8,&n就可以得到变量n的地址(需要注意的是:一个int类型有4个字节,每一个字节都有一个地址,而&n取出的是4个字节中地址较小的字节的地址)这里用int*类型的指针变量p来存放n的地址,因为在C语言中,地址就是指针,要用指针变量来接收,至于为什么是int*类型,下文讲指针变量类型的意义时会有解释
在int * p = &n;这句代码中,p是指针变量的名字,int *是指针变量p的类型,*表示p是一个指针变量,int表示p指向的变量n的类型是int
解引用操作符*
解引用操作符也叫间接访问操作符,对指针变量进行解引用就能找到指针变量所指向的那块空间
#include <stdio.h>
int main()
{
int n = 50;
int * p = &n;
*p = 70;
printf("%d\n", n);
return 0;
}
在这段代码中,指针变量p中存放了n的地址,然后*p = 70;这句代码对p进行了解引用操作找到了变量n所在的空间,并把n的值改变成了70,最后打印n的值(注:*是靠近int还是靠近p都是可以的,不影响,但一般写代码时和类型靠近,代表它是int*类型的变量)
指针变量的大小
首先,指针变量是用来存放地址的,一个地址的存放需要多大空间,那么指针变量的大小就是多大空间,所以指针变量的大小与类型无关。
比如在32位机器上,有32根地址总线,每一根地址线上都会产生一个0或1的数字信号,将这些数字信号组成的二进制序列作为一个地址就需要32个比特位(bit),即4个字节的空间才能存储。
同理,在64位机器上,有64根地址总线,那么一个地址就需要64个比特位(bit),即8个字节。
指针变量类型的意义
指针的解引用
指针的类型决定了对指针进行解引用时候的权限,即一次能访问几个字节比如指针类型是int*,那么解引用操作就能访问4个字节的空间
如果指针类型是char*,那么解引用操作就能访问一个字节的空间
指针加减整数
指针的类型决定了指针加减整数的时候向前或向后走一步有多大距离,例如:
p1是int*类型的指针,+1跳过一个整型,即4个字节;p2是char*类型的指针,+1跳过一个char类型的空间,即一个字节
void*指针
void*类型的指针是无具体类型的指针(也叫泛型指针),所以它可以接收任意类型的地址,但是void*类型的指针不能直接进行解引用操作和指针加减整数的操作
#include <stdio.h>
int main()
{
int n = 10;
int* p1 = &n;
void* p2 = &n;
*p2 = 20;
return 0;
}
编译结果如下:指针变量的运算更其指向的对象无关,而是取决于指针变量的类型的
从这段代码中可以看出:
void*指针的作用
一般void*类型的指针是使用在函数的参数部分,用来接收不同类型数据的地址,这样的设计可以实现泛型编程的效果,使得一个函数可以处理多种类型的数据
以后想使用的时候,强制类型转换成想要的类型就行了
const修饰指针
const关键字
一个变量的值本来是可以被修改的,要想变量的值不被修改,就可以用const关键字
const修饰的变量具有常量的性质,叫做常变量
这个被修饰的变量还是变量,但是不能被修改,例如:
#include <stdio.h>
int main()
{
const int n = 7;
n = 24;//会出错
return 0;
}
变量n被const修饰,具有了常属性,如果被修改就不符合语法规则,编译时会报错
但是要修改变量n的值,还有别的方法,那就是指针
const修饰指针变量
先说结论:
1:const放在*的左边,限制的是指针指向的内容,即不能通过指针变量来改变它所指向的内容,但是指针变量本身是可以修改的
int n = 24;
int m = 15;
int const *p = &n;//const int *p = &n;只要const在*的左边就行
p = &m;
上面这段代码中,const限制的就是*p,即*p不能被修改,但p本身是可以修改的,所以p = &m;这句代码就是对的
2:const放在*的右边,限制的是指针变量本身,即指针变量不能改变它的指向,但是可以通过指针变量修改它所指向的内容
int n = 45;
int * const p = &n;
*p = 56;
上面这段代码中,const限制的就是p,即p变量本身不能被修改,但是*p是可以被修改的,所以*p = 56;这句代码就是对的
指针运算
指针加减整数
前面已经说过了指针变量类型的意义,即指针的类型决定了指针解引用的权限,也决定了指针加减整数的时候向前或向后走一步有多大距离,例如用指针打印数组的元素
这里需要补充一些数组和指针的知识
数组名是数组首元素的地址,比如一个整型数组arr,那么arr就是数组首元素的地址,而&arr[0]也是数组首元素的地址,因为是整型数组,所以该地址的类型是int*
#include <stdio.h>
int main()
{
int arr[] = { 1,2,3,4,5,6,7,8,9,0 };
int len = sizeof(arr) / sizeof(arr[0]);
int* parr = arr;
int i = 0;
for (i = 0; i < len; i++)
{
printf("%d ", *(parr + i));
}
return 0;
}
上面这段代码中,将数组名即数组首元素的地址赋给了指针变量parr,通过对指针变量的解引用操作来访问数组的每一个元素,parr + i就是下标为i的数组元素
运行结果:
指针减指针
指针-指针的绝对值是指针和指针之间的元素个数,但计算的前提条件是两个指针指向的是同一块空间,例如:
#include <stdio.h>
int main()
{
int arr[] = { 1,2,3,4,5,6,7,8,9,10 };
int len = sizeof(arr) / sizeof(arr[0]);
printf("%d\n", &arr[len - 1] - &arr[0]);
return 0;
}
这段代码中,&arr[len - 1]是数组的最后一个元素,&arr[0]是数组的首元素,二者之间的元素个数如下图
运行结果:
指针的关系运算
主要指比大小,还以打印数组元素为例
#include <stdio.h>
int main()
{
int arr[] = { 1,2,3,4,5,6,7,8,9,10 };
int len = sizeof(arr) / sizeof(arr[0]);
int* p = arr;
while (p <= &arr[len - 1])
{
printf("%d ", *p);
p++;
}
return 0;
}
p中存放着数组首元素的地址,从p地址开始向后访问,直到将数组所有元素全部打印完,即到数组的最后一个元素为止
运行结果:
野指针
野指针就是只想未知空间的指针,这中指针是不安全的
野指针的成因
1:指针未初始化
一个局部变量不初始化,它的值是随机的,那指针变量不初始化,而是直接将指针变量的值当做地址,解引用操作就会非法访问
2:指针越界访问
3:指针指向的空间释放(如返回局部变量的地址)
如何避免野指针
1:指针初始化
一般情况下,可以给指针变量赋某个变量的地址,但如果不知道要给指针变量赋什么值,可以给指针赋值为NULL
NULL是C语言中定义的一个标识符常量,它的值是0,因为0也是地址,但是这个地址是无法使用的,读写该地址会报错
2:小心指针越界
3:指针变量不在使用时,及时置为NULL;在指针使用之前,检查指针的有效性(在使用之前判断指针变量是否为NULL)因为约定俗成的规则是只要是NULL指针就不去访问
4:避免返回局部变量的地址
assert断言
assert断言常用来检测指针的有效性
assert.h头文件中定义了宏assert(),用于在运行时确保程序符合指定条件,如果不符合,就报错终止运行,这个宏常常被称作“断言”。
assert( p != NULL );
当程序执行到上面这条语句时,验证变量p是否等于NULL,如果不等于NULL,则程序继续执行,否则会终止运行,并给出报错信息提示,比如下面这段代码
#include <assert.h>
int main()
{
int* p = NULL;
assert(p != NULL);
return 0;
}
运行结果:
在程序终止运行的同时给出了报错信息的提示
assert()宏接受一个表达式作为参数,如果该表达式为真(返回值非零),assert()不会产生任何作用,程序继续执行,而如果该表达式为假(返回值为零),assert()就会报错,在标准错误流stderr中写入一条错误信息,显示没有通过的表达式,以及包含这个表达式的文件名和行号
使用assert()的好处
assert()不仅能自动标识文件和出问题的行号,还有一种无需更改代码就能开启或关闭 assert() 的机制。如果已经确认程序没有问题,不需要再做判断,就在语句前面定义一个宏NDEBUG,即
#define NDEBUG
#include <assert.h>
就像一个开关一样,然后重新编译程序,编译器就会禁用文件中所有的assert()语句
传值调用和传址调用
在函数传参的时候,有时会传变量的值,有时会传变量的地址
传值调用
当实参传递给形参的时候,形参是实参的一份临时拷贝,对形参的修改不会影响实参
传址调用
将实参的地址传递给形参,形参通过地址可以找到地址所对应的变量,传址调用可以让函数和主调函数之间建立真正的联系
指针的使用(strlen函数的模拟实现)
strlen是一个库函数,作用是计算字符串中\0之前字符的个数,函数原型如下
size_t strlen ( const char * str );
参数str接收一个字符串的起始地址,然后开始统计字符串中 \0 之前的字符个数,最终返回长度。如果要模拟实现可以从起始地址开始向后逐个字符的遍历,只要不是 \0 字符,计数器就+1,这样直 到 \0 就停止,代码如下
//模拟实现库函数strlen
#include <stdio.h>
size_t mystrlen(const char* str)
{
size_t count = 0;
while (*str)
{
count++;
str++;
}
return count;
}
int main()
{
char arr[] = "abcdef";
size_t len = mystrlen(arr);
printf("%zd\n", len);
return 0;
}
在实现strlen函数的时候,参数部分str指向了数组的第一个元素(或者第一个字符)并向后开始计算字符串的长度,我们并不希望str指向的内容被修改,所以用const对*srt进行了限制,这样也提升了函数的健壮性(也叫鲁棒性,指的是在异常情况下系统生存的能力)