|
1 | 1 | # Baremetal use of Embedded Swift
|
2 | 2 |
|
3 |
| -🚧 Under construction... |
| 3 | +Programming without an SDK for maximum control and minimal size |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +Developing in "baremetal mode" means programming directly for the hardware without any operating system or SDK abstractions. This provides maximum control and minimum codesize, but requires deeper understanding of the hardware. |
| 8 | + |
| 9 | +Embedded Swift supports true baremetal development, where you directly program hardware peripherals by manipulating memory-mapped registers. This approach is suitable for: |
| 10 | + |
| 11 | +- Extremely resource-constrained environments |
| 12 | +- Safety-critical applications that need deterministic behavior |
| 13 | +- Projects requiring full control over every aspect of the hardware |
| 14 | +- Educational purposes to understand how hardware and software interact |
| 15 | + |
| 16 | +## Key components of a baremetal project |
| 17 | + |
| 18 | +A complete baremetal project typically includes: |
| 19 | + |
| 20 | +1. **Startup code** - Sets up the initial environment before `main()` runs |
| 21 | +2. **Interrupt vector table** - Maps hardware events to handler functions |
| 22 | +3. **Linker script** - Defines memory layout and sections |
| 23 | +4. **Hardware register definition code** - To interface with peripherals |
| 24 | +5. **Runtime support** - E.g. implementations of functions like `memcpy` and `malloc` |
| 25 | +6. **Application logic** - Your actual embedded application code |
| 26 | + |
| 27 | +For a full working example of all these components, see <doc:STM32BaremetalGuide>. The rest of this document provides general platform-independent guidance when working in baremetal mode. However, much of the problem space of baremetal development is outside of the scope of this documentation, and requires deeper familiary with your specific setup. This information is typically provided by your board vendor, the spec of the MCU, the ISA spec of the execution core, the C toolchain documentation, ELF file format spec, and other similar sources. |
| 28 | + |
| 29 | +## Hardware access patterns |
| 30 | + |
| 31 | +### 1. Direct memory access using pointers |
| 32 | + |
| 33 | +Note that using UnsafePointers to directly access registers at known addresses is not recommended in almost any situation because doing that correctly is tricky, it's inherently unsafe (and shifts the safety responsibility to the user) and can easily cause very hard to debug runtime problems. However, sometimes it might be neccessary to use this method. |
| 34 | + |
| 35 | +One common issue when directly accessing hardware registers is that the compiler may optimize away repeated reads or writes, thinking they're redundant. This is a problem that's inherent to pointers in most programming languages (including C and C++). |
| 36 | + |
| 37 | +```swift |
| 38 | +// Accessing a register at address 0x40010000 |
| 39 | +let gpioBase = 0x40010000 |
| 40 | + |
| 41 | +// ❌ Do not do this - the memory write might be optimized out |
| 42 | +let gpioDataRegister = UnsafeMutablePointer<UInt32>(bitPattern: gpioBase)! |
| 43 | +gpioDataRegister.pointee |= (1 << 5) // Set bit 5 |
| 44 | +``` |
| 45 | + |
| 46 | +Hardware registers are volatile - their values can change independently of your program's execution (due to hardware events, interrupts, or peripheral operation). To ensure correct behavior, you must inform the compiler that these memory locations are volatile, preventing unwanted optimizations: |
| 47 | + |
| 48 | +```swift |
| 49 | +// ✅ Correct approach using volatile operations |
| 50 | +@inline(never) |
| 51 | +func volatileStore(_ value: UInt32, to address: UInt) { |
| 52 | + UnsafeMutablePointer<UInt32>(bitPattern: address)!.pointee = value |
| 53 | +} |
| 54 | + |
| 55 | +@inline(never) |
| 56 | +func volatileLoad(from address: UInt) -> UInt32 { |
| 57 | + return UnsafeMutablePointer<UInt32>(bitPattern: address)!.pointee |
| 58 | +} |
| 59 | + |
| 60 | +// Using the volatile operations |
| 61 | +let gpioBase = 0x40010000 |
| 62 | +let currentValue = volatileLoad(from: gpioBase) |
| 63 | +volatileStore(currentValue | (1 << 5), to: gpioBase) |
| 64 | +``` |
| 65 | + |
| 66 | +The `@inline(never)` attribute prevents the compiler from inlining these functions, which helps ensure the memory accesses actually occur. |
| 67 | + |
| 68 | +Consider using Swift MMIO (see below) which uses compiler intrinsics for true volatile semantics and abstracts this problem away from the user. |
| 69 | + |
| 70 | +### 2. Using Swift MMIO for type-safe register access |
| 71 | + |
| 72 | +Swift MMIO provides strongly-typed access to memory-mapped hardware and can automatically generate register definitions from SVD files. It can provide a higher-level type-safe access to hardware registers, for example: |
| 73 | + |
| 74 | +```swift |
| 75 | +// Type-safe register access |
| 76 | +gpioa.odr.write { $0.odr5 = true } // Set pin 5 high |
| 77 | +``` |
| 78 | + |
| 79 | +See [Swift MMIO](https://github.com/apple/swift-mmio/) for details and <doc:STM32BaremetalGuide> for a guided example of using it. |
| 80 | + |
| 81 | +## Creating a linker script and data segment relocation |
| 82 | + |
| 83 | +A baremetal project requires a custom linker script to properly place code and data in memory. This is a relatively complex task to get right, and requires understanding of the memory map, flash and RAM setup of your target device, as well as understanding the ELF file format and what code/data sections do you expect your entire program to use. |
| 84 | + |
| 85 | +Besides just defining the position of code at runtime, a linker script needs to also: |
| 86 | +- Handle and coordinate the initialization of "zero-fill" global variables (aka the BSS section) |
| 87 | +- Handle and coordinate the initialization of non-zero writable global variables (copying initial values from flash to RAM) |
| 88 | + |
| 89 | +Here's an incomplete sketch of an example linker script: |
| 90 | + |
| 91 | +``` |
| 92 | +MEMORY |
| 93 | +{ |
| 94 | + flash (rx) : ORIGIN = 0x08000000, LENGTH = 1024K |
| 95 | + sram (rwx) : ORIGIN = 0x20000000, LENGTH = 320K |
| 96 | +} |
| 97 | +
|
| 98 | +SECTIONS |
| 99 | +{ |
| 100 | + .text : { *(.vectors*) ; *(.text*) } > flash |
| 101 | + .rodata : { *(.rodata*) ; *(.got*) } > flash |
| 102 | + .bss : { *(.bss*) } > sram ; needs runtime handling |
| 103 | + .data : { *(.data*) } > sram AT>flash ; needs runtime handling |
| 104 | +
|
| 105 | + ... |
| 106 | +} |
| 107 | +``` |
| 108 | + |
| 109 | +A sketch of an example corresponding startup code (in C): |
| 110 | + |
| 111 | +```c |
| 112 | +void ResetISR(void) { |
| 113 | + // Initialize bss section |
| 114 | + uint8_t *bss = &__bss_start; |
| 115 | + while (bss < &__bss_end) *bss = 0; |
| 116 | + |
| 117 | + // Initialize read-write data section |
| 118 | + extern uint8_t __data_start_flash, __data_start, __data_end; |
| 119 | + uint8_t *src = &__data_start_flash; |
| 120 | + uint8_t *dst = &__data_start; |
| 121 | + while (dst < &__data_end) *dst++ = *src++; |
| 122 | + |
| 123 | + // Call main |
| 124 | + extern int main(void); |
| 125 | + main(); |
| 126 | + |
| 127 | + // If main returns, loop forever |
| 128 | + while(1); |
| 129 | +} |
| 130 | +``` |
| 131 | +
|
| 132 | +Both these code snippets are not fully functional, they are only demonstrating the complexity of what the linker script and startup code need to do to initialize handle global variables. |
| 133 | +
|
| 134 | +Tip: If this handling is not done correctly, a typical symptom is that global variables "don't work", i.e. reading from them doesn't yield the right value, and writing to them doesn't persist. A good way to double check this is by using a debugging a dumping memory at runtime and checking if it matches the virtual memory layout of the ELF file. |
| 135 | +
|
| 136 | +## Vector table and interrupts |
| 137 | +
|
| 138 | +The vector table is a critical component that maps hardware interrupts and exceptions to specific handler functions in your code. It's typically placed at the beginning of flash memory and contains function pointers that the processor uses when responding to various events. |
| 139 | +
|
| 140 | +The processor automatically jumps to the appropriate handler when an interrupt occurs by indexing into this table. If you don't provide a specific handler, it's common to point to a default handler that can help with debugging. |
| 141 | +
|
| 142 | +Example vector table structure: |
| 143 | +
|
| 144 | +```c |
| 145 | +// Vector table for ARM Cortex-M |
| 146 | +__attribute__((section(".vectors"))) const void *VectorTable[] = { |
| 147 | + (void*)0x20008000, // Initial stack pointer |
| 148 | + ResetISR, // Reset handler |
| 149 | + DefaultHandler, // NMI handler |
| 150 | + DefaultHandler, // Hard fault handler |
| 151 | + // Additional vectors as needed |
| 152 | +}; |
| 153 | +``` |
| 154 | + |
| 155 | +If you want to actually handle an interrupt (e.g. a GPIO or UART interrupt) in your Swift code, you can forward declare the function in C, and define it using `@cdecl` in Swift: |
| 156 | + |
| 157 | +```c |
| 158 | +// In startup.c or header file |
| 159 | +void UART1_IRQHandler(void); |
| 160 | +``` |
| 161 | +
|
| 162 | +```swift |
| 163 | +// In Swift code |
| 164 | +@_cdecl("UART1_IRQHandler") |
| 165 | +func uartInterruptHandler() { |
| 166 | + // Handle UART interrupt in Swift |
| 167 | + // Clear interrupt flags, process received data, etc. |
| 168 | +} |
| 169 | +``` |
| 170 | + |
| 171 | +However, note that Swift currently does not provide any form of synchronization or "interrupt safety" for the code that executes the interrupt. Namely, if your interrupt handler modifies global variables that are also accessed by your main program, you need to be careful about data races and ensure proper synchronization (such as using atomic operations or disabling interrupts during critical sections). Additionally, interrupt handlers should be kept short and fast to avoid blocking other important system events. |
| 172 | + |
| 173 | +## Building a minimal project |
| 174 | + |
| 175 | +To build an Embedded Swift baremetal project with SwiftPM, you will need a setup like this: |
| 176 | + |
| 177 | +- Your main application target defined in Package.swift. |
| 178 | +- A helper C code helper target defined in Package.swift - this will contain your C startup code, vector table and possibly an assembly file. |
| 179 | +- Invoke `swift build` with a `--triple` argument that specifies. |
| 180 | +- Use a `toolset.json` file that defines the common Swift and C compilation flags, and linking flags. This will e.g. enable the Embedded Swift mode when compiling Swift code, and point the linker at the right linker script. |
| 181 | + |
| 182 | +Example file structure: |
| 183 | + |
| 184 | +``` |
| 185 | +MyBaremetalProject/ |
| 186 | +├── Package.swift |
| 187 | +├── toolset.json |
| 188 | +├── Sources/ |
| 189 | +│ ├── MyApp/ |
| 190 | +│ │ └── main.swift |
| 191 | +│ └── CStartup/ |
| 192 | +│ ├── startup.c |
| 193 | +│ ├── linker.ld |
| 194 | +│ └── include/ |
| 195 | +│ └── startup.h |
| 196 | +└── README.md |
| 197 | +``` |
| 198 | + |
| 199 | +Example toolset.json file: |
| 200 | + |
| 201 | +```json |
| 202 | +{ |
| 203 | + "schemaVersion": "1.0", |
| 204 | + "swiftCompiler": { |
| 205 | + "extraCLIOptions": [ |
| 206 | + "-enable-experimental-feature", "Embedded", |
| 207 | + "-Xclang-linker", "-nostdlib", |
| 208 | + ] |
| 209 | + }, |
| 210 | + "linker": { |
| 211 | + "extraCLIOptions": [ |
| 212 | + "-T", "Sources/CStartup/linker.ld", |
| 213 | + "--gc-sections", |
| 214 | + ] |
| 215 | + } |
| 216 | +} |
| 217 | +``` |
| 218 | + |
| 219 | +Example Package.swift file: |
| 220 | + |
| 221 | +```swift |
| 222 | +// swift-tools-version: 5.9 |
| 223 | +import PackageDescription |
| 224 | + |
| 225 | +let package = Package( |
| 226 | + name: "MyBaremetalProject", |
| 227 | + products: [ |
| 228 | + .executable( |
| 229 | + name: "MyBaremetalApp", |
| 230 | + targets: ["MyApp"] |
| 231 | + ) |
| 232 | + ], |
| 233 | + targets: [ |
| 234 | + .executableTarget( |
| 235 | + name: "MyApp", |
| 236 | + dependencies: ["CStartup"], |
| 237 | + swiftSettings: [ |
| 238 | + .enableExperimentalFeature("Embedded") |
| 239 | + ] |
| 240 | + ), |
| 241 | + .target( |
| 242 | + name: "CStartup", |
| 243 | + publicHeadersPath: "include" |
| 244 | + ) |
| 245 | + ] |
| 246 | +) |
| 247 | +``` |
| 248 | + |
| 249 | +Example compilation invocation: |
| 250 | + |
| 251 | +```bash |
| 252 | +swift build --triple armv7em-none-eabi --toolset toolset.json |
| 253 | +``` |
0 commit comments