资讯详情

资讯详情

C++11嵌入ChaiScript脚本引擎:Makefile构建与热加载实践

简介这份 ChaiScript 集成示例面向希望在 C11 可执行文件中嵌入脚本能力的开发者重点解决动态配置、插件扩展与逻辑热更新等场景下的集成问题适合具备一定 C 基础、想了解脚本绑定的中级读者。压缩包共 18 个文件约 18KB包含 make、makefile 与 vcxproj/sln 等构建配置cpp/h 源码演示对象注册与脚本调用md、yml 与 lua 文件辅助说明工程结构、持续集成与构建流程两份 cpp 与一份 h 头文件可对照阅读整体体量轻量、结构清晰。目前已有 239 人学习下载。读者可从中获得跨 Linux、Windows、macOS 的构建入口参考 Makefile 与 Visual Studio 工程配置理解如何把 C 类、函数暴露给 ChaiScript并通过示例代码掌握表达式求值、类对象交互及脚本函数调用的基本写法也可将其作为插件系统、测试脚本或游戏逻辑脚本化的最小原型便于按目录拆分后快速改造到自己的项目。1. 改一行阈值就要重编译这个示例解决的就是这类麻烦一个用 C11 写的采集服务判定逻辑写死在 .cpp 里每改一个数字就要走一遍编译、打包、发版。这类需求最省事的解法是把那几十行策略搬到脚本里主程序只保留一个 ChaiScript 引擎。chaiscript_example 做的就是这件事的最小样例一个 header-only 的 ChaiScript 引擎用 C11 嵌入可执行文件配一份手写 Makefilemake一下就能得到一个能跑脚本的宿主程序。它适合两类人想把业务规则从 C 里拆出去的后端开发和想找一个嵌入式脚本引擎入门样本、又不想被 CMake 和第三方包管理器绕晕的人。麻雀虽小eval_file、fun注册、异常隔离这三件事一样没少。2. 选型与目录header-only 引擎 Makefile 的最小组合2.1 ChaiScript 与 C11 的契合点选 ChaiScript 而不是 Lua、Python最直接的理由是它 header-only。整个引擎就是一堆.hpp把include目录往-I里一丢就能编不需要预编译.so、不需要find_package、也不需要在目标机器上装运行时。对于“给一个已有 C11 工程加个脚本层”这种需求这点省下来的工程量比想象中大——不用改构建系统不用管动态库搜索路径交付物仍然是一个单文件可执行程序。第二个理由是类型系统贴得近。ChaiScript 的绑定是编译期模板推导chaiscript::fun(some_func)直接吃函数指针、lambda、成员函数指针参数类型和返回值类型会自动注册进脚本的全局命名空间。写脚本的人不需要理解“栈式绑定”那套东西直接over_limit(93.5, 80)就行语法接近 JavaScript 或者简化版 C学习成本基本只有半天。代价也要说清楚不然容易翻车。ChaiScript 的模板展开很重一个chaiscript::ChaiScript实例化下来编译时间肉眼可见地涨头文件解析能吃掉好几秒。常见做法是把引擎的创建和注册收敛到一到两个.cpp里其他地方只传接口别让每个编译单元都#include chaiscript/chaiscript.hpp。另外 C11 的 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 为什么这个示例坚持用 MakefileCMake 当然是更主流的选法但对这个示例来说它带来的东西全是负收益。整个工程就一个.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 ? -stdc11 -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/%.cppbuild/%.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 CXXclang CXXFLAGS-stdc11 -g就能临时覆盖不用改文件。则是追加所以外部就算覆盖了CXXFLAGS头文件路径那一行仍然生效——这是有意设计的-I属于工程结构的一部分不该被命令行漏掉。$(SRCS:src/%.cppbuild/%.o)是替换引用把src/main.cpp变成build/main.o。这样源文件和中间产物物理隔离clean只要删build目录不会误伤源码。想加文件不用改 Makefilewildcard会自动收。$^展开成所有依赖这里是全部.o$是目标名$是第一个依赖。这三个自动变量用熟了Makefile 能短一半。| build是 order-only 依赖含义是“build 目录必须先存在但它发生变化时不触发重编”。如果用普通依赖写build/%.o: src/%.cpp build那么 build 目录里任何一个新文件导致目录 mtime 更新所有.o都会重编一次增量编译直接失效。这个坑我踩过表现是make每次都全量重编找了两小时才发现是目录依赖写错了。参数怎么改换调试版就把-O2换成-g -O0要开 C14/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 -O0make什么都不做因为它只看文件时间戳不看命令行。想彻底一点可以在 Makefile 里存一个版本戳文件把编译参数哈希进去参数变了就touch它逼所有.o失效。工程小的时候没必要直接make clean make更省事。另外make -j并行编译要确保目录创建先于编译。上面用 order-only 依赖保证了这一点如果没有| build-j8下多个编译进程同时mkdir或者同时往一个不存在的目录写文件会随机失败。这种偶发失败最浪费时间因为重跑一次又好了。4. 嵌入核心三段式把脚本引擎装进 main4.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.evaldouble(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_typeThreshold(), Threshold); chai.add(chaiscript::constructorThreshold()(), 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::functiondouble(double) rule chai.evalstd::functiondouble(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.evalstd::functiondouble(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_fileeval_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_ptrchaiscript::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_ptrchaiscript::ChaiScript engine_; time_t mtime_ 0; };“旁路引擎”是这段代码的核心新引擎是全新的ChaiScript对象注册和加载都在它身上完成成功了才swap进去。失败时旧引擎一次都没被碰过正在跑的请求不受影响。如果用engine_-eval_file()原地重载脚本执行到一半失败引擎内部状态就是半新半旧后面所有调用都可能是错的。验证方法我一般走三步先把脚本改对一个值看日志里版本号变没变确认加载链路通再把脚本故意写错确认服务不崩、日志里有reload failed、并且旧逻辑仍然生效最后压一轮改脚本的同时保持请求不断看有没有毛刺。第三步最能暴露问题因为swap和正在执行的std::function之间有竞态单线程 demo 看不出上了多线程就得加锁或者用原子指针。想再往前走一步可以把std::function的取值也做成懒加载——每次判定前检查一次mtime变了就换。检查stat的开销远小于一次脚本解析一秒一次完全够用。要是脚本目录里文件变多可以监听inotify但那就得处理事件合并复杂度上来了。我的经验是轮询足够简单、可预测、没有平台差异。从那以后我每次接这类嵌入式脚本的活都强制先把“脚本出错不能影响主流程”这条验证跑一遍再谈功能。这条过不了后面写得再花哨都是负债。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →