1 嵌入式Linux系统启动时序
目前,嵌入式系统的硬件平台和应用方向区别很大,但总体启动流程一致的。这里的系统启动是指从用户执行上电/复位操作,到系统开始提供用户可接收的服务水平所需要的过程。典型的上电/复位时序如表1所列。
表1 嵌入式Linux系统启动时序
2 Linux快速启动方法
目前,一些Linux的发行版本已经对启动速度进行了优化。如果利用标准Linux进行开发,则启动速度的提高主要是通过内核配置和各种补丁包来实现的。下面分析快速启动的一些关键技术。
2.1 Firmware和Bootloader阶段
目标板一旦确定,Firmware运行的时间就无法改变了,Flash和RAM的读写速度也就随之确定了。但
如果复位时能够绕过Firmware和Bootloader,即允许运行中的内核加载以及运行另一个内核,可以缩短启动的时间。典型的实现有Kexec,它有2个组件,即用户空间组件kexectools和内核补丁。另外一种办法是在内核命令行中加入reboot=soft数,同样可以跳过Firmware,但是缺点在于无法从用户空间调用。
对于正常启动,可以选择速度比较快的Bootloader,并对内核进行小型化处理;还可以使用高速的映像复制技术(如DMA2RAM),从而缩短复制的时间。为了缩短解压消耗的时间,可寻求比较高效的压缩算法。但一般情况下,压缩比越高,算法越复杂,解压速度就越慢,从而造成复制时间(与压缩比成反比)和解压时间(一般与压缩比成正比)之间的矛盾。
2.2 内核阶段
内核初始化时要对RealTime Clock (RTC)进行同步。此过程要占用1s的时间,可去掉以节约时间,但这样CPU会与正确的时间有1s的偏差,如果关机时CPU时钟又要保存在RTC中,偏差就会不断累积。但对于使用外部时钟源进行同步的系统,则可安全地跳过这个阶段。
Preset LPJ可以用来缩短每次启动时调用calibrate_delay()来校准loops_per_jiffy消耗的时间。这个时间开销与CPU频率无关,在典型的嵌入式硬件环境下会消耗300ms左右。LPJ值对于固定硬件平台应该是一致的,可以只计算一次,在后续的启动中就可以在启动参数中强制指定LPJ值,而跳过实际的计算过程。具体方法是:在正常启动后记录下内核启动信息中的"Calibrating Delay"数值,在启动参数中以"lpj=xxxxxx"的形式强制指定。
启动过程默认打开控制台输出启动消息,但是控制台尤其是基于帧缓冲的控制台会减慢启动速度。因此在嵌入式Linux产品中,将启动过程中的控制台设为静默状态,方法是在内核启动参数中加入"quiet"。
设备搜索和驱动安装是比较耗时的操作,因此要在编译内核时确定需要安装哪些驱动模块,以免系统搜索那些根本不存在的设备,尤其是多余的IDE设备。对于启动时暂时不用安装的设备,尽量将驱动编译成模块,在以后空闲时或者使用设备时加载,而不是全部放在启动阶段。
2.3 用户空间阶段
传统Linux的初始化脚本是由bash执行的,在内核引导后启动init进程(/sbin/init)。它使用一个ASCII文件(/etc/inittab)来改变运行级别,这个文件中又会调用RCSript,由RCSript查找/etc/rc.d/rc5.d/并启动相应链接指向的系统服务。
消费电子类Linux系统需要启用图形界面等必要的服务,未经优化的系统在这个过程中会默认启动很多根本用不到或者当前用不到的系统服务,这一部分会花去较大的时间开销。最简单的优化办法就是根据实际需要,通过改写服务配置文件定制系统服务。另外,init脚本的执行是串行的,在脚本量大时会导致引导过程非常,因此可以考虑并行运行各种服务以加快启动的速度。现在已经出现了一些初始化程序来替代init进程,下面介绍initng和upstart。
initng(init nextgerneration)能够并行启动服务从而快速完成初始化工作。initng认为满足了依赖关系的服务就可以启动。在从外存加载一个脚本或等待硬件设备启动的同时,可以运行另一个脚本来启动别的服务,使系统在CPU 和 I/O 之间实现较好的平衡。作为一个基于依赖关系的解决方案,initng使用自己的初始化脚本集,它们对服务和守护进程的依赖性进行了编码。如果某个服务依赖(使用 need关键字定义)于其他服务,则要保证启动时它所依赖的所有服务均可用。无依赖关系的服务立即并行启动,具有依赖关系的服务则要等待以安全启动。
upstart与 initng的区别在于: upstart基于事件,任务/服务的启动/停止都取决于它所等待的事件是否发生。upstart对事件的定义非常灵活,分为3类:edge (simple) events, level (value) events和temporal events。使用start/stop、事件名以及它所期待的值(可选)组成条目对触发事件进行描述。事件依赖有两种办法:一种是任务自身导致事件发生,不管任务何时启动/结束都会有事件发生,对于启动时要执行的基本任务,这种办法比较有效;而对于较复杂的依赖关系,则可使用任务的Shell脚本工具。
2.4 预读取和预链接
预读取(Readahead)可以将文件(程序和库文件)在使用之前预先加载到RAM缓存中,这样就不用在使用时为读取这个文件而访问I/O。如果知道下一步操作要访问哪些文件,就可以提前将它们全部/部分读取到缓冲区,从而加快执行速度。嵌入式系统很多场合下对于下一步操作都是可预测的,比如系统启动时总是以同样的顺序访问同样的可执行/数据文件,文件块的访问往往是顺序的,应用程序启动时总是访问同样的程序文件段、共享库、资源或者输入文件。这样使用预读取有很强的针对性,从而提高程序执行速度。
ELF(Excutable and Linkable File)是目前Linux中的标准二进制格式,其启动需要以下步骤:将共享库映射到虚拟地址空间;解析符号引用;初始化每个ELF文件。由于共享库是位置无关的,要在运行时完成部分重定位处理和符号查找的工作,才能跳到程序的入口点,因此在带来灵活性的同时,也造成ELF文件的启动速度缓慢,尤其是解析符号引用要消耗大量的时间,对于使用多个共享库的大型程序更是如此。但在很多嵌入式系统中,可执行文件和共享库极少变化,而且每次程序运行时链接工作完全相同。
预链接(Prelink)利用这一点,修改ELF共享库和二进制文件,将链接信息加入到可执行文件中以简化动态链接重定位,从而使程序启动加快。预链接首先搜集要预链接的ELF二进制文件及其所依赖的共享库,为每个库分配唯一的虚拟空间位置,并将共享库重新链接到这个基准位置(动态链接器要加载这个库时,只要虚拟空间地址未被占用,它就会将库映射到指定位置);然后预链接解析二进制或者库中的所有重定位,并将重定位信息存放到ELF对象,还要将所有依赖库的列表及校验和添加到二进制文件或库中。对于二进制文件,还需列出所有的冲突(在共享库的自然搜索范围内对符号的解析不相同)。在运行时,动态链接器先检查是否所有依赖的库都已经映射到指定的位置,而且库文件没有变化,只考虑冲突而不用处理每个库的重定位,这样大大提高了程序启动的速度。使用时要注意的是,若共享库发生了改变,则使用它的所有程序都要重新链接,否则程序仍要进行耗时的正常重定位。
3 XIP和文件系统优化
3.1 代码执行方式
嵌入式系统中代码的执行方式主要有3种:
① 完全映射(fully shadowed)。嵌入式系统程序运行时,将所有的代码从非易失存储器(Flash、ROM等)复制到RAM中运行。
② 按需分页(demand paging)。只复制部分代码到RAM中。这种方法对RAM中的页进行导入/导出管理,如果访问位于虚存中但不在物理RAM中会产生页错误,这时才将代码和数据映射到RAM中。
③ eXecute In Place (XIP)。在系统启动时,不将代码复制到RAM,而是直接在非易失性存储位置执行。RAM中只存放需要不断变化的数据部分,如图1所示。如果非易失性存储器的读取速度与RAM相近,则XIP可以节省复制和解压的时间。NOR Flash和ROM的读取速度比较快(约100 ns),适合XIP;而NAND Flash的读操作是基于扇区的,速度相对很慢(μs级),因此不宜实现XIP。