ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Piccolo 引擎 sol2 集成实战:Lua 错误处理、异常传播与编译/链接故障排查完整指南

Piccolo 引擎 sol2 集成实战:Lua 错误处理、异常传播与编译/链接故障排查完整指南
  • 游戏开发
  • 图形学

【免费下载链接】Piccolo

Piccolo (formerly Pilot) – mini game engine for games104

项目地址:https://gitcode.com/gh_mirrors/pi/Piccolo
点击查看免费下载

本指南以 sol2-3.3.0 官方文档《errors》为核心骨架,系统讲解在 Piccolo 引擎(内置 sol2 与 Lua 5.4)中如何检测脚本运行错误、处置 panic 崩溃、规避 C++/Lua 混合编程下的编译与链接陷阱,并给出可直接复制的代码与编译参数。读完你将掌握safe_script家族 API、script_default_on_error/script_pass_on_error两个内置错误处理器、SOL_USING_CXX_LUA与SOL_EXCEPTIONS_SAFE_PROPAGATION等关键宏的使用边界,以及一套从"报错"到"定位"的完整排查方法论。

一、sol2 在 Piccolo 引擎中的实际角色

Piccolo 引擎(原 Pilot)在engine/3rdparty/目录下以源码形式内置了 Lua 5.4.4、sol2-3.3.0 以及 sol2 所需的绑定代码,并封装出一个 Lua 组件供游戏对象使用。在 lua_component.cpp 中可以看到典型的 sol2 集成方式:

// 打开 base 库,向 Lua 暴露 C++ 函数与对象 m_lua_state.open_libraries(sol::lib::base); m_lua_state.set_function("set_float", &LuaComponent::set<float>); m_lua_state.set_function("get_bool", &LuaComponent::get<bool>); m_lua_state.set_function("invoke", &LuaComponent::invoke); m_lua_state["GameObject"] = m_parent_object; // 每帧执行 Lua 脚本 m_lua_state.script(m_lua_script);

其中m_lua_state的类型是 sol::state(见 lua_component.h)。这里直接调用了script,而官方文档 errors.rst 正是围绕"脚本可能出错"这一现实展开的:脚本可能语法错误、可能从文件系统加载失败、可能运行期抛错,如果不做区分和处理,轻则静默失败,重则直接拖垮整个 Lua VM。下面逐一展开。

二、脚本运行错误的三层排查:safe_script 与内置错误处理器

2.1 为什么会分 safe / unsafe 两套

sol2 在 state_view.hpp 中为sol::state/sol::state_view提供了两组脚本执行接口:

  • safe 系:safe_script、safe_script_file。加载并执行脚本,出错时走错误回调,返回sol::protected_function_result,可检查valid()。
  • unsafe 系:unsafe_script、unsafe_script_file。出错时直接调用lua_error(进而触发 panic),不做任何检查,性能最快但最危险。

默认的script/script_file在定义SOL_SAFE_FUNCTION_OBJECTS(或SOL_ALL_SAFETIES_ON)时等价于 safe 版本,否则等价于 unsafe 版本。官方文档给出的建议很明确:只要不是刻意追求极致速度,请优先使用safe_script系列。

2.2 内置错误处理器:default / pass / throw

safe_script的最后一个参数是"仅当出错时才被调用"的错误回调。sol2 预置了两个默认处理器(定义见 state_handling.hpp):

  • sol::script_pass_on_error(lua_State*, protected_function_result):原样透传protected_function_result,不抛异常、不触发 panic,把错误判断完全交给调用方;
  • sol::script_default_on_error(lua_State*, protected_function_result):默认处理器。在未定义SOL_DEFAULT_PASS_ON_ERROR时实际转发给script_throw_on_error——它会拼装一条包含call_status、异常类型与what()文本的错误信息,然后抛出sol::error;若定义了SOL_DEFAULT_PASS_ON_ERROR则退化为 pass 行为(官方在 safety.rst 中强烈警告该宏"非常危险",除非有极其充分的理由否则不要开启)。

2.3 三种错误处理方式完整对照

官方示例 script_error_handling.cpp 用同一段坏脚本(bad&$#*$syntax)演示了三种策略:

方式一:default_on_error + try/catch(异常可用时)

try { int value = lua.script(code, sol::script_default_on_error); // 脚本错误,这里永远不会到达 std::cout << value << std::endl; } catch (const sol::error& err) { std::cout << "Something went horribly wrong: thrown error" << "\n\t" << err.what() << std::endl; }

方式二:pass_on_error + 结果检查(不依赖异常)

sol::protected_function_result result = lua.script(code, sol::script_pass_on_error); if (!result.valid()) { sol::error err = result; sol::call_status status = result.status(); std::cout << sol::to_string(status) << " error" << "\n\t" << err.what() << std::endl; }

方式三:load + 分步执行(最低层、最显式)

sol::load_result loaded_chunk = lua.load(code); if (!loaded_chunk.valid()) { // 语法错误在此暴露 sol::error err = loaded_chunk; sol::load_status status = loaded_chunk.status(); std::cout << "loading error: " << sol::to_string(status) << "\n\t" << err.what() << std::endl; } else { sol::protected_function script_func = loaded_chunk.get<sol::protected_function>(); sol::protected_function_result result = script_func(); if (!result.valid()) { // 运行期错误在此暴露(nil 访问、内存错误等) sol::error err = result; sol::call_status status = result.status(); std::cout << "running error: " << sol::to_string(status) << "\n\t" << err.what() << std::endl; } }

2.4 自定义错误回调:只会在出错时被调用

示例 state_script_safe.cpp 展示了自定义回调的写法。回调收到protected_function_result,你可以自由处理(甚至再次抛出),但若选择不抛出,就必须把结果return回去:

auto result2 = lua.safe_script("123 bad.code", [](lua_State*, sol::protected_function_result pfr) { sol::error err = pfr; std::cout << "An expected error occurred: " << err.what() << std::endl; // 不抛异常就必须返回结果 return pfr; });

底层实现(state_view.hpp)也很直白:safe_script先调用do_string/do_reader/do_file加载执行,若结果!pfr.valid()才调用on_error(L, std::move(pfr)),否则原样返回。这保证了回调只在真正出错时触发,正常情况下没有任何额外开销。

三、编译器错误与警告:一份可对照的排查清单

sol2 是重度模板库,错误信息往往又长又吓人。官方文档给出了 8 条实战经验,逐一展开如下:

3.1 先检查 C++ 标准开关

如果报错堆里满是std::index_sequence、type traits 等std::成员相关错误,十有八九是没有开启 C++14。Visual Studio 2015 起默认开启,但 g++ / clang++ 需要显式指定:

--std=c++14 # 或早期编译器使用 --std=c++1y

3.2 非基本类型入栈报出 initializer list / luaL_Reg 错误

向 Lua 推送非基本类型时若出现奇怪的 initializer list 或无法初始化luaL_Reg的错误,多半与 sol2 的自动注册机制(automagical registration,为 usertype 自动生成常见运算符与成员访问方法)有关。可尝试通过#define SOL_DEFAULT_AUTOMAGICAL_USERTYPES 0关闭默认的 automagical 行为(相关开关定义见 version.hpp),关闭后自行显式注册需要的成员。

3.3 大量成员绑定导致的调试符号截断警告

当单个 usertype 绑定了大量成员函数时,生成的代码可能触发"debug symbols cut off"或__LINE_VAR超长等警告。这类警告在多数编译器下可以安全屏蔽(例如 MSVC 的/wd系列或对应 warning level 调整)。

3.4 模板深度错误

早期 clang++ / g++ 上常出现模板实例化深度超限。用-ftemplate-depth把深度提到足够高即可:

-ftemplate-depth=2048 # 甚至更高

3.5 MSVC C1128:需要 /bigobj

大量 usertype 模板实例化会让 MSVC 触发compiler error C1128(目标文件段数超限),解决办法是加编译标志/bigobj。

3.6 move-only 类型必须 readonly

如果绑定的是 move-only 类型(如std::unique_ptr成员),当它作为 usertype 的成员变量、或用state_view::set_function绑定时,需要包一层sol::readonly。示例见 usertype_advanced.cpp:

player_type.set("bullets", sol::readonly(&player::bullets));

此时 Lua 侧只能读不能写,写入会报错。sol::readonly的详细说明见 readonly.rst。

3.7 std::string / std::pair 的 operator= 赋值错误

std::string或std::pair<T1, T2>在构造后用operator=赋值,在与sol::function及其返回值交互时可能产生编译错误(官方在 issue 中给出的修复方式是对这类返回值显式取类型或改用显式构造,避免隐式 operator= 路径)。

3.8 __stdcall(x86)与 /std:c++latest 的坑

  • 在 32 位 (x86) 环境下,VC++ 的编译器 bug 可能导致__stdcall函数绑定失败。sol2 已带初步修复;若仍有问题,把函数塞进std::function再绑定通常能绕开。
  • 若使用/std:c++latest并出现大量针对函数noexcept说明符的报错,说明当前 VC++ 编译器版本对最新标准支持尚不完善,需要等待编译器更新或向编译器厂商反馈。

四、Mac OSX 下 LuaJIT 的随机崩溃

在 Mac OSX 上使用 LuaJIT 时,代码可能在看似随机的位置崩溃。官方建议按 LuaJIT 官网说明在构建中加入如下链接标志:

-pagezero_size 10000 -image_base 100000000

这两个标志能让 LuaJIT 的 JIT 代码在 macOS 的地址空间布局下正常运行,避免任意崩溃。注意:本项目默认内置的是 PUC-RIO Lua 5.4.4(见 lua-5.4.4 目录),此条主要面向自行替换为 LuaJIT 的场景。

五、"compiler out of heap space":让 VS 编译器使用 x64 工具链

这是 Visual Studio 的典型问题:VS 默认的编译器本身是 32 位可执行程序(它照样能产出 x86 / x64 / ARM 二进制),因此宿主进程的堆空间受限。按官方指引(源自社区 issue 中 OrfeasZ 的修复方案),在.vcxproj的<Import .../>语句之后加入:

<PropertyGroup> <PreferredToolArchitecture>x64</PreferredToolArchitecture> </PropertyGroup>

让编译器默认使用 64 位工具,堆空间上限随之提升到 64 位 Windows 机器可承载的水平。如果内存仍不足(例如机器不足 4GB RAM),就要考虑拆分 usertype:

  • 把一个大 usertype 分散到多个 C++ 文件;
  • 不要在单个new_usertype(...)调用里塞全部函数,改用下标赋值逐个注册:
auto my_usertype = lua.new_usertype<my_class>("my_class"); my_usertype["talkin"] = &about; my_usertype["this"] = &my_class::that; my_usertype["and_the"] = sol::property(&my_class::third);

逐条my_usertype["func"] = func;的方式能显著降低单次模板实例化的压力,是官方文档明确推荐的写法。

六、链接错误:C 与 C++ 调用约定之争

6.1 extern "C" 的默认假设

sol2 假定 Lua 库按 C 约定编译,因此在其包含代码里用extern "C"包裹了 Lua 头文件(静态/动态链接的 Lua 通常都导出 C 符号)。但如果目标 Lua 库是用 C++ 编译的,就必须去掉extern "C"块、切换到 C++ 的名称修饰(name mangling),否则链接阶段找不到符号。

6.2 SOL_USING_CXX_LUA / SOL_USING_CXX_LUA_JIT

在包含sol/sol.hpp之前(或加入编译命令行)定义:

#define SOL_USING_CXX_LUA

即可告知 sol2:Lua 是 C++ 编译的。若用的是以 C++ 模式构建的 LuaJIT,还需额外定义SOL_USING_CXX_LUAJIT(官方原话:基本不会有人这么干,通常不需要)。标准构建的 Lua / LuaJIT 都不应该定义这些宏,否则反而会破坏 C 链接假设。

这两个宏的实际效果记录在 safety.rst 的 Linker Config 一节:取消extern "C"包裹、自动打开SOL_EXCEPTIONS_SAFE_PROPAGATION等。

七、"caught (...) exception":异常被吞掉的问题

有时你期望捕获到精心构造的错误,实际却只收到一个"catch(...)"级别的通配异常。这通常意味着:

  • 你把 Lua 构建成了 C++;或
  • 你正在使用对 C++ 异常有完整互操作支持的框架(LuaJIT 在特定平台:LuaJIT 2.0.5 的 x64,LuaJIT 2.1.x-beta 及之后的 x86 与 x64)。

此时请在包含 sol2 前定义:

#define SOL_EXCEPTIONS_SAFE_PROPAGATION

该宏的语义(见 safety.rst):不再在 C 函数 trampoline 与safe_/protected_function内部使用 try/catch 包裹,让 C++ 异常直接穿透 Lua API 传播回来,从而在 C++ 侧用catch (...)捕获到真正的异常。注意 exceptions.rst 中的警告:当检测到 LuaJIT 时 sol2 不会自动定义该宏,必须由你确认平台支持后自行定义。

实现层面,该宏最终映射为SOL_PROPAGATE_EXCEPTIONS(见 lua_version.hpp),并在两处改变行为:函数 trampoline(trampoline.hpp)与protected_function的调用核心(protected_function.hpp)——后者在开启时跳过try/catch分支,让异常原样上抛。

八、panic 与崩溃:捕获错误不等于一切安好

8.1 default_at_panic 做了什么

sol2 在创建状态时会自动安装default_at_panic处理器(见 state_handling.hpp):异常未关闭时,它取出栈顶错误消息,若定义了SOL_PRINT_ERRORS会打印到std::cerr,然后throw sol::error(err)给 C++ 侧一个恢复机会。完整的状态初始化流程封装在set_default_state中(state_handling.hpp),它会依次设置 panic 函数、默认 traceback 处理器、异常处理器并注册主线程。

8.2 为什么官方强烈建议"捕获 + 崩溃"

关键警告来自 errors.rst 的Catch and CRASH!一节:Lua 调用lua_atpanic意味着 VM 已经处于不可预测甚至死亡的状态。Lua 本质上是 C API,异常从它里面"冒出来"属于最后的、终止性的行为。在默认处理器抛出的异常上做try { ... } catch (...) {}后继续运行,等于在一个状态不明的 VM 上继续执行——release/优化模式下会出现难以排查的诡异 bug。

官方给出的推荐流程是:记录日志 → 尽快终止当前 Lua VM → 若应用无法新建一个 Lua state 则直接崩溃。set_panic的签名在 state.rst 中有说明:它是void set_panic(lua_CFunction panic),必须匹配int(lua_State*)签名。

8.3 自定义 panic 函数示例

若你不想让默认处理器抛异常(例如异常被关闭时),可用sol::state lua(my_panic_function)构造、lua_atpanic(L, fn)或state_view::set_panic自行注册。官方示例 my_panic.cpp:

inline void my_panic(sol::optional<std::string> maybe_msg) { std::cerr << "Lua is in a panic state and will now abort() the application" << std::endl; if (maybe_msg) { std::cerr << "\terror message: " << maybe_msg.value() << std::endl; } // 函数返回后 Lua 将按默认行为 abort() } int main() { sol::state lua(sol::c_call<decltype(&my_panic), &my_panic>); // 等价写法: // lua_atpanic(L, sol::c_call<decltype(&my_panic), &my_panic>); // lua.set_panic(sol::c_call<decltype(&my_panic), &my_panic>); return 0; }

九、析构函数与安全:为什么建议把 Lua 构建为 C++

Lua 是 C API,出错时依赖setjmp/longjmp跳出代码。longjmp不会触发 C++ 析构函数——如果你错误地使用了库或底层 Lua VM,栈上的 RAII 对象可能被直接跳过,造成资源泄漏与未定义行为。

官方建议:把 Lua 构建为 C++。此时lua_error以 C++ 异常的形式上抛,触发正常的栈展开(stack unwinding),析构函数得以按标准语义执行。这与第七节SOL_USING_CXX_LUA的设定是配套的——用 C++ 构建 Lua 并定义该宏,既解决链接约定,又获得正确的异常展开语义。

十、受保护函数与安全取值:日常开发的默认姿势

10.1 默认 API 的"乐观假设"

errors.rst 明确指出:默认情况下sol::function假设代码运行完美无缺,sol::state(_view)::script(_file)同样如此——它们追求简单与速度,出错时以抛异常"暴力"终止 VM。因此在生产代码里应改用:

  • sol::protected_function:调用结果可检查(valid()/status()/ 错误消息);
  • sol::optional<T>:从 Lua 安全取值,取不到时为nullopt;
  • do_string/do_file/load/load_file:安全加载并拿到结果。

10.2 protected_function 实战

示例 protected_functions.cpp 展示了带错误处理函数的受保护调用:

lua.script(R"( function handler (message) return "Handled this message: " .. message end function f (a) if a < 0 then error("negative number detected") end return a + 5 end )"); sol::protected_function f(lua["f"], lua["handler"]); sol::protected_function_result result = f(-500); if (result.valid()) { int x = result; std::cout << "call succeeded, result is " << x << std::endl; } else { sol::error err = result; std::cout << "call failed, what(): " << err.what() << std::endl; // what() 将得到 "Handled this message: negative number detected" }

其实现核心是lua_pcall(见 protected_function.hpp),并针对const char*、std::string、std::exception等类型做分类捕获,把 C++ 函数里抛出的异常转成 Lua 错误再传回结果。

10.3 exception_handler:捕获从 C++ 函数泄漏的异常

sol2 还提供sol::exception_handler_function:当绑定到 Lua 的 C++ 函数抛出异常并冒泡出来时,被注册的处理器会被调用,且必须向栈上压入 1 个元素(通常是一个字符串,用于lua_error)。官方示例 exception_handler.cpp:

int my_exception_handler(lua_State* L, sol::optional<const std::exception&> maybe_exception, sol::string_view description) { // 打印异常信息…… // 必须压入 1 个元素作为 Lua 侧的错误对象 return sol::stack::push(L, description); } void will_throw() { throw std::runtime_error("oh no not an exception!!!"); } int main() { sol::state lua; lua.open_libraries(sol::lib::base); lua.set_exception_handler(&my_exception_handler); lua.set_function("will_throw", &will_throw); sol::protected_function_result pfr = lua.safe_script( "will_throw()", &sol::script_pass_on_error); sol_c_assert(!pfr.valid()); sol::error err = pfr; std::cout << err.what() << std::endl; }

十一、受保护函数不是万能:加载失败的边界情况

sol::protected_function只能保护"函数调用"环节。如果问题出在文件加载本身——例如脚本文件路径错误、文件内容无法解析、执行前的加载步骤就失败——那么单纯包一层protected_function救不了你。

正确的兜底姿势是:

  • 注册自己的 panic 处理器,捕获到达 panic 的错误;
  • 或遵循第八节的"捕获 + 崩溃"策略;
  • 或在极端场景下放弃 sol2 的内置保护,直接绑定一个裸lua_CFunction,完全自己掌控错误流。

十二、表迭代的注意点

对 Lua 表做迭代时有两个常见陷阱:

  1. 表上可能挂着其他"杂物"(非数字键、__pairs等),导致for-each风格的循环或 sol 的for_each在处理数字段时行为奇怪。此时应改用显式的数值循环(for i = 1, #t)来遍历数字段。
  2. 迭代本身没有任何定义好的顺序。依赖顺序的代码必须显式处理(排序或改用数组语义),官方在 table.rst 中对此有专门说明。

十三、本指南相关文件索引

主题仓库路径
错误处理官方文档errors.rst
异常专题文档exceptions.rst
配置与安全宏文档safety.rst
state 自动处理器说明api/state.rst
内置错误处理器实现state_handling.hpp
safe/unsafe 脚本实现state_view.hpp
受保护函数实现protected_function.hpp
异常传播宏解析lua_version.hpp
官方错误处理示例script_error_handling.cpp
safe_script 示例state_script_safe.cpp
panic 函数示例my_panic.cpp
异常处理器示例exception_handler.cpp
Piccolo 引擎集成点lua_component.cpp

总结:sol2 的错误处理体系可以概括为三条主线——脚本层用safe_script+ 内置/自定义错误回调兜底;调用层用sol::protected_function检查每次调用的成败;VM 层用 panic 处理器 + 异常传播宏控制崩溃边界。而编译/链接期的各类陷阱,绝大多数都能通过正确设置 C++ 标准、SOL_USING_CXX_LUA、SOL_EXCEPTIONS_SAFE_PROPAGATION及拆分 usertype 等针对性手段化解。建议在接入 Piccolo 引擎的 Lua 组件时,把m_lua_state.script(m_lua_script)换成带错误检查的safe_script变体,让每帧脚本执行的结果可观测、可诊断。

  • 游戏开发
  • 图形学

【免费下载链接】Piccolo

Piccolo (formerly Pilot) – mini game engine for games104

项目地址:https://gitcode.com/gh_mirrors/pi/Piccolo
点击查看免费下载

相关推荐

上一篇:Input Leap 完整指南:开源KVM软件,免费实现一套键鼠控制多台电脑
下一篇:docker-selenium 发布日志深度解读:Edge 128 镜像的版本矩阵与 Tag 命名机制

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进