
做这个项目算是缘分。我原本在做一个用STM32控制小玩意的练手项目结果在查资料时看到一堆51单片机简易电子琴 矩阵键盘8音符 按键长按发声的提问突然意识到很多刚入门的朋友都在卡在同一个点上怎么把按键、声音、定时器这些零散的东西拼成一个能跑起来、能演奏的完整系统。刚好我手头有一块STM32F103C8T6最小系统板干脆就把它做成一个基于STM32的电子琴音乐播放器——既能当电子琴弹又能自动播放预置音乐顺便把矩阵键盘扫描、PWM发声、定时器中断、状态机这些嵌入式基本功全部串起来。这个项目非常适合正在学STM32的人尤其是做完流水灯和呼吸灯之后不知道下一步该做什么的朋友。今天我就把这套东西从头到尾拆开把硬件连接、软件逻辑、踩过的坑一次讲清楚。1. 整体方案设计与模块拆解1.1 需求拆解电子琴与音乐播放器结合的本质先说清楚这个项目到底在做一件什么事。要做一个电子琴音乐播放器本质上就是要实现两个功能模式一个是演奏模式用户按下键盘上的按键系统立刻发出对应音高的声音松开按键就停止另一个是播放模式系统按照预置曲谱自动发声不需要人为干预。这两个模式看起来差别很大但站在硬件角度它们的底层需求是一致的都需要在某个时间点产生某个频率的声音并持续一段可控的时间。演奏模式里时间长短由用户按键的时长决定播放模式里时间长短由曲谱中音符的时值决定。所以整个项目的核心就收敛成了一个点如何用STM32稳定地产生指定频率、指定时长的方波信号。把这个核心想明白之后整个系统就分成了几大块。键盘输入负责捕捉用户意图频率生成负责把音高翻译成电信号时序控制负责管理每个音的持续时间再加上一套曲谱数据结构和模式切换逻辑就可以把两个功能统一在同一套框架里。我在设计时没有把电子琴和播放器当作两个割裂的功能去写两套代码而是复用了同一个发声引擎只是把谁来决定按下和松开这件事做了抽象——演奏模式由用户按键驱动播放模式由软件定时器驱动。这样代码量大幅减少结构也干净很多。1.2 核心硬件选型与理由主控芯片我选了STM32F103C8T6。选它不是因为参数有多强而是因为它有72MHz主频、多个定时器、丰富的中断资源这些能力对于产生音频信号来说绰绰有余。更重要的是它的资料在国内社区非常丰富几乎所有问题都能搜到现成解决办法对新手极其友好。发声器件上用无源蜂鸣器有源蜂鸣器买回来就能响但它内部自带振荡电路频率固定想要控制出不同的音高根本不可能。无源蜂鸣器需要外部给它一定频率的方波才会发声正好我们能通过STM32定时器输出任意频率的方波两者配合完美。至于很多人纠结的用DAC加功放和喇叭的方案虽然音质好很多但对新手来说引入了运放调音、功放选型、滤波电路设计等一系列新问题我建议先把方波方案跑通后面有兴趣再升级。按键部分用4x4矩阵键盘。为什么不用8个独立按键16个独立按键会占掉16个GPIO而STM32F103C8T6虽然I/O不少但我们要留出足够引脚给调试和后续扩展。矩阵键盘用8个引脚4行4列就能管理16个按键资源利用效率高得明显。而且这个项目需要的功能不止8个音符还需要播放、暂停、切歌、模式切换这些控制功能16键刚好能把音阶键和功能键分开互不干扰。器件型号/规格作用选择理由主控STM32F103C8T6逻辑控制、频率生成资源充足、资料丰富、性价比高发热源无源蜂鸣器5V发声可通过方波频率精确控制音高键盘4x4薄膜矩阵键盘输入音符与控制命令8个GPIO管理16个按键节省引脚显示OLED SSD1306可选显示当前模式、音符提升交互体验非必需电源USB 5V转3.3V模块供电开发板自带稳压简单可靠硬件清单确认之后我建议你在动手前先画一画引脚分配表别等焊好了才发现引脚被占用。我的分配思路是PB0-PB3做矩阵键盘行线PB4-PB7做列线PA0输出PWM到蜂鸣器PA1接按键LED指示灯剩余引脚留作SWD调试或OLED扩展。这个分配的讲究在于把定时器通道、复用功能优先级都考虑进去避免后续调试时被引脚冲突卡住。2. 硬件电路设计要点2.1 矩阵键盘的电路原理与连接矩阵键盘电路这块很多新手第一次接触时会觉得神秘其实拆开了看就是一组行线与列线的交叉网络。4x4矩阵意味着有4根行线和4根列线每个按键连接某一行和某一列。扫描的核心思路是逐行置高电平读取列线的电平状态二者相交的位置就是被按下的按键。具体到硬件连接我在STM32最小系统板上的接法是行线接PB0-PB3并配置为推挽输出列线接PB4-PB7并配置为输入下拉模式。扫描时先把PB0输出高电平、PB1-PB3输出低电平再读取PB4-PB7的电平状态如果某一列为高就说明第一行对应的键被按下然后切换到PB1输出高电平再读一次列状态以此类推。整个过程循环很快人感觉不到延迟。有个细节必须注意列线一定要加上下拉电阻。有些开发板内部没有上下拉或者默认状态不确定会造成读到的电平随机跳变导致按键判定错乱。STM32的GPIO内部有可配置的上拉/下拉电阻我会在代码里把它们打开但为了稳定起见我在硬件上也给四根列线各接了一个10K下拉电阻。这个习惯是我从工业控制板的设计上学来的——能用硬件兜底的就不要完全依赖软件配置。2.2 发声方案对比无源蜂鸣器与DAC功放在发声方案上很多人一上来就纠结声音好不好听。我的建议是别在第一步追求音质先用最简单的方案把功能跑通。无源蜂鸣器加定时器PWM的方案听起来确实比较电子味像早期游戏机的声音但它有一个不可替代的优点原理简单、调试容易、硬件成本极低。你只需要一根I/O通过三极管驱动蜂鸣器不需要额外的音频放大器、不需要滤波器、不需要考虑阻抗匹配。如果后续想提升音质可以考虑用STM32的DAC输出正弦波再经过功放芯片驱动小喇叭。这个方案的音色接近真实乐器但你需要处理波形的查表生成、DMA传输、低通滤波、功放芯片的选型和布线等问题。我自己在升级版里用的是MCP4725这种I2C接口的DAC模块加PAM8403功放模块解决了STM32F103C8T6只有一路DAC不够用的问题效果确实提升了一个档次。但初版调试时我仍然建议先用蜂鸣器把时序逻辑调通因为系统核心难点根本不在发声器件的档次而在于音符时序的控制。2.3 电源与噪声处理经验电源这个事我吃过一次亏。最初我把蜂鸣器直接接到STM32的GPIO上一响起来屏幕就闪有时候还会莫名其妙复位。后来用示波器一看蜂鸣器启动瞬间的电流尖峰直接把3.3V电压拉到了2.8V导致主控供电不足。解决方案有两个一是蜂鸣器使用独立的5V供电通过三极管或MOS管来开关二是在蜂鸣器两端并联一个100uF电解电容和一个小容量的104陶瓷电容吸收瞬间冲击。三极管驱动电路也很简单我用的是S8050 NPN三极管基极串一个1K电阻接到STM32的PA0引脚发射极接地集电极接蜂鸣器的负极蜂鸣器正极接5V电源。这样STM32 GPIO只需要提供几个毫安的基极电流蜂鸣器的几十毫安电流直接从5V电源走主控的3.3V完全不受影响。如果手头有MOS管比如AO3400效果会更好导通压降更小声音更响亮。3. 核心软件架构与音符频率设计3.1 音符频率表的计算与映射电子琴的本质是把每个音名映射到一个确定的频率。国际标准音A4的频率是440Hz每升高一个半音比如从C到升C频率乘以2的1/12次方即约1.059463。而每升高一个八度频率翻倍。通过这个规律可以计算出从C4到B5一共16个音符的频率值正好对应4x4键盘的前两行。我用C语言定义了一个音符频率查找表用数组下标来索引音名。比如数组的第一个元素对应C4261.63Hz第二个对应C#4277.18Hz以此类推。这样我只需要定义一个索引宏比如#define NOTE_C4 0、#define NOTE_CS4 1代码可读性就很高。查找表比实时计算速度更快而且避免浮点运算——对没有浮点单元的STM32F103来说整型查找表是非常划算的做法。频率有了之后下一步就要把它转换为定时器的参数。我用的是TIM2的PWM输出模式TIM2挂载在APB1总线上时钟频率是72MHz。要让定时器输出指定频率freq的方波需要设置预分频器PSC和自动重装载值ARR。计算公式是freq 72MHz / ((PSC1) * (ARR1))。我固定PSC为71这样定时器计数频率就是1MHz即每微秒计数一次然后ARR就等于1000000/freq-1。这样ARR是一个整数而且运算简单误差通常在0.1%以内人耳完全分辨不出来。音符频率(Hz)定时器ARR(预分频71)C4261.633822D4293.663405E4329.633033F4349.232863G4392.002551A4440.002272B4493.882024C5523.2519113.2 软件整体架构前后台与中断分层这个项目的软件架构我采用了典型的前后台结构一个主循环负责键盘扫描和模式调度若干个定时器中断负责精确的发声控制。这个架构虽然朴素但对于教学和演示来说是最容易理解和最稳定的。不要把复杂的时间片轮询或RTOS引入这个项目除非你想增加不必要的调试难度。主循环里做的事情是扫描矩阵键盘、处理按键事件、更新OLED显示。定时器中断里TIM2负责产生音符频率的PWM信号这是发声的核心TIM3作为节拍计时器在播放模式下每10毫秒产生一次中断用来计时当前音符已经播放了多久、什么时候需要播放下一个音符。这里有个很重要的设计思路发声和按键扫描是解耦的。演奏模式下按键按下时只做一件事——告诉TIM2现在你要输出某个频率的PWM按键松开时告诉TIM2停止输出。至于这个键按了多久、扫描期间抖动了一下这些都是键盘扫描层应该处理的不能让它们干扰到发声层。如果按键状态判断与PWM输出耦合得太紧很容易出现按一个键时声音卡顿或者破音。3.3 曲谱数据结构设计用数组描述音乐播放模式的关键在于定义一种容易编写和解析的音乐数据格式。我采用的是音符-音长二元组数组。每个音符用一个结构体表示包含音高索引和时值计数。这个设计借鉴了MIDI文件的思路但去掉了文件解析的复杂度直接用C语言数组固化在Flash里。typedef struct { uint8_t pitch; // 音高索引0表示休止符 uint16_t duration; // 节拍数单位1/4拍 } Note_t;然后定义节拍速度比如设定一分钟120拍那么一拍就是500毫秒。我让TIM3的时基为10毫秒中断一次那么一个四分音符1拍就对应50个中断计数。这个计数关系在代码里用一个宏定义#define BEAT_QUARTER 50来表示清晰明了。曲谱的编排就非常直观了。以《小星星》为例它的第一句是C C G G A A G对应的音符时长是两个半拍、两个半拍、两个半拍、一个整拍所以在数组里写出来就是const Note_t melody_star[] { {NOTE_C4, HALF}, {NOTE_C4, HALF}, {NOTE_G4, HALF}, {NOTE_G4, HALF}, {NOTE_A4, HALF}, {NOTE_A4, HALF}, {NOTE_G4, FULL}, };为了让播放器可连续播放多首曲子我定义了一个曲目列表把所有预置歌曲的数组地址和长度登记在册。播放器通过一个索引变量来指向当前播放的曲目支持上一曲下一曲切换。这个设计扩展起来很方便以后加新曲目只需要新增一个数组然后在列表里登记一行播放引擎完全不用改。4. 关键代码实现与调试过程4.1 TIM2 PWM发声模块的初始化与实现TIM2的PWM输出是这个项目的声音之源。我先把它的完整配置贴出来然后逐行解释为什么这样设。void TIM2_PWM_Init(void) { GPIO_InitTypeDef gpio_init; TIM_TimeBaseInitTypeDef tim_base; TIM_OCInitTypeDef tim_oc; // 1. 使能时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); // 2. 配置PA0为复用推挽输出 gpio_init.GPIO_Pin GPIO_Pin_0; gpio_init.GPIO_Mode GPIO_Mode_AF_PP; gpio_init.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio_init); // 3. 定时器时基预分频71计数频率为1MHz tim_base.TIM_Prescaler 71; tim_base.TIM_CounterMode TIM_CounterMode_Up; tim_base.TIM_Period 3822; // 默认输出C4261.63Hz tim_base.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, tim_base); // 4. PWM模式1占空比50% tim_oc.TIM_OCMode TIM_OCMode_PWM1; tim_oc.TIM_OutputState TIM_OutputState_Enable; tim_oc.TIM_Pulse 1911; // 占空比50% tim_oc.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM2, tim_oc); TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable); // 5. 使能ARR预装载 TIM_ARRPreloadConfig(TIM2, ENABLE); }PA0复用为定时器2的通道1这是数据手册里确定的映射关系不能用错引脚。预分频器设为71是因为系统主频72MHz除以72得到1MHz也就是计数频率1微秒一次这样ARR的值直接就是周期的微秒数换算直观。占空比设为50%意味着高低电平各占一半方波信号正好对称驱动蜂鸣器的效率最高声音最响亮。播放某个音符时只需要修改ARR并保持Pulse为ARR的一半void Tone_Play(uint16_t freq) { if (freq 0) { TIM_Cmd(TIM2, DISABLE); GPIO_ResetBits(GPIOA, GPIO_Pin_0); return; } uint16_t arr 1000000 / freq - 1; TIM_SetAutoreload(TIM2, arr); TIM_SetCompare1(TIM2, arr / 2); TIM_Cmd(TIM2, ENABLE); }这里要注意一个坑不能在TIM_SetAutoreload之前让定时器处于停止状态否则改ARR不生效。我最初写代码时就因为顺序错了导致频率怎么改都没有变化花了大半天排查。先设ARR、再设CCR、最后使能定时器这个顺序是经过验证的。4.2 矩阵键盘扫描与长按识别键盘扫描的代码有很多种写法我采用的是逐行拉高、按列读入的经典方法同时加入了状态机和消抖逻辑。独立按键的消抖大家都懂延时10到20毫秒再确认但矩阵键盘的消抖如果简单地对每一个按键延时会严重拖慢整体扫描速度导致其他按键响应迟钝。我使用的改进方案是边沿检测延迟确认的两步法。第一步先快速扫描得到当前行和列的编号如果与上次状态不同启动一个20毫秒的软件定时器第二步在定时器到期后再次读取同一位置的按键状态如果依然按下才认定这是一个有效的按键事件。用语言描述可能有点绕看代码会更直观。#define KEY_ROWS 4 #define KEY_COLS 4 uint8_t key_scan(void) { uint8_t row, col; for (row 0; row KEY_ROWS; row) { // 当前行输出高其余行输出低 GPIO_Write(GPIOB, (uint16_t)(0x01 row)); delay_us(10); // 读取列状态 uint8_t col_data GPIO_ReadInputData(GPIOB) 4; if (col_data ! 0x0F) { for (col 0; col KEY_COLS; col) { if ((col_data (0x01 col)) ! 0) { return row * KEY_COLS col; } } } } return 0xFF; // 无按键 }这个扫描函数返回0-15的按键编号0xFF表示无按键。调用者需要自己做消抖和状态处理。长按识别的思路是在检测到按键按下之后记录按下的时间戳如果该按键持续按下超过500毫秒判定为长按触发长按对应的动作如果不到500毫秒就松开了则判定为短按触发短按对应的动作。这个时间戳不需要用RTC直接用主循环的循环计数或HAL_GetTick()标准库用SysTick来记录即可。4.3 播放状态机从演奏模式到音乐模式模式切换用状态机来实现是这个项目中最优雅的一部分。我把系统分为三个状态MODE_IDLE待机、MODE_PLAY演奏、MODE_MUSIC自动播放。初始状态是MODE_IDLE此时按任何音符键会直接进入演奏模式并响音按播放功能键会进入音乐模式开始放歌。状态机的核心代码用switch-case实现void mode_update(KeyEvent_t *evt) { switch (current_mode) { case MODE_IDLE: if (evt-type KEY_SHORT_PRESS) { if (evt-key 8) { current_mode MODE_PLAY; Tone_Play(note_freq[evt-key]); } else if (evt-key KEY_PLAY) { current_mode MODE_MUSIC; music_start(0); } } break; case MODE_PLAY: if (evt-type KEY_SHORT_PRESS) { if (evt-key 8) { Tone_Play(note_freq[evt-key]); } } else if (evt-type KEY_LONG_PRESS || evt-type KEY_RELEASE) { current_mode MODE_IDLE; Tone_Stop(); } break; case MODE_MUSIC: if (evt-type KEY_SHORT_PRESS) { if (evt-key KEY_STOP) { music_stop(); current_mode MODE_IDLE; } else if (evt-key KEY_NEXT) { music_next(); } } break; } }这里有一个细节值得说明在演奏模式下短按任意音符键就是切换音符这要求键盘扫描能够区分新的按键事件和按住同一个按键不松。按键事件结构体里包含了按键编号和事件类型事件类型可以是按下、松开、长按、短按。每次扫描周期状态机只处理事件不关心底层GPIO的状态这大大降低了耦合度。4.4 播放引擎节拍计时与音符切换音乐模式的核心是播放引擎它由一个TIM3中断定时驱动。TIM3配置为10毫秒产生一次中断中断服务函数里做以下三件事累加节拍计数、检查当前音符是否播完、播完则加载下一个音符。volatile uint16_t tick_count 0; volatile uint8_t music_flag 0; volatile uint16_t note_index 0; void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update)) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); if (music_flag) { tick_count; if (tick_count current_note_duration) { tick_count 0; note_index; if (note_index current_song_len) { music_flag 0; // 播放结束 Tone_Stop(); } else { tone_set_from_note(current_song[note_index]); } } } } }current_song指针指向当前播放的曲谱数组current_note_duration表示当前音符持续的节拍计数值。每次进入中断先检查计数器有没有到达当前音符的时值到了就切换到下一个音符。这种用粗粒度时基10毫秒驱动音乐进度、用高频PWM产生音调的方式避免了在音频回路里做耗时运算稳定性非常高。播放过程中如果用户按键停止只需要把music_flag清零并调用Tone_Stop()停掉PWM输出系统立刻回到待机状态不需要额外做复杂的清理工作。这也是状态机设计的好处状态切换只发生在明确的事件点没有异步的竞态问题。5. 实测过程中踩过的坑与排查技巧5.1 按键抖动导致音符乱跳第一个遇到的典型问题就是按键抖动。最开始我的扫描函数没有消抖按下一次键经常被识别成两三次声音卡顿得像老式电话拨号。排查方法是用串口把每次识别到的按键编号打印出来发现按下一次串口输出了两到三个不同编号——这就是抖动导致的。解决方式是两层组合。第一层是硬件上在列线对地接10K下拉电阻把不确定的电平稳定下来第二层是软件上采用我之前说的边沿检测延迟确认流程。总结一下核心经验矩阵键盘的消抖必须在按键编号确认之前完成不能在确认之后再补救。如果先判定出一个按键编号再去做延时消抖那么消抖期间这个编号可能已经因为抖动变成了另一个值等于白做了。5.2 蜂鸣器音量小或无声蜂鸣器无声或音量极小这个问题常见的诱因有三个。第一个是GPIO驱动能力不足直接接蜂鸣器的话GPIO最大只能提供20mA左右电流很多无源蜂鸣器工作电流远超这个值电压被拉低声音自然小解决方法是加三极管或MOS管驱动。第二个是频率参数配置错误有些朋友把ARR的值直接当作频率去设置导致实际输出频率差了数千倍听起来像滋滋的电流声而不是乐音这时应该用示波器或者耳朵对比一根琴弦的频率确认输出频率接近目标。第三个是极性接反无源蜂鸣器有正负极之分接反了不会发声。用万用表二极管挡可以测出蜂鸣器的极性正向导通时会有轻微的蜂鸣声。5.3 长按与短按识别冲突长按和短按同时存在时最容易出现的问题就是用户明明想长按切换曲目结果系统先触发了短按动作或者明明想短按弹一个音系统却因为按键停留时间稍长就判定成长按导致音突然断了。我的解决思路是短按动作在按键松开时触发长按动作在持续按住达到阈值时触发。这样安排的好处是当系统检测到长按条件满足并执行了长按动作后即使后续用户松开按键也不会再触发短按因为短按的触发条件要求按键在此之前出现过完整的按下-松开过程。这条逻辑看起来简单但很多人一开始都写成按下时立即触发短按超过阈值再触发长按结果必然是短按和长按同时触发逻辑冲突。我做了一个按下、释放、时长三分法的表格方便理解按键过程事件类型触发时机按下-松开500ms短按松开瞬间按下-持续500ms长按达到阈值瞬间长按后的释放无不触发任何事件5.4 播放模式下按键响应卡顿播放模式下按停止键有时要等半秒才响应。这个问题出在TIM3中断函数的执行时间上。早期版本里我在中断服务函数中直接调用了Tone_Play()而这个函数内部有时会调用GPIO操作和定时器寄存器修改需要十几个微秒。虽然看起来不多但TIM3的中断频率是100Hz如果主循环里刚好在扫描键盘的延时阶段按键响应就会被中断反复打断导致延迟不均匀。优化方案是在中断服务函数中只做标志位的置位和索引变化把真正的频率切换操作放到主循环中完成。中断里只负责计数主循环检测到note_index发生了变化然后调用Tone_Play()更新频率。这样中断服务函数的执行时间从十几微秒降到了不到一微秒系统响应变得非常平稳。这也是嵌入式开发中一个重要的经验法则中断服务函数越短越好凡是能在主循环中处理的事情尽量不要压在中断里做。5.5 常见问题速查表现象可能原因排查方向按下按键无反应接线错误、GPIO模式配置错误用万用表测行线电平均值串口输出按键编号跳动抖动未滤除检查下拉电阻、延时是否够长蜂鸣器有嘶嘶声PWM频率与目标不符检查PSC和ARR计算音准偏低或偏高晶振频率偏差导致定时器不准检查时钟配置、改用HSI或校准HSE播放到一半卡住曲谱长度或索引越界打印note_index检查边界长按误触发生长按阈值设置过短增大阈值或改为松开确认6. 从项目到催生更多想法的扩展方向这个项目做完之后整个系统的框架非常清晰它的扩展空间大得超乎想象。我建议你沿着几个方向继续深挖每一个方向都能独立做成一个不错的作品。第一个方向是音质升级。把无源蜂鸣器换成DAC加喇叭利用STM32F103的DAC输出平滑的正弦波可以让声音从单调的方波变成接近真实乐器的音色。更进一步可以采集不同乐器的采样波形存入Flash播放音符时直接调用对应的采样这就变成了一个简易的波表合成器。第二个方向是增加OLED显示界面。用I2C接口的0.96寸OLED实时显示当前状态是演奏模式还是播放模式、显示当前音符的简谱或英语名称、显示的播放进度。这会让作品瞬间有了产品感也适合拿去做课程设计或毕业设计展示。第三个方向是加入音色控制。比如加上一个旋钮调节音量和速度或者加上一个按键切换不同的音阶调式C大调、G大调等。这些功能本质上都是在已有的状态机架构上增加状态和参数不需要改动底层发声引擎恰好验证了你对软件分层设计的理解有没有到位。我个人建议你优先尝试音质升级这条路因为它在硬件上增加的东西不多一个DAC模块、一个功放模块、一个小喇叭但收获的成就感会大得多。听着自己焊的板子演奏出接近八音盒音色的旋律那种满足感是光看仿真跑通完全比不上的。做完这个项目你也会发现STM32嵌入式开发并没有多神秘无非是理解清楚什么功能对应什么外设、什么中断、什么数据格式然后把他们组装起来。每一次烧录调试、每一次用示波器抓到正确的波形都是在帮助你建立对这一行的真实手感。希望这篇分享能帮你少走一些弯路也期待看到你的版本有什么不一样的想法。