windows下TZ环境变量带来的时区问题。

TL;DR

因为我平时会比较经常地使用 msys2 中提供的工具,我又不想每次都手动开一个 msys2 的 Shell,就把 msys2 的二进制文件目录加到环境变量里面了。一部分其中的程序又会需要一些环境变量做本地化设置,所以索性就对着 printenv 的环境变量抄了一些。

这其中就包括了我遇到问题的罪魁祸首:TZ

在 POSIX 下,我们可以使用 printenv | grep TZ或者echo $TZ来查看TZ环境变量。可能是因为 POSIX 要求能解析字符串,我才会看到Asia/Shanghai或者Asia/Hong Kong之类的东西,但是 Windows 不支持这种形式,不能解析的东西他就会一股脑的当成 UTC,因此可能会造成你需要的时区偏移消失。

Windows 接受的 TZ 写法大致如下:
TZ=std offset[dst[offset][,start[/time],end[/time]]]

  • std:表示时区缩写。经测试,此处至少需要三个字母,内容不重要。
  • offset:表示当地时间和UTC时间的偏移。如果当地时区在本初子午线以西,偏移量为正;如果当地时区在东边,偏移量为负。即平时所见的+08:00要在此处写为-08:00
  • 因我所在地区不涉及夏令时,故无法验证夏令时,故略过dst部分。

即在我重设TZBJT-8或清空TZ后恢复正常。


发现问题

  1. 我在2024-2-11 14:44:21使用搜狗输入法输入sj想快速输入时间时,输入法自动补全的时间为2024-2-11 06:44:21
  2. 我使用 Telegram 收发消息时,显示的时间不正确。
  3. 我使用 QQ 9.7.22(区分于 QQNT)收发消息时,其显示的时间依然不正确。
  4. 使用 Python time.ctime函数的返回值也不正确。

以上四种问题的错误均正好和当前的北京时间相相比迟了 8 小时,即 UTC 时间。

寻找思路

最开始没有发现time.ctime()出现的问题。依据前三个问题,我首先会想到早八个小时是什么时间,即 UTC 时间。也就是说问题更可能出现在错误的本地化上。而不是说这个程序本地化正常拿到的时间错误。于是我进行了以下尝试:

  1. 使用 Windows sandbox 安装搜狗输入法尝试打出时间,结果为时间正常。

    > 并结合其他应用程序获取到的正确时间,问题应当出现在 Windows 系统 API 返回的结果不一致上。

  2. 我尝试手动设置为其他时区再复位或者手动设置为其他市区后再打开自动设置时区,问题依然存在。我开始思考是否是使用了过时的 API,是否因为这些 API 引用了在我电脑上未设置的时区设置。

    > 我的电脑是 Windows Insider Preview Canary Channel 的版本(24H2 26052.1000)。系统安装之初为英语(美国),后续有添加了中文(中国)和日语的语言包、并且区域设置为香港特别行政区。

  3. 我开始对比正常设备和异常设备的注册表,具体有以下两个键:

    1. HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\TimeZoneInformation
    2. HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Time Zones\

    > 经对比,并没有发现区别。

  4. 我开始尝试从原因入手,而不是从结果入手。于是查询到了如下 Windows 系统 API

    • kernel32.GetSystemTime
    • kernel32.GetLocalTime
    • kernel32.GetTimeZoneInformation
    • kernel32.GetDynamicTimeZoneInformation

    这些函数均位于 kernel32.dll 中,因此一种简单的调用方式是使用 Python 的标准库 ctypes。而这几个函数均采用自行填充数据并将传入指针指向的变量修改为填充数据的地址,返回错误码的方法与外层交互。因此需要使用ctypes.POINTER函数和ctypes.cast函数。
    该四个函数的原型如下:

    void GetSystemTime(SYSTEMTIME *lpSystemTime);
    void GetLocalTime(SYSTEMTIME *lpSystemTime);
    DWORD GetTimeZoneInformation(LPTIME_ZONE_INFORMATION lpTimeZoneInformation);
    DWORD GetDynamicTimeZoneInformation(PDYNAMIC_TIME_ZONE_INFORMATION pTimeZoneInformation);

    其中引用了如下数据类型:

    typedef struct _SYSTEMTIME {
    WORD wYear;
    WORD wMonth;
    WORD wDayOfWeek;
    WORD wDay;
    WORD wHour;
    WORD wMinute;
    WORD wSecond;
    WORD wMilliseconds;
    } SYSTEMTIME;
    typedef struct _TIME_ZONE_INFORMATION {
    LONG Bias;
    WCHAR StandardName[32];
    SYSTEMTIME StandardDate;
    LONG StandardBias;
    WCHAR DaylightName[32];
    SYSTEMTIME DaylightDate;
    LONG DaylightBias;
    } TIME_ZONE_INFORMATION;
    typedef struct _DYNAMIC_TIME_ZONE_INFORMATION {
    LONG Bias;
    WCHAR StandardName[32];
    SYSTEMTIME StandardDate;
    LONG StandardBias;
    WCHAR DaylightName[32];
    SYSTEMTIME DaylightDate;
    LONG DaylightBias;
    WCHAR TimeZoneKeyName[128];
    BOOLEAN DynamicDaylightTimeDisabled;
    } DYNAMIC_TIME_ZONE_INFORMATION;

    将其转为使用 ctypes 库描述的代码如下:

    import ctypes
    class SYSTEMTIME(ctypes.Structure):
    _fields_ = [
        ('wYear', ctypes.c_ushort),
        ('wMonth', ctypes.c_ushort),
        ('wDayOfWeek', ctypes.c_ushort),
        ('wDay', ctypes.c_ushort),
        ('wHour', ctypes.c_ushort),
        ('wMinute', ctypes.c_ushort),
        ('wSecond', ctypes.c_ushort),
        ('wMilliseconds', ctypes.c_ushort)
    ]
    class TIME_ZONE_INFORMATION(ctypes.Structure):
    _fields_ = [
        ('Bias', ctypes.c_long),
        ('StandardName', ctypes.c_wchar * 32),
        ('StandardDate', SYSTEMTIME),
        ('StandardBias', ctypes.c_long),
        ('DaylightName', ctypes.c_wchar * 32),
        ('DaylightDate', SYSTEMTIME),
        ('DaylightBias', ctypes.c_long)
    ]
    class DYNAMIC_TIME_ZONE_INFORMATION(ctypes.Structure):
    _fields_ = [
        ('Bias', ctypes.c_long),
        ('StandardName', ctypes.c_wchar * 32),
        ('StandardDate', SYSTEMTIME),
        ('StandardBias', ctypes.c_long),
        ('DaylightName', ctypes.c_wchar * 32),
        ('DaylightDate', SYSTEMTIME),
        ('DaylightBias', ctypes.c_long),
        ('TimeZoneKeyName', ctypes.c_wchar * 128),
        ('DynamicDaylightTimeDisabled', ctypes.c_bool)
    ]

    调用这四个函数的方法大致如下,由于仅通过小时和偏移即可确定时区,故不列出获取其他信息的代码;由于这个 md 编辑器意外的不能嵌套标签和 markdown 语法,不便使用过长代码,故不列出全部四个函数的调用。

    addr = (ctypes.c_int * 2)()
    result = ctypes.windll.kernel32.GetTimeZoneInformation(timezone_info)
    tz_info = tz_info_ptr.contents
    print(tz_info.Bias)

    但是此时通过kernel32.GetTimeZoneInformation 获取到的tz_info.Bias为 -480 即正常的北京时间,我不由得开始疑惑是否是我找错了方向。

  5. 我开始怀疑我是否走错了方向,但是仅凭借我的逆向技术不可能在这三个如此庞大且复杂的程序中获取我需要的重要信息,于是试图寻找其他用于获取时间的 API。

  6. 这其中免不了在 Google、GitHub 中大海捞针的搜索,考虑到这些平台的用户或许不会对 QQ 和搜狗输入法提问,我将主要目标放在了 Telegram 上。很快就获得了相关的信息。telegramdesktop/tdesktop#3904
    于是我开始查询 Telegram Windows 的客户端代码……遗憾的是并没有收获。Telegram 使用 Qt 作为界面。其获取时间似乎也是通过 Qt 来进行。
    我似乎找到了可能的代码,并将其翻译为使用 PyQt6 的 Python 代码,但我发现我翻错代码了,关于时区的本地化并不在 UI 层面进行。

    Utf8String FormatDateTime(
        TimeId date,
        bool hasTimeZone,
        QChar dateSeparator,
        QChar timeSeparator,
        QChar separator);
  7. 我偶然的尝试 Python 标准库 time 的 ctime 函数,并打算在此问题不能解决的时候像 Microsoft 社区提问。
    幸运的是,我成功复现了问题。他返回的时间也是 UTC 时间而不是 CST 时间。

  8. Python 实现的代码就好翻多了,我直接打开 CPython 的 Repo,搜索repo:python/cpython ctime language:C,逐层深入,很快找到了这样一段代码,以下代码通过编译器与处理指令手动精简代码

    int
    _PyTime_localtime(time_t t, struct tm *tm)
    {
    int error;
    
    error = localtime_s(tm, &t);
    if (error != 0) {
        errno = error;
        PyErr_SetFromErrno(PyExc_OSError);
        return -1;
    }
    return 0;
    }

    可以注意到,其使用 localtime_s 获取时间。

  9. 据此,我们可以编写如下验证代码:

    #include 
    #include 
    int main(void) {
    struct tm t;
    time_t now;
    time(&now);
    localtime_s(&t, &now);
    printf("时:%d\n", t.tm_hour);
    return 0;
    }

    再通过cl localtime_s.c编译得到 localtime_s.exe 之后运行就可以获得和 Python time.ctime() 相同的结果。注意到,该结果依然为 UTC 时间。
    通过 x64dbg 调试该程序,观察符号表,查找可能有关的函数,发现其导入了如下符号:

    • kernel32.GetSystemTimeAsFileTime
    • kernel32.GetTimeZoneInformation
      于是继续查阅微软的文档,得知 localtime_s 会优先从环境变量中获取时区信息。遂检查该环境变量,为Asia/Shanghai
  10. 关于TZ环境变量的设置本文见 TL;DR 部分。

水落石出


另请参照

localtime_s, _localtime32_s, _localtime64_s
GetSystemTime 函数 (sysinfoapi.h)
GetTimeZoneInformation 函数 (timezoneapi.h)
Telegram Desktop FormatDateTime函数
CPython _PyLong_AsTime_t 函数
CPython _PyTime_localtime 函数
时区缩写


转载请保留以下信息:

Author: char46 (haosiru@h-sr.cn)
License: CC BY-NC 4.0 International
First published on: 乌鸦小窝

作者: char46 (haosiru@h-sr.cn)
协议: CC BY-NC 4.0 国际
首发于: 乌鸦小窝