Inside of LOVOT

GROOVE X 技術ブログ

Cortex-M4マイコンにおけるBootloader & Application構成時の遷移について

この記事は、GROOVE X Advent Calendar 2024の4日目の記事です。

はじめに

こんにちは。 ファームウェアチームに所属しているf-sakashitaと申します。

LOVOTのファームウェア開発については過去記事をご覧頂ければと思います。

tech.groove-x.com

今回はニッチかつ重要な機能でもある、ファームウェア(以後FW)のアップデートに纏わるお話です。

FWのROM構成

LOVOTには数多くのマイコンが搭載されており、ARM Cortex-Mシリーズを採用しています。
これら各マイコンのFWのバイナリはLOVOTのOSの一部に組み込まれ、ネットワーク経由でアップデートできるようになっています。
LOVOTのOSについてはこちらをご参照ください。

そのために、各マイコンのROMは以下のように主にBootloader (以後Boot)およびApplication (以後App)領域から構成されています。

開始アドレス 構成 内容
0x08000000 Boot 電源投入時に動き出すソフトウェア。
主にApp領域をアップデートする
0x08○○○○○○
(任意の位置)
App メインとなるソフトウェア。
Boot領域から遷移後に動作し始める

App領域を更新する場合は以下のようなシーケンスで行われます。

  1. アップデータとなるコンピュータ(Linuxマシン or 別マイコン)からFW更新指令が飛ぶ
    1. この時、更新対象のマイコンはApp領域で動作している前提
  2. 更新対象のマイコンは処理をApp領域からBoot領域に遷移する
  3. Boot内の処理にてアップデータ側から伝わるバイナリをApp領域に書き込む
  4. 更新完了後、処理をApp領域に遷移する

今回はCortex-M4シリーズマイコンにて、ROM構成を分割して双方に処理を遷移させる方法についてご紹介します。

参考

ARM: How to Write a Bootloader
これにしたがって実装すれば間違いないです。

実装したコード

void jump_to_another_fw(uint32_t another_fw_addr) {
  uint32_t jump_addr = *(__IO uint32_t*)(another_fw_addr + 4);
  
  __disable_irq();            // 他方のFWに飛んで初期化が終わるまで全割り込みを禁止
  
  /* NVICの有効にしている割り込み全てを無効にする */
  NVIC->ICER[0] = 0xFFFFFFFF;
  NVIC->ICER[1] = 0xFFFFFFFF;
  NVIC->ICER[2] = 0xFFFFFFFF;
  NVIC->ICER[3] = 0xFFFFFFFF;
  NVIC->ICER[4] = 0xFFFFFFFF;
  NVIC->ICER[5] = 0xFFFFFFFF;
  NVIC->ICER[6] = 0xFFFFFFFF;
  NVIC->ICER[7] = 0xFFFFFFFF;
  NVIC->ICPR[0] = 0xFFFFFFFF;
  /* NVICの全ての保留中の割り込み要求を無効にする */
  NVIC->ICPR[1] = 0xFFFFFFFF;
  NVIC->ICPR[2] = 0xFFFFFFFF;
  NVIC->ICPR[3] = 0xFFFFFFFF;
  NVIC->ICPR[4] = 0xFFFFFFFF;
  NVIC->ICPR[5] = 0xFFFFFFFF;
  NVIC->ICPR[6] = 0xFFFFFFFF;
  NVIC->ICPR[7] = 0xFFFFFFFF;
  
  /* Jump後すぐにSysTick割り込みが入らない用にするために、SysTickを無効化 */
  SysTick->CTRL = 0;
  SCB->ICSR |= SCB_ICSR_PENDSTCLR_Msk;
  SCB->SHCSR &= ~( SCB_SHCSR_USGFAULTENA_Msk | SCB_SHCSR_BUSFAULTENA_Msk | SCB_SHCSR_MEMFAULTENA_Msk ) ;
  
  /* 他方のFW用にVectorTableの配置変更 */
  SCB->VTOR = another_fw_addr;

  /* スタックポインタとして PSP と MSP のどちらが使われているかを確認し PSP だった場合は if文の中に入る */
  if (CONTROL_SPSEL_Msk & __get_CONTROL()) {
    __set_MSP(__get_PSP());                                  // PSPの値をMSPにコピー
    __set_CONTROL(__get_CONTROL() & ~CONTROL_SPSEL_Msk) ;    // スタックポインタとして MSP を使うように設定変更
  } 

  /* 最適化オプションによってはスタックを使うようなアセンブラコードに変換される可能性があるため、インラインアセンブラで実装 */
  asm volatile ("MSR msp, %0\n"      // スタックポインタを他方のFW用に初期化
                "bx %1\n"            // 他方のFWにジャンプ
                 : : "r" (*(__IO uint32_t*)another_fw_addr),  "r"(jump_addr)); 
}

コードの説明

基本的な遷移方法

Cortex-Mシリーズでは、FWバイナリの先頭にベクターテーブルが配置されます(参考

先頭アドレスから4バイトがメインスタックポインタの初期値として配置、その後ろの4バイトがリセットハンドラとして配置されています。
これらがBoot / Appそれぞれのバイナリに存在します。

つまり、他方へFWを遷移させるにはメインスタックポインタを他方の初期値に変更し、そのリセットハンドラを呼び出せば良いということになります。

上記遷移関数の引数のanother_fw_addrには遷移先のFWのスタックポインタの初期値のアドレスが入ります。 jump_addrには遷移先のリセットハンドラのアドレスが入ります。

void jump_to_another_fw(uint32_t another_fw_addr) {
  uint32_t jump_addr = *(__IO uint32_t*)(another_fw_addr + 4);

全割り込み禁止

他方のFWのベクタテーブルを再配置したり、スタックポインタを初期化したりするため、割り込みを禁止にしておくのが安全です。
実施しないとHard Faultに陥る可能性があります。

  __disable_irq();            // 他方のFWに飛んで初期化が終わるまで全割り込みを禁止

  /* NVICの有効にしている割り込み全てを無効にする */
  NVIC->ICER[0] = 0xFFFFFFFF;
  NVIC->ICER[1] = 0xFFFFFFFF;
  NVIC->ICER[2] = 0xFFFFFFFF;
  NVIC->ICER[3] = 0xFFFFFFFF;
  NVIC->ICER[4] = 0xFFFFFFFF;
  NVIC->ICER[5] = 0xFFFFFFFF;
  NVIC->ICER[6] = 0xFFFFFFFF;
  NVIC->ICER[7] = 0xFFFFFFFF;
  NVIC->ICPR[0] = 0xFFFFFFFF;
  /* NVICの全ての保留中の割り込み要求を無効にする */
  NVIC->ICPR[1] = 0xFFFFFFFF;
  NVIC->ICPR[2] = 0xFFFFFFFF;
  NVIC->ICPR[3] = 0xFFFFFFFF;
  NVIC->ICPR[4] = 0xFFFFFFFF;
  NVIC->ICPR[5] = 0xFFFFFFFF;
  NVIC->ICPR[6] = 0xFFFFFFFF;
  NVIC->ICPR[7] = 0xFFFFFFFF;

SysTickの無効化

他方のFWの実装内容によっては不要な処理かもしれませんが、遷移前のSysTickは無効にしておくとより安心です。
というのも、開発中にこれが原因でHard Faultに陥る問題に遭遇したためです。それはApp側でFreeRTOSを使用しているケースでした。

Cortex-MシリーズにてFreeRTOSを導入した場合、スケジューラにSysTickが使われます。
もし遷移前にSysTickを無効にしておかないと、Appに遷移した後に割り込みが許可され、その後直ぐにSysTick割り込みが入る可能性があります。すると、RTOSが未初期化状態でスケジューリング処理が走ってしまい、結果Hard Faultが発生、というわけです。

そこで下記のようにSysTickを無効化してます。

/* Jump後すぐにSysTick割り込みが入らない用にするために、SysTickを無効化 */
  SysTick->CTRL = 0;
  SCB->ICSR |= SCB_ICSR_PENDSTCLR_Msk;
  SCB->SHCSR &= ~( SCB_SHCSR_USGFAULTENA_Msk | SCB_SHCSR_BUSFAULTENA_Msk | SCB_SHCSR_MEMFAULTENA_Msk ) ;

ベクターテーブルの再配置

他方のFWに遷移した後は、そのFWにおける各ベクターハンドラが割り込み時に呼ばれる必要があります。 下記により、ベクターテーブルの再配置を実現しています。

  /* 他方のFW用にVectorTableの配置変更 */
  SCB->VTOR = another_fw_addr;

メインスタックポインタを使用するように変更

Cortex-Mシリーズにはスタックポインタが2種類存在し、MSP (メインスタックポインタ)とPSP (プロセススタックポインタ)があります。
基本的にはMSPが使われますが、例えばFreeRTOSを使用している場合はカーネル実行時はMSPが使われ、各タスク実行時はPSPが使われます。
そのため、例えばあるタスクの処理にてFWを他方に遷移するとなると、遷移後にPSPを使わないようにするためにMSPに戻しておく必要があります。
これを実現しているのが下記処理です。

  /* スタックポインタとして PSP と MSP のどちらが使われているかを確認し PSP だった場合は if文の中に入る */
  if (CONTROL_SPSEL_Msk & __get_CONTROL()) {
    __set_MSP(__get_PSP());                                  // PSPの値をMSPにコピー
    __set_CONTROL(__get_CONTROL() & ~CONTROL_SPSEL_Msk) ;    // スタックポインタとして MSP を使うように設定変更
  } 

スタックポインタの初期化後に遷移するまで絶対にスタックを使用しないようにする

ここが一番重要なポイントです。
最初の説明でもあった通り、FWを他方に遷移する際、事前にスタックポインタを他方のアドレスに初期化しておく必要があります。
このとき、もし初期化後にスタックを使用してしまうと、スタックオーバーフローとなり、Hard Faultに陥ります。

例えば下記のような実装でも問題ない場合はありますが、最適化オプションによって生成されるアセンブラコードがスタックを使用する可能性があります。実際これにハマりました。

    __set_MSP(*(__IO uint32_t*)another_fw_addr);
    jump_addr();

そのため、下記のようにインラインアセンブラを使って実装しておくと、最適化の影響を受けなくなるため安全です。

  /* 最適化オプションによってはスタックを使うようなアセンブラコードに変換される可能性があるため、インラインアセンブラで実装 */
  asm volatile ("MSR msp, %0\n"      // スタックポインタを他方のFW用に初期化
                "bx %1\n"            // 他方のFWにジャンプ
                 : : "r" (*(__IO uint32_t*)another_fw_addr),  "r"(jump_addr)); 

最後に

ローンチ後もFWをアップデートできる仕組みを事前に作っておけば、不具合のあるFWを遠隔で修正したり、新規機能を追加することができます。
しかしアップデート機能そのものに不具合があると大変なお祭り状態と化す恐れがありますので、ローンチ前にしっかりとテストしてバグを潰しておくのが重要です。

GROOVE Xではファームウェアエンジニア以外にも、様々な領域のエンジニアを募集中です。
詳細は下記募集ページをご確認ください!

recruit.jobcan.jp