Closed10

git cloneして5分で例外・割り込みハンドラをGDBデバッグする

Ryo TakahashiRyo Takahashi

目的

  • 今後、筆者自身が業務でRTOS/ベアメタル on Cortex-Rを扱う機会がありそうだから、関連領域の土地勘を得たい
  • 特に、例外・割り込みのようなOSのコアとなる領域は、これまで担当者に任せきりだったので、コード・レベルで触れてみたい
  • 個人的に、どんなソフトウェアもデバッガでブレークポイントが張れると、心理的に安寧を得られるので、例外・割り込みをステップ実行したい
  • また、何となく動かすだけだと、すぐ忘れるので、自身の記憶の半永続化のために、本スクラップに関連コードの解説を書く
Ryo TakahashiRyo Takahashi

試みたこと

ただ、Linux on Cortex-A評価ボードのような超実践的な環境だと準備(e.g. JTAG+OpenOCDの接続など)が大変になるため、プロセッサエミュレータQEMUでサクッと試みたい。また、私の業務的にはMMUが出てくることはなさそうなので、QEMU-emulated ARM Cortex-M3で、超QuickにFreeRTOSを動かしてみる。具体的には、FreeRTOSのCORTEX_MPU_M3_MPS2_QEMU_GCCというサンプルをGDBデバッグしてみる。


(引用: freertos.org)

Ryo TakahashiRyo Takahashi

CORTEX_MPU_M3_MPS2_QEMU_GCCって何?

まず、背景として、ARM社ではCortex-Mシリーズを試す、Cortex-M Prototyping System (MPS2+)というFPGA基板(FPGA自体は旧Altera社製)を発売している。QEMUでは、このCortex-M3環境をエミュレート可能になっており、FreeRTOSもこの環境で動くサンプルを公開している。それがCORTEX_MPU_M3_MPS2_QEMU_GCCである。このリンク先に書かれている「1. GNU Arm Embedded Toolchain download here」は若干の手間が掛かるが、それさえ手に入れば、FreeRTOSのビルドからサンプル・アプリの実行、GDBデバッグまで、恐らく2~3分で到達できる。この点で『5分で~はタイトル詐欺』って思われる方もいらっしゃるだろうが、恐らく同オーダーのセットアップ時間で、割り込みハンドラのGDBデバッグまで辿り着ける教材は、この上なく希少だと思う。なお、このサンプル自体はあまり栄えることはしておらず、main()到達後、

  1. FreeRTOS経由でメモリ属性をイジって、
  2. タスク上で意図的にメモリ・アクセス例外を起こし、
  3. MemManage Handlerでその例外から復帰し、
  4. 1↔️2の繰り返しをしているだけである。

全く映えないが、だからこそ教材として適していると思う。実行方法も簡単で、このReadme.mdの「How to start debugging」まで実行すれば、下記話は通じるはずである。


(引用: DigiKey)

Ryo TakahashiRyo Takahashi

オススメ書籍

FreeRTOSは、Linuxのような汎用OSと比べて格段にシンプルなRTOSとは言え、「普段、C/C++使っているけど、アプリしか書いていません」って人がコードを読むと、恐らくシンドいことが多い。そこで、CQ出版社の「ARM Cortex-M3システム開発ガイド」という書籍をオススメする。低レイヤは初心者お断りの本が多いが、この本は図が多く、大学でOSやコンピュータ・アーキテクチャの基礎を学んだ人なら多分読める。


(引用: CQ出版社)

ただ、この本自体、私が前職の新卒N年目でCortex-M0/M4F系の業務を始める際に推薦された本であり、やや古い感は否めない(2009年出版)。そのため、新しめの書籍が良いなら「ARMマイコンCortex-M教科書」などでも全然問題無いように見える。また、「英語でも耐えられる」という人や、私のように「O'Reillyのサブスク入っている」という人は、システム開発ガイドの原著者の最新版「Definitive Guide to Arm Cortex-M23 and Cortex-M33 Processors」が無料で読めるので、それがオススメである(こちらは2020年出版)。


(引用: O’Reilly Media, Inc.)

Ryo TakahashiRyo Takahashi

コード読み [1/5]: Reset_Handler()

そもそも、これ何ですか

  • Linuxのアプリで言えば、main関数前に動くcrt0に該当する存在である
  • Cortex-Mでは、システム・リセットが入った時に、下記図のように
    1. まずベクター・テーブルの1 word目からMSP(Main Stack Pointer)から読み込み、
    2. 次に2 word目から例外ハンドラReset_Handler()のアドレスを取得し、
    3. Reset_Handler()の処理を進める
  • 本サンプル・アプリでは、これを実現するために、startup.cで割り込みベクタテーブルを定義し、このリンカー・スクリプトでEFLファイルbuild/RTOSDemo.axfの0番地に配置している


(引用: Definitive Guide to Arm Cortex-M23 and Cortex-M33 Processors FIG. 4.32)

GDBでステップ実行したい

  • 実際にGDBで下記コマンドを打つと(target後にcontinueしてはいけない)、下記図のようにPCはReset_Handler()の先頭を差し、grep _estack build/output.mapと同じ値(正確にはword alignされた値)がSPレジスターに入っていることを確認できるはずである
  • なお、ここまでのハードウェア処理は、QEMUでない実機においては、Flashメモリ上のELFファイルをSystem Bus(AHB)経由で読み込むことで実現されるはずである
(gdb) target remote :1234
(gdb) layout asm
(gdb) info registers

内部で何をしているんですか

  • Reset_Handler()の内部では、リンカー・スクリプトのシンボルに基づき、1) FlashからRAMへの.dataセクションのコピー、2) .bssセクションのゼロ埋めを実施している
  • その後、_start()内でアプリ・コードのmain()を呼び出す
  • この_start()では、その他にもUARTの初期化を行うが、実際的にはクロックやPLLの初期化をすることも多い
__attribute__( ( optimize( "O0" ) ) )
__attribute__( ( naked ) )
void Reset_Handler( void )
{
    /* set stack pointer */
    __asm volatile ( "ldr r0, =_estack" );
    __asm volatile ( "mov sp, r0" );

    /* copy .data section from flash to RAM */
    for( uint32_t * src = &_sidata, * dest = &_sdata; dest < &_edata; )
    {
        *dest++ = *src++;
    }

    /* zero out .bss section */
    for( uint32_t * dest = &_sbss; dest < &_ebss; )
    {
        *dest++ = 0;
    }

    /* jump to board initialisation */
    void _start( void );
    _start();
}
Ryo TakahashiRyo Takahashi

コード読み [2/5]: main()

  • app_main()自体は特段面白いことはしておらず、FreeRTOSの作法に則ってタスクを2つ作って、スケジューリングを開始しているだけである
  • この時、2つのタスクには、.bss領域のバッファがスタックとして割り当てられると共に、ucSharedMemoryという共有メモリへのアクセス権も提供されている
  • 2つのタスクは、この共有メモリへのアクセス権において異なっており、{.pcName= "ROAccess"}は名前の通りRead権限しかなく、一方の{.pcName= "RWAccess"}はWrite権限も持つ
Ryo TakahashiRyo Takahashi

コード読み [3/5]: prvROAccessTask()

この関数は、上記の"ROAccess"側タスクの実体であり、無限ループの中で以下を繰り返している。

  1. ucSharedMemory[ 0 ]をRead
  2. ucSharedMemory[ 0 ]に0をWrite
  3. (2.をきっかけにMemMang_Handler()が呼び出される)
  4. 1秒sleep

本当はもう少しいろいろやっているが、この関数は本家コメントが充実しているため、そちらを読むことをオススメする。

Ryo TakahashiRyo Takahashi

コード読み [4/5]: MemMang_Handler()

どうやってこの関数に飛んでくるか

  • 以下、話が込み入ってくるが、この関数が本スクラップで話したかった例外ハンドラ
  • この関数は、上記の「ucSharedMemory[ 0 ]に0をWrite」をきっかけに
    1. MPU (Memory Protection Unit)がMemory Management Faultを検知し、
    2. 下記図のように、System Exceptionsの1つとしてNVIC (Nested Vectored. Interrupt Controller)に伝達され、
    3. isr_vector[3]としてMemMang_Handler()が呼び出される


(引用: Definitive Guide to Arm Cortex-M23 and Cortex-M33 Processors FIG. 8.1)

GDBでステップ実行したい

  • GDBでは、下記のように操作することで、MemMang_Handler()にブレークポイントを張れる
  • ここで実行を停められたら、以降ni(もしくはsi)によって、インストラクション単位で処理を追えて楽しい
(gdb) target remote :1234
(gdb) b MemMang_Handler
(gdb) layout asm

内部で何をしているんですか (初級編)

  • 少し面を喰らうが、ここの関数では、
    1. まず、lrレジスタの下位2番目のbitをチェックし、
    2. Armv7-M以降導入されたIF-THENで分岐をかけ、
    3. 1がTrueならMSP(Main Stack Pointer)を、
    4. そうでなければPSP(Process Stack Pointer)r0にコピーする
    5. その後、vHandleMemoryFaultという別の関数のアドレスをr1に込めて、
    6. MSP or PSPを引数として関数呼び出ししている
static void MemMang_Handler( void ) __attribute__( ( naked ) );
void MemMang_Handler( void )
{
    __asm volatile
    (
        " tst lr, #4                                                         \n"
        " ite eq                                                             \n"
        " mrseq r0, msp                                                      \n"
        " mrsne r0, psp                                                      \n"
        " ldr r1, handler3_address_const                                      \n"
        " bx r1                                                              \n"
        " handler3_address_const: .word vHandleMemoryFault                    \n"
    );
}

内部で何をしているんですか (中級編)

「それは分かるが、関数の戻り番地であるlrの下位2番目bitを見ているの?」と言いたくなる。
ここが本スクラップ記事で一番厄介なところで、本件は下記仕様に起因する。

  • lrはHandler ModeではEXC_RETURNという位置付けに変わる
  • EXC_RETURNには、戻り番地ではなく、下記仕様で例外関連情報が保存され、2番目bitには例外発生時のCONTROLレジスタのSPSELの値がコピーされる
  • この値を見ることで、例外発生前にMSP or PSPどちらを使っていたか判別できる

つまり、MemMang_Handler()は、vHandleMemoryFault()に例外発生時のスタック・ポインターを渡していると言える。


(引用: Definitive Guide to Arm Cortex-M23 and Cortex-M33 Processors FIG. 8.25)

どうやってThread Modeに返るんですか

Handler Mode時にbx EXC_RETURN を行うと、特殊な扱いとなり、ハードウェア側が例外契機で詰んだスタック・フレームからPCを取り出し、Thread Modeに返る。なお、この下記図のスタック・フレームは、AAPCSという仕様で規格化されており、ARM IHI 0042Eという文書で詳細を確認できる。


(引用: Definitive Guide to Arm Cortex-M23 and Cortex-M33 Processors FIG. 8.17)

Ryo TakahashiRyo Takahashi

コード読み [5/5]: vHandleMemoryFault()

  • 本関数は、MemMang_Handler()からFIG. 8.17のスタック・ポインターを引数として受け取る
  • ulPC = pulFaultStackAddress[ 6 ]って何やねん?」と思うかもだが、これは上記の通り、例外発生時のPCレジスタ
  • Thumb-2のため、命令長が16bitか32bitか分からないため、1つif文を噛ませた上で、旧PCをincrementして、返り先を調整している
Ryo TakahashiRyo Takahashi

所感

いかがでしたでしょうか。仕事でSIMD命令を使ったり、趣味でコンパイラを書いたりする人も、なかなか例外・割り込みハンドラまで触ることはあまりないから、ここら辺の理解がモヤッとしている人、少なくないのではないのでしょうか。かく言う私も、仕事で割り込みコンテキスト中のコード書く機会はあったのですが、フレームワーク化されていて、上記のvHandleMemoryFault()相当の関数を恐る恐る書くくらいだったので、いつかステップ実行しながら落ち着いてコード読みたかったんですよね。サクッと読めて良かった良かった😊

このスクラップは2023/12/03にクローズされました