Skip to content

Progress Log: ESP32 GPIO直接レジスタ読み出しの実装確認

Task Description

kurikintonsで実装されている「GPIOレジスタの直接読み出し」について、以下を中心に整理しました:

  1. GPIOレジスタ直接アクセスの現状把握 - どのように定義し、どう動作するのか
  2. メリットとデメリット - 直接アクセスの利点と欠点
  3. kurikintonsでの設計判断 - なぜこのアプローチを選んだのか
  4. 初心者向け説明 - わかりやすい説明を目指す

Outcome

1. GPIOレジスタ直接アクセスの現状把握

1.1 どのように定義しているのか(現在)

注記(2025-11-20更新):このセクションは現在の実装を説明しています。セクション6.5で、より保守性の高いgpio_get_level()への移行を推奨しています。

kurikintonsでは、GPIO読み出しを高速に行うため、ESP32のハードウェアレジスタに直接アクセスしています。

設定ファイル (config.h:158-159):

// ESP32 GPIO入力レジスタのメモリアドレス
#define GPIO_IN_REG_ADDRESS 0x3FF4403C
// 3つのGPIOピンに対応するビット
#define GPIO_IN_REG_MASK 0b00001000000010000001000000000000
  • GPIO_IN_REG_ADDRESS = メモリ内のどこにGPIOの値が格納されているか
  • GPIO_IN_REG_MASK = その中で、どのビット(0と1の並び)が対象なのか

1.2 マスクとビット抽出の計算

ビットマスク 0b 00001000_00001000_00010000_00000000 の構造:

この32ビットの値は以下のピンに対応しています。

  • ビット12 = GPIO12(検出チャネル1)
  • ビット19 = GPIO19(検出チャネル2)
  • ビット27 = GPIO27(検出チャネル3)

ビット抽出の流れ (cosmic_detector.cpp:61-63):

detector_signal1 += ((gpio_in_reg & GPIO_IN_REG_MASK) >> 12) & 1;
detector_signal2 += ((gpio_in_reg & GPIO_IN_REG_MASK) >> 19) & 1;
detector_signal3 += ((gpio_in_reg & GPIO_IN_REG_MASK) >> 27) & 1;

計算の手順

  1. gpio_in_reg & GPIO_IN_REG_MASK = 必要なビットだけを抽出
  2. >> 12 = 必要な位置に合わせてビットを右にシフト
  3. & 1 = 最下位ビット(0か1)だけを取り出す
  4. += = 信号カウンターに足す

ビット演算のフロー:

  1. 入力 gpio_in_reg = 0b_ZZZZZZZZ_YYYYYYYY_XXXXXXXX_WWWWWWWW
  2. gpio_in_reg & GPIO_IN_REG_MASK
  3. 0b_0000Z000_0000Y000_000X0000_00000000
  4. >>12: 右に12ビットシフト
  5. 0b_0000Z000_0000Y000_000X
  6. >>19: 右に19ビットシフト
  7. 0b_0000Z000_0000Y
  8. >>27: 右に27ビットシフト
  9. 0b_0000Z
  10. & 1: 最下位のビットを取得
  11. X, Y, Z を取得

ポーリングの意味

100回のループの中で、毎回レジスタを読んでGPIOの状態をカウントしています。

  • GPIO12がHIGH(1)なら、signal1 += 1
  • GPIO12がLOW(0)なら、signal1 += 0

重要signalNの値の大きさは「信号がどのくらい強かったか」ではなく「ループの間にHIGHだった回数」です。

  • signal1の値は、ループ中でGPIO12HIGH だった回数
  • signal1100なら、ループ中のすべてでGPIO12HIGHだった
  • signal10なら、ループ中にGPIO12が一度もHIGHにならなかった → イベントなし(signal1に接続したコンパレーターのスレッショルドを超えた信号がなかった)

2. 直接アクセスのメリット

  • 高速
  • Arduino APIを経由しない分、処理が超高速
  • kurikintonsでの効果:100回のポーリングが5~10usで完了
  • API(digitalRead())を使うと100~150us(予想)かかる
  • 同時サンプリング
  • 複数のピンを同時に読める
  • kurikintonsでの効果:3チャネルを完全に同期して読み取り可能
  • 予測可能
  • CPUの最小限の処理だけなので、実行時間の揺らぎが小さい
  • kurikintonsでの効果:タイミング精度が高い
  • リアルタイム性
  • 宇宙線検出のような高速イベントに対応できる
  • kurikintonsでの効果:検出遅延を最小化

3. 直接アクセスのデメリット

  • ハードウェア依存
  • ESP32固有のレジスタアドレス(0x3FF4403C)に直結
  • kurikintonsでの影響:ArduinoやSTM32など他のマイコンへの移植が困難
    • 他のマイコンはまったく異なるレジスタアドレス
    • 「アドレスを変更すればOK」ではなく、レジスタの仕様そのものが異なる可能性もある
  • 保守性が低い
  • レジスタ値の意味がコードから読み取りにくい
  • kurikintonsでの影響:ESP32のデータシートを見ないと理解できない
  • 互換性が脆い
  • ESP32のリビジョン変更で動作保証ができない
  • kurikintonsでの影響:現在のESP32-WROOM-32Eでのみ動作保証
  • デバッグが難しい
  • 普通のデバッガーでは値の意味が分かりにくい
  • kurikintonsでの影響:ロジアナやオシロスコープで直接確認が必要
  • タイミングが変わる可能性
  • CPUキャッシュやパイプラインの影響を受ける
  • kurikintonsでの影響:理論値と実測値がズレる可能性がある

4. kurikintonsの場合:なぜ直接アクセスを選んだのか

4.1 宇宙線検出に必要な要求

OSECHIは「宇宙線検出器」です。 kurikintonsには以下の特性が必要です。

  1. リアルタイム性
  2. 宇宙線は突然やってくる(数秒おきのランダムイベント)
  3. イベントを逃さずにすぐに検出する必要がある
  4. タイミング精度
  5. 3つのチャネルの信号が同時に到達したかを判定する
  6. わずかな時間差でも判定結果が変わる可能性がある
  7. 信号が来たかどうかの確認
  8. ポーリングして、レジスタを繰り返し確認することで、信号が来たかどうかを確認する
  9. 1回あたりのポーリング速度が、デッドタイムとなる
  10. メインループの余裕
  11. 検出だけでなく、センサー(温度・湿度・気圧)も読みたい
  12. シリアル通信でデータを送信したい
  13. LEDの制御もしたい
  14. 検出に時間を取られすぎると他の処理ができない

4.2 直接アクセスを選ぶ理由

  • digitalRead()を使った場合
  • 1回のポーリング:1.0~1.5us
  • 100回:100~150us
  • 直接レジスタアクセスの場合
  • 1回のポーリング:0.05~0.1us
  • 100回:5~10us

差は約15倍~30倍。この時間の差がシリアル通信やセンサー読み出しを処理する余裕になります。

4.3 設計判断

kurikintonsは以下を優先しています。

  • 優先順位1位:リアルタイム性(宇宙線を逃さない)
  • 優先順位2位:タイミング精度(同時検出の判定)
  • 優先順位3位:処理余裕(他の機能を並行実行)
犠牲にしたもの
  • 移植性(他マイコンへの対応)
  • 保守性(誰でも理解できるコード)
  • 互換性(ESP32-WROOM-32E固定)
判断の妥当性

このプロジェクトは「単一マイコン・単一用途・科学機器」という特性があるため、デメリットを受け入れることが合理的です。


5. 読み出しフロー:宇宙線検出時の処理

メインループの流れ (main.cpp:143-193)

// 1. GPIO直接レジスタ読み出し -> 5~10us
cosmic_detection_t detection = cosmic_detector_read();

// 検出時のセンサー読み出し -> 合計で約30~40ms
if (detection.detected) {
  float temp = bme280_read_temperature();
  float pres = bme280_read_pressure();
  float humid = bme280_read_humidity();

  int sensorValue = adc_read_with_channel_filter(
    detection.signal1,
    detection.signal2,
    detection.signal3
  );

  // 3. シリアル出力 -> 約100us
  Serial.print(detection.signal1);
  Serial.print(" ");
  Serial.print(detection.signal2);
  Serial.print(" ");
  Serial.print(detection.signal3);
  Serial.print(" ");
  Serial.print(sensorValue);
  Serial.print(" ");
  Serial.print(temp);
  Serial.print(" ");
  Serial.print(pres);
  Serial.print(" ");
  Serial.println(humid);

  // 4. LED制御 -> 約50ms
  cosmic_detector_led_feedback(detection.signal1, detection.signal2, detection.signal3);
  delay(50);
  cosmic_detector_led_off();

  // 5. リセット -> 1~2us
  adc_reset_pin_mode();
}
  1. GPIO直接レジスタ読み出し → 5~10us
    • 100回のポーリング(各0.05~0.1us)
    • 検出フラグをセット
  2. 検出時のセンサー(BME280)読み出し → 約30000~40000us
    • I2C通信は低速(100kHz程度)のため、各センサーの読み出しに約10msずつかかる
    • float temp = bme280_read_temperature(); → 〜10000us
    • float pres = bme280_read_pressure(); // ~10000us
    • float humid = bme280_read_humidity(); // ~10000us
    • int sensorValue = adc_read_with_channel_filter(...); // ~200us
  3. シリアル出力 → 約100us
  4. LED制御 → 約50000us
    • 視覚確認用に50000usのdelay(50)
  5. 信号カウンターリセット → 1~2us

処理時間の概要

  • 検出なし:5~10us
  • 検出あり:50000~150000us(LED制御の50ms遅延が支配的)

6. デメリット改善の検討:HAL化(Hardware Abstraction Layer)

6.1 HALとは何か

HAL(Hardware Abstraction Layer) は、ハードウェア固有の操作を抽象化する中間層です。

直接アクセスの場合

uint32_t gpio_in_reg = *((uint32_t*)0x3FF4403C);  // ESP32固有のアドレス

HALの場合

#include "driver/gpio.h"

// 方法1: GPIO_NUM_Xの定数を直接使用(推奨)
int signal1 = gpio_get_level(GPIO_NUM_12);  // 型安全で可読性が高い
int signal2 = gpio_get_level(GPIO_NUM_19);
int signal3 = gpio_get_level(GPIO_NUM_27);

// 方法2: config.hのマクロをキャストして使用
// #include "config.h"
// int signal1 = gpio_get_level((gpio_num_t)DETECT_PIN1);
// int signal2 = gpio_get_level((gpio_num_t)DETECT_PIN2);
// int signal3 = gpio_get_level((gpio_num_t)DETECT_PIN3);

HALの特徴

  • ESP-IDFの公式API(Espressif提供)
  • ハードウェアレイヤーの実装が変わってもコードは変わらない
  • ドキュメントが充実している
  • GPIO設定機能も統合(入力/出力モード、プルアップ/ダウン設定)

ピン指定の2つの方法の比較

方法1:GPIO_NUM_Xを直接使用
  • 例:gpio_get_level(GPIO_NUM_12)
  • メリット:型安全、可読性が高い、キャスト不要
  • デメリット:ピン番号がハードコードされる
方法2:config.hのマクロをキャストして使用
  • 例:gpio_get_level((gpio_num_t)DETECT_PIN1)
  • メリット:ピン番号を一元管理できる
  • デメリット:キャストが必要、やや冗長

推奨される実装パターン

  • 単発の使用(初期化時など):GPIO_NUM_Xを直接使用
  • 複数箇所で使用config.hのマクロ経由(保守性向上)
  • kurikintonsの場合:ポーリング処理はループ内で複数回実行されるため、マクロ経由の方が保守性が高い

6.2 直接アクセス vs HAL:処理時間比較

読み出し1回あたり

  • 直接レジスタアクセス:0.05~0.1us
  • HAL(gpio_get_level()):0.2~0.5us
  • Arduino API(digitalRead()):1.0~1.5us

100回ポーリングの場合

  • 直接レジスタ:5~10us
  • gpio_get_level()を使った場合:20~50us
  • 追加オーバーヘッド:15~40us(約2~5倍増)

注記gpio_get_level()は関数呼び出しのオーバーヘッドがある(HAL → LL層の3段階)ですが、それでもArduinoのdigitalRead()より約3倍高速です。

6.3 全体処理での影響分析

検出がない場合(毎ループで発生):

  • 現在(直接):5~10us
  • HAL化:20~50us
  • 追加時間:15~40us
  • メインループ全体への影響:数%程度

検出がある場合(イベント時):

  • GPIO読み出し増加:+15~40us
  • 全体処理時間:50000~150000us
  • 増加分の割合:0.01~0.03%(ほぼ無視できるレベル)
  • LED制御(50000us)の方が圧倒的に支配的

6.4 HAL化の評価:妥当性の判断

処理時間への影響

  • 許容範囲内(全体の0.03%未満)
  • LED制御の50msに比べると無視できる
  • 実行時の性能は事実上変わらない

保守性への効果

  • ハードコードされたレジスタアドレス(0x3FF4403C)の削除
  • ESP32のリビジョン変更に対応しやすくなる
  • コードの意図がより明確になる
  • 新しい開発者でもコードが理解しやすい
  • データシートの参照が不要になる

移植性への効果

  • 他のESP32マイコン(ESP32-S3など)への対応が容易
  • 関数の実装を置き換えるだけで済む
  • 実装難易度は低い(関数呼び出しの置き換え程度)

6.5 HAL化すべきか

YES。gpio_get_level()への移行を強く推奨します。

理由

  1. 性能への影響が無視できるレベル
  2. 100回ポーリング時の増加:15~40us
  3. 全体処理時間(検出時)に占める割合:0.01~0.03%
  4. LED制御の50ms遅延の方が圧倒的に支配的

  5. 保守性の大幅な向上

  6. ハードコードされたレジスタアドレス(0x3FF4403C)を削除
  7. GPIO番号を明示的に指定(GPIO_NUM_12など)
  8. ESP32のデータシートを参照しなくてもコードが理解できる

  9. 可読性の向上

  10. 関数名が処理の意図を表現(gpio_get_level()は「GPIOのレベルを取得」という意図が明確)
  11. ビット演算の複雑な計算がなくなる

  12. 将来の拡張性

  13. 他のESP32マイコン(ESP32-S3など)への移植が簡単(関数の実装は同じ)
  14. ハードウェア変更時のコード修正が局所化される

実装方法

パターン1:GPIO_NUM_X定数を直接使用(シンプル)
// Before(現在)
uint32_t gpio_in_reg = *((uint32_t*)GPIO_IN_REG_ADDRESS);
detector_signal1 += ((gpio_in_reg & GPIO_IN_REG_MASK) >> 12) & 1;
detector_signal2 += ((gpio_in_reg & GPIO_IN_REG_MASK) >> 19) & 1;
detector_signal3 += ((gpio_in_reg & GPIO_IN_REG_MASK) >> 27) & 1;

// After(推奨:方法1)
detector_signal1 += gpio_get_level(GPIO_NUM_12);
detector_signal2 += gpio_get_level(GPIO_NUM_19);
detector_signal3 += gpio_get_level(GPIO_NUM_27);
パターン2:config.hマクロを使用(保守的)
// config.hの定義
#define DETECT_PIN1 12
#define DETECT_PIN2 19
#define DETECT_PIN3 27

// cosmic_detector.cppでの使用
#include "driver/gpio.h"
#include "config.h"

detector_signal1 += gpio_get_level((gpio_num_t)DETECT_PIN1);
detector_signal2 += gpio_get_level((gpio_num_t)DETECT_PIN2);
detector_signal3 += gpio_get_level((gpio_num_t)DETECT_PIN3);

実装のメリット

  • コード行数削減:複雑なビット演算(3行)→ シンプルな関数呼び出し(3行)
  • テスト期間は最小限で済む
  • ロールバックも簡単
  • デバッグが容易になる(関数呼び出しスタックが明確)

ピン指定方法の選択

  • パターン1推奨:可読性重視、コード中に意図が明確
  • パターン2推奨:ピン番号を頻繁に変更する場合、保守性重視

Learnings

1. パフォーマンスと実装の選択肢

  • 「高速にしたかったら、低レベルでプログラムする」
  • 「理解しやすくしたかったら、高レベルのAPIを使う」

kurikintonsはこのトレードオフで「低レベル」を選びました。

2. ハードウェア依存性の現実

マイコン開発では、最終的に「ハードウェアは何か」という現実から逃げられません。

  • Arduino では汎用性を重視している
  • kurikintons は特定ハードウェアに最適化している

どちらが「正解」というわけではなく、プロジェクトの目的に応じた選択です。

3. デバッグの観点

直接レジスタアクセスは「ブラックボックス化しやすい」という課題があります。

  • レジスタ値を見ても「何を意味するのか」が不明
  • Arduino API なら関数名が意図を表現

保守性を高めるには:

  • コード内のコメント(// GPIO12のビット位置など)が重要
  • デバッグ用のヘルパー関数を用意する
  • データシートを参照しやすいドキュメント

4. 「要件によって実装が決まる」

このプロジェクトから学べる重要な原則:

要件がなければ、API を使って読みやすさを優先する 要件があれば、最適化の余地を探す 要件があり、性能が生死を分けるなら、低レベルにも手を出す

kurikintons の場合、「リアルタイム性」が要件だったため、この選択になりました。


Next Steps

  1. [優先] gpio_get_level()への移行実装 - 直接レジスタアクセスをgpio_get_level()に置き換え
  2. cosmic_detector.cppの行57, 94を修正
  3. テストで動作確認(性能に変化がないことを確認)
  4. config.hGPIO_IN_REG_ADDRESSGPIO_IN_REG_MASKを削除(不要になる)

  5. 移行後のドキュメント更新 - gpio_get_level()を使った実装にドキュメントを更新

  6. 性能測定 - 実際のボード上でgpio_get_level()の性能測定を実施

  7. 理論値(0.2~0.5us)と実測値の比較
  8. 全体処理時間への影響確認

  9. テスト拡充 - 各チャネルの信号読み取りが正確に動作しているか、ベンチテストで確認

  10. デバッグ機能(オプション) - GPIO状態をシリアル出力でデバッグできるヘルパー関数を実装