1. 通信接口
- 通信的目的:将一个设备的数据传送到另一个设备,扩展硬件系统
- 通信协议:制定通信的规则,通信双方按照协议规则进行数据收发
- 全双工:指通信双方能够同时进行双向通信。发送线路和接收线路互不影响,一般有两根数据传输线
- 半双工:一般只有一根数据传输线
- 单工:指数据只能从一个设备到另一个设备,不能反过来
2. 串口通信
- 串口是一种应用十分广泛的通讯接口,串口成本低、容易使用、通信线路简单,可实现两个设备的互相通信
- 单片机的串口可以使单片机与单片机、单片机与电脑、单片机与各式各样的模块互相通信,极大地扩展了单片机的应用范围,增强了单片机系统的硬件实力
2.1 硬件电路
- 简单双向串口通信有两根通信线(发送端TX和接收端RX)
- TX和RX是单端信号,它们的高低电平都是相对于GND的
- TX与RX要交叉连接
- 当只需单向的数据传输时,可以只接一根通信线
- 当电平标准不一致时,需要加电平转换芯片
2.2 电平标准
电平标准是数据1和数据0的表达方式,是传输线缆中人为规定的电压与数据的对应关系,串口常用的电平标准有如下三种:
- TTL电平:+3.3V或+5V表示1,0V表示0
- RS232电平:-3~-15V表示1,+3~+15V表示0
- RS485电平:两线压差+2~+6V表示1,-2~-6V表示0(差分信号)
如果需要其他的电平,加电平转换芯片即可,在软件层面都属于串口,不影响程序。
2.3 串口参数及时序
- 波特率:串口通信的速率,决定了每隔多久发送一位数据
- 起始位:标志一个数据帧的开始,固定为低电平。串口的空闲状态是高电平,需要传输时,必须先发送一个低电平的起始位。
- 数据位:数据帧的有效载荷,1为高电平,0为低电平,低位先行。串口中,每个字节都装载在一个数据帧内,每个数据帧都由起始位、数据位和停止位组成。数据位有8位,在数据位的最后还可以加一个奇偶校验位,这样数据位就是9位。
- 校验位:用于数据验证,根据数据位计算得来,计算1的个数。校验可以选择3种方式,无校验、奇校验和偶校验。如果使用奇校验,那么包括校验位在内的9位数据会出现奇数个1,比如传输0000 1111,那么校验位为1,如果数据是0000 1110,那么校验位为0。
- 停止位:用于数据帧间隔,固定为高电平。可以选择1位、1.5位、2位等
3. USART外设
- USART(Universal Synchronous/Asynchronous Receiver/Transmitter)通用同步/异步收发器
- USART是STM32内部集成的硬件外设,可根据数据寄存器的一个字节数据自动生成数据帧时序,从TX引脚发送出去,也可自动接收RX引脚的数据帧时序,拼接为一个字节数据,存放在数据寄存器里
- 自带波特率发生器,最高达4.5Mbits/s。波特率常用9600或115200
- 可配置数据位长度(8/9)、停止位长度(0.5/1/1.5/2)
- 可选校验位(无校验/奇校验/偶校验)
- 支持同步模式、硬件流控制、DMA、智能卡、IrDA、LIN
- STM32F103C8T6 USART资源: USART1(APB2总线,f_PCLK2=72MHz)、 USART2(APB1总线,f_PCLK1=36MHz)、 USART3(APB1总线)
3.1 数据帧
当输入电路侦测到一个数据帧的起始位后,就会以波特率的频率连续采样一帧数据。同时,从起始位开始,采样位置要对齐到位的正中间。首先以波特率的16位频率进行采样,也就是在一位的时间里可以进行16次采样。最开始,空闲状态为高电平,采样值为1,某个位置采到0,开始在起始位进行16次采样,如果没有噪声,这16次采样均为0。由于实际电路存在噪声,接收电路会在下降沿后的第3、5、7次进行一批采样,在第8、9、10次再进行一批采样,且这两批采样均要求每3位至少有两个0,如果3位里面有两个0一个1,也算检测到了起始位,但会在状态寄存器内置噪声标志位NE(Noise Error)为1。如果3位里面只有1个0,就不算检测到起始位,重新检测。
如果通过了起始位侦测,那么接收状态就由空闲变为接收起始位。同时,第8、9、10次采样的位置正好是起始位的正中间。之后接收数据位时,就均在第8、9、10次进行采样,这样就可以保证采样位置在位的正中间了。
从1到16是一个数据位的时间长度,在一个数据位有16个采样时钟,由于起始位侦测已经对齐了采样时钟,所以直接在第8、9、10次采样数据位。没有噪声的理想情况下,三次采样全为0或全为1。如果有噪声,2次为1就认为收到了1,2次为0就认为收到了0,并且噪声标志位NE置1。
3.2 波特率发生器
- 发送器和接收器的波特率由波特率寄存器BRR里的DIV确定
- 计算公式:波特率 = f_PCLK2/1 / (16 * DIV)
- 比如要配置USART1为9600的波特率,9600 = 72M / (16 * DIV),解得DIV=468.75
4. 数据模式
- HEX模式/十六进制模式/二进制模式:以原始数据的形式显示
- 文本模式/字符模式:以原始数据编码后的形式显示
5. 串口发送
5.1 接线图
根据引脚定义表,USART1_TX是PA9,USART1_RX是PA10。PA9是STM32的TX发送,所以接到串口模块的RXD接收。PA10是STM32的RX接收,所以接到串口模块的TXD发送。两个设备之间要把负极接在一起,进行共地。最后,串口模块和STLINK都要插在电脑上,使两者均有独立供电。
5.2 代码
USART初始化流程:
- 开启时钟,USART(APB2)和GPIO
- 配置GPIO,TX配置为复用输出,RX配置为输入
- 配置USART
- 开启USART
配置参数:9600波特率、8位字长、无校验、1位停止位、无流控、只有发送模式
Serial.c
#include "stm32f10x.h" // Device header
void Serial_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
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);
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;// 停止位
USART_InitStructure.USART_WordLength = USART_WordLength_8b;// 字长。不需要校验,所以选8位即可
USART_Init(USART1, &USART_InitStructure);
USART_Cmd(USART1, ENABLE);
}
// 发送字节
void Serial_SendByte(uint8_t Byte)
{
USART_SendData(USART1, Byte);// 调用此库函数,Byte变量就写入TDR了,写完后需要等待TDR数据转移至移位寄存器。如果数据在TDR内再写入数据,会产生数据覆盖
while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);// 发送数据寄存器(TDR)空标志位
// 这里标志位置1后不需要手动清零,当下一次再SendData时,此标志位会自动清零
}
// 发送数组
void Serial_SendArray(uint8_t *Array, uint16_t Length)// 指向待发送数组的首地址。由于数组无法判断是否结束,所以需要传递一个Length进来
{
uint16_t i;
for(i = 0; i < Length; i++)
{
Serial_SendByte(Array[i]);
}
}
// 发送字符串
void Serial_SendString(char *String)// 由于字符串自带一个结束标志位,所以不需要传递长度参数
{
uint8_t i;
for(i = 0; String[i] != 0; i++)// 循环条件用结束标志位来判断。这里数字0对应空字符,是字符串的结束标志位。如果不等于0,就是还没结束,进行循环
// 这里数据0也可以写成字符形式,就是'\0',这就是空字符的转义字符表示形式for(i = 0; String[i] != '\0'; i++),和直接写0最终效果是一样的
{
Serial_SendByte(String[i]);
}
}
// 计算次方函数
uint32_t Serial_Pow(uint32_t X, uint32_t Y)
{
uint32_t Result = 1;
while(Y--)
{
Result *= X;
}
return Result;
}
// 发送数字
void Serial_SendNumber(uint32_t Number, uint8_t Length)
{
// 需要把Number的个位、十位、百位等以十进制拆分开,然后转换成字符数字对应的数据,依次发送
uint8_t i;
for(i = 0; i < Length; i++)
{
Serial_SendByte(Number / Serial_Pow(10, Length - i - 1) % 10 + '0');// 最终要以字符的形式发送,所以最后要加上字符的偏移,根据ASCII码表,字符0对应的数据是0x30,也可以以字符的形式写'0'
}
}
Serial.h
#ifndef __SERIAL_H
#define __SERIAL_H
void Serial_Init(void);
void Serial_SendByte(uint8_t Byte);
void Serial_SendArray(uint8_t *Array, uint16_t Length);
void Serial_SendString(char *String);
uint32_t Serial_Pow(uint32_t X, uint32_t Y);
void Serial_SendNumber(uint32_t Number, uint8_t Length);
#endif
main.c
#include "stm32f10x.h" // Device
#include "Delay.h"
#include "OLED.h"
#include "Serial.h"
int main(void)
{
OLED_Init();
Serial_Init();
Serial_SendByte(0x41);// 调用此函数后,TX引脚就会产生一个0x41对应的波形,此波形可以发送给其他支持串口的模块,也可以通过USB转串口模块发送到电脑端
uint8_t MyArray[] = {0x42, 0x43, 0x44, 0x45};
Serial_SendArray(MyArray, 4);
Serial_SendString("HelloWorld!\r\n");// 写完字符串后,编译器会自动补上结束标志位,所以字符串的存储空间,会比字符的个数大1
// 如果需要换行,使用转义字符\r \n
Serial_SendNumber(12345, 5);
while(1)
{
}
}
其他引用的头文件和c代码可在此处查阅:OLED.h(【江协STM32】4 OLED调试工具,第5节)、 Delay.h(【江协STM32】3-2 LED闪烁&LED流水灯&蜂鸣器,第1.3节)
5.3 printf函数的移植
使用printf前,需要打开工程选项,勾选“Use MicroLIB”。MicroLIB是Keil为嵌入式平台优化的一个精简库。
然后还需要对printf进行重定向,将printf函数打印的内容输出到串口。首先,在串口模块的最开始加上“#include <stdio.h>”(c文件和h文件都需要加)。之后,在串口模块内重写fputc函数即可,代码如下。因为fputc是printf函数的底层,printf函数在打印的时候,就是不断调用fputc函数一个个打印的,所以将fputc函数重定向到了串口,printf函数自然就输出到串口了。
int fputc(int ch, FILE *f)
{
Serial_SendByte(ch);
return ch;
}
此时便可以直接在主函数中使用printf函数,例如:printf("Num = %d\r\n", 666); 这种方式是最常用的方法。
上述printf函数的移植方法,printf重定向到串口1后,串口2就没有了。如果想要在多个串口上使用printf,可以使用下面的方法。
sprintf可以把格式化字符输出到一个字符串里,所以可以先定义一个字符串,然后sprintf中第一个参数是打印输出的位置,之后参数就和printf一样了,最后再把字符串通过串口发送出去,代码如下。
char String[100];// 首先定义一个字符串,长度给够
sprintf(String, "Num = %d\r\n", 666);// 将格式化字符串保存到String中
Serial_SendString(String);// 将字符串通过串口发送出去,可以更改此函数的串口
可以将上述三行代码封装一下,简化使用过程。封装过程:首先在串口模块中添加头文件“#include <stdarg.h>” ,然后在最后对sprintf函数进行封装,代码如下
void Serial_Printf(char *format, ...)
{
char String[100];
va_list arg;// 定义一个参数列表变量
va_start(arg, format);// 从format位置开始接收参数表,放在arg内
vsprintf(String, format, arg);
va_end(arg);// 释放参数表
Serial_SendString(String);// 发送String
}
此时便可以直接在主函数中使用“Serial_Printf("Num = %d\r\n", 666); ”进行打印。
5.4 汉字显示乱码
文档汉字编码格式UTF8。最终发送到串口,汉字会以UTF8的方式编码,所以在串口助手也要选择UTF8才能解码正确。
直接在printf函数中写汉字,编译器有时会报错,解决方法是打开工程选项,C/C++,杂项控制栏内写上“--no-multibyte-chars”
另外,如果要切换文档编码格式。切换文档编码格式后,需要把汉字删掉,再把文件关掉,重新打开,编码格式才算改过来。
文档使用GB2312编码格式时 ,串口助手的解码要选择GBK。
6. 串口发送+接收
6.1 接线图
6.2 代码
在上一节代码的基础上添加接收部分的代码。
首先,GPIO要使用RX的引脚(PA10),引脚模式可以选择浮空输入或上拉输入。USART初始化中,模式部分增加串口模式RX。对于串口接收来说,可以使用查询和中断两种方法。
6.2.1 查询方法
查询的流程是,在主函数中不断判断RXNE标志位,如果置1就说明收到了数据,再读取DR寄存器即可。
Serial.c(更改后的初始化函数)
void Serial_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
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);
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);
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;// 停止位
USART_InitStructure.USART_WordLength = USART_WordLength_8b;// 字长。不需要校验,所以选8位即可
USART_Init(USART1, &USART_InitStructure);
USART_Cmd(USART1, ENABLE);
}
main.c
#include "stm32f10x.h" // Device
#include "Delay.h"
#include "OLED.h"
#include "Serial.h"
uint8_t RxData;
int main(void)
{
OLED_Init();
Serial_Init();
while(1)
{
if(USART_GetFlagStatus(USART1, USART_FLAG_RXNE) == SET)
{
RxData = USART_ReceiveData(USART1);// 将接收到的一个字节的数据保存在RxData中
// 读DR时会自动清空RXNE标志位,所以这里不需要再清除标志位了
OLED_ShowHexNum(1, 1, RxData, 2);
}
}
}
适用于程序比较简单的情况。
6.2.2 中断方法
首先,在初始化中需要加上开启中断的代码,开启RXNE标志位到NVIC的输出。之后配置NVIC,写中断函数。
Serial.c
#include "stm32f10x.h" // Device header
#include <stdio.h>
uint8_t Serial_RxData;
uint8_t Serial_RxFlag;
void Serial_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
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);
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);
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;// 停止位
USART_InitStructure.USART_WordLength = USART_WordLength_8b;// 字长。不需要校验,所以选8位即可
USART_Init(USART1, &USART_InitStructure);
USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);// 开启RXNE标志位到NVIC的输出
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
NVIC_InitTypeDef NVIC_InitSturcture;
NVIC_InitSturcture.NVIC_IRQChannel = USART1_IRQn;// 中断通道
NVIC_InitSturcture.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitSturcture.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitSturcture.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&NVIC_InitSturcture);
USART_Cmd(USART1, ENABLE);
}
// 发送字节
void Serial_SendByte(uint8_t Byte)
{
USART_SendData(USART1, Byte);// 调用此库函数,Byte变量就写入TDR了,写完后需要等待TDR数据转移至移位寄存器。如果数据在TDR内再写入数据,会产生数据覆盖
while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);// 发送数据寄存器(TDR)空标志位
// 这里标志位置1后不需要手动清零,当下一次再SendData时,此标志位会自动清零
}
// 发送数组
void Serial_SendArray(uint8_t *Array, uint16_t Length)// 指向待发送数组的首地址。由于数组无法判断是否结束,所以需要传递一个Length进来
{
uint16_t i;
for(i = 0; i < Length; i++)
{
Serial_SendByte(Array[i]);
}
}
// 发送字符串
void Serial_SendString(char *String)// 由于字符串自带一个结束标志位,所以不需要传递长度参数
{
uint8_t i;
for(i = 0; String[i] != 0; i++)// 循环条件用结束标志位来判断。这里数字0对应空字符,是字符串的结束标志位。如果不等于0,就是还没结束,进行循环
// 这里数据0也可以写成字符形式,就是'\0',这就是空字符的转义字符表示形式for(i = 0; String[i] != '\0'; i++),和直接写0最终效果是一样的
{
Serial_SendByte(String[i]);
}
}
// 计算次方函数
uint32_t Serial_Pow(uint32_t X, uint32_t Y)
{
uint32_t Result = 1;
while(Y--)
{
Result *= X;
}
return Result;
}
// 发送数字
void Serial_SendNumber(uint32_t Number, uint8_t Length)
{
// 需要把Number的个位、十位、百位等以十进制拆分开,然后转换成字符数字对应的数据,依次发送
uint8_t i;
for(i = 0; i < Length; i++)
{
Serial_SendByte(Number / Serial_Pow(10, Length - i - 1) % 10 + '0');// 最终要以字符的形式发送,所以最后要加上字符的偏移,根据ASCII码表,字符0对应的数据是0x30,也可以以字符的形式写'0'
}
}
int fputc(int ch, FILE *f)
{
Serial_SendByte(ch);
return ch;
}
// 实现Serial_RxFlag标志位读后自动清除
uint8_t Serial_GetRxFlag(void)
{
if(Serial_RxFlag == 1)
{
Serial_RxFlag = 0;
return 1;
}
return 0;
}
uint8_t Serial_GetRxData(void)
{
return Serial_RxData;
}
// 中断函数
void USART1_IRQHandler(void)
{
if(USART_GetFlagStatus(USART1, USART_IT_RXNE) == SET)
{
Serial_RxData = USART_ReceiveData(USART1);
Serial_RxFlag = 1;// 置标志位为1
USART_ClearITPendingBit(USART1, USART_IT_RXNE);// 清除标志位
}
}
Serial.h
#ifndef __SERIAL_H
#define __SERIAL_H
#include <stdio.h>
void Serial_Init(void);
void Serial_SendByte(uint8_t Byte);
void Serial_SendArray(uint8_t *Array, uint16_t Length);
void Serial_SendString(char *String);
uint32_t Serial_Pow(uint32_t X, uint32_t Y);
void Serial_SendNumber(uint32_t Number, uint8_t Length);
uint8_t Serial_GetRxFlag(void);
uint8_t Serial_GetRxData(void);
#endif
main.c
#include "stm32f10x.h" // Device
#include "Delay.h"
#include "OLED.h"
#include "Serial.h"
uint8_t RxData;
int main(void)
{
OLED_Init();
Serial_Init();
OLED_ShowString(1, 1, "RxData:");
while(1)
{
if(Serial_GetRxFlag() == 1)
{
RxData = Serial_GetRxData();// 将接收到的一个字节的数据保存在RxData中
// 读DR时会自动清空RXNE标志位,所以这里不需要再清除标志位了
Serial_SendByte(RxData);// 串口接收回传
OLED_ShowHexNum(1, 8, RxData, 2);
}
}
}
目前这里只支持1个字节的接收,对于大量数据需要用数据包的形式进行传输。
其他引用的头文件和c代码可在此处查阅:OLED.h(【江协STM32】4 OLED调试工具,第5节)、 Delay.h(【江协STM32】3-2 LED闪烁&LED流水灯&蜂鸣器,第1.3节)