/* * 模块名称:Time SNTP — 板端 SNTP 客户端(状态机实现) * 模块功能:见 time_sntp.h。节拍由 timeSyncTask 主循环驱动(每 ≤200ms 一次), * 报文编解码用 vendor coreSNTP serializer(仅 serializer 层—— * client 层要求自带 transport 接口与定时器,本仓 UDP 已队列化在 * net_socket,节奏循环留在任务里,见 #57 调研 §4.1) * 适用平台:STM32407ZGTx + CH395F;模拟器/主机单测经 stub 编译 * 作者:王建锋 * 创建日期:2026-11-04 * 修改记录: * v1.0 2026-11-04 王建锋 创建初始版本(SNTP 客户端接入,#57 调研结论: * RFC 4330 §10 节奏、§5.3 防欺骗判据、§5.5 步进策略、 * 钟面 = 本地时间(UTC+8 固定,§1.1 裁定)) */ #include #include "time_sntp.h" #include "core_sntp_serializer.h" #include "net_socket.h" #include "sntp_cfg.h" #include "sys_clock.h" #include "sys_events.h" #include "sys_log.h" /* 深调用链模块(与 time_sync.c 同任务栈预算口径,见其文件头注释): * 日志行缓冲收到 128 B(默认 192) */ #define DBG_LINE_BUF_MAX 128U #define DBG_TAG "[SNTP]" #include "dbg_log.h" /* 私有宏定义区 */ #define SNTP_SERVER_PORT 123U /* 应答超时:基础 5s,失败逐次翻倍封顶 60s(RFC 4330 §10:间隔递增; * 翻倍节奏 = "超时即退避",下一次请求最早在 timeout_ms 之后发出) */ #define SNTP_RESP_TIMEOUT_BASE_MS 5000U #define SNTP_RESP_TIMEOUT_MAX_MS 60000U /* 首次请求随机散布 1~5 分钟(RFC 4330 §10 避免同批设备上电撞服务器) */ #define SNTP_FIRST_DELAY_MIN_MS 60000U #define SNTP_FIRST_DELAY_SPAN_MS 240000U /* 等应答期的单次收片粒度(看门狗 100ms 抽样窗约束,同 time_sync.c 第 8b 轮口径) */ #define SNTP_RECV_SLICE_MS 50U /* 步进策略(调研 §5.5):|钟差| < 2s 只记录不步进(防 RTC 秒抖动来回拨针) */ #define SNTP_STEP_DEADBAND_MS 2000 /* 内部钟面 = 本地时间(#56 裁定 §1.1:sys_clock 存"钟面秒",104 写 UTC 的 * 8 小时差已有文档口径)——SNTP 给的是 UTC,落钟面前加固定东八区偏移; * 全仓无时区存储,本常量与 UI 手工对时 / 8888 推送同源 */ #define SNTP_TZ_LOCAL_OFFSET_SECS 28800U /* 合理性下限:2020-01-01 UTC;上限 = sys_clock 钟面域 0x7FFFFFFF 扣除时区 */ #define SNTP_UNIX_MIN_VALID 1577836800U #define SNTP_UNIX_MAX_UTC (0x7FFFFFFFUL - SNTP_TZ_LOCAL_OFFSET_SECS) /* 收包缓冲:48B 报文 + 富余(>48 的扩展按 BSD 语义截断,serializer 只读前 48) */ #define SNTP_RX_BUF_SIZE 64U /* 状态机 */ #define SNTP_ST_IDLE 0 /* 停用 / 从未启用 */ #define SNTP_ST_WAIT_DUE 1 /* 等下一次请求窗口 */ #define SNTP_ST_SEND 2 /* 本轮应发请求 */ #define SNTP_ST_WAIT_RESP 3 /* 等应答(每次 run 收一个 ≤50ms 片) */ /* 静态变量定义区 */ static struct { uint8_t state; /* SNTP_ST_* */ int fd; /* UDP socket(-1 = 未开) */ uint32_t gen; /* 打开 socket 时的网络代次 */ uint32_t due_ms; /* 下一次请求的 tick 锚点(回绕安全比较) */ uint32_t timeout_ms; /* 当前应答超时(失败翻倍,成功复位) */ uint32_t deadline_ms; /* 本轮应答截止 tick */ uint8_t srv; /* 0 = 主 server1,1 = 备 server2 */ uint8_t no_srv_logged; /* "未配置服务器"只警一次(配置态,不是故障) */ uint8_t ready_posted; /* SYS_EVT_TIMESYNC_READY 已投(一次性) */ uint32_t sync_count; /* 校验通过次数(含死区内未步进) */ int32_t last_off_ms; /* 最近一次钟差(int64 截断,诊断用) */ SntpTimestamp_t t1; /* 本轮请求时间(serializer 要求跨收发保存) */ uint8_t pkt[SNTP_PACKET_BASE_SIZE]; /* 请求报文(48B,静态省栈) */ } s_ctx; static uint32_t s_rng; /* xorshift32:散布/防欺骗随机数(非密钥用途) */ static uint8_t s_rng_seeded; /* 私有函数区 */ /* * 函数功能:PRNG 播种一次(芯片 96 位 UID 折叠 ^ tick ^ 当前钟面) * 入口参数:无 * 返回值:无 * 限定条件:任务上下文 * 函数说明:F407 软件上无可用硬件 RNG 通路(HAL_RNG 在 hal_conf 关闭), * 此处只需要"不可预测的 16 位低分数 + 上电散布",UID 折叠足够; * 非 MCU 构建(宿主单测/模拟器)不碰 0x1FFF7A10,用固定盐—— * 防欺骗强度依赖的是应答 echo 回 T1(含随机低 16 位)的攻击者 * 不可知性,真机上由 UID+tick 提供 */ static void s_rng_seed_once(void) { uint32_t seed; if (s_rng_seeded != 0u) { return; } #ifdef STM32F407XX { const volatile uint32_t *uid = (const volatile uint32_t *)0x1FFF7A10UL; seed = uid[0] ^ (uid[1] * 2654435761UL) ^ uid[2]; } #else seed = 0x9E3779B9UL; #endif seed ^= (uint32_t)xTaskGetTickCount(); seed ^= sys_clock_get(); if (seed == 0u) { seed = 0xA5A5A5A5u; } s_rng = seed; s_rng_seeded = 1u; } /* * 函数功能:xorshift32 随机数 * 入口参数:无 * 返回值:伪随机 uint32(播种前兜底返回黄金比例常数,不会为 0) uint32_t */ static uint32_t s_rng_next(void) { uint32_t x = s_rng; if (x == 0u) { x = 0x9E3779B9u; } x ^= x << 13; x ^= x >> 17; x ^= x << 5; s_rng = x; return x; } /* * 函数功能:回绕安全的"到点"判断 * 入口参数:now - 当前 tick;due - 目标 tick uint32_t * 返回值:1 - 已到/已过;0 - 未到 uint8_t */ static uint8_t s_tick_due(uint32_t now, uint32_t due) { return ((int32_t)(now - due) >= 0) ? 1u : 0u; } /* * 函数功能:IPv4 四字节是否全零(未配置) * 入口参数:p_ip - 4 字节 const uint8_t * * 返回值:1 - 全零;0 - 已配置 uint8_t */ static uint8_t s_ip_is_zero(const uint8_t *p_ip) { return ((p_ip[0] | p_ip[1] | p_ip[2] | p_ip[3]) == 0u) ? 1u : 0u; } /* * 函数功能:取当前应使用的服务器 IP(按 s_ctx.srv 选主/备,缺位自动落另一侧) * 入口参数:无 * 返回值:4 字节 IP 指针;主备都未配置返回 NULL const uint8_t * */ static const uint8_t *s_server_ip(const sntp_cfg_t *p_cfg) { const uint8_t *pri = p_cfg->server_pri; const uint8_t *bak = p_cfg->server_bak; if (s_ctx.srv == 0u) { if (s_ip_is_zero(pri) == 0u) { return pri; } s_ctx.srv = 1u; } if (s_ip_is_zero(bak) == 0u) { return bak; } if (s_ip_is_zero(pri) == 0u) { s_ctx.srv = 0u; return pri; } return NULL; } /* * 函数功能:主备切换(仅两侧都配置时有效;失败退避时调用) * 入口参数:p_cfg - 配置副本 const sntp_cfg_t * * 返回值:无 */ static void s_server_toggle(const sntp_cfg_t *p_cfg) { if ((s_ip_is_zero(p_cfg->server_pri) == 0u) && (s_ip_is_zero(p_cfg->server_bak) == 0u)) { s_ctx.srv = (s_ctx.srv == 0u) ? 1u : 0u; } } /* * 函数功能:当前 UTC 秒(钟面有效才返回) * 入口参数:p_utc - 输出 UTC 秒 int64_t * * 返回值:1 - 钟面有效;0 - 未初始化/早于合理性下限 uint8_t */ static uint8_t s_utc_now(int64_t *p_utc) { uint32_t face = sys_clock_get(); if ((face == UINT32_MAX) || ((uint64_t)face < (uint64_t)SNTP_UNIX_MIN_VALID + SNTP_TZ_LOCAL_OFFSET_SECS)) { return 0u; } *p_utc = (int64_t)face - (int64_t)SNTP_TZ_LOCAL_OFFSET_SECS; return 1u; } /* * 函数功能:取当前 SNTP 时间戳(1900 纪元秒 + 毫秒分数) * 入口参数:p_ts - 输出 SntpTimestamp_t * * 返回值:无 * 函数说明:钟面无效(冷启动)时用"纪元差 + 开机秒 + 随机扰动"构造非零且 * 每次上电唯一的时间戳——serializer 只要求 T1 非零防欺骗,冷启动 * 路径的成功处理不使用 clockOffset(直接采用服务器 T3),乱值无害 */ static void s_ts_now(SntpTimestamp_t *p_ts) { int64_t utc; uint32_t ms; ms = (uint32_t)xTaskGetTickCount(); if (s_utc_now(&utc) != 0u) { /* uint32 加和天然回绕 = SNTP era 进位语义(2036 年后仍成立) */ p_ts->seconds = (uint32_t)(utc + (int64_t)SNTP_TIME_AT_UNIX_EPOCH_SECS); } else { p_ts->seconds = SNTP_TIME_AT_UNIX_EPOCH_SECS + (ms / 1000u) + (s_rng_next() & 0xFFFFu); } p_ts->fractions = (ms % 1000u) * (1000u * SNTP_FRACTION_VALUE_PER_MICROSECOND); } /* * 函数功能:打开并绑定 UDP socket(客户端侧动态端口) * 入口参数:gen - 当前网络代次 uint32_t * 返回值:0 - 成功;-1 - 失败(fd 保持 -1) int * 函数说明:UDP bind 恒开芯片服务器形态 Socket(PR #74 后语义), * 端口 0 = 动态分配;发送用带目的地址的 net_sendto,不经 * net_udp_set_peer 轻切换(每包显式对端,源过滤未证不依赖) */ static int s_socket_open(uint32_t gen) { struct net_sockaddr_in addr; int s; s = net_socket(NET_AF_INET, NET_SOCK_DGRAM); if (s < 0) { DBG_ERROR("net_socket FAIL errno=%d", net_get_errno()); return -1; } (void)memset(&addr, 0, sizeof(addr)); addr.sin_family = NET_AF_INET; addr.sin_port = net_htons(0u); /* 0 = 动态本地端口 */ addr.sin_addr.s_addr = NET_INADDR_ANY; if (net_bind(s, (struct net_sockaddr *)&addr, (int)sizeof(addr)) != 0) { DBG_ERROR("udp bind FAIL errno=%d", net_get_errno()); net_close(s); return -1; } s_ctx.fd = s; s_ctx.gen = gen; return 0; } /* * 函数功能:关闭 UDP socket(代次变化 / 配置停用 / 死 fd 时调用) * 入口参数:无 * 返回值:无 */ static void s_socket_close(void) { if (s_ctx.fd >= 0) { net_close(s_ctx.fd); s_ctx.fd = -1; } } /* * 函数功能:一次失败收口——超时翻倍(封顶)并重排下一次请求 * 入口参数:now - 当前 tick uint32_t * 返回值:无 * 函数说明:状态回 WAIT_DUE;socket 保留(轻问题不重开,代次变化才重开) */ static void s_fail_backoff(uint32_t now) { uint32_t t = s_ctx.timeout_ms * 2u; if (t > SNTP_RESP_TIMEOUT_MAX_MS) { t = SNTP_RESP_TIMEOUT_MAX_MS; } s_ctx.timeout_ms = t; s_ctx.due_ms = now + t; s_ctx.state = SNTP_ST_WAIT_DUE; } /* * 函数功能:int64 毫秒钟差截断到 int32(日志/诊断口) * 入口参数:off - 毫秒钟差 int64_t * 返回值:截断后的 int32 毫秒 int32_t */ static int32_t s_off_clamp(int64_t off) { if (off > 2147483647LL) { return 2147483647; } if (off < -2147483647LL) { return -2147483647; } return (int32_t)off; } /* * 函数功能:校验通过的应答落地——步进策略 + 节奏复位 + 就绪事件 * 入口参数:p_resp - 解析出的应答数据;now - 当前 tick uint32_t * 返回值:无 * 函数说明:见文件头步进策略注释;**任何校验通过都算一次成功同步** * (含死区内未步进),复位退避并按 poll_min 重排下一轮; * 首个成功投 SYS_EVT_TIMESYNC_READY(状态位,不消费); * 每次实际步进落一条 quiet 操作记录("clock set by SNTP", * 与 8888/104 同口径,失败不落痕) */ static void s_handle_success(const SntpResponseData_t *p_resp, uint32_t now) { int64_t utc; int64_t new_utc; int32_t off_ms = s_off_clamp(p_resp->clockOffsetMs); uint8_t clock_valid; s_ctx.sync_count++; s_ctx.last_off_ms = off_ms; s_ctx.timeout_ms = SNTP_RESP_TIMEOUT_BASE_MS; s_ctx.due_ms = now + (uint32_t)sntp_cfg_poll_min() * 60000u; s_ctx.state = SNTP_ST_WAIT_DUE; if ((s_ctx.ready_posted == 0u) && (sys_evtHandle != NULL)) { (void)osEventFlagsSet(sys_evtHandle, SYS_EVT_TIMESYNC_READY); s_ctx.ready_posted = 1u; } clock_valid = s_utc_now(&utc); if (clock_valid != 0u) { int64_t off = p_resp->clockOffsetMs; if ((off <= (int64_t)SNTP_STEP_DEADBAND_MS) && (off >= -(int64_t)SNTP_STEP_DEADBAND_MS)) { /* 死区:本地钟足够准,不拨针也不写 RTC(防 SD2506 无谓磨损) */ DBG_INFO("off=%ldms within deadband, no step", (long)off_ms); return; } /* 四元组中点估计:把"服务器秒"折算到本地应有的 UTC 秒 */ { int64_t off_s = (off >= 0) ? ((off + 500) / 1000) : ((off - 500) / 1000); new_utc = utc + off_s; } } else { /* 冷启动(钟面未初始化/早于 2020):钟差不可信,直接采用服务器 T3 */ uint32_t t3_secs = 0u; uint32_t t3_us = 0u; if (Sntp_ConvertToUnixTime(&p_resp->serverTime, &t3_secs, &t3_us) != SntpSuccess) { DBG_ERROR("unix convert FAIL"); return; } new_utc = (int64_t)t3_secs; } if ((new_utc < (int64_t)SNTP_UNIX_MIN_VALID) || (new_utc > (int64_t)SNTP_UNIX_MAX_UTC)) { DBG_ERROR("target unix out of domain, skip step"); return; } (void)sys_clock_set_unix((uint32_t)(new_utc + (int64_t)SNTP_TZ_LOCAL_OFFSET_SECS)); syslog_post_quiet(SYSLOG_OPER, "clock set by SNTP"); DBG_INFO("step offset=%ldms face=%lu", (long)off_ms, (unsigned long)(uint32_t)(new_utc + (int64_t)SNTP_TZ_LOCAL_OFFSET_SECS)); } /* * 函数功能:收包校验 + 反欺骗判据 + 解析落地(WAIT_RESP 态的单包处理) * 入口参数:p_buf - 载荷;len - 长度;p_src - 源地址;now - tick uint32_t * 返回值:1 - 本轮已收口(成功或按失败处理,调用方可停止收片); * 0 - 该包丢弃,继续等到 deadline uint8_t * 函数说明:判据顺序 = 长度 → 源地址(IP+端口必须 = 本轮请求目的)→ * stratum≤15 → LI≠3(服务器未同步告警态)→ serializer 校验 * (mode=4、origin 与 T1 含随机低 16 位精确匹配、时间戳非零、 * KoD 分类)。调研 §5.3:LI=3 / stratum>15 serializer 不拦, * 必须在这里补;KoD 与畸形应答按失败退避(KoD 语义里 * ChangeServer 换服务器,RATE 保留当前服务器) */ static uint8_t s_handle_packet(const uint8_t *p_buf, int len, const struct net_sockaddr_in *p_src, const uint8_t *p_srv_ip, uint32_t now) { const sntp_cfg_t *cfg = sntp_cfg_get(); SntpResponseData_t resp; SntpTimestamp_t t4; SntpStatus_t st; if (len < (int)SNTP_PACKET_BASE_SIZE) { DBG_INFO("WARN: short resp len=%d, drop", len); return 0u; } if ((memcmp(&p_src->sin_addr.s_addr, p_srv_ip, 4u) != 0) || (p_src->sin_port != net_htons(SNTP_SERVER_PORT))) { /* 防欺骗第一道:应答源必须是我们刚发过请求的那个 IP:123 */ DBG_INFO("WARN: resp from unexpected src, drop"); return 0u; } if ((p_buf[1] & 0x7FU) > 15u) { DBG_INFO("WARN: stratum=%u invalid, drop", (unsigned)(p_buf[1] & 0x7FU)); s_fail_backoff(now); return 1u; } if ((p_buf[0] >> 6) == 3u) { /* LI=3:服务器自己没同步,时间不可用;再等多久也一样 → 直接失败 */ DBG_INFO("WARN: server not synced (LI=3)"); s_server_toggle(cfg); s_fail_backoff(now); return 1u; } s_ts_now(&t4); st = Sntp_DeserializeResponse(&s_ctx.t1, &t4, p_buf, (size_t)len, &resp); switch (st) { case SntpSuccess: s_handle_success(&resp, now); return 1u; case SntpRejectedResponseChangeServer: case SntpRejectedResponseOtherCode: DBG_INFO("WARN: kiss-of-death code=%lu, switch server", (unsigned long)resp.rejectedResponseCode); s_server_toggle(cfg); s_fail_backoff(now); return 1u; case SntpRejectedResponseRetryWithBackoff: DBG_INFO("WARN: kiss-of-death RATE, backoff"); s_fail_backoff(now); return 1u; case SntpInvalidResponse: default: /* originate 不匹配 / 时间戳为零 / mode 错 = 伪造或损坏:丢弃继续等 */ DBG_INFO("WARN: invalid resp st=%d, drop", (int)st); return 0u; } } /* * 函数功能:发起一轮请求(SEND 态执行体:选服务器 → 开 socket → 序列化 → 发送) * 入口参数:net_gen - 网络代次;now - 当前 tick uint32_t * 返回值:无 * 函数说明:成功 → 状态进 WAIT_RESP;任一失败 → 主备切换(适用时)+ * s_fail_backoff 退避重排;服务器未配置 → 停在 WAIT_DUE 只警一次, * 等下一次 run(配置补齐即恢复,不产生退避——没发过请求不算失败) */ static void s_send_round(uint32_t net_gen, uint32_t now) { const sntp_cfg_t *cfg = sntp_cfg_get(); const uint8_t *srv_ip; struct net_sockaddr_in dst; srv_ip = s_server_ip(cfg); if (srv_ip == NULL) { if (s_ctx.no_srv_logged == 0u) { DBG_INFO("WARN: enabled but no server configured"); s_ctx.no_srv_logged = 1u; } return; } s_ctx.no_srv_logged = 0u; if (s_ctx.fd < 0) { if (s_socket_open(net_gen) != 0) { s_fail_backoff(now); return; } } s_ts_now(&s_ctx.t1); if (Sntp_SerializeRequest(&s_ctx.t1, s_rng_next(), s_ctx.pkt, sizeof(s_ctx.pkt)) != SntpSuccess) { /* T1 为 0 才会失败(ts 构造保证非零)——真到这儿是构造性 bug */ DBG_ERROR("serialize FAIL"); s_fail_backoff(now); return; } (void)memset(&dst, 0, sizeof(dst)); dst.sin_family = NET_AF_INET; dst.sin_port = net_htons(SNTP_SERVER_PORT); (void)memcpy(&dst.sin_addr.s_addr, srv_ip, 4u); if (net_sendto(s_ctx.fd, s_ctx.pkt, (int)sizeof(s_ctx.pkt), 0, (struct net_sockaddr *)&dst, (int)sizeof(dst)) != (int)sizeof(s_ctx.pkt)) { DBG_ERROR("sendto FAIL errno=%d", net_get_errno()); s_server_toggle(cfg); s_fail_backoff(now); return; } s_ctx.deadline_ms = now + s_ctx.timeout_ms; s_ctx.state = SNTP_ST_WAIT_RESP; /* 发送轮不占收片时间,立即让出回主循环 */ } /* 函数定义区 */ /** * 函数功能:SNTP 客户端单步驱动——由 timeSyncTask 主循环每轮调用一次 * 入口参数:net_gen - 当前网络代次(time_sync 循环持有) uint32_t * 返回值:无 * 限定条件:仅 timeSyncTask 上下文;单次调用最多停留一片 SNTP_RECV_SLICE_MS, * 看门狗喂点由调用方节奏保证;配置热读 [sntp] RAM 副本,无需重启 */ void time_sntp_run(uint32_t net_gen) { const sntp_cfg_t *cfg; uint32_t now = (uint32_t)xTaskGetTickCount(); s_rng_seed_once(); cfg = sntp_cfg_get(); /* 网络代次变化:旧 socket 随芯片复位失效,弃掉重排首轮散布 */ if (net_gen != s_ctx.gen) { s_socket_close(); s_ctx.gen = net_gen; s_ctx.state = SNTP_ST_IDLE; s_ctx.no_srv_logged = 0u; } if (cfg->enable == 0u) { if (s_ctx.state != SNTP_ST_IDLE) { s_socket_close(); s_ctx.state = SNTP_ST_IDLE; } return; } if (s_ctx.state == SNTP_ST_IDLE) { s_ctx.state = SNTP_ST_WAIT_DUE; s_ctx.timeout_ms = SNTP_RESP_TIMEOUT_BASE_MS; s_ctx.due_ms = now + SNTP_FIRST_DELAY_MIN_MS + (s_rng_next() % SNTP_FIRST_DELAY_SPAN_MS); } switch (s_ctx.state) { case SNTP_ST_WAIT_DUE: if (s_tick_due(now, s_ctx.due_ms) != 0u) { s_send_round(net_gen, now); } return; case SNTP_ST_SEND: s_send_round(net_gen, now); return; case SNTP_ST_WAIT_RESP: { uint8_t rxbuf[SNTP_RX_BUF_SIZE]; struct net_sockaddr_in src; int src_len = (int)sizeof(src); int n; (void)memset(&src, 0, sizeof(src)); n = net_recvfrom_to(s_ctx.fd, rxbuf, (int)sizeof(rxbuf), SNTP_RECV_SLICE_MS, (struct net_sockaddr *)&src, &src_len); if (n > 0) { const uint8_t *srv_ip = s_server_ip(cfg); if (srv_ip == NULL) { s_fail_backoff(now); return; } (void)s_handle_packet(rxbuf, n, &src, srv_ip, now); return; } if (n == -2) { /* 本片等满无数据:看总截止 */ if (s_tick_due(now, s_ctx.deadline_ms) != 0u) { DBG_INFO("WARN: resp timeout, tmo=%lu ms", (unsigned long)s_ctx.timeout_ms); s_server_toggle(cfg); s_fail_backoff(now); } return; } if (n == -1) { int e = net_get_errno(); DBG_ERROR("recvfrom FAIL errno=%d", e); if ((e == NET_ERR_BADF) || (e == NET_ERR_CONNRESET) || (e == NET_ERR_INVAL)) { /* fd 已死(重建取消/非法):关掉等代次/下轮重开 */ s_socket_close(); s_fail_backoff(now); } /* 其余瞬时错误:继续等到 deadline,超时自然收口 */ return; } /* n == 0:UDP 语义下不应出现(EOF 是 TCP 概念),防御收口 */ s_fail_backoff(now); return; } default: s_ctx.state = SNTP_ST_IDLE; return; } } /** * 函数功能:累计校验通过的同步次数(诊断口) * 入口参数:无 * 返回值:同步成功次数 uint32_t * 限定条件:任意上下文只读 */ uint32_t time_sntp_sync_count(void) { return s_ctx.sync_count; } /** * 函数功能:最近一次校验通过应答的毫秒钟差(诊断口,int64 截断值) * 入口参数:无 * 返回值:(T2-T1+T3-T4)/2 毫秒,截断到 int32;从未成功为 0 int32_t * 限定条件:任意上下文只读;仅日志/排障用,不作判据 */ int32_t time_sntp_last_offset_ms(void) { return s_ctx.last_off_ms; }