ARMアセンブリを使ってnucleo(STM32F103)でLチカをします。
rogy Advent Calendar 2017の11日目の記事です。

はじめに

一年前に書いたARMアセンブリでLチカの続きです。 前回は、ARMアセンブリを使ってLチカをするためにこんなことを紹介しました。

  • ARM Cortex-M3のレジスタ
  • STM32F1でGPIOをいじってLチカをするコード
  • gccを使ってアセンブリからマイコンで動作するバイナリの生成方法

今回はこれの続きの話になります。やることはLチカなのですが、SysTickと例外(割り込み)を使ってみようと思います。

ここでの内容は ARM Cortex‐M3システム開発ガイド という書籍や、 ARM Information Center: Cortex-M3 の情報を元にしています。

Cortex-M3の例外の仕組み

まず例外という言葉についてですが、一般的に言う割り込みのことです。 明確な使い分けの定義を発見することはできませんでしたが、なんとなくの意味の違いの推測は後で紹介します。 とりあえずここでは例外=割り込みと考えても問題ありません。

ベクタテーブル

ARM Cortex-M3にはベクタテーブルというものがあり、 例外の要因とその例外ハンドラのある場所(メモリ上のアドレス)の対応を表しています。 ベクタテーブルの要素は例外の個数だけあり、大抵の場合Flashの先頭に配置します。 Cortex-M3のベクタテーブルの内容は以下のようになっています。

アドレスオフセット 例外の種類
0x00 MSPの初期値
0x04 Reset
0x08 NMI
0x0C HardFault
0x10 MemoryManageFault
0x14 BusFault
0x18 UsageFault
…. ….
0x3C SysTick
0x40 IRQ #0
0x44 IRQ #1
…. ….
(最大)0x3FF IRQ #239

先頭だけはMSP(スタックポインタ)の初期値を指定します。 リセット時にはベクタテーブルの先頭(大抵Flashの先頭)にある値がMSPにロードされます。

ベクタテーブルの各要素は4byteのサイズがあり、そこに例外ハンドラのアドレスをセットしていきます。 Thumb命令セットでは命令長が16bitなので、例外ハンドラのあるアドレスは2byte alignされていて、LSBは0になります。 しかしCortex-M3ではLSBを必ず1にしてベクタテーブルを作る必要があります。

ARMのルールで、PC(保存先であるLRも)にアドレスをセットする際に、 その値のLSBを使ってThumbモードで動作させるかARMモードで動作させるかを設定するというものがあります。 Cortex-M3はThumbのみをサポートしているので、PCに値を書き込むときはLSBを1にセットする必要があります。 なのでベクタテーブルのLSBも1にセットします。

アドレスオフセットというのは、ベクタテーブルの配置された場所からのオフセット値です。 例えばベクタテーブルをSTM32F103のFLashの先頭(0x08000000)に配置する場合、 ベクタテーブルの先頭のMSPの初期値は0x08000000、Resetハンドラは0x08000004に配置されます。 ベクタテーブルの先頭の場所はSCB_VTORというレジスタで設定をします。

SCB_VTORを実行時に書き換えることで、ベクタテーブルを移動することもできます。 身近な例として、bootloaderを実行しているときのベクタテーブルの位置と、 bootloaderから実行されたアプリケーションのベクタテーブルを別のものになっていたりします。 そうすることで例えば同じUSBペリフェラルからの割り込みでも、 bootloaderを実行している時とアプリケーションを実行している時でジャンプ先を変えたりすることができます。 bootloaderからアプリケーションに処理を移す際にSCB_VTORを書き換えるだけで実現できます。

IRQ#Nは外部割込みのためのもので、ここでの外部はARMのコアの外部を意味しています。 このIRQ#N具体的にはSTM32などのマイコンのペリフェラルの割り込みのことです。 UARTなどのペリフェラルの回路からARMコアのIRQピンに割り込みを伝える信号線が配線されているのでしょう。

例外と割り込みという名前について

ARMのマニュアルにおいては、外部割込みIRQ#Nからの割り込みを割り込み(interrupt)、それ以外の割り込みを例外(exception)と呼んでいるような気がします。 ただ、正確な定義を発見できなかってのでよくわからないです。

SM32 + CubeMXの例

話がそれるのですが、このベクタテーブルはC言語で開発を行っている場合もどこかで設定されていて、誰でも見つけられるものなので紹介したいと思います。 例えばCube MXからgccでコンパイルされるコードを生成すると、以下のようなものがstartup_stm32fxxx.sに書かれています。 これがベクタテーブルそのものです。

 	.section	.isr_vector,"a",%progbits
	.type	g_pfnVectors, %object
	.size	g_pfnVectors, .-g_pfnVectors

g_pfnVectors:
	.word	_estack
	.word	Reset_Handler
	.word	NMI_Handler
	.word	HardFault_Handler

Reset_HandlerやNMI_Handlerは例外ハンドラであるC言語の関数名(関数名はポインタ)です。 上のように書くことで例外ハンドラを並べた領域をg_pfnVectorsというシンボルが指すようになります。 また、これらが.isr_vectorセクションに配置されます。 関数自体は2byte境界に配置されているのですが、コンパイル時にはそのアドレスのLSBを1にセットしたものが 格納されます。

_estackの値はリンカスクリプトでRAMの終わり(スタックの始まり)を指すように設定されています。

_estack = 0x20003000;    /* end of RAM */

ベクタテーブルを意味するg_pfnVectorsをFlashの先頭に確実に配置するために、 リンカスクリプトにはこのように書かれています。

SECTIONS
{
  /* The startup code goes first into FLASH */
  .isr_vector :
  {
    . = ALIGN(4);
    KEEP(*(.isr_vector)) /* Startup code */
    . = ALIGN(4);
  } >FLASH

  /* The program code and other data goes into FLASH */
  .text :
  {

  // 以下省略

isr_vectorセクションが一番初めにFLASHに配置されているので、FLASHの先頭に配置されます。

例外が発生したときに何が起こるのか

ざっくり言うとレジスタの内容がスタック上に退避され、PCが例外ハンドラに飛びます。 以下ではレジスタの退避、PCとLRに分けて説明していきます。

レジスタの退避

例外が発生すると、R0~R3, R12, LR, PC, PSRの内容がスタック上に退避されます。 この時の退避先のスタックは、例外発生時にPSPが選択されていればPSRの指す先、 MSPが選択されていればMSRの指す先が選択されます。 そして、例外ハンドラ内ではMSPが使用されます(自動で切り替わる)。

スタックには以下のような構造でレジスタの内容が退避されます。 Cortex-M3ではスタックはアドレスが減少する向きに伸びて行くことに注意してください。

アドレス データ
古いSPの指す先(N) —–
N-4 PSR
N-8 PC
N-12 LR
N-16 R12
N-20 R3
N-24 R2
N-28 R1
新しいSPの指す先(N-32) R0

スタックフレームを8byte境界に配置設定がある場合は、古いSPの指す先の次の4byteの空白が挿入されることもあります。

例外ハンドラへのジャンプ、復帰

上記のレジスタが退避された後、PCにはベクタテーブルで設定された例外ハンドラのアドレスがセットされ、 処理が例外ハンドラへの飛びます。

ここでポイントなのは、例外の要因をユーザーがプログラムを書いて判別し、適切なハンドラへジャンプするのではなく、 自動的に例外の要因に対応したハンドラへジャンプすることです。 例えばPIC16シリーズのマイコンでは割込みが発生すると、割り込みの要因によらず全て4番地へジャンプします。 なのでジャンプ後にユーザーがプログラムを書いて割込み要因を判定する必要がありました。 Cortex-M3ではそれが自動化されているので、割込みのレイテンシを小さくすることが可能となっています。

通常の関数呼び出しの場合はLRに戻り番地(PCの値 + ThumbかARMかのbit)をセットしてジャンプするのですが、 例外の場合はLRには戻り番地がセットされません。 戻り番地はいきなりスタック上に保存され、LRには例外時だけのEXC_RETURNという特別な値がセットされます。

EXC_RETURNの値は以下の意味を持ちます。

ビット 31:4 3 2 1 0
意味 0xFFFFFFF固定 戻り先のモード 復帰に使うスタック 0固定 Thumb or ARM

戻り先のモードは、戻る先がハンドラモードかスレッドモードのどちらであるかを示しています。 例外ハンドラ内で例外が発生した場合のみハンドラモードへ戻ることになり、それ以外はスレッドモードへ戻ることになります。 復帰に使うスタックは、復帰にメインスタックを使うかプロセススタックを使うかを示しています。 LSBは戻り先ではThumb命令モードかARM命令モードどちらで動作するかを表しています。 Cortex-M3はThumb命令しか使えないので、このbitは1で固定です。

例外ハンドラから復帰するときにはbx命令などが使えるのですが、 bx lrとかを実行したときにLRの値が表のような値になっている場合は PCを含めたレジスタの値がスタック上から復元され、 例外ハンドラに来る前の状態に戻ることができます。

例外ハンドラ実行中のスタックトレース

また話がそれます。 gdb+Eclipseな環境でデバッグをしている際にブレークすると、 スタックトレースにはこのように表示されると思います。

このように例外ハンドラ以外でブレークした時のスタックトレースを見ると、 普通に関数名とそのアドレス(Flash上だと0x0800xxxxとか)が表示されます。

一方で例外ハンドラ内でブレークした場合には以下のように、 0xFFFFFE9という関数があるはずのないアドレスが表示されています。

これは前述のとおり、例外ハンドラ内でのLRのEXC_RETURNの値だったということです。 この画像はSTM32F4(Cortex-M4F)のものなのでEXC_RETURNが先ほどの説明にはなかった0xE9となっていますが、下4bitの9の値の意味はCortex-M3と同じです。 今はOSがない状態で使っていて、何も設定してないのでスタックの操作にはMSPが使用され、 例外はスレッドモード(例外ハンドラでない部分)で実行中に発生しているので、EXC_RETURNの下4bitが0x9になっています。

別の例として、同じくSTM32F4で動くFreeRTOSのタスクを実行中に発生した例外の例外ハンドラでブレークし、スタックトレースを見てみました。 するとEXC_RETURNの値は0xFFFFFEDになっていました。

FreeRTOSの各タスクの実行において、スタックの操作にはPSPが使われ、 例外はスレッドモードで実行中に発生したのでEXC_RETURNの下4bitは0xDになっています。

ずっと疑問に思ってたスタックトレース時にでる謎の数字の意味を知ることができました。 あと、FreeRTOSがちゃんとMSPとPSPを使い分けていることも分かりました。

例外のための設定

基本的には優先度と例外を許可するかしないかの設定をします。 ちゃんと使うと色々設定が必要なのですが、今回はSysTickを使ってLチカがしたいだけなので手抜きをします。 デフォルト値でSysTickの例外は優先度0で有効なので、何も設定しないことにしました。 解説も省きます。

Lチカをするコード

以上を踏まえて、SysTickから定期的に発せられる例外を利用してLチカをしてみたいと思います。 使うマイコンボードは前回同様nucleo(STM32F103)です。

ソースは以下の通りです。 1秒ごとにLEDの点灯、消灯を繰り返します(2秒周期)。

実行ファイルは次のコマンドを実行して生成します。(詳細は前回の記事を)

arm-none-eabi-as -mcpu=cortex-m3 -mthumb -o led_blink_systick.o led_blink_systick.s
arm-none-eabi-ld -Ttext 0x08000000 -o led_blink_systick.elf led_blink_systick.o

# hexファイルを作りたいとき
arm-none-eabi-objcopy -O binary led_blink_systick.elf led_blink_systick.hex
# binファイルを作りたいとき
arm-none-eabi-objcopy -O binary led_blink_systick.elf led_blink_systick.bin

生成されたバイナリをマイコンに書き込み、実行するとちゃんと2秒周期でLチカしました。

解説

基本的なことで、前回と同じ部分の説明は省きます。

クロックは何も設定していないので、CPUもバス(AHB APB1 APB2)もすべて8MHzで動作します。

ベクタテーブルの設定

_start:
    .org 0x00000000
    .word STACK_TOP
    .word init
    .type init, function
    
    .org 0x0000003C
    .word systick_handler
    .type systick_handler, function

_startからの部分ではベクタテーブルを設定しています。 .wordを使って4byteのデータを順番に配置しています。 最低限スタックポインタの初期値とresetのハンドラだけは設定しないと動かすことができません。

今回はそれに加えてSysTickの例外を使うので、オフセット0x3Cにsystick_handlerのアドレスを指定しています。 systick_handlerという文字列自体はシンボルなので、どこかのアドレスに置き換わります。 その際に

    .type init, function
    .type systick_handler, function

があるおかげで「これは関数を意味するシンボルだから、ThumbモードのためにLSBを1にセットする」 という処理がコンパイル時に行われます。 これによってベクタテーブルにセットしたハンドラのLSBを1にセットするという作業をコンパイラが自動でやってくれます。

initの先頭ではベクタテーブルが0x08000000から始まるということを設定しています。 CubeMXから生成したコードでは、SYstemInitという関数に同じような処理が書いてあります。

SysTickの設定

1sでタイムアウトして例外を発生させるように、SYSTICK_LOADレジスタを設定して、動作を開始させています。 いつもC言語でやっている設定と同じです。

Systickを動作開始したら無限ループに入って例外が発生するのをひたすら待ちます。

例外ハンドラ

例外ハンドであるsystick_handlerには、LEDの繋がったPA5のビットを反転させ、 bx lrで復帰する処理が書いてあります。 レジスタの退避復元の操作は何も書かなくても、例外が発生したら自動でスタック上に退避され、 復帰時にスタック上から復元されます。

まとめ

主にCortex-M3の例外の仕組みの概要を知ることができました。 例外優先度の話や例外中に例外が発生した場合の振る舞いについては全く振れませんでしたが、 いろんなことが起こります。

この記事に書いたようなことを知ったところで何かできるわけではありませんが、 普段Cで書いていてもどこかで定義されているベクタテーブルや、 例外ハンドラでのスタックトレースといった身近なところへの理解を深めることができました。

今回紹介した例外周りの動作や、普段意識しないコアの機能(MSP/PSPや、特権モード/ユーザモード)は OSを動かすときにガンガン使われるっぽいです。 実際にFreeRTOSのSTM32独自の部分(portの着くファイル)の実装を見ると、 MSPとPSPを切り替える部分や、 RTOSとしてのレイテンシを上げるために例外の細かい設定をしている部分を見つけることができます。 逆に考えるとCortexMシリーズにはOS前提の機能がたくさんあるんだなと思いました。 CortexMシリーズの真の力を発揮するためにも、FreeRTOSをどんどん使っていこうと思います。