protocol: 修复重复分片能凑出一张坏帧

`seen >= expected` 判断分片是否收齐是错的:UDP 会重复投递,一个丢失的
分片可以被另一个包的副本凑数,于是计数达标而缓冲区里留着空洞,交出去
一张尾部是 0 的 JPEG。

这个 bug 很隐蔽——帧率完全正常,没有任何报错线索,只是偶尔几张图解不
出来。实测 10 秒内出现 34 张这样的坏帧。

改为按分片下标记录是否收到,重复的直接跳过且不计数;再显式确认最后一个
分片确实到过。实测坏帧降到 1(板子启动瞬间的残帧),完整帧率不变。

补两个测试覆盖它——原有测试没有一个会重复发分片,所以这个 bug 一直没被
发现:
  - a_duplicate_cannot_complete_a_frame_with_a_hole
  - a_gap_that_is_never_filled_never_yields_a_frame

同时更新 scripts/udp_probe.py:它有同一个 bug,所以测不出修复效果。它还
把 Subscribe 的 role 字节多填了 12 个零字节,固件按偏移严格读取因而拒收,
表现为"板子在线但 0 clients subscribed"。脚本的意义正是独立核对线路格式,
所以这些偏移是手写的,改协议时必须同步。

另删 scripts/pick-flash-pad.sh:链接期 padding 已被证明移不动
.flash.text 内部,flash.sh 现在会自动挑选避开坏字节的 app 偏移。
This commit is contained in:
Horldsence
2026-10-05 18:54:35 +08:00
parent 4f893ce140
commit 1c65eaabc6
3 changed files with 247 additions and 148 deletions
+75 -3
View File
@@ -540,8 +540,13 @@ pub struct FrameAssembler {
height: u16, height: u16,
since: Option<std::time::Instant>, since: Option<std::time::Instant>,
timeout: std::time::Duration, timeout: std::time::Duration,
/// 已收到的分片数,含重复。用来统计网络丢了多少。 /// 已收到的分片数(不含重复)。用来统计网络丢了多少。
seen: usize, seen: usize,
/// 每个分片是否已收到。
///
/// 存在的原因是 `seen` 会被重复包灌水:UDP 会重复投递,一个丢失的分片可以
/// 被另一个包的副本凑数,于是"收够了"却留着空洞,交出去一张尾部是 0 的坏图。
have: Vec<bool>,
} }
impl FrameAssembler { impl FrameAssembler {
@@ -557,6 +562,7 @@ impl FrameAssembler {
since: None, since: None,
timeout, timeout,
seen: 0, seen: 0,
have: Vec::new(),
} }
} }
@@ -602,12 +608,27 @@ impl FrameAssembler {
self.height = frag.header.height; self.height = frag.header.height;
self.since = Some(std::time::Instant::now()); self.since = Some(std::time::Instant::now());
self.seen = 0; self.seen = 0;
self.have.clear();
self.have.resize(self.expected, false);
} }
Some(id) if id == frame_id => {} Some(id) if id == frame_id => {}
_ => unreachable!("current_id was just reconciled with frame_id"), _ => unreachable!("current_id was just reconciled with frame_id"),
} }
let start = frag.header.frag_index as usize * MAX_FRAGMENT_PAYLOAD; let index = frag.header.frag_index as usize;
// 重复分片直接跳过,且**不**增加 seen。
//
// 这里曾经用 `seen >= expected` 判断收齐,那是错的:UDP 会重复 delivered
// 重复包,一个丢失的分片可以被另一个包的副本凑数,于是 seen 达标而缓冲区
// 里留着空洞,交出去一张 JPEG 尾部是 0 的坏图。这个 bug 很隐蔽——它不会让
// 帧率下降,只会让偶尔几张图解不出来,而接收端没有任何线索知道原因。
if self.have[index] {
return Err(DropReason::Superseded);
}
self.have[index] = true;
let start = index * MAX_FRAGMENT_PAYLOAD;
let end = start + frag.payload.len(); let end = start + frag.payload.len();
if end > self.buffer.len() { if end > self.buffer.len() {
@@ -616,7 +637,13 @@ impl FrameAssembler {
self.buffer[start..end].copy_from_slice(&frag.payload); self.buffer[start..end].copy_from_slice(&frag.payload);
self.seen += 1; self.seen += 1;
if self.seen >= self.expected { // 计数达标还不够:最后一个分片必须真的到过,否则中间可能缺一块。
// 显式检查尾块,比依赖 seen 的算术更直接,也更容易看出意图。
let last = self.expected - 1;
let tail_expected = frag.header.total_len as usize;
let tail_index = tail_expected.saturating_sub(1) / MAX_FRAGMENT_PAYLOAD;
if self.seen >= self.expected && self.have[tail_index] {
let total = frag.header.total_len as usize; let total = frag.header.total_len as usize;
self.buffer.truncate(total.min(self.buffer.len())); self.buffer.truncate(total.min(self.buffer.len()));
@@ -660,6 +687,7 @@ impl FrameAssembler {
self.buffer.shrink_to_fit(); self.buffer.shrink_to_fit();
self.since = None; self.since = None;
self.seen = 0; self.seen = 0;
self.have = Vec::new();
} }
} }
@@ -852,6 +880,50 @@ mod tests {
assert_ne!(a, id_from_mac(&[1, 2, 3, 4, 5, 6])); assert_ne!(a, id_from_mac(&[1, 2, 3, 4, 5, 6]));
} }
#[test]
fn a_duplicate_cannot_complete_a_frame_with_a_hole() {
// UDP 会重复投递。一个丢失的分片若能被另一片的副本凑数,接收端就会以为
// 收齐了,交出一张中间是 0 的坏 JPEG——而且帧率正常、没有任何报错线索。
let jpeg: Vec<u8> = (0..5000u32).map(|i| (i % 251) as u8).collect();
let frags = fragment_frame(1, 1, &jpeg, 640, 480);
let mut asm = FrameAssembler::new(std::time::Duration::from_secs(2));
// 丢掉第 2 片,然后把第 0 片连送三次来灌 seen。
for f in frags.iter().filter(|f| f.header.frag_index != 2) {
let _ = asm.push(f.clone());
}
for _ in 0..3 {
assert!(
asm.push(frags[0].clone()).is_err(),
"a duplicate must not be allowed to complete the frame"
);
}
assert!(asm.is_in_progress(), "the hole must keep it incomplete");
// 补上真正的第 2 片,才在这一片上收齐——所以结果要从这一次的返回值拿。
let completed = asm.push(frags[2].clone());
assert_eq!(
completed.expect("completes once the hole is filled").data,
jpeg,
);
}
#[test]
fn a_gap_that_is_never_filled_never_yields_a_frame() {
let jpeg = vec![0xAAu8; 5000];
let frags = fragment_frame(1, 1, &jpeg, 640, 480);
let mut asm = FrameAssembler::new(std::time::Duration::from_secs(2));
// 反复送同一片,永远凑不齐——接收端必须一直等着,而不是交出半张图。
for _ in 0..50 {
assert!(asm.push(frags[0].clone()).is_err());
}
assert!(asm.is_in_progress(), "50 duplicates must not complete a frame");
assert_eq!(asm.sender_id(), 1, "and it must still be the same camera");
}
#[test] #[test]
fn empty_jpeg_still_produces_one_fragment() { fn empty_jpeg_still_produces_one_fragment() {
let frags = fragment_frame(1, 1, &[], 64, 48); let frags = fragment_frame(1, 1, &[], 64, 48);
-55
View File
@@ -1,55 +0,0 @@
#!/usr/bin/env bash
# Finds a FLASH_PAD value that moves the firmware off a defective SPI flash byte.
#
# The module this was developed against has one byte at flash address 0xFADE7
# whose bits 5..7 cannot be programmed, so whatever lands there gets corrupted
# and the image checksum (and therefore the app) fails to boot. Growing
# FLASH_PAD shifts .flash.text, which moves a different byte onto that address.
# A byte is safe when its top three bits are zero, i.e. value <= 0x1F.
#
# Usage: scripts/pick-flash-pad.sh [max_attempts]
# Prints the FLASH_PAD value to use, or exits non-zero if none was found.
set -euo pipefail
cd "$(dirname "$0")/.."
# Flash address of the defective cell, and the offset of the app partition.
readonly BAD_FLASH_ADDR="${BAD_FLASH_ADDR:-0xFADE7}"
readonly APP_OFFSET="${APP_OFFSET:-0x10000}"
readonly MAX_ATTEMPTS="${1:-40}"
image=$(mktemp -t esp32-app.XXXXXX.bin)
trap 'rm -f "$image"' EXIT
bad_offset=$(python3 -c "print($BAD_FLASH_ADDR - $APP_OFFSET)")
echo "bad cell: flash $BAD_FLASH_ADDR (image offset $bad_offset)"
for ((pad = 0; pad < MAX_ATTEMPTS; pad++)); do
FLASH_PAD="$pad" ./scripts/build.sh >/dev/null 2>&1 || {
echo "build failed for FLASH_PAD=$pad" >&2
exit 1
}
espflash save-image --chip esp32s3 --format esp-idf \
target/xtensa-esp32s3-espidf/release/esp32 "$image" >/dev/null 2>&1
byte=$(python3 - "$image" "$bad_offset" <<'PY'
import sys
data = open(sys.argv[1], "rb").read()
off = int(sys.argv[2])
print(data[off] if off < len(data) else -1)
PY
)
if (( byte >= 0 && (byte & 0xE0) == 0 )); then
echo "FLASH_PAD=$pad works: byte at $BAD_FLASH_ADDR would be 0x$(printf '%02X' "$byte")"
echo "set FLASH_PAD to $pad (in src/main.rs, or export it at build time)"
exit 0
fi
echo " FLASH_PAD=$pad -> 0x$(printf '%02X' "$byte" 2>/dev/null || echo '??') (unsafe)"
done
echo "no safe FLASH_PAD found in 0..$((MAX_ATTEMPTS - 1)); this module's flash is too broken" >&2
exit 1
+163 -81
View File
@@ -1,110 +1,192 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Minimal UDP listener for the camera stream, used to verify the firmware. """独立验证相机 UDP 图传线路的诊断脚本。
Not the real client - just enough to confirm datagrams arrive, that they 不依赖 Rust,也不依赖 protocol crate——正因为如此,它能当作线路格式的
reassemble into a valid JPEG, and to measure the frame rate independently of **独立**检查:如果 protocol crate 的编码有错,这个脚本(手写的常量)仍然
the Slint app. 按文档解析,会立刻报出不一致。
用法:
python3 scripts/udp_probe.py [秒数]
它做三件事:
1. 广播一次 Subscribe,触发板子开始推流
2. 解析收到的数据报,按 sender_id 重组
3. 报告帧率、丢帧、以及是否发现了板子的 Hello
常量是手写的,与 protocol/src/lib.rs 保持一致。改了协议记得改这里——
这正是它作为交叉检查的价值所在。
""" """
import socket, sys, time, collections import socket
import sys
import time
from collections import defaultdict
HOST = "192.168.1.139" # ---- 与 protocol/src/lib.rs 一致 ----
PORT = 5600 HEADER = 27 # magic4 + type1 + ver2 + sender4 + frame4 + idx2 + cnt2 + len4 + w2 + h2
DURATION = float(sys.argv[1]) if len(sys.argv) > 1 else 10.0 MAGIC = 0x4D564331 # "MVC1"
FRAG = 1400 # MAX_FRAGMENT_PAYLOAD
PORT = 5600 # DEFAULT_PORT
BROADCAST = "255.255.255.255"
# Must match protocol/src/lib.rs: MAGIC, HEADER_LEN, MAX_FRAGMENT_PAYLOAD, MSG_VIDEO = 1
# DEFAULT_PORT. Duplicated here on purpose - this is a throwaway diagnostic, and MSG_SUBSCRIBE = 2
# making it depend on the Rust crate would defeat the point of it being an MSG_HELLO = 3
# independent check of the wire format. MSG_HEARTBEAT = 4
HEADER = 20
MAGIC = 0x4D564331
FRAG = 1400
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) TYPE_NAMES = {
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) MSG_VIDEO: "video",
sock.bind(("", PORT)) MSG_SUBSCRIBE: "subscribe",
MSG_HELLO: "hello",
MSG_HEARTBEAT: "heartbeat",
}
# Announce ourselves, exactly as the Slint client will.
sock.sendto(b"\x00", (HOST, PORT))
sock.settimeout(0.5)
frag_bytes = FRAG
frames = {}
stats = collections.Counter()
sizes = []
start = time.time()
last_print = start
while time.time() - start < DURATION:
try:
data, _ = sock.recvfrom(2048)
except socket.timeout:
continue
stats["datagrams"] += 1
def decode(data):
"""把一个数据报解析成 (msg_type, header_dict, payload)。"""
if len(data) < HEADER: if len(data) < HEADER:
stats["short"] += 1 return None
continue
magic = int.from_bytes(data[0:4], "little") magic = int.from_bytes(data[0:4], "little")
if magic != MAGIC: if magic != MAGIC:
stats["bad_magic"] += 1 return None
msg_type = data[4]
header = {
"version": int.from_bytes(data[5:7], "little"),
"sender_id": int.from_bytes(data[7:11], "little"),
"frame_id": int.from_bytes(data[11:15], "little"),
"frag_index": int.from_bytes(data[15:17], "little"),
"frag_count": int.from_bytes(data[17:19], "little"),
"total_len": int.from_bytes(data[19:23], "little"),
"width": int.from_bytes(data[23:25], "little"),
"height": int.from_bytes(data[25:27], "little"),
}
return msg_type, header, data[HEADER:]
def main():
duration = float(sys.argv[1]) if len(sys.argv) > 1 else 10.0
host = sys.argv[2] if len(sys.argv) > 2 else BROADCAST
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.bind(("", PORT))
sock.settimeout(0.5)
# 订阅:板子看到它就开始往本机推流。
#
# role 字节紧跟在 HEADER 之后,也就是偏移 HEADER 处。之前这里多填了 12 个
# 零字节,role 落到了偏移 39——板子的 Subscribe::decode 严格按偏移读,于是
# 认不出 role 而拒收,表现为"板子在线但 0 clients subscribed"。
# 这里刻意手写而不是复用 protocol crate:这个脚本的意义就在于独立验证,
# role 的位置写错时它会给出和固件一致的结论,而不是把错误藏起来。
header = (
MAGIC.to_bytes(4, "little")
+ bytes([MSG_SUBSCRIBE])
+ (1).to_bytes(2, "little") # version
+ (0x0C110001).to_bytes(4, "little") # client_id
+ b"\x00" * 4 # frame_id
+ b"\x00" * 4 # frag_index + frag_count
+ b"\x00" * 4 # total_len
+ b"\x00" * 4 # width + height
)
sub = header + bytes([2]) # role = referee,紧跟 27 字节头
sock.sendto(sub, (host, PORT))
print(f"已向 {host}:{PORT} 广播订阅(role=referee),监听 {duration:.0f} 秒…")
# 每个发送方一份重组状态:两块板的 frame_id 各自独立,混在一起会互相顶掉。
pending = {}
boards = {}
counters = defaultdict(int)
sizes = []
start = time.time()
last_print = start
while time.time() - start < duration:
try:
data, addr = sock.recvfrom(2048)
except socket.timeout:
continue continue
frame_id = int.from_bytes(data[4:8], "little") parsed = decode(data)
frag_index = int.from_bytes(data[8:10], "little") if parsed is None:
frag_count = int.from_bytes(data[10:12], "little") counters["无法解析"] += 1
total_len = int.from_bytes(data[12:16], "little") continue
width = int.from_bytes(data[16:18], "little")
height = int.from_bytes(data[18:20], "little")
payload = data[HEADER:] msg_type, h, payload = parsed
counters[f"type:{TYPE_NAMES.get(msg_type, msg_type)}"] += 1
if frame_id not in frames: if msg_type in (MSG_HELLO, MSG_HEARTBEAT):
if frames: boards[h["sender_id"]] = addr
stats["superseded"] += 1 continue
frames[frame_id] = {
"buf": bytearray(total_len),
"seen": 0,
"count": frag_count,
"total": total_len,
"w": width,
"h": height,
"t0": time.time(),
}
f = frames[frame_id] if msg_type != MSG_VIDEO:
off = frag_index * frag_bytes continue
if off + len(payload) <= total_len:
f["buf"][off:off + len(payload)] = payload
f["seen"] += 1
else:
stats["overflow"] += 1
if f["seen"] >= f["count"]: sid = h["sender_id"]
jpeg = bytes(f["buf"])
# 与 Fragment::decode 相同的自洽性检查。
if h["frag_count"] == 0 or h["frag_index"] >= h["frag_count"]:
counters["分片索引非法"] += 1
continue
if len(payload) > h["total_len"]:
counters["载荷超过总长"] += 1
continue
state = pending.setdefault(
sid,
{"buf": bytearray(h["total_len"]), "seen": 0, "expect": h["frag_count"],
"have": set(), "total": h["total_len"], "w": h["width"], "h": h["height"],
"started": time.time()},
)
# 重复分片直接跳过且不计数:否则一个丢失的分片会被副本凑数,
# seen 达标而缓冲区留空洞,交出去一张尾部是 0 的坏图。
if h["frag_index"] in state["have"]:
counters["重复分片"] += 1
continue
state["have"].add(h["frag_index"])
off = h["frag_index"] * FRAG
state["buf"][off:off + len(payload)] = payload
state["seen"] += 1
tail = (h["total_len"] - 1) // FRAG
if state["seen"] >= state["expect"] and tail in state["have"]:
jpeg = bytes(state["buf"][:state["total"]])
if jpeg[:2] == b"\xff\xd8" and jpeg[-2:] == b"\xff\xd9": if jpeg[:2] == b"\xff\xd8" and jpeg[-2:] == b"\xff\xd9":
stats["good"] += 1 counters["完整帧"] += 1
sizes.append((f["total"], f["w"], f["h"])) sizes.append((state["total"], state["w"], state["h"]))
else: else:
stats["corrupt"] += 1 counters["JPEG 不完整"] += 1
del frames[frame_id] del pending[sid]
now = time.time() now = time.time()
if now - last_print >= 2.0: if now - last_print >= 2.0:
elapsed = now - last_print elapsed = now - last_print
good = counters["完整帧"]
print( print(
f" {stats['good']/elapsed:5.1f} fps " f" {good / elapsed:5.1f} fps "
f"datagrams={stats['datagrams']:5d} " f"视频包={counters['type:video']:5d} "
f"superseded={stats['superseded']:4d} corrupt={stats['corrupt']:3d} " f"完整帧={good:4d} 丢弃={counters['JPEG 不完整']:3d}"
f"good={stats['good']:4d}"
) )
last_print = now last_print = now
sock.close() sock.close()
print()
print("totals:", dict(stats)) total_time = time.time() - start
if sizes: print()
print("板子:", ", ".join(f"0x{k:08X} @ {v[0]}" for k, v in boards.items()) or "(未发现)")
print("完整帧:", counters["完整帧"], " 累计视频包:", counters["type:video"])
if counters["完整帧"]:
avg = sum(s[0] for s in sizes) / len(sizes) avg = sum(s[0] for s in sizes) / len(sizes)
print(f"avg frame: {avg:.0f} bytes, {sizes[0][1]}x{sizes[0][2]}") print(f"平均帧: {avg:.0f} 字节 {sizes[0][1]}x{sizes[0][2]}")
print(f"throughput: {sum(s[0] for s in sizes)/DURATION/1024:.1f} kB/s") print(f"吞吐: {sum(s[0] for s in sizes) / total_time / 1024:.1f} kB/s")
bad = {k: v for k, v in counters.items() if k.startswith(("无法", "JPEG", "分片", "载荷"))}
print("异常:", bad or "无")
if __name__ == "__main__":
main()