外部イベントの検知~割り込み処理 (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_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 の状態の反転が防げず,ON = !ON が意図せず2回以上実行されることがある.挙動がおかしいと感じた場合は,基板上の EN ボタンを押してマイコンを再起動して下さい.

レポート課題 3: GPIO 割り込み

実施方法

[1] 目的:

  • GPIO 割り込みを発生させる条件について理解する.

[2] 使用する機器:

  • マイコンボード
  • LED (マイコンボード上の LED を利用)
  • タクトスイッチ

[3] 機器の接続方法

  • タクトスイッチをケーブルで接続すること

[4] プログラムの作成

  • 先のチャタリング対策を行ったプログラムを基本とし,以下の行を修正対象とする.

    //割り込みトリガーの設定 (falling edge)
    gpio_set_intr_type(sw,  GPIO_INTR_NEGEDGE);
  • タクトスイッチを使う場合において,以下の 3 種の割り込み定数を 与えた場合の挙動を確認するためのプログラムを作成すること.

    // 今回の実験で利用する割り込みのトリガー 3 種.
    //  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_DISABLE = 0,     /*!< Disable GPIO interrupt                             */
    //  GPIO_INTR_LOW_LEVEL = 4,   /*!< GPIO interrupt type : input low level trigger      */
    //  GPIO_INTR_HIGH_LEVEL = 5,  /*!< GPIO interrupt type : input high level trigger     */

レポートのまとめ方

以下の内容を記述すること.

  • 実験の目的
  • 実験方法
    • 利用する機器の一覧
    • 機器の接続方法
      • ボタンスイッチをマイコンボードのどのピンに接続したか明記すること
  • 実験設定
    • 実験に利用した割り込み方法 (トリガー) を一覧表にして示すこと.一覧表には表番号を付すこと.
      • 書き方の例:「作成したプログラムは実験手順書に書かれているサンプルプログラムを元にした.サンプルプログラム中の割り込み方法として使った変数を表 X に示す.」
  • 実験結果
    • 作成したプログラムを実行したときの LED2 の挙動をまとめること.
    • 以下のような表をタクトスイッチについて作成すること.

スイッチのタイプ割り込み方法LED2 の挙動
タクトGPIO_INTR_NEGEDGEスイッチを押すたびに LED の点灯・消灯が繰り返される
タクトGPIO_INTR_POSEDGEスイッチを押すたびに LED の点灯・消灯が繰り返される
.........

  • 議論
    • GPIO_INTR_POSEDGE, GPIO_INTR_NEGEDGE, GPIO_INTR_ANYEDGE の違いを説明し,それぞれの割り込みがどのようなタイミングで発生するかを実験結果に基づいて考察せよ.
    • GPIO_INTR_POSEDGE, GPIO_INTR_NEGEDGE, GPIO_INTR_ANYEDGE のそれぞれについて,利用シーンを議論せよ.
      • 例: タクトスイッチを使って,スイッチを押すたびに LED が点灯・消灯を繰り返すようにするには,... を使うと良い.

タイマー割り込み

タイマー割り込みを使って,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 関数は引数で指定された周期で割り込みを発生させていることがわかるだろう. 必要に応じて引数で与える周期を変更し,割り込み周期が変化する様子を確認してみよ.

レポート課題 4 : ポーリングとタイマー割り込み

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

ストップウォッチ的なものを作りたいと考え,以下のようなサンプルプログラムを作成した. このサンプルプログラムについて設問 [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 回分を含む出力をファイルとして保存しておくこと.

レポートのまとめ方

以下の内容を記述すること.

  • 実験の目的
  • 実験設定
    • 設問[2],設問[3] で追加したコードを示すこと.
      • プログラム全部を示す必要はありません.
  • 実験結果
    • 設問 [1]~[3] で得た出力 (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

実践課題 2

GPIO 割り込み機能を用いて,「ドレミ」を繰り返し流すが,タクトスイッチを押したときに瞬時に音量がゼロになり,もう一度スイッチを押すと音が再開するようなプログラムを作成しなさい.

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

static gpio_num_t pin = 15;  //ブザー

static gpio_num_t     sw      = 13;  //スイッチ
static ledc_timer_t   timer   = 0;   //タイマー
static ledc_channel_t channel = 0;   //チャンネル
uint32_t              freq    = 500; // 周波数
uint32_t              duty    = 128; // デューティ比 50% に相当

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

// 割り込みハンドラ
static void IRAM_ATTR button_isr_handler(void* arg) {

     ..... 

}

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

   //割り込みトリガーの設定 (falling edge)
   gpio_set_intr_type(sw, ..........);        //適切な値を入れること

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

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

   //ブザーの設定
   ledc_fade_func_install(0); // 後述の ledc_set_duty_and_update 関数を使うために必要

   ledc_timer_config_t ledc_timer = {
      .timer_num  = timer,                 //タイマーの番号
      .duty_resolution = LEDC_TIMER_8_BIT, // resolution of PWM duty. 8bit (0-255) に設定.
      .freq_hz    = freq,                  // frequency of PWM signal
      .speed_mode = LEDC_HIGH_SPEED_MODE   // timer mode
   };
   ledc_timer_config(&ledc_timer);        //タイマーの設定

   ledc_channel_config_t ledc_channel = {
      .channel    = channel,             // チャンネルの番号
      .duty       = duty,                // 初期のデューティ比
      .gpio_num   = pin,                 // ピン番号
      .timer_sel  = timer,               // このチャンネルに紐づけるタイマーの番号
   };
   ledc_channel_config(&ledc_channel);  //チャンネルの設定

   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);
   }
}