延迟确认抑制:连接时关闭 Windows 延迟 ACK + 收帧轻推兜底,帧周期 68.8→20.7ms

This commit is contained in:
2026-09-22 06:12:21 +08:00
parent 8af48f8b6e
commit 448a44345c
2 changed files with 110 additions and 5 deletions
+19
View File
@@ -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
View File
@@ -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,