前回のRISC-Vの回は調査でした。オープンな命令セットの現在地を並べたわけですが、書きながらずっと思っていたのが「で、実際に動かすとどうなるのか」です。

そこで今回はOSの無い世界=ベアメタルに降りてみます。しかもボードは1枚も買いません。QEMUという道具を使えば、Macの上に仮想の組み込みボードを立てて、そこで自分のコードを動かせます😊

ブログとしてはC言語の回から続く流れですが、組み込みの回は今回が初めてです。

🧠 この記事でわかること
・★全コード掲載:3つのファイルだけで起動する最小構成
電源投入からmainまでに何が起きているのかを図解
リンカスクリプトという「配置の設計図」の読み方
・★つまずき:QEMUでは動くのに実機では一文字も出ないコード
GDBでレジスタとメモリを覗く(実機なしでデバッグできる)
ARM/RISC-V/Rustの3通りで書いてサイズを比較

ボードを買わずに、OSの無い世界へ

スポンサーリンク

普段プログラムを書くとき、OSがやってくれていることは驚くほど多いです。メモリを割り当ててくれる、printfが画面に出る、mainが呼ばれる——全部OSの仕事です。

ベアメタルにはそれがありません。電源が入った瞬間、CPUはあなたの書いた命令をいきなり実行し始めます。スタックすら自分で用意する必要がある。この「何もない状態」を一度触っておくと、組み込みの話がぐっと具体的になります。

実測環境は Apple M4 / macOS 26.5.2。ボードもデバッガ基板も使っていません。

用語集

この分野は初出の言葉が多いので、先に片付けます。

用語意味
ベアメタル「むき出しの金属」。OSを介さず、CPUの上で自分のコードが直接動く状態のこと
QEMU別のCPUのコンピュータをまるごと再現するソフト。Mac上に仮想のARMボードやRISC-Vボードを立てられます
クロスコンパイラ今動いているCPUとは別のCPU向けの機械語を作るコンパイラ。Mac(Apple Silicon)でARM組み込み用やRISC-V用の実行ファイルを作ります
UARTシリアル通信の部品。組み込みではこれが唯一の「画面」になることが多く、ここに文字を送ることがデバッグの基本手段です
レジスタ
(周辺機器の)
部品を操作するための決まったメモリ番地。「この番地に文字を書き込むと送信される」といった約束事になっています
リンカスクリプトプログラムの各部分をメモリのどこへ置くかを書いた設計図。PCでは意識しませんが、ベアメタルでは必須です
スタートアップ電源投入後、C言語のmainを呼べる状態に整えるためのごく短いアセンブラ。今回の主役のひとつです
.bss初期値を書いていない変数が置かれる領域。ゼロで埋めるのは誰かの仕事で、ベアメタルでは自分でやります
例外ベクタリセットや割り込みが起きたときCPUが飛んでくる番地の一覧表。メモリの先頭に置く決まりになっています

用意するものは4つだけ

Homebrewで入ります。合計で1.7GBほどありました。

brew install qemu               # 仮想ボード(713MB)
brew install arm-none-eabi-gcc  # ARM用クロスコンパイラ(563MB)
brew install riscv64-elf-gcc    # RISC-V用クロスコンパイラ(377MB)
brew install arm-none-eabi-gdb  # デバッガ(14MB)

今回使ったのは QEMU 11.0.2 / GCC 16.1.0 / GDB 17.2 です。Rustは後半で使うので、そこで入れます。

電源が入ってからmainが動くまで

ここが一番のキモです。「mainが呼ばれる」までに何が起きているのかを図にしました。

電源投入から main() までの4ステップ ① 電源が入る CPUが決まった番地から実行 ARM: 0x40000000 (例外ベクタの先頭) ② スタックを立てる ldr sp, =_stack_top これが無いとC言語の 関数呼び出しができない (起動直後 sp = 0) ③ .bss をゼロで埋める 初期値なしの変数を0に PCではOSがやってくれる。 ここでは自分で書く ④ bl main ようやくC言語の世界へ ここまで全部アセンブラ。 行数にすると20行ほど 必要なファイルは3つだけ startup.s ①〜④を行うアセンブラ linker.ld どこに何を置くかの設計図 main.c UARTに文字を出す処理

部品①:startup.s(入口)

電源が入った直後に動くアセンブラです。図の①〜④をそのまま書き下します。

/* 電源投入直後にCPUが最初に見る場所 */
.section .vectors, "ax"
.global _vectors
_vectors:
    ldr pc, =_start          /* 0x00: リセット。電源投入でここへ飛ぶ */
    b .                      /* 0x04: 未定義命令 */
    b .                      /* 0x08: ソフトウェア割り込み */
    /* …以下、アボートやIRQ・FIQの分だけ並ぶ */

.section .text
.global _start
_start:
    ldr sp, =_stack_top      /* ② スタックポインタを立てる */

    ldr r0, =_bss_start      /* ③ .bss をゼロで埋める */
    ldr r1, =_bss_end
    mov r2, #0
1:  cmp r0, r1
    bge 2f
    str r2, [r0], #4
    b 1b
2:
    bl  main                 /* ④ C言語の世界へ */
    b   .                    /* mainが戻ってきたら無限ループ */

例外ベクタは「何かが起きたときCPUが飛んでくる番地の一覧」です。今回はリセット以外すべて b .(その場で無限ループ)にしています。止まってくれたほうが、暴走するより原因を追いやすいためです。

部品②:linker.ld(どこに何を置くかの設計図)

PCの開発では意識しませんが、ベアメタルでは「このプログラムをメモリのどこに置くか」を自分で決めます。QEMUの virt ボードはRAMが 0x40000000 から始まるので、そこに合わせます。

ENTRY(_start)

MEMORY
{
    RAM (rwx) : ORIGIN = 0x40000000, LENGTH = 128M
}

SECTIONS
{
    . = ORIGIN(RAM);

    .vectors : { KEEP(*(.vectors)) } > RAM   /* 例外ベクタは必ず先頭に */
    .text    : { *(.text*)   } > RAM         /* 命令 */
    .rodata  : { *(.rodata*) } > RAM         /* 定数・文字列 */
    .data    : { *(.data*)   } > RAM         /* 初期値ありの変数 */

    .bss     : {
        _bss_start = .;
        *(.bss*) *(COMMON)
        _bss_end = .;
    } > RAM

    . = ALIGN(8);
    . = . + 0x1000;                          /* スタック用に4KB確保 */
    _stack_top = .;
}

ポイントは_bss_start_bss_end をここで定義しているところです。startup.s はこの2つの目印を使ってゼロ埋めの範囲を知ります。アセンブラとリンカスクリプトが、この名前で待ち合わせをしているわけです。

KEEP() も大事で、これが無いと「誰も呼んでいないコード」と判断されて例外ベクタごと削除されることがあります。

部品③:main.c(文字を出す)

ようやくC言語です。といっても printf はありません。UARTのレジスタに直接書き込みます

/* QEMUのvirtボードに載っているPL011というUART */
#define UART0_BASE 0x09000000
#define UART_DR    (*(volatile unsigned int *)(UART0_BASE + 0x00))  /* データ */
#define UART_FR    (*(volatile unsigned int *)(UART0_BASE + 0x18))  /* 状態 */
#define FR_TXFF    (1 << 5)   /* 送信バッファが満杯か */

static void putc_(char c)
{
    while (UART_FR & FR_TXFF) { }   /* 満杯の間は待つ */
    UART_DR = c;
}

static void puts_(const char *s)
{
    while (*s) {
        if (*s == '\n') putc_('\r');   /* 端末では改行の前に復帰が要る */
        putc_(*s++);
    }
}

volatile「この番地は勝手に値が変わるから、最適化で読み飛ばすな」とコンパイラに伝える指定です。これを忘れると、状態レジスタを読む while ループが「どうせ変わらない」と判断されて消され、無限ループになります。組み込みで最初にハマる定番です😅

ビルドして動かす

コンパイル時のオプションが独特なので、意味を添えておきます。

arm-none-eabi-gcc -mcpu=cortex-a15 -marm \
  -nostdlib -nostartfiles -ffreestanding -O0 -g \
  -T linker.ld startup.s main.c -o kernel.elf
オプション意味
-nostdlib標準ライブラリを使わない。OSが無いのでprintf等は存在しません
-nostartfiles既製のスタートアップを使わない。自分のstartup.sを使うため
-ffreestanding「OSが無い環境向け」と宣言。mainが特別扱いされなくなります
-T linker.ld自作のリンカスクリプトを指定

できたものをQEMUに渡します。

qemu-system-arm -machine virt -cpu cortex-a15 -nographic -kernel kernel.elf

実際の出力がこちらです。

=== ベアメタル起動 (ARM / QEMU virt) ===
OSはありません。このコードが直接CPUで動いています。

counter (.bssの初期値) = 0x00000000  <- startup.sがゼロ埋めした結果
スタックポインタ        = 0x400014D0
main関数のアドレス      = 0x40000208

カウント 1
カウント 2
カウント 3

終了。ここから先は無限ループです。

出ました😊 OSも標準ライブラリも無いところから、文字が画面に出るまで到達です。counter がゼロになっているのは、startup.s のゼロ埋めがちゃんと効いている証拠になります。

つまずき:動いたのに、実機では動かないコード

ここで、書いておきたいつまずきがあります。

上のコードは動きましたが、実機に持っていくと一文字も出ません。

気づいたのは、QEMUに -d guest_errors(ゲスト側の不正操作を報告する指定)を付けたときでした。

PL011 data written to disabled UART

UARTを有効にしないまま書き込んでいたのです。QEMUは親切なので、無効な状態でも文字を通してくれます。しかし本物のチップは無視します。

⚠ エミュレータの怖いところ
「動いた」が「正しい」とは限りません。QEMUが大目に見てくれた部分は、実機で必ず跳ね返ってきます。-d guest_errors は付ける習慣にしたほうがよさそうです。

直したのがこちらです。使う前に電源を入れる、という当たり前の手順でした。

static void uart_init(void)
{
    UART_CR   = 0;                    /* いったん止める */
    UART_IBRD = 26;                   /* 通信速度の設定 */
    UART_FBRD = 3;
    UART_LCRH = (3 << 5) | (1 << 4);  /* 8ビット・FIFO有効 */
    UART_CR   = (1 << 0) | (1 << 8) | (1 << 9);  /* 有効・送信・受信 */
}

これで警告は0件になりました。コードは108バイト増えて1,242バイトです。

GDBで中を覗く

ベアメタルの何が大変かというと、うまく動かないときに手がかりが無いことです。printfも無ければログも残りません。

ところがQEMUにはデバッガをつなぐ口が最初から付いています。-s -S を付けると「1234番ポートで待機し、CPUを止めた状態で起動」します。

# 端末1:CPUを止めた状態でQEMUを起動
qemu-system-arm -machine virt -cpu cortex-a15 -nographic -kernel kernel.elf -s -S

# 端末2:デバッガを接続
arm-none-eabi-gdb kernel.elf -ex "target remote :1234"

実際のセッションがこちらです(見やすいよう抜粋しています)。

--- 起動直後(まだ何も実行していない) ---
pc  0x40000024   <_start>
sp  0x0                        ← スタックはまだ立っていない

--- _start の中身を逆アセンブル ---
=> 0x40000024 <_start>:      ldr  sp, [pc, #32]
   0x40000028 <_start+4>:    ldr  r0, [pc, #32]
   0x4000002c <_start+8>:    ldr  r1, [pc, #32]
   0x40000030 <_start+12>:   mov  r2, #0

--- main にブレークポイントを置いて進める ---
Breakpoint 1, main () at main.c:49
pc  0x40000218   <main+16>
sp  0x400014d0                 ← 立った

--- 変数 counter(.bssがゼロ埋めされたか) ---
$1 = 0
0x400004dc <counter>:  0x00000000

--- UARTの状態レジスタを直接覗く ---
0x9000018:  0x00000090

注目したいのが起動直後の sp が 0 であるところです。「スタックは自分で立てる」という話が、そのまま数字で見えています。そして main に着いた時点では 0x400014d0 になっている。startup.s が仕事をした瞬間が観測できるわけです😳

最後の行では、UARTという部品の状態レジスタを、プログラムを止めたまま直接読んでいます。実機でこれをやるにはデバッガ基板が要りますが、QEMUなら0円です。

同じものをRISC-Vで動かす

前回調べたRISC-Vでも、同じことをやってみます。C言語の部分はほぼそのままで、変わるのは「ボードの事情」だけでした。

項目ARMRISC-V
RAMの開始番地0x400000000x80000000
UARTの種類PL01116550
UARTの番地0x090000000x10000000
スタック設定ldr sp, =_stack_topla sp, _stack_top

スタートアップはこれだけです。前回「基本命令はたった47個」と書きましたが、実際に書いてみると確かに素直で、読む量が少ないと感じました。

.section .text.init
.global _start
_start:
    la   sp, _stack_top        # スタックポインタを立てる

    la   t0, _bss_start        # .bss をゼロ埋め
    la   t1, _bss_end
1:  bgeu t0, t1, 2f
    sw   zero, 0(t0)
    addi t0, t0, 4
    j    1b
2:
    call main                  # C言語の世界へ
3:  j    3b

実行結果です。-bios none は「起動用ファームウェアを載せず、自分のコードだけを動かす」指定になります。

$ qemu-system-riscv64 -machine virt -bios none -nographic -kernel kernel.elf

=== ベアメタル起動 (RISC-V / QEMU virt) ===
同じC言語のコードが、別のCPUで動いています。

counter (.bssの初期値) = 0x0000000000000000
スタックポインタ        = 0x0000000080001360
main関数のアドレス      = 0x0000000080000122

カウント 1
カウント 2
カウント 3

アドレスの桁が増えているのは64ビットだからです。こういう違いが数字で出てくるのは、実際に動かしたときの楽しさですね😊

同じものをRustで書く

Rustの回で入門したきりだったので、同じものをRustでも書いてみます。RISC-V版を移植しました。

// OSが無いので、標準ライブラリもmainの入口も自前で用意する
#![no_std]
#![no_main]

// C版と同じことをするが、生ポインタ操作は unsafe で囲むことが強制される。
// 「どこが危ないか」がコード上に必ず現れるのが一番の違い。
fn putc(c: u8) {
    unsafe {
        while read_volatile(LSR as *const u8) & LSR_THRE == 0 {}
        write_volatile(THR as *mut u8, c);
    }
}

// パニック時にどうするかも自分で決める(OSが居ないので)
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    puts("\n!!! panic !!!\n");
    loop {}
}

C版との違いで印象的だったのは3点です。

  • #![no_std]#![no_main]:標準ライブラリと既定の入口を切り離す宣言。GCCの -nostdlib -nostartfiles に対応します
  • unsafe が強制される:UARTのレジスタを直接叩く部分は必ず unsafe で囲む必要があり、危ない場所がコード上で目に見える
  • #[panic_handler] が必須:異常時の振る舞いを自分で決めないとコンパイルが通りません。OSが居ないので当然といえば当然です

ここでもう一つつまずきました。リンカに渡すオプションでビルドが通りません

rust-lld: error: unknown argument '-nostartfiles'

Rustが標準で使うリンカ rust-lld はGCCと同じ引数を受け付けません-nostartfiles を外したら通りました。「Cでの常識がそのままでは使えない」のは、移植でよくある引っかかりだと思います😅

3つのサイズを並べてみる

せっかく3通り作ったので、できあがったコードの大きさを比べます。

構成コードサイズ備考
C / ARM1,242 バイトUART初期化と例外ベクタを含む
C / RISC-V886 バイトUART初期化なしの分、単純比較はできない
Rust / RISC-V870 バイト最適化 opt-level="s"。C版とほぼ同じ

RustがC版とほぼ同じサイズに収まったのは、正直な感想として意外でした。「Rustは安全な分だけ重い」という印象を持っていたのですが、標準ライブラリを外した領域では差が出ないということのようです。

ただしこの比較はあくまで目安です。ARM版だけUART初期化が入っていますし、最適化の設定も揃っていません。条件を揃えた厳密な比較にはなっていない点は正直に書いておきます。

実機へ持っていくと何が変わるか

QEMUで動いたものを本物のボードに載せるとき、何が変わるのかも整理しておきます。

項目実機で変わること
メモリ配置RAMやUARTの番地はボードごとに違います。リンカスクリプトと定数を書き換えるのが最初の作業
クロック設定QEMUでは省略できましたが、実機では周辺機器へ電力とクロックを供給する手順が要ります。ここを忘れると沈黙します
通信速度今回の設定値は実機では合いません。元のクロック周波数から計算し直す必要があります
書き込み手段-kernel で渡す代わりに、ROMへ書き込む道具(デバッガ基板など)が要ります
デバッグQEMUなら0円だったGDB接続に、ハードウェアデバッガが必要になります

逆に言えば、これら以外はそのまま通用します。スタートアップの考え方も、リンカスクリプトの書き方も、volatileの使いどころも変わりません。ボードを買う前に考え方を身につけられるのがQEMUの価値だと思います。

まとめ

  • ボードを1枚も買わずにベアメタルが動かせる。必要なのはQEMUとクロスコンパイラで、Homebrewで合計1.7GBほど
  • 必要なファイルは3つだけ。startup.s(入口)・linker.ld(配置の設計図)・main.c(UARTに文字を出す)
  • 電源投入からmainまでの間に、スタックを立てて.bssをゼロ埋めしている。PCではOSがやってくれる仕事を自分で書く
  • つまずき①:QEMUでは動くのに実機では動かないコードができる。UARTを有効化せずに書き込んでいたが、QEMUは通してくれる。-d guest_errors で発覚した
  • GDBで起動直後の sp が 0 であることまで観測できる。実機ならデバッガ基板が要る作業が0円
  • RISC-Vでも同じことができた。変わるのはRAMの番地(0x40000000 ↔ 0x80000000)とUARTの種類だけで、C言語部分はほぼ共通
  • つまずき②:Rustのリンカ rust-lld はGCCの引数を受け付けない-nostartfiles でエラー)
  • Rust版は870バイトでC版(886バイト)とほぼ同じ。標準ライブラリを外した領域では大きさの差が出なかった

やってみて一番おもしろかったのは、GDBで sp が 0 から 0x400014d0 に変わる瞬間が見えたことでした。「スタックは自分で立てる」と文字で読んでも実感がありませんでしたが、数字が変わるのを目で見ると腑に落ちます😊

そして、エミュレータが親切すぎて間違いに気づけないという点。動いてしまったので危うくそのまま記事にするところでした。「動いた」と「正しい」は別物だと、あらためて思い知らされました😅

それでは、今回はここまで。最後までありがとうございました😊

【実測環境】Apple M4 / macOS 26.5.2 / QEMU 11.0.2 / arm-none-eabi-gcc 16.1.0 / riscv64-elf-gcc 16.1.0 / GDB 17.2 / rustc 1.97.1。【主な参考】QEMU公式「arm virt board」同「RISC-V virt board」Arm PL011 UART 技術資料GNU ld リンカスクリプト仕様The Embedded Rust Book。いずれも2026年7月時点で確認。