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. 1: #include <stdio.h>
  2. 2:
  3. 3: int main(void)
  4. 4: {
  5. 5: int hoge;
  6. 6: char buf[256];
  7. 7:
  8. 8: printf("&hoge...%p\n", &hoge);
  9. 9:
  10. 10: printf("Input initial value.\n");
  11. 11: fgets(buf, sizeof(buf), stdin);
  12. 12: sscanf(buf, "%d", &hoge);
  13. 13:
  14. 14: for (;;) {
  15. 15: printf("hoge..%d\n", hoge);
  16. 16: /*
  17. 17: * getchar()让控制台处于等待输入的状态.
  18. 18: * 每次敲入回车键,增加 hoge 的值
  19. 19: */
  20. 20: getchar();
  21. 21: hoge++;
  22. 22: }
  23. 23:
  24. 24: return 0;
  25. 25: }

如今的操作系统,大多提供了多窗口环境,请你在自己的操作系统中打开两个新的窗口。若习惯用 UNIX,请使用 kterm(或者类似的工具);若习惯用 Windows,请使用 DOS 窗口。另外,如果你没有使用完全相同的方式启动这些窗口,后面的实验可能会进行得不太顺利。Windows 的话,通过开始菜单打开两个新窗口就 OK。

然后,请在两个窗口分别试着运行刚才的程序(如果有必要,可以通过 cd 命令进入程序所在的目录)。

在我的环境里,得到了如图 2-1 所示的结果。

2.1 虚拟地址 - 图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.1 虚拟地址 - 图2

图 2-2 虚拟内存的概念图

要 点

在如今的运行环境中,应用程序面对的是虚拟地址空间。

 

补充 关于 scanf()

代码清单 2-1 中,使用下面的两条语句让用户输入整数值:

  1. fgets(buf, sizeof(buf), stdin);
  2. sscanf(buf, "%d", &hoge);

在一般的 C 入门书籍中,却经常看到以下写法:

  1. scanf("%d", &hoge);

在这一次的例子中如果使用这种写法,程序是不会如愿运行起来的。因为这种写法在一开始漏掉了 getchar(),没有使程序执行进入输入等待状态。

这个问题是由 scanf()的自身实现造成的。

scanf()不是以行单位对输入内容进行解释,而是对连续字符流进行解释(换行字符也视为一个字符)。

scanf()连续地从流读入字符,并且对和格式说明符(%d)相匹配的部分进行变换处理。

例如,当格式说明符为%d 的时候,输入

  1. 123

从流中取得 123 部分的内容,并对它进行处理。换行符依旧会残留在流中。因此,后续的 getchar()会吞食这个留下的换行符。

此外,当 scanf()变换失败的时候(比如,尽管你指定了%d,但是输入的却是英文字符),scanf()会将导致失败的部分遗留在流中。

在读入过程中有几个对象被成功地变换,则 scanf()的返回值就为几。如果做一下错误检查,可能有人会写出下面的代码:

  1. while (scanf("%d", &hoge) != 1) {
  2. printf("输入错误,请再次输入!");
  3. }

分析一下上面的代码,我们就会知道,一旦用户错误输入过一次,这段程序就会进入无限循环。原因就是:错误输入的那部分字符串,将会被下一个 scanf()读到。

像代码清单 2-1 那样,如果将 fgets()sscanf()组合使用,就可以避免这个问题。

当然,一旦对 fgets()函数的第 2 个参数赋予超过指定长度的字符串,也是会出问题的。不过,如果像代码清单 2-1 这样指定了 256 个字符,对于自己使用的程序来说应该是足够了。

顺便说一下,在 scanf()中通过指定复杂的格式说明符,同样可以避免问题的发生。但我还是感觉不如使用 fgets()这种方式来得便利。

此外,为了解决这个问题,有人会使用 fflush(stdin);,其实这是个错误的处理方法

fflush()是对输出流使用的,它不能用于输入流。标准中并没有定义用于输入流的 fflush()的行为。