第三章 GPIO控制与LED闪烁:从寄存器到闪烁的灯

各位同学,欢迎来到嵌入式开发的第一个实战关卡。今天我们要聊的,是嵌入式世界里最经典、也最基础的操作——点亮一颗LED,并让它闪烁起来。

你可能会想:“不就是让灯亮灭吗?有什么难的?” 嗯,表面上看确实简单。但背后涉及的GPIO寄存器操作、内存映射I/O、volatile读写,这些概念如果没搞透,后面做复杂项目时很容易踩坑。我自己刚入行时,就因为漏了一个volatile关键字,调试了整整两天……

3.1 GPIO是什么?

GPIO,全称General-Purpose Input/Output,通用输入输出口。说白了,就是芯片上那些可以编程控制的引脚。你可以让它们输出高电平(通常3.3V或5V),也可以输出低电平(0V),或者读取外部信号是高还是低。

在嵌入式系统中,GPIO是最基本的“手脚”。LED闪烁、按键检测、蜂鸣器控制,甚至模拟一些简单的通信协议,都离不开它。

每个GPIO引脚背后,都对应着一组寄存器。这些寄存器位于芯片的特定内存地址上。你往这些地址写数据,引脚就输出对应的电平;你从这些地址读数据,就能知道引脚当前的状态。

3.2 内存映射I/O:把寄存器当内存用

这里有个关键概念:内存映射I/O(Memory-Mapped I/O)。

在大多数嵌入式芯片(比如STM32、ESP32、RISC-V系列)中,外设寄存器被映射到CPU的地址空间里。也就是说,CPU访问这些寄存器的方式,和访问普通内存完全一样——用指针读写某个地址。

举个例子,假设某个GPIO输出寄存器的地址是0x40020C14。那么在Rust里,你可以这样写:

let gpio_out_reg = 0x40020C14 as *mut u32;
unsafe {
    *gpio_out_reg = 0x01;  // 让引脚0输出高电平
}

你看,这就是内存映射I/O的本质:把物理寄存器地址,映射成指针,然后直接读写。

⚠️ 注意: 这里用了unsafe,因为直接操作裸指针是Rust的安全红线。但在嵌入式底层,这是不可避免的。我们要做的,是把unsafe封装在安全的抽象层里。

3.3 volatile:别让编译器“优化”掉你的操作

好,现在问题来了。你写了一个循环,想让LED闪烁:

loop {
    *gpio_out_reg = 0x01;  // 亮
    delay();
    *gpio_out_reg = 0x00;  // 灭
    delay();
}

看起来没问题对吧?但编译器不这么想。它发现你反复往同一个地址写0x01和0x00,而且中间没有读操作。于是它“聪明”地认为:既然写0x01之后马上又写0x00,那第一次写就是多余的,直接优化掉!

结果就是:LED根本不亮,或者只亮一次就灭了。

这就是volatile关键字存在的意义。它告诉编译器:“这个变量的值可能会被外部因素改变,或者每次写入都有副作用,不要对我做任何优化!”

在Rust中,我们使用core::ptr::write_volatileread_volatile

use core::ptr;

let addr = 0x40020C14 as *mut u32;
unsafe {
    ptr::write_volatile(addr, 0x01);  // 亮
    delay();
    ptr::write_volatile(addr, 0x00);  // 灭
    delay();
}
💡 我的经验: 我曾经在一个项目中,因为忘记用volatile,导致SPI通信时数据总是错位。排查了整整一天,最后发现是编译器把某些寄存器写操作优化掉了。从那以后,我只要操作外设寄存器,第一反应就是加上volatile。

3.4 构建LED闪烁程序:从零开始

好了,理论讲完,我们动手写一个完整的LED闪烁程序。假设我们用的是STM32F103系列芯片,LED接在PA5引脚上。

首先,我们需要知道PA5对应的寄存器地址。查阅芯片手册,得到:

寄存器 地址 作用
GPIOA_CRL 0x40010800 配置引脚模式(输入/输出/复用)
GPIOA_BSRR 0x40010810 设置/复位引脚(写1置位,写1复位)
GPIOA_BRR 0x40010814 复位引脚(写1复位)

接下来,我们分三步走:

  1. 使能GPIOA时钟:在STM32中,外设需要先开启时钟才能工作。RCC_APB2ENR寄存器的第2位控制GPIOA时钟。
  2. 配置引脚模式:将PA5设置为推挽输出,速度50MHz。
  3. 循环闪烁:交替设置和复位PA5。

完整的Rust代码:

#![no_std]
#![no_main]

use core::ptr;
use panic_halt as _;

const RCC_APB2ENR: *mut u32 = 0x40021018 as *mut u32;
const GPIOA_CRL: *mut u32 = 0x40010800 as *mut u32;
const GPIOA_BSRR: *mut u32 = 0x40010810 as *mut u32;

fn delay() {
    // 简单的延时循环,实际项目中应使用定时器
    for _ in 0..500_000 {
        unsafe { ptr::read_volatile(&0xE000E010 as *const u32); }
    }
}

#[no_mangle]
pub extern "C" fn main() -> ! {
    // 1. 使能GPIOA时钟
    unsafe {
        ptr::write_volatile(RCC_APB2ENR, ptr::read_volatile(RCC_APB2ENR) | (1 << 2));
    }

    // 2. 配置PA5为推挽输出,模式:输出模式,速度50MHz
    // CRL寄存器每4位控制一个引脚,PA5对应位[23:20]
    unsafe {
        let mut crl = ptr::read_volatile(GPIOA_CRL);
        crl &= !(0xF << 20);  // 清除PA5的配置位
        crl |= (0x3 << 20);   // 设置为推挽输出,50MHz
        ptr::write_volatile(GPIOA_CRL, crl);
    }

    // 3. 循环闪烁
    loop {
        // 置位PA5(亮)
        unsafe {
            ptr::write_volatile(GPIOA_BSRR, 1 << 5);
        }
        delay();

        // 复位PA5(灭)
        unsafe {
            ptr::write_volatile(GPIOA_BSRR, 1 << (5 + 16));
        }
        delay();
    }
}
🔧 小技巧: 使用BSRR寄存器来置位和复位引脚,比直接操作ODR寄存器更安全。因为BSRR是“写1有效”,不会影响其他引脚的状态。我曾经见过有人用ODR寄存器做位操作,结果不小心把其他引脚的电平也改了……

3.5 知识体系总览

下面这张图,帮你理清本章的核心逻辑:

GPIO控制与LED闪烁知识体系 GPIO控制 寄存器操作 • 配置寄存器(CRL/CRH) • 输出寄存器(BSRR/ODR) • 输入寄存器(IDR) 内存映射I/O • 寄存器映射到内存地址 • 使用指针直接读写 • 需要unsafe代码块 volatile读写 • 防止编译器优化 • 使用write_volatile • 使用read_volatile 最终目标:LED闪烁程序

3.6 避坑指南

最后,分享几个我踩过的坑:

  • 忘记使能时钟:这是新手最容易犯的错误。你配置了寄存器,但时钟没开,外设根本不工作。我刚开始学STM32时,对着数据手册查了半天,最后发现是RCC寄存器没设置。
  • 位操作失误:修改寄存器时,一定要先读-改-写,而不是直接赋值。否则会覆盖其他引脚配置。我曾经因为直接写CRL寄存器,把其他引脚的配置全清掉了,导致整个板子上的LED乱闪。
  • 延时不准:用循环做延时,在不同优化级别下时间差异很大。正式项目一定要用硬件定时器。
  • 忘记volatile:这个前面说过了,再强调一遍——只要操作外设寄存器,就用volatile。

好了,这一章的内容就到这里。你学会了GPIO寄存器操作、内存映射I/O、volatile读写,还亲手写了一个LED闪烁程序。这些基础打牢了,后面做更复杂的项目就会顺手很多。


专注资料整理