时间究竟是什么?这既可以是一个哲学问题,也可以是一个物理问题。古人对太阳进行观测,利用太阳的投影发明了日晷,定义了最初的时间。随着科技的发展,天文观测的精度也越来越准确,人们发现地球的自转并不是完全一致的,这就导致每天经过的时间是不一样的。这点误差对于基本生活基本没有影响,但是对于股票交易、火箭发射等等要求高精度时间的场景就无法忍受了。科学家们开始把观测转移到了微观世界,找到了一种运动高度稳定的原子——铯,最终定义出了准确的时间:铯原子电子跃迁 9192631770 个周期所持续的时间长度定义为 1 秒。基于这个定义制造出了高度稳定的原子钟。

时间在计算机中又是如何定义的呢?通常使用 Unix 时间戳进行表示,记录的是自公元 1970 年 1 月 1 日 0 时 0 分 0 秒以来的秒数。计算机为了维持时钟的走时,硬件层面使用晶体振荡器保障时钟的精确性(也是石英钟的原理),操作系统层面使用时钟中断去更新时间的流逝。现代计算机的硬件设计通常有独立的时钟(RTC),这源于 Intel 和微软创立的标准 High Precision Event Timer(HPET),标准指定了 10 MHz 的时钟速度,因此时钟可以获得 100 纳秒的分辨率。这也是 .NET 时间有关的类型中 Ticks 属性的由来,1秒 = 10000000 Ticks。虽然计算机的时钟已经足够精准,但也会受到环境温度的影响造成过快或者过慢的问题。为了对计算机的时钟进行校准,通常使用 NTP 协议与网络中的时间服务器进行同步。时间服务器的时间又会使用 GPS 接收机、无线电或者是原子钟进行校准。

本文将从 GPS 时间的获取、NTP 报文的编写实现一个“玩具”级别的时间同步服务器,使用 .NET 6 编写一个控制台应用程序,通过本文你可以学到:

  1. 串口 SerialPort 类的使用;
  2. 使用 Socket 类实现 UDP 的监听与回复;
  3. 在程序中使用 Process 类执行命令行指令;
  4. 了解 GPS 数据报文的 NMEA-0183 协议;
  5. 了解 NTP 协议报文。

  • https://github.com/ZhangGaoxing/gps-ntp

    项目结构

    创建一个控制台应用和类库,项目结构如下:

    项目依赖

    添加如下 NuGet 包引用:

    <ItemGroup>
       <PackageReference Include="System.IO.Ports" Version="6.0.0" />
    </ItemGroup>
    

    配置串口读取 GPS 数据

    绝大部分 GPS 模块每秒会通过串口输出 NMEA-0183 协议报文,因此我们只需要通过串口读取需要的时间数据即可。此环节包含 3 个步骤:

    1. 初始化串口;
    2. 读取 $GPRMC 数据帧的内容,提取时间信息;
    3. 更新系统时间。

    初始化串口

    使用串口时最重要的属性是波特率,请查阅对应 GPS 模块的数据手册,这里使用的 NEO-6M 模块的波特率是 9600。串口的名称取决于你的连接方式,在 Linux 中串口对应的驱动文件在 /dev 目录下,使用内置串口可能的文件名称为 ttySx,使用 USB 串口可能的文件名称为 ttyUSBx,在 Windows 中串口的名称为 COMx,其中 x 表示的是数字编号。

    // 使用的串口名称
    const string SERIAL_NAME = "/dev/ttyUSB0";
    using SerialPort gps = new SerialPort(SERIAL_NAME)
    {
        BaudRate = 9600,
        Encoding = Encoding.UTF8,
        ReadTimeout = 500,
        WriteTimeout = 500,
    };
    

    从串口中获取数据

    从串口中读取数据时使用的是 SerialPort 类中的 DataReceived 事件。事件(event)可以理解为一种广播,当完成某种操作后向外发送通知。即串口接收到数据后,触发数据处理事件。

    gps.DataReceived += GpsFrameReceived;
    
    /// <summary>
    /// GPS 报文处理
    /// </summary>
    void GpsFrameReceived(object sender, SerialDataReceivedEventArgs e)
    {
        // TODO:读取 `$GPRMC` 数据帧;提取时间;更新系统时间
    }
    

    由于 GPS 模块输出的不只有 $GPRMC 数据帧,因此需要在处理事件中判断帧头以及帧的有效性。

    void GpsFrameReceived(object sender, SerialDataReceivedEventArgs e)
    {
        string frame = gps.ReadLine();
    
        if (frame.StartsWith("$GPRMC"))
        {
            // $GPRMC,UTC 时间,定位状态,纬度,纬度半球,经度,经度半球,速度,航向,UTC 日期,磁偏角,磁偏角方向,指示模式*校验和
            // $GPRMC,013717.00,A,3816.57392,N,10708.73951,E,0.467,,050722,,,A*78
            string[] field = frame.Split(',');
    
            // 帧数据有效
            if (!field[12].StartsWith("N"))
            {
                // TODO:提取时间;更新系统时间
            }
        }
    }
    

    在验证 $GPRMC 数据帧有效后,根据帧解析提取对应字段的时间信息。

    void GpsFrameReceived(object sender, SerialDataReceivedEventArgs e)
    {
        string frame = gps.ReadLine();
    
        if (frame.StartsWith("$GPRMC"))
        {
            string[] field = frame.Split(',');
    
            if (!field[12].StartsWith("N"))
            {
                // 获取 GPS 时间
                string time = field[1][0..6];
                string date = field[9];
                DateTime utcNow = DateTime.ParseExact($"{date}{time}", "ddMMyyHHmmss", CultureInfo.InvariantCulture);
    
                // TODO:更新系统时间
            }
        }
    }
    

    更新系统时间

    由于 .NET 并不提供修改系统时间的操作,因此我们要使用间接的方式修改系统时间。一种方式是使用 P/Invoke 调用 C++ 的函数,这种方式可以精确的修改时间,但涉及引用、数据类型转换,过于复杂,和本入门指南不符。这里使用的是运行命令行指令的方式修改系统的时间,但修改时间的精度只能精确到秒。在 Windows 中使用 PowerShellSet-Date 命令,在 Linux 中使用 date 命令。

    /// <summary>
    /// 更新系统时间
    /// </summary>
    void UpdateSystemTime(DateTime time)
    {
        ProcessStartInfo processInfo;
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            processInfo = new ProcessStartInfo
            {
                FileName = "powershell.exe",
                Arguments = $"Set-Date \"\"\"{time.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")}\"\"\"",
                RedirectStandardOutput = true,
                UseShellExecute = false,
                CreateNoWindow = true,
            };
        }
        else
        {
            processInfo = new ProcessStartInfo
            {
                FileName = "date",
                Arguments = $"-s \"{time.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")}\"",
                RedirectStandardOutput = true,
                UseShellExecute = false,
                CreateNoWindow = true,
            };
        }
    
        var process = Process.Start(processInfo);
        process.WaitForExit();
    }
    

    最终报文处理事件由以下代码构成:

    void GpsFrameReceived(object sender, SerialDataReceivedEventArgs e)
    {
        string frame = gps.ReadLine();
    
        if (frame.StartsWith("$GPRMC"))
        {
            string[] field = frame.Split(',');
    
            if (!field[12].StartsWith("N"))
            {
                string time = field[1][0..6];
                string date = field[9];
                DateTime utcNow = DateTime.ParseExact($"{date}{time}", "ddMMyyHHmmss", CultureInfo.InvariantCulture);
    
                UpdateSystemTime(utcNow);
    
                // 记录时钟最后一次被更新的时间
                lastUpdatedTime = utcNow;
            }
        }
    }
    

    使用 gps.Open(); 打开串口后就可以获取时间数据了。

    实现 NTP 服务

    下面使用 Socket 类实现一个简单的 UDP 服务器,用于监听和回复 NTP 报文。

    初始化 UDP 服务

    // NTP 服务初始化
    using Socket ntpServer = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
    IPEndPoint ip = new IPEndPoint(IPAddress.Any, 123);
    ntpServer.Bind(ip);
    

    监听和回复 NTP 报文

    在后台新建一个进程用于监听 NTP 请求报文:

    new Thread(NtpFrameReceived)
    {
        IsBackground = true
    }.Start();
    
    /// <summary>
    /// NTP 报文接收与回复
    /// </summary>
    void NtpFrameReceived()
    {
        // 存储接收到的 NTP 请求报文
        Span<byte> receiveFrame = stackalloc byte[48];
    
        while (true)
        {
            // 接收请求报文
            EndPoint clientPoint = new IPEndPoint(IPAddress.Any, 0);
            ntpServer.ReceiveFrom(receiveFrame, ref clientPoint);
            DateTime receiveTime = DateTime.UtcNow;
    
            // TODO:回复 NTP 报文
        }
    }
    

    根据帧解析生成 NTP 回复报文:

    /// <summary>
    /// 生成 NTP 报文
    /// </summary>
    Span<byte> GenerateNtpFrame(Span<byte> receivedFrame, DateTime receiveTime)
    {
        Span<byte> ntpFrame = stackalloc byte[48]
        {
            0x1c, 0x01, 0x11, 0xe9, 0x00, 0x00, 0x00, 0x00,
            0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        };
    
        // Client Transmit Timestamp => Server Origin Timestamp
        for (int i = 0; i < 8; i++)
        {
            ntpFrame[24 + i] = receivedFrame[40 + i];
        }
    
        // 本机时钟最后更新时间
        long referenceTicks = (lastUpdatedTime - ntpStart).Ticks;
        uint referenceTimeInt = (uint)(referenceTicks / TICK_2_SECOND);
        uint referenceTimeFract = (uint)(referenceTicks % TICK_2_SECOND);
        var referenceTimeIntByte = BitConverter.GetBytes(referenceTimeInt);
        var referenceTimeFractByte = BitConverter.GetBytes(referenceTimeFract);
    
        // 接收报文时间
        long receiveTicks = (receiveTime - ntpStart).Ticks;
        uint receiveTimeInt = (uint)(receiveTicks / TICK_2_SECOND);
        uint receiveTimeFract = (uint)(receiveTicks % TICK_2_SECOND);
        var receiveTimeIntByte = BitConverter.GetBytes(receiveTimeInt);
        var receiveTimeFractByte = BitConverter.GetBytes(receiveTimeFract);
    
        // 发送报文时间
        long transmitTicks = (DateTime.UtcNow - ntpStart).Ticks;
        uint transmitTimeInt = (uint)(receiveTicks / TICK_2_SECOND);
        uint transmitTimeFract = (uint)(receiveTicks % TICK_2_SECOND);
        var transmitTimeIntByte = BitConverter.GetBytes(receiveTimeInt);
        var transmitTimeFractByte = BitConverter.GetBytes(receiveTimeFract);
    
        if (BitConverter.IsLittleEndian)
        {
            for (int i = 0; i < 4; i++)
            {
                ntpFrame[19 - i] = referenceTimeIntByte[i];
                ntpFrame[23 - i] = referenceTimeFractByte[i];
    
                ntpFrame[35 - i] = receiveTimeIntByte[i];
                ntpFrame[39 - i] = receiveTimeFractByte[i];
    
                ntpFrame[43 - i] = transmitTimeIntByte[i];
                ntpFrame[47 - i] = transmitTimeFractByte[i];
            }
        }
        else
        {
            for (int i = 0; i < 4; i++)
            {
                ntpFrame[16 + i] = referenceTimeIntByte[i];
                ntpFrame[20 + i] = referenceTimeFractByte[i];
    
                ntpFrame[32 + i] = receiveTimeIntByte[i];
                ntpFrame[36 + i] = receiveTimeFractByte[i];
    
                ntpFrame[40 + i] = transmitTimeIntByte[i];
                ntpFrame[44 + i] = transmitTimeFractByte[i];
            }
        }
    
        return ntpFrame.ToArray();
    }
    

    最终报文请求与回复由以下代码构成:

    void NtpFrameReceived()
    {
        Span<byte> receiveFrame = stackalloc byte[48];
    
        while (true)
        {
            EndPoint clientPoint = new IPEndPoint(IPAddress.Any, 0);
            ntpServer.ReceiveFrom(receiveFrame, ref clientPoint);
            DateTime receiveTime = DateTime.UtcNow;
    
            // 回复 NTP 报文
            Span<byte> sendFrame = GenerateNtpFrame(receiveFrame, DateTime.UtcNow);
            ntpServer.SendTo(sendFrame, clientPoint);
            DateTime sendTime = DateTime.UtcNow;
        }
    }
    

    将上述代码进行整合就构成了基于 GPS 的 NTP 时间同步服务器。

    部署应用

    发布到文件

    1. 切换到 GpsNtp 项目运行发布命令:
    dotnet publish -c release -r linux-x64 --no-self-contained
    
    1. 将发布后的文件通过 FTP 等方式复制到 Linux 开发板;
    2. GpsNtp 文件增加可执行权限
    sudo chmod +x GpsNtp
    
    1. 运行程序
    sudo ./GpsNtp
    

    构建 Docker 镜像

    1. 在项目的根目录中创建 Dockerfile,并将整个项目复制到 Linux 开发板中:
    FROM mcr.microsoft.com/dotnet/core/sdk:6.0-focal AS build
    WORKDIR /app
    
    # publish app
    COPY src .
    WORKDIR /app/GpsNtp
    RUN dotnet restore
    RUN dotnet publish -c release -r linux-arm -o out
    
    # run app
    FROM mcr.microsoft.com/dotnet/core/runtime:6.0-focal AS runtime
    WORKDIR /app
    COPY --from=build /app/GpsNtp/out ./
    
    ENTRYPOINT ["dotnet", "GpsNtp.dll"]
    
    1. 切换到项目目录,构建镜像:
    docker build -t gps-ntp -f Dockerfile .
    
    1. 运行镜像:
    docker run --rm -it --device /dev/ttySx gps-ntp
    

    程序运行后,使用 Windows 时间同步服务进行一下测试。

标签智能推荐:

.NET Core ❤ gRPC

生态里gRpc有着举足轻重的地位。.NET目前有两种正式的gRPC实现:Grpc.Core:基于本地gRpcCore库的原生gRpcC#实现,支持.NETCore2.1/.NETFramework4.5+/Mono4+。grpc-dotnet:完全以C#编写的新实现,没有任何本机依赖性,并且基于最新发布的.NETCore3.0。这两种实现并排共存,并且在可用功能,集成,支持的平台,成熟度和性能方面

逆向学习物联网-云服务平台-02OneNETMQTT通讯协议简介

1.菜鸟教程MQTT入门介绍https://www.runoob.com/w3cnote/mqtt-intro.html2.OneNET的MQTT协议文档https://open.iot.10086.cn/doc/book/device-develop/multpro/MQTT/MQTT-manual.html&nbsp;3.消息订阅机制&nbsp;

使用MQTT 连接Azure IoT Hub

-iot-hub-1-prepare&nbsp;中VSCode部分);一.在MQTTX中连接AzureIoTHub:1.点击1添加按钮;2.名称随意输入,此处表示连接的名称,不重复即可。3.ClientID填写IoTHub中DeviceID:4.服务器地址为mqtts,后边地址为IoTHub的主机名:5.端口88836.用户名“用户名”字段使用&nbsp;{iothubhostname}/{dev

国内物联网平台

com/c/en/us/solutions/internet-of-things/kinetic.html总结&nbsp;据统计,全球物联网平台从2017年的450家到2019年的620家,2020年估计这个数字还会继续增长。别看平台这么多,设备真正接了多少,接的都是干啥的设备,哪家平台接的设备多,我们也不清楚,反正大家都说自己平台接的非常多。你看,同样参考同一份IDC报告,阿里说自己中国最大:华

C#/.NET/.NET Core学习视频汇总(持续更新ing)

伴在我的公众号后台留言问有没有C#/.NET/.NETCore这方面相关的视频推荐,我一般都会推荐他们去B站搜索一下。今天刚好有空收集了网上一些比较好的C#/.NET/.NETCore这方面的学习视频,希望能够帮助到有需要的小伙伴们。当然假如你有更好的资源视频推荐可以在我的文章下面留言,开篇之前我要感谢各位小伙伴对【C#/.NET/.NETCore学习、工作、面试指南💖】知识库的支持已经突破了5

JavaCV入门指南:FrameConverter转换工具类及CanvasFrame图像预览工具类(javaCV教程完结篇)

JavaCV入门指南系列:JavaCV入门指南:序章JavaCV入门指南:调用FFmpeg原生API和JavaCV是如何封装了FFmpeg的音视频操作JavaCV入门指南:调用opencv原生API和JavaCV是如何封装了opencv的图像处理操作JavaCV入门指南:帧抓取器(FrameGrabber)的原理与应用JavaCV入门指南:帧录制器/推流器(FrameRecorder)的原理与应用

JavaCV入门指南:调用opencv原生API和JavaCV是如何封装了opencv图像处理操作?

V入门指南系列:JavaCV入门指南:序章JavaCV入门指南:调用FFmpeg原生API和JavaCV是如何封装了FFmpeg的音视频操作JavaCV入门指南:调用opencv原生API和JavaCV是如何封装了opencv的图像处理操作JavaCV入门指南:帧抓取器(FrameGrabber)的原理与应用JavaCV入门指南:帧录制器/推流器(FrameRecorder)的原理与应用JavaC

2021年11月工作笔记

于开源项目二次开发的坑https://zhuanlan.zhihu.com/p/352861858SpringCloudEureka解析(3)EurekaClient重要缓存解析https://my.oschina.net/u/3747772/blog/1588958设备端OTA升级https://help.aliyun.com/document_detail/85700.htmlMQTT协议网络

JavaCV入门指南:帧过滤器(FrameFilter)原理与应用

JavaCV入门指南系列:JavaCV入门指南:序章JavaCV入门指南:调用FFmpeg原生API和JavaCV是如何封装了FFmpeg的音视频操作JavaCV入门指南:调用opencv原生API和JavaCV是如何封装了opencv的图像处理操作JavaCV入门指南:帧抓取器(FrameGrabber)的原理与应用JavaCV入门指南:帧录制器/推流器(FrameRecorder)的原理与应用

.NET 体系结构组件

.NET应用开发用于并运行于一个或多个.NET实现。&nbsp;.NET实现包括.NETFramework、.NETCore和Mono。&nbsp;.NET的所有实现都有一个名为.NETStandard的通用API规范。&nbsp;本文简要介绍了每个概念。.NETStandard.NETStandard是一组由.NET实现的基类库实现的API。&nbsp;更正式地说,它是构成协定统一集(这些协定是