Een minimale kernel in Swift, draaiend in QEMU: Een reis naar een "wild pointer"

Update (28 september 2026)

Max Desiatov wees me erop dat het @c Swift-attribuut zou moeten werken, in plaats van het declareren van functies met @cdecl. Het @c attribuut markeert een globale functie als een C-functie geïmplementeerd in Swift, wat het @cdecl attribuut formaliseert. Een link naar het voorstel is hier beschikbaar. Ik heb het blogbericht en de code naar aanleiding van deze opmerking bijgewerkt. Bedankt, Max!

---

Ik ben begonnen met een klein experiment: het schrijven van een zeer basis kernel in Swift en deze draaien op QEMU. Het doel is uiteraard niet om Linux of een andere populaire kernel te vervangen, maar simpelweg om plezier te hebben en te begrijpen wat er nodig is om een programma te laten draaien zonder dat er een besturingssysteem onder ligt.

Eigenlijk hebde ik tien jaar geleden al een soortgelijk experiment gedaan met arOS. Voorlopig doet de kernel slechts één ding: hij print een bericht via QEMU en wacht daarna eeuwig. Dat is voldoende voor een eerste diepe duik.

Embedded Swift

Het project maakt gebruik van Embedded Swift, een subset van Swift die is ontworpen voor omgevingen waar geen besturingssysteem en geen standaardbibliotheek beschikbaar zijn (in de gebruikelijke zin).

Mijn eerste Package.swift was zeer beknopt:

// 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),
            ],
        )
    ]
)

En het eerste Swift-bestand was bijna beledigend in zijn eenvoud:

// Sources/swift_kernel/test.swift
print("Hello... world?")

Toen ik probeerde het uit te voeren: > swift run <unknown>:0: error: unable to load standard library for target 'arm64-apple-macosx27.0.0'

Oeps. Het lijkt erop dat mijn huidige Swift-compiler nog niet is geleverd met de Embedded Swift-standaardbibliotheek. Zoals ik in de documentatie had gemist:

Aangezien Embedded Swift nog experimenteel is en nog niet wordt ondersteund in publieke Swift-releases, moet je een development toolchain gebruiken.

Dus, laten we de development toolchain installeren en het opnieuw proberen:

> swiftly install main-snapshot && swiftly use main-snapshot > swift run [...] Hello... world?

Succes! Op dit punt had ik bevestigd dat Embedded Swift een klein programma kon compileren en uitvoeren. De volgende stap was om te compileren voor een machine zonder besturingssysteem.

QEMU-opstelling

Voor dit project test ik op een machine-emulator genaamd QEMU, een van de bekendste emulators voor hackers. Ik gebruik de virt machine van QEMU op een Apple Silicon-machine. Het doelwit (target) is daarom aarch64-none-none-elf.

Dit target, ontworpen als een "triple", beschrijft een 64-bit ARM-machine zonder besturingssysteem en zonder omgeving die door een C-runtime wordt verzorgd. Wanneer QEMU start, bevindt de CPU zich in een ruwe staat. Voordat er veilig Swift-code kan worden uitgevoerd, moet ik een stack configureren en bepalen waar het programma start.

Ik heb een minimaal assembly-bestand nodig dat:

  • het _start symbool definieert;
  • een stack instelt voor de eerste core;
  • springt naar onze Swift entry-functie, kernel_main;
  • en eeuwig wacht om het hoofdprogramma draaiende te houden.

Compiler-instellingen

Ik moet de compiler laten weten dat dit geen normaal uitvoerbaar bestand is: de compiler moet het linken van de gebruikelijke standaardbibliotheken vermijden, en de linker moet ons linker-script gebruiken in plaats van een platformspecifiek script.

Het project gebruikt de volgende toolset.json, die wordt doorgegeven aan onze 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"
    ]
  }
}

Nu bouwen we het project met het nieuwe target, de toolset en het native bouwsysteem van Swift:

> swift build --triple aarch64-none-none-elf --toolset toolset.json --build-system native

Helaas mislukt de eerste linker-poging:

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

Wat is hier aan de hand? Omdat ik compileer voor een bare-metal target (none-none-none-elf), is er geen onderliggend besturingssysteem en geen C-standaardbibliotheek. Daarom is er geen putchar en ook geen memmove.

De sectiefouten hebben een andere oorsprong. Met "orphan handling" ingeschakeld wil de linker niet gissen waar de ELF-metadata heen moet. Daarom moet het linker-script expliciet de secties plaatsen die ik behoud.

Swift koppelen aan assembly

Ik kan geen normaal Swift-bestand met een main-functie gebruiken. In plaats daarvan stel ik een aangewezen kernel-entryfunctie beschikbaar met @c, zodat het assembly-bestand deze kan aanroepen via zijn C-compatibele naam.

@c
func kernel_main() {
    print("Hello, Embedded Swift 😊")
    while true {} // Houd het programma draaiende
}

Het assembly entry-point bevindt zich in Sources/boot/boot.S:

.section .text.boot
.global _start
_start:
    /* Lees de CPU ID. We willen dat alleen Core 0 onze Swift-code uitvoert. */
    mrs x1, mpidr_el1
    and x1, x1, #3
    cbz x1, 2f

    /* Parkeer secundaire cores in een oneindige wachtlus. */
1:  wfe
    b 1b

    /* Stel de stack in voor Core 0. */
2:  ldr x0, =__stack_top
    mov sp, x0

    /* Spring naar ons Swift entry point. */
    bl kernel_main

    /* Stop als er ooit wordt teruggekeerd. */
    b 1b

QEMU kan meerdere virtuele cores starten, maar voorlopig mag alleen de eerste onze kernel uitvoeren. De andere cores wachten (met wfe) op een event dat nog niet verzonden zal worden.

Ontbrekende functies toevoegen

De print-implementatie die door Embedded Swift wordt gebruikt, heeft een manier nodig om ten minste één karakter te schrijven. Op de virt machine van QEMU is de ARM PL011 UART gemapt op 0x09000000. De functie putchar kan dus direct naar dat memory-mapped register schrijven:

// QEMU's PL011 UART register
let QEMU_UART_REGISTER: UInt = 0x0900_0000

@c
@unsafe func putchar(_ c: CInt) -> CInt {
    let uart = unsafe UnsafeMutablePointer<UInt8>(bitPattern: QEMU_UART_REGISTER)!
    unsafe uart.pointee = UInt8(c)
    return c
}

Dit is geen schermdriver, maar een seriële output-driver. QEMU toont de bytes die door de geëmuleerde UART zijn ontvangen in de terminal (omdat ik het uitvoer met de -nographic optie).

De andere ontbrekende functie is memmove. De low-level code van Swift heeft basisgeheugenoperaties nodig, zelfs als ik geen normale standaardbibliotheek gebruik. Voor dit eerste experiment zorg ik voor een kleine implementatie die voorwaarts of achterwaarts kopieert, afhankelijk van of de bron en bestemming overlappen:

@c
@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
}

Dit is het eerste punt waar het project stopt met gewoon applicatieprogrammeren te zijn. Hier roep ik niet langer een API van het besturingssysteem aan om een string te printen, maar implementeer ik de low-level functies die de language runtime nodig heeft om te kunnen bestaan.

De geheugenindeling beschrijven

Nu de Swift-code is geïmplementeerd, moet de linker weten waar de kernel wordt geladen en waar elk deel van het uitvoerbare bestand thuishoort. Dit is de rol van linker.ld.

In deze opstelling laadt de virt machine van QEMU een AArch64-kernel op 0x40080000:

ENTRY(_start)
. = 0x40080000;

Het linker-script plaatst vervolgens eerst de boot-code, gevolgd door read-only data, geïnitialiseerde data, niet-geïnitialiseerde data en een 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 = .;
}

De assembly-code gebruikt het __stack_top symbool om de stack pointer te initialiseren. De stack is momenteel 128 kB, wat een onredelijke hoeveelheid is voor een kernel die slechts één zin print, maar het geeft Swift wat ruimte om functies aan te roepen terwijl dit project nog experimenteel is.

Ten slotte plaatst het script expliciet de ELF-metadatasecties. Dit voorkomt de orphan-section fouten van de eerste linker-poging:

/DISCARD/ : {
    *(.comment)
    *(.debug*)
    *(.eh_frame*)
    *(.swift_*)
}
.symtab : { *(.symtab) }
.strtab : { *(.strtab) }
.shstrtab : { *(.shstrtab) }

Hallo, Embedded Swift

Nu gaan we het bouwen: > swift build --triple aarch64-none-none-elf --toolset toolset.json --build-system native

En lanceren met QEMU: > qemu-system-aarch64 -machine virt -cpu cortex-a57 -nographic -kernel .build/aarch64-none-none-elf/debug/kernel

En... Hello, Embedded Swift 😊

Daarna blijft hij eeuwig draaien, omdat de kernel gevangen zit in zijn oneindige lus. Dit is nog lang niet een nuttige kernel, maar het is een echt AArch64 ELF-uitvoerbaar bestand. Het start via een assembly entry-point, linkt zonder standaardbibliotheek en roept Swift-code aan bovenop een handmatig geconfigureerde stack. Voor een eerste "hello world" is dit al voldoende.

Wat volgt er nu?

Het project is bewust onvoltooid. De volgende stappen zijn nog niet geïmplementeerd, maar ze geven een richting voor het vervolg:

  • de .bss sectie legen tijdens het booten;
  • het UART-register verbergen achter een kleine Swift-abstractie (ik heb de QEMU-registers voor de eenvoud hardcoded);
  • begrijpen welke allocatiefuncties Embedded Swift kan ondersteunen;
  • onderzoek doen naar interrupts en timers;
  • en uiteindelijk deze oneindige lus vervangen door iets dat daadwerkelijk werk kan plannen (scheduling).

Op een gegeven moment wil ik misschien ook experimenteren met een framebuffer, een echte toetsenborddriver en een minimale interactieve omgeving. Maar voordat ik een gebruikersinterface bouw, moet ik de machine eronder begrijpen.

Reageren op een mens

Voor nu zegt de machine "hallo". Een bericht printen is leuk, maar het is nog een eenrichtingsverkeer. De PL011 UART heeft ook een receive-register. Voordat ik daaruit lees, moet ik het flag-register controleren. Bit 4, genaamd RXFE, vertelt ons of de receive FIFO leeg is. Als deze leeg is, wachten we.

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)!

    // Het RXFE-bit is gezet terwijl de receive FIFO leeg is.
    while unsafe (flags.pointee & 0x10) != 0 {}
    return unsafe uart.pointee
}

Dit is polling. De CPU controleert herhaaldelijk de UART tot er een karakter arriveert. Dit is een zeer inefficiënte manier om dit te doen. Een echte kernel zou uiteindelijk interrupts gebruiken, maar voor een eerste implementatie is dit simpel genoeg.

De kernel kan nu karakters terug-echoën naar de terminal:

@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 { // Carriage Return
            let _ = unsafe putchar(10) // Line Feed
            let _ = unsafe putchar(62)
            let _ = unsafe putchar(32)
            ignoreLineFeed = true
        } else if character == 10 { // Line Feed
            if !ignoreLineFeed {
                let _ = unsafe putchar(10)
                let _ = unsafe putchar(62)
                let _ = unsafe putchar(32)
            }
            ignoreLineFeed = false
        } else {
            let _ = unsafe putchar(CInt(character))
            ignoreLineFeed = false
        }
    }
}

De getallen 62 en 32 zijn simpelweg de ASCII-codes voor > en een spatie. Ik zou een kleine abstractie voor het schrijven van strings kunnen maken, maar voorlopig geef ik de voorkeur aan het zichtbaar houden van het mechanisme.

Het uitvoeren van hetzelfde QEMU-commando geeft ons nu een prompt:

Swift kernel ready.
> hello kernel
hello kernel
>

Dit is nog steeds een zeer kleine functie. Er is geen regel-editor, geen afhandeling van backspace, geen command-parser... en de CPU is constant bezig te wachten op elk karakter omdat polling wordt gebruikt in plaats van interrupts.

Voor een programma dat begon met print("Hello... world?"), voelt dit als een goed punt om te stoppen en een TODO-lijst te maken om dit alles te verbeteren.

De volledige code is beschikbaar in mijn private git-ruimte: https://git.carette.xyz/k0pernicus/swift-kernel.

Veel plezier!