CXD Linux Engineer

移植Linux到一个新的处理器架构上 part 3


前言

这篇文章是翻译这三篇文章的第三篇:
Porting Linux to a new processor architecture, part 1: The basics
Porting Linux to a new processor architecture, part 2: The early code
Porting Linux to a new processor architecture, part 3: To the finish line

正文

这一系列文章提供了一个移植Linux内核到新的处理器架构上的大概流程。Part 1part 2分别介绍了代码无关的基础工作和从汇编启动代码到创建第一个内核线程的早期代码。这是最后一篇文章主要介绍启动init进程进行线程和进程的管理工作。

启动内核线程

start_kernel()函数调用了最后一个函数rest_init()时,内存管理子系统全面运行了,处理器开始运行并且可以处理异常和中断,系统已经具备时钟概念。

但是执行流到目前为止还是单线程的,rest_init()函数在进入idle线程之前的主要任务是创建两个内核线程:kernel_init它会在下一节进行讨论和kthreadd。你可以想象,创建这些线程(和其他各种线程,用户线程也是通过相同的方式创建的)需要一个复杂的进程管理体系。创建一个新的线程的大部分代码是架构无关的:例如复制task_struct结构体或者证书,设置调度器等等通常不需要架构特定的代码。然而,进程管理代码必须定义一些架构特定的部分,主要是为新线程设置栈和线程之间的切换。

Linux总是避免从头创建新资源尤其是新的线程。初始线程(这个线程正在启动系统)是个例外,内核总是复制已有线程然后改造为新线程。同样的原则应用于创建线程后当新线程第一次执行时恢复线程的执行比从头开始执行要容易。意思就是新线程的第一次运行时新分配的栈必须先初始化,使线程看起来像被停止后在重新恢复运行。

为了进一步理解这种机制,了解了一些线程切换机制然后再深入了解架构特定的上下文函数switch_to()是必须的。这个函数通常是用汇编代码写的,它总是由当前线程调用然后由下一个线程返回。这个功能的部分实现是通过保存当前上下文到当前线程的堆栈中,切换堆栈指针指向下一个线程的堆栈,然后恢复被保存的上下文。 由于这是一个特殊函数,switch_to()返回调用函数的方法是使用新的当前线程堆栈中保存的指令地址。

在这种情况下,下一个线程是先前运行过的然后被暂时移出处理器,返回调用函数是一个正常事件最终会使线程恢复自身代码的执行。然而作为一个新的线程,他还没有调用switch_to()函数来保存线程上下文。这就是为什么新线程的栈必须初始化用来假装它以前已经调用过函数,使这个新线程恢复运行后switch_to()可以返回。这种函数通常设置为少量汇编代码跳转到线程的代码中。

注意内核线程切换时通常不涉及页表的切换因为是在内核地址空间中,所有内核线程的运行定义在每个页表结构体中。对于用户进程,切换他们自己的页表是通过架构特定函数switch_mm()完成的。

第一个内核线程

就像源码中解释的那样内核线程kernel_init第一个被创建的原因是它必须获得PID 1。这是init进程的PID(即 第一个用户空间进程由kernel_init创建)

有趣的是kernel_init的第一个任务是等待第二个内核线程kthreadd的完成。kthreadd是内核线程的守护进程负责异步生成内核线程。一旦kthreadd开始运行,kernel_init继续进行第二阶段的引导,他包含一点架构特定的初始化。

在多核处理器系统中,kernel_init首先启动其他处理器核然后初始化构成驱动模型的各个子系统(devtmpfs, devices, buses, etc.)最后使用已定义的初始化调用来初始化实际底层硬件设备驱动程序。在进入设备驱动程序(e.g. block device, framebuffer, etc.)之前,至少初始化一个操作终端(通过安装相应的驱动程序)是一个好的主意。尤其是early_printk()函数设置的早期终端应该被一个真正的全功能的终端所取代。

也是通过这些初始化调用来解压initramfs和挂载这个初始根文件系统(rootfs)。挂载初始rootfs有几种选择但是我发现initramfs是当移植Linux时最简单的方法。这个rootfs会直接编译进内核二进制映象中。挂载之后这个rootfs可以访问/init/dev/console

最后init段的内存会被释放(即 这段内存中包含的是只在初始化阶段使用以后不需要的代码和数据)然后启动在rootfs中找到的init进程。

运行init进程

此时启动init进程当试图取第一条指令时可能导致错误。这是因为运行init进程(实际上是所有用户空间的应用程序)首先需要涉及一点基础设施。

解决取指令问题的函数实际上就是需要处理内存页缺失异常。Linux非常懒,尤其是运行用户程序时默认情况下Linux不会预加载代码和数据到内存。它只设置所有必需的内核结构体然后让应用程序在取第一条指令时发生异常因为包含应用程序的文本段内存页通常还没有被加载。

这是实际上是故意这样设计的因为当发生内存故障时会被页故障处理函数捕获。这个处理函数可以看作是一个复杂的switch语句它可以所有内存故障:来自vmalloc()的故障会同步参考页表来扩展用户应用程序的堆。在这种情况下处理函数会确认页故障对应的应用程序的有效虚拟内存区域(VMA)然后加载缺失页到内存在次运行应用程序。

一旦页故障处理函数可以捕获内存故障,一个非常简单init进程好像可以运行。然而,它不能做很多事情因为还不能通过系统调用来请求任何服务,例如打印字符到终端。为此系统调用必须完成架构特定的部分。系统调用被视为软件中断因为他们使用用户指令使处理器自动切换到内核模式,就像硬件中断那样。除了定义支持系统调用的列表,处理系统调用还需要增加中断和异常处理函数的额外功能来接受系统调用引起的异常。

一旦支持系统调用现在应该可以运行一个"hello world"版的init程序它可以打开主控制台然后输入信息。但是仍然不能运行可以启动其它应用程序和相互通信以及和内核交换数据的全功能init程序。

实现这个目标的第一步是关注信号的管理更具体的是信号的传递(传递给另一个进程或者传递个内核自己)。如果一个进程定义了一个特定信号的处理函数那么只要给定信号还没有处理此函数会被调用。当这种事件出现在目标进程需要再次被调度时。进一步说这意味着在恢复进程时,就在返回用户模式的瞬间,为了执行处理函数这个进程的执行流必须被改变。还必须在这个应用程序的栈上增加空间来执行这个处理函数。一旦处理函数执行完成返回给内核(通过早已加入这个处理函数的上下文中的系统调用),这个进程的上下文被被加载于是它可以恢复正常运行。

运行用户空间应用程序的第二和最后一个步骤是处理用户空间内存访问:当内核想从用户空间内存页中复制数据。这样的操作可能是非常危险的,例如应用程序提供一个假指针内核如果没有正确检查会导致内核恐慌(或者是安全漏洞)。为了避免这种问题有必要编写一个架构特定函数使用一些assembly magic(汇编魔术??)在异常表中注册所有指令执行时访问的用户空间内存地址。就像2001年的LWN文章中解释的“如果一个故障发生在内核模式下,故障处理函数会扫描异常表试图使用一个表项匹配故障指令的地址。如果找到匹配项,一个特殊的错误会出现,这个复制操作会优雅的失败,这个系统调用会返回一个段错误”

总结

一旦全功能的init进程可以运行并且访问shell,这可能是移植过程结束的信号。但是这更像是这场冒险的开始,如果这个移植需要维护(因为内部APIs有时候变化的很快),也可以通过很多方法来完善:增加支持多核处理器和NUMA系统,实现更多设备驱动程序等等。

通过描述移植Linux到一个新的处理器架构上,我希望这一系列文章有助于弥补内核文档在这方面的缺失来帮助下一个从事这个挑战的程序员,但是最终的收获是经验。


下一篇 ELF文件格式

Comments

Content