简介:这份 ChaiScript 集成示例面向希望在 C++11 可执行文件中嵌入脚本能力的开发者,重点解决动态配置、插件扩展与逻辑热更新等场景下的集成问题,适合具备一定 C++ 基础、想了解脚本绑定的中级读者。压缩包共 18 个文件,约 18KB,包含 make、makefile 与 vcxproj/sln 等构建配置,cpp/h 源码演示对象注册与脚本调用,md、yml 与 lua 文件辅助说明工程结构、持续集成与构建流程,两份 cpp 与一份 h 头文件可对照阅读,整体体量轻量、结构清晰。目前已有 239 人学习下载。读者可从中获得跨 Linux、Windows、macOS 的构建入口,参考 Makefile 与 Visual Studio 工程配置,理解如何把 C++ 类、函数暴露给 ChaiScript,并通过示例代码掌握表达式求值、类对象交互及脚本函数调用的基本写法;也可将其作为插件系统、测试脚本或游戏逻辑脚本化的最小原型,便于按目录拆分后快速改造到自己的项目。
1. 改一行阈值就要重编译:这个示例解决的就是这类麻烦
一个用 C++11 写的采集服务,判定逻辑写死在 .cpp 里,每改一个数字就要走一遍编译、打包、发版。这类需求最省事的解法,是把那几十行策略搬到脚本里,主程序只保留一个 ChaiScript 引擎。chaiscript_example 做的就是这件事的最小样例:一个 header-only 的 ChaiScript 引擎用 C++11 嵌入可执行文件,配一份手写 Makefile,make一下就能得到一个能跑脚本的宿主程序。它适合两类人:想把业务规则从 C++ 里拆出去的后端开发,和想找一个嵌入式脚本引擎入门样本、又不想被 CMake 和第三方包管理器绕晕的人。麻雀虽小,eval_file、fun注册、异常隔离这三件事一样没少。
2. 选型与目录:header-only 引擎 + Makefile 的最小组合
2.1 ChaiScript 与 C++11 的契合点
选 ChaiScript 而不是 Lua、Python,最直接的理由是它 header-only。整个引擎就是一堆.hpp,把include目录往-I里一丢就能编,不需要预编译.so、不需要find_package、也不需要在目标机器上装运行时。对于“给一个已有 C++11 工程加个脚本层”这种需求,这点省下来的工程量比想象中大——不用改构建系统,不用管动态库搜索路径,交付物仍然是一个单文件可执行程序。
第二个理由是类型系统贴得近。ChaiScript 的绑定是编译期模板推导,chaiscript::fun(&some_func)直接吃函数指针、lambda、成员函数指针,参数类型和返回值类型会自动注册进脚本的全局命名空间。写脚本的人不需要理解“栈式绑定”那套东西,直接over_limit(93.5, 80)就行,语法接近 JavaScript 或者简化版 C++,学习成本基本只有半天。
代价也要说清楚,不然容易翻车。ChaiScript 的模板展开很重,一个chaiscript::ChaiScript实例化下来编译时间肉眼可见地涨,头文件解析能吃掉好几秒。常见做法是把引擎的创建和注册收敛到一到两个.cpp里,其他地方只传接口,别让每个编译单元都#include <chaiscript/chaiscript.hpp>。另外 C++11 的 lambda 捕获要注意:按引用捕获一个栈上变量再注册进引擎,出了作用域脚本一调就是未定义行为,这个问题在第 5 章还会再提。
第三个点是版本差异。比较老的 5.x 之前,构造引擎要显式传chaiscript::Std_Lib::library();新一点的版本chaiscript::ChaiScript chai;默认构造就带标准库了。这个示例按默认构造写,如果你的依赖目录里是旧版头文件,编译报构造函数参数不匹配,就退回显式传库的写法,不用怀疑是编译器的问题。
2.2 目录结构:src 只放宿主,scripts 是运行时资产
这个示例的目录划分很克制,三层各管一件事,值得照抄:
chaiscript_example/ ├── Makefile ├── src/ │ └── main.cpp # 宿主程序,只负责搭引擎、注册函数、跑脚本 ├── scripts/ │ └── main.chai # 策略脚本,运行期加载,不参与编译 └── third_party/ └── ChaiScript/ └── include/ └── chaiscript/ ├── chaiscript.hpp └── ...src里只有宿主,它的职责边界很清楚:把 C++ 的能力注册给脚本,把脚本的执行结果拿回来,剩下的判断逻辑一行都不写。scripts是被eval_file在运行时读的,Makefile 里完全不会出现这个目录的名字——这一点很关键,脚本改动不应该触发任何重新编译,否则就白折腾了。third_party是只读依赖,升级引擎就整个目录替换,业务代码不用动。
-I指向的是third_party/ChaiScript/include这一层,而不是它下面的chaiscript子目录。因为代码里写的是#include <chaiscript/chaiscript.hpp>,路径前缀里必须保留chaiscript/这一段。这个细节看着无聊,但实际项目里第一次接进来报“找不到头文件”,八成就是-I多指或者少指了一层。
2.3 为什么这个示例坚持用 Makefile
CMake 当然是更主流的选法,但对这个示例来说,它带来的东西全是负收益。整个工程就一个.cpp、一个可执行文件、一份纯头文件依赖,用 CMake 会多出CMakeLists.txt、一个build目录、一堆CMakeCache和CMakeFiles,读者要理解的东西反而变多了。Makefile 在编辑器里打开就是全部:编译器是谁、标准是哪个、优化级别多少、头文件在哪、链接了什么,一眼看完。
判断什么时候该从 Makefile 换走,我给三个信号:一是依赖数量涨到三个以上,开始需要探测库的存在和版本;二是要出 Windows 产物,MSVC 那套参数跟 GCC 差得远,手写条件分支会很难看;三是团队里有人要在 IDE 里做断点调试,需要 IDE 能直接读的工程描述文件。这三点都没踩到之前,Makefile 是最划算的。
还有一点实际收益:CI 里只要机器上有make和g++就能跑,不用先装构建工具本身。镜像小、启动快,对示例工程和小型服务来说这点很实在。
3. Makefile 逐行拆解:从 .cpp 到可执行文件的构建链路
3.1 编译和链接为什么分成两步
一个.cpp变成可执行文件,中间要经过“编译成目标文件”和“把目标文件链接成可执行文件”两个阶段。很多人图省事直接g++ src/main.cpp -o app,一条命令确实能出结果,但每次都把所有源文件重新编译一遍。当工程从 1 个.cpp长到 20 个,改一行就要等十几秒,这种等待会实打实地磨掉节奏。
分开之后,每个.cpp独立产出.o,谁的时间戳更新了就只重编谁,其余的直接复用。Makefile 的核心价值就在这里:它维护的是一张“目标 ← 依赖”的图,make只重跑那些依赖比目标新的规则。理解这一点,后面所有看起来奇怪的写法(模式规则、order-only、.d文件)就都能对上号了。
链接阶段则要关注顺序:目标文件在前,库在后。GCC 的链接器是从左往右扫描的,-lxxx写在.o前面会导致符号找不到,报undefined reference。这个顺序错误在 Makefile 里特别常见,因为变量拼接时很容易把$(LDLIBS)放错位置。
3.2 一份能直接跑的 Makefile
# ---- 工具链与编译参数 ---- CXX ?= g++ CXXFLAGS ?= -std=c++11 -O2 -Wall -Wextra CXXFLAGS += -Ithird_party/ChaiScript/include # 指向含 chaiscript/ 的那一层 # 链接参数:ChaiScript 在 Linux 上可能要用到 dlopen LDFLAGS ?= LDLIBS ?= -ldl -pthread # ---- 目标与源文件 ---- TARGET := chai_demo SRCS := $(wildcard src/*.cpp) OBJS := $(SRCS:src/%.cpp=build/%.o) # 源文件路径映射到 build 下 DEPS := $(OBJS:.o=.d) # 由 -MMD 生成的依赖文件 # ---- 默认目标 ---- all: $(TARGET) # ---- 链接:目标文件在前,库在后 ---- $(TARGET): $(OBJS) $(CXX) $(CXXFLAGS) $^ -o $@ $(LDFLAGS) $(LDLIBS) # ---- 编译:$< 是源文件,$@ 是目标文件;| build 是 order-only 依赖 ---- build/%.o: src/%.cpp | build $(CXX) $(CXXFLAGS) -MMD -MP -c $< -o $@ build: mkdir -p build # ---- 引入自动生成的依赖,文件不存在时不报错 ---- -include $(DEPS) run: $(TARGET) ./$(TARGET) scripts/main.chai clean: rm -rf build $(TARGET) .PHONY: all run clean逻辑说明:?=表示“变量没被外部赋值时才用这个默认值”,这样make CXX=clang++ CXXFLAGS="-std=c++11 -g"就能临时覆盖,不用改文件。+=则是追加,所以外部就算覆盖了CXXFLAGS,头文件路径那一行仍然生效——这是有意设计的,-I属于工程结构的一部分,不该被命令行漏掉。
$(SRCS:src/%.cpp=build/%.o)是替换引用,把src/main.cpp变成build/main.o。这样源文件和中间产物物理隔离,clean只要删build目录,不会误伤源码。想加文件不用改 Makefile,wildcard会自动收。
$^展开成所有依赖(这里是全部.o),$@是目标名,$<是第一个依赖。这三个自动变量用熟了,Makefile 能短一半。
| build是 order-only 依赖,含义是“build 目录必须先存在,但它发生变化时不触发重编”。如果用普通依赖写build/%.o: src/%.cpp build,那么 build 目录里任何一个新文件导致目录 mtime 更新,所有.o都会重编一次,增量编译直接失效。这个坑我踩过,表现是make每次都全量重编,找了两小时才发现是目录依赖写错了。
参数怎么改:换调试版就把-O2换成-g -O0;要开 C++14/17 改-std=;如果目标机器是老内核,-pthread换成-lpthread也行,但推荐前者,它同时管编译和链接两侧的宏定义。
3.3 -MMD -MP 与 -include:让头文件改动也能触发重编
编译命令里的-MMD -MP是这套构建能用的关键。-MMD让编译器顺手生成一个.d文件,内容是一条 Make 规则,列出这个.o依赖了哪些头文件;-MP再补上每个头文件的空规则,避免某个头文件被删掉之后make报“no rule to make target”。
-include $(DEPS)前面的短横线表示“这个文件可能不存在,别报错”。第一次make时build/*.d还没生成,没有短横线会直接中断。第二次开始,make在解析阶段就把这些.d读进来,依赖图里就多了“build/main.o依赖chaiscript/chaiscript.hpp”这类边。
没有这一步会怎样?改了chaiscript.hpp里的某个宏,make认为.cpp没变,直接跳过编译,用旧的.o链接。程序行为跟代码对不上,这种问题最难查,因为源码看着完全正确。这是典型的“构建系统黑匣子”问题,-MMD -MP就是那颗后悔药。
还有一个细节:.d文件跟.o放在一起,都在build下,clean一次就全清了。如果分散在源码目录里,很容易被误提交进版本库。
3.4 增量编译失效的三种触发点
| 改动内容 | 期望行为 | 失效时的典型原因 |
|---|---|---|
src/main.cpp | 重编该.o并重新链接 | 时间戳被工具改乱,或文件在别的机器上生成后拷贝过来 |
third_party/.../chaiscript.hpp | 所有引用它的.o重编 | 缺-MMD -MP,.d没生成或被clean清掉后没重新生成 |
Makefile里的CXXFLAGS | 全部重编 | Make 不会因为变量变化重编,需要make clean或加-B |
第三行值得展开说。改了-O2为-g -O0,make什么都不做,因为它只看文件时间戳,不看命令行。想彻底一点,可以在 Makefile 里存一个版本戳文件,把编译参数哈希进去,参数变了就touch它,逼所有.o失效。工程小的时候没必要,直接make clean && make更省事。
另外make -j并行编译要确保目录创建先于编译。上面用 order-only 依赖保证了这一点,如果没有| build,-j8下多个编译进程同时mkdir或者同时往一个不存在的目录写文件,会随机失败。这种偶发失败最浪费时间,因为重跑一次又好了。
4. 嵌入核心:三段式把脚本引擎装进 main
4.1 创建引擎对象与加载脚本
宿主程序的第一件事是造一个ChaiScript对象。构造过程会完成标准库注册——算术、字符串、容器、print这些基础能力都在里面。之后每调用一次eval或eval_file,脚本就在这个对象持有的全局状态里执行,上一次定义的变量和函数下一次还能看到。
#include <chaiscript/chaiscript.hpp> #include <iostream> #include <string> // 暴露给脚本的业务函数:判定是否超过阈值 bool over_limit(double value, double limit) { return value > limit; } int main(int argc, char** argv) { if (argc < 2) { std::cerr << "usage: chai_demo <script.chai>" << std::endl; return 2; } chaiscript::ChaiScript chai; // 构造即完成标准库注册 // 1) 把 C++ 函数注册进脚本的全局命名空间 chai.add(chaiscript::fun(&over_limit), "over_limit"); // 2) 按引用注入一个变量,脚本里改它会同步回 C++ double limit = 80.0; chai.add(chaiscript::var(limit), "limit"); // 3) 执行脚本文件,出错不炸掉整个进程 try { chai.eval_file(argv[1]); } catch (const chaiscript::exception::eval_error& e) { std::cerr << "script error: " << e.what() << std::endl; return 1; } std::cout << "limit after script = " << limit << std::endl; return 0; }对应的脚本scripts/main.chai:
// 脚本侧:直接调用 C++ 注册进来的函数 if (over_limit(93.5, limit)) { print("over limit, tighten policy"); limit = 95.0; // 写回 C++ 里那个 double }逻辑说明:chai.add(chaiscript::fun(&over_limit), "over_limit")做了两件事——把函数指针包成一个可被脚本调用的对象,同时按参数类型自动推导出double, double -> bool的签名。脚本里传int也能调,ChaiScript 会做数值提升。
chaiscript::var(limit)是按引用注入,脚本里limit = 95.0会真的改掉 C++ 栈上那个变量,所以最后打印出来是 95。注意limit的生命周期必须长于chai,否则脚本一访问就是野指针。在main里因为chai后声明、先析构,顺序是对的;封装成类的时候要留意成员声明顺序。
eval_file的返回值是Boxed_Value,示例里没接。想拿脚本的计算结果,用double r = chai.eval<double>("1.2 * limit;");这种带类型的eval,比手动从Boxed_Value里拆要干净。
4.2 用 chaiscript::fun 暴露更多形态:lambda 与成员函数
函数指针只是最基础的一种。实际项目里更常见的是 lambda——需要捕获一点上下文,又不想专门写个自由函数。
#include <chaiscript/chaiscript.hpp> #include <string> // 用一个有状态的 lambda 包住外部依赖,注意按值捕获 std::string tag = "sensor-A"; chai.add(chaiscript::fun([tag](const std::string& msg) { return "[" + tag + "] " + msg; // 按值捕获,安全 }), "log_with_tag"); // 成员函数绑定:结构体整体暴露给脚本 struct Threshold { double limit = 80.0; bool hit(double v) const { return v > limit; } }; chai.add(chaiscript::user_type<Threshold>(), "Threshold"); chai.add(chaiscript::constructor<Threshold()>(), "Threshold"); chai.add(chaiscript::fun(&Threshold::hit), "hit"); chai.add(chaiscript::fun(&Threshold::limit), "limit"); // 成员变量读写逻辑说明:lambda 这里用[tag]按值捕获。如果写成[&tag],而tag是某个函数里的局部变量,函数返回后脚本再调用这个 lambda,读到的就是已经销毁的内存——这类问题不会在编译期报错,运行时表现是日志里出现乱码或者进程偶发崩溃,属于最难定位的一类。
成员函数绑定这块,user_type注册类型名,constructor注册构造函数,fun(&Threshold::hit)让脚本可以写t.hit(90)。fun(&Threshold::limit)比较特殊,成员变量指针会被处理成可读可写的属性,脚本里t.limit = 75就能改。这套写法比手写一堆 getter/setter 省事,代价是暴露面变大,内部状态从此对脚本完全敞开——设计接口时要意识到这一点。
脚本侧用起来是这样:
var t = Threshold(); t.limit = 75.0; if (t.hit(93.5)) { print("hit"); }4.3 脚本回调 C++:把 std::function 拿回来
真正让“策略可配置”成立的是反过来的方向:脚本定义函数,C++ 拿着这个函数反复调用。这样每次判定走的是脚本里的逻辑,C++ 只负责驱动。
#include <functional> // 从脚本里取出一个函数对象,之后在 C++ 侧像普通 lambda 一样调用 std::function<double(double)> rule = chai.eval<std::function<double(double)>>(R"( fun(raw) { if (raw < 0) { return 0.0; } return raw * 1.2 + 3; } )"); // 主循环里反复用,不再回头进脚本解释器 for (double raw : {10.0, -5.0, 42.5}) { std::cout << raw << " -> " << rule(raw) << std::endl; }逻辑说明:chai.eval<std::function<double(double)>>(...)让脚本的最后一条表达式的值被转成std::function。ChaiScript 支持把脚本函数转成任意匹配签名的std::function,参数和返回值类型要能对上。
这段代码的实际意义在于性能:转换只发生一次,之后rule(raw)就是一次普通的std::function调用,不用每次都进解释器解析字符串。把“解析脚本”和“执行脚本”拆开,是嵌入式脚本能用在热路径上的前提。如果你的规则一秒要跑几万次,这个差别非常明显。
参数说明:std::function的签名必须和脚本函数完全一致,double(double)写成float(double)会在转换时报类型错误。脚本里返回int也会被转成double,但反过来double转int会被拒绝,因为可能丢精度。
4.4 异常与状态隔离:别让一个分号拖垮进程
脚本是外部输入,一定会写错。eval_file遇到语法错误、类型不匹配、调用了不存在的函数,都会抛chaiscript::exception::eval_error。不接住它,异常会一路穿透main,进程直接 abort——一个运维改了半行脚本,整个服务就挂了。
try { chai.eval_file(path); } catch (const chaiscript::exception::eval_error& e) { // 语法/类型错误,带上位置信息便于排查 std::cerr << "eval_error: " << e.what() << std::endl; } catch (const std::exception& e) { // 脚本里调用的 C++ 函数自己抛出来的异常 std::cerr << "std exception from script: " << e.what() << std::endl; }逻辑说明:这里分两层捕获是有原因的。eval_error是引擎自己抛的,信息里带脚本文件名和大概位置;第二层std::exception兜住的是“脚本调了一个 C++ 函数,那个函数内部 throw 了”这种情况,比如参数校验失败。两层都写,日志里才能看出到底是脚本写错还是业务函数报错。
需要注意的是捕获之后的状态:eval_error抛出时,引擎内部的全局变量可能已经被部分修改,脚本执行到一半的状态是脏的。所以生产环境里的正确做法不是“catch 住继续用同一个引擎”,而是“catch 住、丢掉这个引擎、用旧的那份继续服务”。具体做法放在第 6 章的热加载壳里讲,那里会用一个旁路引擎跑通再切换,保证全有或全无。
5. 排查清单:Makefile 与嵌入运行时的五个高频翻车点
5.1 构建期的三个坑
现象一:fatal error: chaiscript/chaiscript.hpp: No such file or directory,但头文件明明在目录里。原因:-I指错了层级。源码写的是#include <chaiscript/chaiscript.hpp>,-I必须指向包含chaiscript这个子目录的父目录。指到.../include/chaiscript会导致预处理器再拼一次chaiscript/,自然找不到。 解决:-Ithird_party/ChaiScript/include,并确认这一层下面确实有个叫chaiscript的目录。实在拿不准,用g++ -E -v -x c++ /dev/null看默认搜索路径,再对照自己的-I。
现象二:改了头文件里的结构体定义,make说没事可做,程序行为还是旧的。原因:没有-MMD -MP,或者.d文件存在但没被-include进去。Make 只知道.o依赖同名.cpp,不知道它还依赖一串头文件。 解决:加上-MMD -MP和-include $(DEPS),然后make clean && make跑一次让.d生成出来。之后随手touch一个头文件再看make -n,能打印出重编命令就说明依赖链生效了。
现象三:编译全过,链接阶段一堆undefined reference to 'dlopen'或pthread_...。原因:LDLIBS没写,或者写在.o前面了。链接器从左往右扫,库在前时符号还没被引用,扫描结束就丢掉了。 解决:$(CXX) $(CXXFLAGS) $^ -o $@ $(LDFLAGS) $(LDLIBS),库一定放在.o之后。缺哪个补哪个,dlopen补-ldl,线程相关补-pthread(推荐,它在编译和链接两侧都生效)。
5.2 运行期的两个坑
现象四:脚本里少写一个分号,整个服务进程消失,日志里只有一行terminate called after throwing。原因:C++ 侧没有try/catch包住eval_file,eval_error一路穿到main外面。 解决:见 4.4 的两层捕获。还有一个容易漏的地方——脚本第二次加载出错时,如果复用了同一个引擎对象,之前已经注册进去的变量和函数会残留,行为变得不可预测。要么每次重建引擎,要么明确接受这种残留并做好清理。
现象五:make run跑得好好的,直接./chai_demo scripts/main.chai就报找不到文件。原因:eval_file的路径是相对当前工作目录解析的。make run在工程根目录执行,直接运行可执行文件时工作目录可能在别处。 解决:路径从argv传进来,别在代码里写死相对路径;或者在 Makefile 里用-DSCRIPT_DIR=\"$(CURDIR)/scripts\"注入一个编译期常量。部署时更稳妥的做法是把脚本目录作为启动参数或者配置项,跟可执行文件放哪儿解耦。
顺带提一个不算坑但容易忽略的点:chaiscript::var(limit)注入的引用,如果limit是某个对象的成员,而那个对象被移动或销毁了,脚本访问就是悬空引用。我一般把注入的变量集中放在宿主类里、声明在引擎成员之前,靠析构顺序兜底。
6. 二次开发:把它改成支持热加载的脚本宿主
demo 跑通之后,下一步基本都会想到热加载——改脚本不重启进程。做法不复杂,关键是“先在旁路引擎里跑通,再切换”,别在原引擎上原地重载。
#include <memory> #include <sys/stat.h> class ScriptHost { public: explicit ScriptHost(std::string path) : path_(std::move(path)) {} // 返回 false 表示脚本有问题,旧引擎继续对外服务 bool reload() { struct stat st {}; if (::stat(path_.c_str(), &st) != 0) return false; if (engine_ && st.st_mtime == mtime_) return true; // 没变,直接返回 auto fresh = std::unique_ptr<chaiscript::ChaiScript>(new chaiscript::ChaiScript()); fresh->add(chaiscript::fun(&over_limit), "over_limit"); try { fresh->eval_file(path_); // 旁路引擎里先跑一遍,语法错就抛 } catch (const std::exception& e) { std::cerr << "reload failed, keep old: " << e.what() << std::endl; return false; // 旧引擎原封不动 } engine_.swap(fresh); // 全有或全无地切换 mtime_ = st.st_mtime; return true; } private: std::string path_; std::unique_ptr<chaiscript::ChaiScript> engine_; time_t mtime_ = 0; };“旁路引擎”是这段代码的核心:新引擎是全新的ChaiScript对象,注册和加载都在它身上完成,成功了才swap进去。失败时旧引擎一次都没被碰过,正在跑的请求不受影响。如果用engine_->eval_file()原地重载,脚本执行到一半失败,引擎内部状态就是半新半旧,后面所有调用都可能是错的。
验证方法我一般走三步:先把脚本改对一个值,看日志里版本号变没变,确认加载链路通;再把脚本故意写错,确认服务不崩、日志里有reload failed、并且旧逻辑仍然生效;最后压一轮,改脚本的同时保持请求不断,看有没有毛刺。第三步最能暴露问题,因为swap和正在执行的std::function之间有竞态,单线程 demo 看不出,上了多线程就得加锁或者用原子指针。
想再往前走一步,可以把std::function的取值也做成懒加载——每次判定前检查一次mtime,变了就换。检查stat的开销远小于一次脚本解析,一秒一次完全够用。要是脚本目录里文件变多,可以监听inotify,但那就得处理事件合并,复杂度上来了。我的经验是轮询足够,简单、可预测、没有平台差异。
从那以后我每次接这类嵌入式脚本的活,都强制先把“脚本出错不能影响主流程”这条验证跑一遍,再谈功能。这条过不了,后面写得再花哨都是负债。希望帮到你。
本文还有配套的精品资源,点击获取