
简介面向C#开发者的一份FFmpeg实战示例包聚焦以API方式拉取RTMP流并在WinForms界面中实时播放解决C#侧调用FFmpeg资料稀缺、多数示例仅限C/C的痛点。借助ffmpeg.autogen与FFmpeg命名一致的特点可直接移植C/C代码并在方法前加ffmpeg.前缀调用适合具备一定C#基础、希望深入理解FFmpeg解码与渲染流程的中级开发者参考。压缩包共145个文件、约25.46MB含8个cs源码文件、16个dll动态库、3个exe可执行程序及config、resx、csproj、sln等工程配置源码与运行库齐备开箱即可编译调试。已有9707人学习下载热度较高。读者可从中获得RTMP拉流、解码与画面播放的完整工程结构以及FFmpeg二进制依赖的引用与配置思路便于对照源码理解API调用链路并迁移到自己的项目中。1. 从一条 RTMP 流说起这个 C# 项目到底能干什么手头有个摄像头或者直播推流地址想在 Windows 桌面程序里直接看到画面而不是先开一个播放器再切窗口——这是很多做 C# 上位机、监控客户端、教学演示工具的开发者都会撞上的需求。这个项目就是干这件事的用 C# 通过 FFmpeg 的 API 方式拉取 RTMP 流并播放不是简单调个命令行ffplay而是把avformat、avcodec、swscale这些库函数一层层 P/Invoke 进来自己管解封装、解码、像素格式转换和渲染。它适合两类人一类是想在 WinForm/WPF 里嵌一个轻量播放内核的开发者另一类是已经会用 FFmpeg 命令行、但想搞清楚「API 方式到底比命令行多做了什么」的熟手。读完你至少能判断这套代码能不能直接塞进自己的项目哪些地方必须改。2. 技术选型与工程骨架为什么是 P/Invoke 而不是命令行2.1 API 方式与命令行方式的真实差别命令行方式本质是起一个子进程把参数丢给ffmpeg.exe或ffplay.exe画面在它自己的窗口里你只能通过句柄去抓或者干脆全屏盖住。这种方式上手快但一旦要做「在指定 Panel 里画、叠加自己的 OSD、按帧做图像处理」就会立刻卡住——因为你拿不到解码后的AVFrame。API 方式把这条链路拆开暴露给你avformat_open_input拿到流上下文av_read_frame读到一个 packetavcodec_send_packet/avcodec_receive_frame解出一帧 YUV再sws_scale转成 BGRA最后Bitmap.LockBits写进托管位图。每一步都在你手里代价是要自己管内存、管线程、管错误码。这个项目选 API 方式核心动机就是「可控」。RTMP 拉流的延迟、断线重连、首帧等待命令行方式基本只能靠参数调API 方式可以在av_read_frame返回负值时自己决定重连策略。常见做法是把解码放在一个独立的后台线程UI 线程只负责把最新一帧画出来中间用一个「最新帧槽位」做解耦避免解码快于渲染时无限堆积。2.2 目录结构与依赖清单这类项目的工程骨架通常很朴素但每个文件职责要分清否则后面改起来就是一团。下面是我拆这类项目时最常看到的布局也是我建议你照着整理的FfmpegRtmpPlayer/ ├── Native/ │ ├── avformat.cs // avformat_open_input / av_read_frame 等声明 │ ├── avcodec.cs // 解码器相关 P/Invoke │ ├── swscale.cs // sws_getContext / sws_scale │ └── avutil.cs // av_frame_alloc / av_image_* 等工具函数 ├── Core/ │ ├── RtmpDecoder.cs // 拉流 解码主循环 │ ├── FrameConverter.cs // YUV - BGRA 转换 │ └── PlayerState.cs // 状态机Idle/Connecting/Playing/Error ├── UI/ │ └── MainForm.cs // 渲染 Panel、按钮、状态显示 ├── libs/ │ ├── avformat-*.dll │ ├── avcodec-*.dll │ ├── swscale-*.dll │ └── avutil-*.dll └── FfmpegRtmpPlayer.csproj依赖上要注意两点。第一FFmpeg 的 DLL 必须和 P/Invoke 声明的函数签名版本对得上avcodec在 4.x 到 6.x 之间有过 API 变动比如avcodec_decode_video2被废弃改用 send/receive 模型拿错版本会直接EntryPointNotFoundException。第二平台位数要统一x64 程序就放 x64 的 DLL混放会在DllNotFoundException和BadImageFormatException之间反复横跳。我一般会在 csproj 里用None Includelibs\** CopyToOutputDirectoryPreserveNewest /把 DLL 跟着输出目录走省得部署时漏文件。2.3 初始化链路从 avformat 到解码器打开拉流的第一步不是解码是「找到流并打开解码器」。这段代码是整个项目的入口写错一个参数后面全是黑屏。核心逻辑如下// 初始化网络模块RTMP 走网络必须先调 avformat_network_init(); IntPtr fmtCtx IntPtr.Zero; // 打开输入参数上下文指针、URL、输入格式(IntPtr.Zero 表示自动探测)、参数字典 int ret avformat_open_input(ref fmtCtx, url, IntPtr.Zero, IntPtr.Zero); if (ret 0) throw new Exception($open_input failed: {ret}); // 探测流信息RTMP 需要读若干包才能确定编码参数 ret avformat_find_stream_info(fmtCtx, IntPtr.Zero); if (ret 0) throw new Exception($find_stream_info failed: {ret}); // 找第一条视频流 int videoIndex av_find_best_stream(fmtCtx, AVMEDIA_TYPE_VIDEO, -1, -1, out IntPtr codec, 0); if (videoIndex 0) throw new Exception(no video stream); // 用流里的参数打开解码器 IntPtr codecCtx avcodec_alloc_context3(codec); avcodec_parameters_to_context(codecCtx, stream.codecpar); ret avcodec_open2(codecCtx, codec, IntPtr.Zero); if (ret 0) throw new Exception($open2 failed: {ret});avformat_open_input的第三个参数传IntPtr.Zero让 FFmpeg 自己根据 URL 前缀判断是 rtmp 还是 http除非你要强制指定协议否则不要硬塞。av_find_best_stream比手动遍历nb_streams稳它会跳过封面图、字幕这类干扰流。avcodec_parameters_to_context这一步不能省解码器的宽高、像素格式、extradata 都从这里来漏了它avcodec_open2大概率返回-22Invalid argument。参数说明上avcodec_open2的第三个参数是选项字典一般传IntPtr.Zero需要开多线程解码时再传threads选项。3. 解码主循环与渲染一帧画面怎么从网络走到 Panel3.1 av_read_frame 到 avcodec_receive_frame 的完整链路API 方式的核心是一个循环读包、送包、收帧。FFmpeg 新版的 send/receive 模型把「送」和「收」拆成两个函数好处是解码器内部有缓冲一次送包可能收出多帧也可能一帧都收不出B 帧重排。主循环长这样IntPtr packet av_packet_alloc(); IntPtr frame av_frame_alloc(); while (running) { int ret av_read_frame(fmtCtx, packet); if (ret 0) { // AVERROR_EOF 或网络错误交给重连逻辑 HandleReadError(ret); break; } if (packet.stream_index videoIndex) { // 送包返回 0 或 EAGAIN 都正常 ret avcodec_send_packet(codecCtx, packet); if (ret 0 ret ! AVERROR_EAGAIN) break; // 收帧一次送包可能对应多帧要循环收 while (true) { ret avcodec_receive_frame(codecCtx, frame); if (ret AVERROR_EAGAIN || ret AVERROR_EOF) break; if (ret 0) break; // 拿到一帧转成 BGRA 交给渲染 RenderFrame(frame); av_frame_unref(frame); } } av_packet_unref(packet); }这里最容易翻车的是「收帧只收一次」。很多人写完avcodec_receive_frame拿到一帧就去读下一个包结果 B 帧场景下画面卡顿、丢帧因为解码器缓冲里还有帧没取出来。正确做法是内层while一直收到EAGAIN为止。另一个细节是av_packet_unref必须每轮调用否则 packet 内部引用计数泄漏跑几分钟内存就上去了。AVERROR_EAGAIN在 C# 里通常定义成-11但不同 FFmpeg 版本常量值可能不同建议在 native 声明里用const int AVERROR_EAGAIN -11;并注释来源版本。3.2 sws_scale 做 YUV 到 BGRA 的转换解码出来的AVFrame是 YUV420P大多数 RTMP 推流都是这个格式GDI 的Bitmap要的是 BGRA。中间这层转换用sws_scale上下文只创建一次别每帧都建// 只创建一次源宽高、源格式、目标宽高、目标格式、缩放算法 IntPtr swsCtx sws_getContext( srcW, srcH, frame.format, dstW, dstH, AVPixelFormat.AV_PIX_FMT_BGRA, SWS_BILINEAR, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero); // 每帧把 frame 的 data/linesize 转到目标缓冲区 byte[] buffer new byte[dstW * dstH * 4]; fixed (byte* pBuf buffer) { byte*[] dstData { pBuf }; int[] dstLinesize { dstW * 4 }; sws_scale(swsCtx, frame.data, frame.linesize, 0, srcH, dstData, dstLinesize); }sws_getContext的缩放算法参数SWS_BILINEAR是画质和速度的折中如果源和目标是同尺寸其实可以用SWS_POINT更快但画质会略糙。frame.data和frame.linesize是数组指针P/Invoke 声明时要写成IntPtr[]或IntPtr*这里用IntPtr[]更省事。转换完的buffer通过Bitmap.LockBits拷进位图再Graphics.DrawImage到 Panel整条链路就通了。注意swsCtx在流分辨率变化时要重建否则会花屏——这是 RTMP 推流端动态改分辨率时的经典坑。3.3 渲染线程与 UI 线程的解耦解码线程不能直接碰 UI 控件WinForm 里跨线程操作 Panel 会抛InvalidOperationException。常见做法是用一个ConcurrentQueueBitmap或者更简单的「最新帧槽位」解码线程把转好的 Bitmap 存到一个volatile字段UI 用System.Windows.Forms.Timer每 33ms 取一次画出来。槽位方案会丢帧但延迟最低适合直播队列方案不丢帧但解码快于渲染时会积压延迟越滚越大。我一般默认用槽位只有在做录像回放这种「不能丢帧」的场景才换队列。渲染时记得Interlocked.Exchange换出旧帧并Dispose不然 GDI 对象泄漏任务管理器里 GDI 对象数一路涨到一万就崩。4. 避坑与排查RTMP 拉流最常见的五类翻车4.1 现象程序启动就 DllNotFoundException原因几乎都是 DLL 没跟着输出目录走或者位数不匹配。先看bin\Debug\net8.0-windows\下有没有avformat-*.dll这一组文件再看 csproj 的PlatformTarget是 x64 还是 AnyCPU。AnyCPU 在 64 位系统上跑 64 位进程但如果你引的是 32 位 DLL就会报BadImageFormatException而不是DllNotFoundException两者要分清。解决方式是把libs目录整个拷到输出目录并在 P/Invoke 的DllImport里写不带路径的文件名让系统按输出目录找。4.2 现象连上了但一直黑屏日志里 find_stream_info 超时RTMP 服务器推流后需要一点时间才有数据avformat_find_stream_info默认会阻塞读若干包。如果服务器还没开始推或者 URL 里带了错误的 app/stream 名就会卡在这里直到超时。排查时先把 URL 丢给命令行ffprobe跑一遍确认能出流信息再在代码里给avformat_open_input传一个timeout选项字典单位微秒别让它无限等。另外 RTMP 的 URL 格式是rtmp://host/app/stream少一段或者多一个斜杠都会连不上这个错误码通常是-2No such file or directory别被字面意思骗了。4.3 现象播放几秒后画面卡住CPU 占用飙高典型是收帧循环写错avcodec_receive_frame返回EAGAIN后没有跳出内层循环或者外层av_read_frame在EAGAIN时没有继续读。还有一种可能是sws_scale的目标缓冲区每帧都new byte[]GC 压力大导致卡顿。解决方式是缓冲区复用swsCtx和byte[]都提到循环外只在分辨率变化时重建。CPU 飙高则要检查是不是在 UI 线程里做了解码把解码挪到Task.Run或独立Thread里。4.4 现象内存持续上涨跑一晚上 OOM九成是AVFrame/AVPacket没unref或者Bitmap没Dispose。av_frame_alloc出来的 frame 在每次receive_frame后要av_frame_unrefpacket 每轮av_read_frame后要av_packet_unref。托管侧槽位换出来的旧 Bitmap 必须DisposeGraphics对象用using包住。排查时可以在任务管理器里看「句柄数」和「GDI 对象」如果 GDI 对象只涨不降基本就是 Bitmap 泄漏。4.5 现象断网后程序不恢复必须重启av_read_frame返回负值后如果直接break出主循环线程就结束了没有任何重连逻辑。正确做法是把「打开流 解码循环」包在一个外层while里读失败后avformat_close_input释放旧上下文Thread.Sleep一两秒再重新avformat_open_input。重连次数要设上限并做退避否则服务器挂了会疯狂重连把本机端口耗光。状态机里加一个Reconnecting状态UI 上给个提示比无声卡死体验好得多。5. 二次开发把播放内核抽出来复用的几个改法5.1 把解码器封装成可替换的组件原始代码往往把「拉流 解码 渲染」揉在一个类里二次开发时想换个渲染后端比如从 GDI 换到 Direct2D 或 OpenGL就得动主循环。我的改法是定义一个IFrameSink接口只暴露void OnFrame(IntPtr bgra, int width, int height, int stride)解码器只管转换完往 sink 里丢渲染实现随便换。这样同一套解码逻辑可以同时服务 WinForm 预览、录像落盘、AI 推理三个下游互不干扰。接口定义如下public interface IFrameSink { // bgra 指针在回调返回后即失效需要拷贝的自行拷贝 void OnFrame(IntPtr bgra, int width, int height, int stride); }解码器内部持有IFrameSink列表RenderFrame里遍历调用。注意回调里不要做耗时操作否则会拖慢解码线程重活丢到线程池。5.2 延迟优化从缓冲区到渲染的三处可调点RTMP 直播对延迟敏感能调的地方有三处。第一avformat_open_input的选项里加fflagsnobuffer和flagslow_delay减少解封装缓冲第二解码器开threads1或thread_typeslice多线程解码虽然吞吐高但会引入帧重排延迟第三渲染用槽位而不是队列并且 Timer 间隔压到 20ms 左右。这三处一起调端到端延迟能从两三秒压到几百毫秒。但要注意low_delay对某些编码器不友好可能出现花屏调完必须实际推流验证别只看代码。5.3 验证方法用本地推流做闭环测试改完代码怎么确认没改坏最稳的办法是本地起一个推流端用 FFmpeg 命令行把一段测试视频循环推到本地 RTMP 服务然后你的 C# 程序去拉。推流命令大致是ffmpeg -re -stream_loop -1 -i test.mp4 -c copy -f flv rtmp://127.0.0.1/live/test-re保证按实时速率推-stream_loop -1循环。这样你能稳定复现首帧等待、断流重连、分辨率切换这些场景比拿公网流调试可控得多。我一般还会在推流端故意中途CtrlC再重启专门验证重连逻辑。改造点改动位置验证方式换渲染后端实现 IFrameSink本地推流看画面是否正常降延迟open_input 选项 槽位渲染秒表对比首帧时间断线重连外层重连循环推流端中断再恢复5.4 一个我踩过的坑早期我把sws_scale的目标缓冲区按dstW * dstH * 4分配但dstLinesize传的是dstW * 4看起来没问题直到遇到宽度不是 4 的倍数的流sws_scale内部按对齐写越界堆上数据被踩程序随机崩溃调试器还指不到出错行。后来我改成用av_image_get_buffer_size算大小、av_image_fill_arrays填 linesize再也没出过这事。从那以后我每次接新的 FFmpeg 封装都强制先用av_image_*系列把缓冲区对齐走一遍不再手算字节数。希望这个项目拆下来能帮你少走我当年那段弯路。本文还有配套的精品资源点击获取