开发人员有必要理解CE系统启动过程。首先回顾一下系统怎样建立起来的。微软工具链生成.exe和.dll文件。这些文件都包含了Portable Executable格式,简称PE格式。它们的结构都是一样的:
1、 是一种common object文件格式的扩展
2、 有导入、导出表
3、 头部有入口点,是开始执行的地方。
操作系统都是由编译器生成的,一个exe(nk.exe)不会连接到任何外部的库或者DLL。当这个文件执行时候,系统中还没有任何东西。Exe需要具有一个已知的头部(PE),来决定程序入口点。因而CPU能知道从那里开始执行。
另外,PE文件可以按序排列,所以可以XIP(execute in place)。这意味着,加入文件的数据放在某一个虚拟地址,不需要改变情况下,程序代码可以访问和使用这个地址中的数据。例如,使用微软的链接器,把内核代码文件放到虚拟地址0x80000000。那么程序入口地址会放到exe的文件中,执行时候就能依靠地址,跳到真正的代码段执行。如果函数foo是放在0x80001000的,foo中又调用了函数bar,bar地址在0x80005000。那么会有一段结构直接保存在代码中,去调用地址0x80005000。如下,虚线是函数代码的分割线。
假如内核的exe文件改变的地址,bar函数也会跟着移动。Foo函数的调用地址,现在指向不对了,需要指向新的地址。
上图是内核exe文件从0x80000000移到0x80050000,foo函数内的调用地址就不对了。
进程当加载到真正的地址空间后,修改exe和dll文件的动作,称为——修正。普通的exe文件允许程序修正地址的记录,不修正前地址都是错误的。所以CE内核exe在加载到特定地址前,会做地址修正。ROMIMAGE程序在生成系统镜像文件前(nk.bin),会修正内核的exe和某些dll的地址。
最后,我们得到一个修正后的exe——nk.exe,系统内核的一部分。这个exe和其他exe、dll一样,有程序入口点。执行前,系统的bootloader会把镜像文件放到正确的地址中。下面我们来看看bootloader如何在镜像中,找到nk.exe和它的入口点。
Nk.exe是CE6内核的唯一部分,包含了OAL和系统启动的模板流程。这个流程主要的部分,操作系统内核的所有进程、线程和内存管理放到kernel.dll中。这个dll也是经过ROMIMAGE修正过运行的虚拟地址了。这就是说至少有2个可执行模块,我们需要找到存放的地址和入口点。入口点的地址在exe和dll中,但在镜像中怎样找到exe和dll呢。
CE镜像有一个重要的结构体,通过ROMIMAGE生成的,叫Table Of Contents,简称TOC。TOC保存了系统的指针和数据。在镜像文件开头附近,有一个标志,内容是CECE(0x44424442)。这个标志后面就存放着TOC的偏移值,那么bootloader和其他程序可以通过TOC找到镜像相关的信息。这个偏移值在OAL中定义了一个全局指针pTOC来保存,ROMIMAGE可以使用这个指针来找到和填充TOC的内容。编译时候,nk.exe的pTOC变量是0xFFFFFFFF,当生产nk.bin时候,ROMIMAGE会做以下处理:
1、 加载nk.exe,然后修正
2、 生产TOC内容,找到镜像文件存放TOC的地方
3、 找到pTOC指针,确认是指向0xFFFFFFFF
4、 把pTOC的指针,执行真正TOC所在位置
那么当nk.exe开始运行时候,就知道在那里能找到TOC。再根据TOC的内容,找到镜像其他部分。
ROMIMAGE通过bib文件,获取系统镜像的地址分布。Config.bib有2个重要部分,RAMIMAGE和RAM。下面是例子:
NK 0x80070000 0x02000000 RAMIMAGE
RAM 0x82070000 0x01E7F000 RAM
这是告诉ROMIMAGE该怎样做,系统镜像在地址0x80070000,可读写的内存地址在0x82070000。根据这些信息,就能知道那里可以加载模块运行,然后建立TOC内容。为了让内核运行起来,TOC也会存放这RAM的信息。下图是内存中内核放置的示意图:
操作系统要运行,还需要bootloader做以下工作:
1、 把镜像放到内存的正确地方
2、 找到CECE标记
3、 使用TOC指针,找到TOC
4、 在TOC中,找到nk.exe的地址
5、 扫描exe文件,找到入口点(通过PE)
6、 跳到入口点地址,开始执行
Nk.exe运行时:
1、 建立和打开虚拟内存映射
2、 收集kernel.dll运行需要的信息
3、 使用pTOC找到kernel.dll
4、 找到kernel.dll入口点
5、 把收集到的信息,传入kernel.dll的入口点
不同的处理器在启动过程不太相同,ARM和X86的CPU有不同的虚拟内存管理器(MMU)。但是大体的流程是相同的。
当nk.exe运行前,系统有些条件是一致的:
1、 所有的cache是关闭的
2、 在config.bib配置的RAMIMAGE和RAM段,物理上可访问的,可读的。
3、 虚拟地址是预先确定好的
4、RAM无需额外操作,就可以写入。
以上是任何系统启动前的先决条件。内核运行是独立的,不会依赖运行前的bootloader配置的虚拟内存。当内核运行时,nk.exe首先是计算OEMAddressTable中的物理地址。OEMAddressTable是静态定义了虚拟地址和物理地址的映射。Nk.exe知道:
1、 所属的虚拟内存
2、 所属的物理内存
3、 OEMAddressTable的虚拟地址空间
一个简单公式,计算内核OEMAddressTable的物理地址:
NK:hysicalBase + (NK::Virtual OEMAddressTable – NK::Virtual Base) è NK Physical OEMAddressTable
OEMAddressTable的格式:
<region virtual start> <region physical start> <region size in MB>
<region virtual start> <region physical start> <region size in MB>
...
根据以上表格的信息,nk.exe可以通过MMU设置虚拟内存的映射关系。虚拟内存使用OEMAddressTable中的数据,并且使其生效,然后Nk.exe转换为可执行的虚拟地址。
注意的是,所有在RAM中的模块都还没初始化。不管RAM初始化后的数据是多少,初始化数据都还保存在镜像文件中(data段的数据)。对数据的读写,必须要把镜像的真实数据内容,复制到RAM中,才允许使用。那么nk.exe如何知道数据段在镜像那个位置呢,通过TOC。
TOC不但列出了镜像中,各个模块的开始地址,还描述了各个模块的读写指针。从系统镜像复制到RAM的动作称为——copy entries。Nk.exe在访问读写变量之前,需要copy entries到RAM中。指针pTOC就必须是有效的,如何保证pTOC是有效呢。pTOC是只读变量,在镜像文件创建时,ROMIMAGE就会把pTOC写入。保存pTOC的介质不是RAM,在使用pTOC前,不需要复制到RAM中。Nk.exe有函数把所有的相关信息复制到RAM,称为KernelRelocate。这是一个简单的过程,只是遍历一个表格内的结构体,然后把虚拟内存内容复制出来。当这个动作结束后,nk.exe的变量才能像其他程序一样,可以被正常的访问。
这时,我们才有真正可以工作的程序,像之前提到那样可以执行、调用函数、读写内存。这还不是线程、进程或任何系统的对象,但是所有东西都放到已知的地方,在系统高端地址开始执行时候,可以使用到。
虚拟内存有很大的弹性,CE保留了一些虚拟地址段,只给系统内核使用。大小为4K页面的虚拟内存,在0xFFFE0000以上的高端地址空间中,保留起来。内核映射了一些物理地址到这些页面,用来保存全局动态的数据。它们一部分用来MMU的内存映射,一部分保留用来做内核态和中断的堆栈,最重要是一部分保留作为Kernel Data Page。根据内核版本,保留不同的页面大小。Nk.exe直接可以访问和初始化这些页面。
Nk.exe的3个重要数据
1、 pTOC的备份
2、 OEMAddressTable的地址
3、OEMInitGolbals函数的地址
前2项内容保存在Kernel Data Page中,任何代码知道这个页的地址,就可以找到系统镜像的内容和基本的虚拟映射关系。最后一项信息比较特殊,nk.exe使用一次后就传递给kernel.dll了。放置方式如下:
现在Kernel Data Page被初始化了,虚拟内存也激活了,可以跳入到微软的kernel.dll中入口了。记住,我们通过TOC找到镜像的kernel.dll,同时也可以找到其他模块的入口。即使Nk.exe知道如何把Kernel Data Page放到虚拟内存中,但kernel.dll不知道确认它自己运行位置。因此,我们需要把Kernel Data Page的虚拟地址传递给kernel.dll的入口。
跳转完成后,开始执行内核代码。入口点获取了Kernel Data Page的地址,因此通过TOC可以获取任何系统镜像的信息。内核开始做一些准备工作和临界区,确保它是Kernel Data Page当前唯一使用者。
Kernel.dll有一个静态函数和数据表,编译时候作为dll的一个静态数据结构体,称为NKGlobals。由于kernel.dll被ROMIMAGE修正过,运行在特定的地址中,所以运行时NKGlobals的指针也会被修改成正确的地址。这些函数指针中,如SetLastError()和NKwvsprintfW(),内核允许它们直接调用。但内核并不清楚这些函数其实在kernel.dll中,接着内核会被告知这部分的函数和数据,其实是在kernel.dll中。
Kernel.dll通过OEMInitGlobals,把NKGlobals的地址传回nk.exe。流程如下:
如上,OEMInitGlobals函数保存了一个指向OMEGlobals结构体的指针。这个结构体是内核能够其他功能函数的关键。Kernel.dll模块确立后,可以被任何一种结构的处理器运行(如x86、ARM等)。Nk.exe提取了这类处理器的特有部分,提供给平台,来确保系统的运行(xcale或OAMP,它们与ARM有些微差别)。OMEGlobals的组成与NKGlobals类似,有以下成员:
- PFN_InitDebugSerial(), PFN_WriteDebugByte(), PFN_ReadDebugByte()
- PFN_SetRealTime(), PFN_GetRealTime(), PFN_SetAlarmTime()
- PFN_Ioctl()
这些函数指针指向OEM提供的函数,如nk.exe中的OEMInitDebugSerial 和OEMIoctl。这里会列出许多函数,因此kernel.dll能知道特定处理器环境下的功能函数。
OEMInitGlobals完成后, kernel.dll对特定环境下的工作环境就能确定下来。它能知道那里有内存,内存怎样映射,镜像每个模块的地址等。Nk.exe也有个指针能获取这些信息,因此2个模块通过握手方式,在动态连接环境下进行简单的数据交互。
Nk.exe和kernel.dll在没有进程、线程和内核服务的情况下,完成了所有该做的事情。为让系统继续运行下去,kernel.dll还需要做3件事情:
1、 处理器特定的设置
2、 处理器本地的设置
3、 平台特殊的设置
处理器特定的设置,是由kernel.dll调用特定的处理设置函数,如ARM芯片的是ARMSetup函数,X86是X86Setup函数。虽然处理器特定设置的代码较多,但是在一个线程中执行的,没有进程存在。因此这个操作有些限制:
1、 设置很难申请页表和保留的虚拟内存给内核页表
2、 在页表中根系cache信息
3、 刷新TLB
4、 配置处理器的总线和协处理器
处理器特定设置代码中,还要设置Interlocked函数,以便nk.exe可以调用它。即使是运行在比较早的阶段,CE需要在多个线程之间做同步工作。其中使用频率最高的就是Interlocked函数,它有多个功能函数组成,包括InterlockedCompareExchange。InterlockedCompareExchange函数流程是:
1、 读取本地内存,设置到寄存器中(R1)
2、 与其他寄存器(R2)读取值,做比较
3、 如果2个寄存器(R1和R2)的值不相等,则退出
4、 将另外一个寄存器值(R3)写回到本来内存中
这4步维系着线程之间的同步。但这4步之间可能会被中断,要保证处理器执行函数时候的正确,那么就要确保能之间操作到硬件,硬件的中断必须关闭。可这又引出一个问题,由于用户态进程没有权限关闭中断,每次在线程之间同步,就要通过内核去关闭中断,是比较低效的。
为了提升效率,整个系统只能有一个地方让InterlockedCompareExchange运行。4个步骤的代码都放到Kernel Data Page的一个特定位置中,nk.exe和kernel.dll(其他能访问到Kernel Data Pag的进程)就能调用到函数,那么所有的操作都在同一个位置上执行。这样的设置后,函数需要是可从头执行的(意味着即使线程切换后,函数不是由现场恢复,而是从头再开始运行),为什么要这样呢?
首先我们来看看操作系统中,线程切换的情况:
1、 正在运行的线程有特殊的操作(sleep、wait等)
2、 线程的时间片轮用完(timer中断里面做判断),其他线程开始运行
3、 中断产生了,一个高优先级的线程要开始运行
后2种情况是一样的,中断产生导致线程的切换。由于同步函数1-4步之间都有可能被中断打断,产生线程切换。这就需要我们执行函数时候,保持原子性操作。
为了让1-4步的操作是原子性,每次有中断产生时候,一个边界检查会判断CPU是否在执行1-4步的操作代码中。如果判断到,CPU是在1-4步执行过程中,产生的中断。那么一个运行指针会被重置到函数步骤1的位置,那么这个操作可以从头再来一次。为让中断代码可以检查到CPU是否正运行在1-4步中,那这段代码必须放到Kernel Data Page中。当Interlocked函数放到Kernel Data Page后,nk.exe和kernel.dll都能使用它,做多线程的同步工作了。
回到正题,kernel.dll执行的下一步,即是处理器本地的设置。这里第一步就是设置KITL.dll,用来调试系统内核的工具。
KITL(Kernel Independent Transport Layer),是设备内核和桌面PB之间进行数据通讯的方式。通常KITL由内核提供,做数据编码和传输的工作。BSP(Board Support Package)不需要关心设备与桌面PC之间的数据内容,只需用完成数据的正确通讯就可。使用KITL的通讯载体,可以是RS232串口、USB、网卡等串行设备。
处理器本地设置的另外一些操作,包括
1、 初始化系统内核的调试输出(OEMGlobals 结构内的OEMInitDebugSerial函数)
2、 输出调试字符串(Windows CE Kernel Version xxxx)
3、 为处理器选择可用的配置
当处理器本地设置完成后,就是平台的特殊设置步骤了。由于是OEM和板子相关代码,因此存放在nk.exe中。初始化时候,内核通过OEMGlobals的OEMInit函数进行。OEMInit是初始化板子相关的设置,另外还会启动KITL。
如果KITL是Nk.exe包含的,nk.exe就能之间访问。如果KITL是dll的形式,那么内核调试时候,在处理器本地设置阶段,就要加载这个dll。无论那种形式,内核都会使用OEMInit来启动KITL。
OEMInit完毕后,内核开始允许进程、线程运行了。接着同步cache,如果还没准备好运行,就进入处理器服务模式。这里会做一些操作,包括:
1、 枚举有效的内存(OEMEnumExtensionDRAM)
2、 为内核初始化临界区
3、 初始化堆
4、 初始化进程和线程的结构体
5、 多线程模式启动前的其他操作
当所有线程的初始化完毕后,内核准备调度第一个线程。这个线程在kernel.dll中,叫SystemStartupFunc。为了让线程运行起来,内核设置成没有其他线程切换,第一个线程是有效线程,然后才调用线程调度代码。线程调度先查看有效的线程,选择下一个运行的线程。这时,系统只有一个线程手动的配置运行起来,然后再一个个的切换其他线程。
SystemStartupFunc在cache刷新后,就可以被执行了。为了顺利的运行线程,还有以下工作:
1、 初始化系统加载器
2、 初始化页池
3、 初始化系统logging
4、 初始化系统debugger
SystemStartupFunc通过OEM函数来完成初始化动作,这个函数在OEMGlobals中,通过OEMIoctl传入OEM_HAL_POSTINIT。这会告诉nk.exe,开始的准备工作都完成了,可以进行进程、线程的调度了。
从OEMIoctl退出后,SystemStartupFunc继续会初始化系统的消息队列、watchdogs,然后创建电源管理和文件系统的线程。因此,操作系统其他高端内容开始被执行。SystemStartupFunc最后一步会创建其他线程,来执行RunAppsAtStartup。这个函数会创建第一个用户态进程。
至此,内核、电源管理、文件系统等都被创建和执行了,应用程序也开始就绪运行,系统注册表也可以使用了,系统内核启动完毕。
以上是CE6的内核启动过程,CE5的启动过程也非常类似。