/* USB-Serial-JTAG 链路的分帧(A13,需求 §10.8 规矩 4 / IF-13 / 计划 D13)。 ESP32-S3 的原生 USB-Serial-JTAG 是**一根线三用**:esptool 烧录、运行日志、命令链路。 日志与命令回应在同一条串流上,所以必须分帧 —— 否则日志一刷就把回应冲散。 本文件是与平台无关的那一半(宿主机可测),ESP 胶水在 `ulink_esp.c`。 ---- 线上格式 ------------------------------------------------------------ 上位机 → 板 @C {"seq":5,"cmd":"status"}*1A2B\n (*CRC 可省,给人手敲用) 板 → 上位机 @R {"seq":5,"ok":true,...}*3C4D\n (*CRC 必带) 其余任何一行 = 日志,上位机原样丢给日志窗口 · 前缀 `@C ` / `@R `(含一个空格)只在**行首**才算数。 · CRC 是 CRC-16/CCITT-FALSE(多项式 0x1021,初值 0xFFFF),算的是 JSON 那一段 (前缀与 `*` 都不算),4 位大写十六进制。「123456789」的校验值是 0x29B1。 · 行尾 `\n`,前面带一个 `\r` 也认(Windows 终端会发)。 ---- 为什么回应必须带 CRC ------------------------------------------------ 日志与回应都经 `ulink_tx_*` 串行写出(ESP 侧拿一把互斥锁),**正常情况下不会交错**。 但总有绕开这把锁的写者:panic 处理、ROM 打印、别的组件直接往 USB 寄存器写。 它们插进一条回应中间时,剩下的仍可能是一段「看起来合法」的 JSON —— 比如 `"ok":false` 被截掉后半句。CRC 让这种回应在上位机那一侧**一定**被丢弃 (然后按超时重传同一个 seq,由判重兜住)。 ---- 为什么日志永远不可能被当成帧 ---------------------------------------- `ulink_tx_log()` 发现一行日志以 `@` 开头时,在前面补一个空格。 日志内容是任意的(错误信息里可能引用了一条命令原文),这一条保证「行首的 `@R `」 只可能来自 `ulink_tx_frame()`。 */ #ifndef ULINK_H #define ULINK_H #include #include #include #include "cmd.h" #ifdef __cplusplus extern "C" { #endif #define ULINK_CMD_PREFIX "@C " #define ULINK_RSP_PREFIX "@R " #define ULINK_PREFIX_LEN 3 #define ULINK_CRC_LEN 5 /* "*XXXX" */ /* 一行请求最长多少字节(不含 `\r\n`):前缀 + JSON(≤ CMD_LINE_MAX − 1)+ CRC。 */ #define ULINK_RX_LINE_MAX (ULINK_PREFIX_LEN + (CMD_LINE_MAX - 1) + ULINK_CRC_LEN) uint16_t ulink_crc16(const char *data, size_t len); /* ---- 接收:字节流 → 命令 ---------------------------------------------- */ typedef enum { ULINK_RX_CMD = 0, /* 一条完整的命令,payload 是去掉前缀与 CRC 的 JSON */ ULINK_RX_TOO_LONG, /* 以 `@C ` 开头但超长:整行丢弃,payload 为 "" */ ULINK_RX_BAD_CRC, /* 带了 CRC 但对不上:payload 是 JSON 部分(只用来回显 seq) */ ULINK_RX_BAD_BYTES, /* 行里有 NUL 或控制字符:payload 同上 */ } ulink_rx_kind_t; typedef void (*ulink_rx_fn)(void *ctx, ulink_rx_kind_t kind, const char *payload); typedef struct { char buf[ULINK_RX_LINE_MAX + 1]; size_t n; bool overflow; /* 本行已超长:一直丢到 `\n` */ bool bad; /* 本行出现过 NUL / 控制字符 */ /* 统计 */ uint32_t cmds; uint32_t ignored; /* 不是命令的行(日志回显、人手敲的回车……) */ uint32_t too_long; uint32_t bad_crc; uint32_t bad_bytes; } ulink_rx_t; void ulink_rx_init(ulink_rx_t *rx); /* 喂任意长度的一段字节(可以在任何位置切开,包括 `\r\n` 中间与 CRC 中间)。 每凑齐一行就回调一次 —— 回调在本函数里同步发生。 */ void ulink_rx_feed(ulink_rx_t *rx, const uint8_t *data, size_t len, ulink_rx_fn fn, void *ctx); /* ---- 发送:日志与帧交错写出 --------------------------------------------- */ /* 往线上写字节。返回**真的写出去**几个(USB 缓冲满时可能少于 len)。 */ typedef size_t (*ulink_sink_fn)(void *ctx, const char *data, size_t len); typedef struct { ulink_sink_fn sink; void *ctx; bool at_bol; /* 最后真正写出去的字节是 `\n`(或者什么都还没写) */ uint32_t frames; uint32_t short_writes; /* 没写全的次数:上位机那一侧会看到一条 CRC 错的行 */ } ulink_tx_t; void ulink_tx_init(ulink_tx_t *tx, ulink_sink_fn sink, void *ctx); /* 写一段日志。可以是半行、一行或多行;行首 `@` 会被垫一个空格。 */ void ulink_tx_log(ulink_tx_t *tx, const char *data, size_t len); /* 写一帧回应:必要时先补 `\n` 把半行日志收尾,再写 `@R *XXXX\n`。 `json` 里不许有 `\n`(cmd.h 的回应本来就没有)。返回是否整帧写出。 */ bool ulink_tx_frame(ulink_tx_t *tx, const char *json, size_t len); /* ---- 链路层自己的错误回应 -------------------------------------------------- 这几种错在命令处理器**之前**就发生了(行太长 / CRC 错 / 坏字节 / 队列满), 由链路任务直接回,不进处理器、不记事件(USB 线上的噪声不该刷掉事件缓冲)。 `payload` 里能解析出 seq 就回显,上位机据此知道是哪一条要重发。 err 名字:`too_long` / `bad_crc` / `bad_frame` / `queue_full`。返回 JSON 长度。 */ size_t ulink_link_error(const char *payload, const char *err, char *out, size_t cap); const char *ulink_rx_err_str(ulink_rx_kind_t kind); /* CMD → NULL */ /* ---- 参考解帧器(上位机那一侧的逻辑,放这里给测试和文档对账) --------------- */ /* 一行输出(不含 `\n`)是不是一条合法回应帧。是则把 JSON 拷进 out 并返回其长度, 不是(日志、CRC 错、没带 CRC)返回 −1。`tools/prlink.py` 是同一逻辑的 Python 版。 */ int ulink_deframe(const char *line, char *out, size_t cap); #ifdef __cplusplus } #endif #endif /* ULINK_H */