USART 串口协议-stm32入门

本文围绕STM32串口通信展开,介绍了通信接口概念、协议、双工模式等基础知识。详细阐述串口通信的硬件电路、电平标准、参数及时序。通过两个功能案例,展示串口发送和发送+接收的实现,包括硬件电路、初始化、示例代码,还提及printf函数移植和汉字乱码解决方法。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

通讯接口简介

通信的目的:将一个设备的数据传送到另一个设备,扩展硬件系统

STM32 芯片里面集成了很多功能模块,如定时器计数、PWM 输出、AD 采集等等,这些都是芯片内部的电路,这些电路的配置寄存器、数据寄存器都在芯片里面,操作这些寄存器非常简单,直接读写就行了。但是也有一些功能是 STM32 内部没有的,比如我们想要蓝牙无线遥控的功能,想要陀螺仪加速度计测量姿态的功能,STM32 没有,所以就只能外挂芯片来完成,那外挂的芯片,它的数据都在 STM32 外面,STM32 如何才能获取到这些数据呢?这就需要我们在这两个设备之间,连接上一根或者多根通信线,通过通信线路发送或者接受数据,完成数据交换,从而实现控制外挂模块和读取外挂模块数据的目的。所以通信的目的是将一个设备的数据传送到另一个设备。
单片机有了通信的功能,就能与众多别的模块互联,极大地扩展了硬件系统。

通信协议:制定通信的规则,通信双方按照协议规则进行数据收发

通信的目的是进行信息传递,双方约定的规则就是通信协议。

在 STM32 中,集成了很多用于通信的外设模块,比如 USART、I2C、SPI、CAN 和 USB,这么多通信接口,我们这个 C8T6 芯片是全部都支持的。

在 STM32 里面有下表这么多的通信协议:

名称引脚双工时钟电平设备
USARTTX、RX(或称 TXD、RXD)全双工异步单端点对点
I2CSCL、SDA半双工同步单端多设备
SPISCLK、MOSI、MISO、CS全双工同步单端多设备
CANCAN_H、CAN_L半双工异步差分多设备
USBDP、DM半双工异步差分点对点
  • 表中只列了通信协议的一个最典型的参数,因为各种通信协议应用都很宽泛,参数也很多,所以这里列出的仅是它最常用、最简单的配置。

  • 通信协议规定的引脚:数据按照协议的规定在这些引脚上进行输入和输出,从而实现通信。

    • TX 与 RX:TX(Transmit Exchange) 是数据发送脚,RX(Receive Exchange) 是数据接收脚。
    • SCL 与 SDA:SCL(Serial Clock)是时钟,SDA 是数据。
    • SCLK、MOSI、MISO、CS:SCLK(Serial Clock)是时钟,MOSI(Master Output Slave Input)是主机输出数据脚,MISO(Master Input Slave Output)是主机输入数据脚,CS(Chip Select)是片选,用于指定通信的对象。
    • CAN_H、CAN_L:这两个是差分数据脚,用两个引脚表示一个差分数据。
    • DP、DM:或者叫 D+ 和 D-,也是一对差分数据脚
  • 双工模式:

    • 全双工:指通信双方能够同时进行双向通信。一般来说,全双工的通信都有两根通信线,比如串口,一根 TX 发送,一根 RX 接收;SPI,一根 MOSI 发送,一根 MISO 接收 。发送线路和接收线路互不影响,全双工。
    • 半双工:剩下的这些,I2C、CAN 和 USB,都只有一根数据线,CAN 和 USB 两根差分线也是组合成为一根数据线的,所以都是半双工。当然还有一种方式,就是单工。
    • 单工:是指数据只能从一个设备到另一个设备,而不能反着来,比如把 串口的 RX 引脚去掉,那串口就退化成单工了。
  • 时钟特性:比如你发送一个波形,高电平然后低电平,接收方怎么知道你是 1、0 还是 1、1、0、0 呢?这就需要有一个时钟信号来告诉接收方,你什么时候需要采集数据。时钟特性分为同步和异步。

    • 同步:这里 I2C 和 SPI 有单独的时钟线,所以它们是同步的,接收方可以在时钟信号的指引下进行采样。
    • 异步:剩下的串口、CAN 和 USB 没有时钟线,所以需要双方约定一个采样频率,这就是异步通信。并且还需要加一些帧头帧尾等,进行采样位置的对齐。
  • 电平特性:

    • 上面三个都是单端信号,也就是它们引脚的高低电平都是对 GND 的电压差,所以单端信号通信的双方必须要共地,就是把 GND 接在一起,所以说这里通信的引脚,前三个还应该加一个 GND 引脚,不接 GND 是无法通信的。
    • 之后 CAN 和 USB 是差分信号,它是靠两个差分引脚的电压差来传输信号的,是差分信号。在通信的时候可以不需要 GND,不过 USB 协议里面也有一些地方需要单端信号,所以 USB 还是需要共地的。使用差分信号可以极大地提高抗干扰特性,所以差分信号一般传输速度和距离都会非常高,性能也是很不错的。
  • 设备特性:

    • 串口 和 USB 属于点对点通信,点对点通信就相当于老师找你去办公室谈话,只有两个人,直接传输数据就可以了。
    • 中间三个是可以在总线上挂载多个设备的,多设备就相当于老师在教室里,面对所有同学谈话,需要有一个寻址的过程,以确定通信的对象。

那本节。我们就来学习一下这里的第一个通信接口 — USART 串口。

1. 串口通信简介

就是软硬件的规则,与某个具体的硬件无关。

1.1 基本概念

串口是一种应用十分广泛的通讯接口,串口成本低、容易使用、通信线路简单,可实现两个设备的互相通信。

  • 在单片机领域,串口其实是一种最简单的通信接口。它的协议相比较 I2C、SPI 等,已经是非常简单了。而且一般单片机,它里面都会有串口的硬件外设,使用也是非常方便的。
  • 一般串口都是点对点通信,所以是两个设备之间的互相通信。

单片机的串口可以使单片机与单片机、单片机与电脑、单片机与各式各样的模块互相通信,极大地扩展了单片机的应用范围,增强了单片机系统的硬件实力。

其中单片机和电脑通信是串口的一大优势,可以接电脑屏幕,非常适合调试程序,打印信息,像 I2C 和 SPI 这些,一般都是芯片之间的通信,不会接在电脑上。

在这里插入图片描述
一些使用串口通讯的模块:

  1. USB 转串口模块,上面有个芯片,型号是 CH340,这个芯片可以把串口协议转换为 USB 协议,它一边是 USB 口,可以插在电脑上,另一边是串口的引脚,可以和支持串口的芯片接在一起,这样就能实现串口和电脑的通信了。
  2. 陀螺仪传感器的模块,可以测量角速度、加速度这些姿态参数,它左右各有 4 个引脚,一边是串口的引脚,另一边是 I2C 的引脚。
  3. 蓝牙串口模块,下面 4 个脚是串口通信的引脚,上面的芯片可以和手机互联,实现手机遥控单片机的功能。

1.2 硬件电路

简单双向串口通信有两根通信线(发送端 TX 和接收端 RX),复杂的串口通信还有其他引脚,比如时钟引脚、硬件流控制的引脚,这些引脚 STM32 的串口也有,不过我们最常用的还是简单的串口通信。也就是 VCC、GND、TX 和 TX 这 4 个引脚。

TX与RX要交叉连接。TX 是发送,RX 是接收,那肯定是一个设备的发送接另一个设备的接收,一个设备的接收接另一个设备的发送 这样来接线。这个注意一下,别接错了。

当只需单向的数据传输时,可以只接一根通信线。比如你只需要设备 1 向设备 2 的单向通信,那就可以只接这一根 TX 到 RX 的线,另一根就可以不接,这就变成了单工的通信方式。

当电平标准不一致时,需要加电平转换芯片。串口也是有很多电平标准的,像我们这种,直接从控制器里出来的信号,一般都是 TTL 电平,相同的电平才能互相通信,不同的电平信号,需要加一个电平转换芯片,转接一下。

在这里插入图片描述
串口接线图:
一般串口通信的模块都有 4 个引脚:VCC、TX、RX、GND。
VCC 和 GND 是供电,TX 和 RX 是通信的引脚。TX 和 RX 是单端信号,它们的高低电平都是相对于 GND 的,所以严格上来说,GND 应该也算是通信线。所以,串口通信的 TX、RX、GND 是必须要接的。上面的 VCC,如果两个设备都有独立供电,那 VCC 可以不接,如果其中一个设备没有供电。比如这里设备 1 是 STM32,设备 2 是蓝牙串口模块,STM32 有独立供电,蓝牙串口没有独立供电,那就需要把蓝牙串口的 VCC 和 STM32 的 VCC 接在一起,STM32 通过这根线,向右边的子模块供电,当然供电的电压也需要注意一下,要按照子模块的要求来,这就是供电要求。

1.3 电平标准

电平标准是数据1和数据0的表达方式,是传输线缆中人为规定的电压与数据的对应关系,串口常用的电平标准有如下三种:

  • TTL电平:+3.3V或+5V表示1,0V表示0。
  • 我们在单片机电路中最常见的是 TTL 电平,也就是 5V 或者 3.3V 表示逻辑 1,0V 表示逻辑 0,这是我们最多遇到的电平标准。但是串口还有一些其他的电平标准,这里了解一下。
  • 这里 1 的电压,如果你是 5V 的器件,就是 +5V;如果是 3.3V 的器件,就是 +3.3V。逻辑 1,就是高电平的电压,就是 VCC 的电压。
  • RS232 电平:-3~-15V表示1,+3~+15V表示0

RS232 电平一般在大型的机器上使用,由于环境可能比较恶劣,静电干扰比较大,所以这里电平的电压都比较大,而且允许波动的范围也很大。

  • RS485 电平:规定是两线压差+2~+6V表示1,-2~-6V表示0(差分信号)

这里电平参考是两线压差,所以 RS485 电平是差分信号,差分信号抗干扰能力非常强,使用 RS485 电平标准,通信距离可以达到上千米。而上面这两种电平,最远只能达到几十米,再远就传不了了。

像单片机这种低压小型设备,使用的都是 TTL 电平,我们之后的内容,也都是基于 TTL 电平来讲解的,如果你做设备需要其他的电平,那就再加电平转换芯片就行了,在软件层面,它们都属于串口,所以程序并不会有什么变化。

到这里,串口协议的硬件部分我们就清楚了。在硬件电路上,协议规定是,一个设备使用 TX 发送高低电平,另一个设备使用 RX 接收高低电平。在线路中,使用 TTL 电平,因为 STM32 是 3.3V 的器件,所以如果线路对地是 3.3V,就代表发送了逻辑 1;如果线路对地是 0V,就代表发送了逻辑 0,那现在如何接线,如何发送 1 和 0 我们就知道了。

1.4 串口参数及时序

接下来我们来看一下串口协议的软件部分。如何用 1 和 0,来组成我们想要发送的一个字节数据。

在这里插入图片描述

这两个时序图就是串口发送一个字节的格式。这个格式是串口协议规定的,串口中,每一个字节都装载在一个数据帧里面,每个数据帧都由起始位、数据位和停止位组成,这里数据位有 8 个,代表一个字节的 8 位;在下边这个数据帧里面,还可以在数据位的最后,加一个奇偶校验位,这样数据位总共就是九位,其中有效载荷是前 8 位,代表一个字节,校验位跟在有效载荷后面,占 1 位,这就是串口数据帧的整体结构。

那我们来看一下串口的参数:

  1. 波特率:串口通信的速率

串口一般是使用异步通信,所以需要双方约定一个通信速率,比如我每隔 1s 发送一位,那你就也得每隔 1s 接收一位,如果你接收快了,那就会重复接收某些位。如果你接收慢了,那就会漏掉某些位,所以说发送和接收,必须要约定好速率,这个速率参数,就是波特率。
波特率本来的意思是每秒传输码元的个数,单位是 码元/s,或者直接叫波特(Baud);另外还有个速率表示,叫比特率,比特率的意思是每秒传输的比特数,单位是 bit/s,或者叫 bps。在二进制调制的情况下,一个码元就是一个 bit,此时波特率就等于比特率,像我们单片机的串口通信,基本都是二进制调制,也就是高电平表示 1,低电平表示 0,一位就是 1bit,所以说,这个串口的波特率,经常会和比特率混用,不过这也是没关系的,因为这两个说法的数值相等。如果是多进制调制,那比特率和波特率就不一样了,这个了解一下。
那反应到波形上,比如我们双方规定波特率为 1000bps,那就表示,1s 要发 1000 位,每一位的时间就是 1ms,发送方每隔 1ms 发送一位,接收方每隔 1ms 接收一位,这就是波特率,它决定了每隔多久发送一位。

  1. 起始位:标志一个数据帧的开始,固定为低电平
  • 在时序图的波形中,首先,串口的空闲状态是高电平,也就是没有数据传输的时候,引脚必须要置高电平,作为空闲状态,然后需要传输的时候,必须要先发送一个起始位,这个起始位必须是低电平,来打破空闲状态的高电平,产生一个下降沿,这个下降沿,就告诉接收设备,这一帧数据要开始了。如果没有起始位,那当我发送 8 个 1 的时候,是不是数据线就一直都是高电平,没有任何波动,对吧,这样接收方怎么知道我发送数据了呢?所以这里必须要有一个固定为低电平的起始位,产生下降沿,来告诉接收设备,我要发送数据了。
  • 同原理,在一个字节数据发送完成之后,必须要有一个停止位。停止位的作用是:用于数据帧间隔,固定为高电平。同时这个停止位,也是为下一个起始位做准备的,如果没有停止位,那当我数据最后一位是 0 的时候,下次再发送新的一帧,是不是就没法产生下降沿了,对吧,这就是起始位和停止位的作用。

起始位固定为 0,产生下降沿,表示传输开始,停止位固定为 1,把引脚恢复成高电平,方便下一次的下降沿,如果没有数据了,正好引脚也为高电平,代表空闲状态。

  1. 数据位:数据帧的有效载荷,1为高电平,0为低电平,低位先行

比如我要发送一个字节,是 0x0F,那就首先把 0F 转换为二进制,就是 0000 1111,然后低位先行,所以数据要从低位开始发送。也就是 1111 0000,像这样,依次放在发送引脚上,所以最终,引脚的波形就是 111(空闲状态) 0(起始位) 1111 0000 1(停止位) 111(空闲状态)。所以说如果你想发送 0x0F 这一个字节数据,那就按照波特率要求,定时翻转引脚电平,产生一个这样的波形就行了。

  1. 校验位:用于数据验证,根据数据位计算得来

这里串口使用的是一种叫奇偶检验的数据验证方法,奇偶校验可以判断数据传输是不是出错了,如果数据出错了,可以选择丢弃或者要求重传,校验可以选择 3 种方式,无校验、奇校验和偶校验,无校验,就是不需要校验位,波形就是上边这个,起始位、数据位、停止位,总共 3 个部分;奇校验和偶校验的波形是下边这个,起始位、数据位、校验位、停止位,总共 4 个部分。如果使用了奇校验,那么包括校验位在内的 9 位数据会出现奇数个 1,比如如果你传输 0000 1111,目前总共 4 个 1,是偶数个,那么校验位就需要再补一个 1,连同检验位就是 0000 1111 1,总共 5 个 1,保证 1 为奇数;如果数据是 0000 1110,此时 3 个 1,是奇数个,那么检验位就补一个 0,连同校验位就是 0000 1110 0,总共还是 3 个 1,1 的个数为奇数。发送方在发送数据后,会补一个校验位,保证 1 的个数为 奇数,接收方在接收数据后,会验证数据位和检验位,如果 1 的个数还是奇数,就认为数据没有出错,如果在传输中,因为干扰,有 1 位由 1 变成 0,或者由 0 变成 1 了,那么整个数据的奇偶特性就会变化,接收方一验证,发现 1 的个数不是奇数,那就认为传输出错,就可以选择丢弃或者要求重传,这就是奇校验的差错控制方法。如果选择双方约定偶校验,那就是保证 1 的个数为偶数,校验方法也是一样的道理。当然奇偶校验的 检出率并不是很高,比如如果有两位数据同时出错,奇偶特性不变,那就校验不出来了,所以奇偶校验只能保证一定程度上的数据校验,如果想要更高的检出率,可以了解一下 CRC 校验,这个校验会更加好用,当然也会更复杂,我们这个 STM32 内部也有 CRC 的外设,可以了解一下。

  1. 停止位:用于数据帧间隔,固定为高电平

我们这里的数据位,有两种表示方法,一种是把检验位作为数据位的一部分,就像上面那个时序一样,分为 8 位数据和 9 位数据,其中 9 位数据,就是 8 位有效载荷和 1 位校验位;另一种就是把数据位和校验位独立开,数据位就是有效载荷,校验位就是独立的 1 位,像上面的描述,就是把数据位和校验位分开描述了,在串口助手软件里,也是用的这种分开描述的方法,数据位 8 位,检验位 1 位。总之,无论是合在一起,还是分开描述,描述的都是同一个东西,这个应该也好理解。

1.5 串口通信的实际波形

通过波形理解串口是如何来传输数据的,这些波形是用示波器实测的。(操作方法是,把探头的 GND 接在负极,探头接在发送设备的 TX 引脚,然后发送数据,就能捕捉到这些波形了。
在这里插入图片描述

  1. 第一个波形是发送一个字节数据 0x55 时,在 TX 引脚输出的波形,波特率是 9600,所以每一位的时间就是 1/9600,大概是 104us,可以看到,这里一位就是 100us 多一点,就是 104us,没发送数据的时候,是空闲状态高电平,数据帧开始,先发送起始位,产生下降沿,代表数据帧开始,数据 0x55 转到二进制,低位先行,就是依次发送 1010 1010,然后这个参数是 8 位数据,1 位停止,无校验,没有校验位,所以之后就是停止位,把引脚置回高电平,这样一个数据帧就完成了,在 STM32 中,这个根据字节数据翻转高低电平,是由 USART 外设自动完成的,不用我们操心,当然你也可以软件模拟产生这样的波形,那就是定时器定一个 104us 的时间,时间到之后,按照数据帧的要求,调用 GPIO_WriteBit 置高低电平,产生一个和这一模一样的波形,这样也是可以完成串口通信的。TX 引脚发送,就是置高低电平,那在 RX 引脚接收,显然就是读取高低电平了,这也可以由 USART 外设自动来完成,不用我们操心,如果想软件模拟的话,那就是定时调用 GPIO_ReadInputDataBit 来读取每一个位,最终拼接成一个字节。当然接收的时候,应该还需要一个外部中断,在起始位的下降沿,进入接收状态,并且对齐采样时钟,然后依次采样 8 次,这就是接收的逻辑。
  2. 接着看下下面的波形,如果发送 0xAA,波形就是第二个波形这样的,起始位,然后 0101 0101,停止位,结束,再下面,如果发送 0xFF,就是 8 个 1,那波形就是第三个波形这样的,起始位,8 个 1,停止位,结束,在起始位下降沿之后的一个数据帧的时间内,这个高电平就是数据 1 来看的,当数据帧结束后,这里虽然还是 1,没有任何变化,但此时的 1,已经是属于空闲状态了,它需要等待下一个下降沿,来开启新的一帧数据,之后再看下面,如果发送 0x00,就是 8 个 0,那波形就是第四个波形这样的,起始位,8 个 0,停止位置回高电平,这样来进行。
  3. 然后继续看右边的波形,将波特率改为 4800,也就是波特率变为一半,那相应的波形时长就会变为原来的二倍,可以看到,这里 10 位数据总共大概 2ms 多一点,具体应该在 2.08ms,那一位就是 208us,是之前的二倍,数据波形的时间拉宽,波形的变化趋势是不变的,之后看下面这第二个波形,这里加了一个偶校验位,数据是 0x55,1 的个数是 4 个,已经是偶数了,所以输出的校验位是 0,低电平,此时包括校验位在内的数据总共是偶数个 1,总的波形就是这个样子。最后看一下停止位的变化,串口的停止位是可以进行配置的,可以选择 1 位、1.5 位、2 位等。看一下,这第三个波形是 1 位停止位,连续发送两个 0x55,两个数据帧会接在一起,中间没有空闲状态,这第四个波形是 2 位停止位,连续发送两个 0x55,可以看到,这里停止位就是两位的宽度,中间也没有空闲状态,不过这样数据分隔的就更宽一些了,这就是不同长度停止位的现象。

到这里,有关串口协议的硬件和软件就介绍完了,总结一下就是,TX 引脚输出定时翻转的高低电平,RX 引脚定时读取引脚的高低电平,每个字节的数据加上起始位、停止位、可选的校验位,打包成数据帧,依次输出在 TX 引脚,另一端 RX 引脚依次接收,这样就完成了字节数据的传递。这就是串口通信。

2. STM32 内部的 USRAT 外设

2.1 USRAT 简介

外设作用:按照串口协议来产生和接收高低电平信号,实现串口通信。

USART(Universal Synchronous/Asynchronous Receiver/Transmitter)通用同步/异步收发器

  • 我们经常还会遇到串口,叫 UART,这里少了 S,就是异步收发器;
  • 其实这个STM32 的 USART 同步模式,只是多了个时钟输出而已,它只支持时钟输出,不支持时钟输入,所以这个同步模式更多的是为了兼容别的协议或者特殊用途而设计的,并不支持两个 USART 之间进行同步通信。所以我们学习串口,主要还是异步通信。
  • 一般我们串口很少使用同步功能。所以 USART 和 UART 使用起来,也没什么区别。

USART 是 STM32 内部集成的硬件外设,可根据数据寄存器的一个字节数据自动生成数据帧时序,从 TX 引脚发送出去,也可自动接收 RX 引脚的数据帧时序,拼接为一个字节数据,存放在数据寄存器里。

  • 我们之前学习串口协议,串口主要就是靠收发约定好的波形来进行通信的,那这个 USART 外设,就是串口通信的硬件支持电路,USART 大体可分为发送和接收两部分,发送部分就是将数据寄存器的一个字节数据,自动转换为协议规定的波形,从 TX 引脚发送出去;接收部分就是自动接收 RX 引脚的波形,按照协议规定,解码为一个字节数据,存放在数据寄存器里。这就是 USART 电路的功能。
  • 当我们配置好了 USART 电路,直接读写数据寄存器,就能自动发送和接收数据了。

自带波特率发生器,最高达4.5Mbits/s

这个波特率发生器就是用来配置波特率的,它其实就是一个分频器,比如我们 APB2 总线给个 72MHz 的频率,然后波特率发生器进行一个分频,得到我们想要的波特率时钟,最后在这个时钟下,进行收发,就是我们指定的通信波特率。

可配置数据位长度(8/9)、停止位长度(0.5/1/1.5/2)

  • 这些就是 STM32 的 USART 支持配置的参数了,这个数据位长度就是我们 串口参数及时序 节的参数,有 8 位和 9 位,是包含奇偶校验位的长度,一般不需要校验就选 8 位,需要校验就选 9 位。
  • 停止位长度支持三种停止位,也就是在进行连续发送时,停止位长度决定了帧的间隔,我们最常用的就是 1 位停止位,其他的很少用。

可选校验位(无校验/奇校验/偶校验)

我们最常用的是无校验。

以上这些所有的参数,都是可以通过配置寄存器来完成的。使用库函数配置的话,就更简单了,直接给结构体赋值就行了。

串口参数我们最常用的是波特率 9600 或者 115200,数据位 8 位,停止位 1 位,无校验,一般我们都选这种常用的参数,不用纠结,选它就是了。

支持同步模式、硬件流控制、DMA、智能卡、IrDA、LIN

  • 同步模式,就是多了个时钟 CLK 的输出(可以配置时钟的极性、相位等参数)(UART4 和 UART5 不支持同步功能,所以它们的名字就直接叫 UART 了)
  • 硬件流控制,比如 A 设备有个 TX 向 B 设备的 RX 发送数据,A 设备一直在发,发的太快了,B 处理不过来,如果没有硬件流控制,那 B 就只能抛弃新数据或者覆盖原数据了,如果有硬件流控制,在硬件电路上,就会多出一根线,如果 B 没准备好接收,就置高电平,如果准备好了,就置低电平,A 接收到了 B 反馈的准备信号,就只会在 B 准备好的时候,才发数据,如果 B 没准备好,那数据就不会发送出去。这就是硬件流控制,可以防止因为 B 处理慢而导致的数据丢失的问题。硬件流控制,STM32 也是有的,不过我们一般不用,需要的话可以了解一下。
  • DMA,是这个串口支持 DMA 进行数据转运,如果有大量的数据进行收发,可以使用 DMA 转运数据,减轻 CPU 的负担。
  • 智能卡、IrDA、LIN,这些是其他的一些协议,因为这些协议和串口是非常像的,所以 STM32 就对 USART 加了一些小改动,就能兼容这么多协议了。不过我们一般不用。智能卡应该是跟我们刷的饭卡、公交卡这类有关的;IrDA 用于红外通信,这个红外通信就是一个红外发光管,另一边是红外接收管,然后靠闪烁红外光通信,并不是我们遥控器的那个红外通信,所以并不能模拟遥控器;LIN 是局域网的通信协议,这个感兴趣的话,可以自己研究一下。

STM32F103C8T6 USART资源: USART1、 USART2、 USART3

总共 3 个独立的 USART 外设,可以挂载很多串口设备,其中这里 USART1 是 APB2 总线上的设备,剩下的都是 APB1 总线上的设备,这个就开启时钟的时候注意一下,在使用的时候,挂载在哪个总线,影响并不是很大。

2.2 USART 框图

在这里插入图片描述
这个框图一眼看上去还是非常复杂的,但是实际上主要部分也没有很多,它这里就是把各个寄存器和寄存器每一位控制的地方都画出来了,所以才显得比较乱,我们看的时候可以先忽略这些寄存器,先看主体结构。

  1. 左上角的引脚部分,有 TX 和 RX,这两个就是发送和接收引脚;下面这里的 SW_RX、IRDA_OUT/IN 这些是智能卡和 IrDA 通信的引脚,我们不用这些协议,所以这些引脚就不用管的。右边这个框框,IrDA、SIR 这些东西也都不用管的。引脚这块,TX 发送脚,就是从这里接出去的,RX 接收脚,就是通向这里,这样就行了。
  2. 然后灰色区域部分就是串口的数据寄存器了,发送或接收的字节数据就存在这里。上面这有两个数据寄存器,一个是发送数据寄存器 TDR(Transmit DR),另一个是接受数据寄存器 RDR(Receive DR),这两个寄存器占用同一个地址,就跟 51 单片机串口的 SBUF 寄存器一样,在程序上,只表现为一个寄存器,就是数据寄存器 DR(Data Register),但实际硬件中,是分成了两个寄存器,一个用于发送 TDR,一个用于接收 RDR,TDR 是只写的,RDR 是只读的,当你进行写操作的时候,数据就写入到 TDR;当你进行读操作的时候,数据就从 RDR 读出来的,这个了解一下。 然后往下看,下面是两个移位寄存器,一个用于发送,一个用于接收,发送移位寄存器的作用就是,把一个字节的数据一位一位的移出去,正好对应串口协议的波形的数据位。这两个寄存器如何工作?TDR 与移位寄存器的配合:举个例子,比如你在某时刻给 TDR 写入 0x55 这个数据,在寄存器里就是二进制存储,0101 0101,那么此时,硬件检测到你写入数据了,它就会检查,当前移位寄存器是不是有数据正在移位,如果没有,这个 0101 0101 就会立刻全部移动到发送移位寄存器,准备发送,当数据从 TDR 移动到移位寄存器时,会置一个标志位,叫 TXE(TX Empty),发送寄存器空,我们检查这个标志位,如果置 1 了,我们就可以在 TDR 写入下一个数据了,注意一下,当 TXE 标志置 1 时,数据其实还没有发送出去,只要数据从 TDR 转移到发送移位寄存器了,TXE 就会置 1,我们就可以写入新的数据了,然后发送移位寄存器就会在下面的发生器控制的驱动下,向右移位,然后一位一位的,把数据输出到 TX 引脚,这里是向右移位,所以正好和串口协议规定的低位先行,是一致的,当数据移位完成后,新的数据就会再次自动的从 TDR 转移到发送移位寄存器里来,如果当前移位寄存器移位还没有完成,TDR 的数据就会进行等待,一旦移位完成,就会立刻转移过来,有了 TDR 和 移位寄存器的双重缓存,可以保证连续发送数据的时候,数据帧之间不会有空闲,提高了工作效率。简单来说,就是你数据一旦从 TDR 转移到移位寄存器了,管你有没有移位完成,我就立刻把下一个数据放在 TDR 等着,一旦移完了,新的数据就会立刻跟上,这样做,效率就会比较高。然后看一下接收端这里,也是类似的,RDR 与移位寄存器的配合:数据从 RX 引脚通向接收移位寄存器,在接受器控制的驱动下,一位一位的读取 RX 电平,先放在最高位,然后向右移,移位 8 次之后,就能接受一个字节了,同样,因为串口协议规定是低位先行,所以接收移位寄存器是从高位往低位这个方向移动的,之后,当一个字节移动完成后,这一个字节的数据就会整体的一下子转移到接收数据寄存器 RDR 里来,在转移的过程中,也会置一个标志位,叫 RXNE(RX Not Empty),接收数据寄存器非空,当我们检测到 RXNE 置 1 之后,就可以把数据读走了,同样,这里也是两个寄存器进行缓存,当数据从移位寄存器转移到 RDR 时,就可以直接移位接收下一帧数据了,这就是 USART 外设整个的工作流程。

到这里,这个外设的主要功能就差不多了。大体上,就是数据寄存器和移位寄存器,发送移位寄存器往 TX 引脚移位,接收移位寄存器从 RX 引脚移位,当然发送还需要加上帧头帧尾,接收还需要剔除帧头帧尾,这些操作,它内部有电路会自动执行,我们知道有硬件帮我们做了这些工作就行了。

  1. 接着我们继续看一下下面的控制部分和其他增强部分,下面这里是发送器控制,它就是用来控制发送移位寄存器的工作的,接收器控制,用来控制接收移位寄存器的工作的,然后左边有一个硬件数据流控,也就是硬件流控制,简称流控。前面我们也大概介绍过,如果发送设备发的太快,接收设备来不及处理,就会出现丢弃或覆盖数据的现象,那有了流控,就可以避免这个问题,这里流控有两个引脚,一个是 nRTS,一个是 nCTS。nRTS 和 nCTS 用途:nRTS(Request To Send)是请求发送,是输出脚,也就是告诉别人,我当前能不能接收;nCTS(Clear To Send)是清除发送,是输入脚,也就是用于接收别人 nRTS 的信号的,这里前面加个 n 意思是低电平有效,那这两个引脚怎么玩的呢?首先得找另一个支持流控的串口,它的 TX 接到了我的 RX,然后我的 RTS 要输出一个能不能接收的反馈信号,接到对方的 CTS,当我能接收的时候,RTS 就置低电平,请求对方发送,对方的 CTS 接收到之后,就可以一直发。当我处理不过来时,比如接收数据寄存器我一直没有读,又有新的数据过来了,现在就代表我没有及时处理,那 RTS 就会置高电平,对方 CTS 接收到之后,就会暂停发送,直到这里接收数据寄存器被读走,RTS 置低电平,新的数据才会继续发送,那反过来,当我的 TX 给对方发送数据时,我们 CTS 就要接到对方的 RTS,用于判断对方,能不能接收,TX 和 CTS 是一对的,RX 和 RTS 是一对的,CTS 和 RTS 也要交叉连接,这就是流控的工作模式。

我们一般不使用流控,所以流控就了解一下就行。

  1. 右边 SCLK 控制部分电路用于产生同步的时钟信号,它是配合发送移位寄存器输出的,发送寄存器每移位一次,同步时钟电平就跳变一个周期,时钟告诉对方,我移出去一位数据了,你看要不要让我这个时钟信号来指导你接收一下?当然这个时钟只支持输出,不支持输入,所以两个 USART 之间,不能实现同步的串口通信,那这个时钟信号有什么用呢?第一个用途:兼容别的协议,比如串口加上时钟之后,就跟 SPI 协议特别像,所以有了时钟输出的串口,就可以兼容 SPI;另外这个时钟也可以做自适应波特率,比如接收设备不确定发送设备给的什么波特率,那就可以测量一下这个时钟的周期,然后再计算得到波特率,不过这就需要另外写程序来实现这个功能了。这个时钟功能,我们一般不用,所以也是了解一下就行了。
  2. 中间这个唤醒单元,这部分的作用是实现串口挂载多设备,我们之前说,串口一般是点对点的通信,点对点,支支持两个设备互相通信,想发数据直接发就行,而多设备,在一条总线上,可以接多个从设备,每个设备分配一个地址,我想跟某个设备通信,就先进行寻址,确定通信对象,再进行数据收发。这个唤醒单元就可以用来实现多设备的功能,在这里可以给串口分配一个地址,当你发送指定地址时,此设备唤醒开始工作;当你发送别的设备地址时,别的设备就唤醒工作,这个设备没收到地址,就会保持沉默,这样就可以实现多设备的串口通信了。这部分功能我们一般不用,大家也了解一下就行。
  3. 下面有中断输出控制,中断申请位,就是状态寄存器这里的各种标志位,状态寄存器这里就(接受其控制模块下方),有两个标志位比较重要,一个是 TXE 发送寄存器空,另一个是 RXNE 接收寄存器非空,这两个是判断发送状态和接收状态的必要标志位,刚才也都讲过,剩下的标志位了解一下就行。中断输出控制这里,就是配置中断是不是能通向 NVIC,这个应该好理解。
  4. 最下面是波特率发生器部分,之前提到过,波特率发生器其实就是分频器,APB 时钟进行分频,得到发送和接收移位的时钟,看一下,这里时钟输入是 fPCLKx(x = 1 或 2),USART1 挂载在 APB2,所以就是 PCLK2 的时钟,一般是 72M,其他的 USART 都挂载在 APB1,所以是 PCLK1 的时钟,一般是 36M,之后这个时钟进行一个分频,除一个 USARTDIV 的分频系数,USARTDIV 里面就是右边这样,是一个数值,并且分为了整数部分和小数部分,因为有些波特率,用 72M 除一个整数的话,可能除不尽,会有误差,所以这里分频系数是支持小数点后 4 位的,分频就更加精准,之后分频完之后,还要再除一个 16,得到发送器时钟和接收器时钟,通向控制部分,然后右边这里,如果 TE(TX Enable)为 1,就是发送器使能了,发送部分的波特率就有效,如果 RE(RX Enable)为 1,就是接收器使能了,接收部分的波特率就有效。

剩下还有一些寄存器的指示,比如各个 CR 控制寄存器的哪一位控制哪一部分电路,SR 状态寄存器都有哪些标志位,这些可以自己看看手册里的寄存器描述,那里的描述比这里清晰很多。

最后我们再看看串口的引脚。在引脚定义表的复用功能这一栏就给出了每个 USART,它的各个引脚都是复用在了哪个 GPIO 口上的,比如这里 USART2 的 TX 是 PA2 口,RX 是 PA3 口,USART3 的 TX 和 RX 分别是 PB10 和 PB11,然后,USART1 的 TX 和 RX 分别是 PA9 和 PA10,这些引脚都必须按照引脚定义里的规定来。
在这里插入图片描述
比如你要使用 USART1,那 TX 必须是 PA9,RX 必须是 PA10,或者看一下重映射这里,有没有重映射,这里有 USART1 的重映射,所有有机会换一次口。剩下的引脚,就没有机会作为 USART1 的接口了,所以这个表在设计电路的时候很重要,要提前规划好引脚,别让引脚复用功能冲突了,有关外设的复用引脚是哪个的问题,都是看引脚定义表,一看就知道。

2.3 USART 基本结构

在这里插入图片描述
这就是 USART 最主要、最基本的结构。

  1. 最左边是波特率发生器,用于产生约定的通信速率,时钟来源是 PCLK2 或 1,经过波特率发生器分频后,产生的时钟通向发送控制器和接收控制器。
  2. 发送控制器和接收控制器,用来控制发送移位和接收移位,之后,由发送数据寄存器和发送移位寄存器这两个寄存器的配合,将数据一位一位的移出去,通过 GPIO 口的复用输出,输出到 TX 引脚,产生串口协议规定的波形,这里画了几个右移的符号,这就代表这个移位寄存器是往右移的,是低位先行。当数据由数据寄存器转移到移位寄存器时,会置一个 TXE 的标志位,我们判断这个标志位,就可以知道是不是可以写下一个数据了。
  3. 然后接收部分也是类似的,RX 引脚的波形,通过 GPIO口 输入,在接收控制器的控制下,一位一位的移入接收移位寄存器,这里画了右移的符号,也是右移的,因为是低位先行,所以要从左边开始移进来,移完一帧数据后,数据就会统一转运到接受数据寄存器,在转移的同时,置一个 RXNE 标志位,我们检查这个标志位,就可以知道是不是收到数据了,同时这个标志位也可以去申请中断,这样就可以在收到数据时,直接进入中断函数,然后快速的读取和保存数据。
  4. USART 内部结构中实际上有 4 个寄存器,但是在软件层面,只有一个 DR 寄存器可以供我们读写,写入 DR 时,数据走上面这条路,进行发送;读取 DR 时,数据走下面这条路,进行接收,这就是 USART 进行串口数据收发的过程。
  5. 最后右下角是一个开关控制,就是配置完成之后,用 Cmd 开启一下外设。

3. 细节问题

3.1 数据帧

在这里插入图片描述
这个图是在程序中配置 8 位字长和 9 位字长的波形对比。这里的字长就是我们前面说的数据位长度。它这里的字长,是包含校验位的描述方式。

  1. 上面 9 位字长的波形:
    1. 第一条时序,很明显就是 TX 发送或者 RX 接收的数据帧格式,空闲高电平,然后起始位 0,然后根据写入数据,置 1 或 0,依次发送位 0 到 位 8,共 9 位,最后停止位 1,数据帧结束,在这里位 8,也就是第 9 个位置,是一个可能的奇偶校验位,通过配置寄存器就可以配置成奇校验、偶校验或者无校验。这里可以选择配置成 8 位有效载荷 + 1 位校验位,也可以选择 9 位全都是有效载荷,不过既然选择了 9 位字长,那一般都是要加上校验位的,因为 8 位有效载荷,正好对应一个字节。
    2. 然后下面这个时钟,就是我们之前说的同步时钟输出的功能,可以看到在每个数据位中间,都有一个时钟上升沿,时钟频率和数据速率也是一样的,接收端可以在时钟上升沿进行采样,这样就可以精准定位每一位数据,这个时钟的最后一位,可以通过这个 LBCL 位控制要不要输出,另外这个时钟的极性、相位什么的,也可以通过配置寄存器配置,需要的话可以了解一下。
    3. 然后下面这两个波形,一个是空闲帧,就是从头到尾都是 1,还有一个是断开帧,就是从头到尾都是 0,这两个数据帧,是局域网协议用的,我们串口用不着,不用管的。
  2. 下面 8 位字长的波形:
    1. 可以看到,这里的数据位是从位 0 一直到 位 7,总共是 8 位,比上面这个少了一个位 8,同样这个最后一位位 7,也是一个可能的奇偶校验位,还是同样,既然选择了 8 位字长,那这里就最好选择无校验,要不然校验位占 1 位,有效载荷就只剩7 位了,一个字节都发不了,这不逼死强迫症么。
    2. 最后这些时钟什么的,跟上面也是类似的。

总的来说,这里有 4 种选择,9 位字长,有校验或无校验;8 位字长,有校验或无校验;但我们最好选择 9 位字长,有校验 或 8 位字长,无校验这两种,这样每一帧的有效载荷都是 1 字节,这样才舒服。

在这里插入图片描述
接下来我们继续来看这个数据帧不同停止位的波形变化。

STM32 的串口可以配置停止位长度为 0.5、1、1.5、2 这四种。这四种参数区别就是停止位的时长不一样。

  1. 第一个是 1 个停止位,这时停止位的时长就和数据位是一位,时长一样。
  2. 然后是 1.5 个停止位,这时的停止位就是数据位的 1 位,时长是 1.5 倍。
  3. 2 个停止位,那停止位时长就是 2 倍。
  4. 0.5 个停止位,时长就是 0.5 倍。

就是控制停止位的时长的,一般选择 1 位停止位就行了。其他的参数不太常用。

3.2 USART 电路输入数据的一些策略

对于串口来说,串口的输出 TX 应该是比输入 RX 简单得多,输出就定时翻转 TX 引脚高低电平就行了。但是输入,不仅要保证输入的采样频率和波特率一致,还要保证每次输入采用的位置,要正好处于每一位的正中间,只有再每一位的正中间采样,这样高低电平读进来,才是最可靠的,如果你采样点过于靠前或靠后,那有可能高低电平还正在翻转,电平不稳定,或者稍有误差,数据就采样错了;另外,输入最好还要对噪声有一定的判断能力,如果是噪声,最好能置一个标志位提醒我一下,这些就是输入数据所面临的问题。

那我们来看一下 STM32 是如何来设计输入电路的呢?

3.2.1 起始位侦测

在这里插入图片描述
这里展示的是 USART 的起始位侦测。当输入电路侦测到一个数据帧的起始位之后,就会以波特率的频率,连续采样一帧数据,同时,从起始位开始,采样位置就要对齐到位的正中间,只要第一位对齐了,后面就肯定都是对齐的。那为了实现这些功能,首先输入的这部分电路对采样时钟进行了细分,它会以波特率的 16 倍频率进行采样,也就是在一位的时间里,可以进行 16 次采样,然后它的策略是,最开始,空闲状态高电平,那采样就一直是 1,在某个位置,突然采到一个 0,那么就说明,在这两次采样之间,出现了一个下降沿,如果没有任何噪声,那之后就应该是起始位了,在起始位,会进行连续 16 次采样,没有噪声的话,这 16 次采样,肯定就都是 0,这没问题,但是实际电路还是会存在一些噪声的,所以这里即使出现下降沿了,后续也要在采样几次,以防万一。

那根据手册描述,这个接收电路,还会在下降沿之后的第 3 次、5 次、7 次进行一批采样,在第 8 次、9 次、10 次再进行一批采样,且这两批采样,都要要求每 3 位里面至少应有 2 个 0。如果没有噪声,那肯定全是 0,满足情况,如果有一些轻微的噪声,导致这 3 位里面,只有两个 0,另一个是 1,那也算是检测到了起始位,但是在状态寄存器里会置一个 NE(Noise Error),噪声标志位,就是提醒你一下,数据我收到了,但是有噪声,你悠着点用,如果这里 3 位里面,只有 1 个 0,那就不算检测到了起始位,可能前面那个下降沿是噪声导致的,这时电路就忽略前面的数据,重新开始捕捉下降沿,这就是 STM32 的串口,在接收过程中,对噪声的处理,如果通过了这个起始位侦测,那接收状态就由空闲,变为接收起始位;同时,第 8、9、10 次采样的位置就正好是起始位的正中间,之后,接收数据位时,就都在第 8、9、10 次,进行采样,这样就能保证采样位置在位的正中间了,这就是起始位侦测和采样位置对齐的策略。

3.2.2 数据采样

在这里插入图片描述

那紧跟着,我们就可以看这个数据采样的流程了。这里从 1 到 16,是一个数据位的时间长度,在一个数据位,有 16 个采样时钟;由于起始位侦测已经对齐了采样时钟,所以,这里就直接在第 8、9、10 次采样数据位,为了保证数据的可靠性,这里是连续采样 3 次,没有噪声的理想情况下,这 3 次肯定全为 1 或者全为 0,全为 1 就认为收到了 1,全为 0 就认为收到了 0;如果有噪声,导致 3 次采样不是全为 1 或者全为 0,那它就按照 2:1 的规则来。2 次为 1,就认为收到了 1,2 次为 0,就认为收到了 0,在这种情况下,噪声标志位 NE 也会置 1,告诉你,我收到数据了,但是有噪声,你悠着点用,这就是检测噪声的数据采样,可见 STM32 对这个电路的设计考虑还是很充分的。

3.3 波特率发生器

最后,我们再来看一下波特率发生器,波特率发生器就是分频器。

  • 发送器和接收器的波特率由波特率寄存器 BRR 里的 DIV 确定。

在这里插入图片描述
上面这个图就是 BRR 寄存器,里面就是分频系数 DIV,DIV 分为整数部分和小数部分,可以实现更细腻的分频。

  • 波特率和分频系数的关系,由计算公式进行计算:波特率 = fPCLK2/1 / (16 * DIV)。

这里的 16 就是数据采样中一个数据位的时间长度,因为它内部还有一个 16 倍波特率的采样时钟,所以这里输入时钟 / DIV 要等于 16 倍的波特率,最终计算波特率,自然要多除一个 16 了。

举个例子,比如我要配置 USART1 为 9600 的波特率,那如何配置这个 BRR 寄存器呢?我们代入公式,就是 9600 = USART1时钟(72M) / 16 倍的 DIV,解得 DIV = 72M / 9600 / 16,然后可以用计算器算一下最终等于 468.75,这是一个带小数的分频系数,最终写到寄存器还需要转化为二进制,最终得到二进制数是 111010100.11,所以最终写到寄存器就是,整数部分为 111010100,前面多出来的补 0,小数部分为 11,后面多出来的补 0,这就是根据波特率写 BRR 寄存器的方法,了解一下。不过我们用库函数配置的话,就非常方便,需要多少波特率,直接写就行了,库函数会自动帮我们算。

3.4 USB 转串口模块内部电路图

在这里插入图片描述

  1. 最左边是 USB 的端口,USB 有 4 根线,GND、D+、D-、VCC,USB 标准供电是 5 V,然后中间 D+ 和 D- 是通信线,走的也是 USB 协议,所以需要一个 CH340 芯片转换一下。转换之后输出的就是 TXD 和 RXD,是串口协议,最后,通过排针引出来,那需要注意的就是供电策略,首先,所有的电都是从 VCC+5V 来的,然后 VCC+5V,通过这个稳压管电路进行降压,得到 VCC+3.3V,之后,VCC+5V 和 VCC+3.3V 都通过排针引出来了,所以第 6 脚和第 4 脚,是分别有 5V 和 3.3V 输出的,那很多人迷惑的是第 5 脚,板子上标的是 VCC,这个引脚通过原理图可以看到,它是通向了 CH340 芯片的 VCC 上,所以这个第 5 脚,实际上是 CH340 的电源输入脚,一般我们这个模块的排针会有一个跳线帽,这个跳线帽需要插在 4、5 脚,或者 5、6 脚上,右上方也有文字说明,短路 5V 到 VCC,CH340 供电为 5V,TTL 电平为 5V,短路 3.3V 到 VCC,CH340 供电为 3.3V,TTL 电平为 3.3V,所以这个跳线帽,是用来选择通信电平的,也是给 CH340 芯片供电的,所以最好不要拿掉,如果你拿掉了,就相当于这整个芯片,没有供电,不过神奇的是,即使把跳线帽拔掉,不给芯片供电,这个串口还是能正常工作,可能是从别的地方汲取了一些电流吧,当然还有可能是别的原因,我们这个模块的电路时有些变动的,如果不插跳线帽,测试了通信电平为 3.3V,不过为了稳定,最好还是要插上跳线帽。那我们 STM32,通信需要 3.3V,所以把跳线帽插在这里的 4、5 脚上就行了,那供电就只剩一个 5V 脚了,所以这个供电有点折磨人,要么选择 5V 电平,剩下供电脚就只有 3.3V,要么选择 3.3V 电平,剩下供电脚就是 5V,所以这个供电脚的设计有点不太方便,不过考虑到电平的 5V 和 3.3V 可以互相兼容,所以如果你既需要通信,又需要供电,那就保证供电是正确的就行了,通信电平没法一致,这应该也没问题。
  2. 然后右边还有引脚说明(5V 脚、VCC、3.3V、TXD、RXD、GND)。
  3. 最后右下角这些是指示灯和电源滤波,这里有 PWR 电源指示灯和 TXD、RXD 的指示灯,如果引脚上有数据传输,这两个指示灯会对应闪烁,方便我们观察。

寄存器也有几个经典的分类:

  • 状态寄存器 SR(Status Register),存放各种标志位;
  • 数据寄存器 DR(Data Register),存放最关键的数据;
  • 配置寄存器 CR(Config Register),存放各种配置参数。

这三类寄存器基本每个外设都有,剩下的就是一些零碎的寄存器了。

4. 两个 串口通信 功能案例

4.1 串口发送

4.1.1 硬件电路图

在这里插入图片描述

下面是 USB 转串口的模块。这里有个跳线帽,要插在 VCC 和 3.3V 这两个脚上,选择通信的 TTL 电平为 3.3V,然后通信引脚,TXD 和 RXD,要接在 STM32 的 PA9 和 PA10 口。因为引脚定义表中 USART1 的 TX 和 RX 分别是 PA9 和 PA10,我们计划用 USART1 进行通信,所以就选这两个脚,如果你用 USART2 或 3 的话,就要在引脚定义表中找一下 USART2 或 3 的对应引脚。

然后是 TX 和 RX 交叉连接,这里一定要注意,别接错了。这里 PA9 是 STM32 的 TX,发送,在接线图这里,接的就是串口模块的 RXD,接收;然后串口模块的 TXD,发送,要接在 STM32 的 PA10,也就是 RX,接收。然后两个设备之间要把负极接在一起,进行共地。

一般多个系统之间互连,都要进行共地,这样电平才能有高低的参考。就像两个人比升高一样,它两必须要站在同一地平面上,才能比较。如果一个人站在地面上,一个人站在台阶上,他两怎么知道谁高谁低呢?这就是共地的问题。

最后这个串口模块和 STLINK,都要插在电脑上,这样,STM32 和串口模块都有独立供电。所以这里通信的电源正极就不需要接了,只接 3 根线就行。当然我们第一个代码,只有 STM32 的发送部分,所以,通信线只有这个发送的有用,另一根线,第一个代码没有用到,暂时可以不接。在我们下一个串口发送 + 接收的代码,两根通信线就都需要接了,所以我们把这两根通信线一起都接上吧。这样两个代码的接线图是一模一样的。

接线完成后,打开电脑的设备管理器,确保串口的驱动没问题。在端口目录下,可以看到有这个 CH340 的驱动,如果出现了 COM 号,并且前面图标没有感叹号,那就证明串口驱动没问题,否则的话,需要安装一下串口模块的驱动。(这个在软件安装章节有介绍,不会装驱动的可以回去再看一下)

4.1.2 串口通信模块初始化

  • 串口通信模块的库函数(在 usart.h 中)
void USART_DeInit(USART_TypeDef* USARTx);
void USART_Init(USART_TypeDef* USARTx, USART_InitTypeDef* USART_InitStruct);
void USART_StructInit(USART_InitTypeDef* USART_InitStruct);
void USART_ClockInit(USART_TypeDef* USARTx, USART_ClockInitTypeDef* USART_ClockInitStruct);//用来配置同步时钟输出的,包括时钟是不是要输出,时钟的极性相位等参数,因为参数也比较多,所以也是用结构体这种方式来配置的,需要时钟输出的话,可以了解一下这两个函数 
void USART_ClockStructInit(USART_ClockInitTypeDef* USART_ClockInitStruct);
void USART_Cmd(USART_TypeDef* USARTx, FunctionalState NewState);
void USART_ITConfig(USART_TypeDef* USARTx, uint16_t USART_IT, FunctionalState NewState);
void USART_DMACmd(USART_TypeDef* USARTx, uint16_t USART_DMAReq, FunctionalState NewState);//可以开启 USART 到 DMA 的触发通道,需要用 DMA 的话,可以了解一下。
//这些函数我们都不用
void USART_SetAddress(USART_TypeDef* USARTx, uint8_t USART_Address);//设置地址
void USART_WakeUpConfig(USART_TypeDef* USARTx, uint16_t USART_WakeUp);//唤醒
void USART_ReceiverWakeUpCmd(USART_TypeDef* USARTx, FunctionalState NewState);
void USART_LINBreakDetectLengthConfig(USART_TypeDef* USARTx, uint16_t USART_LINBreakDetectLength);//LIN
void USART_LINCmd(USART_TypeDef* USARTx, FunctionalState NewState);
//这两个函数,在我们发送和接收的时候会用到
void USART_SendData(USART_TypeDef* USARTx, uint16_t Data);//发送数据,写 DR 寄存器
uint16_t USART_ReceiveData(USART_TypeDef* USARTx);//接收数据,读 DR 寄存器
//DR 寄存器内部有 4 个寄存器,控制发送和接收,执行细节我们上面已经分析过了,这里程序上就非常简单了,写 DR 就是发送,读 DR 就是接收,至于怎么产生波形,怎么判断输入,软件一概不管。
//这里一大段函数,什么智能卡、IrDA、我们也都不用,不用看的
void USART_SendBreak(USART_TypeDef* USARTx);
void USART_SetGuardTime(USART_TypeDef* USARTx, uint8_t USART_GuardTime);
void USART_SetPrescaler(USART_TypeDef* USARTx, uint8_t USART_Prescaler);
void USART_SmartCardCmd(USART_TypeDef* USARTx, FunctionalState NewState);
void USART_SmartCardNACKCmd(USART_TypeDef* USARTx, FunctionalState NewState);
void USART_HalfDuplexCmd(USART_TypeDef* USARTx, FunctionalState NewState);
void USART_OverSampling8Cmd(USART_TypeDef* USARTx, FunctionalState NewState);
void USART_OneBitMethodCmd(USART_TypeDef* USARTx, FunctionalState NewState);
void USART_IrDAConfig(USART_TypeDef* USARTx, uint16_t USART_IrDAMode);
void USART_IrDACmd(USART_TypeDef* USARTx, FunctionalState NewState);
//最后 4 个标志位相关的函数
FlagStatus USART_GetFlagStatus(USART_TypeDef* USARTx, uint16_t USART_FLAG);
void USART_ClearFlag(USART_TypeDef* USARTx, uint16_t USART_FLAG);
ITStatus USART_GetITStatus(USART_TypeDef* USARTx, uint16_t USART_IT);
void USART_ClearITPendingBit(USART_TypeDef* USARTx, uint16_t USART_IT);

这里函数一眼看上去还是挺多的,但是这里面很多都是那些增强功能和兼容其他协议的函数,我们都不会用到,所以总结下来,我们用的函数非常少,而且都是常见函数。

  • 配置 串口通信 模块
  1. 开启时钟,吧需要用的 USART 和 GPIO 的时钟打开
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);//USART1 是 APB2 的外设,其他的都是 APB1 的外设,注意一下
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
  1. 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 使用

TX 引脚是 USART 外设控制的数据输出脚,所以要选复用推挽输出。

RX 引脚是 USART 外设控制的数据输入脚,所以要选择输入模式,输入模式并不分什么普通输入,复用输入。一般 RX 配置时浮空输入或者上拉输入,因为串口波形空闲状态是高电平,所以不使用下拉输入。

一根线只能有一个输出,但可以有多个输入,所以输入脚,外设和 GPIO 都可以同时用,这个引脚模式不清楚的话,还是看一下手册。GPIO 那一节有个推荐的配置表,可以参考一下。

  1. 配置 USART,直接使用一个结构体,就可以把所有的参数都配置好了
USART_InitTypeDef USART_InitStructure;
USART_InitStructure.USART_BaudRate = 9600;//波特率,可以直接写一个波特率的数值
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;//硬件流控制,我们不使用流控,所以选择 None。
USART_InitStructure.USART_Mode = USART_Mode_Tx;//串口模式,可以选择 Tx 发送模式和 Rx 接收模式,那我们这个程序只需要发送功能,所以选择 Tx。
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);
  • 波特率:可以直接写一个波特率的数值。比如 9600,这样就行了,写完之后,这个 Init 函数内部会自动算好 9600 对应的分频系数,然后写到 BRR 寄存器。计算部分我们上面也讲过,所以这里我们就非常方便了,需要什么波特率,直接写就行了。
  • 硬件流控制,这个参数的取值可以是 None 不使用流控、只用 CTS、只用 RTS 或者 CTS 和 RTS 都使用。我们不使用流控,所以选择 None。
  • 串口模式,可以选择 Tx 发送模式和 Rx 接收模式,那我们这个程序只需要发送功能,所以选择 Tx。如果你既需要发送又需要接收,那就用或符号把 Tx 和 Rx 或起来。就跟 GPIO_Pin_1 | GPIO_Pin_2 一样的用法。
  • 校验位,可以选择 No 无检验、Odd 奇校验、Even 偶校验,我们不需要校验,所以选择 No。
  • 停止位,这个参数可以选择 0.5、1、1.5、2,我们选择 1 位停止位。
  • 字长,这个参数可以选择 8 位或 9 位,我们不需要校验,所以字长就选择 8 位。

参数取值一般都是以参数名称作为前缀的,所以复制一下参数名称,再开启代码提示,就可以很方便的选取参数了。如果没提示到合适的参数,那就再跳转定义,看看解释咋说。可能这个参数是需要你自己写的而不是选的。

  1. 如果你只需要发送的功能,就直接开启 USART,初始化就结束了。如果你需要接收的功能,可能还需要配置中断,那就在开启 USART 之前,再加上 ITConfig 和 NVIC 的代码就行了。
USART_Cmd(USART1, ENABLE);

整个初始化流程非常中规中矩,每个函数大家应该都已经很熟悉了,得益于库函数的封装,内部各种细节问题,就不需要我们再关心了。

  • USART 外设使用

那初始化完成之后,如果要发送数据,调用一个发送函数就行了;如果要接收数据,就调用接收的函数。如果要获取发送和接收的状态,就调用获取标志位的函数。这就是 USART 外设的使用思路。

写一个发送数据的函数,调用这个函数,就可以从 Tx 引脚发送一个字节数据

  1. 调用串口的 SendData 函数
USART_SendData(USART1, Byte);

在函数内部,Data&0x01FF,就是把无关的高位清零,然后直接赋值给 DR 寄存器。因为这是写入 DR,所以数据最终通向 TDR,发送数据寄存器,TDR 再传递给发送移位寄存器,最后一位一位的把数据移出到 TX 引脚,完成数据的发送。

在这里可以看出,单片机是一种软件和硬件高度配合的产品,要想学好单片机,程序思维和电路分析都是必不可少的。

  1. 等待标志位

调用这一个库函数,Byte 变量就写入到 TDR 了,写完之后,我们还需要等待一下,等 TDR 的数据转移到移位寄存器,我们才能放心。要不然数据还在 TDR 进行等待,我们再写入数据,就会产生数据覆盖。所以在发送之后,我们还需要等待一下标志位。

while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);//发送数据寄存器空标志位
  1. 标志位清零

参考手册,在状态寄存器里有对 TXE 这一位的描述:对 USART_DR 的写操作,将该位清零。所以这里标志位置 1 之后,不需要手动清零,当我们下一次再 SendData 时,这个标志位会自动清零。

4.1.3 数据模型

就是不同数据模式的具体解释

  • HEX模式/十六进制模式/二进制模式:以原始数据的形式显示,收到什么数据,就把这个数据本身显示出来,在这个模式下,只能显示一个个的 16 进制数。比如 41,42,7A,8B,不能显示文本,比如 HelloWorld,和各种符号,比如 ! , .,如果想显示文本,那就要对一个个的数据进行编码了,这就是文本模式,或者叫做字符模式。
  • 文本模式/字符模式:以原始数据编码后的形式显示,在这种模式下,每一个字节数据,通过查找字符集,编码成一个字符,比如下面这个表展示的就是 ASCII 码字符集。

ASCII 码字符集的大概分布:在这里面,可以看到,0x41 这个数据对应的就是大写字母 A,如果想显示小写字母呢,可以看到右边这里 0x61,对应的就是小写字母 a,如果想显示字符形式的数字,那可以看到 0x30 到 0x30 对应的是字符形式的 0 到 9,然后还有各种各样的标点符号,背后都是对应了一个字节的数据。另外,最前面还有一些不可见字符,比如退格,换行、换页,等等,这些字符可以用于控制文本的打印。最后,字符集的第一个字符,原始数据是 0x00,对应字符是空字符,也就是保留位,不映射任何字符。一般这个 0,经常作为字符串的结束标志位,字符串遇到数据 0x00 之后,就代表字符串结束了。

大家在做数据和字符串的相互转换时,需要查一下这个表,ASCII 码是一种最简单,最常用的字符集。如果想显示和存储汉字的话,也得指定汉字的字符集,由于汉字比较多,所以就需要多个字节才能编码一个汉字,常用的汉字字符集有 GB2312、GBK、GB18030 等等。对应外国也有相应的字符集,比如欧洲的 ASCII 扩展码、日本的编码、韩国的编码等等。随着计算机的发展,全球互相通信,为了防止不同国家编码的不兼容现象,我们可以把所有国家的字符全部收录到一个统一的字符集,这就是 Unicode 字符集,Unicode 最常用的传输形式就是 UTF8。有关字符编码的内容,大家可以自己再去网上搜一搜,如果编码不匹配,就会出现非常烦人的乱码,这个得注意一下。
在这里插入图片描述
然后我们看一下下面这个模式图
在这里插入图片描述
这个图描述的是字符和数据,在发送和接收的转换关系。

  1. 比如最上面,发送 0x41 数据,发送到线路传输的就是 0x41。接收方如果以原始数据形式显示,就是 0x41;如果以字符显示,就是走下面这一路,通过字符集译码。这个译码的字符集就是我们刚才说的 ASCII 码、GBK、UTF8 等等,通过字符集译码,找到字符,然后显示字符 A。
  2. 在发送方,也可以直接发送字符,比如发送字符 A,这时它就会先从字符集找到 A 的数据,进行编码,发现 A 对应的数据是 0x41,最终在线路中传输的必须是 16 进制数,0x41。然后接收方,可以选择查看原始数据 0x41;也可进行译码,得到字符 A。

这就是字符和数据在发送接受过程中经历的变化。

4.1.4 示例代码

Serial.h

#ifndef __SERIAL_H
#define __SERIAL_H

void Serial_Init(void);
void Serial_SendByte(uint8_t Byte);
	
#endif

Serial.cpp

#include "stm32f10x.h"                  // Device header

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 使用
//3.配置 USART,直接使用一个结构体,就可以把所有的参数都配置好了
	USART_InitTypeDef USART_InitStructure;
	USART_InitStructure.USART_BaudRate = 9600;//波特率,可以直接写一个波特率的数值。
	USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;//硬件流控制,我们不使用流控,所以选择 None。
	USART_InitStructure.USART_Mode = USART_Mode_Tx;//串口模式,可以选择 Tx 发送模式和 Rx 接收模式,那我们这个程序只需要发送功能,所以选择 Tx。
	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_Cmd(USART1, ENABLE);
}

//写一个发送数据的函数,调用这个函数,就可以从 Tx 引脚发送一个字节数据
void Serial_SendByte(uint8_t Byte) {
	//1.调用串口的 SendData 函数
	USART_SendData(USART1, Byte);

	//2.等待标志位
	while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);//发送数据寄存器空标志位
	
	//3.标志位会自动清零。
}


main.c

#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "Serial.h"

int main(void) {
	OLED_Init();
	
	Serial_Init();//初始化串口
	
	Serial_SendByte(0x41);//调用串口发送一个 0x41,调用这个函数之后,Tx 引脚就会产生一个 0x41 对应的波形

	Serial_SendByte('A');//以字符的方式发送,它先会对 A 进行编码,找到数据 0x41,最终发送的其实还是 0x41
	
	while(1){

	}
}

Serial_SendByte(0x41);

  1. 调用串口发送一个 0x41,调用这个函数之后,Tx 引脚就会产生一个 0x41 对应的波形。这个波形可以发送给其他支持串口模块,也可以通过 USB 转串口模块,发送到电脑端。我们本节主要是和电脑通信,所以是在电脑端接收数据。我们下载程序后,按一下复位键,这时可以看到串口模块的接收指示灯闪了一下,说明有波形发过来了。那在电脑端,我们需要打开串口助手这个软件,来查看接收到的数据。

  2. 在工具软件目录下有一个串口助手软件。旁边是工程源码,感兴趣的话可以学习一下。那我们打开串口助手这个软件。

  • 串口号:选择我们电脑里的设备管理器里的 COM 号,两个 COM 号保持一致。(这里必须要插上串口且串口是空闲状态,才能扫描到对应的串口号。如果没有显示到串口号,那需要你先检查一下设备管理器,看是不是有设备,然后再关掉其他串口软件,防止串口号被占用了。)
  • 下面的参数:需要和你 STM32 初始化这里的配置保持一致。双方约定的参数必须一致,要不然接收就会解析错误。
  • 打开串口,这样电脑的串口就准备就绪了。
  1. 然后我们再按一次复位键,可以看到,这个数据 41,就接收到了。复位一次,接收到一个 41。这就是发送一个字节数据的现象。

  2. 然后大家注意到,下面这里有一个接收模式,目前选择的是 HEX 模式,也就是以原始数据的形式显示,发送 41,显示就是 41 本身。如果我们想显示一下字符串,怎么办呢?那就可以选择文本模式,这样就是以字符的形式显示。再发送试一下,可以看到,目前 41 这个数据,就解析成了字符 A

Serial_SendByte('A');

  1. 以字符的方式发送,它先会对 A 进行编码,找到数据 0x41,最终发送的其实还是 0x41。
  2. 我们编译下载看一下,这个现象其实和刚才是一样的,因为在线路中传输的数据本身 0x41,并没有改变。

数据还是字符,只是数据的一种表现形式而已。

这就是发送一个字节数据,或者发送一个字符的程序现象。

4.1.5 函数模块封装

这些函数大家之后用串口肯定会经常用到。光有一个发送字节函数,功能太简单,满足不了需求。

接下来我写的函数其实都是对 SendByte 的封装。是纯软件的内容。

  1. 发送一个数组

比如我们有一个很大的数组,需要通过串口发送到电脑,那就需要一个发送数组的函数。

void Serial_SendArray(uint8_t* Array, uint16_t Length) {//指向待发送数组的首地址
	uint16_t i;
	for (i = 0; i < Length; i++) {
		Serial_SendByte(Array[i]);
	}
}

这样就可以把 数组一次性发送过去了,非常方便。

SendArray 一般应用在 HEX 模式下。

  1. 发送一个字符串
void Serial_SendString(char* str) {//由于字符串自带一个结束标志位,所以就不需要再传递长度参数了
	uint8_t i;
	for (i = 0; str[i] != '\0'; i++ ) {//数据 0 对应空字符,这里 数据 0 也可以写成字符的形式,就是 '\0',这里就以转义字符的形式来写吧。
		Serial_SendByte(str[i]);
	}
}
  • 由于字符串自带一个结束标志位,所以就不需要再传递长度参数了
  • 数据 0 对应空字符,是字符串结束标志位,如果不等于 0,就是还没结束,进入循环。如果等于 0,就是结束了,停止循环。这里 数据 0 也可以写成字符的形式,就是 ‘\0’,这就是空字符的转义字符表示形式。和直接写 0,最终效果是一样的。
  1. 发送字符形式的数字

//次方函数
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 的个位、十位、百位等等,以十进制拆分开
	uint8_t i;
	for (i = 0; i < Length; i++) {
		//然后转换成字符数字对应的数据(以字符的形式显示,所以需要加一个偏移),依次发送出去
		Serial_SendByte(Number / Serial_Pow(10, Length - i - 1) % 10 + '0');//以十进制从高位到低位依次发送
	}
}

  1. 把 Number 的个位、十位、百位等等,以十进制拆分开。怎么以十进制拆分开:
    取某一位就是 数字 / 10 ^ x % 10。/ 10 ^ x 就是把这一位的右边去掉,%10 就是把左边去掉。这就是拆分数字的思路。
  2. 然后转换成字符数字对应的数据,以十进制从高位到低位依次发送。(以字符的形式显示,所以需要加一个偏移,可以看一下 ASCII 码表,字符 0 对应的数据是 0x30,所以这里还得加上一个 0x30,或者以字符的形式写,就是’0’)

这就是发送数据的逻辑,其实这两个函数 SendString 和 SendNumber 和 OLED 的 ShowString 和 ShowNumber 是一样的逻辑,可以对照 OLED 代码看看,都是一样的。

SendString 配合 Serial_SendNumber,就可以显示各种字符和数字了,当然这里 SendNumber 默认是十进制(无符号)显示,其他进制的,可以参考一下 OLED 的代码,这里就不再演示了

那这些就是串口常用的模块函数。

4.1.6 printf 函数移植

那最后,再给大家介绍一下 printf 函数的移植方法。

  1. 使用 printf 之前,我们需要打开工程选项,在 Target 目录中把 Use MicroLIB 勾上,MicroLIB 是 Keil 为嵌入式平台优化的一个精简库,我们等会要用到 printf 函数就可以用这个 MicroLIB。
  2. 然后我们还需要对 printf 进行重定向,将 printf 函数打印的东西输出到串口。因为 printf 函数默认是输出到屏幕,我们单片机没有屏幕,所以要进行重定向。步骤就是:
    1. 在串口模块里了(Serial.cpp),最开始加上 #include <stdio.h>
    2. 之后,在后面重写 fputc 函数,在里面,我们要把 fputc 重定向到串口。
int fputc(int ch, FILE* f) {//这是 fputc 函数的原型,这些参数上面的,按照我这样写就行,不需要管那么多
	//把 fputc 重定向到串口
	Serial_SendByte(ch);
	return ch;
}

那重定向 fputc 和 printf 有什么关系呢?
这是因为,这个 fputc 是 printf 函数的底层,printf 函数在打印的时候,就是不断调用 fputc 函数一个个打印的。我们把 fputc 函数重定向到了串口,那 printf 自然就输出到串口了。

好,这样,printf 就移植好了。

接下来再介绍两种 printf 函数移植方法,刚才这种方法,printf 只能有一个。你重定向到串口 1 了,那串口 2 就没有了。

  • 如果多个串口都想用 printf 怎么办呢,这时就可以用 sprintf。

sprintf 可以把格式化字符输出到一个字符串里,所以这里可以先定义一个字符串:char str[100];

然后 sprintf,第一个参数,是打印输出的位置,我们指定打印到 str,之后就跟 printf 一样了。sprintf(str, "Num = %d\r\n", 666);

目前这个格式化的字符串在 str 里,最后需要再来一个 Serial_SendString(str); 把字符串 str 通过串口发送出去,这样就完成了。因为 sprintf 可以指定打印位置,不涉及重定向的东西,所以每个串口都可以用 sprintf 进行格式化打印。

那最后,再介绍一种方法,sprintf 每次都得先定义字符串,再打印到字符串,再发送字符串,要麻烦了,要是能封装一下这个过程,就再好不过了。

  • 所以第三种方法就是封装 sprintf。

由于 printf 这类函数比较特殊,它支持可变的参数,像我们之前写的函数,参数的个数都是固定的,可变参数执行起来比较复杂,如果想深入学习的话,可以百度搜索 C 语言可变参数 学习一下。这里只介绍封装的步骤了。

首先在串口模块里,先添加头文件 #include <stdarg.h>

然后在最后,对 sprintf 函数进行封装

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 发送出去
}

这样就把 printf 这种可变参数的格式封装好了。这里面出现了很多没见过的函数,如果没学过这种用法,可能比较难理解,这个也没关系,知道这样移植就行了。如果学过了,那其实是基本操作。大家也可以学学这种可变参数的用法。自己写其他函数的时候也可以用,还是非常高级的。

  • format:用来接收格式化字符串
  • …:这部分 用来接收后面的可变参数列表

最后将函数在头文件中声明一下,在主函数中调用 Serial_Printf,这样也可以实现 printf 的功能。

以上就是 printf 函数的移植方法了,最常见的是第一种,如果你有多个 printf 的需求,可以了解一下后两种方法。

最后,再介绍一个显示汉字的操作方法。printf 打印函数,经常会乱码,这里给大家提供几种解决方案。

目前,我们这个汉字编码格式,选的是 UTF8,所以最终发送到串口,汉字会以 UTF8 的方式编码。最终串口助手,也得选择 UTF8,才能解码正确。

先说一下 UTF8 不乱码的方案。

比如我写个字符串:Serial_Printf("你好,世界"); 不过这样直接写汉字,编译器有时候会报错,这里需要打开工程选项,C/C++ 目录下,在杂项控制栏(Misc Controls)写上 --no-multibyte-chars,需要给编译器输入一个这样的参数,注意别写错了。然后 OK,这样编译就没问题了。

下载后在串口助手这里,目前是乱码。译码方式要选择 UTF8,这样汉字就没问题了。这就是 UTF8 的解决方案。

但是 UTF8 可能有些软件兼容性不好。所以第二种方式就是切换为 GB2312 编码,打开配置,Encoding 选择 GB2312,这是汉字的编码方式,OK。目前这个文件编码格式其实还是 UTF8。我们需要把汉字删掉,再把文件关掉,再打开文件,等字体变为宋体了,编码格式才算改过来。然后再写 Serial_Printf("你好,世界"); 这样编码就是 GB2312。

下载后,在串口助手这里选择 GBK 编码,一般 Windows 软件默认就是 GBK 的编码,GBK 和 GB2312 一样,都是中文的编码,基本都是兼容的。再复位,可以看到,这样也是没问题的。

所以总结下就是:要么 Keil 和 串口助手都选择 UTF8,且 Keil 加上 --no-multibyte-chars 参数;要么都是用 GB 开头的中文编码格式,参数不用加的,如果你已经有很多选好编码格式的工程了。可以使用转码软件进行批量转码,记得关闭文件的只读,这个了解一下。这就是汉字乱码的解决方案。

本节工程的编码格式选择了 UTF8,既然最开始选择了 UTF8,再多乱码也不离不弃。对于英文来说,任何编码都是兼容的,所以英文怎么选都不会乱码。

最终 main.c 函数

#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "Serial.h"

int main(void) {
	OLED_Init();
	
	Serial_Init();//初始化串口
	
	Serial_SendByte(0x41);//调用串口发送一个 0x41,调用这个函数之后,Tx 引脚就会产生一个 0x41 对应的波形
	
//	Serial_SendByte('A');//以字符的方式发送,它先会对 A 进行编码,找到数据 0x41,最终发送的其实还是 0x41

	uint8_t myArray[] = {0x42, 0x43, 0x44, 0x45};
	Serial_SendArray(myArray, 4);//可以看到目前显示 HEX 模式的 42 43 44 45 
	
	Serial_SendString("\r\nNum1=");//可以看到目前显示文本形式的 Num1=
	
	Serial_SendNumber(111, 3);//可以看到目前显示文本形式的 111
	
	printf("\r\nNum2=%d", 222);//可以看到显示文本形式的 Num2=222,printf 函数移植没有问题。

	char str[100];
	sprintf(str, "\r\nNum3=%d", 333);
	Serial_SendString(str);

	Serial_Printf("\r\nNum4=%d", 444);
	Serial_Printf("\r\n");
	
	while(1){

	}
}
  • 在写完字符串之后,编译器会自动补上结束标志位,所以字符串的存储空间,会比字符的个数大 1。
  • 可以用转义字符 \r\n 来执行换行的命令。这里注意需要用 \r\n 两个转义字符才能执行换行,这两个转义字符在 ASCII 码表里也可以查到,都是不可见的控制字符,这样每次打印之后,都会执行一次换行命令。

有关串口发送的代码,到这里就结束了。接下来我们来学习一下串口的接收。

4.2 串口发送 + 接收

4.2.1 硬件电路图

在这里插入图片描述
接线这里,和串口发送是一样的,我们已经把 TX 和 RX 的线都接好了。

4.1.2 串口接收模块初始化

我们在串口发送工程的基础上,加上接收的代码。

  • 串口模块初始化中加入接收部分
//1.首先是 GPIO 口,我们要使用 RX 的引脚,在引脚定义表里,我们知道 USART1 的 RX 复用在 PA10 引脚。所以这里需要再初始化下 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);//这样就是把 PA10 配置好,供 USART1 的 RX 使用

//2.将 USART 初始化模块的串口模式修改
USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;//如果既需要发送又需要接收,那就用或符号把 Tx 和 Rx 或起来。如果只需要接收,那就把 USART_Mode_Tx  去掉就行了。

然后串口接收的代码其实就配置差不多了。对于串口接收来说,可以使用查询和中断两种方法。如果使用查询,那初始化就结束了;如果使用中断,那还需要在初始化部分开启中断,配置 NVIC。

这里我就先演示下查询,再演示一下中断。

查询流程:
在主函数里不断的判断 RXNE 标志位,如果置 1 了,就说明收到数据了,那再调用 ReceiveData,读取 DR 急促请你,这样就行了。我在主函数直接演示一下,就不再封装了。

uint8_t RxData;
int main(void) {
	OLED_Init();
	
	Serial_Init();//初始化串口
	
	while(1){
		if (USART_GetFlagsStatus(USART1, USART_FLAG_RXNE) == SET) {
			RxData = USART_ReceiveData(USART1);//目前接收到的一个字节数据就已经在 RxData 里了
			OLED_ShowHexNum(1, 1, RxData, 2);
		}
		//清除标志位:参考手册里 RXNE 标志位的说明:对 USART_DR 的读操作可以将该位清零,这里读 DR 可以自动清零标志位。所以不需要我们再手动清除标志位了。
	}
}

这里串口助手我们需要在发送区里写入数据,发送模式可以选择 HEX 模式或文本模式,HEX 就是原始数据,文本模式首先要过一遍字符编码。在 HEX 模式下,发送区只能写 16 进制数,也就是 0~9,A~F,不用写 0x 的,两个数为一组,非法字符都将会被忽略。

发送 41,在 OLED 显示屏上就可以看到数据 41;发送 AF,在 OLED 显示屏上可以看到数据 AF;这就是查询方法的串口接收程序现象。如果你程序比较简单,查询方法是可以考虑的。

那接下来我们再演示一下中断方法的程序。如何使用中断呢?
首先,初始化这里,我们要加上开启中断的代码:

//1.配置中断
USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);//开启 RXNE 标志位到 NVIC 的输出
//2. 配置 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

然后,在最下面实现 中断函数

uint8_t Serial_RxData;
uint8_t Serial_RxFlag;

void USART1_IRQHandler(void) {
	//先判断标志位
	if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET) {//如果 RXNE 确实置 1 了,就进 if
		//之后在这里面,可以直接读取 DR,执行一些操作,当然由于这个代码是在模块里,不太适合加入过多其他的代码,比较适合弄个封装,大家可以根据自己的需求来
		Serial_RxData = USART_ReceiveData(USART1);
		Serial_RxFlag = 1;
		
		//如果读取了 DR,就可以自动清除标志位,如果没读取 DR,就需要手动清除,我们这里直接手动清除
		USART_ClearITPendingBit(USART1, USART_IT_RXNE);
	}
		
}
  • 在中断函数里面,可以直接读取 DR,执行一些操作,当然由于这个代码是在模块里,不太适合加入过多其他的代码,比较适合弄个封装,大家可以根据自己的需求来
  • 如果读取了 DR,就可以自动清除标志位;如果没读取 DR,就需要手动清除。

然后这两个变量也封装一个 Get 函数,大家也可以把这两个变量 extern 出去,都是可以的。

uint8_t Serial_GetRxFlag(void) {
	if (Serial_RxFlag == 1) {
		Serial_RxFlag = 0;
		return 1;
	}
	return 0;
}

uint8_t Serial_GetRxData(void) {
	return Serial_RxData;
}

然后把这两个 Get 函数放在头文件中声明一下。

uint8_t Serial_GetRxFlag(void);
uint8_t Serial_GetRxData(void);

好到这里,中断接收和变量的封装,我们就完成了,其实这里我就是在中断里,把数据进行了一次转存,最终还是要扫描查询这个 RxFlag,来接收数据的。对于这种单字节接收来说,可能转存一下意义不大。这里这样写,主要是给大家演示一下中断接收的操作方法。另外也是为我们下一节多字节数据包接收做一个铺垫。

最后在主函数里,就可以改成我们自己的函数

uint8_t RxData;
int main(void) {
	OLED_Init();
	
	Serial_Init();//初始化串口
	
	while(1){
		if (Serial_GetRxFlag() == 1) {
			RxData = Serial_GetRxData();//这样,代码的功能和之前演示的也是一样
			
			OLED_ShowHexNum(1, 1, RxData, 2);
		}
	}
}

4.1.3 示例代码

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* str);
void Serial_SendNumber(uint32_t Number, uint8_t Length);
void Serial_Printf(char* format, ...);
uint8_t Serial_GetRxFlag(void);
uint8_t Serial_GetRxData(void);
	
#endif

Serial.cpp

#include "stm32f10x.h"                  // Device header
#include "stdio.h"
#include "stdarg.h"

uint8_t Serial_RxData;
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 或起来。
	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 申请中断
}

//写一个发送数据的函数,调用这个函数,就可以从 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,最终效果是一样的,这里就以转义字符的形式来写吧。
		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 的个位、十位、百位等等,以十进制拆分开
	uint8_t i;
	for (i = 0; i < Length; i++) {
		//然后转换成字符数字对应的数据(以字符的形式显示,所以需要加一个偏移),依次发送出去
		Serial_SendByte(Number / Serial_Pow(10, Length - i - 1) % 10 + '0');//以十进制从高位到低位依次发送
	}
}

int fputc(int ch, FILE* f) {//这是 fputc 函数的原型,这些参数上面的,按照我这样写就行,不需要管那么多
	//把 fputc 重定向到串口
	Serial_SendByte(ch);
	return ch;
}

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;
}

uint8_t Serial_GetRxData(void) {
	return Serial_RxData;
}


void USART1_IRQHandler(void) {
	//先判断标志位
	if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET) {//如果 RXNE 确实置 1 了,就进 if
		//之后在这里面,可以直接读取 DR,执行一些操作,当然由于这个代码是在模块里,不太适合加入过多其他的代码,比较适合弄个封装,大家可以根据自己的需求来
		Serial_RxData = USART_ReceiveData(USART1);
		Serial_RxFlag = 1;
		//然后这两个变量也封装一个 Get 函数,大家也可以把这两个变量 extern 出去,都是可以的。
		
		//如果读取了 DR,就可以自动清除标志位,如果没读取 DR,就需要手动清除,我们这里直接手动清除
		USART_ClearITPendingBit(USART1, USART_IT_RXNE);
	}
		
}

main.c

#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "Serial.h"

uint8_t RxData;

int main(void) {
	OLED_Init();
	OLED_ShowString(1, 1, "RxData:");
	
	Serial_Init();//初始化串口
	
	
	while(1){
		if (Serial_GetRxFlag() == 1) {
			RxData = Serial_GetRxData();//这样,代码的功能和之前演示的也是一样
			Serial_SendByte(RxData);//然后我们再加一个数据回传功能,把接收到的这一字节数据回传给电脑,这就是目前程序的全部功能。
			
			OLED_ShowHexNum(1, 8, RxData, 2);
		}
	}
}

然后我们再加一个数据回传功能,把接收到的这一字节数据回传给电脑,这就是目前程序的全部功能。

在串口助手中,发送接收都设置成 HEX 模式。在发送区 发送 41,屏幕显示 41,串口接收回传的数据 41 在接收区显示。这就是这个程序的现象。

目前这里仅支持一个字节的接收,这个功能比较简单;那现在很多模块,都需要回传大量数据,这时,就需要用数据包的形式进行传输,接收部分也需要按照数据包的格式来接收,这样才能接收多字节数据包。

数据包的发送和接收也是比较常见和重要的内容,有关串口这部分进阶的内容,我们下一小节还会继续来为大家介绍。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值