
我最早掉进NAK这个坑是在调一款带TypeC接口的SoC做OTG主机功能的时候。当时系统里接了一个U盘枚举阶段一切正常可一跑到批量传输就频繁超时。逻辑分析仪抓下来发现U盘端点一直在回NAK而我的主机库代码拿到NAK之后啥也没干直接当“传输失败”上报给了应用层。后果就是U盘偶尔能读到目录偶尔直接报错。后来翻控制器手册、查USB 2.0协议规范、再看库里那几行状态判断才意识到NAK处理不是简单“重发一次”就完事的问题。这篇文章就是把我在OTG主机库中处理NAK的完整过程、踩过的坑和最终采用的方案整理出来。适合正在做嵌入式USB主机开发、用MCU/SoC自带OTG控制器当主机、或者自己写轻量级USB主机协议栈的朋友看。1. 先把NAK这层窗户纸捅破它到底在说什么NACKNegative Acknowledgment协议里写成NAK是USB传输事务中设备返回的一种握手包。很多人第一次接触NAK是在U盘读写时设备端没准备好于是回了NAK。这东西本身不复杂但它的出现时机、处理策略和错误边界直接影响主机库的稳定性。1.1 USB事务层面的NAK一次握手里的小动作USB传输Transfer由若干事务Transaction组成事务依次是令牌阶段、数据阶段、握手阶段。以批量输出为例主机先发OUT令牌然后发数据包设备收到后如果忙不过来了会在握手阶段回一个NAK。也就是说设备明确告诉你“我收到你的请求了但现在没空处理你过会儿再来。”这个“过会儿再来”不是拒绝也不是错误。它本质上是USB协议里的一种流量控制机制。设备的端点缓冲区满了比如U盘内部正在擦写Flash或端点还在准备数据比如主机在读取数据但设备还没把数据搬进缓冲区就会回NAK。你可以把它想象成去办事大厅排队窗口的工作人员跟你说“稍等前面还有人在办”。这不是说你手续不对而是让他缓一缓。在USB 2.0协议里不同传输类型对NAK的门槛不一样控制传输设备在上电、配置、处理标准请求时如果没准备好可以回NAK。批量传输设备端点缓冲区不可用回NAK。中断传输设备没有新数据或未准备好接收回NAK。同步传输Isochronous没有NAK因为要保证带宽设备必须按时处理。主机端软件需要做的不是拿到NAK就反复重发而是要按照合理的节奏安排重试同时不能影响总线上其他设备的通信。1.2 NAK、NYET与STALL别把它们混为一谈调试NAK时最容易搞混的是另外两个握手包NYET和STALL。很多新手把NYET当成NAK处理或者把STALL也纳入“重试大法”结果越搞越糟。握手包含义处理建议NAK设备忙暂时无法处理稍后重试注意重试节奏与次数限制NYETNo Response Yet高速Split Transaction中集线器还没准备好接收完整数据可重试但通常由主机控制器硬件处理软件层很少直接遇到STALL功能中止设备指示该请求不支持或端点出错不要重试直接向应用层上报错误重试只会浪费总线时间实际开发中我见过有人把STALL也当NAK结果对不支持的命令反复重发导致设备长时间卡在异常状态。最简单的方式是先抓一次枚举流程看设备回什么再决定后续策略。如果你的主机库没有把STALL和NAK分开返回那一定要改库或自己解析握手包状态否则后面所有逻辑都是错的。1.3 为什么主机不能“死磕”NAK如果收到NAK就立刻重发同一个事务比如循环“发数据-收NAK-再发-再收NAK”会发生什么最直接的问题是这条总线被你的端点占满了。USB是共享总线尤其在一个主机带多个设备的情况下一个端点疯狂重试其他设备可能连中断传输都安排不上带宽被挤占整个系统的实时性会崩。另一个问题是设备端可能越忙越糟。以U盘为例它正在擦除块设备内部已经有很重的任务主机还在以最快速度发批量事务U盘控制器需要逐包回应NAK这会消耗它的处理时间。到后来主机自己超时U盘也烦了可能出现复位的连锁反应。所以NAK处理的核心不是“重试多少次”而是“怎么重试才能既完成传输又不成为总线上的害群之马”。后面说的所有策略都是围绕这一点展开的。2. OTG主机库和普通主机栈对NAK的差异库为什么要暴露NAK同样是USB主机Linux内核里的USB core、Windows的USB驱动栈和你在MCU上用的轻量级OTG主机库对NAK的处理方式完全是两个世界。搞清楚这个差异你才能理解“在OTG主机库对NAK的处理”到底在说什么。2.1 标准协议栈会帮你把NAK吃掉在Linux里URBUSB Request Block提交后大部分情况不会把NAK直接返回给驱动程序。USB core和主机控制器驱动HCD会在内核里完成重试直到传输成功或超时。比如批量URB只要没有发生STALL或设备断开HCD会把事务反复提交URB的完成回调会收到成功或超时很少会收到“NAK”这个中间状态。这是标准协议栈对开发者的友好之处你不需要关心每次事务的握手结果只要设置合适的URB超时时间就行。但代价是代码库庞大、抽象层多不适合资源受限的嵌入式平台。而在OTG主机库特别是MCU或SoC厂商提供的裸机库、BSP驱动里情况完全不同。控制器硬件往往只是完成单次事务NAK会作为一个状态直接返回给你。库本身不做重试它把选择权交到你的手上。2.2 为什么轻量级库会把NAK抛给上层很多轻量级USB主机库的内部模型是“提交一次传输等待完成中断”。控制器的寄存器里会有传输完成XferCompl和NAK等待NAKInt等状态位。当设备返回NAK时控制器认为这次事务“没完成”但不是“错误”于是通过中断或状态寄存器把这个结果暴露出来。库之所以不帮你自动重试有几个原因。一是自动重试会改变传输的实时行为有些场景比如控制传输需要尽快判断设备是否响应二是重试策略与上层业务强相关有的希望重试100次有的希望重试3次就报错三是如果库自动重试在某些控制器上会和DMA链表的next描述符机制冲突反而增加复杂度。所以一个设计合理的OTG主机库会把NAK作为一个独立状态返回告诉上层“这次事务没完成原因不是错误而是忙”。上层拿到这个状态后需要自行决定是忽略、重试还是放弃。2.3 从一次RK3568 TypeC OTG调试经历说起我之前在某款基于RK3568的板子上调TypeC OTG跑的就是厂商SDK里的USB主机库。硬件上TypeC口连接U盘OTG控制器通过DMA搬运数据。第一次调批量传输时应用层一直报“transfer error”跟踪到库里的传输完成回调返回值不是成功也不是超时而是USBH_NAK。我当时的第一反应是“NAK就重发呗”于是在回调里直接重新提交传输请求。结果U盘还是时而成功时而失败而且失败次数比之前还多。后来用协议分析仪看发现我的重发是无间隔的连续提交总线被批量事务占得满满的U盘在每个间隔都回NAK形成了一个“NAK风暴”。那一刻我才意识到问题不是“要不要重试”而是“怎么重试”。这个经历让我养成了一个习惯拿到任何OTG主机库先看它的传输状态定义里有没有NAK再看它的回调是否在中断上下文最后看控制器DMA描述符在NAK后是否需要重置。这三件事没搞明白之前不要写任何重试代码。3. 实操在主机库代码里正确处理NAK下面这部分是经验核心。我会从一个普通OTG主机库的角度讲清楚处理NAK的完整流程包括怎么找到NAK状态、怎么设计重试策略、怎么写状态机以及不同传输类型的差异。代码是伪代码和实际C语言的结合但思路可以直接拿到你的项目里用。3.1 先确认你的库/控制器把NAK放在哪个状态位不同控制器对NAK的呈现方式不一样处理前必须先把这一点查清楚。常见的有两种情况第一种库函数返回状态码。比如一次传输完成后你通过USBH_GetTransferResult()拿到结果结果可能是USBH_OK、USBH_NAK、USBH_STALL、USBH_TIMEOUT。这种情况下你只需要在代码里增加一个对USBH_NAK的分支。第二种控制器通过独立中断上报NAK。比如有一个NAK Interrupt当某端点收到NAK时触发需要你读取端点号和传输方向。这种情况下你要在中断服务函数里记录是哪个端点NAK了然后调用该端点对应的重试状态机。以某款常见SDK为例库的传输结构体大概是这样的typedef struct { uint8_t ep_addr; uint8_t *buffer; uint32_t length; uint32_t actual; uint16_t nak_count; uint16_t max_retries; uint32_t retry_delay_ms; transfer_status status; } usb_transfer_t;status会包含TRANSFER_OK、TRANSFER_NAK、TRANSFER_STALL等枚举。如果你发现自己的库没有nak_count和retry_delay_ms这两个字段建议自己加上否则很难做出合理的重试策略。3.2 重试策略固定重试、指数退避还是延迟调度拿到NAK之后“重试”这两个字听起来简单但策略选错一样会出事。我踩过的坑就是“无间隔重试”。后来我把重试策略分为三个层次固定次数固定间隔适合低速设备、控制传输逻辑简单。比如重试10次每次间隔1ms。指数退避适合批量传输尤其是设备内部在忙比较重的任务比如NAND擦写。第一次间隔1ms第二次2ms第三次4ms最多不超过10ms重试次数可以放宽到几十次。延迟队列调度适合中断传输和多设备场景。NAK后不立即重试而是把这个端点的重试请求挂到一个定时器队列里等下一个轮询周期再发起。为什么推荐指数退避因为设备越忙你越快重试它越可能继续回NAK。给设备一点喘息时间反而能更快完成传输。你可以把它理解成追着人问问题对方越忙你问得越急他越没法回答你退一步等他处理完手头的事再问一下就通了。通用代码模板可以这样写bool handle_nak(usb_transfer_t *t) { if (t-nak_count t-max_retries) { t-status TRANSFER_FAIL; report_error(t); return false; } t-nak_count; uint32_t delay t-retry_delay_ms; if (t-use_exponential_backoff) { delay t-retry_delay_ms (t-nak_count - 1); if (delay MAX_NAK_RETRY_DELAY_MS) { delay MAX_NAK_RETRY_DELAY_MS; } } schedule_transfer(t, delay); // 把重试挂到定时器而不是在这里立刻调用 return true; }注意schedule_transfer这一步很关键。如果你在回调里直接再调用submit_transfer那就是无间隔重试。正确做法是把重试挂到延迟任务里让主循环或定时器在指定时间后再发起。3.3 实现一个简单的NAK重试状态机对于单个端点的传输我建议维护一个简单的状态机。状态包括空闲IDLE、等待传输完成BUSY、收到NAK等待重试WAIT_RETRY、传输完成COMPLETE、传输失败ERROR。核心状态转移如下收到NAK状态从BUSY进入WAIT_RETRY同时设置一个定时器。定时器到点重新提交传输状态回到BUSYnak_count累加。收到成功完成状态进入COMPLETE上报成功。重试次数超限状态进入ERROR上报失败。伪代码typedef enum { TRANS_IDLE, TRANS_BUSY, TRANS_WAIT_RETRY, TRANS_COMPLETE, TRANS_ERROR } trans_state_t; void usb_transfer_task(usb_transfer_t *t) { switch (t-state) { case TRANS_BUSY: if (t-status TRANSFER_NAK) { if (handle_nak(t)) { t-state TRANS_WAIT_RETRY; } else { t-state TRANS_ERROR; } } else if (t-status TRANSFER_OK) { t-state TRANS_COMPLETE; } break; case TRANS_WAIT_RETRY: if (timer_expired(t-retry_timer)) { submit_transfer(t); t-state TRANS_BUSY; } break; default: break; } }这个状态机的核心是“NAK不立刻重发”而是通过定时器延后。有了状态机你可以在主循环、RTOS任务或者低优先级中断里统一驱动避免在USB完成回调里做太多事情。3.4 控制传输、批量传输、中断传输的NAK处理差异不同的传输类型对重试节奏的要求完全不同。一开始我用一套固定参数处理所有端点后来发现控制传输和批量传输的适配完全不一样。下面是我的调参经验。控制传输比如枚举时的GET_DESCRIPTOR、SET_CONFIGURATION通常发生在设备刚上电或状态切换时设备可能还没完全准备好。控制传输的NAK重试可以稍微积极一些默认每次间隔1ms最多重试50次。因为控制传输本身有超时限制通常5秒50次毫秒级重试足够也不会把总线拖死。注意控制传输如果收到STALL必须立刻停止重试返回不支持。批量传输U盘、虚拟串口等是NAK的重灾区。批量端点通常在设备忙时回NAK比如U盘写入时Flash正在忙此时建议使用指数退避最大间隔不要超过10ms总重试次数可以放宽到100次甚至更多。因为批量传输对延迟不敏感但要求最终完成。中断传输键盘、鼠标、HID设备比较特殊。这类端点在正常工作时就有固定的轮询间隔比如全速设备每10ms一次高速设备每125us一次。设备在两次轮询之间如果没有事件就会回NAK。如果你的主机库把这种NAK上报上来你绝对不能用“指数退避”去重试而要严格等待端点描述符里的bInterval时间后再重试。否则你会发现中断传输的带宽被自己消耗掉其他设备也跟着卡。同步传输没有NAK不用考虑。4. 常见坑与排查实录下面这些问题是我在多个项目里实际遇到过的每一个都能让系统看起来“时好时坏”很难排查。我按踩坑频率从高到低列出来。4.1 坑一NAK重试时没有清对应的中断标志有些控制器的NAK中断是独立标志位如果你在处理完NAK后没有写“清中断”寄存器中断标志会一直挂着。这种情况下即使你还没有发起新的传输ISR也会被反复触发导致重试代码被连续执行甚至溢出栈。排查方法很简单在NAK处理函数入口和出口各加一个调试计数看ISR是不是在没有新传输的情况下也一直进来。如果是先查数据手册里对应控制器的中断清除位在读取状态后立即清除。清标志和重新提交传输之间最好再读一次状态寄存器确认避免标志清除又被新到的NAK置位造成重复处理。4.2 坑二把NAK当成错误上报给应用层这是一个“看起来没毛病、实际很要命”的坑。如果你的库函数返回USBH_NAK而应用层把任何非成功的返回值都当成错误处理那么UA盘读写失败率会非常高。尤其当U盘正在执行长时间写操作Flash忙时NAK可能是常态你若上报错误应用层可能去复位总线反而打断设备。处理建议是明确划分状态码NAK属于“可重试状态”STALL和超时才属于“错误状态”。如果你用的是别人封装的库库里统一返回-1表示失败那你要么改库要么在库调用层把NAK单独拦截出来。总之应用层永远不应该看到NAK。4.3 坑三在ISR里做重试导致系统卡死很多轻量级USB主机库的传输完成回调是在中断上下文执行的NAK重试也容易顺手写在回调里。这样做的直接问题是如果你在ISR里调用定时器等待或提交新的DMA描述符而这个操作依赖另一个低优先级任务去处理就可能造成死锁或丢失中断。我推荐的做法是所有NAK重试都通过“标志位状态机”延后到主循环或独立任务中执行。ISR里只做两件事保存状态、置一个nak_pending标志。主循环检测到标志后再去执行延迟调度和重新提交。这样做的代码量没有增加多少但系统的稳定性提升了一个量级。4.4 问题速查表现象可能原因排查/解决方法设备传输偶尔失败尤其是写U盘时没有处理NAK直接上报错误在协议层单独处理NAK按批量传输策略重试持续NAK风暴总线占用高无间隔重试或重试逻辑在ISR里使用指数退避定时器延后重试重试一段时间后系统卡死在ISR中调用阻塞等待或重入库函数改为状态机主循环触发每次NAK处理后会重复进入多次中断标志未清除查控制器手册处理完及时清标志控制传输超时重试间隔过长/次数过少控制传输使用1ms固定间隔总超时约5s中断传输性能下降用批量退避策略处理中断NAK中断传输严格按bInterval等待后重试5. 几个实测数据与调优建议最后聊聊我实际调试出来的几个参数以及一些和DMA相关的经验。这部分每一套参数未必通用但可以作为你调优的起点。5.1 重试次数与延迟怎么选以USB 2.0全速设备为例一个帧是1ms批量设备NAK通常意味着设备内存忙。我常用的初始参数是批量传输指数退避初始延迟1ms最大10ms最大重试次数100次。控制传输固定延迟1ms最大重试次数50次。中断传输固定延迟为bInterval全速为1ms的倍数最大重试次数为2次。如果中断端点连续NAK两次说明设备可能有问题应上报错误。如果你发现传输偶尔失败但重试能成功优先调大重试次数而不是缩短延迟。因为NAK本身的出现频率和设备负载有关缩短延迟只会让设备更忙效果反而不好。我测过一款U盘在擦写时的NAK频率连续写入时每10个批量事务大概能遇到3~5个NAK使用指数退避后单次写入请求通常在3次重试内完成。如果无间隔重发这个数字会变成10次以上而且偶尔真的会把U盘搞到“假死”。5.2 配合DMA时的注意事项如果你的OTG主机控制器用DMA搬运数据NAK后要特别小心DMA描述符的状态。有些控制器的DMA会在NAK时停止并保留当前传输地址你需要判断是否需要重新提交描述符还是只重新触发一次传输即可。我踩过的坑是DMA描述符里的缓冲区地址在NAK之后没有被重置而主机库的重试逻辑又把整个传输重新提交了一遍于是出现数据错位。后来我在重试前加了一步判断当前传输的actual字节数是否被控制器正确更新如果DMA状态寄存器里还有错误标志就先复位DMA通道、重新配置描述符再发起重试。如果你是裸机开发建议在重试函数里加上这样一个保护if (dma_channel_is_busy(hcd-dma_channel)) { dma_channel_reset(hcd-dma_channel); dma_channel_configure(hcd-dma_channel, t-buffer t-actual, t-length - t-actual); }这样做还有一个好处即使调用重试前DMA还没完全停下来也能保证数据从头开始或者从断点继续不会污染缓冲区。另外重试期间不要让其他端点同时修改同一个DMA通道否则会出各种诡异问题。如果你要支持多端点并发最好为每个端点分配独立的DMA通道或者在调度上保证同一时间只有一个端点做重试。最后再分享一个小技巧在调试阶段把每次NAK都打印出来包括端点号、方向、重试次数、延迟时间。等整个系统稳定后再把打印改成统计计数。这个习惯帮我快速定位了不少问题比如某个控制传输在设备复位后频繁NAK一看日志就发现是供电不稳导致设备反复重启这和重试策略无关。有了日志你才能分清“协议栈的问题”和“外围硬件的问题”少走很多弯路。