July 25, 2022

在Parent Shell中执行内置命令的方法

编写了一个二进制程序,想要替用户自动切换当前 Shell-Session 的工作路径,在程序内执行 `chdir` 系统调用却发现毫无作用。为什么没有产生作用?那应该怎么做才能产生作用?本文就此记录下解决这个问题的经历。

背景

最近在为 大仓项目(Monorepo) 制作一个脚手架,其中构思了一个自动替用户切换工作路径的工具(代码是通过模板初始化的,在结构上基本一致,但是代码文件较深,想要在terminal去和文件交互时,只能使用 cd 命令,费时费力),因此我期望一个小工具,能比较方便的帮我跳转到目标路径。预期的使用效果如下:

这个命令执行后,你可以从当前路径(任意路径)跳转到目标路径,那我就不用记我应该先跳转到根目录再前往目标目录了,少敲击很多次 tab。

PS: 后续计划给这个命令扩展一下历史记录,可以通过筛选匹配历史快速补全命令,提高效率。

chdir没作用?

这个小工具是通过Go来编写的,我从文档中看到 os.Chdir 这个调用, 因此用它来试试:

// Chdir changes the current working directory to the named directory. 
// If there is an error, it will be of type *PathError.
func Chdir(dir string) error

结果我发现没有效果,通过 os.Getwd() 却能看到当前路径已经被改变了。这个切换的需求还没解决,我却产生了几个新的疑问:

  • cd 命令怎么实现的?
  • chdir 系统调用的使用和原理?

通过执行如下的命令,会发现 cd 命令并不是通过一个独立的可执行文件来实现的,而是内置在 bash 等 shell 程序中,其次通过查阅资料可以发现 大部分Linux发行版都部分符合 POSIX 标准。如果再检索一下shell程序的原理,我们会得出如下的结论:

$ which cd
cd: shell built-in command

shell 程序在执行内建命令时,是通过调用内部的函数的执行。而非内建的命令(如 git / gcc / gdb)都是通过 fork 一个进程来执行;cd 命令是通过 chdir [IEEE Std 1003.1-1988] 系统调用实现的;chdir 在 <uinstd.h> 头文件中定义;

说人话:在另一个进程里执行 chdir 系统调用,只会影响当前进程而不是影响其他的进程(包括父进程)。

POSIX: is a family of standards specified by the IEEE Computer Society for maintaining compatibility between operating systems.POSIX defines both the system- and user-level application programming interfaces (API), along with command line shells and utility interfaces, for software compatibility (portability) with variants of Unix and other operating systems.

解决方案

那么走编程语言提供的系统调用这条路已经不行了,那么应该咋办呢?在stackoverflow 上搜索良久,收集到如下的方法:

  1. gdb attach 到进程上,再执行 chdir 系统调用
  2. 通过 bash 脚本执行 source 命令
  3. 执行系统调用 ioctl 和 terminal-session 的 tty 文件交互
  4. 通过 alias 来实现: pcd=cd $(program command))

其中各种方法尝试的过程就略去不说,最终采用了第三种非常好用,下面提供一个小 demo 以供使用:

#include <unistd.h>
#include <sys/ioctl.h>
#include <stdio.h>
#include <fcntl.h>

int injectCmd(int fd, char* cmd) {
    int i = 0;
    while (cmd[i] != '\0') {
        int ret = ioctl(fd, TIOSTI, &cmd[i++])
        if (ret != 0) return ret;
    }

    return 0;
}

/* getty help get terminal session tty filename, you can open the fie
 * and get the file descriptor to call injectCmd.
 */
char* getty() {
    const int STDOUT = 1;
    return ttyname(STDOUT);
}

void main() {
    char * ttyfile = getty(); 
    int fd = open(ttyfile, "w");
    if (fd == -1) {
        perror("open ttyfile failed");
        return -1;
    } 

    injectCmd(fd, "echo 'hello world'");
}

在 go 里可以直接使用 syscall 包或者 cgo 来执行,原理是是一致的,不过多赘述。

参考文献