【Arduino】四位数码管按键计时系统(共阴极踩坑与代码优化) - 指南

摘要:本文记录了一次“惊心动魄”的实验过程。原本参照网上的教程中共阳极数码管教程编写代码,结果发现手头的硬件竟是共阴极的!本文将详细介绍如何通过反转电平逻辑来适配共阴极数码管,同时通过对比原始阻塞代码优化后的非阻塞代码,深入讲解如何解决“按键按下导致数码管熄灭”的问题,最终实现一个稳定、流畅的计时系统。

1. ✨ 实验效果预览

  • 初始状态:系统上电后,四位数码管静止显示 0000
  • 启动计时:按下按键,系统立即响应开始计时,末位数字每秒跳动一次(0000 -> 0001 …)。
  • 暂停计时:再次按下按键,计时瞬间停止,数字定格。
  • 视觉体验:无论按键操作如何频繁,数码管的显示始终稳定、无闪烁、无残影

在这里插入图片描述

2. ️ 硬件准备与“避坑”指南

2.1 必备硬件清单

  1. Arduino Uno控制器:1块
  2. 四位共阴数码管:1个(关键点:讲义教程多为共阳,如果你手头的是共阴,必须修改代码逻辑!)
  3. 按键开关:1个
  4. 10kΩ 电阻:1个(下拉电阻)
  5. 面包板及跳线:若干

2.2 ⚡ 核心避坑:共阴 vs 共阳

在实验过程中,我发现第一次进行实验时,数码管完全不亮。经过排查,原来是极性搞反了。

  • 共阳极 (Common Anode)
    • 位选(公共端):接 VCC。选通时给 HIGH
    • 段选(a-dp):接 GND 点亮。输出 LOW 为亮。
  • 共阴极 (Common Cathode)
    • 位选(公共端):接 GND。选通时需给 LOW(拉低电平)。
    • 段选(a-dp):接 VCC 点亮。输出 HIGH 为亮。

结论:我们要把讲义代码中所有的 HIGHLOW 逻辑全部反转,才能点亮共阴极数码管。

2.3 接线图示(根据实际配置)

我的实际接线配置如下:

  • 段选引脚 (SEG A - SEG H):连接至 D2 - D9

  • 位选引脚 (COM1 - COM4):连接至 D10 - D13

  • 控制按键:连接至 D1 引脚。

    (注意:D1也是串口TX引脚,上传代码时若遇到问题请先断开按键连接)

在这里插入图片描述

2.4 核心控制逻辑

2.4.1 动态扫描 (Dynamic Scanning)

由于四位数码管共用了段选引脚,物理上我们无法同时让四位显示不同的数字。动态扫描利用了人眼的视觉暂留效应:

  1. 第1ms拉低第一位位选(选中),输出数字段码。
  2. 第2ms拉高第一位(关闭),拉低第二位(选中),输出数字段码。
  3. 循环往复:频率 > 50Hz,人眼看到的就是静止画面。
2.4.2 ⚡ 按键消抖 (Debouncing)

机械按键在闭合瞬间会发生物理抖动。我们使用非阻塞的方式,在检测到电平变化后,利用 millis() 延时 20-50ms 再次确认状态,防止误触发。

3. 代码进化论:从阻塞到非阻塞

为了解决按键卡死(阻塞)问题,并适配我们的共阴极数码管,我对代码进行了全面重构。这个过程分为两个阶段。

3.1 第一阶段:初代代码 (能跑,但有Bug)

首先,写出了第一版代码进行实验。

虽然使用了 millis() 来处理扫描和计时,但在按键处理上,我故意保留了讲义中原始的 delay 和 while 等待逻辑,以便复现那个经典的 Bug。

初代完整源码(可以直接复制运行):

/*
 * 项目名称:Arduino四位数码管计时系统 (初代共阴极版)
 * 硬件配置:
 * - 段选 A-H: D2-D9
 * - 位选 COM1-4: D10-D13
 * - 按键: D1
 * 状态:存在阻塞Bug
 * 说明:按住按键不放时,数码管会熄灭
 */
// 按键定义
#define KEY_PIN 1
// 数码管段选引脚 (a, b, c, d, e, f, g, dp) -> (2, 3, 4, 5, 6, 7, 8, 9)
int ledPins[] = {2, 3, 4, 5, 6, 7, 8, 9};
// 数码管位选引脚 (千, 百, 十, 个) -> (COM1, COM2, COM3, COM4) -> (10, 11, 12, 13)
int segPins[] = {10, 11, 12, 13};
// 共阴极段码表 (0-9)
// 1(HIGH) 亮
const unsigned char DuanMa[10] = {
  0x3f, 0x06, 0x5b, 0x4f, 0x66, 0x6d, 0x7d, 0x07, 0x7f, 0x6f
};
unsigned char displayTemp[4];
bool countEnable = false;
int currentNumber = 0;
// 按键状态记录
bool lastKeyState = LOW;
bool currentKeyState = LOW;
void setup() {
  // 初始化段选
  for (int i = 0; i < 8; i++) pinMode(ledPins[i], OUTPUT);
  // 初始化位选
  for (int i = 0; i < 4; i++) {
    pinMode(segPins[i], OUTPUT);
    digitalWrite(segPins[i], HIGH); // 共阴初始关闭
  }
  pinMode(KEY_PIN, INPUT);
  updateDisplayBuffer(0);
}
// ❌ 存在问题的按键处理函数
void handleKeyPress() {
  currentKeyState = digitalRead(KEY_PIN);
  // 检测按下瞬间
  if (currentKeyState == HIGH && lastKeyState == LOW) {
    delay(20); // 阻塞1:延时消抖
    // 再次确认
    if (digitalRead(KEY_PIN) == HIGH) {
      countEnable = !countEnable;
      // ☠️ 阻塞2:死循环等待按键松开
      // 只要你不松手,程序就永远卡在这里,回不到 loop()
      while(digitalRead(KEY_PIN) == HIGH);
    }
  }
  lastKeyState = currentKeyState;
}
void loop() {
  // 1. 处理按键
  handleKeyPress();
  // 2. 计时逻辑
  static unsigned long lastTimerTime = 0;
  if(countEnable && millis() - lastTimerTime >= 1000) {
    lastTimerTime = millis();
    currentNumber = (currentNumber + 1) % 10000;
    updateDisplayBuffer(currentNumber);
  }
  // 3. 动态扫描
  // 如果 handleKeyPress 卡住了,这里就不会执行 -> 数码管熄灭
  refreshDisplay();
}
void updateDisplayBuffer(int num) {
  displayTemp[0] = DuanMa[num / 1000];
  displayTemp[1] = DuanMa[(num % 1000) / 100];
  displayTemp[2] = DuanMa[(num % 100) / 10];
  displayTemp[3] = DuanMa[num % 10];
}
void refreshDisplay() {
  static unsigned long lastScanTime = 0;
  static int currentDigit = 0;
  if(millis() - lastScanTime >= 3) {
    lastScanTime = millis();
    // 消影
    for(int i=0; i<8; i++) digitalWrite(ledPins[i], LOW);
    digitalWrite(segPins[currentDigit], HIGH); // 关位选
    currentDigit = (currentDigit + 1) % 4;
    digitalWrite(segPins[currentDigit], LOW); // 开新位选
    for(int i=0; i<8; i++) digitalWrite(ledPins[i], bitRead(displayTemp[currentDigit], i));
  }
}

Bug现象分析:

当你点按(快速按下松开)时,系统工作正常。

但是,如果你长按按键(哪怕超过几十毫秒),数码管会立刻熄灭或只显示某一位。这是因为程序卡在了 while 循环里,导致主循环 loop() 停止运行,数码管的动态扫描也随之停止。

3.2 第二阶段:优化思路 (去阻塞化)

为了修复这个问题,我们需要彻底抛弃 delay()while 等待,改用状态机思想。

优化策略:

  1. 移除 delay(20):改用 millis() 记录时间戳来判断消抖。
  2. 移除 while 等待:我们不需要等待按键松开,只需要检测按键状态的跳变沿(从 LOW 变为 HIGH 的瞬间)。

非阻塞逻辑流程图:

graph TD
    start(loop循环) --> checkBtn{检测按键};
    checkBtn -- 状态改变 --> debounce[重置消抖计时];
    checkBtn -- 稳定 > 50ms --> confirm{确认按下?};
    confirm -- Yes --> toggle[切换计时开关];
    confirm -- No --> updateState[更新按键状态];
    debounce --> timerCheck;
    toggle --> updateState;
    updateState --> timerCheck;
    timerCheck{> 1000ms?};
    timerCheck -- Yes --> addNum[数字 + 1];
    timerCheck -- No --> scanCheck;
    addNum --> scanCheck;
    scanCheck{> 3ms?};
    scanCheck -- Yes --> refresh[拉低下一位位选];
    scanCheck -- No --> loopEnd(结束);
    refresh --> loopEnd;

3.3 ✅ 最终源码(完美优化版)

以下是经过重构的完整代码,不仅适配了共阴极,还完美解决了按键阻塞问题。这个版本适合教学,注释详尽,结构清晰。

* 项目名称:Arduino四位数码管计时系统 (共阴极适配 + 非阻塞优化版)
* 硬件配置:
* - 段选 A-H: D2-D9
* - 位选 COM1-4: D10-D13
* - 按键: D1
* 修改记录:
* - [Fix] 修复了用于共阴数码管时无法点亮的问题
* - [Opt] 移除了while死循环,解决了按键按下时数码管熄灭的问题
*/
// --- 硬件引脚定义 ---
#define KEY_PIN 1
// 数码管段选引脚 (a,b,c,d,e,f,g,dp) -> D2-D9
int ledPins[] = {2, 3, 4, 5, 6, 7, 8, 9};
// 数码管位选引脚 (千, 百, 十, 个) -> D10-D13
int segPins[] = {10, 11, 12, 13};
int ledCount = 8;
int segCount = 4;
// --- 共阴数码管段码表 (0-9) ---
// ⚠️ 重点修改:共阴极是 1(HIGH) 亮,共阳极是 0(LOW) 亮
const unsigned char DuanMa[10] = {
0x3f, 0x06, 0x5b, 0x4f, 0x66, 0x6d, 0x7d, 0x07, 0x7f, 0x6f
};
// --- 全局变量 ---
unsigned char displayTemp[4]; // 显示缓冲区
bool countEnable = false;     // 计时使能标志
int currentNumber = 0;        // 当前计数值
// --- 按键相关变量 (非阻塞消抖) ---
bool lastButtonState = LOW;   // 上一次读取的按键状态
unsigned long lastDebounceTime = 0;  // 上一次去抖动时间
unsigned long debounceDelay = 50;    // 消抖延时阈值
void setup() {
// 1. 初始化段选引脚
for (int i = 0; i < ledCount; i++) {
pinMode(ledPins[i], OUTPUT);
}
// 2. 初始化位选引脚
for (int i = 0; i < segCount; i++) {
pinMode(segPins[i], OUTPUT);
// ⚠️ 共阴极初始化:位选置 HIGH (关闭)
digitalWrite(segPins[i], HIGH);
}
// 3. 初始化按键
pinMode(KEY_PIN, INPUT);
// 4. 初始化缓冲区
updateDisplayBuffer(0);
}
void loop() {
// 任务1:处理按键 (非阻塞)
handleButton();
// 任务2:处理计时逻辑 (非阻塞,每1000ms增加)
handleTimer();
// 任务3:数码管动态扫描 (需极高频率执行)
refreshDisplay();
}
/**
* @brief 按键处理函数(带状态机消抖,无阻塞)
*/
void handleButton() {
int reading = digitalRead(KEY_PIN);
// 如果状态发生改变,重置计时器
if (reading != lastButtonState) {
lastDebounceTime = millis();
}
// 只有当状态稳定保持超过延时时间,才认为有效
if ((millis() - lastDebounceTime) > debounceDelay) {
static bool stableState = LOW;
// 如果稳定状态发生了改变
if (reading != stableState) {
stableState = reading;
// 仅在按下瞬间 (Rising Edge) 触发动作
if (stableState == HIGH) {
countEnable = !countEnable;
}
}
}
lastButtonState = reading;
}
/**
* @brief 计时逻辑:每秒增加一次计数
*/
void handleTimer() {
static unsigned long lastTimerTime = 0;
if (countEnable) {
if (millis() - lastTimerTime >= 1000) {
lastTimerTime = millis();
currentNumber++;
if (currentNumber > 9999) currentNumber = 0;
updateDisplayBuffer(currentNumber);
}
} else {
lastTimerTime = millis();
}
}
/**
* @brief 更新显示缓冲区
*/
void updateDisplayBuffer(int num) {
displayTemp[0] = DuanMa[num / 1000];
displayTemp[1] = DuanMa[(num % 1000) / 100];
displayTemp[2] = DuanMa[(num % 100) / 10];
displayTemp[3] = DuanMa[num % 10];
}
/**
* @brief 数码管刷新函数 (核心逻辑)
*/
void refreshDisplay() {
static unsigned long lastScanTime = 0;
static int currentDigit = 0;
// 每3ms切换一位,频率约 83Hz
if (millis() - lastScanTime >= 3) {
lastScanTime = millis();
displaySegment(0x00); // 1. 消影 (全灭)
// ⚠️ 共阴极:拉高位选 = 关闭当前位
digitalWrite(segPins[currentDigit], HIGH);
currentDigit = (currentDigit + 1) % 4; // 切换下一位
// ⚠️ 共阴极:拉低位选 = 选中新的一位
digitalWrite(segPins[currentDigit], LOW);
displaySegment(displayTemp[currentDigit]); // 输出段码
}
}
void displaySegment(unsigned char value) {
for (int i = 0; i < 8; i++) {
// ⚠️ 共阴极:bit为1时输出HIGH点亮
digitalWrite(ledPins[i], bitRead(value, i));
}
}

3.4 极简极客版:代码瘦身

如果你已经完全掌握了原理,可能会觉得上面的代码有点“啰嗦”。下面提供一个高度浓缩的版本,利用 C++ 的特性(如范围 for 循环、直接逻辑运算)将代码量压缩到极致,功能却完全一样。适合追求代码简洁的“强迫症”玩家!

/* 极简版:非阻塞按键+数码管计时 (共阴极) */
const byte segs[] = {2,3,4,5,6,7,8,9};   // 段选 D2-D9
const byte coms[] = {10,11,12,13};       // 位选 D10-D13
const byte btn = 1;                      // 按键 D1
const byte code[] = {0x3f,0x06,0x5b,0x4f,0x66,0x6d,0x7d,0x07,0x7f,0x6f}; // 共阴码表
unsigned long t_scan=0, t_btn=0, t_count=0;
int num=0, digit=0, divArr[]={1000,100,10,1};
bool run=false, lastBtn=0;
void setup() {
  for(auto p:segs) pinMode(p, OUTPUT); // C++11 范围for循环
  for(auto p:coms) { pinMode(p, OUTPUT); digitalWrite(p, HIGH); } // 共阴初始HIGH(关)
  pinMode(btn, INPUT);
}
void loop() {
  unsigned long now = millis();
  // 1. 动态扫描 (3ms)
  if(now - t_scan > 3) {
    t_scan = now;
    digitalWrite(coms[digit], HIGH); // 关旧位
    digit = (digit + 1) % 4;         // 切新位
    // 核心一行:计算位值 -> 查表 -> 逐位输出
    byte val = code[(num / divArr[digit]) % 10];
    for(int i=0; i<8; i++) digitalWrite(segs[i], bitRead(val, i));
    digitalWrite(coms[digit], LOW);  // 开新位(共阴LOW)
  }
  // 2. 按键处理 (50ms消抖)
  bool b = digitalRead(btn);
  if(b != lastBtn && now - t_btn > 50) {
    t_btn = now;
    if(b) run = !run; // 按下瞬间翻转状态
    lastBtn = b;
  }
  // 3. 计时逻辑 (1000ms)
  if(run && now - t_count > 1000) {
    t_count = now;
    num = (num + 1) % 10000;
  }
}

4. 举一反三:实验拓展任务

4.1 基础训练:循环结构的灵活转换

虽然 for 循环最常用,但理解 whiledo...while 的执行流程同样重要。

原始 for 循环:

for(int ledpin = 0; ledpin < ledCount; ledpin++) {
    pinMode(ledPins[ledpin], OUTPUT);
}

变形 1:使用 while 循环

int ledpin = 0;
while(ledpin < ledCount) {
    pinMode(ledPins[ledpin], OUTPUT);
    ledpin++;
}

变形 2:使用 do...while 循环

int ledpin = 0;
do {
    pinMode(ledPins[ledpin], OUTPUT);
    ledpin++;
} while(ledpin < ledCount);

4.2 进阶挑战:构建十六进制计时器 (0~FFFF)

想要显示 A-F,我们需要扩充字库。

核心代码补丁:

// 1. 扩展段码表 (新增 A-F, 共阴极编码)
const unsigned char DuanMaHex[16] = {
  // 0-9
  0x3f, 0x06, 0x5b, 0x4f, 0x66, 0x6d, 0x7d, 0x07, 0x7f, 0x6f,
  // A, b, C, d, E, F
  0x77, 0x7c, 0x39, 0x5e, 0x79, 0x71
};
// 2. 修改位分离算法 (Base 16)
void updateDisplayBufferHex(unsigned int num) {
  // 范围扩大至 0 - 65535 (FFFF)
  displayTemp[0] = DuanMaHex[(num / 4096) % 16];
  displayTemp[1] = DuanMaHex[(num / 256) % 16];
  displayTemp[2] = DuanMaHex[(num / 16) % 16];
  displayTemp[3] = DuanMaHex[num % 16];
}

5. 技术深究:知其然更知其所以然

5.1 ❓ 灵魂拷问:放弃 delay() 的理由

初学者最爱问:“为什么要搞这么复杂的 millis(),直接 delay(1000) 不香吗?”

答案是:不香,甚至不能用。

单片机就像一个只能做一件事的保安。

  • 使用 delay(1000):保安在这1秒内闭上眼睛睡觉,不管按键,也不管扫描,数码管会直接熄灭。
  • 使用 millis():保安不睡觉,快速轮询,实现“分身术”,兼顾按键检测和数码管刷新。

5.2 ️ 视觉欺骗:揭秘动态扫描频率

为什么代码中设定 3ms 切换一次?

我们来算一笔账:4位数码管,每位显示3ms,扫描一轮需要 3ms * 4 = 12ms。

一秒钟能扫描多少轮?

f = 1000 m s 12 m s ≈ 83.3 H z f = \frac{1000 \mathrm{ms}}{12 \mathrm{ms}} \approx 83.3 \mathrm{Hz} f=12ms1000ms83.3Hz

电影的帧率通常才24Hz,而我们的扫描频率高达83Hz,远远超过了人眼的闪烁临界值,所以看起来画面如丝般顺滑。

6. 结语

这次实验虽然遇到了“共阴共阳”的小插曲,但正是这个插曲让我们更深刻地理解了数码管的驱动原理。编程不只是敲代码,更是解决实际硬件问题的过程。 掌握了电平逻辑反转和非阻塞编程,你已经迈过了嵌入式入门的一道大坎

000)`:保安在这1秒内闭上眼睛睡觉,不管按键,也不管扫描,数码管会直接熄灭。

  • 使用 millis():保安不睡觉,快速轮询,实现“分身术”,兼顾按键检测和数码管刷新。

5.2 ️ 视觉欺骗:揭秘动态扫描频率

为什么代码中设定 3ms 切换一次?

我们来算一笔账:4位数码管,每位显示3ms,扫描一轮需要 3ms * 4 = 12ms。

一秒钟能扫描多少轮?

f = 1000 m s 12 m s ≈ 83.3 H z f = \frac{1000 \mathrm{ms}}{12 \mathrm{ms}} \approx 83.3 \mathrm{Hz} f=12ms1000ms83.3Hz

电影的帧率通常才24Hz,而我们的扫描频率高达83Hz,远远超过了人眼的闪烁临界值,所以看起来画面如丝般顺滑。

6. 结语

这次实验虽然遇到了“共阴共阳”的小插曲,但正是这个插曲让我们更深刻地理解了数码管的驱动原理。编程不只是敲代码,更是解决实际硬件问题的过程。 掌握了电平逻辑反转和非阻塞编程,你已经迈过了嵌入式入门的一道大坎

原创不易,如果这篇博客对你有帮助,欢迎点赞、收藏⭐、关注➕!

posted @ 2026-01-13 20:53  yangykaifa  阅读(3)  评论(0)    收藏  举报