18
0
0

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

2026-10-27
2026-10-28
【嵌入式硬件】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运行。

图片_20261027225800_167_98.jpg


2. GPIO 简介

GPIO 是 General Purpose Input/Output(通用输入输出)的缩写,指 STM32 上我们能用代码控制电平高低的引脚。引脚接上外部设备,就能实现控制(输出高低电平)、通讯(发送接收信号)、采集(读取外部电平)。

2.1 内部结构

下图最右边是芯片引出的引脚,往左全是芯片内部的东西:

image-JDOv.png

编号模块干什么的
①保护二极管(上下一对)引脚电压超出 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 + 组字母 + 编号:

写法含义
PB0GPIOB 组的第 0 号引脚
PC2GPIOC 组的第 2 号引脚
PA8GPIOA 组的第 8 号引脚

下文说「端口」指一整组(如 GPIOB),「引脚」指组里的一根(如 PB0)。

有个关键点:寄存器是按端口给的,不是按引脚给的。一个 32 位寄存器里装着整组 16 个引脚的信息,改某一个引脚,只能在寄存器里动属于它的那几位——这就是后文代码里全是位操作的原因。

2.4 基本功能

功能干什么典型应用
输出代码控制引脚输出高(3.3V)或低(0V)控制LED亮灭;经三极管/继电器控制大功率电路
输入代码读取引脚当前是高还是低判断按键有没有被按下

本文只用输出功能:让引脚输出低电平,点亮接在上面的 LED。输入的读法在 6.7 节顺带说明。


3. 硬件连接

野火开发板的 LED 是「共阳」接法:LED 正极接 3.3V 电源,负极接 STM32 引脚,中间串一个限流电阻。

image-LDBD.png

灯亮不亮,只看有没有电流流过 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 个寄存器。名字看着像乱码,其实全是英文缩写,对照全称就知道各自管什么:

偏移名字全称(缩写从这来)管什么
0x00CRLControl Register Low,端口配置低寄存器配置 0~7 号引脚的模式和速度
0x04CRHControl Register High,端口配置高寄存器配置 8~15 号引脚的模式和速度
0x08IDRInput Data Register,输入数据寄存器读引脚电平(只读)
0x0CODROutput Data Register,输出数据寄存器控制引脚输出什么电平
0x10BSRRBit Set/Reset Register,端口位设置/清除寄存器ODR 的原子操作版(置1/清0一步到位)
0x14BRRBit Reset Register,端口位清除寄存器只清0,BSRR 的简化版
0x18LCKRLock 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,而是先执行启动文件里的汇编,它干两件事:

  1. 调用 SystemInit 配置系统时钟
  2. 跳进 C 库的 __main,最后进入我们写的 main

这就是 main.c 里放一个空 SystemInit 占位的原因。留空不配时钟时,芯片自动用内部自带的 8MHz 振荡器,点灯足够。


6. 寄存器配置说明

把 main 里的 4 行代码逐行讲清楚。

6.1 开启端口时钟——不给电,写了也白写

RCC_APB2ENR |= (1 << 3);   // 开启GPIOB时钟

STM32 为了省电,每个外设默认「断电」(时钟关闭)。断电的外设,往它寄存器写什么都没反应。

RCC_APB2ENR 就是一排电源开关,每位管一个外设:

位外设位外设
0AFIO5GPIOD
2GPIOA6GPIOE
3GPIOB9ADC1
4GPIOC14USART1

(1 << 3) 就是把第 3 号开关(GPIOB)拨到开。

新手第一大坑就是漏了这行:程序能编译、能下载、看起来一切正常,就是灯不亮。

参考手册里 RCC_APB2ENR 的位定义原图:

image-SxYw.png

6.2 CRL 与 CRH 的引脚定位

引脚复位后默认是输入模式,不能输出,要先配置。配置信息存在端口配置低寄存器 CRL、端口配置高寄存器 CRH 里(全称见 4.4),规则两条:

  • 每个引脚占 4 个位
  • 07 号引脚归 CRL 管,815 号归 CRH 管

以 PB0 为例:0 号引脚归 CRL 管,排在 CRL 最开头的 4 位(bit[3:0]),所以位移表达式是 4 * 0。

一般规则:引脚号 n 在 07 → 用 CRL,位移 4*n;n 在 815 → 用 CRH,位移 4*(n-8)。CRH 里从 0 重新数起,比如 PA8 的位移是 4*0 而不是 4*8,这点最容易搞错。

参考手册里 CRL 和 CRH 的位定义原图(两者结构一样,只是管的引脚不同):

image-pcCg.png

image-qVSy.png

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 的全部编码以此为准:

image-jRLA.png

点灯选最普通的组合 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,约)
0x3FFFF26 万0.25 秒
0x7FFFF52 万半秒
0xFFFFF105 万1 秒
0x500000524 万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 波形一般由定时器产生,是另一个话题,本文不展开。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!

评论