みなさん時間測ってますか? 僕はパスタ茹でる時とかに測ります。
また、時間の間隔だけでなく、正確な時刻を知ったり、正確な時刻に合わせて何かアクションをしたい時があります。 たとえば JST 2026年06月21日22時23分24秒きっかりに数十 ns の精度でパスタを茹で始めたい時などがあると思います。
そんな時に便利なのが GNSS(Global Navigation Satellite System)です。 GPS(Global Positioning System)とも呼ばれがちですが、GPS は正確にはアメリカのやつのことだけを指します。 そのため、日本の準天頂衛星システム QZSS(Quasi-Zenith Satellite System)や、ヨーロッパの Gallileo、中国の BeiDou、ロシアの GLONASS といった測位衛星システムの総称が GNSS です。
さて、この GNSS でどうやって測位をするのかといえば、この衛星群から正確な(衛星の)位置情報と時刻情報を配信することによって実現されています。
測位信号は電波であり光速で伝搬するため、測位信号に乗った時刻情報と自分の時刻の時間差 Δt から衛星と受信機の距離がわかり、そうすると三次元的には受信機は衛星から球状の場所に位置していることがわかります。 そのため、衛星を3つ使えばこの球の接する1点に受信機が存在することがわかります。 三次元的な三辺測量ですね。
ただ、これで正確な測位を実現するためには、かなり正確な時刻が必要になります。光速ってめちゃめちゃ速いですし。 なので測位衛星は原子時計が搭載することで、非常に正確な時刻を配信しています。
しかし一方で、受信機側にはそんなに正確な時計があることはほとんどないでしょう。 また、インターネット等に接続していないような air-gapped な環境でも、というかそういう環境でこそ測位を実現したいものです。 そのため、位置座標だけでなく、受信機自身の時刻も未知変数であり、これも含めて連立方程式で解くために、実際には衛星は4機は必要です。 これが測位衛星がたくさん・色々な軌道に打ち上げられている理由です。地球上のどこにいても4つ以上の測位衛星が目に入るようにしたいですからね。
QZSS(いわゆる「みちびき」)が8の字の軌道とかたまによく言われるのもこれが理由です。 たまに勘違いされますが、8の字というのは地球から見た時/地図上にプロットした時の話であって、軌道の形そのものは8の字ではありません。軌道は(重心である地球を1つ目の焦点とする)楕円にしかならないですからね。
さて、話が大変逸れましたが、そんなわけで現代では無料で超高精度な時刻情報が宇宙から降り注いでいて(そしてそれによって自分の時刻を超高精度に決定できて)とっても便利です。
実際、高精度な時刻を得ることも GNSS の代表的な用途のひとつです。
みなさんはよく NTP などで PC などの時刻同期をしていると思うんですが、NTP には Stratum と呼ばれる階層構造があり、より高精度な偉い時計に同期するような仕組みになっています。
この時に一番偉い時計 Stratum-0 も原子時計であり、日本の場合だと東京都小金井市にある NICT 本部に日本で一番偉い原子時計があり、ここに"本物の JST: Japan Standard Time"があります。これが定義なので真に本物です。
ちなみに最近は光格子時計なる技術が使われていたり、NICT 支部との分散化による冗長化が行われていたりするみたいです。 これで日本以外や小金井市が吹き飛んでも安心ですね(?)
さて、そんな偉い Stratum-0 ですが、GNSS 衛星というやつも原子時計を搭載した Stratum-0 です。 時刻の定義という意味での偉さでは NICT や各国の NICT 的な機関の原子時計が最も偉くはあるものの、時刻の正確さという意味ではかなり似たようなオーダーのものというわけですね。
なので、GNSS 受信機を接続して NTP などより正確な時刻を得てその時刻を使うようなシステムが作られることがあります。 Stratum-1 time server を自作するというやつですね。
ここらへんの話は Raspberry Pi 大好き YouTuber であるところの Jeff が色々動画を出していて便利です。
ということで、前置きが長くなりましたが時計を作っていこうかなと思います。 使うのはどこのご家庭にもある Raspberry Pi Pico と、以前は秋月で安価にたくさん売っていた GNSS モジュール*1です。 終売品ですが、まあなんか家に転がっていたので。
アンテナはまあ適当に窓際にでも置きましょう。外に出すのは面倒なので。養生テープで窓の内側に張り付けます。 測位条件としては結構悪めな気もしますが、絶対時刻を得るぐらいならいけるはずです。
さて、今回は数十 ns 精度でパスタを茹でるのが目標なので、ここでひとつ注意すべきことがあります。 それは UART でこの精度の正確な時刻取得/時刻同期を行うことは逆立ちしても不可能だということです。
それが如何に不可能かという話は id:koba789 のスライドでも見てください。
そのため、PPS をちゃんと使う必要があります。 PPS というのは Pulse Per Second のことで、真に正確に "per second" 単位でパルスを出力してくれる機能です。 多くの GNSS 受信機には付いています。もちろん今回の秋月のやつにも付いています。 測位が fix すると LED がチカチカするやつとかもありがちだと思うんですが、あのタイミングとかもだいたいこれですね。
ということで、この PPS の線も配線してやります。 まあ NMEA UART は GP0, GP1 で、PPS は GP2 とかでいいでしょう。
ファームウェアは例によって Rust で書きます。最近は embassy で書くと考えることがだいぶ減っていいかんじです。
非同期ランタイムだしこれはこれで独自のエコシステムを築いているので中身をちゃんと把握しようと思うと大変な時もありますけどね。
embassy.dev
デバッグは rust-dap で。これはまあ個人的「いつもの」構成です。仕事でもこういうかんじです。
あとは defmt でログを吐いてあげまして、
このログを喰って色々可視化する Dashboard まで Opus 4.8 に作ってもらえばいいでしょう。これぐらいなら1セッション、なんならここに書いた構成を雑に /goal に突っ込めば一発でできるんではないでしょうか。
......しかし残念ながら、これだけでは話は終わりません。 単純に GPIO を read するだけではろくに精度が出せないのです。 ナイーブにポーリングしたらミリ秒とかのオーダーですし、割り込みで頑張っても数 µs とかのオーダーがせいぜいです。 実際ラズピコでやってみると、前者は ±1.4ms、後者は ±9µs とかでした。
これではパスタが伸びてしまいます。困ります。
そもそもの問題として、ナノ秒とかのオーダーになってくるとソフトウェアで頑張るのは原理的に厳しいです。 Raspberry Pi Pico に載っている RP2040 の動作周波数は 125MHz なので、数十 ns の間に動かせるのは数命令とかです。 関数呼んだりしたらスタック積んでる間に終わりそうですね。
ということで、正確なパスタを食べたい人のために、ハードウェアが PPS などのパルスを見てタイムスタンプを打ってくれる機能があるデバイスがあったりします。具体的には STM32 の input capture とかですね。使えるピンが限定されているので注意。
なのですが、Raspberry Pi Pico というか RP2040 にはそういう機能はありません。ざんねん。
しかし、直球のそういう機能はないものの、代わりに(?)PIO(Programmable I/O)があります。
これは大変 気持ち悪い 便利なペリフェラルで、GPIO に接続できる超軽量なコプロセッサです。
これをうまく活用すると、メインのソフトウェアでは不可能なほど高速に GPIO をピコピコさせることで胡乱なプロトコルをラズピコから喋らせることができます。めちゃめちゃ便利。もはや軽量 FPGA ぐらいの使い勝手です。他のあらゆるマイコンにも欲しい。
PIO の入門は tnisinaga さんのこのスライドが便利です。
さて、こんな便利な物体があればパスタも茹でられそうです。
この PIO に PPS のパルスの立ち上がりを見張らせて、エッジが着た瞬間のカウンタ値を FIFO に入れれば、input capture もどきの完成です。
.wrap_target low: jmp pin rising ; ① PPS ピンが high なら捕捉へ飛ぶ。low なら次へ落ちる (1 cycle) jmp x-- low ; ② X-- して、X が 0 でなければ low へ戻る (1 cycle) jmp low ; ③ X が 0 に達した時だけここに来る。偽エッジを出さず継続 (通常は通らない) rising: in x, 32 ; 立ち上がり検出。今の X を ISR へ push noblock ; ISR を RX FIFO へ(メイン CPU が後で読む) high: jmp x-- highchk ; high の間もカウンタは回し続ける highchk: jmp pin high ; ピンが落ちるまで待つ(同じパルスで再トリガしない) .wrap
これを雑にまとめるとこんなかんじで、ループとしては実質2命令になっています。
loop: if pps_pin == HIGH: # jmp pin capture(x); break x -= 1 # jmp x--
とすると、125MHz では 2 cycle は 16ns です。だいぶパスタ茹でられそうですね。
しかし、まだ安心してパスタを茹でることはできません。 なぜなら、これで分かるのはあくまで PPS の立ち上がりの瞬間だけだからです。 正確には、RP2040 の内部時計から見て PPS がいつ上がったのかが分かるだけです。 そして、この内部時計というやつはあんまり、いやかなり、いやものすごく、正確ではありません。
この内部時計というやつは Raspberry Pi Pico に搭載されている水晶発信器から作られたクロックからなる時計です。 「動作周波数」ってやつの源泉がこれですね。
で、これがどれぐらい不正確なのかというと、この時計は1秒あたり10µs、1日だと0.86秒とかのオーダーでズレます。 かなしい。 まあ、原子時計とかには逆立ちしても勝てないのは仕方ないとしても、もうちょっとイイやつを使うと1~2桁ぐらいはマシになります。 時間にセンシティブなことをするならちゃんとしたクロックを使えと言われる理由がわかりますね。 しかし今回はどこの家にもあるようなモノで作りたいです。どうにか Pico だけで頑張りたい。
また、PPS のパルスの瞬間だけを扱っても仕方がありません。 パスタを茹でている途中に「茹で上がりまでは何 ns だっけ?」と思ったり、ザルを鍋から上げようとしたりする際、PPS のパルスとパルスの間ではこの内部時計を信じるしかないので途端に精度が落ちてしまいます。 そこで、1PPS のパルスのタイミング幅を計測し続けることで、この内部時計というやつがどれぐらいズレているものなのかを推定し続け、クロック周波数を微調整していけば内部時計も正確になっていき、PPS のパルスとパルスの間の時刻も正確に知ることができます。 これを GPSDO(GPS Disciplined Oscillator)/GNSSDO(GNSS Disciplined Oscillator)と言います。 こうすると、衛星からの信号をロストしても、ある程度は正確な時間を刻み続けることができます(holdover といいます)。
今回はこれを真似して、別のピンから PPS と同期したパルス信号を出してみることにしました。 ただ、Raspberry Pi Pico にはこの話で意味のある精度でクロック周波数を弄ることのできる API がありません。 そこで発想を転換して、RP2040 の動作周波数自体を変えるのではなく、あくまで PPS 信号に正確に GNSSDO 信号タイミングを同期することを考えます。 この時に必要になるのが、1PPS のタイミング幅の正確な計測による自クロックと 1PPS の比率(とその傾向)と、GNSSDO 信号と PPS 信号の同期誤差の確認です。
ここで、前者はやるだけとして、問題は後者です。 ここで少し HW に工夫をします。 具体的には、Pico GNSSDO 信号の出力ピンからまた別のピンに入力するループバック接続をすることで、PIO を使って PPS 信号と GNSSDO 信号の誤差を高精度に内部で計測可能にします。
誤差を計測することができたらやることはひとつですね。そう、制御器を組んでフィードバックループを組んで収束させます。
これにより、どんな制御手法ならどう収束していくのかをファームウェアから観測することもできます。 実際に可視化してみると、PID の各成分が教科書的に出て面白いですね。
また、PIO なら高精度な制御ができることも観測できます。
なんか 18ns とか出てそうですね。最高じゃん。
これで、パスタはたぶんあんまり伸びずに済むようになりました。 しかし、時刻同期/信号の同期という観点ではどうでしょうか?念のためオシロスコープで GPS-R PPS 信号と Pico GNSSDO 信号を比較してみましょう。
なんと 300ns 近くもズレています。このズレは Pico を再起動する度に変化します。 つまり、実はパスタの茹で上げ時刻は正確に合わせられていません。 例えば友人と一緒にパスタを食べる約束をしている場合、友人が原子時計を持っていても、なんと最大 80µs ほど茹で上がりが遅刻してしまう可能性があります。困りますね。
これはなぜかというと、PPS 捕捉に使っている PIO と GPSOD loopback 捕捉に使っている PIO が別の PIO state machine で動いているからです。 それぞれのカウンタは同じクロックで lockstep されて動いているものの、これらの state machine はあくまで別々に動いているため、このカウンタ同士は offset してしまっています。 そのため、制御はちゃんと収束して jitter は抑えられているものの、そもそもズレたカウンタに対して計測・制御をしてしまっているため、ここからは見えない offset 誤差が乗ってしまうのです。
jitter が収束していても固定の offset があっては台無しです。 ただ、PIO はあくまで同じクロック上で動いています。そのためこの offset は固定されているはずです。 そこで、このオフセットを計測します。方法は簡単で、制御を開始する前などたまーに最初に GNSSDO loopback 用 PIO state machine でも PPS を捕捉します。 PIO がどの GPIO にも気軽に接続できるからこそ可能な芸当ですね。
しかし、これで問題は解決したかと思いきや、長時間観測しているとどうにもまたオフセットしていきます。 そのため、定期的に校正をするようにしてみます。
こうすると、たまに暴れてオフセットが 100ns とかになることはありつつ、測位が安定していれば全体的にはオフセットは 40ns ぐらいの幅にほとんど収まるようになりました。
ここまで色々やると、ようやくサブ 100ns パスタを安心して食べることができます。
ただ、定期校正が必要なのはちょっと気持ち悪いです。 そこで、もう一つ PIO State Machine を増やして GPS-R PPS を観測してみます。
これにより、別の PIO で観測していることによって発生しているズレなのか、一時的な PIO の観測ピン切替による影響が発生しているのかを切り分けることができます。 追加したカウンタと GPS-R PPS 用カウンタの差はほとんど無いので、どうやら原因は一時的な観測ピン切替にありそうです。
ということで、校正の切替時に何が起きているかをよく見てみます。
sm.set_config(cfg_gp2); // 観測先ピン切替
fn set_config(cfg) {
// レジスタ一式の設定 (観測ピン設定は EXECCTRL の一部)
write CLKDIV / EXECCTRL / SHIFTCTRL / PINCTRL;
exec_jmp(top); // 走行中の SM を top へ強制ジャンプする
}
このうち、レジスタの書き込みが悪いのか、それとも exec_jmp(top) が悪いのかを切り分けるために、set_config() と、一部レジスタのみへの書き込みを先ほど足した追加観測ピンに対してやってみることにします。
これを見ると、単なるレジスタ書き込みだけでは影響が無いものの、明らかに exec_jmp(top) に相関してカウンタが遅れることがわかります。
この原因は PIO ロジックの中身を思い出してみるとわかります。
// PIO ロジック top: jmp capture if pin high // 立ち上がっていれば捕捉へ X = X - 1 // 立ち上がり待ち(1 tick 進む) jmp top capture: push X to FIFO // 捕捉 fall: X = X - 1 // 立ち下がり待ち(1 tick 進む) jmp fall if pin high // high の間は繰り返す jmp top
つまり、基本的にカウンタ X が下がり続けるはずのところで、突然ホストから top へのジャンプが指令されるため、それによって X = X - 1 がスキップされてしまうタイミングが発生しています。
そこで、この top へのジャンプをなくしてみます。実際に観測ピンの切替に必要なのは EXECCTRL というレジスタへの設定だけなので、もっとシンプルにすることが可能です。
sm.execctrl().modify(|w| w.set_jmp_pin(GP2));
これでまた実験してみると、まだ謎のドリフトは残っているので定期校正自体は必要ですが、定期校正した上での長期的な中央値のオフセットは改善しました。
しかし、対症療法が効くとはいえ、謎のドリフトがあるのは気持ち悪いですね。 デバッグしましょう*2。
まずは、GNSSDO 出力側に何か問題があり、制御ループの外でドリフトしている可能性を考えてみます。 この検証のため、途中から露骨に追加でドリフトさせるような処理を注入してみたところ、正しく制御されるので特に最終的なドリフト挙動には変化がみられません。
また、定期校正と同じ方法で、カウンタのズレの記録だけして校正はしないで放置してみたところ、カウンタのズレと実際の信号のズレはちゃんと相関していそうなことがわかります。
ここで、PIO をどんなロジックにしていたのかを思い出してみると、PIO のカウンタを 2 cycle ごとに1つ減らす、ということをしていました。 先ほどは擬似コードにしていましたが、実物の GPS-R PPS 補足 PIO アセンブリはこのようになっています。
.wrap_target low: jmp pin rising ; 立ち上がっていれば捕捉 jmp x-- low ; カウンタを 1 減らし、0 以外なら low へ (ふだんはここまでの 2 命令で 1 tick = 2 cycle) jmp low ; カウンタが 0 だった回だけここを通る (+1 cycle) rising: ; いまのカウンタの値を FIFO へ送る in x, 32 push noblock high: jmp x-- highchk ; カウンタを 1 減らす(+1 cycle) highchk: jmp pin high ; まだ high なら high へ (ここまでの 2 命令で 1 tick = 2 cycle) .wrap ; ここまできたら .wrap_target へ戻る(この巻き戻しは命令 fetch に組み込まれているので 0 cycle)
これをよーく見ると、low(PPS 立ち上がり待ち)のとき、カウンタが 0 の時だけ 1 cycle 分命令が多く実行されることがわかります。
PIO における jmp x-- <label> 命令は、X が0以外の時は <label> にジャンプするのですが、X が 0の時はジャンプせず次の命令に fall through します。
そのため、high の時は jmp x-- highchk はそもそも常に分岐せずカウンタを減らすだけ*3なのですが、low の時は実行命令数が 1 cycle 分増えてしまいます。
これにより、通常時は 2 cycle に一度カウンタが減算される、つまり 16ns ごとにカウントしていたわけですが、このときだけカウンタの進みが 1 cycle = 8ns 止まってしまうわけです。
PIO のこのカウンタは 32bit なので、16 ns * (2**32) ≒ 68.7 s ごとに一周します。 結構頻繁に起きそうですね。
そして真の問題は、このロジックで GPS-R PPS と GNSSO loopback の両方の観測を行っていたことです。 今回の場合は GNSSDO loopback のパルス幅と GPS-R PPS のパルス幅を変えていたこともあり、1 tick = cycle になるタイミングがズレてしまい、GPS-R PPS 観測カウンタと GNSSDO loopback 観測カウンタがズレ続けてしまっていました。
そこで、GNSSDO のパルス幅を low の幅含めて GPS-R 1PPS に合わせてみると、だいぶ安定することが分かります。 パルス幅を変えずに low の時の 0 跨ぎも 3 cycle になるようにしてみても安定しますね。
ここまでやると(再)、Pico GNSSDO 信号はの精度はオフセット誤差の中央値が +8.0ns、±100ns 内(サブ 100ns 精度)達成度合いは全体の81.4%を達成することができました。 これでそこそこ安心してパスタを食べられそうです。
ちなみに、サブ100ns達成度合いが8割というのはどうにかならんのか、というのが次に思うところですが、これは今回のハードウェア構成だとたぶん厳しいです。 というのも、結局のところ Pico のクロックの精度が悪いんですよね。 具体的には精度が温度によってかなりバラつきます。
ロック後にオシロスコープを眺めながら RP2040 のチップを指で触ったりすると、温度が急上昇して GNSSDO 信号のオフセットがズパーンと数百~数千ns ぐらい吹っ飛んでいく様子を眺めることができます。
こういう時、一般的にはどうするべきかというと、温度補償型水晶発振器(TCXO)というものを使うべきです。
www.macnica.co.jp
ですが、まあなんかどうにかならんのかという気はしますよね。 で、考えてみると、水晶発信器そのものとはちょっと離れてはいるし、精度は低いものの、RP2040 にも温度計が載っているんですよね。なんかなんとかなりそうじゃないですか?
ということで RP2040 温度を使ったフィードフォワード制御実装してみました。 が、手で触るみたいな急な温度変化による急激な変化自体は防げないものです。
ただ、これは流石に反応しすぎなので、リミッタは設けました(急激な温度変化の条件を合わせるのは面倒なので比較は雑)。
微妙に効果が分かりにくい温度制御ですが、これが役に立つのは長時間運転した時です。 長時間運転してみると、気温がバラついているとき(これはたぶん夜中のエアコンの制御をもろに喰らってる)、つまり温度外乱が大きいときの GNSSDO オフセットの標準偏差がだいぶ改善していることがわかります。
最終的な実装での挙動はこんなかんじになりました。
実装はこちら。
今回の実装のうち、ターゲットフリーな部分を gnssdo crate、PIO による input capture / loopback などの Raspberry Pi Pico 特有部分の実装を rp-pps crate というライブラリに、全部込みのファームウェアを pico-gnss という crate に分けてあります。
麺類や麺類以外のものにご活用ください。
結論
パスタにちゃんとした保証が欲しいときは qErr 補正機能付きの GNSS 受信機を買って空のひらけたところで TCXO を使った機械で茹でましょう。