基于STM32的USB MIDI键盘自制指南:从硬件设计到固件开发全解析
1. 项目概述从零打造一个会“说话”的USB MIDI键盘几年前我在整理一堆旧开发板时翻出了一块落灰的STM32F103C8T6核心板也就是大家常说的“蓝板”或“最小系统板”。看着它我突然想起学生时代玩音乐制作软件时总被那些动辄上千的专业MIDI键盘价格劝退。一个念头冒了出来能不能用这块几十块钱的板子自己做一个能用的MIDI键盘这个想法最终催生了这个项目——一个完全自制、从硬件扫描到USB协议通信全栈打通的USB MIDI键盘。它不仅仅是一个“串口转MIDI”的演示而是一个完整的、可扩展的电子乐器输入设备原型。通过这个项目你不仅能让你手头的MCU“唱起歌来”更能深入理解USB HID类设备、MIDI协议以及实时扫描算法是如何协同工作的。无论你是嵌入式新手想找个有趣的综合项目练手还是音乐爱好者渴望拥有一个独一无二的控制器这篇文章都将为你提供一份详实的“造物指南”。2. 核心思路与方案选型为什么是“USB MIDI”在动手之前我们需要明确方向。MIDI键盘的本质是一个输入设备它不产生声音而是向电脑或音源发送标准的MIDI指令如“按下中央C键力度100”、“释放中央C键”等。那么如何让我们的自制设备被电脑识别并通信呢这里有几种常见路径方案一传统的MIDI接口5针DIN。这是专业设备的标配接口标准、兼容性极好。但缺点是需要专用的MIDI接口芯片如6N138光耦和电脑上的MIDI IN接口现在很多电脑已不再配备需要额外购买USB-MIDI转换线增加了复杂度和成本。方案二模拟成USB键盘发送字符。让MCU模拟成一个USB键盘按下琴键就发送一个特定的字母如A、S、D。然后在电脑端的音乐软件里将这几个字母映射成音符。这个方法实现简单但极其不专业无法传递力度、弯音、踏板等丰富的MIDI信息延迟和可靠性也成问题。方案三模拟成标准的USB-MIDI设备。这正是我们选择的方案。USB MIDI设备属于USB的“音频设备类”Audio Device Class下的一个子类。操作系统Windows、macOS、Linux都内置了其标准驱动即插即用。我们的MCU通过USB口直接与电脑“说”MIDI协议的语言。这是最专业、最灵活、延迟最低的方案。因此我们的技术栈非常清晰一块具备USB Device功能的MCU 一套按键矩阵扫描电路 实现USB MIDI类设备协议 将扫描结果编码为MIDI消息并通过USB发送。我选择了STM32F103系列因为它资源丰富、性价比高、生态完善自带USB Device外设完全满足需求。3. 硬件设计与核心电路解析一个完整的MIDI键盘硬件主要包括三部分主控MCU、按键矩阵、USB接口电路。电源通常直接从USB总线获取5V经MCU内部的稳压器转为3.3V供系统使用。3.1 主控MCUSTM32F103C8T6的资源配置为什么是它除了性价比关键看资源。STM32F103C8T6拥有72MHz主频、64KB Flash、20KB RAM性能绰绰有余。更重要的是它集成了一个全速USB 2.0 Device接口这是我们项目的基石。我们需要分配以下引脚USB DM/DP (PA11/PA12):必须专用用于USB数据通信。矩阵行线 (如PA0-PA7):配置为推挽输出用于扫描时逐行输出低电平。矩阵列线 (如PB0-PB7):配置为上拉输入或外部上拉电阻用于读取列状态判断该行有哪些键被按下。调试/状态指示LED (如PC13):可选用于指示USB枚举成功、工作状态等。3.2 按键矩阵设计如何用最少的IO控制最多的键对于多键位的键盘逐引脚连接每个键一个IO是IO资源的灾难。矩阵扫描是标准解决方案。原理很简单将按键布置在行线和列线的交叉点上。一个4x4的矩阵只需要8个IO口就能管理16个键。设计要点二极管防鬼键这是专业键盘设计的必备。在每个按键上串联一个开关二极管如1N4148阴极接行线阳极接列线。它的作用是防止在多个键同时按下时产生“鬼影”按键误判按下了一个实际未按的键。对于音乐键盘和弦演奏是常态防鬼键至关重要。上拉电阻列线需要通过电阻上拉到VCC3.3V。STM32的GPIO内部可以配置为上拉模式通常足够稳定。如果矩阵较大或线缆较长建议额外使用10kΩ的外部上拉电阻增强抗干扰能力。消抖处理按键的机械触点会在闭合或断开瞬间产生抖动导致多次误触发。这必须在软件中处理硬件上可以在按键两端并联一个小电容如104来辅助滤波但软件消抖更灵活可靠。注意如果你只是做一个小型演示键盘例如一个八度13个键可以暂时不用二极管但必须清楚一旦同时按下三个或以上特定组合的键如一个矩形的四个角就可能出现鬼键。对于真正的演奏键盘二极管矩阵是必须的。3.3 USB接口电路简单但不可马虎STM32的USB接口电路非常简洁但细节决定成败。USB Connector:使用标准的Micro-USB或USB Type-C母座。DM/DP数据线直接连接MCU的PA11和PA12。走线应尽可能短、等长并避免靠近高频噪声源。上拉电阻在USB DP (PA12) 线上需要通过一个1.5kΩ的电阻上拉到3.3V。这个电阻至关重要它告诉USB主机这是一个全速设备。很多自制项目失败就是因为漏了这个电阻或阻值不对。STM32有些型号内部集成了这个上拉需要通过软件配置使能而F103通常需要外接。电源滤波在USB的5V电源入口处放置一个10-100μF的电解电容和一个0.1μF的陶瓷电容并联用于滤除低频和高频噪声保证MCU供电稳定。4. 软件架构与核心驱动实现软件部分是项目的灵魂可以分为三层底层驱动USB、GPIO、中间件矩阵扫描、MIDI编码、应用逻辑主循环。4.1 USB MIDI设备描述符告诉电脑“我是谁”这是设备插入电脑后进行的第一次“自我介绍”。描述符是一系列标准的数据结构定义了设备的类型、接口、端点等。一个USB MIDI设备至少需要以下描述符设备描述符指定供应商IDVID、产品IDPID、设备类比如我们设为0x00在接口描述符中再具体定义、协议等。你可以使用公开的测试PID如0xFFFE但如果是正式产品需要申请自己的VID/PID。配置描述符与接口描述符这里最关键。我们需要声明自己属于“音频设备类”0x01并且接口子类是“MIDI流接口”0x03。同时要指定使用的端点。端点描述符MIDI通信通常使用两个Bulk端点一个IN端点设备到主机用于发送MIDI消息和一个OUT端点主机到设备用于接收如系统独占消息我们的键盘暂不需要但最好保留。例如我们可以定义端点0x81IN和0x01OUT包大小为64字节全速USB的最大包大小。编写描述符是USB开发中最繁琐的一步但也是基础。通常可以参考ST官方USB库中的例程如“USB_Device/AUDIO_Standalone”在其基础上修改为MIDI类。核心是将所有类Class、子类SubClass、协议Protocol的代码从“AUDIO”改为“MIDI”对应的值。4.2 按键矩阵扫描算法快速且准确扫描算法需要在主循环或定时器中断中周期性执行。这里以定时器中断为例保证扫描间隔稳定例如1ms这对降低演奏延迟很重要。扫描步骤初始化所有行线设置为高阻输入或输出高电平所有列线配置为上拉输入。逐行扫描将当前扫描行Row N设置为输出低电平其他所有行设置为输出高电平或高阻态。短暂延时几个微秒等待电平稳定。读取所有列线Column 0-M的状态。如果某列线为低电平因为被行线拉低且该列线上的二极管导通则说明该交叉点的按键被按下。记录下Row N Column M的按键状态。恢复与循环将当前扫描行恢复为高电平切换到下一行重复步骤2直到所有行扫描完毕。消抖与状态跟踪得到原始键值后需要进行软件消抖。我常用的是“状态机消抖法”为每个按键维护一个状态如IDLE,PRESS_DOWN,PRESSED,RELEASE和一个计数器。当连续多次扫描如5-10ms都检测到按下才确认为“按下事件”同样连续多次检测到释放才确认为“释放事件”。这能有效滤除抖动。生成事件当确认一个“按下事件”或“释放事件”后将其放入一个事件队列等待主循环处理并转换为MIDI消息。4.3 MIDI消息编码与USB发送MIDI协议消息格式很精简。我们最需要的是“音符开Note On”和“音符关Note Off”消息。Note On消息0x9n, Note Number, Velocity0x9n:0x90是通道1的Note On状态字。n的范围是0-15代表16个通道。我们通常用通道10x90。Note Number: 音符编号0-127。中央CC4通常是60。Velocity: 力度0-127。我们简易键盘可以固定一个值如100或者用ADc读取一个电位器来模拟力度。Note Off消息0x8n, Note Number, Velocity格式同Note On状态字为0x80。实际上很多设备也把Velocity为0的Note On当作Note Off处理。在USB MIDI中传输USB MIDI定义了一种封装格式将MIDI消息打包进USB数据包。一个USB数据包最多64字节可以包含多个MIDI事件封装。每个事件封装占4字节[Cable Number (0x0) | Code Index Number (CIN)] [MIDI_0] [MIDI_1] [MIDI_2]Cable Number和CIN 表示后面3个字节的含义。对于普通的3字节MIDI消息如Note OnCIN为0x09。MIDI_0, MIDI_1, MIDI_2: 就是原始的3字节MIDI消息。因此当我们检测到一个按键按下需要发送Note On时软件流程如下根据按键位置映射到对应的音符编号例如第一个键是60。组装MIDI消息0x90, 60, 100。组装USB-MIDI事件封装0x09, 0x90, 60, 100。将这个4字节封装放入USB发送缓冲区。调用USB库的发送函数通过IN端点将数据发送给主机。5. 固件开发实战基于HAL库的步骤详解我们以STM32CubeIDE和HAL库为例一步步搭建工程。5.1 工程创建与基础配置启动STM32CubeIDE选择你的MCU型号STM32F103C8Tx。在Pinout Configuration视图系统核心在SYS中将Debug设为Serial Wire。时钟在RCC中将HSE设为Crystal/Ceramic Resonator。然后在Clock Configuration标签页配置系统时钟为72MHz。确保USB Clock的时钟源是PLL且频率为48MHzUSB模块的硬性要求。USB在Connectivity中启用USB (Device)。模式选择Device (FS)。这会自动占用PA11和PA12。GPIO根据你的矩阵设计配置行线如PA0-PA3为Output Push Pull列线如PB0-PB3为Input Pull-up。定时器启用一个定时器如TIM2配置为1ms中断用于按键扫描。生成代码。5.2 修改USB描述符这是最核心的修改。不要直接修改usbd_conf.c和usbd_desc.c而是找到Core/Inc目录下的usbd_conf.h和usbd_desc.h以及对应的.c文件。更关键的是你需要替换或修改USB设备类库。方法一推荐ST的Cube库中可能没有直接提供MIDI例程。你可以从ST官网或社区寻找一个名为“USB_Device/MIDI_Standalone”的例程将其中的USB_Device类驱动文件通常是一个Middlewares/ST/STM32_USB_Device_Library/Class下的子目录MIDI复制到你工程的相同目录并在项目属性中添加该路径。方法二基于AUDIO例程深度修改。这需要对USB协议有较深理解需要将usbd_audio.c/.h中的所有“AUDIO”替换为“MIDI”并对照USB MIDI类规范修改描述符的具体数值。这个过程极易出错建议优先寻找现成的MIDI类库。假设你获得了正确的usbd_midi.c和usbd_midi.h你需要在main.c或usbd_conf.c中将设备类句柄从USBD_AUDIO改为USBD_MIDI并调用相应的初始化函数。5.3 编写按键扫描与MIDI发送代码在main.c中// 按键状态和消抖缓冲区 #define ROWS 4 #define COLS 4 uint8_t key_state[ROWS][COLS] {0}; uint8_t key_debounce_cnt[ROWS][COLS] {0}; #define DEBOUNCE_TIME 5 // 5ms // 定时器中断回调函数1ms void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { scan_keyboard(); } } void scan_keyboard(void) { for (int row 0; row ROWS; row) { // 1. 拉低当前行 HAL_GPIO_WritePin(ROW_PORT, ROW_PINS[row], GPIO_PIN_RESET); // 2. 短暂延时 HAL_Delay(1); // 实际应用用微秒级延时这里简化 // 3. 读取所有列 for (int col 0; col COLS; col) { GPIO_PinState state HAL_GPIO_ReadPin(COL_PORT, COL_PINS[col]); uint8_t pressed (state GPIO_PIN_RESET); // 低电平表示按下因为有上拉 // 消抖状态机 if (pressed) { if (key_debounce_cnt[row][col] DEBOUNCE_TIME) { key_debounce_cnt[row][col]; if (key_debounce_cnt[row][col] DEBOUNCE_TIME) { // 确认为按下事件 if (key_state[row][col] 0) { key_state[row][col] 1; send_midi_note_on(map_to_note(row, col), 100); // 发送Note On } } } } else { if (key_debounce_cnt[row][col] 0) { key_debounce_cnt[row][col]--; if (key_debounce_cnt[row][col] 0) { // 确认为释放事件 if (key_state[row][col] 1) { key_state[row][col] 0; send_midi_note_off(map_to_note(row, col), 0); // 发送Note Off } } } } } // 4. 恢复当前行为高电平 HAL_GPIO_WritePin(ROW_PORT, ROW_PINS[row], GPIO_PIN_SET); } } // 映射行列到音符编号示例从C4开始 uint8_t map_to_note(uint8_t row, uint8_t col) { return 60 row * COLS col; // 简单线性映射 } // 发送MIDI Note On消息 void send_midi_note_on(uint8_t note, uint8_t velocity) { uint8_t midi_buffer[4]; midi_buffer[0] 0x09; // USB-MIDI CIN: 3-byte message midi_buffer[1] 0x90; // MIDI Note On, Channel 1 midi_buffer[2] note; midi_buffer[3] velocity; // 调用USB MIDI类库的发送函数例如 // USBD_MIDI_SendData(hUsbDeviceFS, midi_buffer, 4); // 具体函数名需根据你使用的库而定 USBD_MIDI_SendPacket(hUsbDeviceFS, midi_buffer); }实操心得在scan_keyboard函数中HAL_Delay(1)在实际产品中是不可接受的它会阻塞整个系统。这里仅用于示意。正确做法是使用for循环实现微秒级延时或者更好的方法是将“设置行线”和“读取列线”的操作分步放在不同的小时间片里用状态机实现非阻塞扫描这是实现低延迟键盘的关键。5.4 主循环与USB处理主循环通常很简单主要是处理USB事件和可能的其他任务如扫描旋钮、LED显示。int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USB_DEVICE_Init(); // 初始化USB设备栈 MX_TIM2_Init(); HAL_TIM_Base_Start_IT(htim2); // 启动定时器中断 while (1) { // 主循环可以处理非实时任务例如更新LED状态 // USB的轮询如SOF处理通常在中断或底层驱动中自动完成HAL库已经处理好 } }6. 调试、测试与问题排查实录烧录程序后将设备插入电脑USB口激动人心的时刻到了。但现实往往不会一帆风顺。6.1 阶段一电脑毫无反应现象设备插入后电脑没有任何提示音设备管理器里看不到新设备或者看到一个“未知设备”。排查硬件检查首先用万用表测量USB口的5V和GND是否连通电压是否正常。检查DP线上的1.5kΩ上拉电阻是否焊接牢固阻值是否正确。软件描述符检查这是最常见的问题。使用USB协议分析软件如USBlyzerWiresharkwith USB capture抓取USB枚举过程的数据包。重点看设备对GET_DESCRIPTOR请求的回复是否正确。任何一个描述符字段错误都可能导致枚举失败。仔细核对你的设备描述符、配置描述符、接口描述符中的类、子类、协议代码。时钟检查确认系统时钟和USB时钟必须是48MHz配置正确。可以在代码中初始化后用示波器或逻辑分析仪测量一个GPIO翻转的频率来验证系统时钟。6.2 阶段二电脑识别为“无法识别的USB设备”或错误设备现象电脑有提示音但显示“未知设备”或识别成了别的设备如“HID-compliant device”。排查驱动问题确保你的描述符声明自己是MIDI设备。如果识别为HID设备说明你的接口描述符中的bInterfaceClass可能还是0x03HID类应该改为0x01音频设备类bInterfaceSubClass改为0x03。PID/VID冲突如果你使用了常见的测试PID/VID而电脑里安装了某个驱动强制绑定了这个ID可能会冲突。尝试修改一对不常用的PID/VID。6.3 阶段三设备识别成功但软件里没有声音现象设备管理器里能看到“USB Audio Device”或类似的MIDI设备但在DAW如Cakewalk, FL Studio的MIDI输入设备列表里找不到它或者找到了但没有信号。排查操作系统MIDI映射在Windows中打开“运行”输入midi打开“MIDI设置”查看你的设备是否出现在列表中。有时需要在这里启用设备。DAW设置在DAW的偏好设置或选项里找到MIDI设备设置确保你的设备输入端口被启用。数据发送验证这是关键。使用一个叫MIDI-OX或MIDI Monitor的软件。它们可以监听所有MIDI输入端口并显示原始MIDI消息。打开软件选择你的设备作为输入。然后按下自制键盘的按键看软件界面上是否有90 3C 64Note On C4这样的消息出现。如果没有说明你的MCU程序没有成功发送数据。端点发送函数检查你的send_midi_note_on函数是否被正确调用。在函数里加一个LED翻转的调试代码确保按键事件确实触发了发送流程。然后检查USB发送函数如USBD_MIDI_SendPacket的返回值确认数据是否成功放入发送FIFO。6.4 阶段四有声音但延迟大、丢音或鬼键现象按键后声音明显延迟快速弹奏时丢音或同时按多个键出现奇怪的声音。排查与优化扫描延迟你的扫描周期定时器中断间隔是1ms吗如果主循环有其他阻塞任务如不当的延时会导致扫描变慢。确保扫描中断是最高优先级之一。消抖时间消抖时间如5ms会增加按下识别的延迟。可以尝试优化算法比如在检测到下降沿时立即发送Note On但同时启动一个计时器如果在消抖时间内按键弹起再发送一个Note Off来取消。这能减少感知延迟。USB发送阻塞USB的IN端点可能在上一次数据未发送完成时拒绝新的发送请求。检查发送函数的返回值如果返回“忙”需要将MIDI事件缓存起来等待下次USB中断或主循环中再尝试发送。实现一个简单的环形队列来缓冲MIDI事件是专业做法。鬼键问题回顾硬件部分。如果没加二极管出现鬼键是必然的。加上二极管是唯一根治方法。软件无法解决物理上的鬼键问题。7. 功能扩展与进阶玩法当基础键盘能稳定工作后你可以把它变成一个真正的创意工具。7.1 增加模拟输入弯音轮与调制轮MIDI键盘除了琴键通常还有弯音轮和调制轮。它们本质上都是电位器。你可以使用MCU的ADC功能来读取两个电位器的电压值0-3.3V映射到MIDI的0-127范围然后分别发送弯音Pitch Bend消息状态字0xEn后跟两个字节的14位数值和调制CC #1消息状态字0xBn, 0x01, value。注意ADC读取也需要定时进行但频率可以比键盘扫描低得多如100Hz。7.2 增加按钮与控制器走带控制与CC控制器你可以添加一些按钮映射到不同的MIDI控制改变CC消息或系统独占消息。例如一个按钮可以发送CC #64延音踏板的0或127值。另一个按钮可以发送MIDI Start0xFA和MIDI Stop0xFC消息来控制DAW的播放。这需要你扩展你的扫描矩阵或使用额外的IO口。7.3 实现力度感应与触后这才是MIDI键盘的灵魂。有两种常见方式力度感应使用带有力度感应的键盘模块每个键下有双触点或压力传感器。通过测量两个触点闭合的时间差来计算力度速度。这需要更精密的硬件和更复杂的扫描算法。通道触后在键盘下方安装一个长条形的压力传感器感知整个键盘区域受到的压力。通过ADC读取其值并发送通道触后消息0xDn, pressure。这是成本相对较低的实现方式能增加很多表现力。7.4 外壳与用户体验一个裸露的电路板绝不是终点。使用3D打印或亚克力板为你的键盘制作一个外壳。精心布局琴键、旋钮和滑轮。好的外壳不仅能保护电路更能提供舒适、专业的演奏体验。你甚至可以为其设计一个OLED小屏幕用来显示当前音色、弯音值等信息。从一块简单的开发板到一个能表达音乐的工具这个过程充满了挑战与乐趣。我自己的第一个原型琴键是用废旧电脑键盘的按键改装的外壳是硬纸板糊的但它发出第一个音符时的喜悦至今难忘。这个项目像一座桥梁连接了数字世界的精确与音乐世界的感性。当你用自己的代码让硬件“唱”出你想要的旋律时那种创造者的成就感是任何现成产品都无法给予的。希望这篇长文能为你点亮这条路剩下的就交给你的双手和创意去完成了。如果在实现过程中遇到具体问题不妨从检查最简单的电源和上拉电阻开始再用MIDI-OX这类工具层层深入大部分难题都会迎刃而解。