學(xué)校網(wǎng)站建設(shè)電話代刷網(wǎng)站推廣鏈接免費(fèi)
在編程世界里,從 UTC 日期時(shí)間字符串獲取 Unix 時(shí)間戳,看似簡(jiǎn)單,實(shí)則暗藏玄機(jī)。你以為輸入一個(gè)像 “Fri, 17 Jan 2025 06:07:07” 這樣的 UTC 時(shí)間,然后輕松得到 1737094027(從 1970 年 1 月 1 日 00:00:00 UTC 開始經(jīng)過(guò)的秒數(shù))就萬(wàn)事大吉了?事實(shí)可沒這么簡(jiǎn)單,這背后涉及到一系列復(fù)雜的時(shí)間處理問(wèn)題,還會(huì)讓你發(fā)現(xiàn) POSIX 時(shí)間處理函數(shù)在不同 C 庫(kù)及相關(guān)語(yǔ)言中的各種 “意外特性”。今天,咱們就來(lái)深入探討一下這個(gè)讓人又愛又恨的話題。
一、時(shí)間處理的復(fù)雜性
時(shí)間本身就是一個(gè)復(fù)雜的概念,要是再把閏秒和相對(duì)論這些因素考慮進(jìn)去,那就更讓人頭疼了。而人類對(duì)時(shí)間的記錄方式,從模糊不清到精確無(wú)比,各不相同。就拿阿姆斯特丹的時(shí)間來(lái)說(shuō),由于夏令時(shí)的存在,“2025 年 3 月 30 日 02:20” 這個(gè)時(shí)間點(diǎn)在當(dāng)?shù)厥遣淮嬖诘?#xff0c;時(shí)間會(huì)直接從 01:59:59 跳到 03:00:00。但 “2024 年 10 月 27 日 02:30” 就更讓人困惑了,因?yàn)橄牧顣r(shí)結(jié)束時(shí),02:59:59 的下一秒又回到了 02:00:00,這就導(dǎo)致有兩個(gè) “02:00” 的時(shí)間點(diǎn)。從下面的命令行示例就能看出工具在處理這種情況時(shí)的隨意性:
$ 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
你看,當(dāng)要求解釋 02:00:00 這個(gè)時(shí)間時(shí),GNU date 工具選擇了第二個(gè)出現(xiàn)的時(shí)間點(diǎn)。而且據(jù)我觀察,這還和執(zhí)行命令的時(shí)間有關(guān),如果在四月執(zhí)行,可能就會(huì)選擇第一個(gè) 02:00:00 實(shí)例,是不是很讓人摸不著頭腦?
二、POSIX 時(shí)間概念與?struct tm
在 POSIX/Unix 系統(tǒng)中,指定時(shí)間的有效方式是用相對(duì)于某個(gè) “紀(jì)元”(epoch)的秒數(shù)。POSIX/Unix 的紀(jì)元是 1970 年 1 月 1 日 00:00:00 UTC,GPS 的紀(jì)元是 1980 年 1 月 6 日 00:00:00 UTC,伽利略(歐盟的 GPS 系統(tǒng))的紀(jì)元是 1999 年 8 月 21 日 23:59:47 UTC,北斗系統(tǒng)的紀(jì)元是 2006 年 1 月 1 日 00:00:00 UTC。其中,GPS、伽利略和北斗系統(tǒng)都明智地忽略了閏秒,把這些麻煩事留給人類去處理。而我們常用的 POSIX/Unix 的 “time_t” 時(shí)間戳,除了在閏秒期間可能會(huì)有歧義(不過(guò)閏秒以后可能也不會(huì)再有了),其他時(shí)候還是很可靠的。
為了在時(shí)間戳和人類可讀的時(shí)間格式之間進(jìn)行轉(zhuǎn)換,UNIX 提供了?struct tm
?結(jié)構(gòu)體,它包含了年、月、日、時(shí)、分、秒等時(shí)間信息:
struct tm {int tm_sec; /* 秒 [0, 60] */int tm_min; /* 分 [0, 59] */int tm_hour; /* 時(shí) [0, 23] */int tm_mday; /* 一個(gè)月中的第幾天 [1, 31] */int tm_mon; /* 月份 [0, 11] (一月是 0) */int tm_year; /* 年份減去 1900 */int tm_wday; /* 一周中的第幾天 [0, 6] (周日是 0) */int tm_yday; /* 一年中的第幾天 [0, 365] (1 月 1 日是 0) */int tm_isdst; /* 夏令時(shí)標(biāo)志 */long tm_gmtoff; /* 相對(duì)于 UTC 的秒數(shù) */const char *tm_zone; /* 時(shí)區(qū)縮寫 */
};
不過(guò),這個(gè)結(jié)構(gòu)體的設(shè)計(jì)其實(shí)有點(diǎn)冗余,像一周中的第幾天和一年中的第幾天,通過(guò)其他字段就能推算出來(lái)。而且,tm_gmtoff
、tm_zone
?和?tm_isdst
?這幾個(gè)字段的含義不僅定義得不太清晰,理解起來(lái)也有難度,并且它們的作用還會(huì)根據(jù)結(jié)構(gòu)體的使用方式而變化。
struct tm
?的一個(gè)重要作用是作為?mktime()
?函數(shù)的輸入,mktime()
?會(huì)把 “根據(jù)本地時(shí)區(qū)拆分的時(shí)間” 轉(zhuǎn)換為 Unix 時(shí)間戳。但它的功能可不止這一個(gè),它還會(huì)對(duì)傳入的?struct tm
?進(jìn)行標(biāo)準(zhǔn)化處理。比如說(shuō),如果你想把當(dāng)前時(shí)間往后調(diào)一周,你可能會(huì)直接給?time_t
?時(shí)間戳加上 604800 秒(一周的秒數(shù)),但如果這個(gè)調(diào)整跨越了夏令時(shí)的邊界,你原本下午 2 點(diǎn)的約會(huì)可能就會(huì)變成下周下午 1 點(diǎn)或 3 點(diǎn),這可不是我們想要的結(jié)果。而?mktime()
?就能幫你處理這種情況,即使你傳入像 “3 月 35 日” 這樣不合理的日期,它也能幫你修正。不過(guò),使用?mktime()
?時(shí)也有一些需要注意的地方,下面我們就來(lái)詳細(xì)說(shuō)說(shuō)。
三、mktime()
?的使用與注意事項(xiàng)
先來(lái)看個(gè)例子:
struct tm tm = {.tm_hour=14, .tm_mday = 28,
.tm_mon = 2, .tm_year = 2025 - 1900,
.tm_isdst = -1}; // <- 注意這里的 -1
time_t t = mktime(&tm);
cout << "original: "<< ctime(&t);
tm.tm_mday += 7;
t = mktime(&tm);
cout << "mktime adjusted: "<< ctime(&t);
在歐洲 / 阿姆斯特丹時(shí)區(qū),這段代碼的輸出結(jié)果是:
original: Fri Mar 28 14:00:00 2025
mktime adjusted: Fri Apr 4 15:00:00 2025
為什么我們的約會(huì)時(shí)間會(huì)偏移一個(gè)小時(shí)呢?問(wèn)題就出在?tm.tm_isdst
?這個(gè)字段上。mktime()
?要求你明確指定時(shí)間是否處于夏令時(shí),或者讓它自己去判斷(我們一開始把?tm.tm_isdst
?設(shè)置為 -1 就是讓它自己判斷)。當(dāng)我們第一次調(diào)用?mktime()
?時(shí),它發(fā)現(xiàn)初始時(shí)間不在夏令時(shí),就把?tm_isdst
?設(shè)置為 0。第二次調(diào)用時(shí),這個(gè)設(shè)置沒有改變,但新的時(shí)間其實(shí)是處于夏令時(shí)的,所以就出現(xiàn)了時(shí)間偏移的情況。解決辦法就是在第二次調(diào)用?mktime()
?之前,把?tm_isdst
?重新設(shè)置為 -1。
另外,使用?mktime()
?處理 UTC 時(shí)間時(shí)也有個(gè)大坑。mktime()
?會(huì)把傳入的時(shí)間當(dāng)作 “本地時(shí)間” 來(lái)處理,所以如果你要處理 UTC 時(shí)間,就需要在調(diào)用?mktime()
?之前把時(shí)區(qū)設(shè)置為 UTC。但如果你的程序有其他線程在運(yùn)行,修改整個(gè)應(yīng)用程序的時(shí)區(qū)可能會(huì)產(chǎn)生副作用。不過(guò),多線程程序本來(lái)就不能隨意更改環(huán)境變量,所以這個(gè)方法也行不通。
還好,有一個(gè)非標(biāo)準(zhǔn)但廣泛可用的函數(shù)?timegm()
?能很好地解決 UTC 時(shí)間的處理問(wèn)題。從 IEEE Std 1003.1 - 2024 標(biāo)準(zhǔn)中可知,“未來(lái)的標(biāo)準(zhǔn)版本預(yù)計(jì)會(huì)添加一個(gè)?timegm()
?函數(shù),它與?mktime()
?類似,但?timeptr
?指向的?tm
?結(jié)構(gòu)體包含的是協(xié)調(diào)世界時(shí)(UTC)的拆分時(shí)間”。在 Windows 系統(tǒng)上,timegm()
?對(duì)應(yīng)的函數(shù)是?mkgmtime()
。如果你的系統(tǒng)是 AIX,沒有?timegm()
?函數(shù),也可以在特定的地方找到獨(dú)立的實(shí)現(xiàn)。
總結(jié)一下使用?mktime()
?和?timegm()
(或?mkgmtime()
)的要點(diǎn):
-
使用?
mktime()
?處理本地時(shí)間時(shí),把?tm_isdst
?設(shè)置為 -1,這通常符合人們的預(yù)期,但在夏令時(shí)切換時(shí),可能會(huì)隨機(jī)得到兩個(gè) “02:30”(或類似時(shí)間點(diǎn))中的一個(gè)。 -
在填充?
struct tm
?之前,最好先把其他字段清零,以防萬(wàn)一。 -
要知道?
mktime()
?會(huì)修改傳入的?struct tm
,可能會(huì)產(chǎn)生副作用,所以在重復(fù)使用?struct tm
?之前,至少要重置?tm_isdst
。 -
不管你怎么設(shè)置?
tm_gmtoffset
?或?tm_zone
,mktime()
?都會(huì)使用當(dāng)前時(shí)區(qū)。如果你想讓它把?struct tm
?當(dāng)作 UTC 時(shí)間處理,就需要設(shè)置 TZ 環(huán)境變量為 UTC,但這會(huì)影響其他做時(shí)間操作的線程。所以,能使用?timegm()
?或?mkgmtime()
?就盡量用它們。
四、解析 UTC 時(shí)間字符串
我們都希望能把像 “Fri, 17 Jan 2025 06:07:07 GMT” 這樣的時(shí)間字符串直接傳入?strptime()
?函數(shù),然后得到一個(gè)合理的?struct tm
?結(jié)構(gòu)體。但 Linux glibc 的?strptime()
?手冊(cè)中關(guān)于?%z
?和?%Z
?這兩個(gè)時(shí)區(qū)格式說(shuō)明符的描述含糊不清,讓人摸不著頭腦。很多人可能會(huì)期望用?strptime()
?結(jié)合?%Z
(用于解析 “GMT”),再配合?mktime()
?就能把 UTC 時(shí)間字符串轉(zhuǎn)換為 Unix 時(shí)間戳,但實(shí)際上?mktime()
?根本不會(huì)看?tm_gmtoffset
?和?tm_zone
?這兩個(gè)字段,所以即使?strptime()
?做對(duì)了,也無(wú)法實(shí)現(xiàn)我們的目標(biāo),而且它還真就做不對(duì)。
截至 2024 年,雖然 Open Group 對(duì)?strptime()
?有了更詳細(xì)的規(guī)范,但里面也都是些讓人無(wú)奈的消息。strptime()
?對(duì)?%z
?沒有明確的行為定義,這個(gè)原本應(yīng)該用來(lái)處理 “+0200” 這種偏移標(biāo)識(shí)符的符號(hào),現(xiàn)在根本靠不住。對(duì)于?%Z
,雖然有一些說(shuō)明,但作用也非常有限。只有在特定的本地化設(shè)置下,它才可能設(shè)置?tm_isdst
?的正確值,而且像 “EST” 這樣的字符串,由于沒有明確的定義,也很難通過(guò)?%Z
?來(lái)解析。
不過(guò),既然我們知道了?timegm()
?這個(gè)好幫手,就可以忽略?%z
?和?%Z
?了。
五、strptime()
?與本地化問(wèn)題
在很多情況下,我們需要解析包含英文日期和月份名稱的時(shí)間字符串,并且希望?strptime()
?能正確處理。但 IEEE/Open Group 標(biāo)準(zhǔn)規(guī)定,strptime()
?的轉(zhuǎn)換操作是由當(dāng)前本地化設(shè)置的 LC_TIME 類別決定的。這里有個(gè)容易忽略的點(diǎn),C 和 C++ 程序默認(rèn)使用的是 “C” 本地化,這實(shí)際上就是美式英語(yǔ)。這對(duì)于解析數(shù)據(jù)中的時(shí)間字符串來(lái)說(shuō)通常是好事,因?yàn)檫@些字符串大多是英文的。
但如果你的程序調(diào)用了?setlocale()
?函數(shù),設(shè)置了非 “C” 的本地化,那你的程序可能就只能處理特定語(yǔ)言(比如荷蘭語(yǔ))的時(shí)間字符串了,這可就麻煩了。你可能會(huì)想在調(diào)用?strptime()
?之前把本地化設(shè)置為 “C”,用完再改回來(lái),但?setlocale()
?在多線程程序中調(diào)用并不安全(除了在線程啟動(dòng)之前),而且即使安全調(diào)用,也可能會(huì)影響其他線程的輸出。
所以,一般來(lái)說(shuō),如果你需要解析特定的時(shí)間字符串并且想用?strptime()
,一定要確保程序處于你期望的本地化環(huán)境中。雖然有?strftime_l()
?函數(shù)可以指定格式化時(shí)間時(shí)使用的本地化,但并沒有官方可用的?strptime_l()
?函數(shù)。
當(dāng)然,你也可以自己解析像 “17 Jan 2025 06:07:07” 這樣的字符串,填充?struct tm
?結(jié)構(gòu)體,然后讓?mktime()
?來(lái)計(jì)算 Unix 時(shí)間戳,這也是一種可行的辦法。
六、用 C++ 解決本地化問(wèn)題及 C++20 的強(qiáng)大時(shí)間處理功能
C++ 的輸入輸出流(iostreams)在處理本地化方面比 C/POSIX 做得更好。在 C++ 中,你可以為每個(gè)輸入輸出流設(shè)置本地化。下面是一個(gè) C++ 輔助函數(shù),如果你在設(shè)置了本地化的 C 程序中需要解析任意 UTC 時(shí)間字符串,可以調(diào)用這個(gè)函數(shù):
extern "C"
int utcstr2epoch(const char* timestr, const char* fmtstr, struct tm* output)
{std::tm t = {}; // tm_isdst = 0, 不用考慮夏令時(shí),這是 UTC 時(shí)間std::istringstream ss(timestr);ss.imbue(std::locale()); // "LANG=C", 但本地化設(shè)置是本地的ss >> std::get_time(&t, fmtstr);if (ss.fail())return -1;// 修正星期幾、一年中的第幾天等字段t.tm_isdst = 0; // 不用考慮夏令時(shí)t.tm_wday = -1;if(mktime(&t) == -1 && t.tm_wday == -1) // "真正的錯(cuò)誤"return -1;*output = t;return 0;
}
這個(gè)函數(shù)還展示了如何處理?mktime()
?的錯(cuò)誤。當(dāng)?mktime()
?處理 1969 年 12 月 31 日 23:59 這樣的時(shí)間時(shí),會(huì)返回 -1 作為錯(cuò)誤代碼。我們可以用?tm_wday
?作為標(biāo)志來(lái)判斷是否有數(shù)據(jù)被處理,以此確定是否發(fā)生了錯(cuò)誤。
另外,還有一個(gè)基于 C 的小示例程序,它可以解析英文的 UTC 時(shí)間戳,并使用調(diào)用環(huán)境的本地化設(shè)置來(lái)打印時(shí)間:
$ 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 001
time_t: 0
到了 C++20 及更高版本,更是引入了強(qiáng)大的時(shí)區(qū)數(shù)據(jù)庫(kù)。雖然這個(gè)功能還沒有在所有編譯器上都可用,但預(yù)標(biāo)準(zhǔn)化版本可以單獨(dú)使用。比如下面這個(gè)超酷的例子:
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 月的第一個(gè)星期一,紐約當(dāng)?shù)貢r(shí)間上午 9 點(diǎn)”,然后輕松地將其轉(zhuǎn)換為另外兩個(gè)時(shí)區(qū)的時(shí)間:
The New York meeting is 2016-05-02 09:00:00 EDT
The London meeting is 2016-05-02 14:00:00 BST
The Sydney meeting is 2016-05-02 23:00:00 AEST
更厲害的是,這個(gè)時(shí)區(qū)庫(kù)不僅可以使用操作系統(tǒng)的時(shí)區(qū)數(shù)據(jù)庫(kù)(可能缺少關(guān)鍵的閏秒細(xì)節(jié)),還能直接從 IANA tzdb 獲取數(shù)據(jù)。這意味著你可以精確計(jì)算 1978 年一次航班飛行的實(shí)際時(shí)長(zhǎng),即使這次飛行跨越了夏令時(shí)變化和閏秒,也不在話下。
在 C 和 C++ 中從 UTC 日期時(shí)間字符串獲取 Unix 時(shí)間戳確實(shí)充滿挑戰(zhàn),但只要掌握了正確的方法,也能輕松應(yīng)對(duì)。希望今天的分享能讓你在處理時(shí)間相關(guān)的編程問(wèn)題時(shí)更加得心應(yīng)手。
科技脈搏,每日跳動(dòng)。
與敖行客 Allthinker一起,創(chuàng)造屬于開發(fā)者的多彩世界。
- 智慧鏈接 思想?yún)f(xié)作 -