延迟确认抑制:连接时关闭 Windows 延迟 ACK + 收帧轻推兜底,帧周期 68.8→20.7ms
This commit is contained in:
@@ -165,6 +165,25 @@ AA 03 00 04 00 00 00 00 00
|
||||
| 2 | KEY | 按键 |
|
||||
| 3 | LCDMEM | 读取显存(屏幕画面) |
|
||||
|
||||
### 延迟确认抑制(性能关键,2026-09-22 实测引入)
|
||||
|
||||
**现象**:拉取一帧 LCDMEM(3209B)原本中位 **68.8ms**,其中约 52ms 卡在帧尾那 137B 小段上。
|
||||
|
||||
**根因**(已在 DTU 侧台架确证,见 STM32F4-Base 仓库 `docs/测试说明/链路时延实测数据分析.md` §3.3):
|
||||
DTU 侧 CH395F 的 MSS=1024、发送 FIFO=1024B,因此每次 1024B 写恰好是**满 MSS 段**(立即发送);
|
||||
而 3209B = 3×1024 + **137B**,最后这段是**次 MSS 段** → 芯片按 Nagle 规则扣住它,等前一数据被 ACK。
|
||||
Windows 默认**延迟确认**把该 ACK 推迟 40~200ms,整帧因此被拖住。
|
||||
|
||||
**本工具的处理**(`remo_disp_server.py`,两条路径自动择一):
|
||||
|
||||
| 路径 | 条件 | 做法 | 实测帧耗时 |
|
||||
|------|------|------|-----------|
|
||||
| **A. 关闭延迟确认** | Windows(默认) | `WSAIoctl(SIO_TCP_SET_ACK_FREQUENCY=1)`(ctypes 直调;Python `socket.ioctl` 白名单不接受该码) | **20.7ms** |
|
||||
| **B. 入站"轻推"兜底** | 非 Windows / A 失败 | 收帧途中若帧已开始却停顿 ≥5ms,向装置补发 1 个 `0x00`(装置只认帧头 0xBB,会丢弃)→ 该段捎带 ACK | **29.4ms** |
|
||||
|
||||
连接时会打印 `[connect] 延迟确认抑制: 已关闭(ACK frequency=1)` 或 `未生效(改用入站轻推兜底)`。
|
||||
两条路径均**不需要改协议、不需要改装置固件**;日志级别调至 DEBUG 可看到 `[_recv] 轻推 1 字节捎带 ACK`。
|
||||
|
||||
## 按键映射
|
||||
|
||||
| 键名 | 协议码 | 说明 |
|
||||
|
||||
+91
-5
@@ -122,12 +122,47 @@ def build_frame(cmd: int, data: bytes) -> bytes:
|
||||
# 连接与收发
|
||||
# =============================================================================
|
||||
|
||||
def _enable_immediate_ack(sock: "socket.socket") -> bool:
|
||||
"""
|
||||
关闭本 socket 的延迟确认(仅 Windows 有效),返回是否生效。
|
||||
用 ctypes 直接调 WSAIoctl(SIO_TCP_SET_ACK_FREQUENCY=1):Python 的
|
||||
socket.ioctl() 有控制码白名单,不接受该码;频度 1 = 每段立即 ACK。
|
||||
其他平台 / 调用失败返回 False(此时由 _recv 的"轻推"兜底,效果相同)。
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
try:
|
||||
import ctypes # 仅 Windows 路径需要;放函数内避免非 Windows 环境无谓导入
|
||||
|
||||
ws2 = ctypes.WinDLL("ws2_32", use_last_error=True)
|
||||
ws_ioctl = ws2.WSAIoctl
|
||||
ws_ioctl.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_void_p,
|
||||
ctypes.c_uint32, ctypes.c_void_p, ctypes.c_uint32,
|
||||
ctypes.POINTER(ctypes.c_uint32), ctypes.c_void_p,
|
||||
ctypes.c_void_p]
|
||||
ws_ioctl.restype = ctypes.c_int
|
||||
freq = ctypes.c_uint32(1) # 1 = ACK every segment
|
||||
ret = ctypes.c_uint32(0)
|
||||
rc = ws_ioctl(ctypes.c_void_p(sock.fileno()),
|
||||
0x98000017, # SIO_TCP_SET_ACK_FREQUENCY = _WSAIOW(IOC_VENDOR, 23)
|
||||
ctypes.byref(freq), 4, None, 0, ctypes.byref(ret), None, None)
|
||||
if rc != 0:
|
||||
logger.warning("[_enable_immediate_ack] WSAIoctl 返回 {rc}(err={err})",
|
||||
rc=rc, err=ctypes.get_last_error())
|
||||
return rc == 0
|
||||
except Exception as e:
|
||||
logger.warning("[_enable_immediate_ack] 设置失败,将使用轻推兜底 - {err}", err=e)
|
||||
return False
|
||||
|
||||
|
||||
def connect(host: str, port: int = PORT) -> bool:
|
||||
"""
|
||||
建立与 DTU 装置的 TCP 连接。
|
||||
若有旧连接则先关闭;连接时超时 3 秒;连接成功后设为 2 秒。
|
||||
连接成功后关闭本 socket 的延迟确认(LCDMEM 帧尾小块被 Nagle 扣住等 ACK,
|
||||
延迟确认会把它拖 40~200ms;详见文件上方"延迟确认抑制"说明)。
|
||||
"""
|
||||
global _sock
|
||||
global _sock, IMMEDIATE_ACK_OK
|
||||
try:
|
||||
if _sock:
|
||||
_sock.close() # 关闭旧连接
|
||||
@@ -137,6 +172,11 @@ def connect(host: str, port: int = PORT) -> bool:
|
||||
_sock.connect((host, port)) # 连接目标主机和端口
|
||||
_sock.settimeout(2.0) # 连接成功后收发超时 2 秒
|
||||
logger.info("[connect] 连接 DTU 成功: {host}:{port}", host=host, port=port)
|
||||
# 抑制延迟确认(性能关键路径:决定整帧是 ~14ms 还是 ~70ms)
|
||||
IMMEDIATE_ACK_OK = _enable_immediate_ack(_sock)
|
||||
logger.info("[connect] 延迟确认抑制: {mode}",
|
||||
mode="已关闭(ACK frequency=1)" if IMMEDIATE_ACK_OK
|
||||
else "未生效(改用入站轻推兜底)")
|
||||
_rm_parser.reset() # 新连接 = 新协议流,复位接收状态机(对齐 MCU 的 accept 时 reset)
|
||||
_pending_frames.clear() # 丢弃旧连接的挂起帧
|
||||
# 连接成功后尝试通过 CMD_INIT 动态获取屏幕分辨率,失败则保持默认 160x160
|
||||
@@ -162,13 +202,34 @@ def _send(cmd: int, data: bytes = b"") -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 延迟确认(Delayed ACK)抑制:LCDMEM 整帧被拖慢 40~200ms 的根因与解法
|
||||
# =============================================================================
|
||||
# 机理(2026-09-22 台架实测确证,见 STM32F4-Base 仓库
|
||||
# docs/测试说明/链路时延实测数据分析.md §3.3):
|
||||
# CH395F 的 MSS=1024、发送 FIFO=1024B,因此 MCU 每次 1024B 写恰好是一个
|
||||
# “满 MSS 段”(Nagle 允许立即发送);而 LCDMEM 应答 3209B = 3×1024 + 137B,
|
||||
# 最后 137B 是“次 MSS 段” → 芯片按 Nagle 规则把它扣住,等前一数据被 ACK。
|
||||
# Windows 默认延迟确认把该 ACK 推迟 40~200ms → 整帧被拖住
|
||||
# (实测:尾块 43~64ms,整帧 65~75ms;关掉延迟确认后尾块 → 3.9ms、整帧 → 14ms)。
|
||||
# 解法(全部在客户端,协议与设备代码都不需要改):
|
||||
# ① 本 socket 关闭延迟确认:Windows 用 WSAIoctl(SIO_TCP_SET_ACK_FREQUENCY=1)
|
||||
# —— Python 的 socket.ioctl() 有控制码白名单,不接受该码,故用 ctypes 直调;
|
||||
# ② 兜底“轻推”:收帧途中若帧已开始却迟迟不再来数据,向设备补发 1 个字节
|
||||
# (0x00,非帧头 0xBB,设备状态机会丢弃)→ 该 TCP 段捎带 ACK,同样立即解锁尾块。
|
||||
# 非 Windows(或 ① 失败)时自动启用;① 生效时不启用以免多余上行流量。
|
||||
IMMEDIATE_ACK_OK: bool = False # ① 是否已生效(connect() 时探测;False → 启用 ②)
|
||||
NUDGE_POLL_S = 0.005 # ② 兜底模式下的 recv 超时(秒):决定"发现停顿"的粒度
|
||||
NUDGE_INTERVAL = 0.02 # ② 两次轻推的最小间隔(秒):首推后若仍无数据才需要它
|
||||
NUDGE_MAX_PER_RECV = 64 # ② 单次 _recv 内最多轻推次数(防失控)
|
||||
|
||||
# =============================================================================
|
||||
# 接收耐心参数(MCU 侧 CH395F 按 1KB 块握手发送,速度较慢,客户端需耐心)
|
||||
# =============================================================================
|
||||
# CH395F 发送 FIFO 为 1KB 硬上限,每写满一块要等芯片"发送缓冲释放"中断才能
|
||||
# 写下一块,节奏还受 PC 端 TCP 延迟确认(40~200ms)门控,整帧 LCDMEM(3209 字节)
|
||||
# 分 4 块到达耗时 0.5~3 秒以上属正常现象。旧实现 3 次短等待就放弃,会把帧截断,
|
||||
# 且迟到的尾块污染下一帧帧头,导致之后所有解析永久错乱。故改为:
|
||||
# 写下一块;旧实现 3 次短等待就放弃,会把帧截断,且迟到的尾块污染下一帧帧头,
|
||||
# 导致之后所有解析永久错乱。故保留宽松的总预算:
|
||||
# (尾块被延迟确认拖慢的问题已由上方"延迟确认抑制"处理,这里的预算只作安全网)
|
||||
RECV_CHUNK_TIMEOUT = 3.0 # 单次 recv 最长等待秒数;超时只表示"暂无数据",不算失败
|
||||
RECV_FIRST_BYTE_TIMEOUT = 3.0 # 发出请求后等待首字节的秒数;全程无字节判为对端无应答,快速失败
|
||||
RECV_TOTAL_BUDGET = 10.0 # 收齐一整帧的总时间预算(秒);超预算判本帧失败(丢弃进行中帧并复位状态机)
|
||||
@@ -302,6 +363,8 @@ def _recv(deadline: Optional[float] = None, skip_first_byte_wait: bool = False)
|
||||
- 阶段 2:持续接收直至凑齐整帧,或超过总预算;
|
||||
单次 recv 超时(RECV_CHUNK_TIMEOUT)只表示"暂无数据",继续等待——
|
||||
MCU 经 CH395F 按 1KB 块握手发送,整帧耗时可达 0.5~3 秒以上;
|
||||
- 延迟确认抑制:本 socket 已关延迟确认时用长超时;否则改用短超时 + "轻推"
|
||||
(帧中停顿即补发 1 字节捎带 ACK),避免帧尾小块被拖 40~200ms;
|
||||
- deadline:总预算截止点(monotonic 时钟);None 表示从当前起新计 RECV_TOTAL_BUDGET 预算;
|
||||
给出时与调用方共享预算(如 fetch_screen 跳过夹带短帧的场景,避免每次调用重起预算叠加);
|
||||
- 超预算 / 连接关闭:复位状态机(丢弃进行中帧),防止旧帧污染下一轮。
|
||||
@@ -320,6 +383,8 @@ def _recv(deadline: Optional[float] = None, skip_first_byte_wait: bool = False)
|
||||
else:
|
||||
first_byte_deadline = now0 + RECV_FIRST_BYTE_TIMEOUT
|
||||
first_byte_seen = False
|
||||
last_nudge = 0.0 # 上一次"轻推"时刻(monotonic);兜底模式用
|
||||
nudges = 0 # 本轮 _recv 已轻推次数(上限 NUDGE_MAX_PER_RECV)
|
||||
while True:
|
||||
# 已有本轮之前凑齐的完整帧(挂起队列):优先交付,无需读 socket
|
||||
if _pending_frames:
|
||||
@@ -341,10 +406,31 @@ def _recv(deadline: Optional[float] = None, skip_first_byte_wait: bool = False)
|
||||
)
|
||||
return None
|
||||
remaining = deadline - now
|
||||
_sock.settimeout(min(RECV_CHUNK_TIMEOUT, remaining))
|
||||
# 立即 ACK 已生效 → 用长超时(省 CPU);未生效(非 Windows/失败)→ 用短超时,
|
||||
# 才能在"帧中停顿"期间及时轻推(见文件上方"延迟确认抑制"说明)
|
||||
if IMMEDIATE_ACK_OK:
|
||||
_sock.settimeout(min(RECV_CHUNK_TIMEOUT, remaining))
|
||||
else:
|
||||
_sock.settimeout(min(NUDGE_POLL_S, RECV_CHUNK_TIMEOUT, remaining))
|
||||
try:
|
||||
chunk = _sock.recv(256 * 1024)
|
||||
except (socket.timeout, TimeoutError):
|
||||
# 兜底轻推:帧已开始(状态机不在 TAG 态)但久久不再来数据 →
|
||||
# 补发 1 个 0x00(设备状态机只认 0xBB 帧头,会丢弃该字节),
|
||||
# 这个 TCP 段捎带的 ACK 即解锁被 Nagle 扣住的帧尾小块。
|
||||
if (not IMMEDIATE_ACK_OK) and (_rm_parser.st != _rm_parser.ST_TAG) \
|
||||
and (nudges < NUDGE_MAX_PER_RECV) \
|
||||
and ((now - last_nudge) >= NUDGE_INTERVAL):
|
||||
try:
|
||||
_sock.sendall(b"\x00")
|
||||
nudges += 1
|
||||
last_nudge = time.monotonic()
|
||||
logger.debug(
|
||||
"[_recv] 轻推 1 字节捎带 ACK(第 {n} 次,帧中 {pos} 字节)",
|
||||
n=nudges, pos=_rm_parser.pos,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("[_recv] 轻推失败 - {err}", err=e)
|
||||
logger.debug(
|
||||
"[_recv] recv 超时(本轮无数据),状态机进行中 {pos} 字节,继续等待",
|
||||
pos=_rm_parser.pos,
|
||||
|
||||
Reference in New Issue
Block a user