本文介绍在GNU/Linux环境下一个C程序由源代码到程序,到加载运行,最后终止的过程。同时以此过程为载体,介绍GNU/Linux平台下软件开发工具的使用。
 
本文以我们最常见的hello, world!为例:
 
#include <stdio.h>
main () 
{
      printf(“hello, world!\n”);
}
 
    
C程序生成

 
下图是一个由C源代码转化为可执行文件的过程:
GNU/Linux平台的C程序开发及程序运行环境 - hackbin - 一个人的天空
 
代码编辑: 比较流行的编辑器是GNU Emacs和vim。Emacs具有非常强大的功能,并且可扩展。

编译:包括编译预处理,编译,汇编,连接过程。它们都可以通过GCC来实现。关于GCC,可以参考我关于GCC的笔记
 
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程序的开始及终止

 
GNU/Linux平台的C程序开发及程序运行环境 - hackbin - 一个人的天空
    
程序的运行:

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


程序开始执行时,在调用main函数之前会运行C启动例程,该例程将命令行参数和环境变量从内核传递到main函数。
 
程序的终止:有8种途径:
正常终止
1,    从main返回。
2,    调用exit。
3,    调用_exit或_Exit。
4,    从最后一个线程的开始例程返回。
异常终止
5,    调用abort。
6,    接收到一个终止信号。
7,    对最后一个线程发出的取消请求做出响应。
 
_exit与_Exit的区别        :前者由POSIX定义,后者由ISO C定义。
exit与_exit, _Exit的区别:前者在退出时会调用由用户定义的退出处理函数,而后两者直接退出. (关于退出处理函数atexit(), 参考APUE2, P182.)



另外, 调用exit()或_Exit()需要包含<stdlib.h>, 调用_exit()需要包含<unistd.h>.


    

要退出程序,除了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)步中, 操作系统将可执行模块由硬盘拷贝到内存的程序映像中, 程序映像的一般布局如下图:

GNU/Linux平台的C程序开发及程序运行环境 - hackbin - 一个人的天空

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

1, 代码段:即机器码,只读,可共享(多个进程共享代码段)。
2, 数据段:储存已被初始化了的静态数据。
3, 未初始化的数据段(也被称为BSS段):储存未始化的静态数据。

4, 堆:储存动态分配的内存.
5, 栈:储存函数调用的上下文, 动态数据.


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


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

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

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

磁盘: 可执行文件段    内存: 内存程序映像  进程: 进程地址空间

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

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()). 参考本blog的: C程序中的内存管理
posted on 2009-12-31 16:51  一个人的天空@  阅读(1530)  评论(0编辑  收藏  举报