【Arduino】四位数码管按键计时系统(共阴极踩坑与代码优化) - 指南
摘要:本文记录了一次“惊心动魄”的实验过程。原本参照网上的教程中共阳极数码管教程编写代码,结果发现手头的硬件竟是共阴极的!本文将详细介绍如何通过反转电平逻辑来适配共阴极数码管,同时通过对比原始阻塞代码与优化后的非阻塞代码,深入讲解如何解决“按键按下导致数码管熄灭”的问题,最终实现一个稳定、流畅的计时系统。
1. ✨ 实验效果预览
- 初始状态:系统上电后,四位数码管静止显示
0000。 - 启动计时:按下按键,系统立即响应开始计时,末位数字每秒跳动一次(
0000->0001…)。 - 暂停计时:再次按下按键,计时瞬间停止,数字定格。
- 视觉体验:无论按键操作如何频繁,数码管的显示始终稳定、无闪烁、无残影。

2. ️ 硬件准备与“避坑”指南
2.1 必备硬件清单
- Arduino Uno控制器:1块
- 四位共阴数码管:1个(关键点:讲义教程多为共阳,如果你手头的是共阴,必须修改代码逻辑!)
- 按键开关:1个
- 10kΩ 电阻:1个(下拉电阻)
- 面包板及跳线:若干
2.2 ⚡ 核心避坑:共阴 vs 共阳
在实验过程中,我发现第一次进行实验时,数码管完全不亮。经过排查,原来是极性搞反了。
- 共阳极 (Common Anode):
- 位选(公共端):接 VCC。选通时给 HIGH。
- 段选(a-dp):接 GND 点亮。输出 LOW 为亮。
- 共阴极 (Common Cathode):
- 位选(公共端):接 GND。选通时需给 LOW(拉低电平)。
- 段选(a-dp):接 VCC 点亮。输出 HIGH 为亮。
结论:我们要把讲义代码中所有的
HIGH和LOW逻辑全部反转,才能点亮共阴极数码管。
2.3 接线图示(根据实际配置)
我的实际接线配置如下:
段选引脚 (SEG A - SEG H):连接至 D2 - D9。
位选引脚 (COM1 - COM4):连接至 D10 - D13。
控制按键:连接至 D1 引脚。
(注意:D1也是串口TX引脚,上传代码时若遇到问题请先断开按键连接)
2.4 核心控制逻辑
2.4.1 动态扫描 (Dynamic Scanning)
由于四位数码管共用了段选引脚,物理上我们无法同时让四位显示不同的数字。动态扫描利用了人眼的视觉暂留效应:
- 第1ms:拉低第一位位选(选中),输出数字段码。
- 第2ms:拉高第一位(关闭),拉低第二位(选中),输出数字段码。
- 循环往复:频率 > 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 等待,改用状态机思想。
优化策略:
- 移除
delay(20):改用millis()记录时间戳来判断消抖。 - 移除
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 循环最常用,但理解 while 和 do...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=12ms1000ms≈83.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=12ms1000ms≈83.3Hz
电影的帧率通常才24Hz,而我们的扫描频率高达83Hz,远远超过了人眼的闪烁临界值,所以看起来画面如丝般顺滑。
6. 结语
这次实验虽然遇到了“共阴共阳”的小插曲,但正是这个插曲让我们更深刻地理解了数码管的驱动原理。编程不只是敲代码,更是解决实际硬件问题的过程。 掌握了电平逻辑反转和非阻塞编程,你已经迈过了嵌入式入门的一道大坎
原创不易,如果这篇博客对你有帮助,欢迎点赞、收藏⭐、关注➕!


浙公网安备 33010602011771号