轩辕问了一个问题,居然都翻车了~
大家好,我是轩辕,最近干货有点少呀,我要检讨一下了。
因为我在忙两件事,一件是忙着校招面试,另一件嘛,先卖个关子,过段时间告诉你们。
说到面试,操作系统知识是必考内容,基本上对所有人,我都问了一个同一个问题:进程地址空间里有什么?
这个问题展开可以聊的东西非常多,从编程语言到可执行文件,从堆栈空间到虚拟内存,可以让我快速了解候选人这部分的知识储备。
而实际上,我发现对于这个问题,基本上没有人能够说得特别清楚,各种是似而非的回答:
“代码段”
“静态存储区”
“动态数据区”
“堆栈区”
一系列书本气十足的说法,不一而足。
确实,很多同学手里那本谭浩强的《C程序设计》告诉我们,内存中不就是这样的吗?难道书上写错了?
书上写的也不算错,但它只是提出了一个非常非常简单的内存模型,实际的操作系统上的进程空间中,远比这复杂100倍。
虚拟内存
众所周知,现代操作系统采用虚拟内存的方式管理内存,虽然计算机上的内存条只有几个G,但却为每个进程营造出了一个完整的地址空间,加起来远超内存条容量的大小。
这个地址空间,在32位操作系统上是4GB大小,这是32位CPU在正常模式下能寻址的最大范围,Linux和Windows均是如此。
至于64位系统的情况则变得更加复杂,寻址范围更大。可以参考这两篇文章:
Linux 64位地址空间:https://www.cnblogs.com/yizhanwillsucceed/p/13578076.html Windows 64位地址空间:https://www.cnblogs.com/xuanyuan/p/5260871.html
4GB,也就是4*1024*1024*1024 = 4294967294个字节。如果一个字节用一个小格子表示,那进程的地址空间就是这么多个小格子排列组成:
不过,这样子看起来有些麻烦,我们一般不这样画图,而是以4个字节为一组,画出地址空间来:
这篇文章要讨论的就是,在上面这张图中,到底有哪些内容。我们一步步填充进去,最后在文章的末尾你将会看到一个进程空间内容的全貌。
内核地址空间
首先,在进程地址空间中占据最大篇幅的当属操作系统内核空间部分。
内核空间的部分,所有进程共享,在不同的进程中,这部分内存空间映射的内存页面是一样的。
注:其实上面这句话也不是完全正确,如果你研究过操作系统内核就会知道不同进程的内核空间部分也不是完全一致的。一个最简单的例子就是在Windows操作系统上,不同用户登录同一台计算机后会产生会话session隔离,不同用户启动的进程位于各自的session中,而不同session在内核空间部分页面的映射会有差异。
内核空间部分一般位于进程地址空间中高地址区域,至于大小,在Windows 32位系统上是2GB,在Linux上是1GB。
可执行文件
抛开了系统内核空间部分,接下来来看一下用户态地址空间中有哪些东西?
第一个非常重要的区域就是可执行文件所在的区域。
我们编写的程序,最终是转换成对应操作系统上可执行文件在运行,在Linux上是ELF格式,在Windows上是PE格式,比如exe。
程序运行的时候,加载器会将目标可执行文件加载到进程的地址空间中。
映射后的可执行程序所占大小可能会比文件的真实尺寸更大,这是由于内存页面对齐的原因,导致可执行文件中的不同节会通过填充0来对齐,从而占据了更大的空间。
你可能会问:那我写的Java程序、Python脚本程序呢?它们的进程空间中没有可执行文件吧?
Java程序是通过JVM虚拟机在翻译执行,主进程就是JVM的可执行文件,执行Java程序的时候,会先启动EXE/ELF格式的虚拟机,再由虚拟机加载java字节码文件执行。
Python是解释执行的脚本语言,执行Python脚本的时候,也是先启动Python的解释器程序,这也是一个EXE/ELF格式的可执行文件,再由解释器解释执行Python脚本。
其他脚本语言也差不多类似。
总之,所有程序的执行,都会有一个核心的可执行文件。
不管是Windows的PE格式,还是Linux的ELF格式,一般都会包含这几个部分:
代码区:主要是程序编译后的CPU指令,所有的函数代码编译后的指令都在这里。
数据区:主要是程序中定义的全局变量,static变量。
常量区:咱们程序中会用到常量字符串编译后就存在这里。
可执行文件区域在进程地址空间哪个位置呢?
在早期的操作系统中,一般是在一个固定地址,比如Windows上,是在0x400000地址。但因为安全性的原因,后期的操作系统都开启了随机加载的功能,每一次程序启动加载到地址空间中的地址都可能不一样。
动态链接库
程序需要运行,光靠自己的可执行文件是不够的,还需要依赖一些动态链接库。在Windows上是DLL文件,在Linux上是so文件。
即便你编写的程序只是一个单独的可执行程序,没有指定依赖任何动态库,它仍然需要依赖操作系统的一系列动态链接库才能工作。
程序需要依靠这些系统动态链接库才能使用操作系统提供的系统调用,这是用户态进程和操作系统之间的一个中间层。
在Windows上,可以通过ProcessExplore
,看到一个进程中加载了非常多的动态链接库。
在Linux上,可以通过pmap
命令查看一个进程中的动态链接库。
和可执行文件的加载类似,现代操作系统加载动态链接库的地址一般都不固定,而是随机的。
线程栈
栈是程序执行过程中非常重要的一个东西,程序执行时的局部变量,函数调用时传参、返回地址这些东西都是存储在栈中的。
很多同学都知道程序执行会用到栈,但又经常弄错,比如有两个很多人容易弄错的点。
1、堆栈
很多人经常把“堆栈”两字挂在嘴边,但“堆”和“栈”其实是两个东西。
2、栈不止一个
栈不是程序所有,而是线程所有,如果一个程序运行后开启了多个线程,则每一个线程都会有一个自己的栈。
所有线程的栈都在进程的地址空间中,具体位置是由操作系统内核在创建线程的时候确定的,用户程序无法控制。
进程堆
说到栈,那就必然离不开它的好基友——堆。
堆大家应该不会陌生,C语言中malloc、C++中的new都是在堆区域中分配内存。
堆是一大块内存,由C和C++语言的运行时库Runtime初始化时向系统申请的,后续调用malloc和new的时候再去堆中分配。
不同于前面介绍的部分,堆这个东西是语言层面的东西,理论上完全可能存在一个没有动态内存分配的语言写出的程序,进程地址空间中就没有堆。
不过这样貌似也不行,因为Windows和Linux的动态库都是用C语言写成的,它们也会用到堆。
除了栈可能有多个,堆其实也是可以有多个。
文件映射
除了栈和堆,我们在编程中,还经常用到共享内存、内存文件映射、或者直接使用VirtualAlloc/mmap
分配内存等操作,这些操作,是直接在进程地址空间中的空余部分,划出的一块单独区域。他们也是地址空间中经常出现的部分。
最后推荐一个神器:VMMap
,用来查看进程地址空间中所有内容的占用情况。
在软件开发过程中,常用来定位程序问题,观察进程内存空间使用情况,比如观察线程栈暴涨,则程序有可能陷入了死循环,无限递归,如果堆内存暴涨,则很有可能有内存泄露。
在软件漏洞安全研究中,也常用来分析程序被攻击的原理。
如果你需要这个神器,可以添加我的微信,我发给你。