gmtime/localtimeと閏秒の面倒くさい関係

お久しぶりです、ψ(プサイ)です。

今日は今やってるプロジェクトとは一切関係なく、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標準で「うるう秒を考慮しない」としてる点だと思います。今更変えられないのが非常に…悔しいですね…。


コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください