
简介这套基于STM32F103C8T6的DIY USB 25键MIDI键盘源码包面向嵌入式开发入门者与电子音乐DIY玩家。项目通过25路电容式触摸传感器检测琴键状态由单片机完成信号扫描、键值映射与USB MIDI协议打包最终被电脑识别为标准MIDI设备适合用于学习USB HID设备开发、GPIO矩阵扫描和实时嵌入式任务调度。压缩包共24个文件大小1.58MB。核心代码为5个C源文件和4个头文件涵盖主程序、MIDI配置描述符、触摸扫描与实用工具模块另附2张实物照片、2张按键切割模板图和1张原理图可直接参考焊接调试还有makefile与烧录脚本便于编译下载README提供项目说明。目前已有177人查看学习。读者可获取完整的工程目录结构和可编译源码并借助硬件图片、钻孔模板来复刻键盘外观php脚本用于生成琴键切割图能按需调整布局参数方便自行改版或扩展键位。1. 从一颗 STM32 到 25 键 MIDI 键盘这个源码包到底帮你省了什么拿到“基于STM32的DIY USB 25键MIDI传感器键盘”这个压缩包时你大概率和我一样手里已经有按键/传感器、一块最小板和一个外壳缺的只是让它们开口说话的固件。这个项目把 USB MIDI 设备枚举、25 键矩阵扫描、力度感应这三件最磨人的事预先打包让你绕开协议细节直接把演奏动作变成 DAW 里跳动的音符。它最适合两类人一类是卡在“电脑不认设备”的嵌入式中级玩家另一类是给学生做低成本 MIDI 控制器教学演示的开发者。下面按我自己的复现顺序逐层拆解。2. 源码结构拆解USB 描述符、MIDI 协议和键扫描矩阵2.1 工程骨架main 循环、USB 中断和音符映射表拿到一个陌生固件包我第一动作不是打开代码而是先看目录。这类工程通常由芯片厂商的图形配置工具生成外设初始化代码再叠加应用层所以大概率会看到Core/、USB_DEVICE/、Drivers/和几个以KeyScan、MidiApp命名的自定义文件。先分清哪些是工具自动生成的哪些是作者的业务逻辑后面改起来才不会迷路。核心逻辑集中在两个函数上键扫描和 MIDI 发送。一个精简的主循环长这样/* main.c 主循环示意 */ int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USB_DEVICE_Init(); KeyScan_Init(); /* 初始化行列IO与ADC通道 */ MidiApp_Init(); /* 清空事件队列 */ while (1) { KeyScan_Task(); /* 非阻塞扫描周期由内部计数器控制 */ MidiApp_Task(); /* 从队列取事件通过USB端点发送 */ } }KeyScan_Task里绝不能放HAL_Delay否则按键按下到音符发出之间的延迟会忽长忽短。我一般把扫描周期放在 1ms 到 2ms用系统 tick 计数实现每 1ms 置一个标志主循环看到标志才扫描这样发送节奏是稳定的。USB 初始化走的是中断 端点轮询主循环只要保证事件队列不被写满就行。2.2 MIDI 1.0 协议里必须懂的三个字节状态、音号和力度MIDI 不是音频流它是一条一条的指令。电脑里的 DAW 收到指令后自己合成声音。所以你的固件只需要把“哪个键按下了、用了多大力”翻译成三个字节消息类型状态字节数据1数据2Note On0x9n音号 (0-127)力度 (1-127)Note Off0x8n音号释放力度Control Change0xBn控制器号 (0-127)控制值 (0-127)状态字节里的n是通道号0-15DIY 键盘通常固定发通道 0也就是0x90。音号对应键盘位置25 键一般是 C1 到 C3起始音符常用 MIDI 36 或 48不同产品定义不同。力度是 0-127很多传感器键盘把它做成“0 不发声1-127 强弱”。发送一个 Note On 的最小实现/* midi_app.c - 构造一条Note On消息并通过USB发送 */ uint8_t midi_note_on(uint8_t channel, uint8_t note, uint8_t velocity) { uint8_t buf[4]; buf[0] 0x90 | (channel 0x0F); /* 状态字节通道只取低4位 */ buf[1] note 0x7F; /* 音号最高位必须是0 */ buf[2] velocity 0x7F; /* 力度0x00等价于Note Off */ buf[3] 0; /* USB MIDI的保留字节 */ return USB_MIDI_Send(buf, 4); }注意最后多了一个字节。USB MIDI 的端点包是 4 字节前面 1 字节是 Cable Number Code Index NumberCIN后面跟 1 到 3 字节的 MIDI 消息。很多第一次做的人漏掉这个buf[3]导致电脑收到的消息错位。如果直接用芯片厂家 USB 库里的发送接口通常已经帮你处理好这层这里是为了说清楚字节边界。2.3 25 键为什么用矩阵扫描IO 不够时的标准解法25 个按键如果每个占一个 IO需要 25 个引脚对低成本控制芯片来说太奢侈。常见做法是排成 5 行 × 5 列只占 10 个 IO。扫描原理是行引脚输出电平列引脚读输入一行一行地“点亮”检查。这样每个键都能被定位到(row, col)。/* key_scan.c - 5x5矩阵扫描骨架 */ #define ROWS 5 #define COLS 5 /* 行引脚输出推挽列引脚带上拉输入 */ void KeyScan_Task(void) { for (int row 0; row ROWS; row) { HAL_GPIO_WritePin(row_ports[row], row_pins[row], GPIO_PIN_RESET); for (int col 0; col COLS; col) { if (HAL_GPIO_ReadPin(col_ports[col], col_pins[col]) GPIO_PIN_RESET) { /* 检测到按下交给去抖与事件记录 */ Key_Process(row, col); } } HAL_GPIO_WritePin(row_ports[row], row_pins[row], GPIO_PIN_SET); } }为什么扫描完一行要把引脚拉回高电平因为如果保持低下一轮扫描时电流会从其他行漏过来造成“串键”。如果你的按键是力度传感器比如压阻片、压电片就不能只用开/关判断——传感器的模拟输出需要进 ADC矩阵扫描的代码里会多一步“按下后采样峰值”。纯数字矩阵和模拟力度矩阵的差别我在第 4 章展开。3. 把源码跑起来编译烧录和 USB MIDI 首测3.1 从图形配置工具生成 USB MIDI 最小工程到合并源码不推荐从零手写 USB 描述符那是最容易翻车的地方。常见做法是先用芯片厂商的 USB 设备库生成一个“USB Audio/MIDI”类的最小工程验证枚举能通过再把源码包里的业务文件并进去。生成时注意三点一是选择正确的设备类二是把 USB 时钟设为 48MHz三是工程名不要带中文。合并源码我的习惯是把KeyScan.c、MidiApp.c等文件复制到工程目录然后在main.c里加两次调用。需要改的地方通常是引脚定义——每块板子的行列表引脚不一样源码包里的宏定义一般需要按你的硬件改。一个小技巧先不改逻辑只把引脚初始化跑通用示波器看行扫描波形确认 5 行轮流拉低后再去管上层。3.2 编译烧录链接错误、下载地址和第一次枚举编译这类工程最容易遇到的链接错误是找不到USB_MIDI_Send或HAL_PCD相关函数。原因通常是漏了 USB 设备库中间层的源文件或者没有在工具链里把USB_DEVICE目录加进构建路径。检查点就一个报错符号所在的.c文件必须出现在编译清单里。烧录命令我用命令行比较多方便反复验证# 以某款Cortex-M0核心芯片为例编译并烧录到0x08000000 make clean make st-flash write build/firmware.hex 0x08000000st-flash是常见的命令行烧录工具0x08000000是内部 Flash 的起始地址不同芯片可能不同以参考手册为准。烧录完先别装进外壳直接通过 USB 线连着电脑看系统是否识别为 MIDI 设备。第一次不识别很正常先从设备管理器确认有没有出现未知设备再看电源指示灯是否稳定——这里埋了很多坑第 5 章专门讲。3.3 用 MIDI 监视器验证音符与力度Python 一行脚本固件烧好之后需要确认三件事按键按下有没有发出 Note On、力度值是否变化、释放时有没有 Note Off。最稳的验证办法是写几行 Python 监听 MIDI 输入# midi_monitor.py - 监听电脑收到的所有MIDI消息 import mido in_port mido.open_input(DIY Keyboard) # 设备名在系统里可查 for msg in in_port: print(msg.type, msg.note, msg.velocity, msg.channel)先安装mido和rtmidi两个库跑起来后按一个键终端应该立刻打出一行note_on。如果只看到note_on没有note_off说明固件发送逻辑有问题如果力度总是一个固定值说明力度采样没通。这一步能在不接音箱的情况下把整个数据链路验证完。4. 参数调优去抖、扫描周期和力度曲线4.1 按键去抖从“连发”到“一次一击”薄膜按键和自复位触点都有机械抖动——按下的一瞬间电平会在 0 和 1 之间弹跳几十毫秒。如果不去抖一个键会被识别成十几次按下演奏时就是“机关枪”。最简单的去抖是延时后再读一次但HAL_Delay会阻塞主循环在 MIDI 键盘里绝对不能这么用。正确做法是记录电平变化时间超过阈值才确认状态。/* key_scan.c - 基于时间戳的去抖实现 */ #define DEBOUNCE_MS 5 static uint8_t last_stable[ROWS][COLS]; static uint32_t last_change[ROWS][COLS]; uint8_t DebounceRead(uint8_t row, uint8_t col) { uint8_t raw ReadGpio(row, col); uint32_t now HAL_GetTick(); if (raw ! last_stable[row][col]) { last_change[row][col] now; /* 记录变化瞬间 */ last_stable[row][col] raw; /* 先临时更新 */ } /* 稳定超过5ms才返回新状态否则返回旧值 */ if ((now - last_change[row][col]) DEBOUNCE_MS) { return last_stable[row][col]; } return !last_stable[row][col]; }DEBOUNCE_MS取值很关键。太小滤不掉弹跳太大影响快速连击。击键间隔一般在 30ms 以上5ms 到 10ms 是安全区间。注意这个实现里有一个“边界情况”按键极快地按下又释放如果弹跳时间超过阈值会被误判成两次动作。所以更稳的做法是记录原始状态序列做两次方向一致确认但会多占一点 RAM。4.2 力度响应ADC 峰值采样、阈值和曲线映射“传感器键盘”和普通按键键盘最大的区别在于力度。力度信号通常来自压阻片或压电片按得越重ADC 采到的电压就越高。难点有两个一是采样时机要准必须在指腹刚接触传感器的瞬间开始采否则采样到的是峰值已经衰减后的波形二是不同传感器的底噪和满量程都不一样不能用一个固定死公式。我常用的做法是扫描检测到按下后立刻启动一个 2ms 的采样窗口连续采 8 到 16 次取最大值作为力度原始值。然后做非线性映射/* midi_app.c - 把ADC原始值映射成MIDI力度 */ #define VEL_THRESHOLD 40 /* 低于这个值视为没按下 */ #define VEL_MAX 3000 /* 传感器满量程实测得出 */ uint8_t VelocityMap(uint32_t adc_value) { if (adc_value VEL_THRESHOLD) { return 0; } if (adc_value VEL_MAX) { adc_value VEL_MAX; } /* 归一化后做平方映射让轻弹和重击的区分更明显 */ float normalized (float)(adc_value - VEL_THRESHOLD) / (VEL_MAX - VEL_THRESHOLD); uint8_t velocity (uint8_t)(127.0f * normalized * normalized); if (velocity 1) { velocity 1; /* 最小力度为1避免无声 */ } return velocity; }为什么用平方而不是直线映射因为人手对手指力度的控制在小力度区更细腻直线映射会让大力度区域挤在一起弹出来强弱不明显。平方、立方甚至指数曲线都有人用我建议做一个可调参数让使用者自己选。VEL_THRESHOLD不是一成不变的传感器用久了底噪会漂我会在自检模式里先采集 100 次静态值取平均作为底噪再写回阈值。4.3 控制信号留口弯音轮、延音踏板与 CC 控制25 键键盘的演奏表现力不能只靠力度弯音轮和延音踏板是刚需。这一类源码包通常会在传感器方案里留出两个辅助输入一个模拟量弯音一个开关量踏板。MIDI 协议里弯音轮是 14 位分辨率两个数据字节各取 7 位。/* midi_app.c - 发送Pitch Bendvalue范围-8192到8191 */ void Midi_SendPitchBend(uint8_t channel, int16_t value) { uint16_t pb (uint16_t)(value 8192); /* 偏移成0~16383 */ uint8_t buf[4]; buf[0] 0xE0 | (channel 0x0F); buf[1] pb 0x7F; /* 低7位 */ buf[2] (pb 7) 0x7F; /* 高7位 */ buf[3] 0; USB_MIDI_Send(buf, 4); }延音踏板则对应 CC64按下发送 127释放发送 0。很多源码包里默认只开了 Note 通道控制信号是空着的。你在组装自己作品时建议先读完这两个接口的注释再决定要不要引线——因为模拟信号输入的 ADC 通道和矩阵扫描复用同一个引脚时时序会有冲突。5. 避坑记录枚举失败、延迟抖动和供电玄学5.1 USB 枚举失败设备显示“未知 USB 设备”现象固件烧进去插上电脑没有任何反应设备管理器里冒出一个带黄色感叹号的“未知 USB 设备”。拔掉重插也一样。原因最常见的是USB 时钟没有配置到 48MHz。很多开发板外部晶振是 8MHz系统时钟跑到 72MHz但 USB 外设分频配错时主机收不到正确的帧起始包直接判定设备故障。其次是 USB 描述符里的端点描述符写错比如端点大小填了 64 而实际配置为 8。解决回到图形配置工具打开时钟树页面找到 USB 相关的时钟源确认它稳定显示 48MHz。如果时钟没问题再对照芯片参考手册检查usbd_desc.c里的设备描述符重点看bMaxPacketSize0是否为 64、端点地址是否冲突。我个人的经验是先用芯片厂商的原始 MIDI 例程验证一把如果原始例程能被识别问题一定出在你合并代码时改坏了某处。5.2 按键延迟忽高忽低音符时值对不上现象弹一个音有时立刻出声有时晚几十毫秒快速弹音阶时后面的音会突然消失一个。原因主循环里混入了阻塞操作。最常见的是用串口打印调试信息——每按一个键就输出一行日志而串口波特率只有 115200一次打印要占掉几百微秒到几毫秒。MIDI 是实时流哪个字节晚了整个节奏就崩了。另一个原因是 USB 发送端点缓冲满后调用发送函数会等待等待时间不可控。解决把串口打印全部关掉需要调试时用 GPIO 翻转加示波器看。发送消息不要直接操作端点而是先放进一个 FIFO 队列在主循环里批量发送。队列深度 64 条消息足够。还要确保 ADC 采样用 DMA不要用阻塞式轮询否则采样期间主循环被卡住。5.3 力度忽轻忽重传感器的“玄学”现象同一个键用同样的力度去弹两次输出的力度值能差出 30 以上相邻两个键的力度数字差异也很大。原因第一传感器本身一致性差这是硬件问题没有软件能根治。第二采样时机不对——如果你在按键扫描到按下后隔了几毫秒才采 ADC压电片的电压已经从峰值掉下来了力度值自然低而且不稳定。第三ADC 采样没有做数字滤波工频干扰和电源纹波会混进来。解决在检测到按下的那个 1ms 内就必须开始采样采样窗口设 2ms每次间隔 200us取窗口内最大值。对采样序列做一次简单的滑动平均比如去掉最大值和最小值再对剩余值取平均。如果还不行就检查传感器供电使用单独的 LDO 而不是直接吃数字电源的 3.3V压电片信号太微弱数字电路的开关噪声足以覆盖它。5.4 烧录后第一次插拔正常第二次不识别现象固件烧完第一次插 USB 电脑正常识别拔下来再插就报“无法识别的 USB 设备”。重启电脑又好拔插几次又坏。原因多数情况下不是固件逻辑的问题而是上电时序。设备拔掉后USB 线上的电容没有完全放电重新插入时 D 或 D- 电平状态不对主机认为设备没有拔出。或者 MCU 复位后USB 初始化代码执行太快而外部晶振还没有起振稳定导致设备第一次控制传输就失败。解决在MX_USB_DEVICE_Init()之前加一个 50ms 到 100ms 的延时等电源和晶振都稳定。另外在 USB 初始化里把设备内部的上拉电阻使能放最后一步有些库默认先拉高 D会导致主机以为有设备了但后续响应超时。如果板子是自己画的检查 USB 信号线上有没有加 22 欧姆串联电阻这个电阻太小会反射信号太大又会让沿变缓。5.5 矩阵串键同时按三个键时多出“幽灵音符”现象同时按下相邻的 C、E、G 时除了这三个音还会多出一个音或者某个音被吃掉。原因矩阵扫描的本质是分时复用 IO当三键同时按下时电流会通过按键网络反向灌入别的行或列形成一条逻辑上不存在的路径这就是“鬼键”。只要没有用二极管做隔离三键以上的组合永远会出这种问题。解决硬件上每个按键串一只 1N4148 二极管方向统一这是标准做法。如果你想不改硬件软件层面可以做逐行扫描后的状态矩阵修正检测到同一行出现连续三个按键同时按下时强制把中间那个当作未按下。但这会影响实际演奏特别是弹三和弦的时候。我建议在做 PCB 时就把二极管位置预留出来成本很低省掉后面所有鬼键排查。6. 进阶玩法键位映射、自检模式与保留最小工程6.1 用系统自带映射工具自定义键位25 键出厂映射通常是 C1 到 C3但不同的宿主音乐软件对音域的默认值不一样。我习惯在固件里留一张映射表把每个物理键的扫描索引映射到任意 MIDI 音号。映射表放在一个独立数组中烧录前改数组值就能转调。如果你不想反复烧录还可以在 PC 端用 MIDI 映射工具做一次转发把物理键的固定音号改写成目标音号这样固件完全不用动。6.2 固件里加一个自检模式3 秒扫完 25 个键自检模式是一个常常被忽略但非常实用的功能。我习惯在启动时按住最高音键上电进入自检模式后自动扫描全部 25 个键每个键被按下时点亮一个独立 LED并把检测结果通过 USB 发成一条 SysEx 消息。这样组装时不需要电脑只用看 LED 就能确认每个键对应的行列引脚有没有焊错。自检的成本只有几百行代码却能让你在批量安装时节省大量时间。6.3 我的一个习惯保留一份“能出声”的最小工程我从做第一版 MIDI 键盘起就养成了一个习惯保留一个只有 USB 枚举、固定音符发送、不接任何传感器的最小工程不管后来功能加了多少这个最小工程永远单独存一份。每次遇到“电脑不认设备”这类问题先烧最小工程确认硬件链路是好的再回头查自己的业务代码。这个习惯帮我赶走很多“玄学”问题。如果你也打算长期做 DIY MIDI 设备建议同样留一份别在原工程上越改越乱。希望帮到你。本文还有配套的精品资源点击获取