最近手头一个房间智能灯的项目,客户提了个需求:人走进来灯亮,人离开灯灭,人坐着不动也不能灭。这听着简单,做起来一堆坑。我最后敲定的方案是STM32通过串口接一块24GHz毫米波雷达,直接读取它回传的人体存在检测结果。实测了一个多月,移动检测、微动检测、静止人体识别都能稳,误报比红外低很多。这篇把整体思路、串口协议解析、STM32代码实现、调试过程踩的坑全部摊开写,给同样想用STM32做人体存在检测的人一个能直接抄作业的参考。
1. 整体方案:为什么串口毫米波雷达加STM32能省一大半事
1.1 先想明白:人体存在检测到底在检测什么
做智能家居、办公室节能或者卫生间空位检测,本质上都在回答同一个问题:这个区域里现在有没有人。我最早用PIR红外热释电传感器做过一版,问题非常典型:人坐在工位上不动,十分钟不到PIR就认为“没人”了,灯啪一下灭掉,体验很差。后来试过摄像头,识别算法本身跑在单片机上不现实,云端的方案又涉及隐私,对很多家庭场景和办公场景来说压根过不了关。
毫米波雷达解决的是“静止人体”检测问题。它通过发射连续的调频毫米波,接收人体反射的回波,再利用回波频率差计算目标的距离和速度。人即使坐着不动,呼吸时胸腔的起伏、手指轻微的抖动、眼球转动,都会形成微小的多普勒信号,雷达能识别出这种“活着”的迹象。这就是它和PIR的核心区别:PIR只在目标移动时有输出,毫米波雷达能检测到静止目标,所以它在智能台灯、空调联动、房间有人无人判断这类场景里表现好得多。
我用的方案里,雷达模块负责把“有没有人、人在几米处、目标能量多强”这些信息通过串口直接输出,STM32只需要做串口接收和协议解析。这种设计把感知和处理分得很清楚,主控不用碰射频、不用做FFT,省下来的算力去处理继电器、灯光逻辑、联网上报,整个项目简单了一大截。
1.2 雷达模块、主控和接线怎么选
雷达模块我手头这块是海凌科的LD2410,24GHz频段,专门做人体存在检测,官方提供串口输出目标状态、距离和能量值。这类雷达的优势很直观:价格不高、开发资料多、调试工具齐全,而且它直出结果,不是给你一堆I/Q原始数据让你自己去算。如果只需要判断“有没有人”,完全没必要选高配的带原始ADC输出的雷达,那种模块需要做距离FFT、CFAR检测,STM32F103这种级别的主控跑起来很吃力。
当然市面上还有其他选择,矽典微、微雪、英飞凌、TI都有毫米波方案,选型时重点看三点。第一是频段:5.8GHz、10GHz、24GHz、60GHz、77GHz各有各的适用场景,人体存在检测这一圈里24GHz和60GHz最常见,24GHz性价比高,60GHz探测精度更高但价格和功耗也上去了。第二是接口:我优先选串口直出结果的模块,后面接STM32最省事,有些模块走SPI或者I2C输出原始数据,开发量完全不是一个量级。第三是供电和功耗:很多雷达模块标称5V供电,电流几十毫安到一百多毫安,要确认稳压电路能扛住。
主控我用的是STM32F103C8T6,这种“蓝板子”做原型验证非常合适,串口资源够用,价格便宜,cube生成代码也快。如果项目要做低功耗,可以考虑STM32L4系列;如果要多路雷达同时接,选多点串口的型号。接线看起来简单,其实有个特别容易犯的错:串口交叉连接,STM32的TX接雷达的RX,STM32的RX接雷达的TX,然后必须共地,否则电平参考点不一致,数据很容易乱。雷达的TX输出电平要注意,有的模块是3.3V有的是5V,如果是5V雷达往3.3V的STM32里怼,稳妥起见加个电平转换或者分压电阻,长期运行别赌它兼容。
1.3 系统整体链路
整个系统链路从感应到执行是这样的:毫米波雷达探测到人体反射信号,在模块内部完成信号处理后,把目标信息封装成串口数据帧,通过UART发送给STM32。STM32的串口接收采用DMA加空闲中断的方式,把一帧不定长的数据完整接进缓冲区,然后在代码里通过状态机搜索帧头、解析数据区、校验和校验,最终得到“有人/无人”“距离”“能量”这几个关键字段。
STM32拿到目标信息后,再做一次存在判定处理。不是雷达上报一帧“有人”我就立刻开灯,而是加了一个确认延时和迟滞逻辑,避免误触发和频繁抖动。确认有人之后,驱动继电器或者通过IO口去控制灯、空调、风扇,同时可以留一个串口打印口,把状态和数据发到调试助手,方便现场调参。整条链路的优点是每个环节都单一职责,雷达只管感知,STM32只管解析和控制,出了问题排查范围非常明确。
2. 雷达串口数据帧怎么拆:协议与解析思路
2.1 一台毫米波雷达通过串口在吐什么
毫米波雷达的串口输出从外设上看就是一个标准的UART数据流,但数据内容有讲究。我用的LD2410为例,它默认波特率是115200,8位数据位、1位停止位、无校验。雷达每隔一段时间就会主动上报一帧目标信息,不要求单片机发查询指令,这在工程上叫主动上报模式,用起来很舒服:单片机只管接,不用操心“问一次答一次”的时序问题。
LD2410的帧格式是典型的“帧头+长度+数据+校验+帧尾”结构,帧头是4个字节F4 F3 F2 F1,帧尾是F8 F7 F6 F5,中间是2字节的长度字段,小端存储,表示后面数据区加校验的字节数。再往后就是实际的数据区,里面有命令字、目标状态、移动目标距离、移动目标能量、静止目标距离、静止目标能量等字段。不同固件版本的数据区内容会有差异,我手头这个版本的格式里,目标状态字节的低位用来表示有人还是无人,后面跟着的距离单位是厘米,能量值范围在0到100左右,数值越大说明目标反射信号越强。
这里有个关键点要提醒:不同品牌甚至同一品牌不同批次的雷达,帧格式都可能不一样。动手写代码之前一定要先看协议文档,最好用串口助手抓几帧真实数据对着文档比对一遍。我之前帮人调过一块雷达,网上找的解析代码能过编译,数据却全是错的,最后翻出手册一核对才发现数据区里多了一个版本号字段,偏移量整体后移。所以解析模块我强烈建议自己写,别直接抄别人现成的。
2.2 帧头搜索状态机:最稳的解析姿势
串口数据是字节流,接收方永远不知道当前字节处在哪一帧的什么位置。如果只是简单判断“缓冲区开头是不是F4”,万一上一次接收是从帧中间开始的,整帧就会错位,后面全部白干。我在项目里用的方法是状态机,也叫逐字节滑动搜索,每收到一个字节就推进一次状态,任何位置进来都能重新对齐到帧头,不会因为上一帧被截断而卡死。
状态机的思路拆开是这样的:
- 一开始处于“找帧头”状态,如果当前字节不是
0xF4,就继续等待;收到0xF4后进入下一状态,等待0xF3。 - 如果中间某个字节对不上,就回退到状态0重新找头,不会死等。
- 帧头四个字节全对上之后,进入长度解析状态,收满2字节长度,把它们拼成一个完整的长度值。
- 然后按照长度字段,把后面对应字节数的数据依次搬进payload缓冲,一边搬一边做累加和计算。
- 数据区收完之后,下一个字节是校验和。LD2410的校验算法比较简单,就是帧头之后、校验字节之前的所有字节累加,取低8位。比对通过才认为这一帧有效。
- 最后如果帧尾也能对上,整帧解析成功,置一个标志位让主循环去处理;如果帧尾不对,同样回到找帧头的状态。
写状态机的代码时,我习惯把每个状态定义成一个case,每个case里只做“当前字节该干什么”这一件事,逻辑清晰也好调试。这种解析方式对任何带帧头的串口协议都通用,以后换个雷达型号,只需要改帧头数组、长度计算方式和校验算法,状态机骨架不用动。我在实际项目里用这段代码跑了两个月,没出过一次粘帧错位的问题。
2.3 串口参数与注意事项
串口参数这一块看起来不起眼,但能不能收到数据全看它。初始化的时候波特率、数据位、停止位、校验位这四个参数必须和雷达模块手册完全一致。LD2410默认115200,但有些雷达模块或者固件版本支持9600、38400等低速档位,如果调过参数,单片机和雷达两边都要对应上,否则收到的全是乱码。
还有一个容易忽略的点:雷达模块上电之后并不是马上开始上报的,有的模块要等几百毫秒初始化完成,有的模块甚至要先发送一组配置命令才会切换到连续上报模式。调试的时候如果刚上电就打开串口助手、发现没数据,别急着怀疑硬件,先等两三秒再看。我遇到过“换了三根杜邦线都没数据、最后发现是板子供电电压不够”的情况,雷达模块一旦供电不足就会出现偶发复位或者不上报,这个问题在后面排查章节我会详细说。
另外,我这里用了一块USB转TTL工具加上XCOM串口助手做第一轮验证,先把电脑和雷达直接连起来,抓包确认雷达在吐什么数据、波特率是多少,再让STM32上场。这一步虽然多花十分钟,但能省掉大量“到底是雷达问题还是单片机问题”的排查时间。
3. 代码实现:DMA接收、数据解析与存在判定
3.1 串口DMA加空闲中断:把主频省下来干别的
STM32接收不定长串口数据,有三种常见做法:主循环轮询、普通中断收固定长度、DMA加空闲中断。第一种浪费CPU,第二种只适合定长帧,不接收这种不定长的雷达数据,所以我用的是第三种:DMA负责把串口收到的数据自动搬运到内存缓冲区,等一帧数据接收完成,硬件产生空闲中断,我在中断里处理这一帧。
DMA的特点是搬运数据不占CPU,它把串口外设的数据寄存器和内存缓冲区之间建立一条“直通路”,收到一个字节就搬一个字节,不需要CPU每收一个字节都进一次中断。空闲中断是串口外设自带的一个中断信号,意思是“接收线上超过一个字节时间没有新数据进来,说明一帧发完了”,这时候DMA计数器会告诉我已经收到了多少字节。我在这条思路下写的代码,即使串口来一帧30字节的数据,CPU也只在帧结束后进去处理一次,主循环该跑继电器逻辑、该跑按键扫描都不受影响。
初始化部分用STM32CubeMX生成很不复杂。串口设为115200、8N1,打开串口全局中断,配置DMA接收通道,然后调用HAL_UART_Receive_DMA把接收缓冲区和长度交给DMA。这里有一个HAL库版本差异要提醒一下:老版本里空闲中断的清理方式是用__HAL_UART_CLEAR_IDLEFLAG,新版本CubeMX生成的代码里,空闲中断建议直接在HAL_UARTEx_RxEventCallback回调里处理,因人而异,看你用的HAL库版本,两种写法都能通,注意别搞混就好。
参考代码片段:
#define RX_BUF_SIZE 256 uint8_t rx_buf[RX_BUF_SIZE]; void MX_USART1_UART_Init(void) { huart1.Instance = USART1; huart1.Init.BaudRate = 115200; huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_TX_RX; huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; huart1.Init.OverSampling = UART_OVERSAMPLING_16; HAL_UART_Init(&huart1); // 开启空闲中断 __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); // 启动DMA接收 HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE); }中断处理函数里,核心动作是判断空闲标志、计算本次接收长度、清零恢复DMA:
void USART1_IRQHandler(void) { uint32_t isr = READ_REG(huart1.Instance->ISR); if ((isr & USART_ISR_IDLE) != 0U && READ_BIT(huart1.Instance->CR1, USART_CR1_IDLEIE) != 0U) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); HAL_UART_DMAStop(&huart1); uint16_t len = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); if (len > 0) { parse_radar_bytes(rx_buf, len); } HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE); } HAL_UART_IRQHandler(&huart1); }注意一点:接收缓冲区的长度要设置得比最长的一帧数据大,否则DMA可能被装到缓冲区满才停下来。缓冲满之后接收指针会回到头部形成循环覆盖,在未处理完之前就把数据覆盖掉,那就真的找不回来了。我把256字节作为接收缓冲区,一帧雷达数据最多30多个字节,远远够用。
3.2 数据解析模块怎么组织
解析模块我按状态机的方式写了一个parse_radar_bytes函数,外层负责遍历接收缓冲区里的每个字节,内层是状态机的switch/case。这样写的好处是,即使一次空闲中断只收到半帧数据,剩下的半帧会在下一次中断里继续接着解析,状态机天然支持分片处理,不容易丢帧。
以LD2410为例,我定义状态的顺序就是“找第一个帧头、找第二个帧头、找第三个、找第四个、收长度低、收长度高、收数据区、收校验、收帧尾”。下面这段代码是去掉业务逻辑后的核心骨架:
static uint8_t s_step = 0; static uint16_t s_data_len = 0; static uint16_t s_idx = 0; static uint8_t s_payload[64]; static uint8_t s_checksum = 0; void parse_radar_bytes(uint8_t *data, uint16_t len) { for (uint16_t i = 0; i < len; i++) { uint8_t byte = data[i]; switch (s_step) { case 0: if (byte == 0xF4) s_step = 1; break; case 1: s_step = (byte == 0xF3) ? 2 : 0; break; case 2: s_step = (byte == 0xF2) ? 3 : 0; break; case 3: if (byte == 0xF1) { s_step = 4; s_idx = 0; s_checksum = 0; } else s_step = 0; break; case 4: s_data_len = byte; s_checksum += byte; s_step = 5; break; case 5: s_data_len |= (uint16_t)byte << 8; s_checksum += byte; s_step = 6; break; case 6: if (s_idx < s_data_len) { s_payload[s_idx++] = byte; s_checksum += byte; if (s_idx >= s_data_len) s_step = 7; } else { s_step = 0; } break; case 7: if (byte == (s_checksum & 0xFF)) s_step = 8; else s_step = 0; break; case 8: if (byte == 0xF8) { // 帧尾第一个字节正确,继续等后面三个 // 实际写成子状态更严谨,这里简化为直接判断 process_payload(s_payload, s_data_len - 1); } s_step = 0; break; default: s_step = 0; break; } } }从payload里提取业务字段时,我先看命令字,命令字是0x02的帧属于“目标信息周期上报”,然后按照手册给的偏移量把目标状态、距离、能量取出来,存到一个全局结构体里,主循环读这个结构体就行。为了调试方便,我在解析函数里加了两个计数器:一个统计收到帧头次数,一个统计校验失败次数。这两个数字在调现场时非常有用,通过串口日志能看到雷达数据质量好不好。
3.3 人体存在判定不能只看一帧:阈值与迟滞
雷达上报的每一帧目标状态当然可以直接拿来判断,但直接用的效果很一般。实测中会出现两种情况:一是刚上电雷达还没有发出第一帧,或者 человек突然进入检测区域时第一帧数据还没稳定;二是雷达检测到一个人然后又丢失一两次目标,如果立刻当成“无人”,灯就会闪一下或者关掉,特别烦。所以我在解析出目标状态之后,又加了一层存在判定逻辑,本质上是一个带确认延时的状态机。
我的做法是定义两个时间参数:确认有人需要目标状态连续为有人的时间,比如800毫秒;确认无人需要持续检测不到人的时间,比如30秒。前者防止瞬时的误触发,后者防止人只是短暂走出雷达探测盲区两秒就误判。这种“开着延时、关着延时”的思路和机械开关的去抖动很像,无非是输入信号从一个电平变成一串布尔量。
参考代码:
typedef enum { PRESENCE_NOBODY = 0, PRESENCE_OCCUPIED } presence_state_t; static presence_state_t s_presence = PRESENCE_NOBODY; static uint32_t s_occupy_start_ms = 0; static uint32_t s_last_seen_ms = 0; #define PRESENCE_ON_MS 800 #define PRESENCE_OFF_MS 30000 void presence_update(uint8_t has_human, uint32_t now_ms) { if (has_human) { s_last_seen_ms = now_ms; if (s_presence == PRESENCE_NOBODY) { if (s_occupy_start_ms == 0) s_occupy_start_ms = now_ms; else if (now_ms - s_occupy_start_ms >= PRESENCE_ON_MS) s_presence = PRESENCE_OCCUPIED; } } else { s_occupy_start_ms = 0; if (s_presence == PRESENCE_OCCUPIED && (now_ms - s_last_seen_ms) >= PRESENCE_OFF_MS) { s_presence = PRESENCE_NOBODY; } } }这里面的逻辑是:一旦雷达说有人的次数持续累积到800毫秒,才真正确认有人,之后即使某几帧丢失,只要在30秒内重新出现有人信号,状态就不会切回无人。实际项目里,我把30秒这个值暴露成一个配置项,客户嫌关灯慢就调小,嫌太敏感就调大,做产品就是要留这种可调的口子。
除了迟滞,阈值也值得调。LD2410上报的移动能量和静止能量,反映目标反射信号的强度,如果环境中窗帘、风扇、小动物也会被雷达捕捉,能量值通常比较低。我在主循环里会比对能量值,低于某个阈值的目标直接当不存在处理。这个阈值没有统一标准,和安装高度、房间大小、雷达灵敏度都有关,我一般是现场用串口抓正常人的能量值再除以二,作为默认阈值。
4. 实测调试与常见问题排查
4.1 先别看MCU,用串口助手把雷达摸清
拿到雷达之后我第一步做的不是写STM32代码,而是拿一块USB转TTL模块加上串口调试助手,先把雷达的通信底细摸清楚。这一步非常重要,它把“雷达问题”和“单片机问题”彻底隔离开:如果串口助手上能看到规整的数据帧,说明雷达工作正常、协议正确,后面的问题只可能在单片机这边;如果串口助手上都看不到数据,那就别急着查代码,先回去查供电、接线和波特率。
连接的时候USB转TTL模块的RX接雷达TX,USB转TTL的TX接雷达RX,GND必须连在一起,然后插电脑、打开设备管理器确认端口号。我这边用的是CH340方案的USB转TTL,装好驱动后识别成COM口,选115200波特率,打开串口,很快就能看到十六进制显示的数据流。这一刻最好拍照或者把几帧数据保存下来,后面写解析代码的时候对着看,比对着手册空想直观得多。
如果串口助手打开后没有任何数据,我会按顺序排查:雷达指示灯是否在闪、供电电压是否在正常范围、TX和RX是不是接反了、GND有没有共地、串口助手波特率对不对。十个里面九个是这几类低级问题。如果收到的全是乱码,优先怀疑波特率不匹配,或者雷达模块电平是5V而你用的是3.3V的串口工具,信号畸变了。
4.2 常见问题速查表
现场调试遇到的问题五花八门,我整理了一个速查表,基本覆盖这套方案里遇到过的情况。
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 串口一条数据都没有 | TX/RX接反、没共地、雷达没上电 | 交叉连接,GND共地,检查供电 |
| 数据全是乱码 | 波特率不匹配、电平不兼容、干扰太大 | 核对串口参数,缩短杜邦线,加电平匹配 |
| 串口助手打不开COM口 | CH340/CP2102驱动没装好 | 重装驱动,换USB线或USB口 |
| 数据是规整的但STM32解析不到 | 空闲中断处理不对、DMA缓冲区太小 | 检查串口空闲标志清理,加大接收缓冲 |
| 帧头找到过但校验老失败 | 数据区长度解析错误、固件版本不同 | 重新对照协议文档,逐字节打印比对 |
| 人站着有输出,坐下就变无人 | 静止目标检测未开启、阈值过高 | 查看雷达配置,降低静止能量阈值 |
| 人离开很久仍然是有人状态 | 无人延时设置太长 | 减小无人确认延时,或在雷达端调延时 |
| ST-LINK连不上芯片 | stlink驱动问题、BOOT0跳线没设对 | 重装驱动,确认BOOT0为0,复位重试 |
| 串口烧写失败 | 一键下载电路RTS/DTR控制线不对 | 确认DTR/RTS接到BOOT0和复位脚 |
这里面我想重点展开校验失败这一类问题。早期我DEBUG时发现,状态机里进入过校验阶段,但校验一直是失败,最后查出来是长度字段计算方式理解错了:雷达手册里长度值是“数据区长度加校验字节”,我解析时把数据区长度当成了纯数据长度,导致多搬了一个字节进payload,checksum自然对不上。这类问题只要把一个字节一个字节打印出来,对照手册手工算一遍校验和就能定位,别一上来就怀疑天线和信号。
4.3 安装部署与多雷共存的避坑经验
代码调通之后,真正影响体验的是安装调试。毫米波雷达虽然不需要开孔,因为它能穿透石膏板、木板、亚克力等非金属材料,但安装位置、高度、朝向直接决定检测效果。我踩过的坑有几个,写下来供参考。
第一是安装高度。雷达推荐安装在2米到2.5米的高度,天线面朝下或者略向前倾斜,这样能覆盖一个扇形区域。装太低会把桌椅、绿植当成目标,装太高又会把信号摊得太薄,人走过时能量值不够。第二是强反射体。金属门、冰箱、风扇叶片、水族箱、大玻璃窗,这些物体反射毫米波的能力比人体强得多,如果它们处于雷达波束主瓣内,会持续产生高能量回波,导致雷达认为“一直有人”。现场调阈值时,我习惯先把房间清空,看雷达上报的无人能量基准值是多少,然后在正常有人环境下对比,确保阈值在两者之间留出余量。
第三是雷达之间的相互干扰。如果一个房间里装了两台以上雷达,它们的发射信号可能互相串扰,造成目标状态乱跳。部分雷达模块内部有错频或者时分机制,但我实测过,距离太近(比如并排在同一个接线盒里)时还是会出现偶发误报。解决办法是尽量拉开雷达间距,避免天线正对正,条件允许的话用遮蔽物隔开。第四是注意电源纹波。雷达模块对供电质量比较敏感,和继电器、电机共用一路电源时,继电器吸合的瞬间电压跌落可能导致雷达复位,这也是“灯一亮雷达就掉线”这种奇怪现象最常见的原因,处理办法是雷达供电单独走一路稳压,或者在电源端加一个大一点的电解电容。
最后再提醒一下固件配置问题。很多人体存在雷达出厂默认参数是面向通用场景的,探测距离比较远、灵敏度比较高,放在小房间里反而会误报。LD2410这种模块可以通过串口下发配置命令来调整探测距离、灵敏度、延时参数,我每次装完都会按照房间大小重新配置一遍,把探测距离压到实际需要的范围,误报率能明显下降。配置好之后记得保存参数,不然断电又回到出厂默认值。
5. 结尾多说几句
我自己做这个项目最大的体会是,传感器选型确实决定项目的上限。PIR便宜但只能检测运动,摄像头功能强但复杂度和隐私问题一大堆,毫米波雷达在“存在检测”这个细分需求上属于那种“贵一点但省一堆事”的方案。STM32配合串口雷达的做法,本质上就是把感知和解析分开,雷达把结果喂给单片机,单片机专注业务逻辑,这种架构无论是加联网还是加屏幕扩展都很顺手。
最后再分享一个调试小技巧:解析代码里我留了一个调试开关,开起来之后把每一帧原始数据和解析结果都通过另一个串口往电脑打,现场调阈值时用这个日志和XCOM配合,把雷达能量、判定状态、当前模式全部可视化出来。很多时候你以为的“误报”其实是数据没解析对的“假误报”,日志一打出来真相就很明显了。这个项目的代码结构我目前用着挺顺手,后面如果再加多目标跟踪和区域划分,我准备在这个框架上继续扩展,到时候有新经验再回来更新。