前回(環境構築編)では、macOS 上の LLVM(ATfE)で Cortex-M33 向けにビルドできる土台を整えました。今回は、いよいよリセット直後の入口を書いていきます。CPU はリセットが解除された瞬間、どこからスタックポインタを読み、どこへ分岐するのか。その最初の数命令を、生成コードに任せず自分で配置してみます。
1. はじめに ── 「main が呼ばれる前」の世界へ
C のプログラムは main から始まる、とよく説明されます。けれど、ベアメタルでは main を呼び出してくれる OS もランタイムもありません。
リセットが解除されてから main の最初の一行に到達するまでの間に、確かに何かが起きています。今回はその「main が呼ばれる前」の世界を、ベクタテーブル、リセットハンドラ、リンカスクリプトの3つに分けて組み立てます。
なお、今回は .data のコピーや .bss のゼロクリアまでは扱いません。そこは次回以降の主題に回し、この記事では「CPU が最初に読むものを、ELF の中に正しく置く」ことに集中します。
2. なぜ、最初の一歩はアセンブラなのか
「Cortex-M はリセット時にハードウェアがスタックポインタを読み込んでくれるので、リセット処理は C で書ける」——これは条件付きで正しい説明です。実際、リセット直後の MSP はベクタテーブル先頭の値で初期化されます。
ただし、私は スタックポインタと最低限のレジスタ状態を自分で確認できる地点までは、アセンブラで書く 方が安全だと考えています。理由は3つあります。
ひとつめ。C コンパイラが関数入口のプロローグで何をするかを、私たちは完全には制御できません。レジスタ退避、スタックフレームの確保、最適化レベルによる命令列の変化。こうした処理が入る以上、「C の関数に入った瞬間にスタックが使われる可能性がある」と見ておくべきです。
ふたつめ。Armv8-M には、最初の C 関数に入る前に済ませておきたい初期化があります。たとえば MSPLIM(Main Stack Pointer Limit)の設定です。FPU を使うなら、CPACR で有効化する処理も必要になります。これらは C のロジックというより、C を安全に実行するための土台づくりです。
みっつめ。だからこそ、「ここまでは自分が確実に制御している」と言える地点まではアセンブラで進み、状態が整ってから C にバトンを渡す。このバトンの渡しどころを意識することが、堅いスタートアップを書く第一歩になります。
それでは、最初の部品から置いていきましょう。
3. ベクタテーブルを置く
リセットが解除されると、コアはソフトウェアより先に、ベクタテーブルの先頭から2つの値を読み込みます。先頭(オフセット 0x00)が初期スタックポインタの値、次(0x04)がリセットハンドラのアドレスです [注1]。つまり、私たちの C コードが1命令も走らないうちに、スタックの初期値と最初の分岐先は決まっています。
ベクタテーブルをどこに置くかは、リセット時の VTOR(Vector Table Offset Register)の初期値に依存します。この初期値は、CPU コアだけで決まるものではなく、シリコンやボード側の実装に左右されます。後で使う QEMU の mps2-an505 では 0x10000000、実機の STM32L552 では Flash 側を指す前提で進めます [注2][注3]。
.syntax unified
.cpu cortex-m33
.thumb
.section .isr_vector, "a"
.p2align 7 /* 2^7 = 128byte 境界 */
.global g_pfnVectors
g_pfnVectors:
.word _estack /* 0x00: 初期MSP値(飛び先ではなくSPの値) */
.word Reset_Handler /* 0x04: Reset */
.word Default_Handler /* 0x08: NMI */
.word Default_Handler /* 0x0C: HardFault */
.word Default_Handler /* 0x10: MemManage */
.word Default_Handler /* 0x14: BusFault */
.word Default_Handler /* 0x18: UsageFault */
.word Default_Handler /* 0x1C: SecureFault */
/* 実際には、この後に予約領域、SVC、PendSV、SysTick、外部IRQなどを必要に応じて続ける */
ここで覚えておきたい点があります。
.word Reset_Handler と書いたとき、Reset_Handler が .thumb_func で Thumb 関数シンボルとして宣言されていれば、リンカはベクタに bit0 を 1 にした値を書き込みます。Cortex-M のベクタは、「分岐先が Thumb コードである」ことを示すため、各ハンドラアドレスの bit0 を 1 にしておく必要があるからです [注1]。
したがって、この形で書く限り、手で | 1 を足す必要はありません。逆に、Thumb 関数として扱われないシンボルや、生の数値を直接置く書き方をすると、bit0 が立たない可能性があります。その場合、コアは正しい実行状態に入れず、即座に例外へ落ちます。
.p2align 7 で 128 バイト境界に整えているのは、ベクタテーブルにアライメント要件があるためです [注4]。先頭の _estack だけは飛び先ではなく「スタックの初期値」です。この実体は、後ほどリンカスクリプトで定義します。
4. リセットエントリ ── ここまでが ASM の領分
次に、ベクタテーブルの 0x04 から呼ばれる Reset_Handler を書きます。ここで、前章で触れた「自分が制御している地点」を作ります。
.section .text
.thumb_func
.global Reset_Handler
Reset_Handler:
ldr r0, =_estack
mov sp, r0 /* SP を明示的に確定する */
ldr r0, =_sstack
msr MSPLIM, r0 /* Armv8-M: メインスタックの下限を設定 */
bl main /* ここで初めて C にバトンを渡す */
.Lhang:
b .Lhang /* main から戻ってきたら、ここで停止 */
.thumb_func
.global Default_Handler
Default_Handler:
b Default_Handler /* 想定外の例外は、ここで捕まえて止める */
mov sp, r0 で、スタックポインタをあらためて _estack に設定します。リセット時点ですでにハードウェアが同じ値を MSP に読み込んでいますが、ここでは「この値を使う」と明示しています。
続く msr MSPLIM, r0 では、メインスタックがどこまで伸びてよいかの下限を設定します。これが、Armv8-M で C に入る前に済ませておきたい初期化の一例です。
そして bl main で、ようやく C の世界へ入ります。
Default_Handler を無限ループにしているのは、想定外の例外を握りつぶさず、その場で止めるためです。後でデバッガをつないだとき、どの例外に落ちたのかを追いやすくなります。
5. リンカスクリプト ── アドレス空間に「ルールを敷く」
ここからが今回の山場です。
リンカスクリプトというと、「ROM や RAM のアドレスを書くもの」と思われがちです。もちろん、それは間違いではありません。ただし本質は、もう少し深いところにあります。
リンカスクリプトを書くということは、アドレス空間に配置のルールを敷くということです。
どの領域をコード置き場にするのか。どこを RAM として使うのか。何バイト境界に揃えるのか。読み出し専用データをどこへ置くのか。初期値を持つ変数は、ロード時には Flash に置き、実行時には RAM に置くのか。こうした判断が、すべてリンカスクリプトに現れます。
つまり、リンカスクリプトは単なる住所録ではありません。メモリ配置に対する設計思想そのものです。
その中でも、アライメントは経験者でも見落としやすいポイントです。
ARM の Thumb 命令や RH850 の命令は、少なくとも 2 バイト境界に配置されている必要があります。これは、命令が置かれるアドレスの最下位ビットが 0 でなければならない、という意味です [注5]。
これを誤ると、厄介なことが起きます。リンクやオブジェクト生成は通ってしまう場合があります。エラーが出ないままバイナリができる。ところが実行すると、CPU は命令フェッチや分岐先の異常として例外に落ちます。常時実行される起動コードでこれを踏むと、再起動ループのように見えることもあります。
私も過去に、命令配置のアライメントを見落として半日潰したことがあります。JTAG/SWD デバッガで追っても、リセット直後に例外へ落ちるため、原因の切り分けにかなり手間がかかりました。リンカスクリプトの不備は、ソースコード上のバグより見えにくい。だからこそ、最初から「アドレスを書いている」のではなく「配置規則を書いている」と意識した方がよいです。
それを踏まえて、実際のスクリプトを見ていきます。今回は「メモリマップを差し替えれば、同じスタートアップを別ターゲットにも載せられる」形を目指します。
5.1 QEMU 検証版
QEMU の mps2-an505 は Cortex-M33 を積んだ仮想ボードで、リセット時の Secure 側ベクタテーブル初期位置が 0x10000000 になるよう実装されています [注2]。
検証用なので、ここでは単一のメモリ領域にコードもデータも同居させます。起動時の .data コピーを不要にし、まずは最短でリセットハンドラまで確認するためです。
ENTRY(Reset_Handler)
MEMORY
{
CODE (rwx) : ORIGIN = 0x10000000, LENGTH = 4M
}
_estack = ORIGIN(CODE) + 0x00100000; /* スタックトップ */
_sstack = _estack - 0x00004000; /* 16KB を MSPLIM の下限に */
SECTIONS
{
.isr_vector ORIGIN(CODE) : { KEEP(*(.isr_vector)) } > CODE
.text : { *(.text*) *(.rodata*) } > CODE
.data : { *(.data*) } > CODE
.bss : { *(.bss*) *(COMMON) } > CODE
}
ENTRY(Reset_Handler) でエントリを宣言し、KEEP(*(.isr_vector)) でベクタテーブルが --gc-sections によって捨てられないようにします。
_estack と _sstack は、先ほどアセンブラから参照していたスタックの上限と下限です。_estack を SP の初期値にし、_sstack を MSPLIM に設定します。
5.2 実機 STM32L552 版
実機では、メモリマップが変わります。STM32L552ZE は Flash 512KB を 0x08000000 に、SRAM 256KB を 0x20000000 に持ちます [注3]。
まず、メモリ定義は次のようになります。
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 256K
}
_estack = ORIGIN(RAM) + LENGTH(RAM); /* スタックトップ = RAM 末尾 */
QEMU 検証版との大きな違いは、命令や定数(.text / .rodata)を FLASH に置き、書き換わるデータ(.data / .bss)を RAM に置くことです。
そのため実機版では、単に MEMORY を変えるだけでは完結しません。起動時に .data の初期値を Flash から RAM へコピーし、.bss をゼロで埋める処理が必要になります。この「自前のコピー・クリア」は、次回以降の主役です。
今回は、まず QEMU 上で「ベクタテーブルとリセットハンドラを意図した場所に置く」ことを確認します。実機向けの完全な初期化は、その土台の上に積みます。
6. ビルドして、配置を ELF で確かめる
ビルドしたら、いきなり動かす前に ELF の中身を見て、配置が意図どおりかを確認します。freestanding の世界では、この確認が非常に重要です。
COMMON="--target=arm-none-eabi -mcpu=cortex-m33 -mfpu=fpv5-sp-d16 -mfloat-abi=hard -ffreestanding"
clang $COMMON -Os -c startup.s -o startup.o
clang $COMMON -Os -c main.c -o main.o
clang $COMMON -nostdlib -fuse-ld=lld -T link_qemu.ld -Wl,--gc-sections startup.o main.o -o firmware.elf
# 配置の確認
llvm-readelf -S firmware.elf | grep -E 'isr_vector|\.text'
llvm-objdump -s -j .isr_vector firmware.elf | head
確認するポイントは次の2つです。
-
.isr_vectorが0x10000000に配置されていること。 - ベクタ先頭の2語が、初期スタックポインタとリセットハンドラを指していること。
たとえば _estack を 0x10100000 にしていれば、ベクタ先頭にはリトルエンディアンで 00 00 10 10 が見えるはずです。
また、Reset_Handler が仮に 0x10000030 に配置されていれば、リセットベクタには 0x10000031 が入ります。末尾が ...30 ではなく ...31 になる点に注目してください。これが **bit0=1(Thumb ビット)**が立っている証拠です。
つまり、仕様として読んだ話を、ELF の実バイトとして確認できます。
さらに Reset_Handler を逆アセンブルすると、mov sp → msr MSPLIM → bl main が、書いたとおりに並んでいることも確認できます。
リセット直後にどのスタック値を使い、どこへ飛ぶのか。
それが生成物の奥に隠れず、自分の ELF の中に明示されている。これが今回の到達点です。
7. まとめと、次回のこと
今回は、リセット直後にコアが読むベクタテーブルと、スタックを確定させるリセットハンドラをアセンブラで置きました。さらに、その配置をリンカスクリプトで決め、ELF の中身として確認しました。
リンカスクリプトは、単にアドレスを書くためのファイルではありません。コード、データ、スタック、アライメントといった配置規則を定義する設計文書です。ここを曖昧にすると、ビルドは通るのに起動しない、という一番面倒な不具合に直結します。
ただし、ここまではまだ「動かさずに、配置の正しさを確かめた」段階です。
次回(検証・デバッグ編)では、これを QEMU で実際に動かし、GDB と VSCode で止めて、レジスタの中身まで確認します。書いたコードが本当に意図どおりに動いているのかを、自分の目で追っていきます。
注記
-
[注1](3.「ベクタテーブルを置く」) リセット時、コアはベクタテーブルのオフセット
0x00から初期メインスタックポインタ(MSP)を、0x04からリセットハンドラのアドレスを読み込む。各ベクタの最下位ビット(bit0)は、その分岐先が Thumb 状態のコードであることを示すため 1 とする必要がある。Cortex-M は Thumb 専用であり、bit0 が 0 だと正しい実行状態に入れず、例外に至る。出典:STMicroelectronics「PM0264 STM32 Cortex-M33 MCUs and MPUs programming manual」(ベクタテーブル/例外モデルの章)。 -
[注2](3.・5.1) QEMU の
mps2-an505マシンは Cortex-M33 を実装し、リセット時の Secure ベクタテーブルオフセット(init_svtor)の初期値が0x10000000に設定される。このため検証用バイナリのベクタテーブルは0x10000000に置く。出典:QEMU ソースツリーhw/arm/mps2-tz.c(mps2-an505マシン定義)。 -
[注3](3.・5.2) STM32L552ZE は Flash 512KB(ベースアドレス
0x08000000)、SRAM 256KB(ベースアドレス0x20000000)を持つ。出典:STMicroelectronics「STM32L552xx データシート」(メモリマッピングの章)。 -
[注4](3.「ベクタテーブルを置く」) ベクタテーブルには配置アライメント要件があり、実装する例外・割り込み数に応じて 2 の累乗境界に整える必要がある。本記事では 128 バイト境界(
.p2align 7)に配置している。出典:PM0264(ベクタテーブルアライメントの記述)。 -
[注5](5.「リンカスクリプト」) ARM(Thumb 命令)および RH850 では、命令を配置するアドレスはアーキテクチャの定めるアライメントに従う必要がある。最小 2 バイト境界とは、命令アドレスの最下位ビットが 0 であることを意味する。これに反する配置は、実行時に例外やミスアライン関連の割り込みとして顕在化する可能性がある。ARM 側の根拠は、PM0264 および Armv8-M アーキテクチャリファレンスの命令アライメント規定にある。RH850 側は、ルネサス エレクトロニクスの該当アーキテクチャ/ユーザーズマニュアルの命令アライメント規定を参照のこと。