linux进程地址空间分析及其应用_legend

 

(1)linux进程的虚拟地址传统空间分布

注:为了使内核切换到传统内存布局,执行命令#sysctl -w vm.legacy_va_layout=1(因为Linux 2.6.7及以后版本内核已经默认使用新的内存布局方式了)。

(1.1)虚拟地址空间分类

在32位机器上linux操作系统中的进程的地址空间大小是4G

其中0-3G(0x00000000~0xbfffffff)是用户空间,用户态空间无论进程运行在用户态还是内核态都可以寻址;

3G-4G(0xc0000000~0xffffffff)是内核空间,只有内核态的进程才能寻址;

即:用户空间进程只能用0~3G的用户空间的虚拟地址,不可以用3G-4G的内核空间的虚拟地址;

       内核空间进程可以用0~4G的虚拟地址空间;

(1.2)传统布局方式中虚拟地址空间之用户空间的各个段组成

 

(1.2.1)栈(stack)

栈:存储局部变量、函数参数、返回地址等;

自动变量(临时变量)以及每次函数调用时所需保存的信息都存放在此段中。

每次函数调用时,其返回地址、以及调用者的环境信息(例如某些机器寄存器)都存放在栈中。然后,新被调用的函数在栈上为其自动和临时变量分配存储空间。通过以这种方式使用栈,C函数可以递归调用。

虚拟地址空间传统内存布局中,进程的栈从地址0xc0000000向低地址发展;

(1.2.1.1)栈的原理

栈由编译器自动分配释放,行为类似数据结构中的栈(先进后出)。

进程中的每个线程都有属于自己的栈。向栈中不断压入数据时,若超出其容量就会耗尽栈对应的内存区域,从而触发一个页错误。此时若栈的大小低于堆栈最大值RLIMIT_STACK(通常是8M),则栈会动态增长,程序继续运行。映射的栈区扩展到所需大小后,不再收缩。

(1.2.1.2)栈的大小

Linux中ulimit -s命令可查看和设置堆栈最大值,当程序使用的堆栈超过该值时, 发生栈溢出(Stack Overflow),程序收到一个段错误(Segmentation Fault)。

注意,调高堆栈容量可能会增加内存开销和启动时间。

进程的子线程们有各自的私有栈,可以共享父进程分配的堆内存空间,只有一个主线程的进程也就是有主线程对应的栈,所以栈这个说法通常只有线程栈,并没有明确的进程栈之说,那就更没有所谓的进程栈大小了。

通常使用ulimit -s可以看到”进程对应栈“的大小(现代linux系统下通常是8MB大小),不论在只有一个主线程的进程中,还是包含许多子线程的进程,都是指线程栈大小,默认8MB,

(1.2.1.3)函数调用时栈的行为

栈在函数调用时,首先压入主调函数中下条指令(函数调用语句的下条可执行语句)的地址,然后是函数实参,然后是被调函数的局部变量。本次调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的指令地址,程序由该点继续运行下条可执行语句

 

(1.2.2)内存映射区(memory mapping region)

内存映射区:映射可执行文件用到的动态链接库,;

虚拟地址空间传统内存布局中(linux 2.4内核),内存映射区域从0×40000000向高地址发展,栈所用内存相对较小(通常小于100MB),因此内存映射区域约有2GB左右的映射空间;

在新的布局(在Linux 2.6内核)中,共享库的起始地址被往上移动至更靠近栈区的位置。

(1.2.2.1)内存映射区的背景

内核将硬盘文件的内容直接映射到内存, 任何应用程序都可通过Linux的mmap()系统调用请求这种映射。内存映射是一种方便高效的文件I/O方式, 因而被用于装载动态共享库。

注:用户也可创建匿名内存映射,该映射没有对应的文件, 可用于存放程序数据在 Linux中,若通过malloc()请求一大块内存,C运行库将创建一个匿名内存映射,而不使用堆内存。”大块” 意味着比阈值 MMAP_THRESHOLD还大,缺省为128KB,可通过mallopt()调整。

(1.2.3)堆(heap)

堆:存储动态分配的内存;

通常在堆中进行动态存储分配。由于历史上形成的惯例,堆位于非初始化数据段顶和栈底之间。

进程堆的起始点大于BSS段的结束点,并向高地址发展,因为0×40000000以上已用作内存映射用,因此堆的大小是接近1G,这有点太小了。

(1.2.3.1)堆的原理

堆用于存放进程运行时动态分配的内存段,可动态扩张或缩减。堆中内容是匿名的,不能按名字直接访问,只能通过指针间接访问。当进程调用malloc(C)/new(C++)等函数分配内存时,新分配的内存动态添加到堆上(扩张);当调用free(C)/delete(C++)等函数释放内存时,被释放的内存从堆中剔除(缩减) 。

 分配的堆内存是经过字节对齐的空间,以适合原子操作。堆管理器通过链表管理每个申请的内存,由于堆申请和释放是无序的,最终会产生内存碎片。堆内存一般由应用程序分配释放,回收的内存可供重新使用。若程序员不释放,程序结束时操作系统可能会自动回收。

注:堆不同于数据结构中的”堆”,其行为类似链表。

(1.2.3.2)使用堆的问题

使用堆时经常出现两种问题:

1) 释放或改写仍在使用的内存(“内存破坏/踩内存”);

2)未释放不再使用的内存(“内存泄漏”)。

当释放次数少于申请次数时,可能已造成内存泄漏。泄漏的内存往往比忘记释放的数据结构更大,因为所分配的内存通常会圆整为下个大于申请数量的2的幂次(如申请212B,会圆整为256B)(即申请空间大小往下2的幂来取整)。

(1.2.3.3)堆的总大小

linux2.4中,在有共享库的情况下,留给堆的可用空间还有两处:

一处是从.bss段到0x40000000,约不到1GB的空间;

另一处是从共享库到栈之间的空间,约不到2GB。

这两块空间大小取决于栈、共享库的大小和数量。这样来看,是否应用程序可申请的最大堆空间只有2GB?事实上,这与Linux内核版本有关。在上面给出的进程地址空间经典布局图中,共享库的装载地址为0x40000000,这实际上是Linux kernel 2.6版本之前的情况了。

在2.6版本里,共享库的装载地址已经被挪到靠近栈的位置,即位于0xBFxxxxxx附近,因此,此时的堆范围就不会被共享库分割成2个“碎片”,故kernel 2.6的32位Linux系统中,malloc申请的最大内存理论值在2.9GB左右。

(1.2.3.4)堆和栈的区别

1)管理方式:

栈由编译器自动管理;堆由程序员控制,使用方便,但易产生内存泄露。

2)生长方向:

栈向低地址扩展(即”向下生长”),是连续的内存区域;堆向高地址扩展(即”向上生长”),是不连续的内存区域。这是由于系统用链表来存储空闲内存地址,自然不连续,而链表从低地址向高地址遍历。

3)空间大小:

栈顶地址和栈的最大容量由系统预先规定(通常默认2M或10M);堆的大小则受限于计算机系统中有效的虚拟内存,32位Linux系统中堆内存可达2.9G空间。

4)存储内容:

栈在函数调用时,首先压入主调函数中下条指令(函数调用语句的下条可执行语句)的地址,然后是函数实参,然后是被调函数的局部变量。本次调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的指令地址,程序由该点继续运行下条可执行语句。堆通常在头部用一个字节存放其大小,堆用于存储生存期与函数调用无关的数据,具体内容由程序员安排。

5)分配方式:

栈可静态分配或动态分配。静态分配由编译器完成,如局部变量的分配。动态分配由alloca函数在栈上申请空间,用完后自动释放。堆只能动态分配且手工释放。

6)分配效率:

栈由计算机底层提供支持:分配专门的寄存器存放栈地址,压栈出栈由专门的指令执行,因此效率较高。

堆由函数库提供,机制复杂,效率比栈低得多。Windows系统中VirtualAlloc可直接在进程地址空间中分配一块内存,快速且灵活。

7)分配后系统响应:

只要栈剩余空间大于所申请空间,系统将为程序提供内存,否则报告异常提示栈溢出。
 操作系统为堆维护一个记录空闲内存地址的链表。

当系统收到程序的内存分配申请时,会遍历该链表寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点空间分配给程序。

若无足够大小的空间(可能由于内存碎片太多),有可能调用系统功能去增加程序数据段的内存空间,以便有机会分到足够大小的内存,然后进行返回。大多数系统会在该内存空间首地址处记录本次分配的内存大小,供后续的释放函数(如free/delete)正确释放本内存空间。

此外,由于找到的堆结点大小不一定正好等于申请的大小,系统会自动将多余的部分重新放入空闲链表中。

8)碎片问题

栈不会存在碎片问题,因为栈是先进后出的队列,内存块弹出栈之前,在其上面的后进的栈内容已弹出。

而频繁申请释放操作会造成堆内存空间的不连续,从而造成大量碎片,使程序效率降低。

堆容易造成内存碎片;由于没有专门的系统支持,效率很低;由于可能引发用户态和内核态切换,内存申请的代价更为昂贵。

所以栈在程序中应用最广泛,函数调用也利用栈来完成,调用过程中的参数、返回地址、栈基指针和局部变量等都采用栈的方式存放。所以,建议尽量使用栈,仅在分配大量或大块内存空间时使用堆。

 

(1.2.4)BSS段 (bss segment)

BSS段:未初始化或初值为0的全局变量和静态局部变量;

bss 这一名称来源于早期汇编程序的一个操作,意思是"block started by symbol",在程序开始执行之前,内核将此段初始化为0。

特点是:可读写的,在程序执行之前BSS段会自动清0。所以,未初始的全局变量在程序执行之前已经成0了。

C语言中,未显式初始化的静态分配变量被初始化为0(算术类型)或空指针(指针类型)。由于程序加载时,BSS会被操作系统清零,所以未赋初值或初值为0的全局变量都在BSS中。

BSS段仅为未初始化的静态分配变量预留位置,在目标文件(可执行文件)中并不占据空间,这样可减少目标文件(可执行文件)体积。程序运行(程序即可执行文件被执行)需为变量分配内存空间,故目标文件(可执行文件)必须记录所有未初始化的静态分配变量大小总和(通过start_bss和end_bss地址写入机器代码)。当加载器(loader)加载程序时,将为BSS段分配的内存初始化为0。

注:BSS段不包含数据,仅维护开始和结束地址,以便内存能在运行时被有效地清零。BSS所需的运行时空间由目标文件记录,但BSS并不占用目标文件(可执行文件)内的实际空间,即BSS节段应用程序的二进制映象文件中并不存在。

 

(1.2.5)数据段(data segment)

数据段:存储已初始化且初值非0的全局变量和静态局部变量;数据段属于静态内存分配(静态存储区),可读可写。

(1.2.5.1)数据段和BSS段的区别

1) BSS段不占用目标文件(可执行文件)尺寸,但占用程序内存空间;数据段占用目标文件(可执行文件),也占用程序内存空间。

对于大型数组如int ar0[10000] = {1, 2, 3, ...}和int ar1[10000],ar1放在BSS段,只记录共有10000*4个字节需要初始化为0,而不是像ar0那样记录每个数据1、2、3...,此时BSS为目标文件所节省的磁盘空间相当可观。

 2) 当程序读取数据段的数据时,系统会出发缺页故障,从而分配相应的物理内存;

当程序读取BSS段的数据时,内核会将其转到一个全零页面,不会发生缺页故障,也不会为其分配相应的物理内存。

运行时数据段和BSS段的整个区段通常称为数据区。某些资料中“数据段”指代数据段 + BSS段 + 堆。

 

(1.2.6)文本段/代码段/正文段(text segment)

代码段:可执行代码(函数)、字符串字面值(字符串常量)、只读变量

代码段存放 程序执行代码(即CPU可执行的机器指令),一般C语言执行语句都编译成机器代码保存在代码段。另外,文本段常常是只读的,以防止程序由于意外事故而修改器自身的指令(对该段的写操作将导致段错误)。

另外:通常代码段是可共享的,因此频繁执行的程序只需要在内存中拥有一份拷贝即可。

可执行文件的正文段从0×8048000开始,然后依次是数据段,BSS段;

 

代码段指令根据程序设计流程依次执行:对于顺序指令,只会执行一次(每个进程);若有反复,则需使用跳转指令;若进行递归,则需要借助栈来实现。

代码段指令中包括操作码和操作对象(或对象地址引用)。若操作对象是立即数(具体数值),将直接包含在代码中;若是局部数据,将在栈区分配空间,然后引用该数据地址;若位于BSS段和数据段,同样引用该数据地址。

(1.2.7)保留区(reserved)

保留区位于虚拟地址空间的最低部分,未赋予物理地址。任何对它的引用都是非法的,用于捕捉使用空指针和小整型值指针引用内存的异常情况。

它并不是一个单一的内存区域,而是对地址空间中受到操作系统保护而禁止用户进程访问的地址区域的总称。大多数操作系统中,极小的地址通常都是不允许访问的,如NULL。C语言将无效指针赋值为0也是出于这种考虑,因为0地址上正常情况下不会存放有效的可访问数据。

(1.2.8)分段的好处

进程运行过程中,代码指令根据流程依次执行,只需访问一次(当然跳转和递归可能使代码执行多次);而数据(数据段和BSS段)通常需要访问多次;

因此单独开辟空间以方便访问和节约空间。具体解释如下:

1》当程序被装载后,数据和指令分别映射到两个虚存区域。数据区对于进程而言可读写,而指令区对于进程只读。两区的权限可分别设置为可读写和只读。以防止程序指令被有意或无意地改写。

2》现代CPU具有极为强大的缓存(Cache)体系,程序必须尽量提高缓存命中率。指令区和数据区的分离有利于提高程序的局部性。现代CPU一般数据缓存和指令缓存分离,故程序的指令和数据分开存放有利于提高CPU缓存命中率。

3》当系统中运行多个该程序的副本时,其指令相同,故内存中只须保存一份该程序的指令部分。若系统中运行数百进程,通过共享指令将节省大量空间(尤其对于有动态链接的系统)。其他只读数据如程序里的图标、图片、文本等资源也可共享。而每个副本进程的数据区域不同,它们是进程私有的。

 

(1.3)字符串常量 && const常量 && 栈溢出

(1.3.1)字符串常量

变量、常量分为:局部变量、静态局部变量、全局变量、全局静态变量、字符串常量以及动态申请的内存区;

1)字符串常量存储在代码段

字符串常量,只需要读,不需要修改,代码段不能修改,只能读取,放在代码段可以节约数据段空间,这是编译器优化的;

注:有的文章中说字符串常量则是存储在常量存储区;常量存储区是在代码区和数据区之间的应该;

 

2)字符串常量可共享

一个程序中多次出现相同的字符串常量,其实只保存了一份;

 

(1.3.2)const变量

和const无关,根据变量的性质,决定其存储在哪里;

 

(1.3.3)栈溢出

(1.3.3.1)分类

因为栈通常是从高地址向低地址增长的,因此"栈溢出"分为两种:

超出低地址范围的overrun(上溢)和超出高地址范围的underrun(下溢);

"上溢"主要是由过深的函数调用引起(比如递归调用):

而"下溢"则会出现在数组/字符串越界的时候,即高地址越界,比如超过数组的大小;(数组的内存分布是从低地址到高地址的)。

 

(1.3.3.2)栈溢出的检测

(1)上溢检测之填充魔数

对于那些不使用虚拟内存机制的RTOS,通常采用的做法是在stack创建之初就填充满固定的字符(比如0x5a5a5a5a),如果发生了"上溢",那么stack末端(最低地址处)的填充字符则有可能会被更改,这样操作系统就可以在发生线程切换的时候,通过检测线程栈的末端字符(比如最后16个字节)是否被更改来判断是否有"上溢"发生,当然这会增加一些线程切换的开销。之所以说是“有可能”,是因为末端的那段字节可能正好被跳过,所以这种检测方法并不是100%有效的。

分析:
在线程运行过程中,栈空间的使用率有起有落,但没有被覆盖过的"0x5a5a5a5a"一定是栈未曾达到过的区域,由此我们可以计算出栈的最大使用率。如果这个最大使用率已经逼近栈的极限(最低地址),那么我们就应该适当增加该线程的栈空间大小,避免在更极端的情况下出现"栈溢出"。

(2)下溢检测之数组后随机数是否被修改;

至于"下溢",则可以在将函数的返回地址压栈的时候,加上一个随机产生的整数,如果出现了数组越界,那么这个整数将被修改,这样在函数返回的时候,就可以通过检测这个整数是否被修改,来判断是否有"下溢"发生。这个随机的整数被称为"canary",它的原意是金丝雀,这种鸟对危险气体的敏感度超过人类,所以过去煤矿工人往往会带着金丝雀下井,如果金丝雀死了,矿工便知道井下有危险气体,需要撤离。

只需要在gcc编译的时候,加入"-fstack-protector"选项即可。一个函数对应一个stack frame,每个stack frame都需要一个canary,这会消耗掉一部分的栈空间。此外,由于每次函数返回时都需要检测canary,代码的整执行时间也势必会增加。

 

 

(1.4)查看进程的内存地址空间分布情况

1)cat /proc/Pid/maps 可以查看当前进程的虚拟地址的情况;

2)size 可执行文件

#size a.out
   text       data        bss        dec        hex    filename
5415541     130120    6697952    12243613     bad29d   a.out

 

(1.5)32位机器上查看进程地址空间的范例

范例如下:

#include <stdio.h>
#include <stdlib.h>
int main(int argc, char* argv[])
{
    int first = 0;
    int *p0 = malloc(1024);
    int *p1 = malloc(1024 * 1024);
    int *p2 = malloc(512 * 1024 * 1024 );
    int *p3 = malloc(1024 * 1024 * 1024 );
    printf("main=%p print=%p\n", main, printf);
    printf("first=%p\n", &first);
    printf("p0=%p p1=%p p2=%p p3=%p\n", p0, p1, p2, p3);
    sleep(10);
    return 0;
}

结果如下:
#root@slack:~#./a.out &
[6] 9528
main=0x80483e4 print=0x8048300
first=0xbfd00b9c
p0=0x804a008 p1=0x4018d008 p2=0x4028e008 p3=0x6028f008
 
#root@slack:~#cat /proc/9528/maps
08048000-08049000 r-xp 00000000 08:01 140878     /root/a.out
08049000-0804a000 rw-p 00000000 08:01 140878     /root/a.out
0804a000-0806b000 rw-p 00000000 00:00 0          [heap]
40000000-4001d000 r-xp 00000000 08:01 931801     /lib/ld-2.13.so
4001d000-4001e000 r--p 0001c000 08:01 931801     /lib/ld-2.13.so
4001e000-4001f000 rw-p 0001d000 08:01 931801     /lib/ld-2.13.so
4001f000-40022000 rw-p 00000000 00:00 0
40029000-40185000 r-xp 00000000 08:01 931779     /lib/libc-2.13.so
40185000-40186000 ---p 0015c000 08:01 931779     /lib/libc-2.13.so
40186000-40188000 r--p 0015c000 08:01 931779     /lib/libc-2.13.so
40188000-40189000 rw-p 0015e000 08:01 931779     /lib/libc-2.13.so
40189000-a0290000 rw-p 00000000 00:00 0
bf841000-bf862000 rw-p 00000000 00:00 0          [stack]
ffffe000-fffff000 r-xp 00000000 00:00 0          [vdso]

说明:

1、因为main()函数和printf()函数位于文本段中,而文本段是从0×08048000开始的,所以符合表中所述;

2、first是第一个临时变量,临时变量存在于栈中;由于在first之前还有一些环境变量,它的值并非0xbfffffff,而是0xbfcd1264,这是正常的。

3、p0是在堆中分配的,其地址小于0×4000 0000,这也是正常的。

4、但p1和p2也是在堆中分配的,而其地址竟大于0×4000 0000,与表一描述不符。

原因在于:运行时堆的位置与内存管理算法相关,也就是与malloc的实现相关。在glibc实现的内存管理算法中,malloc小块内存是在小于0×4000 0000的内存中分配的,通过brk/sbrk不断向上扩展;而分配大块内存,malloc直接通过系统调用mmap实现,分配得到的地址在文件映射区,所以其地址大于0×40000000。

所以,p0是在堆上分配的,而p1~p3则是通过mmap实现的,这表现在maps文件的倒数第三行(40189000-a0290000)。

5. maps内容分析:

08048000-08049000 代码段
08049000-0804a000 数据段
(注意本程序无BSS段)
0804a000-0806b000 堆
40000000-40189000 内存映射区,本程序映射了ld和libc动态链接库
40189000-a0290000 内存映射区,malloc用其为p1~p3分配内存
bf841000-bf862000 栈
ffffe000-fffff000 内核为我们映射的系统调用入口代码;

---------

(2)linux进程的虚拟地址新空间分布

鉴于以上传统内存布局的限制,Linux 2.6.7及以后版本已经默认使用另一种新的内存布局方式,如下图所示:

比如,传统布局方式中,32位机器上,堆的大小最大接近1G的限制;

为了使用此新的内存布局,执行命令#sysctl -w vm.legacy_va_layout=0;新编译运行程序并查看其输出及maps文件内容:

(2.1)新布局方式中虚拟地址空间之用户空间组成

mm_struct 内存段

用户空间地址还是由代码段(文本段)、数据段,bss段,堆,内存映射区,栈组成;

(2.2)区别

栈顶和栈之间、栈和内存映射区之间,堆和BSS段之间都有一个随机的offset,每次运行程序时的值都不一样;Linux通过对栈、内存映射段、堆的起始地址加上随机偏移量来打乱布局,以免恶意程序通过计算访问栈、库函数等地址。

注:如果需要,当然也可以让程序的栈和映射区域从一个固定位置开始,只需要设置全局变量randomize_va_space值为0即可(默认值为 1)。

 

(2.3)范例:

进程范例如上;

(2.3.1)进程的maps说明:

#root@slack:~#./a.out &
[6] 9529
main=0x80483e4 print=0x8048300
first=0xbff18e6c
p0=0x804a008 p1=0xb7554008 p2=0x97553008 p3=0x57552008
 
#root@slack:~#cat /proc/9529/maps
08048000-08049000 r-xp 00000000 08:01 140882     /root/a.out
08049000-0804a000 rw-p 00000000 08:01 140882     /root/a.out
0804a000-0806b000 rw-p 00000000 00:00 0          [heap]
575c8000-b76cc000 rw-p 00000000 00:00 0
b76cc000-b7828000 r-xp 00000000 08:01 931779     /lib/libc-2.13.so
b7828000-b7829000 ---p 0015c000 08:01 931779     /lib/libc-2.13.so
b7829000-b782b000 r--p 0015c000 08:01 931779     /lib/libc-2.13.so
b782b000-b782c000 rw-p 0015e000 08:01 931779     /lib/libc-2.13.so
b782c000-b782f000 rw-p 00000000 00:00 0
b7837000-b7839000 rw-p 00000000 00:00 0
b7839000-b7856000 r-xp 00000000 08:01 931801     /lib/ld-2.13.so
b7856000-b7857000 r--p 0001c000 08:01 931801     /lib/ld-2.13.so
b7857000-b7858000 rw-p 0001d000 08:01 931801     /lib/ld-2.13.so
bff4e000-bff6f000 rw-p 00000000 00:00 0          [stack]
ffffe000-fffff000 r-xp 00000000 00:00 0          [vdso]

说明:

对比上一次maps文件内容,发现现在的内存映射区域已经从b7857000开始了,并且向下发展。p1~p3现在位于575c8000-b76cc000区域内。

(2.3.2)offset影响说明

说明:由于offset每次执行程序都是不一样的;如下,再执行一次;

#root@slack:~#./a.out &
[6] 9564
main=0x80483e4 print=0x8048300
first=0xbfec32cc
p0=0x804a008 p1=0xb7654008 p2=0x97653008 p3=0x57652008
 
#root@slack:~#cat /proc/9564/maps
08048000-08049000 r-xp 00000000 08:01 140882     /root/a.out
08049000-0804a000 rw-p 00000000 08:01 140882     /root/a.out
0804a000-0806b000 rw-p 00000000 00:00 0          [heap]
574ea000-b75ee000 rw-p 00000000 00:00 0
b75ee000-b774a000 r-xp 00000000 08:01 931779     /lib/libc-2.13.so
b774a000-b774b000 ---p 0015c000 08:01 931779     /lib/libc-2.13.so
b774b000-b774d000 r--p 0015c000 08:01 931779     /lib/libc-2.13.so
b774d000-b774e000 rw-p 0015e000 08:01 931779     /lib/libc-2.13.so
b774e000-b7751000 rw-p 00000000 00:00 0
b7759000-b775b000 rw-p 00000000 00:00 0
b775b000-b7778000 r-xp 00000000 08:01 931801     /lib/ld-2.13.so
b7778000-b7779000 r--p 0001c000 08:01 931801     /lib/ld-2.13.so
b7779000-b777a000 rw-p 0001d000 08:01 931801     /lib/ld-2.13.so
bfa0c000-bfa2d000 rw-p 00000000 00:00 0          [stack]
ffffe000-fffff000 r-xp 00000000 00:00 0          [vdso]

和之前一次执行的输出相比较,发现程序输出中first变量的地址已经变了,而且maps文件中栈和内存映射区域的地址也变了。

------

(3)多线程进程的虚拟地址空间分布

(3.1)多线程范例

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
void* thread_proc(void* param)
{
    int  first = 0;
    int* p0 = malloc(1024);
    int* p1 = malloc(1024 * 1024);
    printf("(0x%x): first=%p/n",    pthread_self(), &first);
    printf("(0x%x): p0=%p p1=%p /n", pthread_self(), p0, p1);
    return 0;
}
 
#define N 5
int main(int argc, char* argv[])
{
    int first = 0;
    int i= 0;
    void* ret = NULL;
    pthread_t tid[N] = {0};
    printf("first=%p/n", &first);
    for(i = 0; i < N; i++)
    {
        pthread_create(tid+i, NULL, thread_proc, NULL);
    }
    for(i = 0; i < N; i++)
    {
        pthread_join(tid[i], &ret);
    }
    return 0;
}

说明:

first=0xbfbaf648
(0xb671db70): first=0xb671d384
(0xb671db70): p0=0x804a248 p1=0xb4e1d008
(0xb6f1db70): first=0xb6f1d384
(0xb6f1db70): p0=0x804a650 p1=0xb49fd008
(0xb771db70): first=0xb771d384
(0xb771db70): p0=0xb4d00468 p1=0xb4bff008
(0xb5f1db70): first=0xb5f1d384
(0xb5f1db70): p0=0xb4d00900 p1=0xb4afe008
(0xb571db70): first=0xb571d384
(0xb571db70): p0=0x804aa58 p1=0xb48fc008

主线程与第一个线程的栈之间的距离:0xbfbaf648 – 0xb771d384 = 132M

第一个线程与第二个线程的栈之间的距离:0xb771d384 – 0xb6f1d384= 8M

其它几个线程的栈之间距离均为8M。

也就是说,主线程的栈空间最大为132M,而普通线程的栈空间仅为8M,超这个范围就会造成栈溢出(后果很严重)。

----------------------

(4)进程的虚拟地址和真实物理地址

linux操作系统每个进程的地址空间都是独立的,其实这里的独立说得是物理空间上的独立。虚拟地址通过页表(Page Table)映射到物理内存,页表由操作系统维护并被处理器引用。

execve(2)负责为进程代码段和数据段建立映射,真正将代码段和数据段的内容读入内存是由系统的缺页异常处理程序按需完成的。另外,execve(2)还会将BSS段清零。

 

那相同的虚拟地址,不同的物理地址,他们之间是怎样联系起来的呢?

在linux操作系统中,每个进程都通过一个task_struct的结构体描叙,每个进程的地址空间都通过一个mm_struct描叙,c语言中的每个段空间都通过vm_area_struct表示,他们关系如下 :

当一个程序被执行时,该程序的内容必须被放到进程的虚拟地址空间,对于可执行程序的共享库也是如此。可执行程序并非真正读到物理内存中,而只是链接到进程的虚拟内存中。

当一个可执行程序映射到进程虚拟地址空间时,一组vm_area_struct数据结构将被产生。每个vm_area_struct数据结构是或是初始化的数据,以及未初始化的数据等。

(4.1)进程加载数据的流程

在进程创建的过程中,程序内容被映射到进程的虚拟内存空间。为了让一个很大的程序在有限的物理内存空间运行,我们可以把这个程序的开始部分先加载到物理内存空间运行,因为操作系统处理的是进程的虚拟地址,如果在进行虚拟到物理地址的转换工程中,发现物理地址不存在时,这个时候就会发生缺页异常(nopage),接着操作系统就会把磁盘上还没有加载到内存中的数据加载到物理内存中,对应的进程页表进行更新。

(4.1.1)物理内存不足时

如果一个进程想将一个虚拟页装入物理内存,而又没有可使用的空闲物理页,操作系统就必须淘汰物理内存中的其他页来为此页腾出空间。

1)  如果从物理内存中被淘汰的页来自于一个映像或数据文件,并且还没有被写过,则该页不必保存,它可以丢掉。如果有进程在需要该页时就可以把它从映像或数据文件中取回内存。

2)如果该页被修改过,操作系统必须保留该页的内容以便晚些时候在被访问。这种页称为"脏(dirty)页",当它被从内存中删除时,将被保存在一个称为交换文件的特殊文件中(磁盘上)。

(4.1.2) 内存淘汰算法

相对于处理器和物理内存的速度,访问交换文件要很长时间,操作系统必须在将页写到磁盘以及再次使用时取回到内存的问题上花费心机。linux使用"最近最少使用(Least Recently Used ,LRU)"页面调度技巧来公平地选择哪个页可以从系统中删除。这种设计系统中每个页都有一个"年龄",年龄随页面被访问而改变。页面被访问越多它越年轻;被访问越少越老。年老的页是用于交换的最佳候选页。

 

参见:https://blog.csdn.net/freeelinux/article/details/53782986

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章