资讯动态

STM32裸机实现USB-C PD受电端:UCPD外设与状态机实战解析

发布时间:2026/8/30 23:19:20 来源:尧图企业网站定制
1. 这个项目在做什么为什么值得折腾先聊清楚一件事这个标题里最关键的两个词是“USB-C PD”和“bare-metal”合起来就是——在 STM32 上用 UCPD 外设做一个完整的 USB-C Power Delivery 受电端全程不用 RTOS不跑协议栈靠裸机状态机把 PD 协商跑通。我最初看到这个项目时第一反应是“又一个拿 Type-C 当充电口的玩具”但仔细看完设计思路之后我得说这活儿比表面看起来要有价值得多。过去做 STM32 的 USB-C PD 受电绝大多数人第一反应是去网上找一颗 PD 协议芯片比如一些专用 sink 芯片I2C 读一下就完事。这样做确实省事但有几个绕不开的痛协议芯片的电流档位和 PDO 策略往往是写死的想自定义很麻烦小批量打样时协议芯片的采购周期和成本都不友好更关键的是如果你做的是低功耗设备协议芯片本身常驻工作的功耗经常让人抓狂。用 STM32 的 UCPD 外设自己做 sink等于把整个 PD 协议交互的控制权完全握在自己手里。那为什么选 STM32 的 UCPD 而不是其他方案因为这颗外设本身就是为 USB-C 和 PD 设计的硬件加速器。它内部的物理层负责 BMC 编码解码、4b5b 编码、CRC 校验、SOP/SOP/SOP 包的过滤这些如果靠 GPIO 模拟去做光是时序就能把人逼疯。UCPD 把这些底层脏活做完之后留给固件的只是一串干净的数据包和几个状态寄存器MCU 要做的就是解析和回复。这意味着你可以用很低的 CPU 占用率和内存开销跑一个稳定可靠的 PD sink这正是 bare-metal 方案能成立的硬件基础。这个项目解决的核心问题用一句话概括就是让你在一颗最普通的 STM32 上用最少的代码量拿下一个可靠的 USB-C PD 受电端同时保留完全的自由度。它不需要官方的 USB PD 中间件也不需要 RTOS 的调度适合想深入理解 PD 协议、在做低功耗受电设备、或者只是单纯想省掉协议芯片的朋友。2. 为什么坚持不用 RTOS以及整体设计思路拆解2.1 裸机与 RTOS 的关键取舍我知道很多人看到“no RTOS”会觉得这是在堆噱头毕竟用 FreeRTOS 或 Zephyr 跑 PD 协议栈不是更常规吗但你要想明白一件事PD 协议交互的实时性要求其实非常高尤其是 GoodCRC 的回复是在收完一个包之后的几个微秒窗口内要做出响应的。这个时间窗口如果跑在 RTOS 的任务调度里哪怕加了中断优先级控制只要任务切换路径多绕两圈延迟就容易顶不住。更实际的问题出在资源占用上。一块入门级 STM32G0 的 Flash 也就 64KB 到 128KBPD 协议栈加 RTOS 内核加驱动加应用装下不是不行但会很挤。裸机方案里没有什么内核对象、任务栈、调度器状态所有变量都摆在你眼前Flash 占用可以压到非常低RAM 占用也基本是 KB 级别。对于做电池设备、可穿戴设备、传感器节点的人来说每一 KB 的 Flash 和 RAM 都可能决定能不能把功能塞进那颗便宜的芯片里。另外必须承认裸机调试是真的舒服。RTOS 下你需要开着调试器去追踪任务切换、信号量、队列而裸机就是一条直线中断进来状态机走一步然后出去。配合 ST-Link 和 SEGGER RTT Viewer 的日志输出整个 PD 协商过程像看心电图一样清楚问题往往一眼就能定位这对前期验证 PD 时序和兼容性帮助极大。不是说 RTOS 不好而是这个项目追求的是最小化、可控和可解释裸机是这个目标下的最优解。2.2 UCPD 外设带来的天然优势UCPD 这颗外设是 ST 在 G0/G4/L5 等系列上放出来的“PD 硬件加速器”。很多人第一次看到它的时候会被一大堆寄存器吓到但拆开看其实核心就三块接收路径、发送路径、状态检测。接收路径上UCPD 物理层已经完成了模拟比较、BMC 解码、时钟恢复、4b5b 解码、CRC 校验和 SOP 识别。固件只需要在接收 FIFO 有数据E_RXBOF时把数据读出来再做协议层的解析。发送路径上固件只需要把待发送的 payload 按格式填进 TX 寄存器并触发发送物理层会自动完成 CRC 追加、4b5b 编码和 BMC 调制。状态检测则是通过中断标志反映 CC 引脚上的状态变化包括 CC 检测、连接的设备类型等。所以 UCPD 的价值在于把最脏、最硬实时、最容易出错的物理层活全干完了留给固件的是一层相对干净的寄存器接口。这也是这个项目能做成裸机的前提条件。如果你手上只是普通 STM32 没有 UCPD那要么上模拟比较器配定时器硬搭要么直接加协议芯片完全不是一个玩法了。2.3 整体状态机的设计思路整个 PD sink 的核心是一个状态机大致是这样的流程等待 CC 检测到连接然后发送 Get_Source_Capabilities 获取供电端的 PDO 列表解析 PDO 之后根据自己的需求选择一档电压电流发送 Request 请求进入协商收到 PS_RDY 之后进入“合约建立”状态开始正常从 VBUS 取电。从工程实现的角度看这个状态机和串口接收状态机没有本质区别关键是要把“每个状态能收到什么包、收到之后做什么、超时怎么办”定义清楚。比如在等待 Source_Capabilities 的状态下如果迟迟收不到包就需要做硬复位或者超时重启。再比如在发送 Request 之后正常流程是收到 Accept 然后等 PS_RDY但也有可能对方直接发 Reject 或者发新的 Source_Capabilities这些都要在状态机里覆盖到。用裸机做这个的好处是状态机里面每一步都是显式切换不会有别的任务在中间插一脚出问题时你完全知道它是在哪个状态哪个动作上卡住的。这种确定性对做协议栈初版验证来说太重要了。3. USB-C PD 协议要点与 UCPD 核心细节解析3.1 CC 检测和初次连接是怎么工作的USB-C PD 协商的第一步不是通信而是物理连接检测。CC1 和 CC2 引脚上的电阻连接方式决定了设备是 DFP供电端、UFP受电端还是 DRP双角色端口。Sink 设备需要在 CC1 和 CC2 上分别接下拉电阻通常是 5.1kΩ供电端检测到下拉就认为有受电设备接入。STM32 UCPD 外设内部集成了相关的检测功能还可以配置通过 ADC 采样 CC 引脚电压来判断对的连接类型和电压档位能力。实操中这里有个容易踩的坑UCPD 的 CC 检测使能之后不能死等硬件自动把所有事情做完因为 PD 规范里明确规定了 tAttachDet 和 tPDDebounce 这样的时间参数。Bare-metal 方案里这些时间都是靠定时器或者 SysTick 倒计时实现的不做就会在兼容性测试时被各种奇怪的充电器打回原形。我自己的做法是在状态机里专门开一个 time tick 计数器每次进入状态时记录当前 tick状态机里统一做超时判断这样可以保持各状态的时间逻辑一致。另外注意一点CC 引脚上的电平范围是 0V 到 1.2V 左右内部比较器参考电压要配置好。不同 STM32 型号的 UCPD 在参考电压选择上略有差异一定要查参考手册里的电气特性表别按别的型号直接抄配置。3.2 数据包格式与 SOP 包类型PD 的数据包在 UCPD 外设里收发时都是下面这个结构SOP起始包格式标识这包是发给谁的然后依次是头、数据负载、CRC。物理层加上的头字节和 CRC 已经被 UCPD 硬件处理掉了固件看到的基本就是消息头和 payload。SOP 总共有五种类型SOP、SOP、SOP、SOP Debug、SOP Debug。Sink 通常只和 SOP 打交道SOP 和 SOP 是给线缆 E-Marker 和供电端内部通信用的。做 sink 时要注意如果你插了一根带 E-Marker 的线缆UCPD 物理层会把 SOP 的包过滤掉或者单独报中断固件应该忽略而不是把它当成 SOP 包去解析不然状态机就会乱套。消息头里各位的含义要牢记尤其是 message type、number of data objects、port data role 这几个字段。调试的时候我习惯写一个非常简单的 dump 函数把收到的每个包的消息头、payload 和方向打出来配合 RTT Viewer 看整个协商过程一目了然。3.3 关键寄存器与起手式配置UCPD 外设常用的寄存器并不多但每个都很关键。UCPD_CFGR 用来配置端口角色Sink、CC 选通用自己的 CCA/CCB 模式或者通过 UCPD_CR 的 TXSEND 选哪个引脚接收使能、发送使能还有 SOP 过滤配置。UCPD_CR 控制软复位、发送触发、TX 类型设置等。比较重要的是 UCPD_TX_PAYLOAD_SIZE、UCPD_RX_PAYLOAD_SIZE 以及收发数据寄存器组的顺序——数据是按 4 字节为一行写入 UCPD_TXDR 的这跟常规的外设发送寄存器不太一样。起手配置的步骤我整理一下基本流程打开 UCPD 和 GPIO 时钟配置 CC1/CC2 为复用功能配好 UCPD 的电源、参考电压、BMC 参数一般手册会给参考值然后配置 UCPD_CFGR 为 sink 角色并开启接收中断再使能 UCPD_CR 的 RXEN 和 TXEN。最后打开 UCPD 的“CC 检测”使能等 CC 连接事件中断到来。这里有个细节——必须先配好所有东西再使能 CC 检测否则硬件会在初始化过程中误触发一连串错误中断白白增加调试难度。3.4 实际要处理的几个协议细节从实践经验来看有几个点非常容易被忽略。第一个是首次通信时 Sink 要先发送 Get_Source_Capabilities。虽然规范中说供电端可选自动发送 Source_Capabilities但为了稳妥硬复位之后的协商还是手动发一次比较好。第二个是收到 Source_Capabilities 之后payload 里可能有多个 PDOPower Data Object要按顺序解析。比如一个 5V/3A、9V/3A、12V/3A 的供电端Payload 里会有三组 PDO每组 4 字节包含电压、电流等信息。第三个是发送 Request 时要把 object position 字段填正确。你想请求第 2 个 PDO就要把 pos 写为 2这算是最容易犯的低级错误了。第四个是如果请求的是 PPS可编程电源档位Request Data Object 里还要带上输出电压和电流的具体目标值这些字段的格式和普通 Fixed PDO 不一样如果没搞清楚就容易协商失败。协议细节这块我的建议是手里常备一份 USB PD 3.0 规范文档不需要全读但相关章节要反复看尤其是 6.4 和 8.3 这些策略相关的内容。别信网上那些残缺不全的翻译版看英文原版细节定义严谨得多。4. 实操过程与核心实现4.1 工程结构怎么搭这个项目既然定位是 bare-metal工程结构就该尽量精简。我测试时用的是 STM32G0B1 的开发板工具链用的是 arm-none-eabi-gcc编辑器用 VSCode编译和烧录走 CMake 加 ST-Link 的 OpenOCD。这么做是因为标准库串口工程、Keil 工程等都容易把人带偏方向而 VSCode 加 arm-none-eabi-gcc 对新手和资深开发者来说都足够透明。目录结构大体是project/ ├── CMakeLists.txt ├── core/ │ ├── main.c │ ├── ucPD.c │ ├── ucPD.h │ ├── pd_sink_sm.c │ ├── pd_sink_sm.h │ ├── cc_hal.c │ └── cc_hal.h ├── startup/ │ └── startup_stm32g0b1.s └── link/ └── stm32g0b1.ld启动文件直接用 ST 官方 CMSIS 里的 startup 文件链接脚本也直接从官方例程移植工程里不引入 HAL 库只依赖 CMSIS 头文件。这样整个代码路径最短出问题能追到根。4.2 初始化代码的关键片段这里的初始化是大致思路具体寄存器配置要以你手上的芯片参考手册为准。UCPD 的初始化大体分两块先是 GPIO 和复位时钟void ucPD_init_gpio(void) { RCC-IOPENR | RCC_IOPENR_GPIOAEN; GPIOA-MODER ~(GPIO_MODER_MODE11 | GPIO_MODER_MODE12); GPIOA-MODER | (GPIO_MODER_MODE11_1 | GPIO_MODER_MODE12_1); GPIOA-AFR[1] | (3 GPIO_AFRL_AFSEL11_Pos) | (3 GPIO_AFRL_AFSEL12_Pos); }注意 CC1/CC2 的复用功能号要查数据手册不同系列可能是 AF1、AF3 或其他编号直接照搬会翻车。然后是 UCPD 外设自身的时钟和模式配置包括开启 UCPD 时钟和设置端口的角色RCC-APBENR1 | RCC_APBENR1_UCPD1EN; UCPD1-CFGR | UCPD_CFGR_ROLE_SNK;之后配置接收、发送和 CC 相关的参数。完整配置要参照官方例程不同型号差异比较大。我在调试时一般是分步走先把 GPIO 对保证 CC 引脚上有正确的电压再配置好 UCPD 的收发通道用逻辑分析仪或者示波器看 BMC 波形最后再上协议交互。别上来就全配置完那样出了问题你根本不知道是硬件配置问题还是协议逻辑问题。4.3 发送和接收数据的封装UCPD 的收发控制是核心中的核心。发送侧要注意的是发送前要确保 UCPD_TX_PAYLOAD_SIZE 和数据寄存器填写的字节数一致。先写 TXSEND 之前的所有 payload然后触发 TXSEND完成之后再等待发送完成中断或状态位。发送类型要区分是 SOP 还是 SOP这个通过 CR 寄存器里的 TXSOP 位设置。接收侧的流程更简单些收到一个合法包时UCPD_SR 里的 RXNE 会置位然后触发中断。FIFO 里数据的读取是 4 字节一行地读数据放进一个局部缓冲区之后再做协议解析。接收侧要注意关闭不关心的 SOP 和 SOP否则调试的日子里你的中断会被线缆上的 E-Marker 包骚扰到怀疑人生。下面是一个简化的包处理示例这段代码是我做协议解析时一直沿用的核心结构static void ucPD_handle_rx(uint32_t status) { uint8_t buf[32]; uint32_t len 0; uint32_t header; int i; len (UCPD1-RX_PAYLOAD_SIZE UCPD_RX_PAYLOAD_SIZE_RXPSIZE_MASK); for (i 0; i len; i 4) { uint32_t word UCPD1-RXDR; memcpy(buf[i], word, 4); } header (buf[0] | (buf[1] 8)); if ((header 0x1F) PD_MSG_GOODCRC) return; parse_message(header, buf 2, len - 2); }GoodCRC 包不需要应用层回业务逻辑它本身就是物理层控制的一部分可以静默忽略但一定要在计数统计里记录一下方便判断丢包率。4.4 状态机的关键实现状态机是本项目的主干我用一个 switch 语句和一个事件队列实现。事件来源主要是UCPD 接收中断解析出来的消息、定时器超时、CC 检测状态变化。状态机的实现没有太多的奇技淫巧但要注意每个状态的“出口”要尽量收敛。比如说等待 Source_Capabilities 的状态出口只有三个拿到正确的 Source_Capabilities、超时、遇到硬复位。所有可能的异常路径都要在出口处找到归属不要出现“不知道去哪”的状态。状态机跑起来之后最直观的验证方式是接入一个带 USB 电流电压表的充电器。正确协商之后电压会从 5V 跳到请求的档位电流电压表上会看到电压跳变。那一刻的成就感还是很强的。5. 常见问题排查与避坑经验5.1 电压协商失败或根本没反应这个是最常见的现象。排查步骤我建议按从物理层到协议层的顺序来先示波器看 CC 引脚上有没有 BMC 波形如果连波形都没有检查 CC 下拉电阻焊没焊、UCPD 配置是不是把 CC 检测使能了如果有波形但是没有回应就要看接收中断有没有触发收到的包内容是什么。很多时候问题出在 UCPD_CFGR 里的接收使能和 SOP 过滤配置不对导致包收到了但是被硬件过滤掉了中断根本没上来。5.2 GoodCRC 一直不回复另一个常见问题是发送 Get_Source_Capabilities 之后供电端一直不回应。这时候要用逻辑分析仪抓 CC 引脚上的 BMC 信号数一下发送的包是否完整、CRC 是否正常。如果发送的包结尾缺了一段多半是因为发送中断处理和触发逻辑顺序写错了或者发送之后没有等发送完成就立刻关掉了发送通道。5.3 状态机卡死超时状态机卡死一般有几个原因超时定时器没实现好导致超时事件永远不触发或者某个状态收到了意外类型的包直接 drop 但没做统计和复位。我在调试时会在每个 case 分支里加上调试日志和状态统计确认它停在哪一步。用 RTT 做日志非常方便插入的 print 直接走 SWD不用占用串口引脚。5.4 踩坑速查表现象可能原因解决思路CC 无波形下拉电阻缺失/GPIO 复用配置错确认 5.1k 下拉查手册复用功能号收到包不认识SOP/SOP 干扰或消息类型解析错UCPD 关闭 SOP 接收补全消息类型解析协商后电压不跳Request 里 PDO 位置错误或 RDO 字段填错核对 object position 和 RDO 字段偶发失败重插就好硬复位/超时逻辑不干净完善超时重启和异常复位路径低功耗模式唤醒异常唤醒源没包含 UCPD 中断检查 EXTI 和低功耗配置这些坑基本是每一轮测试下来都会遇到的有些问题最初会被怀疑成硬件故障但实际上都是配置细节。所以做协议调试的时候一定要准备好一套能快速打日志、快速复现的手段。6. 为什么这个项目需要你测试者要做什么6.1 当前项目的状态和测试目标标题里写“looking for testers”这是个很坦诚的信号。说明截至目前的代码只在有限的开发板上验证过还没有在各种真实充电器、充电宝、扩展坞、车载充电器上做足够的兼容性测试。USB-C PD 这个领域最麻烦的就是兼容性同一套逻辑在不同供电端上表现可能完全不一样——有的充电器对你发来的 Request 响应迅速有的会先发一个新的 Source_Capabilities 来调整档位有的对超时时间特别敏感有的会直接进入硬复位。所以这个阶段测试的核心目标是摸清在不同供电端下这套裸机 sink 的协商成功率、超时处理和异常恢复能力。具体来说测试者需要准备一个带 Type-C 口的开发板推荐 STM32G0 系列或者任意带 UCPD 外设的型号烧录当前固件然后插不同的充电器、充电宝、笔记本的 PD 口观察是否成功协商到目标电压。最好能记录日志把每次协商的流程、状态转换、耗时和结果都记录下来。6.2 你需要准备什么硬件方面一块带 UCPD 外设的 STM32 开发板是刚需的。如果你已经在做 USB-C 相关的项目大概率手上有。如果没有可以找带 Type-C 接口的 STM32G0B1 或者 STM32G491 开发板这类型号的学习成本和功耗都很合适。调试工具方面如果你有逻辑分析仪或者示波器测 BMC 信号会轻松很多。不过没有也没关系依靠 UCPD 内部状态寄存器的日志很多问题也能定位。固件方面目前代码基于标准外设库 / CMSIS 编写不依赖 HAL 库所以编译环境不需要花太多时间搭建。用 CMake 或 Makefile 都可以烧录用 ST-Link 配合 STM32CubeProgrammer 或者 OpenOCD 就行。日志输出优先用 RTT Viewer因为它不占额外串口资源时序上也没有干扰。6.3 反馈时重点记录什么如果你参与测试反馈信息越详细越好。我建议至少包含使用的 STM32 具体型号、充电器/供电端品牌型号和协议档位、协商结果成功/失败/超时/硬复位、RTT 或串口日志的完整抓取。如果能记录 5V 时的初始协商状态和电压切换之后的 VBUS 行为对定位问题非常有帮助。我预期的主要风险点有两个一是某些充电器在收到 Request 后会发送拒绝包或切换到 PPS需要验证状态机的应对策略二是不同供电端的发送时序差异可能会触发我们尚未处理好的边界条件。这些问题只有在大量真实设备上跑过才能暴露出来这也是项目需要大家的原因。任何测试结果哪怕是失败记录对完善这个裸机 PD sink 都很有价值可以提 issue 讨论后续的修复方案。7. 用这套方案还能继续做些什么项目做到现在摆在眼前的可扩展方向其实不少。一个很自然的方向是支持 PPS 可编程电源让端子电压可以随时动态调整而不是只能选择一个固定 PDO这对电池快充和精密供电场景很有意义。另一个方向是加入数据角色交换、双角色端口支持让设备既能当 sink 也能当 source这在小型化桌面设备和便携工具里很实用。还有一个我特别想提的方向是低功耗。目前裸机实现已经把资源占用压得很低如果再把 UCPD 的状态检测和系统的休眠唤醒机制结合起来就可以做出一颗真正靠 USB-C 供电、平时零功耗、插上线缆自动唤醒的设备。这也是 UCPD 外设最具吸引力的使用场景之一。这个项目还在继续演进的路上后续的测试计划和代码更新都是在一步步验证和打磨。如果你也对这个方向感兴趣手上刚好有带 UCPD 外设的板子欢迎一起测试把更多真实场景下的兼容性数据汇集起来让这套 bare-metal 的 PD sink 方案变得更稳更成熟。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价