Episode 28 <ベアメタルシリーズ> Raspberry Pi Picoで超音波距離センサーを使ってみた

ブログ

皆さんこんにちは、グレキチです。
気がついたら、2026年も2月に入ってました。まだまだ寒い日が続きますが、今週末が多分寒さのピークになるんじゃ無かろうかと思っています。そう言えば、2/3の節分が終わって立春の節気になりましたね。春までもうしばらくです。あー待ち遠しい😙

さて今回の内容ですが、以前から気になっていた超音波距離センサーを使ってみることにしたので、その実施記録をここに残しておきたいと思います。今回もベースマイコンはラズパイPicoを使用します。と言うか、今考えているプロジェクトの関係で、しばらくはラズパイPicoを集中して使うことになると思います。
また、使用した超音波距離センサーは、組み込み開発界隈では広く普及してるHC-SR04です。安価で入手性が良かったので、とりあえず買ってみて使ったという流れです。
それでは詳しく説明していきます。

超音波距離センサーについて

今回使用したHC-SR04の外観とスペックは、以下の通りです。

デバイス名HC-SR04
動作電圧 DC 3 〜 5.5 V (※推測値)
動作電流2.2 mA
動作周波数40 Hz
計測距離範囲2 〜 450 cm
計測可能角度15 °
通信方式GPIO、UART、I2C、1-Wire
トリガー入力信号10μs(min.) TTLパルス
エコー出力信号TTLレベル信号(距離比例)
動作温度-10 〜 70 ℃
外観サイズ(W×H×D)45 × 20 × 15 mm
外観確認

まずは外観を見ていきましょう。
デバイスを正面から見た外観は人の目のように円筒が2つ設置されていて、ここがセンサーになっており、写真の向かって左側がトリガー部、右側がレシーバー部です。よく見ると、センサー下部に“T”と“R”の文字が記載されているので、そこからもどちらが何かは判断がつくと思います。距離測定の原理としては、トリガー部から発せられた超音波の跳ね返りをレシーバー部で検出するまでの時間を計測して、その計測時間を使った計算から距離を算出するようでした。厳密には通信手段によって異なると思いますが、ざっくりとはこんな感じのようです。
センサーから出ているピンは、Vcc、Trig、Echo そして Gnd(※表記のまま)の4本です。TrigとEchoは今回初めてお目にかかるピン名でしたが、何を意味しているのかはおいおい分かりますので、このまま読み進めて下さい😀
このセンサーはコンパチ品やコピー品が乱立しているようなので、どれがオリジナル品か判断がつきにくいですが、調査の結果、Elecfreaksという深圳(中国)のメーカーのものがオリジナルのようでした。私が使用したのは、背面に設置されているICおよび抵抗などの配置から考えると、どうやらRCWL(正式にはShenzhen Ruichuangwei Electronic Technology:深圳)社のものであると推測されました。
製品購入時マニュアルなどは添付されておらずデバイスのみの納入だったので、いつもの如くコンパチ品などのマニュアルをネットでググってダウンロードし、ドキュメント内容を確認して使い方などを把握しました。そこで入手した内容から上記のスペック表を作成してます☺️

スペック

次にスペックについて見ていきましょう。
まず動作電圧ですが、オリジナル品は5V専用のようでしたが、今回のRCWL品は改良版のため、3Vから動作できるようになっていたので、ラズパイPicoでも昇圧不要で3V3で使用可能です。また動作電流もオリジナル品は15mAだったのですが、今回のものは標準値で2.2mAとかなりの低消費電力に抑えられています。
測定可能範囲は2〜450cmなのですが、最大距離が450cmと筐体サイズの割に意外と長い距離まで測れるようでちょっと驚きでした😮
それともう一つ驚いたのは、通信手段が何と、GPIO、UART、I2Cおよび1-Wireと4つも使えることでした。(背面部を見ると、右側に文字で印刷されています)
それで、今回の通信手段はというと、過去の投稿記事の通りI2CとUARTはすでに何度もトライした通信方法であったこと、1-WireはGPIOとほぼ同じ操作方法、ということで、今回は最もシンプルなGPIOで制御する方法でトライしてみました。

距離測定方法

ここでは、GPIOでの測定方法について詳しく見ていきます。
GPIO方式では、設けられた4ピン全てを使います。このセクションの最初でも説明した通り、測定原理は、センサーから超音波を発して、超音波が対象物に当たって跳ね返ってセンサーに戻ってくるまでの時間を計測することを用いて行います。
Trigピンへパルスを入力してからEchoピンにパルスが発生するまでの具体的なシーケンスを図にすると以下のようになります。

出典:Arduino Facileサイト資料

Trigピンを規定時間(10μs)以上Highにすると、トリガー部センサーから40kHzで8サイクルの超音波バーストが発射されます。超音波バーストの発射後、Echoピンが自動的にHighになります。その後、測定対象物に衝突して跳ね返ってくる超音波をレシーバー部センサーが検出するまでHigh状態を維持し、検出されるとEchoピンがLowに切り替わります。よって、EchoピンがHighを維持していた時間が計測時間(この値は往復時間なので、計算上では2で割る必要がある)として扱われ、その値と超音波速度の値を使って、計算により距離を算出する流れです。
具体的な計算式は下記の通りです。(※これは極めて一般的な式で、ドキュメントによってはもっと簡略化されて記載されているものもあります)

Distance = T × C / 2

T: EchoがHighレベルを維持した時間
C: 超音波速度(=電磁波速度) 約340 m/sec

とても簡単な計算式です☺️
したがって、この計算式を介して、結果を数値で返すようなプログラムを作成することになります。

デバイスの説明内容は以上です。

回路図と配線図

センサーでの計測から得られた距離の値を、小型ディスプレイに表示するような回路構成を今回考えました。小型ディスプレイと言えば、そう、前回の記事で紹介したAE-AQM0802をここでも流用しました。(成果物が段々と積み上がって来てます😆)

しかし、ここでまたしてもAQMディスプレイでトラブルが発生しまして・・・、急遽プルアップ抵抗をSDAピンおよびSCLピンに外付けすることになりました💦 今回も原因分析にちょっと時間を費やしました。詳しい説明はここでは割愛しますが、原因として考えられたのは、追加機能として設けられていたPCA9515による静電容量のために、SCLピンがプルアップ出来ていなかったということのようでした。
前回のI2C通信トライの際は、並列接続していたSHT31の内部プルアップ抵抗がそれを無害化していたようで、問題無かったようでした。
なかなか奥が深いですね〜🧐 でも、これでまた一つレベルが上がりました!(と、思いたい😆)

色々書きましたが、以上を踏まえた結果、作成した回路図が以下になります。

AQM用のI2C SDAとSCLは前回同様にGPIO4とGPIO5を、LED制御とリセットには各々GPIO10とGPIO11を使用しました。また、HC-SR04のEchoとTrigにはGPIO14とGPIO15を使用しました。

それと、配線図は以下の通りです。

プログラム内容

今回のプログラムでは、超音波距離センサーによる計測時間が主要変数となるため、時間関連のレジスタを制御する必要があります。時間関連と言えば、システムクロック周波数やI2C周波数の設定などで既にCLK_SYSPLL_SYSなどを使っていますが、これらとは別に、今回から新しくタイマーに関係するTIMER_BASEレジスタとWATCHDOG_TICKレジスタというものを使います。

RP2040公式データシートの“4.6. Timer”の概要説明には、下記のように記述されています。
RP2040 のシステムタイマーペリフェラルは、システムにグローバルなマイクロ秒単位のタイムベースを提供し、このタイムベースに基づいて割り込みを生成します
以下の機能をサポートします。
1マイクロ秒ごとに1回インクリメントする64ビットカウンタ
・このカウンタは、ペア(2つ)のラッチレジスタから読み出すことができ、32ビットバスを介して競合のない読み出しが可能です。
• 4つのアラーム:カウンタの下位32ビットが一致した場合、IRQ発生。
タイマーは、ウォッチドッグで生成される1マイクロ秒単位の基準クロックを使用します。この基準クロックは、通常、水晶発振器に直接接続されるリファレンスクロック(clk_ref)から生成されます。


一方、“4.7.2. Tick generation”(Watchdog)の説明は、下記の通りです。
ウォッチドッグ基準クロック clk_tickclk_ref から駆動されます。理想的には、clk_ref は、正確な基準クロックを提供できるように、水晶発振器を使用するように構成されます基準クロックは内部で分周され、ウォッチドッグ ティックとして使用するティック (公称 1μs) を生成します。ティックは TICK レジスタを使用して設定されます。

言わずもがな、赤マーカーの部分が重要ポイントです。
これらを踏まえて、dist_sensor_init(ペリフェラル初期化用)とget_distance(測距用)の2つの関数を作成しました。
まず、dist_sensor_init関数の中身は下記の通りです。

static void dist_sensor_init(void)
{
    // ウォッチドッグの設定(1MHz(=1μs)に設定)
    PUT32(WATCHDOG_TICK_RW, ((1 << 9) | 12));  // clk_tickはclk_refベースのため、XOSCの12(MHz)を設定

    // GPIO設定
    PUT32(IO_BANK0_GPIO14_CTRL_RW, 5);  // SIO Echo set
    PUT32(IO_BANK0_GPIO15_CTRL_RW, 5);  // SIO Trig set

    PUT32(SIO_GPIO_OE_CLR, 1 << 14);  // GPIO14(Echo)出力を無効化
    PUT32(SIO_GPIO_OE_CLR, 1 << 15);  // GPIO15(Trig)出力を無効化(リセット)

    PUT32(SIO_GPIO_OUT_CLR, 1 << 15);  // GPIO15の値をクリア

    PUT32(SIO_GPIO_OE_SET, 1 << 15);  // GPIO15出力を有効化
}

注目ポイントは2行目のWATCHDOG_TICK_RWの設定です。このレジスタは、タイマーのカウントを開始するために動作させる必要があります。SDKでは初期化コードの1部となっているとのことだったので、ここでも初期化関数内で設定することとしました。
なお、2行目以降のコードはGPIO設定のことなので、説明は割愛します。

次に、get_distance関数についてです。
本関数作成にあたり、“超音波距離センサーについて”のセクションで説明したように、距離測定方法についての内容を再確認しておきます。説明内容は以下の通りでした。

Trigピンを規定時間(10μs)以上Highにすると、トリガー部センサーから40kHzで8サイクルの超音波バーストが発射されます。超音波バーストの発射後、Echoピンが自動的にHighになります。その後、測定対象物に衝突して跳ね返ってくる超音波をレシーバー部センサーが検出するまでHigh状態を維持し、検出されるとEchoピンがLowに切り替わります

基本的にはこの文言に沿った形でコードを作成する必要があります。

それと、“EchoがHighを維持する時間”を測定するために使用するレジスタについて説明しておきます。それは、TIMER_TIMERAWL_RWレジスタです。このレジスタは何かと言うと、時間を読み取るためのレジスタで、読み取り専用になってます。
時間読み取りのレジスタは他にTIMER_TIMERAWH_RWというものがありますが、これは先ほどTimer概要の所で説明した、64ビットカウンタのペアとなるもう片方のものです。こちらのHが、63〜32ビットの上位数値部のもので、先ほどのLの方が、31〜0ビットの下位数値部を表すものになっています。
タイマーカウントにはHLのトータル64ビットで管理できますが、1カウント1μsで64ビットとなると、1MHzで数千年をカウントできる😳ことになるので、現実的に考えると、下位ビットを管理するTIMER_TIMERAWL_RWだけを使えば事足りますよね?
と言うことで、ここではTIMER_TIMERAWL_RWレジスタのみを使って時間計測します。

以上を踏まえて、作成したコードが下記になります。

static inline uint32_t get_distance(void)
{
    PUT32(SIO_GPIO_OUT_SET, 1 << 15);  // GPIO15(Trig)をHighにセット
    uint32_t start_pulse = GET32(TIMER_TIMERAWL_RW);
    while ((GET32(TIMER_TIMERAWL_RW) - start_pulse) < 11);  // 11μs待機 
    PUT32(SIO_GPIO_OUT_CLR, 1 << 15);
    
    uint32_t timeout = GET32(TIMER_TIMERAWL_RW);
    bool chk_timeout = false;
    while (!(GET32(SIO_GPIO_IN) & (1 << 14)))  // GPIO14(Echo)がHighになったか確認
    {
        if ((GET32(TIMER_TIMERAWL_RW) - timeout) > 100000)
        {
            chk_timeout = true;
            break;  // 100ms反応がない場合は終了
        }
    }
    uint32_t start_time = GET32(TIMER_TIMERAWL_RW);
    
    while (GET32(SIO_GPIO_IN) & (1 << 14))  // GPIO14がLowになったか確認
    {
        if ((GET32(TIMER_TIMERAWL_RW) - start_time) > 30000)
        {
            chk_timeout = true;
            break;  // 30ms(=5000mm以上)反応が無い場合は終了
        }
    }
    uint32_t end_time = GET32(TIMER_TIMERAWL_RW);
    
    uint32_t elaps, res;
    // uint8_t buf[33];  // DEBUG
    if (chk_timeout == true)
    {
        res = 0;
    }
    else
    {
        elaps = 0;
        elaps = end_time - start_time;
        res = (elaps * 11239) >> 16;  // 343*1000/2/1000000 = 0.1715 ≒ (11239 / 65536)  のため
    }

    return res;  // 単位:mm
}

コード内容と先ほど再掲した文言に照らし合わせてみると、文言に沿った形でコードが記述出来ていることがわかると思います。(分かりますよね?🙄)
とは言え、いくつか補足説明しておきます。

  • 超音波速度の値
    数値精度の厳密さは不問とした為、343m/sec(雰囲気温度20℃時)で計算式に適用
  • 距離計算式の内容
    ベアメタル特有の事情のために、除算を使わないようにした

ちなみに、上の関数内には記載していない内容ですが、超音波バーストの発射間隔は200ms(※ドキュメント違いで諸説あり)以上空けることが推奨されています。これについてはメイン関数のループ時間(1s設定)で十分足りているので、特に何も設定しませんでしたが、このセンサーを使う以上は、頭の片隅に置いておく必要はあります。

それと参考までですが、今回TIMER_BASEレジスタの使用方法を習得したため、遅延時間の調整用として新しく下記のdelay_mcrs関数というものを設定しました。

static void delay_mcrs(uint32_t microsec)
{
    uint32_t start = GET32(TIMER_TIMERAWL_RW);
    while ((GET32(TIMER_TIMERAWL_RW) - start) < microsec);
}

これまではアセンブリファイルに設定したDELAY関数を使っていたんですが、ベアメタルの性質上で今回ちょっと不具合があったので、今後の使用を控えるようにしました。

ということで、プログラムの説明は以上です。ご参考までに、全体プログラムはこちらにアップしています。

実行結果

実行結果は、以下の通りです。

測定誤差がちょっと大きめに出ているように感じますが、それほど正確性を求めない用途に使用する分には十分使える設定になっていると思います。

まとめ

超音波距離測定センサーの実装自体は、特段難しいこともありませんでした。手間取ったのは、今回もプルアップ抵抗の扱いについてでしたね😵 いや、ちょっと特殊なLCDを使っていたのが一番の理由でしょうか!? 今後使用するLCDは、手に余る機能がついていなくて、自分で完全に制御できるものを使うようにしたいと思いました😅
それと、センサーに話を戻すと、このセンサーはGPIOで操作できるので、同時に色んなセンサーを使いたい場合でも、“特殊ポートが足りなくて実装出来ない〜”ということは無さそうなので、なかなか重宝する感じがしますね。色々な用途で活用できると思うので、今回ベアメタルでの使い方がマスター出来て良かったです。

また、プログラム作成では、センサー制御の過程で新しくタイマー関連のレジスタ制御を学べたのが、実に棚ぼただったなと感じており、今後もベアメタル実装を続けていく上での大きな進歩だと感じました。

それでは今回はここまでです。最後まで読んで頂いてありがとうございました。
また次の機会まで🖐️