I3C target发送完成回调为何不触发?协议模型与寄存器事件解析
开头搞I3C从机target功能的朋友应该都有体会I3C协议本身不算难难的是控制器外设的实际行为和你的预期对不上。最近在做一个传感器采集模块主控端通过I3C主机读数据模块这边是target。功能逻辑不复杂——主机发一个读取请求模块把缓存的传感器数据通过I3C总线发出去发完之后置个标志位再推进下一帧数据的准备。我原本以为从I2C迁移到I3C就是改改寄存器配置、换一套中断处理就行结果第一步就卡了半个下午target发送回调始终不触发。示波器上数据明明已经发出去了可软件里就是拿不到“发送完成”的事件。这个问题看着简单背后牵扯到的却是I3C协议模型和控制器中断设计之间一个很基本的认知差异。这篇文章就把这件事从头到尾拆一遍目标模式下的“发送完成”在协议层面到底怎么定义、控制器寄存器实际行为是什么、我当时怎么一步步排查出来以及最终在工程上我选择了哪种方案来判断发送完成。给同样在做I3C target开发的同行一个参考省得再踩一遍这个坑。1. 现象描述与问题定性1.1 项目背景和数据通路先交代一下背景。模块主控是一颗国产ARM Cortex-M内核的MCU自带一个I3C控制器支持target模式。传感器数据通过SPI进MCU缓存在RAM里主控端大概每10ms来读一次。I3C这边的链路是主控端作为I3C controller发起读取事务模块作为target响应把缓冲区的数据按固定长度发出去。SDK里提供了一个I3C target发送的API函数原型类似这样int32_t i3c_target_transmit(uint8_t *buf, uint32_t len);SDK文档里写着调用这个接口之后数据发送完毕会触发一个发送完成回调开发者可以在回调里释放缓冲区、更新状态。我当时就是按照这个流程写的static uint8_t tx_buf[64]; static volatile uint8_t send_done_flag 0; void send_done_callback(void) { send_done_flag 1; // 在这里释放DMA描述符、更新状态机 } // 收到主机读请求后调用 int32_t on_read_request(void) { memcpy(tx_buf, sensor_data, 64); send_done_flag 0; return i3c_target_transmit(tx_buf, 64); }代码看起来没什么问题主循环里轮询send_done_flag置位后进入下一帧准备。结果一跑起来现象非常明确总线上数据确实出来了主控端也能正确收到完整数据但send_done_flag永远不变程序卡死在等待发送完成的循环里。1.2 复现步骤与现象记录我把复现步骤和现象整理一下方便后面定位。主控端发起I3C读请求模块端进入target读取流程调用i3c_target_transmit把64字节填入发送FIFO用示波器观察SDA/SCL可以看到完整的数据帧ACK正常结束条件P正常主控端读取到的数据内容完全正确CRC也通过模块端中断服务函数里加了调试打印确认进入了I3C中断但发送完成回调从未被调用主循环一直卡在等待send_done_flag的位置我当时的第一个反应是中断配置没弄对于是反复检查了I3C使能寄存器、中断屏蔽寄存器、NVIC使能甚至把SDK里所有和发送相关的回调都打印了一遍依然没有任何输出。这就开始有意思了——总线层面完全正常软件层面就是缺了最后那个“事件”。1.3 问题初步定性到这里基本能排除数据通路的问题了。数据已经通过总线正常发出说明FIFO写入、移位寄存器、SDA驱动、时钟同步这些硬件通路都没问题。卡住的是“软件感知”这一层也就是说我们预期的“发送完成”事件在实际的I3C控制器中根本没有产生或者它的含义和我们理解的不一样。这个初步判断很重要因为它把问题从“数据发不出去”引导到了“协议事件机制理解偏差”上。接下来必须回到协议和寄存器层面去看控制器到底在什么情况下才会上报“发送完成”。2. 协议层面target模式下“传输完成”到底怎么定义2.1 一次完整的target发送事务的协议拆解先抛开寄存器把I3C协议在target模式下的一次发送事务完整看一遍。I3C的读取事务在SDRSingle Data Rate模式下总线上的基本流程是这样的主机发送START条件主机发送target的7位动态地址最后一bit为1表示读target识别到自己的地址后回应ACK主机释放SDA开始产生SCL时钟target在SCL驱动下逐bit把数据放到SDA上每发送一个字节主机在第9个时钟周期给ACK表示“继续发”主机读够了数据发送NACK并跟着一个STOP条件P或者重复STARTSr结束事务关键点在于整个过程中SCL时钟始终由主机控制。target手里只有SDA这根数据线的驱动权时钟是主机给的。target不能自己决定“我发完了”它只能按照主机的时钟节奏把数据一位一位送出去。如果主机不产生时钟target哪怕FIFO里还有数据也只能干等着反过来如果主机持续产生时钟target的FIFO空了硬件就会在SDA上填充0或者触发下溢事件这属于另一种异常。所以从协议角度讲target侧的“发送完成”在物理上可以拆成两个层次层级一FIFO里待发送的数据全部被移位寄存器取走逐个bit被时钟推出了SDA——这是“数据出完了”。层级二主机发出了NACKP或Sr整个事务在法律上结束了——这是“事务结束了”。这两个层次是不同的事件。很多嵌入式开发者在I2C时代习惯了“主机发完数据产生停止条件”这种比较直观的模型到了I3C这里容易被绕进去。2.2 “FIFO空”不等于“发送完成”我在排查过程中一度以为FIFO空就是发送完成。看SDK的发送实现它确实在等待FIFO空。但仔细一想这里有个时间差问题。FIFO空只能说明“数据已经进入了移位寄存器”但移位寄存器里的字节还没有完全被时钟推出去。如果直接拿FIFO空当发送完成信号在低速SCL下任务可能会提前唤醒几百微秒。更麻烦的是如果后面主机还有额外的时钟周期比如连续读多个字节FIFO空之后可能还会从DMA或者软件里继续加载下一批数据这时“空”只是一个瞬时状态不是事务终点。我自己做过一个测试在目标模式发送8个字节发送完成后持续读取FIFO状态寄存器打印出来的原始记录大概是这样的[I3C] TX_START: 写入8字节 [I3C] STATUS 0x0028 // FIFO_EMPTY1, TX_READY1 [I3C] STATUS 0x0020 // FIFO_EMPTY1 [I3C] STATUS 0x0020 [I3C] STATUS 0x0020FIFO_EMPTY在第1个字节发出后就一直保持因为它是一个“当前是否为空”的实时状态不是“本次传输完成”的事件标志。如果你在中断里看到FIFO_EMPTY为真就认为发送完了那其实从第1个字节结束开始这个条件就满足了后续数据还没发完回调就提前触发了。2.3 控制器不给你“传输完成”中断的真实原因那为什么很多I3C控制器根本没有“TX complete”这个中断位我查阅了手头这对芯片的数据手册也翻了一部分MIPI I3C Basic规范的内容。原因概括起来就是一句话在target模式下控制器自身无法识别“本次传输已经完成”因为何时结束完全由主机决定target控制器只能在检测到主机发出的总线条件之后反过来推断“哦这个事务结束了”。说白了target侧的事件模型是被动的。控制器知道的无外乎两件事自己的FIFO还有没有数据内部状态总线上的条件P、Sr、IBI请求等有没有发生外部事件至于“主机打算读多少字节”“主机什么时候发NACK”这些在事务开始之前target控制器并不知道。所以“传输完成”并不能作为一个精确的、由控制器主动上报的内部事件存在它要么依赖“FIFO空主机还没结束”的组合判断要么依赖“总线结束后反推”的间接确认。这也是为什么你搜索这类问题时很多I3C控制器手册里给你的中断是END、COMPLETE、STOP_DETECT这类“总线条件事件”而不是一个简单的“数据发送完成”。理解到这一层排查方向就清晰了。3. 寄存器级实测中断状态到底缺了谁3.1 中断使能配置逐项检查定位到事件模型差异之后我开始一项一项检查寄存器配置。我用的这个控制器的中断源大概有这么几类TX_FIFO_UNDERFLOW发送FIFO下溢TX_FIFO_EMPTY发送FIFO为空TX_FIFO_NOT_FULL发送FIFO未满可以继续写入RX_FIFO_OVERFLOW接收FIFO上溢RX_FIFO_NOT_EMPTY接收FIFO非空STOP_DETECT检测到停止条件START_DETECT检测到起始条件IBI_PENDING带内中断请求挂起我一开始为了等“发送完成”把TX_FIFO_EMPTY、TX_FIFO_UNDERFLOW、STOP_DETECT这几个中断全部打开了。之所以这么干是因为我下意识认为“FIFO空了差不多就是发完了”再加上STOP_DETECT兜底怎么也能触发其中一个。结果实测下来TX_FIFO_EMPTY中断确实触发了但触发时机很早第一个字节从FIFO进入移位寄存器后就置位了根本不能用作“整个发送完成”的标志。TX_FIFO_UNDERFLOW没有触发因为数据量刚好匹配没有出现无数据可发的下溢情况。STOP_DETECT中断只有在主机发送停止条件时触发而这个场景里主机读完之后除了P还会发Sr继续写命令所以检测到的条件和我预期对不上。3.2 中断状态寄存器的实测记录我写了一段临时调试代码在I3C中断服务函数里直接读ISR寄存器并通过串口打印出来用来观察各状态位在完整发送过程中的变化顺序。void I3C_IRQHandler(void) { uint32_t isr I3C-ISR; if (isr ISR_TX_FIFO_EMPTY_MASK) { I3C-ICR ISR_TX_FIFO_EMPTY_MASK; printf([I3C] ISRTX_FIFO_EMPTY\r\n); } if (isr ISR_TX_FIFO_UNDERFLOW_MASK) { I3C-ICR ISR_TX_FIFO_UNDERFLOW_MASK; printf([I3C] ISRTX_FIFO_UNDERFLOW\r\n); } if (isr ISR_STOP_DETECT_MASK) { I3C-ICR ISR_STOP_DETECT_MASK; printf([I3C] ISRSTOP_DETECT\r\n); } // ... 其他标志位同理 }实测打印结果[I3C] ISRTX_FIFO_EMPTY [I3C] ISRSTOP_DETECT [I3C] ISRSTART_DETECT [I3C] ISRTX_FIFO_EMPTY [I3C] ISRSTOP_DETECT [I3C] ISRSTART_DETECT仔细观察这个顺序TX_FIFO_EMPTY经常在一开始就出现STOP_DETECT和START_DETECT成对出现但始终没有一个标志表示“刚才那一整包数据发送完了”。这里我意识到一个更关键的问题STOP_DETECT虽然在数据发完后出现了但它有没有可能是“上一笔事务”的停止条件如果再往前看一次打印会发现主控端在发起读请求之前可能刚发完一个短写命令那个写命令的结束条件同样会触发STOP_DETECT。也就是说即使我依赖STOP_DETECT来判断发送完成也无法区分它属于哪一笔事务。3.3 关键结论对应事件位不存在逐位对照参考手册之后我确认了一件事这个I3C控制器在target模式下确实没有“发送数据全部写完”这个独立中断位。参考手册里所有和发送相关的事件都是围绕FIFO的实时状态或者FIFO下溢异常设计的控制器的设计假设是开发者应该自己做状态管理通过合理设置事务长度、监控FIFO状态、结合总线条件事件来综合判断发送什么时候结束。后来我又翻了另一款友商芯片的I3C控制器手册发现情况类似只是在事件命名上略有差异有的叫TX_END有的叫TCOMPLETE但它们本质上都是“总线事务结束”事件而不是“数据移位完成”事件。也就是说你不太可能找到一个和“I2C从机发送停止中断”一样简单直观的硬件信号。这个结论直接否定了我最初的代码写法也强行把方案设计从“等一个中断”变成了“根据协议时序主动判断”。这个转变才是问题的真正解法。4. 工程上我是怎么正确判断“发送完成”的4.1 方案一用总线事务结束事件替代既然控制器不是什么事都不给而是给了一个“总线事务结束”事件我第一个想到的方案就是把它用起来。思路是主机发起读事务target把数据发完主机最后一定会发NACKP或NACKSr来终止这个事务。只要在目标代码里维护一个状态标志比如“当前是否处于发送中”然后在STOP_DETECT中断里检查这个标志如果为真就说明刚才那笔发送事务已经结束了此时触发回调。实现上大概是这样的static volatile uint8_t tx_in_progress 0; int32_t i3c_target_transmit(uint8_t *buf, uint32_t len) { // 写FIFO、配置DMA等省略 tx_in_progress 1; return 0; } void I3C_IRQHandler(void) { uint32_t isr I3C-ISR; if (isr ISR_STOP_DETECT_MASK) { if (tx_in_progress) { tx_in_progress 0; send_done_callback(); } I3C-ICR ISR_STOP_DETECT_MASK; } }这个方案有个前提主机的事务节奏必须和你的代码预期一致。如果主机在读请求之后马上跟了一个写命令STOP_DETECT可能会先被那个写命令触发导致你误判当前那笔发送还没真正结束。所以最好在STOP_DETECT中断里再加一层判断比如检查当前控制器状态寄存器里的“控制器/目标模式状态”和“最后传输方向”确保确实是在读状态下收到的停止条件。实测下来这个方案是可以用的但它要求你对主机的事务时序有整体了解。如果主机端是颗Linux SoCI3C驱动里面可能还会带HDR模式切换、CCC命令穿插等操作STOP_DETECT会更频繁判断起来需要更仔细。4.2 方案二用最后一个字节发送完成的硬件标志在排查过程中我发现有些芯片提供了“TX byte complete”之类的标志尽管名字可能不叫这个。这个标志的含义是当前正在发送的这一个字节已经从移位寄存器完全送出SDA已经释放或者即将释放。它和FIFO空不一样是一个“当前字节结束”的事件。如果你的控制器有这个标志那么配合发送长度可以在软件里做计数发送开始时记录待发送字节数n每个字节发送完成标志触发时计数值减1减到0时说明最后一个字节已经移出此时可以认为数据发送完成这个方案比FIFO空精确因为它确实反映了物理发送的进度。而且不依赖主机是否发送停止条件即使主机后面没有立即发P只要最后一个字节移位完成你也能确认数据已经出去了。但要注意如果主机读的数据比你FIFO里准备的数据多计数器可能永远减不到0反而会因为FIFO空触发下溢。所以这个方案需要配合一个超时或者数据量匹配检查防止双方约定不一致时卡死。4.3 方案三FIFO空加小延时兜底最朴素的方案是先检查FIFO空然后等一小段时间确保最后一个字节已经从移位寄存器完全送出去再触发回调。为什么需要延时简单算一下。假设I3C工作在10MHz SDR模式一个字节加上起始位和停止位大约需要10个SCL时钟周期也就是1微秒。如果FIFO空在最后一个字节进入移位寄存器时就置位了那么你还需要再等一个字节的发送时间才能确保数据真正到了SDA上。工程上为了留余量我通常等两个字节周期也就是2微秒左右。实现方式可以是在主循环里查一次FIFO空标志然后延时200个CPU周期再看时间到没到也可以直接用定时器做短延时。这个方法实现简单不需要理解复杂的中断事件代价是CPU会被占住一小段时间而且延时时间需要根据实际SCL频率计算调整如果总线频率很低比如100kHz一个字节就要100微秒这个方案就不太合适了。我在没有字节完成标志的平台上用过这个方案属于一种“物理意义上虽然不够精确但工程上足够用”的办法。4.4 三种方案怎么选三个方案摆在一起各有适用场景。我平时选择的标准是优先看芯片提供了什么硬件事件再看主机的I3C事务规律最后才考虑CPU占用。为方便对比我做了一个简单的评估表方案精确度中断依赖实现复杂度适用场景总线事务结束事件高但依赖主机按时发P/Sr需要STOP_DETECT等总线事件中断中需要维护事务状态主机事务规范、不会乱穿插命令的场景字节完成计数最高逐个字节确认需要每个字节的完成事件中断中高需要做计数和超时数据量不大、需要严格确认发出字节数的场景FIFO空延时较低延时依赖总线频率不需要额外中断低低速调试阶段、临时验证、对实时性要求不高的场景我最后的实现是方案一和方案二结合用字节完成事件做计数确认数据已经全部移出同时保留STOP_DETECT做事务边界的兜底。这样即使主机在数据没发完时就发了NACK结束我也能感知到异常不会让代码一直卡在等待状态。5. 常见问题与排查技巧实录5.1 这个问题的高频排查速查表我在排查过程中也把常见原因整理成了一个速查表后续遇到类似问题可以直接对照现象可能原因解决思路数据发出去了但没有发送完成回调控制器只有总线条件事件没有独立发送完成事件改用STOP_DETECT、事务结束事件判断回调触发时机过早数据还没完全发出把FIFO空当成了发送完成区分FIFO空、字节完成、事务结束三层含义中断服务函数里状态标志识别不了是哪一笔事务总线上存在多个事务彼此之间无法区分在事件中断里增加状态寄存器过滤判断事务方向回调触发了一次之后后续不触发了中断状态标志没清除或者状态机卡在了中间态检查ICR清除时序发送前重置状态变量DMA模式下回调完全没反应DMA配置错误或者DMA完成中断没有映射到应用回调先用轮询模式验证数据通路再切DMA使用STOP_DETECT后误触发频繁主机频繁穿插短写命令、CCC命令增加事务状态标记只有发送状态才响应停止条件这张表其实回答了我收到的很多类似问题大多数人在I3C target模式下碰到“回调不触发”根源都在对事件层次的理解不深。5.2 中断标志清不掉引发连续中断排查过程中我踩过另一个和回调无关的坑中断状态标志没有及时清除导致中断服务函数被反复执行系统看起来就像“卡死”了。I3C控制器的事件标志一般有两种清除方式读清除RO和写清除W1C。我用的控制器大部分标志位是写1清除规则是“写1清零写0无影响”。如果代码里习惯性地写0清零那个标志位就永远不会被清除中断会不断触发。典型的现象是系统运行到一半突然频繁进中断串口打印一堆相同的状态或者程序整体变慢像是被什么持续占用CPU。排查时可以在中断入口打印ISR确认是否是同一个未清除标志不断触发。建议在写清除寄存器之前先看一眼手册里对ICR的描述确认是写1清除还是写0清除。这是一个很低级但很常见的坑。5.3 DMA模式下的特殊坑如果target发送是用DMA把数据从内存搬到FIFO的那么还会有另一个层面的“完成”DMA传输完成。很多人在这里把“DMA完成”和“总线发送完成”混在一起结果就是在DMA中断里触发了回调但总线上的数据其实还没发完。我遇到过这样一个案例DMA配置为从内存到外设FIFO传输完成后触发DMA中断回调里直接释放了源缓冲区。由于释放缓冲区后数据可能被覆盖主机端读到的数据随机出现错位。这就是因为DMA搬完数据之后FIFO里和移位寄存器里还有一部分数据等待总线时钟推出去。正确做法是DMA完成中断只代表“数据进了FIFO”后续还必须等待FIFO将数据全部移出才能释放缓冲区。要么等FIFO空要么等字节完成计数归零要么等总线事务结束。我在目标模式下一般会再加一层判断确保缓冲区生命周期真正覆盖到数据全部出现在SDA之上。5.4 调试I3C target的几个实用建议最后分享几个平时调试I3C target时比较实用的习惯第一个习惯是使用逻辑分析仪抓取总线波形并和我自己的软件事件日志同时对比。先把主机的地址分配、CCC命令、读写请求协议解析清楚再上MCU调试。总线层面的协议流程都不清楚的时候调软件只会越调越乱。第二个习惯是在中断服务函数里用GPIO翻转来标注关键事件比如“进入发送中断”“检测到STOP”“数据写入FIFO”然后和逻辑分析仪波形放在同一时间轴上对比。相比串口打印GPIO翻转的时间精度高得多能直观看到软件事件和总线事件的先后关系。第三个习惯是每配置一个中断源就单独验证一次。我试过一次性把所有I3C中断全部打开结果一进调试模式满屏都是中断日志根本分不清哪些事件是这次发送产生的。定位到具体功能所需的事件之后再批量打开其他中断效率会高很多。最后一个建议在使用发送完成回调之前先确认这个回调在控制器上是否真的有触发的物理条件。很多SDK文档是从通用框架里抄过来的控制器硬件根本没有对应的中断源。直接改代码不如先看手册确认中断源存在、命名对应、触发条件符合预期再动软件。结尾这次排查前后花了大概半天时间最后回头想问题本身并不复杂卡住我的其实是思维惯性总下意识认为“发送完成”就该有一个对应的硬件事件等着我。但实际上I3C target模式下的发送完成不是一个单独的中断源而是“数据移出”和“事务结束”两个事件配合出来的结果需要软件自己去组合判断。想明白了这一点之后后面的代码改动其实很小。我也养成了一个习惯拿到一款新芯片先不看SDK的callback框架直接把控制器寄存器列表里和发送、总线条件相关的中断源全部列出来理一遍各自的触发条件再决定软件事件怎么搭。这样既能避开文档和实现的偏差也不会被回调框架束缚住。如果你也在做I3C target开发恰好碰上类似的回调不触发问题建议先别急着改代码拿逻辑分析仪把总线时序拉出来再对照参考手册的中断事件表逐项确认大概率能省下一整个下午的排查时间。
