【文章翻译】将Linux移植到新的处理器架构(3)——最后冲刺

Posted by 尹天宇 on January 31, 2019

译者注:原文链接


这个系列的文章给想要将Linux移植到新的处理器架构的人提供一个综述。第一部分第二部分聚焦于代码无关的基础工作以及早期代码,从汇编引导代码到第一个内核线程的创建。这个系列继续关注一直过程的最后一个部分。剩下的工作基本上就是开启init进程,处理线程和进程管理。

产生内核线程

start_kernel()执行了它最后一个函数调用(rest_init())时,内存管理子系统已经完全开启,引导处理器已经运行并且能够处理异常和中断,系统有了时间上的概念。

尽管目前执行流还是贯序的、单线程的,但是在转向引导空闲线程之前由rest_init()处理的主要任务是创建两个内核线程:一个是我们在下一部分讨论的kernel_init,另一个是kthreadd。可以想象,创建这些内核线程(以及任何其他类型的线程,从同一进程内的用户线程到实际进程)意味着存在复杂的进程管理基础结构。创建新线程的基础结构的绝大部分不是架构特定的:诸如拷贝task_struct结构或者授权证书、建立调度器等这些操作不需要任何架构特定代码。然而,进程管理代码必须定义一些架构特定的部分,主要是为了给每个新线程和线程间切换建立栈。

Linux总是避免从零开始创建新资源,尤其是新线程。除了初始线程,内核总是拷贝一个已经存在的线程然后加以修改来使它成为需要的新线程。同样的原则在线程创建后仍然适用,当新线程的第一次开始执行时,恢复一个线程的执行总是比从头开始执行要简单一些。这意味着新分配的栈必须被初始化以至于第一次切换到这个新线程时,这个线程看起来像是恢复执行。

为了更加深入地理解这个机制,我们必须稍微钻研一下线程切换机制,尤其是由架构特定的上下文切换方法switch_to()实现的执行流的切换。这个总是由汇编写成的方法,总是被当前执行的线程在切换到下一个进程时调用。这个机制的一部分是,在当前线程的栈中保存当前上下文,将栈指针切换到下一个线程栈上,然后从它的栈恢复上下文。就像一个一般的函数一样,switch_to()函数最终通过保存到新的当前线程栈中的指令地址返回到“调用”函数。

在下一个进程之前运行过,只不过是临时从处理器中移除了这种情况下,返回到调用函数就是一个常规事件,最终将会导致线程恢复它自己代码的执行。然而,对于一个全新线程,不会有任何函数调用过switch_to()来保存线程的上下文。这就是为什么新线程的栈必须被初始化来假装曾经有过函数调用,从而使得switch_to()能够在恢复这个新线程后返回。这样一个函数通常又几行汇编代码组成,构成通向线程代码的蹦床。

注意切换到一个内核线程一般不会涉及切换到另一个页表,因为支撑所有线程运行的内核线程地址空间空间在每个也表结构中都有定义。对于用户进程来说,切换到他们自己的页表是由架构特定方法switch_mm()完成的。

第一个内核线程

如在源代码中解释的,内核线程kernel_init是第一个被创建的唯一原因是它必须获取PID 1。这是init进程(比如第一个从kernel_init的诞生的用户进程)传统上继承的PID。

有趣的是,kernel_init的第一项任务是等待第二个内核线程kthreadd准备好。kthreadd是负责在被要求时异步地产生新内核线程的内核线程守护进程。一旦kthreadd启动,kernel_init完成启动的第二阶段,包括一些架构特定的初始化。

在多处理器系统中,kernel_init首先启动其它处理器,然后再初始化组成驱动程序模型的各种子系统(比如devtmps、设备、总线等等),之后再使用定义的初始化调用来启动底层硬件系统的实际设备驱动程序。

再进行“有趣的”设备驱动(比如块设备,帧缓存等)之前,致力于拥有至少一个可操作的终端(必要情况下实现相应的驱动)可能是一个好主意,尤其是考虑到由early_printk()建立的早期控制台不久之后应该由一个真正的、功能完备的控制台代替。

在这个初始化调用时进行的还有initramfs的解包,以及最初的root文件系统(rootfs)被挂载。挂载一个最初的rootfs有几个选项,但是我找到一个在移植Linux的时候最简单的选项initramfs。这基本上意味着rootfs在编译时被静态构建,并被集成到内核二进制映像。在挂载之后,rootfs可以访问/init/dev/console

最终,初始化内存被释放(比如包含只在初始化阶段使用,以后用不到的代码和数据),在rootfs找到的init进程启动。

执行初始化

此时,运行init可能在取得第一条指令时马上会导致错误。这是因为,与创建线程一样,能够执行init进程(实际上是任何用户空间应用程序)首先需要进行一些基础工作。

为了解决取指问题需要实现的函数是缺页处理程序。Linux是lazy的,尤其是对于用户程序,默认上是不预先将程序的文本和数据加载到内存中的。相反,它只加载需要的内核结构,让应用在第一次取值时引发缺页中断,因为包括上下文段的内存页通常还没有加载。

这实际上是国际惯例,因为它预期这样一个中断会被缺页处理程序抓住并修正。这个处理程序可以被看作能够吹任何与内存有关的错误的复杂的切换状态:来自vmalloc()的错误,需要与参考页表同步以堆叠用户应用程序中的扩展。在这种情况下,处理程序将确定页面错误对应于应用程序的有效虚拟内存区域(VMA),并因此在重试运行应用程序之前将缺少的页面加载到内存中。

缺页处理程序能够catch内存错误之后,一个极其简单的init可能就能够执行了。然而,它还不能做很多工作,因为它不能通过系统调用向内核请求任何服务,比如打印到终端。到了这一步,带有架构特定部分的系统调用基础结构必须完成。系统调用被当作软件中断来处理,因为他们像硬件中断一样,是由用户指令访问,让处理器自动切换到内核模式。除了定义移植支持的系统调用列表,处理系统调用还涉及到增加接收它们的附加功能,来增强中断和异常处理程序。

一旦支持了系统调用,现在可能能够执行一个打开主控制台,写一个信息的“hello world”init程序。但是距离实现一个功能齐全的init还是缺少能够开启其它程序并且与之交互,以及和内核交换数据的部分。

面向这个目标的第一步就是信号管理,更准确地说,是信号传递(包括来自其它进程或者来自内核本身)。如果一个进程为某个特定的信号定义了处理程序,则只要给定信号处于挂起状态,就必须调用此处理程序。这样一个事件在目标进程将要被再次调度时发生。更加准确地说,这意味着当恢复这个进程时,就在下一个转移回到用户模式时,这个进程的执行流必须被警告来执行处理程序(而不是那个程序本身)。在应用程序栈上也必须为处理程序的执行留出一定空间。一旦处理程序结束,返回内核(通过一个之前注入到处理程序上下文中的系统调用),就恢复了那个进程的上下文使其能够回到正常执行状态。

完全运行用户空间程序的第二步也是最后一步就是当内核想要从用户空间页存取数据时处理用户空间的内存访问。这样一个操作可以是十分危险的,比如应用程序给出了一个假的指针,在不检查的情况下可能会导致内核的安全风险。为了解决这个问题,有必要编写特定于体系结构的例程,这些例程使用一些汇编魔法来注册在异常表中执行对用户空间内存的实际访问的所有指令的地址。就像这篇2001年的LWN文章解释的:

如果在内核模式中发生故障,则故障处理程序扫描异常表,尝试将故障指令的地址与表条目匹配。 如果找到匹配项,则会执行特殊错误退出,复制操作将正常失败,并且系统调用将返回segmentation fault错误。

结论

一旦一个功能齐全的init进程能够执行并且给shell访问权限,就表示移植过程结束了。但是它只是冒险的开始,因为移植需要维护(因为内部API有时候改变地很快),也可以通过很多方法来增强:比如增加多处理器支持,实现更多的设备驱动等。

通过描述将Linux移植到新的处理器架构的漫长旅程,我希望这系列文章能够致力于解决这个领域缺少文档的现状,并帮助下一个勇敢的程序员有朝一日开始这一挑战,但最终会有所回报。