前回(最小起動編)では、リセット直後に参照されるベクタテーブル、初期スタックポインタ、
Reset_Handlerからmainまでの最小経路を自分で構成し、ELF上の配置を確認しました。今回は、そのELFを実際に走らせて、起動経路が期待どおりに実行されるかを確認します。使うのは QEMU、GDB、VS Code です。QEMU の
mps2-an505は Cortex-M33 を載せた仮想ボードなので、STM32L552そのものを再現するものではありません。それでも、Cortex-M33としてのリセット処理、ベクタテーブル、スタック設定、mainへの分岐を検証するには十分です。この記事では、まず semihosting で最小の出力を行い、次に GDB でリセット直後から1命令ずつ追います。最後に VS Code から同じGDBサーバへ接続し、レジスタやメモリをGUIで確認できる状態にします。
1. はじめに ── 「配置が正しい」と「正しく動く」は別
前回、私たちは firmware.elf をELFとして読み、ベクタテーブルとリセットベクタが意図したアドレスに配置されていることを確認しました。
ただし、「正しく置かれている」ことと「正しく動く」ことは別です。Cortex-Mでは、リセット時にベクタテーブルから初期スタックポインタとリセットハンドラのアドレスが読み出されます。その後、CPUは Reset_Handler へ分岐し、そこから main へ到達します。この流れは、ELFの静的な確認だけでは見えません。
実機がなくても、この起動経路は QEMU で確認できます。さらに QEMU の GDB サーバへ GDB を接続すれば、リセット直後から main へ入るまでを1命令ずつ追えます。
今回使うターゲットは QEMU の mps2-an505 です。これは Cortex-M33 を搭載した Arm MPS2/AN505 相当の仮想ボードであり、STM32L552の周辺回路まで再現するものではありません。したがって、ここで検証するのは「Cortex-M33としての最小起動」です。STM32L552実機へ持っていく段階では、メモリマップ、Flash、クロック、周辺レジスタの差分を別途扱う必要があります。
2. QEMU で動かす
2.1 動作確認用の main
まずは、起動したことを確認できる最小の main を用意します。ここでは semihosting を使い、QEMU側のコンソールに一行だけ文字列を出します。
標準ライブラリは使いません。printf() も使いません。ターゲット側では、決められたレジスタに番号と引数を入れ、bkpt 0xAB を実行するだけです。
/* semihosting: nostdlib のまま、ホストに一行だけ出す */
static void sh_write0(const char *s)
{
register int op asm("r0") = 0x04; /* SYS_WRITE0 */
register const char *param asm("r1") = s;
asm volatile("bkpt 0xAB" :: "r"(op), "r"(param) : "memory");
}
__attribute__((noreturn))
static void sh_exit(int code)
{
int block[2] = {
0x20026, /* ADP_Stopped_ApplicationExit */
code
};
register int op asm("r0") = 0x20; /* SYS_EXIT_EXTENDED */
register int *param asm("r1") = block;
asm volatile("bkpt 0xAB" :: "r"(op), "r"(param) : "memory");
for (;;) {
/* semihosting 実装が戻ってきた場合の保険 */
}
}
int main(void)
{
sh_write0("Hello from freestanding Cortex-M33 (QEMU mps2-an505)\n");
sh_exit(0);
}
bkpt 0xAB が semihosting の入口です。M-profile の semihosting では、r0 に操作番号、r1 に引数を置き、bkpt 0xAB でホスト側の実装に処理を渡します。
ここで使っている操作は2つです。
-
SYS_WRITE0 (0x04)
r1が指すNUL終端文字列をホスト側へ出力します。 -
SYS_EXIT_EXTENDED (0x20)
2フィールドの引数ブロックを渡し、終了理由と終了コードをホスト側へ伝えます。
古い資料では SYS_EXIT (0x18) を使った例もありますが、終了コードを明示的に渡すなら SYS_EXIT_EXTENDED を使う方が意図が明確です。
2.2 走らせる
ビルドした firmware.elf を QEMU に渡します。
qemu-system-arm -M mps2-an505 -cpu cortex-m33 -nographic \
-semihosting-config enable=on,target=native \
-kernel firmware.elf
期待する出力は次のとおりです。
Hello from freestanding Cortex-M33 (QEMU mps2-an505)
-semihosting-config enable=on,target=native は、ターゲット側で実行された semihosting 要求を QEMU が受け取るための指定です。
この一行が表示され、QEMUが正常終了すれば、少なくとも次の経路は通っています。
ベクタテーブル
→ Reset_Handler
→ スタック設定
→ main
→ semihosting 出力
→ semihosting 終了
つまり、前回ELF上で確認した配置が、実行時にも機能したことになります。
3. GDB で止める・1命令ずつ追う・レジスタを覗く
QEMUで文字が出るだけでは、起動時の内部状態までは分かりません。次は GDB をつなぎ、リセット直後から命令単位で確認します。
ここでは、-g を付けてビルドしたELFを firmware_dbg.elf と呼ぶことにします。実行用の firmware.elf と別名にしていますが、ソースとリンカスクリプトは同じです。違いは、GDBがソース行やシンボルを追うためのデバッグ情報を含めている点です。
3.1 QEMUを停止状態で起動する
QEMUを -S 付きで起動します。-S は、CPUをリセット直後の停止状態にして、GDBからの操作を待たせる指定です。
qemu-system-arm -M mps2-an505 -cpu cortex-m33 -nographic \
-semihosting-config enable=on,target=native \
-kernel firmware_dbg.elf \
-S -gdb tcp::1234
-gdb tcp::1234 により、QEMU は TCP 1234番ポートで GDB の接続を待ちます。
別の端末から GDB を起動します。
(gdb) file firmware_dbg.elf
(gdb) target remote :1234
(gdb) break main
(gdb) continue
main で止まれば、Reset_Handler から main まで到達できています。
3.2 1命令ずつ、ASMからCへのバトンを追う
main で止めるだけでは、起動時のアセンブリ処理を見落とします。リセット直後から追う場合は、QEMUを -S で止めた状態で接続し、stepi を使います。
stepi は、機械語を1命令だけ実行するGDBコマンドです。
(gdb) target remote :1234
(gdb) x/8i $pc
(gdb) info registers
(gdb) stepi
たとえば、前回の Reset_Handler が次のような構成だったとします。
Reset_Handler:
ldr r0, =_estack
mov sp, r0
ldr r0, =_sstack
msr MSPLIM, r0
bl main
この場合、命令を進めると概ね次のように見えます。
リセット直後
PC = 0x10000030
SP = 0x10100000 ← ベクタテーブル先頭の初期SPがロードされている
stepi → ldr r0, =_estack
stepi → mov sp, r0
SP = 0x10100000 ← Reset_Handler 内で明示的に再設定
stepi → ldr r0, =_sstack
stepi → msr MSPLIM, r0
MSPLIM = 0x100fc000 ← スタック下限を設定
stepi → bl main
PC = main ← Cの世界へ入る
ここで重要なのは、CPUがアセンブリを順に実行し、最後に bl main でCの関数へ入っていることです。
前々回から扱ってきた「ASMからCへのバトン」は、実行時には bl main という1命令として現れます。抽象的な説明ではなく、GDB上で実際のPCと命令列として確認できます。
3.3 レジスタを覗く
main で止めた状態で、レジスタを確認します。
(gdb) info registers
Cortex-M33 / TrustZone 有効の構成では、GDBや拡張の表示上、Secure側のスタックポインタやスタックリミットが見えることがあります。
xpsr = 0x41000000 ← bit24(T-bit) が立っている
msp_s = 0x100ffff8 ← Secure側のメインスタックポインタ
msplim_s = 0x100fc000 ← Reset_Handlerで設定したスタック下限
control = 0x00000000 ← Thread mode / privileged / MSP 使用
psp = 0x00000000 ← PSPは未使用
ここで見るべき点は3つです。
ひとつめは、xpsr の T-bit が立っていることです。Cortex-M は Thumb状態で実行されます。リセットベクタの bit0 は、実行開始アドレスの一部ではなく、Thumb状態を示すために使われます。実際のPCは偶数アドレスになります。
ふたつめは、msp_s と msplim_s です。今回の構成では、リセット後に Secure側のMSPを使い、Reset_Handler 内で MSPLIM を設定しています。前回リンカスクリプトで用意したスタック範囲が、実行時のレジスタにも反映されています。
みっつめは、control と psp です。control = 0 であれば、Thread mode、特権、MSP使用という、リセット直後に近い素直な状態です。OSもRTOSも入れていないので、PSPはまだ使っていません。
これで、リセット直後の最低限の実行状態を、自分の手で確認できました。
4. VS Code で止める
CLIのGDBだけでも十分に追えますが、VS Codeから見ると、レジスタ、逆アセンブル、ソース行、メモリを並べて確認できます。
Cortex-M のベアメタルデバッグでは、VS Code拡張の Cortex-Debug がよく使われます。ここでは、QEMUを外部GDBサーバとして起動し、VS Codeからそこへ接続します。
考え方は単純です。
QEMU
└─ GDBサーバとして localhost:1234 を開く
VS Code / Cortex-Debug
└─ arm-none-eabi-gdb で localhost:1234 へ接続する
4.1 launch.json
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "QEMU Debug (external)",
"type": "cortex-debug",
"request": "launch",
"servertype": "external",
"cwd": "${workspaceFolder}",
"executable": "${workspaceFolder}/firmware_dbg.elf",
"gdbTarget": "localhost:1234",
"gdbPath": "arm-none-eabi-gdb",
"runToEntryPoint": "Reset_Handler",
"preLaunchTask": "qemu-debug-server",
},
],
}
servertype: "external" は、Cortex-Debug自身がGDBサーバを起動するのではなく、すでに起動している外部GDBサーバへ接続する指定です。
gdbTarget には QEMU のGDBサーバを指定します。gdbPath には、実際に動作する arm-none-eabi-gdb を指定します。macOS環境ではPATH解決で詰まることがあるため、安定させるなら絶対パスで書く方が確実です。
4.2 tasks.json
次に、デバッグ開始前に QEMU を起動するタスクを用意します。
// .vscode/tasks.json
{
"version": "2.0.0",
"tasks": [
{
"label": "qemu-debug-server",
"type": "shell",
"command": "bash",
"args": [
"-c",
"qemu-system-arm -M mps2-an505 -cpu cortex-m33 -nographic -semihosting-config enable=on,target=native -kernel ${workspaceFolder}/firmware_dbg.elf -S -gdb tcp::1234 & QPID=$!; for i in $(seq 1 100); do (echo > /dev/tcp/localhost/1234) 2>/dev/null && break; sleep 0.05; done; echo QEMU_LISTENING; wait $QPID",
],
"isBackground": true,
"problemMatcher": {
"pattern": {
"regexp": "^____never_matches____$",
},
"background": {
"activeOnStart": true,
"beginsPattern": "^____begin____$",
"endsPattern": "QEMU_LISTENING",
},
},
},
],
}
ここで重要なのは、QEMU_LISTENING という合図です。
QEMUを -S 付きで起動すると、CPUは停止状態でGDB接続を待ちます。このとき、QEMUは「準備完了」と分かりやすく表示してくれるとは限りません。そのため VS Code は、バックグラウンドタスクが完了したか判断できず、デバッグ開始前に待ち続けることがあります。
そこで、タスク側で localhost:1234 が開くまで待ち、ポートが開いたら QEMU_LISTENING を出力します。Cortex-Debugは、その後にGDB接続を開始します。
うまく接続できると、VS Codeのレジスタビューに xpsr、msp、msplim などが表示されます。周辺レジスタまで見たい場合は svdFile を指定しますが、コアレジスタと特殊レジスタを見るだけなら必須ではありません。
5. コラム① semihosting は「OSのシステムコール」ではない
main で文字列を出すために使った bkpt 0xAB は、Breakpoint命令です。Backportではありません。
M-profile の semihosting では、ターゲット側のコードが r0 に操作番号、r1 に引数を置き、bkpt 0xAB を実行します。すると、QEMUやデバッガなどのホスト側実装がその例外を受け取り、要求された処理を代行します。
たとえば、今回の SYS_WRITE0 では次のようになります。
ターゲット側
r0 = 0x04 // SYS_WRITE0
r1 = 文字列のアドレス
bkpt 0xAB
ホスト側
r1 が指す文字列を読み取り、コンソールへ出力する
ここで処理している相手は、ターゲット上のOSカーネルではありません。QEMUやデバッガなど、ホスト側のsemihosting実装です。
本物のOSシステムコールであれば、通常は SVC などを使い、ターゲット側のOSへ制御を渡します。しかし、今回のシステムにはOSがありません。semihosting は、デバッグ用の例外処理に便乗して、ホスト側へ入出力を依頼する仕組みです。
したがって、semihosting には明確な制約があります。
実機でデバッガを接続せずに実行すると、semihosting は動きません。
bkpt 0xAB を受け止める相手がいないからです。最悪の場合、その場でHardFaultやデバッグ例外になって止まります。
semihosting は、あくまで検証用の足場です。実機で継続的にログを出したいなら、UART、SWO、RTTなどに置き換えるべきです。足場を本番に持ち込まない。この線引きは、ベアメタルではかなり重要です。
6. コラム② QEMU の「起動待ち」と、VS Code が固まる理由
CLIでデバッグする場合、QEMUを次のように起動します。
qemu-system-arm -M mps2-an505 -cpu cortex-m33 -nographic \
-semihosting-config enable=on,target=native \
-kernel firmware_dbg.elf \
-S -gdb tcp::1234
この状態では、QEMUはCPUを停止させたまま、GDB接続を待ちます。ターミナルがそこで止まって見えるのは正常です。別端末からGDBを起動して接続すれば問題ありません。
VS Codeでは少し事情が違います。
VS Codeの preLaunchTask は、デバッグ開始前の準備タスクです。ここでQEMUを起動すると、VS Codeは「準備が完了した」という合図を待ちます。しかし、-S で停止しているQEMUは、分かりやすい準備完了メッセージを出さないことがあります。
その結果、VS Codeは次の状態に陥ります。
VS Code:
QEMUの準備完了を待っている
QEMU:
GDBの接続を待っている
結果:
どちらも先へ進まない
これが、「VS Codeがデバッグ開始前に固まったように見える」原因です。
対策は単純です。QEMUのGDBサーバポートが開いたことをタスク側で確認し、その後に明示的な合図を出します。
今回の tasks.json では、次の文字列を使いました。
QEMU_LISTENING
problemMatcher.background.endsPattern にこの文字列を指定しておけば、VS Codeは「QEMUの準備が完了した」と判断できます。
これにより、GDBサーバのポートが開く前に接続しに行って失敗する問題と、準備完了を検出できずに待ち続ける問題を避けられます。
7. コラム③ 「つながっているのに、ブレークできない」── たいてい -g 忘れ
GDBはつながっている。QEMUも動いている。なのに VS Code のエディタ上で張ったブレークポイントが効かない。
この場合、最初に疑うべきなのは -g の付け忘れです。
-g なしでも、関数名でのブレークは効くことがあります。
(gdb) break main
これは、ELFに関数シンボルが残っていれば、GDBが main のアドレスを解決できるからです。
一方、ソース行でのブレークには、ソースファイル名と行番号を機械語アドレスへ対応づける情報が必要です。
(gdb) break main.c:14
この対応関係は、DWARFなどのデバッグ情報に含まれます。-g を付けずにビルドすると、この行情報が入らないため、ソース行ブレークは解決できません。
VS Codeのエディタで行番号の横をクリックして張るブレークポイントも、実体は file:line ブレークです。したがって、-g がないELFを指定していると、ブレークポイントが灰色のままになったり、実行時にバインドされなかったりします。
本記事のような最小ベアメタルの条件では、-g を付けても .text の配置やサイズは変わらないことを llvm-readelf で確認できます。-g は主にデバッグ情報をELFへ追加する指定であり、実機へ書き込む .bin には通常その情報は含まれません。
したがって、開発中は -g 付きのELFを残しておき、配布や書き込み用の成果物では必要に応じてデバッグ情報を取り除く、という運用が自然です。
「つながっているのにブレークできない」ときは、まず次の2点を確認します。
1. ビルド時に -g を付けているか
2. VS Code の executable が -g 付きのELFを指しているか
ここを外すと、QEMUやGDBの設定が正しくても、ソース行デバッグは成立しません。
8. まとめ ── ベクタテーブルから main までを、実行時の姿として確認した
今回は、前回作成した最小起動コードを QEMU 上で実行し、GDB と VS Code から確認しました。
確認したことを整理すると、次のとおりです。
1. QEMU の mps2-an505 で Cortex-M33 向けELFを実行した
2. semihosting で標準ライブラリなしに文字列を出力した
3. GDB でリセット直後から1命令ずつ追跡した
4. 初期SP、MSPLIM、xPSR、CONTROLなどのレジスタを確認した
5. VS Code / Cortex-Debug から QEMU のGDBサーバへ接続した
ここまでで、ベクタテーブル、初期スタック、Reset_Handler、main までの流れを、静的なELF配置だけでなく、実行時のPCとレジスタとして確認できるようになりました。

ただし、今回の main は動作確認用の最小版です。実機の STM32L552 で動かすには、まだ起動処理が足りません。
具体的には、起動時に .data をROMからRAMへコピーし、.bss をゼロクリアする必要があります。また、-ffreestanding を指定していても、コンパイラが memcpy や memset 相当の呼び出しを生成する場合があります。
次回は、この起動処理の本体を作ります。Reset_Handler の中で .data と .bss を初期化し、Cのプログラムが前提としている実行環境を自分の手で整えます。
注記
-
[注1](5.「コラム① semihosting」/3.3 レジスタ)
M-profile の semihosting では、T32命令BKPT #0xABを semihosting 要求に用いる。操作番号はr0、引数はr1に置く。SYS_WRITE0は0x04、SYS_EXITは0x18、SYS_EXIT_EXTENDEDは0x20。終了コードを含む2フィールドブロックを渡す場合はSYS_EXIT_EXTENDEDを使う。出典:Arm semihosting 仕様。 -
[注2](4.「VS Code で止める」)
Cortex-Debug は、Cortex-M向けのVS Codeデバッグ拡張である。servertype: "external"を使うと、OpenOCDやQEMUなど、外部で起動済みのGDBサーバへ接続できる。gdbTargetには接続先、gdbPathには使用するGDBを指定する。出典:Cortex-Debug 公式ドキュメント。 -
[注3](4.「VS Code で止める」)
gdbPathには、実際に動作するGDBを指定する。macOSではarm-none-eabi-gdbの入手経路やコード署名の都合で詰まることがあるため、PATH任せにせず絶対パスで指定すると切り分けやすい。objdumpやnmなど、同じprefixのツールも必要になる場合がある。 -
[注4](7.「コラム③ -g 忘れ」)
-gはデバッグ情報をELFへ付加する指定である。ソース行ブレークには、ソースファイル名・行番号・機械語アドレスを対応づけるDWARF行情報が必要になる。本記事の条件では、-gの有無で.textのサイズと配置が一致することをllvm-readelfで確認している。配布用にデバッグ情報を落とす場合は、llvm-stripやllvm-objcopyを使う。