ベアメタルとファームウェア
OSなしで動くファームウェアの起動から実行までを一気通貫で理解できる。リセット直後にCPUが何を見て、どうやってmain()にたどり着き、なぜ無限ループで回り続けるのか——組込みの土台が原理からつかめる。
- 1.ベアメタルはOSを介さずCPUが直接ファームウェアを実行する形態。リセット直後はベクタテーブルの先頭からスタックポインタと初期PCを読み、スタートアップコードが .data/.bss を整えてから main() を呼ぶ。
- 2.どのアドレスに何を配置するかはリンカスクリプトが決める。ベクタテーブルとコードはROM/Flash、変数はRAMへ割り当て、起動時にFlashからRAMへ初期値をコピーする。
- 3.アプリの基本形はスーパーループ(初期化→while(1)で処理を回す)。割り込みを併用し、ペリフェラルはレジスタへの書き込みで手動初期化する。
OSがない世界のプログラムはどう動くか
PC やサーバーのプログラムは、OS がメモリ配置・スタック・入出力をお膳立てした上で main() から動き出します。ところがマイコン(MCU)上のベアメタル(bare metal)ファームウェアには、その下支えがありません。電源が入った瞬間、CPU はいきなり生のハードウェアと向き合い、自分でスタックを立て、変数領域を整え、周辺回路を初期化してから、ようやくアプリケーション本体を実行します。
この「OS が肩代わりしていた仕事を全部自前でやる」のがベアメタルの本質です。裏を返せば、リセットから main() までの一連の流れ——ベクタテーブル、スタートアップコード、リンカスクリプト——を理解すれば、組込みファームウェアの土台はほぼ見通せます。以下では ARM Cortex-M を主な題材に、リセットから実行ループまでを順に追います。CPU そのものの動作原理は /semiconductor/、その周辺のハードウェアは /hardware-components/ を前提知識とします。
リセットベクタ:CPUが最初に見る2つの値
CPU はリセット直後、あらかじめ決められた固定アドレスからプログラムを開始します。このアドレスに置かれるのがベクタテーブル(vector table)です。ベクタテーブルは「例外・割り込みが起きたとき、どのアドレスへ飛ぶか」を並べたジャンプ先の表で、その先頭に必ずリセット時の初期値が入っています。
Cortex-M では、ベクタテーブルの並びが特徴的です。多くの CPU が「テーブル先頭 = 実行開始アドレス」なのに対し、Cortex-M は先頭の2ワードを起動準備に使います。
Cortex-M ベクタテーブル(アドレス 0x0000_0000 付近, ROM/Flash 上)
オフセット 内容
+0x00 初期スタックポインタ (MSP) の値 ← リセット時に SP へロード
+0x04 リセットハンドラのアドレス ← リセット時に PC へロード
+0x08 NMI ハンドラのアドレス
+0x0C HardFault ハンドラのアドレス
+0x10 MemManage ...
: (以降、各例外・各周辺割り込みのハンドラアドレスが並ぶ)
リセットが解除されると、コアはハードウェア的に次の2手を踏みます。まず +0x00 の値をスタックポインタ(MSP)にロードし、次に +0x04 の値をプログラムカウンタ(PC)にロードして、そのアドレス——リセットハンドラ——の実行を始めます。つまり C 言語で最初に呼ばれるのは main() ではなく、このリセットハンドラです。
Cortex-M が先頭にスタックポインタ値を置くのは、例外処理のためです。Cortex-M は割り込み・例外の入口でレジスタの一部を自動的にスタックへ退避します(自動スタッキング)。もし SP が未設定のまま最初の例外が起きるとスタックが不正な番地を指し、即座に破綻します。だからハードウェアがコード実行より前に、テーブル先頭の値で SP を確定させるのです。
スタートアップコード:main()に至るまでの地ならし
リセットハンドラの実体がスタートアップコードです。C の実行環境(Cランタイム)を整える、ごく短いが不可欠なコードで、最低限これだけの仕事をします。
| 段階 | やること | 理由 |
|---|---|---|
| .data のコピー | 初期値付きグローバル変数を Flash から RAM へコピー | 変数はRAM上にあるが初期値はFlashに焼かれている。両者を繋ぐ |
| .bss のクリア | 初期値ゼロのグローバル変数領域を 0 で埋める | C規格が「初期化なしのstatic/globalは0」を保証するため |
| (任意)ライブラリ初期化 | libcの初期化やFPU有効化など | 標準ライブラリや浮動小数点演算を使う準備 |
| main() 呼び出し | アプリ本体へジャンプ | ここでようやくアプリケーションが始まる |
核心は最初の2つ、.data と .bss の処理です。C 言語では、int counter = 5; のようなグローバル変数は実行時に RAM 上に存在しなければなりませんが、その初期値 5 は電源を切っても消えない Flash に焼かれています。RAM は起動時に中身が不定なので、誰かが Flash 上の初期値を RAM へコピーしなければ counter は 5 になりません。この橋渡しがスタートアップコードの .data コピーです。
スタートアップコードの骨子(擬似コード)
ResetHandler:
# 1) .data セクションを Flash(LMA) から RAM(VMA) へコピー
src = &_sidata # Flash上の初期値の先頭
dst = &_sdata # RAM上の.data先頭
while dst が _edata 未満:
*dst++ = *src++
# 2) .bss を 0 クリア
dst = &_sbss
while dst が _ebss 未満:
*dst++ = 0
# 3) (必要なら)システムクロック初期化など
# 4) アプリ本体へ
main()
# 5) main が万一戻ってきたら、無限ループで安全に停止
while(1) { }
ここに現れる _sidata / _sdata / _edata / _sbss / _ebss といったシンボルは、C コードにもスタートアップコードにも定義がありません。これらは次に述べるリンカスクリプトが供給するアドレスです。スタートアップコードは「どこからどこへコピーするか」をこれらのシンボル経由で受け取っており、コードとメモリ配置がリンカスクリプトで結び付いています。
リンカスクリプト:アドレス空間の設計図
リンカスクリプト(linker script)は、コンパイル済みの各セクション(コード・定数・変数)を物理的にどのアドレスへ置くかを指示するファイルです。ベアメタルでは OS がメモリを割り当ててくれないため、このアドレス設計を開発者が明示する必要があります。
まず MCU のメモリは大きく2種類に分かれます。不揮発の Flash(ROM) はプログラムと定数を保持し、電源を切っても消えません。揮発の RAM は実行時の変数とスタック・ヒープを置き、電源を切ると消えます。リンカスクリプトはこの2領域に各セクションを割り付けます。
典型的なメモリ割り付け(Cortex-M)
MEMORY {
FLASH : ORIGIN = 0x08000000, LENGTH = 512K # ROM
RAM : ORIGIN = 0x20000000, LENGTH = 128K # RAM
}
配置の考え方(セクション → 置き場所)
.isr_vector → FLASH 先頭 ベクタテーブル。リセット時に読まれる
.text → FLASH 実行コード(関数群)
.rodata → FLASH const 定数・文字列リテラル
.data → RAM に実体、初期値は FLASH に格納
.bss → RAM ゼロ初期化変数。Flash上に実体は持たない
stack / heap → RAM 末尾側 実行時に伸縮する領域
ここでVMA と LMA の区別が決定的に重要です。VMA(Virtual/実行時アドレス)は「プログラムがその変数を参照するアドレス」、LMA(Load/格納アドレス)は「そのデータが実際に焼かれているアドレス」です。.data は VMA が RAM、LMA が Flash になります——実行時は RAM 上の番地でアクセスするが、初期値の実体は Flash に置く、という二重性です。この LMA と VMA の差こそ、スタートアップコードが Flash から RAM へコピーせざるを得ない根本理由です。
リンカは各領域の使用量を集計し、Flash や RAM の LENGTH を超えると region RAM overflowed のようなエラーで教えてくれます。ただし危ないのはスタックとヒープです。これらは実行時に動的に伸びるため、リンカの静的集計には現れません。大きなローカル配列や深い再帰でスタックが .bss 領域へ食い込み、変数を破壊する——OS のガードページがないベアメタルでは、この種の暴走が無警告で起こります。スタックサイズの見積もりとガード配置は自前の責任です。
スーパーループとペリフェラル初期化
main() に到達した後のアプリケーションの基本構造がスーパーループ(super loop)です。RTOS を使わないベアメタルの定番形で、「一度きりの初期化」と「延々と回り続けるメインループ」の2部構成を取ります。
ベアメタル・アプリの基本形
int main(void) {
// ---- 初期化フェーズ(起動時に一度だけ)----
clock_init(); // システムクロック(PLL)設定
gpio_init(); // 入出力ピンの向き・モード
uart_init(115200); // 通信ペリフェラルの設定
timer_init(); // 周期割り込みの設定
// ---- スーパーループ(電源が切れるまで無限に)----
while (1) {
read_sensors(); // 入力を読む
update_state(); // 処理する
drive_outputs(); // 出力へ反映
}
}
ループが while(1) で決して終わらない点がベアメタルの特徴です。戻る先の OS が存在しないため、main() から抜けることは想定されません(抜けた場合はスタートアップコード末尾の無限ループが受け止めます)。
ペリフェラル初期化は、周辺回路(GPIO・UART・タイマ・ADC など)をメモリマップド・レジスタへの書き込みで設定する作業です。MCU では周辺回路の制御レジスタが特定のアドレスに割り当てられており(メモリマップド I/O)、そこへ値を書くことがハードウェアの設定そのものになります。
GPIO を出力にする例(概念コード, メモリマップド I/O)
#define GPIOA_MODER (*(volatile uint32_t *)0x48000000)
// ピン5を出力モード(01)に設定:該当2ビットをクリアしてから書く
GPIOA_MODER &= ~(0x3u << (5 * 2)); // 対象ビットを 00 に
GPIOA_MODER |= (0x1u << (5 * 2)); // 01 = 汎用出力
ペリフェラルレジスタを指すポインタには必ず volatile を付けます。理由は、レジスタの値がCPUのコード実行とは無関係にハードウェア側で変化するからです。volatile がないと、コンパイラは「この番地は誰も書き換えない」と判断してアクセスをレジスタキャッシュに畳み込み、ステータスビットのポーリングが実際のメモリを読まなくなります。結果、割り込みフラグを永遠に見逃す・書いたはずの設定が最適化で消える、といった再現困難なバグを生みます。組込みで volatile を落とすのは典型的な事故原因です。
多くの MCU では、周辺回路はリセット直後はクロックが供給されず眠っている点にも注意が要ります。消費電力削減のため、使うペリフェラルごとにクロックイネーブルレジスタ(RCC など)でクロックを供給してからでないと、レジスタへの書き込みすら効きません。「設定したのに動かない」の定番原因がこのクロック未供給です。電源・クロック側の詳細は /power/ も参照してください。
割り込み:ループを止めずに即応する
スーパーループだけでは、時間のかかる処理の最中に来たイベントへの応答が遅れます。そこで割り込み(interrupt)を併用します。割り込みは、特定の事象(タイマ満了・データ受信・ピン変化など)が起きた瞬間にメインループを一時中断し、対応する割り込みハンドラ(ISR)へ制御を移す仕組みです。
割り込みが発生すると、Cortex-M はハードウェアで次を行います。現在の処理を中断してレジスタを自動退避(スタッキング)し、ベクタテーブルの該当エントリからハンドラのアドレスを取得してそこへジャンプ、ハンドラ終了後は退避したレジスタを復帰してメインループの続きに戻ります。冒頭のベクタテーブルが「表」である理由がここにあり、各割り込み要因に対応するハンドラの入口が並んでいるわけです。
「リセット後 main() までに何が起きるか」は組込みの頻出問です。答えの筋は『(1) ベクタテーブル先頭からSPと初期PCをロード → (2) リセットハンドラ=スタートアップコードが .data を Flash から RAM へコピーし .bss をゼロクリア → (3) main() 呼び出し → (4) スーパーループ』。加えて「.data と .bss の違い」(初期値付き変数か、ゼロ初期化変数か)、「VMAとLMAの違い」(実行時アドレスか格納アドレスか)、「ペリフェラルレジスタに volatile が要る理由」はセットで押さえます。
まとめ
- ベアメタルは OS を介さず CPU が直接ファームウェアを実行する形態で、OS が担っていたメモリ配置・スタック設定・入出力初期化をすべて自前で行う。
- リセット直後、Cortex-M はベクタテーブル先頭から初期スタックポインタと初期PCを読み、**リセットハンドラ(スタートアップコード)**へ入る。
- スタートアップコードは
.dataを Flash から RAM へコピーし.bssをゼロクリアして C 実行環境を整えてからmain()を呼ぶ。使うアドレスはリンカスクリプトが供給する。 - リンカスクリプトは各セクションを Flash/RAM のどこへ置くかを決める設計図で、
.dataの LMA(Flash)と VMA(RAM)の差がコピー処理の根拠になる。 - アプリ本体はスーパーループ(初期化 →
while(1))が基本形。ペリフェラル初期化はメモリマップド・レジスタへの書き込みで行い、レジスタポインタにはvolatileが必須。眠っている周辺回路のクロック供給を忘れない。 - 土台となる CPU・ハードウェアは /semiconductor/ と /hardware-components/、周期処理や制御の理論は /dsp-control/ を参照。
組込み・IoT Article
ベアメタルとファームウェアを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
組込み
比較で見る軸
難易度: advanced / カテゴリ: 組込み・IoT / タグ数: 6
導入後に効く点
どのアドレスに何を配置するかはリンカスクリプトが決める。ベクタテーブルとコードはROM/Flash、変数はRAMへ割り当て、起動時にFlashからRAMへ初期値をコピーする。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- 組込み・IoT
- タグ数
- 6
判断チェックリスト
- 自社の用途が「組込み / ファームウェア」に近いか確認する。
- 強みである「ベアメタルはOSを介さずCPUが直接ファームウェアを実行する形態。リセット直後はベクタテーブルの先頭からスタックポインタと初期PCを読み、スタートアップコードが .data/.bss を整えてから main() を呼ぶ。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。