ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

DWC3 USB控制器驱动解析:从初始化到TRB传输与调试

DWC3 USB控制器驱动解析:从初始化到TRB传输与调试

搞USB驱动的人,不管是在哪个平台,应该都绕不开DWC3这个控制器。它是目前很多SoC上常见的USB 3.0控制器IP核,Linux内核里对应drivers/usb/dwc3这一整个目录。我的实际感受是,大多数人第一次接触它不是因为它好用,而是因为板子上的USB口不识别、枚举失败、或者吞吐量上不去,最后被日志里一行行dwc3相关报错逼着去读源码。

这篇内容不打算把dwc3整个源码逐行过一遍,那要写一本书。我按项目里实际排查问题的顺序来讲:先认识驱动规模和分层,再顺着probe初始化、核心数据结构、TRB传输、事件中断这几条链路,把DWC3 gadget侧分析透。host侧也会提,但篇幅不会太多,因为DWC3在host模式下的核心逻辑实际上是交回xhci了。适合理想快速建立整体认知的读者,也适合已经在调USB、想找排查思路的开发者。

1. DWC3驱动是什么:先搞清楚你面对的是哪一层

1.1 控制器IP与Linux驱动的对应关系

DWC3本身是一颗USB控制器IP,支持SuperSpeed、HighSpeed、FullSpeed,并且能同时做到host和device两种角色。SoC集成它之后,对外就是一组寄存器加中断线,Linux这边通过platform总线把它描述成一个平台设备,驱动源码全部落在drivers/usb/dwc3目录下。

进入这个目录你会发现文件不少:core.c处理控制器全局初始化,gadget.c负责device侧逻辑,host.c负责host侧桥接,ep0.c处理端点0控制传输,debugfs.c提供调试接口。这种拆分不是随便分的,它体现了这个驱动最核心的设计思想:一套寄存器、两种角色、多条路径。如果你一上来就抱着“看一个文件就能全懂”的想法,很容易卡住。

我建议先去读目录下的Makefile,里面能看到哪些文件编进内核、哪些是条件编译,这比直接跳进某个函数更管用。你会发现gadget和host是两条完全独立的编译分支,这直接影响后续分析方向。DWC3的host侧并没有自己实现一套完整的枚举逻辑,而是注册了一个xhci平台设备,把USB主机控制器的活全部交给xhci-hcd驱动去处理。也就是说,host模式下dwc3只负责电源、时钟、复位、PHY设定这些“前置条件”,真正管hub、管urb、管带宽调度的,还是xhci那套成熟框架。

1.2 host和gadget两条线的差异

很多初学者搞混一件事:DWC3驱动到底怎么管host和device?答案是靠dr_mode属性来选。设备树里通常写dr_mode = "host"、"peripheral"或者"otg"。probe阶段解析这个字符串,然后走不同的初始化分支。

host线逻辑相对简单。dwc3_host_init会把硬件状态拉起来,然后注册一个新的平台设备,名字是xhci-hcd。这个设备被xhci-plat-hcd匹配上以后,USB主控的复杂度就被xhci接管了。所以你调host问题,比如U盘识别慢、hub枚举异常,重点应该放在xhci和PHY,而不应该死磕gadget.c里的代码。

gadget线就不一样了。USB device侧没有现成的、通用的控制器驱动框架可以复用,所有UDC逻辑都得DWC3自己实现。从端点0的控制传输,到普通BULK/INTERRUPT端点的TRB调度,再到各种状态机,全部实现在gadget.c加上ep0.c里。项目里做USB device功能,比如模拟一个网卡、一个串口、一个大容量存储,最后都会落到dwc3_ep_queue这类函数上。

所以“DWC3驱动分析”这件事,严格说90%的篇幅都应该放在gadget侧的代码路径上。host侧的xhci驱动是另一个话题,只是因为DWC3硬件同时支持两种角色,才被放到了同一个目录里。记住这个分工,后面看代码不会迷路。

2. 驱动初始化路径:从probe到模式选择

2.1 设备树匹配与platform总线绑定

DWC3驱动的入口是platform_driver,设备树里节点形如:

usb@60000000 { compatible = "snps,dwc3"; reg = <0x0 0x60000000 0x0 0x10000>; interrupts = <0 64 4>; dr_mode = "peripheral"; };

compatible字符串是驱动和硬件绑定的依据。内核里常见的还有"snps,usb3"这种老式compatible,调试时如果发现驱动没probe上,先检查设备树字符串是不是对得上。

probe开始后,驱动调用dwc3_get_properties读取属性,这一步很关键。这里不仅解析dr_mode、maximum-speed这类人尽皆知的参数,还会解析一堆quirk属性,比如snps,dis_u2_susphy_quirk、snps,dis_u3_susphy_quirk。这些quirk在项目里经常是救命的,某平台USB不识别,加个禁用suspend PHY的quirk就好了。因为控制器IP在集成到不同SoC上时,外部PHY的行为会有差异,IP核默认的那套逻辑不一定适配所有平台。

如果你在dmesg里看到:

dwc3 60000000.usb: failed to find clock: -2

那基本就是设备树里clock属性没配全。DWC3做初始化前,必须保证时钟、复位都就绪。很多项目把DWC3挂在某个父节点的时钟域下,父节点没有正确使能时钟,就会卡在这一步。

2.2 probe阶段的关键步骤

dwc3_probe函数我按执行顺序拆成几个关键节点:

  • 获取寄存器资源,映射IO地址。一般用devm_ioremap_resource,不配reg就直接失败。
  • 解析属性,设置dwc3_get_properties。这一步填充struct dwc3里大量“能改硬件行为的字段”。
  • 分配事件缓冲区dwc3_alloc_event_buffers。没有事件缓冲区,后面一个中断都处理不了。
  • 调用dwc3_core_init,启动控制器核心。这里会读写GCTL等全局寄存器,做软复位、配置直通模式。
  • 最后走dwc3_core_init_mode,根据dr_mode选择注册gadget还是host。

dwc3_core_init是我建议仔细看的一个函数。它里面做了硬件的软复位,然后配置控制器cache类型、写GCTL的某些bit。这个阶段出问题时,通常表现为挂死或者probe直接超时。我在项目里见过一种情况:软件复位后需要等待某个状态位清零,但某平台因为外部PHY没上电,这个位永远不停,结果驱动卡在while循环里。这个时候就得检查外部PHY供电和复位控制,而不是怀疑内核代码。

2.3 模式选择:OTG逻辑要注意

dwc3_core_init_mode里会根据role做switch。peripheral就走dwc3_gadget_init,host走dwc3_host_init,otg则走dwc3_drd_init。

OTG模式是项目里最容易出问题的一环。dwc3_drd_init依赖ID引脚或者外部extcon/typec驱动来通知角色变化。角色切换时,dwc3要先停掉当前角色相关的控制器状态,再重新初始化另一种角色。时序稍微不对,就会出现反复link_up/link_down的抖动。

如果你的项目是USB device接口做充电和联网复用,大概率会用到OTG。调试这类问题不能只盯dwc3驱动,要把typec switch、extcon notify这条完整链路也拉进来。角色切换事件从哪个驱动发给dwc3、信号是否丢失,这些问题在source code层面看不出来,必须靠动态打印和trace去抓。

3. 核心数据结构:读懂DWC3驱动源码的基础

3.1 struct dwc3全局对象

整个DWC3驱动维护一个很大的全局结构体struct dwc3。probe时分配,然后贯穿所有子模块。

struct dwc3 { struct device *dev; void __iomem *regs; struct dwc3_event_buffer *ev_buf; struct dwc3_ep *eps; enum dwc3_role role; u32 revision; ... };

这里面每一类字段都有自己的意义。regs是寄存器基址,ev_buf是中断事件缓冲区,eps是端点的数组,role是当前角色。调试时想了解一个请求从哪儿走到哪儿,其实就是在追这些字段如何被填充和使用。

建议你拿到一份源码后,先把struct dwc3的成员全部扫一遍,不需要背,混个眼熟。因为后面看gadget.c时频繁出现dwc->xxx的写法,你不知道某个字段是干嘛的,读起来会非常痛苦。我习惯把结构体打出来放在屏幕另一边,边看函数边对照。

GSNPSID寄存器里能读到控制器版本号。这个值在初始化时会被驱动保存下来,用来处理不同版本的硬件差异。比如某个老版本控制器对TRB处理有硬件bug,驱动里就会有基于版本号的workaround,这属于经验层面,遇到时再细看。

3.2 端点与请求:dwc3_ep、dwc3_request

USB device侧,上层协议栈发起的每一次传输,最终都对应一次usb_request。在DWC3里这个请求被包了一层,变成struct dwc3_request。端点则用struct dwc3_ep表示。

struct dwc3_ep { struct usb_ep endpoint; struct dwc3_trb *trb_pool; unsigned int trb_enqueue; unsigned int trb_dequeue; ... };

端点和请求是两个层面的事。端点代表USB协议里某个通信管道,请求是应用层想在这条管道里传的一块数据。驱动要做的事情非常朴素:把请求里的buffer地址转成硬件能读的TRB,塞进端点的传输环形队列,然后通知控制器“可以搬了”。

上层会调usb_ep_queue发起请求,但这个函数只是gadget框架的统一入口,真正实现落在dwc3_ep_queue。请求进来后先检查端点状态,比如端点有没有被配置、是不是已经停用,然后决定能不能接受这个请求。项目里常见的“请求卡死”问题,往往就是这里某个检查没过,上层傻等completion。

dwc3_request里还有dma地址和trb链表指针。由于USB控制器做的是DMA搬运,内存中的buffer必须是连续且对齐的。上层传下来的buffer如果不满足要求,驱动需要做bounce或者直接返回错误。这也是为什么有些USB gadget驱动在开启IOMMU的平台和关闭IOMMU的平台表现完全不同。

3.3 TRB:驱动和控制器之间的交接单

TRB全称Transfer Request Block,翻译过来就是传输请求块。它就是一块32字节的内存结构,分为两个64位:低64位放buffer地址,高64位放长度和控制位。

struct dwc3_trb { __le64 lo; __le64 hi; };

控制位里最重要的是HWO、IOC、LST这三个。HWO表示“这块TRB是否归硬件所有”,软件填充完置1,硬件处理完清0;IOC表示“完成时要不要发中断事件”;LST表示“这是整个transfer的最后一块”。

每个端点有一个TRB环形队列,软件从enqueue位置写入,硬件从dequeue位置消费。TRB数量的限制意味着一个超长请求可能要拆成多个TRB。DWC3驱动里dwc3_prepare_trb就是干这个的。

绕了这么多,本质逻辑其实很像餐厅传菜:厨师(软件)把菜放在传菜台上(TRB),服务员(控制器DMA)按顺序端走。你说传菜台满了,厨师就不能硬塞;你说某道菜要加急,那就是IOC位通知你处理完了。把TRB机制理解成这种交接关系,后面看中断处理就顺了。

4. 传输流程:一次BULK传输从发起到完成的完整旅程

4.1 gadget侧:UDC注册与请求下发

DWC3在gadget模式下,probe会调用dwc3_gadget_init。这个函数里除了初始化端点0之外,还会调用usb_add_gadget_udc把DWC3注册成UDC。之后上层composite框架通过configfs构造function,最终调用usb_ep_queue把请求交给驱动。

以我自己调过的USB虚拟串口为例,上层gadget serial驱动发一个写请求,调用链是:

usb_ep_queue -> dwc3_gadget_ep_queue -> dwc3_gadget_start_transfer -> dwc3_prepare_trb -> dwc3_send_gadget_ep_cmd

dwc3_gadget_ep_queue先判断端点是否正在传输。如果端点没在跑,直接启动一个新的transfer;如果端点已经在跑,就把请求挂到pending list里。这个pending list是调试时非常想看的队列,因为它决定请求的排队顺序。

很多项目发现传输乱序或吞吐低,追到最后都是pending list处理逻辑的问题。比如某个请求在队列里等待,但端点停掉了,结果pending请求没有及时清理,恢复传输后又把它发出去,导致数据顺序和协议对不上。

4.2 端点命令:真正让控制器搬运数据的指令

端点准备好了,TRB填充好了,怎么告诉DWC3“开搬”?靠的是端点命令。驱动往DWC3_DEPCMD寄存器写命令,同时附带参数寄存器。

命令类型大致分几类:

  • 配置端点(DEPCFG):下发端点类型、方向、maxpacket、burst等。
  • 启动传输(DEPXFER):告诉控制器开始处理某个transfer。
  • 更新传输(DEPUPDXFER):在原有transfer后面追加TRB。
  • 结束传输(DEPENDXFER):异常时强制终止当前transfer。
  • 停止端点(DEPSTOP):暂停端点处理。

要特别注意的是dwc3_send_gadget_ep_cmd不是写完寄存器就完事。驱动要等控制器给出命令完成事件,期间它会检查EPSOME(Endpoint Status Modified Event)这类状态。如果控制器没回应,说明命令没被接受,可能时硬件状态和驱动预期不一致。

项目里遇到过一种情况:上层调用usb_ep_disable把端点禁用了,但硬件其实还在传输中,这时发DEPENDXFER往往会超时。因为端点确实是busy的,必须先把TD清除再停端点。这种问题不读datasheet是真猜不出来的。

TRB处理完之后,如果是短包或者遇到IOC置位,控制器会往事件缓冲区写一个端点事件。驱动读取这个事件,去唤醒等待的completion,完成一次usb_request的回调。

4.3 中断与事件:完成通知机制

DWC3把所有中断合并到一个中断线上,probe里申请的中断处理函数是dwc3_interrupt。它读取事件缓冲区,然后逐个分发事件。事件类型常见的有设备事件、端点事件、连接状态事件、断开事件。

处理端点事件时,事件里会带上物理端点号和事件类型。其中XferComplete是最常见的完成事件,它表示某个transfer做完了,驱动可以回收TRB并complete对应的request。IOC位没有置位的TRB不会触发完成事件,而是靠short packet事件来判断。这个机制在处理不定长数据时很有用。

如果中断丢了,上层请求就会一直挂着。所以事件缓冲区的读取必须足够及时。DWC3支持IMOD中断调制,也就是把多个中断攒到一起处理,减少CPU唤醒频率。IMOD配置大了,中断延迟会明显变大,反过来又影响吞吐。这个参数在吞吐调优里要反复试。

还有一点,事件缓冲区也是一个环形结构。如果驱动处理太慢,硬件事件写满了缓冲,就会产生溢出事件。看到溢出,第一反应是CPU被关中断太久,或者中断线程被其他高优先级任务抢占。

4.4 host侧要理解到哪一层

host侧虽然核心是xhci,但DWC3驱动也不是完全撒手不管。dwc3_host_init里会注册xhci-hcd平台设备,同时要保证DWC3的控制器状态能支撑xhci正常工作。

比如链路训练失败,host口插上设备没反应,这种问题很多发生在PHY或者控制器配置层面,而不是xhci。你在xhci的日志里看不到什么东西,但DWC3的link状态寄存器已经告诉我们链路没起来。所以host侧排查既要懂xhci,也需要回头去查DWC3的link training。

说句实在话,如果你做纯host应用,直接调xhci和usbcore就够了,DWC3的gadget部分可以先不碰。只有当问题定位到PHY/链路层,才需要回来翻dwc3的core逻辑。

5. 调试DWC3驱动的实用手段与避坑记录

5.1 丢掉printk:先学会看寄存器

DWC3寄存器数量多,不能瞎看。我项目中遇到USB问题时,第一件事是看三个寄存器:

  • GSNPSID:控制器版本。某些问题在旧版本上是bug,在新版本上不是。
  • DSTS:当前设备状态和速度。如果这里显示不是SuperSpeed,但硬件链路明明应该支持,说明链路训练有问题。
  • GCTL:全局控制。看软复位是否完成,cache设定是否正确。

如果驱动已经挂载了debugfs,可以直接看:

mount -t debugfs none /sys/kernel/debug cat /sys/kernel/debug/dwc3/regdump

但很多嵌入式环境没开debugfs,这时用devmem直接读物理地址也够用。前提是知道基地址和偏移。比如寄存器基址是0x60000000,GSNPSID默认偏移是0xC0,那么:

devmem 0x600000c0

读出来的值对一下datasheet,能确认控制器是否正常活着。我踩过最大的坑是以为驱动写错了,最后发现外部PHY没上电,控制器跑不起来,读寄存器全返回0xFF。

5.2 用trace event替代到处加日志

DWC3源码里本身就有trace event,比如dwc3_ep_queue、dwc3_complete、dwc3_event这些事件。开启方式特别简单:

echo 0 > /sys/kernel/debug/tracing/tracing_on echo 1 > /sys/kernel/debug/tracing/events/dwc3/enable echo 1 > /sys/kernel/debug/tracing/tracing_on cat /sys/kernel/debug/tracing/trace

用trace有几个好处:不用重新编译内核,不影响时序,抓到的直接是驱动内部关键节点的状态。printk加多了会改变中断时序,有些USB问题是时序敏感的,printk加得越多越复现不了。

项目里实测,追一个request卡死的案例,用trace看事件流比人的眼神好使太多。你直接能看到请求有没有被queue进来,有没有产生完成事件,事件处理时有没有报错。比在dwc3_interrupt里打断点半天敲不开要高效得多。

5.3 常见问题定位思路速查表

现象可能原因先查什么
USB不识别,dmesg没有link upPHY供电/复位、时钟配置、链路训练失败GCTL、DSTS、外部PHY配置
枚举卡在device descriptorEP0处理异常、事件缓冲溢出、DMA地址错误抓dwc3 event trace,看EP0 Complete
传输进行中,completion超时IMOD太大、中断丢失、TRB环形队列满检查IMOD配置、抓中断事件、看TRB enqueue位置
只有HighSpeed,跑不到SuperSpeed设备树maximum-speed限制、PHY lane配置错误DSTS速度位、dwc3_get_properties里的参数
gadget卸载端点卡死端点busy但没走完,pending request未清理查dwc3_gadget_ep_disable返回值
设备反复link_up/link_downOTG角色切换干扰、ID信号抖动查extcon/typec通知链路、DWC3_DEVCTL

这张表不是万能药,但能快速把问题范围从整个USB协议栈缩小到某一层。缩小以后再决定是看代码还是看datasheet。

5.4 几个容易踩的坑

第一个坑是缓存一致性问题。TRB缓冲区用dma_alloc_coherent分配没事,但如果是自己分配的普通buffer并手动转DMA地址,在DMA前后要做sync。DWC3某些平台开启cache后,忘记sync就会出现数据“一半新一半旧”,传输也不报错,数据就是错的。

第二个坑是IMOD。这个中断调制参数不是越大越好,默认值在有些内核里也不是最优的。吞吐量测试上不去,先把这个值调小试试。

第三个坑是TRB队列满的处理。有些场景需要暂停端点才能更新TRB,如果上层调usb_ep_queue时端点TRB满了就应该等待完成事件释放,而不是强行塞。某些驱动实现为了省事直接返回-EAGAIN,上层如果不处理就会丢请求。

第四个坑是调试时改设备树。DWC3设备树属性非常多,不少属性名带有snps,前缀,随便加个不认识的名字影响不大,但拼写错误会被直接忽略。排查的时候可以先cat /proc/device-tree下面的节点,确认属性实际生效了没有。

最后说点实际经验

我个人的体会是,DWC3这套驱动代码虽然庞大,但非常有规律。调了几个月USB相关的项目之后,我最常用的手段不是读源码,而是抓trace。先看link状态有没有起来,再看EP0有没有正常处理,最后才看数据传输路径。按这个顺序,大多数问题都能在十几分钟内定位到具体模块。

还有一点想分享给刚入门的朋友:不要试图一次性把整个dwc3目录看完,没有意义。实现一个gadget功能,你只需要关注gadget.c、ep0.c、udc核心接口;解决一个不识别问题,你要关注core.c和PHY配置;排查吞吐问题,你去看TRB调度和IMOD。每个症状背后对应的代码路径不一样,按需阅读,效率最高。

DWC3的难点不在于代码本身,而在于它挂在一堆SoC私有逻辑中间。同一份源码,在不同平台上表现完全不同,所以源码里到处是quirk和版本判断。遇到怪问题,先别怪驱动,检查完外部PHY和时钟,再回头深入代码,往往会有答案。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进