从一段小代码理解C语言指针

       指针可以说是C语言中最核心的概念之一,也是C语言如此强大的原因所在。在透彻理解指针的基础上,对语法结构、数据类型和计算机基础知识稍加了解,便可以写出强大的C语言程序来。不过指针的重要性也决定了它的难学,不少新手在入门C语言时,往往都卡在指针处痛苦不堪,甚至萌生退意。我最初是从python入门,然后学了java,这些高级语言最大的特点就是高效简洁,但是由于与底层相去甚远,所以常常有种“隔靴搔痒”的感觉,总感觉不够痛快。因此今年我又重新拾起了这门最底层的语言,不过确实,在理解了高级语言的基础上来学习底层语言进度很快,一路上也没有感觉很吃力。今天就先从指针讲起,从一小段代码帮助大家透彻理解C语言的“法门”。


从一段代码说起

/*test.c -- 从这段代码看c语言的指针*/

#include <stdio.h>

int main(void)
{
	int i = 1; // 初始化变量i为0
	int *ptr; // 定义一个指向int型变量的指针变量ptr
	ptr = &i; // 将int型变量i的地址赋值给指针变量ptr
	printf("%d",*ptr); // 用*号解引用,获取指针ptr所存的地址所指向的变量值
	return 0; // 函数返回
}

这段代码对有一定C语言基础对大家来说肯定毫无压力,轻而易举可以得出输出为1.那我们来看看,当我们编译执行的时候,计算机内部到底发生了什么,这里的ptr到底是什么?

程序的预处理

从编译执行的角度来说,一段C代码在成为可执行文件(例如Windows中的.exe文件)之前会经历预处理、编译、链接三个阶段,过程如下图,对这三个阶段的理解格外重要。

以上面这段test.c程序为例,test.c首先会有C语言的预处理器进行预处理,根据《C premier plus》,预处理器主要对预处理命令进行处理,包括#include #define等;然后交由编译器进行编译,编译过程有各种优化,经过编译生成test.o文件,在链接上所需的外部代码生成可执行文件,可执行文件就是整个任务对应的二进制代码,计算机将它调进内存后可以一步一步完成任务

为了便于理解指针,我们将上面的过程统一称为“预处理”,也就有了“源代码 => 预处理 => 可执行文件”的过程。

在我们这里所说的“预处理”环节中,编译器会知道“i”是一个int型变量,占4个字节,ptr是一个指向int类型的指针等……

在生成可执行文件后,exe文件会存储在外部存储器中,一般是硬盘(C盘、D盘等,这些分区都是操作系统对硬盘进行管理的结果,硬盘本身没有这些分区结构),之所以存储在硬盘是因为硬盘有“掉电不丢内容”的特点,而内存一旦断电内容就会丢失,这主要是因为内存和硬盘所采用的硬件物理特性差异。

大家平时肯定没少用word写论文,双击word就意味着将word程序调入内存开始执行,当敲击键盘输入文字时,所以内容都还在内存中,点击“保存”或者“Ctrl+S”就是将尚在内存中的这些文字保存到外部的硬盘中去。这也就是为什么电脑突然断电没保存的话文字就没了,而且这也能解释为什么如果我们随意新建一个文档在保存时系统提示我们需要输入文件名和选择文件类型,这是因为此时要把内存永久保存到硬盘中了,而文件保存到硬盘中操作系统就需要知道文件的名字和类型,这样才能在磁盘中分配空间,对文件进行管理,而在内存中大家都是二进制代码,无所谓文件类型。

C语言运行的内存模型

本质而言,程序其实就是“操作+数据”,就是规定对什么数据执行什么操作。

可执行文件从硬盘加载到内存中就是将操作指令和要操作的数据加载到内存中的的过程,对C语言而言,程序在加载到内存中后会将代码(操作)和数据分开存放,形成代码段和数据段。其中代码段就是指令的文本,数据段分为堆栈区、常量区和静态/全局变量区。

  • 堆栈区:堆栈是程序逐步执行时为变量分配的区域,栈区是局部变量的分配区,栈具有“后进先出”的特点,这样方便函数调用和返回,堆区由程序员通过malloc()自主分配,在C语言中使用malloc()分配的内存空间就位于堆区;
  • 常量区:在编译过程中编译器会识别处代码中所有的常量,如程序中的1,2,3……,“Hello”等,这些常量都会被存放在常量区;
  • 静态变量/全局变量区:静态变量(static)和全局变量存放的内存区域,分为已初始化,和未初始化。

在编译运行的过程中,所有标识符其实都对应着内存地址,标识符仅很大程度上增强了程序的可读性和可维护性,比如:

int i = 1;

   这样一条语句的实质是在内存中(栈区)分配一个4字节的空间存放一个整数1,这一整段内存空间我们在代码中标识为“i”,但是编译器对于“i”,没有任何认知,与我们将这段内存空间标识为“m”,“n”没有任何区别,在这段定义之后对“i”的所有操作都是对这段内存空间或存放于其中的这个数1的操作了。

再来看这一小段代码

综合前面的知识,我们可以将test.c用伪代码来表示

/*假设这台计算机的内存地址是16位*/

程序入口(从此开始执行)=> main;

在栈区分配一个4字节空间;
在这个空间中存入整数1;     
/*
假设地址的起始位置是0x0021,
那么从0x0021~0x0024的这4字节空间中存储着数字1,类似于这样:
0x0021:00000000
0x0022:00000000
0x0023:00000000
0x0024:00000001
*/

在栈区分配一个2字节空间;
/*首先,ptr是一个程序运行过程中的变量,因此存放在栈区,
其次,这段空间应该分配多大呢?
ptr是指向int类型的指针,其中存放着int的地址,
所以ptr所占的空间应该是计算机中的地址长度即16位,也就是2个字节
由于ptr和i先后定义,所以我们可以假设ptr和i所处的那一段内存空间是这样的:
0x0021:00000000
0x0022:00000000
0x0023:00000000
0x0024:00000001
0x0025:00000000
0x0026:00000000  === 未初始化,更有可能是随机值,此处假设系统默认初始化为0
*/

将i的地址赋值给ptr;
/*
0x0021:00000000
0x0022:00000000
0x0023:00000000
0x0024:00000001
0x0025:00000000
0x0026:00100001  === 21的二进制表示
*/

*ptr ==> 取出ptr所指向的值;
/*
*ptr 即取出ptr的值,一看是一个地址0x0021,然后就前往那个地址0x21取值取到了1
*/

我们得出三点结论:

1.int* ptr中的ptr是一个简单的标识符而已,不需要过分紧张;

2.ptr指向一个特定的内存地址,这个地址里面存放的是一个地址;

3.单独的*ptr成为解引用,这里和取i没有任何区别,唯一的问题是:我们在前面已经定义分ptr是指向int 类型的指针,所以系统知道我从ptr取出来的是一个地址,我需要在顺着这个地址才能取到值。

普通变量使用:传入内存地址1(标识符“i”所对应) ==> 得到值

指针变量使用:传入内存地址1 (标识符“ptr”所对应)==> 得到值(值是一个地址2) ==> 传入内存地址2 ==> 得到值

总结、思考和代码验证

总的来说,指针就是在内存中分配一段空间,这段空间中存放的是一个地址。指针也是一个变量,因此我们可以对指针所对应的地址所存的值进行操作,当然也可以对指针本身,也就是这个地址操作,因为它本身就是一个数字而已。

不过有一个问题?就是当从ptr对应的内存空间中取出这个地址后,到了相应的地址,怎么知道取多少位呢?为什么不是取2个字节,3个字节,偏偏会取出4个字节来?

这就是在定义指针时指定指针类型的重要性了!!

只有指定了指针所对应的类型,在运行时才知道ptr是一个指向int类型的指针,而int类型是4字节,所以我们要从那个地址开始取连续4个字节才能得到i的值。

其实大家在学习指针的时候关于“ptr + 1”这样的语句会有疑惑?

ptr是一个变量,所以使用ptr就是取标识符ptr对应的那一段内存空间中的值,也就是i的起始地址,在前面的例子中,i的起始地址是0x0021,那“0x0021 + 1”是多少呢?很多人可能会觉得应该是0x0022,也就是ptr开始指向下一段地址空间。其实不然,指针加减的结果取决于指针所指向的数据类型。指针所指向的数据类型在内存中占多少字节,那么该指针每次增减就对应对多少字节。

也就是说

int *ptr; // ptr + 1 = 原存的地址偏移4个字节
char *ptr // ptr + 1 = 原存的地址偏移1个字节

//……以此类推   

接下来通过代码对前面的论述进行验证⬇️

代码验证

#include <stdio.h>

int main(void)
{

	int i = 1;
	int *ptr;
	ptr = &i;

	printf("%p\n",ptr); // 获取标识符ptr对应的内存空间中存储的值 
	printf("%p\n",ptr + 1); // 将ptr所存的地址+1后所指向的地址

	printf("%p\n", &i); // 获取i对应的内存地址(其实是起始地址)

	printf("%lu \n", sizeof(int)); // int 类型所占的内存长度
	printf("%lu \n", sizeof ptr); // 指向int类型的指针所占据的内存长度

	return 0;
}


/*
0x7ffee30bb908
0x7ffee30bb90c
0x7ffee30bb908
4 
8 
*/

以上的梳理有不少待细化的地方:如预处理器、编译器和链接到底做了些什么?内存地址是如何取到的?数组名作为指针该如何理解?这些之后一步步整理。

发布了15 篇原创文章 · 获赞 2 · 访问量 3703

猜你喜欢

转载自blog.csdn.net/KageYamaa/article/details/103891321