EOF,到底怎么回事

TL;DR

首先,确未想到,为说清楚这个玩意儿,居然要用不少的篇幅;其次,当涉及对一些概念、原理的追溯时,递归到多深的地步,也不容易拿捏;好在,写这些文字主要是为了将来碰到某些反直觉的情况时可以有个快捷解答;最后若能得到碰巧逛到这里的同仁指点迷津,纠正错误,互通有无,就算赚到了 :-)

希望读完此文,能够消除一些关于 EOF 的疑惑,再碰到关于她的一些争论时,大家能够相视一笑。

愿此文,能解释

  • 什么是 EOF?
  • 为什么需要 EOF?
  • 文件里包不包含 EOF?
  • 终端输入时的 EOF 的表示方式和处理行为是怎么样的?
  • 不同计算机语言的 EOF 如何定义的?
  • ……
  • may your blade never dull

section-0

概念澄清

In computing, end-of-file (commonly abbreviated EOF) is a condition in a computer operating system where no more data can be read from a data source. The data source is usually called a file or stream.

——Wikipedia

难为下定义的人们,描述既不能太复杂,又要尽可能的说清一个事物的本质。

好,从上面的叙述中,我们萃取出关于 EOF:

  • 范畴:计算机操作系统中,其他领域看来用不着这玩意儿
  • 含义:一种状况,什么状况?表明从数据源(通常指文件或流)中已无数据可读

如果只看到这里,EOF 似乎只是抽象概念而已,她应该独立于操作系统的种类、也应该独立于能够在某种操作系统下编译的计算机语言,everything before 'but' is bullshit。

但是,维基百科在紧挨着定义的下一段里说:

In the C Standard Library, the character reading functions such as getchar return a value equal to the symbolic value (macro) EOF to indicate that an end-of-file condition has occurred. The actual value of EOF is implementation-dependent (but is commonly -1, such as in glibc[2]) and is distinct from all valid character codes. Block-reading functions return the number of bytes read, and if this is fewer than asked for, then the end of file was reached or an error occurred (checking of errno or dedicated function, such as ferror is often required to determine which).

——Wikipedia

展开:

  • 定义既然说:EOF 表明了"已无数据可读"的状况,那么识别这种状况的依据是什么?
  • 计算机语言上千种,唯独选了 C 来描述 EOF 的实现,隐约感到虽然不同语言对于 EOF 的实现可能不同,但 C 的很有代表性
  • 只要能起到识别结尾在哪的作用就成,并没有一个标准说 EOF 该怎么实现,但通常是用一个能够区别全部有效字符码,比如 glibc 里用 -1。啊,越来越具体,越来越靠近 CPU 里那些用于判断的指令和寄存器
  • 短暂的概念陈述后,定义者放下了遮面的琵琶,挥舞着藏在身上的各种刀凿斧锯迎面扑来——阻塞 IO 中的读函数,返回读取到的字节数,如果实际数据长度小于指定的长度,
    • 重点 1:就会产生一个 EOF(谁产生的后面说)
    • 重点 2:出错了呢?也是返个 EOF 给你。啊?那我的程序怎么办,到底是读完了还是出错了?别慌——瞧 errno 或问 ferror 去

至此,我们暂且可以总结出:

  • 从功用的角度说 EOF,其对于输入输出操作时标识结尾具有不可或缺性,这种概括屏蔽掉了实现细节
  • 一旦讨论具体实现时,就不可避免的要限定在某种操作系统和某种计算机语言的环境中

同时,新的疑问自然产生:

  • 尝听人说,在现代操作系统中,对于输入输出这类操作,运行在用户态的应用程序一般是不直接访问硬件的,要请求叫做系统调用的内核接口通过缓冲区来间接进行,那么 EOF 是由内核生成并返给应用程序的吗?除了直接告诉我答案是或否,哪里有直接的证据?
  • EOF 的实现没有具体规范,那么操作系统与应用程序之间是如何打招呼:“文件已经读完”的呢?
  • EOF 的处理策略与表现特征,是跨平台跨语言统一的,还是各家有各家的高招儿呢?

让我们带着疑问,误入藕花深处……

随笔

下定义,但不规定具体的实现,这下好了,且看众多的操作系统和计算机语言们如何搭配自己的 EOF "卡组"。


section-1

先说文件

注意

不要将 Linux 中的一切皆文件文件二字,与我们现在所说的文件混淆,这里说的文件,就是通常在外部存储器(如:磁盘)中保存的那些普通文件,特别是文本文件。

静态的相对单纯些,我们就从其开始——

Some MS-DOS programs, including parts of the Microsoft MS-DOS shell (COMMAND.COM) and operating-system utility programs (such as EDLIN), treat a Control-Z in a text file as marking the end of meaningful data, and/or append a Control-Z to the end when writing a text file. This was done for two reasons:

  • Backward compatibility with CP/M. The CP/M file system only recorded the lengths of files in multiples of 128-byte "records", so by convention a Control-Z character was used to mark the end of meaningful data if it ended in the middle of a record. The MS-DOS filesystem has always recorded the exact byte-length of files, so this was never necessary on MS-DOS.
  • It allows programs to use the same code to read input from both a terminal and a text file.

——Wikipedia

乍看之下,为了兼容性和代码复用以及便利性,某些 MS-DOS 程序在保存文件的时候会在最后多写入一个和正文无关的,但用来标识有意义数据的结尾的字符,只不过选的值不是 -1(这是必然的,ASCII 里没 -1),而是一个有效的 ASCII 字符,即:替换字符( Control-Z 码值 26),这算不算文件中包含 EOF?

嗯,不算。

简明结论

文件中不包含 EOF。或者说,文件不通过在自身内容的最后放一个特殊的额外的字符来标记自己的结尾!

说的这么绝对,那上面的(Control-Z 码值 26)被写在了某些文件的尾部该作何解?

可以这么说:这个在某些文件中的(Control-Z 码值 26)字符,只有在那些把他当作文件结尾标识来对待的程序中,才表现得具备了 EOF 的特征;换句话说,对于那些不把(Control-Z 码值 26)当做文件结尾标识的程序来说,这就是个普通的 ASCII,只不过他不可打印而已。

$$ 那文件的结尾到底在哪? $$

回答之前,我们先问这样一个问题:假设文件中有一个或多个字节的数据用来标明其结尾,那么对于二进制文件,如何区分内容和结尾呢?

嗯,不太灵光呢。看来,单靠文件内容本身,做不到这一点。

$$ 『文件的结尾在哪』这个问题该转换为:怎么判断文件读完了? $$

听老辈们说——戏不够,神仙凑~

简明结论

读取文件时,判断是否读完全部内容,不是靠文件内容中的特殊字符,而是文件系统中的重要元数据之一——文件长度

长度,居然不是文件内容的附属品,有这么重要的作用?为了突出这一点,我们来做个实验,感受一下文件长度的威力,只关注 EOF 的话可以跳过这一段:(不开虚拟机了,怪麻烦的,就手头的 Windows 了)

通常情况下,我们无法直接修改诸如文件大小、创建时间等重要的元数据信息,但我们可以间接地晃 Windows 一枪……

删文件大家都干过,那么 Windows 的回收站想必不陌生,我们的实验步骤如下:

  1. 为演示方便先清空回收站。然后随便建个什么文件,本例是空的 txt,叫 empty.txt;这个文件扔进回收站,右键属性:

  2. 管理员身份启动 cmd

  3. 获取当前用户的 SID 后,进入该用户对应的回收站

  4. $R 开头的那个是我们删除的文件本身,大小是 0 没错;而我们关注的是在回收站中与其成对出现的以 $I 开头的文件,本例中名为:$ICPJMHU.txt。他的大小不是 0,看来里头有东西,我们来看一下

    1type $ICPJMHU.txt
    2Zu?"C:\Users\Looper\Desktop\empty.txt
    

    嗯,有能看懂的,有乱码的。我们需要从二进制视角来看他的内容

  5. 打开 WSL,我们已经知道 SID了,所以这次直接进目录

  6. vi -b $ICPJMHU.txt 二进制打开,调 xxd 查看十六进制显示

    路径的部分,没什么可说的,除了每个字符占了俩字节(这是因为 Windows 默认存的是 UTF-16)

  7. 我们逐个说明各个二进制段的作用

  8. 好,吟唱完毕,施法~
    我们把8~158个字节修改成:FFFF FFFF FFFF FF7F
    保存退出,回到 Windows,从新查看被删除文件的属性

    $$ 7.99 EB = 7.99 * 1024 PB $$ $$ 7.99 EB = 7.99 * 1024 * 1024 TB $$ $$ 7.99 EB = 7.99 * 1024 * 1024 * 1024 GB = 8579197173.76 GB $$

友情提示

24~27的4个字节存了一个长度。欸,你怎么就和文件大小长得那么像呢~

再说终端

上一节中,在已知文件长度的情况下,不需要 EOF 存在于文件中,就可以知道何时文件读取完毕。然而,对于处理在终端中输入的数据来说,怎么标识出输入的结束呢?

Input from a terminal never really "ends" (unless the device is disconnected), but it is useful to enter more than one "file"into a terminal, so a key sequence is reserved to indicate end of input. In UNIX the translation of the keystroke to EOF is performed by the terminal driver, so a program does not need to distinguish terminals from other input files. By default, the driver converts a Control-D character at the start of a line into an end-of-file indicator. To insert an actual Control-D (ASCII 04) character into the input stream, the user precedes it with a "quote" command character (usually Control-V). AmigaDOS is similar but uses Control-\ instead of Control-D.

In DOS and Windows (and in CP/M and many DEC operating systems such as RT-11 or VMS), reading from the terminal will never produce an EOF. Instead, programs recognize that the source is a terminal (or other "character device") and interpret a given reserved character or sequence as an end-of-file indicator; most commonly this is an ASCII Control-Z, code 26.

——Wikipedia

诚然,这回长度是未知的了,若不指明在何时在哪里结束,程序将无法得知:一段输入已经结束。

虽然,定义规范并没有限制要用那个值来表示 EOF,可实现者却无法规避以下问题:

  1. 这个值不能和任何有效字符冲突
  2. 这个值得能从键盘输入,且不能太麻烦
  3. 这个值虽然特殊,但没有特殊到占用太多的内存,为其设计特别的处理逻辑的地步
  4. 键盘上就那么些组合,怎么才能同时满足以上 3 点

且看,具有代表性的实现:

  • 在 UNIX 中,组合键击(Control-D 码值 04)转换为 EOF 是由终端驱动完成的,因此应用程序不需要将终端和普通文件区别对待

    • 提问:既然我们从键盘输入的(Control-D 码值 04)被终端截胡转成 EOF 了,文件中又不存 EOF,那我要想在文件中切实的存一个(Control-D 码值 04)怎么办呢?
    • 回答:转义,先按(Control-V)紧接着按(Control-D)。注意,这不是把 EOF 输入进去了,是把没被转成 EOF 的(Control-D)的原本码值输入进去了。
  • 在 DOS 和 Windows 中,从终端读取永远不会产生 EOF。取而代之的,因为程序知道数据源是终端而不是文件(留意其与 UNIX 策略的不同),所以将特定的保留字符或序列当作文件结束指示符看待,最常见的是(Control-Z 码值 26)。(哦~ Control-Z 好像在哪里见过你。)


section-2

let us reading the fucking source code...

随笔

就说嘛,拿个 echo 和 hexdump 就想糊弄过去?笑~

在 faq.cprogramming.com 站点上,有一篇很古老的文章 Definition of EOF and how to use it effectively

开篇就一锤定音的说到:

  • EOF 不是 A char
  • EOF 不是 A value that exists at the end of a file
  • EOF 不是 A value that could exist in the middle of a file

还好还好,和我们前面说的不冲突。该文作者的目的,是希望学习该语言的程序员不要掉进该语言的 EOF 的一些陷阱,留意作者所说的关于与 EOF 比较时变量类型的坑。其与我们当前关注的问题并不直接关联,因此在这里仅做了链接,不打算展开。

回到我们的探索。

C 语言中对 EOF 的定义简单明了:

<stdio.h>

1...
2#define EOF     (-1)
3...

真的是-1,那么,各家都是用的-1吗?我们看一看 The GNU C Library 怎么说

12.15 End-Of-File and Errors

Macro: int EOF
This macro is an integer value that is returned by a number of narrow stream functions to indicate an end-of-file condition, or some other error situation. With the GNU C Library, EOF is -1. In other libraries, its value may be some other negative number.

也就是说,程序要做判断时 EOF 不可与 -1 互换。欸,NULL 和 ‘\0’ 何尝不是如此。像这类规范中不限定具体实现的例子,在 CS(Computer Science) 世界里比比皆是,比如 Go 的包导入;比如 C++ 自增自减运算符的副作用;比如……停,眼前这点儿事还没扯完呢。

记得之前我们说在某些条件下,会产生 EOF,但那时没说是谁产生的。

下面就去代码中寻觅答案,这也能捎带着回答 section-0 末尾提出的问题。

我们选 glibc-2.31 (clone 能不能快点)里的 getc 为例子:

glibc-2.31/libio/getc.c

 1#include "libioP.h"
 2#include "stdio.h"
 3
 4...
 5
 6int
 7_IO_getc (FILE *fp)
 8{
 9  int result;
10  CHECK_FILE (fp, EOF);
11  if (!_IO_need_lock (fp))
12    return _IO_getc_unlocked (fp);
13  _IO_acquire_lock (fp);
14  result = _IO_getc_unlocked (fp);
15  _IO_release_lock (fp);
16  return result;
17}
18
19...

这是一个需要确保线程安全的函数,可以看到其为了进入临界区而获得锁的逻辑。这里关注的是 _IO_getc_unlocked,查看此宏,看到他又展开为另一个宏(老一些的版本没有这个步骤):

glibc-2.31/libio/libio.h

1...
2#define _IO_getc_unlocked(_fp) __getc_unlocked_body (_fp) 
3...

继续定位__getc_unlocked_body,这是一个内联宏,作用是可以用不调函数的方式执行从流中读取数据的逻辑。其中,__glibc_unlikely并不改变比较结果,而是用来通知编译器在此处进行恰当优化的,他与我们的读调用何时产生 EOF 无关,不展开了,继续追索:

glibc-2.31/bits/types/struct_FILE.h

1...
2/* These macros are used by bits/stdio.h and internal headers.  */
3
4#define __getc_unlocked_body(_fp)                                 \
5(__glibc_unlikely ((_fp)->_IO_read_ptr >= (_fp)->_IO_read_end)    \
6? __uflow (_fp) : *(unsigned char *) (_fp)->_IO_read_ptr++)
7...

我们离真相,还差一步,这里先把不太直观的代码简化为等价的函数体:

1inline int __getc_unlocked_body(FILE *fp) {
2    if (_fp->_IO_read_ptr >= _fp->_IO_read_end)
3        return __uflow(_fp);
4    else
5        return *(unsigned char *)(_fp->_IO_read_ptr++);
6}
  • 如果,_IO_read_ptr >= _IO_read_end,说明缓冲区已读完,需要重新从 IO 设备中读取数据到缓冲区。
  • 否则,说明尚未到达缓冲区末尾,只需返回_IO_read_ptr所指向的一个字节大小的内容,然后_IO_read_ptr加 1(指针加减要留神),指向下一个字节。
  • 最初的时候_IO_read_ptr_IO_read_end是相等的,这样才会从键盘中进行读取。

最终,定位到 __uflow 函数:

glibc-2.31/libio/genops.c

 1...
 2int
 3__uflow (FILE *fp)
 4{
 5  if (_IO_vtable_offset (fp) == 0 && _IO_fwide (fp, -1) != -1)
 6    return EOF;
 7
 8  if (fp->_mode == 0)
 9    _IO_fwide (fp, -1);
10  if (_IO_in_put_mode (fp))
11    if (_IO_switch_to_get_mode (fp) == EOF)
12      return EOF;
13  if (fp->_IO_read_ptr < fp->_IO_read_end)
14    return *(unsigned char *) fp->_IO_read_ptr++;
15  if (_IO_in_backup (fp))
16    {
17      _IO_switch_to_main_get_area (fp);
18      if (fp->_IO_read_ptr < fp->_IO_read_end)
19	return *(unsigned char *) fp->_IO_read_ptr++;
20    }
21  if (_IO_have_markers (fp))
22    {
23      if (save_for_backup (fp, fp->_IO_read_end))
24	return EOF;
25    }
26  else if (_IO_have_backup (fp))
27    _IO_free_backup_area (fp);
28  return _IO_UFLOW (fp);
29}
30...

呜呼呀,满屏的 EOF。——注意上面代码的语境,程序领空中运行在用户态的库函数。

关于 EOF 是在哪里产生的,这里还有一篇老外的文章,EOF is not a character。图文并茂,既有跨语言的横向对比,又有纵深的底层原理说明。比我写的好多了,大家有空可以看看。

我们摘抄一段:

How do the high-level I/O routines in the examples above determine the end-of-file condition? On Linux systems the routines either directly or indirectly use the read() system call provided by the kernel. The getc() function (or macro) in C, for example, uses the read() system call and returns EOF if read() indicated the end-of-file condition. The read() system call returns 0 to indicate the EOF condition.

这里就告诉了我们结论,EOF 并非由内核直接产生,库函数是通过判断系统调用的返回值,进而决定是否要返回 EOF 给调用者的。(老外随手画的图,看起来比我费半天劲用 visio 做的一点不差。莫非是因为:只要不是汉字,其他的看起来就都是画儿的缘故。。。)

另外多说一句:其实到int __uflow (FILE *fp)这里还没完,还有很多宏、函数,可以细追下去,比如在最终的系统调用sys_read之前,能追到int _IO_new_file_underflow (FILE *fp),但可能需要运行时调试。因其与产生 EOF 的关系不是直接的,为省略篇幅就不全粘过来了。


section-3

举起我们的栗子

看了那么多别人的代码,我们自己也应该试着写一写,写不好没关系,反正也不是拿去卖钱的。

$$ \left ( \frac{表演真正的技术}{露一把真正的怯}\right )^{是时候了}= 1 $$

原则,我们的程序

  • 要尽量简单
  • 要能达到让我们不再对 EOF 的怪异行为困惑的目的
  • 尽量不只使用一种系统、一种语言,免得孤证不证,以偏概全

既然是问题带我们误入的藕花深处,为了解答疑问,就让我们——争渡,睁渡

记得前文中,UNIX 和 Windows 下的终端对于文件结尾的处理策略是不同的,具体差在哪呢?

看如下代码:

1<?php
2
3$line = 1;
4
5while (false !== ($char = fgetc(STDIN))) {
6    echo "\tchar-", $line++, "\t", ord($char), "\n";
7}

这段程序的功能如下:

  • 启动后阻塞,等待用户输入,遇到文件结尾标记(注意我这里没用 EOF 的叫法)就退出。
  • 在遇到文件结尾标记之前,用户每输入一次回车,就执行循环内的代码,fgetc 负责读取一个字符(不是字节),而 ord 将被读到的字符的第一个字节转为 0-255 之间的值(就是对应的 ASCII)。
  • 把经过处理的输入,用稍微友好一点的格式打印出来。
步骤

在 Windows 10 终端下,使用 php-cli 7.x 执行此程序,输入的过程是:
ENTER、a ENTER、A ENTER、abc ENTER、aBc ENTER、CTRL-D ENTER、CTRL-DCTRL-D ENTER、CTRL-Z ENTER

输出如下:# 和后面的内容是我加的注释

 1
 2		char-1  13 # ASCII of CR \r
 3		char-2  10 # ASCII of LF \n
 4a      
 5		char-3  97 # ASCII of a
 6		char-4  13 # ASCII of CR \r
 7		char-5  10 # ASCII of LF \n
 8A      
 9		char-6  65 # ASCII of A
10		char-7  13 # ASCII of CR \r
11		char-8  10 # ASCII of LF \n
12abc    
13		char-9  97 # ASCII of a
14		char-10 98 # ASCII of b
15		char-11 99 # ASCII of c
16		char-12 13 # ASCII of CR \r
17		char-13 10 # ASCII of LF \n
18aBc    
19		char-14 97 # ASCII of a
20		char-15 66 # ASCII of B
21		char-16 99 # ASCII of c
22		char-17 13 # ASCII of CR \r
23		char-18 10 # ASCII of LF \n
24^D     
25		char-19 4  # ASCII of CTRL-D
26		char-20 13 # ASCII of CR \r
27		char-21 10 # ASCII of LF \n
28^D^D   
29		char-22 4  # ASCII of CTRL-D
30		char-23 4  # ASCII of CTRL-D
31		char-24 13 # ASCII of CR \r
32		char-25 10 # ASCII of LF \n
33^Z

关于 CR 和 LF 的话题,网络上的文章比关于 EOF 的多,可以搜索来看。在 Linux 和 MacOS 下,同样的程序,同样的输入,输出会与 Windows 不同。例如在 MacOS 下 ENTER 只对应一个单独的 \n,没有 \r。

我们目前的关注点不在于不同系统对于 CR 和 LF 的不同策略,那是一个可以追溯到没有计算机时代的故事。现在想看的是不同平台下,对待表示输入结束的文件结尾标识的异同。

首先,前文中说过,Windows 中从终端读取输入,不会产生 EOF;其次,为了表示文件结尾,他选用了 CTRL-Z ASCII 26,作为输入结束的标记,所以我们才能够看到对于 CTRL-D,Windows 将其视作普通字符,程序读取该字符并将其 ASCII 码值打印到屏幕上。而当我们输入了 CTRL-Z 并 ENTER 后,程序退出(实际上是退出的循环,但因为退出循环后没别的代码了,程序自然退出),并没有打印 CTRL-Z 的 ASCII 码值。那么,能不能让该程序在 Windows 下打出这个 CTRL-Z 的码值,并且不退出程序呢?可以的,这样操作:

步骤

启动程序,输入一些除了 CTRL-Z 以外的其他输入,然后 CTRL-Z,最后回车。
比如:anything but "CTRL-Z",然后 CTRL-Z,最后 ENTER

输出如下:

 1anything but "CTRL-Z"^Z
 2		char-1  97 # 我该选个字母少的句子。。。懒,省略一些注释
 3		char-2  110
 4		char-3  121
 5		char-4  116
 6		char-5  104
 7		char-6  105
 8		char-7  110
 9		char-8  103
10		char-9  32
11		char-10 98
12		char-11 117
13		char-12 116
14		char-13 32
15		char-14 34
16		char-15 67
17		char-16 84
18		char-17 82
19		char-18 76
20		char-19 45
21		char-20 90
22		char-21 34
23		char-22 26 # ASCII of CTRL-Z,哦,26,我们终于亲眼见到了你
24		char-23 13 # ASCII of CR \r
25		char-24 10 # ASCII of LF \n

程序打印了 CTRL-Z 26,并且没有退出,继续等待我们的下一轮输入。为什么没退出,后面详解。

在其他平台下运行同样程序的操作留给有好奇心的你我他。

让我们的 Windows 先那儿等着,说说其他系统。

比如 Linux 下,输入 CTRL-Z,程序会被切入后台执行,需用 fg 命令唤回,这个和我们的 EOF 无关,略过。那么输入 CTRL-D 呢,不必输入 ENTER 了,程序会立刻对 EOF 做出响应。

系统和语言都换一换——

 1// 你好,号称 C+Python 的 Golang
 2package main
 3
 4import (
 5	"bufio"
 6	"io"
 7	"os"
 8)
 9
10func main() {
11	line, r := 1, bufio.NewReader(os.Stdin)
12	for {
13		c, err := r.ReadByte()
14		if err == io.EOF {
15			// println("\tchar|", line, "\t", c) // 注意 Go 对于这种情况下 c 值的处理
16			break
17		}
18		if err != nil {
19			panic(err)
20		}
21		println("\tchar-", line, "\t", c)
22		line++
23	}
24}
步骤

以下为 Ubuntu 20.04 终端下,go 1.14.4 运行该程序的输出
输入过程:ENTER、a ENTER、A ENTER、abc ENTER、aBc ENTER、CTRL-D

 1
 2		char-1          10
 3a      
 4		char-2          97
 5		char-3          10
 6A      
 7		char-4          65
 8		char-5          10
 9abc    
10		char-6          97
11		char-7          98
12		char-8          99
13		char-9          10
14aBc    
15		char-10         97
16		char-11         66
17		char-12         99
18		char-13         10

可以看到,不同平台下的终端对待 ENTER 的行为是一致的,但对待文件结束标记却不同

  • Windows 下输入 CTRL-Z,并不会有响应,必须等 ENTER 后,才进入判断和处理流程( EOF 跑 ENTER 前头去了?No。后文详述)
  • Linux 下只要输入 CTRL-D,就会立刻进入处理流程( EOF 变成 ENTER 了?No。后文详述)

还没完,记得之前的例子,我们曾用某种输入方式让那个 PHP 程序在 Windows 下打印了我们输入的 CTRL-Z 的码值,那么对于这个 GO 程序,是否适用呢?很遗憾,GO 并不把 EOF 当作有效输入返回给用户程序,无论在哪个平台;可以通过取消上面代码中 io.EOF 判断块中的注释来验证这点。

虽然无法让 GO 打印出我们通过终端输入的 EOF 的码值,但前面的方法(先输入一些内容,不回车直接给 EOF)的确改变了文件结尾标记的默认行为,下面解释其原理:

The Open Group Base Specifications Issue 7, 2018 edition(如果感觉这玩意儿名字陌生,他还有另一个名字:POSIX。虽然也可能像我一样,久闻其名,却不知其全貌,但起码比第一个名字脸熟多了)的11.1.9 Special Characters中,关于 EOF 的描述:

EOF
Special character on input, which is recognized if the ICANON flag is set. When received, all the bytes waiting to be read are immediately passed to the process without waiting for a <newline>, and the EOF is discarded. Thus, if there are no bytes waiting (that is, the EOF occurred at the beginning of a line), a byte count of zero shall be returned from the read(), representing an end-of-file indication. If ICANON is set, the EOF character shall be discarded when processed.

我们来试着翻译一下:

EOF
输入中的特殊字符,在 ICANON 标志为真时被识别。当遇到这个字符时,所有等待被读取的字节将立刻被传给处理程序而无需等待一个新行符,同时该 EOF 被丢弃。然而,如果当前没有字节等待被处理(也即:EOF 出现在了一行的行首),一个 0 长度的字节计数将被 read() 返回,表现出一种 EOF 迹象。如果 ICANON 为真,EOF 字符在被处理后应该被丢弃。

EOF 就够晕的了,怎么又杀出个 ICANON flag?

嗯,规范中也是都写清楚了的,是终端 I/O 工作模式相关的内容(欸~就不能只有一种键盘,一种鼠标,一种显示器,一种系统,一种接口么。。。),EOF 在其中只是很小的一部分。可以看看国内这哥们儿的一篇文章:终端 I/O 之综述,弄清楚终端 I/O 的故事,有助于我们正向的理解终端处理用户输入的各种策略。

最后一例:代码取自我翻译的一个老外的汇编教程,原址:https://asmtutor.com/

高级语言看真相,总是隔着层纱,我们需要定性的结论

 1; Hello World Program (Getting input)
 2; Compile with: nasm -f elf helloworld-input.asm
 3; Link with (64 bit systems require elf_i386 option): ld -m elf_i386 helloworld-input.o -o helloworld-input
 4; Run with: ./helloworld-input
 5 
 6%include        'functions.asm'
 7 
 8SECTION .data
 9msg1        db      'Please enter your name: ', 0h      ; message string asking user for input
10msg2        db      'Hello, ', 0h                       ; message string to use after user has entered their name
11 
12SECTION .bss
13sinput:     resb    255                                 ; reserve a 255 byte space in memory for the users input string
14 
15SECTION .text
16global  _start
17 
18_start:
19 
20    mov     eax, msg1
21    call    sprint
22 
23    mov     edx, 255        ; number of bytes to read
24    mov     ecx, sinput     ; reserved space to store our input (known as a buffer)
25    mov     ebx, 0          ; read from the STDIN file
26    mov     eax, 3          ; invoke SYS_READ (kernel opcode 3)
27    int     80h
28 
29    mov     eax, msg2
30    call    sprint
31 
32    mov     eax, sinput     ; move our buffer into eax (Note: input contains a linefeed)
33    call    sprint          ; call our print function
34 
35    call    quit
1$ nasm -f elf helloworld-input.asm
2$ ld -m elf_i386 helloworld-input.o -o helloworld-input
3$ ./helloworld-input
4Please enter your name: Dylan
5Hello, Dylan

ENTER 后程序结束。嗯?EOF 呢?

如果,你的阻塞读,只需要处理一次(在 Canonical mode input processing 里就是一行)输入,那么 ENTER 就够用了,用不着 EOF。ENTER 的作用只是:

  • 这一行输入已完成(结束),读函数别阻塞了,排空缓冲区,立刻返回。还想再读?从新发 sys_read!(嘿,老铁,你的煎饼熟了,拿走吧,钱货两清。要加个蛋?那您从新下单)

但是,如果你需要不停的处理用户输入,则要将阻塞读扔进循环,通常是满足某种条件就 break 的死循环(一如:Server 端的 ACCEPT 所属的循环)。而这时,ENTER 还是那个 ENTER,该起什么作用还起什么作用,但他不能用来退出这个循环,那咱们选个谁来退出这个循环?

啊~ EOF,掀起你的盖头来(你说啥?杀进程、重启机器、拔插销……噫,夫子乱人之性也!)

哦噫,那人,你一开始说 C 的实现很具有代表性,结果你仨例子没一个 C 的?(忘了我 多难过 多不能接受;忘了我 只要你好过 就足够;忘了我 忘了我们的梦;当你想起我 我已不是我……)

.

.

.

我们多空出一些空间,给出比较重要的结论:

EOF 对于在整个阻塞读过程中的系统、终端、用户程序来说,至关重要。因而关于 EOF 的众多描述,往往是系统、终端、程序和 EOF 放一堆儿全搓了。这容易产生系统和终端的 EOF 和某种语言定义的 EOF 是一回事的错觉。

而实际上——操作系统并不关心也不必知道某种计算机语言所定义的 EOF 是什么样子;他只是和终端配合,识别出由某种组合键击标识的他俩所认识的 EOF。当满足条件时结束阻塞,终端和系统按自己的逻辑处理后将控制权返给某种语言的库函数,库函数此时有 100% 解释权,比如:出错就直接捏一个自己定义的 EOF 返回(也就是说,在库函数按自己的逻辑解释系统的返回之前,压根儿就没有语言自己定义的 EOF 什么事呢)。

.

.

.

至此,我们大概可以解释——

  • EOF 做什么用的?没有行不行?
  • EOF 是归语言的,还是归系统的?
  • 取名字可是个学问,老外管这个“用来解决前面那一堆文字阐述的事儿”的玩意儿叫:End-of-File。你觉得是否传神呢。
  • 计算机唯快不破的,怎么分清个头尾居然这么麻烦。
  • 为什么阻塞读所在的循环,在已经输入了一些内容到终端的情况下,要按两次 CTRL-D 才能退出。
  • ENTER 不能替代 EOF,但 EOF 有时却起到了 ENTER 的效用。

场外乱入一下,之后的内容大都与本文无关可略:写到这里的时候,不知怎么的,忽然忆起了一个关于32位操作系统和4G物理内存的故事,有兴趣可以递归浏览一把,这哥们写的挺好,让我不必再为这个话题在脑子里反转剧情了:为何微软不在新的操作系统中让 32 位支持大于 4GB 的内存?


section-NaN

多余的话

今天,我们使用计算机也好,学习计算机相关的知识技术也罢,都已经是在面对一个封装到几乎无可在封、间接到几乎无缝可插、象与质天差地别、与其诞生伊始相去甚远的集大成的存在了。用起来固然比旧时要愉悦(比如:输入方式一再的简化),但乱花渐欲迷人眼,对于追溯着学习这门学科并试图窥其全貌的人就不那么友好了……时常是,原本好奇心给足了动力的,就是向回捣着捣着就困了。端的是不如从一开始就看着计算机经过飞速成长,激烈蜕变,无奈取舍等等过程而演化成今天的样子来的自然。(好在,据那些玩过大型机的人自己说,还是微机省电~)

各行有各行的业障,各界有各界的风景。在 CS 世界里——

我们有:

  • 图灵的杯具
  • 二极管诞生的趣闻
  • 快摩尔不下去的定律
  • 因为 Intel 曾把地址总线和数据总线做的不一边宽而造成的内存管理的违和,以及为了兼容之而不得不背到今后的内存分段包袱

也有:

  • 人月神话中绕不过的怪圈
  • 被日本的毒舌程序员喷的体无完肤、他自己却耍的飞起的 C 指针声明
  • IBM 发现应该摁住微软但已经来不及时的故事,被微软和谷歌换位上演
  • Sping 望着 EJB 远去的背影,独自呢喃——我终于变成了自己当年所讨厌的样子

曾记否:

  • 今日大红大紫的 iOS 因为 jobs 和 linus 两人的"矜持"而没能跑在 Linux 上的"佳话"
  • 感谢 BitMover 公司,我们能用上 linus 用快的难以望其项背的速度开发的免费开源的 GIT;哦对,别忘记听一听关于 GIT 用 C 却没用 C++ 编写而引起的论战,其规模和影响力丝毫不亚于宏内核与微内核之辩
  • 我至今记忆犹新的一个笑话——【1987 - Larry Wall 在电脑前打了个盹,脑门子压到了键盘上。醒来之后,Larry Wall 深信 ,在他的显示器上出现的神秘字符串并非是随机的,那是某种编程语言之程序样例的神谕。那必是上帝要他的先知——Larry Wall,去设计的。Perl 语言就此诞生了。】容我先笑一阵
  • 高德纳老爷子说,读不懂《计算机程序设计艺术》就不要学习编程,给我造成的是不是入错行的疑问,好伤心

不可略:

  • 自互联网普及以来,现实中的种种在虚拟中的微妙映射
  • 尚未从“心,脑,计算机”的灵魂拷问中醒过神来的人们,面对人工智能时的不知所措与欲罢不能
  • 似乎这个领域生来就是未走先跑,因而其衍生与附属也就难免其俗
  • 太多了,太多了……

别忘了:

  • 程序员们可以为争论哪种语言天下第一而错过人生中许多更重要的事情,比如休息

还有还有,梦中的橄榄树,橄榄树……

——2020年08月01日,北京丰台


本文采用 知识共享署名许可协议(CC-BY 4.0)进行许可,转载注明来源即可。如有错误劳烦评论或邮件指出。


comments powered by Disqus