大家好,我是杂烩君。
我们一直在东八区写代码,处理时间时习惯性地把时区写死为东八区,即设备的小时数 = GMT小时数 + 8。设备上使用GPS授时来校准时间,GPS给出的是UTC时间。在国内一切正常,但设备一旦到了海外,时间就对不上了。
时区写死是嵌入式开发中非常常见的问题,尤其是当设备需要出口到不同国家和地区时。要正确处理这个问题,我们需要理解一套完整的知识链:UTC时间 -> 时区 -> 本地时间。
一、基础概念:GMT 与 UTC
GMT(Greenwich Mean Time,格林威治平均时间),基于天文观测,曾作为全球时间的基准。
UTC(Coordinated Universal Time,协调世界时),基于原子钟,精度远高于GMT,是当前国际通用的时间标准。
在工程实践中,UTC 和 GMT 的时刻基本相同(误差不超过0.9秒),可以认为等价。但严格来说,UTC 才是现代标准,我们在代码中应统一使用 UTC 作为基准。
1、获取UTC时间戳
C标准库提供了 time() 函数,返回自 1970-01-01 00:00:00 UTC(即 Unix 纪元)至今的秒数:
#include <time.h>
time_t time(time_t *tloc);
使用例子:
#include <stdio.h>
#include <time.h>
int main(int argc, char **argv)
{
time_t utc_time = time(NULL);
printf("UTC timestamp = %ld sn", utc_time);
return 0;
}
运行结果:
2、将UTC时间戳转换为日历时间
#include <time.h>
struct tm *gmtime_r(const time_t *timep, struct tm *result);
struct tm *localtime_r(const time_t *timep, struct tm *result);
struct tm 结构如下:
struct tm
{
int tm_sec; /* 秒 (0-60,60用于闰秒) */
int tm_min; /* 分 (0-59) */
int tm_hour; /* 时 (0-23) */
int tm_mday; /* 日 (1-31) */
int tm_mon; /* 月 (0-11,注意从0开始) */
int tm_year; /* 年 (自1900起的偏移) */
int tm_wday; /* 星期 (0-6,Sunday=0) */
int tm_yday; /* 一年中的第几天 (0-365) */
int tm_isdst; /* 夏令时标志 (>0生效, 0未生效, <0未知) */
};
使用例子:
#include <stdio.h>
#include <time.h>
int main(int argc, char **argv)
{
time_t utc_time = time(NULL);
printf("UTC timestamp = %ld sn", utc_time);
struct tm gmt_tm;
gmtime_r(&utc_time, &gmt_tm);
printf("UTC time = %.4d-%.2d-%.2d %.2d:%.2d:%.2dn",
gmt_tm.tm_year + 1900,
gmt_tm.tm_mon + 1,
gmt_tm.tm_mday,
gmt_tm.tm_hour,
gmt_tm.tm_min,
gmt_tm.tm_sec);
return0;
}
运行结果:
gmtime_r()可以将时间戳转换为struct tm结构的UTC日历时间。相比gmtime(),gmtime_r()是可重入版本,结果写入调用者提供的缓冲区,在RTOS等多任务嵌入式环境下使用更安全。同理,后面用到的localtime_r()也是localtime()的可重入版本。
二、时区原理
由于地球各地经度不同,太阳照射的时间也不同,因此全球划分为不同的时区。
1、时区划分规则
全球共分为 24个时区。以英国格林尼治天文台所在的经线(本初子午线)为中心,划分为零时区(中时区),向东为东1区至东12区,向西为西1区至西12区。
每个时区横跨经度15度(360° / 24 = 15°),对应1小时的时间差。每个时区以其中央经线的时间作为统一的区时。东12区和西12区各跨7.5度,以180度经线为界。
2、经度与时区对照
3、用代码计算本地时区
localtime_r() 函数可以将UTC时间戳转换为本地日历时间(依赖系统的时区设置):
通过对比 gmtime_r() 和 localtime_r() 的结果差异,可以计算出当前时区:
#include <stdio.h>
#include <time.h>
int main(int argc, char **argv)
{
time_t utc_time = time(NULL);
printf("UTC timestamp = %ld sn", utc_time);
struct tm gmt_result, local_result;
gmtime_r(&utc_time, &gmt_result);
localtime_r(&utc_time, &local_result);
printf("UTC time = %.4d-%.2d-%.2d %.2d:%.2d:%.2dn",
gmt_result.tm_year + 1900, gmt_result.tm_mon + 1,
gmt_result.tm_mday, gmt_result.tm_hour,
gmt_result.tm_min, gmt_result.tm_sec);
printf("Local time = %.4d-%.2d-%.2d %.2d:%.2d:%.2dn",
local_result.tm_year + 1900, local_result.tm_mon + 1,
local_result.tm_mday, local_result.tm_hour,
local_result.tm_min, local_result.tm_sec);
int tz_offset = local_result.tm_hour - gmt_result.tm_hour;
if (tz_offset < -12)
{
tz_offset += 24;
}
elseif (tz_offset > 12)
{
tz_offset -= 24;
}
printf("Local timezone = UTC%+dn", tz_offset);
return0;
}
在北京时间环境下运行:
把Ubuntu系统的时区切换到其它国家,再次运行:
三、嵌入式实战:根据GPS经度计算时区
对于带GPS模块的嵌入式设备,可以从GPS获取当前经度,再通过经度推算时区。
计算规则:用经度除以15度,若余数的绝对值小于等于7.5度,商即为时区数;若余数的绝对值大于7.5度,则时区数为商+1(东经)或商-1(西经)。
#include <stdio.h>
#include <math.h>
int calc_timezone_by_longitude(double longitude)
{
if (longitude < -180.0 || longitude > 180.0)
{
return0;
}
int quotient = (int)(longitude / 15);
double remainder = fabs(longitude - quotient * 15);
if (remainder <= 7.5)
{
return quotient;
}
else
{
return quotient + (longitude >= 0 ? 1 : -1);
}
}
int main(int argc, char **argv)
{
double test_cases[] = {116.4, -73.9, 0.0, 139.7, -122.4, 55.3};
constchar *cities[] = {"Beijing", "New York", "London", "Tokyo", "San Francisco", "Dubai"};
for (int i = 0; i < 6; i++)
{
int tz = calc_timezone_by_longitude(test_cases[i]);
printf("%-15s longitude=%.1f => UTC%+dn", cities[i], test_cases[i], tz);
}
return0;
}
局限性: 经度计算只能得到理论时区,实际时区受政治、地理因素影响,可能与理论值偏差1小时甚至更多。例如中国横跨5个时区,但全国统一使用UTC+8。西班牙地理上属于UTC+0,实际使用UTC+1。因此,这种方法适合作为一个粗略的兜底方案,而非精确解。
四、注意点
1、夏令时(DST)—— 最容易被忽略的大坑
很多国家在夏季会将时钟拨快1小时以充分利用日照,这就是夏令时(Daylight Saving Time)。
前面 struct tm 里的 tm_isdst 字段就是用来标识夏令时状态的:
> 0:夏令时生效中
= 0:非夏令时
< 0:未知
夏令时带来的问题:
-
- 同一个地方,一年中有一段时间的 UTC 偏移与其它时间不同(例如美国东部,冬季UTC-5,夏季UTC-4)每个国家的夏令时起止日期不同,有的国家甚至不用夏令时
仅靠经度完全无法判断夏令时
对于资源充足的嵌入式设备(如跑Linux的),可以利用系统的 IANA 时区数据库(tzdata)来处理。对于资源受限的MCU,则需要预置一张简化的时区/夏令时规则表,或通过云端/手机APP下发时区信息。
2、半时区 —— 不是所有时区都是整数
很多时候我们会假设时区偏移都是整数小时,但现实并非如此:
- 印度:UTC+5:30尼泊尔:UTC+5:45伊朗:UTC+3:30澳大利亚中部:UTC+9:30
因此,在代码中存储时区偏移时,不要用 int timezone_hour,而应该用分钟作为单位:
typedef struct {
int tz_offset_minutes; /* 时区偏移,单位分钟,如 UTC+5:30 = 330 */
} device_timezone_t;
五、嵌入式设备时间架构设计建议
结合以上知识点,推荐以下架构:
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ 时间源 │ ──> │ 设备内部存储 │ ──> │ 显示/业务层 │
│ (GPS/NTP/APP)│ │ (统一用UTC) │ │ (UTC+偏移) │
└─────────────┘ └──────────────┘ └─────────────┘
设备内部统一存储和处理UTC时间,仅在显示和业务逻辑的最外层做时区转换。
1. 时间源校准
- GPS授时:NMEA语句中的时间即UTC,直接使用NTP校准:如果设备联网,NTP获取的也是UTC手机APP校准:APP将UTC时间戳和当地时区偏移(分钟)一并下发给设备
2. 时区信息获取(优先级由高到低)
- 手机APP/云端下发(最准确,可包含夏令时信息)用户手动设置GPS经度推算(兜底方案,不含夏令时)
3. 本地时间转换
#include <stdio.h>
#include <time.h>
typedefstruct {
int tz_offset_minutes;
} device_timezone_t;
void utc_to_local(time_t utc_timestamp, device_timezone_t *tz,
struct tm *local_tm)
{
time_t local_timestamp = utc_timestamp + tz->tz_offset_minutes * 60;
gmtime_r(&local_timestamp, local_tm);
}
int main(void)
{
time_t utc_now = time(NULL);
device_timezone_t tz_dubai = { .tz_offset_minutes = 240 }; /* UTC+4 */
device_timezone_t tz_india = { .tz_offset_minutes = 330 }; /* UTC+5:30 */
struct tm local;
utc_to_local(utc_now, &tz_dubai, &local);
printf("Dubai: %.4d-%.2d-%.2d %.2d:%.2d:%.2dn",
local.tm_year + 1900, local.tm_mon + 1, local.tm_mday,
local.tm_hour, local.tm_min, local.tm_sec);
utc_to_local(utc_now, &tz_india, &local);
printf("India: %.4d-%.2d-%.2d %.2d:%.2d:%.2dn",
local.tm_year + 1900, local.tm_mon + 1, local.tm_mday,
local.tm_hour, local.tm_min, local.tm_sec);
return0;
}
4. 定时功能的处理
定时功能(如"每天早上7:00开机")需要特别注意:
-
-
- 存储定时任务时,记录的应该是
本地时间 + 时区偏移
- ,或直接转换为UTC时间存储时区变化时(设备移动到新地区、夏令时切换),需要重新计算定时任务的UTC触发时间推荐方案:每次手机APP连接设备时,自动同步UTC时间和时区信息,并重新校准定时任务
-
六、总结
| 要点 | 说明 |
|---|---|
| 内部统一用UTC | 所有存储、传输、比较都用UTC时间戳 |
| 时区用分钟存储 | 支持UTC+5:30等半时区 |
| 不要写死时区 | 通过APP/云端/GPS动态获取 |
| 注意夏令时 | 仅靠经度无法处理,需要外部信息源 |
| 定时任务注意时区变化 | 时区更新后需要重新计算触发时间 |
355