分类:
2012-10-29 15:34:54
本文介绍在GNU/Linux环境下一个C程序由源代码到程序,到加载运行,最后终止的过程。同时以此过程为载体,介绍GNU/Linux平台下软件开发工具的使用。
本文以我们最常见的hello, world!为例:
#include main () { printf(“hello, world!\n”); } |
C程序生成
下图是一个由C源代码转化为可执行文件的过程:
代码编辑: 比较流行的编辑器是GNU Emacs和vim。Emacs具有非常强大的功能,并且可扩展。
编译:包括编译预处理,编译,汇编,连接过程。它们都可以通过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 |
程序的运行:
唯一入口: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()需要包含 |
要退出程序,除了return只能在main中调用外,exit, _exit, _Exit可以在任意函数中调用。
在main函数最后调用return (0); 与调用exit (0)是等价的。
程序中调用exit时,exit首先调用注册的退出处理函数(通过atexit注册),然后关闭所有的文件流。
在程序运行结束时,main函数会向调用它的父进程(shell)返回一个整数值,称之为返回状态。该数值由exit或return定义。如果没有显示地调用它们,程序还是会正常终止,但返回数值不确定(以前面的hello, world程序为例,返回值为13,实际上是printf函数的字符个数)。
程序映象
我们已经了解了一个可执行模块(executable module)是怎样由源代码生成的. 那么, 执行这个程序时, 又是怎样的情况呢? 下面介绍一个位于磁盘中的可执行程序是如何被执行的.
(1) 程序被执行时, 操作系统将可执行模块拷贝到内存的程序映像(program image)中去.
(2) 正在执行的程序实例被称为进程: 当操作系统向内核数据结构中添加了适当的信息, 并为运行程序代码分配了必要的资源之后, 程序就变成了进程. 这里所说的资源就包括分配给进程的地址空间和至少一个被称为线程(thread)的控制流.
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 |
- |
正因为内存程序映像中的各段可能位于不同的地址空间中, 它们不一定位于连续的内存块中. 操作系统将程序映像映射到地址空间时, 通常将内存程序映像划分为大小相同的块(也就是page, 页). 只有该页被引用时, 它才被加载到内存中. 不过对于程序员来说, 可以视内存程序映像在逻辑上是连续的. |
int a[50000] = {1, 2, 3, 4}; /* 被显式初始化为非0的静态数据 */
int main(void) {
a[0] = 3;
return 0;
}
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命令可以删除可执行程序中的符号表:size命令不光可以查看最终生成的可执行文件的内存映像信息, 还可以查看可.o目标文件. |
进程地址空间的数据段还包括了堆, 即内存程序映像中的堆. 堆一般用作动态分配内存. ( malloc(), calloc(), realloc(), free()).