[apue] 一圖讀懂 Unix 時間日期常式相互關係

来源:https://www.cnblogs.com/goodcitizen/archive/2023/04/17/unix_date_time_api_relationship_in_one_picture.html
-Advertisement-
Play Games

GMT 和 UTC 時間有何區別?Unix 時間常式為何不處理閏秒?系統時區是如何設置的?哪些時間常式受夏時制影響?localtime 和 gmtime 是否共用內部存儲區?strftime 獲取第幾周使用的 %U/%V/%W 有何區別?linux date 和 mac date 語法有何區別?本文... ...


概覽

 開門見山先上圖

界定一些術語,方便後面說明:

  • GMT:格林威治平均時,太陽每天經過位於英國倫敦郊區的皇家格林威治天文臺的時間為中午 12 點,1972 年之前使用的國際標準時間,因地球在它的橢圓軌道里的運動速度不均勻,這個時刻可能和實際的太陽時相差16分鐘。
  • UTC:國際標準時間,相當於本初子午線 (即經度0度) 上的平均太陽時。UTC 時間是經過平均太陽時 (以格林威治時間 GMT 為準)、地軸運動修正後的新時標以及以秒為單位的國際原子時所綜合精算而成。
  • Epoch:日曆時間,自國際標準時間公元 1970 年 1 月 1 日 00:00:00 以來經過的秒數。

Unix 日期時間

獲取

unix 通過介面 time 將 Epoch 作為整數返回,自然的包含了日期和時間兩部分:

time_t time(time_t *tloc);

其中 time_t 在 64 位系統上是 8 位元組整數 (long long):

sizeof (time_t) = 8

在 32 位系統上可能是 4 位元組整數,沒有試。

time 常式的 tloc 參數如果不為空,則時間值也存放在由 tloc 指向的單元內。

 

如果想獲取更精準的時間,需要藉助另外的介面:

int gettimeofday(struct timeval *tv, struct timezone *tz);

時間通過參數 tv 返回:

struct timeval {
    time_t      tv_sec;     /* seconds */
    suseconds_t tv_usec;    /* microseconds */
};

除了代表 UTC 的 tv_sec 外還有代表微秒的 tv_usec,註意如果只需要精確到毫秒,需要將這個值除以 1000。

在 64 位 CentOS 上它是 8 位元組整數:

sizeof (suseconds_t) = 8, sizeof (struct timeval) = 16

不過不是所有 64 位系統這個欄位都是 long long,在 64 位 Darwin 上它是 4 位元組整數:

sizeof (suseconds_t) = 4, sizeof (struct timeval) = 16

但最終 timeval 結構體的長度還是 16,可能是記憶體對齊的緣故。

tz 參數用來指定時區信息:

struct timezone {
    int tz_minuteswest;     /* minutes west of Greenwich */
    int tz_dsttime;         /* type of DST correction */
};

因為一些原因,tz 在 SUS 標準中唯一合法值是 NULL,某些平臺支持使用 tz 說明時區,但完全沒有可移植性,例如在 Linux 上,建議這個參數設置為 NULL:

The  use  of  the  timezone  structure  is  obsolete; the tz argument should normally be specified as NULL.  (See NOTES below.)

不為 NULL 也不會報錯,但是不會修改指定參數的內容。Darwin 支持這個參數,下麵是它的日常返回:

minuteswest = -480, dsttime = 0

具體可參考時區和夏時制一節。

轉換

time_t 類型利於介面返回,但可讀性比較差,需要將它轉換為人能理解的日期和時間。

struct tm {
    int tm_sec;         /* seconds */
    int tm_min;         /* minutes */
    int tm_hour;        /* hours */
    int tm_mday;        /* day of the month */
    int tm_mon;         /* month */
    int tm_year;        /* year */
    int tm_wday;        /* day of the week */
    int tm_yday;        /* day in the year */
    int tm_isdst;       /* daylight saving time */
};

這就是 struct tm,除了年月日時分秒,還有兩個欄位 wday / yday  用於方便的展示當前周/年中的天數,另外 isdst 標識了是否為夏時制 (參考夏時制一節)。

int tm_sec;     /* seconds (0 - 60) */
int tm_min;     /* minutes (0 - 59) */
int tm_hour;    /* hours (0 - 23) */
int tm_mday;    /* day of month (1 - 31) */
int tm_mon;     /* month of year (0 - 11) */
int tm_year;    /* year - 1900 */
int tm_wday;    /* day of week (Sunday = 0) */
int tm_yday;    /* day of year (0 - 365) */
int tm_isdst;   /* is summer time in effect? */
char *tm_zone;  /* abbreviation of timezone name */
long tm_gmtoff; /* offset from UTC in seconds */

上面給出了各個欄位的取值範圍,有幾個點值得註意:

  • 秒:可取值 60,這是因閏秒的原因 (參考閏秒一節)
  • 年:從 1900 開始計數
  • mday:從 1 開始計數,設置為 0 表示上月最後一天
  • wday:從 0 開始計數,以周日作為第一天,因此 1 就是表示周一,以此類推
  • isdst:
    • > 0:夏時制生效
    • = 0:夏時制不生效
    • < 0:此信息不可用
  •  tm_zone 和 tm_gmtoff 兩個欄位是 Drawin 獨有的,作用有點類似上面介紹過的 timezone,不屬於 SUS 標準

如果直接用這個結構體顯示給用戶,經常會看到以下校正代碼:

printf ("%04d/%02d/%02d %02d:%02d:%02d", 
        tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, 
        tm.tm_hour, tm.tm_min, tm.tm_sec);

 對 yday 的處理類似 mon。

 

再複習一下開始的關係圖:

將 time_t 轉換為 struct tm 的是 localtime 和 gmtime,反過來是 mktime:

struct tm *gmtime(const time_t *timep);
struct tm *localtime(const time_t *timep);
time_t mktime(struct tm *tm);

localtime 和 gmttime 的區別是,前者將 Epoch 轉換為本地時間 (受時區、夏時制影響)、後者將 Epoch 轉換為 UTC (不受時區、夏時制影響)。

mktime 只接受本地時間作為參數、將其轉換為 Epoch,註意沒有 mkgmtime 這類東東。

mktime 並不使用 tm 參數的所有欄位,例如 wday 和 yday 就會被忽略,isdst 參數將按如下取值進行解釋:

  • > 0:啟用夏時制
  • = 0:禁用夏時制
  • < 0:依據系統或環境設置自行決定是否使用夏時制

mktime 還會自動規範化 (normalize) 各個欄位,例如 70 秒會被更新為 1 分 10 秒。除此之外,還有以下欄位會被更新:

  • wday:賦值
  • yday:賦值
  • isdst:
    • 0:不生效
    • > 0:生效
    • 不再存在 < 0 的情況

極端的情況下,struct tm 中的每個欄位都可能被修改,這也是參數 tm 沒有加 const 修飾的原因。

利用 mktime 的 normalize 特性,很容易就可以求出 "N 年/月/日/時/分/秒" 前/後的時間,像下麵這段代碼:

#include "../apue.h" 
#include <sys/time.h> 
#include <time.h> 

void print_tm (struct tm* t)
{
  printf ("%04d-%02d-%02d %02d:%02d:%02d (week day %d) (year day %d) (daylight saving time %d)\n", 
    t->tm_year + 1900, 
    t->tm_mon + 1, 
    t->tm_mday, 
    t->tm_hour, 
    t->tm_min, 
    t->tm_sec,
    t->tm_wday == 0 ? 7 : t->tm_wday, 
    t->tm_yday + 1, 
    t->tm_isdst); 
}

int main (int argc, char *argv[])
{
  if (argc < 2)
  {
    printf ("Usage: %s [+/-] [N[Y/M/D/H/m/S/w/y]]\n", argv[0]); 
    return 0; 
  }

  int ret = 0; 
  time_t now = time (NULL); 
  printf ("sizeof (time_t) = %d, now = %ld\n", sizeof(time_t), now); 

  struct tm *tm_now = localtime (&now); 
  print_tm (tm_now); 

  int shift = 0; 
  char *endptr = 0; 
  shift = strtol (argv[1], &endptr, 10); 
  switch (*endptr)
  {
    case 'Y':
      tm_now->tm_year += shift; 
      break; 
    case 'M':
      tm_now->tm_mon += shift; 
      break; 
    case 'D':
    case 'd':
      tm_now->tm_mday += shift; 
      break; 
    case 'H':
    case 'h':
      tm_now->tm_hour += shift; 
      break; 
    case 'm':
      tm_now->tm_min += shift; 
      break; 
    case 's':
    case 'S':
      tm_now->tm_sec += shift; 
      break; 
    /* 
     * tm_wday & tm_yday is ignored normally, 
     * here just do a test !!
     */
    case 'w':
    case 'W':
      tm_now->tm_wday += shift; 
      break; 
    case 'y':
      tm_now->tm_yday += shift; 
      break; 
    default:
      printf ("unkonwn postfix %c", *endptr); 
      break; 
  }

  print_tm (tm_now); 
  time_t tick = mktime (tm_now); 
  printf ("tick = %ld\n", tick); 
  print_tm (tm_now); 
  return 0; 
}

運行時隨意指定:

> ./timeshift +70s
sizeof (time_t) = 8, now = 1678544442
2023-03-11 22:20:42 (week day 6) (year day 70) (daylight saving time 0)
2023-03-11 22:20:112 (week day 6) (year day 70) (daylight saving time 0)
tick = 1678544512
2023-03-11 22:21:52 (week day 6) (year day 70) (daylight saving time 0)

觀察到增加 sec 欄位 70 秒後達到 112 秒,經過 mktime 規範化後變為 52 秒並向上進位 1 分鐘。

這個 demo 還可以用來驗證設置 wday 或 yday 沒有效果,例如:

> ./timeshift +100y
sizeof (time_t) = 8, now = 1678544584
2023-03-11 22:23:04 (week day 6) (year day 70) (daylight saving time 0)
2023-03-11 22:23:04 (week day 6) (year day 170) (daylight saving time 0)
tick = 1678544584
2023-03-11 22:23:04 (week day 6) (year day 70) (daylight saving time 0)

直接被忽略了,yday 根據其它欄位推導,恢復了 70 的初始值。

同時也可以驗證 mday = 0 時其實是指上個月最後一天,例如:

> ./timeshift -11d
sizeof (time_t) = 8, now = 1678544711
2023-03-11 22:25:11 (week day 6) (year day 70) (daylight saving time 0)
2023-03-00 22:25:11 (week day 6) (year day 70) (daylight saving time 0)
tick = 1677594311
2023-02-28 22:25:11 (week day 2) (year day 59) (daylight saving time 0)

觀察到 2023-03-00 其實是 2023-02-28。

閏秒

為了減少學習曲線,一些相對零碎的概念將在遇到的時候再行說明,閏秒就屬於這種情況。在解釋閏秒之前,先介紹兩個新的術語:

  • TAI:原子時間,基於銫原子的能級躍遷原子秒作為時標,結合了全球 400 個所有的原子鐘而得到的時間,它決定了我們每個人的鐘錶中時間流動的速度
  • UT:世界時間,也稱天文時間,或太陽時,他的依據是地球的自轉,我們用它來確定多少原子時對應於一個地球日的時間長度

在確定 TAI 起點之後,由於地球自轉速度有變慢的趨勢 (非常小),UT 與 TAI 之間的時差便逐年積累。為彌補這一差距,便採用跳秒 (閏秒) 的方法使 TAI 與 UT 的時刻相接近,其差不超過 1 秒,這樣既保持時間尺度的均勻性,又能近似地反映地球自轉的變化。一般會在每年的 6 月 30 日、12 月 31 日的最後一秒進行調整。

現在回過頭來看 UTC 的定義——UTC 時間是經過平均太陽時、地軸運動修正後的新時標以及以秒為單位的國際原子時所綜合精算而成——是不是加深了印象?可以認為 UTC 是參考 TAI 增加閏秒的 UT。

較早的 SUS 標準允許雙閏秒,tm_sec 的取值範圍是 [0-61],UTC 的正式定義不允許雙閏秒,所以後來的 tm_sec 的的範圍被定義為 [0-60]。

不過 gmtime / localtime / mktime 都不處理閏秒,以最近的閏秒為例,2016/12/31 23:59:60,通過 linux date 命令來驗證:

> date -d "2016/12/31 23:59:59" "+%s"
1483199999
> date -d @1483200000
Sun Jan  1 00:00:00 CST 2017

先反解 2016/12/31 23:59:59 的 Epoch,將其加一秒後再通過 date 展示為直觀的時間,發現並沒有展示為 23:59:60,而是直接進入 2017 年。

難道是示例中的這個閏秒太"新"了?找個老一點的閏秒試試:

> date -d "1995/12/31 23:59:59" "+%s"
820425599
> date -d @820425600
Mon Jan  1 00:00:00 CST 1996

1995 年這個同樣不行。使用 mktime 傳遞 struct tm 的方式也試了,效果一樣。

特別是直接反解閏秒時,date 直接報錯:

> date -d "2016/12/31 23:59:60" 
date: invalid date ‘2016/12/31 23:59:60’

上面特別強調使用 linux date,因為 mac date 有另外一套語法:

> date -j -f "%Y/%m/%d %H:%M:%S" "2016/12/31 23:59:59" "+%s"
1483199999
> date -r 1483200000 "+%Y/%m/%d %H:%M:%S"
2017/01/01 00:00:00

這一點需要註意。

 

來看一下閏秒的分佈:

可見是完全沒有規律的,甚至沒辦法提前把幾年之後的閏秒寫到系統庫里,要讓庫可以長久的使用,只有不去管它。

想象一下 gmtime 是如何根據 Epoch 計算時間的:

  • 首先確定年份
    • 平年按 365 天計算一年的秒數,一天固定 86400 秒
    • 如果是閏年,則按 366 天計算一年的秒數
    • 計算所給的 Epoch 經歷了幾個閏年,閏年就是年份能被 4 整除卻不能被 100 整除
  • 再依次計算月、天、時、分、秒,其中計算月、日時仍要考慮閏月的影響

壓根不可能處理閏秒這種複雜的東東,反過來看,這個介面叫 gmtime 而不是 utctime 也是有道理的。

總結一下:基於 POSIX 標準的系統不處理閏秒,不過這並不影響它的精度,因為絕大多數時間來講,GMT 時間和 UTC 給用戶展示的字元串是一致的,畢竟 GTC 多出來的閏秒被安插在了 59:59:60 這種時間位置,對後面的時間沒有影響。唯一的區別在於,GTC 時間的 time_t 表示會比 GMT 多那麼幾十秒,除非要精確計算大跨度時間範圍內的絕對時間差,才需要用到閏秒。

時區

從格林威治本初子午線起,經度每向東或者向西間隔 15°,就劃分一個時區,在這個區域內,大家使用同樣的標準時間。

但實際上,為了照顧到行政上的方便,常將一個國家或一個省份劃在一起。所以時區並不嚴格按南北直線來劃分,而是按自然條件來劃分。

全球共分為24個標準時區,相鄰時區的時間相差一個小時。全部的時區定義:Time Zone Abbreviations – Worldwide List

中國位於東八區 (UTC+8),沒有像美國那樣劃分多個時區,中國一整個都在一個時區:CST。

不過由於幅員遼闊,新疆烏魯木齊實際位於東六區,早上 9 點才相當於北京早上 7 點,因此如果觀察一個國內伺服器早高峰,會發現新疆是最後上線的。

時區是導致同一個系統 localtime 和 gmtime 返回值有差異的主要原因。回顧一下開始的關係圖:

紅色表示介面會受時區影響,以 localtime 為例,man 中是這樣解釋它如何獲取當前時區設置的:

  • TZ 環境變數,形如 Asia/Shanghai 的字元串
    • 為空:UTC
    • 解析成功:設置 tzname、timezone 等全局變數
    • 解析不成功:UTC
  • 系統時區設置
    • CentOS - /etc/localtime -> /usr/share/zoneinfo/Asia/Shanghai
    • Darwin  - /etc/localtime -> /var/db/timezone/zoneinfo/Asia/Shanghai
    • Ubuntu  - /etc/timezone: Asia/Shanghai
    • ...

TZ 環境變數

優先檢查 TZ 環境變數,如果存在,不論是否有效,都不再檢查系統設置。

void tzset (void);

extern char *tzname[2];
extern long timezone;
extern int daylight;

tzset 介面用於將 TZ 中的信息解析到全局變數 tzname / timezone / daylight 欄位,紅色介面通過調用它來設置正確的時區、夏時制等信息,用於後期時間轉換。

下麵的程式片段演示了各個調用對 tzset 的調用情況:

#include "../apue.h" 
#include <sys/time.h> 
#include <time.h> 

struct timezone
{
    int     tz_minuteswest; /* of Greenwich */
    int     tz_dsttime;     /* type of dst correction to apply */
}; 

void print_tm (struct tm* t)
{
  printf ("%04d-%02d-%02d %02d:%02d:%02d (week day %d) (year day %d) (daylight saving time %d)\n", 
    t->tm_year + 1900, 
    t->tm_mon + 1, 
    t->tm_mday, 
    t->tm_hour, 
    t->tm_min, 
    t->tm_sec, 
    t->tm_wday == 0 ? 7 : t->tm_wday, 
    t->tm_yday + 1, 
    t->tm_isdst); 
}

void print_tz ()
{
  printf ("tzname[0] = %s, tzname[1] = %s, timezone = %d, daylight = %d\n", tzname[0], tzname[1], timezone, daylight); 
}

int main (int argc, char *argv[])
{
  int ret = 0; 
  time_t t1, t2; 
  print_tz (); 
  t1 = time (&t2); 
  printf ("t1 = %ld, t2 = %ld\n", t1, t2); 
  print_tz (); 

  struct timeval tv; 
  struct timezone tzp; 
  ret = gettimeofday (&tv, (void*) &tzp); 
  if (ret == -1)
    perror("gettimeofday"); 

  printf ("sizeof (suseconds_t) = %d, sizeof (struct timeval) = %d, ret %d, tv.sec = %ld, tv.usec = %ld\n", 
          sizeof (suseconds_t), sizeof (struct timeval), ret, tv.tv_sec, tv.tv_usec); 
  printf ("minuteswest = %d, dsttime = %d\n", tzp.tz_minuteswest, tzp.tz_dsttime); 
  print_tz (); 

  struct tm *tm1 = gmtime (&t1); 
  print_tm (tm1); 
  print_tz (); 

  struct tm *tm2 = localtime (&t2); 
  print_tm (tm2); 
  print_tz (); 

  time_t t3 = mktime (tm1); 
  printf ("t3 = %ld\n", t3); 
  print_tz (); 

  printf ("from asctime: %s", asctime (tm1)); 
  print_tz (); 

  printf ("from ctime: %s", ctime (&t1)); 
  print_tz (); 
  return 0; 
}

上面的 demo 演示了在各個時間常式調用後的時區信息 (print_tz),以便觀察是否間接調用了 tzset,先來看 Darwin 上運行的結果:

> ./time
tzname[0] =    , tzname[1] =    , timezone = 0, daylight = 0
t1 = 1679811210, t2 = 1679811210
tzname[0] =    , tzname[1] =    , timezone = 0, daylight = 0
sizeof (suseconds_t) = 4, sizeof (struct timeval) = 16, ret 0, tv.sec = 1679811210, tv.usec = 909062
minuteswest = -480, dsttime = 0
tzname[0] = CST, tzname[1] = CDT, timezone = -28800, daylight = 1
2023-03-26 06:13:30 (week day 7) (year day 85) (daylight saving time 0)
tzname[0] = CST, tzname[1] = CDT, timezone = -28800, daylight = 1
2023-03-26 14:13:30 (week day 7) (year day 85) (daylight saving time 0)
tzname[0] = CST, tzname[1] = CDT, timezone = -28800, daylight = 1
t3 = 1679782410
tzname[0] = CST, tzname[1] = CDT, timezone = -28800, daylight = 1
from asctime: Sun Mar 26 06:13:30 2023
tzname[0] = CST, tzname[1] = CDT, timezone = -28800, daylight = 1
from ctime: Sun Mar 26 14:13:30 2023
tzname[0] = CST, tzname[1] = CDT, timezone = -28800, daylight = 1

從輸出中可以看到:

  • 時區預設為空,timezone 時間偏移為 0,daylight 為 false
  • 調用 time 對時區沒有影響
  • 調用 gettimeofday 並傳遞時區信息,在 Darwin 上有時區信息返回並更改了它們:時區 CST:CDT, timezone 為 +8 小時,daylight 為 true
  • 調用 gmtime/localtime/mktime/asctime/ctime 後時區信息不變

由於後五個輸出一致,為防止相互干擾,可以通過調整調用順序,或手動屏蔽其它調用來觀察輸出,結論是一致的。

需要註意的一點是,mktime 和 asctime 的結果是正確的,這是因為它們使用了 gmtime 的返回值,將其作為本地時間處理了,這直接導致 t3 比 t1 小了 28800 秒。

> ./time
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
t1 = 1679810748, t2 = 1679810748
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
sizeof (suseconds_t) = 8, sizeof (struct timeval) = 16, ret 0, tv.sec = 1679810748, tv.usec = 451237
minuteswest = 0, dsttime = 0
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
2023-03-26 06:05:48 (week day 7) (year day 85) (daylight saving time 0)
tzname[0] = CST, tzname[1] = CDT, timezone = -28800, daylight = 1
2023-03-26 14:05:48 (week day 7) (year day 85) (daylight saving time 0)
tzname[0] = CST, tzname[1] = CST, timezone = -28800, daylight = 0
t3 = 1679810748
tzname[0] = CST, tzname[1] = CST, timezone = -28800, daylight = 0
from asctime: Sun Mar 26 14:05:48 2023
tzname[0] = CST, tzname[1] = CST, timezone = -28800, daylight = 0
from ctime: Sun Mar 26 14:05:48 2023
tzname[0] = CST, tzname[1] = CST, timezone = -28800, daylight = 0

上面是在 linux 系統上運行的結果,和 Darwin 有以下不同:

  • 時區預設為 GMT:GMT
  • 調用 gettimeofday 並傳遞時區信息的情況下沒有返回信息,也沒有更改它們,也就是說和調用 time 效果一致
  • 調用 gmtime 後時區變為 CST:CDT,timezone 為 +8 小時,daylight 為 true,這和書上講的有出入,理應不變才對。不過並沒有影響介面返回的結果
  • 調用 localtime/mktime/asctime/ctime 後時區變為 CST:CST,timezone +8 小時,daylight 為 false, 但是 mktime 和 asctime 結果不正確

其中 mktime 在使用 gmtime 的結果作為輸入後,居然得到了和 time 一樣的結果,實在是匪夷所思,導致後面的 asctime 結果也跟著出錯。轉念一想,是否是因為 gmtime 和 localtime 返回了同一塊靜態存儲區呢?加入下麵的一行代碼印證:

  printf ("gmt %p, local %p\n", tm1, tm2); 

新日誌顯示果然如此:

gmt 0x7f2206a8cda0, local 0x7f2206a8cda0

而在 Darwin 上則不同:

gmt 0x7facbce04330, local 0x7facbcc058d0

看來 Darwin 確實做的要稍好一些,不同介面返回了不同靜態緩衝區。不過對於這種靜態對象,能不緩存還是不要緩存了,免的同類型的相互覆蓋,下麵是 linux 改進後的輸出:

> ./time
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
t1 = 1679814314, t2 = 1679814314
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
sizeof (suseconds_t) = 8, sizeof (struct timeval) = 16, ret 0, tv.sec = 1679814314, tv.usec = 70725
minuteswest = 0, dsttime = 0
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
2023-03-26 07:05:14 (week day 7) (year day 85) (daylight saving time 0)
tzname[0] = CST, tzname[1] = CDT, timezone = -28800, daylight = 1
2023-03-26 15:05:14 (week day 7) (year day 85) (daylight saving time 0)
tzname[0] = CST, tzname[1] = CST, timezone = -28800, daylight = 0
t3 = 1679785514
tzname[0] = CST, tzname[1] = CST, timezone = -28800, daylight = 0
from asctime: Sun Mar 26 07:05:14 2023
tzname[0] = CST, tzname[1] = CST, timezone = -28800, daylight = 0
from ctime: Sun Mar 26 15:05:14 2023
tzname[0] = CST, tzname[1] = CST, timezone = -28800, daylight = 0

mktime 終於正常了。至於 linux gmtime 是否調用了 tzset 的問題,留待以後瀏覽 glibc 源碼再行確認。

系統時區設置

在沒有定義 TZ 環境變數時,會查找當前的系統時區設置。系統時區表示方式隨系統不同而不同:

  • CentOS 是文件 /etc/localtime 鏈接到 /usr/share/zoneinfo 目錄下的一個時區文件
  • Ubuntu 則是在 /etc/timezone 文件中直接記錄了時區信息
  • Darwin 和 CentOS 類似,只是鏈接目標不同,到了 /var/db/timezone/zoneinfo 目錄下麵

時區一般在安裝系統時進行設置,也可以在系統設置面板中更改。在某些沒有 GUI 的場景中 (遠程 ssh),也可以通過 tzselect 來更改時區:

查看代碼
 > tzselect
Please identify a location so that time zone rules can be set correctly.
Please select a continent or ocean.
 1) Africa
 2) Americas
 3) Antarctica
 4) Arctic Ocean
 5) Asia
 6) Atlantic Ocean
 7) Australia
 8) Europe
 9) Indian Ocean
10) Pacific Ocean
11) none - I want to specify the time zone using the Posix TZ format.
#? 5
Please select a country.
 1) Afghanistan		  18) Israel		    35) Palestine
 2) Armenia		  19) Japan		    36) Philippines
 3) Azerbaijan		  20) Jordan		    37) Qatar
 4) Bahrain		  21) Kazakhstan	    38) Russia
 5) Bangladesh		  22) Korea (North)	    39) Saudi Arabia
 6) Bhutan		  23) Korea (South)	    40) Singapore
 7) Brunei		  24) Kuwait		    41) Sri Lanka
 8) Cambodia		  25) Kyrgyzstan	    42) Syria
 9) China		  26) Laos		    43) Taiwan
10) Cyprus		  27) Lebanon		    44) Tajikistan
11) East Timor		  28) Macau		    45) Thailand
12) Georgia		  29) Malaysia		    46) Turkmenistan
13) Hong Kong		  30) Mongolia		    47) United Arab Emirates
14) India		  31) Myanmar (Burma)	    48) Uzbekistan
15) Indonesia		  32) Nepal		    49) Vietnam
16) Iran		  33) Oman		    50) Yemen
17) Iraq		  34) Pakistan
#? 9
Please select one of the following time zone regions.
1) Beijing Time
2) Xinjiang Time
#? 1

The following information has been given:

	China
	Beijing Time

Therefore TZ='Asia/Shanghai' will be used.
Local time is now:	Sun Mar 12 17:37:12 CST 2023.
Universal Time is now:	Sun Mar 12 09:37:12 UTC 2023.
Is the above information OK?
1) Yes
2) No
#? 1

You can make this change permanent for yourself by appending the line
	TZ='Asia/Shanghai'; export TZ
to the file '.profile' in your home directory; then log out and log in again.

Here is that TZ value again, this time on standard output so that you
can use the /usr/bin/tzselect command in shell scripts:
Asia/Shanghai

根據提示一步步選擇就可以了,註意這個命令執行後時區並沒有變更,它只是根據用戶選擇的地區提供了 TZ 環境變數的內容,後續還需要用戶手動設置一下,最終還是走的環境變數的方式,畢竟這種方式有優先順序,能影響最終的結果。如果不想設置環境變數,也直接更改系統文件內容 (Ubuntu) 或軟鏈接指向 (CentOS/Darwin),這種需要提權,必需有管理員許可權才可以。

CentOS 和 Darwin 上的時區文件為二進位,可以通過 zdump 查看:

查看代碼
 > zdump -v /usr/share/zoneinfo/Asia/Shanghai 
/usr/share/zoneinfo/Asia/Shanghai  -9223372036854775808 = NULL
/usr/share/zoneinfo/Asia/Shanghai  -9223372036854689408 = NULL
/usr/share/zoneinfo/Asia/Shanghai  Mon Dec 31 15:54:16 1900 UTC = Mon Dec 31 23:59:59 1900 LMT isdst=0 gmtoff=29143
/usr/share/zoneinfo/Asia/Shanghai  Mon Dec 31 15:54:17 1900 UTC = Mon Dec 31 23:54:17 1900 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 12 15:59:59 1919 UTC = Sat Apr 12 23:59:59 1919 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 12 16:00:00 1919 UTC = Sun Apr 13 01:00:00 1919 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Tue Sep 30 14:59:59 1919 UTC = Tue Sep 30 23:59:59 1919 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Tue Sep 30 15:00:00 1919 UTC = Tue Sep 30 23:00:00 1919 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Fri May 31 15:59:59 1940 UTC = Fri May 31 23:59:59 1940 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Fri May 31 16:00:00 1940 UTC = Sat Jun  1 01:00:00 1940 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Oct 12 14:59:59 1940 UTC = Sat Oct 12 23:59:59 1940 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Oct 12 15:00:00 1940 UTC = Sat Oct 12 23:00:00 1940 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Fri Mar 14 15:59:59 1941 UTC = Fri Mar 14 23:59:59 1941 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Fri Mar 14 16:00:00 1941 UTC = Sat Mar 15 01:00:00 1941 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Nov  1 14:59:59 1941 UTC = Sat Nov  1 23:59:59 1941 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Nov  1 15:00:00 1941 UTC = Sat Nov  1 23:00:00 1941 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Fri Jan 30 15:59:59 1942 UTC = Fri Jan 30 23:59:59 1942 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Fri Jan 30 16:00:00 1942 UTC = Sat Jan 31 01:00:00 1942 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep  1 14:59:59 1945 UTC = Sat Sep  1 23:59:59 1945 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep  1 15:00:00 1945 UTC = Sat Sep  1 23:00:00 1945 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Tue May 14 15:59:59 1946 UTC = Tue May 14 23:59:59 1946 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Tue May 14 16:00:00 1946 UTC = Wed May 15 01:00:00 1946 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Mon Sep 30 14:59:59 1946 UTC = Mon Sep 30 23:59:59 1946 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Mon Sep 30 15:00:00 1946 UTC = Mon Sep 30 23:00:00 1946 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Mon Apr 14 15:59:59 1947 UTC = Mon Apr 14 23:59:59 1947 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Mon Apr 14 16:00:00 1947 UTC = Tue Apr 15 01:00:00 1947 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Fri Oct 31 14:59:59 1947 UTC = Fri Oct 31 23:59:59 1947 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Fri Oct 31 15:00:00 1947 UTC = Fri Oct 31 23:00:00 1947 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Fri Apr 30 15:59:59 1948 UTC = Fri Apr 30 23:59:59 1948 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Fri Apr 30 16:00:00 1948 UTC = Sat May  1 01:00:00 1948 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Thu Sep 30 14:59:59 1948 UTC = Thu Sep 30 23:59:59 1948 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Thu Sep 30 15:00:00 1948 UTC = Thu Sep 30 23:00:00 1948 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 30 15:59:59 1949 UTC = Sat Apr 30 23:59:59 1949 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 30 16:00:00 1949 UTC = Sun May  1 01:00:00 1949 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Fri May 27 14:59:59 1949 UTC = Fri May 27 23:59:59 1949 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Fri May 27 15:00:00 1949 UTC = Fri May 27 23:00:00 1949 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat May  3 17:59:59 1986 UTC = Sun May  4 01:59:59 1986 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat May  3 18:00:00 1986 UTC = Sun May  4 03:00:00 1986 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 13 16:59:59 1986 UTC = Sun Sep 14 01:59:59 1986 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 13 17:00:00 1986 UTC = Sun Sep 14 01:00:00 1986 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 11 17:59:59 1987 UTC = Sun Apr 12 01:59:59 1987 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 11 18:00:00 1987 UTC = Sun Apr 12 03:00:00 1987 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 12 16:59:59 1987 UTC = Sun Sep 13 01:59:59 1987 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 12 17:00:00 1987 UTC = Sun Sep 13 01:00:00 1987 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 16 17:59:59 1988 UTC = Sun Apr 17 01:59:59 1988 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 16 18:00:00 1988 UTC = Sun Apr 17 03:00:00 1988 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 10 16:59:59 1988 UTC = Sun Sep 11 01:59:59 1988 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 10 17:00:00 1988 UTC = Sun Sep 11 01:00:00 1988 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 15 17:59:59 1989 UTC = Sun Apr 16 01:59:59 1989 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 15 18:00:00 1989 UTC = Sun Apr 16 03:00:00 1989 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 16 16:59:59 1989 UTC = Sun Sep 17 01:59:59 1989 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 16 17:00:00 1989 UTC = Sun Sep 17 01:00:00 1989 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 14 17:59:59 1990 UTC = Sun Apr 15 01:59:59 1990 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 14 18:00:00 1990 UTC = Sun Apr 15 03:00:00 1990 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 15 16:59:59 1990 UTC = Sun Sep 16 01:59:59 1990 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 15 17:00:00 1990 UTC = Sun Sep 16 01:00:00 1990 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 13 17:59:59 1991 UTC = Sun Apr 14 01:59:59 1991 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 13 18:00:00 1991 UTC = Sun Apr 14 03:00:00 1991 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 14 16:59:59 1991 UTC = Sun Sep 15 01:59:59 1991 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 14 17:00:00 1991 UTC = Sun Sep 15 01:00:00 1991 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  9223372036854689407 = NULL
/usr/share/zoneinfo/Asia/Shanghai  9223372036854775807 = NULL

也可以指定相對路徑或國家縮寫,如 zdump -v Asia/Shanghaizdump -v PRC,輸出信息一致。看起來文件內容中包含了該時區對應的夏時制起始時間,怪不得文件尺寸各不相等。這或許就是 Darwin 中 gettimeofday 返回當前時間是否處於夏時制的依據,關於夏時制,請參考下節。

不加 -v 選項調用 zdump,會返回時區的當前時間:

> zdump America/New_York 
America/New_York  Sun Mar 26 03:31:01 2023 EDT
> zdump PRC
PRC  Sun Mar 26 15:31:05 2023 CST
> date
Sun Mar 26 15:31:10 CST 2023

可以看到和 date 命令的輸出有些許差別。如果時區不合法或沒找到,通通返回 GMT 時間。

夏時制

夏時制也稱夏令時 (Daylight Saving Time),直譯過來就是日光節約時間制。這是因為北半球夏季時白天變長、夜晚變短,有人認為通過推行夏時制可以有效利用天光,節約晚上能源消耗。

具體操作就是,在進入夏季某天後,統一將時鐘調快一小時,此時早上七點將變為早上八點,提早開始上班上學,晚上五點將變為晚上六點,提早開始下班放學。即通過讓人早起早睡來達到多利用天光的目的,而且統一調整時間制後,學校、公司都不用調整了,省去了很多不一致的地方。到某個夏季結束的一天,再統一將時鐘調慢一小時,人們又可以晚起晚睡了,自此時間恢復到往常一樣。

我國曾實行過六年的夏時制 (1986-1991),發現對社會節約用電效果有限,另外還有其它弊端,例如切換夏時制後睡眠不足導致的車禍、列車時刻表的調整、全國一個時區帶來的偏遠地區時差更大等等問題,最終放棄了這一做法。歐盟也在 2021 年投票廢棄了夏時制,目前在執行夏時制的比較大的國家就剩美國、加拿大、澳大利亞等。

再來複習一下文章開關的關係圖:

其中虛線部分表示受夏時制影響,POSIX 時間常式中的 time、gettimeofday 不考慮夏時制,否則 Epoch 憑空多了 3600 或少了 3600 是什麼鬼。下麵舉個例子:

> date 
Sun Mar 26 16:00:40 CST 2023
> export TZ=America/New_York
> date
Sun Mar 26 04:00:51 EDT 2023
> zdump America/New_York
America/New_York  Sun Mar 26 04:00:55 2023 EDT

已知美國紐約在 2023-03-12 已進入夏時制,持續直到 11-05:

> zdump -v America/New_York | grep 2023 
...
America/New_York  Sun Mar 12 06:59:59 2023 UTC = Sun Mar 12 01:59:59 2023 EST isdst=0
America/New_York  Sun Mar 12 07:00:00 2023 UTC = Sun Mar 12 03:00:00 2023 EDT isdst=1
America/New_York  Sun Nov  5 05:59:59 2023 UTC = Sun Nov  5 01:59:59 2023 EDT isdst=1
America/New_York  Sun Nov  5 06:00:00 2023 UTC = Sun Nov  5 01:00:00 2023 EST isdst=0
...

那日期 2023-03-26 應該處於夏時制期間,時間理應調慢一小時,即中國東 8 區與美國西 4 區之差再加一小時——13 小時時差才對,而實際仍只有 12 小時時差 (16:00 vs 4:00)。上面的 demo 在 linux 和 Darwin 上運行結果一致。

下麵再來考慮一下其它日期常式是否夏時制敏感,為了說明問題,保留上例中 export TZ=America/New_York 設置,註意運行這個例子和當前系統時間也有關係 (必需是在所在區域的夏時制範圍內):

> ./time
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
t1 = 1679819691, t2 = 1679819691
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
sizeof (suseconds_t) = 8, sizeof (struct timeval) = 16, ret 0, tv.sec = 1679819691, tv.usec = 695922
minuteswest = 0, dsttime = 0
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
2023-03-26 08:34:51 (week day 7) (year day 85) (daylight saving time 0)
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1
2023-03-26 04:34:51 (week day 7) (year day 85) (daylight saving time 1)
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1
t3 = 1679837691
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1
from asctime: Sun Mar 26 09:34:51 2023
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1
from ctime: Sun Mar 26 04:34:51 2023
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1

發現幾點有趣的變化:

  • 根據 TZ 環境變數的設置解析出了美國紐約所在的西 4 區 (timezone = 18000)
  • gmttime 和 localtime 返回的 tm_isdst 不同
  • asctime 的輸出表明它在 gmtime 的返回結果之上加了 1 個小時,看起來是受夏時制影響了

好好分析一下第三條現象,asctime 列印的 tm1 結構體是 gmtime 返回的,不應該受夏時制影響才對,那將 asctime 和 ctime 的輸入參數替換為 localtime 返回的 tm2 和 t2 會如何呢?

from asctime: Sun Mar 26 05:03:16 2023
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1
from ctime: Sun Mar 26 05:03:16 2023
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1

結論是完全沒影響。那上面突然增加的 1 小時怎麼解釋呢?難不成是 mktime 修改了 tm 結構體?增加下麵的代碼用於驗證:

  time_t t3 = mktime (&tm1); 
  printf ("t3 = %ld\n", t3); 
  print_tm (&tm1); 
  print_tz (); 

  printf ("from asctime: %s", asctime (&tm1)); 
  print_tz (); 

  printf ("from ctime: %s", ctime (&t3)); 
  print_tz (); 
  return 0; 

在 mktime 後列印 tm1 的內容,並將 asctime 和 ctime 的參數指向 mktime 的結果,新的日誌如下:

> ./time
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
t1 = 1679821804, t2 = 1679821804
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
sizeof (suseconds_t) = 8, sizeof (struct timeval) = 16, ret 0, tv.sec = 1679821804, tv.usec = 289229
minuteswest = 0, dsttime = 0
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
2023-03-26 09:10:04 (week day 7) (year day 85) (daylight saving time 0)
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1
2023-03-26 05:10:04 (week day 7) (year day 85) (daylight saving time 1)
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1
t3 = 1679839804
2023-03-26 10:10:04 (week day 7) (year day 85) (daylight saving time 1)
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1
from asctime: Sun Mar 26 10:10:04 2023
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1
from ctime: Sun Mar 26 10:10:04 2023
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1

果然是 mktime 做的手腳!將 tm1 結構體中的 tm_hour 增加了 1 小時,看起來是受 tm_isdst 影響了。然而 tm1  的 tm_isdst 值為 0,不應該影響 mktime 的結果,神奇,懷疑是有 tzset 在內部被調用了。下麵是另外一些嘗試:

  • 將 tm1 的內容複製一份傳遞給 mktime,那麼 asctime 的結果將不再增加 1 小時,可見 asctime 是對夏時制不敏感的
  • ctime 是比較神奇的,它會根據不同的 time_t 做出不同的反應:
    • 解釋 mktime 返回的 t3 時它增加了 1 個小時,為了排除 mktime 的影響,直接將 t3 設置為 t1 + 18000,仍然能加 1 小時
    • 解釋 time 和 gettimeofday 返回的 t1/t2 時它卻不增加時間

對於 ctime 的神奇表現簡直是匪夷所思,一個小小的 time_t 中無法包含任何關於夏時制的信息;如果通過全局變數,那麼將 mktime 都註釋掉了仍能增加 1 小時,這讓人上哪講理去。

同樣的現象出在 mktime 身上,如果傳遞的是 localtime 的結果,則 mktime 不會增加 1 小時,後續的 ctime 也不會增加,可見他們的問題是一致的——傳遞 gmtime 的結果到 mktime 可能會有意想不到的結果,最好不要這樣做。

最終結論是,當正常使用時間常式時,它們都不受夏時制影響;如果錯誤的將 gmtime 結果傳遞給 mktime,則 mktime 和 ctime 會受夏時制影響自動增加 1 小時。後者受影響的規律還沒有摸清楚,留待後面瀏覽 mktime / ctime 源碼時給出解釋。

以上現象在 Darwin 上也能復現。最後上一張 linux 上 strace 的輸出:

> strace ./time |& less
...
brk(NULL)                               = 0x257d000
brk(0x259e000)                          = 0x259e000
brk(NULL)                               = 0x259e000
open("/usr/share/zoneinfo/America/New_York", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=3535, ...}) = 0
fstat(3, {st_mode=S_IFREG|0644, st_size=3535, ...}) = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fa3b07b7000
read(3, "TZif2\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\5\0\0\0\5\0\0\0\0"..., 4096) = 3535
lseek(3, -2260, SEEK_CUR)               = 1275
read(3, "TZif2\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\6\0\0\0\6\0\0\0\0"..., 4096) = 2260
close(3)                                = 0
munmap(0x7fa3b07b7000, 4096)            = 0
write(1, "tzname[0] = GMT, tzname[1] = GMT"..., 979tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
t1 = 1679823212, t2 = 1679823212
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
sizeof (suseconds_t) = 8, sizeof (struct timeval) = 16, ret 0, tv.sec = 1679823212, tv.usec = 189083
minuteswest = 0, dsttime = 0
...

可以看到在設置了 TZ 環境變數的情況下,時區文件仍被打開以確認夏時制的起始結束範圍。

可讀性

time_t 表示的 Epoch 適合電腦存儲、計算,但對人並不友好。將它們轉換為人能理解的日期時間需要藉助於以下常式:

char *asctime(const struct tm *tm);
char *ctime(const time_t *timep);
size_t strftime(char *s, size_t max, const char *format, const struct tm *tm);

其中 asctime 和 ctime 前面已經介紹過,它們分別將 strut tm 和 time_t 轉換為固定的時間格式。strftime 用於將 strut tm 轉換為任意用戶指定的格式,類似於 printf 做的工作。

其中 s 和 max 參數指定了輸出緩存區,如果生成的字元串長度 (包含結尾 null) 大於 max,則返回 0;否則返回生成的字元串長度 (不包含結尾 null)。

讓我們再回顧一下開頭的關係圖:

strftime 和 strptme 互逆,asctime 生成的 string 也可以通過 strptime 轉換回 struct tm,但沒有直接從 string 轉換到 time_t 的途徑,也沒有直接從 time_t 生成格式化字元串的路徑。

下麵是對 format 參數的說明:

格式 說明 實例
%Y 2023
%C 年/100 20
%y 年%100: [00-99] 23
%G ISO 基於周的年 2023
%g ISO 基於周的年%100 23
%m 月: [01-12] 04
%b / %h 月名縮寫 Apr
%B 月名全寫 April
%w 日in周: [0-6],周日: 0 6
%u ISO 日in周: [1-7],周日: 7 7
%a 日in周縮寫 Sun
%A 日in周全寫 Sunday
%d 日in月: [01-31],前導零 02
%e 日in月: [1-31],前導空格  2
%j 日in年: [001-366] 092
%U 星期日周數: [00-53] 14
%W 星期一周數: [01-53] 13
%V ISO 周數: [01-53] 13
%D / %x %m/%d/%y 04/02/23
%F %Y-%m-%d 2023-04-02
%H 24 時制小時: [00-23] 17
%I 12 時制小時: [01-12] 05
%M 分: [00-59] 38
%S 秒: [00-60] 31
%T / %X 24 時制時間: %H:%M:%S 17:38:31
%R %H:%M 17:38
%p AM/PM PM
%r 12 時制時間: %I:%M:%S %p 05:38:31 PM
%c 日期和時間 Sun Apr  2 17:38:31 2023
%z ISO UTC 偏移量 +0800
%Z 時區名 CST
%n 換行符  
%t 水平製表符  
%% 百分號 %

大部分選項是直接明瞭的,有幾個需要單獨解釋下:

  • %g/%G: 當前周所在的年,這裡一周是從周一到周日,例如 2023-01-01 (周日) 對應的年卻是 2022
  • %U:日期在該年中所屬的周數,包含該年中第一個星期日的周是第一周 (即星期日周數),例如 2023-01-01 (周日) 對應的周是 1
  • %W:同上,不同點在於包含該年中第一個星期一的周數是第一周 (即星期一周數),例如 2023-01-01 (周日) 對應的周是 0
  • %V:同上,不同點在於確定第一周的演算法更複雜了:若某周包含了 1 月 1 日,而且至少包含了其後的另外 3 天,那麼該周才被視為這年的第一周;否則該周為上一年的最後一周。還是以 2023-01-01 (周日) 為例,它對應的周是 52,即上一年最後一周

下麵的代碼可以用來測試任何格式化選項:

#include "../apue.h" 
#include <sys/time.h> 
#include <time.h> 

void print_tm (struct tm* t)
{
  printf ("%04d-%02d-%02d %02d:%02d:%02d (week day %d) (year day %d) (daylight saving time %d)\n", 
    t->tm_year + 1900, 
    t->tm_mon + 1, 
    t->tm_mday, 
    t->tm_hour, 
    t->tm_min, 
    t->tm_sec, 
    t->tm_wday, 
    t->tm_yday + 1, 
    t->tm_isdst); 
}

void my_strftime (char const* fmt, struct tm* t)
{
  char buf[64] = { 0 }; 
  int ret = strftime (buf, sizeof (buf), fmt, t); 
  printf ("[%02d] '%s': %s\n", ret, fmt, buf); 
}

int main (int argc, char *argv[])
{
  int ret = 0; 
  time_t now = time (NULL); 
  printf ("now = %ld\n", now); 

  struct tm *t = localtime (&now); 
  print_tm (t); 
  printf ("year group:\n");
  my_strftime ("%Y", t); 
  my_strftime ("%C", t); 
  my_strftime ("%y", t); 
  my_strftime ("%G", t); 
  my_strftime ("%g", t); 

  printf ("month group:\n"); 
  my_strftime ("%m", t); 
  my_strftime ("%b", t); 
  my_strftime ("%h", t); 
  my_strftime ("%B", t); 

  printf ("day group:\n");
  my_strftime ("%w", t); 
  my_strftime ("%u", t); 
  my_strftime ("%a", t); 
  my_strftime ("%A", t); 
  my_strftime ("%d", t); 
  my_strftime ("%e", t); 
  my_strftime ("%j", t); 

  printf ("week group:\n"); 
  my_strftime ("%U", t); 
  my_strftime ("%W", t); 
  my_strftime ("%V", t); 

  printf ("date group\n"); 
  my_strftime ("%D", t); 
  my_strftime ("%x", t); 
  my_strftime ("%F", t); 

  printf ("time group\n"); 
  my_strftime ("%H", t); 
  my_strftime ("%k", t); 
  my_strftime ("%I", t); 
  my_strftime ("%l", t); 
  my_strftime ("%M", t); 
  my_strftime ("%S", t); 
  my_strftime ("%T", t); 
  my_strftime ("%X", t); 
  my_strftime ("%R", t); 
  my_strftime ("%p", t); 
  my_strftime ("%P", t); 
  my_strftime ("%r", t); 
  my_strftime ("%c", t); 
  my_strftime ("%s", t); 

  printf ("timezone group\n"); 
  my_strftime ("%z", t); 
  my_strftime ("%Z", t); 

  printf ("common group\n"); 
  my_strftime ("%n", t); 
  my_strftime ("%t", t); 
  my_strftime ("%%", t); 
  return 0; 
}

下麵是代碼的典型輸出:

> ./timeprintf 
now = 1680431880
2023-04-02 18:38:00 (week day 0) (year day 92) (daylight saving time 0)
year group:
[04] '%Y': 2023
[02] '%C': 20
[02] '%y': 23
[04] '%G': 2023
[02] '%g': 23
month group:
[02] '%m': 04
[03] '%b': Apr
[03] '%h': Apr
[05] '%B': April
day group:
[01] '%w': 0
[01] '%u': 7
[03] '%a': Sun
[06] '%A': Sunday
[02] '%d': 02
[02] '%e':  2
[03] '%j': 092
week group:
[02] '%U': 14
[02] '%W': 13
[02] '%V': 13
date group
[08] '%D': 04/02/23
[08] '%x': 04/02/23
[10] '%F': 2023-04-02
time group
[02] '%H': 18
[02] '%k': 18
[02] '%I': 06
[02] '%l':  6
[02] '%M': 38
[02] '%S': 00
[08] '%T': 18:38:00
[08] '%X': 18:38:00
[05] '%R': 18:38
[02] '%p': PM
[02] '%P': pm
[11] '%r': 06:38:00 PM
[24] '%c': Sun Apr  2 18:38:00 2023
[10] '%s': 1680431880
timezone group
[05] '%z': +0800
[03] '%Z': CST
common group
[01] '%n': 

[01] '%t': 	
[01] '%%': %

示例中演示了另外一些非標準擴展,例如 %s 展示時間對應的 Epoch 值,在 linux 和 darwin 上都是被支持的。

回顧上面的關係圖,strftime 是受時區和夏時制影響的 (標紅部分),下麵通過導出 TZ 環境變數來驗證:

> date
Wed Apr  5 16:28:12 CST 2023
> export TZ=America/New_York
> date
Wed Apr  5 03:28:17 EDT 2023
> ./timeprintf 
now = 1680679740
2023-04-05 03:29:00 (week day 3) (year day 95) (daylight saving time 1)
year group:
[04] '%Y': 2023
[02] '%C': 20
[02] '%y': 23
[04] '%G': 2023
[02] '%g': 23
month group:
[02] '%m': 04
[03] '%b': Apr
[03] '%h': Apr
[05] '%B': April
day group:
[01] '%w': 3
[01] '%u': 3
[03] '%a': Wed
[09] '%A': Wednesday
[02] '%d': 05
[02] '%e':  5
[03] '%j': 095
week group:
[02] '%U': 14
[02] '%W': 14
[02] '%V': 14
date group
[08] '%D': 04/05/23
[08] '%x': 04/05/23
[10] '%F': 2023-04-05
time group
[02] '%H': 03
[02] '%k':  3
[02] '%I': 03
[02] '%l':  3
[02] '%M': 29
[02] '%S': 00
[08] '%T': 03:29:00
[08] '%X': 03:29:00
[05] '%R': 03:29
[02] '%p': AM
[02] '%P': am
[11] '%r': 03:29:00 AM
[24] '%c': Wed Apr  5 03:29:00 2023
[10] '%s': 1680679740
timezone group
[05] '%z': -0400
[03] '%Z': EDT
common group
[01] '%n': 

[01] '%t': 	
[01] '%%': %

添加紐約時區後,strftime 生成的時間與北京時間差了 13 個小時,除去時區跨度 12 個小時 (+8 & -4),還有 1 小時是夏時制引發的。通過 %z 和 %Z 的輸出可以觀察到時區的變數。對於夏時制,strftime 沒有提供對應的 format 參數,所以不好直接確認,只能通過時間差值來間接確認。

char *strptime(const char *s, const char *format, struct tm *tm);

strptime 是 strftime 的逆操作,藉助 format 參數解析輸入字元串 s,並將結果保存在參數 tm 中,它的返回值有如下幾種場景:

  • 解析了部分 format 或一個也沒有解析出來,返回 NULL
  • 解析了全部 format,將最後解析位置返回給調用者 (如果恰好為末尾 null,則表示完全匹配)

它的 format 參數和 strftime 幾乎完全一致,除以下幾點:

  • %t / %n:匹配任意空白
  • %y:69-99 將匹配到 19xx 年,00-68 將匹配到 20xx 年
  • 可添加 E / 0 首碼指定使用當前 locale 使用的日期時間符號

仍以上面的代碼為例,如果想查看任意時間的 format 參數效果,可以增加時間參數並通過 strptime 做解析:

int main (int argc, char *argv[])
{
  int ret = 0; 
  struct tm *t = NULL; 
  if (argc == 1) 
  {
    time_t now = time (NULL); 
    printf ("now = %ld\n", now); 
    t = localtime (&now); 
  }
  else if (argc == 2)
  {
    static struct tm tmp = { 0}; 
    char const* ptr = strptime (argv[1], "%F %T", &tmp); 
    if (ptr == NULL)
    {
        printf ("parse time %s failed\n", argv[1]); 
        return -1;
    }

    if (*ptr != NULL)
    {
        printf ("strptime ret:[%d] %s\n", ptr-argv[1], ptr); 
    }

    t = &tmp; 
  }
  else
  {
      printf ("Usage: ./timeprintf [YYYY-MM-DD HH:MM:SS]\n"); 
      exit (1); 
  }
...
}

和之前的區別在於,當用戶給定一個額外參數時,嘗試使用 strptime 進行解析,如果成功,將解析結果用於後續的 strftime 時間參數。預設按 YYYY-MM-DD HH:MM:SS 格式解析:

> ./timeprintf "2023-01-01 10:00:00"
2023-01-01 10:00:00 (week day 0) (year day 1) (daylight saving time 0)
year group:
[04] '%Y': 2023
[02] '%C': 20
[02] '%y': 23
[04] '%G': 2022
[02] '%g': 22
...

註意需要將整個日期時間參數用引號括起來,不然會被 shell 解析為兩個參數。

這裡使用今年第一天來驗證 %g / %G 的輸出,可以看到,因為這天仍屬於 2022 的最後一周,所以它們都返回了 2022。

有的人或許有疑問,經 strptime 解析的時間和 localtime 返回的完全一致嗎?下麵做個試驗:

> ./timeprintf > out.1
> ./timeprintf "2023-04-05 16:31:00" > out.2
> diff out.1 out.2
1d0
< now = 1680683460
46c45
< [05] '%z': +0800
---
> [05] '%z': +0000

可以看到,除時區偏移沒解析成功外,其它欄位確實相符 (沒帶參數的 timeprintf 使用的時間也是 16:31;00),這也比較好理解,畢竟提供給 strptime 的字元串沒帶時區信息,如果修改 format 信息帶上時區呢?

    char const* ptr = strptime (argv[1], "%F %T %Z", &tmp); 

下麵就來試一試:

> ./timeprintf "2023-04-05 16:31:00 CST" > out.2
> diff out.1 ou

您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • demo軟體園每日更新資源,請看到最後就能獲取你想要的: 1.多語言BNB鏈上智能合約區塊鏈 別人發的我沒啥用,還有前面發的和這個好像不一樣 自己需要的下載玩,這個本來就沒有後臺,別下載了找我說不完整。看著還是挺不錯的。 這玩意好像還有人改盜u 頁面效果: 1.數據挖掘與預測分析 數據挖掘與預測分析 ...
  • 操作系統 :CentOS 7.6_x64 FreeSWITCH版本 :1.10.9 一、安裝ilbc庫 從第三方庫里下載指定版本: git clone https://freeswitch.org/stash/scm/sd/libilbc.git 如果下載過慢,可從如下途徑獲取: 關註微信公眾號(聊 ...
  • 說明 使用 VLD 記憶體泄漏檢測工具輔助開發時整理的學習筆記。同系列文章目錄可見 《記憶體泄漏檢測工具》目錄 1. 使用方式 在 VS 中使用 VLD 的方法可以查看另外一篇博客:在 VS 2015 中使用 VLD。 2. 輸出報告 在 VS 中使用 VLD 時的輸出報告,與在 QT 中使用時是一致的 ...
  • .NET 實現JWT登錄認證 在ASP.NET Core應用程式中,使用JWT進行身份驗證和授權已成為一種流行的方式。JWT是一種安全的方式,用於在客戶端和伺服器之間傳輸用戶信息。 添加NuGet包 首先,我們需要添加一些NuGet包來支持JWT身份驗證。在您的ASP.NET Core項目中,打開S ...
  • 眾所周知,bash命令執行的時候會輸出信息,但有時這些信息必須要經過幾次處理之後才能得到我們想要的格式,此時應該如何處置?這就牽涉到 管道命令(pipe) 了。管道命令使用的是|這個界定符號。每個管道後面接的第一個數據必定是命令,而且這個命令必須要能夠接受標準輸出的數據才行,這樣的命令才可為管道命令... ...
  • 考點:文件的打開和讀取 打開文件的過程: 打開操作本質上是使用了open這個系統調用,參數如下圖所示。 操作系統通過文件的路徑在外存中找到了這個test.txt文件所在的目錄,繼續找該文件的目錄項(FCB),一個文件只有一個目錄項。然後將這個目錄項調到記憶體中,系統中有一個系統打開文件表,裡面存放的是 ...
  • 資源管理器右鍵添加打開cmd視窗指令 資源管理器空白處右鍵添加打開cmd視窗命令,直接打開cmd並切換到當前目錄 首先刪除該指令 添加該指令條目、名稱,然後添加具體的指令 此.reg文件必須以UTF-8-BOM編碼格式保存,否則無法設置中文名稱 Windows Registry Editor Ver ...
  • linux vi命令詳解 剛開始學著用linux,對vi命令不是很熟,在網上轉接了一篇。 vi編輯器是所有Unix及Linux系統下標準的編輯器,它的強大不遜色於任何最新的文本編輯器,這裡只是簡單地介紹一下它的用法和一小部分指 令。由於 對Unix及Linux系統的任何版本,vi編輯器是完全相同的, ...
一周排行
    -Advertisement-
    Play Games
  • 前言 本文介紹一款使用 C# 與 WPF 開發的音頻播放器,其界面簡潔大方,操作體驗流暢。該播放器支持多種音頻格式(如 MP4、WMA、OGG、FLAC 等),並具備標記、實時歌詞顯示等功能。 另外,還支持換膚及多語言(中英文)切換。核心音頻處理採用 FFmpeg 組件,獲得了廣泛認可,目前 Git ...
  • OAuth2.0授權驗證-gitee授權碼模式 本文主要介紹如何筆者自己是如何使用gitee提供的OAuth2.0協議完成授權驗證並登錄到自己的系統,完整模式如圖 1、創建應用 打開gitee個人中心->第三方應用->創建應用 創建應用後在我的應用界面,查看已創建應用的Client ID和Clien ...
  • 解決了這個問題:《winForm下,fastReport.net 從.net framework 升級到.net5遇到的錯誤“Operation is not supported on this platform.”》 本文內容轉載自:https://www.fcnsoft.com/Home/Sho ...
  • 國內文章 WPF 從裸 Win 32 的 WM_Pointer 消息獲取觸摸點繪製筆跡 https://www.cnblogs.com/lindexi/p/18390983 本文將告訴大家如何在 WPF 裡面,接收裸 Win 32 的 WM_Pointer 消息,從消息裡面獲取觸摸點信息,使用觸摸點 ...
  • 前言 給大家推薦一個專為新零售快消行業打造了一套高效的進銷存管理系統。 系統不僅具備強大的庫存管理功能,還集成了高性能的輕量級 POS 解決方案,確保頁面載入速度極快,提供良好的用戶體驗。 項目介紹 Dorisoy.POS 是一款基於 .NET 7 和 Angular 4 開發的新零售快消進銷存管理 ...
  • ABP CLI常用的代碼分享 一、確保環境配置正確 安裝.NET CLI: ABP CLI是基於.NET Core或.NET 5/6/7等更高版本構建的,因此首先需要在你的開發環境中安裝.NET CLI。這可以通過訪問Microsoft官網下載並安裝相應版本的.NET SDK來實現。 安裝ABP ...
  • 問題 問題是這樣的:第三方的webapi,需要先調用登陸介面獲取Cookie,訪問其它介面時攜帶Cookie信息。 但使用HttpClient類調用登陸介面,返回的Headers中沒有找到Cookie信息。 分析 首先,使用Postman測試該登陸介面,正常返回Cookie信息,說明是HttpCli ...
  • 國內文章 關於.NET在中國為什麼工資低的分析 https://www.cnblogs.com/thinkingmore/p/18406244 .NET在中國開發者的薪資偏低,主要因市場需求、技術棧選擇和企業文化等因素所致。歷史上,.NET曾因微軟的閉源策略發展受限,儘管後來推出了跨平臺的.NET ...
  • 在WPF開發應用中,動畫不僅可以引起用戶的註意與興趣,而且還使軟體更加便於使用。前面幾篇文章講解了畫筆(Brush),形狀(Shape),幾何圖形(Geometry),變換(Transform)等相關內容,今天繼續講解動畫相關內容和知識點,僅供學習分享使用,如有不足之處,還請指正。 ...
  • 什麼是委托? 委托可以說是把一個方法代入另一個方法執行,相當於指向函數的指針;事件就相當於保存委托的數組; 1.實例化委托的方式: 方式1:通過new創建實例: public delegate void ShowDelegate(); 或者 public delegate string ShowDe ...