演奏家:用蜂鸣器演奏一首《小星星》
平台:NUCLEO-H533RE(STM32H533RET6)|语言:C++
工具链:STM32CubeMX + CMake + VSCode
上一篇点灯,GPIO 只有高、低两个状态;这一篇换成 PWM,让引脚以指定频率在高低之间切换,蜂鸣器就能发出不同音高。流程还是那条链路:建工程 → 读原理图 → 配定时器 → 写代码 → 下载试听,最后让板子把《小星星》完整弹一遍。
一、新建 CubeMX 工程
1.1 选择芯片
打开 STM32CubeMX,在 MCU/MPU Selector 中搜索 STM32H533RET6,确认右侧的封装、Flash、RAM 等信息后,点击 Start Project。

图 1:在 MCU/MPU Selector 中选中 STM32H533RET6(对应 NUCLEO-H533RE)
1.2 处理 TrustZone 提示
STM32H5 系列带 Arm TrustZone,新建工程时会询问是否启用安全隔离。本例不涉及安全分区,选择 without TrustZone activated,点击 OK。

图 2:TrustZone 选择“不启用”
1.3 保留 ICACHE / MPU 默认配置
紧接着会提示是否为 ICACHE 和 MPU 访问 OTP / RO 区域应用默认配置,直接点 Yes 即可,省去手动配置,性能也更好。

图 3:建议直接 Yes,启用默认的 ICACHE 与 MPU 配置
二、先读原理图,再动配置
配置外设之前,先确认两件事:蜂鸣器接在哪个引脚、外部晶振是多少。这两点都能在 NUCLEO-H533RE 的原理图里找到。
2.1 蜂鸣器:PA7,TIM3_CH2

图 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

图 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。

图 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 的输入源,按图设置分频与倍频。

图 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 标成复用功能。

图 8:TIM3 的 Channel2 设为 PWM Generation CH2,PSC / ARR 先给初值
按下表配置 TIM3:
参数取值
| Clock Source | Internal Clock |
| Channel2 | PWM Generation CH2 |
| Prescaler (PSC) | 0 |
| Counter Mode | Up |
| Counter Period (ARR) | 65535 |
| auto-reload preload | Disable |
| Pulse | 0 |
| CH Polarity | High |
这里的 PSC / ARR 只是上电初值,真正播放时的频率会由后面的 SetConfig() 按音符重算。Pulse = 0 保证在 Enable() 之前蜂鸣器保持安静。
[!tip] 为什么用定时器而不是软件延时 用 HAL_GPIO_TogglePin() 加 HAL_Delay() 也能“凑”出声音,但延时会被中断和其他任务拉长,音准会飘;PWM 由定时器硬件产生,频率只取决于 PSC / ARR,CPU 去干别的也不影响音高。
四、生成 CMake 工程
4.1 Project Manager 设置
切到 Project Manager,按下表填写:
配置项取值
| Project Name | buzzer |
| Project Location | 全英文路径,例如 E:\stm32cubemx\buzzer |
| Application Structure | Advanced |
| Toolchain / IDE | CMake |
| Code Generator | Copy 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 |

图 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)
| C5 | 523 | F5 | 698 |
| D5 | 587 | G5 | 784 |
| E5 | 659 | A5 | 880 |
把用到的音符定义成常量,再用一个 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 下载并启动调试。

图 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 的时域波形:
CubeMX 选型建工程,处理 TrustZone 与 ICACHE 默认配置;
从原理图确认蜂鸣器接在 PA7,并按无源蜂鸣器的方式用方波驱动;
配置 SWD、HSE 24 MHz → 250 MHz 时钟树、TIM3_CH2 PWM;
生成 CMake 工程,用 STM32PWM 封装频率与占空比,按 PSC / ARR 反算目标频率;
把音符频率和拍数写成数组,循环播放《小星星》。
HAL_Delay() 排音符简单直接,但整首曲子会一直占着 CPU。下一篇先把 LIS3DH 接上 I²C,把三轴加速度和 FIFO 读上来;等外设多起来,再考虑用定时器中断或 DMA 把“演奏”放到后台。
我要赚赏金
