Just4U 不会算圈图的程序猿不是个好厨子

ffmpeg历史垃圾桶中的AVFifoBuffer源码分析

2023-01-02

AVFifoBuffer历史变更

今天阅读ffmpeg n5.2-dev代码时发现ffplay.c中的AVPacket缓冲队列的实现做了较多改动,原来的AVFifoBuffer已经被新的AVFifo所取代,今后代码中就没有AVFifoBuffer了。

ffplay.c中的AVPacket缓冲在n4.4之前一直使用链表实现的,最早可追溯到上古版本n1.1,链表实现用了十几年,直到2021年 n4.4的时候改用了ffmpeg自己的实现AVFifoBuffer,然后在2022年在n5.1版本中改为了AVFifo,AVFifoBuffer也就用了一年左右的时间就被扫进历史垃圾桶,为了防止被遗忘,今天就以n4.4.3版本分析下AVFifoBuffer源码。

源码分析

AVFifoBuffer使用的是环形队列,数据结构定义如下:

typedef struct AVFifoBuffer {
    uint8_t *buffer;//缓冲区地址
    uint8_t *rptr, *wptr, *end;//读指针,写指针,以及尾巴
    uint32_t rndx, wndx;//用于记录写了多少个字节,读了多少个字节
} AVFifoBuffer;

可参考下面的示意图:

下面分析他的实现源码,主要是下面的接口:

  • av_fifo_alloc
  • av_fifo_alloc_array
  • av_fifo_free
  • av_fifo_freep
  • av_fifo_size
  • av_fifo_space
  • av_fifo_grow
  • av_fifo_generic_read
  • av_fifo_drain
  • av_fifo_generic_peek
  • av_fifo_generic_peek_at
  • av_fifo_generic_write

下面逐个分析每个接口:

  1. av_fifo_alloc
/* 初始化一个 AVFifoBuffer

@参数 size: FIFO的尺寸,实际是FIFO中buffer的大小

@返回值 成功则返回指向 AVFifoBuffer的指针,否则返回NULL

*/

AVFifoBuffer *av_fifo_alloc(unsigned int size)
{
    //可以看出是先分配buffer的内存,然后再分配AVFifoBuffer内存
    //原因可能是考虑到buffer内存有可能会很大,
    //存在更多分配失败的可能,便于错误处理
    //注意av_malloc与malloc的区别,av_malloc会进行内存对齐
    void *buffer = av_malloc(size);
    return fifo_alloc_common(buffer, size);
}

//主要分配AVFifoBuffer本身内存,及部分参数赋值
//独立出此函数,是为了与接下来的av_fifo_alloc_array复用部分代码
//参数buffer表示已分配的buffer指针,size为传入的FIFO大小参数
static AVFifoBuffer *fifo_alloc_common(void *buffer, size_t size)
{
    AVFifoBuffer *f; 
    if (!buffer)
        return NULL;
    f = av_mallocz(sizeof(AVFifoBuffer));
    if (!f) {
        av_free(buffer);
        return NULL;
    }
    f->buffer = buffer;        //size字节的buffer
    f->end    = f->buffer + size;  //buffer一共size个字节,所以end指向了buffer的尾巴
    av_fifo_reset(f);        //部分参数初始化
    return f;
}
void av_fifo_reset(AVFifoBuffer *f)
{
    //初始化参数,此时没有任何数据,所以读指针,写指针与buffer一致
    f->wptr = f->rptr = f->buffer;
    //初始时没读也没写,均为0
    f->wndx = f->rndx = 0;
}
  1. av_fifo_alloc_array
/**  * 初始化一个AVFifoBuffer,区别是此时的buffer大小不是直接使用size来分配,而是使用单个元素大小乘以元素个数的方式.

* @param nmemb:元素个数

* @param size :单个元素的字节数

* @返回值 成功则返回指向 AVFifoBuffer的指针,否则返回NULL

*/

AVFifoBuffer *av_fifo_alloc_array(size_t nmemb, size_t size)
{
    //先来看av_malloc_array
    void *buffer = av_malloc_array(nmemb, size);
    return fifo_alloc_common(buffer, nmemb * size);
}
//本质还是调用的av_malloc,区别是多了个av_size_mult操作
void *av_malloc_array(size_t nmemb, size_t size)
{
    size_t result;
    //重点看这个函数
    if (av_size_mult(nmemb, size, &result) < 0)
        return NULL;
    return av_malloc(result);
}

//检查两个数字相乘会不会溢出
//因为比较复杂,在正文中分析
static inline int av_size_mult(size_t a, size_t b, size_t *r)
{
    size_t t = a * b;
    /* Hack inspired from glibc: don't try the division if nelem and elsize
     * are both less than sqrt(SIZE_MAX). */
    if ((a | b) >= ((size_t)1 << (sizeof(size_t) * 4)) && a && t / a != b)
        return AVERROR(EINVAL);
    *r = t;
    return 0;
}

ffmpeg中如果在内部函数中见到注释,通常开心不起来,因为每个有注释的地方都很复杂。

上面注释表示这段是受glibc的启发,当两个乘数都小于sqrt(SIZE_MAX)时不进行除法操作,为什么不进行除法操作,因为没必要,两个乘数都小于最大值的开方,说明乘积一定小于最大值,同时除法又是个相对耗时的操作,省掉这个除法操作可以提高一点性能。

现在主要看这个判断条件:if ((a b) >= ((size_t)1 « (sizeof(size_t) * 4)) && a && t / a != b) 这里的后半部分是比较好理解的,a && t / a != b如果a不是0,则用这两个乘积处以其中一个乘数,如果不等于另一个乘数,则说明发生了溢出。

前半部分中先看,size_t的长度与操作系统有关,64位下是8字节,32位下是4字节,这样每个字节8位,这样sizeof(size_t) * 4 就刚好是最大移位距离的一半,((size_t)1 « (sizeof(size_t) * 4))这样就刚好是size_t最大值的平方根。比如64位下,sizeof(size_t)为8, 8×4=32, size_t长度64位,两个1«32相乘就是1«64,所以((size_t)1 « (sizeof(size_t) * 4))是size_t最大值的平方根。glibc的方法是,a跟b均小于最大数的平方根就不需要除法,即a跟b至少有一个大于最大树平方根才去做除法,因为size_t是无符号数,直接按位或运算就能判断是否有任何一个数值大于最大数的平方根。

再回到av_fifo_alloc_array,它跟av_fifo_alloc有什么本质区别吗?没有本质的区别,只不过方便了一点点而已,比如我们想分配10k的buffer,我们可以用av_fifo_alloc(10*1024),也可以用av_fifo_alloc_array(10,1024),区别是av_fifo_alloc_array会进行是否溢出的检查。

  1. av_fifo_free 与 av_fifo_freep

两者作用一致,av_fifo_freep多了指针置null的操作

void av_fifo_free(AVFifoBuffer *f)
{
    if (f) {
      //释放buffer内存,并把buffer指针置null
      //这里重点看下到底是怎么把buffer指针置null的
        av_freep(&f->buffer);
        av_free(f);      //释放AVFifoBuffer内存
    }
}
//注意这里传入的是个二级指针,即指向指针的指针
void av_freep(void *arg)
{
    void *val;

    memcpy(&val, arg, sizeof(val));
    //这里用到了C99的语法,Compound Literals
    memcpy(arg, &(void *){ NULL }, sizeof(val));
    av_free(val);
}

void av_fifo_freep(AVFifoBuffer **f)
{
    if (f) {
        av_fifo_free(*f);  //调用av_fifo_free
        *f = NULL;      //多了一个指针置null的操作
    }
}

这里面主要有几个问题,第一个是av_freep接受的是二级指针,这里为什么参数可以是(void )而不是(void)? 首先当然是void*可以指代任何指针,所以可以用void*,为什么不用viod? 是因为void**就不能指代任何指针,这里只有void才能兼容一切。

第二个问题是,上面第17行的那段代码是个什么东西?这是个叫compound literals的东西,简单的说就是允许一个匿名的数组或结构,并且这个匿名的数组或结构是个左值,可以取地址。可参考公众号内另一篇《c99 复合字面量介绍》的笔记。总之这句代码就是把指向NULL的,一个指针个长度的数据拷贝到arg,实际就是arg = NULL,那为甚么还要这样绕来绕去,不干脆使用arg = NULL?实际上ffmpeg在九年之前的n2.6之前都是用的*arg = NULL这种方式,之所以改成现在的版本,从提交记录上看是为了修复编译器的指针别名冲突问题,但这里我也不懂,后面搞懂了再来介绍。

  1. av_fifo_size
/**  * 返回buffer中还有多少字节可以读  */

int av_fifo_size(const AVFifoBuffer *f)
{
    //看图就好了,表示写了还没读的,就是有多少字节可以读
    return (uint32_t)(f->wndx - f->rndx);
}
  1. av_fifo_space
/** 返回还有多少字节可用来写  */

int av_fifo_space(const AVFifoBuffer *f)
{
    //f->end - f->buffer是buffer的总大小
    //av_fifo_size(f)是多少字节可读,即已经写进去的字节数
    //总的减去已经写进去多少,等于还有多少可以写
    return f->end - f->buffer - av_fifo_size(f);
}
  1. av_fifo_grow
/**  * 扩充一个AVFifoBuffer.

* 为防止重新分配内存失败, 老的fifo保持不变.

* 扩充大小有一定的规则,所以新的fifo大小可能大于需要的大小.

* @param f :待扩充的AVFifoBuffer

* @param additional_space,需要额外扩充的字节数

 * @return <0 for failure, >=0 otherwise

*/
int av_fifo_grow(AVFifoBuffer *f, unsigned int size)
{
    //old_size实际为原来可用的总空间
    unsigned int old_size = f->end - f->buffer;
    if(size + (unsigned)av_fifo_size(f) < size) //防止超过u32范围
        return AVERROR(EINVAL);
    //入参size为需要扩充的字节数,新的空间大小 = size + 已经写入了的字节数
    size += av_fifo_size(f);

    //比如原来有120字节空间,已经写了30个,希望扩展60个,此时一共需要90个字节,
    //但是原来就有120个了,此时就不需要再扩展了,如果希望扩展100个字节
    //此时一同需要130个字节,原来的空间120个是不够的,则进入重新分配内存的流程
    if (old_size < size)
        //原来的总空间小于扩展后的总空间,则需要重新分配内存
        return av_fifo_realloc2(f, FFMAX(size, 2*old_size));
    //若原来的总空间就比新空间大,则不需要分配内存,直接返回即可
    return 0;
}

这里主要关注old_size < size的情况,之所以需要这个判断,大概率还是出于效率的考虑, 这样某些情况下就不需要重新分配内存。重新分配内存的操作见av_fifo_realloc2,先看入参,size与2old_size之间的最大值,size是已使用的空间加上要扩展的空间大小,old_size是原来总的可用空间,比如原来总的是120个字节,已经写入30个,想要扩展100个,此时size为130,2old_size为240。也就是说实际扩展到了240个字节,这就是为什么注释中说新的fifo大小可能会大于需要的大小。

为什么要这样扩展?还是效率的问题的,就是为了避免某些情况下频繁的扩容。

//修改AVFifoBuffer的大小
//传入的参数new_size是希望AVFifoBuffer变成的大小
int av_fifo_realloc2(AVFifoBuffer *f, unsigned int new_size)
{
    //之前的总大小
    unsigned int old_size = f->end - f->buffer;
    //通常都是小于new_size,否则的话直接不需要扩容
    if (old_size < new_size) {
        //已经写入的大小,也就是可以读取的大小
        int len          = av_fifo_size(f);
        //按照新的大小分配一个新的
        AVFifoBuffer *f2 = av_fifo_alloc(new_size);
        if (!f2)
            return AVERROR(ENOMEM);
        //把原来f中的已经写入的拷贝到新的buffer中
        av_fifo_generic_read(f, f2->buffer, len, NULL);
        f2->wptr += len;
        f2->wndx += len;
        //这里把原来的buffer释放掉
        av_free(f->buffer);
        //这里直接把新分配的AVFifoBuffer替换掉了原来的AVFifoBuffer,
        //用户看着好像什么都没发生,其实已经被偷偷换成了复制品
        *f = *f2;
        //把AVFifoBuffer本身的内存释放掉,不会影响到buffer
        av_free(f2);
    }
    return 0;
}
  1. av_fifo_generic_read
/**  

* 从一个AVFifoBuffer中把buffer取出buf_size的长度交给func回调处理,如果func为空就直接复制到dest中  

* @param f:要读取的AVFifoBuffer  

* @param 将要读取的数据大小  

* @param func 用户自定义的回调函数   

* @param dest 目标内存地址  

*/ 
int av_fifo_generic_read(AVFifoBuffer *f, void *dest, int buf_size,
                         void (*func)(void *, void *, int))
{
// Read memory barrier needed for SMP here in theory
    do {
        //取两者之间较小值是为了防止读时越界,
        //这里越界是不要越过0点位置,0点前后分开处理
        //原因是两个地址不一样,需要手动拨回
        int len = FFMIN(f->end - f->rptr, buf_size);
        //只有在func有定义的时候才执行,且跟memcpy是互斥的
        if (func)
            func(dest, f->rptr, len);
        else {
            //直接拷贝len长度数据过去
            memcpy(dest, f->rptr, len);
            //这里只为循环服务,在这里修改dest只是修改入参的副本
            dest = (uint8_t *)dest + len;
        }
// memory barrier needed for SMP here in theory
        //已经拷贝走的数据就读出来扔掉
        av_fifo_drain(f, len);
        //也是为循环服务
        buf_size -= len;
        //如果len等于buf_size,此时循环结束,
        //什么时候buf_size > 0成立?
        //就是f->end - f->rptr,要小于要读取的大小buf_size,
        //即数据越过0边界的s以后,就需要跟多次处理
    } while (buf_size > 0);
    return 0;
}
  1. av_fifo_drain
/**

* 从AVFifoBuffer读取一定数量的数据然后抛弃掉

* @param f 要读取的AVFifoBuffer

* @param size 要读取的字节数

*/

void av_fifo_drain(AVFifoBuffer *f, int size)
{
    //无法只剩十个字节可读取时还要读取十一个字节,
    //但实际上这行代码只是调试代码,实际中由于编译选项
    //assert-level默认为0,所以av_assert2
    //并没有任何作用,可认为不存在
    av_assert2(av_fifo_size(f) >= size);
    //读指针后移动size个字节
    f->rptr += size;
    //读到边界了或者超过了边界
    if (f->rptr >= f->end)
        //读指针往前移动整个长度
        f->rptr -= f->end - f->buffer;
    //因为是读操作所以加法运算
    f->rndx += size;
}

av_fifo_drain这部分代码大概来源于2006年,虽然年代久远,但是基本保持了原来的思路。不要被av_assert2(av_fifo_size(f) >= size)这个判断条件影响到,因为它只存在于开发调试阶段,可以认为它完全不存在。在av_fifo_generic_read中调用av_fifo_drain,由于对size做了限制, 不会有f->rptr 大于f->end的情况,但在直接调用av_fifo_drain时,由于rptr是先移动size个单位,就很难保证他俩的大小关系。这里的

f->rptr -= f->end - f->buffer相当往回拨了一圈。

  1. av_fifo_generic_peek
/**

* 从AVFifoBuffer中读取数据进行用户自定义的处理,与av_fifo_gereric_read相似,

* 但是读取之后不会把数据舍弃.

* @param f 待读取的AVFifoBuffer

* @param buf_size 要读取的字节数

* @param func 用户自定义的方法,用于处理读取到的数据

*@param dest 目标数据区域

*/
int av_fifo_generic_peek(AVFifoBuffer *f, void *dest, int buf_size,
                         void (*func)(void *, void *, int))
{
    // Read memory barrier needed for SMP here in theory
    uint8_t *rptr = f->rptr;

    do {
        int len = FFMIN(f->end - rptr, buf_size);
        if (func)
            func(dest, rptr, len);
        else {
            memcpy(dest, rptr, len);
            dest = (uint8_t *)dest + len;
        }
        // memory barrier needed for SMP here in theory
        // 与av_fifo_gereric_read的唯一区别,就是增加的指针的副本,
        // 而不是直接对指针操作
        rptr += len;
        if (rptr >= f->end)
            rptr -= f->end - f->buffer;
        buf_size -= len;
    } while (buf_size > 0);

    return 0;
}

上面这段代码与av_fifo_gereric_read流程一致,唯一的区别在18行-20行,av_fifo_drain的操作修改指针本身指向的内容,但这里只是修改了指针的副本,不会影响原指针,即AVFifoBuffer自己并不能意识到自己被读了。

  1. av_fifo_generic_peek_at
/**  * 从AVFifoBuffer中读取数据进行用户自定义的处理, 

* 与av_fifo_generic_peek类似, 

* 唯一的区别是可以从指定的偏移量开始读取 

* @param offset:从当前读取位置开始的偏移量

*/


int av_fifo_generic_peek_at(AVFifoBuffer *f, void *dest, int offset, int buf_size, void (*func)(void*, void*, int))
{
    uint8_t *rptr = f->rptr;

    av_assert2(offset >= 0);

    /*
     * *ndx are indexes modulo 2^32, they are intended to overflow,
     * to handle *ndx greater than 4gb.
     */
    av_assert2(buf_size + (unsigned)offset <= f->wndx - f->rndx);

    if (offset >= f->end - rptr)
        rptr += offset - (f->end - f->buffer);
    else
        rptr += offset;

    while (buf_size > 0) {
        int len;

        if (rptr >= f->end)
            rptr -= f->end - f->buffer;

        len = FFMIN(f->end - rptr, buf_size);
        if (func)
            func(dest, rptr, len);
        else {
            memcpy(dest, rptr, len);
            dest = (uint8_t *)dest + len;
        }

        buf_size -= len;
        rptr     += len;
    }

    return 0;
}

这个函数从代码风格上看各种无必要的空格,函数内容也无必要,因为用户是很容易通过修改自己的自定义函数来实现这个功能。虽然但是,还是分析一下,首先这两个av_assert2忽略掉,因为不起任何作用。再看下13-14行的处理,offset >= f->end - rptr这个判断是防止offset比当前剩余空间还要大,就认为这时候是因为多了一圈,所以减去了(f->end - f->buffer),即整个buffer的长度。

  1. av_fifo_generic_write
/**
*  经过用户自定义的回调从src读取数据填充到AVFifoBuffer
* @param f 待写的AVFifoBuffer
* @param src:源数据,不能是const指针,因为自定义回调中可能会更改数据
* @param size:即将要写的字节数
* @param func:写回调函数,第一个参数是源,第二个是目标buf,第三个是目标buf字节数
* 函数必须返回写入到目标buf的字节数,如果没有足够的数据可写,返回值 <= 0
* 如果用户自定义回调是空,则直接拷贝src中的数据到AVFifoBuffer
* @返回值: 写到目标buf中的字节数
*/
int av_fifo_generic_write(AVFifoBuffer *f, void *src, int size,
                          int (*func)(void *, void *, int))
{
    int total = size;
    //几乎是读数据的镜像过程
    uint32_t wndx= f->wndx;
    uint8_t *wptr= f->wptr;
    do {
        //f->end - wptr 即为还没被写的空间
        int len = FFMIN(f->end - wptr, size);
        if (func) {
            len = func(src, wptr, len);
            if (len <= 0)
                break;
        } else {
            memcpy(wptr, src, len);
            src = (uint8_t *)src + len;
        }
// Write memory barrier needed for SMP here in theory
        wptr += len;
        if (wptr >= f->end)
            //此时转了一圈,写满了,wptr强制归位
            wptr = f->buffer;
        wndx    += len;
        size    -= len;
    } while (size > 0);
    //这里最后再更新,是为了防止中间处理到一半中断了,虽然并不能从根本上防止
    f->wndx= wndx;
    f->wptr= wptr;
    //如果刚好写完,此时size应为0,total即为最开始的要写入的全部数据
    //当需要写入的空间大于剩余的空间时,size即为剩余的那几个字节,total-size即为已经写入的数据

    return total - size;
}

写的过程与读几乎是镜像关系,且更好的诠释了环形buffer的概念,比如整个buffer大小是100字节,此时已经写入10个字节,读了3个字节,我想写404个字节,第一圈执行时FFMIN(f->end - wptr, size)即FFMIN(90,404) 也就是 90,拷贝结束后,wptr += len移动90个字节到f->end处,wptr = f->buffer即初始位置,wndx += len即 3+90=93, size -=len 即404 - 90 = 314,此时size >0 ,继续FFMIN(f->end - wptr, size)即FFMIN(100, 314)也就是100, 拷贝100字节后,wptr +=len 移动100个字节,又到f->end处,wptr = f->buffer即再回初始位置,此时wndx 一直在增加,size此时应为size - len = 314 - 100 = 214, size还是大于0,再进行循环,这样实际404个字节只有最后100个字节,也就是buffer大小被最终保留到AVFifoBuffer中了,其他都被覆盖掉了。

其他

首先这是个无锁实现,所以在使用时是需要注意多线程场景的。

注意write及read中的注释,有提到类似// Write memory barrier needed for SMP here in theory等理论上需要添加读写内存屏障的提示,但最终代码中没有考虑这个问题,不知道是出于什么原因,关于内存屏障的使用可参考linux kernel 2.6版本中的kfifo,同样这也是我的知识盲区。ffmpeg这个版本的fifo实现还是非常精简且典型的,真正有效的数据只有buffer,rptr,wprt,end四个指针,中间为了性能也做了很多优化,是可以单独移植出来放在自己项目中使用的。下次分析AVFifo的实现。


Similar Posts

Comments