【零基础嵌入式学习日志】Day28:Proteus 仿真 STM32F103C8 实现 LED 闪烁

前言

在前面的学习中,我主要使用 Linux、GCC 和纯 C 语言模拟 GPIO、定时器、中断以及通信协议。虽然这种方式有利于理解程序逻辑和模块化设计,但缺少单片机寄存器、引脚电平与外部电路之间的实际联系。

从本次实验开始,学习内容由纯 C 软件模拟进入 STM32F103C8 与 Proteus 联合仿真阶段。本文使用 Keil 编写寄存器级程序,通过配置 RCC、GPIOA 和 SysTick,实现 PA0 控制 LED 周期闪烁。同时使用逻辑探针、直流电压表和虚拟示波器验证 GPIO 输出状态,并记录调试过程中遇到的时钟缩放、LED 显示和测量节点等问题。


一、实验目标

本实验主要完成以下内容:

  1. 在 Proteus 中搭建 STM32F103C8 LED 闪烁仿真电路。
  2. 使用寄存器方式配置 PA0 为通用推挽输出。
  3. 使用 SysTick 建立 1 ms 时间基准。
  4. 控制 LED 周期性亮灭。
  5. 使用逻辑探针、电压表和示波器验证 PA0 输出。
  6. 理解 Proteus 中 OSC FrequencyClock Scale 的作用。

二、开发环境

操作系统:Windows
单片机:STM32F103C8
开发工具:Keil μVision
仿真软件:Proteus 9 Professional
编程语言:C
编程方式:寄存器级编程
系统时钟:内部 HSI 8 MHz
LED 限流电阻:330 Ω

Proteus 中 STM32F103C8 的关键属性设置如下:

Program File:Keil 生成的 HEX 文件
OSC Frequency:8 MHz
Clock Scale:(Default)
OSC32 Frequency:0

注意:不要将 Clock Scale 设置为 8 Times。否则 Proteus 会额外放大程序执行速度,导致程序中的延时明显变快。


三、硬件连接

本实验使用 PA0 驱动一只红色 LED,连接方式如下:

PA0 → 330 Ω 限流电阻 → LED 阳极
LED 阴极 → GND

采用这种连接方式时,LED 为高电平有效:

PA0 = 1:LED 点亮
PA0 = 0:LED 熄灭

完整原理图中主要包括:

  • STM32F103C8
  • PA0 输出支路
  • 330 Ω 限流电阻
  • LED
  • NRST 上拉电阻
  • BOOT0 接地
  • VDD、VDDA、VSS 和 VSSA
  • 逻辑探针
  • 直流电压表
  • 示波器 A 通道

四、GPIO 配置原理

STM32F103C8 的 GPIOA 外设默认没有开启时钟,因此在访问 GPIOA 寄存器之前,必须先开启 GPIOA 时钟:

RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;

PA0 至 PA7 由 GPIOA->CRL 寄存器控制,每个引脚占 4 位。

对于 PA0,其最低 4 位由 MODE0CNF0 组成:

CNF0 = 00:通用推挽输出
MODE0 = 10:最大输出速度 2 MHz

因此,PA0 对应的 4 位配置值为:

0010,即 0x2

对应代码如下:

GPIOA->CRL &= ~0x0000000FUL;
GPIOA->CRL |=  0x00000002UL;

第一行用于清除 PA0 原来的配置,第二行写入新的输出模式。


五、SysTick 时间基准

SysTick 是 Cortex-M3 内核自带的 24 位递减计数器,可以用于建立周期性时间基准。

本实验将 SysTick 配置为每 1 ms 完成一次计数:

static void systick_init_1ms(void)
{
    SystemCoreClockUpdate();

    SysTick->LOAD = (SystemCoreClock / 1000U) - 1U;
    SysTick->VAL  = 0U;

    SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk |
                    SysTick_CTRL_ENABLE_Msk;
}

其中:

SystemCoreClockUpdate();

用于根据当前 RCC 时钟配置更新 SystemCoreClock

当系统时钟为 8 MHz 时:

8 000 000 ÷ 1000 = 8000

因此,SysTick 每计数 8000 次约对应 1 ms。

本实验没有开启 SysTick 中断,而是通过轮询 COUNTFLAG 判断一次计数是否结束。


六、毫秒延时函数

static void delay_ms(uint32_t milliseconds)
{
    while (milliseconds > 0U)
    {
        while ((SysTick->CTRL & SysTick_CTRL_COUNTFLAG_Msk) == 0U)
        {
            /* 等待 1 ms */
        }

        milliseconds--;
    }
}

每当 SysTick 完成一次递减计数,COUNTFLAG 会置 1。程序检测到该标志后,将毫秒计数减 1。

例如:

delay_ms(1000U);

表示等待 1000 个 1 ms 时间片,即约 1 秒。

这种方式属于阻塞式延时。在延时期间,CPU 无法执行其他任务。它适合当前基础实验,后续可以改为基于系统 Tick 和状态变量的非阻塞调度。


七、完整程序

#include "stm32f10x.h"

/*
 * 配置 SysTick,每 1 ms 产生一次 COUNTFLAG。
 */
static void systick_init_1ms(void)
{
    SystemCoreClockUpdate();

    SysTick->LOAD = (SystemCoreClock / 1000U) - 1U;
    SysTick->VAL  = 0U;

    SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk |
                    SysTick_CTRL_ENABLE_Msk;
}

/*
 * 阻塞式毫秒延时。
 */
static void delay_ms(uint32_t milliseconds)
{
    while (milliseconds > 0U)
    {
        while ((SysTick->CTRL & SysTick_CTRL_COUNTFLAG_Msk) == 0U)
        {
            /* 等待 1 ms */
        }

        milliseconds--;
    }
}

int main(void)
{
    /*
     * 开启 GPIOA 外设时钟。
     */
    RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;

    /*
     * PA0 配置为 2 MHz 通用推挽输出。
     */
    GPIOA->CRL &= ~0x0000000FUL;
    GPIOA->CRL |=  0x00000002UL;

    /*
     * 初始输出低电平。
     */
    GPIOA->BRR = GPIO_BRR_BR0;

    systick_init_1ms();

    while (1)
    {
        /*
         * 翻转 PA0 输出状态。
         */
        GPIOA->ODR ^= GPIO_ODR_ODR0;

        /*
         * 当前状态保持 1 秒。
         */
        delay_ms(1000U);
    }
}

八、程序执行流程

程序执行流程如下:

系统复位
  ↓
进入 main 函数
  ↓
开启 GPIOA 时钟
  ↓
配置 PA0 为推挽输出
  ↓
配置 SysTick 为 1 ms 时间基准
  ↓
翻转 PA0
  ↓
延时 1000 ms
  ↓
再次翻转 PA0
  ↓
循环执行

PA0 每隔 1 秒翻转一次,因此:

LED 亮 1 秒
LED 灭 1 秒
完整闪烁周期约为 2 秒

九、仿真结果验证

1. 逻辑探针

将逻辑探针连接到 PA0 后,可以看到其状态在 01 之间周期切换。

逻辑探针适合连接在 GPIO 数字输出节点,不适合连接在 LED 和电阻之间。

LED 导通时,中间节点可能处于约 1.5 V,既不是标准逻辑 0,也不是标准逻辑 1,因此探针可能显示:

?

这并不表示程序错误。

2. 直流电压表

电压表正端连接 PA0,负端连接 GND。

正常情况下,电压表在以下数值之间切换:

低电平:约 0.00 V
高电平:约 3.20 V

3. 示波器

示波器 A 通道连接 PA0。Proteus 中的虚拟示波器不需要单独连接 GND,其默认参考原理图中的全局地。

建议设置如下:

Channel A:DC
垂直档位:约 1 V/div
Horizontal:200 ms/div
Trigger:Auto
Source:A

此处插入图2:PA0 输出电压的示波器波形

示波器中可以看到 PA0 在约 0 V 和 3.2 V 之间周期切换,说明 GPIO 配置和延时程序能够正常工作。


十、调试过程中遇到的问题

1. 固定输出 0 和 1 正常,但闪烁速度异常

最初将 Proteus 器件属性设置为:

OSC Frequency = 8 MHz
Clock Scale = 8 Times

虽然程序根据 8 MHz 系统时钟计算 SysTick,但 Proteus 又将执行速度放大了 8 倍。

例如程序设置 500 ms 延时,实际观察时间约为:

500 ÷ 8 = 62.5 ms

因此 LED 看起来闪烁得非常快。

解决方法如下:

OSC Frequency = 8 MHz
Clock Scale = (Default)

2. LED 两端电压为 0,但元件仍显示红色

Proteus 中的 LED-RED 元件在熄灭状态下仍会保留暗红色外观,因此不能仅根据元件颜色判断 LED 是否点亮。

判断 LED 状态应结合:

  • PA0 逻辑探针
  • PA0 对地电压
  • LED 内部亮度变化
  • 支路电流
  • 示波器波形

当 PA0 为 0 且 LED 两端电压为 0 V 时,LED 实际上已经熄灭。

3. 电阻看似放在导线上,但没有真正接入电路

Proteus 中元件必须通过端点真正连接。若电阻两端仍显示短红色引脚,说明电阻悬空,没有接入导线。

正确串联关系必须为:

PA0 → 电阻 → LED → GND

不能存在绕过电阻的并联导线。

4. 示波器中只能看到一条直线

如果示波器时基设置得过小,例如使用 1 ms/div 观察秒级信号,在当前屏幕范围内只能看到一段高电平或低电平,因此会表现为水平直线。

本实验将水平时基设置为:

200 ms/div

这样可以在屏幕中观察到完整的高低电平变化。


十一、实验总结

通过本实验,我完成了从纯 C 软件模拟到 STM32 寄存器级仿真的第一步,主要掌握了:

  1. STM32F103C8 的 GPIOA 时钟使能方法。
  2. GPIOA->CRL 中 MODE 和 CNF 位的配置方法。
  3. 使用 ODR、BSRR 和 BRR 控制 GPIO 输出。
  4. 使用 SysTick 建立 1 ms 时间基准。
  5. 使用逻辑探针、电压表和示波器验证 GPIO 输出。
  6. Proteus 中 Clock Scale 对仿真速度的影响。
  7. 数字节点和模拟节点测量方式的区别。

下一步将在 PA0 至 PA3 连接 4 只 LED,通过位移运算实现流水灯。


GitHub 项目

【零基础嵌入式学习日志】Day29:STM32F103C8 四路 LED 流水灯与位运算

前言

在 Day28 中,我完成了 STM32F103C8 单路 GPIO 控制 LED 闪烁,并使用 SysTick 建立毫秒级时间基准。

本次实验在此基础上,将 PA0 至 PA3 配置为通用推挽输出,并分别连接 4 只 LED。程序通过位移运算生成不同的 GPIO 输出状态,使 4 只 LED 按照 PA0、PA1、PA2、PA3 的顺序依次点亮。

本实验重点包括多引脚寄存器配置、GPIO 输出位清除、位移运算以及流水灯序号循环控制。


一、实验目标

本实验主要完成以下内容:

  1. 配置 PA0 至 PA3 为通用推挽输出。
  2. 使用 4 个独立限流电阻驱动 4 只 LED。
  3. 使用位移运算选择当前点亮的 LED。
  4. 实现 4 路 LED 循环流水效果。
  5. 使用逻辑探针和示波器验证 GPIO 状态。
  6. 理解多引脚配置和单比特控制方法。

二、开发环境

操作系统:Windows
单片机:STM32F103C8
开发工具:Keil μVision
仿真软件:Proteus 9 Professional
编程语言:C
编程方式:寄存器级编程
系统时钟:内部 HSI 8 MHz
LED 限流电阻:330 Ω

Proteus 中 STM32F103C8 的关键属性设置如下:

Program File:Keil 生成的 HEX 文件
OSC Frequency:8 MHz
Clock Scale:(Default)
OSC32 Frequency:0

三、硬件连接

4 只 LED 分别连接到 PA0 至 PA3:

PA0 → 330 Ω → LED0 → GND
PA1 → 330 Ω → LED1 → GND
PA2 → 330 Ω → LED2 → GND
PA3 → 330 Ω → LED3 → GND

每一只 LED 必须使用独立限流电阻,不能让 4 只 LED 共用一个电阻。

本实验采用高电平点亮方式:

GPIO 输出 1:对应 LED 点亮
GPIO 输出 0:对应 LED 熄灭

完整仿真电路主要包括:

  • STM32F103C8
  • PA0 至 PA3 输出支路
  • 4 个 330 Ω 限流电阻
  • 4 只 LED
  • NRST 上拉电阻
  • BOOT0 接地
  • 电源和地
  • 逻辑探针
  • 示波器

此处插入图1:STM32F103C8 四路 LED 流水灯 Proteus 仿真电路


四、PA0 至 PA3 寄存器配置

PA0 至 PA3 都位于 GPIOA->CRL 寄存器中,每个引脚占 4 位。

每个引脚均配置为:

CNF = 00:通用推挽输出
MODE = 10:最大输出速度 2 MHz
配置值 = 0x2

PA0 至 PA3 对应位置如下:

PA0:CRL[3:0]
PA1:CRL[7:4]
PA2:CRL[11:8]
PA3:CRL[15:12]

将 4 个配置值组合后得到:

0x00002222

对应代码如下:

GPIOA->CRL &= ~0x0000FFFFUL;
GPIOA->CRL |=  0x00002222UL;

第一行清除 PA0 至 PA3 原来的配置,第二行写入 4 组推挽输出配置。


五、流水灯位运算原理

流水灯的核心表达式为:

1UL << led_index

其中,led_index 的取值范围为 0 至 3。

led_index 运算结果 二进制 点亮引脚
0 1 << 0 0001 PA0
1 1 << 1 0010 PA1
2 1 << 2 0100 PA2
3 1 << 3 1000 PA3

例如:

1UL << 2

相当于将二进制 0001 左移 2 位,结果为:

0100

因此,PA2 输出高电平。

使用位移运算后,不需要分别编写 4 组固定的 GPIO 控制代码,只需修改 led_index 的数值,就可以选择需要点亮的引脚。


六、清除 PA0 至 PA3 输出

在点亮下一只 LED 之前,需要先关闭当前所有 LED:

GPIOA->ODR &= ~0x0000000FUL;

0x0F 对应最低 4 位:

0000 1111

取反后,最低 4 位变为 0,再与 ODR 进行按位与运算,就可以将 PA0 至 PA3 清零,同时保留 GPIOA 其他输出位的状态。

随后使用:

GPIOA->ODR |= (1UL << led_index);

将当前需要点亮的引脚置 1。

两条语句组合后的作用为:

先关闭 PA0 至 PA3
再点亮 led_index 对应的一个引脚

七、完整程序

#include "stm32f10x.h"

/*
 * 配置 SysTick,每 1 ms 产生一次 COUNTFLAG。
 */
static void systick_init_1ms(void)
{
    SystemCoreClockUpdate();

    SysTick->LOAD = (SystemCoreClock / 1000U) - 1U;
    SysTick->VAL  = 0U;

    SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk |
                    SysTick_CTRL_ENABLE_Msk;
}

/*
 * 阻塞式毫秒延时。
 */
static void delay_ms(uint32_t milliseconds)
{
    while (milliseconds > 0U)
    {
        while ((SysTick->CTRL & SysTick_CTRL_COUNTFLAG_Msk) == 0U)
        {
            /* 等待 1 ms */
        }

        milliseconds--;
    }
}

int main(void)
{
    uint32_t led_index = 0U;

    /*
     * 开启 GPIOA 时钟。
     */
    RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;

    /*
     * PA0 至 PA3 配置为 2 MHz 通用推挽输出。
     */
    GPIOA->CRL &= ~0x0000FFFFUL;
    GPIOA->CRL |=  0x00002222UL;

    /*
     * 初始关闭 4 只 LED。
     */
    GPIOA->BRR = GPIO_BRR_BR0 |
                 GPIO_BRR_BR1 |
                 GPIO_BRR_BR2 |
                 GPIO_BRR_BR3;

    systick_init_1ms();

    while (1)
    {
        /*
         * 清除 PA0 至 PA3。
         */
        GPIOA->ODR &= ~0x0000000FUL;

        /*
         * 点亮当前 LED。
         */
        GPIOA->ODR |= (1UL << led_index);

        /*
         * 当前 LED 保持点亮 300 ms。
         */
        delay_ms(300U);

        /*
         * 切换到下一只 LED。
         */
        led_index++;

        if (led_index >= 4U)
        {
            led_index = 0U;
        }
    }
}

八、程序执行过程

程序执行顺序如下:

led_index = 0
PA0 点亮
延时 300 ms
  ↓
led_index = 1
PA1 点亮
延时 300 ms
  ↓
led_index = 2
PA2 点亮
延时 300 ms
  ↓
led_index = 3
PA3 点亮
延时 300 ms
  ↓
led_index 重新变为 0

因此,流水灯顺序为:

PA0 → PA1 → PA2 → PA3 → PA0

完整循环周期约为:

4 × 300 ms = 1200 ms

九、仿真结果

运行 Proteus 后,可以看到 4 只 LED 依次点亮,每次仅有一只 LED 对应的 GPIO 输出高电平。

此处插入图2:四路 LED 流水灯仿真运行状态

从仿真结果可以观察到:

PA0 点亮后,PA1、PA2 和 PA3 保持低电平
PA1 点亮后,PA0、PA2 和 PA3 保持低电平
PA2 点亮后,PA0、PA1 和 PA3 保持低电平
PA3 点亮后,PA0、PA1 和 PA2 保持低电平

这说明程序每次只设置一个输出位,流水灯状态控制正确。


十、示波器波形分析

将示波器 A 通道连接 PA0。

由于完整循环中 PA0 只点亮一次,因此 PA0 波形为:

高电平约 300 ms
低电平约 900 ms

完整周期约为:

1200 ms

PA0 的理论占空比为:

300 ÷ 1200 = 25%

因此,在示波器中可以观察到一段较短的高电平和一段较长的低电平。

如果分别将示波器的 A、B、C、D 通道连接 PA0、PA1、PA2 和 PA3,还可以观察到 4 路方波依次错开 300 ms。


十一、调试过程中需要注意的问题

1. 每只 LED 应使用独立限流电阻

不能让 4 只 LED 共用一个限流电阻,否则不同 LED 的电流会相互影响,实际硬件中也可能出现亮度不一致的问题。

正确连接方式为:

一个 GPIO
一个电阻
一只 LED

2. LED 方向必须一致

本实验采用:

GPIO → 电阻 → LED 阳极
LED 阴极 → GND

因此高电平点亮。

如果某只 LED 的方向与其他 LED 相反,它将无法正常点亮。

3. Proteus 时钟缩放保持默认

STM32F103C8 的属性应设置为:

OSC Frequency = 8 MHz
Clock Scale = (Default)

如果将 Clock Scale 设置为 8 Times,流水灯切换速度会明显快于程序中设定的 300 ms。

4. 原理图中的元件必须真正连接

在 Proteus 中,元件放在导线上并不一定代表已经连接。

如果元件端点仍显示悬空的红色短线,说明导线没有吸附到引脚端点,需要重新连接。


十二、程序改进方向

1. 反向流水灯

实现以下顺序:

PA3 → PA2 → PA1 → PA0

可以将点亮语句修改为:

GPIOA->ODR |= (1UL << (3U - led_index));

2. 往返流水灯

实现以下顺序:

PA0 → PA1 → PA2 → PA3 → PA2 → PA1 → PA0

可以增加一个方向变量:

int32_t direction = 1;

当序号到达 0 或 3 时改变方向。

3. 按键控制流水灯

下一步可以增加独立按键,实现:

按键 1:暂停或继续
按键 2:切换流水方向
按键 3:调整流水速度

4. 非阻塞流水灯

当前 delay_ms() 属于阻塞式延时。在延时期间,CPU 无法处理按键和其他任务。

后续可以使用以下结构实现非阻塞流水灯:

SysTick 中断
系统毫秒计数
主循环时间判断
状态机

这样程序可以同时处理按键、串口和其他外设。


十三、实验总结

本实验完成了 4 路 LED 流水灯,主要掌握了:

  1. PA0 至 PA3 多引脚寄存器配置。
  2. GPIOA->CRL 中多个 4 位配置字段的组合。
  3. 使用按位与运算清除指定 GPIO。
  4. 使用左移运算产生单比特输出。
  5. 使用序号变量控制流水灯状态。
  6. 使用 SysTick 阻塞延时控制流水速度。
  7. 使用示波器分析流水灯输出周期和占空比。

本次实验将前面学习过的 GPIO 位操作、系统 Tick 和状态变量真正应用到了 STM32 与 Proteus 仿真中。

下一步将增加独立按键,实现流水灯暂停、继续和方向切换。


GitHub 项目

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐