第2章 GPIO基础与寄存器映射:模式配置、推挽与开漏、寄存器实战

各位同学,欢迎来到第二章。上一章我们搭好了开发环境,点亮了第一颗LED。但说实话,那个点灯程序你大概率没看懂——为什么往某个地址写个1,灯就亮了?

这一章,我们就彻底把GPIO的底裤扒干净。从寄存器映射到模式配置,从推挽输出到开漏输出,最后手写一段寄存器操作代码。学完这章,你才算真正入了嵌入式开发的门。

2.1 GPIO模块的寄存器映射

CH32V307的GPIO挂在APB2总线上。每个GPIO端口(PA、PB、PC...)都有一组固定的寄存器,控制着该端口的所有引脚。

我个人习惯把寄存器映射理解成“门牌号”。芯片设计者给每个寄存器分配了一个唯一的地址,CPU通过地址总线找到它,然后读写数据。就这么简单。

以GPIOA为例,它的基地址是0x40010800。从这个地址开始,偏移0x00是配置寄存器(CFGLR),偏移0x04是配置寄存器高(CFGHR),偏移0x0C是输出数据寄存器(OUTDR),偏移0x10是输入数据寄存器(INDR),等等。

核心记忆点: 寄存器地址 = 基地址 + 偏移。基地址查手册,偏移量也查手册。别死记,学会查手册才是真本事。

下面这张图展示了GPIOA的寄存器映射结构,我建议你把它存下来,写代码时经常要回头看的。

GPIOA 寄存器映射结构 基地址 0x40010800 +0x00 CFGLR (配置低) +0x04 CFGHR (配置高) +0x0C OUTDR (输出数据) +0x10 INDR (输入数据) +0x14 BSHR (位设置/清除) +0x1C LCKR (配置锁定) CFGLR/CFGHR:每4位配置一个引脚 模式选择:输入、输出、复用、模拟 OUTDR:写1输出高电平,写0输出低电平 INDR:读取引脚当前电平状态 BSHR:低16位置位,高16位清除 原子操作,不会被打断

2.2 GPIO的四种工作模式

CH32V307的每个GPIO引脚都可以独立配置为四种模式之一。你想想看,一个引脚既要能输出控制信号,又要能读取传感器状态,还要能复用成串口、SPI——没有模式切换根本做不到。

模式 CNF[1:0] 典型用途 内部结构
通用推挽输出 00 驱动LED、继电器、普通IO PMOS+NMOS互补输出
通用开漏输出 01 I²C总线、电平转换、线与逻辑 只有NMOS,高阻需上拉
复用功能推挽 10 SPI、USART、PWM等外设输出 输出由外设控制
复用功能开漏 11 I²C的SCL/SDA、某些特殊协议 外设控制+开漏结构

这里有个细节:配置寄存器CFGLR和CFGHR每个引脚占用4位,其中高2位是CNF(模式),低2位是MODE(速度)。MODE只在输出模式下有效,输入模式下MODE必须写00。

我的小技巧: 配置GPIO时,我习惯先清零目标引脚对应的4位,再写入新值。用“读-改-写”三步法,避免影响其他引脚。后面代码里你会看到。

2.3 推挽输出 vs 开漏输出

这两个概念,我当年学的时候也迷糊了好一阵。说白了,就是输出级的结构不同。

推挽输出:内部有PMOS和NMOS两个管子。输出1时PMOS导通、NMOS截止,引脚直接连VCC;输出0时PMOS截止、NMOS导通,引脚直接连GND。所以推挽输出能主动拉高和拉低,驱动能力强。

开漏输出:内部只有NMOS,没有PMOS。输出0时NMOS导通,引脚拉低;输出1时NMOS截止,引脚处于高阻态(浮空)。要想输出高电平,必须在外部接一个上拉电阻到VCC。

我在项目中遇到过一个问题:用推挽输出驱动I²C总线,结果通信死活不正常。后来发现,I²C协议要求“线与”特性——多个设备可以同时拉低总线,但推挽输出会强行拉高,导致冲突。换成开漏输出加4.7kΩ上拉电阻,问题立刻解决。

避坑指南: 千万不要用推挽输出直接驱动两个不同电压的设备!我曾经把3.3V的MCU和5V的器件用推挽直连,结果MCU引脚烧了。开漏输出配合上拉电阻,才能安全实现电平转换。

下面这个表格总结了推挽和开漏的核心区别,写代码前看一眼,能省很多调试时间。

特性 推挽输出 开漏输出
输出高电平 主动拉高到VCC 高阻态,需外部上拉
输出低电平 主动拉低到GND 主动拉低到GND
驱动能力 强(源电流+灌电流) 仅灌电流,源电流靠上拉
线与能力 不支持 支持
电平转换 需电平转换芯片 上拉到目标电压即可
功耗 静态无电流 上拉电阻有持续电流

2.4 寄存器操作实战:手写GPIO驱动

好了,理论讲完了,咱们直接上代码。下面这段程序,用寄存器操作的方式,把PA0配置为推挽输出,然后循环闪烁。

为什么不用库函数?因为你要理解底层。库函数封装得再好,出了问题你还是要看寄存器。我面试嵌入式工程师时,第一个问题就是“GPIO的配置寄存器地址是多少”。答不上来的,基本就凉了。

// 寄存器地址定义
#define GPIOA_BASE      0x40010800
#define GPIOA_CFGLR     (*(volatile uint32_t *)(GPIOA_BASE + 0x00))
#define GPIOA_OUTDR     (*(volatile uint32_t *)(GPIOA_BASE + 0x0C))
#define GPIOA_BSHR      (*(volatile uint32_t *)(GPIOA_BASE + 0x14))

// RCC寄存器:使能GPIOA时钟
#define RCC_APB2PCENR   (*(volatile uint32_t *)0x40021018)

void GPIO_Init(void)
{
    // 1. 使能GPIOA时钟(APB2总线)
    RCC_APB2PCENR |= (1 << 2);   // 第2位置1,使能GPIOA时钟

    // 2. 配置PA0为推挽输出,速度50MHz
    //    PA0对应CFGLR的[3:0]位
    //    先清零,再写入:CNF=00(推挽),MODE=11(50MHz)
    GPIOA_CFGLR &= ~(0xF << 0);  // 清零低4位
    GPIOA_CFGLR |= (0x3 << 0);   // MODE=11, CNF=00 → 0x3
}

void GPIO_SetPin(uint8_t pin, uint8_t level)
{
    if (level)
        GPIOA_BSHR = (1 << pin);      // 置位:输出高
    else
        GPIOA_BSHR = (1 << (pin + 16)); // 清除:输出低
}

// 主循环
int main(void)
{
    GPIO_Init();

    while (1)
    {
        GPIO_SetPin(0, 1);  // PA0输出高
        Delay(500000);
        GPIO_SetPin(0, 0);  // PA0输出低
        Delay(500000);
    }
}
关键点解析:
  • volatile关键字告诉编译器不要优化这个变量,每次都要从内存读取。没有它,优化后的代码可能永远读不到外设的最新状态。
  • BSHR寄存器是“宝贝”——低16位写1置位,高16位写1清除。同时写0无影响。这样一条指令就能完成原子操作,不会被中断打断。
  • 时钟使能是第一步。CH32V307的外设默认时钟是关闭的,你不开时钟,写寄存器等于白写。我见过太多新手卡在这一步。

2.5 开漏输出实战:模拟I²C起始信号

为了让你彻底理解开漏,咱们写一个模拟I²C起始信号的代码。SCL和SDA都配置为开漏输出,外部接4.7kΩ上拉到3.3V。

// 配置PB6(SCL)和PB7(SDA)为开漏输出
#define GPIOB_CFGLR     (*(volatile uint32_t *)(GPIOB_BASE + 0x00))
#define GPIOB_OUTDR     (*(volatile uint32_t *)(GPIOB_BASE + 0x0C))
#define GPIOB_BSHR      (*(volatile uint32_t *)(GPIOB_BASE + 0x14))

void I2C_GPIO_Init(void)
{
    RCC_APB2PCENR |= (1 << 3);   // 使能GPIOB时钟

    // PB6: CFGLR[27:24], PB7: CFGLR[31:28]
    // 开漏输出:CNF=01, MODE=11 → 0b0111 = 0x7
    GPIOB_CFGLR &= ~(0xFF << 24); // 清零PB6和PB7的配置位
    GPIOB_CFGLR |= (0x77 << 24);  // 同时配置两个引脚
}

void I2C_Start(void)
{
    // 起始信号:SCL高电平时,SDA从高变低
    GPIOB_BSHR = (1 << 6);        // SDA输出高(实际靠上拉)
    GPIOB_BSHR = (1 << 7);        // SCL输出高
    Delay(10);

    GPIOB_BSHR = (1 << (6 + 16)); // SDA拉低
    Delay(10);

    GPIOB_BSHR = (1 << (7 + 16)); // SCL拉低
    Delay(10);
}

注意看,开漏模式下,我们“输出高”其实只是让引脚变成高阻态,真正的高电平来自上拉电阻。这就是为什么开漏输出能实现电平转换——上拉电阻接3.3V就是3.3V电平,接5V就是5V电平。

上拉电阻怎么选? 我一般遵循这个原则:I²C总线用4.7kΩ,普通开漏信号用10kΩ,高速信号用1kΩ~2.2kΩ。阻值太小功耗大,阻值太大信号上升沿变缓。具体选多少,看你的总线负载和速度要求。

2.6 本章小结

这一章我们干了三件事:

  • 搞懂了寄存器映射——基地址+偏移量,查手册就能找到所有寄存器
  • 分清了推挽和开漏——推挽主动拉高拉低,开漏只拉低、高电平靠上拉
  • 手写了寄存器操作代码——从时钟使能到配置寄存器,再到BSHR原子操作