51单片机ADC/DAC与PWM实战:从模拟信号采集到PWM调光全解析 📅 发布时间:2026/8/28 23:27:54 👁 浏览次数: 1. 项目缘起从“看得见”到“摸得着”的信号世界搞单片机开发尤其是从51单片机入门的朋友常常会卡在一个关键节点上我们写的程序处理的都是0和1的数字信号但真实世界里的温度、光照、声音、压力都是连续变化的模拟信号。这就好比我们只会说“开灯”和“关灯”两个指令却无法描述“把灯光调到黄昏时分那种柔和的亮度”。这个从模拟世界到数字世界的“翻译”过程就是模数转换ADC反过来把数字指令变回模拟控制就是数模转换DAC。而PWM则是一种非常巧妙且成本低廉的“模拟”DAC实现方式。我刚开始玩51的时候总觉得ADC、DAC这些词很高深是高级芯片才有的功能。后来才发现哪怕是最基础的STC89C52通过外接ADC芯片如PCF8591、ADC0804或者利用其内部资源某些增强型51内核配合PWM技术完全能玩转模拟信号。这次我就以最经典的51单片机平台为例抛开复杂的理论推导用C语言代码和实际电路带你打通“感知与控制”的任督二脉。无论你是想做个电子温度计、光控小夜灯还是想控制电机的转速、LED的呼吸效果这篇文章里的思路和代码都能直接拿去用。2. 核心概念扫盲ADC、DAC与PWM到底是什么关系在动手写代码前我们必须先理清这三个核心概念的本质以及它们在单片机系统中的角色。很多初学者容易混淆觉得DAC和PWM是并列的两种技术其实不然。ADC模数转换器它的角色是一个“侦察兵”。它的任务是把外部连续的模拟电压信号比如0-5V按照一定的规则转换成一个单片机能够理解和处理的数字值。这个规则的核心是“分辨率”。比如一个8位的ADC它会把0-5V的电压范围分成2的8次方也就是256个等级。那么每个数字等级对应的电压变化量就是 5V / 256 ≈ 0.0195V19.5mV。当ADC读到2.5V的电压时它就会报告给单片机一个数字值128因为2.5V / 0.0195V ≈ 128。这样单片机就知道“哦现在的电压大概在中间值”。DAC数模转换器它的角色是一个“指挥官”。它执行与ADC相反的任务接收单片机发出的数字指令比如一个8位的数字200然后输出一个对应的模拟电压比如200 * 0.0195V ≈ 3.91V。这样单片机就能用数字精确控制外部设备的电压从而控制其亮度、速度、音量等。PWM脉冲宽度调制它不是真正的DAC而是一个“模仿者”或“巧匠”。真正的DAC芯片内部有复杂的电阻网络和基准源能输出真正平滑、连续的直流电压。而PWM则取巧了它通过快速开关比如控制一个IO口高低电平来产生一个方波信号。如果我们改变方波中高电平所占时间的比例即占空比那么从宏观效果上看其平均电压就发生了变化。例如一个5V的方波占空比为50%其平均电压就是2.5V占空比为80%平均电压就是4V。用电容电感等元件对这个PWM波进行滤波就能得到一个比较平滑的直流电压。对于电机调速、LED调光这类对电压平滑度要求不高的场景PWM是成本最低、实现最简单的“类DAC”方案。所以三者的关系可以这样概括ADC负责“感知”世界输入DAC/PWM负责“影响”世界输出。而PWM是单片机在没有硬件DAC时实现模拟量输出的最常用替代方案。3. 硬件准备与电路设计搭建你的信号转换实验平台理论清楚了我们得看看手头有什么“兵器”。经典的AT89C51/52本身没有内置ADC和DAC所以我们需要外挂。这里我推荐两个非常经典且易用的芯片方案你可以根据手头的元件选择。方案一使用PCF8591模块I2C接口集成ADC和DAC这是我最推荐给新手的方案。PCF8591是一个单片、单电源、低功耗的8位CMOS数据采集器件它集成了4路模拟输入ADC和1路模拟输出DAC。关键是它采用I2C总线通信只需要两根线SDA, SCL就能与单片机连接极大地节省了IO口。电路连接非常简单VCC, GND分别接单片机系统的5V和地。SDA, SCL分别接51单片机的任意两个IO口如P2.0, P2.1注意要接上拉电阻通常4.7K或10K。AIN0-AIN3这是4个模拟输入通道可以接电位器中间脚接AIN两端接VCC和GND、光敏电阻分压电路等。AOUT模拟输出脚可以接一个滤波电路一个电阻串联一个电容到地后再接示波器或LED驱动电路观察效果。注意51单片机没有硬件I2C控制器我们需要用软件模拟I2C时序即“软件I2C”。这听起来复杂但其实有固定的代码模板后面会给出。方案二独立ADC0804芯片并行接口仅ADC如果你只想先玩ADCADC0804是更古老但更直观的选择。它是一个8位逐次逼近型ADC采用并行数据传输。这意味着它转换完成后会把8位数据直接放在8个数据引脚上单片机可以像读取外部存储器一样一次性读走。电路连接要点DB0-DB78位数据总线接单片机的P0口需加上拉电阻或任意8个IO口。CS片选、RD读、WR写接单片机IO控制芯片的启动和读取。INTR中断转换完成信号可接单片机外部中断引脚或普通IO查询。VIN模拟电压输入。时钟CLK可由单片机IO提供脉冲或外接RC电路。PWM输出电路对于51单片机PWM输出极其简单。选择一个IO口如P1.0作为PWM输出脚。如果要驱动LED可以串联一个限流电阻220Ω-1K直接接LED。如果要驱动电机则必须通过三极管或MOS管搭建驱动电路单片机IO口仅提供控制信号切记不能直接驱动电机4. 软件I2C驱动PCF8591从零开始编写通信底层由于51单片机通常没有硬件I2C我们必须先用代码“模拟”出I2C总线的时序。这是和PCF8591对话的基础。I2C时序有严格的时间要求但好在51的速度对于PCF8591来说绰绰有余。我们需要定义两个IO口作为SDA数据线和SCL时钟线并实现以下几个最基础的函数#include reg52.h #include intrins.h // 用于_nop_()空操作延时 sbit SDA P2^0; // 假设SDA接P2.0 sbit SCL P2^1; // 假设SCL接P2.1 // 微秒级延时函数具体延时需根据单片机主频调整 void I2C_Delay(unsigned char t) { while(t--); } // 产生I2C起始信号SCL高电平时SDA产生一个下降沿 void I2C_Start(void) { SDA 1; SCL 1; I2C_Delay(5); SDA 0; I2C_Delay(5); SCL 0; // 钳住总线准备发送数据 } // 产生I2C停止信号SCL高电平时SDA产生一个上升沿 void I2C_Stop(void) { SDA 0; SCL 1; I2C_Delay(5); SDA 1; I2C_Delay(5); } // I2C发送一个字节8位数据 void I2C_SendByte(unsigned char dat) { unsigned char i; for(i0; i8; i) { SDA (dat 0x80) 7; // 取出最高位 dat 1; SCL 1; I2C_Delay(5); SCL 0; I2C_Delay(5); } // 释放SDA线准备接收应答 SDA 1; I2C_Delay(2); SCL 1; I2C_Delay(3); // 此处可以读取ACK应答位简单应用可省略 SCL 0; } // I2C读取一个字节 unsigned char I2C_ReadByte(void) { unsigned char i, dat 0; SDA 1; // 确保单片机释放SDA由上拉电阻控制 for(i0; i8; i) { dat 1; SCL 1; I2C_Delay(5); if(SDA) dat | 0x01; // 读取SDA线状态 SCL 0; I2C_Delay(5); } return dat; }有了这些底层函数我们就可以封装针对PCF8591的读写函数了。PCF8591的器件地址是固定的0x90含读写位控制字节用于选择通道和输出使能。5. ADC数据采集实战读取电位器与光敏电阻的值现在我们用PCF8591来读取模拟量。假设我们想读取AIN0通道上电位器的电压值并显示在数码管或通过串口发送到电脑。首先要了解PCF8591的控制字格式。它是一个8位的字节位6和位7保留必须为0。位5模拟输出使能位。1启用AOUT输出0禁用。位4和位3输入模式选择。00四路单端输入我们最常用的模式。位2自动增量标志。1每次读取后通道号自动加1方便轮流读取多路。位1和位0通道选择位。00AIN001AIN110AIN211AIN3。例如我们要读取AIN0且不启用自动增量和模拟输出控制字就是0x00。如果我们要读取AIN0并同时启用模拟输出用于后续DAC实验控制字就是0x40因为位51。下面是读取ADC值的核心函数// 从PCF8591的指定通道读取一次ADC值 unsigned char Read_PCF8591_ADC(unsigned char channel) { unsigned char adc_value; I2C_Start(); // 启动I2C I2C_SendByte(0x90); // 发送器件地址写命令 (0x90 | 0x00) // 控制字节选择通道禁用自动增量禁用模拟输出先 I2C_SendByte(channel 0x03); I2C_Stop(); // 第一次停止 // PCF8591需要一次“空读”来启动转换 I2C_Start(); I2C_SendByte(0x91); // 发送器件地址读命令 (0x90 | 0x01) adc_value I2C_ReadByte(); // 读取上一次转换的结果第一次读可能是无效值 I2C_Stop(); // 再次启动读取本次转换的有效值 I2C_Start(); I2C_SendByte(0x91); // 读命令 adc_value I2C_ReadByte(); // 这才是本次通道的真实ADC值 I2C_Stop(); // 发送非应答信号并停止在ReadByte函数中我们没发ACK这里直接Stop // 实际应在I2C_ReadByte最后发送一个非应答位(ACK1)再Stop。简单应用可忽略。 return adc_value; }在主循环中调用这个函数就能持续获取ADC值。你可以将这个值0-255通过公式电压值 (adc_value / 255.0) * 基准电压换算成实际电压。基准电压通常是VCC5V。实操心得1关于“空读”上面代码中的“空读”操作是必须的。PCF8591的工作机制是你发送控制字选择通道后它内部开始转换但读出的第一个字节是上一次转换的结果。所以我们需要先读一次丢弃掉再读一次才是当前通道的值。这是很多新手会忽略导致数据不对的坑。实操心得2滤波处理直接读取的ADC值可能会跳动。在实际应用中简单的软件滤波能极大提升稳定性。最常用的是“中位值平均滤波法”连续采样N次去掉最大最小值求剩下数据的平均值。或者更简单的连续采样10次求和再除以10效果也很显著。6. DAC模拟输出实战用PCF8591生成可控电压PCF8591的DAC功能使用起来比ADC更简单。你只需要向它写入一个控制字启用模拟输出和一个数据字节它就会在AOUT引脚输出对应的电压。// 向PCF8591的DAC输出一个电压值 void Write_PCF8591_DAC(unsigned char dac_value) { I2C_Start(); I2C_SendByte(0x90); // 写命令 // 控制字节任意通道通常选0启用模拟输出(0x40) I2C_SendByte(0x40); // 发送要输出的DAC数据值 I2C_SendByte(dac_value); I2C_Stop(); }调用Write_PCF8591_DAC(128)AOUT引脚就会输出大约2.5V的电压假设基准是5V。你可以用万用表测量AOUT引脚验证。更直观的方法是将AOUT接一个LED串联电阻通过改变dac_value0-255可以看到LED的亮度平滑变化虽然PCF8591输出的是直流不是PWM但改变电压也能改变LED亮度只是线性度不如PWM好。一个综合实验ADC读取电位器DAC控制LED亮度这个实验能完美闭环ADC和DAC。将电位器接在AIN0AOUT通过一个三极管驱动一个LED。程序逻辑就是主循环不断读取AIN0的ADC值然后将这个值直接写入DAC。这样你旋转电位器LED的亮度就会随之同步变化。这是理解“信号链”最直观的例子。7. 51单片机PWM波生成纯软件与定时器两种实现当没有DAC芯片时PWM是我们的救星。在51上生成PWM主要有两种方法纯软件延时和定时器中断。前者简单但低效占用CPU后者复杂但精准不干扰主程序。方法一纯软件延时PWM不推荐用于实际项目仅理解原理sbit PWM_OUT P1^0; // PWM输出引脚 void Soft_PWM(unsigned char duty, unsigned int period) { // duty: 高电平时间占比 (0-100) // period: 一个PWM周期的总时间微秒级延时循环次数 unsigned int high_time (period * duty) / 100; unsigned int low_time period - high_time; PWM_OUT 1; Delay_us(high_time); // 需要一个微秒延时函数 PWM_OUT 0; Delay_us(low_time); }在主循环中不断调用Soft_PWM(70, 1000)就能产生占空比70%、周期约1ms的PWM波。这种方法的问题在于Delay_us函数执行时CPU什么都干不了整个程序会被PWM拖死。方法二定时器中断PWM推荐这是标准的实现方式。我们利用一个定时器如Timer0来产生固定的时间基准比如100us中断一次在中断服务程序中维护一个周期计数器和比较值从而控制IO口的高低电平。#include reg52.h sbit PWM_OUT P1^0; unsigned int pwm_tick 0; // 周期计数器 unsigned char pwm_duty 70; // 占空比 (0-100) #define PWM_PERIOD 100 // 一个周期包含100个tick100*100us10ms频率100Hz void Timer0_Init() { TMOD 0xF0; // 清除T0控制位 TMOD | 0x01; // 设置T0为模式116位定时器 // 假设晶振11.0592MHz12T模式定时100us // 定时器初值 65536 - (100 * 11.0592 / 12) ≈ 65483 0xFFCB TH0 0xFF; TL0 0xCB; ET0 1; // 开启T0中断 EA 1; // 开启总中断 TR0 1; // 启动T0 } void Timer0_ISR() interrupt 1 { // 重装初值 TH0 0xFF; TL0 0xCB; pwm_tick; if(pwm_tick PWM_PERIOD) { pwm_tick 0; } // 比较并输出 if(pwm_tick pwm_duty) { PWM_OUT 1; // 高电平时间 } else { PWM_OUT 0; // 低电平时间 } } void main() { Timer0_Init(); while(1) { // 主循环可以自由做其他事情比如按键扫描、更新pwm_duty值等 // pwm_duty Read_ADC() / 2.55; // 将ADC值(0-255)映射为占空比(0-100) } }这种方法PWM的产生完全由定时器中断在后台完成主程序while(1)循环可以自由地处理其他逻辑比如读取ADC来动态改变pwm_duty实现呼吸灯效果。实操心得3PWM频率与分辨率的权衡在上面的代码中PWM_PERIOD决定了频率。PWM_PERIOD100定时器100us中断一次则PWM频率1/(100*100us)100Hz。这个频率用于LED调光完全足够人眼感觉不到闪烁但用于电机控制可能偏低会有嗡嗡声。提高频率就需要减小定时器中断周期比如改为50us但这样PWM_PERIOD不变的情况下一个周期内的tick数就翻倍了pwm_duty的分辨率步进就变细了。然而pwm_duty是unsigned char型0-255如果PWM_PERIOD超过255我们就需要更大的变量类型如unsigned int来存储tick和比较。这是一个需要根据实际需求电机响应、LED频闪、控制精度来权衡的关键点。8. 综合项目制作一个光控呼吸灯我们把ADC、PWM和主程序逻辑串起来做一个有趣的小项目用光敏电阻检测环境光强度光线越暗LED呼吸得越慢PWM周期变长光线越亮LED呼吸得越快。硬件连接PCF8591的AIN0接光敏电阻与固定电阻的分压电路。P1.0引脚接LED串联220Ω电阻。PCF8591的I2C引脚接单片机。程序逻辑初始化定时器0用于PWM初始化软件I2C。在主循环中读取AIN0的光强ADC值假设环境越暗ADC值越小。将ADC值映射到两个PWM参数上pwm_duty固定为50%让LED保持半亮呼吸。PWM_PERIOD将ADC值映射到一个范围例如target_period 50 (255 - adc_value) / 2。这样光强越弱adc_value小周期越长呼吸越慢。为了平滑变化不让周期突然跳变我们采用“渐变”的方式更新当前周期current_period (target_period - current_period) / 8。这种一阶低通滤波思想会让变化非常平滑。在定时器中断中使用current_period作为实际周期值进行PWM输出。这个项目综合了模拟信号采集ADC、数字信号处理映射、滤波和模拟信号输出PWM是一个非常好的51单片机综合练习。通过它你能深刻理解如何让单片机与模拟世界互动。避坑指南电源噪声与滤波无论是ADC采集还是PWM输出电源的稳定性至关重要。如果你的ADC值跳动厉害或者PWM驱动的LED有轻微闪烁很可能不是代码问题。尝试在单片机电源引脚VCC和GND之间并联一个10uF的电解电容和一个0.1uF的瓷片电容可以有效滤除电源噪声。对于PWM驱动电机等大电流负载一定要在电机两端并联一个续流二极管并在电源处使用更大容量的电容防止电机产生的反向电动势和电流突变干扰单片机导致复位或ADC不准。