ffplay源码分析2-数据结构

简介: ffplay是FFmpeg工程自带的简单播放器,使用FFmpeg提供的解码器和SDL库进行视频播放。

作者:叶余

来源:https://www.cnblogs.com/leisure_chn/p/10301253.html


ffplay是FFmpeg工程自带的简单播放器,使用FFmpeg提供的解码器和SDL库进行视频播放。本文基于FFmpeg工程4.1版本进行分析,其中ffplay源码清单如下:

https://github.com/FFmpeg/FFmpeg/blob/n4.1/fftools/ffplay.c

在尝试分析源码前,可先阅读如下参考文章作为铺垫:

[1]. 雷霄骅,视音频编解码技术零基础学习方法

[2]. 视频编解码基础概念

[3]. 色彩空间与像素格式

[4]. 音频参数解析

[5]. FFmpeg基础概念

“ffplay源码分析”系列文章如下:

[1]. ffplay源码分析1-概述

[2]. ffplay源码分析2-数据结构

[3]. ffplay源码分析3-代码框架

[4]. ffplay源码分析4-音视频同步

[5]. ffplay源码分析5-图像格式转换

[6]. ffplay源码分析6-音频重采样

[7]. ffplay源码分析7-播放控制

2. 数据结构

几个关键的数据结构如下:

2.1 struct VideoState

typedef struct VideoState {
    SDL_Thread *read_tid;           // demux解复用线程
    AVInputFormat *iformat;
    int abort_request;
    int force_refresh;
    int paused;
    int last_paused;
    int queue_attachments_req;
    int seek_req;                   // 标识一次SEEK请求
    int seek_flags;                 // SEEK标志,诸如AVSEEK_FLAG_BYTE等
    int64_t seek_pos;               // SEEK的目标位置(当前位置+增量)
    int64_t seek_rel;               // 本次SEEK的位置增量
    int read_pause_return;
    AVFormatContext *ic;
    int realtime;
    Clock audclk;                   // 音频时钟
    Clock vidclk;                   // 视频时钟
    Clock extclk;                   // 外部时钟
    FrameQueue pictq;               // 视频frame队列
    FrameQueue subpq;               // 字幕frame队列
    FrameQueue sampq;               // 音频frame队列
    Decoder auddec;                 // 音频解码器
    Decoder viddec;                 // 视频解码器
    Decoder subdec;                 // 字幕解码器
    int audio_stream;               // 音频流索引
    int av_sync_type;
    double audio_clock;             // 每个音频帧更新一下此值,以pts形式表示
    int audio_clock_serial;         // 播放序列,seek可改变此值
    double audio_diff_cum; /* used for AV difference average computation */
    double audio_diff_avg_coef;
    double audio_diff_threshold;
    int audio_diff_avg_count;
    AVStream *audio_st;             // 音频流
    PacketQueue audioq;             // 音频packet队列
    int audio_hw_buf_size;          // SDL音频缓冲区大小(单位字节)
    uint8_t *audio_buf;             // 指向待播放的一帧音频数据,指向的数据区将被拷入SDL音频缓冲区。若经过重采样则指向audio_buf1,否则指向frame中的音频
    uint8_t *audio_buf1;            // 音频重采样的输出缓冲区
    unsigned int audio_buf_size; /* in bytes */ // 待播放的一帧音频数据(audio_buf指向)的大小
    unsigned int audio_buf1_size;   // 申请到的音频缓冲区audio_buf1的实际尺寸
    int audio_buf_index; /* in bytes */ // 当前音频帧中已拷入SDL音频缓冲区的位置索引(指向第一个待拷贝字节)
    int audio_write_buf_size;       // 当前音频帧中尚未拷入SDL音频缓冲区的数据量,audio_buf_size = audio_buf_index + audio_write_buf_size
    int audio_volume;               // 音量
    int muted;                      // 静音状态
    struct AudioParams audio_src;   // 音频frame的参数
#if CONFIG_AVFILTER
    struct AudioParams audio_filter_src;
#endif
    struct AudioParams audio_tgt;   // SDL支持的音频参数,重采样转换:audio_src->audio_tgt
    struct SwrContext *swr_ctx;     // 音频重采样context
    int frame_drops_early;          // 丢弃视频packet计数
    int frame_drops_late;           // 丢弃视频frame计数
    enum ShowMode {
        SHOW_MODE_NONE = -1, SHOW_MODE_VIDEO = 0, SHOW_MODE_WAVES, SHOW_MODE_RDFT, SHOW_MODE_NB
    } show_mode;
    int16_t sample_array[SAMPLE_ARRAY_SIZE];
    int sample_array_index;
    int last_i_start;
    RDFTContext *rdft;
    int rdft_bits;
    FFTSample *rdft_data;
    int xpos;
    double last_vis_time;
    SDL_Texture *vis_texture;
    SDL_Texture *sub_texture;
    SDL_Texture *vid_texture;
    int subtitle_stream;                // 字幕流索引
    AVStream *subtitle_st;              // 字幕流
    PacketQueue subtitleq;              // 字幕packet队列
    double frame_timer;                 // 记录最后一帧播放的时刻
    double frame_last_returned_time;
    double frame_last_filter_delay;
    int video_stream;
    AVStream *video_st;                 // 视频流
    PacketQueue videoq;                 // 视频队列
    double max_frame_duration;      // maximum duration of a frame - above this, we consider the jump a timestamp discontinuity
    struct SwsContext *img_convert_ctx;
    struct SwsContext *sub_convert_ctx;
    int eof;
    char *filename;
    int width, height, xleft, ytop;
    int step;
#if CONFIG_AVFILTER
    int vfilter_idx;
    AVFilterContext *in_video_filter;   // the first filter in the video chain
    AVFilterContext *out_video_filter;  // the last filter in the video chain
    AVFilterContext *in_audio_filter;   // the first filter in the audio chain
    AVFilterContext *out_audio_filter;  // the last filter in the audio chain
    AVFilterGraph *agraph;              // audio filter graph
#endif
    int last_video_stream, last_audio_stream, last_subtitle_stream;
    SDL_cond *continue_read_thread;
} VideoState;

2.2 struct Clock

typedef struct Clock {
    // 当前帧(待播放)显示时间戳,播放后,当前帧变成上一帧
    double pts;           /* clock base */
    // 当前帧显示时间戳与当前系统时钟时间的差值
    double pts_drift;     /* clock base minus time at which we updated the clock */
    // 当前时钟(如视频时钟)最后一次更新时间,也可称当前时钟时间
    double last_updated;
    // 时钟速度控制,用于控制播放速度
    double speed;
    // 播放序列,所谓播放序列就是一段连续的播放动作,一个seek操作会启动一段新的播放序列
    int serial;           /* clock is based on a packet with this serial */
    // 暂停标志
    int paused;
    // 指向packet_serial
    int *queue_serial;    /* pointer to the current packet queue serial, used for obsolete clock detection */
} Clock;

2.3 struct PacketQueue

typedef struct PacketQueue {
    MyAVPacketList *first_pkt, *last_pkt;
    int nb_packets;                 // 队列中packet的数量
    int size;                       // 队列所占内存空间大小
    int64_t duration;               // 队列中所有packet总的播放时长
    int abort_request;
    int serial;                     // 播放序列,所谓播放序列就是一段连续的播放动作,一个seek操作会启动一段新的播放序列
    SDL_mutex *mutex;
    SDL_cond *cond;
} PacketQueue;

栈(LIFO)是一种表,队列(FIFO)也是一种表。数组是表的一种实现方式,链表也是表的一种实现方式,例如FIFO既可以用数组实现,也可以用链表实现。PacketQueue是用链表实现的一个FIFO。

2.4 struct FrameQueue

typedef struct FrameQueue {
    Frame queue[FRAME_QUEUE_SIZE];
    int rindex;                     // 读索引。待播放时读取此帧进行播放,播放后此帧成为上一帧
    int windex;                     // 写索引
    int size;                       // 总帧数
    int max_size;                   // 队列可存储最大帧数
    int keep_last;                  // 是否保留已播放的最后一帧使能标志
    int rindex_shown;               // 是否保留已播放的最后一帧实现手段
    SDL_mutex *mutex;
    SDL_cond *cond;
    PacketQueue *pktq;              // 指向对应的packet_queue
} FrameQueue;

FrameQueue是一个环形缓冲区(ring buffer),是用数组实现的一个FIFO。下面先讲一下环形缓冲区的基本原理,其示意图如下:

image.png

环形缓冲区的一个元素被用掉后,其余元素不需要移动其存储位置。相反,一个非环形缓冲区在用掉一个元素后,其余元素需要向前搬移。换句话说,环形缓冲区适合实现FIFO,而非环形缓冲区适合实现LIFO。环形缓冲区适合于事先明确了缓冲区的最大容量的情形。扩展一个环形缓冲区的容量,需要搬移其中的数据。因此一个缓冲区如果需要经常调整其容量,用链表实现更为合适。

环形缓冲区使用中要避免读空和写满,但空和满状态下读指针和写指针均相等,因此其实现中的关键点就是如何区分出空和满。有多种策略可以用来区分空和满的标志:

  1. 总是保持一个存储单元为空:“读指针”“写指针”时为空,“读指针”“写指针+1”时为满;
  2. 使用有效数据计数:每次读写都更新数据计数,计数等于0时为空,等于BUF_SIZE时为满;
  3. 记录最后一次操作:用一个标志记录最后一次是读还是写,在“读指针”==“写指针”时若最后一次是写,则为满状态;若最后一次是读,则为空状态。

可以看到,FrameQueue使用上述第2种方式,使用FrameQueue.size记录环形缓冲区中元素数量,作为有效数据计数。

ffplay中创建了三个frame_queue:音频frame_queue,视频frame_queue,字幕frame_queue。每一个frame_queue一个写端一个读端,写端位于解码线程,读端位于播放线程。

为了叙述方便,环形缓冲区的一个元素也称作节点(或帧),将rindex称作读指针或读索引,将windex称作写指针或写索引,叫法用混用的情况,不作文字上的严格区分。

2.4.1 初始化与销毁

static int frame_queue_init(FrameQueue *f, PacketQueue *pktq, int max_size, int keep_last)
{
    int i;
    memset(f, 0, sizeof(FrameQueue));
    if (!(f->mutex = SDL_CreateMutex())) {
        av_log(NULL, AV_LOG_FATAL, "SDL_CreateMutex(): %s\n", SDL_GetError());
        return AVERROR(ENOMEM);
    }
    if (!(f->cond = SDL_CreateCond())) {
        av_log(NULL, AV_LOG_FATAL, "SDL_CreateCond(): %s\n", SDL_GetError());
        return AVERROR(ENOMEM);
    }
    f->pktq = pktq;
    f->max_size = FFMIN(max_size, FRAME_QUEUE_SIZE);
    f->keep_last = !!keep_last;
    for (i = 0; i < f->max_size; i++)
        if (!(f->queue[i].frame = av_frame_alloc()))
            return AVERROR(ENOMEM);
    return 0;
}

队列初始化函数确定了队列大小,将为队列中每一个节点的frame(f->queue[i].frame)分配内存,注意只是分配frame对象本身,而不关注frame中的数据缓冲区。frame中的数据缓冲区是AVBuffer,使用引用计数机制。

f->max_size是队列的大小,此处值为16,细节不展开。

f->keep_last是队列中是否保留最后一次播放的帧的标志。f->keep_last = !!keep_last是将int取值的keep_last转换为boot取值(0或1)。

static void frame_queue_destory(FrameQueue *f)
{
    int i;
    for (i = 0; i < f->max_size; i++) {
        Frame *vp = &f->queue[i];
        frame_queue_unref_item(vp);     // 释放对vp->frame中的数据缓冲区的引用,注意不是释放frame对象本身
        av_frame_free(&vp->frame);      // 释放vp->frame对象
    }
    SDL_DestroyMutex(f->mutex);
    SDL_DestroyCond(f->cond);
}

队列销毁函数对队列中的每个节点作了如下处理:

  1. frame_queue_unref_item(vp)释放本队列对vp->frame中AVBuffer的引用
  2. av_frame_free(&vp->frame)释放vp->frame对象本身

2.4.2 写队列

写队列的步骤是:

  1. 获取写指针(若写满则等待);
  2. 将元素写入队列;
  3. 更新写指针。
    写队列涉及下列两个函数:
frame_queue_peek_writable()     // 获取写指针
frame_queue_push()              // 更新写指针

通过实例看一下写队列的用法:

static int queue_picture(VideoState *is, AVFrame *src_frame, double pts, double duration, int64_t pos, int serial)
{
    Frame *vp;
    if (!(vp = frame_queue_peek_writable(&is->pictq)))
        return -1;
    vp->sar = src_frame->sample_aspect_ratio;
    vp->uploaded = 0;
    vp->width = src_frame->width;
    vp->height = src_frame->height;
    vp->format = src_frame->format;
    vp->pts = pts;
    vp->duration = duration;
    vp->pos = pos;
    vp->serial = serial;
    set_default_window_size(vp->width, vp->height, vp->sar);
    av_frame_move_ref(vp->frame, src_frame);
    frame_queue_push(&is->pictq);
    return 0;
}

上面一段代码是视频解码线程向视频frame_queue中写入一帧的代码,步骤如下:

  1. frame_queue_peek_writable(&is->pictq)向队列尾部申请一个可写的帧空间,若队列已满无空间可写,则等待
  2. av_frame_move_ref(vp->frame, src_frame)将src_frame中所有数据拷贝到vp->
    frame并复位src_frame,vp->
    frame中AVBuffer使用引用计数机制,不会执行AVBuffer的拷贝动作,仅是修改指针指向值。为避免内存泄漏,在av_frame_move_ref(dst, src)之前应先调用av_frame_unref(dst),这里没有调用,是因为frame_queue在删除一个节点时,已经释放了frame及frame中的AVBuffer。
  3. frame_queue_push(&is->pictq)此步仅将frame_queue中的写指针加1,实际的数据写入在此步之前已经完成。

frame_queue写操作相关函数实现如下:

frame_queue_peek_writable()

static Frame *frame_queue_peek_writable(FrameQueue *f)
{
    /* wait until we have space to put a new frame */
    SDL_LockMutex(f->mutex);
    while (f->size >= f->max_size &&
           !f->pktq->abort_request) {
        SDL_CondWait(f->cond, f->mutex);
    }
    SDL_UnlockMutex(f->mutex);
    if (f->pktq->abort_request)
        return NULL;
    return &f->queue[f->windex];
}

向队列尾部申请一个可写的帧空间,若无空间可写,则等待

frame_queue_push()

static void frame_queue_push(FrameQueue *f)
{
    if (++f->windex == f->max_size)
        f->windex = 0;
    SDL_LockMutex(f->mutex);
    f->size++;
    SDL_CondSignal(f->cond);
    SDL_UnlockMutex(f->mutex);
}

向队列尾部压入一帧,只更新计数与写指针,因此调用此函数前应将帧数据写入队列相应位置

2.4.3 读队列

写队列中,应用程序写入一个新帧后通常总是将写指针加1。而读队列中,“读取”和“更新读指针(同时删除旧帧)”二者是独立的,可以只读取而不更新读指针,也可以只更新读指针(只删除)而不读取。而且读队列引入了是否保留已显示的最后一帧的机制,导致读队列比写队列要复杂很多。

读队列和写队列步骤是类似的,基本步骤如下:

  1. 获取读指针(若读空则等待);
  2. 读取一个节点;
  3. 更新写指针(同时删除旧节点)。
    写队列涉及如下函数:
frame_queue_peek_readable()     // 获取读指针(若读空则等待)
frame_queue_peek()              // 获取当前节点指针
frame_queue_peek_next()         // 获取下一节点指针
frame_queue_peek_last()         // 获取上一节点指针
frame_queue_next()              // 更新读指针(同时删除旧节点)

通过实例看一下读队列的用法:

static void video_refresh(void *opaque, double *remaining_time)
{
    ......
    if (frame_queue_nb_remaining(&is->pictq) == 0) {    // 所有帧已显示
        // nothing to do, no picture to display in the queue
    } else {
        Frame *vp, *lastvp;
        lastvp = frame_queue_peek_last(&is->pictq);     // 上一帧:上次已显示的帧
        vp = frame_queue_peek(&is->pictq);              // 当前帧:当前待显示的帧
        frame_queue_next(&is->pictq);                   // 删除上一帧,并更新rindex
        video_display(is)-->video_image_display()-->frame_queue_peek_last();
    }
    ......
}

上面一段代码是视频播放线程从视频frame_queue中读取视频帧进行显示的基本步骤,其他代码已省略,只保留了读队列部分。video_refresh()的实现详情可参考第3节。

记lastvp为上一次已播放的帧,vp为本次待播放的帧,下图中方框中的数字表示显示序列中帧的序号(实际就是Frame.frame.display_picture_number变量值)。

image.png

在启用keep_last机制后,rindex_shown值总是为1,rindex_shown确保了最后播放的一帧总保留在队列中。

假设某次进入video_refresh()的时刻为T0,下次进入的时刻为T1。在T0时刻,读队列的步骤如下:

  1. rindex(图中ri)表示上一次播放的帧lastvp,本次调用video_refresh()中,lastvp会被删除,rindex会加1
  2. rindex+rindex_shown(图中ris)表示本次待播放的帧vp,本次调用video_refresh()中,vp会被读出播放
    图中已播放的帧是灰色方框,本次待播放的帧是黑色方框,其他未播放的帧是绿色方框,队列中空位置为白色方框。
    在之后的某一时刻TX,首先调用frame_queue_nb_remaining()判断是否有帧未播放,若无待播放帧,函数video_refresh()直接返回,不往下执行。
/* return the number of undisplayed frames in the queue */
static int frame_queue_nb_remaining(FrameQueue *f)
{
    return f->size - f->rindex_shown;
}

rindex_shown为1时,队列中总是保留了最后一帧lastvp(灰色方框)。按照这样的设计思路,如果rindex_shown为2,队列中就会保留最后2帧。

但keep_last机制有什么用途呢?希望知道的同学指点一下。

注意,在TX时刻,无新帧可显示,保留的一帧是已经显示过的。那么最后一帧什么时候被清掉呢?在播放结束或用户中途取消播放时,会调用frame_queue_destory()清空播放队列。

rindex_shown的引入增加了读队列操作的理解难度。大多数读操作函数都会用到这个变量。

通过FrameQueue.keep_lastFrameQueue.rindex_shown两个变量实现了保留最后一次播放帧的机制。

是否启用keep_last机制是由全局变量keep_last值决定的,在队列初始化函数frame_queue_init()中有f->keep_last = !!keep_last;,而在更新读指针函数frame_queue_next()中如果启用keep_last机制,则f->rindex_shown值为1。如果rindex_shown对理解代码造成了困扰,可以先将全局变量keep_last值赋为0,这样f->rindex_shown值为0,代码看起来会清晰很多。理解了读队列的基本方法后,再看f->rindex_shown值为1时代码是如何运行的。

先看frame_queue_next()函数:

frame_queue_next()

static void frame_queue_next(FrameQueue *f)
{
    if (f->keep_last && !f->rindex_shown) {
        f->rindex_shown = 1;
        return;
    }
    frame_queue_unref_item(&f->queue[f->rindex]);
    if (++f->rindex == f->max_size)
        f->rindex = 0;
    SDL_LockMutex(f->mutex);
    f->size--;
    SDL_CondSignal(f->cond);
    SDL_UnlockMutex(f->mutex);
}

三个动作:删除rindex节点(lastvp),更新f->rindexf->size

frame_queue_peek_readable()

static Frame *frame_queue_peek_readable(FrameQueue *f)
{
    /* wait until we have a readable a new frame */
    SDL_LockMutex(f->mutex);
    while (f->size - f->rindex_shown <= 0 &&
           !f->pktq->abort_request) {
        SDL_CondWait(f->cond, f->mutex);
    }
    SDL_UnlockMutex(f->mutex);
    if (f->pktq->abort_request)
        return NULL;
    return &f->queue[(f->rindex + f->rindex_shown) % f->max_size];
}

从队列头部读取一帧(vp),只读取不删除,若无帧可读则等待。这个函数和frame_queue_peek()的区别仅仅是多了不可读时等待的操作。

frame_queue_peek()

static Frame *frame_queue_peek(FrameQueue *f)
{
    return &f->queue[(f->rindex + f->rindex_shown) % f->max_size];
}
static Frame *frame_queue_peek_next(FrameQueue *f)
{
    return &f->queue[(f->rindex + f->rindex_shown + 1) % f->max_size];
}
// 取出此帧进行播放,只读取不删除,不删除是因为此帧需要缓存下来供下一次使用。播放后,此帧变为上一帧
static Frame *frame_queue_peek_last(FrameQueue *f)
{
    return &f->queue[f->rindex];
}

从队列头部读取一帧(vp),只读取不删除。


「视频云技术」你最值得关注的音视频技术公众号,每周推送来自阿里云一线的实践技术文章,在这里与音视频领域一流工程师交流切磋。

阿里云视频云@凡科快图.png

相关文章
|
4月前
|
消息中间件 存储 NoSQL
Redis数据结构—跳跃表 skiplist 实现源码分析
Redis 是一个内存中的数据结构服务器,使用跳跃表(skiplist)来实现有序集合。跳跃表是一种概率型数据结构,支持平均 O(logN) 查找复杂度,它通过多层链表加速查找,同时保持有序性。节点高度随机生成,最大为 32 层,以平衡查找速度和空间效率。跳跃表在 Redis 中用于插入、删除和按范围查询元素,其内部节点包含对象、分值、后退指针和多个前向指针。Redis 源码中的 `t_zset.c` 文件包含了跳跃表的具体实现细节。
|
6月前
|
存储 API
milvus insert api的数据结构源码分析
milvus insert api的数据结构源码分析
1051 6
milvus insert api的数据结构源码分析
|
监控 NoSQL Redis
Redis 源码分析客户端数据结构(client)(下)
Redis 源码分析客户端数据结构(client)
449 0
Redis 源码分析客户端数据结构(client)(下)
|
存储 算法 Java
《恋上数据结构第1季》哈希表介绍以及从源码分析哈希值计算
《恋上数据结构第1季》哈希表介绍以及从源码分析哈希值计算
135 0
《恋上数据结构第1季》哈希表介绍以及从源码分析哈希值计算
|
存储 NoSQL Unix
Redis 源码分析客户端数据结构(serverDb)
数据库存储在 redisDb 结构中,而服务端 redisServer 结构中保存着 redisDb 对象和个数,个数可以在配置文件中进行更新。
279 0
Redis 源码分析客户端数据结构(serverDb)
|
存储 Java 索引
netty案例,netty4.1源码分析篇四《ByteBuf的数据结构在使用方式中的剖析》
在Netty中ByteBuf是一个非常重要的类,它可以以高效易用的数据结构方式来满足网络通信过程中处理数据包内字节码序列的移动。
206 0
netty案例,netty4.1源码分析篇四《ByteBuf的数据结构在使用方式中的剖析》
|
NoSQL 网络协议 Redis
Redis 源码分析客户端数据结构(client)(上)
Redis 源码分析客户端数据结构(client)
282 0
|
存储
Mosquitto-1.5.4源码分析,主题订阅的数据结构及SUBSCRIBE的函数跳转关系
Mosquitto-1.5.4源码分析,主题订阅的数据结构及SUBSCRIBE的函数跳转关系
361 0
Mosquitto-1.5.4源码分析,主题订阅的数据结构及SUBSCRIBE的函数跳转关系
Mosquitto-1.5.4源码分析,把握全局的数据结构
Mosquitto-1.5.4源码分析,把握全局的数据结构
474 0
|
C语言
Mosquitto-1.5.4源码分析,数据结构之哈希表uthash
Mosquitto-1.5.4源码分析,数据结构之哈希表uthash
247 0

热门文章

最新文章