控制台版的内容只有「一个窗口 + 一个事件循环」,其余全用上一章的 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 秒实验
- 把
SDL_Delay(1)改成SDL_Delay(5),看平均休眠时间是不是变成五倍。 - 算一下:如果没有主时钟,循环跑到每秒两万帧,一秒钟白烧多少次「画一帧」?
- 判断一下:
-x 800写在文件名后面,程序会怎么理解这一行?
常见误区
- 以为编译过、链接过就没问题了。 最常见的失败在运行期:DLL 没拷、工作目录不对, 报的是「找不到 DLL」。
- 以为
SDL_Delay(16)就是 60 帧。 实测睡一次接近两毫秒起,误差累积,一分钟就差出好几秒。 - 以为参数写在哪儿都行。
a.mp4 -x 800里的-x会被当成第二个文件名。 - 以为
SDL_CreateRenderer传-1是「自动选最好的」。 它是「自动选一个能用的」, 可能是软件渲染。
自测三问(点开对答案)
1. `LNK2019` 未解析的外部符号,最可能是哪一步配错了? —— 第四步,附加依赖项里漏了对应的 lib。也可能是平台不一致(x86 的 lib 配 x64 的工程)。 2. 为什么不能写「每帧 SDL_Delay(16)」来控速? —— 因为 Sleep 类函数的实际休眠时间大于请求值,误差会累积。 正确做法是记住下一帧的绝对时间,每次补差。 3. 退出事件为什么能做到「立刻」? —— 因为循环每轮都轮询事件,事件一进来当轮就跳出。这是消息驱动,不是「跑完 120 帧再看」。控制台版跑通,说明「引擎 + 壳」这条线是通的。接下来换壳:先把 SDL 的窗口换成 MFC 的窗口。那里要动的地方比想象中少——只有两处,但两处都很要命。
- ☐ (本节完,下一节 08.03 MFC移植FFplay播放器及二次开发)