try! Swift Tokyo 2026で「Writing Swift Without an OS」というタイトルで発表しました。
発表の概要
Raspberry Pi Pico(RP2040、ARM Cortex-M0+)をターゲットに、OSなし・Cなし・100% SwiftでLEDの点滅からテトリスまでを実装する、という内容です。
この発表は組み込みシステムの話というよりは、ソフトウェアが動く基本的な仕組みの話です。iOSアプリで@mainをつけるだけでアプリが起動しますが、その裏側ではスタックの設定、メモリの初期化、ランタイムの準備といった処理が行われています。普段はOSやフレームワークが用意してくれているこれらの仕組みを、マイコン上で自分の手で一から書いていきます。マイコンを使うのは通常のPCでは複雑すぎてできないことができ、すべてが見えているからです。MacやiPhoneでも同じことが起きていますが、OSによって隠されています。
発表は大きく2つのパートで構成しました。
Part 1: main()を呼ぶまで
電源を入れてからSwiftのmain()関数を呼べるようになるまでに必要なことを順番に見ていきます。
- Boot2: Flashを設定してCPUがコードを実行できるようにする(256バイト)
- ベクタテーブル: CPUにスタックポインタの位置と最初に呼ぶ関数を教える
- リセットハンドラ: ハードウェアから最初に呼ばれるSwift関数
- メモリ初期化: グローバル変数の初期値をROMからRAMにコピーし、残りをゼロクリア
これらをSwiftの@section、@used、@cといった属性を使って実装します。iOSでは使う機会のほとんどない属性ですが、ここではSwiftとハードウェアの橋渡しになります。
Part 2: main()の先
main()の先では、アプリケーションの実行をどう構成するかを考えます。iOSではUIKitやFoundationがRunLoopやスレッドを提供してくれますが、ベアメタル環境ではそれはありません。
3つの実行パターンを実際のコードで紹介しました。
- スーパーループ: while trueの中ですべてを順番に実行する。ゲームコントローラの実装で使用
- 協調スケジューラ: タスクをインターバルと共に登録し、時間が来たら実行する。タスクごとに状態管理を分離できる
- ハイブリッド: スケジューラに割り込みを組み合わせる。テトリスの実装で使用。音楽はSysTick割り込みで正確に再生し、ボタン入力はGPIO割り込みで即座に反応する
発表で触れられなかった補足
メモリマップドI/OとVolatile
発表ではメモリマップドI/Oについて「すべてはメモリの読み書き」と説明しました。CPUがLEDを光らせるのも、UARTで通信するのも、すべて特定のメモリアドレスに値を書き込むことで行われます。
ここで重要になるのがVolatileです。発表では簡単に触れましたが、もう少し詳しく説明します。
LEDをトグルするコードでは、ループの毎回の繰り返しで同じアドレスに同じ値を書き込みます。
while true { Register(address: 0xD000_001C).store(1 << 25) // ビットを反転 // ... }
普通のコンパイラの最適化では「同じ場所に同じ値を書いているから、最初の1回だけでいい」と判断されます。しかしハードウェアレジスタへの書き込みには副作用があり、書き込むたびに物理的にLEDがトグルします。コンパイラが書き込みを省略すると、その副作用も消えてしまいます。
Register構造体の内部ではVolatileMappedRegisterを使っています。Volatileはコンパイラに2つのことを保証させます。
- 省略禁止: 読み書きを最適化で削除しない
- 順序保持: volatile同士の読み書きの順序を入れ替えない
順序の保持も重要です。ペリフェラルを有効にしてからピンを設定する、という順番が入れ替わるとハードウェアは正しく動きません。
SwiftコンパイラとPIC、GOTの問題
発表では時間の関係で触れられませんでしたが、開発中に遭遇した興味深い問題があります。
グローバル変数を使うためにはメモリの初期化が必要です。初期化コードは@_extern属性でリンカシンボル(__data_start、__data_endなど)を参照し、ROMからRAMへのコピー範囲を知ります。
@_extern(c, "__data_start") nonisolated(unsafe) var __data_start: UInt8
ここで問題が起きます。Swiftコンパイラは常にPIC(Position Independent Code)を生成します。これはコンパイラのソースコード(lib/IRGen/IRGen.cppのcreateTargetMachine関数)でReloc::PIC_がハードコーディングされているためで、変更するオプションはありません。
llvm::TargetMachine *TargetMachine = Target->createTargetMachine( EffectiveTriple, CPU, targetFeatures, TargetOpts, Reloc::PIC_, // ← ハードコーディング cmodel, OptLevel);
PICではグローバル変数へのアクセスにGOT(Global Offset Table)を経由します。GOTにはグローバル変数の実際のアドレスが格納されています。
問題は、リンカースクリプトにGOTの配置を明示しない場合、リンカのデフォルト動作でGOTがRAMに配置されることです。すると以下の鶏と卵のどちらが先かという問題が発生します。
initializeMemorySections()が@_externの__data_startにアクセスする- PICコードなのでGOT経由でアクセスする
- GOTはRAMにある
- しかしRAMはまだ初期化されていない(
initializeMemorySections()がやろうとしていること) - 未初期化のGOTから不定値を読む → クラッシュ
修正はリンカースクリプトに3行追加するだけです。
.got : {
*(.got)
*(.got.plt)
} > ROM
GOTをROMに配置することで、メモリ初期化前でも常にGOTの値を正しく読めるようになります。静的バイナリではGOTの値はリンク時に確定して変わらないので、ROMに置いても問題ありません。
これはバイナリダンプで確認できます。
# GOTがROMにある場合(正常動作) $ llvm-objdump -h Application 4 .got 00000010 10000b4c ← ROM ✓ # GOTの記述がない場合(クラッシュ) $ llvm-objdump -h Application 6 .got 00000010 2000000c ← RAM ✗
リンカースクリプトとiOS/macOS
リンカースクリプトはベアメタルや組み込み開発で使われるもので、iOSやmacOSのアプリ開発では使いません。Apple環境ではリンカ(ld64)にプラットフォームのルールがハードコーディングされており、実行時の配置はカーネルとdyld(dynamic linker/loader)が仮想メモリ上で決定します。
ベアメタルではOSがないため、物理メモリのどこに何を置くかを自分でリンカースクリプトに書きます。やっていることの本質は同じで、誰がやるかが違うだけです。
ビルドの仕組み
発表ではswift buildコマンドだけでビルドできると説明しました。
$ swift build \
--toolset toolset.json \
--triple armv6m-none-none-eabi
--tripleはターゲットのアーキテクチャを指定します。armv6m-none-none-eabiは4つの部分で構成されています。
armv6m: CPU(ARM Cortex-M0+)none: ベンダー(特定のベンダーなし)none: OS(OSなし)eabi: ABI(Embedded ARM ABI)
--toolsetはコンパイラとリンカのオプションをまとめたJSONファイルです。Embedded Swiftモードの有効化、標準ライブラリなしのリンク(-nostdlib)、リンカースクリプトの指定などが含まれています。
Swift 6.3以降、Embedded Swiftはリリース版のSwiftツールチェーンに含まれるようになりました。ただしXcodeに同梱されているSwiftにはARMのクロスコンパイルターゲットが含まれていないため、swift.orgからOSS版のSwift 6.3以上をインストールする必要があります。
ARM Cortex-M0+のメモリマップ
ARM Cortex-M0+は4GBのアドレス空間を固定的に分割しています。
| アドレス | 用途 |
|---|---|
0x00000000 |
Code(512MB) |
0x20000000 |
SRAM(512MB) |
0x40000000 |
Peripherals(512MB) |
0xE0000000 |
System(SysTick、NVIC) |
RP2040はこの中に2MBのFlash(0x10000000)と256KBのSRAM(0x20000000)を持っています。RAMが0x20000000から始まるのはARMのアーキテクチャ仕様でSRAM領域がそう定められているからです。
スタックポインタの初期値0x20040000は、RAMの開始0x20000000 + RAMのサイズ0x40000(256KB)= RAMの末尾です。スタックはここから下(アドレスの小さい方向)に伸びていきます。
サンプルコード
サンプルコードはGitHubで公開しています。段階的に複雑になる構成で、起動処理からテトリスまでのコードを追えるようにしてあります。
- tryswift2026: 発表で使用したサンプルコード
- pico-bare-swift: 汎用的なサンプルコード(LED点滅、OLEDディスプレイ表示、ボタン入力など)
何かの原理や仕組みを学ぶための最も優れた方法の一つは、自分で作ってみることです。すべてをSwiftで書き、コンピュータの動作原理を学ぶ。それはソフトウェア開発の原始的な楽しさを与えてくれます。