11.8 缓存 I/O
通过使用输入输出缓存, 我们可以提高代码的效率。 我们可以建立一个输入缓存, 并一次读入一系列的字节。 然后, 我们再一个接一个地从缓存中提取它们。
同样, 我们可以建立一个输出缓存。 把我们的输出存在里面,直到添满。 同时, 我们将让内核将缓存的内容写到标准输出 stdout 上。
程序将在没有输入的时候结束。 但是我们仍然需要让内核再向标准输出 stdout 进行最后一次写操作, 否则一些内容将留在缓存中, 永不输出。 别忘记这个操作, 否则你将会困惑为什么你的程序丢失了一些应有的输出。
%include 'system.inc' %define BUFSIZE 2048 section .data hex db '0123456789ABCDEF' section .bss ibuffer resb BUFSIZE obuffer resb BUFSIZE section .text global _start _start: sub eax, eax sub ebx, ebx sub ecx, ecx mov edi, obuffer .loop: ; read a byte from stdin call getchar ; convert it to hex mov dl, al shr al, 4 mov al, [hex+eax] call putchar mov al, dl and al, 0Fh mov al, [hex+eax] call putchar mov al, ' ' cmp dl, 0Ah jne .put mov al, dl .put: call putchar jmp short .loop align 4 getchar: or ebx, ebx jne .fetch call read .fetch: lodsb dec ebx ret read: push dword BUFSIZE mov esi, ibuffer push esi push dword stdin sys.read add esp, byte 12 mov ebx, eax or eax, eax je .done sub eax, eax ret align 4 .done: call write ; flush output buffer push dword 0 sys.exit align 4 putchar: stosb inc ecx cmp ecx, BUFSIZE je write ret align 4 write: sub edi, ecx ; start of buffer push ecx push edi push dword stdout sys.write add esp, byte 12 sub eax, eax sub ecx, ecx ; buffer is empty now ret
现在我们的程序有了第三个部分,名字叫 .bss
。 这个部分不会包含在我们可执行文件里, 因此不会被初始化。 我们需要用 resb
代替 db
。 它仅仅为我们保留了指定大小的未初始化内存。
我们将充分利用系统不会修改寄存器这个优点:我们为那些可能存储在 .data
数据段里的全局变量使用寄存器。 和 Microsoft 将参数直接传递进寄存器的方式相比, UNIX® 规范中将参数存放在堆栈里的方式更加高级。 因为我们将更自由得使用寄存器。
我们将 EDI
和 ESI
当作指针使用, 以次来对下一个字节进行读写操作。同时,我们使用 EBX
和 ECX
来保存两个缓存里字节的数量。 因此,我们知道什么时候该对系统停止读写操作。
我们来看看它是怎样工作的?
% nasm -f elf hex.asm % ld -s -o hex hex.o % ./hex Hello, World! Here I come! 48 65 6C 6C 6F 2C 20 57 6F 72 6C 64 21 0A 48 65 72 65 20 49 20 63 6F 6D 65 21 0A ^D %
在我们输入 ^D 前, 程序没有输出,这个和我们的预计不符。 不过修复很简单, 插入三行代码, 让程序在我们换行的时候, 每次都有输出就可以了。 我用 > 标记了这三行 ( 请不要将 > 加入你的代码中)。
%include 'system.inc' %define BUFSIZE 2048 section .data hex db '0123456789ABCDEF' section .bss ibuffer resb BUFSIZE obuffer resb BUFSIZE section .text global _start _start: sub eax, eax sub ebx, ebx sub ecx, ecx mov edi, obuffer .loop: ; read a byte from stdin call getchar ; convert it to hex mov dl, al shr al, 4 mov al, [hex+eax] call putchar mov al, dl and al, 0Fh mov al, [hex+eax] call putchar mov al, ' ' cmp dl, 0Ah jne .put mov al, dl .put: call putchar > cmp al, 0Ah > jne .loop > call write jmp short .loop align 4 getchar: or ebx, ebx jne .fetch call read .fetch: lodsb dec ebx ret read: push dword BUFSIZE mov esi, ibuffer push esi push dword stdin sys.read add esp, byte 12 mov ebx, eax or eax, eax je .done sub eax, eax ret align 4 .done: call write ; flush output buffer push dword 0 sys.exit align 4 putchar: stosb inc ecx cmp ecx, BUFSIZE je write ret align 4 write: sub edi, ecx ; start of buffer push ecx push edi push dword stdout sys.write add esp, byte 12 sub eax, eax sub ecx, ecx ; buffer is empty now ret
现在看看它运行的结果:
% nasm -f elf hex.asm % ld -s -o hex hex.o % ./hex Hello, World! 48 65 6C 6C 6F 2C 20 57 6F 72 6C 64 21 0A Here I come! 48 65 72 65 20 49 20 63 6F 6D 65 21 0A ^D %
看来程序运行得不错!
注意: 这样对缓存 I/O 的操作存在着潜在危险。 我会在说明完 缓存的缺陷 后,讨论并修改它。
11.8.1 如何将字符放回输入流
警告: 这可能是一个较为进阶的话题, 熟悉编译理论的程序员可能会对此有兴趣。 如果愿意的话, 您可以 直接跳转到下一节, 以后再阅读这一部分。
尽管我们的示范程序并不需要它, 但更复杂一些的过滤器可能会用到。 换言之, 这些过滤器可能需要知道下一个字符 (甚至接下来的几个字符) 是什么。 如果下一个字符是某个特定的值, 则它是当前短语 (token) 的一部分, 反之则可能不是。
例如, 您可能正对输入流进行语义串的词法分析 (例如, 实现某种语言的编译器): 如果一个字符跟着另一个字符或数字, 则后面这个字符是正在处理的短语的一部分。 如果这个字符后面是空格, 或某个其它的值, 则它就不是当前短语的一部分了。
这带来了一个很有意思的问题: 如何将接下来的这个字符送回输入流, 使之能够在稍后被重新读取?
一种可行的方法是将其保存到一个字符变量中, 并设置一个标志。 我们可以修改 getchar
使之检查这一标志, 如果这个标志被置位, 则从变量而非输入缓冲区中获取字符, 并复位标志。 不过, 很显然, 这会拖慢我们的脚步。
C 语言中有一个 ungetc()
函数, 它正是用于实现这个目的的。 那么, 有没有办法在我们的代码中迅速实现这一函数呢? 在阅读下一节之前, 希望您能回到前面, 并查看 getchar
过程, 并思考您是否能找到一个漂亮且快捷的解决方案。 接着回到这里来看看我的解法。
将字符放回流的关键是, 如何获取开始的字符:
首先, 我们会通过检查 EBX
的值来检测输入缓冲区是否为空。 如果它是零, 则调用 read
过程。
如果有可用的字符, 则使用 lodsb
, 接着对 EBX
的值做递减。 lodsb
指令实际上相当于:
mov al, [esi] inc esi
我们拿到的这个字节会保留在缓冲区中, 直到下次调用 read
为止。 尽管我们不知道这会在何时发生, 但我们知道它不会在下次 getchar
之前。 因此, 要将读取的最后一个字节 “还给” 流, 我们需要做的就是将 ESI
的值递减, 并使 EBX
的值递增:
ungetc: dec esi inc ebx ret
但是, 一定要小心! 如果我们每次只向前读取一个字符的话, 这样做没有任何问题。 但是如果我们会读取多个字符, 并多次调用 ungetc
的话, 有时就会出问题了 (而且会很难调试)。 为什么呢?
这是因为 getchar
并不必然调用 read
, 所有预先读入的字符仍会出于缓冲区, 而我们的 ungetc
也会毫无问题地运行。 但是如果 getchar
调用了 read
, 则缓冲区的内容就会随之变化。
我们可以认为 ungetc
一定能在使用 getchar
读入的最后一个字符上正确工作, 但在这之前的则没有任何保证。
如果您的程序需要一次读取多个字节, 则有两个选择:
如果可能的话, 修改程序使其一次只向前读最多一个字符。 这是最简单的办法。
如果没办法这样做, 首先要确定程序最多会向流交还多少各字符。 保险起见, 适当地增大这个数字, 最好是 16 的整数倍 ── 使其能够对齐。 接着, 修改您的代码中的 .bss
节, 并在输入缓冲区之前建立一个小的 “预留” 缓冲, 类似下面这样:
section .bss resb 16 ; or whatever the value you came up with ibuffer resb BUFSIZE obuffer resb BUFSIZE
您还需要修改 ungetc
以便将还回的字符放入 AL
:
ungetc: dec esi inc ebx mov [esi], al ret
经过这样的修改, 您就可以安全地调用最多 17 次 ungetc
了 (第一次调用仍会使用输入缓冲, 而余下的 16 次则有可能在输入缓冲, 也可能在 “预留缓冲” 中)。