この記事は Calendar for Akerun | Advent Calendar 2021 - Qiita の 24 日目の記事です1.
本 Advent Calendar 2度目の登場となる tarotene - Qiita です.先日は「組み込みエンジニアのための徒手空拳のすゝめ」という記事でかなりポエミーな内容をぶっ放したので,今回は地に足のついた内容でお送りします.
チャタリング・ノイズ・ディレイ...
突然ですが,読者の皆さんはボタンのついたデバイスを日常的にお使いかと思います.PC のマウスやキーボード然り,ゲームのコントローラー然り,何らかの ON/OFF 判定を伴う動作にボタンは欠かせないモノです.
こういったボタンはタクトスイッチという電子部品で実装されていることが多いでしょう2:
タクトスイッチは,触れていない時には基本 OFF(接点開放)の状態で,押し込んだ瞬間だけ ON(接点短絡)と判定されるように作られています.こういった判定方式を特別に A 接点と呼んだりします.逆に,押し込んだ瞬間だけ OFF と判定する方式は B 接点と呼ばれています:
そして,デジタル電子回路や MCU においてボタンの ON/OFF が常に意図通りのデジタル入力となってくれれば良いのですが,現実にはそうはいきません.というのは,そもそも世の中のデジタル信号はデジタルとして解釈するためのルールが設けられたアナログ信号に過ぎず,開放/短絡の間にはかならず中間の状態が存在するからです3.
スイッチの ON/OFF も御多分に洩れず中間状態をとります.よくあるのが,接点そのものの跳ね(bouncing)に伴ってデジタル信号が HIGH/LOW を行ったり来たりするチャタリングと呼ばれる現象です.これは,考えられうる最もシンプルな設計のメカニカルスイッチでは不可避な現象です.こうした現象を回避するのに,例えば水銀リレーなどでは開放(短絡)->短絡(開放)の状態遷移時に接点そのものが張り付いて跳ねないような設計が採用されています:
また,こうした現象を回路レベルで取り除くためにコンデンサと抵抗を加えたり(CR と呼ばれる)やシュミットトリガと呼ばれるヒステリシスを持つ A/D 変換器を用いたりすることもあります:
チャタリングを接点レベルや回路レベルで除去できるのだから,当然ソフトウェアレベルでもできるだろうと期待したあなた,その通りです.
実際,世の中の電子機器では回路(というかハードウェア全体)とソフトウェアそれぞれにかかる要件と相談しながらこの手のあるある問題を解決していきます.例えば,回路のレイアウト制約が大きければソフトウェアに一任,ハード・ソフト両方に潤沢なリソースがあって要件も緩ければ両方で対応,逆にソフトウェアのデリバリーが間に合わない(あるいはできない)ことが最初からわかっていればハードウェアだけで済ませる,といった具合です.世の中の電子機器の設計は残念ながらそのほとんどが非公開なので想像の域を出ませんが,民生機器のレベルだとソフトウェアだけでチャタリング除去することが多いと聞きます4.
本記事では,実在のマイコンを使ってソフトウェアだけでチャタリング除去を(擬似的に)行う様子をコード例とともに示していきます.
用いたマイコンは nRF52 DK(正式名 PCA10040)という評価ボードです.
この評価ボードは元々 nRF52832 と呼ばれる BLE チップを評価するためのものですが,
- 偶然手元にあった
- ボタンと LED が 4 つずつ付いている
という理由で今回の実験に採用します.
作る機能は,
- ボタンを押下(リリース)すると一定の遅延時間の後に LED が点灯(消灯)する
というシンプルなものですが,採用するアルゴリズムが割とこの手のコードにあるあるなので事前に簡単な説明を入れます.
なお,今回は工数の都合で機能的な振る舞いにフォーカスするためにコードの可読性とかメンテナンス性には目を瞑ろうと思います5.
ホールド & カウントアップ
チャタリング対策の本質を考えると,最終的にはボタンの ON/OFF 判定における偽陽性・偽陰性を減らすという所に行き着くと思います.
です.前者の偽陽性は,そもそものチャタリング対策のニーズと直結しているので今更議論は必要ないでしょう.そして,後者の偽陰性はアルゴリズムの停止性に関わる大事な問題です:
これらの要件を勘案すると,どんな実装でも下記の 2 つのイベントを考慮することになりそうです:
- 仮判定: チャタリングでもノイズでも何でもとりあえず入力としてキャッチし,後段の実判定に渡す
- 実判定: 仮判定のイベントから有限の時間で本当に入力があったかどうかを通知する
一般に,マイコンではハードリアルタイム要求を守らせるような実装が可能で,逆に手を緩めることでソフトリアルタイム要求だけ充足,スループット要求だけ充足,とパフォーマンスを変化させることもできます:
つまり,仮判定と実判定のそれぞれに対して応答のリアルタイム性を非機能要求として課すことで最終的な実装が決まります6.
ここでは,ベアメタル(RTOS を用いない実装)で動く最大限のリアルタイム性を保証するアルゴリズム---ホールド & カウントアップ---をいきなり紹介します.
と進んでいき,最後に完全なコード例(動作確認済)を示します.
main()
の実装
まずはモノから:
#include "boards.h" #include "bsp.h" #include "nrf_drv_gpiote.h" #include "nrf_log.h" #include "nrf_log_ctrl.h" #include "nrf_log_default_backends.h" #include "app_timer.h" #include "nrf_drv_clock.h" APP_TIMER_DEF(m_repeated_timer_id); /**< Handler for repeated timer used to blink LED 1. */ int main(void) { NRF_LOG_INIT(NULL); NRF_LOG_DEFAULT_BACKENDS_INIT(); nrf_drv_clock_init(); nrf_drv_clock_lfclk_request(NULL); nrf_drv_gpiote_init(); nrf_gpio_cfg_output(LED_1); nrf_drv_gpiote_out_set(LED_1); nrf_drv_gpiote_in_config_t button_1_config = GPIOTE_CONFIG_IN_SENSE_TOGGLE(true); button_1_config.pull = NRF_GPIO_PIN_PULLUP; nrf_drv_gpiote_in_init(BUTTON_1, &button_1_config, button_1_toggle_handler); nrf_drv_gpiote_in_event_enable(BUTTON_1, true); app_timer_init(); app_timer_create(&m_repeated_timer_id, APP_TIMER_MODE_REPEATED, repeated_timer_handler); m_button_1 = nrf_gpio_pin_read(BUTTON_1); // Enter main loop. while (true) { __WFI(); } }
まだこれだけではコンパイルが通りませんが,重要な概念がいくつかあります.まず,nRF5 SDK で利用可能なアプリケーションタイマ,そして GPIOTE の 2 つです.
アプリケーションタイマは,起動用のイベントを投げると有限の整数値にセットされたカウンタが一定のペースでデクリメントされ,値が 0
になる(expire する)とユーザ定義のイベントを投げるといった機能を提供します.nRF5 SDK では app_timer_***()
といった関数で提供され,#include "app_timer.h"
で利用できるようになります.expire 後の挙動としては再度カウンタを元の整数値にセットし直しタイマを起動させっぱなし,そのままタイマを停止させるの 2 択です.今回みたいに周期的にイベントを投げるタイマを利用したかったら前者が妥当ですが,場合によっては後者も使います.
GPIOTE は詳しくは後述しますが,対象の入力ピン(今回だとボタンに直結した GPIO ピン)の状態変化を直接イベントハンドラに繋げる機能です7.これを制御・管理するために一連の HAL ライブラリ関数 nrf_drv_gpiote_***()
が提供されています.割り込みベクタを直接消費する機能っぽいので,当然ながら登録可能なピン数は有限で,実際のピン数よりずっと少ないです.なので,たくさんの入出力を一手に担いながらリアルタイム処理を行う製品を作ろうと思ったら全部 GPOITE 任せというのは土台無理で,ここに工夫が求められます.
やろうとしていることは,ざっくり
- ボタン入力に対応する GPIO ピンを GPIOTE に登録し,常時監視.
- 実現方法: SDK 側の GPIOTE 用ユーティリティ関数を使用.
- GPIOTE は状態変化を検知したら周期タイマを起動する.
- 周期タイマは,カウンタ expire 時に対象の GPIO ピンをチェック.
- 実現方法: 周期タイマ起動時に GPIO ピンをチェックする関数(タイマイベントハンドラ)を登録.
です.ここで,タイマイベントハンドラには
- GPIOTE が検知した状態変化がそのまま一定時間に渡って保持されていれば正式に受理
- それ以外の場合は拒否し,タイマを停止
という仕様を守らせることにします8.
これによって前述の仮判定から実判定までがスッと繋がる実装になります.これに加えて
- 周期タイマを呼ばせる周期(msec 単位)と呼ばせる最大回数
を決めてあげると非機能的な部分も含めて仕様が完成します.
なので, main()
内部の処理としてはざっくり,
- クロックドライバの初期化:
nrf_drv_clock_init();
- クロックインスタンスの生成:
nrf_drv_clock_lfclk_request(NULL);
- GPIOTE ドライバの初期化:
nrf_drv_gpiote_init();
- GPIOTE インスタンスの生成:
nrf_drv_gpiote_in_init(BUTTON_1, &button_1_config, button_1_toggle_handler);
- GPIOTE インスタンスの有効化:
nrf_drv_gpiote_in_event_enable(BUTTON_1, true);
- タイマの初期化:
app_timer_init();
- タイマインスタンスの生成:
app_timer_create(&m_repeated_timer_id, APP_TIMER_MODE_REPEATED, repeated_timer_handler);
という順になります.
button_1_toggle_handler
および repeated_timer_handler
はそれぞれこの後実装するボタンイベントハンドラ,タイマイベントハンドラへの関数ポインタです.
補足の処理として
- タイマインスタンスの ID 宣言:
APP_TIMER_DEF(m_repeated_timer_id); /**< Handler for repeated timer used to blink LED 1. */
- 監視対象の GPIOTE ピンのプルアップ指定:
nrf_drv_gpiote_in_config_t button_1_config = GPIOTE_CONFIG_IN_SENSE_TOGGLE(true);
button_1_config.pull = NRF_GPIO_PIN_PULLUP;
- 実判定用の変数の初期化:
m_button_1 = nrf_gpio_pin_read(BUTTON_1);
があるというイメージです.ここで,
m_repeated_timer_id
: タイマインスタンスの IDAPP_TIMER_MODE_REPEATED
: タイマがイベントを投げるタイミングを周期的にするための設定子マクロ
で,実際の処理対象である LED の点灯・消灯用ピンの初期化は
nrf_gpio_cfg_output(LED_1);
nrf_drv_gpiote_out_set(LED_1);
で行います.ビルドが通るためにはヘッダファイルのインクルードに加えてコンパイル時に参照される設定ファイルへの変更が必要です.細かい情報についてはフォーラム記事も参考にしてみてください:
残るはボタンイベントハンドラとタイマイベントハンドラの実装です.
ボタンイベントハンドラ button_1_toggle_handler
の実装
ただタイマを起動するだけの簡易な実装です:
void button_1_toggle_handler(nrf_drv_gpiote_pin_t pin, nrf_gpiote_polarity_t action) { NRF_LOG_INFO("timer start"); app_timer_start(m_repeated_timer_id, APP_TIMER_TICKS(100), NULL); }
タイマイベントハンドラ repeated_timer_handler
の実装
まず,グローバルスコープでカウンタを宣言・初期化しておきます:
static uint32_t m_button_1; static uint32_t m_button_1_cnt = 0;
タイマイベントハンドラはカウンタを利用して条件分岐を行います:
void repeated_timer_handler(void * p_context) { // COUNT-UP FINISH if (m_button_1_cnt >= BUTTON_1_CNT_MAX) { NRF_LOG_INFO("count-up finished (%d)", m_button_1_cnt); m_button_1 ^= 1; nrf_drv_gpiote_out_toggle(LED_1); NRF_LOG_INFO("button state changed to %d", m_button_1); app_timer_stop(m_repeated_timer_id); m_button_1_cnt = 0; return; } // COUNT-UP CONTINUE / ABORT if (m_button_1 != nrf_gpio_pin_read(BUTTON_1)) { NRF_LOG_INFO("counting up... (%d)", m_button_1_cnt); m_button_1_cnt++; } else { NRF_LOG_INFO("count-up aborted"); app_timer_stop(m_repeated_timer_id); m_button_1_cnt = 0; } }
以上で処理の大まかな流れが完成します.最終形は下記のようになります:
// unused // #include <stdbool.h> #include "boards.h" #include "bsp.h" #include "nrf_drv_gpiote.h" #include "nrf_log.h" #include "nrf_log_ctrl.h" #include "nrf_log_default_backends.h" #include "app_timer.h" #include "nrf_drv_clock.h" #define BUTTON_1_CNT_MAX (10) APP_TIMER_DEF(m_repeated_timer_id); /**< Handler for repeated timer used to blink LED 1. */ static uint32_t m_button_1; static uint32_t m_button_1_cnt = 0; void button_1_toggle_handler(nrf_drv_gpiote_pin_t pin, nrf_gpiote_polarity_t action) { NRF_LOG_INFO("timer start"); app_timer_start(m_repeated_timer_id, APP_TIMER_TICKS(100), NULL); } void repeated_timer_handler(void * p_context) { // COUNT-UP FINISH if (m_button_1_cnt >= BUTTON_1_CNT_MAX) { NRF_LOG_INFO("count-up finished (%d)", m_button_1_cnt); m_button_1 ^= 1; nrf_drv_gpiote_out_toggle(LED_1); NRF_LOG_INFO("button state changed to %d", m_button_1); app_timer_stop(m_repeated_timer_id); m_button_1_cnt = 0; return; } // COUNT-UP CONTINUE / ABORT if (m_button_1 != nrf_gpio_pin_read(BUTTON_1)) { NRF_LOG_INFO("counting up... (%d)", m_button_1_cnt); m_button_1_cnt++; } else { NRF_LOG_INFO("count-up aborted"); app_timer_stop(m_repeated_timer_id); m_button_1_cnt = 0; } } int main(void) { NRF_LOG_INIT(NULL); NRF_LOG_DEFAULT_BACKENDS_INIT(); nrf_drv_clock_init(); nrf_drv_clock_lfclk_request(NULL); nrf_drv_gpiote_init(); nrf_gpio_cfg_output(LED_1); nrf_drv_gpiote_out_set(LED_1); nrf_drv_gpiote_in_config_t button_1_config = GPIOTE_CONFIG_IN_SENSE_TOGGLE(true); button_1_config.pull = NRF_GPIO_PIN_PULLUP; nrf_drv_gpiote_in_init(BUTTON_1, &button_1_config, button_1_toggle_handler); nrf_drv_gpiote_in_event_enable(BUTTON_1, true); app_timer_init(); app_timer_create(&m_repeated_timer_id, APP_TIMER_MODE_REPEATED, repeated_timer_handler); m_button_1 = nrf_gpio_pin_read(BUTTON_1); // Enter main loop. while (true) { __WFI(); } }
まとめ・発展的なこと
前述のコードをビルドして nRF52 DK にロードし,RTT でログを出しながら 1 つ目のボタンを押したり離したりすることで動きを確認できます.
もっとも,nRF52 DK にはもともとボタンのチャタリングを上手くフィルタする素子が組み込まれているので,nRF52 DK を動かす上ではほぼ不要な機能の実装と言えます.
ただ,複数通りの実装が考えられるチャタリング対策という機能で「要件 XX を満たすコードは最低こう書きましょう」みたいな内容の記事を一度書いてみたかったので,身近なマイコンボードを題材に書かせてもらいました.
ベテランの組み込み屋さんにとっては基本の基みたいな内容でありながら「機能要求から実装へ」という流れで解説を書くというのは難しかったですが,それなりの内容を提供できたのではないかと思います.
コード上の改善については,例えば
みたいな要求に対して最小限のコード変更で対応できる,というのが良い指導原理になると思います.このあたりは読者の演習問題とはせず,どこかで記事にする予定です.
株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co
Akerun Proの購入はこちらから akerun.com
-
性懲りも無く投稿予定日を過ぎてからの投稿でごめんなさい… でも書くのが大事だと思うので書きます.↩
-
ちなみに「タクト」という言葉からは指揮棒(takt)を連想させますが,タクトスイッチの語源は tactile switch(直訳: 感触のあるスイッチ)です.↩
-
スイッチの開放/短絡を直接入力するタイプの I/F では,電子回路の接地(GND)電位に対する相対的な HIGH/LOW を与えるわけではないことに注意しましょう.このタイプの I/F を無電圧接点と呼んだりします.そして,端子のインピーダンスは通常,開放時に無限大(複素インピーダンスなら絶対値が無限大),短絡時にゼロとなります.↩
-
PlayStation 4 のコントローラ DualShock 4 のボタン(もとい FW)がどういう実装なのかが気になりちょっとだけ検索したら何らかの方法で FW を dump し公開したような形跡と DMCA による取り下げ記録のあるGithubリポジトリが見つかりました,アーメン…(そりゃそうですよね)↩
-
余裕があれば改良編と称してまた記事にするかも知れません.↩
-
ナイーブに考えると仮判定と実判定のそれぞれで要求するリアルタイム性が異なる変なコードもあり得りえますが,実装コストが無駄になることを考えると両者で要求レベルを揃えておくのが普通です.↩
-
この実装は,タイマの起動・停止を担う関数がそれぞれ通常のコンテキスト・割り込みコンテキストという異なるコンテキストで呼ばれる非対称な実装で,一度起動したタイマが停止されずに動いたままという状況になっても気が付かない恐れもあります.これはよくある
malloc()
で確保したメモリ領域のポインタを特定の関数に値渡しした後,関数内部でfree()
させる実装と似たような話なので気にしないで良いと言えば気にしないで良い… ですが,対称化できるならした方が見通しは良いでしょう.↩