简介:PCF8591是一款集成I2C总线接口的8位DA/ADC转换芯片,本项目以其DA转换功能为主线,面向单片机学习者和嵌入式入门开发者,提供可直接导入的工程代码与硬件适配说明。芯片内置8位DAC和ADC,支持4路模拟输入,通过权电阻网络等方式实现数字到模拟信号的转换。使用时先配置I2C接口,再发送通道选择与转换命令,将数字量写入DA寄存器即可在输出引脚得到对应模拟电压,适用于音频输出、模拟信号发生、工业控制等场景。压缩包共15个文件,约31KB,包含C语言源文件、Keil工程文件、编译生成的hex烧录文件、备份文件以及lst、m51等列表映射文件,便于读者查看编译过程、对照代码理解底层实现。已有1302人学习下载。代码覆盖了完整的DA转换流程,同时展示ADC回读示例,并针对PCF8591的I2C时序、通道选择、参考电压等关键细节给出注意事项,可帮助读者快速掌握该芯片在单片机系统中的实际用法。
1. 把 PCF8591 的 DA 转换玩明白:一颗 8 位 DAC 撑起单片机的模拟输出
不少做单片机开发的朋友第一次接触 PCF8591,都是冲着它的 4 路 ADC 去的,DA 转换反而被当成附赠功能。但实际用下来你会发现,这颗芯片的 DA 转换恰恰是低成本方案里最实用的模拟输出手段:一条 I2C 总线写一个字节,就能输出 0 到 Vref 之间的任意电压,用来做音量控制、调光、传感器偏置电压、信号发生器底座都够用。本文不讲 ADC 那半边,专门把 DA 转换从原理、驱动到踩坑拆开讲透,适合正在调模拟输出、或者想把 PCF8591 的 DAC 功能真正用起来的单片机和嵌入式开发者。
2. 芯片侧原理与 I2C 协议:先搞懂控制字节再动代码
2.1 控制字节的每一位都是工作模式开关
PCF8591 的 DA 转换和 ADC 共享同一个控制字节,这个字节通过 I2C 总线在地址之后发送。控制字节的 bit 位定义如下:
- bit 0 和 bit 1(通道选择):ADC 的输入通道选择,DA 转换时用不到,但必须保持有效值,一般填 0。
- bit 2(Auto-increment):自动增量标志,置 1 时连续读多个 ADC 通道会自动切换,DA 转换建议置 0。
- bit 4(Analog output enable):这是 DA 转换的总开关,必须置 1,否则 DA 输出引脚(AOUT)呈高阻态,量不到电压。
- bit 6(Analog input configuration):ADC 输入模式选择,单端还是差分,DA 转换时同样不影响,但建议和实际接线保持一致。
这里最容易踩的第一个坑就是控制字节的 bit 4。很多人写了地址、发了数据,万用表量 AOUT 引脚却是 0V,查了半天发现是控制字节里 DAC 使能位没置 1。
我一般初始化时会固定写 0x40 这个值:bit 4 置 1 使能 DA 输出,bit 6 置 0 选择单端输入,其余位清零。如果之后要切到 ADC 模式,再按需修改这个字节重新发送即可。
2.2 写入流程与字节顺序:地址、控制字节、数据字节缺一不可
PCF8591 的 I2C 从机地址是 1001 A2 A1 A0,其中 A0、A1 是板上引脚电平决定的地址位。写 DA 转换数据时,完整的一帧需要包含三部分:
- 起始信号 + 从机地址(写方向,最低位为 0)
- 控制字节(配置 DA 输出使能等工作模式)
- DAC 数据字节(要输出的 8 位数字量,范围 0x00~0xFF)
值得注意的一个细节:PCF8591 的数据手册里写了,DAC 数据寄存器是缓冲的,但写入后并不是立即生效。实际行为是——你写入的数据先进了 DAC 寄存器,转换结果在下一个 I2C 时钟周期才更新到输出引脚上。这在高速连续写入时会造成一个时钟周期的延迟,做音频输出或快速斜坡时要注意。
用伪代码描述一遍:
// I2C 写 DA 值 uint8_t addr = 0x90; // 从机地址,A0=0, A1=0 时为 0x90 uint8_t ctrl = 0x40; // 控制字节:bit4=1 使能 DA 输出 uint8_t value = 0x80; // 要输出的数字量,0x80 对应中间电压 i2c_start(); i2c_send(addr | 0x00); // 写方向 i2c_send(ctrl); // 控制字节 i2c_send(value); // DAC 数据 i2c_stop();2.3 与 ADC 共用控制字节时的配置冲突
PCF8591 的特点是 ADC 和 DAC 共用控制字节,这意味着每次操作都要重新发送完整的控制字节。如果你先做了一次 ADC 读取,把控制字节设置成了0x01(选择通道 1),紧接着去写 DA 输出,必须重新发送一次带0x40的控制字节,否则 DA 输出还是关闭的。
这个看似简单的问题在代码复用时最容易翻车。很多人会封装一个pcf8591_read_adc(channel)函数,内部自己维护控制字节,但pcf8591_write_dac(value)函数里没有重新设置控制字节的高位,导致读写切换时 DA 输出不稳定或干脆没输出。
我的习惯是读写函数各自独立维护控制字节:
uint8_t pcf8591_write_dac(uint8_t value) { uint8_t ctrl = 0x40; // 每次都重建控制字节,不依赖全局状态 i2c_start(); if (i2c_send(addr) != 0) return 1; if (i2c_send(ctrl) != 0) return 1; if (i2c_send(value) != 0) return 1; i2c_stop(); return 0; }3. 驱动实现与电压换算:从写入函数到可用波形输出
3.1 基础驱动封装:软件 I2C 版,适配任意 GPIO
之前的代码片段偏伪代码,实际项目中我更常用软件 I2C,这样 GPIO 可以随便选,不受硬件 I2C 引脚限制。下面是一个可以直接移植到 STM32 或其它单片机上的软件 I2C 驱动骨架:
// 软件 I2C 引脚定义 #define SDA_PIN GPIO_PIN_5 #define SCL_PIN GPIO_PIN_6 #define SDA_PORT GPIOB #define SCL_PORT GPIOB static void i2c_delay(void) { // 约 5us 延时,100kHz 标准模式 for (volatile int i = 0; i < 20; i++); } static void i2c_start(void) { SDA_HIGH(); SCL_HIGH(); i2c_delay(); SDA_LOW(); // SDA 拉低,产生起始条件 i2c_delay(); SCL_LOW(); } static void i2c_stop(void) { SDA_LOW(); SCL_HIGH(); i2c_delay(); SDA_HIGH(); // SDA 拉高,产生停止条件 i2c_delay(); } static uint8_t i2c_send_byte(uint8_t byte) { for (int i = 7; i >= 0; i--) { if (byte & (1 << i)) SDA_HIGH(); else SDA_LOW(); SCL_HIGH(); i2c_delay(); SCL_LOW(); i2c_delay(); } // 读取 ACK SDA_HIGH(); // 释放 SDA,等待从机拉低 SCL_HIGH(); i2c_delay(); uint8_t ack = (SDA_READ() == 0) ? 0 : 1; SCL_LOW(); i2c_delay(); return ack; }软件 I2C 的好处是任何单片机都能用,只要有两个 GPIO 就行。要注意延时函数的时长决定了 I2C 速率,标准模式 100kHz 下时钟高电平和低电平各约 5us。如果换成硬件 I2C 或者拉高速率,这个延时要相应缩短。
3.2 模拟斜坡输出:验证 DA 是否真正工作的最小实验
写好了驱动,先做一个最简单的斜坡测试:让输出从 0 递增到 255,再递减回 0,用示波器或者万用表观察 AOUT 引脚的变化。这一步能快速验证接线、地址、控制字节是否都对了。
void dac_ramp_test(void) { // 上升斜坡 for (uint16_t val = 0; val <= 255; val++) { pcf8591_write_dac((uint8_t)val); delay_ms(5); // 每步 5ms,让电压稳定 } // 下降斜坡 for (int16_t val = 255; val >= 0; val--) { pcf8591_write_dac((uint8_t)val); delay_ms(5); } }这个测试如果正常,示波器上会看到一个锯齿波。如果看到的是突变台阶,说明延时不够,DAC 输出还没有稳定;如果波形是条直线,要么控制字节没写好,要么硬件接线有问题。
3.3 输出电压计算:理论值和实测值的差距在哪
PCF8591 的 DA 输出电压计算公式是:
Vout = Vref × (value / 256)注意是除以 256 不是 255!这是 8 位 DAC 的常见坑。DAC 的分辨率是 2^8 = 256 个离散电平,当 value = 0xFF(255)时,输出电压是 Vref × (255/256),不是 Vref。比如说 Vref = 5V,最大输出约为 4.98V,永远差那 20mV 的满量程量。
实际使用中还有几个因素会让实测值和理论值偏离:
- Vref 引脚上的电压不是精确的 5.00V,可能只有 4.95V,需要实际测量
- PCF8591 的 DA 输出是轨到轨的,但输出驱动能力有限,后级负载电阻太小会造成压降
- 芯片手册里的参考电压值在实际电路里受电源纹波影响,最好在 Vref 引脚加一颗 100nF 的去耦电容
如果对精度有要求,建议先测出 Vref 的实际电压,再代入公式反算:
// 反向计算:给定目标电压,求 DAC 写入值 float vref = 4.95f; // 实际测得的 Vref 电压 float target = 2.50f; // 目标输出电压 uint8_t dac_value = (uint8_t)(target * 256.0f / vref); if (dac_value > 255) dac_value = 255; pcf8591_write_dac(dac_value);4. 避坑与常见问题:调 DA 输出时最容易翻车的五个实战场景
4.1 问题一:写的值变了,输出电压纹丝不动
现象:程序里循环改变 DAC 数值,但 AOUT 引脚电压恒定不变。
原因:控制字节的 bit 4(模拟输出使能)没有被置 1。很多人第一次用的时候不知道有这个位,或者从 ADC 代码改过来时漏了。
解决:检查发送的控制字节,确保 bit 4 = 1,即写入 0x40 或 0x48(同时开启自动增量)。用逻辑分析仪抓 I2C 波形,确认第二个字节确实是 0x40 而不是 0x00 或 0x01。
4.2 问题二:输出波形有台阶感,且台阶高度不一致
现象:斜坡测试时波形不是平滑锯齿,而是有可见阶梯,且每级台阶电压跳变不一致。
原因:Vref 引脚引入了外部干扰,或者 I2C 速率过高导致数据写入不稳定。另一个可能是 Vref 和 VDD 共用一个电源,电源本身有纹波。
解决:Vref 和 VDD 建议各接一颗 10uF + 100nF 滤波电容,尽量靠近芯片引脚。I2C 速率降到 100kHz 标准模式,避免高速写入时的时序抖动影响 DAC 寄存器更新。
4.3 问题三:I2C 总线上挂了多颗 PCF8591,地址冲突导致写入错乱
现象:多颗芯片同时接入,写 DA 时只有其中一颗有输出,或者所有芯片输出都异常。
原因:PCF8591 的地址位只有 A0、A1 两位,最多 4 颗可用不同地址。如果你用了超过 4 颗,或者 A0/A1 引脚悬空(读取到不定电平),就会地址冲突。
解决:确认 A0、A1 引脚都有明确的上下拉电阻,不要悬空。地址计算规则:基础地址 0x90(1001 0000),A1 位对应 bit 1,A0 位对应 bit 0。比如 A0=1、A1=0 时地址为 0x92。如果超过了 4 颗,需要用 I2C 多路复用器或者换带更多地址位的芯片。
4.4 问题四:DA 输出接了负载后电压跌落严重
现象:空载时输出电压正确,接上 1kΩ 负载后电压明显下降。
原因:PCF8591 的 DA 输出驱动能力有限,手册里标称输出电流只有 1mA 级别。负载阻抗太低时,输出引脚无法维持设定电压。
解决:在 DA 输出后加一级电压跟随器(运算放大器),或者改用阻抗更高的负载。如果你要驱动 LED 或蜂鸣器这类低阻抗器件,建议用运放做缓冲,顺便还能通过调整反馈电阻来放大或缩小输出电压范围。
4.5 问题五:上电瞬间 DA 输出是高阻态,后级电路误动作
现象:系统上电时,DA 输出引脚既不是 0V 也不是 Vref,而是浮空状态,导致接在后面的比较器或功放误触发。
原因:PCF8591 上电后 DA 输出使能位默认是 0,输出呈高阻态。这是芯片设计决定的,不是你的代码 bug。
解决:硬件上在后级电路加入上拉/下拉电阻,让高阻态时电压钳位在安全电平。软件上在系统初始化时尽快完成 DA 配置并写入一个安全值,避免长时间停留在高阻状态。一般情况下初始化顺序应该是:I2C 外设时钟 → GPIO → PCF8591 地址探测 → 写 0x40 使能 DA → 写入默认电压值(比如 0x00)。
5. 进阶用法:用 DA 输出做简易信号源并校准精度
如果你前面都调通了,下一步可以尝试用 DA 输出制作一个低频信号发生器,比如产生 100Hz 的正弦波。做法是把一个周期的正弦波形采样成 256 个点,用定时器中断匀速写入 DAC:
// 正弦波查找表,256 点,幅值对应 0x00~0xFF const uint8_t sine_table[256] = { 128, 131, 134, 137, 140, 143, 146, 149, // ... 其余点由工具生成,此处省略 }; // 定时器中断里更新 DA 输出 void TIM_IRQHandler(void) { static uint16_t idx = 0; pcf8591_write_dac(sine_table[idx]); idx = (idx + 1) & 0xFF; }波形频率由定时器中断频率决定,256 点查表、10kHz 中断频率对应约 39Hz 正弦波。再配合一个二阶低通滤波器,把台阶感磨掉,就能得到比较干净的模拟信号。
最后说一个校准的技巧。用万用表实测输出电压,和理论值对比,会发现存在固定的偏移和增益误差。我的做法是:在 0x00、0x80、0xFF 三个点分别测量,算出偏移量和实际增益系数,把公式修正为:
value = (target - offset) / (vref * gain / 256)其中 offset 和 gain 每次换芯片都要重新测,因为每个芯片的 DAC 线性度都有细微差别。从那以后我每次用 DA 输出前都会强制走一遍三点点测的流程,宁可多花两分钟,也不愿意在项目后期才发现在低电压段输出偏了快 50mV。这套方法同样适合拿去校准其它带 DAC 的芯片,希望帮到你。
本文还有配套的精品资源,点击获取