☰
STM32F4接激光雷达:串口DMA接收与点云解析实战指南
2026/10/19 23:40:31 网站建设 项目流程

简介:一份基于STM32F4与激光雷达(LiDAR)的嵌入式串口通信工程代码包,面向机器人、无人机及自动化设备开发者,解决雷达数据读取、串口显示与PWM调速控制等核心问题。工程覆盖UART参数配置、中断接收、数据帧解析、PWM定时器调速、异常处理等完整流程,适合有STM32基础、希望快速上手雷达集成的人群。压缩包共161个文件,含大量C源码与头文件、编译链接产物及Keil工程配置,另有TXT说明等文档,整体约3.2MB,可直接在Keil uVision或STM32CubeMX相关环境中打开查看。目前已有670人学习下载。代码结构完整,基于标准外设库的分模块驱动清晰易读,展示了将距离、角度等数据通过串口工具实时显示的过程,并封装了帧错误、溢出等通信异常的排查思路;同时PWM调速部分给出定时器配置与占空比调整示例,可作为机器人避障、导航等智能系统开发的参考起点。

1. 先让激光雷达在 STM32F4 上“开口说话”:串口与点云之间缺了什么

用 STM32F4 接一颗单线激光雷达,第一次点亮的场景往往不是发光,而是发懵:雷达电机转了,串口助手里刷出来一串 0xFA 开头的十六进制,肉眼完全看不出角度和距离。这包代码解决的就是这个问题——它把“串口接收、帧解析、点云计算、数据上送”这条链补齐了。拿到手之后,你不需要从寄存器开始啃,也不需要对着雷达协议文档死磕帧结构,直接能跑出每个采样点的角度和距离值。适合正在做避障小车、低成本 AGV、或者想拿激光雷达验证导航算法的人。代码以 STM32F4 为底座,核心依赖就是串口和 DMA,工程结构简单到可以在一两个晚上读完读懂。

2. 串口链路篇:波特率 230400、DMA 空闲中断与收发缓冲怎么搭

激光雷达的“串口”和普通调试串口的差别,一是数据量大,二是不定长。雷达每秒要往外吐几万个字节,如果还用一字节一中断的方式接收,CPU 会被打断得喘不过气;如果只配一个固定长度接收缓冲区,又因为协议帧长短不一,很难判断一帧什么时候结束。所以这一层的关键选型是 DMA 接收配合空闲中断,让硬件帮你“攒”数据,然后整包取走。

2.1 物理链路:电平、共地与供电电流

先处理接线。大多数单线激光雷达是 TTL 电平输出,可以直接和 STM32F4 的串口引脚相连,但三个细节容易翻车:第一,雷达的 TX 要接单片机的 RX,雷达的 RX 接单片机的 TX,交叉接;第二,两边必须共地,否则信号电平没有参考点,数据流会随机丢字节;第三,雷达电机启动瞬间电流很大,常见做法是外接电源给雷达供电,不要从开发板的 3.3V 管脚硬拉,否则雷达一转,MCU 电压被拉低,整个系统重启或乱码。

我拿到板子后一般先用万用表量雷达供电引脚的空载电压,再接上雷达量带载电压,压差超过 0.2V 就换供电方案。很多“雷达转但不出数”的问题,其实不是代码问题,是电平根本不满足通信要求。

2.2 串口参数:为什么默认是 230400 8N1

这包代码默认的串口参数是波特率 230400、8 位数据位、1 位停止位、无校验,简写为 230400-8N1。选择这个参数不是拍脑袋:单线雷达转一圈通常输出千级点,每点两字节,加上帧头、角度、校验等开销,10Hz 扫描频率下数据吞吐量在每秒几十 KB 量级,115200 波特率有点紧,230400 刚好留出余量,256000 虽然更快但不少雷达固件不支持。

参数项数值说明
波特率230400常见单线雷达默认速率,也有 115200/256000 的,以雷达标签为准
数据位8固定
停止位1固定
校验位无雷达协议内部自带校验,串口层不需要再加
流控无硬件流控会多两根线,雷达基本不支持

如果串口助手上全是乱码,第一反应不要改波特率,而是先确认单片机的外部晶振是不是和工程里 HSE_VALUE 一致,这个坑后面专门说。

2.3 HAL 库的 DMA 空闲中断接收配置与代码

下面这段是工程里串口初始化部分的核心,以 HAL 库风格编写。它做的事情是:把 USART2 配置成 230400-8N1,开启 DMA 接收,并打开串口空闲中断。

// j_lidar_uart.c 片段 #include "stm32f4xx_hal.h" UART_HandleTypeDef huart2; DMA_HandleTypeDef hdma_usart2_rx; #define RX_DMA_BUF_SIZE 512 // DMA 缓冲区,可容纳多帧数据 void MX_USART2_UART_Init(void) { huart2.Instance = USART2; huart2.Init.BaudRate = 230400; huart2.Init.WordLength = UART_WORDLENGTH_8B; huart2.Init.StopBits = UART_STOPBITS_1; huart2.Init.Parity = UART_PARITY_NONE; huart2.Init.Mode = UART_MODE_TX_RX; huart2.Init.HwFlowCtl = UART_HWCONTROL_NONE; HAL_UART_Init(&huart2); // DMA 接收通道:USART2_RX 映射到 DMA1 Stream5 Channel4 hdma_usart2_rx.Instance = DMA1_Stream5; hdma_usart2_rx.Init.Channel = DMA_CHANNEL_4; hdma_usart2_rx.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_usart2_rx.Init.PeriphInc = DMA_PINC_DISABLE; hdma_usart2_rx.Init.MemInc = DMA_MINC_ENABLE; hdma_usart2_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_usart2_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_usart2_rx.Init.Mode = DMA_NORMAL; // 普通模式,收满停下 hdma_usart2_rx.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma_usart2_rx); __HAL_LINKDMA(&huart2, hdmarx, hdma_usart2_rx); }

这里的关键模式参数是DMA_NORMAL。很多人习惯配成DMA_CIRCULAR循环模式,想着缓冲区自动循环接收更省事,但循环模式配合空闲中断取数据时,要额外处理指针回绕和“写指针追读指针”的问题,状态机稍一慢就会丢帧。普通模式是收到一定字节数后 DMA 停住,空闲中断告诉你“当前缓冲区里有多少有效数据”,处理完再重新启动 DMA,思路更直白。

接下来是重启动 DMA 和处理空闲数据的代码,放在串口中断服务函数里:

void USART2_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart2); // 必须先清标志,再取 DMA 剩余计数 uint16_t remain = __HAL_DMA_GET_COUNTER(&hdma_usart2_rx); // DMA 还剩多少没搬 uint16_t len = RX_DMA_BUF_SIZE - remain; // 本次实际收到的字节数 if (len > 0) { lidar_rx_indicator = 1; // 告诉主循环有数据可处理 rx_len = len; } HAL_UART_DMAStop(&huart2); // 停止再重开,保证下一次接收从头开始 HAL_UART_Receive_DMA(&huart2, rx_dma_buf, RX_DMA_BUF_SIZE); } }

顺序很重要:必须先清 IDLE 标志,再去读 DMA 计数器。如果没有清标志就读取,极端情况下会拿到上一次残留的长度,导致帧错位。HAL_UART_DMAStop这一步是后悔药,它把 DMA 和串口都复位到初始状态,代价是这次收进来的数据必须尽快取走,所以中断服务函数里只置标志位,不做帧解析,帧解析放到主循环去做,避免长时间霸占中断。

3. 协议解析篇:从字节流到点云的帧结构、校验与角度换算

串口数据进来了,但缓冲区里是一堆没有边界的字节流。雷达的协议会按固定格式把数据切成“帧”,解析层要做的就是从字节流里找到每一帧的边界,提取出角度和距离字段,再按雷达定义的缩放规则换算成物理值。

3.1 单线雷达数据帧的通用结构

不同厂商的帧格式有差异,但多数基于三角测距原理的单线雷达,帧结构大同小异。这包代码兼容的是下面这种最常见的排布:

字段字节数含义典型值/说明
HEADER1帧头常见 0xFA,用于同步
CT1信息类型0x01 表示正常扫描数据
LSN1本次数据点数量通常 6 到 40,帧越长点越多
FSA2起始角度,低字节在前原始编码值,需换算成角度
LSA2结束角度,低字节在前同上
DATA2 * LSN各采样点距离数据每点两字节,低字节在前
CRC/CS1 或 2校验码有的用累计和,有的用 CRC 表

角度字段 FSA 和 LSA 不是直接的角度值。以这包代码默认的雷达为例,角度的解析规则是:把 FSA 两字节组合成 16 位无符号整数后右移一位,再除以 64,就得到起始角度的度数;LSA 同样处理得到结束角度。换算公式如下:

angle_start = (FSA >> 1) / 64.0f angle_end = (LSA >> 1) / 64.0f

距离数据每个点两字节,组合成一个 16 位整数后,除以 4 得到毫米值。为什么有这些除法?因为雷达内部用更高精度的 ADC 采样,输出时把小数部分压缩在低位,用倍数换精度。不同雷达这个缩放因子不同,有的是 /4,有的是 /8,甚至有的是直接毫米值,解析前务必对着你的雷达型号确认。

3.2 状态机解析代码:从环形缓冲区逐字节提取帧

主循环从 DMA 缓冲区里逐字节取数据,喂给状态机函数。状态机的思路是:当前在等帧头,遇到 0xFA 就进入“等类型字节”状态,然后依次收 LSN、角度、数据体,收满 LSN 指定的数据点后再收校验字节,收完一个完整帧后返回成功。

// j_lidar_parse.c 片段:状态机解析 #define MAX_POINTS_PER_FRAME 40 static uint8_t parse_state = 0; static uint8_t frame_buf[256]; static uint16_t frame_len = 0; static uint16_t need_len = 0; // 还差多少字节才能形成完整帧 static uint8_t lsn = 0; static uint8_t crc_calc = 0; int8_t lidar_parse_byte(uint8_t byte) { if (parse_state == 0) { if (byte == 0xFA) // 等帧头 { parse_state = 1; frame_len = 0; frame_buf[frame_len++] = byte; crc_calc = 0; } return -1; } if (parse_state == 1) // 帧类型 { if (byte != 0x01) // 非扫描帧,退出重新同步 { parse_state = 0; return -1; } parse_state = 2; } else if (parse_state == 2) // LSN { lsn = byte; if (lsn > MAX_POINTS_PER_FRAME) { parse_state = 0; // 点数异常,帧头误判 return -1; } need_len = 5 + 2 * lsn + 1; // CT+LSN+FSA+LSA(4) + 距离数据 + 校验 parse_state = 3; } frame_buf[frame_len++] = byte; crc_calc += byte; if (frame_len >= need_len) // 收满一帧 { parse_state = 0; if ((crc_calc & 0xFF) != frame_buf[frame_len - 1]) return -2; // 校验失败 return 1; // 完整一帧 } return -1; }

注意一个细节:第一字节 0xFA 没有计入crc_calc,其余字节全部累计。这是不少雷达协议的习惯——帧头不参与校验。最后一位校验字节是前面所有字节的和取低 8 位。如果你的雷达校验正好反过来(帧头也参与),结果会在避坑章里提到。状态机的优势是无论数据流从哪里开始,都能自动找到帧头,一个坏帧最多损失一个帧,不会把后续数据全部带偏。

3.3 角度与距离换算再上送

拿到一帧完整数据后,把角度和距离解算出来,装入点云数组。下面的函数演示了从frame_buf中取 FSA、LSA 和每个数据点,换算成角度和毫米距离:

// j_lidar_point.c 片段:角度与距离换算 typedef struct { float angle_deg; // 角度,单位度 uint16_t dist_mm; // 距离,单位毫米 } lidar_point_t; void lidar_parse_frame_to_points(uint8_t *buf, lidar_point_t *out, uint8_t *point_cnt) { uint16_t fsa_raw = buf[2] | (buf[3] << 8); uint16_t lsa_raw = buf[4] | (buf[5] << 8); float angle_start = (float)(fsa_raw >> 1) / 64.0f; float angle_end = (float)(lsa_raw >> 1) / 64.0f; float step = (angle_end - angle_start) / (float)(lsn - 1); if (step < 0.0f) step += 360.0f; // 跨零度包络 for (int i = 0; i < lsn; i++) { uint16_t raw = buf[6 + i * 2] | (buf[7 + i * 2] << 8); out[i].dist_mm = raw >> 2; // 距离缩放 out[i].angle_deg = angle_start + step * i; if (out[i].angle_deg >= 360.0f) out[i].angle_deg -= 360.0f; } *point_cnt = lsn; }

step计算了相邻采样点之间的角度增量。有些雷达在跨 0°/360° 边界时,angle_end会比angle_start小,直接相减得到负值,这时候补加 360° 是必须的处理,否则整个扇形区域的角度会整体拧成麻花。这属于“不调试根本想不到”的坑,解析完角度后先打印一帧验证,角度能顺序递增再继续。

4. 移植实战篇:换板子、换串口、换雷达时的时钟与中断配置

代码写得再好,工程不能在自己板子上跑起来,等于白搭。这包代码默认是在某款 F4 开发板上调通的,但换到自己的板子之后,时钟、引脚、中断优先级这三处几乎都要动。

4.1 压缩包里通常包含哪些文件,怎么组织工程

这包代码在你手里解压后,大概是下面这套组织方式。每个文件的职责边界很清晰,改一处不会牵连其他地方:

文件职责修改频率
j_lidar_uart.c / .h串口与 DMA 初始化,中断服务换串口时改
j_lidar_parse.c / .h状态机帧解析换雷达协议时改
j_lidar_point.c / .h角度距离换算、点云数组换雷达缩放因子时改
j_lidar_config.h波特率、缓冲区大小、雷达型号宏每次必看
main.c主循环调用、NVIC 配置初始化阶段改
README.txt接线、参数说明不常改

我拿到任何一份工程代码,都有一个强迫症习惯:先读配置文件。j_lidar_config.h里通常会集中定义波特率、DMA 缓冲区大小、最大点数这些编译期常量。不要把数字散落在各个 .c 文件里,否则换个雷达要改三个文件,漏一个就等着调半天。

4.2 时钟树与波特率偏差:HSE_VALUE 宏必须对

STM32F4 的串口波特率不是独立的,它由 APB1 总线时钟经过分频得到。如果外部晶振是 8MHz,工程里HSE_VALUE默认是 8000000,PLL 分频配好,总线时钟 168MHz,APB1 42MHz,这时候 230400 波特率非常准。

但换到某块板上是 12MHz 晶振,而工程里HSE_VALUE还是 8000000,初始化时系统还按 8M 去算 PLL,实际跑起来的时钟就乱了,串口数据会变成稳定的乱码。这类问题的特征非常明显:每个字节在示波器或逻辑分析仪上看波形完全对,但接收端解出的位流和发送端不一致。

// 在 stm32f4xx_hal_conf.h 或 system_stm32f4xx.c 中确认 #define HSE_VALUE 8000000U // 改成你板子的实际晶振频率 // PLL 配置示例(8MHz 晶振 → 168MHz 系统时钟) // PLL_M = 8, PLL_N = 336, PLL_P = 2, PLL_Q = 7

对外部晶振不是 8MHz 的板子,我一般会先在调试器里停住主循环,读HAL_RCC_GetSysClockFreq()的返回值,如果和预期差很远,那就是HSE_VALUE或 PLL 参数不对。这一步验证比盲改波特率有效得多。

4.3 换串口时改引脚与配置 NVIC

如果不用默认的 USART2,改串口的本质是改三个地方:GPIO 复用、UART 实例、DMA 通道。以把 USART2 的 PA2/PA3 换成 USART1 的 PA9/PA10 为例,GPIO 配置代码里要改GPIO_AF_USART2_MASK为GPIO_AF_USART1_MASK,DMA 通道也要对应查询数据手册。

串口中断优先级是另一个容易忽略的坑。激光雷达数据是持续不间断的流,串口中断优先级如果被设置为普通级别,系统里任何一个定时器中断都可能把雷达中断挤掉,导致丢字节。工程里的做法是:

HAL_NVIC_SetPriority(USART2_IRQn, 0, 0); // 抢占优先级最高 HAL_NVIC_EnableIRQ(USART2_IRQn);

优先级用 0 而不是默认的 5,原因很直接:宁可让雷达中断打断一个不重要的任务,也不能让不重要的任务打断雷达中断。如果系统里同时有需要高实时性的电机控制中断,至少保证串口中断和它同级,而不是更低。

5. 常见问题排查:雷达不出数、断流、乱码的六类现场

这一章直接给排查顺序。遇到雷达接上不工作,按下面的现象对号入座,比从头读代码快得多。

5.1 雷达电机转动,但串口没有任何字节输出

现象:雷达电机在转,串口助手打开对应端口,屏幕上一片空白,一帧数据都没有。

原因:九成是接线错误,其中最常见的是 TX/RX 接反。另一类常见原因是雷达和 MCU 没有共地。还有一类是雷达的串口电平是 5V,而 MCU 的 RX 引脚是 3.3V 容忍,虽然大多数 F4 引脚是 FT 引脚能容忍 5V,但如果 PCA 小板转换电平不对,也会收不到。

解决:先不用 MCU,直接把雷达的 TX 输出到 USB 转 TTL 工具,接电脑串口助手。如果电脑上能看到数据,说明雷达本身正常,问题在 MCU 侧接线;如果电脑上也看不到,先检查雷达供电电压和电流。

5.2 串口助手有数据,但全是 0xFF 或 0x00

现象:数据能收到,但字节值不是 0xFF 就是 0x00,找不到 0xFA 帧头。

原因:大概率是波特率不匹配。雷达发送速率 230400,接收端配成 115200,收到的位流就是错位的字节值。也有可能是时钟偏移导致波特率误差超过 2%,在高波特率下更容易出现。

解决:先把接收端波特率改成和雷达标签一致的数值。如果雷达标签模糊不清,可以逐个试 115200、230400、256000 三档,哪一档能看到稳定 0xFA 开头的数据,就用那一档。确认后再去检查代码里的BaudRate配置。

5.3 数据帧能同步,但角度跳变到 300 多度时突然归零

现象:串口助手手动解析数据,能读到帧头,但角度序列在某一点从 359.9° 直接掉到 0.1°,看起来像角度突变。

原因:这不是故障,是雷达跨零度包络时的正常现象。雷达扫描一圈,角度从 0° 增长到 359.9°,然后下一圈从 0.1° 重新开始。解析时如果不做回绕处理,上位机画出来的点云图会在 0° 附近拉出一条长斜线。

解决:在换算函数里加判断,如果当前角度小于上一角度且差值大于 180°,认为发生了回绕,用前面代码里的if (step < 0.0f) step += 360.0f;处理即可。这个坑几乎每个写雷达解析的人都会踩一次。

5.4 数据流时断时续,暂停几百毫秒后又能收到

现象:数据不是连续的,每隔一段时间断一次,断的时长看起来像是缓冲区被塞满后的恢复过程。

原因:这是典型的 DMA 缓冲区溢出或处理不及时。主循环解析帧的速度跟不上 DMA 接收速度,DMA 缓冲区存满后,后续数据被丢弃,等空闲中断处理后重新启动 DMA,又继续收。如果缓冲区只给 32 字节,而雷达一帧就有 40 多个字节,永远不可能收完整。

解决:把RX_DMA_BUF_SIZE调到 512 或更大,同时确认主循环没有长时间阻塞操作。可以在主循环里加一个计数,对比“DMA 收到的字节数”和“解析处理的字节数”,两边的差持续增长,说明处理速度跟不上。

5.5 帧头能同步,但校验一直失败

现象:能找到 0xFA 和 0x01,但crc_calc和接收到的校验字节对不上,每次都是差一个固定偏移量。

原因:协议里校验的参与范围不一样。有的雷达校验时帧头不参与,有的帧头参与;有的校验字节是“前面所有字节累加和”,有的是“累加和取反加一”。如果解析代码把帧头算进去了,而雷达协议没算,结果就差一个固定数。

解决:对比雷达官方协议里校验码的说明,把crc_calc的参与范围改成一致。判断方法很朴素:如果失败的差值恒定(比如总是差 0xFA),基本就是帧头参与与否的问题。

5.6 DMA 重启动时序不对,第一次能收,第二次就丢

现象:程序上电第一次接收正常,解析出几帧后彻底罢工,串口助手再也看不到数据。

原因:空闲中断里先HAL_UART_DMAStop再HAL_UART_Receive_DMA,这个停止和重开之间有一个时序窗口。如果在这中间又来了一帧数据开头,DMA 还没来得及武装,数据就丢了,并且之后再不会有空闲中断发生——因为状态机卡在等待下一次启动。

解决:在重启 DMA 后,加一个软件标志提醒主循环“缓冲区已经被重置,旧数据作废”。更稳妥的写法是先停 DMA,清标志,再立刻启动,中间不要插入耗时操作。如果用的是新版本 HAL 库,可以用HAL_UARTEx_RxEventCallback,它把空闲检测封装得更完整,但代码逻辑仍然是一样的。

6. 进阶验证篇:先用 Python 看一轮点云,再谈避障算法

代码在 MCU 端跑通了,下一步是验证点云数据有没有问题。我一般不会直接开始写避障算法,而是先用电脑把一帧距离数据还原成点,画出来看看。

6.1 用 Python 脚本验证点云输出

在 MCU 端把换算后的角度和距离打印成 ASCII 串(比如“123,4567”表示 123.00°、4567mm),PC 端用 pyserial 读取并画图,这是最省事的验证方式。

# lidar_view.py:读取串口数据并绘制点云 import serial import matplotlib.pyplot as plt ser = serial.Serial("COM3", 230400, timeout=1) angles, dists = [], [] for line in ser: line = line.decode(errors="ignore").strip() if "," not in line: continue try: angle_01, dist = line.split(",") angles.append(int(angle_01) / 100.0) # 角度还原为度 dists.append(int(dist)) except ValueError: continue if len(angles) >= 500: # 攒够一圈 plt.clf() x = [d * np.cos(np.radians(a)) for a, d in zip(angles, dists)] y = [d * np.sin(np.radians(a)) for a, d in zip(angles, dists)] plt.scatter(x, y, s=2) plt.axis("equal") plt.pause(0.05) angles.clear() dists.clear()

注意int(angle_01) / 100.0这一步:MCU 端把角度乘以 100 再发出来,是为了避免串口传输浮点数。验证点云时如果发现图形上角度呈“漩涡状”发散,先看角度是否跨零点回绕;如果距离值整体偏大或偏小,再回头查距离缩放因子。

6.2 把解析层抽象成接口

验证没问题后,我把雷达解析部分抽成一组函数指针,这样换雷达型号时不需要改动上层避障逻辑:

typedef struct { void (*init)(void); // 串口和DMA初始化 int8_t (*parse)(uint8_t byte); // 状态机解析 uint8_t (*get_point)(lidar_point_t *p, uint8_t max_cnt); // 取点云 } lidar_drv_t; const lidar_drv_t lidar_rplidar = { rp_init, rp_parse, rp_get_point }; const lidar_drv_t lidar_ydlidar = { yd_init, yd_parse, yd_get_point };

上层程序只持有lidar_drv_t指针,调用同一套接口,雷达差异全被隔离在驱动层。这是这包代码值得长期保留的原因:它不只是一个能跑的工程,还是一个可扩展的驱动骨架。

之前有次调试,我在现场花了两小时排查一个丢字节问题,最后发现是 DMA 缓冲区只有 32 字节,而雷达一帧数据超过 200 字节,缓冲区越小问题越隐蔽。从那以后,我接到任何新雷达的第一件事,就是列一个参数清单:波特率、帧头、校验范围、角度缩放、距离缩放,确认完毕才让芯片去解码。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询