从我们学到的知识了解到,我们的单片机是一个典型的数字系统。数字系统只能对输入的数字信号进行处理,其输出信号也是数字信号。但是在工业检测系统和日常生活中的许多物理量都是模拟量,比如温度、长度、压力、速度等等,这些模拟量可以通过传感器变成与之对应的电压、电流等电模拟量。为了实现数字系统对这些电模拟量的检测、运算和控制,就需要一个模拟量和数字量之间相互转换的过程。这节课我们就要学习这个相互转换过程。
17.1 A/D和D/A的基本概念
A/D 是模拟量到数字量的转换,依靠的是模数转换器 (Analog to Digital Converter) ,简称 ADC ; D/A 是数字量到模拟量的转换,依靠的是数模转换器 (Digital to Analog Converter) ,简称 DAC 。他们的道理是完全一样的,只是转换方向不同,因此我们讲解过程主要以 A/D 为例来讲解。
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 一个简单的米尺刻度示意图。
图 17-1 米尺刻度示意图
我们往杯子里倒水,水位会随着倒入的水量的多少而变化。现在就用这个米尺来测量我们杯子里的水位的高度。水位变化是连续的,而我们只能通过尺子上的刻度来读取水位的高度,获取我们想得到的水位的数字量信息。这个过程,就可以简单理解为我们电路中的 ADC 采样。
17.2 A/D的主要指标
我们在选取和使用 A/D 的时候,依靠什么指标来判断很重要。由于 AD 的种类很多,分为积分型、逐次逼近型、并行 / 串行比较型、Σ -Δ型等多种类型。同时指标也比较多,并且有的指标还有轻微差别,具体可上 www.51hei.com查询.在这里我是以同学们便于理解的方法去讲解,如果和某一确定类型 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 所示。
图 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 所示。
图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 所示。
图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 所示。
图 17-5 PCF8591 地址字节
发送到 PCF8591 的第二个字节将被存储在控制寄存器,用于控制 PCF8591 的功能。其中第 3 位和第 7 位是固定的 0 ,另外 6 位各自有各自的作用,如图 17-6 所示,我逐一介绍。
图17-6 PCF8591 控制字节
控制字节的第 6 位是 DA 使能位,这一位置 1 表示 DA 输出引脚使能,会产生模拟电压输出功能。第 4 位和第 5 位可以实现把 PCF8591 的 4 路模拟输入配置成单端模式和差分模式,单端模式和差分模式的区别,我们 17.4 章节有介绍,这里大家只需要知道这两位是配置 AD 输入方式的控制位即可,如图 17-7 所示。
图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 <reg52.h>
#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 != '\0') // 连续写入字符串数据,直到检测到结束符
{
LcdWriteDat(*str);
str++;
}
}
void LcdInit() // 液晶初始化函数
{
LcdWriteCmd(0x38); //16*2 显示, 5*7 点阵, 8 位数据接口
LcdWriteCmd(0x0C); // 显示器开,光标关闭
LcdWriteCmd(0x06); // 文字不动,地址自动 +1
LcdWriteCmd(0x01); // 清屏
}
/***********************I2C.c 文件程序源代码 *************************/
#include <reg52.h>
#include <intrins.h>
#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 <reg52.h>
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] = '\0'; // 结束符
}
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 所示。
图17-8 差分输入原理
差分输入的话,就不是单个输入,而是由 2 个输入端构成的一组差分输入。我们的 PCF8591 一共是 4 个模拟输入端,可以配置成 4 种模式,最典型的是 4 个输入端构造成的两路差分模式,如图 17-9 所示。
图17-9 PCF8591 差分输入模式
当控制字的第 4 位和第 5 位都是 1 的时候,那么 4 路模拟被配置成 2 路差分模式输入 channel 0 和 channel 1 。我们以 channel 0 为例,其中 AIN0 是正向输入端, AIN1 是反向输入端,他们之间的信号输入是幅度相同,极性相反的信号,通过减法器后,得到的是两个输入通道的差值,如图 17-10 所示。
图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 <reg52.h>
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 <reg52.h>
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 <reg52.h>
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 所示。
图 17-11 D/A 输出正弦波形
图 17-12 D/A 输出三角波形
图 17-13 D/A 输出锯齿波形
这几张图可以直接说明我们实现的波形发生器的程序。细心的同学会发现我们波形上有很多小锯齿,没有平滑的连起来。这是因为我们 DA 最多只能输出 0 ~Vref之间的 256 个离散的电压值,而不是连续的任意值,所以每个离散值都会持续一定的时间,然后跳变到下一个离散值,于是就呈现出了波形上的这种锯齿。在实际开发中,我们只需要在 DA 后级加一级低通滤波电路,就可以让带锯齿的波形变得平滑起来。