首页 / 资讯中心 / 文章详情

STM32F103实现USB摄像头:UVC协议与图像推送详解

STM32F103实现USB摄像头:UVC协议与图像推送详解 ★ FEATURED ARTICLE
简介这份资源面向具备一定嵌入式基础的STM32开发者聚焦于用STM32微控制器模拟USB摄像头并向PC发送图像这一实战课题。内容围绕USB视频设备类UVC规范的固件实现展开涉及USB控制器配置、设备枚举、视频流封装传输、图像采集与主机端兼容性等关键环节适合希望深入理解USB设备编程与图像传输流程的读者参考。压缩包共240个文件约2.05MB以62个h头文件、44个c源文件为核心代码辅以工程编译产生的o、d、crf等中间文件及uvproj工程配置、hex烧录文件构成一套可直接在Keil环境下打开分析的完整工程。目前已有742人学习下载读者可从中获取UVC枚举描述符编写、端点与缓冲区管理、图像数据打包发送等具体实现思路并借助工程结构梳理STM32模拟摄像头的整体开发脉络为自行移植与调试提供可对照的代码基础。1. 把 STM32 插上电脑它就成了一颗 USB 摄像头手头有一块 STM32F103 最小系统板板子上没有摄像头接口也没有 SD 卡槽只有一颗 USB 口和几根杜邦线。某天我想验证一个上位机的图像采集流程又不想为了测试专门去买一个 USB 摄像头于是冒出一个念头能不能让这块板子自己伪装成一颗 UVC 摄像头插上电脑之后系统设备管理器里直接多出一个「USB Video Device」打开相机应用就能看到画面这个想法听起来有点玄学但技术上完全成立。STM32F103 自带 USB 全速外设配合 ST 官方的 USB 设备库把设备描述符里的接口类改成 Video Class再实现几个 UVC 规范要求的控制请求主机就会把它当成一颗标准摄像头。图像数据不需要真实采集用内存里生成的一张测试图或者一段固定图案通过等时端点或者批量端点推给主机就行。整个项目不依赖任何外部摄像头模组一块十几块钱的板子加一根 USB 线就能跑起来。适合谁来读这份解析一类是想搞懂 USB 设备枚举流程、想拿 UVC 当练手对象的嵌入式开发者另一类是做上位机图像采集、需要一个可控图像源来联调的同学。下面从工程结构、描述符配置、请求处理、数据推送几个层面把这份源码拆开讲清楚最后落到怎么改分辨率、怎么换图像源这些二次开发动作上。2. 工程结构与 USB 设备栈从时钟树到端点缓冲区2.1 目录分层与文件职责拿到源码包第一件事不是急着编译而是先看清楚哪些文件是 ST 官方库、哪些是项目自己写的。这类工程通常长这样project/ ├── CMSIS/ # 内核头文件与启动文件 ├── STM32F10x_StdPeriph_Driver/ # 标准外设库 ├── USB-FS-Device_Driver/ # ST 官方 USB 设备底层驱动 │ ├── src/ │ │ ├── usb_core.c # USB 协议核心状态机 │ │ ├── usb_init.c # 设备初始化与复位处理 │ │ ├── usb_int.c # 中断服务入口 │ │ ├── usb_mem.c # 端点缓冲区读写 │ │ └── usb_regs.c # 寄存器操作封装 │ └── inc/ ├── usb_application/ # 项目自己的 UVC 实现 │ ├── usb_desc.c # 设备/配置/接口描述符 │ ├── usb_endp.c # 端点回调 │ ├── usb_istr.c # 中断请求分发 │ ├── usb_prop.c # 类请求处理UVC 核心 │ └── usb_pwr.c # 电源管理 ├── uvc/ # 图像数据与 UVC 协议常量 │ ├── uvc_camera.c # 帧数据生成与推送 │ └── uvc_desc.h # UVC 描述符宏定义 └── main.c这个分层很关键。USB-FS-Device_Driver是 ST 提供的通用设备栈负责枚举、端点收发、中断处理这些和具体设备类无关的事情usb_application才是把设备「装扮」成摄像头的部分。二次开发时绝大多数改动落在usb_application和uvc两个目录底层驱动基本不动。我见过有人一上来就去改usb_core.c结果枚举都过不去血泪经验是先把边界划清楚。2.2 时钟与 USB 外设初始化STM32F103 的 USB 外设要求 48MHz 时钟这个时钟必须由 PLL 分频得到不能直接用外部晶振。常见做法是外部 8MHz 晶振PLL 倍频到 72MHz 作为系统时钟再二分频得到 48MHz 给 USB。初始化顺序错了USB 根本不会枚举。// main.c 中的时钟配置片段 void RCC_Configuration(void) { RCC_DeInit(); RCC_HSEConfig(RCC_HSE_ON); // 外部 8MHz 晶振 while (RCC_GetFlagStatus(RCC_FLAG_HSERDY) RESET); RCC_PLLConfig(RCC_PLLSource_HSE_Div1, RCC_PLLMul_9); // 8 * 9 72MHz RCC_PLLCmd(ENABLE); while (RCC_GetFlagStatus(RCC_FLAG_PLLRDY) RESET); RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK); RCC_USBCLKConfig(RCC_USBCLKSource_PLLCLK_Div1_5); // 72 / 1.5 48MHz RCC_APB1PeriphClockCmd(RCC_APB1Periph_USB, ENABLE); }这里有两个参数容易翻车。RCC_PLLMul_9决定了系统主频如果板子用的是 12MHz 晶振这个倍频系数要改成 6否则 USB 时钟对不上主机识别会时好时坏。RCC_USBCLKSource_PLLCLK_Div1_5是固定写法USB 时钟必须是精确的 48MHz差一点都会导致枚举失败。初始化完成后还要调用USB_Init()让设备栈接管这个函数内部会配置 USB 中断、复位端点、等待主机发来的 SETUP 包。2.3 端点规划与缓冲区分配USB 全速设备最多有 8 个双向端点UVC 摄像头通常需要三个控制端点 EP0 负责枚举和类请求等时端点或者批量端点负责图像数据另一个可选端点负责状态。STM32F103 的 USB 缓冲区总共 512 字节需要按端点分配。// usb_prop.c 中的端点初始化 #define UVC_DATA_EP 0x81 // IN 端点 1方向为主机到设备 #define UVC_EP_SIZE 64 // 全速下批量端点最大包长 void UVC_Reset(void) { SetEPType(ENDP0, EP_CONTROL); SetEPTxStatus(ENDP0, EP_TX_STALL); SetEPType(UVC_DATA_EP, EP_BULK); SetEPRxAddr(UVC_DATA_EP, 0x100); // 缓冲区起始地址 SetEPRxCount(UVC_DATA_EP, UVC_EP_SIZE); SetEPRxStatus(UVC_DATA_EP, EP_RX_VALID); }SetEPRxAddr里的0x100是 USB 专用缓冲区里的偏移地址不是普通内存地址这一点新手很容易搞混。缓冲区总大小 512 字节EP0 通常占 64 字节剩下的分给数据端点。如果分辨率调大、包长增加要重新算这个偏移否则端点之间会互相踩内存现象是图像数据错乱或者主机直接断开。我一般会在注释里把每个端点的地址区间写清楚省得后面改的时候自己都忘了。3. UVC 描述符配置让主机认得出这是一颗摄像头3.1 设备描述符与配置描述符的类字段USB 枚举的本质是主机一层层读描述符然后根据描述符里的类代码加载对应驱动。普通 USB 设备在设备描述符里写bDeviceClass 0x00表示类信息在接口描述符里定义接口描述符的bInterfaceClass写0x0E就是 Video Class。这两个字段是主机判断「这是不是摄像头」的第一道关卡。// usb_desc.c 中的设备描述符 const uint8_t UVC_DeviceDescriptor[] { 0x12, // bLength 0x01, // bDescriptorType DEVICE 0x00, 0x02, // bcdUSB 2.0 0x00, // bDeviceClass类信息放到接口层 0x00, // bDeviceSubClass 0x00, // bDeviceProtocol 0x40, // bMaxPacketSize0 64 0x83, 0x04, // idVendor示例值 0x40, 0x57, // idProduct示例值 0x00, 0x01, // bcdDevice 0x01, // iManufacturer 0x02, // iProduct 0x00, // iSerialNumber 0x01 // bNumConfigurations };idVendor和idProduct这两个字段在示例里是占位值实际使用时如果不想和系统里已有驱动冲突可以换成自己申请的 VID/PID或者干脆用一组不常见的值。但要注意Windows 对 UVC 设备有内置驱动只要类代码对VID/PID 不是必须匹配某个白名单。真正决定成败的是后面的接口关联描述符和类特定描述符少一个字段主机就可能只把它识别成「未知 USB 设备」。3.2 接口关联描述符与视频控制接口UVC 规范要求设备至少有两个接口视频控制接口VideoControl和视频流接口VideoStreaming。前者负责摄像头参数控制后者负责图像数据传输。为了让主机把这两个接口当成同一个功能还需要一个接口关联描述符IAD把它们绑在一起。// usb_desc.c 中的配置描述符片段 const uint8_t UVC_ConfigDescriptor[] { // 配置描述符 0x09, 0x02, 0x9A, 0x00, 0x02, 0x01, 0x00, 0x80, 0x32, // 接口关联描述符 IAD 0x08, 0x0B, 0x00, 0x02, 0x0E, 0x03, 0x00, 0x00, // 视频控制接口 0x09, 0x04, 0x00, 0x00, 0x01, 0x0E, 0x01, 0x00, 0x00, // 视频控制接口的类特定描述符头部 0x0D, 0x24, 0x01, 0x00, 0x01, 0x4D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 摄像头输入终端描述符 0x12, 0x24, 0x02, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 输出终端描述符 0x09, 0x24, 0x03, 0x03, 0x01, 0x01, 0x00, 0x00, 0x00, // 视频流接口 0x09, 0x04, 0x01, 0x00, 0x00, 0x0E, 0x02, 0x00, 0x00, // 视频流接口的类特定描述符输入头部 0x0E, 0x24, 0x01, 0x01, 0x01, 0x4D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 格式描述符MJPEG 或未压缩 0x1B, 0x24, 0x06, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 帧描述符 0x1E, 0x24, 0x07, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };这段描述符数组看起来密密麻麻但结构是有规律的每个描述符第一个字节是长度第二个字节是类型。0x24开头的都是 UVC 类特定描述符0x0B是 IAD。帧描述符里的分辨率、帧率字段在示例里是占位实际要按你打算输出的图像尺寸填。常见做法是先填一个 320x240、15fps 的配置跑通之后再往上加。描述符总长度0x9A要和实际数组长度一致不一致的话主机读配置时会截断现象是设备能识别但打不开。3.3 字符串描述符与设备名称字符串描述符决定了设备在系统里显示的名字。UVC 设备通常会在产品字符串里写「USB Camera」之类的名称方便用户辨认。字符串是 UTF-16LE 编码每个字符占两个字节前面加一个长度字节和类型字节。const uint8_t UVC_StringProduct[] { 0x1A, 0x03, U, 0x00, S, 0x00, B, 0x00, , 0x00, C, 0x00, a, 0x00, m, 0x00, e, 0x00, r, 0x00, a, 0x00 };长度字节0x1A等于 2 加字符数乘 2算错了主机会读越界或者显示乱码。这个细节不起眼但调试时如果发现设备名是乱码先回来检查这个长度。字符串索引在设备描述符的iProduct字段里引用索引值要和字符串描述符数组的顺序对上错位了就会显示成别的字符串。4. 类请求处理与图像数据推送从 SETUP 包到一帧画面4.1 UVC 类请求的分发逻辑主机枚举完设备之后会发一系列 UVC 类请求来查询摄像头能力、设置参数、启动视频流。这些请求走控制端点 EP0bRequest字段是请求码wIndex指明目标接口和实体。STM32 这边在usb_prop.c的Data_Setup或者NoData_Setup回调里分发处理。// usb_prop.c 中的类请求处理 RESULT UVC_Data_Setup(uint8_t RequestNo) { uint8_t *pwBuf; uint16_t len; switch (RequestNo) { case GET_CUR: // 查询当前值 case GET_MIN: case GET_MAX: case GET_RES: // 根据 wIndex 高字节判断是哪个实体 switch (USB_Received_Index_High()) { case 0x01: // 摄像头终端 pwBuf (uint8_t *)camera_terminal; len sizeof(camera_terminal); break; case 0x02: // 处理单元 pwBuf (uint8_t *)processing_unit; len sizeof(processing_unit); break; default: return USB_NOT_STALLED; } UserToPMABufferCopy(pwBuf, GetEPRxAddr(ENDP0), len); SetEPTxCount(ENDP0, len); SetEPTxStatus(ENDP0, EP_TX_VALID); return USB_NOT_STALLED; case SET_CUR: // 设置参数比如亮度、对比度 // 读取主机下发的数据更新内部状态 return USB_NOT_STALLED; default: return USB_NOT_STALLED; } }USB_Received_Index_High()取的是wIndex的高字节UVC 规范里这个字节表示实体 ID。摄像头终端 ID 是 1处理单元 ID 是 2和描述符里定义的实体 ID 必须一致。如果主机发来的请求实体 ID 在描述符里找不到设备应该返回 STALL但示例里为了简化直接返回USB_NOT_STALLED这在调试阶段没问题正式用的时候最好按规范回 STALL否则某些主机会反复重试。4.2 视频流启动与帧数据生成主机打开摄像头应用后会先发SET_CUR请求设置视频流接口的交替设置然后开始从数据端点读数据。STM32 这边要在端点回调里准备好一帧图像按包长切分后依次推给主机。// uvc_camera.c 中的帧生成与推送 #define FRAME_WIDTH 320 #define FRAME_HEIGHT 240 #define FRAME_SIZE (FRAME_WIDTH * FRAME_HEIGHT * 2) // YUY2 格式 static uint8_t frame_buffer[FRAME_SIZE]; void UVC_GenerateTestFrame(uint8_t pattern) { uint32_t i; for (i 0; i FRAME_SIZE; i 2) { // 生成简单的渐变图案Y 分量随位置变化 frame_buffer[i] (uint8_t)((i / 2) % 256); // Y frame_buffer[i 1] 0x80; // U/V 固定 } } void UVC_SendFrame(void) { uint32_t offset 0; while (offset FRAME_SIZE) { uint16_t chunk (FRAME_SIZE - offset UVC_EP_SIZE) ? UVC_EP_SIZE : (FRAME_SIZE - offset); if (GetEPTxStatus(UVC_DATA_EP) EP_TX_NAK) { UserToPMABufferCopy(frame_buffer[offset], GetEPTxAddr(UVC_DATA_EP), chunk); SetEPTxCount(UVC_DATA_EP, chunk); SetEPTxStatus(UVC_DATA_EP, EP_TX_VALID); offset chunk; } } }FRAME_SIZE按 YUY2 格式算每个像素两个字节320x240 就是 153600 字节。STM32F103 的 RAM 只有 20KB 左右这个缓冲区根本放不下所以实际工程里要么用更小的分辨率要么用 MJPEG 压缩后的数据要么只生成一行数据循环发送。示例里写成这样是为了说明数据流的结构真正跑的时候要把FRAME_WIDTH和FRAME_HEIGHT降到 160x120 甚至更小或者改成按行生成、边生成边发。这是这个项目最容易翻车的地方编译能过一跑就 HardFault原因就是缓冲区超了 RAM。4.3 端点回调与双缓冲数据端点每发完一个包会触发一次端点回调。在回调里继续填下一个包形成连续的数据流。如果主机读得慢端点状态会变成 NAK这时候不能硬写要等下一次回调。// usb_endp.c 中的端点回调 void EP1_IN_Callback(void) { static uint32_t frame_offset 0; if (frame_offset FRAME_SIZE) { frame_offset 0; // 一帧发完从头开始 UVC_GenerateTestFrame(0); } uint16_t chunk (FRAME_SIZE - frame_offset UVC_EP_SIZE) ? UVC_EP_SIZE : (FRAME_SIZE - frame_offset); UserToPMABufferCopy(frame_buffer[frame_offset], GetEPTxAddr(UVC_DATA_EP), chunk); SetEPTxCount(UVC_DATA_EP, chunk); SetEPTxStatus(UVC_DATA_EP, EP_TX_VALID); frame_offset chunk; }这个回调是中断上下文里面不能做耗时操作。UVC_GenerateTestFrame如果图案复杂生成一帧的时间可能超过帧间隔导致画面卡顿。常见做法是把图案生成放到主循环里回调只负责搬运已经准备好的数据。另外frame_offset用静态变量保存多个端点共用时要小心重入虽然这里只有一个数据端点但养成加保护的习惯没坏处。5. 避坑与排查枚举失败、花屏、掉线这几件事5.1 设备管理器显示未知设备现象插上板子设备管理器里出现「未知 USB 设备设备描述符请求失败」或者干脆没有任何反应。原因通常有三个时钟不对、描述符长度错、供电不足。先量 USB 的 D 上拉电阻有没有接STM32F103 的 USB 需要 1.5k 上拉到 3.3V有些最小系统板没焊这个电阻主机根本检测不到设备插入。再检查RCC_USBCLKConfig的分频系数48MHz 差一点都会导致枚举中断。最后核对配置描述符的总长度字段和实际数组长度是否一致不一致时主机读描述符会超时。解决用 USB 分析仪或者软件抓包看主机发到哪一步失败。没有分析仪的话在USB_Init之后点灯枚举成功进入配置状态时再点另一个灯靠灯的状态判断卡在哪。我一般会在usb_istr.c的中断处理里加一个计数器枚举过程中每收到一个 SETUP 包加一通过串口打出来能快速定位是没收到包还是回了错包。5.2 摄像头能打开但画面全黑或花屏现象相机应用里能看到设备点开之后画面是黑的或者一片彩色噪点。原因数据端点的包长和描述符里声明的不一致或者帧缓冲区越界。UVC 描述符里的wMaxPacketSize要和SetEPRxCount设的值对上对不上主机按错误长度解析画面就花了。另一个常见原因是 YUY2 数据的排列顺序搞反了Y 和 UV 交错的位置错了颜色会整体偏绿或偏紫。解决先把分辨率降到 160x120用纯色帧测试确认链路通了再换复杂图案。纯色帧能显示说明端点和描述符没问题问题出在数据格式上。用UserToPMABufferCopy之前打印一下chunk的值确认每次拷贝的长度没超过端点缓冲区。5.3 运行一段时间后设备掉线现象画面能出来但跑几十秒到几分钟后设备从系统里消失需要重新插拔。原因中断里做了耗时操作导致 USB 中断响应不及时或者看门狗复位。EP1_IN_Callback里如果调用了浮点运算或者大循环中断延迟会累积主机等不到数据就判定设备异常。另外如果主循环里有死等USB 中断被屏蔽同样会掉线。解决把图像生成移出中断中断里只做内存拷贝和端点状态设置。检查NVIC优先级配置USB 中断优先级要高于其他非关键中断。如果用了看门狗喂狗周期要小于 USB 帧间隔别让看门狗在数据推送密集的时候复位。5.4 修改分辨率后编译通过但跑不起来现象把FRAME_WIDTH改成 640编译没报错下载后设备枚举失败或者 HardFault。原因RAM 不够。640x480 的 YUY2 帧是 614400 字节STM32F103 的 RAM 只有 20KB静态数组直接撑爆。链接器不一定报错因为数组可能被放到未初始化段运行时才崩。解决改成分块生成每次只保留一行或者几行的数据发完再生成下一块。或者换 MJPEG 格式一帧压缩后可能只有几 KB但需要在 STM32 上跑 JPEG 编码F103 的性能比较吃力常见做法是预存几张压缩好的图片循环发送。分辨率不是越大越好全速 USB 的带宽摆在那里320x240 已经接近极限。6. 二次开发换图像源与调帧率的几个实操点把这份工程跑通之后真正有价值的是把它当成一个可编程的图像源。我一般会从两个方向改一是换数据来源二是调传输节奏。换数据来源最直接的做法是把UVC_GenerateTestFrame替换成从传感器读数据。如果接的是 SPI 接口的摄像头模组在EP1_IN_Callback里改成从模组的 FIFO 读一行数据再推给端点。注意 SPI 读取不能在中断里做要放在主循环里预读到缓冲区中断只负责搬运。下面是一个把固定图案换成外部缓冲区的改法// 外部缓冲区由主循环填充中断只负责发送 volatile uint8_t *g_frame_src NULL; volatile uint32_t g_frame_len 0; void EP1_IN_Callback(void) { static uint32_t offset 0; if (g_frame_src NULL || g_frame_len 0) return; if (offset g_frame_len) offset 0; uint16_t chunk (g_frame_len - offset UVC_EP_SIZE) ? UVC_EP_SIZE : (g_frame_len - offset); UserToPMABufferCopy((uint8_t *)g_frame_src[offset], GetEPTxAddr(UVC_DATA_EP), chunk); SetEPTxCount(UVC_DATA_EP, chunk); SetEPTxStatus(UVC_DATA_EP, EP_TX_VALID); offset chunk; }g_frame_src和g_frame_len用volatile修饰因为它们在主循环和中断之间共享。主循环填完一帧后更新g_frame_len中断侧读到长度变化就从头开始发。这里有个隐患主循环正在写缓冲区的时候中断来读会读到半帧数据。稳妥的做法是双缓冲主循环写 A 缓冲中断读 B 缓冲写完之后交换指针。指针交换本身不是原子操作在 Cortex-M3 上可以用关中断保护几个指令周期。调帧率则是改描述符里的帧间隔字段。UVC 帧描述符里有一个dwFrameInterval字段单位是 100ns15fps 对应 66666630fps 对应 333333。改了这个值主机就会按新节奏请求数据但实际能不能达到要看你的数据生成速度。如果生成一帧的时间超过帧间隔画面会卡顿这时候要么降分辨率要么降帧率别硬撑。还有一个容易被忽略的点是bEndpointAddress的方向位。数据端点如果是 IN 方向地址最高位要置 1比如0x81写成0x01就变成 OUT 端点主机会往设备发数据方向反了整个链路都不通。这个错误编译和枚举都可能过但一开流就卡住排查起来很费时间。从那以后我每次改 UVC 描述符都会先用一个最小配置160x120、单帧率、纯色帧跑通枚举和出图确认链路没问题再往上加分辨率、加格式、换数据源。描述符里任何一个长度字段或者实体 ID 对不上主机那边的表现都是「设备能用但不对劲」没有明确的报错只能靠二分法定位。希望这份拆解能帮你少走几段弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站