ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

FFmpeg av_init_packet详解:AVPacket内存模型与引用计数避坑指南

FFmpeg av_init_packet详解:AVPacket内存模型与引用计数避坑指南 前几天群里有个人贴了一段解码代码问为什么偶现段错误。代码里就是定义一个AVPacket pkt没有初始化直接丢给av_read_frame。很多人第一反应说“加个av_init_packet试试”结果真就好了。但问题远没结束——后来他又在后面手动给pkt.data赋了个指针程序跑起来不是double free就是内存泄漏。这件事是我一直想写一篇av_init_packet详解的原因FFmpeg里几乎所有音视频开发教程都会让你在用到AVPacket前先av_init_packet但很少有人把这行代码背后的内存模型、引用计数机制、以及它为什么不建议在新代码里继续用讲清楚。这篇文章不打算贴一堆官方文档只讲我实际写代码、查崩溃、看源码总结出来的干货。读完你至少能避开一半以上跟AVPacket生命周期相关的崩溃和泄漏。1. av_init_packet为什么存在先处理栈上AVPacket的“垃圾状态”在C语言里玩FFmpeg绕不开AVPacket这个结构体。它对应一条音视频数据包可能是解码后的一帧输出可能是压缩后的一段流数据也可能是封装层读出来的一包原始数据。很多初学者上来就写AVPacket pkt; av_read_frame(ifmt_ctx, pkt);这样写在新旧版本FFmpeg里都是雷。AVPacket pkt只是声明一个栈上变量C语言不会帮你把内存清零所以pkt.buf、pkt.data、pkt.size这些字段都是未知垃圾值。av_read_frame底层会做av_packet_unref(pkt)来清理传入的包而av_packet_unref看到pkt-buf是个野指针会直接尝试对随机地址做引用计数释放——这基本等于自杀。运气差就是段错误运气“好”就变成某个偏僻字段被改掉隔几秒才崩。av_init_packet就是为解决这种不确定性而生的。它的职责专一明确把一个AVPacket变量恢复成“未持有任何资源、所有字段都有确定值”的出厂状态。你可以把它看作是AVPacket的构造函数。注意它不会分配任何内存也不会做任何引用计数操作它只负责“摆平”结构体本身的字段。这就要解释为什么av_packet_alloc能成为官方推荐方案。av_packet_alloc分配一个AVPacket并初始化它帮你把“声明变量”和“初始化变量”两步合成一步而且返回的是堆上指针方便跨函数传递。但理解av_init_packet仍然有价值老教程、老项目、某些嵌入式代码里你还得跟它打交道如果不懂它的初始化边界很容易把它当成万能的清理函数来用反而埋雷。1.1 不初始化直接用的现场还原我们先看一个具体崩溃现场。代码类似这样AVPacket pkt; if (av_read_frame(ifmt_ctx, pkt) 0) { process_packet(pkt); av_packet_unref(pkt); // 这里炸了 }实际表现是double free或者invalid pointerGDB回溯栈通常在av_buffer_unref附近。原因就是pkt栈上垃圾值里的buf正好指向了一块“看起来像AVBufferRef”的内存地址av_buffer_unref就勇敢地去把那个地址的引用计数减一发现不匹配直接abort。更隐蔽的一种情况是pkt-buf刚好是NULL但pkt-data是个野指针av_read_frame内部在没有清理干净的情况下往野地址写数据让你感觉是“随机崩溃”。在老版本FFmpeg里av_read_frame文档明确写着传入的AVPacket必须已经通过av_init_packet初始化。原因就是它内部对传入包先做清理如果包是垃圾状态清理就变成对垃圾地址的操作。新版av_read_frame虽然对分配好的packet有额外保护但官方示例几乎清一色改成av_packet_alloc本质还是同一个逻辑先有干净状态再往里面填数据。1.2 av_init_packet给每个字段的出厂设置av_init_packet的源码在不同版本略有差异但骨架基本是这样void av_init_packet(AVPacket *pkt) { pkt-pts AV_NOPTS_VALUE; // 没有时间戳 pkt-dts AV_NOPTS_VALUE; pkt-pos -1; pkt-duration 0; pkt-flags 0; pkt-stream_index 0; pkt-data NULL; pkt-size 0; pkt-side_data NULL; pkt-side_data_elems 0; pkt-buf NULL; // 新版本还包含 pkt-time_base (AVRational){0, 1}; pkt-opaque NULL; pkt-opaque_ref NULL; }这些字段的作用后面会细讲这里先明确几个关键点pts/dts被设为AV_NOPTS_VALUE这是一个int64_t的特殊值INT64_MIN表示“没有时间戳”而不是0。因为0在时间基里可能是合法值用INT64_MIN才能区分“无效”和“第0个时间刻度”。pos设为-1表示“不知道在文件里的字节位置”。data、size、buf全部归零意味着这个包不持有任何媒体数据缓冲区。side_data和side_data_elems归零意味着没有附加元数据。一句话总结调用av_init_packet之后你得到一个干净的、空的、可以安全传给解复用器或解码器的AVPacket。它不等于av_new_packet后者还会额外分配一块数据缓冲区。2. 从空包到有数据的包data、size、buf三者怎么配合av_init_packet只管清空不管填充。你拿到一个干净包后最常见的操作是从文件读包或从编码器取包这些场景FFmpeg内部会帮你在pkt-buf上挂引用。但如果你要手动构造一个AVPacket问题就来了从空包到有数据的包到底要改哪几个字段先看三个最关键成员uint8_t *data指向媒体数据起始地址。解码器、编码器、复用器和滤镜都靠这个指针读数据。int sizedata指向的有效数据长度单位是字节。AVBufferRef *buf引用计数容器。buf管理一块内存的所有权data通常指向buf-data。三者关系是data和size描述“这段数据在哪里、有多长”buf决定“这段内存归谁管、什么时候释放”。如果你只设置了data和size让buf保持NULLFFmpeg的引用计数体系就会认为这个包不拥有内存最后av_packet_unref不会碰你的data。在某些一次性处理场景下这可能“能跑”但一旦包被跨线程传递、多次引用、或者被解码器持有就必然出现没人释放或重复释放的乱局。2.1 理解空包、新包和已分配包的区别av_init_packet(pkt)之后是空包buf NULL data NULL size 0。空包是“状态干净但没有数据”。如果你需要一块指定大小的缓冲区正确做法是AVPacket pkt; av_init_packet(pkt); av_new_packet(pkt, 4096);av_new_packet内部会先做一次av_init_packet语义然后分配4096字节的buffer并挂到pkt-buf上同时把pkt-data指向buf-data。这时包是“已分配包”数据区定了但里面是垃圾内容你可以往里写一帧H264、一帧PCM或任何封装数据。还有一种情况是你已经有一段buffer想“托管”给AVPacket可以使用AVPacket pkt; av_init_packet(pkt); uint8_t *my_data av_malloc(4096); memcpy(my_data, raw, 2024); int ret av_packet_from_data(pkt, my_data, 2024);av_packet_from_data会创建一个AVBufferRef来接管my_data。从此以后你不再手动av_free这块内存而是通过av_packet_unref(pkt)来释放。这正是很多旧代码容易写错的地方直接pkt.data av_malloc(...)然后送进解码器解码器消费完就av_packet_unref结果内存迟迟不释放因为pkt.buf还是NULL引用计数机制根本不认识这块内存。2.2 为什么buf才是生命周期管理的主角buf是AVBufferRef结构体的指针里面保存了底层引用计数。当多个AVPacket共享同一份媒体数据时它们复制的是AVBufferRef指针而不是拷贝整块内存。调用av_packet_ref时新的包通过av_buffer_ref让引用计数加一调用av_packet_unref时av_buffer_unref让引用计数减一。只有当计数归零底层内存才会真正释放。这个设计决定了一个包是否“拥有”数据不是看data指针而是看buf。av_init_packet把buf置NULL等于告诉FFmpeg“这个包目前没有数据所有权”。后续所有API都围绕这个约定工作。因此我强烈建议只要你的代码里出现了“给pkt.data手动赋地址”的操作就要警惕。除非你能保证该地址的生命周期一定长于包并且不会经过av_packet_unref否则请通过av_packet_alloc/av_packet_from_data/av_new_packet来建立正确的buf关系。这些API是FFmpeg给你的工具箱不是摆设。2.3 清空一个包的正确做法不是av_init_packet很多开发者的习惯是用完一个包再av_init_packet一下准备下次用。这在包未持有任何资源时没问题但如果包已经带着buf和引用计数这样做的结果是你把pkt-buf直接改成NULL原来那个引用没有经过av_buffer_unref的减一操作等于“放生”了一块内存次数多了就是内存泄漏更糟的是如果别的包还持有同一个AVBufferRef你只是丢掉这个包自己的一份引用到别人的包应该释放时计数可能不准确。正确的清空操作是av_packet_unref它会把buf、side_data、opaque_ref等所有内部资源按引用计数规则逐一释放然后让包回到类似av_init_packet的初态。这才是“重置包”的完整语义。3. 解码、编码、传包av_init_packet在不同场景的正确姿势前面都是理论这节给实际用法。不同FFmpeg版本对AVPacket生命周期的要求不一样但理解av_init_packet可以帮助你平滑过渡。3.1 老式解码循环为什么要求手动初始化在FFmpeg 4.0以前官方示例里很常见的模式AVPacket pkt; av_init_packet(pkt); while (av_read_frame(ifmt_ctx, pkt) 0) { avcodec_send_packet(dec_ctx, pkt); // 取出解码结果... av_packet_unref(pkt); }av_init_packet(pkt)必须在每次循环开始前调用吗不一定。第一次循环前调用一次后续每次av_packet_unref会把包重置回初始状态所以循环里不需要再av_init_packet。但如果你在循环内对包做了浅拷贝或者在某个分支提前返回了下一次循环时包的状态可能是旧的那就必须重新初始化。老版本av_read_frame对传入包的依赖更强它可能直接在一个未初始化的AVPacket上填充data和size而不会先清理原有buf。所以大家约定俗成地先av_init_packet。新版本里av_read_frame内部会先av_packet_unref(pkt)再做实际读取这意味着传入包如果完全没初始化反而会在这一步崩溃。这也是为什么我鼓励新代码直接用av_packet_alloc而不是手动栈上变量加av_init_packet前者能保证包永远是“已分配且已初始化”的状态。3.2 新工程推荐的生命周期主线新代码建议这样写AVPacket *pkt av_packet_alloc(); if (!pkt) return -1; while (av_read_frame(ifmt_ctx, pkt) 0) { if (pkt-stream_index video_stream_index) { avcodec_send_packet(dec_ctx, pkt); while (avcodec_receive_frame(dec_ctx, frame) 0) { // 处理帧 } } av_packet_unref(pkt); } av_packet_free(pkt);av_packet_alloc做了两件事从堆上分配AVPacket结构体空间然后完成与av_init_packet等价的初始化。av_packet_unref(pkt)负责用完释放内部资源同时把包带回初始空状态。av_packet_free(pkt)在最后释放结构体本身。使用这个模式后你基本不再需要手动调用av_init_packet因为av_packet_alloc已经把初始化做完了。但如果你看到老代码里的av_init_packet不要觉得它错得离谱它只是在老API环境下的标准操作。3.3 编码输出侧的坑receive_packet不给你免罪符编码侧更容易踩坑。avcodec_receive_packet会把输出包写到传入的AVPacket里但它同样要求这个包处于干净状态。最典型的错法AVPacket pkt; // 没有av_init_packet也没有av_packet_alloc if (avcodec_receive_packet(enc_ctx, pkt) 0) { fwrite(pkt.data, 1, pkt.size, out); av_packet_unref(pkt); // 崩溃 }avcodec_receive_packet内部会先对传入包执行清理来保证写数据时没有旧引用干扰。所以传入包不干净就会在清理阶段崩溃。正确写法是int ret avcodec_receive_packet(enc_ctx, pkt); if (ret 0) { // 立即消费pkt.data或者av_packet_ref一份给别处用 ... av_packet_unref(pkt); }这里有个容易被忽略的细节avcodec_receive_packet成功拿到的pkt里数据是挂在引用计数buffer上的。如果你只是把pkt这个指针变量本身存到队列里等循环下一轮av_packet_unref之后队列里那个指针指向的缓冲区已经释放了。所以跨函数、跨线程传递时要么提前av_packet_ref一份新的引用要么用av_packet_move_ref把所有权搬走。这与av_init_packet的“只清字段”完全是两码事。4. 我排查过的几个“灵异崩溃”AVPacket生命周期误用实录下面分享几个我实际遇到、或者帮人排过的崩溃都是围绕av_init_packet和AVPacket生命周期。4.1 浅拷贝后av_init_packet引用计数直接断裂某次一个转码服务偶发崩溃排查后发现代码里是这样的AVPacket src, dst; av_init_packet(src); av_read_frame(ifmt_ctx, src); // src.buf非空 dst src; // 浅拷贝src.buf和dst.buf指向同一个AVBufferRef av_init_packet(dst); // 错误dst.buf被置NULL问题在于dst src只是拷贝了指针。src.buf和dst.buf本来指向同一个引用计数对象二者共享一份数据。如果只是想复制包应该用av_packet_ref(dst, src)它会创建新的引用并让计数加一。而av_init_packet(dst)把这个引用凭空扔掉后继src使用时计数已经对不上最后释放就会出问题。更微妙的是有些库函数内部可能对传入的AVPacket做了一次av_init_packet如果你同时在外层也做了一次浅拷贝多个字段就会被覆盖。排查这种问题最直接的方式是在av_init_packet处打断点看它到底作用在哪个包上以及这个包的buf是否为空。4.2 自己malloc给data却没走av_packet_from_data我见过很多从老C代码迁移过来的工程还在用这种写法AVPacket pkt; av_init_packet(pkt); pkt.data (uint8_t*)malloc(1024); pkt.size 1024; // 不设置pkt.buf avcodec_send_packet(dec_ctx, pkt); ... av_packet_unref(pkt);第一眼看上去逻辑没问题初始化了有数据用完了释放。但实际运行是内存持续增长或者某些怪异行为。原因就是pkt.buf NULLav_packet_unref不检查data是否由malloc分配它只负责释放buf指向的AVBuffer。所以1024字节永远不会释放。这也回答了很多人“为什么我调了av_init_packet还泄漏”的疑问。正确的托管方式前面已经写过av_packet_from_data会为你的指针建立AVBufferRef。当然你也可以分配内存后用av_buffer_create手动包装但较少用。我建议一律用av_packet_from_data它能处理data NULL size 0的边界情况并保证buf不会被误设。4.3 对非空包再次av_init_packet旧buffer被“放生”还有一种模式也很常见在循环里每次读取前都调用一次av_init_packet就像这样AVPacket pkt; while (av_read_frame(ifmt_ctx, pkt) 0) { ... av_init_packet(pkt); // 以为在“为下一次读包做准备” }这同样有问题。第一次av_read_frame后pkt.buf已经持有引用。第二次循环开头再av_init_packet时它把buf强行置NULL旧引用没有释放造成泄漏。这个泄漏不会立刻暴露但跑一夜转码任务后内存占用可能翻几倍。更安全的是在每轮循环结尾调用av_packet_unref(pkt)它会释放旧buf并把包重置为空状态。这样下一轮循环开头的包就是干净的无需再手动av_init_packet。顺便说一句av_packet_unref之后的AVPacket状态和av_init_packet后的状态几乎一致所以它才是更完整的“重置释放”函数。4.4 时间戳字段的手工初始化不要只盯着data最后补充一个常见问题有人只用av_init_packet初始化结构体然后自己填data和size却忘记填pts、dts、duration。结果编码出来的流时间戳全部变成AV_NOPTS_VALUE播放器要么快进要么卡死。av_init_packet只是把字段设为默认值不是帮你生成正确的时间戳。你需要在业务代码里根据实际帧率、采集时间或解码顺序来赋值。这个坑不属于崩溃但属于“看起来正常、实际没法用”。5. av_init_packet还该不该用我的取舍与替代方案FFmpeg 4.0之后av_init_packet被官方标记为deprecated弃用新代码推荐用av_packet_alloc与av_packet_unref的组合。但现实世界不是一纸公告就能瞬间切换的很多老库、嵌入式平台、第三方封装还在大量使用它。我的建议是读懂它但尽量不依赖它。5.1 我在哪些场景仍然会手动av_init_packet第一种场景是嵌入式或低内存环境。你不想每次都在堆上av_packet_alloc一个对象而是希望把AVPacket作为某个大结构体的字段直接嵌入typedef struct { AVPacket pkt; // 其他业务字段 } MyQueueNode;这种场景AVPacket不是独立的堆对象无法用av_packet_alloc管理只能手动初始化。在节点入池时调用av_init_packet(node-pkt)配合av_packet_unref维护生命周期这是完全合理的用法。第二种场景是“临时包”。有时候你只是想搭一个临时包给滤镜或复用器例如把一个已经持有数据的buffer包装成包送出去但又不想引入堆分配AVPacket tmp; av_init_packet(tmp); av_packet_from_data(tmp, data, size); // 用完unref即可这种情况下栈上AVPacket加av_init_packet是最轻量的选择。注意av_packet_from_data要求传入的包已经初始化这里av_init_packet正是为了满足这个前提。第三种场景是兼容老代码。项目里大量使用FFmpeg 3.x接口且编码器回调要求传入已完成av_init_packet的包你没有动力全面重构。这时只要把“初始化、引用、释放”的边界搞清楚继续使用也没有原则性问题只是编译时会有弃用警告未来切版本要预留时间。5.2 av_packet_move_ref比拷贝unref更高效的所有权转移如果你需要把已经持有的包转交给另一个包av_packet_move_ref(pkt, src)是最安全的方式。它把src的所有buffer指针整体搬给pkt然后把src清空。整个过程中引用计数不需要增减避免了av_packet_ref加av_packet_unref的两次操作。对比一下三种常见操作操作行为适用场景av_packet_ref(dst, src)复制引用并增加计数src不变dst拥有独立引用需要两个包同时生效av_packet_move_ref(dst, src)所有权整体转移src被清空把包交给队列/线程原处不再用av_packet_copy_props只复制pts/dts等描述字段不拷贝data/buf拆分side_data或时间戳属性时av_init_packet在这个演进里的位置是它作为“结构体重置”的基础操作被av_packet_alloc、av_packet_unref间接使用。所以你仍然可以把它理解为AVPacket生命周期的基石只是在新代码里它不再站在前台。5.3 给新人的生命周期主线如果你刚接触FFmpeg我建议直接把下面这条主线背下来不要被老教程带偏创建AVPacket *pkt av_packet_alloc();塞数据交给av_read_frame或avcodec_receive_packet它们会用内部buf填充拷贝/转移需要保留包时用av_packet_ref或av_packet_move_ref释放/重置每轮循环结束调用av_packet_unref(pkt)最后销毁退出时av_packet_free(pkt)在这条主线里av_init_packet只是隐藏在底层的初始化器。你明白它的存在但不一定需要亲手调它。真正上手调它的时候往往是写自定义数据源、包管理器、或者嵌入式队列那时这篇详解里讲的“buf/data/size必须配套”“浅拷贝即引用共享”“清空不等于释放”这些认知才是你排查问题的底气。
返回列表