「ボタンを1回押しただけなのに、カウントが2つも3つも増える…」「LEDのON/OFFが押すたびにランダムになるんです」

Arduinoでタクトスイッチを使い始めると、かなりの確率でこの現象に出会いますよね。配線もコードも合っているのに、なぜか1回の押下が何回にも数えられてしまう。原因はチャタリングです。

この記事では、チャタリングの正体をつかんだうえで、delay()を使わずmillis()でデバウンスする方法と、同じしくみで短押し・長押しを判定する方法まで一緒に作っていきます。難易度はArduino入門〜初級。最後に、判定ロジックをPythonで再現して、本当にチャタリングを吸収できるかPCだけで確かめます😊

チャタリングとは?押した瞬間に信号がバタつく現象

arduino push button breadboard
arduino push button breadboard / Photo by Chengxin Zhao via Pexels

タクトスイッチの中では、金属の接点がぶつかって電気が流れます。押した瞬間(離した瞬間も)は接点が細かく跳ね返るので、ほんの数ミリ秒のあいだON→OFF→ON…と信号が何度も切り替わります。これがチャタリング(バウンス)です。

イメージとしては、ボールを床に落としたときの「ポン、ポン、ポン」です。人間には1回押しただけでも、マイコンは1秒間に何万回も状態を読めるので、跳ね返りを全部「押された」と数えてしまうわけです。

まずは対策なしで数えてみる

配線は、タクトスイッチの片側をD2、もう片側をGNDにつなぐだけです。INPUT_PULLUPで内蔵のプルアップ抵抗を使うので、外付けの抵抗はいりません。このとき離しているとHIGH、押すとLOWになる点に注意してください。

// ボタンを押した回数を数える(チャタリング対策なし)
const int BUTTON_PIN = 2;  // ボタンのもう片方はGNDへ

int lastState = HIGH;      // 前回の状態(INPUT_PULLUPなので離すとHIGH)
int count = 0;

void setup() {
  Serial.begin(9600);
  pinMode(BUTTON_PIN, INPUT_PULLUP);  // 内蔵プルアップを使う
}

void loop() {
  int state = digitalRead(BUTTON_PIN);
  if (lastState == HIGH && state == LOW) {  // HIGH→LOWになった=押された
    count++;
    Serial.print("押された回数: ");
    Serial.println(count);
  }
  lastState = state;
}

ポイントをまとめるとこんな感じです。

  • 前回の状態を覚えておき、HIGH→LOWに変わった瞬間だけを「押された」と数える
  • 押しっぱなしで何回も数えないための、よくある書き方

考え方は正しいのですが、ゆっくり5回押しても、シリアルモニタには次のように表示されることがあります(表示例。回数はスイッチや押し方で変わります)。

押された回数: 1
押された回数: 2
押された回数: 3
押された回数: 4
押された回数: 5
押された回数: 6
押された回数: 7

跳ね返りのたびにHIGH→LOWが起きるので、5回押しても7回数えてしまう、というわけです。

よく見かける対策は「押されたらdelay(50)で待つ」方法です。手軽ですが、待っているあいだは他の処理がすべて止まります。センサーの読み取りやLEDの点滅と組み合わせると、動きがカクつく原因になるんですよね。

millis()でデバウンスする(短押し・長押し判定つき)

そこで、millis()(起動してからの経過ミリ秒)を使います。考え方はシンプルで、「読んだ値が一定時間(ここでは30ms)変わらなかったら、その値を確定とみなす」だけです。跳ね返りは数ms程度で収まるので、30ms続いた値は「本物の押下」と判断できます。

ここが重要です👇

  • 生の値(reading)と確定した状態(stableState)を別々の変数で持つ
  • 生の値が変わるたびに、時刻(lastChange)をリセットする
  • 確定した時刻を覚えておけば、押していた長さ=長押しの判定にもそのまま使える
// チャタリング対策(millis()デバウンス)+短押し・長押し判定
const int BUTTON_PIN = 2;   // ボタンのもう片方はGNDへ
const int LED_PIN = 13;     // 基板上のLED

const unsigned long DEBOUNCE_MS = 30;     // この時間、値が変わらなければ確定
const unsigned long LONG_PRESS_MS = 800;  // これ以上押し続けたら長押し

int lastReading = HIGH;        // 前回読んだ生の値
int stableState = HIGH;        // 確定した状態
unsigned long lastChange = 0;  // 生の値が最後に変わった時刻
unsigned long pressedAt = 0;   // 押下が確定した時刻
bool longFired = false;        // 長押しを通知済みか
bool ledOn = false;
int pressCount = 0;

void setup() {
  Serial.begin(9600);
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  pinMode(LED_PIN, OUTPUT);
}

void loop() {
  unsigned long now = millis();
  int reading = digitalRead(BUTTON_PIN);

  if (reading != lastReading) {
    lastChange = now;  // 値が変わったら計り直し
  }
  lastReading = reading;

  // 30ms変化がなく、確定状態と違う → 状態を確定させる
  if (now - lastChange >= DEBOUNCE_MS && reading != stableState) {
    stableState = reading;
    if (stableState == LOW) {   // 押された(確定)
      pressedAt = now;
      longFired = false;
    } else if (!longFired) {    // 離された&長押しではない → 短押し
      pressCount++;
      ledOn = !ledOn;
      digitalWrite(LED_PIN, ledOn ? HIGH : LOW);
      Serial.print("短押し: ");
      Serial.print(pressCount);
      Serial.println("回目(LED切り替え)");
    }
  }

  // 押したまま800ms経ったら、離すのを待たずに長押しを1回だけ実行
  if (stableState == LOW && !longFired && now - pressedAt >= LONG_PRESS_MS) {
    longFired = true;
    pressCount = 0;
    ledOn = false;
    digitalWrite(LED_PIN, LOW);
    Serial.println("長押し: カウントをリセットしました");
  }

  // delay()を使っていないので、ここに他の処理を書いても止まらない
}

短押しでLEDが切り替わり、長押し(800ms)でカウントがリセットされます。シリアルモニタの表示例はこんな感じです。

短押し: 1回目(LED切り替え)
短押し: 2回目(LED切り替え)
短押し: 3回目(LED切り替え)
長押し: カウントをリセットしました
短押し: 1回目(LED切り替え)

ちなみに、短押しを「押したとき」ではなく「離したとき」に判定しているのがポイントです。押した瞬間に短押しとして処理すると、そのあと長押しになったときに両方が実行されてしまいます。長押しのほうは、離すのを待たずに800ms経った時点で1回だけ実行しています。

⚠️ 時刻の変数は必ずunsigned longにしてください。millis()は約49.7日で0に戻りますが、now - lastChangeのように「引き算で経過時間を比べる」書き方なら、0に戻っても正しく計算できます。intにすると約32秒であふれてしまいます。

30msで足りないスイッチ(古いものや安価なもの)は、DEBOUNCE_MSを50ms程度に増やしてみてください。

Pythonで判定ロジックを確かめる

「本当にこれで跳ね返りを吸収できるの?」と気になりますよね。実機だとチャタリングは速すぎて目で見えないので、同じロジックをPythonに移して、チャタリング入りの信号で試してみます。1msごとのピンの状態をリストで作り、for文の番号をmillis()の代わりに使っています。

# ボタン信号のチャタリングを再現して、Arduinoと同じ判定ロジックを試すシミュレーション
import random
import matplotlib.pyplot as plt

random.seed(1)  # 毎回同じ結果になるように固定

HIGH, LOW = 1, 0
DEBOUNCE_MS = 30      # この時間、値が変わらなければ「確定」とみなす
LONG_PRESS_MS = 800   # これ以上押し続けたら「長押し」


def make_signal(total_ms, presses):
    """1msごとのピン状態を作る(INPUT_PULLUPなので押すとLOW)"""
    sig = [HIGH] * total_ms
    for start, end in presses:
        for t in range(start, end):
            sig[t] = LOW
        # 押した直後と離した直後の数msだけ、値がバタつく(チャタリング)
        for edge in (start, end):
            for t in range(edge, edge + 5):
                sig[t] = random.choice([HIGH, LOW])
    return sig


# 1回目:200msの短押し 2回目:1000msの長押し
signal = make_signal(2000, [(100, 300), (600, 1600)])

# --- ① そのまま読む:HIGH→LOW の変化を全部「押された」と数える ---
naive_count = 0
prev = HIGH
for t, v in enumerate(signal):
    if prev == HIGH and v == LOW:
        naive_count += 1
    prev = v
print(f"そのまま数えた回数: {naive_count}回")

# --- ② デバウンス+短押し・長押し判定(Arduinoのloop()と同じ考え方) ---
last_reading = HIGH     # 前回読んだ生の値
stable_state = HIGH     # 確定した状態
last_change = 0         # 生の値が最後に変わった時刻
pressed_at = 0          # 押下が確定した時刻
long_fired = False      # 長押しを通知済みか
debounced = []          # グラフ用

for now, reading in enumerate(signal):          # now が millis() の代わり
    if reading != last_reading:
        last_change = now                       # 値が変わったら計り直し
    last_reading = reading

    if now - last_change >= DEBOUNCE_MS and reading != stable_state:
        stable_state = reading
        if stable_state == LOW:
            pressed_at = now
            long_fired = False
        elif not long_fired:
            print(f"{now:4d}ms 短押し({now - pressed_at}ms)")

    if stable_state == LOW and not long_fired and now - pressed_at >= LONG_PRESS_MS:
        long_fired = True
        print(f"{now:4d}ms 長押し")
    debounced.append(stable_state)

# --- ③ 押した瞬間(100ms付近)をグラフで比べる ---
plt.rcParams["font.family"] = "Meiryo"
fig, axes = plt.subplots(2, 1, figsize=(9, 4.5), sharex=True)
rng = range(80, 140)
axes[0].step(list(rng), [signal[t] for t in rng], where="post", color="tab:red")
axes[0].set_title("押した瞬間の生の信号(チャタリングあり)")
axes[1].step(list(rng), [debounced[t] for t in rng], where="post", color="tab:blue")
axes[1].set_title("デバウンス後(30ms 変化なしで確定)")
for ax in axes:
    ax.set_yticks([0, 1], ["LOW(押した)", "HIGH"])
    ax.set_ylim(-0.3, 1.3)
axes[1].set_xlabel("時間(ms)")
plt.tight_layout()
plt.show()
実行結果:チャタリング入りの信号で判定ロジックを確認
実行結果:チャタリング入りの信号で判定ロジックを確認
実行結果:押した瞬間の生の信号とデバウンス後の信号
実行結果:押した瞬間の生の信号とデバウンス後の信号

シミュレーションしているのは、200msの短押しと1000msの長押し、つまり2回しか押していない信号です。これ、実行せずに「そのまま数えた回数」がいくつになるか予想できますか?

実行すると「そのまま数えた回数: 6回」「333ms 短押し(199ms)」「1435ms 長押し」と表示されました(コードのすぐ下の実行結果とグラフを参照)。

  • 対策なしだと、押したときと離したときの跳ね返りを拾って2回の押下が6回に数えられた
  • デバウンス後は、短押し1回・長押し1回だけを正しく判定できた
  • 短押しが199msと表示されるのは、押下も離しも「30ms変化なし」を待ってから確定させているため。そのぶん反応は少し遅れますが、人の感覚では気にならない程度です

グラフを見ると、上の生の信号は押した直後にバタついていますが、下のデバウンス後の信号はきれいに1回だけ切り替わっているのがわかります。

まとめ

  • チャタリングは、スイッチの接点が跳ね返って信号が数msバタつく現象
  • delay()で待つ方法は簡単だが、待っているあいだ他の処理が止まる
  • millis()で「一定時間変わらなければ確定」とすれば、止まらずにデバウンスできる
  • 確定した時刻を使えば、短押し(離したときに判定)と長押し(押している最中に1回だけ)も区別できる
  • 時刻の変数はunsigned long、経過時間は引き算で比べる

ボタン1つで「短押し」「長押し」の2つの操作ができると、作品の幅がぐっと広がります。手元のスイッチでDEBOUNCE_MSを変えながら、ぜひ試してみてください🚀

📡 Arduinoをもっと深く学ぼう!

Arduino・ラズパイ・ロボットプログラミングを体系的に学びたい方へ。おすすめのUdemyコースや電子部品もまとめています。

Arduinoロボット入門のおすすめコース・部品を見る →

📚 関連商品・おすすめ書籍

スッキリわかるPython入門 第2版 (スッキリわかる入門シリーズ)

もしも

スッキリわかるPython入門 第2版 (スッキリわかる入門シリーズ)

初心者に定番のPython入門書

Amazonで見る
Arduinoをはじめよう 第4版 (Make: PROJECTS)

もしも

Arduinoをはじめよう 第4版 (Make: PROJECTS)

Arduino公式推薦の定番入門書

Amazonで見る
徹底攻略! 電子工作&プログラミング Arduinoで学ぶ電子工作完全ガイド

もしも

徹底攻略! 電子工作&プログラミング Arduinoで学ぶ電子工作完全ガイド

電子工作とプログラミングを同時に学べる

Amazonで見る

※本記事にはアフィリエイトリンクが含まれます。

ABOUT ME
やまちゃん
これまで学生と社会人を合わせて5000人以上にプログラミング学習を指導。 ゼロからイチをわかりやすく解説する専門家として活動しており、本業ではArduinoを用いたIoT開発とロボットプログラミングが専門。 Pythonを用いたアプリ開発、ウェブアプリケーションの開発で業務の効率化をサポートしています。