做循迹小车排查串口时,我发现"能发不能收"特别坑。查了才知道是 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 清掉标志,继续收,不卡死 */
}
重点:sr&USART_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 读出 = 0x0028(bit3 和 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、照常触发中断。
为什么必须清 ORE:ORE 标志如果一直挂着,硬件会把"新数据来了"这件事卡住(在某些配置下,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\n"(5个字节),波特率 115200,每字节间隔 ~87µs
1. 'P' 到 → DR='P', RXNE=1 → 中断 → sr=快照(RXNE=1)
→ 读 DR 拿走 'P' → RXNE 自动清
2. ' ' 到 → 同上,拿走 ' '
3. '8' 到 → 同上,拿走 '8'
4. '\r' 到 → 同上,结账 cmd_ready=1
5. '\n' 到 → cmd_len=0(篮子空了)→ 不举旗,跳过
—— 如果第 3 步没来得及(比如中断被更高优先级打断超过 87µs):
3'. 第二个字节 '8' 到时 DR 里还躺着 ' ' 没读走
→ '8' 被丢弃,ORE=1
4'. 中断终于执行 → sr=快照(RXNE=1, ORE=1)
→ 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] & 0xDF
char c = (char)(cmd_line[0] & 0xDF);
这行的作用是让小写 'p' 也当 'P' 处理(命令大小写不敏感)。原理是位运算:
大写 P = 0x50 = 0101 0000
小写 p = 0x70 = 0111 0000
& 0xDF (=1101 1111) → 去掉第5位 → 0x50 = 'P'
ASCII 大小写的区别就在第 5 位(0x20):大写该位=0,小写=1。& 0xDF 把第 5 位清零 → 小写变大写。这是嵌入式里大小写不敏感的经典位运算技巧。
重点函数atof(cmd_line + 1)
float v = atof(cmd_line + 1);
cmd_line + 1= 跳过首字母('P'),指向后面的 " 8"(cmd_line是数组名,+1是指针运算,移到第 2 个字符)atof(ASCII to Float)= 把字符串 " 8" 转成浮点数 8.0- 注意它自动跳过前导空格,所以 "P 8" 和 "P8" 都能解析(
atof(" 8")=8.0,atof("8")=8.0)
- 注意它自动跳过前导空格,所以 "P 8" 和 "P8" 都能解析(
回显:HAL_UART_Transmit
HAL_UART_Transmit(&huart1, (uint8_t*)buf, (uint16_t)n, 100);
把结果字符串("OK KP=8.0\r\n")发回电脑,让你看到"改成功了"。snprintf 先把结果格式化到 buf,再发送。
一个"防呆"细节:参数范围校验
if (v >= 0.5f && v <= 50.0f) { kp_x10 = ...; } /* 只在这个范围才改 */
限制参数范围,防止乱发命令把参数改飞。比如 KP 只能在 0.5~50 之间,超了就不改(容错)。
文章结尾自问自答几个问题
二、自测问答(Q&A 形式)
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' ✅
嵌入式里大小写不敏感的标准位运算技巧。
Q2:atof(cmd_line + 1) 的 +1 干嘛?命令是 S40(没空格)能解析吗?
+1 是跳过首字母。cmd_line 是数组首地址,cmd_line + 1 是指针运算,指向第 2 个字符(" 40" 或 "40")。
能解析。 atof(ASCII to Float)会自动跳过前导空格:atof(" 40")=40.0,atof("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_ready=1
主循环(出) = process_command() 清 cmd_ready=0,解析并执行
两者连接 = cmd_ready 旗子 + volatile
整体 = 一次完整的在线调参
就先读走
- 只有没有数据但溢出灯亮时,才走清溢出分支
为什么这么设计?因为溢出和数据经常同时发生(一个字节没来得及读,下一个又来了 → 既有 RXNE 又有 ORE)。此时我们要先救数据(读 DR 拿到那个字节),至于溢出,读 DR 的动作本身就把 ORE 也清了(硬件规则:读 SR 再读 DR 会清 ORE)——所以有数据时根本不用专门清溢出,else if 保证了"能读数据就读数据,读不到才清溢出"。