タクテク2
昭和歌謡好き大学院生の雑記

マイコンでチャタリングを防ぎつつスイッチ入力をいい感じに取る

割り込み中にwaitを呼ぶな

前回、ARMマイコンの割り込みを使ってチャタリングを防止しつつスイッチ入力を取ろうとしました。しかしながら、改めてあのコードを見直してみると割り込み中にwaitを呼んでいるんですよね。

割り込みを使うことでポーリングのようにCPUリソースをムダ食いしないようにすることが目標なのに、これだと割り込みルーチンからなかなか帰ってこないので意味がありません。というか割り込み中にwaitとかprintfとかを呼ぶのは禁忌です。過去に割り込みハンドラ内でprintfを呼んでデッドロックさせたのに何も学んでいないという…

というわけで、今回はこの実装をやり直す話です。

結局、割り込みよりポーリング?

今回の結論を言ってしまうと、

スイッチ入力は割り込みではなくポーリングで取るべき

となったのですが、その結論に至った経緯を書いていきます。

割り込みを使った実装を改良する

まずは、前回同様割り込みを使って入力を取る方法の改良を行いました。今回は

割り込みハンドラからは最速で抜ける

ことを念頭に設計を行い、以下のような動作を考えました。

チャタリング タイミングチャート

これにより、スイッチが押されると即座にステートを更新しつつ、スイッチが押されてから\(T_{th}\)間はステート更新を行わないことでチャタリングを除去できるわけです。そして、できたコードがこちら。

スイッチ読み取りルーチン

//Systick interval is 10 ms.
static const uint8_t DebouceCount = 2;

//Switch status
enum SW_State {UP, DN};
static uint32_t sw_tick[1][2] = {{0, 0}};
static uint8_t sw_state[1] = {UP};
static uint8_t sw_flag[1] = {0};

// Prototypes for local functions
void Update_SW_State(uint8_t SWNum);

void Init_SW()
{
    __disable_irq();
	LPC_IOCON->PIO0_1 = 0b00010000; // GPIO0_1, pull-up
	GPIOSetDir(0, 1, 0); // GPIO0_1, input
	GPIOSetIS(0, 1, 0);  // GPIO0_1, edge interruption
	GPIOSetIEV(0, 1, 0); // GPIO0_1, falling edge
	GPIOSetIE(0, 1, 1); // Enable interrupt
	NVIC_SetPriority(EINT0_IRQn,3);
	NVIC_EnableIRQ(EINT0_IRQn);
	UARTInit(115200);
	__enable_irq();
}

// IRQ Handler
void PIOINT0_IRQHandler()
{
    GPIOSetIE(0, 1, 0); // Disable interrupt
    GPIOClearInt(0, 1); //Clear Flag
    Update_SW_State(0);
    GPIOSetIE(0, 1, 1); // Enable interrupt
}

void Update_SW_State(uint8_t SWNum)
{
	uint32_t currentTick = Get_Ticks();
	if(sw_state[SWNum] == UP && currentTick - sw_tick[SWNum][UP] >= DebouceCount)
	{
		sw_state[SWNum] = DN;
		sw_tick[SWNum][DN] = currentTick;
		GPIOSetIEV(0, 1, 1); // GPIO0_1, rising edge
		sw_flag[SWNum] = 1;
	}
	else if(currentTick - sw_tick[SWNum][DN] >= DebouceCount)
	{
		sw_state[SWNum] = UP;
		sw_tick[SWNum][UP] = Get_Ticks();
		GPIOSetIEV(0, 1, 0); // GPIO0_1, falling edge
		sw_flag[SWNum] = 1;
	}
}

uint8_t Get_SW_Flag(uint8_t SWNum)
{
    if(sw_flag[SWNum])
    {
        sw_flag[SWNum] = 0;
        return 1;
    }
    return 0;
}

uint8_t Get_SW_State(uint8_t SWNum)
{
	return sw_state[SWNum];
}

メインルーチン

int main(void)
{
    LPC_IOCON->PIO2_0 = 0; // GPIO2_0, NO pull-up or pull-down
    GPIOSetDir(2, 0, 1); // GPIO2_0, output
    Init_SW();
    Init_SysTick();
    while(1)
    {
        if(Get_SW_Flag(0))
        {
            if(Get_SW_State(0) == DN)
            {
                //スイッチが押されたときの関数
            }
        }
    }
    return 0;
}

途中、SysTickの値が更新されない不具合に悩んでいたらSysTickのイニシャライズ関数を忘れていてタイマーがスタートしていないだけだったという凡ミスで2時間くらいをむだにしつつも、なんとか完成。

タイマーのオーバーフローへの対応

ところで、現在のタイムステップをカウントしているSysTickタイマーはuint32の変数を10msごとにインクリメントさせていくわけですが、こいつのオーバーフローって考えなくてもいいんでしょうか。

筆者は数学が嫌いであるため、Wikipediaの2の補数に関する記事を参考にしながら考えてみました。

考えたい事例は、我々符号付き10進数の世界において、現在のTick\({T}_{now}\)と、スイッチが押された時間\({T}_{detect}\)の関係が

\[ {T}_{now} - {T}_{detect} < 0 \]

となる場合です。このとき、本当はオーバーフロー分を足して

\[ {T}_{now} + 2^{32} - {T}_{detect} \]

を求めたいわけです。
ここで、2の補数の関係より、引き算は\({T}_{detect}\)の補数\({T}_{detect}^{'}\)を用いて以下の通り足し算で表されます。

\[ {T}_{now} - {T}_{detect} \\= {T}_{now} + {T}_{detect}^{'} \]

ここで、

\[ {T}_{detect}^{'} = 2^{32} - {T}_{detect} \]

したがって

\[ {T}_{now} - {T}_{detect} \\= {T}_{now} + 2^{32} - {T}_{detect} \]

となって勝手にオーバーフロー分が足されるのでuint型を使う限りはオーバーフローは考えなくていいわけです。

…ということをムダに1時間くらいかけて考えたわけですが、そもそもオーバーフローするのにどれくらいかかるのかと言うと

\[ 10\cdot10^{-3}\cdot2^{32}[sec]\approx497[day] \]

はい、考える必要すらなかったですね。

この実装の問題点

そして、この実装でうまく行ったのかと言うと、

という結果となりました。

まず、誤動作については今回は検証も兼ねてチャタリングが酷いスイッチを使ったのですが、この実装だと時間が\(T_{th}\)を越えてさえいればわずかなノイズでも判定されるので、長押ししている間にノイズが入ったり、短い時間で繰り返し押したりすると見事にバグりました。あと、この記事を書いている間にずっとこのプログラムを走らせたままにしていたのですが、ブランケットから発生した静電気で勝手にスイッチ入力判定が入りました

使用したスイッチ
もはや買ったのかどうかすらわからないスイッチ

そして、2点目については、この実装では結局メインルーチンからポーリングでステートを取っているので本末転倒であるばかりか、何なら大量の割り込みを発生させるのでポーリングより非効率な気がします。

誤動作 タイミングチャート

結局、スイッチ入力での割り込みを使う限りはチャタリングによる大量の割り込み発生は避けられないですし、かといって割り込みを一定時間ブロックしたとしても今回のようなノイズによる誤動作は避けられないですから、やはりポーリングによる方法が最適であるという結論に達しました。

ポーリングを用いた「賢い」方法

とは言え、ポーリングを用いた例としてよく例として見かけるようなwaitによるチャタリング回避では処理を止めてしまいますし、長押しとかの検知はできないので微妙です。

そこで調べた結果、以下のページがかなり参考になると感じました。

マイコンにおけるチャタリング&ノイズ対策

このページで「サンプリング(+ゲージ判定)方式」として紹介されている、カウンターを使用した方式なら

ということで、これが最適だと感じたので、これを参考に以下の通り実装を行いました。

サンプリング(+ゲージ判定)方式によるスイッチ入力処理部 抜粋

enum SW_State {UP, DN, LONGDN, IDLE};
//Systick interval is 10 ms.
static const uint8_t DebouceCount = 3;
static const uint8_t LongDnCount = 50;

//Switch counter and state
static uint32_t sw_counter[2][1];
static uint8_t sw_state[1] = {IDLE};
//この関数はSysTick割り込みで10msごとに呼び出す
void Update_SW_State(uint8_t SWNum)
{
    uint8_t sw_currentState = !GPIOGetValue(0, 1);
    sw_counter[UP][SWNum] += sw_currentState;
    sw_counter[DN][SWNum] += !sw_currentState;
    if(sw_state[SWNum] == IDLE && sw_counter[DN][SWNum] >= DebouceCount)
    {
        sw_state[SWNum] = UP;
        sw_counter[UP][SWNum] = 0;
        sw_counter[DN][SWNum] = 0;
    }
    else if(sw_counter[UP][SWNum] >= DebouceCount)
    {
        if(sw_counter[UP][SWNum] >= LongDnCount)
        {
            sw_state[SWNum] = LONGDN;
        }
        else
        {
            sw_state[SWNum] = DN;
        }
    }
}

uint8_t Get_SW_State(uint8_t SWNum)
{
    uint8_t currentState = sw_state[SWNum];
    sw_state[SWNum] = IDLE;
    return currentState;
}

main関数 抜粋

 while(1)
    {
        switch(Get_SW_State(0))
        {
            case UP:
            {
                Set_BLU_LED(0);
                Set_RED_LED(0);
                break;
            }
            case DN:
            {
                Set_RED_LED(0);
                Set_BLU_LED(1);
                break;
            }
            case LONGDN:
            {
                Set_RED_LED(1);
                Set_BLU_LED(0);
                break;
            }
            default:
            {
                break;
            }
        }
    }

実装としては、スイッチ入力がなくなった場合も検出したかったので、スイッチの押下のみならず入力がなくなった場合のカウンターも設置し、

という実装にしました。メインルーチンには動作確認用にLEDを状態に合わせて点灯させるプログラムを書いています。また、スイッチ数が増加した場合に備えて各変数は配列にしています。

このプログラムを実行した結果が以下の通り。今回、SysTickは10ms刻みであり、DebouceCountは2、LongDnCountは50としたので

はずですが、以下の通り正しく動作していることがわかります。

サンプリング(+ゲージ判定)方式によるスイッチ入力結果 長期

というわけで、今回の結論は

素直に先人の知恵に従おう

以上です。

というか、このスイッチ、チャタリングしすぎじゃない?

サンプリング(+ゲージ判定)方式によるスイッチ入力結果 拡大
Tags: