多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

PCF8591 I2C 实现 AD/DA 转换:单片机调光与电压回读校准

PCF8591 I2C 实现 AD/DA 转换:单片机调光与电压回读校准 简介这是一份面向单片机应用技术学习者的可调光台灯设计课件围绕A/D与D/A转换芯片PCF8591展开适合电子、自动化专业学生及嵌入式入门者理解模拟量与数字量的转换控制。课件按任务导入、硬件电路、控制程序、举一反三组织先提出用PCF8591产生05V模拟电压、经1KΩ限流电阻驱动LED的任务再讲单片机与PCF8591的I2C总线连接随后剖析DAC_PCF8591写入、IIC_Start与IIC_SendByte等时序代码以及按键S1、S2增减电压调节亮度的主程序末尾延伸到正弦波输出与数字万用表设计。压缩包共1个pptx文件约751KB轻量便于课堂演示或自学。已有274人学习可作为任务驱动教学的参考素材帮助读者把A/D、D/A转换、I2C通信与单片机编程串联成完整认识。1. 搜 AD 转换先撞上一堆 Altium DesignerPCF8591 到底解决什么问题想补一下模数转换的基础搜索引擎给你的却是一屏 PCB 画图教程和封装库下载这个岔路口很多人都走过。这里的 AD/DA 说的是模拟量与数字量之间的相互转换跟画板子的那套软件没有关系。PCF8591 这颗 8 位芯片的设计思路很讨巧它把一路 DAC 和四路 ADC 塞进同一颗 I2C 从机里只用两根线就能跟单片机对话。拿它做一盏可调光台灯链路短到不能再短单片机算出 0 到 255 的数字量通过 I2C 写进 PCF8591 的 DAC 寄存器芯片对应的输出引脚就吐出一个 0 到 5V 的模拟电压再串一颗 1K 电阻驱动 LED亮度就跟着变。反向再走一遍把输出引脚接回 ADC 通道还能自己读自己用采样值验证刚才写出去的到底对不对。适合手上有 51 开发板、想把 I2C 时序从电平翻转的层面看明白的人也适合课程设计抽到「可调光台灯」或「数字电压表」题目的同学。2. PCF8591 的 I2C 地址帧与控制字格式拆解很多教程直接甩一句「写地址填 0x90读地址填 0x91」照抄能跑但一旦换一块板子、换一种接线方式程序立刻装死。搞懂这两个字节怎么来的比背下来有用得多。2.1 写地址 0x90 与读地址 0x91 的由来PCF8591 的从机地址由固定部分和三个引脚共同决定高四位固定为1001低三位对应 A2、A1、A0 三个地址引脚。开发板上通常把这三个脚全部接地于是 7 位地址就是1001 000也就是 0x48。I2C 协议在总线上传的是 8 位前 7 位是地址最低位表示本次是写还是读。把 0x48 左移一位再补上读写位就得到两个常用字节。A2 A1 A0 状态7 位地址写地址R/W0读地址R/W1000全接地0x480x900x910010x490x920x930100x4A0x940x95111全接高0x4F0x9E0x9F提示如果同一条 I2C 总线上挂了两片 PCF8591必须把其中一片的 A0 拉高否则两个从机同时应答总线上的数据会被拉成低电平现象是读数永远为 0。2.2 控制字 0x40 的位定义写地址之后紧跟的第二个字节是控制字它决定芯片怎么工作。代码里出现的 0x40 拆开看就是0100 0000逐位对应的含义如下。位名称本设计取值作用说明7固定位0保留固定写 06模拟输出使能1置 1 才打开 DAC否则 DAC 输出保持高阻5模拟输入编程0单端输入模式4自动增量0关闭通道自动递增3、2通道选择00指定 ADC 采样的通道号1、0固定位00保留通常写 0把 DMA 输出和 ADC 读取对比一下就很清楚做调光时控制字写 0x40第 6 位置 1 打开模拟输出做电压采样时控制字写 0x00第 6 位清零同时通过低两位选择通道。同一个字节改一位就切换了芯片的角色。2.3 起始、停止与字节收发的时序实现I2C 的一切都建立在 SDA 和 SCL 两根线的电平关系上。核心规则只有一条SCL 为高电平期间SDA 上的数据必须保持稳定任何跳变都被当作控制信号。// 起始条件SCL 为高时SDA 由高变低 void IIC_Start(void) { SDA 1; SCL 1; Delay4us(); SDA 0; Delay4us(); // 这一跳变被从机识别为 START SCL 0; // 拉低 SCL准备发送数据 } // 停止条件SCL 为高时SDA 由低变高 void IIC_Stop(void) { SDA 0; SCL 1; Delay4us(); SDA 1; Delay4us(); // 这一跳变被从机识别为 STOP }发送一个字节时数据必须最高位先行每发送一位就在 SCL 上打一个高电平脉冲第 9 个脉冲用来读取从机的应答。void IIC_SendByte(uchar wd) { uchar i; for (i 0; i 8; i) // 循环移入 8 位MSB 先行 { SDA (bit)(wd 0x80); // 取当前最高位送到 SDA _nop_(); _nop_(); // 数据建立时间防止毛刺 SCL 1; Delay4us(); // 高电平期间数据保持稳定 SCL 0; wd 1; // 左移一位准备下一个 bit } Delay4us(); SDA 1; // 主机释放 SDA交给从机驱动 SCL 1; Delay4us(); IIC_ERROR SDA; // 读到 0 表示应答1 表示无应答 SCL 0; Delay4us(); }Delay4us()的长度直接决定总线速率。半周期取 4us一个完整时钟约 8us对应 125kHz 左右落在 PCF8591 支持的 100kHz 标准模式附近。如果把这几个延时压缩到 1us总线跑到 500kHz芯片根本来不及响应IIC_ERROR会一直读到 1。反过来把延时拉到 20us通信虽然稳但 DAC 刷新一次要接近一毫秒调光时会明显感觉到响应迟滞。_nop_()这两条空指令容易被忽略。51 单片机的 IO 口是准双向结构写 1 之后靠内部弱上拉把电平抬起来这个过程需要时间。省掉这两条SDA 还没完全拉高SCL 就已经跳上去了从机采样到的是中间电平现象是偶发通信失败而且复位重来又能跑非常难查。3. 硬件链路从单片机 IO 到 LED 亮度的电流计算光有程序跑不通输出引脚必须接到正确的负载上。这一段是很多台灯方案亮度不够或烧灯珠的根源。3.1 0 到 5V 输出范围与 1K 限流电阻的取值PCF8591 的 DAC 输出电压范围由它的参考电压 VREF 决定本设计里 VREF 接 5V所以数字量 0 对应 0.0V255 对应 5.0V。输出引脚经过一颗 1KΩ 电阻再接到 LED 阳极LED 阴极接地。LED 的正向压降 Vf 是关键参数普通红色灯珠约 1.8V 到 2.0V蓝色和白色约 3.0V 到 3.2V。电流按欧姆定律反推。DAC 输出电压假设 Vf 2.0V实际电流亮度表现0.0V低于导通阈值0mA完全不亮1.0V低于导通阈值0mA完全不亮2.0V压差为 00mA临界刚有微弱余光3.0V压差 1.0V约 1.0mA微亮暗环境下可见5.0V压差 3.0V约 3.0mA最亮但远未到灯珠极限注意3mA 对大多数 LED 来说相当保守亮是亮但不刺眼。如果换成 Vf 只有 1.8V 的红灯珠5V 时电流能到 3.2mA仍在安全区。真正要警惕的是把 1K 电阻换成 100Ω满量程电流会冲到 30mA多数 3mm 灯珠的连续工作电流只有 20mA跑几个小时就开始发黄发暗。选 1K 的真正理由不是亮度而是线性度。电阻越大电流随电压变化的曲线越接近直线DAC 每加一个步进人眼看到的亮度变化越均匀。用 100Ω 时低电压段电流几乎为零高电压段急剧上升调节手感会非常突兀。3.2 8 位步进 5 对应 0.1V 的换算关系程序里每次按键让voltage加减 5这个 5 不是随便定的。8 位 DAC 把 0 到 5V 切成 256 份最小分辨率是// 1 LSB 对应的电压 // 5.0V / 255 0.0196V约 19.6mV // 一次按键步进 5 个 LSB // 5 * 0.0196V 0.098V约等于 0.1V所以每按一次输出电压变化约 0.1V从初始的voltage 125开始对应125 * 0.0196 2.45V正好落在亮度曲线的中间位置往两边都有调节余量。这个初始值选得很讲究如果从 0 开始前几次按键按下去灯都不亮用户会以为电路坏了。这里有个细节值得留意。LED 亮度跟电流近似成正比但人眼对亮度的感知是对数关系。电压从 2.45V 抬到 2.55V电流只多了 0.1mA肉眼几乎看不出差别。想让调节手感更明显可以把步进从 5 改成 15 甚至 25代价是可调档位变少。课程设计里常见做法是保留 0.1V 步进但把两个按键做成「短按微调、长按连续加速」。3.3 上拉电阻与电源去耦I2C 总线是开漏结构SDA 和 SCL 都必须外接上拉电阻否则线永远拉不高通信从一开始就失败。// 典型接法SDA、SCL 各接一只 4.7K 到 5V // 总线电容较大或走线较长时可降到 2.2K 提升上升沿速度 // 但电阻太小会增加从机灌电流负担一般不低于 1K pullup_r 4700; // 单位欧姆上拉电阻的取值要在上升沿速度和功耗之间折中。4.7K 是绝大多数开发板的默认值直接照搬即可。如果总线上只挂一片 PCF8591、走线不到 10 厘米用 10K 也能稳定工作。去耦电容同样不能省。在 PCF8591 的 VDD 和 GND 之间并一只 0.1uF 瓷片电容位置尽量贴近芯片引脚。DAC 每次更新输出都会在电源上引起一个小电流脉冲没有这个电容脉冲会耦合到 ADC 的参考电压上表现为采样值出现几十毫伏的随机跳动。4. DAC_PCF8591 函数拆解与按键调光逻辑时序和硬件都成立了接下来看程序怎么把这些字节组织起来。4.1 DAC_PCF8591 的逐行执行过程这个函数只做一件事把两个字节通过 I2C 送进 PCF8591参数分别是控制字和待转换的数字量。void DAC_PCF8591(uchar CtrlByte, uchar wdata) { IIC_Start(); // 发起始条件占用总线 IIC_SendByte(0x90); // 写地址选中 PCF8591 if (IIC_ERROR 1) return; // 没有应答说明从机不在直接退出 IIC_SendByte(CtrlByte); // 控制字节0x40 打开模拟输出 if (IIC_ERROR 1) return; IIC_SendByte(wdata); // 8 位数字量写入 DAC 数据寄存器 if (IIC_ERROR 1) return; IIC_Stop(); // 停止条件触发芯片更新输出 }三次IIC_ERROR判断是这套代码的关键防线。任何一步没收到从机应答就提前返回避免在已经断掉的总线上继续硬发数据。实际调试时如果发现灯完全没反应可以在这三个分支里各点一个 LED 或者置一个标志位就能快速定位是地址错、控制字错还是数据没送到。还有一个容易踩的坑IIC_Stop()之后PCF8591 才会真正把新数据锁存到输出端。如果程序里连续调用这个函数而不给间隔中间那些值会被快速覆盖DAC 输出看起来像是直接跳变锯齿波的台阶感全没了。4.2 按键步进、边界回卷与主循环结构主函数把 DAC 输出和两个按键绑在一起形成一个持续运行的循环。void main(void) { unsigned char voltage 125; // 0 对应 0.0V255 对应 5.0V while (1) { DAC_PCF8591(0x40, voltage); // 每轮循环都刷新一次输出 if (S1 0) // S1 按下亮度上调 { if (voltage 250) voltage 125; // 到顶后回到中位防止越界 else voltage 5; } if (S2 0) // S2 按下亮度下调 { if (voltage 5) voltage 125; else voltage - 5; } delay_ms(1); } }原始示例把判断写成了if (voltage 255)从 125 每次加 5 确实能精确命中 255但一旦改动步进值比如换成加 3 或者加 7就再也回不到边界voltage会从 254 加 5 溢出成 3亮度突然从最亮掉到几乎不亮。改成 250这种区间判断换任何步进都不会出问题。delay_ms(1)的作用有两层。一是给按键留出足够的按下时间人手一次按键的持续时间在几十毫秒量级循环跑太快会在一次按压里重复加好几次。二是降低 I2C 总线的占用率让 DAC 输出的更新频率和按键扫描解耦。提示这个delay_ms(1)并不能替代按键消抖。机械按键在按下和弹起的瞬间会产生 5 到 10ms 的抖动循环转一圈只要 1ms一次按压照样可能被识别成两三次。稳妥的做法是在检测到低电平后延时 10ms 再确认一次状态。4.3 锯齿波扫描的时序代价把按键换成循环自增就得到一段经典的锯齿波发生程序。void main(void) { uchar i; while (1) { for (i 0; i 255; i) DAC_PCF8591(0x40, i); // 从暗到亮逐步爬升 for (i 255; i 0; i--) DAC_PCF8591(0x40, i); // 从亮到暗逐步回落 } }上升沿和下降沿各 255 次调用一趟完整周期共 510 次 DAC 写入。每次写入包含起始、三个字节、停止按前面 4us 半周期算单次大概在 200us 出头。阶段调用次数单次耗时估算阶段总耗时上升沿 0 到 255255约 210us约 54ms下降沿 255 到 0255约 210us约 54ms一个完整周期510约 210us约 108ms108ms 对应大约 9Hz肉眼看到的就是一个来回呼吸的效果而不是闪烁。如果觉得呼吸太快把两个循环外面各加一层计数重复执行若干次再进入下一轮。如果觉得台阶感太强那是 8 位分辨率的固有限制换 12 位的 DAC 芯片才能改善。5. ADC 回读校准与「空读一次」陷阱灯亮了但写出去的数字量到底对应多少伏靠万用表量是一种办法用 PCF8591 自己的 ADC 读回来是另一种而且更省事。5.1 ADC_PCF8591 里为什么要先空读一次PCF8591 的 ADC 是逐次逼近结构转换结果在下一个读周期才被送上总线。也就是说第一次读到的字节很可能是上一次采样的旧值直接拿来用会导致显示值总比真实值慢一拍。void ADC_PCF8591(uchar CtrlByte) { IIC_Start(); IIC_SendByte(0x90); // 写地址 if (IIC_ERROR 1) return; IIC_SendByte(CtrlByte); // 控制字0x00 表示通道 0 单端输入 if (IIC_ERROR 1) return; IIC_Start(); // 重复起始条件不释放总线 IIC_SendByte(0x91); // 读地址 if (IIC_ERROR 1) return; IIC_ReceiveByte(); // 空读一次丢弃上一次的转换结果 Slave_ACK(); // 主机回一个应答请求下一个字节 Recv_Buffer IIC_ReceiveByte(); // 这一次才是当前通道的有效数据 Slave_NOACK(); // 收尾用非应答告诉从机别再发了 IIC_Stop(); }最后两字节的应答处理顺序很容易写反中间字节用应答表示「我还要继续读」最后一个字节用非应答表示「读完了」。如果最后一个字节还发应答从机会以为主机要继续接着往总线上吐数据把后面的时序全部打乱。5.2 采样值到电压的换算Recv_Buffer是一个 0 到 255 的原始码值换算成电压只要按参考电压等比缩放。// 参考电压 5.0V8 位满量程 255 // voltage_mv Recv_Buffer * 5000UL / 255; // 用整数运算避免浮点单位是毫伏 // 例Recv_Buffer 128 时voltage_mv 2510即 2.51V用无符号长整型先乘后除是为了避免在 8 位机上溢出。如果写成Recv_Buffer / 255 * 5000整除会先把结果变成 0读数永远是 0。5.3 用 DA 输出加 AD 回读做闭环校准把 DAC 的输出引脚直接跳线接到 ADC 的通道 0 输入就得到一个自检回路程序写出去一个值立刻读回来换算成电压两者对不上就说明链路有问题。写入的数字量理论输出电压实测正常范围偏差偏大的可能原因00.00V0 到 0.02V输出端有残余失调641.25V1.23 到 1.27V参考电压不是精确 5.0V1282.51V2.48 到 2.54V芯片温漂或电源波动2555.00V4.95 到 5.00V输出驱动能力不足带载后掉压实测和理论差 20mV 以内属于正常8 位芯片本身的量化误差就有将近 20mV。如果偏差稳定存在且方向一致说明 VREF 实际不是 5.00V用万用表量一下 VREF 引脚把换算公式里的 5000 换成实测值精度立刻上一个台阶。做数字万用表题目时这一步校准是必做的跳过它读数永远差着零点几伏。本文还有配套的精品资源点击获取
返回列表