2. GPIO 基础:从寄存器到实战

各位同学,欢迎来到第二章。上一章我们把 CH32V203 的开发环境搭好了,也跑了个点灯程序。但说实话,那时候你可能只是复制粘贴,心里没底。

这一章,咱们就把 GPIO 彻底搞明白。我做了十几年嵌入式,GPIO 是打交道最多的外设,没有之一。你想想看,一个芯片再强大,最后不还是得靠那几个引脚跟外界通信?

好,咱们开始。

2.1 GPIO 工作原理:引脚背后的电路

GPIO,全称 General-Purpose Input/Output,通用输入输出口。说白了,就是芯片上那些可以编程控制的金属引脚。

每个 GPIO 引脚内部,其实是一套完整的电路。我画个简化的框图,你一看就懂。

GPIO 引脚内部结构框图 芯片内部 输出数据寄存器 (ODR) 输入数据寄存器 (IDR) 控制寄存器 (CFGLR/CFGHR) 输出驱动电路 (推挽/开漏) 施密特触发器 (输入整形) 上拉/下拉电阻 (PU/PD) I/O 引脚 关键点: • 输出:CPU写ODR → 驱动电路 → 引脚 • 输入:引脚电平 → 施密特 → CPU读IDR • 模式:由CFGLR/CFGHR配置 • 上拉/下拉:内部电阻,省外部元件 CH32V203 GPIO 特点: • 最多 80 个 GPIO,分布在 5 组 • 每组最多 16 个引脚 (PA0-PA15...) • 所有引脚可触发中断

从图里你能看到,GPIO 不是一根导线那么简单。它内部有寄存器、驱动电路、保护电路。输出时,CPU 往寄存器写数据,驱动电路再把信号送到引脚。输入时,引脚电平经过施密特触发器整形,然后 CPU 去读寄存器。

小提示:施密特触发器的作用是消除信号抖动。比如按键按下时会有机械抖动,施密特触发器能输出干净的电平跳变。我在做工业控制面板时,就靠这个省掉了外部去抖电路。

2.2 寄存器映射:操作硬件的钥匙

好,现在你知道 GPIO 内部长什么样了。但怎么控制它?答案是:操作寄存器。

寄存器,就是芯片内部一个个"小房间",每个房间有特定地址。CPU 通过读写这些地址,就能控制硬件。这就是所谓的"寄存器映射"——把硬件功能映射到内存地址空间。

CH32V203 的 GPIO 寄存器,起始地址是 0x4001 0800(GPIOA)。每个 GPIO 组有 7 个主要寄存器,我列个表:

寄存器 偏移地址 功能描述 宽度
CFGLR 0x00 配置低 8 个引脚的模式/速度 32 位
CFGHR 0x04 配置高 8 个引脚的模式/速度 32 位
INDR 0x08 输入数据寄存器(只读) 32 位
OUTDR 0x0C 输出数据寄存器 32 位
BSHR 0x10 位设置/清除寄存器 32 位
BCR 0x14 位清除寄存器 32 位
LCKR 0x18 配置锁定寄存器 32 位

举个例子,如果你想操作 GPIOA 的第 0 脚(PA0),那么:

  • 配置模式:操作 GPIOA_BASE + 0x00(CFGLR)的 bit[3:0]
  • 输出高电平:操作 GPIOA_BASE + 0x0C(OUTDR)的 bit 0
  • 读取电平:读 GPIOA_BASE + 0x08(INDR)的 bit 0
核心思想:嵌入式编程的本质,就是"读寄存器、写寄存器、等寄存器"。你掌握了这个,就掌握了硬件的命脉。

2.3 点亮 LED:第一个实战

理论讲完了,咱们动手。开发板上通常有个 LED 接在 PA0 上(具体看你的板子原理图)。点亮它,需要三步:

  1. 开启 GPIOA 时钟——CH32V203 的外设时钟默认是关闭的,省电嘛。
  2. 配置 PA0 为推挽输出——设置 CFGLR 寄存器。
  3. 设置 PA0 输出高电平——写 OUTDR 或 BSHR 寄存器。

看代码:

// 步骤1:开启 GPIOA 时钟
// RCC 基地址 0x4002 1000,APB2PCENR 偏移 0x18
// GPIOA 对应 bit 2
*(volatile uint32_t *)0x40021018 |= (1 << 2);

// 步骤2:配置 PA0 为推挽输出,50MHz
// GPIOA 基地址 0x4001 0800,CFGLR 偏移 0x00
// PA0 对应 bit[3:0],模式 = 0011 (推挽输出 50MHz)
*(volatile uint32_t *)0x40010800 &= ~(0x0F << 0);  // 先清零
*(volatile uint32_t *)0x40010800 |= (0x03 << 0);   // 设置模式

// 步骤3:PA0 输出高电平
// 使用 BSHR 寄存器(偏移 0x10),低 16 位是设置位
// 写 bit 0 为 1,PA0 输出高电平
*(volatile uint32_t *)0x40010810 |= (1 << 0);

这段代码直接操作物理地址,看起来很原始。但这就是底层原理。你理解了它,再用库函数就知其所以然了。

注意:千万不要直接写 OUTDR 来置位单个引脚!比如你想置位 PA0,如果直接写 OUTDR |= 0x01,这是一个"读-改-写"操作,在多任务环境下可能出问题。BSHR 寄存器是专门为原子操作设计的——写 1 置位,写 0 无影响。我曾经在一个产品中因为没注意这个,导致两个任务同时操作 GPIO 时出现毛刺,排查了两天才找到原因。

2.4 按键输入检测:从读到消抖

LED 能亮了,接下来让按键控制它。按键检测比输出复杂一点,因为涉及:

  • 输入模式配置
  • 电平读取
  • 软件消抖

假设按键接在 PA1,按下为低电平(外部上拉)。配置代码:

// 配置 PA1 为输入模式(浮空输入)
// CFGLR 中 PA1 对应 bit[7:4],模式 = 0100 (浮空输入)
*(volatile uint32_t *)0x40010800 &= ~(0x0F << 4);
*(volatile uint32_t *)0x40010800 |= (0x04 << 4);

// 读取 PA1 电平
uint8_t key_val = (*(volatile uint32_t *)0x40010808 >> 1) & 0x01;

但直接读不行,按键有抖动。按下和松开时,电平会跳变几十毫秒。我的做法是:

// 按键消抖函数
uint8_t Key_Scan(void)
{
    if((*(volatile uint32_t *)0x40010808 >> 1) & 0x01)  // 读 PA1
    {
        return 0;  // 高电平,按键未按下
    }
    
    // 延时消抖,大约 20ms
    for(volatile uint32_t i = 0; i < 200000; i++);
    
    // 再次确认
    if((*(volatile uint32_t *)0x40010808 >> 1) & 0x01)
    {
        return 0;  // 抖动,不是真的按下
    }
    
    return 1;  // 确实按下了
}
经验之谈:消抖延时 10-20ms 就够了,别太长。我见过有人延时 200ms,按键手感像"踩棉花"。另外,如果做低功耗产品,可以用定时器中断+状态机来做消抖,别用死等。

2.5 位带操作:让 GPIO 像 51 一样简单

说到位带操作,我得先感慨一句。很多从 51 单片机转过来的同学,最不习惯的就是 STM32/CH32 的 GPIO 操作——不能直接写 PA0 = 1。位带操作就是来解决这个问题的。

位带操作的原理:把 1 个 bit 映射到 1 个 32 位的字地址。你操作这个字地址,就等于操作那个 bit。CH32V203 支持两个位带区:

位带区 地址范围 别名区地址
SRAM 位带 0x2000 0000 ~ 0x200F FFFF 0x2200 0000 ~ 0x23FF FFFF
外设位带 0x4000 0000 ~ 0x400F FFFF 0x4200 0000 ~ 0x43FF FFFF

计算公式很简单:

别名区地址 = 位带区基地址 + (字节偏移 * 32) + (位号 * 4)

举个例子,GPIOA 的 OUTDR 寄存器地址是 0x4001 080C,我们要操作 bit 0(PA0):

// 计算别名地址
// 字节偏移 = 0x4001080C - 0x40000000 = 0x1080C
// 别名地址 = 0x42000000 + (0x1080C * 32) + (0 * 4)
//          = 0x42000000 + 0x210180
//          = 0x42210180

#define PA0_OUT  (*(volatile uint32_t *)0x42210180)

// 现在可以像 51 一样操作了
PA0_OUT = 1;  // PA0 输出高电平
PA0_OUT = 0;  // PA0 输出低电平

是不是很爽?但注意,位带操作不是所有芯片都支持。CH32V203 是支持的,我确认过。

我的习惯:在工程里用宏定义把所有用到的 GPIO 位带地址都定义好。比如:
#define LED_PIN    PA0_OUT
#define KEY_PIN    PA1_IN
这样代码可读性极高,而且修改引脚时只需要改一个宏。

2.6 综合实战:按键控制 LED

最后,把今天学的串起来。用位带操作实现:按键按下,LED 翻转。

// 位带地址定义
#define PA0_OUT  (*(volatile uint32_t *)0x42210180)
#define PA1_IN   (*(volatile uint32_t *)0x42210184)

// 按键消抖
uint8_t Key_Scan(void)
{
    if(PA1_IN) return 0;  // 高电平,未按下
    
    for(volatile uint32_t i = 0; i < 200000; i++);  // 延时
    
    if(PA1_IN) return 0;  // 抖动
    
    return 1;
}

int main(void)
{
    // 开启 GPIOA 时钟
    *(volatile uint32_t *)0x40021018 |= (1 << 2);
    
    // PA0 推挽输出
    *(volatile uint32_t *)0x40010800 &= ~(0x0F << 0);
    *(volatile uint32_t *)0x40010800 |= (0x03 << 0);
    
    // PA1 浮空输入
    *(volatile uint32_t *)0x40010800 &= ~(0x0F << 4);
    *(volatile uint32_t *)0x40010800 |= (0x04 << 4);
    
    while(1)
    {
        if(Key_Scan())
        {
            PA0_OUT = !PA0_OUT;  // 翻转 LED
            while(!PA1_IN);      // 等待按键松开
        }
    }
}

这段代码虽然简单,但包含了 GPIO 的全部核心操作:时钟使能、模式配置、输入输出、位带操作、消抖处理。你把它吃透了,后面学 SPI、I2C、UART 这些外设,思路是一样的——无非是配置更多寄存器,操作更复杂的数据。

最后提醒一句:做硬件编程,一定要多看数据手册。CH32V203 的参考手册里,GPIO 章节有 40 多页,每个寄存器的每个 bit 都写得清清楚楚。我每次做新项目,都会把相关章节打印出来,边看边写代码。别嫌麻烦,这是最靠谱的方法。