Hello World是如何被打印的?
(本文章为本人自媒体账号视频的文字版,方便大家阅读)
我们以Java为例,深入背后的JDK,Glibc和Linux内核,浅显地解释一下。打印Hello World涉及的技术细节。
Java
这就是所有学Java的人梦开始地方:
不过可能很少有人在乎,out具体是什么呢?out是一个PrintStream类型的实例:
它由 initPhase1 这个private 方法初始化,注释里有写这个方法是VM调用的,我们不用关心。在这个方法里,它先使用FileDescriptor.out创建了一个FileOutputStream
FileDescriptor.out是什么呢?实际上是值为1的一个文件描述符:
1代表的是标准输出流,现在不理解也没有关系,在后面讲Linux的时候还会提到。
拿到FileOutputStream之后,它又在newPrintStream这个方法里面,把它包了一层BufferedOutputStream,然后又包了一层PrintStream:
最后set给了out这个变量:
PrintStream里面也没闲着,它先用外面传进来的OutputStream和charset,创建了一个OutputStreamWriter,再给writer包了一层BufferedWriter:
BufferedWriter就是一层加Buffer,这个很好理解,我重点想说OutputStreamWriter。它里面干了一个很有意思的事情。它用外面传进来的Charset字符集,创建了一个StreamEncoder:
它在write里面实际上用的是,同样的Charset创建的CharsetEncoder的write。
折腾这么多东西是为了干什么呢?就是为了做encoding编码。编码是个很复杂的话题,首先我们得区分内部编码和外部编码。内部编码就是语言内部String类型存储使用的编码类型,而外部编码则是包括源代码文件输入输出文件等等“外部"文件的编码。
Java和C#这俩语言,内部编码使用的是UTF-16(JDK9中引入latin1),而现在主流的外部编码是UTF-8。因此它们来说打印输出String类型的时候,需要进行一个编码转换然后才能得到正确的字节流。值得一提的是,Swift和Rust这种新势力语言,内部编码直接使用的就是UTF-8,跟上了时代发展的浪潮,也就省去了转换的步骤。
回到正题,最后经过很深的一个调用栈,实际输出编码后的字节流的函数是,FileOutputStream里面的writeBytes:
它是一个原生方法,并不是Java写的。这代表着我们需要去看JDK源码了。
JDK 中的 C
JDK1的源代码中除了Java代码之外,也有一些代码是由C编写的。我们想找的方法在这儿,FileOutputStream里面采用JNI实现的writeBytes方法:
在它的内部,经过一些异常处理逻辑之后,调用了IO_Append和IO_Write这两个宏,它在不同平台上的实现是不一样的。
在unix文件夹下,它的实现是这样的:
在windows文件夹下,它的实现是这样的:
看到这儿大家应该也能理解,为什么需要C代码了。当JDK需要和操作系统进行交互的时候,使用C进行调用是更加方便的。
还有个很有意思的问题是,为什么不让JVM来负责这种和操作系统的交互呢?JVM本身就是C/C++写的,为什么还要从JVM里绕出来再用JNI呢?我的理解是JVM是一个纯粹的VM,它只负责JVM底层指令的解析执行。而跟操作系统交互对于它来说是一个太“高层”的任务了,不符合JVM的抽象层级。
回到writeBytes,我们主要关注Linux上的实现,它调用的是 Glibc 提供 write方法,下面我们看一下 Glibc 的源码。
Glibc
Glibc全称GNU C Library,GNU C标准库,是包括Linux在内的GNU系统当中重要的组成部分。几乎所有程序都对Glibc存在直接或者间接的依赖。
在Linux上Glibc里write方法,由__write_nocancel提供,里面调用的就是write这个Linux系统调用:
既然就是 Linux 系统调用,那为什么要封装这么一层呢?直接调用write这个系统调用不好吗?在Linux肯定没问题,但是万一它不是Linux呢?Glibc里还提供了Hurd内核的write实现,里面调用的就是Hurd有关的方法:
现在大家应该能理解了,面向Glibc而不是直接面向Linux内核,提供了更强的可移植性 。因此除了一些特殊情况,很少直接使用Linux的系统调用,而是使用Glibc提供的对应方法。
回到打印Hello World的正题,Linux的write系统调用是怎么实现的?下面我们看一下Linux的源码。
Linux 内核
在Linux内核里,write这个系统调用的实现,最终由 ksys_write 这个函数承接:
它先根据传进来的FD,调用fdget_pos拿到了一个FD结构体。然后在计算了一下位置之后,调用了vfs_write这个函数写入数据。
有这么几个问题,首先什么是VFS?VFS全称Virtual File System 虚拟文件系统。Linux里贯彻了Unix”一切皆文件“的思想,VFS就是对各种文件系统进行的抽象。不管是往磁盘写,还是往命令行输出写,都可以用write来实现。
第二个问题,大家还记得FD是什么吗?FD是1,真的就是一个普通的int1,而且对所有程序来说都是1。为什么能都是1呢?你是1我也是1大家都是1,Linux怎么知道往哪儿写呢?我们追踪一下fdget_pos这个函数,最后会找到__fget_light这个函数:
它里面第一句有个很重要的东西叫current,它是个宏,在不同的平台上由不同的实现,在x86上它的实现是这样的:
它的作用是拿到当前正在运行的,也就是正在调用内核的进程的信息,返回的是 task_struct 这个结构体。然后__fget_light再从里面拿到 files_struct,也就是进程里面打开的文件信息。这样就很清晰了,虽然你是1我也是1,但是我们的1只是看起来一样,背后的”文件“并不是一个。
然后就是第三个问题了,这个1对应的”文件“是什么?它有文件系统吗?它还真有,在Linux里它由一个叫做Devpts的虚拟文件系统承接。PTS的全称是Pseduo Terminal Device伪终端设备,它的作用就是模拟终端设备提供命令行的输入输出。PTS具体的实现原理就不在本视频的讨论范围了,我们只需要关注它的效果。每open一次 /dev/ptmx,它就会返回 /dev/pts/0 /dev/pts/1这样的负责输入输出的”文件“。使用 lsof 可以看到当前有哪些程序占用了输入输出:
当我们打开新的程序比方python,你会发现它继承了bash的输入输出,也占用了相同的 /dev/pts/0:
如果我们直接用echo往 /dev/pts/0 打印,是可以直接打到命令行的。
结尾
终于浅显地把打印Hello World的流程说完了,限于篇幅,这篇文章内容比较粗糙,包括Linux内核里面的细节,以及终端模拟器的实现,也就是字符具体是怎么被打印到屏幕上的,都没有涉及。感兴趣的大家把这篇文章当成引子,自己去探索。希望对大家有用吧。
