GD32 GPIO — 流水灯

GD32 GD32F30x_Firmware_Library V3.0.3 GPIO 流水灯 示例教学

GPIO — 流水灯

固件库: GD32F30x_Firmware_Library V3.0.3 芯片: GD32F307 (GD32307C-EVAL-V1.1 开发板) 源文件: GD32F30x_Firmware_Library_V3.0.3/Examples/GPIO/Running_led/main.c


功能简介

这是嵌入式世界的"Hello World"。

程序让开发板上的 4 颗 LED(LED2、LED3、LED4、LED5)依次点亮、依次熄灭,形成一个循环往复的"跑马灯"效果:LED2 亮 → 1 秒后 LED3 亮同时 LED2 灭 → 1 秒后 LED4 亮同时 LED3 灭 → …… 一直循环。

别看它简单,这个例子把每一个 GD32 项目都必须做的三件事都演示完了:

  1. 配置系统节拍定时器(SysTick),这样才有能力"延时 1 秒";
  2. 配置 GPIO 为推挽输出,这样引脚才有能力驱动 LED;
  3. 在主循环里改变引脚电平,控制外部器件。

只要你彻底看懂这一篇,后面所有外设的学习都会顺很多,因为套路完全一样。


硬件准备

器材 说明
GD32307C-EVAL-V1.1 开发板 或任意 GD32F30x 系列最小系统板
GD-Link / J-Link / ST-Link 用来下载程序
USB 线 供电

开发板上 LED 的实际接线(这份信息来自官方 readme,非常重要):

LED 名称 引脚
LED2 PC0
LED3 PC2
LED4 PE0
LED5 PE1

不需要任何额外接线 —— LED 已经焊在板子上了,插上 USB 下载就能看效果。

如果你用的是自己画的板子或者别的最小系统板,LED 大概率不在这些脚上。别慌,往下看"常见问题",里面有改成任意引脚的完整写法。


完整代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include "gd32f30x.h"          /* GD32F30x 系列芯片的总头文件,包含所有外设寄存器定义 */
#include "gd32f307c_eval.h"    /* 开发板板级支持包(BSP):里面把 LED2~LED5 对应的引脚封装好了 */
#include "systick.h"           /* SysTick 延时函数的声明:systick_config() 和 delay_1ms() */
#include <stdio.h>

/*!
    \brief      主函数
    \param[in]  无
    \param[out] 无
    \retval     无
*/
int main(void)
{
    /* 第一步:配置 SysTick 系统节拍定时器
       作用是让 delay_1ms() 这个毫秒级延时函数能正常工作。
       没有这句,后面的 delay_1ms(1000) 会失效或时间完全不准。 */
    systick_config();

    /* 第二步:逐个初始化 4 颗 LED 对应的 GPIO 引脚
       gd_eval_led_init() 是 BSP 提供的封装函数,
       它内部帮我们做了两件事:开 GPIO 时钟 + 配成推挽输出。 */
    gd_eval_led_init(LED2);    /* PC0 */
    gd_eval_led_init(LED3);    /* PC2 */
    gd_eval_led_init(LED4);    /* PE0 */
    gd_eval_led_init(LED5);    /* PE1 */

    /* 第三步:进入主循环,永不退出 */
    while(1){
        /* 点亮 LED2,同时熄灭 LED5(上一轮最后亮的那个) */
        gd_eval_led_on(LED2);
        gd_eval_led_off(LED5);
        delay_1ms(1000);        /* 延时 1000 毫秒 = 1 秒 */

        /* 点亮 LED3,熄灭 LED2 —— 灯"往前走了一格" */
        gd_eval_led_on(LED3);
        gd_eval_led_off(LED2);
        delay_1ms(1000);

        /* 点亮 LED4,熄灭 LED3 */
        gd_eval_led_on(LED4);
        gd_eval_led_off(LED3);
        delay_1ms(1000);

        /* 点亮 LED5,熄灭 LED4 —— 走到最后一格,
           下一轮循环开头会点亮 LED2 并熄灭 LED5,首尾相接形成流水效果 */
        gd_eval_led_on(LED5);
        gd_eval_led_off(LED4);
        delay_1ms(1000);
    }
}

代码总共只有 20 多行有效语句,但每一行都有讲究。下面拆开细讲。


代码分段讲解

第 1 段:三个头文件分别管什么

1
2
3
#include "gd32f30x.h"
#include "gd32f307c_eval.h"
#include "systick.h"

新手常问:“为什么要包含这么多头文件,能不能只留一个?”

头文件 提供了什么 能不能删
gd32f30x.h 芯片所有寄存器地址、所有外设库函数声明(gpio_initrcu_periph_clock_enable…) 绝对不能删,这是根
gd32f307c_eval.h 板级封装:LED2 这个枚举值、gd_eval_led_init/on/off 函数 可以不用,但你得自己写 GPIO 代码
systick.h systick_config()delay_1ms() 不用延时的话可以删

关键理解gd32f307c_eval.c/h 不是芯片固件库的一部分,而是官方为这块特定开发板写的适配层。它把"LED2 在 PC0 上"这种板子专有的信息封装起来。所以如果你换了板子,只要改这个文件里的引脚定义,main.c 一行都不用动 —— 这就是分层设计的好处。

第 2 段:systick_config() 到底做了什么

1
systick_config();

SysTick 是 Cortex-M 内核自带的一个 24 位递减计数器。systick_config() 的典型实现是这样的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
void systick_config(void)
{
    /* SystemCoreClock 是系统主频(GD32F307 通常是 120MHz)
       除以 1000 表示"每 1 毫秒中断一次" */
    if(SysTick_Config(SystemCoreClock / 1000U)){
        /* 配置失败就死等,方便调试时发现问题 */
        while(1){
        }
    }
    /* 设置 SysTick 中断优先级为最低(0x0F),
       避免它抢占其他重要中断 */
    NVIC_SetPriority(SysTick_IRQn, 0x00U);
}

配好之后,SysTick 每毫秒触发一次中断,中断服务函数里给一个全局计数变量减 1,delay_1ms(n) 就是"把计数变量设成 n,然后死等它变成 0"。

为什么不用 for(i=0;i<1000000;i++); 这种空循环延时? 因为空循环的实际耗时取决于编译器优化等级、主频、Flash 等待周期,换个编译选项延时就变了,完全不可控。用 SysTick 是硬件计时,准确、可移植。这是专业写法和业余写法的分水岭。

第 3 段:gd_eval_led_init() 里面藏着什么

1
gd_eval_led_init(LED2);

这一句看起来简单,但它是本例最值得深挖的地方。这个函数在 gd32f307c_eval.c 里的实现,展开后逻辑等价于:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
void gd_eval_led_init(led_typedef_enum lednum)
{
    /* 步骤 A:打开这个 LED 所在 GPIO 组的时钟
       LED2 在 PC0,所以要开 RCU_GPIOC */
    rcu_periph_clock_enable(GPIO_CLK[lednum]);

    /* 步骤 B:把引脚配成推挽输出,速度 50MHz */
    gpio_init(GPIO_PORT[lednum], GPIO_MODE_OUT_PP,
              GPIO_OSPEED_50MHZ, GPIO_PIN[lednum]);

    /* 步骤 C:初始化后先输出低电平,保证一开机 LED 是熄灭状态 */
    GPIO_BC(GPIO_PORT[lednum]) = GPIO_PIN[lednum];
}

如果不用 BSP,自己手写点亮 PC0 上的 LED,完整代码是这样:

1
2
3
4
5
6
7
8
/* 1. 开 GPIOC 时钟(必做,否则后面全白干) */
rcu_periph_clock_enable(RCU_GPIOC);

/* 2. PC0 配成推挽输出 */
gpio_init(GPIOC, GPIO_MODE_OUT_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_0);

/* 3. 输出高电平 */
gpio_bit_set(GPIOC, GPIO_PIN_0);

这三行就是"GPIO 输出三部曲",请务必背下来。GD32 上任何一个外设的配置,都逃不出"开时钟 → 配参数 → 用起来"这个模式。

第 4 段:为什么要"开一个、关一个"

1
2
3
gd_eval_led_on(LED2);
gd_eval_led_off(LED5);
delay_1ms(1000);

注意作者的写法:点亮当前灯的同时,熄灭上一颗灯。

  • 如果只写 on 不写 off,效果就变成"灯一颗一颗累加点亮",最后 4 颗全亮 —— 那是"跑马灯累积效果",不是流水灯。
  • 第一轮循环刚进来时 LED5 本来就是灭的,再 off 一次也无害,所以代码不需要特殊处理首轮。这种"多余但无害"的写法让循环体保持了完美的对称,可读性更好。

gd_eval_led_on() 内部长这样:

1
2
3
4
5
void gd_eval_led_on(led_typedef_enum lednum)
{
    /* GPIO_BOP 是位设置/清除寄存器,写 1 到对应位 = 该引脚输出高 */
    GPIO_BOP(GPIO_PORT[lednum]) = GPIO_PIN[lednum];
}

这里用的是 GD32 的 BOP / BC 位操作寄存器,它的好处是:一次写入只影响你指定的那一位,不需要"读出整个端口 → 改一位 → 写回"。这个操作是原子的,中断打断也不会出错。所以在实际项目中,推荐用 gpio_bit_set/reset(内部就是 BOP/BC),而不是直接操作 GPIO_OCTL 整个输出寄存器。

第 5 段:while(1) 不能省

1
2
3
while(1){
    ...
}

嵌入式的 main() 函数永远不能返回。如果 while(1) 写漏了,main 执行完会跳到启动文件里的一个死循环(或者更糟,跳到不可预知的地址导致 HardFault)。所有裸机程序的骨架都是:

1
2
3
4
5
6
7
int main(void)
{
    /* 初始化区:只执行一次 */
    while(1){
        /* 业务逻辑区:无限重复 */
    }
}

实验现象

  1. 用 Keil / GCC 编译工程,通过 GD-Link 下载到板子。
  2. 上电或按下复位键。
  3. 观察板上 4 颗 LED:
1
2
3
4
5
时间轴:   0s      1s      2s      3s      4s      5s
LED2(PC0)  亮 ───→ 灭      灭      灭      亮 ───→ 灭
LED3(PC2)  灭      亮 ───→ 灭      灭      灭      亮
LED4(PE0)  灭      灭      亮 ───→ 灭      灭      灭
LED5(PE1)  灭      灭      灭      亮 ───→ 灭      灭

肉眼看到的效果就是一个光点在 4 颗 LED 之间来回"跑",每秒走一格,循环不停

如果想让它跑得更快,把 delay_1ms(1000) 改成 delay_1ms(200),效果会明显活泼很多,也更接近常见的流水灯灯带。


常见问题

Q1:下载成功了,但一颗灯都不亮

按这个顺序排查,90% 的问题在前两条:

  1. 引脚对不上。你的板子 LED 可能不在 PC0/PC2/PE0/PE1。用万用表量一下,或者查板子原理图,然后改 gd32f307c_eval.h 里的引脚宏。
  2. LED 是低电平点亮的。很多板子的接法是 3.3V → 电阻 → LED → MCU 引脚,这时候引脚输出低电平才亮,输出高电平反而灭。解决办法是把 gd_eval_led_onoff 的行为互换,或者直接自己写:
1
gpio_bit_reset(GPIOC, GPIO_PIN_0);   /* 低电平点亮 */
  1. 忘了开 GPIO 时钟。如果你没用 BSP 而是自己写的,检查有没有 rcu_periph_clock_enable(RCU_GPIOx)
  2. 芯片没跑起来。用调试器单步,看是否卡在 HardFault_HandlerSystemInit 里。

Q2:灯亮了,但闪得飞快 / 完全不闪

这是 delay_1ms() 不准的典型症状,根因通常是 SysTick 配置和实际主频不匹配

检查 system_gd32f30x.c 里的时钟配置宏,确认你选的主频(比如 __SYSTEM_CLOCK_120M_PLL_HXTAL)和板子上实际的晶振频率(GD32307C-EVAL 是 8MHz 或 25MHz)匹配。如果外部晶振起振失败,芯片会自动降频跑内部 8MHz RC,延时就会差十几倍。

main() 开头加一句打印验证:

1
printf("SystemCoreClock = %d Hz\r\n", SystemCoreClock);

Q3:PE0、PE1 在小封装芯片上找不到

GD32F303CBT6 这类 48 脚 LQFP 封装没有 GPIOE。这时候把 LED4/LED5 改到别的空闲引脚(比如 PB0、PB1),或者干脆只做 2 颗灯的流水灯。

Q4:能不能用 for 循环把重复代码简化掉?

当然可以,这也是更工程化的写法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
int main(void)
{
    /* 用数组把 4 颗灯管理起来 */
    led_typedef_enum leds[4] = {LED2, LED3, LED4, LED5};
    uint8_t i;

    systick_config();
    for(i = 0; i < 4; i++){
        gd_eval_led_init(leds[i]);
        gd_eval_led_off(leds[i]);
    }

    while(1){
        for(i = 0; i < 4; i++){
            gd_eval_led_on(leds[i]);                    /* 点亮当前灯 */
            gd_eval_led_off(leds[(i + 3) % 4]);         /* 熄灭上一颗(i-1,防负数用 +3) */
            delay_1ms(1000);
        }
    }
}

代码从 20 行缩到 10 行,而且想加到 8 颗灯只需改数组和常量。官方例程为了让新手一眼看懂流程才写成展开形式,实际项目里推荐用数组 + 循环。

Q5:delay_1ms() 期间 CPU 在干什么?

死等,什么正事都没干(这叫"阻塞式延时")。跑流水灯没问题,但如果你还要同时读按键、发串口,CPU 全被延时占住就麻烦了。

进阶做法是用 SysTick 中断 + 状态机做"非阻塞延时":

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
extern volatile uint32_t g_tick;   /* 在 SysTick_Handler 里每毫秒 ++ */

int main(void)
{
    uint32_t last_time = 0;
    uint8_t  index = 0;
    /* ... 初始化 ... */
    while(1){
        /* 时间到了才切换灯,没到就直接跳过,CPU 空出来干别的 */
        if(g_tick - last_time >= 1000){
            last_time = g_tick;
            /* 切换 LED */
            index = (index + 1) % 4;
        }
        /* 这里可以放按键扫描、串口处理等其他任务,不会被延时卡住 */
    }
}

这个思路是从"点灯玩具"迈向"实际产品"的关键一步,建议学完 SysTick 中断示例后回来自己改一遍。


小结

知识点 要记住的核心
GPIO 输出三部曲 rcu_periph_clock_enable()gpio_init()gpio_bit_set/reset()
时钟必须先开 GD32 外设默认时钟关闭,不开时钟配置全部无效
推挽输出 点灯、驱动开关量用 GPIO_MODE_OUT_PP
位操作寄存器 gpio_bit_set/reset 是原子操作,比读改写整个端口安全
SysTick 延时 systick_config() + delay_1ms(),比空循环准确且可移植
BSP 分层 板子相关信息(哪个脚接哪颗灯)应该封装在 xxx_eval.c 里,不要写死在 main
while(1) 裸机 main 永不返回

核心心法:GD32 的每一个外设,配置流程都是"开时钟 → 配 GPIO → 配外设参数 → 使能外设 → 读写数据"。流水灯只用到了前三步,但这个骨架会贯穿你后面学的 USART、ADC、SPI、TIMER 全部内容。


拓展应用

学会 GPIO 输出,下面这些项目你现在就能做:

入门级(改改本例代码就行)

  • 呼吸灯(软件版):不用 PWM,纯靠"亮 1ms 灭 9ms"快速切换 + 逐渐改变比例,实现渐亮渐暗。理解这个再去学 PWM 会豁然开期。
  • 摩尔斯电码发报机:把字符串编码成长短闪烁,用 LED 发 “SOS”。练习数组遍历 + 延时控制。
  • 交通灯模型:3 颗 LED 当红黄绿,按真实时序(绿 30s → 黄 3s → 红 30s)循环,加上数字倒计时就是完整的课程设计。
  • 蜂鸣器报警器:把 LED 换成有源蜂鸣器(引脚经三极管驱动),改变间隔就是不同的报警音效。

进阶级(需要再学一点别的外设)

  • 8 位 LED 灯带跑马灯:8 颗 LED 接一整组 GPIO,用 gpio_port_write() 一次写 8 位,通过移位运算 1 << i 实现流水,代码优雅很多。
  • 74HC595 级联驱动:只用 3 根 GPIO(DATA / CLOCK / LATCH)驱动 8、16、32 颗 LED。这是学习"用软件模拟时序协议"的最佳入门项目,也是理解 SPI 的前置知识。
  • 数码管动态扫描显示:4 位共阴数码管,GPIO 快速轮流点亮每一位(每位亮 2ms),利用视觉暂留让 4 位看起来同时亮。这是 GPIO 输出 + 精确定时的经典综合应用。
  • 步进电机四相八拍驱动:4 根 GPIO 按特定相序通电(A→AB→B→BC→C→CD→D→DA),电机就转起来了。改变相序间隔就是调速,改变顺序就是反转。
  • 继电器控制 + 定时开关:GPIO 经光耦驱动继电器,配合 RTC 做"每天早上 7 点自动开灯"的智能插座。

实战产品级

  • LED 点阵屏 / WS2812 灯带:WS2812 是单总线协议,靠精确的高低电平宽度(0.35μs / 0.9μs)编码数据。用 GPIO + 精确延时(或 PWM+DMA)就能驱动任意长度的全彩灯带,做氛围灯、电竞灯效。
  • 工业开关量采集卡:多路 GPIO 输入读传感器状态 + 多路 GPIO 输出驱动执行机构,加上 Modbus 串口通信,这就是一个最小的 PLC 模块。

下一步该学什么:看完流水灯,建议按 按键轮询(GPIO 输入)USART Printf(学会打印调试)TIMER PWM(真正的呼吸灯) 的顺序继续。特别是 Printf,它让你能"看见"程序内部在想什么,是效率提升最大的一个技能。

世界是你们
使用 Hugo 构建
主题 StackJimmy 设计