VLC 源码拆解:从 play 到屏幕上的那一帧
VLC 大概是唯一一个不用解释的播放器名字。1996 年从巴黎中央理工的学生项目起步,现在桌面、手机、机顶盒、嵌入式设备上都能见到它——很多商业产品底层用的也是它。
它凭什么什么都能播?我把 videolan/vlc 的 master 分支拉下来看了一段时间,这篇把架构、核心代码和数据流转拆开讲。代码引用都标了文件和行号,可以自己对着看。
一、播放一个视频到底要经过多少步
拿最普通的本地 MP4 说。VLC 要先打开文件,读字节流,解析容器格式,从里面分出视频轨和音频轨,按各自的编码格式解码,视频帧送去渲染、音频采样送去声卡,最后还得让两边对上时间。
单路播放就这些活儿。再加上网络串流、字幕、转码、硬件解码,组合数就上去了。
这里有个常见误解:以为 VLC 是靠写了一大堆 if-else 覆盖各种格式。不是。它是一个很小的核心加一大堆插件,新格式、新协议、新滤镜都做成模块挂上去,核心基本不动。这个思路不新鲜,但 VLC 坚持了二十多年,插件数量到了几百个还能维持住,这就比较难得了。
二、四层结构加一张插件网
2.1 目录就是架构图
VLC 的目录划分基本等于它的架构说明:
lib/:公共 API,也就是 libvlcsrc/:核心引擎,编译产物叫 libvlccoremodules/:几百个功能模块include/:模块之间的接口契约

lib/src/modules/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、streamsrc/video_output/、src/audio_output/:输出核心src/clock/:时间权威,管 PCR/DTS/PTSsrc/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 或音频采样)往后送。
解码和输入解耦这件事,是边下边播、拖拽不卡的基础。输入线程读它的,解码线程解它的,中间靠队列缓冲,一边慢下来不会直接把另一边卡死。
四、一帧画面从文件到屏幕
把上面这些串起来,就是完整链路。一张图把数据流、控制流、时钟流三套链路分开画:

4.1 数据流
access:按 MRL( file://、http://等)打开来源,产出字节流stream_tstream_filter:可选,解压、解密之类 demux:解析容器,产出带时间戳的 ES 数据块,通过 es_out_Add登记轨道es_out:按类别路由,内部建解码器,数据块经 es_out_Send进解码线程的 FIFOdecoder 线程:解码模块消费 FIFO,视频出 picture_t,音频出采样块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 上核对。
