【江科大STM32学习笔记-09】USART串口协议 - 9.1 STM32 USART串口外设

1 通信理论基础

STM32F1 系列微控制器内部集成了丰富的通信外设资源(如 USART、I2C、SPI 等)。在深入剖析 USART 协议栈及其底层硬件实现之前,必须先建立一套完备的数字通信分类框架。从工程物理实现与协议设计的宏观维度来看,数字通信通常可以从以下三个维度进行划分:

  • 按数据传输架构分类: 分为串行通信(Serial)与并行通信(Parallel),主要影响系统的布线复杂度与可实现的传输距离。
  • 按时钟同步机制 分类: 分为同步通信(Synchronous)与异步通信(Asynchronous),用于描述通信双方在数据位采样时所采用的时钟对齐机制。
  • 按数据传输方向分类: 分为单工(Simplex)、半双工(Half-duplex)与全双工(Full-duplex),用于描述通信链路的数据交互能力。

下面将对这些通信模式的基本原理进行简要说明,为后续理解 USART 串口协议及 STM32 USART 外设的工作机制奠定基础。

1.1 并行与串行

根据数据在物理总线上的空间分布方式,通信架构分为并行与串行两种:

1.1.1 并行通信(Parallel Communication)

并行通信将一个完整数据字(如 8 位、16 位或 32 位)的各个比特,分配到多条平行的独立数据线上,在同一个时钟节拍内同时发送。如下图所示:

  • 特点:多位数据同步传输,单位时间内吞吐量高,底层控制逻辑直观。
  • 局限:由于线间干扰(串扰)与信号同步偏差(偏斜),不适合长距离传输;引脚资源占用大。
  • 典型场景:芯片内部总线、FSMC 驱动并行接口屏幕。

1.1.2 串行通信(Serial Communication)

串行通信仅依赖单条(或一对差分)数据线,按照特定的时序协议,将数据流逐位(Bit by Bit)依次发送至总线。如下图所示:

  • 特点:仅需极少物理连线(如单线或差分对),大幅降低布线成本与引脚占用,抗干扰能力强。
  • 局限:需要额外的串并转换电路,协议开销(如起始位/停止位)会导致有效带宽下降。
  • 典型场景:板级传感器互连(I2C/SPI)、工业现场总线(RS-485/CAN)。

1.2 同步与异步

在串行通信中,接收端必须在微秒量级的位宽时间内,精准判定总线电平的采样时刻。这种将接收端时钟与发送端信号进行步调对齐的机制,便是通信中的同步问题。根据对齐策略的不同,串行通信分为同步与异步两类:

1.2.1 同步通信(Synchronous Communication)

在同步通信中,主机通过专用时钟线(例如 SPI 总线的 SCK 引脚、I2C 总线的 SCL 引脚)控制整个通信过程。收发双方严格遵循此时钟信号进行协调和数据同步,通信协议会明确规定在时钟的上升沿或下降沿对数据线(SDA/MOSI/MISO)进行采样。如下图所示:

  • 特点:由共享时钟线驱动,数据位边界由时钟沿严格锁定,传输效率极高(无冗余位)。
  • 局限:对时钟完整性要求高,布线需考虑时序对齐,不适合距离较远的物理节点互连。
  • 典型场景:SPI、I2C、单向同步串口。

1.2.2 异步通信(Asynchronous Communication)

异步通信是指通信的发送与接收设备使用各自的时钟控制数据的发送和接收过程,不共享物理时钟线。为使双方的收发协调,通常要求发送和接收设备的时钟尽可能一致。

异步通信以字符构成的 **帧(Frame,也叫字符帧、数据帧)**为单位进行传输,帧与帧之间的空闲间隔时间是任意的,帧内各数据位按固定的位间隔发送。如下图所示:

帧包含起始位、数据位、检验位与停止位。如下图所示:

  • 特点:收发双方物理独立、时钟解耦,物理连接最精简(仅需 TX/RX),系统容错性较好。
  • 局限:每一帧需附加起始/停止位(带宽损失约 20%~30%),且要求双方波特率偏差极小。
  • 典型场景:串口调试(UART)、点对点指令传输。

1.3 单工、半双工与全双工

在数据通信过程中,信息不仅需要按照既定的时序进行传输,还必须明确数据在通信链路中的流动方向。根据通信信道在同一时刻所允许的数据传输方向不同,通信方式通常分为单工、半双工和全双工三种类型:

1.3.1 单工(Simplex)

单工是指数据传输仅能沿一个方向,不能实现反向传输。如下图所示:

  • 特点:数据流向绝对固定,硬件成本极低。
  • 典型场景:单向传感器上报、广播广播。

1.3.2 半双工(Half-Duplex)

半双工是指可以允许数据双向传输,但受限于单一共享通道,收发操作需要严格分时进行。如下图所示:

  • 特点:共用物理通道分时收发,支持总线型拓扑。
  • 局限:同一时刻无法双向交互,存在总线竞争风险和切换死区时间。
  • 典型场景:RS-485 总线、单线制通信(如 DHT11)。

1.3.3 全双工(Full-Duplex)

全双工是指系统具备完全独立的数据发送线(TX)与接收线(RX),允许节点在同一时刻同步进行数据的收发操作(例如标准的 USART)。如下图所示:

  • 特点:具备独立的发送与接收路径,支持并发交互,实时性最高。
  • 典型场景:标准的 USART、SPI(MOSI/MISO)。

1.4 通信速率

通信速率是衡量数字通信系统 链路(Link,即发送端到接收端之间的数据通道)吞吐性能的核心指标,通常通过比特率(Bitrate)与波特率(Baud Rate)这两个物理量来进行量化。在嵌入式开发与底层协议调试过程中,准确区分两者的物理意义与数学关系是正确配置通信参数的前提。

1.4.1 比特率(Bit Rate)

比特率指每秒钟传输的二进制代码位数,单位为比特每秒(bit/s 或 bps)。它反映了通信链路在逻辑层面上的数据传输能力。

计算示例:若系统每秒传输 240 个完整的数据帧,且每个数据帧的格式包含 1 位起始位、8 位数据位和 1 位停止位(总计 10 位),则该链路的实际总比特率为:

1.4.2 波特率与码元

在探讨通信速率之前,必须首先理解数字信号在物理线路上的最小波形单元------码元。

(1)码元(Symbol)

码元是数字通信中携带信息的基本信号单元。从物理特性上看,它表现为一段特定持续时间的物理波形(如电压电平、相位跳变或光脉冲)。

  • 信号状态数(M):码元可以具备多种离散的物理状态。例如,在一根导线上,若规定 0V 和 3.3V 两种状态分别代表逻辑 0 和 1,则该通信系统的码元状态总数 M=2;若进一步细分电平,规定 0V、1V、2V、3V 四种状态分别代表 00、01、10、11,则状态总数 M=4,此时物理线路上的一次电平跳变(即一个码元)即可承载 2 位比特的信息量。

  • 信息承载量 :一个码元能携带多少位二进制比特,取决于其状态总数 M。根据信息论,单个码元蕴含的二进制位数为

(2)波特率(Baud)

波特率是指通信线路每秒钟传输的码元数量,单位为波特(Baud)。它衡量了物理信道电平状态改变的频率。如果一个码元的持续时间(位宽)为 T 秒,则波特率

(3) 码元状态与比特位的映射关系

码元支持的状态总数越多,单个码元能"打包"的二进制数据就越多。下表列出了不同状态数量下的映射关系:

| 码元类型 | 码元状态(逻辑示例) | 码元状态总数量 (M) | 码元所需比特位数 (log2​M) |
| 2种状态的码元 | 0、1 | 2 | 1 |
| 4种状态的码元 | 00、01、10、11 | 4 | 2 |

8种状态的码元 000、001、010、011、 100、101、110、111 8 3

通过下图展示的不同状态数量码元在物理线路上的波形时序与比特流映射关系可以直观观察到,在相同的时间周期内,通过增加竖坐标的电平状态层级(即增加 M 值),单个码元所承载的信息密度随之提升。

1.4.3 比特率与波特率的数学换算

基于上述码元与比特的映射逻辑,已知码元状态总数量 M(即物理信号共有多少种不同的状态)时,比特率()与波特率()的数学转换公式为:

反之,若已知链路的比特率与码元状态总数,推导底层波特率的公式为:

1.4.4 STM32 串口通信中的工程映射

在微控制器(如 STM32)的 基带串行通信(即直接利用高低电平跳变来代表 0 和 1,而不经过高频载波调制,如 TTL 电平标准的 UART)中,物理链路普遍采用基础的二进制调制。线路仅依靠高、低两种电平来表征逻辑状态(例如使用 0V 表示逻辑 0,3.3V 表示逻辑 1)。

在这种通信架构下,系统状态总数 M = 2。代入公式可知 ,即一个物理码元完全等效于一个二进制比特。

因此,在基础的微控制器串口通信规范内,波特率在数值上严格等于比特率。在日常工程交流与部分微控制器的技术文档中,这两个概念常被交替混用。但在涉及多进制调制(如 PAM-4 等)的高级通信领域,物理波形的单次跳变可携带多个比特的信息,此时两者的数值并不相等,必须进行严格的专业区分。


2 USART 串口协议

在明确了串行、异步与全双工等宏观通信理论之后,若要实现具体的硬件数据交互,必须进一步落实到具体的协议规范与硬件承载体上。由于在工程实践中"串口"一词常被模糊混用,在深入剖析物理层与协议层细节之前,有必要先对相关核心概念进行严格的界定与剥离。

2.1 概念辨析:串口通信协议、USART 协议与 USART 外设

在工程实践中,开发者常将"串口"一词混用。为了保持严谨性,必须从分类范畴、规范标准与硬件实体三个维度对以下概念进行严格界定:

  • 串口通信协议(广义的分类范畴):泛指所有按位(Bit)顺序排布、在单条或一对信号线上进行数据传输的通信机制总称。它是与"并行通信"相对立的概念集合,不仅包含异步串口,也涵盖 SPI、I2C、CAN 以及 USB 等协议。

  • USART 串口协议(狭义的规范标准):特指在串行通信体系下,收发双方进行异步(或同步)数据交互时所共同遵守的数据帧封装格式与时序规则。它定义了总线空闲状态以及起始位、数据位、校验位与停止位的逻辑构成。

  • USART(底层的硬件实体):即通用同步异步收发器(Universal Synchronous Asynchronous Receiver Transmitter),是集成在微控制器(如 STM32)内部的具体物理电路模块。其核心功能是在软件寄存器的驱动下,将 CPU 的并行数据转换为符合"USART 串口协议"规范的串行电平信号。

明确了上述概念边界后,本章将聚焦于USART 串口协议本身,从物理层(电气特性与拓扑连接)和协议层(数据帧结构)两个维度剖析其通信规范。

2.2 物理层:连接拓扑与电平标准

在数据传输前,首先需要解决物理硬件的布线规则以及电气信号的电平定义。

(1)硬件连接拓扑

串口通信最基本的点对点物理连接由三根信号线组成:发送线(TX)、接收线(RX)以及参考地线(GND)。

  • 交叉连接:设备 A 的发送端(TX)必须跨接至设备 B 的接收端(RX),反之亦然,以形成全双工的数据流回环。

  • 共地原则:通信双方必须连接 GND。共地为系统提供了统一的 0V 参考电位,这是准确判定信号线电平高低的前提。如下图所示:

(2)电平标准对比

在电气特性上,不同应用场景下的串口通信采用了不同的电压逻辑标准:

  • TTL 电平(板级通信):STM32 芯片的 GPIO 引脚直接输出与接收的信号遵循 TTL 标准。通常以 3.3V 或 5V 代表逻辑 1,以 0V 代表逻辑 0。该标准适合微控制器与板载传感器或模块之间的短距离通信。

  • RS-232 电平(工业/长距离):在传统的 PC 机或工业控制设备中,为了增强抗干扰能力并延长传输距离,通常采用 RS-232 标准。它使用负逻辑:-3V 至 -15V 的电压代表逻辑 1,+3V 至 +15V 的电压代表逻辑 0。

由于电平范围与逻辑定义完全不同,STM32 的 TTL 串口绝对不能直接与 RS-232 接口物理直连,否则会烧毁芯片 IO 口。两者之间必须通过专用的电平转换芯片(如 MAX3232)进行信号适配。

2.3 协议层:异步通信数据帧格式

由于异步通信摒弃了时钟线,接收端无法依赖外部时钟沿来采集数据。因此,数据流必须被严密封装为特定结构的 数据帧(Frame),依靠信号波形的跳变特征来实现收发相位的动态对齐。数据帧格式如下图所示:

如上图所示,一帧标准的异步串口数据按照时间轴的展开顺序,由以下五个逻辑段构成:

  • 空闲状态(Idle State) :当总线上无数据交互时,发送器强制将 TX 线拉高,保持逻辑 1(高电平)。这确保了线路处于已知的静默状态,并为识别后续的下降沿提供电平基础。

  • 起始位(Start Bit):固定为 1 个位宽的逻辑 0(低电平)。总线从空闲状态的高电平跌落至低电平,产生的下降沿是唤醒接收端的关键信号。接收端检测到该跳变后,内部的采样定时器被触发复位,开始进行位同步与数据接收。

  • 数据位(Data Bits):紧随起始位之后的是有效数据载荷。在 STM32 的 USART 规范中,数据位长度通常配置为 8 位或 9 位。协议规定采用低位先行(LSB First)原则。即字节的最低有效位LSB(Bit 0)最先被发送,最高有效位MSB最后被发送。

  • 校验位(Parity Bit):属于可选配置,紧跟在最高数据位之后,用于基本的数据链路层检错。分为奇校验(Odd Parity)、偶校验(Even Parity)或无校验(None)。

  • 停止位(Stop Bits):固定为逻辑 1(高电平),标志着当前数据帧的结束。在 STM32 中,停止位可配置为 0.5、1、1.5 或 2 个位宽。停止位的存在不仅为接收端提供了提取数据和重置状态机的恢复时间(Recovery Window),更重要的是,它强制将总线恢复到高电平状态,从而保证了下一个字符的起始位必然能产生一个有效的下降沿。

2.4 串口通信波形解析

最后,我们通过示波器捕获的实测波形来分析串口通信的物理特性。前文建立的异步串口帧模型,在实际系统中表现为 TX 引脚上随时间轴展开的电压序列。由于异步通信不依赖外部同步时钟,接收端必须通过识别波形中的关键特征(如起始位下降沿)来重建采样基准,并严格按照预设的位时间解析数据。

以下是示波器实测的不同配置参数对总线电平序列影响的波形对比:

通过对物理波形的对比与分析,可以总结出以下核心工程结论:

  • 波特率决定位宽时间:对比 9600 与 4800 波特率下发送相同数据(如 0x55)的波形。当波特率降低一半时,每一位状态(码元)的持续时间(位宽)相应变为原来的两倍(约 104us ---> 208us)。这种时间轴的比例缩放直观反映了通信速率的物理本质。

  • 起始位触发同步机制:所有数据帧均以一个由高到低的下降沿(起始位)开启。该跳变不仅标志着帧的起始,也是接收端对齐采样时钟的唯一物理依据。这表明异步通信并非绝对无同步,而是通过起始位实现每一帧的一次性时序对齐。

  • 数据位的 LSB 优先原则:UART 协议规定数据从最低有效位(LSB)开始传输。因此,对比发送 0x55(二进制 0101 0101)与 0xAA(二进制 1010 1010)的波形,0x55 在起始位后紧接发送逻辑 1(高电平),而 0xAA 则发送逻辑 0(低电平),两者波形在极性上呈现严格的镜像反转。

  • 校验位改变帧长度:对比 0x55 的无校验与偶校验波形。引入偶校验后,硬件会在 8 位数据位后额外插入一个校验比特,以确保帧内"1"的数量为偶数。这不仅改变了电平序列,也拉长了单帧的物理传输周期。

  • 停止位控制帧间恢复期:对比 1 位与 2 位停止位的波形。停止位强制总线回归高电平的空闲状态。增加停止位长度会拉长高电平的保持时间,为接收方处理数据提供更充裕的缓冲余量,并确保下一帧起始位能产生有效的下降沿。

  • 连续发送与空闲态判定:在连续发送数据(如连续 0x55)时,停止位结束后立即进入下一帧的起始位,中间可能无额外空闲间隔。这证明串口通信本质上是依赖"下降沿"而非"空闲时长"来界定帧边界的。

基于上述波形观察,在进行串口开发前,通信双方必须在软件层面强制统一波特率、数据位、校验位与停止位。

其根本原因在于:异步接收端以起始位下降沿为唯一同步基准,随后完全依赖本地时钟对数据位进行周期性采样。一旦双方参数配置不一致,采样点将产生相位偏移或帧边界判定错误,从而引发数据错位、乱码甚至硬件层面的帧错误(Framing Error)。


3 STM32F1 的 USART 外设架构

通用同步异步收发器(Universal Synchronous Asynchronous Receiver Transmitter,简称 USART)是微控制器内部最基础且广泛使用的串行通信硬件外设。在 STM32F103 系列微控制器中,USART 外设提供了一种高度灵活的全双工串行数据交换接口,用以满足各类工业标准数据格式的传输需求。

STM32F103C8T6 芯片内部仅集成 3 个 USART(USART1、USART2、USART3),不包含 UART4 和 UART5。UART 在 USART 的基础上裁剪了同步时钟通信功能,仅支持异步传输。USART 模块除了支持标准的异步串行通信外,还支持同步单向通信、半双工单线通信,并集成了 LIN(局域互连网络)、智能卡协议、IrDA(红外数据协会)SIR ENDEC 规范以及硬件流控制(CTS/RTS)。在最高总线频率支持下,其最大通信速率可达 4.5Mbits/s。在实际工程开发中,该外设常被用于与其他芯片的指令交互或重定向 printf 函数以输出系统调试信息。

其内部结构框图如下图所示:

将STM32 的 USART 架构分为引脚接口、数据缓冲、控制逻辑与波特率发生器4个模块分别进行介绍:

3.1 功能引脚

USART 外设的物理层交互需要依托特定的 GPIO 引脚来实现信号收发。一个完整的 USART 外设包含以下硬件引脚:

  • TX(发送数据输出):用于向外部设备串行移出数据。
  • RX(接收数据输入):用于接收外部设备的串行数据流。
  • SCLK(发送器时钟输出):仅在 USART 的同步模式下有效,对外输出同步时钟信号(UART4 与 UART5 无此引脚)。
  • nRTS(请求发送):请求以发送(Request To Send),n 表示低电平有效。如果使能 RTS 流 控制,当USART 接收器准备好接收新数据时就会将 nRTS 变成低电平;当接收寄存器已满时,nRTS 将被设置为高电平。该引脚只适用于硬件流控制。
  • **nCTS(清除发送):**清除以发送(Clear To Send),n 表示低电平有效。如果使能 CTS 流控制,发送器在发送下一帧数据之前会检测 nCTS 引脚,如果为低电平,表示可以发送数据,如果为高电平则在发送完当前数据帧之后停止发送。该引脚只适用于硬件流控制。
  • SW_RX(数据接收):内部引脚,数据接收引脚,仅用于单线和智能卡模式,无对应的外部物理引脚。

在 STM32F1 的总线架构中,外设挂载的总线直接决定了其基准工作时钟。针对中等容量的 STM32F103C8T6 芯片,其内部集成了 3 组 USART 资源(USART1、USART2、USART3)。

其中,USART1 挂载于高速总线 APB2 上(标准配置下最高时钟频率为 72MHz);USART2 与 USART3 则挂载于低速总线 APB1 上(标准配置下最高时钟频率为 36MHz)。该频率参数是内核波特率发生器进行时钟分频的核心基准。

根据 STM32F103C8T6 的引脚定义与资源分布,其外设与 GPIO 管脚的默认映射关系整理如下表:

外设名称 挂载总线 默认时钟频率 TX 引脚 (复用输出) RX 引脚 (输入)
USART1 APB2 72MHz PA9 PA10
USART2 APB1 36MHz PA2 PA3
USART3 APB1 36MHz PB10 PB11

3.2 数据寄存器

为了确保数据的高效收发并避免 CPU 频繁干预,STM32 的 USART 在硬件层面采用了双缓冲机制与独立的移位寄存器结构。如下图所示:

在软件代码层面,开发者操作的是一个单一的数据寄存器 USART_DR(仅低 9 位有效)。但在硬件底层,该地址映射着两个物理上完全独立的寄存器:一个专门用于发送的可写发送数据寄存器(TDR),以及一个专门用于接收的可读接收数据寄存器(RDR)。数据帧的具体有效长度由控制寄存器 USART_CR1 中的 M 位决定(M=0 为 8 位数据字长,M=1 为 9 位数据字长)。

  • 发送路径:CPU 或 DMA 控制器将待发送的数据写入 USART_DR(实际写入 TDR)。当底层的发送移位寄存器处于空闲状态时,硬件会自动将 TDR 中的数据并行转移至发送移位寄存器,并同时触发 TXE(发送数据寄存器空)标志位置位(对USART_DR的写操作,将该位清零)。随后,移位寄存器在设定的波特率时钟驱动下,将数据按低位先行的原则从 TX 引脚逐位串行移出。

  • 接收路径:来自外部的串行数据流通过 RX 引脚被逐位移入底层的接收移位寄存器。在成功接收完整的一帧数据后,硬件将数据并行转移至 RDR 中,并触发 RXNE(读数据寄存器非空)标志位置位。此时,CPU 对 USART_DR 执行读取操作即可获取 RDR 中的有效数据,硬件会在数据被读取后自动清除 RXNE 标志。

3.3 控制器

USART 模块内部集成了相互独立的发送器与接收器单元,分别负责串行数据的发送与接收,同时还包含唤醒控制、中断管理等辅助功能。整个 USART 的工作状态由控制寄存器和状态寄存器协同管理,使用前必须先将 USART_CR1 寄存器中的 UE(USART Enable) 位置 1,以使能整个 USART 模块。如下图所示:

3.3.1 发送器

发送器用于将 CPU 或存储器中的并行数据转换为符合 USART 协议格式的串行位流,并通过 TX 引脚输出。USART 可发送 8 位或 9 位数据,具体由 M 位配置决定。

当 USART_CR1 寄存器中的 TE(Transmitter Enable) 位置 1 后,发送器被使能,写入数据寄存器的数据将装载到发送移位寄存器中,并按照设定的波特率、起始位、数据位、校验位和停止位格式,依次从 TX 引脚输出;若工作在同步模式下,还会同时在 SCLK 引脚输出同步时钟脉冲。

3.3.2 接收器

接收器用于从 RX 引脚采样外部输入的串行数据,并将其还原为并行数据供 CPU 读取。当 USART_CR1 寄存器中的 RE(Receiver Enable) 位置 1 后,接收器开始监测 RX 线上的电平变化。

在异步通信模式下,接收器首先检测起始位下降沿,在确认到合法起始位后,按照本地波特率时序对后续数据位进行采样,并将接收到的数据暂存于接收移位寄存器中。待一帧数据接收完成后,硬件会自动将数据转移至数据寄存器,同时置位 USART_SR 寄存器中的 RXNE 标志,表示接收数据寄存器非空,CPU 此时即可读取接收到的数据。

3.3.3 中断控制

除了基本的发送与接收功能外,USART 还提供了较为完善的中断控制机制,可在关键事件发生时主动向 CPU 发出中断请求,从而提高数据处理的实时性。USART 的中断源较多,例如:发送数据寄存器为空(TXE)、发送完成(TC)、接收数据寄存器非空(RXNE)、空闲线路检测(IDLE)、奇偶校验错误(PE)、溢出错误(ORE) 等。

这些中断事件通常由事件标志位与中断使能位共同决定:只有当对应状态标志已经置位,且相应中断使能位被打开时,USART 才会向 NVIC 提交中断请求。对应关系如下表所示:

在硬件路由机制上,上述所有的 USART 中断源(如TXE、TC、RXNE 等)在模块内部经过逻辑或(OR)门汇总后,统一映射至 NVIC(嵌套向量中断控制器)的单一 USART 中断通道。如下图所示:

在实际应用中,最常用的是 RXNE 中断。当接收器完成一帧数据接收后,硬件会置位 RXNE 标志;若同时使能了 RXNEIE,则 USART 会立即申请中断,CPU 进入中断服务函数读取数据。这样可以避免因主程序轮询不及时而导致数据覆盖,从而降低发生 溢出错误(Overrun Error) 的风险。

3.4 波特率发生器

在异步通信中,收发双方必须强制约定相同的波特率,以确保接收端采样定时器的节拍与发送端的波形位宽一致。波特率决定了通信链路每秒传输的码元数量。

USART 模块通过内部集成的小数波特率发生器来提供精确的收发时钟。该波特率由 32 位的波特率寄存器 USART_BRR 控制。该寄存器的低 16 位有效,被划分为 12 位的整数部分(DIV_Mantissa[11:0])和 4 位的小数部分(DIV_Fraction[3:0])。如下图所示:

硬件内部的波特率生成公式如下:

  • : USART 外设所挂载 APB 总线的输入时钟频率。
  • :一个存放在波特率寄存器 (USART_BRR) 的一个无符号定点数。其中DIV_Mantissa[11:0]位定义USARTDIV 的整数部分,DIV_Fraction[3:0]位定义USARTDIV的小数部分。

该小数波特率发生器能够确保在标准的高频总线下,也能高精度地产生诸如 9600、115200 等非整数分频的标准波特率,有效控制通信过程中的时钟累积误差。


4 USART 串口配置步骤

在 STM32F1 系列中,使用标准外设库(Standard Peripheral Library)配置 USART 通信,通常需要完成时钟使能、GPIO 配置、串口参数设置、中断配置以及收发接口封装等内容。

这些步骤分别对应不同的硬件模块,共同决定串口能否正常工作。

下面结合典型工程代码,对 USART 的配置过程进行分步骤说明,并解释每一步的作用。

4.1 使能 USART 与 GPIO 的外设时钟

STM32 中的大多数外设在默认情况下都不会自动工作,必须先为其提供时钟,相关寄存器才能被正常访问。因此,在配置 USART1 之前,需要先开启 USART1 和 GPIOA 的时钟。

由于 USART1 以及其常用引脚 PA9、PA10 都挂载在 APB2 总线上,所以这里需要使能 APB2 对应的时钟。

cpp 复制代码
/* 使能 GPIOA 和 USART1 的 APB2 硬件时钟 */
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);

这一步的作用可以理解为:先让相关硬件模块进入工作状态,后续的 GPIO 配置和串口寄存器配置才会生效。

4.2 配置串口引脚功能与 GPIO 模式

USART 通信需要使用专门的发送引脚(TX)和接收引脚(RX)。对于 USART1,引脚为:发送引脚TX(PA9)和 接收引脚RX(PA10)。

在 STM32 中,GPIO 引脚既可以作为普通输入输出使用,也可以复用为外设功能。如果希望 PA9 和 PA10 用于串口通信,就需要根据它们的功能分别进行配置。

  • TX 引脚(PA9):TX 引脚负责向外发送串行数据,其电平变化由 USART 外设的发送移位寄存器直接控制,因此需要将 PA9 配置为复用推挽输出(Alternate Function Push-Pull)。

  • RX 引脚(PA10):RX 引脚用于接收外部输入的数据。串口线路空闲时通常保持高电平,因此常将 PA10 配置为上拉输入(Input Pull-Up),以便在无信号输入时维持稳定电平。

cpp 复制代码
GPIO_InitTypeDef GPIO_InitStructure;

/* 配置 PA9 (TX) 为复用推挽输出,交接硬件控制权至 USART1 */
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);

/* 配置 PA10 (RX) 为上拉输入,匹配总线空闲时的高电平状态 */
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);

完成这一步后,PA9 和 PA10 就具备了作为串口收发引脚的基本条件。

4.3 配置 USART 的通信参数

串口通信双方要正常交换数据,必须在基本参数上保持一致。这些参数通常包括波特率、数据位、停止位、校验位以及收发模式等。

在标准库中,USART 的参数通过 USART_InitTypeDef 结构体进行设置。下面的配置采用最常见的串口格式:8 位数据位、无校验、1 位停止位,即常说的 8N1。

cpp 复制代码
USART_InitTypeDef USART_InitStructure;

USART_InitStructure.USART_BaudRate = 9600;                                      // 配置通信波特率
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; // 禁用 RTS/CTS 硬件流控
USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;                 // 开启全双工收发模式
USART_InitStructure.USART_Parity = USART_Parity_No;                             // 禁用奇偶校验
USART_InitStructure.USART_StopBits = USART_StopBits_1;                          // 设定 1 个停止位
USART_InitStructure.USART_WordLength = USART_WordLength_8b;                     // 设定单帧有效数据位为 8 位

USART_Init(USART1, &USART_InitStructure);                                       // 将参数写入底层硬件寄存器

调用 USART_Init() 后,库函数会把这些参数写入 USART 的相关控制寄存器中。其中,波特率配置会结合外设时钟频率,计算出对应的分频值并写入波特率寄存器。

这一步完成后,USART1 的基本通信格式就已经确定。

4.4 配置接收中断与 NVIC

对于串口接收,有两种常见处理方式:

  • 轮询方式:不断检查是否收到数据
  • 中断方式:数据到达后由硬件自动触发中断

轮询方式实现简单,但 CPU 需要持续检查状态,效率较低。因此在实际工程中,更常用的是接收中断方式。要使用串口接收中断,需要完成两部分配置:

4.4.1 使能 USART 的接收中断源

当接收数据寄存器非空时,USART 会产生 RXNE 事件。使能这个中断后,收到数据时就可以触发中断服务程序。

cpp 复制代码
/* 开启 USART1 的 RXNE 中断 */
USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);

4.4.2 在 NVIC 中使能对应中断通道

USART 外设提出中断请求后,还需要 NVIC 允许该中断进入 CPU。因此还要配置 NVIC 的中断通道和优先级。

cpp 复制代码
/* NVIC 优先级分组配置(注:整个工程全局执行一次即可,建议置于主函数起始处) */
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);

/* 配置 USART1 在 NVIC 中的优先级通道 */
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;               // 指定 USART1 中断通道
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;                 // 使能该中断通道
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;       // 设定抢占优先级
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;              // 设定响应优先级
NVIC_Init(&NVIC_InitStructure);

这里需要注意,NVIC_PriorityGroupConfig() 是全局配置,整个工程一般只需要设置一次,通常放在主函数初始化阶段统一完成。

4.5 使能 USART 外设

在完成时钟、GPIO、通信参数和中断配置之后,还需要最后一步:打开 USART 外设本身。

cpp 复制代码
/* 使能 USART1 外设,启动硬件状态机 */
USART_Cmd(USART1, ENABLE);

该函数本质上是设置 USART 控制寄存器USART_CR1中的使能位UE(置 1),使串口模块正式进入工作状态。执行这一步之后,USART1 才真正开始参与发送和接收。

4.6 封装数据发送函数

虽然可以直接操作 USART 的数据寄存器完成发送,但在工程中,通常会把底层发送过程封装为独立函数,方便上层程序调用。

4.6.1 单字节发送

发送一个字节时,先将数据写入数据寄存器,然后等待发送数据寄存器为空标志 TXE 置位,置位则表示当前数据已经从数据寄存器转移到发送移位寄存器,可以继续写入下一字节。

cpp 复制代码
/**
  * 串口发送单个字节
  * 向数据寄存器写入 1 字节数据后,等待发送数据寄存器为空
  */
void Serial_SendByte(uint8_t Byte)
{
    USART_SendData(USART1, Byte);                                  // 将 1 字节数据写入 USART 数据寄存器
    while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);  // 等待发送数据寄存器为空,准备写入下一字节
    /* 下一次写入数据寄存器时,TXE 标志位会自动清除,无需手动处理 */
}

这里等待 TXE 的目的,是避免在前一个字节尚未完成转移时又写入新数据,从而导致数据覆盖。

4.6.2 发送数组

在单字节发送函数的基础上,可以很方便地扩展为数组发送。

cpp 复制代码
/**
  * 发送指定长度的字节数组
  */
void Serial_SendArray(uint8_t *Array, uint16_t Length)
{
    for (uint16_t i = 0; i < Length; i++)  // 按顺序遍历数组中的每个元素
    {
        Serial_SendByte(Array[i]);         // 逐字节发送数组内容
    }
}

4.6.3 发送字符串

字符串本质上是以 '\0' 结尾的字符数组,因此也可以逐字节发送。

cpp 复制代码
/**
  * 发送以 '\0' 结尾的字符串
  */
void Serial_SendString(char *String)
{
    for (uint8_t i = 0; String[i] != '\0'; i++)  // 遍历字符串,直到遇到结束符 '\0'
    {
        Serial_SendByte(String[i]);              // 逐个发送字符串中的字符
    }
}

4.6.4 发送数字

若需要通过串口发送十进制数字,通常不能直接把一个整数整体发出,而是要先将其拆分为一个个十进制数位,再把每一位转换为对应的字符,最后逐字节发送。

这是因为串口底层的发送函数 Serial_SendByte() 一次只能发送 1 个字节。它并不关心这个字节在逻辑上代表"数值"还是"字符",只负责把对应的二进制数据发出去。因此,若想让上位机或串口助手显示出"111"这样的十进制数字,就不能直接发送整数 111 的二进制形式,而必须按照文本字符的形式,依次发送字符 '1'、'1'、'1'。

下面的实现中,Serial_Pow() 用于计算 10 的幂,Serial_SendNumber() 则负责按位提取数字,并将每一位转换为字符后发送。

以 Serial_SendNumber(111, 3) 为例,数字 111 在串口中实际发送的并不是"一个 111",而是依次发送三个字符 '1'、'1'、'1'。也就是说,这里的发送过程本质上是:先拆位,再转字符,最后逐字节发送。下面以 111 的百位数字 1 为例,讲解 Serial_SendNumber() 的原理。

(1)先拆位

当 i = 0 时,程序先计算:

cpp 复制代码
Length - i - 1 = 3 - 0 - 1 = 2

这里得到的 2 表示:当前要处理的是一个 3 位数中的第 1 位,也就是百位,因此需要计算 10²。换句话说,这一步是在确定当前数位对应的 10 的幂次。

接着执行:

cpp 复制代码
Serial_Pow(10, 2) = 100

这一步的作用是根据当前处理的数位,求出对应的除数。这里得到的 100 表示当前数位的权值。百位的权值就是 100,十位的权值是 10,个位的权值是 1。

然后执行:

cpp 复制代码
111 / 100 = 1

这一步是用整个数字去除以当前数位的权值,把百位上的数字移到个位。

对于 111 来说,111 / 100 = 1,这是因为这里参与运算的 111、100 以及函数返回值都属于整型数据,在 C 语言中,整型与整型相除执行的是整数除法,结果只保留整数部分,小数部分会被直接舍去,因此结果不是 1.11,而是 1。这样就可以把原来的百位数字提取出来。

(2)转字符

在得到结果 1 后,程序继续执行:

cpp 复制代码
1 % 10 = 1

这一步的作用是只保留当前这一位的数值。虽然111 / 100 的结果本来就是 1,但对于其他情况,例如取111的十位时,111 / 10 = 11,此时得到的 11 包含了百位和十位两个数字,所以还需要再对 10 取余,这样就只保留了十位数字 1,百位数字被舍去,个位数字则已经在前面的除法过程中被移除了。

因此,取余操作的作用就是:把当前位之外的高位全部去掉,只留下当前位数字本身。

随后程序再加上 '0':

cpp 复制代码
1 + '0' = '1'

这里需要特别注意:串口发送的本质是二进制数据,而不是"数字概念"本身。如果直接发送数值 1,串口线上发出去的会是字节 0x01,它并不等于字符 '1'。而我们希望串口助手显示的是文本字符 1,因此必须把数值 1 转换成字符 '1' 对应的 ASCII 码。

为什么加上 '0' 就能实现这个转换?因为 ASCII 编码中,字符 '0' 到 '9' 的编码是连续的,见下表:

所以当 数字 1 加上字符'0' 时,本质上就是:1 + 48 = 49(十进制)。

而 49 正好就是字符 '1' 的 ASCII 码。也就是说,数字 1 加上字符 '0',实际上完成了"数值 1 → 字符 '1'"的转换。这样,程序最终发送出去的就不是原始数值,而是可被串口助手按文本显示的字符 '1'。

(3)逐字节发送

得到字符 '1' 后,程序调用:

cpp 复制代码
Serial_SendByte('1');

逐字节发送字符。循环完成后,百位、十位和个位依次发送,最终完成整个数字的输出。

cpp 复制代码
/**
  * 计算 X 的 Y 次方
  */
uint32_t Serial_Pow(uint32_t X, uint32_t Y)
{
    uint32_t Result = 1;         // 保存乘方结果,初值为 1
    while (Y--) Result *= X;     // 每循环一次乘以 1 个 X,共乘 Y 次
    return Result;               // 返回计算结果
}

/**
  * 发送十进制无符号数
  * 依次取出各位数字,并转换为对应的字符后发送
  */
void Serial_SendNumber(uint32_t Number, uint8_t Length)
{
    for (uint8_t i = 0; i < Length; i++)  // 按位从高位到低位依次处理
    {
        Serial_SendByte(Number / Serial_Pow(10, Length - i - 1) % 10 + '0');  // 取出当前位数字并转为字符发送
    }
}

这样,底层只保留最基本的字节发送功能,而数组、字符串和数字发送都可以在其基础上扩展实现。

4.7 C 标准库 printf 的格式化输出重定向

在调试过程中,仅靠发送单个字节或普通字符串,往往不便于输出变量值、格式化文本或多组调试信息。因此,在已经具备串口字节发送和字符串发送功能的基础上,通常还需要进一步实现类似 printf() 的格式化输出功能。

常见的实现方式有三种:

  • 重定向 printf() 到串口
  • 使用 sprintf() 先格式化到字符串,再发送
  • 封装可变参数函数,实现专用的串口 printf

4.7.1 重定向 fputc

在 C 标准库中,printf() 并不是直接操作串口,它本质上属于标准输出函数。默认情况下,printf() 的输出目标是标准输出流 stdout。在 PC 环境中,stdout 一般对应控制台窗口;而在单片机环境中,若未进行额外配置,stdout 通常没有默认的物理输出设备,因此即使调用 printf(),格式化后的内容也不会自动从串口发出。

因此,若希望 printf() 的内容通过串口输出,就需要将标准其底层的字符输出路径重定向到串口发送函数上。

常见做法是重写 fputc()。这是因为 printf() 在完成格式化之后,底层仍需要通过字符输出接口将结果逐个字符送出,而 printf() 在底层实际上就是调用 fputc()。重写 fputc() 之后,标准库在链接时会优先使用用户自己定义的版本,而不是库中的默认实现。因此,通过重写 fputc(),就可以把 printf() 最终的字符输出路径改到串口发送函数 Serial_SendByte() 上。

使用该方法时,通常还需要在工程选项中启用 Use MicroLIB,并包含头文件 <stdio.h>。

cpp 复制代码
#include <stdio.h>
#include <stdarg.h>

/**
  * 重定向 printf 到串口
  * 部分环境下需启用 Use MicroLIB
  */
int fputc(int ch, FILE *f)
{
    Serial_SendByte(ch);
    return ch;
}

配置完成后,即可直接使用:

cpp 复制代码
printf("\r\nNum2=%d", 222);

此时,printf() 格式化后的内容会被逐字符送入串口发送函数,并最终从 USART 输出。

这种方法实现最直接,调用方式与普通 printf() 完全一致,因此是最常见的一种做法。

但需要注意,printf() 只有一个标准输出路径,一旦重定向到某个串口后,其输出目标就固定下来。如果工程中有多个串口,并希望分别输出不同内容,那么这种方式就不够灵活。

4.7.2 自定义变参输出函数

如果不希望修改标准输出路径,也可以先使用 sprintf() 将格式化结果输出到字符数组中,再调用串口字符串发送函数将其发出。

cpp 复制代码
// 1、定义字符串缓冲区 
// 用于保存 sprintf() 格式化后的结果
// 数组长度需要能够容纳完整字符串以及结尾的 '\0'
char String[100];

// 2、按照指定格式将数据写入字符串缓冲区String[] 
// 第一个参数 String 表示输出位置,"\r\nNum3=%d" 为格式字符串,333 为待替换的数据
// 执行后,String 中保存的内容为 "\r\nNum3=333"
sprintf(String, "\r\nNum3=%d", 333);

// 3、发送格式化后的字符串
// 该函数会将数组 String 中的内容逐字符通过串口发送出去
// 至此完成"先格式化到内存,再发送到串口"的整个过程
Serial_SendString(String);

这种方式的核心思路是:先格式化到内存,再发送到串口。不依赖 stdout,也不需要重定向 printf()。由于格式化结果是先保存到用户自己定义的字符串缓冲区中,因此后续既可以通过串口发送,也可以交给其他模块继续处理。同时,它不受单一标准输出路径的限制,因此在多个串口或多个输出模块共存的场景下更容易使用。

不过,这种方式每次使用时都需要先定义缓冲区,再调用 sprintf(),最后再调用发送函数,步骤相对更繁琐一些。

4.7.3 封装可变参数输出函数

为了简化上一种方法中"定义缓冲区---格式化---发送"的重复操作,可以进一步在串口模块中封装一个专用的格式化输出函数,例如 Serial_Printf()。这种方法本质上仍然基于 sprintf() 一类的格式化思路,只是在其基础上增加了一层函数封装,使其使用方式更接近标准库中的 printf()。

其核心流程并没有改变,仍然是先将数据格式化为字符串,再将该字符串发送出去。不同之处在于,这里借助可变参数函数把"定义缓冲区、格式化输出、发送字符串"这一整套操作统一封装起来,因此在调用时无需每次手动定义字符串数组,也不必重复编写 sprintf() 相关代码。

cpp 复制代码
#include <stdarg.h>

/**
  * 串口格式化输出函数
  * 作用:先将可变参数按照指定格式整理成字符串
  * 再调用串口字符串发送函数,将结果输出到串口
  */
void Serial_Printf(char *format, ...)
{
    // 1、定义字符串缓冲区
    // 用于保存格式化后的结果字符串
    // 数组长度需要足够大,以容纳完整输出内容以及结尾的字符串结束符 '\0'
    char String[100];
    
    // 2、定义可变参数列表变量
    // va_list 是标准库中专门用于处理可变参数的类型
    // 这里的 arg 可以理解为"参数表访问变量",用于依次访问 ... 中传入的参数
    va_list arg;

    // 3、初始化可变参数列表
    // 表示从固定参数 format 之后开始,读取后续传入的可变参数
    // 将后续传入的可变参数交给 arg 管理,供后续函数按顺序读取
    va_start(arg, format);

    // 4、按照格式字符串将参数表中的内容写入缓冲区 String[]
    // format 用于指定输出格式,,例如 "%d" 表示整数,"%c" 表示字符
    // vsprintf() 会根据 format 中的格式说明,从 arg 中依次取出对应参数,
    // 再将它们转换为字符串,最终拼接写入 String 中
    // 这里使用 vsprintf 而不是 sprintf,是因为它可以直接处理 va_list 类型的参数表
    vsprintf(String, format, arg);

    // 5、结束可变参数的读取
    // 表示本次可变参数访问完成
    va_end(arg);

    // 6、发送格式化后的字符串
    // 该函数会将数组 String 中的内容逐字符通过串口发送出去
    // 至此完成"先格式化到内存,再发送到串口"的整个过程
    Serial_SendString(String);
}

例如,调用下面这条语句:

cpp 复制代码
Serial_Printf("Num=%d, Char=%c", 123, 'A');

执行过程可以大致理解为:

(1)固定参数与可变参数分离

函数调用时,format 接收到格式字符串为:

cpp 复制代码
"Num=%d, Char=%c"

而后面的 123 和 'A' 则属于可变参数。

(2)va_start(arg, format) 初始化参数表

执行 va_start(arg, format); 后,程序就知道:从 format 后面开始,后续的参数 123 和 'A' 都属于可变参数,并将它们交由 arg 管理。此时,arg 本身并不是保存所有参数值的数组,而更像是一个"参数表访问器",用于按顺序读取这些参数。

(3)vsprintf(String, format, arg) 按格式整理字符串

执行 vsprintf(String, format, arg); 时,程序会先读取格式字符串:"Num=%d, Char=%c",然后根据其中的格式说明,依次从 arg 中取出参数:

  • 遇到 %d,就取出整数 123
  • 遇到 %c,就取出字符 'A'

接着再把它们转换成对应的字符形式,并按顺序拼接到字符串中。因此执行完成后,缓冲区 String 中保存的内容就是:

cpp 复制代码
"Num=123, Char=A"

(4)Serial_SendString(String) 串口发送结果

最后再调用字符串发送函数,将 String 中的内容逐字符通过串口发送出去。

4.8 中断服务程序(ISR)处理逻辑

当串口接收到数据时,CPU 会自动跳转到对应的中断服务函数(ISR)执行。编写串口接收 ISR 时,建议遵循以下基本原则:

  • **中断源确认:**通过 USART_GetITStatus 判断当前中断是否由接收缓冲区非空(RXNE)触发,避免误判其他中断源。
  • **读取数据并清除标志:**调用 USART_ReceiveData 从数据寄存器中读取数据。需要注意的是,该操作不仅用于获取接收数据,同时也会在硬件层面自动清除 RXNE 标志位。如果未执行读取操作,则必须手动清除中断标志,否则中断将持续触发,程序无法正常退出。
  • **中断与主程序解耦:**中断服务函数应尽量简短,仅完成"数据读取 + 状态通知"的最小工作,不建议在其中执行耗时操作。通常通过设置一个全局标志位(如 Serial_RxFlag),将后续处理交由主循环完成,从而保证系统的实时性与响应效率。
cpp 复制代码
uint8_t Serial_RxData;      // 接收数据缓存
uint8_t Serial_RxFlag;      // 接收完成标志

/**
  * USART1 中断服务函数
  * 注意:函数名必须与启动文件中的中断向量名称一致
  */
void USART1_IRQHandler(void)
{
    /* 判断是否为 RXNE 中断 */
    if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET)
    {
        Serial_RxData = USART_ReceiveData(USART1);  // 读取数据(同时清除 RXNE 标志)
        Serial_RxFlag = 1;                          // 通知主程序有新数据到达
    }
}

为方便主程序使用,通常会封装简单的接口函数,实现"读标志---自动清零"的逻辑:

cpp 复制代码
/**
  * 获取接收完成标志(读后自动清零)
  */
uint8_t Serial_GetRxFlag(void)
{
    if (Serial_RxFlag == 1)
    {
        Serial_RxFlag = 0;
        return 1;
    }
    return 0;
}

/**
  * 获取接收到的数据
  */
uint8_t Serial_GetRxData(void)
{
    return Serial_RxData;
}

主函数 main.c 中可采用轮询方式检测该标志,从而实现中断与业务逻辑的分离:

cpp 复制代码
/* 主循环中采用非阻塞方式查询软件标志,彻底分离底层收发与上层显示 */
while (1)
{
    if (Serial_GetRxFlag() == 1)            // 侦测到完整数据帧落地
    {
        RxData = Serial_GetRxData();        // 安全提取数据
        Serial_SendByte(RxData);            // 物理层数据回环测试
        OLED_ShowHexNum(1, 8, RxData, 2);   // UI 层更新显示
    }
}

4.9 字符编码规范与乱码规避

串行通信在硬件层面仅负责传输字节数据,本身并不具备任何"字符编码"的概念。也就是说,对于串口而言,发送的只是一个个数值(如 0xE4),至于这些数值最终显示成什么字符,完全取决于接收端的软件如何解析。

因此,终端上看到的字符,本质上是"接收字节流 + 解码规则"的结果,而不是串口本身决定的。在实际开发中,如果出现字符串显示为乱码,其根本原因通常是:发送端与接收端使用的字符编码不一致。

例如,单片机工程中的源码文件可能采用 UTF-8 编码,而串口调试助手使用的是 GBK 编码。当同一组字节按不同规则解析时,就会出现显示异常。要解决乱码问题,关键在于保证整个链路的编码一致,即:发送端编码方式 与 接收端解码方式 必须保持统一。

常见的解决思路包括:

  • **统一使用 UTF-8 编码(推荐):**将工程文件保存为 UTF-8,同时将串口调试工具的显示编码设置为 UTF-8。
  • **仅使用 ASCII 字符进行调试:**在调试阶段尽量只发送数字、字母等基础字符,避免涉及多字节编码,从而规避乱码问题。
  • **使用十六进制方式查看数据:**直接以原始字节形式观察通信内容,绕过字符编码带来的影响。

总的来说,串口通信本身不会产生乱码问题,所谓"乱码",本质上是数据在解释阶段出现了偏差。只要保证编码一致,问题即可彻底避免。


5 相关元器件简介

5.1 USB转串口模块

在嵌入式系统开发与调试阶段,微控制器(如 STM32)通常通过 USART 外设输出底层运行状态或接收控制指令。由于现代 PC 机已全面取消了传统的物理 RS-232 串口,因此必须引入 USB 转串口模块作为通信桥梁。

5.1.1 器件简介

USB 转串口模块主要用于将 PC 端的 USB 协议信号转换为微控制器可识别的 TTL 电平异步串行信号。本文以广泛应用的 CH340G 模块为例进行阐述。相比于早期的 PL2303 方案,CH340G 芯片在底层协议转换机制上进行了优化,具备更高的工作稳定性与更广泛的操作系统兼容性。该器件在硬件开发中常用于实现程序的 ISP 下载(系统内编程)以及与上位机串口调试助手的双向数据通信。实物如下图所示:

5.1.2 接口描述

模块对外提供了一组标准排针,用于与目标微控制器进行物理连接与电气匹配。标准引脚定义及功能约束如下:

  • 电压选择跳线(VCC / 5V / 3V3):模块集成了电平转换功能,通过改变短路帽的物理位置,可设定 TTL 信号的逻辑电平参考值。STM32 属于 3.3V 逻辑系统,对接时必须将短路帽接至 3.3V 档位,若错误配置为 5V 模式,其输出的高电平信号极易击穿微控制器的 GPIO 内部结构。

  • TXD(串行数据发送):模块的数据输出端。在接线时,必须采用交叉连接原则,即接入目标微控制器的 RX 引脚。

  • RXD(串行数据接收):模块的数据输入端。同理,需交叉连接至目标微控制器的 TX 引脚。

  • GND(公共地):为收发双方提供统一的电气参考零电位。通信双方未共地是导致信号电平漂移和通信乱码的最常见原因。

  • PWR / TXD / RXD 指示灯:PWR 为电源状态灯。TXD 与 RXD 指示灯的闪烁频率直接映射了物理链路上的数据流密度。在缺乏示波器等专业逻辑分析仪器的环境中,开发者可通过观察这两个指示灯的明暗变化,直观判别总线的空闲/忙碌状态以及数据交互方向。

5.1.3 硬件结构

该模块的硬件拓扑结构相对精简,主要由以下核心电路单元构成:

  • 主控协议芯片(CH340G):模块的核心大脑,内置完整的 USB 2.0 协议栈硬件解码器与标准 UART 状态机,负责两种异构总线之间的高速数据流转。

  • USB 物理接口:通常为 USB Type-A 公头,用于直接插入 PC 机端口,在建立数据链路的同时,直接从 USB 总线获取标准的 5V 直流电源(VCC+5V)为模块供电。

  • 稳压二极管稳压电路:与传统的 LDO 方案不同,本模块采用了由限流电阻(R1)与稳压二极管(VD1)构成的并联稳压电路。该电路利用稳压管的反向击穿特性,将 5V 电压降至约 3.3V(VCC+3V3),用于维持 CH340G 芯片的逻辑电平参考。同时,由于稳压二极管电路的电流输出能力(带载能力)受限流电阻 R1 的制约且效率较低,该 3.3V 输出仅建议作为电平参考或驱动极小功耗的逻辑器件,严禁将其作为外部核心板(如 STM32)的主供电电源。

  • 状态指示灯与外围网络:模块集成了电源指示灯(PWR_DSP)、发送监测灯(TXD_DSP)及接收监测灯(RXD_DSP),通过限流电阻连接至信号线,实时反馈通信状态。同时配有 12MHz 石英晶体振荡器为芯片提供精确的时钟基准。

5.1.4 工作原理

USB 转串口模块的工作机制建立在硬件协议栈解析与操作系统驱动映射的基础之上:

(1)虚拟串口映射(VCP 技术)

在 PC 端安装 CH340 专有驱动后,操作系统的底层枚举机制会将该硬件识别为一个标准的虚拟串行通信端口(Virtual COM Port,例如分配为 COM3)。这一机制对应用层完全透明,使得串口调试助手可以直接调用标准的系统 API 进行串口读写,而无需干预复杂的 USB 数据包构造。

(2)时序转换与封包

  • 下行传输:当上位机软件下发数据时,数据流被驱动层打包成 USB 传输帧,经由 USB 总线抵达 CH340G 芯片。芯片内部逻辑将其解包并按照约定的波特率、数据位等规则,转换为符合串口协议的 TTL 时序信号,从 CH340G_TXD 引脚移出。

  • 上行传输:CH340G_RXD 引脚捕获到的异步串行电平脉冲被芯片逆向封包,通过 USB 链路上传至 PC 端缓冲区。

(3)电平匹配逻辑

通过跳线帽的选择,模块可以灵活切换工作电压。当短接 VCC+3V3 与 CH340G_VCC 时,CH340G 芯片工作在 3.3V 逻辑电平下,从而实现与 STM32 等 3.3V 器件的直接电平兼容,无需额外的电平转换电路。

(4)环回测试验证

在排除硬件故障时,标准的校验方法是将模块的 TXD 与 RXD 引脚(对应原理图中的插针)直接短接。此时,上位机发送的任何字符均会在物理层被回环至接收端。若终端能无损、无延迟地接收到自身发出的字符,即可证明模块的 USB 链路、驱动及协议芯片功能均正常。


6. 本章节实验

6.1 串口发送

6.1.1 实验目标

  • 掌握 USART 发送端的底层配置流程:理解 GPIO 复用推挽输出模式在串行通信中的作用,并熟练配置波特率、数据位等帧格式参数。

  • 熟悉多级数据发送接口的封装逻辑:学习如何基于底层的单字节传输,通过指针与循环结构抽象出数组、字符串与数字格式转换的发送函数。

  • 理解硬件状态标志位的同步机制:掌握 TXE(发送数据寄存器空)标志位在保证数据流连续性与防止覆盖溢出中的关键作用。

  • 掌握 I/O 重定向与变参函数封装技术:实现将标准 C 库的 printf 输出流重定向至 USART 物理接口,并构建独立的可变参数格式化打印模块。

6.1.2 硬件设计

6.1.3 软件设计

本实验采用分层封装的软件架构,将寄存器级操作与应用层逻辑隔离,具体流程如下:

(1)硬件初始化模块(基于 USART1)

  • **引脚映射:**配置 PA9 为复用推挽输出(GPIO_Mode_AF_PP),将引脚的电平控制权交由 USART1 外设。
  • **协议参数配置:**将 USART1 配置为纯发送模式(USART_Mode_Tx),波特率设为 9600,帧格式设定为 8 位字长、1 位停止位且无硬件流控与奇偶校验。

(2)数据收发抽象层(Serial.c 模块)

  • **字节级驱动:**通过向 USART_DR 寄存器写入数据,并采用 while 轮询 TXE 标志位等待转移完成,构建最基础的 Serial_SendByte 函数。
  • **批量数据流封装:**基于字节级驱动,封装 Serial_SendArray 与 Serial_SendString(利用 \0 终止符判定结束),实现数据块的高效推送。
  • **数值解码发送:**构建 Serial_SendNumber 函数,利用求余除法提取十进制各位数值,并加上偏移量 0x30(字符 '0')转换为 ASCII 码输出。

(3)格式化输出流重定向模块

  • **底层重写:**重写 fputc 钩子函数,将其内部实现指向 Serial_SendByte,结合 MicroLIB 开启单通道的 printf 支持。
  • **缓冲与变参封装:**为解决多外设通道复用问题,利用 <stdarg.h> 提供的 va_list 机制与 vsprintf 函数,将格式化变长参数解析至内存字符数组,再经由字符串发送接口输出,构建独立的 Serial_Printf。

具体代码如下:

main.c文件:

cpp 复制代码
#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "Serial.h"

int main(void)
{
	/*模块初始化*/
	OLED_Init();						//OLED初始化
	
	Serial_Init();						//串口初始化
	
	/*串口基本函数*/
	Serial_SendByte(0x41);				//串口发送一个字节数据0x41
	
	uint8_t MyArray[] = {0x42, 0x43, 0x44, 0x45};	//定义数组
	Serial_SendArray(MyArray, 4);		//串口发送一个数组
	
	Serial_SendString("\r\nNum1=");		//串口发送字符串
	
	Serial_SendNumber(111, 3);			//串口发送数字
	
	/*下述3种方法可实现printf的效果*/
	
	/*方法1:直接重定向printf,但printf函数只有一个,此方法不能在多处使用*/
	printf("\r\nNum2=%d", 222);			//串口发送printf打印的格式化字符串
										//需要重定向fputc函数,并在工程选项里勾选Use MicroLIB
	
	/*方法2:使用sprintf打印到字符数组,再用串口发送字符数组,此方法打印到字符数组,之后想怎么处理都可以,可在多处使用*/
	char String[100];					//定义字符数组
	sprintf(String, "\r\nNum3=%d", 333);//使用sprintf,把格式化字符串打印到字符数组
	Serial_SendString(String);			//串口发送字符数组(字符串)
	
	/*方法3:将sprintf函数封装起来,实现专用的printf,此方法就是把方法2封装起来,更加简洁实用,可在多处使用*/
	Serial_Printf("\r\nNum4=%d", 444);	//串口打印字符串,使用自己封装的函数实现printf的效果
	Serial_Printf("\r\n");
	
	while (1)
	{
		
	}
}

Serial.c文件:

cpp 复制代码
#include "stm32f10x.h"                  // Device header
#include <stdio.h>
#include <stdarg.h>

/**
  * 函    数:串口初始化
  * 参    数:无
  * 返 回 值:无
  */
void Serial_Init(void)
{
	/*开启时钟*/
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);	//开启USART1的时钟
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);	//开启GPIOA的时钟
	
	/*GPIO初始化*/
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);					//将PA9引脚初始化为复用推挽输出
	
	/*USART初始化*/
	USART_InitTypeDef USART_InitStructure;					//定义结构体变量
	USART_InitStructure.USART_BaudRate = 9600;				//波特率
	USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;	//硬件流控制,不需要
	USART_InitStructure.USART_Mode = USART_Mode_Tx;			//模式,选择为发送模式
	USART_InitStructure.USART_Parity = USART_Parity_No;		//奇偶校验,不需要
	USART_InitStructure.USART_StopBits = USART_StopBits_1;	//停止位,选择1位
	USART_InitStructure.USART_WordLength = USART_WordLength_8b;		//字长,选择8位
	USART_Init(USART1, &USART_InitStructure);				//将结构体变量交给USART_Init,配置USART1
	
	/*USART使能*/
	USART_Cmd(USART1, ENABLE);								//使能USART1,串口开始运行
}

/**
  * 函    数:串口发送一个字节
  * 参    数:Byte 要发送的一个字节
  * 返 回 值:无
  */
void Serial_SendByte(uint8_t Byte)
{
	USART_SendData(USART1, Byte);		//将字节数据写入数据寄存器,写入后USART自动生成时序波形
	while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);	//等待发送完成
	/*下次写入数据寄存器会自动清除发送完成标志位,故此循环后,无需清除标志位*/
}

/**
  * 函    数:串口发送一个数组
  * 参    数:Array 要发送数组的首地址
  * 参    数:Length 要发送数组的长度
  * 返 回 值:无
  */
void Serial_SendArray(uint8_t *Array, uint16_t Length)
{
	uint16_t i;
	for (i = 0; i < Length; i ++)		//遍历数组
	{
		Serial_SendByte(Array[i]);		//依次调用Serial_SendByte发送每个字节数据
	}
}

/**
  * 函    数:串口发送一个字符串
  * 参    数:String 要发送字符串的首地址
  * 返 回 值:无
  */
void Serial_SendString(char *String)
{
	uint8_t i;
	for (i = 0; String[i] != '\0'; i ++)//遍历字符数组(字符串),遇到字符串结束标志位后停止
	{
		Serial_SendByte(String[i]);		//依次调用Serial_SendByte发送每个字节数据
	}
}

/**
  * 函    数:次方函数(内部使用)
  * 返 回 值:返回值等于X的Y次方
  */
uint32_t Serial_Pow(uint32_t X, uint32_t Y)
{
	uint32_t Result = 1;	//设置结果初值为1
	while (Y --)			//执行Y次
	{
		Result *= X;		//将X累乘到结果
	}
	return Result;
}

/**
  * 函    数:串口发送数字
  * 参    数:Number 要发送的数字,范围:0~4294967295
  * 参    数:Length 要发送数字的长度,范围:0~10
  * 返 回 值:无
  */
void Serial_SendNumber(uint32_t Number, uint8_t Length)
{
	uint8_t i;
	for (i = 0; i < Length; i ++)		//根据数字长度遍历数字的每一位
	{
		Serial_SendByte(Number / Serial_Pow(10, Length - i - 1) % 10 + '0');	//依次调用Serial_SendByte发送每位数字
	}
}

/**
  * 函    数:使用printf需要重定向的底层函数
  * 参    数:保持原始格式即可,无需变动
  * 返 回 值:保持原始格式即可,无需变动
  */
int fputc(int ch, FILE *f)
{
	Serial_SendByte(ch);			//将printf的底层重定向到自己的发送字节函数
	return ch;
}

/**
  * 函    数:自己封装的prinf函数
  * 参    数:format 格式化字符串
  * 参    数:... 可变的参数列表
  * 返 回 值:无
  */
void Serial_Printf(char *format, ...)
{
	char String[100];				//定义字符数组
	va_list arg;					//定义可变参数列表数据类型的变量arg
	va_start(arg, format);			//从format开始,接收参数列表到arg变量
	vsprintf(String, format, arg);	//使用vsprintf打印格式化字符串和参数列表到字符数组中
	va_end(arg);					//结束变量arg
	Serial_SendString(String);		//串口发送字符数组(字符串)
}

6.1.4 实验现象

系统上电复位后,单片机按预定逻辑向外部总线主动推送数据序列:

  • 在 PC 端串口助手配置为 HEX 模式时,接收区呈现原始的十六进制字节序列(如 41 42 43 44 45....)。
  • 切换至文本模式时,接收区正确解析并显示预定义的字符串、数值转化后的 ASCII 字符,以及经由 printf 与封装宏格式化的多行文本序列,每次传输均包含标准的 \r\n 回车换行控制符。

6.2 串口发送+接收

6.2.1 实验目标

  • 掌握 USART 全双工通信的硬件体系:理解发送(TX)与接收(RX)物理链路的独立性,并完成相应的 GPIO 输入输出复合配置。

  • 构建中断驱动的异步接收架构:学习 NVIC 嵌套向量中断控制器的初始化,掌握如何利用外设中断响应外部高频突发事件。

  • 解析接收标志位与寄存器操作的硬件耦合:理解 RXNE(读数据寄存器非空)标志位的触发条件,以及读取 USART_DR 自动清零标志位的硬件特性。

  • 实现中断服务与主程序的业务解耦:掌握在中断服务例程(ISR)中仅执行数据转存与标志抛出,由主循环负责业务处理的经典软件架构。

6.2.2 硬件设计

6.2.3 软件设计

本实验扩展了异步接收链路,并引入了基于 NVIC 的中断响应机制,具体流程如下:

(1)全双工链路配置模块

  • 复合引脚配置:在保留 PA9 输出设定的同时,将接收引脚 PA10 配置为上拉输入(GPIO_Mode_IPU),以维持总线空闲期的高电平状态。
  • 收发模式使能:USART 模式寄存器配置更新为 USART_Mode_Tx | USART_Mode_Rx,开启内部接收移位寄存器的工作时钟。

(2)中断服务与响应模块

  • 中断源路由:调用 USART_ITConfig 使能接收非空中断(USART_IT_RXNE),并配置 NVIC 结构体,分配抢占优先级与响应优先级,打通 USART1 至 CPU 的中断请求路径。
  • 中断上下文处理:在硬件定义的中断向量 USART1_IRQHandler 内,严格判断 RXNE 标志位。确认接收事件后,立即提取 USART_DR 中的有效载荷至全局缓冲变量 Serial_RxData,并置位应用层软件标志 Serial_RxFlag,整个过程极速退出,不阻塞 CPU 执行流程。

(3)应用层数据解算与回显

  • 非阻塞轮询:主程序 while(1) 循环持续调用 Serial_GetRxFlag() 检查新数据到达状态,检测命中后自动清除软件标志。
  • 业务处理:提取缓存数据后,将其十六进制数值实时刷新至 OLED 屏幕,并立即调用 Serial_SendByte 将该字节原样注入发送 TDR 寄存器,完成数据回显(Loopback Test)。

具体代码如下:

main.c文件:

cpp 复制代码
#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "Serial.h"

uint8_t RxData;			//定义用于接收串口数据的变量

int main(void)
{
	/*模块初始化*/
	OLED_Init();		//OLED初始化
	
	/*显示静态字符串*/
	OLED_ShowString(1, 1, "RxData:");
	
	/*串口初始化*/
	Serial_Init();		//串口初始化
	
	while (1)
	{
		if (Serial_GetRxFlag() == 1)			//检查串口接收数据的标志位
		{
			RxData = Serial_GetRxData();		//获取串口接收的数据
			Serial_SendByte(RxData);			//串口将收到的数据回传回去,用于测试
			OLED_ShowHexNum(1, 8, RxData, 2);	//显示串口接收的数据
		}
	}
}

Serial.c文件:

cpp 复制代码
#include "stm32f10x.h"                  // Device header
#include <stdio.h>
#include <stdarg.h>

uint8_t Serial_RxData;		//定义串口接收的数据变量
uint8_t Serial_RxFlag;		//定义串口接收的标志位变量

/**
  * 函    数:串口初始化
  * 参    数:无
  * 返 回 值:无
  */
void Serial_Init(void)
{
	/*开启时钟*/
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);	//开启USART1的时钟
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);	//开启GPIOA的时钟
	
	/*GPIO初始化*/
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);					//将PA9引脚初始化为复用推挽输出
	
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);					//将PA10引脚初始化为上拉输入
	
	/*USART初始化*/
	USART_InitTypeDef USART_InitStructure;					//定义结构体变量
	USART_InitStructure.USART_BaudRate = 9600;				//波特率
	USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;	//硬件流控制,不需要
	USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;	//模式,发送模式和接收模式均选择
	USART_InitStructure.USART_Parity = USART_Parity_No;		//奇偶校验,不需要
	USART_InitStructure.USART_StopBits = USART_StopBits_1;	//停止位,选择1位
	USART_InitStructure.USART_WordLength = USART_WordLength_8b;		//字长,选择8位
	USART_Init(USART1, &USART_InitStructure);				//将结构体变量交给USART_Init,配置USART1
	
	/*中断输出配置*/
	USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);			//开启串口接收数据的中断
	
	/*NVIC中断分组*/
	NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);			//配置NVIC为分组2
	
	/*NVIC配置*/
	NVIC_InitTypeDef NVIC_InitStructure;					//定义结构体变量
	NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;		//选择配置NVIC的USART1线
	NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;			//指定NVIC线路使能
	NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;		//指定NVIC线路的抢占优先级为1
	NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;		//指定NVIC线路的响应优先级为1
	NVIC_Init(&NVIC_InitStructure);							//将结构体变量交给NVIC_Init,配置NVIC外设
	
	/*USART使能*/
	USART_Cmd(USART1, ENABLE);								//使能USART1,串口开始运行
}

/**
  * 函    数:串口发送一个字节
  * 参    数:Byte 要发送的一个字节
  * 返 回 值:无
  */
void Serial_SendByte(uint8_t Byte)
{
	USART_SendData(USART1, Byte);		//将字节数据写入数据寄存器,写入后USART自动生成时序波形
	while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);	//等待发送完成
	/*下次写入数据寄存器会自动清除发送完成标志位,故此循环后,无需清除标志位*/
}

/**
  * 函    数:串口发送一个数组
  * 参    数:Array 要发送数组的首地址
  * 参    数:Length 要发送数组的长度
  * 返 回 值:无
  */
void Serial_SendArray(uint8_t *Array, uint16_t Length)
{
	uint16_t i;
	for (i = 0; i < Length; i ++)		//遍历数组
	{
		Serial_SendByte(Array[i]);		//依次调用Serial_SendByte发送每个字节数据
	}
}

/**
  * 函    数:串口发送一个字符串
  * 参    数:String 要发送字符串的首地址
  * 返 回 值:无
  */
void Serial_SendString(char *String)
{
	uint8_t i;
	for (i = 0; String[i] != '\0'; i ++)//遍历字符数组(字符串),遇到字符串结束标志位后停止
	{
		Serial_SendByte(String[i]);		//依次调用Serial_SendByte发送每个字节数据
	}
}

/**
  * 函    数:次方函数(内部使用)
  * 返 回 值:返回值等于X的Y次方
  */
uint32_t Serial_Pow(uint32_t X, uint32_t Y)
{
	uint32_t Result = 1;	//设置结果初值为1
	while (Y --)			//执行Y次
	{
		Result *= X;		//将X累乘到结果
	}
	return Result;
}

/**
  * 函    数:串口发送数字
  * 参    数:Number 要发送的数字,范围:0~4294967295
  * 参    数:Length 要发送数字的长度,范围:0~10
  * 返 回 值:无
  */
void Serial_SendNumber(uint32_t Number, uint8_t Length)
{
	uint8_t i;
	for (i = 0; i < Length; i ++)		//根据数字长度遍历数字的每一位
	{
		Serial_SendByte(Number / Serial_Pow(10, Length - i - 1) % 10 + '0');	//依次调用Serial_SendByte发送每位数字
	}
}

/**
  * 函    数:使用printf需要重定向的底层函数
  * 参    数:保持原始格式即可,无需变动
  * 返 回 值:保持原始格式即可,无需变动
  */
int fputc(int ch, FILE *f)
{
	Serial_SendByte(ch);			//将printf的底层重定向到自己的发送字节函数
	return ch;
}

/**
  * 函    数:自己封装的prinf函数
  * 参    数:format 格式化字符串
  * 参    数:... 可变的参数列表
  * 返 回 值:无
  */
void Serial_Printf(char *format, ...)
{
	char String[100];				//定义字符数组
	va_list arg;					//定义可变参数列表数据类型的变量arg
	va_start(arg, format);			//从format开始,接收参数列表到arg变量
	vsprintf(String, format, arg);	//使用vsprintf打印格式化字符串和参数列表到字符数组中
	va_end(arg);					//结束变量arg
	Serial_SendString(String);		//串口发送字符数组(字符串)
}

/**
  * 函    数:获取串口接收标志位
  * 参    数:无
  * 返 回 值:串口接收标志位,范围:0~1,接收到数据后,标志位置1,读取后标志位自动清零
  */
uint8_t Serial_GetRxFlag(void)
{
	if (Serial_RxFlag == 1)			//如果标志位为1
	{
		Serial_RxFlag = 0;
		return 1;					//则返回1,并自动清零标志位
	}
	return 0;						//如果标志位为0,则返回0
}

/**
  * 函    数:获取串口接收的数据
  * 参    数:无
  * 返 回 值:接收的数据,范围:0~255
  */
uint8_t Serial_GetRxData(void)
{
	return Serial_RxData;			//返回接收的数据变量
}

/**
  * 函    数:USART1中断函数
  * 参    数:无
  * 返 回 值:无
  * 注意事项:此函数为中断函数,无需调用,中断触发后自动执行
  *           函数名为预留的指定名称,可以从启动文件复制
  *           请确保函数名正确,不能有任何差异,否则中断函数将不能进入
  */
void USART1_IRQHandler(void)
{
	if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET)		//判断是否是USART1的接收事件触发的中断
	{
		Serial_RxData = USART_ReceiveData(USART1);				//读取数据寄存器,存放在接收的数据变量
		Serial_RxFlag = 1;										//置接收标志位变量为1
		USART_ClearITPendingBit(USART1, USART_IT_RXNE);			//清除USART1的RXNE标志位
																//读取数据寄存器会自动清除此标志位
																//如果已经读取了数据寄存器,也可以不执行此代码
	}
}

6.2.4 实验现象

系统处于持续监听状态,OLED 屏幕初始化显示静态提示符:

  • 当通过 PC 端串口助手向单片机发送任意十六进制单字节数据(例如 0xff)时,总线电平跳变触发硬件中断解析。
  • OLED 屏幕的数据显示区瞬间刷新为接收到的数值(显示为 ff)。
  • 与此同时,PC 端串口助手的接收区同步捕获到单片机回传的同一数值(ff),验证了全双工物理链路的完整性与中断收发逻辑的正确性。
相关推荐
C羊驼2 小时前
C语言:随机数
c语言·开发语言·经验分享·笔记·算法
奶茶精Gaaa2 小时前
AI实战(二)生成ui自动化
功能测试·学习·自动化
逐步前行2 小时前
STM32_USART_寄存器操作
stm32·单片机·嵌入式硬件
沐欣工作室_lvyiyi2 小时前
基于单片机的多参数监护仪系统(论文+源码)
stm32·单片机·嵌入式硬件·多参数监护仪
red_redemption3 小时前
自由学习记录(141)
学习
xian_wwq3 小时前
【学习笔记】看参识模型
笔记·学习
星雨流星天的笔记本3 小时前
1、用于制备钙钛矿量子点的新三颈烧瓶的洗涤与使用方法
学习
猹叉叉(学习版)3 小时前
【系统分析师_知识点整理】 3.数据库系统
数据库·笔记·软考·系统分析师
李子琪。3 小时前
攀山的人
经验分享·笔记·百度·新浪微博