无论PC端还是移动端系统都自带时间同步功能,基于的都是NTP协议,这里使用C#来实现基于NTP协议的网络校时功能(也就是实现时间同步)。
1、NTP原理
NTP【Network Time Protocol】是用来使计算机时间同步化的一种协议,它可以使计算机对其服务器或时钟源(如石英钟,GPS等等)做同步化,它可以提供高精准度的时间校正(LAN上与标准间差小于1毫秒,WAN上几十毫秒),且可介由加密确认的方式来防止恶毒的协议攻击。
先介绍下NTP数据包格式(其标准化文档为RFC2030,NTP版本是第4版本):
1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |LI | VN |Mode | Stratum | Poll | Precision | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Root Delay | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Root Dispersion | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Reference Identifier | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | | | Reference Timestamp (64) | | | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | | | Originate Timestamp (64) | | | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | | | Receive Timestamp (64) | | | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | | | Transmit Timestamp (64) | | | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Key Identifier (optional) (32) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | | | | | Message Digest (optional) (128) | | | | | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
其中协议字段的含义如下所示:
LI:跳跃指示器,警告在当月最后一天的最终时刻插入的迫近闺秒(闺秒)。0表示无警告,1表示最后一分钟有61秒,2表示最后一分钟有59秒,3表示告警状态,时钟未被同步。
VN:版本号。这里是4。
Mode:工作模式。该字段包括以下值:0-预留;1-对称行为;3-客户机;4-服务器;5-广播;6-NTP控制信息。NTP协议具有3种工作模式,分别为主/被动对称模式、客户/服务器模式、广播模式。在主/被动对称模式中,有一对一的连接,双方均可同步对方或被对方同步,先发出申请建立连接的一方工作在主动模式下,另一方工作在被动模式下; 客户/服务器模 式与主/被动模式基本相同,惟一区别在于客户方可被服务器同步,但服务器不能被客户同步;在广播模式中,有一对多的连接,服务器不论客户工作 在何种模式下,都会主动发出时间信息,客户根据此信息调整自己的时间。
Stratum:对本地时钟级别的整体识别。
Poll:有符号整数表示连续信息间的最大间隔。
Precision:有符号整数表示本地时钟精确度。
Root Delay:表示到达主参考源的一次往复的总延迟,它是有15~16位小数部分的符号定点小 数。
Root Dispersion:表示一次到达主参考源的标准误差,它是有15~16位小数部分的无符号 定点小数。
Reference Identifier:识别特殊参考源。
Originate Timestamp:NTP请求报文离开发送端是发送端的本地时间,采用64位时标格式。
Receive Timestamp:NTP请求报文接收到时接收端的本地时间,采用64位时标格式。
Transmit Timestamp:这是应答报文离开应答者时应答者的本地时间,采用64位时标格式。
这里采用的是客户端请求服务器的模式,所以只介绍客户端模式报文发送,可选项不需要,如下
字段名称 单播 请求报文 响应报文 ------------------------------------------------ LI 0 0-2 VN 4 3-4 Mode 3 4 Stratum 0 1-14 Poll 0 ignore Precision 0 ignore Root Delay 0 ignore Root Dispersion 0 ignore Reference Identifier 0 ignore Reference Timestamp 0 ignore Originate Timestamp 0 请求报文发送时间(T1) Receive Timestamp 0 请求报文到达服务端时间(T2) Transmit Timestamp 本地时间(T1) 服务端应答报文离开时服务端本地时间(T3)
可以看到客户端发送本地时间(T1)过去后,服务端响应报文会将客户端报文发送时间放在字段Originate Timestamp字段中发回来,同时报文中带有请求报文到达服务端的时间(T2)和服务端应答报文离开服务端时的服务端时间(T3),而客户端接收到来自服务端发送的响应报文时的本地时间为T4,根据这四个参数可以计算:
NTP报文的往返时延delay=(T4-T1)-(T3-T2)
客户端与服务端时间差(时钟补偿)offset=((T2-T1)+(T3-T4))/2
以上时间差计算是假定报文往返相同的情况下,如果请求报文时延和响应报文所花费时间不一致,则计算的时间差offset并不准确(一般来说肯定有误差,误差最大为往返时延的1/2),但这点精度还在容忍范围。如此可以计算服务器端时间ServerTime = LocalTime + offset。
2、代码实现
2.1 报文构造
前面已经讲过,发送的报文Mode为3,版本为4,发送时间是本地时间,其余字段为0,代码如下(可选项不用)
1 private const byte NTPDataLength = 48; 2 // NTP 数据包 (基于RFC 2030) 3 byte[] NTPData = new byte[NTPDataLength]; 4 5 //NTP数据包初始化 6 private void Initialize() 7 { 8 //版本4,模式客户端(3) 9 NTPData[0] = 0x1B; 10 //其他初始化为0 11 for (int i = 1; i < 48; i++) 12 { 13 NTPData[i] = 0; 14 } 15 //发送端本地时间 16 TransmitTimestamp = DateTime.Now; 17 }
2.2报文发送
NTP协议基于UDP,端口号为123,报文构造好后则发送报文,需要先获取NTP服务器端地址,百度搜索下第一个就是豆瓣的,笔者使用的是上海交通大学网络中心NTP服务器地址ntp.sjtu.edu.cn,参照国外一位作者的代码(该代码写于2001年,后续笔者会对该代码进行部分改动并封装,后面会放出改动的代码),通过域名解析的方式获得IP地址,然后进行连接。
1 //在DNS服务器中查询NTP服务器的IP 地址(这里就不要输入IP地址了,否则报错) 2 IPHostEntry hostadd = Dns.GetHostEntry(TimeServer); 3 IPEndPoint EPhost = new IPEndPoint(hostadd.AddressList[0], 123); 4 5 //连接NTP服务器 6 UdpClient TimeSocket = new UdpClient(); 7 TimeSocket.Connect(EPhost); 8 9 //初始化NTP数据报文 10 Initialize(); 11 //发送NTP报文 12 TimeSocket.Send(NTPData, NTPData.Length);
2.3报文接收
报文接收后,首先要记录接收报文时的本地时间,代码非常简单,如下
1 NTPData = TimeSocket.Receive(ref EPhost); 2 //记录接收到报文时的本地时间 3 ReceptionTimestamp = DateTime.Now;
2.4报文解析
首先介绍下时间格式,如下所示,时间分为秒和秒的小数部分,左边是高位,右边是低位,代码如下:
1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Seconds | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Seconds Fraction (0-padded) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
1 private ulong GetMilliSeconds(byte offset) 2 { 3 ulong intpart = 0, fractpart = 0; 4 5 for (int i = 0; i <= 3; i++) 6 { 7 intpart = 256 * intpart + NTPData[offset + i]; 8 } 9 for (int i = 4; i <= 7; i++) 10 { 11 fractpart = 256 * fractpart + NTPData[offset + i]; 12 } 13 14 ulong milliseconds = intpart * 1000 + (fractpart * 1000) / 0x100000000L; 15 return milliseconds; 16 }
主要讲解下秒的小数部分的表示,小数部分由32位整数表示,如果全部为1,并除以以0x100000000,也就是0xFFFFFFFF/0x100000000=0.999999999767(后面的就省略了),可以看到通过换算小数部分最大值可以精确表示到0.999999999,也就是纳秒级别,这里忽略了大约200多皮秒的时间。对我们来说,只要毫秒时间可以了,所以毫秒计算公式为
milliseconds = 1000* fraction / 0x100000000
获得总毫秒时间后换算为具体年月日时间,代码如下
1 private DateTime ComputeDate(ulong milliseconds) 2 { 3 TimeSpan span =TimeSpan.FromMilliseconds((double)milliseconds); 4 DateTime time = new DateTime(1900, 1, 1); 5 time += span; 6 return time; 7 }
基于此,计算上面所讲的T1、T2、T3
1 // T1 请求报文客户端时间 2 public DateTime OriginateTimestamp 3 { 4 get 5 { 6 return 7 ComputeDate(GetMilliSeconds(offOriginateTimestamp)); 8 } 9 } 10 11 // T2 接收到请求报文时服务器端时间 12 public DateTime ReceiveTimestamp 13 { 14 get 15 { 16 DateTime time = ComputeDate(GetMilliSeconds(offReceiveTimestamp)); 17 // 协调世界时转为当地时间 18 TimeSpan offspan = TimeZone.CurrentTimeZone.GetUtcOffset(DateTime.Now); 19 return time + offspan; 20 } 21 } 22 23 // T3 响应报文发送时服务器端时间 24 public DateTime TransmitTimestamp 25 { 26 get 27 { 28 DateTime time = ComputeDate(GetMilliSeconds(offTransmitTimestamp)); 29 // 协调世界时转为当地时间 30 TimeSpan offspan = TimeZone.CurrentTimeZone.GetUtcOffset(DateTime.Now); 31 return time + offspan; 32 } 33 }
这样可以计算得到时钟补偿offset = (ReceiveTimestamp - OriginateTimestamp) - (ReceptionTimestamp - TransmitTimestamp)
3、代码封装
代码封装基于原国外代码基础之上,重复造车轮意义不大,原代码没有进行时钟补偿,直接使用了服务器端发送时间即 TransmitTimestamp(T3),对其封装后直接获取当前时间就可以了,不用再做修改了,代码如下(比较简单,就没有注释了)
1 public class BeijingTime 2 { 3 private const string HOST = "ntp.sjtu.edu.cn"; 4 5 private static BeijingTime _instance = null; 6 private NTPClient _client; 7 8 private TimeSpan _tsClock = new TimeSpan(0); 9 10 private bool _IsConnect = false; //没有建立连接 11 12 private BeijingTime() 13 { 14 _client = new NTPClient(HOST); 15 } 16 17 public bool IsConnect 18 { 19 get { return _IsConnect; } 20 } 21 22 public DateTime BeijingTimeNow 23 { 24 get { return DateTime.Now.Add(_tsClock); } 25 } 26 27 /// <summary> 28 /// 设置本地时间,返回失败可能是因为权限不足,请在管理员权限下使用 29 /// </summary> 30 /// <param name="dtLocal"></param> 31 /// <returns></returns> 32 public bool SetLocalTime(DateTime dtLocal) 33 { 34 return _client.SetTime(dtLocal); 35 } 36 37 public bool Connect() 38 { 39 try 40 { 41 _client.Connect(); 42 _IsConnect = true; 43 _tsClock = new TimeSpan(_client.LocalClockOffset); 44 45 return true; 46 } 47 catch (Exception) 48 { 49 _IsConnect = false; 50 return false; 51 } 52 } 53 54 public static BeijingTime Instance 55 { 56 get 57 { 58 if (_instance == null) 59 { 60 _instance = new BeijingTime(); 61 } 62 63 return _instance; 64 } 65 } 66 }
4、测试结果
下载封装好的代码,如下方式调用
1 static void Main(string[] args) 2 { 3 Utility.BeijingTime beijing = Utility.BeijingTime.Instance; 4 beijing.Connect(); 5 Console.WriteLine(string.Format("时钟补偿:{0:f6}",(beijing.BeijingTimeNow - DateTime.Now).TotalSeconds)); 6 Console.WriteLine(string.Format("本地时间:{0}",beijing.BeijingTimeNow.ToString())); 7 Console.ReadLine(); 8 }
结果如下:因为本身使用Windows自带同步功能同步过,所以结果还是蛮精确的
5、后记
网上虽然有很多相关介绍的文章,但个别地方讲的并不仔细,大多代码也不能直接拿来用,就参照国外的源代码和RFC2030文档写了这篇文章,并修改了代码,方便不愿意看原理的人直接下载代码就可以使用。NTP协议内容很多,这里只讲了客户端请求服务端的方式。限于笔者个人水平,文章中难免会出现疏漏,还望指正。
参考文章
1、http://blog.sina.com.cn/s/blog_772ee6f30100pbzw.html
2、http://www.ietf.org/rfc/rfc2030.txt
3、http://blog.163.com/yzc_5001/blog/static/2061963420121283050787/