DEAD MAN CODING

FOOOLING.COM

IPTV 隔一阵卡一下,最后查出是内核把接收缓冲偷偷砍了一刀

2026-06-15 23:30:40

家里的 IPTV 是组播流,中间挂了一台树莓派双网卡当网关:一头收运营商的组播 UDP,另一头用 msd_lite 把它转成 HTTP 单播,局域网里谁想看就拉一条 http://pi:8888/rtp/...。平时挺好,但有个让强迫症抓狂的毛病:看上半小时到几个小时,画面会冷不丁卡一下、糊一小会儿,然后又自己好了。频率不高,可一旦盯上就忍不了。


麻烦的是,我手边能折腾的这台机器 CPU 太弱,根本放不动这流;能解码的机器又在别处。也就是说我没法"边看边定位"。那干脆就别看了——我把流直接往 /dev/null 里灌,谁也不放,只盯着它的传输层数据,从带宽、丢包、数据完整性里反推到底是哪儿出了问题。


思路其实很简单。画面卡顿逃不开三种原因:带宽不够、丢包、或者数据本身坏了。好在这三样在 MPEG-TS 流里各有各的信号,互不串味——带宽看每秒吞吐稳不稳,丢包看每个 PID 的连续计数器(CC)有没有断,数据坏没坏看包头那个 TEI(传输错误指示)位有没有被上游置起来。把这三个数摆出来,是哪一类基本一眼就分得清。


先用 ffprobe 瞄了一眼这是什么流:H.264、1080i、MPEG-TS,码率大概 9 Mbps 上下,近似恒定。然后写了几十行 Python,curl 用管道把流喂进来,按 188 字节一个包切开自己数:逐 PID 记 CC 断裂当丢包,看 TEI 位当坏包,再按秒累加字节当吞吐。挂了半个钟头。


数据出来,方向指得很干脆:

吞吐    8.76 Mbit/s   (全程一条直线)
TEI     0 个          (没有坏包)
丢包    43 次 / 257 个包

吞吐纹丝不动,带宽排除;TEI 一个都没有,数据没坏。问题就剩丢包这一条。而真正让我确定方向的,是逐条事件日志里的一个规律:每次丢包前 50 毫秒,必定先有一次"数据投递停顿",而且是视频、音频、连节目表(PAT/PMT)所有 PID 在同一瞬间一起丢。丢的是时间轴上一整段连续的流,不是某个编码器单独抽风。

23.100  停顿  1161ms 没有数据进来
23.144  丢包  视频~8 + 音频~10 + 节目表 同时丢


这下就有意思了。我是经 msd_lite 走 HTTP(TCP)把流拉过来的,而 TCP 是有重传的、根本不会丢包。所以这些丢包绝不可能发生在"msd_lite 到我"这一段——它一定在更上游:要么是组播 UDP 那一截(UDP 没重传,丢了就永久没了),要么是 msd_lite 自己没接住。


上 Pi 一查,真相挺让人无语。msd_lite 的配置是出厂默认,接收缓冲 rcvBuf 写着 512,配置文件开头还有行小字 Sizes in kb——也就是它想申请 512KB 的接收缓冲。可再看内核这边:

net.core.rmem_max = 212992   (208KB)

内核给单个 socket 接收缓冲的硬上限只有 208KB,比 msd_lite 想要的还小。于是它申请的 512KB 被默默砍成了 208KB,连个警告都没有。而 208KB 在 9Mbps 这个码率下,只够缓冲不到 200 毫秒的数据。回头再看前面抓到的那些停顿——200 毫秒到一秒半。一切都对上了:这个接收缓冲就是 msd_lite 来不及读时,组播包临时排队的地方;只要它被卡住超过这不到 200 毫秒,缓冲就溢出,后面的组播包直接被内核丢弃。两百多个丢包,根儿就在这一刀上。


那 msd_lite 又是被谁卡的?顺手 top 了一下,有个进程扎眼得很:

PID    %CPU  COMMAND
50125  99.9  kworker/u9:2+hci0      (已经这么跑了 143 天)

kworker/u9:2+hci0——名字里的 hci0 是板载蓝牙,这是树莓派一个挺有名的内核线程跑飞 bug,从开机那一刻起就死死占住了四个核里的一个,整整 143 天。一个核被白白吃满,msd_lite 偶尔就抢不到 CPU、卡上几百毫秒,正好一脚踩进那条 200 毫秒的红线。两个毛病——小得离谱的缓冲、加上一个白占 CPU 的僵尸线程——单拎出来都不致命,凑一块儿就成了这种"隔一阵卡一下"。


找到病根,剩下就是对症。两步,顺序还不能反:先把内核上限抬起来,不然 msd_lite 配多大都白搭、照样被砍;然后再把 msd_lite 自己的缓冲加大。至于加到多大,我的经验是别照搬字节数,按"能扛几秒"来算——能扛的秒数 = 缓冲字节 ÷(码率 ÷ 8)。给它留个四五秒的余量,反算下来十几兆,设上去就是:

# 内核上限(/etc/sysctl.d/99-iptv.conf,单位字节)
net.core.rmem_max=33554432       # 接收缓冲上限抬到 32MB
net.core.rmem_default=4194304

# msd_lite.conf(单位 KB)
rcvBuf       16384               # 组播接收缓冲,16MB ≈ 5 秒余量
ringBufSize  16384               # msd_lite 自己的环形缓冲,同步放大

这里有个容易忽略的细节:内核给 socket 设缓冲时会把你要的值翻倍记账,所以想真正拿到 16MB,rmem_max 得开到 32MB 才不会又被截断。


再就是那个跑飞的 kworker。kill -9 对它没用——内核线程根本不收信号;hciconfig down 也按不住已经自旋住的它。唯一靠谱的办法是关掉板载蓝牙再重启。偏偏这机器之前在这种卡死状态下走正常 reboot 会直接僵死、最后只能拔电,而拔电对 SD 卡是有损伤的。所以我用了 sysrq 那套安全重启:先刷盘、再把根文件系统挂成只读、最后硬重启,绕开那个会卡死的关机流程。

sudo sh -c 'echo 1 > /proc/sys/kernel/sysrq; sync; sleep 1; \
  echo s > /proc/sysrq-trigger; sleep 1; \
  echo u > /proc/sysrq-trigger; sleep 1; \
  echo b > /proc/sysrq-trigger'


重启回来,被白占的那个核空出来了,负载掉下去,iowait 从 25% 回到几乎为零。同样的黑洞采样再跑半小时,丢包从两百多直接归零。实际看,不卡了。


回过头想,这件事最别扭、也最有意思的地方是:一台连流都放不动的弱鸡,靠几十行脚本把流灌进黑洞,反而比"打开播放器盯着看它卡不卡"更早、更准地把根因摸了出来。有时候不直接看,反而看得更清楚。另一个教训是,最阴的 bug 往往一声不吭——内核把你要的 512KB 不动声色砍成 208KB,没有任何报错,全靠把缓冲换算成"能撑多少毫秒"才看穿。还有配置项单位是 kb 这种小坑,也够你愣半天的。

友情链接