Progress Log: ESP32 GPIO直接レジスタ読み出しの実装確認¶
Task Description¶
kurikintonsで実装されている「GPIOレジスタの直接読み出し」について、以下を中心に整理しました:
- GPIOレジスタ直接アクセスの現状把握 - どのように定義し、どう動作するのか
- メリットとデメリット - 直接アクセスの利点と欠点
- kurikintonsでの設計判断 - なぜこのアプローチを選んだのか
- 初心者向け説明 - わかりやすい説明を目指す
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;
計算の手順:
gpio_in_reg & GPIO_IN_REG_MASK= 必要なビットだけを抽出>> 12= 必要な位置に合わせてビットを右にシフト& 1= 最下位ビット(0か1)だけを取り出す+== 信号カウンターに足す
ビット演算のフロー:
- 入力
gpio_in_reg = 0b_ZZZZZZZZ_YYYYYYYY_XXXXXXXX_WWWWWWWW gpio_in_reg & GPIO_IN_REG_MASK0b_0000Z000_0000Y000_000X0000_00000000>>12: 右に12ビットシフト0b_0000Z000_0000Y000_000X>>19: 右に19ビットシフト0b_0000Z000_0000Y>>27: 右に27ビットシフト0b_0000Z& 1: 最下位のビットを取得X,Y,Zを取得
ポーリングの意味:
100回のループの中で、毎回レジスタを読んでGPIOの状態をカウントしています。
- GPIO12がHIGH(1)なら、signal1 += 1
- GPIO12がLOW(0)なら、signal1 += 0
重要:
signalNの値の大きさは「信号がどのくらい強かったか」ではなく「ループの間にHIGHだった回数」です。
signal1の値は、ループ中でGPIO12がHIGHだった回数signal1が100なら、ループ中のすべてでGPIO12がHIGHだったsignal1が0なら、ループ中に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には以下の特性が必要です。
- リアルタイム性
- 宇宙線は突然やってくる(数秒おきのランダムイベント)
- イベントを逃さずにすぐに検出する必要がある
- タイミング精度
- 3つのチャネルの信号が同時に到達したかを判定する
- わずかな時間差でも判定結果が変わる可能性がある
- 信号が来たかどうかの確認
- ポーリングして、レジスタを繰り返し確認することで、信号が来たかどうかを確認する
- 1回あたりのポーリング速度が、デッドタイムとなる
- メインループの余裕
- 検出だけでなく、センサー(温度・湿度・気圧)も読みたい
- シリアル通信でデータを送信したい
- LEDの制御もしたい
- 検出に時間を取られすぎると他の処理ができない
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();
}
- GPIO直接レジスタ読み出し → 5~10us
- 100回のポーリング(各0.05~0.1us)
- 検出フラグをセット
- 検出時のセンサー(BME280)読み出し → 約30000~40000us
- I2C通信は低速(100kHz程度)のため、各センサーの読み出しに約10msずつかかる
float temp = bme280_read_temperature();→ 〜10000usfloat pres = bme280_read_pressure();// ~10000usfloat humid = bme280_read_humidity();// ~10000usint sensorValue = adc_read_with_channel_filter(...);// ~200us
- シリアル出力 → 約100us
- LED制御 → 約50000us
- 視覚確認用に50000usの
delay(50)
- 視覚確認用に50000usの
- 信号カウンターリセット → 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()への移行を強く推奨します。
理由:
- 性能への影響が無視できるレベル
- 100回ポーリング時の増加:15~40us
- 全体処理時間(検出時)に占める割合:0.01~0.03%
-
LED制御の50ms遅延の方が圧倒的に支配的
-
保守性の大幅な向上
- ハードコードされたレジスタアドレス(
0x3FF4403C)を削除 - GPIO番号を明示的に指定(
GPIO_NUM_12など) -
ESP32のデータシートを参照しなくてもコードが理解できる
-
可読性の向上
- 関数名が処理の意図を表現(
gpio_get_level()は「GPIOのレベルを取得」という意図が明確) -
ビット演算の複雑な計算がなくなる
-
将来の拡張性
- 他のESP32マイコン(ESP32-S3など)への移植が簡単(関数の実装は同じ)
- ハードウェア変更時のコード修正が局所化される
実装方法:
パターン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¶
- [優先]
gpio_get_level()への移行実装 - 直接レジスタアクセスをgpio_get_level()に置き換え cosmic_detector.cppの行57, 94を修正- テストで動作確認(性能に変化がないことを確認)
-
config.hのGPIO_IN_REG_ADDRESSとGPIO_IN_REG_MASKを削除(不要になる) -
移行後のドキュメント更新 -
gpio_get_level()を使った実装にドキュメントを更新 -
性能測定 - 実際のボード上で
gpio_get_level()の性能測定を実施 - 理論値(0.2~0.5us)と実測値の比較
-
全体処理時間への影響確認
-
テスト拡充 - 各チャネルの信号読み取りが正確に動作しているか、ベンチテストで確認
-
デバッグ機能(オプション) - GPIO状態をシリアル出力でデバッグできるヘルパー関数を実装