
简介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 和 AudioStreamingUSB MIDI 设备必须实现两个接口AudioControlAC和 AudioStreamingAS。AC 接口负责描述设备内部的 MIDI 实体、输入输出插孔Jack它不传输 MIDI 消息所以端点数为 0。AS 接口才是真正承载 MIDI 数据的接口它带一个 IN 端点用于向主机发送一个 OUT 端点用于从主机接收。主机加载驱动时会遍历这两个接口并识别子类码AC 的子类是 0x01AS 的子类是 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。这个包的格式很简单第一字节的高半字节是 CINCode Index Number低半字节是 Cable Number通常设为 0。后面跟着最多 3 字节的 MIDI 数据。例如发送一次 Note On状态 0x90音高 0x3C力度 0x64对应的 USB 包就是0x09 0x90 0x3C 0x64如果是一条 Program Change2 字节就变成0x0C 0xC0 0x19 0x00最后一个位置填充 0。这里有个容易踩的误区如果固件图省事直接把 3 字节 MIDI 消息塞进 USB 端点没有在前面补 CIN那么主机把数据流按 4 字节对齐解析时第一个字节会变成消息头整条数据链全错位。反过来接收回调里也不能指望 len 是 3 的倍数必须按 4 的倍数遍历。表里列出了和常用消息对应的 CIN 值。CIN消息类型典型 MIDI 状态字节0x8Note Off0x80 - 0x8F0x9Note On0x90 - 0x9F0xAPolyphonic Key Pressure0xA0 - 0xAF0xBControl Change0xB0 - 0xBF0xCProgram Change0xC0 - 0xCF0xDChannel Pressure0xD0 - 0xDF0xEPitch Bend0xE0 - 0xEF0x4SysEx start/continue0xF00x5/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, /* bNumEndpointsIN OUT 两个 */ 0x01, /* bInterfaceClassAudio */ 0x03, /* bInterfaceSubClassMIDI Streaming */ 0x00, /* bInterfaceProtocol */ 0x00, /* iInterface */ /* MIDI Streaming Data Endpoint: IN */ 0x07, /* bLength */ USB_DESC_TYPE_ENDPOINT, /* bDescriptorType */ 0x81, /* bEndpointAddressIN EP1 */ 0x02, /* bmAttributesBulk */ 0x40, 0x00, /* wMaxPacketSize 64 */ 0x00, /* bIntervalBulk 下为 0 */ /* MIDI Streaming Data Endpoint: OUT */ 0x07, USB_DESC_TYPE_ENDPOINT, 0x01, /* bEndpointAddressOUT EP1 */ 0x02, 0x40, 0x00, 0x00,这里的关键是 AS 接口的0x01类和0x03子类主机的 MIDI 驱动靠这两个字节决定是否接管设备。端点属性用 Bulk 还是 Interrupt 都行很多 Windows 和 macOS 的例子默认用 Interrupt 来降低延迟但 F407 的 ST USB 库在设备模式下对 Bulk 有更简单的 DMA 路径我用 Bulk 比较多。wMaxPacketSize64对应全速 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_Handler和USBD_MIDI_DataOut_Handler两个函数名确认它们在 pdev 的端点回调表里被正确挂载。很多工程和正点原子结构例程的差异就在这里回调挂错端点号发送永远发不出去。如果你手上的压缩包只有一份散装代码而不是完整 Keil 工程建议直接新建工程把 USB Device Library 的源文件加进去再复制 usbd_midi.c这样最干净。3.2 最小初始化代码RCC、GPIO、FIFO不依赖 CubeMX标准库下的初始化大致是这样。假设外部晶振 8MHzPLL 已经配置出 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保持 64IN 端点的 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, note60, velocity100)) msg inport.receive() print(msg)open_output和open_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否则丢一次后整个链路就静默了。本文还有配套的精品资源点击获取