admin管理员组

文章数量:1638233

江科大STM32学习笔记(下)

  • 前置知识
    • 串行与并行
    • 同步与异步通讯
    • 通讯速率
  • USART串口
    • 物理层
      • 电平标准
      • RS-232信号线
    • 协议层
      • 波特率
      • 通讯的起始和停止信号
      • 有效数据
      • 数据校验
      • 串口时序
    • STM32的USART串口
      • USART框图详解
      • USART基本结构
      • 几个小细节
      • 代码实战:串口发送&&串口发送+接受
    • USART串口数据包
      • 代码实战:串口收发HEX数据包&&串口收发文本数据包
  • I2C通信协议
    • 物理层
    • 协议层
      • I2C基本读写过程
      • I2C的硬件电路
      • I2C时序设计
        • 起始和终止条件
        • 发送一个字节
        • 接收一个字节
        • 发送应答和接收应答
      • 应用:
        • I2C从机地址
        • 指定地址写
        • 当前地址读
        • 指定地址读
      • 代码实战:10-1 软件I2C读写MPU6050
    • I2C通信外设
      • I2C框图
      • I2C基本结构
      • 代码实战:10-2硬件I2C读写MPU6050
  • SPI通信协议
    • SPI物理层
    • SPI协议层
      • SPI基本通讯过程
      • 1.通讯的起始和停止信号
      • 2.数据有效性
      • 3.CPOL/CPHA及通讯模式
    • STM32的SPI特性及架构
      • 1.STM32的SPI外设简介
      • 2. STM32的SPI架构剖析
        • 1.通讯引脚
        • 2. 时钟控制逻辑
        • 3. 数据控制逻辑
        • 4. 整体控制逻辑
      • 3.通讯过程
      • 4.实战——读写串行FLASH
        • 1.硬件连接
        • 2.软件设计
  • BKP备份寄存器&RTC实时时钟
    • Unix时间戳
    • 时间戳转换
    • BKP
      • 简介
      • 基本结构
    • RTC
      • 简介
      • 框图
      • RTC基本结构
      • RTC硬件电路
        • 备用电池供电
        • 外部低速晶振
        • RTC操作注意事项
    • 代码实战:读写备份寄存器&事实时钟
  • PWR电源控制
    • 电源
    • 电源管理器
      • 上电复位和掉电复位
    • 可编程电压监测器
    • 低功耗模式
      • 睡眠模式
      • 停机模式
      • 待机模式
    • 代码实战:修改主频&睡眠模式&停止模式&待机模式
  • WDR看门狗
    • 独立看门狗
      • IWDG功能框图剖析
        • IWDG键寄存器扩展
      • 怎么用IWDG
    • 窗口独立狗
      • 主要特性
      • 功能描述
      • WWDG超时时间
      • IWDG和WWDG对比
    • 代码实战:独立看门狗&窗口看门狗
  • FLASH闪存
    • 介绍
    • 闪存模块组织
    • FLASH基本结构
        • FLASH解锁
        • 使用指针访问存储器
        • 程序存储器全擦除
        • 程序存储器页擦除
        • 程序存储器编程
      • 选项字节
    • 代码实战:读写内部FLASH&读取芯片 ID


江科大STM32学习笔记(上)
江科大STM32学习笔记(下)


前置知识

相当于专有名词解释

串行与并行

数字数据通信接口可以分为两大类:串行接口和并行接口
  串行通信,又称为逐位传输(Bit-by-Bit Transmission),是指按顺序逐个传输数据位的通信方式。在串行通信中,数据位按照顺序逐一传输,通过传输线进行数据传输。虽然传输速度较慢,但实现简单。串行通信常用于长距离的数据传输,如串口、USB接口等。

  并行通信是一种同时传输多个数据位的通信方式,也称为同时传输多个数据位(Word-by-Word Transmission)。在并行通信中,数据被分成多个并行传输,同时通过多个传输线进行数据传输。虽然传输速度快,但实现起来较为复杂。并行通信常用于短距离的数据传输,如计算机内部数据总线等。
  并行数据传输,可以将一个完整的字节(单词或更大的数据)一下子从发送器传输到了接收器。如你所料,并行接口比串行接口快得多,因为并行-串行和串行-并行的解/译码步骤被省略了。而并行传输的缺点是:需要足够数量的传输线(导线)来传输单独的数字。

同步与异步通讯

  根据通讯的数据同步方式,又分为同步和异步两种,可以根据通讯过程中是否有使用到时钟信号进行简单的区分
  在同步通讯中,收发设备双方会使用一根信号线表示时钟信号,在时钟信号的驱动下双方进行协调, 同步数据,见图 同步通讯 。 通讯中通常双方会统一规定在时钟信号的上升沿或下降沿对数据线进行采样。
   同步通信的数据帧组成一般是:同步信号+若干数据。在最前面是个同步信号,接收端接收数据分析出同步信号之后,就认为后边的数据都是实际传输的数据了。理论上来说同步通信一个数据帧里面的若干数据的位数是不受限制的。
  同步通信中,数据之间是不能有间隔的,因为双方在同一个时钟下工作,这边接收的,必然是另一边发送的。在同步信号之后,认为所有的数据都是实际数据,所以当没有信息要传输是,同步信号要填上空字符。

  异步通信是一种常用的通信方式,发送字符之间的时间间隔可以是任意的。在异步通讯中不使用时钟信号进行数据同步,它们直接在数据信号中穿插一些同步用的信号位,或者把主体数据进行打包, 以数据帧的格式传输数据,某些通讯中还需要双方约定数据的传输速率,以便更好地同步。
   异步通信在发送字符时,所发送的字符之间的时间间隔可以是任意的。因为每一帧的数据都有开始和停止位,他们之间的数据位才是实际数据。所以接收方评判数据是否为完整的一帧数据的方式就是分析这一堆数据中的开始位和停止位。发送端可以在任意时刻开始发送字符,接收端必须时刻做好接收的准备。因为每传输一个数据帧都会有一个开始位和一个停止位,实际数据一般只占到5-8位,这就导致了异步通信的传输效率较低。

同步与异步通信区别

1.同步通信要求接收端和发送端时钟频率一致,而异步通信不要求时钟同步。
2.同步通信效率高,异步通信效率较低。
3.同步通信较复杂,时钟允许误差较小,而异步通信相对简单,时钟可允许一定误差。
4.同步通信可用于点对多点,而异步通信只适用于点对点。

  补充:I2C和SPI由于具有独立的时钟线,因此它们是同步的。在时钟信号的指引下,接收方可以采样数据。然而,串口、CAN和USB没有时钟线,因此需要双方约定一个采样频率,这就是异步通信。为了对齐采样位置,还需要添加一些帧头和帧尾等标识。
同步靠时钟线,异步靠比特率

通讯速率

  衡量通讯性能的一个非常重要的参数就是通讯速率,通常以比特率(Bitrate)来表示,即每秒钟传输的二进制位数, 单位为比特每秒(bit/s)。
  容易与比特率混淆的概念是波特率(Baudrate),它表示每秒钟传输了多少个码元。 而码元是通讯信号调制的概念,通讯中常用时间间隔相同的符号来表示一个二进制数字,这样的信号称为码元。 如常见的通讯传输中,用0V表示数字0,5V表示数字1,那么一个码元可以表示两种状态0和1,所以一个码元等于一个二进制比特位, 此时波特率的大小与比特率一致;如果在通讯传输中,有0V、2V、4V以及6V分别表示二进制数00、01、10、11, 那么每个码元可以表示四种状态,即两个二进制比特位,所以码元数是二进制比特位数的一半,这个时候的波特率为比特率的一半。
   因为很多常见的通讯中一个码元都是表示两种状态,人们常常直接以波特率来表示比特率,虽然严格来说没什么错误,但希望您能了解它们的区别。

  在计算机科学里,大部分复杂的问题都可以通过分层来简化。如芯片被分为内核层和片上外设;STM32标准库则是在寄存器与用户代码之间的软件层。 对于通讯协议,我们也以分层的方式来理解,最基本的是把它分为物理层和协议层 。物理层规定通讯系统中具有机械、电子功能部分的特性, 确保原始数据在物理媒体的传输。协议层主要规定通讯逻辑,统一收发双方的数据打包、解包标准。 简单来说物理层规定我们用嘴巴还是用肢体来交流,协议层则规定我们用中文还是英文来交流。

USART串口

注意:在串口助手的接收模式中有文本模式和HEX模式两种模式,那么它们有什么区别?
  文本模式和Hex模式是两种不同的文件编辑或浏览模式,不是完全相同的概念。文本模式通常是指以ASCII编码格式表示文本文件的编辑或浏览模式。在文本模式下,文本文件的内容以可读的字符形式显示,包括字母、数字、符号等,这些字符被转换为计算机能够识别和处理的二进制编码。而Hex模式则是指以十六进制编码格式显示文件内容的编辑或浏览模式。在Hex模式下,文件的内容以16进制数值的形式显示,每个字节(byte)用两个十六进制数表示,从0x00到0xFF,可以查看文件的二进制编码,包括数据、指令、标志位等信息。因此,虽然文本模式和Hex模式都是用于文件编辑或浏览的模式,但它们的显示和处理方式不同,用途也不同。

STM32如何才能获取到陀螺仪、蓝牙器等这些外挂模的数据呢

  这就需要我们在这两个设备之间,连接上一根或多根通信线,通过通信线路发送或者接收数据,完成数据交换,从而实现控制外挂模块和读取外挂模块数据的目的。所以在这里,通信的目的是,将一个设备的数据传送到另一个设备,单片机有了通信的功能,就能与众多别的模块互联,极大地扩展了硬件系统。

下面我们分别对串口通讯协议的物理层及协议层进行讲解。

物理层

  串口通讯的物理层有很多标准及变种,我们主要讲解RS-232标准 ,RS-232标准主要规定了信号的用途、通讯接口以及信号的电平标准。
  使用RS-232标准的串口设备间常见的通讯结构见图 串口通讯结构图 。

  在上面的通讯方式中,两个通讯设备的“DB9接口”之间通过串口信号线建立起连接,串口信号线中使用“RS-232标准”传输数据信号。 由于RS-232电平标准的信号不能直接被控制器直接识别,所以这些信号会经过一个“电平转换芯片”转换成控制器能识别的“TTL标准”的电平信号,才能实现通讯。

电平标准

  根据通讯使用的电平标准不同,串口通讯可分为TTL标准及RS-232标准,见表 TTL电平标准与RS232电平标准 。


  使用RS232与TTL电平校准表示同一个信号时的对比见图 RS-232与TTL电平标准下表示同一个信号 。
  因为控制器一般使用TTL电平标准,所以常常会使用MAX3232芯片对TTL及RS-232电平的信号进行互相转换。

RS-232信号线

  在最初的应用中,RS-232串口标准常用于计算机、路由与调制调解器(MODEN,俗称“猫”)之间的通讯 ,在这种通讯系统中, 设备被分为数据终端设备DTE(计算机、路由)和数据通讯设备DCE(调制调解器)。我们以这种通讯模型讲解它们的信号线连接方式及各个信号线的作用。
  在旧式的台式计算机中一般会有RS-232标准的COM口(也称DB9接口),见图 电脑主板上的COM口及串口线.


  其中接线口以针式引出信号线的称为公头,以孔式引出信号线的称为母头。在计算机中一般引出公头接口,而在调制调解器设备中引出的一般为母头,使用上图中的串口线即可把它与计算机连接起来。通讯时,串口线中传输的信号就是使用前面讲解的RS-232标准调制的。
  在这种应用场合下,DB9接口中的公头及母头的各个引脚的标准信号线接法见图 DB9标准的公头及母头接法 及表 DB9信号线说明 。


  上表中的是计算机端的DB9公头标准接法,由于两个通讯设备之间的收发信号(RXD与TXD)应交叉相连, 所以调制调解器端的DB9母头的收发信号接法一般与公头的相反,两个设备之间连接时,只要使用“直通型”的串口线连接起来即可, 见图 计算机与调制调解器的信号线连接 。

  串口线中的RTS、CTS、DSR、DTR及DCD信号,使用逻辑 1表示信号有效,逻辑0表示信号无效。 例如,当计算机端控制DTR信号线表示为逻辑1时,它是为了告知远端的调制调解器,本机已准备好接收数据,0则表示还没准备就绪。
  在目前的其它工业控制使用的串口通讯中,一般只使用RXD、TXD以及GND三条信号线, 直接传输数据信号,而RTS、CTS、DSR、DTR及DCD信号都被裁剪掉了。

协议层

  串口通讯的数据包由发送设备通过自身的TXD接口传输到接收设备的RXD接口。在串口通讯的协议层中, 规定了数据包的内容,它由启始位、主体数据、校验位以及停止位组成,通讯双方的数据包格式要约定一致才能正常收发数据, 其组成见图 串口数据包的基本组成 。

串口中,每一个字节都装载在一个数据帧里面,每个数据帧都由起始位、数据位和停止位组成.

波特率

  本章中主要讲解的是串口异步通讯,异步通讯中由于没有时钟信号(如前面讲解的DB9接口中是没有时钟信号的), 所以两个通讯设备之间需要约定好波特率,即每个码元的长度,以便对信号进行解码, 图 串口数据包的基本组成中用虚线分开的每一格就是代表一个码元。常见的波特率为4800、9600、115200等。
  例如,如果每隔1秒发送一位,那么接收方也必须每隔1秒接收一位。如果接收方过早接收,则可能会重复接收某些位;如果接收方过晚接收,则可能会错过某些位。因此,发送方和接收方必须约定好传输速率,这个速率参数,就是波特率。那反应到波形上,比如我们双方规定波特率为1000bps,那就表示,1s要发1000位,每一位的时间就是1ms,发送方每隔1ms发送一位,接收方每隔1ms接收一位,这就是波特率,它决定了每隔多久发送一位。

通讯的起始和停止信号

  • 起始位:
      它是标志一个数据帧的开始,固定为低电平。首先,串口的空闲状态是高电平,也就是没有数据传输的时候,然后需要传输的时候,必须要先发送一个起始位,这个起始位必须是低电平,来打破空闲状态的高电平,产生一个下降沿。这个下降沿,就告诉接收设备,这一帧数据要开始了。如果没有起始位,那当我发送8个1的时候,是不是数据线就一直都是高电平,没有任何波动,对吧。这样,接收方怎么知道我发送数据了呢。
  • 停止位:
      同理,在一个字节数据发送完成后,必须要有一个停止位,这个停止位的作用是,用于数据帧间隔,固定为高电平。同时这个停止位,也是为下一个起始位做准备的,如果没有停止位,那当我数据最后一位是0的时候,下次再发送新的一帧,是不是就没法产生下降沿了,对吧。这就是起始位和停止位的作用。起始位固定为0,产生下降沿,表示传输开始;停止位固定为1,把引脚恢复成高电平,方便下一次的下降沿,如果没有数据了,正好引脚也为高电平,代表空闲状态。
  • 数据位:
      这里数据位表示数据帧的有效载荷,1为高电平,0为低电平,低位先行。比如我要发送一个字节,是0x0F,那就首先把0F转换为二进制,就是0000 1111,然后低位先行,所以数据要从低位开始发送,也就是1111 0000,像这样,依次放在发送引脚上。所以说如果你想发0x0F这一个字节数据,那就按照波特率要求,定时翻转引脚电平,产生一个这样的波形就行了。

有效数据

  在数据包的起始位之后紧接着的就是要传输的主体数据内容,也称为有效数据,有效数据的长度常被约定为5、6、7或8位长。

数据校验

  最后看一下校验位,它的用途是,用于数据验证,是根据数据位计算得来的。这里串口,使用的是一种叫奇偶校验的数据验证方法,奇偶校验可以判断数据传输是不是出错了。如果数据出错了,可以选择丢弃或者要求重传,校验可以选择3种方式,无校验、奇校验和偶校验。无校验,就是不需要校验位,波形就是左边这个,起始位、数据位、停止位,总共3个部分。

  奇校验要求有效数据和校验位中“1”的个数为奇数,比如一个8位长的有效数据为:01101001,此时总共有4个“1”, 为达到奇校验效果,校验位为“1”,最后传输的数据将是8位的有效数据加上1位的校验位总共9位。
  偶校验与奇校验要求刚好相反,要求帧数据和校验位中“1”的个数为偶数, 比如数据帧:11001010,此时数据帧“1”的个数为4个,所以偶校验位为“0”。
  0校验是不管有效数据中的内容是什么,校验位总为“0”,1校验是校验位总为“1”。

  当然奇偶校验的检出率并不是很高,比如如果有两位数据同时出错。奇偶特性不变,那就校验不出来了,所以奇偶校验只能保证一定程度上的数据校验。如果想要更高的检出率,可以了解一下CRC校验,这个校验会更加好用,当然也会更复杂。我们这个STM32内部也有CRC的外设,可以了解一下,那到这里,串口的时序我们就了解了。

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

串口时序


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

STM32的USART串口

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

  串行通信一般是以帧格式传输数据,即是一帧一帧的传输,每帧包含有起始信号、数据信息、停止信息, 可能还有校验信息。USART就是对这些传输参数有具体规定,当然也不是只有唯一一个参数值,很多参数值都可以自定义设置,只是增强它的兼容性。
  我们之前学习了串口的协议,串口主要就是靠收发这样的、约定好的波形来进行通信的,那这个USART外设,就是串口通信的硬件支持电路。

  这个同步模式,就是多了个时钟CLK的输出;硬件流控制,比如A设备的TX脚向B设备的RX脚发送数据,A设备一直在发,发的太快了,B处理不过来,如果没有硬件流控制,那B就只能抛弃新数据或者覆盖原数据了。如果有硬件流控制,在硬件电路上,会多出一根线,如果B没准备好接收,就置高电平,如果准备好了,就置低电平。A接收到了B反馈的准备信号,就只会在B准备好的时候,才发数据,如果B没准备好,那数据就不会发送出去。这就是硬件流控制,可以防止因为B处理慢而导致数据丢失的问题;之后DMA,是这个串口支持DMA进行数据转运,可以使用DMA转运数据,减轻CPU的负担;最后,智能卡、IrDA、LIN,这些是其他的一些协议。因为这些协议和串口是非常的像,所以STM32就对USART加了一些小改动,就能兼容这么多协议了,不过我们一般不用,像这些协议,Up主也都没用过。

USART框图详解

引脚部分:

TX: 发送数据输出引脚。

RX: 接收数据输入引脚。

SCLK: 发送器时钟输出引脚。这个引脚仅适用于同步模式。

下面这里的SWRX、IRDA_OUT/IN这些是智能卡和IrDA通信的引脚,我们不用这些协议,所以这些引脚就不用管的。
SW_RX: 数据接收引脚,只用于单线和智能卡模式,属于内部引脚,没有具体外部引脚。
nRTS: 请求以发送(Request To Send),n表示低电平有效。如果使能RTS流控制,当USART接收器准备好接收新数据时就会将nRTS变成低电平; 当接收寄存器已满时,nRTS将被设置为高电平。该引脚只适用于硬件流控制。
nCTS: 清除以发送(Clear To Send),n表示低电平有效。如果使能CTS流控制,发送器在发送下一帧数据之前会检测nCTS引脚, 如果为低电平,表示可以发送数据,如果为高电平则在发送完当前数据帧之后停止发送。该引脚只适用于硬件流控制。

数据寄存器:

  USART_DR包含了已发送的数据或者接收到的数据。USART_DR实际是包含了两个寄存器,一个专门用于发送的可写TDR, 一个专门用于接收的可读RDR。这两个寄存器占用同一个地址在程序上,只表现为一个寄存器。当进行发送操作时,往USART_DR写入数据会自动存储在TDR内;当进行读取操作时,向USART_DR读取数据会自动提取RDR数据。

USART数据寄存器(USART_DR)只有低9位有效,并且第9位数据是否有效要取决于USART控制寄存器1(USART_CR1)的M位设置, 当M位为0时表示8位数据字长,当M位为1表示9位数据字长,我们一般使用8位数据字长。

  TDR和RDR都是介于系统总线和移位寄存器之间。串行通信是一个位一个位传输的,发送时把TDR内容转移到发送移位寄存器, 然后把移位寄存器数据每一位发送出去,接收时把接收到的每一位顺序保存在接收移位寄存器内然后才转移到RDR。
  USART支持DMA传输,可以实现高速数据传输,具体DMA使用将在DMA章节讲解。

移位寄存器:

  然后往下看,下面是两个移位寄存器,一个用于发送,一个用于接收。发送移位寄存器的作用就是,把一个字节的数据一位一位地移出去,正好对应串口协议的波形的数据位。
这两个寄存器是怎么工作的呢?(图中主要讲的是发送寄存器)

注意一下,当TXE标志位置1时,数据其实还没有发送出去,只要数据从TDR转移到发送移位寄存器了,TXE就会置1,我们就可以写入新的数据了。【就是发送数据寄存器里一直有数据,而发送移位寄存器里的数据一旦移位完成,那么发送数据寄存器里的数据就会立刻传输进入发送移位寄存器里再次传输】
  看一下接收端这里,也是类似的。数据从RX引脚通向接收移位寄存器,在接收器控制的驱动下,一位一位地读取RX电平,先放在最高位,然后向右移,移位8次之后,就能接收一个字节了。同样,因为串口协议规定是低位先行,所以接收移位寄存器是从高位往低位这个方向移动的。之后,当一个字节移位完成之后,这一个字节的数据就会整体地,一下子转移到接收数据寄存器RDR里来,在转移的过程中,也会置一个标志位叫RXNE (RXNot Empty),接收数据寄存器非空,当我们检测到RXNE置1之后,就可以把数据读走了。同样,这里也是两个寄存器进行缓存,当数据从移位寄存器转移到RDR时,就可以直接移位接收下一帧数据了。
  这就是USART外设整个的工作流程,其实讲到这里,这个外设的主要功能就差不多了。大体上,就是数据寄存器和移位寄存器,发送移位寄存器往TX引脚移位,接收移位寄存器从RX引脚移位。当然发送还需要加上帧头帧尾,接收还需要剔除帧头帧尾,这些操作,它内部有电路会自动执行。我们知道有硬件帮我们做了这些工作就行了

接着我们继续看一下下面的控制部分和一些其他的增强功能

硬件流控:

  下面这里是发送器控制,它就是用来控制发送移位寄存器的工作的;接收器控制,用来控制接收移位寄存器的工作;然后左边这里,有一个硬件数据流控,也就是硬件流控制,简称流控。
  这里流控有两个引脚,一个是nRTS,一个是nCTS。nRTS(Request To Send)是请求发送,是输出脚,也就是告诉别人,我当前能不能接收;nCTS (Clear To Send)是清除发送,是输入脚,也就是用于接收别人nRTS的信号的。

这里前面加个n意思是低电平有效,那这两个脚上怎么玩的呢?
  首先,我们需要找到一个支持流控的串口,并将它的TX连接到我们的RX。同时,我们的RTS需要输出一个接收反馈信号,并将其连接到对方的CTS。当我们可以接收数据时,RTS会置为低电平,请求对方发送。对方的CTS接收到信号后,就可以继续发送数据。如果处理不过来,比如接收数据寄存器未及时读取,导致新数据无法接收,此时RTS会置为高电平,对方的CTS接收到信号后,就会暂停发送,直到接收数据寄存器被读取,RTS重新置为低电平,数据才会继续发送。
  当我们的TX向对方发送数据时,对方的RTS会连接到我们的CTS,用于判断对方是否可以接收数据。TX和CTS是一对对应的信号,RX和RTS也是一对对应的信号。此外,CTS和RTS之间也需要交叉连接,这就是流控的工作模式。然而,我们一般不使用流控,因此只需要了解一下即可。(少用原因应该是多消耗两根通信线)

SCLK控制:

  接着继续看右边这个模块,这部分电路用于产生同步的时钟信号,它是配合发送移位寄存器输出的,发送寄存器每移位一次,同步时钟电平就跳变一个周期。时钟告诉对方,我移出去一位数据,你看要不要让我这个时钟信号来指导你接收一下?当然这个时钟只支持输出,不支持输入,所以两个USART之间,不能实现同步的串口通信。

那这个时钟信号有什么用呢

兼容别的协议。比如串口加上时钟之后,就跟SPI协议特别像,所以有了时钟输出的串口,就可以兼容SPI。另外这个时钟也可以做自适应波特率,比如接收设备不确定发送设备给的什么波特率,然后再计算得到波特率,不过这就需要另外写程序来实现这个功能了。这个时钟功能,我们一般不用,所以也是了解一下就行

唤醒单元:

  这部分的作用是实现串口挂载多设备。我们之前说,串口一般是点对点的通信(只支持两个设备互相通信)。而多设备,在一条总线上,可以接多个从设备,每个设备分配一个地址,我想跟某个设备通信,就先进行寻址,确定通信对象。那回到这里,这个唤醒单元就可以用来实现多设备的功能,在这里可以给串口分配一个地址,当你发送指定地址时,此设备唤醒开始工作,当你发送别的设备地址时,别的设备就唤醒工作,这个设备没收到地址,就会保持沉默。这样就可以实现多设备的串口通信了,这部分功能我们一般不用。

中断输出控制:

  中断申请位,就是状态寄存器这里的各种标志位,状态寄存器这里,有两个标志位比较重要,一个是TXE发送寄存器空,另一个是RXNE接收寄存器非空,这两个是判断发送状态和接收状态的必要标志位,剩下的标志位,了解一下就行。中断输出控制这里,就是配置中断是不是能通向NVIC,这个应该好理解

波特率发生器部分:

  波特率发生器其实就是分频器,APB时钟进行分频,得到发送和接收移位的时钟。看一下,这里时钟输入是fPCLKx(x=1或2),(USART1挂载在APB2,所以就是PCLK2的时钟,一般是72M;其他的USART都挂载在APB1,所以是PCLK1的时钟,一般是36M)之后这个时钟进行一个分频,除一个USARTDIV的分频系数,并且分为了整数部分和小数部分,因为有些波特率,用72M除一个整数的话,可能除不尽,会有误差。所以这里分频系数是支持小数点后4位的,分频就更加精准,之后分频完之后,还要再除个16,得到发送器时钟和接收器时钟,通向控制部分。然后右边这里,如果TE (TX Enable)为1,就是发送器使能了,发送部分的波特率就有效;如果RE(RX Enable)为1,就是接收器使能了,接收部分的波特率就有效。

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

引脚定义表,这里复用功能这一栏,就给出了每个USART它的各个引脚都是复用在了哪个GPIO上的。

这些引脚都必须按照引脚定义里的规定来,或者看一下重映射这里,有没有重映射,这里有USART1的重映射,所以有机会换一次口,剩下引脚,就没有机会作为USART1的接口了。

USART基本结构

那到这里,USART的基本结构就讲完了。

几个小细节

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

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

配置停止位:
那最后这些时钟什么的,和上面也都是类似的
接下来我们继续来看这个数据帧,看一下不同停止位的波形变化。STM32的串口,可以配置停止位长度为0.5、1、1.5、2,这四种。

  这四种参数的区别,就是停止位的时长不一样。第一个是1个停止位,这时停止位的时长就和数据位的一位,时长一样;然后是1.5个停止位,这时的停止位就是数据位一位,时长的1.5倍;2个停止位,那停止位时长就是2倍;0.5个停止位,时长就是0.5倍。这个也好理解,就是控制停止位时长的,一般选择1位停止位就行了,其他的参数不太常用。这个是停止位。

起始位侦测和数据采样:
  那之后,我们继续来看一些细节问题,这两个图展示的是USART电路输入数据的一些策略。对于串口来说,根据我们前面的介绍,可以想到,串口的输出TX应该是比输入RX简单很多,输出你就定时翻转TX引脚高低电平就行了。但是输入,就复杂一些。你不仅要保证,输入的采样频率和波特率一致,还要保证每次输入采样的位置,【要正好处于每一位的正中间,只有在每一位的正中间采样,这样高低电平读进来,才是最可靠的,如果你采样点过于靠前或靠后,那有可能高低电平还正在翻转,电平还不稳定,或者稍有误差,数据就采样错了】。另外,输入最好还要对噪声有一定的判断能力,如果是噪声,最好能置个标志位提醒我一下,这些就是输入数据所面临的问题。
那我们来看一下STM32是如何来设计输入电路的呢?

  第一个图展示了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次进行采样这样就能保证采样位置在位的正中间了。这就是起始位侦测和采样位置对齐的策略。

那紧跟着,我们就可以看这个数据采样的流程了。

  这里,从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对这个电路的设计考虑还是很充分的

波特率发生器:
那最后,我们再来看一下波特率发生器

为什么这里公式有个16,因为它内部还有一个16倍波特率的采样时钟,所以这里输入时钟/DV要等于16倍的波特率,最终计算波特率,自然要多除一个16了
举个例子,比如我要配置USART1为9600的波特率,那如何配置这个BRR寄存器呢?
我们代入公式,就是9600等于 USART1的时钟是72M 除 16倍的DIV,解得,DIV=72M/9600/16,最终等于468.75,则二进制数是11101 0100.11v。所以最终写到这个寄存器就是整数部分为11101 0100,前面多出来的补0,小数部分为11,后面多出来的补0。这就是根据波特率写BRR寄存器的方法,了解一下,不过,我们用库函数配置的话,就非常方便,需要多少波特率,直接写就行了,库函数会自动帮我们算。

手册讲解
USB转串口模块的内部电路图

代码实战:串口发送&&串口发送+接受



9-1串口发送:
  下面这个是我们的USB转串口的模块,这里有个跳线帽,上节也说过,要插在VCC和3V3这两个脚上,选择通信的TTL电平为3.3V,然后通信引脚,TXD和RXD,要接在STM32的PA9和PA10口。为什么是这两个口呢,我们看一下引脚定义表就知道USART1的TX是PA9, RX是PA10,我们计划用USART1进行通信,所以就选这两个脚。TX和RX交叉连接,这边一定要注意,别接错了。然后,两个设备之间要把负极接在一起,进行共地,一般多个系统之间互连,都要进行共地。最后,这个串口模块和STLINK都要插在电脑上,这样,STM32和串口模块都有独立供电,所以这里通信的电源正极就不需要接了。


  当然我们第一个代码,只有STM32发送的部分,所以,通信线只有这个发送的有用,另一根线,第一个代码没有用到,暂时可以不接,在我们下一个串口发送+接收的代码,两根通信线就都需要接了。所以我们把这两根通信线一起都接上吧,这样两个代码的接线图是一模一样的。

老规矩,上来先写一个初始化函数

  • 第一步,开启时钟,把需要用的USART和GPIO的时钟打开
  • 第二步,GPIO初始化,把TX配置成复用输出,RX配置成输入
  • 第三步,配置USART,直接使用一个结构体,就可以把这里所有的参数都配置好了
  • 第四步,如果你只需要发送的功能,就直接开启USART,初始化就结束了。如果你需要接收的功能,可能还需要配置中断,那就在开启USART之前,再加上ITConfig和NVIC的代码就行了。

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

Serial.c部分:

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

/**
  * 函    数:串口初始化
  * 参    数:无
  * 返 回 值:无
  */
void Serial_Init(void)
{
	/*开启时钟*/
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);	//开启USART1的时钟
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);	//开启GPIOA的时钟
	
	/*GPIO初始化*/
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);					//将PA9引脚初始化为复用推挽输出
	
	/*USART初始化*/
	USART_InitTypeDef USART_InitStructure;					//定义结构体变量
	USART_InitStructure.USART_BaudRate = 9600;				//波特率
	USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;	//硬件流控制,不需要
	USART_InitStructure.USART_Mode = USART_Mode_Tx;			//模式,选择为发送模式
	USART_InitStructure.USART_Parity = USART_Parity_No;		//奇偶校验,不需要
	USART_InitStructure.USART_StopBits = USART_StopBits_1;	//停止位,选择1位
	USART_InitStructure.USART_WordLength = USART_WordLength_8b;		//字长,选择8位
	USART_Init(USART1, &USART_InitStructure);				//将结构体变量交给USART_Init,配置USART1
	
	/*USART使能*/
	USART_Cmd(USART1, ENABLE);								//使能USART1,串口开始运行
}

/**
  * 函    数:串口发送一个字节
  * 参    数:Byte 要发送的一个字节
  * 返 回 值:无
  */
void Serial_SendByte(uint8_t Byte)
{
	USART_SendData(USART1, Byte);		//将字节数据写入数据寄存器,写入后USART自动生成时序波形
	while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);	//等待发送完成
	/*下次写入数据寄存器会自动清除发送完成标志位,故此循环后,无需清除标志位*/
}

/**
  * 函    数:串口发送一个数组
  * 参    数:Array 要发送数组的首地址
  * 参    数:Length 要发送数组的长度
  * 返 回 值:无
  */
void Serial_SendArray(uint8_t *Array, uint16_t Length)
{
	uint16_t i;
	for (i = 0; i < Length; i ++)		//遍历数组
	{
		Serial_SendByte(Array[i]);		//依次调用Serial_SendByte发送每个字节数据
	}
}

/**
  * 函    数:串口发送一个字符串
  * 参    数:String 要发送字符串的首地址
  * 返 回 值:无
  */
void Serial_SendString(char *String)
{
	uint8_t i;
	for (i = 0; String[i] != '\0'; i ++)//遍历字符数组(字符串),遇到字符串结束标志位后停止
	{
		Serial_SendByte(String[i]);		//依次调用Serial_SendByte发送每个字节数据
	}
}

/**
  * 函    数:次方函数(内部使用)
  * 返 回 值:返回值等于X的Y次方
  */
uint32_t Serial_Pow(uint32_t X, uint32_t Y)
{
	uint32_t Result = 1;	//设置结果初值为1
	while (Y --)			//执行Y次
	{
		Result *= X;		//将X累乘到结果
	}
	return Result;
}

/**
  * 函    数:串口发送数字
  * 参    数:Number 要发送的数字,范围:0~4294967295
  * 参    数:Length 要发送数字的长度,范围:0~10
  * 返 回 值:无
  */
void Serial_SendNumber(uint32_t Number, uint8_t Length)
{
	uint8_t i;
	for (i = 0; i < Length; i ++)		//根据数字长度遍历数字的每一位
	{
		Serial_SendByte(Number / Serial_Pow(10, Length - i - 1) % 10 + '0');	//依次调用Serial_SendByte发送每位数字
	}
}

/**
  * 函    数:使用printf需要重定向的底层函数
  * 参    数:保持原始格式即可,无需变动
  * 返 回 值:保持原始格式即可,无需变动
  */
int fputc(int ch, FILE *f)
{
	Serial_SendByte(ch);			//将printf的底层重定向到自己的发送字节函数
	return ch;
}

/**
  * 函    数:自己封装的prinf函数
  * 参    数:format 格式化字符串
  * 参    数:... 可变的参数列表
  * 返 回 值:无
  */
void Serial_Printf(char *format, ...)
{
	char String[100];				//定义字符数组
	va_list arg;					//定义可变参数列表数据类型的变量arg
	va_start(arg, format);			//从format开始,接收参数列表到arg变量
	vsprintf(String, format, arg);	//使用vsprintf打印格式化字符串和参数列表到字符数组中
	va_end(arg);					//结束变量arg
	Serial_SendString(String);		//串口发送字符数组(字符串)
}

mian.c部分:

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

int main(void)
{
	/*模块初始化*/
	OLED_Init();						//OLED初始化
	
	Serial_Init();						//串口初始化
	
	/*串口基本函数*/
	Serial_SendByte(0x41);				//串口发送一个字节数据0x41
	
	uint8_t MyArray[] = {0x42, 0x43, 0x44, 0x45};	//定义数组
	Serial_SendArray(MyArray, 4);		//串口发送一个数组
	
	Serial_SendString("\r\nNum1=");		//串口发送字符串
	
	Serial_SendNumber(111, 3);			//串口发送数字
	
	/*下述3种方法可实现printf的效果*/
	
	/*方法1:直接重定向printf,但printf函数只有一个,此方法不能在多处使用*/
	printf("\r\nNum2=%d", 222);			//串口发送printf打印的格式化字符串
										//需要重定向fputc函数,并在工程选项里勾选Use MicroLIB
	
	/*方法2:使用sprintf打印到字符数组,再用串口发送字符数组,此方法打印到字符数组,之后想怎么处理都可以,可在多处使用*/
	char String[100];					//定义字符数组
	sprintf(String, "\r\nNum3=%d", 333);//使用sprintf,把格式化字符串打印到字符数组
	Serial_SendString(String);			//串口发送字符数组(字符串)
	
	/*方法3:将sprintf函数封装起来,实现专用的printf,此方法就是把方法2封装起来,更加简洁实用,可在多处使用*/
	Serial_Printf("\r\nNum4=%d", 444);	//串口打印字符串,使用自己封装的函数实现printf的效果
	Serial_Printf("\r\n");
	
	while (1)
	{
		
	}
}

9-2 串口发送+接受

Serial.c部分:

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

uint8_t Serial_RxData;		//定义串口接收的数据变量
uint8_t Serial_RxFlag;		//定义串口接收的标志位变量

/**
  * 函    数:串口初始化
  * 参    数:无
  * 返 回 值:无
  */
void Serial_Init(void)
{
	/*开启时钟*/
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);	//开启USART1的时钟
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);	//开启GPIOA的时钟
	
	/*GPIO初始化*/
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);					//将PA9引脚初始化为复用推挽输出
	
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);					//将PA10引脚初始化为上拉输入
	
	/*USART初始化*/
	USART_InitTypeDef USART_InitStructure;					//定义结构体变量
	USART_InitStructure.USART_BaudRate = 9600;				//波特率
	USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;	//硬件流控制,不需要
	USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;	//模式,发送模式和接收模式均选择
	USART_InitStructure.USART_Parity = USART_Parity_No;		//奇偶校验,不需要
	USART_InitStructure.USART_StopBits = USART_StopBits_1;	//停止位,选择1位
	USART_InitStructure.USART_WordLength = USART_WordLength_8b;		//字长,选择8位
	USART_Init(USART1, &USART_InitStructure);				//将结构体变量交给USART_Init,配置USART1
	
	/*中断输出配置*/
	USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);			//开启串口接收数据的中断
	
	/*NVIC中断分组*/
	NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);			//配置NVIC为分组2
	
	/*NVIC配置*/
	NVIC_InitTypeDef NVIC_InitStructure;					//定义结构体变量
	NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;		//选择配置NVIC的USART1线
	NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;			//指定NVIC线路使能
	NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;		//指定NVIC线路的抢占优先级为1
	NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;		//指定NVIC线路的响应优先级为1
	NVIC_Init(&NVIC_InitStructure);							//将结构体变量交给NVIC_Init,配置NVIC外设
	
	/*USART使能*/
	USART_Cmd(USART1, ENABLE);								//使能USART1,串口开始运行
}

/**
  * 函    数:串口发送一个字节
  * 参    数:Byte 要发送的一个字节
  * 返 回 值:无
  */
void Serial_SendByte(uint8_t Byte)
{
	USART_SendData(USART1, Byte);		//将字节数据写入数据寄存器,写入后USART自动生成时序波形
	while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);	//等待发送完成
	/*下次写入数据寄存器会自动清除发送完成标志位,故此循环后,无需清除标志位*/
}

/**
  * 函    数:串口发送一个数组
  * 参    数:Array 要发送数组的首地址
  * 参    数:Length 要发送数组的长度
  * 返 回 值:无
  */
void Serial_SendArray(uint8_t *Array, uint16_t Length)
{
	uint16_t i;
	for (i = 0; i < Length; i ++)		//遍历数组
	{
		Serial_SendByte(Array[i]);		//依次调用Serial_SendByte发送每个字节数据
	}
}

/**
  * 函    数:串口发送一个字符串
  * 参    数:String 要发送字符串的首地址
  * 返 回 值:无
  */
void Serial_SendString(char *String)
{
	uint8_t i;
	for (i = 0; String[i] != '\0'; i ++)//遍历字符数组(字符串),遇到字符串结束标志位后停止
	{
		Serial_SendByte(String[i]);		//依次调用Serial_SendByte发送每个字节数据
	}
}

/**
  * 函    数:次方函数(内部使用)
  * 返 回 值:返回值等于X的Y次方
  */
uint32_t Serial_Pow(uint32_t X, uint32_t Y)
{
	uint32_t Result = 1;	//设置结果初值为1
	while (Y --)			//执行Y次
	{
		Result *= X;		//将X累乘到结果
	}
	return Result;
}

/**
  * 函    数:串口发送数字
  * 参    数:Number 要发送的数字,范围:0~4294967295
  * 参    数:Length 要发送数字的长度,范围:0~10
  * 返 回 值:无
  */
void Serial_SendNumber(uint32_t Number, uint8_t Length)
{
	uint8_t i;
	for (i = 0; i < Length; i ++)		//根据数字长度遍历数字的每一位
	{
		Serial_SendByte(Number / Serial_Pow(10, Length - i - 1) % 10 + '0');	//依次调用Serial_SendByte发送每位数字
	}
}

/**
  * 函    数:使用printf需要重定向的底层函数
  * 参    数:保持原始格式即可,无需变动
  * 返 回 值:保持原始格式即可,无需变动
  */
int fputc(int ch, FILE *f)
{
	Serial_SendByte(ch);			//将printf的底层重定向到自己的发送字节函数
	return ch;
}

/**
  * 函    数:自己封装的prinf函数
  * 参    数:format 格式化字符串
  * 参    数:... 可变的参数列表
  * 返 回 值:无
  */
void Serial_Printf(char *format, ...)
{
	char String[100];				//定义字符数组
	va_list arg;					//定义可变参数列表数据类型的变量arg
	va_start(arg, format);			//从format开始,接收参数列表到arg变量
	vsprintf(String, format, arg);	//使用vsprintf打印格式化字符串和参数列表到字符数组中
	va_end(arg);					//结束变量arg
	Serial_SendString(String);		//串口发送字符数组(字符串)
}

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

/**
  * 函    数:获取串口接收的数据
  * 参    数:无
  * 返 回 值:接收的数据,范围:0~255
  */
uint8_t Serial_GetRxData(void)
{
	return Serial_RxData;			//返回接收的数据变量
}

/**
  * 函    数:USART1中断函数
  * 参    数:无
  * 返 回 值:无
  * 注意事项:此函数为中断函数,无需调用,中断触发后自动执行
  *           函数名为预留的指定名称,可以从启动文件复制
  *           请确保函数名正确,不能有任何差异,否则中断函数将不能进入
  */
void USART1_IRQHandler(void)
{
	if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET)		//判断是否是USART1的接收事件触发的中断
	{
		Serial_RxData = USART_ReceiveData(USART1);				//读取数据寄存器,存放在接收的数据变量
		Serial_RxFlag = 1;										//置接收标志位变量为1
		USART_ClearITPendingBit(USART1, USART_IT_RXNE);			//清除USART1的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初始化
	
	/*显示静态字符串*/
	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);	//显示串口接收的数据
		}
	}
}

USART串口数据包

先来看两张图,是关于我规定的数据包格式,一种是HEX数据包,一种是文本数据包,之后两个图,展示的就是接收数据包的思路。

接着我们来研究几个问题:

  • 第一个问题:包头包尾和数据载荷重复的问题,这里定义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的指针指向它,把它们当做一个字节数组发送就行了。

好,有关HEX数据包定义的内容,就讲这么多,接下来看一下文本数据包。

  文本数据包和HEX数据包分别对应了文本模式和HEX模式。在HEX数据包中,数据以原始字节形式呈现。而在文本数据包中,每个字节经过了一层编码和译码,最终以文本格式呈现。实际上,每个文本字符背后都有一个字节的HEX数据。
  综上所述,我们需要根据实际场景来选择和设计数据包格式。在需要直接传输和简单解析原始数据的情况下,HEX数据包是更好的选择。而在需要输入指令进行人机交互的场合,文本数据包则更为适用。

  好,数据包格式的定义讲完了,接下来我们就来学一下数据包的收发流程
  首先,发送数据包的过程相对简单。在发送HEX数据包时,可以通过定义一个数组,填充数据,然后使用之前我们写过的SendArray函数发送即可。在发送文本数据包时,可以通过写一个字符串,然后调用SendString函数发送。因此,发送数据包的过程是可控的,我们可以根据需要发送任何类型的数据包。相比之下,接收数据包的过程较为复杂。
  那接下来,接收一个数据包,这就比较复杂了,我们来学习一下,我这里演示了固定包长HEX数据包的接收方法,和可变包长文本数据包的接收方法,其他的数据包也都可以套用这个形式,等会儿我们写程序就会根据这里面的流程来。

  我们先看一下如何来接收这个固定包长的HEX数据包。要接收固定包长的HEX数据包,我们需要设计一个状态机来处理。根据之前的代码,我们知道每当收到一个字节,程序会进入中断。在中断函数里,我们可以获取这个字节,但获取后需要退出中断。因此,每个收到的数据都是独立的过程,而数据包则具有前后关联性,包括包头、数据和包尾。为了处理这三种状态,我们需要设计一个能够记住不同状态的机制,并在不同状态下执行不同的操作,同时进行状态合理转移。这种程序设计思维就是“状态机”。

  这就是使用状态机接收数据包的思路。这个状态机其实是一种很广泛的编程思路,在很多地方都可以用到,使用的基本步骤是,先根据项目要求定义状态,画几个圈,然后考虑好各个状态在什么情况下会进行转移,如何转移,画好线和转移条件,最后根据这个图来进行编程,这样思维就会非常清晰了。
  那接下来继续,我们来看一下这个可变包长、文本数据包的接收流程

  好,到这里,我们这个数据包的,定义、分类、优缺点和注意事项,就讲完了,接下来,我们就来写程序,验证一下刚才所学的内容吧。

代码实战:串口收发HEX数据包&&串口收发文本数据包

9-3 串口收发HEX数据包

Serial.c部分:

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

uint8_t Serial_TxPacket[4];				//定义发送数据包数组,数据包格式:FF 01 02 03 04 FE
uint8_t Serial_RxPacket[4];				//定义接收数据包数组
uint8_t Serial_RxFlag;					//定义接收数据包标志位

/**
  * 函    数:串口初始化
  * 参    数:无
  * 返 回 值:无
  */
void Serial_Init(void)
{
	/*开启时钟*/
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);	//开启USART1的时钟
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);	//开启GPIOA的时钟
	
	/*GPIO初始化*/
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);					//将PA9引脚初始化为复用推挽输出
	
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);					//将PA10引脚初始化为上拉输入
	
	/*USART初始化*/
	USART_InitTypeDef USART_InitStructure;					//定义结构体变量
	USART_InitStructure.USART_BaudRate = 9600;				//波特率
	USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;	//硬件流控制,不需要
	USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;	//模式,发送模式和接收模式均选择
	USART_InitStructure.USART_Parity = USART_Parity_No;		//奇偶校验,不需要
	USART_InitStructure.USART_StopBits = USART_StopBits_1;	//停止位,选择1位
	USART_InitStructure.USART_WordLength = USART_WordLength_8b;		//字长,选择8位
	USART_Init(USART1, &USART_InitStructure);				//将结构体变量交给USART_Init,配置USART1
	
	/*中断输出配置*/
	USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);			//开启串口接收数据的中断
	
	/*NVIC中断分组*/
	NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);			//配置NVIC为分组2
	
	/*NVIC配置*/
	NVIC_InitTypeDef NVIC_InitStructure;					//定义结构体变量
	NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;		//选择配置NVIC的USART1线
	NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;			//指定NVIC线路使能
	NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;		//指定NVIC线路的抢占优先级为1
	NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;		//指定NVIC线路的响应优先级为1
	NVIC_Init(&NVIC_InitStructure);							//将结构体变量交给NVIC_Init,配置NVIC外设
	
	/*USART使能*/
	USART_Cmd(USART1, ENABLE);								//使能USART1,串口开始运行
}

/**
  * 函    数:串口发送一个字节
  * 参    数:Byte 要发送的一个字节
  * 返 回 值:无
  */
void Serial_SendByte(uint8_t Byte)
{
	USART_SendData(USART1, Byte);		//将字节数据写入数据寄存器,写入后USART自动生成时序波形
	while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);	//等待发送完成
	/*下次写入数据寄存器会自动清除发送完成标志位,故此循环后,无需清除标志位*/
}

/**
  * 函    数:串口发送一个数组
  * 参    数:Array 要发送数组的首地址
  * 参    数:Length 要发送数组的长度
  * 返 回 值:无
  */
void Serial_SendArray(uint8_t *Array, uint16_t Length)
{
	uint16_t i;
	for (i = 0; i < Length; i ++)		//遍历数组
	{
		Serial_SendByte(Array[i]);		//依次调用Serial_SendByte发送每个字节数据
	}
}

/**
  * 函    数:串口发送一个字符串
  * 参    数:String 要发送字符串的首地址
  * 返 回 值:无
  */
void Serial_SendString(char *String)
{
	uint8_t i;
	for (i = 0; String[i] != '\0'; i ++)//遍历字符数组(字符串),遇到字符串结束标志位后停止
	{
		Serial_SendByte(String[i]);		//依次调用Serial_SendByte发送每个字节数据
	}
}

/**
  * 函    数:次方函数(内部使用)
  * 返 回 值:返回值等于X的Y次方
  */
uint32_t Serial_Pow(uint32_t X, uint32_t Y)
{
	uint32_t Result = 1;	//设置结果初值为1
	while (Y --)			//执行Y次
	{
		Result *= X;		//将X累乘到结果
	}
	return Result;
}

/**
  * 函    数:串口发送数字
  * 参    数:Number 要发送的数字,范围:0~4294967295
  * 参    数:Length 要发送数字的长度,范围:0~10
  * 返 回 值:无
  */
void Serial_SendNumber(uint32_t Number, uint8_t Length)
{
	uint8_t i;
	for (i = 0; i < Length; i ++)		//根据数字长度遍历数字的每一位
	{
		Serial_SendByte(Number / Serial_Pow(10, Length - i - 1) % 10 + '0');	//依次调用Serial_SendByte发送每位数字
	}
}

/**
  * 函    数:使用printf需要重定向的底层函数
  * 参    数:保持原始格式即可,无需变动
  * 返 回 值:保持原始格式即可,无需变动
  */
int fputc(int ch, FILE *f)
{
	Serial_SendByte(ch);			//将printf的底层重定向到自己的发送字节函数
	return ch;
}

/**
  * 函    数:自己封装的prinf函数
  * 参    数:format 格式化字符串
  * 参    数:... 可变的参数列表
  * 返 回 值:无
  */
void Serial_Printf(char *format, ...)
{
	char String[100];				//定义字符数组
	va_list arg;					//定义可变参数列表数据类型的变量arg
	va_start(arg, format);			//从format开始,接收参数列表到arg变量
	vsprintf(String, format, arg);	//使用vsprintf打印格式化字符串和参数列表到字符数组中
	va_end(arg);					//结束变量arg
	Serial_SendString(String);		//串口发送字符数组(字符串)
}

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

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

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

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

9-4 串口收发文本数据包

Serial.c部分:

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

char Serial_RxPacket[100];				//定义接收数据包数组,数据包格式"@MSG\r\n"
uint8_t Serial_RxFlag;					//定义接收数据包标志位

/**
  * 函    数:串口初始化
  * 参    数:无
  * 返 回 值:无
  */
void Serial_Init(void)
{
	/*开启时钟*/
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);	//开启USART1的时钟
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);	//开启GPIOA的时钟
	
	/*GPIO初始化*/
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);					//将PA9引脚初始化为复用推挽输出
	
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);					//将PA10引脚初始化为上拉输入
	
	/*USART初始化*/
	USART_InitTypeDef USART_InitStructure;					//定义结构体变量
	USART_InitStructure.USART_BaudRate = 9600;				//波特率
	USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;	//硬件流控制,不需要
	USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;	//模式,发送模式和接收模式均选择
	USART_InitStructure.USART_Parity = USART_Parity_No;		//奇偶校验,不需要
	USART_InitStructure.USART_StopBits = USART_StopBits_1;	//停止位,选择1位
	USART_InitStructure.USART_WordLength = USART_WordLength_8b;		//字长,选择8位
	USART_Init(USART1, &USART_InitStructure);				//将结构体变量交给USART_Init,配置USART1
	
	/*中断输出配置*/
	USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);			//开启串口接收数据的中断
	
	/*NVIC中断分组*/
	NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);			//配置NVIC为分组2
	
	/*NVIC配置*/
	NVIC_InitTypeDef NVIC_InitStructure;					//定义结构体变量
	NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;		//选择配置NVIC的USART1线
	NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;			//指定NVIC线路使能
	NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;		//指定NVIC线路的抢占优先级为1
	NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;		//指定NVIC线路的响应优先级为1
	NVIC_Init(&NVIC_InitStructure);							//将结构体变量交给NVIC_Init,配置NVIC外设
	
	/*USART使能*/
	USART_Cmd(USART1, ENABLE);								//使能USART1,串口开始运行
}

/**
  * 函    数:串口发送一个字节
  * 参    数:Byte 要发送的一个字节
  * 返 回 值:无
  */
void Serial_SendByte(uint8_t Byte)
{
	USART_SendData(USART1, Byte);		//将字节数据写入数据寄存器,写入后USART自动生成时序波形
	while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);	//等待发送完成
	/*下次写入数据寄存器会自动清除发送完成标志位,故此循环后,无需清除标志位*/
}

/**
  * 函    数:串口发送一个数组
  * 参    数:Array 要发送数组的首地址
  * 参    数:Length 要发送数组的长度
  * 返 回 值:无
  */
void Serial_SendArray(uint8_t *Array, uint16_t Length)
{
	uint16_t i;
	for (i = 0; i < Length; i ++)		//遍历数组
	{
		Serial_SendByte(Array[i]);		//依次调用Serial_SendByte发送每个字节数据
	}
}

/**
  * 函    数:串口发送一个字符串
  * 参    数:String 要发送字符串的首地址
  * 返 回 值:无
  */
void Serial_SendString(char *String)
{
	uint8_t i;
	for (i = 0; String[i] != '\0'; i ++)//遍历字符数组(字符串),遇到字符串结束标志位后停止
	{
		Serial_SendByte(String[i]);		//依次调用Serial_SendByte发送每个字节数据
	}
}

/**
  * 函    数:次方函数(内部使用)
  * 返 回 值:返回值等于X的Y次方
  */
uint32_t Serial_Pow(uint32_t X, uint32_t Y)
{
	uint32_t Result = 1;	//设置结果初值为1
	while (Y --)			//执行Y次
	{
		Result *= X;		//将X累乘到结果
	}
	return Result;
}

/**
  * 函    数:串口发送数字
  * 参    数:Number 要发送的数字,范围:0~4294967295
  * 参    数:Length 要发送数字的长度,范围:0~10
  * 返 回 值:无
  */
void Serial_SendNumber(uint32_t Number, uint8_t Length)
{
	uint8_t i;
	for (i = 0; i < Length; i ++)		//根据数字长度遍历数字的每一位
	{
		Serial_SendByte(Number / Serial_Pow(10, Length - i - 1) % 10 + '0');	//依次调用Serial_SendByte发送每位数字
	}
}

/**
  * 函    数:使用printf需要重定向的底层函数
  * 参    数:保持原始格式即可,无需变动
  * 返 回 值:保持原始格式即可,无需变动
  */
int fputc(int ch, FILE *f)
{
	Serial_SendByte(ch);			//将printf的底层重定向到自己的发送字节函数
	return ch;
}

/**
  * 函    数:自己封装的prinf函数
  * 参    数:format 格式化字符串
  * 参    数:... 可变的参数列表
  * 返 回 值:无
  */
void Serial_Printf(char *format, ...)
{
	char String[100];				//定义字符数组
	va_list arg;					//定义可变参数列表数据类型的变量arg
	va_start(arg, format);			//从format开始,接收参数列表到arg变量
	vsprintf(String, format, arg);	//使用vsprintf打印格式化字符串和参数列表到字符数组中
	va_end(arg);					//结束变量arg
	Serial_SendString(String);		//串口发送字符数组(字符串)
}

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

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

I2C通信协议

注:通信协议的设计背景 3:00~10:13

   I2C 通讯协议(Inter-Integrated Circuit)是由Phiilps公司开发的,由于它引脚少,硬件实现简单,可扩展性强, 不需要USART、CAN等通讯协议的外部收发设备,现在被广泛地使用在系统内多个集成电路(IC)间的通讯。
  I2C总线是一种用于芯片之间进行通信的串行总线。它由两条线组成:串行时钟线(SCL)和串行数据线(SDA)。这种总线允许多个设备在同一条总线上进行通信。

物理层

I2C通讯设备之间的常用连接方式见图

I2C通信协议是一种通用的总线协议。I2C通信协议有以下特征:

  • (1) 它是一个支持设备的总线。“总线”指多个设备共用的信号线。在一个I2C通讯总线中, 可连接多个I2C通讯设备,支持多个通讯主机及多个通讯从机。
  • (2) 一个I2C总线只使用两条总线线路,一条双向串行数据线(SDA) , 一条串行时钟线 (SCL)。数据线即用来表示数据,时钟线用于数据收发同步。
  • (3) 每个连接到总线的设备都有一个独立的地址, 主机可以利用这个地址进行不同设备之间的访问。
  • (4) 总线通过上拉电阻接到电源。当I2C设备空闲时,会输出高阻态, 而当所有设备都空闲,都输出高阻态时,由上拉电阻把总线拉成高电平。
  • (5) 多个主机同时使用总线时,为了防止数据冲突, 会利用仲裁方式决定由哪个设备占用总线。
  • (6) 具有三种传输模式:标准模式传输速率为100kbit/s ,快速模式为400kbit/s , 高速模式下可达 3.4Mbit/s,但目前大多I2C设备尚不支持高速模式。
  • (7) 连接到相同总线的 IC 数量受到总线的最大电容 400pF 限制
  • SDA数据线在每个SCL的时钟周期传输一位数据,SCL为高电平的时候SDA表示的数据有效。
  • 应答信号和非应答信号I2C的数据和地址传输都带响应。

      一主多从是指单片机作为主机,主导I2C总线的运行。挂在I2C总线上的所有外部模块都是从机,只有被主机点名后才能控制I2C总线,不能在未经允许的情况下访问I2C总线,以防止冲突。这就像在课堂上,老师是主机,学生是从机。未经点名允许,学生不能发言,但可以被动地听老师讲课。
      另外,I2C还支持多主多从模型,即多个主机。在多主多从模型中,总线上任何一个模块都可以主动跳出来说,接下来我就是主机,你们都得听我的。这就像在教室里,老师正在讲课,突然一个学生站起来说,打断一下,接下来让我来说,所有同学听我指挥。但是,同一个时间只能有一个人说话,这时就相当于发生了总线冲突。在总线冲突时,I2C协议会进行仲裁,仲裁胜利的一方取得总线控制权,失败的一方自动变回从机。由于时钟线也由主机控制,所以在多主机的模型下还要进行时钟同步。多主机的情况下,协议是比较复杂的。本课程仅使用一主多从模型。
    以上是有关I2C的设计背景和基本功能。接下来我们将详细分析I2C如何实现这些功能。  作为一个通信协议,I2C必须在硬件和软件上作出规定硬件上的规定包括电路的连接方式、端口的输入输出模式等;软件上的规定包括时序的定义、字节的传输方式、高位先行还是低位先行等。这些硬件和软件的规定结合起来构成了一个完整的通信协议。

协议层

  I2C的协议定义了通讯的起始和停止信号、数据有效性、响应、仲裁、时钟同步和地址广播等环节。

I2C基本读写过程

先看看I2C通讯过程的基本结构,它的通讯过程见图

接下来我们先看一下12C的硬件规定,也就是I2C的硬件电路

I2C的硬件电路


这个图就是I2C的典型电路模型,这个模型采用了一主多从的结构。在左侧,我们可以看到CPU作为主设备,控制着总线并拥有很大的权利。其中,主机对SCL线拥有完全的控制权,无论何时何地,主机都负责掌控SCL线。在空闲状态下,主机还可以主动发起对SDA的控制。但是,从机发送数据或应答时,主机需要将SDA的控制权转交给从机。
接下来,我们看到了一系列被控IC,它们是挂载在12C总线上的从机设备,如姿态传感器、OLED、存储器、时钟模块等。这些从机的权利相对较小。对于SCL时钟线,它们在任何时刻都只能被动的读取,不允许控制SCL线对于SDA数据线,从机也不允许主动发起控制,只有在主机发送读取从机的命令后,或从机应答时,从机才能短暂地取得SDA的控制权。这就是一主多从模型中协议的规定。
然后我们来看接线部分。所有I2C设备的SCL和SDA都连接在一起。主机的SCL线拉出来,所有从机的SCL都接在这上面。主机的SDA线也是一样,拉出来,所有从机的SDA接在这上面。这就是SCL和SDA的接线方式。
那到现在,我们先不继续往后看了,先忽略这两个电阻,那到现在,假设我们就这样连接,那如何规定每个设备SCL和SDA的输入输出模式呢?
由于现在是一主多从结构,主机拥有SCL的绝对控制权,因此主机的SCL可以配置成推挽输出,所有从机的SCL都配置成浮空输入或上拉输入。数据流向为主机发送、所有从机接收。但是到SDA线这里就比较复杂了,因为这是半双工协议,所以主机的SDA在发送时是输出,在接收时是输入。同样地,从机的SDA也会在输入和输出之间反复切换。如果能够协调好输入输出的切换时机就没有问题。但是这样做的话,如果总线时序没有协调好,就极有可能发生两个引脚同时处于输出的状态。如果此时一个引脚输出高电平,一个引脚输出低电平,就会造成电源短路的情况,这是要极力避免的。
为了避免这种情况的发生,I2C的设计规定所有设备不输出强上拉的高电平,而是采用外置弱上拉电阻加开漏输出的电路结构。这两点规定对应于前面提到的“设备的SCL和SDA均要配置成开漏输出模式”以及“SCL和SDA各添加一个上拉电阻,阻值一般为4.7KΩ左右”。对应上面这个图。

所有的设备,包括CPU和被控IC,它们的引脚内部结构都如上图所示。图左侧展示的是SCL的结构,其中SClk代表SCL;右侧则是SDA的结构,其中DATA代表SDA。引脚的信号输入都可以通过一个数据缓冲器或施密特触发器进行,因为输入对电路无影响,所以任何设备在任何时刻都可以输入。然而,在输出部分,采用的是开漏输出的配置。

正常的推挽输出方式如下:上面一个开关管连接正极,下面一个开关管连接负极。当上面导通时,输出高电平;下面导通时,输出低电平。因为这是通过开关管直接连接到正负极的,所以这是强上拉和强下拉的模式。
而开漏输出呢,就是去掉这个强上拉的开关管,输出低电平时,下管导通,是强下拉,输出高电平时,下管断开,但是没有上管了,此时引脚处于浮空的状态,这就是开漏输出。

和这里图示是一样的,输出低电平,这个开关管导通,引脚直接接地,是强下拉,输出高电平,这个开关管断开,引脚什么都不接,处于浮空状态,这样的话,所有的设备都只能输出低电平而不能输出高电平,为了避免高电平造成的引脚浮空,这时就需要在总线外面,SCL和SDA各外置一个上拉电阻,这是通过一个电阻拉到高电平的,所以这是一个弱上拉。
UP主用弹簧和杆子的模型解释这一段硬件电路设计
这样做的好处是:

  • 第一,完全杜绝了电源短路现象,保证电路的安全。你看所有人无论怎么拉杆子或者放手,杆子都不会处于一个被同时强拉和强推的状态,即使有多个人同时往下拉杆子,也没问题
  • 第二,避免了引脚模式的频繁切换。开漏加弱上拉的模式,同时兼具了输入和输出的功能,你要是想输出,就去拉杆子或放手,操作杆子变化就行了,你要是想输入,就直接放手,然后观察杆子高低就行了,因为开漏模式下,输出高电平就相当于断开引脚,所以在输入之前,可以直接输出高电平,不需要再切换成输入模式了
  • 第三,就是这个模式会有一个“线与”的现象。就是只要有任意一个或多个设备输出了低电平,总线就处于低电平,只有所有设备都输出高电平,总线才处于高电平。I2C可以利用这个电路特性执行多主机模式下的时钟同步和总线仲裁,所以这里SCL虽然在一主多从模式下可以用推挽输出,但是它仍然采用了开漏加上拉输出的模式,因为在多主机模式下会利用到这个特征

好,以上就是I2C的硬件电路设计,那接下来,我们就要来学习软件,也就是时序的设计了。

I2C时序设计

首先我们来学习一下I2C规定的一些时序基本单元。

起始和终止条件


起始条件是指SCL高电平期间,SDA从高电平切换到低电平。在I2C总线处于空闲状态时,SCL和SDA都处于高电平状态,由外挂的上拉电阻保持。当主机需要数据收发时,会首先产生一个起始条件。这个起始条件是,SCL保持高电平,然后把SDA拉低,产生一个下降沿。当从机捕获到这个SCL高电平,SDA下降沿信号时,就会进行自身的复位,等待主机的召唤。之后,主机需要将SCL拉低。这样做一方面是占用这个总线,另一方面也是为了方便这些基本单元的拼接。这样,除了起始和终止条件,每个时序单元的SCL都是以低电平开始,低电平结束。

终止条件是,SCL高电平期间,SDA从低电平切换到高电平。SCL先放开并回弹到高电平,SDA再放开并回弹高电平,产生一个上升沿。这个上升沿触发终止条件,同时终止条件之后,SCL和SDA都是高电平,回归到最初的平静状态。这个起始条件和终止条件就类似串口时序里的起始位和停止位。一个完整的数据帧总是以起始条件开始、终止条件结束。另外,起始和终止都是由主机产生的。因此,从机必须始终保持双手放开,不允许主动跳出来去碰总线。如果允许从机这样做,那么就会变成多主机模型,不在本节的讨论范围之内。这就是起始条件和终止条件的含义。

发送一个字节

接着继续看,在起始条件之后,这时就可以紧跟着一个发送一个字节的时序单元,如何发送一个字节呢

就这样的流程,主机拉低SCL,把数据放在SDA上,主机松开SCL,从机读取SDA的数据,在SCL的同步下,依次进行主机发送和从机接收,循环8次,就发送了8位数据,也就是一个字节,另外注意,这里是高位先行,所以第一位是一个字节的最高位B7,然后依次是次高位B6…

接收一个字节


那我们再继续看最后两个基本单元,就是应答机制的设计。

发送应答和接收应答


发一字节收一位,收一字节发一位

应用:

I2C从机地址

12C的完整时序,主要有指定地址写,当前地址读和指定地址读这3种。
首先注意的是,我们这个12C是一主多从的模型,主机可以访问总线上的任何一个设备,那如何发出指令,来确定要访问的是哪个设备呢?
为了解决这个问题,我们需要为每个从设备分配一个唯一的设备地址。这些地址就像是每个设备的名字,主机通过发送这些地址来确定要与哪个设备通信。

当主机发送一个地址时,所有的从设备都会收到这个地址。但是,只有与发送的地址匹配的设备会响应主机的读写操作。
在I2C总线中,每个挂载的设备的地址必须是唯一的,否则当主机发送一个地址时,多个设备响应,就会导致混乱。
在12C协议标准中,从机设备地址分为7位和10位两种。我们今天主要讨论7位地址,因为它们相对简单且应用广泛。

每个I2C设备在出厂时都会被分配一个7位的地址。例如,MPU6050的7位地址是1101 000,而AT24C02的7位地址是1010 000。不同型号的芯片地址是不同的,但相同型号的芯片地址是相同的。
如果多个相同型号的芯片挂载在同一条总线上,我们可以通过调整地址的最后几位来解决这个问题。例如,MPU6050的地址可以通过ADO引脚来改变,而AT24C02的地址可以通过A0、A1、A2引脚来改变。这样,即使相同型号的芯片,挂载在同一个总线上,也可以通过切换地址低位的方式,保证每个设备的地址都是唯一的。这就是12C设备的从机地址。

下面时序讲解详情
注意:时序里面的RA是接收从机的应答位(Receive Ack, RA)

指定地址写



(Sláve Address + R/W) 中最后一位 0=W(写),根据协议规定,紧跟着的单元,就得是接收从机的应答位(Receive Ack, RA),在这个时刻,主机要释放SDA,
所以如果单看主机的波形,应该是这样,

释放SDA之后,引脚电平回弹到高电平,但是根据协议规定,从机要在这个位拉低SDA,所以单看从机的波形,应该是这样(绿色线)

该应答的时候,从机立刻拽住SDA,然后应答结束之后,从机再放开SDA,那现在综合两者的波形,结合线与的特性,在主机释放SDA之后,由于SDA也被从机拽住了,所以主机松手后,SDA并没有回弹高电平,这个过程,就代表从机产生了应答。最终高电平期间,主机读取SDA,发现是0,就说明,我进行寻址,有人给我应答了。如果主机读取SDA,发现是1,就说明,我进行寻址,应答位期间,我松手了,但是没人拽住它,没人给我应答,那就直接产生停止条件吧,并提示一些信息,这就是应答位。
然后这个上升沿,就是应答位结束后,从机释放SDA产生的,从机交出了SDA的控制权,因为从机要在低电平尽快变换数据,所以这个上升沿和SCL的下降沿,几乎是同时发生的。

当前地址读


指定地址读

指定地址读=指定地址写+当前地址读


Sr (Start Repeat)的意思就是重复起始条件,因为指定读写标志位只能是跟着起始条件的第一个字节,所以如果想切换读写方向,只能再来个起始条件。然后起始条件后,重新寻址并且指定读写标志位

代码实战:10-1 软件I2C读写MPU6050


由于我们这个代码使用的是软件I2C,就是用普通的GPIO口,手动翻转电平实现的协议,它并不需要STM32内部的外设资源支持,所以这里的端口(SDA,SCL),其实可以任意指定,不局限于这两个端口,你也可以SCL接PAO,SDA接PB12,或者SCL接PA8,SDA接PA9看,等等等等,接在任意的两个普通的GPIO口就可以。
软件I2C,只需要用gpio的读写函数就行了,就不用I2C的库函数了。

程序的整体框架:

MyI2C.h

#ifndef __MYI2C_H
#define __MYI2C_H

void MyI2C_Init(void);
void MyI2C_Start(void);
void MyI2C_Stop(void);
void MyI2C_SendByte(uint8_t Byte);
uint8_t MyI2C_ReceiveByte(void);
void MyI2C_SendAck(uint8_t AckBit);
uint8_t MyI2C_ReceiveAck(void);

#endif

MyI2C.C

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

/*引脚配置层*/

/**
  * 函    数:I2C写SCL引脚电平
  * 参    数:BitValue 协议层传入的当前需要写入SCL的电平,范围0~1
  * 返 回 值:无
  * 注意事项:此函数需要用户实现内容,当BitValue为0时,需要置SCL为低电平,当BitValue为1时,需要置SCL为高电平
  */
void MyI2C_W_SCL(uint8_t BitValue)
{
	GPIO_WriteBit(GPIOB, GPIO_Pin_10, (BitAction)BitValue);		//根据BitValue,设置SCL引脚的电平
	Delay_us(10);												//延时10us,防止时序频率超过要求
}

/**
  * 函    数:I2C写SDA引脚电平
  * 参    数:BitValue 协议层传入的当前需要写入SDA的电平,范围0~0xFF
  * 返 回 值:无
  * 注意事项:此函数需要用户实现内容,当BitValue为0时,需要置SDA为低电平,当BitValue非0时,需要置SDA为高电平
  */
void MyI2C_W_SDA(uint8_t BitValue)
{
	GPIO_WriteBit(GPIOB, GPIO_Pin_11, (BitAction)BitValue);		//根据BitValue,设置SDA引脚的电平,BitValue要实现非0即1的特性
	Delay_us(10);												//延时10us,防止时序频率超过要求
}

/**
  * 函    数:I2C读SDA引脚电平
  * 参    数:无
  * 返 回 值:协议层需要得到的当前SDA的电平,范围0~1
  * 注意事项:此函数需要用户实现内容,当前SDA为低电平时,返回0,当前SDA为高电平时,返回1
  */
uint8_t MyI2C_R_SDA(void)
{
	uint8_t BitValue;
	BitValue = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11);		//读取SDA电平
	Delay_us(10);												//延时10us,防止时序频率超过要求
	return BitValue;											//返回SDA电平
}

/**
  * 函    数:I2C初始化
  * 参    数:无
  * 返 回 值:无
  * 注意事项:此函数需要用户实现内容,实现SCL和SDA引脚的初始化
  */
void MyI2C_Init(void)
{
	/*开启时钟*/
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);	//开启GPIOB的时钟
	
	/*GPIO初始化*/
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOB, &GPIO_InitStructure);					//将PB10和PB11引脚初始化为开漏输出
	
	/*设置默认电平*/
	GPIO_SetBits(GPIOB, GPIO_Pin_10 | GPIO_Pin_11);			//设置PB10和PB11引脚初始化后默认为高电平(释放总线状态)
}

/*协议层*/

/**
  * 函    数:I2C起始
  * 参    数:无
  * 返 回 值:无
  */
void MyI2C_Start(void)
{
	MyI2C_W_SDA(1);							//释放SDA,确保SDA为高电平
	MyI2C_W_SCL(1);							//释放SCL,确保SCL为高电平
	MyI2C_W_SDA(0);							//在SCL高电平期间,拉低SDA,产生起始信号
	MyI2C_W_SCL(0);							//起始后把SCL也拉低,即为了占用总线,也为了方便总线时序的拼接
}

/**
  * 函    数:I2C终止
  * 参    数:无
  * 返 回 值:无
  */
void MyI2C_Stop(void)
{
	MyI2C_W_SDA(0);							//拉低SDA,确保SDA为低电平
	MyI2C_W_SCL(1);							//释放SCL,使SCL呈现高电平
	MyI2C_W_SDA(1);							//在SCL高电平期间,释放SDA,产生终止信号
}

/**
  * 函    数:I2C发送一个字节
  * 参    数:Byte 要发送的一个字节数据,范围:0x00~0xFF
  * 返 回 值:无
  */
void MyI2C_SendByte(uint8_t Byte)
{
	uint8_t i;
	for (i = 0; i < 8; i ++)				//循环8次,主机依次发送数据的每一位
	{
		MyI2C_W_SDA(Byte & (0x80 >> i));	//使用掩码的方式取出Byte的指定一位数据并写入到SDA线
		MyI2C_W_SCL(1);						//释放SCL,从机在SCL高电平期间读取SDA
		MyI2C_W_SCL(0);						//拉低SCL,主机开始发送下一位数据
	}
}

/**
  * 函    数:I2C接收一个字节
  * 参    数:无
  * 返 回 值:接收到的一个字节数据,范围:0x00~0xFF
  */
uint8_t MyI2C_ReceiveByte(void)
{
	uint8_t i, Byte = 0x00;					//定义接收的数据,并赋初值0x00,此处必须赋初值0x00,后面会用到
	MyI2C_W_SDA(1);							//接收前,主机先确保释放SDA,避免干扰从机的数据发送
	for (i = 0; i < 8; i ++)				//循环8次,主机依次接收数据的每一位
	{
		MyI2C_W_SCL(1);						//释放SCL,主机机在SCL高电平期间读取SDA
		if (MyI2C_R_SDA() == 1){Byte |= (0x80 >> i);}	//读取SDA数据,并存储到Byte变量
														//当SDA为1时,置变量指定位为1,当SDA为0时,不做处理,指定位为默认的初值0
		MyI2C_W_SCL(0);						//拉低SCL,从机在SCL低电平期间写入SDA
	}
	return Byte;							//返回接收到的一个字节数据
}

/**
  * 函    数:I2C发送应答位
  * 参    数:Byte 要发送的应答位,范围:0~1,0表示应答,1表示非应答
  * 返 回 值:无
  */
void MyI2C_SendAck(uint8_t AckBit)
{
	MyI2C_W_SDA(AckBit);					//主机把应答位数据放到SDA线
	MyI2C_W_SCL(1);							//释放SCL,从机在SCL高电平期间,读取应答位
	MyI2C_W_SCL(0);							//拉低SCL,开始下一个时序模块
}

/**
  * 函    数:I2C接收应答位
  * 参    数:无
  * 返 回 值:接收到的应答位,范围:0~1,0表示应答,1表示非应答
  */
uint8_t MyI2C_ReceiveAck(void)
{
	uint8_t AckBit;							//定义应答位变量
	MyI2C_W_SDA(1);							//接收前,主机先确保释放SDA,避免干扰从机的数据发送
	MyI2C_W_SCL(1);							//释放SCL,主机机在SCL高电平期间读取SDA
	AckBit = MyI2C_R_SDA();					//将应答位存储到变量里
	MyI2C_W_SCL(0);							//拉低SCL,开始下一个时序模块
	return AckBit;							//返回定义应答位变量
}

函数逻辑:

  • void MyI2C_Start(void)

    如果起始条件之前SCL和SDA已经是高电平了,那先释放哪一个是一样的效果,但是在指定地址读中,为了改变读写标志位,我们这个Start还要兼容这里的重复起始条件Sr。

    Sr最开始,SCL是低电平,SDA电平不敢确定,所以保险起见,我们趁SCL是低电平,先确保释放SDA,再释放SCL,这时SDA和SCL都是高电平,然后再拉低SDA、拉低SCL,这样这个Start就可以兼容起始条件和重复起始条件了。
    【如果先释放SCL,在SCL高电平期间再释放SDA会被误以为是终止条件;这里Sr是需要重新生成一个开始条件即SCL高电平期间,SDA从高变低。如果不先拉低SDA,就容易造成。SCL高电平期间,SDA从低变高。变成结束信号了。】

  • void MyI2C_Stop(void)


    在这里,如果Stop开始时,那就先释放SCL,再释放SDA就行了,但是在这个时序单元开始时,SDA并不一定是低电平,所以为了确保之后释放SDA能产生上升沿,我们要在时序单元开始时,先拉低SDA,然后再释放SCL、释放SDA。

  • void MyI2C_SendByte(uint8_t Byte)

    发送一个字节时序开始时,SCL是低电平,实际上,除了终止条件,SCL以高电平结束,所有的单元我们都会保证SCL以低电平结束,这样方便各个单元的拼接。
    补充:
    Byte & 0x80 就是用按位与的方式,取出数据的某一位或某几位,感觉这里准确的讲是检查位是否为1,而不是取出最高位

MPU6050_Reg.h

#ifndef __MPU6050_REG_H
#define __MPU6050_REG_H

#define	MPU6050_SMPLRT_DIV		0x19
#define	MPU6050_CONFIG			0x1A
#define	MPU6050_GYRO_CONFIG		0x1B
#define	MPU6050_ACCEL_CONFIG	0x1C

#define	MPU6050_ACCEL_XOUT_H	0x3B
#define	MPU6050_ACCEL_XOUT_L	0x3C
#define	MPU6050_ACCEL_YOUT_H	0x3D
#define	MPU6050_ACCEL_YOUT_L	0x3E
#define	MPU6050_ACCEL_ZOUT_H	0x3F
#define	MPU6050_ACCEL_ZOUT_L	0x40
#define	MPU6050_TEMP_OUT_H		0x41
#define	MPU6050_TEMP_OUT_L		0x42
#define	MPU6050_GYRO_XOUT_H		0x43
#define	MPU6050_GYRO_XOUT_L		0x44
#define	MPU6050_GYRO_YOUT_H		0x45
#define	MPU6050_GYRO_YOUT_L		0x46
#define	MPU6050_GYRO_ZOUT_H		0x47
#define	MPU6050_GYRO_ZOUT_L		0x48

#define	MPU6050_PWR_MGMT_1		0x6B
#define	MPU6050_PWR_MGMT_2		0x6C
#define	MPU6050_WHO_AM_I		0x75

#endif

MPU6050.h

#ifndef __MPU6050_H
#define __MPU6050_H

void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data);
uint8_t MPU6050_ReadReg(uint8_t RegAddress);

void MPU6050_Init(void);
uint8_t MPU6050_GetID(void);
void MPU6050_GetData(int16_t *AccX, int16_t *AccY, int16_t *AccZ, 
						int16_t *GyroX, int16_t *GyroY, int16_t *GyroZ);

#endif

MPU6050.c

#include "stm32f10x.h"                  // Device header
#include "MyI2C.h"
#include "MPU6050_Reg.h"

#define MPU6050_ADDRESS		0xD0		//MPU6050的I2C从机地址

/**
  * 函    数:MPU6050写寄存器
  * 参    数:RegAddress 寄存器地址,范围:参考MPU6050手册的寄存器描述
  * 参    数:Data 要写入寄存器的数据,范围:0x00~0xFF
  * 返 回 值:无
  */
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{
	MyI2C_Start();						//I2C起始
	MyI2C_SendByte(MPU6050_ADDRESS);	//发送从机地址,读写位为0,表示即将写入
	MyI2C_ReceiveAck();					//接收应答
	MyI2C_SendByte(RegAddress);			//发送寄存器地址
	MyI2C_ReceiveAck();					//接收应答
	MyI2C_SendByte(Data);				//发送要写入寄存器的数据
	MyI2C_ReceiveAck();					//接收应答
	MyI2C_Stop();						//I2C终止
}

/**
  * 函    数:MPU6050读寄存器
  * 参    数:RegAddress 寄存器地址,范围:参考MPU6050手册的寄存器描述
  * 返 回 值:读取寄存器的数据,范围:0x00~0xFF
  */
uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{
	uint8_t Data;
	
	MyI2C_Start();						//I2C起始
	MyI2C_SendByte(MPU6050_ADDRESS);	//发送从机地址,读写位为0,表示即将写入
	MyI2C_ReceiveAck();					//接收应答
	MyI2C_SendByte(RegAddress);			//发送寄存器地址
	MyI2C_ReceiveAck();					//接收应答
	
	MyI2C_Start();						//I2C重复起始
	MyI2C_SendByte(MPU6050_ADDRESS | 0x01);	//发送从机地址,读写位为1,表示即将读取
	MyI2C_ReceiveAck();					//接收应答
	Data = MyI2C_ReceiveByte();			//接收指定寄存器的数据
	MyI2C_SendAck(1);					//发送应答,给从机非应答,终止从机的数据输出
	MyI2C_Stop();						//I2C终止
	
	return Data;
}

/**
  * 函    数:MPU6050初始化
  * 参    数:无
  * 返 回 值:无
  */
void MPU6050_Init(void)
{
	MyI2C_Init();									//先初始化底层的I2C
	
	/*MPU6050寄存器初始化,需要对照MPU6050手册的寄存器描述配置,此处仅配置了部分重要的寄存器*/
	MPU6050_WriteReg(MPU6050_PWR_MGMT_1, 0x01);		//电源管理寄存器1,取消睡眠模式,选择时钟源为X轴陀螺仪
	MPU6050_WriteReg(MPU6050_PWR_MGMT_2, 0x00);		//电源管理寄存器2,保持默认值0,所有轴均不待机
	MPU6050_WriteReg(MPU6050_SMPLRT_DIV, 0x09);		//采样率分频寄存器,配置采样率
	MPU6050_WriteReg(MPU6050_CONFIG, 0x06);			//配置寄存器,配置DLPF
	MPU6050_WriteReg(MPU6050_GYRO_CONFIG, 0x18);	//陀螺仪配置寄存器,选择满量程为±2000°/s
	MPU6050_WriteReg(MPU6050_ACCEL_CONFIG, 0x18);	//加速度计配置寄存器,选择满量程为±16g
}

/**
  * 函    数:MPU6050获取ID号
  * 参    数:无
  * 返 回 值:MPU6050的ID号
  */
uint8_t MPU6050_GetID(void)
{
	return MPU6050_ReadReg(MPU6050_WHO_AM_I);		//返回WHO_AM_I寄存器的值
}

/**
  * 函    数:MPU6050获取数据
  * 参    数:AccX AccY AccZ 加速度计X、Y、Z轴的数据,使用输出参数的形式返回,范围:-32768~32767
  * 参    数:GyroX GyroY GyroZ 陀螺仪X、Y、Z轴的数据,使用输出参数的形式返回,范围:-32768~32767
  * 返 回 值:无
  */
void MPU6050_GetData(int16_t *AccX, int16_t *AccY, int16_t *AccZ, 
						int16_t *GyroX, int16_t *GyroY, int16_t *GyroZ)
{
	uint8_t DataH, DataL;								//定义数据高8位和低8位的变量
	
	DataH = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_H);		//读取加速度计X轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_L);		//读取加速度计X轴的低8位数据
	*AccX = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
	
	DataH = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_H);		//读取加速度计Y轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_L);		//读取加速度计Y轴的低8位数据
	*AccY = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
	
	DataH = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_H);		//读取加速度计Z轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_L);		//读取加速度计Z轴的低8位数据
	*AccZ = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
	
	DataH = MPU6050_ReadReg(MPU6050_GYRO_XOUT_H);		//读取陀螺仪X轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_GYRO_XOUT_L);		//读取陀螺仪X轴的低8位数据
	*GyroX = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
	
	DataH = MPU6050_ReadReg(MPU6050_GYRO_YOUT_H);		//读取陀螺仪Y轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_GYRO_YOUT_L);		//读取陀螺仪Y轴的低8位数据
	*GyroY = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
	
	DataH = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_H);		//读取陀螺仪Z轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_L);		//读取陀螺仪Z轴的低8位数据
	*GyroZ = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
}

main.c

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

uint8_t ID;								//定义用于存放ID号的变量
int16_t AX, AY, AZ, GX, GY, GZ;			//定义用于存放各个数据的变量

int main(void)
{
	/*模块初始化*/
	OLED_Init();		//OLED初始化
	MPU6050_Init();		//MPU6050初始化
	
	/*显示ID号*/
	OLED_ShowString(1, 1, "ID:");		//显示静态字符串
	ID = MPU6050_GetID();				//获取MPU6050的ID号
	OLED_ShowHexNum(1, 4, ID, 2);		//OLED显示ID号
	
	while (1)
	{
		MPU6050_GetData(&AX, &AY, &AZ, &GX, &GY, &GZ);		//获取MPU6050的数据
		OLED_ShowSignedNum(2, 1, AX, 5);					//OLED显示数据
		OLED_ShowSignedNum(3, 1, AY, 5);
		OLED_ShowSignedNum(4, 1, AZ, 5);
		OLED_ShowSignedNum(2, 8, GX, 5);
		OLED_ShowSignedNum(3, 8, GY, 5);
		OLED_ShowSignedNum(4, 8, GZ, 5);
	}
}

那之前的课程我们用的是软件I2C,手动拉低或释放时钟线,然后再手动对每个数据位进行判断,拉低或释放数据线,这样来产生这个的波形,这是软件I2C。由于12C是同步时序,这每一位的持续时间要求不严格,或许中途暂停一下时序,影响都不大,所以2C是比较容易用软件模拟的。
在实际项目中,软件模拟的I2C也是非常常见的,但是作为一个协议标准,I2C通信,也是可以有硬件收发电路的。就像之前的串口通信一样,我们先讲了串口的时序波形,但是在程序中,我们并没有用软件去手动翻转电平来实现这个波形,这是因为串口是异步时序,每一位的时间要求很严格,不能过长也不能过短,所以串口时序虽然可以用软件模拟,但是操作起来比较困难。另外,由于串口的硬件收发器在单片机中的普及程度非常高,基本上每个单片机都有串口的硬件资源,而且硬件实现的串口使用起来还非常简单,所以,串口通信,我们基本都是借助硬件收发器来实现的。

I2C通信外设

硬件实现串口(USART)的使用流程:首先配置USART外设,然后写入数据寄存器DR,然后硬件收发器就会自动生成波形发送出去,最后我们等待发送完成的标志位即可。
回到I2C这里,I2C也可以有软件模拟和硬件收发器自动操作这两种异步时序,对于串口这样的异步时序,软件实现麻烦,硬件实现简单,所以串口的实现基本是全部倒向硬件。而对于I2C这样的同步时序来说,软件实现简单灵活,硬件实现麻烦,但可以节省软件资源、可以实现完整的多主机通信模型等,各有优缺点。

I2C框图

I2C基本结构





代码实战:10-2硬件I2C读写MPU6050

MPU6050.h

#ifndef __MPU6050_H
#define __MPU6050_H

void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data);
uint8_t MPU6050_ReadReg(uint8_t RegAddress);

void MPU6050_Init(void);
uint8_t MPU6050_GetID(void);
void MPU6050_GetData(int16_t *AccX, int16_t *AccY, int16_t *AccZ, 
						int16_t *GyroX, int16_t *GyroY, int16_t *GyroZ);

#endif

MPU6050_REG.h

#ifndef __MPU6050_REG_H
#define __MPU6050_REG_H

#define	MPU6050_SMPLRT_DIV		0x19
#define	MPU6050_CONFIG			0x1A
#define	MPU6050_GYRO_CONFIG		0x1B
#define	MPU6050_ACCEL_CONFIG	0x1C

#define	MPU6050_ACCEL_XOUT_H	0x3B
#define	MPU6050_ACCEL_XOUT_L	0x3C
#define	MPU6050_ACCEL_YOUT_H	0x3D
#define	MPU6050_ACCEL_YOUT_L	0x3E
#define	MPU6050_ACCEL_ZOUT_H	0x3F
#define	MPU6050_ACCEL_ZOUT_L	0x40
#define	MPU6050_TEMP_OUT_H		0x41
#define	MPU6050_TEMP_OUT_L		0x42
#define	MPU6050_GYRO_XOUT_H		0x43
#define	MPU6050_GYRO_XOUT_L		0x44
#define	MPU6050_GYRO_YOUT_H		0x45
#define	MPU6050_GYRO_YOUT_L		0x46
#define	MPU6050_GYRO_ZOUT_H		0x47
#define	MPU6050_GYRO_ZOUT_L		0x48

#define	MPU6050_PWR_MGMT_1		0x6B
#define	MPU6050_PWR_MGMT_2		0x6C
#define	MPU6050_WHO_AM_I		0x75

#endif

MPU6050.c

#include "stm32f10x.h"                  // Device header
#include "MPU6050_Reg.h"

#define MPU6050_ADDRESS		0xD0		//MPU6050的I2C从机地址

/**
  * 函    数:MPU6050等待事件
  * 参    数:同I2C_CheckEvent
  * 返 回 值:无
  */
void MPU6050_WaitEvent(I2C_TypeDef* I2Cx, uint32_t I2C_EVENT)
{
	uint32_t Timeout;
	Timeout = 10000;									//给定超时计数时间
	while (I2C_CheckEvent(I2Cx, I2C_EVENT) != SUCCESS)	//循环等待指定事件
	{
		Timeout --;										//等待时,计数值自减
		if (Timeout == 0)								//自减到0后,等待超时
		{
			/*超时的错误处理代码,可以添加到此处*/
			break;										//跳出等待,不等了
		}
	}
}

/**
  * 函    数:MPU6050写寄存器
  * 参    数:RegAddress 寄存器地址,范围:参考MPU6050手册的寄存器描述
  * 参    数:Data 要写入寄存器的数据,范围:0x00~0xFF
  * 返 回 值:无
  */
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{
	I2C_GenerateSTART(I2C2, ENABLE);										//硬件I2C生成起始条件
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);					//等待EV5
	
	I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter);	//硬件I2C发送从机地址,方向为发送
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED);	//等待EV6
	
	I2C_SendData(I2C2, RegAddress);											//硬件I2C发送寄存器地址
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTING);			//等待EV8
	
	I2C_SendData(I2C2, Data);												//硬件I2C发送数据
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED);				//等待EV8_2
	
	I2C_GenerateSTOP(I2C2, ENABLE);											//硬件I2C生成终止条件
}

/**
  * 函    数:MPU6050读寄存器
  * 参    数:RegAddress 寄存器地址,范围:参考MPU6050手册的寄存器描述
  * 返 回 值:读取寄存器的数据,范围:0x00~0xFF
  */
uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{
	uint8_t Data;
	
	I2C_GenerateSTART(I2C2, ENABLE);										//硬件I2C生成起始条件
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);					//等待EV5
	
	I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter);	//硬件I2C发送从机地址,方向为发送
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED);	//等待EV6
	
	I2C_SendData(I2C2, RegAddress);											//硬件I2C发送寄存器地址
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED);				//等待EV8_2
	
	I2C_GenerateSTART(I2C2, ENABLE);										//硬件I2C生成重复起始条件
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);					//等待EV5
	
	I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Receiver);		//硬件I2C发送从机地址,方向为接收
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED);		//等待EV6
	
	I2C_AcknowledgeConfig(I2C2, DISABLE);									//在接收最后一个字节之前提前将应答失能
	I2C_GenerateSTOP(I2C2, ENABLE);											//在接收最后一个字节之前提前申请停止条件
	
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_RECEIVED);				//等待EV7
	Data = I2C_ReceiveData(I2C2);											//接收数据寄存器
	
	I2C_AcknowledgeConfig(I2C2, ENABLE);									//将应答恢复为使能,为了不影响后续可能产生的读取多字节操作
	
	return Data;
}

/**
  * 函    数:MPU6050初始化
  * 参    数:无
  * 返 回 值:无
  */
void MPU6050_Init(void)
{
	/*开启时钟*/
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C2, ENABLE);		//开启I2C2的时钟
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);		//开启GPIOB的时钟
	
	/*GPIO初始化*/
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOB, &GPIO_InitStructure);					//将PB10和PB11引脚初始化为复用开漏输出
	
	/*I2C初始化*/
	I2C_InitTypeDef I2C_InitStructure;						//定义结构体变量
	I2C_InitStructure.I2C_Mode = I2C_Mode_I2C;				//模式,选择为I2C模式
	I2C_InitStructure.I2C_ClockSpeed = 50000;				//时钟速度,选择为50KHz
	I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2;		//时钟占空比,选择Tlow/Thigh = 2
	I2C_InitStructure.I2C_Ack = I2C_Ack_Enable;				//应答,选择使能
	I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit;	//应答地址,选择7位,从机模式下才有效
	I2C_InitStructure.I2C_OwnAddress1 = 0x00;				//自身地址,从机模式下才有效
	I2C_Init(I2C2, &I2C_InitStructure);						//将结构体变量交给I2C_Init,配置I2C2
	
	/*I2C使能*/
	I2C_Cmd(I2C2, ENABLE);									//使能I2C2,开始运行
	
	/*MPU6050寄存器初始化,需要对照MPU6050手册的寄存器描述配置,此处仅配置了部分重要的寄存器*/
	MPU6050_WriteReg(MPU6050_PWR_MGMT_1, 0x01);				//电源管理寄存器1,取消睡眠模式,选择时钟源为X轴陀螺仪
	MPU6050_WriteReg(MPU6050_PWR_MGMT_2, 0x00);				//电源管理寄存器2,保持默认值0,所有轴均不待机
	MPU6050_WriteReg(MPU6050_SMPLRT_DIV, 0x09);				//采样率分频寄存器,配置采样率
	MPU6050_WriteReg(MPU6050_CONFIG, 0x06);					//配置寄存器,配置DLPF
	MPU6050_WriteReg(MPU6050_GYRO_CONFIG, 0x18);			//陀螺仪配置寄存器,选择满量程为±2000°/s
	MPU6050_WriteReg(MPU6050_ACCEL_CONFIG, 0x18);			//加速度计配置寄存器,选择满量程为±16g
}


/**
  * 函    数:MPU6050获取ID号
  * 参    数:无
  * 返 回 值:MPU6050的ID号
  */
uint8_t MPU6050_GetID(void)
{
	return MPU6050_ReadReg(MPU6050_WHO_AM_I);		//返回WHO_AM_I寄存器的值
}

/**
  * 函    数:MPU6050获取数据
  * 参    数:AccX AccY AccZ 加速度计X、Y、Z轴的数据,使用输出参数的形式返回,范围:-32768~32767
  * 参    数:GyroX GyroY GyroZ 陀螺仪X、Y、Z轴的数据,使用输出参数的形式返回,范围:-32768~32767
  * 返 回 值:无
  */
void MPU6050_GetData(int16_t *AccX, int16_t *AccY, int16_t *AccZ, 
						int16_t *GyroX, int16_t *GyroY, int16_t *GyroZ)
{
	uint8_t DataH, DataL;								//定义数据高8位和低8位的变量
	
	DataH = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_H);		//读取加速度计X轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_L);		//读取加速度计X轴的低8位数据
	*AccX = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
	
	DataH = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_H);		//读取加速度计Y轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_L);		//读取加速度计Y轴的低8位数据
	*AccY = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
	
	DataH = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_H);		//读取加速度计Z轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_L);		//读取加速度计Z轴的低8位数据
	*AccZ = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
	
	DataH = MPU6050_ReadReg(MPU6050_GYRO_XOUT_H);		//读取陀螺仪X轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_GYRO_XOUT_L);		//读取陀螺仪X轴的低8位数据
	*GyroX = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
	
	DataH = MPU6050_ReadReg(MPU6050_GYRO_YOUT_H);		//读取陀螺仪Y轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_GYRO_YOUT_L);		//读取陀螺仪Y轴的低8位数据
	*GyroY = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
	
	DataH = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_H);		//读取陀螺仪Z轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_L);		//读取陀螺仪Z轴的低8位数据
	*GyroZ = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
}

main.c

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

uint8_t ID;								//定义用于存放ID号的变量
int16_t AX, AY, AZ, GX, GY, GZ;			//定义用于存放各个数据的变量

int main(void)
{
	/*模块初始化*/
	OLED_Init();		//OLED初始化
	MPU6050_Init();		//MPU6050初始化
	
	/*显示ID号*/
	OLED_ShowString(1, 1, "ID:");		//显示静态字符串
	ID = MPU6050_GetID();				//获取MPU6050的ID号
	OLED_ShowHexNum(1, 4, ID, 2);		//OLED显示ID号
	
	while (1)
	{
		MPU6050_GetData(&AX, &AY, &AZ, &GX, &GY, &GZ);		//获取MPU6050的数据
		OLED_ShowSignedNum(2, 1, AX, 5);					//OLED显示数据
		OLED_ShowSignedNum(3, 1, AY, 5);
		OLED_ShowSignedNum(4, 1, AZ, 5);
		OLED_ShowSignedNum(2, 8, GX, 5);
		OLED_ShowSignedNum(3, 8, GY, 5);
		OLED_ShowSignedNum(4, 8, GZ, 5);
	}
}

SPI通信协议

  SPI协议是由摩托罗拉公司提出的通讯协议(Serial Peripheral Interface),即串行外围设备接口, 是一种高速全双工的通信总线。它被广泛地使用在ADC、LCD等设备与MCU间,要求通讯速率较高的场合。
  学习本章时,可与I2C章节对比阅读,体会两种通讯总线的差异以及EEPROM存储器与FLASH存储器的区别。下面我们分别对SPI协议的物理层及协议层进行讲解。

SPI物理层

SPI通讯设备之间的常用连接方式见图

  SPI通讯使用3条总线及片选线,3条总线分别为SCK、MOSI、MISO,片选线为SS,它们的作用介绍如下:

(1) SS ( Slave Select):从设备选择信号线,常称为片选信号线,也称为NSS、CS,以下用NSS表示。当有多个SPI从设备与SPI主机相连时, 设备的其它信号线SCK、MOSI及MISO同时并联到相同的SPI总线上,即无论有多少个从设备,都共同只使用这3条总线; 而每个从设备都有独立的这一条NSS信号线,本信号线独占主机的一个引脚,即有多少个从设备,就有多少条片选信号线。 I2C协议中通过设备地址来寻址、选中总线上的某个设备并与其进行通讯;而SPI协议中没有设备地址,它使用NSS信号线来寻址当主机要选择从设备时,把该从设备的NSS信号线设置为低电平,该从设备即被选中,即片选有效, 接着主机开始与被选中的从设备进行SPI通讯。所以SPI通讯以NSS线置低电平为开始信号,以NSS线被拉高作为结束信号

(2) SCK (Serial Clock):时钟信号线,用于通讯数据同步。它由通讯主机产生,决定了通讯的速率,不同的设备支持的最高时钟频率不一样, 如STM32的SPI时钟频率最大为fpclk/2,两个设备之间通讯时,通讯速率受限于低速设备。

(3) MOSI (Master Output, Slave Input):主设备输出/从设备输入引脚。主机的数据从这条信号线输出, 从机由这条信号线读入主机发送的数据,即这条线上数据的方向为主机到从机。

(4) MISO (Master Input,,Slave Output):主设备输入/从设备输出引脚。主机从这条信号线读入数据, 从机的数据由这条信号线输出到主机,即在这条线上数据的方向为从机到主机。

SPI协议层

与I2C的类似,SPI协议定义了通讯的起始和停止信号、数据有效性、时钟同步等环节。

SPI基本通讯过程

先看看SPI通讯的通讯时序,

  这是一个主机的通讯时序。NSS、SCK、MOSI信号都由主机控制产生,而MISO的信号由从机产生,主机通过该信号线读取从机的数据。 MOSI与MISO的信号只在NSS为低电平的时候才有效,在SCK的每个时钟周期MOSI和MISO传输一位数据。

以上通讯流程中包含的各个信号分解如下:

1.通讯的起始和停止信号

  在图 SPI通讯时序 中的标号处,NSS信号线由高变低,是SPI通讯的起始信号。NSS是每个从机各自独占的信号线, 当从机在自己的NSS线检测到起始信号后,就知道自己被主机选中了,开始准备与主机通讯。在图中的标号处,NSS信号由低变高, 是SPI通讯的停止信号,表示本次通讯结束,从机的选中状态被取消。

2.数据有效性

MSB高位先行,LSB低位先行

  SPI使用MOSI及MISO信号线来传输数据,使用SCK信号线进行数据同步。MOSI及MISO数据线在SCK的每个时钟周期传输一位数据, 且数据输入输出是同时进行的。数据传输时,MSB先行或LSB先行并没有作硬性规定,但要保证两个SPI通讯设备之间使用同样的协定, 一般都会采用图 SPI通讯时序 中的MSB先行模式。
  观察图中的标号处,MOSI及MISO的数据在SCK的上升沿期间变化输出,在SCK的下降沿时被采样。即在SCK的下降沿时刻, MOSI及MISO的数据有效,高电平时表示数据“1”,为低电平时表示数据“0”。在其它时刻,数据无效,MOSI及MISO为下一次表示数据做准备。
  SPI每次数据传输可以8位或16位为单位,每次传输的单位数不受限制。

3.CPOL/CPHA及通讯模式

  上面讲述的图 SPI通讯时序 中的时序只是SPI中的其中一种通讯模式,SPI一共有四种通讯模式, 它们的主要区别是总线空闲时SCK的时钟状态以及数据采样时刻。为方便说明,在此引入“时钟极性CPOL”和“时钟相位CPHA”的概念。

  时钟极性CPOL是指SPI通讯设备处于空闲状态时,SCK信号线的电平信号(即SPI通讯开始前、 NSS线为高电平时SCK的状态)。CPOL=0时, SCK在空闲状态时为低电平,CPOL=1时,则相反。
  时钟相位CPHA是指数据的采样的时刻,当CPHA=0时,MOSI或MISO数据线上的信号将会在SCK时钟线的“奇数边沿”被采样。当CPHA=1时, 数据线在SCK的“偶数边沿”采样。见图 CPHA = 0时的SPI通讯模式 及图 CPHA = 1时的SPI通讯模式 。

  我们来分析这个CPHA=0的时序图。首先,根据SCK在空闲状态时的电平,分为两种情况。 SCK信号线在空闲状态为低电平时,CPOL=0;空闲状态为高电平时,CPOL=1。
  无论CPOL=0还是=1,因为我们配置的时钟相位CPHA=0,在图中可以看到,采样时刻都是在SCK的奇数边沿。 注意当CPOL=0的时候,时钟的奇数边沿是上升沿,而CPOL=1的时候,时钟的奇数边沿是下降沿。所以SPI的采样时刻不是由上升/下降沿决定的。 MOSI和MISO数据线的有效信号在SCK的奇数边沿保持不变,数据信号将在SCK奇数边沿时被采样,在非采样时刻,MOSI和MISO的有效信号才发生切换
  类似地,当CPHA=1时,不受CPOL的影响,数据信号在SCK的偶数边沿被采样,见图 CPHA=1时的SPI通讯模式_ 。

  由CPOL及CPHA的不同状态,SPI分成了四种模式,见表 SPI的四种模式 , 主机与从机需要工作在相同的模式下才可以正常通讯,实际中采用较多的是“模式0”与“模式3”。

STM32的SPI特性及架构

与I2C外设一样,STM32芯片也集成了专门用于SPI协议通讯的外设。

1.STM32的SPI外设简介

  STM32的SPI外设可用作通讯的主机及从机, 支持最高的SCK时钟频率为fpclk/2 (STM32F103型号的芯片默认fpclk1为36MHz, fpclk2为72MHz),完全支持SPI协议的4种模式,数据帧长度可设置为8位或16位, 可设置数据MSB先行或LSB先行。它还支持双线全双工(前面小节说明的都是这种模式)、双线单向以及单线模式。 其中双线单向模式可以同时使用MOSI及MISO数据线向一个方向传输数据,可以加快一倍的传输速度。而单线模式则可以减少硬件接线, 当然这样速率会受到影响。我们只讲解双线全双工模式。

2. STM32的SPI架构剖析

1.通讯引脚

  SPI的所有硬件架构都从图 SPI架构图 中左侧MOSI、MISO、SCK及NSS线展开的。STM32芯片有多个SPI外设, 它们的SPI通讯信号引出到不同的GPIO引脚上,使用时必须配置到这些指定的引脚,见表 STM32F10x的SPI引脚 。 关于GPIO引脚的复用功能,可查阅《STM32F10x规格书》,以它为准。

  其中SPI1是APB2上的设备,最高通信速率达36Mbtis/s,SPI2、SPI3是APB1上的设备,最高通信速率为18Mbits/s。除了通讯速率, 在其它功能上没有差异。其中SPI3用到了下载接口的引脚,这几个引脚默认功能是下载,第二功能才是IO口,如果想使用SPI3接口, 则程序上必须先禁用掉这几个IO口的下载功能。一般在资源不是十分紧张的情况下,这几个IO口是专门用于下载和调试程序,不会复用为SPI3。

2. 时钟控制逻辑

  SCK线的时钟信号,由波特率发生器根据“控制寄存器CR1”中的BR[0:2]位控制,该位是对fpclk时钟的分频因子, 对fpclk的分频结果就是SCK引脚的输出时钟频率,计算方法见表 BR位对fpclk的分频 。

其中的fpclk频率是指SPI所在的APB总线频率, APB1为fpclk1,APB2为fpckl2。
通过配置“控制寄存器CR”的“CPOL位”及“CPHA”位可以把SPI设置成前面分析的4种SPI模式。

3. 数据控制逻辑

  SPI的MOSI及MISO都连接到数据移位寄存器上,数据移位寄存器的数据来源及目标接收、发送缓冲区以及MISO、MOSI线。 当向外发送数据的时候,数据移位寄存器以“发送缓冲区”为数据源,把数据一位一位地通过数据线发送出去;当从外部接收数据的时候, 数据移位寄存器把数据线采样到的数据一位一位地存储到“接收缓冲区”中。通过写SPI的“数据寄存器DR”把数据填充到发送缓冲区中, 通讯读“数据寄存器DR”,可以获取接收缓冲区中的内容。其中数据帧长度可以通过“控制寄存器CR1”的“DFF位”配置成8位及16位模式; 配置“LSBFIRST位”可选择MSB先行还是LSB先行。

4. 整体控制逻辑

  整体控制逻辑负责协调整个SPI外设,控制逻辑的工作模式根据我们配置的“控制寄存器(CR1/CR2)”的参数而改变, 基本的控制参数包括前面提到的SPI模式、波特率、LSB先行、主从模式、单双向模式等等。在外设工作时, 控制逻辑会根据外设的工作状态修改“状态寄存器(SR)”,我们只要读取状态寄存器相关的寄存器位, 就可以了解SPI的工作状态了。除此之外,控制逻辑还根据要求,负责控制产生SPI中断信号、DMA请求及控制NSS信号线。
  实际应用中,我们一般不使用STM32 SPI外设的标准NSS信号线,而是更简单地使用普通的GPIO,软件控制它的电平输出,从而产生通讯起始和停止信号

3.通讯过程

  STM32使用SPI外设通讯时,在通讯的不同阶段它会对“状态寄存器SR”的不同数据位写入参数,我们通过读取这些寄存器标志来了解通讯状态。
  图 主发送器通讯过程 中的是“主模式”流程,即STM32作为SPI通讯的主机端时的数据收发过程。

主模式收发流程及事件说明如下:

(1) 控制NSS信号线, 产生起始信号(图中没有画出);

(2) 把要发送的数据写入到“数据寄存器DR”中, 该数据会被存储到发送缓冲区;

(3) 通讯开始,SCK时钟开始运行。MOSI把发送缓冲区中的数据一位一位地传输出去; MISO则把数据一位一位地存储进接收缓冲区中;

(4) 当发送完一帧数据的时候,“状态寄存器SR”中的“TXE标志位”会被置1,表示传输完一帧,发送缓冲区已空;类似地, 当接收完一帧数据的时候,“RXNE标志位”会被置1,表示传输完一帧,接收缓冲区非空;

(5) 等待到“TXE标志位”为1时,若还要继续发送数据,则再次往“数据寄存器DR”写入数据即可;等待到“RXNE标志位”为1时, 通过读取“数据寄存器DR”可以获取接收缓冲区中的内容。

  假如我们使能了TXE或RXNE中断,TXE或RXNE置1时会产生SPI中断信号,进入同一个中断服务函数,到SPI中断服务程序后, 可通过检查寄存器位来了解是哪一个事件,再分别进行处理。也可以使用DMA方式来收发“数据寄存器DR”中的数据。

4.实战——读写串行FLASH

1.硬件连接


注意:
如果SPI通讯中的三个从机都是推挽输出(Push-Pull Output),那么在没有适当管理的情况下,当多个从设备同时驱动MISO线时,就会发生线路冲突,这可能导致数据错误和通信故障。为了解决这个问题,【从机】一般采用下面的处理方式:

片选(Chip Select):每个从设备都有一个独立的片选线,用于启用或禁用该设备的输出。当主设备想要与某个从设备通信时,它会通过激活相应的片选线来选择该从设备。未被选中的从设备会将其MISO输出设置为高阻态(High-Impedance),从而不会对MISO线产生影响。

2.软件设计



编程要点:

  1. 初始化通讯使用的目标引脚及端口时钟;
  2. 使能SPI外设的时钟;
  3. 配置SPI外设的模式、地址、速率等参数并使能SPI外设;
  4. 编写基本SPI按字节收发的函数;
  5. 编写对FLASH擦除及读写操作的的函数;
  6. 编写测试程序,对读写数据进行校验。

参考文章:

什么是串行与并行?串行和并行各自有什么优越点和应用场景?
什么是同步通信?什么是异步通信?两者的优缺点是什么?

BKP备份寄存器&RTC实时时钟

Unix时间戳

这小节的内容属于计算机领域的一个通用知识点,不特别应用在STM32中。

GMT(Greenwich Mean Time)格林尼治标准时间是一种以地球自转为基础的时间计量系统。它将地球自转一周的时间间隔等分为24小时,以此确定计时标准
UTC(Universal Time Coordinated)协调世界时是一种以原子钟为基础的时间计量系统。它规定铯133原子基态的两个超精细能级间在零磁场下跃迁辐射9,192.631.770周所持续的时间为1秒。当原子钟计时一天的时间与地球自转一周的时间相差超过0.9秒时,UTC会执行闰秒来保证其计时与地球自转的协调一致
润秒,也称为闰秒,是国际地球自转服务(IERS)为了使协调世界时(UTC)与地球自转时间保持一致而插入或删除的额外一秒钟。由于地球自转的不均匀性,如潮汐摩擦和地球内部的变化,平均日长会发生变化。为了保持协调世界时与原子时标(International Atomic Time, TAI)的一致性,需要不定期地调整闰秒。

时间戳转换


#include <stdio.h>
#include <time.h>

int main() {
    // 获取当前时间
    time_t now;
    time(&now); // 获取当前时间戳

    // 使用localtime将时间戳转换为本地时间结构体
    struct tm *local = localtime(&now);
    if (local == NULL) {
        fprintf(stderr, "Error in localtime\n");
        return 1;
    }

    // 打印当前时间
    printf("当前时间: %d-%d-%d %d:%d:%d\n", local->tm_year + 1900, local->tm_mon + 1, local->tm_mday, local->tm_hour, local->tm_min, local->tm_sec);

    // 使用asctime将时间结构体转换为字符串
    char *asctime_str = asctime(local);
    printf("时间字符串: %s", asctime_str);

    // 使用difftime计算两个时间之间的差值
    time_t future_time = now + 10; // 假设的未来时间(10秒后)
    double diff = difftime(future_time, now);
    printf("当前时间与未来时间相差: %f 秒\n", diff);

    // 使用strftime格式化时间字符串
    char formatted_time[100];
    strftime(formatted_time, sizeof(formatted_time), "%Y-%m-%d %H:%M:%S", local);
    printf("格式化后的时间: %s\n", formatted_time);

    return 0;
}

BKP

简介

基本结构

  • 电池供电:图中橙色区域可以称作后备区域,STM32的后备区域特性是当VDD主电源掉电时,后备区域仍然可以由VBAT的备用电池供电;当VDD主电源上电时,后备区域由VBAT切换到VDD。
  • 侵入检测:这个功能可以用来检测对单片机封装的物理攻击,如打开封装、温度变化、电压干扰等。当TAMPER引脚检测到侵入事件时,单片机可以触发一个中断,或者将后备寄存器中的特定数据清零,以保护存储在其中的敏感信息。
  • 时钟输出:可以将RTC的相关时钟,从PC13位置的RTC引脚输出出去,供外部使用。其中,输出校准时钟时,再配合这个校准寄存器,可以对RTC的误差进行校准。

单片机的后备区域(Backup Domain)是指在单片机中,为了在系统主电源失效时仍能保持数据不丢失的区域。这个区域通常由一个专门的电源域组成,该电源域由一个纽扣电池或超级电容供电,以确保在主电源断电时,后备区域中的数据仍然能够得到保存。
后备区域通常包括以下部分:
后备寄存器(Backup Registers):这是一组存储单元,可以在主电源断电时保持其内容。它们通常用于存储关键的数据,如系统配置参数、实时时钟(RTC)的配置和状态等。
实时时钟(RTC):实时时钟是一个能够在主电源断电时继续运行的时钟模块,它通常有自己的电源域和振荡器,可以在后备电源的支持下继续计时。
后备SRAM:部分单片机在后备区域中包含一定量的SRAM,这些SRAM可以在主电源断电时保持数据不丢失。
唤醒电路:有些单片机的后备区域还包含唤醒电路,可以在后备电源的支持下检测外部事件或定时器事件,并在需要时重新启动系统。
后备区域的设计是为了在主电源断电或系统复位的情况下,保持关键数据不丢失,并能够在适当的时刻恢复系统的运行状态。这在需要高可靠性和数据保持的应用中非常重要,如医疗设备、工业控制系统、汽车电子等。

RTC

简介


RTCCLK是STM32微控制器中RTC模块的时钟源,它有三种可能的来源:HSE时钟除以128、LSE振荡器和LSI振荡器。下面是这三种时钟源的详细讲解和工作机制:

  • HSE时钟除以128
    HSE(High-Speed External)时钟是STM32的一个外部时钟源,通常由一个晶振或陶瓷谐振器提供。HSE时钟的频率可以是4MHz、8MHz、16MHz等,但最常用的是8MHz。
    工作机制:当选择HSE作为RTCCLK源时,HSE时钟首先被分频器除以128,得到62.5kHz的时钟信号。然后,这个信号再通过RTC的预分频器进一步分频,以产生1Hz的RTC时钟。由于HSE时钟的频率较高,这种配置可以提供较快的时钟初始化时间。
    优点:不需要额外的晶振,节省成本和空间。
    缺点:在VDD电源断电时,HSE时钟也会停止,因此需要VBAT电源来维持RTC运行。

  • LSE振荡器
    LSE(Low-Speed External)振荡器是一个32.768kHz的晶振,专门用于提供低功耗的时钟信号给RTC模块。
    工作机制:LSE振荡器直接提供32.768kHz的时钟信号给RTC。这个频率非常适合RTC,因为它可以被精确地分频为1Hz。RTC的预分频器设置为32768,这样每32.768kHz的周期就对应于1秒的RTC时钟。【一秒32768hz,那么这个频率完成2^15次计数产生溢出所用的时间就是1s,也就是1s一次自然溢出。这样就不用额外设计一个计数目标】
    优点:频率稳定,适合长时间计时;只有这一路在VDD电源断电时,LSE振荡器可以由VBAT电源供电,保证RTC的持续运行。

  • LSI振荡器
    LSI(Low-Speed Internal)振荡器是一个内置的RC振荡器,其频率为40kHz。【内部RC振荡器一般没有外部晶振高】
    工作机制:LSI振荡器提供40kHz的时钟信号给RTC。由于这个频率不是标准的RTC时钟频率,因此需要通过RTC的预分频器进行分频,以得到接近1Hz的RTC时钟。由于LSI的频率并不非常稳定,因此它通常不用于对时间精度要求很高的应用。
    优点:不需要外部晶振,降低了成本和电路复杂性。
    缺点:频率不稳定,可能导致时间误差;在VDD电源断电时,LSI振荡器也会停止,因此需要VBAT电源来维持RTC运行。

最常用选择LSE(Low-Speed External)振荡器作为RTC(Real Time Clock)的时钟来源的原因主要有以下几点:

  1. 本身就专供RTC使用的,其余都有各自的任务:HSE主要作为系统主时钟,LSI主要作为看门狗时钟。
  2. 最重要的原因只有它可以通过VBAT备用电池供电,其余两路时钟,在主电源断电后,是停止运行的

框图


详细讲解,有需求可以看视频,更好学习。

RTC基本结构


首先,我们需要确定RTC的时钟源。随后,RTCCLK将通过预分频器进行分频处理。在这个过程中,余数寄存器充当一个自减计数器,负责记录当前的计数值,而重装寄存器则设定了计数的目标值,从而决定了分频的比率。完成分频后,我们得到一个1Hz的秒脉冲信号,该信号被送入一个32位的计数器,该计数器每秒钟递增一次。此外,还存在一个32位的闹钟寄存器,用于设置闹钟时间;如果不使用闹钟功能,可以忽略这一部分。

在右侧,有三个中断信号源,分别是秒脉冲信号、计数器溢出信号和闹钟信号。这些信号首先需要通过中断输出控制进行使能,只有被使能的中断信号才能传递到NVIC(嵌套向量中断控制器),进而向CPU发出中断请求。

在编程过程中,我们首先设置数据选择器以选择时钟源,接着配置重装寄存器以确定分频系数;配置32位计数器以进行日期和时间的读写操作;如果需要闹钟功能,配置32位闹钟值即可;对于中断功能,首先启用中断,然后配置NVIC,并编写相应的中断服务函数。这些步骤构成了RTC外设配置的核心内容。

RTC硬件电路

在最小系统上,外部电路还要额外额外加两部分:第一部分是备用电池供电;第二部分是外部低速晶振。

备用电池供电
  1. 简单连接
    就是直接使用一个3V的电池,负极和系统供地,正极直接引到STM32的VBAT引脚,参考是手册中的内容:

  2. 推荐连接
    在VBAT引脚和备用电池之间,通常会放置一个二极管(如1N4148)。这个二极管的作用是在主电源存在时阻止电池向系统供电,并在主电源断电时允许电池为系统供电。在VBAT引脚附近,通常会放置一个退耦电容(如10uF),用于平滑电源电压,减少电源噪声,确保RTC的稳定运行。
    这个方案参考手册中:

外部低速晶振


STM32 两个晶振的作用

RTC操作注意事项

  • 首先,值得注意的是,虽然大多数外设只需开启时钟即可使用,但BKP和RTC的操作相对复杂。若需使用这两个外设,必须遵循以下两步第一步是开启PWR和BKP的时钟,第二步是通过PWR使能对BKP和RTC的访问。在初始化过程中,务必按照这一流程操作
  • 当我们读取RTC寄存器时,需要特别留意,如果RTC的APB1接口之前处于禁止状态,我们必须等待RTC_CRL寄存器中的RSF位被硬件置1,这一步在代码中对应的是调用RTC等待同步的库函数。通常,这个函数会在设备刚上电时执行一次。为什么要有这一步呢?可以看看框图,是因为存在两个不同的时钟域:PCLK1(36MHz)和RTCCLK((32KHz)。PCLK1在主电源掉电时会停止,而RTCCLK则不会,它由RTC的晶振驱动,确保RTC在电源掉电时仍能正常工作

    在读取RTC寄存器时,由于PCLK1和RTCCLK的频率不同,会出现时钟不同步的问题。RTC寄存器的值是在RTCCLK的上升沿更新的,但PCLK1的频率远高于RTCCLK。如果在APB1接口刚刚启用时就立即读取RTC寄存器,可能会读取到还未同步的旧值,通常会是0。因此,在APB1总线刚开机时,我们需要等待RTCCLK的上升沿,以确保RTC寄存器的值已经同步到APB1总线上。这个过程是自动的,只需调用等待同步的函数即可
  • 接下来,必须设置RTC_CRL寄存器中的CNF位,使RTC进入配置模式,才能写入RTC_PRL、RTC_CNT、RTC_ALR寄存器。这一操作虽然简单,但它是RTC设置时间的关键步骤。在库函数中,每个写寄存器的函数都会自动执行这一操作,因此无需单独调用函数进入配置模式。
  • 最后,对RTC任何寄存器的写操作,都应在前一次写操作完成后进行。通过查询RTC_CR寄存器中的RTOFF状态位,可以判断RTC寄存器是否处于更新中。只有当RTOFF状态位为1时,才能进行下一次写入操作。这一步骤在代码中同样通过调用一个等待函数实现。这与读写flash芯片的操作类似,旨在确保写入操作的完整性。原因在于PCLK1和RTCCLK的时钟频率不同,写入操作完成后需等待RTCCLK的上升沿,以确保值正确更新到RTC寄存器。了解这一操作后,在代码中只需调用相应的等待函数即可。

代码实战:读写备份寄存器&事实时钟

  • 读写备份寄存器接线图

    main.c
#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "Key.h"

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

uint16_t ArrayWrite[] = {0x1234, 0x5678};	//定义要写入数据的测试数组
uint16_t ArrayRead[2];						//定义要读取数据的测试数组

int main(void)
{
	/*模块初始化*/
	OLED_Init();				//OLED初始化
	Key_Init();					//按键初始化
	
	/*显示静态字符串*/
	OLED_ShowString(1, 1, "W:");
	OLED_ShowString(2, 1, "R:");
	
	/*开启时钟*/
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);		//开启PWR的时钟
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_BKP, ENABLE);		//开启BKP的时钟
	
	/*备份寄存器访问使能*/
	PWR_BackupAccessCmd(ENABLE);							//使用PWR开启对备份寄存器的访问
	
	while (1)
	{
		KeyNum = Key_GetNum();		//获取按键键码
		
		if (KeyNum == 1)			//按键1按下
		{
			ArrayWrite[0] ++;		//测试数据自增
			ArrayWrite[1] ++;
			
			BKP_WriteBackupRegister(BKP_DR1, ArrayWrite[0]);	//写入测试数据到备份寄存器
			BKP_WriteBackupRegister(BKP_DR2, ArrayWrite[1]);
			
			OLED_ShowHexNum(1, 3, ArrayWrite[0], 4);		//显示写入的测试数据
			OLED_ShowHexNum(1, 8, ArrayWrite[1], 4);
		}
		
		ArrayRead[0] = BKP_ReadBackupRegister(BKP_DR1);		//读取备份寄存器的数据
		ArrayRead[1] = BKP_ReadBackupRegister(BKP_DR2);
		
		OLED_ShowHexNum(2, 3, ArrayRead[0], 4);				//显示读取的备份寄存器数据
		OLED_ShowHexNum(2, 8, ArrayRead[1], 4);
	}
}

  • 实时时钟接线图

    main.c
#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "MyRTC.h"

int main(void)
{
	/*模块初始化*/
	OLED_Init();		//OLED初始化
	MyRTC_Init();		//RTC初始化
	
	/*显示静态字符串*/
	OLED_ShowString(1, 1, "Date:XXXX-XX-XX");
	OLED_ShowString(2, 1, "Time:XX:XX:XX");
	OLED_ShowString(3, 1, "CNT :");
	OLED_ShowString(4, 1, "DIV :");
	
	while (1)
	{
		MyRTC_ReadTime();							//RTC读取时间,最新的时间存储到MyRTC_Time数组中
		
		OLED_ShowNum(1, 6, MyRTC_Time[0], 4);		//显示MyRTC_Time数组中的时间值,年
		OLED_ShowNum(1, 11, MyRTC_Time[1], 2);		//月
		OLED_ShowNum(1, 14, MyRTC_Time[2], 2);		//日
		OLED_ShowNum(2, 6, MyRTC_Time[3], 2);		//时
		OLED_ShowNum(2, 9, MyRTC_Time[4], 2);		//分
		OLED_ShowNum(2, 12, MyRTC_Time[5], 2);		//秒
		
		OLED_ShowNum(3, 6, RTC_GetCounter(), 10);	//显示32位的秒计数器
		OLED_ShowNum(4, 6, RTC_GetDivider(), 10);	//显示余数寄存器
	}
}

My_RTC.c

#include "stm32f10x.h"                  // Device header
#include <time.h>

uint16_t MyRTC_Time[] = {2023, 1, 1, 23, 59, 55};	//定义全局的时间数组,数组内容分别为年、月、日、时、分、秒

void MyRTC_SetTime(void);				//函数声明

/**
  * 函    数:RTC初始化
  * 参    数:无
  * 返 回 值:无
  */
void MyRTC_Init(void)
{
	/*开启时钟*/
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);		//开启PWR的时钟
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_BKP, ENABLE);		//开启BKP的时钟
	
	/*备份寄存器访问使能*/
	PWR_BackupAccessCmd(ENABLE);							//使用PWR开启对备份寄存器的访问
	
	if (BKP_ReadBackupRegister(BKP_DR1) != 0xA5A5)			//通过写入备份寄存器的标志位,判断RTC是否是第一次配置
															//if成立则执行第一次的RTC配置
	{
		RCC_LSEConfig(RCC_LSE_ON);							//开启LSE时钟
		while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) != SET);	//等待LSE准备就绪
		
		RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE);				//选择RTCCLK来源为LSE
		RCC_RTCCLKCmd(ENABLE);								//RTCCLK使能
		
		RTC_WaitForSynchro();								//等待同步
		RTC_WaitForLastTask();								//等待上一次操作完成
		
		RTC_SetPrescaler(32768 - 1);						//设置RTC预分频器,预分频后的计数频率为1Hz
		RTC_WaitForLastTask();								//等待上一次操作完成
		
		MyRTC_SetTime();									//设置时间,调用此函数,全局数组里时间值刷新到RTC硬件电路
		
		BKP_WriteBackupRegister(BKP_DR1, 0xA5A5);			//在备份寄存器写入自己规定的标志位,用于判断RTC是不是第一次执行配置
	}
	else													//RTC不是第一次配置
	{
		RTC_WaitForSynchro();								//等待同步
		RTC_WaitForLastTask();								//等待上一次操作完成
	}
}

//如果LSE无法起振导致程序卡死在初始化函数中
//可将初始化函数替换为下述代码,使用LSI当作RTCCLK
//LSI无法由备用电源供电,故主电源掉电时,RTC走时会暂停
/* 
void MyRTC_Init(void)
{
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_BKP, ENABLE);
	
	PWR_BackupAccessCmd(ENABLE);
	
	if (BKP_ReadBackupRegister(BKP_DR1) != 0xA5A5)
	{
		RCC_LSICmd(ENABLE);
		while (RCC_GetFlagStatus(RCC_FLAG_LSIRDY) != SET);
		
		RCC_RTCCLKConfig(RCC_RTCCLKSource_LSI);
		RCC_RTCCLKCmd(ENABLE);
		
		RTC_WaitForSynchro();
		RTC_WaitForLastTask();
		
		RTC_SetPrescaler(40000 - 1);
		RTC_WaitForLastTask();
		
		MyRTC_SetTime();
		
		BKP_WriteBackupRegister(BKP_DR1, 0xA5A5);
	}
	else
	{
		RCC_LSICmd(ENABLE);				//即使不是第一次配置,也需要再次开启LSI时钟
		while (RCC_GetFlagStatus(RCC_FLAG_LSIRDY) != SET);
		
		RCC_RTCCLKConfig(RCC_RTCCLKSource_LSI);
		RCC_RTCCLKCmd(ENABLE);
		
		RTC_WaitForSynchro();
		RTC_WaitForLastTask();
	}
}*/

/**
  * 函    数:RTC设置时间
  * 参    数:无
  * 返 回 值:无
  * 说    明:调用此函数后,全局数组里时间值将刷新到RTC硬件电路
  */
void MyRTC_SetTime(void)
{
	time_t time_cnt;		//定义秒计数器数据类型
	struct tm time_date;	//定义日期时间数据类型
	
	time_date.tm_year = MyRTC_Time[0] - 1900;		//将数组的时间赋值给日期时间结构体
	time_date.tm_mon = MyRTC_Time[1] - 1;
	time_date.tm_mday = MyRTC_Time[2];
	time_date.tm_hour = MyRTC_Time[3];
	time_date.tm_min = MyRTC_Time[4];
	time_date.tm_sec = MyRTC_Time[5];
	
	time_cnt = mktime(&time_date) - 8 * 60 * 60;	//调用mktime函数,将日期时间转换为秒计数器格式
													//- 8 * 60 * 60为东八区的时区调整
	
	RTC_SetCounter(time_cnt);						//将秒计数器写入到RTC的CNT中
	RTC_WaitForLastTask();							//等待上一次操作完成
}

/**
  * 函    数:RTC读取时间
  * 参    数:无
  * 返 回 值:无
  * 说    明:调用此函数后,RTC硬件电路里时间值将刷新到全局数组
  */
void MyRTC_ReadTime(void)
{
	time_t time_cnt;		//定义秒计数器数据类型
	struct tm time_date;	//定义日期时间数据类型
	
	time_cnt = RTC_GetCounter() + 8 * 60 * 60;		//读取RTC的CNT,获取当前的秒计数器
													//+ 8 * 60 * 60为东八区的时区调整
	
	time_date = *localtime(&time_cnt);				//使用localtime函数,将秒计数器转换为日期时间格式
	
	MyRTC_Time[0] = time_date.tm_year + 1900;		//将日期时间结构体赋值给数组的时间
	MyRTC_Time[1] = time_date.tm_mon + 1;
	MyRTC_Time[2] = time_date.tm_mday;
	MyRTC_Time[3] = time_date.tm_hour;
	MyRTC_Time[4] = time_date.tm_min;
	MyRTC_Time[5] = time_date.tm_sec;
}

PWR电源控制

在电子设备中,待机(Standby)和睡眠(Sleep)是两种不同的省电模式。 1. 待机模式(Standby Mode):在待机模式下,设备仍然保持一定程度的活动,但大部分功能处于暂停状态。

电源

STM32的工作电压(VDD)为2.0~3.6V。通过内置的电压调节器提供所需的1.8V电源
当主电源VDD掉电后,通过VBAT脚为实时时钟(RTC)和备份寄存器提供电源

这张图展示了STM32微控制器(MCU)内部的电源分配方案。整个系统可分为三大主要供电区域:模拟部分供电(VDDA)、数字部分供电包括VDD供电区域和1.8v供电区域、后备供电(VBAT)。

  • 首先来看模拟部分供电,即VDDA区域,负责为模拟功能如AD转换器、温度传感器、复位模块及PLL锁相环提供电力。这些组件的正极连接至VDDA,而负极则连接至VSSA。特别地,AD转换器的参考电压输入端VREF+和VREF-通常会在在引脚多的型号里会单独引出来;而在像C8T6这样的少引脚型号中,它们已在芯片内部直接连接到VDDA和VSSA上。

  • 接下来是数字部分供电,该部分分为两个子区域:VDD供电区和1.8V供电区。VDD供电区包含了I/O电路、待机电路、唤醒逻辑和独立看门狗等功能。这部分电路的工作电压通常是3.3V。为了提高能效,STM32的设计采用了低压策略,因此大部分关键的内部电路,例如CPU、存储器和数字外设,实际上是以1.8V的较低电压运行。

关于1.8V供电区,它是由VDD通过内置的电压调节器降压得到的,提供给CPU核心、存储器和内置数字外设。当这些外设需要与外界进行交流时,才会通过I/O电路转换到3.3V。这种设计有助于显著降低系统的功耗,因为较低的电压意味着更低的功率消耗。
需要注意的是,STM32的工作电压(VDD)范围为2.0~3.6V,而1.8V电源是通过内置的电压调节器提供的

  • 最后讨论的是后备供电区域。此区域包括了LSE 32K晶体振荡器、后备寄存器、RCC BDCR寄存器和实时时钟(RTC)。RCC BDCR是RTC的控制寄存器之一,也属于后备区域的一部分,因此同样可以通过VBAT供电。此外,图中还显示了一个低电压检测器,它可以监测主电源VDD的状态,并在VDD失效时自动切换到VBAT供电模式,确保RTC和其他关键的后备功能即使在主电源断开的情况下也能继续工作。

电源管理器

上电复位和掉电复位,还有可编程电压监测器这两个内容了解即可

上电复位和掉电复位


上电复位和掉电复位的功能在于当VDD或VDDA的电压降至一定水平时,内部电路会自动触发复位操作,防止STM32在电压不稳定时进行错误操作。为此,系统设置了一个40毫伏的迟滞电压,以避免电压波动导致的不稳定。具体来说,当电压超过上限(POR阈值,即1.92V)时,系统解除复位状态;而当电压低于下限(PDR阈值,即1.88V)时,系统进入复位状态。这种设计采用了迟滞比较器,通过设定上下两个阈值,有效防止了电压在阈值附近波动时引起的输出抖动。

需要注意的是,复位信号是低电平有效,意味着在电压过低(前后两种情况)时,系统会进入复位状态,而在电压正常(中间状态)时,系统则不会复位。关于具体的电压阈值和复位滞后时间,可以参考STM32的数据手册,具体信息在5.3.3节“内嵌复位和电源控制模块特性”中有详细说明。根据数据手册,PDR的典型下限阈值是1.88V,而POR的典型上限阈值是1.92V,这40毫伏的迟滞阈值确保了电压稳定。简而言之,电压大于1.9V时系统上电,低于1.9V时系统掉电。此外,复位持续时间(TRSTTEMPO)的典型值为2.5毫秒。

这个复位持续时间很重要,实际产品这里经常出偶发问题又难以排查

可编程电压监测器

PVD的工作原理与前述的上电复位和掉电复位类似,都是监测VDD和VDDA的供电电压。然而,PVD的独特之处在于其阈值电压是可以编程设定的,提供了更大的灵活性。

根据数据手册中的相关表格,可以通过配置PLS寄存器的3个位来选择PVD的阈值。这些阈值的选择范围大约在2.2V到2.9V之间,且PVD的上限和下限阈值之间的迟滞电压为100毫伏。值得注意的是,PVD的监测电压范围高于上电和掉电复位的阈值。

为了更直观地理解,可以想象一个电压从3.3V的正常供电逐渐降低的情景。当电压降至2.9V至2.2V之间时,就进入了PVD的监测范围。在这个范围内,您可以设置一个警告线,以便在电压进一步降低至1.9V以下,即复位电路的检测范围时,系统可以采取行动。
当PVD被触发时,微控制器仍然可以正常工作,但这是对用户的一个提醒,表明电源电压已经过低。PVD的输出是正逻辑,即电压过低时输出为1,电压正常时输出为0。这个信号可以用来申请中断,在电压上升或下降时触发,从而提醒程序进行相应的处理。
关于PVD的中断申请,它通过外部中断实现。在EXTI(外部中断)的基本结构图中,可以看到PVD的输出信号是如何接入的。因此,如果想要使用PVD,记得正确配置外部中断。
另外,尽管RTC有自己的中断系统,但为了在低功耗模式下唤醒停止模式,只有外部中断能够实现这一点。这也是为什么其他设备,如USB和ETH的唤醒信号,也要通过外部中断来唤醒停止模式。理解这一点对于低功耗设计至关重要。

低功耗模式


STM32F10xxx有三种低功耗模式:

睡眠模式

要进入睡眠模式,只需直接调用WFI(等待中断)或WFE(等待事件)指令,这两个都是内核指令,对应的库函数中也提供了相应的调用方法。WFI的作用是让CPU进入睡眠状态,直到有中断发生时唤醒;而WFE则是让CPU等待一个特定的事件来唤醒。唤醒条件分别是:WFI模式下,任何中断都能唤醒CPU;WFE模式下,则需要一个特定的事件来唤醒。唤醒后,WFI通常需要处理中断服务函数,而WFE则可能直接继续执行。

睡眠模式对电路的影响有限,主要表现在关闭了CPU时钟,而其他时钟和ADC时钟不受影响。电压调节器保持开启状态,因此,睡眠模式主要是通过停止CPU时钟来降低功耗。关闭时钟意味着所有的运算和时序操作暂停,但寄存器和存储器中的数据得以保持。睡眠模式的唤醒条件相对宽松,任何中断都能唤醒CPU,因此,它相当于是在保持身体其他部分工作的情况下,大脑稍作休息,省电程度评为一般。


停机模式

要进入停机模式,首先需要将sleepdeep位设置为1,指示CPU进入深度睡眠。PDDS位用于区分停机模式或待机模式,PDDS为0时进入停机模式。之后LPDS位用于控制电压调节器是保持开启还是进入低功耗模式(RPDS等于电压调节器开启,RPDS等于1电压调节器进入低功耗v)。

设置好这些位后,调用WFI或WFE即可进入停机模式。停机模式的唤醒条件比睡眠模式苛刻,只有外部中断能够唤醒。这意味着,如PVD、RTC闹钟、USB唤醒、ETH唤醒等通过外部中断的信号可以唤醒系统。停机模式关闭了所有1.8伏区域的时钟,包括HSI和HSE振荡器,但LSI和LSE振荡器保持运行。电压调节器可以选择开启或低功耗模式,后者更省电但唤醒时间更长。停机模式相当于整个人的工作完全停止,只有外部中断才能唤醒,省电程度评为非常省电。

注意

系统从停止模式被中断或唤醒事件唤醒时,HSI(内部高速时钟)会被自动选为系统时钟。这是因为,在我们的程序中,默认在SystemInit函数里配置的是使用HSE(外部高速时钟),并通过PLL(锁相环)倍频来获得72MHz的主频。然而,一旦进入停止模式,PLL和HSE都会停止工作。

因此,当系统从停止模式唤醒时,它不会自动通过PLL倍频来恢复到原来的72MHz主频,而是直接使用HSI的8MHz作为主频。如果忽略这一点,就可能会出现以下现象:程序刚上电时运行在72MHz的主频,但进入停止模式并在唤醒之后,主频会降为8MHz。不止慢9倍这么简单,带时序的外设基本上都出问题。

为了避免这种情况,我们通常需要在停止模式唤醒后的第一时间重新启动HSE,并将主频重新配置为72MHz。这一操作并不复杂,因为相关的配置函数已经为我们准备好了。我们只需要在唤醒后调用SystemInit函数,即可完成主频的重新配置。这样,系统就能恢复到停止模式之前的工作状态,确保程序的正常运行。

待机模式

进入待机模式的步骤与停机模式相似,但PDDS位需设置为1。待机模式的唤醒条件最为严格,普通外设中断和外部中断都无法唤醒,只有特定的信号,如wake up引脚的上升沿、RTC闹钟事件、NRST引脚的外部复位和IWDG独立看门狗复位,才能唤醒。待机模式几乎关闭了所有电路,包括1.8伏区域的时钟和电压调节器,这意味着内部存储器和寄存器的数据会丢失。但与停机模式一样,LSI和LSE振荡器保持运行以支持RTC和独立看门狗。待机模式相当于彻底下班,除非有紧急事项,否则不会返回工作,省电程度评为极为省电。

在之前的讨论中,我们提到了多个与低功耗模式相关的寄存器位,这些模式还有一些更细致的划分。例如,睡眠模式中有SLEEP-NOW和SLEEP-ON-EXIT的区别,停机模式中则有电压调节器开启与低功耗模式的区别。了解如何配置这些模式对我们理解程序有很大帮助。
首先这里有一句,执行WFI等待中断或者WFE等待事件指令后,STM32进入低功耗模式,就说这两个指令是最终开启低功耗模式的触发条件,配置其他的寄存器都要在这两个指令之前
以下是基于配置流程的详细说明:

  1. 执行WFI或WFE指令:这两个指令是启动低功耗模式的触发点。在执行这两个指令之前,需要配置好相关的寄存器。
  2. 判断sleep deep位:这一位决定了是进入浅睡眠还是深度睡眠模式。
    • 如果sleep deep位为0,则进入睡眠模式。
    • 如果sleep deep位为1,则进入深度睡眠模式,即停机模式或待机模式。
  3. 睡眠模式的细分:在睡眠模式下,SLEEPONEXIT位可以进一步细分模式。
    • 当SLEEPONEXIT位为0时,执行WFI或WFE后立即进入睡眠模式。
    • 当SLEEPONEXIT位为1时,执行WFI或WFE后会等待当前中断处理完成后才进入睡眠模式。这种情况适用于中断处理中还有一些紧急任务需要完成。
  4. 深度睡眠模式的判断:对于深度睡眠模式,需要进一步判断PDDS位。
    • 如果PDDS位为0,则进入停机模式。
    • 如果PDDS位为1,则进入待机模式。
  5. 停机模式的进一步配置:在停机模式下,LPDS位决定了电压调节器的工作状态。
    • 如果LPDS位为0,则电压调节器保持开启状态。
    • 如果LPDS位为1,则电压调节器进入低功耗模式,虽然更省电,但唤醒延迟更长。

【手册/天书讲解环节请看VCR】

【system_stm32f10x.c/.h文件讲解】

代码实战:修改主频&睡眠模式&停止模式&待机模式

  • 修改主频

最好不要去中途改主频,因为那些外设初始化时都是根据SystemCoreClock来的,就运行一次,主频改完后得重新配置外设初始化那些

  • 睡眠模式+串口发送+接收

main.c

#include "stm32f10x.h"                 // Device header
#include "Delay.h"
#include "OLED.h"
#include "Serial.h"
#include "LED.h"
uint8_t RxData;
uint8_t Pin_9, Pin_10;
int main(void)
{
    OLED_Init();
    Serial_Init();
    OLED_ShowString(1, 1, "RxData:");
    while (1)
    {
        if (Serial_GetRxFlag() == 1)
        {
            RxData = Serial_GetRxData();
            Serial_SendByte(RxData);
            OLED_ShowHexNum(1, 8, RxData, 2);
        }
        // 没有数据要发送但代码一直执行所以可以采用睡眠模式
        OLED_ShowString(2, 1, "Running...");
        Delay_ms(500);
        OLED_ShowString(2, 1, "         ");
        Delay_ms(500);
        __WFI(); // 进入睡眠,中断唤醒
        //执行WFI这时CPU会立刻睡眠,程序就停在了WFI指令这里,但是各个外设比如USRT还是工作状态
		//等到我们用串口助手发送数据时,USRT外设收到数据产生中断,唤醒CPU之后程序在暂停的地方继续运行
    }
}

  • 停止模式+对射式红外传感器计次

main.c

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

int main(void)
{
	/*模块初始化*/
	OLED_Init();			//OLED初始化
	CountSensor_Init();		//计数传感器初始化
	
	/*开启时钟*/
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);		//开启PWR的时钟
															//停止模式和待机模式一定要记得开启
	
	/*显示静态字符串*/
	OLED_ShowString(1, 1, "Count:");
	
	while (1)
	{
		OLED_ShowNum(1, 7, CountSensor_Get(), 5);			//OLED不断刷新显示CountSensor_Get的返回值
		
		OLED_ShowString(2, 1, "Running");					//OLED闪烁Running,指示当前主循环正在运行
		Delay_ms(100);
		OLED_ShowString(2, 1, "       ");
		Delay_ms(100);
		
		PWR_EnterSTOPMode(PWR_Regulator_ON, PWR_STOPEntry_WFI);	//STM32进入停止模式,并等待中断唤醒
		SystemInit();										    //唤醒后,要重新配置时钟,重启HSE配置72M主频
		//退出停止模式时,HSI被选为系统时钟,也就是在我们首次复位后,SystemInit函数里配置的是HSE*9倍频的72M主频
		//所以复位后第一次Running闪烁很快,而之后进入停止模式,再退出时默认时钟就变成HSI了,HSI是8M,所以唤醒之后的程序运行就会明显变慢
	}
}

  • 待机模式+实时时钟

    main.c
#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "MyRTC.h"

int main(void)
{
	/*模块初始化*/
	OLED_Init();		//OLED初始化
	MyRTC_Init();		//RTC初始化
	
	/*开启时钟*/
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);		//开启PWR的时钟
															//停止模式和待机模式一定要记得开启,虽然MyRTC_Init里开启了,多次开启无所谓,防止其他没调用MyRTC_Init的场景   但时钟没开启外设就不会工作
	
	/*显示静态字符串*/
	OLED_ShowString(1, 1, "CNT :");//秒计数器
	OLED_ShowString(2, 1, "ALR :");//闹钟值
	OLED_ShowString(3, 1, "ALRF:");//闹钟标志位
	
	/*使能WKUP引脚*/
	PWR_WakeUpPinCmd(ENABLE);						//使能位于PA0的WKUP引脚,WKUP引脚上升沿唤醒待机模式
	//手册里PWR_CSR的寄存器描述,这里写了使能wake up引脚后,wake up引脚被强制为输入下拉的配置,所以不用再GPIO初始化了
	
	/*设定闹钟*/
	uint32_t Alarm = RTC_GetCounter() + 10;			//闹钟为唤醒后当前时间的后10s
	RTC_SetAlarm(Alarm);							//写入闹钟值到RTC的ALR寄存器 这个寄存器只写不可读,所以使用变量Alarm显示到OLED上
	OLED_ShowNum(2, 6, Alarm, 10);					//显示闹钟值
	
	while (1)
	{
		OLED_ShowNum(1, 6, RTC_GetCounter(), 10);	//显示32位的秒计数器
		OLED_ShowNum(3, 6, RTC_GetFlagStatus(RTC_FLAG_ALR), 1);		//显示闹钟标志位
		
		OLED_ShowString(4, 1, "Running");			//OLED闪烁Running,指示当前主循环正在运行
		Delay_ms(100);
		OLED_ShowString(4, 1, "       ");
		Delay_ms(100);
		
		OLED_ShowString(4, 9, "STANDBY");			//OLED闪烁STANDBY,指示即将进入待机模式
		Delay_ms(1000);
		OLED_ShowString(4, 9, "       ");
		Delay_ms(100);
		
		OLED_Clear();								//OLED清屏,模拟关闭外部所有的耗电设备,以达到极度省电
													
		PWR_EnterSTANDBYMode();						//STM32进入停止模式,并等待指定的唤醒事件(WKUP上升沿或RTC闹钟)
		/*待机模式唤醒后,程序会重头开始运行*/
		//待机模式之后的代码执行不到,下次继续从头开始 在程序刚开始的时候自动调用SystemInit初始化时钟,所以待机模式我们就不用像停止模式那样,自己调用SystemInit了
		//并且这个while循环,实际上也只有执行一遍的机会,把这个while循环去掉也是可以的
	}
}

WDR看门狗

STM32有两个看门狗,一个是独立看门狗另外一个是窗口看门狗,独立看门狗号称宠物狗,窗口看门狗号称警犬,本章我们主要分析独立看门狗的功能框图和它的应用。

独立看门狗

独立看门狗用通俗一点的话来解释就是一个12位的递减计数器,当计数器的值从某个值一直减到0的时候,系统就会产生一个复位信号,即IWDG_RESET。 如果在计数没减到0之前,刷新了计数器的值的话,那么就不会产生复位信号,这个动作就是我们经常说的喂狗。看门狗功能由 VDD 电压域供电, 在停止模式和待机模式下仍能工作

在键寄存器(IWDG_KR)中写入0xCCCC,开始启用独立看门狗;此时计数器开始从其复位值0xFFF递减计数。当计数器计数到末尾0x000时,会产生一个复位信号(IWDG_RESET)。
无论何时,只要在键寄存器IWDG_KR中写入0xAAAA, IWDG_RLR中的值就会被重新加载到计数器,从而避免产生看门狗复位 。

IWDG功能框图剖析


1. 独立看门狗时钟
独立看门狗的时钟由独立的RC振荡器LSI提供,即使主时钟发生故障它仍然有效,非常独立。LSI的频率一般在30~60KHZ之间, 根据温度和工作场合会有一定的漂移,我们一般取40KHZ,所以独立看门狗的定时时间并不一定非常精确,只适用于对时间精度要求比较低的场合。

2. 计数器时钟
递减计数器的时钟由LSI经过一个8位的预分频器得到,我们可以操作预分频器寄存器IWDG_PR来设置分频因子, 分频因子可以是:[4,8,16,32,64,128,256,256]。

3. 计数器
独立看门狗的计数器是一个12位的递减计数器,最大值为0XFFF,当计数器减到0时,会产生一个复位信号:IWDG_RESET, 让程序重新启动运行,如果在计数器减到0之前刷新了计数器的值的话,就不会产生复位信号,重新刷新计数器值的这个动作我们俗称喂狗

4. 重装载寄存器
重装载寄存器是一个12位的寄存器,里面装着要刷新到计数器的值,这个值的大小决定着独立看门狗的溢出时间。 T L S I = 1 / F L S I = 0.025 m s T _{LSI}=1/F_{LSI}=0.025ms TLSI=1/FLSI=0.025ms T I W D G = 0.025 m s × P R 预分频系数 × ( R L + 1 ) T_{IWDG}= 0.025ms × PR预分频系数 × (RL + 1) TIWDG=0.025ms×PR预分频系数×RL+1)
RL计数目标自己定义(范围:0x0000~0xFFFF),计数器的最大值为4095。

5. 键寄存器
键寄存器IWDG_KR可以说是独立看门狗的一个控制寄存器,主要有三种控制方式,往这个寄存器写入下面三个不同的值有不同的效果。

通过写往键寄存器写0XCCCC来启动看门狗是属于软件启动的方式,一旦独立看门狗启动,它就关不掉,只有复位才能关掉。
无论何时,只要在键寄存器IWDG_KR中写入0xAAAA, IWDG_RLR中的值就会被重新加载到计数器,从而避免产生看门狗复位 。

6. 状态寄存器
状态寄存器SR只有位0:PVU和位1:RVU有效,这两位只能由硬件操作,软件操作不了。RVU:看门狗计数器重装载值更新, 硬件置1表示重装载值的更新正在进行中,更新完毕之后由硬件清0。PVU:看门狗预分频值更新,硬件置’1’指示预分频值的更新正在进行中, 当更新完成后,由硬件清0。所以只有当RVU/PVU等于0的时候才可以更新重装载寄存器/预分频寄存器。

IWDG键寄存器扩展


键寄存器实际上是一种特殊的控制寄存器,它用于触发硬件电路的特定操作。例如,执行喂狗操作时,我们通过向键寄存器写入特定的值(如0XAAAA)来完成。下面解释为什么使用键寄存器而不是简单的控制位:

键寄存器的作用

  • 使用键寄存器而非单个控制位的原因在于,键寄存器提供了一种更可靠的硬件控制方式。在可能存在干扰的环境中,如程序跑飞或受到电磁干扰,单独的控制位可能会因误操作而意外改变状态。
  • 通过在整个键寄存器中写入一个特定的值来执行控制操作,可以显著降低因干扰导致的误操作风险。例如,如果键寄存器是16位的,只有当写入特定的数值0XAAAA时,才会执行喂狗操作,这样就减少了误触发喂狗操作的可能性。

写保护逻辑

  • 为了进一步增强指令的抗干扰能力,键寄存器的设计包含了写保护逻辑。这意味着执行任何写操作前,必须先写入一个指定的键值。
  • 在我们的系统中,除了键寄存器,还有PR(预分频器寄存器)、SR(状态寄存器)和RLR(重装载寄存器)等其他寄存器。由于SR是只读的,不需要写保护,但PR和RLR需要防止误写操作。
  • 为了保护PR和RLR,可以设置一个写保护机制。只有在键寄存器中写入特定的值(如5555)后,才能解除写保护。如果写入其他值,PR和RLR将再次受到保护,从而防止了误写操作
  • 这种设计确保了PR和RLR与键寄存器一起,得到了有效的保护,从而提高了系统的稳定性和可靠性。

通过这种方式,键寄存器不仅作为控制硬件电路的关键元素,还作为保护系统免受意外干扰的重要机制。

怎么用IWDG

独立看门狗一般用来检测和解决由程序引起的故障,比如一个程序正常运行的时间是50ms, 在运行完这个段程序之后紧接着进行喂狗,我们设置独立看门狗的定时溢出时间为60ms,比我们需要监控的程序50ms多一点, 如果超过60ms还没有喂狗,那就说明我们监控的程序出故障了,跑飞了,那么就会产生系统复位,让程序重新运行。

  1. 开启LSI时钟:
    虽然独立看门狗的时钟源是LSI(低速内部时钟),但这一步通常不需要手动操作。根据手册6.2.9的说明,一旦独立看门狗被使能,LSI时钟会自动开启,并且稳定后自动为IWDG提供时钟。因此,这一步在代码中通常不需要显式地开启LSI时钟。
  2. 解除写保护:
    在写入预分频器和重装载寄存器之前,必须先解除写保护。这通过写入特定的键值0X5555到IWDG_KR(键寄存器)来完成。
  3. 写入预分频器和重装载值:
    解除写保护后,可以写入预分频器值到IWDG_PR(预分频器寄存器)和重装载值到IWDG_RLR(重装载寄存器)。

  4. 启动独立看门狗:
    配置完预分频器和重装载值后,通过向IWDG_KR写入0xCCCC来启动独立看门狗。这一步将使能看门狗并开始计数。
  5. 喂狗:
    在主循环中,为了防止看门狗超时导致系统复位,需要定期向IWDG_KR写入0xAAAA来刷新计数器。这被称为“喂狗”。

    喂狗和使能的时候会在键寄存器写入0x5555之外的值,这时就顺便给寄存器写保护了,所以写完寄存器器后我们不用手动执行写保护了。
    核心示例代码

窗口独立狗

主要特性

  • 可编程的自由运行递减计数器
  • 条件复位
    • 当递减计数器的值小于0x40,(若看门狗被启动)则产生复位。
    • 当递减计数器在窗口外被重新装载,(若看门狗被启动)则产生复位。
  • 如果启动了看门狗并且允许中断,当递减计数器等于0x40时产生早期唤醒中断(EWI),它可以被用于重装载计数器以避免WWDG复位

功能描述


窗口看门狗的设计和操作流程与独立看门狗有显著差异,这可能是由于它们的设计理念和应用重点不同。以下是窗口看门狗的关键组成部分和工作流程:

时钟源和预分频器

  • 窗口看门狗的时钟源是PCLK1,通常是APB1的时钟,默认频率为36MHz。
  • 时钟源右侧是预分频器,称为WDGTB。它与独立看门狗的PR和定时器的PSC功能相同,用于调整计数器的时钟频率,从而影响计数器溢出时间。

6位递减计数器CNT

  • 计数器CNT位于控制寄存器CR内,它是一个6位递减计数器,尽管标记为T6到T0共七个位,但只有T5到T0这六位是有效的计数值。
  • 最高位T6用作溢出标志位。当T6位为1时,表示计数器未溢出;当T6位为0时,表示计数器已溢出,这时会触发系统复位。

窗口看门狗(WWDG)的6位递减计数器CNT的工作原理如下:

  1. 初始化:
  • 在窗口看门狗被启用之前,必须首先设置计数器的初始值。这个值通常是一个大于0x40的数(因为0x40是计数器的最小可设置值),以确保计数器在开始递减之前有足够的计数周期
    时钟源:
  • 计数器CNT的时钟源通常是PCLK1(APB1时钟),这个时钟源经过预分频器(WDGTB)分频后,提供给CNT作为计数脉冲。
  1. 预分频器(WDGTB):
  • 预分频器用于调整计数器的递减速率。它可以设置不同的分频系数,以改变计数器溢出的时间。
  1. 递减计数:
  • 一旦窗口看门狗被启用,并且计数器的初始值被设置,计数器CNT开始在每个时钟周期递减。由于CNT是6位递减计数器,它的最大值为0x3F(二进制111111)。
  1. 溢出标志(T6位):
  • 计数器的最高位T6用作溢出标志。当计数器的值从0x40(二进制1000000)递减到0x3F(二进制0111111)时,T6位从1变为0,这表示计数器溢出
  1. 喂狗操作:
  • 为了防止计数器溢出导致系统复位,必须在计数器值小于窗口值时更新计数器的值。这个过程称为“喂狗”。通过向控制寄存器CR写入一个新的计数值来更新CNT。
  1. 复位条件:
  • 如果计数器的值递减到0x3F并且T6位变为0,而没有及时“喂狗”,窗口看门狗会产生一个复位信号,导致系统复位。
  • 如果在窗口期内(即在计数器的值大于窗口值时)“喂狗”,也会产生复位,因为这是不允许的操作。

喂狗操作

  • 窗口看门狗没有单独的重装寄存器。要“喂狗”,即重置计数器,可以直接向CNT写入一个值。
  • 计数器的窗口值W6到W0用于设置喂狗的最早时间界限。这个值一旦设置,就不会改变。

复位信号输出逻辑

  • WDGA是窗口看门狗的激活位,写入1以启用窗口看门狗。
  • 当T6位为0时,表示计数器溢出,通过或门产生复位信号。在正常运行状态下,需要保持T6位为1以避免复位。
  • 如果不及时“喂狗”,计数器减到0后会产生复位。

喂狗时间窗口的实现流程

  • 在“喂狗”时,系统会比较当前计数器的值和预设的窗口值。
  • 如果当前计数器的值大于窗口值,表示“喂狗”得太早,比较器输出1,通过或门可以申请复位。
  • 这确保了只有在特定的时间窗口内“喂狗”才是有效的,从而提高了系统的安全性。


递减计数器T[6:0]等于0x40时可以产生早期唤醒中断(EWI),用于重装载计数器以避免WWDG复位
以下是这个过程的具体步骤和用途:

  1. 早期唤醒中断(EWI)触发:
  • 当计数器的值递减到0x40时,如果启用了EWI功能,则会触发一个中断。这个中断是在计数器溢出(即T6位从1变为0)之前触发的,因此它提供了一个“最后的机会”来执行一些紧急操作。
  1. 中断服务例程(ISR):
  • 在中断服务例程中,可以执行以下操作:
    • 保存重要数据: 如果系统中有正在处理的重要数据,可以在中断中将其保存到非易失性存储器中。
    • 关闭危险设备: 如果系统控制着某些可能造成伤害或损坏的设备,可以在中断中安全地关闭这些设备。
    • 执行紧急任务: 可以执行一些紧急的任务,以防止系统因超时而复位。
  1. 喂狗操作:
  • 在中断服务例程中,可以选择重新加载计数器的值(即“喂狗”),以防止系统复位。这样做可以允许系统继续运行,即使它已经接近了超时阈值。
  • 如果超时不是非常严重的问题,或者在某些情况下,系统可以容忍轻微的超时,可以在中断中执行喂狗操作,并可能只是记录一个错误或者向用户显示一个警告信息,而不是让系统复位。
  1. 防止系统复位:
  • 通过在中断中执行喂狗操作,可以防止系统复位,这对于那些不希望因看门狗超时而重启的系统来说非常有用。

WWDG超时时间

这里要多乘一个4096,是因为这里PCLK1进来之后,其实是先执行了一个固定的4096分频,这里框图没画出来,实际上是有的,因为36M的频率还是太快了,先来个固定分频给降一降。

IWDG和WWDG对比

代码实战:独立看门狗&窗口看门狗

  • 独立看门狗


    main.c
#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "Key.h"

int main(void)
{
	/*模块初始化*/
	OLED_Init();						//OLED初始化
	Key_Init();							//按键初始化
	
	/*显示静态字符串*/
	OLED_ShowString(1, 1, "IWDG TEST");
	
	/*判断复位信号来源*/
	if (RCC_GetFlagStatus(RCC_FLAG_IWDGRST) == SET)	//如果是独立看门狗复位
	{
		OLED_ShowString(2, 1, "IWDGRST");			//OLED闪烁IWDGRST字符串
		Delay_ms(500);
		OLED_ShowString(2, 1, "       ");
		Delay_ms(100);
		
		RCC_ClearFlag();							//清除标志位
	}
	else											//否则,即为其他复位
	{
		OLED_ShowString(3, 1, "RST");				//OLED闪烁RST字符串
		Delay_ms(500);
		OLED_ShowString(3, 1, "   ");
		Delay_ms(100);
	}
	
	/*IWDG初始化*/
	IWDG_WriteAccessCmd(IWDG_WriteAccess_Enable);	//独立看门狗写使能
	IWDG_SetPrescaler(IWDG_Prescaler_16);			//设置预分频为16
	IWDG_SetReload(2499);							//设置重装值为2499,独立看门狗的超时时间为1000ms
	IWDG_ReloadCounter();							//重装计数器,喂狗
	IWDG_Enable();									//独立看门狗使能
	
	while (1)
	{
		Key_GetNum();								//调用阻塞式的按键扫描函数,模拟主循环卡死
		
		IWDG_ReloadCounter();						//重装计数器,喂狗
		
		OLED_ShowString(4, 1, "FEED");				//OLED闪烁FEED字符串
		Delay_ms(200);								//喂狗间隔为200+600=800ms
		OLED_ShowString(4, 1, "    ");
		Delay_ms(600);
	}
}

  • 窗口看门狗

  1. 开启窗口看门狗的APB1时钟:
    由于窗口看门狗的时钟来源是PCLK1(APB1时钟),因此第一步是开启APB1时钟。这与独立看门狗不同,后者使用LSI时钟,会自动开启。开启APB1时钟通常通过RCC(Reset and Clock Control)寄存器来完成。
  2. 配置寄存器:
    第二步是配置窗口看门狗的各个寄存器,包括预分频器和窗口值。窗口看门狗没有写保护机制,因此可以直接写入这些寄存器。
    预分频器(WWDG_CFR的WDGTB位)用于设置计数器的分频系数,窗口值(WWDG_CFR的W位)用于设置喂狗的时间窗口。
  3. 写入控制寄存器(CR):
    第三步是写入控制寄存器(WWDG_CR),这个寄存器包含了看门狗使能位(WDGA)、计数器溢出标志位(WDGON)和计数器有效位(T6)等。这些位需要一起设置,以使能窗口看门狗并开始计数。
  4. 喂狗:
    在系统运行过程中,需要定期向计数器写入新的重装载值来喂狗。这通过向WWDG_CR写入特定的值来完成,同时这个操作也必须在设定的时间窗口内执行,以避免系统复位。

main.c

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

int main(void)
{
	/*模块初始化*/
	OLED_Init();						//OLED初始化
	Key_Init();							//按键初始化
	
	/*显示静态字符串*/
	OLED_ShowString(1, 1, "WWDG TEST");
	
	/*判断复位信号来源*/
	if (RCC_GetFlagStatus(RCC_FLAG_WWDGRST) == SET)	//如果是窗口看门狗复位
	{
		OLED_ShowString(2, 1, "WWDGRST");			//OLED闪烁WWDGRST字符串
		Delay_ms(500);
		OLED_ShowString(2, 1, "       ");
		Delay_ms(100);
		
		RCC_ClearFlag();							//清除标志位
	}
	else											//否则,即为其他复位
	{
		OLED_ShowString(3, 1, "RST");				//OLED闪烁RST字符串
		Delay_ms(500);
		OLED_ShowString(3, 1, "   ");
		Delay_ms(100);
	}
	
	/*开启时钟*/
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_WWDG, ENABLE);	//开启WWDG的时钟 PCLK1时钟
	
	/*WWDG初始化*/
	WWDG_SetPrescaler(WWDG_Prescaler_8);			//设置预分频为 8
	WWDG_SetWindowValue(0x40 | 21);					//设置窗口值,窗口时间为30ms  T6位也要设置成1,所以或上0x40
	WWDG_Enable(0x40 | 54);							//使能并第一次喂狗,超时时间为50ms T6位也要设置成1,所以或上0x40
	
	while (1)
	{
		Key_GetNum();								//调用阻塞式的按键扫描函数,模拟主循环卡死
		
		OLED_ShowString(4, 1, "FEED");				//OLED闪烁FEED字符串
		Delay_ms(20);								//喂狗间隔为20+20=40ms
		OLED_ShowString(4, 1, "    ");
		Delay_ms(20);
		
		WWDG_SetCounter(0x40 | 54);					//重装计数器,喂狗
	}
}

FLASH闪存

介绍


读写FLASH的用途:

  • 存储用户数据:
    在C8T6芯片中,可以利用程序存储器的剩余空间来保存掉电不丢失的用户数据。选择存储区域时,应避免覆盖原有程序代码

对于我们这个C8T6芯片来说,它的程序存储器容量是64K,一般我们写个简单的程序,可能就只占前面的很小一部分空间,剩下的大片空余空间我们就可以加以利用,比如存储一些我们自定义的数据,这样就非常方便,而且可以充分利用资源,不过这里要注意我们在选取存储区域时,一定不要覆盖了原有的程序,要不然程序自己把自己给破坏了,一般存储少量的参数,我们就选最后几页存储就行了

  • 程序自我更新(IAP):
    通过在应用程序中编程,可以实现程序的自我更新。这涉及到编写一个BOOTLOADER程序,并将其存放在程序更新时不会覆盖的地方。

闪存模块组织

对于小容量产品和大容量产品,闪存的分配方式有些区别,这个可以参考一下手册,那首先提醒一下闪存这一章的内容在手册里是单独列出来的,并不在之前的参考手册里,我们需要打开这个闪存编程参考手册,这里以中容量产品为例来讲解。

在闪存编程参考手册中,我们可以看到C8T6的闪存分为三个主要部分:主存储器、信息块和闪存存储器接口寄存器。

  1. 主存储器:这是闪存中容量最大的部分,用于存放程序代码。主存储器被划分为多个页,每页大小为1K。C8T6共有64页。
  2. 信息块:这部分包含系统存储器和用户选择字节(也称为选项字节)。系统存储器的起始地址是0x1FFFF000,容量为2K,用于存放原厂写入的BOOTLOADER,以便通过串口下载。用户选择字节的起始地址是0x1FFFF800,容量为16字节,用于存储配置参数。需要注意的是,虽然系统存储器和用户选择字节属于闪存的一部分,但它们通常不计入我们常说的64K或128K闪存容量中
  3. 闪存存储器接口寄存器:这些寄存器不属于闪存本身,而是作为外设存在,其地址以0x4002开头,表明它们是普通的外设寄存器,类似于GPIO、定时器、串口等。这些寄存器包括KEYR、SR、CR等,用于控制闪存的擦除和编程过程。

对于主存储器,这里对它进行了分页,分页是为了更好的管理闪存,擦除和写保护都是以页为单位的,这点和之前W25Q64芯片的闪存一样,同为闪存它们的特性基本一样,写入前必须擦除,擦除必须以最小单位进行,擦除后数据位全变为1,数据只能1写0,不能0写1,擦除和写入之后都需要等待忙,这些都是一样的,学习这节之前,大家可以再复习一下W25Q64,再学这一节就会非常轻松了,那W25Q64的分配方式是先分为块block,再分为扇区sector比较复杂,这里就比较简单了,它只有一个基本单位就是页,每一页的大小都是1K,0到127总共128页,总量就是128K,对于C8T6来说,它只有64K,所以C8T6的页只有一半0~63总共64页共64K,

FLASH基本结构


接下来理一下这个基本结构图,整个闪存分为程序存储器系统存储器选项字节三部分,这里程序存储器为以C8T6为例,它是64K的,所以总共只有64页,最后一页的起始地址是0800FC00;左边这里是闪存存储器接口(闪存编程和擦除控制器LPEC),然后这个控制器就是闪存的管理员,他可以对程序存储器进行擦除和编程,也可以对选项字节进行擦除和编程,系统存储器是不能擦除和编程的,这个选项字节里面有很大一部分配置位,其实是配置主程序存储器的读写保护的,所以右边画的写入选项字节,可以配置程序存储器的读写保护,当然选项字节还有几个别的配置参数,这个待会再讲,那这就是整个闪存的基本结构。

FLASH解锁


解锁过程

  1. 复位后保护状态:在微控制器复位后,FPEC(Flash Programming and Erase Controller)默认是被保护的,此时不能写入FLASH_CR(Flash Control Register)。
  2. 写入解锁键值:要解锁FPEC,需要按照正确的顺序在FLASH_KEYR(Flash Key Register)中写入特定的键值。
    • KEY1:首先写入0x45670123。
    • KEY2:然后写入0xCDEF89AB。
  3. 安全性设计:这种两步解锁过程提高了安全性,因为需要连续写入两个正确的键值才能解锁。这减少了因程序异常而意外解锁的风险。
  4. 错误操作保护:如果解锁序列错误,比如没有按照先KEY1后KEY2的顺序写入,FPEC和FLASH_CR将会在下次复位前被锁死,防止了进一步的误操作。

加锁过程

  1. 操作完成后加锁:在完成所有必要的闪存操作后,为了防止意外写入,需要重新锁定FPEC。
  2. 设置LOCK位:通过在FLASH_CR寄存器中设置LOCK位来重新锁定FPEC。通常,向LOCK位写入1即可完成加锁操作。

注意事项
解锁和加锁操作都需要谨慎进行,以确保系统的稳定性和安全性。
在进行任何解锁操作之前,确保理解了相关寄存器的功能和操作步骤,以避免不必要的风险。

接着看下一个知识点,这个地方我们要学习的是,如何使用指针访问存储器,因为STM32内部的存储器是直接挂在总线上的,所以这时在读写某个存储器就非常简单了,直接使用C语言的指针来访问即可。

使用指针访问存储器


讲解为什么会用到volatile

如果你这个地址写的是SRAM的地址,比如0X20000000,那可以直接写入了,因为SRAM在程序运行时是可读可写的,这是使用指针访问存储器的C语言代码,0X08000000,其中读取可以直接读,写入需要解锁,并且执行后面的流程。

程序存储器全擦除

下面我们来详细审视以下三个流程图的内容。首先是编程流程,亦即数据写入过程。其次是页擦除流程,值得注意的是,在STM32的闪存操作中,写入数据前需进行擦除操作。完成擦除后,该页的所有数据位将统一变为1,页擦除的操作单元是1K,即1024字节。最后是全擦除流程,这一过程涉及对所有页面的擦除。关于这些流程的细节,库函数已经为我们封装好了相应的操作,我们只需调用一个总函数即可,操作便捷

  1. 检查锁状态:首先,读取芯片的LOCK位,以确定芯片是否处于锁定状态。若LOCK位为1,表明芯片已被锁定,此时需要执行解锁操作。
  2. 解锁操作(如果需要):
    如果芯片锁定(LOCK位等于1),则需在KEYR寄存器中依次写入KEY1和KEY2以执行解锁。这一步骤在流程图中有所体现,即锁定了才需要解锁。
    若芯片未锁定,则无需执行解锁操作。然而,库函数的设计是直接执行解锁过程,不考虑芯片是否实际锁定。这种方法虽然简单直接,但最终效果是相同的。
  3. 启动全擦除:
    将控制寄存器中的MER(Mass Erase)位置1,以指示全擦除操作。
    接着,将STRT(Start)位置1。STRT位为1时,将触发芯片开始执行操作。当芯片检测到MER位为1时,它会识别接下来的操作是全擦除,并自动执行全擦除流程。
  4. 等待擦除完成:
    全擦除操作需要一定时间,因此程序需等待擦除过程结束。这通过检查状态寄存器的BSY(Busy)位来实现。
    如果BSY位为1,表示芯片正忙于擦除操作,程序将继续循环检查,直到BSY位变为0,表明擦除操作完成。
  5. 验证擦除结果(可选):
    流程的最后一步是读取并验证所有页的数据。这一步骤通常用于测试程序,以确保擦除操作的成功。
    在正常操作中,全擦除完成后,我们可以默认操作成功。由于全读出并验证所有页的数据工作量巨大,因此在实际应用中,这一步骤通常可以省略。
程序存储器页擦除


接下来,我们来看看页擦除的过程,这一过程与全擦除类似,包含以下步骤:

  1. 解锁操作:首先执行与全擦除相同的解锁流程。如果芯片处于锁定状态,需要在KEYR寄存器中依次写入KEY1和KEY2来解锁。
  2. 设置页擦除模式:
    将控制寄存器中的PER(Page Erase)位置1,指示接下来要执行的是页擦除操作。
    在AR(Address Register)地址寄存器中写入要擦除的页的起始地址。这一步是必要的,因为闪存包含多个页,而页擦除操作需要明确指出具体要擦除哪一页。
  3. 启动擦除操作:
    将控制寄存器的STRT(Start)位置1,这是触发条件,告诉芯片开始执行擦除操作。
    当芯片检测到PER位为1时,它会识别接下来的操作是页擦除,并且会参考AR寄存器中的地址来确定要擦除的具体页。
  4. 等待擦除完成:
    擦除操作开始后,程序需要等待操作完成。这同样是通过检查状态寄存器的BSY(Busy)位来实现的。
    如果BSY位为1,表示芯片正在执行擦除操作,程序将持续检查,直到BSY位变为0,表明擦除操作已经完成。
  5. 验证擦除结果(可选):
    最后一步是读取并验证擦除页的数据。这一步骤在测试程序中可能需要执行,以确保擦除操作的成功。
    在实际应用中,由于验证所有数据的工作量较大,通常可以省略这一步骤,假设擦除操作已经成功完成。

总结来说,页擦除过程包括解锁、设置擦除模式、启动擦除、等待操作完成,以及可选的数据验证步骤。通过这些步骤,我们可以确保指定的页被正确擦除。

程序存储器编程

最后,我们来探讨闪存的写入流程。在擦除操作之后,我们就可以进行数据写入。以下是写入流程的详细步骤:

  1. 解锁操作:与擦除操作类似,写入流程的第一步是对闪存进行解锁,确保可以执行写入操作。
  2. 设置编程模式:
    将控制寄存器中的PG(Programming)位置1,这表示即将进行数据写入操作。
  3. 写入数据:
    在指定的地址写入半字(16位数据)。这一步骤通过指针操作实现,可以直接在指定的内存地址写入想要的数据。
    需要注意的是,STM32的闪存写入操作仅支持半字写入。在STM32中,数据单位有字(32位)、半字(16位)和字节(8位)。因此,写入时必须以半字为单位,即每次写入16位数据。如果需要写入32位数据,则需要分两次写入;而写入8位数据时,则需要额外的处理。

处理字节写入:
如果需要单独写入一个字节且保留另一个字节的原始数据,必须将整页数据读取到SRAM中,修改SRAM中的数据,然后擦除整页闪存,并将修改后的整页数据写回。这种方法虽然繁琐,但能实现类似SRAM的灵活读写。

  1. 触发写入操作:
    写入数据后,芯片将自动进入忙状态,开始执行写入操作。与擦除操作不同,写入操作不需要显式设置STRT位,写入半字即可触发。
    等待写入完成:
    写入过程中,程序需要等待状态寄存器的BSY(Busy)位清0,这表示写入操作已经完成。
  2. 重复写入流程:
    每次执行上述流程,只能写入一个半字。若需要写入大量数据,则需要循环调用写入流程,直到所有数据写入完成。

总结来说,闪存的写入流程包括解锁、设置编程模式、写入数据、等待写入完成,并根据需要重复写入流程以写入多个数据。STM32的闪存写入有一定的限制,需要按照半字单位进行,并且在特定情况下需要采取额外的步骤来保证数据的正确写入。

选项字节

现在,让我们进一步了解选项字节的相关内容。对此有一个基本的认识就足够了。首先,我们要关注的是选项字节的结构和它们的作用。

在图表中,可以看到选项字节的起始地址,即我们之前提到的0x1FFF8000。这一区域包含的数据,正如表格所示,总共只有16个字节。这些字节在图中被详细展示,它们中的每一个都有一个对应的名称。值得注意的是,其中一半的名称带有“N”前缀,例如RDP和nRDP,USER和nUSER。这表示在写入数据到RDP等存储器时,必须同时在对应的nRDP存储器中写入数据的反码。这样的操作确保了写入的有效性。如果芯片检测到这些存储器中的数据不是反码关系,那么数据将被视为无效,相关的功能也不会被执行。这是一种安全特性,旨在防止错误操作。幸运的是,硬件会自动处理反码的写入过程,因此在使用库函数时,我们只需直接调用相应的函数即可,无需手动干预。

接下来看看这些存储器的具体功能。排除带有“N”前缀的字节后,我们剩下八个字节存储器。首先是RDP(读保护配置位),通过向RDP存储器写入特定的RDPRT键(例如0xA5),可以解除读保护。如果RDP不包含0xA5,则闪存将处于读保护状态,防止调试器读取程序代码,从而保护代码不被未授权访问。第二个字节是USER,它包含了一些零碎的配置位,可以用来配置硬件看门狗以及停机待机模式是否产生复位等。

接着是Data0/1这两个字节,它们在芯片中没有预设功能,用户可以根据自己的需求进行自定义。最后四个字节,WRP0/1/2/3,用于配置写保护。在中容量产品中,每个位对应保护四个存储页,总共32位,可以保护128页,这与中容量产品的最大页数相匹配。

对于小容量和大容量产品,写保护配置有所不同。根据手册中的2.5节,小容量产品每个位同样对应保护四个存储页,但由于其最大容量只有32K,因此只需使用一个字节WRP0,即8位,足以保护32页。其他三个字节WRP1、WRP2、WRP3在此不被使用。而对于大容量产品,每个位仅能保护两个存储页,因此四个字节不足以覆盖所有页。为此,规定WRP3的最高位用于保护剩余的所有页,从而确保了写保护功能的完整性。

然后看一下如何去写入这些位呢,这里两页PPT展示的就是选项字节的擦除和编程,因为选项字节本身也是闪存,所以它也得擦除,这里参考手册并没有给流程图,我们看一下这个文字流程,这个文字流程和流程图细节上有些出入,我们知道关键部分就行。

首先,我们来探讨选项字节的擦除流程。虽然第一步在文字描述中未明确提及,但实际上,它同样是解锁闪存。接着,我们看到文字版流程中包含了额外的步骤,即检查状态寄存器(SR)的BSY位,以确保没有其他闪存操作正在进行。这一步骤实际上是一个预先等待的过程:如果检测到BSY位为忙状态,我们需要等待直到操作完成。这一步骤在先前的流程图中并未展示。

下一步是解锁控制寄存器(CR)的OPTWRE(Option Write Enable)位,这是专门针对选项字节的解锁操作。在解锁整个闪存之后,我们还需要单独解锁选项字节,才能对其进行操作。关于解锁选项字节,我们可以参考之前的寄存器组织图。整个闪存的解锁是通过KEYR寄存器完成的,而选项字节的小锁则是通过OPTKEYR(Option Key Register)寄存器来解锁。解锁这个小锁的流程如下:首先在OPTKEYR中写入KEY1,然后写入KEY2,这样就可以成功解锁选项字节。

解锁选项字节的小锁之后,接下来的步骤与之前的擦除操作类似。首先,我们需要将CR的OPTER(Option Erase)位置1,这表示我们准备擦除选项字节。然后,设置CR的STRT位为1,这一操作将触发芯片开始擦除选项字节的过程。在设置STRT位后,我们等待BUSY位变为0,这表明擦除选项字节的过程已经完成。一旦擦除操作完成,我们就可以进行后续的写入操作了。


和普通的闪存写入也差不多,先检测BSY,然后解除小锁,之后设置CR的OPTPG(Option Programming)位为1,表示即将写入选项字节,再之后写入要编程的半字到指定的地址,这个是指针写入操作,最后等待忙,这样写入选项字节就完成了。

最后我们花几分钟学一下器件电子签名,这个非常简单,既然讲到闪存了,就顺便学习一下吧
看一下电子签名存放在闪存存储器模块的系统存储区域,包含的芯片识别信息在出厂时编写不可更改,使用指针读指定地址下的存储器,可获取电子签名,电子签名其实就是STM32的id号,它的存放区域是系统存储器,它不仅有BOOTLOADER程序,还有几个字节的id号,系统存储器起始地址是1FFFF000,看下这里,这里有两段数据,第一个是闪存容量存储器,基地址是1FFF F7E0,通过地址也可以确定它的位置,就是系统存储器,这个存储器的大小是16位,它的值就是闪存的容量单位是KB,然后第二个是产品唯一身份标识寄存器,就是每个芯片的身份证号,这个数据存放的基地址是1FFFF7E8,大小是96位,每一个芯片的这96位数据都是不一样的,使用这个唯一id号可以做一些加密的操作,比如你想写入一段程序,只能在指定设备运行,那也可以在程序的多处加入id号判断,如果不是指定设备的id号,就不执行程序功能,这样即使你的程序被盗,在别的设备上也难以运行,这是STM32的电子签名。

代码实战:读写内部FLASH&读取芯片 ID

建议观看视频:15-2 读写内部FLASH&读取芯片 ID

  • 读写内部FLASH

  • 读取芯片 ID

本文标签: 学习笔记江科