1. 前言

此前,我写了几篇介绍从零开始实现一个使用 TDOA 技术的 UWB 精确定位系统的文章,其中介绍的 TDOA 技术是上行 TDOA。最近,我完整实现了基于下行 TDOA 技术的 UWB 精确定位系统。在研究下行 TDOA 技术期间,我写过几篇相关的文章。现在,我把全部信息汇总,并结合最近的一些心得体会,写成此文。

1.1 UWB 简介

在进入 TDOA 原理之前,先简单介绍一下 UWB(Ultra-Wide Band,超宽带)的基本概念,帮助没有 UWB 背景的工程师快速建立认知。

UWB 是一种短距离无线通讯技术,它与传统的 WiFi、蓝牙有本质的不同:

  • WiFi/蓝牙使用持续的正弦载波,接收方通过解调载波来恢复数据。这类信号的带宽通常只有几十 MHz。
  • UWB 使用极短的脉冲信号(通常在亚纳秒到几纳秒之间),信号带宽达到 500MHz 以上。正是因为带宽极大,所以被称为"超宽带"。

为什么"带宽大"就能"定位准"?

简单来说,脉冲越窄,在时间轴上的分辨率就越高。UWB 脉冲信号的分辨能力可以精确到亚纳秒级别。考虑到电磁波以光速传播——1 纳秒在空气中飞行大约 30 厘米——这意味着如果我们能精确测量信号到达的时间,理论上就能得到厘米级的距离测量精度。这是 WiFi 信号(带宽只有几十 MHz,对应数米的分辨率)根本无法企及的。

如果你对"基于时间的定位"还不太理解,可以想象一下打雷: 闪电瞬间发生(光速传播几乎没有延迟),之后我们才听到雷声(声速约 340 m/s)。通过闪电和雷声之间的时间差,我们可以估算出雷电发生地离我们的距离。UWB 定位的原理非常类似,只不过我们捕捉的不是声波而是电磁脉冲——因为光速极快,所以 UWB 芯片里的计时器需要拥有极高的分辨率(DW3000 芯片的内部计时器分辨率约 15.65 皮秒,相当于约 4.7 毫米的空间分辨率),才能精准抓取这短暂的飞行时间差异。

在本文中,我们选用的 UWB 芯片是 Qorvo DW3000,其内部计时器为 40 位宽,分辨率约 15.65ps/tick。40 位计时器的满量程约为 17.2 秒——也就是说,计时器每隔约 17.2 秒就会溢出归零(overflow)。在软件设计中需要注意处理这个溢出,后续章节我们会讲到。

1.2 TDOA 定位的原理

TDOA 是什么?TDOA 的英文全称是 Time Difference of Arrival(到达时间差)。其实我们日常使用的 GPS/北斗导航定位技术就是 TDOA——手机端的 GPS 芯片根据接收到的不同卫星信号的时间差,计算手机所在位置。GPS/北斗使用的是下行 TDOA 定位,就是由被定位的终端自己计算自己的坐标

下行 TDOA 介绍起来比较复杂,我们先以上行 TDOA 为例说明 TDOA 定位的原理。

需要被定位的 Tag(标签)发出 UWB 无线定位信号,附近的 Anchor(基站/锚点)会收到 Tag 发出的定位信号。无线电波在空中以光速飞行(约 3×108 m/s),因为各个 Anchor 到 Tag 的距离不一样,各个 Anchor 会在不同的时刻收到这个定位信号——距离远的晚一点收到,距离近的早一点收到。例如有 2 个 Anchor A 和 B,Anchor A 距离 Tag 远一点,Anchor B 近一点,这两个 Anchor 收到信号的时刻不同,两个时刻相减,就是 Tag 发出的信号到 Anchor A/B 的到达时间差。把时间差乘以光速,就得到了 Tag 到两个 Anchor 的距离差

从数学的角度来看,假设 Tag 到 A/B 间的距离差是 ΔdABΔdAB。如果以 A/B 分别作为两个焦点,我们可以在空间中画出一条双曲线(或三维中的双曲面),这条双曲线上的任意一个点到 A/B 间的距离差都恰好等于 ΔdABΔdAB。双曲线上的任意一个点都有可能是 Tag 的位置。

如果还有一个 Anchor C,那么我们可以得到 Tag 到这三个 Anchor 两两之间的时间差 ΔdABΔdAB / ΔdACΔdAC / ΔdBCΔdBC,从而画出 3 条双曲线,这 3 条双曲线的交点就是 Tag 的坐标。

注意: 上面的双曲线图示只是帮助你直观理解定位原理的二维示意。实际上我们的空间是三维的,数学上对应的是双曲面。

即使我们只想做二维定位(仅求 X/Y),但 Anchor 通常部署在比较高的位置(例如天花板),而 Tag 则在比较矮的位置(例如佩戴在人的胸前)。计算坐标时,仍要按照三维空间来计算,只不过可以把 Tag 的 z 坐标设置为一个固定常数(例如 150cm)。

如果想要进行真正的三维定位(X/Y/Z 全部未知),只有 3 个 Anchor 是不够的。3 个双曲面相交得到的是一条曲线,而不是一个点。进行三维定位,至少需要 4 个 Anchor

关于"几何精度因子(GDOP)"的小知识:

跟 GPS 卫星定位类似,Anchor 的空间几何分布对定位精度有极大的影响。如果所有 Anchor 都挤在一个很小的区域内,那么双曲面的交线/交点几乎是平行的,微小的时间测量误差会引起巨大的坐标偏差。反之,如果 Anchor 均匀分布在 Tag 周围(理想情况下包围住 Tag),定位精度会好得多。这就是"几何精度因子"(Geometric Dilution of Precision, GDOP)的概念。在部署 Anchor 时,务必注意它们的空间分布均匀性。

通常,为了得到较好的定位效果,我们部署的 Anchor 数量会超过最小数量要求。在做三维定位时,还要把部分 Anchor 部署在比较矮的位置,例如与 Tag 大致同高或更低的地方。

上面所描述的过程,就是上行 TDOA 的原理。Anchor 收到定位数据包时,会记录收到数据包的时间戳,然后把定位数据包及时间戳发送到 RTLE(Real-Time Location Engine,定位引擎,运行在服务器上的软件)去计算坐标。通常,上行 TDOA 的坐标计算都是由 RTLE 集中完成的。

时钟同步

我们知道,计算坐标时需要知道时间差。Tag 发送定位数据包的时间是确定的,但是各个 Anchor 收到定位数据包那个时刻所记录的时间戳,必须基于同一个时间基准,才有可能进行比较。

一般情况下,每个 Anchor 端 UWB 芯片所使用的石英晶振产生的频率都会有细微的差异——可能是因为晶振、电容、芯片的制造差异,也可能是运行中电压、湿度、温度的细微变化。总之,每个 UWB 芯片的内部计数器频率都略有不同。经过一段时间之后,这个差异会越来越大。而接收数据包时的"时间戳"实际上就是 UWB 芯片计数器的值,所以不同 UWB 设备的时间戳直接相减是没有意义的。我们必须有一个统一的时间基准。

举个例子:在跑步比赛时,如果为每个运动员使用单独的秒表计时,但每一个秒表的走时快慢不同,即使看上去大家都显示"10 秒",走得快的那个秒表其实没有用到真正的 10 秒,而走得慢的那个秒表花的时间则超过了 10 秒。

类比家里的石英钟: 大家都有过这样的经验——家里的挂钟和手表哪怕在同一天对准时间,过几天就会出现几秒甚至几十秒的偏差。UWB 芯片的晶振也是一样的,只不过 UWB 对时间精度的要求是纳秒级的,因此即使极微小的频率偏差也会很快变得不可接受。

通常,我们会指定一个 Anchor 作为时钟源(Root Clock Source),它定期发出时钟同步信号。其他 Anchor 接收时钟源的同步信号,并在内部维持一个与时钟源一致的"全局时间"。这个过程我们称之为时钟同步(Clock Synchronization)。

从字面上来说,"时钟同步"和"时间同步"是不同的概念。但是在本文中,我们认为它们是同一个意思——都是让各个 Anchor 维持一个准确的全局时间,让我们的程序可以把 Anchor/Tag 的本地时间与全局时间互相转换。

下行 TDOA

我们知道,上行 TDOA 由 Tag 发送定位信号,各个 Anchor 接收。我们只需要把各个 Anchor 接收信号的全局时间戳相减,就得到了时间差。

下行 TDOA 要复杂一些。各个 Anchor 主动发出定位数据包,Tag 记录收到这些定位数据包的时间戳,再"想办法"得到时间差。为什么不是直接相减,而需要"想办法"呢?

原因在于:通常 Tag 的接收机只有 1 个通道,同一时刻只能接收一个信号。各个 Anchor 是在不同的时刻发出的数据包,Tag 当然是在不同的时刻收到。因为发送时间不同,所以不能直接用 Tag 收到数据包的本地时间戳相减来得到时间差(距离差)。

为了解决这个问题,Tag 端需要对 Anchor 进行锁定操作。所谓"锁定",本质上是 Tag 与某个 Anchor 进行时钟同步,在 Tag 内部维持从该 Anchor 得到的"全局时间"。这样 Tag 就可以随时将自己的本地时间转换为该 Anchor 对应的"全局时间"。

这样,如果 Tag "锁定"了 4 个 Anchor,就可以把 Tag 的某个本地时刻转换为 4 个 Anchor 各自对应的全局时间。因为 Tag 到各个 Anchor 的距离不一样,所以这 4 个"全局时间"是有差异的——把它们两两相减,就得到时间差了。

下面用一幅流程图来对比上行 TDOA 和下行 TDOA 的工作流程:

 

我们在后面的章节会对"时钟同步"和"锁定 Anchor"进行更详细的说明。

1.3 上行 TDOA vs 下行 TDOA

"上行 TDOA"和"下行 TDOA"的比较如下表所示:

对比项 上行 TDOA 下行 TDOA
定位信号发送方 Tag 发送 Anchor 发送
时钟同步精度要求 较高精度即可 需要极高精度
坐标计算 专门的服务器 RTLE 集中计算 Tag 自己计算
Tag 省电表现 非常省电(平时休眠,定期醒来发一个信号后继续休眠) 比较耗电(需要长时间保持接收状态来接收各个 Anchor 的信号)
Tag 硬件成本 很低(功能少,只需要能发送定位信号) 较高(需要较大内存和较强计算能力来完成坐标计算)
系统总成本 需要专门的服务器运行定位引擎,总成本较高 不需要专门的服务器,即使 Tag MCU 成本增加,总成本也更低
系统容量/可扩展性 Tag 数量多时,空口上行竞争激烈,需要时分/频分管理 Anchor 广播,Tag 数量无上限(只收不发)

补充说明——系统容量:

上行 TDOA 中,每个 Tag 都需要在空中发送信号,Tag 数量多的时候,UWB 信道会变得拥挤,需要复杂的时分/频分机制来管理空口资源。而下行 TDOA 中,Anchor 定期广播信号,Tag 只接收不发送,因此 Tag 的数量在理论上没有上限——你可以在同一个区域内部署任意多个 Tag,它们互不干扰。这是下行 TDOA 在大规模部署场景中非常显著的优势。

1.4 下行 TDOA 的技术要点

看过我之前系列文章的读者,应该对 TDOA 技术有了一定了解,也知道了时钟同步和坐标计算这两个核心技术难点。下面我们来分析一下下行 TDOA 在这些方面有哪些特殊的技术要求。

1.4.1 时钟同步

下行 TDOA 对时钟同步的精度要求比上行 TDOA 更高。原因如下:

在上行 TDOA 中,所有 Anchor 收到的是同一个 Tag 发出的同一个信号,时钟同步的误差只会影响各 Anchor 时间戳之间的差值计算。而在下行 TDOA 中,各个 Anchor 在不同时刻发出信号,Tag 需要通过时钟同步参数将自己在不同时刻收到数据包的本地时间戳转换为各个 Anchor 的全局时间——任何时钟同步的误差都会直接叠加到最终的时间差上。如果 Anchor 之间的同步误差为 1 纳秒,那就意味着定位结果多了大约 30 厘米的偏差。

我们知道,在无线电波传输的过程中会有遮挡和干扰,这些因素可能会让接收方收不到信号,或者收到的信号不是来自第一路径(First Path,直线传播路径)。

理论上讲,无线电波是直线传输的(这也是无线电定位的物理基础)。但实际上,无线电波存在多径传播(Multipath)现象。也就是说,无线电波从发送方到接收方的传输路径会有很多条:第一条路径是直线传输,这是我们想要的;还有其他的路径,通过反射、折射、绕射等各种方式进行传输(第二路径、第三路径……)。只有第一路径对应真正的直线距离,其他路径的传输距离都更长,到达时间也更晚。

从道理上说,接收机收到的第一个信号就是第一路径传过来的——因为两点之间直线距离最短,第一路径的信号应该最先到达。但是现实中的情况比较复杂,有时候第一路径的信号比较弱,而其他路径的信号比较强,强信号可能会"压住"第一路径的微弱信号。

通常,接收机中会有一个 AGC(自动增益控制) 电路。当收到的信号太弱时,AGC 会自动提高放大倍数;如果信号太强,AGC 会自动降低放大倍数。总之,AGC 的作用是"把弱信号弄强一点,强信号弄弱一点"。使用过短波收音机的同学可能有这样的体验:调到某个没有信号的频率时,背景噪声会逐步变大(AGC 在拼命放大);调到有信号的频率时,背景噪声就变小甚至消失(AGC 把增益调低)。如果非第一路径的信号太强,AGC 会把整体增益调低,这可能会导致第一路径的微弱信号被"埋"掉而无法检测到。

还有芯片对信号的处理方式。芯片是通过**前导码(Preamble)**来识别数据包的——接收机不断地接收无线信号,当它判断收到的信号与预期的前导码模式相符时,接收机就知道后续将要收到数据包的正文了。通常前导码会重复多次以确保接收机能正确识别。在信号比较弱、或者比较强甚至达到饱和的情况下,芯片的 **LDE(Leading Edge Detection,前导码检测算法)**在提取第一径(First Path)时会出现偏差,导致接收机无法获得准确的"接收时间戳"。

什么是 LDE / Leading Edge Detection?

当 UWB 芯片接收到一连串的信号脉冲时,它需要判断"信号从哪个精确时刻开始的?"这就是 LDE 的工作——它在接收到的信号波形中寻找前沿(Leading Edge),也就是最先到达的脉冲能量。由于多径效应,信号波形中会叠加多个延迟的副本,LDE 需要忽略这些迟到的信号副本,锁定最早到达的那个。

为了便于理解,下面用一幅图来展示第一径与多径传播的物理现象:

UWB 物理层基础:第一径(First Path)与多径传播

 

图解:

  • 红色直线:从发送端到接收端的第一径(First Path),路径最短,时间最准。
  • 灰色虚线:经过墙壁、物体反射/绕射的**多径(Multipath)**信号,路径较长,到达时间晚。

如果环境复杂或第一径被遮挡,非第一径信号可能比第一径更强。我们需要算法(如 CFO 辅助辨别、功率阈值判断等)来确保芯片锁定的是第一径信号的到达时间。

什么是 CFO(Carrier Frequency Offset)?

DW3000 芯片在接收数据包时,其内部的载波恢复环路会估算出载波频率偏移(CFO)。这个值反映了发送端和接收端晶振频率之间的差异。CFO 本身就是一种"频率差"的物理测量,它可以作为辅助信息来判断时钟漂移率(drift/skew),也可以帮助识别异常的接收事件。后续章节在讨论高级时钟同步时会用到这个值。

在实际部署中,Anchor 的安装位置是经过仔细考虑的,所以 Anchor 之间的通讯大部分时候都能准确收到第一路径的信号。但仍可能因为遮挡或干扰导致收到的不是第一路径信号。这时,我们需要能识别出来并把它过滤掉。

另一个重要的问题是:在时钟同步时,我们需要知道时钟源与 Anchor 之间的距离。使用上行 TDOA 时,这个距离偏差可以在定位引擎中统一进行补偿,不需要在时钟同步阶段处理。但在下行 TDOA 中,不再有集中式的定位引擎,时钟源与 Anchor 之间的距离偏差必须在时钟同步阶段就进行补偿。这就要求在系统配置时,把各个 Anchor 的坐标、时钟源的坐标等信息预先加载到每个 Anchor 的固件中。

1.5 系统规划

大致上,整个下行 TDOA 定位系统的结构是这样的:

 

系统组成说明:

  • 事先在现场部署一些定位 Anchor 硬件,这些 Anchor 之间保持时钟同步,并通过 WiFi 接入局域网
  • 有一些固定的或移动的定位 Tag 硬件,接收 Anchor 发出的定位信号(TimeSync 广播包),自己计算出自己的坐标
  • Tag 可以是一个不联网的独立应用系统(例如带屏幕的手持设备),也可以通过 WiFi 联网把坐标发送给应用服务器
  • 需要有 PC 端配置程序来对 Anchor 和 Tag 的各种参数进行配置(如坐标、WiFi SSID/Password、时钟同步层级等)
  • 为方便应用系统的开发,可以写一个数据聚合的服务端程序,收集各个 Tag 发来的坐标信息,再统一与应用系统对接
  • 为方便部署和调试,可以写一个简易的前端地图软件,把各个 Tag 的定位结果实时显示在地图上

针对上面的规划,要做的事大致有:

  1. 定位 Anchor 的硬件设计
  2. 定位 Tag 的硬件设计
  3. 定位 Anchor 的固件
  4. 定位 Tag 的固件
  5. PC 端配置程序
  6. 数据聚合后端程序
  7. 地图显示前端

2. 硬件选型和设计

2.1 选型

2.1.1 MCU 选型

看过我之前文章的同学,应该知道我之前设计的嵌入式系统大多使用 STM32 系列 MCU 作为主控,但后来我更倾向于使用 ESP32 系列 MCU。弃用 STM32 的原因有几个:

  • 价格波动大、供货不稳定。 我遇到过几次 STM32 涨价的冲击。国产的类 STM32 芯片很多,但每次 STM32 涨价,它们也跟着涨。
  • 国产替代芯片尚需时间考验。 在资料、技术支持等方面还不够完善。顺便提一下,很多国产芯片厂家都习惯性地保密,甚至下载 Datasheet 都要签 NDA。遇到技术问题,经常像挤牙膏一样只提供尽量少的信息。
  • STM32 资源偏少。 STM32 的目标应用偏向传统工业场景,价格合适的型号 RAM/Flash 资源都比较紧张,资源充裕的型号往往价格很高。

这次我们的设计选择了 ESP32-S3。与前面几条正好相反:ESP32 系列的价格比较稳定,也不贵;资源比较丰富,RAM 大(最大可外扩 8MB PSRAM)、MCU 计算能力强(双核 240MHz Xtensa LX7);ESP32 的官方文档和社区资料非常丰富。不过,ESP32 也有一些问题(例如后面会提到的 ISR 中浮点运算限制、字节对齐异常等),后面我会详细说到。

在 ESP32 系列 MCU 选型时,可选的型号也很多。其实我最初是打算用 ESP32(经典版本)的,后来仔细比较了几个型号之后,才决定使用 ESP32-S3。

我们对 MCU 有几条核心要求:

  • WiFi。 如果 MCU 自带 WiFi 功能,就不需要外加 WiFi 芯片了,既节省成本,也减少固件设计的复杂度。
  • WiFi 配网。 我们总得用某种方式告知固件 WiFi 的 SSID 和密码。通常使用 WiFi 配网协议(如 SmartConfig)或蓝牙,这两种方式都需要手机 APP 来完成。我个人更倾向于使用 USB 来进行这个设置(用 PC 端配置程序直接下发),所以 MCU 需要支持 原生 USB
  • 计算能力。 ESP32 系列有单核和双核的 MCU,考虑到 Tag 需要完成坐标计算(涉及矩阵运算),最好还是选双核。
  • 稳定性。 ESP32 系列有些型号使用 RISC-V 核心,但对其稳定性我不是很确定。从保守的角度,最好还是选择久经考验的 Xtensa 指令集的 MCU。

综合以上要求,最终选择了 ESP32-S3

2.1.2 UWB 芯片选型

我之前使用过 Decawave DW1000,感觉很好。但是因为无线电管理部门的频率管制要求,DW1000 支持的频率在中国不被允许。目前要用 Decawave(现在被 Qorvo 收购)的芯片,只有 DW3000 可选。DW3000 支持 Channel 5 和 Channel 9,在中国只能使用 Channel 9

DW1000 vs DW3000 频段差异:

DW1000 主要工作在 Channel 1~7(中心频率 3.5GHz~6.5GHz 范围),其中几个频段(特别是 Channel 1~4)在中国未被批准用于 UWB。DW3000 支持 Channel 5(6.5GHz)和 Channel 9(7.99GHz),其中 Channel 9 在中国的 UWB 管理规定中是被允许的。

我也考察过其他公司的 UWB 芯片:

  • NXP:他们的 UWB 芯片很难买到,资料也很难找到。NXP 的 UWB 产品线似乎更关注手机/汽车等大客户,对小批量客户不太在意。有客户反映 NXP 主推的定位模式是 TWR(Two-Way Ranging/TOF),而非 TDOA。
  • 某国产 UWB 芯片:对标 DW3000,集成了 M0 内核的 SOC,支持 4 天线、最大 31Mbps 通讯速率,功能很多。但厂家对小客户的技术支持不太到位,连数据手册都要签 NDA 才能拿到。

所以,最终的选择只有 Qorvo DW3000

DW3000 与 DW1000 类似,也提供了封装底层操作的驱动程序。可能是为了与 DW1000 兼容,提供的大部分函数名基本一样。但千万不要被函数名迷惑—— DW3000 的寄存器布局与 DW1000 有很大不同,功能上也有较大差异。即使有 DW1000 的编程经验,也必须仔细查阅 DW3000 的数据手册。

DW3000 的驱动程序可能为了考虑将来的扩展性和兼容性,多加了一层封装。这导致项目中不会用到的函数也会被编译链接进来。对于 RAM/Flash 比较紧张的 MCU,可能需要自行对 DW3000 驱动进行裁剪。

2.1.3 供电

之前的上行 TDOA 项目,我选用 POE(Power over Ethernet) 作为 Anchor 的供电方式;选择 USB 充电(QI 无线充电)+ 锂电池来为 Tag 供电,锂电池充电使用的芯片是 TP4057,这是一个线性充电芯片。

新项目的 Anchor,因为放弃了 Ethernet 而改用 WiFi 联网,所以改为使用 USB 供电或 DC 12V 供电

某些型号的 Anchor 内置锂电池,以方便用于没有市电的场景。可以定期更换 Anchor 或把 Anchor 拿去充电后再放回原处。

新项目的 Tag 延续老的供电方式——USB 充电(QI 无线充电)+ 锂电池供电。但充电芯片换成了 SLM6600 DC-DC 充电芯片。

之前使用的 TP4057 是线性充电芯片:当使用 5V 充电时,供电电压与电池当前电压之差的那部分能量全部变成热量。例如电池电压为 3.8V 时,电压差有 1.2V。如果充电电流为 500mA,那就有 1.2×0.5=0.6W 的功率转换为热量。电压差越大或充电电流越大,产生的热量越多。这不仅是能源的浪费,也给小体积设备带来散热难题。如果为了控制发热而限制充电电流,充电时间就会很长。

而使用 DC-DC 充电芯片,产生的热量取决于芯片的转换效率。SLM6600 大约有 92% 以上的充电效率,这意味着我们可以加大充电电流,在产生较小热量的情况下大幅缩短充电时间。

线性充电 vs DC-DC 充电直观对比:

  线性充电(如 TP4057) DC-DC 充电(如 SLM6600)
效率 约 Vbatt/VinVbatt/Vin(≈76%@3.8V/5V) ≈92%
500mA 充电时发热功率 ~0.6W ~0.2W
是否可以提高充电电流 受限于散热,很难超过 500mA 可以安全提高到 1A 或更高
外围元件 极少(电阻电容各 1~2 个) 需要电感和续流二极管

对于锂电池供电的设备,如何提供稳定的 3.3V 工作电压也是一个令人纠结的事。锂电池电压在 3.6V ~ 4.2V 之间(满电 4.2V,放电终止约 3.0V)。要转换为 3.3V,有两种方案:

  • LDO(低压差线性稳压器):电路简单、成本低、纹波小,但效率低(锂电池电压高于 3.3V 时多余的能量全成热量)。
  • DC-DC 开关稳压器:效率高,但电路复杂且有开关纹波。

老项目的 Tag 我使用 XC6206P332 这个 LDO 芯片做电压转换,因为老项目的 Tag 功能简单,电流比较小。

新项目我使用 TI TPS63100 这个芯片做电压转换。TPS63100 的输入电压范围是 1.8V ~ 5.5V,标称输出电流 1.5A。

TPS63100 还有一个特别重要的特点——支持无缝升降压(Buck-Boost)。锂电池在满电时电压为 4.2V(高于 3.3V,需要降压),而在放电后期可能降至 3.0V(低于 3.3V,需要升压)。TPS63100 能在整个电池工作电压范围内始终稳定输出 3.3V。由于 UWB 模块在发射信号时瞬间功耗极大(DW3000 的峰值发射电流可达 ~该 Email 地址已受到反垃圾邮件插件保护。要显示它需要在浏览器中启用 JavaScript。),稳定的 3.3V 工作电压对保证发射功率、维持系统稳定性至关重要。

2.1.4 网络

网络联网方式上,我纠结了很久。之前的上行 TDOA 项目使用 Ethernet,顺便用 POE 解决供电问题。Ethernet 的好处是网络连接稳定,接上就能用。但网线的材料成本高,部署人工费更高(尤其是在天花板上布线)。

而 WiFi 不使用网线,成本低得多。但 WiFi 存在一个配网问题:在连接 AP 之前,需要为设备设置 WiFi SSID 和 Password。现在很多 WiFi 设备通过智能手机上的 APP(使用 WiFi SmartConfig 广播或蓝牙)来配置网络。

为了减少系统的复杂性(不想为了一个配网功能就要求用户安装手机 APP),我使用 USB 来配置网络。因为我们本来就需要一个 PC 端配置程序来对设备的各种参数进行配置,如果这个配置程序同时具备 USB 通讯能力,就可以通过 USB 与设备通讯来下发 WiFi 凭证。这就优雅地解决了配网问题。

2.1.5 电量计

本项目中,Anchor 和 Tag 都有可能使用充电电池,那么电量管理就是一个重要的问题。之前的老项目中,我使用电阻分压来检测电池电压,但是电池电压与电池电量之间并不是简单的线性关系(锂电池的放电曲线呈现非线性特征,尤其是在中间段非常平坦,到末尾急剧下降)。如果我们希望精确估算电量百分比,需要复杂的统计和拟合。

为了省事,我使用了 CW2015 这个电量计芯片来检测电池电量。CW2015 内置了电池放电模型,通过电池电压查表来估算剩余电量。虽然精度不如库仑计(通过累计充放电电流来计算电量),但对于我们的应用场景已经足够了。

2.1.6 设备指示

在施工现场,Anchor 部署完成后,我们通过配置程序可以看到有很多 Anchor 在线。但是,某一个 Anchor 真的是我们期望的那一个吗?安装在某个位置的 Anchor,我们期望它是 A,但有可能实际上是 B,而 A 被错误地安装在了其他位置。我们知道,Anchor 的坐标信息是 TDOA 定位的基础——如果某些 Anchor 的位置混淆错乱,整个系统的定位结果都会出错。

所以,我为 Anchor 增加了一个高亮 RGB LED(WS2812),可以通过 PC 端配置程序远程控制这个 LED 亮灭和颜色。这样,在部署现场,配置人员可以在软件中点击某个 Anchor 让它的 LED 闪烁,然后抬头看看哪个设备在闪,就可以确认某个位置装的 Anchor 是不是我们期望的那个。

实际工程经验小贴士: 别小看这个功能。实际部署中,一个场地可能有几十上百个 Anchor,外观完全一样。没有这个远程指示灯功能,每次排查问题都要爬梯子拆下来看序列号,非常痛苦。加了 WS2812 之后,效率提升了数倍。

2.2 硬件设计

为了编程的方便,首先保证 Anchor 和 Tag 的 UWB 芯片与 MCU 的连接使用相同的 GPIO 引脚映射。这样,Anchor 和 Tag 的固件可以共用大量底层驱动代码。

实际上,在项目开始时,我直接使用一块 ESP32-S3 DevKit 开发板和 DWM3000 模块连接,组成一个最基础版本的硬件。根据需要刷写 Anchor 或 Tag 的固件,都可以正常工作。

在正式的硬件设计上,有以下几点需要注意:

  • 天线净空:UWB 芯片天线区域和 ESP32-S3 的 WiFi/BT 天线区域必须保持净空(Keep-Out Zone),让其他铜箔走线和地平面尽量远离天线,避免对天线辐射方向图造成干扰
  • 电源去耦:充电芯片和 DC-DC 芯片的输入电容、输出电容要尽量靠近电源引脚放置(缩短高频环路)
  • 控制按钮:增加一个物理按钮,作为额外的控制功能入口。例如长按恢复出厂设置,短按触发 TWR 测距等
  • 固件烧录接口:引出 ESP32-S3 的 En / IO0 / U0Rx / U0Tx / GND 等 5 个引脚,用于连接外部 USB-to-UART 模组烧写固件
  • 扩展接口:I2C 总线留几个排针,方便外接 OLED 显示模块、IMU 传感器等
  • 预留焊盘:初始版本可以把未使用到的 ESP32-S3 GPIO 引脚留下焊盘,方便后续增加功能或调试使用

ESP32-S3 DevKit 集成了一个 USB-to-UART 芯片,可以直接通过 USB 刷写固件。但对于量产 PCB 来说,没必要在每块板子上都放这颗芯片增加成本。我们使用一个单独的 USB-to-UART 模组,与 ESP32-S3 的 U0Rx 和 U0Tx 连接来刷写固件。

为了方便固件刷写,我还把 ESP32-S3 的 En 和 IO0 引出,并对 USB-to-UART 模组进行了一点改造——增加 2 个 NPN 三极管和 2 个电阻,实现自动进入下载模式(Auto-ISP)的功能。这样就不需要每次刷写固件时手动短接 EN/IO0 了。

Auto-ISP 原理简述:

ESP32-S3 进入下载模式需要在 EN 释放(上升沿)时保持 IO0 为低电平。通过两个 NPN 三极管分别控制 EN 和 IO0,由 USB-to-UART 模组的 DTR 和 RTS 信号线驱动,就可以让烧录工具(如 esptool.py)自动完成"拉低 IO0 → 复位 EN → 释放 IO0"的时序操作。

然后选一个合适的盒子,按盒子的结构画 PCB 就可以了。

3. 软件设计

软件分为几类:Anchor 固件、Tag 固件、配置程序、数据汇聚程序、前端展示等。本章将重点讨论 Anchor 和 Tag 的固件设计,这是整个下行 TDOA 系统中最核心的部分。

3.1 系统的网络架构

Anchor 和 Tag 的联网不是必须的。对于纯定位系统来说,Anchor 的作用只有两个:

  • 充当时钟同步链条中的一个节点——与上级 Anchor 同步,保持上级 Anchor 的全局时间,同时向下级 Anchor 发出时钟同步包
  • 向 Tag 发出定位数据包(实际上就是时钟同步包,后面会详细解释)

Tag 也只需要接收到足够多的 Anchor 的定位数据包,就可以自己计算自己的坐标,根本不需要联网。

但是,作为一个嵌入式系统,它不可能是一个孤岛。定位系统的价值在于为其他应用系统提供位置服务——如果不联网,其应用价值就大大降低了。另外,联网也可以简化系统的配置管理(例如远程修改 Anchor 参数、监控同步状态等)。

在介绍下行 TDOA 定位系统的网络架构之前,先回顾一下老的上行 TDOA 定位系统的架构:

上行 TDOA 系统的 Tag 不联网,Anchor 使用 Ethernet 连接到本地局域网。从业务角度看,主要有两条数据链路:

  • Anchor 配置链路:Anchor 作为 TCP 服务器,接受来自 AnchorConfig 配置程序的 TCP 连接,实现 Anchor 的参数配置功能。Anchor 与 AnchorConfig 程序之间通过 UDP 广播包实现 Anchor 的自动发现(即配置程序启动后自动扫描局域网内的所有 Anchor)。
  • Anchor 上传数据给 RTLE:上行 TDOA 系统的坐标计算由专门的服务器软件 RTLE(Real-Time Location Engine)来做。RTLE 作为 TCP 服务器端,接受来自 Anchor 的连接。RTLE 和 Anchor 之间也通过 UDP 广播实现 RTLE 的自动发现。

另外,RTLE 向应用系统提供多种接口方式,如 TCP/WebSocket/UART 等,计算好坐标后通过接口传送给应用系统。

对本项目,我们基本上也使用类似的网络架构。只是我们不再需要 RTLE(因为坐标计算由 Tag 自己完成),但我们需要一个数据汇聚程序,把来自各个 Tag 的坐标数据汇聚后统一发送给应用系统。

UWB 下行 TDOA 系统网络拓扑图

下面这幅图展示了整个系统的结构:根时钟源(Level 0)如何将时间传递给下级 Anchor(Level 1、Level 2……),观察者(Observer)如何提供反馈来提高同步精度,以及 Tag 如何独立计算坐标并选择性地联网上传数据。

 
 

图解:

  • Anchor A0 (Level 0):是整个系统的根时钟源,它的本地时间就是全局时间。
  • 实线向下箭头(ClockSync):表示层级间的时间同步包传递。每个 Anchor 定期广播时钟同步包,下级 Anchor 接收后维持与上级一致的全局时间。
  • Observer(观察者):每个观察者同时接收一对"父子" Anchor 的时钟同步包,计算它们之间的全局时间差异,并将这个差异作为反馈发回给子 Anchor,帮助子 Anchor 修正同步误差。
  • 虚线(Feedback):表示观察者发给目标 Anchor 的误差反馈包。
  • Tag (T0):独立接收所有 Anchor 的 ClockSync 包,通过"锁定"多个 Anchor 来计算自己的坐标,并通过 WiFi 将坐标上传给数据汇聚服务器。

为什么需要观察者(Observer)? 简单来说,时钟同步仅靠"父 → 子"的单向传递,误差会在多级级联中逐步累积。观察者是一个独立的第三方视角——它同时监听父和子的信号,计算出子相对于父的偏差,然后告诉子去修正。这类似于工业控制中的"闭环反馈控制",是提高多级同步精度的关键手段。后面 §3.2.2.4 会详细解释。

3.2 固件设计

Anchor 固件和 Tag 的固件很相似。主要的器件是 ESP32-S3 和 DW3000,两个设备的 UWB 外设使用的 SPI 引脚映射都一样(这是我们在硬件设计时刻意保证的),所以两个设备的固件可以共用大量底层驱动代码。

MCU 与 DW3000 的通信方式: ESP32-S3 通过 SPI 总线 与 DW3000 通信。DW3000 的所有寄存器读写、数据包收发、配置修改都是通过 SPI 完成的。ESP32-S3 充当 SPI Master,DW3000 充当 SPI Slave。SPI 时钟频率通常设置为 16~20MHz。此外,DW3000 还有一个 IRQ 引脚连接到 ESP32-S3 的一个 GPIO,用于中断通知。

很违反直觉的是,Anchor 的固件几乎是 Tag 固件的子集(或者说缩水版)。因为 Anchor 固件拥有的功能,Tag 固件都有;而 Tag 特有的功能(如坐标计算、Anchor 锁定管理等),Anchor 则没有。

在我的设想中,Tag 将来会拥有很多功能和外设。作为初版,先把最基础的功能——UWB 定位——做好,其他的附加功能以后再逐步加上。计划中的附加功能包括:

  • IMU(惯性测量单元):增加磁力计、加速度计、陀螺仪。可以在 UWB 信号丢失时辅助惯性导航(INS/UWB 融合),提高定位精度;还可以检测 Tag 静止时自动进入休眠模式以节省电量。
  • 振动马达和蜂鸣器:作为警报和提醒功能(例如进入危险区域时振动告警)。
  • 显示屏:可以是 OLED、TFT、E-Ink 等,用于显示文字消息(SMS)、简易地图等。
  • 麦克风和喇叭:可以实现控制中心与 Tag 之间的语音通讯。

以下的固件设计部分不再区分 Anchor 和 Tag,统一讨论。

3.2.1 MCU 与 UWB 芯片交互

DW3000 支持两种操作模式:中断轮询

当 DW3000 芯片内部发生特定事件(如成功接收数据包、发送完成、接收超时、接收错误等)时,可以通过 IRQ 引脚触发硬件中断,程序在中断服务程序(ISR)中对芯片进行读写。也可以不使用中断,而是在程序主循环中对芯片的状态寄存器进行轮询,检查是否有新事件发生。这两种方式各有利弊:

  • 轮询方式

    轮询方式只需要在程序的主循环中周期性地检测芯片的状态寄存器。如果发现收到了新数据包,就读取数据包内容和时间戳,然后继续轮询。

    • ✅ 程序结构简单:所有流程都是顺序处理的,不会被中断打断,不需要设置临界区/信号量/互斥锁来保护共享资源。
    • ❌ 效率较低:新数据包到达后,如果不及时读取,会妨碍后续数据包的接收(DW3000 的接收缓冲区只有一个包的容量)。所以程序循环中的各个环节都要尽量减少 MCU 的时间占用,避免"忙着干别的事而错过数据包"。
  • 中断方式

    中断方式下,程序平时正常运行自己的任务。当有新数据包到达时,DW3000 通过 IRQ 引脚触发硬件中断,MCU 立即跳转到中断服务程序读取新数据包。

    • ✅ 效率高:数据包到达后立即触发中断,中断程序可以即刻读取数据内容和时间戳,极大地减少了数据包丢失的概率。
    • ❌ 程序结构复杂:中断会打断程序的正常执行流程。ISR 中需要访问的资源有可能正在被主任务使用,因此必须设置**临界区(Critical Section)信号量(Semaphore)**来防止访问冲突。

我以前设计的上行 TDOA 使用的是轮询方式,这次改用中断方式。主要原因是下行 TDOA 的 Tag 需要同时跟踪多个 Anchor 的时钟同步包,数据包到达的频率较高,轮询方式容易丢包。

⚠️ 重要提醒——ESP32-S3 上的浮点运算限制:

ESP32-S3 带有硬件浮点运算单元(FPU),可以利用硬件加速执行 float 类型的运算。但是,float 类型的运算不能在 ISR 中执行! 原因是 ESP-IDF 的 FreeRTOS 移植版本在进入 ISR 时不会保存/恢复 FPU 的寄存器上下文。如果在 ISR 中使用 float 运算,会破坏被中断的任务的 FPU 状态,导致不可预见的数值错误。

有趣的是,double 类型的运算因为是使用软件模拟的(ESP32-S3 的 FPU 只支持单精度),反而可以在 ISR 中安全执行——因为软件模拟只使用通用寄存器,不涉及 FPU 上下文。

实际影响:在 rx_ok_cb 这个 ISR 中,如果你需要做时间戳的数学运算,请使用 uint64_t 整数或 double 类型,而不是 float

FreeRTOS 任务架构

我们为 DW3000 创建一个单独的 FreeRTOS 任务,专门处理与 UWB 芯片相关的所有操作:

void task_uwb_chip(void* p_arg)
{
	// 初始化 DW3000, 设置中断回调, 打开接收机
	// ...
	while (1) {
		// 从队列中取出 UWB 事件并处理
		// 检查是否需要发送时钟同步包
		// 处理排队发送的其他数据包
	}
}

为什么要用单独的任务? 把 UWB 操作集中在一个任务中,可以避免多个任务同时通过 SPI 访问 DW3000 而产生总线冲突。所有对 DW3000 的 SPI 读写都在这个任务的上下文中完成(ISR 中的读取除外,ISR 中只做最必要的时间戳和数据包读取)。

接收成功的 ISR

为接收成功事件注册一个回调函数(本质上是 ISR 的一部分):

static void rx_ok_cb(const dwt_cb_data_t* cb_data)
{
	DW_EVENT event;
	event.rx_timestamp = dw_get_rx_timestamp();    // 读取 40-bit 接收时间戳
	event.rx_length = cb_data->datalength;         // 数据包长度
	interface_read_rx_frame(cb_data->dw, event.frame_data, cb_data->datalength); // 读取数据包内容
	event.off_hw = dwt_readclockoffset();          // 读取 CFO(载波频率偏移)
	putUwbEventFromISR(UWB_EVENT_RX_OK, &event);   // 放入 FreeRTOS 队列
	chip_start_rx();                                // 立即重新打开接收机
}

关键设计决策:ISR 中要做多少事?

这里的 ISR 做了比较多的工作——读取时间戳、读取数据包内容、读取 CFO。这些操作都需要通过 SPI 与 DW3000 通信,耗时大约几十微秒。为什么不把这些操作延迟到主任务中再做?

原因是:如果 ISR 中不立即读取数据包内容,而是先重新打开接收机,那么当下一个数据包到达时,DW3000 会覆盖接收缓冲区中的上一个包——上一个包的数据就丢失了。所以必须在 ISR 中完成"读取 + 投递到队列"的操作,然后才能安全地重新打开接收机。

当成功收到一个 UWB 数据包后,我们把它放入一个 FreeRTOS 队列中(putUwbEventFromISR 使用的是 xQueueSendFromISR),然后立即再次打开接收机等待下一个数据包。

发送完成的 ISR

static void tx_done_cb(const dwt_cb_data_t* cb_data)
{
	putUwbEventFromISR(UWB_EVENT_TX_DONE, NULL);
}

发送完成的 ISR 非常简单——只需要通知主任务"发送已完成"即可。主任务收到这个事件后,可以重新打开接收机或进行其他后续操作。

主循环中的事件处理

在 task_uwb_chip 的主循环中,我们从队列中取出 UWB 事件并进行处理(如解析时钟同步包、更新 Kalman 滤波器状态等)。

需要发送的 UWB 数据包,我们分为两种优先级:

  • 立即发送型:对发送时间有严格要求的数据包。例如时钟同步包——一旦到了预定的发送时刻,就必须立即发送,延迟会影响同步精度。
  • 排队发送型:对发送时间要求不高的数据包,可以在主循环有空闲时再发送。例如服务器发来的通知消息、配置应答等。

对于需要立即发送的数据包,我们在主循环中判断,如果到了发送时间,立即中止接收并开始发送。

延迟发射(Delayed TX)机制

下面这段代码展示了 Anchor 定期发送时钟同步包的核心流程。这里用到了 DW3000 的一个重要特性——延迟发射(Delayed Transmit)

为什么要用"延迟发射"而不是"立即发射"?

时钟同步包中最重要的信息是"这个包精确地在什么时刻离开了天线"。如果使用"立即发射"(Immediate TX),数据包会在不确定的时刻离开天线——受到 SPI 通信延迟、MCU 处理时间等不可预知因素的影响——我们事后只能从 DW3000 的寄存器中读回实际发射时间戳,但此时数据包已经发出去了,里面的时间戳已经无法修改。

延迟发射的巧妙之处在于:我们预先告诉 DW3000"在未来某个精确的时刻发射"。这样,我们就可以在发射之前精确计算出这个时刻对应的全局时间戳,并把它写入数据包的 payload 中。DW3000 的硬件定时器会在精确的时刻自动触发发射,保证数据包中携带的时间戳与实际发射时刻完全一致。

if (TimeIsOver(last_clock_sync_time, deviceConfig.clock_sync_interval)) {
	last_clock_sync_time = getSystemTimeMS();
	BROADCAST_DL_CLOCK_SYNC_MESSAGE dlClockSyncMessage;

	dwt_forcetrxoff();  // 强制停止接收

	/* Step 1: 读取当前 DW3000 时间 (高 32 位) */
	uint32_t tx_time32 = dwt_readsystimestamphi32();

	/* Step 2: 加入 ~1.3ms 延迟 (确保有足够时间准备数据包)
	 * UUS_TO_DWT_TIME 是微秒到 DW3000 tick 的转换常数 */
	tx_time32 += ((1300 * UUS_TO_DWT_TIME) >> 8);
	tx_time32 &= 0xFFFFFFFE;   /* DW3000 的延迟发射寄存器忽略低 9 位,
								   确保最低位为 0 以避免对齐问题 */

	/* Step 3: 设置延迟发射时间 */
	dwt_setdelayedtrxtime(tx_time32);

	/* Step 4: 精确计算信号实际离开天线的 40-bit 本地时间
	 * 延迟发射寄存器只有高 32 位, 需要左移 8 位恢复为 40-bit
	 * 再加上 TX 天线延迟(信号从芯片内部到天线的传播延迟) */
	uint64_t tx_local_40 = ((uint64_t)tx_time32 << 8) + permanent_data.tx_antenna_delay;
	tx_local_40 &= UWB_MASK_40BIT;  // 确保不超过 40 位

	/* Step 5: 将本地时间转换为全局时间 */
	uint64_t tx_global;
	if (s_sync.is_root) {
		/* 根 Anchor: 本地时间即全局时间 */
		tx_global = tx_local_40;
	}
	else {
		/* 子 Anchor: 需要把本地时间转换为全局时间
		 * sync_extend_timestamp: 将 40-bit 扩展为 64-bit(处理溢出)
		 * sync_local_to_global: 应用 Kalman 滤波器的 offset 和 drift 进行转换 */
		uint64_t tx_local_full = sync_extend_timestamp(&s_sync, tx_local_40);
		uint64_t tx_global_full = sync_local_to_global(&s_sync, tx_local_full);
		tx_global = tx_global_full & UWB_MASK_40BIT;
	}

	/* Step 6: 生成同步包,将全局时间戳写入 payload */
	GenerateUWBMessage_BROADCAST_DL_CLOCK_SYNC_MESSAGE(&dlClockSyncMessage, tx_global);

	/* Step 7: 写入 DW3000 发射缓冲区并启动延迟发射 */
	dwt_writetxdata(sizeof(dlClockSyncMessage), (uint8_t*)&dlClockSyncMessage, 0);
	dwt_writetxfctrl(sizeof(dlClockSyncMessage), 0, 1);

	int err = dwt_starttx(DWT_START_TX_DELAYED);
	if (err == DWT_SUCCESS) {
		chip_state = CHIP_STATE_TX;
	}
	else {
		/* 延迟发射失败(通常是因为设定的发射时间已经过去了)
		 * 放弃本次发送, 重新打开接收机 */
		chip_state = CHIP_STATE_IDLE;
	}
}

延迟发射失败的处理: dwt_starttx(DWT_START_TX_DELAYED) 返回错误通常意味着我们计算的发射时间点已经过去了(即 MCU 准备数据包的时间超过了预留的 1.3ms)。这种情况下,我们只能放弃本次发送,等待下一个发送周期。在实际运行中,这种情况极少发生,但代码中必须处理这个边界情况。

3.2.2 时钟同步

在前面我们介绍 TDOA 的原理时,已经解释了时钟同步的重要性。下行 TDOA 的 Tag 也需要"锁定"附近的 Anchor 才能计算自己的坐标。所谓的"锁定",本质上就是 Tag 与这些 Anchor 进行时钟同步——在 Tag 内部为每个锁定的 Anchor 维护一套同步参数,随时可以将 Tag 的本地时间转换为该 Anchor 的"全局时间"。

我们知道,因为多种原因(晶振制造误差、温度变化、电压波动等),UWB 设备的振荡频率会有差异,这导致各设备计时器的走速不同。通常,我们指定一个 Anchor 作为时钟源,其他 Anchor 与时钟源进行时钟同步,维持与时钟源一致的全局时间。

3.2.2.1 简易版时钟同步

在之前的项目中我使用的时钟同步方法,我称之为"简易版时钟同步"。原理确实很简单,实现也简单,但已经能在理想环境下工作得不错。

算法推导

假设要把 Anchor A 的时间同步给 Anchor B。换句话说,以 Anchor A 作为时钟源,Anchor B 与 Anchor A 同步——在 Anchor B 内部维持一个 Anchor A 的全局时间,并提供 Anchor B 本地时间与 Anchor A 全局时间之间的双向转换。

Anchor A 定期发送时钟同步数据包,数据包中携带发送时的全局时间戳。Anchor B 接收 Anchor A 发来的同步包,同时记录下自己收到该数据包时的本地时间戳。

我们观察连续的 3 个数据包。假设 Anchor A 的发送时间戳分别是 TC1、TC2、TC,Anchor B 对应的接收时间戳分别是 TA1、TA2、TA。

如果这两个 Anchor 的时钟走速在短期内都是稳定且均匀的(即频率恒定,只是频率不同),那么我们可以得到以下等比例关系

(TC2−TC1)/(TA2−TA1) = (TC−TC2)/(TA−TA2)

直觉理解: 这个等式的含义是——Anchor A 走过的时间间隔与 Anchor B 走过的时间间隔之间的比例关系是恒定的。就像两把尺子刻度不同,但比例是固定的。左边的 Anchor A "等比例缩放"到右边的 Anchor B。

上面的等式变换一下,可以得到:

TC = (TC2−TC1)/(TA2−TA1)×(TA−TA2)+TC2

如果设 k=(TC2−TC1)/(TA2−TA1),则:

TC = k×(TA−TA2)+TC2

这是典型的直线方程 y=kx+bk 是直线的斜率,在这里它的值应该非常接近 1.0(因为两个 Anchor 的频率差异通常只有几个 ppm)。

为了方便表达和提高数值精度,通常我们会把 k 减去 1.0,得到一个接近 0 的小值:

factor = (TC2−TC1)/(TA2−TA1) − 1.0

factor 的物理含义:

  • 如果 :说明 Anchor A(时钟源)这边的时间间隔比 Anchor B 大,即 Anchor B 的时钟走得于 Anchor A。
  • 如果 factor<0:说明 Anchor B 的时钟走得于 Anchor A。
  • factor 的典型值在 ±20×10−6(即 ±20 ppm)范围内,对应普通石英晶振的频率偏差。

为了让大家更直观地理解时钟漂移的概念,请看下面的示意图:

时钟漂移(Clock Drift)与 factor(频率偏移因子)

图解:

  • 横轴(Global Time):是绝对准确的全局时间(即时钟源 A0 的时间)。
  • 纵轴(Local Timer):是两个设备(A0 和 A1)的本地计数器值。
  • 蓝线(斜率 = 1.0):代表时钟源 A0,它的本地时间与全局时间完全一致。
  • 橙线(斜率 > 1.0):代表 Anchor A1,它的晶振频率比 A0 快,所以计数器值增长更快。

两条线的斜率不同,表明它们的走时快慢不同。factor 本质上就是这两条线斜率的比值减去 1。Tag 需要实时计算每个它锁定的 Anchor 的 factor,才能在任意时刻将自己的本地时间准确转换为该 Anchor 的全局时间。

转换公式的实际应用

TC = (factor+1.0)×(TA−TA2)+TC2

这个表达式的各变量含义如下:

  • factor:时钟差异因子(频率偏差比)
  • TA2:最近一次收到时钟同步包时,Anchor B 的本地接收时间戳
  • TC2:最近一次收到的同步包中携带的 Anchor A 全局发送时间戳
  • TA:Anchor B 的当前本地时间(我们想要转换的时间点)
  • TCTA 对应的 Anchor A 全局时间(转换结果)

注意公式细节: 原文中写的是 (factor−1.0),但根据 factor=k−1.0 的定义,代入 TC=k×(TA−TA2)+TC2 应该是 。这是一个容易搞混的地方,实现时请仔细核对。

改进:滤波处理

每次收到新的时钟同步包时,我们可以用当前的同步参数预测该同步包的发送时间,然后与实际的发送时间戳比较。两者之间的差异反映了同步参数的精度——差异越小,说明我们的 factor 越准确。

大部分情况下,预测值与实际值会有一定差异,这也是我们需要定期进行时钟同步的原因。每当收到新的同步包,我们都要重新计算 factor,更新 TA2 和 TC2

为了保持 factor 的稳定性,我们可以使用滑动均值滤波卡尔曼滤波来平滑 factor 的值,避免因为单次接收时间戳的抖动导致 factor 剧烈变化。

工程经验: 在简单场景下(Anchor 间距小、无遮挡),用最近 5~10 次同步包计算 factor 的滑动平均值就足够了。但在干扰较大的环境中,需要更高级的方案——这就是下一节"高级版时钟同步"要解决的问题。

3.2.2.2 高级版时钟同步

前面描述的"简易版时钟同步"在办公室等理想环境下工作得不错,但在干扰较大的环境(如工厂车间、仓库等金属结构物较多的场所)会有问题。

特别是当我们需要多级时钟同步时,问题会更加突出。DW3000 在 6.8Mbps 的通讯速率下,覆盖范围大约在 30 米以内;在 850Kbps 的通讯速率下,覆盖范围在 100~200 米内。而我们要定位的区域通常会大于这个范围。在这种情况下,只能把全部 Anchor 按层级划分,通过级联方式进行时钟同步。

级联同步的核心风险——误差累积:

使用级联方式同步,一旦某一级引入了时间戳误差,这个误差会逐级传播并累积。例如 Level 0 → Level 1 引入了 1ns 误差,Level 1 → Level 2 又引入 1ns,到 Level 2 就累积了 2ns 的误差(约 60cm 的定位偏差)。级数越多,累积越严重。

在比较恶劣的环境下,下级 Anchor 收到时钟同步包的接收时间戳并不一定准确。电波的反射/散射、遮挡物、信号太弱等因素都会影响芯片对数据包第一路径到达时间的判断——有可能提前(本该检测到更晚的信号却提前锁定了),也有可能推后(错过了真正的第一径而锁定了延迟的多径信号)。当然,晶振频率的短期漂变、电压的瞬态变化、温度和湿度的变化等,也会产生影响。

接收时间戳的不准确,会导致差异因子 factor 的计算值波动。所以我们使用滤波手段让 factor 尽量稳定。

但是,仅仅平滑 factor 是不够的!

虽然 factor(漂移率/斜率)被滤波平滑了,但转换公式中的参考基准点 (TA2,TC2) 本身也受到接收时间戳抖动的影响。TA2 的误差会直接导致转换结果跳变——因为 TA2 是公式中的"锚点",它的任何偏差都会被原样传递到输出结果中。

类比: 想象一条直线 y=kx+b。即使斜率 k 被滤波得非常稳定,但如果直线经过的那个固定点 (x0,y0) 在不断抖动,那么整条直线也会跟着上下平移抖动。

假设 factor 不变,在正常情况下,我们应该在某个预期的时刻收到数据包——这个预期时间就是"正确的" TA2。但因为各种干扰,实际的 TA2 会超前或滞后。我们要想办法根据实际的 TA2,估算出一个更接近正确值的 TA2

以下介绍几种改进方案:

3.2.2.2.1 从"点对点转换"转向"线性回归(Linear Regression)"模型

目前简易版的方法是:

Timecs = Timetx_last + ( Timeloc_now − Timerx_last)×factor

这个公式中,Timerx_last(即 TA2)是最后一次收到的包的接收时间戳。一旦这个包的 RX_time 因为多径效应或硬件噪声偏了 300ps(对应约 9cm),整个计算结果就会立刻偏 300ps。

改进方法:

不使用"最后一次"收到的包作为唯一的参考原点,而是维护一个滑动窗口(例如最近 10~20 个同步包),记录多组 [Timelocal_rx, Timeremote_tx] 对。

  • 使用最小二乘线性回归(Least Squares Linear Regression)对这些数据点进行直线拟合。
  • 拟合出的直线方程为:Timecs = a×Timelocal + b
  • 斜率 a 就是 factor+1截距 b 则是综合了多次测量噪声后的最优时间偏移
  • 优点:拟合过程天然具有低通滤波作用。单次 RX_time 的抖动会被其他正常采样点"拉回来",不会导致结果剧烈跳变。

工程提示: 线性回归虽然简单有效,但它假设 factor 在整个窗口期间是恒定的。如果窗口太长(例如超过几秒),晶振频率可能已经发生了非线性漂变,此时线性回归的假设就不再成立。窗口大小需要根据实际环境调优。

3.2.2.2.2 利用 DW3000 的频率偏移估计(CFO)

DW3000 在接收数据包时,内部的载波恢复环路可以给出 Carrier Frequency Offset (CFO) 估计值。

  • 这个 CFO 与发送端和接收端的时钟频率差异直接相关——本质上它就是对 factor 的一个物理层测量。
  • 做法:将 CFO 转换为频率漂移率(ppm),并将其作为一个独立的观测值引入卡尔曼滤波器中。
  • 优点:CFO 是对射频波形的直接测量,其抖动特性与接收时间戳 RX_time 的抖动不相关。引入两个独立的观测源可以更好地估计真实的 factor

CFO 读取方法: 在 DW3000 的驱动中,dwt_readclockoffset() 函数返回的就是 CFO 值(以 ppm 为单位的浮点数)。在前面的 rx_ok_cb ISR 中,我们已经在每次接收成功时读取了这个值(event.off_hw = dwt_readclockoffset())。

3.2.2.2.3 改进卡尔曼滤波器(Kalman Filter)的状态量

如果简单的一阶滤波效果不理想,可能是因为滤波器的状态模型设计得不够完备。可以使用二阶卡尔曼滤波器

  • 状态量 x=[offset, drift]T
    • offset:当前时刻的时间偏移量(本地时间与全局时间的差值)
    • drift:时钟漂移率(即 factor,单位是 tick/tick)
  • 预测步offsetnew = offsetold + drift×Δt
    • 含义:在没有新的观测数据时,根据已知的漂移率来预测时间偏移的演变
  • 更新步:利用新到的同步包计算出的瞬时 offset 作为观测值,更新状态估计
  • 核心技巧:在需要进行"本地时间 → 全局时间"转换时,直接使用滤波后的状态量 offset 和 drift,而不是使用原始的 RX_time。这样转换结果基于多次观测的最优融合估计,而不是依赖某一个可能受干扰严重的单次接收。

卡尔曼滤波直觉理解:

如果你不熟悉卡尔曼滤波,可以这样简单理解:它是一种"智能加权平均"。每次来了新的测量数据,它不会全盘接受,也不会完全忽略,而是根据"我对当前状态有多确定"和"新测量有多可靠"来决定给新数据多大的权重。如果系统已经跑了很久,状态很稳定,那新来的一个异常值几乎不会动摇它;但如果系统刚启动,一切都不确定,那新数据的权重就很大。这就是为什么卡尔曼滤波既能快速收敛(启动时),又能抵抗噪声(稳态时)。

3.2.2.3 Anchor 间距离对时钟同步的影响

在时钟同步时,Anchor 到上级 Anchor(时钟源)之间的物理距离对同步精度有直接影响。

DW3000 的内部计时器单位(DTU, Device Time Unit,通常简称 tick)大约是 15.65ps,等价于约 0.47cm 的空间距离。

当时钟同步包从时钟源(上级 Anchor)发出,经过一段空间距离后到达下级 Anchor 时,下级 Anchor 会对比"同步包中携带的发射时间戳"和"自己记录的接收时间戳"来计算同步参数。但这里有一个容易被忽略的问题:同步包在空中飞行是需要时间的!

如果从"上帝视角"来看,当下级 Anchor 收到同步包的那一刻,时钟源的时钟实际上已经走过了"同步包飞行时间"那么长。也就是说,同步包中的发射时间戳相对于"此刻时钟源的实际时间"是滞后的。要得到时钟源的当前时间,需要把发射时间戳加上数据包在空中飞行花费的时间。

数据包的"空中飞行时间"(Time of Flight)包括三部分:

  1. 时钟源的 TX 天线延迟:信号从芯片内部到离开天线的延迟
  2. 空间传播时间:信号在空中以光速飞行的时间(= 距离 / 光速)
  3. 下级 Anchor 的 RX 天线延迟:信号从进入天线到芯片内部打上时间戳的延迟

设备在出厂前需要做发射天线延迟和接收天线延迟的校准(通常使用已知距离的 TWR 测距来标定),但要把天线延迟校得非常准确是比较困难的。另外,Decawave 在 DW1000 的开发板 TREK1000 的示例程序中,做 DS-TWR 测距时还会根据使用的频道和通讯速率对测距结果进行修正,说明这些参数也会影响时间戳的计算。

关于频率/通讯速率对飞行时间的影响:

从物理角度看,电磁波的频率和调制速率不会改变其在自由空间的传播速度(都是光速)。但实际上,不同的频率和速率配置会影响 DW3000 芯片内部的信号处理延迟——例如前导码的积累时间、LDE 算法的处理时间等。从程序的角度来看,这些内部延迟的变化会体现为"似乎飞行时间变了"的效果。所以 Decawave 提供了修正系数来补偿这些差异。

我们可以使用反馈机制来系统性地解决这些令人头疼的校准问题——这就是下一节的内容。

3.2.2.4 时钟同步的反馈机制

使用高级版的时钟同步方案后,同步精度已经有了很大改进,但依然存在不可忽略的偏差——因为天线延迟的校准误差、Anchor 间距离的测量误差等系统性偏差是无法通过统计滤波消除的。

为此,我们引入反馈机制,通过闭环控制来消除这些系统性误差。

 
 

工作原理:

假设 A0 是时钟源,A1 是 A0 的下级——它接收 A0 的同步包,维持与 A0 一致的全局时间。我们使用第三方 Anchor A2 作为观察者

A2 能同时收到 A0 和 A1 的时钟同步包。A2 在内部同时与 A0 和 A1 进行时钟同步(即维护两套独立的同步参数)。那么,当 A2 收到 A1 发出的时钟同步包时,它可以同时计算出两个值:

  1. 通过 A1 同步得到的全局时间(即 A1 认为的全局时间)
  2. 通过 A0 同步得到的全局时间(即 A0 认为的全局时间,也是"真正的"全局时间)

这两个值的差异,我们可以合理地认为是 A1 在与 A0 同步时引入的误差

当然,实际情况会更复杂一些。A2 作为观察者,它自己与 A1 和 A0 做时钟同步时也不是完全准确的。但是,A2 的观察误差是随机的,而 A1 的系统性偏差(如天线延迟校准误差)是恒定的。通过多次观察并平均,随机误差会被消除,系统性偏差会被凸显出来。

A2 向 A1 发出一个反馈数据包,报告它观察到的 A1 与 A0 之间的全局时间差异。A1 收到 A2 的报告后,调整自己的同步参数中的 offset

A2 观察到的误差——除去 A2 自身的随机观察误差之外——无论其成因是什么(A1 的接收时间戳不准确、A1 的接收天线延迟有偏差、A0 的发射天线延迟有偏差、甚至是两者之间距离估算不准确),都可以通过这个反馈机制被系统性地修正。

A2 的距离补偿:

A2 作为观察者,它到 A0 和 A1 的距离通常不相等。这意味着 A2 收到 A0 的同步包和收到 A1 的同步包之间,存在由于距离差引起的飞行时间差。A2 在计算 A0 和 A1 的全局时间差异时,需要把这些距离差转换为 DW3000 的 tick 并代入计算公式中进行补偿。因此,系统配置时需要提前将各个 Anchor 的坐标信息加载到 Observer 中。

通过 A2 的持续反馈,A1 逐步调整自己的同步参数。从 A2 的视角来看,A1 和 A0 的全局时间差异会越来越小,最终趋近于零。

反馈机制的核心价值在于: 无论 Anchor 之间的距离是多少、无论天线延迟的校准有多少误差,通过闭环反馈都可以确保各 Anchor 的全局时间保持一致。这使得在多级级联同步时,各个 Anchor 间能保持令人满意的时间同步精度。

类比自动控制中的 PID: 反馈机制在概念上类似于经典的 PID 控制——观察者是"传感器",它测量的"偏差信号"被反馈给被控对象(A1),A1 据此调整自己。只不过这里的控制量是时间偏移(offset),而不是温度或速度。

3.2.2.5 时钟同步包和反馈包的结构

typedef struct PACK_ATTRIBUTE {
	uint8_t frame_ctrl[2];          // IEEE 802.15.4 帧控制字段
	uint8_t seq8;                   // 8-bit 序列号
	union {
		uint8_t pan_addr[2];
		uint16_t pan_id;            // PAN ID
	};
	union {
		uint8_t dest_addr[2];
		uint16_t dest_id16;         // 16-bit 短地址(广播时为 0xFFFF)
	};
	union {
		uint8_t source_addr[8];
		EUI64 source_id;            // 64-bit 源地址(本 Anchor 的唯一 ID)
	};
	uint8_t message_type;           // 消息类型标识
	uint8_t seq32_3[3];             // 32-bit 序列号的高 24 位
									//(与 seq8 组合成完整的 32-bit 序列号)
	uint8_t timestamp40[5];         // 40-bit 全局发射时间戳

	float x;                        // Anchor 的 X 坐标(米)
	float y;                        // Anchor 的 Y 坐标(米)
	float z;                        // Anchor 的 Z 坐标(米)
	uint8_t cs_level;               // 时钟同步级别
	union {
		uint8_t parent_clock_source_addr[8];
		EUI64 parent_clock_source_id;  // 上级时钟源的 EUI64
	};
	union {
		uint8_t observer_addr[8];
		EUI64 observer_id;          // 指定的观察者 EUI64
	};

	uint8_t fcs[2];                 // FCS(帧校验序列)
} BROADCAST_DL_CLOCK_SYNC_MESSAGE;

关于 PACK_ATTRIBUTE 宏: 这个宏通常定义为 __attribute__((packed)),告诉编译器不要在结构体成员之间插入对齐填充字节,确保结构体在内存中的布局与 UWB 空口数据包的字节流完全一致。但正如 Part1 中提到的,在 ESP32(Xtensa 架构)上使用 packed 结构体需要格外小心——直接通过指针访问未对齐的成员可能触发 LoadStoreAlignment 异常。

上面是时钟同步包的定义。几个关键字段的说明:

  • timestamp40:40-bit 全局时间戳,表示这个数据包离开天线的精确全局时间。这是时钟同步最核心的数据。
  • x / y / z:本 Anchor 的坐标(单位:米)。Tag 需要这些信息来计算自己的位置。
  • cs_level:时钟源的级别。根时钟源的 cs_level = 0,逐级递增。数字越小表示离根时钟源越近,数据可靠性越高。Tag 在选择"参考 Anchor"时会优先信任 cs_level 较低的 Anchor。
  • parent_clock_source_id:本 Anchor 的上级时钟源的 EUI64。这个信息非常重要,用于在系统启动阶段让下级 Anchor 知道要跟谁同步。
  • observer_id:指定的观察者 Anchor 的 EUI64。在系统配置阶段,管理员为每个 Anchor 选择一个附近的合适 Anchor 作为观察者。目标 Anchor 只接收来自指定观察者的反馈,忽略其他来源的反馈包。

空口时间优化——大/小包分离

从上面的结构定义可以看出,有些字段在运行过程中几乎不会变化:

  • Anchor 坐标(x / y / z)——除非人为修改
  • 上级 Anchor ID(parent_clock_source_id)——除非重新配置
  • 同步级别(cs_level)——除非层级拓扑变更
  • 观察者 ID(observer_id)——除非重新配置

如果每次发送时钟同步包都携带这些字段,会浪费宝贵的 UWB 空口时间(UWB 数据包越长,发送耗时越长,也意味着这段时间内接收机无法接收其他数据包)。

优化方案: 定义一个精简版的同步包(只包含 timestamp40 等必要字段),平时的高频同步只发送小包;偶尔(例如每分钟一次)发送一次完整的大包来同步那些"准静态"字段。这样既节省了空口时间,又保证了这些字段有变化时能及时通知到所有下级 Anchor 和 Tag。

反馈包结构

typedef struct PACK_ATTRIBUTE {
	uint8_t frame_ctrl[2];
	uint8_t seq8;
	union {
		uint8_t pan_addr[2];
		uint16_t pan_id;
	};
	union {
		uint8_t dest_addr[8];
		EUI64 dest_id;              // 目标 Anchor 的地址(单播)
	};
	union {
		uint8_t source_addr[8];
		EUI64 source_id;            // 观察者的地址
	};
	uint8_t message_type;

	EUI64 reference_id;             // 参考 Anchor 的 EUI64
	int32_t error_ticks;            // 观测到的误差(单位:DW3000 tick)
	uint16_t confidence;            // 置信度
	uint8_t fcs[2];
} UNBROADCAST_DL_CLOCK_SYNC_FEEDBACK_MESSAGE;

注意: 反馈包是单播的(注意 dest_addr 是 8 字节,而不是同步包的 2 字节广播地址 0xFFFF),只发给特定的目标 Anchor。这是因为反馈信息只对特定 Anchor 有意义,不需要广播给所有设备。

几个关键字段的说明:

  • reference_id:参考 Anchor 的 EUI64。观察者比较的是"目标 Anchor"和"其上级 Anchor",所以这个 ID 通常就是目标 Anchor 同步包中的 parent_clock_source_id
  • error_ticks:观察到的误差,单位是 DW3000 tick。正值表示目标 Anchor 的全局时间超前,负值表示滞后。目标 Anchor 收到后将此值叠加到自己的 offset 补偿量中。
  • confidence:置信度,表示这个反馈包的可靠程度。通常根据观察者到两个 Anchor 的距离、信号质量(RSSI/首径功率比)等因素综合计算。目标 Anchor 在采纳反馈时,会根据 confidence 决定给予多大的权重。

3.2.3 定位数据包

最初设计系统时,我考虑使用专门的定位数据包——即 Anchor 除了发送时钟同步包之外,还额外发送一种"定位包"给 Tag 使用。

但是当时钟同步功能完成后,我意识到:时钟同步包本身就是最好的定位数据包!

原因很简单:

  1. Tag 需要"锁定"它看到的 Anchor——从本质上说,Tag 就是在与这些 Anchor 做时钟同步。Tag 处理时钟同步包的逻辑与 Anchor 处理时钟同步包的逻辑几乎完全一样,区别只是 Tag 不需要发送反馈包,也不需要向下级转发同步包。
  2. 时钟同步包中已经包含了定位所需的全部信息:精确的全局时间戳(用于时间差计算)、Anchor 坐标(用于位置解算)。
  3. 再定义一种类型的数据包只会增加系统的复杂度和空口占用,完全没有必要。

图例说明:

  • 蓝色方块:4 个 Anchor 设备,按层级从 Level 0(最高)到 Level 3 排列。每个 Anchor 向四周广播 ClockSync 包。
  • 橙色圆形:Tag 设备。它同时接收来自所有可见 Anchor 的 ClockSync 包。
  • 实线箭头:指向具体接收方(下级 Anchor 或 Tag)。
  • 虚线箭头:表示广播信号辐射到周围空间(被不特定的设备接收)。

如上图所示,时钟同步包既用于 Anchor 间的时间同步,又被 Tag 接收用于定位——一包两用,简洁高效。

3.2.4 Anchor 锁定

在前面的章节中我们已经说过,Tag 使用时钟同步包来计算坐标。因为各个 Anchor 并不是同时发出时钟同步包的(每个 Anchor 有自己独立的发送节拍),Tag 需要锁定附近的多个 Anchor——即与这些 Anchor 分别建立时钟同步关系。这样,Tag 就可以在任意时刻将自己的本地时间转换为每个锁定 Anchor 的全局时间,再根据这些全局时间的差异来计算出自己的坐标。

"锁定"的本质: 对于 Anchor 来说,"时钟同步"是与上级 Anchor(时钟源)同步。对于 Tag 来说,"锁定"某个 Anchor 也是在做时钟同步——只不过 Tag 是被动接收,不需要发送反馈包,也不需要向下游转发同步包。Tag 为每个锁定的 Anchor 维护一套独立的同步参数(包括 Kalman 滤波器状态、滑动窗口历史等),这套参数和 Anchor 内部用于与上级同步的参数结构完全一样。

我们创建一个结构来存储单个 Anchor 的跟踪状态:

/** 单个 Anchor 的跟踪状态 */
typedef struct {
	EUI64 anchor_id;                 // 被跟踪的 Anchor 的唯一 ID
	UwbSyncInstance sync_inst;       /**< 时钟同步实例,包含 Kalman 滤波器等
									  *   Tag 模式下不使用反馈功能 */
	float x, y, z;                   // Anchor 的坐标(从同步包中获取)
	uint8_t cs_level;                // Anchor 的时钟同步级别
	uint32_t last_seen_ms;           // 最后一次收到该 Anchor 数据包的时间(ESP 系统时间, ms)
	bool is_active;                  // 该跟踪槽位是否激活
	/** 最近一次该 Anchor 的全局发射时刻,用于后续 TDOA 计算 */
	uint64_t last_remote_tx;
} TagAnchorTracker;

last_seen_ms 的作用: 如果一个 Anchor 长时间没有被收到(例如 Tag 移动到了该 Anchor 的覆盖范围之外),我们需要将它标记为非活跃(is_active = false),并在需要时用新发现的 Anchor 替换它。last_seen_ms 就是用来判断"多久没收到"的依据。

其中的 UwbSyncInstance sync_inst 是时钟同步的核心数据结构,定义如下:

/**
 * @brief 同步实例 (每个设备维护一个, 对应一个上级时钟源)
 *
 * Anchor 用它来维持与上级时钟源的同步;
 * Tag 用它来"锁定"某个 Anchor。
 * 两者使用的是完全相同的算法和数据结构。
 */
typedef struct PACK_ATTRIBUTE {
	/* ---- 配置区 ---- */
	uint64_t my_id;             /**< 本机 EUI64 */
	uint64_t parent_cs_id;      /**< 上级时钟源 EUI64 (Tag 模式下为被锁定的 Anchor ID) */
	bool     is_root;           /**< 是否为根 Anchor (Tag 永远为 false) */
	float    my_x, my_y, my_z;  /**< 本机坐标 (米) */

	/* ---- 40-bit 溢出追踪 (本地 RX 时间域) ---- */
	uint64_t last_raw_tick;     /**< 上次读到的 40-bit 原始值 */
	uint64_t overflow_count;    /**< 溢出次数 */

	/* ---- 40-bit 溢出追踪 (远端全局 TX 时间域) ---- */
	uint64_t last_remote_raw;   /**< 上次收到的远端 40-bit 原始值 */
	uint64_t remote_overflow;   /**< 远端溢出次数 */

	/* ---- Kalman 滤波器 ---- */
	KalmanSync kf;

	/* ---- 滑动窗口历史 ---- */
	SyncHistoryEntry history[SYNC_HISTORY_SIZE];
	int      history_idx;       /**< 环形缓冲写指针 */
	int      history_count;     /**< 有效条目数 */

	/* ---- 质量与统计 ---- */
	float    quality_score;     /**< 同步质量 0.0 ~ 1.0 */
	uint32_t sync_count;        /**< 收到的同步包总数 */
	uint32_t outlier_streak;    /**< 连续异常值计数 */

	/* ---- 反馈缓冲 (仅 Anchor 模式使用, Tag 模式不使用) ---- */
	int32_t  fb_buf[SYNC_FEEDBACK_BUF_SIZE];
	uint16_t fb_conf[SYNC_FEEDBACK_BUF_SIZE];
	uint32_t fb_time_ms[SYNC_FEEDBACK_BUF_SIZE];
	int      fb_count;
	uint32_t fb_last_apply_ms;

	/* ---- 反馈偏差补偿 ----
	 * 独立于 Kalman 的累积偏差修正。
	 * 父节点同步操作 kf.offset, 而 fb_offset_bias 在
	 * local_to_global / global_to_local 时叠加,
	 * 保证反馈修正不会被父节点同步覆盖。 */
	double   fb_offset_bias;

	/* ---- 级联级别 ---- */
	uint8_t  my_cs_level;
	uint8_t  parent_cs_level;

	/* ---- Tag 模式 ----
	 * Tag 被动监听, tof_ticks 设为 0(Tag 不知道自己到 Anchor 的精确距离)。
	 * CFO 读数在不同硬件批次/芯片之间可能与实际时间戳 skew 存在系统偏差,
	 * 启用 tag_mode 后跳过 kf_update_cfo, 让 Kalman 纯靠 offset 观测收敛 skew。 */
	bool     tag_mode;
} UwbSyncInstance;

关于 40-bit 溢出追踪:

DW3000 的计时器是 40 位宽的,满量程约 17.2 秒就会溢出归零。但我们的同步算法需要计算跨越多次溢出的时间差。因此,软件中使用 overflow_count 来记录溢出次数,并通过 sync_extend_timestamp() 函数将 40-bit 时间戳扩展为 64-bit,从而得到一个不会溢出的连续时间轴。这是一个非常重要但容易忽略的工程细节。

判断溢出的方法: 如果当前读到的 40-bit 值比上次的值小(例如上次是 0xF000000000,这次是 0x0100000000),说明发生了一次溢出,overflow_count++

跟踪数组

我们建立一个数组来管理所有被跟踪的 Anchor:

#define MAX_TRACKED_ANCHORS 8
TagAnchorTracker s_trackers[MAX_TRACKED_ANCHORS];

s_trackers 用于记录 Tag 当前视野范围内的 Anchor。如果 Tag 的内存较大,可以增加 MAX_TRACKED_ANCHORS 的值——能跟踪的 Anchor 越多,坐标计算时可用的数据就越多,定位精度和鲁棒性就越好。

MAX_TRACKED_ANCHORS 的选取建议:

每个 TagAnchorTracker 中包含一个 UwbSyncInstance,后者内含 Kalman 滤波器和历史缓冲区,每个实例占用数百字节到几 KB 的 RAM(取决于 SYNC_HISTORY_SIZE 和 SYNC_FEEDBACK_BUF_SIZE)。8 个跟踪槽位在 ESP32-S3(520KB SRAM + 可选 8MB PSRAM)上完全没有压力。对于更大的部署场景(如 Tag 需要穿越覆盖数十个 Anchor 的区域),可以增大到 16 或更多。

每次收到时钟同步包时的处理流程

 
 

每次 Tag 收到一个时钟同步包,都会执行以下操作:

  1. 预测(Predict):将 Kalman 滤波器的状态推进到当前时刻(基于已知的 drift 进行外推)
  2. 时间观测(Offset Observation):计算 z_offset = (remote_tx + tof) − local_rx,其中 tof 在 Tag 模式下设为 0(因为 Tag 不知道自己到 Anchor 的精确距离)
  3. 异常值检测(Outlier Detection):如果观测值与预测值的偏差(innovation)超过设定的门限,认为这是一个异常包(可能由于多径干扰),跳过本次 Kalman 更新,同时 outlier_streak++
  4. CFO 观测(仅 Anchor 模式):将 DW3000 硬件报告的 CFO 值转换为 skew 并作为第二观测源更新 Kalman。Tag 模式下跳过此步骤(原因见结构体注释)
  5. 更新历史缓冲:将本次同步数据记录到滑动窗口中

总之,Tag 持续跟踪每个锁定的 Anchor,保持时钟同步。如果收到了来自新 Anchor 的同步包(s_trackers 中没有它的记录),则从数组中移除最"旧"或同步质量最差的 Anchor,用新 Anchor 替换。

替换策略的考量: 简单的"替换最老的"策略可能不是最优的——比如一个"老"Anchor 一直表现很好(quality_score 高),不应该仅仅因为时间最久就被替换掉。更好的做法是综合考虑 last_seen_msquality_scoreoutlier_streak 等指标来决定替换哪个。

3.2.5 坐标计算

作为 Tag,这是整个系统中最关键的一步——把时钟同步的中间结果最终转化为有意义的三维坐标。

从时间差到距离差

在前面的章节中我们说过,各个 Anchor 在不同时刻发出定位数据包(时钟同步包),Tag 当然是在不同时刻收到的。因为发送时间不同,不能直接用接收时间戳相减来得到距离差。

Tag 通过"锁定"多个 Anchor(为每个 Anchor 维护独立的同步参数),可以在任意时刻将自己的本地时间转换为每个 Anchor 对应的全局时间。

假设 Tag 在本地时间 TlocalTlocal 这一刻,将它转换为 4 个 Anchor 的全局时间,得到 TA0TA0TA1TA1TA2TA2TA3TA3。因为 Tag 到各个 Anchor 的距离不一样,这 4 个全局时间是有差异的——把它们两两相减,然后乘以光速,就得到距离差了。

直觉理解: 假设某一瞬间 Tag 同时向 4 个 Anchor 各发一个脉冲。距离近的 Anchor 先收到,距离远的 Anchor 后收到。收到的时刻之差 × 光速 = 距离之差。在下行 TDOA 中方向是反的(Anchor 发,Tag 收),但数学原理是对称的。

坐标计算的时机

理论上,只要"锁定"了足够多的 Anchor,Tag 可以在任意时刻计算坐标。但如果没有收到新的数据包,多次重复计算得到的结果都是相同的——浪费算力没有意义。

所以,我们选择在每次收到新的同步包后触发坐标计算:构造距离差数组 DDOA,如果构造出的数组满足计算坐标的最低要求(二维定位至少需要 3 对距离差,三维定位至少需要 5 对),就调用坐标计算函数,否则跳过。

计算频率举例: 假设某个区域内有 4 个 Anchor,每个 Anchor 的时钟同步包发送间隔为 150ms(可配置)。那么 Tag 平均每 150/4=37.5ms 就会收到一个新包,即坐标计算频率约为 26Hz。这个频率对于大多数人员/物资定位场景已经足够。如果觉得过于频繁(比如 Tag 基本静止),也可以设置一个最小计算间隔来降低频率,节省电量。

距离差(DDOA)

TDOA(Time Difference of Arrival)的本质是距离差。为了方便后续的坐标解算,我们定义一个结构来记录两个 Anchor 之间的距离差:

typedef struct PACK_ATTRIBUTE ___tag_ddoa___ {
	EUI64 aId;              // Anchor A 的 ID
	float ax;               // Anchor A 的 X 坐标(米)
	float ay;               // Anchor A 的 Y 坐标
	float az;               // Anchor A 的 Z 坐标

	EUI64 bId;              // Anchor B 的 ID
	float bx;               // Anchor B 的 X 坐标
	float by;               // Anchor B 的 Y 坐标
	float bz;               // Anchor B 的 Z 坐标

	float deltaDistance;    // Tag 到 A 与到 B 的距离差(米)
							// 正值表示 Tag 离 A 更远
} DDOA;

再定义一个数组来存放所有的距离差组合:

#define MAX_DDOA_NUM    20
DDOA listDDOAs[MAX_DDOA_NUM];

DDOA 数量与 Anchor 数量的关系: 如果 Tag 锁定了 NN 个 Anchor,理论上可以构造 CN2=N(N−1)/2 组距离差。例如锁定 4 个 Anchor 可构造 6 组,锁定 6 个 Anchor 可构造 15 组。MAX_DDOA_NUM = 20 足以支持 6~7 个 Anchor 的全组合。但其实这些距离差不是全部独立的——NN 个 Anchor 只有 N−1 个独立的距离差。冗余的距离差对于提高鲁棒性(识别异常 Anchor)有帮助,但不会增加定位的几何自由度。

坐标计算算法

有了各个 Anchor 之间的距离差 listDDOAs,就可以进行坐标解算了。

二维定位 vs 三维定位

虽然我们在处理空间坐标时都使用三维坐标(Anchor 的坐标是三维的),但 Tag 的 zz(高度)坐标是一个麻烦问题。

通常,Anchor 部署在比较高的位置(天花板上、墙壁上、室外灯杆上)。部署在高处的好处显而易见——"站得高看得远",Tag 更容易接收到 Anchor 的信号,Line-of-Sight(视距)覆盖更好。但是,如果要计算三维坐标,就需要部分 Anchor 部署在地面附近。

"内插"vs"外推"的定位精度差异:

无论使用哪种算法,当 Tag 位于 Anchor 组成的多边形(二维)或多面体(三维)内部时,计算出的坐标会更准确——这就是"内插"(Interpolation)。当 Tag 在 Anchor 包围区域的外部时,定位精度会显著下降——这就是"外推"(Extrapolation)。

对于三维定位,如果所有 Anchor 都在天花板上,而 Tag 在地面,那么 zz 方向永远是外推,zz 坐标的精度会很差。只有在天花板和地面都部署 Anchor 时,zz 方向才能实现内插。但地面 Anchor 面临严重的遮挡问题——人、家具、货架等都会阻挡信号。

因此,大多数实用的 UWB 定位系统都只做二维定位(仅求 xxyy),只在特殊场景(如多层仓库、竖井等)才使用三维定位。

二维定位的做法:为 Tag 预设一个固定的高度 zz 值(例如 1.5m,代表人胸前佩戴的 Tag 高度),然后在三维方程中将 zz 视为已知常数,只求解 xx 和 yy。本质上,二维坐标计算是三维坐标计算的一个特例。

求解方法——迭代逼近

理论上,计算 Tag 坐标就是解方程——求出 (x,y,z)(x,y,z) 使得恰好满足 listDDOAs 中的所有距离差关系。但由于噪声和测量误差的存在,这个方程组通常没有精确解。我们只能使用各种数值迭代法,找到一个使残差(方程误差)最小的近似解。

 
 

业界常用的坐标解算算法包括:

算法 特点 适用场景
Chan 算法 闭式解,一步出结果,速度快 初始估计、Anchor 数量略多于最小值时
Taylor 算法 迭代式,需要初始值,精度高 在有较好初始估计时做精化
Chan-Taylor 混合 先 Chan 给初始值,再 Taylor 迭代 兼顾速度和精度
Gauss-Newton 算法 经典非线性最小二乘迭代 通用性强,适合超定方程组
LSR (Least Squares Range) 基于距离的最小二乘 Anchor 数量较多时

这些算法的本质区别: 都是从某个初始点出发,根据当前的 DDOA 数据寻找使误差更小的下一个点,反复迭代直到收敛。不同算法的区别主要在于:(1)如何选择搜索方向;(2)如何计算步长;(3)如何判断收敛。Chan 算法比较特殊,它通过代数变换得到一个近似的闭式解,不需要迭代,计算速度极快,但在噪声大时精度不如迭代法。

这些算法我都集成到了 Tag 的固件中,可以通过配置参数选择使用哪种算法。如果你要自行实现坐标解算,可能需要阅读相关的学术论文(如 Chan 的经典论文 "A Simple and Efficient Estimator for Hyperbolic Location"),然后编写代码实现。

坐标计算中反馈包的利用

在 Part2 中介绍时钟同步反馈机制时,我们提到 Observer 会发送 UNBROADCAST_DL_CLOCK_SYNC_FEEDBACK_MESSAGE 反馈包,其中的 error_ticks 字段反映了目标 Anchor 与其上级 Anchor 之间的全局时钟差异。

Tag 如果也收到了这些反馈包(反馈包虽然是单播给目标 Anchor 的,但 UWB 的广播特性意味着附近的 Tag 也能接收到),可以利用 error_ticks 来修正对应 Anchor 的全局时间估计。具体做法是在生成 listDDOAs 时,将这个误差补偿量叠加到对应 Anchor 的全局时间上。

这样可以使相应 Anchor 的全局时间更加准确,从而提高定位精度。某种意义上,这类似于 GPS 定位中的 RTK(实时动态差分) 技术——通过一个已知位置的参考站来修正定位误差。

坐标质量评估

计算出的坐标必然是近似值而非精确解。一个自然的问题是:计算出的坐标与真实位置相差多少?

在实际运行中,由于多径干扰、信号遮挡等原因,某些时刻计算出的坐标可能与真实位置相差很大(偏差数米甚至数十米)。如果直接将这些"坏坐标"输出给应用系统,会造成混乱。因此我们需要建立一套坐标质量评估机制。

但问题是:我们不知道真实坐标是什么(这正是我们要计算的东西),如何评估计算出的坐标的质量呢?

基本思路——残差分析

大多数情况下,listDDOAs 中的各组距离差数据之间存在一定程度的"矛盾"——这也正是我们只能得到近似解的原因。但"矛盾"的程度是有差异的。

举个例子:假设某个正方形区域内,4 个 Anchor 部署在 4 个角上,Tag 正好在正方形的中心。此时 Tag 到所有 Anchor 的距离相等,理论上所有距离差都应该为 0。如果因为干扰,其中某个 Anchor 的全局时间出现了 10 tick(约 4.7cm)的误差,那么这个 Anchor 与其他 3 个 Anchor 的距离差都会有 4.7cm 的偏移,但其他 3 个 Anchor 相互之间的距离差仍然是 0。

显然,这组数据是"自相矛盾"的。正确的那 3 个 Anchor 其实已经足以确定 Tag 的坐标了,但有误差的第 4 个 Anchor 参与计算后,会把结果往错误的方向拉。

我们可以量化这种矛盾的程度来评估坐标质量:

  1. 用最小二乘法求出最优的 (x,y,z)(x,y,z)
  2. 将计算出的坐标代回到所有 DDOA 方程中,计算每组方程的残差(理论距离差 vs 实测距离差)
  3. 计算所有残差的均方根(RMS)作为质量评分。RMS 越小,说明各组数据越"和谐",坐标越可信

对于质量评分低于阈值的坐标,我们直接丢弃不输出。只输出质量较好的坐标给上层应用。

质量评估的局限性: 这种基于残差的评估并非万无一失。有时所有 Anchor 都同时出现了误差,而且这些误差恰好"和谐"地指向同一个错误方向——此时残差很小,我们误以为坐标质量很好,但其实是错误的。这种情况类似于 GPS 中的"共模误差"。不过在实际中,这种巧合概率很低。

3.2.6 USB HID 配置

如前所述,我们使用 WiFi 联网,那么 WiFi SSID 和密码的初始配置就是一个"先有鸡还是先有蛋"的问题——设备还没联网,怎么通过网络告诉它 WiFi 凭证?

ESP-IDF 提供了几种配网方案(SmartConfig、BluFi 等),但我都不太满意——要么需要用户安装手机 APP,要么对网络环境有特殊要求。最终我决定使用 USB HID 来进行 WiFi 设置和管理员密码设置。

为什么选择 USB HID 而不是 USB CDC(串口)?

USB HID(Human Interface Device)设备在 Windows、Linux、macOS 上都免驱动——插上就能用,不需要安装任何驱动程序。这对于部署现场的施工人员来说非常友好。而 USB CDC(Virtual COM Port)虽然传输能力更强,但在 Windows 上可能需要安装额外的 CDC 驱动(虽然 Win10+ 已经自带)。

在老的上行 TDOA 项目中,我使用 USB HID 配置 Tag,体验非常好。

USB HID 的数据量限制

然而,USB HID 有一个令人头疼的限制:标准 HID 的单个 Report 最大只有 64 字节。本来,如果能发送大数据包,所有的 Anchor 配置都可以通过 USB HID 完成(与网络配置并行),但 64 字节的限制使得分包传输大参数集变得很复杂。

为什么是 64 字节? USB HID 规范中,低速设备的中断端点最大包大小为 8 字节,全速设备最大为 64 字节。如果需要更大的数据传输,要么走"Report"分包(增加应用层复杂度),要么改用 USB Bulk 传输类型(不再是 HID 设备)。

最终,我决定让 USB HID 只负责最基础的配置:管理员名称和密码、WiFi SSID 和密码、IP 地址。其他的高级配置(如 Anchor 坐标、UWB 参数、时钟同步层级等)通过 TCP 网络连接完成——反正有了 WiFi 凭证后设备就能联网了。

即使如此,WiFi SSID 和密码的最大长度也被迫缩减了。标准的 WiFi SSID 最长 32 字节,密码最长 63 字节,但为了适应 64 字节的 Report 容量,我将它们各限制在 21 字节以内。

// HID 配置:管理员信息
typedef struct PACK_ATTRIBUTE __HID_MESSAGE_ADMIN_INFO__ {
	char     admin_name[32];        // 管理员名称
	char     admin_password[32];    // 管理员密码
} HID_MESSAGE_ADMIN_INFO;          // sizeof = 64 字节,恰好一个 Report

// HID 配置:WiFi 网络
typedef struct PACK_ATTRIBUTE __HID_MESSAGE_WIFI_INFO__ {
	uint8_t  wifi_ssid[21];         // WiFi SSID(最长 20 字符 + '\0')
	uint8_t  wifi_password[21];     // WiFi 密码(最长 20 字符 + '\0')
	uint8_t  wifi_auto_get_ip;      // 是否使用 DHCP 自动获取 IP
	uint8_t  wifi_ip[4];            // 静态 IP 地址
	uint8_t  wifi_subnet[4];        // 子网掩码
	uint8_t  gateway[4];            // 默认网关
	uint8_t  primary_dns[4];        // 首选 DNS
	uint8_t  secondary_dns[4];      // 备用 DNS
} HID_MESSAGE_WIFI_INFO;           // sizeof = 63 字节

TinyUSB 库的坑

我使用 TinyUSB 作为 USB HID 的底层驱动。TinyUSB 是一个优秀的开源 USB 协议栈,支持 ESP32-S3 原生 USB,可以节省很多开发时间。

然而我遇到了一个令人困惑的问题:虽然 HID 描述符中声明 Report 大小为 64 字节,但实际上设备端只能使用 63 字节

当 PC 端调用 hid_get_feature_report() 读取 Feature Report 时:

// PC 端 (使用 hidapi 库)
int n = hid_get_feature_report(pHidDev, buf,
	USB_HID_GET_SET_SLAVE_LIST_REPORT_LENGTH + 1);  // 数据长度 + 1字节 report_id = 65

PC 请求读取 65 字节(64 字节数据 + 1 字节 Report ID)。但在设备端的回调函数:

uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t report_id,
	hid_report_type_t report_type, uint8_t* buffer, uint16_t reqlen)

reqlen 参数总是 63 而不是 64!

根因分析: 追踪到 TinyUSB 源码 hid_device.c 第 332 行:

uint16_t req_len = tu_min16(request->wLength, CFG_TUD_HID_EP_BUFSIZE);

request->wLength 是 65(PC 请求的总长度),但 CFG_TUD_HID_EP_BUFSIZE 默认定义为 64。取最小值后为 64,再减去 1 字节的 Report ID 头,reqlen 就变成了 63。

解决方案: 将 CFG_TUD_HID_EP_BUFSIZE 修改为 65 或更大的值(比如 128)。但要注意两个陷阱:

  1. 描述符中不能使用修改后的值:HID 描述符中声明的 Report Size 仍然必须保持 64,否则 PC 端枚举设备时会失败。需要在描述符生成代码中把相关的值硬编码为 64,而不是引用 CFG_TUD_HID_EP_BUFSIZE 宏。
  2. 宏的多处定义CFG_TUD_HID_EP_BUFSIZE 在 TinyUSB 代码中有多处定义(头文件和配置文件),需要确保所有位置都统一修改。建议修改为 128(而不是 65),这样既能避免潜在的字节对齐问题(128 能被 4/8 整除),又留有足够余量。

教训: 使用第三方库时,如果遇到数据传输长度"差一个"的问题,先看看库的源码中是否有硬编码的 buffer size 限制。这类问题光靠文档和示例代码几乎无法发现。

USB HID 的 PC 端程序会在后面介绍配置程序时再讨论。

3.2.7 省电

如果我们使用电池供电,必须得考虑如何省电。对于嵌入式系统来说,省电最直接简单的办法就是进入休眠模式。

我们分别分析一下 Anchor 和 Tag 的省电相关设计。

3.2.7.1 Anchor 省电

Anchor 的功能主要功能:

  • 接收来自上级 Anchor 的时钟同步包
  • 接收来自观察者的反馈包
  • 发送时钟同步包给下级 Anchor

我们制定一个日程表,这个日程表是未来 Anchor 的 UWB 收发计划:

  • 根据历史上收到的时钟同步包的情况,推测下一次接收时钟同步包时间,把个时间填写到日程表中
  • 根据历史上收到的反馈包的情况,推测下一次接收反馈包的时间,把这个时间填写到日程表中
  • 计算下次发送时钟同步包的时间,把这个时间填写到日程表中

然后 MCU 根据日程表设置自己的状态:

  • 不处于 UWB 收发的时候进入休眠状态
  • 在接收时间和发送时间到来前唤醒
  • 一旦收发工作完成后,调整日程表,调整唤醒时间,并立即进入休眠
  • 如果接收或发射失败,则不进入休眠

因为 DW3000 如果进入休眠状态后唤醒,醒来后需要 100ms 以上的时间进行初始化,这个时间太长了,所以我们不使用 DW3000 的休眠。但是为了减小 DW3000 的电力消耗,我们可以利用 DW3000 的 IDLE 状态。 我们在 MCU 进入休眠状态前,置 DW3000 为 IDLE 状态。

3.2.7.1 Tag 省电

Tag 的省电比较难做。因为 Tag 需要锁定 Anchor,并且因为 Tag 可能处于移动状态,随时有新的 Anchor 进来,有老的 Anchor 离开。也就是说我们要时刻准备接收新 Anchor 的时钟同步包,新 Anchor 的时钟同步包什么时候到来,我们是无法事先知道的。并且,我们预期某个时间应该收到老的 Anchor 的时钟同步包,如果没有接收成功,有可能是 Tag 离开了这个 Anchor 的覆盖范围,也有可能是因为干扰或其他原因导致没有收到。所以,我们也不能冒然认为老的 Anchor 已经离开。

最好的方法是使用 IMU 检测 Tag 是否处于静止状态。如果 Tag 牌静止状态,则休眠,并由 IMU 唤醒。当 Tag 不处于静止状态时,则正常工作。

在没有 IMU 的情况下,还可以考虑部分省电的方法。Anchor 的时钟同步包间隔是 150ms,我们以 150ms 为一个周期来处理。 我们可以每 3 个周期为一个循环:

  • 在第一个周期完全处于正常工作状态,可以接收到任何时钟同步包,根据接收到的时钟同步包预测后续每个周期会收到哪些时钟同步包,把预测的时间写到日程表中
  • 在第 2、3 周期,日程表休眠/唤醒

这样,如果有新 Anchor 到来时,我们可以最多错过 2 个时钟同步就可以检测到新的 Anchor 了。

3.2.8 OTA

固件能在线升级很重要。在设备长期的运行中,我们可能会发现 Bug,或者需要增加某些功能,如果无法对设备的固件进行在线升级,我们只能把设备拆下来重新刷写固件,这非常麻烦。

ESP IDF 提供了 OTA 功能,我们可以很容易实现在线固件升级。

但是,ESP IDF 的 OTA 功能,我们能利用的只有对 Flash 的操作部分,至于固件的上传,ESP IDF 提供的相关技术对我们就不实用了,我们得自己实现。

OTA 升级的流程大致是: 1、新的固件编译后,被加密打包到桌面配置程序中,由桌面配置程序向设备提供新固件 2、固件使用 AES256-GCM 加密 3、桌面配置程序分块向设备发送加密后的新固件 4、设备收到加密后的新固件后解密,写入 Flash 5、设备切换到新固件,重启设备

OTA 有几个要点:

固件保护

作为商业产品,我们要保护固件不被非法获取,也不允许运行被非法修改过的固件。也即是要保护固件不泄露,同时要保证固件的完整性。

在 OTA 的过程中,用户能接触到的都是加密过的固件,直到新固件进入设备后才会被解密。加密/解密用的 AES Key 在设备端,我们保存在加密后的 NVS 中。

ESP32S3 运行的固件,我们使用 ESP32S3 提供的透明加密,保证外人无法直接从 Flash 芯片上读取到有意义的固件。

ESP32S3 在收到加密后的固件块时,解密后,同时会校验 AES Tag/AAD,以保证数据的完整性。

固件传送

加密后的固件被嵌入到桌面配置程序中。我们定义了设备与桌面配置程序之间的 OTA 交互协议,定义了几个消息类型,保证加密后的固件能分块正确的传送到设备。

3.2.9 固件中的其他功能

3.2.9.1 LED 指示灯

设备中设计有一个 WS2812 RGB LED 作为设备指示灯。这个指示灯的主要用途是现场设备辨识

场景举例:Anchor 在现场安装完毕后,如果调试时发现定位不对或输出混乱,很有可能是 Anchor 安装位置搞混了——比如应该安装在 A 处的 Anchor 被装到了 B 处。现场的 Anchor 通常安装在高处(天花板、灯杆),外观完全一样,不爬上去看铭牌根本分不清谁是谁。

有了 WS2812 指示灯,只需在 PC 端配置程序中点击某个 Anchor,让它点亮特定颜色的 LED(比如绿色闪烁),然后在现场抬头看看哪个设备在闪,就可以确认物理位置与逻辑 ID 的对应关系。

ESP-IDF 中的驱动方式: ESP-IDF 提供了 led_strip 组件,可以通过 RMT(Remote Control Transceiver) 外设来驱动 WS2812。RMT 是 ESP32 特有的一个外设,原本设计用于红外遥控器协议的编解码,但因为它能精确控制脉冲时序,被广泛用于驱动 WS2812 这类需要精确时序的 LED。

3.2.9.2 按钮

我选用的 Anchor 外壳上有一个按钮孔位。我为这个按钮设计了 3 种触发模式:

触发模式 定义 计划功能
短按 按下后在 1 秒内松开 触发 Debug 信息输出(如各任务栈使用情况)
长按 按下后持续 2 秒以上再松开 恢复出厂默认设置
按着开机 上电前按钮已处于按下状态 进入特殊模式(如 TWR 自动定位模式)

一些具体应用场景:

  • 恢复出厂设置:配置参数搞乱后,用户可以长按按钮恢复默认值,然后重新配置需要修改的参数。
  • TWR 自动定位:如果已经有数个 Anchor 的坐标被手动配置好了,新 Anchor 可以通过 TWR(Two-Way Ranging)测量自己到已知 Anchor 的距离,再用三点定位法自动计算自己的坐标。这个功能也可以通过配置程序远程触发。
  • 软复位:对于内置锂电池的 Anchor(无法直接断电),可以通过按钮触发软件复位。

3.2.9.3 显示屏

Anchor 是否需要显示屏,取决于应用场景。在大多数场景下(Anchor 长期安装在天花板或灯杆上),显示屏毫无用处,只增加成本。但在某些需要临时部署电池供电的场景下(需要查看电池电量、设备状态等),显示屏会有价值。

Tag 带显示屏则比较有意义:可以显示计算出的实时坐标、控制中心发来的文字通知等。目前我的开发重心在定位核心功能上,显示屏功能计划在后续版本中加入。

3.2.9.4 麦克风和喇叭

Tag 可以利用 ESP32-S3 的 I2S 接口连接麦克风和喇叭,通过 WiFi 实现类似对讲机的语音通讯功能。目前尚未实现,留作后续扩展。

3.2.10 固件设计中可能遇到的问题

在固件开发过程中,我遇到了一些"坑",记录在这里供参考。

3.2.10.1 字节对齐

对于 C/C++ 程序员来说,字节对齐是个"老生常谈"的问题。但在 ESP32 上,它可能以一种意想不到的方式出现。以我的经验来说,字节对齐问题通常有以下几种表现:

平台 表现
Windows (MSVC) 编译器自动插入 padding,程序正常运行但 sizeof() 比预期大
STM32 (ARM + IAR/GCC) 编译器可能发出警告;如果用 packed 属性,ARM Cortex-M 可以处理未对齐访问(有轻微性能损失)
ESP32 (Xtensa) 直接 Crash! Xtensa 指令集不支持未对齐内存访问,硬件会触发 LoadStoreAlignmentCause 异常

这次我遇到了一个更隐蔽的问题——代码运行看上去正常,但读和写访问的是不同的地址

typedef struct __tag_ddoa___ {
	EUI64 aId;      // 偏移 0,  大小 8
	float ax;       // 偏移 8,  大小 4
	float ay;       // 偏移 12, 大小 4
	float az;       // 偏移 16, 大小 4

	EUI64 bId;      // 偏移 20, 大小 8  ← 问题在这里!
	float bx;       // 偏移 28, 大小 4
	float by;
	float bz;

	float deltaDistance;
} DDOA;

开始没有加 __attribute__((packed))。在生成 listDDOAs 的过程中,给 bId 赋值后立即读回,发现 bId 之后的字段全部不正确。

dump 内存数据后发现:编译器在写入 bId 时将它放在了偏移 20 的位置(紧接在 az 之后),但在读取时却从偏移 24 开始读(因为编译器在另一个编译单元中认为 EUI64 应该 8 字节对齐,在 az 和 bId 之间插入了 4 字节的 padding)。

根本原因: 同一个结构体在不同的编译单元中,由于编译优化级别或编译选项的微小差异,编译器可能做出不同的对齐决策。如果没有显式的 packed 属性,结构体的内存布局就依赖于编译器的默认行为——而这个行为可能不一致。

解决方法:在结构体定义时加上 __attribute__((packed))。当然这会带来一些性能开销(未对齐访问在 Xtensa 上需要软件模拟),另一种做法是手动调整字段顺序来保证自然对齐——例如把所有 8 字节字段放在前面,4 字节字段紧随其后。

3.2.10.2 ISR 中的日志打印

在 ESP-IDF 中,ISR 中不能使用 ESP_LOGI() 等常规日志函数——因为这些函数内部会尝试获取互斥锁、分配内存等操作,这些在 ISR 上下文中是被禁止的。

ESP-IDF 提供了 ESP_DRAM_LOGI() 系列函数专门用于 ISR 中的日志输出。但是要注意:ESP_DRAM_LOGI() 不支持 int64_t / uint64_t 类型的格式化输出。传入 64 位整数参数时,它只会打印低 32 位,高 32 位被丢弃——而且不会有任何警告!

这在调试时钟同步代码时特别坑人——DW3000 的 40-bit 时间戳存储在 uint64_t 变量中,如果你在 ISR 中打印这个时间戳来调试却总是看到一个奇怪的小数字,可能就是因为高位被截断了。

解决方法: 在 ISR 中将 64 位值拆分为两个 32 位部分分别打印,或者将调试数据通过队列发送到主任务中再使用 ESP_LOGI() 打印。

3.3 配置程序

无论是 Anchor 还是 Tag,都有很多参数需要配置——UWB 通信参数(频道、速率、前导码长度等)、Anchor 坐标、时钟同步层级、观察者指定、WiFi 凭证等等。

配置程序的实现方案大致有三种:

方案 优点 缺点
设备内置 WebServer 无需安装任何软件,浏览器即可配置 WiFi 以太网首次配网的"鸡蛋问题";页面存在 Flash 中占空间;JSON 编解码耗 RAM;无法批量配置
手机 APP 人人有手机,随身携带方便 屏幕小、操作不便;需要同时适配 iOS/Android;开发维护成本高
PC 桌面程序 屏幕大、操作方便;支持批量配置;可集成 USB HID 功能 需要到现场带电脑

在老的上行 TDOA 项目中,我使用 Delphi 开发配置程序。选择 Delphi 当时主要是因为开发桌面程序简单且我比较熟悉。但后来很多客户反馈:现在会 Delphi 的开发人员越来越少,程序维护困难。再加上整个系统的固件都是 C/C++ 编写的,很多基础代码(如消息定义、结构体定义等)无法与 Delphi 共用,需要单独维护一份——增加了工作量也容易出现不一致。

新项目我决定使用 C++ + Qt 来开发配置程序。好处是:

  • C++ 可以直接共用固件中的消息定义头文件
  • Qt 跨平台(Windows/Linux/macOS)
  • Qt 生态成熟,图形界面开发效率高

通信协议——二进制 vs JSON

配置程序与 Anchor/Tag 之间的数据交换格式,使用二进制还是 JSON,各有利弊:

  二进制格式 JSON 格式
优点 可共用固件中的结构体定义;传输效率高;解析速度快 向前/向后兼容性好(字段可增减);人类可读,便于调试
缺点 修改字段时兼容性差(新旧版本可能不兼容) 设备端需要 JSON 编解码库,占用 Flash/RAM;编解码过程繁琐

经过权衡,我最终选择了二进制格式。主要原因是 ESP32-S3 的 RAM 虽然不小,但一旦引入成熟的 JSON 库(如 cJSON),还是会占用不少资源。而二进制格式只需在 PC 和设备两端使用相同的结构体定义即可——这正是 C++ 配置程序的优势之一。

兼容性问题的应对策略: 每种消息都有一个 message_type 字段和隐含的版本号。当未来需要修改消息结构时,可以:(1)保留老的消息类型不变,增加新的消息类型;(2)在消息头中加入版本号字段,收发两端根据版本号决定如何解析。

配置程序的基本架构

  1. 设备发现:配置程序启动后通过 UDP 广播发送发现请求,局域网内的所有 Anchor/Tag 收到后回复自己的 IP 地址和基本信息。
  2. 建立连接:配置程序作为 TCP Client,与发现的设备建立 TCP 长连接。通过这个连接获取设备的完整配置并进行修改。
  3. USB HID 配置:对于尚未联网的设备,通过 USB 连接使用 HID 协议配置 WiFi 凭证和管理员密码。我使用 hidapi 开源库来实现 PC 端的 HID 读写操作。

设备插拔检测: hidapi 库本身不提供 USB 设备插拔的事件通知,只能通过轮询 hid_enumerate() 来检测。为了更好的用户体验,在 Windows 版本中我改为监听 Windows 的 WM_DEVICECHANGE 消息来实时检测 USB 设备的插拔。Linux 版本目前尚未适配,如有需要可以通过 udev 机制实现类似功能。

日常对设备的配置使用网络。设备作为 TCP Server,配置程序作为 TCP Client。在建立起 TCP 连接之前,设备与配置程序之前会使用 UDP 广播包进行交互,让配置程序可以发现局域网中的设备(IP 地址),然后配置程序会主动向设备发起 TCP 连接。

配置程序的界面如下: 

上图是主界面,设备列表中的列可以让用户自定义。

 上图是设备基本信息

 上图是网络设置

 上图是 UWB 设置

 上图是时钟同步设置

3.4 消息汇聚程序以及前端地图和数据可视化

3.4.1 消息汇聚服务器

这是一个辅助程序,作为 PC 上的后台服务运行。

当 Tag 计算出坐标后,要么仅供 Tag 自身使用(如在本地显示屏上显示),要么上报给应用系统。如果系统中有很多 Tag,每个 Tag 都各自直接与应用系统对接,对应用系统的开发者来说会非常繁琐(需要管理大量连接)。

因此我编写了一个消息汇聚服务程序——它充当中间层,统一收集所有 Tag 的数据,再对外提供标准化的接口。

 
 

这个程序同时作为 TCP Server(接受来自各个 Anchor/Tag 的连接)和 WebSocket Server(接受来自浏览器和应用程序的连接)。它把来自 TCP Client 的消息经过整理后转发给 WebSocket Client。

3.4.2 前端地图

前端地图使用 Node.js 开发,使用 OpenLayers 作为地图前端组件,以 OpenStreetMap 作为底图。

地图前端在浏览器中加载后,会与消息汇聚程序建立 WebSocket 连接,实时接收来自汇聚程序的消息(主要是 Tag 坐标和 Anchor 坐标),把 Anchor 和 Tag 的位置实时显示在地图上。

这个地图主要用来看定位效果。我加了历史轨迹,让 tag 拖一个小尾巴,可以直观的看到定位精度。 红色是计算出来的坐标,绿色是经过卡尔曼滤波后的坐标。

从上图来看,大部分情况下精度在 20cm 以内。

实际部署提示: 对于室内定位场景,OpenStreetMap 的底图通常不包含室内楼层平面图。实际项目中可以将建筑的 CAD 平面图导出为图片,作为自定义图层叠加在地图上。OpenLayers 支持通过 ImageLayer 来加载自定义的楼层底图。

3.4.3 数据可视化

数据可视化使用 HTML + JavaScript 开发,是一个简单但非常实用的调试页面。

该页面加载到浏览器后,同样与消息汇聚程序建立 WebSocket 连接,但接收的是时钟同步相关 的诊断消息(如各 Anchor 的同步误差、factor 值变化、反馈量等),把时钟同步的状态以曲线方式实时显示。这使我们可以直观地看到:

  • 各个 Anchor 的同步误差随时间的变化趋势
  • 反馈机制是否正常工作(误差是否在逐步收敛)
  • 是否存在周期性的干扰或异常跳变

调试利器: 在开发过程中,这个可视化页面帮了大忙。很多时钟同步的问题(如 Kalman 滤波器参数不合适导致的振荡)仅凭日志输出很难发现,但在曲线图上一眼就能看出来。强烈建议任何 TDOA 系统的开发者都建设类似的可视化工具。


💡 关于作者:
👉 我正在销售使用上行 TDOA 技术的 UWB 定位方案(https://uwbhome.top),这是一个已经量产的产品方案,已被应用于很多领域,经过了大量用户的验证。购买我们的方案,你可以跳过高风险的基础研发阶段,直接进入到生产销售状态。
👉 下行 TDOA 技术的 UWB 定位方案也已完成产品化,本方案完全自研,拥有完整知识产权,无须第三方专门授权。代码中使用了部分开源组件,但完全符合其 License 要求。如果你有兴趣,可以购买这个方案(https://skytracksoft.com)。
👉 我有 30+年的软件开发经验,10 年 Decawave/Qorvo UWB 芯片(DW1000/DW3000)使用经验,如果你有 UWB 方面的项目合作,可以联系我,微信/电话: 18985041403,Email: 该 Email 地址已受到反垃圾邮件插件保护。要显示它需要在浏览器中启用 JavaScript。

版权所有

本站文章均为原创,版权属 Skytrack Software Studio 所有。

所有人可以转载,但必须注明出处以及作者信息。