VLC 源码拆解:从 play 到屏幕上的那一帧

1个月前 (09-10)相关文章

VLC 大概是唯一一个不用解释的播放器名字。1996 年从巴黎中央理工的学生项目起步,现在桌面、手机、机顶盒、嵌入式设备上都能见到它——很多商业产品底层用的也是它。

它凭什么什么都能播?我把 videolan/vlc 的 master 分支拉下来看了一段时间,这篇把架构、核心代码和数据流转拆开讲。代码引用都标了文件和行号,可以自己对着看。

一、播放一个视频到底要经过多少步

拿最普通的本地 MP4 说。VLC 要先打开文件,读字节流,解析容器格式,从里面分出视频轨和音频轨,按各自的编码格式解码,视频帧送去渲染、音频采样送去声卡,最后还得让两边对上时间。

单路播放就这些活儿。再加上网络串流、字幕、转码、硬件解码,组合数就上去了。

这里有个常见误解:以为 VLC 是靠写了一大堆 if-else 覆盖各种格式。不是。它是一个很小的核心加一大堆插件,新格式、新协议、新滤镜都做成模块挂上去,核心基本不动。这个思路不新鲜,但 VLC 坚持了二十多年,插件数量到了几百个还能维持住,这就比较难得了。

二、四层结构加一张插件网

2.1 目录就是架构图

VLC 的目录划分基本等于它的架构说明:

  • lib/:公共 API,也就是 libvlc
  • src/:核心引擎,编译产物叫 libvlccore
  • modules/:几百个功能模块
  • include/:模块之间的接口契约

图片

VLC 播放器分层架构图(依据 videolan/vlc master 源码)
层次
所在目录
职责
前端与 API 层
lib/
对外 libvlc 接口,GUI 只调它
核心引擎层
src/
播放调度、输入、解码、输出、时钟
模块插件层
modules/
access、demux、codec、filter、输出后端等
操作系统层
posix/
win32/darwin/
系统接口的抽象封装

2.2 libvlc:所有 GUI 只碰这一层

Qt 桌面版、Android、iOS、Web、命令行,全都只依赖同一个 C 接口。看一下 play 的实现:

// lib/media_player.c:1044intlibvlc_media_player_play( libvlc_media_player_t *p_mi ){vlc_player_t *player = p_mi->player;    vlc_player_Lock(player);int ret = vlc_player_Start(player);if (ret == VLC_SUCCESS)    {if (vlc_player_IsPaused(player))            vlc_player_Resume(player);    }    vlc_player_Unlock(player);return ret;}

两个细节。一是 API 层不自己管播放状态,直接转给核心层的 vlc_player_t;二是全程加锁——播放器内部线程很多,状态访问不加锁迟早出问题。

对 GUI 开发者来说,需要理解的就只有 libvlc_media_player_* 这几十个函数,内部跑了多少线程完全不用管。

2.3 src/:核心只管流程

src/ 下面这么分的:

  • src/player/:新一代播放状态机 vlc_player,管媒体项、轨道选择、速度、A/V 同步这些高层语义
  • src/input/:输入引擎,创建输入线程,协调 demux、es_out、decoder、stream
  • src/video_output/、src/audio_output/:输出核心
  • src/clock/:时间权威,管 PCR/DTS/PTS
  • src/modules/:模块装载器,按优先级选插件
  • 还有 playlist/、preparser/、stream_output/、interface/ 等

核心层写得很克制:只管流程编排和通用机制,具体能力一律往外推。它根本不知道 MKV 是什么东西——要解某个格式,是模块装载器去 modules/ 里找对应插件来干。

2.4 modules/:能力全外挂

按能力分类,数量很夸张:

  • modules/access/:file、http、ftp、srt、dvdnav、dvb 等
  • modules/demux/:mkv、mp4、ts、avi、ogg、asf 等
  • modules/codec/:avcodec(FFmpeg)、dav1d(AV1)、x264/x265 等
  • modules/packetizer/:把散的数据整理成完整帧
  • modules/video_output/、modules/audio_output/:各平台显示和声卡后端
  • video_filter、audio_filter、mux、stream_out、services_discovery 等

每个模块就是实现了某个接口的插件,由 vlc_module_load 动态加载。几十种封装格式、几百种编解码器,全挂外面。

三、播放流水线是怎么搭起来的

3.1 es_format_t:管道里的通用货币

VLC 内部用 es_format_t 统一描述一路音视频流。MP4 里的 h264 视频轨、AAC 音频轨,最后都归一化成这个结构:

// include/vlc_es.h:615(节选)structes_format_t{enum es_format_category_e i_cat;    /**< ES category */vlc_fourcc_t    i_codec;            /**< FOURCC value as used in vlc */vlc_fourcc_t    i_original_fourcc;  /**< original FOURCC from the container */int             i_id;       /* es identifier */int             i_group;    /* program group */int             i_priority;char            *psz_language;        /* human-readable language name */union {struct {audio_format_t       audio;  /* description of audio format */audio_replay_gain_t  audio_replay_gain;        };video_format_t video;             /* description of video format */subs_format_t  subs;              /* description of subtitle format */    };/* ... */};

i_cat 是类别(VIDEO_ES/AUDIO_ES/SPU_ES/DATA_ES),i_codec 是 FOURCC 编码号,union 里放分辨率、采样率这些具体参数。

这个结构在整条管道里到处传:demux 产出它,decoder 靠它选解码器,vout/aout 靠它分配资源。定义稳定,上下游才能各自独立演进。

3.2 输入线程的 MainLoop

输入引擎的核心在 src/input/input.c 的 MainLoop。结构不复杂,但它是整个播放的驱动源——一个线程反复推着 demux 往前走:

// src/input/input.c:502(节选)staticvoidMainLoopDemux( input_thread_t *p_input, bool *pb_changed ){    ...if( i_ret == VLC_DEMUXER_SUCCESS )        i_ret = demux_Demux( p_demux );   // 驱动解复用器向前解析    ...if( i_ret == VLC_DEMUXER_EOF )    {        msg_Dbg( p_input, "EOF reached" );        p_priv->master->b_eof = true;        es_out_Eos(p_priv->p_es_out);     // 通知输出端:流结束    }/* ... */}

一次 demux_Demux(p_demux) 会让当前 demux 模块(比如 mkv demux)解析出一段数据,同时回调给 es_out。返回 EOF 就广播流结束。

整个播放过程,就是这个循环一遍一遍跑。

3.3 es_out:分发的中间层

demux 解出来的 ES 要送到不同解码器去,中间靠 es_out。它的接口是一张回调表:

// include/vlc_es_out.h:131structes_out_callbacks{es_out_id_t *(*add)(es_out_t *, input_source_t *in, constes_format_t *);int          (*send)(es_out_t *, es_out_id_t *, block_t *);void         (*del)(es_out_t *, es_out_id_t *);int          (*control)(es_out_t *, input_source_t *in, int query, va_list);void         (*destroy)(es_out_t *);};

语义很直接:发现新轨道就 add,有数据就 send,轨道结束就 del。核心通过 control 下发控制命令,比如 ES_OUT_SET_PCR、ES_OUT_SET_ES(枚举在 vlc_es_out.h:39)。

es_out 的实现(src/input/es_out.c)里会为每路 ES 建对应的解码器。demux 完全不知道解码器的存在,两边解耦就靠这一层。

3.4 每路 ES 一条解码线程

解码是 CPU/GPU 重活,不能放在主循环里。所以每路 ES 有自己的解码线程,主循环在 src/input/decoder.c:1976:

staticvoid *DecoderThread( void *p_data ){vlc_input_decoder_t *p_owner = (vlc_input_decoder_t *)p_data;    ...switch (p_owner->cat)    {case VIDEO_ES: thread_name = "vlc-dec-video"; break;case AUDIO_ES: thread_name = "vlc-dec-audio"; break;case SPU_ES:   thread_name = "vlc-dec-spu"; break;        ...    }    vlc_thread_set_name(thread_name);/* The decoder's main loop */    vlc_fifo_Lock( p_owner->p_fifo );while( !p_owner->aborting || p_owner->flushing )    {        ...        DecoderThread_ProcessInput( p_owner, frame );  // 取一帧喂给解码模块    }}

在系统里 ps 一下能看到 vlc-dec-video、vlc-dec-audio 这些线程。各自从一个 FIFO 拿 block_t,调真正的解码模块(多半是 avcodec),把输出(picture_t 或音频采样)往后送。

解码和输入解耦这件事,是边下边播、拖拽不卡的基础。输入线程读它的,解码线程解它的,中间靠队列缓冲,一边慢下来不会直接把另一边卡死。

四、一帧画面从文件到屏幕

把上面这些串起来,就是完整链路。一张图把数据流、控制流、时钟流三套链路分开画:

图片

VLC 播放数据流转图(数据流 + 控制流 + 时钟同步)

4.1 数据流

  1. access:按 MRL(file://、http:// 等)打开来源,产出字节流 stream_t
  2. stream_filter:可选,解压、解密之类
  3. demux:解析容器,产出带时间戳的 ES 数据块,通过 es_out_Add 登记轨道
  4. es_out:按类别路由,内部建解码器,数据块经 es_out_Send 进解码线程的 FIFO
  5. decoder 线程:解码模块消费 FIFO,视频出 picture_t,音频出采样块
  6. vout / aout:分别做渲染调度和混音输出,最终到显示器和声卡

4.2 控制流走另一条路

播放、暂停、拖拽这些操作不走数据管道。它们走的是:GUI → libvlc_* → vlc_player 状态机 → 输入线程的控制队列(ControlPop)→ 影响 demux(seek、暂停)和解码线程(flush、暂停)。

为什么要分开?因为数据管道里随时有几十个包在传,命令如果混进去排队,seek 要等前面的数据都走完才能生效。分开走就能立刻响应。

4.3 时钟流

视频和音频的解码速度天然不一致,靠什么对齐?

VLC 用 src/clock/ 的 vlc_clock 统一管时间基准。demux 通过 ES_OUT_SET_PCR(vlc_es_out.h:66)上报系统时钟参考 PCR,各输出按自己的 PTS 到点呈现。谁早谁晚由时钟裁定,音频输出端还会反过来控制解码节奏,从机制上保证声画同步。

这块其实是整个播放器最麻烦的部分。不响应瞬时偏差、只响应平滑后的长期漂移,还要处理 seek 之后的时间线分叉。展开讲能写一整篇。

五、从 VLC 学到的三件事

  • 接口稳定,实现外挂。es_format_t、es_out_callbacks 这些是契约,格式、协议、编码全做成模块,加新能力就是加一个插件
  • 关注点分离。数据流、控制流、时钟流互不干扰,输入、解码、输出各跑各的线程
  • 让时间说话。播放问题九成是时序问题,用一个权威时钟统一裁定,别让各模块各自为政

这套设计是二十多年前定的,今天看依然站得住。如果你在做一个要长期演进、还要支持海量扩展的系统,VLC 的代码值得翻一翻。


本文代码引用均来自 videolan/vlc 官方仓库 master 分支,可以按文中标注的文件和行号在 GitHub 上核对。