基于STM32的电子音乐盒:定时器PWM驱动蜂鸣器与矩阵键盘设计

发布时间:2026/9/16 2:19:06
基于STM32的电子音乐盒:定时器PWM驱动蜂鸣器与矩阵键盘设计 简介基于STM32的电子音乐盒项目资源面向嵌入式初学者、电子爱好者以及相关课程设计人员以矩阵键盘选曲、两首预置歌曲播放为主线系统展示STM32的GPIO、定时器、DAC等外设的配置与应用。压缩包共255个文件大小6.34MB主要包含C/H源码、Keil MDK工程文件、Proteus仿真工程、原理图与PCB库、编译生成的hex/axf固件及PDF/Word说明文档代码分层清晰关键模块均有详细注释。已有1278人学习下载适合作为STM32入门实践或课设参考。内容涵盖矩阵键盘行列扫描原理、音乐数字编码与解码播放、Flash存储、DAC/定时器控制输出、调试仿真等关键环节借助Proteus可先完成硬件逻辑验证再结合原理图搭建实物帮助读者打通从程序编写到硬件实现的完整链路理解嵌入式音乐播放系统的设计方法。1. 电子音乐盒不是点灯一手矩阵键盘一手定时器做电子音乐盒最容易踩的第一个坑不是不会写代码而是把简单问题想复杂了。看到“音乐”两个字就去找 WAV 解码、I2S 接口或者音频 Codec其实两首歌用简谱存下来连 1KB 都用不掉真正决定音高的是一个定时器持续输出对应频率的方波音长由另一个节拍定时器来卡。花半天理解这条链路比直接抄一份完整工程更有价值。这个 STM32 项目把矩阵键盘、定时器 PWM 和蜂鸣器串成了一个闭环适合刚学完 GPIO 想往定时器和中断方向走的人对已经工作几年的开发者也有得看——工程里那堆 .bak、.axf 和 C51 时代的启动文件暴露出来的工程管理习惯才是实物调试时真正让人头大的地方。2. 定时器PWM与音符频率表的换算音频方案选型2.1 有源蜂鸣器不行I2S又过头为什么最后选定时器PWM网上搜 STM32 音乐盒能搜出三四条技术路线有源蜂鸣器直接电平驱动、无源蜂鸣器加定时器 PWM、DAC 播放采样音频、I2S 加音频 Codec。有源蜂鸣器内部自带振荡源通电就响但它只认“有没有电压”给高电平只会发出固定频率的“哔——”调不了音高直接出局。I2S 加 Codec 音质最好但引脚多、电路复杂对于两首简谱旋律的设备芯片成本和 PCB 面积都浪费。DAC 方案在 STM32F103 上只有一路真正的 DAC播放采样音频还要占用 DMA对初学项目来说外围电路也不简单。这个项目的核心输出器件是无源蜂鸣器也就是内部没有振荡器的电磁式蜂鸣器。它要求外部给一个方波方波的频率决定了音高方波的持续时间决定了音长。只要用 STM32 的定时器产生指定频率、50% 占空比的 PWM 波形再经三极管放大驱动蜂鸣器就能得到相对干净的单音旋律。这条方案胜在三个点只占用一个定时器通道和一根引脚、代码量小、频率精度完全可控。从硬件成本上看无源蜂鸣器加一枚普通三极管加两个电阻总成本不超过两块钱。方案硬件成本引脚占用能否区分音高代码复杂度建议有源蜂鸣器 GPIO最低1否最低只做报警音无源蜂鸣器 定时器 PWM低1是中等音乐盒首选DAC 功放中1~2是较高播放采样音频I2S Audio Codec高多是高需要音质的场合有些资料在描述这类项目时会提到 HAL 库或 LL 库、I2S/SPI 接口、浮点运算能力实际拆开工程看这些大多没用到。播放简谱只需要整数运算Cortex-M3 的主频跑这点负载绰绰有余。如果你正准备复刻建议直接以“定时器 PWM 无源蜂鸣器 4x4 矩阵键盘”为框架把其它方案的关键词从搜索框里划掉能省掉至少半天弯路。2.2 简谱怎么变成定时器的ARR以72MHz为例STM32 的定时器 PWM 输出频率由两部分决定预分频器 PSC 和自动重装载值 ARR。时钟源经过预分频后得到计数频率定时器从 0 计数到 ARR 再回 0触发一次电平翻转PWM 模式下是让输出引脚按占空比翻转所以PWM 频率 定时器时钟 / (PSC 1) / (ARR 1)常见做法是先把 PSC 固定让计数频率变成 1MHz也就是每 1 微秒计数一次那么 ARR 就直接等于 1000000 除以音符频率。以 STM32F103 的 TIM2 为例它挂在 APB1 总线上当系统主频为 72MHz 时定时器时钟会被倍频到 72MHz因此 PSC 设为 71 即可得到 1MHz 的计数频率。这样做的好处是乐谱里任何一个频率到达 ARR 的计算都能在脑内完成不需要反复查手册。音符频率 (Hz)ARRPSC71, 1MHz 计数C4261.633822D4293.663405E4329.633033F4349.232863G4392.002551A4440.002272B4493.882024C5523.251911注意 ARR 寄存器是 16 位的最大值 65535。如果歌曲里出现很低的音比如 C2 只有 65.4Hz在 1MHz 计数下 ARR 需要 15282仍然在范围内但再低几个八度就会溢出。这时要么调大 PSC要么改用 32 位定时器。简谱旋律一般在中音区和高音区活动不会碰到这个问题但设计代码时最好加一个边界检查防止传入非法频率时把 ARR 写成 0。占空比方面方波对无源蜂鸣器的驱动最简单有效50% 占空比即可。PWM 模式下设置比较值 CCR 为 ARR 的一半输出引脚就能得到接近理想的方波。后续如果想改变音色可以动态修改 CCR 让占空比随时间变化这部分会在第 5 章展开。2.3 矩阵键盘的扫描编码方式4x4 矩阵键盘用 8 个 GPIO 就能扩展出 16 个按键原理是行列交叉4 根行线接输入模式并启用内部上拉4 根列线接推挽输出。扫描时逐列输出低电平然后读取行线的电平如果某一行读到低电平说明该行和当前列交叉处的按键被按下。之所以用内部上拉而不是外部电阻是因为 STM32 的 GPIO 内部上拉在弱上拉场景下足够可靠省掉了 4 个贴片电阻。扫描过程中有两个容易忽略的细节。第一个是列线不能同时输出低电平否则按键按下会直接把两个列线短路必须一次只拉低一根列线。第二个是按键按下瞬间存在机械抖动扫描到低电平后不能立刻认为是有效按键要延时 10ms 再确认一次同时还要等按键释放否则一次按键会触发多次。矩阵键盘的键值映射不是硬件固定的完全由软件决定。常见做法是把行号、列号映射到一个二维数组然后再把每个元素映射成曲目编号或功能码。这个项目只有两首歌实际上只用到了 16 个键位里的一小部分剩下的可以预留给暂停、上一首、下一首。下一章的代码会给出一个完整的扫描实现可以直接抄到 Keil5 工程里。3. 工程移植与矩阵键盘扫描代码在Keil里把歌放出来3.1 下载包里那些备份和启动文件是什么下载解压后你会看到STARTUP.A51、LED.uvguix.Administrator、pMusic.uvguix.Administrator、LED.axf、pMusic.axf、test_uvopt.bak、test_uvproj.bak、dzq_Uv2.Bak、dzq_Opt.Bak、LED_sct.Bak。这里没有README也没有原理图 PDF少数几个文件还放在多个目录里第一眼会觉得乱。拆开看它们其实分三类。第一类是编译中间产物和用户界面布局文件LED.axf、pMusic.axf是 ARM 编译链生成的带调试信息的可执行镜像删掉完全不影响工程LED.uvguix.Administrator和pMusic.uvguix.Administrator记录的是 Keil5 窗口布局、断点位置这些个人配置换一台机器打开会自动重新加载所以也不属于源码。第二类是备份文件test_uvopt.bak、test_uvproj.bak、LED_sct.Bak是 Keil 在项目另存时自动生成的备份把后缀改回.uvopt和.uvproj就能恢复旧工程适合拿来对比代码改动。第三类最值得注意dzq_Uv2.Bak和STARTUP.A51是 Keil C51 时代的工程备份和 8051 启动汇编。看到STARTUP.A51千万别直接把它加进 STM32 工程。A51 是 8051 汇编器的启动文件STM32F103 的启动文件叫startup_stm32f10x_hd.s两者完全不同。这个文件的存在说明作者大概率先在 51 单片机上调通过音乐盒逻辑然后把代码移植到了 STM32 平台。移植本身没问题但在下载包里丢掉旧文件会误导后来的人。你拿到手后建议把带Uv2和A51后缀的文件移到单独的legacy文件夹只保留和 STM32 相关的.uvprojx以及.c/.h文件工程结构会清爽很多。如果打开工程时 Keil5 提示找不到芯片和 “stm32 芯片包安装” 是同一个问题路径确认当前电脑的实际芯片型号是 STM32F103C8 还是其它型号然后在 Pack Installer 里安装对应的 Device Family Pack。如果下载包里的.uvprojx是老版 Keil 生成的双击打开后建议先执行一次Project - Clean Targets再重新 Build排除增量编译带来的假报错。3.2 HAL库定时器PWM初始化把PA0变成音高发生器无论原工程用的是标准外设库还是 HAL 库复刻时建议直接用 HAL 库可读性好、网上例程多。下面的初始化函数把 TIM2 通道 1默认映射到 PA0配置成 PWM 输出输出频率由参数arr决定占空比固定 50%。void TIM2_PWM_Init(uint16_t arr, uint16_t psc) { GPIO_InitTypeDef gpio_init {0}; TIM_OC_InitTypeDef oc_init {0}; __HAL_RCC_TIM2_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); /* PA0 复用为 TIM2_CH1 */ gpio_init.Pin GPIO_PIN_0; gpio_init.Mode GPIO_MODE_AF_PP; gpio_init.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, gpio_init); htim2.Instance TIM2; htim2.Init.Prescaler psc - 1; /* 预分频PSC71 时计数频率 1MHz */ htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.Period arr - 1; /* 自动重装载值决定 PWM 频率 */ htim2.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; HAL_TIM_PWM_Init(htim2); oc_init.OCMode TIM_OCMODE_PWM1; oc_init.Pulse (arr - 1) / 2; /* 比较值 一半周期即 50% 占空比 */ oc_init.OCPolarity TIM_OCPolarity_High; HAL_TIM_PWM_ConfigChannel(htim2, oc_init, TIM_CHANNEL_1); HAL_TIM_PWM_Start(htim2, TIM_CHANNEL_1); }这里psc和arr的传入值是实际数值函数内部减 1 是因为定时器寄存器从 0 开始计数。如果主频不是 72MHz修改Prescaler时必须重新计算不能直接套用 71。OCMode选PWM1而不是PWM2两者输出极性相反接蜂鸣器时占空比的直观效果也不一样PWM1 模式下 CCR 越大高电平时间越长比较符合直观。配置完不要忘记HAL_TIM_PWM_Start只调用 Init 不启动输出引脚不会有波形这是新手最容易漏掉的一步。3.3 4x4矩阵键盘扫描与消抖矩阵键盘的 GPIO 初始化和扫描可以封装成两个函数。行线PC0~PC3配成输入模式并启用内部上拉列线PC4~PC7配成推挽输出。扫描时先把所有列置高然后逐列拉低读行线电平。void KEY_GPIO_Init(void) { GPIO_InitTypeDef gpio_init {0}; __HAL_RCC_GPIOC_CLK_ENABLE(); /* PC0~PC3行线输入上拉 */ gpio_init.Pin GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3; gpio_init.Mode GPIO_MODE_INPUT; gpio_init.Pull GPIO_PULLUP; HAL_GPIO_Init(GPIOC, gpio_init); /* PC4~PC7列线推挽输出 */ gpio_init.Pin GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7; gpio_init.Mode GPIO_MODE_OUTPUT_PP; gpio_init.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOC, gpio_init); /* 所有列线初始化为高电平 */ HAL_GPIO_WritePin(GPIOC, GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7, GPIO_PIN_SET); } uint8_t KEY_Scan(void) { const uint8_t key_map[4][4] { {1, 2, 3, A}, {4, 5, 6, B}, {7, 8, 9, C}, {*, 0, #, D} }; uint16_t col_pins[4] {GPIO_PIN_4, GPIO_PIN_5, GPIO_PIN_6, GPIO_PIN_7}; uint16_t row_pins[4] {GPIO_PIN_0, GPIO_PIN_1, GPIO_PIN_2, GPIO_PIN_3}; for (uint8_t c 0; c 4; c) { /* 只拉低当前列 */ HAL_GPIO_WritePin(GPIOC, col_pins[c], GPIO_PIN_RESET); HAL_Delay(1); /* 等电平稳定 */ for (uint8_t r 0; r 4; r) { if (HAL_GPIO_ReadPin(GPIOC, row_pins[r]) GPIO_PIN_RESET) { HAL_Delay(10); /* 消抖确认 */ while (HAL_GPIO_ReadPin(GPIOC, row_pins[r]) GPIO_PIN_RESET); /* 等待按键释放避免重复触发 */ return key_map[r][c]; } } /* 恢复该列为高再切换下一列 */ HAL_GPIO_WritePin(GPIOC, col_pins[c], GPIO_PIN_SET); } return 0; }这段代码的关键在“逐列拉低”这个动作。如果不恢复当前列的高电平就切换下一列两个列线同时为低按键按下时行线的电平状态就会被错误地映射到多个位置。消抖的HAL_Delay(10)用的是阻塞式延时音乐播放时会暂停 10ms听感上几乎感觉不到但如果你的旋律正在用中断方式计时扫描期间要额外留意计数器的补偿。注意while等待按键释放的写法适合音乐盒这类单线程交互逻辑。如果后面要扩展成长按、连发功能不能这样等得改成记录按下时刻的状态机。初学者先用这个版本跑通再考虑优化。3.4 两首歌曲的编码表与播放主循环一首完整的旋律由音符和节拍组成。常见做法是用结构体数组每个元素保存频率和节拍数。频率为 0 表示休止符正好利用数组里freq 0的条件触发静音延时。节拍以四分音符为基本单位beat 2表示这个音持续两个四分音符也就是二分音符。typedef struct { uint16_t freq; /* 音符频率单位 Hz0 表示休止符 */ uint8_t beat; /* 节拍数1 个四分音符为 1 */ } Note; /* 《小星星》片段C 大调 */ const Note song1[] { {262, 1}, {262, 1}, {392, 1}, {392, 1}, {440, 1}, {440, 1}, {392, 2}, {349, 1}, {349, 1}, {330, 1}, {330, 1}, {294, 1}, {294, 1}, {262, 2}, {0, 0} /* 数组结束标记 */ };播放函数每处理一个音符就调用一次第 2 章的频率换算逻辑把freq转换成 ARR 写入定时器然后用HAL_Delay维持音长。beat乘以一个基本时长常量base_duration就可以通过改变base_duration的数值来统一调节整首曲子的速度。void Music_Play(const Note *song, uint16_t base_duration) { uint32_t arr; const Note *n song; while (n-freq ! 0 || n-beat ! 0) { if (n-freq 0) { /* 休止符不输出 PWM只延时 */ TIM2_PWM_Stop(); HAL_Delay(base_duration * n-beat); } else { arr 1000000UL / n-freq; /* PSC71 时计数频率 1MHz */ TIM2_SetFreq(arr); HAL_Delay(base_duration * n-beat); } n; } TIM2_PWM_Stop(); }播放主循环就是“扫描键盘 → 选歌 → 播放 → 再扫描”的无限循环。由于音乐盒只有两首歌你甚至不需要维护一个复杂的按键状态表只需要判断按下的是按键 1 还是按键 2。想加第三首歌只需要再写一个const Note song3[]数组然后把它的首地址加到选择逻辑里存储上完全不构成压力——一个音符的结构体只有 3 字节两首歌加起来通常不超过 150 个音符。4. Proteus仿真调试与硬件排错从虚拟到实物的关卡4.1 Proteus里的器件清单和接线如果原工程附带 Proteus 文件器件连接基本是现成的如果没有按下面的清单自己搭也很快。仿真用 STM32F103C8 模型程序通过 Keil 生成 HEX 文件后加载到 Proteus 的芯片属性里。注意 Proteus 里单片机的 XTAL 频率必须和代码里的时钟配置一致否则旋律会整体偏高或偏低。器件型号/参数数量主要作用主控STM32F103C81定时器 PWM 与键盘扫描无源蜂鸣器Electromagnetic Buzzer1把方波转成声波NPN 三极管2N3904 或 S80501放大 PWM 驱动电流基极限流电阻1kΩ1保护单片机引脚与三极管蜂鸣器限流电阻100Ω1限制蜂鸣器工作电流按键开关轻触按键16组成 4x4 矩阵键盘上拉电阻可选10kΩ4如果不用内部上拉外部补上蜂鸣器的接法有讲究。GPIOC 的四根行线直接连按键矩阵PA0 输出 PWM 到三极管基极三极管发射极接地集电极接无源蜂鸣器负极无源蜂鸣器正极接 3.3V 电源。无源蜂鸣器不是纯阻性负载直接接在单片机引脚上会把输出波形拉变形甚至可能导致复位加一级三极管驱动是必要的。Proteus 里如果找不到无源蜂鸣器带三极管模型可以用“BUZZER”配合“NPN”和三极管器件自己搭效果更接近实物。4.2 仿真不响、乱音、按键失灵先查这几处第一处查时钟。仿真时最常见的问题不是代码逻辑错误而是 Keil 工程里选的是外部晶振HSE但 Proteus 里没接晶振或者 XTAL 频率设置和代码不一致。STM32F103 默认使用内部 RC 振荡器启动如果初始化代码把时钟切换到了 HSE而仿真电路里没有外部晶振那么SystemClock_Config会卡在超时等待上整个程序跑不起来。第二处查波形。蜂鸣器不响时先用 Proteus 的虚拟示波器夹在 PA0 引脚上看有没有方波输出。如果没有波形检查HAL_TIM_PWM_Start是否调用检查 PWM 是否初始化在正确的定时器通道上。PA0 是 TIM2_CH1 的默认复用脚但很多例程会把 PWM 放在其他引脚导致型号不匹配这在 Proteus 里尤其容易混淆。第三处查按键扫描。仿真按键没有机械抖动消抖延时等于浪费了 10ms但不影响功能。按键失灵一般是行线和列线的 GPIO 模式配反了行线不是输入上拉而是配成了推挽输出那么HAL_GPIO_ReadPin读到的永远是你自己写出去的电平自然按不出来。把行线改成GPIO_MODE_INPUT加GPIO_PULLUP即可。如果按键按下时发现相邻键也被触发多半是列线在切换时没有先回到高电平。注意Proteus 仿真通过不了的问题很多是器件模型差异导致的不要一上来就怀疑算法。比如某些蜂鸣器模型需要正极接高电平才发声而实物蜂鸣器的正负极标识恰好相反这时声音输出的表现会截然不同。4.3 硬件和仿真最大的三个差异第一个差异是抖动。仿真按键按下瞬间电平干净利落实物轻触按键在合闸瞬间会产生 5~20ms 的机械抖动如果不消抖一次按键可能被认为按了 3 次歌曲会在切换后立即被中断。建议在实物环境把消抖时间从前面的 10ms 适当加到 20ms菜单跳转类的逻辑可以加一个“按键释放才切换”的保护。第二个差异是驱动能力。单片机的 GPIO 在 3.3V 下输出电流有限无源蜂鸣器的工作电流通常有几十毫安直接驱动会在引脚上产生明显的电压跌落。实物必须用三极管或 MOSFET 做开关级放大三极管的基极串联 1kΩ 电阻限制基极电流蜂鸣器两端视情况并联一个反向续流二极管防止断电瞬间反电动势打坏引脚。第三个差异是整体音准。Proteus 仿真中定时器的时钟模型是理想化的而实物晶振存在容差市售 8MHz 晶振的误差通常在 ±30ppm 以内对音乐盒这种短旋律播放影响很小但如果实物上听到明显跑调先确认是不是启动时误用成了内部 RC内部 RC 在全温范围内精度只有百分之几做音乐盒完全不够。5. 包络控制与音色优化用占空比阶梯改善蜂鸣器听感5.1 阶梯占空比实现简易包络无源蜂鸣器直接给 50% 方波发出的声音是一个持续保持满响度的平直音听久了会觉得很“顶”。给每个音符加一个包络也就是让音量随时间先起音、再衰减听感会自然很多。用 PWM 的占空比可以近似控制蜂鸣器的等效能量占空比从 50% 开始逐步降到 5%听起来就像一个逐渐减弱的敲击音。void Music_PlayNoteWithEnvelope(uint32_t freq, uint16_t duration_ms) { uint32_t arr 0; uint16_t seg_ms duration_ms / 6; /* 6 级衰减每级时长 */ int level; if (freq 0) { TIM2_PWM_Stop(); HAL_Delay(duration_ms); return; } arr 1000000UL / freq; TIM2_SetFreq(arr); /* 占空比从 50% 阶梯式降到 5% */ for (level 0; level 6; level) { uint16_t cmp arr * (50 - level * 9) / 100; TIM2_SetCompareAndStart(cmp, arr); HAL_Delay(seg_ms); } TIM2_PWM_Stop(); }这段代码把一个音符的时值切成了 6 段段与段之间只改比较值不重新初始化定时器避免每次重载都产生毛刺。level从 0 到 5 时比较值对应占空比依次是 50%、41%、32%、23%、14%、5%。seg_ms用duration_ms / 6所以音符越短衰减越快长音才会表现出自然的余音消退。实际听下来高音区 5% 的尾音已经接近消失如果你觉得最后一级太弱可以把level * 9改成level * 7保留一点残响。5.2 用示波器验证包络是否生效包络生效后在示波器上看到的不是频率变化而是 PWM 脉冲宽度在每个时间段内整体变窄。把探头夹到 PA0用单次触发抓一个音符的完整波形展开后你应该能数出 6 组宽度依次递减的脉冲串。如果没有示波器逻辑分析仪也可以把采样率设置在 5MHz 以上才能看清 PWM 的每一个周期低于这个采样率脉冲宽度被混叠展开后看起来像频率抖动很容易误判成定时器配置问题。提示肉眼观察波形后可以做一次定量验证。每组脉冲的占空比应该分别接近 50%、41%、32%、23%、14%、5%。如果某一组的占空比明显跳变说明改动比较值时和定时器时钟不同步可以把TIM2_SetCompareAndStart内的写序列改成先更新比较值、再等待一次更新事件保证改动在周期边界生效。最后一个常用手法是把包络和节拍分离。节拍由HAL_Delay控制包络由定时器更新中断控制。中断每 10ms 递减一次占空比主循环只负责决定哪个音符开始这样当以后扩展到暂停功能时不会因为阻塞延时把扫描键盘的循环卡死。这个思路同样适用于后续接入语音播放模块接口从 PWM 换成 I2S 或 DAC 时包络层的代码修改量远比你想象的小。本文还有配套的精品资源点击获取