割り込み処理 (Interrupt)

外部の信号により動作中のプログラムを止めて,他のプログラムを実行させることを割り込み処理という. この資料では代表例として GPIO 割り込みとタイマー割り込みについて取り扱う.

準備:タクトスイッチの接続

割り込みを理解する上で,スライドスイッチ (実習基板に接続済) よりも, タクトスイッチを使う方が分かりやすいと思われる. そのため,実験の準備として GPIO 12 にタクトスイッチを接続する.

準備:割り込みを使わない場合の確認

割り込みを使わないときの LED の挙動を確認することを目的として, 最初に,LED 2 つとスイッチを使ったプログラムを作成する. LED1 は定期的に点滅させ,LED2 はスイッチの ON/OFF に合わせて点滅させるものとする.

プロジェクトの準備

サンプルプロジェクトをコピーする.

$ cd ~/esp

$ cp -r esp-idf/examples/get-started/sample_project ./test

$ cd test

プログラムの作成

このサンプルプロジェクトのメインファイルは main ディレクトリ以下の main.c であるので, そのファイルを編集する.エディタとして,vi, emacs, gedit, code などが使える.

#include "freertos/FreeRTOS.h"
#include "driver/gpio.h"
#include "esp_log.h"

static gpio_num_t pin1 = 13;
static gpio_num_t pin2 = 14;
static gpio_num_t sw   = 12;

void app_main()
{
  //LED の初期化
  gpio_reset_pin(pin1);                       // リセット
  gpio_set_direction(pin1, GPIO_MODE_OUTPUT); // GPIO 出力
  gpio_reset_pin(pin2);                       // リセット
  gpio_set_direction(pin2, GPIO_MODE_OUTPUT); // GPIO 出力

  //スイッチの初期化
  gpio_reset_pin(sw);                         // リセット
  gpio_set_direction(sw, GPIO_MODE_INPUT);    // GPIO 入力
  gpio_set_pull_mode(sw, GPIO_PULLUP_ONLY);   // 内部プルアップ

  //メインルーチン
  int num = 0;
  while (true) {
     gpio_set_level(pin1, num % 2);
     ESP_LOGI("GPIO Intr", "LoopNum:  %d", num);
     vTaskDelay(5000 / portTICK_PERIOD_MS);  // 5 秒待つ
     num += 1;

     if ( gpio_get_level(sw) == 1){
        gpio_set_level(pin2, 0);
     }else{
        gpio_set_level(pin2, 1);
     }
  }
}

ビルドとマイコンへの書き込み

idf.py build コマンドを実行する.

$ idf.py build

マイコンに書き込むのは idf.py flash コマンド, 標準出力を表示するのは idf.py monitor コマンドである. まとめて idf.py flash monitor としても良い.

$ idf.py flash monitor
monitor を終了するのは Ctrl-] である.

挙動の確認

割り込みを使わない場合は,スイッチを ON/OFF しても即座に LED2 が点灯しない (繰り返しループが 1 周してから ON/OFF の判断がなされるため) ことを確認してほしい. おそらく,LED1 が消灯/点灯する直前にスイッチを押さないと LED2 を点灯させられないだろう.

GPIO 割り込み

回路に接続したタクトスイッチを押すことにより,割り込み要求を発生させ,特定の関数を実行させる. この関数の実行により LED の状態を変えることにする.

プロジェクトの準備

サンプルプロジェクトをコピーする.

$ cd ~/esp

$ cp -r esp-idf/examples/get-started/sample_project ./intr

$ cd intr

プログラムの作成

このサンプルプロジェクトのメインファイルは main ディレクトリ以下の main.c であるので, そのファイルを編集する.エディタとして,vi, emacs, gedit, code などが使える.

このプログラムでは,割り込みが発生したときに実行する関数 button_isr_handler を用意し, その関数とスイッチのピン番号を gpio_isr_handler_add 関数で紐づけている.

また,gpio_set_intr_type 関数で,スイッチに対して割り込み方法を紐づけている. この例では割り込み方法として falling edge を用いているが, 割り込み方法は複数存在する (→実験課題 3 参照).

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"
#include "esp_log.h"

static gpio_num_t pin1 = 13;
static gpio_num_t pin2 = 14;
static gpio_num_t sw   = 12;

// 割り込みハンドラ
static void IRAM_ATTR button_isr_handler(void* arg) {
  static bool ON;
  ON = !ON;
  gpio_set_level(pin2, ON);
}

void app_main()
{
  //LED の初期化
  gpio_reset_pin(pin1);                       // リセット
  gpio_set_direction(pin1, GPIO_MODE_OUTPUT); // GPIO 出力
  gpio_reset_pin(pin2);                       // リセット
  gpio_set_direction(pin2, GPIO_MODE_OUTPUT); // GPIO 出力

  //スイッチの初期化
  gpio_reset_pin(sw);                         // リセット
  gpio_set_direction(sw, GPIO_MODE_INPUT);    // GPIO 入力
  gpio_set_pull_mode(sw, GPIO_PULLUP_ONLY);   // 内部プルアップ

  //割り込みトリガーの設定 (falling edge)
  //  GPIO_INTR_DISABLE = 0,     /*!< Disable GPIO interrupt                             */
  //  GPIO_INTR_POSEDGE = 1,     /*!< GPIO interrupt type : rising edge                  */
  //  GPIO_INTR_NEGEDGE = 2,     /*!< GPIO interrupt type : falling edge                 */
  //  GPIO_INTR_ANYEDGE = 3,     /*!< GPIO interrupt type : both rising and falling edge */
  //  GPIO_INTR_LOW_LEVEL = 4,   /*!< GPIO interrupt type : input low level trigger      */
  //  GPIO_INTR_HIGH_LEVEL = 5,  /*!< GPIO interrupt type : input high level trigger     */

  gpio_set_intr_type(sw,  GPIO_INTR_NEGEDGE);  //第二引数は上記の中から適当なものを選ぶ

  //GPIOの割り込みハンドラサービスをインストールする.引数はとりあえず 0 を入れておけばよい.
  gpio_install_isr_service(0);

  //指定したGPIOに対して割り込みハンドラを追加する
  gpio_isr_handler_add(sw, button_isr_handler, (void *)sw);

  //メインルーチン
  int num = 0;
  while (true) {
     gpio_set_level(pin1, num % 2);
     ESP_LOGI("GPIO Intr", "LoopNum:  %d", num);
     vTaskDelay(5000 / portTICK_PERIOD_MS);  // 5 秒待つ
     num += 1;
  }
}

ビルドとマイコンへの書き込み

idf.py build コマンドを実行する.

$ idf.py build

マイコンに書き込むのは idf.py flash コマンド, 標準出力を表示するのは idf.py monitor コマンドである. まとめて idf.py flash monitor としても良い.

$ idf.py flash monitor
monitor を終了するのは Ctrl-] である.

挙動の確認

この例では,タクトスイッチを押すたびに LED2 が点灯・消灯を繰り返すことが分かるだろう. 割り込みを使わなかった時のように,特定のタイミングでスイッチを押さないと LED2 が点灯しない といったことは生じない.

但し,動作が完璧というわけではなく,スイッチを押したときに点灯しない・消灯しないということも 時々発生しただろう.これは「チャタリング」と呼ばれる入力機器の不具合の 1 つが生じたためである. チャタリングとは,可動接点などが接触状態になる際に,微細な非常に速い機械的振動を起こす現象のことを指す. チャタリングが生じると,人間は 1 度押しただけのつもりでも,機械的には複数の入力がされたことになり, 人間の期待通りの挙動が得られないことになる.

チャタリング対策

プログラム内でチャタリング対策を行う場合はデバウンス処理を追加すると良い.デバウンス処理は,割り込みハンドラ内で行われ,前回の割り込みから一定時間経過しているかを確認する処理である.これにより,チャタリングによる誤動作を防止する.

例えば以下のように割り込みハンドラを修正すればよい.<秒数> にはミリ秒で経過時間の閾値を指定すること.

// デバウンス時間(ミリ秒)
#define DEBOUNCE_TIME_MS <秒数>

// 割り込みハンドラ
static void IRAM_ATTR button_isr_handler(void* arg) {
  static bool ON;
  static uint32_t last_interrupt_time = 0;
  uint32_t interrupt_time = xTaskGetTickCountFromISR();

  // チャタリング防止のためのデバウンス処理.時間経過の判定.
  if (interrupt_time - last_interrupt_time > DEBOUNCE_TIME_MS / portTICK_PERIOD_MS) { 
    ON = !ON;
    gpio_set_level(pin2, ON);
    last_interrupt_time = interrupt_time;
  }
}
プログラムを書き換えて動作確認を行うこと.デバウンス処理の時間 (ミリ秒) をどのくらいの数字にすれば挙動が安定するかを把握しなさい.

参考:チャタリングをオシロスコープで確認する

タイマー割り込み

タイマー割り込みを使って,LED の制御を行う.

この資料では ESP Timer (High Resolution Timer) を使うことにした.他にも,General Purpose Timer を使ってもタイマー割り込みができるようである.

プロジェクトの準備

サンプルプロジェクトをコピーする.

$ cd ~/esp

$ cp -r esp-idf/examples/get-started/sample_project ./intr2

$ cd intr2

プログラムの作成

このサンプルプロジェクトのメインファイルは main ディレクトリ以下の main.c であるので, そのファイルを編集する.エディタとして,vi, emacs, gedit, code などが使える.

#include "freertos/FreeRTOS.h"
#include "esp_timer.h"
#include "driver/gpio.h"
#include "esp_log.h"

static gpio_num_t pin1 = 13;
static gpio_num_t pin2 = 14;

void timer_callback(void *param)
{
  static bool ON;
  ON = !ON;
  gpio_set_level(pin2, ON);
}

void app_main(void)
{
  //LED の初期化
  gpio_reset_pin(pin1);                       // リセット
  gpio_set_direction(pin1, GPIO_MODE_OUTPUT); // GPIO 出力
  gpio_reset_pin(pin2);                       // リセット
  gpio_set_direction(pin2, GPIO_MODE_OUTPUT); // GPIO 出力

  // タイマー設定
  const esp_timer_create_args_t my_timer_args = {
    .callback = &timer_callback,                    //割り込みハンドラ
    .name = "My Timer"};
  esp_timer_handle_t timer_handler;
  esp_timer_create(&my_timer_args, &timer_handler);

  // 割り込みを周期的にかける.第二引数で時間を指定 (マイクロ秒単位).
  esp_timer_start_periodic(timer_handler, 2000000);

  int num = 0;
  while (true){
     gpio_set_level(pin1, num % 2);
     ESP_LOGI("TimerIntr", "LoopNum:  %d", num);
     vTaskDelay(pdMS_TO_TICKS(1000));  // 1 秒待つ
     num += 1;
  }
}                      

ビルドとマイコンへの書き込み

idf.py build コマンドを実行する.

$ idf.py build

マイコンに書き込むのは idf.py flash コマンド, 標準出力を表示するのは idf.py monitor コマンドである. まとめて idf.py flash monitor としても良い.

$ idf.py flash monitor
monitor を終了するのは Ctrl-] である.

挙動の確認

繰り返しループ内で点灯させる LED1 とタイマー割り込みで点灯させる LED2 では 点灯・消灯のタイミングが同じであることが把握できるであろう. while 文の中の処理が軽いようなプログラムだと,人間の目にはほとんど同じタイミングで点灯しているように見える.

また,esp_timer_start_periodic 関数は引数で指定された周期で割り込みを発生させていることがわかるだろう. 必要に応じて引数で与える周期を変更し,割り込み周期が変化する様子を確認してみよ.

例題

「ポーリング(ループ)だと他の処理に引きずられてしまうが,タイマー割込みなら他の処理に影響されず正確な時間を作ることが出来る」 ことを理解することを目的とした実験を行う.

ストップウォッチ的なものを作りたいと考え,以下のようなサンプルプログラムを作成した. このサンプルプログラムについて設問 [1]~[3] を実施しなさい.

#include "freertos/FreeRTOS.h"
#include "driver/gpio.h"
#include "esp_timer.h"
#include "esp_log.h"

static gpio_num_t pin[8] = {13, 12, 14, 27, 26, 25, 33, 32};

int num1 = 0;
int num2 = 0;

static void IRAM_ATTR timer_callback(void *param){
  num2 += 1;
  ESP_LOGI("Time", "interrupt: %d", num2);    
}

void app_main(void)
{
  //LED の初期化
  for (int i = 0; i < 8; i++){
     gpio_reset_pin(pin[i]);                       // リセット
     gpio_set_direction(pin[i], GPIO_MODE_OUTPUT); // GPIO 出力
  }

  // タイマー設定
  const esp_timer_create_args_t my_timer_args = {
     .callback = &timer_callback,                    //割り込みハンドラ
     .name = "My Timer"};
  esp_timer_handle_t timer_handler;
  esp_timer_create(&my_timer_args, &timer_handler);

  // 割り込みを周期的にかける.第二引数で時間を指定 (マイクロ秒単位).
  esp_timer_start_periodic(timer_handler, 1000000); // 1 秒間隔

  while (true){
     ESP_LOGI("Time", "pooling: %d", num1);

     vTaskDelay(pdMS_TO_TICKS(1000));  // 1 秒待つ
     num1 += 1;
  }
}

設問[1]

上記プログラムを実行し,出力される pooling と interrupt のカウント数を調べなさい. pooling 8~10 回分を含む出力をファイルとして保存しておくこと.

I (8385) Time: pooling: 8
I (8385) Time: interrupt: 8
I (9385) Time: pooling: 9
I (9385) Time: interrupt: 9
I (10385) Time: pooling: 10
I (10385) Time: interrupt: 10

設問[2]

このプログラムに対して,現在のカウント数を LED 表示する機能を追加したい. カウント数を 2 進数に変換し,それを LED で表示するコードは, 例えばビット演算を使って以下のように書ける.

for (int i = 0; i < 8; i++){
   int bit = (num2 >> i) & 1;    // num1 の i ビット目を取得
   gpio_set_level(pin[i], bit);
}

但し,これだけではちょっと負荷が足りないので,例えば以下のように 点滅させるといった装飾を加えるなど,各自で工夫してほしい.

for (int i = 0; i < 8; i++){
   int bit = (num2 >> i) & 1;    // num1 の i ビット目を取得
   gpio_set_level(pin[i], bit);
   vTaskDelay(pdMS_TO_TICKS(50));  
   gpio_set_level(pin[i], (bit+1) % 2);
   vTaskDelay(pdMS_TO_TICKS(50));  
   gpio_set_level(pin[i], bit);
}

このような LED を使うコードを,「while ループ」内に配置しなさい. そしてプログラムを実行し,出力される pooling と interrupt のカウント数を調べなさい. pooling 8~10 回分を含む出力をファイルとして保存しておくこと.

設問 [3]

設問 [2] で示したコードを「割り込み」内に配置しなさい. そしてプログラムを実行し,出力される pooling と interrupt のカウント数を調べなさい. pooling 8~10 回分を含む出力をファイルとして保存しておくこと.

実行結果から実行時間を把握する方法

このような実験では,得られた結果と「正解」からのずれ (誤差) を示しながら結果について吟味することが常套手段である. 今回の実験では出力値に,ESP32 マイコン自体のアプリケーションが開始されてからの経過時間がミリ秒単位で表示されている.これを「正解」として使うことができる.

I (9385) Time: pooling: 9
I (9385) Time: interrupt: 9          ←ポーリング(繰り返し) : 1 カウント上がるとき,
I (10385) Time: pooling: 10            ESP32 マイコンのカウント値の変化は 10385 - 9385 = 1000 [ミリ秒] 
I (10385) Time: interrupt: 10          正確に 1 秒刻みになっている  
     ↑
   このカッコ内の数字


I (7585) Time: pooling: 4            ←ポーリング(繰り返し) : 1 カウント上がるとき,
I (8385) Time: interrupt: 8            ESP32 マイコンのカウント値の変化は 9385 - 7585 = 1800 [ミリ秒] 
I (9385) Time: pooling: 5              ループ内の処理に引きずられて 1 秒刻みになっていない.= ストップウォッチにならない.
I (9385) Time: interrupt: 9
I (10385) Time: interrupt: 10

実践課題

GPIO 割り込み機能を用いて,スイッチが ON の時には「ドレミファソラシド」や音楽を繰り返し流す,スイッチが OFF の時は音量がゼロになるようにしなさい.

但し,メイン関数内の while 文の中でデューティー比を変える操作はしないこと. 例えば while 文は以下のようにすること.

while (1) {
   //ド
   freq = 262;
   ledc_set_freq(LEDC_HIGH_SPEED_MODE, timer, freq);
   vTaskDelay(1000 / portTICK_PERIOD_MS);

   // レ
   freq = 294;
   ledc_set_freq(LEDC_HIGH_SPEED_MODE, timer, freq);
   vTaskDelay(1000 / portTICK_PERIOD_MS);

   // ミ
   freq = 330;
   ledc_set_freq(LEDC_HIGH_SPEED_MODE, timer, freq);
   vTaskDelay(1000 / portTICK_PERIOD_MS);
}