Ray-D-Song's Blog

为脚本语言创建可执行文件的方法

2025-4-16 7min

PythonJavaScript等脚本语言,都需要一个运行时(runtime)来执行。

python foo.py # 运行 Python 脚本
node foo.js # 运行 JavaScript 脚本

所以脚本语言编写的程序基本都是源码分发,比如 Node.js 的 npx,但这要求用户的电脑上也有对应的运行环境。
又因为语言的API会发生改变,所以有时候需要安装多个版本。
比如工具A依赖Python 3.10,工具B依赖Python 3.11,这时候就需要安装两个版本的Python,这时又要引入一个管理多个版本的工具,比如 pyenv。

更好的方法是直接把脚本打包成可执行文件,这样用户就不需要安装运行时了。

思路

目前已经有很多打包工具,比如 JavaScript 的 Denopkg 和 Python 的 PyInstaller
其思路基本都是:

  1. 提取一个干净的语言运行时,去掉不必要的功能以减少体积
  2. 将源码压缩,并嵌入到运行时的可执行文件中,并添加一个魔数,用于识别源码是否存在
  3. 修改运行时的执行逻辑,在启动时先通过魔数检查源码是否存在,如果存在则解压并执行,否则进入 REPL 或者直接报错

为 LLRT 添加构建可执行文件的功能

LLRT 是一个轻量级的 JavaScript 运行时,提供了不少 Node.js 的 API 的同时,体积只有 8MB 左右,相比 Node.js 的 100MB 小了很多,非常适合打包轻量级服务和CLI工具。
为了验证上面的思路,我为 LLRT 添加了构建可执行文件的功能。

lexe 是一个基于 LLRT 的打包工具,支持将 Node.js 项目打包成可执行文件

下面是具体的实现过程:

提取干净的语言运行时

对于 LLRT,这部分可以省略,因为由JavaScript实现的标准库代码量并不多。

将源码压缩,并嵌入到运行时的可执行文件中

这里我借助了 Deno 团队编写的 libsui 来实现。
libsui 是一个基于 Rust 语言的数据嵌入工具,支持将任意数据嵌入到可执行文件中,并在运行时提取出来。
对于不同的系统,libsui 会执行不同的操作:

if platform == &Platform::WindowsX64 {
    PortableExecutable::from(&llrt_binary)?
        .write_resource(SECTION_NAME, compiled.clone())?
        .build(&mut output)?;
    output.write_all(MAGIC_NUMBER.as_bytes())?;
} else if platform == &Platform::LinuxX64 || platform == &Platform::LinuxArm64 {
    Elf::new(&llrt_binary)
        .append(SECTION_NAME, &compiled, &mut output)?;
} else if platform == &Platform::DarwinX64 || platform == &Platform::DarwinArm64 {
    Macho::from(llrt_binary)?
        .write_section(SECTION_NAME, compiled.clone())?
        .build_and_sign(&mut output)?;
    output.write_all(MAGIC_NUMBER.as_bytes())?;
}

修改运行时的执行逻辑

在上一步中,对于Windows和macOS系统,我们在可执行文件的最后追加了一个魔数,用于识别源码是否存在。
对于Linux系统,libsui 已经添加了一个魔数,如果再添加一个,会导致 libsui 无法识别,所以直接使用 libsui 的魔数。

首先编写一个函数,用于检查魔数是否存在:

pub fn has_magic_number(platform: &Platform) -> std::io::Result<bool> {
    let path = env::current_exe()?;
    let mut file = File::open(path)?;

    let file_size = file.metadata()?.len() as usize;
    if file_size < 16 {  // 如果文件太小则可以认为没有源码
        return Ok(false);
    }

    // 根据不同的平台,使用不同的方法检查魔数
    match platform {
        Platform::LinuxX64 | Platform::LinuxArm64 => {
            // 对于 Linux 平台,libsui 在文件末尾添加了 16 字节的数据:魔数(4 字节)+ 哈希(4 字节)+ 大小(8 字节)
            const TRAILER_LEN: i64 = 16;
            file.seek(SeekFrom::End(-TRAILER_LEN))?;
            let mut buf = [0; 4]; // 只读取魔数部分
            file.read_exact(&mut buf)?;
            
            // 检查魔数(小端序)
            let magic = u32::from_le_bytes(buf);
            Ok(magic == LIBSUI_MAGIC_NUMBER)
        },
        _ => {
            // 其他平台使用字符串魔数检测方法
            let search_area_size = 1024.min(file_size);
            let search_start = file_size.saturating_sub(search_area_size);

            file.seek(SeekFrom::Start(search_start as u64))?;

            let mut buffer = vec![0; search_area_size];
            file.read_exact(&mut buffer)?;

            // 搜索字符串魔数
            // 从后往前搜索,因为魔数在文件末尾
            for i in (0..search_area_size - MAGIC_NUMBER.len() + 1).rev() {
                if &buffer[i..i + MAGIC_NUMBER.len()] == MAGIC_NUMBER.as_bytes() {
                    return Ok(true);
                }
            }
            Ok(false)
        }
    }
}

值得注意的一点是,采用从后向前搜索,而不是对整个文件进行匹配,这样可以降低误匹配的概率并提高效率。

提取源码并执行

如果魔数存在,则提取源码并执行。

if has_magic_number {
    // 提取源码
    let code_binary = extract_code_binary();
    if let Some(code_binary) = code_binary {
        // 执行源码
        vm.run_with(|ctx| {
            let module = llrt_core::modules::require::loader::CustomLoader::load_bytecode_module(ctx.clone(), &code_binary)?;
            module.eval()?;
            Ok(())
        }).await;
    }
} 

这里执行源码的方式是调用虚拟机实例,不同运行时暴露的 API 不同。
比如 Node.js 的虚拟机实例暴露的 API 是 vm.run_with,而 Python 的虚拟机实例暴露的 API 是 vm.run

结语

通过上面的步骤,我们成功为 LLRT 添加了构建可执行文件的功能。
剩下的工作就是添加 CLI 选项再编写一个 JS 包装器并发布。

因为 LLRT 的代码是基于 Rust 编写的,所以可以蹭 Deno 生态的工具,如果你希望为其他语言编写可执行文件,可以参考这个思路的同时,用别的工具替换 libsui。
或者你也可以参考 pyinstaller 的思路,在运行时将源码先解压到临时目录,然后执行。

参考