用 Swift 编写的极简内核,在 QEMU 中运行

Hacker News Top 工具

摘要

作者用 Embedded Swift 编写了一个极简内核并在 QEMU 的 aarch64 virt 机器上运行,记录了从工具链安装、Package.swift 配置到汇编入口与栈初始化的完整过程,并更新了 Swift 6.3 中新的 @c 属性替代 @_cdecl 的用法。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/10/08 12:44

# 一个用 Swift 编写的极简内核,运行在 QEMU 上 来源:https://carette.xyz/posts/minimal_swift_kernel_on_qemu/ --- **更新(2026 年 9 月 28 日)** Max Desiatov 告诉我(https://mastodon.social/@maxd/117348871405186390),`@c` 这个 Swift 属性(https://www.swift.org/blog/swift-6.3-released/#language-and-standard-library)应该可以用,而不必使用 `@_cdecl` 来声明函数。`@c` 属性将全局函数标记为用 Swift 实现的 C 函数,这使 `@_cdecl` 属性的形式化成为可能。该提案的链接在这里(https://github.com/swiftlang/swift-evolution/blob/main/proposals/0495-cdecl.md)。我已根据这个说明更新了博客文章和相关代码。谢谢 Max! --- 我开始了一个小实验:用 Swift 编写一个非常基础的内核,并在 QEMU(https://www.qemu.org/)上运行它。目标显然不是取代 Linux 或其他任何流行的内核,而只是为了好玩,并理解在没有操作系统支撑的情况下让一个程序运行需要什么。实际上,我在 10 年前曾用 arOS(https://carette.xyz/posts/im_writing_my_own_os/)做过类似的实验。 目前,这个内核只做一件事:通过 QEMU 打印一条消息,然后永远等待下去,这对第一次深入研究来说已经足够了。 ## Embedded Swift 该项目使用的是 Embedded Swift(https://docs.swift.org/latest/documentation/embeddedswift/),它是 Swift 的一个子集,专为没有操作系统且(在通常意义上)没有标准库的环境而设计。我最初的那个 `Package.swift` 非常小: ``` // swift-tools-version: 6.4 import PackageDescription let package = Package( name: "swift-kernel", platforms: [.macOS(.v14)], targets: [ .executableTarget( name: "swift_kernel", swiftSettings: [ .enableExperimentalFeature("Embedded"), .strictMemorySafety(), .treatAllWarnings(as: .error), ] ) ] ) ``` 而第一个 Swift 文件简单得近乎侮辱: ``` // Sources/swift_kernel/test.swift print("Hello... world?") ``` 我试着运行它: ``` > swift run :0: error: unable to load standard library for target 'arm64-apple-macosx27.0.0' ``` 哎呀。看起来我当前的 Swift 编译器还没有随附 Embedded Swift 的标准库。我在文档中漏看了这一点(https://docs.swift.org/latest/documentation/embeddedswift/install-embedded-swift): > 由于 Embedded Swift 仍然处于实验阶段,且尚未在公开的 Swift 版本中获得支持,你需要使用开发工具链。 所以,我们来安装开发工具链,然后重新测试: ``` > swiftly install main-snapshot && swiftly use main-snapshot > swift run [...] Hello... world? ``` 成功了!到这里我已经确认了 Embedded Swift 能够编译并运行一个小程序。下一步是为一台完全没有操作系统的机器进行编译。 ## QEMU 设置 在这个项目中,我将在一个名为 QEMU(https://www.qemu.org/)的机器模拟器上进行测试,它是黑客们最著名的机器模拟器之一。我在一台 Apple Silicon 机器上使用 QEMU 的 `virt` 机器。因此目标三元组是 `aarch64-none-none-none-elf`。这个目标以三元组(https://wiki.osdev.org/Target_Triplet)的形式描述了一台 64 位 ARM 机器:没有操作系统,也没有由 C 运行时提供的环境。 当 QEMU 启动时,CPU 处于原始状态。在它能安全执行 Swift 代码之前,我需要**配置一个栈**并**决定程序从哪里开始**。我需要一个最小的汇编文件,它要: 1. 定义 `_start` 符号(http://dbp-consulting.com/tutorials/debugging/linuxProgramStartup.html), 2. 为第一个核心设置栈, 3. 跳转到我们的 Swift 入口函数 `kernel_main`, 4. 并且永远等待,以保持主流程运行。 ## 编译器设置 我必须告诉编译器这不是一个普通的可执行文件:编译器必须避免链接通常的标准库,而链接器必须使用我们自己的链接脚本,而不是平台专用的脚本。该项目使用以下 `toolset.json`,它将传递给 Swift Package Manager(SPM): ``` { "schemaVersion": "1.0", "swiftCompiler": { "extraCLIOptions": [ "-enable-experimental-feature", "Embedded", "-enable-experimental-feature", "Volatile", "-Xfrontend", "-no-allocations", "-Xfrontend", "-function-sections", "-Xfrontend", "-disable-stack-protector", "-Xlinker-driver", "-nostdlib", "-Xlinker-driver", "-fuse-ld=lld" ] }, "linker": { "extraCLIOptions": [ "-nostdlib", "-static", "--gc-sections", "--orphan-handling=error", "-T", "linker.ld", "-Map", ".build/kernel.map" ] } } ``` 现在,我们用新的目标、工具集和 Swift 的原生构建系统来构建项目: ``` > swift build --triple aarch64-none-none-elf --toolset toolset.json --build-system native ``` 不幸的是,第一次链接尝试失败了: ``` ld.lld: error: section type mismatch for .strtab >>> .(.strtab): SHT_STRTAB >>> output section .nonalloc: SHT_SYMTAB ld.lld: error: section type mismatch for .shstrtab >>> .(.shstrtab): SHT_STRTAB >>> output section .nonalloc: SHT_PROGBITS ld.lld: error: undefined symbol: putchar ld.lld: error: undefined symbol: memmove ``` 嗯,这里发生了什么?因为我是在为裸机目标进行编译(提醒:`none-none-elf`),所以没有底层操作系统,也没有 C 标准库。因此既没有 `putchar`,也没有 `memmove`…… 段(section)错误的来源不同。在启用了孤儿段处理(orphan handling)的情况下,链接器不想猜测 ELF 元数据应该放在哪里。因此,链接脚本必须显式地安排我要保留的那些段。 ## 将 Swift 桥接到汇编 我不能使用带 `main` 函数的普通 Swift 文件。相反,我用 `@c` 暴露一个专门的内核入口函数,这样汇编文件就可以通过它兼容 C 的名称来调用它。 ``` @c func kernel_main() { print("Hello, Embedded Swift 😊") while true {} // 保持程序运行 } ``` 汇编入口点位于 `Sources/boot/boot.S`: ``` .section .text.boot .global _start _start: /* Read the CPU ID. We only want Core 0 to run our Swift code. */ mrs x1, mpidr_el1 and x1, x1, #3 cbz x1, 2f /* Park secondary cores in an infinite wait loop. */ 1: wfe b 1b /* Set up the stack for Core 0. */ 2: ldr x0, =__stack_top mov sp, x0 /* Jump to our Swift entry point. */ bl kernel_main /* Halt if we ever return. */ b 1b ``` QEMU 可以启动多个虚拟核心,但目前只有第一个核心应该执行我们的内核。其他核心则等待(使用 `wfe`),等待一个尚未发出的事件。 ## 提供缺失的函数 Embedded Swift 使用的 `print` 实现需要一种至少能写入一个字符的方式。在 QEMU 的 `virt` 机器上,ARM 的 PL011 UART(https://krinkinmu.github.io/2020/11/29/PL011.html)被映射在 `0x09000000`。因此 `putchar` 可以直接写入那个内存映射寄存器: ``` // QEMU 的 PL011 UART 寄存器 let QEMU_UART_REGISTER: UInt = 0x0900_0000 @unsafe func putchar(_ c: CInt) -> CInt { let uart = unsafe UnsafeMutablePointer(bitPattern: QEMU_UART_REGISTER)! unsafe uart.pointee = UInt8(c) return c } ``` **这并不是一个屏幕驱动程序**,而是一个串口输出驱动程序。QEMU 随后会在终端中显示**由模拟 UART 接收到**的字节(因为我将使用 `-nographic` 选项来执行它)。 另一个缺失的函数是 `memmove`。即使我没有使用普通的标准库,Swift 的底层代码仍然需要基本的内存操作。对于这个第一次的实验,我提供了一个小型实现,它会根据源和目标是否重叠来决定向前或向后复制: ``` @unsafe func memmove( _ dest: UnsafeMutableRawPointer, _ src: UnsafeRawPointer, _ n: Int ) -> UnsafeMutableRawPointer { let d = unsafe dest.assumingMemoryBound(to: UInt8.self) let s = unsafe src.assumingMemoryBound(to: UInt8.self) if unsafe d < s { for i in 0..<n { unsafe d[i] = s[i] } } else { for i in (0..<n).reversed() { unsafe d[i] = s[i] } } return unsafe dest } ``` 有了这些之后,让我们再次构建: ``` > swift build --triple aarch64-none-none-elf --toolset toolset.json --build-system native ``` 然后用 QEMU 启动: ``` > qemu-system-aarch64 -machine virt -cpu cortex-a57 -nographic -kernel .build/aarch64-none-none-elf/debug/kernel ``` 接下来……它会永远运行下去,因为内核被困在它的无限循环里。 这离一个有用的内核还差得远,但它确实是一个真实的 AArch64 ELF 可执行文件。它从一个汇编入口点开始,不链接任何标准库,并在手动配置的栈之上调用 Swift 代码。对于第一个 hello world 来说,这已经足够了。 ## 接下来会怎样? 该项目是故意未完成的。下一步还没有实现,但它们为后续工作指明了方向: 1. 在启动期间清空 `.bss` 段, 2. 将 UART 寄存器封装在一个小型 Swift 抽象之后(为了简单起见,我把 QEMU 的硬编码了), 3. 弄清楚 Embedded Swift 能够支持哪些分配特性, 4. 研究中断和定时器, 5. 最终用能够调度实际工作的东西来替换这个无限循环。 在某个时候,我也可能想尝试帧缓冲区、真正的键盘驱动程序和一个最小的交互式环境。但在构建用户界面之前,我需要先理解它底下的机器。 ## 与人类的交互 目前,这台机器在说"你好"。打印一条消息挺不错的,但仍然只是一种单向对话。PL011 UART 也有一个接收寄存器。在从它读取之前,我需要先检查它的标志寄存器。第 4 位,称为 `RXFE`,告诉我们接收 FIFO 是否为空。如果它是空的,我们就等待。 ``` let QEMU_UART_FLAG_REGISTER: UInt = 0x0900_0018 @unsafe func readChar() -> UInt8 { let uart = unsafe UnsafeMutablePointer(bitPattern: QEMU_UART_REGISTER)! let flags = unsafe UnsafeMutablePointer(bitPattern: QEMU_UART_FLAG_REGISTER)! // 在接收 FIFO 为空时,RXFE 位会被置位。 while unsafe (flags.pointee & 0x10) != 0 {} return unsafe uart.pointee } ``` 这是**轮询**。CPU 会反复检查 UART,直到一个字符到来,而这是做这类事情的一种非常**低效**的方式。真正的内核最终会使用中断,但对于第一个实现来说,这样做已经足够简单了。 内核现在可以将字符回显到终端了: ``` @c func kernel_main() { print("Swift kernel ready.") let _ = unsafe putchar(62) let _ = unsafe putchar(32) var ignoreLineFeed = false while true { let character = unsafe readChar() if character == 13 { let _ = unsafe putchar(10) let _ = unsafe putchar(62) let _ = unsafe putchar(32) ignoreLineFeed = true } else if character == 10 { if !ignoreLineFeed { let _ = unsafe putchar(10) let _ = unsafe putchar(62) let _ = unsafe putchar(32) } ignoreLineFeed = false } else { let _ = unsafe putchar(CInt(character)) ignoreLineFeed = false } } } ``` 数字 `62` 和 `32` 只是 `>` 和一个空格的 ASCII 码。我可以创建一个小的字符串写入抽象,但目前我更愿意让机制保持可见。 现在运行相同的 QEMU 命令会给我们一个提示符: ``` Swift kernel ready. > hello kernel hello kernel > ``` 这仍然是一个非常小的功能。没有行编辑器,没有退格键处理,没有命令解析器……而且 CPU 因为使用轮询而非中断而一直忙于等待每个字符。 对于一个从 `print("Hello... world?")` 开始的程序来说,感觉这是一个很好的停下来的地方,并开始写一份 TODO 列表来改进所有这些。 完整代码可在我的私有 git 空间(https://carette.xyz/posts/free-of-github/)中找到:https://git.carette.xyz/k0pernicus/swift-kernel 玩得开心!

相似文章

Apple 内部:内核中的 Swift

Lobsters Hottest

Apple 已开始通过一项名为 KernelKit 的新工作将 Swift 集成到内核中,嵌入式 Swift 运行时出现在 macOS 和 iOS 中,标志着向内存安全内核扩展迈出了一步。

仅运行NES模拟器的内核

Lobsters Hottest

一个旨在无需任何操作系统的情况下运行NES模拟器的最小化内核,并附带像Mandelbrot分形可视化这样的额外程序。