【江科大STM32学习笔记-09】USART串口协议 - 9.2 USART串口数据包

1 数据包概述

在嵌入式系统的串口通信中,USART 外设在硬件层面只负责完成字节级的数据收发。也就是说,它能够按照既定的波特率正确地发送和接收每一个字节,但并不会理解这些字节在应用层中的具体含义。

然而在实际工程中,通过串口传输的数据通常并不是孤立的单个字节,而是一组具有明确逻辑关系的数据。例如:

  • 传感器采集的多通道数据
  • 高精度的电机控制指令
  • 上位机下发的复杂参数配置
  • 设备运行状态信息的定时上报

如果仅仅按照单向的字节顺序发送数据,接收端面对连续不断到达的字节流时,就会遇到以下几个致命问题:

  • 边界模糊:一帧有效数据从哪里开始,又到哪里结束;
  • 语义混乱:当前接收到的字节,究竟属于哪一条指令的哪一个参数;
  • 完整性存疑:数据在电磁环境复杂的传输过程中,是否发生了位翻转、错位或丢失;

1.1 数据包定义

为了彻底解决这些问题,在工程实践中通常会在原始数据之上增加一层简单的通信协议,将多个零散的字节组织成结构化的数据帧。这种具有特定格式和边界的数据帧,称为数据包(Data Packet)

通过引入协议字段,接收端就可以在连续的串口字节流中识别出完整的数据帧,从而正确解析其中的有效数据。一个典型的数据包结构如下图所示:

1.2 数据包的组成与设计灵活性

一个典型的数据包通常由以下几个部分组成。

协议字段 核心作用 工程备注
包头 标识数据帧开始 常选用 0xFF 等特殊字节,用于触发接收状态机。
数据载荷 实际需要传输的数据 核心内容,如传感器的原始数值或控制指令。
校验位 检测数据传输正确性 (可选) 用于甄别传输过程中的干扰。
包尾 标识数据帧结束 配合包头,完成一帧数据边界的严格闭环。

**注意:**需要说明的是,数据包协议并不是一成不变的固定标准,开发者可以根据具体的应用场景和可靠性需求,自由设计协议的具体规约(如包头取值、载荷长度、是否包含校验等)。

1.3 关于实验协议的特别说明

在后续的实验代码中,为了让大家能够更直观地掌握有限状态机(FSM)的接收逻辑,我们对协议进行了适度的简化。这里有几点需要提前说明:

  • 协议的自定义性:在本博客的实验中,我们自定义了两套简单的协议:一套是 HEX 协议(如 0xFF 开始,0xFE 结束),另一套是文本协议(如 @ 开始,\r\n 结束)。大家要记住,协议的规则是可以根据需求自己设计的。

  • 关于校验位(Sum Check):在工业级应用中,通常会使用 "和校验" 来确保数据准确,其公式通常为:

江科大up在本章节的实验中,为了降低初学者的理解门槛,暂未引入校验位的计算逻辑,所以这里仅作了解即可。 后续读者在掌握了基础的状态机接收原理后,可以根据需求自行在协议中添加校验字段,以提升通信的稳健性。

通过这种"包层级"的思考方式,串口通信就从简单的字节流传输转变为结构化的数据帧通信。

(mod 256)的含义: 简单来说,它就是取余数的意思。在单片机里,一个字节(uint8_t)能存的最大数字是 255。如果包里所有数字加起来超过了 255(比如总和是 300),就把它除以 256 取余数(300 ➗ 256 = 1....余44),最后只把这个 44 存进校验位。 在代码中,这通常对应 & 0xFF 操作。 这样做的目的是确保校验结果永远只有一个字节的大小,方便传输。


2 串口数据包协议设计

在设计串口通信协议时,需要首先确定数据包的组织方式以及控制字段的设计规则。一个合理的数据包协议不仅能够提高通信的可靠性,还能够简化接收端的解析逻辑。

在实际工程中,串口数据包协议通常需要重点考虑两个问题:

  • 数据包的基本组织形式
  • 控制字段与数据内容之间的冲突问题

2.1 数据包基本类型

按照协议组织方式的不同,串口数据包通常可以分为两类:定长数据包和变长数据包。

2.1.1 定长数据包

定长数据包是指每一帧数据的长度固定不变。接收端只要检测到包头,然后继续接收固定数量的字节,就可以确定一帧数据已经完整到达。例如一个简单的定长数据包结构:

cpp 复制代码
包头 | 数据1 | 数据2 | 数据3 | 包尾

若规定数据包总长度为 5 字节,接收端在检测到包头后,只需继续接收剩余 4 个字节即可完成整帧数据的接收。这种方式的特点是:

优点:

  • 协议结构简单
  • 接收逻辑清晰
  • 状态机实现容易

缺点:

  • 数据结构必须固定
  • 当数据长度变化时不够灵活
  • 如果通信过程中发生字节丢失,可能导致后续解析持续错位,需要等待新的包头重新同步

因此,定长数据包通常适用于 数据结构稳定、字段固定 的通信场景,例如周期性传输的传感器数据或设备状态数据。

2.1.2 变长数据包

变长数据包是指每一帧数据的长度不固定。在这种情况下,需要通过额外的协议字段来确定数据帧的结束位置。常见方式包括:

  • 使用 包头 + 包尾
  • 使用 包头 + 长度字段
  • 使用 结束符

例如一种常见结构:

cpp 复制代码
包头 | 长度字段 | 数据载荷 | 包尾

接收端检测到包头后,首先读取长度字段,据此确定数据范围。完整接收数据后,再验证包尾。这种方式具有以下特点:

优点:

  • 灵活性高
  • 可以传输任意长度数据
  • 协议扩展方便

缺点:

  • 接收逻辑相对复杂
  • 状态机设计要求更高

变长数据包常见于上位机通信协议或命令控制协议。

2.2 载荷与控制字冲突问题

在串口通信协议设计中,经常会遇到一个典型问题:载荷数据中的字节可能和协议控制字取值相同。例如,若协议规定:

类型 数值
包头 0xFF
包尾 0xFE

如果数据载荷中恰好也包含 0xFF 或 0xFE,接收端在解析数据时就可能将其误判为协议控制字段,从而导致数据帧解析错误。

这种问题在二进制数据通信中非常常见。为了解决这一问题,工程上通常采用以下几种方法。

2.2.1 限制载荷取值范围

最简单的方法是在协议设计阶段直接规定:

  • 某些字节值不能出现在数据载荷中
  • 控制字与数据范围严格区分

例如规定:

  • 包头使用 0xFF
  • 数据载荷只允许 0x00 ~ 0x7F

这种方式实现简单,但会限制数据的取值范围,因此只适用于数据格式完全可控的场景。

2.2.2 采用转义机制

如果协议要求数据载荷支持任意字节值,则通常需要引入 转义机制(Escape Mechanism)

其基本思想是:当数据中出现协议控制字(包头、包尾、转义字节)时,在发送端对其进行转义编码;接收端在解析数据时再进行反转义,从而恢复原始数据。

例如,假设当前串口协议规定如下:

类型 字节值 含义
包头 0xFF 表示 一帧数据开始
包尾 0xFE 表示 一帧数据结束
转义字节 0x1B 为转义符,用于标记后续字节经过转义处理

当载荷中出现这些特殊字节时,需要按照约定规则进行转义。例如:

原始字节 转义后字节
0xFF 1B FF
0xFE 1B FE
0x1B 1B 1B

经过编码后,原本可能与协议控制字冲突的字节,将不再以单独形式出现在数据流中,而是变为带有转义符的组合字节序列。接收端在解析数据时,只需按照相同规则进行反转义处理,即可恢复原始载荷数据。如下图所示:

如图所示,在发送数据时,发送端会对载荷数据进行逐字节检查。当检测到协议中的特殊字节(如包头 0xFF、包尾 0xFE 或转义符 0x1B)时,会在该字节前插入转义符 0x1B 进行编码。例如原始数据 0xFF 会被发送为 0x1B 0xFF。

接收端在解析数据时执行相应的反转义处理:当检测到 0x1B 时,说明其后一个字节为被转义的数据,应直接作为载荷数据处理,而不再按协议控制字解释。

通过这种方式,可以保证控制字只用于协议结构,同时数据内容不会被误判为协议字段。但转义机制也会带来一定代价,例如:

  • 数据长度可能增加
  • 编解码逻辑更加复杂

因此在设计协议时需要综合权衡。

2.2.3 使用长度字段辅助识别

另一种常见方法是在数据包中加入 长度字段(Length Field),用于指示数据载荷的字节数量。例如:

cpp 复制代码
包头 | 长度 | 数据 | 包尾

当接收端检测到包头后,解析流程通常如下:

  1. 读取长度字段
  2. 根据长度字段继续接收指定数量的数据字节
  3. 最后读取包尾,完成一帧数据的解析

如上图所示,当接收端检测到包头 0xFE 后,会先读取长度字段 0x04,表示当前数据帧包含 4 字节数据载荷。随后接收端继续接收 4 个字节的数据(0x12 0x34 0x56 0x78),最后读取包尾完成数据帧解析。

通过长度字段,接收端可以直接确定数据载荷的范围,而不必在数据流中逐字节搜索包尾,从而提高解析效率。

相比仅依赖 包头 + 包尾 的分帧方式,引入长度字段具有以下优点:

  • 解析过程更加明确:接收端可以按长度直接接收完整数据
  • 减少控制字冲突影响:即使载荷中出现与包尾相同的字节,也不会影响数据解析
  • 更适合二进制数据传输:能够可靠传输任意字节数据

因此,在许多实际工程中,包头 + 长度字段的组合是一种非常常见的数据帧设计方式。

3 基于有限状态机的数据包解析

USART 外设的接收硬件机制决定了其通信过程具有典型的异步特征。在实际运行中,CPU 无法一次性获取完整的字符串或数据帧,而是依赖接收非空中断(RXNE),逐字节地读取到达的数据。因此,软件解析逻辑必须适应数据流离散到达的物理事实。

为了确保接收流程的逻辑清晰与执行可控,工程实践中通常引入有限状态机(Finite State Machine, FSM)模型来组织底层的串口解析业务。其核心机制包括:

  1. 声明一个静态状态变量(如 RxState),用于记录当前数据帧的拼接进度。

  2. 在每次触发接收中断时,根据 RxState 的当前值与读取到的单字节输入,执行特定的动作。

  3. 通过预设的条件触发状态跃迁,将零散的字节流重组为符合协议规范的完整数据包。

结合常见的串口通信协议(如包含包头、有效载荷与包尾的数据帧),底层状态机与上层同步标志(Serial_RxFlag)协同工作的流转逻辑如下图所示:

如图所示,状态机通常被划分为三个核心状态。具体的流转逻辑如下:

状态 0:等待包头

系统初始状态或上一帧解析结束后的默认状态,此时 RxState = 0。 在该状态下,状态机的唯一任务是监听通信总线,等待数据帧的起始信号。当 USART 触发中断时,程序会读取接收数据寄存器并将其与协议规定的"包头"进行比对,同时严格检查同步标志位 Serial_RxFlag 是否为 0(即确认主程序已释放接收缓冲区)。若满足"收到包头且缓冲区空闲",说明新的一帧可以开始接收,程序将重置接收缓冲区的索引指针,并将 RxState 切换为 1。若不满足,则判定为总线噪声或被并发机制拦截,直接丢弃该字节,维持状态 0 不变。

状态 1:接收载荷

确认包头后,系统进入有效数据提取阶段,此时 RxState = 1。 由于数据流逐字节到达,状态机在该状态下表现为"自循环"特征。每次进入中断,只要未达到结束条件,程序就会将读取到的普通数据字节按顺序存入接收缓冲区(如 Serial_RxPacket 数组),同步递增索引指针,并维持 RxState = 1 不变。当接收到的数据长度达到协议规定的定长阈值,或者探测到特定的结束符(对于变长协议)时,说明载荷部分接收完毕,程序才会将 RxState 切换为 2。

状态 2:等待包尾

载荷接收完成后,需要对数据帧的边界完整性进行最终确认,此时 RxState = 2。 程序提取当前中断接收到的字节,判断其是否为协议规定的"包尾":

  • 确认包尾(成功路径): 若数据匹配,说明该帧数据首尾完整,没有发生字节错位。状态机立即将 RxState 复位为 0,准备接收下一帧;同时,置位同步标志位(Serial_RxFlag = 1),以异步通知主循环锁定并处理缓冲区中的合法数据。

  • 包尾不匹配(失败路径): 若收到的不是预期的包尾,说明在传输过程中发生了干扰、丢包或错位,导致数据帧结构遭到破坏。状态机会直接将 RxState 复位为 0,果断丢弃当前缓冲区内的所有半成品数据,防止向应用层提交错乱的信息。

通过上述三态流转模型,串口接收过程被固化为清晰的分步执行逻辑。该方案在中断服务函数中具有显著的优势:代码不存在任何阻塞延时或死循环(如 while 等待),每次中断仅执行一到两次 if 条件判断与内存赋值即可退出。这不仅极大降低了 CPU 的资源占用率,也保证了系统对高频异步通信的实时响应能力。


4 USART 接收硬件机制

在编写串口接收驱动程序与解析逻辑之前,需要明确 STM32 内部处理接收数据的硬件机制。USART 的接收过程是由硬件自动完成的字节级操作,而数据包的拼接与解析则由软件在中断与主循环中协同完成。理解两者之间的衔接关系,是确保数据包完整性和程序稳定性的基础。

4.1 硬件接收链路与中断触发机制

STM32 的 USART 接收流程是由硬件逻辑自动控制的信号采样与缓存过程。从物理引脚上的电平变化到软件可读取的字节数据,其处理步骤如下:

  1. 起始位检测与同步: 当 USART 接收引脚(RX)检测到从高电平向低电平跳转的下降沿时,硬件将其识别为数据帧的起始位。随后,硬件启动内部的时钟同步机制,并按照初始化的波特率参数对后续数据位进行采样。

  2. 移位寄存器接收: 内部硬件逻辑依次对 RX 引脚上的电平进行采样,并将采样得到的位数据存入内部的接收移位寄存器(Receive Shift Register)。这一过程实现了串行数据到并行数据的转换。

  3. 数据转移至数据寄存器: 当一帧完整的字节(包含数据位和停止位)采集并移位完毕后,硬件会自动将这 8 位数据从接收移位寄存器转移至接收数据寄存器(RDR)。在 STM32 的寄存器映射中,软件通过读取 USART_DR(Data Register)来获取该字节。

  4. 状态标志置位与中断请求:数据转移至 RDR 的同时,硬件会自动将状态寄存器(USART_SR)中的 RXNE(Read data register not empty,读数据寄存器非空)标志位置 1。如果软件在初始化时使能了接收中断(USART_IT_RXNE),该标志位的置位会向嵌套向量中断控制器(NVIC)发起中断请求,使 CPU 暂停当前主循环的执行,跳转至对应的中断服务函数(ISR)。

RXNE 标志位的置位状态表明 DR 寄存器内已存储有效字节。在中断服务函数中,通过读取 USART_DR 寄存器获取当前接收到的字节。根据 STM32 的硬件设计规范,执行读取 USART_DR 的操作会自动将 RXNE 标志位清零,因此在正常接收流程中无需软件手动清除该标志位。

4.2 中断与主循环的数据同步问题

在实际的串口通信工程中,数据包的处理通常采用"中断接收 + 主循环处理"的异步协作模型。根据前文所述的状态机解析方法(第3章),两者分工如下:

  • 中断服务程序(ISR) :负责实时响应硬件的 RXNE 中断,读取单字节数据,并驱动有限状态机(FSM)完成包头识别、载荷存储与包尾校验。
  • 主循环(Main Loop):负责在数据包完整接收后,从接收缓冲区读取载荷数据,并执行指令匹配、设备控制或显示刷新等具体的应用层业务。

由于 USART 接收是由外部硬件时钟驱动的异步事件,而主循环的执行周期取决于内部业务代码的耗时(如 OLED 屏幕刷新函数通常需要数毫秒),两者在执行速度上存在差异。如果缺乏同步机制,会导致以下数据覆盖问题:

当主循环正在处理上一帧数据(或尚未开始处理)时,外部设备持续发送新的数据。中断服务函数会立即响应并开始将新数据写入接收缓冲区(Serial_RxPacket 数组),这会导致主循环最终读取到的数据部分属于旧帧,部分属于新帧,破坏了数据包的完整性与协议结构。

为了解决这一同步冲突,工程上通常引入一个全局状态标志位(本博客实验代码中为 Serial_RxFlag)来实现简单的互斥访问机制。该机制的完整生命周期可分为以下几个阶段:

  1. 缓冲区空闲与监听状态: 初始状态下,Serial_RxFlag = 0。表示接收缓冲区(Serial_RxPacket)处于空闲状态。此时中断服务函数允许检测新数据帧,状态机处于等待包头阶段( RxState = 0)。

  2. 数据接收与缓存阶段: 当检测到合法的包头后,状态机进入数据接收阶段( RxState = 1)。在 Serial_RxFlag 维持为 0 的整个周期内,硬件每完成一个字节的采样并触发 RXNE 中断,中断服务函数就会将读取到的有效载荷依次存入缓冲区数组Serial_RxPacket中。此阶段数据包正在逐步构建,尚不具备协议完整性,主循环不会介入读取。

  3. 数据包就绪状态: 当状态机成功检测到协议规定的包尾(如 HEX 协议的 0xFE 或文本协议的 \n)时,表明一帧完整的数据包已拼接完毕并安全驻留在缓冲区Serial_RxPacket内。此时,中断服务函数将 Serial_RxFlag 置为 1,正式向主循环发出"数据就绪"信号,并结束当前帧的接收逻辑。

  4. 并发拦截逻辑: 在接收文本数据包的实验代码中,状态机在检测包头(起始符 @)时,增加了一个条件判断:只有当 Serial_RxFlag == 0 时,才允许进入接收状态。如果 Serial_RxFlag == 1,说明主循环尚未处理完上一帧数据,此时状态机主动拒绝识别新包头,后续到达的单字节数据会被视为无效干扰而丢弃。这一机制实质上在 ISR 与主循环之间建立了一种轻量级的读写锁。

  5. 主循环业务处理与缓冲区释放: 主循环通过轮询检测到 Serial_RxFlag == 1 后,执行具体的应用层业务逻辑(如调用 strcmp 进行字符串匹配)。在业务逻辑执行完毕、数据被完全消耗后,主循环必须显式地将 Serial_RxFlag 清零(Serial_RxFlag = 0),从而解除对缓冲区的锁定,允许中断服务函数开启下一帧数据的接收。

通过这一同步机制,保证了在单缓冲区的条件下,主循环读取数据的过程不会被硬件高频触发的中断接收过程所覆盖或干扰,确保了应用层协议解析的准确性与系统运行的稳定性。

**补充:**需要特别注意的是,作为跨越中断与主循环的全局共享标志位,Serial_RxFlag 在声明时必须使用 volatile 关键字修饰(如 volatile uint8_t Serial_RxFlag = 0;),以强制编译器每次都从内存中读取该变量的最新值,防止因编译优化导致死锁。


5 基于状态机的 USART 接收驱动实现

在明确了 USART 硬件异步接收机制与有限状态机(FSM)同步策略的基础上,本章将详细阐述如何在 STM32 平台上编写高可靠性的串口驱动程序。通过将底层接收逻辑封装在中断服务程序(ISR)中,并利用全局标志位与应用层交互,可以构建出一套抗干扰能力强、逻辑解耦的通信架构。

5.1 USART 初始化与 NVIC 配置

在实现数据包接收之前,必须先完成外设和中断控制器的底层硬件初始化。典型的配置流程包含以下五个步骤:

  1. 开启时钟: 使能 GPIO 和 USART 外设所在总线的时钟。

  2. GPIO 模式配置: 将 TX 配置为复用推挽输出,RX 配置为浮空或上拉输入。

  3. 通信参数配置: 设定波特率、数据位长、停止位及校验方式,确保通信双方物理层对齐。

  4. 中断配置: 开启接收非空(RXNE)中断,并在 NVIC 中分配对应的抢占与响应优先级。

  5. 外设使能: 正式启动 USART 工作。

以下为 USART1 的标准初始化代码框架:

cpp 复制代码
/**
  * @brief  串口 1 初始化函数
  * @param  无
  * @retval 无
  */
void Serial_Init(void)
{
    /* 1. 开启外设时钟 (USART1 和 GPIOA 均挂载在 APB2 总线上) */
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE);
    
    /* 2. 配置 GPIO 引脚模式 */
    GPIO_InitTypeDef GPIO_InitStructure;
    // TX 引脚 (PA9) 必须配置为复用推挽输出,将引脚控制权交由 USART 外设
    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);
    
    // RX 引脚 (PA10) 配置为上拉输入 (也可为浮空输入),用于接收外部电平信号
    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);
    
    /* 3. 配置 USART 通信参数 (8位字长,无奇偶校验,1个停止位) */
    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);
    
    /* 4. 配置中断控制与 NVIC 嵌套向量中断控制器 */
    // 开启 USART1 的接收数据寄存器非空 (RXNE) 中断
    USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);
    // 配置 NVIC 优先级分组 (系统级配置,建议在 main 函数开头统一调用)
    NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
    
    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);
    
    /* 5. 最终使能 USART1 外设,正式开启通信 */
    USART_Cmd(USART1, ENABLE);
}

5.2 定长 HEX 数据包中断接收实现

对于定长 HEX 数据包,本节设定的通信协议格式为:[包头 0xFF] + [4字节载荷] + [包尾 0xFE]。 为保证解析的严谨性,中断服务程序严格遵循第 3 章所述的 3 状态 FSM 逻辑,避免了单字节噪声导致的错位。

cpp 复制代码
/**
  * @brief  USART1 中断服务函数 (定长 HEX 协议解析)
  * @note   该函数由硬件自动触发,内部无阻塞逻辑
  */
void USART1_IRQHandler(void)
{
    static uint8_t RxState = 0;     // 静态变量:记录状态机当前所处的状态 (IDLE=0, RECV=1, STOP=2)
    static uint8_t pRxPacket = 0;   // 静态变量:记录当前接收到的载荷字节索引

    // 判断是否为 RXNE (接收非空) 事件触发的中断
    if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET)
    {
        // 读取数据寄存器 DR。注意:读取 DR 会自动清除 RXNE 标志位
        uint8_t RxData = USART_ReceiveData(USART1);

        /* 状态 0:等待包头 (IDLE) */
        if (RxState == 0)
        {
            // 校验包头,且确保主循环已经释放了上一帧缓冲区 (Serial_RxFlag == 0)
            if (RxData == 0xFF && Serial_RxFlag == 0)
            {
                RxState = 1;        // 匹配成功,状态机跃迁至状态 1
                pRxPacket = 0;      // 接收索引指针清零,准备接收新载荷
            }
        }
        /* 状态 1:接收载荷 (RECEIVE) */
        else if (RxState == 1)
        {
            Serial_RxPacket[pRxPacket] = RxData; // 将读取到的数据存入全局接收数组
            pRxPacket++;                         // 索引自增
            
            // 检查是否已达到协议规定的 4 字节定长
            if (pRxPacket >= 4)
            {
                RxState = 2;        // 载荷收集完毕,状态机跃迁至状态 2
            }
        }
        /* 状态 2:等待包尾 (STOP) */
        else if (RxState == 2)
        {
            // 校验包尾,确认帧结构完整性
            if (RxData == 0xFE)
            {
                RxState = 0;        // 校验成功,复位状态机准备下一帧
                Serial_RxFlag = 1;  // 置位全局同步标志,通知主循环数据已就绪
            }
            else
            {
                RxState = 0;        // 校验失败 (发生错位),强行复位状态机,丢弃当前错误帧
            }
        }
        
        // 保险起见,手动清除 RXNE 中断标志位
        USART_ClearITPendingBit(USART1, USART_IT_RXNE);
    }
}

5.3 变长文本数据包中断接收实现

在人机交互或调试场景中,变长文本数据包(如 AT 指令)应用广泛。本节协议设定为:[包头 @] + [不定长 ASCII 字符] + [包尾 \r\n]。 由于长度不固定,状态机的跳转完全依赖于结束符的识别。同时,为了符合 C 语言字符串的规范,接收完成后必须手动追加字符串结束符 \0。

cpp 复制代码
#include <stdint.h>
#include <string.h>

/* 定义接收缓冲区的最大长度,确保系统内存安全 */
#define MAX_PACKET_LEN 100 

/**
  * @brief  USART1 中断服务函数 (变长文本协议解析)
  * @note   逻辑特点:基于三态 FSM,具备防缓冲区溢出保护与并发拦截机制
  */
void USART1_IRQHandler(void)
{
    // 使用静态变量维持状态机上下文,确保跨中断周期的数据连贯性
    static uint8_t RxState = 0;     // 状态机当前阶段 (0:等待包头, 1:接收载荷, 2:等待包尾)
    static uint8_t pRxPacket = 0;   // 缓冲区当前写入位置的索引指针

    // 检查是否为接收数据寄存器非空 (RXNE) 触发的中断
    if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET)
    {
        // 从硬件读取字节,此操作会自动清除寄存器状态,为接收下一字节做准备
        uint8_t RxData = USART_ReceiveData(USART1);

        /* 状态 0:等待文本包起始符 '@' */
        if (RxState == 0)
        {
            // 双重拦截:匹配包头且确保 Serial_RxFlag 已清零(主循环已处理完前一帧)
            if (RxData == '@' && Serial_RxFlag == 0)
            {
                RxState = 1;        // 跃迁至载荷接收状态
                pRxPacket = 0;      // 复位指针,准备从缓冲区起始位置写入
            }
        }
        /* 状态 1:持续接收文本载荷并累积至缓冲区 */
        else if (RxState == 1)
        {
            // 探测到协议规定的第一个结束符 '\r' (回车)
            if (RxData == '\r')
            {
                RxState = 2;        // 准备确认最后一个结束符 '\n'
            }
            else
            {
                /* 关键防御逻辑:防缓冲区溢出保护 */
                // 预留 1 字节空间给字符串结束符 '\0'
                if (pRxPacket < (MAX_PACKET_LEN - 1)) 
                {
                    Serial_RxPacket[pRxPacket] = RxData;
                    pRxPacket++;
                }
                else
                {
                    // 若超过最大长度限制,强行复位状态机,防止内存非法篡改
                    RxState = 0;
                }
            }
        }
        /* 状态 2:确认最终结束符 '\n' (换行) */
        else if (RxState == 2)
        {
            if (RxData == '\n')
            {
                RxState = 0;                        // 解析完成,复位状态机准备下一帧
                Serial_RxPacket[pRxPacket] = '\0';  // ★核心步骤:将载荷封装为标准 C 字符串
                Serial_RxFlag = 1;                  // 置位同步标志,向主循环发出读取信号
            }
            else
            {
                // 协议格式违规(回车后未紧跟换行),丢弃当前帧并复位同步
                RxState = 0;                        
            }
        }
        
        // 显式清除中断挂起位,确保 NVIC 能够正确响应下一次接收中断
        USART_ClearITPendingBit(USART1, USART_IT_RXNE);
    }
}

5.4 应用层协议解析与解耦逻辑

底层中断服务函数仅负责"数据搬运与边界划分",而真正的"语义理解"应交由主循环处理。这种基于 Serial_RxFlag 的异步交互模式,实现了驱动层与应用层的彻底松耦合。

在主循环中,程序只需采用非阻塞的方式轮询该标志位。对于已格式化为标准 C 字符串的文本包,可以直接调用 <string.h> 库中的 strcmp 函数进行高效的指令路由。指令执行完毕后,必须由软件主动清零标志位,从而解除底层的写入锁定。

cpp 复制代码
/* 主循环变长指令解析业务逻辑 */
int main(void)
{
    // 系统初始化 (硬件时钟、串口驱动、外设等)
    Serial_Init();
    /* ... 其他初始化逻辑 ... */

    while (1)
    {
        // 轮询检测全局同步标志位:判断缓冲区内是否驻留了完整的有效数据帧
        if (Serial_RxFlag == 1)
        {
            // 利用 strcmp 进行标准的字符串严格匹配
            if (strcmp((char *)Serial_RxPacket, "LED_ON") == 0)
            {
                LED1_ON();                              // 执行具体硬件动作
                Serial_SendString("LED_ON_OK\r\n");     // 向终端回传执行结果
            }
            else if (strcmp((char *)Serial_RxPacket, "LED_OFF") == 0)
            {
                LED1_OFF();
                Serial_SendString("LED_OFF_OK\r\n");
            }
            else
            {
                // 未知指令拦截,返回错误提示,增强系统交互友好性
                Serial_SendString("ERROR_COMMAND\r\n");
            }

            // ★ 业务处理完毕,显式清零标志位,释放接收缓冲区的写入权限,允许 ISR 接收下一帧
            Serial_RxFlag = 0;
        }
        
        // 主循环的其他常规任务...
    }
}

通过上述的分层设计,底层 ISR 能够以极低的延迟快速响应高频硬件中断,而耗时的字符串比较与业务逻辑则在主循环中从容调度,极大保障了嵌入式系统的实时性与稳定性。


6 本章节实验

6.1 串口收发HEX数据包

6.1.1 实验目标

  • 掌握状态机接收逻辑:理解如何利用有限状态机(FSM)在中断服务函数中实现对固定长度 HEX 数据包的帧同步与有效载荷提取。

  • 理解数据包封装机制:学习在发送端按预定物理协议(包头 0xFF、四字节载荷、包尾 0xFE)组装并推送字节流的方法。

  • 实现收发双向异步通信:通过硬件按键触发单次主动发送,并利用嵌套向量中断控制器(NVIC)与全局标志位结合的方式,无阻塞地处理外部总线的异步接收事件。

6.1.2 硬件设计

6.1.3 软件设计

本实验采用基于中断状态机的软件架构,将底层物理字节流解析与应用层数据处理分离,具体流程如下:

(1)USART 硬件通信链路初始化

  • 引脚与外设配置:配置 PA9 为复用推挽输出(TX),PA10 为上拉输入(RX)。配置 USART1 为全双工收发模式,通信时序设定为波特率 9600、8位字长、无硬件流控、无校验、1位停止位。

  • 中断路由配置:使能 USART 接收数据寄存器非空(RXNE)中断,打通中断至 CPU 的路径,并在 NVIC 中配置对应通道的抢占优先级与响应优先级。

(2)数据包发送封装模块

  • 载荷更新与时序触发:在主循环中通过检测按键(PB1)的下降沿触发载荷数据自增逻辑。

  • 协议组帧:调用 Serial_SendPacket 函数,依次向外设数据寄存器写入包头(0xFF)、四字节载荷缓存(调用 Serial_SendArray 遍历)以及包尾(0xFE),由硬件自动生成完整帧时序波形。

(3)状态机驱动的异步接收模块

  • 中断上下文解包:在硬件触发的 USART1_IRQHandler 中,定义静态状态变量 RxState 记录帧解析进度。

  • 状态流转逻辑:状态 0 负责捕获总线上的包头同步字(0xFF);状态 1 依序提取 4 字节有效载荷至接收缓存区;状态 2 校验帧尾(0xFE),校验通过后置位应用层数据就绪标志 Serial_RxFlag,并重置状态机以迎接下一帧。

具体代码如下:

main.c文件:

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

uint8_t KeyNum;			//定义用于接收按键键码的变量

int main(void)
{
	/*模块初始化*/
	OLED_Init();		//OLED初始化
	Key_Init();			//按键初始化
	Serial_Init();		//串口初始化
	
	/*显示静态字符串*/
	OLED_ShowString(1, 1, "TxPacket");
	OLED_ShowString(3, 1, "RxPacket");
	
	/*设置发送数据包数组的初始值,用于测试*/
	Serial_TxPacket[0] = 0x01;
	Serial_TxPacket[1] = 0x02;
	Serial_TxPacket[2] = 0x03;
	Serial_TxPacket[3] = 0x04;
	
	while (1)
	{
		KeyNum = Key_GetNum();			//获取按键键码
		if (KeyNum == 1)				//按键1按下
		{
			Serial_TxPacket[0] ++;		//测试数据自增
			Serial_TxPacket[1] ++;
			Serial_TxPacket[2] ++;
			Serial_TxPacket[3] ++;
			
			Serial_SendPacket();		//串口发送数据包Serial_TxPacket
			
			OLED_ShowHexNum(2, 1, Serial_TxPacket[0], 2);	//显示发送的数据包
			OLED_ShowHexNum(2, 4, Serial_TxPacket[1], 2);
			OLED_ShowHexNum(2, 7, Serial_TxPacket[2], 2);
			OLED_ShowHexNum(2, 10, Serial_TxPacket[3], 2);
		}
		
		if (Serial_GetRxFlag() == 1)	//如果接收到数据包
		{
			OLED_ShowHexNum(4, 1, Serial_RxPacket[0], 2);	//显示接收的数据包
			OLED_ShowHexNum(4, 4, Serial_RxPacket[1], 2);
			OLED_ShowHexNum(4, 7, Serial_RxPacket[2], 2);
			OLED_ShowHexNum(4, 10, Serial_RxPacket[3], 2);
		}
	}
}

Serial.c文件:

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

uint8_t Serial_TxPacket[4];				//定义发送数据包数组,数据包格式:FF 01 02 03 04 FE
uint8_t Serial_RxPacket[4];				//定义接收数据包数组
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);		//串口发送字符数组(字符串)
}

/**
  * 函    数:串口发送数据包
  * 参    数:无
  * 返 回 值:无
  * 说    明:调用此函数后,Serial_TxPacket数组的内容将加上包头(FF)包尾(FE)后,作为数据包发送出去
  */
void Serial_SendPacket(void)
{
	Serial_SendByte(0xFF);
	Serial_SendArray(Serial_TxPacket, 4);
	Serial_SendByte(0xFE);
}

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

/**
  * 函    数:USART1中断函数
  * 参    数:无
  * 返 回 值:无
  * 注意事项:此函数为中断函数,无需调用,中断触发后自动执行
  *           函数名为预留的指定名称,可以从启动文件复制
  *           请确保函数名正确,不能有任何差异,否则中断函数将不能进入
  */
void USART1_IRQHandler(void)
{
	static uint8_t RxState = 0;		//定义表示当前状态机状态的静态变量
	static uint8_t pRxPacket = 0;	//定义表示当前接收数据位置的静态变量
	if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET)		//判断是否是USART1的接收事件触发的中断
	{
		uint8_t RxData = USART_ReceiveData(USART1);				//读取数据寄存器,存放在接收的数据变量
		
		/*使用状态机的思路,依次处理数据包的不同部分*/
		
		/*当前状态为0,接收数据包包头*/
		if (RxState == 0)
		{
			if (RxData == 0xFF)			//如果数据确实是包头
			{
				RxState = 1;			//置下一个状态
				pRxPacket = 0;			//数据包的位置归零
			}
		}
		/*当前状态为1,接收数据包数据*/
		else if (RxState == 1)
		{
			Serial_RxPacket[pRxPacket] = RxData;	//将数据存入数据包数组的指定位置
			pRxPacket ++;				//数据包的位置自增
			if (pRxPacket >= 4)			//如果收够4个数据
			{
				RxState = 2;			//置下一个状态
			}
		}
		/*当前状态为2,接收数据包包尾*/
		else if (RxState == 2)
		{
			if (RxData == 0xFE)			//如果数据确实是包尾部
			{
				RxState = 0;			//状态归0
				Serial_RxFlag = 1;		//接收数据包标志位置1,成功接收一个数据包
			}
		}
		
		USART_ClearITPendingBit(USART1, USART_IT_RXNE);		//清除标志位
	}
}

Key.c文件:

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

/**
  * 函    数:按键初始化
  * 参    数:无
  * 返 回 值:无
  */
void Key_Init(void)
{
	/*开启时钟*/
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);		//开启GPIOB的时钟
	
	/*GPIO初始化*/
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1 | GPIO_Pin_11;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOB, &GPIO_InitStructure);						//将PB1和PB11引脚初始化为上拉输入
}

/**
  * 函    数:按键获取键码
  * 参    数:无
  * 返 回 值:按下按键的键码值,范围:0~2,返回0代表没有按键按下
  * 注意事项:此函数是阻塞式操作,当按键按住不放时,函数会卡住,直到按键松手
  */
uint8_t Key_GetNum(void)
{
	uint8_t KeyNum = 0;		//定义变量,默认键码值为0
	
	if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_1) == 0)			//读PB1输入寄存器的状态,如果为0,则代表按键1按下
	{
		Delay_ms(20);											//延时消抖
		while (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_1) == 0);	//等待按键松手
		Delay_ms(20);											//延时消抖
		KeyNum = 1;												//置键码为1
	}
	
	if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11) == 0)			//读PB11输入寄存器的状态,如果为0,则代表按键2按下
	{
		Delay_ms(20);											//延时消抖
		while (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11) == 0);	//等待按键松手
		Delay_ms(20);											//延时消抖
		KeyNum = 2;												//置键码为2
	}
	
	return KeyNum;			//返回键码值,如果没有按键按下,所有if都不成立,则键码为默认值0
}

6.1.4 实验现象

系统上电复位并完成初始化后,OLED 屏幕显示 "TxPacket" 与 "RxPacket" 的静态提示符:

  • 按键触发发送:每按下一次单片机上的测试按键,向 PC 端串口助手发送一帧包含自增测试数据的 HEX 格式数据包(例如 FF 01 02 03 04 FE),OLED 屏幕数据区同步刷新当前发出的载荷数值。

  • 异步接收响应 :通过 PC 端串口助手向单片机发送符合协议格式的 HEX 数据包(以 FF 起始,中间 4 字节载荷,FE 结束),OLED 屏幕实时解析并显示接收到的有效载荷;若数据包未对齐或不符合协议边界,屏幕状态保持上一帧结果不予更新。


6.2 串口收发文本数据包

6.2.1 实验目标

  • 掌握可变长帧的接收机制:理解如何利用双终止符(\r\n)动态判定变长文本数据包的边界,并自动为字符流附加 C 语言字符串结束标志(\0)。

  • 理解缓冲区互斥锁原理:学习通过软件标志位(RxFlag)建立访问屏障,拦截总线高频输入,避免中断直接覆盖主循环尚未处理完毕的接收缓冲区。

  • 实现人机交互指令解析:掌握标准 C 库函数在底层通信链路与高层控制逻辑映射中的应用,构建简易的指令译码引擎。

6.2.2 硬件设计

6.2.3 软件设计

本实验在底层通信的基础上引入了互斥同步机制与字符匹配算法,构建了字符级的人机交互系统,具体流程如下:

(1)底层硬件接口复用

  • 通信链路维持:沿用 USART1 引脚映射与波特率配置,将接收缓冲区的数据类型更改为大容量字符数组(char型,长度100),以适应指令长度不确定的变长文本输入环境。

(2)具备互斥逻辑的变长接收状态机

  • 条件触发机制:在中断服务例程的状态 0 中,增设互斥判定逻辑------仅当同时满足接收到起始符(@)且上一数据帧已被主循环处理完毕(Serial_RxFlag == 0)时,才启动新一轮写入,有效阻断并发读写竞争。

  • 终止符检测与封装:状态 1 在存储有效载荷的同时持续嗅探首个终止符(\r);状态 2 验证次级终止符(\n),并在数组有效载荷末尾补入 \0,将物理字节流合法化为标准 C 字符串,随后挂起接收标志锁。

(3)应用层指令译码与执行模块

  • 指令查表匹配:主循环轮询到接收标志置位后,利用 <string.h> 库的 strcmp 函数将接收到的字符串缓冲与预设指令字典("LED_ON", "LED_OFF")进行字典序匹配。

  • 动作执行与反馈回显:比对命中后触发相对应的 GPIO 硬件动作(控制 PA1 点亮/熄灭 LED),经由 Serial_SendString 接口向发送端回传执行状态,最后显式清除接收标志,释放数据接收缓冲区供外设更新。

具体代码如下:

main.c文件:

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

int main(void)
{
	/*模块初始化*/
	OLED_Init();		//OLED初始化
	LED_Init();			//LED初始化
	Serial_Init();		//串口初始化
	
	/*显示静态字符串*/
	OLED_ShowString(1, 1, "TxPacket");
	OLED_ShowString(3, 1, "RxPacket");
	
	while (1)
	{
		if (Serial_RxFlag == 1)		//如果接收到数据包
		{
			OLED_ShowString(4, 1, "                ");
			OLED_ShowString(4, 1, Serial_RxPacket);				//OLED清除指定位置,并显示接收到的数据包
			
			/*将收到的数据包与预设的指令对比,以此决定将要执行的操作*/
			if (strcmp(Serial_RxPacket, "LED_ON") == 0)			//如果收到LED_ON指令
			{
				LED1_ON();										//点亮LED
				Serial_SendString("LED_ON_OK\r\n");				//串口回传一个字符串LED_ON_OK
				OLED_ShowString(2, 1, "                ");
				OLED_ShowString(2, 1, "LED_ON_OK");				//OLED清除指定位置,并显示LED_ON_OK
			}
			else if (strcmp(Serial_RxPacket, "LED_OFF") == 0)	//如果收到LED_OFF指令
			{
				LED1_OFF();										//熄灭LED
				Serial_SendString("LED_OFF_OK\r\n");			//串口回传一个字符串LED_OFF_OK
				OLED_ShowString(2, 1, "                ");
				OLED_ShowString(2, 1, "LED_OFF_OK");			//OLED清除指定位置,并显示LED_OFF_OK
			}
			else						//上述所有条件均不满足,即收到了未知指令
			{
				Serial_SendString("ERROR_COMMAND\r\n");			//串口回传一个字符串ERROR_COMMAND
				OLED_ShowString(2, 1, "                ");
				OLED_ShowString(2, 1, "ERROR_COMMAND");			//OLED清除指定位置,并显示ERROR_COMMAND
			}
			
			Serial_RxFlag = 0;			//处理完成后,需要将接收数据包标志位清零,否则将无法接收后续数据包
		}
	}
}

Serial.c文件:

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

char Serial_RxPacket[100];				//定义接收数据包数组,数据包格式"@MSG\r\n"
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);		//串口发送字符数组(字符串)
}

/**
  * 函    数:USART1中断函数
  * 参    数:无
  * 返 回 值:无
  * 注意事项:此函数为中断函数,无需调用,中断触发后自动执行
  *           函数名为预留的指定名称,可以从启动文件复制
  *           请确保函数名正确,不能有任何差异,否则中断函数将不能进入
  */
void USART1_IRQHandler(void)
{
	static uint8_t RxState = 0;		//定义表示当前状态机状态的静态变量
	static uint8_t pRxPacket = 0;	//定义表示当前接收数据位置的静态变量
	if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET)	//判断是否是USART1的接收事件触发的中断
	{
		uint8_t RxData = USART_ReceiveData(USART1);			//读取数据寄存器,存放在接收的数据变量
		
		/*使用状态机的思路,依次处理数据包的不同部分*/
		
		/*当前状态为0,接收数据包包头*/
		if (RxState == 0)
		{
			if (RxData == '@' && Serial_RxFlag == 0)		//如果数据确实是包头,并且上一个数据包已处理完毕
			{
				RxState = 1;			//置下一个状态
				pRxPacket = 0;			//数据包的位置归零
			}
		}
		/*当前状态为1,接收数据包数据,同时判断是否接收到了第一个包尾*/
		else if (RxState == 1)
		{
			if (RxData == '\r')			//如果收到第一个包尾
			{
				RxState = 2;			//置下一个状态
			}
			else						//接收到了正常的数据
			{
				Serial_RxPacket[pRxPacket] = RxData;		//将数据存入数据包数组的指定位置
				pRxPacket ++;			//数据包的位置自增
			}
		}
		/*当前状态为2,接收数据包第二个包尾*/
		else if (RxState == 2)
		{
			if (RxData == '\n')			//如果收到第二个包尾
			{
				RxState = 0;			//状态归0
				Serial_RxPacket[pRxPacket] = '\0';			//将收到的字符数据包添加一个字符串结束标志
				Serial_RxFlag = 1;		//接收数据包标志位置1,成功接收一个数据包
			}
		}
		
		USART_ClearITPendingBit(USART1, USART_IT_RXNE);		//清除标志位
	}
}

LED.c文件:

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

/**
  * 函    数:LED初始化
  * 参    数:无
  * 返 回 值:无
  */
void LED_Init(void)
{
	/*开启时钟*/
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);		//开启GPIOA的时钟
	
	/*GPIO初始化*/
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1 | GPIO_Pin_2;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);						//将PA1和PA2引脚初始化为推挽输出
	
	/*设置GPIO初始化后的默认电平*/
	GPIO_SetBits(GPIOA, GPIO_Pin_1 | GPIO_Pin_2);				//设置PA1和PA2引脚为高电平
}

/**
  * 函    数:LED1开启
  * 参    数:无
  * 返 回 值:无
  */
void LED1_ON(void)
{
	GPIO_ResetBits(GPIOA, GPIO_Pin_1);		//设置PA1引脚为低电平
}

/**
  * 函    数:LED1关闭
  * 参    数:无
  * 返 回 值:无
  */
void LED1_OFF(void)
{
	GPIO_SetBits(GPIOA, GPIO_Pin_1);		//设置PA1引脚为高电平
}

/**
  * 函    数:LED1状态翻转
  * 参    数:无
  * 返 回 值:无
  */
void LED1_Turn(void)
{
	if (GPIO_ReadOutputDataBit(GPIOA, GPIO_Pin_1) == 0)		//获取输出寄存器的状态,如果当前引脚输出低电平
	{
		GPIO_SetBits(GPIOA, GPIO_Pin_1);					//则设置PA1引脚为高电平
	}
	else													//否则,即当前引脚输出高电平
	{
		GPIO_ResetBits(GPIOA, GPIO_Pin_1);					//则设置PA1引脚为低电平
	}
}

/**
  * 函    数:LED2开启
  * 参    数:无
  * 返 回 值:无
  */
void LED2_ON(void)
{
	GPIO_ResetBits(GPIOA, GPIO_Pin_2);		//设置PA2引脚为低电平
}

/**
  * 函    数:LED2关闭
  * 参    数:无
  * 返 回 值:无
  */
void LED2_OFF(void)
{
	GPIO_SetBits(GPIOA, GPIO_Pin_2);		//设置PA2引脚为高电平
}

/**
  * 函    数:LED2状态翻转
  * 参    数:无
  * 返 回 值:无
  */
void LED2_Turn(void)
{
	if (GPIO_ReadOutputDataBit(GPIOA, GPIO_Pin_2) == 0)		//获取输出寄存器的状态,如果当前引脚输出低电平
	{                                                  
		GPIO_SetBits(GPIOA, GPIO_Pin_2);               		//则设置PA2引脚为高电平
	}                                                  
	else                                               		//否则,即当前引脚输出高电平
	{                                                  
		GPIO_ResetBits(GPIOA, GPIO_Pin_2);             		//则设置PA2引脚为低电平
	}
}

6.2.4 实验现象

系统上电后,OLED 屏幕初始化完毕,程序进入阻塞监听状态等待外部指令介入:

  • 合法指令执行:在 PC 端串口助手(文本模式)发送 @LED_ON\r\n,系统解析指令并驱动底层引脚点亮 LED 测试灯。同时,OLED 屏幕清除旧数据并刷新显示接收到的指令及 "LED_ON_OK",PC 端同步接收到单片机回传的 "LED_ON_OK" 字符串;发送 @LED_OFF\r\n 则熄灭 LED,回传并显示 "LED_OFF_OK"。

  • 非法指令拦截:若发送未在程序指令库中定义的字符串(如 @TEST\r\n),系统异常处理分支捕获该行为,拒绝执行任何硬件电平翻转,并向上位机原样回传 "ERROR_COMMAND",维持系统的容错与稳定状态。

相关推荐
【 STM32开发 】2 小时前
【STM32 + CubeMX】低功耗 -- Standby 待机模式
单片机·嵌入式硬件
happymaker06262 小时前
web前端学习日记——DAY07(js交互编程)
前端·javascript·学习
广药门徒2 小时前
PADS 改变飞线方向 改变同网络既定路径规划 改变VIRTUAL HOLE连接路径 修复差分信号自动规划飞线错误问题的办法
嵌入式硬件
●VON2 小时前
Flutter 入门指南:从基础组件到状态管理核心机制
前端·学习·flutter·von
毕设源码-郭学长2 小时前
【开题答辩全过程】以 基于SSM Vue的中药知识学习交流网站为例,包含答辩的问题和答案
学习
童话名剑2 小时前
FCOS(学习笔记)
笔记·学习·fcos
猪猪童鞋2 小时前
PADS安装包资源分享
嵌入式硬件
weixin_458872612 小时前
东华复试OJ冲刺1
学习
Zarek枫煜2 小时前
zig与c3的算法 -- 静态队列
开发语言·stm32·单片机·嵌入式硬件·算法·51单片机