ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

STM32F429串口通信实战:从标准库工程到DMA+空闲中断

STM32F429串口通信实战:从标准库工程到DMA+空闲中断 简介STM32F429基于ARM Cortex-M4内核并集成浮点运算单元串口通信是其在工业控制、医疗设备、消费电子等场景中最常用的外设功能之一。配套工程围绕标准库串口的完整实现展开覆盖GPIO复用模式配置、USART参数初始化、阻塞与非阻塞收发、中断回调以及DMA配合传输等关键环节适合嵌入式初学者及需要快速搭建串口通信功能的开发者参考。资源包共295个文件压缩包约17.11MB以45个c源文件和46个h头文件为主体内含标准库各外设驱动与串口例程源码同时附带Keil工程配置、启动汇编文件、链接脚本以及编译生成的axf、map、lst等文件打开即可对照学习。目前已有1027人浏览学习。通过这套例程读者可直观掌握USART1下PA9/PA10引脚配置、串口收发系列函数的调用方式以及帧错误、校验错误等异常回调的处理思路对后续扩展RS-232、RS-485等实际通信项目具有直接参考价值。1. 应届生不会告诉你的串口入门真相刚开始学STM32F429的时候我一度觉得串口通信是嵌入式开发里最没技术含量的东西。直到后来在项目里做Bootloader升级、接RS232转485的Modbus仪表、用串口调试W25Q256的外部Flash下载算法才发现一个扎心的事实串口是整个嵌入式系统调试的地基地基打不牢上面盖什么都歪。F429标准库的串口通信看似只是配置几个寄存器实际上牵扯到时钟树、引脚复用、中断优先级、DMA传输、环形缓冲区设计等一串知识点每一个坑都能让你在调试图上多熬一晚上。这篇文章不是照搬参考手册的翻译稿而是把我从标准库工程新建到串口收发稳定运行全过程的踩坑记录写出来。适合刚入手F429的初学者照着复现也适合那些从F103升级到F429后发现怎么串口配置不一样了的老手快速定位差异。无论你是要做裸机调试、移植FreeModbus跑RTU协议还是想在宿主机Windows和Linux虚拟机之间通过串口互通数据这篇都能帮你省下不少弯路。顺便说一句我用的开发板主控是STM32F429IGT6176脚2MB Flash256KB RAM标准库版本是STM32F4xx_DSP_StdPeriph_Lib_V1.8.0IDE用的是Keil MDK 5.27。后面所有代码和寄存器说明都基于这个组合如果你用的是F429ZI或者F429NI除了引脚编号略有差异外配置逻辑完全通用。2. 从零搭一个F429标准库串口工程2.1 标准库和新版HAL库到底怎么选这几年ST官方把重心全压在了HAL库和LL库上标准库停在V1.8.0不更新了于是不少新手一入坑就被教育不要学标准库学了就落后。但我个人观点是F429标准库不但没过时反而是理解芯片底层机制的最佳教材。标准库的每个函数基本都能对应到参考手册里的寄存器和状态位你很清楚每个步骤做了什么HAL库封装太多层一旦出问题不看源码根本定位不到原因。另外工程上还有很现实的一点很多老项目、方案商的参考代码都是标准库写的尤其是电机控制、仪表采集这类迭代多年的产品线。你接手旧项目如果只会HAL那基本等于要重写整个驱动层。我见过好几个单位招聘嵌入式工程师JD上还明确写着熟悉标准库优先。所以对这个话题我的结论是标准库值得学而且F429这种高性能M4芯片用标准库完全够跑。2.2 新建工程的六个关键步骤标准库新建F429工程网上教程很多但大部分都漏掉了关键细节。我把核心步骤压缩成可以直接照做的清单准备固件库从ST官网或Github镜像下载STM32F4xx_DSP_StdPeriph_Lib_V1.8.0解压后我们需要的是 Libraries标准外设库源码、Project官方模板、Utilities板级支持包可选三个目录。建立工程目录骨架在工程根目录下分别创建 User用户代码、App应用层、BSP板级驱动、StdPeriph标准库源码、CMSIS内核相关文件、MDK-ARMkeil工程文件等文件夹。这是最佳实践后面代码量大了你才知道好处。拷贝核心文件CMSIS 目录放入core_cm4.h、core_cmFunc.h、core_cmInstr.h、core_cmSimd.h以及 startup_stm32f429xx.s 启动文件StdPeriph 目录放入整个 src 和 inc 下的外设驱动源文件和头文件User 目录放入main.c、stm32f4xx_it.c中断服务、stm32f4xx_it.h配置Keil工程在 C/C 选项卡的 Define 中填入STM32F429xx,USE_STDPERIPH_DRIVER添加所有头文件路径勾选 Use MicroLIB避免串口重定向时半主机模式卡死加入系统时钟配置F429最高主频180MHz但默认上电是16MHz内部HSI。必须在main函数一开始调用SystemInit()并通过 PLL 锁相环把主频抬上去。标准库提供了System_stm32f4xx.c文件默认就已经把时钟树配置成了180MHz你需要确认工程里包含这个文件。编译测试新建工程后先写一个空的main函数点编译0 Error 0 Warning 是基本要求。如果你第一步就报几十个错误九成是头文件路径没加全。注意F429烧录时的Flash算法选择也很关键。如果你的板载Flash是外置W25Q256并需要从外部Flash启动还要单独制作SPI下载算法这和串口通信本身没直接关系但调试串口时经常需要用这个算法把程序烧进去所以工程里提前配好能省事不少。2.3 标准库代码文件组织的心得我见过太多人的工程就三个文件main.c、stm32f4xx_it.c、stm32f4xx_gpio.c 全堆在一起变量命名从a到z齐了。这种代码自己调试都费劲更别说团队协作。建议这样分bsp_usart.c 专门放串口初始化、中断处理、收发缓冲管理bsp_gpio.c 放无关外设的引脚初始化app_xxx.c 放业务逻辑。串口驱动做到只提供接口不涉及业务上层要发什么数据调USART_SendString就行底层用中断还是DMA外部不需要关心。我在实际项目里把串口驱动当公共组件抽出来换芯片平台时只改底层上层逻辑一行不动省了无数次重复劳动。3. USART硬件原理与F429串口初始化细节3.1 F429的串口资源分布STM32F429一共有8个串口USART1/2/3、UART4/5、USART6/7/8。其中USART1和USART6挂载在APB2总线上最高时钟90MHz其余的挂在APB1上最高45MHz标准库SystemInit()默认配置下。如果你要跑高速通信比如921600波特率建议优先选USART1或USART6因为时钟越高波特率误差越小。F429的每个串口都有多个引脚映射可选通过GPIO_AF配置复用功能。比如USART1可以映射到PA9/PA10也可以映射到PB6/PB7。同一个串口选哪组引脚看你的PCB布局是不是有冲突。特别注意F429引入了GPIO_AFAlternate Function复用机制和F103的GPIO_Remap完全不是一个概念用F103的惯性思维写F429的串口必然会卡壳。3.2 GPIO与USART初始化代码逐行拆解下面给出一个标准的USART1初始化代码这是我最常用的模板void BSP_USART1_Init(uint32_t baudrate) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; // 1. 开启时钟GPIOA、USART1均挂在AHB1/APB2上 RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); // 2. 引脚配置PA9-TX复用推挽输出PA10-RX复用浮空输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_OType GPIO_OType_PP; GPIO_InitStructure.GPIO_PuPd GPIO_PuPd_UP; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF; GPIO_InitStructure.GPIO_OType GPIO_OType_PP; GPIO_InitStructure.GPIO_PuPd GPIO_PuPd_UP; GPIO_Init(GPIOA, GPIO_InitStructure); // 3. 引脚连接到USART1复用功能 GPIO_PinAFConfig(GPIOA, GPIO_PinSource9, GPIO_AF_USART1); GPIO_PinAFConfig(GPIOA, GPIO_PinSource10, GPIO_AF_USART1); // 4. 串口参数配置 USART_InitStructure.USART_BaudRate baudrate; USART_InitStructure.USART_WordLength USART_WordLength_8b; USART_InitStructure.USART_StopBits USART_StopBits_1; USART_InitStructure.USART_Parity USART_Parity_No; USART_InitStructure.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_InitStructure.USART_HardwareFlowControl USART_HardwareFlowControl_None; USART_Init(USART1, USART_InitStructure); // 5. 使能接收中断 USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); // 6. NVIC配置 NVIC_InitStructure.NVIC_IRQChannel USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 2; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); // 7. 使能串口 USART_Cmd(USART1, ENABLE); }这段代码有几处初学者容易踩雷的地方我逐个说一下。3.3 波特率背后的计算逻辑很多人直接往USART_InitStructure.USART_BaudRate 115200一填就完事从不问这个值是怎么来的。F429的USART波特率计算公式是TX/RX Baud fCK / (16 * USARTDIV)其中 fCK 是串口所在总线的时钟。USART1挂在APB2上APB2时钟90MHz当系统主频180MHz时APB2预分频系数为2。那么115200波特率对应的USARTDIV就是USARTDIV 90,000,000 / (16 * 115200) ≈ 48.828125USART_BRR寄存器的DIV_Mantissa部分存放整数48DIV_Fraction部分存放小数的4位近似值所以实际波特率会有微小偏差。只要总线时钟不是串口波特率的整数倍关系就一定存在误差。F429这个误差通常小于0.2%完全在RS232的容错范围内。如果哪天你用非常规波特率比如250000发现通信乱码优先怀疑是不是分频舍入误差太大。注意APB1和APB2的最高频率不一样。如果你的程序改了时钟树APB1不再是45MHz、APB2不再是90MHz那么同样配置115200实际波特率完全不同。排查乱码问题的时候先确认RCC_GetClocksFreq()返回的总线时钟频率是否符合预期。4. 发送、接收与中断处理的完整实现4.1 轮询发送和中断接收的黄金组合最基础的串口用法就是主循环轮询发送接收靠中断。这种模式简单可靠适合指令应答类场景。发送一个字节的核心代码void USART_SendByte(USART_TypeDef* USARTx, uint8_t data) { // 等待发送数据寄存器为空 while (USART_GetFlagStatus(USARTx, USART_FLAG_TXE) RESET); USART_SendData(USARTx, data); }发送字符串就是循环调用。但我必须提醒轮询发送有个致命问题在低优先级任务耗时较长时发送长字符串会阻塞CPU。115200波特率下发送1000字节大约耗时87ms这期间主循环啥也干不了。如果只是调试打印无所谓但如果要处理实时性要求高的任务建议要么用DMA发送要么把发送数据拆包分批。接收中断是串口驱动里最核心的部分。RXNE标志置位表示收到一个字节进入中断后把数据从USART_ReceiveData读走存入接收缓冲区#define RX_BUFFER_SIZE 256 volatile uint8_t rx_buffer[RX_BUFFER_SIZE]; volatile uint16_t rx_write_index 0; volatile uint16_t rx_read_index 0; volatile uint16_t rx_count 0; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t data USART_ReceiveData(USART1); // 环形缓冲区写入 if (rx_count RX_BUFFER_SIZE) { rx_buffer[rx_write_index] data; rx_write_index (rx_write_index 1) % RX_BUFFER_SIZE; rx_count; } // 溢出处理清除标志 USART_ClearITPendingBit(USART1, USART_IT_RXNE); } }读走数据的操作必须放在RXNE标志位清除之前因为USART_ReceiveData的读取动作本身就会清掉RXNE标志。如果你先USART_ClearITPendingBit再读数据极端情况下新数据覆盖了旧数据你会莫名丢字节。4.2 如何正确处理一帧完整的消息串口本身是字节流协议它根本不知道一帧是什么。你发来的一大串数据可能拆成好几次中断到达也可能几帧数据黏在一次中断里。所以处理串口数据的时候永远不要假设一次中断就是一帧消息。我的做法是引入状态机逐字节解析。以最常见的包头长度数据校验格式为例typedef enum { FRAME_STATE_HEADER, FRAME_STATE_LENGTH, FRAME_STATE_DATA, FRAME_STATE_CHECK } frame_state_t; void UART_ParseByte(uint8_t data) { static frame_state_t state FRAME_STATE_HEADER; static uint8_t frame_len 0; static uint8_t data_cnt 0; static uint8_t frame_data[64]; static uint8_t sum 0; switch (state) { case FRAME_STATE_HEADER: if (data 0xAA) // 帧头 { state FRAME_STATE_LENGTH; } break; case FRAME_STATE_LENGTH: frame_len data; data_cnt 0; sum data; state FRAME_STATE_DATA; break; case FRAME_STATE_DATA: frame_data[data_cnt] data; sum data; if (data_cnt frame_len) { state FRAME_STATE_CHECK; } break; case FRAME_STATE_CHECK: if (sum data) { // 校验通过回调处理完整帧 ProcessFrame(frame_data, frame_len); } state FRAME_STATE_HEADER; break; default: state FRAME_STATE_HEADER; break; } }这个状态机在主循环里被调用中断只负责把原始字节丢进环形缓冲区。中断里不要做复杂的协议解析因为中断服务函数要短平快你在里面跑状态机一旦数据量大或者解析逻辑复杂很容易打断其他中断导致系统异常。4.3 串口重定向printf的三种方法调试时最爽的莫过于直接printf(%d, value);然后从串口助手看到输出。F429标准库做重定向有三种办法我分别说清楚方法一重写fputc函数最常用int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }同时需在Keil里勾选Use MicroLIB否则链接时会报__stdout找不到的错误。方法二利用__io_putchar兼容性强int __io_putchar(int ch) { USART_SendData(USART1, (uint8_t)ch); while (USART_GetFlagStatus(USART1, USART_FLAG_TC) RESET); return ch; }方法三直接封装一个自定义UART_Printf函数底层用vsnprintf格式化。这个适合最终产品阶段因为printf系列函数体积大占十几KB Flash而产品里回忆空间有限。还有一个坑重定向后如果串口没初始化程序会死在fputc的while循环里。很多人烧完程序发现CPU卡死就是这个原因。所以printf重定向的串口必须最先初始化。5. 进阶方案空闲中断DMA让CPU彻底解放5.1 为什么建议用DMA接收如果只是做调试和简单的数据收发4.1节的方案足够了。但在实际项目中上位的机一条指令发几十上百个字节每字节一个中断还好但如果频率再高一点比如做固件IAP升级、W25Q256固件下载、或者Modbus RTU多从机轮询每毫秒几百上千个字节的数据量单纯靠中断逐字节搬运就会把CPU消耗在无关紧要的数据复制上。DMA接收的思路是串口硬件收到数据后DMA控制器自动把数据搬到内存缓冲区不靠CPU干预。只有当一帧数据接收完成时进一次中断CPU去处理完整帧。这样CPU负载大幅下降系统整体响应速度也快很多。F429的USART1支持DMA2Stream2是接收方向Stream7是发送方向。配置如下void BSP_USART1_DMA_Init(uint8_t *buffer, uint16_t len) { DMA_InitTypeDef DMA_InitStructure; RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2, ENABLE); // DMA2 Stream2 Channel4 对应USART1_RX DMA_InitStructure.DMA_Channel DMA_Channel_4; DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; DMA_InitStructure.DMA_Memory0BaseAddr (uint32_t)buffer; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralToMemory; DMA_InitStructure.DMA_BufferSize len; 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_FIFOMode DMA_FIFOMode_Disable; DMA_InitStructure.DMA_MemoryBurst DMA_MemoryBurst_Single; DMA_InitStructure.DMA_PeripheralBurst DMA_PeripheralBurst_Single; DMA_Init(DMA2_Stream2, DMA_InitStructure); USART_DMACmd(USART1, USART_DMAReq_RX, ENABLE); DMA_Cmd(DMA2_Stream2, ENABLE); }配合空闲中断IDLE实现不定长接收这是Modbus RTU这类协议最常见的实现方式void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { // 先读SR再读DR清除IDLE标志 USART_ReceiveData(USART1); // 关闭DMA传输计算收到多少字节 DMA_Cmd(DMA2_Stream2, DISABLE); uint16_t received RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA2_Stream2); // 标记一帧数据接收完成把received交给协议层处理 frame_received_flag 1; frame_length received; // 重新装载DMA并开启 DMA_SetCurrDataCounter(DMA2_Stream2, RX_BUF_SIZE); DMA_Cmd(DMA2_Stream2, ENABLE); } }提示空闲中断的清除方式比较特殊很多人搞错。正确做法是先读USART_SR寄存器再读USART_DR寄存器两步缺一不可。如果你只调用USART_ClearITPendingBit(USARTx, USART_IT_IDLE)实际是清不掉的IDLE标志会一直挂着导致反复进中断。DMA模式最需要注意的坑就是DMA接收缓冲够大吗。如果上位机一帧数据超过DMA设置的长度DMA会自动停止接收多余的数据直接丢弃而且没有任何标志提示。我踩过这个坑Modbus一帧最长256字节我DMA缓冲区设了512本来以为够用结果上位机调试时一次发了600字节后面344字节全丢了排查了两个小时才定位到。根据协议最坏情况设定缓冲区并预留20%余量。6. 串口实战中的典型问题与排查清单这一节全是干货是我在调试各种串口问题时总结的高频故障现象和定位思路直接拿去对照排查。6.1 常见问题速查表现象可能原因排查方法完全无输出串口没使能/时钟没开/引脚复用错确认RCC和GPIO配置示波器量TX引脚波形输出乱码波特率不对/主频和库配置不符用逻辑分析仪抓波形数实际波特率首字节丢失TXE标志位处理不当/上电时间不足发送前加延时或用TC标志位接收丢字节中断响应不及时/缓冲区太小把目标数据的处理降低优先级扩大缓冲区接线正确但收不到串口号选错/CH340驱动异常确认设备管理器里串口号换USB口重试运行时死机中断里处理耗时任务把协议解析挪到主循环打印浮点死机MicroLIB的浮点打印bug用%d输出整数部分加小数部分或改用自定义格式化6.2 最坑的引脚复用冲突F429的引脚复用功能强大但也有坑。PA9和PA10同时可以映射为USART1和USB_OTG_FS的VBUS、ID等信号如果你在初始化USB后又初始化串口两个外设同时对同一引脚做了复用配置轻则通信异常重则直接烧坏引脚驱动。同一个引脚不能同时分配给两个外设这在F429上尤为突出因为外设太多引脚不够用很多芯片型号的引脚都是多功能复用。遇到串口莫名其妙的异常看看是不是有什么外设和你抢引脚。6.3 移植Modbus RTU时串口驱动怎么配合FreeModbus V1.6的串口移植核心是三个钩子函数xMBPortSerialInit初始化、xMBPortSerialPutByte发单字节、xMBPortSerialGetByte收单字节。移植的关键在于FreeModbus要求串口收到第一个字节就启动超时定时器或者借助串口空闲中断来判断一帧结束。用空闲中断DMA的方式做Modbus RTU是目前最优雅的方案接收性能比传统逐字节中断好太多。但要注意Modbus RTU规定帧间间隔是3.5个字符时间如果你的空闲中断配置得太敏感可能会把正常的帧拆成两半。这个阈值要根据波特率计算115200波特率下3.5个字符时间大约304微秒。6.4 串口排查的四板斧遇到串口问题我的排查顺序永远是示波器测波形、换线换USB口、降低波特率、打印寄存器状态。示波器测TX波形先确认有没有信号输出再看波形高低电平是否正确。如果TX一直是高电平说明程序根本没发送如果是方波但接收端乱码多半是波特率偏差。换线换USB口CH340、CP2102、FT232这些USB转串口芯片偶尔会抽风。我遇到过CH340驱动被Windows更新搞挂导致收发异常重装驱动就好。买转接板优先选FT232和CP2102稳定性好很多。USB转TTL模块的TX/RX不要接反虽然很多新手都会犯这个错但这条真的要放在最显眼处。降低波特率如果9600能通信115200乱码说明硬件或布线有干扰或者时钟配置有问题导致波特率误差过大。打印寄存器状态在初始化后面打印USART1-CR1、USART1-BRR的寄存器值和预期值比对能快速发现哪步没执行到位。7. 当我用这套串口方案做W25Q256下载算法调试最后分享一个最近真实项目里的例子。我做的板子是F429IGT6板载一片W25Q25632MB外部SPI Flash。要支持在Keil里通过调试器直接把程序下载到外部Flash需要自制一个F载算法FLM格式其中串口打印起了大作用。下载算法本质上是在RAM里运行的一段独立程序烧录器把程序通过SWD接口加载到RAM然后由算法驱动SPI接口读写外部Flash。这个算法内部没法直接看状态唯一的调试通道就是串口。我在算法的初始化代码里加上串口打印每次进入Init、EraseSector、ProgramPage、Verify函数时输出一行状态信息就能直观看到烧录器调用了哪些函数、走到了哪一步。那次的经历让我对串口有了一层新理解串口不仅是一个通信外设它更是一个观察嵌入式系统内部状态的窗口。Flash擦写超时、SPI通信失败、地址偏移错误这些问题要是没有串口打印排查难度至少翻三倍。F429标准库的串口通信看着简单但当你把它作为调试基础设施建设起来后面做Bootloader、Modbus网关、各种传感器采集都会顺畅很多。所以架子要搭好底层要扎实这个投入绝对值得。本文还有配套的精品资源点击获取
返回列表