GPSシールドを使ってStratum 1 NTPサーバーを作ってみた
とある仮想通貨取引所のAPI認証で、リクエストが次々に弾かれる問題が発生しました。原因を調べてみると、システムの時刻がなぜか未来へ飛んでいたことが判明しました。認証サーバーに「未来から来ました」と言っても、そりゃ弾かれますよね。。時差ボケ状態のシステムでは、取引どころではないので、手元にあったESPr® Developer用GPSシールド SAM-M8Q搭載を使ってStratum 1 NTPサーバーを立て完璧な(!?)時刻同期を実現しました。その方法をご紹介します!
コンパクトなStratum 1 NTPサーバ
窓ガラスに設置して運用しているところ
屋内だと初回起動時に衛星を捕捉できないようで、1度外にでて衛星を捕捉する必要がありました。外部アンテナがほしいですね。
Stratum 1 NTPサーバーとは?
NTP(Network Time Protocol)は、ネットワーク上で時刻を同期するためのプロトコルです。GPSや原子時計などの高精度なタイムソースに直接接続するサーバーはStratum 1と呼ばれています。今回の構成では、ESPr® Developer用GPSシールド SAM-M8Q搭載が高精度な時刻情報を提供し、ESPr® Developer C6がNTPサーバーとして時刻情報をネットワークに配信します。
使用機材と役割
-
ESPr® Developer用GPSシールド SAM-M8Q搭載
- GPS衛星から時刻データを受信します。PPS(Pulse Per Second)信号も出力します。
- PPS信号は毎秒1回出力されるデジタルパルスで、GPS衛星の原子時計を基準としているため、正確な1秒間隔のパルスが得られます。
- ¥3,850 (税込)
-
ESPr® Developer C6
- ESP32-C6-WROOM-1を搭載したマイクロコントローラでNTPサーバーのロジックを実装します。
- Wi-Fi経由のクライアントからの時刻同期要求に、GPSシールドから取得した時刻情報を基にNTPパケットを生成し、応答を返します。
- ¥2,420 (税込)
システム動作の概要
- GPSシールドから時刻情報を取得
- PPS信号を割り込みで検知し、1秒の境界を決定
- 時刻情報を基にNTPパケットを生成
- クライアントからのリクエストに応答
PPS信号との同期の仕組み
次の図はPPS信号とGPSシールドから送られてくる時刻情報のタイミングチャートです。割り込み処理を用いてPPSの立ち上がりエッジを検出、micros
関数でマイコン内部時間を取得します。この時間がジャスト0秒の時間です。
GPSシールドからの時刻情報(UBX-NAV-PVT)はエポックタイム(1970年1月1日経過秒数)に変換します。クライアントからの時刻同期要求に対してはエポックタイムとジャスト0秒からの経過時間を返します。
GPSシールドからの時刻情報(UBX-NAV-PVT)には”今の時間”が入っているので、次のPPS割込みではエポックタイムを1秒進め、次の時刻情報が来るまでの間の時間を補正します。
u-blox8-M8_ReceiverDescrProtSpec_UBX-13003221.pdfより抜粋
割込み処理と時刻情報を返す関数のコードはこんな感じ。衛星の電波の受信状況が悪くなった時にPPS信号が止まるので、その場合は、GPSシールドから時刻情報を受け取ったタイミングで、PPS信号を受信した前回の時間に経過秒数×1000000を加算しています。
void ppsInterrupt(void) {
lastPPSTime = micros() - 2; // PPS信号立ち上がりからこの処理までの遅延が2us
unixEpochTime += 1; // 1秒進める
ppsValid = true;
ppsCount += 1;
}
// GNSSの時刻情報から計算したUnixエポックタイムとPPS信号からの経過マイクロ秒を返す
bool getUnixEpoch(uint32_t *seconds, uint32_t *microseconds) {
bool ret = false;
if (unixEpochTime && ppsCount > 0) {
uint32_t sec = unixEpochTime;
uint32_t msec = micros() - lastPPSTime;
// PPS信号が入ってきていない時にunixEpochTimeが1秒遅れてしまうので補正する
uint32_t ovf = msec / 1000000;
*seconds = sec + ovf;
*microseconds = msec - ovf * 1000000;
ret = true;
}
return ret;
}
void processPVTdata(UBX_NAV_PVT_data_t *data) {
if (myGNSS.getTimeFullyResolved()) {
unsigned long new_epoch = myGNSS.getUnixEpoch();
if (!ppsValid) {
if (ppsCount > 0) {
lastPPSTime += (new_epoch - unixEpochTime) * 1000000;
} else {
lastPPSTime = micros();
}
}
ppsValid = false;
unixEpochTime = new_epoch;
}
debugPrintPVT(*data);
}
PPS信号を受信できるようにGPSシールドを改造
GPSシールドのPPS信号は2列のピンヘッダにはつながっていないので、ジャンパーSJ3
をカットしIO16
とTIMEPULSE
をワイヤで接続しています。
NTPリクエストの処理
UDP123番ポートでNTPリクエストを受信し、受信時刻と送信時刻を応答パケットにのせて返します。NTPパケットのタイムスタンプは1900年1月1日を起点にしています。GPSから取得した時間は1970年1月1日を起点にしているので70年分の秒数を加算しています。また時刻情報はNTPパケットのタイムスタンプの型、64bit固定小数点(32bit秒数.32bit小数)に変換して格納しています。
// NTPパケットの受信処理
// リクエストがあったらGPSと同期した今の時間を返す!
void processNTPRequest() {
int packetSize = ntpClient.parsePacket();
if (packetSize) {
// 受信時刻
uint32_t seconds, microseconds;
uint32_t recvtime = micros();
bool valid = getUnixEpoch(&seconds, µseconds);
uint32_t currentNTPTime = seconds + 2208988800UL; // 1970年1月1日からの秒数に変換
const int NTP_PACKET_SIZE = 48;
byte packetBuffer[NTP_PACKET_SIZE];
ntpClient.read(packetBuffer, packetSize);
if (packetSize == NTP_PACKET_SIZE) {
if (valid) {
// NTPレスポンスパケットの作成
packetBuffer[0] = 0x24; // LI = 00 VN = 100 Mode = 010
// Stratum 1 サーバーの階層
packetBuffer[1] = 1;
// Poll Interval 16秒 ポーリング間隔(2のべき乗秒)
packetBuffer[2] = 4;
// Precision -20 時刻精度(2の負のべき乗秒)
packetBuffer[3] = 0xec;
// Root Delay サーバーから基準サーバーまでの往復遅延
packetBuffer[4] = 0;
packetBuffer[5] = 0;
packetBuffer[6] = 0;
packetBuffer[7] = 0;
// Root Dispersion サーバーの時刻誤差範囲
packetBuffer[8] = 0;
packetBuffer[9] = 0;
packetBuffer[10] = 0;
packetBuffer[11] = 0;
// Reference Identifier 基準サーバーの識別子
packetBuffer[12] = 'G';
packetBuffer[13] = 'P';
packetBuffer[14] = 'S';
packetBuffer[15] = ' ';
// Reference Timestamp 基準タイムスタンプ(サーバーが最後に同期された時刻)
convert_to_fixed_timestamp(currentNTPTime, 0, &packetBuffer[16]);
// Origin Timestamp クライアントがリクエストを送信した時刻
packetBuffer[24] = packetBuffer[40];
packetBuffer[25] = packetBuffer[41];
packetBuffer[26] = packetBuffer[42];
packetBuffer[27] = packetBuffer[43];
packetBuffer[28] = packetBuffer[44];
packetBuffer[29] = packetBuffer[45];
packetBuffer[30] = packetBuffer[46];
packetBuffer[31] = packetBuffer[47];
// Receive Timestamp 受信タイムスタンプ(リクエストをサーバーが受け取った時刻)
convert_to_fixed_timestamp(currentNTPTime, microseconds, &packetBuffer[32]);
// Transmit Timestamp 送信タイムスタンプ(サーバーが応答をクライアントに送信した時刻)
microseconds += micros() - recvtime;
if (microseconds >= 1000000) {
currentNTPTime += 1;
microseconds -= 1000000;
}
convert_to_fixed_timestamp(currentNTPTime, microseconds, &packetBuffer[40]);
// NTPパケットを送信
ntpClient.beginPacket(ntpClient.remoteIP(), ntpClient.remotePort());
ntpClient.write(packetBuffer, 48);
ntpClient.endPacket();
debugPrintNTPPacket(packetBuffer);
}
}
}
}
ソースコード
SparkFun_u-blox_GNSS_Arduino_Library
u-bloxのGPSモジュール(SAM-M8Qなど)は、位置情報や時刻情報を取得するために、標準のNMEAプロトコルだけでなく、独自のUBXプロトコルをサポートしています。UBXはバイナリ形式の通信プロトコルで、GPSモジュールの動作を細かく設定できます。SparkFunのSparkFun_u-blox_GNSS_Arduino_LibraryはUBXプロトコルに対応していて使い勝手が良さそうなので今回採用しました。v3は最新のGPSモジュールしか対応していないのでv2を使っています。
ビルド
Arduino IDEを使ってビルドしています。SparkFun_u-blox_GNSS_Arduino_Libraryはライブラリマネージャからインストールしてください。ESPr® Developer C6のビルド手順は商品ページに記載しています。そちらも参照してください。ESPr® Developer 32やESPr® Developer S3でも動作すると思います、が、動作確認はしていません(ごめんなさい!)。
時間のずれの評価
ntpdate
コマンドを使ってシステム時刻との差を今回製作した自作NTPサーバーと公開NTPサーバーを比較します。システム時刻との差が両者で±数ミリ秒程度の差であれば、問題なく動作していると判断します。
※※公開NTPサーバーへ過剰なリクエストを投げないよう注意してください※※
time.google.comの場合
$ ntpdate -q time.google.com
server 2001:4860:4806:4::, stratum 1, offset 0.060522, delay 0.07152
server 2001:4860:4806:c::, stratum 1, offset 0.059511, delay 0.07344
server 2001:4860:4806:8::, stratum 1, offset 0.059947, delay 0.07359
server 2001:4860:4806::, stratum 1, offset 0.059860, delay 0.07333
server 216.239.35.8, stratum 1, offset 0.060588, delay 0.07188
server 216.239.35.12, stratum 1, offset 0.060744, delay 0.07155
server 216.239.35.4, stratum 1, offset 0.060276, delay 0.07196
server 216.239.35.0, stratum 1, offset 0.060418, delay 0.07144
22 Nov 16:52:29 ntpdate[7548]: adjust time server 216.239.35.0 offset 0.060418 sec
※offsetがシステム時刻との差(秒数)です。プラスで遅れていて、マイナスで進んでいます。
ntp.nict.jpの場合(日本標準時)
$ ntpdate -q ntp.nict.jp
server 2001:ce8:78::2, stratum 1, offset 0.059654, delay 0.05025
server 2001:df0:232:eea0::fff4, stratum 1, offset 0.059821, delay 0.03935
server 2001:df0:232:eea0::fff3, stratum 1, offset 0.059924, delay 0.03964
server 133.243.238.163, stratum 1, offset 0.059972, delay 0.03970
server 133.243.238.164, stratum 1, offset 0.060565, delay 0.03874
server 133.243.238.244, stratum 1, offset 0.060068, delay 0.03964
server 133.243.238.243, stratum 1, offset 0.060027, delay 0.03928
server 61.205.120.130, stratum 1, offset 0.060179, delay 0.03679
22 Nov 16:52:52 ntpdate[7575]: adjust time server 61.205.120.130 offset 0.060179 sec
自作NTPサーバーの場合
$ ntpdate -q 192.168.3.49
server 192.168.3.49, stratum 1, offset 0.060572, delay 0.03056
22 Nov 16:53:01 ntpdate[7598]: adjust time server 192.168.3.49 offset 0.060572 sec
$ ntpdate -q 192.168.3.49
server 192.168.3.49, stratum 1, offset 0.077958, delay 0.06479
22 Nov 16:53:10 ntpdate[7621]: adjust time server 192.168.3.49 offset 0.077958 sec
$ ntpdate -q 192.168.3.49
server 192.168.3.49, stratum 1, offset 0.065870, delay 0.04388
22 Nov 16:53:18 ntpdate[7644]: adjust time server 192.168.3.49 offset 0.065870 sec
NTPサーバー | システム時刻との差(秒) |
---|---|
time.google.com(1) | 0.060744 |
time.google.com(2) | 0.060276 |
time.google.com(3) | 0.060418 |
ntp.nict.jp(1) | 0.060068 |
ntp.nict.jp(2) | 0.060027 |
ntp.nict.jp(3) | 0.060179 |
自作サーバー (1回目) | 0.060572 |
自作サーバー (2回目) | 0.077958 |
自作サーバー (3回目) | 0.065870 |
公開NTPサーバーへのアクセスでは、いずれのIPアドレスでもシステム時刻との差は約60msと安定しており、ばらつきもほとんど見られませんでした。一方、自作NTPサーバーでは、時刻のズレが60~77msとやや変動が大きくなりました。
NTPプロトコルは「送信と受信にかかる時間が等しい」という前提で動作しますが、Wi-Fiではこの前提が崩れるため、時刻のずれが発生しやすくなるのではないかと考えています。
まとめ
自作NTPサーバーの計測結果に若干のばらつきが見られるものの、公開NTPサーバーと比較してもそれほど大きな時間のズレはありませんでした。十分実用的なNTPサーバーが完成したのではないかと思います。
以上です。
付録.GPSシールドとのピン対応表
ESPr® Developer用GPSシールド SAM-M8Q搭載をESPr Developer 32/S3/C6に接続する際の注意点をまとめます。S3を使用する場合、GPSシールドのRX端子がIO35に接続されますが、IO35はPSRAMと共有されているため、PSRAMを使うとGPSとの通信に問題が生じます。また、S3やC6では、SDカードコネクタのCD/DAT3端子がUSB-D-端子と共有されているため、SDカードを使用するとUSB機能が動作しなくなる点に注意が必要です。
ESPr C6 | ESPr S3 | ESPr 32 | GPSシールド(左列) | GPSシールド(右列) | ESPr 32 | ESPr S3 | ESPr C6 | |
---|---|---|---|---|---|---|---|---|
3V3 | 3V3 | 3V3 | 3V3 | GND | GND | GND | GND | |
EN | EN | EN | 23 | 1 | 3 | |||
6 | 17 | 25 | SD CLK | SD SW_A | 4 | 42 | 2 | |
0 | 18 | 26 | SD DAT0 | SAM-M8Q RESET | 41 | 23 | ||
1 | 47 | 15 | SD CMD | SAM-M8Q RXD | 21 | 35 | 21 | |
USB_N | USB_N | 14 | SD CD | TXD0 | TXD0 | TXD | ||
USB_P | USB_P | 12 | RXD0 | RXD0 | RXD | |||
11 | 9 | 13 | SAM-M8Q TXD | 19 | 40 | 15 | ||
GND | GND | GND | GND | GND | GND | GND | GND | |
VIN | VIN | VIN | VOUT | VOUT | VOUT |