argv[0] 背后的 Multicall Binary 实现

posts/multicall-binary-argv0

背景

朋友在更新 Bun 的时候出现了问题把 bun 给更新没了,由于网络的问题不能手动下载二进制包。我帮他下载后得知 bunxbun 版本不一致。后来发现实际上 bunbunx 是同一个二进制文件,所以来水一篇博客来介绍一下 Multicall Binary 的原理。以利用到这一点实现一些意想不到的功能,例如 Multicall Binary 的版本强一致性。只要升级一个 binary,所有 invocation 自动同步,不会出现 tool 是 v1.0、tool-x 还是 v0.9 的尴尬——这也正是 Bun / Bunx 案例的根本动机。

argv[0] 是什么?

你可以理解为 argv[0] 是在一个程序被调用时传入的包含文件地址的第一个参数。

以下面的 C 语言程序举例:

#include <stdio.h>

int main(int argc, char **argv) {
    printf("argv[0] = %s\n", argv[0]);
    return 0;
}
$ gcc -o greet greet.c
$ ln -s greet hello
$ ln -s greet world
$ ./hello
argv[0] = ./hello
$ ./world
argv[0] = ./world

虽然两个程序加载的是同一个二进制程序(ELF),但 argv[0] 各自不同,所以程序能够输出不同的结果。

于是利用这个特性我们可以来实现 Multicall Binary。

Multicall Binary

Multicall Binary 是包含多个命令并根据调用时 argv[0] 的不同来进行派发到对应的命令实现的二进制文件。

                 greet
                   │ argv[0]
              ┌───────────┐
              │ dispatcher│
              └─────┬─────┘
          ┌─────────┼─────────┐
          ▼         ▼         ▼
       hello()   world()   server()

表面上用户看到的是三个独立命令,但底层可能是同一个二进制文件。

部署方式

Multicall Binary 有三种常见的部署方式:

  • 符号链接:ln -s greet hello
  • 硬链接:ln greet hello,三个文件名对应同一 inode
  • 直接复制:cp greet hello,三个二进制文件完全独立,但 argv[0] 仍然能让程序分辨

自己实现一个 Multicall Binary

下面来用 Go 写一个最小版本的 Multicall Binary。

package main

import (
    "fmt"
    "os"
    "path/filepath"
)

func main() {
    name := filepath.Base(os.Args[0])

    switch name {
    case "hello":
        fmt.Println("Hello from hello")

    case "world":
        fmt.Println("World from world")

    default:
        fmt.Printf("Unknown command: %s\n", name)
        os.Exit(1)
    }
}
$ go build -o multicall multicall.go
$ ln -s multicall hello
$ ln -s multicall world
$ ./hello
Hello from hello
$ ./world
World from world

这已经是一个最小可用的 Multicall Binary 了。但在实际项目里不会一直写 switch:

commands := map[string]func([]string) int{
    "hello":  hello,
    "world":  world,
    "server": server,
}

name := filepath.Base(os.Args[0])
command, ok := commands[name]
if !ok {
    fmt.Fprintf(os.Stderr, "unknown command: %s\n", name)
    os.Exit(1)
}
os.Exit(command(os.Args[1:]))

再补上 help、version、logging、configuration、signal handling、tracing、error handling 等共用逻辑,就形成了一个 CLI framework。

Busybox

BusyBox 是 Multicall Binary 最典型的案例。它把大量 Unix 工具塞进一个 executable:

busybox ls
busybox cat
busybox cp
busybox mount
busybox sh

典型安装方式下以 symlink 暴露:

/bin/ls    -> /bin/busybox
/bin/cat   -> /bin/busybox
/bin/cp    -> /bin/busybox
/bin/mount -> /bin/busybox
/bin/sh    -> /bin/busybox

用户看到的是 ls / cat / cp / mount / sh,底层全部来自 busybox。BusyBox 内部把这些功能称为 applets。

嵌入式 Linux 偏爱这种设计,因为它减少重复代码和动态链接和运行时开销。对于只有几十 MB 的 root filesystem 来说,能够节省大量的存储空间。

argv[0] 伪造

argv[0] 是调用者传进来的字符串,不一定是真实的 executable identity,例如调用者可通过以下方式进行伪装调用:

char *args[] = { "totally-not-greet", NULL };
execve("./greet", args, envp);

实际调用的 ./greet,通过获取 argv[0] 发现参数是 totally-not-greet

Multicall Binary 本身就是利用这个特性提供不同入口,所以对 multicall 场景不可信不是问题。但用 argv[0] 做权限判断就不行,所以说 argv[0] 判断不了我到底是谁。

想要知道自己是谁?这里使用 /proc/self/exe 就行了。

进程可以获取 argv[0],也可以通过 /proc/self/exe 观察当前 executable。但两者表达的是不同概念:

  • argv[0]:我以什么名字被调用
  • /proc/self/exe:当前进程对应的 executable
  • execve() 的 path:要加载哪个 executable
  • $PATH:shell 如何找到 executable
  • symlink:文件系统层面的路径映射
argv[0]          → invocation identity
/proc/self/exe   → executable identity

分开理解这两个,Unix 很多行为就直观了。

Multicall Binary 和 Subcommand

现代 CLI 多用 subcommand,例如:

git clone
git push
git pull

docker run
docker build
docker ps

其结构是 git clone。而 Multicall Binary 更接近 git-clone / git-push / git-pull 这类独立命令名,最终指向同一个 executable,按 argv[0] 派发到 clone_main() 等。

实际上两种模式完全可以组合:

greet server start
greet-server

例如一组高度相关的 CLI(tool / tool-admin / tool-debug / tool-server),大家共用一份 runtime、logging、auth 这些基础库就比较适合做 Multicall Binary。

但几个命令之间生命周期完全独立、发布周期不同、依赖完全不同、需要独立升级或强隔离、单 command 体积很大,硬塞到一个 binary 里反而增加复杂度。