首页
/ lazygit 中的 kardianos/osext:Go 程序如何可靠地定位“当前正在运行的可执行文件”

lazygit 中的 kardianos/osext:Go 程序如何可靠地定位“当前正在运行的可执行文件”

2026-09-06 13:35:15作者:廉皓灿Ida

本文以 lazygit 仓库中 vendored 的依赖 github.com/kardianos/osext(见 vendor/github.com/kardianos/osext/README.md)为主线,讲清一个底层但高频的问题:为什么不能依赖工作目录和 os.Args[0] 来识别正在运行的程序,osext 如何在 Linux、macOS、Windows、Plan 9 与 BSD 上定位当前可执行文件,以及 lazygit 的内置自更新功能如何依赖这一能力完成“原地替换运行中的二进制”。读完后,你可以在自己的 Go 程序中正确获取可执行文件路径,并理解跨平台实现背后的系统机制。

为什么工作目录和 os.Args[0] 都不可靠

README 给出的核心论点是:有时你需要知道“当前正在运行的可执行文件”位于何处,典型场景是升级当前可执行文件、或查找相对于可执行文件位置的资源文件。而它明确指出:

Both working directory and the os.Args[0] value are arbitrary and cannot be relied on; os.Args[0] can be "faked".

也就是说,工作目录完全取决于用户从哪里启动了程序,而 os.Args[0] 是调用方传给进程的字符串——它可能是相对路径、被包装器伪造,甚至只是一个任意名字。两者都不能作为“程序本体在哪”的可靠依据。这正是 osext 这类库存在的意义:向操作系统内核“问”出真实的可执行文件路径,而不是听信 argv。

osext 对外暴露的两个 API

库的公共接口非常小,全部定义在 osext.go 中:

// Executable returns an absolute path that can be used to
// re-invoke the current program.
// It may not be valid after the current program exits.
func Executable() (string, error) {
    return cx, ce
}

// Returns same path as Executable, returns just the folder
// path. Excludes the executable name and any trailing slash.
func ExecutableFolder() (string, error) {
    p, err := Executable()
    if err != nil {
        return "", err
    }
    return filepath.Dir(p), nil
}

两个实现细节值得注意(osext.go#L10-L33):

  1. 包级缓存var cx, ce = executableClean() 在包初始化时只调用一次底层的 executable(),之后 Executable() 永远返回同一份结果。这在语义上是安全的(一个进程的可执行文件不会变),也避免了重复系统调用;
  2. filepath.Clean 归一化:缓存前会先对结果做一次 filepath.Clean,消除 ///. 之类的冗余分量,得到规范化的绝对路径。

Executable() 的注释也设定了使用边界:返回的是“可用于重新调用当前程序的绝对路径”,但它在当前程序退出之后可能失效(例如文件已被删除或替换),这一点在使用它做资源定位时要心中有数。

go1.8 之后的降级策略:直接复用标准库

README 中明确写道:

As of go1.8 the Executable function may be found in os. The Executable function in the std lib os package is used if available.

这在源码中体现为按 Go 版本划分的构建标签。osext_go18.go 的头部是 //+build go1.8,!openbsd,实现只有一行:

func executable() (string, error) {
    return os.Executable()
}

也就是说,用 go1.8 及以后的工具链构建时,osext 只是一层薄封装,把调用转交给标准库的 os.Executable(),避免维护两套逻辑。标签里特意排除了 openbsd——因为从 osext_sysctl.go 的构建约束 // +build !go1.8,darwin !go1.8,freebsd openbsd 可以看出,OpenBSD 的实现不受 Go 版本限制,始终走 sysctl 路径(OpenBSD 没有提供 os.Executable() 所依赖的直接内核接口,下文会解释)。

平台级实现:procfs、sysctl、Win32 与 Plan 9

在 go1.8 之前(或对 OpenBSD 而言),executable() 由各平台文件分别实现。README 列出的支持范围是:Linux、OS X、Windows、Plan 9 和 BSDs。逐一看这些实现:

Linux/NetBSD/DragonFly/Solaris:读 /proc 符号链接

osext_procfs.go(构建约束 !go1.8,...)按 runtime.GOOS 分支:

  • Linux/Android:os.Readlink("/proc/self/exe")
  • NetBSD:/proc/curproc/exe
  • DragonFly:/proc/curproc/file
  • Solaris:/proc/<pid>/path/a.out

其中 Linux 分支有一段很有意思的防御代码:

const deletedTag = " (deleted)"
execpath, err := os.Readlink("/proc/self/exe")
...
execpath = strings.TrimSuffix(execpath, deletedTag)
execpath = strings.TrimPrefix(execpath, deletedTag)

这对应一个真实的系统行为:如果正在运行的二进制文件在运行期间被删除或替换,/proc/self/exe 指向的 inode 仍然存在,但内核会在链接目标后附加 (deleted) 后缀。这段清理逻辑保证即使“文件已不在原位置”,也能得到一个干净的原始路径——这对自更新场景尤其关键。

macOS/FreeBSD/OpenBSD:sysctl 查询进程参数

osext_sysctl.go(构建约束 !go1.8,darwin !go1.8,freebsd openbsd)通过 SYS___SYSCTL 系统调用获取信息,各平台的 MIB 不同:

  • FreeBSD:{CTL_KERN, KERN_PROC, KERN_PROC_PATHNAME, -1},直接拿到可执行文件路径;
  • macOS(Darwin):{CTL_KERN, KERN_PROCARGS, pid, -1},拿到的是 argv 缓冲区,取其中的第一个元素;
  • OpenBSD:{CTL_KERN, KERN_PROC_ARGS, pid, KERN_PROC_ARGV},缓冲区内容是 **argv 指针数组,代码用一段手动遍历把 C 风格字符串逐个取出,args[0] 即为可执行文件名。

之后还有三个规范化步骤:

  1. 若结果不是绝对路径(不以 /. 开头),则用进程启动时的工作目录 filepath.Join 成绝对路径(getAbs);
  2. OpenBSD 下 args[0] 可能只是裸名字,代码会尝试 exec.LookPath$PATH 中解析出实际位置;
  3. 对 Darwin 额外做 filepath.EvalSymlinks,因为 KERN_PROCARGS 可能返回的是符号链接而非真实二进制路径——对自更新这种“要替换文件本体”的操作,这一步直接决定了替换的是哪个文件。

Windows:Win32 API

osext_windows.go!go1.8)通过 syscall.MustLoadDLL("kernel32.dll") 加载 GetModuleFileNameW,以 hModule = NULL 调用获取当前进程主模块的文件名,再把 UTF-16 结果解码为 Go 字符串。这正是“问内核模块表,而不是信 argv”的 Windows 版本。

Plan 9

osext_plan9.go 打开 /proc/<pid>/text 后用 syscall.Fd2path 反查文件描述符对应的路径,属于 Plan 9 特有的 proc 文件系统玩法。

这些分支实现共同说明了一个事实:POSIX 并没有统一的标准接口可以拿到“本进程可执行文件的绝对路径”,每个操作系统都要各走各的系统调用或伪文件系统——这也是为什么 lazygit 选择 vendor 一个专门处理这些差异的库,而不是自己写平台判断。

lazygit 的实际用途:自更新时定位待替换的二进制

在 lazygit 中,osext 的依赖版本被固定在 go.mod#L25

github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0

它的核心使用点位于内置更新器 pkg/updates/updates.go。整个自更新流程是:

  1. checkForNewUpdate() 请求仓库的 /releases/latest,比较主次版本号(majorVersionDiffers 会拦截跨主版本更新),并用 HEAD 请求验证对应平台的 release 二进制 URL 存在(verifyResourceFound);
  2. downloadAndInstall 把 release 包下载到用户配置目录(configDir 下的 temp_lazygit.tar.gz/.zip),然后在工作目录执行 tar -zxf 解包,得到一个新的 lazygit 二进制;
  3. 关键一步就是调用 osext
// get the path of the current binary
binaryPath, err := osext.Executable()
if err != nil {
    return err
}
...
// swap out the old binary for the new one
err = os.Rename(tempLazygitFilePath, binaryPath)

updates.go#L298-L314。这里恰好是 README 所述“upgrading the current executable”场景的落地:必须知道正在运行的那个二进制的绝对路径,才能用 os.Rename 把新解出来的文件原位覆盖上去。如果误用 os.Args[0](用户可能用相对路径启动)或相对工作目录的路径,替换就会落到错误的文件上。

同时,更新器也有明确的适用前提(skipUpdateCheckupdates.go#L153-L187):Windows 上因权限问题暂时跳过自动更新;版本号为 unversioned(非官方 release 构建)或构建时未带 buildBinary 标志时不检查;用户可配置 update.method: never 关闭检查;并受 update.days 控制检查频率(上次检查时间记录在 app state 的 LastUpdateCheck 字段中)。osext.Executable() 只在这条路径真正走到“替换”环节时才被调用,平时启动 lazygit 并不会触发它。

在自己的 Go 程序中使用

如果你也需要在程序里定位自身或相对自身查找资源,直接使用标准库即可(go1.8+ 等价于 osext 的新版实现):

func main() {
    exe, err := os.Executable()
    if err != nil {
        log.Fatal(err)
    }
    // 可执行文件所在目录,用于查找随程序分发的资源
    dir := filepath.Dir(exe)
    _ = dir
}

如果项目需要兼容很老的 Go 版本,或要覆盖 README 所列的 BSD/Plan 9 等平台边角情况,则引入 github.com/kardianos/osext 并使用 osext.Executable() / osext.ExecutableFolder(),其接口语义与标准库一致(返回可复用路径、可能随进程退出失效、结果经过 filepath.Clean 归一化)。

小结

kardianos/osext 的 README 很短,但它回答了一个跨平台的硬问题:如何不依赖不可信的 os.Args[0] 和工作目录,向操作系统内核确认“我到底是从哪个文件运行起来的”。从 lazygit 仓库中 vendored 的实现可以看到完整答案:go1.8+ 直接委托 os.Executable();老版本则分别在 Linux 读 /proc/self/exe(并剥离 (deleted) 标记)、在 macOS/FreeBSD/OpenBSD 走 sysctl、在 Windows 调 GetModuleFileNameW、在 Plan 9 用 Fd2path。而 lazygit 的 pkg/updates/updates.go 正是这一能力的实际受益者——下载新 release、解包、用 os.Rename 原位替换当前二进制,整个自更新链条的安全性建立在“拿到的是正在运行的可执行文件的真实绝对路径”之上。

登录后查看全文
热门项目推荐
相关项目推荐