githubにはRaspberry Pi Pico(以下Pico)のブートROMを含めたすべてのソースコードが公開されています。
そこでgithub上のコードを参照しながら、電源が投入されてCPUが動作を開始してからPico SDKを使ってユーザが書いたアプリケーションのmain()が呼び出されるまでの流れを追ってみます。
ブートROM
Picoに搭載されているSoC、RP2040のアドレス 0x00000000 からにはブートROMが置かれています。これはRP2040内に埋め込まれていて書き換えることはできません。
リセット
PicoのCPUであるArm Cortex-M0+は、リセットされるとメモリの 0x00000000 からに置かれているベクタテーブルを読み出すところから動作を開始します。
ベクタテーブルの最初の2ワードには、以下の値を置くことになっています。
- 0x00000000 : スタックポインタ(MSP)の初期値
- 0x00000004 : プログラムカウンタ(PC)の初期値
CPUはこれらの値を読み出し、PCの初期値として与えられた _start
から実行を開始します。_start
には以下のような処理が書かれています。
- 自分自身のCPU番号を調べて、Core 1なら
wait_for_vector
に飛ぶ- RP2040には2つのCortex-M0+が搭載されていますが、これらはリセットされると2つとも同時に動き始めます。
- このままだと都合が悪いので、まず最初に自身がCore 0かCore 1かを調べて、Core 1なら後でCore 0から起動の指示があるまで待ちに入ります。
- Pico SDKのpico_multicore APIなどが使われるまで、Core 1はずっとこの状態です。以降のコード実行はCore 0のみが行います。
- Core0だった場合はC言語で書かれたブート処理である、ブートROM内の
main()
へ飛ぶ
ブートROMのmain()
Picoに搭載されているフラッシュメモリから起動するか、USBマスストレージデバイスとして起動してフラッシュ書き込みモードに移行するかの処理を行います。
- 起動に必要な最低限の周辺回路のリセット
- フラッシュメモリが接続されているかを確認
- Pico基板上のBOOTSELボタンはこの接続を回路上で切って、強制的に「フラッシュメモリが接続されていない状態」にします。これによりUSB起動モードに移行できるようにしています。
- フラッシュメモリが存在したら
_flash_boot()
へ - フラッシュメモリが存在しなかったら
_usb_boot()
へ- USBデバイスとして起動するのに必要な初期化を行って、フラッシュ書き込みモードに移行します。
_flash_boot()
では以下のような処理を行います。
- フラッシュメモリ読み出しのための初期化
- Picoで使用されているのはSPI接続のNOR Flashなので、SPI読み出しのための設定を行います。
- 最初の256バイトをSRAMに読む(boot2)
- SRAMの末尾256バイトに読み込まれます。
- 正しく読めてチェックサムが合っていたらboot2の先頭アドレスへジャンプ
- 読めなければなにもしないで戻る
-
_usb_boot()
によるUSB起動へ移行します。
-
Boot Stage2 (フラッシュメモリ上)
ブートROMによってSRAMに読み込まれた、フラッシュメモリの先頭256バイトに置かれているBoot Stage2という2段階目のブートローダを実行します。
これはPico SDKでアプリをビルドするとバイナリの先頭に必ず置かれるようになっていて、アプリをフラッシュに書き込むことでフラッシュメモリ先頭に書き込まれます。
フラッシュメモリのモード切り替え
- ソースコード
フラッシュメモリのアクセスモードを最も高速にアクセスできるように切り替えて、システムをXIPモードという、CPUのメモリアドレス0x10000000~にフラッシュの内容が見えるようなモード1に切り替えます。
フラッシュメモリのモード切り替えのコードがフラッシュメモリ自身にあるのは、このモード切り替えの方法がフラッシュメモリの品種によって微妙に異なるからです。
特定のフラッシュメモリに特化したコードをRP2040のROMの中に入れてしまうとそのメモリしか繋げなくなってしまうので、そうしたコードをフラッシュメモリ側に置いて、ROMからはどのフラッシュメモリでも共通な(その代わり遅い)アクセスコマンドでBoot Stage2のみをSRAMに読みだして実行することで、異なるフラッシュメモリが繋がれた場合もBoot Stage2の差し替えで対応できるようにしています。
Picoの基板上に搭載されているフラッシュメモリはW25Q16JVというもので、上記ソースコードはこのメモリに対応したものになっています。
Boot Stage2のソースコードのあるフォルダには他にもいくつかのコードが置かれていて、他のフラッシュメモリが使われる場合も考慮しているようです。
SDKへジャンプ
モード切り替えによって、フラッシュメモリの内容がCPUのメモリアドレス 0x10000000~ から見えるようになります。Boot Stage2の直後、0x10000100 を新たなベクタテーブルとして設定し、MSPとPCの値を読んで実行を開始します。
SDK
メモリアドレス 0x10000100~ には、ユーザが作成するアプリとそれを実行できるようにするためのランタイムライブラリが置かれます。
ランタイムライブラリがユーザアプリを起動するために必要な周辺の初期化とクロック設定を行い、最終的にユーザの書いたmain()
を呼び出します。
SDKスタートアップ
Boot Stage2の直後に置かれるベクタテーブルとSDKのスタートアップ処理が書かれています。
スタートアップ処理のある_reset_handler
から以下を実行します。
- CPU番号を調べて、Core 1ならブートROMに戻る
- デバッガなどでROMを通らずにここに飛んできたケースを想定しているものと思われます。
- dataセクションの初期値コピー、bssセクションのクリア
- 以降のC言語で書かれたコードが実行できるようにするための準備を行います。
-
runtime_init()
、main()
、exit()
を呼ぶ- これら、C言語で書かれた処理を実行します。
SDK初期化
SDKスタートアップから最初に呼び出されるruntime_init()
の処理です。
SDKを使って書かれたアプリを実行できるようにするための、以下の初期化を行います。
- 周辺デバイスのリセット
- preinit_array呼び出し
- 特定のセクションに置かれた関数ポインタを通して追加の初期化コードを実行します。
- 現状のSDKではこれを使っているコードはなさそうです。
- クロック初期化
- CPU自身と全周辺デバイスを通常のクロック周波数で動作するように初期化します。
- リセット直後は、CPUはRP2040内のリングオシレータ(ROSC)というクロック源で低速(6.5MHz程度)で動作していますが、ここで外部接続されたクリスタル(XOSC)とSoC内のPLLを初期化することで、通常の動作周波数である125MHzで動作するようになります。
- ベクタテーブルの再初期化
- SDK初期化時に設定されているベクタテーブルはフラッシュメモリ上にあって書き換えができないため、SRAMに内容をコピーしてこちらを使うように再設定します。
- これにより、SDKアプリから割り込みハンドラを登録できるようになります。
- init_array呼び出し
- C++のグローバルコンストラクタは
main()
より先に実行されるようになっています。コンパイル時にこうしたコンストラクタへの関数ポインタがinit_arrayセクションに置かれるようになっているため、ここにポインタがあればその関数を呼び出します。
- C++のグローバルコンストラクタは
main()
- ソースコード
- ユーザ自身が記述するmain()です。
ようやくたどり着きました。ここからがユーザがSDKを使って作成したアプリのmain()
関数の処理になります。
-
フラッシュメモリ自体はSPIインターフェースでSoCに接続されているので、このままではCPUのアドレス空間としては見えません。そこで、SoCの機能で「CPUが特定のアドレス領域をアクセスしたら、対応する領域をSPIフラッシュから読みだしてSRAMキャッシュメモリ上に展開し、それをCPUに見せることで、あたかもフラッシュメモリの内容がそのまま配置されているように見せかける」といった動作を行います。 ↩