将 Linux 移植到 DEC Alpha
随着所有基础设施的就位,我现在能够将注意力转向移植内核本身的任务。我之前移植 Unix 的经验表明,最大的系统依赖性存在于虚拟内存子系统、进程调度器、系统调用接口、设备驱动程序和陷阱处理程序中。 在这种特殊情况下,我并不担心设备驱动程序,因为我已准备好编写一些简单的驱动程序来连接到控制台设备。
我自己的软件开发和移植方法是考虑程序运行的核心数据结构。 因此,我使用内核包含文件作为我理解代码结构和系统依赖性的起点。 我梳理了包含文件,记下我认为存在系统依赖性的地方以及可能需要修改算法以适应新环境的地方。 我经常在包含文件、C 代码和我的移植笔记之间来回切换。 最终,一种(相对)连贯的移植方法出现了,我开始实施。
我做出的一个更改遍及各处——后来我后悔了——涉及 cli() 和 sti() 例程。 在 Intel 上,cli() 和 sti() 分别禁用和启用中断。 然而,Alpha 上的 Digital Unix PALcode 实现了七级优先级中断方案。 在我开始移植时,我不确定是否需要保留中断层次结构。
我费力地将所有 cli() 实例替换为对 ipl() 例程的调用,将当前 IPL(中断优先级)设置为最大值并保留先前的 IPL。 我将对 sti() 的调用替换为对 ipl() 的调用,以恢复先前保存的级别。 我这样做是因为我不确定当运行特定代码段时 IPL 可能是什么,如果代码实际上是在非零 IPLl 下输入的,则将 sti() 实现为 ipl(0) 将是一个错误; 事实证明这在很大程度上是不必要的。
Linux 实现了两阶段中断处理,其中中断服务例程分为“上半部分”和“下半部分”。 上半部分是在接收到中断时以非零 IPL 运行的部分。 通常,上半部分执行确认中断所需的最小工作量,并将后续操作排队以供下半部分运行。 这意味着中断处理程序本身几乎是独立的,并且除非明确提高,否则大部分内核代码都在 IPL 0 运行。 对于 Alpha,我可以轻松地将 cli() 实现为 ipl(IPLMAX),将 sti() 实现为 ipl(0),而不会产生不良影响。 这正是我们为设备驱动程序工作所做的事情。
虚拟内存子系统是我不得不实现 Intel 特定例程的 Alpha 特定版本的地方之一。 在许多方面,Alpha 内存映射方案与 Intel 方案相似:Intel 使用两级页表来映射 32 位虚拟地址空间,而 Alpha 使用三级页表来映射 64 位虚拟地址空间。 但是,如果仅映射 32 位虚拟地址,则 Alpha 仅需要单个一级页表项和单个二级页表。 因此,在 32 位系统上,Alpha 方案基本上折叠成两级方案。 所有这一切的结果是,可以使用类似的算法来操作 Intel 和 Alpha 页表。
Alpha 1 级页表在启动时设置一次,之后再也不会被听到; 2 级页表对应于 Intel 上的页目录; 3 级页表对应于实际的 Intel 页表。 实际上,为了节省内存,我只实现了一个系统范围的 2 级页表。 事实证明,使用我上面概述的寻址方案,我可以使用前 256 个 2 级页表项映射整个地址空间,其中 128 个可以映射整个用户地址空间。 因此,我维护了一个单独的 2 级页表,保持内核条目持续映射,并为每个上下文切换复制新的用户条目。 用户条目的内容保存在 pcb_struct 中(Alpha 特定的结构,Intel 版本中不存在),该结构附加到 task_struct。
不幸的是,Intel Linux 内存管理代码利用了 Intel 分页模型的一些偶然特性。 例如,要在 Intel 上获取虚拟内存页的物理内存地址,您只需获取相应的页表项并屏蔽掉低位即可。 Alpha 上的页表项并非如此方便——它们是 64 位宽。 如果我在开始时可以使用 64 位计算,我可以进行掩码和移位。 实际上,我不得不将页表项视为两个整数的结构,从一个成员中提取页帧号,并将其移位以获得物理地址。
因为我最终更改了 memory.c 中的几乎每一行代码以适应略有不同的页帧遍历和解析语义,所以我反而为 memory.c 中的每个例程生成了两个版本——一个用于 i386,一个用于 alpha。 上下文切换是另一个需要重大更改的领域,也是更难调试的领域之一。 大部分上下文切换和系统调用处理代码都必须重写,因为它最初是用 Intel 汇编语言实现的。 Intel 代码在堆栈上保存一些进程状态,但依赖于 Intel CPU 的本机任务切换机制来保存进程状态并将其从任务状态段 (TSS) 恢复。 虽然 Digital Unix PALcode 支持“进程上下文”结构的概念,但此结构包含的实际进程上下文相对较少。 相反,它包含允许进程保存和恢复其 自身 上下文所需的重要指针(内核堆栈指针、用户堆栈指针、页表基址寄存器)。
Linux/Alpha 进程的大部分进程上下文驻留在进程的内核堆栈上。 当进入内核模式时(即,任何时候发生陷阱或中断),PALcode 会将六个项目(PS、PC、GP、A0、A1 和 A2)推送到内核堆栈上。 处理器寄存器状态的其余部分要么由陷阱处理程序推送到内核堆栈上,要么存储在进程的 task_struct 中。
在我的 32 位移植中,我决定为了安全起见,始终将整个寄存器状态推送到堆栈上,包括浮点寄存器。 当然,这是不必要的浪费,特别是如果所讨论的进程从未用过浮点寄存器。 我曾希望最终优化寄存器保存/恢复路径,但我们的开发组在我在完成之前就切换到了 1.2 版本。
我还必须在每次上下文切换时更新 2 级页表区域。 每个进程有 128 个 2 级页表项,其中最多通常使用两到三个。 为了易于实现,我只是在每次上下文切换时保存和恢复所有 128 个条目。 同样,这是我希望能够优化但没有机会在切换到 1.2 之前实现的东西。
重新实现系统调用和陷阱处理程序并不太困难。 对于系统调用处理程序,我必须弄清楚 Intel 系统调用语义,以在寄存器中传递参数,并使用类似的 Alpha 寄存器来传递参数。 至于陷阱处理:虽然 Alpha 实现了与 Intel 不同的一组陷阱,但相对容易弄清楚将各种 Alpha 陷阱向量化到哪里。
文件系统中唯一需要大量关注的部分是缓冲区缓存和 exec() 代码。 必须审查缓冲区缓存,以验证它是否适用于不同的硬件页面大小(Alpha 上为 8KB,而 Intel 上为 4KB)。 必须使 exec() 代码意识到 gcc 和 GNU binutils 生成的可执行文件格式(在本例中,它是 COFF 变体)。
经过几周的代码审查和修改,我准备尝试编译它。 毫不奇怪,获得干净的编译本身就是一个迭代过程。 我会遇到一个错误,决定它是代表我这边的错误,还是试图编译我尚不想支持的代码,并采取适当的行动。
经过一番努力,我终于得到了一个名为“linux”的可执行文件,其中充满了 Alpha 代码。 下一步是尝试启动它。
毫不奇怪,第一次...或第二次...或第三次,我都没有取得太大进展。 所以我在启动序列的早期放置了一个 printk 语句,以便我可以向我的管理层展示一些早期的成功,并添加了许多额外的 printk 以跟踪内核在初始化序列中的进度。 在接下来的几周里,我遇到的大部分问题都是由于我没有注意到某些代码更改的所有后果而造成的错误。 令人惊奇的是,我没有触及的代码经常在第一次就完美地工作。 例如,我会花几天时间调试 init(),然后当轮到挂载根文件系统时,它就会正常工作。
一旦我挂载了根文件系统并完成了所有内核初始化,下一步就是运行用户模式可执行文件。 由于我还没有 C 运行时库或任何 gcc 支持内核以外的任何东西,我决定手工制作一个程序,虽然非常简单,但仍然会显示一些外部功能迹象。 我用汇编语言编写了一个流行的“hello, world”程序的变体。 我没有使用 printf(),而是手工制作了一个汇编语言的系统调用,它调用了 write() 系统调用,并将字符串的地址和长度传递给它。 尝试运行这个程序为我提供了很多机会来调试文件系统中的 exec() 代码和虚拟内存页错误处理程序。 然而,最终,Linux/Alpha 确实对我说“hello, world”。
此时,我需要更多的可执行文件,既可以测试 Linux/Alpha,也可以将其从内核转换为有用的系统。 由于我没有将我的 32 位移植设计为与任何其他东西(例如 Digital Unix)二进制兼容,因此我必须从头开始生成我将要使用的任何可执行文件。 为了编译除专门手工制作的程序之外的任何东西,我将需要一个 C 运行时库。 此时,该项目已经变得超出了一个人可以处理的范围。 (实际上,它很久以前就超过了那个点,但在这一点上我再也无法否认了。)
幸运的是,Brian Nelson 以个人的身份提供了帮助。 Brian 已经在我们小组工作了一段时间,支持 VEST VAX 到 Alpha 二进制转换器。 此时,VEST 的支持需求有所减少,Brian 发现自己有一些空闲时间。 尽管当时他对 Unix 知之甚少,但他对 Linux 项目的热情弥补了任何特定知识的不足。 我辅导他学习了 gcc、make 和库的奥秘,并让他将 InfoMagic CD-ROM 中的 GNU libc 移植到 Alpha。 我处理了一些系统相关的部分,而 Brian 处理了其余部分。
移植 libc 结果证明并非易事,主要是因为我们无法让 libc 的 symbol_alias 宏为我们正常工作。 此宏本质上是在对象文件的符号表中创建一个符号,该符号是另一个符号的精确同义词,并且 stdio 大量使用它。 我们最终设法通过将来自各种来源的片段拼接在一起,构建了一个“弗兰肯斯坦式”的 libc。 其中大部分是 GNU libc 4.1,但 stdio 来自 BSD,一些杂项例程来自我可以找到的任何地方。 然而,我们(以某种方式)设法使用这个库获得了各种 GNU 实用程序的干净构建。
我们开始移植一些 Slackware 软件包,但很快意识到较小的发行版可以更快地使我们获得可用的系统。 我四处打听,认为 MCC 会是更好的选择。
我们编译几乎任何软件包时都遇到的一个问题与配置有关。 几个软件包的自动配置脚本不理解交叉编译的概念。 由于我们在 Digital Unix 系统上进行开发,因此尝试配置软件包要么会失败,要么会在我想要 Linux 版本时生成 Digital Unix 版本。 最后,我向 Brian 建议,他应该登录到 Intel Linux 系统,在那里配置软件包,手动编辑 makefile 以引用交叉工具套件,然后在 Digital Unix 系统上使用 Linux/Alpha 交叉工具编译软件包。 这种相当巴洛克的策略实际上奏效了,他最终能够获得一些较小实用程序的干净构建。 我首先需要的是一个 shell。 Brian 开始移植 bash,但遇到了麻烦。 我在网上搜寻并找到了一堆免费软件 shell。 然后 Brian 和我开始疯狂地移植,直到我们可以找到一个可以与交叉工具干净编译的 shell。 我们最终能够编译 Plan-9 rc shell。
然后 Brian 继续移植其他实用程序,而我尝试启动 Linux 并运行 rc shell。
通常,在简单情况下工作的代码在遇到更复杂的情况时可能会以微妙的方式失败——shell 的情况就是如此。 虽然我正在使用的 COFF 映像加载代码适用于加载单页“hello, world”可执行文件,但在我尝试将其用于更大的文件时,错误就显现出来了。 一旦这些问题得到解决,我就不得不调试 rc 尝试使用的各种系统调用。
当调试新移植的实用程序时,该实用程序使用新移植的库并在新移植的系统上运行时,需要对潜在问题可能出现的地方保持开放的心态。 在调试 rc 时,我遇到了所有领域的问题。 在一种情况下,我没有将系统调用错误状态从内核正确传播到用户; 这导致将错误的成功条件返回给程序。 在另一种情况下,我发现内核 init() 函数没有正确打开 /dev/tty0,因此即使 rc 运行正常,它也无法从控制台读取或写入。
一天下午晚些时候,我在家工作,使用 ISP Alpha 模拟器和几个 nm 列表来调试另一个 rc 问题。 当我修复了一个虚拟内存错误后,我给我的同事发了一条邮件,说我进展顺利,可能会在本周末之前获得 shell 提示符。 然后我尝试了一个 One More Fix,重新启动,看着初始化消息滚动过去,然后看到屏幕冻结。 仔细一看,我看到屏幕底部有一个提示符! 按下 return 键产生了预期的效果。 我的工具很少,但我可以通过输入 echo 来模拟一个粗略的 ls; 我这样做了,并受到了根文件系统上几个文件名的欢迎。
获得 shell 提示符是任何操作系统移植项目的主要成就之一。 我通知了我的同事,我们下班去喝了啤酒,为我们的成就感到自豪。 下个月,我们将介绍调试和进一步开发。
Jim Paradis 在 Digital Equipment Corporation 担任首席软件工程师,是 Alpha 迁移工具组的成员。 自从一位大型机系统管理员在大学里对他大吼大叫以来,他就一直希望在自己的桌面系统上拥有一个多用户、多任务操作系统。 为此,他尝试了几乎所有为 PC 生产的 Unix 变体,包括 PCNX、System V、Minix、BSD 和 Linux。 不用说,他最喜欢 Linux。 Jim 目前与他的妻子、十一只猫和一栋永远在装修的房子住在马萨诸塞州伍斯特市。