STM32定时器PWM驱动电子琴:从GPIO输入到音频输出的嵌入式综合实践
1. 项目缘起从按键到音符的跨越很多朋友刚开始玩STM32都是从点灯开始的紧接着就是按键控制。当你熟练掌握了GPIO的输入输出看着LED随着按键的按下而明灭成就感是有的但总觉得少了点意思——这和我们小时候玩的“小霸王”游戏机手柄有什么区别无非是把物理开关的电信号转换成了芯片能识别的数字信号而已。今天我们就来做点不一样的。我们不满足于仅仅控制一个LED的亮灭我们要用STM32配合几个简单的按键开关来制作一个简易的电子琴。这个项目听起来有点“跨界”但它恰恰是嵌入式开发中一个非常经典的综合性实验。它把GPIO输入、定时器、PWM脉冲宽度调制这几个看似独立的知识点巧妙地串联在了一起。你不再是在操作一个“开关”而是在通过代码“演奏”音乐。这个项目的核心逻辑是这样的我们将几个普通的机械按键定义成电子琴上不同的“琴键”。当你按下某个按键时STM32需要识别是哪个键被触发了。然后根据音乐知识每个琴键对应一个特定的频率比如中央C是261.63Hz。最后STM32需要驱动一个发声装置比如无源蜂鸣器或者通过功放接喇叭以这个特定的频率持续振动直到你松开按键。声音就产生了。整个过程GPIO负责“感知”你的手指动作定时器负责产生精确的“时间基准”而PWM则负责将数字信号转换成可以驱动扬声器振动的“模拟量”信号。通过STM32CubeMX这个强大的图形化配置工具我们可以像搭积木一样把这些功能模块配置好生成基础的代码框架然后我们只需要专注于核心的音乐逻辑和按键处理即可。这比从头开始写寄存器要高效、清晰得多尤其适合初学者快速建立系统性的概念。2. 硬件设计与核心元件选型在动手写代码之前合理的硬件设计是项目成功的基石。我们这个简易电子琴的硬件结构非常简单主要包含三大部分主控芯片、输入设备和输出设备。2.1 主控芯片STM32F103C8T6核心板我选择的是堪称“嵌入式界网红”的STM32F103C8T6核心板也就是大家常说的“蓝色药丸”或“最小系统板”。选它的理由很充分价格极其亲民通常十几块钱资源对于本项目绰绰有余72MHz主频64KB Flash20KB RAM外设丰富拥有多个通用定时器都支持PWM输出而且社区资料浩如烟海遇到问题几乎都能找到答案。它自带了晶振、复位电路和USB转串口芯片我们只需要通过Micro USB线供电和下载程序即可极大降低了硬件门槛。2.2 输入设备轻触按键与电阻琴键我们使用最普通的4脚轻触按键。为什么是4脚其实内部是两两相连的相当于一个开关的两端。为了构成一个有效的输入电路我们需要配合“上拉电阻”或“下拉电阻”。这里我选择使用“上拉电阻”模式。具体接法是按键的一端连接到STM32的某个GPIO引脚如PA0另一端连接到GND地。同时在PA0引脚与3.3V电源之间连接一个10kΩ的电阻。这个电阻就是上拉电阻。它的工作原理是当按键没有按下时由于上拉电阻的存在PA0引脚被“拉”到了高电平3.3VSTM32读取到的就是“1”当按键按下时按键相当于一根导线将PA0引脚直接与GND接通此时引脚电平被“拉低”到0VSTM32读取到的就是“0”。这样我们就通过电平的高低变化检测到了按键动作。注意STM32的GPIO引脚内部可以配置成上拉或下拉模式这样我们就可以省去外部电阻。但在实际项目中尤其是面对可能存在的静电干扰时外加一个物理电阻通常更可靠。对于学习而言我们优先使用内部上拉简化电路。我们需要多个按键来对应不同的音符。例如我们可以用PA0, PA1, PA2, PA3, PA4, PA5, PA6这7个GPIO来接7个按键构成一个八度音阶Do, Re, Mi, Fa, So, La, Si。2.3 输出设备无源蜂鸣器与三极管要让STM32“唱歌”我们需要一个电声转换器件。这里有两个常见选择有源蜂鸣器和无源蜂鸣器。有源蜂鸣器内部自带振荡电路通电就响声音频率固定。它本质上是一个“数字”器件只能控制响或不响无法控制音调。所以它不适合做电子琴。无源蜂鸣器内部没有振荡源相当于一个微型喇叭。它需要外部提供一定频率的交流信号方波才能发声声音的频率完全由外部驱动信号的频率决定。这正是我们需要的因此我们必须选择无源蜂鸣器。但是STM32的GPIO引脚驱动能力有限通常只能输出几十mA电流而蜂鸣器工作可能需要更大的电流。直接驱动可能导致声音小、芯片发热甚至损坏。解决方案是增加一个简单的“三极管驱动电路”。我们使用一个普通的NPN型三极管如S8050。接法如下STM32的PWM输出引脚如PA8通过一个1kΩ的限流电阻连接到三极管的基极B。蜂鸣器的正极连接到电源比如5V负极连接到三极管的集电极C。三极管的发射极E连接到GND。这样当STM32的PWM引脚输出高电平时三极管导通蜂鸣器两端形成电压差电流流过蜂鸣器发声输出低电平时三极管截止蜂鸣器不发声。通过PWM波形的频率控制就控制了蜂鸣器振动的频率从而产生不同音调。1kΩ的电阻是为了限制流入基极的电流保护STM32引脚和三极管。2.4 整体连接示意图总结一下硬件连接STM32F103C8T6核心板通过USB供电。按键K1-K7分别连接PA0-PA6另一端统一接GND。GPIO配置为内部上拉输入模式。无源蜂鸣器正极接5V负极接三极管集电极。三极管S8050基极通过1kΩ电阻接STM32的PA8定时器通道发射极接GND。STM32的PA8引脚配置为PWM输出模式。3. 软件核心音符频率与定时器PWM的映射硬件准备好了接下来是软件的灵魂如何让定时器产生对应音符频率的PWM波。3.1 音符与频率的对应关系音乐中每个音名都有其标准的振动频率。国际上将小字一组的A即钢琴中央C右边的La定为440Hz其他音符的频率按十二平均律推算。对于我们这个简易七音阶电子琴我们可以直接使用一组常用的频率值单位Hz音名简谱频率 (Hz)CDo262DRe294EMi330FFa349GSo392ALa440BSi494这些频率值就是我们的目标。当按下“Do”键时我们需要让蜂鸣器以262Hz的频率振动。3.2 PWM如何产生特定频率的声音PWM波有两个关键参数频率Freq和占空比Duty Cycle。对于驱动蜂鸣器发声占空比固定为50%即高电平和低电平时间各一半时声音最纯净、效率也较好。我们需要调节的是PWM的频率让它等于目标音符的频率。那么STM32的定时器如何产生一个特定频率的PWM呢这涉及到定时器的几个核心寄存器预分频器PSC、自动重装载寄存器ARR和捕获/比较寄存器CCR。定时器时钟CK_CNT首先定时器有一个输入时钟比如STM32F103在默认配置下APB1总线上的定时器时钟是72MHz。预分频器PSC这个72MHz的时钟太快了我们需要先对它进行分频。PSC是一个16位的寄存器写入值PSC则实际分频系数为PSC1。所以分频后的时钟F_{CK_CNT} F_{CK_PSC} / (PSC 1)。自动重装载寄存器ARR这是定时器的周期。计数器从0开始向上计数每来一个F_{CK_CNT}时钟就加1一直加到ARR的值然后产生一个更新事件UEV计数器归零重新开始。这样一个从0到ARR的计数过程就是一个PWM周期。PWM频率公式综合以上PWM的输出频率为F_{PWM} F_{CK_CNT} / (ARR 1) F_{CK_PSC} / [(PSC 1) * (ARR 1)]我们的目标F_{PWM}就是音符频率如262Hz。F_{CK_PSC}是已知的72MHz。现在的问题就是如何选择一对合适的PSC和ARR值。3.3 参数计算与精度权衡ARR和PSC的值都是整数而72MHz除以262Hz得到的并不是一个整数。所以我们需要找一个近似的、合理的整数对。首先我们确定一个思路为了让PWM分辨率尽可能高即ARR尽可能大我们通常先设定一个较大的ARR值然后反推PSC。PWM的分辨率可以理解为占空比调节的精细程度分辨率1/(ARR1)。虽然我们占空比固定50%但高ARR值意味着计数器计数更慢对于频率的微调更有利。例如我们设定ARR 999。那么根据公式(PSC 1) F_{CK_PSC} / [F_{PWM} * (ARR 1)] 72,000,000 / (262 * 1000) ≈ 274.8所以 PSC 274 (取整)。此时实际的PWM频率为F_{PWM_实际} 72,000,000 / ((2741) * 1000) 72,000,000 / 275,000 ≈ 261.82 Hz这个值261.82Hz与目标262Hz的误差极小人耳几乎无法分辨。我们可以用这个方法为每个音符计算出一组PSC, ARR值。但是这里有一个更巧妙也更常用的方法固定PSC和ARR只改变一个叫做“捕获/比较寄存器CCR”的值来改变频率。等一下CCR不是控制占空比的吗是的但那是对于固定频率的PWM而言。如果我们想用CCR来改变频率需要用到定时器的另一种模式PWM输出模式1但结合每次比较匹配后重置计数器的操作不这并不标准。更标准且简单的做法是我们固定PSC和ARR将其设置为能产生一个基准高频PWM比如1MHz。然后我们并不直接用这个PWM驱动蜂鸣器。而是将这个PWM输出作为一个“开关信号”去控制一个GPIO引脚的高低电平翻转。我们通过软件根据音符频率计算出这个“开关信号”需要翻转的间隔时间用另一个定时器或者系统滴答定时器SysTick来精确控制翻转。这种方法更灵活但软件开销大。实际上对于这种简单应用最直接高效的方法就是为每个音符预先计算好ARR值PSC固定。在按键按下时动态改变定时器的ARR寄存器。因为改变ARR会立刻影响下一个周期的频率响应很快。我们来统一一下固定PSC 71。这样分频后的计数器时钟F_{CK_CNT} 72MHz / (711) 1MHz。也就是说计数器每1微秒加1。那么对于频率FHz其周期T 1/F 秒。计数器需要计数的次数即ARR值为ARR T / (1 / F_{CK_CNT}) F_{CK_CNT} / F - 1。以Do (262Hz)为例ARR 1,000,000 / 262 - 1 ≈ 3816.79 - 1 ≈ 3815实际频率F_实际 1,000,000 / (3815 1) ≈ 262.0 Hz非常精确我们可以预先计算好一个数组const uint16_t note_freq_arr[] { 3815, // 262Hz - Do 3400, // 294Hz - Re (计算: 1,000,000/294 -1 ≈ 3400.3) 3030, // 330Hz - Mi (计算: 1,000,000/330 -1 ≈ 3030.3) 2864, // 349Hz - Fa 2550, // 392Hz - So 2272, // 440Hz - La 2024, // 494Hz - Si };这样当按下对应按键时我们只需要将定时器的ARR值更新为数组中的对应值PWM的输出频率就会立即改变。4. 使用STM32CubeMX进行图形化配置理论分析完毕现在开始实战。我们打开STM32CubeMX新建一个工程选择STM32F103C8Tx。4.1 时钟树配置这是确保定时器时钟准确的基础。在Clock Configuration标签页。在HSE外部高速时钟选择Crystal/Ceramic Resonator。将HCLK系统时钟设置为72MHz。通常直接输入72然后回车软件会自动帮你配置PLL倍频系数。确认APB1 Timer clocks和APB2 Timer clocks的时钟频率。在默认配置下当APB1预分频器不为1时其上的定时器时钟会是APB1时钟的2倍。我们保持默认可以看到APB1总线时钟是36MHz而APB1 Timer Clocks显示为72MHz。这正是我们需要的定时器时钟源。4.2 GPIO配置在Pinout Configuration标签页的左侧找到GPIO选项。配置按键引脚PA0-PA6点击芯片图上PA0引脚选择GPIO_Input。在左侧System Core-GPIO中点击刚配置的PA0在右侧详情页中将GPIO Pull-up/Pull-down设置为Pull-up上拉模式。这样软件上就省去了外部上拉电阻。同理将PA1到PA6也配置为GPIO_Input和Pull-up模式。配置PWM输出引脚PA8点击芯片图上PA8引脚你会发现它有很多复用功能。选择TIM1_CH1。这意味着PA8引脚将被用作定时器1TIM1的通道1输出。在左侧Timers-TIM1中会自动出现配置项。4.3 定时器TIM1配置定时器1是一个高级控制定时器功能强大完全满足我们的需求。在Timers-TIM1配置中首先选择Clock Source为Internal Clock内部时钟。在下方Parameter Settings标签页中Prescaler (PSC - 16 bits value)这里填入我们之前计算好的预分频值71。Counter Mode选择Up向上计数模式。Counter Period (AutoReload Register - 16 bits value)这里先填入一个初始值比如999。这个值会在程序运行时被动态修改。注意ARR是一个16位寄存器最大值65535我们计算的值最大3815远小于此完全没问题。Internal Clock Division (CKD)保持默认No Division。auto-reload preload建议使能Enable。这会在更新ARR值时等到下一个更新事件时才生效防止当前周期被意外截断使输出更平滑。切换到PWM Generation CH1子标签页或在Parameter Settings页面向下滚动Mode选择PWM Mode 1。Pulse (16 bits value)这个值就是捕获/比较寄存器CCR1的值它决定了占空比。因为我们要50%占空比所以这里应该设置为(Counter Period / 2)。但由于Counter PeriodARR是动态变化的我们不能在这里写死。一个简单的方法是先设置一个值比如500然后在代码中每次更新ARR后也同步更新CCR为ARR的一半。更专业的做法是将Pulse设置为0在代码中通过__HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, htim1.Init.Period / 2);来动态设置。Fast Mode禁用。CH Polarity选择High。这意味着当计数器小于CCR值时输出高电平大于CCR值时输出低电平。这就是PWM Mode 1。4.4 生成工程代码点击右上方的Project Manager标签页。Project子标签页设置项目名称、路径、IDE选择MDK-ARM V5即Keil。Code Generator子标签页这里有几个关键设置。Generated files建议勾选Generate peripheral initialization as a pair of ‘.c/.h’ files per peripheral。这样每个外设如GPIO、TIM1的初始化代码会单独放在一组文件中结构更清晰。HAL Settings勾选Enable Full Assert有助于调试但会增大代码量学习阶段可以勾选。点击GENERATE CODE生成Keil工程。5. 在Keil中编写核心业务逻辑打开生成的Keil工程我们的主要工作集中在main.c和gpio.c、tim.c这几个文件中。5.1 定义音符频率对应的ARR值表在main.c文件开始用户代码区/* USER CODE BEGIN PV */定义我们之前计算好的数组。/* Private variables ---------------------------------------------------------*/ /* USER CODE BEGIN PV */ // 音符频率对应的ARR值表 (PSC固定为71, 计数器时钟1MHz) const uint16_t Note_ARR_Table[] { 3815, // Do - 262Hz 3400, // Re - 294Hz 3030, // Mi - 330Hz 2864, // Fa - 349Hz 2550, // So - 392Hz 2272, // La - 440Hz 2024, // Si - 494Hz }; // 当前播放的音符索引-1表示静音 int8_t current_note -1; /* USER CODE END PV */5.2 按键扫描与消抖处理在main.c中我们需要一个函数来检测哪个按键被按下。这里涉及一个关键概念按键消抖。机械按键在按下和松开的瞬间金属触点会发生物理抖动导致电平在短时间内多次快速变化如果不处理单片机可能会误判为多次按下。我们采用简单的“延时消抖法”。在/* USER CODE BEGIN 4 */区域添加一个函数/* USER CODE BEGIN 4 */ /** * brief 扫描7个按键返回被按下的按键索引(0-6)若无按键按下返回-1。 * note 使用了简单的延时消抖。 */ int8_t Key_Scan(void) { static uint8_t key_state[7] {0}; // 按键状态机状态 static uint32_t key_press_time[7] {0}; // 按键按下时刻 const uint32_t DEBOUNCE_TIME 20; // 消抖时间单位ms uint8_t i; int8_t key_pressed -1; uint32_t current_tick HAL_GetTick(); // 获取当前系统毫秒时钟 // 读取所有按键当前电平 (按下为0因为上拉) uint8_t key_pin_val[7]; key_pin_val[0] HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0); key_pin_val[1] HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1); key_pin_val[2] HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_2); key_pin_val[3] HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_3); key_pin_val[4] HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_4); key_pin_val[5] HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_5); key_pin_val[6] HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_6); for(i 0; i 7; i) { switch(key_state[i]) { case 0: // 状态0等待按键按下 if(key_pin_val[i] GPIO_PIN_RESET) // 检测到低电平按下 { key_state[i] 1; // 进入消抖确认状态 key_press_time[i] current_tick; // 记录按下时刻 } break; case 1: // 状态1消抖确认 if(key_pin_val[i] GPIO_PIN_SET) // 如果又变成高电平了说明是抖动回到状态0 { key_state[i] 0; } else if((current_tick - key_press_time[i]) DEBOUNCE_TIME) // 低电平持续超过消抖时间 { // 确认按键稳定按下 key_state[i] 2; // 进入按下保持状态 key_pressed i; // 返回被按下的按键索引 } break; case 2: // 状态2按下保持等待释放 if(key_pin_val[i] GPIO_PIN_SET) // 检测到高电平释放 { key_state[i] 0; // 回到初始状态等待下一次按下 } break; default: key_state[i] 0; break; } } return key_pressed; // 返回被按下的按键编号-1表示无按键按下 } /* USER CODE END 4 */这个函数实现了一个简单的状态机能有效消除按键抖动并且每次按下只返回一次按键索引避免长按重复触发。5.3 主循环逻辑在main函数的while (1)循环中我们整合所有逻辑。/* USER CODE BEGIN WHILE */ HAL_TIM_PWM_Start(htim1, TIM_CHANNEL_1); // 启动TIM1的通道1输出PWM // 初始设置一个占空比50%基于初始ARR值999 __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, htim1.Init.Period / 2); while (1) { /* USER CODE END WHILE */ /* USER CODE BEGIN 3 */ int8_t key Key_Scan(); // 扫描按键 if(key ! -1) { // 有按键按下 if(current_note ! key) // 如果按下的不是当前正在响的音符 { current_note key; // 更新当前音符 // 动态修改定时器的周期ARR以改变频率 __HAL_TIM_SET_AUTORELOAD(htim1, Note_ARR_Table[key]); // 同时需要更新捕获比较寄存器CCR以保持50%占空比 __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, Note_ARR_Table[key] / 2); // 注意修改ARR后有些HAL库版本需要手动触发一次更新事件以确保新参数生效 __HAL_TIM_GENERATE_SW_EVENT(htim1, TIM_EVENTSOURCE_UPDATE); } } else { // 没有按键按下 if(current_note ! -1) { current_note -1; // 标记为静音 // 停止PWM输出或者将ARR设为一个听不到的频率如1Hz // 方法1停止定时器声音立即停止可能有“咔”声 // HAL_TIM_PWM_Stop(htim1, TIM_CHANNEL_1); // 方法2将频率调到极低更柔和 __HAL_TIM_SET_AUTORELOAD(htim1, 99999); // 频率约10Hz人耳几乎听不到 __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, 49999); __HAL_TIM_GENERATE_SW_EVENT(htim1, TIM_EVENTSOURCE_UPDATE); // 注意如果使用方法2下次按键前需要重新启动PWM如果之前停止了 // HAL_TIM_PWM_Start(htim1, TIM_CHANNEL_1); } } HAL_Delay(10); // 主循环延时降低CPU占用 } /* USER CODE END 3 */这段代码的逻辑很清晰不断扫描按键如果检测到新的按键按下就根据按键编号从表中查找对应的ARR值更新定时器从而改变PWM频率。当所有按键都松开时将频率调至超低频或停止定时器来实现静音。6. 调试技巧与常见问题排查代码写完了编译下载但很可能第一次不会成功。以下是几个常见的坑和排查思路。6.1 没有声音这是最可能遇到的问题。检查硬件连接这是第一步也是最容易出错的一步。用万用表蜂鸣档确认蜂鸣器正负极是否接反三极管的E、B、C脚是否接错限流电阻是否虚焊STM32的PA8引脚是否确实连接到了三极管基极电阻检查供电无源蜂鸣器通常需要一定的电压和电流才能响亮发声。确保给蜂鸣器供电的5V电源有足够的电流能力500mA以上适配器较好。如果只用USB供电可能因为电流不足导致声音微弱。检查PWM输出使用示波器或者逻辑分析仪探头连接到PA8引脚。按下按键时应该能看到频率变化的方波。如果没有波形检查CubeMX中TIM1的配置是否成功生成特别是TIM1_CH1是否已分配到PA8。检查代码中HAL_TIM_PWM_Start函数是否被正确执行。检查__HAL_TIM_SET_AUTORELOAD和__HAL_TIM_SET_COMPARE函数是否真的改变了寄存器的值。可以在这些函数后加个断点查看htim1.Instance-ARR和htim1.Instance-CCR1的值。检查三极管驱动测量三极管集电极接蜂鸣器负极的电压。当PWM输出高电平时此处电压应接近0V三极管饱和导通低电平时应接近5V三极管截止。如果不是检查三极管型号和引脚。6.2 声音失真或杂音大占空比不是50%虽然我们代码里设置了CCR为ARR的一半但请确认计算是否正确。特别是当ARR是奇数时除以2会有舍入。可以尝试固定设置一个占空比比如__HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, 500);先听听声音是否变纯。电源噪声电机、蜂鸣器等器件工作时会对电源造成干扰。在蜂鸣器的电源两端并联一个100μF的电解电容和一个0.1μF的瓷片电容可以很好地滤除低频和高频噪声。机械共振蜂鸣器本身或它与电路板的固定方式可能会在某些频率产生共振杂音。尝试在蜂鸣器外壳贴一点海绵胶垫或者更换一个蜂鸣器试试。6.3 按键反应不灵或串音消抖时间不合适代码中的DEBOUNCE_TIME20ms是一个经验值。如果按键太“灵敏”一次按下触发多次可以增大这个值到30或50ms。如果反应迟钝可以减小到10ms。最好用示波器观察按键引脚的实际抖动时间。按键扫描逻辑冲突我们的Key_Scan函数一次只返回一个按键。如果同时按下两个键只会识别先扫描到的那一个。如果需要支持和弦多个音同时响需要修改逻辑用一个变量来记录所有按键的状态而不是返回单个索引。GPIO配置错误确认CubeMX中按键GPIO模式是Input并且上拉Pull-up已使能。如果配置成了浮空输入Floating引脚电平不确定会导致误触发。6.4 音调不准系统时钟不对这是根本原因。确保CubeMX中HCLK确实配置为72MHz并且APB1 Timer Clocks是72MHz。可以在代码中通过SystemCoreClock变量查看系统时钟频率或者通过一个GPIO翻转配合示波器测量实际频率。计算误差我们计算ARR的公式是ARR 1,000,000 / F - 1。其中1,000,000来源于72MHz / (711)。请再次核对PSC的值是否为71。如果PSC设置错误这个基准频率就错了。定时器位数限制ARR是16位的最大值65535。对于低音音符频率很低计算出的ARR可能超过65535导致无法正确设置。例如想要发一个65.4Hz的低音DoARR需要约15289这在范围内。但如果想要更低的音就需要减小PSC增大基准频率F_CK_CNT或者使用32位定时器如TIM2或TIM5如果芯片支持。7. 功能扩展与进阶玩法一个简单的单音电子琴做出来了但这只是开始。你可以在此基础上进行很多有趣的扩展让项目更具挑战性和实用性。7.1 增加更多音符和八度我们只做了一个八度的7个白键。你可以增加更多按键来实现更多八度。甚至可以用一些按键作为“升调键”类似钢琴的黑键。这需要扩展按键阵列可以使用矩阵键盘来节省GPIO并扩充你的音符频率表。7.2 实现按键力度感应初阶真正的电子琴可以感知你按下的力度。我们可以用一个简单的模拟通过检测按键按下的速度不这需要更复杂的传感器。一个取巧的办法是利用GPIO的“输入捕获”模式测量按键从按下到稳定低电平的时间在消抖区间内时间越短假设力度越大。然后根据这个“力度”值去调节PWM的占空比即音量但注意蜂鸣器音量随占空比变化不明显或者更好的是通过一个数字电位器或DAC去控制一个音频放大器的增益。7.3 播放预存旋律你可以将一段简单的乐谱如《小星星》编码成两个数组一个存放音符索引一个存放节拍时长以系统滴答数为单位。然后在主循环中用一个状态机或者定时器中断来依次读取并播放这些音符实现自动演奏。这涉及到更复杂的定时和状态管理。7.4 使用DAC和音频放大器提升音质无源蜂鸣器发出的基本是方波音色单一、刺耳。要获得更好的音质可以使用STM32的DAC数模转换器功能。你可以在内存中预存一段正弦波的采样数据然后根据音符频率以相应的速度通过DAC循环输出这些数据再经过一个音频功率放大器如LM386、PAM8403驱动喇叭就能得到接近纯正正弦波的声音音质会有质的飞跃。这需要了解DAC、DMA直接存储器访问和定时器触发等更高级的外设。7.5 加入LED指示为每个按键配上对应的LED按下时LED点亮松开时熄灭增加视觉效果。这需要增加LED和限流电阻并在按键处理代码中增加控制LED的GPIO输出语句。通过这个从按键控制LED到制作电子琴的项目你不仅实践了GPIO输入、定时器、PWM这些基础外设更重要的是理解了如何将软件算法频率计算、状态机与硬件外设结合起来解决一个实际的、有趣的问题。这种“系统集成”的思维是嵌入式开发的核心能力。当你听到自己用代码“弹奏”出第一个音符时那种感觉绝对比点亮一个LED要美妙得多。

相关新闻