LV015-串口数据接收设计
一、 串口字节是怎么来的
1. 一帧是 10 位,不是 8 位
配置表里写的 8N1 只是"8 位数据、无校验、1 位停止",真正在线路上传输的一帧是 10 位:1 位起始位 + 8 位数据位 + 1 位停止位。
几个能从图上直接读出来的数字:
| 项目 | 数值 | 怎么来的 |
|---|---|---|
| 每位时间 | 8.68 µs | 1 / 115200 |
| 一帧时间 | 86.8 µs | 10 位 × 8.68 µs |
| 理论上限速率 | 约 11.5 KB/s | 115200 / 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 中断接收最朴素的用法:
// 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 位变量里,省掉多个标志位的组合判断:
// 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)"当成阻塞性错误处理:一旦置位,它会中止本次接收再回调错误处理函数。若这里不重新武装,串口将永久静默,只能靠复位恢复——这是必须实现错误回调的唯一理由:
// 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:
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()
同一个中断还要能服务二进制协议,靠的是可替换的"字节流消费者":
// 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_SIZE | 200 字节 |
| 中断优先级 | HAL_UART_MspInit() | 抢占 3、子优先级 2 |
| 接收模式 | USART_USE_LINE_RX | 1(行缓冲) |
| 现象 | 原因 | 处理 |
|---|---|---|
| 只能收到第一行,之后静默 | 中断里出错后没重新武装 | 确认 HAL_UART_ErrorCallback() 已实现并清 ORE |
| 连续发送时随机丢字节 | 主循环阻塞过久,ORE 累计增加 | 打印 usart1_rx_error_count(),缩短阻塞段 |
| 命令行取不到数据 | 已注册字节流消费者(IAP 接管) | 属正常行为,此时数据归帧状态机 |
只发 \n 的终端没有反应 | 行装配要求 \r\n 严格配对 | 改用 \r\n 结尾,或调整状态机 |
本文档由 markdowncli 技能辅助生成