Skip to content
Published on

FingerScore ハードウェア 3 — MCU ファームウェアとジェスチャー入力処理

Authors

はじめに — 指一本でスコアを上げる

このシリーズは、ラケットスポーツ(テニス、バドミントン、スカッシュ、卓球)のスコアを指のジェスチャーで記録する BLE リングデバイス FingerScore を自分の手で作ってみる学習の旅です。第1回では全体のシステム構成と部品選定を、第2回では PCB と電源設計を扱いました。今回の第3回は、それらすべてのハードウェアに命を吹き込む部分、すなわち MCU ファームウェアとジェスチャー入力処理です。

一見すると FingerScore のやることは単純です。ユーザーが得点したらリングをはめた指を軽く動かすかボタンを押し、スコアが1点上がり、その値を BLE でスマートフォンに送ります。しかしこの単純な動作をコインセル電池一つで数週間もたせるには、ファームウェアがほとんどの時間眠っている必要があります。「必要なときだけ目覚める」というイベント駆動の設計が、この記事の中心的なテーマです。

この記事は組み込みを初めて触れる方も追えるよう概念から丁寧に説明しますが、コードは実際にコンパイルされ動作するレベルを目標とします。すべての C コードはコードブロックの中に収めていますので、そのまま書き写しながら読んでも構いません。

MCU ファームウェアの全体像

MCU(Micro Controller Unit)のファームウェアは、たいてい似た骨格を持ちます。電源が入ると一度だけ実行される初期化フェーズ、その後ずっと回り続けるメインループ、そして外部の出来事に反応する割り込みです。

   電源 ON
     |
     v
 [ 初期化 ]  <- クロック、GPIO、タイマー、BLE スタック、センサー設定
     |
     v
 [ メインループ ] ---> やることがなければ [ スリープ ] へ
     ^                                    |
     |                                    v
     +------ [ 割り込みで目覚める ] <------+
            (ボタン押下 / IMU の動き / タイマー)

伝統的な Arduino スケッチは loop() 関数が休まず回るポーリング方式です。学びやすい一方で CPU が常に起きているため電流を多く消費します。FingerScore のように電池寿命が重要な機器では、メインループがやることを終えたら直ちにスリープへ入り、割り込みが起こしてくれたときだけ働く構造を使います。

ポーリング vs 割り込み vs イベント駆動

方式動作電流応答性適した場面
ポーリングループが状態を確認し続ける高い速い単純なプロトタイプ
割り込み出来事の発生時にハンドラ呼び出し中程度非常に速いボタン、センサー入力
イベント駆動スリープ普段はスリープ、割り込みで目覚める非常に低い速い電池駆動ウェアラブル

FingerScore は3番目の方式を採ります。普段はディープスリープにとどまり、ボタン押下や IMU の動き検出信号が GPIO 割り込みとして入ってくると目覚めて処理し、再び眠ります。

ファームウェアの骨格コード

まず最上位の骨格を見ましょう。この例は ARM Cortex-M 系(例:Nordic nRF52)を想定した擬似コードに近い C ですが、構造そのものはどの MCU でも通用します。

#include <stdint.h>
#include <stdbool.h>

/* グローバルなイベントフラグ。割り込みが set し、メインループが処理する。 */
volatile bool g_button_event = false;
volatile bool g_motion_event = false;

static void system_init(void);
static void enter_low_power_sleep(void);
static void handle_score_increment(void);

int main(void)
{
    system_init();          /* クロック、GPIO、タイマー、BLE、IMU を初期化 */

    for (;;) {              /* ずっと回るメインループ */
        if (g_button_event) {
            g_button_event = false;
            handle_score_increment();
        }
        if (g_motion_event) {
            g_motion_event = false;
            handle_score_increment();
        }

        /* 処理すべきイベントがなければスリープへ。
           割り込みが入るとこの行の次から再び実行される。 */
        enter_low_power_sleep();
    }

    return 0;               /* 実際には到達しない */
}

要点は volatile キーワードです。割り込みハンドラとメインループが同じ変数を共有するとき、コンパイラがその値をレジスタにキャッシュしてメモリを読み直さない最適化を防いでくれます。これを忘れると「ボタンを押したのにメインループが永遠に気づかない」という悪名高いバグが生まれます。

system_init の役割

static void system_init(void)
{
    clock_init();           /* 低電力クロック源を選択 (例: 32.768kHz) */
    gpio_init();            /* ボタンピンを入力 + プルアップ、IMU 割り込みピン設定 */
    timer_init();           /* デバウンス用タイマー */
    ble_stack_init();       /* BLE スタックを有効化、アドバタイズパラメータ設定 */
    imu_init();             /* 加速度計のしきい値、モーション割り込みを有効化 */

    /* 電源が入った直後にランプを一瞬点けて消す起動シグナル */
    led_blink_once();
}

_init() 関数の詳細は MCU の SDK ごとに異なりますが、順序には理由があります。クロックを最初に確立してこそ、タイマーや通信周辺装置が正確な時間基準を持てます。GPIO と割り込みは BLE スタックより先に設定しておくほうが安全です。

GPIO 割り込みでボタンを検出する

ボタンは最も直感的な入力です。押すとピンの電圧が変わり、その変化(エッジ)を MCU が検出して割り込みを発生させます。FingerScore のボタンは片側が GPIO ピン、もう片側が GND に接続され、ピン内部のプルアップ抵抗により普段は HIGH(1)を保ち、押すと LOW(0)へ落ちます。これをプルアップ + アクティブロー構成と呼びます。

   VCC
    |
   [内部プルアップ抵抗]
    |
    +---- GPIO2 (MCU 入力ピン) --- 普段 HIGH
    |
  [ボタン]
    |
   GND   <- 押すと GPIO2 が LOW へ落ちる (falling edge)

falling edge で割り込みが発生するよう設定すれば、ボタンを押した瞬間にハンドラが呼ばれます。

/* GPIO 割り込みハンドラ。
   ハードウェアが falling edge を検出するとこの関数が自動的に呼ばれる。 */
void GPIO_IRQHandler(void)
{
    if (gpio_interrupt_pending(BUTTON_PIN)) {
        gpio_clear_interrupt(BUTTON_PIN);   /* 割り込みフラグをクリア (必須) */

        /* ハンドラの中では重い処理をしない。
           フラグを立てるだけで素早く抜ける。 */
        g_button_event = true;
    }

    if (gpio_interrupt_pending(IMU_INT_PIN)) {
        gpio_clear_interrupt(IMU_INT_PIN);
        g_motion_event = true;
    }
}

割り込みハンドラ(ISR, Interrupt Service Routine)を書く黄金律は「短く速く」です。ISR の中で BLE 送信のような重い処理をすると、その間に他の割り込みが塞がれシステムが不安定になります。そのため ISR はフラグだけを立て、実際の処理はメインループへ委ねます。このパターンはよく「deferred work」または「bottom-half」と呼ばれます。

もう一つ忘れがちなのが gpio_clear_interrupt() で割り込みフラグを空にすることです。これを抜かすと同じ割り込みが延々と再発生し、システムが止まったように見えます。

デバウンス — 一度押したのに何度も数える問題

物理ボタンには誰もが一度は引っかかる落とし穴があります。人の目には一度押されたように見えても、金属の接点が触れる瞬間に微細に弾み、数ミリ秒のあいだ HIGH と LOW を何度も行き来します。これをチャタリング(chattering)またはバウンス(bounce)と呼びます。デバウンスをしないと、一度のクリックがスコアを2点、3点と上げてしまいます。

理想的なボタン:  ----+________________
                     |
実際のボタン:    ----+_|‾|_|‾|________   <- 短く何度も弾む
                     <-- 5~20ms -->

解決策は二つあります。ハードウェアデバウンス(RC フィルタ、シュミットトリガ)とソフトウェアデバウンスです。FingerScore は部品を減らすためソフトウェアデバウンスを使います。最も一般的な方式は「最後の入力から一定時間(例:30ms)が経っていなければ無視する」ことです。

#include <stdint.h>
#include <stdbool.h>

#define DEBOUNCE_MS   30u

extern uint32_t millis(void);   /* 起動後の経過ミリ秒を返す */

static uint32_t s_last_press_ms = 0;

/* メインループから呼ばれるデバウンス判定関数。
   有効な新しい入力なら true、チャタリングなら false を返す。 */
static bool button_debounced_accept(void)
{
    uint32_t now = millis();

    if ((now - s_last_press_ms) < DEBOUNCE_MS) {
        return false;   /* 早すぎる再入力 -> チャタリングとみなし無視 */
    }

    s_last_press_ms = now;
    return true;        /* 有効な入力 */
}

millis() は起動以降の経過ミリ秒を返す関数で、通常はタイマー割り込みで 1ms ごとに増えるカウンタを置きます。now - s_last_press_ms のような符号なし整数の引き算は、カウンタが一周してオーバーフロー(wrap-around)しても正しく動作するよい性質があり、組み込みでよく使う慣用句です。

メインループのボタン処理は、このようにデバウンスを挟み込みます。

if (g_button_event) {
    g_button_event = false;
    if (button_debounced_accept()) {
        handle_score_increment();   /* 有効なクリックだけスコアに反映 */
    }
}

時間ベース vs 安定化ベースのデバウンス

方式原理利点欠点
時間ベース一定時間内の再入力を無視実装が簡単速いダブルクリックを取りこぼす可能性
安定化(サンプリング)N 回連続同じ値のときだけ確定ノイズに強いタイマーポーリングが必要

ウェアラブルなスコア計のように速いダブルクリックが重要でない場合は、時間ベースで十分です。逆にノイズが激しい環境なら、一定周期でピンを複数回読み、連続して同じ値のときだけ認める安定化方式のほうが頑健です。

IMU で指のジェスチャーを認識する

ボタンが最も確実ですが、FingerScore の真の魅力はボタンなしで指を軽く動かすジェスチャーでスコアを上げることです。そのために IMU(Inertial Measurement Unit)センサーを使います。IMU は通常、3軸加速度計(accelerometer)と3軸ジャイロスコープ(gyroscope)を一つのチップに収めています。

  • 加速度計:重力を含む線形加速度(どの方向にどれだけ速く動いているか)を測ります。静止状態でも重力 1g を読みます。
  • ジャイロスコープ:角速度(どれだけ速く回転しているか)を測ります。

指を素早く動かす動作は、短く強い加速度の変化として現れます。最も単純な認識法は「全体の加速度の大きさがしきい値を超えたらジェスチャーとみなす」ことです。3軸の値を二乗和の平方根で合わせると、方向に依らない大きさが得られます。

   大きさ = sqrt(ax^2 + ay^2 + az^2)

   静止:           約 1.0 g (重力のみ)
   軽い揺れ:        1.2 ~ 1.5 g
   意図的な動き:    2.0 g 以上  <- この点をしきい値に

単純なしきい値ステートマシン

単に「しきい値を一度超えた = ジェスチャー」と処理すると、一度の動作が何度も拾われたり、日常的な震えまでスコアに入ってきます。そこでステートマシン(state machine)を置きます。静止状態(IDLE)で大きな加速度に出会うと「動作中(ACTIVE)」へ入り、再び静かになったら(しきい値以下を一定時間維持)一度のジェスチャーとして確定し、IDLE へ戻ります。

   [IDLE] --(大きさ > HIGH_TH)--> [ACTIVE]
      ^                              |
      |   (大きさ < LOW_TH を          |
      |    QUIET_MS の間維持)          |
      +----- ジェスチャー1回確定 <-----+

HIGH しきい値と LOW しきい値を別に置くことをヒステリシス(hysteresis)と呼びます。しきい値が一つだけだと境界付近で信号が出入りするときに状態がやたらにトグルしますが、二つのしきい値の間に「余裕の区間」を置くと安定して一度だけ拾えます。

#include <stdint.h>
#include <stdbool.h>
#include <math.h>

#define HIGH_TH_G    2.0f    /* ジェスチャー開始とみなす加速度の大きさ */
#define LOW_TH_G     1.3f    /* 動作が終わったとみなす加速度の大きさ */
#define QUIET_MS     120u    /* LOW 以下をこの長さ維持で動作終了 */

typedef enum {
    GESTURE_IDLE = 0,
    GESTURE_ACTIVE
} gesture_state_t;

static gesture_state_t s_state = GESTURE_IDLE;
static uint32_t s_quiet_start_ms = 0;

extern uint32_t millis(void);

/* 加速度3軸(g 単位)を受け取り、ジェスチャー1回が完成すると true を返す */
static bool gesture_update(float ax, float ay, float az)
{
    float magnitude = sqrtf(ax * ax + ay * ay + az * az);
    uint32_t now = millis();

    switch (s_state) {
    case GESTURE_IDLE:
        if (magnitude > HIGH_TH_G) {
            s_state = GESTURE_ACTIVE;   /* 動作開始を検出 */
        }
        return false;

    case GESTURE_ACTIVE:
        if (magnitude > LOW_TH_G) {
            /* まだ動いている -> 静かになったタイマーをリセット */
            s_quiet_start_ms = now;
            return false;
        }
        /* LOW 以下に落ちた状態。十分長く静かだったか? */
        if ((now - s_quiet_start_ms) >= QUIET_MS) {
            s_state = GESTURE_IDLE;     /* ジェスチャー1回完成、再び待機 */
            return true;
        }
        return false;

    default:
        s_state = GESTURE_IDLE;
        return false;
    }
}

この関数はメインループで IMU データが新たに入ってくるたびに呼ばれます。戻り値が true なら意図されたジェスチャー一回とみなしてスコアを上げます。しきい値と QUIET_MS は、実際に手にはめて振りながら調整すべき値です。最初から完璧な数字はないので、ロギングで実際の加速度の大きさを出力しながらチューニングする過程が必ず必要です。

IMU のモーションウェイクアップ機能

多くの低電力 IMU(例:ST LSM6DSO、Bosch BMI270)は、チップ内部で「一定の加速度以上を検出すると割り込みピンをトグルする」ハードウェア機能を内蔵しています。これを活用すれば MCU はディープスリープにとどまり、意味のある動きがあったときだけ目覚めます。つまり MCU が自分で加速度をポーリングしなくてよくなり、電力を大きく節約できます。imu_init() でこのしきい値と割り込みを設定しておくのはそのためです。

タッチ/フレックスセンサーという選択肢

IMU ジェスチャーが負担なら、より単純な入力方式もあります。

センサー原理利点欠点
タクトボタン機械式接点確かなクリック感、安価チャタリング、摩耗
静電容量タッチ指の静電容量変化可動部がない汗/手袋に敏感
フレックスセンサー曲がると抵抗変化指の曲げを検出かさばる、耐久性

静電容量タッチは多くの MCU が内蔵周辺装置として対応しておりピン一つで実装でき、可動部がないので耐久性に優れます。ただし汗が多い、手袋をしている状態では認識が揺らぐことがあります。フレックスセンサーは指を曲げる動作そのものを直接読めますが、リングのフォームファクタに収めるにはかさばります。FingerScore の一次プロトタイプはタクトボタンで動作を検証し、二次で IMU ジェスチャーを加える段階的なアプローチをおすすめします。

イベントからスコア加算へ

入力をどう受けても、結局は「一度の有効入力 = スコア +1 = BLE 送信」に収束します。この流れを一つの関数にまとめておきます。

#include <stdint.h>

static uint16_t s_score = 0;

extern void ble_send_score(uint16_t score);
extern void led_blink_once(void);

/* 有効な入力(ボタンまたはジェスチャー)が確定したときに呼ばれる。 */
static void handle_score_increment(void)
{
    s_score++;                  /* スコアを1増やす */

    led_blink_once();           /* 入力を認識したことをユーザーへフィードバック */
    ble_send_score(s_score);    /* 現在のスコアをスマートフォンへ送信 */
}

ここで BLE 送信が ISR ではなくメインループの文脈で起こる点が重要です。先ほど ISR ではフラグだけを立てたので、実際の重い無線送信は安全なメインループで行われます。スコアを 0 に戻すリセットや、片方が一ゲームを勝ったときの処理などは次の段階のロジックですが、最小の単位はこの一行の加算です。

全体をメインループに合わせると、次のようになります。

for (;;) {
    if (g_button_event) {
        g_button_event = false;
        if (button_debounced_accept()) {
            handle_score_increment();
        }
    }

    if (g_motion_event) {
        g_motion_event = false;
        float ax, ay, az;
        imu_read_accel(&ax, &ay, &az);     /* 最新の加速度を読む */
        if (gesture_update(ax, ay, az)) {
            handle_score_increment();
        }
    }

    enter_low_power_sleep();   /* 仕事終わり、再びスリープ */
}

低電力設計 — 眠る技術

ウェアラブルでは電力がそのまま使いやすさです。毎日充電が必要なスコアリングは誰もはめません。低電力設計の核心は「CPU が起きている時間を最小化する」ことです。

  アクティブ:      数 mA ~ 十数 mA  (CPU + 無線動作)
  アイドル:        数百 uA
  ディープスリープ: 1 uA 前後       <- 普段とどまる状態

単位に注目してください。ディープスリープのマイクロアンペア(uA)はアクティブのミリアンペア(mA)よりおよそ千倍小さいです。一日のうち起きている時間が 1 パーセントでも、電池寿命は劇的に変わります。

enter_low_power_sleep() の内部は MCU ごとに異なりますが概念は同じです。CPU コアを止め、不要な周辺装置のクロックを切り、割り込みを受ける回路だけを生かしておきます。

static void enter_low_power_sleep(void)
{
    /* 処理待ちのイベントがあれば眠らない (競合状態を防ぐ)。 */
    if (g_button_event || g_motion_event) {
        return;
    }

    disable_unused_peripherals();   /* 不要なクロック/電源を遮断 */

    /* WFI = Wait For Interrupt. CPU を止めて割り込みを待つ。
       割り込みが入るとこの行の次から再び目覚める。 */
    __asm volatile ("wfi");
}

WFI(Wait For Interrupt)は ARM Cortex-M の命令で、CPU を低電力状態に置き、割り込みが来るまで止めます。進入の直前にもう一度イベントフラグを確認する理由は競合状態(race condition)です。フラグ確認とスリープ進入の間に割り込みが割り込むと、ともすれば目覚める理由を取りこぼして永遠に眠りかねません。この確認がその隙を塞ぎます。

ファームウェア開発環境を整える

コードを書くのと同じくらい環境が重要です。組み込みを始めるとき最も途方に暮れる部分でもあります。

  • SDK / ツールチェーン:MCU メーカーが提供する SDK を使います。Nordic nRF52 なら nRF Connect SDK(Zephyr ベース)、Espressif ESP32 なら ESP-IDF が標準です。コンパイラは通常 GCC(arm-none-eabi-gcc)です。
  • デバッガ / プログラマ:コードをチップに焼き、ステップ実行するにはハードウェアデバッガが必要です。SEGGER J-Link、ST-Link、あるいはボード内蔵のデバッグプローブを使います。GDB でブレークポイントを置き、変数をのぞき見ます。
  • ロギング:組み込みの printf は通常 UART(シリアル)や RTT(Real-Time Transfer)で PC にメッセージを送ります。「ここまで実行された」という一行のログがデバッグの半分です。ただしログ出力自体が電流を食うので、量産ファームウェアでは減らすか切ります。

RTOS を使うか、ベアメタルで行くか

方式特徴適した場合
ベアメタルRTOS なしのメインループ + 割り込み小さいファームウェア、学習段階
RTOS(FreeRTOS など)タスクスケジューリング、同期ツール同時作業が多く複雑なとき

FingerScore の一次ファームウェア程度なら、ベアメタル(メインループ + 割り込み)で十分です。ただし BLE スタックが内部的に RTOS を要求する SDK(例:Zephyr)では、自然とタスクの上で書くことになります。どちらであれ、この記事で扱った割り込み・デバウンス・ステートマシンの概念はそのまま適用されます。

テスト — 手で振ってみる前に

ファームウェアのテストは PC ソフトウェアより厄介です。チップの上で回るコードをどう検証するのでしょうか。

  • ユニットテスト:デバウンス関数やジェスチャーステートマシンのようにハードウェアと無関係な純粋ロジックは PC でテストできます。millis() のような関数をモック関数に差し替えて時間を自在に操れば、「30ms 以内に二度入った入力は一度だけ認められるか」を自動で検証できます。
  • HIL(Hardware-in-the-Loop):実際のハードウェアをテスト装置につなぎ、信号を流して結果を確認する方式です。たとえばボタンピンに定めたパルスを与え、BLE でスコアが正しく出ていくかを自動測定します。本格的な量産段階で導入します。

学習段階では、純粋ロジックのユニットテストだけでも大きな助けになります。ステートマシンの境界値(しきい値のすぐ上/下、静かな時間の直前/直後)を表に整理してテストケースにすれば、手で振ってみる前に論理エラーをほぼ全部つかめます。

よくある落とし穴

組み込み入門者がよく陥る落とし穴を集めました。

  • スイッチのチャタリング:デバウンスを抜かすと一度のクリックが複数スコアとして拾われます。最もありふれ、最初に疑うべき問題です。
  • 割り込みフラグの未クリア:ISR でフラグを空にしないと同じ割り込みが延々と再発生し、システムが止まったように見えます。
  • volatile の抜け:割り込みとメインループが共有する変数に volatile を抜かすと、最適化のせいでイベントを永久に取りこぼします。
  • リーク電流(current leakage):入力ピンをプルアップ/プルダウンなしで浮いた(floating)状態にすると、ピン電圧が漂って微細な電流が漏れ、スリープ電流が跳ね上がります。使わないピンも明確な状態に縛っておく必要があります。
  • ISR での重い処理:ハンドラの中で BLE 送信や長い演算をすると他の割り込みが塞がれ、システムの応答性が崩れます。フラグだけ立てて抜けましょう。
  • スリープ直前の競合状態:イベント確認とスリープ進入の隙で割り込みを取りこぼすと永遠に眠ります。進入直前の再確認で防ぎます。

おわりに

今回の記事では FingerScore の頭脳である MCU ファームウェアを扱いました。初期化・メインループ・割り込み・スリープという骨格、GPIO 割り込みでボタンを検出する方法、チャタリングをつかむソフトウェアデバウンス、IMU のしきい値とヒステリシスを活用したジェスチャーステートマシン、そしてイベントからスコア加算へつながる流れまでを、コンパイル可能なレベルの C コードとともに見てきました。核心の哲学はただ一つ、「普段は眠り、意味のある出来事にだけ目覚める」というイベント駆動の低電力設計です。

次の第4回では、ここで作ったスコアの値を実際にスマートフォンまで届ける BLE 通信を深く扱います。GATT サービスとキャラクタリスティックの設計、アドバタイズと接続パラメータ、そして無線区間での電力最適化まで、ファームウェアが外の世界と対話する方法を続けて解きほぐしていきます。

参考資料