简介:DSView平台专用快充协议解码插件,面向DSView/DSLogic逻辑分析仪用户,覆盖华为FCP、三星SCP、OPPO/AFC等基于相同物理层与握手机制的私有快充协议,可实时解析USB PD协商之外的厂商定制快充通信数据流,适用于快充芯片验证、充电器兼容性测试及Type-C接口协议逆向分析等场景。压缩包共6个文件,以Python编写的pd.py解码器与__init__.py初始化模块为核心,另含预编译字节码、配置说明等,整包仅6KB,适配Python 3.6运行环境,轻量易集成。解码器通过DSView标准插件接口加载,支持波形触发、字段高亮、时序标注与CSV导出,通过直观的高亮与时序视图,可快速识别协议请求、应答与状态字段,显著提升排障效率。目前已有77人浏览学习,适合具备Python基础和逻辑分析仪使用经验的嵌入式开发、电源工程师及协议分析爱好者。 搞硬件调试这些年,我手里最常用的工具除了万用表就是逻辑分析仪。前阵子朋友拿了一台快充充电器过来,说手机偶尔能触发快充、偶尔又掉回普通5V充电,让我帮忙看看是不是协议握手有问题。我接上DSView抓了一段USB D+/D-上的波形,电平翻转倒是拍得清清楚楚,可盯着那些方波看了半天,完全看不出充电器回了什么、手机又请求了什么。市面上现成的解码器基本都是UART、I2C、SPI这一类通用协议,针对FCP/SCP/AFC这些快充协议的现成解码工具少得可怜。没办法,我只能自己动手,基于DSView平台写了一套快充协议解码工具,专门把D+/D-上的协商报文翻译成人能看懂的内容。
这篇文章会把整套工具的完整思路、协议分析过程、核心代码实现和实测中踩过的坑都记录下来。内容主要面向硬件工程师、嵌入式开发者和对快充协议感兴趣的DIY玩家,已经熟悉DSView基本操作的人可以直接跳到第3节看解码器实现。
1. 项目背景:抓得到波形,看不懂内容
1.1 为什么大家需要一套快充协议解码工具
快充协商的本质,是手机和充电器通过USB的D+/D-两根线做带外通信。普通充电线里的D+/D-在纯充电场景下不传输数据,正好被快充协议拿来当天线用:充电器插入的瞬间,设备端会通过D+/D-上的电平变化或数据帧,告诉充电器“我需要更高的电压”或者“我可以承受更大的电流”。
问题在于,逻辑分析仪抓下来的原始波形只是一串0和1的电平序列。你用肉眼能看出这里有个脉宽、那里有个毛刺,但看不出这串波形对应的语义。比如华为FCP/SCP这类走UART风格通信的协议,波形的字节序、校验方式、包结构都是半公开的,没有对应解码器时,分析一次握手过程要手动把波形一位位抠出来再拼成字节,效率极低。三星AFC则是另一套思路,它不靠数据帧通信,主要靠D+/D-上的电平组合来触发电压切换,分析这种波形需要的是状态识别,不是字节解析。
所以我做这类解码工具的目标很明确:把DSView采集到的D+/D-原始信号,实时转换成协议报文列表,在波形窗口上直接标注出“请求9V”“回执成功”“电压切换”这类事件,让分析快充协商过程从“看波形猜含义”变成“直接读报文”。配合DSView自带的波形缩放和搜索功能,排查握手失败会快非常多。
1.2 FCP/SCP/AFC三种协议的核心特征
做解码器之前,得先把三个协议的特点理清楚。华为FCP(Fast Charge Protocol)是比较早的快充方案,它通过在D+/D-上建立一套类似UART的通信链路,协商输出电压等级。SCP(Super Charge Protocol)是在FCP基础上演进出的超级快充协议,通信方式类似,但协商内容从电压档位扩展到了更精细的电流控制。这两个协议在D+/D-上的信号形式高度相似,都是一帧一帧的二进制数据,所以解码器可以把它们的帧解析合并成同一套逻辑,只在包结构解析时做区分。
三星AFC(Adaptive Fast Charging)跟上面两个协议完全不同。它不是通过连续的数据帧来通信,而是靠D+/D-上的电平组合来配置充电器输出。充电器检测到特定的电平状态后,直接把输出从5V切到9V或者切回来。这更像是“硬件状态机”而不是“通信协议”。给AFC写解码器,核心不是解析字节流,而是识别电平状态跳变,并把跳变标注成“进入AFC请求”“退出AFC回落到5V”这类事件。我最初也试图按UART的思路去解AFC,结果波形完全对不上,后来调整了思路才对。
还有一个共同点值得注意:这三种协议的协商过程都发生在充电器插入后的几百毫秒内,一次握手可能只有几十到几百个字节。而且D+/D-上的信号幅度本身比较小,逻辑分析仪采样到的波形经常混着噪声。这对解码器的触发设计和噪声容错提出了要求,后面第4节我会专门讲怎么处理。
2. 解码器整体设计:从波形到可读报文的四层转换
2.1 工具架构:为什么选DSView加Python扩展
解码工具不是独立软件,而是做成DSView的协议解码器插件。DSView本身是DSLogic逻辑分析仪的配套软件,支持最多16通道采样,底层有一套协议解码框架,允许用户用Python编写自定义解码器。解码器被放到指定目录后,DSView启动时会自动加载,在通道设置界面里就能像选UART一样选到自定义协议。
我选择这个方案,而不是写一个独立的桌面程序,有几个现实原因。首先是省时间,DSView已经处理了采样、波形绘制、缩放、导出一整套繁琐工作,我只需要关注“怎么把电平变成协议报文”这一件事。其次是跨平台,DSView在Windows、Linux、macOS上都能跑,解码器只要写一遍,到哪都能用。第三是数据分析环境好,解码结果能直接叠加在波形上,拖动时间轴就能看某一帧对应的原始电平,排查问题非常直观。
整个解码工具的数据流大致是四层:第一层是原始采样,DSView把D+/D-的电平按采样率转换成逻辑0/1序列;第二层是位同步,解码器在电平序列中识别起始位、数据位和停止位,把物理电平还原成字节;第三层是报文组装,解码器根据协议格式把连续字节拼成完整的报文帧;第四层是语义解析,根据协议状态机把报文翻译成“请求电压”“协商成功”这类事件,以注释形式标注在DSView波形上。
2.2 FCP/SCP的UART式信号解码思路
FCP/SCP在D+/D-上的通信,波形特征和UART非常接近:一个起始位,接着8个左右数据位,带停止位,低电平有效。但有一点和标准UART不一样:标准UART是单根线收发,而快充协议的通信链路里,D+/D-两根线都参与了电平配置,设备端和充电器端的信号是交替出现的。所以解码器不能只盯着一个通道,要把两个通道都纳入分析范围。
我的具体做法是,把D+和D-分别作为两个逻辑通道采进来。在解码时,先按“哪一个通道出现下降沿”来判定当前是哪个方向的数据传输,然后用该通道的后续采样点去恢复字节序列。这里有个取舍:完全按UART的波特率去采样,实现简单,但如果充电头用的通信速率和预设不一致就会解码失败。为了兼容性,我的解码器支持两种模式:手动指定波特率,以及自动估算波特率。自动估算的原理是测量起始位低电平的时长,再根据该时长反推每一位的时间宽度。实测下来,自动估算在绝大多数场景下都能得到准确结果。
字节重组完成之后,就是报文帧解析。FCP/SCP这类私有协议不会把帧格式完整公开,但它们的报文基本都遵循“包头+长度+数据+校验”的结构。解码器先把连续字节缓冲起来,通过包头特征判断属于FCP还是SCP,再按对应协议的长度字段截取完整报文,最后做一次校验计算。即使校验算法不完全匹配,解码器也会把原始字节保留下来,这样拿到错误校验结果时,也能看到完整的通信内容。
2.3 AFC的电平状态解码思路
AFC的解码思路跟FCP/SCP完全不一样,刚开始我很不适应。AFC不产生连续的数据帧,它的通信过程更像“充电器检测到D+/D-处于某个电平组合,然后切换输出电压”。比如设备想请求9V输出,就会在D+/D-上呈现一种特定的电平组合,充电器检测到之后拉高输出电压;设备想退出快充,又会切换另一种组合,充电器看到后回落到5V。
对这种协议,解码器的任务不再是“解析字节”,而是“跟踪电平状态”。我把两个通道的电平组合看成一个状态机的输入,当D+/D-从空闲状态跳变到高电平组合时,输出一条“AFC请求高电压”的事件;当电平组合回落到普通状态时,输出“退出AFC”的事件。因为AFC协商发生得很快,电平维持时间可能只有几十毫秒,解码器必须一直监控两个通道的电平变化,不能依赖起始位触发。实现上我用了一个简单的状态变量,每次通道电平变化都重新评估当前状态,并在状态发生切换时输出标注。这样处理下来,AFC的协商过程在DSView里看起来就是一串清晰的事件时间线。
3. 核心实操:从零写一个DSView协议解码器
3.1 解码器目录结构与基本框架
DSView的协议解码器本质上是一个Python包,放在解码器目录下的独立文件夹里。目录结构大概是这样:
decoders/ └── fcp_scp_afc/ ├── __init__.py └── ...__init__.py里定义一个继承自解码框架基类的Decoder类,并通过类属性声明协议信息、通道和注解类型。以我实际使用的写法为例,代码骨架大致如下:
import sigrokdecode as srd class Decoder(srd.Decoder): api_version = 3 id = 'fcp_scp_afc' name = 'FCP/SCP/AFC' longname = 'Fast Charge Protocol Decoder' desc = 'Decode FCP/SCP/AFC fast charge signals on USB D+/D-' license = 'gplv2+' inputs = ['logic'] outputs = ['fcp_scp_afc'] tags = ['USB', 'Charger', 'Fast charge'] channels = ( {'id': 'dp', 'name': 'D+', 'desc': 'USB D+ line'}, {'id': 'dm', 'name': 'D-', 'desc': 'USB D- line'}, ) annotations = ( ('bit', 'Bit'), ('byte', 'Byte'), ('packet', 'Packet'), ('warning', 'Warning'), ) annotation_rows = ( ('bits', 'Bits', (0,)), ('bytes', 'Bytes', (1,)), ('packets', 'Packets', (2,)), ('warnings', 'Warnings', (3,)), )这里有几个关键点。channels里声明了两个通道,分别对应D+和D-,采样时DSView会提示你把这些通道绑到实际的逻辑分析仪通道上。annotations声明了解码器能在波形上标注的不同内容类型,bit是最原始的电平标注,byte是重组后的字节,packet是解析完成的报文,warning用来标出校验错误等异常。这样的分层设计,让我在调试时可以先看byte对不对,再看packet对不对,定位问题非常方便。
不同版本的DSView对解码器API的支持会有细微差别。如果你的DSView版本较老,APIv3的某些字段可能不支持,最稳妥的办法是参考DSView自带解码器的写法,复制一个现有解码器的骨架再改。我第一次写的时候直接照搬了UART解码器的模式,省了不少事。
3.2 位同步与字节重组:核心代码实现
解码器的核心逻辑在decode()方法里。DSView会持续调用这个方法,并把采集到的逻辑采样数据送进来。整个解码过程是一个循环,循环体里等待通道状态变化,然后根据变化做处理。简化后的位同步和字节重组逻辑大概是这样的:
def decode(self): self.state = 'IDLE' while True: # 等待D+或D-出现电平变化 (dp, dm) = self.wait({0: 'e', 1: 'e'}) if self.state == 'IDLE': # 检测到下降沿,认为是起始位,进入字节收集 if dp == 0 or dm == 0: self.state = 'RECV' self.bit_pos = 0 self.current_byte = 0 # 进入起始位后的位采样循环 continue elif self.state == 'RECV': # 按位时钟采样,每周期采样一次中间位置 self.bit_pos += 1 if 1 <= self.bit_pos <= 8: # 数据位,低位在前 self.current_byte >>= 1 if dp == 1 or dm == 1: self.current_byte |= 0x80 elif self.bit_pos == 9: # 停止位,整理一个完整的字节 self.put(self.samplenum, self.samplenum, self.out_ann, [1, '0x%02X' % self.current_byte]) self.bytebuf.append(self.current_byte) self.state = 'IDLE'这段代码的核心是状态切换和位采样。检测到下降沿之后,进入RECV状态,每来一个采样周期就把当前位写入字节缓冲区。因为D+/D-上的信号是双向交替的,我用dp和dm两个通道的取值共同决定当前位是1还是0:实际使用中,不同协议可能让D+和D-分别承担不同含义,所以这一块逻辑要根据实测波形微调。我最初写死只用D+通道判断数据位,结果SCP协议解析错误率很高,后来改成双通道联合判断才解决。
这里必须强调一个采样周期和波特率的对应关系。DSView送到解码器里的采样点带时间戳(samplenum),要准确地在一个bit中间位置采样,必须知道当前波特率。手动设置波特率时,用采样率除以波特率就能得到每个bit的采样点数,然后在起始位之后偏移半个bit开始采样。自动估算波特率时,起始位低电平的时长除以预估的bit宽度就是波特率。我建议调试阶段先用手动方式,把波特率固定下来,等报文能正确解析了再开自动估算,不然一个问题里混着两个变量,排查起来很痛苦。
3.3 报文解析状态机:组装出有意义的报文
字节重组完成之后,原始字节都攒在bytebuf缓冲区里,接下来要做的是报文解析。我用了一个简单的状态机:
def parse_packet(self): # 简化逻辑,示意报文解析流程 while len(self.bytebuf) >= 4: # 查找包头 if self.bytebuf[0] == FCP_HEADER: pkt_len = self.bytebuf[1] if len(self.bytebuf) >= pkt_len + 2: pkt = self.bytebuf[:pkt_len + 2] # 校验 if self.check_crc(pkt): self.put(self.samplenum, self.samplenum, self.out_ann, [2, 'FCP packet: %s' % pkt.hex()]) else: self.put(self.samplenum, self.samplenum, self.out_ann, [3, 'CRC ERROR']) del self.bytebuf[:pkt_len + 2] continue # 没找到包头,丢弃一个字节 self.bytebuf.pop(0)状态机的关键设计是容错。因为D+/D-上的信号在开始阶段可能不稳定,字节缓冲区里会有一些噪声字节,所以解析器不能假设第一个字节就是包头,而是不断扫描缓冲区,找到符合包头特征的字节才开始拼帧。为了应对校验算法不匹配的情况,我把原始报文用十六进制同时输出出来,即使解析器不认识内容,使用者也能看到完整的数据,这个设计在实际排查中帮了我大忙。
FCP和SCP的包头特征不同,解析器会根据第一个字节的值判断该走哪套解析逻辑。AFC不走这个状态机,它单独走状态跟踪逻辑,也就是上一节说的电平状态机。因为两种协议的解析逻辑差异很大,我把它们写成了两个独立的处理分支,在decode()主循环里根据当前解析模式调用。这里有个经验:不要把FCP/SCP的字节解析逻辑和AFC的状态跟踪逻辑混在一个函数里,后面维护会非常痛苦。
3.4 在DSView中加载解码器并验证结果
解码器写完之后,把它放到DSView的解码器目录下,重启软件就能在协议选择列表里看到“FCP/SCP/AFC”。使用时先把逻辑分析仪的CH0接到USB线的D+测试点,CH1接到D-测试点,GND接GND,然后打开DSView开始采样。建议采样率设在2M Sa/s或更高,预算充足的话直接上5M Sa/s,这样每个bit至少能采到几十个点,解码容错率会高很多。
接线是最容易翻车的地方。D+/D-在USB线内部,必须把线皮剥开找到对应的测试点,或者用USB调试板引出。我一开始拿杜邦线直接怼在USB座上乱戳,波形全是毛刺,根本没法解。后来老老实实焊了一个测试点,再并上逻辑分析仪的探头,波形才干净起来。采样完成后,在DSView里添加解码器,分配通道,调整波特率,波形窗口就会立刻显示解析出来的字节和报文。我第一次跑通时看到“FCP packet”整整齐齐列出来,那种感觉确实很爽——至少在那一刻,之前手工扒位的日子算是过去了。
验证解码器是否正确的标准,不能只看“有没有输出”,要看“输出对不对”。我建议用一台协议已知的充电器和一台手机做基准测试:手机正常触发快充时,解码器应该能看到完整的电压请求报文和握手成功事件。如果只有零散的字节没有完整报文,或者报文全是CRC错误,那就要回到位同步和波特率配置上排查。
4. 实测中的问题与排查记录
4.1 采样率不足导致乱码
刚写好的时候,我拿一个FCP充电器做实测,解码器输出的字节乱七八糟,报文完全拼不出来。我第一反应是协议格式写错了,排查了很久才发现是采样率设得太低。当时用了500k Sa/s的采样率去抓波形,一个bit只有四五个采样点,起始位位置判断稍微偏一点,后面的数据位就全部错位。
后来我算了一笔账:FCP/SCP这类通信的波特率通常在几十到几百kbps量级,就算按115200bps算,一个bit的时长大约是8.68微秒。采样率至少要有10倍于信号频率,也就是大概2M Sa/s以上,才能保证每个bit有足够的采样点来定位起始位和数据位中间位置。把采样率提到5M Sa/s之后,乱码问题立刻消失。这个经验也让我养成了一个习惯:解码任何未知协议前,先用高采样率抓一遍波形,再根据实际波形宽度调整采样率,而不是一开始就为了省存储空间压低采样率。
4.2 噪声导致误触发和额外字节
D+/D-上的信号幅度本来就不大,如果接地处理不好,波形上会有明显毛刺。这些毛刺在解码器看来就是额外的高低电平变化,可能触发误判的起始位,也可能在字节中间多出一个“假数据位”。我遇到的典型问题是:报文主体解析是对的,但每个报文前面都多出一个0x00字节,导致包头识别失败。
排查下来,问题出在逻辑分析仪的地线太长,形成了天线效应。把地线换成短弹簧地线之后,波形毛刺明显减少。代码层面我也加了一道保险:检测到起始位后,如果发现起始位持续的时间比预设波特率对应的bit宽度短太多,就判定为毛刺并丢弃,不进入字节收集状态。这个去抖逻辑简单有效,尤其是采样率较高的时候,可以让解码器忽略那些只有一两个采样点的窄脉冲。
4.3 私有协议变种的兼容性问题
快充协议虽然没有对外完全公开,但不同品牌、不同批次的充电头在实现上会有细微差别。我遇到过同一个品牌的两款充电器,一个能正常解析,另一个报文头总是差一个字节,后来对比原始波形才发现是字节序不同。这种问题在解码器里很难完全规避,我采取的做法是:解码器保留原始字节流输出,当报文解析失败时,把原始字节以十六进制列表展示出来,方便使用者手动确认;同时在解码器设置里提供“字节序”和“校验方式”两个可配置项,遇到异常协议变种时可以手动切换。
靠这个思路,后续再遇到兼容性问题,基本不需要改代码,调整配置就能适配。这也算是个设计上的取舍:与其猜一个万能的协议格式,不如把不确定的部分暴露给使用者自己判断。解码器不是万能的,能提供清晰的原始数据,就已经解决了大部分实际问题。
5. 写在最后的实操体会
这套解码工具做下来,我最大的体会是:写协议解码器,七分靠波形分析,三分靠写代码。真正花时间的地方不是Python语法的实现,而是对着逻辑分析仪的波形一段一段确认起始位位置、bit宽度、字节顺序和报文边界。如果你也想给自己的项目写类似的自定义协议解码器,我建议先从最简单的手动波特率配置开始,跑通一条完整链路之后,再考虑自动波特率、容错这些进阶功能。还有一个小技巧:DSView支持把解码结果导出成带时间戳的CSV,配合脚本做批量数据分析效率很高,分析一次完整充电过程的协商日志会非常方便。
本文还有配套的精品资源,点击获取