STM32F407 USB MIDI设备开发:从协议移植到调试实战
2026/9/16 9:50:33 网站建设 项目流程

简介:STM32F407_UsbMidi.7z 是一套基于STM32F407标准库的USB MIDI设备实现工程,面向嵌入式开发者和电子音乐爱好者,围绕 STM32F4 通过 USB Audio 类接口与宿主机交换 MIDI 消息这一场景,提供可复用的工程源码与配置参考。压缩包共 397 个文件、约 1.5 MB,其中含 109 个头文件和 90 个 C 源文件,覆盖 USB 堆栈初始化、设备描述符编写、音频/MIDI 流接口描述符、端点管理与 MIDI 事件处理等完整代码流程;另有 uvprojx/uvoptx 工程文件、sct 链接脚本、axf/hex 编译输出以及 d、o、crf 等中间文件,便于直接打开工程、编译验证与对照分析。已有 376 人学习下载。通过这套工程可快速掌握 STM32F407 在 USB Audio 模式下作为 MIDI 输入/输出设备的工作流程,包括设备描述符编写、批量/中断端点设置以及中断服务程序的应答逻辑;工程代码模块化清晰,可用于向其他 STM32F4 系列芯片移植,也适合作为理解 USB 协议、MIDI 规范及嵌入式音频数据流处理的参考范例。

1. STM32F407_UsbMidi.7z:把单片机变成 MIDI 设备的最短路径

STM32F407_UsbMidi.7z 这个压缩包名,在搜索它的人眼里往往意味着一条从裸机到可演奏的捷径。它说明芯片是 STM32F407,用 USB 走 MIDI 协议,封装成 7z 大概率是某位工程师动手移植好的完整工程。对做 MIDI 控制器、电子琴键床、合成器音源的人来说,这正是想要的东西:不需要再买一块带 USB 的音频编解码器,也不用把 MIDI 信号先转成串口再接 USB 转串口芯片,STM32F407 内置的 USB OTG FS 外设可以直接枚举为标准的 USB MIDI 设备。下面按我自己的移植习惯,把协议要点、F407 上的代码写法和联调时的抓错列表过一遍,最后给一个 SysEx 长消息的分段发送函数。无论你拿到的是别人整理的工程还是自己从 CubeMX 生成的,这套思路都能直接套用。

2. USB MIDI 协议与 STM32F407 端点设计:从描述符到数据流

在用 STM32F407 写 MIDI 设备的第一行代码之前,得先弄清 USB MIDI 在 USB 层次上的位置。它不是一个独立的 Device Class,而是嵌套在 USB Audio Class 1.0 下的 MIDI Streaming 子类。这就是为什么很多第一次接触的人会在枚举信息里看到 Audio 字样,却找不到 MIDI Device Class。

2.1 双接口结构:AudioControl 和 AudioStreaming

USB MIDI 设备必须实现两个接口:AudioControl(AC)和 AudioStreaming(AS)。AC 接口负责描述设备内部的 MIDI 实体、输入输出插孔(Jack),它不传输 MIDI 消息,所以端点数为 0。AS 接口才是真正承载 MIDI 数据的接口,它带一个 IN 端点用于向主机发送,一个 OUT 端点用于从主机接收。主机加载驱动时,会遍历这两个接口并识别子类码:AC 的子类是 0x01,AS 的子类是 0x03。如果你的描述符漏掉 AS 接口,或者把类编码写成厂商类,Windows 会直接不认。

很多现成的 STM32 USB 例程里,这两个接口是连续写在配置描述符里的,中间穿插若干 CS_INTERFACE 描述符。核对一个工程是否完整,可以打开描述符数组,搜索0x01, 0x03连续字节对,找到它说明 AS 接口存在。找不到的话,插上电脑就是 Unknown Device,或者被识别成泛用 Audio Device。

2.2 4 字节的 MIDI Event Packet:字节对齐决定成败

USB 传输层不感知 MIDI 消息的 3 字节边界,它只看到一组 32 位的 MIDI Event Packet。这个包的格式很简单:第一字节的高半字节是 CIN(Code Index Number),低半字节是 Cable Number,通常设为 0。后面跟着最多 3 字节的 MIDI 数据。例如发送一次 Note On(状态 0x90,音高 0x3C,力度 0x64),对应的 USB 包就是0x09 0x90 0x3C 0x64;如果是一条 Program Change(2 字节),就变成0x0C 0xC0 0x19 0x00,最后一个位置填充 0。

这里有个容易踩的误区:如果固件图省事,直接把 3 字节 MIDI 消息塞进 USB 端点,没有在前面补 CIN,那么主机把数据流按 4 字节对齐解析时,第一个字节会变成消息头,整条数据链全错位。反过来,接收回调里也不能指望 len 是 3 的倍数,必须按 4 的倍数遍历。表里列出了和常用消息对应的 CIN 值。

CIN消息类型典型 MIDI 状态字节
0x8Note Off0x80 - 0x8F
0x9Note On0x90 - 0x9F
0xAPolyphonic Key Pressure0xA0 - 0xAF
0xBControl Change0xB0 - 0xBF
0xCProgram Change0xC0 - 0xCF
0xDChannel Pressure0xD0 - 0xDF
0xEPitch Bend0xE0 - 0xEF
0x4SysEx start/continue0xF0
0x5/0x6/0x7SysEx end0xF7

表里这些值在写发送函数时直接用,不需要额外定义枚举,注释写清楚就不会错。

2.3 选型:为什么 F407 的 FS 外设就是最佳答案

STM32F407 的 USB 有两个独立外设:OTG FS 和 OTG HS。HS 需要外接 ULPI PHY,可选型号有 USB3300 或 USB3320,别和 DP83848 这类以太网 PHY 混为一谈。MIDI 数据率按传统串口 31250 bps 计算,每秒钟也就几百个事件,USB 全速 12 Mbps 的带宽对它来说绰绰有余。F407 的 OTG FS 内置 12 Mbps 收发器,PA11、PA12 直接作为 DM/DP 引脚透出,不用额外加 PHY 芯片。因此 UsbMidi 工程只要 PA8 能感受到 VBUS 的 5V 电平,并且 PLL48CLK 是准确的 48 MHz,就可以正常枚举。

需要留意的是 PA8 的 VBUS 检测功能。在 CubeMX 里配置 USB_OTG_FS 时,Device 模式会默认启用 PA8 的 GPIO_Input。如果打样用的是 Type-C 接口,VBUS 信号线上最好经过限流电阻再进 PA8,防止热插拔时 5V 尖峰打坏 GPIO。实际调试中,见过好几块板子枚举失败,最后发现是 PA8 被配置成了普通输出,或者线缆另一头根本没供电。用万用表量 PA8 对地电压,插线后应接近 3.3V 或 5V,这个动作能排除一半的硬件问题。

2.4 配置描述符里必须写对的关键字段

要实现 UsbMidi,配置描述符必须包含下面这些被主机解析的字节。完整的 AC 接口还包含 CS_INTERFACE 和 JACK 描述符,理论上是必需的,但很多工程模板已经写好,这里重点看 AS 接口和端点。

/* AudioStreaming 接口开始 */ 0x09, /* bLength */ USB_DESC_TYPE_INTERFACE, /* bDescriptorType */ 0x01, /* bInterfaceNumber = 1 */ 0x00, /* bAlternateSetting */ 0x02, /* bNumEndpoints:IN + OUT 两个 */ 0x01, /* bInterfaceClass:Audio */ 0x03, /* bInterfaceSubClass:MIDI Streaming */ 0x00, /* bInterfaceProtocol */ 0x00, /* iInterface */ /* MIDI Streaming Data Endpoint: IN */ 0x07, /* bLength */ USB_DESC_TYPE_ENDPOINT, /* bDescriptorType */ 0x81, /* bEndpointAddress:IN EP1 */ 0x02, /* bmAttributes:Bulk */ 0x40, 0x00, /* wMaxPacketSize = 64 */ 0x00, /* bInterval:Bulk 下为 0 */ /* MIDI Streaming Data Endpoint: OUT */ 0x07, USB_DESC_TYPE_ENDPOINT, 0x01, /* bEndpointAddress:OUT EP1 */ 0x02, 0x40, 0x00, 0x00,

这里的关键是 AS 接口的0x01类和0x03子类,主机的 MIDI 驱动靠这两个字节决定是否接管设备。端点属性用 Bulk 还是 Interrupt 都行,很多 Windows 和 macOS 的例子默认用 Interrupt 来降低延迟,但 F407 的 ST USB 库在设备模式下对 Bulk 有更简单的 DMA 路径,我用 Bulk 比较多。wMaxPacketSize=64对应全速 USB 一个事务的最大包长,比它小不是不能用,但主机每毫秒只能调度一轮,包小会浪费带宽。

3. 从解压到编译:在 STM32F407 标准库上移植 UsbMidi

拿到 STM32F407_UsbMidi.7z 后,第一件事不是打开工程看代码,而是先确认它适配的是标准外设库还是 HAL 库。库不同,回调函数名完全不同,混用会出现大量undefined reference

3.1 工程里需要的文件和你应该检查的部分

一个典型的 F407 UsbMidi 工程在 Keil 下的结构是这样的:标准外设库的 stm32f4xx_usb_otg.c、stm32f4xx_usb_dcd_int.c,再加上 ST USB Device Library 的 usbd_core.c、usbd_ctlreq.c、usbd_stdreq.c,以及一个自己实现的 usbd_midi.c 和 usbd_midi.h。最后两个文件是核心,里面的 MIDI 描述符数组和回调函数决定了设备到底能不能被识别。

检查工程时,优先看 usbd_midi.c 里的USBD_MIDI_CfgDesc[]是否包含上面写的 0x01/0x03 字节对,再找USBD_MIDI_DataIn_HandlerUSBD_MIDI_DataOut_Handler两个函数名,确认它们在 pdev 的端点回调表里被正确挂载。很多工程和正点原子结构例程的差异就在这里:回调挂错端点号,发送永远发不出去。如果你手上的压缩包只有一份散装代码而不是完整 Keil 工程,建议直接新建工程,把 USB Device Library 的源文件加进去,再复制 usbd_midi.c,这样最干净。

3.2 最小初始化代码:RCC、GPIO、FIFO

不依赖 CubeMX,标准库下的初始化大致是这样。假设外部晶振 8MHz,PLL 已经配置出 168MHz 系统时钟和 48MHz USB 时钟,下面只列出 USB 外设相关部分。

void MX_USB_DEVICE_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA | RCC_AHB1Periph_OTG_FS, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_11 | GPIO_Pin_12; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz; GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_NOPULL; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_PinAFConfig(GPIOA, GPIO_PinSource11, GPIO_AF_OTG_FS); GPIO_PinAFConfig(GPIOA, GPIO_PinSource12, GPIO_AF_OTG_FS); USBD_Init(&USB_OTG_dev, USB_OTG_FS_CORE_ID, &USBD_MIDI_cb, &USBD_MIDI_Desc, &USBD_MIDI_CfgDesc); }

这段代码做了什么:第 4 行开启 GPIOA 和 OTG_FS 的时钟;PA11、PA12 配置为复用推挽输出,并指定复用功能为GPIO_AF_OTG_FS。最后一行把设备描述符、配置描述符和回调结构体绑定到 USB 设备库。参数USB_OTG_FS_CORE_ID告诉库使用 FS 核心而不是 HS 核心,用错的话 PA11/PA12 上的信号不会起来。OTG_FS 的端点 FIFO 配置一般在 usbd_conf.h 里,USBD_FSINEP0_MAXPACKET保持 64,IN 端点的 FIFO 大小建议设为 128 字节,避免某些驱动在枚举完成后一次性查询多个描述符时溢出。

3.3 发送和接收回调:一个能跑的最小示例

发送函数要做的就是把 MIDI 消息打成一个 4 字节 Event Packet。下面这个函数把 Note On 消息封装好并提交到端点。

uint8_t usb_midi_tx_buf[4]; void UsbMidi_SendNoteOn(uint8_t channel, uint8_t key, uint8_t velocity) { usb_midi_tx_buf[0] = 0x09; /* CIN: Note On */ usb_midi_tx_buf[1] = 0x90 | (channel & 0x0F); /* 状态字节 */ usb_midi_tx_buf[2] = key & 0x7F; usb_midi_tx_buf[3] = velocity & 0x7F; USBD_MIDI_Transmit(&USB_OTG_dev, usb_midi_tx_buf, 4); }

channel是 0 到 15 的 MIDI 通道,key是音高编号,velocity是力度。和串口 MIDI 不同,这里的状态字节不需要复用上一次的值,每次手动拼完整状态字节最省心。USBD_MIDI_Transmit内部会申请端点 FIFO,如果 FIFO 满,调用会返回错误码。

接收回调的典型写法是:

uint8_t midi_rx_buf[64]; int8_t USBD_MIDI_DataOut_Handler(USBD_HandleTypeDef *pdev, uint8_t epnum, uint8_t *data, uint16_t len) { for (uint16_t i = 0; i + 3 < len; i += 4) { if ((data[i] & 0xF0) == 0x90) { uint8_t note = data[i + 2]; uint8_t vel = data[i + 3]; /* 做你的发声或控制逻辑 */ } } USBD_MIDI_Receive(pdev, epnum, midi_rx_buf, sizeof(midi_rx_buf)); return USBD_OK; }

data缓冲区是库内部维护的,len可能是 4、8、12 等 4 的倍数。循环里用i + 3 < len防止越界,同时保证只解析完整 Event Packet。最后一行USBD_MIDI_Receive必须重新调用,作用是重新挂载接收缓冲区,否则主机发下一包数据到端点时,设备端没有缓冲区可用,表现就是只能收到第一串消息。

3.4 编译链接时的两个经典问题

第一个是缓冲区对齐。USB DMA 要求 4 字节对齐,所以收发缓冲区的定义建议这样写,否则在开了 DMA 优化的库中会随机出错:

__ALIGN_BEGIN uint8_t midi_rx_buf[64] __ALIGN_END;

__ALIGN_BEGIN__ALIGN_END是 ST 标准库和 CMSIS 里预定义的宏,Keil 下翻译成__align(4)

第二个是 CCM RAM 的问题。STM32F407 有 64KB 的 CCM RAM,地址从 0x10000000 开始,只能被内核访问,不能给 USB 的 DMA 控制器使用。有的工程师为了省主 RAM 把midi_rx_buf放到 CCM RAM,结果发现 USB 枚举正常,但收不到任何数据,因为 DCD 层直接使用 DMA 搬运,DMA 访问不了这块区域。凡是传给 USB 库函数的数组,都必须放在普通 SRAM 里,并在启动文件或 linker 文件里显式保留默认 RAM 区域。

4. STM32F407 UsbMidi 联调实录:从设备冒烟到 PC 端数据验证

4.1 插上电脑后,先看两个“设备已经识别”的信号

一个写好描述符的 UsbMidi 设备插到 Windows 上,首先会在右下角提示设置 USB 设备,随后设备管理器里出现 MIDI 分类下的 USB MIDI Interface 或具体品牌名。macOS 上可以用system_profiler SPUSBDataType查看,设备的 MIDI 属性会显示为 yes。如果看到的还是 USB Input Device 或其他 HID 设备,别怀疑,描述符肯定没按第 2 章说的写。此时不要急着换线,先用 USBlyzer 抓一次枚举包,比对配置描述符里的接口子类是不是 0x03。

4.2 用 Python 做回环测试,验证发送和接收路径

如果固件里已经写了轮询按键发送 Note On,或者做了 echo,用 mido 这个 Python 库做端到端验证最直观。先安装依赖并列出端口:

pip install mido python -c "import mido; print(mido.get_input_names()); print(mido.get_output_names())"

假设打印出来的设备名是 USB MIDI Interface,下面这段脚本会让设备发出一个 Note On,再等它回显:

import mido port_name = 'USB MIDI Interface' outport = mido.open_output(port_name) inport = mido.open_input(port_name) outport.send(mido.Message('note_on', note=60, velocity=100)) msg = inport.receive() print(msg)

open_outputopen_input需要对应设备在系统 MIDI 服务中的完整名称,如果列表里有多个同名前缀,用下标选第一个即可。receive()是阻塞函数,如果固件没有回,会在那里卡住,可以给它一个超时限制,或者改用 rtmidi 的低层 API。回环验证通过后,再拿一台软音源播放 Note On,整个链路就完全通了。

4.3 用逻辑分析仪和 Bus Hound 隔离故障层

当 PC 端不出 MIDI 设备时,最快的定位方法是用逻辑分析仪抓 PA12 (D+) 上电瞬间的状态。USB 全速设备要求 D+ 被拉高后 1 秒内完成枚举。分析仪里至少能看到主机发的复位信号和后面的 SETUP 包,如果完全看不到,说明 D+ 上拉或 VBUS 检测没生效。stm32f407 pa8 vbus typec就是这类问题的标配搜索词:Type-C 线缆插头里没有默认上拉,如果板子上的 VBUS 检测路径没走对,OTG 控制器根本感知不到线缆插入。

到了包层级,Bus Hound 抓批量传输最顺手。配置端点地址为 0x81,在 USB Bus 页签里选择 Capture,插拔一次后过滤 IN 事务,看是否出现 64 字节包。如果包内容全是 0,大概率是发送缓冲区没更新;如果出现 09 90 3C 64 之后又紧跟一个错误数据,往往是回调里对 len 的解析越界,或者 DMA 和外设时序冲突。

4.4 一张排查对照表,把最常见的几个症状列清楚

现象第一排查点第二排查点
Unknown DevicePA8 VBUS 电压DP/DM 对地电阻
枚举成功但不出现 MIDI 设备AS 接口子类不是 0x03缺少 JACK 描述符
发送音符没反应端点地址是否为 0x81FIFO 是否配置 128 字节
收到一次后停止是否重新调用 Receive缓冲区长度是否 4 的倍数
系统卡死或死机缓冲区是否在 CCM RAM中断优先级是否过低

这张表是我在排掉大量 F407 USB 问题后整理的。第一列若没有,直接按表往下查,通常十分钟内能定位到具体项。

5. UsbMidi 进阶处理:SysEx 分段发送与中断安全状态标志

5.1 每 3 字节一段的 SysEx 封装

SysEx 是和设备厂商私有交互的主要手段,例如调音台快照、固件更新、音色导入。SysEx 消息长度不固定,可以超过一包 USB 数据,所以必须拆分。USB MIDI 规定,每包最多带 3 字节 SysEx 数据,并在包头的 CIN 标识当前段的位置。发送函数可以这样写:

void UsbMidi_SendSysEx(const uint8_t *data, uint16_t len) { uint16_t idx = 0; while (idx < len) { uint8_t pkt[4]; uint16_t remain = len - idx; uint8_t take = remain > 3 ? 3 : remain; if (idx == 0) pkt[0] = 0x04; /* SysEx start */ else if (remain == 1) pkt[0] = 0x05; /* SysEx end + 1 byte */ else if (remain == 2) pkt[0] = 0x06; /* SysEx end + 2 bytes */ else if (remain == 3) pkt[0] = 0x07; /* SysEx end + 3 bytes */ else pkt[0] = 0x04; /* SysEx continue */ memcpy(&pkt[1], &data[idx], take); if (take < 3) memset(&pkt[1 + take], 0, 3 - take); USBD_MIDI_Transmit(&USB_OTG_dev, pkt, 4); idx += take; } }

逻辑说明:idx指向当前已发送位置,每次最多发 3 字节。第一包固定用 0x04,中间段也用 0x04,最后一包根据剩余字节数选择 0x05、0x06 或 0x07。补齐填充字节是为了保证总是 4 字节包。这里的USBD_MIDI_Transmit返回错误就表明 FIFO 正忙,这个实现没有等 FIFO,适合数据量小、发送不密集的场景。

5.2 配合中断发送时的忙标志

当 SysEx 发送和 Note 消息来自不同上下文时,比如主循环调 SendSysEx,定时器中断里调 SendNoteOn,两个函数会竞争同一个端点。最省事的做法是维护一个volatile uint8_t midi_tx_busy标志,在 Transmit 前检查,忙就直接丢。需要注意volatile只能保证编译器不优化,并不能解决并发修改;如果两个上下文同时执行if (!busy)判断,会同时通过检查。F407 的 USB 中断里跑的回调函数一般会和主循环并行,最稳妥的方法是关中断后再检查标志。

__disable_irq(); if (!midi_tx_busy) { midi_tx_busy = 1; USBD_MIDI_Transmit(&USB_OTG_dev, pkt, 4); } __enable_irq();

在 FreeRTOS 工程里,__disable_irq()会屏蔽全局中断,如果 USB 中断的优先级已经设置合理,不用担心死锁。更好的做法是使用taskENTER_CRITICAL()taskEXIT_CRITICAL(),它们只锁定当前优先级能调度的临界区,不会误伤 USB 实时性。发送完成回调里要记得清掉midi_tx_busy,否则丢一次后整个链路就静默了。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询