Arduino驱动无源蜂鸣器播放音乐:从tone()函数到完整代码实现
2026/9/24 5:12:36 网站建设 项目流程

1. 别急着接线:先分清手里的蜂鸣器是有源还是无源

我第一次做蜂鸣器音乐播放时,在接线和代码上栽了大跟头。当时随便买了个蜂鸣器,照着网上的例子把tone(8, 262)写进程序,结果不管频率改成 262、294 还是 523,蜂鸣器都只发一种声音——干巴巴的“哔——”。后来拆开一看,蜂鸣器底部封着黑胶,里面藏了一个振荡电路,这就是典型的有源蜂鸣器。有源蜂鸣器通电就响,频率固定,天生不适合播放旋律。

播放音乐必须用无源蜂鸣器。这里的“有源”和“无源”指的是有没有内置振荡源,不是要不要接电源。无源蜂鸣器内部没有振荡电路,靠外部给一个交变信号——通常是一串方波——让压电陶瓷片或电磁线圈驱动振膜振动,才能发出声音。外部信号的频率决定了发音音调,所以只要不断改变 Arduino 引脚输出的方波频率,就能让蜂鸣器“唱”出不同音符。

1.1 有源和无源蜂鸣器的工作原理差异

有源蜂鸣器的核心结构是“振荡器 + 压电片或线圈”,上电后振荡器自己产生固定频率的驱动信号,通常是 2.7kHz 左右。这也是为什么很多人把有源蜂鸣器的两个引脚分别接 5V 和 GND,它就会直接响,不需要任何代码控制。

无源蜂鸣器则只有发声单元本身,比如压电陶瓷片或者电磁振膜。压电式无源蜂鸣器利用压电效应,在陶瓷片两端施加交变电压时,陶瓷片会随之发生机械弯曲,从而推动空气发声;电磁式无源蜂鸣器则是让交变电流通过线圈,产生交变磁场,吸放一个铁片或磁化振膜。无论哪种,都需要外部信号持续驱动。

判断手里的蜂鸣器能不能用来播音乐,最直接的办法是看引脚数量和使用方式:大多数有源蜂鸣器只需要两个引脚通直流电就会响,而无源蜂鸣器接上直流电只会“咔”一下不会连续发声。不过稳妥起见,我建议用代码测试,方法在下面。

1.2 三步判断你手里的蜂鸣器能不能播音乐

  1. 直接接线测试:把蜂鸣器两个引脚分别接 Arduino 的 5V 和 GND。如果通电就持续响,100% 是有源蜂鸣器,赶紧换掉;如果只有通电瞬间发出“哒”一声或者完全没声音,大概率是无源蜂鸣器。
  2. 用 Arduino 给一个固定方波:写一个简单的tone(8, 1000),如果听到 1kHz 的连续音,是无源蜂鸣器。如果听到的还是那种尖锐的固定“哔”声,就是有源的。
  3. 看型号和外观:很多无源蜂鸣器型号里带“无源”字样或标注为“speaker”,有源的则常标注“buzzer”或“with oscillator”。外壳底面有三条腿甚至带电路板痕迹的一般是有源蜂鸣器。

记住这个核心判断:想要播放音乐,必须使用无源蜂鸣器。后面的电路连接和代码也都是基于无源蜂鸣器来讲的。

2. 音阶与频率:那个 440Hz 的 A4 到底是怎么算出来的

搞清楚蜂鸣器类型后,下一个问题是:怎么把一段旋律变成 Arduino 能理解的频率数值?

先补一点最基础的物理和乐理。声音的本质是空气振动,人耳听到的音高由振动频率决定,频率越高音越高。音符之间有固定的频率比例关系,比如国际标准音 A4 的频率是 440Hz,高一个八度的 A5 频率是 880Hz,正好是它的两倍。十二平均律把八度分成 12 个半音,每相邻两个半音的频率比是 2 的 12 次方根,也就是大约 1.0595 倍。

所以从任意一个基准音符出发,都能算出其他所有音符的频率。MIDI 标准里把 A4 定义为编号 69,频率 440Hz,相邻编号每增加 1,频率乘上2^(1/12)。公式长这样:

f = 440 × 2^((n - 69) / 12)

其中 n 是音符的 MIDI 编号。比如中央 C(C4)的 MIDI 编号是 60,代入计算:

f = 440 × 2^((60 - 69) / 12) = 440 × 2^(-0.75) ≈ 261.63 Hz

如果你不想用 MIDI 编号,也可以换个写法:确定 C4 为 261.63Hz,然后每升高一个半音(比如 C#4、D4)频率乘上2^(1/12)

2.1 常用音符频率参考表

实际编程时没必要每次现场算,直接查表就好。下面是一张 C4 到 B5 的常用频率表,覆盖了绝大多数儿歌和流行曲目:

音符频率 (Hz)音符频率 (Hz)
C4262C5523
C#4277C#5554
D4294D5587
D#4311D#5622
E4330E5659
F4349F5698
F#4370F#5740
G4392G5784
G#4415G#5831
A4440A5880
A#4466A#5932
B4494B5988

别问我为什么表里绕开了 C6 之后的高音——很多无源蜂鸣器在高频段的发声效率会下降,加上小尺寸振膜本来就不擅长高频大音量输出,实际做项目时常用范围还是 C4 到 C5 这个区间。

2.2 在 Arduino 里把音符宏定义为常量

有了频率表,接着把它写成 Arduino 的宏定义。推荐#define一组常量,代码可读性会好很多:

#define NOTE_C4 262 #define NOTE_CS4 277 #define NOTE_D4 294 #define NOTE_DS4 311 #define NOTE_E4 330 #define NOTE_F4 349 #define NOTE_FS4 370 #define NOTE_G4 392 #define NOTE_GS4 415 #define NOTE_A4 440 #define NOTE_AS4 466 #define NOTE_B4 494 #define NOTE_C5 523

注意CS是升 C(C#),DS是升 D(D#),这套命名方式在 Arduino 官方示例的pitches.h里也是这么用的。如果你要覆盖更多八度,同样规则把数字换成 1、2、3 即可。

有些教程还会贴一整份pitches.h头文件,从 B0 到 D8 全列出来。我的建议是:刚开始不要直接引用大文件,先自己定义要用到的十几个音符,等需要跨多个八度复杂编曲时再考虑引入完整头文件。这样代码更轻,也能逼自己理解每个音符的实际频率。

3. 硬件连接:直连 Arduino 还是加三极管驱动

蜂鸣器的硬件接线看起来简单,但这里有个很多人忽略的坑:压电式无源蜂鸣器和电磁式无源蜂鸣器对驱动电流的要求完全不同,如果一上来就全部直连,轻则声音不对,重则可能拉垮 Arduino 的电平输出。

先看压电式无源蜂鸣器。它的核心是压电陶瓷片,属于高阻抗负载,工作电流通常在 5mA 以下。Arduino 的 GPIO 引脚最大能输出 40mA 电流,所以直接把这个蜂鸣器接在引脚和 GND 之间完全没问题。实际接法:

Arduino D8 引脚 → 蜂鸣器正极(+) 蜂鸣器负极(-) → Arduino GND

为了限流和保护引脚,我习惯在 D8 和蜂鸣器正极之间串一个 100Ω 的电阻。这不是必须的,但能避免误接或大电压尖峰时损伤引脚。

如果是电磁式无源蜂鸣器,情况就不同了。它内部是一个线圈,工作时电流可以达到 20mA 甚至更高,启动瞬间还有电流冲击。直接把大功率电磁蜂鸣器接在 GPIO 上,可能导致引脚电压被拉低、蜂鸣器音量变小,长时间工作还可能损坏芯片。

3.1 三极管驱动电路怎么搭

更稳妥的方案是用一个 NPN 三极管(比如 S9013、2N2222、BC547 都行)做开关驱动,用 Arduino 的小电流去控制蜂鸣器的大电流通路。

接法如下:

5V ─────────── 蜂鸣器正极 蜂鸣器负极 ──── NPN 三极管集电极(C) Arduino D8 ── 1kΩ电阻── NPN 三极管基极(B) NPN 三极管发射极(E) ─── GND

原理很简单:当 D8 输出高电平时,三极管基极有电流流过,三极管导通,蜂鸣器负极被拉到地,蜂鸣器通电发声;D8 输出低电平时三极管截止,蜂鸣器不响。tone()函数在后台以极快速度切换引脚高低电平,三极管就跟着开关,蜂鸣器两端得到的同样是方波信号。

另外建议在蜂鸣器两端反并联一个二极管,阴极接 5V,阳极接三极管集电极。这个二极管不是必须的,但对电磁式蜂鸣器有保护作用——线圈断电瞬间会产生反向电动势,可能反冲到三极管或 5V 电源,二极管可以给这股反向电流一个泄放回路。压电式蜂鸣器通常不需要,因为压电片本身阻抗高,反向冲击很小。

3.2 压电和电磁蜂鸣器怎么选

做蜂鸣器音乐播放,我实际用下来各有优缺点:压电蜂鸣器音量偏小但声音清脆、功耗极低,适合电池供电的小项目;电磁蜂鸣器低音效果稍好、音量也更大,但功耗大,驱动电路要细心一点。如果只是想最快跑通“蜂鸣器唱歌”,选一个普通的压电无源蜂鸣器直连 Arduino 即可,完全不需要三极管电路。想做好一点,比如声音洪亮、低音饱满,就上电磁蜂鸣器配三极管驱动。

4. tone() 函数从里到外:方波产生机制、定时器冲突和真实限制

tone()是 Arduino 内置函数,也是播放音乐的核心。这个函数看起来只有一个“发出声音”的作用,但如果你不了解它在芯片层面干了什么,后面踩坑会很莫名其妙。

tone(pin, frequency)做的事可以理解为:让指定引脚以某个频率持续输出方波,直到调用noTone()或再次调用tone()。它的输出信号是 50% 占空比的方波,不是像音叉一样的纯净正弦波。

4.1 tone() 和 PWM 有什么区别

很多新手会混淆tone()analogWrite()的 PWM 输出,两者都是用数字引脚模拟某个信号,但用途完全不一样。

PWM 通过快速开关引脚来调节平均电压,重点在“占空比”,而不是“切换频率”。比如把引脚以 490Hz 的频率快速开关,同时让高电平占 50%,平均电压就是 2.5V,适合用来控制 LED 亮度、电机转速。虽然 PWM 的频率也处在一个范围内,但analogWrite()一般是固定频率、靠改变占空比来控制电压,而不是为了产生音频。

tone()则正好相反,它保持 50% 占空比不变,重点在“切换频率”。芯片内部的定时器精确控制引脚电平翻转的时间间隔,从而产生指定的方波频率。这个功能是专为音频应用设计的,能覆盖从几十赫兹到几十万赫兹的频率范围。用生活类比:PWM 像是调光灯旋钮,重点在于灯多亮;tone()像是指节拍器,重点在于每秒敲多少下。

4.2 tone() 是异步的,但很多人把它当阻塞函数用

tone(pin, frequency)调用后立刻返回,声音在后台由定时器硬件继续生成,不占用 CPU。tone(pin, frequency, duration)的第三个参数表示持续时间,到时间后自动停止,同样不阻塞程序。

但要注意:因为tone()不阻塞,如果你在loop()里每一轮都调用tone(pin, 262),主程序会不停重置频率,实际听起来就像声音断断续续。正确的做法是设计好播放状态,要么用delay()等待音符持续结束,要么用millis()做非阻塞定时。

4.3 定时器冲突:不是所有引脚都能随便用

这是tone()最大的隐藏坑。在 Arduino Uno(ATmega328P)上,tone()用的是 Timer2 这个硬件定时器。而 Timer2 同时驱动着 GPIO 3 和 GPIO 11 两路 PWM 输出。

也就是说,当tone()正在工作时,analogWrite(3, ...)analogWrite(11, ...)会失效或者产生异常输出。反过来也一样,如果你正在用引脚 3 或 11 做 PWM 调光,这时候调用tone()就会冲突。

因此我写程序时会刻意避开这两条引脚,默认把蜂鸣器接在 D8 或 D9 上。类似地,如果需要同时控制舵机,注意 Servo 库和tone()也可能有定时器争用的问题,这时要提前查清楚所用开发板的定时器分配情况。

4.4 频率范围和其他限制

官方文档说明,tone()支持的频率从约 31Hz 起,通用库在 Uno 上能到 65535Hz。实际做音乐播放时,这个范围完全够用——人耳能分辨的音高基本集中在 20Hz 到 20kHz,而小蜂鸣器在 4kHz 以上的响应就明显变差了。

另一个限制是同一时刻只能输出一个音调。这个限制是硬件层面的,你不要想着用两条引脚接两个音符模拟和弦,因为tone()对第二个引脚的调用很可能被忽略。如果想实现类似和弦的效果,可以用更高频率的音符做“琶音”快速轮流播放,不过那已经是进阶玩法了,这篇先不展开。

5. 节拍工程:从 BPM 到毫秒,把歌曲拆成数组

频率解决了“唱哪个音”的问题,剩下的一半是“每个音唱多久”,也就是节拍。这里需要引入 BPM(Beats Per Minute)的概念——每分钟的拍数。比如 120 BPM 意味着每分钟 120 拍,每一拍 0.5 秒。

在音乐中,不同音符时值对应不同的拍数。以最常见的 4/4 拍为例,一个全音符占 4 拍,二分音符占 2 拍,四分音符占 1 拍,八分音符占半拍,十六分音符占四分之一拍。于是任意音符的持续时间可以这样计算:

一拍时长(ms) = 60000 / BPM 音符时长(ms) = 一拍时长 × 拍数

比如 BPM = 120 时,一拍是 500ms,四分音符就是 500ms,八分音符就是 250ms。

5.1 用音符时值做数据编码

写程序时,我会把每首歌抽象成两个平行数组:一个存音高频率,一个存“时值类型”。时值用整数表示,比如 4 代表四分音符,2 代表二分音符,8 代表八分音符,1 代表全音符。这样音符时长可以统一算:

时长(ms) = (60000 / BPM) × (4 / 时值)

这里除以 4 是因为我们以四分音符为“1 拍”基准。如果这首歌是 3/4 拍或 6/8 拍,(4 / 时值)这个换算关系依然成立,因为实际拍数关系已经在时值编码里了。

停顿怎么办?在音高数组里用 0 表示休止符。播放时判断音高是否为 0,如果是就只做延时,不调用tone()

5.2 以《小星星》为例手工拆谱

《小星星》的简谱开头是:1 1 5 5 6 6 5 — ,对应 C C G G A A G(二分音符)。继续往下是 4 4 3 3 2 2 1 —。用上面的编码方式,可以拆成这样:

int melody[] = { NOTE_C4, NOTE_C4, NOTE_G4, NOTE_G4, NOTE_A4, NOTE_A4, NOTE_G4, 0, NOTE_F4, NOTE_F4, NOTE_E4, NOTE_E4, NOTE_D4, NOTE_D4, NOTE_C4, 0, 0, 0, NOTE_G4, NOTE_G4, NOTE_F4, NOTE_F4, NOTE_E4, NOTE_E4, NOTE_D4, 0, NOTE_G4, NOTE_G4, NOTE_F4, NOTE_F4, NOTE_E4, NOTE_E4, NOTE_D4, 0, NOTE_C4, NOTE_C4, NOTE_G4, NOTE_G4, NOTE_A4, NOTE_A4, NOTE_G4, 0, NOTE_F4, NOTE_F4, NOTE_E4, NOTE_E4, NOTE_D4, NOTE_D4, NOTE_C4, 0 }; int beats[] = { 4, 4, 4, 4, 4, 4, 2, 2, 4, 4, 4, 4, 4, 4, 2, 2, 2, 2, 4, 4, 4, 4, 4, 4, 2, 2, 4, 4, 4, 4, 4, 4, 4, 4, 2, 2, 4, 4, 4, 4, 4, 4, 2, 2, 4, 4, 4, 4, 4, 4, 2, 2 };

注意我在小节之间插入了休止符 0,这时候beats数组里对应位置用 2(二分音符)给一个停顿时长。这样听起来会有明显一句一句的呼吸感,比音符连着播完自然得多。

5.3 为什么要在音符之间留一点空隙

听感上有个细节:tone() 的方波如果两个音符之间完全无间隔,会连成一片糊在一起,尤其遇到同音重复时特别明显。所以播放完一个音符后,我会在音符时长基础上额外增加 20% 到 30% 的延时,再调用noTone()或者直接播放下一个音符。这个额外延时相当于乐谱里的短休止或换气,效果差异很大。

6. 完整示例:让《小星星》真正跑起来,并封装成通用播放函数

下面给出一份可以直接上传到 Arduino 的完整代码。代码里我做了三件事:定义音符宏、声明旋律数组和节拍数组、在setuo里播放整首歌并循环。

// 无源蜂鸣器接 D8 const int buzzerPin = 8; const int tempo = 120; // BPM #define NOTE_C4 262 #define NOTE_D4 294 #define NOTE_E4 330 #define NOTE_F4 349 #define NOTE_G4 392 #define NOTE_A4 440 int melody[] = { NOTE_C4, NOTE_C4, NOTE_G4, NOTE_G4, NOTE_A4, NOTE_A4, NOTE_G4, 0, NOTE_F4, NOTE_F4, NOTE_E4, NOTE_E4, NOTE_D4, NOTE_D4, NOTE_C4, 0, NOTE_G4, NOTE_G4, NOTE_F4, NOTE_F4, NOTE_E4, NOTE_E4, NOTE_D4, 0, NOTE_G4, NOTE_G4, NOTE_F4, NOTE_F4, NOTE_E4, NOTE_E4, NOTE_D4, 0, NOTE_C4, NOTE_C4, NOTE_G4, NOTE_G4, NOTE_A4, NOTE_A4, NOTE_G4, 0, NOTE_F4, NOTE_F4, NOTE_E4, NOTE_E4, NOTE_D4, NOTE_D4, NOTE_C4, 0 }; int beats[] = { 4, 4, 4, 4, 4, 4, 2, 2, 4, 4, 4, 4, 4, 4, 2, 2, 2, 2, 4, 4, 4, 4, 4, 4, 2, 2, 4, 4, 4, 4, 4, 4, 4, 4, 2, 2, 4, 4, 4, 4, 4, 4, 2, 2, 4, 4, 4, 4, 4, 4, 2, 2 }; void playMelody(int* mel, int* beat, int len) { int beatMs = 60000 / tempo; // 一拍多少毫秒 for (int i = 0; i < len; i++) { int noteDur = beatMs * 4 / beat[i]; if (mel[i] != 0) { tone(buzzerPin, mel[i], noteDur); } delay(noteDur * 1.3); // 多留 30% 的呼吸感 noTone(buzzerPin); } } void setup() { int totalNotes = sizeof(melody) / sizeof(melody[0]); playMelody(melody, beats, totalNotes); } void loop() { // 播放结束后可以在这里加其他逻辑 }

这个代码在播放过程中是阻塞的,因为delay()卡住了整个程序。如果你希望音乐播放的同时还能响应按键、更新屏幕,那就不能直接用这种写法,得把播放状态机改成millis()非阻塞模式。对于入门演示,这个版本已经足够直观。

6.1 如何换一首歌

换歌只需要改melodybeats两个数组,其他部分完全不用动。比如《欢乐颂》开头是 3 3 4 5 5 4 3 2 1 1 2 3 3 2 2,对应 E E F G G F E D C C D E E D D,你照着简谱往数组里填就行。

一个实用建议:先用纸笔把简谱转成“音名 + 时值”两行列表,再填进数组。直接在代码里改容易漏音符,尤其遇到重复段落时长度对不上会很难排查。我在做《生日快乐》时因为数组长度不一致,播放到一半就“哑火”,排查了很久才发现是某个段落的音符数量少写了一个。

6.2 增加循环播放和变速控制

如果你想让它循环播放,在loop()里再调用一次playMelody()即可。如果想让音乐可以根据旋钮调整速度,把tempo改成全局变量,播放前从电位器的analogRead()映射到合理区间,比如 60 到 200 BPM。注意tempo是全局的还是局部的是有区别的,因为playMelody()每次播放都要临时算beatMs,所以改成变量后无需改动播放函数内部逻辑。

7. 实测中的问题清单:音量失控、音调不对、程序“卡死”怎么办

做蜂鸣器音乐播放时,我踩过的坑足够写满一页纸了。下面这些是出现频率最高的问题,按我的习惯从硬件到软件逐一排查。

7.1 蜂鸣器响但音调完全不对

先确认是不是有源蜂鸣器。如果你把有源蜂鸣器接上来调tone(),它只会发出一个固定音调,因为内部振荡器根本不受你的方波控制。我用过最简单暴力的检查办法:直接给蜂鸣器通 5V 直流电,能持续响就是有源的,不能持续响才是无源的。

另一个常见原因是蜂鸣器类型本身不适合所选频率。有些压电蜂鸣器的谐振频率在 3kHz 左右,你给它一个 200Hz 低频方波,声音可能很小甚至听不见。这不一定是代码问题,换个频率范围更宽的蜂鸣器就好了。

7.2 声音忽大忽小、有刺耳失真

一种情况是供电不稳。蜂鸣器发声瞬间会拉低电源电压,如果 Arduino 用的是 USB 供电,尤其是廉价数据线,电压跌落可能很明显,导致发声波形被拉垮。换个质量好的 USB 线、或者单独给蜂鸣器一路 5V 电源会有改善。

另一种情况是方波驱动导致的高频毛刺。tone()输出的方波本身包含丰富的奇次谐波,听起来比正弦波刺耳。想改善音色有两个方向:第一,串联一个几十到几百欧姆的电阻限流,音量会降一些但声音变柔和;第二,在蜂鸣器两端并联一个 0.1uF 小电容滤波,可以把方波毛刺削掉一部分。注意电容别选太大,否则会把主频率的高频部分一并滤掉,声音变闷。

7.3 程序不“卡死”但播放到一半停了

排查顺序:先确认数组长度是不是算对了。sizeof(melody)/sizeof(melody[0])这个方法可以自动计算数组长度,但如果你在函数里传数组,函数内部再用sizeof是拿不到真实长度的,因为数组退化成指针了。我这里把长度作为参数传进playMelody(),就是避免这种坑。

还有可能是定时器冲突。比如你一边用tone()一边用analogWrite(11, ...)调节某个 LED,一旦tone()启动,PWM 输出就会异常,甚至导致程序行为怪异。遇到说不清的问题,先把所有其他功能模块都去掉,只留蜂鸣器播放,确认核心流程没问题再逐步加回其他功能。

7.4 想控制音量但tone()没有音量参数

tone()只能控制频率,不能直接控制音量,这是很多新手找不到“音量函数”的原因。控制音量有几种替代办法:

  • 在蜂鸣器回路里串联电阻,阻值越大音量越小。缺点是阻值固定,音量不可调。
  • 用数字电位器或者三极管控制蜂鸣器工作电压,可以实现软件调音量,但电路复杂度上升。
  • 用两个 GPIO 通过电阻分压衰减并联输出,得到一个固定的降档音量,方法比较巧但通用性一般。

入门阶段我建议直接用串联电阻调音量,等后面做更复杂的音频项目时,再考虑专门音频功放芯片或者用analogWrite()配合 PWM 驱动。

7.5 把tone()塞进中断里要注意的事

如果要在中断服务函数里调用tone(),一定要小心。tone()本身依赖定时器中断,如果你在中断回调里调用它,可能和底层中断产生竞争,导致不可预期行为。更常见的做法是:在中断里设置一个标志位或者记录事件,在主loop()里检查标志位再调用tone()

我最早做“按键触发音乐”的小项目时,就是在按键中断里直接调用tone(),结果音乐播放断断续续。改成中断里只置位、主循环里播放后,问题就消失了。类似这种“中断里尽量不干活”的习惯,做 Arduino 项目越早养成越省心。

做蜂鸣器音乐播放这个项目,最大的收获不是把《小星星》播出来,而是彻底弄懂了方波、频率、定时器之间的关系。tone()函数表面简单,背后牵涉到芯片定时器分配、信号波形、驱动能力这些绕不开的底层知识。把这些搞明白之后,再去玩语音播报、音效合成,思路会清晰很多。如果你照着做的时候也碰到什么奇怪的问题,欢迎在评论区留言聊聊,说不定正好是我踩过的那个。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询