ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

串口DMA接收不定长数据:空闲中断组合方案与实战避坑

串口DMA接收不定长数据:空闲中断组合方案与实战避坑 1. 为什么串口收不定长数据这么头疼 —— 传统方案的实际痛点串口通信在MCU开发里就像水电煤一样基础但越是基础的东西越容易在真正干活的时候卡住。我见过不少刚接触嵌入式开发的工程师第一版串口收发功能用的是最传统的方式接收引脚来一个字节触发一次中断在中断里把数据丢进一个数组攒够固定长度再处理。这套逻辑在数据帧长度固定、波特率不高、数据量不大的时候确实能用但一旦业务要求变成每次过来的数据长短不一发完就结束问题立刻暴露出来。1.1 逐字节中断接收的三大问题第一个问题是CPU资源被严重浪费。假设波特率是115200一个字节约耗时86.8微秒这期间CPU要被中断打断一次做入栈、读寄存器、存数组、清标志、出栈这一套流程。听起来单次开销不大但架不住中断频繁触发数据量一上来整个主循环的执行节奏全被打乱。如果这时候主循环里还跑着控制算法、屏幕刷新、传感器采集时序一抖动后果很麻烦。第二个问题是无法自然判断一帧数据什么时候结束。逐字节中断方案里接收方往往依赖帧长度固定或者约定帧头帧尾、超时判断。帧头帧尾遇到数据内容恰巧与帧头相同就得做转义处理超时判断就得另开一个定时器在每收到一个字节后重置计数一旦超时认为帧结束代码逻辑比较复杂。第三个问题是数据量大的时候容易出现丢字节。中断里做的事情太多下个字节就来的时候还没处理完硬件FIFO溢出数据就丢了。这在连续接收大数据块时尤为明显。1.2 DMA的本职工作与局限DMADirect Memory Access的价值在于外设和内存之间的数据搬运不需要CPU介入。对于串口接收来说只要配置好DMA通道UART收进来的数据会自动被搬到指定的内存缓冲区收满了触发一次传输完成中断CPU只需要在中断里处理一整块数据。这样CPU从每字节中断一次变成了每块数据中断一次压力小了一个数量级。但DMA也有一个天然局限它只负责搬运不负责理解数据语义。DMA只能按设定好的长度搬运并不知道串口上过来的数据是否已经收完一帧。如果只用一个固定长度接收缓冲当实际数据不足设定长度时DMA永远不会触发传输完成中断数据就一直躺在缓冲区里没人处理当实际数据超过设定长度时多余的部分会被DMA搬运到缓冲区之外的内存地址造成缓冲区溢出这是极其隐蔽的内存踩踏问题。1.3 本文方案适合哪些场合LAT1315的具体配置我在后面会详细展开但先明确这个方案的适用场景设备间通信帧不固定长度、单帧几十到几百字节、通信频率不太极端、MCU主频和内存资源适中。典型场景比如串口屏交互指令、GPS模块上报的NMEA语句、蓝牙透传模块发的数据包、上位机通过串口台发的长短不一的命令。这类场景的共同特征是——数据到达有停顿一帧数据发完之后线路会空闲一阵子。如果你要处理的是持续不间断的音频流或者高速大数据吞吐那属于串口DMA环形缓冲区轮询方案的范畴和本文的不定长帧接收思路不太一样。本文方案解决的核心问题是如何优雅地知道这一帧结束了可以开始处理了。2. 方案核心空闲中断 DMA 的组合逻辑既然DMA不知道帧边界那就要靠另一个机制来告诉CPU数据收完了。在LAT1315这类MCU上最顺手的就是串口空闲中断IDLE Interrupt。这个中断不是每收一个字节触发一次而是在串口接收线上出现一段空闲时间即收到一个字节后后续超过一个字节时间没有新数据时触发。利用这个特性就可以组合出完整的不定长接收方案。2.1 帧结束怎么判定先理清一个基本逻辑不定长接收的本质是知道这一帧在哪里结束。常见的判定方式有三种。第一种是字节间隔超时。每收到一个字节就重置一个超时计数器超过N个字节时间没收到新数据就认为一帧结束。这种方式最通用但需要额外开定时器资源。第二种是帧尾识别。预先知道每帧数据的结束标志字节或者校验字段收满帧尾就代表结束。这种方式适合协议固定的场合但如果数据内容里可能包含帧尾值处理起来比较麻烦。第三种就是本文要用的方式利用串口外设自带的空闲中断。它本质上是硬件帮你做了字节间隔超时的判断——只要接收线上空闲超过一个字节的时间就自动触发中断。不用额外占用定时器资源也不用担心数据内容里有什么特殊字节因为它的判断依据是时序不是数据内容。这里有一个需要澄清的点很多人会误以为空闲中断是在线路完全没有信号时才触发其实不是。空闲指的是从最近一个停止位结束之后RX线继续保持高电平超过一个字节的时间。你发一帧100字节的数据只要有正常的停止位字节之间的间隔都在一个字节时间以内不会触发空闲中断只有整帧发完下一个字节迟迟不来线路空闲超过一个字节时间才会触发。2.2 为什么选串口空闲中断对比定时器超时法硬件空闲中断有很明显的优势。以LAT1315为例它的串口外设内置了空闲检测逻辑触发条件精确且可重复无需额外初始化一个定时器通道。对于资源紧张的项目省掉一个定时器的意义很大尤其是当这个定时器还要用于PWM输出或者电机控制的时候。另一方面空闲中断配合DMA有一个非常舒服的节奏DMA负责把数据静默地搬进内存空闲中断负责在一帧数据搬完了的时刻精确通知CPU。这就像请了一个搬货工人DMA他负责把车上的货一件件搬进仓库再请了一个监工空闲中断装完一车货后喊一声这车完事了来处理吧。CPU这个老板不需要一直盯着听到喊声再来处理就行。2.3 整体数据流整个方案的数据流可以概括为四个环节配置DMA接收通道指定接收缓冲区地址和最大接收长度使能串口接收和DMA请求。启动一轮接收后CPU该干嘛干嘛去DMA自动把UART RX寄存器收到的字节搬运到缓冲区。当串口线上出现空闲硬件触发空闲中断。在空闲中断服务函数里读取DMA当前传输计数算出本轮实际收到多少字节把缓冲区里的数据交给上层协议解析然后重新调整DMA接收长度准备接收下一帧。这四个环节看起来简单实际落地时会遇到不少细节问题我在后文会逐个展开。3. LAT1315 实操配置与代码在LAT1315上落地这套方案我习惯分三步走先串口初始化再DMA初始化最后处理中断逻辑。下面直接给可以套用的代码和配置思路。3.1 引脚与串口基础配置以UART1为例假设引脚已经复用到了UR1_TX和UR1_RX上。基础串口配置包括波特率、数据位、停止位、校验位、收发模式这里按常规的115200-8-N-1配置// 串口1基础配置 void UART1_Init(void) { // 使能UART1时钟、GPIO时钟 // 配置TX、RX引脚复用 // 串口参数 UART1-BAUD UART_BAUD_115200; // 波特率 UART1-CR UART_CR_UEN | // 串口使能 UART_CR_TXEN | // 发送使能 UART_CR_RXEN | // 接收使能 UART_CR_TXINT | // 发送中断按需 UART_CR_IDLEIE; // 空闲中断使能关键点 }有两点值得强调。第一UART_CR_IDLEIE这个位必须置起来否则串口不会产生空闲中断。第二接收中断RX interrupt在这个方案里通常可以不用使能因为我们不打算逐字节处理接收所有接收工作交给DMA完成。如果还开着接收中断会出现DMA和中断同时在工作的情况逻辑容易乱。3.2 DMA 接收通道配置DMA配置是整个方案的核心。这里的关键点包括传输方向设为外设到内存、外设地址固定为UART1的数据寄存器地址、内存地址为接收缓冲区地址、传输长度设为单次最大期望帧长。#define UART1_RX_BUF_SIZE 256 uint8_t uart1_rx_buf[UART1_RX_BUF_SIZE]; // DMA接收缓冲区 volatile uint8_t uart1_frame_done 0; // 帧接收完成标志 volatile uint16_t uart1_frame_len 0; // 本轮实际接收长度 void UART1_RX_DMA_Init(void) { // 1. 使能DMA时钟 // 2. 配置DMA通道 // 外设地址: (uint32_t)UART1-DR // 内存地址: (uint32_t)uart1_rx_buf // 传输方向: 外设 - 内存 // 传输长度: UART1_RX_BUF_SIZE // 地址增量: 外设固定, 内存递增 // 数据宽度: 字节 (8bit) // 3. 使能DMA通道 }这里有一个特别容易踩坑的地方DMA接收缓冲区的长度设多少合适。设太大缓冲区占用RAM过多设太小一帧数据稍微长一点就溢出了。我一般按项目协议实际最大帧长加20%余量来设比如协议规定最大帧长200字节DMA缓冲区就设256字节留出足够余量不至于每帧数据都顶到边界。3.3 中断服务与数据处理这一步是整个方案的灵魂。LAT1315的空闲中断和接收中断都是在同一个串口中断服务函数里处理的需要在中断里做两件事一是读取DMA剩余计数寄存器计算实际接收长度二是清除中断标志重新配置DMA传输长度准备接收下一帧。void UART1_IRQHandler(void) { if (UART1-SR UART_SR_IDLEF) { // 1. 清除空闲中断标志重新使能空闲中断 UART1-SR ~UART_SR_IDLEF; // 写0清标志 UART1-CR | UART_CR_IDLEIE; // 2. 读取DMA剩余未传输计数 uint32_t remain_cnt DMA_GetRemainCount(DMA_CH_UART1_RX); // 3. 实际接收长度 缓冲区总长度 - 剩余计数 uart1_frame_len UART1_RX_BUF_SIZE - (uint16_t)remain_cnt; // 4. 设置帧完成标志通知主循环处理 uart1_frame_done 1; // 5. 重新配置DMA传输长度准备下一轮接收 DMA_DisableChannel(DMA_CH_UART1_RX); DMA_SetTransferCnt(DMA_CH_UART1_RX, UART1_RX_BUF_SIZE); DMA_EnableChannel(DMA_CH_UART1_RX); } }主循环里处理帧数据的逻辑大致如下while (1) { if (uart1_frame_done) { uart1_frame_done 0; // 处理一帧数据: uart1_rx_buf 里有效长度为 uart1_frame_len ProcessFrame(uart1_rx_buf, uart1_frame_len); } // 其他任务 }需要注意的是在DMA重新使能之前要先把DMA通道关闭重新设置传输长度再打开。有些读者可能在调试时发现第一帧数据正常第二帧开始长度不对多半就是因为DMA传输长度没有正确复位导致的。还有一个细节如果希望在帧处理期间禁止下一帧数据覆盖当前缓冲区可以在设置uart1_frame_done 1之后先关闭DMA通道等主循环处理完数据再重新使能DMA。如果缓冲区被下一帧数据覆盖了上一帧数据就被破坏了。具体取舍取决于你的数据可靠性和实时性要求。4. 不定长帧误判与数据安全的四个坑理论方案和代码框架都清楚了但真正在调试板上跑起来的时候问题才会逐渐浮出水面。这一节我把实际项目中遇到的坑整理出来每一个都是真实调试经历排序从最常遇到到最隐蔽的。4.1 坑一低频串口通信时空闲中断间隔怎么把握空闲中断的触发条件是空闲超过一个字节时间这个时间是不是真的适合你在115200波特率下一个字节时间约86.8微秒空闲中断在收到最后一个字节后约86.8微秒触发。如果上位机每次发送一帧数据时字节之间间隔非常均匀且小于一个字节时间那没问题但如果上位机发送时字节之间有较大的间隔超过了空闲判定的阈值MCU会把一帧数据拆成多帧处理。举个例子有些上位机程序用循环发串口每次循环之间调用了延时毫秒级函数导致字节间隔不稳定。1毫秒的间隔在115200波特率下远大于86.8微秒的空闲阈值MCU收到的就是一帧被拆成多次空闲中断的散装数据。解决思路有两个方向。方向一修改上位机发送逻辑确保发送一帧数据时字节之间没有大间隔最好用连续写缓冲区的方式一次发出完整数据。方向二如果上位机逻辑没法改就只能改MCU侧的帧判定策略比如把空闲中断触发的长度阈值调整成N个字节时间。部分MCU支持配置空闲检测的时间长度LAT1315如果支持的话可以适当调大阈值但要注意阈值调太大低波特率下帧间响应时间会变长实时性受影响。实测下来的合理区间是1到3个字节时间超过3个字节时间时处理多帧连续到达的场景会比较吃力。4.2 坑二DMA缓冲区满的情况 —— 没触发空闲中断怎么办这个坑最容易在测试中被忽略。假设DMA缓冲区设了256字节上位机一次性发了300字节数据。DMA会在搬到256字节时触发传输完成中断但这时空闲中断还没触发因为线上还有数据在传。如果此时你的串口中断服务函数里只处理了空闲中断DMA传输完成中断却没有做处理那后44字节数据会被DMA搬到缓冲区之外的地址造成内存覆盖。这是一个非常隐蔽且破坏力极大的bug。解决这个问题有两个层面的举措。第一在DMA传输完成中断服务函数中也要做处理读取实际接收长度此时应该是缓冲区满先把数据取走再重新调整缓冲区。处理逻辑和空闲中断类似但标志位不同以便主循环区分完整帧还是缓冲区满了。第二协议设计上要保证单帧数据长度永远小于DMA缓冲区长度。这是最保守也最稳妥的做法。如果协议确实存在超长帧的可能性就要用环形缓冲区的思路来配合而不是用固定长度的单次DMA缓冲。但那样的话整个方案就不是本文讨论的不定长帧接收而是连续流接收了逻辑复杂度会明显上升。4.3 坑三处理耗时过长下一帧已经到了 —— 数据被覆盖的问题这是实际项目里非常常见的问题。主循环里解析数据的逻辑太耗时比如要做CRC校验、字符串解析、数据库写入之类的操作处理完一帧数据的时间超过了下一帧数据的到达间隔。下帧数据到达时DMA还在往同一块缓冲区里搬运数据就会把上一帧还没处理完的数据覆盖掉。解决这个问题有三种思路按优先顺序排列。第一种最简单粗暴在主循环处理帧数据前先关闭DMA接收通道等处理完再重新打开。这台会丢弃处理期间的串口数据但保证当前帧数据完整性。适合对实时性要求不高、丢帧可以接受的业务。第二种是双缓冲交替接收。准备两个DMA缓冲区配置DMA交替往两个缓冲区里搬运处理完一个缓冲区里的数据后再切换到另一个缓冲区接收。这个方案在实时性和数据完整性之间取得了较好的平衡代码量会增加一些。第三种是把数据处理尽量放在中断之外、主循环之内并且优化处理逻辑的耗时比如把耗时操作拆分成多个小步骤分时处理或者把数据拷贝到另一个缓冲区主循环处理时用的是拷贝后的数据不占用DMA接收缓冲区。4.4 坑四与RS485收发切换的配合如果你的项目用的是RS485总线有一个额外的细节要注意RS485是半双工通信收和发共用一对信号线切换到发送状态后收发器会关闭接收通道发送结束后要重新切换到接收状态。这就意味着帧的接收状态和RS485的收发控制方向必须严格配合。在空闲中断里收到一帧数据后如果上层协议要求立即回复应答指令需要先关闭DMA接收切换到发送模式发完数据后再切回接收模式重新使能DMA。这个过程中有一个时间窗口从关闭DMA接收到重新使能DMA接收之间如果有人往总线上发数据这些数据就会丢。这里有两条实际经验值得记录。第一RS485的收发切换不是瞬时完成的收发器芯片有切换延时通常几百纳秒到几微秒不等。如果在切换瞬间立即发包总线电平还没稳定很容易造成发错码。需要加一个极短的延时比如2-3微秒再启动发送。第二发送完成之后不要立即切回接收模式也要等一下等发送移位寄存器彻底排空否则最后一个字节的停止位可能会被切断接收端解析时就会出错。5. 从能用走到好用 —— 扩展思路与实测建议上面四节的内容已经可以把串口DMA接收不定长数据这个功能跑起来了。但从能跑到好用之间还有些细节值得打磨。这一节分享一些我在实际项目中用到的扩展思路和调试技巧。5.1 用环形缓冲区解决低功耗 不定长的复合需求有些项目需要MCU在空闲时进入低功耗模式同时又要保持串口接收能力。逐字节中断方式在低功耗模式下无法工作——中断一触发MCU就要醒过来处理没法实现真正意义上的休眠接收。DMA的另一个价值在此时体现出来某些MCU支持DMA在低功耗模式下搬运数据串口收到数据后会自动唤醒MCU。以LAT1315为例如果它支持相关的低功耗DMA唤醒机制就能在休眠状态下持续接收数据直到数据量达到设定值或出现空闲中断。在需要低功耗接收的场景下环形缓冲区配合DMA是个好方案DMA往环形缓冲区尾部搬运数据主循环在空闲中断的提示下从缓冲区头部按帧读取。这样即使MCU在低功耗状态下数据也不会因为RAM空间不足而丢失。不过要注意低功耗模式下的DMA搬运能力受限于MCU时钟源如果低功耗模式下的时钟频率很低高波特率下可能会出现数据处理不及时的问题。5.2 帧标志位的设计细节文章前面的示例代码里用了uart1_frame_done这个标志位实际项目里我建议把它改成带计数的标志或者直接在标志位里附带帧长度信息。一种更稳妥的做法是定义一个结构体作为接收队列typedef struct { uint8_t data[UART1_RX_BUF_SIZE]; uint16_t len; uint8_t state; // 0空闲, 1接收完成待处理 } UART1_Frame_t; UART1_Frame_t uart1_rx_frames[4]; // 4个槽位的帧队列中断里只负责把数据写入空闲槽位并置state1主循环遍历所有槽位发现state1就解析数据然后清状态。这样即使主循环处理不及时缓冲区里也可以缓存多帧数据降低丢帧概率。代价是RAM占用会成倍增加4个256字节的槽位就是1KB内存在小型MCU上要斟酌。5.3 实测中好用的调试排错流程把方案移植到新板子或者新芯片上时建议按以下步骤验证第一步只配置串口不用DMA先用逐字节中断方式接收确认串口本身工作正常波特率误差在可接受范围内。第二步在逐字节中断基础上加上空闲中断让中断里打印出每次空闲中断触发时的累计接收字节数。这一步验证的是空闲中断能否在你预期的帧边界触发。如果帧边界判断不对先别再往下走优先解决触发时序问题。第三步把逐字节中断改成DMA接收但保留串口空闲中断。打印每次DMA剩余计数比对实际接收长度是否和第二步的累计字节数一致。第四步再上业务逻辑主循环解析帧数据。这套流程的核心思想是每一步只改一个变量。很多人调试DMA串口接收时喜欢一步到位DMA、空闲中断、解析逻辑全部写完再上电调一旦出错定位问题范围极大。按上面四步走每一步都能明确知道问题出在哪个环节。5.4 关于中断里做事的控制粒度最后说说一个容易被忽略的编程习惯问题中断服务函数里尽量只做标记事件 搬数据这两件事不要做协议解析、命令分发、响应处理等耗时操作。文章示例代码里中断只执行了计算长度、设置标志、复位DMA这几步状态处理全部放到主循环这个结构应该坚持。原因是中断优先级的调度方式和主循环完全不同中断里执行时间太长会影响其他高优先级中断的响应。尤其在DMA和串口同时工作的情况下如果中断里花了太长时间去处理上一帧数据下一帧数据到了还没法立刻响应很容易造成丢数据。保持中断函数短小精悍是嵌入式开发的基础素养在DMA接收方案中这条原则更加重要。LAT1315或其他同类MCU上这套串口DMA 空闲中断的接收方案核心思想一句话总结用硬件DMA省CPU心力用硬件空闲中断解决什么时候该处理数据的判断问题。这个组合在各类MCU平台上的思路共通只是寄存器和库函数名字不同。我最初在8位单片机上调通这套逻辑后切到32位平台几乎没费什么力气因为原理不变变的只是代码外壳。希望这篇文章的代码和踩坑经验能帮你少走几步弯路。
返回列表