お久しぶりです、ψ(プサイ)です。
今日は今やってるプロジェクトとは一切関係なく、gmtime/localtimeの実装が気になったので、読んで分かったことをまとめておきます。
軽くおさらいですが、gmtime/localtimeは、unixtime(1970年1月1 00:00:00 AM UTCからの経過秒数)をtm構造体に入った年・月・日・時間・分・秒に変換してくれるCの標準ライブラリ関数です。unixtimeのままでは人間が到底読めないので、表示する時などに必要な関数です。
思えば、このgmtime/localtimeの実装は結構めんどくさそうです。すごくざっくりで良いなら、
年=(int)1970+(unixtime/(24*365*3600))
…
みたいな感じで簡単に算出できますが、これでは全然駄目な点がたくさんあります。
- うるう年は?
- うるう秒は?
- (localtimeの時)時差は?
どうやるか自体は大体想像できると思いますが、折角なので読んでみました。そんな具体的な話には興味ないという方は飛ばして下さい。
もくじ
newlibのgmtimeを読んでみよう
newlibは標準Cライブラリの軽量な実装です。組み込みの分野でよく使われていて、コンパクトなのが特徴だそうです。
ためしに、このライブラリでのgmtimeを読んでみます。newlib/libc/time以下のフォルダにあります。
gmtime.c
struct tm * _DEFUN (gmtime, (tim_p), _CONST time_t * tim_p) { _REENT_CHECK_TM(_REENT); return gmtime_r (tim_p, (struct tm *)_REENT_TM(_REENT)); }
gmtimeはグローバル変数のtm構造体を返すのですが、ユーザの指定したtm構造体に値を格納するgmtime_rという関数もあるので、それに処理を投げているようです。その先は…
gmtime_r.c
struct tm * _DEFUN (gmtime_r, (tim_p, res), _CONST time_t * tim_p _AND struct tm *res) { return (_mktm_r (tim_p, res, 1)); }
localtimeと処理を共用しているようで、その共通の関数に投げてます。3つ目の引数の「1」は、gmtを使うという意味のようです。
mktime_r.c
struct tm * _DEFUN (_mktm_r, (tim_p, res, is_gmtime), _CONST time_t * tim_p _AND struct tm *res _AND int is_gmtime) { long days, rem; time_t lcltime; int y; int yleap; _CONST int *ip; __tzinfo_type *tz = __gettzinfo (); /* base decision about std/dst time on current time */ lcltime = *tim_p;
この関数は結構長いので、ちょっとずつ読んで行きましょう。まずはローカル変数の定義です。昔のCの規格でも動くように、ローカル変数は必ず関数(もっというとスコープ)の一番最初で宣言されているみたいです。std/dstという文字列が見えますね…サマータイムと通常時間の事です。私は最初、すっかり存在を忘れてました。。。もしオレオレ実装を作っていたらすっかり忘れていた所です。
days = ( (long)lcltime) / SECSPERDAY; rem = ( (long)lcltime) % SECSPERDAY; while (rem < 0) { rem += SECSPERDAY; --days; } while (rem >= SECSPERDAY) { rem -= SECSPERDAY; ++days; }
1970年の元旦からの日数と、残りの秒数を求めているみたいです。
if文の分岐先が中々不思議ですね。あまりを求めるときのあまりは、法(割る数)の符号に合わせて決めるはずだったと思うので、マイナスになることは無いし、法を超える事も無いはずだと思うのですが、もしかするとCの仕様ではそう決まってないのかも…?? よくわかりません(
/* compute hour, min, and sec */ res->tm_hour = (int) (rem / SECSPERHOUR); rem %= SECSPERHOUR; res->tm_min = (int) (rem / SECSPERMIN); res->tm_sec = (int) (rem % SECSPERMIN); /* compute day of week */ if ( (res->tm_wday = ( (EPOCH_WDAY + days) % DAYSPERWEEK)) < 0) res->tm_wday += DAYSPERWEEK;
時間・分・秒・曜日は特に何も考えなくても機械的に決定できるので、問題ないです。
/* compute year & day of year */ y = EPOCH_YEAR; if (days >= 0) { for (;;) { yleap = isleap(y); if (days < year_lengths[yleap]) break; y++; days -= year_lengths[yleap]; } } else { do { --y; yleap = isleap(y); days += year_lengths[yleap]; } while (days < 0); } res->tm_year = y - YEAR_BASE; res->tm_yday = days;
少し長いですが、1,970年の元旦からの日数から、年を求めています。isleap関数はうるう年の時は1を、そうでないときは0を返す関数で、year_lengthsは{365,366}の配列です。このふたつを組み合わせるとその年の日数をうるう年を考慮した上で知ることができます。
基本的には1年ずつ年数を調べて、最初に計算した1970年からの経過時間であるdaysから引いていって、1年に満たなくなるまでループを回す、っていう、素朴な実装です。
最初に分岐がありますが、相変わらず、days<0になるのはいまいちどういうケースなのか想像できないです。。。
そして最後の-YEAR_BASEですが、tm構造体のtm_yearの値は1900年からの年数を表す事になってるので、これで引いています。
ip = mon_lengths[yleap]; for (res->tm_mon = 0; days >= ip[res->tm_mon]; ++res->tm_mon) days -= ip[res->tm_mon]; res->tm_mday = days + 1;
ここまでで、daysは1年のうちでの日数を表しているので、年でやったのと同じように何月なのかを計算しています。mon_lengthsは…
static _CONST int mon_lengths[2][MONSPERYEAR] = { {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}, {31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31} } ;
こんな感じの配列で、うるう年を考慮した月の日数の一覧です。
最後の+1ですが、tm_mdayは1から始まるきまりになってるので、その調整です。
そして最後の最後にgmtimeとlocaltimeでの分岐処理が入ります。
if (!is_gmtime) //省略 else res->tm_isdst = 0;
gmtの時はtm_isdts=0に設定してるだけで簡単です。gmtにはサマータイムとかは無く、またunixtimeはGMTの1970年の元旦からの秒数なので時差もありません。
localtimeの時の処理はかなり骨がおれます。if文の中身を読んで行きましょう。
if (_daylight) { if (y == tz->__tzyear || __tzcalc_limits (y)) res->tm_isdst = (tz->__tznorth ? (*tim_p >= tz->__tzrule[0].change && *tim_p < tz->__tzrule[1].change) : (*tim_p >= tz->__tzrule[0].change || *tim_p < tz->__tzrule[1].change)); else res->tm_isdst = -1; } else res->tm_isdst = 0;
_daylightはグローバル変数で、サマータイムを使う可能性があるときにセットされます。
res->tm_isdstの判定なのですが、すいません、よくわかりません。__tznorthがfalseの時、条件が反転するみたいです。謎い…。サマータイム、嫌すぎる
→コメント覧参照ですが、これはどうやら南半球でのサマータイム(10月〜翌年3月ぐらい)の判定と、北半球でのそれとでの差のようです。これも最初まったく考えが至りませんでした。…北半球から出たことないです…。
offset = (res->tm_isdst == 1 ? tz->__tzrule[1].offset : tz->__tzrule[0].offset); hours = (int) (offset / SECSPERHOUR); offset = offset % SECSPERHOUR; mins = (int) (offset / SECSPERMIN); secs = (int) (offset % SECSPERMIN); res->tm_sec -= secs; res->tm_min -= mins; res->tm_hour -= hours;
サマータイムを考慮しつつ、時差を取得して、その分を足したり引いたりしている処理です。この時、機械的に足すので、時間が24を超えたり、マイナスになるかもしれません。ので、次で調整します。
if (res->tm_sec >= SECSPERMIN) { res->tm_min += 1; res->tm_sec -= SECSPERMIN; } else if (res->tm_sec < 0) { res->tm_min -= 1; res->tm_sec += SECSPERMIN; } if (res->tm_min >= MINSPERHOUR) { res->tm_hour += 1; res->tm_min -= MINSPERHOUR; } else if (res->tm_min < 0) { res->tm_hour -= 1; res->tm_min += MINSPERHOUR; } if (res->tm_hour >= HOURSPERDAY) { ++res->tm_yday; ++res->tm_wday; if (res->tm_wday > 6) res->tm_wday = 0; ++res->tm_mday; res->tm_hour -= HOURSPERDAY; if (res->tm_mday > ip[res->tm_mon]) { res->tm_mday -= ip[res->tm_mon]; res->tm_mon += 1; if (res->tm_mon == 12) { res->tm_mon = 0; res->tm_year += 1; res->tm_yday = 0; } } } else if (res->tm_hour < 0) { res->tm_yday -= 1; res->tm_wday -= 1; if (res->tm_wday < 0) res->tm_wday = 6; res->tm_mday -= 1; res->tm_hour += 24; if (res->tm_mday == 0) { res->tm_mon -= 1; if (res->tm_mon < 0) { res->tm_mon = 11; res->tm_year -= 1; res->tm_yday = 364 + isleap(res->tm_year + 1900); } res->tm_mday = ip[res->tm_mon]; } }
かなり長いですが、これはかなり単純で、例えば分が60を超えていたら調整のためにその上の時間を足して、…という処理を延々年まで行なっていってるだけです。
以上です。いかがだったでしょうか。一つ一つの処理はそこまで複雑ではないのですが、やはり長いですし、細かい例外もちゃんとフォローしてありましたね。ライブラリは偉大です。
閏秒は????????
さて、ここまでで気づきませんでしたか。
閏秒という概念が登場しなかった、という事実を。
そう、実はposix標準ではgmtime/localtimeは閏秒を考慮しないらしいです!えっそれ普通に困るのでは
が、しかし、glibcのソースを眺めてみると、めんどくさくてあまり読んでないのですが、次の内部関数を見ると、どうやらうるう秒を考慮していそうです。
time/tzfile.c
void __tzfile_compute (time_t timer, int use_localtime, long int *leap_correct, int *leap_hit, struct tm *tp) /* Apply its correction. */ *leap_correct = leaps[i].change; if (timer == leaps[i].transition && /* Exactly at the transition time. */ ( (i == 0 && leaps[i].change > 0) || leaps[i].change > leaps[i - 1].change)) { *leap_hit = 1; while (i > 0 && leaps[i].transition == leaps[i - 1].transition + 1 && leaps[i].change == leaps[i - 1].change + 1) { ++*leap_hit; --i; } } }
むー、なんか複雑になってきました。
ということは:同じ時刻、違うunixtime?
閏秒を考慮する場合としない場合で、当然時刻の計算が違ってくるはずです。glibはうるう秒を考慮に入れてるっぽいので、適当に決めたunixtime=1354320000で、差をチェックしてみましょう。
#include #include int main() { struct tm* result; time_t constTime = 1354320000L; result = gmtime(&constTime); printf("%04d/%02d/%02d %02d:%02d:%02d\n", result->tm_year+1900, result->tm_mon+1, //1月=0 result->tm_mday, result->tm_hour, result->tm_min, result->tm_sec); return 0; }
まずはnewlibでやってみます。なんかprintfがうまく動かないので、gdbでステップ実行しました(情弱)
結果がこんな感じ
# gdb ./gmtime_test (略) (gdb) print *result //resultの中身を表示するよ $2 = {tm_sec = 0, tm_min = 0, tm_hour = 0, tm_mday = 1, tm_mon = 11, tm_year = 112, tm_wday = 6, tm_yday = 335, tm_isdst = 0} # -> 2012/12/01 00:00:00
で、Fedora 17のデフォルトのglibc(2.15)とリンクした方でテストすると、
# ./gmtime_test 2012/12/01 00:00:00
あれれ???一緒ですね…
さらに泥沼が待っているとはこの時まだ私は知らないのであった…
タイムゾーンの設定が、2つある
glibcのところで、うるう秒を考慮にいれているようなソースがありましたね。うるう秒というのはうるう年とは違って、ランダムに挿入されます。ので、どこかに「うるう秒の一覧」を記録しておかないと、正しく計算することができません。
「うるう秒一覧」が何処にあるかというと、fedora 17の場合、/usr/share/zoneinfoというフォルダの中にあり…
Africa Chile GB Indian Mideast posixrules US America CST6CDT GB-Eire Iran MST PRC UTC Antarctica Cuba GMT iso3166.tab MST7MDT PST8PDT WET Arctic EET GMT0 Israel Navajo right W-SU Asia Egypt GMT-0 Jamaica NZ ROC zone.tab Atlantic Eire GMT+0 Japan NZ-CHAT ROK Zulu Australia EST Greenwich Kwajalein Pacific Singapore Brazil EST5EDT Hongkong Libya Poland Turkey Canada Etc HST MET Portugal UCT CET Europe Iceland Mexico posix Universal
こんな感じでたくさん入ってます。この中には時差その他のデータが入っていて、うるう秒のデータも含めることができるそうです。詳しくは:tz database – Wikipedia
これを日本について見てみると…
% zdump Japan -v Japan -9223372036854775808 = NULL Japan -9223372036854689408 = NULL Japan Sat Dec 31 14:59:59 1887 UTC = Sun Jan 1 00:18:58 1888 LMT isdst=0 gmtoff=33539 Japan Sat Dec 31 15:00:00 1887 UTC = Sun Jan 1 00:00:00 1888 JST isdst=0 gmtoff=32400 (略) Japan Fri Sep 7 16:00:00 1951 UTC = Sat Sep 8 01:00:00 1951 JST isdst=0 gmtoff=32400 Japan 9223372036854689407 = NULL Japan 9223372036854775807 = NULL
となってて、2012年の7月にあった最新のうるう秒などのデータが入ってるようには見えません。
実はright/Japanというファイルもあって、こちらを見てみると…
% zdump right/Japan -v right/Japan -9223372036854775808 = NULL right/Japan -9223372036854689408 = NULL right/Japan Sat Dec 31 14:59:59 1887 UTC = Sun Jan 1 00:18:58 1888 LMT isdst=0 gmtoff=33539 (略) right/Japan Fri Sep 7 16:00:00 1951 UTC = Sat Sep 8 01:00:00 1951 JST isdst=0 gmtoff=32400 (略) right/Japan Sat Jun 30 23:59:60 2012 UTC = Sun Jul 1 08:59:60 2012 JST isdst=0 gmtoff=32400
2012年7月の表記があるので、ちゃんとうるう秒が反映されているようだ、とわかります。
タイムゾーンを正しい方に設定してみる
やっと正しい方が分かった所で、この正しい方にタイムゾーンを設定してみましょう。TZ環境変数に正しい方のファイルへのパスを指定すると、どのタイムゾーンのファイルを使うか設定することができます。
glibcの方から先にしてみましょう。
% TZ=/usr/share/zoneinfo/right/Japan ./gmtime_test 2012/11/30 23:59:35
ふぅ。。。。さっきは12/01でしたが、うるう秒を考慮した結果25秒ほど過去に戻って11月になってしまいました。
一応、うるう秒を考慮にいれていないはずであるnewlibも試して見ました。
% TZ=/usr/share/zoneinfo/right/Japan gdb ./gmtime_test_newlib (gdb) print *result $1 = {tm_sec = 0, tm_min = 0, tm_hour = 0, tm_mday = 1, tm_mon = 11, tm_year = 112, tm_wday = 6, tm_yday = 335, tm_isdst = 0} # -> 2012/12/01 00:00:00
おおお、やっと差が出ましたね!つ、疲れた…。まとめましょう。
ちなみに毎度毎度TZ環境変数にいれなくても、システム全体で正しい方を使うようにもできます。
まとめ
- gmtime/localtime、簡単そうな処理ですがサマータイムとかうるう秒を考えたらやっぱりライブラリ使いましょう
- gmtime/localtimeはposix標準ではうるう秒を考慮しません。
- glibcでは考慮しますが、デフォルトでは無効化されていて、タイムゾーンファイルの指定を変更することで「正しい」時間を表示するように出来ます。
- する場合としない場合で、2012年現在25秒も違います。
なんていうか、…大変ですね(しみじみ)。一番の問題はやっぱりposix標準で「うるう秒を考慮しない」としてる点だと思います。今更変えられないのが非常に…悔しいですね…。