news 2026/9/16 16:31:51

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 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),描述后续数据的类型
byte1MIDI 状态字节或第一数据
byte2Second data byte
byte3Third 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_MRCC_PLL_NRCC_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 共存的主流方式。步骤顺序很关键:

  1. 新建工程,选择 CPU 为STM32F407VGTx或具体型号。
  2. System Core > RCC:HSE 选 Crystal/Ceramic Resonator。
  3. 点击USB_OTG_FS,Mode 选Device_Only
  4. Middleware组里选USB_DEVICE,Class 下拉里选AUDIO_MIDI或者MIDI(老版本叫MIDI,新版本归类为Audio)。这里如果找不到该选项,说明你的 STM32CubeF4 固件包太老,需要下载 1.26 以上版本。
  5. 工程生成后,在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 On90 3C 6409 90 3C 64
Note Off80 3C 4008 80 3C 40
Control ChangeB0 07 7F0B B0 07 7F
Program ChangeC0 05 000C C0 05 00
Pitch BendE0 00 400E E0 00 40
SysEx 开始/继续F0 7E 7F __0F F0 7E 7F 不适用
Active SensingFE0F FE 00 00
ClockF80F 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,最后一块用0xF20xF3看情况。其实更简单的办法是直接把 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.cMIDI_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 里的bNumEmbMIDIJackbNumEmbMIDIJack没有匹配真正定义的 jack 数量。如果只做单一 cable MIDI,就必须定义两个 jack:Embedded In Jack 和 Embedded Out Jack,外加两个 Jack Descriptor 指向它们。

5. 验证与进阶:从 Windows 看到设备到做回环测试

5.1 用 midi 工具验证收发

设备枚举成功后,Windows 系统里会出现一个叫 "USB MIDI" 的输入输出端口。验证接收可以直接用 MIDI 工具发送一条消息,看板子是否把数据放入指定的回调。我最常用的免费工具是midi_monitorloopMIDI,也可以写一段 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_readtud_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 上验证过多次,问题基本只出现在时钟配置和端点描述符不一致这两个点上。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 16:30:49

嵌入式架构选型:MCU、MPU与SoC的边界与迁移实战

做嵌入式这些年,我最大的教训是:选 MCU、MPU 还是 SoC,千万别只盯着参数表。五年前做一款工业网关,当时团队最熟的平台是 STM32,方案评审阶段大家一致选 MCU 主控,理由很充分:便宜、功耗低、团队…

作者头像 李华
网站建设 2026/9/16 16:28:26

MyBatis多对一关系映射实战与优化

1. 项目概述在数据库设计中,多对一关系是最常见的数据关联方式之一。比如一个部门可以有多个员工,但每个员工只属于一个部门。这种关系在实际业务场景中无处不在,但在ORM框架中如何优雅地处理这种映射关系,一直是开发者需要面对的…

作者头像 李华
网站建设 2026/9/16 16:26:57

STM32F103红外循迹与超声波避障协同控制实战

简介:本资源是一套基于STM32F103微控制器的智能循迹避障小车完整工程代码与开发资料,面向嵌入式初学者、电子设计竞赛备赛学生及STM32实践开发者,解决智能小车自主导航、路径跟踪与动态避障的核心实现问题。压缩包共192个文件,含3…

作者头像 李华