双击图标到执行main函数,这之间发生了什么?

我们需要运行一个程序或者软件,双击图标即可完成。不过从你双击到程序的窗口产生的这“短暂”的时间内,这背后发生了什么事?

首先,系统有一个进程监测到了你的双击操作,这个进程就是系统shell,没错,就是资源管理器explorer.exe,不是IE浏览器了,那是另一个进程iexplorer.exe。你可以尝试打开任务管理器将这个进程结束掉,然后桌面的一切元素都没有了,任务栏,图标什么的都消失了。只剩下墙纸一张,此时,右键菜单也不复存在···因为平时负责这些东西的explorer.exe已经被你干掉。要恢复的话,在任务管理器中新建任务,运行explorer.exe即可。

系统shell感知你双击的操作后,取得你双击对象的完整路径,然后最终会调用一个叫做CreateProcessA/CreateProcessW的函数来创建一个新的进程。这个函数是ring3上的API函数,在内核中,即ring0里,与之对应的是NtCreateProcess函数来完成创建进程的任务(此为Windows 2000的做法,XP和Win7后稍有差别)。阅读WRK的源码可以知道,NtCreateProcess对参数做一个简单的验证随即转而调用NtCreateProcessEx。再看NtCreateProcessEx的源码,可以发现,真正完成进程创建的是PspCreateProcessa函数。所以说,你所双击而运行的所有进程都是资源管理器的子进程。

懂点进程知识的应该知道,创建进程的几个关键是建立新的运行环境,即建立4GB虚拟地址空间给进程使用,然后创建进程内核对象,以及内核管理进程的数据结构,这包括内核层的KPROCESS和执行体层的EPROCESS。然后是主线程的创建以及相关内核数据结构,同样是内核层的KTHREAD和执行体层的KTHREAD。主线程创建完成后将两个内核对象的句柄即进程ID和主线程ID封装在PROCESS_INFORMATION结构体中,然后CreateProcess函数返回。创建进程过程完成。这一部分主要是内核来完成,大概过程如此,细节过程这里不展开了。

主线程创建后,就得到了时间片,开始参与系统的线程调度。那么程序从哪里开始执行呢。PE文件中有一个OEP的术语描述的便是这个概念,OEP便是指的程序入口点。所谓入口点便是顾名思义就是主线程最开始执行的地方,许多病毒加壳技术其中一点就是对这个OEP进行处理。现在,我们来使用PEID来看一个程序(VC8.0编译)的OEP如图:

图片

0x00011078乃是RVA(相对虚拟地址),要看在进程地址空间中的起始地址,还得加上PE文件的映射基址,默认为0x00400000,不过,可以通过编译器选项进行调整。不知道也没关系,将程序放入OllyDbg,在内存映射中可以看到程序的映射基址:

图片

由图看到映射基址是0x00400000。那么由前面所述,程序执行的第一条指令应该位于0x00400000  +  0x00011078  =  0x00411078。没错,就是这样。切换到OllyDbg的主窗口,我们发现了,程序确实初始停在了这里,并且这里是一条jmp指令。

图片

我们到Jmp的目的地0x00411800去看看那里是什么东东?

图片

这是什么东西?先卖个关子,总之,这里是程序进来之后真正做的第一件事。

换个思路,我们打开VS2008写一个简单的程序,程序做什么并不重要,我们要看它的启动原理。

图片

注意看调用堆栈窗口,因为我是使用UNICODE编码环境,故 _tmain()就是wmain(),如果是ANSI编码就是最开始学程序时的 main()函数了。以前写程序就想过一个问题,我们写的所有函数都会被我们自己直接或间接调用,但有一个函数例外,那就是 main()函数。我们写了它但从不会去调用它,事实上也不可能去调用它,它是我们写来供操作系统调用的。这个说法很笼统,操作系统调用是什么意思?今天就来弄清这个疑问。从调用堆栈看到,我们的wmain函数是被_tmainCRTStartup函数调用的,这是个什么东西?再往前推是wmainCRTStartup调用的_tmainCRTStartup。这两个函数是做什么的,他们之间有什么关系?双击调用堆栈里的项即可转到对应的源代码,我们可以发现,这两个函数是在crtexe.c文件中实现的。阅读源码可以发现,有四个启动函数分别是:

  • mainCRTStartup()       ANSI  +  控制台程序
  • wmainCRTStartup()       UNICODE  +  控制台程序
  • WinMainCRTStartup()    ANSI  +  GUI程序
  • wWinMainCRTStartup()    UNICODE  +  GUI程序

这一点在 《windows核心编程》 中也有提到。不过我们可以更进一步一窥它们的实现代码:

图片

就这么简单,先调用了 __security_init_cookie(),然后是我们前面看到的 _tmainCRTStartup()

第一个函数是做什么的呢?这个是微软在VS2003后引入的防止缓冲区溢出攻击的技术。简单的说就是在调用函数的时候在栈里安装一个随机的cookie值,这一cookie值在内存的一个地方有备份,函数调用完成后需要检测这个cookie和备份的一不一致,以此来判断有没有栈溢出发生。那么,这个函数就是来初始化这个备份区域的数据的。

然后第二个函数调用 _initterm()进行全局变量、对象初始化。之后,我们可以看到才是真正调用了我们的main()/wmain()/WinMain()/wWinMain()的地方。饶了一大圈,回答了开始的疑问了。

图片

这两个函数是链接器在生产可执行文件的时候给我们链接进来的。

至此,我们来看看第一个函数wmainCRTStartup的汇编代码。如图:

图片

请注意和我们前面使用OllyDbg调试时的图对比:

图片

发现没有?一样的!我们之前留的那个问题的答案想必已经出来了,程序一进来从OEP处执行了jmp指令,这条指令转向了wmainCRTStartup开始了程序真正的起点!

小结:编译生成的exe文件,双击运行后,建立新进程的地址空间,然后主线程开始运行,程序一进来通过jmp指令来到前面列出的四个启动函数,它们进行 __security_init_cookie操作后便调用最终的启动器 _tmainCRTStartup。这个启动器干了几件大事,分别是,使用GetStartupInfo获取进程启动信息,然后使用 _inititem初始化全局变量和对象,最后调用我们main、wmain、WinMain、wWinMain进入我们的程序。。。

说明:这里谈到的是使用VC编译器生成的exe文件形态,如果采用其他编译器,甚至直接采用汇编程序情况就不同了。甚至于 .net平台的托管程序运行于CLR上,则又是另外一回事了。

 

内存分页不就够了?为什么还要分段?

关于内存访问你可能听过分段,分页,还有段页式。

但是为什么要分段?又为什么要分页?

有了分页为什么还要分段?

这就需要看一看历史的发展,知晓历史之后就知道这一切其实都是自然而然的。

这些概念也不是硬塞出来的。

正文

1971 年 11 月 15 日,Intel 推出世界第一块个人微型处理器 4004(4位处理器)。

随后又推出了 8080(8 位处理器)。

那时候访问内存就只有直白自然的想法,用具体物理地址。

所有的内存访问就是通过绝对物理地址去访问的,那时候还没有段的概念。

段的概念是起源于 8086,这个 16 位处理器。

限于当时的技术背景和经济,寄存器只有 16 位,而地址总线是 20 位。

那 16 的位的寄存器如何能访问 20 位的地址?

2 的16 次方如果直着来如何能访问到 2 的 20 次方所表达的数?

直着来是不可能的,因此就需要操作一下。

也就是引入段的概念,让 CPU 通过「段基地址+段内偏移」来访问内存。

有人可能就问你这都只有 16 位,两个 16 位加起来最多只能表示 17 位呀。

你说的没错。

所以再具体一点的计算规则其实是:段基地址左移 4 位(就是乘16)再加上段内偏移,这样得到的就是 20 位的地址。

比如现在的要访问的内存地址是0x05808,那么段基地址可以是 0x0580,偏移量就是 0x0008。

图片

这样内存的寻址空间就扩大到 20 位了。

至于为什么称之为段,其实就是因为寄存器只有 16 位一段只能访问 64 KB,所以需要移动基地址,一段一段的去访问所有的内存空间。

对了,专门为分段而生的寄存器为段寄存器,当时里面直接存放段基地址。

不过渐渐地人们就考虑到安全问题,因为在这个时候程序之间的地址没有隔离,我的程序可以访问你的程序地址,这就很不安全。

于是在 1982 年 80286 推出时,就有了保护模式。

图片

其实就是 CPU 在访问地址的时候做了约束,会判断地址是否在允许的范围内,会判断当前的程序对目的地址是否有访问权限。

搞了个 GDT (全局描述符表)存放所有段描述符。

图片

段寄存器里面也不是直接放段基地址了,而是放了一个叫选择子的东西。

大致可以认为就是段描述符的索引,也就是通过这个索引去找到段描述符,所以叫选择子。

这个选择子里面还有一点属性。

图片

这个 T1 就是标明要去哪个表找,而 RPL 就是特权级了,一共分为四层,0 为最高特权级,3 为最低特权级。

当地址访问时,如果 RPL 的权限低于目标特权级(DPL)时,就会拒绝访问,于是就起到了保护的作用。

所以称之为保护模式,之前的那种没有判断权限的称之为实模式。

图片

当时 80286 的地址总线已经是 24 位,但是用于寻址的通用寄存器还是 16 位,虽然段基地址的位数已经足够访问到 24 位(因为已经放到 GDT 中,且有 24位)。

但是因每次一段只有 64 KB,这样访问就很不方便,需要不断的更换段基地址,于是 80286 很快就被淘汰,换上了 80386。

这是 Intel 第一代 32 位处理器。

图片

除了段寄存器还是 16 位之外,地址总线和寄存器都是 32 位,这就意味着以前为了寻址搞的段机制其实没用了。

因为单单段内偏移就可以访问到 4GB 空间,但是为了向前兼容段机制还是保留了下来,段寄存器还是 16 位是因为够用了,所以没必要扩充。

不过上有政策,下有对策。

虽说段机制保留了,但是咱可以“忽悠”着用,把段基值都设置为 0 ,就用段内偏移地址来访问内存空间就好了。

这其实就意味着每个段的起始地址都是一样的,那就等于不分段了,这就叫平坦模式。

Linux 就是这样实现的。

那为什么要分页?

因为分段粒度太粗了,导致内存碎片大,不利于管理。

当时加载到内存等于一个段都得搞到内存中,而段的范围过大,举个例子。

假设此时你有 200M 内存,此时有 3 个应用在运行,分别是 LOL、chrome、微信。

图片

此时内存中明明有 30MB 的空闲,但是网易云加载不进来,这内存碎片就有点大了。

然后就得把 chrome 先换到磁盘中,然后再让 chrome 加载进来到微信的后面,这样空闲的 30MB 就连续了,于是网易云就能加载到内存中了。

但是这样等于要把 50MB 的内存来个反复横跳,磁盘的访问太慢了,所以效率就很低。

总体而言可以认为分段内存的管理粒度太粗了,所以随着 80386 就出来了个分页管理,一个更加精细化的内存管理方式。

简单地说就是把内存等分成一页一页,每页 4KB 大小,按页为单位来管理内存。

你看按一页一页来管理这样就不用把一段程序都加载进内存,只需要将用到的页加载进内存。

这样内存的利用率就更高了,能同时运行的程序就更多了。

并且由于一页就  4KB, 所以内存交换的性能问题得以缓解,毕竟只要换一定的页,而不需要整个段都换到磁盘中。

对应的还有个虚拟内存的概念。

分页机制构造了一个虚拟内存空间,让每个进程误以为自己掌控所有的内存。

图片

再具体一点就是每个进程都有一个页表,页表中有物理页号和属性,这样寻址的时候通过页表就能利用虚拟地址找到对应的物理地址。

属性用来做权限的一些管理。

图片

就理解为进程想要内存中的任意一个地址都行,没问题,反正背地里偷偷的会换成可以用的物理内存地址。

如果物理内存满了也没事,把不常用的内存页先换到磁盘中,即 swap,腾出空间来就好了,到时候要用再换到内存中。

上面提到的虚拟地址也叫线性地址,简单地说就是通过绕不开的段机制得到线性地址,然后再通过分页机制转化得到物理地址。

最后

至此我们已经知晓了为什么有分段,又有分页,还有段页式。

一开始限于技术和成本所以寄存器的位数不够,因此为了扩大寻址范围搞了个分段访问内存。

而随后技术起来了,位数都扩充了,寄存器其实已经可以访问全部内存空间了,所以分段已经没用了。

但是为了向前兼容还是保留着分段访问的形式,并且随着软件的发展,同时运行各种进程的需求越发强烈。

为了更好的管理内存,提高内存的利用率和内存交互性能引入了分页管理。

所以就变成了先分段,然后再分页的段页式。

当然也可以和 Linux 那样让每一段的基地址都设为 0 ,这样就等于“绕开”了段机制。

至此今天的内容就差不多了,这篇文章没有深入具体的分段和分页的细节,之后再作一篇文章来阐述细节。

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