简介:本资源是一套基于STM32单片机开发的多功能电子琴完整工程,面向嵌入式初学者与课程设计实践者,解决音源生成、人机交互及音乐控制逻辑等典型嵌入式应用问题。项目通过STM32定时器PWM驱动无源蜂鸣器发声,配合独立按键实现弹奏、播放、暂停、切歌等核心功能,涵盖硬件原理图与Keil MDK工程源码,适合作为单片机外设综合实训案例。压缩包共169个文件,含47个.h头文件(定义外设配置与功能接口)、20个.c源文件(含HAL库驱动与主控逻辑)、1个.schdoc原理图、1个.prjpcb工程及.hex可烧录文件,另有调试配置、编译日志与说明文档,总大小10.6MB。已有1368人学习下载,资源结构规范,包含完整工程框架、TIM/PWM底层配置、按键消抖与状态机管理代码,并附带实操演示mp4视频,便于理解音乐节奏控制与时序协调机制。 其实最开始做这个项目的时候,我并没有想过要把电子琴做成什么“高大上”的东西,纯粹是因为手头有一块吃灰很久的STM32F103C8T6最小系统板,加上想系统地把定时器、GPIO、中断这些外设串在一起练一练。结果做着做着,发现这个“基于STM32设计的多功能电子琴”能玩出的花样比想象中多得多——除了按键发声,还能加录音回放、自动演奏、OLED显示、八度切换。而且整个项目用到的都是STM32最基础的资源,对刚学完单片机、想找一个综合训练项目的朋友来说,是一个非常合适的练手选题。这篇文章我就把这个项目的完整思路、原理图设计、源代码关键逻辑,以及我在调试中踩过的坑全部整理出来,给后面做类似项目的人一个可以直接“抄作业”的参考。
1. 项目整体设计与功能拆解
1.1 为什么选STM32F103C8T6而不是51单片机
我做之前其实纠结过一阵子,要不要用STC89C52这种51内核的芯片来做,毕竟网上电子琴的51版本一抓一大把。但认真评估之后还是选了STM32F103C8T6,核心原因有三个:定时器资源、PWM精度、后期扩展空间。
先说定时器。电子琴发声的核心是生成不同频率的方波或PWM信号,而STM32F103C8T6内部有TIM1(高级定时器)、TIM2、TIM3、TIM4等多个定时器,可以独立输出多路PWM,还能通过修改自动重装载值(ARR)快速调整输出频率。51单片机虽然也能用定时器中断翻转IO口来发声,但CPU会被占得比较死,一旦你要同时做按键扫描和录音存储,非常容易卡顿。
再说精度。STM32的定时器是16位的,配合72MHz系统时钟,分频后可以得到从几赫兹到几十千赫兹的精准频率。像标准A4音是440Hz,按十二平均律算C4大约是261.63Hz,对这些频率做到个位数误差完全没问题。51的定时器是8位,要么溢出率不够,要么频率精度做不上去,弹高音的时候音准漂移会很明显。
最后就是扩展性了。这个项目做完之后,如果你想加上OLED显示当前音符、用ADC做音量调节、通过串口和上位机交互,STM32F103C8T6的64KB Flash、20KB RAM、丰富的外设接口都能轻松扛住。而51的RAM通常只有128字节到512字节,存一小段录音就爆了。所以如果你希望这个项目“做完还能接着升级”,STM32是不二之选。
1.2 “多功能”具体包含哪些功能模块
这个项目的“多功能”不是我拍脑袋吹出来的,而是把电子琴常见的功能需求梳理了一遍之后选出的四个最有代表性、又都能在单片机资源范围内实现的方向:
| 功能 | 实现方式 | 对应硬件模块 |
|---|---|---|
| 实时弹奏 | 检测矩阵按键,输出对应频率PWM | 4×4按键矩阵、定时器PWM |
| 八度切换 | 按键对音符频率进行倍频/半频 | 独立功能按键 |
| 录音回放 | 将按键和时长存入数组,回放时重新触发 | 内部SRAM、定时器中断 |
| 自动演奏 | 预置曲谱数据,按节拍依次播放 | 内部Flash存储曲谱 |
| 信息显示 | 显示当前音符、模式、播放状态 | OLED屏或数码管 |
选这几项是有讲究的。实时弹奏是电子琴的基本盘,八度切换能让你用有限数量的物理按键覆盖更广的音域,录音回放考验存储和状态机设计,自动演奏则把音符频率表和节拍定时这两个核心逻辑都串起来了。一句话说,做完这个项目,你对“单片机怎么把一个物理操作变成声音输出”的整个链路会有非常清晰的认识。
2. 硬件设计:原理图与模块化思路
2.1 电源与主控最小系统设计
STM32F103C8T6的工作电压是2.0V到3.6V,一般用3.3V供电。我的方案是USB的5V输入经过AMS1117-3.3稳压芯片降到3.3V,同时在输入和输出端各加一个10uF钽电容和0.1uF陶瓷电容做滤波。很多新手容易忽略这些电容,觉得“不就是供电吗”,但实际上STM32内部逻辑翻转频率很高,瞬间电流需求大,如果电源纹波大,轻则ADC采样不准,重则芯片直接复位。
主控最小系统里还有几个要注意的点。首先是8MHz晶振,必须并联两个20pF左右的负载电容,电容值按晶振datasheet要求来,接长了容易不起振。其次是复位电路,用10k电阻上拉到3.3V、0.1uF电容下拉到地,NRST引脚上电瞬间拉低复位,这个是最经典的RC复位电路。然后是BOOT0引脚,正常工作必须通过10k电阻下拉到地,否则芯片上电会进入Bootloader模式,程序跑了没反应。最后是每个VDD引脚旁边都要放一个0.1uF去耦电容,并且要尽量靠近芯片引脚放置,这是PCB布线的常识,很多手焊的板子在这上面吃过亏。
如果你是用现成的STM32F103C8T6最小系统板,这部分可以省心,但如果是自己画PCB,一定把最小系统画对,因为这是整个项目的地基,地基不稳后面全是问题。
2.2 按键矩阵电路设计
考虑到按键数量,我用的是4×4矩阵扫描方式,一共16个琴键,加上4个独立功能键。矩阵的接法是4根行线接STM32的PB0~PB3,4根列线接PA0~PA3。行线设置为推挽输出,列线设置为上拉输入。扫描的时候按行依次拉低,再读取所有列的电平,如果某一行被拉低且某一列读到低电平,就说明该行和该列交叉位置的按键被按下了。
连接方式很简单,但原理图设计里有两个细节值得注意。
第一个细节是上拉电阻。列线用STM32内部上拉就能工作,但如果你发现按键按下时电平跳变不明显,或者误触很严重,可以在每根列线外部再加一个10k上拉到3.3V,增强抗干扰能力。我用的是内部上拉,实测足够稳定,但外部上拉的余量更大。
第二个细节是串联电阻。每个按键两端不直接接GPIO,而是串一个100Ω到330Ω的电阻再接入MCU。这个电阻的作用是限制静电放电对GPIO的冲击,也能在按键接线的瞬间抑制毛刺电流。虽然不串也能跑,但做产品化和可靠性测试的时候这个电阻是省不得的。
功能键我用了4个独立按键,分别接PB4、PB5、PB8、PB9,同样每个按键串一个100Ω电阻,另一端接GND,GPIO配置为上拉输入。按下为低电平,松开为高电平,逻辑非常直观。
2.3 音效输出:PWM+DAC滤波+功放方案
电子琴的声音输出是整个项目最核心的模拟链路。我不建议直接用有源蜂鸣器,因为那玩意儿内部带了振荡源,只能发固定频率的声音,根本无法随按键改变音调。正确做法是:用STM32的定时器输出可调频率的PWM方波,经过RC低通滤波把方波变成近似正弦的柔和声音,再通过功放芯片驱动扬声器。
STM32的PWM输出引脚我选的是PA8,这是TIM1_CH1的输出通道。TIM1是高级定时器,输出PWM时需要额外配置刹车和死区相关的寄存器,用STM32CubeMX初始化比标准库手动写要省事很多。PWM信号经过一个一阶RC低通滤波器,R用100Ω、C用100nF,可以算出截止频率大约是16kHz,人耳听觉范围是20Hz到20kHz,音乐信号主要集中在几百赫兹到几千赫兹,16kHz的截止能保留有效频段同时滤掉PWM的高频载波。这个值我调过,太大则声音发毛,太小则高音衰减明显,100Ω加100nF是折中后比较满意的一组。
滤波后接功放,我用的是LM386,这是最经典的低功率音频放大器,供电5V,外围只需要几个电容电阻就能工作。1脚和8脚之间接一个10uF电容可以把增益从默认的20倍提高到200倍,但实测200倍增益在5V供电下容易削波失真,所以我保留默认20倍,音量靠电位器分压调节。功放输出接一个8Ω/1W的小扬声器,听感上低频稍微薄一点,但做电子琴演示完全够用。
3. 核心代码实现:从音符频率到发声
3.1 音符频率表与十二平均律
电子琴发声的本质是让扬声器振动特定频率的空气,所以第一步就是建立“按键到频率”的映射关系。国际标准规定A4(钢琴中央A)的频率是440Hz,其余音符按照十二平均律计算,相邻半音之间的频率比是2的1/12次方,也就是大约1.059463。比如C4(中央C)距离A4有9个半音,频率就是440 / 2^(9/12) = 261.63Hz。
虽然可以在代码里实时计算,但我强烈建议直接查表。MCU做浮点运算的开销不低,而且你需要在中断或主循环里频繁调用,查表是零延迟的。我做过一个MIDI音符表,把C4到B5共24个半音的频率值预先算好,用const数组存在Flash里。下面是表格的一部分:
// 音符频率表,索引0对应C4,索引11对应B4,依此类推 const uint16_t note_freq[] = { 262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494, // C4 ~ B4 523, 554, 587, 622, 659, 698, 740, 784, 831, 880, 932, 988, // C5 ~ B5 };这个表里数值我用的是四舍五入后的整数,对听感来说误差远小于1%,完全够用。如果你的琴键跨度更大,可以按同样的公式扩展到C3到C6,甚至更大范围。八度切换的本质就是让当前按键对应的索引加12或减12,频率刚好翻倍或减半,听感上是同一个音升高或降低了一个八度。
3.2 定时器PWM配置与发声函数
我用的是TIM1_CH1来输出PWM。TIM1挂在APB2总线上,时钟是72MHz。要让PWM输出指定频率,需要先设置预分频器PSC和自动重装载值ARR,计算公式是:
PWM频率 = 定时器时钟 / ((PSC + 1) × (ARR + 1))
我的做法是把PSC固定为71,这样定时器计数时钟就是72MHz / 72 = 1MHz,也就是计数器每1微秒加1。那么要得到262Hz的C4,ARR = 1,000,000 / 262 - 1 ≈ 3815。用代码封装一个函数,每次传入频率就重新计算ARR并更新:
void set_tone(uint16_t freq) { if (freq == 0) { __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, 0); // 静音 return; } uint32_t arr = 1000000 / freq - 1; __HAL_TIM_SET_AUTORELOAD(&htim1, arr); __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, arr / 2); // 50%占空比 }这里占空比设成50%,方波音色最饱满,谐波分量也比较丰富,听着像传统电子琴的音色。如果占空比调小,声音会偏“薄”,有类似拨弦的效果,感兴趣的可以自己试,这也是后面做音色切换的入口。
需要注意一点:ARR是16位寄存器,最大值是65535。我们这个公式下最低能输出的频率大约是1,000,000 / 65535 ≈ 15Hz,远低于电子琴最低音,所以不用担心超出范围。但如果你把PSC调小了,或者希望输出更低频,就一定要检查ARR是否溢出。
3.3 矩阵按键扫描与防抖状态机
按键扫描我放在了主循环里轮询,没有用外部中断,原因是琴键按下时需要快速响应,但中断频繁触发会干扰PWM定时器,反而影响发声稳定性。主循环每10ms跑一圈扫描,完全能满足演奏需求。
矩阵扫描的核心逻辑是:依次将PB0到PB3中的一行拉低,其他行保持高电平,然后读取PA0到PA3的电平,任一为低说明该列有按键按下。把行索引和列索引组合起来,就能唯一确定一个琴键。
按键抖动是个绕不开的问题。机械按键在按下和松开的瞬间,电平会产生几毫秒到十几毫秒的毛刺,如果不做处理,按一次可能被识别成好几次,弹出来的音就会“抖”。我的做法是20ms软件延时消抖,具体实现是:检测到电平变化后,延时20ms再读一次,如果状态一致就认为有效。
为了给录音功能打基础,我把按键检测改成了带边缘触发的状态机。核心变量是当前按键值和上一次按键值,只有当前按下且上一次没按下,才认为产生了一次新的“按下事件”。代码如下:
uint8_t key_scan(void) { uint8_t current = read_matrix(); // 读取当前按键,无按键返回0xFF uint8_t event = 0xFF; if (current == last_key) { return 0xFF; // 状态未变化 } HAL_Delay(20); current = read_matrix(); // 消抖后重新读取 if (current != last_key) { if (current != 0xFF) { event = current; // 产生按下事件 } last_key = current; } return event; }这种设计的好处是,录音时可以只记录这个“按下事件”和它的时间戳,回放时按同样的事件和时间间隔去触发发声,逻辑会非常清晰。
3.4 录音回放与自动演奏的实现
录音功能看起来高大上,本质上就是一个带时间戳的事件记录器。我用一个结构体数组来存储音符和时长:
typedef struct { uint8_t note; // 音符索引,0代表无音(休止符) uint16_t duration; // 持续时间,单位ms } MusicNote;开始录音后,每次检测到按键事件就把当前音符和该音符持续的时间写入数组,按键松开或进入下一个按键时,把上一段时长封存。这样弹完一段旋律,数组里就存了一串音符和时值。回放时,创建一个播放状态机,定时器中断每1ms递减一个计数器,计数器归零就播放数组中的下一个音符,直到数组结束。这个方案实现简单,而且录完马上就能听效果,成就感很强。
自动演奏则完全复用回放逻辑,区别只是曲谱数据不用现场录,而是预先把几首内置乐曲的“音符+时值”数组烧进Flash。曲谱的编写格式用我上面定义的结构体,比如《小星星》的一段就是:
const MusicNote twinkle[] = { {0, 250}, {0, 250}, {4, 250}, {4, 250}, {7, 250}, {7, 250}, {9, 500}, // 简谱:1 1 5 5 6 6 5 -(需要把简谱映射为音符索引) };写曲谱最花时间的就是把简谱一个个映射成音符索引,我的建议是先写好映射表再批量填,减少出错概率。另外,曲谱数组的结束一定要放一个{0xFF, 0}作为结束标记,不然回放不知道什么时候停下,会把Flash后面垃圾数据也当音符播出来,声音会非常诡异。
4. 显示模块与交互逻辑设计
4.1 OLED显示当前音符与模式
想要知道当前按的是哪个音、处于什么模式,光靠耳朵听其实不够直观,所以我加了一个0.96寸的OLED屏,接口是I2C,IC是SSD1306,这是做STM32小项目最常见也最便宜的显示方案,几块钱一片,驱动代码网上遍地都是,移植成本很低。
OLED的I2C我用的是PB6(I2C1_SCL)和PB7(I2C1_SDA)。选择这两个引脚是因为I2C1刚好默认映射在这俩脚上,不需要配置重映射,减少出错概率。注意PB6和PB7是开漏输出,必须外部接上拉电阻,一般OLED模块板上已经自带了4.7k上拉,所以不用额外加。
显示内容我规划了三块区域:第一行显示当前模式(PLAY、REC、AUTO PLAY),第二行显示当前音符名,比如C4、E5,第三行显示音量或者八度数字。刷新频率不需要太高,200ms刷一次就够,因为人的肉眼对慢速变化的文本已经非常敏感了,刷太快反而占用主循环时间,影响按键扫描的响应速度。
SSD1306驱动里有一个需要注意的地方,它内部是1KB的GDDRAM,你要把要显示的内容先写入一个缓存数组,再通过I2C一次性刷到屏幕。如果逐像素直接写,I2C通信会被撕碎成几十次小传输,屏幕会闪烁,所以务必要用“缓存+整屏刷新”的模式。
4.2 操作流程与功能按键设计
4个功能键的用途我做了明确分工:K1切换模式,K2开始/停止录音,K3播放录音,K4八度切换。这里有一个交互上的小设计:模式并不是循环切换,而是按一下K1进入录音模式,再按一下回到弹奏模式,中间通过OLED上的状态提示来引导用户。这样比单个按键循环切模式直观得多,不会让用户搞不清自己到底在哪个状态。
整个操作流程是这样的:上电默认弹奏模式,直接按16个琴键就能发声。按下K1进入录音待机状态,OLED显示REC,这时弹奏的每个音符都会被记录。再按K1退出录音,按K3开始回放刚才的旋律。在弹奏模式按K4,八度在+1、0、-1之间循环,OLED同步显示当前八度。这个流程我让几个朋友试过,基本不需要说明书就能上手,对嵌入式项目来说交互做到这个程度就算合格了。
5. 调试过程与常见问题排查实录
5.1 上电没声音或声音断续
这个是我遇到最多的现象,排查的思路一定要按信号链从后往前捋。先看功放输入端有没有信号,用示波器或者万用表交流档测LM386的3脚,如果这里有PWM波形但扬声器不响,问题在功放电路;如果没有波形,问题在主控或滤波前端。
我早期犯过一个低级错误——PA8的GPIO复用没配对。STM32的引脚不是你想输出PWM就能输出,必须把GPIO模式设成AF_PP(复用推挽)并选择对应的复用功能。用STM32CubeMX的时候,你在Pinout界面把PA8选为TIM1_CH1,它会自动配好,但如果你手动写寄存器初始化,很容易只配置了GPIO而忘了选复用功能,结果PA8一直输出高电平,扬声器只有上电的“啪”一声。
还有一个坑是PWM比较值CCR设成了0。我最初测试发声函数时,忘记在静音分支把CCR清零,而是在静音时直接关闭定时器,结果重新开启定时器后,因为ARR已经改过了,CCR还是老的,输出频率和占空比就乱了。用上面代码里的写法,静音就设CCR=0,发声就设CCR=ARR/2,不要在定时器使能上反复横跳,省心很多。
5.2 声音发闷、高音缺失
如果你感觉声音闷闷的,弹高音时音量明显比低音小,十有八九是RC低通滤波的截止频率设太低了。方波本身包含大量高频谐波,这是它听感“亮”的原因,如果把截止频率压到1kHz以下,高音的基波都会被削掉,自然就闷了。
我的调试经历是这样的:最开始用R=10k、C=10uF,算出来的截止频率才1.6Hz,基本把音频信号全滤光了,出来的声音只有微弱的“噗噗”声。后来才意识到这里不是要滤掉方波的高次谐波,而是要滤掉PWM载波频率。TIM1的PWM载波频率在ARR几百的时候大约是1MHz/PSC,远超20kHz,所以RC的截止频率设在15kHz到20kHz之间是合理的,最终R=100Ω、C=100nF就是按这个思路定下来的。
调试高频缺失的时候,建议直接用示波器看LM386输入端的波形。当频率到2kHz以上时,如果你看到正弦波顶部变得圆滑、幅度明显下降,那就是滤波把这个频段吃掉了。把截止频率提高,或者干脆把RC滤波改成二阶,高频衰减会改善很多。
5.3 按键误触和“跳音”
按键误触有两种情况:一种是没有按却自己发声,另一种是按一个音结果跳到了别的音。前者大概率是上拉电阻没配置好,GPIO悬空导致电平漂移;后者则是矩阵扫描逻辑有bug或者行线和列线序搞错了。
我的排查方法是写一个自检程序:把16个矩阵按键全部轮询一遍,串口打印出“行-列”坐标,逐个按键测试,看坐标是否和预期一致。这套方法帮我抓到一个非常隐蔽的问题——我把行线和列线接反了,导致扫描时读到的矩阵是镜像的,按左上角的键实际触发的是右下角的音。PCB布线的时候不仔细看丝印很容易犯这个错,所以原理图阶段一定把网络标号写清楚,焊接完先跑自检再装外壳。
按键消抖时间也有讲究。消抖太短(比如5ms)按键容易重复触发,消抖太长(比如50ms)演奏快速音阶时会感觉卡顿。我最终用的是20ms,在稳定性和手感之间比较平衡。注意消抖延时不要用HAL_Delay阻塞实现,否则主循环会被卡住,建议改成“检测到变化→记录时间戳→时间到了再确认”的非阻塞方式。我的代码为了演示简单用了HAL_Delay,但如果你想让演奏手感更好,建议改成状态机加定时器计数。
6. 源码工程结构与复现指引
最后说一下工程的结构,方便你拿到手能快速看懂。我用的是STM32CubeMX生成HAL库工程,IDE是Keil MDK,整个工程目录分为三层:Core(主函数、中断服务函数)、Drivers(HAL库)、App(我自己的应用代码)。
App目录下有几个关键文件:key_scan.c负责矩阵按键和功能键扫描,music.c负责音符频率表、发声、录音回放,oled.c负责屏幕显示,main.c里是主循环和状态机流转。如果后面有人想基于这个项目扩展,我的建议是先看music.c,因为所有跟声音相关的核心逻辑都在这里,理解了它,整个项目的骨架也就清楚了。
工程配置上有一个关键点:TIM1的时钟源要选内部时钟,通道1要选PWM Generation CH1,否则CubeMX不会自动生成PWM配置代码。另外,定时器的预分频PSC我是在MX_TIM1_Init里手动改成71的,CubeMX默认是0,如果你直接编译不修改,初始PWM频率会高出几十倍,声音非常刺耳。这个细节我在代码注释里写了加粗加亮,但很多人还是会漏掉。
说句实在话,这个电子琴项目我前后折腾了将近一周,真正写原理图和代码只用了两天,剩下时间全花在调试声音和按键稳定性上。但恰恰是这些调试过程让我把STM32的定时器、GPIO复用、PWM输出这几个知识点嚼碎了。如果你也准备动手做,我的建议是先别急着追求功能多,把发声链路调通、把按键扫描调稳,再去加录音和自动演奏。另外一个小技巧:调试阶段用串口把按键状态和频率值打印出来,比插着示波器效率高得多,我后面几轮调试基本全靠串口定位问题。这个项目后续我还打算加个通过电位器调节音量的ADC通道,再扩展成支持双音同时发声,不过那是另一个故事了。
本文还有配套的精品资源,点击获取