基于STM32的UWB LinkTrack一键标定实现与协议解析

发布时间:2026/9/17 21:30:46
基于STM32的UWB LinkTrack一键标定实现与协议解析 简介基于STM32的Nooploop Linktrack超宽带UWB一键标定程序完整工程适合毕业设计、课程作业及嵌入式定位入门者。项目以STM32F103为主控结合超宽带UWB模块实现一键标定与测距定位代码涵盖GPIO、定时器、外部中断、串口通信、超宽带UWB驱动以及Timer/Key/LED/Serial等硬件模块封装有助于理解系统级嵌入式程序设计。压缩包内共83个文件包括34个头文件、33个C源文件、8个汇编启动文件并附带Keil工程文件、README说明与链接脚本工程结构清晰便于按模块阅读。整个资源包仅312KB内容精炼目前已有196人学习下载。通过研读该工程可掌握STM32标准外设库的调用方式、超宽带UWB模块的初始化与数据交互流程并借鉴一键标定的状态机或流程设计思路为后续算法验证与产品开发提供可直接参考的工程样板。1. 把 LinkTrack 标定做成一键究竟在标什么答辩前一周我把 Nooploop LinkTrack 的四个锚点固定在实验室四个墙角定时器也开了标签也戴上了测距数据却始终在原地抖动——原因后来查到锚点模块里的坐标参数是出厂默认值根本没写入实际位置。手动用串口助手逐条发命令写错一条整组测距就偏。给 STM32F103C8 写一个一键标定程序本质就是把原本需要在串口助手上一行一行敲的配置命令变成按键触发、状态机驱动、带超时重试的自动流程。这个项目适合两种人正在做 UWB 定位相关毕设的学生以及想把 LinkTrack 模块快速接入自己硬件平台的嵌入式工程师。理解帧结构和状态机比直接抄代码更能应付现场问题。2. LinkTrack 协议帧与标定命令的字节级设计2.1 链路层帧结构0x55 0x04 开头校验和怎么算Nooploop LinkTrack 模块在 UART 上跑的是自定义帧协议结构并不复杂。一帧从0x55 0x04开始随后两字节是负载长度字段小端序第五字节固定为0x5E再往后是功能字和数据段最后一字节是校验和。校验和算法是从帧头到数据段最后一个字节逐字节累加后取低 8 位这个算法在裸机环境下用循环实现成本极低。偏移字节数内容说明0~12帧头0x55 0x042~32负载长度 len小端不含帧头与校验和含功能字41分隔符0x5E51功能字例如0x18表示写配置6~6len-2len-1实际参数数据末尾1校验和从0x55到数据末累加取低 8 位构建帧时最容易出错的地方是长度字段。负载长度指的是功能字加数据段的总长度如果数据段有 N 字节长度字段要写 N1。我在 UWB.c 里封装了统一的上层函数专门处理这个偏移uint8_t UWB_BuildFrame(uint8_t func, const uint8_t *payload, uint8_t len, uint8_t *out) { uint8_t idx 0, sum 0; out[idx] 0x55; // 帧头第一字节 out[idx] 0x04; // 帧头第二字节 out[idx] (uint8_t)((len 1) 0xFF); // 负载长度低字节 out[idx] (uint8_t)(((len 1) 8) 0xFF); // 负载长度高字节 out[idx] 0x5E; // 分隔符 out[idx] func; // 功能字 for (uint8_t i 0; i len; i) { out[idx] payload[i]; // 参数数据 } for (uint8_t i 0; i idx; i) { sum out[i]; // 累加校验和 } out[idx] sum; return idx; }注意len是数据段字节数帧最后一个字节是校验和返回的idx就是实际生成的总长度。这里把长度字段写成len 1是因为功能字也算在负载里。如果你的模块固件版本对长度定义不同标定前先用逻辑分析仪抓一帧出厂命令对比一下再决定要不要保留这个加一。2.2 标定要写进去的几组参数一键标定真正写入的不只是坐标还包括网络 ID、工作模式、角色定义。每个参数对应一条写配置请求。常见配置如下参数取值范围示例说明网络 ID1~200视固件同一物理空间多套系统时用于隔离工作模式TWR / TDOA点名定位优先用 TWR便于逐锚点验证距离锚点坐标x、y单位毫米写错单位会导致定位整体缩放节点角色Anchor / Tag必须保证至少一个 Tag 才能触发测距毕设场景建议用 TWR 模式五个模块里一个配置成 Tag其余四个配置成 Anchor每个 Anchor 写入自己的实际坐标。TDOA 虽然基站数量更灵活但对时间同步要求更高调试时多一个维度的问题课设周期内不容易摸透。坐标单位方面LinkTrack 的坐标参数在协议层一般以毫米为整数从浮点转整数时要乘 1000不要乘 100。2.3 标定前的自检读设备 ID一键标定开始之前我会先发一帧读设备 ID 命令确认模块在线。读 ID 命令的功能字是0x14负载为空。模块返回的数据帧里会有设备类型、固件版本和节点 ID这些信息直接决定后面的功能字是否需要调整。uint8_t buf[32]; uint8_t len UWB_BuildFrame(0x14, NULL, 0, buf); UWB_SendBytes(buf, len);发送之后模块会在几十毫秒内回复一帧带0x14功能字的数据。自检通过再进入写配置流程能减少因为供电不足、串口接反导致的“假标定”——也就是程序报成功、实际什么都没写进去的情况。如果模块没有回复优先检查电源和 TX/RX 是否交叉连接而不是急着改协议。3. STM32F103C8 串口外设配置与 UWB 驱动骨架3.1 引脚分配与时钟配置STM32F103C8T6 有 3 个 USART工程里最常见的分配方案是USART1 接 PC 的 USB 转 TTL用于输出调试日志USART3 接 Nooploop LinkTrack 模块。我这里选择 USART3PB10 为 TXPB11 为 RX波特率 1152008 数据位、1 停止位、无校验。这个波特率也是 LinkTrack 出厂默认值除非你知道模块被改过配置否则先用 115200。外设引脚用途USART1PA9(TX) / PA10(RX)Debug 日志输出到 PCUSART3PB10(TX) / PB11(RX)与 LinkTrack UWB 模块通信TIM2无外部引脚10ms 节拍按键扫描与超时计数GPIOPC13LED 状态指示GPIOPA0按键输入长按触发标定标准外设库下的串口初始化如下。注意 USART3 挂在 APB1 总线上时钟频率是 36MHz而不是 APB2 的 72MHz这会影响波特率分频计算但标准库的USART_Init会直接用 RCC 配置好的时钟所以只要 PCLK1 时钟源配置正确即可。void UWB_UART_Init(void) { GPIO_InitTypeDef gpio; USART_InitTypeDef usart; NVIC_InitTypeDef nvic; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART3, ENABLE); gpio.GPIO_Pin GPIO_Pin_10 | GPIO_Pin_11; gpio.GPIO_Mode GPIO_Mode_AF_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, gpio); usart.USART_BaudRate 115200; usart.USART_WordLength USART_WordLength_8b; usart.USART_StopBits USART_StopBits_1; usart.USART_Parity USART_Parity_No; usart.USART_Mode USART_Mode_RX | USART_Mode_TX; usart.USART_HardwareFlowControl USART_HardwareFlowControl_None; USART_Init(USART3, usart); USART_ITConfig(USART3, USART_IT_RXNE, ENABLE); nvic.NVIC_IRQChannel USART3_IRQn; nvic.NVIC_IRQChannelPreemptionPriority 1; nvic.NVIC_IRQChannelSubPriority 0; nvic.NVIC_IRQChannelCmd ENABLE; NVIC_Init(nvic); USART_Cmd(USART3, ENABLE); }GPIO 模式设成复用推挽输出USART 硬件会自动控制引脚电平。RXNE 中断使能后每收到一个字节都会进一次 USART3 中断这是后面接收逻辑的基础。如果你手头这块板子 PA9/PA10 被其他功能占用了把 USART3 换成 USART2 也是同样的流程只改时钟和 NVIC 通道号逻辑不用变。3.2 中断接收与环形队列不用 DMA 完全可以LinkTrack 单帧负载最大几十字节115200 波特率下每字节约 87 微秒主循环只要不在这段时间里关中断太久就不会丢帧。但为了稳妥我在 UWB.c 里放了一个 128 字节的接收缓冲区中断里只负责存字节解析放到主循环static volatile uint8_t rx_buf[128]; static volatile uint8_t rx_cnt 0; void USART3_IRQHandler(void) { if (USART_GetITStatus(USART3, USART_IT_RXNE) ! RESET) { uint8_t ch (uint8_t)USART_ReceiveData(USART3); if (rx_cnt sizeof(rx_buf)) { rx_buf[rx_cnt] ch; // 缓冲区未满就存入 } } }rx_cnt是累计接收计数超过 128 后直接丢弃新数据等主循环处理完一帧后置零。更健壮的方案是用带头尾指针的环形队列但 128 字节缓冲区加主循环周期扫描已经能覆盖毕业设计里模块测距数据的接收场景。关键是主循环的扫描周期不要超过 87 微秒量级的若干倍否则模块连续发多帧时缓冲区会溢出。3.3 UWB.c 的解析状态机解析 LinkTrack 帧最需要注意的帧头是0x55 0x04两字节连续出现而不是单独的0x55。长度字段又是小端序所以解析状态机要按状态分步推进。static uint8_t rx_frame[132]; static uint8_t frame_len 0; static uint8_t parse_idx 0; void UWB_PollFrame(const uint8_t *data, uint16_t len) { for (uint16_t i 0; i len; i) { uint8_t ch data[i]; if (parse_idx 0 ch ! 0x55) continue; // 等待帧头1 if (parse_idx 1 ch ! 0x04) { // 等待帧头2 if (ch 0x55) { parse_idx 1; continue; } parse_idx 0; continue; } if (parse_idx 4 ch ! 0x5E) { // 等待分隔符 if (ch 0x55) { parse_idx 1; continue; } parse_idx 0; continue; } rx_frame[parse_idx] ch; if (parse_idx 2) { frame_len ch; // 先取长度低字节 } if (parse_idx 6 parse_idx frame_len 6) { uint8_t sum 0; for (uint8_t j 0; j parse_idx; j) { sum rx_frame[j]; // 累加校验 } if (sum ch) { UWB_HandleFrame(rx_frame[6], parse_idx - 6); } parse_idx 0; frame_len 0; } } }解析流程是先确认0x55 0x04再在第 5 字节判断隔离符0x5E随后按长度字段逐字节累积最后用校验和判断帧是否完整。UWB_HandleFrame()是上层分发入口根据功能字决定是刷新测距缓存还是释放标定状态的等待信号量。帧长度低字节为 0 时说明负载超过 255 字节这种情况在 LinkTrack 模块的广播帧中不多见毕设级别的代码按单字节长度处理就够了。4. 一键标定状态机按键触发、超时保护与坐标写入4.1 按键长按与状态指示标定不是每次上电都要做所以按键要区分短按和长按。我用 TIM2 产生 10ms 中断在中断里做按键消抖和长按计时。连续 500 个 tick5 秒读到低电平判定为长按触发主循环检测到标志后进入标定流程。void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); Key_Scan10ms(); // 消抖和电平状态累计 if (key_press_tick 500) { calib_start_flag 1; // 长按5秒触发标定 key_press_tick 0; } } }key_press_tick在Key_Scan10ms()里根据当前 GPIO 电平累加或清零。500 个 tick 等于 5 秒这个阈值在正式演示前不要调太短否则误触会导致模块参数被覆盖。LED 状态按标定阶段区分慢闪表示待机快闪表示标定进行中常亮表示标定完成熄灭表示失败。由于 LinkTrack 写完参数后需要重启我会在标定流程里故意让 LED 快闪 2 秒再进入验证这个可视化节奏能直接看出模块是已经重启还是卡死。4.2 标定状态机与超时重传标定流程本质是一个严格串行的事务自检 → 写网络 ID → 写工作模式 → 写四个锚点坐标 → 重启模块 → 验证。每个步骤都要等模块 ACK不能连续写入否则模块处理不过来。用状态机组织最直观状态动作超时时间超时处理CAL_STATE_CHECK发读 ID 自检命令500ms重试 2 次CAL_STATE_NETID写网络 ID500ms重试 1 次CAL_STATE_MODE写工作模式500ms重试 1 次CAL_STATE_COORD0~3依次写 4 个锚点坐标500ms重试 1 次CAL_STATE_REBOOT发重启命令2000ms不重试void Calib_StateMachine(void) { switch (calib_state) { case CAL_STATE_CHECK: UWB_SendCmd(0x14, NULL, 0); calib_tick 0; calib_retry 2; calib_state CAL_STATE_CHECK_WAIT; break; case CAL_STATE_CHECK_WAIT: if (calib_ack_flag) { calib_ack_flag 0; calib_state CAL_STATE_NETID; } else if (calib_tick 50) { // 500ms 超时 if (--calib_retry 0) { UWB_SendCmd(0x14, NULL, 0); calib_tick 0; } else { calib_state CAL_STATE_FAIL; } } break; case CAL_STATE_NETID: // 组装网络ID参数并发送逻辑与CHECK_WAIT相似 break; // MODE、COORD0~3 同理只是发送内容不同 } }这里calib_tick是 TIM2 的 10ms 累计计数50 就是 500ms。每次发命令前先清零等待 ACK 期间持续累加。calib_ack_flag在UWB_HandleFrame()里解析到 ACK 功能字时置位。重试次数和超时时间要根据实际模块的响应速度调整LinkTrack 在电源波动大的场景下ACK 有时会延迟到 300ms 以上所以超时设在 500ms 比较稳调太短容易误判失败。4.3 坐标写入时的单位与字节序坐标写入是标定里最不能省的环节。LinkTrack 的坐标参数要求毫米为单位的整数假设要把(1.20, 2.35)这个浮点坐标写进去必须先乘以 1000 得到(1200, 2350)再按小端序填入负载。typedef struct { int16_t x_mm; int16_t y_mm; } Coord_t; uint8_t UWB_SetAnchorCoord(uint8_t anchor_id, int16_t x_mm, int16_t y_mm) { uint8_t payload[5]; payload[0] anchor_id; payload[1] (uint8_t)(x_mm 0xFF); // 坐标低字节 payload[2] (uint8_t)((x_mm 8) 0xFF); // 坐标高字节 payload[3] (uint8_t)(y_mm 0xFF); payload[4] (uint8_t)((y_mm 8) 0xFF); return UWB_SendCmd(0x18, payload, sizeof(payload)); }调用UWB_SetAnchorCoord(1, 1200, 2350)时payload[1..2] 是 1200 的小端序payload[3..4] 是 2350 的小端序。如果浮点转整数时乘的是 100 而不是 1000定位结果就会整体缩放到十分之一尺度表现是测距数据正常但坐标点全部挤在原点附近。这是标定排错时的典型特征不用怀疑模块坏了先查单位。所有坐标写完并确认 ACK 后程序会发一帧重启命令让模块重新加载配置。我在调试中发现如果跳过了重启直接验证即使参数写入成功模块内部生效的仍然是旧配置测距结果和标定前一致。这个坑很容易被忽略。5. 用逻辑分析仪抓帧校验时序以及标定后如何复验5.1 逻辑分析仪抓串口帧标定后测距异常时第一件事不是改代码而是抓 STM32 的 TX 引脚波形。把逻辑分析仪通道接在 PB10USART3_TX上波特率设为 115200、8N1下降沿触发能看到类似这样的十六进制序列55 04 06 00 5E 18 01 02 03 04 00 2B55 04是帧头06 00表示负载 6 字节5E是分隔符18是写配置功能字后面的01 02 03 04 00是数据最后的2B是校验和。如果抓到的数据中间有连续FF或字节间隔过大原因多半是时钟配置错误或者波特率不匹配。把模块单独接一个 USB 转 TTL用串口助手发同样的帧做交叉验证能快速区分是 STM32 侧的问题还是模块侧的问题。5.2 标定后用测距信息复验标定完成后模块在 TWR 模式下会周期性发送0x02功能字的测距帧里面包含锚点编号和距离值。程序里解析出来在调试串口上打印类似ANCHOR 1, DIST 3.24 m的日志。如果四个锚点的距离都能稳定输出且波动在 20 厘米以内说明标定生效。实际布局时我习惯把四个锚点放在矩形的四个角用卷尺量好实际距离后把坐标以毫米为单位写入锚点模块最终定位输出的坐标会和卷尺量出来的结果基本吻合。void UWB_PrintDistance(const uint8_t *data) { // data[0] 是锚点 IDdata[1..2] 是距离毫米小端 int16_t dist_mm data[1] | (data[2] 8); printf(ANCHOR %d, DIST %.2f m\r\n, data[0], dist_mm / 1000.0f); }注意不要在串口中断里直接调用printf中断里只做缓冲把测距帧推进队列主循环里统一打印。否则 USART1 和 USART3 互相抢占日志会乱掉而且丢帧概率显著变大。5.3 最容易踩的三个坑第一个坑写完配置后没有发重启命令。LinkTrack 的参数写入要重启才能完全生效如果标定程序只写不重启模块上电后依然是旧参数。第二个坑不同固件版本的功能字有差异。毕设拿到的模块可能刷过不同版本的固件0x18写配置基本通用但 ACK 功能字和读配置的返回结构不一定相同标定开始前先打印一版模块固件版本遇到协议对不上就找对应的固件手册。第三个坑标定期间锚点不能离得太远。如果四个锚点分布跨度超过 10 米USB 供电模块在标定瞬间可能因为射频信号弱导致 ACK 超时。我之前遇到过重试 3 次仍然失败的案例解决办法是先近距离完成标定再把锚点挪到实际位置上电后不要重新标定直接进入测距模式。本文还有配套的精品资源点击获取