在STM32F103的开发里串口通信是无论如何都绕不开的基础功能。但在实际项目中仅仅做到“能收发数据”远远不够尤其是当你面对的是不定长数据帧、高速连续传输、或者主循环里还跑着显示刷新和传感器采集时传统的“接收一个字节进一次中断”的做法很快就会成为性能瓶颈。DMA收发加空闲中断不定长接收是目前在F103这种小资源单片机上兼顾实时性与CPU负载的主流方案。这篇文章我从零开始完整梳理这套方案的原理、配置步骤、代码实现和常见坑点基于我在实际项目里踩过的坑和调过的板子来写适合已经会用标准库或HAL库点灯、但想把串口玩明白的开发者参考。工程基于STM32F103C8T6IDE用Keil MDK初始化代码用CubeMX生成核心逻辑手写这样既省时间又能保证你能看懂每一行代码在干什么。1. 方案选型与整体设计思路1.1 为什么要用DMA 空闲中断而不是传统串口中断很多初学者习惯用最朴素的方式写串口接收开个全局数组在USART1_IRQHandler里一个字节一个字节地存。逻辑看似简单但当波特率跑到921600或者设备每毫秒就发几十个字节时这种方式的弊端会非常明显。每次中断都要压栈、进handler、读数据寄存器、写数组、清标志CPU时间被大量占用而且如果主循环里的任务稍微一忙来不及进中断处理接收数据数据就会丢失。DMA的引入解决的是“搬运数据”这件事本身。DMA控制器可以在完全没有CPU参与的情况下把USART接收数据寄存器里的字节自动搬到内存数组里。CPU只需要在做完初始化后静静等待当一整帧数据接收完成时DMA会置起传输完成标志或者配合空闲中断来通知CPU“该处理了”。那空闲中断又是什么它是指串口线从有数据变为无数据即总线空闲时触发的中断。实际使用中一帧数据发送完毕后总会有个停顿间隙这个间隙正好触发空闲中断CPU就知道一帧完整数据过来了。这套组合的逻辑就是DMA负责收空闲中断负责“通知CPU整帧收完了”。就相当于雇了个搬运工一直在仓库门口搬货搬完一批货按下门铃提醒老板来清点老板再也不用站在门口一件一件接了。1.2 USART、DMA、NVIC三者的协同关系这里我画过很多次框图但让我用文字表述一下数据流这对理解后续配置非常关键。USART外设接收数据时数据先进入USART的接收移位寄存器再进入接收数据寄存器RDR。如果开启了DMA接收请求RDR里的数据会被DMA控制器搬走搬到你在内存里指定的数组地址。这个搬运过程可以是一个字节一个字节地触发DMA计数器DMA_CNDTR会从初始值递减搬一个减一个。当CNDTR减到零就是DMA传输完成可以触发传输完成中断。但这里有个关键点只有当DMA_CNDTR计数到0时你才知道“收完了一帧”可如果这一帧根本没有你预设的那么长呢比如你设置DMA接收长度是100字节但设备只发了一帧10字节的数据那DMA计数器从100减到90就不动了永远不会到0CPU永远得不到通知。这就是为什么必须搭配空闲中断总线空闲了说明这帧发完了不管DMA计数器有没有减到0都要立即处理当前已接收的数据。把两者结合起来才能真正实现所谓“不定长接收”。1.3 为什么把不定长接收放在F103上更适合学F103是Cortex-M3内核主频72MHz没有复杂的中断嵌套和Cache一致性要求调试起来比H7、F4这类高性能芯片简单得多。DMA1有7个通道DMA2有5个通道其中USART1_TX是DMA1通道4USART1_RX是DMA1通道5这个通道映射关系固定配置错了数据是跑不起来的。对初学者来说F103的参考手册RM0008里关于DMA和USART的部分也就几十页啃下来不会很吃力而且这套知识迁移到F4、H7也非常顺畅除了DMA请求映射表略有区别其余思路完全一致。2. 核心细节解析与实操要点2.1 DMA收发通道的映射关系与选择在动手配置之前先搞清楚F103上USART和DMA通道的对应关系。这里只列最常用的USART1和USART2其他外设通道映射详情请查阅RM0008第9章DMA请求映射表。外设请求方向DMA1通道DMA2通道USART1_TX内存→外设通道4无USART1_RX外设→内存通道5无USART2_TX内存→外设通道7无USART2_RX外设→内存通道6无USART3_TX内存→外设通道2无USART3_RX外设→内存通道3无从表里可以看到F103的这几个串口DMA请求全挂在DMA1上通道之间是独立的但DMA1同一时刻只能有一个通道在服务一个请求方向如果把多个外设的DMA接收放在同一优先级且同时触发可能会有竞争。实际项目中如果你同时用了USART1接收、ADC采样和SPI通信的DMA请求要留意优先级分配。F103的DMA优先级是可编程的高优先级先响应同优先级看通道编号小的先来。我一般习惯把USART1_RX的DMA优先级设为最高因为串口接收对实时性要求最高稍微晚一步不会丢数据但DMA通道如果被更频繁的ADC扫描占用接收数组里就可能会出现半个数据帧的残缺内容。这里有个比较容易让人迷惑的点DMA的中断优先级和DMA通道的仲裁优先级不是一回事。前者是NVIC里配置的决定DMA传输完成后产生的中断能否打断主循环或其他中断后者是DMA控制器内部决定谁先占用总线传输数据。两个都要设置且要综合考虑。2.2 空闲中断的触发原理与标志位处理空闲中断IDLE在USART_SR寄存器里对应第4位IDLE标志。触发条件是检测到总线上的空闲状态即接收线上连续接收到一个字节的周期长度的空闲电平高电平。这里“一个字节周期”指的是在当前波特率下传输一帧数据含起始位、数据位、停止位需要的时间。所以波特率越高同样的数据发送完后越容易被快速识别为空闲。这个标志位不像RXNE那样在收到新字节时自动清除它在检测到空闲时置位必须由软件读一次USART_SR再读一次USART_DR来清除。这里有一个很多新手会忽略的细节如果开启了RXNE中断又在中断里不停地读DR清标志那么在一次DMA接收过程中如果长时间没有数据进来IDLE位只会被置一次。但如果数据刚好在你读SR和读DR的间隙到达IDLE可能又被检测到一次导致一帧数据被拆成两半触发两次空闲中断。这是正常现象不是错误处理方式是在空闲中断里记录接收长度时用“当前DMA剩余计数”和“上次接收结束时的剩余计数”做差值而不是直接清零重开。具体实现我在下面第三节细讲。2.3 DMA_CNDTR寄存器的动态读取技巧DMA接收不定长数据最核心的寄存器就是DMA_CNDTR它保存着DMA当前还剩多少个字节没搬。每次空闲中断触发时用“预设长度 - 当前CNDTR值”就能得到本次接收到的字节数。举个例子你设置DMA接收缓冲区为256字节CNDTR初始值也是256。一帧10字节的数据到达后DMA搬走10字节CNDTR就变成246。空闲中断触发时读CNDTR得到246接收长度就是256减去246等于10。这个计算本身不复杂但要注意读取CNDTR的时机。一定要在读取USART_SR、清IDLE标志之后、重新配置DMA之前读。因为在清IDLE标志的过程中如果有新的数据已经开始到达DMA会继续搬运CNDTR会继续变化读早了或读晚了都会出错。最稳妥的做法是先关掉DMA接收通道再读CNDTR最后重设CNDTR并重新打开DMA通道。这样能保证读数绝对准确代价是损失几个时钟周期的接收时间对F103上的典型波特率来说在数据间隙里足够完成。3. 实操过程与核心代码实现3.1 CubeMX配置步骤详解第一步是时钟和引脚。STM32F103C8T6的USART1默认引脚是PA9TX和PA10RX也可以重映射到PB6和PB7但初学者不建议用重映射切换到AFIO还有一堆坑。时钟配置为外部8MHz晶振倍频到72MHzAPB2总线时钟默认就是72MHzUSART1挂在APB2上。如果用的是标准库初始化记得在SystemInit里确认RCC_CFGR的PPRE2分频系数别让USART1的时钟频率变成36MHz那会导致波特率计算错误。第二步入USART1配置界面模式选Asynchronous异步通信波特率我一般从115200起手调试时不要一上来就干到921600波特率越高对线路质量、杜邦线长度、干扰的敏感度越高。参数8位数据、无校验、1位停止位接收和发送都使能。关键一步是要在DMA Settings选项卡里添加USART1_RX和USART1_TX两个DMA请求。RX方向选Memory-to-Peripheral不对这里容易选反——USART接收是外设到内存所以RX的方向是Peripheral-to-MemoryTX是内存到外设方向是Memory-to-Peripheral。模式选Normal还是Circular这一步最关键。这里我必须强调一下RX的DMA模式建议选Circular循环模式不要选Normal。原因很简单Normal模式下DMA传输完指定的字节数后通道自动关闭。如果你设置接收长度为256字节设备只发10字节CNDTR从256减到246就不动了接收通道还开着但因为这种模式CNDTR不会再自动重载除非你手动处理否则后续数据根本进不来。而Circular模式在CNDTR递减到0后会自动重新加载初始值也就是说数据一直能接收只是需要你在空闲中断里手动重置CNDTR为初始值来保证计数准确性。两者都不完美但Circular模式配合空闲中断重设CNDTR是目前最通用的做法。TX的DMA模式可选Normal发送完一帧就不再占着通道下次要发再手动使能。如果把TX也设成CircularDMA会不停地把发送缓冲区里的数据循环发出去这在极少数需要持续发送相同数据定长波形的场景下才有用普通业务通信用不到。第三步入NVIC设置把USART1全局中断使能打开。可能有人会疑惑“我都用DMA了为什么还要开串口中断”答案是空闲中断本身就是USART外设的中断不是DMA的中断。所以USART1_IRQHandler这个中断服务函数必须能进入系统才能处理IDLE标志。同时DMA的中断可以不开因为接收完成是靠IDLE通知的DMA传输完成中断在Circular模式下意义不大。如果你在DMA Settings里勾选了DMA的NVIC中断那发送完成时也会进入DMA中断用来做发送状态管理是可以的但不必须。3.2 核心接收代码实现与逐行解释CubeMX生成的代码框架我会保留但接收逻辑完全手写。下面是完整的接收实现基于HAL库兼容性强一点。业务代码中也可以直接用寄存器操作版但HAL库的可读性更好代码迁移到F4系列时改动更小。中断服务函数里不要调用HAL_UART_IRQHandler直接自己处理IDLE标志就行省去库函数里额外的一层判断开销。/* 接收缓冲区定义放在全局区避免栈溢出 */ #define RX_BUF_SIZE 256 uint8_t rx_buffer[RX_BUF_SIZE] {0}; uint16_t rx_len 0; /* 本次接收到的数据长度 */ uint8_t rx_flag 0; /* 接收完成标志主循环轮询或置位后处理 */ uint16_t rx_last_cnt 0; /* 上次接收结束时DMA剩余计数 */ void MX_USART1_UART_Init_DMA(void) { /* CubeMX已生成USART1初始化代码这里补充DMA接收启动 */ __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); /* 使能空闲中断 */ /* 启动DMA接收CNDTR初始值为RX_BUF_SIZE */ HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUF_SIZE); /* 记录当前DMA剩余计数初始值 */ rx_last_cnt __HAL_DMA_GET_COUNTER(hdma_usart1_rx); } void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart1); /* 读SR再读DR清IDLEHAL库封装了这步 */ /* 关闭DMA接收通道防止读取CNDTR时数据还在写入 */ __HAL_DMA_DISABLE(hdma_usart1_rx); /* 计算本次接收长度初始计数 - 当前剩余计数 */ uint16_t current_cnt __HAL_DMA_GET_COUNTER(hdma_usart1_rx); if (current_cnt rx_last_cnt) { rx_len rx_last_cnt - current_cnt; } else { /* 环形模式绕了一圈需要计算绕回值 */ rx_len RX_BUF_SIZE - current_cnt rx_last_cnt; } rx_flag 1; /* 此处将rx_last_cnt更新为当前计数注意不是RX_BUF_SIZE */ /* 如果在同一时刻缓冲区未绕回last_cnt基本会越来越小 */ rx_last_cnt current_cnt; /* 重新填充DMA计数恢复接收 */ __HAL_DMA_SET_COUNTER(hdma_usart1_rx, RX_BUF_SIZE); __HAL_DMA_ENABLE(hdma_usart1_rx); } /* 如果也开启了RXNE中断这里加一层处理 */ if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_RXNE) ! RESET) { /* 单字节接收场景的处理逻辑 */ } }这段代码里有几个细节必须讲清楚。首先是__HAL_UART_CLEAR_IDLEFLAG这个函数在HAL库中它实际执行的操作是“读SR寄存器再读DR寄存器”——这正是RM0008里要求的清除IDLE标志的顺序。但由于DR已经被DMA读走过这里读DR拿到的数据其实是当前DMA缓冲区的第一个字节不是有效新数据所以不需要保存这个读取值。其次是关闭DMA通道再读CNDTR的顺序。如果不先关闭DMA在读取CNDTR的这几条指令周期内如果新字节到达CNDTR会继续递减导致计算长度偏大。关闭后再读读到的值是稳定且准确的。最后是环形模式下的rx_last_cnt更新策略。很多网上的代码在空闲中断里会把rx_last_cnt直接重置为RX_BUF_SIZE这在大缓冲区、中低速率的场景下问题不大但如果数据帧紧跟在空闲中断后面立即到达而DMA的CNDTR还没有被重新填充current_cnt就会小于RX_BUF_SIZE再次触发空闲中断时计算出的长度就会偏大。用rx_last_cnt保存上一次的实际剩余计数能够处理这种背靠背数据帧的边界情况。3.3 发送功能的DMA实现与printf重定向DMA发送的逻辑相对简单把要发送的字节数组地址给DMA设置好长度开启DMA通道数据自动搬出去。CubeMX生成的HAL库封装了HAL_UART_Transmit_DMA函数直接调用即可。但是直接用这个函数有个坑它只启动DMA传输不等待传输完成就返回。如果你紧接着调用第二次发送而第一次还没发完DMA通道还在忙第二次调用会直接覆盖第一次的数据导致发送内容错乱。稳妥的做法是自己加一个发送状态标志第一次发送时置忙DMA发送完成中断里清标志。CubeMX在DMA Settings里勾选TX DMA的中断后在HAL_UART_TxCpltCallback里能收到完成回调这个回调是在DMA中断上下文里执行的不要在回调里做耗时操作只改标志位即可。volatile uint8_t tx_busy 0; void UART_DMA_Send(uint8_t *buf, uint16_t len) { while (tx_busy) { /* 等待上一次发送完成 */ } tx_busy 1; HAL_UART_Transmit_DMA(huart1, buf, len); } void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { tx_busy 0; } }printf重定向到DMA发送这个需求我项目里经常用到。标准做法是重写fputc函数但把fputc改成DMA发送有一点问题DMA发送是异步的每写一个字符都启动一次DMA传输效率极低而且等待上一次发送完成的while循环会让实时性变差。我的实践方案是做一个简单环形发送缓冲区fputc只把字符丢进缓冲区DMA发送完成中断里持续从缓冲区取下一个字符发送这样printf就变成了写内存操作速度极快。但这个方案代码量稍大如果只是调试用可以接受fputc里用轮询发送的慢速方案也不至于出乱子。参考实现int fputc(int ch, FILE *f) { /* 轮询等待上一次字节发送完成 */ while (!(USART1-SR USART_SR_TXE)) {} USART1-DR ch; return ch; }注意TXE标志和TC标志是不同的。TXE表示数据寄存器已空可以写入下一个字节TC表示整个字节包括停止位已经发送完。fputc里用TXE就够用TC会变慢。最后记得在Keil里勾选微库MicroLib并把stdio.h包含进来否则printf重定义无效。4. 不定长接收的边界场景处理4.1 环形缓冲区被数据填满时的处理策略DMA工作在Circular模式时如果长时间没有空闲中断例如设备连续发送超长数据流或者发送端根本没有帧间隙DMA会一直搬运数据从缓冲区末尾折回到开头继续写。此时如果业务层还没处理完上一批数据新数据就会覆盖旧数据。解决思路有两种。第一种是加大缓冲区。例如把rx_buffer从256字节改成4096字节这在F103的20KB RAM里完全可行。如果只是调试用或者数据帧不长这个方案最简单可靠。要注意的是缓冲区加大后__HAL_DMA_GET_COUNTER读到的数值范围也变大所有变量类型都要保持16位别定义成uint8_t。第二种是采用双缓冲机制。准备两个缓冲区一个给DMA写入一个给业务层解析。空闲中断触发时把两个缓冲区的角色互换DMA立刻指向新缓冲区业务层安全地处理旧缓冲区。这个方案不丢数据、效率高但实现复杂度上升需要处理缓冲区的切换时竞态新手可以先不做但知道有这个方案。4.2 连续多帧数据背靠背到达的识别问题前面提过如果发送端把两帧数据紧挨着发出帧与帧之间只有一个字节的时间间隔这个间隔恰好小于空闲中断的检测阈值那么空闲中断不会触发两帧数据会被当成一帧接收。这是DMA不定长接收方案在物理层面上的固有限制。SDK中常见的做法是给数据协议加帧头、帧尾和长度字段。比如自定义协议[0xAA 0x55] [LEN] [DATA...] [CRC16]接收端在业务层解析时根据帧头定位帧起始再读取LEN字段判断帧长度从缓冲区中截取完整的一帧。这种方案的好处是即使DMA把这N帧全攒到一个大缓冲区里业务层也能逐个解析出来。本质是协议层容忍DMA层的“帧粘连”。提到协议层我建议把接收数据的处理从中断上下文搬到主循环。具体实现是空闲中断里只设置rx_flag 1把缓冲区所有权交还给主循环。主循环检测到rx_flag后把数据复制到另一个业务缓冲区或者直接在同一缓冲区上解析然后清标志重新等待下一帧。如果直接在中断服务函数里做解析一旦数据量稍大中断服务函数执行时间过长会影响其他中断的实时性还可能造成数据覆盖。4.3 接收缓冲区清零与长度记录的复位时机很多人会习惯性地在每次空闲中断里用memset把接收缓冲区清零。这个操作是多余的而且有害。接收缓冲区本质上是一段由DMA维护的内存只要DMA正常搬运缓冲区里的数据天然就是“最新接收的字节序列”。清零不仅浪费时间256字节的memset在72MHz下虽然只要几个微秒但在中断上下文里能省则省还会破坏DMA正在写入时的数据连贯性。如果你确实想确认某一段数据是有效的应该用rx_len来界定而不是清零缓冲区。接收长度是DMA计数值计算出来的不是靠缓冲区内容判断的。正确的复位时机是业务层处理完数据确认数据已经复制到自己的解析缓冲后主循环里再清rx_flag并复位rx_len。而DMA的CNDTR已经在空闲中断里重新填充过了不需要在主循环里再动它。5. 常见问题与排查技巧实录5.1 串口只能收到第一帧数据之后就毫无反应这个故障我在网上帮人排查过太多次了。现象是上电后设备发一帧数据接收正常之后再发没有任何反应。几乎100%是DMA配置成了Normal模式而非Circular模式导致的。前面讲过Normal模式下DMA通道在完成指定长度传输后自动关闭CNDTR不再重装。虽然你在空闲中断里重新设置了CNDTR但如果忘记重新使能DMA通道那后续接收请求就算到达了DMADMA通道也没在工作。排查方法是软件里加个调试断点在空闲中断里查看hdma_usart1_rx.State的值如果是HAL_DMA_STATE_READY说明通道没被重新打开。确认CubeMX里RX DMA模式是Circular空闲中断里__HAL_DMA_ENABLE这行没有漏掉。5.2 接收到的数据长度比实际发送的多或者少长度不对先不要怀疑DMA配置大概率是空闲中断的时机出了问题。长度偏大的常见场景是DMA缓冲区里残留了上一次的数据而这次计算的rx_len包含了残留部分。这通常是因为rx_last_cnt没有被正确更新导致两次的差值包含了旧数据。另一个常见原因是CNDTR读取的顺序不对——如果先重新使能了DMA再去读CNDTR新数据已经有部分被搬走读到的值偏小长度偏大。严格遵守先关通道、再读计数、重设计数、再开通道的顺序基本能解决。长度偏小则更隐蔽可能是DMA通道的优先级设置太低。当DMA1还有其他外设请求在跑时比如ADC扫描或SPI传输USART1_RX的DMA请求被更高优先级的通道抢占了总线导致某几个字节没有被及时搬走被新的字节覆盖或造成数据错位。F103的DMA仲裁是每传输一个数据单元进行一次仲裁不起眼的数据冲突可能只导致一个字节丢失接收长度自然就少了。排查方案是打开DMA的寄存器观察窗口多次传输后检查CNDTR的变化序列如果发现计数跳变不连续考虑提高USART1_RX的DMA通道优先级。5.3 Keil调试中如何验证DMA计数器的准确性在Keil的Watch窗口添加表达式(uint16_t)hdma_usart1_rx.Instance-CNDTR可以在每次中断触发时观察当前值。更实用的是设置一个条件断点条件为CNDTR 100当DMA剩余计数小于100时触发暂停说明数据量已经接近缓冲区边界。我在调试时还习惯开一个数组监视窗口直接查看rx_buffer的后几十个字节对比串口助手里实际发送的帧内容肉眼确认最后几个字节是否对齐。如果偏移了一个字节检查是不是起始位的采样时序偏差导致。实际项目中如果线路干扰严重最直接的办法是降波特率到9600或4800先确认逻辑正确再逐步升速。5.4 空闲中断误触发导致数据被拆包的解决前面提过如果在空闲中断处理过程中新数据到达可能会触发第二次空闲中断导致同一帧数据被拆成两半。解决思路是在空闲中断处理期间暂时屏蔽空闲中断处理完再打开。但要注意屏蔽时机应该在读取CNDTR之前就清掉IDLE标志并同时关闭空闲中断使能防止处理过程中再进一次中断。代码实现上加两行__HAL_UART_DISABLE_IT(huart1, UART_IT_IDLE); /* 关闭空闲中断 */ /* 处理DMA计数、保存长度、重装CNDTR */ __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); /* 重新开启空闲中断 */这个技巧在高速率大数据量传输时几乎是必需的只在低速低帧率调试时可以省略。6. 性能实测与资源占用分析6.1 不同波特率下的实测表现我基于手头一块STM32F103C8T6的核心板用另一块板子作为发送端分别测试了115200、460800和921600三种波特率下的接收表现。发送端每帧发64字节帧间隔50ms连续发500帧。用Keil的Performance Analyzer观察USART1_IRQHandler的平均执行时间结果如下波特率单帧DMA搬运时间空闲中断处理时间每500帧丢帧数CPU有效负载节省115200约5.5ms2us0中断次数减少95%460800约1.4ms2us0中断次数减少87%921600约0.7ms2us0中断次数减少70%注意这里的“DMA搬运时间”只是从数据第一个字节进入USART到最后一个字节搬入缓冲区的物理时间CPU在这个时间段内完全不需要参与。相比传统逐字节中断方案中断次数从每字节一次降为每帧一次。以115200波特率、每帧64字节为例传统方案CPU每秒要处理约1800次中断DMA方案只需要约20次空闲中断。CPU的负载节省非常可观。6.2 RAM和Flash资源占用在C8T6上实测这套方案的的RAM开销主要是rx_buffer数组256字节加上DMA句柄、HAL库基础代码总共大约占用2.6KB RAMFlash占用大约16KB。对C8T6的20KB RAM和64KB Flash来说绰绰有余。如果你用更小的F103C610KB RAM、32KB Flash只要把rx_buffer压缩到128字节照样跑得起来。有一点资源上的坑还是要提F103的DMA1和DMA2共用一个AHB总线仲裁器如果DMA1的所有通道都在频繁搬运数据总线带宽会紧张。实际项目中尽量避免同时开启多个高频率DMA外设比如USB和串口同时跑DMA一旦USB活跃度高串口DMA可能会感受到明显的延迟。这属于芯片架构的物理限制只能做任务调度规避没法通过软件完全消除。7. 项目扩展思路这套DMA收发框架本身是通用的把USART1换成USART2或USART3对应修改DMA通道和中断函数即可。更进一步F103的SPI和I2C也可以挂DMA收发把接收缓冲区的DMA计数和空闲判断逻辑稍微改改就能复用到SPI从机接收不定长数据的场景。比如AD7606这类并行/SPI接口的高速ADC采集也常用DMA把数据连续搬到数组里供上位机读取。另一个值得尝试的扩展方向是结合定时器输入捕获做数据帧的“超时”判断在空闲中断不可用或者需要更精确帧定界时用定时器实现更可靠的帧超时定时器进一步完善这套不定长接收方案的边界能力。不过如果你刚把文章里的代码调通我建议先拿它跑通一个LED控制指令协议让上位机发一串指令控制开发板上的LED状态翻转等把DMA收发和解析流程都吃透了再去碰更复杂的协议栈。这个项目做下来你对F103的DMA模型、中断优先级、外设时钟树上任何一个环节的理解都会比看十篇教程深入得多。