51单片机+DAC0832+LM358信号发生器:四波形输出原理与实现
发布时间:2026/10/5 1:20:28 作者:尧图编辑部 阅读量:1,286

前阵子翻实验箱找出一把落灰的DAC0832和LM358顺手搭了个信号发生器用51单片机输出正弦波、方波、三角波、锯齿波这四种标准波形。说实话这套方案在现在一堆DDS芯片和STM32面前显得很“复古”但它的价值恰恰在于足够简单、足够透明DAC0832是8位并行DAC你把数据写到总线上它立刻转成对应的电流信号LM358再把电流变成电压整个过程没有任何封装好的库也没有黑盒寄存器每一步都是看得见摸得着的模拟电路和数字逻辑。这个项目特别适合两类人一是正在做单片机课程设计、电子设计竞赛入门题的学生二是想真正搞明白“DAC到底是怎么把数字变模拟”的硬件爱好者。整个系统成本不到二十块Proteus里也能完整仿真不吃硬件资源51单片机的速度和内存完全够用。这篇文章我就把从电路设计、原理计算到代码实现、实测调试的完整过程拆开讲所有参数和步骤都是可以直接照着抄的。1. 项目定位与整体方案1.1 为什么选DAC0832而不是DDS或串行DAC现在做一个信号发生器很多人第一反应是直接用DDS芯片比如AD9833或者直接用STM32的DAC那我为什么还要用DAC0832这套老掉牙的方案因为DDS芯片虽然输出频率高、波形质量好但它把大量的细节都封装在芯片内部了你只是发几个SPI命令剩下的都交给芯片内部的状态机和波形ROM去处理学习价值反而打了折扣。串行DAC比如MCP4921虽然接线少但你需要先搞懂SPI时序而且它输出的是电压少了一个电流转电压的分析环节。DAC0832则完全不一样。它是经典的8位并行乘法DAC数据输入端直接挂在单片机的P0口上你往某个地址写一个字节它内部那个R-2R梯形电阻网络立刻把这个数字量转成对应的电流输出。这个过程里你能亲眼看到每一个环节并行总线的时序关系、地址译码、电流输出、运放的I/V转换所有层层剥开没有黑盒。而且DAC0832的建立时间只有1微秒左右对于输出几百赫兹到几千赫兹的波形来说完全够用51单片机12MHz晶振下的指令周期约1微秒配合定时器刚好能卡上节奏。还有个现实因素DAC0832在课程设计和竞赛中存量极大Proteus元件库里直接就有网上能找到海量参考电路。对新手来说抄作业都容易找到模板。等把这个项目的每个细节吃透了再去做DDS、做更高端的波形发生器你会有一种“知其然也知其所以然”的底气。1.2 系统组成与信号链路这套信号发生器的整体架构非常简单核心信号链路就一条单片机查表生成波形数据通过P0口送给DAC0832DAC0832输出电流信号LM358把这个电流转成电压并调整幅度最后从输出端得到完整的模拟波形。整个系统可以拆成四个功能块主控部分STC89C52或AT89C51晶振11.0592MHz或12MHz都行。它负责维护一张波形查找表并在定时器中断里按固定节奏把表里的数据点逐个送到DAC0832。数模转换部分DAC0832工作在直通模式P0口直接接它的8个数据输入引脚P2.7作为片选信号配合单片机的写信号完成数据锁存。模拟调理部分LM358内部有两个独立运放第一个运放做电流转电压第二个运放做反相和幅度调整同时解决输出极性问题。人机交互部分两个独立按键一个切换波形一个切换频率必要时加一个LCD1602显示当前波形和频率。工作流程通俗理解就是把正弦波的一个完整周期按256个点等间距采样预先算好每个采样点对应的8位数值放在数组里。定时器每次溢出中断就取一个数送给DAC0832循环往复DAC的输出端就出现了一个阶梯状逼近正弦波的电压信号再经过运放平滑滤波就是人眼和示波器看到的波形。方波、三角波、锯齿波的原理完全一样只是表里的数据不同。2. 硬件电路设计与原理分析2.1 DAC0832的三种工作方式与选型DAC0832有三种工作方式直通模式、单缓冲模式、双缓冲模式。很多人第一次看数据手册会被这三个概念绕晕我用人话解释一下。直通模式就是把ILE接高电平CS、WR1、WR2、XFER全部接地这样内部的两个锁存器始终处于透明状态你往数据脚上送什么电平DAC就立刻输出对应的电流。好处是控制最简单不需要任何写时序坏处是如果数据线在变化过程中DAC也在输出那么输出端会出现短暂的不确定毛刺。单缓冲模式是把其中一个锁存器接成直通另一个由单片机控制锁存。常用的做法是ILE接高、WR2和XFER接地让第二级锁存器直通然后由CS和WR1控制第一级锁存。当你向对应地址写数据时WR1的上升沿把数据锁存住数据线后面的变化不会影响DAC输出。这种方式比直通模式稳定得多毛刺明显减小。双缓冲模式是两个锁存器都由软件控制主要用于多片DAC需要同步输出的场景比如示波器的X-Y显示。这个项目用不到。我的建议是采用单缓冲模式理由很实在直通模式虽然省事但51单片机向P0口写数据时P0口从高电平到低电平的过渡需要时间数据线上的毛刺会直接串到模拟输出端在示波器上表现为波形边缘的杂散尖峰。单缓冲模式只需要多接一根P2.7做片选就能把数据稳定锁存波形干净不少。具体接法是ILE接VCCWR1接单片机P3.6WRCS接P2.7WR2和XFER一起接地。这样单片机执行一条MOVX外部RAM写指令向地址0x7FFF写入数据时DAC0832就完成一次数据更新。2.2 LM358的电流转电压电路与幅度计算DAC0832输出的是电流不是电压这是它和电压输出型DAC最大的区别。它的两个输出端IOUT1和IOUT2是一对互补电流输出满足IOUT1 IOUT2 满量程电流。满量程电流由参考电压VREF和内部电阻网络决定理论计算公式是I_out1 (VREF / RFB) × (D / 256)其中D是写入的8位数字量范围0到255RFB是内部反馈电阻典型值10千欧。当VREF接5V时满量程输出电流约为5V / 10k × 255 / 256大概4.98毫安。这个电流很小不能直接驱动负载必须经过运放转换成电压。LM358在这里的第一个运放接成反相放大器DAC0832的IOUT1接运放反相输入端运放输出通过RFB反馈回反相输入端同相端接地。需要注意RFB这个反馈电阻可以借用DAC0832内部的引脚也可以自己外接一个精密电阻。如果直接用DAC内部的RFB整个I/V转换的增益就是固定的输出电压V_out1 -I_out1 × RFB -(D / 256) × VREF。VREF 5V时输出范围是0到约-4.98V是一个单极性负电压。但大多数应用和示波器观察习惯都希望得到正电压波形所以LM358的第二个运放就派上用场了。第二个运放接成反相加法器把第一个运放的负电压反相回来同时通过调整增益来控制输出幅度。两个运放配合的完整转换关系是V_final (D / 256) × VREF × (R2 / R1)如果R1和R2都取10千欧那么输出就是0到5V的正极性单极性波形峰峰值约5V也就是热词里常问的Vpp大约5V。想要2Vpp的输出把R2取4千欧或者用10千欧电位器调到对应位置就行。实际调试时我会在反馈回路里串一个10千欧的精密多圈电位器这样幅度可以在一个范围内连续可调不用反复换电阻。2.3 供电方案与去耦细节这个项目最容易翻车的不是原理图而是供电。DAC0832的数字部分用5V供电没问题但LM358如果也用单5V供电输出波形会出现很明显的底部削波。原因是LM358的输出级并不是轨到轨的在单电源5V下它能输出的最低电压大约在0.7V到1V左右最高电压大概在3.5V到3.8V。也就是说即便理论上应该输出0到5V的波形实际底部会被切掉一大块正弦波的负半周和三角波的底部都是平的。解决这个问题的思路有两个。思路一给LM358用双电源供电正负5V或者正负12V都可以这样输出能完整覆盖0V上下信号幅度可以做到真正的双极性。思路二如果手头只有单电源就把LM358的接地端改成VCC/2的虚拟地然后输出端串联一个隔直电容把直流偏置去掉得到双极性的交流波形。这个方法在很多便携设备里很常见。此外DAC0832的VREF输入对噪声相当敏感参考电压上的纹波会等比例调制到输出波形上。我一般会在VREF引脚对地并联一个10微法电解电容和一个0.1微法瓷片电容做高低频去耦。单片机的电源引脚同样要就近放一个0.1微法去耦电容DAC0832的VCC引脚也是。这类看起来不起眼的电容往往决定了最终波形上有没有高频毛刺。3. 波形生成的软件实现3.1 查表法原理与正弦表的生成软件部分的核心是查表法。最笨的办法是每输出一个点就现场算一次正弦值但在51上做浮点sin运算非常慢一个点可能算几百微秒输出不了几个点波形就已经卡死了。正确的做法是提前把波形算好以数组形式存放在程序存储器里运行时只是按照索引一个接一个地取数据速度极快。正弦波表我常用256点也就是把一个完整的360度周期切成256份。每份对应角度1.40625度查表索引i对应的数值计算公式是value[i] (unsigned char)((sin(2 × π × i / 256) 1) / 2 × 255)这个式子里sin的结果范围是-1到1加1之后变成0到2除以2变成0到1乘以255之后取整就得到0到255的8位无符号数正好对应DAC0832的输入范围。生成这张表不需要手工算用C语言程序、Python或者Excel都可以。我常用的是在电脑上跑一段C语言代码把生成的数组直接复制到Keil工程里。#include stdio.h #include math.h int main(void) { unsigned char i; for (i 0; i 256; i) { double y (sin(2 * 3.14159265 * i / 256) 1) / 2 * 255; if (i % 16 0) printf(\n); printf(0x%02X, , (unsigned char)(y 0.5)); } return 0; }方波就简单多了前128个点全部是0xFF后128个点全部是0x00。三角波是前128个点从0均匀递增到255后128个点从255均匀递减到0。锯齿波是256个点从0均匀递增到255然后瞬间回到0。这四个表都放到code区用code关键字修饰ROM足够大完全不用省。3.2 定时器中断与输出频率的计算波形表有了接下来要解决的核心问题是怎么控制输出节奏。波形输出频率取决于两个参数表长和每次取点的间隔时间。想象你手里有一个圆盘上面均匀画了256个刻度每按固定间隔转动一个刻度圆盘的转速就决定了波形的频率。这里的固定间隔由定时器中断实现我用定时器0工作在方式1也就是16位定时模式。每次定时器溢出中断就在中断服务程序里做三件事从当前波形表中取出第index个数据写入DAC0832、index加1、如果index等于256就清零。因此波形的周期由256次中断组成输出频率的计算公式是F_wave F_timer_overflow / 256而定时器溢出频率又由晶振频率和定时初值决定。以12MHz晶振为例机器周期是12 / 12MHz 1微秒定时器每计数一次耗时1微秒。如果设定时器初值是N16位范围0到65535那么每次溢出时间为(65536 - N)微秒溢出频率就是1 / ((65536 - N) × 1us)。想要输出1000Hz正弦波需要溢出频率为256kHz那么65536 - N应该等于1 / 256k / 1us算出来大约是3.9取整为4初值就是65532也就是0xFFFC。实际项目中晶振频率往往不是整数比如常用的11.0592MHz晶振机器周期约1.085微秒计算结果就要做四舍五入最终频率会有微小偏差。这是51单片机做信号发生器的一个固有局限好在低频段相对误差不大做课程设计和基础实验完全够用。下面这张表我直接给出11.0592MHz晶振下实测比较准的一组定时器初值注意这是256点波形表对应的数据。目标波形频率定时器溢出频率定时器初值(TH0/TL0)50Hz12.8kHz0xF4 / 0x48100Hz25.6kHz0xE8 / 0x90200Hz51.2kHz0xD1 / 0x20500Hz128kHz0x87 / 0x801kHz256kHz0x0F / 0x00这里要特别说明我用的是256点表如果你把表长改成128点同样的频率需要把定时器溢出频率减半否则波形频率会翻倍。对于方波这种简单的波形其实完全没必要用256点2个点就够了但为了代码结构统一我仍然用256点表这样切换波形时不用重新计算定时器参数。3.3 按键扫描与波形切换逻辑人机交互部分我用两个独立按键一个是切换波形一个是切换频率档位。和很多单片机项目一样按键必须做消抖处理。我采用的方案是在主循环里轮询按键检测到P3.2或P3.3为低电平时先延时10毫秒再读一次如果仍然为低就认为按键有效。按键对应的动作逻辑也简单波形切换键按一下wave_type加1超过3就从0重新开始分别对应正弦波、方波、三角波、锯齿波。频率切换键按一下freq_index加1超过4也归零对应50Hz、100Hz、200Hz、500Hz、1kHz这五档。每次切换波形或频率时都要做两件事把wave_table指针指向对应的波形表数组首地址把定时器初值更新成新频率对应的数值。有个小细节值得提一下从方波切换到正弦波时如果切换发生在一个周期中间波形的第一个输出点可能不是波的起始位置这在示波器上表现为切换瞬间波形有一个相位跳变。虽然不影响使用但看起来不够优雅。我后来加了一个小优化在切换波形时强制把索引index清零让新波形总是从表的0号位置开始输出这样切换瞬间波形从0起点重新播放观察体验好了很多。3.4 主程序框架与完整代码思路整个软件结构不复杂核心就三块定时器0初始化、定时器0中断服务函数、主循环按键处理。这里给出一个能直接用的代码框架你在Keil里新建工程把DAC0832的地址定义和四个波形表放进去基本就能跑起来。#include reg52.h #include absacc.h #define DAC0832 XBYTE[0x7FFF] // P2.7做片选地址最低位任意 #define TABLE_LEN 256 code unsigned char sin_table[TABLE_LEN] { /* 正弦表数据 */ }; code unsigned char square_table[TABLE_LEN] { /* 方波表数据 */ }; code unsigned char triangle_table[TABLE_LEN] { /* 三角波表数据 */ }; code unsigned char sawtooth_table[TABLE_LEN] { /* 锯齿波表数据 */ }; code unsigned char *code wave_tables[4] { sin_table, square_table, triangle_table, sawtooth_table }; unsigned char wave_type 0; unsigned char freq_index 0; unsigned char table_index 0; // 频率档位对应的定时器初值按50/100/200/500/1kHz排列 code unsigned int freq_tab[5] { 0xF448, 0xE890, 0xD120, 0x8780, 0x0F00 }; void Timer0_Init(void) { TMOD 0x01; // 定时器0方式1 TH0 (freq_tab[freq_index] 8); TL0 (freq_tab[freq_index] 0xFF); ET0 1; EA 1; TR0 1; } void Timer0_ISR(void) interrupt 1 { TH0 (freq_tab[freq_index] 8); TL0 (freq_tab[freq_index] 0xFF); DAC0832 wave_tables[wave_type][table_index]; table_index; if (table_index TABLE_LEN) { table_index 0; } } void delay_ms(unsigned int ms) { unsigned int i, j; for (i 0; i ms; i) for (j 0; j 120; j); } void key_scan(void) { if (P3_2 0) { delay_ms(10); if (P3_2 0) { while (P3_2 0); wave_type (wave_type 1) % 4; table_index 0; } } if (P3_3 0) { delay_ms(10); if (P3_3 0) { while (P3_3 0); freq_index (freq_index 1) % 5; } } } void main(void) { Timer0_Init(); while (1) { key_scan(); } }这段代码有一个地方需要特别注意中断服务函数里每次取的数据来自当前wave_type指向的表而主循环里修改wave_type和freq_index的时机是不可预测的。51单片机在处理这类共享变量时最稳妥的方法是在修改wave_type前先关闭中断改完再打开或者至少在修改完table_index后等待一个完整的波形周期。我这里简单处理在切换波形时加了table_index 0实际跑下来还没遇到什么问题但如果做得更严谨应该在主循环修改wave_type时临时关一下EA防止中断正在读取表指针的瞬间指针被切断。4. Proteus仿真验证4.1 仿真电路搭建要点如果你的硬件条件暂时不凑手Proteus仿真完全可以先把这个项目跑通。我在Proteus里搭这套电路时发现几个容易踩的坑这里单独说一下。器件选择上单片机和LM358都在常用元件库里DAC0832在Converters芯片类别下能找到。DAC0832的VREF引脚需要接一个直流电压源我习惯用5V这样和单片机的逻辑电平一致。要注意的是Proteus里的DAC0832模型有些版本必须给VCC接上5V电源否则输出始终是0看起来就像坏了。LM358的双电源方案在仿真里也很好实现直接放两个DC电源一个5V一个-5V共地之后再接到运放的正负电源脚。晶振参数和单片机型号要匹配。如果用AT89C51双击单片机可以设置晶振频率默认是11.0592MHz不需要改。但如果代码里的定时器初值是按12MHz算的仿真波形频率就会有偏差所以我建议仿真和实物都用11.0592MHz至少心里有数。示波器就用Proteus的虚拟示波器接在LM358第二级运放的输出端。运行仿真后如果波形出不来优先检查DAC0832有没有被正确写入。一个很实用的排查方法是先不动代码直接给DAC0832的数据引脚手动加一个固定的数字量比如0x80如果运放输出端有对应的直流电压说明DAC和运放电路是好的问题出在软件或者时序上。4.2 在仿真里观察和调优波形波形正常出来之后仿真阶段还可以做几件很有价值的事。第一是改变VREF电压观察输出波形幅度怎么变化这能直观理解乘法DAC的含义。VREF从5V降到2.5V输出波形峰峰值几乎等比例减半这对理解后面的幅度设计很有帮助。第二是增加输出端的RC低通滤波比如串一个1千欧电阻再接0.1微法电容到地示波器上能看到波形变得平滑很多高频毛刺被滤掉了这就是最简单的重建滤波器。还有一点值得在仿真里验证方波和三角波在切换瞬间会不会出现毛刺。因为方波的两个极端值是0和255相邻两次中断从0跳到255其实就相当于一个满幅度跳变的阶跃信号如果程序里定时器初值更新不及时或者索引清零时机不对方波会偶尔出现宽度异常的窄脉冲。我在仿真里加大示波器的时间尺度一格代表几十微秒这样能比较清楚地看到方波边缘的细节。如果发现问题多数原因是中断里更新TH0/TL0和DAC0832输出这两个动作的先后顺序不对调整一下顺序通常能解决。仿真的另一个好处是能方便地测量波形频率。Proteus的虚拟示波器有标尺功能把两个光标分别放在两个相邻波峰上就能读出周期再换算成频率。我实测下来用256点表和上面那组定时器初值仿真输出的频率和目标值误差在百分之几以内这个精度用肉眼观察完全够用。5. 常见问题与排障实录5.1 输出端完全没有波形这个问题在实物搭建时最常遇到。按我的排查顺序先检查单片机有没有运行起来。最简单的方法是用一个LED接在某个IO口让它每隔一段时间翻转一下如果LED在闪烁说明程序至少跑到了主循环。如果连LED都不闪先查晶振两边的电容和复位电路再用万用表量单片机的VCC和GND是否正常。单片机正常之后再查DAC0832有没有被选通。单缓冲模式下单片机执行MOVX写操作时P2.7必须输出低电平同时P3.6产生一个低脉冲。用示波器同时抓这两个引脚的波形如果写操作发生时P2.7没有拉低多半是地址宏定义不对。XBYTE[0x7FFF]的位7为0也就是地址最高位为0如果你的实际硬件把片选接到了P2.0那么宏定义里要对应改成一个位0为0的地址。最后检查DAC0832的ILE引脚是否确实接了高电平。这个引脚很容易在面包板上虚接一旦悬空内部的输入锁存器永远无法通过数据写进去也白搭。我踩过这个坑排查了半个小时最后发现是ILE跳线没插紧。5.2 波形底部削波或顶部削顶波形削波是LM358供电问题最典型的表现。如果你用单5V电源给LM358供电输出波形在接近0V附近会被削平看起来就像波形底部被切掉了一块。这是因为LM358的输出级用的是准NPN结构不是轨到轨最低输出大概在0.5V到1V之间。解决办法我之前提到了优先改用正负5V双电源供电。如果暂时没有双电源可以用两个5V电源串联中间抽头做虚拟地也能得到正负5V。务必注意两个电源共地要可靠否则运放的工作点会漂移输出波形直接乱掉。顶部削顶的情况多半是第二级运放的增益设置过高输出接近电源电压被限幅把R1增大或者把R2减小就能解决。这里给一个通用排查口诀顶部平是削顶查增益和正电源底部平是削底查负电源和同相端接地。大部分波形异常都能用这个思路定位。5.3 输出频率和计算值对不上频率偏差的来源主要有三个。一是晶振频率和代码计算时假设的值不一致比如你以为用的是12MHz晶振实际板子上是11.0592MHz频率就会按比例偏低大约8%。二是定时器初值四舍五入带来的量化误差频率越高误差越明显1kHz以上时用256点表可能误差达到百分之几。三是中断服务函数本身的执行时间被忽略了。中断函数里有查表、有写DAC、有索引自加这个执行时间虽然只有几个机器周期但对于微秒级的中断周期来说已经不小了。如果对频率精度有更高要求可以考虑两个方向。一是把中断服务函数精简到极致用汇编或者减少无关操作让实际中断间隔更接近理论值。二是改用硬件定时器做PWM用PWM的不同占空比直接得到方波频率由定时器硬件保证精度会高很多。当然这就偏离了DAC0832方案的初衷但作为后续进阶方向是合理的。5.4 波形边缘毛刺和噪声偏大波形边缘出现细小的毛刺基本是两类原因。一是DAC0832工作模式选成了直通数据线变化时的竞争冒险直接反映在输出端换到单缓冲模式后毛刺会明显减少。二是电源和地线处理不当DAC部分是数字电路运放部分是模拟电路两者如果混在一起数字开关噪声会通过地线耦合进模拟输出。如果PCB Layout或者面包板走线已经定型最简单的补救方法是在输出端加一个一阶低通滤波器比如串接100欧姆电阻再接0.1微法电容到地截止频率大约16kHz对1kHz以内的波形几乎没有影响但能滤掉很多高频毛刺。6. 扩展方向与个人体会6.1 从单极性输出升级为双极性输出DAC0832配合LM358最自然的输出是0到5V的单极性波形。但真正实用的信号发生器通常需要输出正负对称的波形比如-2.5V到2.5V这样接交流放大器、做滤波器测试时才方便。双极性输出的改法不复杂在第一级I/V转换运放的同相输入端接一个VREF/2的偏置电压让DAC输出0的时候对应-2.5V输出255的时候对应2.5V。这样正弦波就变成围绕0V上下摆动的交流信号。这个改动在电路上只多了两个电阻和一个电压源软件只需要把波形表的0到255改成127到255或0到127的半量程策略本质是给数字量加个偏移量。6.2 加装LCD1602显示当前状态按键切来切去时间一长就分不清当前是哪个波形、哪个频率了。我在第二版里加了块LCD1602第一行显示波形类型第二行显示频数字。LCD1602的8个数据引脚占用P1口和P2口的几个IO代码也不复杂核心就是初始化、写命令、写数据这几步。有了显示之后整个项目的完整度一下子提升了不少拿去当课程设计交差也更有底气。如果你用的是STC系列有些型号自带EEPROM或者IAP功能甚至可以把断电前的波形和频率存下来下次开机自动恢复这个小功能在很多成品仪器上都有实现起来其实很便宜。6.3 这个老方案还能怎么继续玩DAC0832方案的上升空间上限不高毕竟8位分辨率决定了它最多输出256个电平等级做细腻的声波、做高精度波形都勉强。但作为理解信号发生器的底层逻辑它是一块极好的敲门砖。玩明白这个之后你可以继续往DDS方向走用AD9833或者AD9850感受一下高性能芯片的威力也可以转向STM32内置DAC加DMA的方式用更高的分辨率和更快的更新率生成任意波形甚至可以往软件无线电、任意波形发生器的方向深挖。但不管走多远当年用51单片机和DAC0832第一次在示波器上看到标准正弦波时的那个瞬间是很多电子工程师入坑的起点。这套方案从原理到代码我前前后后做过好几遍每次重新做都还会发现一些小问题。比如运放的虚拟地布局、电源去耦电容的摆放位置、中断服务函数里是否该先更新初值再写DAC这些细节单独看都不起眼但凑在一起就决定了整个系统能不能稳定工作。如果你也打算动手做这个项目我的建议是别急着抄板子先把信号链路和软件流程在Proteus里跑通再去搭实物。仿真里暴露的问题往往比实物上烧半天脑细胞去猜要快速得多也安全得多。