stm32寻迹小车制作——串口

0 阅读11分钟

做循迹小车排查串口时,我发现"能发不能收"特别坑。查了才知道是 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 吗?

结论:不会"补发",但新的字节照常接收。

关键要分清两件事:

  1. 被丢掉的字节——回不来了。溢出的那一刻,那个放不下的字节已经被硬件丢弃(物理上没了),清 ORE 只是"翻篇",不会让丢的字节重新进来。
  2. 之后的字节——正常接收,不受影响。清掉 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)

回显: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 保证了"能读数据就读数据,读不到才清溢出"。