这些小活动你都参加了吗?快来围观一下吧!>>
电子产品世界 » 论坛首页 » DIY与开源设计 » 电子DIY » 演奏家:用蜂鸣器演奏一首《小星星》

共1条 1/1 1 跳转至页

演奏家:用蜂鸣器演奏一首《小星星》

菜鸟
2026-10-29 22:50:06     打赏

演奏家:用蜂鸣器演奏一首《小星星》

平台:NUCLEO-H533RE(STM32H533RET6)|语言:C++
工具链:STM32CubeMX + CMake + VSCode

上一篇点灯,GPIO 只有高、低两个状态;这一篇换成 PWM,让引脚以指定频率在高低之间切换,蜂鸣器就能发出不同音高。流程还是那条链路:建工程 → 读原理图 → 配定时器 → 写代码 → 下载试听,最后让板子把《小星星》完整弹一遍。

一、新建 CubeMX 工程

1.1 选择芯片

打开 STM32CubeMX,在 MCU/MPU Selector 中搜索 STM32H533RET6,确认右侧的封装、Flash、RAM 等信息后,点击 Start Project。

image.png

图 1:在 MCU/MPU Selector 中选中 STM32H533RET6(对应 NUCLEO-H533RE)

1.2 处理 TrustZone 提示

STM32H5 系列带 Arm TrustZone,新建工程时会询问是否启用安全隔离。本例不涉及安全分区,选择 without TrustZone activated,点击 OK。

image.png

图 2:TrustZone 选择“不启用”

1.3 保留 ICACHE / MPU 默认配置

紧接着会提示是否为 ICACHE 和 MPU 访问 OTP / RO 区域应用默认配置,直接点 Yes 即可,省去手动配置,性能也更好。

image.png

图 3:建议直接 Yes,启用默认的 ICACHE 与 MPU 配置

二、先读原理图,再动配置

配置外设之前,先确认两件事:蜂鸣器接在哪个引脚、外部晶振是多少。这两点都能在 NUCLEO-H533RE 的原理图里找到。

2.1 蜂鸣器:PA7,TIM3_CH2

image.png

图 4:NUCLEO 的 Arduino 排针,D11(PA7)带 PWM 复用功能

结合原理图和模块资料,先理清三件事:

  • 蜂鸣器的控制信号接在 PA7(Arduino 排针 D11);

  • PA7 的复用功能里包含 TIM3_CH2,正好可以用定时器硬件输出 PWM;

  • 本文按无源蜂鸣器来写:内部没有振荡电路,给恒定直流只会“嗒”一声,必须由外部方波驱动。

所以蜂鸣器不能像 LED 那样用 HAL_GPIO_WritePin() 写个高电平就完事,而要让 PA7 输出一连串方波:方波的频率决定音高,占空比决定响度。

接线就三根:VCC → 3.3 V、GND → GND、SIG → PA7。如果实际接线不同,只要换成对应的定时器通道,后面的频率计算和旋律代码都不用改。

[!note] 有源还是无源 有源蜂鸣器内部自带振荡源,通电就响,音高固定,PWM 只能控制响/停;无源蜂鸣器只认方波,方波频率就是音高。手上的模块如果不确定,给它 3.3 V 直流试一下:一直响的是有源,只有“嗒”一声的是无源。

2.2 外部晶振:24 MHz

image.png

图 5:HSE 使用 24 MHz 晶振(X1),LSE 为 32.768 kHz(本例不使用)

HSE 接的是 24 MHz 晶振(X1,OSC_IN / OSC_OUT),后面的时钟树就以此为输入。

三、CubeMX 外设配置

3.1 调试接口:Serial Wire

在左侧 Trace and Debug → DEBUG(旧版本在 System Core → SYS)中,把 Debug 设为 Serial Wire。

image.png

图 6:Debug 选择 Serial Wire,保留 PA13 / PA14 的 SWD 功能

[!tip] 为什么第一步就配调试口 把 PA13 / PA14 保留给 SWD,避免它们被当作普通 GPIO 占用,导致后面下载、调试都连不上。

3.2 时钟树:24 MHz → 250 MHz

在 System Core → RCC 中,把 High Speed Clock (HSE) 设为 Crystal/Ceramic Resonator,Low Speed Clock (LSE) 保持 Disable;再切到 Clock Configuration,将 HSE 选为 PLL1 的输入源,按图设置分频与倍频。

image.png

图 7:HSE 24 MHz 作为 PLL 源,SYSCLK 250 MHz,AHB 不分频

STM32H533 的最高主频为 250 MHz,这里直接拉满:SYSCLK = HCLK = 250 MHz,AHB Prescaler = /1。若右上角出现 Resolve Clock Issues 红标,按提示逐项检查未使能的时钟源即可。

TIM3 挂在 APB1 上,它的计数时钟来自 PCLK1(APB1 分频时再 ×2)。这一点在第五节的 SetConfig() 里会用到。

3.3 TIM3:PA7 输出 PWM

回到 Pinout & Configuration,在芯片图上点击 PA7,选择 TIM3_CH2;也可以在左侧 Timers → TIM3 中把 Channel2 设为 PWM Generation CH2,CubeMX 会自动把 PA7 标成复用功能。

image.png

图 8:TIM3 的 Channel2 设为 PWM Generation CH2,PSC / ARR 先给初值

按下表配置 TIM3:

参数取值

Clock SourceInternal Clock
Channel2PWM Generation CH2
Prescaler (PSC)0
Counter ModeUp
Counter Period (ARR)65535
auto-reload preloadDisable
Pulse0
CH PolarityHigh

这里的 PSC / ARR 只是上电初值,真正播放时的频率会由后面的 SetConfig() 按音符重算。Pulse = 0 保证在 Enable() 之前蜂鸣器保持安静。

[!tip] 为什么用定时器而不是软件延时 用 HAL_GPIO_TogglePin() 加 HAL_Delay() 也能“凑”出声音,但延时会被中断和其他任务拉长,音准会飘;PWM 由定时器硬件产生,频率只取决于 PSC / ARR,CPU 去干别的也不影响音高。

四、生成 CMake 工程

4.1 Project Manager 设置

切到 Project Manager,按下表填写:

配置项取值

Project Namebuzzer
Project Location全英文路径,例如 E:\stm32cubemx\buzzer
Application StructureAdvanced
Toolchain / IDECMake
Code GeneratorCopy only the necessary library files
Generated files勾选 Generate peripheral initialization as a pair of '.c/.h' files per peripheral
勾选 Keep User Code when re-generating

image.png

图 9:工程名、路径、CMake 工具链与代码生成选项

[!warning] 路径不要带中文 CubeMX 生成的工程路径请保持全英文,中文路径在 CMake / GCC 环节很容易出现难以排查的报错。

4.2 生成代码

点击右上角的 GENERATE CODE,等待生成完成后选择 Open Project,即可用 VSCode 打开工程。

五、用 C++ 封装 PWM

5.1 为什么再包一层

点灯只需要 Write(true) / Write(false);蜂鸣器要的是频率和占空比两个维度。HAL 的 HAL_TIM_PWM_Start() 只负责启动通道,想换音高还得自己算 PSC / ARR。把这些细节收进一个 STM32PWM 类,上层就只需要关心“多少赫兹、多响”:

class STM32PWM
{
public:
    struct Configuration
    {
        uint32_t frequency;  // Hz
    };

    // complementary 表示互补输出,蜂鸣器用不到,填 false
    STM32PWM(TIM_HandleTypeDef* htim, uint32_t channel, bool complementary = false)
        : htim_(htim), channel_(channel), complementary_(complementary) {}

    ErrorCode SetConfig(Configuration config);
    ErrorCode SetDutyCycle(float value);
    ErrorCode Enable();
    ErrorCode Disable();

private:
    TIM_HandleTypeDef* htim_;
    uint32_t channel_;
    bool complementary_;
};

构造函数只保存句柄和通道号,不碰硬件,所以可以放心定义成全局对象。

5.2 频率是怎么算出来的

PWM 频率、占空比和定时器寄存器之间的关系是:

f_pwm = f_tim / ((PSC + 1) × (ARR + 1))
duty  = CCR / (ARR + 1)

PSC 和 ARR 都是 16 位,ARR 最大 65535。给定目标频率后,先取定时器时钟,再从 PSC = 1 开始往上试,直到 ARR 落进 16 位范围——这样 ARR 尽可能大,频率分辨率也最高。

ErrorCode STM32PWM::SetConfig(Configuration config)
{
    const uint32_t target_freq = config.frequency;
    if (target_freq == 0)
    {
        return ErrorCode::ARG_ERR;
    }

    // TIM3 挂在 APB1 上:APB1 不分频时等于 HCLK,分频后定时器时钟是 PCLK1 的两倍
    uint32_t clock_freq = HAL_RCC_GetPCLK1Freq();
    if (clock_freq != HAL_RCC_GetHCLKFreq())
    {
        clock_freq *= 2;
    }

    bool found = false;
    uint32_t prescaler = 1;
    uint32_t period = 0;

    // 从最小的 PSC 往上试,让 ARR 尽量大,频率分辨率才高
    for (; prescaler <= 0xFFFF; ++prescaler)
    {
        const uint32_t temp_period = clock_freq / (prescaler * target_freq);
        // period 最大取 65536,减 1 后正好填满 16 位 ARR
        if (temp_period <= 0x10000)
        {
            period = temp_period;
            found = true;
            break;
        }
    }

    if (!found || period == 0)
    {
        return ErrorCode::INIT_ERR;
    }

    htim_->Init.Prescaler = prescaler - 1;
    htim_->Init.Period = period - 1;

    // 只改结构体不会写寄存器,重新 Init 才会把 PSC / ARR 装进去
    if (HAL_TIM_PWM_Init(htim_) != HAL_OK)
    {
        return ErrorCode::INIT_ERR;
    }
    return ErrorCode::OK;
}

[!warning] 别忘了定时器时钟clock_freq 必须按定时器所在总线取。TIM3 挂在 APB1 上:先用 HAL_RCC_GetPCLK1Freq() 取 PCLK1,APB1 分频不为 1 时再乘 2。如果这里漏掉、clock_freq 一直是 0,函数会直接返回 INIT_ERR,蜂鸣器一声不响。

占空比这边更简单,把 0.0~1.0 换算成比较值写进 CCR 即可:

ErrorCode STM32PWM::SetDutyCycle(float value)
{
    if (value < 0.0f)
    {
        value = 0.0f;
    }
    else if (value > 1.0f)
    {
        value = 1.0f;
    }

    const uint32_t pulse =
        static_cast<uint32_t>(static_cast<float>(htim_->Init.Period + 1) * value);
    __HAL_TIM_SET_COMPARE(htim_, channel_, pulse);
    return ErrorCode::OK;
}

Enable() / Disable() 只是 HAL_TIM_PWM_Start() / HAL_TIM_PWM_Stop() 的薄包装(互补输出时换成 HAL_TIMEx_PWMN_Start() / HAL_TIMEx_PWMN_Stop()),不再展开。

[!note] 换音之后要重设占空比SetConfig() 只改 PSC / ARR,不碰 CCR。如果旧占空比的比较值比新周期还大,输出会短暂地一直为高,所以每次换音后都要再调用一次 SetDutyCycle()。

另外,方波在 50% 占空比时基频分量最大,听起来最响、最干净;偏离 50% 会引入更多谐波,音色变尖,基频反而变弱。

5.3 把音符写成数组

音乐里的每个音高都对应一个频率,按十二平均律:

音符频率 (Hz)音符频率 (Hz)

C5523F5698
D5587G5784
E5659A5880

把用到的音符定义成常量,再用一个 Tone 结构体把“频率 + 拍数”绑在一起:

static constexpr uint16_t NOTE_C5 = 523;
static constexpr uint16_t NOTE_D5 = 587;
static constexpr uint16_t NOTE_E5 = 659;
static constexpr uint16_t NOTE_F5 = 698;
static constexpr uint16_t NOTE_G5 = 784;
static constexpr uint16_t NOTE_A5 = 880;

struct Tone
{
    uint16_t frequency;  // Hz,0 表示休止
    uint16_t beats;      // 拍数
};

《小星星》的简谱是 C C G G A A G | F F E E D D C | ...,照着写成数组:

static const Tone kLittleStar[] = {
    {NOTE_C5, 1}, {NOTE_C5, 1}, {NOTE_G5, 1}, {NOTE_G5, 1},
    {NOTE_A5, 1}, {NOTE_A5, 1}, {NOTE_G5, 2},

    {NOTE_F5, 1}, {NOTE_F5, 1}, {NOTE_E5, 1}, {NOTE_E5, 1},
    {NOTE_D5, 1}, {NOTE_D5, 1}, {NOTE_C5, 2},

    {NOTE_G5, 1}, {NOTE_G5, 1}, {NOTE_F5, 1}, {NOTE_F5, 1},
    {NOTE_E5, 1}, {NOTE_E5, 1}, {NOTE_D5, 2},

    {NOTE_G5, 1}, {NOTE_G5, 1}, {NOTE_F5, 1}, {NOTE_F5, 1},
    {NOTE_E5, 1}, {NOTE_E5, 1}, {NOTE_D5, 2},

    {NOTE_C5, 1}, {NOTE_C5, 1}, {NOTE_G5, 1}, {NOTE_G5, 1},
    {NOTE_A5, 1}, {NOTE_A5, 1}, {NOTE_G5, 2},

    {NOTE_F5, 1}, {NOTE_F5, 1}, {NOTE_E5, 1}, {NOTE_E5, 1},
    {NOTE_D5, 1}, {NOTE_D5, 1}, {NOTE_C5, 2},

    {0, 1},  // 收尾留一拍静音
};

数组里的 1 / 2 是拍数,具体多长由 kBeatMs 决定;想弹快一点就把它改小。

5.4 先让蜂鸣器响一声

写旋律之前,先用一个固定单音验证硬件链路:

/* USER CODE BEGIN PV */
static STM32PWM pwm_tim3_ch2(&htim3, TIM_CHANNEL_2, false);
/* USER CODE END PV */

/* USER CODE BEGIN 2 */
pwm_tim3_ch2.SetConfig({.frequency = 2300});
pwm_tim3_ch2.SetDutyCycle(0.8f);
pwm_tim3_ch2.Enable();
HAL_Delay(1000);
pwm_tim3_ch2.SetDutyCycle(0.0f);
/* USER CODE END 2 */

对象定义放在 main() 外面(USER CODE BEGIN PV 区),试音代码放在 USER CODE BEGIN 2 区。烧进去应该能听到 1 秒 2.3 kHz 的单音——这一步只验证 PA7、TIM3_CH2 和蜂鸣器有没有接对,不参与演奏。

试音用 0.8 是让方波更“满”、更容易确认模块有没有工作;正式演奏统一用 0.5,基频更集中,音色也更干净。

[!tip] 为什么是 2.3 kHz 常见无源蜂鸣器的谐振点就在 2~4 kHz 附近,这个频率最容易听清。正式演奏时音符会落在这个范围以下,音量小一点是正常的。

[!tip] htim3 从哪来htim3 是 CubeMX 在 tim.c 里定义的句柄,声明在 tim.h。用 C++ 写应用入口时记得包含 tim.h,否则编译会报 htim3 未声明。

5.5 演奏《小星星》

确认能响之后,把试音代码换成播放循环。先写一个“唱一个音”的函数:

static constexpr uint16_t kBeatMs = 400;  // 一拍 400 ms,约 150 BPM

static void PlayTone(uint16_t frequency, uint16_t duration_ms)
{
    if (frequency == 0)
    {
        pwm_tim3_ch2.SetDutyCycle(0.0f);
        HAL_Delay(duration_ms);
        return;
    }

    if (pwm_tim3_ch2.SetConfig({.frequency = frequency}) != ErrorCode::OK)
    {
        HAL_Delay(duration_ms);
        return;
    }

    pwm_tim3_ch2.SetDutyCycle(0.5f);
    HAL_Delay(duration_ms);

    // 音符之间留 20 ms 静音,连续相同的音才不会粘成一个长音
    pwm_tim3_ch2.SetDutyCycle(0.0f);
    HAL_Delay(20);
}

然后在 main() 里循环播放:

/* USER CODE BEGIN 2 */
pwm_tim3_ch2.Enable();  // CCR 还是 0,上电不会响
/* USER CODE END 2 */

while (true)
{
    for (const Tone& tone : kLittleStar)
    {
        PlayTone(tone.frequency, tone.beats * kBeatMs);
    }

    pwm_tim3_ch2.SetDutyCycle(0.0f);
    HAL_Delay(1000);  // 一遍结束,停一秒再从头开始
}

Enable() 只调用一次,之后换音只改频率和占空比;不要每个音符都 Disable() / Enable(),否则通道反复启停,容易带出“咔哒”声。

六、编译、下载与验证

在 VSCode 中装好 CMake Tools 与 STM32 扩展,打开工程后先 Configure、再 Build。随后打开 运行和调试,选择 STM32Cube: STM32 Launch STLink GDB Server(NUCLEO 板载 ST-Link),按 F5 下载并启动调试。

image.png

图 10:在 VSCode 的运行和调试中选择 ST-Link GDB Server

下载成功后,蜂鸣器会从头播放《小星星》,一遍结束停 1 秒,再循环。听到熟悉的调子,说明 PWM 频率、定时器时钟和音符表都对上了。

如果声音不对,按下面顺序排查:

现象优先检查

完全没声音蜂鸣器是有源还是无源;PA7 是否真的复用为 TIM3_CH2;Enable() 有没有调用
只有上电“嗒”一声PWM 没输出;检查 SetConfig() 的返回值是不是 ErrorCode::OK
音调明显不对frequency 的单位是不是 Hz;clock_freq 有没有按 APB1 取对
声音很轻、发闷占空比是否过低;音符是否远离蜂鸣器谐振点(2~4 kHz)
节奏忽快忽慢HAL_Delay() 被中断打断;后续可以改成定时器更新中断来推进音符

七、小结

这次从“会闪”走到“会唱”,关键是把 GPIO 的开关量换成了 PWM 的时域波形:

  1. CubeMX 选型建工程,处理 TrustZone 与 ICACHE 默认配置;

  2. 从原理图确认蜂鸣器接在 PA7,并按无源蜂鸣器的方式用方波驱动;

  3. 配置 SWD、HSE 24 MHz → 250 MHz 时钟树、TIM3_CH2 PWM;

  4. 生成 CMake 工程,用 STM32PWM 封装频率与占空比,按 PSC / ARR 反算目标频率;

  5. 把音符频率和拍数写成数组,循环播放《小星星》。

HAL_Delay() 排音符简单直接,但整首曲子会一直占着 CPU。下一篇先把 LIS3DH 接上 I²C,把三轴加速度和 FIFO 读上来;等外设多起来,再考虑用定时器中断或 DMA 把“演奏”放到后台。





关键词: 2026年第     基于     边缘     电机     状态     检测     ---    

共1条 1/1 1 跳转至页

回复

匿名不能发帖!请先 [ 登陆 注册 ]