本节主要内容:
- 如何去规定一个合理的数据包格式
- 如何收发数据包
1. 数据包格式规定/定义
1.1 HEX 数据包定义
- 固定包长,含包头包尾
- 可变包长,含包头包尾
首先数据包的作用是把一个个单独的数据给打包起来,方便我们进行多字节的数据通信。我们之前学习了串口的代码,发送一个字节,接收一个字节都没问题,但在实际应用中,我们可能需要把多个字节打包为一个整体进行发送,比如说,我们有个陀螺仪传感器,需要用串口发送数据到 STM32,陀螺仪的数据,比如 X 轴一个字节,Y 轴一个字节,Z 轴一个字节,总共 3 个数据,需要连续不断地发送,当你像这样,XYZXYZXYZ 连续发送的时候,就会出现一个问题,就是接收方,它不知道这数据哪个对应 X,哪个对应 Y,哪个对应 Z,因为接收方可能会从任意位置开始接收,所以会出现数据错位的现象,这时候,我们就需要研究一种方式,把这个数据进行分割,把 XYZ 这一批数据分隔开,分成一个个数据包,这样再接收的时候,就知道了数据包的第一个数据就是 X、第二个数据就是 Y,第三个数据就是 Z,这就是数据包的任务,就是把属于同一批的数据进行打包和分割,方便接收方进行识别,那有关分割打包的方法,可以自己发挥想象力来设计,只要逻辑行得通就行。
比如我可以设计,在这个 XYZXYZ 数据流中,数据包的第一个数据,也就是 X 的数据包,它的最高位置 1,其余数据包,最高位都置 0,当我接收到了数据之后,判断一下最高位,如果是 1,那就是 X 数据,然后紧跟着的两个数据就分别是 Y 和 Z,这就是一种可行的分割方法。这种方法就是把每个数据的最高位当作标志位来进行分割的,实际也有应用的例子,比如 UTF8 的编码方式,和这就是类似的,不过它那个编码更高级一些,感兴趣的话可以了解一下。
那本节我们主要讲的数据包分割方法,并不是在数据的高位添加标志位这种方式,因为这种方式破坏了原有数据,使用起来比较复杂,我们串口数据包,通常使用的是额外添加包头包尾的这种方式,比如我这里就列举了两种数据包格式,第一种是固定包长,含包头包尾,也就是每个数据包的长度都固定不变,数据包前面是包头,后面是包尾;第二种是可变包长,含包头包尾,也就是每个数据包的长度可以是不一样的,数据包前面是包头,后面是包尾。它们的数据包格式,可以是用户根据需求,自己规定的,也可以是你买一个模块,别的开发者规定的。
那本节规定是,比如固定包长这里,一批数据规定有 4 个字节,在这 4 个字节之前,加一个包头,比如我定义 0xFF 为包头,在 4 个字节之后,加一个包尾,比如我定义 0xFE 为包尾。那当我接收到 0xFF 之后,我就知道一个数据包来了,接着我再接收到的 4 个字节,就当作数据包的第 1、2、3、4 个数据,存在一个数组里,最后跟一个包尾,当我收到 0xFE 之后,就可以置一个标志位,告诉程序,我收到了一个数据包,然后新的数据包过来,再重复之前的过程,这样就可以在一个连续不断的数据流中,分割出我们想要的数据包了,这就是添加包头包尾实现数据分割打包的思路。
接着我们来研究几个问题:
- 包头包尾和数据载荷重复问题
这里定义 FF 为包头,FE 为包尾,如果我传输的数据本身就是 FF 和 FE 怎么办呢?那这个问题确实存在,如果数据和包头包尾重复,可能会引起误判。
对应这个问题我们有如下几种解决方法:
- 限制载荷数据的范围,如果可以的话,我们可以在发送的时候,对数据进行限幅,比如 XYZ,3 个数据,变化范围都可以是 0~100,那就好办了,我们可以在载荷中只发送 0~100 的数据,这样就不会和包头包尾重复了。
- 如果无法避免载荷数据和包头包尾重复,那我们就尽量使用固定长度的数据包,这样由于载荷数据是固定的,只要我们通过包头包尾对齐了数据,我们就可以严格知道,哪个数据应该是包头包尾,哪个数据应该是载荷数据,在接收载荷数据的时候,我们并不会判断它是否是包头包尾,而在接收包头包尾的时候,我们会判断它是不是确实是包头包尾,用于数据对齐。这样,在经过几个数据包的对齐之后,剩下的数据包应该就不会出现问题了。
- 增加包头包尾的数量,并且让它尽量呈现出载荷数据出现不了的状态,比如我们使用 FF、FE 做为包头,FD、FC 作为包尾,这样也可以避免载荷数据和包头包尾重复的情况发生。
- 包头包尾并不是全部都需要的。
比如我们可以只要一个包头,把包尾删掉,这样数据包的格式就是,一个包头 FF,加 4 个数据,这样也是可以的,当检测到 FF,开始接收,收够 4 个字节后,置标志位,一个数据包接收完成,这样也可以,不过这样的话,载荷和包头重复的问题会更严重一些,比如最严重的情况下,我载荷全是 FF,包头也是 FF,那你肯定不知道哪个是包头了,而加上 FE 作为包尾,无论数据怎么变化都是可以分辨出包头包尾的。
- 固定包长和可变包长的选择问题
对于 HEX 数据包来说,如果你的载荷会出现和包头包尾重复的情况,那就最好选择固定包长,这样可以避免接收错误,如果你又会重复,又选择可变包长,那数据很容易就乱套了;如果载荷不会和包头包尾重复,那可以选择可变包长,数据长度,像这样,4 位、3 位、等等,1 位、10 位,来回任意变肯定都没问题。因为包头包尾是唯一的,只要出现包头,就开始数据包,只要出现包尾,就结束数据包,这样就非常灵活了。
- 各种数据转换为字节流的问题
这里数据包都是一个字节一个字节组成的,如果你想发送 16 位的整型数据、32 位的整型数据、float、double、甚至是结构体,其实都没问题,因为它们内部其实都是由一个字节一个字节组成的,只需要用一个 uint8_t 的指针指向它,把它们当作一个字节数组发送就行了。
1.2 文本数据包定义
- 固定包长,含包头包尾
- 可变包长,含包头包尾
文本数据包和 HEX 数据包,就分别对应了文本模式和 HEX 这两种模式。在 HEX 数据包里面,数据都是以原始的字节数据本身呈现的,而在文本数据包里面,每个字节就经过了一层编码和译码,最终表现出来的,就是文本格式,但实际上,每个文本字符背后,其实都还是一个字节的 HEX 数据。
那我们看一下,同样给出了固定包长和可变包长这两种模式,由于数据译码成了字符形式,这就会存在大量的字符可以作为包头包尾,可以有效避免载荷和包头包尾重复的问题。比如本节规定以 @ 这个字符作为包头,以 \r\n 也就是换行,这两个字符作为包尾,在载荷数据中间可以出现除了包头包尾的任意字符,这很容易做到,所以文本数据包基本不用担心载荷和包头包尾重复的问题,使用非常灵活。可变包长、各种字母、符号、数字,都可以随意使用,当我们接收到载荷数据之后,得到的就是一个字符串,在软件中再对字符串进行操作和判断,就可以实现各种指令控制的功能了。而且字符串数据包表达的意义很明显,可以把字符串数据包直接打印到串口助手上,什么指令,什么数据,一眼就能看明白,所以这个文本数据包,通常会以换行作为包尾,这样在打印的时候,就可以一行一行地显示了,非常方便。
那 HEX 数据包与文本数据包这两种对比下来,其实也是各有优缺点的。
HEX 数据包:
- 优点:传输最直接,解析数据非常简单,比较适合一些模块发送原始的数据。比如一些使用串口通信的陀螺仪、温湿度传感器。
- 缺点:灵活性不足、载荷容易和包头包尾重复。
文本数据包:
- 优点:数据直观易理解,非常灵活,比较适合一些输入指令进行人机交互的场合,比如蓝牙模块常用的 AT 指令,CNC 和 3D 打印机常用的 G 代码,都是文本数据包的格式。
- 缺点:解析效率低。比如发送一个数 100,HEX 数据包就是一个字节 100,完事,文本数据包就得是 3 个字节的字符,‘1’,‘0’,‘0’,收到之后还要把字符转换成数据,才能得到 100。所以说,我们需要根据实际场景来选择和设计数据包格式。
2. 数据包的收发流程
2.1 数据包的发送
数据包的发送非常简单。
在 HEX 数据包这里,我如果想发送一个数据包,就定义一个数组,填充数据,然后用上节我们写过的 SendArray 一发就完事了。
在 文本 数据包这里,我如果想发送数据包,就写一个字符串,然后调用 SendString 一发送也完事了。
数据包的发送非常简单。因为发送过程是完全自主可控的,想发啥就发啥。我们写到的时候也能感受到,串口发送,比接收简单多了。
2.2 数据包的接收
接受一个数据包就比较复杂了。我们来学习一下。
这里演示了固定包长 HEX 数据包的接收方法。首先,根据前一节的代码,我们知道,每收到一个字节,程序都会进一遍中断,在中断函数里,我们可以拿到这一个字节,但拿到之后,我们就得退出中断了,所以,每拿到一个数据,都是一个独立的过程,而对于数据包来说,很明显它具有前后关联性,包头之后是数据,数据之后是包尾,对于包头、数据和包尾这三种状态,我们都需要有不同的处理逻辑,所以在程序中,我们需要设计一个能记住不同状态的机制,在不同状态执行不同的操作,同时还要进行状态的合理转移,这种程序设计思维,就叫做“状态机”。
在这里我们就使用状态机的方法来接收一个数据包,要想设计一个好的状态机程序,画一个下面这样的状态转移图是必要的。对于上面这样一个固定包长 HEX 数据包来说,我们可以定义 3 个状态,第一个状态是等待包头,第二个状态是接收数据,第三个状态是等待包尾。每个状态需要用一个变量来标志一下,比如我这里用变量 S 来标志,三个状态依次为 S = 0,S = 1,S = 2,这一点类似于置标志位,只不过标志位只有 0 和 1,而状态机是多标志位状态的一种方式,然后执行流程是:
- 最开始,S = 0,收到一个数据,进中断,根据 S = 0,进入第一个状态的程序,判断数据是不是包头 FF,如果是 FF,则代表收到包头,之后置 S = 1,退出中断,结束,这样下次再进中断,根据 S = 1,就可以接收数据的程序了,那在第一个状态,如果收到的不是 FF,就证明数据包没有对齐,我们应该等待数据包包头的出现,这时状态就仍然是 0,下次进中断,就还是判断包头的逻辑,直到出现 FF,才能转到下一个状态。
- 那之后,出现了 FF,我们就可以转移到接收数据的状态了。这时再收到数据,我们就直接把它存在数组里,另外再用一个变量,记录收了多少个数据,如果没收够四个数据,就一直是接收状态,如果收够了,就置 S = 2,下次中断时,就可以进入下一个状态了。
- 那最后一个状态就是等待包尾了,判断数据是不是 FE,正常情况,应该是 FE,这样就可以置 S = 0,回到最初的状态,开始下一个轮回,当然也有可能这个数据不是 FE,比如数据和包头重复了,导致包头位置判断错了,那这个包尾位置就有可能不是 FE,这时就可以进入重复等待包尾的状态,直到接收到真正的包尾。这样加入包尾的判断,更能预防因数据和包头重复造成的错误。
这就是使用状态机接收数据包的思路。这个状态机其实是一种很广泛的编程思路,在很多地方都可以用到。使用的基本步骤是:
- 先根据项目要求定义状态,画几个圈
- 然后考虑好各个状态在什么情况下会进行转移,如何转移,画好线和转移条件。
- 最后根据这个图来进行编程。
这样思维就会非常清晰了。比如你要做个菜单,就可以用到状态机的思维,按什么键,切换什么菜单,执行什么样的程序,还有一些芯片内部逻辑,也会用到状态机。比如芯片什么情况下进入待机状态,什么情况下进入工作状态,这也是状态机的应用,希望大家可以研究一下,对你的编程肯定会有帮助。
这里演示了可变包长 文本 数据包的接收方法。我们看一下可变包长 文本 数据包的接收流程,同样也是利用状态机,定义了三个状态。
- 第一个状态,等待包头,判断收到的是不是我们规定的 @ 符号,如果收到 @,就进入接收状态。
- 在接收状态下,依次接收数据,同时,这个状态还应该要兼具等待包尾的功能,因为这是可变包长,我们接收数据的时候,也要时刻监视,是不是收到包尾了,一旦收到包尾了,就结束,那这里,这个状态的逻辑就应该是,收到一个数据,判断是不是 \r,如果不是,就正常接收,如果是,则不接收,同时跳到下一个状态,等待包尾 \n,因为这里数据包有两个包尾 \r,\n,所以需要第三个状态。
- 如果只有一个包尾,那在出现包尾之后,就可以直接回到初始状态了,只需要两个状态就行。因为接收数据和等待包尾需要在一个状态里同时进行。由于串口的包头包尾不会出现在数据中,所以基本不会出现数据错位的现象。
这就是使用状态机接收文本数据包的方法。
其他的数据包也都可以套用这两个形式,等会我们写程序就会根据这里面的流程来。
到这里,关于数据包的定义、分类、优缺点和注意事项就介绍完了。
3. 两个 串口收发数据包 功能案例
3.1 串口收发 HEX 数据包
3.1.1 硬件电路图
这两个代码的接线图之前都用到过,应该都挺简单的。下面这个串口模块的接线和之前都是一样的,没有变化。然后在 PB1 口接了一个按键,用于控制。
3.1.2 串口发送 HEX 数据包代码
我们从上一节的代码(串口发送+接收)的基础上来改。在 Serial.c 文件中添加收发 HEX 数据包的部分。其中 HEX 数据包的格式与 1.1 节一样。固定包长,含包头包尾,其中包头为 FF,载荷数据固定 4 字节,包尾为 FE。
- 在 .c 文件这里为了收发数据包,我们先定义两个缓存区的数组。并在 .h 头文件声明为外部可调用。
uint8_t Serial_TxPacket[4];//数据个数为四个
uint8_t Serial_RxPacket[4];//数据个数为四个
这 4 个数据只存储发送或接收的载荷数据,包头包尾就不存了。
extern uint8_t Serial_TxPacket[];//数组声明的时候数量可以不要。
extern uint8_t Serial_RxPacket[];
- 数组声明的时候数量可以不要。
- 如果模块里有数组需要外部调用,一般就习惯直接把变量声明出去了。如果要封装写个 Set、Get 的话,也可以,那就得用指针进行传递,不过那样太麻烦了,所以这里就直接声明出去。
- 之后 RxFlag 留着,如果收到一个数据包,就置 RxFlag。
接着继续往下,这些初始化的代码,都不需要更改。发送的模块函数也先放着。之后,GetRxData 这个函数删掉,GetRxFlag 留着,最后中断内部的实现先删掉。
- 先写一个 SendPacket 的函数
我们想要的效果是,调用一下这个函数,TxPacket 数组的 4 个数据,就会自动加上包头包尾发送出去。
void Serial_SendPacket(void) {
Serial_SendByte(0xFF);//发送包头
Serial_SendArray(Serial_TxPacket, 4);//这样就依次把 4 个载荷数据发出去了
Serial_SendByte(0xFE);//发送包尾
}
包头 FF 和 包尾 FE 用于控制接收,没有显示。
- main.c 函数实现
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "Serial.h"
int main(void) {
OLED_Init();
OLED_ShowString(1, 1, "RxData:");
Serial_Init();//初始化串口
Serial_TxPacket[0] = 0x01;//先填充发送缓存区数组
Serial_TxPacket[1] = 0x02;
Serial_TxPacket[2] = 0x03;
Serial_TxPacket[3] = 0x04;
Serial_SendPacket();//加上包头包尾,统一发送出去
while(1){
}
}
数组填充好之后,调用一下 SendPacket,之后 SendPacket 就会取出数组的内容,加上包头包尾,统一发送出去
这里打开串口,发送模式和接收模式都选择 HEX 模式,然后按一下复位,可以看到,数据包就发送过来了。数据包格式是 FF,4 个载荷数据,FE。实际上就是 SendArray 对吧,这个没有问题。
3.1.3 串口接收 HEX 数据包代码
-
接收数据包的缓存区和标志位,我们已经定义好了。
-
然后在接收中断函数里,我们就需要用状态机来执行接收逻辑了。
接收数据包,然后把载荷数据存在 RxPacket 数组里,这是我们的任务。根据状态转移图,首先我们要定义一个标志当前状态的变量 S。
那在中断这里,我们可以在函数里面定义一个静态变量。这个静态变量类似于全局变量,函数进入只会初始化一次 0,在函数退出后,数据仍然有效。与全局变量不同的是,静态变量只能在本函数使用。
void USART1_IRQHandler(void) {
static uint8_t RxState = 0;//状态变量 S,三个状态 S 分别为 0、1、2
static uint8_t pRxPacket = 0;//指示接收到哪一个了,最开始默认为 0
//先判断标志位
if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET) {//如果 RXNE 确实置 1 了,就进 if
uint8_t RxData = USART_ReceiveData(USART1);//首先获取一下 RxData
//根据 RxState 不同,我们需要进入不同的处理程序。
//以下就是状态选择的部分,然后就依次写每个状态执行的操作和状态转移条件就行了。
if (RxState == 0) {//进入等待包头程序
if (RxData == 0xFF) {//就说明收到包头了
RxState = 1;//转移状态
pRxPacket = 0;//在状态 0 转移到状态 1 时,提前清一个 0
}
//如果 RxData != FF 呢?就不转移状态。
}
else if (RxState == 1) {//进入接收数据程序
//这里要依次接收 4 个数据,存在 RxPacket 数组里,所以还需要一个变量,来记一下,接收几个了。
Serial_RxPacket[pRxPacket] = RxData;//将 RxData 存在接收数组里
pRxPacket ++;//移动到下一个位置
//这样就是,每进一次接收状态,数据就转存一次缓存数组,同时存的位置 ++
if (pRxPacket >= 4) {//也就是 4 个载荷数据已经收完了,这时就可以转移到下一个状态了
RxState = 2;//转移状态
pRxPacket = 0;//清零,为下次接收做准备
}
}
else if (RxState == 2) {//进入等待包尾程序
if (RxData == 0xFE) {//判断是不是包尾,
RxState = 0;//如果是的话,回到最初的状态,
Serial_RxFlag = 1;//同时,代表一个数据包接收到了,可以置一个接收标志位。
}
//如果不等于 FE 的话,就是还没收到包尾,我们同样不做处理。仍然在这个状态,等待包尾
}
}
}
好,到这里,我们这个接收数据包的状态机程序就完成了。大家可以对照状态转移图来理解,都是一一对应的。
注意一定要用 else if,else 不要去掉。如果你只是 3 个并列的 if,可能在状态转移的时候会出现问题。比如在状态 0,你想转移到状态 1,就置 RxState = 1。结果就会造成下面状态 1 的条件就立马满足了,这样会出现连续两个 if 都同时成立的情况。这个情况我们不希望出现。所以这里使用 else if,保证每次进来之后,只能选择执行其中一个状态的代码。或者你用 switch case 语句,也可以保证只有一个条件满足。
- main.c 函数实现
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "Serial.h"
int main(void) {
OLED_Init();
OLED_ShowString(1, 1, "RxData:");
Serial_Init();//初始化串口
Serial_TxPacket[0] = 0x01;//先填充发送缓存区数组
Serial_TxPacket[1] = 0x02;
Serial_TxPacket[2] = 0x03;
Serial_TxPacket[3] = 0x04;
Serial_SendPacket();//数组填充好之后,调用一下 SendPacket,之后 SendPacket 就会取出数组的内容,加上包头包尾,统一发送出去
while(1){
if (Serial_GetRxFlag() == 1) {//即收到了数据包
OLED_ShowHexNum(1, 1, Serial_RxPacket[0], 2);
OLED_ShowHexNum(1, 4, Serial_RxPacket[1], 2);
OLED_ShowHexNum(1, 7, Serial_RxPacket[2], 2);
OLED_ShowHexNum(1, 10, Serial_RxPacket[3], 2);
}
}
}
在串口助手的发送区,按照数据包格式发送数据,包头 FF,数据,给个 11 22 33 44,包尾 FE,发送。可以看到 OLED 显示了这个数据包的载荷数据 11 22 33 44。我们改一下,比如 FE FF 88 FE,这时载荷数据和 包头包尾是有重复的,再发送一下看看,目前收到的数据,仍然没问题,因为我们程序在接收载荷数据的时候,并不会判断包头包尾,所以这时载荷数据即使和包头包尾重复,也干扰不到我们。这就是数据包接收的程序。
之后这个程序还隐藏有一个问题大家需要注意一下:就是这个 RxPacket 数组,它是一个同时被写入又同时被读出的数组。在中断函数里,我们会依次写入它。在主函数里,我们又会依次读出它。
这会造成什么问题呢?
就是数据包之间可能会混在一起,比如你读出的过程太慢了,前面两个数据刚读出来,等了一会,才继续往后读取。那这时后面的数据就可能会刷新为下一个数据包的数据,也就是你读出的数据可能一部分属于上一个数据包,另一部分属于下一个数据包。这个问题需要注意一下。
那解决方法呢?
可以是在接收部分加入判断,就是在每个数据包读取处理完毕后,再接收下一个数据包。
当然很多情况下其实还可以不进行处理,像这种 HEX 数据包,多是用于传输各种传感器的每个独立数据。比如陀螺仪的 X,Y,Z 轴数据,温湿度数据等等,它们相邻数据包之间的数据,具有连续性,这样即使相邻数据包混在一起了,也没关系,所以这种情况下,就不需要关心这个问题了。具体到底怎么处理,还需要大家结合实际情况来操作了。这里就提一下这个可能存在的问题,大家了解一下就行。
好,那我们收发 HEX 数据包的程序,大概就讲完了。
3.1.4 示例代码
接下来我们来完善一下最终的程序现象。
Serial.h
#ifndef __SERIAL_H
#define __SERIAL_H
#include "stdio.h"
extern uint8_t Serial_TxPacket[];//数组声明的时候数量可以不要。如果模块里有数组需要外部调用,一般就习惯直接把变量声明出去了。
extern uint8_t Serial_RxPacket[];//
void Serial_Init(void);
void Serial_SendByte(uint8_t Byte);
void Serial_SendArray(uint8_t* Array, uint16_t Length);
void Serial_SendString(char* str);
void Serial_SendNumber(uint32_t Number, uint8_t Length);
void Serial_Printf(char* format, ...);
void Serial_SendPacket(void);
uint8_t Serial_GetRxFlag(void);
#endif
Serial.c
#include "stm32f10x.h" // Device header
#include "stdio.h"
#include "stdarg.h"
uint8_t Serial_TxPacket[4];//数据个数为四个
uint8_t Serial_RxPacket[4];//数据个数为四个
uint8_t Serial_RxFlag;
void Serial_Init(void) {
//1.开启时钟,吧需要用的 USART 和 GPIO 的时钟打开
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);//USART1 是 APB2 的外设,其他的都是 APB1 的外设,注意一下
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
//2.GPIO 初始化,把 TX 配置成复用输出,RX 配置成输入
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;//目前需要数据发送,所以初始化 TX
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);//这样就是把 PA9 配置为复用推挽输出,供 USART1 的 TX 使用
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 配置好,供 USART1 的 RX 使用
//3.配置 USART,直接使用一个结构体,就可以把所有的参数都配置好了
USART_InitTypeDef USART_InitStructure;
USART_InitStructure.USART_BaudRate = 9600;//波特率,可以直接写一个波特率的数值
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;//硬件流控制,这个参数的取值可以是 None 不使用流控、只用 CTS、只用 RTS 或者 CTS 和 RTS 都使用。我们不使用流控,所以选择 None。
USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;//串口模式,可以选择 Tx 发送模式和 Rx 接收模式,那我们这个程序既需要发送又需要接收,那就用或符号把 Tx 和 Rx 或起来。就跟 GPIO_Pin_1 | GPIO_Pin_2 一样的用法。
USART_InitStructure.USART_Parity = USART_Parity_No;//校验位,可以选择 No无检验、Odd奇校验、Even偶校验,我们不需要校验,所以选择 No。
USART_InitStructure.USART_StopBits = USART_StopBits_1;//停止位,这个参数可以选择 0.5、1、1.5、2,我们选择 1 位停止位。
USART_InitStructure.USART_WordLength = USART_WordLength_8b;//字长,这个参数可以选择 8 位或 9 位,我们不需要校验,所以字长就选择 8 位。
USART_Init(USART1, &USART_InitStructure);
//4.如果你只需要发送的功能,就直接开启 USART,初始化就结束了,如果你需要接收的功能,可能还需要配置中断,那就在开启 USART 之前,再加上 ITConfig 和 NVIC 的代码就行了。
USART_Cmd(USART1, ENABLE);
//5. 配置中断
USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);//开启 RXNE 标志位到 NVIC 的输出
//6. 配置 NVIC
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);//先分组
NVIC_InitTypeDef NVIC_InitStructure;//再初始化 NVIC 的 USART1 通道
NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;//中断通道
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;//优先级
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&NVIC_InitStructure);//到这里,RXNE 标志位一旦置 1 了,就会向 NVIC 申请中断,之后我们可以在中断函数里接收数据,中断函数的名字,我们看一下启动文件 startup_stm32f10x_md.s,复制 USART1_IRQHandler
//整个初始化流程非常中规中矩,每个函数大家应该都已经很熟悉了,得益于库函数的封装,内部各种细节问题,就不需要我们再关心了。
}
//那初始化完成之后,如果要发送数据,调用一个发送函数就行了,如果要接收数据,就调用接收的函数。如果要获取发送和接收的状态,就调用获取标志位的函数。
//这就是 USART 外设的使用思路。
//写一个发送数据的函数,调用这个函数,就可以从 Tx 引脚发送一个字节数据
void Serial_SendByte(uint8_t Byte) {
//1.调用串口的 SendData 函数
USART_SendData(USART1, Byte);
//2.等待标志位
while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);//发送数据寄存器空标志位
//3.标志位会自动清零。
}
//发送数组
void Serial_SendArray(uint8_t* Array, uint16_t Length) {//指向待发送数组的首地址
uint16_t i;
for (i = 0; i < Length; i++) {
Serial_SendByte(Array[i]);
}
}
//发送字符串
void Serial_SendString(char* str) {//由于字符串自带一个结束标志位,所以就不需要再传递长度参数了
uint8_t i;
for (i = 0; str[i] != '\0'; i++ ) {//数据 0 对应空字符,是字符串结束标志位,如果不等于 0,就是还没结束,进入循环。如果等于 0,就是结束了,停止循环。
//这里 数据 0 也可以写成字符的形式,就是 '\0',这就是空字符的转义字符表示形式。和直接写 0,最终效果是一样的,这里就以转义字符的形式来写吧。
Serial_SendByte(str[i]);
}
}
//次方函数
uint32_t Serial_Pow(uint32_t X, uint32_t Y) {//返回值 = X ^ Y
uint32_t ret = 1;
while( Y-- ) {
ret *= X;
}
return ret;
}
//发送字符形式的数字,最终能在电脑上显示字符串形式的数字
void Serial_SendNumber(uint32_t Number, uint8_t Length) {
//把 Number 的个位、十位、百位等等,以十进制拆分开
//怎么以十进制拆分开:
//取某一位就是 数字 / 10 ^ x % 10。/ 10 ^ x 就是把这一位的右边去掉,%10 就是把左边去掉。这就是拆分数字的思路。
uint8_t i;
for (i = 0; i < Length; i++) {
//然后转换成字符数字对应的数据(以字符的形式显示,所以需要加一个偏移,可以看一下 ASCII 码表,字符 0 对应的数据是 0x30,所以这里还得加上一个 0x30,或者以字符的形式写,就是'0'),依次发送出去
Serial_SendByte(Number / Serial_Pow(10, Length - i - 1) % 10 + '0');//以十进制从高位到低位依次发送
}
//这就是发送数据的逻辑,其实这两个函数 SendString 和 SendNumber 和 OLED 的 ShowString 和 ShowNumber 是一样的逻辑,可以对照 OLED 代码看看,都是一样的
}
int fputc(int ch, FILE* f) {//这是 fputc 函数的原型,这些参数上面的,按照我这样写就行,不需要管那么多
//把 fputc 重定向到串口
Serial_SendByte(ch);
return ch;
}
//那重定向 fputc 和 printf 有什么关系呢?这是因为,这个 fputc 是 printf 函数的底层,printf 函数在打印的时候,就是不断调用 fputc 函数一个个打印的。
//我们把 fputc 函数重定向到了串口,那 printf 自然就输出到串口了。好,这样,printf 就移植好了
void Serial_Printf(char* format, ...) {
//首先定义输出的字符串
char str[100];
//注意:接下来的部分就比较难了
va_list arg;//定义一个参数列表变量。va_list 是一个类型名,arg 是一个变量名
va_start(arg, format);//从 format 位置开始接收参数表,放在 arg 里面。
vsprintf(str, format, arg);//之后 sprintf,打印位置是 str,格式化字符串是 format,参数表是 arg。在这里 sprintf 要改成 vsprintf。因为 sprintf 只能接收直接写的参数,对于这种封装格式,要用 vsprintf。
va_end(arg);//之后,释放参数表。
Serial_SendString(str);//最后,把 str 发送出去
}
void Serial_SendPacket(void) {
Serial_SendByte(0xFF);//发送包头
Serial_SendArray(Serial_TxPacket, 4);//这样就依次把 4 个载荷数据发出去了
Serial_SendByte(0xFE);//发送包尾
}
uint8_t Serial_GetRxFlag(void) {
if (Serial_RxFlag == 1) {
Serial_RxFlag = 0;
return 1;
}
return 0;
}
void USART1_IRQHandler(void) {
static uint8_t RxState = 0;//状态变量 S,三个状态 S 分别为 0、1、2
static uint8_t pRxPacket = 0;//指示接收到哪一个了,最开始默认为 0
//先判断标志位
if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET) {//如果 RXNE 确实置 1 了,就进 if
uint8_t RxData = USART_ReceiveData(USART1);//首先获取一下 RxData
//根据 RxState 不同,我们需要进入不同的处理程序。
//好,这就是状态选择的部分,然后就依次写每个状态执行的操作和状态转移条件就行了。
if (RxState == 0) {//进入等待包头程序
if (RxData == 0xFF) {//就说明收到包头了
RxState = 1;//转移状态
pRxPacket = 0;//在状态 0 转移到状态 1 时,提前清一个 0
}
//如果 RxData != FF 呢?就不转移状态。
}
else if (RxState == 1) {//进入接收数据程序
//这里要依次接收 4 个数据,存在 RxPacket 数组里,所以还需要一个变量,来记一下,接收几个了。
Serial_RxPacket[pRxPacket] = RxData;//将 RxData 存在接收数组里
pRxPacket ++;//移动到下一个位置
//这样就是,每进一次接收状态,数据就转存一次缓存数组,同时存的位置 ++
if (pRxPacket >= 4) {//也就是 4 个载荷数据已经收完了,这时就可以转移到下一个状态了
RxState = 2;//转移状态
pRxPacket = 0;//清零,为下次接收做准备
}
}
else if (RxState == 2) {//进入等待包尾程序
if (RxData == 0xFE) {//判断是不是包尾,
RxState = 0;//如果是的话,回到最初的状态,
Serial_RxFlag = 1;//同时,代表一个数据包接收到了,可以置一个接收标志位。
}
//如果不等于 FE 的话,就是还没收到包尾,我们同样不做处理。仍然在这个状态,等待包尾
}
}
}
main.c
#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();
Key_Init();
Serial_Init();//初始化串口
OLED_ShowString(1, 1, "TxPacket");
OLED_ShowString(3, 1, "RxPacket");
//先写一下发送的逻辑,按一下按键,变换一下数据,发送到串口助手上
//1.先把发送缓存赋一个初始值
Serial_TxPacket[0] = 0x01;
Serial_TxPacket[1] = 0x02;
Serial_TxPacket[2] = 0x03;
Serial_TxPacket[3] = 0x04;
while(1){
KeyNum = Key_GetNum();
if (KeyNum == 1) {//按键按下
//2.变换测试数据
Serial_TxPacket[0]++;
Serial_TxPacket[1]++;
Serial_TxPacket[2]++;
Serial_TxPacket[3]++;
//3.执行发送数据包(SendPacket 就会取出数组的内容,加上包头包尾,统一发送出去)
Serial_SendPacket();
//4.用 OLED 显示一下发送的数据
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 显示一下接收到的数据
OLED_ShowHexNum(4, 4, Serial_RxPacket[1], 2);
OLED_ShowHexNum(4, 7, Serial_RxPacket[2], 2);
OLED_ShowHexNum(4, 10, Serial_RxPacket[3], 2);
}
}
}
首先是发送数据包,按一下按键,变换一次数据,发送一个数据包,OLED 显示发送数据,串口助手收到数据;然后是接收数据包,我们发送指定格式的数据包,OLED 显示接收到的数据包。这就是第一个程序串口收发 HEX 数据包的内容。
3.2 串口收发文本数据包
3.2.1 硬件电路图
之后就是,收发文本数据包的接线图。下面也同样是串口模块,然后在 PA1 口接了一个 LED,用于指示。(长脚接正,短脚接 PA1)
3.2.2 示例代码
修改一下串口的 c 文件(Serial.c)
我们这个程序的数据包格式定义,是可变包长,含包头包尾,以 @ 符号为包头,换行的两个符号为包尾,中间的载荷字符数量不固定。
程序里只写接收部分。因为发送的话,不方便像 HEX 数组一样一个个更改的。所以发送就直接在主函数里 SendString 或者 printf 就行了,非常简单。
- 首先缓冲区,发送的删掉。接收的数据类型定义为 char,用于接收字符。
char Serial_RxPacket[100];//数量给多点,防止溢出
数量给多点,防止溢出,这里要求单条指令最长不能超过 100 个字符。
那接收缓存区就定义好了。
- 之后是中断的状态机部分,参考状态转移图
void USART1_IRQHandler(void) {
static uint8_t RxState = 0;//状态变量 S,三个状态 S 分别为 0、1、2
static uint8_t pRxPacket = 0;//指示接收到哪一个了,最开始默认为 0
//先判断标志位
if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET) {//如果 RXNE 确实置 1 了,就进 if
uint8_t RxData = USART_ReceiveData(USART1);//首先获取一下 RxData
//根据 RxState 不同,我们需要进入不同的处理程序。
//以下就是状态选择的部分,然后就依次写每个状态执行的操作和状态转移条件就行了。
if (RxState == 0) {//进入等待包头程序
if (RxData == '@') {//就说明收到包头了
RxState = 1;//转移状态
pRxPacket = 0;//在状态 0 转移到状态 1 时,提前清一个 0
}
//如果 RxData != '@' 呢?就不转移状态。
}
else if (RxState == 1) {//进入接收数据程序
//因为载荷字符数量并不确定,所以每次接收之前,我们必须先判断是不是包尾
if (RxData == '\r') {//就说明收到包尾了
RxState = 2;//转移状态
}
else {//如果不是包尾的话,才需要接收数据
Serial_RxPacket[pRxPacket] = RxData;
pRxPacket ++;
}
}
else if (RxState == 2) {//进入等待第二个包尾程序,在这个状态我们需要检测 RxData 是不是 == '\n'
if (RxData == '\n') {
RxState = 0;//如果是的话,回到最初的状态,
Serial_RxFlag = 1;//同时,代表一个数据包接收到了,可以置一个接收标志位。
//同时,接收到之后,我们还需要给我这个字符数组的最后,加一个字符串结束标志为 '\0',方便我们后续对字符串进行处理。
Serial_RxPacket[pRxPacket] = '\0';
}
}
}
}
接收到字符数组之后,我们还需要给我这个字符数组的最后,加一个字符串结束标志为 ‘\0’,方便我们后续对字符串进行处理。要不然你要 ShowString,它没有结束标志位,就不知道这个字符串到底有多长了。加结束标志位也很简单,写入字符数组的下一个位置数据给 ‘\0’,这样它才是一个完整的字符串。
- main.c 实现
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "Serial.h"
int main(void) {
OLED_Init();
Serial_Init();//初始化串口
OLED_ShowString(1, 1, "TxPacket");
OLED_ShowString(3, 1, "RxPacket");
while(1){
if (Serial_GetRxFlag() == 1) {//接收到数据包
OLED_ShowString(4, 1, " ");//显示之前,清除一下第 4 行,给 16 个空格,这就相当于擦除第 4 行
OLED_ShowString(4, 1, Serial_RxPacket);//OLED 显示接收到的数据包,这里接收到的是字符串
}
}
}
显示之前,清除一下第 4 行,给 16 个空格,这就相当于擦除第 4 行。因为这个字符串长度不确定,如果先显示一个长的,再显示一个短的,那长的那个字符串的屁股就会露出来
串口助手这里,发送和接收都选择文本模式,然后按照规定的格式发送数据包。比如 @ABC 换行,这个换行一定要打。发送,可以看到屏幕显示 ABC 这个字符串,可以看到是没问题的。字符串都有了,那之后的内容,就比较好办了。我们完善一下最终的程序。
Serial.h
#ifndef __SERIAL_H
#define __SERIAL_H
#include "stdio.h"
extern char Serial_RxPacket[];
void Serial_Init(void);
void Serial_SendByte(uint8_t Byte);
void Serial_SendArray(uint8_t* Array, uint16_t Length);
void Serial_SendString(char* str);
void Serial_SendNumber(uint32_t Number, uint8_t Length);
void Serial_Printf(char* format, ...);
void Serial_SendPacket(void);
uint8_t Serial_GetRxFlag(void);
#endif
Serial.c
#include "stm32f10x.h" // Device header
#include "stdio.h"
#include "stdarg.h"
char Serial_RxPacket[100];//数量给多点,防止溢出
uint8_t Serial_RxFlag;
void Serial_Init(void) {
//1.开启时钟,吧需要用的 USART 和 GPIO 的时钟打开
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);//USART1 是 APB2 的外设,其他的都是 APB1 的外设,注意一下
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
//2.GPIO 初始化,把 TX 配置成复用输出,RX 配置成输入
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;//目前需要数据发送,所以初始化 TX
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);//这样就是把 PA9 配置为复用推挽输出,供 USART1 的 TX 使用
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 配置好,供 USART1 的 RX 使用
//3.配置 USART,直接使用一个结构体,就可以把所有的参数都配置好了
USART_InitTypeDef USART_InitStructure;
USART_InitStructure.USART_BaudRate = 9600;//波特率,可以直接写一个波特率的数值
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;//硬件流控制,这个参数的取值可以是 None 不使用流控、只用 CTS、只用 RTS 或者 CTS 和 RTS 都使用。我们不使用流控,所以选择 None。
USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;//串口模式,可以选择 Tx 发送模式和 Rx 接收模式,那我们这个程序既需要发送又需要接收,那就用或符号把 Tx 和 Rx 或起来。就跟 GPIO_Pin_1 | GPIO_Pin_2 一样的用法。
USART_InitStructure.USART_Parity = USART_Parity_No;//校验位,可以选择 No无检验、Odd奇校验、Even偶校验,我们不需要校验,所以选择 No。
USART_InitStructure.USART_StopBits = USART_StopBits_1;//停止位,这个参数可以选择 0.5、1、1.5、2,我们选择 1 位停止位。
USART_InitStructure.USART_WordLength = USART_WordLength_8b;//字长,这个参数可以选择 8 位或 9 位,我们不需要校验,所以字长就选择 8 位。
USART_Init(USART1, &USART_InitStructure);
//4.如果你只需要发送的功能,就直接开启 USART,初始化就结束了,如果你需要接收的功能,可能还需要配置中断,那就在开启 USART 之前,再加上 ITConfig 和 NVIC 的代码就行了。
USART_Cmd(USART1, ENABLE);
//5. 配置中断
USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);//开启 RXNE 标志位到 NVIC 的输出
//6. 配置 NVIC
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);//先分组
NVIC_InitTypeDef NVIC_InitStructure;//再初始化 NVIC 的 USART1 通道
NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;//中断通道
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;//优先级
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&NVIC_InitStructure);//到这里,RXNE 标志位一旦置 1 了,就会向 NVIC 申请中断,之后我们可以在中断函数里接收数据,中断函数的名字,我们看一下启动文件 startup_stm32f10x_md.s,复制 USART1_IRQHandler
//整个初始化流程非常中规中矩,每个函数大家应该都已经很熟悉了,得益于库函数的封装,内部各种细节问题,就不需要我们再关心了。
}
//那初始化完成之后,如果要发送数据,调用一个发送函数就行了,如果要接收数据,就调用接收的函数。如果要获取发送和接收的状态,就调用获取标志位的函数。
//这就是 USART 外设的使用思路。
//写一个发送数据的函数,调用这个函数,就可以从 Tx 引脚发送一个字节数据
void Serial_SendByte(uint8_t Byte) {
//1.调用串口的 SendData 函数
USART_SendData(USART1, Byte);
//2.等待标志位
while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);//发送数据寄存器空标志位
//3.标志位会自动清零。
}
//发送数组
void Serial_SendArray(uint8_t* Array, uint16_t Length) {//指向待发送数组的首地址
uint16_t i;
for (i = 0; i < Length; i++) {
Serial_SendByte(Array[i]);
}
}
//发送字符串
void Serial_SendString(char* str) {//由于字符串自带一个结束标志位,所以就不需要再传递长度参数了
uint8_t i;
for (i = 0; str[i] != '\0'; i++ ) {//数据 0 对应空字符,是字符串结束标志位,如果不等于 0,就是还没结束,进入循环。如果等于 0,就是结束了,停止循环。
//这里 数据 0 也可以写成字符的形式,就是 '\0',这就是空字符的转义字符表示形式。和直接写 0,最终效果是一样的,这里就以转义字符的形式来写吧。
Serial_SendByte(str[i]);
}
}
//次方函数
uint32_t Serial_Pow(uint32_t X, uint32_t Y) {//返回值 = X ^ Y
uint32_t ret = 1;
while( Y-- ) {
ret *= X;
}
return ret;
}
//发送字符形式的数字,最终能在电脑上显示字符串形式的数字
void Serial_SendNumber(uint32_t Number, uint8_t Length) {
//把 Number 的个位、十位、百位等等,以十进制拆分开
//怎么以十进制拆分开:
//取某一位就是 数字 / 10 ^ x % 10。/ 10 ^ x 就是把这一位的右边去掉,%10 就是把左边去掉。这就是拆分数字的思路。
uint8_t i;
for (i = 0; i < Length; i++) {
//然后转换成字符数字对应的数据(以字符的形式显示,所以需要加一个偏移,可以看一下 ASCII 码表,字符 0 对应的数据是 0x30,所以这里还得加上一个 0x30,或者以字符的形式写,就是'0'),依次发送出去
Serial_SendByte(Number / Serial_Pow(10, Length - i - 1) % 10 + '0');//以十进制从高位到低位依次发送
}
//这就是发送数据的逻辑,其实这两个函数 SendString 和 SendNumber 和 OLED 的 ShowString 和 ShowNumber 是一样的逻辑,可以对照 OLED 代码看看,都是一样的
}
int fputc(int ch, FILE* f) {//这是 fputc 函数的原型,这些参数上面的,按照我这样写就行,不需要管那么多
//把 fputc 重定向到串口
Serial_SendByte(ch);
return ch;
}
//那重定向 fputc 和 printf 有什么关系呢?这是因为,这个 fputc 是 printf 函数的底层,printf 函数在打印的时候,就是不断调用 fputc 函数一个个打印的。
//我们把 fputc 函数重定向到了串口,那 printf 自然就输出到串口了。好,这样,printf 就移植好了
void Serial_Printf(char* format, ...) {
//首先定义输出的字符串
char str[100];
//注意:接下来的部分就比较难了
va_list arg;//定义一个参数列表变量。va_list 是一个类型名,arg 是一个变量名
va_start(arg, format);//从 format 位置开始接收参数表,放在 arg 里面。
vsprintf(str, format, arg);//之后 sprintf,打印位置是 str,格式化字符串是 format,参数表是 arg。在这里 sprintf 要改成 vsprintf。因为 sprintf 只能接收直接写的参数,对于这种封装格式,要用 vsprintf。
va_end(arg);//之后,释放参数表。
Serial_SendString(str);//最后,把 str 发送出去
}
uint8_t Serial_GetRxFlag(void) {
if (Serial_RxFlag == 1) {
Serial_RxFlag = 0;
return 1;
}
return 0;
}
void USART1_IRQHandler(void) {
static uint8_t RxState = 0;//状态变量 S,三个状态 S 分别为 0、1、2
static uint8_t pRxPacket = 0;//指示接收到哪一个了,最开始默认为 0
//先判断标志位
if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET) {//如果 RXNE 确实置 1 了,就进 if
uint8_t RxData = USART_ReceiveData(USART1);//首先获取一下 RxData
//根据 RxState 不同,我们需要进入不同的处理程序。
//以下就是状态选择的部分,然后就依次写每个状态执行的操作和状态转移条件就行了。
if (RxState == 0) {//进入等待包头程序
if (RxData == '@') {//就说明收到包头了
RxState = 1;//转移状态
pRxPacket = 0;//在状态 0 转移到状态 1 时,提前清一个 0
}
//如果 RxData != '@' 呢?就不转移状态。
}
else if (RxState == 1) {//进入接收数据程序
//因为载荷字符数量并不确定,所以每次接收之前,我们必须先判断是不是包尾
if (RxData == '\r') {//就说明收到包尾了
RxState = 2;//转移状态
}
else {//如果不是包尾的话,才需要接收数据
Serial_RxPacket[pRxPacket] = RxData;
pRxPacket ++;
}
}
else if (RxState == 2) {//进入等待第二个包尾程序,在这个状态我们需要检测 RxData 是不是 == '\n'
if (RxData == '\n') {
RxState = 0;//如果是的话,回到最初的状态,
Serial_RxFlag = 1;//同时,代表一个数据包接收到了,可以置一个接收标志位。
//同时,接收到之后,我们还需要给我这个字符数组的最后,加一个字符串结束标志为 '\0',方便我们后续对字符串进行处理。
Serial_RxPacket[pRxPacket] = '\0';
}
}
}
}
main.c
#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();
Serial_Init();//初始化串口
LED_Init();
OLED_ShowString(1, 1, "TxPacket");
OLED_ShowString(3, 1, "RxPacket");
while(1){
if (Serial_GetRxFlag() == 1) {//接收到数据包
OLED_ShowString(4, 1, " ");//显示之前,清除一下第 4 行,给 16 个空格,这就相当于擦除第 4 行
OLED_ShowString(4, 1, Serial_RxPacket);//OLED 显示接收到的数据包,这里接收到的是字符串
//判断字符串是不是等于我们规定的指令,再执行相应的操作
//1.判断字符串呢,我们可以调用 C 语言字符串的官方库,首先 #include <string.h>,这个库里有很多字符串的处理函数
//2.判断两个字符串是否相等,需要用到一个函数 strcmp,如果相等的话,函数返回 0。
if (strcmp(Serial_RxPacket, "LED_ON") == 0) {//相等,指令匹配
LED1_ON();//点亮 LED
//同时我们可以反馈一些信息,向串口助手回传一个字符串
Serial_SendString("LED_ON_OK\r\n");
//然后 OLED 也显示一下发送的数据
OLED_ShowString(2, 1, " ");
OLED_ShowString(2, 1, "LED_ON_OK");
//这样 LED_ON 指令的动作就完成了。
}
else if (strcmp(Serial_RxPacket, "LED_OFF") == 0) {
LED1_OFF();//熄灭 LED
Serial_SendString("LED_OFF_OK\r\n");
OLED_ShowString(2, 1, " ");
OLED_ShowString(2, 1, "LED_OFF_OK");
}
else {//上面的指令都不匹配,则不执行操作
Serial_SendString("ERROR_COMMAND\r\n");//回传 ERROR_COMMAND 显示 ERROR_COMMAND,错误指令
OLED_ShowString(2, 1, " ");
OLED_ShowString(2, 1, "LED_OFF_OK");
}
}
}
}
我们先发送一个 LED_ON,LED 点亮,回传 LED_ON_OK,再来个 LED_OFF,LED 熄灭,回传 LED_OFF_OK,再来个其他指令,LED 无操作,回传 ERROR_COMMAND,这就是第二个程序的现象。最后还有一个问题需要说明,同样还是之前的那个问题,如果连续发送数据包,程序处理不及时,可能导致数据包错位,在这里,文本数据包,每个数据包是独立的,不存在连续,这如果错位了,问题就比较大了。
所以在程序这里,我们可以修改一下。等每次处理完成之后,再开始接收下一个数据包,怎么改呢?我们可以利用这个 RxFlag,在这里就不使用读取 Flag 之后立刻清除的策略了,我们可以在中断函数里等待包头的时候,再加一个条件,if (RxData == '@' && Serial_RxFlag == 0)
,才执行接收,否则就是你发的太快了,我还没处理完呢,就跳过这个数据包。然后上面的读取标志位之后立刻清零的函数先删掉,我们可以把这个 RxFlag 也声明为外部可调用,暂时不封装了。然后主函数里 if (Serial_RxFlag == 1)
代表接收到数据包了,执行操作,等操作完成后,再 RxFlag = 0
,把 Flag 清 0,在中断这里,只有 Flag 为 0 了,才会继续接收下一个数据包。这样写数据和读数据就是严格分开的,不会同时进行,就可以避免数据包错位的现象了。最终代码如下:
Serial.h
#ifndef __SERIAL_H
#define __SERIAL_H
#include "stdio.h"
extern char Serial_RxPacket[];
extern uint8_t Serial_RxFlag;
void Serial_Init(void);
void Serial_SendByte(uint8_t Byte);
void Serial_SendArray(uint8_t* Array, uint16_t Length);
void Serial_SendString(char* str);
void Serial_SendNumber(uint32_t Number, uint8_t Length);
void Serial_Printf(char* format, ...);
void Serial_SendPacket(void);
#endif
Serial.c
#include "stm32f10x.h" // Device header
#include "stdio.h"
#include "stdarg.h"
char Serial_RxPacket[100];//数量给多点,防止溢出,这要求单条指令最长不能超过 100 个字符,那接收缓存区就定义好了
uint8_t Serial_RxFlag;
void Serial_Init(void) {
//1.开启时钟,吧需要用的 USART 和 GPIO 的时钟打开
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);//USART1 是 APB2 的外设,其他的都是 APB1 的外设,注意一下
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
//2.GPIO 初始化,把 TX 配置成复用输出,RX 配置成输入
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;//目前需要数据发送,所以初始化 TX
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);//这样就是把 PA9 配置为复用推挽输出,供 USART1 的 TX 使用
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 配置好,供 USART1 的 RX 使用
//3.配置 USART,直接使用一个结构体,就可以把所有的参数都配置好了
USART_InitTypeDef USART_InitStructure;
USART_InitStructure.USART_BaudRate = 9600;//波特率,可以直接写一个波特率的数值
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;//硬件流控制,这个参数的取值可以是 None 不使用流控、只用 CTS、只用 RTS 或者 CTS 和 RTS 都使用。我们不使用流控,所以选择 None。
USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;//串口模式,可以选择 Tx 发送模式和 Rx 接收模式,那我们这个程序既需要发送又需要接收,那就用或符号把 Tx 和 Rx 或起来。就跟 GPIO_Pin_1 | GPIO_Pin_2 一样的用法。
USART_InitStructure.USART_Parity = USART_Parity_No;//校验位,可以选择 No无检验、Odd奇校验、Even偶校验,我们不需要校验,所以选择 No。
USART_InitStructure.USART_StopBits = USART_StopBits_1;//停止位,这个参数可以选择 0.5、1、1.5、2,我们选择 1 位停止位。
USART_InitStructure.USART_WordLength = USART_WordLength_8b;//字长,这个参数可以选择 8 位或 9 位,我们不需要校验,所以字长就选择 8 位。
USART_Init(USART1, &USART_InitStructure);
//4.如果你只需要发送的功能,就直接开启 USART,初始化就结束了,如果你需要接收的功能,可能还需要配置中断,那就在开启 USART 之前,再加上 ITConfig 和 NVIC 的代码就行了。
USART_Cmd(USART1, ENABLE);
//5. 配置中断
USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);//开启 RXNE 标志位到 NVIC 的输出
//6. 配置 NVIC
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);//先分组
NVIC_InitTypeDef NVIC_InitStructure;//再初始化 NVIC 的 USART1 通道
NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;//中断通道
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;//优先级
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&NVIC_InitStructure);//到这里,RXNE 标志位一旦置 1 了,就会向 NVIC 申请中断,之后我们可以在中断函数里接收数据,中断函数的名字,我们看一下启动文件 startup_stm32f10x_md.s,复制 USART1_IRQHandler
//整个初始化流程非常中规中矩,每个函数大家应该都已经很熟悉了,得益于库函数的封装,内部各种细节问题,就不需要我们再关心了。
}
//那初始化完成之后,如果要发送数据,调用一个发送函数就行了,如果要接收数据,就调用接收的函数。如果要获取发送和接收的状态,就调用获取标志位的函数。
//这就是 USART 外设的使用思路。
//写一个发送数据的函数,调用这个函数,就可以从 Tx 引脚发送一个字节数据
void Serial_SendByte(uint8_t Byte) {
//1.调用串口的 SendData 函数
USART_SendData(USART1, Byte);
//2.等待标志位
while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);//发送数据寄存器空标志位
//3.标志位会自动清零。
}
//发送数组
void Serial_SendArray(uint8_t* Array, uint16_t Length) {//指向待发送数组的首地址
uint16_t i;
for (i = 0; i < Length; i++) {
Serial_SendByte(Array[i]);
}
}
//发送字符串
void Serial_SendString(char* str) {//由于字符串自带一个结束标志位,所以就不需要再传递长度参数了
uint8_t i;
for (i = 0; str[i] != '\0'; i++ ) {//数据 0 对应空字符,是字符串结束标志位,如果不等于 0,就是还没结束,进入循环。如果等于 0,就是结束了,停止循环。
//这里 数据 0 也可以写成字符的形式,就是 '\0',这就是空字符的转义字符表示形式。和直接写 0,最终效果是一样的,这里就以转义字符的形式来写吧。
Serial_SendByte(str[i]);
}
}
//次方函数
uint32_t Serial_Pow(uint32_t X, uint32_t Y) {//返回值 = X ^ Y
uint32_t ret = 1;
while( Y-- ) {
ret *= X;
}
return ret;
}
//发送字符形式的数字,最终能在电脑上显示字符串形式的数字
void Serial_SendNumber(uint32_t Number, uint8_t Length) {
//把 Number 的个位、十位、百位等等,以十进制拆分开
//怎么以十进制拆分开:
//取某一位就是 数字 / 10 ^ x % 10。/ 10 ^ x 就是把这一位的右边去掉,%10 就是把左边去掉。这就是拆分数字的思路。
uint8_t i;
for (i = 0; i < Length; i++) {
//然后转换成字符数字对应的数据(以字符的形式显示,所以需要加一个偏移,可以看一下 ASCII 码表,字符 0 对应的数据是 0x30,所以这里还得加上一个 0x30,或者以字符的形式写,就是'0'),依次发送出去
Serial_SendByte(Number / Serial_Pow(10, Length - i - 1) % 10 + '0');//以十进制从高位到低位依次发送
}
//这就是发送数据的逻辑,其实这两个函数 SendString 和 SendNumber 和 OLED 的 ShowString 和 ShowNumber 是一样的逻辑,可以对照 OLED 代码看看,都是一样的
}
int fputc(int ch, FILE* f) {//这是 fputc 函数的原型,这些参数上面的,按照我这样写就行,不需要管那么多
//把 fputc 重定向到串口
Serial_SendByte(ch);
return ch;
}
//那重定向 fputc 和 printf 有什么关系呢?这是因为,这个 fputc 是 printf 函数的底层,printf 函数在打印的时候,就是不断调用 fputc 函数一个个打印的。
//我们把 fputc 函数重定向到了串口,那 printf 自然就输出到串口了。好,这样,printf 就移植好了
void Serial_Printf(char* format, ...) {
//首先定义输出的字符串
char str[100];
//注意:接下来的部分就比较难了
va_list arg;//定义一个参数列表变量。va_list 是一个类型名,arg 是一个变量名
va_start(arg, format);//从 format 位置开始接收参数表,放在 arg 里面。
vsprintf(str, format, arg);//之后 sprintf,打印位置是 str,格式化字符串是 format,参数表是 arg。在这里 sprintf 要改成 vsprintf。因为 sprintf 只能接收直接写的参数,对于这种封装格式,要用 vsprintf。
va_end(arg);//之后,释放参数表。
Serial_SendString(str);//最后,把 str 发送出去
}
void USART1_IRQHandler(void) {
static uint8_t RxState = 0;//状态变量 S,三个状态 S 分别为 0、1、2
static uint8_t pRxPacket = 0;//指示接收到哪一个了,最开始默认为 0
//先判断标志位
if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET) {//如果 RXNE 确实置 1 了,就进 if
uint8_t RxData = USART_ReceiveData(USART1);//首先获取一下 RxData
//根据 RxState 不同,我们需要进入不同的处理程序。
//以下就是状态选择的部分,然后就依次写每个状态执行的操作和状态转移条件就行了。
if (RxState == 0) {//进入等待包头程序
//对于状态 0,我们的操作是
if (RxData == '@' && Serial_RxFlag == 0) {//就说明收到包头了
RxState = 1;//转移状态
pRxPacket = 0;//在状态 0 转移到状态 1 时,提前清一个 0
}
//如果 RxData != '@' 呢?就不转移状态。
}
else if (RxState == 1) {//进入接收数据程序
//因为载荷字符数量并不确定,所以每次接收之前,我们必须先判断是不是包尾
if (RxData == '\r') {//就说明收到包尾了
RxState = 2;//转移状态
}
else {//如果不是包尾的话,才需要接收数据
Serial_RxPacket[pRxPacket] = RxData;
pRxPacket ++;
}
}
else if (RxState == 2) {//进入等待第二个包尾程序,在这个状态我们需要检测 RxData 是不是 == '\n'
if (RxData == '\n') {
RxState = 0;//如果是的话,回到最初的状态,
Serial_RxFlag = 1;//同时,代表一个数据包接收到了,可以置一个接收标志位。
//同时,接收到之后,我们还需要给我这个字符数组的最后,加一个字符串结束标志为 '\0',方便我们后续对字符串进行处理。
//要不然你要 ShowString,它没有结束标志位,就不知道这个字符串到底有多长了。
//加结束标志位也很简单,写入字符数组的下一个位置数据给 '\0',这样它才是一个完整的字符串。
Serial_RxPacket[pRxPacket] = '\0';
}
}
}
}
main.c
#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();
Serial_Init();//初始化串口
LED_Init();
OLED_ShowString(1, 1, "TxPacket");
OLED_ShowString(3, 1, "RxPacket");
while(1){
if (Serial_RxFlag == 1) {//接收到数据包
OLED_ShowString(4, 1, " ");//显示之前,清除一下第 4 行,给 16 个空格,这就相当于擦除第 4 行
//因为这个字符串长度不确定,如果先显示一个长的,再显示一个短的,那长的那个字符串的屁股就会露出来
OLED_ShowString(4, 1, Serial_RxPacket);//OLED 显示接收到的数据包,这里接收到的是字符串
//判断字符串是不是等于我们规定的指令,再执行相应的操作
//1.判断字符串呢,我们可以调用 C 语言字符串的官方库,首先 #include <string.h>,这个库里有很多字符串的处理函数
//2.判断两个字符串是否相等,需要用到一个函数 strcmp,如果相等的话,函数返回 0。
if (strcmp(Serial_RxPacket, "LED_ON") == 0) {//相等,指令匹配
LED1_ON();//点亮 LED
//同时我们可以反馈一些信息,向串口助手回传一个字符串
Serial_SendString("LED_ON_OK\r\n");
//然后 OLED 也显示一下发送的数据
OLED_ShowString(2, 1, " ");
OLED_ShowString(2, 1, "LED_ON_OK");
//这样 LED_ON 指令的动作就完成了。
}
else if (strcmp(Serial_RxPacket, "LED_OFF") == 0) {
LED1_OFF();//熄灭 LED
Serial_SendString("LED_OFF_OK\r\n");
OLED_ShowString(2, 1, " ");
OLED_ShowString(2, 1, "LED_OFF_OK");
}
else {//上面的指令都不匹配,则不执行操作
Serial_SendString("ERROR_COMMAND\r\n");//回传 ERROR_COMMAND 显示 ERROR_COMMAND,错误指令
OLED_ShowString(2, 1, " ");
OLED_ShowString(2, 1, "LED_OFF_OK");
}
Serial_RxFlag = 0;
}
}
}
不过这样的话,你发送数据包的频率不能太快了,否则会丢弃部分包。如果你数据包发送的频率比较低,有足够长的时间处理,那其实都没问题。就是发太快的时候,可能会有些问题。这个注意一下。
或者你还可以再定义一个指令缓存区,把接收好的字符串放在这个指令缓存区进行排队,这样处理起来会更加有条理,有关这个数据包的收发,其实还是有非常多的细节问题需要考虑的。实际应用的话,还是得多想想。