C++基础教程
——
C语言部分卷1C语言基础语法
节4C程序执行过程

最新版本V2.0
王道C++团队
COPYRIGHT ⓒ 2021-2024. 王道版权所有

概述

Gn!

本小节主要讲解一个C程序从源代码到最终执行的过程,这个过程又可以细分为两部分:

  1. 源代码到可执行文件的过程

  2. 可执行文件在内存中执行

本小节是C语言基础当中,比较容易被初学者忽视的知识点。而实际上:

  1. 熟悉C程序从源文件,经过一系列过程成为一个可执行文件的过程。

  2. 理解C程序在内存中执行的虚拟内存空间。

是C程序员非常重要的基本功,本小节是C语言基础中的重点。

我们首先研究一下C语言程序,从源码到可执行文件的过程。这个过程总的来说,可以分为两大步骤:

  1. 编译

  2. 链接

广义编译的过程

Gn!

从广义上来说,一个C语言源文件编译的过程可以分为以下三个小步骤:

C源文件编译的过程

  1. 预处理,把一个.c文件处理成.i文件。

  2. 编译,把.i文件进一步处理成.s文件。狭义上来说,编译就是指编译器处理.i文件生成.s文件的过程。

  3. 汇编,把.s文件最终处理得到.o文件。

注意:

广义上的编译过程,最终得到的是一个".o"文件,它并不是一个可执行文件。

汇编过程结束后,实际还需要一个链接过程,才能得到一个可执行文件。

下面逐一讲解这三个小步骤。

预处理过程

Gn!

预处理是C语言源文件编译处理的第一步,由预处理器来完成。那么预处理器到底干啥呢?

答:执行预处理指令。

在前面的小节中,我们已经简单讲解过了hello world程序,也知道了一条预处理指令:

实际上,在C语言代码中,以"#"开头的指令就是预处理指令。预处理指令有多种形式,在今天我们先学习两种最常见的形式:

  1. "#include":用于包含头文件。

  2. "#define":用于定义宏或者常量。

下列讲解一下这两种预处理指令,分别完成什么操作。

注释的处理

Gn!

我们都知道注释是不参与编译更不会影响代码的执行的,而实际上,注释的处理是在预处理阶段完成的:

在预处理阶段,代码中的注释会被预处理器忽略丢掉。

那么从预处理器过程开始,后续的所有过程注释都不会参与了。

查看预处理.i文件

Gn!

在VS当中,默认情况下,点击按钮启动程序,并不会保留预处理后的.i文件。为了能够看到这个.i文件,我们需要让VS的编译过程停留在预处理阶段。可以按照以下步骤设置:

右键点击项目 --> 属性 --> C/C++ --> 预处理器 --> 预处理到文件 --> 设置从否改成是。效果图如下:

修改VS设置-保留预处理文件

完成以上设置后,再重新点击按钮启动程序,打开项目的本地文件,在Debug文件夹下就可以找到该.i文件。(当然项目此时是无法运行成功的。)

注:如果项目仍成功运行,可以打开项目的本地文件删除Debug这个文件夹,或者点击"VS主界面 --> 生成 --> 重新生成解决方案"即可。

#include包含头文件

Gn!

include,具有包含的意思。预处理指令"#include",用于包含头文件。

头文件,即.h为后缀的文件,头文件一般用于声明函数、结构体类型、变量等。(关于头文件的详细使用,我们后面再讲)

简单来说,你可以认为该预处理指令的作用就是:

找到该头文件,把其中的内容处理后复制到指令所在的位置。

比如一个基础的hello world程序:

简单的HelloWorld程序

预处理过程就是执行指令:

#include <stdio.h>

预处理器会先找到stdio.h头文件,然后将其内容处理后复制#include <stdio.h>的位置。大体上,可以把"#include"当成一次文本复制的过程。这样,编译器在编译代码时就可以访问stdio.h中定义的所有函数和变量。

比如前面讲的printf函数,就是由于预处理机制,才能够被我们自己的程序去调用。

头文件包含具体做了什么呢?

包含头文件的预处理过程并不仅仅是一个简单的文本替换或复制粘贴操作。

预处理器会执行一系列任务,其中包括条件编译、宏替换等,这会影响头文件内容如何整合到源文件中。

所以,预处理后的 .i 文件可能不会简单地是原 .c 文件和 .h 文件内容的组合,而是这些文件在经过预处理器处理后的结果。

但大体上,预处理后的结果文件会包含源文件和头文件的大多文本内容。

#define定义符号常量

Gn!

预处理指令"#define"的第一个常见作用,是给字面常量起个名字,使得程序员可以更方便得在程序中使用一个字面常量。

这种使用#define指令定义的常量,被称之为符号常量(Symbolic Constants)

举例:

#define定义符号常量-示例代码

最终程序的输出结果是:

圆的半径是2,周长是:12.560000

N * 2 + 2 = 14

通过查看.i的预处理后的文件:该指令实际上就是把代码中的符号常量替换成实际的字面常量,比如上述两行printf代码预处理后就会变成:

printf("圆的半径是2,周长是:%f\n", 3.14f * 2 * 2);

printf("N * 2 + 2 = %d\n", 6 * 2 + 2);

符号常量的优点

Gn!

学会了使用符号常量,知道符号常量实际上就是字面值起个名字,我们不禁会有一个问题:

为什么非要给字面常量起个名字?直接用不好吗?

符号常量的使用,主要有以下几个好处:

  1. 提高代码的可读性。使用名称而非直接用字面常量可以使代码更易于理解。

  2. 易于维护。如果某个常量需要更改,你只需要在 #define 指令中更改它,而不需要遍历整个代码库去替换。

  3. 避免魔法数字(magic number)。编程领域把代码中直接出现的、意义不明的一个字面常量称为"魔法数字、魔数",避免魔数在代码中直接出现是一个好的编程习惯。建议使用有意义的符号常量名字,来提高代码的可读性和可维护性。

所以,对于C语言代码编写中需要使用的字面常量,尤其是频繁使用的、带有确定意义的字面常量,请将它设置为符号常量。

定义符号常量的注意事项

Gn!

使用#define定义符号常量,应注意:

  1. 符号常量的本质是文本替换,它的定义和使用,没有任何数据类型、取值范围等的限制。这既是优点带来了灵活性,但也会带来一定的安全隐患。

  2. 采取这种方式定义符号常量,在命名时采取"全大写英文单词 + 下划线分割"的风格!

  3. 定义时,添加必要的符号后缀,比如float类型的字面常量可以明确加"f"后缀。这有利于增加代码可读性,以及为编译器提供更多信息。

#define定义函数宏

Gn!

什么是宏?

在C语言中,是一种由预处理器处理的代码生成机制。简单地说,宏可以看作是一种用于在编译前、预处理阶段自动替换代码片段的方式。在C语言中,#define 指令通常用于创建宏。

上面讲的通过#define定义符号常量,也是定义了一个宏,你可以叫常量宏或者宏常量。

当然,这里要讲C语言中的函数宏。

函数宏的定义会稍微复杂一点,参考以下格式:

#define定义函数宏-示例代码

很显然,运行结果是:

长和宽分别是10和5的长方形,其周长是:30

3和2的平方差是:5

函数宏的本质仍然是文本替换,但会复杂一点,相当于把调用宏函数时的参数传入公式直接计算,比如上述两行printf代码预处理后就会变成:

printf("长和宽分别是10和5的长方形,其周长是:%d\n", (((10) + (5)) * 2));

printf("3和2的平方差是:%d\n", ((3) * (3) - (2) * (2)));

函数宏的优点

Gn!

使用函数宏在C语言中能带来一些好处:

  1. 代码复用,简化代码,提高可读性啥。

  2. 提升性能。函数宏虽然叫函数,但本质是文本替换,而且是预处理阶段就完成替换,这完全不会消耗运行时性能。

  3. 灵活。既然本质是文本替换,那么带使用函数宏时给不同类型的参数都是可以的。

函数宏定义时"()"的运用

Gn!

我们先给出三条结论:

  1. 定义函数宏的表达式内部在有必要时要用小括号括起来,以确保函数宏内部的运算的顺序是正确的。

  2. 函数宏表达式中的每一个参数,都必须用小括号括起来!!!

  3. 函数宏的整个表达式部分也必须用小括号括起来!!!

下面逐一举例说明为什么要这么做。

比如宏函数定义:

((length + width) * 2)当中的(length + width)小括号就是必须要添加的,不添加宏内部的运算顺序就不正确了。

第二条要求宏函数表达式的每一个参数都要用小括号括起来,这是为什么呢?

假如宏函数如下:

参考下列代码:

结果是表达式"32 - 22"的值吗?

显然不是,这是因为预处理替换的结果是:

计算出来的结果是: (5) - (3) = 2

第三条要求宏函数的整个表达式要用小括号括起来,原因也类似。比如下列宏函数定义:

假如按照下列方式调用宏:

结果是表达式"(32 - 22) * 10"吗?

显然也不是,这是因为预处理替换的结果是:

计算出来的结果是:9 - 4 * 10 = 9 - 40 = -31

总之,由于函数宏调用的本质是文本替换,本身不涉及任何计算规则优先级,所以以上三点加括号的原则是一定要注意遵守的!!

注意事项

Gn!

使用函数宏时,主要注意以下几点:

  1. 函数宏调用的本质是在预处理阶段的文本替换,要切记函数宏和函数完全不是一回事。(这一点在后续讲函数后,可以加深理解)

  2. 函数宏的命名采取"全大写英文单词 + 下划线分割"。比如"SQUARE_AREA",该宏的名字表示求正方形的面积。这样命名的宏

  3. 函数宏适用于替换程序中一些简短的、但反复执行的函数。不要定义很复杂的函数宏。

#define设置编译选项和scanf函数

Gn!

在C语言中,#define 预处理指令通常用于定义宏,但也可以用来设置编译选项,从而影响编译过程。简而言之,在预处理阶段,#define 可以被用来调整编译器的行为,进而改变最终生成的代码。

在这里我们要讲解一个案例,并讲解一下scanf函数的使用。

实现以下功能:

键盘输入一个华氏温度(Fahrenheit temperature),程序输出对应的摄氏度(Celsius temperature)。

注:3

华氏温度转换成摄氏度的公式

F代表华氏度,C代表摄氏度。

在这个公式中:

  1. 32代表华氏温度的冰点(Freezing point)

  2. 5 / 9 是华氏温度和摄氏温度之间转换的比例因子(scale factor)

举例:

键盘录入一个华氏温度212,程序会输出一个摄氏度100。

在这个程序中,涉及到了键盘输入。所以我们要使用scanf函数。它是标准输入/输出库中,另一个重要的函数,代表输入。

scanf也并不是一个单词,而是词组"scan formatted"的缩写,意为“格式化扫描”。表示按照指定的格式,读取输入数据。默认情况下,是从键盘接收数据输入。

下列一行代码表示读入一个int类型的数据,并赋值给变量i(变量i需要先声明):

下列一行代码表示读入一个float类型的数据,并赋值给变量x(变量x需要先声明):

注意:变量名的前面需要加一个取地址运算符"&",表示将读取的数据存入目标地址中,不要忘记写"&"符号。

这个程序代码并不难写,我们可以写出以下代码:

华氏温度转换为摄氏度-代码1

这段代码有语法错误吗?可以正常执行吗?

这段代码实际是没有语法错误的,但在VS当中却不可以直接运行,这是因为"坑爹的"微软编译器MSVC,不允许此代码执行。原因是:

'scanf': This function or variable may be unsafe. Consider using scanf_s instead. To disable deprecation, use _CRT_SECURE_NO_WARNINGS.

scanf函数是不安全的,所以MSVC编译器不允许代码中直接使用scanf函数,而是:

  1. 选用更安全的"scanf_s"函数

  2. 使用"_CRT_SECURE_NO_WARNINGS"来禁用安全警告。

注意:

  1. "_CRT_SECURE_NO_WARNINGS"这个字符串中,"CRE"是"C Runtime"的缩写,整个字符串表示C语言运行时不安全函数的警告。

  2. 对“不安全”函数的警告是MSVC编译器特有的;其他编译器通常不会有这样的限制。

  3. scanf_s是微软特有的函数,并不是C标准库的一部分,不兼容标准C语言,在其它平台、编译器环境下不可以使用。

  4. 在后续的课程,更多在Linux上运行C程序,不会选择MSVC编译器。

因此,为了代码的可移植性和跨平台兼容性,我们选择禁用这个特定的编译器警告。

那么如何禁用这个安全警告呢?答:使用"#define"宏定义语法。具体而言就是让代码变成:

华氏温度转换为摄氏度-代码2

此时代码正常通过编译启动,但程序的输出结果对吗?实际上,这个程序,不管你键盘录入什么数据,结果都是:

0

为什么呢?

很简单,因为计算摄氏度的表达式中存在:"5 / 9"。这实际上是两个整数字面常量直接相除,结果还是一个整数,5 / 9 结果是0,那么后面无论输入什么,结果肯定都是0。

那怎么改呢?

很简单,不要使用两个整数常量相除,而使用浮点数,参考代码如下:

华氏温度转换为摄氏度-代码3

如此,我们就得到了一个合格的"华氏度转换成摄氏度"的C程序。

但合格不意味着优秀,这段代码还是可以继续优化的。代码中直接出现的"5.0f / 9.0f"、"32"这样的字面量,也就是魔数,是不规范的。

所以我们可以通过宏定义符号常量的方式,指明它们的含义,去掉魔数。参考下列代码:

华氏温度转换为摄氏度-代码4

上面的例子给的是用符号常量的方式,当然也可以用函数宏的方式,参考下列代码:

华氏温度转换为摄氏度-代码5

注:函数宏也是代码的一部分,在定义函数宏时也要尽量避免魔数。

对于程序员而言,写出代码实现功能是最基本最起码的要求。但作为一名优秀的程序员,写出可读性更好、性能更强、更优良的代码也是毕生的追求。

预处理过程总结

Gn!

预处理过程主要就两个作用:

  1. 执行预处理指令,展开宏(进行处理文本替换)。

  2. 丢弃代码中的注释。

通俗的说,你可以认为预处理后得到的.i文件是一个无预处理指令,无注释以及无宏定义的源代码文件。

预处理过程得到的仍然是一个源代码文件,这个文件的内容人肉眼是完全可以看懂的。

最后再谈几个细节问题:

  1. 预处理指令包含字符"#",还存在一些单词如"define"、"include"等,需要注意的是:

    1. "#"不是C语言标识符中允许出现的字符,所以不要把预处理指令理解成标识符。

    2. "define"、"include"等也不属于关键字,不要理解成关键字。

  2. 预处理指令也不要理解成语句,不要在预处理指令的末尾加;,也不要使用=等字符。诸如下列预处理指令的写法都是不正确的:

  3. 以上。

编译过程(狭义)

Gn!

在预处理完成后,编译器(Compiler)就开始处理预处理生成的.i文件了,这个过程就被称为为(狭义上的)编译

编译器在这个过程,主要完成以下工作:

  1. 进行词法分析、语法分析、类型检查等操作。

  2. 编译器还在此阶段对代码进行各种优化,以提高效率,减少最终生成代码的大小。

  3. 最终,将预处理后的源代码转换成汇编语言。也就是将.i文件转换成.s文件。

一个汇编代码的演示:

HelloWorld的C语言代码

其可能生成的汇编代码如下:

HelloWorld的C语言代码-对应汇编代码

值得注意的是,C语言最初是为了替代更低级的B语言和汇编语言而设计的。因此,C语言源代码被编译成汇编代码是完全符合其设计初衷的。

生成汇编代码的过程中,编译器还会自动对代码进行优化,但对于一般的C程序员而言,编译器或汇编语言都不是必须掌握的知识点。

汇编过程

Gn!

编译器完成编译后,汇编器(Assembler)会将这些汇编指令转换为目标代码(机器代码),生成了一个.o(或者.obj)文件。从这一步生成的文件开始,文件中的内容就不是程序员能够肉眼看懂的文本代码了,而是二进制代码指令。

所以汇编过程的主要作用就是将汇编语言代码转换成机器语言,即转换成机器可以执行的指令,也就是生成.o文件。

.o文件还不是一个能直接执行的文件,因为它可能依赖于其他外部代码,比如在代码中调用printf函数。

预处理阶段不是已经包含头文件,为什么还说要依赖外部代码?

头文件中往往只有函数的声明,包含头文件大概只意味着预处理器告诉编译器:

“这里有一个函数叫做printf,它大概是这个鬼样子,后续的代码中会用到这个函数。你先知道有这么个玩意,别给我报错,至于这个函数到底是干啥的,做什么的,你先别管。”

所以汇编后的代码,还需要经历一个链接的步骤,来依赖外部代码才能够真正的运行。

链接的过程

Gn!

在链接阶段,链接器(Linker)会把项目中,经过汇编过程生成的多个(至少有1个).o文件和程序所需要的其它附加代码整合在一起,生成最终的可执行程序。

比如你在代码中调用了标准库函数,那么链接器会将库中的代码包含到最终的可执行文件中。

总结编译和链接

Gn!

经过预处理、编译、汇编和链接这四个主要步骤,源代码最终会被转换成可执行文件。

虽然具体的转换过程可能因平台、编译器等因素有所不同,但这四个核心步骤是通用的。对于大多数C程序员来说,掌握这些基本概念有助于更深入地理解和应用C语言。

整个流程参考下图:

编译和链接完整过程

当然幸运的是,这些工作在普通的开发中,也确实不需要我们操心了。我们只需要点几个按钮,或者输入几行命令就可以自动完成编译和链接的过程。比如:

  1. 在Windows平台,我们使用集成开发环境Visual Studio,它提供了易用的界面和命令行工具来自动化编译和链接过程。

  2. 在Linux平台,GCC(GNU Compiler Collection)是最常用的编译工具套件。我们也只需要在终端使用几行简短的命令,也可以快速完成编译和链接的过程。

进程虚拟内存空间

Gn!

C源文件经过预处理、编译、汇编、链接后,生成可执行文件,这就是一个C语言可执行程序(Program)。可执行文件被操作系统加载到内存中,程序得以运行,运行的程序我们称之为"进程(Process)"

此时操作系统会为每一个进程分配独属于进程的,唯一的虚拟内存空间。那么什么是虚拟内存空间呢?

虽然这更多是操作系统课程中的概念,但大家至少还是需要了解一下。

无虚拟内存时的内存管理

Gn!

为了让大家更好的理解虚拟内存空间,我们先从反方向思考一下,如果没有虚拟内存空间,那么进程是如何进行内存管理的呢?

内存管理属于操作系统的核心功能之一,所以此时内存管理仍然由操作系统完成。此时所有的进程都共用同一块物理内存(RAM),由操作系统来管理它们的内存使用。

可以参考以下示意图:

虚拟内存空间-分层示意图1

此时,内存管理是这样的:

  1. 每个进程都直接使用物理内存,进程获取的内存空间就是真实的物理内存空间。

  2. 进程获取的内存地址就是真实的物理内存地址。

  3. 每个进程使用物理内存的哪一段,由操作系统来管理分配。

这种内存管理的方式显然比较原始低级,也比较低效。很明显就有以下问题(了解):

  1. 进程间隔离实现困难。进程间需要保证隔离,一个进程显然不能直接修改其它进程的数据。但由于进程直接使用实地址,那么不同进程就需要使用不同的实地址进行编程,这其实非常困难。

  2. 不利于程序员管理内存。在这种情况下,如果程序员想要管理内存则需要充分了解物理硬件(RAM),这其实并不容易。

  3. 内存碎片化无可避免、导致内存利用率低下。当进程分配和释放内存时,物理内存中的空闲区域可能变得不连续,形成碎片。这会导致操作系统难以找到足够大的连续内存块来满足新的内存请求,从而降低了内存利用率。

  4. 无法共享内存。既然每个进程都占用独立的一块物理内存,那么即便可以共享内存数据,也无法实现。

  5. 复杂性很高,难以实现。操作系统必须精细地管理每个进程的物理内存,避免内存冲突、浪费和碎片化,显然难度非常大。在实施具体方案时,如果某个方案实现难度很大,则往往不会选择它,这在计算机科学乃至于日常生活中都非常常见。

  6. ...

总之,让操作系统直接管理物理内存的分配使用,看起来是一个比较的方案,实际上实现困难,还面临很多弊端。

所以现代操作系统都不会直接管理物理内存,而是使用虚拟内存技术。虚拟内存已经成为现代操作系统的重要特性,它大大提高了系统的稳定性、安全性、灵活性和扩展性。

下面我们具体看看虚拟内存这种技术。

有虚拟内存时的内存管理

Gn!

在现代操作系统中,虚拟内存是一种重要的内存管理功能,它使得进程能够使用一个独立于物理内存容量的、看似连续的地址空间。这个地址空间被称为"虚拟内存空间"。

"虚拟"这一术语用于描述这种机制,因为每个进程获得的内存空间并不是实际物理内存中连续的一段。

站在一个进程的角度来说,它所“看到”的是操作系统为其分配的一片连续的内存空间,进程获取的内存地址也不是真实的物理内存地址(实地址),而是虚拟内存空间的地址(虚地址)。

可以参考以下示意图:

虚拟内存空间-分层示意图2

此时的内存管理是这样的:

  1. 每个进程获取都只是操作系统提供的虚拟内存空间,而不是真实的物理内存空间。

  2. 进程获取的内存地址只是虚拟内存空间的地址,只是虚地址,而不是实地址。

  3. 进程虽然使用虚拟内存空间,但数据始终要存储在真实物理内存上。操作系统和MMU(内存管理单元)负责管理虚拟内存空间和物理内存之间的映射关系。

MMU(Memory Management Unit,内存管理单元) 是计算机系统中的一个硬件组件,负责和操作系统一起实现虚拟内存与物理内存之间的映射。

这种“加一层”的做法虽然看起来让整个系统更加复杂了,但实际上带来了很多好处:

  1. 易于实现进程隔离。每个进程获得的虚拟内存空间是独立,互不干扰的。两个进程即便使用相同的虚地址编程也不会影响进程隔离,因为操作系统和MMU会确保它们映射到不同的物理内存区域。

  2. 简化程序员的内存管理。虚拟内存使得程序员无需关心物理内存,而是直接使用连续的虚拟内存空间即可。

  3. 内存共享易于实现。虚拟内存使得多个进程能够共享相同的物理内存区域,因为可以通过映射共用同一块物理内存。

  4. 通过分段、换段、分页、换页等技术,提升内存管理的灵活性和安全性,内存碎片也大大减少。

关于虚拟内存的扩展内存,可以扩展阅读补充04_虚拟内存的核心技术

总之,一系列的好处使虚拟内存成为现代操作系统中不可或缺的一部分。

虚拟内存空间模型

Gn!

基于虚拟内存分段技术的原理,为了更直观的从C程序的视角理解虚拟内存空间,帮助C程序员更好的管理和操作内存,我们用虚拟内存空间模型来描述虚拟内存空间。

虚拟内存空间模型将一个C进程的虚拟内存空间,划分为几个不同的内存区域,这些内存区域存放的数据、用途、特点等皆有不同,是我们后续学习课程的重点!

虽然不同的平台、操作系统在虚拟内存空间模型上可能会有所差异,但虚拟内存空间模型普遍包括以下几个关键部分:

进程虚拟内存空间-模型图

从低地址到高地址,虚拟内存空间模型的内存区域包括:

  1. 代码段(Code):

    1. 代码段一般位于虚拟内存空间的最低地址处。

    2. 代码段用于存放一个C程序编译后得到的可执行代码指令,一般是只读的。

    3. C程序员的操作一般不涉及代码段。

  2. 数据段(Data):

    1. 数据段用于存放程序运行时具有静态存储期限的全局数据,包括:全局变量和静态变量(static修饰的局部变量和全局变量)

    2. 数据段还用于存储程序运行时的只读数据(只读数据段),比如字符串字面值。

  3. 堆空间(Heap):

    1. 堆空间是虚拟内存空间中C程序员最关注的区域,没有之一。

    2. 堆空间涉及到C程序的动态内存分配和管理,我们常说C语言可以操作和管理内存,最主要说的就是管理堆空间。对堆空间的自由管理,也是C语言和其他语言(如Java)的重要区别!

    3. 堆空间往往占据虚拟内存空间的大部分,它可以按照程序员的需求,自由的从低地址向高地址生长。

  4. 栈空间(Stack):

    1. 栈空间也是C程序员比较关注的内存区域。

    2. C语言是一门以函数调用为核心的编程语言,而栈空间保障了函数调用的正常进行,决定了函数调用的流程。

    3. 栈空间的特点是"先进后出"。它会随着函数调用从高地址向低地址生长,也会随着函数调用结束裁剪内存空间。栈空间的大小往往十分有限,内存占用远小于堆空间。

    4. C程序中的局部变量数据存储在栈空间中。

  5. 内核区域(Kernel):

    1. 内核区域由操作系统内核使用,存储内核代码和内核级的数据结构。

    2. 应用程序通常不能直接访问内核空间。但涉及到"系统调用"时,应用程序会从"用户态"转入"内核态",此时应用程序就可以访问内核区域了。

好了,我相信你看完这一段描述,有些看懂了,有些则还有些摸不到头脑。但都没有关系,在随后的课程中,我们会逐步讲解这些内存区域,不需要着急。

地址相关概念

Gn!

相信每一位对C/C++编程语言有一丢丢了解的同学,都听说过"内存地址"、"地址"的概念,那么什么是内存地址呢?

内存地址

Gn!

什么是内存地址?

首先我们在C/C++编程当中说提到的地址,都指的是虚拟内存空间的地址,即虚地址。切记不是实地址,不是物理内存地址。

内存地址的概念如下:

内存地址是(虚拟)内存空间中某个位置的唯一性标识。由于现代计算机的最小寻址单位是8位1个字节,所以我们可以直接认为,内存地址就是虚拟内存空间中某1个字节区域的唯一性标识。

虚拟内存空间是连续的,我们完全可以把虚拟内存空间视为一个连续的、每个存储单元是1个字节的大数组,此时:

  1. 数组中存储的数据就是虚拟内存空间中存储的数据。

  2. 数组的下标就是虚拟内存空间一个字节存储单元的唯一性标识,即内存地址。

参考下图:

内存地址-示意图

和内存地址相对应的还有一个非常重要的概念:变量地址

变量地址

Gn!

所谓变量地址,其概念如下:

变量地址就是变量所占内存空间的第一个字节的内存地址。

比如一个32位(4字节)的整数变量num,那么这个变量的地址指的是这4字节中的第一个字节的地址。

C语言提供了专门的取地址运算符"&",它一般用于和一个变量名结合,如"&num",用于取变量num的内存地址。当然此时你得到的就是变量num的第一个字节的内存地址。

如下图所示:

变量地址-示意图

很明显第一个字节的地址就是变量num的地址。

地址值

Gn!

在上述图中,我们已经看到了一个以"0X"开头的十六进制数字,这个数字是什么呢?

这就是地址值

内存地址是虚拟内存空间中某1个字节区域的唯一性标识,为了直观地描述这些地址,我们使用了"地址值"这个概念。

在大多数情况下,"地址"和"地址值"可以被视为同一概念。

说白了,我们使用地址值来描述一个地址。

高地址和低地址

Gn!

在描述虚拟内存地址时,我们可以把虚拟内存空间想象成一个多单元(多字节)组成的数组,每个单元(每一个字节)都有其唯一标号"索引",这个"索引值"就是其地址值。

类比于索引不可能是负数,我们也规定地址必须是一个非负数,最小地址为0。

于是:

地址值的取值范围就很好得出来了:[0, 最大地址值]

我们先不探究这个最大地址值究竟是多少,先来看"低地址"和"高地址"这两个不同的概念,在后续课程中会经常提到:

  1. 低地址:位于内存取值范围的较低端,即接近0的地址。

  2. 高地址:相对于低地址而言,高地址指的是内存范围中接近最大地址的部分。

例如,假设有一个内存范围从地址0x1000到地址0x2000:

  1. 0x1000就是这个范围的最低地址。接近它,就处于低地址。

  2. 0x2000就是这个范围的最高地址。接近它,就处于高地址。

明确高、低地址的概念十分重要,比如上面虚拟内存模型中提到的概念:

  1. 我们描述虚拟内存空间,当我们说从"低地址到高地址",意味着从代码段到内核虚拟内存这样的内存排布。

  2. 堆(heap)通常从低地址开始向高地址增长,栈(stack)则从高地址开始向低地址增长。

那么对于一个具体的平台而言,虚拟内存空间的最大地址是什么呢?

最大地址值

Gn!

实际上,虚拟内存空间的最大地址通常由平台的地址位数决定。目前主流的平台有两种:

  1. 32位架构平台

  2. 64位架构平台

32位系统平台

Gn!

32位的系统架构:

地址总位数是32位,虚拟内存空间中存在232个可能的地址,也就是对应232个字节(即4GB)的虚拟内存空间。这232个可能中:

  1. 最小的可能(低地址),所有位都是0,即00000000 00000000 00000000 00000000,即十六进制的0x00000000。

  2. 最大的可能(高地址),所有位都是1,即11111111 11111111 11111111 11111111,即十六进制的0xFFFFFFFF。

我们普遍使用十六进制来表示地址值(因为这样地址值比较短易读)

对于32位平台,我们使用8位十六进制数来表示地址值,所以32位平台的地址值范围:[0x00000000, 0xFFFFFFFF]。在这个范围内,十六进制数的每一个取值就代表内存中的一个字节内存区域。

64位系统平台

Gn!

64位的系统架构:

地址总位数是64位,虚拟内存空间中存在264个可能的地址,对应着极大的虚拟地址空间(理论上有16 EB,也就是224TB)。

然而,现代64位架构和操作系统并没有利用所有64位进行寻址,主要是由于:

  1. 当前的硬件无法支持如此大的物理内存。

  2. 也没有应用需要如此大的内存空间。

  3. 即使操作系统支持更大的地址空间,也并不是所有程序都会直接受益。

  4. 过大的虚拟内存空间,内存利用率不高。

现代的64位操作系统,大多只实际使用48位来进行虚拟内存空间寻址。这使得最大虚拟地址空间为 2^48 = 256 TB。这个空间已经足够满足现代软件应用和操作系统的需求了。

但需要注意的是,为了更好的统一性,在实际表述64位平台下变量的地址值时,仍然选择使用64位二进制数或16位十六进制数来表示。

补充:两种平台的区别

Gn!

32位平台和64位平台的差异性是什么呢?

要想弄明白它们的差异性,首先就要知道这两个平台的命名以及它们命名的由来。

32位平台,官方的叫法是X86,为什么叫这个呢?

在上个世纪70年代,Intel公司开创性的发明了世界上第一款商用微型处理器(CPU),最早期Intel推出的CPU架构是8位的,随后相继推出了16位,以及32位架构的CPU。

在这个时期Intel推出了一系列的CPU都以"86"作为结尾,如:

  1. Intel 8086(16位)

  2. Intel 80386(32位)

  3. Intel 80486(32位)

  4. ...

于是人们将"X86"作为一种表示16位,32位架构的系统平台,其中的字母"X"只是一个代号,没有特殊含义。

沿用到今天,X86就多用于表示32位的系统架构,因为16位在商用计算机中已经不常见了。所以要记住,32平台架构使用"X86",而不是"X32"。

后续随着时代的发展,32位的平台架构也不能满足需求了(因为虚拟内存可寻址空间只有4GB),于是出现了64位平台,这就是"X64"系统架构。

这就不得不提另一个商用CPU生产大厂——AMD了。

在2003年,AMD率先提出了"X86-64"系统架构,意为兼容32位"X86"平台的64位系统架构,或者x86架构的64位扩展。

随后Intel采纳这一建议,AMD和Intel相继推出了一系列64位商用CPU,成为了现代商用CPU双雄。

所以严格意义上来说,我们这里提到的64位系统架构是32位系统架构的拓展,全名是"X86-64"系统架构。但为了简洁,普遍我们都把这种架构称之为"X64"系统架构。

所以X64系统架构是兼容X86的32位平台架构的。

通过以上描述,我们可以总结两种平台架构的优缺点以及区别。

64位平台架构:

  1. 更大的内存寻址空间。

  2. 更高的性能。64位处理器能够一次处理更大数据块,所以性能比32位处理器要强很多。

  3. 可以向下兼容32位平台。

32位平台架构:

  1. 更小的内存占用,更加轻量化。

  2. 兼容性更好。由于历史积累原因,32位架构平台的硬件和软件至今都非常多,使用32位平台架构可以更好的兼容这些旧时代的遗民。

总之,不要认为64位架构淘汰了32位架构,两种架构更多是一种并存的状态,很多软件都会同时开发32位架构和64位架构两种版本。

在学习过程中,为了便于大家理解虚拟内存,简化地址值,我们会选择生成32位架构的应用程序。

扩展:指针变量

Gn!

C/C++语言中所谓的指针变量,其实就是存储另一个变量内存地址的变量。

指针是C/C++的绝对核心语法,在后续的课程中我们将详细谈一下指针,在这里可以仅做了解。

这里提出一个小问题,检验一下,你有没有彻底搞清楚上述概念:

指针变量也是一种变量,既然是变量就需要存储占用一定的内存空间,那么指针变量占据多少字节的内存空间呢?

答:要看系统平台位数:

  1. 如果是32位系统平台,地址值使用32位二进制数或8位十六进制数表示,于是就需要32位来存储这个地址值,指针变量固定为4个字节。

  2. 如果是32位系统平台,地址值使用64位二进制数或16位十六进制数表示,于是就需要64位来存储这个地址值,指针变量固定为8个字节。

字节顺序

Gn!

所谓字节顺序(Endianness)是指多字节数据(如 intfloatdouble 等类型)在内存中存储的顺序。由于计算机存储数据时常使用多个字节来表示一个数值,字节顺序就决定了这些字节在内存中的排列顺序。

紧接上述内容,现在我们可以把虚拟内存空间简化看成一个数组,而地址值就是这个数组的索引下标,如下图所示:

32位虚拟内存空间数组-简化图

如果你理解了上图,那么我就要提出一个新的问题了:

"数组"中要存储元素数据,每个存储单元是1个字节,需要存储1个字节的数据,那么要如何存储呢?

比如一个占32位(4个字节)的整型数据变量 int num = 10;,在虚拟内存空间中该如何存储呢?

我们都知道计算机中存储整数,采用的是有符号数补码的形式存储,变量num用补码形式表示是:

00000000 00000000 00000000 00001010

那么虚拟内存空间中存储num,就是按顺序从低地址到高地址存储这个补码吗?

当然不是,下面介绍计算机存储数据的字节顺序的两种常见方式。

小端存储法

Gn!

Intel、AMD等主流32、64位架构的CPU,在存储数据时,选择将此数据的最低有效位字节存储在低内存地址上,即"小端存储法"。

小端存储法是一种比较反直觉的数据存储格式。

在采用小端存储时,1个字节的低有效位被存储在低地址上,也就是说num是按照下列格式从低地址到高地址存储的:

00001010 00000000 00000000 00000000(数据的低有效位存储在低地址端)

如果画图来描述的话就是:

内存地址-示意图

此图描述了一个int类型变量:

  1. 它占用4个字节的内存空间,地址范围是0x004ffd6c ~ 0x004ffd6f

  2. 此变量的地址是0x004ffd6c

  3. 这4个字节的空间共同存储了整数值10,其中最低地址的字节(0x004ffd6c)存储了"10"的低有效位,其余高地址字节存储"0"。

大端存储法

Gn!

与小端存储法相对应的,就有了大端存储法。所谓大端存储法,指的是在存储数据时,选择将此数据的最低有效位字节存储在高内存地址上。

大端存储法则比较符合直觉,直接将数据按照高有效位到低有效位,存储在低地址到高地址当中。

大端存储法最常见的场景就是:网络传输数据时,使用大端序列来进行数据传输。

还有一些文件的格式也会采用大端序列来进行数据存储,比如某些图片或音频文件格式。

后续课程内容中,我们会遇到大端存储法,到那时我们再详谈。

The End