MC68HC908JW32 USB设备开发实战:从协议到固件实现
1. 项目概述与核心价值
如果你正在用一款像MC68HC908JW32这样的老牌8位微控制器做USB设备开发,大概率会对着官方那份几十页的英文应用笔记(Application Note)头疼。文档里塞满了协议时序图、寄存器位定义和函数原型,但真到了写代码的时候,还是不知道从哪下手,怎么把控制传输、批量传输和中断传输这些概念变成实实在在能跑起来的固件。我当年第一次用这颗芯片做USB鼠标和虚拟串口时,也踩过不少坑,比如描述符配错了电脑不认,端点缓冲区没处理好导致数据丢包,中断服务程序(ISR)写得太慢拖垮了整个系统响应。
这篇文章,就是帮你把那份名为“Using the Full-Speed USB Module on MC68HC908JW32”的官方文档嚼碎了,再混上我实际调试中积累的经验,重新讲一遍。我们不会照本宣科,而是聚焦在如何动手编程实现上。你会看到,USB协议里那些抽象的“阶段”、“令牌”、“握手包”,在JW32的代码里,其实就是几个核心函数(如USB_TxBuff0,USB_RxBuffx)的调用和几个状态位(如UEPxCSR[DVALID])的检查。我们的目标是:让你在理解USB基础原理的前提下,能直接参考这里的代码框架和注意事项,快速搭建起一个可用的USB设备,无论是做一个简单的HID鼠标键盘,还是实现一个稳定的CDC虚拟串口,都能心中有数,手中有码。
2. USB传输类型深度解析与JW32实现机制
理解USB编程,首先要抛开对“串口透传”的简单想象。USB是一种严格的主从式、轮询式总线,一切通信的发起权都在主机(Host,通常是你的电脑)手里。设备(Device,也就是你的JW32)只能被动响应。这种通信被组织成“传输(Transfer)”,而一次传输又由一次或多次“事务(Transaction)”构成。对于JW32这样的全速(Full-Speed, 12 Mbps)USB设备,我们需要熟练掌握三种最核心的传输类型:控制(Control)、批量(Bulk)和中断(Interrupt)。等会儿要讲的等时(Isochronous)传输常用于音频、视频等实时流媒体,对时间容错性要求高,但数据完整性要求相对较低,JW32也支持,但本文先聚焦于前三种更通用、更要求可靠性的类型。
2.1 控制传输:设备的“身份证”与“指挥棒”
控制传输是USB设备的基石,所有设备都必须支持端点0(EP0)上的控制传输。它主要用于枚举(Enumeration)和配置过程。你可以把它想象成设备插上电脑后,电脑对你进行的一场“入职面试”和“岗位配置”。这场对话结构严谨,分为三个阶段,缺一不可。
2.1.1 SETUP阶段:主机下达命令SETUP阶段总是由主机发起的一个SETUP事务构成。这个事务包含一个8字节的数据包,其格式是固定的(bmRequestType,bRequest,wValue,wIndex,wLength)。这8个字节告诉设备:“我要你做什么”(比如,获取设备描述符GET_DESCRIPTOR),“参数是什么”,“需要多少数据”。
在JW32的编程中,好消息是芯片的USB模块内置了一个请求处理器(Request Processor)。对于像SET_ADDRESS(设置设备地址)和CLEAR_FEATURE(清除特性)这类标准请求,硬件会自动处理,无需你的固件干预。只有当收到GET_DESCRIPTOR(获取描述符)、SYNC_FRAME(同步帧)或厂商自定义(Vendor)及类别(Class)特定请求时,硬件才会将SETUP数据包加载到EP0的缓冲区,并触发一个SETUP中断。
你的固件需要在这个中断的服务程序里,解析这8个字节。官方驱动库通常会提供一个像USB_StandardRequest()这样的函数来帮你做初步分拣。它的逻辑就像一个路由器:
- 判断请求类型(标准、厂商、类别)。
- 如果是标准请求且是
GET_DESCRIPTOR,则进一步判断要的是设备(Device)、配置(Configuration)还是字符串(String)描述符。如果是,驱动库会从你预先定义在Flash中的描述符地址读取并准备数据。 - 如果是厂商或类别请求,则调用你预先注册的回调函数,比如
USB_VendorRequest_CB()或USB_ClassRequest_CB()。
这里的关键是,你必须在usb_periph_cfg.h这样的配置文件中,通过#define宏来告诉驱动库这些回调函数的名字,以及你的描述符在内存中的位置。例如:
#define USB_VENDOR_REQUEST_CB myVendorRequestHandler #define STRING_DSC_TAB myStringDescriptorTable #define STRING_DSC_TAB_LEN 4如果驱动库找不到对应的处理函数,它就会对主机的请求回复一个STALL握手包,表示错误或端点暂停,枚举过程就会卡住。
2.1.2 DATA阶段(可选):数据传输如果SETUP命令要求交换数据(比如GET_DESCRIPTOR肯定需要返回描述符数据),就会进入DATA阶段。这个阶段可以包含一个或多个IN或OUT事务,但所有事务的方向必须一致(全是主机读IN,或全是主机写OUT)。
- IN事务(设备发送数据给主机):主机发IN令牌包 -> 设备将数据放入数据包发出 -> 主机回复ACK握手包。
- OUT事务(主机发送数据给设备):主机发OUT令牌包和数据包 -> 设备接收数据并回复ACK握手包。
在JW32编程中,这个阶段的数据搬运是通过USB_TxBuff0()(对于IN方向)和USB_RxBuff0()(对于OUT方向)这两个函数来完成的。你需要提前准备好用户缓冲区(User Buffer),并调用这些函数告知驱动库缓冲区的地址和期望传输的字节数。
注意:DATA阶段的长度协商。在SETUP阶段的8字节数据中,
wLength字段指明了主机期望的数据长度,而你的描述符里也定义了当前配置下EP0支持的最大包大小(Max Packet Size,对于全速控制端点通常是8、16、32或64字节)。如果wLength大于最大包大小,DATA阶段会自动拆分成多个事务,直到发送完所有数据或最后一个数据包小于最大包大小(包括长度为0的包),以此标志DATA阶段结束。
2.1.3 STATUS阶段:确认操作结果这是控制传输的收尾,用于向主机报告整个传输(SETUP+DATA)的结果。它的方向与DATA阶段相反:如果DATA阶段是IN(设备发数据),那么STATUS阶段就是OUT(主机发一个长度为0的数据包来确认接收);反之亦然。
在JW32中,STATUS阶段通常由驱动库自动管理。例如,在DATA阶段为IN的控制传输完成后,当主机发来一个OUT令牌包附带零长度数据包时,硬件会自动回复ACK,你的固件通常无需额外操作。这大大简化了编程。
2.2 批量传输与中断传输:数据搬运的主力军
控制传输搭好了舞台,真正干“重活”(大量数据搬运)和“急活”(及时响应)的,就是批量传输和中断传输了。在JW32的编程接口层面,它们使用的函数几乎一模一样,核心区别在于协议赋予它们的“服务质量”不同。
2.2.1 批量传输:追求可靠,不赶时间批量传输用于传输大量、对时间不敏感但必须准确无误的数据。典型应用是U盘、打印机。它利用USB的带宽空闲时段进行传输,没有延迟保证,但拥有错误检测和重传机制,确保数据100%正确。
在JW32上,无论是批量IN(设备到主机)还是OUT(主机到设备),都使用同一组函数:
USB_TxBuffx(uchar* adr, uchar cnt): 发送数据。x是端点号(如1,2,3)。你需要把待发送数据放在adr指向的用户缓冲区,并指定长度cnt。函数会尽可能将数据拷贝到端点的硬件缓冲区,并返回用户缓冲区中尚未拷贝的剩余字节数。这是一个非阻塞函数,拷贝不一定会一次完成。USB_RxBuffx(uchar* adr, uchar cnt): 接收数据。你提供一个用户缓冲区adr和期望接收的字节数cnt。函数会立即将端点硬件缓冲区里已有的数据拷贝过来,并返回用户缓冲区剩余的可用空间字节数。同样是非阻塞的。
这里的关键机制是双缓冲区和中断驱动:
- 你操作的是“用户缓冲区”,是你在RAM里定义的一块内存。
- USB模块硬件有自己的“端点缓冲区”(FIFO)。
- 当你调用
USB_TxBuffx(),驱动会尝试从用户缓冲区向端点FIFO拷贝数据。如果FIFO满了,就只拷贝一部分,剩下的等下次再拷。 - 当端点FIFO被填满(对于TX)或收到一个完整数据包(对于RX),并且主机成功完成一次事务后,硬件会触发一个端点传输完成中断(
USB_EP_ISR)。 - 在这个中断服务程序里,驱动库的代码会检查:如果用户缓冲区还有数据要发送(TX),就继续往空的端点FIFO里拷贝;如果用户缓冲区还有空间要接收(RX),就把端点FIFO里新到的数据拷贝过来。
- 如果用户缓冲区已满(RX方向)或已空(TX方向),端点FIFO就会保持“就绪”或“空”状态,等待你的主程序或下一次中断来处理。
2.2.2 中断传输:准时打卡,低延迟中断传输用于传输少量、周期性的数据,要求有有保证的延迟(Latency)。典型应用是USB鼠标、键盘。主机会以端点描述符中定义的间隔(如鼠标通常是10ms)定期向设备发起IN令牌查询。
从编程函数角度看,中断传输和批量传输完全一样,用的也是USB_TxBuffx()和USB_RxBuffx()。它们的区别是由USB协议栈在底层保障的:主机控制器会为中断端点保留固定的带宽,确保每隔一个固定的时间片就会来询问一次。
这意味着,如果你用EP1配置成一个中断IN端点来模拟鼠标,你只需要在检测到鼠标移动或按键时,将报告数据通过USB_TxBuff1()放入发送队列。当下一个主机IN令牌到来时,数据就会被自动发出。如果当前没有新数据,硬件会自动回复NAK,主机稍后会重试。
实操心得:理解“非阻塞”与数据流管理很多新手会困惑于
USB_TxBuffx()和USB_RxBuffx()的返回值。它们返回的是“用户缓冲区”的状态,而不是本次函数调用“成功发送/接收了多少”。例如,调用USB_TxBuff1(buf, 64),它可能立刻返回0(表示64字节全部从用户buf拷贝到了端点FIFO,等待主机来取),也可能返回32(表示只拷贝了32字节到FIFO,因为FIFO只有32字节空闲,剩下32字节还在用户buf里)。你需要结合USB_TxBuffPendingx()(查询用户缓冲区待发送字节数)和USB_GetTxEmptyx()(查询端点FIFO空闲空间)这两个函数,来有效管理你的数据流,避免用户缓冲区堆积或溢出。中断服务程序(ISR)是处理这些零碎搬运工作的最佳场所,务必保持ISR代码简短高效。
3. 从理论到实践:HID鼠标与CDC串口项目详解
掌握了传输机制和基本函数,我们来看两个最经典的应用:HID鼠标和CDC虚拟串口。通过它们,你能把前面的知识点全部串联起来。
3.1 HID类设备实现:打造一个USB鼠标
HID设备之所以简单,是因为操作系统自带通用驱动。我们只需要“告诉”电脑我们是一个鼠标,并按规定格式报告数据即可。
3.1.1 描述符配置:设备的“简历”描述符是一系列数据结构,告诉主机“我是谁”、“我能做什么”。对于鼠标,我们需要:
- 设备描述符:声明这是一个全速USB设备,厂商ID(VID)、产品ID(PID)等信息。PID/VID如果是商业产品需要向USB-IF申请,学习阶段可以使用一些公开的测试ID,但要注意避免冲突。
- 配置描述符:描述设备的供电模式(总线供电/自供电),最大电流等。一个设备可以有多个配置,但同一时间只能激活一个。
- 接口描述符:鼠标只需要一个接口。这里要指明接口类(Class)是0x03(HID),子类(Subclass)和协议(Protocol)通常设为0。
- HID描述符:指向报告描述符(Report Descriptor),并定义HID规范的版本。
- 端点描述符:描述中断IN端点(例如EP1)。需要指定端点地址(0x81表示EP1 IN)、属性(中断传输)、最大包大小(例如8字节对于鼠标足够了)、轮询间隔(例如10ms)。
- 报告描述符:这是HID设备的灵魂。它用一套复杂的“HID用语”定义了你上报的数据格式。原文中给出的例子定义了一个4字节的报告:
- 第1个字节(
but):低3位表示3个按键(左、右、中键),高5位是填充位。 - 第2、3个字节(
x,y):表示鼠标在X、Y轴上的移动量,范围-127到127。 - 第4个字节(
w):鼠标滚轮。
- 第1个字节(
在代码中,你需要将这些描述符以常量数组的形式定义在Flash中,并通过前面提到的宏(如IDENT_DEVICE_DSC,IDENT_CONFIG_DSC)让驱动库知道它们的位置。
3.1.2 数据报告与类请求处理描述符配置好,枚举成功后,你的设备在系统里就会被识别为一个鼠标。接下来就是让鼠标“动起来”。
你的主循环或定时器中断里,需要不断检测GPIO(连接按键和编码器/传感器),根据状态更新一个MouseReportStrc结构体。当有状态变化(如按键按下或移动)时,就调用USB_TxBuff1(&mouseReport, sizeof(mouseReport))将报告数据放入发送队列。
关键细节:中断端点的“自动应答”你不需要手动触发发送。只要EP1的端点缓冲区里有数据(即
UEP1CSR[DVALID]位被设置),当下一个主机IN令牌到来时,硬件会自动将数据发出并回复ACK。如果缓冲区是空的,硬件会自动回复NAK。你只需要确保在需要报告的时候,及时把数据填充进去。
此外,HID类有一些特定的请求,如GET_REPORT(主机主动读取报告)、SET_REPORT(主机发送报告给设备,如设置LED)、GET_PROTOCOL/SET_PROTOCOL(用于启动/报告协议)。你需要在USB_CLASS_REQUEST_CB()回调函数中处理这些请求。对于简单的鼠标,GET_PROTOCOL(返回0)和SET_PROTOCOL(通常忽略)是必须实现的。
3.2 CDC类设备实现:构建虚拟串口(Virtual COM Port)
CDC类让你在USB上模拟一个串口,对于嵌入式调试、设备升级等场景极其有用。它的结构比HID稍复杂,因为它包含两个接口。
3.2.1 双接口结构与描述符一个CDC-ACM(抽象控制模型)设备包含:
- 通信类接口(Communication Interface):
- 端点0(EP0):控制管道,用于传输标准USB请求和CDC特定的类请求(管理功能)。
- 端点1(EP1,中断IN):通知管道(Notification Pipe),用于向主机发送串口状态变化,如DCD(载波检测)、DSR(数据设备就绪)等。这个端点不是必须的,但建议实现。
- 数据类接口(Data Interface):
- 端点2(EP2,批量OUT):用于接收来自主机(PC应用程序)的数据,相当于串口的RX。
- 端点3(EP3,批量IN):用于向主机发送数据,相当于串口的TX。
在描述符中,你需要依次定义:设备描述符 -> 配置描述符 -> 通信类接口描述符(关联端点0和1)-> 数据类接口描述符(关联端点2和3)。同时还需要包含CDC特定的功能描述符,如头部功能描述符(Header Functional Descriptor)、呼叫管理功能描述符(Call Management Functional Descriptor)、抽象控制管理功能描述符(Abstract Control Management Functional Descriptor)和联合功能描述符(Union Functional Descriptor),后者用于指明通信接口和数据接口是“联合”在一起的。
3.2.2 关键类请求处理CDC设备需要处理几个重要的类请求,这些请求在PC端打开虚拟串口时会被触发:
SET_LINE_CODING:主机设置串口参数(波特率、数据位、停止位、校验位)。你会收到一个CdcLineCodingStrc结构的数据。重要提示:对于JW32这类微控制器,这个波特率设置通常不直接控制芯片内部UART的波特率发生器!它只是一个“通知”。你需要在这个请求的处理函数中,记录下这些参数,然后根据这些参数去配置你与外部设备通信的真实物理UART(如果你有的话)。对于纯粹的USB转数据流应用,你可以忽略这个值,或者用它来配置一个软件波特率模拟。GET_LINE_CODING:主机查询当前的串口参数。你需要返回之前通过SET_LINE_CODING设置的值,或一个默认值。SET_CONTROL_LINE_STATE:主机控制RS-232信号线,主要是DTR(数据终端就绪)和RTS(请求发送)。通常,PC端串口软件在打开串口时会设置DTR=1,RTS=1。你可以利用这个信号作为设备“连接已建立,可以开始通信”的触发标志。
3.2.3 数据收发实战数据接口的两个批量端点(EP2 OUT, EP3 IN)才是数据流通的干道。编程模型和前面讲的批量传输完全一致。
一个典型的数据回环(Loopback)示例代码如下,它演示了如何从EP2接收数据,并立即从EP3发送回去:
// 假设已正确初始化,枚举完成 unsigned char rxBuffer[64]; unsigned char txBuffer[64]; unsigned int rxLen = 0; void main(void) { USB_Init(); // ... 其他初始化 USB_RxBuff2(rxBuffer, sizeof(rxBuffer)); // 启动EP2的接收,指定用户缓冲区 while(1) { // 1. 检查并处理接收到的数据 if(USB_GetRxReady2() > 0) { // 有数据在端点FIFO,但USB_RxBuff2可能已将其搬至用户缓冲区。 // 更可靠的方法是检查USB_RxBuff2的返回值或使用一个“数据到达”标志。 // 这里我们简化处理:在EP2的传输完成中断中处理数据搬运。 } // 2. 处理发送(示例:将接收到的数据回传) // 假设我们在中断里将接收到的数据拷贝到了txBuffer,并知道了长度txLen if(dataReadyToSend) { if(USB_TxBuff3(txBuffer, txLen) == 0) { // 如果返回0,表示所有数据都已成功放入发送队列(不一定已发出) dataReadyToSend = FALSE; } else { // 返回非0,表示用户缓冲区还有数据没拷完,等待下次中断继续拷 // 此时不应重复调用USB_TxBuff3,以免打乱内部状态 } } // 处理其他任务... } } // 在端点传输完成中断服务程序 (USB_EP_ISR) 中: #pragma interrupt_handler USB_EP_ISR void USB_EP_ISR(void) { unsigned char ep_status = USB_GetEPStatus(); // 获取是哪个端点触发了中断 if(ep_status & EP2_MASK) { // EP2 OUT 完成中断 unsigned char bytesReceived = USB_GetRxReady2(); // 查询端点FIFO中刚收到的字节数 if(bytesReceived > 0) { // 将数据从端点FIFO搬运到用户缓冲区(由USB_RxBuff2启动的后续搬运) // 通常驱动库会自动完成,我们只需检查用户缓冲区的状态 unsigned char remainingSpace = USB_RxBuffPending2(); if(remainingSpace == 0) { // 用户缓冲区已满,处理数据... processReceivedData(rxBuffer, sizeof(rxBuffer)); // 重新启动接收,清空用户缓冲区指针 USB_RxBuff2(rxBuffer, sizeof(rxBuffer)); } // 如果用户缓冲区未满,中断返回,等待下一个OUT包继续填充 } } if(ep_status & EP3_MASK) { // EP3 IN 完成中断 // 一批数据已成功发送到主机,ACK已收到 // 检查用户缓冲区是否还有待发送数据 unsigned char pendingBytes = USB_TxBuffPending3(); if(pendingBytes > 0) { // 驱动库会自动将剩余数据从用户缓冲区拷贝到空的端点FIFO // 我们这里可以设置一个标志,通知主循环可以准备下一批数据了 txBufferEmpty = TRUE; } } }这个例子展示了中断驱动结合状态机管理数据流的核心思想。主循环负责高层逻辑和准备数据,短暂的中断服务程序负责与硬件FIFO之间的数据搬运。
4. 开发陷阱、调试技巧与性能优化
即使理解了所有原理,实际开发中依然会遇到各种问题。下面分享一些我踩过的坑和总结的调试方法。
4.1 枚举失败:90%的问题在这里设备插上电脑没反应,或者提示“无法识别的USB设备”,基本是枚举失败。
- 检查清单:
- 描述符:这是头号嫌疑犯。用USB协议分析仪(如Saleae, Beagle)抓取数据包,逐字节对比你的描述符和USB规范。特别注意长度字段、类型字段、端点地址和轮询间隔。一个常见的错误是配置描述符的总长度计算错误。
- 供电与时钟:确保MCU的USB模块供电稳定,且USB时钟(通常来自PLL)精确地是48MHz。JW32需要正确配置时钟生成模块(CGM)和锁相环(PLL)。
- 上拉电阻:USB规范要求全速设备在D+线上接一个1.5kΩ的上拉电阻到3.3V。JW32内部集成了这个上拉,但需要通过软件使能(通常在
USB_Init()函数里设置相关寄存器位)。确保它被正确打开了。 - 请求处理:确保你的
USB_StandardRequest()函数或相关回调能正确响应所有标准请求。特别是GET_DESCRIPTOR请求,主机可能会分多次请求不同长度(如先要8字节,再要全部),你的代码要能正确处理。 - 端点0缓冲区:控制传输都在EP0进行。确保EP0的发送和接收缓冲区配置正确,并且
USB_TxBuff0和USB_RxBuff0只在合适的阶段被调用(如在SETUP中断解析后,针对DATA阶段调用)。
4.2 数据传输不稳定:丢包或速度慢
- 缓冲区管理不当:这是最常见的原因。
USB_TxBuffx()是非阻塞的。如果你在主循环中不断以超过USB处理能力的速度调用它,而之前的数据还没发送完,就会导致用户缓冲区被覆盖,或者内部状态混乱。务必等待USB_TxBuffPendingx()返回0,或者等待一个“发送完成”标志(在IN端点中断里设置)后,再准备下一批数据。 - 中断服务程序(ISR)过长:USB中断(尤其是端点传输中断)应该尽快处理完毕。不要在ISR里做复杂计算、长时间循环或调用可能阻塞的函数。只做最必要的状态检查和数据指针移动,将数据处理等耗时任务留给主循环。
- 端点FIFO大小与包大小:JW32每个端点的硬件FIFO大小是固定的(例如64字节)。你在端点描述符中声明的“最大包大小”不能超过这个硬件限制。同时,如果你的应用数据包很小(比如鼠标的4字节报告),但主机每次IN事务都来取64字节,会造成带宽浪费。可以适当设置较小的最大包大小,但需符合USB规范(全速批量端点最大64,中断端点最大64)。
- NAK风暴:如果设备长时间无法响应主机(比如用户缓冲区一直满着),它会持续回复NAK。主机可能会认为设备出错。要优化你的数据处理流程,确保能及时消费或生产数据。
4.3 调试手段
- 软件模拟与日志:在关键位置(如进入不同请求的处理分支、数据收发前后)通过一个额外的UART口打印调试信息。这是最直接的方法。
- 总线分析仪:硬件工具如Saleae Logic Pro(配合USB协议解码软件)或专用的USB协议分析仪,可以无损捕获总线上的所有数据包,让你清晰地看到枚举过程、描述符内容、每一次事务和握手包。这是解决复杂问题的终极武器。
- Windows设备管理器与日志:在Windows中,打开设备管理器,查看设备状态。启用“显示隐藏设备”可以查看未成功安装的设备。更高级的可以使用Windows Driver Kit (WDK) 中的工具如
devcon或启用内核调试来获取更详细的安装错误信息。
4.4 性能优化要点
- 双缓冲与乒乓缓冲:对于高速批量传输,可以考虑在用户层实现双缓冲。当USB驱动正在通过中断服务程序从“缓冲区A”向硬件FIFO搬运数据时,你的主程序可以同时向“缓冲区B”填充下一批数据。两个缓冲区交替使用,最大化吞吐量。
- 合理规划端点:JW32的USB模块支持多个端点。将不同功能、不同速率要求的数据分配到不同的端点上。例如,将调试打印信息通过一个中断端点发送(低优先级,保证可达),而将主要业务数据通过批量端点传输(高带宽)。
- 减少数据拷贝:如果可能,让你的应用数据直接生成在作为USB用户缓冲区的数组中,避免从“生产缓冲区”到“USB发送缓冲区”的额外内存拷贝。
- 关闭未用中断:如果某个端点只用于OUT,就关闭它的IN传输完成中断,反之亦然。减少不必要的中断触发,降低CPU负载。
开发USB设备是一个对细节要求极高的工作,从准确的时钟到字节对齐的描述符,从高效的缓冲区管理到严谨的中断处理,每一个环节都可能导致失败。但一旦打通,看到自己的设备在系统里稳定识别、流畅通信,那种成就感是无与伦比的。希望这篇结合了协议原理与JW32实战经验的详解,能帮你少走弯路,顺利点亮你的USB设备。
