尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于STM32的电子琴音乐播放器:频率原理与状态机设计详解

基于STM32的电子琴音乐播放器:频率原理与状态机设计详解 基于STM32的电子琴音乐播放器设计从频率原理到调试排障的完整记录我做课程设计指导这些年被问得最多的一个问题不是“STM32怎么学”而是“我照着教程写完了代码蜂鸣器为什么只会滴一声不会唱哆来咪”。这个画面几乎概括了基于STM32的电子琴音乐播放器设计最核心的门槛——原理没吃透代码写得再长也是白搭。这个项目表面上看是“一块最小系统板、一个蜂鸣器、几个按键”接在一起实际做起来会发现它横跨了GPIO控制、定时器PWM、按键扫描、消抖处理、状态机切换、音频频率合成好几块内容。适合正在做课程设计或毕业设计的同学也适合想借一个具体项目把STM32常用外设串起来巩固一遍的工程师。这篇文章不打算给你一版“下载即用”的完整工程而是把从硬件选型、频率计算、程序结构到调试排障的完整思路讲透让你自己能写出来也能改得动。1. 项目定位与功能边界先想清楚电子琴到底要做什么1.1 核心功能拆分把标题拆开看这个项目包含两个相互独立但又共享底层资源的功能第一是电子琴功能。用户按下琴键系统马上发出对应音高的声音松开按键声音停止。这是典型的实时演奏场景对响应速度有要求但不需要多高的音质。第二是音乐播放器功能。系统内部预置几首曲目按一个播放键后自动按乐谱顺序逐音播放像八音盒一样。这个功能对实时性要求低但对时序调度要求高每个音符响多长时间、音符之间停多久都必须精确控制否则曲子听起来节奏乱套。这两个功能背后重合的技术点只有一个按需产生指定频率、指定时长的方波信号。所以整个项目的本质就是一个“频率合成器加时序调度器”。想通这一点后面所有代码设计都会围绕它展开不会东一榔头西一棒子。1.2 先给自己画一条“不做清单”很多初学者把这个项目做砸不是因为做得太少而是因为想得太多。我给这类项目画过一条“不做清单”照着这个边界做能少走一半弯路。这个项目不需要运行实时操作系统几个功能用状态机就能写得非常清楚不需要做音频文件解码不碰WAV也不碰MP3那是另一个量级的工程不需要复音功能也就是不追求多个按键同时按下时发出和弦课程设计评审里几乎不会拿这个当硬性指标也不需要触摸屏、LCD这类显示外设你完全可以用串口或LED做状态指示。我见过不少学生一上来就规划了蓝牙模块、语音识别、触摸键盘结果光调通信就花了两周核心的“按键出声”功能却一直没跑稳。先把最基础的闭环打通后面再谈扩展这个项目才能真正落地。2. 发声原理与音阶映射音高在单片机里是一串定时器参数2.1 从十二平均律到C调音阶频率表你听到的“哆来咪发嗦拉西”本质上是一系列频率不同的声波振动。钢琴之所以能弹出不同的音高是因为琴槌敲击不同长度的琴弦产生不同的振动频率。单片机没有琴弦但它可以让蜂鸣器的振膜按照你指定的频率振动——方法就是输出一个频率可调的方波信号。音阶频率不是随便定的国际上通用的标准是十二平均律基准是A4440Hz。任意一个音的频率可以用这个公式算出来f 440 × 2^((n - 69) / 12)其中n是MIDI音符编号。A4的MIDI编号是69C5的MIDI编号是72算出来就是523.25Hz。我们常用的C调音阶从低音到高音每个音之间不是等间隔的赫兹数而是等比关系相邻半音的频率比是2的1/12次方约等于1.059463。实际编程时不需要每次都现算直接把一张查表放代码里就行。以C4到C6为例常用频率如下表简谱音名频率Hz计数时钟1MHz下的ARR值1低音C4261.6338222低音D4293.6634053低音E4329.6330334低音F4349.2328635低音G4392.0025516低音A4440.0022727低音B4493.8820241中音C5523.2519112中音D5587.3317023中音E5659.2615164中音F5698.4614315中音G5783.9912756中音A5880.0011367中音B5987.771012这张表里的ARR值是按1MHz计数时钟算出来的不同分频配置下数值不同但它背后的对应关系是固定的。一旦你把频率表建好电子琴的音准问题就解决了。2.2 定时器PWM让振膜按指定频率振动STM32输出一个指定频率的方波最直接的办法是用定时器的PWM输出模式。先搞清楚PWM频率的数学关系PWM频率 定时器时钟频率 / ((PSC 1) × (ARR 1))以STM32F103C8T6为例主频72MHz。如果把预分频器PSC设为71那么定时器计数时钟就变成72MHz / 72 1MHz也就是计数器每1微秒加一。此时要让PWM输出523Hz的方波ARR需要满足ARR 1 1000000 / 523 ≈ 1911这就对上前面表格里的1911了。配置好ARR之后再把比较寄存器CCR设为ARR的一半输出就是占空比50%的方波。50%占空比对无源蜂鸣器来说驱动效率最高音色也最干净。这里要特别强调一下对无源蜂鸣器来说方波的频率就是它发出的声音频率。蜂鸣器内部的振膜会被这个方波信号反复驱动频率对了耳朵听到的音高就对了。这也是为什么这个项目不需要DAC不需要音频解码芯片一个定时器PWM通道就能解决发声问题——虽然音色比较“数码味”但作为课程设计完全够用。2.3 音长与休止音乐节奏的数据结构光有频率只能发声不能叫音乐。音乐里每个音符还有“时值”也就是这个音持续多长时间。时值由乐曲速度决定速度用BPM表示意思是每分钟多少拍。如果一首曲子是120BPM那么一个四分音符的时长是60秒 / 120 0.5秒。一个八分音符是这个的一半0.25秒一个二分音符是这个的两倍1秒。乐谱里常看到这种写法4表示四分音符2表示二分音符8表示八分音符。休止符也必须在乐谱数据里体现否则音符之间会连成一片听不出乐句的分割。处理方式很简单该休止的地方停止PWM输出让蜂鸣器静音。所以一个音符要能完整表示至少需要两个信息音高和时值。这决定了后面乐谱的编码方式——完全可以用两个数组一个存音高一个存时值。延迟、停顿、休止都靠这个数据结构驱动。这个设计思路比在代码里写死一串HAL_Delay要科学得多因为后者会把主循环彻底阻塞住按键响应全部失灵。3. 硬件电路设计元件选型、连接方式与容易忽视的电平细节3.1 主控选型与引脚规划主控选STM32F103C8T6是这门课最稳的选择。它虽然只是Cortex-M3内核、64KB Flash、20KB RAM但跑这个项目绰绰有余。价格便宜、资料多、CubeMX一键生成初始化代码这些优势让它成为课程设计和毕设的绝对主力。引脚分配上要留一个心眼。PWM输出用一个定时器通道比如TIM2的CH1对应PA0矩阵键盘的4根行线和2根列线占6个IO再加一个模式切换按键总共不到10个GPIO非常宽裕。但这里有一个高频坑STM32的PB3、PB4、PA15这几个引脚默认是JTAG调试口不是普通GPIO。如果你想省引脚把它们当按键扫描线用不在代码里禁用JTAG会出现IO口完全不受控的诡异现象。标准库要调用GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE)HAL库则要确保CubeMX里SYS配置为Serial Wire。不要问我怎么知道的我见过太多人在这个上面卡了一整天。3.2 从GPIO到蜂鸣器驱动电路与常见误区很多人一开始直接把蜂鸣器两根线接到STM32的GPIO和GND上结果发现声音小得可怜或者干脆不响。原因很简单STM32的GPIO输出驱动能力有限一般只有几毫安到二十毫安级别直接驱动蜂鸣器这种感性负载电平会被拉垮电流也不够。正确的做法是用一个三极管做开关放大。最简单的电路是这样的器件参数连接说明NPN三极管SS8050或S8050基极经1kΩ限流电阻接单片机PWM输出引脚蜂鸣器无源蜂鸣器5V或3.3V规格一端接电源正极另一端接三极管集电极三极管发射极直接接GND作为低端开关续流二极管1N4148反向并联在蜂鸣器两端防止关断瞬间反电动势击穿三极管有人可能会问为什么要用三极管直接用GPIO推挽输出不行吗行是行但工程上不推荐。三极管在这里起到了电流放大的作用让蜂鸣器获得足够能量。另外加续流二极管这个细节很多人会漏掉蜂鸣器是感性负载PWM切换瞬间会产生反向电动势没有这个二极管三极管容易损坏。还要强调一个选型问题无源蜂鸣器必须用无源。无源蜂鸣器内部没有振荡电路给它什么频率的方波它就发出什么频率的声音有源蜂鸣器内部自带振荡器一通电就发出固定频率的“嘀”声根本无法通过改变PWM频率来改变音高。这个区别后面调试部分还会再提到。如果手头有带功放的小喇叭模块也可以直接用它替代蜂鸣器。这类模块内部有功放芯片输入一个PWM方波信号喇叭就能发声声音比蜂鸣器圆润。缺点是功耗大一些5V供电时要注意电流。对想提升音质的同学这是个不错的曲线救国方案。3.3 矩阵键盘的硬件细节8个音阶按键最省IO的接法是用矩阵键盘。常见的方案是4行×2列或者2行×4列。这里以4行×2列为例4根行线接GPIO输出2根列线接GPIO输入总共6个IO就能识别8个按键。硬件上要做的关键决定是列线的默认电平。最简单可靠的方案是每个按键一端接行线另一端接列线行线作为扫描输出列线作为扫描输入列线通过10kΩ电阻上拉到3.3V。当某一行被拉低时按下这一行上的按键对应的列线就会被拉低程序读列线的电平状态就能定位按键位置。还有一种接法是按键一端接列线、另一端直接接GNDGPIO配置为内部上拉输入。这种方案行线就不需要输出了每根列线直接对应一个按键。如果按键数量不多这个方案更简单逻辑也不容易出错。矩阵键盘真正容易出问题的地方在程序扫描逻辑上硬件上只要线不接错、上拉电阻没漏焊基本都能正常工作。如果按键按下时万用表量到的电平变化不对优先检查上拉电阻和接线而不是急着改代码。4. 软件实现状态机、矩阵扫描与音乐播放逻辑4.1 两个功能如何共用一个发声通道状态机设计系统里只有一个PWM输出作为发声通道但电子琴和音乐播放器都要用它所以必须设计好状态机避免两个功能打架。我设计的系统状态很简单状态含义触发条件S_IDLE空闲等待按键上电复位或一段演奏结束后S_KEYBOARD电子琴演奏模式处于演奏模式时按下琴键进入松开后回到S_IDLES_PLAYING自动播放曲目功能键触发播放S_FAST_FORWARD播放暂停或切换曲目功能键中断播放关键设计在于模式切换用一个独立的功能键完成比如长按功能键1秒从演奏模式切到播放模式播放模式中短按一次功能键切换到下一首曲目再长按回到演奏模式。这样做的好处是琴键和音乐播放器共用的那6个矩阵IO不会因为模式切换被误判成按键。状态机要处理“播放中有人按琴键”的情况。我的策略是播放模式下琴键完全不响应只有功能键能打断播放。这样可以避免用户手一抖就把正在放的歌按停了。中断播放时要做的动作是关闭PWM输出、重置歌曲索引、回到S_IDLE。这三个动作缺一不可不然会出现下一首歌“哑巴”的怪现象。4.2 矩阵键盘扫描与消抖矩阵键盘的扫描原理不复杂每次只把一行拉低其他行保持高电平然后依次读取列线。4行2列的矩阵扫描一轮最多读8次就能确认当前有没有按键按下、按的是哪一个。下面是HAL库下的扫描核心代码#define ROW_PORT GPIOB #define ROW_PINS (GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3) #define COL_PORT GPIOB #define COL_PINS (GPIO_PIN_4 | GPIO_PIN_5) uint8_t Matrix_Scan(void) { uint8_t row_pins[4] {GPIO_PIN_0, GPIO_PIN_1, GPIO_PIN_2, GPIO_PIN_3}; uint8_t col_pins[2] {GPIO_PIN_4, GPIO_PIN_5}; for (uint8_t r 0; r 4; r) { // 先把所有行线拉高再拉低目标行避免扫描瞬间误触发 HAL_GPIO_WritePin(ROW_PORT, ROW_PINS, GPIO_PIN_SET); HAL_GPIO_WritePin(ROW_PORT, row_pins[r], GPIO_PIN_RESET); for (uint8_t c 0; c 2; c) { if (HAL_GPIO_ReadPin(COL_PORT, col_pins[c]) GPIO_PIN_RESET) { return r * 2 c; // 返回按键编号 0~7 } } } return KEY_NONE; }注意代码里先把所有行线拉高再拉低目标行的操作。这个顺序很多人都忽略结果就是扫描过程中行线电平状态混乱出现“串键”——你按的是第1个键程序却判断成第3个键。消抖也不能省。机械按键在按下和松开的瞬间触点会抖动几十毫秒。如果不对这个抖动做处理一次按键会被识别成好几次触发。最简单的消抖方法是检测到电平变化后延时10到20毫秒再读一次两次读到的状态一致才确认按键有效。虽然Delay会阻塞主循环但因为消抖时间极短对整体实时性影响可以忽略。关于长按发声电子琴的“按下发声、松开停声”实现起来并不需要额外检测长按事件。主循环里只要持续调用扫描函数在当前按键编号有效且没有松开时保持PWM输出频率不变就是长音效果一旦扫描返回KEY_NONE立刻关闭PWM。这个逻辑在演奏模式的状态机里非常直观。4.3 音乐播放的乐谱编码与非阻塞调度自动播放曲目是这个项目最容易写出“一股屎山味”的部分。很多人的第一反应是在代码里顺序写响500ms、停100ms、响500ms……一旦要换歌或者改节奏代码就完全没法维护。正确的做法是把乐谱数据化。用两个数组一个存音高一个存时值// 音符编码1~7对应do~si0表示休止255表示乐曲结束 const uint8_t song_notes[] { 1, 1, 5, 5, 6, 6, 5, 0, 4, 4, 3, 3, 2, 2, 1, 0, 5, 5, 4, 4, 3, 3, 2, 0, 5, 5, 4, 4, 3, 3, 2, 0, 1, 1, 5, 5, 6, 6, 5, 0, 4, 4, 3, 3, 2, 2, 1, 255 }; // 时值编码2表示二分音符4表示四分音符8表示八分音符 const uint8_t song_durations[] { 4, 4, 4, 4, 4, 4, 2, 2, 4, 4, 4, 4, 4, 4, 2, 2, 4, 4, 4, 4, 4, 4, 2, 2, 4, 4, 4, 4, 4, 4, 2, 2, 4, 4, 4, 4, 4, 4, 2, 2, 4, 4, 4, 4, 4, 4, 2, 255 };上面这段是《小星星》前两句的编码。一眼就能看明白第一句“1155665”每两个音之间穿插一个休止符听不出乐句切割反而奇怪。播放调度用非阻塞方式核心思想是“记录音符开始时间到时间了就切下一个音符”而不是“延时到时间再继续”static uint8_t music_index 0; static uint32_t note_start_time 0; static uint16_t note_duration_ms 0; void Music_Process(void) { if (play_state ! S_PLAYING) return; // 当前音符还没播完 if ((HAL_GetTick() - note_start_time) note_duration_ms) return; // 播完了停掉当前声音 Stop_Tone(); // 取下一个音符 uint8_t note song_notes[music_index]; uint8_t dur song_durations[music_index]; music_index; if (note 255) { // 整首曲子放完复位索引回到空闲 music_index 0; play_state S_IDLE; return; } if (note 0) { note_duration_ms 60000 / BPM * 4 / dur; note_start_time HAL_GetTick(); } else { Start_Tone(note_to_freq(note)); note_duration_ms 60000 / BPM * 4 / dur; note_start_time HAL_GetTick(); } }这段代码的关键在于用了HAL_GetTick()来计时。主循环每轮都调用Music_Process它只是在不断比较时间差不会阻塞等待。主循环可以去扫描其他按键、刷新LED整个系统依然是“活的”。用HAL_Delay来写音乐播放严格来说不是一个好方案它会让整个程序在播放期间完全无法响应任何按键。4.4 发声通道的切换细节演奏模式和播放模式都要用PWM通道切换时如果处理不当会出现爆音或者“咔哒”声。我踩过几次坑之后总结的经验是切换频率时先停掉PWM输出再改ARR再重新启动输出。直接改ARR计数器正在计数时可能会产生一个畸形的长脉冲听感就是“咔哒”一声。钢琴式的自然衰减很难用方波实现但在音符结束时给一个短促的关闭过程并不复杂。具体做法在音符时长结束前10ms开始把PWM占空比从50%逐步降到0。这个包络效果能让音色圆润不少代码量也不大算是一个性价比很高的音质提升手段。5. 实测翻车记录四个高频问题与完整排查链路5.1 上电只会“滴”不会“哆来咪”先分清有源蜂鸣器和无源蜂鸣器这是我接手这个课题时遇到最多的一个“假故障”。现象是程序运行后无论怎么改PWM频率蜂鸣器都只发出同一个音调的“嘀嘀”声按键毫无作用。排查链路先检查PWM输出是否正常。用示波器或万用表频率档测PWM引脚如果按键切换时频率确实在变说明单片机和程序逻辑没问题。再检查蜂鸣器类型。把蜂鸣器拆下来看丝印带“有源”标识或型号中带“A”的是有源蜂鸣器用万用表量直流电阻有源蜂鸣器电阻一般只有几十欧无源蜂鸣器通常是几百欧或更高。确认是有源蜂鸣器后不用怀疑代码直接换无源蜂鸣器。这个坑之所以坑是因为“会响”给了人一种“电路没问题”的错觉。有些人会在这上面反复调定时器配置越调越怀疑人生。我建议做这个设计之前先确认手里蜂鸣器是无源的省得后面白折腾。5.2 按键乱触发或串键IO口复用与上拉配置一起排查现象是按下第1个键程序却识别成第3个键或者按下一次触发了好几个音。排查链路先用万用表逐个量按键两端的电压。按下之前列线应该是稳定的高电平按下之后列线应该被拉低。如果测量到的电平不稳定问题在硬件电路不在程序。检查矩阵扫描代码是否在拉低当前行之前把所有行都恢复了高电平。如果漏了这一步上一次扫描留下的低电平会影响下一次扫描的判断造成串键。检查是否用到了PB3、PB4、PA15这些JTAG默认复用引脚。如果用了在CubeMX里把SYS的Debug选项改为Serial Wire重新生成代码或者用标准库调用GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE)。这个检查顺序很重要因为很多人一上来就怀疑程序逻辑其实罪魁祸首是引脚根本不在普通GPIO模式下。软件消抖延时是否足够。消抖不是越短越好至少10ms。太短的话按键抖动期就被当成有效触发。我实际排查过的一个案例是按键扫描的程序逻辑完全正确但接线时把行线和列线的定义搞反了程序里行输出、列输入实际接的却是行列对调最后按出来的键位完全错乱。这一类问题在板子上用标签纸标注好每根线的功能能省很多时间。5.3 播放音乐节奏忽快忽慢甚至卡死Delay的双重罪现象是播放前几个音符还挺正常后面节奏越来越奇怪偶尔还会整个程序卡住不动。分析下来原因通常出在Delay函数上。两种情况最常见第一种用HAL_Delay做音符时长控制。这种方式会阻塞主循环播放期间所有按键全部失灵。更麻烦的是如果在按键消抖里也用了HAL_Delay消抖延迟会叠加到播放节奏里让每一拍的长短都变得不可控。第二种HAL_Delay本身卡死。HAL_Delay依赖SysTick中断。如果程序里有其他中断服务函数把SysTick的中断优先级改了或者长时间关中断HAL_Delay的计时就会失效程序会一直卡在等待里出不来。热搜里“stm32延时函数delay卡死”基本都是这个原因。排查方法很简单在播放程序里不使用任何阻塞延时所有时长控制都改成“记录时间点、查询时间差”的非阻塞方式。用HAL_GetTick()作为时间基准做全局计时。这样即使程序里有其他任务播放调度也只是以轮询方式运行不会互相阻塞。5.4 歌放完一遍后再播放没有声音数组索引没复位现象是第一次按播放键曲子正常播完第二次按播放键蜂鸣器一点声音都没有。我第一次遇到这个问题时排查了硬件、PWM配置、状态机入口条件绕了一大圈最后发现原因特别简单播放结束后music_index停在数组末尾的255处没有复位回0。第二次启动播放时Music_Process立即读到255以为乐曲已经结束直接返回所以一直不发声。修正方法就是前面代码里写的在读取到255时把music_index清零并把状态切回S_IDLE。这样每次重新触发播放都会从数组头部开始。这种问题的教训是凡是涉及“从头再播”“重新开始”的功能都要检查索引变量、状态变量有没有恢复到初始值。调试时用STM32的在线调试功能在Music_Process里打断点观察music_index的值问题几秒钟就能定位比我当时盲猜快得多。6. 做完之后值得继续改进的几个方向6.1 把方波换成正弦波音色会完全不一样方波驱动蜂鸣器声音很“硬”听起来像老式电子表。方波包含大量高次谐波所以音色发“脆”。如果换成STM32的DAC输出一段查表生成的正弦波音色会柔和很多更接近真实乐器的听感。具体做法在代码里存一张256点的正弦波表用定时器触发DAC转换每个音符频率对应的采样周期不同转换出来的正弦波频率也不同。这个改动会引入DAC和DMA工程量稍大但做完之后对“音频合成”的理解会深很多。6.2 加一个OLED显示当前音名和曲名硬件上加一块0.96寸OLED用I2C接口只占两个IO。电子琴模式下显示当前按下的音名播放模式下显示当前播放的曲名和进度。这个功能一方面能提升作品完整度另一方面也让“模式切换”不再是一个让人摸不着头脑的盲操作——用户至少能看见当前处于什么状态。驱动OLED的代码完全可以在不改变原系统结构的情况下加进主循环因为它也是非阻塞的查询方式只是周期性刷新显示内容。6.3 把演奏过程录下来回放“录音回放”是一个很有吸引力的扩展功能。原理很简单在演奏模式下把按键编号和按键时间戳存进一个数组。切换为播放模式时按时间戳依次触发对应的音符就能把刚才弹的旋律原样回放出来。这个功能能实现的关键还是那套非阻塞的“事件时间戳”调度逻辑。演奏时存下来的本质上是一串事件序列播放时按时间顺序消费这个序列。理解了这个模型你会发现音乐播放器的核心难度不在硬件而在状态管理上。做完这个项目之后我自己最大的体会是STM32课程设计类项目难的不是某个单独的寄存器配置而是如何把一个完整的用户场景拆解成清晰的软件状态机。电子琴和音乐播放器合用同一个PWM通道本质上就是一个“资源冲突如何用设计解决”的经典案例。如果你做的时候能想明白这一层这个项目给你的收获就不只是交差拿学分而是真正学会了一套嵌入式系统的软件组织方法。
返回列表