实现一个编译时的“编译时间戳”!

你有没有遇到过这样的需求:一个程序需要记录自己被编译的时间戳?当然,首先排除硬编码的方式,那蠢爆了。

“这题我会!我们有 __DATE____TIME__ 宏!”

说的道理。

确实,__DATE__ 给你一个 "Apr 1 2024"__TIME__ 给你一个 "03:34:29"。然后我们进行一些神必的字符串解析操作,就能得到一个时间戳了。但是,这一切真的值得吗?

不是,哥们,我们思考一下这到底意味着什么。这意味着在编译好的二进制文件里其实只有这两个 const char [],你甚至可以用 strings 直接把这两个字符串给 dump 出来。而解析是在运行时进行的。当然,it works,但你要在运行时花额外的时间CPU 周期来解析这两个字符串。作为追求极致性能整天被隔壁 Rust 用户骑脸输出的 C++ 批,这是不能容忍的。

OK,说到这你大概已经能猜到我们今天又要用到哪个神奇妙妙工具了。对,就是你,constexpr,所以这意味着你需要一个能支持 C++11 的编译器。(你不会没有吧?不会吧?)我们会实现一个编译时 struct tm 常量。作为 Bonus,我们还会有一个 time_t 常量。

不想看过程的可以直接跳到最后。

…不好意思,还是先暂时忘了 C++11 吧。

当然,不是 C++11 写不起,而是 C++14 更有性价比。C++11 中的 constexpr 函数太弱小了,函数体内只能有单行 return。C++14 以后就没有这个限制了。所以我们先按 C++14 的标准来写。C++11 的版本会在最后给出,单行 return 限制的存在意味着我们注定需要一些额外的操作,最后的实现也没有 C++14 版本易读,当然效果上是一样的。-std=c++14 开!

(C++14 前)

  • 函数体必须被弃置或预置,或只含有下列内容:
    • 空语句(仅分号)
    • static_assert 声明
    • 不定义类或枚举的 typedef 声明及别名声明
    • using 声明
    • using 指令
    • 当函数不是构造函数时,有恰好一条 return 语句

(C++14 起)(C++23 前)

  • 函数体必须不含
    • goto 语句
    • 带有除 casedefault 之外的标号的语句

      (C++20 前)

      • try
      • asm 声明
      • 不进行初始化的变量定义
    • 非字面类型的变量定义
    • 静态或线程存储期变量的定义
      (是 =default;=delete; 的函数体均不含任何上述内容。)

(抄自 constexpr 说明符 - cppreference.com

去码头整点轮子

思考一下我们目前应该怎么下手。切入点肯定是 __DATE____TIME__

__DATE__

This macro expands to a string constant that describes the date on which the preprocessor is being run. The string constant contains eleven characters and looks like "Feb 12 1996". If the day of the month is less than 10, it is padded with a space on the left.

If GCC cannot determine the current date, it will emit a warning message (once per compilation) and __DATE__ will expand to "??? ?? ????"

__TIME__

This macro expands to a string constant that describes the time at which the preprocessor is being run. The string constant contains eight characters and looks like "23:59:01".

If GCC cannot determine the current time, it will emit a warning message (once per compilation) and __TIME__ will expand to "??:??:??".

——That’s what GCC says.

再想一下如果在运行时解析我们要用到什么——也就是说,大概需要一个 constexpr 版本的 atoi。这应该是最简单的一部分。

CompileTime.cpp
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
constexpr int atoi_constexpr(const char *s) {
int result = 0;

while (*s == ' ') {
s++;
}

while ('0' <= *s && *s <= '9') {
result *= 10;
result += *s - '0';
s++;
}

return result;
}

非常好。atoi_constexpr 在手,我们现在可以解析的数据有:

CompileTime.cpp
32
33
34
35
36
constexpr int mday = atoi_constexpr(&__DATE__[4]);
constexpr int year = atoi_constexpr(&__DATE__[7]);
constexpr int hour = atoi_constexpr(__TIME__);
constexpr int min = atoi_constexpr(&__TIME__[3]);
constexpr int sec = atoi_constexpr(&__TIME__[6]);

可以,那现在我们来解析月份——稍微有点麻烦的部分,因为月份是以英文缩写的形式给出的。聪明的小朋友们可能会想实现一个 constexpr 版本的 strcmp 或者 strncmp——但其实不用。我们有最暴力的方法:

CompileTime.cpp
37
38
39
40
41
42
43
44
45
46
constexpr int mon = __DATE__[0] == 'J'   ? __DATE__[1] == 'a'   ? 1
: __DATE__[2] == 'n' ? 6
: 7
: __DATE__[0] == 'F' ? 2
: __DATE__[0] == 'M' ? __DATE__[2] == 'r' ? 3 : 5
: __DATE__[0] == 'A' ? __DATE__[1] == 'p' ? 4 : 8
: __DATE__[0] == 'S' ? 9
: __DATE__[0] == 'O' ? 10
: __DATE__[0] == 'N' ? 11
: 12;

…OK,必须承认这一堆 ?: 丑爆了。

在急着投产之前

在这个点,有必要回顾一下 tm 到底是什么。

std::tm

保有拆分到各组分的日历日期和时间的结构体。

成员对象

  • int tm_sec - 分后之秒 - [0, 60][注 1]
  • int tm_min - 时后之分 - [0, 59]
  • int tm_hour - 自午夜起之时 - [0, 23]
  • int tm_mday - 月内日期 - [1, 31]
  • int tm_mon - 自一月起之月 - [0, 11]
  • int tm_year - 自 1900 起之年
  • int tm_wday - 自星期日起之日 - [0, 6]
  • int tm_yday - 自一月 1 日起之日 - [0, 365]
  • int tm_isdst - 夏令时标志。值若夏令时有效则为正,若无效则为零,若无可用信息则为负

标准只强制前述成员按任一顺序存在。实现通常对此结构体添加额外的数据成员。

  1. 范围允许正闰秒。不允许同一分钟内有两个闰秒(C89 中错误地引入范围 [​0​, 61],而在 C99 中更正)

…实际上,现在所有必要的信息都已经有了。“必要的”的意思是:

std::mktime

转换本地日历时间为从纪元起的时间,作为 std::time_t 对象。忽略 time->tm_wdaytime->tm_yday。容许 time 中的值在其正常范围外。

time->tm_isdst 的负值会导致 mktime 尝试确定在指定时间夏时令是否有效。

若转换成功,则 time 对象会被修改。更新 time 的所有字段为符合其正确范围的值。用其他字段的可用信息重新计算 time->tm_wdaytime->tm_yday

mktime 和我们朴素的认知都告诉我们,要计算一个 UNIX 时间戳,只需要年月日时分秒。理论上来说这个时候我们直接摆烂也就可以了。…就像你的人生糊弄糊弄不就完了吗。

虽然我很想把 wdayyday 的实现留作课后习题,但有时候在编译期知道一年的第几天和星期几也是有用的。

我们继续。

constexpr everything

其实到这已经有点索然无味了。计算 yday 是很简单的。

CompileTime.cpp
54
55
56
57
constexpr int days[] = {0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334};

constexpr bool is_leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
constexpr int yday = (mday - 1) + days[mon - 1] + (mon > 2 && is_leap);

这里除了 days 数组有点抽象之外都很 trivial。其实 days 数组也很 trivial,单纯只是天数的部分和。那 wday 咋办?

这要用到我们小学二年级就学过的蔡勒公式

w=(y+y4+c42c+26(m+1)10+d1)mod7\displaystyle w = \left(y + \left\lfloor \frac y4 \right\rfloor + \left\lfloor \frac c4 \right\rfloor - 2c + \left\lfloor \frac{26(m+1)}{10} \right\rfloor + d - 1\right) \bmod 7

其中 cc 是年份前两位,yy 是年份后两位,mm 是月份,但一月和二月要看作上一年的十三月和十四月。同时我们还需要考虑到可能出现的负数结果。ww 就是星期。上面给出的公式是经过调整的蔡勒公式,使得星期日是 0——与 tm_wday 的定义一致。

没什么好说的,看公式说话就完事了。记得注意可能出现的负数结果。

CompileTime.cpp
48
49
50
51
52
constexpr int m = mon < 3 ? mon + 12 : mon;
constexpr int y = (year - (mon < 3)) % 100;
constexpr int c = (year - (mon < 3)) / 100;

constexpr int wday = ((y + y / 4 + c / 4 - 2 * c + 26 * (m + 1) / 10 + mday - 1) % 7 + 7) % 7;

至此,已成艺术。

CompileTime.cpp
59
60
61
62
63
64
65
66
67
68
69
return tm {
.tm_sec = sec,
.tm_min = min,
.tm_hour = hour,
.tm_mday = mday,
.tm_mon = mon - 1,
.tm_year = year - 1900,
.tm_wday = wday,
.tm_yday = yday,
.tm_isdst = -1,
};

在这里 DST 是唯一的我们没有任何手段能在编译时确定的东西。甚至没有任何办法能让你在编译时自动确定你的时区。但无所谓,我们冲国人不用 DST。(不…不吗?见后面

现在,时间戳

现在就是更索然无味的 OI 向问题了。给定年月日时分秒,同时还给了 yday 降低难度,求 UNIX 时间戳。

在这里我们还需要考虑的一点是 Epoch。对于 UTC+8 的我们来说,UNIX 时间 0 是 1970 年 1 月 1 日 08:00:00。所以 1970 年 1 月 1 日 00:00:00 的时间戳是 -28800。

CompileTime.cpp
72
73
74
75
76
77
constexpr time_t get_timestamp(const tm &t, int epoch) {
uint32_t leap_days = (t.tm_year - 69) / 4 - (t.tm_year - 101) / 100 + (t.tm_year - 101) / 400;
uint32_t day = t.tm_yday + 365 * (t.tm_year - 70) + leap_days;

return epoch + 86400LL * day + 3600LL * t.tm_hour + 60 * t.tm_min + t.tm_sec;
}

思考一下 leap_days 那一行是怎么算的。因为已经有 yday 了,所以其实我们需要的是从 1970 到 year - 1 年的闰年数。

结束了。和无法获取 DST 信息一个道理,我们也无法在编译时确定 epoch。这是唯一需要写死的地方。

CompileTime.cpp
149
150
constexpr tm COMPILE_TIME = get_compile_time();
constexpr time_t COMPILE_TIMESTAMP = get_timestamp(COMPILE_TIME, -28800);

测试!

热知识:constexpr 并不是只能在编译时使用。所以来点高强度随机测试。

CompileTime.cpp
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
#include <random>
#include <chrono>
#include <iomanip>
#include <iostream>

int main() {
tm t {};

std::cerr << __cplusplus << std::endl;

std::mt19937 gen(std::random_device {}());
std::uniform_int_distribution<int> dist(0, 500);

for (int i = 0; i < 1000000; i++) {
t.tm_sec = dist(gen);
t.tm_min = dist(gen);
t.tm_hour = dist(gen);
t.tm_mday = dist(gen);
t.tm_mon = dist(gen);
t.tm_year = 70 + dist(gen);

time_t gold = timegm(&t);
time_t calc = get_timestamp(t, 0);
if (gold != calc) {
std::cerr << "Test failed: " << std::put_time(&t, "%F %T") << std::endl;
std::cerr << "Expected: " << gold << std::endl;
std::cerr << "Got: " << calc << std::endl;
std::cerr << "Diff: " << gold - calc << std::endl;
return 1;
}
}
}

记得开 -std=c++14

一些测试时可能会遇到的神必问题

注意到我们在上面用了非标准的 timegm。这是因为 mktime 会按本地时间来处理,而 timegm 是按 UTC 时间来处理。你可以把 timegm 换成 mktime,然后给 get_timestamp 的 epoch 换成 -28800。俺寻思能行,直到遇到这种东西:

1
2
3
4
5
6
$ ./a.out
201103
Test failed: 1986-05-29 07:02:06
Expected: 517701726
Got: 517705326
Diff: -3600

…实际上是因为我国在历史上曾有一段时间推行过夏令时。mktime 甚至能处理这种情况。所以如果没有 timegm 用的话,你也许需要正常用 mktime 并把 epoch 换成 0,然后在运行的时候:

1
$ TZ=UTC ./a.out

一些索然无味的分析

我们现在来关心一点别的问题。我们定义了两个辅助函数,get_compile_timeget_timestamp。时间复杂度这一块我们已经拿捏死了,但是空间复杂度呢?

当然,其实不该说“空间复杂度”——其实我只是想表达,这些辅助函数会被带进最终的二进制文件吗?我的意思是,现在的 CompileTime.cpp 正确的使用方法,应该是有另一个单独的 main.cpp,里面有大概这样的东西:

main.cpp
1
2
3
4
5
6
7
8
9
10
#include <ctime>
#include <cassert>

extern const tm COMPILE_TIME;
extern const time_t COMPILE_TIMESTAMP;

int main() {
tm t {COMPILE_TIME};
assert(COMPILE_TIMESTAMP == std::mktime(&t));
}

然后我们这样编译:

1
$ clang++ -O0 -std=c++14 -o main main.cpp CompileTime.cpp

注意那个 -O0,这是我们待会儿会用到的神奇妙妙参数。如果你读过动态链接、系统调用与 C 的 inline 这篇,会知道有个妙妙工具叫 nm

1
2
3
4
5
6
7
8
$ nm main
0000000100003f68 S _COMPILE_TIME
0000000100003fa0 S _COMPILE_TIMESTAMP
U ___assert_rtn
0000000100000000 T __mh_execute_header
0000000100003e88 T _main
U _memcpy
U _mktime

我们发现这里只有 COMPILE_TIMECOMPILE_TIMESTAMP,没有别的东西。这就是我们 constexpr 函数——运行时用不到的东西不会被带进来。-O0 说明即使不开启任何优化也是如此。

如果 nm 仍然说服不了你:

Ghidra

注意左侧的 _COMPILE_TIME_COMPILE_TIMESTAMP 是以 __const 数据形式存在的。再看看右侧反编译出来的代码。

这就是我们 constexpr 啊,你们有没有这样的 constexpr 啊,真是常常又量量啊。

最后的最大最恶危机

实际上这里还有最后的一点不足——但实际上是一个很大的问题——如果 tm 结构体的成员声明顺序不是按我们所写的顺序怎么办?毕竟,标准里(这里指 ISO/IEC 9899:2011)确实这么说了(你可以在 N1570 中找到这段话):

§ 7.27.1 Components of time


  1. The tm structure shall contain at least the following members, in any order. The semantics of the members and their normal ranges are expressed in the comments.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    int tm_sec;    // seconds after the minute - [0, 60]
    int tm_min; // minutes after the hour - [0, 59]
    int tm_hour; // hours since midnight - [0, 23]
    int tm_mday; // day of the month - [1, 31]
    int tm_mon; // months since January - [0, 11]
    int tm_year; // years since 1900
    int tm_wday; // days since Sunday - [0, 6]
    int tm_yday; // days since January 1 - [0, 365]
    int tm_isdst; // Daylight Saving Time flag

当然,ISO/IEC 9899 是 C 标准,而 C 中结构体成员初始化的顺序是无关紧要的。但 C++ 不一样——指派初始化器不允许乱序。

(好吧,其实这里还有另一个问题——指派初始化器直至 C++20 才被标准化。所以在那之前,严格上来说,我们其实都是在依赖编译器的扩展。)

思考一下这意味着什么。tm 是 C++ 从 C 继承来的,因此也没有特别对其成员顺序做规定。这意味着可能有的实现会更改其成员顺序。而一旦更改了,我们现在的实现必会报错。

CompileTime.cpp
59
60
61
62
63
64
65
66
67
68
69
return tm {
.tm_sec = sec,
.tm_min = min,
.tm_hour = hour,
.tm_mday = mday,
.tm_mon = mon - 1,
.tm_year = year - 1900,
.tm_wday = wday,
.tm_yday = yday,
.tm_isdst = -1,
};

很难绷,因为,实际上,这是一个完全无解的问题。是的,没有任何手段,直到 C++20 允许我们在一个 constexpr 函数中这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
tm t; // this fails in C++14 and C++17

t.tm_sec = sec;
t.tm_min = min;
t.tm_hour = hour;
t.tm_mday = mday;
t.tm_mon = mon - 1;
t.tm_year = year - 1900;
t.tm_wday = wday;
t.tm_yday = yday;
t.tm_isdst = -1;

return t;

所以如果你的编译器报错 designator order for field 'tm_sec' does not match declaration order in 'tm',唯一的办法是去观察你的平台上 tm 的定义,然后手动调整这些成员的顺序。

实际上也不是那么无解

…前提是我们完全抛弃 C++11。对它来说确实是无解的,因为单行 return 限制。

(即使是用上一些别的肮脏手段,比如 GCC 的 Statements and Declarations in Expressions 也摆脱不了这个限制。实际上,C++11 模式下的 g++-13 甚至会对 constexpr 函数内的 statement expression 的一些写法产生 internal compiler error。clang++ 就是正常的。赞美 clang!)

对 C++14 和 C++17,它们不支持:

不进行初始化的变量定义

这是什么意思呢?这意味着只要我们给 tm 一个初始值就可以了。空初始化足矣。

1
2
tm t {};
t.tm_sec = sec; // OK

一些课后习题

  • 把它改造成一个头文件!(一般我们管这种东西叫 single-header library)
    • 更优雅一点,也许你需要思考一下怎么把那一堆辅助函数给隐藏起来…?
    • 但其实如果真的这样做了,也许会产生一些潜在的其他问题…?
      • Hint:思考一个有巨多文件的项目和/或一台巨慢无比的机器。
  • 试着自己把它改写成 C++11 版本吧!
    • 那个 days 数组可能比较难以下手,所以我们在这里给出 yday 的一种实现。
      CompileTime.cpp
      118
      119
      120
      121
      constexpr int __yday(int mday, int mon, int year) {
      return (mday - 1) + (const int[]) {0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334}[mon - 1]
      + (mon > 2 && (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)));
      }

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
/**
* @file CompileTime.cpp
* @brief Compile-time compile-time implementation (Pun intended)
* @author [@45gfg9](https://45gfg9.net)
*
* This file is a compile-time implementation of the date & time when the code was compiled,
* expressed as a full-featured `tm` structure and a UNIX timestamp in `time_t`.
*/

#include <cstdint>
#include <ctime>

#if __cplusplus >= 201402L

static constexpr int __a2i(const char *s) {
int result = 0;

while (*s == ' ') {
s++;
}

while ('0' <= *s && *s <= '9') {
result *= 10;
result += *s - '0';
s++;
}

return result;
}

static constexpr tm __get_compile_time() {
constexpr int mday = __a2i(&__DATE__[4]);
constexpr int year = __a2i(&__DATE__[7]);
constexpr int hour = __a2i(__TIME__);
constexpr int min = __a2i(&__TIME__[3]);
constexpr int sec = __a2i(&__TIME__[6]);
constexpr int mon = __DATE__[0] == 'J' ? __DATE__[1] == 'a' ? 1
: __DATE__[2] == 'n' ? 6
: 7
: __DATE__[0] == 'F' ? 2
: __DATE__[0] == 'M' ? __DATE__[2] == 'r' ? 3 : 5
: __DATE__[0] == 'A' ? __DATE__[1] == 'p' ? 4 : 8
: __DATE__[0] == 'S' ? 9
: __DATE__[0] == 'O' ? 10
: __DATE__[0] == 'N' ? 11
: 12;

constexpr int m = mon < 3 ? mon + 12 : mon;
constexpr int y = (year - (mon < 3)) % 100;
constexpr int c = (year - (mon < 3)) / 100;

constexpr int wday = ((y + y / 4 + c / 4 - 2 * c + 26 * (m + 1) / 10 + mday - 1) % 7 + 7) % 7;

constexpr int days[] = {0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334};

constexpr bool is_leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
constexpr int yday = (mday - 1) + days[mon - 1] + (mon > 2 && is_leap);

tm t {};
t.tm_sec = sec;
t.tm_min = min;
t.tm_hour = hour;
t.tm_mday = mday;
t.tm_mon = mon - 1;
t.tm_year = year - 1900;
t.tm_wday = wday;
t.tm_yday = yday;
t.tm_isdst = -1;
return t;
}

static constexpr time_t __get_timestamp(const tm &t, int epoch) {
uint32_t leap_days = (t.tm_year - 69) / 4 - (t.tm_year - 101) / 100 + (t.tm_year - 101) / 400;
uint32_t day = t.tm_yday + 365 * (t.tm_year - 70) + leap_days;

return epoch + 86400LL * day + 3600LL * t.tm_hour + 60 * t.tm_min + t.tm_sec;
}

#else

static constexpr const char *__space_sled(const char *s) {
return *s == ' ' ? __space_sled(s + 1) : s;
}

static constexpr int __a2i(const char *s, int c) {
return *s >= '0' && *s <= '9' ? __a2i(s + 1, c * 10 + *s - '0') : c;
}

static constexpr int __a2i(const char *s) {
return __a2i(__space_sled(s), 0);
}

static constexpr int __mon() {
return __DATE__[0] == 'J' ? __DATE__[1] == 'a' ? 1
: __DATE__[2] == 'n' ? 6
: 7
: __DATE__[0] == 'F' ? 2
: __DATE__[0] == 'M' ? __DATE__[2] == 'r' ? 3 : 5
: __DATE__[0] == 'A' ? __DATE__[1] == 'p' ? 4 : 8
: __DATE__[0] == 'S' ? 9
: __DATE__[0] == 'O' ? 10
: __DATE__[0] == 'N' ? 11
: 12;
}

static constexpr int __year() {
return __a2i(&__DATE__[7]);
}

static constexpr int __mday() {
return __a2i(&__DATE__[4]);
}

static constexpr int __wday(int y, int c, int m, int mday) {
return ((y + y / 4 + c / 4 - 2 * c + 26 * (m + 1) / 10 + mday - 1) % 7 + 7) % 7;
}

static constexpr int __yday(int mday, int mon, int year) {
return (mday - 1) + (const int[]) {0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334}[mon - 1]
+ (mon > 2 && (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)));
}

static constexpr tm __get_compile_time() {
return tm {
.tm_sec = __a2i(&__TIME__[6]),
.tm_min = __a2i(&__TIME__[3]),
.tm_hour = __a2i(__TIME__),
.tm_mday = __mday(),
.tm_mon = __mon() - 1,
.tm_year = __year() - 1900,
.tm_wday = __wday((__year() - (__mon() < 3)) % 100, (__year() - (__mon() < 3)) / 100,
__mon() < 3 ? __mon() + 12 : __mon(), __mday()),
.tm_yday = __yday(__mday(), __mon(), __year()),
.tm_isdst = -1,
};
}

static constexpr int __days_since_epoch(int yday, int year) {
return yday + 365 * year + ((year + 1) / 4 - (year - 31) / 100 + (year - 31) / 400);
}

static constexpr time_t __get_timestamp(const tm &t, int epoch) {
return epoch + 86400LL * __days_since_epoch(t.tm_yday, t.tm_year - 70) + 3600LL * t.tm_hour + 60 * t.tm_min
+ t.tm_sec;
}

#endif

extern constexpr tm COMPILE_TIME = __get_compile_time();
extern constexpr time_t COMPILE_TIMESTAMP = __get_timestamp(COMPILE_TIME, -28800);

实现一个编译时的“编译时间戳”!
https://heap.45gfg9.net/t/a6879c5cd48f/
作者
45gfg9
发布于
2024-04-01
许可协议