为什么我们的设备需要脚本引擎?
在现代测试测量仪器(如源表 SourceMeter、示波器、电子负载等)的开发中,我们通常会提供精美的 GUI 界面供用户手动操作,并提供 SCPI 指令供上位机远程控制。
但这往往不够。在工业自动化流水线或复杂的科研测试场景中,客户经常需要设备脱机自动执行一连串的复杂动作 (例如:设置电压 -> 延时 -> 测量 -> 条件判断 -> 循环扫描)。如果全靠上位机发指令,通信延迟会极大影响测试吞吐量。因此,提供一种设备端的二次开发脚本语言(如吉时利的 TSP 技术)几乎是高端仪器的标配。
经过多方技术选型,在 C++ 工程中引入了 Lua 最合适。
为什么是 Lua + Sol2?
- Lua 的极致轻量:官方源码仅几十个文件,几百 KB 大小,运行极快,是嵌入式设备的天选之子。
- Sol2 的优雅绑定 :原生的 Lua C-API 需要开发者疯狂操作"虚拟栈"(
lua_push、lua_pop),极其痛苦且容易内存泄漏。而 Sol2 是一个仅头文件(Header-only)的 C++17 库,它利用现代 C++ 的模板元编程,让你能用一行代码将 C++ 的类、函数、变量直接暴露给 Lua。
本文将手把手带你在一台基于 Linux + CMake + C++17 构建的设备中,从零搭建这套 Lua 脚本引擎。
🛠️ 第一步:环境准备与源码目录构建
与其他庞大的第三方库不同,引入 Lua 和 Sol2 最优雅的方式是源码级集成,直接让它们跟着你的主工程一起编译。
1. 获取 Lua 源码 (避坑警告 ⚠️)
- 前往 Lua 官方 FTP 下载稳定版源码。强烈建议下载 Lua 5.4.2(经过实测,该版本与当前 Sol2 v3.3 的兼容性最为完美,能避开新版 Lua 底层 API 变更导致的编译报错)。
- 解压后,将其
src目录下的所有.c和.h文件拷贝到你工程的thirdparty/lua/src/目录下。 - 💣 高能避坑 :必须删除
lua.c和luac.c这两个文件!
- 原因 :这两个文件是 Lua 官方提供的命令行解释器(REPL)和编译器入口,里面都包含了独立的
main()函数。如果不删,后续make编译时必然会报multiple definition of 'main'的致命错误!
2. 获取 Sol2 库
- 前往 GitHub 的
ThePhD/sol2仓库,在 Releases 中下载最新的 v3.x 版本(必须是 v3,才支持 C++17)。 - 下载其单文件版本(Single-header),你会得到一个
sol.hpp。 - 将其放入工程的
thirdparty/sol2/sol/目录下。
最终,你的工程目录树应该长这样:
text
MyProject/
├── CMakeLists.txt # 你的工程主构建脚本
├── main.cpp # 你的主程序
├── Script/ # 稍后用来存放引擎封装代码的目录
└── thirdparty/ # 第三方依赖
├── lua/
│ └── src/ # 包含几十个 .c 和 .h (已删 lua.c/luac.c)
└── sol2/
└── sol/
└── sol.hpp # Sol2 核心头文件
⚙️ 第二步:CMakeLists.txt 核心配置
代码放好后,我们需要在 CMake 中把它们"粘合"起来。这里有三个核心关键点:开启 C++17 、汇集源文件 、链接系统依赖。
在你的 CMakeLists.txt 中添加以下内容:
cmake
# 1. 强制开启 C++17 标准 (Sol2 v3 的硬性要求,否则大量模板报错)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# 2. 搜集 Lua 源码 (匹配我们刚刚放进去的 .c 文件)
file(GLOB_RECURSE LUA_SRCS "${CMAKE_CURRENT_SOURCE_DIR}/thirdparty/lua/src/*.c")
# 3. 添加头文件搜索路径 (让编译器能找到 <lua.h> 和 <sol/sol.hpp>)
target_include_directories(${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/thirdparty/lua/src)
target_include_directories(${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/thirdparty/sol2)
# 4. 将 Lua 源码一并加入你的执行目标编译列表中
target_sources(${PROJECT_NAME} PRIVATE ${YOUR_APP_SRCS} ${LUA_SRCS})
# 5. Linux 环境下的神秘依赖 (必加)
if(UNIX AND NOT APPLE)
# Lua 底层可能需要动态加载 C 模块 (dl) 并使用了大量的数学函数 (m)
target_link_libraries(${PROJECT_NAME} PRIVATE dl m)
endif()
🚀 第三步:封装 C++ 侧的引擎骨架
环境就绪后,我们不要把 Lua 的调用散落在业务代码里。最佳实践是创建一个单例类 ScriptEngine,由它来统筹 Lua 虚拟机的生命周期。
在 Script/ 目录下新建 ScriptEngine.hpp 和 ScriptEngine.cpp。
ScriptEngine.hpp
💣 高能避坑 :如果你使用的是 Ubuntu 22.04 或 GCC 11 以上的现代编译器,请务必在 #include <sol/sol.hpp> 之前加上 #include <limits>,否则会报 numeric_limits is not a member of std 的错!
cpp
/**
* @file ScriptEngine.hpp
* @brief Lua 脚本引擎核心封装
*/
#pragma once
#include <limits> // 修复 GCC11+ 下 sol2 的 numeric_limits 报错
#include <sol/sol.hpp>
#include <string>
namespace APP {
class ScriptEngine {
public:
// 获取单例
static ScriptEngine& GetInstance() {
static ScriptEngine instance;
return instance;
}
// 初始化虚拟机
void Init();
// 运行一段代码字符串
void RunString(const std::string& script);
// 运行一个本地 .lua 文件
bool RunFile(const std::string& filepath);
private:
ScriptEngine() = default;
~ScriptEngine() = default;
private:
sol::state lua_; // Sol2 包装的 Lua 虚拟机状态机
};
} // namespace APP
ScriptEngine.cpp
cpp
#include "ScriptEngine.hpp"
#include <iostream>
namespace APP {
void ScriptEngine::Init() {
// 1. 打开 Lua 的基础标准库 (math, string, os, table 等)
// 这样脚本里就能愉快地使用 string.format 和 math.sin 啦
lua_.open_libraries(sol::lib::base,
sol::lib::math,
sol::lib::string,
sol::lib::os,
sol::lib::table);
// 2. (可选) 验证一下是否联通
std::cout << "[ScriptEngine] Lua 虚拟机初始化完成!" << std::endl;
}
void ScriptEngine::RunString(const std::string& script) {
try {
lua_.script(script);
} catch (const sol::error& e) {
std::cerr << "Lua 语法/运行错误: " << e.what() << std::endl;
}
}
bool ScriptEngine::RunFile(const std::string& filepath) {
try {
auto result = lua_.script_file(filepath);
if (!result.valid()) {
sol::error err = result;
std::cerr << "执行文件失败: " << err.what() << std::endl;
return false;
}
return true;
} catch (const std::exception& e) {
std::cerr << "发生异常: " << e.what() << std::endl;
return false;
}
}
} // namespace APP
✅ 第四步:点亮你的脚本引擎
最后,在你的 main.cpp 中引入并调用它:
cpp
#include "Script/ScriptEngine.hpp"
#include <iostream>
int main() {
std::cout << "系统启动中..." << std::endl;
// 1. 初始化引擎
APP::ScriptEngine::GetInstance().Init();
// 2. 跑一句经典的 Hello World 压压惊
APP::ScriptEngine::GetInstance().RunString("print('Hello from Lua! 1+1 = ' .. tostring(1+1))");
// 你的主程序循环...
while(1) {
// ...
}
return 0;
}
执行 make 编译,如果你能在终端看到打印出的 Hello from Lua! 1+1 = 2,恭喜你!这台 C++ 仪器的"跨次元壁"大脑已经成功点亮了!
🔮 总结与下期预告
在这篇文章中,我们扫清了环境搭建、CMake 链接、版本冲突等一系列外围障碍,成功在 Linux C++ 程序内部养了一只轻盈的 Lua 虚拟机。
但这套代码还远不能投入工业使用!
在一个带有 UI(比如 LVGL)的仪器中,如果你直接调用 RunFile() 跑一个带有长延时(delay)甚至死循环(while true)的脚本,整个仪器的触控和界面会瞬间卡死(UI 线程被阻塞)! 在下一篇《实战篇:拒绝 UI 卡死!C++ 仪器系统中的 Lua 脚本多线程与硬件绑定揭秘》中,我将硬核分享:
- 多线程调度:如何把 Lua 关进后台独立线程?
- 底层硬件绑定 :如何用 Sol2 把 C++ 的底层控制类(如设置电压、读取电流)暴露给 Lua
smu命名空间? - 安全拦截器:用户写了死循环怎么办?如何利用 Lua Hook 机制实现"强杀"?