3. GPIO 基础与寄存器操作:模式配置、推挽与开漏、寄存器级编程实战
GPIO 是嵌入式开发里最基础、也最绕不开的外设。说白了,它就是芯片和外界打交道的那双脚——要么往外送信号,要么往里收信号。CH32V203 的 GPIO 模块继承自 RISC-V 内核的通用设计,但又有一些自己的脾气。今天我就带你把它摸透。
3.1 GPIO 模式配置:你得知道这四种状态
CH32V203 的每个 GPIO 引脚都可以独立配置成四种基本模式。我刚开始用的时候,总觉得模式多反而容易搞混,后来发现其实就两类:输出和输入,每种再分两种。
| 模式 | 方向 | 典型用途 | 内部结构特点 |
|---|---|---|---|
| 推挽输出 | 输出 | 驱动 LED、普通数字信号 | PMOS + NMOS 交替导通 |
| 开漏输出 | 输出 | I²C 总线、多设备共享线 | 只有 NMOS,高阻需外接上拉 |
| 浮空输入 | 输入 | 外部电平检测(无上下拉) | 高阻态,电平不确定 |
| 上拉/下拉输入 | 输入 | 按键检测、电平锁定 | 内部弱上拉/弱下拉电阻 |
嗯,这里要注意:浮空输入在引脚悬空时,电平是随机跳变的。我在一个温控项目里吃过这个亏——按键没按下,程序却以为按下了。后来换成上拉输入,问题立刻解决。
3.2 推挽输出 vs 开漏输出:到底差在哪?
这两个概念,很多初学者容易混淆。我换个说法你就明白了。
推挽输出,就像两个开关轮流工作:一个负责把电平拉到 VCC,一个负责拉到 GND。输出高电平时,PMOS 导通,NMOS 关断;输出低电平时反过来。所以推挽输出的驱动能力强,信号完整。
开漏输出,只保留了下拉的那个 NMOS 管。输出低电平时,NMOS 导通,引脚拉到 GND;输出高电平时,NMOS 关断,引脚变成高阻态。这时候如果没有外部上拉电阻,引脚电平就是悬空的。
核心区别一句话:推挽能主动推高和拉低,开漏只能主动拉低,高电平靠外部电阻拉上去。
我个人习惯在 I²C 总线上用开漏输出,因为多个设备可以共用一根线,谁拉低谁说话,不会打架。而驱动 LED 或者继电器,我肯定用推挽,省事又可靠。
3.3 寄存器级编程实战:直接操作才是硬道理
CH32V203 的 GPIO 寄存器不多,但每个都很关键。我列几个最常用的:
- GPIOx_CFGLR / GPIOx_CFGHR:配置模式(每 4 位控制一个引脚)
- GPIOx_INDR:输入数据寄存器(只读)
- GPIOx_OUTDR:输出数据寄存器(可读可写)
- GPIOx_BSHR:位设置/清除寄存器(写 1 有效)
- GPIOx_BCR:位清除寄存器(写 1 清零)
你想想看,用库函数固然方便,但一旦遇到时序敏感或者资源受限的场景,寄存器操作才是王道。我曾经在一个电机控制项目里,因为库函数多了一层封装,导致 PWM 信号延迟了几个微秒,电机抖动得厉害。换成直接写寄存器,问题立刻消失。
3.3.1 配置推挽输出:点亮一个 LED
假设我们要把 PA0 配置成推挽输出,驱动一个 LED。代码很简单:
// 开启 GPIOA 时钟(RCC 寄存器操作)
RCC->APB2PCENR |= RCC_APB2Periph_GPIOA;
// 配置 PA0 为推挽输出,50MHz 速度
// CFGLR 每 4 位控制一个引脚,PA0 对应 bit[3:0]
// 模式:0011(输出,50MHz),配置:00(推挽)
GPIOA->CFGLR &= ~(0xF << 0); // 先清零
GPIOA->CFGLR |= (0x3 << 0); // 设置模式
// 输出高电平,点亮 LED
GPIOA->BSHR = (1 << 0); // 写 1 置位
// 或者直接写 OUTDR
// GPIOA->OUTDR |= (1 << 0);
小技巧:用 BSHR 寄存器操作单个引脚,比直接读写 OUTDR 更安全。因为 BSHR 是原子操作,不会影响其他引脚的状态。我在多线程中断里一直用这个方式。
3.3.2 配置开漏输出:模拟 I²C 的 SDA 线
开漏输出的配置和推挽几乎一样,只是 CFGLR 的配置位不同:
// 配置 PB1 为开漏输出
// 模式:0011(输出,50MHz),配置:01(开漏)
GPIOB->CFGLR &= ~(0xF << 4); // PB1 对应 bit[7:4]
GPIOB->CFGLR |= (0x7 << 4); // 0b0111 = 开漏输出
// 输出低电平(主动拉低)
GPIOB->BCR = (1 << 1); // 写 1 清零
// 输出高电平(释放总线,靠外部上拉)
GPIOB->BSHR = (1 << 1); // 写 1 置位,实际引脚变高阻
注意看,开漏输出时,BSHR 写 1 并不会真的把引脚拉到 VCC,而是让引脚变成高阻态。外部上拉电阻会把电平拉高。这个细节我当年调试 I²C 时卡了半天,后来用示波器一看才明白。
3.3.3 配置输入模式:读取按键状态
按键检测一般用上拉输入,省掉外部电阻:
// 配置 PC13 为上拉输入
// 模式:1000(输入,上拉/下拉),配置:10(上拉)
GPIOC->CFGLR &= ~(0xF << 20); // PC13 对应 bit[23:20]
GPIOC->CFGLR |= (0x8 << 20); // 输入模式
// 使能内部上拉(通过 OUTDR 控制上拉/下拉)
GPIOC->OUTDR |= (1 << 13); // 写 1 使能上拉
// 读取按键状态
uint8_t key_state = (GPIOC->INDR >> 13) & 0x01;
避坑指南:我曾经在读取按键时直接用了 INDR 的值做判断,结果因为没做消抖,一次按下触发了十几次中断。后来加了 10ms 延时消抖,才稳定下来。寄存器操作再快,也扛不住机械抖动。
3.4 知识结构图:GPIO 操作全景
下面这张图把 GPIO 的配置、操作和典型场景串起来了,你可以对照着看。
3.5 实战要点总结
- 配置前先开时钟:RCC 寄存器不使能,GPIO 寄存器写不进去。这个顺序我见过太多人搞反。
- CFGLR/CFGHR 每 4 位控制一个引脚:低 2 位是模式,高 2 位是配置。别搞混了位偏移。
- BSHR 写 1 置位,BCR 写 1 清零:这两个寄存器写 0 无效,所以不用担心误操作其他引脚。
- 开漏输出必须外接上拉电阻:内部上拉太弱,驱动能力不够。I²C 标准里上拉电阻一般是 4.7kΩ。
- 输入模式的上拉/下拉由 OUTDR 控制:OUTDR 写 1 使能上拉,写 0 使能下拉。这个设计有点反直觉,但用熟了就好。
我的习惯:每次写 GPIO 配置前,先画一个引脚功能表,把每个引脚的模式、初始电平、用途列清楚。这样写代码时不会乱,调试时也容易定位问题。