banner
NEWS LETTER

在精简 Linux RootFS 中动态设置时区

Scroll down

精简 Linux 系统里的时区问题经常被低估。桌面发行版通常有 systemd、timedatectl、完整 tzdata 和图形化配置,但嵌入式 rootfs 里可能只有 BusyBox、少量动态库和几个启动脚本。更常见的情况是 /etc/localtime 位于只读 rootfs 中,运行时不能直接替换;设备还需要通过 MQTT 接收其他设备下发的时区修改协议,并且重启后不能丢失配置。

一、先区分时间和时区

系统时间和时区是两件事:

  • 系统时间:内核维护的当前时间,通常用 Unix timestamp 表示。
  • 硬件时间:RTC 芯片里的时间,掉电后仍能保存。
  • 时区:把 timestamp 显示成本地时间时使用的规则。

举例来说,同一个 timestamp 在中国显示为北京时间,在日本显示为东京时间,在美国显示为太平洋时间。切换时区并不应该改变真实 timestamp,只应该改变显示和日志格式。

在设备系统里建议遵循一个原则:

1
系统内部尽量使用 UTC,界面展示和日志输出再按时区转换。

如果把 RTC 设置成本地时间,跨时区部署、夏令时、NTP 校时都会变得更难排查。

二、常见 rootfs 里的时区能力

一个完整 Linux 发行版通常会有:

1
2
3
4
/usr/share/zoneinfo/Asia/Shanghai
/usr/share/zoneinfo/UTC
/etc/localtime
/etc/timezone

但精简 rootfs 里不一定都有。常见情况有三种:

  1. 有完整 zoneinfo 数据库。
  2. 只有少量常用时区文件。
  3. 完全没有 zoneinfo,只能依赖 TZ 环境变量。

可以先在目标板上检查:

1
2
3
ls -l /etc/localtime
ls /usr/share/zoneinfo
ls /usr/share/zoneinfo/Asia/Shanghai

再看 C 库类型:

1
ldd --version

glibc、musl、uClibc 对时区文件和 TZ 的支持细节略有差异,但主流路径基本都围绕 /etc/localtimeTZ 展开。

三、只读 /etc/localtime 下的设计

如果 /etc/localtime 在只读 rootfs 中,运行时直接执行下面命令会失败:

1
ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime

这种设备更适合把“时区配置”和“系统默认文件”分开处理:

1
2
3
4
5
6
7
只读 rootfs:
/etc/localtime
/usr/share/zoneinfo/...

可写分区:
/data/config/timezone
/data/runtime/localtime

推荐流程:

  1. MQTT 收到时区协议。
  2. 校验时区名称是否合法。
  3. 把时区写入可写分区,例如 /data/config/timezone
  4. 如果支持 bind mount,就把目标 zoneinfo 绑定到 /etc/localtime
  5. 如果不能 bind mount,就更新服务环境变量 TZ,并通知或重启相关进程。
  6. 启动脚本开机读取 /data/config/timezone,恢复同样的配置。

这里不要把持久化文件放在 /etc/timezone,因为 /etc 已经是只读的。应该选择设备上明确可写、不会被升级清空的分区,例如 /data/mnt/data 或厂商定义的配置分区。

四、优先方案:启动和运行时 bind mount /etc/localtime

如果系统有 mount 命令,并且进程有 CAP_SYS_ADMIN 权限,可以用 bind mount 覆盖只读 rootfs 里的 /etc/localtime

这不会修改只读 rootfs 的文件内容,只是在当前运行系统的挂载表中把另一个文件挂到 /etc/localtime 这个路径上。

例如:

1
2
mount --bind /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
date

切换时区时可以先解除旧挂载,再绑定新时区:

1
2
umount /etc/localtime 2>/dev/null || true
mount --bind /usr/share/zoneinfo/UTC /etc/localtime

这种方式的优点是:

  • 不需要改只读 rootfs。
  • 大部分读取 /etc/localtime 的程序能继续使用标准路径。
  • 重启后不会污染镜像,启动脚本重新挂载即可。

限制也很明确:

  • 需要 root 权限或对应 capability。
  • /etc/localtime 这个挂载目标必须存在。
  • 已经运行很久的进程可能缓存了时区,需要调用 tzset()、重新加载配置或重启服务。
  • 挂载状态不会自动持久化,必须配合可写分区里的配置文件在开机时恢复。

五、兜底方案:使用 TZ 环境变量

如果系统不允许 bind mount,或者 /usr/share/zoneinfo 被裁剪得很厉害,可以使用 TZ 环境变量。

临时设置:

1
2
export TZ='CST-8'
date

POSIX 格式里符号容易让人误解。CST-8 表示本地时间比 UTC 快 8 小时,也就是 UTC+8。

也可以使用 zoneinfo 路径形式:

1
export TZ='Asia/Shanghai'

但这种写法通常仍依赖 /usr/share/zoneinfo 中存在对应文件。

TZ 的优点是轻量,不要求改 /etc/localtime。缺点是它是进程环境变量,只影响当前 shell 和由它启动的子进程。已经启动的服务不会自动继承新的 TZ

因此在 MQTT 动态切换场景里,TZ 方案通常要配合服务管理策略:

  • 由统一 supervisor 启动业务进程。
  • supervisor 读取 /data/config/timezone 后设置 TZ
  • MQTT 修改时区后,通知业务进程重新读取配置。
  • 不支持热更新的进程由 supervisor 重启。

六、Buildroot 中加入时区数据

如果使用 Buildroot 构建 rootfs,可以在配置里加入 tzdata。

常见配置路径:

1
2
3
4
Target packages
Libraries
Other
tzdata

不同 Buildroot 版本菜单位置可能略有差异,可以直接搜索:

1
/tzdata

如果只需要少量时区,可以在 rootfs overlay 中放入必要文件,例如:

1
2
3
4
5
6
7
board/my_board/rootfs_overlay/
usr/share/zoneinfo/
UTC
Asia/
Shanghai
etc/
default_timezone

这样可以避免把完整时区数据库打进镜像,节省空间。

如果设备第一次启动时 /data/config/timezone 还不存在,可以由启动脚本把 /etc/default_timezone 复制过去,作为出厂默认时区。

七、一个可落地的动态设置脚本

可以在设备上提供一个脚本,例如:

1
/usr/bin/set-timezone

脚本内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#!/bin/sh
set -eu

ZONE="${1:-}"
ZONEINFO_DIR="/usr/share/zoneinfo"
CONFIG_DIR="/data/config"
TIMEZONE_FILE="$CONFIG_DIR/timezone"
LOCALTIME_FILE="/etc/localtime"

if [ -z "$ZONE" ]; then
echo "Usage: set-timezone <ZoneName|POSIX_TZ>"
echo "Example: set-timezone Asia/Shanghai"
echo "Example: set-timezone CST-8"
exit 2
fi

case "$ZONE" in
/*|*..*|*[!/A-Za-z0-9_+:-]*)
echo "Invalid timezone: $ZONE" >&2
exit 2
;;
esac

mkdir -p "$CONFIG_DIR"

if [ -f "$ZONEINFO_DIR/$ZONE" ]; then
echo "$ZONE" > "$TIMEZONE_FILE"
sync "$TIMEZONE_FILE" 2>/dev/null || sync

if mountpoint -q "$LOCALTIME_FILE" 2>/dev/null; then
umount "$LOCALTIME_FILE" 2>/dev/null || true
fi

if mount --bind "$ZONEINFO_DIR/$ZONE" "$LOCALTIME_FILE" 2>/dev/null; then
echo "timezone=$ZONE"
echo "mode=bind-localtime"
exit 0
fi

export TZ="$ZONE"
echo "timezone=$ZONE"
echo "mode=env-fallback"
exit 0
fi

echo "$ZONE" > "$TIMEZONE_FILE"
sync "$TIMEZONE_FILE" 2>/dev/null || sync
export TZ="$ZONE"
echo "timezone=$ZONE"
echo "zoneinfo not found, saved as POSIX TZ"

这个脚本做了两件事:

  • 如果系统里有 zoneinfo 文件,优先尝试 bind mount 到 /etc/localtime
  • 如果 bind mount 不可用,就把当前进程的 TZ 设置为目标时区。
  • 如果没有对应 zoneinfo 文件,就把传入值作为 POSIX TZ 保存。
  • 所有配置都写入 /data/config/timezone,重启后可以恢复。

注意:脚本里 export TZ="$ZONE" 只能影响脚本自身和它启动的子进程,不能直接修改已经运行的其他进程环境。MQTT 服务收到协议后,还需要通知业务进程重新加载时区,或者重启依赖本地时区的服务。

八、MQTT 协议处理流程

设备通过 MQTT 收到其他设备下发的时区修改协议后,不建议直接信任 payload。至少要做三层处理:

  1. 校验 topic 是否来自可信来源。
  2. 校验 payload 中的时区字段是否在白名单内。
  3. 设置成功后发布 ack,失败时返回明确错误码。

协议可以设计得简单一些:

1
2
3
4
5
{
"cmd": "set_timezone",
"request_id": "20260626211515001",
"timezone": "Asia/Shanghai"
}

返回:

1
2
3
4
5
6
{
"cmd": "set_timezone_ack",
"request_id": "20260626211515001",
"result": "ok",
"timezone": "Asia/Shanghai"
}

MQTT 服务收到后调用:

1
/usr/bin/set-timezone Asia/Shanghai

如果设备只允许固定地区,建议维护白名单:

1
2
3
4
5
UTC
Asia/Shanghai
Asia/Tokyo
Europe/London
America/Los_Angeles

不要允许任意字符串直接进入 shell 命令。即使脚本里有校验,MQTT 层也应该先做一次白名单判断。

九、启动时恢复时区

BusyBox init 常见启动入口是:

1
/etc/init.d/rcS

或者某个自定义启动脚本。可以加入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
TIMEZONE_FILE="/data/config/timezone"
DEFAULT_TIMEZONE_FILE="/etc/default_timezone"
ZONEINFO_DIR="/usr/share/zoneinfo"
LOCALTIME_FILE="/etc/localtime"

if [ ! -f "$TIMEZONE_FILE" ] && [ -f "$DEFAULT_TIMEZONE_FILE" ]; then
mkdir -p "$(dirname "$TIMEZONE_FILE")"
cp "$DEFAULT_TIMEZONE_FILE" "$TIMEZONE_FILE"
fi

if [ -f "$TIMEZONE_FILE" ]; then
TZ_VALUE="$(cat "$TIMEZONE_FILE")"
case "$TZ_VALUE" in
/*|*..*|*[!/A-Za-z0-9_+:-]*)
TZ_VALUE="UTC"
;;
esac

if [ -f "$ZONEINFO_DIR/$TZ_VALUE" ]; then
if mountpoint -q "$LOCALTIME_FILE" 2>/dev/null; then
umount "$LOCALTIME_FILE" 2>/dev/null || true
fi

if ! mount --bind "$ZONEINFO_DIR/$TZ_VALUE" "$LOCALTIME_FILE" 2>/dev/null; then
export TZ="$TZ_VALUE"
fi
else
export TZ="$TZ_VALUE"
fi
fi

如果应用由脚本启动,要确保应用进程能继承 TZ

1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/sh

TIMEZONE_FILE="/data/config/timezone"

if [ -f "$TIMEZONE_FILE" ]; then
TZ_VALUE="$(cat "$TIMEZONE_FILE")"
if [ ! -f "/usr/share/zoneinfo/$TZ_VALUE" ]; then
export TZ="$TZ_VALUE"
fi
fi

exec /usr/bin/my_app

如果启动阶段 bind mount 成功,通常不需要给每个进程单独设置环境变量。如果 bind mount 失败,就必须保证业务进程从统一启动脚本继承 TZ

十、应用层需要注意 tzset

C/C++ 程序如果在运行中切换时区,建议在修改 TZ/etc/localtime 后调用:

1
tzset();

示例:

1
2
3
4
5
6
7
8
#include <time.h>
#include <stdlib.h>

void set_app_timezone(const char *tz)
{
setenv("TZ", tz, 1);
tzset();
}

对于 Java、Go、Python、Node.js 等运行时,也要确认它们是否缓存了本地时区。很多服务类程序在启动时读取一次配置,运行中切换系统时区不一定能立即反映到日志和定时任务里。

工程上更稳的做法是让 MQTT 服务在设置成功后发出内部通知,例如 Unix domain socket、进程信号、本地消息队列,或者写入一个状态文件后让业务进程轮询。业务进程收到通知后重新加载 /data/config/timezone,并调用运行时对应的时区刷新接口。

如果业务进程不支持热更新,就不要假装“系统时区已经全局生效”。更可靠的方式是由 supervisor 重启依赖本地时区的服务。

可以把运行时切换拆成三类:

  • 系统设置变更后通知应用。
  • 应用重新加载时区配置。
  • 必要时重启依赖本地时区的服务。

十一、和 NTP、RTC 的关系

动态设置时区时,不要顺手改系统时间。

推荐流程是:

  1. 开机从 RTC 读取时间。
  2. 网络可用后通过 NTP 校准系统时间。
  3. RTC 保存 UTC 时间。
  4. 时区只影响本地展示。

如果业务必须把 RTC 存成本地时间,也要在文档里明确,因为这会影响:

  • 夏令时切换。
  • 跨地区部署。
  • 日志对齐。
  • 云端时间戳分析。
  • OTA 后默认时区恢复。

设备日志建议同时保留 UTC 时间戳或毫秒 timestamp。这样即使本地时区配置错误,仍然可以和服务端日志对齐。

十二、排查清单

遇到“时区设置了但不生效”,可以按下面顺序查:

1
2
3
4
5
6
date
readlink -f /etc/localtime
cat /data/config/timezone
echo "$TZ"
mount | grep localtime
ls -l /usr/share/zoneinfo/Asia/Shanghai

再检查:

  • /data/config/timezone 是否已经写入新时区。
  • /etc/localtime 是否被 bind mount 覆盖。
  • 当前系统是否支持 mount --bind
  • 目标进程是否已经启动太久。
  • 启动脚本是否覆盖了 TZ
  • rootfs 是否真的包含目标时区文件。
  • 应用是否缓存了时区。
  • MQTT ack 是否在设置成功后才返回。
  • 容器或 chroot 环境是否有自己的 /etc/localtime

如果是日志时间不对,还要确认日志库使用的是本地时间还是 UTC。

总结

在精简 Linux rootfs 中动态设置时区,核心思路很简单:只读 /etc/localtime 不要硬改,运行时优先用 bind mount 覆盖标准路径,失败时用 TZ 环境变量兜底,并把时区写入 /data/config/timezone 这类可写分区。

MQTT 收到修改时区协议后,要完成校验、持久化、运行时生效、业务进程通知和 ack 返回。重启后再由启动脚本读取持久化配置恢复 bind mount 或 TZ。真正容易出问题的地方不在命令本身,而在 tzdata 是否裁剪、服务是否缓存时区、启动脚本是否正确恢复配置。把这些细节处理好,设备就能在不依赖完整发行版工具链的情况下稳定支持动态时区切换。

其他文章
目录导航 置顶
  1. 1. 一、先区分时间和时区
  2. 2. 二、常见 rootfs 里的时区能力
  3. 3. 三、只读 /etc/localtime 下的设计
  4. 4. 四、优先方案:启动和运行时 bind mount /etc/localtime
  5. 5. 五、兜底方案:使用 TZ 环境变量
  6. 6. 六、Buildroot 中加入时区数据
  7. 7. 七、一个可落地的动态设置脚本
  8. 8. 八、MQTT 协议处理流程
  9. 9. 九、启动时恢复时区
  10. 10. 十、应用层需要注意 tzset
  11. 11. 十一、和 NTP、RTC 的关系
  12. 12. 十二、排查清单
  13. 13. 总结
请输入关键词进行搜索