stm32寻迹小车制作——串口
做循迹小车排查串口时我发现能发不能收特别坑。查了才知道是 HAL 的 RXNE 中断被溢出卡死。这篇文章把串口中断接收的底层原理和寄存器实现讲透。串口调参所应用的知识——C语言stm32串口通信stm32hal库stm32寄存器何为“逐字节攒命令”先记住变量static char cmd_line[32]; /* ① 存字符的篮子最多装 31 个字符 */ static uint8_t cmd_len 0; /* ② 篮子里已经装了几个字符 */ static volatile uint8_t cmd_ready 0; /* ③ 旗子攒完一条命令了吗1是 */记住并开始初步理解这三个变量。从寄存器读字节读取一个字节uint8_t b (uint8_t)(USART1-DR 0xFF); /* 从数据寄存器读出这个字节 */读 DR 这个动作自动清掉 RXNE 标志硬件设计所以下次字节来还能触发中断是不是“回车”or“换行”if (b \n || b \r) /* 电脑发命令末尾都会带回车 */ { if (cmd_len 0) /* 篮子里确实有东西防止空行触发 */ { cmd_line[cmd_len] \0; /* 在末尾补个字符串结束符 */ cmd_len 0; /* 篮子清空迎接下一条 */ cmd_ready 1; /* 举旗告诉主循环有一条命令可以处理了 */ } }为啥用回车当命令结束电脑串口助手发P 8时实际发的是P空格8回车——回车就是天然的这句话说完了标记。就像微信聊天按发送键。普通字符else if (cmd_len (uint8_t)(sizeof(cmd_line) - 1)) /* 篮子没满才装防溢出 */ { cmd_line[cmd_len] (char)b; /* 装进去然后计数器1 */ }如果溢出else if (sr USART_SR_ORE) /* 防止 DR 没及时读走导致溢出 */ { (void)USART1-DR; /* 读一下 DR 清掉标志继续收不卡死 */ }重点srUSART_SR_ORE一SR 是谁SR Status Register状态寄存器它是串口外设USART1自带的一个寄存器不是某个变量的名字。地址上它是USART1基地址偏移0x01实际按 4 字节对齐写代码时用USART1-SR访问。它的 16 个位 串口硬件当前的实时状态灯面板bit9 CTS (清除发送硬件流控) bit8 LBDF (LIN 断开检测) bit7 TXE (发送寄存器空——可以往 DR 写新字节了) bit6 TC (发送完成) bit5 RXNE (收到数据——DR 里有字节可读) ← 我们用的 bit4 IDLE (总线空闲) bit3 ORE (溢出错误) ← 我们用的 bit2 NE (噪声错误) bit1 FE (帧错误) bit0 PE (校验错误)它是硬件实时维护的字节到了 RXNE 自动置 1你读走 DR 自动清 0——你什么都不用写硬件一直在更新它。你的程序只是去看一眼。二sr USART_SR_ORE有啥用ORE OverRun Error溢出错误是 SR 状态寄存器里的第 3 位bit3 0x0008。USART_SR_ORE这个名字就是USART_SR 寄存器里代表 ORE 的那一位的掩码。和USART_SR_RXNE第 5 位一样都是预定义的位掩码常量USART_SR_RXNE 0x0020 (bit5, 有数据灯) USART_SR_ORE 0x0008 (bit3, 溢出灯)假设sr读出 0x0028bit3 和 bit5 都亮了即既溢出又有数据sr: 0x0000 0000 0010 1000 USART_SR_ORE: 0x0000 0000 0000 1000 (只有 bit3 是1) 结果: 0x0000 0000 0000 1000 非0 → 条件成立 ✅ 的作用sr里有 16 个状态位RXNE、ORE、TXE、PE、FE、NE……我们只关心 ORE 那一位 USART_SR_ORE就把其他位全清零只剩 bit3——bit3 是 1 就返回非 0真是 0 就返回 0假三为啥在读完了数据之后才想着清溢出优先处理有数据RXNE→ 有数据就先读走只有没有数据但溢出灯亮时才走清溢出分支为什么这么设计因为溢出和数据经常同时发生一个字节没来得及读下一个又来了 → 既有 RXNE 又有 ORE。此时我们要先救数据读 DR 拿到那个字节至于溢出读 DR 的动作本身就把 ORE 也清了硬件规则读 SR 再读 DR 会清 ORE——所以有数据时根本不用专门清溢出else if保证了能读数据就读数据读不到才清溢出。这几个知识点很密集需要着重理解列出两个我在学这里时出现的问题问题 1清掉 ORE 后溢出的数据会继续进 DR 吗结论不会补发但新的字节照常接收。关键要分清两件事被丢掉的字节——回不来了。溢出的那一刻那个放不下的字节已经被硬件丢弃物理上没了清 ORE 只是翻篇不会让丢的字节重新进来。之后的字节——正常接收不受影响。清掉 ORE 标志后后续到达的字节照常进 DR、照常置 RXNE、照常触发中断。为什么必须清 OREORE 标志如果一直挂着硬件会把新数据来了这件事卡住在某些配置下ORE 未清会导致 RXNE 中断不再触发——所以清 ORE 告诉硬件溢出这事我处理完了你继续正常收。清的是错误标记不是数据缓存——DR 只有 1 个字节的容量从来没有缓存队列这种东西。一句话溢出丢的就是丢了清标志是为了让接收复活不是为了找回丢的数据。问题2什么时候读 SR两个典型时机① 中断一进来立刻读一次我们代码的写法uint32_t sr USART1-SR; /* 一进来就拍照存到局部变量 */ if (sr USART_SR_RXNE) {...} else if (sr USART_SR_ORE) {...}为什么立刻读状态是实时变化的如果先处理别的事再去读 SR可能 RXNE 已经被清了比如你自己读走了数据或者又来了新事件。进中断第一件事就拍照保证后面判断的是同一时刻的状态。② 清标志的时候隐含的读STM32 的很多标志是读相关清除Read-Clear——比如读 SR 再读 DR 会清 RXNE 和 ORE。所以读 DR 这个动作本身就顺带读了 SR硬件内部逻辑只是代码上不用显式写。一个容易混的点sr是变量还是寄存器uint32_t sr USART1-SR; /* 左边的 sr 是局部变量快照副本 */USART1-SR寄存器本身硬件实时状态sr那一刻的拷贝存进栈里的局部变量判断用sr快照而不是USART1-SR实时就是防止判断到一半状态变了导致逻辑错乱。完整走一遍场景电脑连发 P 8\r\n5个字节波特率 115200每字节间隔 ~87µs 1. P 到 → DRP, RXNE1 → 中断 → sr快照(RXNE1) → 读 DR 拿走 P → RXNE 自动清 2. 到 → 同上拿走 3. 8 到 → 同上拿走 8 4. \r 到 → 同上结账 cmd_ready1 5. \n 到 → cmd_len0篮子空了→ 不举旗跳过 —— 如果第 3 步没来得及比如中断被更高优先级打断超过 87µs 3. 第二个字节 8 到时 DR 里还躺着 没读走 → 8 被丢弃ORE1 4. 中断终于执行 → sr快照(RXNE1, ORE1) → if(RXNE) 先读走 数据优先 → 读 DR 动作顺带清 ORE → 后续 \r \n 照常接收系统已复活结论溢出丢的是最挤的那一两个字节系统靠清 ORE 继续收自愈——命令可能偶尔解析错一条但永远不会死锁。这就是寄存器版 vs HAL 版死锁的本质区别。如何在收到数据后根据数据改变对应参数先看整体骨架static void process_command(void) { if (!cmd_ready) { return; } /* ① 没命令直接走不阻塞主循环 */ cmd_ready 0; /* ② 取走命令放倒旗子 */ char c cmd_line[0] 0xDF; /* ③ 解析命令首字母 */ float v atof(cmd_line 1); /* ④ 取后面的数值 */ char buf[48]; int n 0; /* ⑤ 准备回显用的缓冲区 */ if (c P) { ... } /* ⑥ 按首字母分派到对应处理 */ else if (c S) { ... } ... HAL_UART_Transmit(huart1, buf, n, 100); /* ⑦ 回显结果 */ }重点句式cmd_line[0] 0xDFchar c (char)(cmd_line[0] 0xDF);这行的作用是让小写 p 也当 P 处理命令大小写不敏感。原理是位运算大写 P 0x50 0101 0000 小写 p 0x70 0111 0000 0xDF (1101 1111) → 去掉第5位 → 0x50 PASCII 大小写的区别就在第 5 位0x20大写该位0小写1。 0xDF把第 5 位清零 → 小写变大写。这是嵌入式里大小写不敏感的经典位运算技巧。重点函数atof(cmd_line 1)float v atof(cmd_line 1);cmd_line 1跳过首字母P指向后面的 8cmd_line是数组名1是指针运算移到第 2 个字符atofASCII to Float 把字符串 8 转成浮点数 8.0注意它自动跳过前导空格所以 P 8 和 P8 都能解析atof( 8)8.0atof(8)8.0回显HAL_UART_TransmitHAL_UART_Transmit(huart1, (uint8_t*)buf, (uint16_t)n, 100);把结果字符串OK KP8.0\r\n发回电脑让你看到改成功了。snprintf先把结果格式化到 buf再发送。一个防呆细节参数范围校验if (v 0.5f v 50.0f) { kp_x10 ...; } /* 只在这个范围才改 */限制参数范围防止乱发命令把参数改飞。比如 KP 只能在 0.5~50 之间超了就不改容错。文章结尾自问自答几个问题二、自测问答QA 形式Q1发p 12小写 p能改 KP 吗为什么能改。cmd_line[0] 0xDF会把小写字母转成大写所以大小写都能识别。原理ASCII 大小写只差第 5 位大写 P 0x50 0101 0000 小写 p 0x70 0111 0000 0xDF (1101 1111) → 第5位清零 → 0x50 P ✅嵌入式里大小写不敏感的标准位运算技巧。Q2atof(cmd_line 1)的1干嘛命令是S40没空格能解析吗1是跳过首字母。cmd_line是数组首地址cmd_line 1是指针运算指向第 2 个字符 40 或 40。能解析。atofASCII to Float会自动跳过前导空格atof( 40)40.0atof(40)40.0。所以S 40和S40都行。补充atof返回float浮点数。代码里base_speed是整数所以后面base_speed (int16_t)(v 0.5f)把它转成整数——0.5f是四舍五入技巧浮点加0.5再强转。Q3为什么process_command用if (!cmd_ready) return而不是一直等在接收上防止占用 CPU不阻碍主循环——这就是非阻塞思想。如果主循环死等接收那 20ms 的循迹控制、500ms 的测速打印就全卡住了 → 车会失控if (!cmd_ready) return有命令才处理没命令立刻走把 CPU 还给主循环的其他任务嵌入式核心原则绝不阻塞主循环。慢的、耗时的操作回显、发送都放主循环中断里只做最快的事攒字节。三、总结复制中断(进程) 攒命令进 cmd_line置 cmd_ready1 主循环(出) process_command() 清 cmd_ready0解析并执行 两者连接 cmd_ready 旗子 volatile 整体 一次完整的在线调参就先读走只有没有数据但溢出灯亮时才走清溢出分支为什么这么设计因为溢出和数据经常同时发生一个字节没来得及读下一个又来了 → 既有 RXNE 又有 ORE。此时我们要先救数据读 DR 拿到那个字节至于溢出读 DR 的动作本身就把 ORE 也清了硬件规则读 SR 再读 DR 会清 ORE——所以有数据时根本不用专门清溢出else if保证了能读数据就读数据读不到才清溢出。