限于作者能力水平,本文可能存在谬误,因此而给读者带来的损失,作者不做任何承诺。
本文基于 util-linux-2.32-rc1
代码分析,测试环境为 Ubuntu 16.04.4 LTS + QEMU + ARM vexpress-a9
,rootfs
基于 ubuntu-base-16.04-core-armhf.tar.gz
制作。
我们通常会听到这种说法,新进程会默认打开 标准输入(STDIN_FILENO)、标准输出(STDOUT_FILENO)、标准错误输出(STDERR_FILENO)
,譬如我们写了一个如下代码的程序:
int main(void)
{
while (1) asm("nop");
return 0;
}
我们将上面的代码编译成名为 test
的程序,然后在 QEMU
模拟的 ARM vexpress-a9
板型下运行,接着查看程序打开的文件句柄信息:
# ./test &
[1] 1010
# ls -l /proc/1010/fd
total 0
lrwx------ 1 root root 64 Mar 22 07:26 0 -> /dev/ttyAMA0
lrwx------ 1 root root 64 Mar 22 07:26 1 -> /dev/ttyAMA0
lrwx------ 1 root root 64 Mar 22 07:26 2 -> /dev/ttyAMA0
我们把这一切当做理所当然的,但是为什么?我们的程序根本没有打开过 ttyAMA0
,是谁做了这些工作?我们知道,在 main()
之前,编译器为我们插入了程序的启动代码,但是你查看 glibc
的代码,根本找不到打开 ttyAMA0
的操作。为了解开这个谜团,我们需要从系统的启动过程开始分析。
Linux 内核在加载 rootfs
时,找到其中的 init
程序,然后加载运行 init
程序。init
会 fork 一个子进程,然后在 fork 的子进程中运行 exec*("/bin/getty", ...)
加载 getty
程序;getty
会打开 TTY 设备(如 /dev/ttyAMA0
) ,用它建立 {STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO}
,之后运行 execv("/bin/login", login_argv);
启动登录程序 login
;login
程序获取用户输入的用户名和密码,如果用户名和密码都正确,login
程序从 /etc/passwd
文件中对应登录用户名的数据项,提取用户设定的 shell (如 /bin/bash
),然后 fork 一个子进程,在子进程中启动 shell 程序 (如 /bin/bash
),这期间,login 程序也会如同 getty 一样,用 TTY 设备建立 标准输入(STDIN_FILENO)、标准输出(STDOUT_FILENO)、标准错误输出(STDERR_FILENO)
。bash
程序启动后,我们就可以在 bash
进程下进行输入输出操作了。
上面概述了系统的启动登录过程,接下来我们对这些细节进行展开。
/* util-linux-2.32-rc1/term-utils/agetty.c */
int main(int argc, char **argv)
{
struct options options = {
.flags = F_ISSUE, /* show /etc/issue (SYSV_STYLE) */
.login = _PATH_LOGIN, /* default login program (/bin/login) */
.tty = "tty1" /* default tty line */
};
...
/* Open the tty as standard { input, output, error }. */
/* 打开 标准输入、标准输出、标准错误输出 */
open_tty(options.tty, &termios, &options);
...
/* Let the login program take care of password validation. */
/* 启动登录程序 /bin/login */
execv(options.login, login_argv);
...
}
/* Set up tty as stdin, stdout & stderr. */
static void open_tty(char *tty, struct termios *tp, struct options *op)
{
...
/* Set up new standard input, unless we are given an already opened port. */
if (strcmp(tty, "-") != 0) {
...
len = snprintf(buf, sizeof(buf), "/dev/%s", tty); /* 如 /dev/ttyAMA0 */
...
/* Open the tty as standard input. */
if ((fd = open(buf, O_RDWR|O_NOCTTY|O_NONBLOCK, 0)) < 0)
log_err(_("/dev/%s: cannot open as standard input: %m"), tty);
...
if (!isatty(fd)) /* 打开的必须是 TTY 设备 */
log_err(_("/dev/%s: not a tty"), tty);
close(STDIN_FILENO);
if (op->flags & F_HANGUP) {
...
} else
close(fd);
...
if (open(buf, O_RDWR|O_NOCTTY|O_NONBLOCK, 0) != 0) /* STDOUT_FILENO */
log_err(_("/dev/%s: cannot open as standard input: %m"), tty);
...
} else {
...
}
...
/* Get rid of the present outputs. */
if (!closed) {
close(STDOUT_FILENO);
close(STDERR_FILENO);
errno = 0;
}
/* set up stdout and stderr */
if (dup(STDIN_FILENO) != 1/*STDOUT_FILENO*/ || dup(STDIN_FILENO) != 2/*STDERR_FILENO*/)
log_err(_("%s: dup problem: %m"), tty);
...
}
/* util-linux-2.32-rc1/login-utils/login.c */
int main(int argc, char **argv)
{
struct passwd *pwd;
...
for (cnt = get_fd_tabsize() - 1; cnt > 2; cnt--)
close(cnt);
...
cxt.pwd = xgetpwnam(cxt.username, &cxt.pwdbuf);
...
pwd = cxt.pwd;
cxt.username = pwd->pw_name;
...
if (pwd->pw_shell == NULL || *pwd->pw_shell == '\0')
pwd->pw_shell = _PATH_BSHELL; /* 默认登录到 bash */
init_environ(&cxt); /* init $HOME, $TERM ... */
...
/*
* Detach the controlling terminal, fork, and create a new session
* and reinitialize syslog stuff.
*/
/* 创建子进程:用来启动 shell 程序(如 bash) */
fork_session(&cxt);
...
/* 在新创建的子进程中,启动 shell 程序 */
execvp(childArgv[0], childArgv + 1);
...
}
static void fork_session(struct login_context *cxt)
{
...
/*
* Detach the controlling tty.
* We don't need the tty in a parent who only waits for a child.
* The child calls setsid() that detaches from the tty as well.
*/
ioctl(0, TIOCNOTTY, NULL);
...
child_pid = fork(); /* 创建子进程 */
...
if (child_pid) { /* 父进程:login 进程 */
/*
* parent - wait for child to finish, then clean up session
*/
close(0);
close(1);
close(2);
...
/* wait as long as any child is there */
/* 等待 shell 程序退出 */
while (wait(NULL) == -1 && errno == EINTR) ;
...
/* 退出 login 程序 */
pam_setcred(cxt->pamh, PAM_DELETE_CRED);
pam_end(cxt->pamh, pam_close_session(cxt->pamh, 0));
exit(EXIT_SUCCESS);
}
/* 子进程上下文 */
...
/* start new session */
setsid();
/* make sure we have a controlling tty */
open_tty(cxt->tty_path);
...
/*
* TIOCSCTTY: steal tty from other process group.
*/
if (ioctl(0, TIOCSCTTY, 1))
syslog(LOG_ERR, _("TIOCSCTTY failed: %m"));
...
}
static void open_tty(const char *tty)
{
/* 为 shell 准备 标准输入、输出的 TTY 设备 */
fd = open(tty, O_RDWR | O_NONBLOCK);
...
for (i = 0; i < fd; i++)
close(i);
/* 打开 STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO */
for (i = 0; i < 3; i++)
if (fd != i)
dup2(fd, i);
if (fd >= 3)
close(fd);
}
我们看到,login
程序创建了一个子进程,接着在子进程中,打开了 标准输入(STDIN_FILENO)、标准输出(STDOUT_FILENO)、标准错误输出(STDERR_FILENO)
,最后在子进程中启动了 shell
程序。
总结一下系统启动到 login
的流程,如下:
fork() + exec("gettty") exec("login") fork() + exec("bash")
init ------------------------> getty --------------> login ------------------------> bash
注意到, getty
在完成其使命后,将淹没在历史长河中(因为它没有调用 fork()
,而是直接调用 exec*()
),所以本文章节 3.2
的最后 ps
命令输出中,我们看不到 getty
的存在。
此时,我们已经位于 shell
程序的上下文,然后在其中启动了测试程序 test
,然后 test
程序就神奇般的拥有了自己的 标准输入(STDIN_FILENO)、标准输出(STDOUT_FILENO)、标准错误输出(STDERR_FILENO)
,到底是什么原因?到目前为止,好像还是没说明白。确实,因为问题的答案还欠缺了最后一块拼图,我们来补齐它。
shell
程序通过 fork() + exec*()
调用序列,来启动 test
程序。在 fork()
过程中,作为 shell
程序子进程的 test
程序,将继承 shell
程序打开的文件描述符表,这意味在 3.1.2
章节分析中,shell
打开的 标准输入(STDIN_FILENO)、标准输出(STDOUT_FILENO)、标准错误输出(STDERR_FILENO)
文件描述符,将会被 test
程序继承。我们来看一下内核的实现细节:
sys_fork()
do_fork()
_do_fork()
copy_process()
copy_files()
static int copy_files(unsigned long clone_flags, struct task_struct *tsk)
{
struct files_struct *oldf, *newf;
int error = 0;
oldf = current->files;
...
/* 复制父进程 shell 的 打开的文件描述符表,到 子进程 test */
newf = dup_fd(oldf, &error);
...
tsk->files = newf;
error = 0;
out:
return error;
}
这就是从 shell
启动的程序(如 test
)的 标准输入(STDIN_FILENO)、标准输出(STDOUT_FILENO)、标准错误输出(STDERR_FILENO)
建立的秘密。
在最后,我们看一下 test
在系统进程树中的位置:
# ps -efH
root 1 0 13 07:23 ? 00:00:19 /sbin/init
...
root 910 1 2 07:24 ttyAMA0 00:00:02 /bin/login --
root 1004 910 2 07:25 ttyAMA0 00:00:00 -bash
root 1010 1004 99 07:26 ttyAMA0 00:00:08 ./test
输出信息中,第2列为进程的 PID
,第3列为进程的 PPID
(父进程 PID)。看到了吗?test
的父进程为 bash
;bash
的父进程为 /bin/login
;/bin/login
的父进程为 /sbin/init
,正与前面在 章节 3
中分析的系统启动过程一致。
看似简单的背后,实际隐藏了复杂的细节;而正是这些看似不起眼的细节,往往成为阻碍我们解决问题、了解真相的障碍。这正应了古先贤的那句话:纸上得来终觉浅,绝知此事要躬行。
https://blog.csdn.net/d_leo/article/details/73073876
https://www.man7.org/linux/man-pages/man2/fork.2.html
https://www.man7.org/linux/man-pages/man8/agetty.8.html
https://www.man7.org/linux/man-pages/man3/getpwnam.3.html