Linux: 进程标准输入、输出的建立过程简析

1. 前言

限于作者能力水平,本文可能存在谬误,因此而给读者带来的损失,作者不做任何承诺。

2. 背景

本文基于 util-linux-2.32-rc1 代码分析,测试环境为 Ubuntu 16.04.4 LTS + QEMU + ARM vexpress-a9rootfs 基于 ubuntu-base-16.04-core-armhf.tar.gz 制作。

3. 进程标准输入、输出的建立过程

我们通常会听到这种说法,新进程会默认打开 标准输入(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 的操作。为了解开这个谜团,我们需要从系统的启动过程开始分析。

3.1 系统的启动登录过程

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); 启动登录程序 loginlogin 程序获取用户输入的用户名和密码,如果用户名和密码都正确,login 程序从 /etc/passwd 文件中对应登录用户名的数据项,提取用户设定的 shell (如 /bin/bash ),然后 fork 一个子进程,在子进程中启动 shell 程序 (如 /bin/bash ),这期间,login 程序也会如同 getty 一样,用 TTY 设备建立 标准输入(STDIN_FILENO)、标准输出(STDOUT_FILENO)、标准错误输出(STDERR_FILENO)bash 程序启动后,我们就可以在 bash 进程下进行输入输出操作了。
上面概述了系统的启动登录过程,接下来我们对这些细节进行展开。

3.1.1 getty 启动过程

/* 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);
	...
}

3.1.2 login 登录过程

/* 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 的存在。

3.2 在 shell 里启动程序的标准输入、输出的建立

此时,我们已经位于 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 的父进程为 bashbash 的父进程为 /bin/login/bin/login 的父进程为 /sbin/init ,正与前面在 章节 3 中分析的系统启动过程一致。

4. 结语

看似简单的背后,实际隐藏了复杂的细节;而正是这些看似不起眼的细节,往往成为阻碍我们解决问题、了解真相的障碍。这正应了古先贤的那句话:纸上得来终觉浅,绝知此事要躬行。

5. 参考资料

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

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值