2.1 虚拟地址
关于“地址”的概念,我们在第 1 章已经做了如下说明:
变量总是保存在内存的“某个地方”。“某个地方”这样的说法不容易理解,因此,就像使用“门牌号”确定“住址”一样,在内存中,我们给变量分配“门牌号”。在 C 的内存世界里,“门牌号”被称为“地址”。
经过以上的说明,有人可能会有这样的想法:
哦,原来是这样子的!俺的机器是 128MB 的内存,这 128MB 的内存是以字节为单位从 0 开始分配了连续的编号呀。也就是说,如果在俺的机器里用十进制来粗略地计数,从 0 开始有大约 128 000 000 个地址吧*。那么使用
printf()
打印指针的值,其结果究竟是什么样的呢?
* 准确地说,应该是 128×1024×1024,结果是 134 217 728。
但是,如今的计算机可不是那么简单的哦。
现在的 PC 机和工作站的操作系统大多都提供了多任务环境,可以同时运行多个应用程序(进程)。那么,假设同时运行两个应用程序,然后尝试打印各自的变量地址,会出现一致的结果吗?若遵循刚才的推论,不会。
好吧,我们一起做个实验。首先,请将代码清单 2-1 编译成可执行的文件。
这里可执行文件的名称为 vmtest
。
代码清单 2-1 vmtest.c
1: #include <stdio.h>
2:
3: int main(void)
4: {
5: int hoge;
6: char buf[256];
7:
8: printf("&hoge...%p\n", &hoge);
9:
10: printf("Input initial value.\n");
11: fgets(buf, sizeof(buf), stdin);
12: sscanf(buf, "%d", &hoge);
13:
14: for (;;) {
15: printf("hoge..%d\n", hoge);
16: /*
17: * getchar()让控制台处于等待输入的状态.
18: * 每次敲入回车键,增加 hoge 的值
19: */
20: getchar();
21: hoge++;
22: }
23:
24: return 0;
25: }
如今的操作系统,大多提供了多窗口环境,请你在自己的操作系统中打开两个新的窗口。若习惯用 UNIX,请使用 kterm(或者类似的工具);若习惯用 Windows,请使用 DOS 窗口。另外,如果你没有使用完全相同的方式启动这些窗口,后面的实验可能会进行得不太顺利。Windows 的话,通过开始菜单打开两个新窗口就 OK。
然后,请在两个窗口分别试着运行刚才的程序(如果有必要,可以通过 cd
命令进入程序所在的目录)。
在我的环境里,得到了如图 2-1 所示的结果。
图 2-1 通过两个进程同时表示变量的地址
在第 8 行,通过 printf()
输出变量 hoge
的地址,然后通过第 11 行的 fgets()
,程序进入了等待输入的停止状态。显然,启动后的两个窗口肯定都处于运行状态,但 hoge
的地址却两边完全相同。
这两个进程的 hoge
看上去地址完全相同,但它们确实是在各自进程里面彼此独立无关的两个变量。根据 Input initial value.
的屏幕窗口提示,我们接着输入一个任意值,这个值被赋予变量 hoge
(第 12 行),在第 15 行又被 printf()
输出。随后,getchar()
让程序处于等待输入状态。每次敲击回车键,hoge
的值都会增长并且被输出到窗口。正如我们看到的那样,这两个 hoge
内存地址明明是相同的,却各自保持着不同的值。
我在 FreeBSD 3.2-RELEASE 和 Windows 98 上运行了这个实验,都得到了以上的结果(编译器是 gcc)。
通对这样的实验发现,在如今的运行环境中,使用 printf()
输出指针的时候,打印输出的并不是物理内存地址本身。
当今的操作系统都会给应用程序的每一个进程分配独立的“虚拟地址空间”。这和 C 语言本身并没有关系,而是操作系统和 CPU 协同工作的结果。正是因为操作系统和 CPU 努力地为每一个进程分配独立的地址空间,所以就算我毛手毛脚、糊里糊涂地制造了一个 bug,破坏了某个内存区域,顶多也就是让当前的应用程序趴窝,但不会影响其他进程*。
* Windows 95/98 时代,经常出现由于应用程序的异常导致操作系统瘫痪的情况……想不到操作系统也会有这样的硬伤,唉。
当然了,真正去保存内存数据的还是物理内存。操作系统负责将物理内存分配给虚拟地址空间,同时还会对每一个内存区域设定“只读”或者“可读写”等属性。
通常,因为程序的执行代码是只读的,所以有时候会和其他进程共享物理内存*。另外,当我们启动了几个笨重的应用程序而使内存出现不足时,操作系统把物理内存中还没有被引用的部分倒腾出来,保存到硬盘上。当程序再次需要引用这个区域的数据的时候,再从磁盘写回到内存(恐怕会把别的部分从磁盘里面取出来也说不定哦)。这个操作完全是在操作系统的后台进行的,对于应用程序来说,压根儿不知道背后发生的事。这时硬盘“咔嗒咔嗒”响,机器的反应也慢了下来。
* 现在,将程序的一部分以共享库的形式来共享使用,已经是很普遍的设计手法了。在写入之前,连内存数据也可以共享。
之所以能够这样,多亏了虚拟地址。正是因为避免了让应用程序直接面对物理内存的地址,操作系统才能够顺利地对内存区域进行重新配置(参照图 2-2)。
图 2-2 虚拟内存的概念图
要 点
在如今的运行环境中,应用程序面对的是虚拟地址空间。
补充 关于 scanf()
代码清单 2-1 中,使用下面的两条语句让用户输入整数值:
- fgets(buf, sizeof(buf), stdin);
- sscanf(buf, "%d", &hoge);
在一般的 C 入门书籍中,却经常看到以下写法:
- scanf("%d", &hoge);
在这一次的例子中如果使用这种写法,程序是不会如愿运行起来的。因为这种写法在一开始漏掉了
getchar()
,没有使程序执行进入输入等待状态。这个问题是由
scanf()
的自身实现造成的。
scanf()
不是以行单位对输入内容进行解释,而是对连续字符流进行解释(换行字符也视为一个字符)。
scanf()
连续地从流读入字符,并且对和格式说明符(%d
)相匹配的部分进行变换处理。例如,当格式说明符为
%d
的时候,输入
- 123↲
从流中取得 123 部分的内容,并对它进行处理。换行符依旧会残留在流中。因此,后续的
getchar()
会吞食这个留下的换行符。此外,当
scanf()
变换失败的时候(比如,尽管你指定了%d
,但是输入的却是英文字符),scanf()
会将导致失败的部分遗留在流中。在读入过程中有几个对象被成功地变换,则
scanf()
的返回值就为几。如果做一下错误检查,可能有人会写出下面的代码:
- while (scanf("%d", &hoge) != 1) {
- printf("输入错误,请再次输入!");
- }
分析一下上面的代码,我们就会知道,一旦用户错误输入过一次,这段程序就会进入无限循环。原因就是:错误输入的那部分字符串,将会被下一个
scanf()
读到。像代码清单 2-1 那样,如果将
fgets()
和sscanf()
组合使用,就可以避免这个问题。当然,一旦对
fgets()
函数的第 2 个参数赋予超过指定长度的字符串,也是会出问题的。不过,如果像代码清单 2-1 这样指定了 256 个字符,对于自己使用的程序来说应该是足够了。顺便说一下,在
scanf()
中通过指定复杂的格式说明符,同样可以避免问题的发生。但我还是感觉不如使用fgets()
这种方式来得便利。此外,为了解决这个问题,有人会使用
fflush(stdin);
,其实这是个错误的处理方法。
fflush()
是对输出流使用的,它不能用于输入流。标准中并没有定义用于输入流的fflush()
的行为。