
2026/05/12 23:57
裸金属 STM32:ベクターテーブル、リンカー・スクリプト、ならびにスタートアップコードをゼロから構築すること
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
核心となるメッセージは、STM32 ボード上で「Hello World」を表示するという単純なタスクさえも、抽象化レイヤーによって隠されることが多い複雑で目に見えないハードウェア起動ルーチンに依存しているという点である。高レベル抽象化層(HALs)をバイパスする際には、開発者がプロセッサが正常に起動することを確実にするための重要な機構を手動で管理しなければならない。これには、メモリを初期化し、複数の割り込みに対する特定の処理機能へのアドレスをマッピングするベクタテーブルを構成することを含む。主要な技術要件として、アドレス 0x08000000 にスタックポインタを設定し、Thumb モードを有効にすることが挙げられ、これらのビットを設定しない場合、リセット時に即座に HardFault エラーが発生する。Rust の
stm32f4xx_hal といった現代のツールは自動的にこの必要な起動コードを生成するが、従来の C 開発では、本質的なハードウェアとの相互作用がコンパイラによって最適化されなくなるように、明示的に volatile キーワードを使用する必要がある。その結果、エンジニアたちは「単純はほとんど決して真に単純ではない」ということを学ぶ。これらの上層工程を無視すると、ソフトウェアプロジェクトの複雑さに関わらず物理的なリセットシーケンスは同じであるため、予期せぬ障害が発生する。HAL が除去された場合、あるいは誤って理解された場合にコードが破損する理由をデバッグするためには、これらの機構を理解することが不可欠である。本文
最近、STM32 Nucleo マイコンボードを購入し、実験用に使ってみました。私を魅了したのは、このレベルでのシステムの柔軟さと、自分でどのようなことが可能かを改めて実感させた点です。特に ESP32 ではそのような自由度は得られず、常に ESP-IDF や他かのフレームワークに縛られてしまいます。
まずは誰もが知っており、すでに多くの人が経験している「Hello World」の基本的な例から始めました。それは確かにシンプルで、そしてそれが有用である所以です。言語の構文を学ぶためにこれをやるべきではありません(それには小さすぎます)。重要なのは、言語を実際にどう使うかということです。どのようなファイル形式を使うのか、どのようにコンパイルするか、どのようにリンカするかなどを学びます。
そのため、新しい言語や環境を取り扱うたびに、私はまず「Hello World」の例から始めます。それは私に、他の作業をする前に一晩中ビルドシステムを実行させるのです。このプロセスを通じて私が気づいた二つの重要な事柄は、当初には明らかではありませんでした。第一に、簡単な例であっても真剣に取り組めば、驚くほど多くのことを学べるということです。第二に、「シンプルとはほとんどが単なる錯覚であり、実際にはそう簡単ではない」ということです。見かけ上簡単なものほど、それは他者があなたのために複雑さを隠してくれたからです。
組み込みの世界にも自らの「Hello World」があり、それは点滅する LED です。多くのマイコンボードには搭載された LED があり、ある周波数以降にオン・オフを切り替えることができます。これは一見すると些末なように聞こえますが、ハードウェア抽象化層 (HAL) とあるテンプレートプロジェクトを使えば確かにそうなのです。
私は長年来自動車ソフトウェア分野で働き、以前は大学で物理学シミュレーションの研究をしていました。私の認識の中で常に「ハードウェアに近い」という意識を持っていましたが、実際には組み込みソフトウェアを書くためであり、フロントエンドではありませんでした。先日、自動車における C、C++、Rust の役割について書きましたが、その際に静かに「組み込み=ハードウェアに近い」という前提をおいていました。しかしある時点で、それは幻想だったことに気づきました。MCAL (Microcontroller Abstraction Layer)、AUTOSAR OS、RTE: シリコン層と私との間に存在するのは、ウェブアプリとカーネルとの間の階層よりもさらに多くのレイヤーが存在するのです。一度は本当にその底辺まで下りてみたいと思い立ちました。HAL なし、フレームワークなし、ベンダーのブラックボックスなし。ただ、データシートの参照マニュアルとコンパイラーのみを頼りにしたいと考えたのです。
Rust で点滅させる例
Rust のエコシステムにおけるこの例はすぐに以下のような形になります:
#![no_std] #![no_main] use cortex_m_rt::entry; use panic_halt as _; use stm32f4xx_hal::{pac, prelude::*}; #[entry] fn main() -> ! { let dp = pac::Peripherals::take().unwrap(); let rcc = dp.RCC.constrain(); let clocks = rcc.cfgr.sysclk(48.MHz()).freeze(); let gpiob = dp.GPIOB.split(); let mut led1 = gpiob.pb0.into_push_pull_output(); let mut led2 = gpiob.pb7.into_push_pull_output(); let mut delay = dp.TIM1.delay_ms(&clocks); loop { led1.toggle(); delay.delay_ms(400u32); led2.toggle(); delay.delay_ms(100u32); } }
これは短く、型安全であり、動作します。コンパイラーは入出力ピンを切り替える操作を防ぎます。クロック構成はビルダーパターンを通じて設定されます。ピンタイプは型システム内でその構成を持ち、入力ピン上の
toggle() 呼び出しはコンパイルエラーになります。遅延時間は SYSCLK を基準としています。また、ここでは見えない部分(ベクターテーブル、リセット処理関数、.data セクションのためのコピーループ、.bss の初期化など)はすべて cortex-m-rt クレイトから提供されます。リンカは単に小さな memory.x ファイルを受け取り、フラッシュおよび RAM の位置を指定するだけです。
これこそ私が深く掘り下げたかった問題点です(「HAL が悪い」という意味ではありません;実際にはその価値を再認識しました)。「HAL が何を私のためにやってるのか」を確認したいのです。同じことを行いますが今度は C で、HAL も CMSIS もなし、ただ参照マニュアルから提示されるレジスタアドレスのみを使用します。
組み込みの Hello World
私が選んだボードは Nucleo-F446ZE です。まずドキュメント(参考データシートの RM0390)を読み始めました。第 6 章で RCC、第 8 章で GPIO を確認しました。
点滅自体の説明はとても簡単です。GPIOB のクロックを有効にし、PB0 を出力として構成し、ループ内で出力レジスタを切り替えます。C で抽象化を行わずに書くと、まずレジスタをマクロとして定義し、その後
main() 関数如下となります:
#include <stdbool.h> #include <stdint.h> #define RCC_BASE 0x40023800UL #define GPIOB_BASE 0x40020400UL #define RCC_AHB1ENR (*(volatile uint32_t*)(RCC_BASE + 0x30UL)) #define GPIOB_MODER (*(volatile uint32_t*)(GPIOB_BASE + 0x00UL)) #define GPIOB_ODR (*(volatile uint32_t*)(GPIOB_BASE + 0x14UL)) #define RCC_AHB1ENR_GPIOBEN (1UL << 1) #define LED_PIN 0U static void delay(volatile uint32_t n) { while (n--) { __asm__("nop"); } } int main(void) { RCC_AHB1ENR |= RCC_AHB1ENR_GPIOBEN; GPIOB_MODER &= ~(3UL << (LED_PIN * 2)); GPIOB_MODER |= (1UL << (LED_PIN * 2)); while (true) { GPIOB_ODR ^= (1UL << LED_PIN); delay(500000); } }
このコードについて説明が必要な点があります。
第一に、
*(volatile uint32_t*)(...) です。これは純粋なメモリアドレスマッピング I/O の形式です。ハードウェアは通常の RAM セルを指さない特定のアドレスを露出しており、これらは周辺装置のレジスタを指しています。RCC_AHB1ENR に書き込むことは「メモリセルに書き込みを行う」ことを意味しないもので、「RCC ブロックに対してどのクロックを有効にするか指示する」ことを意味します。volatile キャストはスタイルの選択ではなく必須です。volatile がなければコンパイラーはあなたの書き込み頻度に関心を持たず、アクセスを不要なストアとして最適化して削除してしまう可能性があります。その結果、点滅動作が静かに機能しなくなるでしょう。volatile はコンパイラーとの契約であり、「手を付けず、すべてのアクセスには目に見えない副作用がある」という約束です。
第二に、GPIOB_MODER の初期化です。PB0 の 2 ビットのモードビットを最初にクリアし、その後 01(一般目的の出力)として設定します。他のピンも同じレジスタに含まれているため、これによりそれらの影響を受けずに済みます(Read-Modify-Write)。Cortex-M ではこの操作は原子性ではありません(LDR, ORR/BIC, STR の 3 つの命令)。ISR が間に発火する可能性がありますが、これは初期化中はインタラプトがアクティブでないため機能します。実際には原子性が必要な場合は、ビットバンド領域を利用するか(Cortex-M7 では利用できない場合がある)、LDREX/STREX を使用します。GPIO 出力ピンに対して純粋なセットまたはクリアを行う場合は、BSRR レジスタを使用でき、これは特定の設計により一回の書き込みで個々のビットを原子的にセットまたはリセットすることを可能にします(Read-Modify-Write の必要がない)。
第三に、
delay() 関数です。パラメータ上の volatile と明示的な nop は飾りではありません。volatile がなければ、また最適化レベルにもよりますが、コンパイラーは誰もその値を読まないため減算スキップする可能性があります。また nop がない場合、ループ本体を圧縮する可能性があります。この二つは実際にループが実行されるように強制します。「16MHz で 500ms」という注釈は楽観的なものであり、実際の持続時間はコンパイラーの最適化、フラッシュウェイト状態、パイプラインによって異なります。点滅例としては問題ありませんが、プロダクション環境では SysTick を使用すべきです。
機能性についてこれだけでした。本質的に興味深い問いは
main() 内の内容ではなく、「main() がどうやって最初に呼び出されるのか」です。PC ではオペレーティングシステムがこの処理を行います。しかしマイコンにはオペレーティングシステム、ローダー、プロセス、コードを読み込みメモリを割り当て、ランタイムを準備するものは何もないのです。これはすべて手動で行う必要があります。これが私にとって興味深い点になりました。
ハードウェアは main()
について知らず
main()ARM Cortex-M4 の STM32 が起動すると、非常に具体的な動作を行います。アドレス
0x08000000 から 4 バイトを読み込み、これを初期スタックポインタとしてロードします。次に次の 4 バイトを 0x08000004 から読み取り、それをアドレスとして解釈し、そこにジャンプします。これはソフトウェアの命令ではなく、シロン内に設定された回路論理です。これ以降のすべての動作はソフトウェアによるものです。
知らないと数時間失う可能性がある詳細の一つに、リセットベクターアドレスのビット 0 を有効にしなければならないことが挙げられます。Cortex-M4 は Thumb 命令集のみを理解しており、CPU はジャンプアドレスのビット 0 をモードビットとして使用します。これがゼロの場合、リセット直後に HardFault が発生します。リンカは通常この処理を代行しますが、手動でベクターテーブルを構築し、関数ポインタシンボルをキャストする必要がある人はこれを苦く学ぶことになります。
これにより明確な要件が生じます。アドレス
0x08000000 には正確に正しいものが置かれている必要があります。この構造はベクターテーブルと呼ばれ、単なる関数ポインタの配列です。最初のエントリはスタックポインタ(ハードウェアは型を気にせず 4 バイトを読むだけ)、次のエントリはリセット処理関数のアドレスです。その後、NMI、HardFault、その他の処理関数が続きます。インタラプト発生時、ハードウェアはこのテーブルを探し、アドレスを読み取りそこにジャンプします。これはソフトウェアディスパッチではなく、ハードウェアによるジャンプテーブルです。
コードで大幅に短縮した例は以下の通りです。フルバージョンには MemManage、BusFault、UsageFault、SVCall、PendSV、SysTick、そして STM32 固有の IRQ 約 80 も含まれます:
__attribute__((section(".isr_vector"))) void (*const vector_table[])(void) = { (void (*)(void))(&_estack), Reset_Handler, Default_Handler, /* NMI */ Default_Handler, /* HardFault */ };
section(".isr_vector") 属性は重要です。これはコンパイラーに「このデータは特別名前のセクションに属する」と伝えますが、そのセクションがメモリ上にどこに配置されるかはここで決まりません。ここが初めて私は、コンパイラーとハードウェアが直接やり取りしていないことに気づいた瞬間です。何か欠落しているのです。
Cortex-M4 がライセンスされた ARM コアであるため、この仕組みは ST 固有ではなく、NXP や Microchip、TI のボードでも同じように動作します。一度理解すれば、異なるボードでそのまま掘り下げられます。
セクションは何もない空間に浮かんでいる
STM32 は二つのメモリ領域を持っています。フラッシュ(非揮発性)は
0x08000000 から始まり、RAM(揮発性)は 0x20000000 から始まります。どちらも 32 ビットアドレスバス上にあります。CPU にとって両方の領域は同等にアクセス可能で、どのアドレスがフラッシュを指し、どのアドレスが RAM を指すかはチップのワイヤーアップによって決まります。
C コンパイラーはこのすべてを知らないのです。
main.c を受け取り、マシーナコードを生成し、.text というセクションに入れるだけです。定数は .rodata に入ります、初期化された変数は .data、初期化されていない変数は .bss に入ります。これらはすべて単なる名前です。コンパイラーは .text が後にフラッシュに入ること、.bss が RAM に入ることを全く知りません。さらに言えば、フラッシュや RAM が存在することすら認識していません。これらのセクションには絶対アドレスが割り当てられていません。それらはただ何もない空間に浮かんでいるだけなのです。
したがって、誰かがどのセクションをどの物理アドレスに配置すべきかを決める必要があります。それがリンカースクリプトの仕事です。
リンカースクリプトは
.ld 拡張子を持つテキストファイルであり、二つのことを記述します。どのようなメモリ領域が存在するか、そしてどのセクションがどこに配置されるかです。
ENTRY(Reset_Handler)
この行はリンカーにエントリポイントの場所を伝えます。
MEMORY ブロックで物理的な領域がリストされます。数字はチップのデータシートの情報からそのまま引用されています:
MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 128K }
SECTIONS ブロックでは各セクションが領域に割り当てられます。.isr_vector はフラッシュの最初から開始されます。なぜなら、それはハードウェアが最初に読む 4 バイトの場所だからです。KEEP(*(.isr_vector)) は C コード内で明示的に参照されない vector_table シンボルを持つベクターテーブルをリンカが捨ててしまうのを防ぎます。
.text と .rodata はフラッシュに配置され、非揮発性になるように設計されています。.bss は RAM に配置され、ランタイム時にゼロで埋められます。
私が特に気に入っているのは
.data です。これらは初期値を持つ変数です(例:int baud_rate = 9600;)。ランタイムでは RAM に存在し、そうでないと書き換えできない必要があります。しかしボードが電力供給を受ける前に、その初期値はどこかに保存しておく必要があります。つまり初期値はフラッシュにあり、起動時に RAM へコピーされるのです。
リンカースクリプトは
.data に二つのアドレスを与えてこれを解決します。RAM 内の仮想アドレス(VMA)で、コードが変数の位置を期待するもの。そしてフラッシュ内のロードアドレス(LMA)で、初期値が物理的に存在する場所。実際のコピーを行うのはリンカースクリプトではありません。境界マーカーとしてシンボルだけを出力します:_sidata(フラッシュ内の初期値の開始)、_sdata と _edata(RAM 内の開始と終了)、そして .bss セクション用の _sbss と _ebss です。
これらのシンボルは通常の感覚での変数ではありません。メモリを占有するわけではありません。リンカーが最後にスタンプする数字に過ぎません。C ではそれらをアクセスするには
extern で宣言し、その後そのアドレスを取得します:
extern uint32_t _sidata; extern uint32_t _sdata; extern uint32_t _edata;
これは私を一瞬混乱させました。変数のように見えますが実際には変数ではないからです。
_sdata の値を読んだことがなく、常に &_sdata を使用します。「変数」は自身のアドレスなのです。
スタートアップコードはミニ OS
リンカースクリプトは境界を決定します。中身を埋めるのはスタートアップコードの仕事です。伝統的にはアセンブリの
startup.s でしたが、現在は startup.c または同じファイル内でインラインで行うことも多いです。私は点滅例で最大限の可読性を確保するため、インラインとして保持しました。
スタートアップコードとはリセット処理関数であり、三つのことを行います。初期化された変数の初期値を持つために
.data をフラッシュから RAM へコピーします。C 標準に従って初期化されていない変数をゼロで埋め込むために .bss を埋めます。そして main() を呼び出します。C++ の場合、四番目のステップとしてグローバルコンストラクタを呼び出す必要があります(これには __init_array セクションが含まれます)。AUTOSAR プロジェクトではこの特定の作業はサプライヤーのスタートアップコード内で実行され、EcuM_Init がレジスタを見る前に実行されます。
点滅例では以下のように書きます:
void Reset_Handler(void) { uint32_t* src = &_sidata; uint32_t* dst = &_sdata; while (dst < &_edata) { *dst++ = *src++; } dst = &_sbss; while (dst < &_ebss) { *dst++ = 0; } main(); while (true) { } }
これを初めて書いた際、二つの特徴が目に留まりました。
第一に、スタートアップコードはリンカーシンボルを直接ループの境界として使用している点です。これが二つのファイル間の契約です。リンカースクリプトはシンボルが存在し、正しいアドレスを指すと約束します。スタートアップコードは無条件に信頼します。もしリンカースクリプト内でシンボル名を変更すれば、スタートアップコードは静かに壊れてしまいます。警告は出ません。余談になりますが、コピーループは
uint32_t* で実行され、uint8_t* ではありません。これによりバストランザクションあたりワード単位(4 バイト)で動作し、より高速になります。リンカーがセクションを 4 バイトにアラインしているため、機能します。アンアラインした 32 ビットアクセスは構成によっては HardFault を引き起こす可能性があります。
第二に、
main() の後の while (true) {} です。PC では main() がオペレーティングシステムに戻りますが、ここでは OS はありません。もし main() が誤ってリターンすると、プロセッサは行く所がないことになります。この無限ループは、メモリ中を暴走するのを防ぐための保険です。
スタートアップコードが C 環境を設定される前に走る唯一の理由は、スタックローカル変数のみを使用しているからです。また、スタックは既に機能しており、これはリセット時にベクターテーブルからスタックポインタをハードウェアがロードしたためです。この一連の動作はドミノチェーンです。各ステップは直前のステップで生成された一つの条件だけが必要です。
ソースからフラッシュ内のバイトまで
ソースコードから点滅する LED までに至るプロセスも単一のステップではなく、複数のステップからなります。プリプロセッサがインクルードとマクロを解決し、コンパイラーが各
.c ファイルを個別にアセンブリに変換し(-mcpu=cortex-m4 -mthumb でアーキテクチャ固有)、アセンブラが相対アドレスと未解決シンボルを持つ ELF 形式のオブジェクトファイルを作成します。リンカはすべてのオブジェクトファイルから同じ名前のセクションを集め、リンカースクリプトを通じて絶対アドレスを割り当て、シンボルを解決します。「delay にジャンプ」という指示が、delay の具体的なフラッシュアドレスを持つことになります。
最終的な製品は ELF ファイルです。マシーナコードだけでなく、プログラムヘッダー(メモリ内のバイトの場所)、シンボルテーブル(関数と変数の名前にそのアドレス、デバッガー用)、オプションでデバッグ情報(マシーナコードからソース行へのマッピング)も含まれます。
objcopy -O binary を使って生成する .bin ファイルは、メタデータのない生のバイトのみです。
これをフラッシュするには、probe-rs や OpenOCD などのツールを使用します。このツールは ST-Link、J-Link、または CMSIS-DAP のデバッガーアダプタを介して Cortex-M4 の SWD ポート(Serial Wire Debug)と通信します。デバッグポートは CPU が動作しているかどうかに関係なく、チップの全アドレス空間に直接アクセスできます。ツールは ELF ファイルを読み取り、プログラムヘッダーから抽出し、バイトをフラッシュに書き込み、リセットをトリガーします。その後、一連のサイクルが再開されます。ハードウェアがベクターテーブルを読み、リセット処理関数へジャンプし、スタートアップで初期化され、
main() が実行され、LED が点滅します。
再々度強調しますが、これは私の学習プロジェクトであり、プロダクションコードではありません。プロダクション環境では抽象層を上に置いておく必要があります。ベンダーの HAL(例:ST のもの)や、ベンダーに依存しない Cortex マイコンソフトウェアインターフェース規格 (CMSIS) です。
私が取った帰結
二つのことが頭に残りました。
第一に、「Hello World」は真剣に取り組めば時間の無駄ではありませんでした。HAL を使用して点滅例を 5 行で書くこともできましたが、代わりに私はベクターテーブル、リセット処理関数、リンカースクリプト、境界マーカーを理解しました。フレームワークチュートリアルの数週間よりも多くシステムについて学ぶことができました。
第二に、「シンプル」とはほとんど常に単なる錯覚です。HAL を使用した点滅例とレジスタを使用した点滅例の間には、何かの実行性が変わっているわけではありません。ただ、実際に起きていることからはより遠い位置にあるだけです。make ファイルから点滅する LED までの間にリンカースクリプト、スタートアップコード、ELF、フラッシュ作業、ハードウェアリセットが存在しています。これらはすべて見えないまま存在しています。
私たちが自動車分野にいる者にとっては特に興味深いところです。古典的な AUTOSAR プロジェクトではベクターテーブル、リセット処理関数、リンカースクリプトを見ることがありません。MCAL、サプライヤーのスタートアップコード、OS 初期化、BswM スケジューリングはすべてブラックボックスとして届き、RTE から呼び出されるランナブルを書きます。電源投入から
Rte_MainFunction_* までの間には、ここでは説明した完全なチェーンが存在します。ハードウェアが 4 バイトを読み、リセット処理関数へジャンプし、誰かが .data と .bss を初期化し、誰かが OS 初期化を呼び出し、誰かがタスクを開始します。ただ、その各ステップは AUTOSAR 設定の内部に埋め込まれているだけで、通常はその設定を開かないためです。この基礎自体を自分で構築した後は、MCAL ドキュメントを読む方法も変わります。また言語の問題も鮮明になります。以前の記事で私は C、C++、Rust それぞれが独自のレイヤーを持つと主張しましたが、AUTOSAR で私たちが実際に作業するレイヤーは、その選択が本当に重要なレイヤーから数ステップ上にあるのです。リセット処理関数、.data のコピー、リンカースクリプトなどはすべて C であり、それを上に書いているものとは関係なくです。サプライヤーはその下の言語を選択し、私たちはそうではありません。
全てが機能していれば、それを見ることをしないことは快適です。しかしそれが機能しない瞬間には、より深いレベルに行かなければなりません。そして、前にそこを訪れた経験があると良いものです。