Linux 内存映像、磁盘映像的理解

#include
main ()
{
    printf(hello, world!\n);
}

C 程序生成

下图是一个由 C 源代码转化为可执行文件的过程:

process

C 编译器将源文件转换为目标文件,如果有多个目标文件,编译器还将它们与所需的库相连接,生成可执行模块。当程序执行时,操作系统将可执行模块拷贝到内存中的程序映象。

程序又是如何执行的呢?执行中的程序称之为进程。程序转化为进程的步骤如下:

  1. 内核将程序读入内存,为程序镜像分配内存空间。
  2. 内核为该进程分配进程标志符(PID)。
  3. 内核为该进程保存 PID 及相应的进程状态信息。

经过上述步骤,程序转变为进程,即可以被调度执行。

上述的 hello, world 程序实际是不规范的,POSIX 规定 main 函数的原型为:

int main(int argc, char *argv[])

argc 是命令行参数的个数,argv 是一个指针数组,每个指针元素指向一个命令行参数。

e.g:  $ ./a.out arg1 arg2
argc = 4
argv[0] = ./a.out   argv[1] = arg1  argv[2] = arg2

C 程序的开始及终止

start and exit

程序的运行:

唯一入口:exec 函数族(包括 execl, execv, execle, execve, execlp, execvp)

程序开始执行时,在调用 main 函数之前会运行 C 启动例程,该例程将命令行参数和环境变量从内核传递到 main 函数。

程序的终止

有 8 种途径:

正常终止:

  1. 从 main 返回。
  2. 调用 exit。
  3. 调用_exit 或_Exit。
  4. 从最后一个线程的开始例程返回。

异常终止:

  1. 调用 abort。
  2. 接收到一个终止信号。
  3. 对最后一个线程发出的取消请求做出响应。

_exit 与_Exit 的区别 :前者由 POSIX 定义,后者由 ISO C 定义。 exit 与_exit, _Exit 的区别:前者在退出时会调用由用户定义的退出处理函数,而后两者直接退出. (关于退出处理函数 atexit(), 参考 APUE2, P182.)

另外, 调用 exit() 或_Exit() 需要包含, 调用_exit() 需要包含.

要退出程序,除了 return 只能在 main 中调用外,exit, _exit, _Exit 可以在任意函数中调用。

在 main 函数最后调用 return(0); 与调用 exit(0) 是等价的。

程序中调用 exit 时,exit 首先调用注册的退出处理函数(通过 atexit 注册),然后关闭所有的文件流。

在程序运行结束时,main 函数会向调用它的父进程 (shell) 返回一个整数值,称之为返回状态。该数值由 exit 或 return 定义。如果没有显示地调用它们,程序还是会正常终止,但返回数值不确定(以前面的 hello, world 程序为例,返回值为 13,实际上是 printf 函数的字符个数)。

$ gcc -Wall -o hello hello.c
$ ./hello
$ echo $?             (echo $? 用于在 bash 中查看子程序的返回值)
13

程序映象

我们已经了解了一个可执行模块 (executable module) 是怎样由源代码生成的. 那么, 执行这个程序时, 又是怎样的情况呢? 下面介绍一个位于磁盘中的可执行程序是如何被执行的.

  1. 程序被执行时, 操作系统将可执行模块拷贝到内存的程序映像 (program image) 中去.
  2. 正在执行的程序实例被称为进程: 当操作系统向内核数据结构中添加了适当的信息, 并为运行程序代码分配了必要的资源之后, 程序就变成了进程. 这里所说的资源就包括分配给进程的地址空间和至少一个被称为线程 (thread) 的控制流.

上面只是大而化之地介绍了程序是如何转化为进程的, 这里关注的是内存程序映像. 在第 1 步中, 操作系统将可执行模块由硬盘拷贝到内存的程序映像中, 程序映像的一般布局如下图:

program image

从低地址到高地址依次为下列段:

  1. 代码段:即机器码,只读,可共享(多个进程共享代码段)。
  2. 数据段:储存已被初始化了的静态数据。
  3. 未初始化的数据段 (也被称为 BSS 段):储存未始化的静态数据。
  4. 堆:储存动态分配的内存.
  5. 栈:储存函数调用的上下文, 动态数据.

另外, 在高地址还储存了命令行参数及环境变量.

程序代码 (text) 段一般是在进程之间共享的. 比如一个进程 fork 出一个子进程时, 父子进程共享 text 段, 子进程获得父进程数据段, 堆, 栈的拷贝.

磁盘映像, 内存映像, 地址空间之比较

前面提到, 可执行程序首先被操作系统从磁盘中拷贝到内存中, 还要为进程分配地址空间. 加上已经介绍的内存程序映像, 这就有三种关于可执行程序的存储组织了:

下标列出了它们之间的对应关系: 内存程序映像|进程地址空间|可执行文件段 ———-|———-|———- code(text)|code(text)|code(text) data|data|data bss|data|bss heap|data|- stack|stack|-

内存程序映像和进程地址空间之比较

  1. 它们的代码段和栈相互对应.
  2. 内存程序映像的 data, bss, heap 对应到进程地址空间的 data 段. 也就是说, data, bss, heap 会位于一个连续的地址空间中, code 和 stack 可能位于另外的地址空间. 这就可以针对不同的段实现不同的内存管理策略: code 段所在的地址空间可以是 “只能被执行的”, data, bss, heap 所在的地址空间是不可执行的…

正因为内存程序映像中的各段可能位于不同的地址空间中, 它们不一定位于连续的内存块中. 操作系统将程序映像映射到地址空间时, 通常将内存程序映像划分为大小相同的块 (也就是 page, 页). 只有该页被引用时, 它才被加载到内存中. 不过对于程序员来说, 可以视内存程序映像在逻辑上是连续的.

内存程序映像和可执行文件段之比较

  1. 明显, 前者位于内存中, 后者位于磁盘中.
  2. 内存程序映像中的 code, data, bss 段分别对应于可执行文件段中的 code, data, bss 段.
  3. 堆栈在可执行文件段中是没有的, 因为只有程序被加载到内存中运行时才会被分配堆栈.
  4. 虽然可执行文件段中包含了 bss, 但 bss 并不被储存在位于磁盘中的可执行文件中.

使用 file, ls, size, strip 命令来查看相关信息

我们利用下面 3 个简单的例子来理清上述概念:

(1) array1.c

int a[50000] = {1, 2, 3, 4};      /* 被显式初始化为非 0 的静态数据 */
int main(void) {
   a[0] = 3;
   return 0;
}

(2) array2.c

int b[50000];            /* 未被显式初始化的静态数据 */
int main(void) {
   b[0] = 3;
   return 0;
}

(3) array3.c

int c[50000] = {0,0,0,0};  /* 被显式初始化为0的静态数据 */
int main(void) {
   c[0] = 3;
   return 0;
}

array1.c 中, 数组 a 被显式初始化为非 0. array2.c 中, 数组 b 未被显式初始化, 但由于它是静态变量, 所以被编译器初始化为默认的值: b 中所有元素被初始化为 0. array3.c 中, 数组 c 的所有元素被显式地初始化为全 0.

$ gcc -Wall -o init array1.c
$ gcc -Wall -o noinit array2.c
$ gcc -Wall -o init-0 array3.c

# 使用 ls 命令, 查看磁盘文件大小:
$ ls -l init noinit init-0
-rwxr-xr-x 1 zp zp 209840 2006-08-21 15:56 init
-rwxr-xr-x 1 zp zp   9808 2006-08-21 15:57 init-0
-rwxr-xr-x 1 zp zp   9808 2006-08-21 15:57 noinit

我们发现 array1.c 生成的 init 可执行文件比 array2.c, array3.c 生成的要大大约 200000 字节. 而 array2.c 和 array3.c 生成的可执行文件在大小上是一样的.

严格地说, 上述内存程序映像中的 “未初始化的静态数据” 应该改称为 “被初始化为全 0 的静态数据”: 被程序员显式地初始化为 0 或被编译起隐式地初始化为默认的 0. 而且, 只有程序被加载到内存中时, 被初始化为全 0 的静态数据所对应的内存空间才被分配, 同时被赋予 0 值.

使用 size 命令, 查看内存程序映像信息:

$ size init noinit init-0
 text    data          bss         dec         hex    filename
 822  200272        4        201098   3118a   init
 822     252       200032  201106   31192   noinit
 822     252       200032  201106   31192   init-0

size 命令显示内存程序映像中的 text, data, bss 三个段大小, 以及这 3 个段大小之和的十进制和十六进制表示. (由于堆栈是在程序执行时动态分配的, size 无法显示它们的大小. 可以使用 ps 命令查看进程地址空间信息.)

通过 size 命令, 我们可以得知如下事实:

  1. 不管静态数据是否被初始化, 加载到内存中的程序映像大小是不变的. 它们之间的区别只是 data 和 bss 段大小的不同 (影响磁盘文件的大小).
  2. 由于 size 不计算堆栈大小, 所以 ls 命令和 size 命令类出的磁盘程序映像大小和内存程序映像大小应该是一样的, 但通过上面的 ls 和 size 命令输出我们发现:
    1. 若静态变量被初始化为非 0, 磁盘映像要大于内存映像.
    2. 若静态变量被初始化为全 0, 磁盘影响(映像)要小于内存影响(映像).

这是因为:

(1) 位于磁盘中的可执行程序中不关(光)包含上面类出的磁盘映像的内容 (code, data, bss), 它还包括: 符号表, 调试信息, 针对动态库的链接表等内容. 但这些内容在程序被执行时是不会被加载到内存中的.

使用 file 命令可以查看可执行文件的信息. 使用 strip 命令可以删除可执行程序中的符号表:

$ strip init; ls -l init
-rwxr-xr-x 1 zp zp 205920 2006-08-21 16:41 init

虽然符号表被删除了, 但 init 中还有其他信息, 所以仍比内存镜像大.)

(2) 静态变量被初始化为全 0 时 (不管是程序员显式地初始化还是被编译器初始化为默认的 0), 这一过程是在程序被加载到内存中时进行的, 数据无非位于 data 和 bss 段中, 所以它们是否被初始化为全 0 对于 size 来说, 内存映像总的大小是不变的, 但由于磁盘映像中不包含 bss 的值, 所以此时磁盘映像可能小于内存映像 (如果 bss 段大于符号表, 调试信息, 链接表等的大小).

size 命令不光可以查看最终生成的可执行文件的内存映像信息, 还可以查看可. o 目标文件.

进程地址空间的数据段还包括了堆, 即内存程序映像中的堆. 堆一般用作动态分配内存. (malloc(), calloc(), realloc(), free()).

-->