
简介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 而不是完整 NTPNTP 和 SNTP 的协议格式完全一致差别在行为上。完整 NTP 客户端会维护多个对时源保存历史样本用滤波算法剔除异常源还要处理对称模式、广播模式和密钥认证。SNTP 则砍掉了这些客户端发一个 Mode3 的单播请求服务端回一个 Mode4 的应答客户端用报文里的四个时间戳算出偏差直接校准系统时间。大多数业务场景其实只需要一个可靠的时间源而不是一整套时间同步算法。完整 NTP 实现动辄几千行编译依赖也多放到嵌入式板子上不划算。SNTP 的服务端和客户端加起来通常不到一千行纯 C 实现不依赖第三方库拿到手改改网络配置就能跑。代价是它不做样本滤波单次对时的偏差完全依赖网络往返的对称性所以它对超时和偏移门限这两个参数更敏感这在后面客户端配置里会重点讲。2.2 48 字节报文8 个关键字段先记牢SNTP 报文固定在 48 字节头部字段按网络字节序传输。不要一上来就背整张表先抓住 8 个字段就够用。字段长度说明LI闰秒指示2 bit值为 3 表示时钟未同步客户端必须丢弃这类应答VN版本号3 bitSNTPv4 填 4Mode模式3 bit3 表示客户端请求4 表示服务端应答Stratum层数8 bit1 为高精度时钟2 表示经网络同步16 表示不可用Poll轮询间隔8 bitSNTP 单播场景通常填 0Precision精度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. 构造请求包Mode3VN4其余字段清 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!3Mode4Originate 时间戳等于我们发的 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/-p123服务端 UDP 端口--timeout3000单次等待应答超时单位毫秒--retry3失败后的重试次数--offset-max500偏差超过此值只告警不设置时间单位毫秒--smooth11 表示用 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; } /* 只响应单播客户端请求Mode3广播和对称模式一律忽略 */ 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; /* VN4, Mode4 */ 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 # 期望输出 server10.0.0.5 stratum2 offset-3.2ms rtt0.8ms第一次验证时如果看到超时不要急着改客户端。先确认服务端有没有收到包用 tcpdump 看 UDP 123 端口最直接。服务端日志通常记录三件事来源地址、解析后的 Mode、应答是否成功。日志里出现 Mode 不是 3 的记录时说明有设备在用 NTP 模式访问这种包源码默认丢弃。4.3 服务端参数化端口、stratum、限速与日志服务端可配置参数如下源码里都有对应命令行选项参数默认值说明--port/-p123监听端口端口冲突时排查一下是否有其他 NTP 服务--bind/-b127.0.0.1监听地址内网环境改成实际网卡 IP--stratum/-s2本服务器层数1 表示直接连接高精度时钟源--rate-limit/-r0每来源 IP 每分钟最大请求数0 表示不限--refidLOCL参考时钟标识一般不用改--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 守护进程时在服务配置里加上AmbientCapabilitiesCAP_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跨机房 10msRTT 波动大要先解决网络再谈对时精度24 小时最大偏差 100ms超过 100ms 说明设备晶振漂移过大或对时频率不够跳变次数48 小时内为 0出现一次 settimeofday 跳变就要检查是否触发了偏移门限客户端默认的对时频率建议 300 秒一次。设备晶振漂移一般每秒几微秒到几十微秒300 秒间隔足够把偏差控制在毫秒级又不会给服务端造成压力。6.2 平滑调整的三个习惯动作第一所有时间写操作统一走一个 adjust 函数不要在业务代码里直接调 settimeofday。偏差小于 128ms 用 adjtime大于 128ms 才用 settimeofday。第二每次对时先记录 offset连续三次 offset 都同向且超过 50ms再触发调整。这能过滤掉网络抖动造成的单次异常。第三服务端如果接外部时间源不要让客户端直接访问外部源而是统一访问内网服务端外网链路的抖动不会影响内网设备。从那以后我每次上线对时程序都会强制走一遍先抓包确认时间戳无误再统计 100 次 offset 分布最后连续观测 24 小时确认零跳变才敢把设备批量接入。这套流程救过我很多次希望帮到你。本文还有配套的精品资源点击获取