一个成功的男人背后,至少有一个伟大的女人;一个不成功的男人,至少有一双手。
而一个C程序,无论成功不成功,它的背后一定有一个操作系统,一个shell,一套工具链。
世界本就不公平。隐藏在显而易见的事实背后的,你若能看透,便可以站在对自己公平的那一端。
1、进程地址空间
一个进程一旦建立,就会自认为占有4G内存(X86_32),这个内存被称作虚拟内存,也就是进程的地址空间。在Linux下,进程地址空间的布局大致如下图所示,其中的用户空间大致由这些部分组成:
这些段,反映到ELF格式的目标文件(object file)中,就又可能由许多不同的节(section)组成。节这个东西更加细致复杂,暂且不表。
代码段
保存的是可执行指令,通常是只读的,防止指令被程序自身修改。但程序是无法防止被人为修改,否则哪来那么多的修改器。Vim就可以直接编辑二进制文件,指令的机器码任意修改。
存储实例:
push %ebp
movl %esp, %ebp
初始化数据段
保存的是已初始化了的全局变量和静态变量,它可以进一步划分为只读区域和可读写区域。
存储实例:
Char *string = “hello world”(全局)
“hello world”在只读区域,指针string在可读写区域
而Char string[] = “hello world”(全局)
就只存储string在读写区域中。因为string已被分配存储空间。
Static int class = 6 (全局/局部)
全局的容易理解。局部静态变量的意义,在于函数调用完后,其占用的存储单元也不被释放。如此便不可以存放到栈中,而又已被初始化,那么存放到这个段自然是合理的。
未初始化数据段
通常称为bss段,名字来自于“block started by symbol”—由符号开始的块。存放于此段的变量,在程序执行之前就被初始化为0或Null指针。
注意,未赋值的指针会被初始化为空指针!如果程序中定义的指针没有初始化,而后面又赋值于它,那么在Linux下会引发“段错误”。
栈
这就是个狗皮膏药,用处大,却难搞。函数调用时,对栈的操作基本上由编译器完成。函数一旦被调用,就会生成一个栈帧(stack frame),栈帧的范围由两个 “栈指针”寄存器%ebp、%esp限定。
存储实例:
Caller的返回地址;
Caller的寄存器信息,如%ebp,%eax;
Callee自身的局部变量
堆
用户手动分配内存的区域,malloc和free,谁用谁知道。另外,共享库和动态加载的模块,也存放于堆中。
那么问题来了,实际编译好的目标文件是否真的是这样的呢?
以一个非常简单的C程序—memlayout.c—作为例程:
int main() { return 0; }
用GCC分别编译生成memlayout.o和memlayout文件,并查看它们的内存布局:
[root@localhost ~]# size memlayout.o text data bss dec hex filename 66 0 0 66 42 memlayout.o [root@localhost ~]# size memlayout text data bss dec hex filename 1055 272 4 1331 533 memlayout
这个程序没有定义任何的变量,由memlayout.o可以看出,data、bss为0是符合预期的。
段依然还是那些段,可最终的可执行文件如何却把它们都搞大了?
我并没有调用exit,为何程序自动流产?
男人的直觉也很准的,特别是程序出轨的时候。凭男人的直觉,我想,一定是编译器(实质是链接器)在某个地方插了一脚。
这也是一个细琐的问题,先做简要说明,容以后再表。
2、程序的生命周期
编译好的C程序是躺在磁盘里的,这时只能叫文件。加载到内存并撒腿狂奔的时候,才叫进程。老师们也告诉过我们,一个运行的“hello world”也是一个进程。所以一定要先有一个进程环境,程序才有狂奔的空间。我的家里没有草原,所以董小姐没有理我。
一个C程序的前世今生大概是这样的:
也就是说,在写好的main函数之前,编译器添加了一段C启动代码,是C程序执行之前的准备工作;在main函数之后,编译器至少添加(调用)了_exit()来保证进程的正确终止。这也是为什么,中间目标文件和最终可执行文件size相差悬殊,用户空间的程序总会终结的原因。