A6:PCA9555 驱动 + 「接地脚永远是输入」的硬约束

U23 @ 0x20,16 个脚全部是输入:5×EFUSE_FLT# + 5×EFUSE_PGOOD + BUCK_PG
+ INA_ALERT + 4 个焊死在地上的备用脚(P0_7 / P1_5~7)。位表与地址从
.claude/pcb1-parts.json 的真实焊盘网络逐脚核回来,与设计文档 §17.2 一致。

驱动根本不提供「把某个脚配成输出」的接口(和 sr595.h 不提供「抬 OE#」同一条
规矩),方向字一律过 pca9555_dir_safe(),不安全直接 abort。原因是 PCA9555 的
Output Port 寄存器复位值是 0xFF —— 把接地脚配成输出的那一刻它就输出高电平,
而真器件既不报错也不 NAK,只是发烫。

三种极性(FLT# 低有效 / PGOOD 高有效 / INA_ALERT 低有效)各自只在一个函数里
出现一次;上层问「有没有故障」,不许自己去 & 位。

sim 侧新增按 datasheet 建模的假 PCA9555:Output 复位 0xFF、输入按位合成、
接地脚被驱动时记短路、INT# 只被「读输入端口」清除。为此给假 I²C 总线加了一个
窃听钩子(POST_WRITE / PRE_READ)—— 通用寄存器表模拟不了「配成输出就对地
短路」这类事,以后再有这种器件照这个挂。

验收:8 条 ctest(81 用例)全绿;idf.py build 通过,pca9555.c.obj 在。
12 个「一定会被写出来的错」逐个注进去跑过,全部变红。

Co-Authored-By: Claude Opus 5 <[email protected]>
这个提交包含在:
zhou yang
2026-09-07 06:28:11 +08:00
共同撰写人 Claude Opus 5
父节点 ab37f9dc49
当前提交 eb3df9ff49
共修改 12 个文件,包含 962 行新增和 59 行删除
+1
查看文件
@@ -6,5 +6,6 @@ idf_component_register(
SRCS "ina226.c"
"dac63204.c"
"sr595.c"
"pca9555.c"
INCLUDE_DIRS "include"
REQUIRES phonerail_hal)
+139
查看文件
@@ -0,0 +1,139 @@
/* PCA9555(U23,地址 0x20)—— 板上唯一的 I/O 扩展器,**16 个脚全部是输入**。
挂的是五路 eFuse 的 FLT#/PGOOD、Buck 的线或 PWRGD、六片 INA226 的线或 ALERT,
共 12 个信号 + 4 个直接接地的备用脚(设计文档 §17.2)。
⚠️ **本驱动不提供「把某个脚配成输出」的接口,一个都不提供。**
四个备用脚 P0_7 / P1_5~7 在板上**直接接地、不串限流电阻**(§17.2 的注,
图纸上已标)。而 PCA9555 的 Output Port 寄存器**复位值是 0xFF**——
所以只要把其中一个配成输出,它下一刻就输出高电平,**直接对地短路**。
配错既不会报错也不会 NAK,只会发烫。所以方向字一律走 pca9555_dir_safe(),
不安全直接 abort。这跟 sr595.h 不提供「抬 OE#」是同一条规矩。
§10.10 的降级行为:这片不应答 = 失去 FAULT / PG 可见性,**不关输出**,
改用 INA226 的电压判据兜底并持续告警。所以本驱动的读失败一律只是返回错误码,
不做任何动作。
本驱动只经 hal_i2c_*,宿主机上由 sim 的假 PCA9555(`sim_pca9555_*`)全部测完。 */
#ifndef PCA9555_H
#define PCA9555_H
#include <stdbool.h>
#include <stdint.h>
#include "hal_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ---- 地址与引脚 ---------------------------------------------------------- */
/* A0 = A1 = A2 = GND(U23 的 pin21 / pin2 / pin3,已按 PCB 焊盘网络核过)。 */
#define PCA9555_ADDR 0x20
/* `IOEXP_INT#`:开漏、低有效、板上 R608 10 kΩ 上拉,直连 ESP32 IO38(§8.1)。
⚠️ **中断只是加速路径,不是主路径**:§10.3 的 `t_safety` 每 20 ms 无条件轮询
读一次两个输入端口。原因是 PCA9555 的 INT 要**读走输入端口才会释放**,而且
输入自己变回原值时它也会释放 —— 只靠中断的话,漏掉一次读就再也等不到下一个沿。
A6 只留这个宏,读它是 A8/A10 的活。 */
#define PCA9555_PIN_INT_N 38
/* ---- 寄存器(全部 8 位) ------------------------------------------------- */
#define PCA9555_REG_INPUT0 0x00
#define PCA9555_REG_INPUT1 0x01
#define PCA9555_REG_OUTPUT0 0x02
#define PCA9555_REG_OUTPUT1 0x03
#define PCA9555_REG_POLINV0 0x04
#define PCA9555_REG_POLINV1 0x05
#define PCA9555_REG_CONFIG0 0x06
#define PCA9555_REG_CONFIG1 0x07
/* 方向寄存器:**1 = 输入**(复位值 0xFF)。本板全输入,所以只会写这一个值。 */
#define PCA9555_DIR_ALL_INPUT 0xFFu
/* 极性反转寄存器必须是 0。写成非 0 的现象是「五路同时报故障」或者更糟的
「五路故障全都看不见」,而 I²C 一切正常 —— init 会写 0 并回读校验。 */
#define PCA9555_POLINV_NONE 0x00u
/* ---- 位分配(从 `.claude/pcb1-parts.json` 的真实焊盘网络逐脚核对) -------
Port0 bit0~4 = EFUSE_FLT1~5 bit5 = BUCK_PG bit6 = INA_ALERT bit7 = 接地
Port1 bit0~4 = EFUSE_PG1~5 bit5~7 = 接地
这一次两张表**确实是顺排的**(`bit = rail - 1`),和 INA226 地址、595 位号
那两张表不一样。所以这里最容易写出来的错不是「算式」,而是
**把 FLT 和 PG 的端口搞反** —— 两个端口的 bit0~4 长得一模一样,
搞反之后「有故障」会被读成「电源良好」,方向正好相反。
所以取位号一律走下面两个函数,不要在别处写 `1 << (rail - 1)`。 */
#define PCA9555_RAIL_COUNT 5
#define PCA9555_BIT_BUCK_PG 5 /* Port0,五路 Buck 的 PWRGD 线或(都是开漏) */
#define PCA9555_BIT_INA_ALERT 6 /* Port0,六片 INA226 的 ALERT 线或 */
/* 接地的备用脚。**永远、永远是输入。** */
#define PCA9555_P0_GND_MASK 0x80u /* P0_7 */
#define PCA9555_P1_GND_MASK 0xE0u /* P1_5 P1_6 P1_7 */
/* rail 是 **1~5**,越界 abort。返回的是**位号**(0~4),不是掩码。 */
int pca9555_flt_bit(int rail); /* Port0 */
int pca9555_pg_bit(int rail); /* Port1 */
/* 12 个信号位互不重复、都没落到接地脚上、FLT 与 PG 分属两个端口。
启动自检调一次(A7,和 ina226_addr_table_ok() / sr595_map_ok() 一起)。 */
bool pca9555_map_ok(void);
/* 0x20 不与 INA226(0x40/41/44/45/42/43) / DAC63204(0x4A/0x4B) / LM75(0x48) 撞车。 */
bool pca9555_addr_ok(void);
/* dir 里 1 = 输入。返回 false = 这个方向字会把某个**接地脚**配成输出。
驱动内部走到 false 直接 abort:悄悄放过去的后果是板子发烫,不是功能不好用。 */
bool pca9555_dir_safe(int port, uint8_t dir);
/* ---- 采样 ---------------------------------------------------------------- */
typedef struct {
uint8_t port0;
uint8_t port1;
} pca9555_in_t;
/* 认件:读回两个方向寄存器,全 0xFF(复位值)才算是它。
不应答 → HAL_ERR_NAK;应答了但方向寄存器不是复位值 → HAL_ERR_BUS
(这个地址上有东西,但不是一片刚上电的 PCA9555)。 */
hal_err_t pca9555_probe(void);
/* 写方向(全输入)与极性反转(全 0)并**逐个回读校验**,回读不一致 → HAL_ERR_BUS。
⚠️ Output Port 一个字节都不写:写它没有意义(没有输出脚),而写错方向的
后果见文件头。 */
hal_err_t pca9555_init(void);
/* 巡检主路径:**一次事务**读两个输入端口(§10.3 的 0.12 ms 预算就是按一次算的;
PCA9555 的读指针在 0x00 / 0x01 之间 toggle,这两个字节是 datasheet 保证的)。 */
hal_err_t pca9555_read_inputs(pca9555_in_t *out);
/* ---- 语义判读 ------------------------------------------------------------
四个信号**三种极性**,这是本文件第二个容易写反的地方。极性只在下面四个函数里
各出现一次(和 sr595_set_hub_reset() 里那个反相同一个道理:多一处就多一处写反
的机会)。上层一律问「有没有故障」,不许自己去 & 位。 */
/* TPS25940A 的 `FLT#`:开漏、板上 R113 47 kΩ 上拉 ⇒ **低 = 有故障**。 */
bool pca9555_rail_fault(const pca9555_in_t *in, int rail);
/* TPS25940A 的 `PGOOD`:开漏、R115 47 kΩ 上拉,输出 2.97 V 以上才释放
⇒ **高 = 电源良好**。 */
bool pca9555_rail_pg(const pca9555_in_t *in, int rail);
/* 五路 Buck 的 `PWRGD` 线或(开漏)⇒ **高 = 五路都正常**;低只说明「有某一路
不正常」,看不出是哪一路(§16.1)。 */
bool pca9555_buck_pg(const pca9555_in_t *in);
/* 六片 INA226 的 `ALERT` 线或:开漏、低有效 ⇒ **低 = 有片子在告警**。 */
bool pca9555_ina_alert(const pca9555_in_t *in);
#ifdef __cplusplus
}
#endif
#endif /* PCA9555_H */
+194
查看文件
@@ -0,0 +1,194 @@
/* PCA9555 驱动。只经 hal_i2c_*,不碰任何平台头文件。 */
#include "pca9555.h"
#include <assert.h>
#include <stddef.h>
#include "hal_i2c.h"
/* 位表见 pca9555.h。这两张表**这次确实是顺排的**,但仍然写成表:
哪天硬件改一根线,改这里一处就够,而不是满工程找 `1 << (rail - 1)`。 */
static const uint8_t s_flt_bit[PCA9555_RAIL_COUNT] = { 0, 1, 2, 3, 4 }; /* Port0 */
static const uint8_t s_pg_bit[PCA9555_RAIL_COUNT] = { 0, 1, 2, 3, 4 }; /* Port1 */
int pca9555_flt_bit(int rail)
{
assert(rail >= 1 && rail <= PCA9555_RAIL_COUNT); /* 越界是调用方算错了,不许悄悄返回 */
return s_flt_bit[rail - 1];
}
int pca9555_pg_bit(int rail)
{
assert(rail >= 1 && rail <= PCA9555_RAIL_COUNT);
return s_pg_bit[rail - 1];
}
bool pca9555_map_ok(void)
{
uint8_t p0 = 0, p1 = 0;
for (int rail = 1; rail <= PCA9555_RAIL_COUNT; rail++) {
const uint8_t f = (uint8_t)(1u << s_flt_bit[rail - 1]);
const uint8_t g = (uint8_t)(1u << s_pg_bit[rail - 1]);
if ((p0 & f) != 0 || (p1 & g) != 0) {
return false; /* 两路占了同一位 */
}
p0 |= f;
p1 |= g;
}
const uint8_t buck = (uint8_t)(1u << PCA9555_BIT_BUCK_PG);
const uint8_t alrt = (uint8_t)(1u << PCA9555_BIT_INA_ALERT);
if ((p0 & buck) != 0 || (p0 & alrt) != 0 || buck == alrt) {
return false; /* 线或的两根撞到某路 FLT 上,或者撞到一起 */
}
p0 |= (uint8_t)(buck | alrt);
/* 一个信号都不许落到接地脚上 —— 落上去就等于永远读到 0(FLT 永远报故障,
PG 永远报「不良」),而 I²C 一切正常。 */
if ((p0 & PCA9555_P0_GND_MASK) != 0 || (p1 & PCA9555_P1_GND_MASK) != 0) {
return false;
}
return true;
}
bool pca9555_addr_ok(void)
{
/* 同一条 I²C 上的其它器件(§8.2)。 */
static const uint8_t others[] = { 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x48, 0x4A, 0x4B };
if (PCA9555_ADDR > 0x7f) {
return false;
}
for (size_t i = 0; i < sizeof(others); i++) {
if (others[i] == PCA9555_ADDR) {
return false;
}
}
return true;
}
bool pca9555_dir_safe(int port, uint8_t dir)
{
if (port != 0 && port != 1) {
return false;
}
/* 1 = 输入。接地脚对应的位必须是 1,一位都不能少。 */
const uint8_t gnd = (port == 0) ? PCA9555_P0_GND_MASK : PCA9555_P1_GND_MASK;
return (dir & gnd) == gnd;
}
/* ---- 寄存器读写 ---------------------------------------------------------
PCA9555 的寄存器是 8 位的,命令字节在**成对**的寄存器之间 toggle
(0/1、2/3、4/5、6/7),不是无限自增。本驱动只在一对之内连读 2 字节,
落在 datasheet 保证的范围里;跨对连读(比如从 0x06 读 4 字节)行为不同,
别那么写。 */
static hal_err_t read_reg(uint8_t reg, uint8_t *val)
{
return hal_i2c_write_read(PCA9555_ADDR, &reg, 1, val, 1);
}
static hal_err_t write_reg(uint8_t reg, uint8_t val)
{
const uint8_t wr[2] = { reg, val };
return hal_i2c_write(PCA9555_ADDR, wr, sizeof(wr));
}
/* 写完立刻回读比对。回读不一致 = 总线上有东西但不是我们以为的那片。 */
static hal_err_t write_verify(uint8_t reg, uint8_t val)
{
hal_err_t err = write_reg(reg, val);
if (err != HAL_OK) {
return err;
}
uint8_t rb = 0;
err = read_reg(reg, &rb);
if (err != HAL_OK) {
return err;
}
return (rb == val) ? HAL_OK : HAL_ERR_BUS;
}
hal_err_t pca9555_probe(void)
{
uint8_t c0 = 0, c1 = 0;
hal_err_t err = read_reg(PCA9555_REG_CONFIG0, &c0);
if (err != HAL_OK) {
return err;
}
err = read_reg(PCA9555_REG_CONFIG1, &c1);
if (err != HAL_OK) {
return err;
}
/* 刚上电的 PCA9555 两个方向寄存器都是 0xFF。不是的话,要么这地址上蹲着别的
器件,要么它已经被谁配过了 —— 两种都要在启动时就叫出来。 */
return (c0 == PCA9555_DIR_ALL_INPUT && c1 == PCA9555_DIR_ALL_INPUT) ? HAL_OK : HAL_ERR_BUS;
}
hal_err_t pca9555_init(void)
{
/* 方向必须先于一切:这一步写错就是四个接地脚被驱动成高电平。
全输入是唯一合法值,assert 拦的是「哪天有人顺手改了这个常量」。 */
assert(pca9555_dir_safe(0, PCA9555_DIR_ALL_INPUT));
assert(pca9555_dir_safe(1, PCA9555_DIR_ALL_INPUT));
hal_err_t err = write_verify(PCA9555_REG_CONFIG0, PCA9555_DIR_ALL_INPUT);
if (err != HAL_OK) {
return err;
}
err = write_verify(PCA9555_REG_CONFIG1, PCA9555_DIR_ALL_INPUT);
if (err != HAL_OK) {
return err;
}
/* 极性反转清零。复位值本来就是 0,但「本来就是」不值得赌 —— 看门狗复位
只复位 ESP32,PCA9555 是不掉电的,上一条命的残留值会原样留着。 */
err = write_verify(PCA9555_REG_POLINV0, PCA9555_POLINV_NONE);
if (err != HAL_OK) {
return err;
}
return write_verify(PCA9555_REG_POLINV1, PCA9555_POLINV_NONE);
}
hal_err_t pca9555_read_inputs(pca9555_in_t *out)
{
if (out == NULL) {
return HAL_ERR_ARG;
}
const uint8_t reg = PCA9555_REG_INPUT0;
uint8_t rd[2] = { 0, 0 };
hal_err_t err = hal_i2c_write_read(PCA9555_ADDR, &reg, 1, rd, sizeof(rd));
if (err != HAL_OK) {
return err;
}
out->port0 = rd[0];
out->port1 = rd[1];
return HAL_OK;
}
/* ---- 语义判读(极性各出现一次,见头文件) -------------------------------- */
bool pca9555_rail_fault(const pca9555_in_t *in, int rail)
{
assert(in != NULL);
return (in->port0 & (uint8_t)(1u << pca9555_flt_bit(rail))) == 0; /* FLT# 低有效 */
}
bool pca9555_rail_pg(const pca9555_in_t *in, int rail)
{
assert(in != NULL);
return (in->port1 & (uint8_t)(1u << pca9555_pg_bit(rail))) != 0; /* PGOOD 高 = 良好 */
}
bool pca9555_buck_pg(const pca9555_in_t *in)
{
assert(in != NULL);
return (in->port0 & (uint8_t)(1u << PCA9555_BIT_BUCK_PG)) != 0; /* 高 = 五路都正常 */
}
bool pca9555_ina_alert(const pca9555_in_t *in)
{
assert(in != NULL);
return (in->port0 & (uint8_t)(1u << PCA9555_BIT_INA_ALERT)) == 0; /* ALERT 低有效 */
}