单片机
返回首页

第17章 A/D和D/A的学习

2020-07-23 来源:51hei

从我们学到的知识了解到,我们的单片机是一个典型的数字系统。数字系统只能对输入的数字信号进行处理,其输出信号也是数字信号。但是在工业检测系统和日常生活中的许多物理量都是模拟量,比如温度、长度、压力、速度等等,这些模拟量可以通过传感器变成与之对应的电压、电流等电模拟量。为了实现数字系统对这些电模拟量的检测、运算和控制,就需要一个模拟量和数字量之间相互转换的过程。这节课我们就要学习这个相互转换过程。


17.1 A/D和D/A的基本概念
A/D是模拟量到数字量的转换,依靠的是模数转换器(Analog to Digital Converter),简称ADC;D/A是数字量到模拟量的转换,依靠的是数模转换器(Digital to Analog Converter),简称DAC。他们的道理是完全一样的,只是转换方向不同,因此我们讲解过程主要以A/D为例来讲解。


很多同学学到A/D这部分的时候,感觉是个难点,概念掌握不清楚。我个人认为主要原因不在于技术问题,而是不太会感悟生活。我们生活中有很多很多A/D的例子,只是没有在单片机领域里应用而已,下面我带着大家一起感悟一下A/D的概念。


什么是模拟量?就是指变量在一定范围内连续变化的量,也就是在一定范围内可以取任意值。比如我们米尺,从0到1米之间,可以是任意值。什么是任意值,也就是可以是1cm,也可以是1.001cm,当然也可以10.000......后边有无限个小数。总之,任何两个数字之间都有无限个中间值,所以称之为连续变化的量,也就是模拟量。


而我们用的米尺上被我们人为的做上了刻度符号,每两个刻度之间的间隔是1mm,这个刻度实际上就是我们对模拟量的数字化,由于有一定的间隔,不是连续的,所以在专业领域里我们称之为离散的。我们的ADC就是起到把连续的信号用离散的数字表达出来的作用。那么我们就可以使用米尺这个“ADC”来测量连续的长度或者高度这些模拟量。如图17-1一个简单的米尺刻度示意图。

ps5b153.jpg

图17-1 米尺刻度示意图


我们往杯子里倒水,水位会随着倒入的水量的多少而变化。现在就用这个米尺来测量我们杯子里的水位的高度。水位变化是连续的,而我们只能通过尺子上的刻度来读取水位的高度,获取我们想得到的水位的数字量信息。这个过程,就可以简单理解为我们电路中的ADC采样。


17.2 A/D的主要指标

我们在选取和使用A/D的时候,依靠什么指标来判断很重要。由于AD的种类很多,分为积分型、逐次逼近型、并行/串行比较型、Σ-Δ型等多种类型。同时指标也比较多,并且有的指标还有轻微差别,在这里我是以同学们便于理解的方法去讲解,如果和某一确定类型A/D概念和原理有差别,也不会影响实际应用。


1、ADC的位数。

一个n位的ADC表示这个ADC共有2的n次方个刻度。8位的ADC,输出的是从0

到255一共256个数字量,也就是2的8次方个数据刻度。


2、基准源

基准源,也叫基准电压,是ADC的一个重要指标,要想把输入ADC的信号测量准确,那么基准源首先要准,基准源的偏差会直接导致转换结果的偏差。比如一根米尺,总长度本应该是1米,假定这根米尺被火烤了一下,实际变成了1.2米,再用这根米尺测物体长度的话自然就有了较大的偏差。假如我们的基准源应该是5.10V,但是实际上提供的却是4.5V,这样误把4.5V当成了5.10V来处理的话,偏差也会比较大。


3、分辨率

分辨率是数字量变化一个最小刻度时,模拟信号的变化量,定义为满刻度量程与2n-1的

比值。5.10V的电压系统,使用8位的ADC进行测量,那么相当于0到255一共256个刻度,把5.10V平均分成了255份,那么分辨率就是5.10/255 = 0.02V。


4、INL(积分非线性度)和DNL(差分非线性度)

初学者最容易混淆的两个概念就是“分辨率”和“精度”,认为分辨率越高,则精度越高,而实际上,两者之间是没有必然联系的。分辨率是用来描述刻度划分的,而精度是用来描述准确程度的。同样一根米尺,刻度数相同,分辨率就相当,但是精度却可以相差很大,如图17-2所示。

ps5b154.jpg


图17-2 米尺精度对比


图17-2表示的精度一目了然,不需多说。和ADC精度关系重大的两个指标是INL(Integral NonLiner)和DNL(Differencial NonLiner)。


INL指的是ADC器件在所有的数值上对应的模拟值,和真实值之间误差最大的那一个点的误差值,是ADC最重要的一个精度指标,单位是LSB。LSB(Least Significant Bit)是最低有效位的意思,那么它实际上对应的就是ADC的分辨率。一个基准为5.10V的8位ADC,它的分辨率就是0.02V,用它去测量一个电压信号,得到的结果是100,就表示它测到的电压值是100*0.02V=2V,假定它的INL是1LSB,就表示这个电压信号真实的准确值是在1.98V~2.02V之间的,按理想情况对应得到的数字应该是99~101,测量误差是一个最低有效位,即1LSB。


DNL表示的是ADC相邻两个刻度之间最大的差异,单位是LSB。一把分辨率是1毫米的尺子,相邻的刻度之间并不都刚好是1毫米,而总是会存在或大或小的误差。同理,一个ADC的两个刻度线之间也不总是准确的等于分辨率,也是存在误差,这个误差就是DNL。一个基准为5.10V的8位ADC,假定它的DNL是0.5LSB,那么当它的转换结果从100增加到101时,理想情况下实际电压应该增加0.02V,但DNL为0.5LSB的情况下实际电压的增加值是在0.01~0.03之间。值得一提的是DNL并非一定小于1LSB,很多时候它会等于或大于1LSB,这就相当于是一定程度上的刻度紊乱,当实际电压保持不变时,ADC得出的结果可能会在几个数值之间跳动,很大程度上就是由于这个原因(但并不完全是,因为还有无时无处不在的干扰的影响)。


5、转换速率

转换速率,是指ADC每秒能进行采样转换的最大次数,单位是sps(或s/s、sa/s,即samples per second),它与ADC完成一次从模拟到数字的转换所需要的时间互为倒数关系。ADC的种类比较多,其中积分型的ADC转换时间是毫秒级的,属于低速ADC;逐次逼近型ADC转换时间是微妙级的,属于中速ADC;并行/串行的ADC的转换时间可达到纳秒级,属于高速ADC。


ADC的这几个主要指标大家先熟悉一下,对于其他的,作为一个入门级别的选手来说,先不着急深入理解。以后使用过程中遇到了,再查找相关资料深入学习,当前重点是在头脑中建立一个ADC的基本概念。


17.3 PCF8591的硬件接口

PCF8591是一个单电源低功耗的8位CMOS数据采集器件,具有4路模拟输入,1路模拟输出和一个串行I2C总线接口用来与MCU通信。3个地址引脚A0、A1、A2用于编程硬件地址,允许最多8个器件连接到I2C总线而不需要额外的片选电路。器件的地址、控制以及数据都是通过I2C总线来传输,我们先看一下PCF8591的原理图,如图17-3所示。

ps5b155.jpg

图17-3 PCF8591原理图


其中引脚1、2、3、4是4路模拟输入,引脚5、6、7是I2C总线的硬件地址,8脚是数字GND,9脚和10脚是I2C总线的SDA和SCL。12脚是时钟选择引脚,如果接高电平表示用外部时钟输入,接低电平则用内部时钟,我们这套电路用的是内部时钟,因此12脚直接接GND,同时11脚悬空。13脚是模拟GND,在实际开发中,如果有比较复杂的模拟电路,那么模拟GND部分在布局布线上要特别处理,而且和数字GND的连接也有多种方式,这里大家先了解即可。在我们板子上没有复杂的模拟部分电路,所以我们把模拟的GND和数字GND接到一起即可。14脚是基准源,15脚是DAC的模拟输出,16脚是供电电源VCC。


PCF8591的ADC是逐次逼近型的,转换速率算是中速,但是他的速度瓶颈在I2C通信上。由于I2C通信速度较慢,所以最终的PCF8591的转换速度,直接取决于I2C的通信速率。由于I2C速度的限制,所以PCF8591的算是个低速的AD和DA集成,主要应用在一些转换速度要求不高,希望成本较低的场合,比如电池供电设备,测量电池的供电电压,电压低于某一个值,报警提示更换电池等类似场合。


Vref基准电压的提供,方法一是采用简易的原则,直接接到VCC上去。但是由于VCC会受到整个线路的用电功耗情况影响,一来不是准确的5V,实测大多在4.8V左右,二来随着整个系统负载情况的变化会产生波动,所以只能用在简易的、对精度要求不高的场合。方法二是使用专门的基准电压器件,比如TL431,它可以提供一个精度很高的2.5V的电压基准,这是我们通常采用的方法。如图17-4所示。

ps5b156.jpg

图17-4 PCF8591电路图


图中J17是双排插针,大家可以根据自己的需求选择跳线帽短接还是使用杜邦线接其他外接电路,都是可以的。在这个地方,我们直接把J17的3脚和4脚用跳线帽短路起来,那么现在Vref的基准源就是2.5V了。分别把5和6、7和8、9和10、11和12用跳线帽短接起来的话,那么我们的AIN0实测的就是滑动变阻器的分压值,AIN1和AIN2测的是GND的值,AIN3测的是+5V的值。这里需要注意的是,AIN3虽然测的是+5V的值,但是对于AD来说,只要输入信号超过Vref基准源,它得到的始终都是最大值,即255,也就是说它实际上无法测量超过其Vref的电压信号。需要注意的是,所有输入信号的电压值都不能超过VCC,即+5V,否则可能会损坏ADC芯片。


17.4 PCF8591的软件编程

PCF8591的通信接口是I2C,那么编程肯定是符合这个协议的。单片机对PCF8591进行初始化,一共发送三个字节即可。第一个字节,和EEPROM类似,第一个字节是地址字节,其中7位代表地址,1位代表读写方向。地址高4位固定是1001,低三位是A2,A1,A0,这三位我们电路上都接了GND,因此也就是000,如图17-5所示。

ps5b157.jpg

图17-5 PCF8591地址字节

发送到PCF8591的第二个字节将被存储在控制寄存器,用于控制PCF8591的功能。其中第3位和第7位是固定的0,另外6位各自有各自的作用,如图17-6所示,我逐一介绍。

ps5b158.jpg

图17-6 PCF8591控制字节


控制字节的第6位是DA使能位,这一位置1表示DA输出引脚使能,会产生模拟电压输出功能。第4位和第5位可以实现把PCF8591的4路模拟输入配置成单端模式和差分模式,单端模式和差分模式的区别,我们17.4章节有介绍,这里大家只需要知道这两位是配置AD输入方式的控制位即可,如图17-7所示。

ps5b159.jpg

图17-7 PCF8591模拟输入配置方式


控制字节的第2位是自动增量控制位,自动增量的意思就是,比如我们一共有4个通道,当我们全部使用的时候,读完了通道0,下一次再读,会自动进入通道1进行读取,不需要我们指定下一个通道,由于A/D每次读到的数据,都是上一次的转换结果,所以同学们在使用自动增量功能的时候,要特别注意,当前读到的是上一个通道的值。为了保持程序的通用性,我们的代码没有使用这个功能,直接做了一个通用的程序。


控制字节的第0位和第1位就是通道选择位了,00、01、10、11代表了从0到3的一共4个通道选择。


发送给PCF8591的第三个字节D/A数据寄存器,表示D/A模拟输出的电压值。D/A模拟我们一会介绍,大家知道这个字节的作用即可。我们如果仅仅使用A/D功能的话,就可以不发送第三个字节。


下面我们用一个程序,把AIN0、AIN1、AIN3测到的电压值显示在液晶上,同时大家可以转动电位器,会发现AIN0的值发生变化。


/***********************lcd1602.c文件程序源代码*************************/

#include


#define LCD1602_DB   P0


sbit LCD1602_RS = P1^0;

sbit LCD1602_RW = P1^1;

sbit LCD1602_E  = P1^5;


void LcdWaitReady()  //等待液晶准备好

{

    unsigned char sta;


    LCD1602_DB = 0xFF;

    LCD1602_RS = 0;

    LCD1602_RW = 1;

    do

    {

        LCD1602_E = 1;

        sta = LCD1602_DB; //读取状态字

        LCD1602_E = 0;

    } while (sta & 0x80); //bit7等于1表示液晶正忙,重复检测直到其等于0为止

}

void LcdWriteCmd(unsigned char cmd)  //写入命令函数

{

    LcdWaitReady();

    LCD1602_RS = 0;

    LCD1602_RW = 0;

    LCD1602_DB = cmd;

    LCD1602_E  = 1;

    LCD1602_E  = 0;

}

void LcdWriteDat(unsigned char dat)  //写入数据函数

{

    LcdWaitReady();

    LCD1602_RS = 1;

    LCD1602_RW = 0;

    LCD1602_DB = dat;

    LCD1602_E  = 1;

    LCD1602_E  = 0;

}

void LcdShowStr(unsigned char x, unsigned char y, const unsigned char *str)  //显示字符串,屏幕起始坐标(x,y),字符串指针str

{

    unsigned char addr;


    //由输入的显示坐标计算显示RAM的地址

    if (y == 0)

        addr = 0x00 + x; //第一行字符地址从0x00起始

    else

        addr = 0x40 + x; //第二行字符地址从0x40起始


    //由起始显示RAM地址连续写入字符串

    LcdWriteCmd(addr | 0x80); //写入起始地址

    while (*str != '')      //连续写入字符串数据,直到检测到结束符

    {

        LcdWriteDat(*str);

        str++;

    }

}

void LcdInit()  //液晶初始化函数

{

    LcdWriteCmd(0x38);  //16*2显示,5*7点阵,8位数据接口

    LcdWriteCmd(0x0C);  //显示器开,光标关闭

    LcdWriteCmd(0x06);  //文字不动,地址自动+1

    LcdWriteCmd(0x01);  //清屏

}

/***********************I2C.c文件程序源代码*************************/

#include

#include


#define I2CDelay()  {_nop_();_nop_();_nop_();_nop_();}


sbit I2C_SCL = P3^7;

sbit I2C_SDA = P3^6;


void I2CStart()  //产生总线起始信号

{

    I2C_SDA = 1; //首先确保SDA、SCL都是高电平

    I2C_SCL = 1;

    I2CDelay();

    I2C_SDA = 0; //先拉低SDA

    I2CDelay();

    I2C_SCL = 0; //再拉低SCL

}

void I2CStop()   //产生总线停止信号

{

    I2C_SCL = 0; //首先确保SDA、SCL都是低电平

    I2C_SDA = 0;

    I2CDelay();

    I2C_SCL = 1; //先拉高SCL

    I2CDelay();

    I2C_SDA = 1; //再拉高SDA

    I2CDelay();

}

bit I2CWrite(unsigned char dat) //I2C总线写操作,待写入字节dat,返回值为应答状态

{

    bit ack;  //用于暂存应答位的值

    unsigned char mask;  //用于探测字节内某一位值的掩码变量


    for (mask=0x80; mask!=0; mask>>=1) //从高位到低位依次进行

    {

        if ((mask&dat) == 0)  //该位的值输出到SDA上

            I2C_SDA = 0;

        else

            I2C_SDA = 1;

        I2CDelay();

        I2C_SCL = 1;          //拉高SCL

        I2CDelay();

        I2C_SCL = 0;          //再拉低SCL,完成一个位周期

    }

    I2C_SDA = 1;   //8位数据发送完后,主机释放SDA,以检测从机应答

    I2CDelay();

    I2C_SCL = 1;   //拉高SCL

    ack = I2C_SDA; //读取此时的SDA值,即为从机的应答值

    I2CDelay();

    I2C_SCL = 0;   //再拉低SCL完成应答位,并保持住总线


    return (~ack); //应答值取反以符合通常的逻辑:0=不存在或忙或写入失败,1=存在且空闲或写入成功

}

unsigned char I2CReadNAK() //I2C总线读操作,并发送非应答信号,返回值为读到的字节

{

    unsigned char mask;

    unsigned char dat;


    I2C_SDA = 1;  //首先确保主机释放SDA

    for (mask=0x80; mask!=0; mask>>=1) //从高位到低位依次进行

    {

        I2CDelay();

        I2C_SCL = 1;      //拉高SCL

        if(I2C_SDA == 0)  //读取SDA的值

            dat &= ~mask; //为0时,dat中对应位清零

        else

            dat |= mask;  //为1时,dat中对应位置1

        I2CDelay();

        I2C_SCL = 0;      //再拉低SCL,以使从机发送出下一位

    }

    I2C_SDA = 1;   //8位数据发送完后,拉高SDA,发送非应答信号

    I2CDelay();

    I2C_SCL = 1;   //拉高SCL

    I2CDelay();

    I2C_SCL = 0;   //再拉低SCL完成非应答位,并保持住总线


    return dat;

}

unsigned char I2CReadACK() //I2C总线读操作,并发送应答信号,返回值为读到的字节

{

    unsigned char mask;

    unsigned char dat;


    I2C_SDA = 1;  //首先确保主机释放SDA

    for (mask=0x80; mask!=0; mask>>=1) //从高位到低位依次进行

    {

        I2CDelay();

        I2C_SCL = 1;      //拉高SCL

        if(I2C_SDA == 0)  //读取SDA的值

            dat &= ~mask; //为0时,dat中对应位清零

        else

            dat |= mask;  //为1时,dat中对应位置1

        I2CDelay();

        I2C_SCL = 0;      //再拉低SCL,以使从机发送出下一位

    }

    I2C_SDA = 0;   //8位数据发送完后,拉低SDA,发送应答信号

    I2CDelay();

    I2C_SCL = 1;   //拉高SCL

    I2CDelay();

    I2C_SCL = 0;   //再拉低SCL完成应答位,并保持住总线


    return dat;

}

/***********************main.c文件程序源代码*************************/

#include


bit flag300ms = 1;       //300ms定时标志

unsigned char T0RH = 0;  //T0重载值的高字节

unsigned char T0RL = 0;  //T0重载值的低字节


unsigned char GetADCValue(unsigned char chn);

void ValueToString(unsigned char *str, unsigned char val);

void ConfigTimer0(unsigned int ms);

extern void LcdInit();

extern void LcdShowStr(unsigned char x, unsigned char y, const unsigned char *str);

extern void I2CStart();

extern void I2CStop();

extern unsigned char I2CReadACK();

extern unsigned char I2CReadNAK();

extern bit I2CWrite(unsigned char dat);


void main ()

{

    unsigned char val;

    unsigned char str[10];


    EA = 1;           //开总中断

    ConfigTimer0(10); //配置T0定时10ms

    LcdInit();        //初始化液晶   

    LcdShowStr(0, 0, 'AIN0  AIN1  AIN3');  //显示通道指示


    while(1)

    {

        if (flag300ms)

        {

            flag300ms = 0;

            //显示通道0的电压

            val = GetADCValue(0);     //获取ADC通道0的转换值

            ValueToString(str, val);  //转为字符串格式的电压值

            LcdShowStr(0, 1, str);    //显示到液晶上

            //显示通道1的电压

            val = GetADCValue(1);

            ValueToString(str, val);

            LcdShowStr(6, 1, str);

            //显示通道3的电压

            val = GetADCValue(3);

            ValueToString(str, val);

            LcdShowStr(12, 1, str);

        }

    }

}


unsigned char GetADCValue(unsigned char chn)  //读取当前的ADC转换值,chn为ADC通道号0-3

{

    unsigned char val;


                    I2CStart();

            if (!I2CWrite(0x48<<1))   //寻址PCF8591,如未应答,则停止操作并返回0

    {

                I2CStop();

                        return 0;

            }

    I2CWrite(0x40|chn);       //写入控制字节,选择转换通道

    I2CStart();

    I2CWrite((0x48<<1)|0x01); //寻址PCF8591,指定后续为读操作   

            I2CReadACK();             //先空读一个字节,提供采样转换时间

            val = I2CReadNAK();       //读取刚刚转换完的值

            I2CStop();


    return val;

}

void ValueToString(unsigned char *str, unsigned char val)  //ADC转换值转为实际电压值的字符串形式

{

    val = (val*25) / 255;    //电压值=转换结果*2.5V/255,式中的25隐含了一位十进制小数

    str[0] = (val/10) + '0'; //整数位字符

    str[1] = '.';            //小数点

    str[2] = (val%10) + '0'; //小数位字符

    str[3] = 'V';            //电压单位

    str[4] = '';           //结束符

}


void ConfigTimer0(unsigned int ms)  //T0配置函数

{

    unsigned long tmp;


    tmp = 11059200 / 12;      //定时器计数频率

    tmp = (tmp * ms) / 1000;  //计算所需的计数值

    tmp = 65536 - tmp;        //计算定时器重载值

    tmp = tmp + 12;           //修正中断响应延时造成的误差


    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 InterruptTimer0() interrupt 1  //T0中断服务函数

{

    static unsigned char tmr300ms = 0;


    TH0 = T0RH;  //定时器重新加载重载值

    TL0 = T0RL;

    tmr300ms++;

    if (tmr300ms >= 30)  //定时300ms

    {

        tmr300ms = 0;

        flag300ms = 1;

    }

}

细心阅读程序的同学会发现,我们程序在进行A/D读取数据的时候,共使用了两条程序去读了2个字节。I2CReadACK();        val = I2CReadNAK();PCF8591的转换时钟是I2C的SCL,而A/D的特点是每次读到的都是上一次的转换结果,因此我们这里第一条语句的作用是产生一个整体的SCL时钟提供给PCF8591进行A/D转换,第二次是读取当前的转换结果。如果我们只使用第二条语句的话,每次读到的都是上一次的转换结果。


17.5 A/D差分输入信号

细心的同学在阅读PCF8591手册的时候,会发现控制字的第4位和第5位是用于控制PCF8591的模拟输入引脚是单端输入还是差分输入。差分输入是模拟电路常用的一个技巧,这里我们简单介绍一些相关内容。


从严格意义上来讲,所有的信号都是差分信号,因为所有的电压只能是相对于另外一个电压而言。但是大多数系统,我们都是把系统的GND作为基准点。而对于A/D来说的差分输入,通常情况下是除了GND以外,另外两路幅度相同,极性相反的差分输入信号,其实理解起来很简单,就如同我们的跷跷板一样。如图17-8所示。

ps5b120.jpg

图17-8 差分输入原理

差分输入的话,就不是单个输入,而是由2个输入端构成的一组差分输入。我们的PCF8591一共是4个模拟输入端,可以配置成4种模式,最典型的是4个输入端构造成的两路差分模式,如图17-9所示。

ps5b121.jpg

图17-9 PCF8591差分输入模式


当控制字的第4位和第5位都是1的时候,那么4路模拟被配置成2路差分模式输入channel 0和channel 1。我们以channel 0为例,其中AIN0是正向输入端,AIN1是反向输入端,他们之间的信号输入是幅度相同,极性相反的信号,通过减法器后,得到的是两个输入通道的差值,如图17-10所示。

ps5b122.jpg

图17-10 差分输入信号


通常情况下,差分输入的中线是基准电压的一半,我们的基准电压是2.5V,假如1.25V作为中线,V+是AIN0的输入波形,V-是AIN1的输入波形,Signal Value就是经过减法器后的波形。很多A/D都采用差分的方式输入,因为差分输入方式比单端输入来说,有很强的抗干扰能力。


1、单端输入信号时,如果一线上发生干扰变化,比如幅度增大5mv,GND不变,测到的数据会有偏差;而差分信号输入时,当外界存在干扰信号时,几乎同时被耦合到两条线上,幅度增大5mv会同时增大5mv,而接收端关心的只是两个信号的差值,所以外界的这种共模噪声可以被完全抵消掉。


2、由于两根信号的极性相反,他们对外辐射的电磁场可以相互抵消,有效的抑制释放到外界的电磁能量。


在我们的KST-51开发板上,我们没有做差分信号输入的实验环境,由于这个内容在A/D部分比较重要,所以大家还是要学习一下的。


17.6 D/A输出

D/A是和A/D刚好反方向,一个8位的D/A,从0到255,代表了0到2.55V的话,那么我们用单片机给第三个字节发送100,D/A引脚就会输出一个1V的电压,发送200就输出一个2V的电压,很简单,我们用一个简单的程序实现出来,并且通过上、下按键可以增大输出幅度值,每次增加或减小0.1V。如果有万用表的话,可以直接测试一下板子上AOUT点的输出电压,观察它的变化。由于PCF8591的偏置误差最大是50mv(由数据手册提供),所以我们用万用表测到的电压值和理论值之间的误差就应该在50mV以内。


/***********************I2C.c文件程序源代码*************************/

                          略

/***********************keyboard.c文件程序源代码*************************/

#include


sbit KEY_IN_1  = P2^4;  //矩阵按键的扫描输入引脚1

sbit KEY_IN_2  = P2^5;  //矩阵按键的扫描输入引脚2

sbit KEY_IN_3  = P2^6;  //矩阵按键的扫描输入引脚3

sbit KEY_IN_4  = P2^7;  //矩阵按键的扫描输入引脚4

sbit KEY_OUT_1 = P2^3;  //矩阵按键的扫描输出引脚1

sbit KEY_OUT_2 = P2^2;  //矩阵按键的扫描输出引脚2

sbit KEY_OUT_3 = P2^1;  //矩阵按键的扫描输出引脚3

sbit KEY_OUT_4 = P2^0;  //矩阵按键的扫描输出引脚4


const unsigned char code KeyCodeMap[4][4] = { //矩阵按键编号到PC标准键盘键码的映射表

    { '1',  '2',  '3', 0x26 }, //数字键1、数字键2、数字键3、向上键

    { '4',  '5',  '6', 0x25 }, //数字键4、数字键5、数字键6、向左键

    { '7',  '8',  '9', 0x28 }, //数字键7、数字键8、数字键9、向下键

    { '0', 0x1B, 0x0D, 0x27 }  //数字键0、ESC键、  回车键、 向右键

};

unsigned char pdata KeySta[4][4] = {  //全部矩阵按键的当前状态

    {1, 1, 1, 1},

    {1, 1, 1, 1},

    {1, 1, 1, 1},

    {1, 1, 1, 1}

};


extern void KeyAction(unsigned char keycode);


void KeyDriver()  //按键动作驱动函数

{

    unsigned char i, j;

    static unsigned char pdata backup[4][4] = {  //按键值备份,保存前一次的值

        {1, 1, 1, 1},

        {1, 1, 1, 1},

        {1, 1, 1, 1},

        {1, 1, 1, 1}

    };


    for (i=0; i<4; i++)  //循环扫描4*4的矩阵按键

    {

        for (j=0; j<4; j++)

        {

            if (backup[ i][j] != KeySta[ i][j])  //检测按键动作

            {

                if (backup[ i][j] != 0)  //按键按下时执行动作

                {

                    KeyAction(KeyCodeMap[ i][j]);  //调用按键动作函数

                }

                backup[ i][j] = KeySta[ i][j];

            }

        }

    }

}

void KeyScan()  //按键扫描函数

{

    unsigned char i;

    static unsigned char keyout = 0;  //矩阵按键扫描输出计数器

    static unsigned char keybuf[4][4] = {  //按键扫描缓冲区,保存一段时间内的扫描值

        {0xFF, 0xFF, 0xFF, 0xFF},

        {0xFF, 0xFF, 0xFF, 0xFF},

        {0xFF, 0xFF, 0xFF, 0xFF},

        {0xFF, 0xFF, 0xFF, 0xFF}

    };


    //将一行的4个按键值移入缓冲区

    keybuf[keyout][0] = (keybuf[keyout][0] << 1) | KEY_IN_1;

    keybuf[keyout][1] = (keybuf[keyout][1] << 1) | KEY_IN_2;

    keybuf[keyout][2] = (keybuf[keyout][2] << 1) | KEY_IN_3;

    keybuf[keyout][3] = (keybuf[keyout][3] << 1) | KEY_IN_4;


    //消抖后更新按键状态

    for (i=0; i<4; i++)  //每行4个按键,所以循环4次

    {

        if ((keybuf[keyout][ i] & 0x0F) == 0x00)

        {   //连续4次扫描值为0,即16ms(4*4ms)内都只检测到按下状态时,可认为按键已按下

            KeySta[keyout][ i] = 0;

        }

        else if ((keybuf[keyout][ i] & 0x0F) == 0x0F)

        {   //连续4次扫描值为1,即16ms(4*4ms)内都只检测到弹起状态时,可认为按键已弹起

            KeySta[keyout][ i] = 1;

        }

    }


    //执行下一次的扫描输出

    keyout++;

    keyout &= 0x03;

    switch (keyout)

    {

        case 0: KEY_OUT_4 = 1; KEY_OUT_1 = 0; break;

        case 1: KEY_OUT_1 = 1; KEY_OUT_2 = 0; break;

        case 2: KEY_OUT_2 = 1; KEY_OUT_3 = 0; break;

        case 3: KEY_OUT_3 = 1; KEY_OUT_4 = 0; break;

        default: break;

    }

}

/***********************main.c文件程序源代码*************************/

#include


unsigned char T0RH = 0;  //T0重载值的高字节

unsigned char T0RL = 0;  //T0重载值的低字节


void ConfigTimer0(unsigned int ms);

extern void KeyScan();

extern void KeyDriver();

extern void I2CStart();

extern void I2CStop();

extern bit I2CWrite(unsigned char dat);


void main ()

{   

    EA = 1;           //开总中断

    ConfigTimer0(1);  //配置T0定时1ms


    while(1)

    {

        KeyDriver();

    }

}


void SetDACOut(unsigned char val)  //设置DAC输出值

{

            I2CStart();

            if (!I2CWrite(0x48<<1)) //寻址PCF8591,如未应答,则停止操作并返回

    {

                I2CStop();

                        return;

            }

    I2CWrite(0x40);         //写入控制字节

    I2CWrite(val);          //写如DA值  

            I2CStop();

}

void KeyAction(unsigned char keycode)  //按键动作函数,根据键码执行相应动作

{

    static unsigned char volt = 0;  //输出电压值,隐含了一位十进制小数位


    if (keycode == 0x26)  //向上键,增加0.1V电压值

    {

        if (volt < 25)

        {

            volt++;

            SetDACOut(volt*255/25); //转换为AD输出值

        }

    }

    else if (keycode == 0x28)  //向下键,减小0.1V电压值

    {

        if (volt > 0)

        {

            volt--;

            SetDACOut(volt*255/25); //转换为AD输出值

        }

    }

}

void ConfigTimer0(unsigned int ms)  //T0配置函数

{

    unsigned long tmp;


    tmp = 11059200 / 12;      //定时器计数频率

    tmp = (tmp * ms) / 1000;  //计算所需的计数值

    tmp = 65536 - tmp;        //计算定时器重载值

    tmp = tmp + 34;           //修正中断响应延时造成的误差


    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 InterruptTimer0() interrupt 1  //T0中断服务函数

{

    TH0 = T0RH;  //定时器重新加载重载值

    TL0 = T0RL;

    KeyScan();

}


17.7 PCF8591信号发生器

有了D/A这个武器,我们就不仅仅可以输出方波信号了,可以输出任意波形了,比如正弦波、三角波、锯齿波等等。以正弦波为例,首先我们要建立一个正弦波的波表。这些不需要大家去逐一计算,可以通过搜索找到正弦波数据表,然后可以根据时间参数自己选取其中一定量数据作为我们程序的正弦波表,我们的程序代码选取了32个点。

/***********************I2C.c文件程序源代码*************************/

                          略

/***********************keyboard.c文件程序源代码********************/

                          略

/***********************main.c文件程序源代码************************/

#include


unsigned char T0RH = 0;  //T0重载值的高字节

unsigned char T0RL = 0;  //T0重载值的低字节

unsigned char T1RH = 1;  //T1重载值的高字节

unsigned char T1RL = 1;  //T1重载值的低字节


unsigned char code SinWave[] = {  //正弦波波表

127, 152, 176, 198, 217, 233, 245, 252, 255, 252, 245, 233, 217, 198,

176, 152,127, 102,  78,  56,  37,  21,   9,   2,   0,   2,   9,  21,  

37,  56,  78, 102,

    };

unsigned char code TriWave[] = {  //三角波波表

    0,  16,  32,  48,  64,  80,  96, 112, 128, 144, 160, 176, 192, 208,

224, 240,255, 240, 224, 208, 192, 176, 160, 144, 128, 112,  96,  80,  

64,  48,  32,  16,

    };

unsigned char code SawWave[] = {  //锯齿波表

0,   8,  16,  24,  32,  40,  48,  56,  64,  72,  80,  88,  96, 104, 112,

120,128, 136, 144, 152, 160, 168, 176, 184, 192, 200, 208, 216, 224,

232, 240, 248,

    };

unsigned char code *pWave;  //波表指针


void SetWaveFreq(unsigned char freq);

void ConfigTimer0(unsigned int ms);

extern void KeyScan();

extern void KeyDriver();

extern void I2CStart();

extern void I2CStop();

extern bit I2CWrite(unsigned char dat);


void main ()

{   

    EA = 1;           //开总中断

    ConfigTimer0(1);  //配置T0定时1ms

    pWave = SinWave;  //默认正弦波

    SetWaveFreq(10);  //默认频率10Hz


    while(1)

    {

        KeyDriver();

    }

}


void KeyAction(unsigned char keycode)  //按键动作函数,根据键码执行相应动作

{

    static unsigned char wave = 0;


    if (keycode == 0x26)  //向上键,切换波形

    {

        if (wave == 0)

        {

            wave = 1;

            pWave = TriWave;

        }

        else if (wave == 1)

        {

            wave = 2;

            pWave = SawWave;

        }

        else

        {

            wave = 0;

            pWave = SinWave;

        }

    }

}

void SetDACOut(unsigned char val)  //设置DAC输出值

{

            I2CStart();

            if (!I2CWrite(0x48<<1)) //寻址PCF8591,如未应答,则停止操作并返回

    {

                I2CStop();

                        return;

            }

    I2CWrite(0x40);  //写入控制字节

    I2CWrite(val);   //写如DA值  

            I2CStop();

}

void SetWaveFreq(unsigned char freq) //设置输出波形的频率

{

    unsigned long tmp;


    tmp = (11059200/12) / (freq*32); //定时器计数频率,是波形频率的32倍

    tmp = 65536 - tmp;               //计算定时器重载值

    tmp = tmp + 36;                  //修正中断响应延时造成的误差


    T1RH = (unsigned char)(tmp >> 8);  //定时器重载值拆分为高低字节

    T1RL = (unsigned char)tmp;

    TMOD &= 0x0F;   //清零T1的控制位

    TMOD |= 0x10;   //配置T1为模式1

    TH1 = T1RH;     //加载T1重载值

    TL1 = T1RL;

    ET1 = 1;        //使能T1中断

    PT1 = 1;        //设置为高优先级

    TR1 = 1;        //启动T1

}

void ConfigTimer0(unsigned int ms)  //T0配置函数

{

    unsigned long tmp;


    tmp = 11059200 / 12;      //定时器计数频率

    tmp = (tmp * ms) / 1000;  //计算所需的计数值

    tmp = 65536 - tmp;        //计算定时器重载值

    tmp = tmp + 34;           //修正中断响应延时造成的误差


    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 InterruptTimer0() interrupt 1  //T0中断服务函数

{

    TH0 = T0RH;  //定时器重新加载重载值

    TL0 = T0RL;

    KeyScan();

}

void InterruptTimer1() interrupt 3  //T1中断服务函数

{

    static unsigned char i = 0;


    TH1 = T1RH;  //定时器重新加载重载值

    TL1 = T1RL;

    //循环输出波表中的数据

    SetDACOut(pWave[ i]);

    i++;

    if (i >= 32)

    {

        i = 0;

    }

}

这个程序可以通过“向上”按键来实现波形输出切换,但是我们的D/A输出没有办法接到显示界面,所以我们用示波器抓出来波形给大家看一下,如图17-11、图17-12、图17-13所示。

ps5b123.jpg

图17-11 D/A输出正弦波形

ps5b124.jpg

图17-12 D/A输出三角波形

ps5b125.jpg

图17-13 D/A输出锯齿波形


这几张图可以直接说明我们实现的波形发生器的程序。细心的同学会发现我们波形上有很多小锯齿,没有平滑的连起来。这是因为我们DA最多只能输出0~Vref之间的256个离散的电压值,而不是连续的任意值,所以每个离散值都会持续一定的时间,然后跳变到下一个离散值,于是就呈现出了波形上的这种锯齿。在实际开发中,我们只需要在DA后级加一级低通滤波电路,就可以让带锯齿的波形变得平滑起来

进入单片机查看更多内容>>
相关视频
  • RISC-V嵌入式系统开发

  • SOC系统级芯片设计实验

  • 云龙51单片机实训视频教程(王云,字幕版)

  • 2022 Digi-Key KOL 系列: 你见过1GHz主频的单片机吗?Teensy 4.1开发板介绍

  • TI 新一代 C2000™ 微控制器:全方位助力伺服及马达驱动应用

  • MSP430电容触摸技术 - 防水Demo演示

精选电路图
  • PIC单片机控制的遥控防盗报警器电路

  • 红外线探测报警器

  • 短波AM发射器电路设计图

  • 使用ESP8266从NTP服务器获取时间并在OLED显示器上显示

  • 开关电源的基本组成及工作原理

  • 用NE555制作定时器

    相关电子头条文章