C/C++日期差计算全指南:struct tm、mktime与夏令时避坑

发布时间:2026/10/14 19:59:41
C/C++日期差计算全指南:struct tm、mktime与夏令时避坑 接后台需求的时候经常要算日期差会员还剩多少天、合同还有几天到期、两个订单间隔了多久。这种“给两个年月日求相差多少天”的需求我第一反应就是上struct tm配合mktime和difftime三件套先把符号日期转成秒级时间戳再做差。路子简单但坑真不少tm_year不是年份本身tm_mon从 0 开始mktime还跟本地时区和夏令时纠缠不清稍不留神算出来的天数就莫名其妙差一天。这篇文章就把这个问题彻底聊透。你会看到struct tm的字段到底怎么填、mktime在幕后做了什么、秒差怎么稳稳当当地折算成天小时分钟以及一批实战中才碰得到的边界场景和排错清单。适合刚接触 C/C 日期时间处理的同学也适合已经写了几年代码、但一碰到日期差就心里发怵的开发者。1. 先搞懂struct tm到底装的是什么1.1tm的字段和两个最反直觉的偏移量struct tm定义在time.hC 里是ctime中它的作用是把“2024年1月1日 12:30:00”这种人类友好的时间表示拆成一组整数字段。标准字段如下字段含义取值范围 / 写法特别提醒tm_year自 1900 年以来的年数年份减 1900比如 2024 就是 124十个人有八个在这里翻车tm_mon月份0 代表 1 月11 代表 12 月填 12 表示的是“明年1月”tm_mday一个月中的第几天1 ~ 31这个本身没坑tm_hour小时0 ~ 23不关心时间就填 0tm_min分钟0 ~ 59同上tm_sec秒0 ~ 60理论上有闰秒实践中填 0 就行tm_wday星期几0 代表星期天不需要主动填mktime会算tm_yday一年中的第几天0 代表 1 月 1 日不需要主动填mktime会算tm_isdst夏令时标记0 生效0 不生效0 未知强烈建议赋 -1让系统判断两个偏移量是历史包袱但你必须接纳它。tm_year之所以要从 1900 开始数是因为早期 C 语言设计时觉得用一个 int 表示“年份差”比存完整年份更省事还可以让 1900 年成为自然的起点。tm_mon从 0 开始则是因为“数组下标从 0 开始”的惯性1 月就对应month_days[0]。理解了这层背景填错的概率会小很多。比如要表示 2024 年 1 月 1 日零点代码是这样struct tm t {0}; t.tm_year 2024 - 1900; // 124 t.tm_mon 1 - 1; // 0 t.tm_mday 1;注意先{0}一把初始化再覆盖需要的字段。不初始化就直接赋值tm_isdst、tm_wday这些你不知道它是多少的字段全凭栈上残留垃圾值决定运气不好就给你算出一个诡异结果。这种随机性 bug 是最耗时间的。1.2 初始化tm的三个“野路子”错误我见过太多次这类错误集中写出来对号入座只给tm_year、tm_mon、tm_mday赋值其他字段不管。后果是tm_isdst是随机值、tm_hour可能是某个栈残留值mktime基于这种半残数据计算结果自然不可控。直接写t.tm_year 2024。mktime会认为你说的是 1900 2024 3924 年然后大概率返回(time_t)-1或者算出一个离谱到天际的秒数。把月份写成 12 表示 12 月。mktime对这个不报错它只会默默进位成下一年的 1 月然后你的天数差就凭空多了一年加几天。正确姿势很固定先写struct tm t {0};再单独给年月日时分秒赋值最后显式设t.tm_isdst -1;。这套动作下来基本可以安心。2. 核心流程拆解从两个日期到精确秒差2.1 三步走构造、mktime、difftime计算两个日期差的标准三步构造两个struct tm填入起始日期和结束日期。分别调用mktime(t1)和mktime(t2)得到两个time_t。time_t是一个“自 1970-01-01 00:00:00 UTC 起经过的秒数”的时间戳类型可以理解为时间轴上的绝对刻度。用difftime(t2, t1)求差得到一个以秒为单位的double。为什么非要绕一圈用mktime因为struct tm是“人类时间”它不能直接做减法。比如“2024-01-01”和“2025-03-15”你没法直接拿tm_year相减再折算天数还得考虑闰年、每月天数不同。而mktime把年份、月份、日期、时分秒统一换算成秒减法就变成了简单的刻度差。至于为什么用difftime而不是直接t2 - t1是因为标准 C 没有规定time_t一定是整数某些平台上它可能是一个浮点数。difftime是标准规定的可移植写法。现代 Windows/Linux 上time_t基本都是 64 位整数直接用减法也行但我个人习惯保持difftime这样代码搬到别的环境也挑不出毛病。2.2 秒差如何换算成天、时、分、秒difftime给的是秒差要转成“天 小时 分钟 秒”思路就是把这一大坨秒数一层层剥开。86400 秒是一天剥掉整天数后剩下的秒数里按 3600 剥出小时再按 60 剥出分钟最后剩多少秒就是多少秒。形象点说就像手上一大把硬币先十个一捆剩下的再五个一捆再两个一捆最后数数单个的。long total_sec (long)difftime(t_end, t_start); long days total_sec / 86400; long hours (total_sec % 86400) / 3600; long minutes (total_sec % 3600) / 60; long seconds total_sec % 60;如果只需要整天数直接seconds / 86400就行小数部分舍弃。但这里有个隐藏的浮点坑difftime返回的是double在极端情况下diff_sec / 86400可能得到1.9999999999998这样的小数转换成long取整后变成 1而不是 2。对付这种问题要么先加一个极小量再取整要么直接用llround(diff_sec / 86400.0)或者更彻底点既然时间戳本来就是整数秒直接把diff_sec强转成long long再做整数除法浮点误差就无从谈起了。2.3 一个完整的可运行示例看一个完整例子计算 2024-01-01 00:00:00 到 2025-03-15 12:30:00 的时间差。#include stdio.h #include time.h int main(void) { struct tm start_tm {0}; struct tm end_tm {0}; start_tm.tm_year 2024 - 1900; start_tm.tm_mon 1 - 1; start_tm.tm_mday 1; start_tm.tm_isdst -1; end_tm.tm_year 2025 - 1900; end_tm.tm_mon 3 - 1; end_tm.tm_mday 15; end_tm.tm_hour 12; end_tm.tm_min 30; end_tm.tm_isdst -1; time_t start_sec mktime(start_tm); time_t end_sec mktime(end_tm); if (start_sec (time_t)-1 || end_sec (time_t)-1) { printf(日期转换失败\n); return 1; } long long total_sec (long long)difftime(end_sec, start_sec); long long days total_sec / 86400; long long hours (total_sec % 86400) / 3600; long long minutes (total_sec % 3600) / 60; long long seconds total_sec % 60; printf(相差 %lld 天 %lld 小时 %lld 分 %lld 秒\n, days, hours, minutes, seconds); return 0; }输出相差 439 天 12 小时 30 分 0 秒这个结果怎么验算2024-01-01 到 2025-01-01 是 366 天2024 是闰年2025-01-01 到 2025-03-15 又是 73 天零 12 小时 30 分加起来就是 439 天多。和代码输出对得上说明思路没问题。3.mktime这个函数远比你想的聪明3.1 自动规范化非法日期也会被“纠正”mktime有个非常容易忽略的行为它会把struct tm里那些“看起来不合理”的字段自动归一化。比如你把tm_mon写成 12它不报错而是当成下一年的 1 月处理把tm_mday写成 0它当成上个月最后一天把 2024 年 2 月 30 日写进去2024 年是闰年2 月只有 29 天它就把日期进位成 3 月 1 日。这意味着什么一方面你不小心填错日期mktime一般不会直接失败只会默默给你“纠正”成一个合理日期所以结果看起来正常实际上不是你想要的。排查时看到这种“不算错但不对”的结果往往比报错更头疼。另一方面这个特性可以被拿来正经用。比如“算 30 天后的日期”这种需求你根本不需要自己去算 9 月 30 加 30 天会不会变成 10 月 30 日、还是 10 月 31 日直接让mktime处理进位struct tm t {0}; t.tm_year 2024 - 1900; t.tm_mon 2 - 1; t.tm_mday 15; mktime(t); // 此刻 t.tm_mday 可能已被改写如果你 mday 超限的话 // 求 30 天后的日期 t.tm_mday 30; mktime(t); printf(30天后的日期: %d-%02d-%02d\n, t.tm_year 1900, t.tm_mon 1, t.tm_mday);mktime调用一次后tm_wday、tm_yday也会被正确填上等于帮你把日历算好了。这种“时间运算交给时间库”的思路比手写一堆大小月、闰年逻辑省事得多。3.2 时区与夏令时tm_isdst到底该填什么mktime把struct tm解释为“本地时间”然后转换成 UTC 基础上的time_t。这句话信息量很大。如果你的程序跑在中国标准时间UTC8环境下调用mktime把 2024-01-01 00:00:00 变成time_t得到的时间戳其实是 UTC 时间 2023-12-31 16:00:00 对应的秒数。但如果你再同样转换另一个本地时间这两个时间戳做差时区偏移就抵消了最终差值依然是准确的。所以同机、同时区下用mktime算两个本地时间的差是安全的。真正的麻烦是夏令时。在某些实行夏令时的地区每年的某个周末凌晨时钟会直接跳 1 小时。那天只有 23 小时秋天再跳回来那天有 25 小时。于是哪怕两个“日历日期”明明隔了一天秒差可能是 23 小时或 25 小时按 86400 秒折算整天数时边界情况就可能差 1 天。tm_isdst就是为了解决这个问题的。它的约定是正数这个时间处于夏令时生效期0这个时间处于标准时间非夏令时负数不知道请帮我自动判断。mktime在计算时会根据本地时区规则和日期自行判断当天是否是夏令时。但如果你手动把tm_isdst写成 0那么即使当天确实在夏令时窗口内mktime也可能按标准时间处理结果偏差 1 小时。反之亦然。所以最稳妥的做法就是统一赋-1把专业的事交给mktime。调用完成后它可以反手把tm_isdst改成它最终判断出的值你可以读它来确认。如果需求是纯粹算“两个日历日期的天数差”根本不想被时区、夏令时这些破事干扰更干净的姿势是用“日历天数差”算法不经过time_t这个我在后面第 4.3 节给出方案。3.3 返回值(time_t)-1的两种含义mktime转换失败时返回(time_t)-1。麻烦的是-1本身也是一个合法时间戳1969-12-31 23:59:59 UTC。也就是说你不能只看返回值是不是 -1 来判断失败因为如果输入的日期恰好能转成这个值返回值也是 -1但它是成功的。标准建议是调用前把errno清零调用后检查返回值是否为(time_t)-1并且errno是否变成EOVERFLOW。如果time_t表示不了这个日期通常就会设置这个错误码。实践代码可以这样写#include errno.h errno 0; time_t ts mktime(t); if (ts (time_t)-1 errno EOVERFLOW) { printf(日期超出 time_t 可表示范围\n); }别小看这个检查。有些平台对年份范围限制很死你传一个 1900-01-01 进去它可能直接给 -1 并置 EOVERFLOW而你还在那傻等一个正常时间戳。4. 边界场景和性能哪些日期能用哪些会翻车4.1 适合mktime的年份范围和time_t溢出问题mktime不是万能的。它的能力上限是time_t能表示的范围。在 32 位平台上time_t通常是 32 位带符号整数最大能表示到 2038 年 1 月 19 日 03:14:07 UTC这就是著名的“2038 年问题”。你如果要算 2038 年之后的日期差在这种平台上直接得到(time_t)-1EOVERFLOW你就得换方案。现代 64 位 Linux/Windows 上time_t基本已经是 64 位可以覆盖极大范围的年份但这些平台的mktime到底支持多早的年份也各不相同。有的支持到 1901 年有的支持到 0000 年有的甚至往前推得更远。这类环境差异没法靠脑补写代码的时候必须验证边界。我的经验是“先拿你业务里出现的最早和最晚日期各跑一次mktime确认返回值不是(time_t)-1再上线。”这一条能帮你挡掉至少三成生产事故。4.2 同一天、跨年、跨闰年、跨夏令时的实测结果拿实际例子跑一遍比空谈理论有用。下面这些用例我在 64 位 Linux 上实测过结果如下起始日期结束日期秒差按 86400 折算天数备注2024-01-01 00:002024-01-01 00:000 秒0 天同一天2024-01-01 00:002024-01-02 00:0086400 秒1 天普通相邻日2024-01-01 00:002024-12-31 00:0031536000 秒365 天平年视角下差一天到整年2024-01-01 00:002025-01-01 00:0031622400 秒366 天2024 闰年2023-03-01 00:002024-03-01 00:0031622400 秒366 天跨闰日重点看“2024-01-01 到 2024-12-31”这个例子它是 365 天不是 366 天。如果你要算的是“今年从元旦到今天经历了多少天”很多语言习惯是把当天也算进去最后结果会大一。这不是代码问题是需求口径问题开发前必须对齐。再看夏令时场景。假设你在一个实行夏令时的时区比如纽约时间2024 年 3 月 10 日凌晨 2 点切到 3 点。那么 2024-03-09 00:00 到 2024-03-11 00:00 的秒差是 47 小时而不是 48 小时折算整天数就是 1 天。但按日历数这明明是两天。如果你用mktime 秒差折算就会踩坑。这种场景下要么把时间基准调成 UTC要么干脆别用秒差。4.3 如果你不想依赖tm手写日期差算法的思路有些场景mktime不好使要算 1900 年以前的历史日期或者要绕开时区/夏令时又或者平台太破没有完整的time_t。这时候需要自己写一个“日历天数差”函数思路很直白——从某个固定基准日比如公元 1 年 1 月 1 日出发分别算出两个日期距离基准日的“总天数”再相减。先给一个容易理解的教学版实现一年一年累加代码简单但性能一般int is_leap(int y) { return (y % 4 0 y % 100 ! 0) || y % 400 0; } long days_from_civil_v1(int y, int m, int d) { static const int month_days[] {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; long total d; for (int yy 1; yy y; yy) total is_leap(yy) ? 366 : 365; for (int mm 1; mm m; mm) { total month_days[mm - 1]; if (mm 2 is_leap(y)) total 1; } return total; }调用方式就是days_from_civil_v1(y2, m2, d2) - days_from_civil_v1(y1, m1, d1)。比如算 2024-01-01 和 2025-03-15 的天数差可以得到 439和前面mktime结果一致。教学版最大的问题是循环。如果年份跨度是几千年几万次循环也没什么但有些嵌入式环境就是不喜欢循环。这时候可以用经典日历算法一条公式算出累计天数业界常见的days_from_civil实现长这样long days_from_civil(int y, unsigned m, unsigned d) { y - m 2; const long era (y 0 ? y : y - 399) / 400; const unsigned yoe (unsigned)(y - era * 400); // [0, 399] const unsigned doy (153 * (m (m 2 ? -3 : 9)) 2) / 5 d - 1; // [0, 365] const unsigned doe yoe * 365 yoe / 4 - yoe / 100 doy; // [0, 146096] return era * 146097 (long)doe - 719468; }这个公式把 1 月、2 月当成上一年的 13 月、14 月然后利用了 400 年一个循环周期的规律一次循环都没有纯数学运算。它的输出是“距离 1970-01-01 的天数”负数表示更早。你可以拿它算任意公元年份的天数差稳得很。缺点是不太容易一眼看懂所以我代码注释也写得比较细方便后来人接手。5. 常见问题与排查速查表5.1 相差天数总是比预期少 1 天先检查需求还是代码这是出现频率最高的问题。我的排查顺序是先核口径“同一天”算 0 还是算 1“昨天到今天”算 1 还是算 2不同业务对“日期差”的定义经常不同。如果需求是“算优惠券剩余天数”通常今天也算一天那就得在代码结果上自己加 1。再查时间点两个日期是不是都设在00:00:00如果起始日期是昨天 23:00结束日期是今天 00:00差值只有 1 小时折算整天数是 0但业务上它应该是“相差 1 天”。这种情况要把时分秒清零再做差。最后查时区有没有可能跨了夏令时切换日导致秒差不是 86400 的整数倍。这点我前面已经展开过。多数时候问题出在第 1 和第 2 步代码本身反而没问题。5.2mktime返回 -1别急着断言“日期无效”我在排查线上问题时见过这样的代码if (mktime(t) -1) { printf(日期无效); }。这个判断是错的。原因就是我前面说的-1 本身可以是一个合法时间戳而且真正的失败原因是“超出time_t范围”而不是“无效日期”。无效日期会被mktime强行规范化根本到不了失败分支。正确写法要同时看errno。另外如果业务里有“1900-01-01”这类早期日期先在测试环境验证一下mktime支不支持别等上线了才发现所有历史数据都算不了。5.3 少部分“日期差”场景根本不该用mktime如果你发现自己的代码为了绕开时区、绕开夏令时、绕开年份范围而加了一堆补丁那大概率是该换方案了。mktime适合“用当前时间体系的真实瞬间差”但有些业务要的是“日历上的纯天数差”人工翻日历数出来的那种。这时直接用上一节days_from_civil之类的日历算法把两个日期变成累计天数再相减干净利落不受时区、夏令时、time_t范围任何一项影响。另外一个常见需求是“判断某个日期是否在另一个日期之前/之后”。如果日期差只用于比较大小不需要具体数值很多情况下直接比较“累加天数”就够了连秒都不用转。5.4 速查表汇总症状大概率原因建议处理天数差总是少 1 天口径问题今天算没算进去 / 时分秒没清零明确语义清零时分秒必要时结果加 1结果在跨夏令时那几天不对秒差是 23 或 25 小时改用日历天数算法mktime返回 -1年份超出time_t范围或恰好得到 1969 年底的时间戳检查errno换 64 位换算法结果每次运行不一样tm未初始化垃圾值干扰一律{0}初始化日期填进去变成奇怪结果tm_year/tm_mon偏移量搞错年份减 1900月份减 1浮点除法出现 1.9999999difftime返回 double 的精度问题转成整数后再做除法或llround6. 最后一点工程化建议把日期差计算直接揉进业务代码里短期爽长期痛。我一般会封装成一个小函数参数直接收年月日内部自己处理tm、mktime、失败分支调用方只关心“两个日期差多少天”。比如这样long diff_days(int y1, int m1, int d1, int y2, int m2, int d2) { struct tm t1 {0}; struct tm t2 {0}; t1.tm_year y1 - 1900; t1.tm_mon m1 - 1; t1.tm_mday d1; t2.tm_year y2 - 1900; t2.tm_mon m2 - 1; t2.tm_mday d2; t1.tm_isdst -1; t2.tm_isdst -1; time_t s1 mktime(t1); time_t s2 mktime(t2); if (s1 (time_t)-1 || s2 (time_t)-1) return days_from_civil(y2, m2, d2) - days_from_civil(y1, m1, d1); return (long)((s2 - s1) / 86400); }这样正常年份走mktime快捷路径极端年份自动退化到日历算法。调用方拿到的永远是一个“看起来正常的整数天数”。最后再分享一个小技巧mktime的自动归一化特别适合做“相对日期推算”。比如t.tm_mday 30; mktime(t);就能得到 30 天后的日期你不需要关心这个月是 28 天还是 31 天也不用管跨年跨月。这个技巧我到处用确实香。日期计算这件事思路清晰比背 API 重要先把“时间戳”和“日历字段”这两个概念分开再把口径和边界想清楚坑自然就少一大半。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询