☰
CCG系列HPI协议实战:I2C寄存器、PD事件与固件升级避坑
2026/10/28 20:18:50 网站建设 项目流程

简介:《CCG Host Processor Interface 001-97863-0P-V Apr 2021》是一份面向硬件与固件工程师、嵌入式控制器开发者的技术文档,聚焦赛普拉斯(现英飞凌)CCG系列USB Type-C与PD控制器的主机处理器接口设计,可用于厘清HPI传输、协议、寄存器访问及PD消息处理等实现问题。压缩包共1个PDF文件,约3.12MB,篇幅完整,便于按章节查阅接口规范与寄存器说明。文档区分HPIv1(CCG1/CCG2)与HPIv2(CCG3/CCG4/CCG5/CCG6等)的I2C读写实现,并覆盖INTR GPIO、从机地址配置、传统启动与双固件模式,以及EC与Billboard设备共享I2C访问等细节。已有200人学习下载,适合需要对照芯片家族差异、梳理Type-C PD端口控制通信流程的读者作为案头参考。

1. HPI 不是“驱动”,而是 EC 与 CCG 之间的一份通信契约

很多人第一次拿到 CCG 系列 USB Type-C/PD 控制器资料,会下意识去找“驱动代码”或“SDK”,结果翻完 273 页发现全是寄存器地址、事件码和 I2C 时序——这份文档确实不是代码包,而是主机处理器接口(HPI)的协议契约。它要解决的问题很具体:笔记本、平板、扩展坞里,CCG 芯片负责 Type-C 连接检测和 PD 协商,但整机电源策略、端口使能、固件升级这些决策必须由 EC 或 SoC 来做,两者之间走什么总线、发什么命令、寄存器怎么排布,全由 HPI 规定。搞不定 HPI,CCG 就是一颗“黑匣子”,PD 能协商但状态读不出来,或者固件刷一半变砖。这份 Rev. *P 版本覆盖 CCG1 到 CCG6SF 全系,适合正在做 Type-C 端口控制、需要把 CCG 接入自有 EC 固件的从业者。

2. HPI 传输层:I2C 读写与 INTR 握手的实现细节

2.1 先分清 HPIv1 和 HPIv2,否则寄存器偏移全部对不上

HPI 不是一套统一协议,而是按芯片代际切成两代。翻文档 Table 1 会看到,CCG1/CCG2 走 HPIv1,CCG3/CCG4/CCG5/CCG6/CCG6DF/CCG6SF 走 HPIv2。两代在 I2C 帧格式、寄存器地址映射、事件结构体长度上都有差异,最常见的翻车就是拿 CCG3 的代码去读 CCG2,读出来的寄存器全是错位的。

区分方式有两条路:一是看硬件 BOM 上的具体型号,二是上电后读READ_SILICON_ID寄存器(地址在文档 4.2 节的设备信息寄存器区)。后者更可靠,因为同一块板子可能换料。HPIv1 的命令-响应模型是单次事务里完成写命令和读响应,HPIv2 则把“发命令”和“取响应”拆成两次 I2C 传输,中间靠 INTR GPIO 或轮询RESPONSE寄存器判断完成。我刚接触时就因为没区分版本,在 CCG4 上照抄 CCG2 的读时序,结果 I2C ACK 正常但数据全是 0xFF——因为 CCG4 根本不会在第一次传输里返回响应。

提示:如果手头没有原理图,先用 I2C 扫描工具确认从机地址。CCG 的 I2C 从机地址可通过配置选择,文档 2.4 节列了可选地址,别默认 0x08。

2.2 I2C 写 CCG:HPIv2 的两段式帧结构

以 HPIv2 写一个 16 位寄存器为例,I2C 帧分两段:第一段发寄存器地址(高字节在前),第二段发数据。注意 CCG 的寄存器地址空间是 16 位的,但 I2C 传输时按字节流处理,所以代码里要显式拆高低字节。下面这段基于 Linux i2c-dev 的写法可以直接抄:

#include <linux/i2c-dev.h> #include <sys/ioctl.h> #include <fcntl.h> #include <unistd.h> #include <stdint.h> /* fd: 已打开的 /dev/i2c-N * addr: CCG 的 7 位 I2C 从机地址 * reg: 16 位寄存器地址 * val: 待写入的值(长度由 len 决定,1 或 2 字节) */ int ccg_hpi_write(int fd, uint8_t addr, uint16_t reg, const uint8_t *val, uint8_t len) { uint8_t buf[4]; int n = 0; buf[n++] = (reg >> 8) & 0xFF; /* 寄存器地址高字节 */ buf[n++] = reg & 0xFF; /* 寄存器地址低字节 */ for (int i = 0; i < len; i++) buf[n++] = val[i]; /* 数据按小端或大端取决于寄存器定义 */ if (ioctl(fd, I2C_SLAVE, addr) < 0) return -1; /* 一次 write 完成“地址+数据”,CCG 内部自动递增 */ return write(fd, buf, n) == n ? 0 : -1; }

这段代码的关键在buf的拼装顺序:先地址后数据,且地址高字节在前。文档 2.1.2 节明确 HPIv2 的 I2C 写操作要求首字节是寄存器地址 MSB。如果你把高低字节写反,CCG 会把它当成另一个寄存器——比如把0x0008写成0x0800,可能直接踩到保留区,返回 NACK 或静默丢弃。ioctl(fd, I2C_SLAVE, addr)设置从机地址,这一步在每次传输前都要做,因为同一总线上可能挂多个 CCG 端口控制器。

参数len的控制也要注意:设备信息寄存器大多是 1 字节,PD 策略寄存器可能是 4 字节。写多字节时 CCG 按小端解释(低字节在低地址),所以val数组里要先放低字节。这一点文档没有用大标题强调,但在 4.2.3 节的寄存器定义表里每个字段都标了字节偏移,对照着看就清楚。

2.3 I2C 读 CCG:先写地址再读数据,中间不能有 STOP

HPIv2 的读操作比写多一步:先发寄存器地址(写方向),再发重复起始条件(Repeated START),然后读数据。很多 MCU 的 I2C 外设默认在两次传输之间插入 STOP,这会导致 CCG 复位内部地址指针,读回来的是寄存器 0x0000 的内容。用 Linux i2c-dev 时,write后紧接read且不关闭 fd,内核会帮你处理 Repeated START;但如果你用的是裸机 I2C 驱动,必须手动配置为“写地址→Repeated START→读数据”的时序。

int ccg_hpi_read(int fd, uint8_t addr, uint16_t reg, uint8_t *val, uint8_t len) { uint8_t regbuf[2]; regbuf[0] = (reg >> 8) & 0xFF; regbuf[1] = reg & 0xFF; if (ioctl(fd, I2C_SLAVE, addr) < 0) return -1; /* 第一步:写寄存器地址,不带 STOP 由内核保证 */ if (write(fd, regbuf, 2) != 2) return -1; /* 第二步:读数据,i2c-dev 会在写和读之间发 Repeated START */ return read(fd, val, len) == len ? 0 : -1; }

这段代码在树莓派或 BeagleBone 上验证过可行,但有两个边界:第一,read的len不能超过 CCG 单个寄存器允许的长度,PD 数据对象最多 4 字节,超过会读到相邻寄存器;第二,如果 CCG 正在处理 PD 事件,I2C 从机可能拉低 SCL 做时钟延展,此时read会阻塞,建议在应用层加超时重试(比如 3 次,每次间隔 1ms)。文档 5.6 节给了 HPI 响应时间参考值,普通寄存器读不超过 1ms,PD 命令响应可能到 10ms 量级,超时设 50ms 比较稳妥。

2.4 INTR GPIO:别用轮询代替中断,但中断里别做 I2C

CCG 通过一根 INTR 引脚通知主机“有事件或响应就绪”。文档 2.3 节说明 INTR 是电平/边沿可配置的,常见做法是配成下降沿触发。我见过有人把 INTR 接到 MCU 的普通 GPIO 然后用 1ms 定时器轮询,功能上能跑,但 PD 协商过程中事件密集时 CPU 占用率飙升,而且容易漏事件——因为 INTR 脉冲宽度可能只有几微秒,轮询根本抓不住。

正确做法是 INTR 接外部中断,中断服务程序里只置一个标志位或发信号量,实际 I2C 读取放到任务上下文。下面是一个伪代码框架:

volatile int ccg_intr_flag = 0; void ccg_intr_isr(void) { ccg_intr_flag = 1; /* 中断里只置位,不碰 I2C */ } void ccg_event_task(void) { while (1) { if (ccg_intr_flag) { ccg_intr_flag = 0; uint8_t resp; /* 读 RESPONSE 寄存器判断是命令响应还是异步事件 */ ccg_hpi_read(fd, CCG_ADDR, REG_RESPONSE, &resp, 1); if (resp & 0x80) { /* 异步事件:读事件结构体,长度按 HPIv2 定义 */ ccg_handle_event(); } else { /* 命令响应:唤醒等待该命令的线程 */ ccg_wake_waiter(resp); } } } }

REG_RESPONSE的地址在 HPIv1 和 HPIv2 下不同,HPIv1 是 0x0000,HPIv2 是 0x0008(以文档 4.2.2 节为准)。判断位0x80表示事件有效,这是 HPIv2 的约定;HPIv1 没有这个位,事件和响应共用一套编码,所以 HPIv1 的代码不能直接移植。中断服务程序里不碰 I2C 的原因很实际:I2C 传输可能睡眠等待总线,在中断上下文里调用会导致内核崩溃或看门狗复位,这是嵌入式 Linux 下的血泪经验。

3. 寄存器体系:四个命名空间与访问权限的边界

3.1 设备信息寄存器:只读区不是“随便读”,有些读操作有副作用

文档 4.2 节把设备信息寄存器分成几组:RESPONSE、设备配置和状态、数据内存、Flash 读写、UCSI、厂商自定义。大部分是只读的,但“只读”不等于“读无副作用”。比如FLASH_ROW_READ_WRITE寄存器(4.2.5 节)在读取时会触发 Flash 行读操作,如果上一笔写操作还没完成,读回来的数据是旧的。我遇到过 EC 读固件版本号,连续读两次结果不一致——第一次读到旧版本,第二次才刷新,原因是前一次固件升级后没有等VALIDATE_FW完成就急着读版本。

稳妥的初始化顺序是:上电→延时 10ms→读DEVICE_MODE确认启动模式→读BOOT_MODE_REASON判断是否异常复位→读READ_ALL_VERSION获取固件版本。每一步之间留 1ms 间隔,避免寄存器更新竞争。

int ccg_read_device_info(int fd) { uint8_t mode, reason; uint8_t ver[8]; usleep(10000); /* 上电后等 CCG 固件就绪 */ if (ccg_hpi_read(fd, CCG_ADDR, REG_DEVICE_MODE, &mode, 1)) return -1; usleep(1000); if (ccg_hpi_read(fd, CCG_ADDR, REG_BOOT_MODE_REASON, &reason, 1)) return -1; usleep(1000); /* READ_ALL_VERSION 返回多字节结构,按文档 4.2 定义解析 */ if (ccg_hpi_read(fd, CCG_ADDR, REG_READ_ALL_VERSION, ver, 8)) return -1; return 0; }

DEVICE_MODE的低两位标识当前固件模式(Legacy Boot 或 Dual Firmware),如果读出来是 Bootloader 模式,说明主固件损坏,需要走固件恢复流程而不是正常初始化。BOOT_MODE_REASON能告诉你上次复位原因(看门狗、软复位、上电),调试时这两个寄存器比日志好用。

3.2 PD 策略寄存器:源端和宿端的配置表不能混写

文档 4.3 节定义 PD 策略/状态寄存器的布局,包括PD_RESPONSE、PD 策略寄存器、数据内存。这里最容易犯的错是把 Source PDO 和 Sink PDO 写到对方的区域。CCG 内部有 Source 和 Sink 两套 PDO 表,寄存器地址不同,写错的结果是 PD 协商时对方发Accept但实际电压不对,或者直接回Reject。

以更新 Source PDO 为例,文档 5.3.1 节给的流程是:先发“Source PDO Update”命令→等PD_RESPONSE返回成功→再写 PDO 数据寄存器。两步顺序不能反,因为命令会让 CCG 锁定当前 PDO 表,提前写数据会被丢弃。下面的代码演示命令触发和轮询响应的配合:

int ccg_update_source_pdo(int fd, const uint8_t *pdo_data, int len) { uint8_t cmd = HPI_CMD_UPDATE_SOURCE_PDO; uint8_t resp = 0; int retry = 50; /* 第一步:发更新命令 */ if (ccg_hpi_write(fd, CCG_ADDR, REG_PD_CMD, &cmd, 1)) return -1; /* 第二步:轮询 PD_RESPONSE,最多等 50ms */ while (retry--) { if (ccg_hpi_read(fd, CCG_ADDR, REG_PD_RESPONSE, &resp, 1) == 0) { if (resp == PD_RESP_SUCCESS) break; if (resp == PD_RESP_FAIL) return -2; } usleep(1000); } if (retry < 0) return -3; /* 第三步:写 PDO 数据到数据内存区 */ return ccg_hpi_write(fd, CCG_ADDR, REG_PD_DATA_MEM, pdo_data, len); }

HPI_CMD_UPDATE_SOURCE_PDO和PD_RESP_SUCCESS的具体值要从文档 4.1.1 节响应码表和 5.3.1 节命令表里查,不同 HPI 版本编码不同。retry设 50 次对应 50ms 超时,覆盖文档 5.6 节给的最大响应时间。如果返回PD_RESP_FAIL,先查CONTRACT_INFO寄存器看当前是否已有 PD 契约——有些 CCG 固件不允许在契约活跃时改 PDO,需要先发PD_CTRL_SOFT_RESET再改。

3.3 数据内存:大小端和访问对齐是两回事

文档 4.2.4 节的数据内存区用来传递不定长数据,比如 VDM 载荷、扩展消息。访问这块区域时,寄存器地址必须按 4 字节对齐,但数据内容本身可以是任意字节序。我踩过一个坑:读 VDM 响应时按 1 字节偏移连续读,结果每 4 字节里有 3 字节是填充值——因为 CCG 内部以 32 位字为单位组织数据内存,非对齐读会返回字内偏移对应的字节,但相邻字节可能来自不同的字。正确做法是按 4 字节读,再在缓冲区里截取有效长度。

int ccg_read_vdm_data(int fd, uint8_t *out, int out_len) { int words = (out_len + 3) / 4; uint8_t buf[4]; for (int i = 0; i < words; i++) { /* 数据内存按 4 字节字寻址,地址 = 基地址 + i*4 */ if (ccg_hpi_read(fd, CCG_ADDR, REG_PD_DATA_MEM + i * 4, buf, 4)) return -1; for (int j = 0; j < 4 && (i * 4 + j) < out_len; j++) out[i * 4 + j] = buf[j]; } return 0; }

这段代码把 4 字节对齐读取和有效数据截取分开处理,out_len由 VDM 头里的对象数量决定(文档 5.4.2 节)。如果实际数据不满 4 字节,最后一块的高位字节是无效的,不要当有效数据用。

4. PD 消息与事件处理:异步事件的解析与状态机衔接

4.1 事件结构体不是一次读完的,先读头部再决定长度

HPIv2 的异步事件通过RESPONSE寄存器最高位置位来通知,然后主机读事件结构体。文档 4.1.3 节定义的事件结构体是变长的:头部 2 字节包含事件码和长度,后面跟事件数据。常见错误是固定读 16 字节,结果事件短的时候读到垃圾,事件长的时候截断。

正确流程是用ccg_event_task里先读 2 字节头部,再根据长度字段读剩余部分:

void ccg_handle_event(int fd) { uint8_t hdr[2]; uint8_t evt_data[32]; uint8_t evt_code, evt_len; if (ccg_hpi_read(fd, CCG_ADDR, REG_EVENT_HEADER, hdr, 2)) return; evt_code = hdr[0]; evt_len = hdr[1]; if (evt_len > sizeof(evt_data)) evt_len = sizeof(evt_data); /* 防御性截断 */ if (evt_len && ccg_hpi_read(fd, CCG_ADDR, REG_EVENT_DATA, evt_data, evt_len)) return; switch (evt_code) { case EVT_CONTRACT_INFO: /* 解析 CONTRACT_INFO,更新本地 PD 状态 */ break; case EVT_DP_STATUS_UPDATE: /* 解析 DP 状态,文档 Table 6 */ break; default: break; } }

事件码EVT_CONTRACT_INFO和EVT_DP_STATUS_UPDATE的数值从文档 Table 3 查,REG_EVENT_HEADER和REG_EVENT_DATA在 4.2 节设备信息寄存器区。事件数据长度字段是 1 字节,但EVT_CONTRACT_INFO结构体有多个字段,实际长度要看 Table 4 的字段偏移汇总。如果事件数据里有 4 字节的 VBUS 电压值,按小端解析——文档 Table 7 给了VBUS Control Event Data的字节序。

4.2 DisplayPort Alternate Mode 的寄存器配置顺序

文档 5.5 节讲 Alt Mode 控制,HPIv1 和 HPIv2 的实现方式差很多。HPIv1 是“直接配置 DP 状态寄存器”,HPIv2 是“通过事件+命令间接控制”。以 HPIv2 进入 DP Alt Mode 为例,EC 需要做三步:先发HPI_CMD_CONFIG_ALT_MODE命令并带上 SVID(DisplayPort 的 SVID 是 0xFF01),等PD_RESPONSE返回成功,再读DP_STATUS_UPDATE事件确认引脚分配。顺序错了会导致 CCG 回NAK但状态寄存器没变化,看起来像“命令没生效”,实际是前置条件不满足——CCG 要求 Type-C 连接已经建立(CONTRACT_INFO里 CC 状态为 Attached)才处理 Alt Mode 配置。

下面这段配置代码把前置检查也包含进去:

int ccg_enter_dp_alt_mode(int fd) { uint8_t contract[8], resp; uint8_t cmd_buf[4] = {HPI_CMD_CONFIG_ALT_MODE, 0x01, 0xFF, 0x00}; /* 前置检查:是否有活跃 PD 契约 */ if (ccg_hpi_read(fd, CCG_ADDR, REG_CONTRACT_INFO, contract, 8)) return -1; if (!(contract[0] & 0x01)) /* bit0 = attached */ return -2; /* 配置 Alt Mode,SVID 0xFF01 小端填充 */ if (ccg_hpi_write(fd, CCG_ADDR, REG_ALT_MODE_CMD, cmd_buf, 4)) return -3; /* 等响应 */ for (int i = 0; i < 50; i++) { ccg_hpi_read(fd, CCG_ADDR, REG_PD_RESPONSE, &resp, 1); if (resp == PD_RESP_SUCCESS) return 0; if (resp == PD_RESP_FAIL) return -4; usleep(1000); } return -5; }

cmd_buf里的0x01, 0xFF是 SVID 的低字节在前,这是 HPIv2 数据内存的约定。如果写成0xFF, 0x01,CCG 会把它解析成 SVID 0x01FF,返回NAK。contract[0]的 bit0 判断 Attached 是文档 Table 4 里的定义,实际固件版本不同可能有额外位,建议用掩码& 0x03同时检查 attached 和 powered。

4.3 PD 消息发送:控制消息和 VDM 走不同的寄存器通道

文档 5.4 节把 PD 消息分成 Control Messages、VDM、Battery Status、Extended Data 几类。Control Message 走PD_CMD寄存器直接触发,VDM 走数据内存 + 命令组合。常见误用是把 VDM 载荷直接写PD_CMD寄存器,结果 CCG 当成未知控制命令回Reject。

VDM 发送的正确路径是:写 VDM header 到数据内存起始地址→写 VDM payload 到后续地址→发HPI_CMD_SEND_VDM命令→等PD_RESPONSE。VDM header 的第 15 位是“结构化 VDM”标志,DisplayPort 这类标准协议要求置 1;如果置 0,对方会当成非结构化 VDM 处理,可能不响应。这个位在文档 5.4.2 节有说明,但字体不大,容易漏看。

5. 避坑与排查:从 I2C 无响应到固件刷不进的现场记录

5.1 现象:I2C 写寄存器有 ACK,但读回来全是 0xFF

原因:HPIv2 的读操作依赖 Repeated START,如果 I2C 适配器驱动在write和read之间插入 STOP,CCG 会复位内部地址指针,后续读返回无效数据。MCU 裸机 I2C 外设默认往往是“每次传输带 STOP”,需要手动改成“写地址后发 Repeated START”。解决:用逻辑分析仪抓 SCL/SDA 波形,确认写地址之后有没有 STOP;如果没有波形仪,改用 i2c-dev 的read/write组合(内核会处理好),不要自己逐字节操作。

5.2 现象:PD 协商成功,但读CONTRACT_INFO一直是旧值

原因:CONTRACT_INFO寄存器在 PD 事件发生后不会自动更新,主机必须读走异步事件才会刷新。如果 INTR 中断没接或中断服务里没读事件,寄存器里就是上一次缓存的值。解决:确认 INTR 引脚实际接上了,并检查中断触发方式——文档 2.3 节说 INTR 默认极性可配置,如果配成高电平有效但硬件是低有效,中断永远进不来。用轮询RESPONSE寄存器做对照测试,能快速判断是中断问题还是寄存器问题。

5.3 现象:固件升级到一半 CCG 失联,I2C NACK

原因:文档 5.2 节固件更新流程要求先发ENTER_FLASHING_MODE,CCG 进入 Flashing 模式后会切换 I2C 从机地址(或者暂时关闭 I2C)。如果在切换前就用旧地址连续写,后续传输全部 NACK。解决:严格按流程走——发ENTER_FLASHING_MODE→读DEVICE_MODE确认模式位变化→如果地址变了重新ioctl设置新地址→再写 Flash 行。ENTER_FLASHING_MODE之后至少要等 5ms 再访问,文档没有给精确延时,但实测 1ms 不够。

5.4 现象:写 Source PDO 返回成功但协商电压不对

原因:PDO 数据里的电压/电流编码是特定格式,不是直接填 mV 和 mA。文档 6.1 节配置表结构和 5.3.1 节 Source PDO 更新流程里,PDO 的 bit 0-9 是电压(50mV 单位),bit 10-19 是电流(10mA 单位)。如果直接填 5000(想表示 5V),CCG 会解析成 250V。解决:写 PDO 前先按文档折算,5V/3A 对应0x0064 | (300 << 10),即0x4B064这类值。调试时读回寄存器反算是最快的验证方式。

6. 进阶:用READ_ALL_VERSION和 Flash 行操作做固件健康检查

固件升级不是“写完就完事”,CCG 的双固件模式(Dual Firmware Mode,文档 1.3.2 节)允许一份固件损坏时从另一份启动,但前提是VALIDATE_FW被正确调用。我一般在固件升级后强制走一遍健康检查:读READ_ALL_VERSION对比写入前后的版本号,再读BOOT_MODE_REASON确认没有异常复位标志。下面这段检查代码可以直接嵌到升级流程末尾:

int ccg_post_update_check(int fd, const uint8_t *expected_ver, int len) { uint8_t ver[8], reason; /* 等固件校验完成,文档 5.2.2 节建议留出校验时间 */ usleep(20000); if (ccg_hpi_read(fd, CCG_ADDR, REG_READ_ALL_VERSION, ver, 8)) return -1; if (memcmp(ver, expected_ver, len) != 0) return -2; if (ccg_hpi_read(fd, CCG_ADDR, REG_BOOT_MODE_REASON, &reason, 1)) return -3; /* bit7 若置位表示上次启动是看门狗复位,需进一步排查 */ if (reason & 0x80) return -4; return 0; }

这段代码里usleep(20000)的 20ms 是经验值:VALIDATE_FW命令返回成功只代表校验启动,不代表完成,Flash 行校验在 CCG 内部是异步的。如果读版本号太早,可能读到旧版本,误判为升级失败。reason的 bit7 含义在文档 4.2.3 节BOOT_MODE_REASON寄存器表里有定义,不同芯片家族位定义略有差异,用之前对照一下具体型号的表格。

另一个容易被忽略的点是 Flash 行读写(FLASH_ROW_READ_WRITE,文档 4.2.5 节)的对齐要求:行地址必须按 Flash 行大小对齐(CCG6 是 128 字节一行),非对齐写会触发FLASH_ROW_READ_WRITE错误并可能损坏相邻行。我现在的习惯是每次写 Flash 前先用宏把地址向下对齐到行边界,再按整行写入,哪怕中间有空白字节也填 0xFF——擦除态是 0xFF,填 0xFF 不会改变已有数据。从那以后我每次刷 CCG 固件都强制走一遍“地址对齐检查→写→回读校验”三步,再也没出现过刷一半变砖的情况。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询