朋友一台老 Windows 台式机,主板 CMOS 电池老化,每次关机断电后 BIOS 时间都被重置成出厂默认。开机一看——2015 年。
每次出问题,朋友都会远程敲我:"帮我调一下时间。"一开始我还耐心调一下,后来每周都来一两次。
我开始烦了。
我一想,索性写个工具丢给他——点一下就完事。两个月后,这个不到 200KB 的小工具还在用,朋友没再为时间找过我。
一、为什么要自己造这个轮子
朋友那台机器不能装企业版的时间客户端(公司策略),也不能跑 w32time 服务(系统组件关掉了)。他能用的最简方案就是去系统"日期和时间"里点"立即同步"——但那个按钮有时候点了没反应。
于是我想:自己写一个就好。
写工具这件事,我一开始其实是有抗拒的——一个 100 行的 NTP 同步小工具,写完不会有成就感。但我更烦的是每周被打断一次,所以这笔账怎么算都划算。
工具的需求特别朴素:
- 打开 → 一个按钮 → 点了 → 时间就对了
- 别跳一堆窗口、别装 .NET、别写注册表(写也行,但别没事找事)
定下来之后,我没有选的有:
这种"克制"不是克制——是真的不需要。朋友那一台机器、一位用户,没有并发、没有扩展需求,单进程 + 单文件 + 单权限,把事情做完就是。
二、第一版:一个窗口 + 一个按钮
技术栈直接定 C++ + 原生 Win32,没有用任何框架。代码总量大概 600 行,release 编译出来 80KB,加静态 CRT 也不到 200KB。
主窗口就是个 WNDCLASSEX + CreateWindowEx,里面塞两个控件:一个按钮 IDC_BUTTON_SYNC、一个状态文本 IDC_STATIC_STATUS。
LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
switch (msg) {
case WM_COMMAND:
if (LOWORD(wParam) == IDC_BUTTON_SYNC) {
SyncTimeFromNtp(hWnd); // 关键函数,下面详述
}
break;
case WM_DESTROY:
PostQuitMessage(0);
break;
}
return DefWindowProc(hWnd, msg, wParam, lParam);
}
第一次写完跑起来的时候,没那种"作品完成"的成就感——一个按钮、一个回调、一个同步函数,跟玩具没什么两样。我发给那个朋友让他试,他点了一下,回我三个字:"终于对了。"
SyncTimeFromNtp 做三件事:
- 给一个 NTP 服务器发 UDP 包;
- 算时间偏移;
- 调 Win32 API 把系统时间改对。
下面分别讲这三个环节里踩到的坑。
三、坑 1:你以为你能改系统时间,其实你不能
新手最容易栽在这里——我也是。明明写的是管理员启动,SetSystemTime 直接返回 ERROR_ACCESS_DENIED。
一开始我以为是 Windows 的 bug,连续试了三次都以 ERROR_ACCESS_DENIED 收尾。翻 MSDN 翻到后半夜,才意识到是 token privileges 的事。
原因:SetSystemTime 虽是 kernel32.dll 导出,但权限检查在 LSA 里——它要求调用进程拥有 SeSystemTimePrivilege。这个权限普通管理员进程默认没有开启,需要主动申请。
下面这段是修好之后的样子:
bool EnableSystemTimePrivilege()
{
HANDLE hToken;
if (!OpenProcessToken(GetCurrentProcess(),
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
&hToken))
return false;
TOKEN_PRIVILEGES tp{};
tp.PrivilegeCount = 1;
tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
if (!LookupPrivilegeValueW(nullptr, L"SeSystemTimePrivilege",
&tp.Privileges[0].Luid)) {
CloseHandle(hToken);
return false;
}
BOOL ok = AdjustTokenPrivileges(hToken, FALSE, &tp,
sizeof(tp), nullptr, nullptr);
DWORD err = GetLastError();
CloseHandle(hToken);
// AdjustTokenPrivileges 成功但没生效时 GetLastError 会返回 ERROR_NOT_ALL_ASSIGNED
return ok && err != ERROR_NOT_ALL_ASSIGNED;
}
如果你打开的是普通 exe(没 manifest),连 OpenProcessToken 都要先 OpenSCManager 自己的服务;最干脆的办法是给 exe 加一个 requestedExecutionLevel level="requireAdministrator" 的 manifest,让系统弹 UAC 提权后再启动。
两种方案我都留了一份代码,最终用的是方案 A(manifest 直接要求管理员)——朋友不会忘了右键"以管理员身份运行"。
四、坑 2:自己写 NTP 客户端比想象的简单
很多人以为 NTP 客户端是高深协议,其实最朴素那一段——发 UDP 包、收 48 字节、解析——真不难。
写 NTP 客户端那会儿我其实有点慌——毕竟是网络代码,写错了就是把别人机器的时间调乱。一行错位,那台机器就不再"准时",可能再开机什么都错乱了。
最简的 NTP v4 客户端长这样:
bool QueryNtp(const wchar_t* server, DWORD& outOffsetMs)
{
SOCKET s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (s == INVALID_SOCKET) return false;
sockaddr_in sa{};
sa.sin_family = AF_INET;
sa.sin_port = htons(123);
addrinfoW hints{};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_DGRAM;
addrinfoW* res = nullptr;
if (GetAddrInfoW(server, L"123", &hints, &res) != 0 || !res) {
closesocket(s);
return false;
}
sa = *(sockaddr_in*)res->ai_addr;
FreeAddrInfoW(res);
// NTP 包 48 字节。0x1B = LI=0, VN=3, Mode=3 客户端
NtpPacket pkt{};
pkt.li_vn_mode = 0x1B;
sendto(s, (char*)&pkt, sizeof(pkt), 0, (sockaddr*)&sa, sizeof(sa));
NtpPacket reply{};
sockaddr_in from{};
int fromLen = sizeof(from);
int recvd = recvfrom(s, (char*)&reply, sizeof(reply), 0,
(sockaddr*)&from, &fromLen);
closesocket(s);
if (recvd != sizeof(reply)) return false;
// reply.txTm_s 是自 1900-01-01 起的秒数,转成 UNIX 秒要减 2208988800
DWORD unixSecs = ntohl(reply.txTm_s) - 2208988800u;
outOffsetMs = ComputeOffsetAndDelay(unixSecs);
return true;
}
两件容易踩的小事:
0x1B这个魔数别拼错:低 2 位是 mode(3 = 客户端),中间 3 位是 version(3 = NTP v3 / v4 通用),高 3 位是 leap indicator(0)。第一次手抖拼成0x2B,调出来时间永远是 2036 年——我盯着那个数字十秒钟才反应过来。- Windows 上 UDP 防火墙经常拦 123 端口。发出去收不到时,先
netsh advfirewall firewall show rule name=all | findstr 123看一眼。朋友那台机器出站是开的,所以没加 fallback。
至于多层 NTP、Stratum 处理、抖动过滤……第一版全没做。对一个"按一下同步一下"的工具够用了。如果你打算做金融级的同步,再去补 RFC 5905。
五、坑 3:SetSystemTime 不动时区
更隐蔽的坑——我第一版做完根本没意识到,是朋友实测发现后告诉我的。
他的反馈是:"点了同步,下面那行时间对了,右下角的'8:32'好像还是 8:32,但是……日期是不是没变?"
SetSystemTime 改的是 UTC,UI 上显示的时间还要经过时区转换。如果用户本地时区是对的,那么 UTC 调完之后 OS 会自动重画时钟;但如果时区被设错(比如漂成 UTC+0,而中国应该是 UTC+8),就要单独调 SetTimeZoneInformation。
我原本在 UI 上加了第二个按钮"同步时区",但十次用下来只有一次点过(朋友出差去了一趟乌鲁木齐,回来时区没改回来,点过那么一次)。后来我又把它隐藏掉了——能不让人选,就不让人选,是工具类小工具的第一原则。
六、第二版:给朋友加了"开机自启"
第一版发出去用了一个月,朋友反馈是"挺稳的,就是每次都要点一下"。原话。
写工具最幸福的时刻其实就是这个——你写完了用户开始用了,他反馈了,他嫌麻烦。一个真正在用的人才会嫌麻烦。
第二版需求:开机自动同步一次。
实现就是经典的注册表 Run 键,但有几个细节:
bool SetAutoStart(bool enable)
{
HKEY hKey;
LONG r = RegOpenKeyExW(HKEY_CURRENT_USER,
L"Software\Microsoft\Windows\CurrentVersion\Run",
0, KEY_SET_VALUE | KEY_QUERY_VALUE, &hKey);
if (r != ERROR_SUCCESS) return false;
wchar_t path[MAX_PATH];
GetModuleFileNameW(nullptr, path, MAX_PATH);
if (enable) {
std::wstring cmd = L"\"" + std::wstring(path) + L"\" --autostart";
r = RegSetValueExW(hKey, L"TimeSyncTool", 0, REG_SZ,
(BYTE*)cmd.c_str(),
(DWORD)(cmd.size() + 1) * sizeof(wchar_t));
} else {
r = RegDeleteValueW(hKey, L"TimeSyncTool");
}
RegCloseKey(hKey);
return r == ERROR_SUCCESS;
}
几个细节要注意:
- 写在 HKCU,不写 HKLM。HKLM 要管理员权限,又要多一次 UAC 体验差;HKCU 只需当前用户,对自启动够用。
- 命令行加
--autostart参数,让程序启动时不弹窗口、直接同步。 - 加
--minimize标志,运行时ShowWindow(hWnd, SW_HIDE),同步完事后再把窗口显示出来——否则朋友以为它没在跑。
这版上线的时候,朋友截图发我,让我看见任务栏的小图标在"同步中…同步完成"之间跳了一下。他没说别的,就回了一句:"嗯,行。"
这一版用到现在,朋友没再找我。