前言
本篇文章是自己在学习xv6操作系统内核时,发现自己对进程在内存中的布局知识点上还是有一些混淆不清,所以在这里做一些补充整理。
一、内存堆栈模型
简要地分类,进程在内存中可以分为4个部分,从低地址到高地址分别是:
-
程序代码区:该区域在程序运行时存放程序的二进制代码。 -
全局数据区:该区域主要存放全局变量,静态变量和各种常量。 -
堆:堆用于在程序运行时动态分配内存,比如new一个新的对象,或者malloc一个新数组,就是在堆中分配存储空间的,一般由程序员手动控制,但也容易造成内存泄漏。 -
栈:该区域主要存放程序运行时函数的参数与局部变量等,当程序员完成某个软件的编译时,一般该软件对应内存栈的大小也就由编译器确定了,但直到程序真正运行时,操作系统才会在内存栈中为其分配空间。
具体细分还可以见下图: 下面做一些常见的内存名词解释:
-
栈:一种操作受限制的线性表,一般元素只能先进后出,在内存模型中栈是一块按后进先出规则访问的存储区域,用来实现中断嵌套和子程序调用的参数和返回断点。 -
堆:堆在数据结构中表示一种树结构,但在内存模型中是表示一块存储区域,对该存储区域的访问是任意的,没有后进先出的要求,堆主要用来为动态变量分配存储空间。 -
ESP:ESP(Extended stack pointer)扩展栈指针寄存器,是指针寄存器的一种,用于存放函数栈顶指针。 -
EBP:EBP(Extended Base Pointer),扩展基址指针寄存器,也被称为帧指针寄存器,用于存放函数栈底指针。 -
栈帧:每个函数的每次调用,都会在内存栈中开辟一段存储空间给这个函数使用,这段栈存储空间就叫做栈帧,包含着调用时的各种信息,包括传递的参数,返回地址,局部变量。 -
内存泄漏:内存泄漏(Memory Leak)是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。 -
调用规范:也称调用约定,决定了函数调用时,实参压栈、退栈及堆栈释放方式,以及编译后的函数命名规范。
值得注意的是,每一个进程的程序栈一定是从高地址向低地址延伸,相反地,用于动态分配的堆是从低地址向高地址进行延伸。也就是说内存可详细分为5个部分,从低地址到高地址分别是:
-
只读区(read only):存放二进制代码与常量,在linux系统中常常是ELF文件格式。 -
可读写区(read write):存放全局变量和静态变量。 -
堆(heap):同前述。 -
共享库的内存映像区(memory mapped region for shared libraries):共享库可以是各种标准库,也可以是自定义DLL等,通过链接完成,链接可以发生在编译阶段,也可以发生在程序加载阶段或者是程序运行时(run-time)的动态链接。 -
栈(stack):同前述。
具体分配时,还要注意字符串的分配,系统会创建一个字符串常量存储在只读区,然后再在栈中创建一个指针变量(局部变量)指向该字符串常量,如下图所示(这张图是高地址在下,低地址在上,与之前的图相反):
char s[]="Hello";
下面是一个小的代码示例来演示常见的变量存放位置:
int a = 0;
char *p1;
int main(int argc,char *argv[])
{
int b;
char str[] = "abc";
char *p2;
char *p3 = "6666";
static int c =0;
p1 = (char *)malloc(6);
p2 = (char *)malloc(6);
return 0;
}
二、系统栈和用户栈
操作系统从某个角度也可以近似看做是一个进程,只不过这个进程负责管理其它进程而已,所以操作系统也会有自己的内存空间,和用户的内存空间相互隔离,其中一部分叫做系统栈,也叫内核栈,作用主要有两个:
-
保存中断现场信息 -
保存操作系统的子程序调用信息
然后一个进程也有用户态和内核态的的区分,当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核运行态(内核态)。此时处理器处于特权级最高的内核代码中执行。
当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。即此时处理器在特权级最低的用户代码中运行。
当进程处于内核态时,执行的内核代码会使用当前进程的内核栈,而当进程处于用户态时,进程本身的代码会使用当前进程的用户栈。
每个进程都有自己的内核栈和用户栈,用户栈的地址空间会随着进程不同而不同,而对于所有的进程其系统栈的地址空间则不会改变。原因很简单,进程运行时,只能运行在一个操作系统上,不同的进程运行在同一个操作系统上,也就共享一个内核空间了。
三、函数调用时的内存栈分配
现代的应用程序都运行在一个虚拟内存空间里,在32位的系统里,这个内存空间拥有4GB的寻址能力。现代的应用程序可以直接使用32位的地址进行寻址,整个内存是一个统一的地址空间,用户可以使用一个32位的指针访问任意内存位置。在进程的不同地址区间上有着不同的地位,Windows在默认情况下会将高地址的2GB空间分配给内核,而Linux默认将高地址的1GB空间分配给内核。 高级语言写出的程序经过编译链接,最终会变成可执行文件。当可执行文件被装载运行后,就成了所谓的进程。
在经典的操作系统里,栈总是向下增长的。栈顶由esp寄存器定位。压栈操作使栈顶的地址减小,弹出操作使栈顶地址增大。
代码示例
int main(void)
{
foo(1,2,3) ;
return 0 ;
}
当方法main需要调用foo时,它的标准行为:
-
在main方法的调用栈中,将 foo的参数从右向左依次push到栈中。 -
把main方法当前指令的下一条指令地址 (即return address)push到栈中。(隐藏在call指令中) -
使用call指令调用目标函数体foo。 请注意,以上3步都处于main的调用栈,其中ebp保存其栈底,而esp保存其栈顶。 接下来,在foo函数中: -
push ebp: 将ebp的当前值push到栈中,即保存ebp。 -
mov ebp,esp: 将esp的值赋给ebp,则意味着进入了foo方法的调用栈。 -
[可选]sub esp, XXX: 在栈上分配XXX字节的临时空间。(抬高栈顶)(编译器根据函数中的局部变量的总大小确定临时空间的大小) -
[可选]push XXX: 保存(push)一些寄存器的值。
注意:push寄存器的值,这一操作,可以在分配临时空间之前,也可在其之后。
而在foo方法调用完毕后,便执行前面阶段的逆操作:
1、保存返回值: 通常将函数的返回值保存在寄存器eax中。 2、[可选]恢复(pop)一些寄存器的值。 3、mov esp,ebp: 恢复esp同时回收局部变量空间。(恢复原栈顶) 4、pop ebp: 将栈顶的值赋给ebp,即恢复main调用栈的栈底。(恢复原栈底) 5、ret: 从栈顶获得之前保留的return address,并跳转到此位置继续执行。
main方法先将foo方法所需的参数压入栈中,然后再改变ebp,进入foo方法的调用栈。
因此,如果在foo方法中需要访问那些参数,则需要根据当前ebp中的值,再向高地址偏移后进行访问——因为高地址才是main方法的调用栈。
也就是说,地址ebp + 8存放了foo方法的第1个参数,地址ebp + 12存放了foo方法的第2个参数,以此类推。那么地址ebp + 4存放了什么呢?它存放的是return address,即foo方法返回后,需要继续执行下去的main方法指令的地址。
【注意】 若需在函数中保存被调函数保存寄存器(如ESI、EDI),则编译器在保存EBP值时进行保存,或延迟保存直到局部变量空间被分配。在栈帧中并未为被调函数保存寄存器的空间指定标准的存储位置。
【注:几个相关的寄存器】 (1)esp:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。 (2)ebp:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。(ebp在当前栈帧内位置固定,故函数中对大部分数据的访问都基于ebp进行) (3)eip:指令寄存器(extended instruction pointer),其内存放着一个指针,该指针永远指向下一条等待执行的指令地址。 可以说如果控制了EIP寄存器的内容,就控制了进程——我们让eip指向哪里,CPU就会去执行哪里的指令。eip可被jmp、call和ret等指令隐含地改变(事实上它一直都在改变)(ret指令就是把当前栈顶保存的返回值地址 弹到eip中)
函数栈帧的大小并不固定,一般与其对应函数的局部变量多少有关。函数运行过程中,其栈帧大小也是在不停变化的。
四、brk(), sbrk() 用法详解
头文件以及函数原型
#include <unistd.h>
int brk(void *addr);
void *sbrk(intptr_t increment);
一个程序一旦编译好后,text segment ,data segment 和 bss segment 是确定下来的,这也可以通过 objdump 观察到。下面通过一个程序来测试这个 program break 是不是在 bss segment 结束那里:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/time.h>
#include <sys/resource.h>
int bssvar;
int main(void)
{
char *pmem;
long heap_gap_bss;
printf ("end of bss section:%p\n", (long)&bssvar + 4);
pmem = (char *)malloc(32);
if (pmem == NULL) {
perror("malloc");
exit (EXIT_FAILURE);
}
printf ("pmem:%p\n", pmem);
heap_gap_bss = (long)pmem - (long)&bssvar - 4;
printf ("1-gap between heap and bss:%lu\n", heap_gap_bss);
free (pmem);
sbrk(32);
pmem = (char *)malloc(32);
if (pmem == NULL) {
perror("malloc");
exit (EXIT_FAILURE);
}
printf ("pmem:%p\n", pmem);
heap_gap_bss = (long)pmem - (long)&bssvar - 4;
printf ("2-gap between heap and bss:%lu\n", heap_gap_bss);
free(pmem);
return 0;
}
end of bss section:0x559957beb018
pmem:0x559958b4c6b0
1-gap between heap and bss:16127640
pmem:0x559958b4c6b0
2-gap between heap and bss:16127640
end of bss section:0x558889fde018
pmem:0x55888b9086b0
1-gap between heap and bss:26388120
pmem:0x55888b9086b0
2-gap between heap and bss:26388120
从上面的输出中,可以发现几点:
- bss 段一旦在在程序编译好后,它的地址就已经规定下来。
- 一般及简单的情况下,使用 malloc() 申请的内存,释放后,仍然归还回原处,再次申请同样大小的内存区时,还是从第 1 次那里获得。
- bss segment 结束处和堆的开始处的空隙大小,并不因为 sbrk() 的调整而改变,也就是说明了 program break 不是调整堆头部。
所以,man 手册里所说的 “program break 是在未初始化数据段终止处后的第一个位置” ,不能将这个位置理解为堆头部。这时,可以猜想应该是在堆尾部,也就是堆增长方向的最前方。下面用程序进行检验:
当 sbrk() 中的参数为 0 时,我们可以找到 program break 的位置。那么根据这一点,检查一下每次在程序加载时,系统给堆的分配是不是等同大小的:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/time.h>
#include <sys/resource.h>
int main(void)
{
void *tret;
char *pmem;
pmem = (char *)malloc(32);
if (pmem == NULL) {
perror("malloc");
exit (EXIT_FAILURE);
}
printf ("pmem:%p\n", pmem);
tret = sbrk(0);
if (tret != (void *)-1)
printf ("heap size on each load: %lu\n", (long)tret - (long)pmem);
return 0;
}
运行上面的程序 3 次:
(base) zengsy@LAPTOP-MVNUN6DN:~/OS$ ./brk
pmem:0x55df4dbef2a0
heap size on each load: 134496
(base) zengsy@LAPTOP-MVNUN6DN:~/OS$ ./brk
pmem:0x558b0e23c2a0
heap size on each load: 134496
(base) zengsy@LAPTOP-MVNUN6DN:~/OS$ ./brk
pmem:0x561b16bd22a0
heap size on each load: 134496
从输出可以看到,虽然堆的头部地址在每次程序加载后都不一样,但是每次加载后,堆的大小默认分配是一致的。
下面就来验证 sbrk() 改变的 program break 位置在堆的增长方向处:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/time.h>
#include <sys/resource.h>
int main(void)
{
void *tret;
char *pmem;
int i;
long sbrkret;
pmem = (char *)malloc(32);
if (pmem == NULL) {
perror("malloc");
exit (EXIT_FAILURE);
}
printf ("pmem:%p\n", pmem);
long offset = sbrk(0) - (long)pmem;
for (i = 0; i < 65; i++) {
sbrk(1);
printf ("%d\n", sbrk(0) - (long)pmem - offset);
}
free(pmem);
return 0;
}
(base) zengsy@LAPTOP-MVNUN6DN:~/OS$ ./brk
pmem:0x5602f9e3e2a0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
从输出看到,sbrk(1) 每次让堆往栈的方向增加 1 个字节的大小空间。 而 brk() 这个函数的参数是一个地址,假如你已经知道了堆的起始地址,还有堆的大小,那么你就可以据此修改 brk() 中的地址参数已达到调整堆的目的。
实际上,在应用程序中,基本不直接使用这两个函数,取而代之的是 malloc() 一类函数,这一类库函数的执行效率会更高。还需要注意一点,当使用 malloc() 分配过大的空间,比如超出 0x20ff8 这个常数(在我的系统(Fedora15)上是这样,别的系统可能会有变)时,malloc 不再从堆中分配空间,而是使用 mmap() 这个系统调用从映射区寻找可用的内存空间。
|