当前位置: 首页 > 科技观察

从零开始学Linux:三级跳转过程详解——从bootloader到操作系统,再到应用程序

时间:2023-03-14 22:11:01 科技观察

目录bootloader跳转到操作系统操作系统跳转到应用程序applicationcallsfunctions在操作系统中是否在x86平台在互联网上,或者在嵌入式平台上,系统的启动一般都会经历一个从bootloader到操作系统再到应用程序的三级跳转过程。相互交接的每一个过程都是我们学习的重点。在本文中,我们仍然以x86平台为例,来看看系统上电后是如何一步步进入应用程序入口地址的。bootloader跳转到操作系统上一篇文章中讨论了bootloader进入保护模式后,在地址0x0001_0000处创建了一个全局描述符表(GDT),并在表中创建了3个段描述符:只要在GDT创建这3个描述符,然后将GDT地址(如:0x0001_0000)设置到GDTR寄存器中,即可进入保护模式工作(将CR0寄存器的bit0设置为1)。在之前的第六篇中,Linux从06:16的结构图开始学习,彻底理解了【代码重定位】的底层原理。我们假设引导加载程序将操作系统程序读取到内存0x0002_0000的位置。这里我们继续用这个例子:文件头的内容和实模式下的不一样。在实模式下,header的布局如下:bootloader将操作系统从硬盘加载到内存后,从header中获取三个段的汇编地址(即:起始地址的偏移量)段相对于文件开头的位置),然后计算段的基地址,最后将段的基地址写回头的三个段地址空间。这样,当操作系统开始执行时,就可以准确地从头中获取每个段的基地址,然后设置相应的段寄存器,从而进入正确的执行上下文。所以在保护模式下,操作系统需要的不是段的基地址,而是每个段的描述符。显然,需要bootloader来完成这个目标,即:在GDT中为操作系统程序中的三个段创建相应的描述符;将每个段的描述符索引号写回操作系统程序头;注:这里描述的只是一个可能的过程,主要用于理解原理。有些系统可以采用不同的实现方式,例如:进入操作系统后,将GDT存放在另一个位置,并在其中重新创建段描述符。操作系统的header布局由于header需要作为接收bootloader向其中写入段索引号的媒介,所以bootloader和OS必须协商,应该写在哪里呢?可以直接按照前面的方法覆盖各个段。汇编地址位置也可以写在其他位置,例如,其中最后3个位置可以用来接收操作系统的三个段索引号。创建操作系统的三个段描述符。bootloader将OS加载到内存后,会解析OS头中的数据,得到各段的基址和界限。虽然头部中并没有明确记录每个段的边界,但是可以根据下一个段的起始地址计算出上一个段的长度。我们可以这样想:现代Linux系统中ELF文件的格式,文件头中记录了每一段的长度,具体分析可以参考这篇文章:Linux系统中编译链接的基石——ELF文件:打开它层层叠叠,从字节码的粒度进行探索。此时bootloader可以利用这几条信息:段基地址、limit、type等属性来构造对应的段描述符(下图中橙色部分):PS:这里的例子只是为了运行而创建的system3个段描述符,实际情况可能有更多的段。OS段描述符建立后,bootloader将这3个段描述符在GDT中的索引号填入OS头中的相应位置:上图中,“入口地址”下面的4本质上不是必须的,加上更多好处,好处如下:从bootloader跳转到操作系统的入口地址时,需要告诉处理器两件事:代码段的索引号;代码入口地址;因此,将入口地址和索引号放在一起有助于引导加载程序直接使用跳转语句进入操作系统的起始标记并开始执行。操作系统跳转到应用程序从现代操作系统的角度来看,这个称呼是错误的:操作系统是应用程序的底层支撑,相当于应用程序的运行时。怎么才叫跳转到应用呢?实际上,我认为表达的意思是:操作系统如何加载和执行一个应用程序。既然是保护模式,操作系统就承担了一个重要的责任:保护系统免受每一个应用程序的恶意破坏!因此,操作系统:将应用程序从硬盘复制到内存后,跳转到应用程序的第一条指令前,需要为应用程序分配内存资源:基址、限制、类型、权限等信息代码段;数据段的基址、限制、类型、权限等信息;基址、栈段限制、类型和权限等信息;以上信息以段描述符的形式在GDT中创建。PS:在现代操作系统中,应用程序都会有自己私有的局部描述符表LDT,里面存放着应用程序自己的段描述符。还记得之前讨论过的下图吗?段寄存器的bit2位TI标志表示需要在GDT中查找段描述符?还是在LDT中找?为方便起见,我们描述的所有段符号都放在GDT中。正如引导加载程序为操作系统创建段描述符一样,操作系统以相同的步骤为应用程序创建每个段描述符。此时的GDT如下:从这张图中,我们已经可以看出一个问题:如果所有应用程序的段描述符都放在全局GDT中,当应用程序结束时,需要更新GDT。系统的代码带来了很多麻烦。因此,更合理的做法应该是放在应用程序的私有LDT中。这个问题将在未来进一步讨论。不管怎样,操作系统启动一个应用程序的整个过程如下:操作系统将应用程序读入内存中的空闲位置;操作系统分析应用程序头部信息;操作系统为应用程序创建每个段描述符,并将索引号写回到头部;跳转到应用程序的入口地址,应用程序从头部获取每个段的索引号,并设置自己的执行上下文(即:设置各种寄存器);应用程序调用操作系统中的函数这里的函数可以理解为系统调用,即操作系统为所有应用程序提供的公共函数。在Linux系统中,系统调用是通过中断来实现的。在中断处理程序中,通过一个寄存器来标识:当前应用程序要调用哪个系统函数,也就是说:每个系统函数都有一个固定的数字编号。回到我们目前讨论的x86处理器,操作系统提供系统函数最简单的方式就是把所有的系统函数放在一个单独的代码段中,并把这个段的索引号和每个系统函数的偏移地址告诉应用程序。这样,应用程序就可以通过这两条信息来调用系统功能了。如果:有两个系统函数os_func1和os_func2,放在一个单独的段中:由于OS中有一个额外的代码段,bootloader需要帮助它在GDT中创建一个额外的段描述符:在应用程序的头部,预留足够大的空间存放各个系统函数的跳转信息(系统函数的段索引号和函数的偏移地址):应用程序有了这些信息后,需要调用os_func1时,直接跳转到对应的段索引号:函数偏移地址,就可以调用这个系统函数了。这里同样会引出两个问题:如果操作系统提供了很多系统功能和很多应用程序,那么操作系统在加载每个应用程序时会不会忙不过来?而应用程序不知道自己应该保留多少空间来存储这些系统函数的跳转信息;在执行系统函数时,代码段和数据段属于操作系统的势力范围,但是栈基地址和栈顶指针仍然属于应用程序Stack,这合理吗?对于第一个问题,Linux通过中断提供了一个统一的调用入口地址,然后用一个寄存器来区分是哪个函数。对于第二个问题,Linux在加载每一个应用程序的时候,都会在内核中建立一个与该应用程序相关的数据结构,并在内核中创建一块内存空间,专门用于:从这个应用程序跳转到内核的栈空间执行代码时使用。但是,仍然存在一些问题,例如:虽然应用程序可以调用操作系统提供的函数,但是操作系统如何保护内核代码?Linux为应用程序构建内部堆栈的底层支持是什么?这就涉及到了x86中复杂权限级别的相关内容。在下一篇文章中,我们将继续探讨这些细节。End从bootloader到操作系统,再到应用程序,这个三级跳最简单的过程讨论到此结束。希望对您有所帮助,谢谢!本文转载自微信公众号「IOT物联网小镇」