【嵌入式硬件】6.0 STM32F103 HAL库开发实战——使用寄存器点亮LED灯

目标:本文通过直接读写寄存器的方式点亮开发板上的LED灯,不依赖任何固件库,不需要先学完 GPIO 框图,先完成一个可编译、可下载的最小工程,再逐项说明代码中每个寄存器和数值的含义。
1. 点灯实现步骤
控制一个引脚输出电平固定需要三个步骤,顺序不可调换:
| 步骤 | 操作 | 涉及寄存器 |
|---|---|---|
| 1 | 开启GPIO端口时钟 | RCC_APB2ENR |
| 2 | 配置引脚为推挽输出 | GPIOx_CRL / GPIOx_CRH |
| 3 | 向引脚写入低电平 | GPIOx_ODR |
以LED接在PB0的开发板为例,main函数的内容如下:
#include "stm32f10x.h"
// SystemInit 留空即可——后面直接在 main 里配时钟,用不到它
void SystemInit(void)
{
}
int main(void)
{
RCC_APB2ENR |= (1 << 3); // 开启GPIOB时钟
GPIOB_CRL &= ~(0x0F << (4 * 0)); // 清空PB0的配置位
GPIOB_CRL |= (0x01 << (4 * 0)); // PB0配为推挽输出,10MHz
GPIOB_ODR &= ~(1 << 0); // PB0输出低电平,LED亮
while (1); // 程序在此停留
}
stm32f10x.h
/*片上外设基地址 */
#define PERIPH_BASE ((unsigned int)0x40000000)
/*总线基地址,GPIO都挂载到APB2上 */
#define APB2PERIPH_BASE (PERIPH_BASE + 0x10000)
/*GPIOB外设基地址*/
#define GPIOB_BASE (APB2PERIPH_BASE + 0x0C00)
/* AHB总线基地址 */
#define AHBPERIPH_BASE (PERIPH_BASE + 0x20000)
/* GPIOB寄存器地址,强制转换成指针 */
#define GPIOB_CRL *(unsigned int*)(GPIOB_BASE+0x00)
#define GPIOB_CRH *(unsigned int*)(GPIOB_BASE+0x04)
#define GPIOB_IDR *(unsigned int*)(GPIOB_BASE+0x08)
#define GPIOB_ODR *(unsigned int*)(GPIOB_BASE+0x0C)
#define GPIOB_BSRR *(unsigned int*)(GPIOB_BASE+0x10)
#define GPIOB_BRR *(unsigned int*)(GPIOB_BASE+0x14)
#define GPIOB_LCKR *(unsigned int*)(GPIOB_BASE+0x18)
/*RCC外设基地址*/
#define RCC_BASE (AHBPERIPH_BASE + 0x1000)
/*RCC的AHB1时钟使能寄存器地址,强制转换成指针*/
#define RCC_APB2ENR *(unsigned int*)(RCC_BASE+0x18)
startup_stm32f10x_hd.s还是之前的默认文件。比如Keil_v5下面就有现成的。
Keil_v5\ARM\PACK\Keil\STM32F1xx_DFP\2.4.1\Boards\Keil\MCBSTM32E\Blinky\RTE\Device\STM32F103ZE\startup_stm32f10x_hd.s
F7编译,F8运行。

2. GPIO 简介
GPIO 是 General Purpose Input/Output(通用输入输出)的缩写,指 STM32 上我们能用代码控制电平高低的引脚。引脚接上外部设备,就能实现控制(输出高低电平)、通讯(发送接收信号)、采集(读取外部电平)。
2.1 内部结构
下图最右边是芯片引出的引脚,往左全是芯片内部的东西:

| 编号 | 模块 | 干什么的 |
|---|---|---|
| ① | 保护二极管(上下一对) | 引脚电压超出 0~3.3V 范围时导通泄流,防止烧芯片;但扛不住大电流,大功率器件不能直驱 |
| ② | P-MOS + N-MOS 驱动管 | 输出的两个「开关」:P管把引脚推到 3.3V,N管把引脚拉到 0V。推挽输出=两个都开;开漏输出=只开N管、只能拉低;两个都关=纯输入 |
| ③ | 输出数据寄存器 ODR | 你要输出的 0/1 写在这里 |
| ④ | 复用功能输出 | 引脚交给外设(串口TX、定时器PWM)控制时走的通道,和 ODR 信号二选一 |
| ⑤ | 施密特触发器 + 输入数据寄存器 IDR | 引脚电压先经触发器整形成干净的 0/1,存进 IDR 供 CPU 读 |
| ⑥ | 复用功能输入 | 外设(串口RX等)读引脚走的通道 |
| ⑦ | 模拟输入 | 绕过触发器和MOS管,原始电压直接送 ADC(模数转换器,把电压变成数字) |
2.2 八种工作模式
上面的结构决定了每个引脚可以配成 8 种工作模式,归纳成三类:
| 类别 | 细分 | 输出 | 输入 | 速度可配 |
|---|---|---|---|---|
| 输入模式 | 模拟、浮空、上拉、下拉 | 不能输出 | 读 IDR | 否 |
| 输出模式 | 推挽、开漏 | 写 ODR 控制 | 可读 IDR 看实际状态 | 是 |
| 复用功能 | 推挽、开漏 | 由外设驱动,ODR 无效 | 可读 IDR,一般读外设寄存器 | 是 |
点灯用的是其中的通用推挽输出。以后用标准库或 HAL 库时见到的 GPIO_Mode_Out_PP 这类名字,对应的就是这 8 种模式。
2.3 引脚编号
GPIO 按组划分,每组最多 16 个引脚,编号 0-15。比如 STM32F103VET6 有 GPIOA-GPIOE 共 5 组。
引脚名 = P + 组字母 + 编号:
| 写法 | 含义 |
|---|---|
PB0 | GPIOB 组的第 0 号引脚 |
PC2 | GPIOC 组的第 2 号引脚 |
PA8 | GPIOA 组的第 8 号引脚 |
下文说「端口」指一整组(如 GPIOB),「引脚」指组里的一根(如 PB0)。
有个关键点:寄存器是按端口给的,不是按引脚给的。一个 32 位寄存器里装着整组 16 个引脚的信息,改某一个引脚,只能在寄存器里动属于它的那几位——这就是后文代码里全是位操作的原因。
2.4 基本功能
| 功能 | 干什么 | 典型应用 |
|---|---|---|
| 输出 | 代码控制引脚输出高(3.3V)或低(0V) | 控制LED亮灭;经三极管/继电器控制大功率电路 |
| 输入 | 代码读取引脚当前是高还是低 | 判断按键有没有被按下 |
本文只用输出功能:让引脚输出低电平,点亮接在上面的 LED。输入的读法在 6.7 节顺带说明。
3. 硬件连接
野火开发板的 LED 是「共阳」接法:LED 正极接 3.3V 电源,负极接 STM32 引脚,中间串一个限流电阻。

灯亮不亮,只看有没有电流流过 LED,也就是引脚两端有没有电压差:
| 引脚输出 | 结果 | 原因 |
|---|---|---|
| 低电平(0V) | 灯亮 | 3.3V 对 0V,有压差,电流流过 |
| 高电平(3.3V) | 灯灭 | 两边都是 3.3V,没有电流 |
一句话记住:这块板子,写 0 灯亮,写 1 灯灭。(如果板子是 LED 负极接地、正极接引脚的共阴接法,就反过来。)
另外,引脚内部虽有保护二极管(电压超出范围时导通泄流,防止烧芯片),但它扛不住大电流——想直接驱动电机这类大功率器件,要么不转要么烧芯片,必须外加驱动电路。LED 功率小,串个限流电阻就够了。
3.1 指南者板的LED引脚
本文以 F103-指南者为例,LED 接在 PB0。板上是 RGB 三色灯,红、绿、蓝三个引脚各控一色,本文只配其中一个。
如果换其他引脚,按这个规律换算:
- 时钟开关的位号 = 字母序号 + 2(A=2、B=3、C=4……)
- 引脚号 0~7 → 用 CRL,位移
4 * 引脚号 - 引脚号 8~15 → 用 CRH,位移
4 * (引脚号 - 8)
4. 寄存器地址
4.1 寄存器是什么
寄存器就是芯片内一块有地址的内存,32 个位,每位放 0 或 1。往里面写特定的数字,硬件就会照着做。
比如 GPIOB 的 ODR 寄存器,低 16 位正好对应 16 个引脚:第 0 位管 PB0、第 1 位管 PB1……某位写 0,对应引脚就输出低电平。
4.2 地址怎么算出来的
像门牌号一样逐级叠加:城市 → 区 → 街道 → 门牌号。
0x4000_0000 片上外设(市)
├─ +0x10000 → 0x4001_0000 APB2总线(区)
│ ├─ +0x0800 → 0x4001_0800 GPIOA(街道)
│ ├─ +0x0C00 → 0x4001_0C00 GPIOB
│ └─ +0x1000 → 0x4001_1000 GPIOC
└─ +0x20000 → 0x4002_0000 AHB总线(区)
└─ +0x1000 → 0x4002_1000 RCC(管时钟的)
不用背,知道「名字 = 基地址逐级加偏移算出来的地址」就够了,换算全在 stm32f10x.h 里做好了。
4.3 给地址起名字
数字地址没人记得住,所以在 stm32f10x.h 里给每个地址起了名字:
#define GPIOB_ODR *(unsigned int*)(GPIOB_BASE + 0x0C)
拆开看:
GPIOB_BASE + 0x0C:算出地址0x4001_0C0C(unsigned int*):把这个数当成指针- 开头的
*:以后代码里写GPIOB_ODR,就是直接读写这个地址
点灯步骤开头贴出的 stm32f10x.h 只定义了 GPIOB,要用 GPIOA、GPIOC,照着把 BASE 换一下即可。
4.4 GPIO 端口里的寄存器
每个 GPIO 端口有 7 个寄存器。名字看着像乱码,其实全是英文缩写,对照全称就知道各自管什么:
| 偏移 | 名字 | 全称(缩写从这来) | 管什么 |
|---|---|---|---|
| 0x00 | CRL | Control Register Low,端口配置低寄存器 | 配置 0~7 号引脚的模式和速度 |
| 0x04 | CRH | Control Register High,端口配置高寄存器 | 配置 8~15 号引脚的模式和速度 |
| 0x08 | IDR | Input Data Register,输入数据寄存器 | 读引脚电平(只读) |
| 0x0C | ODR | Output Data Register,输出数据寄存器 | 控制引脚输出什么电平 |
| 0x10 | BSRR | Bit Set/Reset Register,端口位设置/清除寄存器 | ODR 的原子操作版(置1/清0一步到位) |
| 0x14 | BRR | Bit Reset Register,端口位清除寄存器 | 只清0,BSRR 的简化版 |
| 0x18 | LCKR | Lock Register,端口配置锁定寄存器 | 锁定引脚配置,本文用不到 |
读写属性记两条:CRL/CRH/ODR 可读可写;IDR 只读,且只有低 16 位有效。各寄存器每一位的完整说明,可查《STM32F10X-中文参考手册》的 GPIO 章节。
RCC 那边本文只用到一个:偏移 0x18 的 RCC_APB2ENR。拆开念:RCC = Reset and Clock Control(复位与时钟控制),管全芯片的时钟;APB2 是挂 GPIO 的那条总线(4.2 地址树里的「区」);ENR = Enable Register(使能寄存器)。合起来就是「APB2 总线上外设的时钟开关排」。
5. 工程文件
点灯工程就三个文件:
工程目录/
├── startup_stm32f10x_hd.s 启动文件,ST官方提供,不用改
├── stm32f10x.h 寄存器名字 → 地址的对应表
└── main.c 主程序
5.1 启动文件:芯片上电后先跑它
芯片上电后不是直接跑 main,而是先执行启动文件里的汇编,它干两件事:
- 调用
SystemInit配置系统时钟 - 跳进 C 库的
__main,最后进入我们写的main
这就是 main.c 里放一个空 SystemInit 占位的原因。留空不配时钟时,芯片自动用内部自带的 8MHz 振荡器,点灯足够。
6. 寄存器配置说明
把 main 里的 4 行代码逐行讲清楚。
6.1 开启端口时钟——不给电,写了也白写
RCC_APB2ENR |= (1 << 3); // 开启GPIOB时钟
STM32 为了省电,每个外设默认「断电」(时钟关闭)。断电的外设,往它寄存器写什么都没反应。
RCC_APB2ENR 就是一排电源开关,每位管一个外设:
| 位 | 外设 | 位 | 外设 |
|---|---|---|---|
| 0 | AFIO | 5 | GPIOD |
| 2 | GPIOA | 6 | GPIOE |
| 3 | GPIOB | 9 | ADC1 |
| 4 | GPIOC | 14 | USART1 |
(1 << 3) 就是把第 3 号开关(GPIOB)拨到开。
新手第一大坑就是漏了这行:程序能编译、能下载、看起来一切正常,就是灯不亮。
参考手册里 RCC_APB2ENR 的位定义原图:

6.2 CRL 与 CRH 的引脚定位
引脚复位后默认是输入模式,不能输出,要先配置。配置信息存在端口配置低寄存器 CRL、端口配置高寄存器 CRH 里(全称见 4.4),规则两条:
- 每个引脚占 4 个位
- 0
7 号引脚归 CRL 管,815 号归 CRH 管
以 PB0 为例:0 号引脚归 CRL 管,排在 CRL 最开头的 4 位(bit[3:0]),所以位移表达式是 4 * 0。
一般规则:引脚号 n 在 07 → 用 CRL,位移 15 → 用 CRH,位移 4*n;n 在 84*(n-8)。CRH 里从 0 重新数起,比如 PA8 的位移是 4*0 而不是 4*8,这点最容易搞错。
参考手册里 CRL 和 CRH 的位定义原图(两者结构一样,只是管的引脚不同):


6.3 配置位的取值
每个引脚的 4 个位分成两半,各管一件事:
bit3 bit2 | bit1 bit0
CNF[1:0] | MODE[1:0]
输出什么类型 | 输入还是输出、多快
CNF 就是 configuration(配置)的缩写,MODE 原意是「模式」。名字记不住没关系,记住下面几张表就行。
MODE(方向 + 速度):
| MODE | 含义 |
|---|---|
| 00 | 输入模式(复位默认) |
| 01 | 输出,最高 10MHz |
| 10 | 输出,最高 2MHz |
| 11 | 输出,最高 50MHz |
「速度」指电平翻转的最高切换频率:档位越高功耗越大。对功耗没要求选最大也行,点灯这种慢场景选哪档都无所谓。
注意别和 CPU 主频搞混:这个 MHz 是引脚电平翻转的上限,管的是脚。CPU 跑代码的频率由时钟决定——本文 SystemInit 留空、没配时钟,芯片复位后默认用自带的内部 8MHz 振荡器跑,第 7 节算延时用的就是它。
CNF(MODE 不是 00 时,选输出类型):
| CNF | 含义 |
|---|---|
| 00 | 推挽输出——高低电平都能主动输出,点灯用它 |
| 01 | 开漏输出——只能拉低,高电平要外部上拉电阻 |
| 10 / 11 | 复用输出——引脚交给串口、定时器等外设控制 |
开漏输出「1」时两个管子都关、引脚悬空(高阻态),高电平全靠外部上拉电阻。这个特性有两个典型用途:I2C 总线多个设备共用一根线,任一设备拉低整条线就变低(「线与」);上拉电源接 5V 还能输出 5V 电平,实现电平转换。
MODE 为 00(输入模式)时,CNF 改为选输入类型:
| CNF | 含义 | 说明 |
|---|---|---|
| 00 | 模拟输入 | 给 ADC 采原始电压用 |
| 01 | 浮空输入(复位默认) | 电平完全由外部决定,接按键常用 |
| 10 | 上拉/下拉输入 | 引脚悬空时靠内部电阻给默认电平 |
| 11 | 保留 |
常用组合速查:
| 配置位 | 工作模式 |
|---|---|
0001 | 推挽输出 10MHz(本文用的) |
0011 | 推挽输出 50MHz |
0101 | 开漏输出 10MHz |
1011 | 复用推挽输出 50MHz(串口、SPI 的发送脚) |
0100 | 浮空输入(接按键常用) |
1000 | 上拉/下拉输入 |
参考手册里的端口位配置表,CNF 与 MODE 的全部编码以此为准:

点灯选最普通的组合 0001:推挽输出 + 10MHz。速度对点灯无所谓,LED 亮灭根本用不着 10MHz。
6.4 为什么用 &= ~ 和 |=,不直接 =
寄存器一次读写就是整个 32 位,没法单独改一位。而 CRL 里住着 8 个引脚的配置,直接赋值会殃及别人:
GPIOB_CRL = 0x00000001; // 错!PB0配好了,PB1~PB7的配置全被冲成0
CRL 的复位值是 0x44444444,即 8 组 0100——上电后全部引脚默认浮空输入,直接赋值连这些默认值也一起冲掉。
所以要用「读-改-写」,只动自己那几位:
GPIOB_CRL &= ~(0x0F << 0); // 只把PB0那4位清0,其它位不动
GPIOB_CRL |= (0x01 << 0); // 只往PB0那4位写新值,其它位不动
两个口诀:
&= ~xxx:把 xxx 对应的位清 0,其余保持原样|= xxx:把 xxx 对应的位设为 1,其余保持原样
0x0F << 0 这种写法是在构造掩码——一个只有目标 4 位是 1、其余全 0 的数。取反 ~ 之后目标位变 0、其余变 1,于是与运算时「和 1 保持原值、和 0 被清掉」,或运算正好反过来。想改哪几位,就造一个只圈住它们的掩码。
6.5 控制输出电平
能控制电平的寄存器有三个:ODR、BSRR、BRR,其中 BSRR 和 BRR 最终影响的也是 ODR(见 6.6)。本文直接操作 ODR:
GPIOB_ODR &= ~(1 << 0); // PB0输出低电平,LED亮
ODR 的低 16 位一一对应 16 个引脚(高 16 位保留,复位值为 0),写 0 输出低、写 1 输出高。相关的三个写法:
GPIOB_ODR &= ~(1 << 0); // 写0:输出低电平,灯亮
GPIOB_ODR |= (1 << 0); // 写1:输出高电平,灯灭
GPIOB_ODR ^= (1 << 0); // 取反:亮变灭、灭变亮
注意一个坑:有些资料写成 GPIOB_ODR &= (0 << 0);——0<<0 就是 0,这行实际是 &= 0,把整个寄存器全清零。灯确实会亮,但同端口其它引脚也被一起拉低了。正确写法是 &= ~(1 << 0)。
6.6 BSRR 与 BRR
&= 和 |= 是读、改、写三步,中间可能被中断打断。BSRR/BRR 由硬件保证一步到位(原子操作):
| 位段 | 名称 | 写 1 的效果 | 写 0 的效果 |
|---|---|---|---|
| bit[15:0] | BS0~BS15 | 对应引脚置 1 | 无影响 |
| bit[31:16] | BR0~BR15 | 对应引脚置 0 | 无影响 |
GPIOB_BSRR = (1 << 0); // PB0置高电平
GPIOB_BSRR = (1 << (0 + 16)); // PB0置低电平(高16位是清零区)
GPIOB_BRR = (1 << 0); // PB0置低电平,BRR只能清零
这里直接用 = 就行,不用 |=——写 0 的位本来就不产生影响,没必要先读原值。
6.7 读取输入电平
引脚电压先经过施密特触发器,整形成干净的 0/1,再存进 IDR。读它就知道引脚现在是高还是低。IDR 只读、低 16 位有效:
if (GPIOB_IDR & (1 << 0))
{
// PB0当前是高电平
}
输出模式下触发器也开着,IDR 照样能读,可以反过来确认输出是否真的生效。模拟输入是唯一的例外:ADC 要的是原始电压而不是 0/1,所以信号在触发器之前就分岔,不经过触发器和MOS管。同理,DAC 输出模拟电压时也不经过双MOS管,直接从引脚输出。
7. LED 闪烁
在点灯代码外面套个死循环,加上延时和取反:
#include "stm32f10x.h"
void SystemInit(void)
{
}
/* 软件延时:volatile 告诉编译器别把这个循环优化没了 */
static void Delay(volatile unsigned int count)
{
while (count--);
}
int main(void)
{
RCC_APB2ENR |= (1 << 3); // 开GPIOB时钟
GPIOB_CRL &= ~(0x0F << (4 * 0)); // 清空PB0配置位
GPIOB_CRL |= (0x01 << (4 * 0)); // PB0推挽输出
while (1)
{
GPIOB_ODR ^= (1 << 0); // 电平取反:亮变灭、灭变亮
Delay(0x7FFFF); // 亮一阵灭一阵,数字越大闪得越慢
}
}
两个要点:
^=每执行一次电平翻转一次,灯就交替亮灭- 延时函数的参数
volatile不能省,否则编译器认为这个循环没有副作用,直接删掉,灯就不闪了
延时是可以粗算的,三个已知量:
0x7FFFF是十六进制 ≈ 十进制 52 万,即 CPU 空转 52 万圈while (count--)编译成机器码后,每圈约 5~10 个时钟周期(随优化等级浮动,按 8 算)- CPU 每秒跑 800 万个周期——
SystemInit留空没配时钟,芯片默认用自带的内部 8MHz 振荡器(见 5.1)
52 万圈 × 8 周期/圈 ≈ 420 万周期 ÷ 800 万周期/秒 ≈ 半秒
常用档位对照:
| 参数 | 空转圈数(约) | 延时(8MHz,约) |
|---|---|---|
0x3FFFF | 26 万 | 0.25 秒 |
0x7FFFF | 52 万 | 半秒 |
0xFFFFF | 105 万 | 1 秒 |
0x500000 | 524 万 | 5 秒 |
规律:数字翻倍,延时大致翻倍。还想更慢就在 while (1) 里连着多调几次 Delay。
8. 各种颜色的小灯
依次类推,各种颜色的小灯操作如下:
#include "stm32f10x.h"
void SystemInit(void)
{
}
/* 软件延时,同第 7 节 */
static void Delay(volatile unsigned int count)
{
while (count--);
}
int main(void)
{
RCC_APB2ENR |= (1 << 3); // GPIOB时钟,三个颜色脚共用
GPIOB_CRL &= ~(0x0F << (4 * 0)); // PB0 绿,推挽输出
GPIOB_CRL |= (0x01 << (4 * 0));
GPIOB_CRL &= ~(0x0F << (4 * 1)); // PB1 蓝,推挽输出
GPIOB_CRL |= (0x01 << (4 * 1));
GPIOB_CRL &= ~(0x0F << (4 * 5)); // PB5 红,推挽输出
GPIOB_CRL |= (0x01 << (4 * 5));
while (1)
{
/* 每换一色:先全灭(写1),再点亮目标色(写0) */
GPIOB_ODR |= (1 << 5) | (1 << 0) | (1 << 1);
GPIOB_ODR &= ~(1 << 5); // 红
Delay(0x7FFFF);
GPIOB_ODR |= (1 << 5) | (1 << 0) | (1 << 1);
GPIOB_ODR &= ~(1 << 0); // 绿
Delay(0x7FFFF);
GPIOB_ODR |= (1 << 5) | (1 << 0) | (1 << 1);
GPIOB_ODR &= ~(1 << 1); // 蓝
Delay(0x7FFFF);
GPIOB_ODR |= (1 << 5) | (1 << 0) | (1 << 1);
GPIOB_ODR &= ~((1 << 5) | (1 << 0)); // 红+绿=黄
Delay(0x7FFFF);
GPIOB_ODR |= (1 << 5) | (1 << 0) | (1 << 1);
GPIOB_ODR &= ~((1 << 5) | (1 << 1)); // 红+蓝=紫
Delay(0x7FFFF);
GPIOB_ODR |= (1 << 5) | (1 << 0) | (1 << 1);
GPIOB_ODR &= ~((1 << 0) | (1 << 1)); // 绿+蓝=青
Delay(0x7FFFF);
GPIOB_ODR |= (1 << 5) | (1 << 0) | (1 << 1);
GPIOB_ODR &= ~((1 << 5) | (1 << 0) | (1 << 1)); // 三色全亮=白
Delay(0x7FFFF);
}
}
规律:想让哪个颜色亮,就在 ODR 里把对应的位写 0,其余写 1。
但这样每个脚只有「亮 / 灭」两档,最多混出上表 7 种颜色,没有中间亮度。
想要深红、粉色这类过渡色,得用 PWM(脉冲宽度调制):引脚只能输出 0V 或 3.3V,那就在极短时间内高速开关,一个周期里亮的时间占比(占空比)就是平均亮度——
█ = 亮 ░ = 灭(每格等长时间,高速循环)
████████ 占空比 100% → 全亮
████░░░░ 占空比 50% → 半亮
█░░░░░░░ 占空比 10% → 微亮
红、绿、蓝各配一个占空比,就能混出任意深浅的颜色。PWM 波形一般由定时器产生,是另一个话题,本文不展开。
