51单片机波形发生器入门实战:定时器、DAC与Proteus仿真
简介:这份资料面向电子工程、嵌入式系统方向的初学者与爱好者,提供一套基于51单片机的简易波形发生器完整仿真设计,可用于课程设计、自学练手或电路调试场景。压缩包共19个文件,约57KB,包含C语言源程序、Keil工程配置与编译中间文件、Proteus仿真文件以及可烧录的hex目标文件,覆盖从代码编写到仿真验证的完整链路。资源围绕定时器中断、PWM脉宽调制、DA转换与波形频率幅度控制等核心知识点展开,源程序结构清晰,便于对照理解初始化、主循环与中断服务子程序的协作方式。目前已有801人学习下载,读者可借助仿真文件直接观察正弦波、方波、锯齿波等输出效果,并在此基础上修改参数、调试波形质量,是入门51单片机与数字信号处理的实用参考。
1. 从零搭一套 51 单片机波形发生器:为什么它至今仍是入门嵌入式的最佳练手项目
如果你手头正好有一块 STC89C52 或 AT89C51 核心板,想找一个既能吃透定时器、DAC 接口,又能直接出成果的练手项目,波形发生器几乎是首选。它不像流水灯那样单薄,也不像密码锁那样逻辑绕弯,核心链路非常清晰:单片机定时器产生精确的时间基准,查表输出数字量,再经 DAC 转换成模拟波形。整个系统在 Proteus 里就能完整跑通,不需要示波器、不需要信号源,一台电脑加 Keil 就能验证正弦波、方波、三角波、锯齿波四种基础波形。这套方案特别适合刚学完 51 单片机中断和定时器、想找一个综合性项目把知识串起来的人,也适合需要交课程设计或参加工创赛的在校生——仿真文件加源程序的结构,意味着你可以在没有实物板子的情况下先把逻辑跑通,再决定要不要打板。
2. 波形发生器的核心链路:定时器、查表与 DAC 怎么串起来
2.1 定时器决定频率,查表决定波形形状
51 单片机没有内置 DAC,所以波形发生器的本质是“定时器中断 + 查表输出 + 外部 DAC 转换”三件事。定时器负责节拍:每进一次中断,就从波形数据表里取一个点送到 P0 或 P1 口,DAC 芯片把这个数字量变成模拟电压。输出一个完整波形周期需要多少个点,就决定了定时器中断频率和波形频率的关系。
假设一个周期采样 64 个点,定时器中断频率为 f_interrupt,那么输出波形频率 f_out = f_interrupt / 64。以 12MHz 晶振、定时器 1ms 中断为例,f_interrupt = 1000Hz,输出正弦波频率就是 1000/64 ≈ 15.6Hz。这个频率偏低,实际做的时候通常把定时器初值调小,让中断频率到 10kHz 以上,这样 64 点正弦波能到 150Hz 左右,满足音频段基础需求。
定时器初值的计算是第一个必须吃透的点。51 单片机定时器工作在模式 1(16 位定时)时,初值 = 65536 - (晶振频率 / 12 / 目标中断频率)。12MHz 晶振下,机器周期 1μs,要产生 100μs 中断,初值就是 65536 - 100 = 65436,即 TH1 = 0xFF,TL1 = 0x9C。这个计算在 Keil 里写宏定义最清楚:
// 12MHz 晶振,定时器0 模式1,100us 中断 #define TIMER0_HIGH 0xFF #define TIMER0_LOW 0x9C void Timer0_Init(void) { TMOD &= 0xF0; // 清除定时器0模式位 TMOD |= 0x01; // 定时器0,模式1(16位) TH0 = TIMER0_HIGH; // 装载初值高8位 TL0 = TIMER0_LOW; // 装载初值低8位 ET0 = 1; // 使能定时器0中断 EA = 1; // 开总中断 TR0 = 1; // 启动定时器0 }这段代码里 TMOD 的低四位控制定时器 0,0x01 表示模式 1。TH0/TL0 装初值后,每来一个机器周期计数值加一,溢出时触发中断。中断服务函数里要手动重装初值,因为模式 1 不是自动重载的——这是新手最容易翻车的地方,忘了重装初值,波形频率会变成理论值的一半甚至更乱。
2.2 四种波形的查表数据怎么生成
正弦波、三角波、锯齿波、方波的数据表生成方式不同。方波最简单,前半周期输出 0xFF,后半周期输出 0x00,不需要查表。锯齿波是线性递增,从 0x00 到 0xFF 再回绕。三角波是先增后减。正弦波需要预计算,通常用 Excel 或 Python 生成 64 点或 128 点的 8 位采样表。
以 64 点正弦波为例,每个点的值 = 128 + 127 * sin(2π * i / 64),i 从 0 到 63。取整后得到 0 到 255 之间的数据。下面是用 Python 生成这张表的代码:
import math points = 64 table = [] for i in range(points): val = int(128 + 127 * math.sin(2 * math.pi * i / points)) table.append(val) # 按 C 数组格式打印 print("code unsigned char sine_table[64] = {") for i in range(0, points, 8): row = ", ".join(f"0x{v:02X}" for v in table[i:i+8]) print(f" {row},") print("};")生成的数组放在 code 区,不占 RAM。51 单片机 RAM 只有 128 或 256 字节,64 字节的表放 data 区会吃掉一半内存,放 code 区则只占 Flash。Keil 里用code关键字修饰数组即可。三角波和锯齿波可以直接在中断里用计数器算,不一定要查表,但查表方式切换波形更统一,代码结构更干净。
2.3 DAC 选型与 Proteus 里的对应器件
实物制作时,DAC0832 是最常见的选择,8 位并行输入,电流输出,配合运放转电压。Proteus 仿真里可以直接搜 DAC0832,接法跟实物一致:D0~D7 接单片机 P0 口或 P1 口,ILE、CS、WR1、WR2 按直通方式接固定电平,IOUT1 和 IOUT2 接运放。如果只是验证波形逻辑,也可以用 Proteus 自带的“DAC”元件或者用电阻网络搭一个简易 DAC,但 DAC0832 的时序最接近真实项目。
接线时注意 P0 口需要上拉电阻,因为 51 的 P0 是开漏输出。Proteus 里可以在 P0 口加一个排阻,实物上通常用 10kΩ 排阻。DAC0832 的参考电压 VREF 接 +5V 时,输出电压范围是 0 到 -5V(因为它是电流输出,经运放反相)。如果希望输出 0 到 +5V,需要在运放后面再加一级反相器,或者用双电源供电。仿真阶段可以先不纠结极性,重点看波形形状和频率是否正确。
3. 在 Keil 里把源程序跑通:从工程建立到波形切换
3.1 新建工程与芯片选型
打开 Keil μVision5,Project → New μVision Project,选一个空文件夹存放工程。芯片选择 Atmel 的 AT89C51 或 STC 的 STC89C52,两者在仿真里差别不大。弹出“Copy STARTUP.A51”时选“是”,这个启动文件负责初始化堆栈指针,不选的话程序可能跑飞。
新建一个 main.c,把定时器初始化、中断服务函数、波形表、按键扫描都写进去。工程设置里 Output 选项卡勾选“Create HEX File”,仿真和下载都需要这个文件。晶振频率在 Target 选项卡里设为 12MHz,跟 Proteus 里单片机属性中的频率保持一致,否则定时器算出来的频率对不上。
3.2 中断服务函数与波形切换逻辑
中断服务函数是波形发生器的核心。每进一次中断,根据当前波形模式从对应数据表取一个点输出,然后更新索引。索引到达表长度时归零,形成循环。波形切换通过外部按键实现,按键扫描放在主循环里,消抖用延时 10ms 再判断。
#include <reg52.h> #define TABLE_SIZE 64 code unsigned char sine_table[TABLE_SIZE] = { /* 64点正弦数据 */ }; code unsigned char tri_table[TABLE_SIZE] = { /* 64点三角数据 */ }; code unsigned char saw_table[TABLE_SIZE] = { /* 64点锯齿数据 */ }; unsigned char wave_mode = 0; // 0正弦 1三角 2锯齿 3方波 unsigned char index = 0; void Timer0_ISR(void) interrupt 1 { TH0 = 0xFF; // 重装初值,不可省略 TL0 = 0x9C; switch(wave_mode) { case 0: P1 = sine_table[index]; break; case 1: P1 = tri_table[index]; break; case 2: P1 = saw_table[index]; break; case 3: P1 = (index < 32) ? 0xFF : 0x00; break; } index++; if(index >= TABLE_SIZE) index = 0; }这段代码里interrupt 1对应定时器 0 中断,中断号不能写错。P1 口直接输出数字量,DAC0832 接在 P1 上。方波不查表,用 index 判断前半周期和后半周期。每次中断都要重装 TH0/TL0,这是模式 1 的硬性要求。如果发现波形频率是预期的一半,先检查这里。
3.3 按键切换与频率调节的实现
按键接在 P3 口的某一位,比如 P3.2 到 P3.5 分别对应四种波形。主循环里轮询按键,按下时切换 wave_mode。频率调节可以通过改变定时器重装值实现,比如用两个按键加减 TH0/TL0 的值,但要注意重装值不能太接近 65536,否则中断太频繁会阻塞主循环。
void Key_Scan(void) { if(P3_2 == 0) // 切换波形 { Delay10ms(); if(P3_2 == 0) { wave_mode++; if(wave_mode > 3) wave_mode = 0; while(P3_2 == 0); // 等待松手 } } if(P3_3 == 0) // 频率加 { Delay10ms(); if(P3_3 == 0) { if(timer_reload < 65000) timer_reload += 10; while(P3_3 == 0); } } }按键消抖用延时函数最简单,但会阻塞主循环。如果对实时性要求高,可以用定时器做状态机扫描,不过对于波形发生器这种场景,10ms 阻塞影响不大。频率调节时改变 timer_reload 变量,在中断里用这个变量重装 TH0/TL0,而不是写死 0xFF9C。
4. Proteus 仿真联调:接线、加载 HEX 与波形观测
4.1 元件清单与接线表
在 Proteus 里新建工程,按下面的清单放置元件。单片机用 AT89C51,DAC 用 DAC0832,运放用 LM358 或 TL082,示波器用 OSCILLOSCOPE。
| 元件 | Proteus 关键字 | 数量 | 作用 |
|---|---|---|---|
| 单片机 | AT89C51 | 1 | 主控 |
| DAC 芯片 | DAC0832 | 1 | 数模转换 |
| 运放 | LM358 | 1 | 电流转电压 |
| 排阻 | RESPACK-8 | 1 | P0 口上拉 |
| 按键 | BUTTON | 4 | 波形切换与调频 |
| 示波器 | OSCILLOSCOPE | 1 | 观测输出波形 |
| 晶振 | CRYSTAL | 1 | 12MHz |
| 电容 | CAP | 2 | 30pF 起振电容 |
接线要点:单片机 P1.0~P1.7 接 DAC0832 的 DI0~DI7,DAC0832 的 CS、WR1、WR2、XFER 接地(直通模式),ILE 接 +5V。IOUT1 接运放反相输入端,IOUT2 接地。运放反馈电阻用 10kΩ,输出端接示波器 A 通道。P3.2~P3.5 接按键,另一端接地。
4.2 加载 HEX 文件与仿真参数设置
双击单片机,在 Program File 栏选择 Keil 生成的 .hex 文件,Clock Frequency 设为 12MHz。DAC0832 的 VREF 引脚接 +5V。示波器的时间基准先设 5ms/div,电压基准设 2V/div,运行仿真后按空格键暂停,就能看到波形。
如果波形是一条直线,先查三个地方:HEX 文件有没有加载、P1 口有没有输出变化、DAC 的 CS 和 WR 有没有接错。Proteus 里 DAC0832 的 WR1 和 WR2 必须接地才能直通,悬空的话数据锁不进去。另一个常见问题是 P0 口没加上拉,如果程序里用 P0 输出数据,Proteus 会显示灰色不确定电平。
4.3 用示波器测频率和幅值的实操步骤
仿真运行后,示波器上会出现波形。测频率时,看一个完整周期占几个格,乘以时间基准。比如一个周期占 2 格,时间基准 5ms/div,频率就是 1/(2*5ms) = 100Hz。测幅值时,看波形从最低点到最高点占几个格,乘以电压基准。DAC0832 在 VREF=5V 时输出 0 到 -5V,经运放反相后是 0 到 +5V,但实际运放输出会受供电限制,接近 0V 和 5V 时可能削顶。
如果波形有毛刺,通常是定时器中断里做了太多事情,或者主循环和中断冲突。把波形表放 code 区、中断里只做查表和输出,能减少毛刺。Proteus 仿真速度受电脑性能影响,波形刷新可能不流畅,但频率和形状是正确的。
5. 避坑与排查:51 波形发生器仿真里最容易翻车的 5 个点
5.1 波形频率只有理论值的一半
现象:定时器算出来应该是 1kHz 中断,示波器上波形频率却只有一半。原因:模式 1 定时器没有在中断里重装初值,第一次溢出后从 0 开始计数,相当于定时周期翻倍。解决:在中断服务函数第一行就重装 TH0 和 TL0,不要放在输出数据之后。
5.2 波形顶部或底部被削平
现象:正弦波看起来像梯形,波峰波谷是平的。原因:DAC 输出范围超过了运放或后续电路的供电轨,或者数据表的值域没有用满 0 到 255。解决:检查数据表最大值和最小值,确保覆盖 0x00 到 0xFF;检查运放供电是否单电源 5V,单电源下输出无法到负压,需要偏置电路或双电源。
5.3 按键按下去波形乱跳
现象:按一次切换键,波形模式跳了好几次。原因:按键抖动没有处理干净,或者中断里也在读按键。解决:主循环里加 10ms 延时消抖,等待松手再退出;中断里绝对不要读按键,中断只做波形输出。
5.4 Proteus 仿真跑起来但示波器没反应
现象:程序加载了,Proteus 也在运行,示波器就是一条直线。原因:DAC0832 的 WR1 或 CS 悬空,数据没有锁存;或者 P1 口被其他外设占用。解决:DAC0832 直通模式下 CS、WR1、WR2、XFER 全部接地,ILE 接高电平;检查程序里输出端口和接线是否一致。
5.5 换波形时出现半截旧波形
现象:从正弦切到方波,切换瞬间输出一个不完整的正弦周期。原因:index 没有在切换时归零,新波形从旧索引位置开始取点。解决:每次切换 wave_mode 时把 index 清零,保证新波形从第一个点开始输出。
6. 进阶技巧:用状态机重构按键扫描与双定时器调频
6.1 把阻塞式按键改成状态机扫描
前面用的延时消抖会阻塞主循环,如果以后要加 LCD 显示或串口通信,阻塞式按键会拖慢整个系统。更稳妥的做法是用 10ms 定时器中断做状态机扫描,主循环只负责响应按键事件。具体做法:定时器 1 设 10ms 中断,在中断里读按键电平,用状态变量记录“空闲、消抖、按下、等待松手”四个状态,状态迁移时置一个标志位,主循环检测标志位再执行切换。
unsigned char key_state = 0; unsigned char key_event = 0; void Timer1_ISR(void) interrupt 3 { TH1 = 0xDC; // 10ms 重装值 TL1 = 0x00; switch(key_state) { case 0: // 空闲 if(P3_2 == 0) key_state = 1; break; case 1: // 消抖 if(P3_2 == 0) { key_state = 2; key_event = 1; } else key_state = 0; break; case 2: // 等待松手 if(P3_2 == 1) key_state = 0; break; } }主循环里判断if(key_event)然后清零,执行波形切换。这样按键响应不阻塞任何其他任务,后面加显示或通信也不会互相干扰。
6.2 双定时器方案:一个管波形输出,一个管频率调节
单个定时器既要产生波形节拍,又要处理按键和调频,中断里事情太多容易出毛刺。更干净的做法是用定时器 0 专门产生波形输出中断,定时器 1 做 10ms 系统滴答,负责按键扫描和频率更新。频率调节时只改变定时器 0 的重装值,不碰定时器 1。
调频的粒度取决于重装值的步进。12MHz 晶振下,重装值每加 1,中断周期增加 1μs。如果波形表 64 点,输出频率变化约 0.24Hz。实际调频时步进设 10 或 20,避免按一次频率跳太多。重装值上限建议不超过 65000,否则中断周期超过 65ms,波形频率低于 0.24Hz,已经超出音频范围。
6.3 验证波形质量的三个实用方法
第一个方法:在 Proteus 里用频率计(COUNTER)接在运放输出端,直接读频率数值,比示波器数格子准。第二个方法:把波形数据导出到 Excel,画散点图,检查正弦表是否平滑、三角表是否线性。第三个方法:在 Keil 里用软件仿真,打开 Logic Analyzer,把 P1 口加进去,看输出数据的变化序列,确认索引递增和归零逻辑正确。
我自己的习惯是每次改完波形表或定时器参数,先在 Keil 的 Logic Analyzer 里跑一遍,确认数据序列没问题再进 Proteus。这样能把“程序逻辑错”和“仿真接线错”分开排查,省掉很多来回折腾的时间。希望帮到你。
本文还有配套的精品资源,点击获取