10BASE-T1S Arduino 代码接口深度解析(二):PHY Interface 与 SPI HAL

系列导读

本篇接续应用层 UDP Socket API,向下钻到 PHY 抽象层与硬件抽象层:

  1. 应用层 UDP Socket APIArduino_10BASE_T1S_UDP、内部队列、错误码
  2. PHY Interface 与 HAL 层(本篇)— 抽象类 Arduino_10BASE_T1S_PHY_Interface、具体类 TC6_Arduino_10BASE_T1S、SPI HAL TC6_Io
  3. 协议核心 libtc6 与 lwIP 集成 — libtc6 的 C API、回调契约、lwIP netif 集成

1. 抽象基类:Arduino_10BASE_T1S_PHY_Interface

文件位置:src/Arduino_10BASE_T1S_PHY_Interface.h

1
2
3
4
5
6
7
8
9
10
11
12
13
class Arduino_10BASE_T1S_PHY_Interface {
public:
virtual ~Arduino_10BASE_T1S_PHY_Interface() { }

virtual bool begin(IPAddress const ip_addr,
IPAddress const network_mask,
IPAddress const gateway,
MacAddress const mac_addr,
T1SPlcaSettings const t1s_plca_settings,
T1SMacSettings const t1s_mac_settings) = 0;

virtual void service() = 0;
};

1.1 设计动机

该抽象类是库作者预留的”可替换 PHY 后端”接口。设计上让任何实现了该接口的类都能被 Arduino_10BASE_T1S_UDP 无缝使用:

1
2
3
4
5
6
Arduino_10BASE_T1S_UDP  ──不直接持有 PHY 对象

│ 通过 Arduino_10BASE_T1S_PHY_Interface*
│ (但目前代码中是直接引用 TC6_Arduino_10BASE_T1S)

TC6_Arduino_10BASE_T1S ◀── 当前唯一实现

注意:当前代码(v0.1.1)Arduino_10BASE_T1S_UDP 并未通过 Arduino_10BASE_T1S_PHY_Interface* 与 PHY 解耦——它通过全局变量 t1s_phy(宏展开的对象名)和 _lw.tc.tc6(TC6 实例指针)直接通信。该抽象类更多是面向未来扩展用户自行实现 PHY 时的契约。

1.2 纯虚方法契约

begin(...)

参数 含义 约束
ip_addr 本机 IPv4 地址 必须与 mac_addr 在同一 L2 子网,否则 ARP 无法解析
network_mask 子网掩码 标准 IPv4 格式
gateway 默认网关 同子网或可达路由
mac_addr 6 字节 MAC 地址 必须在硬件上唯一(推荐 MacAddress::create_from_uid()
t1s_plca_settings PLCA 节点配置 T1SPlcaSettings 类型,详见辅助类文档
t1s_mac_settings MAC 行为配置 T1SMacSettings 类型
返回 含义
true PHY 已上电、寄存器已配置、netif_add 已完成
false 任一步骤失败(lwIP init、SPI、TC6 init、寄存器写入等)

service()

说明
返回 void
必须调用频率 越高越好(注释:Must be called cyclic. The faster the better.,见 examples/UDP_Client/UDP_Client.ino:107
内部职责 1) sys_check_timeouts() — lwIP 定时器;2) 根据 IRQ 状态决定是否 TC6_Service();3) TC6Regs_CheckTimers() — 寄存器超时监控

关键观察service() 不是异步通知机制——它是轮询。即使没有 IRQ,TC6 内部待发的 TX 数据(来自 lwIP 上层)也会被 service 驱动出去。

1.3 移植到竞品芯片时的最小实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class MyCompetitorPHY : public Arduino_10BASE_T1S_PHY_Interface {
public:
MyCompetitorPHY(MyIo& io) : _io(io) {}

bool begin(IPAddress ip, IPAddress mask, IPAddress gw,
MacAddress mac, T1SPlcaSettings plca, T1SMacSettings ms) override {
// 1. 初始化 lwIP
// 2. 初始化竞品 PHY(寄存器/中断)
// 3. netif_add(netif, lwIpInit, ethernet_input)
// 4. 配置 PLCA(如果竞品支持)
}

void service() override {
// 1. sys_check_timeouts()
// 2. 处理中断或轮询 PHY 状态
// 3. 收帧 -> netif->input(),发帧 <- netif->linkoutput()
}

private:
MyIo& _io;
};

2. 具体实现:TC6::TC6_Arduino_10BASE_T1S

文件位置:src/microchip/TC6_Arduino_10BASE_T1S.h / .cpp

2.1 内部数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// src/microchip/TC6_Arduino_10BASE_T1S.h:33-55
typedef void (*TC6LwIP_On_PlcaStatus)(bool success, bool plcaStatus);

typedef struct {
TC6_t *tc6;
struct pbuf *pbuf; // 当前正在拼装的 RX 帧 pbuf
TC6LwIP_On_PlcaStatus pStatusCallback;
uint16_t rxLen;
bool rxInvalid;
bool tc6NeedService;
} TC6Lib_t;

typedef struct {
char ipAddr[16]; // 用于 lwip 配置的字符串形式
struct netif netint;
uint8_t mac[6];
} LwIp_t;

typedef struct {
TC6Lib_t tc;
LwIp_t ip;
TC6::TC6_Io * io;
} TC6LwIP_t;

三层结构:

  • TC6Lib_t — TC6 协议相关状态(实例指针、当前 pbuf、回调)
  • LwIp_t — lwIP 网络接口 + 字符串形式 IP(lwip_init 配置用)
  • TC6LwIP_t — 聚合根,把 TC6、lwIP、HAL 三个世界连起来

2.2 构造 / 析构

1
2
TC6_Arduino_10BASE_T1S(TC6_Io & tc6_io);    // :106
virtual ~TC6_Arduino_10BASE_T1S(); // :112

构造仅做一件事:_lw.io = &tc6_io(保存 HAL 引用)。不会调用 begin(),这是用户责任。

2.3 begin() 详解

src/microchip/TC6_Arduino_10BASE_T1S.cpp:121-195

sequenceDiagram
    autonumber
    participant App
    participant PHY as TC6_Arduino_10BASE_T1S
    participant lwIP
    participant TC6 as libtc6
    participant Regs as libtc6-regs

    App->>PHY: begin(ip, mask, gw, mac, plca, ms)
    PHY->>lwIP: lwip_init() [首次]
    PHY->>TC6: TC6_Init(&_lw) [注册 instance]
    PHY->>TC6LwIP_instance_list: 添加节点
    PHY->>Regs: TC6Regs_Init(... enablePlca, nodeId, count, burstCount, burstTimer, promisc, txCt, rxCt)
    loop 等待 init done
        PHY->>TC6: TC6_Service(tc6, true)
    end
    PHY->>lwIP: ipaddr_aton() 转换 IP/Mask/GW 字符串
    PHY->>lwIP: netif_add(&netint, ..., lwIpInit, ethernet_input)
    PHY->>lwIP: netif_set_link_up()
    PHY->>PHY: _t1s_plca_settings = ...
    PHY-->>App: true

注意点

  1. lwip_init 静态保护:129-134):static bool is_lwip_init = false; 防止重复初始化。这是库的”安全护栏”,但意味着一个进程只能有一个 PHY 实例
  2. TC6 instance 列表:145-152):维护全局链表 tc6_lwip_instance_list_head,让回调(无 this 上下文)能反向找到 TC6LwIP_t。当前默认 TC6_MAX_INSTANCES=1,但代码留了多实例扩展点。
  3. TC6Regs_Init 失败处理:155-166):返回 false 时不清理 TC6_Init,会留下泄漏的 tc6_t已知问题(v0.1.1)。
  4. while(!TC6Regs_GetInitDone) 同步阻塞:169-170):期间通过 TC6_Service(pInst, true) 轮询驱动。true 表示”force service”——不依赖 IRQ 状态。这是初始化阶段必需的,因为寄存器写入的响应需要 SPI 完成。
  5. IP 字符串转换:173-183):用 ip_addr.toString() 转字符串再 ipaddr_aton()ip4_addr_t。绕了一道,原因是 netif_add() 的参数类型是 ip_addr_t*(联合体,IPv4/IPv6 共用),而 IPAddress 是 IPv4-only 4 字节。轻微性能浪费(每次初始化多两次字符串转换)。

2.4 service() 详解

src/microchip/TC6_Arduino_10BASE_T1S.cpp:205-222

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void TC6_Arduino_10BASE_T1S::service()
{
sys_check_timeouts(); // (1) lwIP 定时器

if (_tc6_io.isInterruptActive()) // (2) IRQ 触发的 service
{
if (TC6_Service(_lw.tc.tc6, false)) // false = 非强制(依赖 IRQ)
_tc6_io.releaseInterrupt();
}
else if (_lw.tc.tc6NeedService) // (3) 协议栈主动要求 service
{
_lw.tc.tc6NeedService = false;
TC6_Service(_lw.tc.tc6, true); // true = 强制(处理 TX)
}

TC6Regs_CheckTimers(); // (4) 寄存器超时监控
}

条件分支(2)的语义

  • _tc6_io.isInterruptActive() 通过 _int_in != _int_out 判断是否有未消费的中断(基于 ISR 计数)
  • TC6_Service(..., false) 的第二个参数是”IRQ 引脚电平”——false 表示 IRQ 仍 active(让 libtc6 知道可以期望 RX 数据)
  • 如果 libtc6 在一次 service 中完成了所有 pending 工作,返回 true——此时 IRQ pin 应已回到 HIGH,调用 releaseInterrupt()_int_out 推平

条件分支(3)TC6_CB_OnNeedService() 回调会被 libtc6 用来通知”我有事要做但不需要等 IRQ”(典型场景:TX 队列有待发数据)。置位 tc6NeedService,让主循环 service() 知道要调用一次 TC6_Service。

TC6Regs_CheckTimers():处理内部寄存器超时重试,例如异步写入未确认的回读检查。

2.5 PLCA 控制

getPlcaStatus(callback)

1
2
// :224
bool getPlcaStatus(TC6LwIP_On_PlcaStatus on_plca_status);
  • 发起一次异步寄存器读:TC6_ReadRegister(tc6, 0x0004CA03, true, OnPlcaStatus, &_lw)
  • 寄存器地址 0x0004CA03 是 LAN8651 的 PLCA_status_register
  • secure=true 启用 secure mode(normal + 反转数据双重校验)—— PLCA 状态读取涉及链路健康,开销值得
  • 回调 OnPlcaStatus:82-97)检查 bit[15]:status = (0u != ((1u << 15) & value))
  • 返回 false 仅当 TC6_ReadRegister 内部 enqueue 失败(队列满)

典型用法(来自 UDP_Server):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static unsigned long prev = 0;
if (millis() - prev > 1000) {
prev = millis();
if (!t1s_phy.getPlcaStatus(OnPlcaStatus))
Serial.println("getPlcaStatus(...) failed");
}

static void OnPlcaStatus(bool success, bool plcaStatus) {
if (!success) {
Serial.println("PLCA status register read failed");
return;
}
if (plcaStatus) Serial.println("PLCA Mode active");
else {
Serial.println("CSMA/CD fallback");
t1s_phy.enablePlca(); // 自动恢复
}
}

enablePlca()

1
2
3
4
5
6
// :230
bool enablePlca() {
return TC6Regs_SetPlca(_lw.tc.tc6, true,
_t1s_plca_settings.nodeId(),
_t1s_plca_settings.nodeCount());
}
  • 写入 PLCA_CONTROL_0 重新使能 PLCA
  • 复用 begin() 时缓存的 _t1s_plca_settings注意:这是构造时的副本,运行中修改 t1s_plca_settings 对象不会影响)
  • Burst 计数和定时器不被重新应用(仅恢复 enable + ID/Count)

sendWouldBlock()

1
2
3
4
5
// :235
bool sendWouldBlock() {
TC6_RawTxSegment *dummy;
return 0u == TC6_GetRawSegments(_lw.tc.tc6, &dummy);
}
  • 查询 TC6 TX segment 队列是否有空闲 slot
  • 不被 Arduino UDP 类使用——UDP 在 endPacket() 阻塞式入队,超出 segment 容量时 udp_sendto 返回错
  • 该方法是为将来实现非阻塞发送 API 准备的钩子

2.6 GPIO 输出:digitalWrite(DIO, value)

1
2
// :197
void digitalWrite(DIO const dio, bool const value);
  • DIO::A0 / DIO::A1 对应 LAN8651 的两个 GPIO 引脚
  • 首次调用时通过 TC6Regs_EnableDio_A0/A1 把寄存器 PADCTRL 配置为输出
  • 后续通过 TC6Regs_ToggleDio_A0/A1 翻转(库实现是 toggle,所以保存本地静态状态来决定要不要翻转)
  • 注意:这是芯片 GPIO,不是 Arduino 引脚——是 LAN8651 自身的两个多功能 pin

示例用途examples/tools/Control-DIOx/Control-DIOx.ino 通过这两个 GPIO 控制 PoDL(Power over Data Line)sink 的电源开关。

2.7 私有函数 digitalWrite_A0/A1

src/microchip/TC6_Arduino_10BASE_T1S.cpp:244-276

实现细节:

  • static bool is_dio_a0_enabled —— 避免重复调用 TC6Regs_EnableDio_A0(带 cached state)
  • static bool dio_a0_val —— 避免重复 toggle(toggle 实现)

注意:因为 static bool多实例 PHY 会共享这些状态——这是库的隐式假设。

3. SPI 硬件抽象层:TC6::TC6_Io

文件位置:src/microchip/TC6_Io.h / .cpp

3.1 头文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/microchip/TC6_Io.h:30-62
class TC6_Io {
public:
static size_t constexpr MAC_SIZE = 6;
static uint8_t constexpr FALLBACK_MAC[MAC_SIZE] = { 0x00, 0x80, 0xC2, 0x00, 0x01, 0xCC };

TC6_Io(HardwareSPI & spi, int const cs_pin, int const reset_pin, int const irq_pin);
virtual bool begin();
void onInterrupt();
bool isInterruptActive();
void releaseInterrupt();
bool spiTransaction(uint8_t const *pTx, uint8_t *pRx, uint16_t const len);

private:
HardwareSPI & _spi;
int const _cs_pin;
int const _reset_pin;
int const _irq_pin;
volatile uint8_t _int_in;
volatile uint8_t _int_out;
volatile uint8_t _int_reported;
};

3.2 构造与 SPI 设置常量

1
2
// src/microchip/TC6_Io.cpp:28
static SPISettings const LAN865x_SPI_SETTING{24 * 1000 * 1000UL, MSBFIRST, SPI_MODE0};
依据
时钟 24 MHz LAN8651 datasheet max 25 MHz,留 4% 余量
位序 MSB First OA TC6 规范
模式 Mode 0 (CPOL=0, CPHA=0) LAN8651 SPI 规格

3.3 begin() — 硬件复位 + SPI 初始化

1
2
3
4
5
6
7
8
9
10
// src/microchip/TC6_Io.cpp:55
bool TC6_Io::begin()
{
digitalWrite(_cs_pin, HIGH); pinMode(_cs_pin, OUTPUT); // (1) CS 默认高
pinMode(_reset_pin, OUTPUT);
digitalWrite(_reset_pin, LOW); delay(100); // (2) 100ms 复位
digitalWrite(_reset_pin, HIGH); delay(100); // (3) 100ms 恢复
_spi.begin(); // (4) 初始化 SPI 外设
return true;
}

时序来源:LAN8651 datasheet 规定的复位保持 / 恢复时间最少各几 ms,库用 100ms 是非常保守的值(很多 reset 文档建议 ≥10ms 即可)。

约束

  1. 必须在 t1s_phy.begin() 之前调用,因为后者会立即通过 SPI 读芯片 ID
  2. 多次调用 begin() 会反复复位 LAN8651——不破坏,但会让 link 短暂中断
  3. 返回值固定 true不检查 SPI 是否成功(_spi.begin() 在 Arduino SPI 库中通常也只是 pinMode(MOSI/MISO/SCK, ...)

3.4 中断计数三态机

1
2
3
4
5
6
7
8
9
10
11
// src/microchip/TC6_Io.cpp:71-86
void TC6_Io::onInterrupt() { _int_in++; }

bool TC6_Io::isInterruptActive() {
_int_reported = _int_in;
return (_int_reported != _int_out);
}

void TC6_Io::releaseInterrupt() {
if (digitalRead(_irq_pin) == HIGH) _int_out = _int_reported;
}

三计数器设计意图

1
2
3
4
5
                  ISR              service()                service() done
─── ────────── ──────────────
_int_in _int_in++ 读 _int_in 到 _int_reported
_int_reported 比较 _int_reported != _int_out
_int_out 推平到 _int_reported
状态 _int_in _int_out _int_reported 含义
初始 0 0 0 无中断
ISR 触发 1 次 1 0 0 有未消费中断
service 进入 1 0 1 识别到 1 次未消费
service 完成(IRQ 已 HIGH) 1 1 1 推平
ISR 再触发 2 1 1 又有 1 次未消费

8-bit 计数器溢出:理论可记录 255 次未消费中断,但 isInterruptActive(_int_reported != _int_out) 在两个计数器相等时返回 false——如果 ISR 比 service 快 256 次_int_in 溢出回到 _int_out,会误判为无中断。这在 24MHz SPI + 64-byte chunk(每帧约 21μs)的速率下不太可能(要在 service 一次完成前产生 256 次 IRQ),但极端情况需注意。

volatile 的必要性_int_in 在 ISR 中改写,主循环读,没有 volatile 编译器可能优化掉读。

releaseInterrupt 的 IRQ 电平检查:只有当 IRQ_PIN == HIGH(实际引脚已回到 inactive)才推平 _int_out。这是边沿触发的正确语义——避免在 libtc6 完成 service 后、IRQ 引脚尚未回到 HIGH 之前错误地”消费”中断。

3.5 spiTransaction()

1
2
3
4
5
6
7
8
9
10
11
// src/microchip/TC6_Io.cpp:88
bool TC6_Io::spiTransaction(uint8_t const *pTx, uint8_t *pRx, uint16_t const len)
{
digitalWrite(_cs_pin, LOW);
_spi.beginTransaction(LAN865x_SPI_SETTING);
memcpy(pRx, pTx, len); // (1) 把 TX 数据预拷贝到 RX 缓冲
_spi.transfer(pRx, len); // (2) in-place 全双工传输
_spi.endTransaction();
digitalWrite(_cs_pin, HIGH);
return true;
}

性能取舍:93-94):

  • Arduino SPI 库的 transfer(buf, len) 是 in-place 的(一边发送 buf 内容,一边把收到的字节覆盖回 buf)
  • 如果 TX 和 RX 是不同的缓冲,需要先 memcpy(pRx, pTx, len) 把 TX 拷贝到 RX 缓冲
  • 64-byte chunk:memcpy 用时约 0.5-1μs,相比 SPI 传输本身(24MHz × 64 字节 ≈ 21μs)占比 < 5%
  • 优点:避免分配单独的 RX 缓冲,节省 RAM(每次可省 64 字节)

返回值的固化return true——不检查 SPI 是否真的成功(Arduino SPI 库对失败的反馈很弱,主要是靠后续 TC6_Service 解析出的 footer 错误位来发现 SPI 问题)。

3.6 替换 SPI HAL 的最小工作量

1
2
3
4
5
6
7
8
9
class MyCompetitorIo {
public:
MyCompetitorIo(/* 平台 SPI 设备/引脚 */);
bool begin(); // 平台 SPI 初始化 + 硬件复位
void onInterrupt(); // 从平台 ISR 调用
bool isInterruptActive();
void releaseInterrupt();
bool spiTransaction(uint8_t const* tx, uint8_t* rx, uint16_t len);
};

只要这 5 个方法的语义保持一致,TC6_Arduino_10BASE_T1S 可以传入 MyCompetitorIo&(前提:也改 TC6_Arduino_10BASE_T1S 的类型签名,或把它改成 template)。当前是直接 TC6_Io &强类型耦合

4. 多实例支持的真实限制

虽然 libtc6 支持 TC6_MAX_INSTANCES 个实例(当前配置 1),但 Arduino 封装层有几个隐式约束:

约束 位置 影响
static bool is_lwip_init TC6_Arduino_10BASE_T1S.cpp:129 全局 lwip_init 保护,禁止两个 PHY 实例
static bool is_dio_a0_enabled TC6_Arduino_10BASE_T1S.cpp:246 DIO 状态共享,多实例会冲突
static TC6ListNode * tc6_lwip_instance_list_head TC6_Arduino_10BASE_T1S.cpp:46 全局链表,理论支持多实例
Arduino_10BASE_T1S_PHY_TC6 宏产生固定名 t1s_io / t1s_phy Arduino_10BASE_T1S.h:61-63 同一个 sketch 只能展开一次

结论:当前库是单 PHY 实例设计,多实例需要绕过宏、用 new 动态分配对象。

5. 错误恢复路径

graph TD
    A["TC6_Service 返回错误"] --> B{"TC6Error 类型"}
    B -->|NoHardware| C["TC6Regs_Reinit"]
    B -->|BadChecksum| C
    B -->|UnexpectedCtrl| C
    B -->|BadTxData| C
    B -->|SyncLost| C
    B -->|SpiError| C
    B -->|UnexpectedSv| D["日志+继续(数据错)"]
    B -->|UnexpectedDvEv| D
    B -->|ControlTxFail| D
    B -->|Succeeded| E["忽略"]
    C --> F["重新初始化全部寄存器"]
    F --> G["PLCA 恢复"]
    F --> H["MAC 配置恢复"]

TC6Regs_Reinit() 在多个错误场景被调用(src/microchip/TC6_Arduino_10BASE_T1S.cpp:495-544)。它会重新下发 TC6_MEMMAP[]、重新配置 PLCA。注意:Reinit 不会重置 lwIP 状态,IP/ARP 表保持——所以重连后网络可达性立即恢复。

6. 调试技巧

6.1 通过 Serial 看 TC6 事件

库中 TC6_CB_OnErrorTC6Regs_CB_OnEvent 当前默认不打日志#define PRINT(...) 空定义,:493)。要在调试中看:

1
2
3
4
// 在 sketch 顶部
#define PRINT(...) Serial.printf(__VA_ARGS__)
#include <Arduino_10BASE_T1S.h>
// ... 但这不会生效,因为 PRINT 在 TC6_Arduino_10BASE_T1S.cpp 里 define

更彻底的方法:直接修改 src/microchip/TC6_Arduino_10BASE_T1S.cpp#define PRINT(...) 改为 Serial.printf(...),然后用 arduino-cli compile 重新构建。

6.2 监控 IRQ 触发频率

1
2
3
4
5
6
7
8
// 在 loop() 中加
static uint32_t prev = 0;
uint32_t cur = millis();
if (cur - prev > 1000) {
prev = cur;
Serial.print("int/sec est: ");
// 需要把 _int_in 暴露成 public,或者加 getIntCount() 方法
}

当前 TC6_Io 没有暴露 _int_in——调试时要临时改成 public。

7. 小结

Arduino_10BASE_T1S_PHY_Interface 是抽象契约,目前只有一个实现 TC6_Arduino_10BASE_T1S。它依赖:

  1. TC6_Io:5 个方法的 SPI HAL,包括一个三计数器中断状态机
  2. libtc6TC6_* C API(详见下一篇)
  3. lwIP 的 netif 机制(也详见下一篇)

service() 是整个栈的心跳,调用频率直接决定:

  • lwIP ARP 表老化是否及时
  • TX credit 用尽后恢复的延迟
  • PLCA 状态监控的粒度

下一篇进入最底层:libtc6 的 C API 完整清单 + lwIP netif 集成的回调契约。