ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

0基础C语言积跬步之深入理解指针(1)

0基础C语言积跬步之深入理解指针(1)

目录

  1. 内存和地址
  2. 指针变量和地址
  3. 指针变量类型的意义
  4. const 修饰指针
  5. 指针运算
  6. 野指针
  7. assert 断言
  8. 指针的使用和传址调用

一、内存和地址

内存和地址的关系:内存是房间,地址是门牌号;靠地址定位内存,靠内存存数据。

内存分为一个个小的内存单元,一个内存单元为一个字节,每一个内存单元都对应着一个唯一的编号,我们把这个编号称之为地址,我们又给地址取了个别名,叫指针。

我们可以理解为:内存单元的编号==地址==指针

那怎么理解编址?

内存中的地址是在硬件生产的时候就已经创建好的,是固定的,不会因为电脑的重启而改变,只有有了这些地址,cpu才能和内存正常交互,找到想读的位置,比如上图中,如果cpu要读取数据,cpu是先通过地址总线在内存找到要操作的内存空间,再通过控制总线cpu发指令给内存是要读数据还是写数据,最后通过数据总线将内存中的数据传给cpu进行处理。如果还要修改内存空间的数据内容,CPU 在运算处理完成后,会再次通过地址总线指定存储位置、控制总线下发写命令,然后由数据总线写回到内存中保存,完成一次完整的内存读写交互。

  • 只读不改:只走「读流程」,不用写回内存。
  • 读完还要修改赋值:读完运算 → 再走一遍「写流程」存回内存。

32位的机器总共有三十二条地址总线,每根地址总线都对应着一个二进制位0或1,32根地址总线就能组成2³²种不同的组合,对应着2³²种不同的地址编码,也就能存2³²=4294967296个地址,也就是4294967296字节,为4GB

  • 1GB=2³ºByte

二、指针变量和地址

2.1取地址操作符(&)

首先在C语言中我们创建变量,就是在向内存空间申请一块内存,比如int a,我们就向内存的栈区中申请了四个字节的内存空间,用来存放变量a

我们可以用取地址操作符(&)来看到变量a的地址,打印地址用占位符%p

#include <stdio.h> int main() { int a = 0; printf("%p\n", &a); //打印a的地址 return 0; }

运行截图:

我们调试在内存地址中可以看到变量a占了4个字节,但打印&a时只打印了最小的地址,所以我们可以知道:&a取出的是a所占四个字节中地址较小的那个地址

2.2指针变量和解引用操作符(*)

2.2.1指针变量

指针变量就是用来存放地址的,存放到指针变量里的值都会被理解为地址

int a = 10; int* p = &a;

int *p就是一个指针,我们拆解来看:*是在说明p是一个指针变量,前面的int是在说明p中所存的地址所指向的内存空间中的数据是一个int类型的数据

比如要存一个char类型变量的地址:我们就要用char*类型的指针变量

char ch = 'a'; char* pc = &ch;

2.2.2解引用操作符(*)

解引用操作符的作用是找到指针变量所存地址指向的内存空间中所存储的内容,也就可以通过对此地址的解引用,去访问和修改里面的内容

#include <stdio.h> int main() { int a = 10; printf("a=%d\n", a); int* p = &a; //将a的地址传给指针变量p printf("*p=%d\n", *p); //验证解引用p可以找到a的值 *p = 5; //通过解引用p找到a的值并修改 printf("a=%d\n", a); //验证是否修改成功 return 0; }

运行截图:

可以看到*p成功找到a原来的值10并打印,在运行完*p=5代码后,也确实成功通过*p找到了a的值并修改成功,将a的值改成了5

2.3指针变量的大小

指针变量是用来存放地址的,而32位的电脑下地址是由32位地址线构成的,也就是每个地址需要32个二进制数==32个bit位==4个字节的大小,所以32位的电脑下,指针变量的大小就需要4个字节,才能存放的下地址

同理,在64位的电脑下,需要64根地址线,也就是64个bit位,8个字节,所以指针变量大小需要8个字节,才能存放的下地址

而且无论哪个类型的指针,大小都是由电脑的位数来决定的,和所指向什么类型的数据无关,我们来验证一下:

#include <stdio.h> int main() { printf("%zu\n", sizeof(char*)); printf("%zu\n", sizeof(int*)); printf("%zu\n", sizeof(float*)); printf("%zu\n", sizeof(double*)); return 0; }

在(x86)32电脑的平台下,运行截图:

在(x64)64位电脑的平台下,运行截图:

结论:

  • 32位平台下地址是32个bit位,指针变量大小是4个字节
  • 64位平台下地址是64个bit位,指针变量大小是8个字节

三、指针变量类型的意义

既然指针变量的大小都是一样的,那为什么要需要不同类型的指针,都用一个类型的指针来存放所有地址不就可以了吗?下面我们来一步步解答这个问题

3.1指针的解引用

我们来看两串代码:

(1)代码1:用int*来接收int类型的地址

#include <stdio.h> int main() { int n = 0x11223344; int* pi = &n; *pi = 0; return 0; }

我们调试&n的地址来看:

可以看到,原本n中存的是十六进制的11223344,当我们用int*类型的指针变量pi,用*pi等于0后,四个字节全部变成了0,这是因为int类型占四个字节,所以在*pi找到n的值后会往后找四个字节进行修改,将其四个字节都变成0

(2)代码2:用char*来接收int类型的地址

#include <stdio.h> int main() { int n = 0x11223344; char* pc = (char*)&n; //不强制转换会报警告:从“int *”到“char *”的类型不兼容 *pc = 0; return 0; }

我们同样调试&n的地址来看:

可以看到,原本n中存的是十六进制的11223344,当我们用char*类型的指针变量pc,用*pc等于0后,只有一个字节变成了0,这是因为char类型占1个字节,所以在*pc找到n的值后会往后找一个字节进行修改,将地址最低的一个字节变成0,后续修改不到

结论:指针的类型决定了,对指针解引用的时候有多大的权限(⼀次能操作几个字节)

char*的指针解引用一次就只能访问一个字节,而int*的指针的解引用一次就能访问四个字节

3.2指针加减整数

我们来观察一段代码:

#include <stdio.h> int main() { int n = 10; char* pc = (char*)&n; int* pi = &n; printf("&n=%p\n", &n); printf("&pc=%p\n", pc); printf("&pc+1=%p\n", pc + 1); printf("&pi=%p\n", pi); printf("&pi+1=%p\n", pi + 1); return 0; }

运行截图:

可以看到:char*类型的指针加1时只跳过了一个字节,int*类型的指针加1时跳过了四个字节,所以指针的类型可以决定指针向前走或向后走一步一次能跳过多少个字节的地址

结论:指针的类型决定了指针向前或者向后走一步有多大(距离)

char*加1跳过一个字节,short*加1跳过两个字节,int*加1跳过4个字节

3.3void*指针

void*类型的指针为无具体类型的指针(也可理解为泛型指针),它可以存进去任何类型的指针,但有局限性,void*类型的指针无法直接进行整数加减和解引用的运算

#include <stdio.h> int main() { int a = 10; void* pa = &a; *pa = 10; printf("%p\n", pa + 1); return 0; }

运行截图:

可以看到*pa无法被解引用,且无法进行赋值、整数加减的运算,显示未知的大小

void*一般用于函数的参数部分,实现泛型编程,使得函数可以处理多种指针类型的数据

四、const 修饰指针

4.1const修饰变量

const用于修饰常变量,会把变量赋予常属性,使其像常量一样无法修改(注意一个点:是static修饰的局部变量才会将变量从栈区放入静态区,使其获得和静态区的变量相同特性,无法修改;但const修饰的局部变量,还是在栈区,只是给变量加了一个只读权限标记,使得其无法直接修改)

比如:用const修饰的变量是无法直接修改的

#include <stdio.h> int main() { const int n = 10; //const修饰的变量无法直接修改 n = 5; //修改失败,会报错 return 0; }

运行截图:

运行错误,无法修改

虽然const修饰的变量在语法上无法直接修改,直接修改会报错,但我们可以通过指针间接修改const修饰的变量

比如:绕过n,通过n的地址,去修改n

#include <stdio.h> int main() { const int n = 10; //n被const修饰,无法直接修改 int* p = &n; //我们把n的地址传给指针变量p *p = 5; //通过解引用p间接找到n的值并修改 printf("%d\n", n); //验证是否修改成功 return 0; }

运行截图:

可以看到虽然成功修改,但是利用指针间接修改变量是不合理的,这样const就失去了意义,所以应该让const修饰的变量也无法通过指针间接修改,我们就有了const修饰指针变量

4.2const修饰指针变量

我们来看几串代码:

(1)代码1:无const修饰

#include <stdio.h> int main() { int n = 10; int m = 5; int* pn = &n; *pn = 20; printf("n==%d\n", n); pn = &m; printf("*pn==%d\n", *pn); return 0; }

运行截图:

(2)代码2:有const修饰,const放在*左边

#include <stdio.h> int main() { int n = 10; int m = 5; int const* pn = &n; //*pn被const修饰,无法修改 *pn = 20; printf("n==%d\n", n); return 0; }

运行截图:

可以看到,const放在*左边时,pn所存地址指向的内容无法修改,会报错

那pn指针变量所存放的地址内容可以修改吗?我们来看:

#include <stdio.h> int main() { int n = 10; int m = 5; int const* pn = &n; //*pn被const修饰,无法修改 pn = &m; //pn指针变量所存的地址是可以修改的,指向其他地址 printf("*pn==%d\n", *pn); return 0; }

运行截图:

可以看到pn所指向的地址成功修改,并且*pn打印出了m的值

如果const放在了最前方,数据类型前面,那么效果和放在*前面的效果是一样的

像这样:

const int* pn = &n;

等同于int const* pn =&n;

结论:const在*左边时,修饰的是指针变量原本所存地址指向的内容,指向的内容无法被修改,但指针变量本身指向的地址可以修改

(3)代码3:有const修饰,const放在*右边

#include <stdio.h> int main() { int n = 10; int m = 5; int* const pn = &n; //指针变量pn被const修饰,pn所指向的地址无法修改 pn = &m; return 0; }

运行截图:

可以看到,当const放在*右边时,将pn本身给限制住了,pn无法再修改为指向其他地方的地址,会报错

那pn指针变量所存放的地址指向的内容可以修改吗?我们来看:

#include <stdio.h> int main() { int n = 10; int m = 5; int* const pn = &n; //pn被const修饰,pn所指向的内容无法修改 *pn = 20;//*pn指针变量所存的地址所指向的内容是可以修改的 printf("*pn==%d\n", *pn); return 0; }

运行截图:

可以看到*pn=20,成功修改了n的值,并打印出来

结论:const在*右边时,修饰的是指针变量所存的地址,地址无法被修改,但指针变量原本存的地址所指向的内容是可以修改的

(4)代码4:有const修饰,const放在*两边

#include <stdio.h> int main() { int n = 10; int m = 5; int const* const pn = &n; *pn = 20; pn = &m; return 0; }

运行截图:

可以看到,当指针变量pn和*pn都被const修饰时,指针变量pn所存地址指向的内容既不能被修改,也不能重新指向其他的地址

结论:当const放在*两边时,既不能修改指针指向的地址,也不能修改所存地址指向的内容

五、指针运算

指针有三种运算:

  • 指针+-整数
  • 指针-指针
  • 指针的关系运算

5.1指针+-整数

指针+-整数还是指针,只是指向的位置变了

比如打印一个一维数组,一维数组在内存中的存储是连续的,所以我们只需要知道首元素的地址,再让这个地址不断自增,就能顺藤摸瓜找到所有其他元素

#include <stdio.h> int main() { int arr[10] = { 1,2,3,4,5,6,7,8,9,10 }; int sz = sizeof(arr) / sizeof(arr[0]); int* p = arr; for (int i = 0; i < sz; i++) { printf("%d ", *p); p++; } return 0; }

我们在其中就运用到了指针+整数的知识点,因为p是int*类型的指针变量,所以每次+1,p都会跳过一个int类型的字节大小,从而找到数组的下一位元素对应的地址

5.2指针-指针

指针-指针不再是指针,得到的时两个指针之间相差的元素个数

比如我们想知道一个字符串中有多少个元素:我们创建了一个计算字符串长度函数my_strlen()

#include <stdio.h> int my_strlen(char* s) { char* p = s; //把字符串的初始地址存入指针变量p中 while (*p != '\0') //若*p不是'\0',p就++,直到p指向'\0',不再++ p++; return p - s; //此时p指向'\0',s指向第一个元素(初始地址),p-s就是'\0'到第一个元素之间一共有的元素个数 } int main() { printf("%d\n", my_strlen("abc")); return 0; }

运行截图:

可以看到,正常计算出了字符串"abc"的长度,我们首先把字符串的初始地址存入到了指针变量p中,然后建立了一个while循环,若*p不是'\0',p就++,直到p指向'\0',不再++,此时p指向'\0',s指向第一个元素(初始地址),我们再returnp-s,p-s就是'\0'到指向首元素的地址之间相差元素个数

5.3指针的关系运算

指针和指针之间大小的比较,称之为指针的关系运算

#include <stdio.h> int main() { int arr[10] = { 1,2,3,4,5,6,7,8,9,10 }; int* p = &arr[0]; int sz = sizeof(arr) / sizeof(arr[0]); while (p < arr + sz) //指针的⼤⼩⽐较 { printf("%d ", *p); p++; } return 0; }

运行截图:

arr+sz刚好指向第10个元素再加上一个int大小的地址,p是arr的首元素地址,只要p<arr+sz,就让p不断自增,最后就能找到所有元素的地址,这个打印数组的方法就运用到了指针的关系运算

六、野指针

野指针:就是指向未知地址的指针(随机的、不正确的、未明确限制的)

6.1野指针成因

(1)指针未初始化

#include <stdio.h> int main() { int* p;//局部变量指针未初始化,默认为随机值 *p = 20; return 0; }

指针变量未初始化,放的是随机值,无法对其地址进行解引用,这块空间属于未知的,不合规的,无法使用。此时指针变量p就是野指针。

运行截图:

(2)指针越界访问

#include <stdio.h> int main() { int arr[10] = { 0 }; int* p = arr; int i = 0; for (i = 0; i <= 11; i++) { //当指针指向的范围超出数组arr的范围时,p就是野指针 *(p+i) = i; } return 0; }

运行截图:

程序因为使用了野指针而崩溃

只创建了大小为10的一维数组,却访问了arr[10]和arr[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的地址并传给指针变量p,接着对这串地址进行了解引用操作,看似是正确的,但是我们要知道,n是test() 函数里面的局部变量,当调用完成此函数出函数的时候,这个局部变量会自动销毁(自动销毁是指栈区回收n对这块内存空间的使用权,但值依然存在于这个地址中,等待着被系统覆盖),这个局部变量原先的地址不再属于它,这块地址空间变成没有指定限制的,无管理的地址,是为野指针

运行截图:

可以看到,虽然我们使用的p是野指针,也成功访问到了调用test() 函数时n的地址,也成功打印了n的值,但这并不代表着n没被销毁,也不代表着*p这样访问就是合法的,只是因为在栈区中,这块地址的值还没被其他值覆盖,100仍然放在这块地址里面,所以我们访问这块地址打印出来的值就还是100,但局部变量n已经被销毁了,这块空间已经是危险空间了,是未被指定,未被限制的危险空间

6.2如果规避野指针

(1)指针初始化

我们可以在指针在创建的时候就明确给它一个初始化,如果不知道指针该指向什么地址,那就给指针赋个空地址(NULL),不能不初始化,在C语言中,NULL是由宏定义的标识符常量,值为0,被强制转化为void*类型,所以0也是地址,但是这个不能被使用,会报错

C语言定义:

#ifdef __cplusplus // 如果是 C++ 环境 #define NULL 0 // 把 NULL 定义成 0 #else // 如果是 C 环境 #define NULL ((void *)0) // 把 NULL 定义成 空指针 #endif

所以我们在创建指针变量时就给它初始化,或者赋值为空指针(NULL):

#include <stdio.h> int main() { int num = 10; int* p1 = &num; //明确指向地址 int* p2 = NULL; //赋值为空指针 return 0; }

(2)指针越界

在使用指针的时候,我们要小心指针越界访问,比如访问数组时,小心越界访问数组里没有的元素。一个指针指向的内容向内存申请了多少空间,我们的指针就只能访问多少的空间,不能超过访问范围,超过了就是指针越界。

(3)变量不再使用时,及时置为NULL,指针使用之前检查有效性

如果指针变量不再使用,我们就把它置为NULL,给它赋上一个空指针,给它拴住,不被误用;而且在使用每个指针之前,我们都检查一下这个指针是否为空指针NULL,如果是,则不能使用

#include <stdio.h> 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++) { printf("%d ", *p); p++; } printf("\n"); //此时p已经越界了,可以把p置为NULL p = NULL; //下次使⽤的时候,判断p不为NULL的时候再使⽤ p = &arr[0];//重新让p获得地址 if (p != NULL) //判断 ,如果p不为空指针,我们再对其进行使用 { printf("%d\n", *p); } return 0; }

运行截图:

指针越界访问打印出来了一串随机值-858993460,此时指针已经越界,就算没越界,因为我们无需再使用此指针,所以我们将此指针置为空指针,需要使用时再次赋地址给它,并判断其是不是空指针进行使用

(4)避免返回局部变量的地址

避免像野指针成因(3)中讲的,指针指向的空间被释放的情况,因为函数中局部变量出了函数范围之后就会被销毁,我们要小心使用

七、assert断言

assert(表达式)和if语句很像,都是在判断括号内的条件是否成立,如果成立,就继续执行下方的程序,如果不成立,则程序终止。

但assert()断言还有一个好处是,在判断条件是否成立时,如果条件成立,assert不会产生任何作用。但如果错误,会直接终止程序,显示没有通过的表达式,以及包含这个表达式的文件名和行号,以让程序员即使修改小bug,防止酿造成大bug

使用assert断言,需要包括头文件#include<assert.h>

#include <stdio.h> #include <assert.h> int main() { int* p = NULL; assert(p != NULL); //assert断言 *p = 5; printf("%d\n", *p); return 0; }

运行截图:

程序直接崩溃,显示了没有通过的表达式、包含这个表达式的文件名、行号

如果确认程序没有问题,不需要再做断言,我们可以定义一个宏#define NDEBUG在头文件#include <assert.h>前面,NDEBUG==no debug(关闭调试),它是一个宏定义,专门用来关闭所有 assert 断言。

我们一般在debug调试版本中使用assert断言,用来校验程序逻辑、排查非法情况。在release发布版本中,我们就在引用头文件前使用宏定义#define NDEBUG,来禁用所有assert断言。这样做的好处是,在release版本中断言被优化删掉,不做多余判断、不损耗运行效率,不影响普通用户使用体验。

#include <stdio.h> #define NDEBUG #include <assert.h> int main() { int* p = NULL; assert(p != NULL); //assert断言失效 *p = 5; printf("%d\n", *p); return 0; }

运行截图:

assert断言被禁用,程序没有终止和报错,但因为p是空指针无法进行解引用,系统明确表明NULL这个空地址是禁止使用的,所以并没有打印出来任何东西

八、指针的使用和传址调用

8.1strlen的模拟实现

strlen()函数是用来求字符串长度的,用于统计'\0'之前的字符个数,strlen()函数原型:

size_t strlen ( const char * str );

模拟实现strlen就需要接收一个起始地址,从起始地址开始往后遍历,只要没找到'\0',就让计数器就++,最后返回计数器的值,达到模拟实现统计字符个数的效果,返回值类型可以用int也可以用size_t

#include <stdio.h> #include <assert.h> size_t my_strlen(const char* p) { size_t count = 0; assert(p != NULL); while (*p != '\0') { count++; p++; } return count; } int main() { size_t ret = my_strlen("abcdef"); printf("%zu\n", ret); return 0; }

运行截图:

8.2传值调用和传址调用

(1)传值调用

void swap(int x, int y) { int temp = 0; temp = x; x = y; y = temp; } int main() { int a = 5, b = 10; printf("交换前:a==%d b==%d\n", a, b); swap(a, b); printf("交换后:a==%d b==%d\n", a, b); return 0; }

运行截图:

像这样把变量的值直接传给函数,就叫做传值调用,但这样做并不能修改a和b的值,因为形参只是对形参的一份临时拷贝,swap函数中创建的int x,int y会重新开辟地址,在出函数后这两份地址将会被销毁,和a,b的地址都不一样,所以修改x,y的值并不是修改a,b的值,并不会对a和b造成什么影响,想要在函数中修改a,b的值,这时我们就需要用到传址调用

(2)传址调用

#include <stdio.h> void swap(int* x, int* y) { int temp = 0; temp = *x; *x = *y; *y = temp; } int main() { int a = 5, b = 10; printf("交换前:a==%d b==%d\n", a, b); swap(&a, &b); printf("交换后:a==%d b==%d\n", a, b); return 0; }

运行截图:

当我们把a和b的地址传给函数时,我们在函数里对指针进行解引用的操作时,其实就是在对我们a,b本身进行操作,因为我们解引用的地址就是我们a,b本身的地址,此时再交换a和b,就能达到交换的效果

所以当我们想函数中的操作和我们主函数建立联系时,如果我们只是想在函数中操作得到一个结果,那我们可以选择传值调用;如果我们是想在函数中修改我们主调函数的值,那我们需要使用传址调用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进