简介:一份基于STM32F407标准库的USB MIDI参考工程,面向需要实现USB Audio类MIDI通信的嵌入式开发者与音乐硬件爱好者。工程遵循USB音频设备类规范,将STM32F407配置为全速USB MIDI设备,完整展示PC与设备间MIDI数据收发流程,涵盖端点配置、描述符编写与实时音频数据流处理,适合学习USB协议栈及标准库驱动调用。压缩包共397个文件,包含90个C源码、109个头文件,以及uvprojx工程配置、hex烧录文件、map/axf调试输出和大量编译中间文件,整体约1.5MB,结构紧凑、层级清晰,便于离线查阅与二次开发。已有376人学习下载。通过该工程可快速掌握STM32F4系列USB设备描述符配置、MIDI端点管理、中断服务程序写法,且代码模块化良好,便于迁移到其他F4型号或定制为合成器、MIDI键盘等设备,是兼顾理论理解与工程落地的实用参考资料。
1. 从名字里的 7z 说起:STM32F407 的 USB MIDI 不比想象的难
拿到一个名为STM32F407_UsbMidi.7z的压缩包,老手的第一个反应通常是:这多半是某个网友放出的一整套 Keil 或 CubeMX 工程,而不是某个安装程序。文件名里两个关键词——STM32F407 和 UsbMidi——恰好是 DIY 电子乐器圈子里最常被塞进同一句话的组合。F407 这颗 Cortex-M4 主控不缺性能和 SRAM,但不少人以为 USB MIDI 要自己拼协议、写描述符、处理枚举,其实靠 ST 官方 USB Device 库里的 Audio MIDI 类,半天就能跑起来。这篇文章就把我顺手做出来的那套路径展开:从 USB 外设的硬件约束,到 CubeMX 工程的最小形态,再到端点收发和回环验证,把参数和坑都摆出来。适合正在做 MIDI 控制器、便携合成器或者想给现有 F407 板子塞一个 USB MIDI 口的工程师。
2. 先搞清 USB MIDI 在 STM32F407 上的硬件与协议边界
2.1 F407 的 OTG 外设和 USB Device 库里的 MIDI 类
STM32F407 全系自带两个 USB 外设:一个 USB OTG FS(全速 12 Mbps)和一个 USB OTG HS(高速 480 Mbps,但需外部 PHY 才能跑高速,内部也有 ULPI 接口)。做 MIDI 设备几乎永远先用 FS 口,因为 MIDI 的实时数据量很小——一个 Note On 事件最多也就 3 字节有效数据,哪怕 1 kHz 采样率也才 3 KB/s,全速 USB 完全绰绰有余。HS 口需要外接 USB3300 这类 PHY,图省事的话也可以用 HS 内核配置成 FS 模式,但这就白白浪费了引脚和文档复杂度。
USB 协议的 MIDI 不是独立规范,而是挂在 USB Audio Device Class 下面的一个子类。协议栈里称它AUDIO_MIDI或者 "MIDI Streaming"。ST 的官方 USB Device 库(从 Cube 包里取出的USB_DEVICE中间件)虽然主打 MSC、HID、CDC 三个类,但在usbd_midi.c这种文件里也提供了 Audio MIDI 的移植样例。第三方库 TinyUSB 则把它单独列为midi设备类,API 更清爽。两个选型我都试过:ST 库胜在能和 CubeMX 生成的初始化代码直接拼,TinyUSB 胜在事件驱动和 descriptor 配置更透明。
从系统角度看,F407 的 OTG 设备模式收发包由硬件 DMA 搬运,CPU 只在每个端点有数据时收到中断。所以 MIDI 响应延迟基本取决于端点轮询间隔(bInterval)和缓冲策略,而不是主频。后面第 3 章会看到,中断回调里只需要拷贝 4 字节,不用做任何协议解析。
2.2 MIDI 流在 USB 上的封装:Audio 类下的 MIDI Streaming
USB MIDI 设备的描述符层级通常是:Device Descriptor -> Configuration Descriptor -> AudioControl Interface -> MIDIStreaming Interface + Bulk/Interrupt Endpoint。一个最简 MIDI 设备可以只有 MIDIStreaming Interface(不需要 AudioControl),但 ST 库往往会把两个一起枚举。端点采用 Bulk 传输(Windows 下常见)或 Interrupt 传输(macOS/Linux 也支持),bInterval设 1ms 就能达到很低延迟。
数据格式上,USB MIDI 引入了 "USB MIDI Event Packet" 概念。一个 packet 固定 4 字节:
| 字节 | 含义 |
|---|---|
| byte0 高半字节 | Cable Number (0~15),表示物理上的虚拟线缆号 |
| byte0 低半字节 | Code Index Number (CIN),描述后续数据的类型 |
| byte1 | MIDI 状态字节或第一数据 |
| byte2 | Second data byte |
| byte3 | Third data byte |
比如一条 Note On(状态字 0x90,音高 60,力度 100)会被封装成0x09 0x90 0x3C 0x64。其中0x09是 Cable Number=0,CIN=9(注意 CIN 9 对应 Note Off/On 这种 voice 消息)。系统实时消息(如时钟 0xF8)则用0x0F 0xF8 0x00 0x00这种形式,后面的字节忽略。这也是为什么很多初看协议的人会晕——USB 层叫 "MIDI Event" 的东西并不是裸的 MIDI 字节流,而是每 4 字节的块。这个设计让主机和设备都不需要做流式状态机,但代价是每个 packet 都有至少 25% 的冗余。
2.3 为什么不用串口转 MIDI 而偏要 USB MIDI
老式 MIDI 是 5 针 DIN 接口,31.25 kbps 串行协议,一块设备上往往只有一个输入一个输出。USB MIDI 的优势在于:同一根线既能供电又能传数据,插上电脑或手机立刻出现一个可见的 MIDI 端口;如果做合成器,还能通过虚拟电缆(Cable 0~15)同时传 16 通道的独立逻辑链路。对 F407 这种直接带 USB 外设的芯片,硬件成本几乎为零,只需一个 USB 连接器加上匹配电阻即可。而如果用 CH340 + 串口 + 电脑端虚拟 MIDI,不仅延迟高,还多一颗芯片。
3. 用 STM32CubeMX 生成最少可用的 USB MIDI 工程
3.1 时钟与引脚:别让 USB 没起来就卡在 48MHz
F407 的 OTG FS 设备模式需要 48 MHz 时钟作为 USB 模块的工作时钟。这个 48 MHz 可以从 PLLQ 输出得到,也可以接外部晶振的 HSE/HSI 分频。常见误把RCC_PLL_M、RCC_PLL_N、RCC_PLL_Q配成 192 MHz 系统时钟后忘了检查 PLLQ,结果 USB 枚举不上。典型的配置是 HSE 8 MHz,PLL_M = 8,PLL_N = 336,PLL_P = 2 得 168 MHz 主频,PLL_Q = 7 得 48 MHz。CubeMX 里打开USB_OTG_FS外设并选择Device_Only,会自动提示你检查时钟树。最偷懒的做法是把USB_OTG_FS的 Clock Source 选为PLL,然后看右边的 48 MHz 是否变绿。
引脚上,F407 的 OTG FS 设备模式通常占用 PA11 (USB_DM) 和 PA12 (USB_DP),PA9 可以配置为 VBUS 检测,但有些板子直接把 VBUS 接在 5V 上,PA9 悬空也能用。我一般把USB_OTG_FS_VBUS保持 Disabled,用板载 5V 检测脚来控制是否需要进入枚举。有些朋友喜欢把 PA12 接 LED,这是不对的,DP/DM 引脚上不能挂负载。
3.2 从 CubeMX 到编译通过的最小配置步骤
下面这份操作基于 STM32CubeMX 6.x 配合 STM32CubeF4 固件包,这也是当前能看到 F407 标准库与 HAL 共存的主流方式。步骤顺序很关键:
- 新建工程,选择 CPU 为
STM32F407VGTx或具体型号。 System Core > RCC:HSE 选 Crystal/Ceramic Resonator。- 点击
USB_OTG_FS,Mode 选Device_Only。 - 在
Middleware组里选USB_DEVICE,Class 下拉里选AUDIO_MIDI或者MIDI(老版本叫MIDI,新版本归类为Audio)。这里如果找不到该选项,说明你的 STM32CubeF4 固件包太老,需要下载 1.26 以上版本。 - 工程生成后,在
usbd_midi.c里把端点参数改到咱们需要的值。
关键端点定义在usbd_midi.h(ST 库早期版本)或usbd_audio.h(新版)。我习惯直接改:
#define MIDI_IN_EP 0x81u #define MIDI_OUT_EP 0x01u #define MIDI_IN_PACKET_SIZE 64u #define MIDI_OUT_PACKET_SIZE 64u这里0x81表示地址 1 的 IN 端点(设备到主机),0x01是 OUT 端点。把包长设成 64 是因为 Bulk 全速端点最大为 64 字节,如果你用 Interrupt,那包长不能超过 64 也要注意。改完直接编译,如果链接不过,检查usbd_midi.c里有没有MIDI_DataOut这类回调。
3.3 代码里必须处理的回调:OUT 端点接收与 IN 端点发送
ST 的 USB 库在收到 OUT 数据后会调用MIDI_Receive回调函数(具体名字可能随版本变化,通常是HAL_PCD_DataOutStageCallback里继续触发)。在这个回调里你需要把收到的缓冲区交给下层处理,否则下个包来了会覆盖。最简单的做法是开辟一块接收缓冲,然后在回调里把数据拷贝出来。
uint8_t midi_rx_buf[128]; uint8_t midi_receive_buf[64]; void MIDI_Receive(uint8_t *buf, uint32_t len) { // 拷贝到自己的缓冲区,随后由主循环或队列处理 memcpy(midi_rx_buf + midi_rx_index, buf, len); midi_rx_index += len; // 必须重新调用接收,准备下一包 MIDI_ReceiveCmd(midi_receive_buf, 64); }代码逻辑:MIDI_Receive是底层库在 OUT 端点有数据时调用的,它收到的buf是内部缓冲,不搬走就会被下次覆盖。拷贝完成后立刻调用MIDI_ReceiveCmd把接收端点重新武装起来,这个函数在 ST 库里有,作用是把内核的 FIFO 交给 USB 外设。如果不调用,主机端会看到设备不再响应 OUT 事务。IN 方向发送则更简单,直接调用MIDI_Send或往指定端点写:
uint8_t usb_midi_packet[4] = {0x09, 0x90, 0x3C, 0x64}; MIDI_Send(usb_midi_packet, 4);注意端点写数据是要锁TPCS的,这个函数封装好了。如果一次要发多包,需要等待上一个传输完成,否则会卡在 FIFO 写。F407 的 USB 库是同步发送,必须确认HAL_PCD_EP_Transmit返回HAL_OK才行。
4. 把 MIDI 事件编码成 USB 包:参数与踩坑
4.1 4 字节 USB MIDI 事件的格式对照
传输层错误理解了 4 字节结构,后面一切白搭。我把常用事件的对照列出来,做 MIDI 键盘或者 LED 控制时查表就行。
| MIDI 事件 | 裸 MIDI 字节 | 对应 USB MIDI Packet (Cable=0) |
|---|---|---|
| Note On | 90 3C 64 | 09 90 3C 64 |
| Note Off | 80 3C 40 | 08 80 3C 40 |
| Control Change | B0 07 7F | 0B B0 07 7F |
| Program Change | C0 05 00 | 0C C0 05 00 |
| Pitch Bend | E0 00 40 | 0E E0 00 40 |
| SysEx 开始/继续 | F0 7E 7F __ | 0F F0 7E 7F 不适用 |
| Active Sensing | FE | 0F FE 00 00 |
| Clock | F8 | 0F F8 00 00 |
第一列的 "CIN" 我直接在第三列里编码成了第一个字节的低半字节。比如 Note On 的 CIN 是 9,所以 byte0 是0x09。SysEx 比较特殊,如果消息长度小于等于 3 字节可以直接在一个包内传,CIN 用 0x0F;如果超过 3 字节,CIN 0xF0 表示 "SysEx start/continue",最后一段用 0xF4 表示结束。很多新手发长 SysEx 时忘了拆包,导致主机收到乱序。我的建议是凡是超过 4 字节的 SysEx 统一拆成 4 字节块,除了第一块用0xF0开头的 packet,中间块用0xF1,最后一块用0xF2或0xF3看情况。其实更简单的办法是直接把 SysEx 数据掰成每 3 字节一组,加上 CIN0x04(SysEx end)或0x03(SysEx continue),但不同主机的解析有差异。稳定性最好的做法还是每 3 字节一个块,最后一个不满 3 字节补 0,CIN 分别按0x04结束。
举一个完整发送 Note On 的实例:
void midi_send_note_on(uint8_t channel, uint8_t note, uint8_t velocity) { uint8_t packet[4]; packet[0] = 0x00 | 0x09; // cable 0, CIN=9 packet[1] = 0x90 | (channel & 0x0F); packet[2] = note & 0x7F; packet[3] = velocity & 0x7F; MIDI_Send(packet, 4); }这里的参数说明:channel是 0~15,对应 MIDI 通道 1~16;note0~127,velocity也是 0~127。packet[1]的状态字节由 0x90(Note On)和通道号或运算得到。如果 velocity 为 0,大多数合成器按 Note Off 处理,所以不用刻意转换。
4.2 端点描述符与最大包长的配置
USB MIDI 设备的端点描述符不是随便选参数的。全速 MIDI Streaming 通常用一个 Bulk IN 和一个 Bulk OUT 端点,包长为 64。若是 Interrupt 端点,最大包长用全速 64,bInterval 必须大于 1ms,这里设 1 最好。实际工作中,Bulk 比 Interrupt 更适合大数据量的 SysEx,因为 Bulk 协议本身有 CRC 校验和重传。但 Interrupt 的确定性更好,某些宿主(比如 Ableton Live)对 Interrupt 端点的调度更稳定。如果使用 ST 库的 AUDIO_MIDI,它默认是 Bulk。TinyUSB 的 midi device 默认也是 Bulk。我遇到一些板子上的外设兼容性问题,发现主机的 usbmidi.sys 驱动对 Bulk 和 Interrupt 都能认,但若把 bInterval 写成 0,在 Windows 上会导致UNKNOWN DEVICE。
配置描述符里另一个容易忽略的是 Audio Control 里的wTotalLength,它必须等于所有描述符长度的总和。ST 库生成的USB_MIDI_ConfigDesc里一长串数据,如果自己手改端点号,比如把MIDI_IN_EP从 0x81 改到 0x82,记得同步修改描述符里的bEndpointAddress。我在 TinyUSB 的描述符里更愿意这么做:
static const tusb_desc_endpoint_t midi_in_ep_desc = { .bLength = sizeof(tusb_desc_endpoint_t), .bDescriptorType = TUSB_DESC_ENDPOINT, .bEndpointAddress = 0x81, .bmAttributes = { .xfer = TUSB_XFER_BULK }, .wMaxPacketSize = 64, .bInterval = 0, // Bulk 忽略 };如果 ST 库找不到直接描述符定义,也可以从usbd_midi.c的MIDI_GetDesc里改。总之确保描述符里的端点号和你的发送端点号一致,这个坑排查起来非常隐蔽,主机枚举成功但一传数据就 hang 就是它。
4.3 常见坑:速度、缓冲区和 Class-Specific 描述符
第一坑:把USB_OTG_FS的 Speed 属性在 CubeMX 里设为High Speed,实际上 F407 内部没有高速 PHY,必须在System > USB_OTG_FS里选Internal PHY且速度选Full Speed。一旦选了 External PHY,还要去连接一个 ULPI 芯片,很多板子没有,直接编译报错。
第二坑:缓冲区对齐。USB 内部 DMA 要求缓冲区地址按 4 字节对齐(有些版本要求 32 字节)。用__ALIGN_BEGIN声明,或者直接用 CMSIS 的__ALIGNED(4)。
__ALIGN_BEGIN static uint8_t midi_rx_buffer[64] __ALIGN_END;如果不对齐,会出现 DMA 传输错误,表现在设备偶尔枚举成功但收发不稳定,重启后还能复现。
第三坑:Class-Specific Descriptor 的长度和格式必须正确。ST 库的 MIDI 类用AUDIO_INTERFACE_DESC_SIZE来描述 AudioControl 和 MIDIStreaming,如果少了任何一条,Windows 的 usbaudio.sys 会报错误码 10。我用 Wireshark 抓包检查枚举过程,发现很多类都是被 "Descriptor validation failed" 卡住。具体说什么,最常见的错误是 MIDIStreaming descriptor 里的bNumEmbMIDIJack和bNumEmbMIDIJack没有匹配真正定义的 jack 数量。如果只做单一 cable MIDI,就必须定义两个 jack:Embedded In Jack 和 Embedded Out Jack,外加两个 Jack Descriptor 指向它们。
5. 验证与进阶:从 Windows 看到设备到做回环测试
5.1 用 midi 工具验证收发
设备枚举成功后,Windows 系统里会出现一个叫 "USB MIDI" 的输入输出端口。验证接收可以直接用 MIDI 工具发送一条消息,看板子是否把数据放入指定的回调。我最常用的免费工具是midi_monitor和loopMIDI,也可以写一段 Python 借助mido库来测试:
import mido # 打印所有端口,确认设备出现 print(mido.get_output_names()) # 打开输出端口并发送 Note On out = mido.open_output("USB MIDI") out.send(mido.Message('note_on', note=60, velocity=100, channel=0))在 F407 的MIDI_Receive回调中打断点或者用串口打印收到的原始字节,就能验证链路。注意电脑上的 "USB MIDI" 名字可能带后缀,用get_output_names()找到准确的端口全名。
5.2 进阶:直接跑 TinyUSB 或者 USB Composite
如果觉得 ST 库的回调太多、状态机太绕,TinyUSB 只调tud_midi_n_read和tud_midi_n_write两个函数,可以 300 行内实现一个最小 MIDI 设备。TinyUSB 对 F407 的 OTG FS 支持很成熟,直接复制examples/device/midi目录里的usb_descriptors.c。另外,如果用 HID 同时传按键状态、用 MIDI 传音序数据,就需要 Composite。TinyUSB 天然支持复合设备,ST 库则要手动拼接描述符,我通常不会去折腾。
TinyUSB 的 MIDI 发送也兼容 4 字节包,但提供了tud_midi_stream_write可以自动拆包,缺点是对 SysEx 的处理比较粗暴,我宁愿自己手动组包,可控性更好。
5.3 一个小技巧:用空闲端点在中断里排队
很多实时 MIDI 应用需要同时收和发,而单一端点只有一个 FIFO。在 F407 上,如果主循环里发送阻塞时间过长,会丢掉入站事件。我一般会在HAL_PCD_DataOutStageCallback里只做入队操作,然后在主循环里批量处理入队和出队。但如果对延迟敏感,可以开一个空闲的 IN 端点(比如 0x82)专门用于 "echo back" 或者应答,这样不会和主 OUT 流争抢。以下是一个简单的非阻塞发送技巧:
uint8_t tx_head, tx_tail; uint8_t tx_ring[64][4]; void midi_enqueue_tx(uint8_t *packet) { uint16_t next = (tx_head + 1) % 64; if (next != tx_tail) { memcpy(tx_ring[tx_head], packet, 4); tx_head = next; } } void midi_poll_tx(void) { if (tx_head != tx_tail && MIDI_Send(tx_ring[tx_tail], 4) == USBD_OK) { tx_tail = (tx_tail + 1) % 64; } }这个技巧解决的是突发数据时由于 USB 发送忙导致丢包的问题。midi_poll_tx放在 main 循环里每几毫秒轮询一次,或者在 TIM 中断里调用。如果队列满,直接丢弃最新的包,来保证老数据先发。注意MIDI_Send的返回值不一定是USBD_OK,ST 库的版本间差异大,最好用HAL_PCD_EP_Transmit的返回值判断——HAL_OK表示端点空且发送已启动。另一个常用做法是直接在回调里发数据,但这要求回调执行时间极短,不适合阻塞式发送。
最后说一个验证收发的硬核手段:在板子上把一个 MIDI Note 自动回传给主机,然后在电脑上用音序器录回波。如果录到的 Note 时间戳和发送时差在 2ms 以内,配置就算合格。这套链路我在 F407 上验证过多次,问题基本只出现在时钟配置和端点描述符不一致这两个点上。
本文还有配套的精品资源,点击获取