我最近在做一套桌面端的画面采集与转播工具核心诉求只有一个:把本机采集到的画面和声音稳稳当当地推到一台流媒体服务器上。听起来像是个被做烂的需求,可真落到 C# 这块地界上,能直接抄的完整方案并不多——大部分资料要么停在几行命令行 demo,要么是别的语言的封装,要么一上来就让你去编译 native 库。我最后选的路子是:宿主程序只负责造帧,真正的编码、封装、推流全交给 FFmpeg 这个独立进程,C# 与它之间只用一根标准输入管道连接。这就是标题里说的基于 FFmpeg 的 RTMP 推流功能(C# 版)。这套东西能干什么?把任意来源的原始画面(屏幕、摄像头、合成帧、甚至是程序里画出来的图表)实时编码成 H.264,叠一路音频,封装成 FLV,走 RTMP 协议推给流媒体服务。它解决的核心问题是:你不需要在 C# 里跟 native 库的指针、结构体、回调函数搏斗,也不需要为了换个编码器重写一遍代码,所有编码策略都退化成一条可拼接的命令行参数。适合三类人看:做桌面直播工具/录屏软件的开发者、需要把设备画面推到远端做集中监看的工程人员、以及想搞清楚推流这件事在系统层面到底发生了什么的技术爱好者。哪怕你只是想在本地搭个最小可跑的链路验证一下,文中第四章的步骤也能直接照抄。1. 技术路线选型:为什么最后落在进程 管道上1.1 把需求拆成三段,再决定哪一段自己写推流这件事,拆开看就是采集、编码、传输三段。采集必须由宿主程序做,因为画面从哪里来只有业务清楚;传输这一段 RTMP 协议本身不复杂但坑极多,握手、分块、chunk 大小协商、时间戳回绕,任何一处写错都会表现为连上了但没画面;编码是纯算力活,自己写等于重造一个 H.264 编码器。所以真正需要做决策的只有一件事:中间这段编码,是自己啃 API 还是外包出去。我在纸上列了三条路,对着自己的实际约束逐条打勾,最后选了第三条,理由在下面几个小节里展开。这里先给结论:如果你的团队里没有专门啃 native 层的人,并且你未来半年内可能换编码器、换封装格式、加滤镜,那独立进程 管道喂帧是投入产出比最高的一条路。1.2 三条路线的横向对比路线实现方式开发成本升级成本性能损耗稳定性风险纯托管实现自己写 RTMP 调用系统编码接口极高极高最低编解码细节全靠自己P/Invoke 绑定直接调用 native 库导出函数高高低版本 ABI 不匹配就崩独立进程 管道启动可执行文件,stdin 喂帧低极低中(一次跨进程拷贝)进程级隔离,崩了不影响主程序纯托管这条我在早期试过一个原型,光是把 YUV 帧喂进系统的硬件编码接口就花了两周,而 RTMP 的分块写入在弱网下反复出现时间戳错乱,最后放弃了。P/Invoke 这条,理论上性能最好,但只要对方库升级一次结构体布局,你的程序就会在客户机器上以访问违例的方式崩掉,排查成本极高。第三条路最土,但有个别人替代不了的优势:故障边界清晰。FFmpeg 进程挂了,主程序还活着,你可以杀掉重启、可以换参数重来、可以把 stderr 里的日志整段吐给用户看。对一个要长期在客户机器上跑的工具来说,这个特性比省下那点拷贝开销重要得多。1.3 独立进程方案的代价,得先想清楚天下没有白吃的午餐,这条路有三个明确的代价,我提前讲明,免得你踩进去才后悔。第一是一次完整的帧拷贝。1280×720 的 BGRA 帧是 3.68MB,30fps 下每秒要往管道里灌 110MB 左右的数据。这个量级对现代机器不算什么,但你要意识到它存在——如果采集帧率提到 60、分辨率提到 1080p,这个数字会翻到 500MB/s 以上,那时候管道本身就会成为瓶颈,需要重新评估。第二是控制粒度粗。你没法在推流过程中动态改分辨率、改帧率,只能杀掉进程重新起一条。想要平滑降码率这种操作,得靠 FFmpeg 内部的码率控制策略,或者用两套参数做无缝合流切换,复杂度立刻上一个台阶。第三是错误信息是文本,不是异常。FFmpeg 不会抛ConnectionRefusedException给你,它只会在 stderr 里打一行红字。你必须自己写一套日志解析,把文本翻译成状态机里的状态。这个工作量不大,但必须做,否则你的程序只能告诉用户推流失败了,然后被追问到底是网络问题还是参数问题。1.4 适用边界与影响范围这套方案最适合的场景是:单机推 1~4 路、分辨率不超过 1080p、对端到端延迟要求在 1~3 秒这个量级。落到具体行业,桌面直播工具、在线教学的板书同步、无人值守设备的画面回传、多屏监看墙、会议录制归档,这些都能直接套。不适合的场景也要说清楚:需要亚秒级延迟的互动场景,RTMP over TCP 本身就很难做到,得换传输协议;需要精确到帧的编辑级控制,命令行方案给不了你这种精度;单机推几十路的高密度场景,进程数会把内存和句柄吃光,应该走别的架构。2. 推流管线拆开看:从一帧像素到 RTMP 包2.1 输入环节的接口契约必须先定死管道喂帧最容易出错的地方不是写,而是约定。FFmpeg 在-f rawvideo模式下,对输入流的唯一认知就是:每帧固定字节数、按顺序排列、没有帧头没有分隔符。所以你必须保证三件事严格一致:像素格式、宽高、每帧字节数。我定死的契约是bgra 宽高在启动时确定 每帧width * height * 4字节。选 BGRA 是因为 Windows 上屏幕采集拿到的就是 BGRA,用Bitmap.LockBits也是这个格式,少一次格式转换就少一次 CPU 热点。如果你从别的源拿帧,比如某些采集 SDK 给的是 NV12 或者 RGB24,那要么在 C# 侧转,要么把-pix_fmt改成对应格式让 FFmpeg 转——后者通常更快,因为 FFmpeg 的转换是 SIMD 优化的,而你在 C# 里写循环转换会慢一个数量级。还有一个隐形陷阱:有些采集接口给的每行字节数(步长)不等于width * 4,而是按 4 字节对齐后可能更大。这时候你不能直接Marshal.Copy整块内存进管道,必须逐行拷贝,跳过每行末尾的填充字节。我第一次接摄像头就栽在这,画面出现了斜向撕裂,排查了半天才反应过来是步长问题。2.2 码率、GOP、缓冲:三个必须算一遍的参数这三个参数是推流质量的地基,不能凭感觉填。先讲码率。经验公式是:目标码率 ≈ 宽 × 高 × 帧率 × 每像素比特数。每像素比特数(bpp)在 H.264 里取 0.06~0.10 之间,画面简单取小值,细节多取大值。1280×720×30 2765 万像素/秒,乘 0.08 得到约 2.2Mbps。这个数和实际调试出来的结果非常接近。分辨率帧率建议视频码率建议音频码率说明640×36025600~800 kbps64 kbps弱网兜底档854×480301000~1500 kbps96 kbps移动端观看友好1280×720302000~2800 kbps128 kbps主力档位1920×1080304000~6000 kbps128 kbps需要稳定上行带宽1920×1080606000~9000 kbps128 kbps上行至少 15Mbps 再考虑GOP 的算法很简单:GOP 长度 帧率 × 关键帧间隔秒数。30fps 下想要 2 秒一个关键帧,就是 60。这里有个关键点:一定要加-sc_threshold 0把场景切换自动插关键帧关掉,否则遇到画面突变 FFmpeg 会自己插一个 I 帧,导致实际 GOP 忽长忽短,对下游的缓冲和秒开体验都不友好。-bufsize我一般设成-maxrate的 2 倍。这个参数决定了码率控制的弹性空间,设太小画面一复杂就糊,设太大网络抖动时容易堆积数据造成延迟上涨。-maxrate和-b:v设成相等,是让码率尽量恒定,这对 RTMP 这种长连接很关键。2.3 音频链路:静音源也是一路正经流很多流媒体服务在收流时会检查是否存在音频流,没有就直接拒掉或者转码失败。所以哪怕你的业务场景根本不需要声音,也建议显式给一路静音源:-f lavfi -i anullsrcchannel_layoutstereo:sample_rate44100anullsrc是 FFmpeg 内置的无限时长静音发生器,几乎不耗 CPU,配合-c:a aac -b:a 128k -ar 44100 -ac 2就能造出一条标准的 AAC 音轨。如果确实要推真实音频,采集侧的坑比视频多得多。采样率不一致是最常见的:采集设备给你 48000,你重采样到 44100,如果重采样器配置不对,会出现周期性的咔哒声。我的做法是不重采样,直接把-ar设成设备的原生采样率,让整条链路统一在 48000,兼容性和音质都更好。还有一个隐蔽问题:音频帧和视频帧的时间基不同。视频按帧率走,音频按采样点走,两路数据从不同管道进来时,如果你的喂数据节奏不均匀,就会出现音画不同步。用anullsrc时因为是 FFmpeg 内部生成,时间基天然对齐,反而不会有这个问题——这也是我建议先用静音源跑通链路、再接入真实音频的原因。2.4 封装与传输:FLV 和 RTMP 的那点事RTMP 只负责传字节流,不负责描述这些字节怎么组织,所以推 RTMP 必须配一个封装格式,实践中 99% 用的是 FLV。命令末尾的-f flv就是在指定这个。有个参数值得单独说:-flvflags no_duration_filesize。默认情况下 FLV 封装器想在头部的 metadata 里写入总时长和文件大小,可推流是个无限长的过程,这两个值根本不存在,某些版本的 FFmpeg 会因此反复尝试回填,表现为推流卡在开头几秒不动。加上这个标志,明确告诉它别管这两个字段,问题就消失了。我在一个客户现场排查了整整一下午,最后就是这一行参数解决的。传输层的超时也建议显式配置。默认的 RTMP 超时可能长达几十秒,网络断了你的程序要等很久才知道。用-rw_timeout 5000000(单位微秒,即 5 秒)能让失败快速暴露出来,加快重连节奏。3. C# 侧封装:把命令行包成一个可控对象3.1 参数构造要可配置,但不能过度抽象我见过一种写法,把 FFmpeg 参数抽象成几十个属性加一个模板引擎,最后没人看得懂最终命令长什么样。我的做法相反:所有参数集中在一个方法里顺序拼装,每个参数旁边一行注释,整体读起来就是一条命令。这样做的理由是,你迟早会遇到这条命令在别人机器上跑不通的情况,那时候你需要的是能把完整命令原样打出来贴进终端复现,而不是从十个属性里反推。拼装用ProcessStartInfo.ArgumentList而不是自己拼Arguments字符串。原因是路径里有空格、URL 里有特殊字符时,ArgumentList会替你处理转义,自己拼字符串是经典的翻车点——尤其是 Windows 路径里的反斜杠和空格。public sealed class PushOptions { public string OutputUrl { get; set; } public int Width { get; set; } 1280; public int Height { get; set; } 720; public int Fps { get; set; } 30; public int VideoKbps { get; set; } 2500; public int AudioKbps { get; set; } 128; public int AudioSampleRate { get; set; } 44100; public int KeyframeIntervalSeconds { get; set; } 2; public string PixelFormat { get; set; } bgra; public string VideoEncoder { get; set; } libx264; public string Preset { get; set; } veryfast; } public IReadOnlyListstring BuildArguments(PushOptions o) { int gop o.Fps * o.KeyframeIntervalSeconds; return new Liststring { -hide_banner, -loglevel, levelinfo, // 只要 info 级别日志量可控 -f, rawvideo, -pix_fmt, o.PixelFormat, -s, ${o.Width}x{o.Height}, -r, o.Fps.ToString(), -i, pipe:0, // 关键从标准输入读 -f, lavfi, -i, $anullsrcchannel_layoutstereo:sample_rate{o.AudioSampleRate}, -c:v, o.VideoEncoder, -preset, o.Preset, -tune, zerolatency, // 关掉编码器内部的前瞻缓冲 -b:v, ${o.VideoKbps}k, -maxrate, ${o.VideoKbps}k, -bufsize, ${o.VideoKbps * 2}k, -g, gop.ToString(), -keyint_min, gop.ToString(), -sc_threshold, 0, // 禁止场景切换插关键帧 -pix_fmt, yuv420p, // 输出色彩空间兼容性最好 -c:a, aac, -b:a, ${o.AudioKbps}k, -ar, o.AudioSampleRate.ToString(), -ac, 2, -rw_timeout, 5000000, -flvflags, no_duration_filesize, -f, flv, o.OutputUrl }; }注意这里有个容易搞混的地方:命令里出现了两次-pix_fmt。第一个在输入端,描述喂进来的数据是什么格式;第二个在输出端,描述编码后的数据是什么格式。前者不对,画面颜色会错乱;后者不对,下游播放器可能解不出来。我第一次写的时候只写了前面那个,结果推上去的画面在部分播放器里是绿屏。3.2 进程启动:三个开关必须开var psi new ProcessStartInfo { FileName ffmpegPath, UseShellExecute false, CreateNoWindow true, RedirectStandardInput true, RedirectStandardOutput true, RedirectStandardError true, }; foreach (var a in BuildArguments(options)) psi.ArgumentList.Add(a); _process new Process { StartInfo psi, EnableRaisingEvents true }; _process.Start(); _stdin _process.StandardInput.BaseStream;UseShellExecute false是必须的,否则重定向全都不生效,而且会弹出一个黑窗口——在客户端软件里看到这个窗口,用户第一反应就是这软件是不是有毒。CreateNoWindow true兜底,防止控制台子系统仍然创建窗口。真正容易漏的是RedirectStandardError true之后没人去读它。Windows 上管道的默认缓冲区只有几 KB,FFmpeg 的-stats输出是不断刷新的进度行,一旦缓冲区写满,FFmpeg 的日志线程就会阻塞,进而整个进程卡死,表现为推流推了几分钟突然不动了。这个坑我踩过两次,两次都是在线上的定时任务里发作,排查起来极其难受。解决办法是给 stderr 单独开一个后台读取线程,循环ReadLine(),把内容丢进一个环形缓冲,同时做关键字匹配。顺带把StandardOutput也重定向并读空,虽然正常情况下 FFmpeg 不往 stdout 写东西,但保险起见还是读掉。3.3 喂帧:节奏控制比写入本身更重要写帧的代码本身很短,难的是节奏。public bool WriteFrame(byte[] frame) { if (!_running || _stdin null) return false; try { _stdin.Write(frame, 0, frame.Length); _stdin.Flush(); // 必须 Flush否则数据会滞留在缓冲区 return true; } catch (IOException) { // 管道破裂 FFmpeg 进程已经退出 HandleProcessExit(); return false; } }Flush()这一句非常关键。BaseStream在 .NET 里默认带缓冲,如果你不 Flush,数据会攒到缓冲区满才真正写进管道,表现就是画面延迟越推越大,而且延迟量极不稳定。有人为了减少系统调用频率会把StandardInput换成自定义的大缓冲流,再做整帧批量写入——这确实能降低 CPU,但前提是你严格按帧边界 Flush,否则延迟会失控。节奏方面,我的做法是维护一个目标时间轴:启动时记一个Stopwatch,每写一帧就计算这一帧本该在第几毫秒发出,如果当前时间落后于目标,立刻写;如果超前,就SpinWait或Thread.Sleep(1)等一小会儿。这样即使采集侧偶尔抖动,长时间平均帧率也是稳定的。这里有个取舍要讲清楚:如果你不加-re参数,FFmpeg 会尽最大速度消费管道里的数据,它不关心你喂多快,帧率完全由你的写入速度决定。所以节奏控制的活必须在你这边干。相反,如果你给输入加了-re,FFmpeg 会按-r指定的帧率限速读取,你不喂它也会等着——但这样多了一层不必要的耦合,而且管道里的积压会变成延迟。我倾向于不加-re,自己在 C# 侧控节奏,链路更透明。写帧失败的处理也要注意。管道破裂时Write抛的是IOException,不是ObjectDisposedException,别抓错类型。而且破裂之后这个_stdin就废了,必须整体重启进程,不能指望重新Open一次——StandardInput是一次性的。3.4 日志解析:把文本翻译成状态机FFmpeg 的进度行长这样:frame 1234 fps 30 q28.0 size 2048kB time00:00:41.13 bitrate 407.9kbits/s speed1.00x我们要从里面抠三个数:fps、speed、bitrate。fps是实际编码帧率,speed是编码速度相对实时的倍率——这个值低于 1.0 就意味着编码跟不上采集,画面会开始堆积。bitrate用于检测码率是否按预期输出,如果实际码率远低于设定值,通常是画面太简单或者编码参数有问题。private static readonly Regex ProgressRx new Regex( frame\s*(\d).*?fps\s*([\d\.]).*?bitrate\s*([\d\.])kbits/s.*?speed\s*([\d\.])x, RegexOptions.Compiled); private void OnStderrLine(string line) { var m ProgressRx.Match(line); if (m.Success) { _lastFps double.Parse(m.Groups[2].Value); _lastBitrate double.Parse(m.Groups[3].Value); _lastSpeed double.Parse(m.Groups[4].Value); if (_lastSpeed 0.9) RaiseWarning(编码速度不足建议降低分辨率或换更快的预设); return; } if (line.Contains(Connection refused) || line.Contains(Connection reset)) RaiseFault(FaultKind.NetworkUnreachable); else if (line.Contains(Server error) || line.Contains(handshake)) RaiseFault(FaultKind.AuthRejected); }匹配失败时不要把整行丢掉,存进一个固定长度的环形缓冲(比如最近 200 行)。用户报障的时候,你把这 200 行导出成文本,问题基本就定位得差不多了。这个功能救过我很多次,因为用户描述的现象往往是它不动了,而日志里写的是明确的原因。3.5 优雅停止:别用 Kill 直接砍停止推流时的顺序是:_stdin.Close()→ 等进程自己退出(最多等 3 秒) → 还没退就Kill()。先关管道,FFmpeg 读到 EOF 会自己结束并冲刷掉 FLV 的尾部数据,这样服务器那边收到的流是完整的,不会挂着一个僵尸会话直到超时。直接Kill()的后果是服务器端迟迟不释放这个流 key,你立刻重连会被拒绝,典型报错是stream already publishing。这个现象在调试阶段特别迷惑人,因为代码看起来毫无问题,实际上要等服务器端超时清理。我在测试环境里专门加了一条规则:重启推流前至少等 2 秒。Process对象本身也要Dispose(),否则句柄会泄漏。长期运行的程序里,每重连一次泄漏几个句柄,跑一两天就会耗尽。记得把EnableRaisingEvents打开的Exited事件也一并解绑,事件持有引用会阻止对象回收。4. 完整实操:从零跑通一条本地推流链路4.1 环境准备清单先把东西备齐,这里列的版本区间是我实际验证过能稳的,不是随便写的。组件要求备注.NET6.0 及以上需要ArgumentList特性FFmpeg4.4 及以上完整构建必须含 libx264 和 aac流媒体服务任意支持 RTMP 接收的实现本地自建即可系统Windows 10/11 或主流 Linux 发行版两边的路径处理有差异选 FFmpeg 构建时有个必须注意的点:某些精简构建里不含 H.264 编码器,因为它涉及额外的许可条款。下载后一定要先验一遍:ffmpeg -hide_banner -encoders | grep 264Linux 上用grep,Windows 上换成findstr。输出里至少要看到libx264,如果想要硬件编码还要看到h264_nvenc(NVIDIA)、h264_qsv(Intel)、h264_amf(AMD)。看不到就换一个完整构建,别浪费时间在编译参数上。关于分发,我建议把 FFmpeg 可执行文件随程序一起打包,放在ffmpeg/bin/目录下,启动时用相对路径解析。依赖系统 PATH 里的版本,在客户机器上几乎必然出问题——你永远不知道对方装的是哪个三年老版本,而且参数兼容性差异很大。4.2 第一步:用测试信号跑通链路,先别碰采集这一步是我强烈建议的做法:在接入真实采集之前,先用 FFmpeg 自己生成的测试图案验证整条链路。这样一旦出问题,你能确定问题在推流参数或服务器,不在采集代码。ffmpeg -hide_banner -loglevel info ^ -re -f lavfi -i testsrc2size1280x720:rate30 ^ -f lavfi -i sinefrequency440:sample_rate44100 ^ -c:v libx264 -preset veryfast -tune zerolatency ^ -b:v 2500k -maxrate 2500k -bufsize 5000k ^ -g 60 -keyint_min 60 -sc_threshold 0 ^ -pix_fmt yuv420p ^ -c:a aac -b:a 128k -ar 44100 -ac 2 ^ -flvflags no_duration_filesize ^ -f flv rtmp://127.0.0.1:1935/live/testtestsrc2会画出一个带时间码和移动色块的画面,sine生成 440Hz 的正弦音。这条命令跑起来之后,用播放器拉一下rtmp://127.0.0.1:1935/live/test,如果能听到嘟——的连续音、看到跳动的数字,说明服务器、网络、参数全都对了。这时候再往 C# 里搬,成功率会高很多。这条命令里的-re是必须的,因为输入是文件式的 lavfi 源,不加-re会全速生成,一瞬间推完几百帧然后卡住。而到了管道输入的场景,-re就不需要了,原因前面讲过。4.3 第二步:C# 侧的完整最小可用实现下面这个类去掉了我项目里的业务耦合,只保留推流核心。可以直接建个控制台项目跑起来。using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Text.RegularExpressions; using System.Threading; public enum PushState { Idle, Starting, Running, Faulted, Stopped } public sealed class RtmpPusher : IDisposable { private Process _proc; private Stream _stdin; private Thread _errReader; private volatile bool _running; private readonly Queuestring _logTail new Queuestring(); private readonly object _logLock new object(); private double _lastFps, _lastSpeed; public PushState State { get; private set; } PushState.Idle; public event ActionPushState, string OnStateChanged; private static readonly Regex ProgressRx new Regex( fps\s*([\d\.]).*?speed\s*([\d\.])x, RegexOptions.Compiled); public bool Start(PushOptions o, string ffmpegPath) { if (_running) return false; try { var psi new ProcessStartInfo { FileName ffmpegPath, UseShellExecute false, CreateNoWindow true, RedirectStandardInput true, RedirectStandardOutput true, RedirectStandardError true, }; int gop o.Fps * o.KeyframeIntervalSeconds; var args new Liststring { -hide_banner, -loglevel, levelinfo, -f, rawvideo, -pix_fmt, o.PixelFormat, -s, ${o.Width}x{o.Height}, -r, o.Fps.ToString(), -i, pipe:0, -f, lavfi, -i, $anullsrcchannel_layoutstereo:sample_rate{o.AudioSampleRate}, -c:v, o.VideoEncoder, -preset, o.Preset, -tune, zerolatency, -b:v, ${o.VideoKbps}k, -maxrate, ${o.VideoKbps}k, -bufsize, ${o.VideoKbps * 2}k, -g, gop.ToString(), -keyint_min, gop.ToString(), -sc_threshold, 0, -pix_fmt, yuv420p, -c:a, aac, -b:a, ${o.AudioKbps}k, -ar, o.AudioSampleRate.ToString(), -ac, 2, -rw_timeout, 5000000, -flvflags, no_duration_filesize, -f, flv, o.OutputUrl }; foreach (var a in args) psi.ArgumentList.Add(a); _proc new Process { StartInfo psi, EnableRaisingEvents true }; _proc.Exited (s, e) OnProcessExited(); _proc.Start(); _stdin _proc.StandardInput.BaseStream; _errReader new Thread(ReadStderrLoop) { IsBackground true }; _errReader.Start(); _ _proc.StandardOutput.ReadToEndAsync(); // 读空防止缓冲写满 _running true; SetState(PushState.Running, started); return true; } catch (Exception ex) { SetState(PushState.Faulted, start failed: ex.Message); Cleanup(); return false; } } public bool WriteFrame(byte[] frame) { if (!_running || _stdin null) return false; try { _stdin.Write(frame, 0, frame.Length); _stdin.Flush(); return true; } catch (Exception ex) when (ex is IOException || ex is ObjectDisposedException) { SetState(PushState.Faulted, pipe broken); return false; } } private void ReadStderrLoop() { try { string line; while ((line _proc.StandardError.ReadLine()) ! null) { lock (_logLock) { _logTail.Enqueue(line); while (_logTail.Count 200) _logTail.Dequeue(); } var m ProgressRx.Match(line); if (m.Success) { _lastFps double.Parse(m.Groups[1].Value); _lastSpeed double.Parse(m.Groups[2].Value); } } } catch { /* 进程退出时读线程会异常忽略 */ } } private void OnProcessExited() { if (!_running) return; _running false; string reason string.Join( | , GetLogTail(10)); SetState(PushState.Faulted, ffmpeg exited: reason); } public string[] GetLogTail(int n) { lock (_logLock) { var arr _logTail.ToArray(); int start Math.Max(0, arr.Length - n); return arr[start..]; } } public double LastFps _lastFps; public double LastSpeed _lastSpeed; public void Stop() { if (!_running _proc null) return; _running false; try { _stdin?.Flush(); _stdin?.Close(); } catch { } try { if (_proc ! null !_proc.WaitForExit(3000)) _proc.Kill(true); } catch { } Cleanup(); SetState(PushState.Stopped, stopped); } private void Cleanup() { try { _stdin?.Dispose(); } catch { } _stdin null; try { _proc?.Dispose(); } catch { } _proc null; } private void SetState(PushState s, string msg) { State s; OnStateChanged?.Invoke(s, msg); } public void Dispose() Stop(); }配套的喂帧循环,用一个伪造的渐变帧做压力测试:int w 1280, h 720, fps 30; var opts new PushOptions { OutputUrl rtmp://127.0.0.1:1935/live/test, Width w, Height h, Fps fps, VideoKbps 2500 }; var pusher new RtmpPusher(); pusher.OnStateChanged (s, m) Console.WriteLine($[{s}] {m}); pusher.Start(opts, ./ffmpeg/bin/ffmpeg.exe); var frame new byte[w * h * 4]; var sw Stopwatch.StartNew(); long frameIndex 0; bool wentDown false; while (true) { if (pusher.State PushState.Faulted !wentDown) { wentDown true; Console.WriteLine(推流中断3 秒后重连); pusher.Stop(); Thread.Sleep(3000); pusher new RtmpPusher(); pusher.Start(opts, ./ffmpeg/bin/ffmpeg.exe); wentDown false; sw.Restart(); frameIndex 0; continue; } // 造一个横向滚动的渐变方便肉眼判断有没有卡顿 int shift (int)(frameIndex * 4) % w; for (int y 0; y h; y) { int row y * w * 4; for (int x 0; x w; x) { int p row x * 4; frame[p 0] (byte)((x shift) 0xFF); // B frame[p 1] (byte)(y 0xFF); // G frame[p 2] (byte)((x shift) 1); // R frame[p 3] 255; // A } } pusher.WriteFrame(frame); frameIndex; long target frameIndex * 1000 / fps; long actual sw.ElapsedMilliseconds; if (actual target) Thread.Sleep((int)(target - actual)); }这段代码跑起来之后,拉流能看到一条横向流动的彩色渐变带,没有卡顿、没有撕裂,就说明管道、编码、传输全通了。测试时可以拔一下网线,观察State是否在几秒内变成Faulted,以及重连逻辑是否按预期工作。4.4 第三步:换成真实采集源真实采集和测试帧的区别只有一个:帧数据的来源和格式。屏幕采集场景下,Windows 上可以用系统提供的图形采集接口拿到位图,LockBits之后逐行拷贝到byte[]里,注意前面提到过的步长问题。摄像头场景下,大多数采集组件给的是 YUY2 或 MJPG,前者需要把-pix_fmt改成yuyv422并把每帧字节数调整为width * height * 2,后者如果你不想解码,可以直接让采集组件输出 MJPG 然后改用-f mjpeg输入——但那样 GOP 就不好控制了,一般还是解码成原始帧更可控。还有一个实际项目里的经验:采集和推流之间要加一个容量为 2 的帧队列。采集回调把帧塞进队列,推流线程从队列取。队列满了就丢弃最旧的一帧并计数。这样采集侧的偶发卡顿不会直接传导到推流侧,代价是可能丢帧。经验值是队列深度不要超过 3,再大延迟就上去了。5. 故障排查:那些日志里看不出来的坑5.1 连不上、秒断、伪连接Connection refused是最好处理的,就是地址或端口不对,或者服务器没起来。麻烦的是能连上但立刻断开,日志里只有一行Server error。这种情况九成是流 key 不对,或者上一次推流的会话还没被服务器释放。我的排查顺序是:先用 FFmpeg 生成测试图案推同一个地址,如果用命令行能推、用程序不能推,那问题在参数或喂帧;如果命令行也推不了,就是地址/服务端的问题,跟代码无关。这个二分手法是所有排查里最快见效的。还有一种诡异情况:日志显示握手成功、开始编码,但拉流端黑屏。这通常是关键帧没有及时发出。检查-g和-keyint_min是否设了,以及-sc_threshold是否关了。有些编码器在低码率下会长时间不产关键帧,播放器就一直等。5.2 花屏、绿边、马赛克花屏的原因集中在两个地方。一是喂进去的帧字节数和-s/-pix_fmt不匹配,FFmpeg 会按固定长度切分你的数据流,一旦错位整条流就是垃圾。二是管道写入过程中出现了部分写入——Stream.Write在理论上可能写不满,虽然FileStream/管道流一般会写满,但在异常关闭时可能出现半帧。绿边的典型原因是分辨率不是偶数。H.264 的色度采样是 2×2 一个块,宽高必须是偶数,奇数宽高会导致边缘色度信息缺失。我遇到过 1365 这种宽度,采集侧给的就是奇数,必须在 C# 里裁成 1364 再推。马赛克在低码率下属于正常现象,但如果只在画面快速运动时出现,说明-bufsize太小,码率控制来不及调整。适当加大-bufsize或者提高-maxrate,画面会平滑很多。5.3 音画不同步用anullsrc静音源时几乎不会出现这个问题,一旦接入真实音频,不同步就是高频问题。判断方法很简单:看延迟是恒定偏移还是逐渐累积。恒定偏移通常是时间基设置问题,加-itsoffset补偿即可;逐渐累积则是喂数据节奏问题——音频和视频两路数据的到达速率和它们的名义时钟不匹配。我的处理原则是:音频永远不丢,视频可以丢。人类对声音断裂极其敏感,对掉几帧画面基本无感。所以在队列拥塞时,优先丢弃视频帧,音频帧尽量完整送入。5.4 延迟越推越大这是 RTMP 推流最典型的慢性病。三个原因按概率排序:一是不 Flush,数据在缓冲区堆积;二是喂帧速度超过实时,FFmpeg 内部缓冲越积越多;三是网络带宽不够,-maxrate超过了实际上行能力。第二个原因值得展开说。-tune zerolatency能关掉编码器的 B 帧和前瞻,但关不掉 FFmpeg 内部的封装缓冲。如果你的喂帧速度比实时快 10%,一小时后就会积压 6 分钟的数据。所以节奏控制那块代码不是可选项,是必需项。5.5 速查表现象最可能原因快速验证方法处理启动即退出参数拼错或编码器不存在命令行跑完整参数看 stderr 首行画面全绿输出像素格式不兼容改-pix_fmt yuv420p加上该参数画面斜向撕裂帧步长不等于 width×4打印 stride 值逐行拷贝推几秒后无声卡死stderr 缓冲写满加后台读线程异步读 stderr延迟线性增长未 Flush 或喂帧超速观察 speed 值加 Flush 限速重连被拒旧会话未释放等 5 秒再试先关管道再 Kill编码速度 1.0CPU 不足看日志 speed 值降低预设或分辨率拉流黑屏无关键帧检查-g参数固定 GOP6. 性能与长期运行的工程化考量6.1 编码器怎么选:先把 CPU 占用测出来libx264是最稳的选择,兼容性最好,但在 1080p60 下用medium预设能吃满好几核。veryfast是画质和速度的平衡点,我大多数场景都用它。如果机器上有独立显卡,h264_nvenc能把 CPU 占用压到个位数,代价是低码率下画质略差,而且不同代显卡支持的预设名不一样。编码器适用硬件CPU 占用画质参数兼容性libx264通用高最好全部预设通用h264_nvenc特定品牌显卡极低中预设名不同,需分支h264_qsv特定品牌核显低中需要驱动支持h264_amf特定品牌显卡低中部分参数无效关键提醒:硬件编码器的-preset取值和 x264 不一样,你不能把veryfast直接传给它。我的做法是在参数构造里按编码器名做一次映射,遇到硬件编码器就换一套预设名,否则会直接报 Unknown preset。另外硬件编码器对-tune zerolatency的支持也参差不齐,传了可能被忽略,不会报错但延迟不会降,这点要有心理预期。6.2 长期运行:内存、句柄、日志三件事一个连续跑 24 小时的推流程序,最容易出问题的地方往往不是推流本身。内存方面,注意别在喂帧循环里反复分配byte[]。1280×720 的 BGRA 帧是 3.6MB,每秒 30 次分配就是 110MB/s 的分配速率,GC 会被打得很惨,表现为周期性卡顿。正确做法是预分配两个缓冲区做双缓冲,采集线程写 A,推流线程读 B,避免分配也避免锁竞争。句柄方面,每次重连都会创建Process、Stream、Thread,必须保证全部释放。我加过一个统计,单次重连泄漏 4 个句柄,跑一天重连几十次就上千个。用Process.GetCurrentProcess().HandleCount定时打点,能很快看出趋势。日志方面,_logTail那个环形缓冲要设上限,而且要考虑磁盘写入。如果每行都File.AppendAllText,磁盘 IO 会成为新的热点。我的做法是内存里只留最近 200 行,遇到Faulted状态才落盘一次,文件名带上时间戳。6.3 监控什么指标才算真的在监控推起来的流看起来正常是不可靠的,必须有几个数字。指标采集方式健康范围异常含义实际编码帧率解析 stderr 的 fps接近设定帧率低于设定值说明处理不过来编码速度解析 stderr 的 speed 1.0 0.9 需降低负载实际码率解析 stderr 的 bitrate设定值的 80%~120%明显偏低说明画面过简或参数错管道写入耗时自己计时单帧 5ms变长说明管道拥塞进程句柄数HandleCount稳定不增长持续增长即有泄漏speed这个值我觉得是最有价值的,它几乎是机器还行不行的直接答案。我在一个低配设备上见过speed长期在 0.7 附近波动,表现就是画面时不时卡一下,换成硬件编码器之后立刻恢复。6.4 断线重连的状态机怎么写才不出乱子重连不是简单地再Start一次。要注意三点:第一,重连前必须确保旧进程已经完全退出,否则服务器会拒绝新连接;第二,重连要有退避,连续快速重试会把服务器打成限流;第三,重连后帧序号要重置,否则节奏控制会以为已经落后一大截,疯狂补帧。我的退避策略是 2s、4s、8s、16s、30s 封顶,连续失败 5 次就停下来通知用户,不要无限重试。因为无限重试会掩盖真实问题——如果是流 key 错了,重试一万次也没用,反而让用户以为程序在正常工作。7. 我踩过的几个具体坑,给后来人省点时间第一个坑是-nostdin。看到某些示例里命令带了这个参数,我直接抄了过来,结果推流一启动就报 Error opening input。原因很简单:-nostdin的作用是禁止 FFmpeg 从标准输入读交互命令,而我们恰恰是用pipe:0作为输入源。这两个是冲突的,加了就等于把自己的数据通道堵死了。第二个坑是路径里的中文和空格。Windows 上如果 FFmpeg 放在C:\Program Files\我的工具\ffmpeg.exe,自己拼字符串启动会失败。用ArgumentList能解决参数转义,但FileName本身的路径问题还得靠引号或者短路径。最稳妥的办法是把 FFmpeg 放在程序目录下一个纯 ASCII 名字的子目录里。第三个坑是防火墙。程序第一次启动 FFmpeg 时,系统会弹出网络访问询问。如果用户点了取消,后续所有连接都会失败,而且日志里看不出原因,只有Connection refused。这种情况要么在安装时预置规则,要么在界面上给一句提示,让用户知道该去哪里放行。我在这上面被远程支持折腾过好几次。第四个坑是-loglevel。默认级别是info,每次连接都会打一堆握手细节,日志量不小。但如果设成error,进度行就没了,你的speed解析会全部失效。正确的做法是设成levelinfo,保留进度行但去掉更啰嗦的debug/trace。这个参数看起来不起眼,却直接影响你的监控能力。最后一个算不上坑,但值得说:先跑通最短路径,再往上加功能。我最开始就想一步到位做成支持任意源、任意编码器、动态切换码率的通用组件,结果两周没跑通一条流。后来退回去,先用一个固定分辨率、固定参数、静音音轨的最小链路跑通,确认能拉流、能看画面、能退出,再一项一项往上加,速度快了不止一倍。推流这件事的变量太多,同时改三个以上参数,一旦失败你根本不知道是哪个引起的。现在这套代码在我的项目里已经稳定跑了小半年,日常推 1080p30、单机单路,CPU 占用在 15% 左右,重连逻辑触发过七八次,每次都能在十秒内恢复。如果你也在这条路上,建议先把第四章那段最小实现原样跑一遍,把管道、编码、传输这三个环节各自的正常长什么样刻进脑子里,后面遇到异常时,判断会快很多。
企业数字化 ERP 产品动态
相关推荐
C#基于FFmpeg实现RTMP直播推流:参数、进程封装与稳定性实践 1. 先搞清楚这条链路上到底谁在干活做直播推流这件事,绕不开一个现实:C# 生态里没有一块像 FFmpeg 这样成熟度、格式覆盖面、编码器支持都齐活的音视频全家桶。我见过不少项目一开始雄心勃勃,想用 C# 从头做采集、编码、RTMP 封装,… · 2026/11/6 2:36:55
第五套内网靶场复盘:ThinkPHP 5.0 RCE到域横向移动 1. 这套靶场到底在练什么:整条链路的骨架拆解第一次把某安全团队公开发布的第五套内网靶场环境解压、导入虚拟化平台,我盯着那台只对外暴露 Web 服务的入口机看了挺久。这套环境在圈子里流传很广,很多人把它当成"入门内网"的第一课… · 2026/11/6 2:36:55
AI团队变动传闻下:知识沉淀、协作机制与模型升级实操 关于一个 AI 研发团队整体调整办公地点、甚至“打包”转移的传闻,几乎每隔一段时间就会在技术圈里冒出来一次。这次传到我耳朵里的时候,我第一反应不是去猜真假,而是想起自己带过的一个项目:那年团队里三个人接连离开,… · 2026/11/6 2:36:55
朴素贝叶斯实战:小数据、低延迟、高可解释场景下的工程落地指南 1. 为什么今天还要学朴素贝叶斯?——一个被低估的“老派”算法的真实战场很多人看到“朴素贝叶斯”四个字,第一反应是:教科书里的古董、面试题里的背景板、调包时顺手点掉的sklearn选项。我第一次在某高校实验室带本科生做文本分类项目时&… · 2026/11/6 2:39:56
二阶锥松弛+改进禁忌克隆遗传算法:配电网故障重构MATLAB实现 简介:面向电力系统自动化与智能电网方向的技术人员及研究人员,这份PDF文档围绕二阶锥松弛(SOCP)在主动配电网故障重构中的应用展开,将非凸潮流方程转化为可解的凸优化形式,并结合改进的禁忌克隆遗传算法搜索… · 2026/11/6 2:39:56
ONTAP集群初始化:CLI建集群、NTP同步与SVM模板实战 简介:这份PDF面向联想凌拓ThinkSystem DM系列存储的运维与实施人员,聚焦命令行界面(CLI)初始化配置场景,解决GUI向导无法覆盖的集群搭建问题,如MetroCluster配置或仅限IPv6的环境。资源包仅含1个PDF文件&am… · 2026/11/6 2:39:56
MySQL连接数优化与高并发解决方案 1. 问题现象与本质分析上周五凌晨2点,我负责维护的电商平台数据库突然告警,前端页面大面积出现"504 Gateway Timeout"错误。登录服务器查看MySQL日志,满屏都是"ERROR 1040 (HY000): Too many connections"的报错信息。这… · 2026/11/6 2:39:56
ASP FileSystemObject 目录遍历与文件列表安全实践 做企业内网系统的人大概都遇到过这种需求:后台要挂一个"文档下载"页面,把服务器某个上传目录下的子文件夹和文件全列出来,让同事点着下。需求听起来比增删改查还简单,我第一次接的时候也是这么想的——调用Scripting.Fi… · 2026/11/6 2:39:56
原生应用选型、架构与性能优化实战指南 原生应用这四个字,放在五年前是个默认选项,放在今天反而成了一个需要反复论证的选择。我最近刚把一个团队从跨平台方案迁回原生,整个过程踩的坑比预想的多得多,也让我重新梳理了一遍:什么样的项目真的该做原生… · 2026/11/6 2:38:56
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:27:59
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:28:03
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:28:07
三菱PLC数据采集到数据库的时序设计:Python+MC协议实战 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/6 1:02:11
ASIC主模式RTL设计:状态机与三态驱动的流片级实现 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/6 1:02:11
STM32嵌入式C++开发:GDB调试与Renode仿真实战指南 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/6 1:02:11
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:27:59
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:28:03
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:28:07