本文經授權轉自公眾號CSDN(ID:CSDNnews)
作者 | bert hubert,翻譯?| 蘇宓
時間處理在編程中看似平常,卻隱藏著無數坑點。本文作者以 C 或 C++ 中將 UTC 時間字符串轉換為 UNIX 時間戳為例,分享其中的難點以及最優解決方案
原文鏈接:https://berthub.eu/articles/posts/how-to-get-a-unix-epoch-from-a-utc-date-time-string/
要將「Fri, 17 Jan 2025 06:07:07」UTC 這樣的時間字符串轉換為 1737094027(一個從 1970-01-01 00:00:00 UTC 開始的秒數表示,雖然只是理論上的秒數,并不完全準確),看起來似乎不難。
但實際上,真正嘗試完成這個操作時會發現,POSIX 時間處理函數在各種 C 庫及其衍生語言中隱藏著許多讓人意想不到的“特性”和不符合直覺的行為。盡管 C 和 UNIX 世界有許多優秀的設計,但時間處理顯然不是其中之一。
然而,仍有一些可行性方法存在。在探討具體方法之前,先提供一些背景知識。
快速解讀(TL;DR):
1. 避免調用 setlocale():如果你從不調用 setlocale(),那么可以直接使用 strptime() 來解析 UTC 時間字符串。
2. 避免 %z 或 %Z 格式符:解析時請勿使用這兩個格式符。
3. 轉換為 UNIX 時間戳:將 strptime() 解析后生成的 struct tm 結構體傳遞給 timegm()(在 Windows 上使用 mkgmtime()),即可得到對應的 UNIX 時間戳。
4. 如果使用了 setlocale(),需要更復雜的處理:具體解決方案在下文中會有解釋。
5. C++ 提供了更好的時間處理支持,這也可以從 C 中借用。
1、時間點的復雜性
即便忽略閏秒和廣義相對論的影響,時間本身就足夠復雜了。當我們將人類的行為和政治因素引入時間處理時,事情會變得異常棘手。
例如,在阿姆斯特丹,“2025 年 3 月 30 日 02:20”這個時間點在當地根本就不存在:
TZ=Europe/Amsterdam date -d '20250330 01:59:59'Sun Mar 30 01:59:59 AM CET 2025TZ=Europe/Amsterdam date -d '20250330 02:30:00'date: invalid date ‘20250330 02:30:00’
這點至少是明確的。由于夏令時的切換,時間會直接從 01:59:59 跳到 03:00:00。因此,工具無法解析“02:30:00”,因為在那一天的阿姆斯特丹,這個時間點根本不存在。
但對于“2024年10月27日 02:30”這個時間點,情況變得更加難以解釋。因為夏令時的結束,在 02:59:59 的下一秒,時間會重新變為 02:00:00。這意味著當地會出現兩個都被稱為“02:00”的時間點。而我們的工具在處理這種情況時開始做出一些看似任意的選擇:
$ TZ=Europe/Amsterdam date -d '20241027 01:59:59' +"%Y-%m-%d %H:%M:%S %s %z"2024-10-27 01:59:59 1729987199 +0200$ TZ=Europe/Amsterdam date -d '20241027 02:00:00' +"%Y-%m-%d %H:%M:%S %s %z"2024-10-27 02:00:00 1729990800 +0100
你看,當解析 02:00:00 時,我使用的 GNU date 工具選擇了第二個出現的時間點。據我觀察,這可能是因為我在一月份運行了這個命令。如果在四月份運行,它可能會選擇第一個 02:00:00 實例。是不是讓人有些搞不懂?
2、POSIX 的時間概念
最有用的時間表示方式,毫無疑問是用某個已知“紀元”(epoch)之后或之前的秒數來指定時間點。例如:
POSIX/Unix 的紀元是 1970-01-01 00:00:00 UTC
GPS 的紀元是 1980-01-06 00:00:00 UTC
Galileo(“歐盟版 GPS”)的紀元是 1999-08-21 23:59:47 UTC
北斗系統的起始歷元是 2006-01-01 00:00:00 UTC
GPS、Galileo 和北斗系統明智地忽略了閏秒,將這些問題留給人類去處理。
但是,我們偏愛 POSIX/Unix 的 time_t 是有充分理由的。它幾乎不會有任何歧義,除了在閏秒期間——而閏秒可能再也不會出現了。
然而,人類難以理解諸如 1737214750 這樣的數字。因此,我們需要將時間戳與包含月份等復雜概念的“人類友好”時間表示相互轉換。為此,UNIX 提供了 struct tm,用于存儲“細分時間”:
struct tm {int tm_sec; /* Seconds [0, 60] */int tm_min; /* Minutes [0, 59] */int tm_hour; /* Hour [0, 23] */int tm_mday; /* Day of the month [1, 31] */int tm_mon; /* Month [0, 11] (January = 0) */int tm_year; /* Year minus 1900 */int tm_wday; /* Day of the week [0, 6] (Sunday = 0) */int tm_yday; /* Day of the year [0, 365] (Jan/01 = 0) */int tm_isdst; /* Daylight savings flag */long tm_gmtoff; /* Seconds East of UTC */const char *tm_zone; /* Timezone abbreviation */};
標準規定 struct tm 至少要包含這些字段,但實現中可能還會有其他字段。
然而,現在這個結構體顯然是“過度定義”的。例如,星期幾(tm_wday)和每年的第幾天(tm_yday)完全可以從其他字段推導出來。而 tm_gmtoff、tm_zone 和 tm_isdst 的意義定義不明確,使用時往往會造成困惑。
有趣的是,蘇聯的 GLONASS 衛星導航系統并沒有采用紀元時間戳的方法,而是基于“莫斯科標準時間”的 struct tm,包括閏秒。這種設計據說引發了許多問題,也算是“自作自受”。
struct tm 的一個重要用途是作為 mktime() 的輸入。mktime() 的部分功能是將“根據你當地時區的細分時間”轉換為 UNIX 時間戳(epoch 時間戳)。然而,mktime() 的作用遠不止于此!
根據 Linux 的 glibc 手冊頁,mktime() 的描述相當模糊。而 IEEE Std 1003.1-2024 規范則用了更多(令人泄氣的)文字來解釋它。
mktime() 不會處理 tm_gmtoff 或 tm_zone。其輸入僅限于:tm_year、tm_mon、tm_mday、tm_hour、tm_min、tm_sec 和 tm_isdst。tm_isdst 也有特殊處理的情況,譬如 tm_isdst 可以設置為負值,表示讓 mktime() 自動判斷指定時間是否處于夏令時。
如上所述,時間問題其實在現實中很復雜。例如,如果想將日期調整一周,你可以簡單地向 time_t 時間戳添加 604800秒。但如果這種調整跨越了夏令時邊界,你的下午兩點約會可能會變成下一周的下午一點或三點。這顯然不是人類期望的結果。
mktime() 不僅返回一個 time_t 值,還會規范化傳入的 struct tm。截至 2024 年,關于如何規范化的規則已經明確。例如,要計算“下一周的同一時間”,可以將當前時間加上 7 天(tm.tm_mday += 7),然后再次調用 mktime()。即使你構造出了一個像“3月35日”這樣的日期,mktime() 也會將其修正為有效日期。
然而,當我們實際這樣做時,卻發現它不起作用:
struct tm tm = {.tm_hour=14, .tm_mday = 28,.tm_mon = 2, .tm_year = 2025 - 1900,.tm_isdst = -1}; // <- NOTE the -1time_t t = mktime(&tm);cout << "original: "<< ctime(&t);tm.tm_mday += 7;t = mktime(&tm);cout?<"mktime?adjusted:??"<
在歐洲/阿姆斯特丹時區,這段代碼輸出:
original: Fri Mar 28 14:00:00 2025mktime?adjusted:?Fri?Apr??4?15:00:00?2025
為什么預約時間發生了 1 小時的偏移?問題實則出在 tm.tm_isdst 身上。
mktime() 的設計要求開發者明確指定時間是否處于夏令時狀態,或者將這一決定交由 mktime() 自動判斷(通過設置 tm_isdst = -1)。
在第一次調用 mktime() 時,系統檢測到時間不處于夏令時,因此將 tm_isdst 設置為 0。但第二次調用時,這一狀態未被清除,盡管新的目標時間實際上處于夏令時中。這導致了錯誤的調整結果。
所以在第二次調用 mktime() 之前,重置 tm_isdst 為 -1:
tm.tm_isdst = -1;這樣可以避免偏移問題,并正確調整預約時間。
3、解析 UTC 時間
現在,mktime() 會將你傳遞的時間解釋為“本地時間”。這意味著在處理 UTC 時間之前,你應該將時區設置為 UTC。但如果你的應用程序中有其他線程運行,修改整個應用程序的時區可能會帶來副作用。不過,如果沒有其他線程,你可以這么做。
更新:有人指出,多線程程序無法修改環境變量。因此,這個方法就無效了。
有一個非標準/預標準的函數廣泛可用,它可以顯著改善處理 UTC 的情況。根據《IEEE Std 1003.1-2024》:
“未來版本的標準預計會新增一個 timegm() 函數,它與 mktime() 類似,但由 timeptr 指向的 tm 結構包含以協調世界時 (UTC) 表示的分解時間?!?/section>
為了解析 UTC 中的分解時間,推薦使用 timegm(),而不是修改 TZ 環境變量。在 Windows 上,timegm() 被稱為 mkgmtime()。如果你使用的是 AIX(唯一不支持 timegm() 的平臺),可以找到一個獨立的實現版本。
總結:
1. 當對本地時間使用 mktime() 時,將 tm_isdst 設置為 -1,這通常是“人類”期望的。否則可能會在夏令時切換時返回隨機的兩個時間點之一(如“02:30”)。
2. 填寫 struct tm 時,確保先將其他字段清零。
3. 注意,mktime() 會修改傳入的 struct tm,可能產生副作用。在重用之前至少重置 tm_isdst。
4. 無論對 tm_gmtoff 或 tm_zone 做了什么,mktime() 都會使用當前時區。如果希望將 struct tm 解釋為 UTC,需要設置 TZ 環境變量為 UTC,但這會影響其他線程的時間操作。
5. 更簡單的做法:直接使用 timegm() 或 mkgmtime()。
但是,我們該如何將時間字符串轉換為 struct tm?
4、解析時間字符串
理想情況下,我們希望能將 Fri, 17 Jan 2025 06:07:07 GMT 輸入到 strptime() 中,并得到一個有效的 struct tm。
但根據 Linux glibc 的 strptime() 手冊頁描述,關于 %z 和 %Z 時區格式說明符的行為是含糊的:
出于對稱性的考慮,glibc 嘗試讓 strptime() 支持與 strftime() 相同的格式字符。(在大多數情況下,相應的字段會被解析,但 tm 中的字段可能不會被更改。)
現在,我們的目標是將 UTC 時間字符串轉換為 UNIX 時間戳。許多人可能希望通過 %Z 解析 GMT 后,再用 mktime() 達到目的。
但我們之前了解到,mktime() 根本不處理 tm_gmtoffset 或 tm_zone,因此即使 strptime() 能正確解析時區信息,也沒有任何作用。而事實是,它也解析不對。
截至 2024 年,Open Group 的規范對 strptime() 提供了明確的說明,提到了當前實現中的各種問題與不足。例如:
%z 的行為沒有明確規定,解析類似 +0200 的偏移量通常不會奏效。
%Z 的作用非常有限,僅在某些情況下有效。如果你的地區時區有 DST 標識符(例如 CEST)與常規時區(例如 CET)不同,并且 %Z 解析到了其中一個,它可能會為您正確設置 tm_isdst,但也可能不行(特別是如果你住在愛爾蘭)。
一般來說,解析像 EST 這樣的字符串毫無意義,因為它并無明確含義,僅在本地可能有用。
幸運的是,由于我們可以通過 gmtime() 獲取 UTC 時間,完全可以忽略 %z 和 %Z,它們并不是必須的。
5、strptime 的語言環境問題
通常情況下,我們希望解析包含英文日期和月份名稱的時間字符串,并希望 strptime() 能處理這些情況。然而,IEEE/Open Group 標準明確指出:
“這些轉換是根據當前語言環境的 LC_TIME 類別決定的。”
糟糕。我以前并不知道,除非特別設置,C 和 C++ 程序會默認使用 “C” 語言環境,這實際上等同于美式英語。這意味著默認情況下,所有 LC_TIME 等環境變量都會被忽略。這對解析大多數以英文表示的時間字符串來說非常有用,因為數據中的時間幾乎總是英文格式。
但是,如果你的 C 或 C++ 程序調用了 setlocale(),請求了非 “C” 的語言環境,那么你的程序可能會僅適用于例如荷蘭語格式的時間字符串,而這種情況非常罕見。
現在你可能會考慮在調用 strptime() 之前將語言環境切換為 “C”,然后再切換回來。然而,不幸的是,setlocale() 在多線程程序中并不安全(除非在線程啟動之前調用)。即使可以安全使用,也可能會干擾其他線程的輸出。
因此,通常情況下,如果需要解析特定的時間字符串并使用 strptime(),請確保程序的語言環境設置為預期的值。雖然有 strftime_l() 允許指定格式化時間時的語言環境,但等價的 strptime_l() 并未正式提供。
值得一提的是,OpenBSD 的 strptime() 實現完全忽略了語言環境,只支持 “C”。
解析類似 17 Jan 2025 06:07:07 的字符串并填寫一個 struct tm 其實并不困難,然后交由 mktime() 處理實際的 UNIX 時間戳計算工作。
6、使用純 C++ 解決語言環境問題
雖然 C++ ?iostreams 不太受歡迎,但在處理語言環境方面比 C/POSIX 做得更好。在 C++ 中,你可以為每個 iostream 設置獨立的語言環境。以下是一個可以從 C 調用的 C++ 輔助函數,用于解析任意 UTC 時間字符串:
extern "C"int utcstr2epoch(const char* timestr, const char* fmtstr, struct tm* output){std::tm t = {}; // tm_isdst = 0, don't think about it please, this is UTCstd::istringstream ss(timestr);ss.imbue(std::locale()); // "LANG=C", but localss >> std::get_time(&t, fmtstr);if (ss.fail())return -1;// now fix up the day of week, day of year etct.tm_isdst = 0; // no thinking!t.tm_wday = -1;if(mktime(&t) == -1 && t.tm_wday == -1) // "real error"return -1;*output = t;return 0;}
這個函數展示了如何為 mktime() 處理錯誤。當你請求解析 31st December 1969 23:59 時,mktime() 會返回 -1 表示錯誤。此時可以使用 tm_wday 作為標志位來判斷是否進行了任何處理。
還有一個基于 C 的小型演示程序,它可以解析英文 UTC 時間戳,并根據調用環境的語言環境輸出結果:
$ LC_TIME="nl_NL.utf-8" ./utcparse "1 Jan 1970 00:00:00" "%d %b %Y %H:%M:%S"UTC Time: donderdag, 1 januari 1970 00:00:00, day of year 001time_t: 0
7、C++20 的極致體驗
C++20 及更高版本提供了豪華的時區數據庫(timezone database)。雖然并非所有編譯器都支持,但幸運的是,可以使用預標準化的獨立版本。有時候,我們得感謝那些愿意花費數年時間為我們提供精美代碼的人,比如 Howard Hinnant。
以下是一個優雅的例子:
auto meet_nyc = make_zoned("America/New_York",date::local_days{Monday[1]/May/2016} + 9h);auto meet_lon = make_zoned("Europe/London", meet_nyc);auto meet_syd = make_zoned("Australia/Sydney", meet_nyc);cout << "The New York meeting is " << meet_nyc << '\n';cout << "The London meeting is " << meet_lon << '\n';cout << "The Sydney meeting is " << meet_syd << '\n';
該代碼解析了“2016 年 5 月第一個星期一上午 9 點(紐約當地時間)”,并無縫轉換到其他兩個時區:
The New York meeting is 2016-05-02 09:00:00 EDTThe London meeting is 2016-05-02 14:00:00 BSTThe?Sydney???meeting?is?2016-05-02?23:00:00?AEST?
更棒的是,這個庫不僅支持操作系統的時區數據庫,還可以直接使用 IANA tzdb,這使得你能夠精確計算 1978 年的一次飛行持續時間,包括經過夏令時的變化以及閏秒。令人驚嘆。
本文轉自公眾號“CSDN”,ID:CSDNnews

1、C++訓練營,來了!
2、HarmonyOS 學習資料分享(無套路免費分享)
我組建了一些社群一起交流,群里有大牛也有小白,如果你有意可以一起進群交流。
歡迎你添加我的微信,我拉你進技術交流群。此外,我也會經常在微信上分享一些計算機學習經驗以及工作體驗,還有一些內推機會。
加個微信,打開另一扇窗
感謝你的分享,點贊,在看三連??