A 板响起了 E6
A 板上那颗蜂鸣器平时只会发出「哔——」这种毫无感情的报警声,可是理论上,只要给它不同频率的方波,它就能发出任何音高。
那……它是不是也能唱歌?
于是就有了这个工程:robomaster-haruhikage-player。A 板、STM32F427、一颗无源蜂鸣器、一段 PWM,演奏 MyGO!!!!! 的《春日影》。
至于为什么要演奏春日影……大概是因为,就算是蜂鸣器,也会有想演奏的东西吧。
「なんで春日影やったの?」——因为它就在那里,而且我正好会一点 PWM。
蜂鸣器是怎么「唱」起来的
无源蜂鸣器:给它频率,它给你音高
A 板板载的蜂鸣器是无源蜂鸣器。它和有源蜂鸣器的区别很直接:有源蜂鸣器内部自带振荡源,通电就响,频率固定,只适合报警;无源蜂鸣器内部就只是一个谐振片,你给它什么频率的方波,它就振动出什么频率的声音。
这个特性对播放音乐来说刚刚好:
- 方波的频率 → 音高。给它 262 Hz,它发中央 C;给它 1318 Hz,它发 E6——春日影前奏的第一个音。
- 方波的占空比 → 音量。占空比越大,谐振片获得的能量越多,声音越响。
所以整个工程的核心问题就变成了:如何让 STM32 的一个引脚,精确地输出任意指定频率的方波?
PWM:硬件级的方波发生器
答案是定时器的 PWM 输出。PWM(脉冲宽度调制)用三个参数完全描述一个方波:
flowchart LR
A["定时器计数器<br/>从 0 数到 ARR"] --> B["CNT < CCR 时<br/>输出高电平"]
A --> C["CNT ≥ CCR 时<br/>输出低电平"]
B --> D["一个周期 = ARR+1 个计数<br/>方波频率 = f_timer / (ARR+1)"]
C --> D
D --> E["占空比 = CCR / (ARR+1)"]
- 计数器从 0 数到 ARR(自动重装载值),周而复始;
- 计数值小于 CCR(比较值)时输出高电平,其余时间低电平;
- 于是输出频率由 ARR 决定,占空比由 CCR 决定。
想换一个音?改 ARR。想调音量?改 CCR。就这么简单。剩下的都是工程细节——而工程细节,正是好玩的地方。
从琴键到查找表:音符系统
十二平均律,一行公式的事
音高本质上就是频率,而西方音乐的十二平均律规定:相邻半音的频率比是 。以 A4 = 440 Hz 为基准,第 个半音(MIDI 编号)的频率为:
从 A0 的 27.5 Hz 一路乘到 C8 的 4186 Hz,钢琴键上的每一个音都能用这一行公式算出来。当然,在单片机里现算有点浪费,所以我直接把结果写成了查找表。
枚举 + 频率表
typedef enum {
NOTE_P = 0, NOTE_A0, NOTE_A0S, NOTE_B0, // Octave 0
NOTE_C1, NOTE_C1S, NOTE_D1, NOTE_D1S, NOTE_E1, NOTE_F1, NOTE_F1S, NOTE_G1, NOTE_G1S, NOTE_A1, NOTE_A1S, NOTE_B1, // Octave 1
NOTE_C2, NOTE_C2S, NOTE_D2, NOTE_D2S, NOTE_E2, NOTE_F2, NOTE_F2S, NOTE_G2, NOTE_G2S, NOTE_A2, NOTE_A2S, NOTE_B2, // Octave 2
// ……中间几个八度同理省略……
NOTE_C7, NOTE_C7S, NOTE_D7, NOTE_D7S, NOTE_E7, NOTE_F7, NOTE_F7S, NOTE_G7, NOTE_G7S, NOTE_A7, NOTE_A7S, NOTE_B7, // Octave 7
NOTE_C8, NOTE_COUNT
} Note_t;
const uint16_t noteFrequencies[NOTE_COUNT] = { 0,
28, 29, 31, // Octave 0
33, 35, 37, 39, 41, 44, 46, 49, 52, 55, 58, 62, // Octave 1
65, 69, 73, 78, 82, 87, 93, 98, 104, 110, 117, 123, // Octave 2
131, 139, 147, 156, 165, 175, 186, 196, 208, 220, 233, 247, // Octave 3
262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494, // Octave 4
523, 554, 587, 622, 659, 698, 740, 784, 831, 880, 932, 988, // Octave 5
1046, 1109, 1175, 1245, 1318, 1397, 1480, 1568, 1661, 1760, 1865, 1976, // Octave 6
2093, 2217, 2349, 2489, 2637, 2794, 2960, 3136, 3322, 3520, 3729, 3951, // Octave 7
4186 // Octave 8
};这里的几个小设计:
NOTE_P是休止符,频率表里对应 0。播放时查到 0 就静音,和普通音符走同一条代码路径,不用特判。NOTE_COUNT放在枚举末尾,天然的「枚举数量」,频率数组直接拿它当长度,加音域时两边自动同步。- 值全部取整。C4 实际是 261.63 Hz,表里写 262——对蜂鸣器这种音色来说,0.1% 的频率误差人耳根本听不出来(后面会算,量化误差最大也就几个音分)。
一个 Note_t 枚举值就是一个 uint8_t,加上一个时长字节,一个音符只要 2 字节。整首春日影塞进一个 const uint8_t 数组,编译后不到 1 KB 的 Flash——比一张封面图的万分之一还小,却能装下一整段青春(喂)。
把春日影塞进数组:乐谱的编码
乐谱的编码格式非常朴素:(音符, 时长)二元组依次排列,最后以 0xFF 结尾。
const uint8_t Haruhikage[] = {
// 前奏 riff:E–D–C–D,春日影最著名的那四个音
NOTE_E6,8, NOTE_D6,4, NOTE_C6,8, NOTE_D6,4,
NOTE_E6,6, NOTE_F6,2, NOTE_E6,4, NOTE_D6,12,
// ……
// 副歌前的一句:B5–C6 的拉锯
NOTE_B5,8, NOTE_C6,4, NOTE_B5,8, NOTE_C6,4,
NOTE_B5,2, NOTE_A5,2, NOTE_G5,4, NOTE_P,4, NOTE_G5,4, NOTE_D5,4, NOTE_F5,4,
// ……
0xFF // 终止符
};翻译一下前两小节:E6 唱 8 拍单位,D6 唱 4,C6 唱 8,D6 唱 4——正是那段「登、登、登登」的吉他 riff。识谱的读者对着原曲听一遍就能对上,我扒谱的时候在谱子软件里来回放了不下一百遍,最后梦里都是灯的鼓点(不是)。
时长是怎么定义的
播放代码里:
HAL_Delay(SPEED / 8 * duration);
其中 SPEED = 500,所以每个时长单位是 ,时长数值就是这个单位的倍数:
| duration | 时长 | 音乐意义 |
|---|---|---|
| 2 | 125 ms | 十六分音符 |
| 4 | 250 ms | 八分音符 |
| 6 | 375 ms | 附点八分 |
| 8 | 500 ms | 四分音符 |
| 12 | 750 ms | 附点二分 |
| 8+4 | 1000 ms | 连音后的二分 |
整曲速度约合 120 BPM,比原曲稍微活泼一点点,但蜂鸣器君已经很努力了。
顺便一个只有自己知道的小彩蛋:SPEED / 8 在 C 的整数除法里是 (截断成 62),于是全曲每个音都短了 0.8%,整首歌悄悄快了那么一丢丢。就当是免费的活泼好了,反正听不出来(心虚)。
关于调性
原曲的编曲远比这个复杂,但我把它简化成了 C 大调家族的单音主旋律——整个乐谱数组里没有一个带升降号的音。这样做一半是为了扒谱省事,一半是故意的:无源蜂鸣器的音色本来就单薄,与其硬塞和弦制造一坨浑浊的方波,不如干干净净地只留主旋律。剩下的,交给听的人自己的记忆去补完就好了。这大概就是 8-bit 音乐的魅力:音色越简单,旋律越纯粹。
让 PH6 以 1318 Hz 抖动:定时器配置
A 板的蜂鸣器接在 PH6,这个引脚可以复用为 TIM12_CH1,正好拿来输出 PWM。CubeMX 里的配置最终生成的代码长这样:
htim12.Instance = TIM12;
htim12.Init.Prescaler = 83; // PSC
htim12.Init.CounterMode = TIM_COUNTERMODE_UP; // 向上计数
htim12.Init.Period = 499; // 初始 ARR
htim12.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE;
HAL_TIM_PWM_Init(&htim12);
sConfigOC.OCMode = TIM_OCMODE_PWM1; // PWM 模式 1
sConfigOC.Pulse = 0; // 初始 CCR = 0(静音)
sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
HAL_TIM_PWM_ConfigChannel(&htim12, &sConfigOC, TIM_CHANNEL_1);
// 引脚复用:PH6 → TIM12_CH1
GPIO_InitStruct.Pin = GPIO_PIN_6;
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
GPIO_InitStruct.Alternate = GPIO_AF9_TIM12;
HAL_GPIO_Init(GPIOH, &GPIO_InitStruct);为什么要算出 1 MHz
预分频值 83 看起来是个随便挑的数,其实是精心算的。整条时钟链是这样的:
flowchart LR
A["HSE 晶振<br/>12 MHz"] --> B["PLL<br/>÷6 ×168 ÷2"]
B --> C["SYSCLK<br/>168 MHz"]
C --> D["APB1 总线 ÷4<br/>42 MHz"]
D --> E["定时器时钟 ×2<br/>84 MHz"]
E --> F["PSC = 83<br/>84M ÷ 84 = 1 MHz"]
F --> G["计数器每 1 µs 数一下"]
TIM12 挂在 APB1 上。APB1 预分频是 4(不等于 1),所以定时器时钟自动翻倍,得到 84 MHz;再除以 ,计数频率正好是 1 MHz,每个计数 1 µs。
这个「凑整」带来一个天大的好处:ARR 和频率的关系变成了心算级别——
想发 E6(1318 Hz)?一百万除以 1318,ARR = 757。想发 A4(440 Hz)?ARR = 2272。不用掏计算器,调试的时候随手就能验算,快乐加倍。
初始 Period = 499 对应 2 kHz,但 Pulse = 0 意味着占空比为 0,上电时蜂鸣器是安静的——不会半夜烧录完板子突然「哔」地吓自己一跳,这个细节很重要(经验之谈)。
换一个音:SetNoteFrequency
播放器的心脏,把「音名」翻译成「寄存器值」的函数:
void SetNoteFrequency(uint16_t frequency)
{
if (frequency == 0) { // 休止符:占空比清零,安静
__HAL_TIM_SET_COMPARE(&htim12, TIM_CHANNEL_1, 0);
return;
}
uint32_t timerFreq = 1000000; // 计数频率 1 MHz
uint32_t arrValue = (timerFreq / frequency) - 1; // 频率 → ARR
float dutyCycle = (volume > 100) ? 50 : (float)(volume * 50 / 100);
uint32_t ccrValue = (uint32_t)(arrValue * dutyCycle / 100.0f); // 音量 → CCR
HAL_TIM_PWM_Stop(&htim12, TIM_CHANNEL_1); // 先停
__HAL_TIM_SET_AUTORELOAD(&htim12, arrValue); // 改频率
__HAL_TIM_SET_COMPARE(&htim12, TIM_CHANNEL_1, ccrValue); // 改音量
HAL_TIM_PWM_Start(&htim12, TIM_CHANNEL_1); // 再启动
}逐块拆解:
休止符通道。频率为 0(NOTE_P)时只把 CCR 清零。PWM 还在跑,但占空比是 0,输出恒为低电平,蜂鸣器安静。停波形的方式有很多种,这种最温柔。
频率 → ARR。就是上面那个一百万除以频率。整数除法会截断,带来一点量化误差——比如 E6 算出 ARR = 757,实际频率是 Hz,比标准值高 1.3 Hz,折合 1.7 音分。就算到了乐谱最高的 A6(1760 Hz),误差也就 2 个多音分,远小于人耳能分辨的 5 音分。蜂鸣器的音准,意外地很能打。
音量 → CCR。占空比上限故意压在 50%——方波占空比超过 50% 之后音量不升反降(高电平时间变长,谐振片反而来不及复位),所以 50% 就是「最大声」。代码里 volume = 2,换算下来占空比只有 1%:
1%:这也是无源蜂鸣器的一个特性:小占空比下的声音柔和很多,没有那种报警器式的尖啸感。
Stop → 改 → Start。每次换音都先停再启。一方面,工程开了 AutoReloadPreload,ARR 的写入要等到下一个更新事件才真正装载,先停一下再启动,配合音符之间那 5 ms 静音(下节会讲),保证新参数一定干净地生效;另一方面也让每个音的头都干脆利落,没有上一个音残留的半个周期。对强迫症十分友好。
播放主体:PlayMusic 和它的主循环
每次只吃一个音符
void PlayMusic(const uint8_t *music)
{
if (music[musicIndex] != 0xFF) {
Note_t note = music[musicIndex]; // 读音符
musicIndex++;
uint8_t duration = music[musicIndex]; // 读时长
musicIndex++;
SetNoteFrequency(noteFrequencies[note]); // 查表 → 发声
HAL_Delay(SPEED / 8 * duration); // 按时长保持
SetNoteFrequency(0); // 静音
HAL_Delay(5); // 音符间隔 5 ms
}
else { // 曲终
musicIndex = 0xFFFF; // 回到待机
HAL_GPIO_WritePin(LED_RED_GPIO_Port, LED_RED_Pin, GPIO_PIN_RESET);
HAL_GPIO_WritePin(LED_GREEN_GPIO_Port, LED_GREEN_Pin, GPIO_PIN_SET);
SetNoteFrequency(0);
HAL_Delay(1);
}
}注意 musicIndex 是全局变量,每调用一次只消费一个音符,然后就返回了。这是刻意的:如果在一个函数里 while 循环放完一整首歌,按键就永远得不到响应;把粒度切到「一个音符」,主循环就有机会在音符之间插入别的事情——比如检查按键、刷新 LED。
那个 5 ms 的小小静音
SetNoteFrequency(0);
HAL_Delay(5);
这两行是我最喜欢的细节。连续两个相同音高的音符(乐谱里 NOTE_E5,4, NOTE_E5,4 这种)之间如果没有间隔,听起来会粘连成一个长音——因为频率根本没变,耳朵无法分辨「这是两个音」。插进 5 ms 的静音之后,每个音符都有了清晰的「头」,相当于管乐演奏里的吐音。短短 5 ms,蜂鸣器从「报时」进化成了「演奏」。
状态机:用 0xFFFF 当哨兵
主循环把所有事情串起来:
while (1)
{
// 按键检测(含 20 ms 消抖)
if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_SET) {
HAL_Delay(20);
if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_SET) {
musicIndex = 0; // 从头开始
}
}
if (musicIndex != 0xFFFF) { // 播放中
HAL_GPIO_WritePin(LED_RED_GPIO_Port, LED_RED_Pin, GPIO_PIN_SET);
HAL_GPIO_WritePin(LED_GREEN_GPIO_Port, LED_GREEN_Pin, GPIO_PIN_RESET);
PlayMusic(Haruhikage);
}
HAL_Delay(1);
}musicIndex 初始化为 0xFFFF——一个合法乐谱永远到不了的值,拿来当「待机」标记。整个系统的状态机长这样:
stateDiagram-v2
[*] --> 待机绿灯
待机绿灯 --> 演奏红灯 : 按下按键<br/>musicIndex = 0
演奏红灯 --> 演奏红灯 : PlayMusic() 消费一个音符
演奏红灯 --> 待机绿灯 : 读到 0xFF<br/>musicIndex = 0xFFFF
待机绿灯 --> 演奏红灯 : 播放中再按按键<br/>回到开头重播
几个顺手写进去的行为:
- 红绿 LED 表示状态:演奏中红灯亮,待机绿灯亮。播放的时候板子会像呼吸灯一样暗示你「它正在认真工作」。
- 按键随时可以按:播放中途按下,
musicIndex直接归零,从头再来一遍。想要「春日影,再一次」的循环播放,只要按住不放……不对,按一下就够了。 - 20 ms 消抖:机械按键按下的瞬间会抖动,电平在几十毫秒内反复横跳。第一次读到高电平后等 20 ms 再读一次,两次都是高电平才算真按下。嵌入式工程师的肌肉记忆。
音准的自查:量化误差到底有多大
整数除法 1000000 / frequency - 1 会截断,所以实际发出的频率是 。抽查乐谱里用到的几个音:
| 音符 | 标准频率 | ARR | 实际频率 | 误差 |
|---|---|---|---|---|
| G4 | 392 Hz | 2550 | 392.0 Hz | < 0.2 Hz |
| C5 | 523 Hz | 1910 | 523.3 Hz | +0.3 Hz |
| E6 | 1318 Hz | 757 | 1319.3 Hz | +1.3 Hz |
| A6 | 1760 Hz | 567 | 1757.5 Hz | −2.5 Hz |
换算成音分(一个半音 = 100 音分),最大偏差不到 3 音分。人耳对音高的分辨阈值大约在 5~10 音分,所以理论上这套系统的音准是过关的——蜂鸣器虽然音色像game boy,音准却是钢琴级的(好吧,夸张了一点点)。
如果哪天真要追求极限,把预分频改成让计数频率为 2 MHz 再用更长的 ARR,误差还能再减半。但目前完全没必要,工程做到「够好」就该停下来,把时间拿去扒下一首歌。
迷子でもいい、迷子でも進め
工程到这里就完整了:按键按下,红灯亮起,蜂鸣器用 1% 占空比的小小声音,认认真真地演奏完三分钟的单音春日影,然后绿灯亮起,回到安静。
当然,这个版本还留了不少「想做的事」在清单上:
- 非阻塞化。现在
HAL_Delay占着 CPU,播放期间干不了别的事。下一步打算把音符调度搬进 SysTick 或定时器中断,主循环彻底解放,顺便就能做暂停/快进。 - 强弱变化。占空比既然是音量,完全可以在长音上做渐强渐弱,让旋律有「呼吸感」。副歌那几个音值得被更认真地对待。
- 双音轨。TIM12 还有 CH2,理论上可以再接一路做简单二声部……虽然两路方波叠在一起的音色如何,需要做好心理准备。
- 更多歌。数据结构已经和具体曲子解耦了,
PlayMusic()接收任意乐谱指针。MyGO 的曲库还有那么多,下一首大概会是想让蜂鸣器试试《壱雫空》,或者偷偷练一首《碧天伴走》(但其实我并不是 MyGO!!!!! 的粉丝 o( ̄▽ ̄)ブ )。
从「哔——」到「E、D、C、D」,中间隔着的只是:一张频率表、一个除法、一个定时器,和一点想让板子唱歌的心情。
嵌入式就是这样可爱的领域——你写给它的每一个字节,它都会认认真真地还给你声音。迷路也没关系,迷路也要前进。春日影,还会再演奏的。