简介:LuaJIT 2.1.0 v2.1.ROLLING 的 2023 年移动端编译成品与配套源码,面向 Android arm64 与 iOS 平台开发者,适用于游戏逻辑、动态脚本、热更新等需要高性能脚本处理的移动应用场景。资源共 234 个文件,压缩包约 1.6MB,文件类型以 C/Objective-C 头文件(81 个 h)、C 源文件(77 个 c)、Lua 脚本(29 个 lua)为核心,辅以构建脚本(bat/makefile)、说明文档(html/readme)和预编译静态库(a),既能从源码自行编译,也可直接使用 libluajit.a 快速集成。该版本基于 2.1.0 滚动更新,包含若干错误修复与兼容性增强,特别优化了 arm64 指令集;压缩包内还提供 msvcbuild、nxbuild、ps4build 等多平台工程脚本,便于研究跨平台构建流程。目前已有 592 人学习使用。对于希望提升移动端脚本执行效率、或深入理解 LuaJIT JIT 编译原理的开发者,这份紧凑的源码包具有直接参考和复用价值。 聊 LuaJIT 之前先说说我为什么折腾它。做移动端性能敏感模块的时候,脚本方案看着多——Lua、JS 引擎、Python——但真正能嵌入应用、体积可控、性能拉满的其实没几个。LuaJIT 2.1 是我这些年用下来最顺手的一个:JIT 编译加 FFI,让它在计算密集场景爆发力极强,标准的 Lua 5.1 语法加一点扩展,团队上手成本也低。这篇文章就围绕一份 LuaJIT 2.1.0(v2.1.ROLLING)的 Android arm64 / iOS 编译产物和源码展开,讲讲我为什么选这个版本、怎么编译、踩了哪些坑,以及这些东西拿到手之后怎么装进你的工程。
1. 为什么是 LuaJIT 2.1.0 ROLLING:版本选型与适用场景
1.1 和标准 Lua 对比,LuaJIT 究竟强在哪
很多人第一次接触 LuaJIT,会觉得它只是“快一点的 Lua”。实际上差别远不止“快一点”这么简单。LuaJIT 的 JIT 编译器会在运行时分析热点代码,把频繁执行的那段字节码直接编译成机器码执行,复杂循环和数值运算的提速非常可观,特别是在粒子系统、物理模拟、数值算法这类场景里,几十倍不是夸张说法。
更关键的是 FFI(Foreign Function Interface)。标准 Lua 想调用 C 函数,得用 C API 写一堆胶水代码,再编译进宿主程序。LuaJIT 的 FFI 允许你在 Lua 代码里直接声明 C 函数和结构体,然后直接调用,省掉了中间层的 C 代码。这对手游热更、工具链脚本、嵌入式配置解析来说,省下的工作量是巨大的。移动端集成的时候,用 FFI 调系统 API 或自家 C++ 引擎的方法,体验非常顺。
它还覆盖了常用架构,x86、x64、ARM32、ARM64、MIPS、PPC 都有支持,这和很多脚本引擎“只能在特定平台跑得欢”完全不同。
1.2 ROLLING 分支、2.0.x、beta3 之间怎么选
LuaJIT 官方仓库有两个主分支:v2.0 和 v2.1。2.0 系列是稳定维护分支,但功能冻结,很多新架构的优化和 bug 修复都不会合入。2.1 则是活跃开发分支,长期处于 rolling 状态,没有正式打 tag,但社区里大量项目都在用。
网上不少人一搜 LuaJIT 就是 2.1.0-beta3,那是 2017 年的老快照,和现在移动端新系统、新工具链的兼容性已经跟不上了。我用 v2.1.ROLLING 的原因很简单:持续修复、对 Android/iOS 的 clang 工具链适配更好,并且有 FFI 和 JIT 层面的性能优化。风险也清楚——rolling 意味着可能引入新的问题。所以我一向的习惯是:每次拉代码记录 commit hash,锁定一个当时验证过的版本再往外发,而不是每次无脑拉最新。
选这个版本的另一层考虑是交付便利。把预编译好的 arm64 静态库、动态库和对应源码一起发出去,接入方不用管交叉编译环境,拿到即可集成。这也是标题里“编译成品及源码”的用意。
2. 编译环境准备:工具链与关键参数
2.1 源码获取与版本固定
从 GitHub 拉 v2.1 分支:
git clone https://github.com/LuaJIT/LuaJIT.git cd LuaJIT git checkout v2.1 git log --oneline -1看一眼 commit hash,记录到这个文件里,之后每次发版都对得上。我踩过坑:有人用 rolling 最新代码编译出了库,过两个月重新编一次,行为变了,排查到最后才发现是源码版本推进了。固定版本听起来是小事,实际维护的时候能省好几个晚上的排查时间。
LuaJIT 的构建系统是 Makefile,不是 CMake,所以官方文档和大量网上教程都以 make 为主。编译的核心在于区分两个编译器:HOST_CC 是跑在开发机上、用来生成构建工具的,比如 buildvm 负责生成字节码和机器码相关的分发表;TARGET_CC 才是真正交叉编译到目标平台的编译器。两者分清楚,后面所有问题都好解。
2.2 Android NDK 交叉编译基础
Android 底层是 Linux 内核,所以 LuaJIT 编译时 TARGET_SYS 填 Linux。交叉编译器用 NDK 自带的 clang。
这里提醒一下:老教程里常见的 standalone toolchain 方式(用 make-standalone-toolchain.sh 生成独立 gcc 工具链)在新版 NDK 里已经废了,NDK r18 之后移除了 GCC,r21 之后官方直接不推荐 standalone toolchain。别再去折腾老路子了,直接用 NDK 的 llvm 工具链。
NDK 路径按需调整,开发环境一般是 macOS 或 Linux,Windows 上我试过也差不多,只是 prebuilt 目录会变成 windows-x86_64。
2.3 iOS 工具链与 JIT 限制
iOS 编译走 Xcode 自带的 clang。工具链好解决,真正的坑在 JIT 限制。iOS 系统出于安全策略,不允许程序在运行时分配可执行内存(也就是 W^X 保护),加上代码签名校验,LuaJIT 的 JIT 编译器在 iOS 真机上没法正常工作。强行开启 JIT,运行到热点代码时大概率直接崩溃,报错常见的是 EXC_BAD_ACCESS 或者 SIGILL。
所以 iOS 版本编译时我会加上-DLUAJIT_DISABLE_JIT,让 LuaJIT 退化为纯解释器执行。别觉得这样性能就没了,LuaJIT 的解释器本身就比标准 Lua 解释器快不少,大部分脚本场景完全够用。同时 FFI 不受影响,还能继续用,这是 iOS 上仍然坚持用 LuaJIT 的核心原因之一。
3. Android arm64 编译实操
3.1 命令行编译完整流程
我用 NDK r25c 做示例,源码放在~/LuaJIT,NDK 放在~/Library/Android/sdk/ndk/25.2.9519653。
export NDK=~/Library/Android/sdk/ndk/25.2.9519653 export TOOLCHAIN=$NDK/toolchains/llvm/prebuilt/darwin-x86_64 export API=26 cd ~/LuaJIT/src make clean make HOST_CC="gcc -m64" \ TARGET_CC="$TOOLCHAIN/bin/aarch64-linux-android$API-clang" \ TARGET_SYS=Linux \ TARGET_FLAGS="--sysroot=$TOOLCHAIN/sysroot -fPIC"这里有几个参数必须说清楚。
HOST_CC="gcc -m64"是在本机跑构建工具的编译器,macOS 上 gcc 实际是 clang 的别名,问题不大。关键是-m64,如果你的开发机是 Apple Silicon,理论上不用显式写,但如果 HOST 构建工具和 TARGET 架构混了,buildvm 会报 exec format error 之类的问题,很费时间。所以我习惯显式指定。
TARGET_CC直接指向 NDK 的 aarch64 clang。注意,我没有用 Makefile 里默认的 CROSS 变量,因为老版本教程里CROSS=aarch64-linux-android-这种方式,Makefile 会自动拼出aarch64-linux-android-gcc,而新版 NDK 里压根没有 gcc,所以老老实实用 TARGET_CC 指定 clang 完整路径,干净利落。
TARGET_SYS=Linux是 Android 必须的。TARGET_FLAGS里的--sysroot指定系统头文件和库的根目录,NDK 的 clang 虽然有时候能自动找 sysroot,但编译 LuaJIT 这种老构建系统,显式指定最稳。-fPIC是为了生成位置无关代码,否则后面打动态库会报 relocation 错误。
如果想让 LuaJIT 运行时打开 JIT,就这么编。Android 没有 iOS 那道 W^X 限制,arm64 处理器的 JIT 是完整可用的。
3.2 产物核对与交付清单
编译完成后,在src/目录下会看到libluajit.a,紧接着生成动态库:
make -C src install PREFIX=$PWD/install或者手动复制也行。交付一份完整档案,一般包括这些:
| 文件 | 说明 |
|---|---|
| libluajit.a | 静态库,Android arm64 |
| libluajit.so | 动态库,Android arm64 |
| lua.h / lualib.h / lauxlib.h / lua.hpp | Lua 核心头文件 |
| luaconf.h / luajit.h | LuaJIT 配置与扩展头文件 |
| lj_arch.h | 架构检测头文件,某些 SDK 集成时会用到 |
| README | 编译命令、commit hash、API level 说明 |
检查静态库架构,用 file 命令确认是 ARM aarch64:
file libluajit.a # 输出应包含 arm64 / aarch64这是一个很容易被忽略的验证步骤。两个同事都在编同一个库,一个人用的是 x86 工具链,编出来的库拷到 arm64 设备上,链接不报错,一运行就崩,这类问题排查成本非常高。所以收到任何编译产物,第一件事就是 file 看架构。
4. iOS 编译实操
4.1 arm64 真机静态库编译
iOS 编译不需要指定 sysroot 到具体的某个 SDK 路径,直接用 xcrun 动态获取:
export SDK=$(xcrun --sdk iphoneos --show-sdk-path) cd ~/LuaJIT/src make clean make HOST_CC="clang" \ TARGET_CC="$(xcrun --sdk iphoneos --find clang) -arch arm64" \ TARGET_SYS=iOS \ TARGET_FLAGS="-isysroot $SDK -DLUAJIT_DISABLE_JIT"关键点:
TARGET_SYS=iOS会让 LuaJIT 的运行时初始化逻辑按 iOS 的方式处理,包括内存管理和异常处理路径。
-arch arm64指定架构。iOS 15 之后基本只考虑 arm64 真机,armv7 的老设备没必要再管。
-DLUAJIT_DISABLE_JIT是 iOS 真机存活的前提。前面说过,iOS 禁止运行时生成可执行代码。如果漏了这个宏,LuaJIT 在纯解释模式下运行没问题,可一旦触发 JIT 编译,就会在写入可执行内存时崩溃,而且崩溃栈往往看不出和 LuaJIT 的关系,问题定位很痛苦。
有一个细节值得注意:加了LUAJIT_DISABLE_JIT之后,LuaJIT 的 luajit -v 打印的版本号后面会带一个-J后缀标记,这是正常的。
4.2 iOS 模拟器支持与 XCFramework 打包
模拟器的架构通常需要 x86_64,Apple Silicon 上还有 arm64 模拟器。模拟器没有真机那么严格的 W^X 限制,理论上可以开 JIT,但为了真机和模拟器行为一致,我建议模拟器版本也统一加LUAJIT_DISABLE_JIT,否则真机解释、模拟器 JIT,同一段脚本跑出来的行为有细微差异,排查起来很烦。
编译模拟器版本,只需要把 TARGET_CC 换成 iphonesimulator SDK:
export SDKX=$(xcrun --sdk iphonesimulator --show-sdk-path) make clean make HOST_CC="clang" \ TARGET_CC="$(xcrun --sdk iphonesimulator --find clang) -arch x86_64" \ TARGET_SYS=iOS \ TARGET_FLAGS="-isysroot $SDKX -DLUAJIT_DISABLE_JIT"拿到 arm64 真机和 x86_64 模拟器两个静态库之后,可以用 Xcode 自带的工具合成 XCFramework,省去接入方自己合并库的麻烦:
xcodebuild -create-xcframework \ -library libluajit-ios-arm64.a -headers ../src \ -library libluajit-ios-x86_64.a -headers ../src \ -output LuaJIT.xcframework我的实际经验是:直接用静态库 + 头文件目录交付,对很多团队来说就够了。XCFramework 更适合做闭源 SDK 分发或内部组件化,如果你只是自己项目里用,直接把 .a 拖进工程更省事。
5. 工程集成与高频问题排查
5.1 Android 集成注意点
拿到 arm64 的 libluajit.a 或 libluajit.so 之后,集成方式分两种。
动态库最简单的做法是放到app/src/main/jniLibs/arm64-v8a/,Android Gradle 构建时会自动打进 APK。静态库则适合通过 CMake 引入:
add_library(luajit STATIC IMPORTED) set_target_properties(luajit PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/arm64-v8a/libluajit.a INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_SOURCE_DIR}/include/luajit )真正容易出问题的在链接这一步。Android 的 FFI 依赖动态链接器,直接用静态库链接时经常会遇到 undefined reference todlopen,dlsym这类符号。解决方式很直接,在 CMake 里补上 libdl:
target_link_libraries(your_target luajit dl)另一个坑是头文件的版本冲突。LuaJIT 的lua.h和标准 Lua 的lua.h是不兼容的,两个头文件混用,轻则编译报错,重则链接通过但运行时行为诡异。集成时把头文件目录隔离干净,或者给 LuaJIT 头文件单独建子目录,别直接丢进全局 include。
还有一个值得留意的点:如果你的宿主程序是一个大型 C++ 工程,LuaJIT 静态库在编译时要保持和宿主一致的 C++ 异常处理选项。某些 NDK 版本下,如果宿主开启了-fexceptions而 LuaJIT 没有,会出现难以解释的 crash,表现为abort信号而不是具体的 Lua 报错。
5.2 iOS 集成注意点
iOS 集成相对简单,把libluajit.a拖进 Xcode 工程,Build Settings 里搜 Library Search Paths,加上 .a 所在目录;Header Search Paths 加上头文件目录。
两个容易踩的点:
一是 Target Membership 别勾错。把 .a 文件拖进工程时,Xcode 有时候会提示添加到哪个 target,勾错了会出现链接期符号找不到。这个错误很隐蔽,编译不报,链接才报。
二是现在 Xcode 默认不启用 Bitcode,很多老教程都在说关闭 Bitcode,实测新版 Xcode 已经没有 ENABLE_BITCODE 选项了,不用管。真正的坑是Other Linker Flags,如果宿主工程里也用了其他 Lua 实现,这里要小心符号冲突。LuaJIT 导出的符号和标准 Lua 高度重合,两个库不能共存于同一进程。
iOS 的 FFI 使用上,注意不要尝试在 Lua 层直接声明并调用 iOS 私有 API,这属于禁令红线,审核被拒是小事,上架后出问题就是大事。用 FFI 调系统公开 API 和自家 C++ 代码,稳定性没问题。
5.3 编译与运行时报错速查表
把我在几个项目里反复遇到的高频问题整理成表格,按场景排查:
| 现象 | 原因 | 解决方法 |
|---|---|---|
| buildvm 执行报 exec format error | HOST_CC 架构和开发机不匹配 | HOST_CC 显式加-m64或用本机编译器 |
| fatal error: 'stdint.h' file not found | sysroot 未指定或 NDK 路径错 | TARGET_FLAGS 加--sysroot=$TOOLCHAIN/sysroot |
| 链接时 undefined reference to 'dlopen' / 'dlsym' | Android 静态库未链接 libdl | target_link_libraries 加 dl |
| iOS 真机运行崩溃 SIGILL / EXC_BAD_ACCESS | 未加 LUAJIT_DISABLE_JIT | 编译参数加-DLUAJIT_DISABLE_JIT |
| file 显示库架构不对 | 工具链选成了 x86 | 确认 TARGET_CC 指向 aarch64 前缀的 clang |
| 运行时 Lua 版本报错不识别字节码 | 头文件/库版本不一致 | 统一头文件和 .a 的编译出处,固定 commit hash |
| libluajit.so 放入 jniLibs 后运行找不到库 | 主 app 未安装到 arm64 设备或库名冲突 | 检查 APK 内 lib 目录,确认是 arm64-v8a |
5.4 动态库与静态库选择的一点经验
最后聊一个不大不小但经常被问到的点:Android 端到底用 .so 还是 .a。
我的看法是:如果 LuaJIT 只是你主进程内部的一个模块,没有多个进程需要共享同一份 Lua 状态,优先用静态库。静态库链接后符号直接进主 so,启动速度快,也没有额外加载顺序问题。如果你的架构是多个独立模块各自持有一份 LuaJIT,比如主程序一个、插件一个,那就必须用动态库,避免每份插件都静态塞一份 LuaJIT,包体积直接爆炸。
另一个经验是,编译动态库时不要漏掉-fPIC,LuaJIT 的 Makefile 默认对某些平台可能没加全,导致链接 so 时报relocation R_AARCH64_ADR_PREL_PG_HI21 cannot be used against symbol。这个问题我 2023 年编译的时候遇到过一次,当时百思不得其解,最后就是 TARGET_FLAGS 里补一个-fPIC解决。
6. 版本管理与后续扩展
这一版编译成品和源码,我的建议是尽量固定一个 commit hash 对外发布,README 里写清三条信息:LuaJIT 源码版本(git hash)、NDK 版本、Xcode 版本。这三个信息缺一个,后面复现都会走弯路。
如果你后续要在自己项目里继续扩展,有几个方向可以深入。一个是给 LuaJIT 打补丁,把自研 C 模块的注册表提前静态链接进去,省去运行时 require 查找。另一个是把它封装成自己的脚本层,在 LuaJIT 之上做热更框架和沙箱隔离,这个思路在游戏和工具类 App 里都非常常见。
我在实际项目里还有一个体会:LuaJIT 这类底层库,不要频繁升级。很多问题不是 LuaJIT 本身的 bug,而是宿主工程升级工具链后暴露出来的兼容性问题。除非有明确的安全公告或性能需求,否则一个经过验证的编译版本,尽量用久一点。
用 LuaJIT 折腾移动端脚本这件事,做一次之后你就会发现,真正花时间的不是编译,而是编译完之后如何让团队其他人不踩你踩过的坑。把上面这些参数、宏、链接选项整理成文档,比单纯丢一个 .a 文件有用得多。
本文还有配套的精品资源,点击获取