文章目录
-
- 一、简介与开发环境准备
-
- [1.1 为什么需要DMA传输](#1.1 为什么需要DMA传输)
- [1.2 开发环境与硬件准备](#1.2 开发环境与硬件准备)
- [1.3 项目目标与技术路线](#1.3 项目目标与技术路线)
- 二、DMA技术原理与数据流向分析
-
- [2.1 DMA工作原理详解](#2.1 DMA工作原理详解)
- [2.2 STM32 DMA控制器特性](#2.2 STM32 DMA控制器特性)
- [2.3 数据流向与系统架构](#2.3 数据流向与系统架构)
- 三、STM32CubeMX图形化配置详解
-
- [3.1 工程创建与芯片选型](#3.1 工程创建与芯片选型)
- [3.2 系统核心配置](#3.2 系统核心配置)
- [3.3 时钟树配置](#3.3 时钟树配置)
- [3.4 USART1外设配置](#3.4 USART1外设配置)
- [3.5 DMA通道配置](#3.5 DMA通道配置)
- [3.6 NVIC中断配置](#3.6 NVIC中断配置)
- [3.7 生成工程代码](#3.7 生成工程代码)
- 四、代码实现与详细解读
-
- [4.1 代码整体架构设计](#4.1 代码整体架构设计)
- [4.2 串口头文件定义](#4.2 串口头文件定义)
- [4.3 串口功能实现](#4.3 串口功能实现)
- [4.4 中断服务程序修改](#4.4 中断服务程序修改)
- [4.5 主函数实现](#4.5 主函数实现)
- 五、硬件连接与调试验证
-
- [5.1 硬件连接说明](#5.1 硬件连接说明)
- [5.2 程序编译与烧录](#5.2 程序编译与烧录)
- [5.3 串口调试助手配置](#5.3 串口调试助手配置)
- [5.4 调试问题排查](#5.4 调试问题排查)
- 六、性能优化与进阶应用
-
- [6.1 DMA传输模式深入理解](#6.1 DMA传输模式深入理解)
- [6.2 双缓冲区设计](#6.2 双缓冲区设计)
- [6.3 环形缓冲区实现原理](#6.3 环形缓冲区实现原理)
- [6.4 串口DMA发送优化](#6.4 串口DMA发送优化)
- [6.5 完整项目代码架构总结](#6.5 完整项目代码架构总结)
- 七、总结与展望
-
- [7.1 技术要点回顾](#7.1 技术要点回顾)
- [7.2 DMA技术优势总结](#7.2 DMA技术优势总结)
- [7.3 进阶学习方向](#7.3 进阶学习方向)
一、简介与开发环境准备
1.1 为什么需要DMA传输
在传统的串口通信模式中,CPU需要亲自参与每一个数据字节的搬运工作。当外设接收到数据时,CPU不得不中断当前正在执行的任务,进入中断服务程序,读取外设数据寄存器中的数据,然后将其存储到内存缓冲区中,最后再返回继续执行之前的任务。这种模式在数据量较小、通信频率较低的应用场景下并不会暴露出明显的问题,然而一旦面临高速数据采集、大文件传输或者多任务并发执行的嵌入式系统时,CPU的负担就会变得极其沉重。
举一个具体的例子来说明这种困境:假设我们需要通过串口以115200bps的波特率持续接收数据,这意味着每秒最多可以传输11520个字节。由于每个字节都会触发一次接收中断,CPU每秒需要处理11520次中断。每次进入中断服务程序需要保存当前寄存器状态、执行数据读取操作、然后恢复寄存器状态并返回,这个过程即使在最优化的代码实现下也需要消耗几十微秒的时间。累积起来,CPU将花费大量宝贵的时间来处理这些重复性的数据搬运工作,而无法专注于业务逻辑的处理。
DMA(Direct Memory Access,直接内存访问)技术的出现彻底改变了这一局面。DMA控制器是独立于CPU之外的一个专用硬件模块,它可以在不占用CPU任何资源的情况下,直接在外设和内存之间建立数据传输通道。当配置好DMA传输参数后,DMA控制器会自动完成数据从外设到内存(或从内存到外设)的搬运工作,整个传输过程完全不需要CPU的介入。CPU可以在DMA传输期间执行其他计算任务,只需要在传输完成时接收一个通知即可。
通过使用DMA传输技术,我们可以获得以下显著优势:首先,CPU的占用率大幅降低,系统可以同时处理更多的并发任务;其次,数据传输的效率得到提升,因为DMA控制器专门为高速数据搬运进行了优化;此外,系统的实时性得到改善,CPU不再被频繁的中断所打断;最后,整体功耗也会降低,因为CPU在数据传输期间可以进入低功耗状态。
1.2 开发环境与硬件准备
工欲善其事,必先利其器。在开始本次实战项目之前,我们需要准备好以下开发工具和硬件设备。
硬件设备方面,我们需要一块STM32开发板,本次教程以STM32F103C8T6最小系统板为例进行讲解,这款芯片是ST公司推出的一款经典型微控制器,性价比极高,广泛应用于各种嵌入式项目中。除了开发板之外,我们还需要一个ST-Link下载器用于程序烧录和调试,或者使用串口ISP方式烧录程序。此外,一块USB转TTL模块(CH340或CP2102芯片)用于与电脑进行串口通信是必不可少的。准备若干根杜邦线用于电路连接,以及一个稳定的5V/3.3V电源也是必需的。
软件工具方面,STM32CubeMX是ST官方提供的图形化配置工具,通过它我们可以直观地生成初始化代码,大大简化了开发流程。Keil MDK(Microcontroller Development Kit)是目前最流行的ARM单片机集成开发环境之一,提供了代码编辑、编译、调试等完整功能。此外,我们还需要一个串口调试助手软件,如SSCOM、XCOM或SecureCRT等,用于在电脑上发送和接收数据。
在安装和配置这些工具时,有几点需要特别注意。首先,确保安装的是最新版本的STM32CubeMX,这样可以获得最新的芯片支持包和HAL库函数。其次,在安装Keil MDK时,记得在Pack Installer中安装对应芯片型号的Device Family Pack。第三,USB转TTL模块的驱动必须在电脑端安装成功,否则串口助手将无法识别设备。最后,建议将所有工具软件都安装到非中文路径下,避免因路径问题导致的编译错误。
1.3 项目目标与技术路线
本次教程的核心目标是实现一个高效、稳定的串口数据收发系统,具体技术指标如下:使用USART1作为通信接口,波特率设置为115200,数据位8位,无校验位,停止位1位;接收端采用DMA+空闲中断的组合方式,实现不定长数据的接收;发送端同样采用DMA方式,提升发送效率;数据缓冲区大小设置为256字节,可以根据实际需求进行调整。
技术路线分为四个主要阶段:第一阶段是STM32CubeMX图形化配置,包括时钟设置、外设配置、DMA配置和NVIC配置;第二阶段是代码编写与解读,包括串口初始化、数据发送、数据接收和中断处理;第三阶段是硬件连接与程序烧录;第四阶段是调试验证与性能测试。
二、DMA技术原理与数据流向分析
2.1 DMA工作原理详解
DMA(Direct Memory Access,直接内存访问)是一种允许外部设备直接与计算机内存进行数据传输的技术,无需CPU的参与。在STM32微控制器中,DMA控制器集成在芯片内部,提供了多个DMA通道,每个通道可以独立配置用于不同的外设。
DMA控制器的工作过程可以分解为以下几个关键步骤。首先是初始化配置阶段,在这个阶段CPU需要设置DMA通道的多个参数:源地址(数据从哪里来)、目标地址(数据到哪里去)、数据长度(传输多少数据)、传输模式(单次传输还是循环传输)以及传输方向(从外设到内存或从内存到外设)。这些配置信息被存储在DMA通道对应的寄存器组中。
配置完成之后,一旦外设发出DMA请求信号,DMA控制器就会接管数据传输的控制权。此时CPU可以继续执行其他指令,完全不参与数据传输过程。DMA控制器会根据之前配置好的参数,自动从源地址读取数据并写入目标地址,同时更新剩余传输计数器的值。这个过程完全由硬件实现,数据传输速度可以达到AHB总线时钟频率,具有极高的效率。
当数据传输完成时(剩余计数器递减至零),DMA控制器会产生一个传输完成中断,通知CPU数据已经传输完毕,CPU可以在中断服务程序中对数据进行处理,或者启动新的DMA传输。这种机制使得CPU可以高效地处理多任务,避免被频繁的数据传输操作所打扰。
2.2 STM32 DMA控制器特性
STM32F1系列的DMA控制器是一个非常重要的外设,它提供了以下主要特性:DMA1有7个通道,DMA2有5个通道(仅大容量型号支持),每个通道都可以独立配置;支持外设到内存、内存到外设、内存到内存四种传输模式;支持循环模式,自动重载传输计数器的值;支持硬件触发和软件触发两种方式;支持可编程的数据宽度(字节、半字、字);支持可配置的优先级和中断产生条件。
在串口通信场景中,我们主要使用外设到内存(接收数据)和内存到外设(发送数据)两种传输模式。对于接收数据来说,当USART外设接收到一个数据字节时,会自动将数据写入DMA通道配置的源地址(USART数据寄存器),同时DMA控制器会自动将数据搬运到我们预设的内存缓冲区中。整个过程不需要CPU干预,CPU可以随时查询缓冲区中的数据或者等待接收完成中断。
对于发送数据来说,CPU只需要将待发送的数据写入内存缓冲区,然后启动DMA传输即可。DMA控制器会自动将数据从内存缓冲区搬运到USART数据寄存器,USART外设会自动将数据发送出去。这种方式特别适合批量数据的发送场景,可以显著提高发送效率。
2.3 数据流向与系统架构
下面通过Mermaid流程图来展示串口DMA接收和发送的数据流向,帮助读者建立直观的概念理解。
发送数据流程
接收数据流程
TTL信号
DMA请求
搬运数据
产生中断
写入缓冲区
启动DMA
搬运数据
发送数据
TTL信号
外部设备
USART1引脚
USART1外设
DMA1通道
内存缓冲区 rx_buffer
CPU处理
CPU准备数据
内存缓冲区 tx_buffer
DMA1通道
USART1外设
USART1引脚
在这个架构图中,我们可以清晰地看到数据流动的完整路径。对于接收流程,外部设备通过TTL电平信号将数据发送给STM32的USART1引脚,USART1外设接收到数据后产生DMA请求,DMA控制器将数据从USART数据寄存器搬运到内存缓冲区rx_buffer中,当接收到一帧数据后产生中断通知CPU处理。对于发送流程,CPU先将待发送的数据写入内存缓冲区tx_buffer,然后启动DMA传输,DMA控制器将数据从缓冲区搬运到USART数据寄存器,USART外设自动完成数据的串行发送。
这种架构设计的核心优势在于:数据搬运工作完全由DMA控制器独立完成,CPU只需要在数据接收完成后进行处理,或者在发送前准备数据。这极大地释放了CPU的计算能力,使得系统可以同时处理其他任务。
三、STM32CubeMX图形化配置详解
3.1 工程创建与芯片选型
打开STM32CubeMX软件,进入主界面后,我们可以看到界面上有"New Project"(新建工程)、"Load Project"(加载工程)、"Board Selector"(开发板选择)等选项。对于初学者来说,建议直接从芯片选型开始,这样可以根据实际使用的硬件进行针对性配置。
点击"New Project"后,会弹出芯片选型对话框。在这个对话框中,我们可以使用多种方式查找目标芯片:通过芯片型号搜索、通过系列筛选、通过应用场景筛选等。在左上角的搜索框中输入"STM32F103C8",很快就会在下面的列表中显示匹配的芯片型号。找到"STM32F103C8Tx"后选中它,然后点击右下角的"Start Project"按钮开始创建工程。
工程创建完成后,我们可以看到CubeMX的主界面分为几个主要区域:左侧是"Project Manager"(项目管理器),可以配置工程名称、存放路径、使用的IDE等;中间是芯片的引脚视图,以图形化的方式显示各个引脚的功能配置;右侧是"Configuration"(配置)面板,显示当前已配置的外设列表;下方是"Pinout"(引脚分配)和"Clock Configuration"(时钟配置)标签页。
在开始配置之前,建议首先设置工程的基本信息。点击左侧的"Project Manager",在"Project"选项卡中填写工程名称(如"UART_DMA_Example"),选择工程存放路径,在"Toolchain/IDE"下拉菜单中选择我们使用的IDE(推荐选择"MDK-ARM",即Keil MDK)。在"Code Generator"选项卡中,建议勾选"Generate peripheral initialization as a pair of .c/.h files per peripheral",这样可以将每个外设的初始化代码分别生成到独立的文件中,便于代码管理和阅读。
3.2 系统核心配置
系统核心配置是整个工程的基础,包括调试接口配置、时钟源配置等,这些配置的准确性直接影响后续所有外设的正常工作。
点击中间视图下方的"System Core"类别下的"SYS"选项,右侧会弹出系统配置面板。在"Debug"选项中,我们需要选择调试接口类型。对于初学者来说,建议选择"Serial Wire"(SWD接口),这是一种两线制的调试接口,只需要两根线(SWDIO和SWDCLK)就可以完成程序的下载和调试,比传统的JTAG接口节省引脚。如果不小心选择错了调试接口,可能会导致芯片被锁死,无法再次下载程序,这一点务必注意。
接下来配置RCC(Reset and Clock Control,复位和时钟控制)。点击"SYS"下方的"RCC"选项,在配置面板中找到"High Speed Clock(HSC)"和"Low Speed Clock(LSC)"两个选项。对于STM32F103C8T6来说,我们需要启用外部高速时钟源(HSE),在"HSE"下拉框中选择"Crystal/Ceramic Resonator"(晶体/陶瓷谐振器)。如果没有外接晶振,也可以选择"Bypass Clock Source"(旁路时钟源),使用外部提供的时钟信号。在"LSE"选项中,建议同样启用外部低速时钟源,设置为"Crystal/Ceramic Resonator",虽然本项目不会用到RTC功能,但养成良好的配置习惯很重要。
3.3 时钟树配置
时钟是STM32运行的心脏,时钟配置的准确性直接影响CPU运行速度和外设工作频率。点击下方标签页中的"Clock Configuration",进入时钟配置视图。
在这个视图中,我们可以看到整个STM32F1系列的时钟树结构。时钟源包括HSI(内部高速时钟,8MHz)、HSE(外部高速时钟,通常为8MHz晶振)、LSI(内部低速时钟,约40kHz)和LSE(外部低速时钟,32.768kHz)。这些时钟源经过分频器和倍频器的组合,可以为不同的外设提供所需频率的时钟信号。
对于本项目,我们需要配置USART1的工作时钟。USART1挂载在APB2总线上,其时钟频率应该等于APB2时钟频率。在HSE行中,填写外部晶振的频率值(通常为8MHz),然后在PLL配置区域,启用PLL(锁相环)并将倍频系数设置为9,这样可以得到72MHz的系统时钟(SYSCLK)。APB2预分频器设置为1,则APB2时钟为72MHz,此时USART1的时钟就是72MHz。
为了验证配置是否正确,我们可以观察右侧的时钟频率显示区域。如果看到"SYSCLK: 72 MHz"、"APB2 Timer Clocks: 72 MHz"、"APB1 Timer Clocks: 36 MHz"等显示,说明时钟配置正确。记住这些时钟频率值,在后续计算波特率分频系数时会用到。
3.4 USART1外设配置
现在进入核心部分:串口外设的配置。在中间视图的引脚上点击PA9(USART1_TX)和PA10(USART1_RX),或者直接在右侧配置面板中点击"USART1",开始配置串口参数。
在"Mode"选项中,确保选择"Asyncchronous"(异步模式),这是最常用的串口通信模式,与标准RS232接口兼容。在"Hardware Flow Control"(硬件流控制)选项中,选择"Disable"(禁用),因为我们不需要使用RTS/CTS硬件流控制引脚。
接下来配置串口的关键参数。点击"Configuration"区域的"USART1",展开详细配置选项。在"Parameter Settings"选项卡中,我们需要设置以下参数:
首先是波特率(Baud Rate),在"Basic Parameters"中可以输入我们期望的波特率值,本项目设置为115200。STM32CubeMX会自动根据系统时钟计算并配置USARTDIV分频系数,确保波特率的准确性。
其次是数据字长(Word Length),设置为"8 Data Bits"(8位数据),这是最常用的配置。注意,如果启用奇偶校验(Parity),数据位会自动变为9位(如果选择奇偶校验)或保持8位(如果选择Mark/Space校验),本项目选择"None"(无校验)。
停止位(Stop Bits)设置为"1 Stop Bit"(1位停止位),这是最常用的配置,可以提供较好的兼容性。
关于数据方向(Data Direction),包含"Receive and Transmit"(接收和发送)、"Receive Only"(仅接收)和"Transmit Only"(仅发送)三个选项。本项目需要同时接收和发送数据,所以选择"Receive and Transmit"。
关于过采样(Over Sampling),F103系列默认为"16 Samples"(16倍过采样),这种模式具有更好的抗干扰性能,但最高支持的波特率相对较低。如果需要使用更高的波特率(如921600),可以选择"8 Samples"(8倍过采样)模式。本项目使用115200波特率,保持默认的16倍过采样即可。
关于硬件流控制,本项目选择"Disable"(禁用),不启用RTS和CTS引脚。
完成以上配置后,USART1的外设设置就完成了。接下来需要配置DMA,用于实现高效的数据传输。
3.5 DMA通道配置
DMA配置是实现高效数据传输的关键环节。返回右侧配置面板,找到" DMA Settings"选项卡,点击进入DMA配置界面。
在这个界面中,我们可以看到一个DMA请求列表,列出了当前芯片所有可用的DMA通道和外设请求。我们需要为USART1添加两个DMA通道:一个用于发送(TX),一个用于接收(RX)。
首先添加发送DMA通道。点击列表上方的"Add"按钮,在弹出的下拉菜单中选择"USART1 TX"。添加成功后,会在列表中看到新增加的DMA通道配置行。点击这一行,可以在下方展开详细的DMA通道参数配置。
对于发送DMA通道(USART1_TX),需要配置以下参数:方向(Direction)设置为"Memory To Peripheral"(内存到外设);模式(Mode)设置为"Normal"(普通模式),因为我们每次发送数据时都需要手动启动DMA传输,如果需要循环发送则选择"Circular";优先级(Priority)设置为"High"(高优先级),因为串口发送通常需要及时处理;数据宽度方面,外设数据宽度(Peripheral Data Width)设置为"Byte"(字节),内存数据宽度(Memory Data Width)也设置为"Byte";递增模式(Increment Address)方面,外设地址不递增(Peripheral)设置为"Disable",内存地址递增(Memory)设置为"Enable",因为我们需要依次从缓冲区读取数据。
接下来添加接收DMA通道。再次点击"Add"按钮,在下拉菜单中选择"USART1 RX"。接收DMA的配置与发送类似,但有几个关键区别:方向设置为"Peripheral To Memory"(外设到内存);模式设置为"Circular"(循环模式),这样即使接收缓冲区满也会自动循环覆盖,保证不会丢失数据;其他参数与发送DMA相同。
这里有一个重要的知识点需要解释:为什么接收DMA要使用循环模式而发送DMA使用普通模式?这是因为接收数据是持续进行的,我们无法预知数据何时会到来以及数据的长度,使用循环模式可以让DMA持续将接收到的数据搬运到缓冲区中,形成一个环形缓冲区(Ring Buffer)的效果。而发送数据是我们主动触发的,发送完成后需要等待新的数据到来再重新启动发送,所以使用普通模式。
3.6 NVIC中断配置
虽然DMA可以在不需要CPU干预的情况下完成数据传输,但我们仍然需要配置中断来通知CPU数据接收完成或者发送完成的事件。特别是在接收不定长数据时,我们需要利用USART的空闲中断(IDLE Line Detection)来检测一帧数据的结束。
返回配置面板,找到" NVIC Settings"选项卡,点击进入中断配置界面。在这里我们可以看到所有可用的中断列表及其当前状态。
我们需要启用以下中断:USART1 global interrupt(USART1全局中断),这个中断包含了接收完成、发送完成、检测到空闲线路等多种中断源;DMA1 Channel4 global interrupt(DMA1通道4全局中断),这是USART1_TX使用的DMA通道;DMA1 Channel5 global interrupt(DMA1通道5全局中断),这是USART1_RX使用的DMA通道。
勾选这三个中断对应的"Enable"复选框,启用这些中断。在"Preemption Priority"列中,可以设置中断的抢占优先级。对于本项目,保持默认的优先级设置即可。如果后续需要更精细的实时性控制,可以根据实际情况调整优先级数值,数值越小表示优先级越高。
3.7 生成工程代码
所有配置完成后,现在可以生成工程代码了。点击上方工具栏中的"Generate Code"按钮(图标像一个发光的灯泡),或者使用快捷键Alt+K,STM32CubeMX会开始生成初始化代码。
在代码生成过程中,CubeMX会显示生成进度。生成完成后,会弹出一个对话框提示代码生成成功,并询问是否打开生成的工程文件。点击"Open Project"按钮可以直接在Keil MDK中打开工程。
生成的工程文件结构如下:Core文件夹包含Inc和Src两个子文件夹,分别存放头文件和源文件;Drivers文件夹包含STM32F1xx_HAL_Driver子文件夹,里面是HAL库的驱动源代码;MDK-ARM文件夹包含使用Keil MDK打开工程所需的文件。
打开工程后,我们可以在各个源文件中看到CubeMX生成的初始化代码。这些代码严格按照我们在CubeMX中的配置生成,一般不需要修改。接下来我们需要在用户代码区域添加应用逻辑代码,实现具体的功能。
四、代码实现与详细解读
4.1 代码整体架构设计
在开始编写代码之前,我们先来理解一下整个代码的架构设计。一个完善的串口DMA通信程序需要包含以下几个核心部分:串口初始化代码(由CubeMX生成)、DMA初始化代码(由CubeMX生成)、用户自定义的头文件(包含缓冲区定义和函数声明)、串口功能实现源文件(包含发送、接收函数的实现)、中断处理函数(处理DMA和USART中断)、主函数中的初始化和启动代码。
为了保证代码的模块化和可维护性,我们将代码分散到多个文件中:usart.h和usart.c用于封装串口相关的所有功能函数;main.c中主要是系统初始化和主循环的框架代码;stm32f1xx_it.c中处理各种中断服务程序。
本项目的设计思路是:接收端采用空闲中断+DMA的方式,实现不定长数据的接收;发送端采用DMA方式,实现高效的数据发送;使用环形缓冲区管理接收数据,避免数据覆盖问题。
4.2 串口头文件定义
首先创建usart.h头文件,在这个文件中我们将定义串口相关的结构体、缓冲区、全局变量声明和函数原型。
File: Core/Inc/usart.h
c
/**
* @file usart.h
* @brief UART DMA 通信驱动头文件
* @details 本文件定义了串口通信所需的缓冲区、结构体和函数声明
* 采用 DMA+空闲中断 实现高效的不定长数据接收
*/
#ifndef __USART_H
#define __USART_H
#ifdef __cplusplus
extern "C" {
#endif
/* 头文件包含 ------------------------------------------------------------*/
#include "main.h"
/* 参数定义 --------------------------------------------------------------*/
/* 接收缓冲区大小,根据实际需求调整,不建议超过4096 */
#define UART_RX_BUFFER_SIZE 256
/* 发送缓冲区大小 */
#define UART_TX_BUFFER_SIZE 256
/* 宏定义 ----------------------------------------------------------------*/
/* 使能串口调试输出功能,如果不定义则关闭调试信息 */
#define UART_DEBUG_ENABLE
/* 类型定义 --------------------------------------------------------------*/
/**
* @brief 串口接收状态枚举
*/
typedef enum {
UART_RX_STATE_IDLE = 0, /* 空闲状态 */
UART_RX_STATE_RECEIVING, /* 接收中状态 */
UART_RX_STATE_COMPLETE /* 接收完成状态 */
} UART_RX_StateTypeDef;
/**
* @brief 串口控制结构体
*/
typedef struct {
uint8_t rx_buffer[UART_RX_BUFFER_SIZE]; /* 接收数据缓冲区 */
uint8_t tx_buffer[UART_TX_BUFFER_SIZE]; /* 发送数据缓冲区 */
volatile uint16_t rx_write_index; /* 接收缓冲区写索引 */
volatile uint16_t rx_read_index; /* 接收缓冲区读索引 */
volatile uint16_t rx_data_len; /* 当前接收到的数据长度 */
volatile UART_RX_StateTypeDef rx_state; /* 接收状态标志 */
volatile uint8_t tx_busy_flag; /* 发送忙标志 */
} UART_ControlTypeDef;
/* 全局变量声明 ----------------------------------------------------------*/
/* 串口控制结构体实例,供外部文件访问 */
extern UART_ControlTypeDef uart1_control;
/* 函数声明 --------------------------------------------------------------*/
/**
* @brief 串口初始化函数
* @param huart: 指向UART_HandleTypeDef结构体的指针
* @retval None
*/
void UART_Init(UART_HandleTypeDef *huart);
/**
* @brief 串口DMA发送函数
* @param huart: 指向UART_HandleTypeDef结构体的指针
* @param pData: 指向待发送数据缓冲区的指针
* @param Size: 待发送数据的字节数
* @retval HAL状态
*/
HAL_StatusTypeDef UART_Transmit_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);
/**
* @brief 启动串口DMA接收函数
* @param huart: 指向UART_HandleTypeDef结构体的指针
* @retval HAL状态
*/
HAL_StatusTypeDef UART_Receive_DMA(UART_HandleTypeDef *huart);
/**
* @brief 获取接收缓冲区中的数据长度
* @param None
* @retval 缓冲区中有效数据的字节数
*/
uint16_t UART_GetRxDataLength(void);
/**
* @brief 从接收缓冲区读取一个字节
* @param None
* @retval 读取到的数据,如果缓冲区为空返回0
*/
uint8_t UART_GetByteFromRxBuffer(void);
/**
* @brief 从接收缓冲区读取数据到指定数组
* @param pData: 指向目标数据缓冲区的指针
* @param Length: 需要读取的数据字节数
* @retval 实际读取的字节数
*/
uint16_t UART_ReadRxBuffer(uint8_t *pData, uint16_t Length);
/**
* @brief 串口printf重定向函数
* @param huart: 指向UART_HandleTypeDef结构体的指针
* @retval 写入的字符数
*/
int fputc(int ch, FILE *f);
/**
* @brief 串口空闲中断处理函数
* @param huart: 指向UART_HandleTypeDef结构体的指针
* @retval None
*/
void UART_IdleCallback(UART_HandleTypeDef *huart);
/**
* @brief 串口接收完成中断处理函数
* @param huart: 指向UART_HandleTypeDef结构体的指针
* @retval None
*/
void UART_RxCpltCallback(UART_HandleTypeDef *huart);
/**
* @brief 串口发送完成中断处理函数
* @param huart: 指向UART_HandleTypeDef结构体的指针
* @retval None
*/
void UART_TxCpltCallback(UART_HandleTypeDef *huart);
#ifdef __cplusplus
}
#endif
#endif /* __USART_H */
这个头文件定义了串口通信所需的全部声明。首先定义了接收和发送缓冲区的大小,这里设置为256字节,对于大多数应用场景已经足够。然后定义了串口控制结构体UART_ControlTypeDef,其中包含了缓冲区、读写索引、状态标志等成员变量,这个结构体将贯穿整个串口驱动实现。最后声明了所有需要用到的函数,包括初始化、发送、接收、数据读取等操作。
4.3 串口功能实现
接下来创建usart.c源文件,实现所有的串口功能函数。
File: Core/Src/usart.c
c
/**
* @file usart.c
* @brief UART DMA 通信驱动源文件
* @details 本文件实现了串口DMA通信的所有功能函数
* 采用 DMA+空闲中断 实现高效的不定长数据接收
*/
#include "usart.h"
#include <stdio.h>
#include <string.h>
/* 全局变量定义 ----------------------------------------------------------*/
/**
* @brief 串口控制结构体实例
* @note 使用volatile修饰,保证在中断中被修改时对主程序可见
*/
UART_ControlTypeDef uart1_control;
/* 指向串口句柄的指针,用于中断回调中使用 */
static UART_HandleTypeDef *g_uart1_handle = NULL;
/* 私有函数声明 ----------------------------------------------------------*/
static void UART_ResetRxBuffer(void);
/**
* @brief 串口初始化函数
* @param huart: 指向UART_HandleTypeDef结构体的指针
* @retval None
* @note 初始化串口控制结构体,启动DMA接收
*/
void UART_Init(UART_HandleTypeDef *huart)
{
/* 保存句柄指针,供中断回调使用 */
g_uart1_handle = huart;
/* 初始化控制结构体成员 */
memset(uart1_control.rx_buffer, 0, UART_RX_BUFFER_SIZE);
memset(uart1_control.tx_buffer, 0, UART_TX_BUFFER_SIZE);
uart1_control.rx_write_index = 0;
uart1_control.rx_read_index = 0;
uart1_control.rx_data_len = 0;
uart1_control.rx_state = UART_RX_STATE_IDLE;
uart1_control.tx_busy_flag = 0;
/* 启用串口空闲中断检测功能 */
/* __HAL_UART_ENABLE_IT 是一个宏,用于使能指定的中断源 */
/* UART_IT_IDLE 表示空闲线路检测中断,当检测到空闲线路时触发 */
__HAL_UART_ENABLE_IT(huart, UART_IT_IDLE);
/* 启动DMA接收 */
/* 使用HAL_UART_Receive_DMA函数启动DMA接收,数据会自动搬运到rx_buffer中 */
UART_Receive_DMA(huart);
#ifdef UART_DEBUG_ENABLE
printf("UART1 DMA initialized successfully!\r\n");
printf("RX Buffer Size: %d bytes\r\n", UART_RX_BUFFER_SIZE);
printf("TX Buffer Size: %d bytes\r\n", UART_RX_BUFFER_SIZE);
#endif
}
/**
* @brief 启动串口DMA接收
* @param huart: 指向UART_HandleTypeDef结构体的指针
* @retval HAL状态
* @note 使用循环模式接收数据,缓冲区满后自动从开头继续接收
*/
HAL_StatusTypeDef UART_Receive_DMA(UART_HandleTypeDef *huart)
{
HAL_StatusTypeDef status;
/* 启动DMA接收
* 参数说明:
* huart: 串口句柄
* pData: 接收数据的目标缓冲区地址
* Size: 接收缓冲区大小
*
* DMA配置为循环模式,当接收数据超过缓冲区大小时会覆盖之前的数据
* 这样可以保证不会因为数据过多而导致DMA传输错误
*/
status = HAL_UART_Receive_DMA(huart, uart1_control.rx_buffer, UART_RX_BUFFER_SIZE);
if (status == HAL_OK) {
uart1_control.rx_state = UART_RX_STATE_RECEIVING;
}
return status;
}
/**
* @brief 串口DMA发送函数
* @param huart: 指向UART_HandleTypeDef结构体的指针
* @param pData: 指向待发送数据缓冲区的指针
* @param Size: 待发送数据的字节数
* @retval HAL状态
* @note 发送完成后会触发TX完成中断,在中断中清除忙标志
*/
HAL_StatusTypeDef UART_Transmit_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size)
{
HAL_StatusTypeDef status;
/* 检查发送是否忙,如果正在发送则返回错误状态 */
if (uart1_control.tx_busy_flag) {
return HAL_BUSY;
}
/* 设置发送忙标志,防止重复启动发送 */
uart1_control.tx_busy_flag = 1;
/* 使用DMA方式发送数据
* HAL_UART_Transmit_DMA函数会启动DMA传输
* DMA控制器会自动将数据从pData指向的缓冲区搬运到USART数据寄存器
* 发送完成后会产生DMA完成中断
*/
status = HAL_UART_Transmit_DMA(huart, pData, Size);
/* 如果启动失败,恢复发送标志 */
if (status != HAL_OK) {
uart1_control.tx_busy_flag = 0;
}
return status;
}
/**
* @brief 获取接收缓冲区中的数据长度
* @param None
* @retval 缓冲区中有效数据的字节数
* @note 这是一个环形缓冲区,通过计算读写索引的差值得到数据长度
*/
uint16_t UART_GetRxDataLength(void)
{
uint16_t length = 0;
/* 计算当前缓冲区中的有效数据长度
* 如果写索引大于等于读索引,直接相减
* 如果写索引小于读索引,说明已经发生环形覆盖,需要加上缓冲区大小
*/
if (uart1_control.rx_write_index >= uart1_control.rx_read_index) {
length = uart1_control.rx_write_index - uart1_control.rx_read_index;
} else {
length = UART_RX_BUFFER_SIZE - uart1_control.rx_read_index + uart1_control.rx_write_index;
}
return length;
}
/**
* @brief 从接收缓冲区读取一个字节
* @param None
* @retval 读取到的数据,如果缓冲区为空返回0
* @note 每次调用后读索引会自动递增,循环模式下会自动回绕
*/
uint8_t UART_GetByteFromRxBuffer(void)
{
uint8_t data = 0;
/* 检查缓冲区中是否有数据 */
if (UART_GetRxDataLength() > 0) {
/* 读取当前读索引位置的数据 */
data = uart1_control.rx_buffer[uart1_control.rx_read_index];
/* 递增读索引 */
uart1_control.rx_read_index++;
/* 如果达到缓冲区末尾,回到开头(环形缓冲区特性) */
if (uart1_control.rx_read_index >= UART_RX_BUFFER_SIZE) {
uart1_control.rx_read_index = 0;
}
}
return data;
}
/**
* @brief 从接收缓冲区读取数据到指定数组
* @param pData: 指向目标数据缓冲区的指针
* @param Length: 需要读取的最大字节数
* @retval 实际读取的字节数
* @note 读取长度不会超过缓冲区中实际有效数据的长度
*/
uint16_t UART_ReadRxBuffer(uint8_t *pData, uint16_t Length)
{
uint16_t actual_length = 0;
uint16_t available_length;
/* 获取缓冲区中有效数据的长度 */
available_length = UART_GetRxDataLength();
/* 实际读取的长度取需求长度和有效长度的较小值 */
actual_length = (Length < available_length) ? Length : available_length;
/* 循环读取数据 */
for (uint16_t i = 0; i < actual_length; i++) {
pData[i] = UART_GetByteFromRxBuffer();
}
return actual_length;
}
/**
* @brief 重置接收缓冲区
* @param None
* @retval None
* @note 清空所有数据,将读写索引都置零
*/
static void UART_ResetRxBuffer(void)
{
uart1_control.rx_write_index = 0;
uart1_control.rx_read_index = 0;
uart1_control.rx_data_len = 0;
uart1_control.rx_state = UART_RX_STATE_IDLE;
}
/**
* @brief printf重定向函数
* @param ch: 要发送的字符
* @param f: 文件指针(本项目中未使用)
* @retval 发送的字符
* @note 将标准库的printf函数输出重定向到串口1
*/
int fputc(int ch, FILE *f)
{
/* 等待串口发送完成 */
/* HAL_UART_Transmit是阻塞式发送函数,会等待数据发送完成 */
/* 参数说明:huart1是串口1的句柄,&ch是数据地址,1是数据长度,100是超时时间 */
HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 100);
return ch;
}
/**
* @brief 串口空闲中断回调函数
* @param huart: 指向UART_HandleTypeDef结构体的指针
* @retval None
* @note 当检测到空闲线路(一段时间内没有接收新数据)时调用
* 此时说明一帧数据接收完成
*/
void UART_IdleCallback(UART_HandleTypeDef *huart)
{
uint16_t rx_counter;
/* 检查是否是USART1的空闲中断 */
if (__HAL_UART_GET_FLAG(huart, UART_FLAG_IDLE) != RESET) {
/* 清除空闲中断标志
* 注意:清除IDLE标志需要先读SR寄存器,再读DR寄存器
* HAL库已经封装了这个过程,我们只需要调用下面的宏即可
*/
__HAL_UART_CLEAR_IDLEFLAG(huart);
/* 计算本次接收到的数据长度
* DMA_CNDTR寄存器保存的是剩余要接收的数据个数
* 用缓冲区大小减去剩余个数,就是已经接收的个数
*/
rx_counter = __HAL_DMA_GET_COUNTER(huart->hdmarx);
/* 计算实际接收的数据长度(总缓冲区大小 - 剩余数量) */
uart1_control.rx_data_len = UART_RX_BUFFER_SIZE - rx_counter;
/* 更新写索引,指向下一个待写入位置 */
uart1_control.rx_write_index += uart1_control.rx_data_len;
/* 如果写索引超出缓冲区范围,回到开头 */
if (uart1_control.rx_write_index >= UART_RX_BUFFER_SIZE) {
uart1_control.rx_write_index = uart1_control.rx_write_index % UART_RX_BUFFER_SIZE;
}
/* 更新接收状态 */
uart1_control.rx_state = UART_RX_STATE_COMPLETE;
#ifdef UART_DEBUG_ENABLE
printf("IDLE interrupt triggered! Received %d bytes\r\n", uart1_control.rx_data_len);
#endif
}
}
/**
* @brief 串口接收完成回调函数
* @param huart: 指向UART_HandleTypeDef结构体的指针
* @retval None
* @note 当DMA接收缓冲区满时触发(循环模式下会重新开始接收)
*/
void UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
/* 在循环模式下,这个回调函数会在缓冲区满时再次触发
* 此时说明数据已经覆盖了之前的数据,需要重新开始计数
*/
/* 更新写索引到缓冲区末尾 */
uart1_control.rx_write_index = UART_RX_BUFFER_SIZE;
/* 接收完成,重新启动DMA接收(循环模式下会自动继续,这里可以做一些统计工作) */
uart1_control.rx_data_len = UART_RX_BUFFER_SIZE;
uart1_control.rx_state = UART_RX_STATE_RECEIVING;
#ifdef UART_DEBUG_ENABLE
printf("RX Buffer full, rewind!\r\n");
#endif
}
/**
* @brief 串口发送完成回调函数
* @param huart: 指向UART_HandleTypeDef结构体的指针
* @retval None
* @note 当DMA发送完成时调用,用于清除发送忙标志
*/
void UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
/* 清除发送忙标志,允许下一次发送 */
uart1_control.tx_busy_flag = 0;
#ifdef UART_DEBUG_ENABLE
printf("TX completed!\r\n");
#endif
}
这段代码实现了串口DMA通信的完整功能。UART_Init函数是入口点,负责初始化控制结构体、启用空闲中断和启动DMA接收。UART_Transmit_DMA函数用于发送数据,在发送过程中会设置忙标志,防止重复启动。UART_GetRxDataLength、UART_GetByteFromRxBuffer和UART_ReadRxBuffer三个函数用于从接收缓冲区读取数据,支持环形缓冲区的数据读取。fputc函数实现了printf重定向,使得我们可以直接使用printf函数向串口输出数据。空闲中断回调函数UART_IdleCallback是接收流程的核心,当检测到空闲线路时表明一帧数据接收完成,此时计算接收到的数据长度并更新缓冲区状态。
4.4 中断服务程序修改
接下来需要修改stm32f1xx_it.c文件,在其中添加对空闲中断的处理。需要找到USART1_IRQHandler函数,添加空闲中断检测和处理代码。
File: Core/Src/stm32f1xx_it.c
c
/* 中断服务程序文件 ------------------------------------------------------*/
/**
* @file stm32f1xx_it.c
* @brief 中断服务程序文件
* @details 本文件包含了所有外设的中断服务程序
* 需要添加USART1的空闲中断处理代码
*/
#include "main.h"
#include "stm32f1xx_it.h"
/* 私有变量 ----------------------------------------------------------*/
/* 外部声明,引用usart.c中定义的句柄 */
extern UART_HandleTypeDef huart1;
/* 函数声明 ----------------------------------------------------------*/
/* 用户可以在此处添加私有函数声明 */
/******************************************************************************/
/* Cortex-M3 Processor Interruption and Exception Handlers */
/******************************************************************************/
/**
* @brief This function handles Non Maskable Interrupt (NMI) exception.
* @param None
* @retval None
*/
void NMI_Handler(void)
{
/* 用户可以在这里添加代码来处理NMI异常 */
while (1) {
/* 无限循环,等待处理 */
}
}
/**
* @brief This function handles Hard Fault exception.
* @param None
* @retval None
*/
void HardFault_Handler(void)
{
/* 用户可以在这里添加代码来处理硬fault异常 */
while (1) {
/* 无限循环,等待处理 */
}
}
/* 省略其他异常处理函数... */
/******************************************************************************/
/* STM32F1xx Peripherals Interrupt Handlers */
/******************************************************************************/
/**
* @brief This function handles USART1 global interrupt.
* @param None
* @retval None
* @note USART1的所有中断(接收、发送、错误、空闲等)都会进入这个函数
* 函数内部会调用HAL库的中断处理函数
*/
void USART1_IRQHandler(void)
{
/* HAL库提供的统一中断处理函数,会根据中断类型调用相应的回调函数 */
HAL_UART_IRQHandler(&huart1);
/* 在HAL_UART_IRQHandler处理完成后,添加空闲中断检测代码
* 这样做的好处是可以立即获取空闲中断状态,不需要等待回调函数
*/
if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) {
/* 检测到空闲中断,调用用户处理函数 */
UART_IdleCallback(&huart1);
/* 清除空闲中断标志
* 注意:HAL_UART_IRQHandler函数内部可能没有清除IDLE标志
* 需要手动清除,否则会一直触发中断
*/
__HAL_UART_CLEAR_IDLEFLAG(&huart1);
}
}
/**
* @brief This function handles DMA1 Channel4 global interrupt.
* @param None
* @retval None
* @note DMA1通道4是USART1的发送DMA通道
*/
void DMA1_Channel4_IRQHandler(void)
{
/* HAL库提供的DMA中断处理函数 */
HAL_DMA_IRQHandler(huart1.hdmatx);
}
/**
* @brief This function handles DMA1 Channel5 global interrupt.
* @param None
* @retval None
* @note DMA1通道5是USART1的接收DMA通道
*/
void DMA1_Channel5_IRQHandler(void)
{
/* HAL库提供的DMA中断处理函数
* 当接收缓冲区满时会触发此中断(循环模式下)
*/
HAL_DMA_IRQHandler(huart1.hdmarx);
/* 在DMA接收中断中,可以添加接收完成回调
* 但由于我们使用的是循环模式,且主要依靠空闲中断来检测帧结束
* 所以这里主要处理DMA本身的错误情况
*/
}
/* 省略其他外设的中断处理函数... */
/******************************************************************************/
/* STM32F1xx Peripherals Timer Handlers */
/******************************************************************************/
/* 如果使用了定时器中断,会在这里添加相应的处理函数 */
这个中断服务程序文件包含了所有外设的中断处理函数。我们重点关注USART1_IRQHandler函数,在这个函数中我们首先调用HAL库的标准中断处理函数HAL_UART_IRQHandler,然后在后面手动检测并处理空闲中断。这种处理方式可以确保空闲中断得到及时响应,避免数据接收延迟的问题。
4.5 主函数实现
最后是main.c文件的修改。我们需要在适当的用户代码区域添加串口初始化代码、主循环中的数据处理代码。
File: Core/Src/main.c
c
/* 主函数文件 ----------------------------------------------------------*/
/**
* @file main.c
* @brief 主函数入口
* @details 本文件是STM32工程的入口点,包含了系统初始化和主循环
* 在本项目中实现了串口DMA通信的数据回显功能
*/
/* 头文件包含 ------------------------------------------------------------*/
#include "main.h"
#include "usart.h"
#include <string.h>
/* 函数声明 ----------------------------------------------------------*/
void SystemClock_Config(void);
/**
* @brief 主函数
* @param None
* @retval int
* @note 程序的入口点
*/
int main(void)
{
/* 复位所有外设,初始化Flash接口和SysTick */
HAL_Init();
/* 配置系统时钟 */
SystemClock_Config();
/* 初始化所有外设
* MX_GPIO_Init: GPIO初始化
* MX_DMA_Init: DMA初始化
* MX_USART1_UART_Init: USART1初始化
*/
MX_GPIO_Init();
MX_DMA_Init();
MX_USART1_UART_Init();
/* 用户代码:串口功能初始化 */
/* 在这里调用我们自定义的UART_Init函数,启动DMA接收和空闲中断 */
UART_Init(&huart1);
/* 打印欢迎信息 */
printf("\r\n");
printf("========================================\r\n");
printf(" STM32 UART DMA Test Program\r\n");
printf(" Version: 1.0\r\n");
printf("========================================\r\n");
printf("\r\n");
printf("Waiting for data...\r\n");
printf("Send data from PC, I will echo it back!\r\n");
printf("\r\n");
/* 无限循环 */
while (1)
{
/* 用户代码:主循环 */
/* 检查是否有接收到数据 */
if (UART_GetRxDataLength() > 0) {
/* 读取接收缓冲区的数据长度 */
uint16_t rx_len = UART_GetRxDataLength();
/* 分配临时缓冲区存储待回显的数据 */
uint8_t echo_buffer[256];
/* 从接收缓冲区读取数据 */
uint16_t actual_read = UART_ReadRxBuffer(echo_buffer, rx_len);
/* 添加字符串结束标志(如果是字符串数据) */
if (actual_read < sizeof(echo_buffer)) {
echo_buffer[actual_read] = '\0';
}
/* 打印接收到的数据信息 */
printf("Received %d bytes: ", actual_read);
/* 发送回显数据到串口(回显接收到的数据) */
/* 逐字节发送,支持二进制数据 */
for (uint16_t i = 0; i < actual_read; i++) {
HAL_UART_Transmit(&huart1, &echo_buffer[i], 1, 100);
}
/* 发送回车换行 */
printf("\r\n");
/* 发送回车换行到串口(回显数据后换行) */
uint8_t crlf[2] = {'\r', '\n'};
HAL_UART_Transmit(&huart1, crlf, 2, 100);
/* 也可以使用DMA方式发送回显数据(效率更高) */
/* UART_Transmit_DMA(&huart1, echo_buffer, actual_read); */
}
/* 可以在此添加其他业务逻辑 */
/* 短暂延时,降低CPU使用率 */
HAL_Delay(10);
}
}
/**
* @brief 系统时钟配置函数
* @param None
* @retval None
* @note 配置系统时钟为72MHz,使用PLL作为时钟源
*/
void SystemClock_Config(void)
{
RCC_OscInitTypeDef RCC_OscInitStruct = {0};
RCC_ClkInitTypeDef RCC_ClkInitStruct = {0};
/* 初始化RCC振荡器配置结构体 */
/* 使能HSE振荡器(外部晶体) */
RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE;
RCC_OscInitStruct.HSEState = RCC_HSE_ON;
/* 使能HSI振荡器(内部高速时钟) */
RCC_OscInitStruct.HSIState = RCC_HSI_ON;
/* 配置PLL时钟源为HSE,倍频系数为9 */
RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON;
RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE;
RCC_OscInitStruct.PLL.PLLMUL = RCC_PLL_MUL9;
/* 初始化RCC振荡器 */
if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK) {
/* 初始化失败,进入错误处理 */
Error_Handler();
}
/* 初始化RCC时钟配置结构体 */
/* 配置AHB预分频器为1 */
RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2;
RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK;
RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1;
/* APB1预分频器设置为2(APB1最大36MHz) */
RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV2;
/* APB2预分频器设置为1(APB2最大72MHz) */
RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1;
/* 初始化RCC时钟 */
if (HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_2) != HAL_OK) {
/* 初始化失败,进入错误处理 */
Error_Handler();
}
}
/**
* @brief 错误处理函数
* @param None
* @retval None
* @note 当发生严重错误时调用此函数,关闭所有中断并进入无限循环
*/
void Error_Handler(void)
{
/* 用户可以在这里添加自己的错误处理代码 */
__disable_irq();
while (1) {
/* 等待看门狗复位或手动复位 */
}
}
/**
* @brief 断言失败处理函数
* @param file: 发生断言失败源文件路径
* @param line: 发生断言失败的行号
* @retval None
* @note 在HAL库中启用断言检查后,当断言失败时会调用此函数
*/
#ifdef USE_FULL_ASSERT
void assert_failed(uint8_t *file, uint32_t line)
{
/* 用户可以在这里添加自己的断言失败处理代码 */
printf("Assert failed: file %s on line %d\r\n", file, line);
while (1) {
}
}
#endif
main.c文件是整个程序的入口点。SystemClock_Config函数配置系统时钟为72MHz,这是STM32F103的标准配置。main函数中首先调用HAL_Init进行系统初始化,然后配置时钟,接着初始化所有外设(包括GPIO、DMA和USART),最后调用我们自定义的UART_Init函数启动串口DMA接收。在主循环中,我们不断检查接收缓冲区中是否有数据,如果有数据则读取并回显到串口,实现了Echo功能。
五、硬件连接与调试验证
5.1 硬件连接说明
完成代码编写后,现在来进行硬件连接。正确的硬件连接是保证项目正常运行的前提条件。
首先是开发板与ST-Link调试器的连接。将ST-Link的SWDIO引脚连接到开发板的PA13引脚(如果使用标准布局),SWCLK引脚连接到PA14引脚,3.3V电源引脚连接到开发板的3.3V电源引脚,GND引脚连接到开发板的地引脚。注意,不同的开发板可能使用不同的调试接口布局,请参考开发板原理图确认具体的引脚分配。
其次是USB转TTL模块与开发板的串口连接。这是实现电脑与开发板通信的关键连接。USB转TTL模块的TX引脚需要连接到开发板的RX引脚(USART1_RX,即PA10),USB转TTL模块的RX引脚需要连接到开发板的TX引脚(USART1_TX,即PA9)。最后将两者的GND引脚连接在一起,确保共地。切记不要将电源引脚连接错误,USB转TTL模块只提供信号电平转换,不能给开发板供电。
最后是电源连接。确保开发板有稳定的供电,可以使用USB供电或者外接电源适配器。供电电压必须与开发板要求一致,STM32F103C8T6通常使用3.3V供电。
下表总结了关键的连接关系:
| USB转TTL模块 | STM32开发板 | 说明 |
|---|---|---|
| TX引脚 | PA10 (USART1_RX) | 模块发送 -> 开发板接收 |
| RX引脚 | PA9 (USART1_TX) | 模块接收 <- 开发板发送 |
| GND | GND | 共地,保证信号参考一致 |
5.2 程序编译与烧录
硬件连接完成后,接下来是程序编译和烧录步骤。
打开Keil MDK软件,加载我们之前生成的工程文件(后缀为.uvprojx)。在加载过程中,如果弹出"Pack Installer"提示,建议安装对应芯片的Device Family Pack,这可以提供更好的调试支持和芯片配置。
编译工程可以使用快捷键F7,或者点击菜单栏中的"Build"按钮。在编译输出窗口中,观察编译结果。如果编译成功,会显示"0 Error(s), 0 Warning(s)"。如果出现错误,需要根据错误提示信息进行排查,常见的错误包括:头文件路径不正确、变量未定义、语法错误等。
编译成功后,将ST-Link调试器连接到电脑USB接口,确保电脑已经安装了ST-Link驱动。在Keil中选择调试器:点击菜单"Project" -> "Options for Target",在弹出的对话框中选择"Debug"选项卡,在右侧的下拉框中选择"ST-Link Debugger",然后点击旁边的"Settings"按钮进入调试器设置。在"Flash Download"选项卡中,确保已经添加了对应芯片的Flash算法。
点击菜单"Flash" -> "Download"或者使用快捷键F8开始烧录程序。烧录成功后,可以在输出窗口看到"Programming Done"和"Verify OK"的提示信息。
5.3 串口调试助手配置
程序烧录完成后,我们需要使用串口调试助手来验证程序是否正常工作。
首先打开串口调试助手软件,如SSCOM、XCOM或SecureCRT等。在软件界面中,需要配置以下参数:串口选择,选择USB转TTL模块对应的COM端口(可以在设备管理器中查看);波特率设置为115200,与代码中配置的一致;数据位设置为8位;停止位设置为1位;校验位设置为无;流控制设置为无。
设置完成后,点击"打开串口"按钮连接串口。此时如果开发板已经上电并正常烧录了程序,应该会看到串口助手中打印出欢迎信息:"STM32 UART DMA Test Program"、"Waiting for data..."等。
接下来就可以进行数据收发测试了。在串口助手的发送区域输入任意字符或字符串,点击发送按钮。开发板接收到数据后,会将数据原样返回(回显功能),同时在串口助手的接收区域可以看到返回的数据。
如果需要测试二进制数据,可以使用串口助手的Hex发送模式。在Hex模式下,输入的数据会被当作十六进制数值发送,例如输入"48 65 6C 6C 6F"会发送"Hello"这5个字符。
5.4 调试问题排查
如果在调试过程中遇到问题,可以按照以下步骤进行排查。
首先是检查硬件连接。确保ST-Link与开发板连接正常,USB转TTL模块与开发板连接正确,电源供电稳定。可以用万用表测量关键信号线的电压,TX/RX信号在空闲状态下应该是3.3V(高电平)。
其次是检查软件配置。确认串口调试助手的参数配置与代码中一致,特别是波特率。检查是否选择了正确的COM端口。
然后是使用调试器进行单步调试。在Keil中设置断点,跟踪程序执行流程,观察变量值的变化。这种方法可以准确定位问题发生在哪个环节。
最后是检查代码逻辑。确认所有初始化函数都被正确调用,中断服务程序是否正确执行,DMA传输参数是否配置正确。
常见的问题和解决方法包括:如果串口没有任何输出,检查是否有 printf 函数重定向问题,确保 fputc 函数实现正确;如果数据接收正常但发送卡死,检查发送忙标志是否被正确清除;如果接收数据总是0,检查DMA配置是否正确,缓冲区地址是否有效。
六、性能优化与进阶应用
6.1 DMA传输模式深入理解
在之前的配置中,我们使用了两种不同的DMA模式:发送使用普通模式,接收使用循环模式。深入理解这两种模式的区别,对于实现更复杂的通信协议非常重要。
普通模式(Normal Mode)下,DMA执行一次数据传输后就会停止。此时DMA_CNDTR(剩余传输计数)递减到0,DMA控制器产生传输完成中断,然后该DMA通道进入空闲状态。如果需要再次传输数据,CPU必须重新配置DMA参数并启动新的传输。这种模式适合已知数据长度、传输完成后需要CPU处理的应用场景。
循环模式(Circular Mode)下,DMA在传输完成后会自动重新加载初始参数并继续传输,形成一个环形缓冲区。这种模式特别适合数据源持续产生数据的场景,例如ADC采样连续数据、串口持续接收数据等。在循环模式下,DMA传输不会产生完成中断(或者说每次循环结束都会产生中断),CPU需要通过其他方式判断数据接收的进度。
对于串口接收来说,循环模式+空闲中断是一个经典的组合。DMA持续将接收到的数据搬运到缓冲区,当检测到空闲线路(一帧数据接收完成)时,CPU可以计算本次接收的数据量。当缓冲区满时,DMA会自动从缓冲区开头继续接收,形成环形缓冲区的效果。这种方式可以实现不定长数据的接收,不会因为单次接收数据过长而丢失数据。
6.2 双缓冲区设计
在实际应用中,有时候需要在接收数据的同时处理之前接收的数据,这时可以使用双缓冲区(Double Buffer)技术。
双缓冲区的基本原理是:准备两个缓冲区,当DMA向其中一个缓冲区写入数据时,CPU可以处理另一个缓冲区中的数据。当DMA完成一个缓冲区的数据传输后,两者交换角色,DMA继续向刚才CPU处理完成的缓冲区写入新数据,CPU则处理刚才DMA写入完成的缓冲区中的数据。
在STM32中,可以通过配置DMA的循环模式和内存地址递增模式来实现双缓冲。具体实现方法是:准备两个缓冲区数组,配置DMA的内存地址指向当前使用的缓冲区,在缓冲区切换时修改DMA的目标地址。
双缓冲区技术的优势在于:可以实现无缝的数据接收和处理,提高系统的实时性能;CPU处理数据的时间更加充裕,不会因为处理速度慢而影响数据接收;适合高速数据流的应用场景,如高速串口通信、音频数据处理等。
6.3 环形缓冲区实现原理
环形缓冲区(Ring Buffer),也称为圆形缓冲区或循环缓冲区,是一种常用的数据结构,特别适合处理生产者-消费者模式的数据流。
环形缓冲区的核心思想是:使用一个固定大小的数组模拟一个首尾相接的圆环。当写索引到达数组末尾时,下一次写入会回到数组开头;同样,当读索引到达数组末尾时,下一次读取也会回到开头。这样就形成了一个逻辑上无限循环的缓冲区。
在我们的实现中,接收数据时使用DMA将数据写入缓冲区,同时更新写索引。检测到空闲中断时,表明一帧数据接收完成,此时记录本帧数据的长度,更新写索引。读取数据时,根据读索引从缓冲区读取数据,同时更新读索引。
判断缓冲区中有效数据数量的算法是:如果写索引大于等于读索引,说明没有发生环形覆盖,直接用写索引减去读索引;如果写索引小于读索引,说明已经发生过环形覆盖,此时有效数据数量等于缓冲区大小减去读索引再加上写索引。
6.4 串口DMA发送优化
虽然DMA已经大大提高了发送效率,但还有一些优化技巧可以进一步提升性能。
首先是批量发送优化。频繁启动DMA传输会产生额外的开销,如果需要发送多个小数据包,可以先将它们合并到一个大缓冲区中,然后一次性启动DMA发送。这样可以减少DMA启动的次数,提高整体效率。
其次是发送完成回调的合理使用。在我们的实现中,使用了轮询方式检查发送忙标志。更优雅的做法是使用发送完成中断(TX Complete Interrupt),在中断回调函数中处理发送完成事件,实现异步发送。
第三是零拷贝发送技术。在某些场景下,可以直接将需要发送的数据缓冲区地址配置给DMA,避免数据复制。这需要保证数据缓冲区在DMA传输期间保持有效。
6.5 完整项目代码架构总结
通过本次实战项目,我们建立了一个完整的串口DMA通信系统。整个项目的代码架构可以分为以下几个层次:
底层是硬件抽象层(HAL),由STM32CubeMX自动生成,包括HAL库初始化、GPIO配置、DMA配置、USART配置等。这些代码直接操作底层硬件寄存器,提供了统一的外设访问接口。
中间层是驱动层,也就是我们实现的usart.c和usart.h文件。这一层封装了串口通信的具体实现细节,提供了简洁易用的函数接口,如UART_Init、UART_Transmit_DMA、UART_ReadRxBuffer等。上层应用不需要关心底层是如何实现的,只需要调用这些接口即可。
应用层是main.c文件中的业务逻辑。在这一层中,我们实现了数据的接收、回显和打印功能。应用层代码简洁明了,通过调用驱动层提供的接口完成所需功能。
这种分层架构的优势在于:代码结构清晰,不同层次的代码各司其职;模块化程度高,便于代码复用和维护;上层应用与底层实现解耦,修改底层实现不影响上层逻辑。
七、总结与展望
7.1 技术要点回顾
通过本次实战项目,我们详细学习了基于STM32CubeMX的串口通信与DMA传输优化技术。从最初的开发环境搭建,到STM32CubeMX图形化配置,再到代码实现和调试验证,每一个环节都有很多知识点需要掌握。
在硬件配置层面,我们学习了如何正确配置系统时钟使其稳定工作在72MHz,如何配置USART1的通信参数(115200波特率、8N1格式),如何配置DMA通道实现高效的数据传输,如何配置NVIC启用必要的中断。
在代码实现层面,我们实现了完整的串口DMA驱动,包括printf重定向、环形缓冲区管理、空闲中断检测接收完成、 DMA发送和接收函数等。这些代码可以直接应用到实际项目中去。
在调试验证层面,我们掌握了硬件连接方法、程序编译烧录流程、串口调试助手的使用以及常见问题的排查方法。这些技能对于后续的嵌入式开发非常重要。
7.2 DMA技术优势总结
通过实际项目测试,我们可以明显感受到DMA技术带来的优势:
在CPU占用率方面,传统的查询方式或中断方式需要CPU全程参与数据收发,而DMA方式下CPU只需要在数据接收完成后进行处理,大大降低了CPU的占用率。在115200波特率下,传统方式可能需要CPU花费超过10%的时间来处理串口接收,而使用DMA后CPU占用率可以降低到1%以下。
在数据传输效率方面,DMA控制器专门为高速数据搬运设计,可以达到总线时钟频率的数据传输速度,比CPU软件方式更快更稳定。
在系统实时性方面,DMA传输不占用CPU时间,CPU可以专注于处理其他实时任务,提高了系统的整体响应速度。
7.3 进阶学习方向
掌握了本项目的基本内容后,可以进一步学习以下进阶主题:
一是学习使用FreeRTOS操作系统。在多任务环境下,如何安全地访问共享资源(环形缓冲区),如何实现多任务间的通信,是一个值得深入研究的话题。
二是学习其他外设的DMA应用,如ADC+DMA实现多通道采样、TIM+DMA实现任意波形生成、SPI+DMA实现高速数据传输等。DMA技术的应用范围非常广泛。
三是学习DMA与DMA之间的内存到内存传输,这在数据搬运场景中非常有用。
四是学习使用DMA的链式传输功能,可以连续执行多个DMA传输任务,进一步提高系统效率。
希望本教程能够帮助读者建立起对STM32 DMA通信的全面认识,为后续的嵌入式开发打下坚实的基础。技术的学习是一个循序渐进的过程,只有不断实践才能真正掌握所学知识。祝大家学习愉快!