A minimal kernel in Swift, running in QEMU
· 9 min read
I started a small experiment: writing a very basic kernel in Swift, and running on QEMU.
The goal is obviously not to replace Linux or any other popular kernel, but just to have fun and understand what is required to make a program run without an operating system underneath it.
Actually I made a similar experiment years before with arOS 10 years ago.
For the moment, the kernel does only one thing: it prints a message through QEMU and then waits forever, which is enough for a first deep dive.
Swift Embedded #
The project uses Embedded Swift, which is a subset of Swift designed for environments where there is no operating system and no standard library available (in the usual sense).
My first Package.swift was very small:
// 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),
],
)
]
)
And the first Swift file was almost insulting in its simplicity:
// Sources/swift_kernel/test.swift
print("Hello... world?")
I tried to run it:
> swift run
<unknown>:0: error: unable to load standard library for target 'arm64-apple-macosx27.0.0'
Oops. It seems like my current Swift compiler does not yet ship with the Embedded Swift standard library.
As I missed in the documentation:
Since Embedded Swift is still experimental and not yet supported in public Swift releases, you’ll need to use a development toolchain.
So, let’s install the development toolchain and test again:
> swiftly install main-snapshot && swiftly use main-snapshot
> swift run
[...]
Hello... world?
Success!
At this point I had confirmed that Embedded Swift could compile and run a small program.
The next step was to compile for a machine with no operating system at all.
QEMU setup #
For this project I will test on a machine emulator called QEMU, which is one of the most famous machine emulators for hackers.
I am using QEMU’s virt machine on an Apple Silicon machine. The target is therefore aarch64-none-none-elf.
This target, designed as a triple, describes a 64-bit ARM machine with no operating system and no environment provided by a C runtime.
When QEMU starts, the CPU is in a raw state.
Before it can safely execute Swift code, I need to configure a stack and decide where the program starts.
I will need a minimal assembly file that:
- defines the
_startsymbol, - sets up a stack for the first core,
- jumps to our Swift entry function,
kernel_main, - and waits forever to keep the main running.
Compiler setup #
I have to tell the compiler that this is not a normal executable: the compiler must avoid linking the usual standard libraries, and the linker must use our linker script instead of a platform-specific one.
The project uses the following toolset.json, which will be passed to our 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"
]
}
}
Now, let’s build the project with the new target, the toolset, and Swift’s native build system:
> swift build --triple aarch64-none-none-elf --toolset toolset.json --build-system native
Unfortunately, the first linker attempt fails:
ld.lld: error: section type mismatch for .strtab
>>> <internal>:(.strtab): SHT_STRTAB
>>> output section .nonalloc: SHT_SYMTAB
ld.lld: error: section type mismatch for .shstrtab
>>> <internal>:(.shstrtab): SHT_STRTAB
>>> output section .nonalloc: SHT_PROGBITS
ld.lld: error: undefined symbol: putchar
ld.lld: error: undefined symbol: memmove
Hmm. What is going on here?
Because I am compiling for a bare-metal target (reminder: none-none-elf), there is no underlying operating system and no C standard library. Therefore there is no putchar, and there is no memmove either…
The section errors have a different origin.
With orphan handling enabled, the linker does not want to guess where ELF metadata should go. Therefore, the linker script must explicitly place the sections that I am keeping.
Bridging Swift to assembly #
I can’t use a normal Swift file with a main function. Instead, I expose a designated kernel entry function with @_cdecl, so that the assembly file can call it by its C-compatible name.
@_cdecl("kernel_main")
func kernel_main() {
print("Hello, Embedded Swift 😊")
while true {} // Keep the program running
}
The assembly entry point lives in 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 can start multiple virtual cores but, for now, only the first one should execute our kernel.
The other cores wait (using wfe), waiting for an event that it will not send yet.
Providing the missing functions #
The print implementation used by Embedded Swift needs a way to write at least one character.
On QEMU’s virt machine, the ARMs PL011 UART is mapped at 0x09000000.
So putchar can write directly to that memory-mapped register:
// QEMU's PL011 UART register
let QEMU_UART_REGISTER: UInt = 0x0900_0000
@_cdecl("putchar")
@unsafe func putchar(_ c: CInt) -> CInt {
let uart = unsafe UnsafeMutablePointer<UInt8>(bitPattern: QEMU_UART_REGISTER)!
unsafe uart.pointee = UInt8(c)
return c
}
This is not a screen driver but a serial output driver.
QEMU will then display the bytes received by the emulated UART in the terminal (because I will execute it with the -nographic option).
The other missing function is memmove.
Swift’s low-level code needs basic memory operations, even when I am not using a normal standard library.
For this first experiment, I provide a small implementation that copies forwards or backwards, depending on whether the source and destination overlap:
@_cdecl("memmove")
@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 if unsafe d > s {
for i in (0..<n).reversed() { unsafe d[i] = s[i] }
}
return unsafe dest
}
This is the first point where the project stops being ordinary application programming.
Here, I am no longer calling an operating-system API to print a string, but I am implementing the lower-level functions that the language runtime needs in order to exist.
Describing the memory layout #
Now that I did implement the Swift code, the linker needs to know where the kernel is loaded and where each part of the executable belongs.
This is the role of linker.ld.
QEMU’s virt machine loads an AArch64 kernel at 0x40080000 in this setup:
ENTRY(_start)
. = 0x40080000;
The linker script then places the boot code first, followed by read-only data, initialized data, uninitialized data, and a stack:
.text : {
KEEP(*(.text.boot))
*(.text*)
}
.rodata : ALIGN(8) {
*(.rodata*)
}
.data : ALIGN(8) {
*(.data*)
}
.bss (NOLOAD) : ALIGN(16) {
__bss_start = .;
*(.bss*)
*(COMMON)
__bss_end = .;
}
.stack (NOLOAD) : ALIGN(16) {
. += 0x20000;
__stack_top = .;
}
The assembly code uses the __stack_top symbol to initialize the stack pointer.
The stack is currently 128 kB, which is an unreasonable amount for a kernel that only prints one sentence, but it gives Swift some room to call functions while this project is still experimental.
Finally, the script explicitly places the ELF metadata sections. This avoids the orphan-section errors from the first linker attempt:
/DISCARD/ : {
*(.comment)
*(.debug*)
*(.eh_frame*)
*(.swift_*)
}
.symtab : { *(.symtab) }
.strtab : { *(.strtab) }
.shstrtab : { *(.shstrtab) }
Hello, Embedded Swift #
Now, let’s build it
> swift build --triple aarch64-none-none-elf --toolset toolset.json --build-system native
, launched with QEMU:
> qemu-system-aarch64 -machine virt -cpu cortex-a57 -nographic -kernel .build/aarch64-none-none-elf/debug/kernel
and…
Hello, Embedded Swift 😊
Then it keeps running forever, because the kernel is trapped in its infinite loop.
This is not close to a useful kernel, but it is a real AArch64 ELF executable.
It starts by an assembly entry point, links without a standard library, and it calls Swift code on top of a manually configured stack.
For a first hello world, this is already enough.
What comes next? #
The project is intentionally unfinished. The next steps are not implemented yet, but they provide a direction for the rest:
- clear the
.bsssection during boot, - hide the UART register behind a small Swift abstraction (I hardcoded QEMU ones for simplicity),
- understand which allocation features Embedded Swift can support,
- investigate interrupts and timers,
- and eventually replace this infinite loop with something that can schedule actual work.
At some point, I may also want to experiment with a framebuffer, a real keyboard driver, and a minimal interactive environment. But before building a user interface, I need to understand the machine underneath it.
Responding to a human #
For now, the machine says hello. Printing a message is nice, but it is still a one-way conversation.
The PL011 UART also has a receive register.
Before reading from it, I need to check its flag register.
Bit 4, called RXFE, tells us whether the receive FIFO is empty.
If it is empty, we wait.
let QEMU_UART_FLAG_REGISTER: UInt = 0x0900_0018
@unsafe func readChar() -> UInt8 {
let uart = unsafe UnsafeMutablePointer<UInt8>(bitPattern: QEMU_UART_REGISTER)!
let flags = unsafe UnsafeMutablePointer<UInt8>(bitPattern: QEMU_UART_FLAG_REGISTER)!
// The RXFE bit is set while the receive FIFO is empty.
while unsafe (flags.pointee & 0x10) != 0 {}
return unsafe uart.pointee
}
This is polling. The CPU repeatedly checks the UART until a character arrives and it is a very inefficient way to do something like that. A real kernel would eventually use interrupts, but it is simple enough for a first implementation.
The kernel can now echo characters back to the terminal:
@_cdecl("kernel_main")
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
}
}
}
The numbers 62 and 32 are simply the ASCII codes for > and a space.
I could create a small string-writing abstraction, but for the moment I prefer to keep the mechanism visible.
Running the same QEMU command now gives us a prompt:
Swift kernel ready.
> hello kernel
hello kernel
>
This is still a very small feature.
There is no line editor, no backspace handling, no command parser… and the CPU is busy waiting for every character because it uses polling instead of interrupts.
For a program that started with print("Hello... world?"), this feels like a good place to stop and start writing a TODO list to improve all of that.
The full code is available on my private git space: https://git.carette.xyz/k0pernicus/swift-kernel.
Have fun!