我们都知道Linux下一个C程序的生成分为4个阶段:预编译(.i) --> 编译(.s) --> 汇编(.o) --> 链接(可执行文件)
在预处理阶段,它会修改原始的C程序,将源程序翻译成一个ASCII码的以.i结尾的中间文件。它会读取系统头文件stdio.h的内容,并把它直接插入到程序文本中。
在编译阶段,编译器将以.i为扩展名的文本文件翻译成以.s作为扩展名的文本文件,它包含一个汇编语言程序。
在汇编阶段,汇编器将以.s为扩展名的文本文件翻译成机器语言指令,将结果存在以.o为扩展名的二进制目标文件中。
在链接阶段,会将文件中调用的库函数合并到上一步生成的二进制目标文件中。比如若调用了printf函数,在链接阶段,它会存在于一个名为printf.o的单独预编译好的目标文件中,这个文件会与我们上一步的.o文件合并成一个文件,即上一步的生成的.o文件中,它是一个可执行目标文件,shell调用操作系统中一个叫做加载器的函数,它拷贝可执行文件中的代码和数据到内存,由系统执行。(PS:任何UNIX程序都可以通过调用execve函数来调用加载器,加载器将可执行文件的代码和数据从磁盘拷贝到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序)
那么生成的这个可执行文件里都有什么内容呢?我们大概可以知道里面至少包含了编译后的机器指令代码,还有程序中的数据,除了 这些以外,文件中还包括链接时需要的一些信息,包括符号表,调试信息等。一般可执行文件将这些信息按不同的属性,以“段”的形式 存储。在UNIX中,段表示一个二进制文件的相关的内容块。当加载器运行后,它创建如下图所示的一个C程序的典型的存储空间结构:
可以看到,从低地址到高地址,分别是正文段、初始化的数据段、未初始化的数据段、堆、栈、命令行参数和环境变量。
正文段 又叫代码段,程序源代码编译后的 机器指令 就被放在代码段,还有 字符串常量 和 const修饰的变量 ,也都存在这个区域。代码段是可 共享的 ,所以即便是频繁执行的程序,在存储器中也只有一个副本。 当系统中运行着多个该程序的副本时,它们的指令都是一样的,所以 内存中只需要保存一份该程序的指令部分,因此, 代码段通常是 只读 的,以防止程序由于意外而修改其指令。
初始化数据段 包含了程序中已明确赋值的全局变量和静态变量以及它们的值。
未初始化数据段, 通常将此称段为bss段(block started by symbol),与已初始化数据段相对应,它保存的是未初始化的全局变量和静态变量。
注意:未初始化段中存的是未初始化的全局变量和静态变量在实际内存中所需要的空间大小,它只是为它们预留了位置,并没有实质的内容,所以也不占空间。也就是说, 未初始化段的内容并不存放在磁盘程序文件中,因为内核在程序开始执行前将它们全部初始化为0或NULL,这样也许可以把它们放入初始化数据段,但因为它们都是0,所以为它们在初始化数据段分配空间并存放0是非常没有必要的。当这个内存区进入程序的地址空间后,包括初始化数据段的和bss段的整个区段此时统称为数据区。
目标文件格式中区分初始化和未初始化变量是为了空间效率:因为在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。
堆 位于未初始化数据段与栈之间,这部分内存是由低地址向高地址分配。我们通常在堆中进行 动态存储分配 ,即malloc申请的内存,这段空间会一直存在,直至我们使用free释放,如果没有主动释放,在进程运行结束时也会被系统释放掉。而内存泄露就是指我们我们没有释放堆空间,从而导致堆不断增长,使内存空间不断减少。
栈是从高地址向低地址分配的,栈中存放的是局部变量,以及每次函数调用时,返回的地址以及调用者的环境信息。栈也经常被叫做栈帧,因为栈以帧为单位,当程序调用函数的时侯,栈会向下增长一帧,帧中存储被调用函数的参数和局部变量,以及该函数的返回地址。此时,位于栈最下方的帧和全局变量一起,构成了当前的环境。一般来说,你只允许使用位于栈最下方的帧,当该函数又调用另一个函数的时候,栈又会向下增长一帧,此时控制权会转移到新的函数中,当函数返回时,会从栈中弹出,并根据帧中记录的地址信息,返回到调用它的函数中,然后删除帧。直到最后main()函数返回,其返回地址为空,进程结束。所以,递归太多层的话可能会让栈溢出。
命令行参数 :ls -a中,ls是可执行程序,-a就是命令行参数。
我们都知道,每个程序被运行起来后,它将拥有自己独立的线性空间,这个线性空间由cpu的位数决定,比如32位硬件平台决定了线性地址空间的寻 址空间为0~2^32-1,即0x00000000~0xFFFFFFFF,也就是4GB大小。如果每个进程都有4GB,那我们的内存根本就不够用。其实,这4GB并不是真正的内存空间,它是虚拟内存空间,顾名思义,之所以称为虚拟内存,是和系统中的物理内存相对而言的,它是操作系统内核为了对进程地址空间进行管理而设计的一个逻辑意义上的内存空间概念。我们程序中的指针其实都是这个虚拟内存空间中的地址。虚拟内存是将系统硬盘空间和系统实际内存联合在一起供进程使用,给进程提供了一个比内存大的多的虚拟空间。在程序运行时,只需要把虚拟内存空间的一小部分通过页映射模式,经过MMU部件转换映射到物理内存即可。
那么这4GB的空间是不是就由该进程随意使用呢?遗憾的是,不可以。因为操作系统需要监控进程,进程只能使用那些操作系统分配给它们的地址,如果访问未经允许的空间,那么操作系统会捕获到这些访问,将进程的这种访问当作非法操作,强制结束进程。Linux操作系统将进程的4G虚拟空间分配为1:3的结构,1G是操作系统本身,剩下的3G是留给进程使用的。所以在一个进程的眼里,只有操作系统和它自己,就像整个计算机都被它“承包”了。
我们可以看到,从0x8048000到0xc0000000的空间才是我们上文说到的进程的线性地址空间。可以发现这张图跟上一张图相比较,在堆和栈中间多了一部分叫作共享库和mmap内存映射区的内容。我们的程序运行后如果是动态链接的C语言运行时库的话,共享库会存在图示的动态库映射区。共享库是一个模块,在运行时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。无论使用C语言运行时库的程序无论有多少,运行时库的代码在内存里只会有一份。对于不同的程序,进行地址映射即可。比如很多程序都会用到的printf,函数共享库 printf.o 固定在某个物理内存位置上,让许多进程映射共享。这样就减少了每个可执行文件的长度。mmap是个系统函数,可以将一个文件映射到某个物理内存位置,让许多进程共享。共享库使得可执行文件中不再需要包含公用的库函数,在程序第一次执行或者第一次调用某个库函数时,用动态链接的方法将程序与共享库函数相链接。
-static参数是阻止gcc使用共享库,下图可明显看出不使用共享库的可执行文件比使用了共享库的大很多。
总是提到映射,可计算机到底是怎样将虚拟地址空间映射到实实在在的物理内存上的呢?这就要提到内存分段和分页模式了。这个转换过程有操作系统和CPU共同完成. 操作系统为CPU设置好页表。CPU通过MMU单元进行地址转换。简单的说,就是人为的把线性地址空间划分为一个一个4k的(几乎所有的PC上的操作都使用4KB大小的页)逻辑页,把内存页以同样的方法等分为固定大小的物理页。当程序执行时,cpu(中的MMU组件)会自动查找逻辑页到到物理页映射关系,从而将数据加载进内存。更加具体的过程可以参照这两篇博文
[保护模式汇编系列之四] 段页式内存管理每个程序员都应该了解的“虚拟内存”知识
总结一下进程的建立过程分为三步:
1.创建一个独立的虚拟地址空间
2.建立虚拟空间与可执行文件的映射关系
3.操作系统将CPU的控制权转交给进程,启动运行。
本文若有任何错误之处,恳请指正。