引言
学习完STM32的串口通讯相关理论知识,接下来,我们就一起来亲自实践一下STM32中串口通讯的实现方式,以便更加深入理解32中的串口通讯。下面我们就来逐步实现一个简单的STM32与计算机进行串口通讯的收发案例吧。
一、案例需求描述
电脑通过串口向STM32发送数据,STM32原封不动的再发送过来。电脑可以借助串口助手来发送或接受数据。
二、硬件电路设计与分析
如今大多数电脑上已经没有自带串口了,所以我们要实现与单片机串口通讯通常是借助下载器内置的USB转串口功能实现的。当下载器连接电脑后,会在电脑上产生一个虚拟串口,进而实现与单片机的串口通讯。
本次我们使用的下载器是STMLink2.1,其电路设计也满足的上述要求:
在我们下载器上,引出了两根线,分别连接了STM32上的USART1的收发端口,由于此时我们只是进行数据收发,因此并未涉及到串口同步功能,所以这里的网络名便直接以UART命名了。
显然,我们应该再去看看STM32上复用USART1收发的引脚,打开数据手册,直接搜索【USART1_TX】即可快速索引到对应的端口,如下图
显然,PA9和PA10是STM32上的GPIOA部分的两个GPIO端口,这里是复用了串口通讯功能。我们简单思考,他们可能是处于什么样的工作模式下呢?
首先,PA9 -> 复用串口发送 ->发送意味着内往外发 -> 相当于我们的输出模式,同时发送数据功能在接线上都是点对点连接,不会直接同时连在一根总线上或者产生“线与”功能,因此我们不用考虑开漏,直接使用推挽输出即可。因此,对于PA9端口我们后面可能会置于【最高速度的复用功能的推挽输出模式】就行了;
其次,PA10 -> 进行串口接收 -> 接收意味着外往内发 -> 相当于我们的输入模式,同时由于接收时是被动的,只需要等待别人发送的数据就行。所以不需要像前面中断一样,利用上下拉进实现电平转换或者给默认电平,因此这里给一个【浮空输入】即可。
三、软件设计
分析硬件电路之后,咱就可以开始进行软件上的实现了。首先串口通讯又会涉及到一些寄存器,所以这里我们先简单介绍一些相关的寄存器的用法。
3.1 相关寄存器介绍
由于关于GPIO相关寄存器我们使用比较多,因此这里我们不再介绍GPIO相关寄存器。我们直接来介绍实现串口通讯时的所需要配置的寄存器。
3.1.1 波特率寄存器
当前我们的串口通讯不使用同步功能,所以需要我们使用波特率来控制收发的频率。我们首先会涉及到波特率配置,使用波特率寄存器【USART_BRR】
上图即为波特率寄存器,其中配置的值显然就是我们前面介绍USART框图时涉及到波特率计算时给的值,所以这里的配置我们直接按照前面讲的公式以及手册给的常见波特率对应配置值即可
参考示例代码如下
USART1->BRR = 0x271; // 波特率为115200 115.2Kpbs
3.1.2 控制寄存器
要使用串口通讯模块,会需要开启串口模块使能;同时要进行串口收发,还要开启发送和接收使能,这样才能开始串口通讯收发数据。
根据前面介绍USART框图可知,这三个使能都由控制寄存器配置:
由手册中的寄存器描述就很容易知道了,如上图我们使用【USART_CR1】即可。开启模块使能、发送使能、接收使能只需要全部置为1即可。
参考示例代码:
// 使能usart1的发送和接收
USART1->CR1 |= (USART_CR1_TE | USART_CR1_RE);
// 使能usart1
USART1->CR1 |= USART_CR1_UE;
3.1.3 状态寄存器
开启串口相关使能后,理应想到的就是要发送或者接收数据。当然在这之前为了确定是否能发送或者接收数据,我们还要进行一个发送数据或接收数据状态的判断,这时候就涉及到状态寄存器了。
如上图,我们主要可以借助状态寄存器USART_SR中的三个位来进行发送接收的检测。
1、【TXE】意味着发送的数据为空,它是由硬件控制,用于监测发送的数据状态。发送的数据为空就会置1,意味着可以把数据放进来了;当写入到数据就会被自动清零,即发送的数据非空;
2、【RXNE】意味着接收的数据非空,他也是硬件控制,用于检测接收的数据状态。接收的数据非空就会置1,非空意味着可以接收数据了,当读取了数据以后就会自动清零,即接收的数据空了,不必再接收数据了。
3、【IDLE】意味着总线空闲,由硬件控制,用于监测发送数据结束后产生的一段连续高电平或者说形成的空闲帧,通常用于判断不定长数据是否可以停止接收了。出现空闲帧就会置1,但其与前两个不同的是,他不会被硬件自动清零,需要软件操作才能清零,即先读状态寄存器、在读数据寄存器就行,此时意味着检测过一次了,可以等待下一次产生的空闲帧了。
参考示例代码如下
// 接收一个字符
uint8_t receive_char(void)
{
/* 等待接收缓冲区 非空 */
while ((USART1->SR & USART_SR_RXNE) == 0)
{
}
return USART1->DR;
}
// 向串口1发送一个字符
void send_char(uint8_t ch)
{
/* 等待发送缓冲区为空。SR_TXE为1表示已经移到移位寄存器, 0表示还没有 */
while ((USART1->SR & USART_SR_TXE) == 0)
{
}
/* 把要发送的数据写入到数据寄存器 */
USART1->DR = ch;
}
3.1.4 数据寄存器
知道什么时候能发送或接收数据了以后,咱自然能够想到是时候开始发数据了。这时候就涉及到数据寄存器,当然这个就很简单了。
如下图数据寄存器USART_DR的描述:
这里值得注意的是,前面介绍USART框图时特意提醒过,我们使用寄存器编程时发送数据和接收数据寄存器是合二为一的,因为这两类寄存器都只涉及到对应的发或者收,所以我们直接用一个寄存器存数据即可,也就是这里的数据寄存器;
我们在前面介绍串口通讯协议的时候知道:我们能够发送的有效数据位一般是一个字符长度即8位,当然我们K如果使用奇偶校验位的话还可以有9位,因此这里数据寄存器可以存最多9位数据。
参考示例代码如下:
USART1->DR = ch; // 写入数据
return USART1->DR; // 读取数据
3.2 工程创建
根据惯例,我这里仍然直接复制前面的keil工程,然后删除不必要文件,修改工程名称就可以了。
然后再在Hardware中新增USART目录,并创建一对串口模块的头文件以及相应源文件即可
效果如上
3.3 工程配置
接着,我们进入keil工程,进行相关配置。
3.3.1 keil中添加文件
我们在本地创建好了相关文件以后,还需要在keil中添加上。所谓一个物理上的目录,一个是逻辑上的目录。keil中的具体操作方式这里不再赘述,主要就是在keil中的品字中添加以及魔法棒中C/C++内包含路径即可,这里直接展示最终效果:
3.3.2 keil中的其他配置
除此之外,还有三个常见配置:调试器的选择、上电运行与否、是否打印日志。前面咱也操作过很多遍了,这里就直接展示最终效果吧
3.4 程序实现
上述配置完成后,我们就可以开始编写代码了。直接进入VSCode,然后导入keil工程,就如图所示样子
然后,我们先来完善usart.h的内容。
3.4.1 usart.h的编写
首先一个不变的结构:防止头文件重复编译,然后再引入stm32那个头文件
接着,我们思考:实现本次案例我们可能需要哪些函数?
1、首先当然肯定要初始化,因为有那么多寄存器需要配置;
2、其次我们要与计算机进行通讯,收发字符,因此还需要发送字符和接收字符的函数声明,要发送,那么这个函数显然会带参数;要接收,显然可能会有返回值;
3、甚至后面我们还可能发送或者接收字符串,所以还会加上发送和接收字符串的函数声明,当然这里我们先不加,一步一步来。
所以,我们先会在里面编写串口初始化函数声明、发送一个字符函数声明、接收一个字符函数声明:
OK,这里我们usart.h基本上就写好了,接下来就是在usart.c中逐一实现这些函数。
3.4.2 usart.c的编写
我们把这三个函数声明复制到usart.c中,然后逐个打开实现:
首先是串口初始化函数USART_Init()。这里我们就是把使用串口要做的一些配置在这里实现,我们一步一步捋下来:首先是串口收发使用的GPIO引脚的配置、其次是串口波特率的配置、接着是串口模块以及收发使能的开启、最后我们还可以做一些其他配置(如字长(数据长度)设置、校验位的使用与否、停止位的设置,当然我们目前直接默认即可)
1、GPIO引脚的配置
对于GPIO,首先别忘了开启时钟,我们查看手册或者根据经验发现,GPIOA以及USART1均由APB2外设总线控制,而时钟是由RCC控制,所以我们只需要找到RCC寄存器中关于PB2的时钟使能寄存器即可
然后根据前面对硬件电路的分析,我们易知PA9对应发送端、PA10对应接收端。
当时分析到我们给PA9应该配置为【最高速度的复用功能的推挽输出模式】,所以根据手册中对GPIO的配置寄存器描述可知,我们给MODE9 -> 11,CNF9 -> 10即可
而PA10对应接收端,应该是配置为【浮空输入模式】,根据手册中寄存器描述可知,我们配置MODE10 -> 00,CNF -> 01即可
OK,根据以上分析,我们就可以开始编写对GPIO进行配置的代码了,参考代码如下
2、串口的配置
对于串口配置,我们首先来配置波特率,也就是要使用波特率寄存器,因为我们一般使用的波特率就是115200,因此关于其配置比较方便,即通过计算或者直接对应手册就能知道115200对应寄存器中的配置值为0x271,因此代码就是一行
紧接着,开启相关使能,在前面介绍控制寄存器时其实已经说到了,我们将UE位、TE位以及RE位置1就是开启使能
然后还有其他的配置,比如设置字长、设置校验位以及停止位的设置。因为前面我们介绍串口通讯协议的时候提到,有效数据位、校验位、停止位都能手动进行配置,如字长可以7位、8位、9位都行、校验位可以选择开或者不开、停止位可以选择1个电平、2个电平或者1.5、0.5个电平。当然了,我们这里直接默认的8位数据、不开校验位、停止位用一个电平即可。所以我们根据寄存器描述其实可以知道就是全部置0就行。
字长在寄存器中名称是M位,校验位设置需要使能(PCE)再设置开与不开(PS),他们都在控制寄存器CR1中就可以配置,然后停止位(STOP)需要再CR2中配置。
一般来说,我们校验位和字长设置是同时设置的,比如开启了校验位即PCE置1、PS置1,意味着我们数据位可以有9位,这时候一般我们就会把M位也置1;相反,不开启校验位即PCE置0,意味着数据位最多8位,此时我们M位也会置0。
参考代码如下
这样,串口的配置也编写完毕。总的串口初始化函数如下:
// 初始化
void USART_Init(void)
{
// 1. 开启GPIO时钟 PA9 PA10
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;
RCC->APB2ENR |= RCC_APB2ENR_USART1EN;
// 2. 设置GPIO工作模式
// PA9 TX 输出,复用推挽输出 MODE-11 CNF-10
// PA10 RX 输入,浮空输入 MODE-00 CNF-01
GPIOA->CRH |= GPIO_CRH_MODE9;
GPIOA->CRH |= GPIO_CRH_CNF9_1;
GPIOA->CRH &= ~GPIO_CRH_CNF9_0;
GPIOA->CRH &= ~GPIO_CRH_MODE10;
GPIOA->CRH &= ~GPIO_CRH_CNF10_1;
GPIOA->CRH |= GPIO_CRH_CNF10_0;
// 3. 串口配置
// 3.1 设置波特率
USART1->BRR = 0x271; // 115.2 Kpbs
// 3.2 开启模块及收发使能
USART1->CR1 |= USART_CR1_UE;
USART1->CR1 |= USART_CR1_TE;
USART1->CR1 |= USART_CR1_RE;
// 3.3 其他配置(字长、奇偶校验、停止位)
USART1->CR1 &= ~USART_CR1_M;
USART1->CR1 &= ~USART_CR1_PCE;
USART1->CR2 &= ~USART_CR2_STOP;
}
其次是发送一个字符函数USART_SendChar(uint8_t ch);发送字符要做些什么呢?首先是看能不能发送,其次是开始发送数据。在STM32中直接借助状态寄存器中提供的TXE位就可以判断要不要发数据,按照前面对状态寄存器介绍可以知道:我们能发数据时,相当于上一次发送的数据已经空了,可以开始发,即TXE为1。
所以这里我们可以借助循环,在TXE没有置1时就一直循环等待,置1了说明数据已经给到移位寄存器了,当前可以继续向数据寄存器写数据了,接着我们就直接把字符给数据寄存器即可。
参考代码如下
// 发送一个字符
void USART_SendChar(uint8_t ch)
{
// 当发送的数据不为空时等待,TXE为1则可以继续写入数据
while ((USART1->SR & USART_SR_TXE) == 0)
{}
// 发送一个字符
USART1->DR = ch;
}
最后是接收一个字符uint8_t USART_ReceiveChar(void);这个要怎么实现呢?首先我们肯定知道接收的话肯定会有返回值,所以这个函数是有返回值的无参函数;其次呢,同样的道理,我们可以借助状态寄存器中的RXNE位来判断是否要开始接收数据即读取数据,RXNE为1就意味着有数据接收了,我们可以开始读取数据寄存器中的数据了。
所以这里我们仍然可以借助循环,在RXNE没有置1时一直循环等待,置1了说明数据已经全部在数据寄存器中等着了,这时我们就可以开始读取数据了,也就是直接返回此时数据寄存器中的数据即可。
参考代码如下
// 接收一个字符
uint8_t USART_ReceiveChar(void)
{
// 当接收端为空时等待
while ((USART1->SR & USART_SR_RXNE) == 0)
{}
// 接收一个字符
return USART1->DR;
}
好了,到目前为止,我们三个函数就实现完成了。
3.4.3 功能测试
功能实现了以后,我们就来测试一下,在主函数中调用这些函数试试能不能收发字符。首先要引入usart.h头文件,然后进行串口初始化,接着我们循环每秒发送字符a。
main.c 参考代码如下
#include "usart.h"
#include "Delay.h"
int main(void)
{
// 初始化
USART_Init();
uint8_t ch = 'a';
// 4. 死循环保持状态
while(1)
{
USART_SendChar(ch);
Delay_ms(1000);
}
}
我们编译试试,
编译成功,没有错误,然后我们烧录了,在串口助手中看看情况
我们看他自动发送的几组数据,前面的时间戳显示我们确实每秒发送了一个a,说明本次编写的发送函数没有问题。
然后我们再测试一下接收函数。值得注意的是,我们现在作为的角色时STM32芯片,芯片发送数据给电脑,然后接收是电脑发送数据给芯片,只不过我们使用串口助手做了这样一个回显的效果。
现在,在main.c中编写代码,实现我们电脑发一个字符,32芯片接收一个字符的效果,也就是咱再while(1)中先来个接收,然后在发送到电脑上就行
#include "usart.h"
int main(void)
{
// 初始化
USART_Init();
// 4. 死循环保持状态
while(1)
{
uint8_t ch1 = USART_ReceiveChar();
USART_SendChar(ch1);
}
}
编译一下看看
没有问题,我们直接烧录然后在串口助手看看效果
显然,烧录测试成功了,说明我们接受函数也没有问题。
接下来呢,我们继续扩展,前面写了发送或接收一个字符数据,现在我们试试编写发送或者接收一个字符串的函数。
3.4.4 功能扩展与测试
首先,我们来看看发送一个字符串数据的函数怎么写,咱思考一番,最简单的想法就是:字符串就相当于是多个单字符嘛,那我直接循环发送字符串中的每一个字符不就OK了。确实没啥问题,既然循环发多个字符,那就在传入字符串内容的同时还有知道字符串长度,因此发送字符串函数会有两个形参。
参考代码如下
// 发送一个字符串
void USART_SendString(uint8_t *str, uint8_t size)
{
// 循环发送字符串中的每一个字符
for (uint8_t i = 0; i < size; i++)
{
USART_SendChar(str[i]);
}
}
这代码怎么简单,自己都有点难以置信哈。然后我们这里别忘了在usart.h中编写函数声明。我这里就不展示了
那么我们继续在main.c中测试一下看看,就直接循环每秒发送一个“hello, world!\n”吧,代码参考如下(借助字符串函数获取了字符串长度)
#include "usart.h"
#include "Delay.h"
#include <string.h>
int main(void)
{
// 初始化
USART_Init();
uint8_t *str = "hello, world!\n";
uint8_t len = strlen((char *)str);
// 4. 死循环保持状态
while(1)
{
USART_SendString(str, len);
Delay_ms(1000);
}
}
我们编译一下
编译成功,直接烧录,然后在串口助手看看效果
显然这个发送字符串函数没有问题。
接下来,继续实现这个字符串接收函数。对于这个函数的话可能就没那么容易了,我们可能直接想到的思路也和上面发送一样,不过字符串接收的话是通过检测空闲帧(IDLE)来判断,前面寄存器介绍也说过大家应该也知道。如果安装上面发送字符串的思路的话能不能实现呢?其实我们可以试试,多试才知道代码能不能行。
好,依照前面的思路,即循环接收每一个字符,然后出现空闲帧就不再接收。我们可以写出如下代码
// 接收一个字符串
void USART_ReceiveString(uint8_t buffer[], uint8_t *size)
{
uint8_t i = 0;
// 没有检测到空闲帧时记录字符,检测到就结束
while ((USART1->SR & USART_SR_IDLE) == 0)
{
buffer[i] = USART_ReceiveChar();
i++;
}
}
如果上述思路,那就是上述代码,然后我们测试一下,就我发一个然后接收到了再发到电脑上的逻辑测试吧,main.c中这样写
#include "usart.h"
// 全局变量 存放接收到的字符串
uint8_t buffer[100] = {0};
uint8_t size = 0;
int main(void)
{
// 初始化
USART_Init();
// 4. 死循环保持状态
while(1)
{
USART_ReceiveString(buffer, &size);
USART_SendString(buffer, size);
}
}
我们编译一下看看
编译成功,我们直接烧录在串口助手看效果
很明显发现,我发了几次,他并没有显示收到了然后再发过来的情况,这意味着我们这样编写接收字符串函数可能确实不太对劲哈。
所以我们现在来分析一下到底是哪出现了问题:我们一步一步来分析这个接受字符串函数的执行过程,发现在前几次都没有检测到空闲帧时。他正一个一个字符地存着,然后当字符全部存完了,这个时候我们接受一个字符的函数已经接收不到字符了,而这个时候因为只是刚开始收不到字符,所以并未出现一段空闲帧,也就是说此时我们还在循环调用接收单字符函数,但是我们已经收不到单字符了呀,这就意味着我们再次执行接收单字符函数的时候会因为可接受的字符数据为空而开始循环等待,然而不巧的是我会一直收不到字符,意味着我卡死在接受一个字符函数内部的循环中了。这样的话我自然没办法结束执行接收字符换函数了,这也是我们为什么最后在串口助手中看不见字符发过来的效果。(这段解释大家对照代码可能更容易理解)
所以,怎么样解决呢?我们思考一下会发现,这次的bug产生的主要原因就是没有可接收的单个字符时的循环等待卡死了程序。
那么我们想:能不能在里面多加个条件让他能够自动退出循环呢?显然是可以实现的哈,我们之所以会进入循环,上是因为我们没有可接收的字符了,也就是总线开始空闲了,这意味着什么一直收不到就会一直空闲,那么一直空闲意味着我们是不是就会产生一段空闲帧呢?如果能够产生空闲帧,那么我们是不是就能够借助监测空闲帧的位IDLE来结束这个循环等待了?因此我们现在就会想到干脆直接在接收字符串函数中重新一个个接收字符,然后那个判断接收字符的循环中我加一个监测空闲帧就break的判断语句。这样咱就能合理地实现这个接受字符串函数啦!
整理一下具体思路:
用外侧循环进行每一个单字符的接收,内层循环就是等待出现可接收的数据然后我们读取可接受的数据,这时我们在内层循环中加判断空闲帧结束的语句,即可在接受完所有字符后结束单字符接收同时也意味着字符串接收完毕了。
参考代码如下
// 接收一个字符串
void USART_ReceiveString(uint8_t buffer[], uint8_t *size)
{
uint8_t i = 0;
// 外层循环接收每一个字符
while (1)
{
// 内层循环,可接收数据为空时判断是否出现空闲帧
while ((USART1->SR & USART_SR_RXNE) == 0)
{
// 出现空闲帧则说明接收完毕所有字符,记录字符串长度,然后接结束执行
if (USART1->SR & USART_SR_IDLE)
{
*size = i;
return;
}
}
// 存放每一个字符并计数
buffer[i] = USART1->DR;
i++;
}
}
现在,我们继续在main.c中用上一次的代码测试本次更改过的接收函数,我们先编译一下
显然编译成功,我们现在直接烧录,在串口助手看效果
显然!我们修改后的接收函数是正确的!
所以到目前为止呢,我们发送和接收一个字符串的功能也实现了。
到这里,本次案例的需求就基本实现了。
四、总结
经过本次案例,我们利用轮询的方式基本实现了STM32芯片与计算机的串口通讯,实现了两者之间单字符或者不定长字符串的发送与接收;其中我们在实现字符串接收时遇到了循环卡死问题,并且经过对代码的分析顺利解决了问题。当然对于接收字符串函数还可以继续优化,我们下次再来进行讲述。
总而言之,经过本次串口通讯的案例实现,我们又深入理解了单片机上串口通讯的原理和使用,也希望我们继续加油,坚持学习!
以上便是本次文章的所有内容,欢迎各位朋友在评论区讨论,本人也是一名初学小白,愿大家共同努力,一起进步吧!
鉴于笔者能力有限,难免出现一些纰漏和不足,望大家在评论区批评指正,谢谢!