Skip to content

LV015-串口数据接收设计 ​

一、 串口字节是怎么来的 ​

1. 一帧是 10 位,不是 8 位 ​

配置表里写的 8N1 只是"8 位数据、无校验、1 位停止",真正在线路上传输的一帧是 10 位:1 位起始位 + 8 位数据位 + 1 位停止位。

8N1 串口帧的位时序

几个能从图上直接读出来的数字:

项目数值怎么来的
每位时间8.68 µs1 / 115200
一帧时间86.8 µs10 位 × 8.68 µs
理论上限速率约 11.5 KB/s115200 / 10
采样位置每位中点起始位下降沿对齐后,半位处第一次采样

"中点采样"是理解串口容错的关键:接收端在每个位的正中间取电平,允许发送端与接收端的波特率有约半位的累积偏差。10 位帧、8N1 的情况下,波特率误差超过 2% ~ 3% 就会开始误码,所以 115200 的波特率必须由准确的时钟分频得到(本工程用 72MHz APB2 分频,误差在 1% 以内)。

2. 一个字节怎么进到 DR ​

字节不是"中断直接读引脚"读来的,硬件替我们完成了串并转换:

一个字节从引脚到缓冲区的路径

(1)RX 引脚检测到起始位下降沿,硬件把采样时钟对齐到这一位的中点;

(2)接收移位寄存器逐位采样 8 个数据位,拼成一个字节,低位先到(LSB first);

(3)拼好的字节被放进数据寄存器 USART1->DR,同时置位 RXNE(读数据寄存器非空);

(4)RXNE 触发中断(HAL_UART_Receive_IT() 使能的就是 RXNEIE),CPU 进中断把 DR 读走,RXNE 自动清零。

这条链路里最关键的一点是:DR 只有一级,没有 FIFO。上一个字节还没被读走、下一个字节已经收完,就会置位 ORE(溢出),新字节被丢弃。F103 的串口没有硬件缓冲队列,所以"每收一个字节就尽快读走"不是优化,而是能正常工作的前提。

3. 时间预算:主循环能慢多少 ​

把各个动作的耗时摆在一起,就能看出为什么要"中断收、主循环处理":

动作典型耗时与字节间隔(86.8 µs)的关系
一次接收中断(读 DR + 重新武装)约 2 ~ 5 µs占比 3% ~ 6%,可以接受
主循环一拍1 ms一拍内最多到 11 个字节
收满一行 200 字节约 17.4 ms主循环只要在这之前取走即可
擦除一页 Flash约 20 ms期间必然丢字节

最后一行是硬约束:擦除/编程期间 CPU 取指会被硬件暂停,串口照收不误,此时没人读 DR,ORE 必然置位。所以收帧过程中绝不能擦写 Flash——本工程把落盘放在"整帧收完、CRC 通过"之后,正是为了避开这个窗口。

二、 接收模型总览 ​

1. 需求与约束 ​

串口(USART1,PA9/PA10,115200 8N1)在本工程里同时承担三件事:打印日志、接收文本命令、接收 IAP 二进制固件帧。三者的速率与实时性要求完全不同,却共用同一个物理通道,接收侧必须同时满足下面几条:

  • 日志是本工程最主要的调试手段,接收逻辑不能反过来阻塞发送;
  • 文本命令是"人敲的",字节间隔大,识别可以慢,但执行动作(写 Flash、跳转、复位)不能放在中断里;
  • 固件帧是"机器灌的",连续高速字节流,中断必须尽快返回,否则 ORE(溢出)会丢字节;
  • 二进制载荷里可能凑出命令字样的字节序列,两种解析不能互相污染。

最终采用的模型是一句话:中断只收字节、只入缓冲;解析、执行、发送全部留给主循环。

串口接收的分层结构

2. 中断收字节、主循环取数据 ​

数据流向分成四段,每段的职责都很窄:

阶段运行上下文做的事不做的事
收字节USART1 中断取走 DR,重新武装接收不发数据、不算校验
入缓冲USART1 中断拼行、写环形状态、或转发给消费者不调用处理函数
取数据主循环usart1_get_line() 取整行不改中断状态机
解析执行主循环命令执行、帧事件处理不在中断里触发

这样切分之后,中断服务程序的耗时与字节内容无关,恒定为 O(1);主循环里哪怕正在擦 Flash(一次页擦除约 20ms),也只是短暂停止取数,不会让中断侧堆积。

3. 两种工作模式 ​

接收侧有两种模式,由编译开关 USART_USE_LINE_RX 选择(Bootloader 在 atk-f103/Core/Inc/main.h,两个 App 在同文件的私有定义区):

开关值模式中断里做什么主循环里做什么
0逐字节回显收到 1 字节立即 HAL_UART_Transmit() 回发无需参与
1行缓冲(本工程采用)只拼行、置完成标志usart1_get_line() 取整行

逐字节回显模式写起来最短,但它在中断里做阻塞发送:一个字节发完要等约 87µs(115200 下),连续输入时接收中断会被发送拖住,溢出几乎是必然的。行缓冲模式把收发彻底解耦,这也是寄存器级别的 ORE 问题在软件层的根治办法。

三、 中断侧实现 ​

1. 单字节中断接收 ​

每次只收 1 个字节,收完立刻重新武装,这是 HAL 中断接收最朴素的用法:

c
// atk-f103/Core/Src/usart.c
void usart1_rx_start(void)
{
  if (g_usart_rx_started == 0)                          /* 只在首次调用时启动接收 */
  {
    g_usart_rx_started = 1;
    HAL_UART_Receive_IT(&huart1, &g_usart_rx_byte, 1);  /* 启动中断接收, 每次收 1 字节 */
  }
}

函数是幂等的:usart1_get_line()、iap_rx_init() 内部都会调用它,重复调用不会重复武装。真正的字节处理在 HAL_UART_RxCpltCallback() 里,回调结尾再武装一次,形成"收一个、武装一个"的链条。

2. 行装配状态机 ​

行缓冲的状态全部压在一个 16 位变量里,省掉多个标志位的组合判断:

c
// atk-f103/Core/Src/usart.c
/* 行接收状态: bit15 = 已收到一整行(以 '\r\n' 结尾)
 *             bit14 = 已收到 '\r'
 *             bit13~0 = 已接收的有效字节数 */
static volatile uint16_t g_line_sta = 0;

中断里的状态迁移只有四条规则:

  • 未收到 \r 时,普通字节写入 g_line_buf[计数值] 并把计数加一;
  • 收到 \r 置 bit14,等待 \n;
  • \r 之后不是 \n,判定帧格式错误,整行丢弃重来;
  • 计数值达到 200 字节仍未收尾,判定超长,整行丢弃重来。

把上述规则整理成迁移表,中断里实际执行的就是这张表:

收到的字节当前状态动作下一状态
普通字节未见 \r存入 g_line_buf[计数],计数加一继续接收
\r未见 \r置 bit14等待 \n
\n已见 \r置 bit15,计数值保留整行就绪
其他字节已见 \r判定格式错误,清零重新开始
任意字节计数达 200判定超长,清零重新开始
任意字节整行就绪直接丢弃保持不变

bit15 置位表示"有一整行可取",同时充当了流控:上一行没被取走之前,中断里的新字节一律丢弃,不做排队。200 字节的上限来自 USART_LINE_BUF_SIZE,与命令行长度上限 80 字节是两套独立参数,前者管物理行,后者管命令解析。

【注意】

行装配只认 \r\n 严格配对,串口助手默认发送的就是这种结尾;只发 \n 的终端需要自己改,否则永远拼不出一行。

3. 错误回调与溢出恢复 ​

HAL 把"中断接收模式下的溢出(ORE)"当成阻塞性错误处理:一旦置位,它会中止本次接收再回调错误处理函数。若这里不重新武装,串口将永久静默,只能靠复位恢复——这是必须实现错误回调的唯一理由:

c
// atk-f103/Core/Src/usart.c
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart)
{
  if (huart->Instance == USART1)
  {
    g_usart_rx_errors++;

    /* 清 ORE/RXNE, 否则错误中断会反复触发; ORE 场景下 DR 里的字节本就已丢失 */
    __HAL_UART_CLEAR_OREFLAG(huart);

    HAL_UART_Receive_IT(&huart1, &g_usart_rx_byte, 1);
  }
}

累计次数通过 usart1_rx_error_count() 暴露给上层,用来判断链路质量:调试时如果这个数字一直在涨,说明主循环被长时间阻塞过(典型元凶是擦 Flash 或大量 printf)。

四、 主循环侧接口 ​

1. usart1_get_line() ​

该函数在 atk-f103/Core/Src/usart.c 中定义,声明位于 atk-f103/Core/Inc/usart.h:

c
uint16_t usart1_get_line(uint8_t *buf, uint16_t max_len);

【函数作用】

取出中断侧拼好的一整行,取走后清掉完成标志,中断才能接收下一行。这是行数据的唯一消费入口,与 usart_echo_test() 互斥——两者都取行,先调用的一方会把数据取走。

【参数含义】

  • buf:存放行数据的缓冲区,内容不含 \r\n,也不追加 '\0'
  • max_len:缓冲区字节数,超出的部分截断

【返回值】

返回 0 表示暂无完整行;非 0 为实际取到的字节数。已注册字节流消费者时恒返回 0(那时没有"行"的概念)。

2. usart1_set_rx_sink() ​

同一个中断还要能服务二进制协议,靠的是可替换的"字节流消费者":

c
// atk-f103/Core/Inc/usart.h
void usart1_set_rx_sink(void (*sink)(uint8_t byte));

注册之后,中断把每个字节原样交给 sink,不再拼行、不再回显;传 0 注销,恢复行缓冲行为。IAP 模块注册的 iap_rx_byte() 就是这样的消费者。契约写得很明确:sink 运行在中断上下文,必须足够短——只做状态迁移与缓冲写入,不许打印、不许算 CRC、不许写 Flash。

3. 命令识别的并行 ​

文本命令的识别并不在这个"行缓冲"里另起一套,而是搭同一个中断:每个字节先喂 usart1_cmd_feed(),再走行缓冲或 sink。这样无论当前是回显、行缓冲还是二进制帧模式,命令通道的行为都一致(具体见 LV020-命令系统设计)。

五、 设计取舍与排查 ​

1. 为什么不用 DMA + 空闲中断 ​

DMA 加 IDLE 中断是更高吞吐的方案,本工程没有采用,理由有三条:

  • 115200 下峰值约 11.5 KB/s,每字节间隔 87µs,单字节中断的开销完全吃得下;
  • DMA 需要一块静态缓冲并把"半满/全满/空闲"三种事件拼起来,与"严格按 \r\n 判帧"的行语义不天然匹配,反而要再写一层解析;
  • 同一条通道还要在"行模式"与"二进制帧模式"之间切换,用中断收字节时切换成本只是改一个函数指针。

代价也很明确:每字节一次中断。若以后把波特率提到 1M 以上,或主循环里出现长时间阻塞,就得换成 DMA 方案。

2. 为什么只保留一行 ​

不排队是刻意的:命令是"人敲一条执行一条",排队没有意义;多行排队还要在 RAM 里开 N 份 200 字节,对 64KB 的 F103 不划算。代价是主循环必须跟得上——本工程主循环 1ms 一拍,usart1_get_line() 每拍都调,正常打字远不会触发丢行。

3. 中断里为什么不做发送 ​

HAL_UART_Transmit() 是阻塞式发送,等待 TXE 的循环会把中断占住几十微秒到几毫秒(取决于长度)。中断里发一行日志的代价,往往就是接收侧的一次 ORE。所以本工程的中断里没有任何发送动作,日志全部走主循环的 printf,靠 fputc() 轮询 TXE 逐字节输出。

4. 关键参数与常见现象 ​

参数位置取值
波特率MX_USART1_UART_Init()115200,8N1,无流控
行缓冲大小USART_LINE_BUF_SIZE200 字节
中断优先级HAL_UART_MspInit()抢占 3、子优先级 2
接收模式USART_USE_LINE_RX1(行缓冲)
现象原因处理
只能收到第一行,之后静默中断里出错后没重新武装确认 HAL_UART_ErrorCallback() 已实现并清 ORE
连续发送时随机丢字节主循环阻塞过久,ORE 累计增加打印 usart1_rx_error_count(),缩短阻塞段
命令行取不到数据已注册字节流消费者(IAP 接管)属正常行为,此时数据归帧状态机
只发 \n 的终端没有反应行装配要求 \r\n 严格配对改用 \r\n 结尾,或调整状态机

本文档由 markdowncli 技能辅助生成