🎬 第八章 FFplay+SDL 2开发音视频流媒体播放器
📄 一句话概括

控制台版的内容只有「一个窗口 + 一个事件循环」,其余全用上一章的 API。 真正卡住人的不是代码,是工程配置——包含目录、库目录、DLL 三样错一样就跑不起来,而且报错各不相同。

📝 本节和前面几节的关系

08.01 说清了 ffplay 的结构,这一节把它缩到最小,先跑起来。 为什么从控制台开始:没有框架掺和,出了问题就一定是你自己的代码或配置。 MFC 和 Qt 版的「跑不起来」,八成都跟这一节是同一个原因。 所以这一节的价值不在「多学了一个程序」,而在「建立一张错误对照表」。

一、为什么先做控制台版

同一个播放器,从控制台到 MFC,改动的只有两处:窗口从哪来、消息谁泵。 其余部分一行不动。

所以先把这两处之外的全都跑通,是省时间的做法: 控制台版跑通了,后面两节的失败就只可能出在那两处。

反过来,如果直接上 MFC:你同时面对「配置对不对」和「框架对不对」两个未知数, 而这两个未知数的报错还特别像——都是「跑起来就崩」,看不出是哪一边。

这是本书反复出现的套路:先把变量减到一个,再改。 第五章从 OpenCV 的单文件开始、第七章从命令行开始,都是这个道理。

二、VS 2015 工程的五步配置

先把清单列出来,后面再讲每步错在哪:

第一步,新建「Win32 控制台应用程序」,空项目,不勾预编译头。 第二步,在「包含目录」里加上 SDL2 的 include 和 FFmpeg 的 include。 第三步,在「库目录」里加上 SDL2 的 lib/x64 和 FFmpeg 的 lib。 第四步,在「附加依赖项」里填七个 lib:SDL2、SDL2main、avformat、avcodec、avutil、swscale、 swresample。 第五步,把 SDL2.dll 和那七个 av*.dll 拷到输出目录, 并把「调试 → 工作目录」设成 $(OutDir)。

第五步是错得最多的一步, 因为它不出编译错误也不出链接错误——编译过了、链接过了,双击运行弹窗说「找不到 SDL2.dll」。

这张表是本节最该抄下来的东西:

症状 原因 改哪一步
LNK2019 未解析的外部符号 附加依赖项漏了某个 lib 第四步
LNK1120 未解析的外部命令 同上,是它的汇总 第四步
LNK2038 运行时不匹配 lib 的编译选项跟工程不一致 换成同一套 lib
编译过、运行提示找不到 DLL DLL 没拷或工作目录不对 第五步
LNK1112 模块计算机类型冲突 lib 是 x86,工程是 x64 平台选 x64
SDL_main 未定义 没链 SDL2main.lib 第四步

最后一行值得单独说:SDL 会把你的 main 改名成 SDL_main,然后自己提供一个真的 main, 在它里面先做平台初始化再调你的。 所以不链 SDL2main.lib,编译器就找不到那个入口。

想绕开这个机制,在文件最前面写一行 #define SDL_MAIN_HANDLED, 然后自己负责初始化——MFC 和 Qt 版必须这么做,因为那里根本不该有 main。

2026 年的做法:别手工配路径了。 vcpkg install sdl2 ffmpeg:x64-windows,再 vcpkg integrate install, 之后新建的工程自动带好包含目录和库,DLL 也会在调试时自动就位。

三、main 的骨架只有六行

把配置这层剥掉,主程序短得有点意外:

SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO);
win = SDL_CreateWindow("p", 100, 100, 800, 600, SDL_WINDOW_SHOWN);
ren = SDL_CreateRenderer(win, -1, SDL_RENDERER_ACCELERATED);
while (!quit) {
    while (SDL_PollEvent(&e)) if (e.type == SDL_QUIT) quit = 1;
    SDL_RenderClear(ren);   /* 画 */ SDL_RenderPresent(ren);
}

六件事:初始化、建窗口、建渲染器、收事件、画、退出。 ffplay 的三千行里,这六件事只占几十行,剩下全在「画什么」上。

这里有两个参数值得解释:SDL_CreateRenderer 的第二个参数传 -1,意思是「你帮我挑」, 第三个参数是「挑不到就退而求其次」的候选顺序。

传 -1 的代价是「不确定」:换台机器可能就从 direct3d11 变成了 software, 而软件渲染的耗时是硬件的好几倍。 调试性能问题时,先把选到的驱动名字打出来,这一步能省掉很多「为什么我的机器这么慢」的困惑。

四、命令行参数:带值的与开关的

ffplay 的参数分两类,混起来就出错:

参数 类型 作用
-x -y 带值 初始窗口宽高
-t 带值 播放多少秒后停
-window_title 带值 窗口标题
-an -vn 开关 关掉音频/视频
-nodisp 开关 不建窗口,只出声
-autoexit 开关 播完自动退出

区分「带值」和「开关」靠一张表,代码里就是「读不读下一个 argv」。 书上那个 opt_default 函数,干的就是这件事。

顺序也有讲究:参数必须写在输入文件前面。 写成 a.mp4 -x 800,那个 -x 会被当成第二个输入文件, 而 a.mp4 前面的窗口宽高就全用默认值。 这种错不报错、不崩溃,只是「你设的没生效」——最难查的一类。

Demo 里把这一段的解析逻辑写了一遍, 结果是四个带值参数、四个开关、一个位置参数,分得干干净净。 注意 -vn 和 -nodisp 在这里都显示「否」, 因为命令里没给——「没给」和「给了个假值」是两件事,别混。

五、事件循环里的节拍

这是控制台版唯一一个「看起来简单、其实全是坑」的地方。 两件事:轮询要多少钱、睡觉准不准。

第一件事的答案是「几乎不要钱」:一轮 SDL_PollEvent 是一两微秒, 所以每帧都轮一遍完全没问题,不必像老式 Win32 程序那样挂在消息循环里等。

第二件事的答案是「很不准」:SDL_Delay(1) 实测平均睡将近两毫秒, 五十次里没有一次低于一毫秒。 你说「睡 1」,系统给你 2。

所以「每帧 SDL_Delay(16) 就是 60 帧」这个写法是错的: 每次多睡一点,误差一路累积,一分钟就差出一大截。

double next = now();
while (running) {
    render();
    next += 1.0 / 60;            /* 对绝对时间,不是「睡 16 毫秒」 */
    double d = next - now();
    if (d > 0) SDL_Delay((Uint32)(d * 1000));
}

这段代码的关键是 next += 1.0 / 60: 它记住的是「下一帧应该在几时几分」,不是「还要等多久」。 这样每次睡偏了,下一次会自动补回来,误差不累积。

这就是「主时钟」的雏形。 第九章讲的音视频同步,本质上就是把这个 next 换成「音频播到哪儿了」。

六、Demo 里那个「空转两万帧」说明了什么

Demo 把一个真事件循环跑了 120 帧,并让它在第 30 帧主动收到退出事件。 三个结果都值得看:

第一,退出是「立刻」的:事件进来那一轮就跳出循环,不会把 120 帧跑完。 这就是「消息驱动」和「轮询」的区别——轮询只配做「顺带看一眼」,退出与否由事件说了算。

第二,帧率完全失控:没有任何节拍控制时,循环跑到每秒两万多帧, 烧的是 CPU,跟视频原本的 30 帧毫无关系。

第三,单帧间隔短到 0.03 毫秒, 这说明「什么都不画」的空循环有多快——所以一个播放器如果 CPU 占用高,一定不是循环本身的问题,而是里面干了重活。

这三条合起来就是一句话:没有主时钟的循环,不是播放器,是一场越快越好的幻灯片。

七、动手 Demo:控制台版的参数解析与事件循环,两段都能单独测

main 只有两件事:解析参数、进事件循环。 这两件事在沙箱里都能真跑,不用装 VS。 先看参数怎么分「带值」和「开关」,再看事件循环里的三个关键数。

# 1) 参数:带值的读下一个,开关的直接置位
i = 0
while i < len(argv):
    a = argv[i]
    if a in OPT_WITH_VALUE:   # -x -y -t -window_title
        opts[a] = argv[i + 1]; i += 2
    elif a in FLAGS:          # -an -vn -nodisp -autoexit
        flags.append(a); i += 1
    else:
        rest.append(a); i += 1

# 2) 节拍:SDL_Delay(1) 到底睡多久
t0 = time.perf_counter(); SDL_Delay(1)
print((time.perf_counter() - t0) * 1000)

# 3) 事件循环:第 30 帧塞一个 SDL_QUIT
pe = SDL_Event(); pe.type = SDL_QUIT
qe = SDL_Event()          # 投递和接收要用两个结构体
while frames < 120:
    if frames == 30: SDL_PushEvent(byref(pe))
    while SDL_PollEvent(byref(qe)):
        if qe.type == SDL_QUIT: done = True; break

实跑输出:

--------------------------------------------------------------------
控制台版的参数解析与事件循环,两段都能单独测
--------------------------------------------------------------------
**ffplay 的 `main` 只有两件事**:**解析参数、进事件循环。**
**这两件事在沙箱里都能真跑。**

**先看参数**。命令行长这样:

  ffplay -x 800 -y 600 -t 10 -an -window_title 测试窗口 -autoexit a.mp4

  类型     参数              取值
  ---------------------------------------
  带值     -x                800
  带值     -y                600
  带值     -t                10
  带值     -window_title     测试窗口
  开关     -an               是
  开关     -vn               否
  开关     -nodisp           否
  开关     -autoexit         是
  位置     输入文件          a.mp4

**注意 `-x` 和 `-y` 是「带值」、`-an` 是「开关」,这个区分靠一张表**。
**书上的 `opt_default` 就是这张表的 C 版本。**
**顺序也有讲究**:**命令行参数必须在输入文件之前**——**`a.mp4 -x 800` 里的 `-x` 会被当成文件名。**

**再看事件循环。先量一个不起眼但很关键的数:`SDL_Delay(1)` 到底睡多久。**

  `SDL_Delay(1)` 五十次:**平均 1.96 毫秒,最短 1.02,最长 2.64**

**它根本睡不到 1 毫秒**——**五十次里没有一次低于 1 毫秒,平均接近 2。**
**你说「睡 1」,操作系统给你 2。**
**这不是 SDL 的毛病**,
**是系统定时器的分辨率决定的**——**Windows 上普通定时器的粒度就在毫秒级往上。**
**所以「用 `SDL_Delay(1)` 数着秒打节拍」一定跑偏**,**一秒打 60 次会变成一秒 30 次。**
**ffplay 的做法是「算出应该到几点,再看还差多少」**——**差多少睡多少,误差不累积。**

**现在跑一个真的事件循环**:120 帧,第 30 帧主动塞一个 `SDL_QUIT` 进去,看它是不是立刻退出。

  **在第 30 帧塞入 `SDL_QUIT`,循环在第 30 帧退出**
  **什么都没做的一轮 `PollEvent`:平均 1.78 微秒**
  空转时两帧的间隔:**中位 0.031 毫秒,最长 0.049 毫秒**
  换算成帧率:**每秒 32258 帧**

**三个数各有各的用处**:
  **第一,`PollEvent` 的微秒级开销说明「轮询」本身几乎不要钱**,
  **所以 ffplay 可以每帧都轮一遍,不必挂着等事件。**
  **第二,退出是「立刻」的**——**`SDL_QUIT` 进来那一轮就跳出去了**,**不会把 120 帧跑完。**
  **第三,帧率完全失控**——**上面那个「每秒几万帧」就是空转烧 CPU 的证据**。
  **循环跑多快,机器就有多拼,跟视频原本的 30 帧/秒毫无关系。**

**第三条正是播放器必须有「主时钟」的原因**:
**没有时钟,你放出来的不是视频,是一场越快越好的幻灯片。**
**第九章整章都在解决这件事。**

**这段代码在 MFC 或 Qt 里就要换写法**:**消息泵不能自己写 `while (PollEvent)`**,
**得挂在宿主框架的 `OnIdle` 或 `QTimer` 上**——**这是第八章后两节要解决的核心矛盾。**

参数那段没什么玄机,但那张表的意义要说清: 「带值」和「开关」的区别不是风格问题,是「会不会把文件名吃掉」的问题。 漏认一个带值参数,后面那个值就会被当成输入文件, 然后你会看到一个莫名其妙的「打不开 xxx」的错误。

SDL_Delay(1) 那个数最反直觉:平均接近两毫秒,最短也超过一毫秒。 它不是 SDL 的实现问题,是系统定时器分辨率的问题。 所以任何「用固定睡眠次数来算时间」的写法都不成立——哪怕你把 SDL_Delay 换成 sleep、换成 nanosleep,结论一样。

事件那段里有个坑要单独提:投递和接收不能用同一个 SDL_Event 结构体。 本课第一次写这段时共用了同一个,结果事件发出去、收不回来,循环一路跑到 120 帧才停。 换成两个结构体,第 30 帧就干净退出了。 这种错不会报错,只会「行为跟你想的不一样」。

退出「立刻生效」这件事,是消息驱动模型的核心:它不管你循环到第几帧,事件来了就是来了。 MFC 里换成了 WM_CLOSE,Qt 里换成了 closeEvent——名字不同,机制一模一样。

最后那个「每秒两万多帧」不是玩笑:它是「没有主时钟」的直接后果。 你在自己写的播放器里如果看到 CPU 占满、风扇狂转,先查节拍,再看解码。

30 秒实验

  1. 把 SDL_Delay(1) 改成 SDL_Delay(5),看平均休眠时间是不是变成五倍。
  2. 算一下:如果没有主时钟,循环跑到每秒两万帧,一秒钟白烧多少次「画一帧」?
  3. 判断一下:-x 800 写在文件名后面,程序会怎么理解这一行?

常见误区

  1. 以为编译过、链接过就没问题了。 最常见的失败在运行期:DLL 没拷、工作目录不对, 报的是「找不到 DLL」。
  2. 以为 SDL_Delay(16) 就是 60 帧。 实测睡一次接近两毫秒起,误差累积,一分钟就差出好几秒。
  3. 以为参数写在哪儿都行。 a.mp4 -x 800 里的 -x 会被当成第二个文件名。
  4. 以为 SDL_CreateRenderer 传 -1 是「自动选最好的」。 它是「自动选一个能用的」, 可能是软件渲染。
自测三问(点开对答案) 1. `LNK2019` 未解析的外部符号,最可能是哪一步配错了? —— 第四步,附加依赖项里漏了对应的 lib。也可能是平台不一致(x86 的 lib 配 x64 的工程)。 2. 为什么不能写「每帧 SDL_Delay(16)」来控速? —— 因为 Sleep 类函数的实际休眠时间大于请求值,误差会累积。 正确做法是记住下一帧的绝对时间,每次补差。 3. 退出事件为什么能做到「立刻」? —— 因为循环每轮都轮询事件,事件一进来当轮就跳出。这是消息驱动,不是「跑完 120 帧再看」。

控制台版跑通,说明「引擎 + 壳」这条线是通的。接下来换壳:先把 SDL 的窗口换成 MFC 的窗口。那里要动的地方比想象中少——只有两处,但两处都很要命。

相关笔记