STM32F107实现IEEE1588v2 PTP纳秒级时间同步解析

发布时间:2026/10/17 16:58:12
STM32F107实现IEEE1588v2 PTP纳秒级时间同步解析
简介面向STM32嵌入式开发者这份资源提供了在STM32F107上基于LwIP协议栈实现IEEE 1588v2精确时间协议PTP的完整方案包含设计文档与Keil工程代码适用于工业自动化、电力系统等需要高精度时钟同步的场景。压缩包共316个文件大小3.2MB以C语言源文件117个.c、128个.h为主辅以链接脚本、配置文件、PDF手册、烧录说明及可直接烧录的hex固件目录结构清晰。已有1978人学习下载。资料内含PTPd 2.8协议栈移植代码、STM32以太网驱动、定时器配置等关键模块并附带评估板用户手册与烧录步骤可帮助开发者从零搭建PTP同步应用理解LwIP与硬件外设的协同方式。适合有一定STM32基础、希望深入了解PTP协议与嵌入式网络时间同步的开发者通过源码和文档可掌握从硬件配置、协议移植到固件烧录的全流程。1. PTP IEEE1588V2 STM32F107实现这块单片机把时间精度做到了纳秒级一块 STM32F107 开发板加上一颗常见的以太网 PHY 芯片就能让设备在网络上完成纳秒级对时这不是玄学。很多人搜索时会把协议名记成 IEEE588正确写法是 IEEE 1588v2也叫 PTPPrecision Time Protocol。STM32F107 这颗单片机有点特殊它的以太网 MAC 里集成了一个硬件时间戳单元收发报文时硬件自动打上时间戳抖动远小于软件中断打时间戳的方案。这份资源就是把 PTP 从时钟协议完整跑在这颗单片机上实现主从时钟同步、偏移计算和本地时钟伺服校正。适合想做分布式采集同步、电力对时、工业控制总线时间对齐的嵌入式开发者——你不需要额外买昂贵的时间同步芯片一块 F107 板子就能起一个从时钟节点。2. 硬件基础与协议选型为什么 F107 适合做 PTP 从时钟2.1 STM32F107 的 ETH 外设硬件时间戳是关键STM32F107 和其他不带以太网的 STM32 最大的区别是它内置了完整的 10/100M 以太网 MAC 和专用的 DMA 控制器。但这还不是它适合做 PTP 的原因真正关键的是它的 MAC 里带了一个 IEEE 1588 硬件时间戳单元。这个时间戳单元工作在 APB2 时钟域下通过分频产生一个纳秒计数器。硬件会在检测到 PTP 报文帧头特征时自动把当前计数器的秒值和纳秒值捕获到对应的 DMA 描述符里。整个过程不经过 CPU不占用中断响应时间。软件打时间戳的典型抖动在几十微秒甚至上百微秒而硬件时间戳可以做到 20ns 级别分辨率两者差了三个数量级。时间戳计数器的寄存器是 32 位纳秒加 32 位秒纳秒字段在达到 1_000_000_000 时会向秒字段进位。读取时如果正好赶上进位可能读到秒和纳秒不一致的瞬间值这个细节后面避坑章节会专门讲。F107 的库函数里提供了ETH_GetPTPTimeStamp之类的接口可以直接读出当前时间戳也可以让时间戳跟随外部触发信号复位方便做多节点的时间基准对齐。2.2 PTPv2 报文与主从同步流程四类报文完成一次校准PTPv2 的同步机制可以简化成一句话靠四类报文测出主时钟和从时钟之间的时间偏移和网络路径延迟。先看报文类型报文类型方向作用Sync主 → 从主时钟发送同步报文出发时刻记为 t1Follow_Up主 → 从携带精确的 t1硬件时间戳Delay_Req从 → 主从时钟发起的延迟测量请求出发时刻记为 t3Delay_Resp主 → 从携带主时钟收到 Delay_Req 的精确时刻 t4假设主从之间的网络延迟对称从时钟收到 Sync 的时刻是 t2。那么网络延迟为((t2 - t1) (t4 - t3)) / 2时钟偏移为((t2 - t1) - (t4 - t3)) / 2。测出偏移后本地伺服机构调整本地时钟频率让偏移逐步收敛到零。PTPv2 报文默认走 UDP 组播端口 319 和 320组播地址是 224.0.1.129。除此之外还有一种走二层直接封装的方式以太网帧类型为 0x88F7不经过 IP 协议栈单片机处理起来更简单延迟也更可控。这份资源里用的是二层裸报文方式从 PHY 收上来的原始帧直接进 DMA解析时直接读报文头字段不需要 lwIP 介入省掉了协议栈的不可控延迟。2.3 移植方案ptpd 协议栈裁剪还是自研状态机PTP 协议本身不复杂但要自己从零写报文解析、状态机、最佳主时钟算法工作量还是不小。常见做法是移植开源的 ptpd 协议栈。ptpd 的代码量不大核心文件也就十来个把它的网络收发层替换成 STM32F107 的 ETH 驱动时间戳读取替换成硬件时间戳寄存器就能跑起来。另一种做法是只保留最小功能集实现从时钟必须的报文处理接收 Sync、接收 Follow_Up、发 Delay_Req、收 Delay_Resp然后维护一个状态机做偏移计算和时钟伺服。这个方案代码量更小行为完全可控也更容易查问题。我实际拆这份资源时发现它采用的是后者整体结构非常干净底层是 ETH 驱动中间是 PTP 报文收发上层是状态机和伺服算法。没有引入复杂的 RTOS主循环里轮询处理接收队列。这种设计特别适合想彻底搞懂 PTP 工作流程的开发者——每个环节都能断点跟踪不会被框架代码拖住。3. 核心模块拆解从报文进入到时钟收敛3.1 工程结构与文件职责整个工程的结构可以分成四层每一层的职责都比较清楚PTP_F107/ ├── App/ │ ├── main.c // 初始化、主循环 │ ├── ptp_task.c // PTP 任务状态机、报文分发 │ └── servo.c // 时钟伺服PI 校正 ├── Middleware/ │ └── ptp_protocol.c // 报文解析/封装、时间戳解码 ├── Hardware/ │ ├── eth_drv.c // ETH MAC DMA 初始化、收发 │ ├── phy_drv.c // PHY 寄存器读写、link 检测 │ └── serial.c // 调试串口 └── Makefile文件关键职责eth_drv.c初始化 MAC、DMA 描述符使能硬件时间戳提供帧收发和 RX 时间戳读取接口ptp_protocol.c定义 PTP 报文结构体负责从原始帧中提取 t1/t2/t3/t4ptp_task.c状态机流转SLAVE 模式下等待 Sync、发送 Delay_Req、处理 Delay_Respservo.c偏移值输入输出时钟调整量维护虚拟时钟看到这里你应该明白了硬件时间戳捕获发生在eth_drv.c层偏移计算发生在协议层最终的时钟校正由servo.c完成。3.2 硬件时间戳的捕获与读取PTP 报文的帧头有一个特征目的 MAC 是组播地址 01:00:5E:00:01:81且以太网类型是 0x88F7。STM32F107 的 MAC 支持在接收路径上识别这类帧并自动捕获时间戳。初始化 ETH 时需要做的就是把时间戳功能打开并把 DMA 描述符调整为增强模式——普通描述符只有 16 字节增强描述符是 32 字节多出来的 8 字节就是时间戳字段。// 打开 ETH 硬件时间戳捕获 ETH_InitStructure.ETH_AutoNegotiation ETH_AutoNegotiation_Enable; ... ETH_PTPTimeStampInitStructure.ETH_TimeStampType ETH_TimeStampType_AllFrames; // 或者只捕 PTP 帧 ETH_PTPTimeStampInitStructure.ETH_TimeStamp_Add_Update ENABLE; ETH_TimeStampCmd(ENABLE); // 在 DMA 描述符初始化时ETH_DMAInit 之后检查 // 增强描述符结构体尾部的两个字段 // TimeStampLow - 纳秒值 // TimeStampHigh - 秒值 uint32_t ts_sec, ts_nsec; void eth_get_rx_timestamp(ETH_DMADescTypeDef *desc, uint32_t *sec, uint32_t *nsec) { // 检查描述符状态字里的 TS 标志位 if ((desc-Status ETH_DMARxDesc_TS) ! 0) { *nsec desc-TimeStampLow; *sec desc-TimeStampHigh; } else { *sec *nsec 0; // 该帧没有时间戳返回 0 } }这段代码里有两个容易忽略的地方。第一个是ETH_TimeStampType_AllFrames会让所有接收帧都打时间戳虽然方便调试但 DMA 需要为每个接收描述符都预留时间戳空间也会增加处理负担。实际发布时可以改成只捕获 PTP 帧。第二个是描述符结构体的字段名在不同版本的标准外设库里略有差异有的是TimeStampLow有的是TimeStampLow加上下划线移植时先编译一下报错了按实际定义改名就行。读取时间戳的时机也很关键。DMA 收到完一帧后会把描述符状态字的所有者位改回软件同时置位 TS 标志位。软件在遍历接收队列时看到 TS 位置位就说明这帧带时间戳可以放心读取。如果 TS 位没置位那这帧可能是普通数据帧或者时间戳没有捕获成功此时时间戳字段的值是无效的。3.3 时钟伺服PI 调节器如何把本地时钟拉回主时钟拿到偏移之后要做的不是直接把本地时间改成主时钟时间那样会造成时间跳变对采集系统来说是大忌。正确做法是用一个 PI 调节器把偏移量折算成频率修正值让本地时钟走快或者走慢一点平滑地收敛到主时钟。#define SERVO_P_DIV (1000LL) #define SERVO_I_DIV (100000LL) static int64_t s_freq_adj; // 每次 tick 的纳秒修正量 static int64_t s_integral; // 积分累积值单位 ns // 每次收到一个有效的 Sync/Follow_Up 对后调用 int64_t servo_update(int64_t offset_ns) { // 比例项把当前偏移量按比例缩减 int64_t p_term offset_ns / SERVO_P_DIV; // 积分项累积历史偏移 s_integral offset_ns; // 防积分饱和 if (s_integral INT64_MAX / SERVO_I_DIV) s_integral INT64_MAX / SERVO_I_DIV; if (s_integral -INT64_MAX / SERVO_I_DIV) s_integral -INT64_MAX / SERVO_I_DIV; int64_t i_term s_integral / SERVO_I_DIV; // 频率修正量叠加 s_freq_adj p_term i_term; // 限制单次最大修正幅度避免抖动 if (s_freq_adj 10) s_freq_adj 10; if (s_freq_adj -10) s_freq_adj -10; return s_freq_adj; }offset_ns是正数代表本地时钟比主时钟慢修正量加到每次 tick 的增量上本地时间就会走快。s_freq_adj的量纲是“每个 tick 多走多少纳秒”。因为本地时间戳的 tick 周期是 1ns所以s_freq_adj 1意味着时间戳每秒多走 1ns。调参时有个血泪经验先只放比例项把SERVO_I_DIV调到很大等偏移收敛到几百纳秒内稳定了再慢慢放开积分项。如果一开始就把积分项加进来启动阶段的巨大偏移会让积分饱和系统反而来回震荡收敛时间拖长好几倍。3.4 报文分发状态机从 Sync 到 Follow_Up 的完整链路PTP 状态机的核心在ptp_task.c里一个消息分发函数就能说清楚整个流程。从时钟收到一个 Sync 时并不知道主时钟发出它的精确时刻要靠后续的 Follow_Up 报文才能算出偏移。所以 Sync 的处理逻辑是先把接收时间戳 t2 存起来等 Follow_Up 到来后一起算。void ptp_task_handle_frame(uint8_t *buf, uint32_t len, uint32_t ts_sec, uint32_t ts_nsec) { ptp_msg_header_t *hdr (ptp_msg_header_t *)buf; switch (hdr-messageType) { case PTP_MSGT_SYNC: // 0x00 // 保存本地接收时刻 t2等待 Follow_Up g_t2_sec ts_sec; g_t2_nsec ts_nsec; g_has_t2 1; break; case PTP_MSGT_FOLLOW_UP: // 0x08 if (g_has_t2) { // 解析 Follow_Up 里的精确主时钟发送时刻 t1 int64_t t1 ptp_msg_get_precise_timestamp(buf); int64_t t2 g_t2_sec * 1000000000LL g_t2_nsec; // 简单模式下先忽略网络延迟进入伺服调节前必须完成 Delay_Req/Resp if (g_has_delay) { int64_t offset t2 - t1 - g_path_delay_ns; servo_update(offset); } g_has_t2 0; } break; case PTP_MSGT_DELAY_RESP: // 0x09 if (g_has_t3) { int64_t t4 ptp_msg_get_receive_timestamp(buf); int64_t t3 g_t3_sec * 1000000000LL g_t3_nsec; int64_t t1 g_last_sync_t1; int64_t t2 g_last_sync_t2; // 路径延迟 ((t2 - t1) (t4 - t3)) / 2 g_path_delay_ns ((t2 - t1) (t4 - t3)) / 2; g_has_delay 1; } break; } }这段代码里t1、t2都是从硬件时间戳或 Follow_Up 报文字段里解析出来的统一换算成纳秒再参与运算避免了秒和纳秒分开做加减法时容易出现的借位错误。路径延迟不需要每个同步周期都重新测量可以每 10 秒测一次或者只在链路状态变化时测一次减少报文交互。主循环里还有一个定时发送 Delay_Req 的逻辑每隔固定间隔置位一个标志主循环检测到标志后就发送一帧 Delay_Req并记录发送时刻 t3。发送时刻 t3 同样来自硬件时间戳这就保证了四个时间点 t1、t2、t3、t4 全部由硬件捕获不存在软件路径上的不确定延迟。4. 编译烧录与运行三十分钟让板子跑起来4.1 硬件连接与网络拓扑跑这个工程需要的最简硬件是一块 STM32F107 核心板、一颗 DM9161 或 DP83848 的 PHY 模块、一根网线和一台电脑。这里要注意的是 PHY 的 RMII 接口时钟问题RMII 模式需要 50MHz 的参考时钟STM32F107 可以从 MCO 引脚输出也可以由外部晶振提供。如果使用外部 25MHz 晶振加 PHY 内部倍频的方式必须确认 PHY 的时钟源配置引脚没有接错否则会出现“能收到数据但时间戳全是乱的”这种让人抓狂的问题。拓扑上主时钟可以是一个跑在电脑上的 ptpd 软件也可以是一块烧录了相同工程的另一块 F107 板子开启主时钟模式。从时钟板子用网线直接连接主时钟设备的网口中间不要插普通交换机——交换机对 PTP 报文有转发延迟会让路径延迟计算结果失真。等后面做到边界时钟时再考虑交换机场景。引脚连接建议如下表按照 RMII 模式配置STM32F107 引脚PHY 引脚说明PA1 (ETH_RMII_REF_CLK)XT1/CLKOUT50MHz 参考时钟PA2 (ETH_MDIO)MDIO管理接口数据PC1 (ETH_MDC)MDC管理接口时钟PA7 (ETH_RMII_CRS_DV)CRS_DV载波侦听数据有效PC4 (ETH_RMII_RXD0)RXD0接收数据位 0PC5 (ETH_RMII_RXD1)RXD1接收数据位 1PB13 (ETH_RMII_TXD1)TXD1发送数据位 1PB12 (ETH_RMII_TXD0)TXD0发送数据位 04.2 编译与烧录工程用 Makefile 管理工具链用arm-none-eabi-gcc。在工程根目录下直接执行make CROSS_COMPILEarm-none-eabi-编译产物是build/ptp_f107.bin。烧录可以用 ST-Linkst-flash write build/ptp_f107.bin 0x08000000也可以用串口 ISP 烧录但要注意 F107 的 BOOT0 引脚拉高后进入系统存储器模式。烧录完成后串口 1 以 115200 波特率输出调试日志。复位板子前先把串口助手打开方便直接看到启动过程。编译时如果报ETH_PTPTimeStampInitStructure未定义说明标准外设库版本比较老需要在stm32f10x_eth.h里确认ETH_PTP相关定义是否被条件编译开关包住了。这种情况一般在工程预定义里加上MODULE_ETH_PTP就能解决。4.3 串口日志解读从启动到收敛正常运行后串口日志大致长这样[PTP] eth init ok, phy link up [PTP] waiting for master... [PTP] master found: 02:11:22:33:44:55 [PTP] sync received, seq1024, t2_raw812345678 [PTP] follow_up t1812345600, offset78 ns [PTP] delay_req sent, t3813000012 [PTP] delay_resp t4813001200, path_delay15200 ns [PTP] offset-4560 ns, adjusting -4 ns/tick ... [PTP] offset312 ns, adjusting 0 ns/tick日志里的关键信息有两处path_delay代表网络路径延迟正常直连情况下应该在 1030 微秒量级如果这个值突然跳变成几十毫秒多半是时间戳没有捕获到算出来的 t4 是垃圾值。offset代表主从时钟偏移启动时会从较大值逐步收敛稳定后应该落在几百纳秒以内。如果日志一直停在waiting for master优先检查 PHY 的 link 状态和网线连接。如果 PHY link 正常但收不到 Sync看看主时钟设备是否真的在发报文可以用电脑上的 Wireshark 抓包确认以太网类型 0x88F7 的帧是否在网络上流动。5. 避坑指南PTP 调试中反复出现的五个问题5.1 PHY 的 RMII 时钟配置不对导致时间戳跳变现象串口日志里能正常收到报文但每一步计算出来的时间戳低 4 位一直在跳算出来的path_delay忽大忽小完全没有稳定性。原因RMII 接口需要 50MHz 参考时钟这个时钟如果抖动太大或者频率偏差超过容许范围MAC 内部的时间戳计数器本身就会不准确。常见原因是 MCO 引脚输出的 50MHz 波形质量差或者 PHY 工作在 MII 模式却配了 RMII 的引脚复用。解决先用示波器测 PHY 的 REF_CLK 引脚确认频率是 50MHz 且波形干净。如果用的是 F107 MCO 输出检查 MCO 的预分频配置是否正确同时确认 PLL 倍频后的系统时钟没有超频。我踩过最深的一次坑是换了 PHY 芯片后没检查其 25MHz 晶振负载电容导致 REF_CLK 频率偏到 49.2MHz时间戳每秒慢 0.8 毫秒表面看起来 PTP 已经收敛实则是用偏移量的积分把频率偏执强行补偿了一旦温度变化就露馅。5.2 DMA 描述符增强模式未开启时间戳字段读不到现象eth_get_rx_timestamp返回的秒和纳秒一直是 0但寄存器里时间戳明明在走。原因STM32F107 的时间戳功能依赖增强描述符。DMA 初始化时如果描述符大小设置不对接收描述符就没有额外的时间戳字段读出来的自然是随机值或全零。解决在 DMA 配置代码里确认DMA_InitStructure.ETH_DMA_TxDesc ...; DMA_InitStructure.ETH_DMA_RxDesc ...; DMA_InitStructure.ETH_DMA_DescSkipSize 0; // 关键是这一项 DMA_InitStructure.ETH_DMA_TxDescSize ETH_DMA_TxDescSize_32bytes; DMA_InitStructure.ETH_DMA_RxDescSize ETH_DMA_RxDescSize_32bytes;描述符结构体在使用时也必须按 32 字节对齐分配内存编译器可能会在结构体尾部插入 padding导致驱动按偏移找时间戳字段时取错位置。定义描述符数组时加__ALIGNED(32)能省掉一晚上的排查时间。5.3 纳秒回绕导致的时间戳读取错位现象从时钟运行一段时间后偏移突然出现一个接近 1 秒的跳变然后又自己恢复。原因时间戳计数器的纳秒字段是 32 位每秒回绕一次并给秒字段进位。软件在读取时如果先读了秒然后读纳秒这时纳秒恰好回绕就会读到秒 上一秒、纳秒 新一秒的值组合出来的时间比真实值差了 1 秒左右。解决读取时加一个校验循环void ts_read_safe(uint32_t *sec, uint32_t *nsec) { do { *sec ETH_GetPTPTimeStamp_Seconds(); *nsec ETH_GetPTPTimeStamp_NanoSeconds(); } while (*sec ! ETH_GetPTPTimeStamp_Seconds()); }先读秒再读纳秒最后再读一次秒如果两次秒值不一致就重读。虽然纳秒回绕的概率很低但在 PTP 这种对时间精度敏感的场合一个偶发的 1 秒跳变就可能让伺服系统振荡很久这种保护代码是值得写的。5.4 普通交换机让 PTP 报文时间戳全部失效现象直连主时钟时一切正常插到办公室交换机上后offset和path_delay都变得极不稳定甚至完全收不到 Sync。原因普通交换机是存储转发机制会把 PTP 报文接收下来再转发出去中间有排队延迟。更关键的是F107 的硬件时间戳只能捕获到报文到达 PHY 的时刻交换机造成的驻留时间完全无法感知路径延迟计算默认的对称假设被打破结果自然错乱。解决实际部署里中间链路必须有支持 1588 功能的交换机边界时钟或透明时钟或者干脆点对点直连。调试阶段不要图方便拿办公网络做测试单独拉一根网线直连主从设备是最省心的做法。5.5 中断优先级和看门狗把伺服周期搞乱现象PTP 状态机逻辑没问题但偏移收敛后总是周期性抖动抖动周期和某个外设的中断频率一致。原因伺服函数依赖主循环周期稳定。如果某个高优先级中断频繁打断主循环或者看门狗喂狗代码占了太长时间Sync 报文的响应和 Delay_Req 的发送时机就会出现随机延迟这些延迟不可补偿最终表现为偏移噪声增大。解决在设计里把 ETH 接收中断的优先级提到最高并保证接收中断里只做帧拷贝和描述符回收报文解析留在主循环。另外喂狗代码不要放在 ETH 接收中断里面避免在报文密集时中断处理时间过长。我当时是把看门狗喂狗操作挪到了定时器中断里用固定周期喂主循环的时序才稳定下来。6. 二次开发从从时钟改为边界时钟的实战路径6.1 从时钟改主时钟改状态机出口默认工程的初始状态是 SLAVE 模式要改成主时钟需要把状态机的初始状态切到 MASTER并开启周期发送 Sync 和 Follow_Up 的逻辑。主时钟模式下 FPGA 需要自己维护一个自由运行时钟ptp_task里定时发送 Sync 报文发送后立刻读取硬件时间戳作为 t1随后发送 Follow_Up 携带这个 t1。void ptp_task_master_tick(void) { if (g_send_sync_flag) { eth_send_ptp_frame(PTP_MSGT_SYNC); ts_read_safe(ts_t1_sec, ts_t1_nsec); eth_send_ptp_frame_with_timestamp(PTP_MSGT_FOLLOW_UP, ts_t1_sec, ts_t1_nsec); g_send_sync_flag 0; } }6.2 从时钟加外部 PPS 秒脉冲输出很多对时场景需要设备输出一个物理的秒脉冲信号用来触发采集卡或者对齐采样时钟。最简单的实现是每次本地虚拟时钟的秒字段跳变时翻转一个 GPIO。因为本地虚拟时钟已经被伺服收敛到主时钟GPIO 的上升沿就近似于主时钟的整秒时刻。// 在虚拟时钟更新函数里 if (local_sec ! last_sec) { GPIO_WriteBit(GPIOB, GPIO_Pin_0, Bit_SET); // PPS 高电平 last_sec local_sec; } // 延时 100ms 后拉低形成标准秒脉冲PPS 信号的 jitter 取决于虚拟时钟的更新频率和伺服收敛状态。实测稳定后 PPS 前沿抖动在正负几百纳秒的范围内。如果想让 PPS 更平滑可以把虚拟时钟的 tick 周期从 1ms 降到 100us让整秒判断更及时。6.3 验证方法与一个排查习惯改完代码后验证工具比代码本身重要。我把板子的 PPS 输出接到示波器 CH1电脑串口发出一条命令让主时钟复位时间源然后观察 PPS 的相位是否在几个周期内重新对齐。这个验证方法能快速暴露伺服参数是否合理。从那以后我每次改 PTP 相关代码都会强制走一遍这套流程先看硬件时间戳是否一致再看path_delay是否正常最后才是offset的收敛效果。三层递进哪一层断了就查哪一层。希望这个习惯和这套工程能帮你在 F107 上少走些弯路。本文还有配套的精品资源点击获取