简介:面向DSP28335学习者的蜂鸣器音乐生成实验资源,基于TI TMS320F28335浮点DSP平台,利用PWM和定时器控制蜂鸣器发出不同频率的声音,帮助理解音乐合成原理。压缩包内仅1个C语言源文件,约1KB,代码包含DSP时钟与GPIO初始化、PWM模块预分频器和比较值设定、音符频率映射、乐谱顺序播放函数以及用于节拍控制的中断服务程序,结构清晰,适合入门级二次开发。当前已有431人学习下载。这份实验示例通过音符与PWM周期转换,搭建起从乐谱到实际发声的完整链路;读者既能直接烧写验证蜂鸣器播放旋律,也能修改数组中的音符序列来更换音乐,或增加按键、数码管等外设进一步扩展成电子琴应用。对DSP28335初学者、嵌入式系统课程设计和音频实验项目而言,是一份兼顾原理讲解与代码实现的实用参考。
1. 用 DSP28335 驱动蜂鸣器播放音乐,比你想象得更像一道数学题
“DSP蜂鸣器实验.zip”解压后,很多人最先找的是 main.c 里的播放函数,想在 DSP28335 上听到一段旋律。但把板子电源一开,听到的往往是一声刺耳的“滴”,而不是哆来咪。原因集中在两个地方:蜂鸣器是有源还是无源,以及 PWM 的频率寄存器算对了没有。这个实验真正要练的并不是“让蜂鸣器响”,而是把一个音符的频率,映射成 DSP28335 片上 ePWM 模块的周期寄存器值,再用定时中断按节拍切换。这套思路适用性很强,凡是需要产生方波音频的 MCU 项目都能用,适合正在调 C2000 板子、或者想搞清楚定时器占空比和音高关系的工程师。
2. 先搞清楚蜂鸣器吃的是什么信号:有源、无源、频率与占空比
2.1 有源蜂鸣器只能“响”,无源蜂鸣器才能“唱歌”
标题里同时出现“蜂鸣器”和“音乐”,这基本就限定在无源蜂鸣器上。有源蜂鸣器内部带了一个固定频率的振荡源,上电就自激,你改变输入方波频率其实没什么用,它只会以固定音调发声;而无源蜂鸣器内部没有振荡器,引脚上加多高的频率方波,它就发对应频率的声音,所以才能播放出不同的音符。区分方法很简单:看底面是否有 PCB 线圈、用万用表电阻档测两个引脚,有源蜂鸣器的电阻通常比较高,而且直流供电会直接响;无源蜂鸣器直流供电只会“咔”一声。
DSP28335 的 GPIO 输出能力虽然能点个 LED,但直接驱动蜂鸣器,电流不够也容易拉垮端口电平,常见做法是加一个 NPN 三极管做开关,比如 S8050,基极串联 1kΩ 电阻接 GPIO 或 PWM 引脚,集电极接蜂鸣器负极,发射极接地,蜂鸣器正极接 3.3V 或 5V 电源。如果蜂鸣器额定电压是 5V,注意 DSP28335 的 GPIO 是 3.3V,三极管开关电路不受这个限制,但 PWM 高电平幅度要能可靠开启基极。实验板上常见的是把蜂鸣器一脚接 3.3V,另一脚经三极管接地,这样用一个普通 GPIO 或 ePWM 输出口控制。
2.2 音高本质是方波频率:音符频率表怎么来的
音乐里标准音 A4 是 440Hz,比它高一个八度的 A5 是 880Hz,相邻半音之间频率相差 2 的 1/12 次方倍。十二平均律下,每个音符频率可以用公式 f = 440 × 2^((n - 69)/12) 算出来,其中 n 是 MIDI 音符编号,A4 对应 69。实际写程序时不需要现场算,直接把 C 大调或跨几个八度的音符频率存成一张表,用查表法换回中断里的执行时间。
下面是一段常见的音符频率表,我一般只取中间四个八度:
| 音名 | 频率(Hz) | 周期(us) | TBPRD(150MHz/16分频) |
|---|---|---|---|
| C4 | 261.63 | 3822 | 3580 |
| D4 | 293.66 | 3405 | 3188 |
| E4 | 329.63 | 3034 | 2840 |
| F4 | 349.23 | 2863 | 2680 |
| G4 | 392.00 | 2551 | 2388 |
| A4 | 440.00 | 2273 | 2128 |
| B4 | 493.88 | 2025 | 1896 |
| C5 | 523.25 | 1911 | 1788 |
表中的周期是一整个方波的周期,不是半周期。用 ePWM 递增计数输出时,一个完整的计数周期本身就对应方波的一个周期,所以 TBPRD 直接用 TBCLK 频率除以目标频率再减一,不需要乘二。很多刚接触 C2000 的人在这里多除一次,导致所有音符都低了八度。
2.3 DSP28335 ePWM 与 GPIO 方波生成能力对照
DSP28335 有 6 个 ePWM 模块,编号 EPWM1 到 EPWM6,每个模块有两路输出 ePWM xA 和 ePWM xB,其中 A 输出经常被用来做硬件 PWM。ePWM 的时基时钟 TBCLK 由系统时钟 SYSCLKOUT 经 CLKDIV 和 HSPCLKDIV 两级分频得到,系统时钟典型值是 150MHz。TBPRD 寄存器是 16 位,最大值 65535,因此不设置分频的话,最低可输出频率大约是 150MHz / 65535 = 2289Hz,这个频率远高于低音音符,所以必须给 ePWM 时基分频。
与之对比,直接翻转 GPIO 虽然不需要配 PWM 外设,但翻转动作依赖软件,每次翻转要执行指令,频率高了中断或循环会占掉大量 CPU。ePWM 是硬件行为,计数器到周期后自动翻转电平,不占用 CPU,所以音乐播放这类需要长时间连续输出的场景,硬件 PWM 是更可靠的选择。用 GPIO 输出方波的代码在 3.1 节里会展开,这里先放一个最小配置:
EALLOW; GpioCtrlRegs.GPAMUX1.bit.GPIO0 = 0; // GPIO0 作为普通 IO GpioCtrlRegs.GPADIR.bit.GPIO0 = 1; // 输出 EDIS; GpioDataRegs.GPADAT.bit.GPIO0 = 1; // 拉高这段代码本身不产生音频,但它展示了 GPIO 与 ePWM 在外设复用上的区别。后面用 PWM 时,GPAMUX 要置 1,而不是 0,这个位是最容易漏的配置。
3. 在 DSP28335 上用 ePWM 产生音符频率的三种方案
3.1 方案一:直接翻转 GPIO,逻辑最简单但频率上限低
把 GPIO 接到蜂鸣器控制器,然后写一个延时函数轮流拉高拉低,就能产生方波。这个方案最大的问题在于延时函数本身不精确:DSP28335 主频 150MHz,一条指令几个周期,简单循环能做到微秒级延时,但一旦中断开启、或者编译器优化级别变化,频率就飘了。再加上播放音乐要持续几百毫秒到几秒,主循环会被完全占死,点个 LED 都要等音乐停。所以这个方案只适合验证蜂鸣器是否有声音,不适合作为实验交付代码。
三种方案对比如下:
| 方案 | 频率精度 | CPU 占用 | 适合场景 |
|---|---|---|---|
| GPIO 翻转加延时 | 低,受中断和优化影响 | 高,播放期间无法做其他事 | 验证蜂鸣器好坏 |
| ePWM 递增计数 | 高,硬件自动输出 | 几乎为零 | 音乐播放主方案 |
| 定时器中断刷新 ePWM | 高,支持节拍切换 | 中,中断开销较小 | 播放完整乐谱 |
3.2 方案二:ePWM 递增计数模式,推荐作为播放主通道
ePWM 的递增计数模式比较适合方波蜂鸣器驱动。设置 TBPRD 决定输出频率,设置 CMPA 为 TBPRD 的一半获得 50% 占空比。关键代码如下,使用 EPWM1A 输出到 GPIO0:
#include "DSP2833x_Device.h" #include "DSP2833x_Examples.h" void EPwm1_Init(Uint16 tbprd) { EALLOW; GpioCtrlRegs.GPAMUX1.bit.GPIO0 = 1; // GPIO0 复用为 EPWM1A GpioCtrlRegs.GPADIR.bit.GPIO0 = 1; // 配置为输出 EDIS; EPwm1Regs.TBCTL.bit.CTRMODE = TB_COUNT_UP; // 递增计数 EPwm1Regs.TBCTL.bit.HSPCLKDIV = TB_DIV4; // 高速分频 4 EPwm1Regs.TBCTL.bit.CLKDIV = TB_DIV4; // 低速分频 4,合计 16 分频 EPwm1Regs.TBPRD = tbprd; // 周期值 EPwm1Regs.CMPA.half.CMPA = tbprd >> 1; // 50% 占空比 EPwm1Regs.AQCTLA.bit.CAU = AQ_SET; // 递增计数到 CMPA 时置高 EPwm1Regs.AQCTLA.bit.PRD = AQ_CLEAR; // 到 TBPRD 时拉低 EPwm1Regs.TBCTL.bit.PRDLD = TB_IMMEDIATE; // 周期值立即生效 }这段代码里,HSPCLKDIV 和 CLKDIV 各 4 分频,TBCLK = 150MHz / 16 = 9.375MHz。此时 TBPRD = 9,375,000 / 频率 - 1。以 A4 440Hz 为例,TBPRD = 21307,在 16 位范围内。换成 1000Hz 以上音符时,TBPRD 降为 9375,精度尚可。分频越大,TBPRD 数值越大,对低频越友好,但两个相邻半音之间的寄存器差值会变密,高频时一个 LSB 会造成较大频偏,所以实验中使用 16 分频是比较均衡的选择。
3.3 方案三:定时器中断刷新 TBPRD,动态改频率
ePWM 寄存器在运行时随时可以改,但要让音乐按节拍走,不能只改一次。常见做法是开一个 CPU 定时器中断,中断周期设为节拍的最小单位,比如一个八分音符 125ms。每次中断里取出乐谱中的下一个音符,重新计算 TBPRD 并写入。这里要注意:写 TBPRD 前如果计数器正在运行,可能造成一个周期异常,建议先设置 TBCTL 的 PRDLD 为立即装载,并在写周期值后不重置计数器,这样下一个周期自然按照新频率走。
下面这段代码示意了中断服务函数里的处理框架:
interrupt void cpu_timer0_isr(void) { if (note_index >= note_count) { stop_buzzer(); // 播完停止 return; } note_t note = score[note_index]; if (note.freq == 0) { stop_buzzer(); // 休止符 } else { EPwm1Regs.TBPRD = (Uint16)(TBCLK_HZ / note.freq - 1); EPwm1Regs.CMPA.half.CMPA = EPwm1Regs.TBPRD >> 1; start_buzzer(); } note_index++; }这个中断服务里不能塞延时,也不能塞像 LCD 刷新那样的重操作,否则节拍会漂。如果后续要加节拍时长控制,可以在中断里维护一个递减计数器,而不是用多个不同周期的定时器。这里 TBCLK_HZ 应该和你初始化时设置的分频一致,建议定义一个宏,避免在 ISR 里做浮点除法;DSP28335 没有硬件浮点单元,F28 系列里有 FPU,但整数除法也够用。
4. 把乐谱变成数组:音符编码、节拍与播放调度
4.1 音符的周期值:查表还是现算
在 DSP28335 里,每个音符对应一个 16 位周期值。为了省去中断里的除法,可以预先把频率换算成 TBPRD 值存表,用音符编号做数组下标。定义一个结构体数组,每个元素包含频率或者直接用周期值、时值计数、是否休止。用周期值更直接,因为中断里只需要赋值,不需要除法;用频率值则代码可读性更好,对变量和常量的区分也更直观。我的经验是,乐谱数组里存音符编号和时值,另外单独建一张周期值表,中断里查表,这样两者优点都占。
下面是一个简化版本的乐谱表示:
typedef struct { Uint16 tone; // 音符编号,0 表示休止 Uint16 duration; // 节拍时长,单位是定时器中断周期个数 } note_t; const note_t score[] = { {C4, 2}, {C4, 2}, {D4, 2}, {C4, 2}, {F4, 4}, {E4, 4}, {C4, 2}, {C4, 2}, {D4, 2}, {C4, 2}, {G4, 4}, {F4, 4}, // 这里省略后续小节 };这里的 tone 用枚举或者宏定义,C4、D4 这些符号在头文件里被定义为周期表的索引。duration 的单位取决于定时器中断的频率,比如中断每 62.5ms 一次,那么一个四分音符的时值是 4,八分音符是 2。这样设计的好处是,主循环只负责推进播放状态,乐谱数据和播放速度分离,调整播放速度只需要改定时器周期或中断里的累加逻辑。
4.2 节拍与中断周期的换算
节拍控制是蜂鸣器音乐实验里最多坑的地方。很多人直接在音符结构体里写 delay_ms(500),看起来播放正常,但 CPU 被占断。正确的是把节拍换成中断次数。假设歌曲速度是每分钟 120 拍,一拍的时间是 500ms,如果定时器中断周期是 10ms,那一拍就是 50 次中断。按八分音符 250ms 计算,时值是 25。
中断周期不宜太短,否则频繁进中断挤占主循环;也不宜太长,否则无法表示三十二分音符。10ms 到 20ms 是一个舒适区。乐谱数组里统一存中断次数而非毫秒,可以避免运行时反复做乘除。同时要注意,DSP28335 的 CPU Timer0 中断默认开启时,PieCtrl 和 IER 都要配置,否则 ISR 永远不会进。一个可用的定时器初始化片段如下:
EALLOW; PieVectTable.TINT0 = &cpu_timer0_isr; EDIS; ConfigCpuTimer(&CpuTimer0, 100, 1000); // 10ms 周期 CpuTimer0Regs.TCR.all = 0x4000; // 启动定时器 IER |= M_INT1; PieCtrlRegs.PIEIER1.bit.INTx7 = 1; EINT;参数说明:ConfigCpuTimer 的第二个参数是 CPU 频率,单位 MHz,第三个参数是周期,单位微秒,所以 100MHz 对应 10ms。DSP28335 使用 150MHz 时,这里要写成 ConfigCpuTimer(&CpuTimer0, 150, 10000)。
4.3 事件驱动播放:主循环只做状态机切换
完整播放逻辑可以拆成两个状态:播放中和停止。主循环检查播放按钮或串口命令,收到播放请求就把 note_index 清零,打开定时器中断;最后一个音符播完后在 ISR 里关闭中断,主循环通过一个标志位得知播放结束。示例中的状态变量如下:
volatile Uint16 play_active = 0; volatile Uint16 note_index = 0; void start_playing(void) { note_index = 0; play_active = 1; // 开启CPU Timer0中断 } interrupt void cpu_timer0_isr(void) { if (!play_active) return; Uint16 tone = score[note_index].tone; set_buzzer_freq(tone); note_index++; if (note_index >= sizeof(score)/sizeof(score[0])) { play_active = 0; stop_buzzer(); } }这种做法下,主循环可以干别的事,例如扫描按键、刷新数码管。中断里只做很小的任务,不会造成音乐卡顿。注意 set_buzzer_freq 里如果 tone 是 0,要调用 stop_buzzer 关闭 PWM 输出,因为 0 赫兹不是一个合法频率。休止符不能忽略,否则两个音符之间没有间隙,会连成一片。
5. 排错:为什么板子上的蜂鸣器只会“滴”不会“哆来咪”
5.1 先确认蜂鸣器类型,再检查驱动电路
程序写完后最常见的现象是:固定“滴”声,音乐启动后没变化。第一步先看蜂鸣器型号,有源蜂鸣器输出恒定频率,改 PWM 频率没有用;实验要求无源蜂鸣器,如果板子上焊的是有源,直接换器件或者在原理图上飞线。第二步测量驱动电路,用万用表二极管档测三极管 B-E 结是否正常,基极电阻有没有焊错位。GPIO 高电平能不能把这个三极管打开,取决于电阻大小和 GPIO 驱动能力,我一般用 1kΩ 到 4.7kΩ,阻值太大基极电流不够,蜂鸣器声音会小。
下面这个表格总结了常见现象和排查方向:
| 现象 | 可能原因 | 优先检查 |
|---|---|---|
| 一直“滴”不变化 | 有源蜂鸣器、PWM 频率未更新 | 蜂鸣器类型、ISR 是否触发 |
| 无声 | 三极管没导通、PWM 没输出 | 基极电阻、GPAMUX、EPwm 时钟 |
| 声音刺耳 | 频率值比预期高一倍 | TBPRD 计算是否少了减一 |
| 播放一半卡住 | 中断进不去或数组越界 | 数组长度、IER、PieCtrl |
| 声音断续 | 中断被高优先级打断 | ISR 执行时间、中断嵌套 |
5.2 示波器看 PWM 引脚和蜂鸣器引脚波形
最直接的定位方法是示波器。先测 DSP28335 的 GPIO0/PWM 引脚,设置为播放状态后应该能看到 50% 占空比的方波,而且频率会按音符变化。如果没有波形,检查 GpioCtrlRegs.GPAMUX1 是否选对,GPIO0 对应 EPWM1A,GPIO1 对应 EPWM1B,GPIO2 对应 EPWM2A,别名不要搞混。再看 EPwm1Regs.TBCTL 的时钟使能位,有些例程会关闭 ePWM 时钟来省电,忘了开自然没有输出。最后测蜂鸣器两端,如果 PWM 端波形正常但蜂鸣器端没有,问题在驱动电路。
另一个常见坑是波形有但频率不对。用示波器的频率测量功能读实际频率,和乐谱频率对照。如果实际频率恰好是目标频率的一半,检查是不是用了上下计数模式又除了一次二;如果频率变化没有规律,检查定时器中断是否被打断,例如有更高优先级的中断长时间占用 CPU。
5.3 中断没进、进得太密、节拍漂移的处理
播放没有音乐但 PWM 一直保持某个频率,通常是中断没触发。检查步骤:CPU Timer0 初始化是否开启了 TSS 位;PieCtrlRegs.PIEIER1 是否允许了 TINT0;IER 是否使能 INT1;以及全局中断 EINT 是否打开。在 ISR 第一行翻转一个 GPIO,用示波器测量这个测试点,如果中断正常,会有规律的脉冲。
如果声音断断续续,可能是中断优先级问题。DSP28335 的中断嵌套默认不开启,ISR 里如果调用了较长函数,下一个中断会被推迟。解决办法是把播放状态更新做得更短,或者把音符周期表查表操作挪到中断外。节拍漂移还有一个来源:在 ISR 里执行除法。整数除法几十个周期还好,但浮点除法会占用上百周期,主频 150MHz 下可能让 10ms 中断超时,避免在 ISR 里写浮点运算。
提示:在开发阶段,把音符切换时翻转另一个 GPIO,用双通道示波器同时看这个 GPIO 和 PWM 输出,能让中断触发情况和频率变化一目了然。
5.4 蜂鸣器音量小或失真排查
音量小通常是驱动电压不够或占空比太小。50% 占空比是方波能量最大的配置,少于一二十个百分点声音就会明显变小。失真常见于把无源蜂鸣器接到了 5V 电源却用了 3.3V 管脚驱动三极管,三极管没有完全饱和,集电极压降增大。对实验板来说,直接用 3.3V 供电、选低导通电压的三极管,比追求 5V 供电更稳定。如果想调节音量,后续可以改 PWM 占空比,但要注意无源蜂鸣器在极低占空比下声音会断续,降到 10% 以下基本就听不到。
6. 从“会响”到“好听”:非阻塞播放、占空比音量与验证技巧
6.1 非阻塞播放是实验的隐藏加分项
DSP 音乐实验最容易被忽略的要求就是“播放同时还能响应其他操作”。前面用的定时器中断方案已经做到了中断驱动播放,主循环留白。再进一步,可以在中断里维护一个累计中断次数计数器,每次中断递减,到零再切换到下一个音符,这样同一个音符即使持续好几拍,也不需要 CPU 在中间反复改写寄存器。
volatile Uint16 note_duration_count = 0; interrupt void cpu_timer0_isr(void) { if (note_duration_count > 0) { note_duration_count--; return; } if (note_index >= note_count) { stop_buzzer(); return; } note_t n = score[note_index]; set_buzzer_freq(n.tone); note_duration_count = n.duration - 1; note_index++; }这个写法把“切音”动作从“每个中断都发生”改成“只有时值耗尽才发生”,中断开销更均匀。
6.2 PWM 占空比做音量,和“PWM+DAC 音乐”的边界
让蜂鸣器声音有大有小,可以修改 CMPA 为 TBPRD 的百分比,也就是占空比调制。无源蜂鸣器对脉宽变化比较敏感,常用于音量渐变或模拟双音。热词里常出现的“PWM+DAC 音乐”其实走的是另一条路线:用 PWM 输出经过低通滤波恢复成模拟音频信号,再接功放和喇叭,才能播放真正意义上的音乐。蜂鸣器实验的核心是方波频率,不是连续信号,如果想延伸,可以在 DSP28335 上同时使用两路 ePWM,一路发声,一路输出 32kHz 左右载波做简易 DAC,但这是后话了。
6.3 三个快速验证播放是否准确的技巧
播放结束后,把音符序列通过串口打印出来,与乐谱对比,能确认中断调度有没有漏音。第二,用示波器在蜂鸣器输入引脚触发单次波形,测一个音符的持续时间,和乐谱时值比较,误差应在定时器周期数量级内。第三,用手机上的调音器 App 对着蜂鸣器,看显示的音名是否落在 C4、D4 之类的位置,如果偏高或偏低半个音,检查 TBCLK 计算里分频是否按初始化写入的寄存器值一致。
实践里大多数刺耳或跑调问题,最后都落在分频和 TBCLK 宏定义不一致上。把 TBCLK_HZ 写成一个宏和初始化寄存器放在同一个文件,修改分频时两处同步更新,可以避免一类隐蔽错误。做到这一步,这个 DSP 蜂鸣器实验就已经超过了大多数“能响就行”的例程。
本文还有配套的精品资源,点击获取