译者注:原文链接
在这个系列的第一部分,我们通过解释(没有代码的)初级步骤来给Linux移植到新的处理器架构上打下了基础。本文从那里开始继续钻研启动代码。这包括为了从早期的汇编启动代码到第一个内核线程的创建而需要编写的代码。
头文件
如之前的文章中简要地提到过的,arch
头文件(在我的例子中位于linux/arch/tsar/include/
)组成了Linux要求的位于架构特定代码和架构无关代码之间的两个接口。
这些头文件的第一个部分(子目录asm/
)是内核接口的一部分,由内核源代码内部使用。第二部分(uapi/asm/
)是用户接口的一部分,用来导出到用户空间(尽管不同的标准C库可能会重新实现这些头文件而不是使用这些我们写好的)。这些接口不是完全封闭的,许多这些asm
头文件也由用户空间使用。
这两个接口一般加一起有一百多个头文件,这也就是为什么头文件是将Linux移植到新的处理器架构中最大的任务之一。幸运的是,在过去的几年中,开发者发现许多处理器架构可以共享相似的代码(因为它们展现了相同的行为),所以这个代码的大部分已经被聚集到一个通用的头文件层(在linux/include/asm-generic/
和linux/include/uapi/asm-generic/
中)。
真正的好处是你可以通过写适当的Kbuild
文件来参考这些通用的头文件,而不是完全自己造轮子。比如说,一个通常的include/asm/Kbuild
看起来像是这样:
1
2
3
4
generic-y += atomic.h
generic-y += barrier.h
generic-y += bitops.h
...
在移植Linux时,恐怕你只有一个选择就是将可能的头文件列出来然后一个一个检查通用版本是否可用,否则就需要修改。这样一个列表可以从Linux提供的通用头文件以及其他架构中实现的头文件中获得。
基本上,一个版本必须实现和架构相关的所有头文件,这些由硬件或软件通过ABI来定义:缓存(asm/cache.h
)和TLB管理(asm/tlbflush.h
),ELF格式(asm/elf.h
),中断启用和禁用(asm/irqflags.h
),页表管理(asm/page.h
、asm/pgalloc.h
、asm/pgtable.h
),上下文切换(asm/mmucontext.h
、asm/ptrace.h
),字节顺序(uapi/asm/byteorder.h
)等等。
引导序列
如第一部分所述,指出引导序列可以帮助我们理解那些必须实现的架构特定函数,以及他们的顺序。
引导序列总是以一个必须手动写的函数开始,这个函数通常是用汇编来写(在我的例子中,这个函数叫做kernel_entry()
,位于arch/tsar/kernel/head.S
)。它被定义为内核映像的主要入口,它向bootloader指示将映像加载到内存后跳转到的位置。
下面这个追踪显示了在引导期间执行的函数序列的摘录(加星标的函数是架构特定函数,将在本文后面讨论):
kernel_entry*
start_kernel
steup_arch*
trap_init*
mm_init
mem_init*
init_IRQ*
time_init*
rest_init
kernel_thread
kernel_thread
cpu_startup_entry
早期汇编引导代码
早期汇编引导代码的特殊光环一开始使我害怕(我相信对于许多其他程序员来说也是如此),因为它经常被看做移植过程中最复杂的代码。但是即使是写汇编代码也不是一个容易的工作,这个早期引导代码也没有魔法。它只不过是第一个架构特定C函数的弹簧垫,到最后也只需要完成一小段确定好的任务。
当早期引导代码开始执行的时候,它不知道任何之前的事情:这个系统是刚被重启还是刚刚上电呢?哪个bootloader把内核加载到内存中了?等等。因此,使处理器处于已知状态是更加安全的。将系统寄存器复位一次或多次通常比较有用,它确保处理器处于内核模式并且禁用了中断。
相似的是,内存的状态知道的也不多。特别是没有保证内存的bss部分被初始化为0(bss部分包含的是未初始化的数据),这也就是为什么这个部分必须被显式地清零的原因。
Linux经常从bootloader接收参数(和一般的程序在启动时接收参数一样)。比如,这些参数可能是扁平化设备树的地址(一般在ARM,MicroBlaze,openRISC等等)或者其他一些架构特定数据结构。这些参数一般是使用寄存器来传递,需要被保存到适当的内核变量中。
此时,虚拟内存还没有启用,有趣的是内核的虚拟地址空间中定义的内核符号必须通过一个特殊的宏来访问:在x86中为pa()
,在openRISC中为tophys()
等。这样一个宏将符号的虚拟内存地址翻译为对应的物理内存地址,这样作为一个临时的软件翻译机制。
现在,为了启用虚拟内存,一个页表结构必须从头建立起来。这个结构通常以内核映像中的一个静态变量的形式存在,因为这个阶段基本上不可能分配内存。同理,一开始只有内核映像可以被页表映射,使用尽可能大的页。按照惯例,初始页表结构被称为swapper_pg_dir
,它习惯上在整个系统执行过程中被用作参考页表结构。
在包括TSAR在内的许多处理器架构上,一个有趣的事情是映射内核实际上需要映射两次。第一次映射实现了如第一部分所述的直接映射机制(比如访问虚拟地址0xC0000000
重定向到物理地址0x00000000
)。然而,另一个映射是在虚拟内存刚刚被启用但是代码还没有跳转到虚拟内存地址的时候被临时请求的。第二个映射是简单的恒等映射(比如访问虚拟地址0x00000000
重定向到物理地址0x00000000
)。
有了一个初始化了的页表结构以后,现在使能虚拟内存变得可能,意味着内核在虚拟地址空间中被完全地执行,并且所有的内核符号都可以通过名字正常访问,而不需要之前提到的翻译宏了。
最后的步骤之一是建立一个带有初始内核栈地址的栈寄存器,使得C函数可以被正确地调用。在大多数处理器架构中(SPARC、Alpha、openRISC等),需要另一个寄存器指针来保存当前线程的信息(struct thread_info
)。建立这个指针是可选的,因为它可以从当前内核栈指针中推测得知(thread_info
通常位于内核栈的底部)。但是,当架构允许的时候,它能够实现更加快速和方便的访问。
早期引导代码的最后一步是跳转到Linux提供的第一个架构无关的C函数:start_kernel()
。
路由到第一个内核线程
许多子系统都在start_kernel()
中初始化,比如从许多虚拟文件系统(VFS)缓存和安全性框架到时间管理,控制台布局等等。这里我们关注start_kernel()
在它最终调用创建最初的两个内核线程并进入引导闲置线程的函数——rest_init()
之前调用的主要的架构特定函数。
setup_arch()
尽管它拥有一个如此通用的名字,setup_arch()
实际上取决于架构做许多不同的事情。观察这个函数在不同的移植中的版本,我们可以发现尽管采用了不同的顺序和不同的方法,它基本上完成的还是同样的任务。对于一个简单的(支持设备树)的移植,setup_arch()
可以遵循一个简单的框架。
第一步是发现系统中的内存范围。一个基于设备树的系统可以快速浏览bootloader(使用early_init_devtree()
)提供的扁平化的设备树,来发现可用的物理内存bank以及把它们注册到memblock
层。然后,使用parse_early_param()
来解析早期参数,这些早期参数可能来自于bootloader或者直接在设备树中包含,以此激活有用的功能,比如early_printk()
。在这里顺序是很重要的,因为设备树可能包括打印时用到的终端设备的物理地址,需要提前扫描。
下一个memblock
层在映射low memory区域前需要更多的配置信息,这些信息使得内存能够被分配。首先,内核映像和设备树占有的内存区域被设置为保留,来把它们从可用内存池中移除,这个可用内存池之后将被释放给buddy allocator。需要确定low memory和high memory的边界(比如物理内存的哪一部分应该属于直接映射区)。最终页表结构(通过移除早期引导代码创建的恒等映射)被清空,low memory被映射。
内存初始化的最后一步是配置内存区域。物理内存页可以被划分成不同区域:ZONE_DMA
用于兼容老式24位DMA地址;ZONE_NORMAL
和ZONE_HIGHMEM
分别用于low-memory和high-memory页。更多关于Linux的内存分配可见Linux Device Drivers[PDF]
最后,使用资源API来注册内核内存段,从扁平化设备树创建struct device_node入口。
如果启用了early_printk()
,这是一个此阶段打印在终端的例子:
Linux version 3.13.0-00201-g7b7e42b-dirty (joel@joel-zenbook) \
(gcc version 4.8.3 (GCC) ) #329 SMP Thu Sep 25 14:17:56 CEST 2014
Model: UPMC/LIP6/SoC - Tsar
bootconsole [early_tty_cons0] enabled
Built 1 zonelists in Zone order, mobility grouping on. Total pages: 65024
Kernel command line: console=tty0 console=ttyVTTY0 earlyprintk
trap_init()
trap_init()
的角色就是配置中断/异常基础结构中涉及的硬件和架构特定部分的软件。到目前为止,一个异常将会使得系统立刻崩溃或者被bootloader设置的处理函数抓住(最终也会崩溃,但是可能会提供更多信息)。
在trap_init()
之背后隐藏着Linux移植的另一块更加复杂的代码:中断/异常处理管理器。它的一大部分需要用汇编写成,因为和早期引导代码一起,它处理目标处理器架构的特殊事件。在一个典型的处理器中,当一个中断到来时可能发生的事件包括:
- 处理器自动切换到内核模式,关闭中断,它的执行流转移到一个执行主中断处理程序的特殊地址。
- 这个主处理程序获取中断来源,通常跳转到对应这个来源的子处理程序。在一些架构中不需要主中断处理程序,因为不同中断在中断向量中的路由由硬件完成。
- 子中断处理程序保存当前上下文,即处理器当前的状态,以待日后恢复。重新使能中断(使得Linux是可重入的),然后通常会跳转到一个C函数来更好的处理这个异常。比如,这样一个C函数可以在访问非法内存地址时通过
SIGBUS
信号来终止这个用户程序。
一旦这些中断基础结构都准备好了,trap_init()
只是初始化中断向量表,同时通过一些系统寄存器来配置处理器以指明主终端处理函数(或中断向量表)的地址。
mem_init()
mem_init()
的主要角色是从memblock
层释放可用内存给buddy allocator(即page allocator)。这代表着slab allocator(比如通过kmalloc()
访问的常用对象缓存)以及vmalloc基础结构开启之前和内存有关的最后一项任务。
mem_init()
经常也会打印像这样的内存系统信息:
Memory: 257916k/262144k available (1412k kernel code, \
4228k reserved, 267k data, 84k bss, 169k init, 0k highmem)
Virtual kernel memory layout:
vmalloc : 0xd0800000 - 0xfffff000 ( 759 MB)
lowmem : 0xc0000000 - 0xd0000000 ( 256 MB)
.init : 0xc01a5000 - 0xc01ba000 ( 84 kB)
.data : 0xc01621f8 - 0xc01a4fe0 ( 267 kB)
.text : 0xc00010c0 - 0xc01621f8 (1412 kB)
init_IRQ()
中断网络可以有不同的大小和复杂度。在简单的系统中,一些硬件设备的中断线直接连接到处理器的中断输入。在复杂的系统中,大量的硬件设备连接到许多可编程中断控制器(PICs),这些PICs经常是互相级连的,组成一个多层次的中断网络。设备树在这个过程中起到了很大作用,因为它简便地描述这样的网络(和路由)而不是在源代码中特意直接描述他们。
在init_IRQ()
中,主要的任务是调用irqchip_init()
来扫描设备树并找出确定为中断控制器(比如PICs)的节点。然后它找出每个节点的驱动程序并初始化它。通常第一个设备驱动是需要编写的,除非目标系统使用了已经被支持的中断控制器。
这样一个驱动包含几个主要函数:一个初始化函数,它将设备映射到内核地址空间,并将控制器本地中断线(通过irq_domain映射库)映射到Linux IRQ号码空间;一个mask/unmask函数,它配置该控制器来屏蔽或取消屏蔽制定的Linux IRQ号码;最后是一个控制器特定中断处理程序,它找出它的输入的哪个是有效的,然后调用注册了那个输入的处理程序(比如,这就是一个块设备的中断处理程序如何连接到PIC最终在该设备引发中断时被调用)。
time_init()
time_init()
的目的是初始化时间保持基础结构中的架构特定方面。这个依赖于设备树的函数的最小版本只涉及到两个函数调用。
首先,of_clk_init()
将会扫描设备树,找出所有被指定为时钟提供器的节点来初始化时钟网络。一个非常简单的时钟提供器节点只需要定义一个直接指定为其属性之一的固定频率。
然后,clocksource_of_init()
函数将会解析设备树的时钟源节点并初始化他们的驱动程序。如内核文档中所述,Linux实际上需要两种时钟保持抽象(通常由同一个设备提供):一个时钟源设备通过单调递增计数(比如计数系统时钟周期)提供基础时间线,以及一个时钟时间设备在这个时间线的特定点引发中断,通常编程来计数一段时间。和时钟提供器一起,它允许精确时钟保持。
时钟源设备的驱动可以非常简单,尤其是对于一个内存映射设备,它的通用MMIO时钟源驱动只需要知道包含计数器的设备寄存器地址。对于时钟事件,它稍微有点复杂,因为驱动需要定义如何确定一段时间以及当它结束时如何感知它,并且为定时器中断提供一个中断处理程序。
结论
start_kernel()
之后的主要任务之一是校正每个瞬间的循环次数,它就是处理器可以在一个瞬间执行的内部循环次数——一个内部计数器周期,通常是1-10ms。这个校正的成功应该意味着我们刚才提出的处理器特定函数构建的不同的基础结构和驱动在正常工作,因为校正使用了他们当中的绝大多数。
在下一篇文章中,我们将会展示移植的而最后一个部分:从第一个内核线程的创建到init
进程。