前回の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が呼ばれる」までに何が起きているのかを図にしました。
部品①: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言語の部分はほぼそのままで、変わるのは「ボードの事情」だけでした。
| 項目 | ARM | RISC-V |
|---|---|---|
| RAMの開始番地 | 0x40000000 | 0x80000000 |
| UARTの種類 | PL011 | 16550 |
| UARTの番地 | 0x09000000 | 0x10000000 |
| スタック設定 | ldr sp, =_stack_top | la 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 / ARM | 1,242 バイト | UART初期化と例外ベクタを含む |
| C / RISC-V | 886 バイト | UART初期化なしの分、単純比較はできない |
| Rust / RISC-V | 870 バイト | 最適化 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月時点で確認。