简介:SNTP(简单网络时间协议)对时客户端与服务端源码,基于C/C++实现,面向需要理解网络时间同步机制或进行局域网内设备校时的开发者、运维人员及C++学习者。程序通过附带文本配置文件即可切换客户端与服务端角色,自由设定对时服务器IP、端口、本地对时间隔,并可选择是否同步本机时钟,实际部署与二次开发都很便捷。资源包共78个文件,以.cpp/.h源文件为主体,包含Visual Studio工程(.sln/.vcxproj)、编译生成的exe可执行程序与pdb调试文件,以及cfg.ini、dns.cpp等关键辅助文件,整体23.44MB,目录结构清晰。此外,包内还附有SNTP协议的客户端参考zip。目前已有1295人学习下载,适合用于学习SNTP协议交互、练习Socket编程或作为校时工具直接使用。
1. SNTP对时客户端和服务端源码:先解决内网时间漂移,再谈精度
把一份SNTP C/C++ 源码从压缩包里解出来,我通常先不看主函数,而是先看它怎么处理那 48 字节的报文头。因为时间同步里大多数诡异问题,都出在字段字节序、闰秒指示和时间戳基准上。这套源码要解决的是内网设备时间漂移问题:客户端每隔一段时间向服务端发一个 UDP 请求,服务端把收到时间和发送时间填回报文,客户端算出偏差后校准本地时钟。适合嵌入式设备、内网服务器集群这类要求不高、但要统一时间源的场景。相比完整 NTP 实现,它不需要状态机、不需要历史样本滤波,一个源码包就能把服务端和客户端都跑起来,落地成本很低。
2. SNTP 报文与源码骨架:先看懂 48 字节,再改代码
2.1 为什么对时首选 SNTP 而不是完整 NTP
NTP 和 SNTP 的协议格式完全一致,差别在行为上。完整 NTP 客户端会维护多个对时源,保存历史样本,用滤波算法剔除异常源,还要处理对称模式、广播模式和密钥认证。SNTP 则砍掉了这些,客户端发一个 Mode=3 的单播请求,服务端回一个 Mode=4 的应答,客户端用报文里的四个时间戳算出偏差,直接校准系统时间。
大多数业务场景其实只需要一个可靠的时间源,而不是一整套时间同步算法。完整 NTP 实现动辄几千行,编译依赖也多,放到嵌入式板子上不划算。SNTP 的服务端和客户端加起来通常不到一千行,纯 C 实现,不依赖第三方库,拿到手改改网络配置就能跑。代价是它不做样本滤波,单次对时的偏差完全依赖网络往返的对称性,所以它对超时和偏移门限这两个参数更敏感,这在后面客户端配置里会重点讲。
2.2 48 字节报文:8 个关键字段先记牢
SNTP 报文固定在 48 字节,头部字段按网络字节序传输。不要一上来就背整张表,先抓住 8 个字段就够用。
| 字段 | 长度 | 说明 |
|---|---|---|
| LI(闰秒指示) | 2 bit | 值为 3 表示时钟未同步,客户端必须丢弃这类应答 |
| VN(版本号) | 3 bit | SNTPv4 填 4 |
| Mode(模式) | 3 bit | 3 表示客户端请求,4 表示服务端应答 |
| Stratum(层数) | 8 bit | 1 为高精度时钟,2 表示经网络同步,16 表示不可用 |
| Poll(轮询间隔) | 8 bit | SNTP 单播场景通常填 0 |
| Precision(精度) | 8 bit | 有符号整数,表示系统时钟分辨率,用 2 的幂表示 |
| Root Delay / Root Dispersion | 各 32 bit | 从本机到根时间源的延迟和分散值,客户端可设阈值 |
| 四个时间戳 | 各 64 bit | 分别为参考时间、原始时间 T1、接收时间 T2、发送时间 T3 |
四个时间戳是关键。时间戳的基准是 1900 年 1 月 1 日,而 Unix 时间戳基准是 1970 年,两者相差 2208988800 秒。NTP 64 位时间戳里,前 32 位是整数秒,后 32 位是小数秒。
2.3 源码骨架与时间戳转换函数
源码目录结构是典型的 C 工程拆分方式,客户端和服务端共用同一个报文头定义,避免字段定义不一致导致的解析错位。
. ├── sntp.h # 报文结构体、常量、时间换算函数 ├── sntp_client.c # 客户端:请求、校验、设置本机时间 ├── sntp_server.c # 服务端:监听 UDP 123、回填时间戳、应答 ├── Makefile # 两个目标:sntp_client 和 sntp_server └── README.md # 编译方式、参数表、常见问题sntp.h 里最核心的是报文结构体和当前时间转 NTP 时间戳的函数。结构体按 4 字节对齐定义,便于直接收发。
/* sntp.h —— SNTPv4 单播报文结构 */ struct ntp_packet { uint8_t li_vn_mode; /* LI(2bit) + VN(3bit) + Mode(3bit) */ uint8_t stratum; /* 时钟层数 */ uint8_t poll; /* 轮询间隔,SNTP 填 0 */ uint8_t precision; /* 时钟精度,2 的幂 */ uint32_t root_delay; /* 根延迟,16.16 定点数 */ uint32_t root_dispersion; /* 根分散,16.16 定点数 */ uint32_t ref_id; /* 参考时钟标识 */ uint32_t ref_ts_sec; /* 参考时间戳,秒部分 */ uint32_t ref_ts_frac; /* 参考时间戳,小数部分 */ uint32_t orig_ts_sec; /* T1:客户端发送时间 */ uint32_t orig_ts_frac; uint32_t rx_ts_sec; /* T2:服务端接收时间 */ uint32_t rx_ts_frac; uint32_t tx_ts_sec; /* T3:服务端发送时间 */ uint32_t tx_ts_frac; };当前系统时间转 NTP 时间戳的写法直接决定精度。常见做法是先用 gettimeofday 拿到秒和微秒,再换算成 NTP 的 64 位时间戳。
/* sntp.h —— 系统时间转换为 NTP 64 位时间戳 */ #define NTP_TO_UNIX_OFFSET 2208988800ULL /* 1900 到 1970 的秒差 */ static uint64_t now_ntp64(void) { struct timeval tv; gettimeofday(&tv, NULL); uint64_t secs = (uint64_t)tv.tv_sec + NTP_TO_UNIX_OFFSET; /* 微秒转 32 位小数秒:除以 1000000 再乘 2^32 */ uint32_t frac = (uint32_t)(((uint64_t)tv.tv_usec << 32) / 1000000ULL); return (secs << 32) | frac; }这段代码里最容易出错的是frac的计算。先把微秒左移 32 位,再除以 1000000,得到的是 0 到 2^32 之间的小数秒表示。如果先除后乘,会丢掉低位的精度,对时误差直接放大到几十毫秒。
客户端判断时间偏差时,不是单独看某个时间戳,而是用 T1、T2、T3 和客户端自己记录的 T4 算偏移。单次对时的偏移公式是:
offset = ((T2 - T1) + (T3 - T4)) / 2其中 T1 是请求发送时刻,T2 是服务端收到请求的时刻,T3 是服务端发出应答的时刻,T4 是客户端收到应答的时刻。这个公式假设网络往返对称,真实网络会有抖动,所以命令里最好返回两个值:offset 和 RTT。RTT 等于(T4 - T1) - (T3 - T2),客户端可以根据 RTT 决定这次对时结果是否可信。
3. 客户端源码实战:编译、参数配置与时间戳换算
3.1 编译出两个可执行文件:Makefile 与两条 gcc 命令
源码针对 Linux 和类 Unix 系统,不需要额外链接数学库或线程库。直接执行 Makefile 就能产出两个可执行文件。
# 进入源码目录,清理后编译 make clean && make # 如果不通过 Makefile,手动执行也行 gcc -Wall -O2 -o sntp_client sntp_client.c gcc -Wall -O2 -o sntp_server sntp_server.c # 确认编译产物 ls -l sntp_client sntp_server-Wall会把字段溢出、类型转换这类警告全部打出来,我建议保留,不要因为警告看着烦就关掉。SNTP 源码对字节序很敏感,-Wall能帮你提前发现隐式类型转换问题。-O2优化级别对这类网络程序足够,不必开到-O3。
如果你的目标平台是嵌入式,交叉编译时只需要把 gcc 换成对应的交叉编译器前缀,例如arm-linux-gnueabihf-gcc,源码里没有依赖 glibc 之外的系统库,移植成本很低。
3.2 客户端主流程:发请求、校验应答、换算时间差
客户端主流程分四步:构造请求报文、发送并等待应答、校验应答合法性、计算偏差并校准时间。下面这段是核心逻辑,去掉了信号处理等外围代码。
/* sntp_client.c —— 单次对时主流程 */ struct ntp_packet pkt; int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); /* 1. 构造请求包:Mode=3,VN=4,其余字段清 0 */ memset(&pkt, 0, sizeof(pkt)); pkt.li_vn_mode = (0 << 6) | (4 << 3) | 3; uint64_t t1 = now_ntp64(); pkt.tx_ts_sec = htonl((uint32_t)(t1 >> 32)); pkt.tx_ts_frac = htonl((uint32_t)t1); /* 2. 发送到服务端 UDP 123 端口 */ sendto(sock, &pkt, sizeof(pkt), 0, (struct sockaddr *)&server_addr, sizeof(server_addr)); /* 3. 等待应答,并记录 T4 */ struct timeval tv; gettimeofday(&tv, NULL); uint64_t t4 = now_ntp64(); recvfrom(sock, &pkt, sizeof(pkt), 0, ...); /* 4. 校验应答:LI!=3,Mode==4,Originate 时间戳等于我们发的 T1 */ uint8_t li = (pkt.li_vn_mode >> 6) & 0x3; uint8_t mode = pkt.li_vn_mode & 0x7; uint64_t orig = ((uint64_t)ntohl(pkt.orig_ts_sec) << 32) | ntohl(pkt.orig_ts_frac); if (li == 3) { /* 服务端时钟未同步,丢弃 */ } if (mode != 4) { /* 不是服务端应答,丢弃 */ } if (orig != t1) { /* 应答不是给本次请求的,丢弃 */ } /* 5. 取 T2、T3,计算 offset 和 RTT */ uint64_t t2 = ((uint64_t)ntohl(pkt.rx_ts_sec) << 32) | ntohl(pkt.rx_ts_frac); uint64_t t3 = ((uint64_t)ntohl(pkt.tx_ts_sec) << 32) | ntohl(pkt.tx_ts_frac); int64_t offset_ms = calc_offset_ms(t1, t2, t3, t4); int64_t rtt_ms = calc_rtt_ms(t1, t2, t3, t4);这段代码里最值得注意的校验是orig != t1。SNTP 没有连接概念,完全靠 UDP 报文里的 Originate Timestamp 字段来对应请求和应答。如果不校验这个字段,攻击者可以伪造应答直接改系统时间,或者网络中滞后的旧应答包会把时间改成错误值。
我一般还会加一个 RTT 判断:当rtt_ms > 1000时,即使 offset 算出来了也不设置系统时间,因为大 RTT 意味着往返不对称,偏移值不可信。
3.3 客户端配置参数与时间设置策略
客户端参数表如下,这些参数在源码里都有默认值,生产环境建议显式配置:
| 参数 | 默认值 | 说明 |
|---|---|---|
--server/-s | 无 | 服务端 IP 或主机名,必填 |
--port/-p | 123 | 服务端 UDP 端口 |
--timeout | 3000 | 单次等待应答超时,单位毫秒 |
--retry | 3 | 失败后的重试次数 |
--offset-max | 500 | 偏差超过此值只告警,不设置时间,单位毫秒 |
--smooth | 1 | 1 表示用 adjtime 平滑调整,0 表示强制 settimeofday |
--offset-max这个参数容易被忽略。新部署的对时程序,第一次跑的时候机器时间和真实时间可能差几分钟甚至几小时。如果这个值设成 500 毫秒,程序会一直只告警不同步,看起来像是坏了。我习惯首次部署时先设一个较大的值,比如 5000 毫秒,确认链路稳定后再调回 500 毫秒。
时间设置策略上,源码里做了个分流:偏差小于 128 毫秒时用 adjtime 做平滑调整,内核会逐步把时间修正过来,业务进程感知不到时钟跳变;偏差大于 128 毫秒时用 settimeofday 直接设置,因为这种大偏差靠平滑调整需要太长时间。需要注意的是,设置系统时间需要 root 权限,非 root 运行会返回 Operation not permitted。
4. 服务端源码实战:从回包填充到内网部署验证
4.1 服务端核心逻辑:填好 T2/T3 再回包
服务端比客户端简单,就是收请求、填时间戳、回包。但 T2 和 T3 的取值位置很有讲究。T2 必须是收到请求那一刻的系统时间,T3 必须是发送应答那一刻的系统时间。中间如果夹了日志打印、磁盘写入这类操作,时间戳就会被污染。
/* sntp_server.c —— 单播应答核心流程 */ struct ntp_packet pkt; struct sockaddr_in client_addr; socklen_t client_len = sizeof(client_addr); ssize_t n = recvfrom(sock, &pkt, sizeof(pkt), 0, (struct sockaddr *)&client_addr, &client_len); if (n < 48) { /* 报文不足 48 字节,直接丢弃 */ return; } /* 只响应单播客户端请求,Mode=3;广播和对称模式一律忽略 */ uint8_t mode = pkt.li_vn_mode & 0x7; if (mode != 3) { return; } /* 保存请求中的 T1,原样填回应答的 Originate Timestamp */ uint32_t orig_sec = pkt.tx_ts_sec; uint32_t orig_frac = pkt.tx_ts_frac; /* 取 T2,此刻是收到请求的时间点 */ uint64_t t2 = now_ntp64(); /* 填充应答头 */ pkt.li_vn_mode = (0 << 6) | (4 << 3) | 4; /* VN=4, Mode=4 */ pkt.stratum = 2; /* 经网络同步的服务器 */ pkt.poll = 0; pkt.precision = (uint8_t)(-6); /* 2^-6 秒,约 15.6ms */ pkt.root_delay = 0; pkt.root_dispersion = htonl(0x0000028F); /* 约 10ms 分散值 */ pkt.ref_id = htonl(0x4C4F434C); /* "LOCL" 表示本地时钟 */ /* 回填四个时间戳 */ pkt.orig_ts_sec = orig_sec; /* T1:原样返回客户端请求时间 */ pkt.orig_ts_frac = orig_frac; pkt.rx_ts_sec = htonl((uint32_t)(t2 >> 32)); /* T2 */ pkt.rx_ts_frac = htonl((uint32_t)t2); uint64_t t3 = now_ntp64(); /* T3 尽量贴近发送 */ pkt.tx_ts_sec = htonl((uint32_t)(t3 >> 32)); pkt.tx_ts_frac = htonl((uint32_t)t3); /* 应答必须发送回客户端来源地址和端口 */ sendto(sock, &pkt, sizeof(pkt), 0, (struct sockaddr *)&client_addr, client_len);ref_id字段赋值 0x4C4F434C 是这里的一个隐藏细节。它对应 ASCII 的 "LOCL",但因为在报文里是网络字节序,小端主机上内存中的字节顺序是 4C 4F 43 4C,正好是 L-O-C-L。直接写字符串再拷贝反而容易出错,所以我习惯直接用 htonl 赋值。
stratum字段对客户端的影响很大。如果你的服务端本身就是从一个更高级的 NTP 服务同步的,就把 stratum 填成比上游大 1。如果服务端只是给内网设备提供一个基准时间,一般填 2 即可。填 1 意味着这台机器直接连接原子钟或 GPS,如果实际不是,会让客户端误判时间源质量。
4.2 启动服务端并与客户端连通验证
服务端启动时绑定 UDP 123 端口,需要 root 权限。生产环境不要默认监听 0.0.0.0,先绑到内网网卡地址,减少暴露面。
# 前台启动,绑定内网地址,便于看日志 sudo ./sntp_server --bind 10.0.0.5 --port 123 --stratum 2 # 另开一个终端,用源码自带的客户端验证 sudo ./sntp_client --server 10.0.0.5 --port 123 --timeout 2000 --retry 3 # 期望输出 server=10.0.0.5 stratum=2 offset=-3.2ms rtt=0.8ms第一次验证时如果看到超时,不要急着改客户端。先确认服务端有没有收到包,用 tcpdump 看 UDP 123 端口最直接。服务端日志通常记录三件事:来源地址、解析后的 Mode、应答是否成功。日志里出现 Mode 不是 3 的记录时,说明有设备在用 NTP 模式访问,这种包源码默认丢弃。
4.3 服务端参数化:端口、stratum、限速与日志
服务端可配置参数如下,源码里都有对应命令行选项:
| 参数 | 默认值 | 说明 |
|---|---|---|
--port/-p | 123 | 监听端口,端口冲突时排查一下是否有其他 NTP 服务 |
--bind/-b | 127.0.0.1 | 监听地址,内网环境改成实际网卡 IP |
--stratum/-s | 2 | 本服务器层数,1 表示直接连接高精度时钟源 |
--rate-limit/-r | 0 | 每来源 IP 每分钟最大请求数,0 表示不限 |
--refid | LOCL | 参考时钟标识,一般不用改 |
--rate-limit在公网场景必须启用。虽然 SNTP 应答包和请求包都是 48 字节,本身放大系数不高,但如果攻击者伪造源 IP 大量发送请求,服务端回包会打到受害者机器上,形成流量反射。内网环境通常不需要限速,但要保留这个参数。
日志字段我建议固定输出五列:时间、客户端 IP、客户端端口、Mode、应答耗时。其中应答耗时这个值能反映 UDP 收发的稳定程度,如果这个值经常超过 1 毫秒,说明系统调度或网卡中断有问题,对时精度会受到直接影响。
5. 避坑与排查:五个最常踩的对时翻车点
5.1 五个真实的对时故障记录
第一坑:同步后时间慢了 8 小时
现象:客户端跑完对时程序,日志和文件时间戳显示的时间比真实时间晚了 8 小时。 原因:机器系统时间存的是 UTC,但系统时区配置成了东八区。NTP 时间戳本身是 UTC,源码在设置系统时间时如果用了本地时区换算,就会整体偏移 8 小时。 解决:统一规则——机器内部一律存 UTC,只在应用层做时区显示。源码里设置系统时间的部分不要调用 localtime(),直接使用 gettimeofday 得到的 UTC 值。排查时用date -u和date两个命令对比,就能确认是时区问题还是同步问题。
第二坑:bind 123 端口提示 Operation not permitted
现象:服务端启动时报错,无法监听 123 端口。 原因:Linux 下普通用户无法绑定 1024 以下端口,必须要有 root 权限或 CAP_NET_BIND_SERVICE 能力。 解决:开发调试用sudo跑;生产环境用 systemd 守护进程时,在服务配置里加上AmbientCapabilities=CAP_NET_BIND_SERVICE,避免让整个进程都拥有 root 权限。
第三坑:客户端收到应答但 offset 忽大忽小
现象:连续跑十次客户端,offset 从 -3ms 跳到 40ms 再跳回来,没有规律。 原因:最常见的是网络路径不对称。跨交换机、跨机房的对时请求,去程走的路由和回程不一定相同。也可能是服务端在应答前做了日志写磁盘操作,导致 T3 取值被延迟。 解决:先看 RTT,如果 RTT 波动超过 5ms,这条链路就不适合做毫秒级对时。服务端把日志改成异步写入或者直接关掉日志,T3 的取值时序就能稳定下来。
第四坑:闰秒导致日志时间重复或跳秒
现象:在某些特殊日期,设备日志出现同一秒重复两条记录,或者时间直接跳过了某秒。 原因:SNTP 报文里有 LI 闰秒指示字段。当上游宣布即将插入闰秒时,客户端如果没有处理这个字段,还是用普通的时间换算逻辑,就会出现 23:59:59 重复或跳变。 解决:客户端解析应答时检查 LI 字段。LI 为 1 表示最后一分钟要加一秒,LI 为 2 表示要减一秒。源码里为这两种情况单独走一条处理路径:加闰秒时,在最后 1 秒内保持时间不动而不是回拨;减闰秒时,直接快速跳过 1 秒。宁可在日志里打一条"闰秒调整"记录,也不要让时间回跳。
第五坑:应答包的 Originate Timestamp 对不上
现象:客户端日志里频繁出现 "orig timestamp mismatch",所有请求都被丢弃。 原因:客户端发出请求时记录了 T1,应答里的 Originate Timestamp 必须等于 T1。如果服务端在构造应答时填的是自己的发送时间,而不是原样返回请求里的 Transmit Timestamp,客户端就会判定这个应答不属于本次请求。 解决:服务端代码里把请求的tx_ts_sec和tx_ts_frac原样拷贝到应答的orig_ts_sec和orig_ts_frac。有的简化实现只拷贝了秒部分,没拷贝小数部分,对时请求频繁时报同样的错。
5.2 用抓包和客户端日志缩小问题范围
遇到对时异常,我先抓包,再跑客户端,最后看日志,按这个顺序能快速缩小问题范围。
# 抓取 UDP 123 端口报文,十进制时间戳方便对照 sudo tcpdump -i eth0 udp port 123 -n -vv 2>/dev/null # 输出样例(模拟): # IP 10.0.0.5.123 > 10.0.0.8.123: NTPv4, length 48 # Stratum 2, poll 0, precision -6 # Root Delay: 0.000000, Root Dispersion: 0.010000 # Reference ID: 'LOCL' # Originate: 0xE5F4A2B0.0x12345678 # Receive: 0xE5F4A2B0.0x23456789 # Transmit: 0xE5F4A2B0.0x3456789A抓包时重点看两个地方:应答包里的 Originate 是否等于客户端请求包里的 Transmit,以及 Receive 和 Transmit 之间差了多少。如果两者差值超过 2 毫秒,说明服务端从收包到发包之间做了过多工作,精度瓶颈在应用层而不是网络。把日志调成 debug 级别,客户端每次对时输出 offset、RTT、应答原始时间戳,连跑 100 次,偏差分布基本就能定位七八成的问题。
6. 进阶:把对时从能用做到稳定——验证指标与平滑调整
6.1 连续观测的量化指标
对时程序跑通只是第一步,生产环境要的是连续运行不跳变。我给内网设备做对时验收时,只盯三个指标:
| 指标 | 合格线 | 说明 |
|---|---|---|
| 单次 RTT | 局域网内 < 2ms,跨机房 < 10ms | RTT 波动大要先解决网络,再谈对时精度 |
| 24 小时最大偏差 | < 100ms | 超过 100ms 说明设备晶振漂移过大或对时频率不够 |
| 跳变次数 | 48 小时内为 0 | 出现一次 settimeofday 跳变,就要检查是否触发了偏移门限 |
客户端默认的对时频率建议 300 秒一次。设备晶振漂移一般每秒几微秒到几十微秒,300 秒间隔足够把偏差控制在毫秒级,又不会给服务端造成压力。
6.2 平滑调整的三个习惯动作
第一,所有时间写操作统一走一个 adjust 函数,不要在业务代码里直接调 settimeofday。偏差小于 128ms 用 adjtime,大于 128ms 才用 settimeofday。第二,每次对时先记录 offset,连续三次 offset 都同向且超过 50ms,再触发调整。这能过滤掉网络抖动造成的单次异常。第三,服务端如果接外部时间源,不要让客户端直接访问外部源,而是统一访问内网服务端,外网链路的抖动不会影响内网设备。
从那以后我每次上线对时程序都会强制走一遍:先抓包确认时间戳无误,再统计 100 次 offset 分布,最后连续观测 24 小时确认零跳变,才敢把设备批量接入。这套流程救过我很多次,希望帮到你。
本文还有配套的精品资源,点击获取