基于Arduino UNO与ILI9341的指针时钟设计与实现
2026/9/19 15:40:35 网站建设 项目流程

简介:一份适合课程设计或期末报告参考的 Arduino 硬件编程资源,核心是基于 ili9341 显示屏的时钟系统,可实时显示日期与时间并通过按钮校准。报告从电子技术、石英晶体振荡器和计算机时钟电路背景切入,明确设计目标为在屏幕上绘制尽量大的时钟表盘、包含时针分针秒针、显示日期,并使用多个按钮调节时间日期,最后完成功能验证与测试。实现部分附有系统电路图、Arduino 引脚连接(IO2-IO7 监控按钮按下时电平变化,IO9-IO10 接显示屏)和详细设计流程图,代码着重讲解 Tft 库画圆、三角函数定位表盘数字、指针联动、日期按大小月与闰年自动进位以及表盘归零等关键逻辑。压缩包仅含 1 个 docx 文档,约 132KB,适合嵌入式入门者、课程设计答辩者或 ili9341 显示项目开发者参考使用。已有 872 人学习下载,完整阅读后可快速复用其硬件接法与核心代码,对 Arduino 控制 LCD 屏会有更具体的认知。

1. 从一块 ILI9341 表盘开始的课程设计

如果只是让一块 ILI9341 屏幕亮起来,教程里的示例代码到处都是;真正麻烦的是在 240x320 的坐标空间里同时装下一个表盘、三根针和一行日期,还要让按钮在走针过程中稳定介入。这个 Arduino 课程设计做的就是这件事:用 UNO 控制 ILI9341 画出指针式表盘,再通过六个按键调节年月日时分,走字过程中日期按月份和闰年自动进位。适合两类人看:一类是拿它当模板改课设的初学者,另一类是遇到指针残影、日期越界、按键抖动这些具体问题时,想快速定位的实践者。后面所有代码都围绕这套硬件结构展开,先解决显示和输入,再谈走时逻辑。

2. 引脚分配与驱动选型:先让屏幕亮起来

ILI9341 是 4 线 SPI 接口的 TFT 屏,驱动库常见的有 Adafruit_ILI9341 和 TFT_eSPI。报告里的Tft.drawNumberTft.drawCircle这类调用说明它用的库自带数字绘制接口,这类 API 在 TFT_eSPI 里比较顺手,Adafruit 库没有现成的drawNumber。所以下面按 TFT_eSPI 的默认脚位来讲解,这套默认值正好能对上报告里“IO9 和 IO10 与 LCD 相接”的描述。

2.1 屏幕一侧的接线:CS、DC、RST 各司其职

Arduino UNO 的硬件 SPI 引脚是固定的:MOSI 在 11,SCLK 在 13,这两个脚不能随意换。CS 和 DC 则可以在库配置里指定,TFT_eSPI 对 AVR 平台的默认配置是 CS=10、DC=9、RST=8。ILI9341 排针上的 SDI 接 11,SCK 接 13,MISO 在很多纯显示项目里可以不接,因为只写不读。

ILI9341 排针Arduino UNO说明
VCC5V液晶逻辑供电,背光也由它驱动
GNDGND共地
CS10片选,低电平有效
RESET8硬件复位,最好由引脚控制
DC / RS9数据/命令选择
SDI / MOSI11SPI 数据线
SCK13SPI 时钟
LED3.3V背光正极,串接 10Ω 电阻更稳

报告里的 IO9 和 IO10 就是 DC 和 CS。如果复位脚不接,单纯靠库内部tft.begin()也能启动,但偶尔会出现初始化时序不对导致的雪花屏。把 RESET 也接到数字脚是更稳妥的做法,尤其在课程答辩现场,拔插 USB 后能不能一次点亮,往往就差这个复位信号。

2.2 按键电路:悬空引脚不等于低电平

原报告说“按钮未按下时 IO2 为低电平,按下后 IO2 变为高电平”,这个表述省略了一个关键元件:下拉电阻。按钮一端接 5V,另一端接 IO2,如果 IO2 悬空,单片机读到的电平是不确定的,手指靠近引脚都会跳动,更别提六个按钮同时存在时的串扰。要让“未按下为低”,必须在 IO2 到 GND 之间接一个 10kΩ 下拉电阻。

另一种更省元件的做法是使用单片机内部上拉:按钮一端接 GND,另一端接 IO,pinMode(pin, INPUT_PULLUP)之后,未按下读 HIGH,按下读 LOW。两种方案各有取舍,用哪种就在代码里统一哪种。

接线方式未按下电平按下电平引脚模式
5V → 按钮 → IO → 10k 下拉到 GNDLOWHIGHINPUT
IO → 按钮 → GNDHIGHLOWINPUT_PULLUP

如果直接照搬原报告的外部下拉方案,初始化时用pinMode(pin, INPUT),不要误开INPUT_PULLUP,否则下拉和内部上拉会打架,按键逻辑正好反过来。

2.3 初始化顺序与常见白屏

TFT_eSPI 的引脚配置写在库目录下的User_Setup.h里,不是写在主程序里。复现时先确认这几个宏和实际接线一致:

#define TFT_CS 10 #define TFT_DC 9 #define TFT_RST 8 #define TFT_MOSI 11 #define TFT_SCLK 13

TFT_MOSITFT_SCLK对应 SPI 的 MOSI 和 SCK,UNO 上不能改到别的脚。初始化顺序一般是先初始化按键引脚,再初始化屏幕:

#include <TFT_eSPI.h> TFT_eSPI tft = TFT_eSPI(); void setup() { Serial.begin(115200); pinMode(2, INPUT_PULLUP); pinMode(3, INPUT_PULLUP); pinMode(4, INPUT_PULLUP); pinMode(5, INPUT_PULLUP); pinMode(6, INPUT_PULLUP); pinMode(7, INPUT_PULLUP); tft.begin(); tft.setRotation(0); // 竖屏 240x320 tft.fillScreen(TFT_BLACK); }

这里用INPUT_PULLUP是按键引脚。如果白屏,先量屏的 VCC 和 GND 是否稳定,再用万用表蜂鸣档查 SDI、SCK、CS、DC 这几根线有没有接触不良。剩下最隐蔽的问题是 CS 没有被拉低,ILI9341 会一直处于禁用状态,库初始化成功但屏幕什么也不显示。用示波器或逻辑分析仪看 CS 是否在begin()后有低电平脉冲,能很快定位。

3. 表盘坐标与三角函数刻度绘制

屏幕亮起来之后,真正的算法从表盘开始。时钟表盘本质是一组极坐标点,12 个数字均匀分布在圆周上,角度间隔 30°。难点在屏幕坐标系的 y 轴方向向下,直接套用数学坐标系会把 3 点和 9 点互换、6 点和 12 点颠倒。

3.1 旋转方向决定坐标范围:横屏竖屏别混用

ILI9341 物理分辨率是 240 宽、320 高,但很多屏模块在库初始化后默认是横屏,坐标变成 320 宽、240 高。报告里写“LCD 屏的大小为 320x240”,却又把日期放到 y=260、y=280 的位置,这说明它实际工作在竖屏 240x320 模式。复现时要在tft.setRotation()上统一:用竖屏,日期坐标才能落在可视区;用横屏,就要把日期行改到 y=220 以下。

这份课程设计的表盘圆心取在 (119, 119),半径 110,并不是屏幕几何中心,而是刻意偏上,给下方的日期留出空间。这个设计是合理的,只要明白它为什么是这个坐标,就不会在改屏幕方向时把整个表盘带到屏幕外面。

3.2 12 个数字坐标的三角函数换算

设圆心为 (CX, CY),数字半径 R,第 i 个数字从 12 点方向顺时针偏移 30°。屏幕坐标中,x 向右增长,y 向下增长,所以标准圆上的点要换算成:

x = CX + R * sin(PI / 6 * i) y = CY - R * cos(PI / 6 * i)

sin负责左右偏移,cos负责上下偏移。12 点方向 i=12,sin(2π)=0cos(2π)=1,得到y = CY - R,正好在正上方;6 点方向 i=6,sin(π)=0cos(π)=-1,得到y = CY + R,在正下方。

表盘位置角度sincos相对圆心的偏移
12 点360°01
3 点90°10
6 点180°0-1
9 点270°-10

绘制函数里数字文本锚点在左上角,所以坐标还要往左上缩 5 个像素,否则 12 点方向的数字会整体偏到右下:

#define DIAL_CX 119 #define DIAL_CY 119 #define DIAL_R 110 void drawDial() { tft.fillScreen(TFT_BLACK); tft.drawCircle(DIAL_CX, DIAL_CY, DIAL_R, TFT_WHITE); tft.fillCircle(DIAL_CX, DIAL_CY, 3, TFT_RED); for (int i = 1; i <= 12; i++) { double ang = PI / 6 * i; int x = DIAL_CX + 100 * sin(ang) - 5; int y = DIAL_CY - 100 * cos(ang) - 5; tft.drawNumber(i, x, y, 2, TFT_WHITE); } }

drawNumber的签名是drawNumber(long number, int x, int y, uint8_t font, uint32_t color),这里的字体2是 TFT_eSPI 内置字体编号,数字越大字号越大。-5是文字居中补偿,换成更大字体时要重新量一下字符宽度。

提示:如果把屏幕旋转成横屏,圆心和日期坐标都要按 320x240 重新算,否则表盘偏一边,日期直接画出屏幕。报告里 y=260 的日期在横屏下完全不可见,这是复现时最容易踩的坑。

3.3 三根针的绘制顺序

秒针、分针和时针都是从圆心出发到圆周附近的一条线段。报告里秒针长度取 70,分针长度取 85,时针更短,这样可以明显区分三根针。绘制顺序其实没有严格要求,因为像素最终都会被覆盖,但擦除时有讲究:如果先擦秒针旧线,再擦分针旧线,分针旧线和秒针新线交叠的地方可能被多擦一个点。

三个旧坐标分别保存,每次刷新先画三条黑色旧线,再画三条新线,最后用红色圆点补一次圆心。补圆心非常必要,因为三根线段从圆心出发后,圆心处的像素会被反复覆盖,不补的话中心会出现一个颜色杂乱的洞。

4. 走时联动与日期进位:从循环计数到 millis()

表盘画好只是静态图案,时钟的核心在“走字”。课程报告里用循环变量模拟时间,if((k-11)%12==0) j++;表示秒针每走 12 下分针走一格,j%120再作为日期进位条件。这个思路在纯演示场景能跑,但把“当前 tick”和“进位周期”耦合在一起,循环里任何额外耗时都会改变走针节奏,调按钮时尤其明显。

4.1 计数器联动的问题在哪

原报告里没有独立的秒、分、时变量,而是靠kj两个计数器直接驱动画线坐标。秒针每秒动一次,分针每 12 秒动一次,时针本来应该每 12 分钟动一小格,但代码里j%120并没有明确单位。如果j是分针的刻度数,那j=120只代表分针转了两圈,也就是 2 小时,和“时针走两圈”差了 12 倍。

这说明课程代码的走时逻辑是演示性质,不是真实时钟。要让时钟真正可用,最直接的办法是把时间基准从“循环次数”换成millis()

4.2 用毫秒计数替代 delay 延时

millis()返回单片机从上电开始的毫秒数,用它判断 1000ms 到达一次,秒针才前进一格。这样即使主循环里执行了 2ms 的绘制,时间计数也不会明显漂移:

unsigned long lastTick = 0; uint8_t secondCnt = 0; uint8_t minuteCnt = 0; uint8_t hourCnt = 0; void loop() { unsigned long now = millis(); if (now - lastTick >= 1000) { lastTick = now; secondCnt++; if (secondCnt >= 60) { secondCnt = 0; minuteCnt++; if (minuteCnt >= 60) { minuteCnt = 0; hourCnt++; if (hourCnt >= 24) { hourCnt = 0; nextDay(); } } } refreshClockFace(secondCnt, minuteCnt, hourCnt); } scanButtons(); }

这里没有用delay(1000),因为delay会阻塞按键扫描,而且循环本身的绘图时间会叠加成误差。now - lastTick >= 1000的写法在millis()回绕时也比直接比较now更安全。secondCntminuteCnthourCnt三个变量构成从秒到日的进位链,nextDay()只在小时从 23 变成 0 时调用一次,避免重复进位。

4.3 日期进位:处理大小月和闰年

课程报告里的日期进位用了e>31和几个||条件混合,月份天数被拆成d==4||d==6||d==9||d==11和二月两组。这个做法在 2021 年这种普通年份没问题,但闰年判断只写了c%4==0,遇到 1900、2100 这类世纪年就会错。更稳的写法是维护一张月份天数表,单独处理二月:

void nextDay() { static const uint8_t daysInMonth[12] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; dd++; if (mo == 2) { bool leap = (yyyy % 4 == 0 && yyyy % 100 != 0) || (yyyy % 400 == 0); if (dd > (leap ? 29 : 28)) { dd = 1; mo++; } } else if (dd > daysInMonth[mo - 1]) { dd = 1; mo++; } if (mo > 12) { mo = 1; yyyy++; } refreshDate(); }

daysInMonth下标从 0 开始,所以读 4 月用daysInMonth[3]。闰年判断(yyyy % 4 == 0 && yyyy % 100 != 0) || (yyyy % 400 == 0)覆盖了普通闰年和世纪闰年。实际使用中,ddmoyyyy这三个变量要声明成全局变量,按钮调节也要直接改它们,改完立即调用refreshDate()刷新屏幕显示。

4.4 指针角度:分针每秒微移,时针每分钟微移

角度换成弧度制后,长针的终点坐标和画表盘数字是同一套三角函数:

void refreshClockFace(uint8_t s, uint8_t m, uint8_t h) { float sDeg = s * 6.0; float mDeg = m * 6.0 + s * 0.1; float hDeg = (h % 12) * 30.0 + m * 0.5; }

秒针每秒转 6°;分针在整分之间不是跳跃的,而是每秒走 0.1°,60 秒后刚好前进 6°;时针每小时走 30°,同时每分钟再走 0.5°,让时针和分针的视觉关系接近真实机械钟。需要把角度转成终点坐标时,mDeghDeg还要再乘PI / 180转成弧度,用sincos计算xy。擦除旧线时,保存三根针上一帧的终点坐标,先画黑色旧线,再画白色新线。

提示:旧终点坐标初始化为 (0, 0) 会导致开机第一帧从圆心向屏幕左上角画一条黑线。初始化时直接让三个旧终点指向 12 点方向,可以省去第一帧的边界判断。

5. 按钮调时、归零与运行验证

六个按钮用来调整年月日时分,外加归零。原报告里只明确写了 IO3 调月、IO4 调日、IO5 和 IO6 分别让两根针向前走、IO7 归零,IO2 可以作为年份调整预留。复现时先把程序里的引脚号和实际按钮接法对上,再考虑电平方向。

5.1 按钮映射与电平方向选择

如果采用内部上拉方案,按钮另一端接 GND,按下后读 LOW;如果坚持原报告的高电平触发,就必须配外部下拉电阻,并关闭内部上拉。两种方案不要混用,否则六个按钮会出现“有的按下才动作,有的松开才动作”的怪现象。

IO 引脚功能按下含义
IO2年加 1边界回绕到初始年
IO3月加 112 月后回 1 月
IO4日加 1超出当月天数后回 1 日
IO5时针向前走驱动时针指针变化
IO6分针向前走驱动分针指针变化
IO7归零所有时间变量复位

按钮调时最大的问题是抖动。机械按键按下瞬间会产生几毫秒到几十毫秒的电平抖动,直接读digitalRead经常一次按下会连续执行五六次加一。可以用上升沿检测加二次确认来消抖:

bool btnState[6]; const uint8_t btnPin[6] = {2, 3, 4, 5, 6, 7}; void scanButtons() { for (uint8_t i = 0; i < 6; i++) { bool now = digitalRead(btnPin[i]) == LOW; if (now && !btnState[i]) { handleButton(i); } btnState[i] = now; } }

btnState记录上一轮电平,检测到从 HIGH 到 LOW 的下降沿时只触发一次handleButton。按住不松手不会连续进位,适合课程答辩时精确调时。如果想长按连续加,就在按钮仍为 LOW 时用millis()做 200ms 间隔重复触发,不要把delay放进这个循环,否则按键扫描会被自身阻塞。

5.2 归零:break 并不是复位

原报告用if(HIGH==digitalRead(7)) break;,这个操作只是跳出当前循环,接着loop()还会继续走字。体现在表盘上就是“归零点了一下,指针停了一瞬又继续走”。正确的归零要把所有时间变量、旧坐标和屏幕内容一起重置:

void resetClock() { secondCnt = 0; minuteCnt = 0; hourCnt = 0; yyyy = 2021; mo = 6; dd = 13; oldSecX = DIAL_CX; oldSecY = DIAL_CY - 70; oldMinX = DIAL_CX; oldMinY = DIAL_CY - 85; oldHourX = DIAL_CX; oldHourY = DIAL_CY - 55; tft.fillScreen(TFT_BLACK); drawDial(); refreshDate(); }

旧坐标同时复位到三根针在 12 点方向的终点,是为了避免复位后第一帧擦除旧线时画出斜向黑线。tft.fillScreen已经清掉了所有残留,旧坐标必须和新绘制坐标一致,否则下一帧会先擦掉正确位置之外的像素。

5.3 上电验证清单

把程序烧进去后,不要急着调时间,按下面的顺序观察现象:

现象检查方向最可能原因
白屏接线和供电CS/DC 接反、USB 供电不足
黑屏但有背光初始化代码tft.begin()没执行或库配置错
数字位置错乱坐标换算角度转弧度遗漏、圆心的-5补偿没算
指针有残影擦除顺序旧坐标更新时机不对
按键“一次加多个”消抖逻辑缺少上升沿或下降沿检测
日期不显示屏幕方向横屏时 y=260 已超出 240 高度

这套验证顺序从底层到应用,能快速区分硬件接线、驱动库、坐标算法和逻辑层的问题。按键无效时,先用串口打印digitalRead的原始电平,确认按下瞬间引脚读数变化,再去查代码,不要一开始就怀疑屏幕。

6. 进阶:把演示时钟改造成可日常使用的时钟

课程设计做到能走字、能调时,基本就达到验收标准了。但如果你手里有 DS3231 这类 I2C 接口的 RTC 模块,可以低成本把“上电从零开始走”改成“断电后仍然准确”。UNO 的 A4 接 SDA,A5 接 SCL,模块供电接 5V,代码里初始化一次时间源,之后每秒读取一次:

#include <RTClib.h> RTC_DS3231 rtc; void setup() { rtc.begin(); } void readFromRTC() { DateTime now = rtc.now(); secondCnt = now.second(); minuteCnt = now.minute(); hourCnt = now.hour(); yyyy = now.year(); mo = now.month(); dd = now.day(); refreshDate(); }

DateTime对象每调用一次rtc.now()就读一次硬件时钟,不需要像millis()那样自己在循环里数秒。这样loop()可以每 50ms 刷新一次屏幕,秒针仍然按真实秒数跳到下一格,断电后靠模块电池维持时间。如果不想加硬件,另一个实用技巧是让秒针连续移动而不是一秒一跳:float fsec = (millis() % 60000) / 1000.0;,用这个浮点值乘以 6 得到秒针角度,动画看起来会平滑很多。

屏幕背光也可以从 3.3V 固定供电改成 PWM 可控:把 LED 引脚接到 UNO 的 9 号脚,用analogWrite(9, 128)调亮度,夜间演示时不会刺眼。前提是 LED 引脚的驱动电路允许 PWM 输入,大多数 ILI9341 模块直接支持。最后再检查一次日期坐标:横屏 320x240 时,报告里的 y=260 和 y=280 全部超出可视区,竖屏 240x320 时则要把日期行留在表盘下方 y=260 附近。把这几处坐标和消抖逻辑改完,这块表就能稳定跑完整个展示周期。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询