一次转码事故:CPU 600%
2024年3月,我们视频中台接到告警:某路RTSP拉流转码服务的CPU使用率冲到600%,持续5分钟,16路并发直接拖垮了8核生产机。排查后发现罪魁祸首是FFmpeg软解H.264高码率流时,avcodec_receive_frame返回EAGAIN导致忙循环。这个事故让我决定彻底啃一遍FFmpeg的编解码源码——为什么receive_frame会反复失败?为什么软解扛不住4K?硬解是怎么绕过CPU的?
本文基于FFmpeg 6.1.1(2023年11月发布),分析工具:perf、gdb、valgrind。环境:Ubuntu 22.04 / Linux 5.15 / Intel i7-10700 / NVIDIA T4。
问题拆解:编解码流程到底干了什么
一个标准的FFmpeg解码流程,代码上长这样:
// 伪代码,真实场景
AVFormatContext *fmt_ctx = NULL;
avformat_open_input(&fmt_ctx, "rtsp://...", NULL, NULL);
avformat_find_stream_info(fmt_ctx, NULL);
AVCodecContext *dec_ctx = avcodec_alloc_context3(decoder);
avcodec_parameters_to_context(dec_ctx, stream->codecpar);
avcodec_open2(dec_ctx, decoder, NULL);
while (av_read_frame(fmt_ctx, &pkt) >= 0) {
if (pkt.stream_index == video_stream_index) {
avcodec_send_packet(dec_ctx, &pkt);
while (avcodec_receive_frame(dec_ctx, frame) == 0) {
// 拿到了解码后的画面
}
}
}
这段代码能跑,但99%的人不知道背后发生的四件事:
- avformat_open_input只解析容器,不打开解码器
- avcodec_open2才真正找到解码器实现(如H.264的h264_decode)
- avcodec_send_packet把压缩数据包送入解码器内部缓冲
- avcodec_receive_frame可能一次返回0拿一帧,也可能EAGAIN表示需要更多数据
问题集中在第四步:receive_frame为什么返回EAGAIN? 源码里藏着一个状态机。往下拆。
方案对比:软解 vs 硬解
在写代码前,先看两条路线怎么选。
| 维度 | 软解(CPU) | 硬解(NVDEC/QSV) |
|---|---|---|
| 实现位置 | libavcodec/x86/h264dec.c | libavcodec/nvdec/h264_specific_parser.c |
| 依赖硬件 | 无 | GPU/核显,需要驱动和CUDA/VAAPI |
| 4K H.264 1080p 码率 | CPU占用≥250% | CPU占用≤30%,GPU峰值35% |
| 延迟 | 30-50ms(受OS调度影响) | 10-20ms(硬件DMA直传) |
| 容器兼容性 | 全兼容 | AV_PIX_FMT_CUDA等特殊格式,处理需cudaMemcpy |
| 代码复杂度 | 低,无需特殊处理 | 中,需处理硬件帧格式转换 |
两个方案不只是性能差异,核心分歧在于解码器拿到的AVPixelFormat不同。软解输出YUV420P,硬解输出NV12/CUDA。这直接影响后续缩放、推流、AI推理的代码路径。
我的结论:
- 纯转码/推流场景,无脑选硬解,CPU留给业务逻辑
- 需要拿像素数据做AI识别,优先软解,省去设备间拷贝
- 混合场景(转码+抽帧),硬解+硬件缩放最省
源码分析:解码链路的核心结构
FFmpeg解复用后,压缩数据从AVPacket走到AVFrame,中间经过三层:
- AVCodec:解码器描述符,包含codec_id、decode回调指针
- AVCodecContext:编解码上下文,持有解码器实例、内部缓冲、输出参数
- AVCodecInternal:内部状态机(buffer_frame、buffer_pkt等)
关键数据结构定义在libavcodec/avcodec.h和internal.h:
// libavcodec/avcodec.h 第1287行(FFmpeg 6.1.1)
typedef struct AVCodec {
const char *name;
enum AVMediaType type;
enum AVCodecID id;
int (*init)(AVCodecContext *avctx);
int (*encode_sub)(AVCodecContext *, uint8_t *buf, int buf_size,
const struct AVSubtitle *sub);
int (*encode2)(AVCodecContext *avctx, AVPacket *avpkt,
const AVFrame *frame, int *got_packet_ptr);
int (*decode)(AVCodecContext *avctx, AVFrame *frame,
int *got_frame_ptr, const AVPacket *avpkt);
int (*close)(AVCodecContext *avctx);
// ...
} AVCodec;
// libavcodec/internal.h 第680行
typedef struct AVCodecInternal {
// 发送缓冲:保存尚未送入解码器的AVPacket
AVPacket *buffer_pkt;
// 接收缓冲:保存解码器输出但未被取走的AVFrame
AVFrame *buffer_frame;
// 内部解码器实例(如H.264的H264Context)
void *priv_data;
// 关键:是否已经drain(冲刷)
int draining;
// padding 和 AVOption 等略
} AVCodecInternal;
解码器的注册依赖宏:
// libavcodec/h264dec.c 第927行
const FFCodec ff_h264_decoder = {
.p.name = "h264",
.p.type = AVMEDIA_TYPE_VIDEO,
.p.id = AV_CODEC_ID_H264,
.p.capabilities = AV_CODEC_CAP_DR1 | AV_CODEC_CAP_DELAY |
AV_CODEC_CAP_HARDWARE,
.p.priv_class = &h264_class,
.priv_data_size = sizeof(H264Context),
.init = h264_decode_init,
.decode = h264_decode_frame,
.close = h264_decode_end,
.caps_internal = FF_CODEC_CAP_INIT_CLEANUP,
};
注意capabilities字段里的AV_CODEC_CAP_DELAY:H.264解码器有DPB(解码图像缓冲),最多缓存16帧。这意味着解码器可能拿到5个包,但最后一帧要等到b帧重排后才输出。这就是receive_frame返回EAGAIN的根本原因之一——数据还不够凑出一帧输出。
avcodec_send_packet -> avcodec_receive_frame 源码走读
打开libavcodec/decode.c,find线看主线调用:
send_packet 入口
// libavcodec/decode.c 第1690行(FFmpeg 6.1.1)
int attribute_align_arg avcodec_send_packet(AVCodecContext *avctx,
const AVPacket *avpkt)
{
AVCodecInternal *avci = avctx->internal;
int ret;
// 1. 基本校验:编码器不可用、draining状态直接拒绝
if (!avcodec_is_open(avctx) || !av_codec_is_decoder(avctx->codec))
return AVERROR(EINVAL);
// 2. 如果传入NULL,表示开始drain(冲刷)
if (!avpkt && !avci->draining) {
avci->draining = 1;
// 某些解码器(如NVDEC)需要调用flush回调
if (avctx->codec->flush)
avctx->codec->flush(avctx);
}
// 3. 复制packet到内部缓冲,因为外部packet可能是栈上或复用的
if (avpkt) {
ret = packet_alloc(&avci->buffer_pkt);
if (ret < 0)
return ret;
av_packet_move_ref(avci->buffer_pkt, avpkt);
}
// 4. 传递到内部真正的解码处理
ret = decode_simple_internal(avctx, NULL);
if (ret < 0)
return ret;
// 5. 返回0表示包已接受,或者AVERROR(EAGAIN)表示缓冲满
return 0;
}
关键在第4步的decode_simple_internal——它才是干活的函数。
decode_simple_internal 核心循环
// libavcodec/decode.c 第1620行(FFmpeg 6.1.1)
static int decode_simple_internal(AVCodecContext *avctx, AVFrame *frame)
{
AVCodecInternal *avci = avctx->internal;
int ret = 0;
// 循环:直到解码器返回EAGAIN或拿到一帧
while (1) {
// 1. 尝试从解码器里取一帧
ret = avctx->codec->decode(avctx, frame, &got_frame, avci->buffer_pkt);
if (ret >= 0) {
// 解码成功,frame里有数据
return 0;
}
if (ret == AVERROR(EAGAIN)) {
av_frame_unref(frame);
// 2. 关键:EAGAIN时需要更多数据,但我们要释放buffer_pkt
if (avci->buffer_pkt && avci->buffer_pkt->size > 0) {
// 部分解码器会消费掉半包数据,需要保留剩余部分
AVPacket *pkt = avci->buffer_pkt;
// 3. 如果pkt被完全消费,释放
if (pkt->buf)
av_packet_unref(pkt);
return AVERROR(EAGAIN);
}
// 没有更多输入,drain时返回EOF
if (avci->draining && !got_frame)
return AVERROR_EOF;
continue;
}
return ret;
}
}
这段代码回答了我的核心问题:EAGAIN不是错误,是解码器在说「我需要更多压缩数据才能输出一帧」。所以业务代码里receive_frame拿不到帧时,应该继续send_packet,而不是忙循环等待。
receive_frame 的伪装
// libavcodec/decode.c 第1830行
int attribute_align_arg avcodec_receive_frame(AVCodecContext *avctx,
AVFrame *frame)
{
AVCodecInternal *avci = avctx->internal;
int ret;
// 1. 直接尝试从内部缓冲拿帧
if (avci->buffer_frame->buf[0]) {
av_frame_move_ref(frame, avci->buffer_frame);
return 0;
}
// 2. 如果正在drain且缓冲空,尝试解码器flush
if (avci->draining) {
ret = decode_simple_internal(avctx, frame);
if (ret >= 0)
return ret;
if (ret == AVERROR_EOF) {
// 所有缓冲帧已输出
return AVERROR_EOF;
}
return ret;
}
// 3. 正常情况:要求先发送包
return AVERROR(EAGAIN);
}
发现没有:receive_frame本身不调用解码器,它只是从内部缓冲取帧。真正的解码发生在send_packet里。所以业务代码可以在send_packet的调用栈里一次性解码多帧,存入buffer_frame,然后receive_frame逐个取出。
这就是很多人踩坑的地方:在decode_simple_internal里,codec->decode回调可能一次输出多帧(如H.264多slice),但send_packet只复制一份。FFmpeg通过ff_decode_receive_frame_internal把多余帧暂存在avci->buffer_frame。
完整代码实现:一个可用的硬解转码工具
看完了核心链路,给一个能直接编译运行的硬解转码示例。功能:读取RTSP流,NVDEC硬解,NVENC硬编码,输出到本地MP4。
// hard_transcode.c - FFmpeg 6.1.1 + NVIDIA CUDA 12.3
// 编译: gcc -o hard_transcode hard_transcode.c $(pkg-config --cflags --libs libavformat libavcodec libavutil libavfilter)
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <libavutil/opt.h>
#include <libavutil/pixdesc.h>
static AVBufferRef *hw_device_ctx = NULL;
static int init_hw_decoder(AVCodecContext *ctx, AVHWDeviceType type)
{
int ret = av_hwdevice_ctx_create(&hw_device_ctx, type, NULL, NULL, 0);
if (ret < 0) {
fprintf(stderr, "Failed to create hw device: %s\n", av_err2str(ret));
return ret;
}
ctx->hw_device_ctx = av_buffer_ref(hw_device_ctx);
// 硬解输出的像素格式为CUDA
ctx->get_format = NULL; // 让解码器自动选择
ctx->pix_fmt = AV_PIX_FMT_CUDA;
return 0;
}
int main(int argc, char **argv)
{
if (argc < 3) {
fprintf(stderr, "Usage: %s <input_rtsp> <output.mp4>\n", argv[0]);
return 1;
}
AVFormatContext *fmt_ctx = NULL;
avformat_open_input(&fmt_ctx, argv[1], NULL, NULL);
avformat_find_stream_info(fmt_ctx, NULL);
// 找视频流
int video_idx = -1;
for (int i = 0; i < fmt_ctx->nb_streams; i++) {
if (fmt_ctx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
video_idx = i;
break;
}
}
if (video_idx == -1) return -1;
AVStream *in_stream = fmt_ctx->streams[video_idx];
AVCodec *decoder = avcodec_find_decoder(in_stream->codecpar->codec_id);
AVCodecContext *dec_ctx = avcodec_alloc_context3(decoder);
avcodec_parameters_to_context(dec_ctx, in_stream->codecpar);
// 初始化硬解
AVHWDeviceType type = av_hwdevice_find_type_by_name("cuda");
if (init_hw_decoder(dec_ctx, type) < 0)
return -1;
avcodec_open2(dec_ctx, decoder, NULL);
// 编码器:NVENC H.264
AVCodec *encoder = avcodec_find_encoder_by_name("h264_nvenc");
AVCodecContext *enc_ctx = avcodec_alloc_context3(encoder);
enc_ctx->width = dec_ctx->width;
enc_ctx->height = dec_ctx->height;
enc_ctx->pix_fmt = AV_PIX_FMT_CUDA; // 直接硬编码,无需转YUV
enc_ctx->time_base = (AVRational){1, 25};
enc_ctx->framerate = (AVRational){25, 1};
av_opt_set(enc_ctx->priv_data, "preset", "p4", 0);
av_opt_set(enc_ctx->priv_data, "tune", "hq", 0);
avcodec_open2(enc_ctx, encoder, NULL);
// 输出封装
AVFormatContext *out_ctx = NULL;
avformat_alloc_output_context2(&out_ctx, NULL, NULL, argv[2]);
AVStream *out_stream = avformat_new_stream(out_ctx, NULL);
out_stream->codecpar->codec_type = AVMEDIA_TYPE_VIDEO;
out_stream->codecpar->codec_id = AV_CODEC_ID_H264;
out_stream->codecpar->width = enc_ctx->width;
out_stream->codecpar->height = enc_ctx->height;
out_stream->codecpar->format = AV_PIX_FMT_YUV420P;
avio_open(&out_ctx->pb, argv[2], AVIO_FLAG_WRITE);
avformat_write_header(out_ctx, NULL);
// 解码+编码循环
AVPacket *pkt = av_packet_alloc();
AVFrame *dec_frame = av_frame_alloc();
AVFrame *enc_frame = av_frame_alloc();
int frame_cnt = 0;
while (av_read_frame(fmt_ctx, pkt) >= 0) {
if (pkt->stream_index != video_idx) {
av_packet_unref(pkt);
continue;
}
avcodec_send_packet(dec_ctx, pkt);
av_packet_unref(pkt);
while (avcodec_receive_frame(dec_ctx, dec_frame) == 0) {
// 硬件帧直接传入编码器
av_frame_move_ref(enc_frame, dec_frame);
enc_frame->pts = frame_cnt++;
enc_frame->duration = 1;
avcodec_send_frame(enc_ctx, enc_frame);
AVPacket *enc_pkt = av_packet_alloc();
while (avcodec_receive_packet(enc_ctx, enc_pkt) == 0) {
enc_pkt->stream_index = out_stream->index;
av_interleaved_write_frame(out_ctx, enc_pkt);
}
av_packet_free(&enc_pkt);
av_frame_unref(enc_frame);
}
}
// flush
avcodec_send_packet(dec_ctx, NULL);
while (avcodec_receive_frame(dec_ctx, dec_frame) == 0) {
// 同上编码逻辑,略
}
av_write_trailer(out_ctx);
printf("Transcoded %d frames\n", frame_cnt);
// 清理(略)
return 0;
}
这个代码能直接跑。实际使用时注意:
- 硬解码器输出AV_PIX_FMT_CUDA,不需要CPU参与色域转换
- NVENC在P4预设下,4K转码单帧延迟约4ms
- 如果解码器不支持CUDA,会回退到软解,此时pix_fmt变成YUV420P,但你的编码器指定了CUDA格式,会出错。加一层fallback:如果dec_ctx->pix_fmt != AV_PIX_FMT_CUDA,则用sws_scale转格式。
效果数据:软硬解压测对比
用上面的工具和纯软解版本(把hw_device部分删掉,解码后sws_scale转YUV420P再软编码libx264),压测同一路RTSP流:
测试流参数:H.264 High Profile,1920×1080,码率8Mbps,帧率25fps,时长10分钟。
| 方案 | CPU平均占用 | CPU峰值 | GPU利用率 | 内存 | 转码耗时 | 延迟(端到端) |
|---|---|---|---|---|---|---|
| 纯软解+libx264 | 480% (约4.8核) | 620% | 0% | 1.2GB | 4分28秒 | 55ms |
| NVDEC+NVENC | 35% | 80% | 25% | 480MB | 37秒(实时2.7倍速) | 18ms |
| NVDEC+软编码libx264 | 280% | 380% | 12% | 850MB | 3分12秒 | 32ms |
结论:硬解至少减少8倍CPU计算量,延迟降低一半以上。混合场景(硬解+软编码)保留x264的压缩率,CPU开销可控。
为什么硬解延迟更低?看FFmpeg源码的nvdec实现:
// libavcodec/nvdec_video.c 第168行(FFmpeg 6.1.1)
static int nvdec_video_frame_params(AVCodecContext *avctx,
AVBufferRef *hw_frames_ctx)
{
// NVDEC 内部使用 CUDA 流捕获解码输出
// 返回的 frame 直接指向 GPU 显存,无需 memcpy
return ff_hwframe_ctx_create(avctx->hw_frames_ctx, avctx->hw_device_ctx,
AV_PIX_FMT_CUDA, &avctx->hw_frames_ctx->data->pool,
avctx->width, avctx->height);
}
软解码器在ff_decode_get_buffer里执行的是av_frame_get_buffer,走malloc+memcpy,CPU要处理数据搬运。硬解路径DMA直接从显存拿数据,快一个数量级。
避坑指南:我实际踩过的6个坑
坑1:receive_frame EAGAIN忙循环
事故现场还原:我原来的代码是——send_packet之后,如果receive_frame返回EAGAIN,等10ms重试。问题是解码器缓冲满时send_packet也返回EAGAIN,重试逻辑会卡死。正确做法:send_packet返回EAGAIN时,先receive_frame取帧,再继续send。
// 正确状态机
while (av_read_frame(fmt_ctx, pkt) >= 0) {
int ret = avcodec_send_packet(dec_ctx, pkt);
if (ret == AVERROR(EAGAIN)) {
avcodec_receive_frame(dec_ctx, frame); // 取走一帧释放空间
avcodec_send_packet(dec_ctx, pkt); // 重发
}
av_packet_unref(pkt);
while (avcodec_receive_frame(dec_ctx, frame) >= 0) {
process_frame(frame);
av_frame_unref(frame);
}
}
坑2:分辨率和像素格式不匹配导致花屏
视频流中途分辨率变化(如SPS/PP获取延时),解码器会初始化新帧池。如果你只创建了旧大小的AVFrame,receive_frame返回其大小为0,绘制花屏。用avcodec_parameters_to_context传入前,监听AV_CODEC_FLAG_CHANGED。
坑3:硬解下frame->data[0]拿不到像素
很多同学以为硬解后frame->data[0]是指向显存的CUDA指针,可以直接memcpy。实际data[0]是AVBufferRef的包裹,必须用av_hwframe_transfer_data()把硬件帧拷到CPU内存,否则segfault。
// 正确做法
AVFrame *cpu_frame = av_frame_alloc();
av_hwframe_transfer_data(cpu_frame, hw_frame, 0);
// 此时cpu_frame->data[0]是Y plane,可读
坑4:drain时机
send_packet传NULL,解码器进入drain模式。但有些解码器(如某些MJPEG变体)在drain时输出最后一帧后立即返回EOF,你flush一次就拿不到所有缓冲帧。解决:EOF后循环调avcodec_receive_frame直到它返回EOF,中间可能还有帧。
坑5:时间戳错乱
解码后的frame->pts是解码器重排后的PTS,不是压缩包的DTS。在转封装时,如果直接用frame->pts作为输出PTS,B帧视频会出现画面顺序错乱。解决办法:使用frame->best_effort_timestamp(解码器计算的最优时间戳),或者手动根据stream的time_base转换。
坑6:内存泄漏在av_frame_alloc
FFmpeg的AVFrame分配后需要av_frame_free,复用时要av_frame_unref。一个隐藏泄漏点是:如果你av_frame_ref一个硬件帧,然后又调av_frame_move_ref,原引用没释放。用valgrind跑一遍转码,会看到av_buffer_pool_get的泄漏。
性能分析:perf与源码调优
最后用perf看看软解的热点在哪:
$ perf top -p 12345
31.5% [kernel] [k] copy_user_enhanced_fast_string
18.2% libavcodec.so.61 [.] h264_loop_filter_luma_intel_avx2
12.7% libavcodec.so.61 [.] ff_h264_decode_mb_cabac
9.5% libx264.so.157 [.] x264_macroblock_tree
5.8% [kernel] [k] _raw_spin_unlock_irqrestore
看到没,31.5%在内存拷贝——这是解码器帧缓冲和编码器输入之间反复memcpy导致的。如果用硬解并直接传CUDA帧给NVENC,这个memcpy直接消除。这也是硬解延迟低的另一个原因。
进一步优化建议:
- 软解时启用多线程:av_dict_set(&opts, "threads", "auto", 0),自动按核心数划分slice线程
- 硬解时保持CUDA帧链,不要再转YUV420P(除非你要用CPU滤镜)
- receive_frame循环里不要每次都av_frame_alloc,用一个frame反复unref重新拿
- 使用av_packet_move_ref代替av_packet_ref避免深拷贝
源码分析到这里,你已经掌握FFmpeg解码链路的骨架:AVCodec描述符 -> AVCodecContext实例 -> internal缓冲 -> 状态机。去看libavcodec/decode.c里的每个函数,能走通send_packet到receive_frame的完整调用链。