
去年做一台上位机联调的采集设备下位机用的是STM32F103外接的传感器模块上报的数据帧长度不固定——短则几字节长则两百多字节没有帧头帧尾唯一能靠的就是帧间隔。我一开始用常规的USART逐字节中断115200波特率下连续来几百个字节CPU基本全耗在进中断和搬数据上一旦优先级更高的中断插进来串口就掉字节。折腾两天后换成DMA收发加空闲中断做不定长接收这一换数据一帧不丢CPU占用率几乎可以忽略。这篇东西就是把这套方案掰开揉碎讲清楚从DMA为什么能让CPU省心、空闲中断怎么给帧收尾到CubeMX配置、完整代码再到我实际调板时踩过的几个暗坑。适合正在用STM32F103做串口通信、被不定长数据帧搞得头大的开发者也适合刚接触DMA、想找一份能直接抄作业代码的初学者。1. 为什么这个组合能成DMA和空闲中断的职责划分1.1 逐字节中断的痛我见过很多初学者包括当年的我实现串口接收时第一反应就是打开单字节接收中断在中断函数里拼数据。逻辑很简单来一个字节中断一次把字节塞进数组等凑齐了再处理。但波特率一高或者数据量一大问题就来了。115200波特率下每秒大概11520字节平均每个字节间隔约86微秒。如果一帧有好几百字节意味着CPU在很短的时间里要进几百次中断。每次中断虽然只有几十条指令但加上进栈出栈、查询状态、协议判断对CPU的占用率是肉眼可见的。如果项目里还有其它实时性更强的中断——比如定时器PWM、ADC采样——中断优先级一抢占串口这边就容易掉数据。打个比方你是前台每个字节都是一封邮件中断就是邮件到达时铃响一次每次响铃都得放下手头的活去取邮件并归档。一天几百封整天就干这个了。DMA解决的就是这个“铃响一次取一封”的问题。1.2 DMA是一个专职快递员DMA的全称是Direct Memory Access直接存储器访问。它不是CPU而是一个独立于CPU的硬件搬运单元。你只需要告诉它三件事数据从哪来外设寄存器地址比如USART_DR、往哪去内存数组的首地址、搬多少传输长度。配置好之后串口每收到一个字节DMA就自动把它从USART_DR搬到内存数组里全程不需要CPU参与DMA搬运一个字节通常只需要几个总线时钟周期。DMA搬到一半的时候CPU可以完全在忙别的。等DMA搬运完成或者你把DMA配置成循环模式让它一直搬CPU从头到尾只需要做一次初始化。这也是为什么很多项目里DMA被称为“零拷贝”级别的外设辅助——说实话它不算零拷贝但确实把CPU从琐碎的搬运工作中解放出来了。1.3 空闲中断是“帧结束哨兵”STM32F103的USART外设里有一个IDLE标志位它检测RX线路的状态如果接收线上出现持续一个字节时间的空闲电平高电平外设就认定“当前没有数据在传输”置起IDLE标志并且如果中断使能了就会触发中断。这个特性天然适合不定长帧的切分只要一帧数据发完了总会有那么一小段时间线上是空闲的——除非下一帧紧挨着发——这个空闲时刻就相当于一帧数据的“结束哨兵”。你不需要提前知道帧有多长只需要在空闲中断来的时候看看DMA已经搬了多少字节那就是这一帧的长度。1.4 DMA计数器接收里程表关键点来了怎么知道DMA到底搬了多少字节STM32的DMA每个通道有一个计数器寄存器在HAL库里用__HAL_DMA_GET_COUNTER读取或者直接读NDTR寄存器。它表示“还有多少字节没搬完”从你配置的传输长度开始递减每搬一个字节减1。所以当空闲中断发生时当前计数器值 配置长度 - 已搬字节数。反推一下接收到的字节数 配置长度 - 当前计数器值。比如我配置DMA接收长度为256字节空闲中断来的那一刻读到计数器值是200说明这一帧收了56字节。这个计算方式非常简单但后面有个和“计数器回绕”有关的坑到第4章再细说。DMA定好搬运逻辑空闲中断定好分帧依据计数器给出帧长——三者配合一台“自动收信、整批通知”的前台就搭起来了。2. CubeMX配置把这套组合在工程里搭出来2.1 基础外设配置我用的依旧是最常规的STM32F103C8T664KB Flash20KB RAM串口1。CubeMX配置步骤基本是固定的RCC章节HSE选Crystal/Ceramic Resonator因为我板子上有8MHz外部晶振。如果你的板子没有外部晶振直接选HSI内部时钟也能跑但波特率精度会略差一点。只接PC的115200调试场景HSI实际够用如果接对时钟要求比较严的模块建议还是用外部晶振。SYS章节Debug选Serial WireST-Link的SWD调试只用两根线。时钟树把系统时钟拉到72MHzSTM32F103的最大主频USART1挂在APB2总线最高72MHz。我一直习惯先把时钟树弄好再配外设不然外设时钟源是默认值看起来没毛病实际波特率或定时器周期跟预期对不上排查起来特别痛苦。2.2 USART1和DMA的配置细节接下来配置USART1Mode选Asynchronous异步收发。参数波特率115200数据位8校验None停止位1无流控。这是最常见组合具体按你外接模块的手册来。关键一步是切到DMA Settings标签页点Add添加USART1_RX和USART1_TX通道。RX通道选Circular循环模式这是整个方案的核心DMA搬完256字节后自动回到起始地址继续搬相当于一个自动续满的信箱。这样串口只要收到数据DMA永远处于准备接收状态不需要每收完一帧再去重启DMA接收。TX通道选Normal模式原因是发送通常是“想发一条就发一条”发完停止Normal正好匹配。如果你有长时间持续往外吐数据的需求可以考虑Circular但那属于固定缓冲区轮流发送的另类场景一般用不到。数据宽度都选Byte。DMA的数据宽度有Byte、HalfWord、Word三种USART的DR寄存器物理上是32位的但实际只用低8位选Byte最直接。DMA Priority我设的是Medium。USART接收的DMA优先级一般不用调太高串口本身波特率就在那不像高速ADC那样要求DMA必须在下个样本到来前抢走数据。如果项目里RTOS加一堆DMA通道在跑把USART_RX的DMA优先级调到High能在高负载下降低偶发漏数据的概率。2.3 中断使能哪个必须开哪个可以不开在NVIC Settings里USART1 global interrupt必须打开。很多人以为有了DMA就不需要串口中断了其实DMA只负责搬数据而“判断一帧结束”的空闲中断属于USART外设的它通过USART1全局中断上报CPU所以USART全局中断必须开。DMA1 Channel5 global interruptUSART1_RX对应的DMA通道中断可以不开。什么时候要开用Normal模式需要知道DMA缓冲区收满的时候才开Circular模式下DMA自己循环不需要额外唤醒CPU。USART1_DMA_TX通道的中断也不用开除非你想在DMA发送完成后再干点别的。优先级设置我的习惯USART1全局中断抢占优先级设2比严格实时中断比如电机控制里的定时器更新中断设0或1低比普通外设中断按键、软件定时器高。STM32的NVIC抢占优先级数值越小优先级越高这个别搞反了。2.4 一个容易被忽略的硬件细节电平转换如果你的板子是3.3V供电的STM32最小系统外接模块却是5V TTL电平——很多GPS模块、串口屏、老式传感器都这样——那要注意了。STM32F103的大多数IO号称5V容忍但“5V容忍”不等于“可以直接双向接5V设备”它有前提引脚要配置为开漏或输入模式且上拉到3.3V。如果用推挽输出直接怼5V器件的TTL输入短期没事长期有风险。最省心的做法是加一块双向电平转换电路BS170 MOS管经典接法或者现成的逻辑电平转换模块或者干脆用3.3V版本的USB转TTL串口模块比如CH340C支持3.3V输出。连接时还有一条铁律必须共地。USB转TTL模块的GND和STM32板的GND不连串口通信就会很玄学——时好时坏、偶发丢字节。我从不少新手那边排查串口问题十有八九最后都是共地问题。3. 核心代码实现从初始化到完整收一帧数据3.1 接收缓冲区与启动代码用HAL库CubeMX生成工程之后在usart.c和main.c里做几处修改就行。先在usart.c顶部或main.c全局区定义#define RX_BUF_SIZE 256 uint8_t rx_buf[RX_BUF_SIZE]; // 接收缓冲区 uint16_t last_ndtr RX_BUF_SIZE; // 上次DMA计数器值 volatile uint16_t rx_len 0; // 当前帧长度 volatile uint8_t rx_frame_ok 0; // 一帧接收完成标志然后在main()里串口初始化完成后调用一次启动函数HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUF_SIZE);就这一行。调用后DMA进入循环搬运状态。注意这一行只需要调用一次不要每收到一帧都调用——原因在第4章的坑里细说。3.2 空闲中断处理框架CubeMX生成的stm32f1xx_it.c里USART1_IRQHandler已经写好了void USART1_IRQHandler(void) { HAL_UART_IRQHandler(huart1); }HAL_UART_IRQHandler会处理RXNE、TC这些HAL接管的中断但它不处理IDLE空闲中断所以我们要在这个函数里补自己的判断void USART1_IRQHandler(void) { HAL_UART_IRQHandler(huart1); if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart1); uint16_t cur_cnt __HAL_DMA_GET_COUNTER(hdma_usart1_rx); uint16_t len; if (cur_cnt last_ndtr) { len last_ndtr - cur_cnt; } else { // DMA计数器回绕说明DMA绕过缓冲区顶部继续写了 len RX_BUF_SIZE - cur_cnt last_ndtr; } last_ndtr cur_cnt; if (len 0) { rx_len len; rx_frame_ok 1; // 主循环检测到这个标志后去处理 rx_buf } } }这里有几个细节值得展开说。为什么用last_ndtr做差值而不是直接RX_BUF_SIZE - cur_cnt因为Circular模式下DMA搬完256字节后计数器会重新从256开始递减写入地址绕回缓冲区开头。如果只收一帧用RX_BUF_SIZE - cur_cnt没毛病但接着收第二帧、第三帧时还拿256去减算出来的就是“上次处理点到当前点的累计偏移”包含了之前收的数据后续帧长度全错。记录上一次计数器值用差值计算才能得到“这一帧期间新增的字节数”。__HAL_UART_CLEAR_IDLEFLAG做了什么在HAL库里这个宏本质是读SR寄存器再写DR寄存器不同库版本稍有差异。读SR再读DR这个序列正是RM0008参考手册里明确给出的清除IDLE标志方法。如果你不用HAL宏直接操作寄存器标准清法是uint32_t tmp USART1-SR; tmp USART1-DR;先读SR再读DR顺序不能反也不能只读一个。len 0的判断为什么有必要开启空闲中断的瞬间或系统上电后串口线本来就处于空闲状态有可能立刻触发一次IDLE中断此时DMA一个字节都没收到cur_cnt等于last_ndtrlen算出来是0。不滤掉这个0主循环就会拿到一堆无意义的空帧。实测中上电后第一帧空帧的出现概率不低很多初学者被这个“多出来的零长度帧”搞懵过。3.3 主循环处理与协议解析在main()的主循环里处理方式很简单while (1) { if (rx_frame_ok) { rx_frame_ok 0; uint16_t len rx_len; // 此时 rx_buf[0] ~ rx_buf[len-1] 就是完整的一帧 process_frame(rx_buf, len); } }这种“中断里置标志 主循环轮询”的方式在裸机工程里最常见好处是协议解析不会阻塞中断也不会因为解析耗时过长而影响下一次空闲中断触发。如果协议解析本身很慢——比如里面有排序、加密运算——下一帧数据可能会在DMA缓冲区里堆积。一般只要解析时间远小于帧间隔就没事。帧间隔通常是毫秒级解析一个几十字节的数组是微秒级完全够用。3.4 DMA发送与printf重定向接收搞定之后发送就简单了直接调HAL_UART_Transmit_DMA(huart1, tx_buf, tx_len);发送和接收用的是两个不同的DMA通道——USART1_TX对应DMA1_Channel4USART1_RX对应DMA1_Channel5——不会互相冲突。不过想连续发两帧不同数据时要注意HAL_UART_Transmit_DMA调用后通道进入BUSY_TX状态上一次还没发完又调一次会返回HAL_BUSY。稳妥做法是维护一个tx_busy标志位利用HAL_UART_TxCpltCallback回调清除volatile uint8_t tx_busy 0; void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { tx_busy 0; } } uint8_t uart_send_dma(uint8_t *buf, uint16_t len) { while (tx_busy); // 等上一帧发完项目里最好加超时 tx_busy 1; return HAL_UART_Transmit_DMA(huart1, buf, len); }再提一嘴printf。很多人在串口调试时喜欢printf重定向思路是重写fputc通过HAL_UART_Transmit发送int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 100); return ch; }注意两点HAL_UART_Transmit是阻塞式发送超时时间我用100ms而不是HAL_MAX_DELAY免得串口异常时程序死等工程里要勾选MicroLIB否则标准printf的完整C库实现会占用更多Flash。串口初始化之前不要调用printf否则会卡在等待发送状态直到超时。当然如果你坚持要DMA发printf也不是不行但需要自己写一个环形发送缓冲工作量直接上一个台阶。调试打印场景阻塞式发送完全够用。我的习惯是正式通信数据走DMA发送调试打印走阻塞式。3.5 可以直接抄的最小工程流程把上面的内容串起来最小工程流程大概是int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_USART1_UART_Init(); HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUF_SIZE); while (1) { if (rx_frame_ok) { rx_frame_ok 0; process_frame(rx_buf, rx_len); } } }process_frame就是你的业务逻辑。到这里一个稳定可靠的不定长接收框架已经能跑了。4. 实测中踩过的坑从“能跑”到“稳定跑”这一节列几个我实际调这套方案时踩过的坑。有些坑调试器一抓一个准有些是项目跑好几天才偶发一次特别隐蔽。4.1 坑一HAL_UART_Receive_DMA只调一次别反复调我最早写代码时每收到一帧数据就在中断里调用一次HAL_UART_Receive_DMA想着“重置”一下接收。结果第二帧数据就收不到了。原因是Circular模式下DMA本来就在转不需要重置而HAL_UART_Receive_DMA内部有锁机制如果huart-RxState不是READY会直接返回HAL_BUSY。第一次调用后RxState就变成BUSY_RX再调就卡住了。如果这时候还顺手把DMA给Disable了接收就彻底停摆。正确做法启动一次之后永远不用再启动Circular模式的接收。如果用Normal模式每收满一帧DMA会停才需要在合适时机重新拉起来——那是另一套逻辑。4.2 坑二IDLE标志清不干净导致中断风暴如果中断里清了IDLE标志但清法不对——比如对SR寄存器直接赋值清零或者只清了一次但标志又被置起——结果就是串口中断被反复触发。程序看起来像卡死了实际是一直在跑中断主循环几乎没有执行时间。HAL库的__HAL_UART_CLEAR_IDLEFLAG在不同版本HAL库里实现不同。在F1的HAL库里它通常就是__HAL_UART_CLEAR_FLAG而后者在F1上的实现又经常被定义为对SR寄存器写清零。但STM32F103的USART SR寄存器按参考手册的说法很多标志是靠“读SR、再读DR”清除的直接写SR在某些场合并不能可靠清掉IDLE。我实际对比过直接USART1-SR ~USART_FLAG_IDLE之后IDLE标志偶尔还会再次置位改成读序列才消停。代码里别想当然去写零清标志跟着HAL宏走或者老老实实用读SR读DR。4.3 坑三循环模式下计数器回绕导致长度算错这是最隐蔽的坑也是我前面反复提“用last_ndtr算差值”的原因。假设缓冲区大小256DMA配置传输长度256。接收过程是这样的第一帧来了DMA从缓冲地址0开始写计数器从256递减到190空闲中断触发。cur_cnt190last_ndtr256差值66正确。处理完第一帧把last_ndtr更新为190。第二帧来了DMA继续从缓冲区偏移66处写计数器从190递减到170空闲中断触发。cur_cnt170差值20正确。如果偷懒每次都写len RX_BUF_SIZE - cur_cnt第一帧算66没错第二帧就变成86——包含了第一帧的66字节数据全错。还有一种极端情况两次空闲中断之间DMA绕过了缓冲区边界即收到的数据总量超过256字节且中间没触发空闲中断。此时计数器“回绕”上一次是50这次是240因为计数器重新从256开始递减。这种情况下cur_cnt last_ndtr差值算法要做回绕处理len RX_BUF_SIZE - cur_cnt last_ndtr。上面3.2节的代码已经把这个逻辑写好了。我想特别强调网上很多教程只给RX_BUF_SIZE - __HAL_DMA_GET_COUNTER这一个公式单帧演示时没问题项目一跑起来就莫名出现长度异常的数据。数据量小、帧不密可能一直遇不到但数据量一大、帧一密立即原形毕露。4.4 坑四上电瞬间的空闲中断开启空闲中断后如果RX线在上电瞬间已经处于空闲高电平IDLE标志很可能在初始化完成后不久就被置位触发一次“假中断”。这时候DMA一个字节都没收到rx_len等于0。处理方式就两个一是在中断里判断len 0就跳过二是设计启动时序——先调用HAL_UART_Receive_DMA再清一次IDLE标志最后再使能USART全局中断。CubeMX默认把USART全局中断在初始化时就打开了所以你要么接受第一帧可能为空的现实要么在main里临时关一下中断再开。我自己常用判断len0这条路简单不用动NVIC。4.5 坑五ORE过载错误USART有一个Overrun Error标志当接收数据还没被取走、新数据又到达时硬件会丢弃新数据并置位ORE。在DMA循环模式下DMA取数速度极快ORE按理说很少触发但有一种情况容易踩调用了HAL_UART_Receive_DMA启动接收后中途DMA被误禁用了——比如错误处理代码里不小心Disable了DMA——此时数据到了RDR寄存器没人取ORE就来了后续接收可能一直不正常。调试阶段可以在主循环里定期检查huart1.ErrorCode或__HAL_UART_GET_FLAG(huart1, UART_FLAG_ORE)一旦发现就执行清除并重启接收。HAL库里HAL_UART_ErrorCallback会通知你错误码CubeMX生成时它是weak函数直接重写即可不需要动原文件。4.6 坑六串口线没共地、USB转TTL模块质量差这不是代码层面的坑但实际项目里遇到率极高。STM32最小系统和USB转TTL模块之间除了TX、RX、GND三根线GND这根如果虚接现象就是“时好时坏”偶尔掉一个字节调试器看不出一点毛病。另外市面上有些便宜的FT232或CH340模块板载晶振精度一般数据量大时容易出现个别错帧。遇到这种问题先别急着怀疑代码用示波器或逻辑分析仪看波形最直接。如果没有逻辑分析仪也可以写一个简单的自发自收测试TX短接RX或者通过模块回环发一串规律数据看收回来是否一致。这能排除大部分硬件干扰因素。5. 换个思路Normal模式加传输完成中断什么时候更合适写完循环模式方案可能有人会问如果主机一次性发过来的数据量超过缓冲区大小怎么办比如DMA缓冲区设了256字节但对方一帧可能发512字节而且中间没空闲。这时候循环模式有点尴尬——空闲中断只在帧间出现数据量大的时候DMA绕了一圈后写的会覆盖先写的而我又没法在写完256字节后立刻得到通知。这种情况就该上Normal模式了。5.1 Normal模式的逻辑Normal模式下DMA传输完配置的长度后自动停止并触发传输完成中断TC。你可以利用TC中断做数据搬运DMA收满256字节时TC中断里先把缓冲区内容搬走或者置一个标志让主循环处理然后重新调用HAL_UART_Receive_DMA再启动一次接收。这样即使一帧数据有512字节也会被拆成“256字节 剩余字节”两段来处理。空闲中断依然保留用于处理不足256字节的短帧。这段逻辑需要CubeMX的NVIC里打开DMA1 Channel5全局中断然后在DMA中断服务函数里写void DMA1_Channel5_IRQHandler(void) { if (__HAL_DMA_GET_FLAG(hdma_usart1_rx, DMA_FLAG_TC5)) { __HAL_DMA_CLEAR_FLAG(hdma_usart1_rx, DMA_FLAG_TC5); // 第一段256字节已就绪置标志让主循环处理 seg_ready 1; } HAL_DMA_IRQHandler(hdma_usart1_rx); }注意Normal模式需要在处理完TC中断后重启接收重启前要把DMA的NDTR寄存器重新设为256因为Normal模式不自动重新加载。其实在F103上还有一种更巧的做法让空闲中断承载短帧判断同时打开Normal模式DMA的TC中断——这就是“双保险”。短帧靠空闲中断兜底长帧靠DMA传输完成中断分段接管。5.2 两种模式的横向对比维度Circular循环模式Normal正常模式启动一次后是否自动接收自动循环无需再启动收满后停止需在TC中断中重启空闲中断做帧尾检测配合差值算法优雅配合TC中断做长短帧处理缓冲区被写满自动覆盖可能丢旧数据触发TC中断可第一时间搬走数据代码复杂度低略高需要处理重启和分段适合场景帧长小于缓冲区、帧间有空闲帧长可能超过缓冲区、或不希望覆盖数据按我的习惯绝大多数通信场景——GPS NMEA语句、串口屏指令、各种传感器模块的AT响应——帧长都在几十到几百字节之间而且天然有帧间隔Circular循环模式最省心。只有确定要长时间连续传输大块数据时才需要换Normal模式。5.3 扩展思路乒乓缓冲如果系统对数据实时性要求很高甚至希望“这一帧在处理的同时下一帧已经在往别处写了”可以用双缓冲乒乓切换。本质是准备两个DMA缓冲区当前缓冲区收满或空闲中断触发后在中断里把DMA的存储器地址切到另一个缓冲区主循环同时处理当前缓冲区里的数据。一个重要的提醒切换DMA存储器地址需要先停止DMA通道修改CMAR寄存器HAL库里对应比如hdma-Instance-CMAR再重新启动。直接修改正在运行的DMA通道地址寄存器属于未定义行为实际跑起来多半出问题。我踩过一次——改地址前忘了停DMA结果数据一会儿在缓冲区A一会儿在缓冲区B完全随缘。加一行停止DMA的代码世界就正常了。这套DMA加空闲中断不定长接收的方案我从F103一路用到后来的F407、G030思路几乎没变过只是换了HAL库版本和外设地址。个人最深的感觉是刚接触时被“空闲中断加DMA”的概念唬住真上手才发现核心就三句话——DMA负责搬运空闲中断负责切帧计数器负责报长度。剩下全是配置细节和边界条件的处理。最后分享一个小技巧调试阶段把__HAL_DMA_GET_COUNTER的值和rx_len直接打印出来对比任何长度计算的偏差都是一目了然的。这个方法帮我少走了很多弯路。