重拾 Lua:从月亮语言到 FreeSWITCH 里的胶水

Posted on 一 14 9月 2026 in Tech

Abstract 重拾 Lua:从月亮语言到 FreeSWITCH 里的胶水
Authors Walter Fan
Category Tech
Status v1.0
Updated 2026-09-14
License CC-BY-NC-ND 4.0

大纲

展开看看
  • 缘起:十几年前我讲过一场《A Glance of Lua》,如今忘了大半。趁着最近在扒 FreeSWITCH,把这门语言重新拾起来,顺便讲清它在 FreeSWITCH 里到底干什么活。
  • Lua 是什么:一门为“被嵌入”而生的脚本语言。小、快、可移植,靠一个 C API 长在别人家的进程里。Photoshop Lightroom、魔兽世界、Wireshark、Nginx、Redis、FreeSWITCH——它专门干这个。
  • 重拾要点(老程序员版):表即一切(数组从 1 开始)、函数是一等公民、元表就是 JS 的原型、闭包和协程、以及那个把它送进无数大项目的 C API。
  • 五个最容易忘、也最容易栽的坑:下标从 1、~= 不是 !=.: 的静态/实例之别、# 只数连续整数键、pairs vs ipairs
  • 落到 FreeSWITCH:mod_lua 默认加载,脚本直接跑在交换机进程里。四个挂载点——dialplan application、XML handler、event hook、startup script——各配一个能看懂的例子。
  • 最佳实践 + 常见陷阱:脚本目录与 require 路径、session 是会阻塞的、别在 hook 里干重活、错误要 pcall 兜住、日志用 freeswitch.consoleLog
  • 收尾:Lua 在 FreeSWITCH 里的定位是“胶水”,不是“主料”——想清楚这一点,才知道什么该写进脚本,什么该留在 C 模块里。

我电脑里翻出一个 2013 年做的 PPT,标题叫《A Glance of Lua》。那会儿我还能顺手写出插入排序、元表继承、lua_State 压栈弹栈的 C API,甚至比过 Luabind、tolua++、SWIG 几种绑定方案的优劣。十几年过去,这门语言我基本没再碰,再看那些幻灯片,有些代码得盯半天才想起来当初为什么那么写。

最近在扒 FreeSWITCH 的源码——前面几篇分别拆了它的动态加载与服务注册APR 内存池浏览器直连的 WebRTC 通话——绕来绕去总会撞见 Lua。FreeSWITCH 默认就把 mod_lua 装上了,很多呼叫逻辑、IVR 菜单、动态配置都是 Lua 写的。于是干脆借这个由头,把 Lua 重新拾起来。

这篇分两半:上半场用“老程序员重拾旧语言”的角度,把 Lua 最该记住的东西快速过一遍;下半场落到 FreeSWITCH,讲清脚本到底挂在哪几个位置,配例子、最佳实践和坑。 语言部分基于 Lua 5.x 语法,FreeSWITCH 部分引用官方 scripting 文档

  • 只想看 FreeSWITCH 怎么用的,可以直接跳到“落到 FreeSWITCH”那节。
  • 想先把 Lua 捡回来的,跟着上半场走。

Lua 是什么:一门为“寄生”而生的语言

Lua(葡萄牙语里是“月亮”的意思,念作 LOO-ah)是一门小、快、可嵌入的脚本语言。关键词是“可嵌入”——它从设计第一天起就不打算独立当主角,而是长在别人家的进程里,负责那些“不值得用 C 重编一次、又需要灵活改”的活儿。

看看它的用户名单就明白定位了:Adobe Photoshop Lightroom 用它做界面逻辑,魔兽世界用它写插件,Wireshark 用它写协议解析器,Nginx(OpenResty)、Redis、FreeSWITCH 都把它嵌进核心。它们要的都是同一件事:一个能安全地跑用户脚本、又不拖累主程序的小引擎。

为什么是 Lua 而不是别的?简单说:

  • ——整个解释器编译出来几百 KB,可以静态编进你的二进制。
  • ——它跑的是寄存器式虚拟机的字节码,在脚本语言里算快的。
  • 可移植——纯 ANSI C 写的,凡是有 C 编译器的地方就能跑。
  • 免费——MIT 协议,商用无负担。

一句话概括它的性格:do more with less,用尽量少的东西干尽量多的事。这也是为什么它的语法学起来快,忘起来也快——因为核心概念真的没几个。


重拾要点:老程序员该先想起哪几样

十几年没写,再上手其实不用从头学。Lua 的核心就那么几块,抓住它们,剩下的查手册就够了。

表(table)即一切

Lua 只有一种复合数据结构:。它既是数组,又是哈希表,还是对象、模块、命名空间。你在别的语言里区分的 list / dict / object,在 Lua 里都是同一个 table

local user = { name = "tom", age = 30 }   -- 当哈希表用
local arr  = { 10, 20, 30 }               -- 当数组用(下标从 1 开始!)

print(user.name)   -- tom
print(arr[1])      -- 10,不是 arr[0]

下标从 1 开始,这是所有 C/Java/Python 程序员来写 Lua 的第一个坑,后面还要专门说。

函数是一等公民

跟 JavaScript 一样,函数在 Lua 里是值:可以赋给变量、当参数传、当返回值。这带来了几个很好用的特性——多返回值、变长参数、闭包。

-- 多返回值:一次拆出字符串两侧
local function split_once(str, sep)
    local i, j = string.find(str, sep)
    return string.sub(str, 1, i - 1), string.sub(str, j + 1)
end

local left, right = split_once("key=value", "=")
print(left, right)   -- key    value

-- 闭包:每调一次 counter,内部的 i 都还记得
local function new_counter()
    local i = 0
    return function()
        i = i + 1
        return i
    end
end

local next_id = new_counter()
print(next_id(), next_id(), next_id())   -- 1    2    3

闭包那个 i 被内层函数“记住”了——这就是闭包的全部魔法:函数带着它出生时的环境一起走。

元表(metatable):就是 JavaScript 的原型

这是 Lua 最容易忘、也最能体现它“简单但强大”的地方。表本身没有继承、没有运算符重载,但你可以给一个表挂一个元表,在元表里定义一批 __ 开头的“元方法”,来接管这个表的各种行为。

最常用的是 __index:当你访问一个表里没有的键时,Lua 会去元表的 __index 里找。这一个机制就够拼出继承了。

local Animal = {}
function Animal:speak() return self.name .. " makes a sound" end

local dog = { name = "Rex" }
setmetatable(dog, { __index = Animal })   -- dog 找不到的,去 Animal 找

print(dog:speak())   -- Rex makes a sound

我当年 PPT 里写过一句话形容它:“走起来像鸭子、游起来像鸭子、叫起来像鸭子,那它就是鸭子。” 元表就是 Lua 的“鸭子类型”开关——你不需要类,只需要让一个表在被访问、被调用、被相加时表现得像你要的那个东西。除了 __index,还有 __call(让表能像函数一样被调用)、__add(重载 +)、__newindex(拦截赋值)等一整套。

协程(coroutine)

Lua 内建协程——可以主动让出(yield)和恢复(resume)的“协作式线程”。它不是抢占式的操作系统线程,而是你自己控制切换时机的执行流。写状态机、写生成器、写异步流程时特别顺手。FreeSWITCH 的一些异步场景底下就有它的影子。

C API:它能寄生的根本原因

前面说 Lua“为嵌入而生”,靠的就是一套干净的 C API。宿主程序(C/C++ 写的那个)通过一个 lua_State 跟脚本打交道,双方用一个传数据:C 往栈上压参数,Lua 取走;Lua 返回值压栈,C 弹出。

lua_State *L = luaL_newstate();   // 新建一个 Lua 状态机
luaL_openlibs(L);                 // 打开标准库
luaL_dostring(L, "print('Hello from Lua')");   // 跑一段脚本
lua_close(L);                     // 别忘了关

反过来,C 也能把自己的函数注册进去,让 Lua 调用;这正是 FreeSWITCH 把成百上千个 switch_* 能力暴露给脚本的方式——通过 SWIG 自动生成绑定,脚本里一个 session:answer() 背后就是一次 C 调用。理解了“C 和 Lua 靠一个栈来回倒数据”,你就理解了 mod_lua 的本质。


五个最容易栽的坑

我把当年 PPT 里那页 “The Pitfall of Lua” 重新校对了一遍,这五条到今天依然是新手(和忘光了的老手)最常摔的地方:

说明 正确写法
下标从 1 表当数组用时,第一个元素是 t[1] 不是 t[0] for i = 1, #t do ... end
~= 不是 != Lua 的“不等于”是波浪号,写 != 直接语法错误 if a ~= b then ... end
.: 有别 obj.method 是普通字段,obj:method 会隐式传 self;定义和调用要用同一种 function obj:m() ... endobj:m()
# 只数连续整数键 表长度运算符 # 只统计从 1 开始、连续的整数键,中间断了就不准 有空洞的表自己维护 count
pairs vs ipairs ipairs 按 1,2,3… 顺序遍历到第一个 nil 就停;pairs 遍历所有键但顺序不保证 数组用 ipairs,哈希表用 pairs

第三条那个 .: 的区别,是 FreeSWITCH 脚本里最常见的报错来源。你想调 session 上的方法,写成 session.answer() 就会因为少传 self 而出错,得写 session:answer()。记住:冒号 = 带 self 的实例调用


落到 FreeSWITCH:mod_lua 把脚本挂在哪

铺垫完语言,进正题。FreeSWITCH 是个开源软交换(软件电话交换机,处理呼叫、媒体、SIP 信令),几十万行 C。它把 Lua 解释器直接嵌进交换机进程——脚本不是外部程序,不走进程间通信,而是在一个专属的 lua_State 里跑,通过 SWIG 绑定直接够到 FreeSWITCH 的全部 C API。

mod_lua默认加载的(modules.conf.xml 里就配好了),Perl、Python3、V8(JavaScript)、Java 也能用,但都得手动打开。所以在 FreeSWITCH 世界里,Lua 是脚本的“默认语言”。

它注册了四个挂载点,也就是你能把 Lua 脚本插进 FreeSWITCH 的四个位置:

  1. dialplan application——在一路通话里同步执行脚本,控制呼叫流程。
  2. XML handler——让脚本动态生成配置(dialplan / directory 等),替代静态 XML。
  3. event hook——某个事件触发时跑脚本,做事件响应。
  4. startup script——模块加载时起一个后台线程,跑常驻脚本。

下面一个个看。

脚本目录:相对路径去哪找

先记住一件事:脚本的相对路径是相对 $${script_dir} 解析的,标准安装通常是 /usr/local/freeswitch/scripts。你在 fs_cli 里可以确认:

eval $${script_dir}

只要脚本名不以 / 开头,FreeSWITCH 就会自动拼上这个目录;绝对路径则原样使用。

挂载点一:dialplan application

这是最常用的。在 dialplan 里用 lua 这个 application,脚本会同步跑在当前通话腿上,整通电话会等它执行完。脚本里自动有一个全局的 session 对象,代表这路通话。

<extension name="ivr-entry">
  <condition field="destination_number" expression="^1000$">
    <action application="lua" data="ivr_menu.lua option1 option2"/>
  </condition>
</extension>

ivr_menu.lua 因为是相对路径,会被解析成 $${script_dir}/ivr_menu.lua。传进去的参数,脚本里用全局表 argv 取:argv[0] 是脚本名,argv[1] 起是真正的参数。

一个能看懂的 IVR 小例子:

-- ivr_menu.lua:一个极简的语音菜单
session:answer()                          -- 接起来
session:sleep(500)                        -- 等半秒,别把提示音说太急

-- 放一段提示音,收一位按键;参数:提示音、超时、位数……
local digit = session:playAndGetDigits(
    1, 1, 3, 5000, "#",
    "ivr/ivr-welcome.wav",                -- 提示音
    "ivr/ivr-that_was_an_invalid_entry.wav", -- 错误音
    "\\d"                                 -- 只收一位数字
)

if digit == "1" then
    session:execute("transfer", "2001 XML default")   -- 转到分机 2001
elseif digit == "2" then
    session:execute("transfer", "2002 XML default")
else
    session:streamFile("ivr/ivr-invalid_extension.wav")
    session:hangup()
end

注意所有方法都是 session:xxx(),冒号——这就是前面那第三条坑的实战。

挂载点二:XML handler(动态配置)

FreeSWITCH 需要某段配置时,会去问注册过的 XML handler。你可以让一个 Lua 脚本来“现造”这段 XML,把 dialplan、directory 这些从静态文件变成动态生成——比如从数据库里查用户、按时间段路由。

lua.conf.xml 里绑定,注意两个参数必须有先后顺序xml-handler-script 要在 xml-handler-bindings 前面:

<param name="xml-handler-script" value="xml_handler.lua"/>
<param name="xml-handler-bindings" value="dialplan"/>

脚本被调用时,FreeSWITCH 通过全局变量传上下文:XML_REQUEST 是个表,带 section / tag_name / key_name / key_value 说明在请求什么;params 是个事件对象,带通道变量。脚本的活儿是把最终 XML 塞进全局变量 XML_STRING

-- xml_handler.lua
local req = XML_REQUEST   -- req.section / req.key_value 由 FreeSWITCH 填好

-- 真实场景里,这里会根据 req.key_value 去数据库查路由
XML_STRING = [[
<document type="freeswitch/xml">
  <section name="dialplan">
    <context name="default">
      <extension name="dynamic">
        <condition field="destination_number" expression="^(\d+)$">
          <action application="bridge" data="user/${1}"/>
        </condition>
      </extension>
    </context>
  </section>
</document>
]]

[[ ... ]] 是 Lua 的长字符串语法,写多行 XML 正合适。

挂载点三:event hook(事件响应)

lua.conf.xml<settings> 中的 <hook> 元素,把一个 Lua 脚本绑到某个 FreeSWITCH 事件上。事件每触发一次,脚本在一个专属线程里跑一次,事件对象通过全局 event 拿到。

<hook event="CHANNEL_HANGUP" script="on_hangup.lua"/>
<hook event="CUSTOM" subclass="conference::maintenance" script="catch-event.lua"/>
-- on_hangup.lua:每次挂机都记一笔
local uuid  = event:getHeader("Unique-ID")
local cause = event:getHeader("Hangup-Cause")
freeswitch.consoleLog("notice",
    string.format("call %s hung up, cause=%s\n", uuid, cause))

event:getHeader(...) 取事件头,冒号,还是那第三条坑。

挂载点四:startup script(常驻后台)

startup-script 参数让脚本在 mod_lua 加载时起一个后台线程,可以一直跑。适合做常驻的事件监听器、定时器、跟外部系统对接的循环。它没有通话 session

<param name="startup-script" value="event_daemon.lua"/>
<param name="startup-script" value="presence_manager.lua"/>

多个 startup script 会依次启动,每个之间有 10 毫秒的间隔,避免 Lua 初始化打架。


最佳实践与常见陷阱

例子会写只是及格,知道哪里会炸才算入门。下面几条是 FreeSWITCH + Lua 组合里最值得先记住的。

session 是会阻塞的——想清楚再用

dialplan 里的 lua application 是同步的:脚本不返回,这通电话就一直被你占着。所以脚本里别干耗时的活儿(同步 HTTP 请求、慢查询),否则这路通话会一直挂着。要做重活,考虑放到 startup script 的后台线程里,或者走异步。

hook 和 startup 里没有 session,别乱用 session API

event hook、startup script 都没有通话 session——它们不在某一路电话的上下文里。在这些脚本里调 session:xxx() 会直接出错。它们能用的是 event(hook 里)和各种 freeswitch.API() 这类全局能力。

错误要用 pcall 兜住

Lua 脚本里一个未捕获的错误,轻则中断这段逻辑,重则在关键路径上留下半截状态。对可能失败的调用(尤其是外部 IO),用 pcall 包一层:

local ok, err = pcall(function()
    -- 可能抛错的代码
    do_something_risky()
end)
if not ok then
    freeswitch.consoleLog("err", "failed: " .. tostring(err) .. "\n")
    -- 兜底处理,别让整通电话烂在这里
end

日志走 freeswitch.consoleLog,别用 print

脚本里 print 出来的东西不一定进 FreeSWITCH 的日志体系。要留痕迹用 freeswitch.consoleLog(level, message),level 是 debug / info / notice / warning / err 之一,消息记得自己带换行 \n

require 找不到模块?检查 script-directory

想在脚本里 require 别的 .lua 模块,得先在 lua.conf.xml 里配 script-directory,用 ?.lua 通配:

<param name="script-directory" value="$${script_dir}/?.lua"/>

这实际上是往 Lua 的 LUA_PATH 前面加路径。native 的 C 扩展(.so)则用 module-directoryLUA_CPATH。这两个配错,是“我明明放了模块却 require 不到”的头号原因。

改了脚本不一定要重启

dialplan / hook 里引用的脚本,通常每次执行都重新读,改完直接生效。但 lua.conf.xml 本身是模块加载时读一次的,改了配置要在 fs_clireload mod_lua。startup script 改了则要重载模块或重启进程才会重新起线程。

陷阱清单速查

症状 多半是 怎么办
attempt to index a nil value 用了 . 该用 :,或 session 不存在 实例方法用冒号;hook/startup 里别用 session
脚本报语法错却看不出哪 写了 != 改成 ~=
遍历表漏了元素 表有空洞还用 ipairs/# 哈希表用 pairs;数组保持连续
require 报 module not found 没配 script-directory lua.conf.xml 里加 ?.lua 路径
改了配置不生效 改的是 lua.conf.xml reload mod_lua
一通电话卡住不挂 lua application 里做了慢 IO 挪到后台线程或改异步
日志里找不到脚本输出 用了 print freeswitch.consoleLog

总结:Lua 在 FreeSWITCH 里是胶水,不是主料

重新拾起 Lua,最大的感受是:这门语言的定位从头到尾没变过——它是胶水,不是主料。

FreeSWITCH 的重活(媒体处理、协议栈、状态机)都在 C 模块里,Lua 负责的是那些“需要经常改、又不值得重编一次核心”的黏合逻辑:呼叫怎么路由、IVR 菜单怎么走、事件来了记什么。想清楚这条边界,你就知道什么该写进 Lua 脚本、什么该老老实实回到 C 模块里去。写脚本时贪多,把重逻辑堆进 lua application,卡住通话;或者该动态生成配置时死抱着静态 XML——都是没认清这层定位。

一句话收尾:

Lua 之所以能长在这么多大项目里,靠的不是功能多,而是够克制。 在 FreeSWITCH 里用它,也请保持这份克制——脚本只做胶水该做的事。

上手清单

  • [ ] fs_clieval $${script_dir} 确认脚本目录。
  • [ ] 分清四个挂载点:dialplan(同步、有 session)、XML handler(动态配置)、hook(事件、无 session)、startup(常驻、无 session)。
  • [ ] 所有实例方法用冒号:session:answer()event:getHeader()
  • [ ] 外部 IO 用 pcall 兜错,日志用 freeswitch.consoleLog
  • [ ] 要 require 别的模块,先在 lua.conf.xml 配好 script-directory
  • [ ] 改了 lua.conf.xml 记得 reload mod_lua
  • [ ] 别在 lua application 里做慢 IO,重活挪到后台。

全文思维导图

@startmindmap
<style>
mindmapDiagram {
  node {
    BackgroundColor #F8F9FA
    RoundCorner 10
    Padding 10
    FontSize 13
  }
  :depth(0) {
    BackgroundColor #1E3A5F
    FontColor white
    FontSize 18
    FontStyle bold
  }
  :depth(1) {
    FontSize 15
    FontStyle bold
  }
  :depth(2) {
    FontSize 13
  }
}
</style>

* 重拾 Lua
** Lua 是什么
*** 为嵌入而生
*** 小 / 快 / 可移植 / 免费
*** 用户:Lightroom / WoW / Nginx / Redis / FreeSWITCH
** 语言要点
*** 表即一切(下标从 1)
*** 函数一等公民 / 闭包
*** 元表 = JS 原型
*** 协程
*** C API(靠栈传数据)
** 五个坑
*** 下标从 1
*** ~= 不是 !=
*** . 与 : 之别
*** "#" 只数连续整数键
*** pairs vs ipairs
** FreeSWITCH 挂载点
*** dialplan application(同步、有 session)
*** XML handler(动态配置)
*** event hook(无 session)
*** startup script(常驻)
** 最佳实践
*** session 会阻塞
*** pcall 兜错
*** consoleLog 记日志
*** script-directory 配 require
@endmindmap

重拾 Lua - 思维导图


本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。 欢迎在我的个人网站 https://www.fanyamin.com 访问原文并评论。