☰
STM32F103ZET6万年历项目实战:RTC走时、农历算法与TFT局部刷新
2026/10/30 3:07:52 网站建设 项目流程

简介:这是一份面向STM32初学者与嵌入式课程实践者的万年历项目资料,以STM32F103ZET6最小系统为主控,通过片内RTC实现日期与时间计时,并借助7针SPI接口驱动OLED显示。项目重点在于理解RTC计数器读写、时间日期换算及修改计数器重设当前时间的方法,适合用作课程设计、毕业设计原型或外设综合练习。压缩包共205个文件、约5.27MB,包含C/H源码、Keil工程与配置、HEX烧录文件、MAP/LST映射列表、SCT分散加载文件及编译中间文件,可据此还原完整工程、查看资源占用并复现烧录调试过程。现有1203人学习下载,便于对照源码梳理OLED显示、RTC初始化与最小系统搭建思路,并积累外设驱动与工程配置经验。

1. 抽屉里那块 ZET6,除了点灯还能干点正经事

F103ZET6 大概是很多人手上第一块 Cortex-M3 的板子,跑完按键点灯、串口打印之后就卡在那儿了,不知道下一步该拿它练什么。做一块 stm32 万年历是我见过性价比最高的选择:一块 TFT 彩屏、一颗 32.768kHz 晶振、两颗按键,屏幕上要同时显示公历、农历、星期、节气、温度,按键能进设置态改时间、设闹钟,拔掉电源再插上日期还在。这个工程把 GPIO、FSMC、RTC、定时器、I2C、SPI、单总线全部串在一起,体量不大但每个环节都能较真。它适合刚学完外设想找一个完整项目练手的人,也适合想给板子加个桌面时钟、需要一份能直接拿来改的代码的人。下面按我拆包的实际顺序讲。

2. 工程骨架与硬件资源:ZET6 上跑一个万年历到底要占哪些外设

拿到压缩包第一件事不是编译,是先看清楚它把什么东西塞进了 512KB Flash 和 64KB SRAM。万年历这个应用看着简单,真正吃资源的是字库和界面缓存,而不是逻辑代码。先把目录和引脚捋清楚,后面调试的时候才知道该往哪儿看。

2.1 目录结构与模块划分

这套工程用的是标准外设库那一套分层方式,不是 CubeMX 自动生成的树。分层思路很朴素:底层驱动、板级硬件、业务逻辑三层,业务层不直接碰寄存器,硬件层不关心农历怎么算。

目录关键文件职责
USERmain.c、stm32f10x_it.c、system_stm32f10x.c启动流程、主循环、中断向量
SYSTEMdelay.c、sys.c、usart.cSysTick 延时、串口重定向到 printf
HARDWARElcd.c、key.c、rtc.c、timer.c、24c02.c、w25qxx.c、ds18b20.c板级外设驱动,一个外设一对文件
APPcalendar.c、ui.c、alarm.c公历转农历、界面刷新、闹钟判断
FONTfont.h、ascii.h8x16 ASCII 与 16x16 汉字点阵数组

calendar.c是唯一一个纯算法文件,里面没有任何寄存器操作,只吃结构体吐结构体。这一点在看代码时挺重要,因为农历算错了你只需要盯着这一个文件,不用怀疑是不是屏幕刷错了。ui.c则是唯一允许调用LCD_*系列函数的地方,其他模块想显示东西都得通过它转发,不然你改个界面要翻遍整个工程。

字库放在FONT目录下是最省事的做法,直接编译进内部 Flash。代价是font.h动辄几百 KB,如果还想放图片就爆了。所以更常见的做法是:ASCII 点阵留在内部 Flash(只有几 KB),汉字点阵烧到板载 SPI Flash 里,上电时按需读取。这套工程两种都支持,w25qxx.c就是给后者准备的。

2.2 引脚与外设分配

ZET6 是 144 脚封装,引脚够多,但 FSMC 一旦用上就会占掉一大片,分配的时候要先把 FSMC 钉死,剩下的再分给别的外设。下面是这套工程实际用到的映射,不同板子可能略有出入,改的时候对着原理图核一遍。

外设引脚说明
TFT 数据线 D0~D15PD14/PD15/PD0/PD1、PE7~PE15FSMC 数据总线,16 位并口
TFT 片选 CSPD7FSMC_NE1,映射到 0x6C000000
TFT 命令/数据 RSPD11FSMC_A16,靠地址线区分命令和数据
TFT 写/读PD5 / PD4FSMC_NWE / FSMC_NOE
TFT 背光PB0普通推挽输出,也可以走定时器 PWM 调光
TFT 复位PG15普通 GPIO,上电拉低再拉高
按键 KEY0~KEY3PE2~PE5上拉输入,低电平有效
蜂鸣器PB8三极管驱动,高电平响
EEPROM 24C02PB6 / PB7I2C1,存闹钟和用户设置
SPI Flash W25Q64PB12~PB15SPI2,存汉字点阵与图片
温度 DS18B20PA1单总线,需要 4.7k 上拉
调试串口PA9 / PA10USART1,115200
RTC 晶振PC14 / PC15LSE,32.768kHz
后备电源VBAT纽扣电池,走时靠它

有两个地方容易翻车。第一,PB6/PB7 做 I2C1 的同时,如果板子把它们引到了别的外设上,I2C 就废了,得改用软件模拟。第二,FSMC 用了 PD 和 PE 大半个端口,如果你还想在这两个口上接别的中断源,先查复用表,别等焊完了才发现冲突。

2.3 上电初始化顺序

main.c里的初始化顺序不是随便排的,顺序错了会出现各种莫名其妙的症状,比如屏幕亮但全是雪花、串口打印不出来、时间每次上电都被重置。这套工程按下面这个次序走。

int main(void) { NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); /* 2 位抢占 + 2 位子优先级 */ delay_init(); /* SysTick 配成 1ms,后面所有延时都靠它 */ uart_init(115200); /* 先开串口,后面调试全靠它输出 */ LED_Init(); KEY_Init(); Buzzer_Init(); I2C_Init(); /* 24C02,读出上次保存的闹钟和亮度 */ W25QXX_Init(); /* SPI Flash,字库要从这里读 */ LCD_Init(); /* FSMC + 屏初始化序列,最耗时的一步 */ DS18B20_Init(); RTC_Init(); /* 内部 RTC,依赖 LSE 起振 */ RTC_Get_Calendar(&g_now); Calendar_Lunar(&g_now, &g_lunar); UI_Init(); /* 画静态部分:边框、标题、固定文字 */ UI_RefreshAll(&g_now, &g_lunar); while (1) { Key_Scan(); /* 非阻塞扫描,内部做消抖 */ App_Task(); /* 秒标志置位后刷新时间、查闹钟 */ } }

这段代码的要点在顺序本身。delay_init()必须排在最前面,因为后面LCD_Init()里全是delay_ms(),而delay_ms()依赖 SysTick 配置完成。串口排在显示前面是我的习惯,屏还没点亮的时候你至少能从串口看到走到哪一步了,不用靠猜。

RTC_Init()放在LCD_Init()之后是有意为之。LSE 起振有时候要几百毫秒甚至更久,如果放在前面,用户看到的就是一段时间的黑屏,会以为板子坏了。放在屏初始化之后,至少屏幕先亮起来,然后时间再跳出来。当然代价是屏幕刚亮的时候时间栏是空的,需要UI_RefreshAll()补一次全刷。

UI_Init()和UI_RefreshAll()分开也是有讲究的。前者只画那些永远不变的东西(边框、标签文字、"农历"这两个字),后者画会变的值。如果你的界面在秒跳的时候整屏闪一下,八成是把这两步合成一步了。

3. 走时与显示链路:RTC 秒中断怎么把时间送到屏幕上

万年历的心脏是 RTC。这一块如果没搞明白,后面所有功能都是空中楼阁。F103 的 RTC 和 F4 完全不是一个东西:F4 有独立的 BCD 日历寄存器,读出来直接就是年月日时分秒;F103 只有一个 32 位的秒计数器,年月日全靠软件换算。这个差异决定了整个工程的写法。

3.1 RTC 与备份域:让时间在断电后活下来

F103 的 RTC 挂在备份域上,由 VBAT 供电,主电源断了它还在跑。但备份域默认是锁着的,不解锁你写什么都被忽略,而且不会报错——这是新手最容易卡住的地方。

/* 返回 0 正常,1 首次上电已配置,2 LSE 起振失败 */ uint8_t RTC_Init(void) { uint32_t timeout = 0; RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR | RCC_APB1Periph_BKP, ENABLE); PWR_BackupAccessCmd(ENABLE); /* 解锁备份域,不写这一行后面全白搭 */ if (BKP_ReadBackupRegister(BKP_DR1) != 0xA5A5) { /* 备份寄存器被清空,说明是第一次上电或电池掉过 */ RCC_LSEConfig(RCC_LSE_ON); while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) == RESET) { if (++timeout > 0xFFFFF) { /* 别写死循环,起振失败要能退出来 */ return 2; } } RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE); RCC_RTCCLKCmd(ENABLE); RTC_WaitForSynchro(); /* 等 RSF 置位,寄存器同步完成 */ RTC_WaitForLastTask(); RTC_SetPrescaler(32767); /* 32768 / (32767 + 1) = 1Hz */ RTC_WaitForLastTask(); RTC_SetCounter(0); /* 后续由 UI 层写入真实时间 */ RTC_WaitForLastTask(); BKP_WriteBackupRegister(BKP_DR1, 0xA5A5); /* 打标记,下次上电跳过配置 */ } else { RTC_WaitForSynchro(); RTC_WaitForLastTask(); } return 0; }

BKP_DR1里那个0xA5A5是整个掉电保持的关键。没有它,每次上电都会重新配置 RTC 并把计数器清零,表现出来就是"电池装了但时间还是归零"。这个标记位只要 VBAT 有电就不会丢,所以换主电池不影响,只有拔掉纽扣电池才会失效。

RTC_SetPrescaler(32767)这个参数也值得说一句。预分频寄存器写的是 32767 而不是 32768,因为硬件是"计数值 = 预分频 + 1"。写成 32768 的话,时钟会慢一半,一天差出好几个小时,而且这种错误很难第一眼发现,因为秒还是在跳,只是跳得慢。

3.2 秒中断里只做一件事

RTC 配好之后,让它每秒产生一次中断,中断里绝对不能干重活。原因很直接:FSMC 刷一个 16x16 汉字要写 32 次总线,一次全屏刷新几十毫秒,如果在中断里刷屏,主循环里的按键扫描和串口处理全被堵死,表现出来就是按键没反应、屏幕一顿一顿。

/* RTC 全局中断服务函数,stm32f10x_it.c 里 */ void RTC_IRQHandler(void) { if (RTC_GetITStatus(RTC_IT_SEC) != RESET) { RTC_ClearITPendingBit(RTC_IT_SEC); RTC_WaitForLastTask(); /* 清中断后必须等写操作完成 */ g_sec_flag = 1; /* 只置一个标志,立即退出 */ } }

中断里就三行有效代码,剩下的全交给主循环。主循环那边的处理长这样:

void App_Task(void) { if (g_sec_flag == 0) return; g_sec_flag = 0; RTC_Get_Calendar(&g_now); /* 秒计数 -> 年月日时分秒 */ Calendar_Lunar(&g_now, &g_lunar); /* 公历 -> 农历、节气 */ UI_Refresh_Time(&g_now); /* 只刷变化的数字 */ UI_Refresh_Lunar(&g_lunar); /* 农历一天只变一次,内部有判断 */ if (Alarm_Check(&g_now)) { /* 闹钟匹配 */ Buzzer_Beep(3); } }

UI_Refresh_Lunar()内部会比对上一次的农历日期,只有跨天才重绘。农历那一行有十几二十个汉字,每秒刷一次的话屏幕闪得没法看,而且完全没有必要。同理,温度的刷新周期是 2 秒一次,跟秒中断解耦,用独立的计数变量控制。

3.3 TFT 局部刷新与字库取模

界面刷新这块,能用局部刷新就别整屏重绘。日期 "2025-06-08" 一共 10 个字符,一秒里真正变的只有最后两位秒数,其他八个字符一动不动,全刷就是纯浪费。

/* 两位数字的局部刷新:值没变直接返回,变了先擦后写 */ void UI_ShowNum_Update(uint16_t x, uint16_t y, uint8_t oldv, uint8_t newv, uint16_t color, uint16_t back) { if (oldv == newv) return; LCD_Fill(x, y, x + 16 - 1, y + 24 - 1, back); /* 按 16x24 字符宽度擦除 */ LCD_ShowNum(x, y, newv, 2, 24, color); }

这个函数看着简单,但有两个参数必须严格对齐:擦除矩形的宽度要等于两个数字的实际像素宽,高度要等于字模高度。宽度算少了会残留上一帧的笔画,算多了会把旁边的分隔符一起擦掉。我一般的做法是把字模尺寸定义成宏,擦除和绘制都引用同一个宏,改字号的时候只改一处。

字库这块,ASCII 用 8x16 或者 16x24 的点阵数组,直接const在 Flash 里。汉字用 16x16 宋体取模,取模软件的设置要选"逐行式、高位在前",跟LCD_ShowChinese()里的移位逻辑对上,不然显示出来是乱码或者阴阳字。

/* 汉字点阵存放在外部 SPI Flash,index 是字在字库里的序号 */ void LCD_ShowChinese(uint16_t x, uint16_t y, uint8_t index, uint16_t color, uint16_t back) { uint8_t buf[32]; /* 16x16 点阵 = 32 字节 */ W25QXX_Read(buf, FONT_BASE_ADDR + index * 32, 32); for (uint8_t row = 0; row < 16; row++) { uint8_t hi = buf[row * 2]; uint8_t lo = buf[row * 2 + 1]; for (uint8_t col = 0; col < 16; col++) { /* 高位在前,col < 8 取 hi,否则取 lo */ uint8_t bit = (col < 8) ? ((hi >> (7 - col)) & 1) : ((lo >> (15 - col)) & 1); LCD_DrawPoint(x + col, y + row, bit ? color : back); } } }

FONT_BASE_ADDR是字库在 SPI Flash 里的起始地址,一般是 0 或者某个扇区边界,烧录字库的时候要跟这个地址对上。index是"这个字在字库里的第几个",不是 GB2312 编码,所以需要一个查找表或者区位码换算。用区位码换算的好处是不用维护映射表,坏处是字库里必须按 GB2312 顺序排满,文件会大不少。

逐点绘制 16x16 的汉字要 256 次LCD_DrawPoint(),每个点又是一次 FSMC 写,速度其实不快。界面上汉字不多的话感觉不出来,真要刷一整行就得改成开窗加连续写,把LCD_DrawPoint换成一次设置窗口再一次灌 32 字节颜色数据,能快好几倍。

4. 日历算法:公历转农历、节气推算与节假日标注

硬件跑通了,剩下的就是算法。公历转农历这块网上的代码一大堆,但能直接用、边界不出错的并不多。核心是一个两百来号元素的十六进制数组,每个 bit 都有含义,写错一位就可能在某一年某一个月差一天,而且这种 bug 往往几个月才暴露一次。

4.1 农历查表法的数据结构

农历没法用公式算,因为它是观测加人为规定的,只能查表。表的结构是:从 1900 年到 2100 年,每年用一个 32 位整数编码,低 4 位是闰哪个月(0 表示不闰),第 5 到 16 位是 12 个月的大小月(1 为大月 30 天),第 17 位是闰月的天数。

/* 1900 ~ 2100,每年一个编码 */ static const uint32_t lunarInfo[201] = { 0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, /* ... 中间省略,完整表格 201 项 ... */ 0x0d520 }; /* 这一年闰几月,返回 0 表示不闰 */ static uint8_t Lunar_LeapMonth(uint16_t y) { return (uint8_t)(lunarInfo[y - 1900] & 0x0f); } /* 这一年闰月有多少天,没有闰月返回 0 */ static uint8_t Lunar_LeapDays(uint16_t y) { if (Lunar_LeapMonth(y) == 0) return 0; return (lunarInfo[y - 1900] & 0x10000) ? 30 : 29; } /* 这一年第 m 月(1~12)有多少天,不含闰月 */ static uint8_t Lunar_MonthDays(uint16_t y, uint8_t m) { return (lunarInfo[y - 1900] & (0x10000 >> m)) ? 30 : 29; } /* 这一农历年总共多少天,闰月要额外加 */ static uint16_t Lunar_YearDays(uint16_t y) { uint16_t sum = 348; /* 12 个月按 29 天算的底数 */ for (uint8_t i = 0x8000; i > 0x8; i >>= 1) { if (lunarInfo[y - 1900] & i) sum++; /* 每个大月补 1 天 */ } return sum + Lunar_LeapDays(y); }

基准点必须记牢:1900 年 1 月 31 日是农历 1900 年正月初一。所有换算都是先算公历日期距这一天的天数,再一年一年往下减。

int Solar2Lunar(const solar_t *s, lunar_t *l) { if (s->year < 1900 || s->year > 2100) return -1; /* 超出表范围 */ uint32_t offset = DaysFrom1900(s->year, s->month, s->day); uint16_t i, yearDays = 0; for (i = 1900; i < 2101 && offset > 0; i++) { yearDays = Lunar_YearDays(i); offset -= yearDays; } if (offset < 0) { offset += yearDays; i--; } /* 减多了退回来一年 */ l->year = i; uint8_t leap = Lunar_LeapMonth(i); uint8_t isLeap = 0; for (uint8_t m = 1; m <= 12 && offset > 0; m++) { /* 到闰月的位置了,先处理闰月 */ if (leap > 0 && m == leap + 1 && isLeap == 0) { --m; isLeap = 1; yearDays = Lunar_LeapDays(i); } else { yearDays = Lunar_MonthDays(i, m); } offset -= yearDays; if (offset < 0) { offset += yearDays; break; } } l->month = ...; /* 由循环里的 m 和 isLeap 决定 */ l->day = offset + 1; l->is_leap = isLeap; return 0; }

这段循环的坑在闰月那一段。--m配合isLeap标志的作用是:减完闰月之后,m要回到原来的月份继续往下走,否则后面所有月份都会错位。我第一次写的时候漏了--m,结果 2023 年闰二月之后的所有日期全部错一个月,测了好几天才定位到。

4.2 节气用通用公式还是查表

节气有两种做法:查表和公式。查表是把 1900 到 2100 每年 24 个节气的日期全部存下来,一共 4824 个字节,塞进 Flash 毫无压力,绝对不会错。公式是通用算法,代码短,但个别年份要打补丁。

/* 21 世纪 24 节气的 C 值,顺序:小寒、大寒、立春……冬至 */ static const float sTermC[24] = { 5.4055f, 20.12f, 3.87f, 18.73f, 5.63f, 20.646f, 4.81f, 20.1f, 5.52f, 21.04f, 5.678f, 21.37f, 7.108f, 22.83f, 7.5f, 23.13f, 7.646f, 23.042f, 8.318f, 23.438f, 7.438f, 22.36f, 7.18f, 21.94f }; /* 返回某年第 idx 个节气(0 起)是几号,Y 取年份后两位 */ uint8_t SolarTerm_Day(uint16_t year, uint8_t idx) { uint8_t y = year % 100; float v = y * 0.2422f + sTermC[idx] - (y / 4); return (uint8_t)(int)v; }

公式的原理是节气在回归年上的位置基本固定,每年推进 0.2422 天,闰年补回来一天。这个算法在多数年份准确,但存在少数例外,比如 2026 年的小寒、2084 年的大雪都需要 +1 天修正。项目里如果想省事,就加一张例外表:

/* 少数年份需要微调的,按 {年份, 节气序号, 修正值} 存 */ static const int8_t sTermFix[][3] = { { 2026, 0, +1 }, { 2084, 20, +1 }, /* ... */ };

我的一般选择是:如果界面只显示当前节气和下一个节气,用公式加例外表足够了;如果要做节气日历,一天不差地列出来,那就老老实实查表,四千多字节换一份永远不会错的数据,很划算。

4.3 星期、闰年与界面上的"今天"

星期用蔡勒公式或者基姆拉尔森公式都行,一行就能算出来。要注意的是 C 语言里负数取模和数学上的取模不是一回事,公式算出来的中间结果可能是负的。

/* 返回 0=周日,1=周一 …… 6=周六 */ uint8_t Weekday(uint16_t y, uint8_t m, uint8_t d) { if (m < 3) { m += 12; y -= 1; } /* 1、2 月按上一年的 13、14 月算 */ uint16_t c = y / 100; uint16_t yy = y % 100; int16_t w = (c / 4) - (2 * c) + yy + (yy / 4) + (13 * (m + 1) / 5) + d - 1; w %= 7; if (w < 0) w += 7; /* 处理负数,别省这一步 */ return (uint8_t)w; }

闰年判断就一行:(y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)。看着简单,但凡是涉及到"2 月 29 日"的界面都要特别注意,尤其是闹钟功能:如果用户在 2 月 29 日设了一个每天响的闹钟,到了平年这天就没有 2 月 29 日,逻辑里要决定是跳过还是顺延到 3 月 1 日。这套工程的做法是跳过,界面上加一行小字提示,比默默顺延用户体验好。

5. 避坑排查:不走时、花屏、农历错一天这 5 个现场问题

下面这几条是我在实际调试中反复遇到的,按出现频率排。每一条都写清楚现象、原因和改法,遇到类似症状可以直接对照着查。

5.1 装上电池了,一断电时间还是归零

现象:拔掉 USB 再插上,屏幕上显示的还是 2000-01-01 00:00:00,说明 RTC 被重新配置了。

原因有三种,按概率排:一是 VBAT 引脚根本没接电池,或者电池座虚焊,万用表量一下 VBAT 对地电压,正常应该在 3V 左右;二是BKP_DR1的标记没写进去,或者写进去了但读的时候没解锁备份域;三是备份域被意外复位过,比如代码里调用了RCC_DeInit()或者上电时序里电源跌落导致备份域掉电。

解决:先量电压,再看代码。PWR_BackupAccessCmd(ENABLE)必须在读写 BKP 之前调用,而且 RTC 配置完之后要确保RTC_WaitForLastTask()返回了再写标记位,否则写操作可能被后面的复位打断。

PWR_BackupAccessCmd(ENABLE); if (BKP_ReadBackupRegister(BKP_DR1) != 0xA5A5) { /* 只有确实没有标记时才重配 RTC */ }

还有一种隐蔽情况:板子上 VBAT 和 3.3V 之间有个二极管,方向焊反了,主电源一断 VBAT 也跟着掉。这种只能对着原理图查。

5.2 时间跳秒时屏幕闪一下,按键也跟着卡

现象:每到整秒,屏幕上的秒数更新时会有一瞬间的白闪,同时按按键没那么灵敏了,偶尔要按两下才有反应。

原因:秒中断里直接调用了LCD_ShowNum()甚至UI_RefreshAll()。FSMC 写屏是阻塞的,刷一个汉字要几十条总线操作,在中断里干这个,主循环的按键扫描被拖慢,消抖逻辑的计数就对不上了。

解决:中断里只置标志位,所有刷屏动作放到主循环。这一点前面 3.2 已经说了,但实际改的时候还要注意一个细节:如果主循环里有其他地方耗时很长(比如读 DS18B20 要 750ms 转换时间),秒标志会堆积。所以g_sec_flag用uint8_t置位而不是递增,主循环处理完清一次,丢一两帧也不影响显示。

5.3 农历日期总是差一天,或者闰月年份全乱

现象:公历显示 6 月 8 日,农历显示五月初四,但手机上一查是五月初三,差一天。

原因:基准日算错了。DaysFrom1900()里如果按"1900 年 1 月 1 日"作为起点,而表里的数据是按"1900 年 1 月 31 日"编的,就会整体差 30 天,表现出来就是差一天(因为农历月有 29/30 天,30 天的偏移正好错开一个月)。另一种情况是闰月处理漏了--m,导致闰月之后所有月份错位。

解决:写一个自检函数,把 1900 到 2100 年每一年的春节日期跟已知数据对一遍,全部对得上再往下做。我一般的习惯是在calendar.c里留一个Calendar_SelfTest(),上电时用串口跑一次,对不上就打印出错的年份。

提示:农历表的最后一位(2100 年)很容易被截断,lunarInfo[200]一定要有值,否则 2100 年会读到数组外面的野数据,症状是那一年显示成乱七八糟的日期。

5.4 屏幕花屏、白屏,或者颜色反了

现象:上电后屏幕全白,或者显示内容但颜色是反的(该红的地方是蓝),再或者内容只在半个屏幕上。

原因基本都在 FSMC 配置和地址映射上。A16 接 RS 的情况下,命令地址是0x6C000000,数据地址是0x6C01FFFE,注意是FFFE不是0000——因为 16 位数据宽度下,地址线是右移一位对齐的,A16 对应到 HADDR 的 bit17,所以偏移是0x20000,再减 2 得到0x1FFFE。写成0x6C020000的话,写数据的时候实际是在写命令,屏幕就不动了。

#define LCD_REG (*(volatile uint16_t *)0x6C000000) /* RS = 0,写命令 */ #define LCD_RAM (*(volatile uint16_t *)0x6C01FFFE) /* RS = 1,写数据 */

颜色反了通常是初始化序列里的 MADCTL 或者像素格式设置不对,RGB 和 BGR 反了,改一个寄存器就行。花屏则多半是 FSMC 时序太快,把FSMC_DataSetupTime从 0x03 加到 0x05 试试,宁可慢一点也别花屏。

5.5 RTC 每天快几秒或者慢几秒

现象:刚对完时,跑一天下来差个三五秒,跑一周差半分钟。

原因:32.768kHz 晶振的负载电容匹配不好,或者晶振本身精度不够。开发板上配的通常是 6pF 或者 12.5pF 的晶振,电容焊错了频率就偏,一天差几秒很正常。

解决分两层。硬件层面,换精度更高的晶振,或者用可调电容调;软件层面可以做补偿:统计一段时间的偏差,然后在 RTC 计数器上做周期性加减。

/* 每 N 秒补偿一次:慢了就加 1 秒,快了就减 1 秒,N 由实测偏差算出 */ void RTC_Drift_Compensate(void) { static uint16_t cnt = 0; if (++cnt < DRIFT_PERIOD) return; /* 比如 86400,一天补一次 */ cnt = 0; if (g_drift_dir > 0) { RTC_SetCounter(RTC_GetCounter() + 1); /* 慢,补一秒 */ } else if (g_drift_dir < 0) { RTC_SetCounter(RTC_GetCounter() - 1); /* 快,扣一秒 */ } RTC_WaitForLastTask(); }

这个补偿是玄学改命,治标不治本,一天差超过 10 秒还是老老实实换晶振。另外补偿不要在秒中断里做,RTC_SetCounter有写保护流程,放主循环里更稳。

6. 二次开发:换屏、加传感器、改界面的改动顺序

这套工程最常被改的三个地方是换屏幕、加温度显示、重做界面。改动顺序搞反了会很痛苦,我的习惯是从"不影响现有功能"的地方开始动。

换屏最先改的是分辨率宏和 FSMC 地址。如果新屏还是 ILI9341 兼容的,只是尺寸从 2.8 寸变成 3.5 寸,那就只改LCD_W和LCD_H两个宏,再检查一遍界面坐标有没有越界。如果换了驱动 IC,初始化序列要整个换掉,但LCD_DrawPoint、LCD_Fill、LCD_ShowChar这几个上层接口保持不动,界面代码一行都不用改。这就是把驱动和界面分开的价值。

加温度显示,DS18B20 的驱动本身不长,麻烦的是它的 750ms 转换时间不能阻塞主循环。做法是把它做成状态机:发起转换、记时间戳、下次进来检查是否到时间、到时间再读。

void Temp_Task(void) { static uint <p> <a href="https://download.csdn.net/download/weixin_50921275/20670763" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>

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

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

立即咨询