从printXX看tty设备(4)伪终端

一、伪终端的意义

在计算机中,有很多的虚拟技术,使用纯软件的技术来模拟一个硬件设备。例如,使用一个qemu来模拟一个计算机系统、使用tun来模拟一个网卡。归根到底,这些虚拟的原因在于兼容,兼容就是后来的实现要以不修改已有实现为前提。就像intel的指令集和windows的API一样,这里的内容就只能增加不能减少。因为减少之后就以为着之前发布的一个可执行文件或者代码在新的平台上无法使用。反过来说,一个优秀的软件框架,从设计之初就应该考虑到虚拟化的实现,例如Linux内核中的VFS系统,它就是要求你只要提供某些接口,在上层就可以把这个结构当做一个文件系统来操作。所以内核中所有的socket管理可以放在一个独立的文件系统中,伪终端的从设备也可以作为一个pts文件系统,而所有的block设备则可以通过一个bdev文件系统来表示和管理。

对于伪终端来说。我们可以认为它基本是需要一个交互的操作界面,而这个操作的基础上有一个重要的概念就是termios结构,这个接口控制了终端设备输入对于接受者的特殊意义。在计算机的早期,终端就是通过串口线或者电话线连接主机和终端,此时的串口通讯还是比较直观的。但是之后出现了计算机网络,为了利用计算机的强大网络能力,摆脱串口线或者电话线的距离限制,自然而然的希望通过网络来远程操作计算机(WIndows下VNC和远程桌面也是网络远程控制,但是是基于GUI形式),这就是telnetd和ssh之类工具的由来。但是telnetd它们本身并不进行命令行的解释,把shell的功能直接和网络协议耦合明显不是一个好的主意。解决的思路就是:shell是需要一个tty设备,而且是需要把这个设备作为自己的标准输入,但是shell本身不具有网络处理能力,那么这个网络处理和tty的创建就可以由telnetd来代劳。这样,telnetd的功能就是使用socket来适配一个tty设备给shell,从而架起一座通讯的桥梁。在本机系统中,我们通过GUI界面启动的shell,它的输出已经不能直接写入显存,因为整个显存的内容已经由窗口系统管理。虽然bash以为自己独占一个终端,但是事实上它只是众多窗口(相对emacs、vi、KDE对话框中的一个)。这就相当于《霍顿与无名氏》中的市长,沉醉在自己的世界之中,事实上自己只是这个世界普通的一个花粉。

二、telnetd的busybox实现
可以看到一个busybox-1.14.2\networking\telnetd.c中对于一个telnet回话的表示结构为/* Structure that describes a session */
struct tsession {
 struct tsession *next;
 pid_t shell_pid;派生的shell进程的pid
 int sockfd_read, sockfd_write, ptyfd;socket套接口以及伪终端对应的文件描述符

……
};
前面说过,telnetd就是通过 套接口+伪终端 来实现一个远程控制,所以其中的socket和伪终端的创建就是必不可少的基础了

创建套接口文件,这里的套接口侦听端口号就是大名鼎鼎的23号端口,这里将会创建一个侦听套接口,用来连接客户端发起的telnet连接。
int FAST_FUNC create_and_bind_stream_or_die(const char *bindaddr, int port)
{
 return create_and_bind_or_die(bindaddr, port, SOCK_STREAM);
}

伪终端的创建是通过make_new_session---->>>xgetpty--->>>>p = open("/dev/ptmx", O_RDWR);创建。之后telnetd就非常贤惠的将两个东西粘合在一起,自己负责在两个功能之间进行通讯的处理。可以认为是中美邦交正常化之前印度在之间起得调和、通话作用。这也就是“策略”工具存在的意义。设备创建之后,还需要让他们同步起来,这个同步就是通过select系统调用来实现的,在telnetd中,其实现为
 count = select(maxfd + 1, &rdfdset, &wrfdset, NULL, NULL);

也就是telnetd负责串口和伪终端之间的迎来送往工作,因为设备必将只是设备,它没有策略,特别是两种不同的设备,它们之间的同步更需要有人来帮助。

通过查看文件系统可以知道这个ptmx对应的设备为

[tsecer@Harry signal]$ ls  /dev/ptmx -l
crw-rw-rw-. 1 root tty 5, 2 2011-11-20 20:53 /dev/ptmx

也就是主设备号为5,此设备号为2的一个设备

三、内核中ptmx的实现

#define TTYAUX_MAJOR  5

linux-2.6.21\drivers\char\tty_io.c:tty_init

#ifdef CONFIG_UNIX98_PTYS
 cdev_init(&ptmx_cdev, &ptmx_fops);
 if (cdev_add(&ptmx_cdev, MKDEV(TTYAUX_MAJOR, 2), 1) ||
     register_chrdev_region(MKDEV(TTYAUX_MAJOR, 2), 1, "/dev/ptmx") < 0)

  panic("Couldn't register /dev/ptmx driver\n");
 device_create(tty_class, NULL, MKDEV(TTYAUX_MAJOR, 2), "ptmx");
#endif

注意,register_chrdev_region(MKDEV(TTYAUX_MAJOR, 2), 1, "/dev/ptmx") 中的/dev/ptmx和用户态的文件路径没有任何必然关系,事实上,你可以在这里注册为/root/ptmx都可以,这个只是为了注记及内核维护。事实上它只显示在了/proc/devices文件中

[tsecer@Harry ~]$ cat /proc/devices 
Character devices:
  1 mem
  4 /dev/vc/0
  4 tty
  4 ttyS
  5 /dev/tty
  5 /dev/console
  5 /dev/ptmx
  7 vcs
当用户态的telnetd打开ptmx的时候,也就是通过VFS之后执行了ptmx_fops--->>>ptmx_open,我为什么要在这里说经过虚拟文件系统到达这里呢?因为这个ptmx是一个比较特殊的打开,它经过的是文件系统的open系统调用,从而它会向用户返回一个文件描述符id。但是我们知道,伪终端总是成对出现的,你这里只返回了一个fd,另一个fd怎么办,代表设备另一端的描述符怎么得到呢?为什么需要两个文件描述符呢?因为telnetd需要一个,用来和shell通讯,而shell一侧同样需要一个,套接口还需要两个呢!这里和管道实现做一个对比,管道也是需要返回两个文件描述符,但是管道没有通过普通的文件系统实现,而是有一个奢侈的方法来实现,那就是它自己使用了一个专门的API,pipe系统调用,从而可以一次返回两个文件描述符。

这一点是通过万能的ioctl来实现的,虽然底层看起来有些猥琐,但是也算是剑走偏锋。具体实现为

static int pty_unix98_ioctl(struct tty_struct *tty, struct file *file,
       unsigned int cmd, unsigned long arg)
{
 switch (cmd) {
 case TIOCSPTLCK: /* Set PT Lock (disallow slave open) */
  return pty_set_lock(tty, (int __user *)arg);
 case TIOCGPTN: /* Get PT Number */
  return put_user(tty->index, (unsigned int __user *)arg);
 }

 return -ENOIOCTLCMD;
}
话不多少,收回话题继续说打开的艰辛历程。没有什么比调用连更能展现调用层次关系了,所以这里依然先放一下ptmx_open的调用连

(gdb) bt
#0  pty_open (tty=0xc09ede80, filp=0xc09ede80) at drivers/char/pty.c:198
#1  0xc03d244b in ptmx_open (inode=0xcf9d41ec, filp=0xcff44ea0) at drivers/char/tty_io.c:2677
#2  0xc01c3bce in chrdev_open (inode=0xcf9d41ec, filp=0xcff44ea0) at fs/char_dev.c:399
#3  0xc01bdc46 in __dentry_open (dentry=0xcf9e7744, mnt=0xc126c7a0, flags=32768, f=0xcff44ea0, 
    open=0xc01c3967 <chrdev_open>) at fs/open.c:700
#4  0xc01bdf99 in nameidata_to_filp (nd=0xcf92df04, flags=32768) at fs/open.c:826
#5  0xc01bde0a in do_filp_open (dfd=-100, filename=0xcf9d0000 "/dev/ptmx", flags=32768, mode=0)
    at fs/open.c:761
#6  0xc01be324 in do_sys_open (dfd=-100, filename=0xbfd77f96 "/dev/ptmx", flags=32768, mode=0)
    at fs/open.c:962
#7  0xc01be41f in sys_open (filename=0xbfd77f96 "/dev/ptmx", flags=32768, mode=0)
    at fs/open.c:983

在ptmx_open函数中


 mutex_lock(&tty_mutex);
 retval = init_dev(ptm_driver, index, &tty);
 mutex_unlock(&tty_mutex);
 
 if (retval)
  goto out;

 set_bit(TTY_PTY_LOCK, &tty->flags); /* LOCK THE SLAVE */
 filp->private_data = tty;
 file_move(filp, &tty->tty_files);

 retval = -ENOMEM;
 if (devpts_pty_new(tty->link)) 

  goto out1;

 check_tty_count(tty, "tty_open");
 retval = ptm_driver->open(tty, filp);

其中的(devpts_pty_new(tty->link))从pts文件系统(伪终端文件系统)中分配了一个新的inode(因此也就有了对应的一个pts设备)。关于pts文件系统。

[tsecer@Harry ~]$ mount
/dev/mapper/vg_harry-lv_root on / type ext4 (rw)
proc on /proc type proc (rw)
sysfs on /sys type sysfs (rw)
devpts on /dev/pts type devpts (rw,gid=5,mode=620)

devpts_pty_new

{

……

 dev_t device = MKDEV(driver->major, driver->minor_start+number);
……

 init_special_inode(inode, S_IFCHR|config.mode, device);

这个deriver->major的初始化ptmx_open--->>>init_dev

 if (driver->type == TTY_DRIVER_TYPE_PTY) {
  o_tty = alloc_tty_struct();
  if (!o_tty)
   goto free_mem_out;
  initialize_tty_struct(o_tty);
  o_tty->driver = driver->other;

而ptmx则在unix98_pty_init中初始化,

 ptm_driver->other = pts_driver;

四、ptmx打开之后

在打开之后,可以通过C库封装的ptsname_r来得到open创建的孪生tty的另一个,C中对该函数的实现为glibc-2.7\sysdeps\unix\sysv\linux\ptsname.c

int
__ptsname_r (int fd, char *buf, size_t buflen)
{
…………
  if (__ioctl (fd, TIOCGPTN, &ptyno) == 0)

之后从tty的文件操作通过unix98_pty_init中注册的

 ptm_driver->other = pts_driver;
 tty_set_operations(ptm_driver, &pty_ops);

……

 pts_driver->other = ptm_driver;
 tty_set_operations(pts_driver, &pty_ops);

两者在这里实现了最终的交汇,也就是相当于伪终端设备使用的最底层的驱动实现。主设备的驱动注册位于ptmx_open-->>init_dev(ptm_driver, index, &tty).更为详细的分析就不再继续了,因为其中繁琐而无趣。

最后需要说明的一点是,虽然它们是主从tty,但是它底层的实现是和管道不同的,因为管道的两端是共享同一个环形缓冲区,而tty的两端是各自都有自己的缓冲区,从而可以完成完全的双工操作。因为在两者相同的写操作中,其中的实现为

static int pty_write(struct tty_struct * tty, const unsigned char *buf, int count)
{
 struct tty_struct *to = tty->link;
 int c;

 if (!to || tty->stopped)
  return 0;

 c = to->receive_room;
 if (c > count)
  c = count;
 to->ldisc.receive_buf(to, buf, NULL, c);
 
 return c;
}

也就是均是向对端写入,所以它们不共享缓冲区。

顺便说管道和伪终端的另一个重要区别:管道只看到缓冲区管理,也就是只有纯粹字节流,而对于tty设备,需要对其中的每个字符做特殊处理,也就是stty -a 展示的内容。更详细的说就是:假设tty收到了一个ctrl+C,它就可能需要向读入者发送一个SIGINT,而不是简单的把这个内容返回给接受者。另一个问题依然是大家最常见的现实为题,包括控制缓冲区的删除等。还有如果termios设置了回显,那么内核还要保证将这个东西再写会到发送者等。总之,tty是比pipe更为细致和复杂的一种控制。

posted on 2019-03-06 20:45  tsecer  阅读(671)  评论(0编辑  收藏  举报

导航