
简介基于C/C实现的SNTP对时服务端与客户端源码包面向需要网络时间同步功能或学习SNTP协议的嵌入式与网络开发者。程序通过文本配置文件灵活设定运行模式客户端/服务端、对时服务器IP、端口、本地对时间隔以及是否同步本地时钟兼顾实用性与教学性。包内共78个文件、约23.44MB以cpp/h源文件、Visual Studio工程文件sln/vcxproj/dsw、编译生成的obj/exe/pdb、中间日志tlog及cfg.ini配置为主并附带SNTP协议客户端zip、CSntp说明文档和界面示意图便于理解协议交互、构建流程和界面演示。资源目录结构清晰源码与构建产物分离可直接编译运行或打开工程调试对比客户端与服务端的报文交互也可基于现有框架修改配置或扩展自定义对时策略。已有1289人学习下载适合学习、实验及二次开发。 SNTP对时客户端和服务端源码我用 C/C 从头写了一套目的很纯粹让局域网里的设备、网关、服务器能够快速对准时间。用过的人都知道时间不同步是个特别隐蔽的坑系统日志错位、证书校验失败、分布式节点互相不认账排查起来很消耗精力。这套源码既能当普通客户端去请求公共 NTP 服务也能自己起一个服务端给内网其他设备校时特别适合嵌入式 Linux、边缘网关和刚接触网络时间协议的同学。下面我把协议要点、客户端实现、服务端实现、工程组织、调试方法和踩过的坑一次性讲清楚。1. 项目概述与SNTP协议基础1.1 为什么用SNTP而不是完整NTPNTPNetwork Time Protocol本身是一个庞大的协议族完整的 NTP 实现要考虑分层架构、多个时间源选择、时钟状态机、环路消除、加密认证等一系列东西。一般做基础时间服务时确实需要完整实现但如果你只是让手头的一块板子、一个服务端程序在几毫秒量级内对时用 SNTP 就够了。SNTPSimple Network Time Protocol是 NTP 的简化版协议标准是 RFC 4330。它复用了 NTP 的报文格式但是砍掉了复杂的时钟选择算法只保留了最核心的请求-响应模式。客户端发一个 UDP 包服务端回一个 UDP 包客户端根据 T1、T2、T3、T4 这四个时间点计算网络延迟和本地时钟偏移然后校准本地时间。这套源码选择 SNTP 还有一个现实原因现在主流操作系统自带的ntpdate、sntp命令以及公共 NTP 服务器都能和 SNTP 客户端互通。也就是说你写的 SNTP 客户端可以直接访问pool.ntp.org这类公共时间源反过来你实现的 SNTP 服务端也可以被标准 NTP 工具查询。这种兼容性让调试和落地都方便很多。1.2 一次校时请求要经过哪些环节SNTP 的网络流程不复杂但时间戳的取点位置直接决定精度。一次完整的单播校时包含四个时间点客户端发送请求报文前记录本地时间 T1。服务端收到请求报文时记录系统时间 T2。服务端发送响应报文前记录系统时间 T3。客户端收到响应报文后记录本地时间 T4。如果忽略中间网络设备处理时间可以认为 T2 - T1 是请求路径上的转发耗时T4 - T3 是响应路径上的转发耗时。假设网络路径对称那么本地时钟相对服务端时钟的偏移量就是偏移量 offset ((T2 - T1) (T3 - T4)) / 2总往返延迟 delay (T4 - T1) - (T3 - T2)为什么要取平均因为 T1 和 T4 是同一台机器的时钟T2 和 T3 是服务器时钟客户端时钟和服务器时钟之间存在一个未知偏差。把两个差值加起来除以二正好可以把那个未知偏差消掉剩下的就是两段网络延迟的平均值。这个推导不复杂但它是整个时间同步算法的根基。1.3 报文结构48字节背后的规矩NTP/SNTP 报文头部固定 48 字节所有多字节字段都是网络字节序大端。关键字段如下偏移字段长度说明0LI / VN / Mode1 字节LI 闰秒指示VN 版本号Mode 报文类型1Stratum1 字节时钟层数1 为主时钟2 为二层级服务器2Poll1 字节轮询间隔的幂指数3Precision1 字节系统时钟精度4Root Delay4 字节到主时间源的总延迟8Root Dispersion4 字节到主时间源的总离散度12Reference ID4 字节参考源标识16Reference Timestamp8 字节本机最近一次被同步的时间24Origin Timestamp8 字节请求报文的发送时间服务端原样回显32Receive Timestamp8 字节服务端收到请求的时间 T240Transmit Timestamp8 字节服务端发出响应的时间 T3时间戳是 64 位定点数单位基准是 1900 年 1 月 1 日零时。高 32 位是秒低 32 位是秒的小数部分每秒钟被分为 2^32 个刻度。Linux 中常用的gettimeofday得到的是从 1970 年起的 Unix 时间戳要换算到 NTP 时间戳需要加上 2208988800 秒也就是 1970 到 1900 之间跨越的年数换算出的秒数。2. 客户端源码核心实现2.1 客户端的主流程和状态客户端源码的核心逻辑是一个无状态的 UDP 请求函数。它不维护任何连接也不做复杂的状态机。伪代码流程如下创建 UDP socket这里用SOCK_DGRAM。设置接收超时避免服务端没响应时recvfrom一直阻塞。构造 48 字节报文把版本号设为 4模式设为 3客户端模式。取本地当前时间 T1填充到报文的 Transmit Timestamp 字段。通过sendto发送到目标端口的 123 端口。通过recvfrom接收响应。收到响应后立刻取本地时间 T4。解析响应校验模式是否为 4服务端模式。提取 Receive TimestampT2和 Transmit TimestampT3。计算偏移量和延迟决定是否调整本地时钟。需要特别说明的是很多初学实现会忽略请求报文里的 Transmit Timestamp觉得反正服务端会自己填。实际上服务端构造响应时会把客户端请求里的 Transmit Timestamp 原样复制到 Origin Timestamp 字段。如果客户端请求里没填响应里的 Origin Timestamp 就是零双方都少了一个可参考的校验点。所以哪怕只是弱校验也应该把 T1 填进去。2.2 构造请求与解析响应的关键代码我整理了一个剪掉错误处理和边界检查的最简版本重点看协议逻辑。源码使用 C17但主体思路和纯 C 基本一致。#include sys/socket.h #include netinet/in.h #include arpa/inet.h #include sys/time.h #include unistd.h #include cstring #include cstdio constexpr uint32_t kNtpEpochOffset 2208988800U; struct SntpPacket { uint8_t flags; uint8_t stratum; int8_t poll; int8_t precision; uint32_t root_delay; uint32_t root_dispersion; uint32_t ref_id; uint32_t ref_ts_sec; uint32_t ref_ts_frac; uint32_t origin_ts_sec; uint32_t origin_ts_frac; uint32_t recv_ts_sec; uint32_t recv_ts_frac; uint32_t trans_ts_sec; uint32_t trans_ts_frac; }; double currentNtpTime() { timeval tv{}; gettimeofday(tv, nullptr); return tv.tv_sec kNtpEpochOffset tv.tv_usec / 1000000.0; } void fillTimestamp(double t, uint32_t secField, uint32_t fracField) { uint32_t sec static_castuint32_t(t); secField htonl(sec); fracField htonl(static_castuint32_t((t - sec) * (1ULL 32))); } double timestampToDouble(uint32_t secField, uint32_t fracField) { uint32_t sec ntohl(secField); uint32_t frac ntohl(fracField); return sec frac / 4294967296.0; }currentNtpTime直接返回 NTP 基准下的浮点秒数这样算偏移量时不需要再去转换 epoch。fillTimestamp专门把浮点秒拆成 64 位定点数并转成网络字节序。注意低 32 位的小数转换是拿小数部分乘以 2^32这个过程有精度损失但对 SNTP 来说足够。请求发送和响应接收部分如下bool queryTime(const char* serverIp, double offset, double delay) { int fd socket(AF_INET, SOCK_DGRAM, 0); if (fd 0) return false; timeval timeout{}; timeout.tv_sec 3; timeout.tv_usec 0; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, timeout, sizeof(timeout)); sockaddr_in addr{}; addr.sin_family AF_INET; addr.sin_port htons(123); inet_pton(AF_INET, serverIp, addr.sin_addr); SntpPacket req{}; req.flags (4 3) | 3; // 前3位LI0VN4Mode3 double t1 currentNtpTime(); fillTimestamp(t1, req.trans_ts_sec, req.trans_ts_frac); sendto(fd, req, sizeof(req), 0, (sockaddr*)addr, sizeof(addr)); SntpPacket resp{}; sockaddr_in from{}; socklen_t fromLen sizeof(from); ssize_t n recvfrom(fd, resp, sizeof(resp), 0, (sockaddr*)from, fromLen); close(fd); if (n (ssize_t)sizeof(resp)) return false; double t4 currentNtpTime(); double t2 timestampToDouble(resp.recv_ts_sec, resp.recv_ts_frac); double t3 timestampToDouble(resp.trans_ts_sec, resp.trans_ts_frac); offset ((t2 - t1) (t3 - t4)) / 2.0; delay (t4 - t1) - (t3 - t2); return true; }这段代码里t1和t4都是本地时钟下 NTP 基准浮点秒。因为大家共用同一个基准所以t2 - t1可以直接算出差值不用管本地到底处于哪个时区NTP 时间戳本身就是 UTC 时间体系。2.3 偏移量计算T1到T4怎么变成修正值很多人在理解偏移量时会被((T2 - T1) (T3 - T4)) / 2绕晕。我习惯用生活例子来解释假设你在一条零延迟的传输带上给学生作业打分你的手表比标准钟慢了 10 秒。你记录发出时间 T1100你的表对方收到时标准时间 T2110对方回复时标准时间 T3111你收到时自己的表 T4101。按公式算offset ((110 - 100) (111 - 101)) / 2 (10 10) / 2 10。也就是说你的表慢了 10 秒修正时要把时间加上 10 秒。这个例子里往返延迟是零所以偏移量一目了然。如果网络延迟不对称公式就不能完全补偿这也是 SNTP 精度无法和 PTP精确时间协议相比的原因之一。实际项目中公共网络环境抖动较大单次采样的 offset 往往有几十毫秒误差。我的做法是在客户端循环采样 3 到 5 次每次记录 offset 和 delay然后挑 delay 最小的那次作为一个周期内的时间偏差。理由很简单delay 越接近真实最小路径延迟说明网络排队、缓存造成的噪声越小那次采样的 offset 就越可信。2.4 客户端避坑清单客户端实现里有几个容易被忽略的细节我实际踩过写在这里接收超时一定要设置。UDP 没有连接状态服务端不存在时会立刻返回 ICMP 端口不可达但 socket 默认不把这种错误反馈给recvfrom它会一直阻塞到天荒地老。设置SO_RCVTIMEO之后超时返回EAGAIN你才能做重试。收到响应后要做最基本校验。至少检查报文长度是否大于等于 48以及 Mode 字段是否为 4。不做校验的话局域网里一个广播包可能让你的时间直接错几分钟。调整系统时钟要小心权限。settimeofday或clock_settime都需要 root 权限或CAP_SYS_TIME。普通用户运行的进程建议先打印偏差让运维决定是否校时。大跳变会带来副作用。如果只是快了几百毫秒用adjtime做渐进式调整更好如果设备刚上电偏差达到十几秒直接一次性校正更实际。3. 服务端源码核心实现3.1 服务端要做的三件事服务端角色的本质是“我提供一个可信时间”。它要做的事情总结起来就是三件接收请求、填写时间戳、发送响应。相比客户端它少了很多方向判断但多了一个被动约束取 T2 和 T3 的时机必须准。服务端的时间来源也很关键。如果服务端本机系统时间本身没有同步那它对外提供的 SNTP 服务就是毒药。常见做法是让服务端单独跑一个chronyd或ntpd和上游时间源同步或者让它接入 GPS/PTP 时钟源。我们的源码里不重复做上游同步而是直接以系统时间作为时间源把系统时间是否可信的判断交给部署者。3.2 处理请求和构造响应的代码服务端代码比客户端还要短。核心处理函数如下void handleRequest(int fd) { SntpPacket req{}; sockaddr_in client{}; socklen_t len sizeof(client); ssize_t n recvfrom(fd, req, sizeof(req), 0, (sockaddr*)client, len); if (n 48) return; timeval tvRecv{}; gettimeofday(tvRecv, nullptr); SntpPacket resp{}; memset(resp, 0, sizeof(resp)); resp.flags (4 3) | 4; // VN4Mode4 Server resp.stratum 2; // 表示通过上游同步来的层级常用2 resp.poll req.poll; resp.precision -20; // 系统时钟精度约为微秒级 resp.root_delay 0; resp.root_dispersion 0; resp.ref_id htonl(0x4C4F434C); // LOCL double refTime currentNtpTime(); fillTimestamp(refTime, resp.ref_ts_sec, resp.ref_ts_frac); resp.origin_ts_sec req.trans_ts_sec; resp.origin_ts_frac req.trans_ts_frac; double t2 tvRecv.tv_sec kNtpEpochOffset tvRecv.tv_usec / 1000000.0; fillTimestamp(t2, resp.recv_ts_sec, resp.recv_ts_frac); double t3 currentNtpTime(); fillTimestamp(t3, resp.trans_ts_sec, resp.trans_ts_frac); sendto(fd, resp, sizeof(resp), 0, (sockaddr*)client, len); }注意recvfrom返回后立刻取tvRecv这个值对应 T2。而在sendto之前立刻取 T3。这两个取点位置是服务端精度的关键。很多人觉得反正服务端在局域网里几十微秒的误差无所谓但多台设备同时校时的时候几十微秒的系统调用时间会形成系统性偏移。服务端绑定端口时使用bind绑定 0.0.0.0:123。123 端口是特权端口Linux 下需要 root 权限。非 root 用户可以绑定 1023 以上端口做测试此时客户端要对应修改目的端口。3.3 并发与多客户端场景下的实现选择SNTP 服务端天然是无状态的UDP 也没有连接概念所以最简单的实现就是单线程recvfrom - 处理 - sendto死循环。在嵌入式环境下这种模式完全够用因为一个包的处理工作在微秒量级甚至比网卡中断开销还要低。如果客户端数量很大或者处理函数里额外做了日志、数据库写入单线程可能变成瓶颈。这时可以用 SO_REUSEPORT 开多个 socket让内核做负载均衡或者改用select/epoll配合非阻塞 socket。但说实话对 SNTP 这种轻量协议并发压力通常不在协议处理而在于日志打印。我在写高利用率服务时会把每条请求日志改成采样打印比如每 1000 条打印一条否则磁盘 IO 先撑不住。4. 工程组织、编译与调试4.1 源码目录和CMake配置整个工程我按最小可复用方式组织没有引入第三方库只依赖 POSIX socket 接口所以大部分 Linux 和类 Unix 系统都能直接编译。sntp/ ├── include/ │ └── sntp.h ├── src/ │ ├── client.cpp │ └── server.cpp └── CMakeLists.txtCMakeLists.txt核心配置如下cmake_minimum_required(VERSION 3.10) project(sntp CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(sntp_client src/client.cpp) add_executable(sntp_server src/server.cpp)编译时只需要执行mkdir build cd build cmake .. make如果你的目标平台是纯 C 环境可以去掉 C 特性把所有函数改成 C 版本用到的memset、socket等函数在 C 语言里同样可用。4.2 本地自测与抓包验证调试网络协议最好的工具是抓包。先在本地启动服务端sudo ./sntp_server然后开另一个终端跑客户端./sntp_client 127.0.0.1如果一切正常客户端会输出类似offset0.003214s delay0.000128s的结果。为了验证服务端是否被标准工具识别可以使用 Linux 自带的ntpdatesudo ntpdate -q 127.0.0.1输出中的offset应该和你自己的客户端算出来的结果接近。如果差异很大说明某个时间戳字段填错了。想看得更细可以用tcpdump在 lo 口抓包sudo tcpdump -i lo udp port 123 -XX观察请求包里的 Mode 是不是 3响应包里的 Mode 是不是 4Origin Timestamp 是否和请求的 Transmit Timestamp 一致。抓包能直观暴露字段错位、字节序错误等问题。4.3 精度测试和结果解读我在虚拟机和真实板子上都跑过测试。虚拟机里由于 CPU 调度中断不稳定偏移量抖动通常在几毫秒到十几毫秒真实物理机上局域网内偏移量可以稳定在 1 毫秒以内。因为服务端和客户端走的是同一个交换机网络延迟极小主要误差来源变成了系统调用取时间戳的延迟。如果你发现单次采样偏移量忽大忽小不要马上去怀疑服务端先做多组采样看分布。正常情况应该是围绕某个值对称波动如果整体向一个方向偏比如客户端时间总是慢 20 毫秒那很可能某个时间戳取点位置偏了或者系统调用顺序不对。还有一种常见情况是 CPU 处于省电模式gettimeofday在频率切换时会有毛刺可以先把 CPU 频率锁到性能模式再测。5. 常见问题与排查速查5.1 高频问题速查表现象可能原因解决办法客户端一直超时服务端没启动或端口被防火墙拦了先确认服务端进程存在再检查ss -ulnp是否监听 123 端口响应包收到但校验失败有别的服务占用 123 端口或报文被中间设备修改抓包看响应模式字段切换测试端口验证时间偏移量偏大且方向固定T2/T3 取点位置不对或 set 时间失败检查服务端代码里取时间戳是否紧贴 recvfrom/sendto系统时间没变化权限不足或代码只打印没有调校时函数用 root 运行或改用adjtime渐进校正连续多次对时结果相差上百毫秒网络抖动或 CPU 频率缩放多次取最小延迟样本物理机锁频测试响应中 Stratum 为 0服务端自己处于未同步状态检查服务端依赖的时间源是否可靠5.2 几个容易忽略的细节第一个是闰秒。NTP 时间戳本身不考虑闰秒闰秒由操作系统内核通过特殊机制注入。应用层写 SNTP 时不需要专门处理闰秒只需要确保改动时间时不要用本地时间误当成 UTC。clock_settime和settimeofday都要求 UTC 时间时区转换交给 C 库的本地化逻辑处理。第二个是 2036 年问题。NTP 的 64 位时间戳中高位 32 位秒字段会在 2036 年回绕一次。目前主流系统通过 ERA 概念处理这个问题我们这套实现基于 1900 年基准至少到 2100 年之前都不会有实际影响但写代码时还是用无符号数处理时间戳差值避免有符号溢出。第三个是容器环境。在 Docker 容器里跑 SNTP 客户端时容器默认继承宿主机的时间也可能被运行时设置了时间偏移。如果容器内clock_settime报权限错误通常是缺少CAP_SYS_TIME此时不能改容器时钟只能把偏差上报给上层的编排系统由宿主机侧统一校时。最后再分享一个小技巧调试阶段别急着把偏移量直接写进系统时钟。我习惯让客户端先跑一整天记录偏差曲线确认服务端稳定后再打开自动校正开关。这样做的好处是避免因为服务端临时抽风把整批设备的时钟全部带偏。SNTP 虽然简单但线上环境里“对时比不对时更糟”的情况是真实存在的多一分谨慎少一堆事故。本文还有配套的精品资源点击获取