跳到主要内容

STM32F103C8T6 + Keil5 学习笔记

上一篇:STM32学习笔记(二)存储器、电源与时钟体系


从建工程到点灯、流水灯、定时器中断

这份笔记不是为了“背代码”,而是为了搞清楚:
STM32 到底是怎么一步一步把一个引脚控制起来的。
先从最简单的点灯开始,再过渡到流水灯,最后用定时器中断来控制灯的变化。


1. 先讲整体思路

刚接触 STM32 的时候,很容易有这种感觉:

  • 工程一大堆文件,看不懂
  • 一堆寄存器名字,像天书
  • 点个灯还要先开时钟、配模式
  • GPIOC->CRHRCC->APB2ENR 这些写法很陌生

其实别急,STM32 的套路很固定:

STM32 控制外设,基本就三步

  1. 先开时钟
    没有时钟,这个外设就像没供电,根本不会工作。

  2. 再配模式
    告诉引脚:你现在是输入、输出、复用,还是模拟功能。

  3. 最后读写寄存器
    比如输出高电平、低电平,或者读取按键状态。

只要这三个步骤想明白了,后面 GPIO、定时器、串口,思路其实都差不多。


2. Keil5 创建 STM32 工程


2.1 新建工程

打开 Keil5,依次点击:

Project -> New uVision Project...

然后新建一个文件夹,比如:

STM32_LED

工程名可以随便起,比如:

LED_Project

2.2 选择芯片

Keil 会弹出芯片选择窗口,在搜索框输入:

STM32F103C8

选择:

STMicroelectronics -> STM32F103C8Tx

然后点确定。


3. 认识 RTE 窗口


工程建好后,有时候会弹出 RTE 窗口。

RTE 的全称是:

Run-Time Environment

你可以把它理解成:

给工程选择“现成组件”的地方。

比如:

  • 启动文件
  • CMSIS 核心支持
  • 设备相关支持
  • 驱动组件

3.1 这个窗口里几个常见栏目什么意思

Software Component

表示“可选的软件组件”。

比如:

CMSIS Device Startup Driver

它们就是工程里要用到的基础模块。

Variant

表示“变体”。

同一个功能,可能有不同实现版本,比如:

标准库版本 HAL 版本 某个厂商定制版本

Vendor

表示提供这个组件的厂商。

比如:

STMicroelectronics ARM Keil

3.2 点灯工程一般要选什么

做最基础的 STM32 工程时,通常只要最核心的部分:

CMSIS -> CORE
Device -> Startup

如果你不太确定怎么选,可以直接点左下角的:

Resolve

Keil 常常会自动帮你补全依赖项。

3.3 工程里会自动出现什么文件

配置完以后,左侧工程栏一般会看到类似文件:

startup_stm32f10x.s
system_stm32f10x.c

这两个文件很关键。

startup_stm32f10x.s 是什么

这是启动汇编文件,主要负责:

  • 初始化堆栈
  • 设置中断向量表
  • 跳转到 main() 函数

你可以把它理解成:

芯片上电后,先跑的一段“开机引导程序”

system_stm32f10x.c

这个文件主要负责:

  • 系统时钟初始化
  • SystemInit() 的相关配置

你可以把它理解成:

给芯片整个运行环境做底层准备

4. 新建 main.c

工程框架有了以后,还要自己写程序逻辑。 建立工程直接跳过,就在Source Group 1里边自己整一个main.c就行


5.先理解:点灯到底在做什么


STM32 的“点灯”,本质上不是一句“让灯亮”那么简单,而是先把那个引脚变成输出模式,再给它一个电平。

以 PC13 为例,点亮板载 LED 的步骤一般是:

  • 打开 GPIOC 时钟
  • 把 PC13 配成输出
  • 输出高电平或低电平

5.1 为什么第一步总是开时钟

外设默认不是一直工作的。

如果你不先开时钟,它就相当于还没通电,后面的配置也没意义。

所以 STM32 里很常见的一句代码就是:

RCC->APB2ENR |= (1 << 4);

它的意思是:

打开 GPIOC 的时钟

5.2 为什么要先配模式

引脚不是天生就知道自己该做什么。

它可以是:

  • 输入
  • 输出
  • 复用功能
  • 模拟输入

所以程序必须先告诉它:“你现在要当输出口用。”


6. GPIO常见模式

这个地方如果不讲透,后面很容易一头雾水。

6.1核心原理图解

推挽/开漏输出图

6.2推挽输出(Push-Pull Output)

工作原理 推挽结构内部有两个互补的晶体管(PMOS + NMOS):

  • 输出逻辑 1 → PMOS 导通,NMOS 截止 → 引脚被强制拉到 VDD(高电平)
  • 输出逻辑 0 → NMOS 导通,PMOS 截止 → 引脚被强制拉到 GND(低电平)

无论输出高还是低,引脚始终有主动驱动,因此驱动电流大、边沿陡峭、抗干扰能力强。

主要应用场景

  • 适合所有单点驱动、独立控制的场合:LED、继电器、单片机之间的单向通信(UART TX、SPI MOSI/SCK/CS)、PWM 信号输出等。

6.3开漏输出(Open-Drain Output)

工作原理 开漏结构只有 NMOS,没有 PMOS。漏极悬空("开漏"即此意):

  • 输出逻辑 0 → NMOS 导通 → 引脚拉到 GND
  • 输出逻辑 1 → NMOS 截止 → 引脚悬空,需要外部上拉电阻将其拉高

关键特性:引脚输出高电平时完全依赖外部电阻,所以高电平的值由外部上拉电压决定,而不是芯片自身的 VDD。

6.4主要应用场景

场景原因
I²C 总线(SDA / SCL)多个设备共用同一根线,任何一个设备将线拉低即表示 0,不会产生总线冲突
线与逻辑(Wired-AND)多设备并联,只要一个输出 0 整条线就是 0,天然实现"与"逻辑
电平转换STM32 是 3.3V,外部设备是 5V;上拉到 5V,引脚只需控制是否拉低,无需承受 5V 输入
1-Wire / SMBus同上,单线双向、多从机协议均依赖开漏特性

6.5核心区别对比

维度推挽开漏
内部结构PMOS + NMOS仅 NMOS
高电平来源芯片内部 VDD外部上拉电阻
多设备共线❌ 会短路✓ 安全共线
驱动能力强(源电流 + 灌电流)弱(只能灌电流)
速度快(边沿陡)较慢(RC 上升沿)
电平转换不支持✓ 支持跨电压域
需要外部电阻不需要必须

6.6 浮空输入(Floating Input)

原理

引脚既没有内部上拉电阻,也没有内部下拉电阻,完全"悬在空中"。 引脚电平完全由外部信号决定。若外部什么都不接,引脚就像天线一样,极易受到电磁干扰,读取到的值是不确定的(可能是 0,也可能是 1,甚至反复跳变)。

VDD

╳ ← 无上拉

PIN ──→ 读取值

╳ ← 无下拉

GND

什么时候用

  • 外部电路已经有明确的驱动信号(如另一个芯片的输出引脚直连过来),不需要内部电阻辅助。
  • UART RX、SPI MISO 等由对端芯片主动驱动的引脚,通常配置为浮空输入。

初学者注意

如果引脚悬空(什么都不接)却配置为浮空输入,读出的值毫无意义,程序会出现莫名其妙的行为。务必保证外部有确定的驱动源。


6.7 上拉输入(Input Pull-Up)

原理

芯片内部在引脚与 VDD 之间接入一个弱上拉电阻(STM32 典型值约 40kΩ)。 当外部没有信号驱动时,引脚通过该电阻被"默默拉到"高电平,读取值稳定为 1。 当外部将引脚拉低(接 GND)时,读取值变为 0

VDD

[R 上拉 ~40kΩ] ← 内部电阻

PIN ──→ 读取值

外部按键/信号

GND

最经典的应用:按键检测

按键一端接引脚,另一端接 GND。

  • 按键未按下:引脚通过上拉电阻连到 VDD,读到 1
  • 按键按下:引脚被直接拉到 GND,读到 0

这就是常说的"低电平有效"按键电路,配合上拉输入使用无需外接任何电阻,简洁可靠。

什么时候用

  • 按键、拨码开关等机械触点输入。
  • I²C 的 SDA / SCL 引脚(配合外部上拉,内部上拉一般太弱)。
  • 任何"默认高、触发低"的信号。

初学者注意

上拉电阻是"弱驱动",只要外部有足够的拉低能力就能覆盖它。上拉电阻越大,功耗越低,但响应越慢;STM32 内部上拉约 40kΩ,适合低速信号。


6.8 下拉输入(Input Pull-Down)

原理

与上拉输入相反——芯片内部在引脚与 GND 之间接入一个弱下拉电阻(同样约 40kΩ)。 当外部没有信号驱动时,引脚通过该电阻被拉到低电平,读取值稳定为 0。 当外部将引脚拉高(接 VDD 或输出高电平)时,读取值变为 1

VDD

外部按键/信号

PIN ──→ 读取值

[R 下拉 ~40kΩ] ← 内部电阻

GND

什么时候用

  • 按键一端接 VDD,另一端接引脚("高电平有效"按键)。
  • 传感器输出为推挽高电平时,用下拉确保无信号时引脚不飘。
  • 任何"默认低、触发高"的信号。

上拉 vs 下拉 选择口诀

触发方式默认状态选择
按下接 GND(低电平触发)默认高上拉输入
按下接 VDD(高电平触发)默认低下拉输入
外部有稳定驱动不需要默认值浮空输入

6.9 模拟输入(Analog Input)

原理

模拟输入模式下,引脚的数字施密特触发器被完全旁路,引脚上的原始模拟电压直接送入芯片内部的 ADC(模数转换器)。 ADC 将连续的模拟电压(如 0~3.3V)采样并量化为离散的数字值(如 STM32 的 12 位 ADC 输出 0~4095)。

外部模拟信号(如传感器电压)

PIN

[施密特触发器 × 禁用]

ADC 采样保持电路

数字量化结果(0~4095)

寄存器

为什么要禁用施密特触发器?

施密特触发器的作用是把模拟信号"掰"成非 0 即 1 的数字信号,这在数字输入时很有用。 但在模拟输入时,我们需要的是完整的电压信息,不能被"非 0 即 1"破坏。 禁用后,模拟信号原汁原味地进入 ADC。

常见应用

应用说明
电位器读取旋转角度转为电压,ADC 读取位置
温度传感器(NTC/热敏电阻)温度变化导致电阻变化,转为电压后 ADC 采集
光敏电阻光照强度转为电压
电池电压监测分压后送入 ADC 判断电量
麦克风 / 音频信号声音波形的模拟采集

初学者注意

  • 模拟输入引脚的电压范围通常是 0V ~ VDD(3.3V),严禁超过此范围,否则损坏芯片。
  • 配置为模拟输入后,不要再给该引脚配置上拉或下拉,否则会影响采样精度。
  • 使用 ADC 前需在 STM32CubeMX 或代码中先开启 ADC 时钟并完成初始化。

四种输入模式总结

模式内部电阻默认电平典型用途
浮空输入不确定(禁止悬空)UART RX、SPI MISO、外部已有驱动的信号
上拉输入上拉至 VDD(~40kΩ)高电平(1)按键(低电平触发)、默认高信号
下拉输入下拉至 GND(~40kΩ)低电平(0)按键(高电平触发)、默认低信号
模拟输入无(触发器旁路)连续电压值ADC 采集、传感器、音频

7. 第一段代码:PC13 点灯

寄存器版代码

#include "stm32f10x.h"

void Delay(unsigned int count)
{
while(count--);
}

int main(void)
{
// 1. 开启 GPIOC 时钟
RCC->APB2ENR |= (1 << 4);

// 2. 配置 PC13 为推挽输出
// PC13 属于 8~15 号引脚,所以操作 CRH
GPIOC->CRH &= ~(0x0F << 20); // 先清掉原来的配置
GPIOC->CRH |= (0x03 << 20); // 50MHz 推挽输出

while(1)
{
GPIOC->ODR |= (1 << 13); // 输出高电平
Delay(1000000);

GPIOC->ODR &= ~(1 << 13); // 输出低电平
Delay(1000000);
}
}

这是典型的通过结构体配置寄存器的操作方法,寄存器每个位都是分别管控着不同输入输出的开启,复位,模式等等,所以寄存器的值需要通过查阅STM官方手册获取相关信息。 我用的板子是PC13端口接了一个LED,所以单颗LED的代码,我配置的就是单片机的GPIO13引脚,和这个相关的时钟,已经开关等等。

8. 第二个实验:流水灯

流水灯其实就是:

  • 一颗灯亮一下,再换下一颗亮一下。

8.1 为什么要用 PB8~PB15

因为这些引脚正好是 GPIOB 的高 8 位,通过排线连接测试,根据自己的开发版选择对应接线进行配置即可。

8.2 为什么要用 CRH

因为 PB8PB15 都属于 815 号引脚,所以必须操作:

GPIOB->CRH

8.3 为什么可以直接写 0x33333333

每个引脚在 CRH 中占 4 位。

如果你希望 PB8~PB15 全部都是:

  • 50MHz 推挽输出

那每一组 4 位都写成 0011,也就是十六进制的 3。

8 个引脚连在一起,整块就是:

0x33333333

8.4 流水灯代码(寄存器版)

#include "stm32f10x.h"

void Delay(unsigned int count)
{
while(count--);
}

int main(void)
{
unsigned short i;

// 1. 开启 GPIOB 时钟
RCC->APB2ENR |= (1 << 3);

// 2. PB8~PB15 全部设置为推挽输出
GPIOB->CRH = 0x33333333;

while(1)
{
for(i = 0; i < 8; i++)
{
// 让第 8 位开始,依次往后亮
GPIOB->ODR = (1 << (8 + i));
// 灯控制逻辑是高电平亮就取反
//GPIOB->ODR = ~(1 << (8 + i));
Delay(1000000);
}
}
}

为什么不建议一直用 Delay

前面的程序都用了:

Delay(1000000);

这种写法简单,但它有明显问题:

  • CPU 一直空转
  • 程序在等待期间什么都不能做
  • 延时精度一般
  • 不利于以后做多任务

所以它适合入门理解,但不适合长期当正式方案。

更好的做法是:

  • 用定时器中断来控制节拍

9. 进阶

9.1 状态变化实现流水灯

#include "stm32f10x.h"

void Delay(unsigned int count)
{
while(count--);
}

int main(void)
{
int pos = 8;
int direction = 1;

RCC->APB2ENR |= (1 << 3); // 开 GPIOB 时钟
GPIOB->CRH = 0x33333333; // PB8~PB15 输出

while(1)
{
GPIOB->ODR = ~(1 << pos); // 低电平点亮写法
Delay(1000000);

if(direction == 1)
{
pos++;
}
else
{
pos--;
}

if(pos == 15)
{
direction = 0;
}
else if(pos == 8)
{
direction = 1;
}
}
}

9.2 定时器控制实现流水灯

#include "stm32f10x.h"
#include "stm32f10x_conf.h"
// 2. 定义全局变量
int pos = 8;
int direction = 1;

// 3. 各种初始化函数 (必须放在 main 的上面)

// --- LED 初始化 (使用标准库写法替代之前的寄存器写法) ---
void LED_Init(void) {
GPIO_InitTypeDef GPIO_InitStructure;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); // 开启 GPIOB 时钟

// 配置 PB8 到 PB15 为推挽输出
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8 | GPIO_Pin_9 | GPIO_Pin_10 | GPIO_Pin_11 |
GPIO_Pin_12 | GPIO_Pin_13 | GPIO_Pin_14 | GPIO_Pin_15;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);

// 初始化全灭 (高电平灭)
GPIO_SetBits(GPIOB, GPIO_Pin_8 | GPIO_Pin_9 | GPIO_Pin_10 | GPIO_Pin_11 |
GPIO_Pin_12 | GPIO_Pin_13 | GPIO_Pin_14 | GPIO_Pin_15);
}

// --- 中断优先级配置 ---
void NVIC_Configuration(void) {
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);

NVIC_InitStructure.NVIC_IRQChannel = TIM2_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
}

// --- 定时器 2 初始化 ---
void Timer_Init(void) {
TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);

TIM_TimeBaseStructure.TIM_Period = 4999; // ARR
TIM_TimeBaseStructure.TIM_Prescaler = 7199; // PSC
TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1;
TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure);

TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE);
TIM_Cmd(TIM2, ENABLE);
}

// 4. 定时器中断服务函数
void TIM2_IRQHandler(void) {
if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) {

GPIOB->ODR = ~(1 << pos); // 点亮当前位置的灯

if (direction == 1) pos++;
else pos--;

if (pos == 15) direction = 0;
else if (pos == 8) direction = 1;

TIM_ClearITPendingBit(TIM2, TIM_IT_Update); // 清除中断标志
}
}

// 5. 主函数
int main(void) {
LED_Init(); // 初始化 LED
NVIC_Configuration(); // 配置中断优先级
Timer_Init(); // 启动定时器

while(1) {
// 流水灯靠 TIM2 中断在后台默默工作
}
}

9.3 定时器控制流水灯的思路

9.3.1 传统方式(阻塞式)

以前控制流水灯,依赖 Delay 函数让 CPU 傻等:

点亮第一颗灯
Delay(500ms) // CPU 被卡在这里,什么都做不了
点亮第二颗灯
Delay(500ms)
...

缺点很明显:Delay 期间 CPU 完全被占用,无法响应其他任务。

9.3.2 定时器中断方式(非阻塞式)

现在的思路是把"等待"交给定时器硬件,CPU 去做别的事:

主程序:空转 / 处理其他逻辑

TIM2 每隔 0.5 秒硬件触发一次中断

CPU 暂停当前任务 → 进入中断函数 → 切换灯状态 → 返回

核心优势

对比项Delay 方式定时器中断方式
CPU 利用率低(等待期间空转)高(主循环可处理其他任务)
时间精度受代码执行时间影响由硬件计数,精度高
可扩展性好,接近真实项目结构

定时器中断是嵌入式开发中最常用的机制之一,掌握它是从"能跑"走向"写得好"的关键一步。


9.3.3 为什么先学 TIM2

STM32F103 系列有多个定时器(TIM1 ~ TIM4 等),入门阶段通常从 TIM2 开始。

STM32 的定时器分三类:

类型代表特点
基本定时器TIM6、TIM7功能最简单,只能计数和触发中断
通用定时器TIM2~TIM5功能适中,支持输入捕获、输出比较、PWM 等
高级定时器TIM1、TIM8功能最强,支持互补输出、死区控制,用于电机控制

TIM2 功能够用、结构不复杂,是学习定时器的最佳起点

TIM1 是高级定时器,拥有刹车输入、互补 PWM 输出等高级功能,主要用于无刷电机、变频器等场景。初学阶段直接上手 TIM1 会引入很多不必要的配置干扰,容易劝退。

TIM2 的中断处理函数名称固定为:

void TIM2_IRQHandler(void)
{
// 每次定时器溢出,自动跳转到这里执行
}

命名规律清晰:外设名 + _IRQHandler,初学者看名字就能理解它的作用,有助于建立"中断"的直觉概念。


10. 定时器中断的配置步骤

让 TIM2 每 0.5 秒触发一次中断,需要按顺序完成以下 7 步:

第 1 步  开启 TIM2 时钟
第 2 步 配置预分频值 PSC
第 3 步 配置自动重装载值 ARR
第 4 步 使能更新中断(UIE)
第 5 步 配置 NVIC(中断优先级)
第 6 步 启动定时器(CEN 位置 1)
第 7 步 编写中断处理函数 TIM2_IRQHandler

为什么顺序很重要? 如果在时钟开启之前就操作 TIM2 的寄存器,写入的值不会生效; 如果在 NVIC 配置之前就启动定时器,中断可能无法被 CPU 响应。


10.1 定时器时间怎么计算

溢出时间公式

定时器每次计数从 0 数到 ARR,然后产生一次溢出中断。完整公式如下:

T = (PSC + 1) × (ARR + 1) / Clk

各参数含义:

参数说明
PSC预分频寄存器值(Prescaler)
ARR自动重装载寄存器值(Auto-Reload Register)
Clk定时器输入时钟频率(单位 Hz)
T定时器溢出时间(单位 s)

计数器工作流程:

时钟脉冲 → [÷(PSC+1) 分频] → 计数器每个节拍 +1
计数器:0 → 1 → 2 → ... → ARR → 溢出!触发中断 → 重新从 0 开始

10.2 例:配置 TIM2 每 0.5 秒中断一次

已知条件:

  • 系统时钟 = 72 MHz
  • 目标定时时间 T = 0.5 s

第一步:选定 PSC,将时钟降到好计算的频率

PSC = 7199

分频后时钟 = 72,000,000 ÷ (7199 + 1) = 10,000 Hz

→ 计数器每秒跳动 10,000 次,即每 0.1 ms 跳一次

第二步:根据目标时间算出 ARR

0.5 秒需要跳动的次数 = 10,000 × 0.5 = 5,000 次

因为计数器从 0 开始,数到 4999 时完成 5000 次跳动

→ ARR = 4999

第三步:验证

T = (7199 + 1) × (4999 + 1) / 72,000,000 = 7200 × 5000 / 72,000,000 = 0.5s ✓

10.3 最终配置结论

TIM2->PSC = 7199;   // 预分频:72MHz ÷ 7200 = 10kHz
TIM2->ARR = 4999; // 自动重装:计满 5000 次溢出,即 0.5s

10.4 PSC 和 ARR 的取值范围

寄存器位宽最大值
PSC16 位65535
ARR16 位(TIM2 在 F103 为 16 位)65535

如果需要更长的定时时间(如 10 秒),可以增大 PSC 降低计数频率,或在中断函数里加软件计数器(每进 20 次中断才执行一次操作)。


清除标志位为什么是必须的?

定时器产生中断后,会在状态寄存器 SR 中置位一个标志。 如果不手动清除,CPU 从中断返回后会发现标志仍然为 1,误以为又来了一次中断,从而反复进入中断函数,程序陷入死循环


附:关键寄存器速查

寄存器作用
TIM2->PSC预分频值
TIM2->ARR自动重装载值(定时上限)
TIM2->CNT当前计数值(可读可写)
TIM2->CR1控制寄存器,bit0(CEN)= 1 启动定时器
TIM2->DIER中断使能寄存器,bit0(UIE)= 1 使能更新中断
TIM2->SR状态寄存器,bit0(UIF)= 1 表示发生了更新中断

总结

这篇笔记的核心不是“写了多少代码”,而是让你明白 STM32 是怎么工作的。

你已经知道了:

  • Keil5 怎么建 STM32 工程
  • RTE 窗口是干什么的
  • 启动文件和系统文件的作用
  • 为什么要先开时钟
  • GPIO 的常见模式
  • PC13 怎么点亮
  • PB8~PB15 怎么做流水灯
  • CRL 和 CRH 的区别
  • 为什么定时器要选 TIM2
  • PSC 和 ARR 怎么算
  • 中断是怎么接管程序节奏的

最后记住一句话:

STM32 不是”背代码”,而是”理解外设,然后按步骤把它打开、配置、控制起来”。