2014年6月13日金曜日

SNTP.cの改造

さて、今回はNTP時計の真髄であるSNTP.c周りの改造についてお話したいと思います。

このファイル、眺めてて真っ先に思うことは「これ、正確な時間じゃない!!!!」ってことなんですね…。はい。改造前のSNTP.cには様々な問題点が含まれています。
しかし、これは全部PIC18シリーズでも動くことを念頭に置いて設計した結果なんです。すなわち、処理を徹底的に軽くして、とりあえずだいたいの現在時刻が分かればいいな、くらいのものだってことなんですね。それはそれでコーディングのポリシーがあるから悪いことだとは思いません。

しかし、今回このSNTPパケットの解釈をし、時計合わせするプロセッサはPIC24FJ64GA004です。PIC18シリーズに比べたらかなりの処理能力の向上が見込めます。なので、多少重い処理でも、より正確な時刻を求めて計算させることくらいきっと大丈夫!

というわけで、まずはSNTP.cの改造をするために問題点を見極めてみましょう。
まず見た場所は、SNTPの主たるネットワーク処理をしている関数、SNTPClient関数です。ここでは、呼び出される度に何らかの処理をし、条件を満たしたらSNTPStateという変数を適宜変えていきます。そうすると、再びSNTPClient関数が呼ばれたときにそのSNTPStateをもとにswitchし、特定の処理をして、またSNTPStateの値を更新していくんですね。C#で言うyield的なやつ?
SNTPにはUDPが使われていて、NTPサーバーに対して時刻を要求するパケットを送ったら、時刻のUDPパケットが返ってくるわけですね。なので、その受信したパケットを処理するところが、時計合わせの最もコアになる部分なわけです。

        case SM_UDP_RECV:
            // Look for a response time packet
            if(!UDPIsGetReady(MySocket)) 
            {
                if((TickGet()) - dwTimer > NTP_REPLY_TIMEOUT)
                {
                    // Abort the request and wait until the next timeout period
                    UDPClose(MySocket);
                    //dwTimer = TickGetDiv64K();
                    //SNTPState = SM_SHORT_WAIT;
                    SNTPState = SM_HOME;
                    MySocket = INVALID_UDP_SOCKET;
                    break;
                }
                break;
            }
            
            // Get the response time packet
            w = UDPGetArray((BYTE*) &pkt, sizeof(pkt));
            UDPClose(MySocket);
            dwTimer = TickGetDiv64K();
            SNTPState = SM_WAIT;
            MySocket = INVALID_UDP_SOCKET;
            
            // Validate packet size
            if(w != sizeof(pkt)) 
            {
                break;    
            }
            
            // Set out local time to match the returned time
            dwLastUpdateTick = TickGet();
            dwSNTPSeconds = swapl(pkt.tx_ts_secs) - NTP_EPOCH;
            // Do rounding.  If the partial seconds is > 0.5 then add 1 to the seconds count.
            if(((BYTE*)&pkt.tx_ts_fraq)[0] & 0x80)
                dwSNTPSeconds++;

            break;

Microchip製のコードはこんな感じになっています。
UDPGetArray関数でUDPの受信パケットを読みだして受信サイズがちゃんとUDPパケットのサイズと合っているか確認しています。そのあとは、NTPパケットはビッグエンディアンなのでそれをリトルエンディアンに変換しています。
NTPパケットのタイムスタンプは1900年1月1日午前0時からの秒数を64bitの固定小数点型で返してきます。その64bitのうち、上位32bitが整数部で、下位32bitが小数部です。C言語の多くの処理系では現在時刻を1960年1月1日0時からの秒数で管理しており、XC16も例には漏れていないので、NTP_EPOCHで表されるその差を引いてやっています。
そして、小数部の最上位ビットを見て、そこが1なら四捨五入ということで秒を1秒繰り上げています。
はい、このライブラリは秒以下の精度を管理していないんです。せっかくのNTP時計ですからミリ秒単位で正確な時刻で刻みたいですよね。というより、コロンを0.5秒ごとにON/OFFを切り替えているので必然的にミリ秒単位の精度が必要になってきます。この点は改善が必要ですね。

ところで、NTPパケットはもちろん、NTPサーバーを出てからインターネットを経由して手元まで届きます。NTPサーバーからNTP時計までの間にはもちろん伝送経路の遅延があり、すなわち、その間に時間が進んでしまいます。なので、手元にパケットが到着した時点でそれはもはやすでに正確な時刻ではないのです。
pkt.tx_ts_secsメンバは、まあ見た目の通り、NTPサーバーがパケットを送信する瞬間の時刻です。NTPパケットには、これ以外にもう1つサーバーが送ってくる時刻情報が入っています。それは、NTPサーバーにパケットが到着した時の時刻です。すなわち、NTP時計側で時刻要求パケットを送信した時刻とその返事を受け取った時刻を記録していれば、NTPサーバーがパケットを受信してから返信するまでの時間を差し引いて1/2にすれば伝送経路の遅延時間がわかりますね。
そうやって、さらにより正確な時計合わせをすることができるようになります。ここまで説明した通り、このプログラムはNTPサーバーをパケットが出発した時刻に合わせた正確じゃない時計合わせですが、まあ、もともと1秒単位の時計合わせしかしていない時点でそんな伝送遅延にこだわるようなレベルじゃないですし、これはこれで軽い処理としての妥協点としては別に(正確な時計として使わないのならば)問題ないレベルでしょう。

もう一つ、このプログラムには問題点があります。
このプログラムでは、NTPパケットを受信したときにそのパケットに示されていた時刻をグローバル変数のdwSNTPSecondsに保管し、それと同時にGetTick関数で現在のTMR1のカウント値(32bit)を同じくグローバル変数のdwLastUpdateTickに保存しています。
後にSNTPGetUTCSeconds関数を呼び出したときに 現在のTickから前回のNTPパケットの取得時のTickを引くことで時間差がわかるので、それをNTPパケットの取得時の時刻に足して返すという仕組みになっています。
はい、 32bitなんですよ、32bit。TMR1のカウント値は、プリスケーラやポストスケーラは何も挟んでいないので、32MHzで動いているPICでは1秒で16メガ増えます。すなわち、5分弱でオーバーフローしてしまうんですね。
というわけで、SNTPGetUTCSeconds関数にはその対策が取られています。この関数が呼ばれたときはdwSNTPSecondsを現在の時刻に変更し、それと同時にdwLastUpdateTickも更新しているのです。
しかし、この仕様では「dwLastUpdateTickは変数の名前に反して前回NTPサーバーに接続した時刻ではない」「SNTPGetUTCSecondsを5分弱以上呼び出さないとバグる」という問題が残ることになります。前者は気持ちの問題ですが、後者は致命的ですね。もちろん、main関数側の実装でカバーすることはできますが、こういう仕様は私は大っ嫌いです。
Tick.cのほうの記事でも触れましたが、Tick.cではTickを48bit値でカウントしているので、SNTP.cのほうの改造だけでさらに16bit増やすことができます。しかし、それでも203.6日間SNTPGetUTCSeconds関数を呼ばなければオーバーフロー起こしてバグります。長いって言えば長いですが、到底NTPが2036年問題を起こすときやC言語の処理系が2038年問題を起こすときには及びません。だから、Tick.cのほうでTickを64bitで管理するように変更したんです。それなら36,000日以上もちます。

はい、まとまりました。
Microchip謹製のライブラリの問題点は
  • 小数以下を四捨五入している程度の精度
  • 伝送遅延を考慮していない実装
  • SNTPGetUTCSeconds関数を頻繁に呼ばないとバグる仕様
の3点のわけですね。
今回の改造では、これらを改善するコードを追加しましょう。



        case SM_UDP_RECV:
            // Look for a response time packet
            if(!UDPIsGetReady(MySocket)) 
            {
                if((TickGet()) - dwTimer > NTP_REPLY_TIMEOUT)
                {
                    // Abort the request and wait until the next timeout period
                    UDPClose(MySocket);
                    //dwTimer = TickGetDiv64K();
                    //SNTPState = SM_SHORT_WAIT;
                    SNTPState = SM_HOME;
                    MySocket = INVALID_UDP_SOCKET;
                    break;
                }
                break;
            }
            
            // Get the response time packet
            w = UDPGetArray((BYTE*) &pkt, sizeof(pkt));
            UDPClose(MySocket);
            dwTimer = TickGetDiv64K();
            SNTPState = SM_WAIT;
            MySocket = INVALID_UDP_SOCKET;
            bForceSync = FALSE;
            
            // Validate packet size
            if(w != sizeof(pkt)) 
            {
                break;    
            }

            // Set out local time to match the returned time
            dwLastUpdateTick = TickGet();
            dwSNTPSeconds = swapl(pkt.tx_ts_secs) - NTP_EPOCH;
            // Do rounding.  If the partial seconds is > 0.5 then add 1 to the seconds count.
            if(((BYTE*)&pkt.tx_ts_fraq)[0] & 0x80)
                dwSNTPSeconds++;

            {
                QWORD now,  qwReceive, qwTx, qwDelay, qwPeriod;
                
                now = MillisecondToNTPTimestamp(SNTPGetUTCMilliseconds());
                qwPeriod = now - qwLastUpdateNTPTimestamp;
                qwLastUpdateTick = TickGetQWord();

                qwReceive = PacketToNTPTimestamp(pkt.recv_ts_secs, pkt.recv_ts_fraq);
                qwTx = PacketToNTPTimestamp(pkt.tx_ts_secs, pkt.tx_ts_fraq);
                qwDelay = (now - qwLastSendNTPTimestamp - (qwTx - qwReceive)) / 2;

                qwLastUpdateNTPTimestamp = qwTx + qwDelay;

                dOscillatorError = (double)((LONGLONG)(now - qwLastUpdateNTPTimestamp)) / qwPeriod;
                dwSyncCount++;
            }

            Stratum = pkt.stratum;

            break;

受信部分はこんな感じにしました。
にしてもC言語のcase文は根底の考え方がラベルとジャンプで、各caseの中はブロックにする必要が無いので変数の定義ができずめんどくさいですね。
後半部分に改造した部分が主に入っていて、特にwhileとかifとかにもくっついていないブロックのところです。
タイムスタンプは64bitで保管し、整数部32bit、小数部32bitの固定小数点型として保存しています。はい。NTPのパケットそのままです。ただし、リトルエンディアンに変換はしています。
固定小数点は特に固定小数点型みたいなものを用意しなくても、足し算引き算に関しては整数の演算として正しい演算ができますね。というわけで、この形で伝送遅延qwDelayを計算しています。そして、送信された時刻+伝送遅延を現在時刻として保存しています。

同時に、オシレーターの誤差も計算しておいています。
現在の時刻とNTP時計によって得られた時刻情報との差を同期間隔で割っています。
また、そのような計算をしている都合上、2回目の同期以降じゃなければその誤差は有効な計算とならないので、dwSyncCountで何回同期されたかをカウントしています。

その他、Stratumの保存も行っています。StratumはNTPサーバーの時刻の純度とでも言いましょうか。NTPサーバーの時刻は別のNTPサーバーによって合わせることもできるので、そのようにして合わせた場合はStratumを増やして純度が下がっていることを示す仕組みが取られています。Stratumが1が最も正確な時計で、例えば原子時計やGPSをもとに時計合わせされたNTPサーバーがそれに値します。

ちなみに、 qwLastSendNTPTimestampはSM_UDP_SENDの中で保存しています。

        case SM_UDP_SEND:
            // Make certain the socket can be written to
            if(!UDPIsPutReady(MySocket))
            {
                UDPClose(MySocket);
                SNTPState = SM_HOME;
                MySocket = INVALID_UDP_SOCKET;
                break;
            }

            // Transmit a time request packet
            memset(&pkt, 0, sizeof(pkt));
            pkt.flags.versionNumber = 3;    // NTP Version 3
            pkt.flags.mode = 3;                // NTP Client
            pkt.orig_ts_secs = swapl(NTP_EPOCH);
            qwLastSendNTPTimestamp = MillisecondToNTPTimestamp(SNTPGetUTCMilliseconds());

            UDPPutArray((BYTE*) &pkt, sizeof(pkt));    
            UDPFlush();    
            
            dwTimer = TickGet();
            SNTPState = SM_UDP_RECV;        
            break;

このようにして大まかな機能の拡張はできたので、後はこの拡張したデータを読み出す系の関数をたくさん実装してあげればいいですね。

QWORD SNTPGetLastUpdateUTCMilliseconds(void)
{
    return NTPTimestampToMillisecond(qwLastUpdateNTPTimestamp);
}

QWORD SNTPGetUTCMilliseconds(void)
{
    QWORD qwDeltaMs = (TickGetQWord() - qwLastUpdateTick) * 1000 / TICK_SECOND;
    
    return  SNTPGetLastUpdateUTCMilliseconds() + qwDeltaMs;
}

void UTCMillisecondToLocaltime(QWORD utcms, int diffmin, struct tm *pTime)
{
    if(pTime != NULL) {
        time_t tick = ((DWORD)(utcms / 1000) + diffmin * 60) & 0x7FFFFFFF;
        struct tm *ptm = localtime(&tick);
        memcpy(pTime, ptm, sizeof(struct tm));
    }
}

char *GetSNTPServerName(char *ptr, int length)
{
    length = (length > NTP_SERVER_LENGTH) ? NTP_SERVER_LENGTH : length;

    if((ptr == NULL) || (length <= 0))
        return NULL;

    strncpy(ptr, NtpServer, length - 1);
    ptr[length - 1] = '\0';

    return ptr;
}

void SetSNTPServerName(const char *ptr)
{
    if(strlen(ptr) < NTP_SERVER_LENGTH) {
        strcpy(NtpServer, ptr);
        
        bForceSync = TRUE;
    }
}

DWORD GetSNTPQueryIntervalSec()
{
    return NtpQueryIntervalSec;
}

void SetSNTPQueryIntervalSec(DWORD sec)
{
    NtpQueryIntervalSec = sec;
}

double GetOscillatorError()
{
    if(dwSyncCount >= 2)
        return dOscillatorError;
    else
        return 0;
}

BYTE GetSNTPStratum()
{
    return Stratum;
}

この関数に含められているので気づいた方もいるかもしれませんが、接続先のNTPサーバーの変更と、同期間隔の変更も可能にしてあります。
そのために、UDPOpenEx関数の呼び出しパラメーターを多少変えたり、SM_WAITでのINVALID_UDP_SOCKETへの変遷条件を若干変更したりしています。

こんなもんですかね。

0 件のコメント:

コメントを投稿