今回実験するプロトコル
今回はArduino UNO R4 WiFiを使ったUDP通信を試してみます。そのための題材として、UDP上で動作するアプリケーションプロトコルの代表例の1つであるNTPをとりあげます。
UDP
概要
UDP(User Datagram Protocol)は、TCPと同様、OSI参照モデルの第4層(トランスポート層)/TCP/IPモデルのの第3層(トランスポート層)のプロトコルです。OSI参照モデルの第3層(ネットワーク層)/TCP/IPモデルの第2層(インターネット層)のIPv4またはIPv6の上で動作します。
TCPとの違いは『信頼性を犠牲にしてスピードを重視』していることです。TCPが最初にクライアント-サーバ間でハンドシェイクを行い、お互いに相手の存在を確認して『接続』をしてから通信を行うのに対して、UDPは相手の都合など無視していきなりデータを送信します。順序制御や輻輳制御、到達確認も行いません。このため、通信の信頼性はTCPに較べて低いですが、制御や確認を行わないことから処理が早く、最小限の時間で送信/受信を完了することができます。
…詳しいことはネットワークの教科書でも見てください(手抜き
NTP
概要
さて今回は、UDPで通信を行うための題材として、『インターネット上の時刻サーバから現在時刻を取得する』ために使用される NTP(Network Time Protocol)というプロトコルの実験を行います。
NTPでは、原子時計などに接続されたサーバから順次下の階層に時刻を同期していくようになっていて、参加しているすべてのコンピュータがネットワークの遅延があっても誤差数十ms程度で時刻合わせができるように設計されています。
NTPのデータフォーマット
今回は上位のサーバに対してクライアントとして時刻を訊きに行く、という方式をとります。NTPではクライアントからのリクエストとサーバからのレスポンスのデータフォーマットがまったく同じで、以下のようになっています。
各フィールドの説明
LI(Leap Indicator)
2bit。閏秒についての情報です。
値(2進数) | 意味 |
---|---|
00 | 24時間以内に閏秒の予定なし |
01 | 24時間以内に閏秒の挿入がある(UTCでその日の最後の1分が61秒ある) |
10 | 24時間以内に閏秒の削除がある(UTCでその日の最後の1分が59秒ある) |
11 | 時刻が時刻ソースに同期していない |
仕様には『閏秒の削除』がありますが、現実には『挿入』はあっても『削除』は発生しないと思われます。
VN(Version Number)
3bit。NTPのバージョンを表します。
値(2進数) | 意味 |
---|---|
000 | (予約) |
001 | Version 1 |
010 | Version 2 |
011 | Version 3 |
100 | Version 4 |
101 | (予約) |
110 | (予約) |
111 | (予約) |
Mode
3bit。ホストの動作モードを表します。
値(2進数) | 意味 |
---|---|
000 | (予約) |
001 | 対称アクティブモード |
010 | 対称パッシブモード |
011 | クライアント |
100 | サーバ |
101 | ブロードキャスト |
110 | NTPコントロールメッセージ |
111 | (予約) |
Stratum
8bit。StartumはNTPツリーの階層を意味します。
値(10進数) | 意味 |
---|---|
0 | 原子時計など、基準となる時刻ソースそのもの。 |
1 | 原子時計などの時刻ソースに直接接続しているコンピュータ。 プライマリ・タイムサーバとも呼ばれる。 |
2~15 | それぞれ自己より上位の(Stratumが1小さい)コンピュータと時刻を同期している |
16 | 時刻を同期していない |
17~ | (予約) |
Poll(Polling Interval)
8bit。次のNTPパケット送出までの最大時間間隔(秒)。値は2を底とする対数値で表されます。つまり、Pollフィールドの値を \(n\) とすると、最大時間間隔は \(2^n\) 秒となります。
例えばPollフィールドの値が \(10\) なら、最大時間間隔は \(2^{10}=1024\)秒です。
Precision
8bit。符号付き8bit整数として扱います。クロックの精度(秒)を表します。これも2を底とする対数値で表されます。
例えばPrecisionフィールドの値が 0xEC なら、これを符号付き8bit整数とみなすと \(-20\) となり、精度は \(2^{-20}=0.00000095367431640625\) 秒 ≒ \(1\) μ秒となります。
Root Delay
32bit。プライマリサーバとのラウンドトリップ遅延を表します。
Root Dispersion
32bit。プライマリサーバとの通信における揺らぎを表します。
Reference ID
32bit。どのNTPサーバを参照しているかを表します。
Stratum2~14の場合、上位のNTPサーバのIPアドレスを指します。
Stratum1の場合、参照先を表す4文字の文字列を表します。
Reference Timestamp
64bit。最後に同期した時刻を表します。
Origin Timestamp
64bit。クライアントがNTPサーバにリクエストを送信した時刻を表します。
Receive Timestamp
64bit。NTPサーバがクライアントからリクエストを受信した時刻を表します。
Transmit Timestamp
64bit。NTPサーバがクライアントにパケットを送信した時刻を表します。
以上、合計48バイトが、NTPのリクエスト/レスポンスで最小限必要な内容です。この後にオプションでいくつかフィールドを追加できるのですが今回は使用しません。
時刻のフォーマット
Reference Timestamp、Origin Timestamp、Receive Timestamp、Transmi Timestamp は、いずれもUTC(協定世界時館)での1900年01月01日 00時00分00秒からの経過秒数を固定小数点64bit値(上位32bitが整数部、下位32bitが小数部)で表します。
よって、
最大値は \(2^{32}-1\) 秒=4,294,967,295 秒
分解能は \(2^{-32}\)秒=0.00000000023283064365386962890625秒≒233ピコ秒
となります。
最大値の4,294,967,295秒は約136.1年であり、1900年1月1日を起点とすると2036年2月上旬でオーバーフローしてしまいます。このため NTP version4 では、整数部・小数部それぞれを64bitに拡張したフォーマットが導入されています。
もっとも簡単なNTPクライアント
プログラム
公式サンプルの WiFiUdpNtpClient を参考にしていますが、絞って絞って半分以下の行数にしました。
#include "WiFiS3.h"
// 無線LAN接続情報は別ファイルに移した
#include "arduino_secrets.h"
char ssid[] = _SSID;
char pass[] = _PASS;
// NTPアクセス関係
char ntpServer[] = "pool.ntp.org";
int ntpPort = 123;
// 送受信用バッファの用意
const int NTP_PACKET_SIZE = 48;
byte buffer[NTP_PACKET_SIZE];
// クライアントのインスタンスの生成
WiFiUDP client;
void setup(){
// シリアルインターフェイスの初期化
Serial.begin(9600);
delay(1000);
// WiFiモジュールの存在確認
if(WiFi.status() == WL_NO_MODULE){
Serial.println("WiFiモジュールがありません");
while(true);
}
// WiFiファームウェアバージョンの確認
String fv = WiFi.firmwareVersion();
if(fv < WIFI_FIRMWARE_LATEST_VERSION){
Serial.println("ファームウェアが最新のものではありません");
}
// 無線LANへの接続
Serial.print("接続中...");
while(true){
if(WiFi.begin(ssid, pass) == WL_CONNECTED)break;
Serial.print(".");
delay(1000);
}
Serial.println("完了");
// Arduinoに割り当てられたIPアドレスの確認
Serial.print("IPアドレス:");
Serial.println(WiFi.localIP().toString());
// UDPポートを開く
client.begin(ntpPort);
// NTPサーバに対してリクエストを送信
memset(buffer, 0, NTP_PACKET_SIZE);
buffer[0] = 0b00001011;
client.beginPacket(ntpServer, ntpPort);
client.write(buffer, NTP_PACKET_SIZE);
client.endPacket();
// NTPサーバからのレスポンスを受信
while(true){
if(client.parsePacket()){
client.read(buffer, NTP_PACKET_SIZE);
// 時刻(Transmit Timestamp)の取得
unsigned long transTime = buffer[40]<<24 | buffer[41]<<16 | buffer[42]<<8 | buffer[43];
int hh = (transTime % 86400L) / 3600;
hh = (hh + 9) % 24; // UTC->JST
int mm = (transTime % 3600) / 60;
int ss = (transTime % 60);
char str[10];
sprintf(str, "%02d:%02d:%02d", hh, mm, ss);
Serial.println(str);
break;
}
}
}
void loop(){
}
プログラムの解説
WiFiアクセスポイントに接続する処理は以前のものとまったく同じです。相違点のみ解説します。
なお、UDPのデータ単位は厳密には『パケットPacket』ではなく『データグラムDatagram』なのですが、WiFiUDP.hで定義される関数名やソース内コメントがすべて『Packet』表記になっているため、以下の解説でも『パケット』と表記しています。
NTP接続情報
// NTPアクセス関係
char ntpServer[] = "pool.ntp.org";
int ntpPort = 123;
接続先のNTPサーバ名と、ポート番号を設定しています。
変数名 | 型 | 意味 |
---|---|---|
ntpServer | char[] | 接続先のNTPサーバ名。FQDNを文字列で指定 |
ntpPort | int | NTPで使用するポート番号 |
このプログラム例では、NTPサーバ名に公式サンプル WiFiUdpNtpClient と同じ pool.ntp.org を指定していますが、ntp.nict.jp や time.aws.com など、他の有名公開NTPサーバに変えてもいいでしょう。
お手本にした WiFiUdpNtpClient では接続先をIPアドレスで指定していますが、RFCでは『IPアドレスではなくサーバ名で指定するべき』としています。また公開NTPサーバにはDNSラウンドロビンを用いて負荷分散をする構成になっているものもあることからも、サーバ名での指定の方がよいと思います。
外部のNTPサーバを利用できない場合、Windows PC上でNTPサーバを動作させることもできます。設定のやり方は後述。
また、このプログラム例ではクライアントとサーバで同じポート番号123を使用するようになっています。
送受信バッファの用意
// 送受信用バッファの用意
const int NTP_PACKET_SIZE = 48;
byte buffer[NTP_PACKET_SIZE];
NTPではクライアントからサーバへのデータもサーバからクライアントへのデータもまったく同じ48バイトのフォーマットです。データを送受信するためのバッファとして、byte型の配列を用意しています。
UDPインスタンスの生成
// クライアントのインスタンスの生成
WiFiUDP client;
UDPで通信する場合、WiFiUDPクラスのインスタンスを生成します。
ポートを開く
// UDPポートを開く
client.begin(ntpPort);
UDPはTCPと異なり『接続』しません。『クライアント』と『サーバ』はアプリケーション層での役割の違いだけで、TCPのように『接続する側』と『接続を待ち受ける側』という区別がありません。よって、『クライアント』側でも受信ポートを開始しておく必要があります。
UDPの受信ポートを開始するには、 WiFiUDP.begin()関数を使用します。
書式
uint8_t WiFiUDP.begin(uint16_t port)
引数
名前 | 型 | 意味 |
---|---|---|
port | uint16_t | 受信するためのポート番号 |
返却値
値 | 意味 |
---|---|
0 | ポートの開始に失敗 |
1 | ポートの開始に成功 |
リクエストメッセージの作成
// NTPサーバに対してリクエストを送信
memset(buffer, 0, NTP_PACKET_SIZE);
buffer[0] = 0b00001011;
まず memset()関数を用いてbufferの領域48バイトを0で埋めています(Javaでは数値型の配列を宣言すると各要素が0に初期化されますが、C言語では配列を宣言しただけでは中身が初期化されません)。
続いてリクエスト時に送信するパケットの内容を作成します。パケットのフォーマットは先に説明したとおりですが、実はリクエストのときはほとんどのフィールドの値が0のままでも動作します。この例では、先頭の3つのフィールド LI、VN、Mode のみ以下のように値を設定しています。
フィールド | 値(2進数) | 意味 |
---|---|---|
LI | 00 | 閏秒の設定なし |
VN | 001 | Version1 |
Mode | 011 | クライアントモード |
この3つのフィールドはすべて送信データの最初のバイトに含まれるので、
buffer[0] = 0b00001011;
として値を設定しています。残り47バイトはmemset()で0にセットしたままです。
データの送信
client.beginPacket(ntpServer, ntpPort);
client.write(buffer, NTP_PACKET_SIZE);
client.endPacket();
UDPでのデータ送信は、TCPと異なり『接続』しないので、送りたいデータができたときだけ相手を指定してデータを投げるようなイメージです。
まず、送信を開始するには WiFiUDP.beginPacket()関数を使用します。
書式
int WiFiUDP.beginPacket(char *serverName, uint16_t port)
または
int WiFiUDP.beginPacket(IPAddress serverIP, uint16_t port)
引数
名前 | 型 | 意味 |
---|---|---|
serverName | char* | 接続先のサーバ名(FQDN) |
serverIP | IPAddress | 接続先のサーバのIPアドレス |
port | uint16_t | 接続先のポート番号 |
返却値
送信開始処理の結果をint値で返します。
値 | 意味 |
---|---|
0 | 送信の開始に失敗 |
1 | 送信の開始に成功 |
解説
WiFiUDP.beginPacket()関数は、UDPパケットの送信の準備をします。
ソースを参照すると、具体的には送信バッファの領域確保やソケットの作成などを行っています。
この例では送信先のサーバが ntpServer、送信先のポートが ntpPort です。また、返却値は無視してエラー処理を省略しています。
データを送信するには WiFiUDP.write()関数を使用します。
書式
size_t WiFiUDP.write(uint8_t data)
または
size_t WiFiUDP.write(uint8_t* buffer, size_t size)
引数
名前 | 型 | 意味 |
---|---|---|
data | uint8_t | 送信バッファに書き込むデータ(1バイト) |
buffer | uint8_t* | 送信バッファに書き込むデータの列(データの列の先頭へのポインタ) |
size | size_t | 送信バッファに書き込むデータのサイズ(バイト数) |
返却値
送信バッファに書き込まれたデータのバイト数を返します。
解説
WiFiUDP.write()関数は、UDP送信バッファに送信したいデータを書き込みます。
なお、実際にネットワークにデータが送出されるのは WiFiUDP.endPacket()関数の実行時です。
ここでは、bufferの中身をすべて一度に送信バッファに書き込んでいます。
そして送信バッファ内のデータを実際に送信するには、 WiFiUDP.endPacket()関数を使用します。
書式
int WiFiUDP.endPacket()
引数
なし
返却値
送信処理の結果をintで返します。
値 | 意味 |
---|---|
0 | 送信に失敗 |
1 | 送信に成功 (ただしUDPの性質上『無事に出ていった』というだけで相手に届いたかは保証しない) |
解説
WiFiUDP.endPacket()関数は、送信バッファ内のデータを実際に送出します。
以上で、リクエストの送信は完了です。
データの受信待ちをする
// NTPサーバからのレスポンスを受信
while(true){
if(client.parsePacket()){
client.read(buffer, NTP_PACKET_SIZE);
// 時刻(Transmit Timestamp)の取得
unsigned long transTime = buffer[40]<<24 | buffer[41]<<16 | buffer[42]<<8 | buffer[43];
int hh = (transTime % 86400L) / 3600;
hh = (hh + 9) % 24; // UTC->JST
int mm = (transTime % 3600) / 60;
int ss = (transTime % 60);
char str[10];
sprintf(str, "%02d:%02d:%02d", hh, mm, ss);
Serial.println(str);
break;
}
}
リクエストを送信したら、今度はサーバからのレスポンスを待ちます。レスポンスのデータが届くまでwhile(true)で無限ループしています。データが届いているかどうかを確認するには WiFiUDP.parsePacket()関数を使用しています。
書式
int WiFiUDP.parsePacket()
引数
なし
返却値
受信したパケットのサイズをintで返却します。
解説
受信バッファ内のUDPパケットのバイト数を取得します。
WiFiUDP.parsePacket()関数は本来、受信したUDPパケットのサイズを取得する関数ですが、C++では条件式に結果が数値となる式を用いると 0(未受信)を false/0以外を true と読み替えるので、if(client.parsePacket()) とすれば受信データがあったときのみ処理を実行することができます。
受信したパケットの内容を読み出す
client.read(buffer, NTP_PACKET_SIZE);
受信バッファからデータを読み出すには、WiFiUDP.read()関数を使用します。
書式
int WiFiUDP.read()
または
int WiFiUDP.read(uint8_t* buffer, size_t size)
引数
名前 | 型 | 意味 |
---|---|---|
buffer | uint8_t* | データを受け取る領域へのポインタ |
size | size_t | 受け取るデータのサイズ(バイト) |
返却値
引数なしの場合、受信バッファから読みだした1バイトのデータ
引数ありの場合、読み出したデータのバイト数
解説
UDP受信バッファからデータを読み出します。引数あり・なしで動作がまったく違います。
引数なしの場合、受信バッファから1バイトのデータを読み出し、int値として返します。読み出すべきデータがなかった場合は-1を返します。
引数ありの場合、受信バッファからbufferで示される領域にsizeバイト分のデータをコピーします。よって、あらかじめ配列やmallocなどでsizeバイト以上の領域を確保しておく必要があります。この場合、返却値は読みだしたデータのバイト数です。
この例では、リクエストパケットの作成にも使用したグローバル変数bufferの領域に、NTPのパケットサイズ NTP_PACKET_SIZE(=48)バイトのデータをまとめて読みだしています。
現在時刻を計算する
// 時刻(Transmit Timestamp)の取得
unsigned long transTime = buffer[40]<<24 | buffer[41]<<16 | buffer[42]<<8 | buffer[43];
int hh = (transTime % 86400L) / 3600;
hh = (hh + 9) % 24; // UTC->JST
int mm = (transTime % 3600) / 60;
int ss = (transTime % 60);
NTPでは本来、ネットワーク伝達にかかる時間による誤差を最小限にするような工夫がなされているのですが、この例ではプログラムを簡単にするために Transmit Timestampフィールド(サーバ側がレスポンスパケットを送出した時刻)をそのまま表示しています。
Transmit Timestampフィールドは64bitの固定小数点数としてパケットの第40~47バイト(先頭を第0バイトとする)に格納されています。この例ではさらに手抜きとして、整数部分である上位32bit(第40~43バイト)のみ使用しています。
計算は単純です。
まず4バイトのデータを1つのunsigned long数として組み立てます。
データは上位バイトから順に格納されているので、
unsigned long transTime = buffer[40]<<24 | buffer[41]<<16 | buffer[42]<<8 | buffer[43];
となります。結果は変数transTimeに格納されます。
『時』と求める
先に説明したとおり、transTimeが示す値は1900年1月1日0時0分0秒からの経過秒数です。
1日は86400秒(=『1分=60秒』×『1時間=60分』×『1日=24時間』)なので、毎日0時0分0秒を表す秒数は86400の整数倍となります。
よって、transTimeを86400で割った余りが、『その日の0時0分0秒』からの経過秒数となります。
『現在までに何度も閏秒があったので、その分だけNTPの秒数がずれているのではないか?』という疑問を持つ人もいると思いますが、実はNTPの時刻は閏秒のとき同じ秒数で足踏みをするようになっているのです。よって2024年現在も、毎日0時0分0秒にNTPサーバが返す秒数は86400の整数倍です。
『その日になってからの経過秒数』を3600で割った整数部分が、時刻の『時』になります。これを計算しているのが、
int hh = (transTime % 86400L) / 3600;
の部分です。これでUTC(協定世界時)が得られます。
日本では時差によりUTCより9時間進んだ JST(日本標準時)を使用していますので、
hh = (hh + 9) % 24; // UTC->JST
として、求めた『時』を変数hhに代入しています。
『分』『秒』を求める
transTimeでは毎正時(?時0分0秒)は3600の倍数になるので、transTimeを3600で割った余りは正時からの経過秒数になります。これをさらに60で割った整数部分が『分になります』
同様に、transTimeでは『?分0秒』は60の倍数になるので、transTimeを60で割った余りが『秒』となります。
int mm = (transTime % 3600) / 60;
int ss = (transTime % 60);
このように『分』を変数mm、『秒』を変数ssに代入しています。
結果を表示する
char str[10];
sprintf(str, "%02d:%02d:%02d", hh, mm, ss);
Serial.println(str);
break;
時刻を 00:00:00 の形式で観やすく表示するため、sprintf()関数を使って文字列を組み立て、Serial.println()関数でシリアル出力しています。
これでNTPからのレスポンスの処理が終了するので、breakで受信待ちループから脱出しています。
例によってloop()は空
void loop(){
}
今回の例でもsetup()関数の中ですべての処理が終わっているのでloop()関数は空です。
52~74行目をloop()関数の中に移動すると繰り返し時刻を表示するようになりますが、その場合は各公開サーバの規定に従ったアクセス頻度/間隔となるようにして下さい。たとえば日本で一番有名なNTPサーバである ntp.nict.jp では、アクセス頻度を『1時間あたり20回まで』としています。
動作確認
Arduino IDEのシリアルモニタなどでこのように表示されればOKです。
まとめ
- Arduino UNO R4でUDP通信を行うには、WiFiUDPクラスを使う
コメント