STM32项目——秒表

1. 项目概述

秒表是嵌入式开发中非常经典的一个入门项目,它综合运用了定时器、按键扫描、数码管或 LCD 显示、中断处理等多项核心知识。本项目基于 STM32 单片机,实现一个功能完整的秒表:支持启动、暂停、继续、清零,并具备分、秒、毫秒的精确计时显示。

通过本项目的学习,你将掌握:

  • STM32 定时器的基本配置与中断使用;
  • 按键的消抖与状态机设计;
  • 数码管动态扫描或 LCD 显示驱动;
  • 模块化编程思想与代码组织。

2. 硬件准备

在开始之前,请准备好以下硬件:

  • STM32 开发板一块(推荐 STM32F103C8T6 最小系统板,性价比高);
  • STM32 下载调试器一个(ST-Link 或 J-Link);
  • 4 位共阴数码管一个(或 0.96 寸 OLED / 1602 LCD,视个人喜好);
  • 三个独立按键(启动/暂停、清零、模式切换,可选);
  • 若干杜邦线、面包板、电阻(数码管限流电阻 220Ω 左右)。

接线参考:

模块 STM32 引脚
数码管段选 A~G、DP PA0~PA7
数码管位选 1~4 PB0~PB3
按键 1(启动/暂停) PB4
按键 2(清零) PB5
按键 3(模式切换,可选) PB6

提示:如果使用 OLED 或 LCD,接线方式略有不同,请参考对应模块的数据手册。

3. 系统设计

3.1 功能需求

  • 按下「启动/暂停」键,秒表开始计时;再次按下则暂停;
  • 按下「清零」键,秒表归零;
  • 显示格式为 分:秒:毫秒,例如 01:23:45;
  • 计时精度达到 1ms。

3.2 计时原理

秒表的计时核心是 STM32 的定时器。我们使用定时器产生 1ms 的中断,在中断服务函数中对毫秒计数变量进行累加。当毫秒计数达到 1000 时,秒计数加 1;当秒计数达到 60 时,分钟计数加 1。

c 复制代码
volatile uint16_t ms_count = 0;   // 毫秒计数
volatile uint8_t  sec_count = 0;  // 秒计数
volatile uint8_t  min_count = 0;  // 分钟计数

3.3 按键状态机

为了避免按键抖动带来的误触发,我们采用「按下检测 + 延时消抖 + 释放确认」的方式,或者使用更规范的状态机:

c 复制代码
typedef enum {
    KEY_IDLE,      // 空闲
    KEY_PRESSED,   // 已按下(待消抖)
    KEY_CONFIRMED  // 确认按下
} KeyState;

下面是按键状态机的状态转移图:
#mermaid-svg-J5j2lqyeKRheIB04{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-J5j2lqyeKRheIB04 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-J5j2lqyeKRheIB04 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-J5j2lqyeKRheIB04 .error-icon{fill:#552222;}#mermaid-svg-J5j2lqyeKRheIB04 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-J5j2lqyeKRheIB04 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-J5j2lqyeKRheIB04 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-J5j2lqyeKRheIB04 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-J5j2lqyeKRheIB04 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-J5j2lqyeKRheIB04 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-J5j2lqyeKRheIB04 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-J5j2lqyeKRheIB04 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-J5j2lqyeKRheIB04 .marker.cross{stroke:#333333;}#mermaid-svg-J5j2lqyeKRheIB04 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-J5j2lqyeKRheIB04 p{margin:0;}#mermaid-svg-J5j2lqyeKRheIB04 defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-J5j2lqyeKRheIB04 g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-J5j2lqyeKRheIB04 g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-J5j2lqyeKRheIB04 g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-J5j2lqyeKRheIB04 g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-J5j2lqyeKRheIB04 g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-J5j2lqyeKRheIB04 .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-J5j2lqyeKRheIB04 .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-J5j2lqyeKRheIB04 .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-J5j2lqyeKRheIB04 .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-J5j2lqyeKRheIB04 .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-J5j2lqyeKRheIB04 .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-J5j2lqyeKRheIB04 .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-J5j2lqyeKRheIB04 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-J5j2lqyeKRheIB04 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-J5j2lqyeKRheIB04 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-J5j2lqyeKRheIB04 .edgeLabel .label text{fill:#333;}#mermaid-svg-J5j2lqyeKRheIB04 .label div .edgeLabel{color:#333;}#mermaid-svg-J5j2lqyeKRheIB04 .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-J5j2lqyeKRheIB04 .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-J5j2lqyeKRheIB04 .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-J5j2lqyeKRheIB04 .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-J5j2lqyeKRheIB04 .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-J5j2lqyeKRheIB04 .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-J5j2lqyeKRheIB04 .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-J5j2lqyeKRheIB04 #statediagram-barbEnd{fill:#333333;}#mermaid-svg-J5j2lqyeKRheIB04 .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-J5j2lqyeKRheIB04 .cluster-label,#mermaid-svg-J5j2lqyeKRheIB04 .nodeLabel{color:#131300;}#mermaid-svg-J5j2lqyeKRheIB04 .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-J5j2lqyeKRheIB04 .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-J5j2lqyeKRheIB04 .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-J5j2lqyeKRheIB04 .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-J5j2lqyeKRheIB04 .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-J5j2lqyeKRheIB04 .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-J5j2lqyeKRheIB04 .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-J5j2lqyeKRheIB04 .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-J5j2lqyeKRheIB04 .note-edge{stroke-dasharray:5;}#mermaid-svg-J5j2lqyeKRheIB04 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-J5j2lqyeKRheIB04 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-J5j2lqyeKRheIB04 .statediagram-note text{fill:black;}#mermaid-svg-J5j2lqyeKRheIB04 .statediagram-note .nodeLabel{color:black;}#mermaid-svg-J5j2lqyeKRheIB04 .statediagram .edgeLabel{color:red;}#mermaid-svg-J5j2lqyeKRheIB04 #dependencyStart,#mermaid-svg-J5j2lqyeKRheIB04 #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-J5j2lqyeKRheIB04 .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-J5j2lqyeKRheIB04 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 检测到按键按下(低电平)
消抖计时中(< 20ms)
消抖计时完成(≥ 20ms)
按键释放(高电平)
按键提前释放(抖动)
KEY_IDLE
KEY_PRESSED
KEY_CONFIRMED

各状态的含义与转移条件说明如下:

  • KEY_IDLE(空闲) :按键未被按下,系统处于待命状态。当检测到按键引脚变为低电平(按下)时,进入 KEY_PRESSED 状态。
  • KEY_PRESSED(已按下,待消抖) :按键已被检测到按下,但尚未确认,此时开始消抖计时。若在 20ms 内按键释放(抖动干扰),则回到 KEY_IDLE;若持续保持按下超过 20ms,则确认按键有效,进入 KEY_CONFIRMED 状态。
  • KEY_CONFIRMED(确认按下) :按键已被确认有效,此时返回按键值供主循环处理(如启动/暂停切换)。当按键释放(引脚恢复高电平)后,回到 KEY_IDLE,等待下一次按下。

4. 代码实现

4.1 定时器初始化

使用 TIM2 产生 1ms 定时中断。假设系统时钟为 72MHz,预分频设置为 71,自动重载值为 999,即可得到 1ms 的中断周期。

c 复制代码
void TIM2_Init(void) {
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
    
    TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;
    TIM_TimeBaseStructure.TIM_Period = 999;          // 自动重载值
    TIM_TimeBaseStructure.TIM_Prescaler = 71;        // 预分频
    TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1;
    TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
    TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure);
    
    TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE);
    TIM_Cmd(TIM2, ENABLE);
    
    NVIC_InitTypeDef NVIC_InitStructure;
    NVIC_InitStructure.NVIC_IRQChannel = TIM2_IRQn;
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0;
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStructure);
}

4.2 定时器中断服务函数

c 复制代码
void TIM2_IRQHandler(void) {
    if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) {
        TIM_ClearITPendingBit(TIM2, TIM_IT_Update);
        
        if (running) {
            ms_count++;
            if (ms_count >= 1000) {
                ms_count = 0;
                sec_count++;
                if (sec_count >= 60) {
                    sec_count = 0;
                    min_count++;
                    if (min_count >= 60) {
                        min_count = 0;
                    }
                }
            }
        }
    }
}

4.3 按键扫描与消抖

c 复制代码
uint8_t Key_Scan(void) {
    static uint8_t key_state = KEY_IDLE;
    static uint32_t last_time = 0;
    uint8_t key = 0;
    
    if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_4) == 0) {
        // 按键按下(低电平有效)
        if (key_state == KEY_IDLE) {
            key_state = KEY_PRESSED;
            last_time = HAL_GetTick();
        } else if (key_state == KEY_PRESSED) {
            if (HAL_GetTick() - last_time > 20) {
                key_state = KEY_CONFIRMED;
                key = 1;  // 确认按下
            }
        }
    } else {
        key_state = KEY_IDLE;
    }
    
    return key;
}

4.4 主循环

c 复制代码
int main(void) {
    // 初始化
    SystemInit();
    GPIO_Init();
    TIM2_Init();
    Display_Init();
    
    while (1) {
        uint8_t key = Key_Scan();
        
        if (key == 1) {
            // 启动/暂停切换
            running = !running;
        }
        
        if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_5) == 0) {
            // 清零
            ms_count = 0;
            sec_count = 0;
            min_count = 0;
            running = 0;
        }
        
        // 刷新显示
        Display_Update(min_count, sec_count, ms_count);
    }
}

5. 显示驱动

5.1 数码管动态扫描

4 位数码管采用动态扫描方式,每次只点亮一位,利用人眼视觉暂留效应,快速轮流点亮各位即可看到完整显示。

c 复制代码
void Display_Update(uint8_t min, uint8_t sec, uint16_t ms) {
    uint8_t digits[4];
    digits[0] = min / 10;      // 分钟的十位
    digits[1] = min % 10;      // 分钟的个位
    digits[2] = sec / 10;      // 秒的十位
    digits[3] = sec % 10;      // 秒的个位
    
    for (uint8_t i = 0; i < 4; i++) {
        GPIO_Write(GPIOA, seg_table[digits[i]]);  // 段选
        GPIO_WriteBit(GPIOB, i, 0);               // 位选
        Delay_Ms(1);
        GPIO_WriteBit(GPIOB, i, 1);               // 关闭位选
    }
}

其中 seg_table 是共阴数码管的段码表:

c 复制代码
const uint8_t seg_table[10] = {
    0x3F,  // 0
    0x06,  // 1
    0x5B,  // 2
    0x4F,  // 3
    0x66,  // 4
    0x6D,  // 5
    0x7D,  // 6
    0x07,  // 7
    0x7F,  // 8
    0x6F   // 9
};

5.2 显示毫秒

如果使用 4 位数码管,通常只显示 分:秒;若需要显示毫秒,可以改用 6 位数码管或 OLED 屏幕。使用 OLED 时,可以直接用 sprintf 格式化输出:

c 复制代码
char buf[16];
sprintf(buf, "%02d:%02d:%03d", min_count, sec_count, ms_count);
OLED_ShowString(0, 0, buf);

下面给出基于 I2C 接口(SSD1306 驱动芯片)的 OLED 初始化与显示函数实现。这里假设 OLED 的 SCL 接 PB6、SDA 接 PB7(STM32F103 的 I2C1 引脚),使用标准外设库编写。

OLED 初始化函数 OLED_Init()
c 复制代码
#define OLED_ADDR  0x78  // SSD1306 的 I2C 从机地址(7 位地址 0x3C 左移一位)

void OLED_Init(void) {
    // 1. 初始化 I2C1
    I2C_InitTypeDef I2C_InitStructure;
    GPIO_InitTypeDef GPIO_InitStructure;
    
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE);
    
    // SCL(PB6)、SDA(PB7) 配置为开漏复用输出
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOB, &GPIO_InitStructure);
    
    I2C_InitStructure.I2C_Mode = I2C_Mode_I2C;
    I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2;
    I2C_InitStructure.I2C_OwnAddress = 0x00;
    I2C_InitStructure.I2C_Ack = I2C_Ack_Enable;
    I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit;
    I2C_InitStructure.I2C_ClockSpeed = 400000;  // 400kHz 快速模式
    I2C_Init(I2C1, &I2C_InitStructure);
    I2C_Cmd(I2C1, ENABLE);
    
    // 2. 发送 SSD1306 初始化命令序列
    OLED_WriteCmd(0xAE);  // 关闭显示
    OLED_WriteCmd(0x20);  // 设置内存寻址模式
    OLED_WriteCmd(0x00);  // 水平寻址模式
    OLED_WriteCmd(0xB0);  // 设置页地址起始
    OLED_WriteCmd(0xC8);  // 扫描方向(从上到下)
    OLED_WriteCmd(0xA1);  // 段重映射(左右翻转)
    OLED_WriteCmd(0xA6);  // 正常显示(非反显)
    OLED_WriteCmd(0xA8);  // 设置多路复用比
    OLED_WriteCmd(0x3F);  // 64 行
    OLED_WriteCmd(0xD3);  // 设置显示偏移
    OLED_WriteCmd(0x00);  // 偏移为 0
    OLED_WriteCmd(0xD5);  // 设置时钟分频
    OLED_WriteCmd(0x80);  // 推荐值
    OLED_WriteCmd(0xD9);  // 设置预充电周期
    OLED_WriteCmd(0xF1);  // 推荐值
    OLED_WriteCmd(0xDA);  // 设置 COM 引脚硬件配置
    OLED_WriteCmd(0x12);  // 兼容 64 行模式
    OLED_WriteCmd(0xDB);  // 设置 VCOMH 电压
    OLED_WriteCmd(0x40);  // 推荐值
    OLED_WriteCmd(0x8D);  // 设置电荷泵
    OLED_WriteCmd(0x14);  // 开启电荷泵
    OLED_WriteCmd(0xAF);  // 开启显示
    
    OLED_Clear();         // 清屏
}
I2C 底层写命令与写数据函数
c 复制代码
// 向 SSD1306 发送一个字节(控制字节 + 数据)
void OLED_WriteByte(uint8_t data, uint8_t cmd) {
    I2C_GenerateSTART(I2C1, ENABLE);
    while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT));
    
    I2C_Send7bitAddress(I2C1, OLED_ADDR, I2C_Direction_Transmitter);
    while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED));
    
    // 控制字节:0x00 表示后续为命令,0x40 表示后续为数据
    I2C_SendData(I2C1, cmd ? 0x00 : 0x40);
    while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED));
    
    I2C_SendData(I2C1, data);
    while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED));
    
    I2C_GenerateSTOP(I2C1, ENABLE);
}

void OLED_WriteCmd(uint8_t cmd) {
    OLED_WriteByte(cmd, 1);  // 命令
}

void OLED_WriteData(uint8_t data) {
    OLED_WriteByte(data, 0);  // 数据
}
显示字符串函数 OLED_ShowString()
c 复制代码
// 在 (x, y) 位置显示字符串,x 为列坐标(0~127),y 为页坐标(0~7)
void OLED_ShowString(uint8_t x, uint8_t y, char *str) {
    uint8_t i = 0;
    while (str[i] != '\0') {
        OLED_ShowChar(x, y, str[i]);
        x += 8;  // 每个字符占 8 个像素宽
        if (x > 120) {  // 超出屏幕宽度则换行
            x = 0;
            y++;
        }
        i++;
    }
}

// 显示单个字符(基于 8x16 点阵字库,这里以 ASCII 码 0x20~0x7E 为例)
void OLED_ShowChar(uint8_t x, uint8_t y, char ch) {
    uint8_t c = 0, i;
    if (ch < 0x20 || ch > 0x7E) {
        ch = ' ';  // 超出范围的字符显示为空格
    }
    c = ch - 0x20;  // 字库索引
    
    // 上半部分(8 行)
    OLED_SetPos(x, y);
    for (i = 0; i < 8; i++) {
        OLED_WriteData(F8x16[c][i]);
    }
    // 下半部分(8 行)
    OLED_SetPos(x, y + 1);
    for (i = 8; i < 16; i++) {
        OLED_WriteData(F8x16[c][i]);
    }
}

// 设置显示位置:列 x(0~127),页 y(0~7)
void OLED_SetPos(uint8_t x, uint8_t y) {
    OLED_WriteCmd(0xB0 + y);              // 设置页地址
    OLED_WriteCmd(((x & 0xF0) >> 4) | 0x10);  // 设置列地址高 4 位
    OLED_WriteCmd(x & 0x0F);              // 设置列地址低 4 位
}

// 清屏
void OLED_Clear(void) {
    uint8_t i, j;
    for (i = 0; i < 8; i++) {       // 8 页
        OLED_SetPos(0, i);
        for (j = 0; j < 128; j++) { // 每页 128 列
            OLED_WriteData(0x00);
        }
    }
}
8x16 点阵字库(节选)
c 复制代码
// 仅列出常用字符,完整字库请自行补充
const uint8_t F8x16[][16] = {
    {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // ' ' 0x20
    {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // 占位
    // ... 其余字符请参考标准 8x16 ASCII 字库
};

关于 F8x16 字库的存储格式,说明如下:

  • 每个字符占 16 字节 :F8x16 是一个二维数组,每个字符对应 16 个字节,正好对应 8x16 点阵的 16 行像素数据。
  • 前 8 字节为上半部分 :F8x16[c][0] ~ F8x16[c][7] 表示字符上半部分 8 行的点阵数据,对应屏幕的页 y。
  • 后 8 字节为下半部分 :F8x16[c][8] ~ F8x16[c][15] 表示字符下半部分 8 行的点阵数据,对应屏幕的页 y + 1。在 OLED_ShowChar 中正是先写前 8 字节到页 y,再写后 8 字节到页 y + 1。

如何生成完整的 128 个字符(0x20~0x7E) :标准 ASCII 可显示字符从 0x20(空格)到 0x7E(波浪号 ~),共 95 个字符。你可以从网上下载标准的 8x16 ASCII 点阵字库(如常见的 F8x16 字库文件),或使用取模软件(如 PCtoLCD2002、字模精灵等)逐字生成。生成时注意:

  • 取模方式选择「纵向取模、字节倒序」或与 OLED_ShowChar 中 OLED_WriteData 的写入顺序保持一致;
  • 字库索引按 c = ch - 0x20 计算,因此数组下标 0 对应空格 0x20,下标 94 对应 0x7E,数组长度应为 95 行。

务必使用 const 修饰 :完整字库共 95 × 16 = 1520 字节,若定义为普通全局数组会占用宝贵的 RAM。STM32F103C8T6 的 RAM 仅 20KB,而 Flash 有 64KB,因此应将字库定义为 const uint8_t F8x16[95][16],让编译器将其存放在 Flash 中,从而节省 RAM 空间。

延时函数说明
  • 上述代码中未直接使用延时函数,因为 I2C 通信本身由硬件时钟控制,I2C_CheckEvent 会等待传输完成,无需额外软件延时。
  • 若使用软件模拟 I2C(GPIO 翻转方式),则需要在每个时钟周期加入 Delay_Us(5) 左右的延时,以保证时序满足 SSD1306 的要求(SCL 高电平时间 ≥ 4.7μs)。
  • 在 OLED_Init() 中,发送完 0xAE(关闭显示)命令后,建议延时 100ms 再继续发送后续命令,让 SSD1306 内部上电稳定;本示例为简洁起见省略了该延时,实际工程中可在初始化开头调用 Delay_Ms(100)。
  • 主循环中刷新显示时,建议每帧之间加入 Delay_Ms(10) 左右的延时,避免刷新过快导致 I2C 总线拥堵。

6. 功能扩展

完成基础秒表后,可以尝试以下扩展:

  • 计时精度提升:改用定时器输入捕获或外部晶振,提高精度;
  • 记录功能:增加「记录圈数」功能,按下记录键保存当前时间;
  • 倒计时模式:增加倒计时功能,时间到后蜂鸣器报警;
  • 低功耗设计:使用 STM32 的低功耗模式,延长电池续航;
  • 上位机通信:通过串口将计时数据发送到 PC 端显示。

7. 常见问题与调试

7.1 数码管显示闪烁

  • 检查动态扫描的刷新频率是否足够(建议大于 50Hz);
  • 检查位选引脚是否接反;
  • 检查限流电阻是否过大导致亮度不足。

7.2 按键不灵敏

  • 确认按键是否接上拉电阻(或使用内部上拉);
  • 检查消抖时间是否过短;
  • 确认按键引脚配置为输入模式。

7.3 计时不准确

  • 确认系统时钟配置是否正确(72MHz);
  • 检查定时器预分频和重载值计算是否正确;
  • 避免在中断中执行耗时过长的操作。

7.4 OLED 显示异常

使用 OLED 时常见的异常现象主要有黑屏、花屏和显示错位三种,下面分别给出排查步骤与调试方法。

黑屏(完全不显示)
  • 检查 I2C 地址是否匹配 :SSD1306 的 7 位从机地址通常为 0x3C,左移一位后写入 I2C_Send7bitAddress 的地址应为 0x78。若你的模块 SA0 引脚接法不同,地址可能变为 0x7A(对应 7 位地址 0x3D)。请核对模块数据手册,确认 OLED_ADDR 宏定义与实际硬件一致。
  • 确认 SCL/SDA 是否接反或漏接上拉电阻:SCL 接 PB6、SDA 接 PB7,接反会导致通信失败;同时 I2C 总线需要 4.7kΩ~10kΩ 的上拉电阻,若模块未自带,需在 SCL 和 SDA 上各加一个上拉电阻到 3.3V。
  • 验证初始化命令序列是否完整 :OLED_Init() 中必须包含开启电荷泵(0x8D + 0x14)和开启显示(0xAF)两条命令,缺少任一条都会导致黑屏。可逐条核对命令是否发送成功。
花屏(显示乱码或雪花点)
  • 检查初始化命令序列是否完整 :花屏通常是因为初始化命令缺失或顺序错误,尤其是 0xA8(多路复用比)、0xD5(时钟分频)、0xDA(COM 引脚配置)等参数设置不当。建议对照 SSD1306 数据手册逐条核对。
  • 确认 SCL/SDA 是否接反或漏接上拉电阻:接线错误或上拉电阻缺失会导致数据位错乱,表现为花屏。用万用表测量 SCL、SDA 对地电压,正常空闲时应接近 3.3V。
  • 检查 OLED_SetPos 的列地址计算是否正确:列地址由高 4 位和低 4 位两条命令组成,若计算错误会导致数据写入错误的显存位置,出现花屏。可参考下方调试代码验证。
显示错位(字符偏移或上下颠倒)
  • 检查 OLED_SetPos 的列地址计算是否正确 :OLED_SetPos 中列地址高 4 位应为 ((x & 0xF0) >> 4) | 0x10,低 4 位为 x & 0x0F。若高 4 位漏掉 | 0x10,列地址会错乱,导致字符显示偏移。
  • 检查初始化命令序列是否完整 :0xC8(扫描方向)和 0xA1(段重映射)决定显示方向,若这两条命令缺失或写反,会出现上下颠倒或左右镜像。
  • 确认 SCL/SDA 是否接反或漏接上拉电阻:接线问题同样可能导致数据错位,进而表现为显示错位。
调试代码片段

使用逻辑分析仪抓取 I2C 波形是定位问题最有效的手段。将逻辑分析仪的 CH0 接 SCL、CH1 接 SDA,在 OLED_WriteByte 中设置断点,观察每次传输的起始位、地址字节、控制字节和数据字节是否符合预期:

c 复制代码
// 用逻辑分析仪抓取 I2C 波形时,可在发送地址后打印实际发送的地址
void OLED_WriteByte(uint8_t data, uint8_t cmd) {
    I2C_GenerateSTART(I2C1, ENABLE);
    while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT));
    
    // 调试:确认发送的从机地址是否为 0x78(7 位地址 0x3C 左移一位)
    // 逻辑分析仪上应看到地址字节为 0x78(写方向)
    I2C_Send7bitAddress(I2C1, OLED_ADDR, I2C_Direction_Transmitter);
    while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED));
    
    // 调试:控制字节 0x00 表示命令,0x40 表示数据
    I2C_SendData(I2C1, cmd ? 0x00 : 0x40);
    while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED));
    
    I2C_SendData(I2C1, data);
    while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED));
    
    I2C_GenerateSTOP(I2C1, ENABLE);
}

此外,可在 OLED_SetPos 中临时打印列地址,验证计算是否正确:

c 复制代码
void OLED_SetPos(uint8_t x, uint8_t y) {
    OLED_WriteCmd(0xB0 + y);                    // 设置页地址
    OLED_WriteCmd(((x & 0xF0) >> 4) | 0x10);    // 列地址高 4 位(必须带 0x10)
    OLED_WriteCmd(x & 0x0F);                    // 列地址低 4 位
    // 调试:printf("x=%d, high=0x%02X, low=0x%02X\n", x,
    //        ((x & 0xF0) >> 4) | 0x10, x & 0x0F);
}

抓取波形时重点观察:起始位后是否出现 0x78 地址字节、ACK 位是否被从机拉低、控制字节与数据字节是否交替正确。若地址字节后无 ACK,说明 I2C 地址不匹配或模块未上电;若波形杂乱无章,则优先检查 SCL/SDA 接线与上拉电阻。

8. 总结

本项目通过 STM32 定时器中断实现了精确的秒表计时功能,涵盖了定时器配置、按键消抖、数码管动态扫描等嵌入式开发的核心知识点。建议读者在此基础上继续扩展功能,加深对 STM32 外设的理解。

希望这个项目能帮助你巩固 STM32 的开发基础,祝你学习愉快!

相关推荐
尼喃2 小时前
PW5100-50和PW5150:1.5V转5V芯片有什么区别?
嵌入式硬件
iowin8882 小时前
在 MCU 上跑 AI:端侧语音识别的极限压缩
人工智能·单片机·边缘计算·语音识别
硅基手札11 小时前
【串口技术系列文档 10】低功耗与唤醒机制
驱动开发·stm32·单片机·嵌入式硬件·mcu·计算机外设
逐光者93312 小时前
STM32——SPI 屏 · Flash · 高速
java·前端·stm32
yuehuaxin0113 小时前
上海贝岭 BL6523GX(D78) 单相计量芯片开发笔记:引脚定义、寄存器配置与校表流程
嵌入式硬件·智能家居·适配器模式·智能硬件
Kuakewei88813 小时前
可调OVP/OCP USB限流保护芯片IC的50μs过流响应测试
嵌入式硬件
HRTOS13 小时前
HRTOS 官网内容调整记录:首页、系统架构与文档页面优化
单片机·系统架构·51单片机
雨田言炎14 小时前
STM32之SysTick--系统定时器(内容简单但重要,附件有程序源码)
stm32·单片机·嵌入式硬件
ccbw1614 小时前
CH32M007K8U7:集成48V三相预驱的RISC-V电机MCU
单片机·嵌入式硬件