
简介针对基于Cortex-M4内核的意法半导体微控制器这份串口例程主要解决串口通信中的初始化、收发中断及数据搬运等问题。代码基于硬件抽象层库编写适合嵌入式初学者系统学习串口外设也适合开发者移植到自己的项目中。压缩包内共有四百六十个文件以C语言源文件和头文件居多配合工程配置文件、编译生成的固件与中间文件整体大小约为二十点二四兆字节解压后可直接用集成开发环境打开。目前已有三千七百三十三人学习该资源例程覆盖串口引脚复用映射、通信参数设置、中断优先级配置、发送与接收函数的调用等关键环节并通过接收到数据立即回发的方式实现环回自测便于在串口终端中观察结果。例程在代码结构上按模块拆分从底层引脚配置到中断服务函数再到主循环调用都有明确的对应关系便于逐段分析。代码还演示了直接存储器访问方式收发大量数据可减少处理器负担。整体注释清晰、结构完整适合作为串口通信模块的开发模板。 手上一块 STM32F405RGT6 最小系统板首要任务就是把串口例程跑通。这个芯片在 F4 系列里算是性价比很能打的一颗168MHz 主频、1MB Flash、192KB RAM串口有 6 路外设资源丰富很多人拿它做飞控、离线语音识别、工业控制板。串口作为最基础的通信手段几乎是所有调试和业务联调的起点。这篇内容不是我临时查手册整理的而是把这些年用 F405 做串口开发时踩过的坑、验证过的配置和最终沉淀下来的例程结构完整过一遍给同样在调 F405 串口的人一个可以直接抄作业的参考。1. 开跑之前先把时钟、引脚和供电这三样盘明白1.1 时钟树APB1 和 APB2 上的串口速度上限不同F405 的串口时钟来源不是同一个总线USART1 和 USART6 挂在 APB2 上USART2、USART3、UART4、UART5 挂在 APB1 上。默认配置下系统时钟 168MHzAPB2 最高 84MHzAPB1 最高 42MHz。虽然对 115200、921600 这些常规波特率来说两条总线的差异不会直接导致通信失败但一旦你要跑 1.5Mbps 以上的高速串口或者同时开多个串口并希望波特率误差足够小就必须关心外设时钟频率。官方数据手册里给了波特率计算公式当 OVER80 时USARTDIV fck / (16 × baud)。HAL 库把 USART 的 BRR 寄存器算得很准确但如果你用标准库或者直接操作寄存器建议老老实实先算一遍。举个例子APB1 时钟 42MHz目标波特率 256000USARTDIV 42000000 / (16 × 256000) 10.2539BRR 只能写整数部分和小数部分最终波特率会有微小偏差。常规 8N1 格式下系统要求收发双方的波特率误差控制在 2% 以内就没有问题实际操作中我习惯把误差控制在 0.5% 以内才觉得稳。还有一个很多人忽略的点如果开启了 USART 的 FIFO 模式部分 STM32 系列支持硬件 FIFOF405 没有或者使用了 DMA需要额外注意总线时钟和 DMA 时钟的分配。F405 的 DMA1、DMA2 挂在 AHB1 上只要使能了对应的 DMA 时钟即可无需像串口那样做分频计算但 CubeMX 生成代码时如果你手动关了某些时钟DMA 传输就会出现“看似配置成功、实则不工作”的怪问题。1.2 引脚复用和封装差异最容易害人STM32F405RGT6 是 LQFP64 封装引脚不算多但串口引脚复用关系很容易看走眼。USART1 的 TX/RX 常规引脚是 PA9/PA10但也可以重映射到 PB6/PB7USART2 是 PA2/PA3 或者 PD5/PD6USART3 是 PB10/PB11 或者 PC10/PC11。F405 不像早期的 F103 那样用“重映射”字眼而是叫 Alternate Function每一个引脚干什么是 GPIOx_AFRL/AFRH 寄存器决定的。我用 CubeMX 配置时遇到过一种情况工程里 USART1 用了 PA9/PA10但 PA9 又被 SPI 或者其他外设占用CubeMX 会提示冲突而如果你绕开配置直接手动改 GPIO 复用经常把 AF7 和 AF8 搞混串口就会莫名其妙没波形。所以真正动手前建议先抄一遍自己板子上的串口引脚对应关系。比如很多 F405 最小系统板把 CH340 接在 USART1 的 PA9/PA10 上也有板子把串口调试口放在 USB 转串口芯片的 UART 侧用的是 UART4 或 USART2。别只看丝印用万用表量一下或参照原理图确认你写代码用的串口号和烧录器/调试器对应的串口号是同一个。这个问题不是空穴来风我见过有人在通信群里问为什么发送有数据、接收一直超时结果原理图一看调试口在 USART2代码却在初始化 USART1。1.3 最小系统的供电和地线会直接决定串口稳不稳F405 最小系统板串口调试时最容易被忽视的是供电和共地。USB 转串口模块和开发板各接各的 USB 口看起来都通了但中间没有共地TTL 电平参考地不一致串口收到的就是乱码甚至完全没反应。我的习惯是调试阶段统一从 USB 转串口模块给板子供 5V或者至少用杜邦线把两个模块的 GND 连在一起再谈后续通信。CH340 这类 USB 转串口芯片还有另一个特性输出电压跟随 VCC通常是 3.3V 或 5V。F405 的串口引脚是 FTFive-volt tolerant引脚但为了省心我一般把 USB 转串口模块的 VCC 跳到 3.3V 档避免电平转换带来的隐患。注意不是说不能接 5V而是如果你板子上的上拉电阻、传感器共用 3.3V 电源域RX 线直接怼 5V 高电平虽然不会烧引脚但可能灌电流到 3.3V 电源轨造成其他芯片异常。2. 一套可以直接抄的轮询发送 中断接收例程2.1 CubeMX 里我一般这样勾选用 CubeMX 生成 F405 工程时我习惯把 RCC 的 HSE 设为 Crystal/Ceramic Resonator时钟树配置成 168MHz。串口部分按“通信对象”分开来配调试串口USART1异步模式115200-8-N-1无硬件流控协议串口根据外设决定RS485 用 USART2 加一个 GPIO 控制方向预留串口UART4/5 作为调试日志输出一般不开中断只在需要时轮询发送在 Pinout 视图里选中 PA9/PA10将模式设置为 USART1 的 TX/RX。NVIC Settings 里把 USART1 global interrupt 打勾。如果是波特率高于 460800 的通信我还会把 Word Length 选成 8 Bits 并启用 Oversampling 16不要为了省一个 stop bit 去开 9 位模式很多上位机解析 9 位数据时会多不少麻烦。2.2 中断接收的核心代码逻辑HAL 库环境下初始化代码由 CubeMX 自动生成我只需要在用户代码区写逻辑。这一段是中断接收的主干uint8_t rx_data; void Start_UART_Receive(void) { HAL_UART_Receive_IT(huart1, rx_data, 1); } void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { // 每收到一个字节进入一次回调 ProcessByte(rx_data); // 重新启动接收 HAL_UART_Receive_IT(huart1, rx_data, 1); } }这段代码的逻辑很简单单字节中断接收进入回调后处理一字节然后立刻重新开启接收。对于一次只发几个字节的简单命令交互这种方式完全够用。如果你需要在 main 函数里持续接收也可以在 while(1) 里做轮询但中断接收的响应更及时尤其是在主循环有大量延时的情况下。2.3 printf 重定向调试日志好帮手F405 串口例程里最常见的需求是 printf 打印日志。使用 HAL 库时我通常重写 fputc并把 stdout 指向串口#include stdio.h int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; }然后在 main 里直接 printf。很多人会遇到 printf 之后程序卡死的情况常见原因有两个一是 MDK 里没勾选 MicroLIBfputc 的重定向没生效二是 HAL_UART_Transmit 的超时时间设成无限大而串口 TX 在发送期间被其他中断打断过久导致一直没发送完。我的建议是调试日志用轮询发送没有大问题但不要把超时时间设成 HAL_MAX_DELAY给一个 100ms 或 500ms 的明确超时值一旦串口异常程序不至于卡在某个发送函数里出不来。3. 不定长数据流用 DMA 空闲中断才算真正把 F405 用明白3.1 单字节中断接收在工程场景里的瓶颈上面的单字节中断接收适合低速、短帧的场景但一旦数据长度不确定、帧间隔不稳定或者数据量比较大时每收一个字节就进一次中断CPU 占用率会快速上升。更麻烦的是你很难在回调里判断“一帧数据结束了”。工业通信里常见的命令帧可能是 5 个字节也可能是 35 个字节靠超时判断帧结束需要额外开一个定时器非常啰嗦。F405 的串口外设提供了一个很趁手的特性空闲中断IDLE Interrupt。当 RX 线上检测到一字节数据的空闲状态即一个字节接收完成后总线保持高电平的时间超过 1 个位时间时硬件会产生中断。这个中断天然适合做“不定长帧接收边界”的判断。配合 DMA数据可以从外设直接搬运到内存缓冲区完全不用 CPU 一粒一粒地搬接收完一帧只有一次中断。3.2 用 HAL_UARTEx_ReceiveToIdle_DMA 实现一帧一收HAL 库里从 1.4 版本开始提供了 HAL_UARTEx_ReceiveToIdle_DMA 这个极其实用的函数它把 DMA 接收和空闲中断做了整合。CubeMX 里需要做两件事在 USART1 DMA Settings 里添加 RX 通道模式选 Normal然后在 NVIC 里使能 USART1 global interrupt 和 DMA1 Stream 中断。代码逻辑如下#define RX_BUF_SIZE 128 uint8_t rx_buf[RX_BUF_SIZE]; void Start_UART_Rx_WithIdle(void) { HAL_UARTEx_ReceiveToIdle_DMA(huart1, rx_buf, RX_BUF_SIZE); __HAL_DMA_DISABLE_IT(hdma_usart1_rx, DMA_IT_HT); } void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { // 本次有效数据长度是 Size ProcessFrame(rx_buf, Size); // 清空缓冲区并重新启动 memset(rx_buf, 0, RX_BUF_SIZE); Start_UART_Rx_WithIdle(); } }这个例程的关键点有两处。第一把 DMA 的 Half Transfer 中断屏蔽掉否则 DMA 传输到缓冲区一半时也会触发回调如果这时候强行处理 Size 长度的数据读到的实际是半帧或半缓冲区的混杂数据。第二回调里拿到的 Size 不是 DMA 整个缓冲区的长度而是从本次启动接收开始到发生空闲中断时实际收到的字节数。这是 HAL 库内部维护的一个计数值非常可靠但前提是代码不要在中途用 HAL_UART_Receive_DMA 又去重启一次否则计数值会被重置。3.3 缓冲区大小和 DMA 模式怎么选接收缓冲区大小要大于你预期最大帧长这是常识但还有一个隐藏问题如果实际接收数据超过了 RX_BUF_SIZEDMA 在 Normal 模式下会直接停止接收后续数据全部丢光。所以缓冲区宁可给大一点也不要卡在“刚好够用”的临界点。F405 的 RAM 有 192KB开几个 1KB 的 DMA 缓冲区完全不心疼。如果你选择 DMA 的 Circular 模式那么缓冲区是一个环形结构DMA 会持续写入不会自动停下来。这种方式适合高速连续数据流比如把串口收到的音频或传感器数据实时搬运到内存但环形缓冲区的读写指针需要自己维护代码复杂度会上一个台阶。做常规命令交互时用 Normal 模式最省心每次处理完一帧后重新启动接收即可。3.4 F405 没有 D-Cache但 DMA 还是有自己的脾气F405 不像 F7 那样有 D-Cache不需要担心数据一致性问题。但 DMA 和 CPU 同时访问同一块缓冲区时如果 DMA 正在写 rx_buf 的尾部而 CPU 在回调里从头开始读这本身没问题因为空闲中断触发时 DMA 已经把这一帧写完了。真正的坑是回调里在处理数据之前被更高优先级的中断插进来此时又有新的串口数据到达但你还没重新启动 DMA 接收这些数据就会丢失。我常用的规避方案是DMA 空闲中断的回调里不做耗时处理ProcessFrame 只负责把数据拷贝到另一个帧队列里真正的协议解析放到主循环。这样做的好处是回调能非常快地重新启动接收不留数据接收空窗期。4. 串口调试绕不开的三个工具性问题4.1 CH340、FTDI 这些 USB 转串口芯片的驱动和识别F405 最小系统板板载 USB 转串口最常用的是 CH340 或 CP2102。CH340 在 Windows 下需要装驱动电脑识别成 COM 口而不是感叹号设备这是第一步。装完驱动后发现串口打不开别急先到设备管理器里看 COM 口号和波特率设置然后确认串口没有被其他软件占用。FTDI 芯片的驱动在 Windows/macOS 下通常更省心但注意 FTDI 芯片的 VCCIO 电平要匹配。有些 FTDI 模块是 5V 逻辑直接连 3.3V 的 F405 串口引脚虽然大多数情况能用但长时间使用不太安心。另外USB 转串口模块的功率有限如果需要给 F405 板子供电不要同时挂一堆外设否则串口会出现间歇性乱码。4.2 串口调试助手里换行和乱码很多人用串口助手和 F405 通信时发现发出来的字符串末尾多了个看不见的字符或者显示成方框。本质上是收发双方对“一行结束”的认识不一致。F405 通过串口发送数据时如果你用 printf(hello world\n)这个 \n 只有换行没有回车。Windows 的记事本/串口助手显示时可能期望的是 \r\n。我一般统一在例程里把换行定义成宏#define SERIAL_NEWLINE \r\n printf(hello%s, SERIAL_NEWLINE);如果收到的中文乱码先检查两边的字符编码是不是一致。串口助手一般默认 GBK 或 GB2312而很多 IDE 工程默认 UTF-8。这个和单片机串口例程本身没关系但调试时容易浪费很长时间排查顺序应该放在波特率、接线之后。4.3 RS485 通信的方向切换时序RS485 是半双工通信F405 的 UART 只是把数据发给收发器芯片收发器芯片的方向控制引脚需要 MCU 在发送前拉高、发送后拉低。很多人写的例程是这样发送函数刚开始时拉高方向调用 HAL_UART_Transmit发送完立刻拉低方向。看着没问题实际在高速波特率或大数据量发送时最后一个字节还没完全从 TX 引脚移出方向引脚已经被拉低了导致最后一个字节被截断。F405 的 USART 有一个 TCTransmission Complete标志必须在检查这个标志之后再去拉低方向控制引脚这一点我在例程里注释得非常明显因为踩过太多次了void UART_Send_RS485(uint8_t *data, uint16_t len) { RS485_DE_GPIO_Port-BSRR RS485_DE_Pin; // 设为发送方向 HAL_UART_Transmit(huart2, data, len, 1000); while (__HAL_UART_GET_FLAG(huart2, UART_FLAG_TC) RESET); RS485_DE_GPIO_Port-BRR RS485_DE_Pin; // 恢复接收方向 }5. 实测过程中沉淀下来的几条稳定性经验5.1 中断优先级和临界区不要乱设串口中断优先级设置过低会导致数据处理不及时DMA 缓冲区溢出。设置过高又可能抢占其他关键时序。我的经验是DMA 接收完成中断优先级设成比 SysTick 低一档比主循环的软件定时器高发送用轮询不用发送中断省去发送中断里需要考虑的临界区问题。如果多个串口同时工作中断优先级要分出层次通信实时性要求最高的那路优先级最高日志串口优先级最低避免一个日志串口把关键命令串口给堵了。5.2 错误标志要及时清理不能视而不见UART 在工作过程中可能因为干扰、线序松动、对方发送中途掉电等原因产生溢出错误ORE、噪声错误NE或帧错误FE。如果这些错误标志不清除那么后续接收可能会一直处于异常状态。以前用标准外设库时经常需要在读 SR 之后再读 DR 来清除错误标志。HAL 库的底层在出错时会在 huart 的 ErrorCode 里记录但如果你没有处理错误回调程序可能什么现象都没有就是收不到数。我一般会在错误回调里打一条日志并把接收状态机复位void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { __HAL_UART_CLEAR_OREFLAG(huart1); Start_UART_Rx_WithIdle(); } }5.3 压测和回环测试是检验例程是否健壮的最好办法串口例程写完之后不要只在桌面上用串口助手点两下就完事。我的做法是把 TX 和 RX 短接也就是回环然后让 F405 发送一长串数据同时检查接收到的内容和发送的是否一致。接着用串口助手以 1ms、5ms、10ms 的间隔持续发送不定长数据帧观察 DMA 缓冲区是否有溢出回调是否有丢失。F405 串口如果配置正确在 115200 波特率下连续发个几分钟不应该掉一个字节。压测时还要注意一个细节如果串口助手的“定时发送”间隔设得太短而上位机本身占用串口的时间过长可能造成下位机缓冲区满。这时优先考虑把波特率提高或者用数据流控制协议而不是一味加大下位机的缓冲区。最后再分享一个我一直在用的例程组织习惯F405 的多个串口不要共用一个接收缓冲区和同一套回调逻辑哪怕是同一型号的芯片板子上的用途也不同分开管理能省掉很多排查时间。调试串口只打印日志协议串口走 DMA 空闲中断备用串口只在需要时用轮询发送。这样的层级清晰后续扩展其他外设或多机通信时也方便直接复用。串口例程看似基础但把时钟、引脚、中断和 DMA 这几件事都理顺了后面做大工程时真的会顺手很多。本文还有配套的精品资源点击获取