【Lua入门系列】:一段 Lua 脚本,为什么能跑进 C++ 服务器?从 CPU 指令到游戏业务的完整推导

发布时间:2026/10/29 2:14:06
【Lua入门系列】:一段 Lua 脚本,为什么能跑进 C++ 服务器?从 CPU 指令到游戏业务的完整推导 本文专栏Lua作者主页努力努力再努力wz 今日博客励志语录别因为现在还普通就怀疑过去所有的努力。很多成长在结果出现之前本来就是沉默的。思维导图这篇文章到底沿着什么逻辑展开我们写的高级语言源代码CPU 为什么不能直接执行 │ ▼ CPU 只能执行机器指令 │ ┌───────────┴───────────┐ ▼ ▼ C 的典型执行路径 标准 Lua 的执行路径 源代码 → 编译链接 源代码 → 编译为字节码 ↓ ↓ 原生机器码 Lua 虚拟机 ↓ ↓ CPU 执行 取指 → 分派 → 执行 ↓ CPU 执行解释器机器码 │ ▼ 解释器本身究竟是什么 │ 已经编译好的 C 程序/库 │ ┌─────────────────┴─────────────────┐ ▼ ▼ 独立的 lua 进程 嵌入宿主进程 lua test.lua C Lua 运行时库 │ │ └─────────────────┬─────────────────┘ ▼ Lua 的轻量级与可嵌入性 │ ▼ 回到我们熟悉的网络服务器 Reactor / TCP / 协议 / 业务逻辑 │ ▼ 底层相对稳定游戏业务规则频繁变化 │ ▼ C/C 提供基础能力Lua 表达业务 │ ▼ 降低业务迭代与部分更新的耦合成本引入为什么我们要在学习 Lua 语法之前先研究它怎么运行我们之前已经使用 C 开发过高并发网络服务器接触过socket、epoll、主从 Reactor、TcpConnection、EventLoop以及 HTTP 解析。现在来到游戏服务端项目却发现工程中不仅有 C/C还有大量.lua文件。这就很容易产生两个问题Lua 到底是什么它和我们熟悉的 C究竟有什么本质差异既然 C 已经可以完成网络通信和业务开发为什么还要引入 Lua如果直接从 Lua 变量、函数、table等语法入手我们当然也能把代码写出来。但是仍然可能不清楚这些 Lua 代码究竟是由谁执行的为什么它能够直接跑在 C 程序内部游戏服务器引入它究竟解决了什么问题因此本文不打算按照“语言定义 → 语法介绍 → 代码示例”的顺序来讲而是从我们已经建立的CPU 执行模型开始逐步把后面的概念推导出来。一、从最底层的问题出发CPU 究竟能够执行什么1. 高级语言源代码并不是 CPU 直接执行的内容我们知道CPU 并不认识程序员写出的 C 或 Lua 关键字。CPU 所能直接执行的是其对应指令集架构ISA下的机器指令。例如x86-64 和 ARM64 具有不同的指令集。因此无论使用哪种高级语言最后都必须通过某种运行机制让 CPU 执行能够实现程序语义的机器指令。这里注意一个非常重要的表述所有高级语言的程序行为最终都要由机器指令实现但并不意味着每一份高级语言源代码都必须被提前编译成一份对应的原生机器码。为什么要强调后半句因为它正好引出了 C 和标准 Lua 的典型执行方式。2. C通常提前编译为特定平台的原生代码对于我们熟悉的 Cintmain(){inta10;intb20;returnab;}典型的构建流程可以概括为C 源代码 ↓ 预处理 / 编译 / 汇编 ↓ 目标文件包含目标平台机器码等内容 ↓ 链接 ↓ 可执行文件 ↓ 操作系统加载并创建进程 ↓ CPU 执行原生机器指令这里我们需要认识两个细节。首先构建产物与目标平台有关。我们针对 x86-64 编译得到的原生程序通常不能直接交给 ARM64 CPU 执行。需要针对目标平台重新构建或者使用相应的兼容/模拟机制。其次源代码级别和可执行文件级别的可移植性不是一回事。C 源代码完全可以跨平台重新编译我们所说的平台差异主要指编译产物的机器指令、依赖库等方面。3. 为什么还会有另一种执行方式现在我们换一个思路。既然 CPU 只能执行原生机器指令那能不能先设计一套不直接绑定 x86 或 ARM 的虚拟指令集让一个专门的程序负责理解并执行它例如我们先不关心 x86 的加法指令究竟怎么编码而是统一描述ADD做加法 SUB做减法 CALL调用函数 JMP跳转到某个位置这套指令描述了程序要完成的操作但它本身不是 CPU 的原生机器指令。如果要执行就需要有一个额外的执行层。这就把我们的思路引向了 Lua 虚拟机。二、标准 Lua 的执行路径为什么会出现字节码和虚拟机Lua 是一门轻量级、可嵌入的脚本语言。对于官方标准 Lua 而言一个重要的执行特征是先将 Lua 代码编译成字节码再通过寄存器式虚拟机解释执行这些字节码。1. 先建立 Lua 的完整执行流程假设我们写了一段 Lua 代码locala10localb20print(ab)执行时的典型逻辑是Lua 源代码test.lua ↓ Lua 编译器 ↓ Lua 字节码虚拟指令序列 ↓ Lua 虚拟机 ↓ 读取并解释执行字节码 ↓ CPU 执行 Lua 虚拟机的原生机器指令和 C 对比我们就会发现对于典型 C 原生程序构建阶段便生成了目标架构的机器码。对于标准 Lua源代码先转成 Lua 虚拟机能识别的字节码由虚拟机负责执行。这里的 Lua也经过了编译。因此不能把“解释执行”理解成“完全不涉及编译”。2. 字节码究竟是什么最开始接触字节码时可以把它理解为面向 Lua 虚拟机的一套虚拟指令。我们之前把一条指令理解成一个“动作 动作作用的对象”这个模型非常合适。例如假设有一条为了说明原理而写的示意指令ADD R0, R1, R2那么我们可以把它拆开操作码 opcodeADD ↓ 表示执行加法 操作数 operandR0、R1、R2 ↓ 描述数据来源和结果位置这条示意指令表示从虚拟寄存器R1、R2获取操作数执行加法结果写入R0。注意这里的R0并不是 CPU 的rax、x0等真实寄存器它是虚拟机所维护的逻辑寄存器位置。而且上面的文本只是为了说明指令结构不是对某个 Lua 版本字节码格式的逐字还原。所以字节码完成了一件事将上层 Lua 代码的执行意图表达成一套由 Lua 虚拟机定义并能够识别的指令序列。但是问题来了。CPU 不认识这些虚拟指令究竟是谁来执行它们三、解释器到底是怎么执行字节码的这一部分是理解 Lua 执行机制的核心。1. 解释器本身也是一个 C 程序我们首先认识到Lua 的核心运行时由 C 语言实现。既然它也是 C 程序那么它自己同样需要经过编译和链接最终成为目标机器能够执行的原生代码。这里实际上存在两条不同的编译链路第一条构建 Lua 解释器 / Lua 运行时库 Lua 运行时 C 源代码 ↓ C 编译器 ↓ 已经编译好的机器码可执行程序或库 第二条加载我们编写的 Lua 脚本 Lua 脚本源代码 ↓ Lua 编译器 ↓ Lua 字节码 ↓ 由已经构建好的 Lua 运行时解释执行解释器不需要为了每次执行一个新的.lua文件就重新编译自己的 C 源码。为什么因为在构建解释器时Lua 所支持的虚拟指令对应的处理逻辑就已经写在解释器内部了。2. 不是“字节码 → C 代码”而是“字节码 → 已有处理逻辑”这里最容易产生的误解是Lua 字节码 ↓ 运行时临时生成一段 C 语言代码 ↓ 再用 C 编译器将它编译成机器码 ↓ CPU 执行这不是标准 Lua 虚拟机正常解释执行字节码的方式。这里我们需要明确一点Lua 虚拟机并不是在读取字节码之后才将其转换为对应的 C 语言代码或者机器码。我们之前已经认识了Lua 虚拟机本身是由 C 语言实现的而这里的 Lua 字节码实际上就是一套虚拟指令集其中包含加法、减法、跳转、函数调用等不同的操作。那么对于这套虚拟指令集Lua 虚拟机在其 C 语言实现中实际上就已经编写好了对应的指令处理逻辑。比如我们可以通过一个简化的 switch-case 模型来理解switch(opcode){caseOP_ADD:// 加法指令对应的 C 语言处理逻辑break;caseOP_SUB:// 减法指令对应的 C 语言处理逻辑break;caseOP_JMP:// 跳转指令对应的 C 语言处理逻辑break;}注意这段代码只是一个用于理解的简化模型。这里的关键在于这些处理逻辑是在解释器开发时就已经通过 C 语言实现的而不是等到某条 Lua 字节码到来之后才临时生成。既然解释器本身就是一个 C 语言程序那么它同样需要经过编译、链接最终生成 CPU 能够执行的原生机器码。这就意味着解释器内部各个指令处理分支所对应的机器码实际上早就已经存在于编译完成的程序中了。因此当我们真正运行一段 Lua 脚本时Lua 虚拟机只需要不断执行以下过程取指Fetch根据当前字节码执行位置读取对应的虚拟指令。分派Dispatch根据指令中的操作码选择解释器内部已经实现好的处理分支。执行Execute获取操作数进行必要的检查然后执行对应处理逻辑已经编译好的机器指令完成相应操作。这里还需要注意一条 Lua 字节码并不一定只对应一条 CPU 机器指令它所对应的处理逻辑可能需要多条机器指令共同完成。所以我们最终可以建立这样一个认知Lua 虚拟机内部已经通过 C 语言实现了虚拟指令集所对应的处理逻辑而这些 C 语言代码又提前被编译成了原生机器码。因此虚拟机运行时并不是重新编译字节码而是通过取指、分派、执行的循环根据字节码中的操作码进入对应处理分支最终由 CPU 执行该分支已经编译好的机器指令。这就是我们之前所说的 Lua 字节码解释执行机制。这里有两个重点。第一switch-case是解释器源码中的一种分派实现思路。当解释器运行时CPU 真正执行的是这个switch-case及各个处理分支经过编译后的机器指令而不是直接“执行 C 源码”。第二一条虚拟指令通常并不等于一条 CPU 机器指令。例如执行 Lua 的加法语义可能涉及取出数据、检查类型、运算、写回结果等多个处理步骤。这也解释了为什么说字节码是“决定做什么”而解释器负责“具体怎么做”。3. 取指、分派、执行解释器的核心循环我们接着拆开刚才的模型。1取指 Fetch解释器会维护当前正在执行的字节码位置可以把它理解为一个虚拟程序计数位置pcInstruction instbytecode[pc];虚拟机据此获取下一条需要执行的虚拟指令。注意这里的pc是Lua 虚拟机层面的执行位置不是 CPU 硬件层面的指令指针。两者属于不同的执行层次。2分派 Dispatch取到字节码之后查看操作码决定进入哪个处理分支。这一点实际上非常像我们之前学习的 Reactor 中的事件分派Reactor 就绪事件 → 判断事件类型 → 调用对应事件处理逻辑 Lua 虚拟机 字节码 → 判断操作码 → 进入对应指令处理逻辑例如遇到ADD就进入加法分支遇到CALL就进入函数调用分支。真实解释器不一定使用普通switch也可能通过其他优化过的分派实现完成相同目的。3执行 Execute进入指令处理分支后解释器根据字节码携带的信息获取操作数执行对应操作并更新虚拟机内部状态。某些操作可能需要检查值的类型。例如 Lua 是动态类型语言执行加法时可能需要区分不同的数值表示、处理不适合相加的值等。但不是所有指令都必须进行类型检查例如部分跳转指令的处理逻辑就不同。执行完成以后虚拟机继续读取下一条指令如果遇到跳转、函数调用或返回下一步执行位置就可能改变而不是永远简单地pc。至此我们终于能够给解释器建立一个足够具体的心智模型Lua 虚拟机是一个已经实现了 Lua 虚拟指令语义的原生执行引擎。它依据字节码不断取指、分派、执行而 CPU 实际执行的是虚拟机已经编译好的机器指令。4. 这也能解释解释执行为什么会带来一定的性能开销我们之前已经知道C 编译器能够根据已知的类型和优化信息提前生成特定的机器码。而对于标准 Lua每执行某些虚拟指令时还要经过字节码读取、分派、操作数处理以及可能的类型判断。因此在一些计算密集型场景下经过良好优化的 C 原生代码通常更容易获得较好的执行性能。不过我们不能简单认为“一条 Lua 指令必然对应多少条机器指令”也不能认为所有 Lua 工作负载都一定比 C 慢。实际表现还取决于实现、算法、数据规模和工作负载四、从解释器进一步过渡到 Linux 进程脚本、解释器、进程是一回事吗1. 单独运行 Lua 时操作系统启动的是谁现在我们假设在终端输入lua test.lua这条命令不是让 CPU 直接执行test.lua而是让操作系统启动已经编译好的lua可执行程序。之后解释器程序在自己的进程中加载、编译并执行脚本。磁盘上的 lua 可执行文件 ↓ 操作系统启动程序创建进程 ↓ Lua 解释器进程 ↓ 读取 test.lua → 编译字节码 → 解释执行这正好与我们原来学习的 Linux 概念对应起来程序是静态的可执行文件进程是该程序的一次运行实例。2. 同一份解释器程序可以形成多个独立进程假设两个终端分别执行# 终端 Alua a.lua# 终端 Blua b.lua如果两个脚本都尚未退出操作系统通常就存在两个不同的lua进程。虽然它们运行的是同一份解释器程序但分别拥有独立的 PID 和进程执行状态各自的虚拟地址空间各自加载的 Lua 脚本、字节码和 Lua 运行状态。它们的只读代码页可能在物理内存层面由操作系统共享但这不改变进程间的地址空间隔离。3. 不过“解释器 一个进程”不是永远成立的这里需要修正一个最开始很容易建立的认知。在命令行模式下我们确实通常是启动一个解释器进程来运行脚本但解释器的核心是执行 Lua 代码的运行时能力而进程是操作系统组织程序执行的资源管理单位。两者并不是严格的一对一关系。因为一个 Lua 进程可以先后加载、执行多份 Lua 脚本一个 C 进程内部可以创建多个独立的 Lua 运行状态一个 Lua 运行状态并不天然意味着独立的 Linux 进程。这一点先记住。它正好为后面 Lua 的可嵌入性做好准备。五、从执行模型自然推导Lua 的跨平台性究竟体现在哪里我们已经知道不同 CPU 架构具有不同的原生指令集。而 Lua 源代码通常不需要直接表示某个 CPU 架构的机器指令它交给目标平台上的 Lua 运行时进行加载、编译和执行。假设同样一份 Lua 源代码print(Hello Lua)可以分别交给 x86-64 或 ARM64 上的兼容 Lua 运行时来执行。同一份 Lua 源代码 │ ┌────────────┴────────────┐ ▼ ▼ x86-64 平台 Lua ARM64 平台 Lua │ │ 运行时完成执行 运行时完成执行 ▼ ▼ x86-64 CPU ARM64 CPU因此Lua 一个重要的优势是脚本源码可以尽可能保持平台无关而由各个平台上的 Lua 运行时来处理执行细节。不过这里有三个限制不同平台需要存在兼容的 Lua 运行时Lua 版本及脚本使用的语义需要兼容如果脚本依赖 C/C 原生扩展扩展库也需要适配目标平台。六、Lua 的轻量级究竟是哪里“轻”认识了 Lua 的解释执行之后我们自然会关注另一个问题既然 Lua 代码需要一个运行时来支持那么这个运行时本身要消耗多少资源所谓 Lua 的轻量级可以先从它的核心运行时比较精简、基础资源需求相对较低来理解。1. 轻量级不只是磁盘上的解释器文件小Lua 官方对它的定位是轻量级、可嵌入的脚本语言其核心实现的体量确实比较小。例如Lua 官方网站给出的 Lua 5.5.1 参考构建数据中64 位 Linux 上包含标准库的独立解释器文件约为293 KBLua 库文件约为484 KB。也就是说我们必须区分指标关注什么可执行文件体积解释器二进制文件占多少磁盘空间虚拟地址空间VSZ进程映射了多大的虚拟地址范围常驻内存RSS进程当前驻留的物理内存页大致有多少基础运行时开销为了建立基本 Lua 执行环境需要付出的资源成本执行性能完成具体计算需要多少 CPU 时间对于我们现在的阶段可以把运行时基础内存成本较低作为一个比较直观的切入点。但 Lua 的“轻”并不是只指某个进程的 RSS 一定很小它还包括核心实现、依赖范围和整体资源成本等方面。七、Lua 的可嵌入性为什么解释器不必单独启动一个进程到这里前面建立的所有知识终于可以联系起来了。我们已经认识到Lua 脚本需要 Lua 运行时来完成加载、编译和执行Lua 运行时的核心是由 C 实现的一套功能这些 C 代码最终都能够编译成原生机器码。那么如果我们现在正在开发一个 C 程序希望在程序中执行 Lua 脚本究竟该怎么做1. 第一种直觉fork exec单独创建解释器进程根据之前的 Linux 系统编程知识我们完全可以设计C 主进程 ↓ fork 创建子进程 ↓ exec 加载 lua 解释器程序 ↓ Lua 子进程执行脚本 ↓ 通过 pipe / socket 等 IPC 返回结果这个方案技术上可以实现而且在某些需要进程隔离的场景下很有意义。但是如果只是希望 C 代码调用一段 Lua 逻辑、传入参数并获取结果就会发现它引入了额外成本需要管理另一个进程的生命周期进程之间的通信需要 IPC数据跨进程传输通常伴随编码、复制或共享内存同步等问题。我们真正需要的只是 Lua 代码的执行能力并不是非要创建一个独立的 Lua 进程。2. 既然解释器是 C 实现的为什么不将它封装为库这里就能和之前的 C 模块化设计联系起来。例如我们的网络程序并不是把所有逻辑写在main()中而是会有Server ├── TcpConnection ├── EventLoop ├── ThreadPool └── Timer这些模块可以编译、组织为可复用的库再通过接口提供给其他程序调用。Lua 运行时同样可以采用这个思路把 Lua 的核心执行能力作为库提供出来由其他程序直接调用它的接口。Lua 官方实际上就是按照这样的思路组织其核心实现是一个 C 库独立的lua命令行解释器是使用这套库的宿主程序。Lua 核心 C 实现 │ 编译为 Lua 运行时库 │ ┌───────┴────────┐ ▼ ▼ 官方 lua 命令行程序 我们自己的 C 程序 │ │ 加载 Lua 脚本 调用 Lua C API └───────┬────────┘ ▼ 运行 Lua 代码3. C 如何调用 Lua 运行时当 Lua 运行时被链接或加载进 C 程序后我们可以通过 Lua 提供的C API调用它。调用过程可以先抽象成C 原有业务逻辑 ↓ 创建 Lua 运行状态 ↓ 加载 Lua 源码 / 字节码 ↓ 执行 Lua 代码 ↓ 获取结果或读取错误信息 ↓ 继续执行 C 逻辑这里有一组真实的 Lua API可以帮助我们认识这些步骤的对应关系但目前不用死记// 概念示例假设工程已经正确配置 Lua 的头文件和链接库externC{#includelua.h#includelauxlib.h#includelualib.h}intmain(){lua_State*LluaL_newstate();// 创建独立 Lua 状态if(!L)return1;luaL_openlibs(L);// 打开标准库按需// 加载并编译脚本这里只加载还没有开始执行intstatusluaL_loadfile(L,test.lua);// 加载成功才调用 Lua 运行时执行if(statusLUA_OK){statuslua_pcall(L,0,0,0);}if(status!LUA_OK){constchar*errlua_tostring(L,-1);// 实际工程在这里记录错误信息(void)err;}lua_close(L);returnstatusLUA_OK?0:1;}重要的不是接口名字而是其背后的控制权C 程序是宿主程序它在需要的时候调用 Lua 库Lua 运行时则在同一个进程内完成 Lua 脚本执行。4. 可嵌入不等于为每段 Lua 代码创建一个操作系统进程这里再回过头来思考之前的进程模型。独立执行时lua test.lua操作系统启动的是lua程序形成一个 Lua 解释器进程。而嵌入运行时./my_cpp_program操作系统启动的是我们的 C 程序该进程内部通过 Lua 运行时库执行脚本。同一个进程里甚至可以存在多个独立的 Lua 状态例如多次调用luaL_newstate()而不要求每个 Lua 状态都对应一个 Linux 进程。所以所谓Lua 的可嵌入性核心就是Lua 不仅可以独立作为脚本解释器使用它的编译与执行能力也可以作为一个库直接嵌入 C/C 等宿主程序中让宿主进程获得执行 Lua 代码的能力。值得强调的是无论独立运行还是嵌入运行我们此前建立的取指、分派、执行模型都没有被推翻。改变的是运行时被组织和调用的方式而不是 Lua 字节码的基本解释执行语义。八、回到最熟悉的网络服务器游戏服务器与普通 TCPServer 有什么联系经过前面的讨论我们已经知道 Lua 为什么可以嵌入 C 程序。但还有一个问题为什么这种能力会特别适合游戏服务器要回答这个问题最自然的起点就是我们已经写过的 Reactor 网络服务器。1. 先从底层网络通信框架入手我们之前实现的网络服务器采用了主从 Reactor 思路。主 Reactor主要负责接收新连接得到客户端 socket 的fd并为连接创建相应的连接管理对象。例如一个TcpConnection可能维护对应的 socket fd 及连接状态输入、输出缓冲区需要的协议解析状态可读、可写以及关闭等回调与所属EventLoop的关联。随后新连接会被分配给相应的从 Reactor / I/O 线程管理。从 Reactor则通过epoll等机制监听各类 fd 的就绪事件。这里我们之前还学习过一个细节一个EventLoop中监听的不一定全部都是普通网络 socket也可能包含eventfd、timerfd等描述符。不同 fd、不同事件类型会进入对应的处理逻辑。对于网络 socket 的可读事件我们通过读回调读取数据再将收到的字节交给输入缓冲区及上层协议处理逻辑。如果只收到半个消息就继续保存已有的数据和解析进度等待后续数据到达。这一点需要特别强调TCP 只提供字节流本身不维护我们的“请求报文边界”。因此“拿到一个完整请求”依赖具体的应用协议。例如可以通过固定头部、长度字段、分隔符等方式完成组包再根据协议格式解析。我们之前实现的 HTTP 增量解析属于这个层次。游戏协议也需要解决类似的消息边界与反序列化问题只不过其格式不一定是 HTTP。2. 底层负责数据怎么来上层负责收到数据以后做什么如果把整个服务器暂时抽象成两层底层网络与协议能力 ↓ 上层应用业务逻辑那么底层主要关注怎样建立并管理大量连接怎样通过 I/O 多路复用提高连接处理效率怎样把网络字节流按照协议组织成完整消息怎样把业务响应通过网络可靠、有序地发送出去在 TCP 提供的语义范围内。上层则关注客户端发送的是登录还是攻击请求攻击是否合法技能处于什么状态需要修改哪些游戏对象结果该通知谁。我们之前开发的 HTTP 服务器上层主要处理 HTTP 协议和相应的业务回调。而对于游戏服务器上层则有战斗、任务、背包、道具、角色状态等具体业务。这里画的是便于理解的概念链路真实项目不一定采用主从 Reactor也不一定让 Lua 直接运行在 I/O 线程网络层、消息层、业务服务层可能采用不同线程甚至不同进程组织。但是从职责划分看我们已经可以建立一个清晰的框架网络层解决数据如何到达协议层解决数据如何成为消息业务层解决消息到达后系统应该做什么。接下来问题就集中到业务层了。九、游戏服务器为什么引入 Lua而不全部使用 C1. 先承认一个前提全部用 C 完全能实现从技术能力上说我们完全可以把网络通信、协议解析、战斗计算、任务系统以及玩家状态管理全部写成 C。所以问题不是C 能不能实现游戏业务而是当游戏业务经常发生变化时怎样降低开发、构建和更新的成本2. 底层通信机制相对稳定业务规则却可能频繁变化对于底层通信框架我们实现一次完整的连接管理、事件调度和数据收发机制后通常并不需要为了每一个技能调整就去修改EventLoop或TcpConnection。但是上层游戏逻辑不一样。例如玩家攻击怪物时最开始只有一条非常简单的规则doublecalculateDamage(doubleattack,doublemultiplier){returnattack*multiplier;}后来策划希望调整技能行为原来 攻击 → 计算伤害 → 扣除目标生命值 修改后 攻击 → 计算伤害 → 扣除目标生命值 ↓ 判断目标当前血量比例 ↓ 满足条件则追加攻击这里我们会发现变化的不只是“倍率从 1.5 改成 1.8”这样的数值配置还可能是整个业务控制流程。简单数值变化可以通过配置文件解决但业务流程本身改变时可能就需要修改代码。如果逻辑直接编译进 C 原生程序通常就需要重新构建、链接或发布相应模块。注意并不是“改了一行 C 就必须把所有源文件重新编译一遍”。CMake、Make、Ninja 等构建工具能够进行增量构建。我们真正关注的是业务逻辑代码与原生程序的构建、发布仍然存在一定耦合。3. 既然业务逻辑经常变化为什么不把它交给 Lua现在我们已经掌握 Lua 的执行机制和可嵌入性因此就能顺理成章地提出一个方案把相对稳定的基础能力保留在原生代码中而把适合频繁调整的部分游戏业务写成 Lua 脚本。例如-- 用于说明业务表达方式的简单示例functioncalculate_damage(attack,multiplier)returnattack*multiplierend需要修改业务规则时我们调整 Lua 脚本。对于已经嵌入 Lua 运行时的 C 程序而言Lua 虚拟指令的处理逻辑没有变Lua 核心运行时库没有变底层网络通信模块也未必需要修改变化的是上层 Lua 脚本及其加载后形成的字节码序列。这就和我们之前的取指、分派、执行模型联系起来了。Lua 运行时本身并不需要预先知道“这是攻击技能还是任务领取”。它只需要实现 Lua 语言的执行语义函数调用、条件判断、数值运算、跳转等。不同的 Lua 脚本通过不同的字节码序列表达不同的业务逻辑同一套 Lua 运行时可以执行这些不同的逻辑。4. 这样做带来了什么工程价值从工程角度来看至少有以下几方面1减少不必要的底层改动。如果只是修改 Lua 层的业务规则而底层 API 和通信框架不变就不必为了这部分变化去修改网络框架实现。2降低部分构建成本。脚本代码通常可以由 Lua 运行时重新加载、编译为字节码不需要针对每一次脚本变化重新编译整个宿主程序。3让职责边界更加清晰。原生代码负责基础设施脚本代码负责适合脚本化的业务两者通过明确接口协作。4为业务动态更新提供可能性。Lua 支持在运行过程中加载代码因此项目可以进一步设计按需加载或热更新机制使部分业务功能的迭代更加灵活。十、把几个最容易混淆的概念彻底区分开理解到这里以后我们再回头检查最开始容易混淆的几个问题。容易产生的认识更严谨的理解“编译型语言”一定编译解释型语言完全不编译编译与解释主要描述实现方式标准 Lua 同样会先编译为字节码Lua 字节码就是 CPU 机器码字节码是 Lua 虚拟机定义的虚拟指令不是 x86/ARM 原生指令虚拟机拿到字节码临时把它翻译成 C 语言代码标准 Lua 解释执行时是进入运行时中已编译好的指令处理逻辑一条字节码对应一条机器指令一条虚拟指令可能通过多条机器指令共同实现解释器就是一个进程解释器是执行 Lua 代码的运行时能力独立运行时可以形成进程嵌入时可以直接存在于宿主进程中一个.lua文件必然对应一个进程一个进程可以加载多份脚本一个宿主进程内也可以有多个 Lua 状态Lua 轻量级仅仅意味着解释器文件体积小小巧的实现和较低的基础运行时资源需求都重要文件大小不等于 RSS游戏服务器使用 Lua是因为 C 写不了业务C 完全可以实现引入 Lua 主要是工程灵活性和维护成本的选择十一、现在重新建立一套完整的 Lua 心智模型我们从最底层的 CPU 指令集出发现在已经可以把整条逻辑串起来。首先无论写 C 还是 LuaCPU 最终执行的都必须是其能够识别的原生机器指令。对于 C典型方式是提前将源代码编译、链接成目标平台的原生程序。而对于标准 Lua它引入了一层虚拟指令集将 Lua 源代码编译成字节码由 Lua 虚拟机进行解释执行。虚拟机本身由 C 语言实现它的各种虚拟指令处理逻辑早已被编译为原生代码。运行时虚拟机通过取指、分派、执行来实现字节码描述的操作。当我们在命令行执行lua test.lua时操作系统启动独立的lua程序解释器在该进程中执行脚本。但是解释器的执行能力并不必然依附于一个独立进程。由于 Lua 核心可以作为 C 库提供我们完全可以将其集成到已有的 C/C 程序中通过 Lua C API 在宿主进程内部创建 Lua 状态并执行脚本。而 Lua 核心运行时比较精简又使这种集成的基础资源成本相对可控。到这里我们回到游戏服务器。此前编写的 Reactor 网络服务器解决的是客户端连接、I/O 事件、数据收发、协议处理等问题。游戏服务器同样需要这些基础能力但是在其上层还要承担战斗、任务、道具等复杂的游戏业务。相较于底层通信框架这些业务规则可能变化得更加频繁。因此一种很自然的工程思路就是将相对稳定的底层能力与频繁变化的部分上层业务解耦把适合脚本化的业务交给 Lua让同一套 Lua 运行时执行不同的脚本逻辑。这样在部分业务变更时就不一定需要重新编译整个 C/C 服务器程序如果项目实现了相应机制还可以进一步支持业务动态加载和热更新。所以回到本文最开始的问题为什么一段 Lua 脚本能够跑进 C 服务器因为 Lua 的核心解释执行能力可以被封装成库嵌入 C 进程内部宿主程序通过接口调用 Lua 运行时Lua 运行时再根据字节码执行相应业务逻辑。为什么游戏服务器又喜欢使用 Lua不是因为 C 不能实现游戏业务而是因为游戏业务存在大量变化Lua 提供了一种让业务逻辑与底层原生程序适度解耦的手段从而在性能、迭代效率与维护成本之间获得更合适的平衡。至此我们其实仍然没有系统学习 Lua 的变量、函数、table、协程等语法但已经把Lua 是怎么执行的、为什么能够嵌入 C以及它为什么会出现在游戏服务器里这三个问题从底层逻辑上贯通起来了。接下来再学习 Lua 语言本身就不再是单纯地背一套新语法而是能够把每一个语言机制放回它真正的运行环境和工程场景中理解。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询