1. 项目缘起与核心需求拆解
1.1 为什么不定长数据帧接收是个“老大难”
做过嵌入式开发的人都有一个共识:串口收发本身不难,难的是怎么优雅地接收一帧长度不固定的数据。你永远不知道上位机下一秒会发来 8 个字节还是 200 个字节,如果按固定长度去读,要么读多了把下一帧的头给吞了,要么读少了导致数据残缺。这个矛盾在资源受限的 MCU 上尤其突出,比如本次要聊的 KEA128——它属于入门级车规 MCU,RAM 只有区区 4KB 到 8KB,Flash 也就 128KB 左右,根本没有余粮让你开一个几百字节的环形缓冲区去“兜底”。
传统做法无非几种:一是轮询加超时,主循环里死等,CPU 利用率极低,稍微复杂点的系统就卡死;二是固定长度接收,协议设计时强行把每帧补齐到固定字节数,浪费带宽不说,遇到变长协议直接歇菜;三是空闲中断(IDLE)配合 DMA,这招在 STM32 上很香,但 KEA128 的 UART 模块并没有 IDLE 中断这个功能,DMA 通道也少得可怜。所以摆在面前的路只有一条:用接收中断(RX 中断)配合定时器超时来判断帧结束,这也是本次项目的核心思路。
1.2 核心需求与设计目标
把需求翻译成工程语言,大概是这么几条:第一,不丢字节,每个到达的字节都要进缓冲区;第二,能判帧尾,通过总线空闲时间来判断一帧是否结束;第三,CPU 占用低,不能因为收数据把主逻辑拖垮;第四,RAM 占用可控,缓冲区大小要能根据实际协议帧长灵活设定;第五,可移植性好,换一个 UART 或者换一款芯片,核心逻辑不用大改。
这五条目标里,最核心的是第二条和第三条。判帧尾决定了协议解析的正确性,CPU 占用决定了系统能不能跑其他任务。很多人写串口接收,收着收着就发现主循环被中断打断得七零八落,或者帧尾判断总是差那么一两个字节,问题基本都出在这两点上。
1.3 适用人群与阅读门槛
这篇内容适合已经能点亮串口、会配置基本中断的嵌入式开发者。如果你连 UART 的波特率、数据位、停止位都还没搞明白,建议先去补一下基础。但如果你已经卡在“不定长数据怎么收”这个坎上,或者你正在用 KEA128、S9KEA 系列、甚至其他没有 IDLE 中断的 MCU,那这篇内容就是为你准备的。我会把寄存器配置、中断服务函数的写法、定时器超时判断的逻辑全部拆开讲,代码可以直接抄作业。
2. 方案选型:为什么是“接收中断 + 定时器超时”
2.1 三种主流方案的横向对比
在动手写代码之前,先把可选方案摆到台面上比一比,这样你才能理解为什么最终选了这条路。
| 方案 | 核心原理 | 优点 | 缺点 | KEA128 适配性 |
|---|---|---|---|---|
| 轮询 + 超时 | 主循环不断查 RX 寄存器 | 实现简单,无需中断 | CPU 占用极高,实时性差 | 不推荐 |
| 接收中断 + 固定长度 | 每收 N 字节触发一次处理 | 逻辑简单 | 无法处理变长帧 | 仅适合定长协议 |
| 接收中断 + 定时器超时 | 每收一字节重置定时器,超时判帧尾 | CPU 占用低,适配变长帧 | 需要额外定时器资源 | 推荐 |
| IDLE 中断 + DMA | 总线空闲触发中断 | 效率最高 | KEA128 无 IDLE 中断 | 不可用 |
从表里能看出来,KEA128 的硬件条件直接排除了 IDLE 中断和 DMA 方案,而轮询和固定长度又满足不了需求,所以接收中断 + 定时器超时是唯一可行的优雅解。
2.2 定时器超时判帧尾的原理
这个方案的逻辑其实很朴素:串口每收到一个字节,就会触发一次 RX 中断,在中断里把这个字节存进缓冲区,同时重置一个定时器。如果总线持续有数据,定时器就会被不断重置,永远不会溢出;一旦数据发完,总线安静下来,定时器不再被重置,经过设定的超时时间后溢出,触发定时器中断,在定时器中断里就可以判定“一帧接收完毕”,然后置一个标志位通知主循环去处理。
这里的关键参数是超时时间。设得太短,两个字节之间稍微有点间隔就误判帧尾;设得太长,帧尾判断延迟大,影响响应速度。一般来说,超时时间取3 到 5 个字节的传输时间比较稳妥。举个例子,波特率 9600,一个字节 10 位(1 起始 + 8 数据 + 1 停止),传输时间是 10 / 9600 ≈ 1.04ms,那么超时时间取 3 到 5ms 就够。如果波特率是 115200,一个字节约 86.8μs,超时时间取 300 到 500μs 即可。
2.3 缓冲区设计的关键考量
缓冲区不能太小,否则一帧还没收完就溢出了;也不能太大,KEA128 的 RAM 经不起挥霍。我的经验是:先统计协议里最长帧的字节数,然后乘以 1.5 到 2 倍作为缓冲区大小。比如你的协议最长帧是 64 字节,那缓冲区开 128 字节就足够。同时要加一个写指针和溢出保护,写指针到达缓冲区末尾时要么停止写入并置错误标志,要么回绕覆盖旧数据——具体选哪种取决于你的协议是否允许丢帧。
另外,缓冲区的读写涉及中断和主循环的并发访问,必须考虑临界区保护。在 KEA128 上,最简单的方法是在主循环读取缓冲区时先关中断,读完再开中断。如果帧处理时间较长,可以先把数据拷贝到另一个缓冲区再开中断处理,避免长时间关中断导致丢字节。
3. 核心细节解析与实操要点
3.1 KEA128 的 UART 寄存器配置要点
KEA128 的 UART 模块寄存器不算多,但有几个位特别容易踩坑。先看波特率配置,它用的是BDH 和 BDL 两个寄存器组合成 13 位的波特率分频值。计算公式是:
波特率 = 总线时钟 / (16 × SBR)
其中 SBR 是 BDH 的低 5 位和 BDL 的 8 位拼起来的 13 位值。假设总线时钟是 20MHz,要配 9600 波特率,SBR = 20000000 / (16 × 9600) ≈ 130.2,取整 130,实际波特率就是 20000000 / (16 × 130) ≈ 9615,误差 0.16%,完全可接受。
控制寄存器方面,C2 寄存器是重点:RE 位使能接收,RIE 位使能接收中断,TE 位使能发送。很多人只开了 RE 忘了 RIE,结果中断死活不进。S1 寄存器里的 RDRF 位是接收数据就绪标志,读 D 寄存器会自动清这个标志,但如果你在中断里忘了读 D,中断会一直触发,直接把系统拖死。
还有一个隐藏坑:KEA128 的 UART 接收中断和错误中断共用同一个中断向量。也就是说,溢出错误、帧错误、噪声错误都会进同一个 ISR。你必须在 ISR 里读 S1 判断具体是哪种事件,如果是错误标志,要读 D 寄存器来清除,否则中断会反复触发。
3.2 定时器选型与配置
KEA128 上可用的定时器有TPM 模块和PIT 模块。TPM 是增强型定时器,支持 PWM、输入捕获、输出比较,功能强但配置复杂;PIT 是周期中断定时器,结构简单,就是一个递减计数器,溢出触发中断。对于“超时判帧尾”这个场景,PIT 完全够用,而且配置更简单。
PIT 的配置步骤:先使能 PIT 时钟,然后设置LDVAL 寄存器决定超时周期。LDVAL 的值 = 超时时间 × 总线时钟 - 1。比如总线时钟 20MHz,要 5ms 超时,LDVAL = 0.005 × 20000000 - 1 = 99999。然后使能TCTRL 寄存器的 TIE 位开启中断,最后使能MCR 寄存器的 MDIS 位启动定时器。
这里有个操作细节:在串口接收中断里重置 PIT 时,不能只写 LDVAL,因为 PIT 是递减计数器,你写 LDVAL 只是改了重载值,当前计数值不会立即重置。正确的做法是先关闭 PIT(置 TCTRL 的 TEN 位为 0),写 LDVAL,再重新使能。或者更简单的办法:在接收中断里直接置 PIT 的 TFLG 标志位,强制触发一次重载。具体用哪种,取决于你对时序精度的要求。
3.3 中断优先级与嵌套处理
KEA128 的中断优先级通过NVIC 的 IPR 寄存器配置,每个中断有 4 个优先级位(实际只用高 2 位或高 3 位,具体看芯片手册)。串口接收中断和 PIT 中断的优先级怎么排?我的建议是:串口接收中断优先级高于 PIT 中断。因为串口接收是“硬实时”的,一个字节没及时读走就可能被下一个字节覆盖;而 PIT 中断只是判帧尾,晚几个微秒处理没关系。
但要注意,如果串口接收中断里重置 PIT 的操作被 PIT 中断打断,可能会出现竞态。比如串口中断刚写完 LDVAL,PIT 中断就触发了,导致帧尾误判。解决办法是在串口中断里重置 PIT 时短暂关中断,或者用原子操作。在 KEA128 上,可以用DisableInterrupts和EnableInterrupts宏包住这几行代码。
4. 完整实操流程与代码实现
4.1 整体软件架构
整个方案分三层:硬件层负责 UART 和 PIT 的寄存器配置;中断层包含 UART RX ISR 和 PIT ISR;应用层在主循环里检查帧接收完成标志,然后解析数据。三层之间通过全局变量通信:一个接收缓冲区数组、一个写指针、一个帧完成标志。
这种分层的好处是移植性强。换一款 MCU,只需要改硬件层的寄存器配置,中断层和应用层的逻辑基本不动。换一个 UART 通道,也只需要改硬件层的基地址和中断向量号。
4.2 UART 初始化代码
#define UART_BAUD_9600 130 #define RX_BUFFER_SIZE 128 volatile uint8_t rxBuffer[RX_BUFFER_SIZE]; volatile uint16_t rxWriteIndex = 0; volatile uint8_t frameReceived = 0; volatile uint8_t rxOverflow = 0; void UART_Init(void) { /* 使能 UART 时钟 */ SIM_SCGC4 |= SIM_SCGC4_UART0_MASK; /* 配置波特率 */ UART0_BDH = (UART_BAUD_9600 >> 8) & 0x1F; UART0_BDL = UART_BAUD_9600 & 0xFF; /* 使能接收和接收中断 */ UART0_C2 |= (UART_C2_RE_MASK | UART_C2_RIE_MASK); /* 使能 UART 中断 */ NVIC_EnableIRQ(UART0_IRQn); NVIC_SetPriority(UART0_IRQn, 1); }这段代码里,SIM_SCGC4是时钟门控寄存器,不开时钟寄存器写了也没用。UART0_BDH只取低 5 位,因为高 3 位是其他控制位。NVIC_SetPriority设优先级为 1,比 PIT 的 2 高。
4.3 PIT 初始化代码
#define PIT_TIMEOUT_VALUE 99999 void PIT_Init(void) { /* 使能 PIT 时钟 */ SIM_SCGC6 |= SIM_SCGC6_PIT_MASK; /* 关闭 PIT 以便配置 */ PIT_MCR = 0x00; /* 设置超时周期 */ PIT_LDVAL0 = PIT_TIMEOUT_VALUE; /* 使能 PIT 中断 */ PIT_TCTRL0 |= PIT_TCTRL_TIE_MASK; /* 启动 PIT */ PIT_TCTRL0 |= PIT_TCTRL_TEN_MASK; /* 使能 PIT 中断 */ NVIC_EnableIRQ(PIT_IRQn); NVIC_SetPriority(PIT_IRQn, 2); }注意PIT_MCR = 0x00是使能 PIT 模块,如果写 0x02 就是关闭。PIT_LDVAL0对应通道 0,如果你用其他通道就改对应的寄存器。
4.4 UART 接收中断服务函数
void UART0_IRQHandler(void) { uint8_t status = UART0_S1; uint8_t data; /* 检查是否有错误 */ if (status & (UART_S1_OR_MASK | UART_S1_FE_MASK | UART_S1_NF_MASK)) { /* 读 D 寄存器清除错误标志 */ data = UART0_D; return; } /* 接收数据就绪 */ if (status & UART_S1_RDRF_MASK) { data = UART0_D; /* 存入缓冲区 */ if (rxWriteIndex < RX_BUFFER_SIZE) { rxBuffer[rxWriteIndex++] = data; } else { rxOverflow = 1; } /* 重置 PIT 超时 */ PIT_TCTRL0 &= ~PIT_TCTRL_TEN_MASK; PIT_LDVAL0 = PIT_TIMEOUT_VALUE; PIT_TCTRL0 |= PIT_TCTRL_TEN_MASK; } }这里有个细节:先读 S1 再读 D,因为读 D 会清 RDRF 标志,如果先读 D 再读 S1,S1 里的状态可能已经变了。另外,错误处理里读 D 是为了清除错误标志,不读的话中断会一直触发。
4.5 PIT 中断服务函数
void PIT_IRQHandler(void) { /* 清除 PIT 中断标志 */ PIT_TFLG0 |= PIT_TFLG_TIF_MASK; /* 判定帧接收完成 */ if (rxWriteIndex > 0) { frameReceived = 1; } }PIT 中断里只做一件事:置帧完成标志。真正的数据处理放到主循环里做,这样中断服务函数尽可能短,不影响其他中断的响应。
4.6 主循环处理逻辑
int main(void) { UART_Init(); PIT_Init(); EnableInterrupts; while (1) { if (frameReceived) { /* 关中断,拷贝数据 */ DisableInterrupts; uint16_t len = rxWriteIndex; uint8_t localBuffer[RX_BUFFER_SIZE]; for (uint16_t i = 0; i < len; i++) { localBuffer[i] = rxBuffer[i]; } rxWriteIndex = 0; frameReceived = 0; EnableInterrupts; /* 处理数据 */ ProcessFrame(localBuffer, len); } } }主循环里先关中断拷贝数据,再开中断处理,这样处理时间再长也不会丢字节。ProcessFrame就是你的协议解析函数,根据具体协议实现。
5. 常见问题与排查技巧实录
5.1 中断不进或反复进
这是最常见的问题,排查思路按以下顺序来:第一,检查时钟门控,SIM_SCGC4和SIM_SCGC6有没有使能对应模块;第二,检查中断使能位,UART 的 RIE 和 PIT 的 TIE 有没有置位;第三,检查 NVIC 使能,NVIC_EnableIRQ有没有调用;第四,检查中断向量表,启动文件里的中断向量名和你的 ISR 函数名是否一致。如果中断反复进,多半是没读 D 寄存器清标志,或者错误标志没清。
5.2 帧尾误判或漏判
帧尾误判表现为:一帧数据被拆成两帧,或者两帧数据被合并成一帧。前者是超时时间设得太短,后者是设得太长。调整方法:用示波器或逻辑分析仪抓一下总线波形,量一下字节之间的实际间隔,然后超时时间取这个间隔的 3 到 5 倍。如果没有仪器,就按前面说的“3 到 5 个字节传输时间”来估算,然后实测微调。
漏判表现为:帧完成标志一直不置位。检查 PIT 中断有没有使能,PIT_TFLG0有没有清除,以及rxWriteIndex是否大于 0。如果rxWriteIndex一直是 0,说明串口中断根本没进,回到上一条排查。
5.3 缓冲区溢出与数据错位
缓冲区溢出通常是帧长超过预期,或者写指针没有正确重置。排查方法:在溢出时置一个错误标志,主循环里检测到这个标志就打印一条日志。数据错位多半是读写指针并发访问导致的,比如主循环正在读缓冲区,串口中断又写入了新数据。解决办法就是前面说的关中断拷贝。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 中断不进 | 时钟未使能 | 查 SIM_SCGC 寄存器 | 使能对应时钟 |
| 中断不进 | NVIC 未使能 | 查 NVIC_ISER | 调用 NVIC_EnableIRQ |
| 中断反复进 | 未清中断标志 | 查 S1 和 D 寄存器 | 读 D 清 RDRF |
| 帧尾误判 | 超时太短 | 逻辑分析仪抓波形 | 增大 LDVAL |
| 帧尾漏判 | 超时太长 | 检查 PIT 中断 | 减小 LDVAL |
| 数据错位 | 并发访问 | 检查读写指针 | 关中断拷贝 |
| 缓冲区溢出 | 帧长超预期 | 检查协议最长帧 | 增大缓冲区 |
5.5 几个独家避坑技巧
第一个技巧:在串口中断里不要做任何耗时操作,包括打印调试信息。我见过有人在 ISR 里调 printf,结果串口中断嵌套把自己堵死。调试信息统一放到主循环里打。
第二个技巧:PIT 超时值不要设成整数边界。比如算出来是 100000,你就设 99999 或 100001,避免和系统里其他定时器产生周期性冲突。
第三个技巧:给缓冲区加一个“帧开始”标志。在 PIT 中断里置帧完成标志的同时,也置一个帧开始标志,这样主循环能区分“新帧”和“旧帧残留”。具体做法是在 PIT 中断里把rxWriteIndex清零之前先记录长度,然后主循环处理完再真正清零。
第四个技巧:如果协议里有帧头帧尾,可以在串口中断里做初步校验,比如检测到帧头才开始存数据,检测到帧尾就提前置帧完成标志,这样能减少对超时判断的依赖,提高可靠性。
6. 方案扩展与性能优化
6.1 双缓冲机制提升吞吐量
单缓冲区在高速率下可能不够用:主循环还在处理上一帧,串口中断已经把下一帧写进来了。解决办法是双缓冲:开两个缓冲区,串口中断写 A 的时候,主循环读 B;A 写满了切换写 B,主循环读 A。切换动作在 PIT 中断里做,用一个标志位指示当前写哪个缓冲区。这样即使主循环处理慢一点,也不会丢帧。
双缓冲的代价是 RAM 占用翻倍,在 KEA128 上要权衡。如果协议帧长不超过 64 字节,两个 128 字节的缓冲区也才 256 字节,对 4KB RAM 来说可以接受。
6.2 用 DMA 进一步降低 CPU 占用
虽然 KEA128 的 DMA 通道有限,但如果你的项目里恰好有富余的 DMA 通道,可以把串口接收配成 DMA 搬运,CPU 完全不参与字节搬运,只在 PIT 中断里判帧尾。这样 CPU 占用能降到最低。不过 KEA128 的 DMA 配置比较复杂,涉及源地址、目的地址、传输长度、触发源等一堆寄存器,如果项目对成本敏感或者开发时间紧,不建议强行上 DMA。
6.3 超时时间的自适应调整
固定超时时间在波特率变化时会失效。如果你的系统支持动态改波特率,超时时间也要跟着变。做法是:在波特率配置函数里顺便算一下超时值,写到一个全局变量里,PIT 中断和串口中断都用这个变量。计算公式就是前面说的“3 到 5 个字节传输时间”。
6.4 低功耗场景下的注意事项
如果系统要进低功耗模式,串口接收中断和 PIT 中断都能把 MCU 唤醒。但要注意:进低功耗前要确保 PIT 已经停止,否则 PIT 会周期性唤醒 MCU,白白耗电。另外,低功耗模式下总线时钟可能变慢,超时时间要重新计算。唤醒后要重新初始化 UART 和 PIT,因为低功耗可能会复位这些外设的寄存器。
7. 实际项目中的经验沉淀
7.1 调试阶段怎么快速验证
我一般会写一个简单的回环测试:PC 端发一串数据,MCU 收到后原样发回。如果 PC 端收到的数据和发出去的一模一样,说明接收逻辑没问题。然后再逐步加长数据、改变发送间隔,测试帧尾判断的边界情况。这个回环测试代码不超过 20 行,但能覆盖 80% 的 bug。
7.2 代码可移植性的处理方式
把硬件相关的部分全部用宏包起来,比如UART_BAUD_9600、PIT_TIMEOUT_VALUE、RX_BUFFER_SIZE这些放到一个头文件里。换芯片时只改这个头文件和硬件初始化函数,中断服务函数和应用层代码原封不动。我试过把同一套逻辑从 KEA128 移植到另一款 Cortex-M0+ 芯片上,只花了半小时改寄存器配置,核心逻辑一行没动。
7.3 长期运行稳定性的保障
工业现场对稳定性要求高,我一般会加两个保护:一是看门狗,主循环里定期喂狗,如果帧处理卡死,看门狗复位;二是帧计数和错误计数,每收到一帧正确数据就加一,每发生一次溢出或错误也加一,定期通过串口上报。这样现场出问题时,看一眼计数就能定位是硬件干扰还是软件 bug。
7.4 关于中断优先级的再思考
前面说串口接收中断优先级高于 PIT,这是通用原则。但如果你的系统里还有更硬实时的中断,比如电机控制 PWM 中断,那串口接收的优先级就要往后排。原则是:越不能容忍延迟的中断,优先级越高。串口接收的容忍度取决于波特率,115200 下一个字节 86.8μs,只要在这个时间内进中断就行,所以优先级不用设到最高。
7.5 一个容易被忽略的细节:停止位和校验位
如果协议里用了校验位,接收中断里读到的数据是不包含校验位的,校验位在 S1 寄存器里。你需要在 ISR 里读 S1 判断校验是否通过,不通过就丢弃这一帧。停止位同理,帧错误标志在 S1 的 FE 位。很多人调试时发现数据偶尔错一个字节,查了半天发现是校验位没处理。
这套方案我在好几个量产项目里用过,从 9600 到 115200 波特率都跑得很稳。核心就是那几行中断代码,但细节决定成败。把超时时间调对、把缓冲区保护好、把中断优先级排好,剩下的就是复制粘贴的功夫了。