
简介面向C#开发者的一套H264视频解码Demo资源包适合正在学习视频处理、希望借助FFmpeg在.NET环境中实现视频解码的读者。示例围绕FFmpeg库的C#调用展开涉及解码上下文初始化、H264 NAL单元读取、YUV帧输出与保存等关键环节可作为后续开发视频播放器或进行视频转码、帧率调整等功能的基础参考。资源包以RAR压缩包形式提供大小10.86MB页面未显示包内文件总数与具体类型明细。目前已有2820人浏览学习对于想在C#中快速掌握H264解码流程的开发者具备不错的参考与借鉴价值。1. C#做H264格式视频解码核心解码能力其实不在C#侧在C#里做H264格式视频解码第一脚踩下去就能感觉到这是个偏底层的活儿。你从UDP拉回来的裸流或者从FileStream读进来的h264文件本质上是一串NAL单元没有帧头、没有长度标记直接丢给某个普通的托管库得到的不是花屏就是异常。C#本身没有原生解码器常见做法都是通过P/Invoke去调Native解码内核FFmpeg是绕不开的那一层剩下的是封装程度的问题。这篇文章面向的读者很具体要么你在写桌面播放器要么在做工业相机的实时预览要么在接网络摄像头H264裸流。整篇文章就回答四件事——用什么解、怎么把裸流喂进去、参数怎么调、遇到花屏和崩溃去哪查。2. 解码器选型对比FFmpeg.AutoGen、FFmpeg.NET还是OpenCvSharp2.1 三条路线的本质区别C#里做H264解码逃不开三选一。第一类是直接绑FFmpeg原生接口最典型的是FFmpeg.AutoGen这类绑定库第二类是FFmpeg的托管封装比如FFmpeg.NET把打开解码器、送包、收帧封装成了高级API第三类是用OpenCvSharp或Emgu CV它们内置了FFmpeg的H264解码能力解码结果直接给到Mat结构。三者的关系很直白第一类是毛坯房什么都能动但水电气得自己接第二类是精装修写着舒服出问题时黑匣子第三类是为了图像处理场景顺带做解码的解码细节藏得最深。以我做过的一个网络摄像头拉流Demo来说最初用了某个封装库调通只花了一个小时但后来发现它把关键解码帧数据又拷了一遍内存暴涨换成底层绑定后内存直接降下去一半。选型H264裸流支持拿到AVFrame难度硬解支持排错体验FFmpeg.AutoGen直接支持全程可控原生指针直接拿支持需自己挂hw_device_ctx依赖自己查源码能精准定位FFmpeg.NET支持但封装隐藏细节封装对象二次拷贝支持有限配置繁琐异常信息模糊难判断是解码还是封装问题OpenCvSharp支持但不灵活转成Mat适合做图像处理不支持硬解行为相对黑盒出问题只能换方案2.2 为什么我倾向用FFmpeg.AutoGen做视频解码最怕的不是代码写不出来而是出了花屏你根本不知道是哪一层出的问题。封装库会把错误吞掉或者给你一个看不懂的托管异常。用FFmpeg.AutoGen这种底层绑定虽然写起来痛苦但每一步都能对上FFmpeg自己的文档和源码出错时你能看到真实的AVERROR返回码能追到是解码器初始化失败、数据包不完整还是像素格式转换出错。这套方案的另一个好处是拿到AVFrame原始指针之后可以直接把YUV数据送上GPU做显示不走CPU转RGB再拷一次的老路。如果你只是偶尔解个摄像头流OpenCvSharp足够快但如果你要做的方向是低延迟播放器、多路并发解码、或者高分辨率视频实时处理那底层绑定几乎是唯一不让自己后面翻车的选择。2.3 初始化FFmpeg解码器的最小代码不管用哪种绑定核心流程都长一样找解码器、分配上下文、打开解码器。下面是FFmpeg.AutoGen风格的C#代码// 声明解码器IDH264 var codecId AVCodecID.AV_CODEC_ID_H264; // 1. 按ID找到H264解码器 var codec ffmpeg.avcodec_find_decoder(codecId); if (codec null) throw new InvalidOperationException(找不到H264解码器检查FFmpeg版本是否支持H264); // 2. 分配解码器上下文 AVCodecContext* ctx ffmpeg.avcodec_alloc_context3(codec); // 3. 设置线程数。4K以下解码一般设4-6就能吃满多核 ctx-thread_count 4; // 4. 打开解码器最终确认当前FFmpeg库能解码 int ret ffmpeg.avcodec_open2(ctx, codec, null); if (ret 0) throw new InvalidOperationException($avcodec_open2失败: {ret});这段代码是之后一切解码操作的地基。注意几个细节avcodec_find_decoder是拿FFmpeg内部注册的解码器如果FFmpeg这套DLL是精简版没编H264进去这里返回的codec就是空表现为你以为写了音视频程序实际FFmpeg根本不会解。thread_count并不是越大越好它表示解码器内部使用几个线程并行解码解码线程多了上下文切换开销反而变大我一般超过1080P就设44K才拉到6。avcodec_open2的第三个参数传字典可以用来传高级选项但H264纯软解基本不需要空着即可。初始化完之后大多数人的困惑就来了我手里的是一整段h264文件或者UDP收到的裸流怎么把它切成能送进解码器的包这就是下一章要解决的问题。3. 读懂裸H264流NAL切分与SPS/PPS/时间戳处理3.1 Annex-B格式的NAL切分H264裸流最常见的封装是Annex-B也就是每个NAL单元前面用00 00 01或者00 00 00 01做起始码。网络上收到的字节流几乎都是这种格式解码器要的是一个一个完整的包而不是一整块大文件。所以第一步是把连续字节流按起始码切分。这个动作不做对后续解出来永远是花屏。/// summary /// 按Annex-B起始码切分NAL单元。 /// onNal回调里的参数分别是NAL数据起始偏移、NAL数据长度。 /// /summary public static void SplitAnnexB(byte[] buffer, Actionint, int onNal) { int prev -1; // 上一个起始码的位置 int prevLen 0; // 上一个起始码的长度3或4字节 for (int i 0; i buffer.Length - 4; i) { int startLen 0; if (buffer[i] 0 buffer[i 1] 0 buffer[i 2] 1) startLen 3; else if (buffer[i] 0 buffer[i 1] 0 buffer[i 2] 0 buffer[i 3] 1) startLen 4; if (startLen 0) { if (prev 0) { // 上一个NAL的数据起点起始码之后的字节 int nalStart prev prevLen; int nalLen i - nalStart; if (nalLen 0) onNal(nalStart, nalLen); } prev i; prevLen startLen; } } // 收尾处理最后一个NAL if (prev 0) { int nalStart prev prevLen; int nalLen buffer.Length - nalStart; if (nalLen 0) onNal(nalStart, nalLen); } }上面这段切分逻辑值得说明两点起始码长度可能是3也可能是4切完的实际数据必须是起始码后面的内容所以代码里用prev prevLen定位数据起点而不是prev。另外如果文件开头直接就是IDR帧而没有前面的SPS/PPS这里依然能正常切出来但解码器不会认这个坑后面会展开。3.2 从NAL头识别类型SPS和PPS必须单独喂给解码器切分之后每个NAL的第一个字节就是NAL头。H264的NALU类型取低5位7代表SPS8代表PPS5代表IDR关键帧1代表普通P帧。拿到SPS和PPS之后不能把它们混在普通帧里丢给解码器它们要先进去解码器才知道画面的宽高和编码参数。int type buffer[nalStart] 0x1F; if (type 7) // SPS序列参数集包含分辨率、帧率信息 { // 交给解码器之前先缓存用于初始化 spsData buffer.Skip(nalStart).Take(nalLen).ToArray(); // 也可从SPS中解析出宽高FFmpeg打开解码器后会自己解析 } else if (type 8) // PPS图像参数集 { ppsData buffer.Skip(nalStart).Take(nalLen).ToArray(); } else if (type 5) // IDR帧 { // 发送之前如果解码器还没收到SPS/PPS先把缓存的两个参数集发过去 SendNal(ppsData); SendNal(spsData); SendNal(currentNal); }这里有个血泪经验某些摄像头在码流切换分辨率时会突然重新发一次SPS/PPS如果你的解码逻辑只在初始化时喂一次后面再遇到换分辨率解码器就会持续输出花屏甚至崩溃。常见的做法是每次拿到type 7或type 8的NAL就立刻重新送给解码器一帧参数集并调用avcodec_flush_buffers重置解码器内部状态。3.3 时间戳PTSB帧的排序靠它H264编码时存在I、P、B三种帧类型解码器输出帧的显示顺序和发送顺序不一定一致中间的排序逻辑完全依赖你送进来的AVPacket.pts。很多人在本地文件场景下不设置pts也能解出连续画面因为文件解码时FFmpeg从容器层读出来的包本来就带时间基。但当你手动切裸流丢给解码器时如果不给AVPacket设置pts解码器对B帧的处理就会乱表现为画面卡顿、丢帧、顺序跳变。AVPacket* packet ffmpeg.av_packet_alloc(); packet-data nalPtr; packet-size nalLen; // 输入给解码器之前先把时间戳按解码器的time_base换算 long ptsUs GetRtpTimestampUs(); // 从RTP包或采集时间换算成微秒 packet-pts ffmpeg.av_rescale_q(ptsUs, new AVRational { num 1, den 1000000 }, ctx-time_base); packet-dts packet-pts;这段代码看起来没什么特别但av_rescale_q很容易被忽略。解码器内部有自己的时间基ctx-time_base你手算的时间戳必须换算成解码器的时间基否则pts数值大得离谱解码器对B帧的缓冲算法会直接失效。踩过一次之后我现在的习惯是任何送入解码器的包都先调用av_rescale_q不做就不解。4. 解码管线落地send_packet/receive_frame循环与YUV转RGB4.1 解码器不是一次送一包出帧要循环收到EAGAINFFmpeg从某个版本开始推荐用avcodec_send_packet和avcodec_receive_frame这对API。它们是一组异步管道送进去一个压缩包解码器可能立刻吐出一帧也可能因为B帧缓冲要收更多包之后才吐。所以完整的流程是——送一包然后在一个循环里收帧直到返回EAGAIN表示解码器暂时没数据可出。// 送一包数据给解码器 int sendRet ffmpeg.avcodec_send_packet(ctx, packet); if (sendRet 0 sendRet ! AVERROR(EAGAIN)) { // 返回EAGAIN说明解码器内部队列满了也可能是上次帧还没收干净 return; } // 收帧循环持续收直到EAGAIN或EOF while (true) { int recvRet ffmpeg.avcodec_receive_frame(ctx, frame); if (recvRet AVERROR(EAGAIN) || recvRet AVERROR_EOF) break; if (recvRet 0) break; // 拿到一帧完整的YUV420P图像 ProcessFrame(frame); // 处理完必须手动解除引用 ffmpeg.av_frame_unref(frame); } // 包用完也必须unref否则内存只涨不降 ffmpeg.av_packet_unref(packet);这里最容易写错的是外层循环。如果avcodec_send_packet返回EAGAIN很多初学直接丢掉这个包结果就是丢帧。正确的做法是先继续收帧收到EAGAIN然后把刚才没送进去的包再送一次。另外av_frame_unref和av_packet_unref是配套动作漏掉一个就是内存泄漏一个24小时跑着的采集程序半天下来内存涨几百兆就是这两个unref没写全。我见过最隐蔽的坑是多个解码器实例共享同一个AVFrame一个unref把另一个正在用的帧数据也释放了导致偶发花屏。4.2 解码器关键参数线程数、跳帧和分辨率切换参数里优先级最高的几个分别是thread_count、skip_frame和flags2。thread_count前面说过了普通1080P建议4skip_frame是按需跳帧的API播放器做倍速预览时可以直接设AVDISCARD_NONREF跳过非参考帧解码开销直接降一半flags2里有一个AV_CODEC_FLAG2_SHOW_ALL能看到所有帧包括被丢弃的帧但通常用不着开了反而增加延迟。下面是我在某个跨平台播放器项目里用的固定参数组合ctx-thread_count 4; ctx-thread_type FF_THREAD_FRAME; // 按帧并行输出顺序由解码器保证 ctx-skip_frame AVDISCARD_DEFAULT; // 默认不跳帧如果摄像头在运行中途换了分辨率解码器的ctx-width和height会变但内部缓冲还是按旧分辨率分配这时候必须调用一次avcodec_flush_buffers(ctx)否则解码器内部残存的参考帧和新的编码参数对不上输出的画面大概率是撕裂的。这个flush操作也是切流的后悔药凡是遇到I帧前面有半个P帧丢了的情况重置一下解码器比什么都管用。4.3 把YUV420P转成RGB缩放和像素格式一起搞定FFmpeg解码出来的原生格式是YUV420P不是你屏幕上常见的RGB。想要显示、截图、或者送给OpenCV做识别都需要用sws_scale做一次像素格式转换。这个转换的开销不容小觑1080P下一帧YUV转RGB在普通CPU上要消耗几毫秒优化空间就在linesize对齐和转换标志上。// 创建转换上下文YUV420P - BGR24 var sws ffmpeg.sws_getContext( width, height, AVPixelFormat.AV_PIX_FMT_YUV420P, width, height, AVPixelFormat.AV_PIX_FMT_BGR24, SWS_FAST_BILINEAR, null, null, null); // 准备输出buffer byte[] bgrBuffer new byte[width * height * 3]; byte*[] dstData { bgrBuffer[0] }; int[] dstLinesize { width * 3 }; // 执行转换 ffmpeg.sws_scale(sws, frame-data, frame-linesize, 0, height, dstData, dstLinesize);注意frame-linesize不一定是width解码器为了对齐内存常常会在每一行末尾多填几个字节尤其在SSE/NEON优化下Y平面的linesize可能是32的倍数。所以sws_scale的输入stride必须用frame-linesize而不是自己算出来的width。输出侧dstLinesize如果设成width * 3没有对齐某些FFmpeg版本会有性能告警但不至于出错。如果想要极致性能可以转成NV12或直接在GPU端做YUV到RGB的转换这部分放到最后一章讲。5. 踩坑排查C#调用原生H264解码器最容易翻车的5个真实案例5.1 现象DllNotFoundException或者BadImageFormatException原因最直白FFmpeg.AutoGen是一个托管程序集但它P/Invoke的是本机的avcodec、avformat、avutil这些DLL。这些DLL没放进程序输出目录、或者放进去的是x86版本而你的进程跑在x64上就会在第一次调用时直接崩。解决方法是统一用64位FFmpeg并在启动时用NativeLibrary.SetDllImportResolver指定搜索目录而不是靠系统PATH。NativeLibrary.SetDllImportResolver( typeof(AVCodec).Assembly, (name, assembly, path) { string ffDir Path.Combine(AppDomain.CurrentDomain.BaseDirectory, ffmpeg); string candidate Path.Combine(ffDir, name .dll); if (File.Exists(candidate)) return NativeLibrary.Load(candidate); return IntPtr.Zero; });这个坑的隐蔽版本是FFmpeg的DLL之间互相依赖比如avcodec依赖avutil和swresample就算你只拷贝了avcodec过去运行时也会报找不到依赖。所以不要只拷一个DLL整套FFmpeg库放同一个目录并且版本匹配混搭版本极其容易在诡异的调用点崩溃。5.2 现象解码出来全是绿屏或者灰屏这是个经典翻车现场。原因通常有三个一是SPS/PPS没有先喂给解码器初始化解码时缺少必要的参数集解码器不知道宽高比二是AVPacket里塞进去的是完整文件而不是单帧NAL解码器当成一个超大包解析失败三是像素格式选错你把AV_PIX_FMT_YUV420P的数据当成了RGB去显示。解决方式是把SPS/PPS单独提取出来每次流开始或分辨率变化时先发送这两类NAL再发IDR帧。排查思路是先落盘一个解码前的数据包用FFmpeg命令行检查当前文件的编码信息确认是不是裸流本身的问题。5.3 现象avcodec_open2返回AVERROR_INVALIDDATA这个错误一来很多人就陷入死循环反复查解码器ID但找不到问题。真正原因是解码器上下文里的width和height初始为0FFmpeg在打开H264解码器时希望要么从SPS解析出宽高要么由调用方先设置好。裸流场景下你还没喂SPS解码器无从获知分辨率。解决方法是先创建一个解析器上下文av_parser_init用av_parser_parse2解析出SPS之后解码器内部会自动填充宽高数据再调avcodec_open2就不会报错了。var parser ffmpeg.av_parser_init(AVCodecID.AV_CODEC_ID_H264); // 循环调用av_parser_parse2把解析出的SPS/PPS先喂给解码器这也是为什么很多人用封装库没遇到这个问题——封装库内部默认做了parser这一步。裸绑定时这一步不能省它负责从字节流里识别出SPS并解析出宽高属于解码器能够正常初始化的前置条件。5.4 现象运行时间长了内存只涨不降这个原因就一个AVPacket和AVFrame的数据没释放。FFmpeg的av_packet_alloc和av_frame_alloc在堆上分配了对象重复使用同一对象时必须av_packet_unref和av_frame_unref。很多人写代码时会先av_frame_alloc一次然后在receive循环里重复用同一个frame对象这没问题但下一轮送包之前必须把上一帧的引用计数减掉否则内部缓冲永远不释放。写这套代码时我给自己定了一条规矩送包一个unref收帧一个unref配对出现缺一不可。5.5 现象解码一个1080P视频CPU直接跑满帧率还上不去问题通常不在解码本身而在于解码线程参数和像素转换的配置。先检查thread_count设没设默认单线程解1080P当然卡再检查sws_getContext每个画面是否重新创建了一次这是个高频耗点一定要在初始化时创建一次、复用到底。最后如果解码又转了RGB又做了缩放务必确认最终输出的分辨率是不是你真正需要的不要先解成1080P再缩到720P直接把sws_getContext的输出分辨率设成720P解码器输出后转换一步到位。这条调参经验值五帧以上亲测有效。6. 让解码帧直接上GPUD3D11纹理上传与硬件解码路线软解跑通了之后如果想做低延迟播放器或有多路预览需求下一步就是别再走CPU转换RGB的老路。解码出来的AVFrame是YUV420P三块独立平面这三块数据可以直接传给显卡。以Windows上的D3D11为例常规做法是创建三个ID3D11Texture2D分别存放Y、U、V平面或者创建一个纹理的三个subresource再用像素着色器做YUV到RGB的颜色矩阵运算。相比先把YUV在CPU端转成BGRA再拷贝到GPU这种方案省掉一次全帧拷贝和一个颜色转换过程1080P下延迟能低2到5毫秒。// 伪代码结构AVFrame的Y/U/V平面数据直接映射到D3D11纹理 D3D11_TEXTURE2D_DESC desc new D3D11_TEXTURE2D_DESC { Width frame-width, Height frame-height, Format DXGI_FORMAT_R8_UNORM, // 单通道8bit每个平面一个纹理 Usage D3D11_USAGE_DEFAULT, BindFlags D3D11_BIND_SHADER_RESOURCE }; // 把frame-data[0]、data[1]、data[2]分别UpdateSubresource到3个纹理更进一步的路线是硬件解码在解码器上下文里挂上AV_HWDEVICE_TYPE_D3D11VA或者AV_HWDEVICE_TYPE_DXVA2让avcodec_send_packet之后的解码动作全部发生在GPU上CPU侧拿到的AVFrame数据直接在显存里再配合D3D11纹理几乎零拷贝就能渲染。开启硬件解码的关键步骤是在avcodec_open2之前创建AVHWDeviceContext并赋给ctx-hw_device_ctx解码时用av_hwframe_transfer_data拉回系统内存。硬件解码的坑也很明显不是说设置了硬件设备就一定硬解成功部分FFmpeg版本在H264的一些profile下只支持特定的GPU型号失败时avcodec_send_packet返回的ret并不是直接报错而是内部切换到了软解表现是CPU占用没降但你不容易察觉。验证方法是在解码后拿avcodec_get_hw_frames_ctx检查是否真的拿到了硬件帧。如果只是做一次性的离线转码硬解收益不大但做长时间运行的实时流服务CPU降下来之后整体稳定性会好很多。我在处理这类项目时留下了一个习惯拿到新的H264源第一件事先落盘一段字节流到本地用命令行工具验证一下码流的编码层信息确认是Annex-B还是AVCC包装确认SPS里的分辨率真实值然后再写C#侧的解码代码。这个动作帮我过滤掉了九成以上“解码器不对”的误判也让后续的每一行代码都有了可对照的基准。很多玄学问题到最后其实就是输入码流不规范而不是C#调用的姿势不对。希望这篇实战拆解能帮你在C#和H264之间少走几步弯路。本文还有配套的精品资源点击获取