在之前写了两篇关于NTP的文章,一篇是使用NTP协议实现本地时间跟NTP授时服务器进行同步介绍了NTP协议的原理,另一篇是实现一个时间同步客户端NTPClock介绍了使用WPF编写一个模仿NTPClock的时间同步的App,因为Windows的自动较时功能不是很完善,所以这个应用我自己使用了很长时间。

在这篇文章里,我准备将前面两篇文章总结一下,对一些方法进行重构,另外实现一个利用NTP来实现授时的服务端,以完善整个功能。一般的我们都是使用一些公开的NTP授时服务地址,比如"time.windows.com"、"pool.ntp.org"等来获取时间,这有一些限制:

  • 必须要求这个服务器能够访问和解析这些授时服务器地址,这在有些机房的内网计算机上其实是无法满足条件的。
  • 这些公开的NTP授时服务器大多有请求频率限制,如果请求次数多过或过快,可能会请求失败。

有时候,我们有自己的GPS接收装置或者原子钟能够获取准确的时间,那么就可以在本地搭建一个NTP授时服务器,其它的计算机如果需要时间同步,只需要利用NTP协议给这台NTP授时服务器发送信息即可。本文重新简单实现一个各个部分,包括一个NTPCore类库、一个NTPClientApp时间同步客户端和一个NTPServerApp授时服务端,下面就简单介绍一下各模块的功能。

NTP协议及相关实现


这部分核心就是对NTP协议的实现,包括一个NtpPacket,NtpClient和一个SimpleNtpServer,这些放在名为NTPCore的类库中。

NtpPacket


它的核心是一个48字节的NtpPacket包。详细代码如下:

/// <summary>
/// 表示一个NTP数据包。
/// </summary>
public class NtpPacket
{
    // NTP包的各个字段偏移量
    private const byte OffLeapIndicator = 0;
    private const byte OffVersionNumber = 0;
    private const byte OffMode = 0;
    private const byte OffStratum = 1;
    private const byte OffPollInterval = 2;
    private const byte OffPrecision = 3;
    private const byte OffRootDelay = 4;
    private const byte OffRootDispersion = 8;
    private const byte OffReferenceId = 12;
    private const byte OffReferenceTimestamp = 16;
    private const byte OffOriginateTimestamp = 24;
    private const byte OffReceiveTimestamp = 32;
    private const byte OffTransmitTimestamp = 40;

    public const int PacketLength = 48; // NTP包的标准长度

    private byte[] _data = new byte[PacketLength];

    public byte LeapIndicator
    {
        get => (byte)((_data[OffLeapIndicator] >> 6) & 0x03);
        set => _data[OffLeapIndicator] = (byte)((_data[OffLeapIndicator] & 0x3F) | (value << 6));
    }

    public byte VersionNumber
    {
        get => (byte)((_data[OffVersionNumber] >> 3) & 0x07);
        set => _data[OffVersionNumber] = (byte)((_data[OffVersionNumber] & 0xC7) | (value << 3));
    }

    public byte Mode
    {
        get => (byte)(_data[OffMode] & 0x07);
        set => _data[OffMode] = (byte)((_data[OffMode] & 0xF8) | value);
    }

    public byte Stratum
    {
        get => _data[OffStratum];
        set => _data[OffStratum] = value;
    }

    public sbyte PollInterval
    {
        get => (sbyte)_data[OffPollInterval];
        set => _data[OffPollInterval] = (byte)value;
    }

    public sbyte Precision
    {
        get => (sbyte)_data[OffPrecision];
        set => _data[OffPrecision] = (byte)value;
    }

    public uint RootDelay
    {
        get => GetUInt32BE(OffRootDelay);
        set => SetUInt32BE(OffRootDelay, value);
    }

    public uint RootDispersion
    {
        get => GetUInt32BE(OffRootDispersion);
        set => SetUInt32BE(OffRootDispersion, value);
    }

    public uint ReferenceId
    {
        get => GetUInt32BE(OffReferenceId);
        set => SetUInt32BE(OffReferenceId, value);
    }

    public NtpTimestamp ReferenceTimestamp
    {
        get => GetNtpTimestamp(OffReferenceTimestamp);
        set => SetNtpTimestamp(OffReferenceTimestamp, value);
    }

    public NtpTimestamp OriginateTimestamp
    {
        get => GetNtpTimestamp(OffOriginateTimestamp);
        set => SetNtpTimestamp(OffOriginateTimestamp, value);
    }

    public NtpTimestamp ReceiveTimestamp
    {
        get => GetNtpTimestamp(OffReceiveTimestamp);
        set => SetNtpTimestamp(OffReceiveTimestamp, value);
    }

    public NtpTimestamp TransmitTimestamp
    {
        get => GetNtpTimestamp(OffTransmitTimestamp);
        set => SetNtpTimestamp(OffTransmitTimestamp, value);
    }

    public NtpTimestamp DestinationTimestamp { get; set; }

    public NtpPacket()
    {
        VersionNumber = 4;
        Mode = 3; // Client
        TransmitTimestamp = new NtpTimestamp(DateTime.UtcNow);
    }

    public NtpPacket(byte[] packetData)
    {
        if (packetData == null || packetData.Length < PacketLength)
            throw new ArgumentException($"NTP packet data must be at least {PacketLength} bytes long.");
        Buffer.BlockCopy(packetData, 0, _data, 0, PacketLength);
    }

    public byte[] ToByteArray()
    {
        byte[] buffer = new byte[PacketLength];
        Buffer.BlockCopy(_data, 0, buffer, 0, PacketLength);
        return buffer;
    }

    private uint GetUInt32BE(int offset)
    {
        return ((uint)_data[offset] << 24) |
               ((uint)_data[offset + 1] << 16) |
               ((uint)_data[offset + 2] << 8) |
               _data[offset + 3];
    }

    private void SetUInt32BE(int offset, uint value)
    {
        _data[offset] = (byte)(value >> 24);
        _data[offset + 1] = (byte)(value >> 16);
        _data[offset + 2] = (byte)(value >> 8);
        _data[offset + 3] = (byte)value;
    }

    private NtpTimestamp GetNtpTimestamp(int offset)
    {
        uint seconds = GetUInt32BE(offset);
        uint fraction = GetUInt32BE(offset + 4);
        return new NtpTimestamp(seconds, fraction);
    }

    private void SetNtpTimestamp(int offset, NtpTimestamp timestamp)
    {
        SetUInt32BE(offset, timestamp.Seconds);
        SetUInt32BE(offset + 4, timestamp.Fraction);
    }

    public TimeSpan CalculateOffset()
    {
        DateTime t1 = OriginateTimestamp.ToDateTime();
        DateTime t2 = ReceiveTimestamp.ToDateTime();
        DateTime t3 = TransmitTimestamp.ToDateTime();
        DateTime t4 = DestinationTimestamp.ToDateTime();

        if (t1 == NtpTimestamp.NtpEpoch || t2 == NtpTimestamp.NtpEpoch ||
            t3 == NtpTimestamp.NtpEpoch || t4 == NtpTimestamp.NtpEpoch ||
            t1 > t4 || t2 > t3)
        {
            throw new InvalidOperationException($"Cannot calculate offset due to invalid or incomplete timestamps. T1:{t1}, T2:{t2}, T3:{t3}, T4:{t4}");
        }

        TimeSpan combinedSpan = (t2 - t1) + (t3 - t4);
        TimeSpan offset = TimeSpan.FromTicks(combinedSpan.Ticks / 2L);
        return offset;
    }

    public TimeSpan CalculateRoundtripDelay()
    {
        DateTime t1 = OriginateTimestamp.ToDateTime();
        DateTime t2 = ReceiveTimestamp.ToDateTime();
        DateTime t3 = TransmitTimestamp.ToDateTime();
        DateTime t4 = DestinationTimestamp.ToDateTime();

        if (t1 == NtpTimestamp.NtpEpoch || t2 == NtpTimestamp.NtpEpoch ||
            t3 == NtpTimestamp.NtpEpoch || t4 == NtpTimestamp.NtpEpoch ||
            t1 > t4 || t2 > t3)
        {
            throw new InvalidOperationException($"Cannot calculate roundtrip delay due to invalid or incomplete timestamps. T1:{t1}, T2:{t2}, T3:{t3}, T4:{t4}");
        }

        TimeSpan delay = (t4 - t1) - (t3 - t2);
        if (delay < TimeSpan.Zero)
        {
            return TimeSpan.MaxValue;
        }
        return delay;
    }

    public override string ToString()
    {
        string offsetStr = "N/A";
        string delayStr = "N/A";
        try
        {
            offsetStr = $"{CalculateOffset().TotalMilliseconds:F3} ms";
        }
        catch (InvalidOperationException) { /* Keep N/A */ }

        try
        {
            TimeSpan delay = CalculateRoundtripDelay();
            delayStr = delay == TimeSpan.MaxValue ? "N/A (Invalid Timestamps)" : $"{delay.TotalMilliseconds:F3} ms";
        }
        catch (InvalidOperationException) { /* Keep N/A */ }

        return $"LI: {LeapIndicator}, VN: {VersionNumber}, Mode: {Mode}, Stratum: {Stratum}\n" +
               $"Poll: {PollInterval}, Precision: {Precision}\n" +
               $"Root Delay: {RootDelay / 65536.0:F3}s, Root Dispersion: {RootDispersion / 65536.0:F3}s\n" +
               $"Reference ID: 0x{ReferenceId:X8}\n" +
               $"Reference Timestamp: {ReferenceTimestamp}\n" +
               $"Originate Timestamp (T1): {OriginateTimestamp}\n" +
               $"Receive Timestamp   (T2): {ReceiveTimestamp}\n" +
               $"Transmit Timestamp  (T3): {TransmitTimestamp}\n" +
               (DestinationTimestamp.ToDateTime() == NtpTimestamp.NtpEpoch ? "" : $"Destination Timestamp (T4): {DestinationTimestamp}\n") +
               $"Calculated Offset: {offsetStr}\n" +
               $"Calculated Roundtrip Delay: {delayStr}";
    }
}

/// <summary>
/// 表示NTP时间戳。
/// NTP时间戳是64位无符号定点数,整数部分在前32位,小数部分在后32位。
/// NTP的纪元是1900年1月1日。
/// </summary>
public struct NtpTimestamp
{
    public static readonly DateTime NtpEpoch = new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc);
    private const ulong NtpTimestampToSecondsRatio = 0x100000000UL; // 2^32

    public uint Seconds { get; }
    public uint Fraction { get; }

    public NtpTimestamp(DateTime dateTime)
    {
        if (dateTime.Kind == DateTimeKind.Local)
        {
            dateTime = dateTime.ToUniversalTime();
        }
        else if (dateTime.Kind == DateTimeKind.Unspecified)
        {
            dateTime = DateTime.SpecifyKind(dateTime, DateTimeKind.Utc);
        }

        if (dateTime < NtpEpoch)
        {
            Seconds = 0;
            Fraction = 0;
        }
        else
        {
            TimeSpan span = dateTime - NtpEpoch;
            Seconds = (uint)span.TotalSeconds;
            Fraction = (uint)((span.TotalMilliseconds - (Seconds * 1000.0)) * (NtpTimestampToSecondsRatio / 1000.0));
        }
    }

    public NtpTimestamp(uint seconds, uint fraction)
    {
        Seconds = seconds;
        Fraction = fraction;
    }

    public DateTime ToDateTime()
    {
        if (Seconds == 0 && Fraction == 0) // NTP的空时间戳或1900年以前的时间
        {
            return NtpEpoch;
        }
        double milliseconds = Seconds * 1000.0 + (Fraction / (double)NtpTimestampToSecondsRatio) * 1000.0;
        return NtpEpoch.AddMilliseconds(milliseconds);
    }

    public override string ToString() => ToDateTime().ToString("yyyy-MM-dd HH:mm:ss.fff UTC");
}

当客户端使用时,只需要实例化一个NtpPacket,它的构造函数默认设置为了客户端请求,并把TransmitTimestamp时间设置为了客户端发送时的时间。实例化完整NtpPacket之后,等待接收服务端返回的NtpPacket对象,此时这个NtpPacket对象里面已经包含了服务器的相关事件信息,客户端只需要调用NtpPacket的CalculateOffset即可获得客户端时间与服务端时间的差值,只需要将这个差值加上本地时间,就可以得到正确的服务端时间。

NtpClient


NtpClient代码如下:

 class NtpClient
{
    private readonly string _serverAddress;
    private readonly int _port;
    private readonly int _timeoutMilliseconds;
    private readonly ILogger _logger;
    private const int MaxRetries = 3; // Maximum number of retries
    private const int RetryDelayMilliseconds = 1000; // Delay between retries

    public NtpClient(string serverAddress, int port = 123, int timeoutMilliseconds = 5000, ILogger logger = null)
    {
        _serverAddress = serverAddress ?? throw new ArgumentNullException(nameof(serverAddress));
        _port = port;
        _timeoutMilliseconds = timeoutMilliseconds;
        _logger = logger ?? new NullLogger(); // Use a NullLogger if no logger is provided
    }

    public async Task<NtpPacket> GetTimeAsync()
    {
        int attempts = 0;
        while (attempts < MaxRetries)
        {
            attempts++;
            _logger.Debug($"Attempt {attempts}/{MaxRetries} to get time from {_serverAddress}.");
            try
            {
                using (var udpClient = new UdpClient())
                {
                    IPAddress[] addresses = await Dns.GetHostAddressesAsync(_serverAddress);
                    if (addresses == null || addresses.Length == 0)
                    {
                        _logger.Warn($"Attempt {attempts}: Hostname '{_serverAddress}' could not be resolved.");
                        if (attempts >= MaxRetries) throw new ArgumentException("Hostname could not be resolved after multiple attempts.", nameof(_serverAddress));
                        await Task.Delay(RetryDelayMilliseconds); // Wait before retrying DNS resolution
                        continue;
                    }
                    IPAddress serverIp = addresses[0];
                    _logger.Debug($"Attempt {attempts}: Resolved {_serverAddress} to {serverIp}. Connecting...");
                    if (serverIp.AddressFamily == AddressFamily.InterNetworkV6)
                    {
                        _logger.Debug($"Attempt {attempts}: Attempting to connect to IPv6 address: {serverIp}");
                    }

                    udpClient.Connect(serverIp, _port);
                    var requestPacket = new NtpPacket { TransmitTimestamp = new NtpTimestamp(DateTime.UtcNow) };
                    byte[] requestBytes = requestPacket.ToByteArray();
                    await udpClient.SendAsync(requestBytes, requestBytes.Length);
                    _logger.Debug($"Attempt {attempts}: NTP request sent to {serverIp}:{_port}.");

                    var receivingTask = udpClient.ReceiveAsync();
                    if (await Task.WhenAny(receivingTask, Task.Delay(_timeoutMilliseconds)) == receivingTask)
                    {
                        if (receivingTask.IsFaulted)
                        {
                            throw receivingTask.Exception.GetBaseException(); // Let catch block handle it
                        }
                        UdpReceiveResult result = receivingTask.Result;
                        _logger.Debug($"Attempt {attempts}: Received {result.Buffer.Length} bytes from {serverIp}.");
                        var responsePacket = new NtpPacket(result.Buffer) { DestinationTimestamp = new NtpTimestamp(DateTime.UtcNow) };
                        _logger.Info($"Attempt {attempts}: Successfully received and parsed NTP packet from {_serverAddress}.");
                        return responsePacket;
                    }
                    else
                    {
                        _logger.Warn($"Attempt {attempts}: Timeout receiving data from {_serverAddress}.");
                        if (attempts >= MaxRetries) throw new TimeoutException($"Request to '{_serverAddress}' timed out after {MaxRetries} attempts and {_timeoutMilliseconds}ms per attempt.");
                    }
                }
            }
            catch (Exception ex)
            {
                _logger.Error($"Attempt {attempts} failed for {_serverAddress}.", ex);
                if (attempts >= MaxRetries)
                {
                    throw; // Rethrow the last exception to be handled by the caller
                }
            }
            if (attempts < MaxRetries)
            {
                _logger.Info($"Retrying {_serverAddress} in {RetryDelayMilliseconds}ms...");
                await Task.Delay(RetryDelayMilliseconds);
            }
        }
        _logger.Error($"All {MaxRetries} attempts to get time from {_serverAddress} failed.");
        return null; // Indicates failure after all retries
    }
}

当需要使用NTP客户端同步时间时,只需要实例化一个NtpClient,然后调用GetTimeAsync异步方法,方法返回一个NtpPacket,调用CalculateOffset方法即可返回本机与授时服务器的时间差。上述方法还设置了超时重试次数。

SimpleNtpServer


对于授时服务端,可以使用SimpleNtpServer类,代码如下:

public class SimpleNtpServer
{
    private UdpClient _listener; // Changed
    private readonly int _port;
    private volatile bool _isRunning;
    private Task _listenTask; // Changed
    public event Action<string> LogMessage; // Changed
    private readonly ILogger _logger;

    public SimpleNtpServer(int port = 123, ILogger logger = null)
    {
        _port = port;
        _logger = logger ?? new NullLogger(); // Use a NullLogger if no logger is provided
    }

    public void Start()
    {
        if (_isRunning)
        {
            LogMessage?.Invoke("Server is already running.");
            _logger.Warn("Server start requested, but it is already running.");
            return;
        }

        try
        {
            _listener = new UdpClient(new IPEndPoint(IPAddress.Any, _port));
            _isRunning = true;
            _listenTask = Task.Run((Action)ListenLoop);
            LogMessage?.Invoke($"NTP Server started on port {_port}, listening on all interfaces.");
            _logger.Info($"NTP Server started on port {_port}, listening on all interfaces.");
        }
        catch (SocketException ex)
        {
            LogMessage?.Invoke($"SocketException starting NTP server on port {_port}: {ex.Message}. (Port might be in use, e.g., by Windows Time service w32time).");
            _logger.Error($"SocketException starting NTP server on port {_port}: {ex.Message}. (Port might be in use, e.g., by Windows Time service w32time).");
            _isRunning = false;
            _listener?.Close();
            _listener = null;
        }
        catch (Exception ex)
        {
            LogMessage?.Invoke($"Error starting NTP server: {ex.Message}");
            _logger.Error($"Failed to start NTP server on port {_port}.", ex);
            _isRunning = false;
            _listener?.Close();
            _listener = null;
        }
    }

    public async Task StopAsync()
    {
        if (!_isRunning || _listener == null)
        {
            _logger.Info("Server stop requested, but it is not running or already stopped.");
            LogMessage?.Invoke("Server is not running or already stopped.");
            return;
        }
        _logger.Info("NTP Server stopping...");
        _isRunning = false;
        _listener.Close();

        if (_listenTask != null)
        {
            try
            {
                bool completed = await Task.WhenAny(_listenTask, Task.Delay(2000)) == _listenTask;
                if (!completed && _listenTask.Status != TaskStatus.RanToCompletion && _listenTask.Status != TaskStatus.Faulted && _listenTask.Status != TaskStatus.Canceled)
                {
                    _logger.Warn("NTP server listen loop did not terminate gracefully within the timeout period.");
                    LogMessage?.Invoke("Warning: Listen loop did not terminate gracefully within timeout.");
                }
            }
            catch (ObjectDisposedException) { /* Expected */ }
            catch (SocketException) { /* Expected */ }
            catch (Exception ex)
            {
                _logger.Error("Exception during server stop task waiting.", ex);
                LogMessage?.Invoke($"Exception during server stop task: {ex.Message}");
            }
        }
        _listener = null;
        _listenTask = null;
        LogMessage?.Invoke("NTP Server stopped.");
        _logger.Info("NTP Server stopped.");
    }

    private async void ListenLoop() // async void is generally for event handlers, but for Task.Run's Action, this needs careful handling on exceptions
    {
        if (_listener == null) return;
        _logger.Info("NTP Server listening loop started.");
        LogMessage?.Invoke("NTP Server listening loop started...");
        while (_isRunning)
        {
            try
            {
                UdpReceiveResult result = await _listener.ReceiveAsync();
                DateTime receiptTime = DateTime.UtcNow;

                IPEndPoint clientEndPoint = result.RemoteEndPoint;
                byte[] clientRequestBytes = result.Buffer;

                LogMessage?.Invoke($"Received {clientRequestBytes.Length} bytes from {clientEndPoint}");
                _logger.Debug($"Received {result.Buffer.Length} bytes from {clientEndPoint}.");
                if (clientRequestBytes.Length < NtpPacket.PacketLength)
                {
                    LogMessage?.Invoke($"Received undersized packet ({clientRequestBytes.Length} bytes) from {clientEndPoint}. Ignoring.");
                    _logger.Error($"Received undersized packet ({clientRequestBytes.Length} bytes) from {clientEndPoint}. Ignoring.");
                    continue;
                }

                var clientPacket = new NtpPacket(clientRequestBytes);
                LogMessage?.Invoke($"Client Packet (VN:{clientPacket.VersionNumber}, Mode:{clientPacket.Mode}, T1_client:{clientPacket.TransmitTimestamp})");
                _logger.Debug($"Client Packet (VN:{clientPacket.VersionNumber}, Mode:{clientPacket.Mode}, T1_client:{clientPacket.TransmitTimestamp})");

                if (clientPacket.Mode != 3)
                {
                    _logger.Warn($"Received packet with non-client mode ({clientPacket.Mode}) from {clientEndPoint}. Ignoring.");
                    LogMessage?.Invoke($"Received packet with non-client mode ({clientPacket.Mode}) from {clientEndPoint}. Ignoring.");
                    continue;
                }

                var responsePacket = new NtpPacket
                {
                    LeapIndicator = 0,
                    VersionNumber = clientPacket.VersionNumber,
                    Mode = 4, // Server mode
                    Stratum = 2, // Secondary reference
                    PollInterval = clientPacket.PollInterval,
                    Precision = -20, // Approx. 1 microsecond precision
                    RootDelay = 0, // Placeholder
                    RootDispersion = (uint)(10 * 65536 / 1000), // 10ms dispersion placeholder
                    ReferenceId = BitConverter.ToUInt32(IPAddress.Parse("127.0.0.1").GetAddressBytes(), 0), // 'LOCL'
                    ReferenceTimestamp = new NtpTimestamp(DateTime.UtcNow.AddMinutes(-5)), // Pretend last sync
                    OriginateTimestamp = clientPacket.TransmitTimestamp, // T1 from client
                    ReceiveTimestamp = new NtpTimestamp(receiptTime), // T2
                    TransmitTimestamp = new NtpTimestamp(DateTime.UtcNow) // T3
                };
                byte[] responseBytes = responsePacket.ToByteArray();
                await _listener.SendAsync(responseBytes, responseBytes.Length, clientEndPoint);
                LogMessage?.Invoke($"Sent response to {clientEndPoint}. T1_client:{responsePacket.OriginateTimestamp}, T2_server:{responsePacket.ReceiveTimestamp}, T3_server:{responsePacket.TransmitTimestamp}");
                _logger.Info($"Sent NTP response to {clientEndPoint}.");
            }
            catch (ObjectDisposedException)
            {
                if (_isRunning)
                {
                    LogMessage?.Invoke("NTP Listener socket was unexpectedly disposed while still supposed to be running.");
                    _logger.Info("NTP Listener socket was unexpectedly disposed while still supposed to be running.");
                }
                else
                {
                    LogMessage?.Invoke("NTP Listener socket closed (server stopping).");
                    _logger.Info("NTP Listener socket closed (server stopping). Loop terminating.");
                }

                break;
            }
            catch (SocketException ex)
            {
                if (_isRunning)
                {
                    LogMessage?.Invoke($"SocketException in listen loop: {ex.Message} (ErrorCode: {ex.SocketErrorCode}). Server continues to run.");
                    _logger.Info($"SocketException in listen loop: {ex.Message} (ErrorCode: {ex.SocketErrorCode}). Server continues to run.");
                    await Task.Delay(100);
                }
                else
                {
                    LogMessage?.Invoke($"SocketException in listen loop (server stopping): {ex.Message}");
                    _logger.Info($"NTP Listener socket exception (server stopping): {ex.Message}. Loop terminating.");
                    break;
                }
            }
            catch (Exception ex) // Catch all exceptions in the async void method to prevent app crash
            {
                LogMessage?.Invoke($"Unhandled error in NTP listen loop: {ex.Message}");
                _logger.Error($"Unhandled error in NTP listen loop: {ex.Message}");
                if (!_isRunning) break;
                await Task.Delay(1000);
            }
        }
        LogMessage?.Invoke("NTP Server listening loop ended.");
        _logger.Info("NTP Server listening loop ended.");
    }
}

基本就是在本地开启一个Udp监听,接收客户端传过来的NtpPacket包,然后返回一个回复的NtpPacket,在回复的包中,填写一些服务端相关的字段和设置。

NTPClientApp


NTPClientApp是一个时间同步客户端,它引用上面的NTPCore库。

界面设计如下:

<Window x:Class="NTPClientApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:NTPClientApp"
        mc:Ignorable="d"
        Title="NTP 时间同步客户端 (v1.1)" Height="720" Width="850" MinHeight="600" MinWidth="700"
        Background="#FFF0F0F0" Foreground="#FF333333" FontFamily="Segoe UI">
    <Window.Resources>
        <Style TargetType="Button">
            <Setter Property="Background" Value="#FF0078D4"/>
            <Setter Property="Foreground" Value="White"/>
            <Setter Property="BorderThickness" Value="0"/>
            <Setter Property="Padding" Value="12,6"/>
            <Setter Property="Margin" Value="5"/>
            <Setter Property="Cursor" Value="Hand"/>
            <Setter Property="MinWidth" Value="80"/>
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="Button">
                        <Border Background="{TemplateBinding Background}" CornerRadius="3" SnapsToDevicePixels="True">
                            <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
                        </Border>
                        <ControlTemplate.Triggers>
                            <Trigger Property="IsMouseOver" Value="True">
                                <Setter Property="Background" Value="#FF005A9E"/>
                            </Trigger>
                            <Trigger Property="IsPressed" Value="True">
                                <Setter Property="Background" Value="#FF004578"/>
                            </Trigger>
                            <Trigger Property="IsEnabled" Value="False">
                                <Setter Property="Background" Value="#FFCCCCCC"/>
                                <Setter Property="Foreground" Value="#FF666666"/>
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
        <Style TargetType="TextBox">
            <Setter Property="Padding" Value="5,3"/>
            <Setter Property="Margin" Value="5"/>
            <Setter Property="BorderBrush" Value="#FFABADB3"/>
            <Setter Property="BorderThickness" Value="1"/>
            <Setter Property="VerticalContentAlignment" Value="Center"/>
            <Setter Property="MinHeight" Value="28"/>
        </Style>
        <Style TargetType="ListBox">
            <Setter Property="Margin" Value="5"/>
            <Setter Property="BorderBrush" Value="#FFABADB3"/>
            <Setter Property="BorderThickness" Value="1"/>
        </Style>
        <Style TargetType="ComboBox">
            <Setter Property="Padding" Value="5,3"/>
            <Setter Property="Margin" Value="5"/>
            <Setter Property="VerticalContentAlignment" Value="Center"/>
            <Setter Property="MinHeight" Value="28"/>
        </Style>
        <Style TargetType="TextBlock">
            <Setter Property="Margin" Value="5,0"/>
            <Setter Property="VerticalAlignment" Value="Center"/>
        </Style>
        <Style TargetType="GroupBox">
            <Setter Property="Padding" Value="10"/>
            <Setter Property="Margin" Value="5"/>
            <Setter Property="BorderBrush" Value="#FFABADB3"/>
            <Setter Property="BorderThickness" Value="1"/>
            <Setter Property="HeaderTemplate">
                <Setter.Value>
                    <DataTemplate>
                        <TextBlock Text="{Binding}" FontWeight="SemiBold" FontSize="14" Margin="0,0,0,5"/>
                    </DataTemplate>
                </Setter.Value>
            </Setter>
        </Style>
        <Style TargetType="RadioButton">
            <Setter Property="VerticalAlignment" Value="Center"/>
            <Setter Property="Margin" Value="0,0,10,0"/>
        </Style>
    </Window.Resources>

    <Grid Margin="10">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <!-- Server Management -->
            <RowDefinition Height="Auto"/>
            <!-- Sync Settings -->
            <RowDefinition Height="Auto"/>
            <!-- Status Info -->
            <RowDefinition Height="*"/>
            <!-- Log Output -->
            <RowDefinition Height="Auto"/>
            <!-- Status Bar -->
        </Grid.RowDefinitions>

        <GroupBox Header="NTP 服务器管理" Grid.Row="0">
            <StackPanel>
                <DockPanel Margin="0,0,0,10">
                    <Button x:Name="RemoveServerButton" DockPanel.Dock="Right" Content="移除选中" Click="RemoveServer_Click" Padding="10,3"/>
                    <Button x:Name="AddServerButton" DockPanel.Dock="Right" Content="添加" Click="AddServer_Click" Margin="5,5,0,5" Padding="10,3"/>
                    <TextBox x:Name="ServerAddressTextBox" Text="pool.ntp.org" VerticalAlignment="Center"/>
                </DockPanel>
                <ListBox x:Name="ServerListBox" Height="100" SelectionMode="Single" ItemsSource="{Binding NtpServers}" DisplayMemberPath="."/>
            </StackPanel>
        </GroupBox>

        <GroupBox Header="同步设置" Grid.Row="1">
            <StackPanel>
                <StackPanel Orientation="Horizontal" Margin="0,0,0,10">
                    <RadioButton x:Name="SimpleIntervalRadio" GroupName="SyncMode" Content="简单间隔" IsChecked="True" Checked="SyncMode_Changed"/>
                    <RadioButton x:Name="AdvancedScheduleRadio" GroupName="SyncMode" Content="高级每日计划" Margin="20,0,0,0" Checked="SyncMode_Changed"/>
                </StackPanel>

                <StackPanel x:Name="SimpleIntervalPanel" Orientation="Horizontal">
                    <TextBlock Text="同步频率:"/>
                    <ComboBox x:Name="SyncIntervalComboBox" Width="120" SelectedIndex="2" SelectionChanged="Settings_Changed">
                        <!-- Default to 1 hour -->
                        <ComboBoxItem Content="5 分钟"/>
                        <ComboBoxItem Content="15 分钟"/>
                        <ComboBoxItem Content="30 分钟"/>
                        <ComboBoxItem Content="1 小时"/>
                        <ComboBoxItem Content="3 小时"/>
                        <ComboBoxItem Content="6 小时"/>
                        <ComboBoxItem Content="12 小时"/>
                        <ComboBoxItem Content="24 小时"/>
                    </ComboBox>
                </StackPanel>

                <StackPanel x:Name="AdvancedSchedulePanel" Visibility="Collapsed">
                    <TextBlock Text="在每天的以下时间段内,按指定频率同步:" Margin="0,0,0,5" TextWrapping="Wrap"/>
                    <Grid>
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition Width="Auto"/>
                            <ColumnDefinition Width="Auto"/>
                            <ColumnDefinition Width="Auto"/>
                            <ColumnDefinition Width="Auto"/>
                            <ColumnDefinition Width="Auto"/>
                            <ColumnDefinition Width="Auto"/>
                        </Grid.ColumnDefinitions>
                        <Grid.RowDefinitions>
                            <RowDefinition Height="Auto"/>
                        </Grid.RowDefinitions>
                        <TextBlock Grid.Column="0" Text="开始时间:"/>
                        <TextBox Grid.Column="1" x:Name="StartTimeTextBox" Width="60" Text="02:00" ToolTip="HH:mm (24小时制)" LostFocus="Settings_Changed"/>
                        <TextBlock Grid.Column="2" Text="结束时间:" Margin="10,0,0,0"/>
                        <TextBox Grid.Column="3" x:Name="EndTimeTextBox" Width="60" Text="05:00" ToolTip="HH:mm (24小时制)" LostFocus="Settings_Changed"/>
                        <TextBlock Grid.Column="4" Text="同步间隔 (分钟):" Margin="10,0,0,0"/>
                        <TextBox Grid.Column="5" x:Name="AdvancedIntervalTextBox" Width="50" Text="30" ToolTip="例如: 15, 30, 60" LostFocus="Settings_Changed"/>
                    </Grid>
                </StackPanel>

                <StackPanel Orientation="Horizontal" Margin="0,15,0,0">
                    <Button x:Name="SyncNowButton" Content="立即同步" Click="SyncNow_Click" />
                    <Button x:Name="ToggleAutoSyncButton" Content="启动自动同步" Click="ToggleAutoSync_Click"/>
                </StackPanel>
            </StackPanel>
        </GroupBox>

        <GroupBox Header="状态信息" Grid.Row="2">
            <Grid>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="Auto"/>
                    <ColumnDefinition Width="*"/>
                </Grid.ColumnDefinitions>
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                </Grid.RowDefinitions>
                <TextBlock Grid.Row="0" Grid.Column="0" Text="当前本地时间:" FontWeight="SemiBold"/>
                <TextBlock Grid.Row="0" Grid.Column="1" x:Name="CurrentTimeTextBlock" Text="---"/>
                <TextBlock Grid.Row="1" Grid.Column="0" Text="上次成功同步:" FontWeight="SemiBold"/>
                <TextBlock Grid.Row="1" Grid.Column="1" x:Name="LastSyncTextBlock" Text="从未"/>
                <TextBlock Grid.Row="2" Grid.Column="0" Text="下次计划同步:" FontWeight="SemiBold"/>
                <TextBlock Grid.Row="2" Grid.Column="1" x:Name="NextSyncTextBlock" Text="---"/>
            </Grid>
        </GroupBox>

        <GroupBox Header="同步日志 (UI)" Grid.Row="3">
            <ListBox x:Name="LogListBox" ItemsSource="{Binding UiLogMessages}" ScrollViewer.VerticalScrollBarVisibility="Auto" ScrollViewer.HorizontalScrollBarVisibility="Auto"/>
        </GroupBox>

        <StatusBar Grid.Row="4" Background="#FFE0E0E0">
            <StatusBarItem>
                <TextBlock x:Name="StatusTextBlock" Text="就绪。请以管理员权限运行以设置系统时间。"/>
            </StatusBarItem>
        </StatusBar>
    </Grid>
</Window>

核心代码SynchronizeTimeAsync如下:

private async void SyncNow_Click(object sender, RoutedEventArgs e)
{
    LogToUiAndFile("手动同步已触发。", "INFO");
    await SynchronizeTimeAsync(true); // true for manual sync
}
 
private async Task SynchronizeTimeAsync(bool isManualSync)
{
    if (ServerListBox.SelectedItem == null || string.IsNullOrWhiteSpace(ServerListBox.SelectedItem.ToString()))
    {
        LogToUiAndFile("错误: 请先从列表中选择一个NTP服务器进行同步。", "WARN");
        MessageBox.Show(this, "请选择一个NTP服务器。", "未选择服务器", MessageBoxButton.OK, MessageBoxImage.Warning);
        return;
    }
    string selectedServer = ServerListBox.SelectedItem.ToString();

    LogToUiAndFile($"开始同步时间 ({(isManualSync ? "手动" : "计划")}) 从 {selectedServer}...", "INFO");
    StatusTextBlock.Text = $"正在连接到 {selectedServer}...";
    SetUiEnabled(false);

    try
    {
        NtpClient client = new NtpClient(selectedServer, logger: _logger); // Pass logger
        NtpPacket packet = await client.GetTimeAsync(); // This method now has retries and throws on final failure

        if (packet == null) // Should be caught by GetTimeAsync's throw, but as a safeguard
        {
            throw new Exception($"无法从 {selectedServer} 获取NTP数据包,所有重试均失败。");
        }

        TimeSpan offset = packet.CalculateOffset(); // Can throw InvalidOperationException
        DateTime correctedUtcTime = DateTime.UtcNow + offset;

        LogToUiAndFile($"从 {selectedServer} 获取时间成功。偏移: {offset.TotalMilliseconds:F3} ms. 包详情: {packet}", "INFO");

        LogToUiAndFile($"当前UTC时间: {DateTime.UtcNow:O}, 校正后UTC时间: {correctedUtcTime:O}", "DEBUG");
        LogToUiAndFile($"尝试设置系统时间...", "INFO");

        if (SystemTimeSetter.SetSystemUtcTime(correctedUtcTime))
        {
            LastSuccessfulSyncTime = DateTime.Now; // Record local time of this successful sync
            LogToUiAndFile("系统时间已成功校准!", "INFO");
            StatusTextBlock.Text = $"时间已与 {selectedServer} 同步。偏移: {offset.TotalMilliseconds:F3} ms";

            if (_isAutoSyncEnabled && !isManualSync && SimpleIntervalRadio.IsChecked == true) // If scheduled simple sync
            {
                CalculatedNextSyncTime = DateTime.Now + GetSimpleInterval();
                LogToUiAndFile($"下次简单间隔同步计划在: {CalculatedNextSyncTime:G}", "DEBUG");
            }
            else if (_isAutoSyncEnabled && !isManualSync && AdvancedScheduleRadio.IsChecked == true)
            {
                // For advanced, next sync is determined by interval from LastSuccessfulSyncTime within the window.
                // MasterTimer_Tick will re-evaluate on its next pass.
                // Setting a specific _calculatedNextSyncTime here might be complex if it falls outside window.
                // MasterTimer will handle it.
                LogToUiAndFile("高级计划同步完成。下次同步将按计划确定。", "DEBUG");
            }
        }
        else
        {
            throw new Exception($"设置系统时间失败。Win32错误码: {Marshal.GetLastWin32Error()}. 请确保以管理员权限运行程序。");
        }
    }
    catch (InvalidOperationException opEx) // Specific to NtpPacket calculations
    {
        LogToUiAndFile($"同步计算错误: {opEx.Message}. 服务器返回的时间戳可能无效。", "ERROR", opEx);
        StatusTextBlock.Text = "同步错误: 时间戳无效。";
        MessageBox.Show(this, $"无法完成时间同步,服务器返回的时间戳无效或不完整。\n\n错误: {opEx.Message}", "同步计算错误", MessageBoxButton.OK, MessageBoxImage.Error);
    }
    catch (Exception ex)
    {
        LogToUiAndFile($"同步失败: {ex.Message}", "ERROR", ex);
        StatusTextBlock.Text = "同步失败。请查看文件日志获取详细信息。";
        MessageBox.Show(this, $"无法完成时间同步。\n\n错误: {ex.Message}\n\n请检查您的网络连接、NTP服务器地址、程序权限以及文件日志 (logs\\client.log)。", "同步失败", MessageBoxButton.OK, MessageBoxImage.Error);
    }
    finally
    {
        SetUiEnabled(true);
        UpdateStatusTextBlocks(); // Refresh UI status
    }
}

项目中使用了log4net记录日志,用户的配置保存在了配置文件中。

▲时间同步客户端界面,NTP授时服务器地址设置为了另一台IP地址为192.168.6.200的服务器,上面部署了授时服务程序

NTPServerApp


授时服务器的界面设计如下:

<Window x:Class="NTPServerApp.MainWindow"
 xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:NTPServerApp"
        mc:Ignorable="d"
        Title="NTP 授时服务器" Height="450" Width="600"
        Background="#FFF0F0F0" Foreground="#FF333333" FontFamily="Microsoft YaHei UI">
    <Window.Resources>
        <Style TargetType="Button">
            <Setter Property="Background" Value="#FF007ACC"/>
            <Setter Property="Foreground" Value="White"/>
            <Setter Property="BorderThickness" Value="0"/>
            <Setter Property="Padding" Value="10,5"/>
            <Setter Property="Margin" Value="5"/>
            <Setter Property="Cursor" Value="Hand"/>
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="Button">
                        <Border Background="{TemplateBinding Background}" CornerRadius="3">
                            <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
                        </Border>
                        <ControlTemplate.Triggers>
                            <Trigger Property="IsMouseOver" Value="True">
                                <Setter Property="Background" Value="#FF005A9E"/>
                            </Trigger>
                            <Trigger Property="IsEnabled" Value="False">
                                <Setter Property="Background" Value="#FFCCCCCC"/>
                                <Setter Property="Foreground" Value="#FF666666"/>
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
        <Style TargetType="ListBox">
            <Setter Property="Margin" Value="5"/>
            <Setter Property="BorderBrush" Value="#FFABADB3"/>
            <Setter Property="BorderThickness" Value="1"/>
        </Style>
        <Style TargetType="TextBlock">
            <Setter Property="Margin" Value="5,0"/>
            <Setter Property="VerticalAlignment" Value="Center"/>
        </Style>
        <Style TargetType="GroupBox">
            <Setter Property="Padding" Value="10"/>
            <Setter Property="Margin" Value="5"/>
            <Setter Property="BorderBrush" Value="#FFABADB3"/>
            <Setter Property="BorderThickness" Value="1"/>
        </Style>
    </Window.Resources>
    <Grid Margin="10">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>

        <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,10">
            <Button x:Name="StartServerButton" Content="启动服务器" Click="StartServer_Click"/>
            <Button x:Name="StopServerButton" Content="停止服务器" Click="StopServer_Click" IsEnabled="False"/>
            <TextBlock Text="服务器状态:" Margin="15,5,5,5" FontWeight="SemiBold"/>
            <TextBlock x:Name="ServerStatusTextBlock" Text="已停止" Foreground="DarkRed"/>
        </StackPanel>

        <GroupBox Header="服务器日志" Grid.Row="1">
            <ListBox x:Name="LogListBox" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
        </GroupBox>

        <StatusBar Grid.Row="2" Background="#FFE0E0E0">
            <StatusBarItem>
                <TextBlock Text="NTP 服务器 (UDP 端口 123)"/>
            </StatusBarItem>
        </StatusBar>
    </Grid>
</Window>

代码如下:

public partial class MainWindow : Window
{
    private SimpleNtpServer _ntpServer;
    public ObservableCollection<string> LogMessages { get; set; }

    public MainWindow()
    {
        InitializeComponent();
        LogMessages = new ObservableCollection<string>();
        LogListBox.ItemsSource = LogMessages;
        UpdateServerStatus(false); // Initially stopped
    }

    private void StartServer_Click(object sender, RoutedEventArgs e)
    {
        if (_ntpServer == null)
        {
            _ntpServer = new SimpleNtpServer(); // Uses default port 123
            _ntpServer.LogMessage += Server_LogMessage;
        }

        try
        {
            _ntpServer.Start(); // This method is synchronous in the provided NTPCore but handles async internally.
                                // If Start itself were async, you'd await it.
            UpdateServerStatus(true);
        }
        catch (Exception ex)
        {
            Log($"启动服务器失败: {ex.Message}");
            UpdateServerStatus(false);
        }
    }

    private async void StopServer_Click(object sender, RoutedEventArgs e)
    {
        if (_ntpServer != null)
        {
            Log("正在停止服务器...");
            await _ntpServer.StopAsync(); // StopAsync is asynchronous
            if (_ntpServer != null) // Check again, as StopAsync might nullify it or LogMessage could be called after disposal
            {
                _ntpServer.LogMessage -= Server_LogMessage; // Unsubscribe
            }
            _ntpServer = null;
            UpdateServerStatus(false);
            // Log("服务器已停止。"); // StopAsync already logs this.
        }
    }

    private void Server_LogMessage(string message)
    {
        Log(message);
    }

    private void Log(string message)
    {
        Dispatcher.Invoke(() =>
        {
            string logEntry = $"[{DateTime.Now:HH:mm:ss}] {message}";
            LogMessages.Add(logEntry);
            if (LogMessages.Count > 200) // Limit log history
            {
                LogMessages.RemoveAt(0);
            }
            // Auto-scroll to the bottom of the ListBox
            if (VisualTreeHelper.GetChildrenCount(LogListBox) > 0)
            {
                var border = (Border)VisualTreeHelper.GetChild(LogListBox, 0);
                var scrollViewer = (ScrollViewer)VisualTreeHelper.GetChild(border, 0);
                scrollViewer.ScrollToBottom();
            }
        });
    }

    private void UpdateServerStatus(bool isRunning)
    {
        if (isRunning)
        {
            ServerStatusTextBlock.Text = "运行中";
            ServerStatusTextBlock.Foreground = Brushes.DarkGreen;
            StartServerButton.IsEnabled = false;
            StopServerButton.IsEnabled = true;
        }
        else
        {
            ServerStatusTextBlock.Text = "已停止";
            ServerStatusTextBlock.Foreground = Brushes.DarkRed;
            StartServerButton.IsEnabled = true;
            StopServerButton.IsEnabled = false;
        }
    }

    protected override async void OnClosing(CancelEventArgs e)
    {
        if (_ntpServer != null)
        {
            // Prevent window from closing immediately if server is stopping
            e.Cancel = true; // Cancel the close for now
            StopServerButton.IsEnabled = false; // Disable stop button during forced stop
            StartServerButton.IsEnabled = false;

            Log("应用程序正在关闭,正在停止NTP服务器...");
            await _ntpServer.StopAsync(); // Ensure server is stopped
            if (_ntpServer != null)
            {
                _ntpServer.LogMessage -= Server_LogMessage;
            }
            _ntpServer = null;
            Log("NTP服务器已停止。应用程序现在将关闭。");

            // Allow closing now
            e.Cancel = false;
            Application.Current.Shutdown(); // Force shutdown if needed or just let it close
        }
        base.OnClosing(e);
    }
}

运行界面如下:

▲NTP授时服务端程序界面