ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32F407 USB MIDI实现:从CubeMX配置到端点收发详解

2026/9/16 16:31:57 拓冰建站 浏览量
STM32F407 USB MIDI实现:从CubeMX配置到端点收发详解 简介一份基于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 StreamingUSB 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)描述后续数据的类型byte1MIDI 状态字节或第一数据byte2Second data bytebyte3Third data byte比如一条 Note On状态字 0x90音高 60力度 100会被封装成0x09 0x90 0x3C 0x64。其中0x09是 Cable Number0CIN9注意 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 没起来就卡在 48MHzF407 的 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 MHzPLL_M 8PLL_N 336PLL_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 RCCHSE 选 Crystal/Ceramic Resonator。点击USB_OTG_FSMode 选Device_Only。在Middleware组里选USB_DEVICEClass 下拉里选AUDIO_MIDI或者MIDI老版本叫MIDI新版本归类为Audio。这里如果找不到该选项说明你的 STM32CubeF4 固件包太老需要下载 1.26 以上版本。工程生成后在usbd_midi.c里把端点参数改到咱们需要的值。关键端点定义在usbd_midi.hST 库早期版本或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 (Cable0)Note On90 3C 6409 90 3C 64Note Off80 3C 4008 80 3C 40Control ChangeB0 07 7F0B B0 07 7FProgram ChangeC0 05 000C C0 05 00Pitch BendE0 00 400E E0 00 40SysEx 开始/继续F0 7E 7F __0F F0 7E 7F 不适用Active SensingFE0F FE 00 00ClockF80F 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 字节一组加上 CIN0x04SysEx end或0x03SysEx continue但不同主机的解析有差异。稳定性最好的做法还是每 3 字节一个块最后一个不满 3 字节补 0CIN 分别按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, CIN9 packet[1] 0x90 | (channel 0x0F); packet[2] note 0x7F; packet[3] velocity 0x7F; MIDI_Send(packet, 4); }这里的参数说明channel是 0~15对应 MIDI 通道 1~16note0~127velocity也是 0~127。packet[1]的状态字节由 0x90Note On和通道号或运算得到。如果 velocity 为 0大多数合成器按 Note Off 处理所以不用刻意转换。4.2 端点描述符与最大包长的配置USB MIDI 设备的端点描述符不是随便选参数的。全速 MIDI Streaming 通常用一个 Bulk IN 和一个 Bulk OUT 端点包长为 64。若是 Interrupt 端点最大包长用全速 64bInterval 必须大于 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就必须定义两个 jackEmbedded 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, note60, velocity100, channel0))在 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_OKST 库的版本间差异大最好用HAL_PCD_EP_Transmit的返回值判断——HAL_OK表示端点空且发送已启动。另一个常用做法是直接在回调里发数据但这要求回调执行时间极短不适合阻塞式发送。最后说一个验证收发的硬核手段在板子上把一个 MIDI Note 自动回传给主机然后在电脑上用音序器录回波。如果录到的 Note 时间戳和发送时差在 2ms 以内配置就算合格。这套链路我在 F407 上验证过多次问题基本只出现在时钟配置和端点描述符不一致这两个点上。本文还有配套的精品资源点击获取