2015年1月8日木曜日

NTP時計のうるう秒対応

2015年7月1日(JST)にうるう秒が挿入されることが決まりました。

うるう秒実施日一覧

すなわち、JSTで1秒ごとに8:59:59→8:59:60→9:00:00と時間が進んでいくことになります。
これは、現在、国際的な標準時を決めるための原子時計と地球の自転速度のズレ(自転速度がそもそも一定ではないため)によって発生する時間差を調整するためのものです。これを導入しないと、だんだん朝、昼、夜と言った太陽の昇り沈みに関する感覚と時刻が対応しなくなってきてしまいます。
最近に導入されたのは2012年7月1日(JST) で、慣例で12/31と6/30の24時直前(UTC)すなわち、1/1と7/1の9時直前(JST)に挿入されることが多いようです。また、いまのところ1分が61秒になる追加パターンしか実施されたことがないないようです。

さて、このうるう秒ですが、対応している時計は8:59:60の表示がされるそうです。
しかし、例えばWindowsをはじめとして多くの時計は対応しておらず、うるう秒が過ぎた後の時刻合わせで正確な時刻に合わせられるというのが実情のようです。そもそもうるう年と違ってうるう秒は不定期に挿入されるものなので電波時計やNTP等の外部と通信しているものでなければ原理上うるう秒に対応させられませんし、ずれは1秒しかなく、日常においては些細な差でしかありません。そのため実装が進まないんでしょうね。
もちろん、今回のNTP時計もうるう秒に対応しておりません。うるう秒の後の同期で正常な時刻になるだけです(自動内蔵オシレーター調整機能があるのでそれが多少ずれると考えられますが、後に収束すると考えられます)。

ですが、せっかくのうるう秒なので、これを機に実装してみることにしました。

まず、うるう秒をどうやって知るかです。
NTP時計では、SNTPプロトコルでインターネットを介して時刻情報を仕入れているわけですが、実はこのSNTPプロトコルにはうるう秒を通知する仕組みがあります。

http://tools.ietf.org/html/rfc5905#page-20

Leap Indicator (LI)と呼ばれる2bitのフィールドがSNTPのパケットにあり、これを見ることでその日にうるう秒が挿入されているかどうかがわかります。00(2進数)のときにうるう秒無し、01(2進数)のときに1日の最後の1分が61秒になる、10(2進数)のときに1日の最後の1分が59秒になることを示しています。もちろんこれはUTCなので、JSTでは午前9時を境にこのフィールドが変わります。

ネットワ-クによる時刻情報提供サービス(NTPサ-ビス)のうるう秒対応

上記のリンクには、2005年末に挿入されたうるう秒に関するNTPのLIの動きとNTP時刻の動きが示されています。
2005/12/31 0:00:00(UTC)になると同時にLIが01になり、2006/1/1 0:00:00(UTC)にLIが00に戻っています。そして、2005/12/31 23:59:60と2006/1/1 0:00:00はUTCタイムが同じ値になっています。
ここには60秒から00秒への遷移の間の細かいことは書いていませんね。サーバーが使用しているプログラムによっては、NTP時刻が3345062400.99から3345062400.00に戻る『時間逆行』仕様になっているものもあるらしいですし、時間が逆行しないように時間の進みを遅めている仕様になっているものもあるらしいので、このあたりはNTPサーバーに問い合わせをしないほうが無難かと思います。もしも時間逆行仕様だったら、単にLIを23:59:60か0:00:00かの判定に使って秒未満の桁をそのまま採用することができますが、本来のNTPの仕様では逆行しないようにするように言われている(まあ物理に沿っていると言えばこっちのほうが正しいですよね)ようなので、このあたりは本当に微妙なところかと思います。


さて、それでは実装の話に移ってみましょう。

typedef enum _tagLeapIndicator {
    LI_NoWarning = 0,
    LI_Increase = 1,
    LI_Decrease = 2,
} LeapIndicator;

まずはなんといってもLeapIndicatorの定義です。NTPパケットのビットフィールドにそのまま連動して、このような列挙型を作ってあげました。
次に、受信パケットについてLIの処理を追加します。(今まではこのようになっていました)

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;
    if((liLastUpdate != LI_Increase) && (pkt.flags.leapIndicator != LI_NoWarning))
        qwPeriod += (QWORD)1 << 32;
    else if((liLastUpdate != LI_Decrease) && (pkt.flags.leapIndicator != LI_NoWarning))
        qwPeriod -= (QWORD)1 << 32;

    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;

    if(qwPeriod > 0) {
        dOscillatorError = (double)((LONGLONG)(now - qwLastUpdateNTPTimestamp)) / qwPeriod;
        dwSyncCount++;
    }
}

Stratum = pkt.stratum;
liLastUpdate = pkt.flags.leapIndicator;

break;

主に改造したのは最後のブロック部分です。
まず、同期周期のqwPeriodですが、今まではうるう秒を何も考慮しておりませんでした。今回の修正では、以前の同期でLIが1秒増しで今はLI無しだった場合、以前の同期以降にうるう秒が挿入されたと言えるのでqwPeriodに1秒を足しています。同様に、LIが1秒減を示していたら1秒減らす処理をしています。
あとは、最後にliLastUpdateとして、pkt.flags.leapIndicatorを保存してあげています。

次は、LIを外から取得できるように、そういったメソッドを作ってあげます。

LeapIndicator GetLeapIndicator()
{
    QWORD lastupdate = SNTPGetLastUpdateUTCMilliseconds();
    QWORD delta = GetDeltaMs();

    switch(liLastUpdate) {
     case LI_NoWarning:
         return LI_NoWarning;
     case LI_Increase:
        if((lastupdate / 86400000) != ((lastupdate + delta - 1000) / 86400000))    //Now leap second has already passed.
            return LI_NoWarning;
        else
            return liLastUpdate;
     case LI_Decrease:
        if((lastupdate / 86400000) != ((lastupdate + delta + 1000) / 86400000))    //Now leap second has already passed.
            return LI_NoWarning;
        else
            return liLastUpdate;
    }
    return liLastUpdate;
}

BOOL IsNowLeapSecond()    //Now Leaping 23:59:60
{
    if(liLastUpdate == LI_Increase) {
        QWORD now = SNTPGetLastUpdateUTCMilliseconds() +  GetDeltaMs();

        if((now / 86400000) != ((now - 1000) / 86400000))    //Now leap second
            return TRUE;
    }
    return FALSE;
}

GetLeapIndicator()のほうですが、これは単にそのまま保存したLIの値を返してしまうと、直前のNTPサーバーとの同期は0:00:00(UTC)以前なのに今は0:00:00(UTC)過ぎという時刻のときに、その日は別にうるう秒があるわけではないのにLIが値を持ってしまうことがあります。なので、0:00:00(UTC)を過ぎたかどうかを判定して、そうでない時のみLIを返すようにしています。
じゃあ逆にうるう秒の日になったとき、うるう秒の日の前日の情報が残っててLIがNoWarningのままないんじゃね?という発想も当然出てくるかと思います。ですが、そういった場合はNTPサーバーに問い合わせない限りわかりませんし、別にそのタイミングはうるう秒が挿入されるわけでもなんでもないのでそんなに大した問題じゃないですね。

IsNowLeapSecond()は、現在うるう秒かどうかを調べるメソッドです。すなわち、23:59:60(UTC)の1秒間のみTRUEを返すメソッドです。

QWORD SNTPGetUTCMilliseconds(void)
{
    QWORD now = SNTPGetLastUpdateUTCMilliseconds() + GetDeltaMs();

    if(GetLeapIndicator() != liLastUpdate) {
        switch(liLastUpdate) {
         case LI_Increase:
            return now - 1000;
         case LI_Decrease:
            return now + 1000;
         default:
            break;
        }
    }
    return now;
}

つづいて、UTCの積算ミリ秒を取得するメソッドです。
GetLeapIndicator()は先ほど述べた通りすでにうるう秒を過ぎていたらLI_NoWarningを返しますが、liLastUpdateはNTPサーバーと同期しない限り更新されません。なので、この2者が異なる=うるう秒が過ぎてからNTPサーバーと同期されるまでの間ということになります。その場合はうるう秒に応じて1秒足したり引いたりした値を現在時刻として返しています。

さて、UTCミリ秒を表示するとき、うるう秒で1秒減のときは単に23:59:59(UTC)がスキップされるだけですので全然問題がありません。しかし、1秒増のときは23:59:60(UTC)が挿入されるので通常の手法では表示できませんよね。というわけで、そのあたりの実装をしました。

void UTCMillisecondToLocaltime(QWORD utcms, int diffmin, struct tm *pTime)
{
    if(pTime != NULL) {
        BOOL IsLeap = IsNowLeapSecond();

        if(IsLeap)
            utcms -= 1000;

        time_t tick = ((DWORD)(utcms / 1000) + diffmin * 60) & 0x7FFFFFFF;
        struct tm *ptm = localtime(&tick);
        memcpy(pTime, ptm, sizeof(struct tm));

        if(IsLeap)
            pTime->tm_sec = 60;
    }
}

diffminはUTCとのずれの分です。JSTはUTC+9なので、9 * 60を渡してもらうということになります。
1秒増のうるう秒のとき、IsLeapがTRUEになります。そのときにはNTP的には翌日0:00:00扱いになるので、とりあえず1秒引いて23:59:59にした上でlocaltime()を呼んでいます。そして、その後にstruct tmのtm_secメンバーを60にして23:59:60を示すようにしました。


これでうるう秒周りの実装が終わりました。





このように、見事にうるう秒で増える場合、増えない場合の動作ができました。

ちなみに、おまけとしてLIの値を表示する機能も追加しておきました。



さて、これで今年の7月1日が楽しみですね。

0 件のコメント:

コメントを投稿