1. 系统整体设计思路
做蓝桥杯省赛题最怕的就是一开始思路不清,代码写着写着就乱了。我当年第一次参加比赛就吃过这个亏,后来总结出一个好方法:先把系统功能模块拆解清楚。这个PCF8591电压监测与智能计数系统,核心就是四个模块的协同工作。
首先是数据采集模块,用PCF8591读取AIN3通道的电压值,这里要注意ADC转换的精度和采样速率。然后是显示模块,需要管理三个界面的切换:数据界面显示实时电压,参数界面设置阈值电压,计数界面记录事件次数。接着是存储模块,用AT24C02保存参数设置,保证掉电不丢失。最后是逻辑控制模块,处理按键输入和LED状态指示。
在实际编程中,我建议采用时间片轮询的方式。就像示例代码里那样,用1ms的定时器中断作为系统心跳,然后在主循环中处理各个任务函数。这样做的好处是每个任务都能得到及时执行,又不会互相阻塞。比如显示刷新放在500ms任务里,电压采样放在100ms任务里,按键检测放在2ms任务里,这样既满足题目要求的时间指标,又让代码结构清晰易懂。
2. PCF8591电压采集实战
PCF8591是个8位精度的ADC/DAC芯片,用I2C协议通信。在实际使用中,我最常遇到两个坑:一是I2C时序问题,二是数据换算错误。
先说说I2C驱动。官方提供的驱动代码有时候不太稳定,需要自己调整延时。写I2C起始信号时,SDA下降沿要在SCL高电平期间完成,这个时序一定要准。我通常会用示波器抓一下波形,确保符合标准I2C时序。
读取电压值的核心代码是这样的:
// 读取指定通道的ADC值
uchar PCF8591_Adc(uchar channel)
{
I2CStart();
I2CSendByte(0x90); // 写设备地址
I2CWaitAck();
I2CSendByte(0x40 | channel); // 控制字:允许模拟输出,选择通道
I2CWaitAck();
I2CStop();
// 需要连续读两次,第一次是无效数据
I2CStart();
I2CSendByte(0x91); // 读设备地址
I2CWaitAck();
I2CReceiveByte(); // 丢弃第一次读数
I2CSendAck(0);
uchar value = I2CReceiveByte(); // 读取有效数据
I2CSendAck(1);
I2CStop();
return value;
}
拿到ADC原始值后要转换成实际电压。因为PCF8591是8位ADC,参考电压是5V,所以每个数字量对应5V/256≈0.0195V。但直接这样计算会有精度损失,我一般采用整数运算:
V_ain3 = (float)PCF8591_Adc(3) * 5.0 / 255;
这里为什么用255而不是256?因为8位ADC的最大输出值是255(0xFF),对应的正好是5V。这种换算方式更准确。
3. 多界面显示设计与实现
数码管显示是省赛题的必考内容,处理好显示效果能让你的作品脱颖而出。我建议采用状态机的方式来管理界面切换,这样逻辑清晰,扩展性也好。
首先定义三个界面状态:
#define UI_DATA 0 // 数据界面
#define UI_PARAM 1 // 参数界面
#define UI_COUNT 2 // 计数界面
uchar UI = UI_DATA; // 当前界面
显示任务函数这样写:
void Display_Task(void)
{
static uint last_time = 0;
if(Tick - last_time < 500) return; // 500ms刷新一次
last_time = Tick;
switch(UI)
{
case UI_DATA:
sprintf(Dsp_Bit, "U %3.2f", V_ain3);
break;
case UI_PARAM:
sprintf(Dsp_Bit, "P %3.2f", V_p);
break;
case UI_COUNT:
if(Count_Flag)
sprintf(Dsp_Bit, "N 00");
else
sprintf(Dsp_Bit, "N %02u", Count);
break;
}
Seg_Tran(); // 转换段码
}
这里有个实用技巧:sprintf函数的格式化输出。用%3.2f可以确保电压值总是显示两位小数,整数部分最多三位,这样显示效果很整齐。注意数码管的段码表要正确,特别是小数点的处理。在Seg_Tran函数中,遇到小数点字符时要将段码的最高位清零(因为一般是共阳数码管)。
4. EEPROM参数存储技巧
AT24C02是256字节的EEPROM,用来保存电压参数Vp。这里最关键的是数据格式转换和写入耐久性问题。
存储时要把浮点数转换成整数:
// 保存参数到EEPROM
void Save_Parameter(void)
{
uchar temp = (uchar)(V_p * 10); // 放大10倍后取整
AT24C02_WriteByte(0, temp); // 写入地址0
}
读取时再转换回来:
// 从EEPROM读取参数
void Load_Parameter(void)
{
uchar temp = AT24C02_ReadByte(0);
V_p = (float)temp / 10.0;
}
为什么要放大10倍?因为Vp的范围是0.00-5.00,有两位小数。放大10倍后变成0-50的整数,正好用一个字节存储,既节省空间又避免浮点数存储的复杂性。
EEPROM有写入次数限制(约10万次),所以不要频繁写入。示例代码中只在退出参数界面时保存一次,这个设计很合理。在实际使用中,我还会加一个写入标志,只有参数确实改变时才执行写入操作,进一步减少EEPROM磨损。
5. 按键逻辑与状态管理
按键处理是人机交互的核心,好的按键逻辑能让用户体验提升不少。这个系统有四个按键:S4切换界面、S5清零计数、S8减小参数、S9增大参数。
按键检测采用矩阵扫描方式,2ms检测一次:
void Key_Task(void)
{
static uint last_time = 0;
if(Tick - last_time < 2) return;
last_time = Tick;
uchar key_value = Key_Scan(); // 获取键值
// 根据不同界面处理按键
switch(UI)
{
case UI_DATA:
if(key_value == 4) // S4切换到参数界面
{
UI = UI_PARAM;
L3_Count = 0; // 重置无效按键计数
LED2 = 0; // 熄灭L3指示灯
}
break;
// 其他界面处理类似...
}
}
这里有个细节处理:无效按键计数。连续3次无效操作点亮L3指示灯,这个功能很容易实现但很多人忽略。我建议用一个静态变量记录连续无效次数,有效操作时清零。
参数调整的边界检查也很重要:
// 在参数界面处理S8按键
if(key_value == 8) // 减小0.5V
{
if(V_p >= 0.5)
V_p -= 0.5;
else
V_p = 5.0; // 循环到最大值
}
这种循环调整的方式很实用,用户不用记住当前值,一直按就能找到想要的参数。
6. 智能计数触发机制
计数功能是这个系统的亮点所在。题目要求当电压值从低于参数值变为高于参数值时计数,这个边沿检测需要仔细处理。
我采用状态机的方式实现:
void Count_Task(void)
{
static uchar state = 0;
static uint last_time = 0;
if(Tick - last_time < 100) return; // 100ms检测一次
last_time = Tick;
switch(state)
{
case 0: // 等待电压超过阈值
if(V_ain3 > V_p)
state = 1;
break;
case 1: // 等待电压回落到阈值以下
if(V_ain3 < V_p)
{
Count++;
state = 0;
}
break;
}
}
这种实现方式有很好的抗抖动能力。因为每100ms检测一次,避免了电压波动导致的误计数。在实际应用中,还可以加入迟滞比较,比如设置一个回差电压,这样计数更稳定。
奇偶判断指示灯L2的实现很简单:
LED1 = (Count % 2) ? 1 : 0;
但要注意这是在计数界面显示时更新的,所以要在显示任务中处理。
7. LED状态指示逻辑
三个LED指示灯分别代表不同状态,其中L1的超时判断需要特别注意。
L1在电压低于阈值超过5秒时点亮:
void L1_Check_Task(void)
{
static uint last_time = 0;
static uchar count_seconds = 0;
if(Tick - last_time < 1000) return; // 1秒检测一次
last_time = Tick;
if(V_ain3 < V_p)
{
count_seconds++;
if(count_seconds >= 5)
LED0 = 1; // 点亮L1
}
else
{
count_seconds = 0;
LED0 = 0; // 熄灭L1
}
}
这里用了一个秒计数器,只有连续5次检测都满足条件才触发。这种实现方式避免了瞬时波动导致的误触发。
L3指示灯处理无效按键,这个功能在按键任务中实现。用一个全局变量记录连续无效按键次数,有效按键时清零。
8. 系统初始化与整体流程
好的初始化是系统稳定运行的基础。上电初始化要完成以下几件事:
void System_Init(void)
{
P_Init(); // 外设初始化
Load_Parameter(); // 读取保存的参数
UI = UI_DATA; // 默认数据界面
Count = 0; // 计数清零
// 其他初始化...
}
外设初始化特别重要:
void P_Init(void)
{
// 关闭所有LED
CONTROL(LED, 0xFF);
// 关闭蜂鸣器和继电器
CONTROL(DEVICE, 0x00);
// 初始化数码管
CONTROL(BIT, 0x00);
CONTROL(SEG, 0xFF);
// 初始化定时器
Timer_2Init();
}
主循环采用时间片轮询方式,这是嵌入式系统的经典设计模式:
void main(void)
{
System_Init();
while(1)
{
Display_Task(); // 显示任务
Key_Task(); // 按键任务
Data_Task(); // 数据采集
L1_Check_Task(); // LED状态检测
Count_Task(); // 计数任务
}
}
每个任务函数内部都有自己的时间控制,不会互相阻塞。这种设计既保证了实时性,又让代码结构清晰易懂。
在实际开发中,我建议先用仿真器调试各个模块功能,然后再整合。特别是I2C通信部分,可以用逻辑分析仪抓取波形,确保时序正确。电压采样部分要用可调电源验证精度,确保显示值准确。
记得在省赛时留出足够时间调试整体功能,因为模块之间的交互往往会出现意想不到的问题。比如显示刷新和按键响应的协调,参数保存和读取的时机等。多做一些边界测试,比如电压临界值、参数极值等情况,确保系统在各种情况下都能稳定工作。
1809



被折叠的 条评论
为什么被折叠?



