この過度に長い答えに対する一種のプロローグとして ...
この質問は、割り込みレイテンシの問題に深く夢中になり、羊ではなくカウントサイクルで睡眠を失うところまで行きました。私はこの回答を、質問に答えるだけでなく、私の発見を共有するために書いています。この資料のほとんどは、実際には適切な回答に適したレベルではない可能性があります。しかし、レイテンシの問題の解決策を探してここに着く読者にとって、それが役立つことを願っています。最初のいくつかのセクションは、元のポスターを含め、幅広い聴衆に役立つことが期待されています。その後、途中で毛むくじゃらになります。
クレイトンミルズは、彼の回答で、割り込みへの応答に多少の遅延があることをすでに説明しています。ここでは、レイテンシ(Arduinoライブラリを使用する場合は非常に大きい)の定量化と、それを最小限に抑える方法に焦点を当てます。以下のほとんどは、Arduino Unoおよび同様のボードのハードウェアに固有のものです。
Arduinoでの割り込みレイテンシの最小化
(または99から5サイクルに取得する方法)
元の質問を実際の例として使用し、割り込みレイテンシの観点から問題を再説明します。割り込みをトリガーするいくつかの外部イベントがあります(ここでは、ピンの変更時にINT0)。割り込みがトリガーされたときに何らかのアクションを実行する必要があります(ここでは、デジタル入力を読み取ります)。問題は、トリガーされる割り込みと適切なアクションの実行の間に遅延があることです。この遅延を「割り込みレイテンシ」と呼びます。長い待ち時間は多くの状況で有害です。この特定の例では、入力信号が遅延中に変化する可能性があり、その場合、誤った読み取りが行われます。遅延を回避するために私たちができることは何もありません。これは、割り込みの動作方法に固有のものです。ただし、できる限り短くすることができます。これにより、悪影響を最小限に抑えることができます。
私たちができる最初の明白なことは、割り込みハンドラー内で、できるだけ早くタイムクリティカルなアクションをとることです。これはdigitalRead()
、ハンドラーの最初で1回(そして1回だけ)呼び出すことを意味し
ます。以下は、ビルドするプログラムの0番目のバージョンです。
#define INT_NUMBER 0
#define PIN_NUMBER 2 // interrupt 0 is on pin 2
#define MAX_COUNT 200
volatile uint8_t count_edges; // count of signal edges
volatile uint8_t count_high; // count of high levels
/* Interrupt handler. */
void read_pin()
{
int pin_state = digitalRead(PIN_NUMBER); // do this first!
if (count_edges >= MAX_COUNT) return; // we are done
count_edges++;
if (pin_state == HIGH) count_high++;
}
void setup()
{
Serial.begin(9600);
attachInterrupt(INT_NUMBER, read_pin, CHANGE);
}
void loop()
{
/* Wait for the interrupt handler to count MAX_COUNT edges. */
while (count_edges < MAX_COUNT) { /* wait */ }
/* Report result. */
Serial.print("Counted ");
Serial.print(count_high);
Serial.print(" HIGH levels for ");
Serial.print(count_edges);
Serial.println(" edges");
/* Count again. */
count_high = 0;
count_edges = 0; // do this last to avoid race condition
}
このプログラムとその後のバージョンをテストするために、さまざまな幅のパルス列を送信しました。パルスの間に十分な間隔があり、エッジが失われないようにします。前の割り込みが完了する前に立ち下がりエッジを受信した場合でも、2番目の割り込み要求は保留され、最終的に処理されます。パルスが割り込みレイテンシより短い場合、プログラムは両方のエッジで0を読み取ります。報告されたHIGHレベルの数は、正しく読み取られたパルスの割合です。
割り込みがトリガーされるとどうなりますか?
上記のコードを改善する前に、割り込みがトリガーされた直後に展開されるイベントを確認します。ストーリーのハードウェア部分はAtmelのドキュメントで説明されています。バイナリを分解することによるソフトウェア部分。
ほとんどの場合、着信割り込みはすぐに処理されます。ただし、MCU(「マイクロコントローラー」を意味する)が、割り込みサービスが無効になっている、時間的に重要なタスクの途中にある場合があります。これは通常、すでに別の割り込みを処理している場合です。これが発生すると、着信割り込み要求は保留され、そのタイムクリティカルセクションが完了したときにのみ処理されます。Arduinoコアライブラリ(これらを「libcore」と呼びます)にはこれらの重要なセクションがかなりあるため、この状況を完全に回避することは困難です。幸いなことに、これらのセクションは短く、頻繁にしか実行されません。したがって、ほとんどの場合、割り込み要求はすぐに処理されます。以下では、これらのいくつかは気にしないと仮定しますこれがそうでない場合の例。
その後、リクエストはすぐに処理されます。これはまだかなり時間がかかることができる多くのものを含みます。最初に、ハードワイヤードシーケンスがあります。MCUは現在の命令の実行を終了します。幸いなことに、ほとんどの命令はシングルサイクルですが、最大4サイクルかかるものもあります。次に、MCUは内部フラグをクリアし、それ以上の割り込み処理を無効にします。これは、ネストされた割り込みを防ぐことを目的としています。次に、PCがスタックに保存されます。スタックは、この種の一時ストレージ用に予約されたRAMの領域です。PC(「プログラムカウンター」の意味)")は、MCUが実行しようとしている次の命令のアドレスを保持する内部レジスタです。これは、MCUが次に何をすべきかを認識できるようにするものであり、メインのために復元する必要があるため、保存することが不可欠です。中断されたところから再開するためのプログラムPCには、受信した要求に固有のハードワイヤードアドレスがロードされ、これがハードワイヤードシーケンスの終わりであり、残りはソフトウェアで制御されます。
MCUは、ハードワイヤードアドレスからの命令を実行します。この命令は「割り込みベクタ」と呼ばれ、通常は「ジャンプ」命令で、ISR(「割り込みサービスルーチン」)と呼ばれる特別なルーチンに移動します。この場合、ISRは「__vector_1」、別名「INT0_vect」と呼ばれます。これは、ISRであり、ベクターではないため、これは誤称です。この特定のISRはlibcoreに由来します。他のISRと同様に、内部CPUレジスタの束をスタックに保存するプロローグから始まります。これにより、これらのレジスタを使用できるようになり、メインプログラムに影響を与えないように、レジスタが以前の値に復元されます。次に、登録された割り込みハンドラを探しますattachInterrupt()
、それはread_pin()
上記の関数であるハンドラを呼び出します。その後、関数はdigitalRead()
libcoreから呼び出します。digitalRead()
Arduinoのポート番号を、読み取る必要のあるハードウェアI / Oポートとテストする関連ビット番号にマップするために、いくつかのテーブルを調べます。また、無効にする必要があるPWMチャネルがそのピンにあるかどうかもチェックします。次に、I / Oポートを読み取ります。これで完了です。さて、割り込みの処理は実際には完了していませんが、タイムクリティカルなタスク(I / Oポートの読み取り)は完了しており、レイテンシを調べる際に重要なのはそれだけです。
CPUサイクルの関連する遅延とともに、上記すべての短い要約を以下に示します。
- ハードワイヤードシーケンス:現在の命令の終了、割り込みのネストの防止、PCの保存、ベクトルのアドレスのロード(≥4サイクル)
- 割り込みベクトルを実行:ISRにジャンプ(3サイクル)
- ISRプロローグ:レジスターの保存(32サイクル)
- ISR本体:ユーザー登録関数の検索と呼び出し(13サイクル)
- read_pin:digitalReadを呼び出す(5サイクル)
- digitalRead:テストする関連ポートとビットを見つける(41サイクル)
- digitalRead:I / Oポートを読み取る(1サイクル)
ハードワイヤードシーケンスに4サイクルのベストケースシナリオを想定します。これにより、99サイクルの合計レイテンシ、つまり16 MHzクロックで約6.2 µsが得られます。以下では、この待機時間を短縮するために使用できるいくつかのトリックを探ります。大まかに複雑さの順になっていますが、MCUの内部を詳細に調べる必要があります。
ダイレクトポートアクセスを使用する
レイテンシを短縮するための明白な最初の目標はdigitalRead()
です。この関数はMCUハードウェアに優れた抽象化を提供しますが、時間重視の作業には非効率的です。これを取り除くことは実際にdigitalReadFast()
は取るに足らないこと
です。それをdigitalwritefastライブラリのに置き換えるだけです。これにより、わずかなダウンロードを犠牲にして、レイテンシがほぼ半分になります。
まあ、それはあまりにも簡単で面白くなかったので、難しい方法でそれを行う方法を紹介します。目的は、低レベルのものから始めることです。この方法は「直接ポートアクセス」と呼ばれ、ポートレジスタのページにあるArduinoリファレンスに詳しく記載されています。この時点で、ATmega328Pデータシートをダウンロードして確認することをお勧めします。この650ページのドキュメントは、一見すると少し気が遠くなるかもしれません。ただし、MCUの各ペリフェラルと機能に固有のセクションに整理されています。そして、私たちがしていることに関連するセクションだけをチェックする必要があります。この場合、これはI / Oポートという名前のセクション
です。以下は、これらの測定値から学んだことの要約です。
- Arduinoピン2は、実際にはAVRチップではPD2(ポートD、ビット2)と呼ばれています。
- 「PIND」と呼ばれる特別なMCUレジスタを読み取ることにより、ポートD全体を一度に取得します。
- 次に、ビットごとの論理ANDおよび(C '&'演算子)を使用してビット番号2をチェックし
1 << 2
ます。
だから、ここに私たちの変更された割り込みハンドラがあります:
#define PIN_REG PIND // interrupt 0 is on AVR pin PD2
#define PIN_BIT 2
/* Interrupt handler. */
void read_pin()
{
uint8_t sampled_pin = PIN_REG; // do this first!
if (count_edges >= MAX_COUNT) return; // we are done
count_edges++;
if (sampled_pin & (1 << PIN_BIT)) count_high++;
}
これで、ハンドラーは呼び出されるとすぐにI / Oレジスターを読み取ります。待ち時間は53 CPUサイクルです。この簡単なトリックにより、46サイクル節約できました。
独自のISRを作成する
サイクルトリミングの次のターゲットはINT0_vect ISRです。このISRは次の機能を提供するために必要ですattachInterrupt()
。プログラムの実行中にいつでも割り込みハンドラーを変更できます。ただし、これは便利ですが、私たちの目的にはあまり役立ちません。したがって、libcoreのISRで割り込みハンドラーを見つけて呼び出す代わりに、ISRをハンドラーで置き換えることにより、数サイクルを節約します。
これは思ったほど難しくありません。ISRは通常の関数のように書くことができます。それらの特定の名前を認識し、ISR()
avr-libcの特別なマクロを使用してそれらを定義する必要があります。この時点で、割り込みに関するavr-libcのドキュメントと、外部割り込みという名前のデータシートセクションを確認することをお勧めします。ここに短い要約があります:
- ピン値の変更時にトリガーされるように割り込みを構成するには、EICRA(外部割り込み制御レジスタA)と呼ばれる特別なハードウェアレジスタにビットを書き込む必要があります。これはで行われ
setup()
ます。
- INT0割り込みを有効にするには、EIMSK(外部割り込みMaSKレジスタ)と呼ばれる別のハードウェアレジスタにビットを書き込む必要があります。これもで行われ
setup()
ます。
- 構文でISRを定義する必要があります
ISR(INT0_vect) { ... }
。
ISRとのコードsetup()
は次のとおりです。それ以外はすべて変更されていません。
/* Interrupt service routine for INT0. */
ISR(INT0_vect)
{
uint8_t sampled_pin = PIN_REG; // do this first!
if (count_edges >= MAX_COUNT) return; // we are done
count_edges++;
if (sampled_pin & (1 << PIN_BIT)) count_high++;
}
void setup()
{
Serial.begin(9600);
EICRA = 1 << ISC00; // sense any change on the INT0 pin
EIMSK = 1 << INT0; // enable INT0 interrupt
}
これには無料のボーナスが付属します。このISRは、置き換えるISRの方がシンプルであるため、必要なレジスターが少なくて済み、レジスターを節約するプロローグが短くなります。これで、レイテンシは20サイクルになりました。100近くに達したことを考えると、悪くはありません。
この時点で、私たちは完了したと言います。任務完了。以下は、いくつかのAVRアセンブリで手を汚すことを恐れない人のためのものです。それ以外の場合は、ここで読むのをやめて、ここまで進んでくれてありがとう。
裸のISRを書く
まだここ?良い!先に進むには、アセンブリがどのように機能するかについて少なくともいくつかの非常に基本的な考えがあり
、avr-libcのドキュメントからインラインアセンブラークックブックを参照すると役立ちます。この時点で、割り込みエントリシーケンスは次のようになります。
- ハードワイヤードシーケンス(4サイクル)
- 割り込みベクトル:ISRにジャンプ(3サイクル)
- ISRプロローグ:regを保存(12サイクル)
- ISR本体の最初のもの:IOポートを読み取る(1サイクル)
もっと上手にしたいのなら、ポートの読みをプロローグに移さなければなりません。アイデアは次のとおりです。PINDレジスタを読み取ると、1つのCPUレジスタが上書きされます。そのため、それを行う前に少なくとも1つのレジスタを保存する必要がありますが、他のレジスタは待機することができます。次に、最初のレジスタを保存した直後にI / Oポートを読み取るカスタムプロローグを記述する必要があります。IVRをネイキッドにすることができることは、avr-libcの割り込みドキュメント(すでに読んでいますよね?)
このアプローチの問題は、おそらくISR全体をアセンブリで記述することになるでしょう。たいしたことではありませんが、退屈なプロローグとエピローグをコンパイラーに作成してもらいます。それで、ここに汚いトリックがあります:ISRを2つの部分に分割します:
- 最初の部分は、
- 1つのレジスタをスタックに保存する
- PINDをそのレジスターに読み込む
- その値をグローバル変数に格納する
- スタックからレジスタを復元する
- 後編にジャンプ
- 2番目の部分は、コンパイラーが生成したプロローグとエピローグを含む通常のCコードです。
以前のINT0 ISRは、次のように置き換えられます。
volatile uint8_t sampled_pin; // this is now a global variable
/* Interrupt service routine for INT0. */
ISR(INT0_vect, ISR_NAKED)
{
asm volatile(
" push r0 \n" // save register r0
" in r0, %[pin] \n" // read PIND into r0
" sts sampled_pin, r0 \n" // store r0 in a global
" pop r0 \n" // restore previous r0
" rjmp INT0_vect_part_2 \n" // go to part 2
:: [pin] "I" (_SFR_IO_ADDR(PIND)));
}
ISR(INT0_vect_part_2)
{
if (count_edges >= MAX_COUNT) return; // we are done
count_edges++;
if (sampled_pin & (1 << PIN_BIT)) count_high++;
}
ここでは、ISR()マクロを使用してINT0_vect_part_2
、必要なプロローグとエピローグを持つコンパイラー計測器を用意してい
ます。コンパイラーは「 'INT0_vect_part_2'はスペルミスのあるシグナルハンドラーのように見える」と文句を言いますが、警告は無視しても問題ありません。これで、ISRは実際のポート読み取りの前に単一の2サイクル命令を持ち、合計遅延はわずか10サイクルです。
GPIOR0レジスタを使用する
この特定の仕事のためにレジスターを予約できるとしたらどうでしょうか?その後、ポートを読み取る前に何も保存する必要はありません。実際に、グローバル変数をレジスターにバインドするようコンパイラーに要求できます。ただし、これには、レジスターが常に予約されていることを確認するために、Arduinoコアとlibc全体を再コンパイルする必要があります。あまり便利ではありません。一方、ATmega328Pはたまたまコンパイラーもライブラリーも使用しない3つのレジスターを持っていて、私たちが好きなものを保存するのに利用できます。それらは、GPIOR0、GPIOR1、およびGPIOR2(汎用I / Oレジスタ)と呼ばれます。それらはMCUのI / Oアドレス空間にマッピングされていますが、実際にはI / Oレジスタ:それらは、バスで何らかの理由で失われ、誤ったアドレス空間で終了した3バイトのRAMのような単なるプレーンメモリです。これらは内部CPUレジスタほどの能力はなく、in
命令でPINDをこれらのいずれかにコピーすることはできません。GPIOR0は興味深いですが、PINDと同様にビットアドレス指定が可能です。これにより、内部CPUレジスタを壊さずに情報を転送できます。
ここにトリックがあります:GPIOR0が最初はゼロであることを確認し(実際にはブート時にハードウェアによってクリアされます)、次に
sbic
(I / Oレジスタの一部のビットがクリアされている場合は次の命令をスキップします)およびsbi
(次のように、一部のI / Oレジスタの一部のビット)命令に設定します。
sbic PIND, 2 ; skip the following if bit 2 of PIND is clear
sbi GPIOR0, 0 ; set to 1 bit 0 of GPIOR0
このようにして、PINDから読み取ろうとしたビットに応じて、GPIOR0は最終的に0または1になります。sbic命令は、条件が偽か真かに応じて、実行に1または2サイクルかかります。明らかに、PINDビットは最初のサイクルでアクセスされます。この新しいバージョンのコードでは、グローバル変数sampled_pin
は基本的にGPIOR0に置き換えられているため、グローバル変数はもう役に立ちません。
/* Interrupt service routine for INT0. */
ISR(INT0_vect, ISR_NAKED)
{
asm volatile(
" sbic %[pin], %[bit] \n"
" sbi %[gpio], 0 \n"
" rjmp INT0_vect_part_2 \n"
:: [pin] "I" (_SFR_IO_ADDR(PIND)),
[bit] "I" (PIN_BIT),
[gpio] "I" (_SFR_IO_ADDR(GPIOR0)));
}
ISR(INT0_vect_part_2)
{
if (count_edges < MAX_COUNT) {
count_edges++;
if (GPIOR0) count_high++;
}
GPIOR0 = 0;
}
GPIOR0は常にISRでリセットする必要があることに注意してください。
PIND I / Oレジスタのサンプリングは、ISR内部で実行される最初の作業です。合計待ち時間は8サイクルです。これはひどく罪深いクラッジに染まる前に私たちができる最善のことです。これもまた、読書をやめる良い機会です...
タイムクリティカルなコードをベクターテーブルに入れる
まだここにいる人のために、ここに私たちの現在の状況があります:
- ハードワイヤードシーケンス(4サイクル)
- 割り込みベクトル:ISRにジャンプ(3サイクル)
- ISR本体:IOポートを読み取る(第1サイクル)
明らかに改善の余地はほとんどありません。この時点でレイテンシを短縮できる唯一の方法は、割り込みベクトル自体をコードで置き換えることです。クリーンなソフトウェア設計を重視する人にとっては、これは非常に不愉快なことです。しかし、それは可能であり、その方法を説明します。
ATmega328Pベクトルテーブルのレイアウトは、データシートのセクション「割り込み」、サブセクション「ATmega328およびATmega328Pの割り込みベクトル」にあります。または、このチップのプログラムを分解することによって。これはどのように見えるかです。Atvrとは異なるavr-gccおよびavr-libcの規則(__initはベクター0、アドレスはバイト単位)を使用しています。
address │ instruction │ comment
────────┼─────────────────┼──────────────────────
0x0000 │ jmp __init │ reset vector
0x0004 │ jmp __vector_1 │ a.k.a. INT0_vect
0x0008 │ jmp __vector_2 │ a.k.a. INT1_vect
0x000c │ jmp __vector_3 │ a.k.a. PCINT0_vect
...
0x0064 │ jmp __vector_25 │ a.k.a. SPM_READY_vect
各ベクトルには、1つのjmp
命令で満たされた4バイトのスロットがあります。これは、16ビットであるほとんどのAVR命令とは異なり、32ビット命令です。しかし、32ビットスロットは小さすぎてISRの最初の部分を保持できません。sbic
とのsbi
命令は適合できますが、は適合できませんrjmp
。そうすると、ベクターテーブルは次のようになります。
address │ instruction │ comment
────────┼─────────────────┼──────────────────────
0x0000 │ jmp __init │ reset vector
0x0004 │ sbic PIND, 2 │ the first part...
0x0006 │ sbi GPIOR0, 0 │ ...of our ISR
0x0008 │ jmp __vector_2 │ a.k.a. INT1_vect
0x000c │ jmp __vector_3 │ a.k.a. PCINT0_vect
...
0x0064 │ jmp __vector_25 │ a.k.a. SPM_READY_vect
INT0が発生すると、PINDが読み取られ、関連するビットがGPIOR0にコピーされ、実行は次のベクトルに移ります。次に、INT0のISRの代わりに、INT1のISRが呼び出されます。これは気味が悪いですが、とにかくINT1を使用していないので、INT0を処理するためにそのベクトルを単に「ハイジャック」します。
これで、デフォルトのテーブルを上書きするために、独自のカスタムベクトルテーブルを作成する必要があります。それはそれほど簡単ではないことがわかりました。デフォルトのベクターテーブルは、avr-libcディストリビューションによって提供されるcrtm328p.oと呼ばれるオブジェクトファイルで提供されます。ライブラリコードとは異なり、オブジェクトファイルコードはオーバーライドされることを意図していません。これを行おうとすると、テーブルが2回定義されているというリンカーエラーが発生します。つまり、crtm328p.o全体をカスタムバージョンに置き換える必要があります。1つのオプションは、完全なavr-libcソースコードをダウンロードし、gcrt1.Sでカスタム変更を
行ってから、これをカスタムlibcとしてビルドすることです。
ここで私は、より軽量で代替的なアプローチに取り組みました。私はavr-libcのオリジナルの簡易バージョンであるカスタムcrt.Sを書きました。「キャッチオール」ISRを定義したり、を呼び出してプログラムを終了したり(つまり、Arduinoをフリーズしたり)できるなど、めったに使用されない機能がいくつかありませんexit()
。これがコードです。スクロールを最小限に抑えるために、ベクターテーブルの繰り返し部分をトリミングしました。
#include <avr/io.h>
.weak __heap_end
.set __heap_end, 0
.macro vector name
.weak \name
.set \name, __vectors
jmp \name
.endm
.section .vectors
__vectors:
jmp __init
sbic _SFR_IO_ADDR(PIND), 2 ; these 2 lines...
sbi _SFR_IO_ADDR(GPIOR0), 0 ; ...replace vector_1
vector __vector_2
vector __vector_3
[...and so forth until...]
vector __vector_25
.section .init2
__init:
clr r1
out _SFR_IO_ADDR(SREG), r1
ldi r28, lo8(RAMEND)
ldi r29, hi8(RAMEND)
out _SFR_IO_ADDR(SPL), r28
out _SFR_IO_ADDR(SPH), r29
.section .init9
jmp main
次のコマンドラインでコンパイルできます:
avr-gcc -c -mmcu=atmega328p silly-crt.S
このスケッチは、INT0_vectがなく、INT0_vect_part_2がINT1_vectに置き換えられていることを除いて、前のスケッチと同じです。
/* Interrupt service routine for INT1 hijacked to service INT0. */
ISR(INT1_vect)
{
if (count_edges < MAX_COUNT) {
count_edges++;
if (GPIOR0) count_high++;
}
GPIOR0 = 0;
}
スケッチをコンパイルするには、カスタムコンパイルコマンドが必要です。ここまで進んでいる場合は、コマンドラインからコンパイルする方法を知っているでしょう。プログラムにリンクするようにsilly-crt.oを明示的に要求-nostartfiles
し、元のcrtm328p.oでリンクを回避するオプションを追加する必要があります。
現在、I / Oポートの読み取りは、割り込みがトリガーされた後に実行される最初の命令です。私は別のArduinoから短いパルスを送信してこのバージョンをテストしましたが、5サイクルという短いレベルのパルスを(確実ではありませんが)キャッチできます。このハードウェアの割り込みレイテンシを短縮するためにこれ以上できることはありません。