前回(検証・デバッグ編)では、QEMU 上で
firmware.elfを走らせ、リセットからmainまでの経路を GDB で1命令ずつ確認しました。ただし、あのときのmainは文字列を出すだけの最小版でした。今回は、その手前で本来必要な「起動の土台」を据えます。グローバル変数の初期値を ROM から RAM へ運び、ゼロ初期化変数を実際にゼロで埋める。しかもこの仕事は C が動き出すより手前なので、アセンブラで書きます。対象:前回まで(環境構築・最小起動・検証)を追ってきた方。
Reset_Handlerからmainまでは構成済みである前提です。
ゴール:.dataの ROM→RAM コピーと.bssのゼロクリアを C より手前にアセンブラで実装し、QEMU 上で確認する。あわせて、RAM を使う前に現場で何をするかの地図を持つ。
前提知識:C のポインタ、Flash と RAM の区別、リンカスクリプトの MEMORY/SECTIONS(前回まで)。アセンブラは未経験でも、命令ごとに説明します。
検証環境:macOS / ATfE 22.1.0(clang + ld.lld)/ QEMUmps2-an505/ GDB。
1. はじめに ──「配置できた」と「C が動く」の間
「C 言語では、初期値を書かない静的変数は 0 から始まる」——これは正しい説明です。ただし組込みでは、その 0 を誰かが代入していなければ、0 にはなりません。そして、その「誰か」が居ないことで起きる不具合は、C の規格を信じているエンジニアほど気づけません。原因が、C の関数が動き出す前にあるからです。
今回書くのは、まさにその「誰か」です。そしてこの仕事は、C が動き出すより手前で起きます。だから——ここが今回の肝です——アセンブラで書きます。
前回、QEMU 上で main に到達することを確認しました。けれど、あの構成のままでは実機の STM32L552 では動きません。理由は、検証用の QEMU 版リンカスクリプトが、コードもデータも単一のメモリ領域に同居させていたからです。初期値つきの変数も、最初からその領域に置かれていて、コピーは不要でした。
実機は違います。.text や .rodata は不揮発の Flash に、書き換わる .data や .bss は RAM に置きます。すると、Flash に焼かれた初期値を、起動時に RAM へ運ぶ仕事が発生します。本記事は、この「C が安全に動き出すための土台」を据えるところまでを扱います。
2. RAM を使い始める前に、本来何をするか
組込みの現場、とくに車載や高信頼の領域では、RAM を使い始める前にやることが、ある程度決まっています。
- 汎用レジスタの書き込みチェックと初期化(CPU の自己診断、いわゆる POST)
- メインクロックの立ち上げ
- ROM のサムチェック(コードが化けていないか)
- RAM のチェックとクリア(スタックビット等の検出と、初期化)
これらに共通するのは、いずれも C の関数が動き出す前の仕事だ、という点です。だからアセンブラで書きます。
本記事が扱うのは、このうちすべての freestanding プログラムに必須で、かつ QEMU でも観測できる核です。具体的には、スタックの確定と、.data / .bss の初期化。
残りの「汎用レジスタ POST」「ROM サムチェック」「RAM のマーチテスト(不良ビット検出)」「クロック立ち上げ」は、重く、車載寄りで、QEMU では意味のある再現がしにくい話です。それぞれが独立した記事になる規模なので、ここでは地図として位置づけだけを示し、深掘りは別記事に譲ります。
補足:内蔵 SRAM の初期化そのものは、リセット直後のクロックで動きます。クロックの立ち上げを前提にしません。クロックが先に必要になるのは、高速 Flash の wait state、外部 RAM、RAM の ECC まわりです。
3. なぜ C で書いてはいけないのか
「スタックは立っているし、ローカル変数しか使わない関数なら、C でも動くのでは?」——確かに、動くことはあります。けれど、保証できません。
C では、作者が書いていない RAM アクセスを、コンパイラが勝手に差し込むことがあります。
-
関数内の
static変数:.bss/.dataに置かれます。RAM 初期化前に触れば不定値です。 -
スタックプロテクタ(
-fstack-protector*):関数プロローグが RAM 上のガード変数__stack_chk_guardを読みます。初期化前なら不定値で、最悪__stack_chk_failに飛びます。 -
構造体コピーや配列の一括初期化:
memcpy/memsetの呼び出しに化けます(§7 で扱います)。
アセンブラには、コンパイラが勝手に入れる隠れた状態がありません。書いたものしか動かない。 RAM をまだ使えない区間で、これほど効く性質はありません。
読者にとっての落とし穴も同じところにあります。「RAM 初期化前でも C 関数が呼べる」例を見せると、それを普通のこととして学んでしまいます。やがて自分で起動処理を書くとき、その区間で static やグローバル変数を使い、不定動作になる。しかもそれを「自分のロジックの誤りではなく、RAM クリア前にやったせいだ」と見抜くには、相応の時間がかかります。だから、ここはアセンブラで書きます。
4. 何を初期化するのか ── .data と .bss
実装の前に、運ぶ対象を物理から押さえます。リンカが用意するシンボルは、いずれも**「値」ではなく「番地」**に意味があります。アセンブラは、その番地を読んで使います。
4.1 .data ── なぜコピーが要るのか
uint32_t g_count = 5; の 5 はどこにいるか。電源を切れば RAM は消えるのに再投入時に 5 から始まるなら、5 は不揮発の ROM に焼かれているはずです。一方、実行中に g_count が住むのは RAM。だから起動時に、ROM 上の初期値イメージを RAM の定位置へ運びます。
-
_sidata… ROM 上の初期値イメージ先頭(LMA、コピー元) -
_sdata… RAM 上の配置先頭(VMA、コピー先の先頭) -
_edata… RAM 上の配置末尾
4.2 .bss ── なぜゼロクリアが要るのか
static uint32_t s_flag; は仕様上ゼロで始まる、と教わります。ホスト環境では、そのゼロを C ランタイムが保証していました。freestanding では保証する主体が消えるので、.bss を自分でゼロで埋めます。飛ばすと、「ゼロから始まる前提」のコードが、前回電源時に RAM に残った値の上で動き出します。
-
_sbss… RAM 上の.bss先頭 -
_ebss… RAM 上の.bss末尾
なお .data は初期値イメージを ROM に持つので ROM 容量を食いますが、.bss は中身がゼロなので ROM にイメージを持ちません。この非対称は、配置設計で効いてきます。
これらの初期化を、§3 の理由から、次のリンカスクリプトとアセンブラで実装します。
5. リンカスクリプトを仕上げる
前回、QEMU 版では .isr_vector と .text だけを置き、「.data / .bss は次回」と送りました。その宿題を回収します。鍵は LMA と VMA の分離です。
- VMA(Virtual Memory Address)=実行時にそのセクションが居るアドレス
- LMA(Load Memory Address)=ロード時(焼き込み時)にイメージが置かれるアドレス
.data は、実行時は RAM(VMA)に居てほしいが、初期値イメージは Flash(LMA)に焼きたい。このズレを宣言するのが AT> です。
5.1 実機 STM32L552 版
SECTIONS
{
.isr_vector ORIGIN(FLASH) : { KEEP(*(.isr_vector)) } > FLASH
.text : { *(.text*) *(.rodata*) . = ALIGN(4); } > FLASH
/* 実行時は RAM(VMA)、初期値イメージは FLASH(LMA) */
.data :
{
. = ALIGN(4);
_sdata = .;
*(.data*)
. = ALIGN(4);
_edata = .;
} > RAM AT> FLASH
_sidata = LOADADDR(.data); /* コピー元 = .data の LMA */
.bss :
{
. = ALIGN(4);
_sbss = .;
*(.bss*) *(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
}
要点は4つです。
-
> RAM AT> FLASH:VMA を RAM に、LMA を Flash に置く宣言。「実行は RAM、イメージは Flash」が成立します。 -
_sidata = LOADADDR(.data);:LOADADDR()はそのセクションの LMA(Flash 側イメージ先頭)を返します。これがコピー元です。 -
_sdata/_edata/_sbss/_ebss:各セクションの内側で.(ロケーションカウンタ)から定義し、アセンブラ側と番地を噛み合わせます。 -
ALIGN(4):あとでアセンブラが 4 バイト単位でコピー/クリアするので、境界を 4 に揃えておきます。
📝【執筆メモ・公開前に処理】
AT>を忘れると.dataの LMA も RAM になり、初期値が Flash に焼かれません。起動時にコピー元が空になり、初期値が入らないまま動きます。ビルドしてobjdump -hの LMA を確認する。
MEMORY ブロック(FLASH @ 0x08000000 / 512K、RAM @ 0x20000000 / 256K)は前回(最小起動編)のものを使います。
5.2 QEMU 版を「コピーが見える」形にする
前回の QEMU 版は単一領域だったため、.data コピーは**何もしない処理(no-op)**でした。今回はコピーが効くところを GDB で見たいので、QEMU 版も LMA と VMA を分けます。mps2-an505 の 0x10000000(ZBT SSRAM、4MB、rwx)を、前半「ROM 相当」・後半「RAM 相当」に見立てて二分します。
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x10000000, LENGTH = 512K /* ROM相当(前半) */
RAM (rwx) : ORIGIN = 0x10080000, LENGTH = 512K /* RAM相当(後半) */
}
_estack = ORIGIN(RAM) + LENGTH(RAM); /* = 0x10100000(前回と同じスタックトップ) */
_sstack = _estack - 0x00004000; /* = 0x100fc000(MSPLIM 下限) */
SECTIONS
{
.isr_vector ORIGIN(FLASH) : { KEEP(*(.isr_vector)) } > FLASH
.text : { *(.text*) *(.rodata*) . = ALIGN(4); } > FLASH
.data : { . = ALIGN(4); _sdata = .; *(.data*) . = ALIGN(4); _edata = .; } > RAM AT> FLASH
_sidata = LOADADDR(.data);
.bss : { . = ALIGN(4); _sbss = .; *(.bss*) *(COMMON) . = ALIGN(4); _ebss = .; } > RAM
}
.isr_vector はリセット時の VTOR 初期値 0x10000000 に置くため FLASH 相当の先頭、スタックトップ _estack は前回・前々回と同じ 0x10100000 です。これで .data コピーが「ROM 相当 → RAM 相当」の実際にバイトを動かす処理になり、§8 の GDB で観測できます。
6. アセンブラで Reset_Handler に実装する
前回の Reset_Handler は、スタックを確定して bl main するだけでした。ここに、.data コピーと .bss クリアをアセンブラのまま挿します。bl main が最初の C 呼び出しになり、その時点で RAM は完全に初期化済みです。
6.1 スタックを確定する(前回の復習)
ldr r0, =_estack
mov sp, r0 /* SP を確定 */
ldr r0, =_sstack
msr MSPLIM, r0 /* Armv8-M: メインスタック下限 */
=_estack は LDR 擬似命令で、リンカが置いた値をリテラルプールから読みます。mov sp, r0 でスタックポインタを立て、msr MSPLIM で下限を設定します(Armv8-M の機能。詳細は最小起動編)。
6.2 .data を ROM から RAM へコピーする
ldr r0, =_sidata /* コピー元: ROM 上の初期値イメージ(LMA) */
ldr r1, =_sdata /* コピー先: RAM 先頭(VMA) */
ldr r2, =_edata /* コピー先: RAM 末尾 */
.Lcopy_data:
cmp r1, r2
bhs .Lcopy_done /* r1 >= r2 なら終了(符号なし比較) */
ldr r3, [r0], #4 /* *r0 を読み、r0 を 4 進める */
str r3, [r1], #4 /* *r1 へ書き、r1 を 4 進める */
b .Lcopy_data
.Lcopy_done:
3 本のシンボルを汎用レジスタに読み込み、_sdata から _edata まで 4 バイト単位で運びます。[r0], #4 はポストインデックスで、読み書きの後にアドレスを 4 進めます。bhs(higher or same)は符号なしの「以上」で、比較対象がどちらもアドレスなので符号なし比較が正しい選択です。4 バイト単位で回せるのは、リンカスクリプトで境界を ALIGN(4) に揃えてあるからです。
6.3 .bss をゼロで埋める
ldr r0, =_sbss
ldr r1, =_ebss
movs r2, #0
.Lzero_bss:
cmp r0, r1
bhs .Lzero_done
str r2, [r0], #4
b .Lzero_bss
.Lzero_done:
考え方は同じで、_sbss から _ebss までを 0 で埋めます。
6.4 ここで初めて C へ
bl main /* RAM 初期化済み。最初の C 呼び出し */
.Lhang:
b .Lhang
bl main が、この firmware で最初に実行される C コードです。ここに来るまでに、スタックも .data も .bss も整っています。だから main 以降は、static でもグローバルでも、初期化済みの前提で安心して使えます。完全版の startup.s は別ファイル(c2-04_startup.s)にまとめてあります。
7. つまずき ── -ffreestanding でも消えない memcpy / memset
ここで、§3 で予告した memcpy / memset の話を回収します。ただし、これは「C より手前」の話ではありません。 これらが呼ばれるのは main の後、つまり RAM 初期化が済んだ後なので、C で書いて問題ありません。§6 でアセンブラにしたのは「RAM を使えるようにする処理」、こちらは「使えるようになった後にアプリが使うヘルパ」です。
-nostdlib で標準ライブラリを切っていても、こういう C を書くと——
uint8_t buf[64] = {0}; /* 配列の一括初期化 */
struct cfg c = default_cfg; /* 構造体のコピー */
——コンパイラが内部的に memset / memcpy の呼び出しに落とすことがあり(GCC / Clang 共通)、実体が無いとリンクで未定義参照になります。
📝【執筆メモ・公開前に処理】実際に未定義参照を起こす最小コードと、ld.lld のエラーメッセージ(
undefined symbol: memcpyなど)を採取して貼る。
塞ぎ方は、最小限の実体を自前で書くことです。
#include <stddef.h>
void *memcpy(void *dst, const void *src, size_t n)
{
unsigned char *d = dst;
const unsigned char *s = src;
while (n--) { *d++ = *s++; }
return dst;
}
void *memset(void *dst, int c, size_t n)
{
unsigned char *d = dst;
while (n--) { *d++ = (unsigned char)c; }
return dst;
}
名前とシグネチャは ISO C のとおりに合わせます(コンパイラが生成する呼び出しと一致させるため)。
📝【執筆メモ・公開前に処理】自作の
memcpy/memsetは、最適化されるとループ自体がmemcpy呼び出しに変換され、自己再帰に陥ることがある(loop-idiom 認識)。回避指定(-fno-builtin-memcpy等)が要るかは、対象の最適化レベルでビルドして確認する。
この「memcpy をなぜ自前で書くのか、どう徹底軽量化し、どう検証するか」は、それ自体が一本の記事になります(環境構築編で触れた話の本丸)。CPU 効率のよいワード単位コピーや強い検証は次回に回し、今回は最小実装に留めます。
8. QEMU で確認する
確認用の main には、.data と .bss の両方を見られる変数を置きます。
#include <stdint.h>
uint32_t g_initialized = 0xCAFEBABE; /* .data: 初期値つき → コピーされるはず */
uint32_t g_zero; /* .bss : ゼロ初期化されるはず */
extern void sh_write0(const char *); /* semihosting 出力(前回のもの) */
extern void sh_exit(int);
int main(void)
{
if (g_initialized == 0xCAFEBABE && g_zero == 0u) {
sh_write0("init OK: .data copied, .bss zeroed\n");
} else {
sh_write0("init NG\n");
}
sh_exit(0);
}
8.1 静的に LMA≠VMA を確かめる
COMMON="--target=arm-none-eabi -mcpu=cortex-m33 -mfpu=fpv5-sp-d16 -mfloat-abi=hard -ffreestanding"
clang $COMMON -Og -g -c startup.s -o startup.o
clang $COMMON -Og -g -c startup.c -o startup_c.o # memcpy/memset のみ
clang $COMMON -Og -g -c main.c -o main.o
clang $COMMON -nostdlib -fuse-ld=lld -T link_qemu.ld startup.o startup_c.o main.o -o firmware_dbg.elf
llvm-objdump -h firmware_dbg.elf | grep -E '\.data|\.bss'
llvm-readelf -S は VMA(Addr 列)しか出さないので、LMA を見るには llvm-objdump -h(VMA/LMA 列)か llvm-readelf -l(VirtAddr/PhysAddr)を使います。
📝【執筆メモ・公開前に処理】上記の実測出力を貼る。
.dataの VMA が0x10080000系、LMA が0x100xxxxx(FLASH 相当)系になり、両者が異なることを示す。
8.2 GDB で「コピー前」と「コピー後」を見る
今回の山場です。-S で止めた直後(初期化前)と、main 到達後(初期化後)で、変数の中身がどう変わるかを見ます。
(gdb) target remote :1234 # -S で停止=初期化前
(gdb) p/x g_initialized # まだ RAM 未コピー
(gdb) p/x g_zero
(gdb) break main
(gdb) continue # .data コピー / .bss クリアを通過
(gdb) p/x g_initialized # 0xcafebabe ← .data がコピーされた
(gdb) p/x g_zero # 0x0 ← .bss がクリアされた
📝【執筆メモ・公開前に処理】GDB の実測値を採取して差し替える。特に「コピー前」の
g_initialized(QEMU では RAM 相当領域が0のはず)を確認し、「コピー後に0xcafebabeへ変わる」差分として見せる。
抽象的に「起動時に初期化される」と言うのではなく、同じアドレスの中身が、初期化処理の前後で実際に変化することを、自分の目で確認できます。
8.3 走らせる
qemu-system-arm -M mps2-an505 -cpu cortex-m33 -nographic \
-semihosting-config enable=on,target=native -kernel firmware.elf
期待する出力:
init OK: .data copied, .bss zeroed
この一行が出れば、.data コピーと .bss クリアが実行時に機能したことになります。
9. 実務ではどう考えるか
初期化処理は、突き詰めると「誰が・いつ・何を前提するか」という契約です。ベンダ提供の startup をコピペで済ませると、この契約が見えないまま main まで来ます。私が原因究明に最後まで付き合い、解決した2件は、いずれもこの契約が破れた例でした。
ひとつは、.bss のクリア漏れです。ゼロ初期化を前提とする変数(いわゆる Z 変数)が、起動時にゼロで埋められないまま、前回電源時に RAM に残っていた値で動き出しました。状態遷移処理の switch 文が、その不定値で分岐します。車載の通信制御では、これが WAKEUP フレームの誤送信や、意図しない Sleep モードへの遷移として現れました。値は毎回同じとは限らないので、症状は不定期で、解析には時間がかかりました。原因は、スタートアップを実装した担当が、コンパイラの前提とする初期化の一部を把握しておらず、.bss クリアが欠けていたことでした。
もうひとつは、.data のコピー漏れです。本来は ROM 上の初期値イメージ(車載のツールチェーンでは ROMDATA セクションと呼ぶこともあります。ARM でいう .data の LMA イメージにあたります)から RAM へ運ぶべき初期値を、ゼロクリアだけで済ませていたユニットがありました。結果、通信処理の状態変数が、意図した初期値ではなく不定の値から始まります。状態遷移の switch がどの case にも当てはまらず、毎回 default を通ってから起動する。すると、初期化後に課された起動時間制約の中で、通信を確立できませんでした。通信ロジック自体には設計上の不良がなかったため、当初は「問題なし」と判断されていました。原因が .data の初期化にあると分かるまでに、相当な時間を要しました。
この2件は別々のユニットで起きましたが、根は同じです。エンジニアは「C では変数が正しく初期化されている」という規格を前提に学びます。その前提が強いほど、不具合の原因が C の関数より手前——スタートアップ——にある可能性を、最初は疑えません。だから原因究明が長引きます。逆に言えば、リセットから main までの初期化を自分の手で書いて理解しておくことは、こうした「C より手前」の不具合を切り分けるための、一番効く備えになります。
判断の軸としてもう一点。初期化を「いつ」やるかには、その前段にあるクロックや電源の状態が絡みます。たとえば、ある RAM へアクセスしてよいのはクロックが立った後か、という前提です。ここは次の主題に譲ります。
10. まとめ
今回は、C が動き出す前の「起動の土台」を、アセンブラで据えました。
- RAM を使う前に現場が何をするか(レジスタ POST → クロック → ROM サム → RAM チェック/クリア)を地図として押さえ、本記事の射程をスタックと
.data/.bssに絞った - RAM 初期化を C で書いてはいけない理由(隠れた RAM 依存:
static・スタックプロテクタ・memcpyイディオム化)を示した -
.dataの初期値が ROM に焼かれ実行時は RAM に居ること、.bssのゼロは本来 C ランタイムの保証だったことを、物理から導いた - リンカで LMA≠VMA(
AT>/LOADADDR)とALIGN(4)を宣言し、.data/.bss初期化をアセンブラで実装した -
main以降で顔を出すmemcpy/memset(初期化後なので C 可)を最小実装で塞いだ - QEMU で、初期化の前後に変数の中身が変わることを GDB で確認した
静的な配置(前々回)→ 実行確認(前回)→ そして今回、C が安全に動き出す土台を自分の手で据えたことで、実機の STM32L552 でも、C のプログラムが前提とする実行環境が一通り揃います。
次回以降の候補は3つです。現場のベースラインの残り(レジスタ POST / ROM サム / RAM マーチテスト / クロック立ち上げ)を設計レビューの観点でまとめる回。今回最小に留めた memcpy/memset を CPU 効率と検証の観点で作り込む回。そして、Cortex-M33 のアセンブラそのものを、パイプラインや例外時のレジスタ退避まで踏み込んで扱う回です。
注記
-
[注1](5.「リンカスクリプト」) VMA/LMA、
AT>、LOADADDR()の意味は GNU ld のマニュアルに定義がある。📝【執筆メモ・公開前に処理】出典 URL を確認して記載(GNU Binutils ld ドキュメントの Output Section LMA の項)。 -
[注2](7.「memcpy / memset」) freestanding 環境でも、コンパイラは構造体コピーや配列の一括初期化を
memcpy/memmove/memset/memcmpの呼び出しに変換することがあり、これらの実体は利用側で用意する必要がある。📝【執筆メモ・公開前に処理】GCC のドキュメント(freestanding でこれら関数が要求される旨)の URL を確認して記載。Clang も同様。 -
[注3](4.・5.1・8.) STM32L552ZE は Flash 512KB(
0x08000000)、SRAM 256KB(0x20000000)。出典:STMicroelectronics「STM32L552xx データシート」メモリマッピングの章(前回と同出典)。 -
[注4](5.2) QEMU
mps2-an505の0x10000000(ZBT SSRAM、4MB、rwx)と Secure ベクタテーブル初期オフセット0x10000000は前回と同出典(QEMUhw/arm/mps2-tz.c)。本記事ではこの領域を ROM 相当/RAM 相当に二分している。 -
[注5](6.) Armv8-M Mainline の
MSPLIM(メインスタックポインタ下限)と、例外エントリでのハードウェアによるレジスタ退避の挙動については、Arm 公式の Armv8-M アーキテクチャ資料を参照(前回と同出典)。 -
[注6](8.) semihosting(
SYS_WRITE0/SYS_EXIT_EXTENDED、bkpt 0xAB)は前回(検証・デバッグ編)と同出典。