简介:一套基于STM32H743的串口DMA与空闲中断完整工程示例,面向需要处理不定长串口数据、希望降低CPU占用率的嵌入式开发者。工程采用HAL库构建,覆盖串口参数配置、DMA通道选择与分配、空闲中断回调、数据搬运与错误处理等完整链路,并附带编译产物与烧录文件,可直接参考或迁移到实际项目。压缩包共141个文件,以.c/.h源码、.o编译中间文件及.hex/.elf可执行文件为主,同时包含.ioc工程配置和.map链接映射,便于对照外设初始化与内存布局,整体体积仅1.25MB,轻量实用。已有5743人学习下载,适合中高级STM32开发者,尤其对正在评估H7系列多DMA控制器资源分配、或需实现不定长帧高吞吐收发的场景很有帮助。通过这份示例,可快速掌握串口空闲中断触发时机、DMA接收模式选择与溢出处理等关键细节,有效减少调试弯路。
1. 项目定位:把串口接收从"每字节打断CPU"换成"DMA+空闲中断"的完整方案
调试一块STM32H743板子时,某开发者把串口波特率从115200一路调到921600,原本逐字节中断的接收方式开始大面积丢包,CPU占用率也直奔30%以上。换成"stm32h743串口DMA+空闲中断"这套组合后,同样波特率下CPU占用降到几乎为零,数据帧一次不丢。这个项目做的就是这样一套完整落地:从DMA与串口初始化、DMAMUX请求映射,到空闲中断里用DMA计数器差值取走不定长帧的环形缓冲区逻辑,全部是可直接编译的H743工程代码。适合正在用H7系列做串口通信、需要高波特率下稳定收包,或想把串口接收从CPU中断里彻底解放出来的开发者。拿到代码改一下引脚和波特率,就能当通信底座用。
2. 技术选型与架构设计:为什么DMA+空闲中断对H743更划算
2.1 三种串口接收方案的取舍:逐字节中断、定长DMA、空闲中断
先看常规的两种做法。第一种是逐字节中断,每个字节到达时触发一次USART中断,在中断里读DR寄存器并写入自己的接收缓冲区。115200波特率下每秒11520个字节,也就是每秒11520次中断;如果波特率拉到2Mbps,每秒就是20万次中断。H743主频480MHz虽然扛得住,但中断频次越高,CPU被占用的比例越高,更重要的问题是:一旦代码某段关了中断,或者中断优先级安排不当,串口数据就直接丢了。第二种是DMA定长接收,配置接收长度N,DMA搬够N个字节才产生一次传输完成中断。这种方案对长度固定的Modbus帧、传感器数据包很好用,但大多数业务场景是帧头不同、长度不定,定长DMA根本不知道这一帧到底有多少字节。
第三种就是本项目采用的DMA+空闲中断:DMA以循环模式持续把数据搬进内存缓冲区,CPU完全不参与搬运;当总线上超过一个字节时间没有新数据时,USART产生一次空闲中断,这时CPU只需要去读DMA的当前计数值,和上次记录的计数值做差,就能完整算出这一帧在缓冲区里的位置和长度。这种方案对不定长协议最友好,而且中断次数等于帧数而不是字节数,CPU开销与波特率基本解耦。H743外设数量不少,串口有8个,如果每个串口都逐字节中断,中断优先级分配会非常挤,何况还要跑RTOS、处理显示和算法。把搬运交给DMA,CPU只在帧边界出现时醒一下,实时性压力小很多。这也是我会在H7上优先推荐这套方案的原因。
2.2 H743的DMA与DMAMUX:请求号映射是理解一切的前提
STM32H743和F1/F4在DMA上的最大区别是加了一层DMAMUX,也就是DMA请求复用器。在F1上,USART1_RX的DMA请求固定绑定在DMA1的某个通道上,换通道还得查表;在H7上,外设请求先汇入DMAMUX,再由DMAMUX分配给DMA1或DMA2的某个通道。这意味着代码里要多做一件事:给DMA句柄指定Request请求号。很多从F1迁移到H7的工程收不到数据,问题就出在这个请求号没配,或者配到了别的外设请求上。
H743上做串口接收一般用DMA1或DMA2,它们各有8个通道(Channel0~7),资源相对充足,多个串口可以各自占用一个通道互不干扰。需要特别注意的是:DMA1/DMA2可以访问AXI SRAM(0x24000000起)和SRAM1~4,但不能访问DTCM RAM(0x20000000起)。这个限制后面避坑章会专门讲。做接收时DMA通道关键要配成Circular循环模式:计数器减到0后自动重装,地址绕回缓冲区起点,配合空闲中断才能实现不定长连续接收。如果是Normal模式,搬完指定长度就停了,一帧结束后DMA不再工作,每次都要人工重启,麻烦得多。
2.3 数据通路设计:从RX引脚到协议解析函数的完整链路
把整条链路按数据流方向拆开看:RX引脚电平进入USART移位寄存器,移位完成后数据进入USART数据寄存器DR。DMA检测到RX请求后,把DR里的字节搬进内存缓冲区,同时NDTR计数器减1。缓冲区是1024字节,DMA用Circular模式,写到末尾自动回到开头。当RX线上空闲超过一个字节时间,USART置起IDLE标志并触发空闲中断。中断服务程序读回NDTR当前值,结合上次记录的值算出增量,增量就是这一帧的字节数;缓冲区里对应的那段内存就是这一帧的原始字节。
理解整个链路的关键是NDTR的语义:NDTR表示DMA还剩多少字节要搬,它从初始值递减。因此"上次NDTR减当前NDTR"就是这段时间DMA从串口搬走的字节数。如果差值为负数,说明指针绕过了缓冲区末尾,需要按环形缓冲区公式换算。所有收帧逻辑都围绕这个差值展开,核心代码部分会把公式拆开讲。还有一个容易被忽略的点:缓冲区是共享的,上一帧和下一帧在内存里是连续排布的,处理上一帧时不能让下一帧的数据覆盖还没读走的区域。好在空闲中断的触发频率等于帧频率,处理函数只要足够快,读完就更新last_ndtr,下一帧从新位置写入就不会有覆盖问题。这也是为什么中断里建议只做"取数据加置标志",真正的协议解析放到主循环去做的原因。
3. 核心代码实现:初始化、空闲中断与环形缓冲区收帧逻辑
3.1 串口初始化:H743的UART参数与时钟配置
引脚用USART1的默认复用PA9/PA10,代码基于STM32CubeMX生成的框架,只保留关键部分。
// 串口句柄:HAL库中UART和DMA句柄分开定义,再用LINK绑定 UART_HandleTypeDef huart1; DMA_HandleTypeDef hdma_usart1_rx; #define RX_BUF_SIZE 1024 uint8_t usart1_rx_buf[RX_BUF_SIZE]; void MX_USART1_UART_Init(void) { huart1.Instance = USART1; huart1.Init.BaudRate = 115200; huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_TX_RX; huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; huart1.Init.OverSampling = UART_OVERSAMPLING_16; HAL_UART_Init(&huart1); }这段配置没有太多可变的地方,但有个容易忽略的参数:OverSampling。默认16倍过采样兼容性最好,如果波特率特别高,比如3Mbps以上,可以切到8倍过采样,但抗干扰能力会弱一点,建议先在16倍下跑通再优化。USART1的底层初始化在HAL_UART_MspInit里完成,包括开启时钟、配置GPIO复用功能、设置中断优先级:
void HAL_UART_MspInit(UART_HandleTypeDef* uartHandle) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_USART1_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin = GPIO_PIN_9 | GPIO_PIN_10; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Pull = GPIO_PULLUP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; GPIO_InitStruct.Alternate = GPIO_AF7_USART1; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); HAL_NVIC_SetPriority(USART1_IRQn, 5, 0); HAL_NVIC_EnableIRQ(USART1_IRQn); }GPIO_AF7_USART1这个宏是H743数据手册的AF复用表里定的:PA9/PA10的USART1功能在AF7。如果换到别的引脚,需要对照复用表改这个宏。NVIC优先级当前设为5,如果工程里同时跑着几个中断,这个值可以根据实际紧张程度调整,但建议空闲中断优先级不要放太低,否则高负载下可能错过帧边界。
3.2 DMA与DMAMUX:把USART1_RX请求挂到DMA1通道
H7的DMA配置比F1多一个Request字段,它就是DMAMUX的请求号,这是H7特有的配置项:
void MX_DMA_Init(void) { __HAL_RCC_DMA1_CLK_ENABLE(); __HAL_RCC_DMAMUX1_CLK_ENABLE(); hdma_usart1_rx.Instance = DMA1_Channel0; hdma_usart1_rx.Init.Request = DMA_REQUEST_USART1_RX; // DMAMUX请求号 hdma_usart1_rx.Init.Direction = DMA_PERIPH_TO_MEMORY; // 外设 -> 内存 hdma_usart1_rx.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址USART_DR固定 hdma_usart1_rx.Init.MemInc = DMA_MINC_ENABLE; // 内存地址递增 hdma_usart1_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_usart1_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_usart1_rx.Init.Mode = DMA_CIRCULAR; // 循环模式,配IDLE的关键 hdma_usart1_rx.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma_usart1_rx); __HAL_LINKDMA(&huart1, hdmarx, hdma_usart1_rx); }逐个说参数含义。Direction是外设到内存,串口接收只有这一个方向,串口发送则是反向的DMA_MEMORY_TO_PERIPH。PeriphInc关闭是因为串口数据寄存器DR的地址固定不变,每次从同一个地址读数;MemInc开启是因为要往内存缓冲区里连续写数据。数据宽度全部按BYTE设置,串口按字节传输,没必要让DMA做半字或字的拼装。Mode用DMA_CIRCULAR,这是整套方案的核心——DMA永远不停,计数器减到0自动重装,缓冲区形成环,空闲中断负责在帧边界"截取"数据。Priority按需调整,如果一个DMA通道同时服务多个外设,高优先级能减少被抢占导致的数据延迟。
__HAL_LINKDMA是HAL库的宏,作用是把hdma_usart1_rx挂到huart1的hdmarx成员上。之后调用HAL_UART_Receive_DMA时,库内部才能从这个成员找到对应的DMA句柄。如果漏掉这一条,串口DMA收发会直接卡住,连错误回调都进不来,这是很常见的低级翻车点。
3.3 启动DMA接收与空闲中断:核心收帧逻辑
在主程序初始化末尾做两件事:启动DMA接收,记录初始NDTR,并打开空闲中断。
void UART1_StartRx(void) { HAL_UART_Receive_DMA(&huart1, usart1_rx_buf, RX_BUF_SIZE); last_ndtr = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); }HAL_UART_Receive_DMA只负责把DMA启动起来,数据到了并不会产生回调,因为循环模式没有"传输完成"这个概念。__HAL_DMA_GET_COUNTER读取DMA当前的NDTR值,记录下来作为上一次的基准点。__HAL_UART_ENABLE_IT打开空闲中断,之后RX线上出现超过一个字节时间的空闲就会触发中断。
中断服务函数里,前半段先让HAL库处理串口自身的事件,比如发送完成、各种错误,后半段专门处理空闲中断:
void USART1_IRQHandler(void) { HAL_UART_IRQHandler(&huart1); if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); HandleUart1Idle(); } }清空闲标志要注意:__HAL_UART_CLEAR_IDLEFLAG在H7上是直接写ICR寄存器清除标志位,不需要像F1那样先读SR再读DR。这段代码依赖HAL库头文件提供的宏,不要在旧工程里拿F1的写法硬套。
重点在HandleUart1Idle里怎么从环形缓冲区把这一帧完整抠出来:
void HandleUart1Idle(void) { uint16_t cur_ndtr = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); uint16_t len; uint16_t start; if (cur_ndtr <= last_ndtr) { // 常规情况:DMA计数器递减,没绕回缓冲区开头 len = last_ndtr - cur_ndtr; start = RX_BUF_SIZE - last_ndtr; } else { // 绕回:缓冲区从头又被写了一段,需要按环形公式计算 len = RX_BUF_SIZE - last_ndtr + cur_ndtr; start = RX_BUF_SIZE - last_ndtr; } if (len == 0) { last_ndtr = cur_ndtr; return; } // 数据可能跨过缓冲区末尾,拆成两段交给处理函数 if (start + len <= RX_BUF_SIZE) { ProcessRxFrame(&usart1_rx_buf[start], len); } else { uint16_t part1 = RX_BUF_SIZE - start; ProcessRxFrame(&usart1_rx_buf[start], part1); ProcessRxFrame(&usart1_rx_buf[0], len - part1); } last_ndtr = cur_ndtr; }逻辑说明。cur_ndtr是这次空闲中断时DMA计数器的当前值,last_ndtr是上一次中断记录的值,两者都是NDTR,也就是DMA还剩多少字节没搬。DMA每搬一个字节,计数器减1,所以last_ndtr - cur_ndtr就是这一段时间新搬进来的字节数。正常情况下,帧数据量小于缓冲区剩余空间,计数器持续递减,走第一个分支。如果这一帧写入的位置越过了缓冲区末尾,DMA自动绕回开头继续写,计数器从某值重新往下减,这时cur_ndtr >= last_ndtr,按环形公式RX_BUF_SIZE - last_ndtr + cur_ndtr计算长度。
数据起点统一用RX_BUF_SIZE - last_ndtr计算。可以这样理解:上一次中断时DMA写到了位置P,而NDTR等于RX_BUF_SIZE - P,所以P等于RX_BUF_SIZE - last_ndtr。这一帧数据就是从P开始、长度为len的一段。如果P + len超过缓冲区大小,就把这段数据从环绕点拆成两段处理,保证处理函数拿到的每一段在内存里都是连续的。代码里的last_ndtr必须定义为全局volatile变量,因为它在中断和主循环两侧都被访问,编译器优化时容易出问题。
3.4 帧解析与缓冲区的配合:一个可替换的ProcessRxFrame
ProcessRxFrame是数据唯一的出口,这里用一个最简单的协议做示例:1字节帧头0xAA,1字节有效数据长度N,N字节数据,1字节校验和(所有字节累加取低8位)。
void ProcessRxFrame(uint8_t *buf, uint16_t len) { uint8_t sum = 0; uint16_t i; // 中断上下文只做拷贝,协议解析丢给标志位通知主循环 if (frame_lock == 0) { memcpy(frame_buf, buf, len); frame_len = len; frame_lock = 1; // 主循环消费完后清0 } }这里的细节是:空闲中断频率等于帧频率,理论上如果主循环处理够快,直接在这个函数里做解析也可以。但稳妥起见,中断里只做一次内存拷贝和标志位置位,真正的协议解析放在主循环里检查frame_lock,这是H743高波特率下不丢帧的根本保证。frame_lock的作用是防止主循环还没消费完,下一帧又覆盖进来;如果加了锁但一直没人消费,新帧会被丢弃,这也倒逼应用层保证消费速度。这个函数是所有二次开发的入口,后面想换协议、加过滤、转发的逻辑都可以在这里扩展。
4. 避坑排查:串口DMA接收最容易翻车的5个问题
4.1 收几帧后串口彻底罢工,IDLE中断不再触发
现象:通电后前几帧收发正常,过一会儿串口像死了一样,再也不进空闲中断,复位后又能工作一会儿,循环往复。
原因:USART检测到ORE(Overrun Error)错误。DMA搬运不及时或线路有干扰时,DR寄存器里的数据还没被DMA读走就来了新字节,产生溢出错误。HAL库的HAL_UART_IRQHandler在收到ORE后会调用HAL_UART_ErrorCallback,但默认实现里什么都不做,如果不重新启动DMA,接收链路就断了。
解决:使能串口全局错误中断,在ErrorCallback里显式停掉并重新启动DMA接收,同时清掉错误标志。常见做法是:
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { HAL_UART_DMAStop(huart); UART1_StartRx(); } }注意必须先停再启,顺序反了可能停在Half/Full传输完成状态,导致后续DMA不工作。
4.2 数据全是零或错乱:DMA缓冲区分配到了DTCM
现象:串口能收到数据,但缓冲区的数据全是0或错乱,仿真内存窗口看到的数值和串口实际发的不一致。
原因:H743的DTCM区域从0x20000000起共128KB,这块内存只有CPU内核能访问,DMA1/DMA2访问不了。CubeMX生成的链接脚本有时会把部分变量分配到DTCM段,如果数组定义不小心被放进去,DMA根本写不进去,CPU读到的永远是初始值。
解决:给DMA缓冲区显式指定到DMA可访问的RAM段。优先用默认的.bss,通常链接到AXI SRAM 0x24000000区域,或者直接加段属性:
__attribute__((section(".ram_d1"))) uint8_t usart1_rx_buf[RX_BUF_SIZE];.ram_d1是CubeMX链接脚本里AXI SRAM的段名,这块区域DMA能访问。如果工程是自定义链接脚本,先确认缓冲区地址落在0x24000000~0x2407FFFF或SRAM1~4区间,避开DTCM。
4.3 开了D-Cache后数据错乱:H7缓存一致性问题
现象:不开缓存一切正常,一开D-Cache,收到的数据偶尔出现旧值、错位,或者帧长度解析对不上。
原因:DMA是总线主设备,直接写内存;CPU读数据时先查D-Cache。如果DMA刚写入的新数据在Cache里对应的行还是旧数据,CPU读到的就是陈旧内容。H7的D-Cache在启动代码里经常被默认开启,很多人第一次接触H7时忽略了这一层。
解决:两个办法二选一。最简单的是用MPU把缓冲区所在地址配成Write-Through或Non-Cacheable。另一个办法是在每次空闲中断取完数据后,无效化对应cache行:
SCB_InvalidateDCache_by_Addr((uint32_t *)buf, len);注意长度要对齐到32字节cache line,缓冲区首地址也建议32字节对齐,否则Invalidate可能会丢掉相邻行的新数据。
4.4 一帧被拆成两次空闲中断:发送端字节间隔导致拆包
现象:接收端收到的帧被拆成两段,整个协议解析失败,但单独看每一段数据又是完整的。
原因:空闲中断的定义是总线上空闲超过1个字节时间。如果发送端两批字节之间的间隔超过一个字节时间,比如发送函数一次发了一半,隔几十毫秒再发另一半,接收端就会认为这是两帧。这种情况在用printf逐段发送、或者上位机分两次write时特别常见。
解决:协议设计上给帧加帧头和总长度字段,应用层按长度做粘包和等待,而不是完全依赖空闲中断判定帧边界。更激进的做法是把空闲判定时间阈值拉长,用定时器辅助:收到第一个字节后开启定时器,超时N个字节时间才判定帧结束,而不是一看到IDLE就收帧。
4.5 一开-O2优化就丢帧:volatile与其他优化陷阱
现象:编译优化从-O0切到-O2后,接收出现偶发丢帧,或者last_ndtr更新异常,帧长度计算时对时错。
原因:last_ndtr这个变量在中断和主循环两边都在读写,编译器在-O2下可能把它优化进寄存器缓存,导致一方改了值另一方看不到。缓冲区如果被声明为局部变量,优化后甚至可能被彻底挪走。
解决:跨中断访问的变量统一加volatile,缓冲区定义为全局数组,并保持在整个工程生命周期内不被重新定义。项目里的last_ndtr正是这样处理的:
volatile uint16_t last_ndtr;顺带一提,只要用了DMA加环形缓冲区,所有共享数据都要警惕"谁在写":DMA在写缓冲区,CPU在读缓冲区,这是天然的共享内存模型。缓冲区本身加不加volatile影响不大,但索引和计数值这些跨上下文变量必须加。
5. 工程落地:从CubeMX生成到跑通完整收包链路
5.1 CubeMX配置要点与最小工程结构
这个方案用CubeMX生成基础工程最快。需要配置的点:USART1工作在异步收发模式,波特率先填115200跑通再往上加;在DMA Settings里添加USART1_RX,Direction选Peripheral to Memory,Mode选Circular;NVIC里打开USART1全局中断。生成代码后,把第3章的DMA初始化代码合并进MX_DMA_Init,把空闲中断处理函数加到USART1_IRQHandler,再在main函数最后调用UART1_StartRx。
一个可复用的最小工程目录大致是这样:
stm32h743_uart_dma_idle/ ├─ Core/ │ ├─ Inc/ │ │ ├─ main.h │ │ ├─ usart.h │ │ └─ dma.h │ └─ Src/ │ ├─ main.c │ ├─ usart.c │ ├─ dma.c │ └─ stm32h7xx_it.c ├─ Drivers/ │ ├─ CMSIS/ │ └─ STM32H7xx_HAL_Driver/ ├─ stm32h743xx_flash.ld └─ project.iocmain函数里的启动顺序必须是:HAL_Init → 时钟配置 → GPIO → DMA → USART → UART1_StartRx → 主循环。顺序反了会导致DMA在串口初始化之前被请求,丢最开始的几帧。还有一点容易被忽略:HAL_UART_Init内部会调用MspInit,而MspInit里会调用DMA相关初始化吗?不会,DMA句柄的初始化要放在UART_Init之前,HAL库在串口初始化时检查hdmarx是否已经配置好。如果DMA后配,串口初始化的DMA关联会失败。
5.2 用串口助手验证收包链路
烧录后先用USB-TTL接PA9/PA10,串口助手按十六进制发送。协议约定格式:0xAA + 长度 + 数据 + 校验和。发一条AA 03 01 02 03 09,程序主循环里解析到frame_lock置位后,把收到的原始字节通过回显串口打回来,或者直接翻转一颗LED,用来快速判断链路是否通。这里的校验和是手动算的:0xAA + 0x03 + 0x01 + 0x02 + 0x03 = 0x09。
验证表格可以按这个思路做:
| 发送内容 | 期望结果 |
|---|---|
| AA 03 01 02 03 09 | 收到5字节,帧头校验通过,LED翻转一次 |
| AA 04 01 02 03 04 | 校验和错误,不翻转LED,但内部错误计数加1 |
| 连续发1000帧 | 计数全部正确,无丢帧,主循环响应正常 |
如果收不到数据,按这个顺序排查:先确认PA9/PA10的TX/RX没有接反;再确认DMA请求号是不是DMA_REQUEST_USART1_RX;然后看USART1_IRQHandler里有没有进到IDLE分支,可以在进入HandleUart1Idle前加断点。前两行验证协议解析,第三行验证长时间稳定性。发1000帧时注意发送间隔不要完全固定,随机间隔更接近真实链路。
5.3 观测CPU占用率与中断频率:用GPIO翻转量化
要看这套方案到底省了多少CPU,可以用最土的方法:在空闲中断里翻转一颗GPIO,用示波器看电平占空比。这是嵌入式里常用的性能观测手法,不需要额外工具。
HAL_GPIO_WritePin(DBG_GPIO_Port, DBG_Pin, GPIO_PIN_SET); // 进入中断 HandleUart1Idle(); HAL_GPIO_WritePin(DBG_GPIO_Port, DBG_Pin, GPIO_PIN_RESET); // 退出中断示波器测这个GPIO高电平时间,再除以帧周期,就是串口接收在中断路径上占用的CPU时间。对115200波特率、每帧5字节、每秒发1000帧的输入,处理函数只做memcpy的情况下,高电平占比应该在1%以内。如果占比超过5%,八成是处理函数里做了耗时的协议解析或日志输出,把它们挪到主循环去。长时间拷机时重点看三个指标:帧计数器是否连续递增、错误回调有没有触发、LED是否按预期翻转。跑一晚上数据不错,这套接收链路就可以放心交给业务层了。
6. 二次开发:把单串口方案升级成通用接收引擎
想把这套代码用到第二个串口、第三种协议上,最直接的做法是复制一份再改。但如果工程里有四五个串口,复制粘贴会带来大量重复代码,改一处漏一处。更值得做的是把接收引擎抽象成一个结构体,每个串口一份实例,同样的逻辑只写一遍。结构体设计大致是这样:
typedef struct { UART_HandleTypeDef *uart; DMA_HandleTypeDef *dma; uint8_t *buf; uint16_t buf_size; volatile uint16_t last_ndtr; void (*on_frame)(uint8_t *data, uint16_t len); // 帧回调 } UartRxEngine; void UartRxEngine_Init(UartRxEngine *eng) { HAL_UART_Receive_DMA(eng->uart, eng->buf, eng->buf_size); eng->last_ndtr = __HAL_DMA_GET_COUNTER(eng->dma); __HAL_UART_ENABLE_IT(eng->uart, UART_IT_IDLE); } void UartRxEngine_HandleIdle(UartRxEngine *eng) { // 和第3章相同的环形计数逻辑,只是操作的是eng的成员 // ... if (eng->on_frame) eng->on_frame(eng->buf + start, len); }这样每加一个串口,只需要初始化一个UartRxEngine实例,把串口句柄、DMA句柄、缓冲区和回调函数填进去,中断服务函数里调用对应的UartRxEngine_HandleIdle(&eng_rx1)、UartRxEngine_HandleIdle(&eng_rx2)就行。换协议时只需替换on_frame指向的解析函数,接收底层完全不动。如果你只需要串口1的现成实现,这份工程代码烧录后改一下波特率和校验位就能直接对接业务层。
我在实际项目里还会给这个结构体加一个"帧超时"字段,允许配置空闲判定时间,兼容不同波特率下的帧间隔差异,比如低速传感器和高速图像模块的波特率差了几十倍,超时阈值跟着波特率走才不会互相干扰。从那以后,我每次换平台移植这套接收引擎,都会强制走一遍同样的验证流程:先115200跑通,再拉高波特率压测,最后开D-Cache和-O2做一轮稳定性检查,这三关过了才敢把代码交给业务层。希望帮到你。
本文还有配套的精品资源,点击获取