版本依据
Glibc-2.33, Kernel-5.10.117

对于不需要时间同步的情况来说,在程序运行前 env 中设置好 TZ 的相关参数即可,这里主要记录存在 NTP 服务情况下的处理方式

问题?

在多进程下,NTP 由另一个应用的时间服务进行托管调用,经过多次调用后发现,只有托管的应用的时间处于正常的时区,其他的时间虽然进行了同步但是时区未同步,后来查询源码后发现,setenv()puttenv() 两个函数在 glibc 中实际上调用的是 __add_to_environ() ,而这个函数是对应用空间中的全局变量 char **environ 进行操作,实际上并没有将时区相关的参数传递给内核:

flowchart TB
    f_putenv("putenv()")
    f_setenv("setenv()")
    f_add_to_environ("__add_to_environ()")
    environ("char** environ")
    f_putenv --> f_add_to_environ
    f_setenv --> f_add_to_environ
    f_add_to_environ --> environ

所以无论怎么修改,都不会影响到其他程序的时区

尝试解决

那应该如何让其他程序时区发生改变呢?后来翻了下 Ubuntu 的 timedate 设置时区 method_set_timezone() 的源码,发现大致的流程如下:

static int method_set_timezone(sd_bus_message *m, void *userdata, sd_bus_error *error) {
... // 修改自身配置

/* 1. Write new configuration file */
r = context_write_data_timezone(c);
....
/* 2. Make glibc notice the new timezone */
tzset();
/* 3. Tell the kernel our timezone */
r = clock_set_timezone(NULL);

... // 按需同步RTC, 触发事件通知并返回
}

第一步的 context_write_data_timezone() 是在 /usr/share/zoneinfo/UTC 下选择对应的时区文件创建对应的软链接到 /etc/localtime ,然后通过 tzset() 同步自身应用的时区,最后通过clock_set_timezone() 中调用 settimeofday() 来同步给内核时区:

int clock_set_timezone(int *ret_minutesdelta) {
...
/* If the RTC does not run in UTC but in local time, the very first call to settimeofday() will set
* the kernel's timezone and will warp the system clock, so that it runs in UTC instead of the local
* time we have read from the RTC. */
if (settimeofday(NULL, &tz) < 0)
return -errno;
...
}

然而移植这段代码后发现,其他进程并没有同步时区,然后我使用 gettimeofday() 拿到 timezone 中的 tz_minuteswest 进行比对发现,无论是自身设置过的应用还是其他应用均返回为 0,说明并没有设置成功,但设置却未返回异常。随后我看了下 glibc 中的实现,发现 gettimeofday() 的实际现实流程如下:

flowchart TB
    gettimeofday("gettimeofday()")
    __gettimeofday("__gettimeofday()")
    __gettimeofday64("__gettimeofday64()")
    zmem("memset(tz, 0, sizeof(struct timezone))")
    __clock_gettime64("__clock_gettime64()")
    sys_call("INLINE_SYSCALL_CALL(clock_gettime(64))")
    gettimeofday --> __gettimeofday --> __gettimeofday64 --> zmem & __clock_gettime64
    __clock_gettime64 --> sys_call

settimeofday() 的实际现实流程如下:

flowchart TB
    settimeofday("settimeofday()")
    __settimeofday("__settimeofday()")
    __settimeofday64("__settimeofday64()")
    sys_call_1("INLINE_SYSCALL_CALL(settimeofday(tz))")
    __clock_settime64("__clock_settime64()")
    sys_call_2("INLINE_SYSCALL_CALL(clock_settime(64))")

settimeofday --> __settimeofday --> __settimeofday64
__settimeofday64 --> judge{tz != 0\nAND\ntv != 0}
judge -->|true| sys_call_1
judge --->|false| inval[INVAL]
__settimeofday64 --> __clock_settime64 --> sys_call_2

由于 glibc 动态库由原厂编译并提供,为了进一步验证我的判断,我在使用 settimeofday() 之后通过库函数以及系统调用输出内核的时区设置:

void test_settimeofday()
{
struct timezone tz;
    tz = (struct timezone) {
        .tz_minuteswest = 480,
        .tz_dsttime = 0, /* DST_NONE */
    };

    if (settimeofday(NULL, &tz) < 0)
    {
        printf("exec settimeofday failed: %s\n", std::strerror(errno));
        return;
    }
}

void test_gettimeofday()
{
#ifdef __NR_gettimeofday
    printf("HAS __NR_gettimeofday!\n");
    struct timezone tz1 {};
    struct timeval tv1 {};
    if (syscall(__NR_gettimeofday, &tv1, &tz1) < 0)
    {
        printf("exec sys_gettimeofday failed: %s\n", std::strerror(errno));
        return;
    }
    printf("get tz_minuteswest by sys_gettimeofday: %d\n", tz1.tz_minuteswest);
#endif

    struct timezone tz2 {};
    struct timeval tv2 {};
    if (gettimeofday(&tv2, &tz2) != 0)
    {
        printf("exec gettimeofday failed: %s\n", std::strerror(errno));
        return;
    }
    printf("get tz_minuteswest by gettimeofday: %d\n", tz2.tz_minuteswest);
}

在运行后发现,发现确实能通过系统回调拿到之前设置的时区值

探索内核源码

虽然上面的个方式证明了 settimeofday() 成功修改了内核时区,但是多进程的时区依然未同步,在阅读内核中的 settimeofday() 源码后有所发现,源码如下:

int do_sys_settimeofday64(const struct timespec64 *tv, const struct timezone *tz)
{
  static int firsttime = 1;
  int error = 0;

...

  if (tz) {
    /* Verify we're within the +-15 hrs range */
    if (tz->tz_minuteswest > 15*60 || tz->tz_minuteswest < -15*60)
      return -EINVAL;

    sys_tz = *tz;
    ...
    if (firsttime) { // 实际上对时间的修改只执行了一次
      firsttime = 0;
      if (!tv) // tv存在时不允许修改时区
        timekeeping_warp_clock(); // 计算时区偏移, 对现有的时间进行增加/减少
    }
  }
 
  if (tv)
    return do_settimeofday64(tv); // 修改时间
  return 0;
}

进一步查看 timekeeping_warp_clock() 源码:

/*
 * Adjust the time obtained from the CMOS to be UTC time instead of
 * local time.
 *
 * This is ugly, but preferable to the alternatives.  Otherwise we
 * would either need to write a program to do it in /etc/rc (and risk
 * confusion if the program gets run more than once; it would also be
 * hard to make the program warp the clock precisely n hours)  or
 * compile in the timezone information into the kernel.  Bad, bad....
 *
 *            - TYT, 1992-01-01
 *
 * The best thing to do is to keep the CMOS clock in universal time (UTC)
 * as real UNIX machines always do it. This avoids all headaches about
 * daylight saving times and warping kernel clocks.
 */

void timekeeping_warp_clock(void)
{
  if (sys_tz.tz_minuteswest != 0) {
    struct timespec64 adjust;

    persistent_clock_is_local = 1;
    adjust.tv_sec = sys_tz.tz_minuteswest * 60;
    adjust.tv_nsec = 0;
    timekeeping_inject_offset(&adjust);
  }
}

其中注释里面已经给出了解决方案,使用这个方式是更倾向于将系统时间置为 UTC 时间,如果多次修改反而会带来精度损失,而且 timekeeping_inject_offset() 中有锁来保证执行的正确性,所以如果有多个应用频繁调用的话也会带来性能上的损失,因此 do_sys_settimeofday64() 中保证这个操作只执行一次也是如此
因此最好的解决方案是保持系统的时间为 UTC 时间,通过修改应用的 TZ 属性和 tzset() 来修改进程的时区设置

解决方案

有两种解决方案:

1. 完善 /etc/localtime 并替换 localtime_r()localtime()

查看下 Linux 下的 /etc/localtime 就不难发现其实是个软连接:

z@z:~$ ls -lh /etc/localtime
lrwxrwxrwx 1 root root 33 9月 20 2023 /etc/localtime -> /usr/share/zoneinfo/Asia/Shanghai

只需要将 /usr/share/zoneinfo/ 复制到开发板上并链接到 /etc/localtime 即可,为什么可以这样?这个其实是由于 tzset()代码所决定:

/* Interpret the TZ envariable.  */
static void
tzset_internal (int always)
{
...

/* Examine the TZ environment variable. */
tz = getenv ("TZ");

... // 对TZ解析验证

// 在未设置环境变量的情况下回去读取存放时区配置的默认目录
if (tz == NULL)
/* No user specification; use the site-wide default. */
tz = TZDEFAULT;

...

/* Try to read a data file. */
__tzfile_read (tz, 0, NULL);

... // 未读取到文件则以默认 UTC 时区

__tzset_parse_tz (tz);
}

TZDEFAULT 是一个宏定义,赋值在 glibc/time/Makefileglibc/timezone/Makefile ,定义在 glibc/Makeconfig,通常情况下一般定义在 /etc/localtime ,有自定义需求则需要查看下定义的路径在哪

localtime()localtime_r() 的区别

两者底层的调用均是 __tz_convert(),主要区别在于传递的 tp 不一样,localtime() 传递的是一个全局变量的 _tmbuf,而 localtime_r() 则是由调用者传入:

/* The C Standard says that localtime and gmtime return the same pointer.  */
struct tm _tmbuf;

/* Return the `struct tm' representation of *T in local time,
using *TP to store the result. */
struct tm *
__localtime64_r (const __time64_t *t, struct tm *tp)
{
return __tz_convert (*t, 1, tp);
}

...

/* Return the `struct tm' representation of *T in local time. */
struct tm *
__localtime64 (const __time64_t *t)
{
return __tz_convert (*t, 1, &_tmbuf);
}

__tz_convert() 正是通过判断 tp 来判断是否读取环境和时区文件并进行进一步解析:

/* Return the `struct tm' representation of TIMER in the local timezone.
Use local time if USE_LOCALTIME is nonzero, UTC otherwise. */
struct tm *
__tz_convert (__time64_t timer, int use_localtime, struct tm *tp)
{
...

/* Update internal database according to current TZ setting.
POSIX.1 8.3.7.2 says that localtime_r is not required to set tzname.
This is a good idea since this allows at least a bit more parallelism. */
tzset_internal (tp == &_tmbuf && use_localtime);
// __use_tzfile 由 tzset_internal 中读取文件的操作进行调整
if (__use_tzfile)
__tzfile_compute (timer, use_localtime, &leap_correction,
&leap_extra_secs, tp);
else
{
...
}
...
}

localtime_r() 就需要调用者手动调用 tzset() 来进一步控制时区:

// 设置TZ
if (setenv("TZ", "GMT-8", 1) != 0)
{
    printf("failed to set TZ environment variable.\n");
}
// 更新库中的时区参数
tzset();

有什么缺点?

关键在于 localtime() ,它在未读取到 TZ 变量时会去读取配置文件进行调整时区,以至于在每次调用的时候均会进行这样的操作,导致了不必要的开销,这也是为什么 spdlog 调用的是 localtime_r() 而不是它。那么由此可以引入第二套方案

2. 采用 IPC 方式动态设置应用时区的环境变量

其实很简单,在拥有 IPC 中间件的基础上只需要传递对应的数据再通过上面的 tzset() 的流程就可以了