被远程调机折磨烦了,我用 C++/Win32 写了个 200KB 的时间同步小工具

朋友一台老 Windows 台式机,主板 CMOS 电池老化,每次关机断电后 BIOS 时间都被重置成出厂默认。开机一看——2015 年。

每次出问题,朋友都会远程敲我:"帮我调一下时间。"一开始我还耐心调一下,后来每周都来一两次。

我开始烦了。

我一想,索性写个工具丢给他——点一下就完事。两个月后,这个不到 200KB 的小工具还在用,朋友没再为时间找过我。

一、为什么要自己造这个轮子

朋友那台机器不能装企业版的时间客户端(公司策略),也不能跑 w32time 服务(系统组件关掉了)。他能用的最简方案就是去系统"日期和时间"里点"立即同步"——但那个按钮有时候点了没反应。

于是我想:自己写一个就好。

写工具这件事,我一开始其实是有抗拒的——一个 100 行的 NTP 同步小工具,写完不会有成就感。但我更烦的是每周被打断一次,所以这笔账怎么算都划算。

工具的需求特别朴素:

  • 打开 → 一个按钮 → 点了 → 时间就对了
  • 别跳一堆窗口、别装 .NET、别写注册表(写也行,但别没事找事)

定下来之后,我没有选的有:

这种"克制"不是克制——是真的不需要。朋友那一台机器、一位用户,没有并发、没有扩展需求,单进程 + 单文件 + 单权限,把事情做完就是。

二、第一版:一个窗口 + 一个按钮

技术栈直接定 C++ + 原生 Win32,没有用任何框架。代码总量大概 600 行,release 编译出来 80KB,加静态 CRT 也不到 200KB。

主窗口就是个 WNDCLASSEX + CreateWindowEx,里面塞两个控件:一个按钮 IDC_BUTTON_SYNC、一个状态文本 IDC_STATIC_STATUS。

第一次写完跑起来的时候,没那种"作品完成"的成就感——一个按钮、一个回调、一个同步函数,跟玩具没什么两样。我发给那个朋友让他试,他点了一下,回我三个字:"终于对了。"

SyncTimeFromNtp 做三件事:

  1. 给一个 NTP 服务器发 UDP 包;
  2. 算时间偏移;
  3. 调 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;

如果你打开的是普通 exe(没 manifest),连 OpenProcessToken 都要先 OpenSCManager 自己的服务;最干脆的办法是给 exe 加一个 requestedExecutionLevel level="requireAdministrator" 的 manifest,让系统弹 UAC 提权后再启动。

两种方案我都留了一份代码,最终用的是方案 A(manifest 直接要求管理员)——朋友不会忘了右键"以管理员身份运行"。

四、坑 2:自己写 NTP 客户端比想象的简单

很多人以为 NTP 客户端是高深协议,其实最朴素那一段——发 UDP 包、收 48 字节、解析——真不难。

写 NTP 客户端那会儿我其实有点慌——毕竟是网络代码,写错了就是把别人机器的时间调乱。一行错位,那台机器就不再"准时",可能再开机什么都错乱了。

最简的 NTP v4 客户端长这样:

两件容易踩的小事:

  • 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 键,但有几个细节:

几个细节要注意:

  • 写在 HKCU,不写 HKLM。HKLM 要管理员权限,又要多一次 UAC 体验差;HKCU 只需当前用户,对自启动够用。
  • 命令行加 --autostart 参数,让程序启动时不弹窗口、直接同步。
  • 加 --minimize 标志,运行时 ShowWindow(hWnd, SW_HIDE),同步完事后再把窗口显示出来——否则朋友以为它没在跑。

这版上线的时候,朋友截图发我,让我看见任务栏的小图标在"同步中…同步完成"之间跳了一下。他没说别的,就回了一句:"嗯,行。"

这一版用到现在,朋友没再找我。