想象一下:你在 mpv 里写了一个 Lua 脚本,只需要
mp.register_event("start-file", fn),播放新文件时fn就自动被调用。你不知道是谁在监听事件、不知道回调是怎么被调度的、甚至不需要写主循环——但它就是能工作。这背后,是
mp模块提供的一整套运行时环境在默默运转。本文用"操作系统"类比,从设计层面讲清楚这套环境做了什么、怎么实现的。
文章目录
1. 把 mp 模块理解为一个"操作系统"
如果你写过嵌入式程序或者 Node.js,这个类比应该很亲切:
| 层 | 对应 mpv 实际组件 | 职责 |
|---|---|---|
| 用户程序 | 所有 Lua 脚本(OSC、stats、你的脚本…) | 写业务逻辑,调用 API |
| 操作系统 | defaults.lua(~600 行 Lua 代码) | 事件循环、定时器、回调分发、属性观察 |
| 内核 | C 层的 mp 模块表(main_fns 数组中的函数) | 底层系统调用:读写属性、等待事件、请求通知 |
| 硬件 | mpv 核心(player/core.c 等) | 解码、渲染、播放控制 |
关键洞察:你写的 Lua 脚本不是在"裸机"上运行——mp 模块的 C 函数是系统调用,defaults.lua 用这些系统调用搭了一套"应用框架",你只需要注册回调就行了。
2. “内核”(C 层 mp 模块)提供了什么?
在 run_lua 初始化之后,每个脚本的 Lua 虚拟机里,mp 这个全局表有近 30 个 C 函数可用。按功能分为五类:
2.1 读取/写入播放器状态
-- 读当前音量
local vol = mp.get_property_number("volume") -- → 75
-- 设置属性
mp.set_property("volume", 50) -- 音量设为 50%
mp.set_property_bool("pause", true) -- 暂停
-- 读取原生(native)格式——返回 Lua table 而非字符串
local tracks = mp.get_property_native("track-list")
-- tracks = {
-- {type="video", selected=true, ...},
-- {type="audio", selected=true, lang="eng", ...},
-- }
C 侧实现:script_get_property_number、script_set_property 等函数,通过 mpv_get_property / mpv_set_property 与核心通信。
2.2 接收播放器事件
-- 告诉 mpv:"我对 start-file 事件感兴趣"
mp.request_event("start-file", true)
-- 阻塞等待下一个事件(通常在事件循环中调用)
local event = mp.wait_event(1.0) -- 最多等 1 秒
-- event = { event = "start-file", ... }
C 侧实现:script_request_event → mpv_request_event;script_wait_event → mpv_wait_event。这是事件驱动模型的基础。
2.3 发送命令
mp.command("playlist-next") -- 跳到下一个文件
mp.commandv("seek", "30", "absolute") -- 跳转到 30 秒
2.4 观察属性变化(底层)
-- raw_ 前缀表示这是底层 API,通常不直接使用
-- 高层封装在 defaults.lua 的 mp.observe_property 中
local id = 1
mp.raw_observe_property(id, "pause", "bool") -- "当 pause 变化时通知我"
C 侧实现:script_raw_observe_property → mpv_observe_property。当属性变化时,mpv 核心会发送 property-change 事件,携带 id 字段来标识是哪个观察。
2.5 日志输出
mp.log("info", "当前音量: " .. vol)
mp.msg.warn("文件即将结束") -- defaults.lua 封装的高层 API
2.6 五类系统调用一览
mp 模块表的 C 函数(~30个)
├── 属性读写 ─── get_property, set_property, get_property_native, ...
├── 事件机制 ─── wait_event, request_event, ...
├── 命令执行 ─── command, commandv, command_native, ...
├── 属性观察 ─── raw_observe_property, raw_unobserve_property
├── 日志输出 ─── log, enable_messages
├── 定时器 ─── (无——定时器纯 Lua 实现,见下文)
└── 工具函数 ─── get_time, format_time, find_config_file, ...
关键:C 层没有提供
add_timeout、register_event、observe_property——这些是defaults.lua用底层 API 拼装出来的。
3. “操作系统”(defaults.lua)在系统调用之上做了什么?
defaults.lua 读起来像一个小型操作系统内核。它在 C 层"系统调用"之上,构建了四个核心服务:
3.1 服务一:事件分发系统
问题:C 层 mp.wait_event() 返回一个原始事件,脚本需要自己判断事件类型、自己写 if/elseif 分发。每个脚本都写一遍这些分支?
defaults.lua 的解决方案:一个全局注册表 + 回调分发。
-- 全局注册表:事件名 → 回调函数列表
local event_handlers = {
["start-file"] = { osc.request_init, stats.on_new_file, ... },
["end-file"] = { ... },
["property-change"] = { defaults.property_change },
["shutdown"] = { defaults.exit },
["client-message"] = { defaults.message_dispatch },
}
-- 注册 API:脚本只需要关心"我要什么事件 + 干什么"
function mp.register_event(name, cb)
local list = event_handlers[name]
if not list then
list = {}
event_handlers[name] = list
end
list[#list + 1] = cb
return mp.request_event(name, true) -- 告诉 C 层"我需要这个事件"
end
-- 事件循环中收到事件后的分发逻辑
local function call_event_handlers(e)
local handlers = event_handlers[e.event]
if handlers then
for _, handler in ipairs(handlers) do
handler(e)
end
end
end
使用效果:
-- 你的脚本只需三行,不需要理解事件循环内部怎么运转:
mp.register_event("start-file", function(e)
mp.msg.info("新文件开始播放!")
end)
3.2 服务二:属性观察系统
问题:C 层的 raw_observe_property 只做了"告诉核心我要观察"这一步。当属性变化时,核心发来的是 {event="property-change", id=1, data=...}——脚本还得自己维护 id → 回调 的映射。
defaults.lua 的解决方案:自动分配 id、自动映射、对脚本完全透明。
local property_id = 0
local properties = {} -- { [id] = callback }
function mp.observe_property(name, t, cb)
local id = property_id + 1
property_id = id
properties[id] = cb -- 记住 id → 回调
mp.raw_observe_property(id, name, t) -- 调用 C 层系统调用
end
-- 事件循环中收到 property-change 事件时的自动分发:
mp.register_event("property-change", function(ev)
local prop = properties[ev.id]
if prop then
prop(ev.name, ev.data) -- 自动找到正确的回调并调用
end
end)
使用效果:
-- 一行代码,属性变化时自动通知你
mp.observe_property("volume", "number", function(name, val)
print("音量变为: " .. val)
end)
3.3 服务三:纯 Lua 实现的定时器系统
问题:C 层没有 setTimeout。怎么让脚本能"1 秒后执行某操作"?
defaults.lua 的解决方案:利用 mp.wait_event(timeout) 的阻塞超时机制,在事件循环中插入定时器检查。
local timers = {} -- 所有活跃定时器
function mp.add_timeout(seconds, cb, disabled)
-- 创建 oneshot 定时器对象,塞入 timers 表
local t = mp.add_periodic_timer(seconds, cb, disabled)
t.oneshot = true
return t
end
function mp.add_periodic_timer(seconds, cb, disabled)
local t = {
timeout = seconds,
cb = cb,
oneshot = false,
next_deadline = mp.get_time() + seconds,
}
timers[t] = t -- 注册进全局表
return t
end
-- 事件循环中每次迭代前执行:
local function process_timers()
while true do
local timer = get_next_timer() -- 找最近到期的
if not timer then return end
local wait = timer.next_deadline - mp.get_time()
if wait > 0 then return wait end -- 还没到期,返回"要等多久"
-- 到期了:执行回调
if timer.oneshot then
timer:kill()
else
timer.next_deadline = mp.get_time() + timer.timeout
end
timer.cb()
end
end
定时器不是操作系统中断——是事件循环中"插空"执行的。每次进入 dispatch_events 的循环体时,先检查有没有到期的定时器,有就先执行。
使用效果:
-- 3 秒后弹出提示
mp.add_timeout(3, function()
mp.osd_message("欢迎使用 mpv!")
end)
-- 每 0.1 秒刷新一次 UI(osc.lua 的核心驱动方式)
local tick_timer = mp.add_periodic_timer(0.1, function()
render_ui()
end)
3.4 服务四:主事件循环
-- 这就是所有脚本共享的主循环:
function mp.dispatch_events(allow_wait)
while mp.keep_running do
-- ❶ 检查并执行到期的定时器
local wait = process_timers() or 1e20
-- ❷ 阻塞等待 mpv 核心发事件(或定时器超时)
local e = mp.wait_event(wait)
-- ❸ 分发给所有注册了此事件的脚本回调
if e.event ~= "none" then
call_event_handlers(e)
end
end
end
_G.mp_event_loop = function()
mp.dispatch_events(true)
end
一个循环周期 = 检查定时器 → 等待事件 → 分发回调。整个过程在 while mp.keep_running 中无限循环,直到收到 shutdown 事件(exit() 设置 mp.keep_running = false)。
4. 一个脚本的完整生命周期
现在我们把前面各篇的知识串起来,看一个脚本从"零"到"运行"的完整过程。
4.1 启动阶段
4.2 运行阶段——具体例子
假设你写了一个脚本,功能是"每次切换文件时打印文件名"。完整过程:
你的脚本(只有 3 行):
mp.register_event("start-file", function()
local path = mp.get_property("path")
mp.msg.info("正在播放: " .. path)
end)
运行时发生了什么:
1. 注册阶段
register_event("start-file", cb)
→ event_handlers["start-file"] = {..., cb} (塞入全局表)
→ mp.request_event("start-file", true) (告诉 C 层)
2. 事件循环中
mp.wait_event(1e20) 阻塞...
↓ mpv 核心加载了新文件
event = { event = "start-file", ... }
↓
call_event_handlers(event)
→ 遍历 event_handlers["start-file"]
→ 调用你的 cb()
→ cb 内部调用 mp.get_property("path")
→ C 函数 script_get_property → mpv_get_property
→ 返回文件路径
→ mp.msg.info(...) 输出日志
3. 无限循环
回到 while mp.keep_running,等待下一个事件
整个过程中你没有写的代码:
- 事件循环(
while循环) - 事件分发(
if event == "start-file" then ...) - 定时器检查
- 退出处理
全部由 defaults.lua 提供。
5. 为什么叫"操作系统"而不是"框架"?
框架通常需要你遵循它的结构——Django 需要你写 urls.py,React 需要你写 render()。但 mpv 的 defaults.lua 更像是操作系统:
-
脚本可以不依赖它。你可以完全不用
register_event,自己写while true do mp.wait_event() end。就像程序可以不调libc,直接调syscall。 -
它是可替换的。如果你不喜欢
defaults.lua的事件循环,你可以在自己的脚本里重新定义_G.mp_event_loop——load_scripts永远取最后定义的版本。
-- 你的脚本覆盖默认事件循环
_G.mp_event_loop = function()
-- 你完全自己控制循环逻辑
while true do
local e = mp.wait_event(-1) -- 永久阻塞
if e.event == "shutdown" then break end
my_dispatch(e)
end
end
- 它提供的是"服务"而非"约束"。你用
register_event也好、observe_property也好、add_timeout也好,都是按需调用,不需要继承任何基类、实现任何接口。
6. 串联四篇:从 C 函数到界面渲染
一条完整的调用链(以读取属性为例):
用户脚本: mp.get_property("volume")
→ C 层: script_get_property(② af_pushcclosure 包装过的闭包)
→ trampoline: talloc_new → pcall → talloc_free(② 自动资源管理)
→ autofree_call: 取出上值中的函数指针
→ 实际执行: mpv_get_property(client, "volume", ...)
← 返回值沿调用栈返回
7. 总结
| 概念 | mpv 中的实际组件 | 提供的核心价值 |
|---|---|---|
| 内核(系统调用) | C 层 mp 模块表(~30个函数) | 属性读写、事件等待、命令执行、属性观察 |
| 操作系统(服务层) | defaults.lua(~600行) | 事件分发、属性观察封装、定时器系统、主事件循环 |
| 进程(用户程序) | 每个 Lua 脚本 | 通过 register_event/observe_property/add_timeout 注册回调 |
| 启动器 | lua.c 的 load_scripts | require("mp.defaults") → require(脚本) → mp_event_loop() |
三个关键设计原则:
- 分层清晰:C 层只提供原子操作,Lua 层负责编排——改动事件循环不需要重编译 C 代码。
- 注册而非继承:脚本通过"注册回调"融入系统,不继承任何基类——灵活且无侵入。
- 可替换默认实现:
_G.mp_event_loop可以被任何脚本覆盖——满足特殊需求,不强迫所有人接受同一套设计。
系列目录:
① Lua 闭包与上值 — 从概念到 C API
② mpv 的 af_pushcclosure — 三层闭包与自动资源管理
③ mpv 模块注册:C 函数如何变成 Lua 模块
④ 本文 — mpv 脚本的"操作系统":事件循环与运行时环境

936

被折叠的 条评论
为什么被折叠?



