STM32 DMA+空闲中断实现串口不定长接收的完整指南
1. 为什么要用DMA空闲中断做不定长接收1.1 传统接收方式的瓶颈写这篇东西的起因是最近调试一个串口通信模块数据帧长度不确定短的时候十几个字节长的时候几百个字节而且上位机下发频率还挺高。用传统的中断逐字节接收要么丢数据要么把CPU累得够呛后来改用DMA接收配合USART的空闲中断来判断一帧数据的结束才算真正解放出来。这篇文章就把这套方案从原理到代码、从踩坑到测试记录完整拆一遍希望能帮正在调串口的兄弟少走点弯路。先说传统的接收方式。很多新手一开始用的是单字节中断也就是在USART中断里每次收到一个字节就往数组里塞同时用一个变量计数。这种方式在小数据量、低波特率下完全能用但有两个明显的坑。第一波特率上去之后比如921600一个字节大概10us如果中断服务程序里做的事情稍微多一点比如做个CRC校验、写个全局状态、拷贝个数组很容易在下一个字节到来之前还没退出中断这时硬件接收寄存器里的数据就被覆盖了也就是丢字节。第二CPU一直在被频繁打断处理一个几百字节的长帧中断可能要触发几百次哪怕是空的串口中断也能把系统时序搅乱实时性根本没法保证。所以数据量一大单字节中断这条路基本走不通。还有一种做法是用定时器超时来判断帧结束比如每个字节进中断时重装定时器超时则认为一帧结束。这种方式确实能解决不定长的问题但代价是每个字节都要操作定时器而且字节间隔稍长一点就会出现误判尤其在波特率不高、发送端有延迟的时候调起来很烦。我当时也试过最后还是放弃了。真正靠谱的方案就是DMA搬运数据让外设直接把字节写进内存CPU完全不用管单个字节只在DMA搬运完成或总线空闲的时候做一次统一处理也就是标题里说的DMA收发加空闲中断不定长接收。1.2 DMA加空闲中断的思路核心这套方案的核心思路其实很简单接收的时候让USART的接收DMA把数据源源不断地写进一片缓冲区DMA计数器会随着字节到来自动减少当一帧数据发完之后总线上会出现一个字节时间的空闲电平USART就会触发IDLE空闲中断。在这个中断里我们只要读出DMA当前还剩多少字节没搬用缓冲区总长度减去剩余计数就知道这一帧实际收了多少字节然后拷贝出来处理即可。这样就做到了不定长接收而且无论帧长多大中断只在帧结束的时候触发一次CPU的压力极小。发送方向也一样把要发的内容放到一个数组里配好DMA通道设置好目的地址为USART的数据寄存器长度指定为数组长度然后触发一次DMA传输USART的发送硬件会自动把每个字节挪到移位寄存器发出去CPU也不用参与。DMA发送的好处是发送大批量数据时不会阻塞主循环坏处是发送一个比较短的命令时DMA初始化那些寄存器代码量相对大所以实际项目中我一般根据报文长短决定用DMA还是直接轮询这后面会细说。这套组合之所以经典还有一个重要原因STM32F103这颗芯片本身DMA控制器不多通道数也有限但USART1的收发DMA通道是独立分开的分别是DMA1的通道4发送和通道5接收配置起来非常清晰。配合空闲中断之后既能把数据搬移任务卸给DMA又能用硬件自动判定帧边界整体的软件结构会非常干净。下面开始说原理和配置。2. 几个必须理解透的原理2.1 USART的发送与接收机制STM32F103的USART是一个通用同步异步收发器核心是两个移位寄存器和一个数据寄存器DR。发送数据时你往DR写一个字节就好比把行李放上传送带硬件会自动把它挪到发送移位寄存器然后按波特率一位一位地送到TX引脚接收数据时RX引脚上的电平按波特率采样凑齐一个字节就放到接收移位寄存器再挪到DR同时置起RXNE接收寄存器非空标志。如果我们开启了RXNE中断那每个字节到DR都会触发一次中断这就是传统中断接收的源头。这里要注意DR寄存器在物理上是两个寄存器一个是发送DR一个是接收DR只是共用了同一个地址。读取DR会清掉RXNE标志写入DR会清掉TC标志。所以很多人刚学的时候发现读一下接收数据顺便就把标志清了其实这是硬件设计的巧妙之处。对于DMA来说它做的事情就相当于自动读DR和自动写DR不需要CPU去碰那个地址。2.2 DMA传输的本质DMA的全称是Direct Memory Access翻译过来就是直接存储器访问。它的本质就是在外设和内存之间搭一条独立于CPU的高速公路由一个控制器管理搬运工作。CPU只需要告诉DMA三件事源地址在哪、目的地址在哪、要搬多少。设定好之后DMA会自己一条一条地搬搬完或者达到指定数量后可以产生中断通知CPU。外设和内存之间的搬运与CPU运算可以并行进行这是它最大的价值。使用DMA时有几个关键参数必须理解清楚不然配置一定会出问题。第一个是传输方向对于USART接收数据方向是外设到内存外设地址固定内存地址递增发送则反过来。第二个是循环模式对应DMA_CircleMode开启后DMA计数器减到0时不会停而是自动重新装回初始值继续接收。这个模式对于串口不定长接收来说几乎必选因为只有这样DMA才能一直在后台待命接收任意时刻到来的字节。第三个是数据宽度串口是8位数据所以内存和外设都配成字节宽度如果是16位或者32位数据则要对应修改但注意外设地址的宽度要和实际寄存器一致。还需要提一点DMA每次传输完成可以触发传输完成中断即TCIF标志。但我们在空闲中断方案里不太依赖DMA的传输完成中断因为缓冲区的容量设置得比最大帧长要大正常情况下DMA不会传输完成只有数据填满了整个缓冲区才会触发。这也说明缓冲区容量的选择非常关键后面会专门讲。2.3 空闲中断的触发条件空闲中断英文叫IDLE interrupt是USART硬件提供的一个非常有用的中断源。它的触发条件是在接收完一个字节后总线保持高电平即空闲状态超过一个完整的字节时间。举个例子如果波特率是115200一个字节在总线上的时间是10.4us左右那么接收完某个字节后只要线路上持续高电平超过这个时间USART就会认为总线空闲置起IDLE标志。这个特性天生就适合用来做不定长帧的边界判定因为绝大多数串口协议里一帧数据内字节间是连续发送的字节间隔远小于一个字节时间而帧与帧之间总会留一点间隔。即使帧间隔很紧凑只要硬件上出现一个完整的空闲位IDLE就能捕捉到。这里有个细节需要注意IDLE标志是在接收过程中检测到空闲才置位的如果一直没数据它反而不会触发所以不用担心空闲状态下疯狂触发中断。清IDLE标志的方法在STM32F103上有个坑和清零RXNE类似需要先读USART_SR寄存器再读USART_DR寄存器顺序不能反。很多人直接用USART_GetITStatus判断然后忘了读DR结果IDLE标志一直不消失导致中断反复进入这是典型的新手问题。我在后面代码里会专门放一个清除操作实测有效。3. 工程配置全过程3.1 硬件与开发环境我做这套实验用的是最普通的STM32F103C8T6最小系统板8MHz晶振通过内部PLL倍频到72MHz。调试器是ST-Link V2用SWD四线方式连接SWDIO、SWCLK、GND、3V3这四根线就够了。串口用的是USART1也就是PA9TX和PA10RX通过USB转串口模块连电脑。如果你用的是其他型号原理一样只要注意DMA通道的映射关系就行。工程环境我用的是Keil MDK 5配合STM32标准外设库StdPeriph SPL来开发。现在很多人喜欢用CubeMX和HAL库但F103这颗老芯片用标准库写DMA和中断的寄存器逻辑更直观也更容易理解底层。如果你是用CubeMX自动生成的代码思路完全一致只要在生成的代码基础上把空闲中断处理和DMA计数器逻辑加上就行。开发工具版本方面我用的是Keil MDK 5.27编译器AC5标准库版本3.5。3.2 GPIO和USART初始化代码首先初始化GPIO把PA9推挽复用输出作为TXPA10浮空输入或者带上拉输入作为RX。这里有一个细节STM32F103的USART引脚必须配置为复用推挽输出AF_PP而RX则可以浮空输入因为RX默认是空闲高电平浮空没问题但如果你怕干扰外部加一个10k上拉更稳。然后是USART初始化波特率根据实际需求来我测试时用的1152008位数据无校验1位停止位。一定要开启USART的接收DMA请求和发送DMA请求否则就算DMA配好了也不会工作。开启的方式是调用USART_DMACmd一个传USART_DMAReq_RX一个传USART_DMAReq_TX。这个开关很多人会漏掉漏掉之后的现象就是DMA始终不搬运数据计数器不动百思不得其解。需要提醒的是开启USART接收缓冲区非空中断和空闲中断时要把空闲中断使能USART_ITConfig(USART1, USART_IT_IDLE, ENABLE)。这个中断在NVIC里属于USART1全局中断也就是IRQHandler所以还要把NVIC配置好。具体的GPIO、USART、DMA初始化代码如下我直接把工程里能跑的代码贴出来变量定义也一并给出。#define RX_BUF_SIZE 256 uint8_t usart1_rx_buf[RX_BUF_SIZE]; volatile uint8_t usart1_rx_last_pos 0; // 上一次帧结束位置 volatile uint16_t usart1_rx_frame_len 0; // 当前帧长度 volatile uint16_t usart1_rx_frame_start 0; // 当前帧起始位置 volatile uint8_t usart1_rx_wrap_flag 0; // 回绕标志 volatile uint8_t usart1_rx_frame_flag 0; // 帧接收完成标志 void USART1_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); } void USART1_Init(void) { USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; USART_InitStructure.USART_BaudRate 115200; USART_InitStructure.USART_WordLength USART_WordLength_8b; USART_InitStructure.USART_StopBits USART_StopBits_1; USART_InitStructure.USART_Parity USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, USART_InitStructure); // 使能IDLE中断 USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); NVIC_InitStructure.NVIC_IRQChannel USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); USART_Cmd(USART1, ENABLE); }这里我故意把NVIC优先级设为抢占优先级1、子优先级1具体数值根据你的工程来但建议串口中断优先级不要设得太低因为你还要在中断里做DMA搬运数据的处理处理不及时容易造成下一个字节覆盖的风险。3.3 DMA接收通道配置接下来是接收DMA的初始化。USART1_RX对应的是DMA1的通道5外设地址是USART1的数据寄存器地址内存地址是我们定义好的usart1_rx_buf数组内存地址方向是递增。传输模式必须配成循环模式这一点前面已经解释过。数据宽度8位。优先级可以设为High因为串口接收不想被其他DMA抢占。需要特别提醒的是DMA配置完成后要调用DMA_Cmd使能通道然后调用USART_DMACmd使能USART的接收DMA请求。两个使能一个都不能少。如果只使能了DMA_CmdUSART不会主动把数据往DMA里送如果只使能了USART_DMACmdDMA通道本身是关闭的也不会有数据搬运。这些都是我实际调试中踩过的坑。DMA_InitTypeDef DMA_InitStructure; void USART1_DMA_RX_Init(void) { RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel5); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)(USART1-DR); DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)usart1_rx_buf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize RX_BUF_SIZE; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode DMA_Mode_Circular; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel5, DMA_InitStructure); DMA_Cmd(DMA1_Channel5, ENABLE); USART_DMACmd(USART1, USART_DMAReq_RX, ENABLE); }这段代码配置好之后只要USART串口有数据进来DMA就会自动搬运到usart1_rx_bufCPU完全不用管。我们可以通过读取DMA_GetCurrDataCounter(DMA1_Channel5)来知道缓冲区还剩多少个空位用RX_BUF_SIZE减去这个值就是当前已经接收的字节数。这个逻辑在空闲中断里会用到。3.4 DMA发送通道配置发送DMA用DMA1的通道4数据方向是内存到外设。外设地址同样是USART1-DR内存地址是你要发送的缓冲区地址。发送DMA不需要循环模式用普通模式就行因为每次发完就停发送完成之后最好产生一个传输完成中断用于清理状态。如果不关心发送完成的时刻也可以轮询DMA的传输完成标志。我这里用了一个简单的全局变量来保存当前发送缓冲区的地址和长度方便发送函数使用。uint8_t usart1_tx_buf[128]; void USART1_DMA_TX_Init(void) { RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel4); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)(USART1-DR); DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)usart1_tx_buf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize 0; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode DMA_Mode_Normal; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel4, DMA_InitStructure); DMA_Cmd(DMA1_Channel4, DISABLE); USART_DMACmd(USART1, USART_DMAReq_TX, ENABLE); } void USART1_DMA_SendBytes(uint8_t *buf, uint16_t len) { // 等待上一次发送完成 while (DMA_GetFlagStatus(DMA1_FLAG_TC4) RESET); DMA_Cmd(DMA1_Channel4, DISABLE); DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)buf; DMA_InitStructure.DMA_BufferSize len; DMA_Init(DMA1_Channel4, DMA_InitStructure); DMA_Cmd(DMA1_Channel4, ENABLE); }这里需要注意我复用了全局的DMA_InitStructure其实工程里每个初始化函数都有自己的局部结构体发送函数这里最好也单独声明一份避免变量冲突。实际项目里我通常会做成一个结构体封装把DMA通道号、完成标志、缓冲区都包进去。4. 核心中断处理与帧解析4.1 空闲中断里的接收逻辑串口中断服务函数是整个方案的大脑。在这个函数里面我们需要先判断是RXNE中断还是IDLE中断。RXNE中断可以开着但实际处理可以很简单甚至只在必要的时候处理。我的做法是判断IDLE标志如果置位说明当前有一帧数据已经接收完接下来计算这一帧的长度置一个帧接收完成标志然后清除IDLE标志。RXNE中断在这里就不做处理因为DMA已经把数据拿走了。计算长度的方法是这样的。由于接收DMA是循环模式DMA的计数器从256开始递减每接收一个字节减1。帧结束的时候我们读一次DMA_GetCurrDataCounter(DMA1_Channel5)用256减去这个值得到一个当前总共已经收到的字节数pos。然后还要记录上一次帧结束时的位置last_pos。如果pos大于last_pos说明这一帧的数据在缓冲区里是连续的长度就是pos - last_pos如果pos小于last_pos说明缓冲区发生了回绕这一帧数据被分成了两段需要分开处理。void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { // 先清除IDLE标志读SR再读DR USART_ReceiveData(USART1); uint16_t cur_pos RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); // 检查是否有新数据 if (cur_pos usart1_rx_last_pos) { return; } if (cur_pos usart1_rx_last_pos) { usart1_rx_frame_start usart1_rx_last_pos; usart1_rx_frame_len cur_pos - usart1_rx_last_pos; usart1_rx_wrap_flag 0; } else { // 回绕数据分为两段 usart1_rx_frame_start usart1_rx_last_pos; usart1_rx_frame_len RX_BUF_SIZE - usart1_rx_last_pos cur_pos; usart1_rx_wrap_flag 1; } usart1_rx_last_pos cur_pos; usart1_rx_frame_flag 1; } }但严格来说如果数据出现回绕我们必须要先把第一段数据拷贝出来再拼接第二段主循环需要知道起始地址和两段长度。上面代码只是为了说明主思路实际工程里我会维护一个帧起始索引和wrap标志然后在主循环里做处理确保不会丢数据。这里还要注意如果cur_pos恰好等于last_pos说明这一帧没有新数据可能是空闲中断误触发可以直接忽略不置标志位。4.2 接收帧数据在主循环中的处理帧接收完成标志置位后主循环就可以检测到标志并处理。处理时要注意一个问题要从缓冲区里把数据拷贝出来否则下一次接收会覆盖。特别是当缓冲区出现回绕时必须分两段拷贝。拷贝完数据后再决定怎么解析比如判断帧头帧尾、校验和、地址码等。下面给出一个通用的处理示范。uint8_t frame_cache[256]; void ProcessReceivedFrame(void) { if (usart1_rx_frame_flag 0) return; usart1_rx_frame_flag 0; if (usart1_rx_frame_len 0) return; if (usart1_rx_wrap_flag 0) { memcpy(frame_cache, usart1_rx_buf[usart1_rx_frame_start], usart1_rx_frame_len); ParseFrame(frame_cache, usart1_rx_frame_len); } else { uint16_t first_len RX_BUF_SIZE - usart1_rx_frame_start; uint16_t second_len usart1_rx_frame_len - first_len; memcpy(frame_cache, usart1_rx_buf[usart1_rx_frame_start], first_len); memcpy(frame_cache[first_len], usart1_rx_buf, second_len); ParseFrame(frame_cache, usart1_rx_frame_len); usart1_rx_wrap_flag 0; } }这个处理虽然简单但已经能应付大部分不定长串口协议了。实际项目里如果有更复杂的协议比如多个帧连续到来我建议在空闲中断里只做“记录位置和长度”主循环里用环形缓冲管理不要一帧一帧地拷贝效率会更高。不过对于大多数MCU应用把帧拷贝到临时缓存再解析逻辑简单且不容易出错完全够用。4.3 DMA发送中断和状态管理DMA发送通道配好之后发送完成会产生传输完成标志TCIF。如果想在发送完成的时候做点什么比如置一个标志给主循环可以开启DMA的传输完成中断。开启方法是在DMA_Init之前调用DMA_ITConfig(DMA1_Channel4, DMA_IT_TC, ENABLE)然后在DMA1_Channel4_IRQHandler中断服务函数里判断DMA_GetITStatus(DMA1_IT_TC4)。我这里建议如果不是特别在乎发送完成的精确时刻干脆不用发送完成中断直接用轮询标志。因为USART1的DMA发送速度非常快一个几百字节的帧在115200波特率下也不过几十毫秒轮询并不会卡太久。如果发送缓冲区很大或者波特率很低再考虑用中断方式避免长时间阻塞主循环。用了中断之后你还需要在中断里清掉TCIF标志并且注意不要在DMA传输过程中关闭通道否则会产生错误。状态管理方面核心是确保发送函数不会同时被主循环和中断调用。最简单的方法是一个全局发送忙标志发送函数入口判断如果正在发送就直接返回或者等待发送完成中断里清标志。对于裸机程序来说这个标志位已经足够可靠了。4.4 如何测试和验证写完代码后我是用电脑上的串口助手轮询发送不同长度数据来测试的。测试要覆盖三种情况固定短帧比如5字节、长帧比如接近缓冲区大小的200字节、连续快速多帧中间不带人工延时。最好的办法是写一个简单的上位机脚本或者用串口助手的定时发送功能每100ms发一帧不同长度的数据然后看单片机解析结果和回显的数据长度是否一致。回显测试很有用。你可以让单片机收到一帧数据后把这一帧的长度和内容原样发回电脑电脑端对比发送内容和返回内容是否完全一致。我当时就是这么测的发现了一个非常隐蔽的bug是回绕处理时拼接顺序反了导致数据错位。后来我把回绕分支单独打印出来才找到原因。所以测试时一定不要只测正常情况要把缓冲区回绕、短帧连续快速到来这些边界情况都测一遍。反正串口助手点几下就能复现总比产品上线后出问题强。5. 常见问题与排查技巧实录5.1 DMA接收数据错位或丢失表现收到的数据总是少几个字节或者数据内容整体偏移。原因大概率是DMA配置里缓冲区大小和实际帧长不匹配或者DMA没有配成循环模式。如果你用的是普通模式DMA传输完设定的长度后会自动停止此时再来数据就不会被搬运空闲中断也可能误判导致丢数据。另外如果USART_DMACmd没有使能接收请求或者DMA_Cmd没有使能通道DMA根本不会搬运数据会留在DR寄存器里接着被下一个字节覆盖表现也是丢数据。排查方法很简单在空闲中断里读DMA计数器的值看它是否正常递减。如果计数器不动先检查USART_DMACmd有没有开再检查DMA控制寄存器里的EN位是不是1。如果计数器在动但数据错位就要怀疑循环模式或者地址递增配置了。可以用调试器在内存窗口里直接看usart1_rx_buf对比串口发的内容非常直观。5.2 空闲中断误触发或反复进入表现没有发数据但是程序频繁进入USART1_IRQHandler。出现这个问题十有八九是IDLE标志没有清除干净。前面强调过清IDLE标志必须读SR寄存器再读DR寄存器。如果你用了USART_GetITStatus(USART1, USART_IT_IDLE)判断但忘记调用USART_ReceiveData(USART1)去读DRIDLE标志会一直为1中断就会一直进。还有一种情况是RX引脚悬空导致总线电平不稳定误判空闲。解决方法是给RX引脚加上拉电阻或者至少配置为带上拉的输入模式。还有一种误触发比较隐蔽当DMA缓冲区满了DMA循环模式会自动从头开始覆盖数据但此时如果你正在处理上一帧数据缓冲区内容已经被新数据覆盖而空闲中断并不会因为这个覆盖而触发。这种情况下你可能会觉得数据“反复进入”但实际上是因为缓冲区太小覆盖得太快。解决方法是增大缓冲区或者加快主循环处理速度必要的时候开启DMA传输完成中断进行保护。5.3 DMA发送卡死或发不完表现调用发送函数后串口完全没有数据发出来或者只发出一部分。先检查DMA通道对不对USART1_TX是DMA1_Channel4。如果初始化DMA用的通道5接收通道也被占了就会冲突现象就是发送完全无反应。再检查有没有开启USART的发送DMA请求也就是USART_DMACmd(USART1, USART_DMAReq_TX, ENABLE)。最后检查发送函数里DMA_Cmd之前有没有赋值正确的BufferSize。如果BufferSize为0DMA不会传输任何东西。很多时候“只发一部分”是因为DMA传输过程中你调用了第二次发送此时DMA还在忙直接改寄存器导致波形乱掉。解决办法是在发送函数入口等上一次传输完成。如果发送频率非常快建议用中断方式并且加上环形发送缓冲不要在阻塞等待上浪费时间。5.4 缓冲区大小怎么选这是很多人忽视的问题。接收缓冲区的大小必须大于一帧数据的最大可能长度否则缓冲区满了会覆盖数据。对于一般modbus、AT指令、自定义协议256字节通常够用。如果你的协议允许单帧数据达到512字节甚至1KB缓冲区就要相应扩大到1KB或2KB。注意STM32F103C8T6的RAM只有20KB如果缓冲区设得太大会挤占其他变量空间所以要根据实际需求权衡。建议的做法是先统计实际项目的最大帧长加上一定余量比如最大帧长是200字节那么缓冲区设置256或者512比较合适。同时主循环一定要把数据处理完再等下一次帧到达不要让处理时间超过帧间隔否则缓冲区还是会被覆盖。还可以考虑使用双缓冲区一个用于DMA接收一个用于主循环处理交替使用这样即使处理时间稍长也不会丢数据。但这个方案比较复杂一般在数据量比较大的时候才需要上。5.5 实测中的其他坑除了以上几个典型问题还有一些零碎但很影响体验的坑。比如在USART1_IRQHandler里千万不要做耗时的操作比如调用printf、长时间循环、甚至软件延时。中断里只做标志置位和简单的长度计算所有协议解析一律放到主循环。否则波特率一高中断响应不及时轻则丢字节重则整个系统卡死。还有一个是DMA引脚的波特率问题。如果你用的是STM32F103C8T6输出DMA时钟频率不能超过36MHz内部已经自动处理不需要额外配置。但如果你用其他系列比如F407或者MSPM0DMA时钟、外设时钟的关系不同最好仔细对照参考手册不要盲目照抄F103的代码。另外如果你后面对接的是GD32、AT32或者HC32这些国产替代芯片它们的USART寄存器大部分兼容STM32的但DMA通道映射和中断向量可能会变比如GD32的DMA通道分配虽然基本兼容但AT32的DMA控制字格式有区别HC32则完全是另一套架构。所以移植的时候一定要以目标芯片的用户手册为准至少要确认DMA通道号和寄存器位定义。我身边就有同事从F103移植到AT32时DMA初始化照抄结果通道号对不上折腾了半天。这个经验供大家参考。6. 使用心得与扩展建议6.1 性能实测对比我实际测试了一下在115200波特率下用传统的单字节中断接收1000字节数据CPU几乎全程都在处理中断偶尔还会因为不小心关闭中断而丢字节。而换成DMA加空闲中断之后CPU在接收过程中基本是零负担只有在每帧结束的时候才进入一次中断做几行运算主循环完全不受影响。如果一帧是100字节那么接收一帧数据CPU中断运行时间总长大概只有几微秒相对于逐字节中断的功耗和资源占用差距非常明显。发送方向也一样用DMA发送1KB数据发送期间CPU可以继续做别的任务比如跑屏幕显示、传感器采集串口发送完全后台完成。如果数据量再大一些比如通过串口做固件升级或者日志传输这个优势就更明显了。所以这套方案不仅仅是“能跑”而是真正能把串口这件事从CPU的任务里剥离出去。6.2 扩展方向这套代码后续可以扩展的方向很多。首先是可以把它改造成一个通用的串口驱动把USART1换成USART2、USART3不同的DMA通道和中断向量都要对应修改。我建议把这些配置集中到一个结构体里用宏定义或者函数指针把串口号、DMA通道、缓冲区地址都抽象出来以后接多个外设就不用写好几遍类似的初始化代码了。第二个扩展方向是加一个环形缓冲区在空闲中断里只记录位置和长度主循环通过环形缓冲区接口取数据。这样即使主循环处理慢了数据也不容易丢可以扛住更高速率的连续收发。第三种扩展是把DMA接收和协议解析结合起来比如modbus的RTU模式它本身就是以3.5个字符时间作为帧间隔这正好可以完全替代定时器超时判断移植起来非常顺。还有一个我一直想推荐的做法把接收缓冲区做成“半满中断全满中断”的双缓冲模式。不过这需要关闭循环模式改用普通模式并且在半满和全满中断里切换缓冲区复杂度会高不少。如果你的串口数据流量极大这种方法可以保证DMA永远不休眠CPU永远有最新数据处理是工业级串口方案的标准做法。但如果你和我一样只是做做设备联调上面这套简单方案已经绰绰有余了。6.3 最后的一点经验算了一下从第一次配置DMA到彻底稳定运行我前后大概花了三天其中大部分时间都消耗在“为什么DMA计数器不动”和“为什么IDLE标志清不掉”这两个问题上。回头看其实都是最基础的细节但正因为原理没吃透才走了那么多弯路。如果你也是刚接触这块我的建议是先别急着抄代码用串口助手加调试器把DMA计数器、IDLE标志的行为摸清楚再动手写业务逻辑你会发现自己写得比想象中顺畅得多。另外想提醒的是STM32F103虽然老但在很多低成本产品里依然是绝对的出货主力。它的DMA和USART配合得好不好直接决定一个设备的通信稳定性。这套DMA加空闲中断的方案不仅仅是省CPU资源更是在为后续更复杂的项目打基础。你把这一套吃透了后续再用HAL库、再换其他芯片思路都是通的。最后送给大家一个小技巧调试串口DMA的时候强烈建议在串口助手里开启hex显示并且打开时间戳功能。这样你能看到每一帧数据到达的具体间隔判断IDLE中断的触发是否和你预期一致。有了这个工具很多玄学问题都能变成看得见的数据排查起来会快很多。希望这篇实战记录能帮你少踩几个坑。

相关新闻

【A6 多层持久化记忆系统】向量语义记忆的生成、去重与元数据标签体系

【A6 多层持久化记忆系统】向量语义记忆的生成、去重与元数据标签体系

【A6 多层持久化记忆系统】向量语义记忆的生成、去重与元数据标签体系在构建面向复杂业务场景的工业级自主智能体(Autonomous Agent)时,记忆系统是区分“无状态问答玩具”与“具备持续进化能力数字员工”的核心分水岭。许多开发团队在早期 Po…

2026/10/4 20:07:57 阅读更多 →
【A7 Agent 生产评测与观测】生产环境任务成功率自动化打分流水线构建

【A7 Agent 生产评测与观测】生产环境任务成功率自动化打分流水线构建

【A7 Agent 生产评测与观测】生产环境任务成功率自动化打分流水线构建在生成式 AI 与大模型 Agent 迈入工业级落地的深水区后,研发团队遭遇的最大瓶颈往往不是“怎么写出 Agent”,而是“如何客观、自动化且可量化地衡量 Agent 在真实生产环境到底跑得怎么…

2026/10/4 20:07:57 阅读更多 →
【嵌入式外设精学】Day16|ADC:分辨率、采样时间、平均滤波

【嵌入式外设精学】Day16|ADC:分辨率、采样时间、平均滤波

【嵌入式外设精学】Day16|ADC:分辨率、采样时间、平均滤波 今天解决:读数抖、通道串扰、静态值偏,分别怎么处理? 目录 问题 2. 原理 3. 代码 4. 误区 5. 自测 6. 答案 1. 问题 同一电压码字乱跳多通道轮流读时互相影…

2026/10/4 20:07:57 阅读更多 →

最新新闻

Python人脸表情识别全流程:数据准备、CNN训练到ONNX部署

Python人脸表情识别全流程:数据准备、CNN训练到ONNX部署

简介:这是一套基于Python实现的人脸表情识别项目资源,面向具备Python基础、希望深入计算机视觉与后端开发场景的开发者。项目围绕人脸68个关键点定位展开,涵盖眼睛、眉毛、鼻子、嘴唇等部位的特征提取,这些关键点的准确检测是表情…

2026/10/4 22:19:47 阅读更多 →
插件机制详解:从 failed to load plugins 报错到通用排查思路

插件机制详解:从 failed to load plugins 报错到通用排查思路

在搜索框里敲下“plugins”的人,多半不是想研究这个英文单词的拼写,而是正在某个软件里跟插件较劲:要么看到了failed to load plugins这种报错,要么在问“某个工具里的 plugins 是干什么的”,要么就是刚接触插件机制&a…

2026/10/4 22:18:47 阅读更多 →
人体姿势识别YOLOv8预训练模型:从环境搭建到关键点提取

人体姿势识别YOLOv8预训练模型:从环境搭建到关键点提取

简介:这是一份可直接运行的YOLOv8人体姿势识别预训练模型资源,面向Python开发者和计算机视觉初学者,解决从零搭建姿势识别环境门槛高的问题。包内含1个pt格式的yolov8s-pose模型文件、1个完整Python运行脚本及2张效果展示图片,压缩…

2026/10/4 22:18:47 阅读更多 →
STM32 C++实战:超声波测距+LCD+USB虚拟串口系统整合

STM32 C++实战:超声波测距+LCD+USB虚拟串口系统整合

哟哟哟,咱们还差活滴——看到这个标题别笑,这是我写完上一期之后最真实的内心活动。前面几期我们拿着 STM32 和 C 把点灯、按键扫描、串口回显这些基础模块都过了一遍,但心里一直不踏实:独立 demo 能跑,不等于能把它们…

2026/10/4 22:18:47 阅读更多 →
ins5699驱动源码集成实战:从设备树到sysfs的数据链路

ins5699驱动源码集成实战:从设备树到sysfs的数据链路

简介:面向嵌入式开发者的 INS5699 实时时钟芯片驱动源码与集成方法,适用于需要精确时间基准的产品项目,适合驱动工程师参考,可作为内核移植与调试的参考资料。压缩包内共包含两个文件,整体仅 45KB,其中一份…

2026/10/4 22:18:46 阅读更多 →
嵌入式C++实战:STM32从VSCode到CAN总线排坑全记录

嵌入式C++实战:STM32从VSCode到CAN总线排坑全记录

这个系列写到第六篇,我本来觉得离收工不远了。结果周末把功能清单摊开一看,哟哟哟,咱们还差活滴——这句带着点方言味的感叹,就是我当时的真实状态。差哪些活?VSCode里的C工程还只能编译不能顺畅调试;超声波…

2026/10/4 22:18:46 阅读更多 →

日新闻

KT148A语音芯片外挂8002D功放的工程实践指南

KT148A语音芯片外挂8002D功放的工程实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 1:00:58 阅读更多 →
LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 1:00:58 阅读更多 →
ARM架构深度解析:从RISC设计理念到交叉编译实战

ARM架构深度解析:从RISC设计理念到交叉编译实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 1:00:58 阅读更多 →

周新闻

KT148A语音芯片外挂8002D功放的工程实践指南

KT148A语音芯片外挂8002D功放的工程实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 1:00:58 阅读更多 →
LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 1:00:58 阅读更多 →
ARM架构深度解析:从RISC设计理念到交叉编译实战

ARM架构深度解析:从RISC设计理念到交叉编译实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 1:00:58 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 11:40:45 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 9:43:54 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 20:14:29 阅读更多 →