基于FUSB302的USB PD协议从零实现:状态机与寄存器配置实战
简介面向嵌入式开发者的FUSB302芯片供电协议PD可运行源码示例聚焦PD1.0/2.0协议初始化、通信流程与电压调整功能适用于需要快速落地USB PD充电方案的充电器、移动电源及Type-C接口设备开发。资源包共2个文件以inscode源码和html说明文档为主压缩后仅约3KB便于直接查看与移植。代码覆盖器件初始化、CC引脚检测、FIFO缓冲区读写、消息ID管理以及电压电流请求处理等核心环节同时实现了源能力消息、请求消息、接受消息等关键PD消息的收发与解析并能根据协商结果调整电源输出通过按键可进入PPS模式在1V、100mV和20mV档位精确调节输出电压满足不同设备的充电需求。目前已有281人学习下载这套源码为工程师提供了可直接运行的PD协议实现框架能够显著降低嵌入式环境下引入Power Delivery功能的复杂度是深入理解FUSB302芯片工作机制与USB PD协议栈的实用参考。 前阵子做一个 Type-C 接口设备需求很简单设备本身是一个标准的 5V 供电外设但用户希望它能“听懂”PD 协议在插入支持 PD 的电源时主动申请更高的电压档位最终跑在 9V/12V 甚至 20V 下。绕了一圈最后选了 FUSB302 这颗芯片来做协议协商整份 PD 协议代码从零写到了能跑。这次把“可运行源码”背后的工程骨架、状态机设计、寄存器配置和调试方法梳理出来给正准备在单片机上模拟 PD 协议的兄弟们一个可以直接参考的路径。如果你手头已经有 FUSB302 这类带有 BMC 物理层和 CRC 校验硬件的 Type-C 控制器那 PD 协议的实现难度其实比很多人想象的低很多你不必去关心比特级的编码也不用自己算 CRC32只要把精力放在“什么时候发什么消息、收到消息怎么回”就行。这篇文章适合已经在用或准备用 FUSB302 的嵌入式开发者特别是那种要给现有产品加 PD 协商能力、又不想一上来就塞一个完整 PD 协议栈的人。核心目标是让你能独立写出一份最小可运行的 PD 源码并且实测能协商成功。1. 为什么我最后选了 FUSB302先搞清楚 PD 协议到底该谁干USB PD 协议从物理层到策略层分层非常清晰。很多人在这个项目里容易犯的第一个错误就是试图用普通单片机直接模拟 PD 协议连 BMC 双相标记编码和 5-bit 4B/5B 转换都自己用 GPIO 打。这条路不是走不通而是成本极高PD 的比特率是 300kbps 左右数据包有严格的 SOP 起始序列、CRC32、EOP 结束中间还要处理电压摆幅和线缆长度引起的信号畸变。用 GPIO 模拟意味着主控需要极高的中断响应频率还要在协议栈之外维护一堆时序补偿代码调试一轮下来头发基本不保。FUSB302 这类控制器就是把最脏最累的物理层活接走了。它内部集成了 BMC 收发器、SOP/SOP/SOP 的识别与生成、CRC32 硬件校验、以及一个可配置的 32 字节 FIFO。主控通过 I2C 接口读写寄存器就能完成“发一条 PD 消息”和“收一条 PD 消息”的操作中间所有信号完整性相关的细节都由芯片处理。这里有一个特别关键的功能叫“自动 GoodCRC”响应。PD 协议里接收方收到一条 SOP 消息后必须在 tReceiverResponse 时间内回复 GoodCRC否则发送方会重发。FUSB302 可以把这层确认逻辑做到硬件里当你使能了自动 GoodCRC 功能芯片收到合法的 SOS 数据包后会自动回 GoodCRC主控只需要处理后续的业务消息。这简直是把协议栈里最容易被时序卡死的环节直接抽掉了。所以选型结论很直接如果产品里边儿有一颗 MCU只是想增加 PD 协商能力FUSB302 是最省事的选择之一。它不需要额外固件、不需要跑协议栈I2C 只要拉两根线占用的 GPIO 极少功耗也在微安级别。当然如果你做的是大功率充电器、需要支持 PPS 可编程电源那 FUSB302 的寄存器配置会更深入一些但核心架构仍然是“MCU 做策略 芯片做物理层”。2. 可运行源码的工程骨架四层结构决定了代码不会乱我这份工程的代码量不大核心驱动大概 800 行左右整体分四层硬件抽象层封装 I2C 读写屏蔽平台差异FUSB302 驱动层寄存器操作、芯片初始化、中断读取PD 协议层消息构造、状态机、超时管理应用策略层决定响应什么 PDO、在什么条件下发送 Request。四层分开的最大好处是调试的时候不用在芯片寄存器里找应用逻辑。状态机放在独立文件里拿台架测试时可以直接把收发函数 mock 掉用 PC 串口模拟对端设备来验证协议流程。FUSB302 的 I2C 从机地址是 0x227 位地址写操作时序很常规没什么特殊要求。但有一点要注意芯片的 FIFO 寄存器是深度 32 字节的一次 I2C 突发传输可以连续读写多个字节PD 的 Extended Message 和高电压 PDO 列表往往超过 32 字节所以驱动里要自己处理 FIFO 的分段写入和读出。我这边用了一个简单办法写 FIFO 之前先读 Status1 寄存器确认 FIFO 非满再按 16 字节一批写入写完检查。寄存器规划上下面这几个是我在工程里必须打交道的寄存器作用我自己用的配置点DeviceID (0x01)芯片版本信息上电后读取校验通讯是否正常Switch (0x02)CC 引脚开关、测量开关选择监测 CC1/CC2配置 Rd/RpControl0 (0x04)发送使能、自动 CRC 使能置位 AUTO_CRC置位 TX_START 触发发送Control1 (0x05)SOP 类型使能、BIST、toggle使能 SOP 和 SOP用于 Sink/SourcePower (0x09)芯片电源模式选择功耗档位配置为 normal modeStatus0/Status1 (0x0B/0x0C)当前 CC 状态、FIFO 状态轮询或中断触发后读取Interrupt (0x0D)中断标志区分收到消息/发送完成/检测到 CC 变化FIFO (0x0E)数据收发缓冲发送时写消息头对象接收时读取代码里没绕开的是状态机。PD 协商本质是一个“空闲 - 发现对端 - 发送能力 - 收到请求 - 确认 - 供电”的流程。我实现的状态机并不复杂任意时刻有一个 pdev_state 变量枚举值只有 UNATTACHED、SRC_ATTACHED、SNK_ATTACHED 和 POWERED 几个。中断或超时事件触发状态迁移主循环只需要周期调用 fusb_poll()。3. 核心实现拆解从 CC 检测到 PDO 握手的一次完整链路这部分直接看着代码流程走一遍从插线到协商成功。第一步CC 检测判断自己是 Source 还是 SinkType-C 插入瞬间FUSB302 的 CC 引脚上会出现电平变化。作为 Sink我们要在 CC 引脚上提供 5.1kΩ 下拉电阻RdSource 端会上拉一个 Rp。所以初始化时把 Switch 寄存器的 CC 相关位配置好使能需要的通道然后轮询 Status0 里的 CC 状态位或者等中断触发。我在代码里做了一个很土但很有效的办法初始化后每秒调用一次 cc_detect()读 Status0 寄存器判断当前 CC 状态是“打开且连接到 Source”还是“无连接”。判断到连接后根据插头方向自动切换到对应通道再往下走。第二步收 Source_Capabilities拿到电源能力清单Source 端检测到 Sink 连接后会先发送一条 Source_Capabilities 消息里面携带一串 PDOPower Data Object每个 PDO 描述一个电压电流档位。FUSB302 收到消息后会触发 I2C 中断通知 MCU此时 MCU 读取 FIFO 内容按 PD 协议解析出 Header 和 PDO 列表。这里要注意 PDO 的比特结构。以最常见的固定供电 PDO 为例32 位数据中bit[31:30] 是 PDO 类型固定供电是 00bit[29:10] 是电压值单位 50mVbit[9:0] 是最大电流单位 10mA。如果收到 0x0002C12Cvoltage (0x0002C12C 10) * 50mV 11 * 50 550mV? 等等这个数值需要小心。我实际解析时是按小端字节序重组后掩码运算的这里贴一个关键解析函数static void parse_pdo(uint32_t pdo_raw, uint16_t *mv, uint16_t *ma20) { uint8_t pdo_type (pdo_raw 30) 0x03; if (pdo_type 0) { // fixed supply PDO *mv (uint16_t)((pdo_raw 10) 0x3FF) * 50; *ma20 (uint16_t)(pdo_raw 0x3FF) * 10; } }注意电流单位PD 协议里固定 PDO 的电流是 10mA 为单位我注释里写 “ma20” 实际指的是 10mA 的倍数命名不太严谨但代码里大家都这么写惯性了。实际产品里一定要把单位处理好否则后续 Request 里请求电流会算错。第三步选档位发 Request 请求目标电压解析出 PDO 列表后应用策略层决定要用哪一档。比如我接到一个支持 5V/3A、9V/3A、12V/2.5A 的充电器我想跑 12V就构造一个 Request 消息。Request 本质上是要告诉 Source“我选第几个 PDO我期望的电流是多少我有没有更高的需求。”核心函数大概是这样的结构void build_request(uint8_t pdo_index, uint16_t request_mv, uint16_t request_ma) { uint32_t obj 0; // 对象头位置1开始计数填入选择的对象位置 obj | ((uint32_t)pdo_index 28); // 电流请求单位 10mA obj | ((uint32_t)(request_ma / 10) 0x3FF) 10; // 电压请求单位 50mV obj | ((uint32_t)(request_mv / 50) 0x3FF); // 然后写入 header 对象到 FIFO }构造好之后把 Header 和对象写入 FUSB302 的 FIFO然后在 Control0 寄存器置位发送使能。这里需要注意时序发送完成后芯片会产生发送完成中断要在中断里处理下一状态。第四步等待 Accept 和 PS_RDY完成握手Request 发出后Source 会先回一个 Accept 控制消息表示“档位我接受”紧接着回 PS_RDYPower Supply Ready表示“我的电源已经稳定到目标电压”。如果中途 Source 不满意我们的请求会回 Reject 或 Wait。Sink 收到 Accept 和 PS_RDY 后才算真正拿到新的电压档位这时候才能加大负载或切换供电通路。FUSB302 收到控制消息同样会触发中断我这边直接根据消息类型走状态机。Accept 来了进“等待 PS_RDY”状态PS_RDY 到了就把 state 置成 POWERED主循环检测到这个状态后点亮对应 LED 或者切负载。整个流程跑下来大概几十毫秒体感就是插上充电器电压从 5V 跳到目标电压屏幕或者万用表能看到明显变化。4. 编译烧录与实测怎么确认 PD 协商真的成功了很多人写完代码就插上充电器看电压电压没跳就以为代码废了。实际上 PD 协商是一个双向过程问题可能出在物理层、协议层、策略层任意一处。我的建议是分三步验证第一步确认 I2C 通讯正常。上电后读 DeviceID 寄存器能读出预设的芯片版本值说明 MCU 和 FUSB302 之间最基本通路没问题别急着写大段协议逻辑先把这一步跑通。第二步跑 CC 检测。接上 Source 和 Sink 后读 Status0 寄存器观察 CC 状态是否从“无连接”变成“有连接”。这里的日志最好做成周期打印否则稍纵即逝。我一般是把 CC 状态变化打出来确认硬件连接和电阻配置无误。第三步抓 PD 总线数据。这是我强烈建议的做法。PD 通讯跑在 CC 线上逻辑分析仪只要支持 1Msps 采样率配合 I2C 解码和 USB PD 解码插件就能直接看到 CC 线上的 SOP、消息头和对象。我用的是普通逻辑分析仪加 PD 解码虽然解码不是 100% 完美但能看清消息类型和对象内容足够定位绝大多数协议问题。日志打印这块我自己的习惯是在协议层每个关键节点打印一行CC_ATTACHED: channel1TX: Source_Capabilities pdo_num3RX: GoodCRCRX: Request obj_index2 req_mv12000 req_ma2500TX: AcceptTX: PS_RDYSTATE: POWERED这样出了问题看日志就知道卡在哪一步。比如如果发现发了 Source_Capabilities 后迟迟等不到 Request大概率是 Sink 端解析 PDO 失败或者我们发的 PDO 格式有问题如果 Request 发出后没有 GoodCRC那说明物理层发送就有问题检查寄存器发送使能或者 I2C 时序。实测中还有一个容易忽视的地方FUSB302 的 I2C 时钟频率不要乱往上抬。控制器本身标注支持 400kHz但实际使用时 I2C 总线上如果有长走线或者上拉电阻选得不对高速写 FIFO 容易丢字节。我最后稳定跑在 100kHz虽然对 PD 这种毫秒级协议完全够用但调试阶段一旦时序不稳数据错位会很隐蔽。作为排查手段先降频试一次最稳妥。5. 踩坑记录寄存器配置、时序和硬件匹配的三类问题这篇文章如果只是把代码跑通写出来价值不大真正值钱的是我在这个项目里踩掉的三类坑。坑一没开自动 GoodCRC导致协议流程卡在第一步。刚开始写代码时我没用 FUSB302 的自动 GoodCRC而是在中断里手动回。结果发现发送方发出的消息经常被认为是超时重发整个协商没法推进。原因在于 PD 协议要求接收方在收到完整数据包后的很短时间内就回 GoodCRC靠 I2C 中断处理往往赶不上。后来把 Control0 寄存器里的自动 GoodCRC 相关位置位芯片硬件在物理层收到消息后立即自动回复协议流程瞬间流畅起来。所以我的建议很明确新项目直接用自动 GoodCRC除非你要自己实现特殊的 SOP 处理否则没有理由不开。坑二PDO 的字节序和位域解析写反了。PD 消息通过 I2C 读出来是字节流而 PDO 是 32 位整数。通信线上是先发 Header 的低字节再发高字节对象也是先发低字节。我在写解析函数时一开始用大端方式组合导致读出来的电压电流值完全不对。这个问题非常隐蔽因为 Source_Capabilities 你仍然能收到日志里 PDO 数量也没问题只是内容对不上。最后通过逻辑分析仪和协议解码对比才发现要按小端方式把 4 个字节拼成 uint32_t。这里如果平台是 ARM Cortex-M直接 memcpy 加一个强制转换通常也是小端但我还是建议显式拼接避免跨平台踩坑。坑三VBUS 电压切换和 PS_RDY 的先后顺序。作为 Sink 端收到 PS_RDY 之后才应该放心使用新电压。我项目里曾经为了省事在发送 Request 之后直接按“预计新电压”切换了内部供电电路的反馈电阻结果 Source 那边还没来得及切换中间出现了几十纳秒的电压跌落把后级芯片搞出过异常。啃完协议文档才发现正确做法是等 PS_RDY 到位后再切负载。后来我改成由状态机控制供电切换收到 PS_RDY 统一延时几十毫秒再做动作问题就再没出现。硬件匹配上还有一个值得说的地方FUSB302 的 CC 引脚有内部比较器和测量通路但它需要外部加合适的电阻。Sink 模式下CC1/CC2 上必须接 5.1kΩ 下拉到地Source 模式下要接上拉电阻典型值根据目标电流档位选比如 10kΩ 对应 3A。很多人以为芯片能自动配置这些电阻实际上 FUSB302 内部只有测量和开关网络外部仍然需要放置符合 Type-C 规范的电阻。我最早画板子时漏了 Sink 下拉电阻结果芯片死活检测不到 Source后来看原理图才发现是电阻漏了。最后一个经验是有关中断引脚的FUSB302 的 INT_N 是开漏输出MCU 侧必须接上拉电阻。我板子上最初省了这颗上拉结果中断信号一直浮空丢消息丢到怀疑人生。这类小细节在原理图评审阶段注意一下能为后面调试省很多时间。PD 代码本身不复杂真正的复杂度往往藏在硬件匹配和时序细节里而这两样东西只有实际烧录、抓波形、看日志才能彻底掌握。本文还有配套的精品资源点击获取
