24/7 twenty-four seven

iOS/OS X application programing topics.

try! Swift 2026「OSのない世界でSwiftを書く」

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()関数を呼べるようになるまでに必要なことを順番に見ていきます。

  1. Boot2: Flashを設定してCPUがコードを実行できるようにする(256バイト)
  2. ベクタテーブル: CPUにスタックポインタの位置と最初に呼ぶ関数を教える
  3. リセットハンドラ: ハードウェアから最初に呼ばれるSwift関数
  4. メモリ初期化: グローバル変数の初期値を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つのことを保証させます。

  1. 省略禁止: 読み書きを最適化で削除しない
  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.cppcreateTargetMachine関数)でReloc::PIC_がハードコーディングされているためで、変更するオプションはありません。

llvm::TargetMachine *TargetMachine = Target->createTargetMachine(
    EffectiveTriple, CPU, targetFeatures, TargetOpts,
    Reloc::PIC_,  // ← ハードコーディング
    cmodel, OptLevel);

https://github.com/swiftlang/swift/blob/8c692ea543216e3577f0bc08ad290d78b71dce59/lib/IRGen/IRGen.cpp#L1195-L1198

PICではグローバル変数へのアクセスにGOT(Global Offset Table)を経由します。GOTにはグローバル変数の実際のアドレスが格納されています。

問題は、リンカースクリプトにGOTの配置を明示しない場合、リンカのデフォルト動作でGOTがRAMに配置されることです。すると以下の鶏と卵のどちらが先かという問題が発生します。

  1. initializeMemorySections()@_extern__data_startにアクセスする
  2. PICコードなのでGOT経由でアクセスする
  3. GOTはRAMにある
  4. しかしRAMはまだ初期化されていない(initializeMemorySections()がやろうとしていること)
  5. 未初期化の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で書き、コンピュータの動作原理を学ぶ。それはソフトウェア開発の原始的な楽しさを与えてくれます。