为什么要用 A 板演奏春日影:让蜂鸣器唱歌的一次尝试

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。就这么简单。剩下的都是工程细节——而工程细节,正是好玩的地方。

从琴键到查找表:音符系统

十二平均律,一行公式的事

音高本质上就是频率,而西方音乐的十二平均律规定:相邻半音的频率比是 212\sqrt[12]{2}。以 A4 = 440 Hz 为基准,第 nn 个半音(MIDI 编号)的频率为:

fn=440×2(n−69)/12 Hzf_n = 440 \times 2^{(n-69)/12} \ \text{Hz}

从 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,所以每个时长单位是 500/8=62.5 ms500/8 = 62.5\ \text{ms},时长数值就是这个单位的倍数:

duration时长音乐意义
2125 ms十六分音符
4250 ms八分音符
6375 ms附点八分
8500 ms四分音符
12750 ms附点二分
8+41000 ms连音后的二分

整曲速度约合 120 BPM,比原曲稍微活泼一点点,但蜂鸣器君已经很努力了。

顺便一个只有自己知道的小彩蛋:SPEED / 8 在 C 的整数除法里是 500/8=62500/8 = 62(截断成 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;再除以 83+1=8483+1 = 84,计数频率正好是 1 MHz,每个计数 1 µs。

这个「凑整」带来一个天大的好处:ARR 和频率的关系变成了心算级别——

ARR=ftimerfnote−1=1 000 000fnote−1\text{ARR} = \frac{f_{\text{timer}}}{f_{\text{note}}} - 1 = \frac{1\,000\,000}{f_{\text{note}}} - 1

想发 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,实际频率是 106/758≈1319.310^6/758 \approx 1319.3 Hz,比标准值高 1.3 Hz,折合 1.7 音分。就算到了乐谱最高的 A6(1760 Hz),误差也就 2 个多音分,远小于人耳能分辨的 5 音分。蜂鸣器的音准,意外地很能打。

音量 → CCR。占空比上限故意压在 50%——方波占空比超过 50% 之后音量不升反降(高电平时间变长,谐振片反而来不及复位),所以 50% 就是「最大声」。代码里 volume = 2,换算下来占空比只有 1%:

duty=2×50100=1%,CCR=ARR×1%\text{duty} = \frac{2 \times 50}{100} = 1\%, \qquad \text{CCR} = \text{ARR} \times 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 会截断,所以实际发出的频率是 106/(ARR+1)10^6 / (\text{ARR}+1)。抽查乐谱里用到的几个音:

音符标准频率ARR实际频率误差
G4392 Hz2550392.0 Hz< 0.2 Hz
C5523 Hz1910523.3 Hz+0.3 Hz
E61318 Hz7571319.3 Hz+1.3 Hz
A61760 Hz5671757.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」,中间隔着的只是:一张频率表、一个除法、一个定时器,和一点想让板子唱歌的心情。

嵌入式就是这样可爱的领域——你写给它的每一个字节,它都会认认真真地还给你声音。迷路也没关系,迷路也要前进。春日影,还会再演奏的。

© 2026 ChinoKoyuki