这个基于STM32的多功能电子钟万年历项目,是我最近完成的一个嵌入式系统设计实践。它不仅仅是一个简单的时钟显示装置,而是整合了时间显示、日期计算、闹钟提醒、环境光照检测和自动灯光控制等多种功能的综合性系统。
作为一名嵌入式开发者,我经常需要设计这类综合性项目来锻炼自己的系统整合能力。这个项目特别适合想要深入学习STM32外设控制和嵌入式系统设计的开发者。它涵盖了GPIO控制、定时器中断、ADC采样、PWM输出等多个核心知识点,代码量适中但功能完整,是进阶学习的好案例。
系统采用STM32F103C8T6作为主控芯片,搭配OLED显示屏作为输出设备,通过按键进行交互。核心功能包括:
项目选用STM32F103C8T6作为主控芯片,这是ST公司Cortex-M3内核的经典款,具有以下优势:
提示:STM32F103系列有多个型号,C8T6是性价比很高的选择,对于时钟类项目完全够用。如果考虑更低功耗,可以选用STM32L系列。
| 组件 | 型号/参数 | 用途 | 接口方式 |
|---|---|---|---|
| OLED显示屏 | 0.96寸SSD1306 | 时间日期显示 | I2C/SPI |
| 按键 | 轻触开关x7 | 时间设置/闹钟设置 | GPIO输入 |
| 光敏电阻 | GL5516 | 环境光检测 | ADC采样 |
| 温度传感器 | NTC热敏电阻 | 温度显示(可选) | ADC采样 |
| 舵机 | SG90 | 模拟灯光开关 | PWM控制 |
| 蜂鸣器 | 无源蜂鸣器 | 闹钟提醒 | GPIO输出 |
电源部分:
按键电路:
ADC采样电路:
PWM输出电路:
整个系统的软件流程可以分为以下几个主要部分:
初始化阶段:
主循环:
中断服务:
系统使用一个全局数组time[6]来维护时间状态:
c复制int time[6] = {30,9,23,23,12,2024}; //秒,分,时,天,月,年
这种线性存储方式便于时间计算和显示更新。对于闹钟时间,使用单独的数组:
c复制int ALMtime[3] = {0,0,0}; //秒,分,时
项目采用模块化设计,每个功能都有对应的.c和.h文件:
显示模块 (OLED.c/h)
输入模块 (key.c/h)
定时模块 (timer.c/h)
ADC模块 (adc.c/h)
舵机控制 (servo.c/h)
蜂鸣器控制 (buzzer.c/h)
时间计算 (timevariant.c/h, leapyear.c/h)
系统使用TIM4作为主定时器,配置为1秒中断:
c复制void timer_init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4, ENABLE);
TIM_InternalClockConfig(TIM4);
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStruct;
TIM_TimeBaseInitStruct.TIM_ClockDivision = TIM_CKD_DIV1;
TIM_TimeBaseInitStruct.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInitStruct.TIM_Period = 10000-1; //ARR
TIM_TimeBaseInitStruct.TIM_Prescaler = 7200-1; //PSC
TIM_TimeBaseInitStruct.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM4, &TIM_TimeBaseInitStruct);
TIM_ClearFlag(TIM4, TIM_IT_Update);
TIM_ITConfig(TIM4, TIM_IT_Update, ENABLE);
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
NVIC_InitTypeDef NVIC_InitStruct;
NVIC_InitStruct.NVIC_IRQChannel = TIM4_IRQn;
NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 3;
NVIC_InitStruct.NVIC_IRQChannelSubPriority = 3;
NVIC_Init(&NVIC_InitStruct);
TIM_Cmd(TIM4, ENABLE);
}
定时器频率计算:
时间进位是万年历的核心算法,需要考虑:
实现代码在timevariant.c中:
c复制void timevarying(int time[])
{
//秒→分
if(time[0] == 60) {
time[1]++;
time[0] = 0;
}
//分→时
if(time[1] == 60) {
time[2]++;
time[1] = 0;
}
//时→天
if(time[2] == 24) {
time[3]++;
time[2] = 0;
}
//天→月
if((time[4] == 1 || time[4] == 3 || time[4] == 5 ||
time[4] == 7 || time[4] == 8 || time[4] == 10 ||
time[4] == 12) && time[3] == 32) {
time[4]++;
time[3] = 1;
}
if((time[4] == 4 || time[4] == 6 ||
time[4] == 9 || time[4] == 11) && time[3] == 31) {
time[4]++;
time[3] = 1;
}
if((time[4] == 2 && leapyear(time[5])) && time[3] == 30) {
time[4]++;
time[3] = 1;
}
if((time[4] == 2 && !leapyear(time[5])) && time[3] == 29) {
time[4]++;
time[3] = 1;
}
//月→年
if(time[4] == 13) {
time[5]++;
time[4] = 1;
}
}
闰年判断算法:
c复制int leapyear(int year)
{
if ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0))
return 1;
else
return 0;
}
系统通过ADC检测环境光照强度,并在特定时间自动控制"灯光"(用舵机位置模拟):
c复制// 主循环中的光控逻辑
if((time[2] == 23 && time[1] >= 10) && ad_value[0] > 3600) {
servo_setangle(30); // "关灯"
}
if(time[2] == 8) {
servo_setangle(0); // "开灯"
}
ADC配置要点:
c复制void ad_init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);
RCC_ADCCLKConfig(RCC_PCLK2_Div6); // ADC时钟=12MHz
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AIN;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_2 | GPIO_Pin_3;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStruct);
ADC_RegularChannelConfig(ADC1, ADC_Channel_2, 1, ADC_SampleTime_41Cycles5);
ADC_RegularChannelConfig(ADC1, ADC_Channel_3, 2, ADC_SampleTime_41Cycles5);
ADC_InitTypeDef ADC_InitStruct;
ADC_InitStruct.ADC_Mode = ADC_Mode_Independent;
ADC_InitStruct.ADC_DataAlign = ADC_DataAlign_Right;
ADC_InitStruct.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;
ADC_InitStruct.ADC_ContinuousConvMode = ENABLE;
ADC_InitStruct.ADC_ScanConvMode = ENABLE;
ADC_InitStruct.ADC_NbrOfChannel = 2;
ADC_Init(ADC1, &ADC_InitStruct);
DMA_InitTypeDef DMA_InitStruct;
DMA_InitStruct.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR;
DMA_InitStruct.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;
DMA_InitStruct.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
DMA_InitStruct.DMA_MemoryBaseAddr = (uint32_t)ad_value;
DMA_InitStruct.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;
DMA_InitStruct.DMA_MemoryInc = DMA_MemoryInc_Enable;
DMA_InitStruct.DMA_DIR = DMA_DIR_PeripheralSRC;
DMA_InitStruct.DMA_BufferSize = 2;
DMA_InitStruct.DMA_Mode = DMA_Mode_Circular;
DMA_InitStruct.DMA_M2M = DMA_M2M_Disable;
DMA_InitStruct.DMA_Priority = DMA_Priority_Medium;
DMA_Init(DMA1_Channel1, &DMA_InitStruct);
DMA_Cmd(DMA1_Channel1, ENABLE);
ADC_DMACmd(ADC1, ENABLE);
ADC_Cmd(ADC1, ENABLE);
ADC_ResetCalibration(ADC1);
while(ADC_GetResetCalibrationStatus(ADC1) == SET);
ADC_StartCalibration(ADC1);
while(ADC_GetCalibrationStatus(ADC1) == SET);
ADC_SoftwareStartConvCmd(ADC1, ENABLE);
}
闹钟功能通过比较当前时间和预设闹钟时间实现:
c复制// 主循环中的闹钟检查
if(time[0] == ALMtime[0] && time[1] == ALMtime[1] && time[2] == ALMtime[2]) {
buzzer_on(); // 触发闹钟
}
if(key_getnumber() == 7) {
buzzer_off(); // 按键关闭闹钟
}
闹钟设置通过按键交互实现,代码在TIM4中断服务函数中:
c复制if(key_getnumber() == 2) { // 闹钟设置按键
uint16_t i = 0;
while(1) {
timevarying(time);
OLED_ShowString(1,10,"ALMSet");
if(i <= 2) {
OLED_ShowString(2,position," ");
OLED_ShowString(2,8-(3*i),"*"); // 光标指示
position = 8-(3*i);
}
OLED_ShowNum(1,1,ALMtime[2],2); // 显示闹钟时
OLED_ShowString(1,3,":");
OLED_ShowNum(1,4,ALMtime[1],2); // 显示闹钟分
OLED_ShowString(1,6,":");
OLED_ShowNum(1,7,ALMtime[0],2); // 显示闹钟秒
OLED_ShowString(3,1," ");
// 按键处理
if(key_getnumber() == 3) { // 左移
Delay_ms(300);
i++;
}
if(key_getnumber() == 4) { // 右移
Delay_ms(300);
i--;
}
if(key_getnumber() == 5) { // 加
Delay_ms(300);
ALMtime[i]++;
}
if(key_getnumber() == 6) { // 减
Delay_ms(300);
ALMtime[i]--;
}
if(key_getnumber() == 7) { // 确认
OLED_ShowString(1,10," ");
OLED_ShowString(2,position," ");
OLED_ShowString(4,position," ");
break;
}
}
}
在实际测试中发现,基于定时器中断的时钟存在累积误差。为提高精度,我采取了以下措施:
定时器校准:
RTC备份方案:
网络校时(进阶):
为延长电池供电时的使用时间,我优化了系统的功耗:
睡眠模式:
动态时钟调整:
外设电源管理:
关键代码实现:
c复制void Enter_StopMode(void)
{
// 配置唤醒源(如RTC闹钟或按键中断)
RTC_ITConfig(RTC_IT_ALR, ENABLE);
EXTI_ClearITPendingBit(EXTI_Line0);
// 进入Stop模式
PWR_EnterSTOPMode(PWR_Regulator_LowPower, PWR_STOPEntry_WFI);
// 唤醒后系统时钟恢复
SystemInit();
}
OLED显示方面,我总结了几点实用技巧:
局部刷新:
反色显示:
动画效果:
实现代码片段:
c复制// 局部刷新时间显示
void RefreshTimeDisplay(int hour, int minute, int second)
{
static int last_hour = -1, last_minute = -1, last_second = -1;
if(hour != last_hour) {
OLED_ShowNum(1,1,hour,2);
last_hour = hour;
}
if(minute != last_minute) {
OLED_ShowNum(1,4,minute,2);
last_minute = minute;
}
if(second != last_second) {
OLED_ShowNum(1,7,second,2);
last_second = second;
}
}
症状:OLED显示的时间静止不变
排查步骤:
解决方案:
症状:需要多次按压按键才能响应
可能原因:
优化方案:
硬件层面:
软件层面:
改进后的按键检测代码:
c复制typedef enum {
IDLE,
DEBOUNCE,
PRESSED,
RELEASE
} KeyState;
KeyState keyState = IDLE;
uint32_t keyPressTime = 0;
uint8_t Key_GetState(uint8_t keyNum)
{
static uint8_t lastKeyState = 0;
uint8_t currentKeyState = GPIO_ReadInputDataBit(KEY_PORT, keyNum);
if(currentKeyState != lastKeyState) {
keyPressTime = HAL_GetTick();
lastKeyState = currentKeyState;
return 0; // 按键状态变化中
}
if((HAL_GetTick() - keyPressTime) > 20) { // 20ms消抖
return currentKeyState;
}
return 0;
}
症状:舵机角度与预期不符,出现抖动
调试方法:
PWM配置要点:
舵机角度设置函数优化:
c复制void servo_setangle(float angle)
{
if(angle < 0) angle = 0;
if(angle > 180) angle = 180;
// 线性映射:0°→500,180°→2500
uint16_t pulse = (uint16_t)(angle * 2000 / 180 + 500);
TIM_SetCompare1(TIM2, pulse);
// 添加小延时稳定信号
Delay_ms(50);
}
症状:光照检测值不稳定,导致误动作
解决方案:
硬件滤波:
软件滤波:
改进后的ADC采样处理:
c复制#define SAMPLE_COUNT 10
uint16_t Get_FilteredADC(uint8_t channel)
{
static uint16_t samples[SAMPLE_COUNT] = {0};
static uint8_t index = 0;
uint32_t sum = 0;
// 更新采样窗口
samples[index] = ADC_GetValue(channel);
index = (index + 1) % SAMPLE_COUNT;
// 计算移动平均
for(int i = 0; i < SAMPLE_COUNT; i++) {
sum += samples[i];
}
return (uint16_t)(sum / SAMPLE_COUNT);
}
基本方案存在温度变化导致的时间漂移问题,可以:
硬件连接:
通过无线模块实现手机控制:
增强闹钟功能:
数据结构扩展:
c复制typedef struct {
uint8_t hour;
uint8_t minute;
uint8_t enable;
uint8_t repeat; // 位域表示周一到周日
uint8_t melody; // 铃声选择
} AlarmSetting;
AlarmSetting alarms[MAX_ALARMS] = {0};
通过外接传感器或网络获取:
这个STM32多功能电子钟项目从构思到实现大约花费了两周时间,期间遇到了不少挑战也积累了许多宝贵经验。最大的收获是对STM32各种外设的综合运用能力得到了显著提升。
几个关键体会:
定时器的重要性:作为实时系统的核心,定时器的稳定性和精度直接影响整个系统的可靠性。实际测试中发现,单纯依赖内部时钟会有累积误差,最终我改为使用外部晶振并定期校准,精度明显改善。
模块化设计的优势:将显示、输入、时间计算等功能分离成独立模块,极大方便了调试和功能扩展。例如添加蓝牙控制时,只需新增一个模块而不影响原有功能。
电源管理的必要性:初期版本功耗较高,后来通过动态调整时钟频率和外设电源管理,电池续航时间提升了3倍以上。
用户交互的细节:按键响应、菜单逻辑等人机交互细节对用户体验影响很大。经过多次调整,最终采用了状态机模型处理按键,操作更加流畅自然。
这个项目还有很多可以完善的地方,比如添加电池电量检测、实现OTA无线升级等。我已经将这些列入下一步的开发计划。对于想要尝试类似项目的开发者,建议先从核心功能做起,逐步添加扩展功能,这样更容易掌控开发进度和调试复杂度。