事件発生
普段メインに使ってるCuriosity Nanoの、オンボード・タクトスイッチの反応がない。というか押した感触がない。でもってよく見てみると…
キートップもげてるし!!!
やむなくその辺に転がってた安物のタクトスイッチを代用に繋いでみたのだが……これが酷いじゃじゃ馬で。
※※※毎秒100連打以上の猛烈なチャタリング・ストーム※※※
中華品質怖いわー。高○名人もドン引くわー。ハ○パー○リン○ック無双だわー。
とりあえず適当な抵抗とセラコン足してみたがそれでも、ひと押し16連打余裕だし。新しい部品調達も時間食うし。何より押したことを検出したいのは当然として押したままの状態と離したことをソフトウェア的には区別して扱いたい最中だったので、これは甚だ不都合だ。
困ったねえ、と嘆いている時間があったら手を動かす主義。ソフトウェアで対策を講じてみよう。
実装検証コード
あまりごちゃごちゃしたものは実装したくないから、制約を設ける。
- 開発環境は MultiX Zinnia Product SDK [modernAVR] を使う。Arduino API ではない。貴兄は何もなくとも割込ベクタをたくさん作ってしまうので、ハードウェア障害切り分けするには騒音が多すぎる。かといって
<avr/io.h>だけで全部賄うのは修行僧だけで良い。 - 使用チップ/開発ボードは AVR64DU32 Curiosity Nano。(tinyAVR-0/1/2、megaAVR-0、AVR-Dx/Ex/Lxならどれでも)
- 外部スイッチはポートピンとGND間にあるだけで、外部コンデンサ/外部プルアップ抵抗は備えない。
- 割込ベクターはひとつだけ占有しても良い。
- 状態遷移フラグの保持には、GPR汎用レジスタを使う。もちろんグローバルな
volatile変数でも良いのだが、グローバル変数絶許の派閥もあるし。
ということで、こんな検証コードを書いた。デバッグ用のLED出力やプリント文を省けば、そんなに複雑なことはしていない。
SDK独特の糖衣構文満載だが、だいたいは読めばわかる範囲のはずだ。
/**
* @file Debouncing.ino
* @author askn (K.Sato) multix.jp
* @brief Demonstration of robust debouncing.
* @version 0.1
* @date 2026-08-19
* @copyright Copyright (c) 2026 askn37 at github.com
* @link Product Potal : https://askn37.github.io/
* MIT License : https://askn37.github.io/LICENSE.html
*/
/* 回路構成に関わらず、SW=PF6 なら GPIO でなければならない */
/* AVR64DU32 Curiosity Nano の場合の設定(LEDは負論理) */
// #define SW_BUILTIN PIN_PF6
// #define LED_BUILTIN PIN_PF2
/* LEDが正論理ならこの行を使う */
// #undef LED_BUILTIN_INVERT
/* PKOBN VCP-UART の速度(Curiosity Nanoの最速値は 500kbps) */
// #define CONSOLE_BAUD 500000L
#if !defined(LED_BUILTIN) || !defined(SW_BUILTIN)
/* 設定漏れはコンパイルを止める */
#error The build settings are wrong!
#include BUILD_STOP
#endif
/* 状態保持変数(汎用レジスタ)の別名化 */
#define STATEREG GPR_GPR3
#define COUNTREG GPR_GPR2
/* 状態変化設定 */
#define INT_FAL (PORT_PULLUPEN_bm | PORT_ISC_FALLING_gc)
#define INT_RIS (PORT_PULLUPEN_bm | PORT_ISC_RISING_gc)
#define CHK_FAL (PORT_ISC_0_bp)
/* 状態フラグ(ビット位置) */
#define FAL_bp 0
#define RIS_bp 1
#define HOLD_bp 2
void setup (void) {
delay(700); /* PKOBN(PicKit On Borad Nano) のVCP仮想シリアルポートが開くのを待つ */
Serial.begin(CONSOLE_BAUD).println(F("\n<startup>"));
Serial.print(F("F_CPU=")).println(F_CPU, DEC);
Serial.print(F("_AVR_IOXXX_H_=")).println(_AVR_IOXXX_H_);
Serial.print(F("CONSOLE_BAUD=")).println(CONSOLE_BAUD, DEC);
pinMode(LED_BUILTIN, OUTPUT); /* LEDの初期状態は点灯 */
digitalWrite(LED_BUILTIN, HIGH);
#ifdef LED_BUILTIN_INVERT /* LEDが負論理接続の場合 */
pinControlRegister(LED_BUILTIN) |= PORT_INVEN_bm;
#endif
/* SWのGPIOはオープンドレイン・プルアップ */
pinMode(SW_BUILTIN, INPUT_PULLUP);
/* 最初のSW割込許可は下降端 */
pinControlRegister(SW_BUILTIN) = INT_FAL;
/* CPUのアイドル休止を有効化 */
power_idle();
}
void loop (void) {
if (bit_is_set(STATEREG, HOLD_bp)) { /* SWはホールド中? */
/* CPUが休止状態でなく、かつSWホールド中なら、ここは繰り返し実行される */
if (bit_is_set(STATEREG, RIS_bp)) { /* SWは解放された? */
/* 状態遷移の最後で1回だけ実行 */
STATEREG = 0; /* 状態フラグを全部clear */
vportRegister(SW_BUILTIN).INTFLAGS = ~0; /* 割込実行フラグをclear */
digitalWrite(LED_BUILTIN, HIGH); /* LED点灯 */
Serial.print(++COUNTREG, DEC).println(":Up");
delay(30); /* お好みの処理時間消費 */
}
}
else if (bit_is_set(STATEREG, FAL_bp) /* SWは押された? */
&& bit_is_clear(STATEREG, HOLD_bp)) { /* SWは非ホールド中? */
/* 状態遷移の最初で1回だけ実行 */
bit_set(STATEREG, HOLD_bp); /* SWホールドフラグをset */
vportRegister(SW_BUILTIN).INTFLAGS = ~0; /* 割込実行フラグをclear */
digitalWrite(LED_BUILTIN, LOW); /* LED消灯 */
Serial.print(++COUNTREG, DEC).println(":Down");
delay(30); /* お好みの処理時間消費 */
}
/* CPUアイドル休止 */
sleep_cpu();
}
/* 割込ベクター */
ISR(portIntrruptVector(SW_BUILTIN)) {
/* SW割込は下降端? */
if (bit_is_set(pinControlRegister(SW_BUILTIN), CHK_FAL)) {
pinControlRegister(SW_BUILTIN) = INT_RIS; /* 次回のSW割込許可は上昇端 */
bit_set(STATEREG, FAL_bp); /* SW押下フラグをset */
}
/* SW割込は上昇端? */
else {
pinControlRegister(SW_BUILTIN) = INT_FAL; /* 次回のSW割込許可は下降端 */
bit_set(STATEREG, RIS_bp); /* SW解放フラグをset */
}
}
// end of code
他の品種のCuriosity Nanoボードなど実体の回路が異なるなら、冒頭の初期設定マクロを適宜修正する。
コード詳解
pinModeやdigitalWriteは説明不要だろう。SW_BUILTINはプルアップ付きオープンドレイン入力にする。これは外部スイッチが押し下げられるとGNDショート==INPUT::LOW、解放が Hi-Zオープン==INPUT::ANYであるので、PULLUPをかけあわすことでINPUT::HIGHが読めるようにする。
SW_BUILTINつまりPIN_PF6は、PORTF割込グループに属している。そのPIN6に対し最初は下降端割込(FALLING)を許可するのだが、このSDKの糖衣構文(MacroAPI)は、本来の<avr/io.h>ネイティブ記述を次のように書き表しなおすことを許す。ちょっとは解りやすくないですか? ちなみにコンパイル結果はバイト単位で寸分違わず同じ。だって糖衣構文の実態はプリプロセッサに丸投げする Macro なんだもの。
- /* <avr/io.h> ネイティブ記述 */
- PORTF_PIN6CTRL = PORT_PULLUPEN_bm | PORT_ISC_FALLING_gc;
- /* MacroAPI糖衣構文 */
- #define INT_FAL (PORT_PULLUPEN_bm | PORT_ISC_FALLING_gc)
+ pinControlRegister(SW_BUILTIN) = INT_FAL;
同様に、割込ベクターの指定方法も MacroAPI では次のように書き表せる。ピンの名前で指定できるのが特色。ピン名を別名マクロにしておけば、対応する#defineの変更だけで、実体記述も追従して変化する。
- ISR(PORTF_PORT_vect)
+ ISR(portIntrruptVector(SW_BUILTIN))
一般的なピン割込では両端検出のPORT_ISC_BOTHEDGES_gcを使うこと(実際Arduino APIはその仕様)が多いと思うが、今回はこれをふたつに分けて一方通行にしておくことが重要。
割込ベクター内部は、押し下げ検出PORT_ISC_FALLING_gc(0x03)とPORT_ISC_RAISING_gc(0x02)それぞれに処理が分岐する。このふたつの定数ラベルはフィールド右端の1ビットPORT_ISC_0_bpが違うだけだ。なのでbit_is_set(PORTF_PIN6CTRL, PORT_ISC_0_bp)を調べることで用をなす。
if (bit_is_set(pinControlRegister(SW_BUILTIN), CHK_FAL)) {
/* 押下側の割込処理 */
pinControlRegister(SW_BUILTIN) = INT_RIS; /* 次回のSW割込許可は上昇端 */
bit_set(STATEREG, FAL_bp); /* SW押下フラグをset */
}
else {
/* 解放側の割込処理 */
pinControlRegister(SW_BUILTIN) = INT_FAL; /* 次回のSW割込許可は下降端 */
bit_set(STATEREG, RIS_bp); /* SW解放フラグをset */
}
それぞれの分岐処理中では、次回の割込許可方向を逆にするよう変更する。そして状態変化フラグをセットする。ここでは次回割込を許可するための割込実行フラグのクリアを行わない。それを本体ループ処理中に移すことで、そこまでがひとつながりの割込処理になる。
メインループも、3種類の状態変化フラグに応じた場合分け(チェックポイント:検問)を行う。状態(ステート)は3区分ある。
if (bit_is_set(STATEREG, HOLD_bp)) { /* SWはホールド中? */
/* CPUが休止状態でなく、かつSWホールド中なら、ここは繰り返し実行される */
if (bit_is_set(STATEREG, RIS_bp)) { /* SWは解放された? */
/* 状態遷移の最後で1回だけ実行 */
}
}
else if (bit_is_set(STATEREG, FAL_bp) /* SWは押された? */
&& bit_is_clear(STATEREG, HOLD_bp)) { /* SWは非ホールド中? */
/* 状態遷移の最初で1回だけ実行 */
}
状態フラグを3bit使うより2bit幅フィールド変数の方がフラグ量節約になるが、bit_is_set/bit_is_clear判定ではなく、AND/ORマスクと定数との比較文で書くことになるので、出力バイナリ量が引き換えに増加する。何より汎用レジスタ相手の固定ビット判定は、1命令1CPUサイクルで完了するし、逆アセンブル結果も判読しやすい。
状況変化の過程(ステートマシン)において、スイッチ押下と、スイッチ解放の検出処理はそれぞれ1回だけ実行されなければならない。押下処理が完了したことを明らかにするために第3のフラグHOLD_bpがある。これがセットされていなければ、それまでにハードウェア的にスイッチ解放が検出されていても、ソフトウェアでの解放処理は保留され続ける。これは俗にフェンスガード(や検問)と言うし、一連の流れはアトミック(不可分)だとも言う。
スイッチ押下検出処理は2個の命令からなる。ホールド状態フラグのセットと、 割込実行フラグのクリアだ。
/* SW押下検出時に1回だけ実行する処理 */
bit_set(STATEREG, HOLD_bp); /* SWホールドフラグをset */
vportRegister(SW_BUILTIN).INTFLAGS = ~0; /* 割込実行フラグをclear */
スイッチ解放検出処理も同様に2個の命令からなる。状況フラグを全部消してステートマシンを初期状態に戻すことと、割込実行フラグのクリアだ。
STATEREG = 0; /* 状態フラグを全部clear */
vportRegister(SW_BUILTIN).INTFLAGS = ~0; /* 割込実行フラグをclear */
割込ベクター開始時から、この割込実行フラグのクリアまでのCPU実行サイクル数は不定で、どれだけのソフトウェア時間経過があるかは不可知である。その間に外部スイッチは不特定多数のバウンスを生じ、複数回の割込発生機会を得ている。しかし割込実行フラグをクリアするまではその全てのトリガーがフィルターされ保留されている。ハードウェア割込を無効にしているのでも禁止にしているのでもなく、保留させるというところが着眼点。下手に無効/禁止にしてしまうと、押し下げたことに対するペアとなる解放が検出不能・とりこぼし・不可知になってしまうので、ステートマシンが成立しない。そして保留しておける割込は(ひとつの割込ベクターに対して)最後の一回だけだ。複数回発生したとしても上書きされ、最後の検出結果だけが残る。だから、その検出トリガーを割込ベクター内でそれまでの逆端にフィルターしている。結果的にこれがチャタリング除去対策の要点になる。
まとめ
真っ当にチャタリング対策(デバウンス処置)しようと思ったら、モーメンタリー・スイッチならディレイド・リセットICを、オルタネート・スイッチならフリップフロップICを、回路に挟まないと満足な信頼性は得られない。金をかけて良いならそれが一番。実際工場などで見かける非常停止通知スイッチなんかはそういう仕組みで、だからこそ単価が高い。
信頼性を得るのにソフトウェアでステート管理するのは、考えるのも書くのも面倒くさい。
割込実行フラグの管理は大事。
アセンブリ的には、割込ベクターはRETIマシン語命令で終わる。割込ベクター開始からこのマシン語命令までがハードウェアレベルでのアトミック・フェンスガードになっていて、重複発生した割込トリガーを保留するようになっている。割込実行フラグのクリアがそのトリガー保留状態を解除して再許可する仕組みだ。
実はこの重複割込フェンスガードが実装されたのは、tinyAVR-0以降の新世代AVRからである。それ以前の ATmega328PなどのAVRではRETIが保留解除そのもので、割込実行フラグを自動的に消してしまっていたから、割込ベクターを抜けるとすぐに重複割込が無条件起動しえた。だから今回の検証コードはそれらの前世代AVRに移植しても、おなじようには動かない。
投稿者の人気記事
![[modernAVR] ひどいタクトスイッチのチャタリング除去と闘う](https://res.cloudinary.com/elchika/image/upload/t_elchika_article_cover/v1/user/060cd109-19e8-40f6-8046-7d4f29a6d7a6/article/221f78cf-4e6a-49c6-b6fe-ff14bd3bcaa8/mrbmeasnlgjs9fgtehgj.jpg)
![[modernAVR] AVR32LA32を使ってみる](https://res.cloudinary.com/elchika/image/upload/t_elchika_article_cover/v1/user/060cd109-19e8-40f6-8046-7d4f29a6d7a6/article/458fafd5-a755-4916-8eef-ff0c762fcfb0/fju7iiukzdlk3ujscgrl.jpg)

-
askn
さんが
前の木曜日の11:30
に
編集
をしました。
(メッセージ: 初版)
ログインしてコメントを投稿する