准备阶段
完成了 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 找到引脚分布图

引脚排列规则:左上角为 1 号,逆时针排序
特殊引脚
- VDD : 供电+ (3.3V)
- VSS : 接地 0V
- NRST : 复位 接复位按钮
- VBAT : 备用电池
- BOOT0 : 启动模式选择
为何不用 VSS

普通引脚的命名规则
- 以字母作为组编号
- 以数字作为引脚编号
例如:
- GPIOA : PA0 PA1 PA2 … PA15 (16个)
- GPIOB : PB0 PB1 PB2 … PB15 (16个)
- GPIOC : PC13 PC14 PC15
- GPIOD : PD0 PD1
注意:引脚编号连续,在实际位置上不一定连续
GPIO
GPIO 是什么?
GPIO 属于一种片上外设。片上外设类似于人体的器官,可以独立完成一些任务。

有什么功能?
可以控制 IO 引脚,见 1.3.3 举例,GPIO 可以控制其对应的一组引脚
八种工作模式
| 输出 | 输入 |
|---|---|
| 通用输出推挽 | 输入上拉 |
| 通用输出开漏 | 输入下拉 |
| 复用输出推挽 | 输入浮空 |
| 复用输出开漏 | 模拟模式 |

输出

推挽
- 推(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 ,如下图:

观看最小系统板电路图,发现使用的是开漏接法,连接的是 PC13 引脚。
时钟
时钟可以类比为人的心跳,在使用片上外设之前应该要开启对应的时钟
// 开启 GPIOx 的时钟
RCC_APB2PriphClockCmd(RCC_APB2Periph_GPIOx, ENABLe);
GPIO 模块的编程接口
| 编程接口 | 作用 | 备注 |
|---|---|---|
| GPOI_Init | 初始化 IO 引脚 | 初始化一根 IO 引脚,即使用 IO 引脚之前配置一些参数(输入输出模式,最大输出速度) |
| GPIO_WriteBit | 向输出数据寄存器写一个比特位 | \ |
| GPIO_ReadInputDataBit | 从输入数据寄存器读一个比特位 | \ |
| GPIO_ReadOutputDataBit | 从输出数据寄存器读一个比特位 | 最近一次写入是什么,读出来的就是什么 |
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);
}
}
输入

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

保护二极管的工作原理
静电电压较高,电流会随着保护二极管流出。
上拉电阻和下拉电阻
上拉电阻和下拉电阻可以保持电路的稳定。施密特触发器流入的电流始终为 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 点亮判断电路是否连接成功。
#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 简介

我们学习的单片机有三个 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 模块有哪些引脚

引脚分布表
根据 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);
使用串口发射数据

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 总线可以让我们实现一对多的通信。
时钟线和数据线

时钟线的频率控制着数据传输的快慢,数据线上传输着的是有效数据。
电路结构与逻辑线与
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 通信协议

起始位和停止位
起始位时,要求 SCL 是高位,SDA 变低。停止位则是 SCL 高位,SDA 升高。
寻址阶段
寻址阶段位于起始位之后,数据传输之前。
I2C 模块的使用方法
查阅文档表格,可以找到 I2C 模块的默认引脚和映射后的引脚。
连接 OLED 显示器(使用复用接法)

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

时钟信号的占空比
只有在快速模式下才可以设置时钟信号的占空比。占空比是 有 2/1 和 16/9 两种模式。一般使用第一种模式。
编程接口
与 GPIO_Init USART_Init 类似,故不再记述函数。

需要注意,开启 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 模块
写数据
内部结构

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

| 标志位 | 含义 |
|---|---|
| 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 ,停止位会被忽略。

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 总线的电路结构

通信流程及波形

主机建立通信时,需要通过 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);
}
}