返回首页 单片机教程(二)

10.1 单片机数字秒表程序

不同数据类型间的相互转换

在 C 语言中,不同数据类型之间是可以混合运算的。当表达式中的数据类型不一致时,首先转换为同一种类型,然后再进行计算。C 语言有两种方法实现类型转换,一是自动类型转换,另外一种是强制类型转换。这块内容是比较繁杂的,因此我们根据常用的编程应用来讲部分相关内容。

当不同数据类型之间混合运算的时候,不同类型的数据首先会转换为同一类型,转换的主要原则是:短字节的数据向长字节数据转换。比如:

unsigned char a;
unsigned int b;
unsigned int c;
c = a *b;

在运算的过程中,程序会自动全部按照 unsigned int 型来计算。比如 a=10,b=200,c 的结果就是2000。那当 a=100,b=700,那 c 是70000吗?新手最容易犯这种错误,大家要注意每个变量类型的取值范围,c 的数据类型是 unsigned int 型,取值范围是 0~65535,而70000超过 65535了,其结果会溢出,最终 c 的结果是(70000 - 65536) = 4464。

那要想让 c 正常获得70000这个结果,需要把 c 定义成一个 unsigned long 型。我们如果写成:

unsigned char a=100;
unsigned int b=700;
unsigned long c=0;
c = a*b;

有做过实验的同学,会发现这个 c 的结果还是4464,这个是个什么情况呢?

大家注意,C 语言不同类型运算的时候数值会转换同一类型运算,但是每一步运算都会进行识别判断,不会进行一个总的分析判断。比如我们这段代码中 a 和 b 相乘的时候,是按照 unsigned int 类型运算的,运算的结果也是 unsigned int 类型的4464,只是最终把 unsigned int 类型 4464赋值给了一个 unsigned long 型的变量而已。我们在运算的时候如何避免这类问题的产生呢?可以采用强制类型转换的方法。

在一个变量前边加上一个数据类型名,并且这个类型名用小括号括起来,就表示把这个变量强制转换成括号里的类型。如 c = (unsigned long)a b;由于强制类型转换运算符优先级高于,所以这个地方的运算是先把 a 转换成一个 unsigned long 型的变量,而后与 b 相乘,根据 C 语言的规则 b 会自动转换成一个 unsigned long 型的变量,而后运算完毕结果也是一个 unsigned long 型的,最终赋值给了 c。

不同类型变量之间的相互赋值,短字节类型变量向长字节类型变量赋值时,其值保持不变,比如:

unsigned char a=100;
unsigned int b=700;
b=a;

那么最终 b 的值就是100了。但是如果我们的程序是

unsigned char a=100;
unsigned int b=700;
a=b;

那么 a 的值仅仅是取了 b 的低8位,我们首先要把700变成一个16位的二进制数据,然后取它的低8位出来,也就是188,这就是长字节类型给短字节类型赋值的结果,会从长字节类型的低位开始截取刚好等于短字节类型长度的位,然后赋给短字节类型。

在51单片机里边,有一种特殊情况,就是 bit 类型的变量,这个 bit 类型的强制类型转换,是不符合上边讲的这个原则的,比如:

bit a=0;
unsigned char b;
a=(bit)b;

这个地方要特别注意,使用 bit 做强制类型转换,不是取 b 的最低位,而是它会判断 b 这个变量是0还是非0的值,如果 b 是0,那么 a 的结果就是0,如果 b 是任意非0的其它值,那么 a 的结果都是1。

定时时间精准性调整

在6.5.2章节有一个数码管秒表显示程序,那个程序是1秒数码管加1,但是细心的同学做了实验后,经过长时间运行会发现,和我们实际的时间有了较大误差了,那如何去调整这种误差呢?要解决问题,先找到问题是什么原因造成的。

先对我们前面讲过的中断内容做一个较深层次的补充。还是讲解中断的那个场景,当我们在看电视的时候,突然发生了水开的中断,我们必须去提水的时候,第一,我们从电视跟前跑到厨房需要一定的时间,第二,因为我们看的电视是智能数字电视,因此在去提水之前我们可以使用遥控器将我们的电视进行暂停操作,方便回来后继续从刚才的剧情往下进行。

那么暂停电视,跑到厨房提水,这一点点时间是很短的,在实际生活中可以忽略不计,但是在单片机秒表程序中,误差是会累计的,每1秒钟都差了几个微妙,时间一久,造成的累计误差就不可小觑了。

单片机系统里,硬件进入中断需要一定的时间,大概是几个机器周期,还要进行原始数据保护,就是把进中断之前程序运行的一些变量先保存起来,专业术语叫做中断压栈,进入中断后,重新给定时器 TH 和 TL 赋值,也需要几个机器周期,这样下来就会消耗一定的时间,我们得把这些时间补偿回来。

方法一,使用软件 debug 进行补偿。

我们在前边讲过使用 debug 来观察程序运行时间,那我们可以把我们2次进入中断的时间间隔观察出来,看看和我们实际定时的时间相差了几个机器周期,然后在进行定时器初值赋值的时候,进行一个调整。我们用的是 11.0592 M 的晶振,发现差了几个机器周期,就把定时器初值加上几个机器周期,这样就相当于进行了一个补偿。

方法二,使用累计误差计算出来。

有的时候,除了程序本身存在的误差外,硬件精度也可能会影响到时钟的精度,比如晶振,会随着温度变化出现温漂现象,就是实际值和标称值要差一点。那么我们还可以采取累计误差的方法来提高精度。比如我们可以让时钟运行半个小时或者一个小时,看看最终时间差了几秒,然后算算一共进了多少次定时器中断,把这差的几秒平均分配到每次的定时器中断中,就可以实现时钟的调整。

大家要明白,这个世界上本就没有绝对的精确,我们只能在一定程度上提高精确度,但是永远都不会使误差为零,如果在这个基础上还感觉精度不够的话,不要着急,后边我们会专门讲时钟芯片的,通常时钟芯片计时的精度比单片机的精度要高一些。

字节操作修改位的技巧

这里再介绍个编程小技巧,在编程时,有的情况下需要改变一个字节中的某一位或者几位,但是又不想改变其它位原有的值,该如何操作呢?

比如我们学定时器的时候遇到一个寄存器 TCON,这个寄存器是可以进行位操作的,可以直接写 TR0=1;TR0 是 TCON 的一个位,因为这个寄存器是允许位操作,这样写是没有任何问题的。还有一个寄存器 TMOD,这个寄存器是不支持位操作的,那如果我们要使用 T0 的模式1,我们希望达到的效果是 TMOD 的低4位是 0b0001,但如果我们直接写成 TMOD =0x01 的话,实际上已经同时操作到了高4位,即属于 T1 的部分,设置成了 0b0000,如果 T1 定时器没有用到的话,那我们随便怎么样都行,但是如果程序中既用到了 T0,又用到了 T1,那我们设置 T0 的同时已经干扰到了 T1 的模式配置,这是我们不希望看到的结果。

在这种情况下,就可以用我们前边学过的“&”和“|”运算了。对于二进制位操作来说,不管该位原来的值是0还是1,它跟0进行&运算,得到的结果都是0,而跟1进行&运算,将保持原来的值不变;不管该位原来的值是0还是1,它跟1进行|运算,得到的结果都是1,而跟0进行|运算,将保持原来的值不变。

利用上述这个规律,我们就可以着手解决刚才的问题了。如果我们现在要设置 TMOD 使定时器0工作在模式1下,又不干扰定时器1的配置,我们可以进行这样的操作:TMOD =TMOD & 0xF0; TMOD = TMOD | 0x01;第一步与 0xF0 做&运算后,TMOD 的高4位不变,低4位清零,变成了 0bxxxx0000;然后再进行第二步与 0x01 进行|运算,那么高7位均不变,最低位变成1了,这样就完成了只将低4位的值修改位 0b0001,而高4位保持原值不变的任务,即只设置了 T0 而不影响 T1。熟练掌握并灵活运用这个方法,会给你以后的编程带来便利。

另外,在 C 语言中,a &= b;等价于 a = a&b;同理,a |= b;等价于 a = a|b;那么刚才的一段代码就可以写成 TMOD &= 0xF0;TMOD |= 0x01 这样的简写形式。这种写法可以一定程度上简化代码,是 C 语言常用的一种编程风格。

数码管扫描函数算法改进

在学习数码管动态扫描的时候,为了方便大家理解,我们程序写的细致一些,给大家引入了 switch 的用法,随着编程能力与领悟能力的增强,对于 74HC138 这种非常有规律的数字器件,我们在编程上也可以改进一下逻辑算法,让程序变的更简洁。这种逻辑算法,通常不是靠学一下可以全部掌握的,而是通过不断的编写程序以及研究他人程序的过程中一点点积累起来的,从今天开始,大家就要开始积累吧。

前边动态扫描刷新函数我们是这么写的:

P0 = 0xFF;
switch (i){
    case 0: ADDR2=0; ADDR1=0; ADDR0=0; i++; P0=LedBuff[0]; break;
    case 1: ADDR2=0; ADDR1=0; ADDR0=1; i++; P0=LedBuff[1]; break;
    case 2: ADDR2=0; ADDR1=1; ADDR0=0; i++; P0=LedBuff[2]; break;
    case 3: ADDR2=0; ADDR1=1; ADDR0=1; i++; P0=LedBuff[3]; break;
    case 4: ADDR2=1; ADDR1=0; ADDR0=0; i++; P0=LedBuff[4]; break;
    case 5: ADDR2=1; ADDR1=0; ADDR0=1; i=0; P0=LedBuff[5]; break;
    default: break;
}

我们来分析每一个 case 分支,它们的结构是相同的,即改变 ADDR2~0、改变索引 i、取数据写入 P0,只要把 case 后的常量与 ADDR2~0 和 LedBuff 的下标对比,就可以发现它们其实是相等的,那么我们可以直接把常量值(实际上就是 i 在改变前的值)赋值给它们即可,而不必写上6遍。还剩下一个 i 的操作,它进行了5次相同的++与一次归0操作,那么很明显用++和 if 判断就可以替代这些操作。下面就是我们据此改进后的代码:

P0 = 0xFF;
P1 = (P1 & 0xF8) | i;
P0 = LedBuff[i];
if (i < 5){
    i++;
}else{
    i = 0;
}

大家看一下,P1 = (P1 & 0xF8) | i;这行代码就利用了上面讲到的&和|运算来将 i 的低3位直接赋值到 P1 口的低3位上,而 P0 的赋值也只需要一行代码,i 的处理也很简单。这样写成的代码是不是要简洁的多,也巧妙的多,而功能与前面的 switch 是一样的,同样可以完美实现动态显示刷新的功能。

秒表程序

做了一个秒表程序给同学们做参考,程序中涉及到的知识点我们都讲过了,包括了定时器、数码管、中断、按键等多个知识点。多知识点同时应用到一个程序中的小综合,因此需要大家完全消化掉。此程序是一个“真正的”并且“实用的”秒表程序,第一它有足够的分辨率,保留到小数点后两位,即每 10ms 计一次数,第二它也足够精确,因为我们补偿了定时器中断延时造成的误差,如果你愿意,它完全可以为用来测量你的百米成绩。这种小综合也是将来做大项目程序的基础,因此还是老规矩,大家边抄边理解,理解透彻后独立写出来就算此关通过。

#include <reg52.h>
sbit ADDR3 = P1^3;
sbit ENLED = P1^4;
sbit KEY1 = P2^4;
sbit KEY2 = P2^5;
sbit KEY3 = P2^6;
sbit KEY4 = P2^7;
unsigned char code LedChar[] = { //数码管显示字符转换表
    0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8,
    0x80, 0x90, 0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8E
};
unsigned char LedBuff[6] = { //数码管显示缓冲区
    0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF
};
unsigned char KeySta[4] = { //按键当前状态
    1, 1, 1, 1
};

bit StopwatchRunning = 0; //秒表运行标志
bit StopwatchRefresh = 1; //秒表计数刷新标志
unsigned char DecimalPart = 0; //秒表的小数部分
unsigned int IntegerPart = 0; //秒表的整数部分
unsigned char T0RH = 0; //T0 重载值的高字节
unsigned char T0RL = 0; //T0 重载值的低字节
void ConfigTimer0(unsigned int ms);
void StopwatchDisplay();
void KeyDriver();

void main(){
    EA = 1;  //开总中断
    ENLED = 0;  //使能选择数码管
    ADDR3 = 1;
    P2 = 0xFE; //P2.0 置0,选择第4行按键作为独立按键
    ConfigTimer0(2); //配置 T0 定时 2 ms
    while (1){
        if (StopwatchRefresh){ //需要刷新秒表示数时调用显示函数
            StopwatchRefresh = 0;
            StopwatchDisplay();
        }
        KeyDriver(); //调用按键驱动函数
    }
}
/* 配置并启动 T0,ms-T0 定时时间 */
void ConfigTimer0(unsigned int ms){
    unsigned long tmp; //临时变量
    tmp = 11059200 / 12; //定时器计数频率
    tmp = (tmp * ms) / 1000; //计算所需的计数值
    tmp = 65536 - tmp;  //计算定时器重载值
    tmp = tmp + 18; //补偿中断响应延时造成的误差
    T0RH = (unsigned char)(tmp>>8); //定时器重载值拆分为高低字节
    T0RL = (unsigned char)tmp;
    TMOD &= 0xF0; //清零 T0 的控制位
    TMOD |= 0x01; //配置 T0 为模式1
    TH0 = T0RH; //加载 T0 重载值
    TL0 = T0RL;
    ET0 = 1; //使能 T0 中断
    TR0 = 1; //启动 T0
}
/* 秒表计数显示函数 */
void StopwatchDisplay(){
    signed char i;
    unsigned char buf[4]; //数据转换的缓冲区
    //小数部分转换到低 2 位
    LedBuff[0] = LedChar[DecimalPart%10];
    LedBuff[1] = LedChar[DecimalPart/10];
    //整数部分转换到高 4 位
    buf[0] = IntegerPart%10;
    buf[1] = (IntegerPart/10)%10;
    buf[2] = (IntegerPart/100)%10;
    buf[3] = (IntegerPart/1000)%10;
    for (i=3; i>=1; i--){ //整数部分高位的0转换为空字符
        if (buf[i] == 0){
            LedBuff[i+2] = 0xFF;
        }else{
            break;    
        }
    }
    for ( ; i>=0; i--){ //有效数字位转换为显示字符
        LedBuff[i+2] = LedChar[buf[i]];
    }
    LedBuff[2] &= 0x7F; //点亮小数点
}
/* 秒表启停函数 */
void StopwatchAction(){
    if (StopwatchRunning){ //已启动则停止
        StopwatchRunning = 0;
    }else{  //未启动则启动
        StopwatchRunning = 1;
    }
}
/* 秒表复位函数 */
void StopwatchReset(){
    StopwatchRunning = 0; //停止秒表
    DecimalPart = 0; //清零计数值
    IntegerPart = 0;
    StopwatchRefresh = 1; //置刷新标志
}
/* 按键驱动函数,检测按键动作,调度相应动作函数,需在主循环中调用 */
void KeyDriver(){
    unsigned char i;
    static unsigned char backup[4] = {1,1,1,1};

    for (i=0; i<4; i++){ //循环检测4个按键
        if (backup[i] != KeySta[i]){ //检测按键动作
            if (backup[i] != 0){ //按键按下时执行动作
                if (i == 1){ //Esc 键复位秒表
                    StopwatchReset();
                }else if (i == 2){//回车键启停秒表
                    StopwatchAction();
                }
            }
            backup[i] = KeySta[i]; //刷新前一次的备份值
        }
    }
}
/* 按键扫描函数,需在定时中断中调用 */
void KeyScan(){
    unsigned char i;
    static unsigned char keybuf[4] = { //按键扫描缓冲区
        0xFF, 0xFF, 0xFF, 0xFF
    };

    //按键值移入缓冲区
    keybuf[0] = (keybuf[0] << 1) | KEY1;
    keybuf[1] = (keybuf[1] << 1) | KEY2;
    keybuf[2] = (keybuf[2] << 1) | KEY3;
    keybuf[3] = (keybuf[3] << 1) | KEY4;
    //消抖后更新按键状态
    for (i=0; i<4; i++){
        if (keybuf[i] == 0x00){
            //连续8次扫描值为 0,即 16 ms 内都是按下状态时,可认为按键已稳定的按下
            KeySta[i] = 0;
        }else if (keybuf[i] == 0xFF){
            //连续8次扫描值为 1,即 16 ms 内都是弹起状态时,可认为按键已稳定的弹起
            KeySta[i] = 1;
        }
    }
}
/* 数码管动态扫描刷新函数,需在定时中断中调用 */
void LedScan(){
    static unsigned char i = 0; //动态扫描索引
    P0 = 0xFF; //关闭所有段选位,显示消隐
    P1 = (P1 & 0xF8) | i; //位选索引值赋值到 P1 口低3位
    P0 = LedBuff[i]; //缓冲区中索引位置的数据送到 P0 口
    if (i < 5){ //索引递增循环,遍历整个缓冲区
        i++;
    }else{
        i = 0;
    }
}
/* 秒表计数函数,每隔 10 ms 调用一次进行秒表计数累加 */
void StopwatchCount(){
    if (StopwatchRunning){ //当处于运行状态时递增计数值
        DecimalPart++; //小数部分+1
        if (DecimalPart >= 100){ //小数部分计到100时进位到整数部分
            DecimalPart = 0;
            IntegerPart++; //整数部分+1
            if (IntegerPart >= 10000){ //整数部分计到10000时归零
                IntegerPart = 0;
            }
        }
        StopwatchRefresh = 1; //设置秒表计数刷新标志
    }
}
/* T0 中断服务函数,完成数码管、按键扫描与秒表计数 */
void InterruptTimer0() interrupt 1{
    static unsigned char tmr10ms = 0;
    TH0 = T0RH; //重新加载重载值
    TL0 = T0RL;
    LedScan(); //数码管扫描显示
    KeyScan(); //按键扫描
    //定时 10ms 进行一次秒表计数
    tmr10ms++;
    if (tmr10ms >= 5){
        tmr10ms = 0;
        StopwatchCount(); //调用秒表计数函数
    }
}

关于这个程序有两点值得提一下:首先是定时器配置函数,虽然这样在程序里通过计算得出初值(重载值)增加了些许代码,但它换来的是便利性和编程效率,因为只要你完成这个函数,之后所有需要用定时器定时 x 毫秒的场合,你都可以直接把函数拿过去,用所需要的毫秒数作为实参调用它即可,不需要在用计算器埋头算一通了,是不是很值呢。其次是我们没有使用矩阵按键的程序,而是只用矩阵按键的第4行作为独立按键来使用,因为秒表只需要2个键就够了,这里是想告诉大家,处理问题要灵活,千万不能墨守成规,能用简单方法解决的问题,就不要选择复杂的方案。