1. 蓝桥杯单片机模块化编程入门指南

参加蓝桥杯单片机竞赛的小伙伴们都知道,面对复杂的题目要求和有限的时间,如何快速搭建稳定可靠的代码框架是关键。我参加过多次蓝桥杯竞赛并担任过指导老师,发现很多选手一开始就陷入了一个误区——直接从头开始写代码,没有预先规划好模块结构,结果做到后面各种外设冲突、代码混乱,最后调试起来苦不堪言。

模块化编程就像是搭积木,每个功能都是一个独立的模块,需要的时候直接拿过来用,不需要关心内部怎么实现的。这样做最大的好处就是代码复用性高,调试方便,而且后期维护简单。就拿最常见的LED控制来说,如果每次都要重新写控制代码,不仅浪费时间,还容易出错。

STC15F2K60S2这款芯片是蓝桥杯竞赛的指定芯片,功能丰富但资源有限,如何合理分配IO口、定时器等资源就显得尤为重要。通过模块化编程,我们可以把各个外设的功能独立出来,比如LED控制一个模块、数码管显示一个模块、按键扫描一个模块,这样在调试的时候就可以逐个模块测试,大大降低了调试难度。

我在实际项目中总结出了一套模块化编程的实践方法,主要包括以下几个步骤:首先建立系统初始化模块,然后逐步添加各个外设驱动模块,最后通过主函数进行模块调度。这种方法的优势在于,即使某个模块需要修改,也不会影响其他模块的正常工作。

2. 系统初始化模块设计与实现

系统初始化是整个程序的基础,好比盖房子要先打地基。这个模块主要负责设置芯片的工作模式、初始化各个外设状态,以及定义一些全局使用的宏和变量。

在STC15F2K60S2芯片中,IO口的功能复用比较灵活,同一个引脚可能同时连接着LED、数码管、按键等多个外设。这就需要使用74HC573锁存器来进行通道选择,相当于一个交通警察,指挥数据流到正确的外设上去。

#ifndef __SYS_H
#define __SYS_H

#define uint unsigned int
#define uchar unsigned char
#include "STC15F2K60S2.H"
#include "intrins.h"
#include "stdio.h"

void hc573(uchar channel);
void system_init();

#endif

这是系统头文件的典型写法,使用#ifndef、#define、#endif是为了防止头文件被重复包含。宏定义uint和uchar可以简化代码书写,避免频繁写unsigned int和unsigned char这么长的关键字。

74HC573锁存器的控制函数是模块化编程的核心之一,它决定了数据流向哪个外设:

void hc573(uchar channel)
{
    switch(channel) //选择通道口
    {
        case 4: P2 = P2 & 0x1f | 0x80; break;
        case 5: P2 = P2 & 0x1f | 0xa0; break;
        case 6: P2 = P2 & 0x1f | 0xc0; break;
        case 7: P2 = P2 & 0x1f | 0xe0; break;
    }
    P2 = P2 & 0x1f; //关锁存
}

这个函数通过改变P2口的高三位来选择不同的外设通道。比如选择通道4时,P2口的高三位变为100,对应二进制就是10000000,即0x80。操作完成后要及时关闭锁存,避免误操作。

系统初始化函数需要关闭所有可能产生干扰的外设:

void system_init()
{
    P0 = 0xff;
    hc573(4); //关闭LED
    
    P0 = 0x00;
    hc573(5); //关闭继电器和蜂鸣器
    
    P0 = 0x00;
    hc573(6); //关闭数码管位选
    
    P0 = 0xff;
    hc573(7); //关闭数码管段选
}

这里有个小技巧:操作完一个外设后,最好立即关闭对应的锁存器,防止后续操作影响到这个外设。我曾经遇到过因为没及时关闭锁存器,导致数码管显示和LED控制互相干扰的问题,调试了好久才发现是这个原因。

3. LED、继电器与蜂鸣器控制模块

LED、继电器和蜂鸣器是最基础的输出设备,在蓝桥杯竞赛中几乎每次都会用到。虽然看起来简单,但如果设计不好,很容易造成代码冗余和控制混乱。

LED控制模块需要实现单个LED的点亮和熄灭,同时还要支持多个LED的组合显示。我推荐使用数组来存储LED状态,这样可以很方便地控制任意一个LED:

#ifndef __LED_H
#define __LED_H

#include "sys.h"

void led(uchar *LED, uchar pos);
void relay(uchar state_relay);
void motor(uchar state_motor);
void beep(uchar state_beep);

#endif

在头文件中声明函数时,要注意参数的类型和含义。比如led函数的第一个参数是LED状态数组的指针,第二个参数是当前正在操作的位置。

LED控制函数的实现有很多种方式,我比较推荐的是使用静态变量来保存当前状态:

void led(uchar *LED, uchar pos)
{
    static uchar temp = 0xff; //静态变量,保持上一次的值
    
    if(LED[pos])
        temp = temp & (~(0x01 << pos)); //点亮
    else
        temp = temp | (0x01 << pos); //熄灭
        
    P0 = temp;
    hc573(4);
}

使用静态变量的好处是不需要全局变量,避免了命名冲突的问题。这里用到了位操作,点亮LED是将对应位清零,熄灭是将对应位置一。为什么要这样呢?因为蓝桥杯开发板上的LED是共阳接法,低电平点亮。

继电器和蜂鸣器的控制相对简单,但要注意它们共用P0口的不同位:

void relay(uchar state_relay) //继电器
{
    uchar temp;
    
    if(state_relay)
        temp = temp | 0x10; //置一
    else
        temp = temp & (~0x10); //置零
        
    P0 = temp;
    hc573(5);
}

void beep(uchar state_beep) //蜂鸣器
{
    uchar temp;
    
    if(state_beep)
        temp = temp | 0x40; //置一
    else
        temp = temp & (~0x40); //置零
        
    P0 = temp;
    hc573(5);
}

这里有个容易出错的地方:继电器和蜂鸣器都使用P0口,但控制的是不同的位。继电器是P0.4(0x10),蜂鸣器是P0.6(0x40)。如果同时控制两者,需要先读取当前P0口的状态,然后只修改对应的位,而不是直接赋值。

在实际应用中,我建议把继电器、蜂鸣器和电机的控制封装成一个函数,因为它们都使用hc573(5)这个通道。这样可以减少函数调用次数,提高代码效率。

4. 数码管显示模块与消影技术

数码管显示是蓝桥杯竞赛中最常用的输出方式,但也是问题最多的模块之一。很多选手都会遇到数码管闪烁、残影等问题,其实这些问题都可以通过合适的编程技巧来解决。

数码管的工作原理是动态扫描,也就是说,同一时间只有一个数码管是点亮的,通过快速切换给人眼一种所有数码管同时亮的错觉。这就涉及到两个关键操作:位选(选择哪个数码管亮)和段选(显示什么内容)。

#ifndef __SMG_H
#define __SMG_H

#include "sys.h"

void smg(uchar *SMG, uchar *dot, uchar pos);

#endif

数码管模块需要三个参数:显示内容数组、小数点位置数组和当前扫描位置。这种设计可以灵活控制每个数码管的显示内容和小数点状态。

数码管驱动函数的核心是消影技术,这是解决残影问题的关键:

code uchar segment[] = {
    0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8, 0x80, 0x90, 0xff
};

void smg(uchar *SMG, uchar *dot, uchar pos)
{
    P0 = 0xff; //消隐
    hc573(7);
    
    P0 = 0x01 << pos; //位选
    hc573(6);
    
    if(dot[pos] == 0) //段选
        P0 = segment[SMG[pos]];
    else
        P0 = segment[SMG[pos]] & 0x7f;
        
    hc573(7);
}

消影的原理很简单:在位选切换前,先将段选设置为全灭(0xff),这样在切换位选时就不会有残留的显示内容了。我刚开始学习时经常忽略这一步,结果数码管总是有淡淡的残影,影响了显示效果。

段码表使用了code关键字,这是51单片机特有的修饰符,表示将数据存放在程序存储器中,节省宝贵的RAM空间。对于STC15F2K60S2来说,RAM只有2K,所以要尽量把常量数据放在程序空间中。

小数点的控制是通过与0x7f进行与运算实现的,因为段码的最高位控制小数点,清零显示小数点,置一不显示。这是一个很巧妙的设计,不需要单独存储小数点状态,只需要在显示时处理一下即可。

在实际使用中,我建议将数码管扫描放在定时器中断中,每隔1-2ms扫描一位,这样既能保证显示稳定,又不会占用太多CPU时间。同时,显示内容最好使用双缓冲机制,避免直接操作显示数组导致闪烁。

5. 按键扫描模块设计技巧

按键扫描是人机交互的基础,好的按键处理程序应该能够准确识别按键动作,同时还要处理抖动和连按等问题。在蓝桥杯竞赛中,按键扫描的稳定性和响应速度直接影响用户体验。

独立按键和矩阵键盘是两种常见的按键形式,它们的工作原理和扫描方式有所不同。独立按键每个按键占用一个IO口,编程简单但占用资源多;矩阵键盘通过行列扫描实现多个按键,节省IO口但编程复杂。

#ifndef __KEY_H
#define __KEY_H

#include "sys.h"

uchar key_scan1();
uchar key_scan2();

#endif

按键扫描函数的设计要考虑到抖动问题。机械按键在按下和松开时会产生10-20ms的抖动,如果不处理就会产生多次误触发。我常用的方法是延时去抖,即在检测到按键按下后延时20ms再次检测,如果仍然按下则认为有效。

独立按键的扫描相对简单:

bit key_up = 1; //按键松手标志位

uchar key_scan1() //独立按键
{
    if(key_up == 1 && (P30 == 0 || P31 == 0 || P32 == 0 || P33 == 0))
    {
        key_up = 0;
        if(P30 == 0) return 1;
        else if(P31 == 0) return 2;
        else if(P32 == 0) return 3;
        else if(P33 == 0) return 4;
    }
    else if(P30 == 1 && P31 == 1 && P32 == 1 && P33 == 1)
        key_up = 1;
        
    return 0;
}

这里使用了一个静态变量key_up作为松手标志,只有检测到按键从松开到按下的变化时才返回键值,这样可以避免按键长按时的重复触发。

矩阵键盘的扫描要复杂一些,需要分别设置行和列的电平:

uchar key_scan2() //矩阵键盘
{
    uchar key;
    
    P44 = P42 = P35 = P34 = 1;
    P30 = P31 = P32 = P33 = 0;
    
    if(key_up == 1 && (P44 == 0 || P42 == 0 || P35 == 0 || P34 == 0))
    {
        if(P44 == 0) key = 1;
        else if(P42 == 0) key = 2;
        else if(P35 == 0) key = 3;
        else if(P34 == 0) key = 4;
        else return 0;
        
        P44 = P42 = P35 = P34 = 0;
        P30 = P31 = P32 = P33 = 1;
        
        key_up = 0;
        
        if(P30 == 0) key = key;
        else if(P31 == 0) key = key + 4;
        else if(P32 == 0) key = key + 8;
        else if(P33 == 0) key = key + 12;
        
        return key;
    }
    else if(P44 == 1 && P42 == 1 && P35 == 1 && P34 == 1)
        key_up = 1;
        
    return 0;
}

矩阵键盘的扫描分为两步:首先设置所有行为高电平,所有列为低电平,检测哪一行有按键按下;然后设置所有行为低电平,所有列为高电平,检测哪一列有按键按下。将行号和列号组合起来就能得到唯一的键值。

在实际应用中,我建议将按键扫描放在定时器中断中定期执行,比如每10ms扫描一次。这样可以避免阻塞主程序,同时也能保证按键响应的及时性。对于需要长按功能的按键,可以在函数中添加计时功能,当检测到按键持续按下超过一定时间时,触发长按事件。

6. 定时器模块与中断处理

定时器是单片机的"心脏",它提供了准确的时间基准,是实现多任务调度的基础。在蓝桥杯竞赛中,定时器常用于数码管扫描、按键扫描、数据采集等需要定时执行的任务。

STC15F2K60S2有三个定时器:T0、T1和T2。T0和T1是传统的51定时器,T2是增强型定时器,功能更强大。一般来说,T0用于通用定时,T1用于串口波特率生成,T2用于其他特殊功能。

定时器的配置涉及到多个寄存器,需要仔细设置每个位的值:

void Timer1Init(void) //1毫秒@12.000MHz
{
    AUXR &= 0xBF; //定时器时钟12T模式
    TMOD &= 0x0F; //设置定时器模式
    TL1 = 0x18; //设置定时初值
    TH1 = 0xFC; //设置定时初值
    TF1 = 0; //清除TF1标志
    TR1 = 1; //定时器1开始计时
    ET1 = 1; //允许定时器1中断
}

定时初值的计算很重要,它决定了定时器的溢出时间。对于12T模式,定时器每12个时钟周期计数一次,12MHz晶振时,计数频率为1MHz,周期为1μs。要实现1ms定时,需要计数1000次,所以初值为65536-1000=64536,即0xFC18。

中断服务函数是定时器模块的核心,它负责处理所有需要定时执行的任务:

void timer1() interrupt 3
{
    if(++count == 1000)
    {
        fre = (TH0 << 8) | TL0;
        TH0 = TL0 = 0;
        count = 0;
    }
    
    systick_ms++;
    key_dly++;
    display_dly++;
    collect_dly++;
    
    smg(SMG, dot, pos); //数码管
    led(LED, pos); //LED
    
    if(++pos == 8)
        pos = 0;
        
    relay(state_relay); //继电器
    beep(state_beep); //蜂鸣器
}

中断服务函数的编写有几个注意事项:首先函数要简短高效,避免执行时间过长影响其他中断;其次要及时清除中断标志,否则会连续进入中断;最后要注意共享变量的保护,避免主程序和中断程序同时访问同一个变量。

我在实际项目中总结出了一套多任务调度的方法:使用多个计数器(如key_dly、display_dly等)来记录各个任务的执行时间,在主程序中检查这些计数器的值,当达到预定时间时执行相应的任务。这种方法简单有效,可以实现伪多任务效果。

定时器模块的头文件需要声明外部变量,方便其他模块使用:

#ifndef __TIMER_H
#define __TIMER_H

#include "sys.h"
#include "SMG.h"
#include "led.h"

void Timer0Init(void);
void Timer0Init_Count(void);
void Timer1Init(void);

extern unsigned long systick_ms;
extern uchar key_dly;
extern uint display_dly, collect_dly;
extern uint fre;
extern uchar state_relay, state_beep;
extern uchar state_motor;
extern uchar SMG[];
extern uchar dot[];
extern uchar LED[];

#endif

使用extern关键字声明外部变量时,要注意这些变量必须在某个C文件中实际定义,否则链接时会报错。我建议在timer.c文件中定义这些变量,并在头文件中声明,这样其他模块只需要包含头文件就可以使用这些变量了。

7. 温度传感器与数据采集模块

DS18B20温度传感器是蓝桥杯竞赛中常用的传感器,它采用单总线协议,只需要一根数据线就可以实现数据传输,大大节省了IO口资源。但是单总线协议对时序要求严格,编程时需要特别注意延时精度。

DS18B20的使用包括初始化、发送命令、读取数据等步骤,每个步骤都要严格按照时序图操作:

unsigned int rd_temperature(uchar dot)
{
    uint temp;
    uchar tml, tmh;
    
    init_ds18b20();
    Write_DS18B20(0xcc);
    Write_DS18B20(0x44);
    
    init_ds18b20();
    Write_DS18B20(0xcc);
    Write_DS18B20(0xbe);
    
    tml = Read_DS18B20();
    tmh = Read_DS18B20();
    
    temp = (tmh << 8) | tml;
    
    switch(dot)
    {
        case 0: temp = temp * 0.0625; break;
        case 1: temp = temp * 0.625; break;
        case 2: temp = temp * 6.25; break;
    }
    
    return temp;
}

DS18B20的温度数据是12位精度,分为高8位和低8位。低8位的前4位是小数部分,后4位是整数部分;高8位的前5位是符号位,后3位是整数部分。温度转换公式为:温度值 = (高8位 << 8 | 低8位) × 0.0625。

DS18B20有一个特点:上电初始化时显示85°C,这是芯片的正常现象,不是故障。为了避免显示85°C,可以在main函数初始化时读一次温度,并延时750ms等待第一次温度转换完成。

在实际使用中,我建议将温度读取放在定时任务中,比如每1秒读取一次。读取温度需要一定时间,最好不要在中断中执行,而是设置一个标志位,在主程序中执行读取操作。

温度数据的处理也很重要,直接读取的原始数据需要经过转换才能显示。我通常使用定点数运算来处理温度数据,避免浮点运算的效率问题。比如将温度放大10倍,用整数表示小数点后一位,显示时再除以10。

对于温度数据的显示,可以使用数码管或者串口输出。如果使用数码管,需要注意小数点的位置;如果使用串口输出,可以方便地记录温度变化曲线。我在一个项目中同时使用了两种显示方式,数码管显示当前温度,串口输出温度日志,这样既方便现场观察,又便于后期分析。

8. 高级外设模块编程技巧

蓝桥杯竞赛中还会用到一些高级外设,如AT24C02存储器、PCF8591 AD/DA转换器、DS1302时钟芯片等。这些外设通常使用I2C、SPI等串行协议,编程复杂度较高,但掌握了基本原理后也不难。

AT24C02是一个2Kbit的串行EEPROM存储器,掉电不丢失数据,常用于保存配置参数或历史数据。它使用I2C协议,需要严格按照时序操作:

void AT24C02_Write(uchar addr, uchar dat)
{
    IIC_Start();
    IIC_SendByte(0xa0);
    IIC_WaitAck();
    IIC_SendByte(addr);
    IIC_WaitAck();
    IIC_SendByte(dat);
    IIC_WaitAck();
    IIC_Stop();
}

AT24C02的写操作需要一定时间(约10ms)来完成内部编程,在这期间不会响应总线命令。所以我通常在写操作后延时10ms,确保数据写入完成。也可以使用轮询方式,不断发送起始条件直到设备响应。

PCF8591是具有I2C接口的8位AD/DA转换芯片,具有4路模拟输入和1路模拟输出。它的控制字需要仔细配置:

void PCF8591_Write(uchar control, uchar dat)
{
    IIC_Start();
    IIC_SendByte(0x90);
    IIC_WaitAck();
    IIC_SendByte(control); //0x40:DA输出
    IIC_WaitAck();
    IIC_SendByte(dat);
    IIC_WaitAck();
    IIC_Stop();
}

PCF8591的控制字配置很灵活:D6位控制DA输出使能,D5和D4位选择输入模式,D1和D0位选择输入通道。如果只需要DA输出,设置D6=1即可;如果需要AD输入,D6必须置0;如果同时使用AD和DA,D6必须置1。

DS1302是串行实时时钟芯片,可以提供准确的年月日时分秒时间信息。它的数据传输采用独特的串行协议,与SPI类似但又不完全相同:

void ds1302_init()
{
    uchar i;
    
    Write_Ds1302_Byte(0x8e, 0x00); //关闭写保护
    
    for(i = 0; i < 3; i++)
    {
        Write_Ds1302_Byte(write_addr[i], Time[i]);
    }
    
    Write_Ds1302_Byte(0x8e, 0x80); //打开写保护
}

DS1302的时间数据采用BCD码格式,需要转换后才能显示。BCD码转十进制的方法

更多推荐