1. Introduction
本章主要介绍进程相关的东西,不过是侧重于进程的执行环境的内容,main函数如何执行的,命令行参数是如何传递给程序的,以及程序的内存结构是什么样的,进程如何使用环境变量的。并且查看longjmp和setjmp函数和它们和栈之间的交互。
2. main Function
一个C语言程序从main函数开始执行,签名如下:
int main(int argc, char *argv[]);
agrc
表示的是命令行参数的格式,argv是指向命令行参数的指针数组。内核调用exec
系统调用来执行程序,接着它会准备命令行参数和环境变量,再去执行main函数。当然这是省略的说法,其中还有一些别的东西。
3. Process Termination
有多种方式来结束进程,正常的方式为:
- 从main返回
- 调用了exit
- 调用
_exit
或者是_Exit
- Return of the last thread from its start routine
- 最后一个线程调用了
pthread_exit
异常退出的情况为:
- 调用abort
- 受到了一个信号(应该是进程结束运行的信号)
- Response of the last thread to a cancellation request(没理解,后续再补上)。
本章只讨论部分,和线程相关的留到后面。
Exit Functions
_exit
和_Exit
会立刻退出进程,而exit
则是将资源回收之后再退出。这三者各自的签名如下:
#include <stdlib.h>
void exit(int status);
void _Exit(int status);
#include <unistd.h>
void _exit(int status);
exit会调用fclose将所有打开的流关闭。这三个函数都接受一个int,这个数称为exit status。大多数的UNIX系统 shell 都提供了方法来获取一个进程的exit status。
如果调用exit的时候没有提供了一个exit status,或者是main函数不具有返回值,或者是main函数定义的返回值类型不是integer类型,这三种情况都是下exit status都是未定义的。如下:
#include <stdio.h>
void main()
{
printf("hello, world\n");
}
如果编译运行这个程序,使用echo $?
来输出exit status,输出的值是十分随机的。
atexit Function
ISO C 标准中一个进程可以至少注册32个函数,这些函数会被调用exit的时候被自动执行。这些函数称为exit handlers,并且可以通过atexit来注册。
#include <stdlib.h>
int atexit(void (*func)(void));
atexit参数是一个无返回值,无参数的函数指针,且在调用的时候,是以注册顺序的相反的,也就是:
atexit(func1);
atexit(func2);
func2先执行,然后func1在执行。在POSIX.1标准中对ISO C标准进行了扩展,一个程序如果调用了exec系列函数,那么会将所注册的exit handlers都清除。
4. Command-Line Arguments
当程序执行的时候,exec函数会将命令行的参数传给将要运行的函数,如下程序就是输出命令行参数:
#include "apue.h"
int main(int argc, char *argv[])
{
int i;
for (i = 0; i < argc; i++)
/* echo all command-line args */
printf("argv[%d]: %s\n", i, argv[i]);
exit(0);
}
ISO C标准以及POSIX.1确保了argv[argc]是空指针,所以也可以写为:
for (i = 0; argv[i] != NULL; i++)
5. Environment List
每一个程序也会被传入一个environment list(环境变量列表),它是一个指向一个字符串指针的数组。这个数组的地址被保存在全局变量environ中:
extern char **environ
下图显示了这几者的关系:
环境变量通常都是大写的,不过这并不是规范,只是一种习惯。通常的形式的都是name=value
。大多数的UNIX系统对一个程序的main
函数提供了第三个参数签名如下:
int main(int argc, char *argv[], char *envp[]);
ISO C标准要求main函数只有两个参数,并且因为environ
这个全局变量,我们不再需要第三个参数了。
6. Memory Layout of a C Program
一般来说,一个C语言程序分为以下几个部分:
- text segment,包含着CPU 可以执行的指令,通常来说,text segment是可以共享的,那么只要single copy 需要保存在内存当中,比如说常常需要被执行的程序,编译器,文本编辑器,shell等。而且,text segment通常是只读的,确保不会被其他程序修改来破坏指令。
- Initialized data segment,通常被称为data segment,那么些被初始化好的变量都会被放到这里。这些变量是放在函数体外部的,也就是全局变量才会被放在data segment中。
- Uninitialized data segment,这个segment通常被称为bss segment,这个段内的变量会被内核初始化未为0或者是空指针,这一切会在程序执行之前完成。比如说
int num;
,定义在函数体之外的全局变量都会被放在bss segment中。 - Stack,自动变量保存的地方,以及函数调用时候的参数,以及调用函数的时候的返回地址都会保存在栈当中,以及局部变量。
- Heap,动态内存分配保存数据的地方,这部分通常位于是低地址,而栈则是位于高地址。
下图显示了一个程序的基本结构:
不过这图这是描述了一个进程的通常的结构,在x86-32下,text segment从0x08048000
开始,而栈从0xC0000000
开始。如果直接使用readelf 指令去读取a.out,会发现还有很多section,不过这些section并不会被加载到内存中,如符号表(symbol section)等内容只是在编译期间才有用的。并且,实际上为未初始化的并不会被在硬盘中占据位置,elf文件中会有信息表明bss section的大小,指示exec在加载的时候,如何初始化进程的内存空间。
size
命令用于输出一个程序的各个段大小(以字节为单位)。如下:
7. Shared Libraries
大多数的UNIX系统支持共享连接库(shared library),这也就是Linux下的.so(应该其他unix系统都有),windows下的dll。Shared libraries将库函数的代码从程序中移除(本来的话,链接要将多个库文件函数连接在一起才是最后一个可运行的程序),共享连接库是内存中(被加载以后)可以让多个程序共享的。共享连接库最直观的优势就是降低了程序共享,但是缺点就是带来了一些运行时的开销,也就是在运行的时候,才会将共享连接库链接起来,这部分是有开销的。另外一个好处就是,Shared libraries不需要对程序重新编译,如果要对程序更新,那么直接更新链接库就行了。
默认情况下gcc使用的是共享连接库,编译好后的程序并不大,如果使用了gcc -static
那么得到的程序会急剧增大。
8. Memory Allocation
ISO C标准指定了三个函数用于函数的分配:
- malloc 分配参数所指定大小的内存空间,所分配的空间的值是indeterminate(没有被初始化)。
- calloc 根据某个对象类型来分配空间,所分配的值都是0
- realloc 增加先前分配的空间的大小。这里可能会涉及到将原先的内存空间上的数据复制到新的地方,并且,多于出来的空闲空间的值是indeterminate。
这三者的签名如下:
#include <stdlib.h>
void *malloc(size_t size);
void *calloc(size_t nobj, size_t size); //分配nobj个大小为size的对象
void *realloc(void *ptr, size_t newsize);
void free(void *ptr);
这三个函数都保证了分配是字节对齐的。free函数则是将内存回收,它将释放的内存放回到内存池当中,来保证后续的内存分配的时候可以复用。realloc用于修改前面所分配的内存块的大小,然而如果当前内存块之后还有可用空间,那么realloc会将这部分空间分配给内存块,那么就会返回相同的地址。这里是因为,底层的内存分配通常在分配的时候不是按照所要求的大小来分配的,可能因为内存对齐,或者预先分配等策略,所分配的内存块后面还有一部分可用的内存,所以在这种情况下就会返回相同的地址,如下的例子在我的测试当中就会输出相同的地址。
void *p = malloc(10);
printf("%p\n",p);
void *p1 = malloc(15); // 新空间分配15字节
printf("%p\n",p1)
但是如果放不下,那么就需要额外开辟一块空间,将现有的数据复制过去,然后将新分配的地址返回。注意reallocd的第二个参数表示的是新空间的大小,而不是新空间和老空间的之差。如果ptr为null,那么realloc就和malloc的效果一样。
alloc函数的作用是管理堆内的内存空间,而sbrk则是扩展堆空间。这是一个系统调用,K & R数中提供了一个简单的malloc和free的实现。大部分的malloc实现都是依赖于一些额外的信息,比如说这个块的大小,以及下一个块的地址等内容。所以在所分配的内存块之后和之前写入数据都会导致这些额外的信息被覆盖。
这里说的是p = malloc(100)
,然后我们在p + 100
之后写入,或者是在p --
写入,这些情况都会导致信息被覆盖,而且这些bug是十分难以发现的。正因为会出现这些情况,某一些系统对于alloc和free函数的实现会引入一些额外的检查机制。
Alternate Memory Allocators
malloc和free有很多替代品,如下,只挑选了书中提到的一些比较著名的:
- jemalloc 它是FreeBSD 8.0的默认实现,它的设计目的是能够在多核机器中多线程中使用malloc的时候具有更好的扩展性。
- TCMalloc 它是由google实现的,使用了thread local caches来避免分配缓存以及释放缓存的时候带来的locking开销。它内置了heap checker来帮助debug。
- alloca 它的实现不是从堆上分配内存,而是从栈上,优点就是不再需要对内存管理,自动会被释放。缺点就是stack frame会增大,而且有一些系统并不支持。
9. Environment Variables
前面我们提过环境变量的基本形式是name=value
,是字符串形式的。UNIX内核不会使用这些字符串,如何去解释这些字符串是交给应用程序来做的。比如说HOME和USER是由登录的时候自动设置的,其他的是交给用户去设置,通常会在start-up file 中进行设置,也就是bashrc,profile这些文件。ISO C定义了用于获得环境变量的函数,如下:
#include <stdlib.h>
char *getenv(const char *name);
返回值指向的是name=value
中的value,我们应该使用getenv来获得环境变量,而不是environ
。设置环境变量的函数如下:
#include <stdlib.h>
int putenv(char *str);
int setenv(const char *name, const char *value, int rewrite);
int unsetenv(const char *name);
putenv和setenv的功能相似,setenv中的rewrite控制着是否要对已经存在的环境变量进行覆盖,如果rewrite != 0,那么就会覆盖,否则的话就不覆盖。putenv和setenv的区别是,setenv要对name=value
分配内存,而putenv则是直接将name=value
放到环境变量中,环境变量是一个字符串数组指针,putenv应该相当关于的是数组直接赋值。所以,如果创建的是局部变量,那么putenv就会在栈上分配,这样在函数结束后就会被回收,环境变量就不再了。
GNU 也对这个做了一些说明。
9. setjmp and longjmp Functions
在C语言当中,不能使用goto
跳转到另外一个函数中的label。我们可以使用setjmp
和longjmp
来实现这样的效果。它们两者的签名如下:
#include <setjmp.h>
int setjmp(jmp_buf env);
void longjmp(jmp_buf env, int val);
调用setjmp来设置将来期望跳转到的地方,它的参数jmp_buf是一个和恢复栈相关的一些信息。通常情况下它都是一个全局变量。
This data type is some form of array that is capable of holding all the information required to restore the status of the stack to the state when we call longjmp
当我们想跳转到setjmp处的时候,调用longjmp即可,它的第一个参数是和setjmp一样的jmp_buf,而第二个参数则是setjmp的返回值。简单的用法如下:
jmp_buf jmpBuf;
int main() {
int res;
if((res = setjmp(jmpBuf) != 0) {
printf("%d\n",res); //logjmp的返回值存在res当中
printf("error\n");
exit(0);
}
printf("hello world\n");
longjmp(jmpBuf,1); // 跳转到Setjmp处继续执行
}
第一次调用setjmp的时候会返回0。当longjmp被调用的时候,第二个参数作为了setjmp的返回值,这里看起来有些违反直觉,因为跳转到了之前以前执行过的代码,不过将其setjmp和longjmp看作是goto就好理解了。
10. getrlimit and setrlimit Functions
每一个进程有各种资源,其中的一些可以通过getrlimit和setrlimit函数来获取以及设置。
#include <sys/resource.h>
int getrlimit(int resource, struct rlimit *rlptr);
int setrlimit(int resource, const struct rlimit *rlptr);
第一个参数是表明要操作的资源是哪个,第二个参数是如下的结构体:
struct rlimit {
rlim_t rlim_cur; /* soft limit: current limit */
rlim_t rlim_max; /* hard limit: maximum value for rlim_cur */
};
resource可取值如下,如果没有被限制,那么rlim_cur和rlim_max是一个RLIM_INFINITY
,表示infinite。
- RLIMIT_AS 进程可用的内存空间大小,影响着sbrk和mmap函数
- RLIMIT_CORE core file的最大大小,core dump是用于分析程序为什么出现异常原因的一种文件,暂时还没学过。RLIMIT_CORE为0表示不会产生core file。
- RLIMIT_CPU 一个进程最多可以使用的CPU 时间(秒),当进程使用的CPU时间超过了这个值,那么SIGXCPU信号会被发送给该进程。
- RLIMIT_DATA 一个进程data segment的综合,包括了bss,data,heap
- RLIMIT_FSIZE 进程可以创建的文件的最大值,如果超过了
rlim_cur
,那么SIGXFSZ会被发送给该进程。 - RLIMIT_MEMLOCK 进程可以使用
mlock
来将内存上锁的最大值 - RLIMIT_MSGQUEUE 一个进程可以分配,用于message queues的内存空间大小
- RLIMIT_NICE 进程的nice 值,这个主要是和进程调度相关
- RLIMIT_NOFILE 一个进程可以打开的文件数,这里说的就是一个进程可以打开的文件描述符
- RLIMIT_NPROC 进程可以创建的子线程数目
- RLIMIT_SBSIZE 进程可以消耗的socket buffer数量
- RLIMIT_SIGPENDING 进程可以queue的信号数
- RLIMIT_STACK 一个进程栈的大小
- RLIMIT_SWAP 一个进程swap space的大小
- RLIMIT_VMEM 和RLIMIT_AS意思一样,是进程的虚拟内存大小
但是不一定所有的内核都支持这些选项,具体情况看内核而定。