MxHanks' Blog

奔赴山海,保持热爱

0%

STM32 学习笔记

准备阶段

完成了 keil5 工具的安装

stm32 的基本信息

由意法半导体公司开发的 32 位单片机,作为中控

ST M32 F103 C8T6
品牌 产品信息 产品系列 规格型号
意法半导体 32位单片机 主流型 M3内核 72MHz 48 引脚 64kFlash LQFP封装 -40~85℃

G系列 通用; F系列 主流; H系列 高性能; L系列 低功耗; W系列 无线

C8T6:

  • 引脚数量 48 ©,
  • Flash 容量 8Kbit ,
  • 封装类型 T = LQFP ,
  • 温度范围 6 = -40~85℃
  • T = 36; C = 48; R = 64; V = 100; Z = 144;
  • H = BGA; I = UFBGA; T = LQFP; U = VFQFPN (封装类型表示焊盘形状)
  • 6 = -40~85 ℃
  • 7 = -40~105 ℃

stm32 引脚分布

根据 LQFP48 找到引脚分布图

alt text

引脚排列规则:左上角为 1 号,逆时针排序

特殊引脚

  • VDD : 供电+ (3.3V)
  • VSS : 接地 0V
  • NRST : 复位 接复位按钮
  • VBAT : 备用电池
  • BOOT0 : 启动模式选择

为何不用 VSS

alt text

普通引脚的命名规则

  1. 以字母作为组编号
  2. 以数字作为引脚编号

例如:

  • GPIOA : PA0 PA1 PA2 … PA15 (16个)
  • GPIOB : PB0 PB1 PB2 … PB15 (16个)
  • GPIOC : PC13 PC14 PC15
  • GPIOD : PD0 PD1

注意:引脚编号连续,在实际位置上不一定连续

GPIO

GPIO 是什么?

GPIO 属于一种片上外设。片上外设类似于人体的器官,可以独立完成一些任务。

alt text

有什么功能?

可以控制 IO 引脚,见 1.3.3 举例,GPIO 可以控制其对应的一组引脚

八种工作模式

输出 输入
通用输出推挽 输入上拉
通用输出开漏 输入下拉
复用输出推挽 输入浮空
复用输出开漏 模拟模式

参考手册 I/O端口位的基本结构

输出

输出结构示意

推挽

  • 推(Push): 寄存器为 0 接入 Vss 向外推电流
  • 挽(Pull): 寄存器为 1 接入 Vdd 从外向里拉电流

推与挽

开漏

开漏模式下,P-MOS 始终保持断开,寄存器为 0 时接入 Vss 输出低电压,寄存器位 1 时引脚悬空,进入高阻抗状态

开漏

通用和复用

通用模式下,直接通过编程 CPU 控制 IO 引脚输出高/低电压;复用模式下,通过其它的片上外设来控制引脚

通用和复用

IO 的最大输出速度

IO 最大输出速度,指的是向 IO 交替写 0 和 1 且不失真的最快速度。

在非理想情况下,电压上升和下降都需要一定时间,那么可以分为三个阶段:上升时间、下降时间、保持时间。这个时间就限制了 IO 的最大输出速度,当保持时间为 0 时,就达到了最大输出速度。

由此,调整上升时间和下降时间就可以调整最大输出速度。

我们使用的芯片提供了三种最大输出速度,低速 2MHz ,中速 10MHz ,高速 50MHz。

在编程时,应该选择满足要求的最低最大输出速度,电压变化过于陡峭时会增加耗电,并且引入 EMI 问题(电磁干扰)。

| 冷知识 人眼的临界闪光频率是 45.8Hz 所以我们完全感受不到 50Hz 的交流灯光

LED 闪灯实验

二极管的导通压降为 0.7V ,通过限流电阻,使得 Vdd 输出 3.3V 的时候,电流为 5mA 左右,使得 LEd 点亮。

推挽模式下控制 LED ,如下图:

推挽模式控制 LED

开漏模式下控制 LED ,如下图:

开漏模式控制 LED

观看最小系统板电路图,发现使用的是开漏接法,连接的是 PC13 引脚。

时钟

时钟可以类比为人的心跳,在使用片上外设之前应该要开启对应的时钟

// 开启 GPIOx 的时钟
RCC_APB2PriphClockCmd(RCC_APB2Periph_GPIOx, ENABLe);

GPIO 模块的编程接口

编程接口 作用 备注
GPOI_Init 初始化 IO 引脚 初始化一根 IO 引脚,即使用 IO 引脚之前配置一些参数(输入输出模式,最大输出速度)
GPIO_WriteBit 向输出数据寄存器写一个比特位 \
GPIO_ReadInputDataBit 从输入数据寄存器读一个比特位 \
GPIO_ReadOutputDataBit 从输出数据寄存器读一个比特位 最近一次写入是什么,读出来的就是什么
GPIO_Init

GPIO_Init

GPIO_WriteBit
void GPIO_WriteBit(GPIOTypeDef *GPI0x, //端口号,取A B C D..
    uint16t GPI0Pin,//引脚编号
    BitAction BitValue); // 要写入的值 Bit_RESET/Bit_SET

完整代码

#include "stm32f10x.h"
#include "delay.h"

int main(void){
	// 1. 开启 GPIOC 的时钟
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE);
	// 2. 初始化 IO 引脚, PC13 通用开漏模式 2Mhz
	GPIO_InitTypeDef GPIO_InitStruct = {0};
	GPIO_InitStruct.GPIO_Pin = GPIO_Pin_13;
	GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_OD;
	GPIO_InitStruct.GPIO_Speed = GPIO_Speed_2MHz;
	GPIO_Init(GPIOC, &GPIO_InitStruct);
	while(1){
		GPIO_WriteBit(GPIOC, GPIO_Pin_13, Bit_SET); // 1 灭
		Delay(100);
		GPIO_WriteBit(GPIOC, GPIO_Pin_13, Bit_RESET); // 0 亮
		Delay(100);
	}
}

输入

参考手册 I/O端口位的基本结构

输入部分主要研究结构图的上半部分,即输入电路部分。

输入电路结构示意

保护二极管的工作原理

静电电压较高,电流会随着保护二极管流出。

上拉电阻和下拉电阻

上拉电阻和下拉电阻可以保持电路的稳定。施密特触发器流入的电流始终为 0 ,不存在压降。

当上拉电阻接入时,IO 接口悬空时,Vdd 的电压会使得施密特触发器两边形成电势差,从而向输入数据寄存器写入 1 。

所以说,上拉电阻连通可以提供默认高电压,下拉电阻连通可以提供默认低电压,两个只需要连通一个就可以了。

按钮试验

GPIO Mode

模式 名称 备注
GPIO_Mode_AIN 模拟输入 Analog Input ADC 采样等模拟信号输入
GPIO_Mode_IN_FLOATING 浮空输入 输入电平不确定,需外部电路决定
GPIO_Mode_IPD 输入下拉 Input Pull-up 内部下拉电阻,默认低电平
GPIO_Mode_IPU 输入上拉 Input Pull-up 内部上拉电阻,默认高电平
GPIO_Mode_Out_OD 通用开漏输出 OD = Open Drain,需外部上拉电阻
GPIO_Mode_Out_PP 通用推挽输出 PP = Push Pull,可输出强高低电平
GPIO_Mode_AF_OD 复用开漏输出 AF = Alternate Function 外设功能(如 I²C、USART 等)的开漏输出
GPIO_Mode_AF_PP 复用推挽输出 外设功能(如 SPI、USART 等)的推挽输出

LED 接线

LED 接线

根据电路图安装好电路。

通过检查 LED 点亮判断电路是否连接成功。

#include "stm32f10x.h"

int main(void){
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 开启 GIPOA 的时钟
	
	GPIO_InitTypeDef GPIO_InitStruct;
	GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP; // 设置推挽模式
	GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0; // 对应 GPIOA0
	GPIO_InitStruct.GPIO_Speed = GPIO_Speed_2MHz;
	GPIO_Init(GPIOA, &GPIO_InitStruct);
	
	GPIO_WriteBit(GPIOA, GPIO_Pin_0, Bit_SET); // 写1点亮 LED
	
}

LED 点亮成功!

按钮接线

按钮读入

输入上拉模式时,当按钮按下时电路连通接地,没有电势差,写入 0 ;当按钮松开时,Vdd 通过连通的上拉电阻提供高位电压,写入 1 。

按钮接线

按图安装好按钮。要特别注意,按钮是具有方向的,不要左右装反了。

uint8_t GPIo_ReadInputDataBit(GPIOTypeDef *GPIOx,//端口号
    uint16_t GPI0_Pin);//引脚编号

// 作用:读取IO引脚的值,BitSET=1 Bit_RESET=0
#include "stm32f10x.h"

int main(void){
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 开启 GIPOA 的时钟
	
	// LED 相关的初始化
	GPIO_InitTypeDef GPIO_InitStruct;
	GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP; // 设置推挽模式
	GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0; // 对应 GPIOA0
	GPIO_InitStruct.GPIO_Speed = GPIO_Speed_2MHz;
	GPIO_Init(GPIOA, &GPIO_InitStruct);
	
	// Button 相关的初始化
	GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU; // 输入上拉模式
	GPIO_InitStruct.GPIO_Pin = GPIO_Pin_1; // 按钮接入 GPIOA1
	// 最大输出速度只对输出模式有效
	GPIO_Init(GPIOA, &GPIO_InitStruct);
	
	
	while(1) {
		if(GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_1) == Bit_RESET) {
			// 按钮按下
			GPIO_WriteBit(GPIOA, GPIO_Pin_0, Bit_SET);
		} else {
			// 按钮松开
			GPIO_WriteBit(GPIOA, GPIO_Pin_0, Bit_RESET);
		}
	}
	
}

效果:按钮按下时, LED 点亮。按钮松开时, LED 熄灭。

| 本人因为把接地接到 R 去了所以一开始半天没效果,要记得接地是 G(Ground) 啊 TwT

串口

通信协议

串口是什么

串口是一种通信接口,可以用于传递数据。

串口由两个引脚组成, Tx(Transmit) 和 Rx(Receive) 。交叉连接两个串口,就可以实现数据的双向传输。

串口的数据格式

Tx 在空闲时是高电压,以低电压作为起始位。在数据位 (8~9位,最后一位可作为校验位) 结束后,以高电压作为停止位,同时回到空闲位。

例如,通过串口发送十进制的数字 27:

空闲|起|11011000|停|空闲

在发送字符串时,使用 ASCII 码。

例如,发送字符串 “Hello”:

空闲|
起|11011000|停|
起|10100110|停|
起|00110110|停|
起|00110110|停|
起|11110110|停|
空闲

校验位的使用方式

校验分为奇校验偶校验。即判断 1 的个数位奇数还是偶数。

USART 模块的使用方法

USART 简介

alt text

我们学习的单片机有三个 USART ,可以把 USART 模块看作人的耳朵和嘴巴的集合。

USART 基本用法

USART 模块示意图

通过读取接收数据寄存器接收数据,通过发送数据寄存器发送数据。

移位寄存器和串并转换

在发送数据时,需要将并行的数据转换位串行,接收数据时则相反。

移位寄存器会按照周期将数据右移,移出去的数据就会发送为电压。可以按照队列来理解。于是就完成了并到串的转换。

同理,如果数据从左边进入,就完成从串到并的转换。

数据帧格式的设置方法

设置内容
数据位 8/9
停止位 0.5/1/1.5/2
校验方式 奇/偶/无

波特率的设置方法

波特率:每秒钟最多传输多少位。常用的波特率有 9600, 115200, 921600。

通过写入波特率寄存器来设置分频器,再叠加一个固定 1/16 的分频器,时钟频率经过两个分频器就会得到波特率。

例如,希望使用 115200 的波特率,且时钟频率为 72MHz ,分频器就要设置为 39.0625。按照浮点数的寄存方式寄存在寄存器中。

编程接口

void USART_Init(USART_TypeDef *USARTx,          // 串口名称
    USART_InitTypeDef *USART_InitStruct);        // 初始化参数结构体

// 作用: 初始化串口

typedef struct {
    uint32_t USART_BaudRate;                     // 波特率 (如 9600, 115200, 921600)
    uint16_t USART_WordLength;                   // 数据位长度
        // USART_WordLength_8b / USART_WordLength_9b
    uint16_t USART_StopBits;                     // 停止位长度
        // USART_StopBits_0_5 / USART_StopBits_1 / USART_StopBits_1_5 / USART_StopBits_2
    uint16_t USART_Parity;                       // 校验方式
        // USART_Parity_No / USART_Parity_Even / USART_Parity_Odd
    uint16_t USART_Mode;                         // 数据收发方向
        // USART_Mode_Tx | USART_Mode_Rx (可同时开启)
} USART_InitTypeDef;
void USART_Cmd(USARTTypeDef *USARTx, // 串口名称
        FunctionalState NewState);  // ENABLE / DISABLE
// 控制 USART 模块的总开关
)
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
	
USART_InitTypeDef USART_InitStruct;
USART_InitStruct.USART_BaudRate = 115200;
USART_InitStruct.USART_Mode =USART_Mode_Tx | USART_Mode_Rx;
USART_InitStruct.USART_WordLength =USART_WordLength_8b;
USART_InitStruct.USART_StopBits =USART_StopBits_1;
USART_InitStruct.USART_Parity =USART_Parity_No;
	
USART_Init(USART1, &USART_InitStruct);

// 要记得闭合 USART 总控开关
USART_Cmd(USART1, ENABLE);

为串口初始化 IO 引脚

USART 模块有哪些引脚

USART 引脚

引脚分布表

根据 C8T6 ,查看数据手册,找到 LQFP48 的表头

LQFP48 Pin No Pin Name Type Default Main Function Default Alternate Function Remap Alternate Function
1 PC13-TAMPER-RTC I/O PC13 TAMPER-RTC
2 PC14-OSC32_IN I/O PC14 OSC32_IN
3 PC15-OSC32_OUT I/O PC15 OSC32_OUT
4 PD0-OSC_IN I/O OSC_IN PD0
5 PD1-OSC_OUT I/O OSC_OUT PD1
6 NRST I/O NRST
7 VSSA S VSSA
8 VDDA S VDDA
9 PA0-WKUP I/O PA0 WKUP / USART2_CTS / ADC12_IN0 / TIM2_CH1_ETR
10 PA1 I/O PA1 USART2_RTS / ADC12_IN1 / TIM2_CH2
11 PA2 I/O PA2 USART2_TX / ADC12_IN2 / TIM2_CH3
12 PA3 I/O PA3 USART2_RX / ADC12_IN3 / TIM2_CH4
13 PA4 I/O PA4 SPI1_NSS / USART2_CK / ADC12_IN4
14 PA5 I/O PA5 SPI1_SCK / ADC12_IN5
15 PA6 I/O PA6 SPI1_MISO / ADC12_IN6 / TIM3_CH1 TIM1_BKIN
16 PA7 I/O PA7 SPI1_MOSI / ADC12_IN7 / TIM3_CH2 TIM1_CH1N
17 PB0 I/O PB0 ADC12_IN8 / TIM3_CH3 TIM1_CH2N
18 PB1 I/O PB1 ADC12_IN9 / TIM3_CH4 TIM1_CH3N
19 VSS_1 S VSS_1
20 VDD_1 S VDD_1
21 PB10 I/O PB10 I2C2_SCL / USART3_TX TIM2_CH3
22 PB11 I/O PB11 I2C2_SDA / USART3_RX TIM2_CH4
23 PB12 I/O PB12 SPI2_NSS / I2C2_SMBAI / USART3_CK / TIM1_BKIN
24 PB13 I/O PB13 SPI2_SCK / USART3_CTS / TIM1_CH1N
25 PB14 I/O PB14 SPI2_MISO / USART3_RTS / TIM1_CH2N
26 PB15 I/O PB15 SPI2_MOSI / TIM1_CH3N
27 PA8 I/O PA8 USART1_CK / TIM1_CH1 / MCO
28 PA9 I/O PA9 USART1_TX / TIM1_CH2
29 PA10 I/O PA10 USART1_RX / TIM1_CH3
30 PA11 I/O PA11 USART1_CTS / CANRX / USBDM / TIM1_CH4
31 PA12 I/O PA12 USART1_RTS / CANTX / USBDP / TIM1_ETR
32 PA13 I/O JTMS/SWDIO
33 PA14 I/O JTCK/SWCLK
34 PA15 I/O JTDI TIM2_CH1_ETR / SPI1_NSS
35 PB3 I/O JTDO TIM2_CH2 / SPI1_SCK / TRACESWO
36 PB4 I/O JNTRST TIM3_CH1 / SPI1_MISO
37 PB5 I/O PB5 I2C1_SMBAI / SPI1_MOSI TIM3_CH2
38 PB6 I/O PB6 I2C1_SCL / TIM4_CH1 USART1_TX
39 PB7 I/O PB7 I2C1_SDA / TIM4_CH2 USART1_RX
40 BOOT0 I BOOT0
41 PB8 I/O PB8 TIM4_CH3 I2C1_SCL / CANRX
42 PB9 I/O PB9 TIM4_CH4 I2C1_SDA / CANTX
43 VSS_3 S VSS_3
44 VDD_3 S VDD_3
35 VSS_2 S VSS_2
36 VDD_2 S VDD_2

观察表格知道, USART1 在默认时 Rx Tx 分别走 PA10 PA9 如果引脚被占用可以走重映射的 PB7 PB6。

重映射表

查表可以看到每个重映射后的引脚。

IO 配置表

查表可以看到每个片上外设应该设置成什么模式。

四种通信模式

全双工模式时相比浮空最好选择输入上拉模式,因为一可以避免电磁干扰,二可以通过默认高电压保持空闲位。

编写代码

使用默认引脚
#include "stm32f10x.h"

int main(void) {
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
	
	GPIO_InitTypeDef GPIO_InitStruct;
	
	// Tx PA9 复用输出推挽
	GPIO_InitStruct.GPIO_Pin = GPIO_Pin_9;
	GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
	GPIO_InitStruct.GPIO_Speed = GPIO_Speed_10MHz;
	
	GPIO_Init(GPIOA, &GPIO_InitStruct);
	
	// Rx PA10 输入浮空 输入上拉
	GPIO_InitStruct.GPIO_Pin = GPIO_Pin_10;
	GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU;
	
	GPIO_Init(GPIOA, &GPIO_InitStruct);
}
重映射引脚

使用 AFIO 片上外设。

// 激活 AFIO 时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE);
// 配置重映射
GPIO_PinRemapConfig(GPIO_Remap_USART1, ENABLE);

RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);

GPIO_InitTypeDef GPIO_InitStruct;

// Tx PA9 复用输出推挽
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_10MHz;

GPIO_Init(GPIOB, &GPIO_InitStruct);

// Rx PA10 输入浮空 输入上拉
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_7;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU;

GPIO_Init(GPIOB, &GPIO_InitStruct);

使用串口发射数据

USART 模块示意图

TxE 标志位

我们可以查询标志位的值来获得 USART 模块工作的相关信息。

TxE: Transmit Data Register Empty 用于判断发送数据寄存器里是否有数据。

发送字节时,需要通过 TxE 标志位判断寄存器是否为空,否则会覆盖数据。

TC 标志位

TC: Transmit Complete 即发送完成。当发送数据寄存器为空时,TC 为 1 否则为 0。

编程接口

FlagStatus USART_GetFlagStatus(USARTTypeDef *USARTx, // 串口名称
        uint16_t USART_FLAG // 要查询的标志位
);
// 返回值 SET / RESET
// 标志位: USART_FLAG_TXE, USART_FLAG_RXNE, USART_FLAG_TC, USART_FLAG_PE
void USART_SendData(USARTTypeDef *USARTx,
        uint16_t Data); // 要发送的数据
// 作用:将要发送的数据填到寄存器中

为什么使用 uint16_t (占两个字节)?9 数据位时数据大于一个字节。

串口发送数据的完整代码

// @作用: 使用串口一次性发送多个字节
// @para: pData: 要发送的数据
// @para: Size: 字节数量
void My_USART_SendBytes(USART_TypeDef *USARTx, uint8_t *pData, uint16_t Size) {
    for(uint32_t i = 0; i < Size; i++) {
        // 等待发送寄存器空
        while(USART_GetFlagStatus(USART1, USART_FLAG_TxE) == RESET);
        // 写入数据
        USART_SendData(USARTx, pData[i]);
    }
    // 等待数据发送完成
    while(USART_GetFlagStatus(USART1, USART_FLAG_TC == RESET));
}

使用串口与电脑通信

与电脑通信 电路图

使用串口给电脑发送数据:

#include "stm32f10x.h"

void My_USART_SendBytes(USART_TypeDef *USARTx, uint8_t *pData, uint16_t Size);

int main(void) {
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
	
	USART_InitTypeDef USART_InitStruct;
	USART_InitStruct.USART_BaudRate = 115200;
	USART_InitStruct.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;
	USART_InitStruct.USART_WordLength = USART_WordLength_8b;
	USART_InitStruct.USART_StopBits = USART_StopBits_1;
	USART_InitStruct.USART_Parity = USART_Parity_No;
	
	USART_Init(USART1, &USART_InitStruct);
	// 激活 AFIO 时钟
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE);
	// 配置重映射
	GPIO_PinRemapConfig(GPIO_Remap_USART1, ENABLE);
	
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
	
	// 闭合 USART 开关
	USART_Cmd(USART1, ENABLE);
	
	GPIO_InitTypeDef GPIO_InitStruct;
	
	// Tx PA9 复用输出推挽
	GPIO_InitStruct.GPIO_Pin = GPIO_Pin_6;
	GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
	GPIO_InitStruct.GPIO_Speed = GPIO_Speed_10MHz;
	
	GPIO_Init(GPIOB, &GPIO_InitStruct);
	
	// Rx PA10 输入浮空 输入上拉
	GPIO_InitStruct.GPIO_Pin = GPIO_Pin_7;
	GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU;
	
	GPIO_Init(GPIOB, &GPIO_InitStruct);
	
	uint8_t bytesToSend[] = {1, 2, 3, 4, 5};
	
	My_USART_SendBytes(USART1, bytesToSend, 5);
}

void My_USART_SendBytes(USART_TypeDef *USARTx, uint8_t *pData, uint16_t Size) {
    for(uint32_t i = 0; i < Size; i++) {
        // 等待发送寄存器空
        while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
        // 写入数据
        USART_SendData(USARTx, pData[i]);
    }
    // 等待数据发送完成
    while(USART_GetFlagStatus(USART1, USART_FLAG_TC == RESET));
}

使用串口给电脑发送数据

成功了!

格式化打印字符串

重写 fputc 函数:
int fputc(int ch, FILE* f) {
    // 等待 TDR 为空
    while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
    // 发送
    USART_SendData(USART1, (uint8_t)ch);
    return ch;
}

此时在 main 函数中调用 printf 函数:

printf("Hello World!");

运行程序,将串口调试助手选择为 ASCII 格式,发现串口调试助手中出现了 Hello World.

原理

在 C 标准库中,printf 最终会逐个字符调用底层输出函数 fputc 将格式化后的字符发送出去。默认的 fputc 是向“标准输出”设备(通常是显示器/终端)写入。

这样当 printf("Hello World!") 执行时,库函数会调用你重写后的 fputc,把每个字符通过 USART1 发送出去,从而实现在串口上输出数据。

实践:实时发送时间

Delay.h 中通过 GetTick() 获得时间,然后通过我们写的发送数据的函数来展示时间:

从 GetTick() 得到的 currentTick 本质上是系统上电以来经过的毫秒数(1 秒 = 1000 tick)。

void printNowTime(void)
{
    uint32_t currentTick = GetTick();

    uint32_t miliSeconds = currentTick % 1000;
    currentTick /= 1000;

    uint32_t seconds = currentTick % 60;
    currentTick /= 60;

    uint32_t minutes = currentTick % 60;
    currentTick /= 60;

    uint32_t hours = currentTick;

    printf("Current time: %02lu:%02lu:%02lu.%03lu\n", hours, minutes, seconds, miliSeconds);
}

我的思考:打印出来之后,每次都会有 2 tick 的延迟,这说明运行代码大概使用了 2 tick 的时间,如果要完美打印时间,我们应该要考虑这个误差,也就是 Delay 的时候应该只用 Delay 998ms

使用串口接收数据

RxNE 标志位

RxNE: Receive Data Register Not Empty - 接收数据寄存器非空。当 RDR 非空时,RxNE = 1 否则为 0。

接收代码的编写

接口
uint16_t USART_ReceiveData(USARTTypeDef *USARTx); // 串口名称
// 同样为了防止溢出,使用 uint16_t
接收
// 等待非空
while(USART_GetFlagStatus(USARTx, USART_FLAG_RXNE) == RESET);
// 接收
uint8_t data = USART_ReceiveData(USARTx);
// 处理

使用串口控制 LED

我们希望编写程序,串口调试发送 1 ,LED 点亮,发送 0 则关闭。

ledInit();
    myUSARTInit();
    while (1)
    {
        // 等待 RDR 非空
        while (USART_GetFlagStatus(USART1, USART_FLAG_RXNE) == RESET)
            ;
        // 读取数据
        uint8_t receivedData = USART_ReceiveData(USART1);
        // 处理数据
        if (receivedData == '0')
        {
            // 灭灯
            GPIO_WriteBit(GPIOC, GPIO_Pin_13, Bit_SET);
        }
        else if (receivedData == '1')
        {
            // 亮灯
            GPIO_WriteBit(GPIOC, GPIO_Pin_13, Bit_RESET);
        }
    }

错误标志位

  • PE: Parity Error 奇偶校验错误 (出现错误为1)
  • FE: Frame Error 帧格式错误,即接收不符合协议
  • NE: Noise Error 噪声错误。采样会采样多次,如果之中有较多次的噪声干扰,就会发生噪声错误。
  • ORE: Overrun Error 过载错误。发数据的的速度比收数据的速度快,导致数据消失。

I2C

基本电路结构

串口的缺点

串口只能实现一对一的通信。这意味着我们使用本单片机最多只能连接三个设备。使用 I2C 总线可以让我们实现一对多的通信。

时钟线和数据线

image-21.png

时钟线的频率控制着数据传输的快慢,数据线上传输着的是有效数据。

电路结构与逻辑线与

7位地址,总共有128个,去除掉特殊地址,I2C 可以同时和一百多个设备通信。

双线的连接方式是为了实现电路的逻辑线与,也就是以并联电路的方式来实现逻辑与运算,即 全1为1,有0则0

当所有的 SCL 引脚都写入 1 (输出开漏模式下为高阻抗),所有的引脚悬空,由于 Vdd 接入,会写入高电压,也就是 1 。

当存在 SCL 引脚写 0 ,电路通过写 0 的引脚接地了,电路输出低电压,也就是 0 。

时钟信号与数据发送

时钟信号只能由主机产生,发送给从机。

所有从机的 SCL 引脚都写入 1 悬空,主机的 SCL 交替写入 0/1 , 当为 0 时为低电压,为 1 时通过上拉电阻输出高电压。

SDA 引脚数据传输的电压变化原理和 SCL 同理。

I2C 通信协议

image-22.png

起始位和停止位

起始位时,要求 SCL 是高位,SDA 变低。停止位则是 SCL 高位,SDA 升高。

寻址阶段

寻址阶段位于起始位之后,数据传输之前。

I2C 模块的使用方法

查阅文档表格,可以找到 I2C 模块的默认引脚和映射后的引脚。

连接 OLED 显示器(使用复用接法)

image-23.png

关于为什么需要有一个电阻,我向群友询问了许久。原因是,没有上拉电阻的话,叫浮空输入,总线的电平不确定,会被环境的电磁波之类的影响,一直变动,浮空就是相当于你的引脚变成了一根天线,接受空气中的信号。接上拉电阻以后,通信线默认就是高电平,这样哪个从机要发信号,就通过拉低通信线的电平来发数据,其它的从机不会对他干扰。

I2C 的速度模式

image-24.png

时钟信号的占空比

只有在快速模式下才可以设置时钟信号的占空比。占空比是 TLow/THighT_{Low}/T_{High} 有 2/1 和 16/9 两种模式。一般使用第一种模式。

编程接口

GPIO_Init USART_Init 类似,故不再记述函数。

image-25.png

需要注意,开启 I2C1 的时钟时应该使用 APB1 开启,而非之前的 APB2。

同时,在使用 I2C 前,要进行复位操作,防止旧信号残留,也就是类似于物理复位按键的按下 抬起操作。

    // 初始化 I2C1 模块
RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE);
RCC_APB1PeriphResetCmd(RCC_APB1Periph_I2C1, ENABLE);
RCC_APB1PeriphResetCmd(RCC_APB1Periph_I2C1, DISABLE);

I2C_InitTypeDef I2C_InitStructure;
I2C_InitStructure.I2C_Mode = I2C_Mode_I2C;
I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2; // 占空比
I2C_InitStructure.I2C_ClockSpeed = 400; // 400kHz
I2C_Init(I2C1, &I2C_InitStructure);

I2C_Cmd(I2C1, ENABLE); // 使能 I2C1 模块

写数据

内部结构

image-26.png

SR:Status Register ,状态寄存器,可以反映 I2C 模块的运行状态。

发送过程

image-22.png

标志位 含义
BUSY 总线忙
SB Start Bit 起始条件允许
AF Ackowledge Failure 应答失败(使用前需要清零)
ADDR 寻址成功
BTF Byte Transfer Finished
& 运算

任何数 & 1 都是本身。任何数 & 0 都是 0。通过 &0xfe(1111 1110) 即可将最后一位清零。

代码 注意看注释
int i2CSendBytes(I2C_TypeDef *I2Cx, uint8_t slaveAddress, uint8_t *data, uint16_t length)
{
    // #1. 等待总线空闲
    while (I2C_GetFlagStatus(I2Cx, I2C_FLAG_BUSY) == SET)
        ;
    // #2. 发送起始位
    I2C_GenerateSTART(I2Cx, ENABLE);
    while (I2C_GetFlagStatus(I2Cx, I2C_FLAG_SB) == RESET)
        ; // 等待发送完成
    // #3. 发送从机地址
    I2C_ClearFlag(I2Cx, I2C_FLAG_ADDR);      // 清除地址标志位
    I2C_SendData(I2Cx, slaveAddress & 0xfe); // 最后一位清零
    // 等待寻址成功
    while (1)
    {
        if (I2C_GetFlagStatus(I2Cx, I2C_FLAG_ADDR) == SET)
        {
            break;
        }
        if (I2C_GetFlagStatus(I2Cx, I2C_FLAG_AF) == SET)
        {
            // 寻址失败,发送停止位
            I2C_GenerateSTOP(I2Cx, ENABLE);
            return -1; // 从机无应答
        }
    }
    // 清除 ADDR. 注意:必须先读取 SR1 寄存器,再读取 SR2 寄存器,才能清除 ADDR 标志位
    I2C_ReadRegister(I2Cx, I2C_Register_SR1);
    I2C_ReadRegister(I2Cx, I2C_Register_SR2);
    // #4. 发送数据
    for (uint16_t i = 0; i < length; i++)
    {
        while (1)
        {
            if (I2C_GetFlagStatus(I2Cx, I2C_FLAG_AF) == SET)
            {
                // 上一个数据拒收了,直接发送停止位
                I2C_GenerateSTOP(I2Cx, ENABLE);
                return -2; // 从机拒收数据
            }
            if (I2C_GetFlagStatus(I2Cx, I2C_FLAG_TXE) == SET)
            {
                // 发送数据
                I2C_SendData(I2Cx, data[i]);
                break;
            }
        }
    }
    while (1)
    {
        if (I2C_GetFlagStatus(I2Cx, I2C_FLAG_AF) == SET)
        {
            // 上一个数据拒收了,直接发送停止位
            I2C_GenerateSTOP(I2Cx, ENABLE);
            return -2; // 从机拒收数据
        }
        if (I2C_GetFlagStatus(I2Cx, I2C_FLAG_BTF) == SET)
        {
            // 数据发送完成,发送停止位
            I2C_GenerateSTOP(I2Cx, ENABLE);
            break;
        }
    }
    // #5. 发送停止位
    I2C_GenerateSTOP(I2Cx, ENABLE);
    return 0; // 发送成功
}

通过这个函数来发送指令

// main 中
myI2CInit();
// 命令流0x00,使能电荷泵0x8d,0x14,打开屏幕开关0xaf,屏幕全亮0xa5
uint8_t commands[] = {0x00, 0x8d, 0x14, 0xaf, 0xa5}; 
i2CSendBytes(I2C1, 0x78, commands, 5);

成功点亮了屏幕!

读数据

寻址时,因为时读取数据,所以 RW# 位的值应该是 1 .

| 运算

任何数 | 0 为本身,任何数 | 1 为 1。

NAK 、 ACK 、STOP

每次接收一个字节,应该回复一个 ACK(Acknowledgement). 当接收完成时,应该回应 NAK(Not Acknowledgement). 如果不发送 NAK ,停止位会被忽略。

image-27.png

ACK 只作用于正在被接收的字节。 当字节正在被接收时,通过控制寄存器来设置 ACK,当 ACK 值为 0 发送 NAK,ACK 为 1 发送 NAK。若字节已经接收完成,即字节完全进入了移位寄存器,ACK 就只能控制下一个字节了。

向 STOP 写入 1 可以发送停止位。但任一时刻写入 1 ,只有在字节完全进入移位寄存器之后才会触发效果。

一次接收多个字节

在 ADDR 标志位被清除之前,硬件会冻结时钟,阻止数据传输的继续。所以在接收字节时要清空 ADDR 标志位。

接收最后一个字节一个字节时,在清除 ADDR 后,要立刻设置 ACK=0 STOP=1,再开始接收。

int i2CReceiveBytes(I2C_TypeDef *I2Cx, uint8_t slaveAddress, uint8_t *data, uint16_t length)
{
    // #1. 发送起始位
    I2C_GenerateSTART(I2Cx, ENABLE);
    while (I2C_GetFlagStatus(I2Cx, I2C_FLAG_SB) == RESET)
        ; // 等待发送完成
    // #2. 寻址
    I2C_ClearFlag(I2Cx, I2C_FLAG_AF);        // 清除 AF 标志位
    I2C_SendData(I2Cx, slaveAddress | 0x01); // 最后一位置 1
    while (1)
    {
        if (I2C_GetFlagStatus(I2Cx, I2C_FLAG_AF) == SET)
        {
            // 寻址失败
            I2C_GenerateSTOP(I2Cx, ENABLE);
            return -1; // 寻址失败
        }
        if (I2C_GetFlagStatus(I2Cx, I2C_FLAG_ADDR) == SET)
        {
            break; // 寻址成功
        }
    }
    // #3. 接收数据
    I2C_ReadRegister(I2Cx, I2C_Register_SR1);
    I2C_ReadRegister(I2Cx, I2C_Register_SR2); // 清除 ADDR
    uint16_t i = 0;
    while (i < length - 2)
    {
        I2C_ReadRegister(I2Cx, I2C_Register_SR1);
        I2C_ReadRegister(I2Cx, I2C_Register_SR2); // 清除 ADDR
        I2C_AcknowledgeConfig(I2Cx, ENABLE); // 使能应答 ACK = 1
        while (I2C_GetFlagStatus(I2Cx, I2C_FLAG_RXNE) == RESET)
            ; // 等待接收数据寄存器清空
        data[i++] = I2C_ReadRegister(I2Cx, I2C_Register_DR);
    }
    I2C_AcknowledgeConfig(I2Cx, DISABLE);     // 关闭应答 ACK = 0
    I2C_GenerateSTOP(I2Cx, ENABLE);           // 发送停止位 STOP = 1
    while (I2C_GetFlagStatus(I2Cx, I2C_FLAG_RXNE) == RESET)
        ; // 等待接收数据寄存器清空
    data[i] = I2C_ReadRegister(I2Cx, I2C_Register_DR);
}

读取 OLED 回应

当对 OLED 做读操作时,它返回的第一个字节是状态字节,其中:

含义
D7 读取状态,读操作时恒为 1
D6 BUSY 忙标志:1 = 忙,0 = 空闲
D5~D0 保留,恒为 0

值得注意的是,两处代码里面写的 OLED 地址 0x78 其实是 (0x3c << 1) ,也就是代码使用的地址是实际的地址左移一位。

int main(void)
{
    myI2CInit();
    onboardLEDInit();
    // 命令流0x00,使能电荷泵0x8d,0x14,打开屏幕开关0xaf,屏幕全亮0xa5
    uint8_t commands[] = {0x00, 0x8d, 0x14, 0xaf, 0xa5};
    i2CSendBytes(I2C1, 0x78, commands, 5);

    uint8_t rcvd;
    i2CReceiveBytes(I2C1, 0x78, &rcvd, 1); // 接收一个字节
    if ((rcvd & (0x01 << 6)) == 0)
    {
        GPIO_WriteBit(GPIOC, GPIO_Pin_13, Bit_RESET); // 点亮 LED
    }
    else
    {
        GPIO_WriteBit(GPIOC, GPIO_Pin_13, Bit_SET); // 熄灭 LED
    }
}

成功收到了 OLED 的回应,LED 被点亮了。

软件 I2C

在引脚受限时,可以使用代码控制电压信号来模拟 I2C 的输入与输出,也就是软 I2C.

OLED

老师将大部分功能都写在了 oled.h 里面,可以直接根据需求使用。

SPI

电路结构和通信协议

SPI 总线的电路结构

image-28.png

通信流程及波形

image-29.png

主机建立通信时,需要通过 IO 引脚向目标主机发送低电压,其它主机发送高电压。如图就是对从机1通信。

时钟信号的极性和相位

SPI 总线不进行通信时,即空闲状态时,如果 SCK 为低电压,即为低极性,否则为高极性。如上图就为低极性。低极性时,发送第一个时钟信号要使电压升高,也就是上升沿。时钟线电压的上升、下降的时刻称为边沿。根据采集数据的时间,分为两种相位:第一边沿采集(0)和第二边沿采集(1)。

SPI 的四种模式

两种极性,两种相位,可以组合出四种模式。

传输顺序

为了更直观地理解,我们以发送二进制数据 1001 0101(十六进制 0x95)为例

模式 英文全称 中文含义 发送顺序(举例)
MSB First Most Significant Bit First 高位(最左边)在前 1 -> 0 -> 0 -> 1 -> 0 -> 1 -> 0 -> 1
LSB First Least Significant Bit First 低位(最右边)在前 1 -> 0 -> 1 -> 0 -> 1 -> 0 -> 0 -> 1

可以理解为,MSB First 是按我们日常书写习惯(从左到右)发送数据,而 LSB First 则是将数据位顺序完全颠倒后发送。

数据宽度

分为 8bit 和 16bit.

[番外]按钮 驱动编写

我们之前学习过,按钮采用输入上拉模式,即使能上拉电阻,按钮按下时读入 0 ,松开时读入 1。板载 LED 采用开漏接法,PC13 设置为输出开漏模式。

按钮消抖

因为按钮抖动导致电压快速变化,可能会出现按键失灵的情况。我们可以在读入到操作后暂停读入 10ms 左右来实现按钮消抖。

使用封装的 Button 功能

实现:当按钮按下时,count 增加,双击清零

#include "stm32f10x.h"
#include "usart.h"
#include "button.h"

Button_TypeDef button;
uint32_t count = 0;

void onClicked(uint8_t clicks)
{
    if (clicks == 1)
    {
        count += 1;
        My_USART_Printf(USART1, "Button clicked %d times\n", count);
    }
    else if (clicks == 2)
    {
        count = 0;
        My_USART_Printf(USART1, "Count Reset!");
    }
}

void onPressed(uint8_t ticks)
{
    count += 1;
    My_USART_Printf(USART1, "Button clicked %d times\n", count);
}

void appUSART1Init(void)
{
    // TX - PA9, RX - PA10
    GPIO_InitTypeDef GPIO_InitStruct;
    USART_InitTypeDef USART_InitStruct;

    // Enable clock for GPIOA and USART1
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE);

    // Configure PA9 as alternate function push-pull
    GPIO_InitStruct.GPIO_Pin = GPIO_Pin_9;
    GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
    GPIO_InitStruct.GPIO_Speed = GPIO_Speed_2MHz;
    GPIO_Init(GPIOA, &GPIO_InitStruct);

    // Configure PA10 as input floating
    GPIO_InitStruct.GPIO_Pin = GPIO_Pin_10;
    GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU;
    GPIO_Init(GPIOA, &GPIO_InitStruct);

    USART_InitStruct.USART_BaudRate = 115200;
    USART_InitStruct.USART_WordLength = USART_WordLength_8b;
    USART_InitStruct.USART_StopBits = USART_StopBits_1;
    USART_InitStruct.USART_Parity = USART_Parity_No;
    USART_InitStruct.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;
    USART_Init(USART1, &USART_InitStruct);

    USART_Cmd(USART1, ENABLE);
}

void buttonInit(void)
{
    Button_InitTypeDef buttonInitStruct;
    buttonInitStruct.GPIOx = GPIOA;
    buttonInitStruct.GPIO_Pin = GPIO_Pin_0;
    buttonInitStruct.button_clicked_cb = onClicked;
    buttonInitStruct.button_released_cb = 0;
    buttonInitStruct.button_long_pressed_cb = onPressed;
    buttonInitStruct.LongPressTime = 0;
    buttonInitStruct.LongPressTickInterval = 0;
    buttonInitStruct.ClickInterval = 0;

    My_Button_Init(&button, &buttonInitStruct);
}

int main(void)
{
    appUSART1Init();
    buttonInit();
    My_USART_Printf(USART1, "Hello, USART1!");
    while (1)
    {
        My_Button_Proc(&button);
    }
}