C++11毫秒时间戳与日期互转:原理、代码与避坑指南

发布时间:2026/10/17 4:30:33
C++11毫秒时间戳与日期互转:原理、代码与避坑指南 跟时间戳打交道每个写 C/C 的人早晚都得碰一次。项目里要记录日志毫秒时间戳或者根据日期字符串生成带毫秒的请求序号一个不留神就翻车。老规矩这篇文章就围绕“C/C11 的毫秒时间戳和日期互转”这个话题把我自己踩过的坑、验证过的代码和排查思路一次说清楚。这不是一篇只贴代码的速查笔记。我会把“为什么这么写”也讲透毫秒时间戳的本质是什么、C11 的 chrono 比老的 time 系列函数强在哪、strftime 和 get_time 到底怎么配合、时区偏移怎么处理才不容易错。无论你是刚接触 C 的学生还是正在维护老项目的开发者看完都能直接抄作业并且知道出问题的时候往哪查。1. 需求背景与整体设计思路1.1 毫秒时间戳是什么先对齐基本概念所谓时间戳简单说就是从某个固定时刻开始计算的累计时间。Unix 时间戳的单位是秒从 1970-01-01 00:00:00 UTC 开始计算毫秒时间戳就是把单位缩小到千分之一秒即毫秒。比如当前 Unix 秒数是 1710000000那对应的毫秒数通常是 1710000000 乘以 1000再加上不足一秒的毫秒余数。很多接口签名、日志系统、缓存 key 都习惯用毫秒级时间戳。原因很直白秒级粒度太粗在同一个进程里连续记录两条日志如果只用秒可能两条日志的时间完全一样排序就乱了。毫秒级别虽然也并非绝对安全但对绝大多数业务场景已经够用而且 64 位整数表示毫秒一直到公元 584 年之前都不会溢出随手用 int64_t 存起来毫无压力。这里有个关键点容易搞混Unix 时间戳本身就是 UTC 时刻的秒数它不携带时区信息。你在北京、纽约、伦敦拿到的是同一个整数只是转换成“人看”的日期时间时才会因为时区不同显示成不同的小时数。很多人写转换函数时没搞清楚这一层后面就容易在“本地时间”和“UTC 时间”之间反复横跳。1.2 C11 里为什么不用老一套时间函数老 C 风格的时间函数是 time_t、time()、localtime()、gmtime()、strftime() 这一串。time_t 本质上是整数C11 也保留着它们但实际写新代码时我会优先用 std::chrono 这套库原因有三个。第一个原因是精度受控。time() 只能拿到秒如果你想拿毫秒老写法得配合 gettimeofday() 或者其他平台 API。而 std::chrono 的 system_clock 自带 duration 模型duration_cast 可以精确截断到毫秒、微秒、纳秒类型系统会告诉你单位是什么不容易出现“把纳秒当成毫秒”这种低级错误。第二个原因是可读性更好。用 chrono 表达“从纪元到现在经过的毫秒数”代码里清清楚楚 std::chrono::duration_cast std::chrono::milliseconds (now.time_since_epoch()).count()。即使你不熟悉模板扫一眼也知道是在把时间点转换成毫秒。第三个原因是 C11 提供了 put_time 和 get_time配合 std::tm 可以做时间格式化和解析虽然还是离不开 C 标准库的 tm 结构但至少在语法层面比手写字符串拼接舒服很多。不过也要说句实话C11 的 chrono 有一个明显的短板它只管“时间点”和“时长”并不管日历换算。也就是说它不会直接告诉你“当前是 2025 年 5 月 3 日”你必须先把时间点转成 time_t再交给 ctime 系函数处理。真正解决日历计算的完整能力在 C20 才补齐所以 C11 项目里我们通常会混用 chrono 和传统时间函数。理解这个边界你就不会在 std::chrono 里死磕“怎么算月份”那不是 C11 的活。1.3 转换方案选型对比做毫秒时间戳和日期互转常见的方案有三条路。第一条是纯 C 函数路线毫秒数除以 1000 得到秒然后用 localtime/gmtime 转成 tm再用 strftime 格式化成字符串。反方向用 mktime 或者 timegm。优点是与 C11 无关老项目也能用缺点是线程安全要自己注意而且在 Windows 和 Linux 上处理 UTC 转 time_t 时函数名不一样。第二条是 C11 风格用 std::chrono 获取毫秒转成 time_t 后用 std::put_time 格式化解析字符串用 std::get_time 生成 tm再调 mktime。优点是代码简洁标准库就能搞定缺点是 get_time 对错误输入比较宽容容易悄悄解析成功需要额外校验。第三条是手写日历算法完全脱离平台函数。比如把年月日时分秒加偏移量后用公式算出对应的时间戳。这适合对跨平台、跨时区一致性要求极高的场景比如我自己以前做过一个分布式计时模块就希望同一段代码在任何机器上输出同样的 UTC 时间戳不依赖本地时区设置。我的建议是如果你的程序只需要在固定平台跑第一条或第二条都行如果你需要处理 UTC 时间且要跨平台一致或者你所在的环境时区配置很乱直接上手写一个小函数反而最可控。2. 转换原理与核心细节拆解2.1 获取当前毫秒时间戳的正确姿势获取当前毫秒时间戳核心代码其实只有三行#include chrono int64_t now_ms() { auto now std::chrono::system_clock::now(); return std::chrono::duration_caststd::chrono::milliseconds( now.time_since_epoch()) .count(); }系统时钟在不同平台上的精度可能不同有的能到纳秒有的只能到微秒。不过 duration_cast 到毫秒之后你拿到的一定是一个毫秒精度的整数余下不足一毫秒的部分直接截断没有四舍五入。这里有个细节system_clock 不一定保证与 Unix 纪元完全一致但在主流平台上它都是从 1970-01-01 UTC 开始计时的。如果你担心跨平台的一致性可以在程序启动时打印一下 now().time_since_epoch().count()和系统时间对比一下心里就有底了。我见过有人为了“高精度”用 high_resolution_clock 去做时间戳。这个我要专门提醒一句high_resolution_clock 通常用单调时钟实现它不保证是 Unix 纪元时间点只是适合测量时间间隔不适合生成墙壁时钟的时间戳。很多测试程序在 Windows 上跑high_resolution_clock 和 system_clock 差了很多就是因为这个。生产环境里千万别用 high_resolution_clock 做日期转换。2.2 毫秒时间戳转日期时间的两种路线拿到毫秒时间戳以后转成日期时间字符串的“正宗流程”是先除以 1000 得到秒级 time_t。再取余 1000 得到毫秒部分。把 time_t 交给 gmtime 或 localtime得到 tm 结构。用 strftime 或 put_time 把 tm 格式化成年月日时分秒。手动拼接毫秒部分。为什么不能直接把毫秒数交给 time_t因为 time_t 虽然在绝大多数平台上是 Unix 秒数但你直接塞进去一个毫秒数会让后面的转换结果完全错乱。你可能会想先转成 double 再格式化比如 1710000000.123 这样的形式也能显示但浮点数在输出时可能丢失最后一位精度而且后续计算、排序都很麻烦。整型拆成“秒 毫秒余数”是最干净的做法。在格式化时strftime 支持的格式串很丰富例如 %Y-%m-%d %H:%M:%S。如果要带毫秒strftime 本身没有 %f 之类的毫秒格式所以只能自己拼接。我习惯的格式是 “%Y-%m-%d %H:%M:%S.%3u”在 POSIX 系统上 %3u 会输出三位微秒补零但这不是 C 标准可移植性不好。跨平台我还是用 strftime 输出秒再手动加 “.” 加毫秒字符串。2.3 日期字符串转毫秒时间戳的解析与换算反向转换比正向要难难在两点一是字符串解析本身容易出错二是“日期时间”到底代表本地时区还是 UTC 时区必须提前定死。如果字符串格式固定最简单是用 sscanf 手工拆int year, month, day, hour, min, sec; int matched sscanf(s.c_str(), %d-%d-%d %d:%d:%d, year, month, day, hour, min, sec); if (matched ! 6) { // 解析失败 }这种方式在格式规整的项目里足够可靠而且没有各种环境依赖。解析完可以把字段填进 std::tm再调用 mktime 得到 time_t。但 mktime 默认把 tm 解释为本地时区这就带来一个非常常见的坑当服务器时区是 UTC8 时原本想构造 UTC 早上 8 点结果 mktime 会把它当成北京时间早上 8 点换算成 UTC 就是 0 点时间戳直接差了 8 小时。所以你必须先确定接口契约。比如日志里记录的“2025-05-03 16:30:00.123”到底是一个 UTC 时刻还是本地时间在某个时区的显示结果如果是前者就需要一个不依赖本地时区的转换函数如果是后者用 mktime 加本地时区反而顺手。C 标准里没有 timegm但 POSIX 系统普遍有 timegmWindows 则有 _mkgmtime。跨平台代码可以用条件编译包装一下。如果你连这个都不想依赖那就可以走下一条路手写日历算法。2.4 时区、偏移量和夏令时到底怎么处理时区是时间戳转换里最大的坑。核心规则只有一句话Unix 时间戳一定代表 UTC 时间点它本身没有时区概念。当你想把一个“本地时间字符串”转成毫秒时间戳你要先想清楚本地时区偏移。比如北京时间是 UTC8那么本地时间“2025-05-03 16:30:00”对应的 UTC 秒数等于“把它当作 UTC 得到的秒数”减去 8 小时。mktime 会自动应用系统当前时区和夏令时规则所以你只要保证系统时区设置正确mktime 也可以直接用但它的行为在夏令时切换时会有不确定性。夏令时是另一个陷阱。中国已经不用夏令时但欧美很多地区还在用如果你的程序要处理国际业务mktime 和 localtime 在处理夏令时切换时可能遇到“不存在的时间”或“重复的时间”。比如凌晨 2:00 到 3:00 之间因为夏令时向前跳一小时这段本地时间客观上不存在反过来秋天回拨时凌晨 1:00 到 2:00 会出现两次。这种场景不是简单加减小时能解决的需要靠系统的时区数据库。我的经验是如果只是单机日志用本地时间没问题如果涉及接口、数据库、分布式系统全部统一成 UTC只在展示层才转本地时间。这样就不用天天想着夏令时和时区偏移转换逻辑会简单非常多。3. 可复用代码实现与实操记录3.1 构造跨平台毫秒时间戳获取与格式化函数先说获取部分。我建议把“当前毫秒时间戳”封装成一个统一的函数项目里所有模块都调用它不要到处写 chrono 代码。#include chrono #include cstdint int64_t GetCurrentMillis() { return std::chrono::duration_caststd::chrono::milliseconds( std::chrono::system_clock::now().time_since_epoch()) .count(); }拿到毫秒后如果需要格式化成带毫秒的本地时间字符串可以使用下面这个函数。我特意把“秒转换”和“毫秒填充”分开处理避免把毫秒当作 time_t 的一部分。#include ctime #include string std::string FormatMillisAsLocal(int64_t ms) { std::time_t seconds static_caststd::time_t(ms / 1000); int milli static_castint(ms % 1000); std::tm tm_value{}; #if defined(_WIN32) localtime_s(tm_value, seconds); #else localtime_r(seconds, tm_value); #endif char buf[32] {0}; std::strftime(buf, sizeof(buf), %Y-%m-%d %H:%M:%S, tm_value); return std::string(buf) . (milli 100 ? (milli 10 ? 00 : 0) : ) std::to_string(milli); }这里用 localtime_r / localtime_s 就是为了线程安全。老代码里常用的 std::localtime 返回的是静态缓冲区指针两个线程同时调用就会出现 “一个线程改了缓冲区另一个线程读出来是乱数据” 的情况。虽然很多人的程序里没有真实并发但日志打印这种高频操作很容易被多线程触发别把安全寄托在运气上。毫秒补零的逻辑我直接写在返回语句里print 成三位数。如果你觉得可读性差可以用 snprintf 一次性输出snprintf(buf 19, 5, .%03d, milli);不过这种 19 是硬编码秒部分长度格式一旦调整就容易失效所以我还是保留了手动拼接。调用效果大概是int64_t now GetCurrentMillis(); std::cout FormatMillisAsLocal(now) std::endl; // 例如 2025-05-03 16:30:00.1233.2 用算法实现 UTC 日期转毫秒不被时区绑架如果项目里需要把确定的 UTC 日期时间转换成毫秒时间戳我强烈建议用手写日历算法。这类算法最出名的参照是 Howard Hinnant 在 chrono 库中的 days_from_civil 思路它在 C20 中被吸收不用平台 API不受时区环境影响。核心逻辑是从公历日期算出“从 1970-01-01 到那天的天数”再乘 86400 秒加上一天内秒数最后拼上毫秒。#include cstdint int64_t DateToMillisUtc(int year, int month, int day, int hour, int minute, int second, int milli) { year - month 2 ? 1 : 0; int era (year 0 ? year : year - 399) / 400; unsigned yoe static_castunsigned(year - era * 400); unsigned doy (153 * (month (month 2 ? -3 : 9)) 2) / 5 day - 1; unsigned doe yoe * 365 yoe / 4 - yoe / 100 doy; int64_t days_since_epoch static_castint64_t(era * 146097 doe - 719468); int64_t seconds_in_day hour * 3600 minute * 60 second; return (days_since_epoch * 86400 seconds_in_day) * 1000 milli; }这段算法的好处是它纯粹以数学方式完成公历到序列日期的换算不依赖 mktime也不依赖系统时区。我曾在某台时区设置混乱的测试机上实测mktime 转出的小时数差了 5 小时而这段算法输出的 UTC 毫秒时间戳与在线时间戳转换网站完全一致。不过这套公式对 year 参数的处理范围很宽正常业务用 1970 到 2100 年完全没有问题。唯一的注意事项是它假定输入是公历的“合法日期”比如 2 月 30 日不会自动纠正你需要在上游做合法性校验。3.3 解析日期字符串成结构化时间的实现解析日期字符串看起来容易但有一个隐藏问题printf 式的解析可能把不合法的日期也吞掉。比如 “2025-02-30 12:00:00”sscanf 能匹配 6 个整数但 2 月 30 日根本不存在。后期转成时间戳时会得到某个奇怪的结果非常难排查。所以我在解析函数里会多做一层校验。可以用 timegm/mktime 的“归一化”特性来判断吗mktime 会把 2 月 30 日自动修正成 3 月 2 日这反而掩盖了输入错误。更好的做法是解析后立即检查month 在 1 到 12 之间day 在 1 到该月最大天数之间hour/min/sec 在正常范围。各月最大天数可以写一个简单函数bool IsValidDate(int year, int month, int day) { if (month 1 || month 12 || day 1) return false; int leap (year % 4 0 year % 100 ! 0) || (year % 400 0); static const int days_in_month[] {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; int max_day days_in_month[month - 1]; if (month 2 leap) max_day 29; return day max_day; }解析实现#include cstdio #include string bool ParseDateTime(const std::string s, int year, int month, int day, int hour, int minute, int second, int milli) { if (s.size() 19) return false; char millis_buf[4] {0}; int matched sscanf(s.c_str(), %d-%d-%d %d:%d:%d.%3s, year, month, day, hour, minute, second, millis_buf); if (matched ! 7 matched ! 6) return false; if (!IsValidDate(year, month, day)) return false; if (hour 0 || hour 23 || minute 0 || minute 59 || second 0 || second 59) { return false; } milli matched 7 ? atoi(millis_buf) : 0; return true; }这个解析函数能处理两种格式带毫秒和不带毫秒。如果毫秒只写一位或两位atoi 会把它当成 0、1、12 这样的“数字”但不会是三位。通常日志格式都会统一三位毫秒如果不是三位你可以在解析后乘一个系数补位。最简单的做法是统一要求三位不要给输入留太多自由。3.4 完整测试用例与输出验证我自己验证时喜欢拿一个固定的 UTC 毫秒时间戳做回归测试比如 1710000000123。先换算成人类可读时间再用算法转回去检查是否一致。手工换算1710000000 秒对应 2024-03-09 16:00:00 UTC我测试时不会依赖心算而是先用在线工具固定几个值。这里我建议你也先找一个可预期的时间点验证比如 1970-01-01 00:00:00.000 对应 02000-01-01 00:00:00.000 UTC 对应 946684800000。用函数测一下#include iostream int main() { std::cout DateToMillisUtc(1970, 1, 1, 0, 0, 0, 0) std::endl; // 期望 0 std::cout DateToMillisUtc(2000, 1, 1, 0, 0, 0, 0) std::endl; // 期望 946684800000 std::cout FormatMillisAsLocal(GetCurrentMillis()) std::endl; }跑完之后如果时间戳能对上再拿一个“当前时间”做对照基本上就可以默认函数没写错。我经历过的很多“时间戳不对”最后都发现是测试本身的时间点不对而不是算法错所以测试一定要有锚点值。4. 常见问题与排查技巧实录4.1 32 位平台上的整数溢出有一个很经典的坑32 位平台上time_t 被定义为 32 位有符号整数的环境仍然存在而毫秒数如果强行塞进 32 位 int会立刻溢出。2038 年问题对秒级 time_t 都够紧张毫秒级更是提前几十年就爆了。所以项目里涉及毫秒时间戳一律用 int64_t 或 long long并且不要在计算过程中用 int 临时存储。我曾经见过一个回调接口函数签名写的是 int timestamp_ms结果传进来的毫秒数被截断成负数排查了很久才发现是接口参数类型定义错了。这个教训很直接看到时间戳相关的整数先确认是不是 64 位。另外还要注意单位换算时的乘法溢出。比如 days_since_epoch * 86400 再乘 1000如果 days_since_epoch 是 int64_t没问题但如果某个中间变量是 int运算结果是 int溢出后再赋给 int64_t就为时已晚。C 的整型提升规则比较隐蔽最好把所有参与时间计算的变量都声明成 int64_t 并显式转换。4.2 浮点数时间戳的精度丢失有人喜欢把时间戳表示成 double觉得 17 位有效数字够用。实际上一旦数值超过 2^53 左右double 就保证不了每个整数都能精确表示。当前毫秒级时间戳大概是 1.72e12远小于 9e15所以用 double 存当前毫秒好像没事。但如果你做微秒、纳秒换算或者把时间戳累计到未来很远double 就会悄悄丢掉个位数精度。一个更常见的错误是用 double 做“秒 小数”的中间表示。比如毫秒 1710000000123除以 1000 得到 1,710,000,000.123转成 double 后可能变成 1710000000.1229999。再乘回 1000 取整结果就差了 1 毫秒。这种问题在排序、去重、签名校验时尤其致命。我的规则是时间戳永远是整数毫秒就是 int64_t需要秒数时用整数除法 1000需要小数秒时才允许字符串拼点号但内部绝不经过浮点。4.3 线程安全与可重入问题调用 std::localtime、std::gmtime 这类函数时内部会使用线程局部存储吗标准并不保证线程安全很多老平台实现是静态缓冲区。虽然现代编译器可能已经做了线程局部线程安全处理但你无法确保所有目标平台都如此。高并发日志系统里我遇到过两条线程同时调 localtime结果一条线程打印出来的时间跑到另一条日志里最后定位到就是这个函数。解决方案很简单优先使用平台提供的可重入版本Windows 上 localtime_sLinux/Unix 上 localtime_r。如果你担心跨平台编译麻烦可以像我前面那样用宏包一层。如果你用的第三方库内部调用了 localtime那就要审视那个库是不是线程安全的或者干脆避免在共享代码路径中调用。还有 strftime 本身没有这种问题因为它操作的是你传入的 tm 结构不访问全局状态。但 put_time 返回的是一个流迭代器要注意流的并发写入问题多线程同时向同一个 ostream 输出时还是建议加锁。4.4 边界条件2038 年、闰秒、跨天毫秒时间函数最容易在最意想不到的边界出问题。首当其冲就是 2038 年。如果你的代码还在用 32 位 time_t那么 2038 年 1 月 19 日之后秒数溢出所有时间戳转换瞬间错乱。现在很多现代系统已经是 64 位 time_t但嵌入式设备不一定。最好在代码里加一个静态断言确认 sizeof(time_t) 8。如果你能控制编译选项强制用 64 位是更好的选择。闰秒是一个说起来让人头疼但实际影响很小的问题。Unix 时间戳在闰秒时可能出现 23:59:60 这样的秒值但很多系统直接忽略闰秒。标准库的时间函数大多不处理闰秒strftime 对 %S 也不会输出 60。因此你的业务系统不要试图精确到“闰秒”级别那需要专门的协议和时间服务普通应用没这个必要。跨天毫秒指的是毫秒时间戳落在 23:59:59.999 之后下一秒就是 00:00:00.000日期也自动加一天。这个逻辑看起来简单但如果你用字符串拼接而不是结构体转换很容易写出 24:00:00 这种结果。手工处理进位非常容易写错所以能交给 tm/mktime 自动归一化就交给它们或者采用完整的日历算法。5. 最后再分享几个实用经验5.1 日志框架里的毫秒时间戳格式化如果你的日志系统要输出毫秒建议把所有格式化逻辑收拢到一个公共模块里不要在业务代码里到处写 DateTimeToString。这样以后想改成纳秒、改成 UTC或者调整格式都只需要改一处。我自己的日志格式是 [2025-05-03 16:30:00.123] [INFO] 消息内容。毫秒部分一定要补足三位否则日志排序看起来特别乱。另外记得在格式化之前先调用获取时间戳不要在格式化和获取之间穿插其他耗时操作否则毫秒数可能延迟你记录到的“当前时间”已经不是当前了。5.2 接口签名如何稳定传时间戳涉及多个服务之间传时间戳时最好把单位写进字段名。比如毫秒字段叫 start_time_ms微秒字段叫 end_time_us避免只看注释然后猜错单位。我见过一个系统里A 服务传毫秒给 B 服务B 服务当成微秒除以 1000结果所有时间都比真实时间小了 1000 倍。从接口定义上消除歧义成本极低收益很大。如果接口还要穿日期字符串建议统一成 ISO 8601 格式并明确时区比如 2025-05-03T16:30:00.12308:00。这种格式带时区偏移解析方不用猜。当然如果只是为了排序直接用毫秒时间戳更简单毕竟 64 位整数排序永远比字符串快。5.3 本地时间还是 UTC提前定好规则无论在日志、数据库还是接口中时间规则必须在项目启动时定好。我个人偏好除展示层之外一律用 UTC 毫秒时间戳存储和传输。数据库字段存 BIGINT日志里既存 UTC 毫秒也定时转换成本地时间展示两者都保留方便日常排查。另外本地时间与 UTC 的转换不要自己硬编码“中国时区是 8”。虽然中国确实常年 8但服务器系统时区可能会被修改代码运行环境可能变成 UTC9。最好用系统提供的当前时区设置或者在配置文件中明确写出时区偏移值。否则换台服务器日志时间就会差一两个小时定位线上问题的时候特别痛苦。我在实际项目里还发现一个习惯性技巧每次写完转换函数顺手写一组“固定时间戳”的断言测试放到版本管理里长期跑。这样日后谁改动时间库、切换编译选项或者跨平台部署测试都能立刻发现问题。时间戳这种东西表面简单出错了却最难排查因为它经常晚几个小时才暴露而根因早就深埋在业务链路里。希望这一篇能帮你在下一次踩坑之前先把雷排掉。

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询