kernel: int 80h ; Call kernel ret open: push dword mode push dword flags push dword path mov eax, 5 call kernel add esp, byte 12 ret
第 11 章。x86 汇编语言编程
目录
本章由 G. Adam Stanislav 撰写 <[email protected]>。
A.1. 概要
在 UNIX® 下进行汇编语言编程的文档很少。通常假设没有人会想要使用它,因为各种 UNIX® 系统运行在不同的微处理器上,所以所有内容都应该用 C 编写以实现可移植性。
实际上,C 的可移植性是一个神话。即使是 C 程序在从一个 UNIX® 移植到另一个 UNIX® 时也需要修改,无论每个 UNIX® 运行在什么处理器上。通常,这样的程序充满了根据其编译到的系统而定的条件语句。
即使我们相信所有 UNIX® 软件都应该用 C 或其他高级语言编写,我们仍然需要汇编语言程序员:谁来编写访问内核的 C 库部分?
在本章中,我将尝试向您展示如何使用汇编语言编写 UNIX® 程序,特别是在 FreeBSD 下。
本章不解释汇编语言的基础知识。关于这方面的资源已经足够了(有关汇编语言的完整在线课程,请参见 Randall Hyde 的 Art of Assembly Language;或者如果您更喜欢印刷书籍,请查看 Jeff Duntemann 的 Assembly Language Step-by-Step (ISBN: 0471375233)。但是,一旦本章结束,任何汇编语言程序员都将能够快速有效地为 FreeBSD 编写程序。
版权所有 ® 2000-2001 G. Adam Stanislav。保留所有权利。
A.2. 工具
A.2.1. 汇编器
汇编语言编程最重要的工具是汇编器,它将汇编语言代码转换为机器语言的软件。
FreeBSD 提供了三个截然不同的汇编器。 llvm-as(1)(包含在 devel/llvm 中)和 as(1)(包含在 devel/binutils 中)都使用传统的 UNIX® 汇编语言语法。
另一方面,nasm(1)(通过 devel/nasm 安装)使用 Intel 语法。它的主要优点是可以为许多操作系统汇编代码。
本章使用 nasm 语法,因为大多数从其他操作系统迁移到 FreeBSD 的汇编语言程序员会发现它更容易理解。而且,坦率地说,这就是我习惯使用的。
A.3. 系统调用
A.3.1. 默认调用约定
默认情况下,FreeBSD 内核使用 C 调用约定。此外,虽然内核是使用 int 80h
访问的,但假设程序会调用发出 int 80h
的函数,而不是直接发出 int 80h
。
这种约定非常方便,并且优于 MS-DOS® 使用的 Microsoft® 约定。为什么?因为 UNIX® 约定允许任何语言编写的任何程序访问内核。
汇编语言程序也可以这样做。例如,我们可以打开一个文件
这是一种非常简洁且可移植的编码方式。如果您需要将代码移植到使用不同中断或不同参数传递方式的 UNIX® 系统,您只需要更改内核过程即可。
但是汇编语言程序员喜欢削减周期。上面的示例需要 call/ret
组合。我们可以通过 push
一个额外的 dword 来消除它
open: push dword mode push dword flags push dword path mov eax, 5 push eax ; Or any other dword int 80h add esp, byte 16
我们放在 EAX
中的 5
标识了内核函数,在本例中为 open
。
A.3.2. 备用调用约定
FreeBSD 是一个非常灵活的系统。它提供了其他调用内核的方法。但是,要使其工作,系统必须安装 Linux 模拟。
Linux 是一个类似 UNIX® 的系统。但是,它的内核使用与 MS-DOS® 在寄存器中传递参数相同的系统调用约定。与 UNIX® 约定一样,函数号放在 EAX
中。但是,参数不是在堆栈上传递,而是在 EBX, ECX, EDX, ESI, EDI, EBP
中传递
open: mov eax, 5 mov ebx, path mov ecx, flags mov edx, mode int 80h
这种约定与 UNIX® 方式相比有一个很大的缺点,至少就汇编语言编程而言:每次进行内核调用时,都必须 push
寄存器,然后稍后 pop
它们。这使得您的代码更庞大且更慢。尽管如此,FreeBSD 还是给了您选择。
如果您确实选择了 Linux 约定,则必须让系统知道它。在程序汇编和链接后,您需要标记可执行文件
% brandelf -t Linux filename
A.3.3. 应该使用哪种约定?
如果您专门为 FreeBSD 编码,则应始终使用 UNIX® 约定:它更快,您可以将全局变量存储在寄存器中,您不必标记可执行文件,并且您不会强迫目标系统安装 Linux 模拟软件包。
如果您想创建可以在 Linux 上运行的可移植代码,您可能仍然希望为 FreeBSD 用户提供尽可能高效的代码。在解释了基础知识之后,我将向您展示如何实现这一点。
A.3.4. 调用号
要告诉内核您正在调用哪个系统服务,请将其编号放在 EAX
中。当然,您需要知道该编号是什么。
A.3.4.1. syscalls 文件
这些编号列在 syscalls 中。locate syscalls
在几种不同的格式中找到此文件,所有这些文件都是从 syscalls.master 自动生成的。
您可以在 /usr/src/sys/kern/syscalls.master 中找到默认 UNIX® 调用约定的主文件。如果您需要使用 Linux 模拟模式中实现的其他约定,请阅读 /usr/src/sys/i386/linux/syscalls.master。
FreeBSD 和 Linux 不仅使用不同的调用约定,有时还会对相同函数使用不同的编号。 |
syscalls.master 描述了如何进行系统调用
0 STD NOHIDE { int nosys(void); } syscall nosys_args int 1 STD NOHIDE { void exit(int rval); } exit rexit_args void 2 STD POSIX { int fork(void); } 3 STD POSIX { ssize_t read(int fd, void *buf, size_t nbyte); } 4 STD POSIX { ssize_t write(int fd, const void *buf, size_t nbyte); } 5 STD POSIX { int open(char *path, int flags, int mode); } 6 STD POSIX { int close(int fd); } etc...
最左边的列告诉我们应该将哪个数字放入EAX
寄存器中。
最右边的列告诉我们应该push
哪些参数。参数是从右到左push
入栈的。
例如,要open
一个文件,我们需要先push
入栈mode
,然后是flags
,最后是存储path
的地址。
A.4. 返回值
如果系统调用不返回某种值,那么大多数情况下它将毫无用处:例如,打开文件的描述符、读取到缓冲区的字节数、系统时间等等。
此外,系统需要通知我们是否发生了错误:例如,文件不存在、系统资源耗尽、传递了无效参数等等。
A.4.1. 手册页
在UNIX®系统下,查找各种系统调用信息的传统位置是手册页。FreeBSD在第2节中描述其系统调用,有时在第3节中描述。
例如,open(2) 表示
如果成功,open()
返回一个非负整数,称为文件描述符。如果失败,则返回-1
,并设置errno
以指示错误。
刚接触UNIX®和FreeBSD的汇编语言程序员会立即提出一个令人费解的问题:errno
在哪里,如何访问它?
手册页中提供的信息适用于C程序。汇编语言程序员需要额外的信息。 |
A.4.2. 返回值在哪里?
不幸的是,这取决于……对于大多数系统调用,返回值在EAX
中,但并非所有系统调用都如此。一个好的经验法则是,在第一次使用系统调用时,先在EAX
中查找返回值。如果不在那里,则需要进一步研究。
我知道有一个系统调用将返回值放在 |
如果在这里或其他任何地方都找不到答案,请研究libc源代码,看看它是如何与内核交互的。 |
A.5. 创建可移植代码
可移植性通常不是汇编语言的优势之一。然而,为不同平台编写汇编语言程序是可能的,尤其是在使用nasm的情况下。我编写了可以在Windows®和FreeBSD等不同操作系统上汇编的汇编语言库。
当您希望代码在两个虽然不同但基于类似架构的平台上运行时,这更加可能。
例如,FreeBSD是UNIX®,Linux是类UNIX®系统。我只提到了它们之间三个差异(从汇编语言程序员的角度):调用约定、函数编号和返回值的方式。
A.5.1. 处理函数编号
在许多情况下,函数编号是相同的。但是,即使它们不同,这个问题也很容易解决:不要在代码中使用数字,而是使用常量,根据目标架构的不同,您对这些常量进行不同的声明。
%ifdef LINUX %define SYS_execve 11 %else %define SYS_execve 59 %endif
A.5.2. 处理约定
调用约定和返回值(errno
问题)都可以使用宏来解决。
%ifdef LINUX %macro system 0 call kernel %endmacro align 4 kernel: push ebx push ecx push edx push esi push edi push ebp mov ebx, [esp+32] mov ecx, [esp+36] mov edx, [esp+40] mov esi, [esp+44] mov ebp, [esp+48] int 80h pop ebp pop edi pop esi pop edx pop ecx pop ebx or eax, eax js .errno clc ret .errno: neg eax stc ret %else %macro system 0 int 80h %endmacro %endif
A.5.3. 处理其他可移植性问题
上述解决方案可以处理大多数在FreeBSD和Linux之间编写可移植代码的情况。然而,对于某些内核服务,差异更深。
在这种情况下,您需要为这些特定的系统调用编写两个不同的处理程序,并使用条件汇编。幸运的是,您的大部分代码执行的操作都与调用内核无关,因此通常您只需要在代码中添加几个这样的条件部分。
A.5.4. 使用库
您可以通过编写系统调用的库来完全避免主代码中的可移植性问题。为FreeBSD创建一个单独的库,为Linux创建一个不同的库,并为更多操作系统创建其他库。
在您的库中,为每个系统调用编写一个单独的函数(或过程,如果您更喜欢传统的汇编语言术语)。使用C的调用约定传递参数。但仍然使用EAX
传递调用号。在这种情况下,您的FreeBSD库可以非常简单,因为许多看似不同的函数可以仅仅是同一个代码的标签。
sys.open: sys.close: [etc...] int 80h ret
您的Linux库将需要更多不同的函数。但即使在这里,您也可以根据参数数量对系统调用进行分组。
sys.exit: sys.close: [etc... one-parameter functions] push ebx mov ebx, [esp+12] int 80h pop ebx jmp sys.return ... sys.return: or eax, eax js sys.err clc ret sys.err: neg eax stc ret
库方法最初可能看起来不方便,因为它要求您生成代码依赖的单独文件。但它有很多优点:首先,您只需要编写一次,就可以在所有程序中使用它。您甚至可以允许其他汇编语言程序员使用它,或者也许使用其他人编写的库。但也许库最大的优点是,您的代码可以移植到其他系统,甚至可以由其他程序员移植,只需编写一个新的库,而无需对您的代码进行任何更改。
如果您不喜欢库的想法,您至少可以将所有系统调用放在一个单独的汇编语言文件中,并将其与您的主程序链接。在这里,所有移植人员需要做的就是创建一个新的目标文件来链接您的主程序。
A.5.5. 使用包含文件
如果您以(或与)源代码的形式发布软件,则可以使用宏并将它们放在一个单独的文件中,然后在代码中包含该文件。
您的软件的移植人员只需编写一个新的包含文件。不需要库或外部目标文件,但您的代码是可移植的,无需编辑代码。
这将是我们在本章中使用的方法。我们将命名我们的包含文件为system.inc,并在处理新的系统调用时向其中添加内容。 |
我们可以从声明标准文件描述符开始我们的system.inc
%define stdin 0 %define stdout 1 %define stderr 2
接下来,我们为每个系统调用创建一个符号名称。
%define SYS_nosys 0 %define SYS_exit 1 %define SYS_fork 2 %define SYS_read 3 %define SYS_write 4 ; [etc...]
我们添加一个简短的、非全局的过程,并使用一个长名称,这样我们不会在代码中意外地重复使用该名称。
section .text align 4 access.the.bsd.kernel: int 80h ret
我们创建一个宏,它接受一个参数,即系统调用号。
%macro system 1 mov eax, %1 call access.the.bsd.kernel %endmacro
最后,我们为每个系统调用创建宏。这些宏不接受任何参数。
%macro sys.exit 0 system SYS_exit %endmacro %macro sys.fork 0 system SYS_fork %endmacro %macro sys.read 0 system SYS_read %endmacro %macro sys.write 0 system SYS_write %endmacro ; [etc...]
继续操作,将其输入到编辑器中并将其保存为system.inc。在讨论更多系统调用时,我们将向其中添加更多内容。
A.6. 我们的第一个程序
现在我们准备编写第一个程序,也就是必须的Hello, World!程序。
%include 'system.inc' section .data hello db 'Hello, World!', 0Ah hbytes equ $-hello section .text global _start _start: push dword hbytes push dword hello push dword stdout sys.write push dword 0 sys.exit
以下是它的作用:第1行包含来自system.inc的定义、宏和代码。
第3-5行是数据:第3行开始数据段/节。第4行包含字符串“Hello, World!”后跟一个换行符(0Ah
)。第5行创建一个常量,该常量以字节为单位包含第4行字符串的长度。
第7-16行包含代码。请注意,FreeBSD在其可执行文件中使用elf文件格式,这要求每个程序都从标记为_start
的点开始(或者更准确地说,链接器期望如此)。此标签必须是全局的。
第10-13行请求系统将hello
字符串的hbytes
字节写入stdout
。
第15-16行请求系统以0
的返回值结束程序。SYS_exit
系统调用永远不会返回,因此代码在那里结束。
如果您是从MS-DOS®汇编语言背景转向UNIX®,您可能习惯于直接写入视频硬件。在FreeBSD或任何其他版本的UNIX®中,您将永远不必担心这个问题。就您而言,您正在写入一个名为stdout的文件。这可以是视频屏幕、telnet终端、实际文件,甚至另一个程序的输入。它是哪一个,由系统来确定。 |
A.6.1. 汇编代码
在编辑器中键入代码,并将其保存到名为hello.asm的文件中。您需要使用nasm来汇编它。
A.6.1.1. 安装nasm
如果您没有nasm,请键入
% su
Password:your root password
# cd /usr/ports/devel/nasm
# make install
# exit
%
如果您不想保留nasm源代码,可以键入make install clean
而不是make install
。
无论哪种方式,FreeBSD都会自动从Internet下载nasm,对其进行编译,并将其安装到您的系统上。
如果您的系统不是FreeBSD,则需要从其主页获取nasm。您仍然可以使用它来汇编FreeBSD代码。 |
现在您可以汇编、链接和运行代码了。
% nasm -f elf hello.asm
% ld -s -o hello hello.o
% ./hello
Hello, World!
%
A.7. 编写UNIX®过滤器
UNIX®应用程序的一种常见类型是过滤器——一个从stdin读取数据、以某种方式处理数据,然后将结果写入stdout的程序。
在本章中,我们将开发一个简单的过滤器,并学习如何从stdin读取数据并写入stdout。此过滤器将把其输入的每个字节转换为十六进制数,后跟一个空格。
%include 'system.inc' section .data hex db '0123456789ABCDEF' buffer db 0, 0, ' ' section .text global _start _start: ; read a byte from stdin push dword 1 push dword buffer push dword stdin sys.read add esp, byte 12 or eax, eax je .done ; convert it to hex movzx eax, byte [buffer] mov edx, eax shr dl, 4 mov dl, [hex+edx] mov [buffer], dl and al, 0Fh mov al, [hex+eax] mov [buffer+1], al ; print it push dword 3 push dword buffer push dword stdout sys.write add esp, byte 12 jmp short _start .done: push dword 0 sys.exit
在数据段中,我们创建一个名为hex
的数组。它包含按升序排列的16个十六进制数字。数组后跟一个缓冲区,我们将同时用于输入和输出。缓冲区的最初两个字节设置为0
。我们将在这里写入两个十六进制数字(第一个字节也是我们读取输入的地方)。第三个字节是一个空格。
代码段由四个部分组成:读取字节、将其转换为十六进制数、写入结果,以及最终退出程序。
要读取字节,我们请求系统从stdin读取一个字节,并将其存储在buffer
的第一个字节中。系统在EAX
中返回读取的字节数。在有数据到达时,这将是1
,在没有更多输入数据可用时,这将是0
。因此,我们检查EAX
的值。如果为0
,则跳转到.done
,否则继续。
为简单起见,我们目前忽略了错误情况的可能性。 |
十六进制转换将buffer
中的字节读入EAX
,或者实际上只是AL
,同时将EAX
的其余位清零。我们还将字节复制到EDX
,因为我们需要分别转换高四位(nibble)和低四位。我们将结果存储在缓冲区的最初两个字节中。
接下来,我们要求系统将缓冲区的三个字节(即两个十六进制数字和一个空格)写入stdout。然后我们跳回程序开头,处理下一个字节。
一旦没有更多输入,我们就要求系统退出我们的程序,返回零,这是程序成功执行的传统值。
继续,将代码保存在名为hex.asm的文件中,然后键入以下内容(^D
表示按住Ctrl键并输入D
)
% 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 %
如果您是从MS-DOS®迁移到UNIX®,您可能想知道为什么每行以 |
我们能改进这一点吗?好吧,一方面,它有点令人困惑,因为一旦我们转换了一行文本,我们的输入就不再从行的开头开始。我们可以修改它,在每个0A
之后打印一个新行而不是空格。
%include 'system.inc' section .data hex db '0123456789ABCDEF' buffer db 0, 0, ' ' section .text global _start _start: mov cl, ' ' .loop: ; read a byte from stdin push dword 1 push dword buffer push dword stdin sys.read add esp, byte 12 or eax, eax je .done ; convert it to hex movzx eax, byte [buffer] mov [buffer+2], cl cmp al, 0Ah jne .hex mov [buffer+2], al .hex: mov edx, eax shr dl, 4 mov dl, [hex+edx] mov [buffer], dl and al, 0Fh mov al, [hex+eax] mov [buffer+1], al ; print it push dword 3 push dword buffer push dword stdout sys.write add esp, byte 12 jmp short .loop .done: push dword 0 sys.exit
我们已将空格存储在CL
寄存器中。我们可以安全地执行此操作,因为与Microsoft® Windows®不同,UNIX®系统调用不会修改任何它们不使用以返回值的寄存器的值。
这意味着我们只需要设置一次CL
。因此,我们添加了一个新的标签.loop
,并跳转到它以处理下一个字节,而不是跳转到_start
。我们还添加了.hex
标签,以便我们可以将空格或换行符作为buffer
的第三个字节。
更改hex.asm以反映这些更改后,键入
% 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 %
看起来好多了。但是这段代码效率很低!我们对每个字节都进行两次系统调用(一次读取,另一次写入输出)。
A.8. 带缓冲的输入和输出
我们可以通过缓冲输入和输出来提高代码效率。我们创建一个输入缓冲区,并一次读取整个字节序列。然后我们逐个从缓冲区中获取它们。
我们还创建一个输出缓冲区。我们将输出存储在其中,直到它填满。此时,我们要求内核将缓冲区的内容写入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
部分的全局变量。这也是UNIX®将参数传递给系统调用的栈约定优于Microsoft将参数传递给寄存器的约定的原因:我们可以将寄存器保留供自己使用。
我们使用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
才打印输出。这很容易修复,只需插入三行代码,以便每次我们将新行转换为0A
时都写入输出。我已经用>标记了这三行(不要将>复制到您的hex.asm中)。
%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 %
对于一个644字节的可执行文件来说还不错,不是吗!
这种带缓冲的输入/输出方法仍然存在隐藏的危险。我将在稍后讨论并修复它,届时我会谈到缓冲的阴暗面。 |
A.8.1. 如何撤消读取字符
这可能是一个有点高级的话题,主要对熟悉编译器理论的程序员感兴趣。如果您愿意,可以跳到下一节,或许以后再阅读本节。 |
虽然我们的示例程序不需要它,但更复杂的过滤器通常需要向前看。换句话说,它们可能需要查看下一个字符是什么(甚至几个字符)。如果下一个字符具有特定值,则它是当前正在处理的标记的一部分。否则,它不是。
例如,您可能正在为文本字符串解析输入流(例如,在实现语言编译器时):如果一个字符后跟另一个字符,或者可能是数字,则它是您正在处理的标记的一部分。如果它后跟空格或其他一些值,则它不是当前标记的一部分。
这提出了一个有趣的问题:如何将下一个字符返回到输入流,以便稍后可以再次读取它?
一种可能的解决方案是将其存储在一个字符变量中,然后设置一个标志。我们可以修改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
通过此修改,您可以连续安全地调用ungetc
最多17次(第一次调用仍然在缓冲区内,其余16次可能在缓冲区内或“备用”缓冲区内)。
A.9. 命令行参数
如果我们的hex程序可以从其命令行读取输入和输出文件的文件名,即如果它可以处理命令行参数,它将更有用。但是……它们在哪里?
在UNIX®系统启动程序之前,它会在栈上push
一些数据,然后跳转到程序的_start
标签。是的,我说的是跳转,而不是调用。这意味着可以通过读取[esp+offset]
或简单地pop
来访问数据。
栈顶的值包含命令行参数的数量。它传统上称为argc
,“参数计数”。
命令行参数紧随其后,所有argc
个。这些通常被称为argv
,“参数值”。也就是说,我们得到argv[0]
、argv[1]
、……、argv[argc-1]
。这些不是实际的参数,而是指向参数的指针,即实际参数的内存地址。参数本身是NUL终止的字符字符串。
argv
列表后跟一个NULL指针,它只是一个0
。还有更多内容,但对于我们现在而言,这已经足够了。
如果您来自MS-DOS®编程环境,主要区别在于每个参数都在一个单独的字符串中。第二个区别是没有关于可以有多少个参数的实际限制。 |
有了这些知识,我们几乎准备好进行hex.asm的下一个版本了。但是,首先,我们需要在system.inc中添加几行。
首先,我们需要在系统调用号列表中添加两个新条目。
%define SYS_open 5 %define SYS_close 6
然后我们在文件末尾添加两个新的宏。
%macro sys.open 0 system SYS_open %endmacro %macro sys.close 0 system SYS_close %endmacro
然后,这是我们修改后的源代码。
%include 'system.inc' %define BUFSIZE 2048 section .data fd.in dd stdin fd.out dd stdout hex db '0123456789ABCDEF' section .bss ibuffer resb BUFSIZE obuffer resb BUFSIZE section .text align 4 err: push dword 1 ; return failure sys.exit align 4 global _start _start: add esp, byte 8 ; discard argc and argv[0] pop ecx jecxz .init ; no more arguments ; ECX contains the path to input file push dword 0 ; O_RDONLY push ecx sys.open jc err ; open failed add esp, byte 8 mov [fd.in], eax pop ecx jecxz .init ; no more arguments ; ECX contains the path to output file push dword 420 ; file mode (644 octal) push dword 0200h | 0400h | 01h ; O_CREAT | O_TRUNC | O_WRONLY push ecx sys.open jc err add esp, byte 12 mov [fd.out], eax .init: sub eax, eax sub ebx, ebx sub ecx, ecx mov edi, obuffer .loop: ; read a byte from input file or 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, dl 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 [fd.in] 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 ; close files push dword [fd.in] sys.close push dword [fd.out] sys.close ; return success 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 [fd.out] sys.write add esp, byte 12 sub eax, eax sub ecx, ecx ; buffer is empty now ret
在我们的.data
段中,现在有两个新的变量,fd.in
和fd.out
。我们在这里存储输入和输出文件描述符。
在.text
段中,我们用[fd.in]
和[fd.out]
替换了对stdin
和stdout
的引用。
.text
段现在以一个简单的错误处理程序开始,它除了以返回值1
退出程序之外什么也不做。错误处理程序在_start
之前,因此我们距离错误发生的地方很近。
当然,程序执行仍然从_start
开始。首先,我们从堆栈中移除argc
和argv[0]
:它们对我们来说没有兴趣(在这个程序中是这样)。
我们将argv[1]
弹出到ECX
。这个寄存器特别适合指针,因为我们可以用jecxz
处理空指针。如果argv[1]
不为空,我们尝试打开第一个参数中命名的文件。否则,我们像以前一样继续程序:从stdin
读取,写入stdout
。如果我们无法打开输入文件(例如,它不存在),我们将跳转到错误处理程序并退出。
如果一切顺利,我们现在检查第二个参数。如果它存在,我们打开输出文件。否则,我们将输出发送到stdout
。如果我们无法打开输出文件(例如,它存在并且我们没有写权限),我们再次跳转到错误处理程序。
其余代码与以前相同,除了我们在退出之前关闭输入和输出文件,并且如前所述,我们使用[fd.in]
和[fd.out]
。
我们的可执行文件现在长达768字节。
我们还能改进它吗?当然!每个程序都可以改进。以下是一些我们可以做的事情的想法
让我们的错误处理程序向
stderr
打印一条消息。为
read
和write
函数添加错误处理程序。当我们打开输入文件时关闭
stdin
,当我们打开输出文件时关闭stdout
。添加命令行开关,例如
-i
和-o
,以便我们可以按任何顺序列出输入和输出文件,或者可能从stdin
读取并写入文件。如果命令行参数不正确,则打印使用信息。
我将把这些增强作为读者的练习:您已经知道实现它们所需的一切。
A.10. UNIX® 环境
一个重要的UNIX®概念是环境,它由环境变量定义。有些由系统设置,有些由您设置,还有一些由shell或任何加载其他程序的程序设置。
A.10.1. 如何查找环境变量
我之前说过,当程序开始执行时,堆栈包含argc
,后面跟着以NULL结尾的argv
数组,然后是其他内容。“其他内容”是环境,或者更准确地说,是指向环境变量的以NULL结尾的指针数组。这通常称为env
。
env
的结构与argv
相同,是一个内存地址列表,后面跟着一个NULL(0
)。在这种情况下,没有"envc"
——我们通过搜索最后的NULL来确定数组的结束位置。
变量通常采用name=value
格式,但有时=value
部分可能缺失。我们需要考虑这种可能性。
A.10.2. webvars
我可以向您展示一些代码,这些代码以与UNIX® env命令相同的方式打印环境。但我认为编写一个简单的汇编语言CGI实用程序会更有趣。
A.10.2.1. CGI:快速概述
我的网站上有一个详细的CGI教程,但这里有一个关于CGI的非常快速的概述
Web服务器通过设置环境变量与CGI程序通信。
CGI程序将其输出发送到stdout。Web服务器从那里读取它。
它必须以HTTP标头开头,后面跟着两行空行。
然后它打印HTML代码,或它正在生成的其他任何类型的数据。
虽然某些环境变量使用标准名称,但其他变量则有所不同,具体取决于Web服务器。这使得webvars成为一个非常有用的诊断工具。 |
A.10.2.2. 代码
然后,我们的webvars程序必须发送HTTP标头,后面跟着一些HTML标记。然后它必须逐个读取环境变量并将它们作为HTML页面的一部分发送出去。
代码如下。我在代码中直接添加了注释和解释
;;;;;;; webvars.asm ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; ; Copyright (c) 2000 G. Adam Stanislav ; All rights reserved. ; ; Redistribution and use in source and binary forms, with or without ; modification, are permitted provided that the following conditions ; are met: ; 1. Redistributions of source code must retain the above copyright ; notice, this list of conditions and the following disclaimer. ; 2. Redistributions in binary form must reproduce the above copyright ; notice, this list of conditions and the following disclaimer in the ; documentation and/or other materials provided with the distribution. ; ; THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND ; ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE ; IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ; ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE ; FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL ; DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS ; OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) ; HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT ; LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY ; OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF ; SUCH DAMAGE. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; ; Version 1.0 ; ; Started: 8-Dec-2000 ; Updated: 8-Dec-2000 ; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; %include 'system.inc' section .data http db 'Content-type: text/html', 0Ah, 0Ah db '<?xml version="1.0" encoding="utf-8"?>', 0Ah db '<!DOCTYPE html PUBLIC "-//W3C/DTD XHTML Strict//EN" ' db '"DTD/xhtml1-strict.dtd">', 0Ah db '<html xmlns="http://www.w3.org/1999/xhtml" ' db 'xml.lang="en" lang="en">', 0Ah db '<head>', 0Ah db '<title>Web Environment</title>', 0Ah db '<meta name="author" content="G. Adam Stanislav" />', 0Ah db '</head>', 0Ah, 0Ah db '<body bgcolor="#ffffff" text="#000000" link="#0000ff" ' db 'vlink="#840084" alink="#0000ff">', 0Ah db '<div class="webvars">', 0Ah db '<h1>Web Environment</h1>', 0Ah db '<p>The following <b>environment variables</b> are defined ' db 'on this web server:</p>', 0Ah, 0Ah db '<table align="center" width="80" border="0" cellpadding="10" ' db 'cellspacing="0" class="webvars">', 0Ah httplen equ $-http left db '<tr>', 0Ah db '<td class="name"><tt>' leftlen equ $-left middle db '</tt></td>', 0Ah db '<td class="value"><tt><b>' midlen equ $-middle undef db '<i>(undefined)</i>' undeflen equ $-undef right db '</b></tt></td>', 0Ah db '</tr>', 0Ah rightlen equ $-right wrap db '</table>', 0Ah db '</div>', 0Ah db '</body>', 0Ah db '</html>', 0Ah, 0Ah wraplen equ $-wrap section .text global _start _start: ; First, send out all the http and xhtml stuff that is ; needed before we start showing the environment push dword httplen push dword http push dword stdout sys.write ; Now find how far on the stack the environment pointers ; are. We have 12 bytes we have pushed before "argc" mov eax, [esp+12] ; We need to remove the following from the stack: ; ; The 12 bytes we pushed for sys.write ; The 4 bytes of argc ; The EAX*4 bytes of argv ; The 4 bytes of the NULL after argv ; ; Total: ; 20 + eax * 4 ; ; Because stack grows down, we need to ADD that many bytes ; to ESP. lea esp, [esp+20+eax*4] cld ; This should already be the case, but let's be sure. ; Loop through the environment, printing it out .loop: pop edi or edi, edi ; Done yet? je near .wrap ; Print the left part of HTML push dword leftlen push dword left push dword stdout sys.write ; It may be tempting to search for the '=' in the env string next. ; But it is possible there is no '=', so we search for the ; terminating NUL first. mov esi, edi ; Save start of string sub ecx, ecx not ecx ; ECX = FFFFFFFF sub eax, eax repne scasb not ecx ; ECX = string length + 1 mov ebx, ecx ; Save it in EBX ; Now is the time to find '=' mov edi, esi ; Start of string mov al, '=' repne scasb not ecx add ecx, ebx ; Length of name push ecx push esi push dword stdout sys.write ; Print the middle part of HTML table code push dword midlen push dword middle push dword stdout sys.write ; Find the length of the value not ecx lea ebx, [ebx+ecx-1] ; Print "undefined" if 0 or ebx, ebx jne .value mov ebx, undeflen mov edi, undef .value: push ebx push edi push dword stdout sys.write ; Print the right part of the table row push dword rightlen push dword right push dword stdout sys.write ; Get rid of the 60 bytes we have pushed add esp, byte 60 ; Get the next variable jmp .loop .wrap: ; Print the rest of HTML push dword wraplen push dword wrap push dword stdout sys.write ; Return success push dword 0 sys.exit
此代码生成一个1,396字节的可执行文件。其中大部分是数据,即我们需要发送的HTML标记。
像往常一样汇编和链接它
% nasm -f elf webvars.asm
% ld -s -o webvars webvars.o
要使用它,您需要将webvars上传到您的Web服务器。根据Web服务器的设置方式,您可能需要将其存储在特殊的cgi-bin目录中,或者可能将其重命名为.cgi扩展名。
然后,您需要使用浏览器查看其输出。要查看我的Web服务器上的输出,请访问http://www.int80h.org/webvars/。如果您好奇密码保护的Web目录中存在哪些其他环境变量,请访问http://www.int80h.org/private/,使用名称asm
和密码programmer
。
A.11. 使用文件
我们已经完成了一些基本的文件工作:我们知道如何打开和关闭它们,如何使用缓冲区读取和写入它们。但是,在处理文件时,UNIX®提供了更多功能。我们将在本节中检查其中的一些内容,并最终获得一个不错的文件转换实用程序。
确实,让我们从最后开始,也就是从文件转换实用程序开始。当我们从一开始就知道最终产品应该做什么时,编程总是更容易。
我为UNIX®编写的第一个程序之一是tuc,一个文本到UNIX®文件的转换器。它将来自其他操作系统的文本文件转换为UNIX®文本文件。换句话说,它将不同类型的行尾更改为UNIX®的新行约定。它将输出保存在不同的文件中。可以选择将UNIX®文本文件转换为DOS文本文件。
我广泛使用过tuc,但始终只用于从某些其他操作系统转换为UNIX®,而不是反过来。我一直希望它只需覆盖文件,而无需我将输出发送到不同的文件。大多数时候,我最终这样使用它
% tuc myfile tempfile
% mv tempfile myfile
最好有一个ftuc,即快速tuc,并像这样使用它
% ftuc myfile
因此,在本章中,我们将用汇编语言编写ftuc(原始的tuc是用C编写的),并在此过程中学习各种面向文件的内核服务。
乍一看,这样的文件转换非常简单:您只需要去除回车符,对吧?
如果您回答是,请再想一想:这种方法大多数时候都能奏效(至少对于MS DOS文本文件而言),但偶尔会失败。
问题在于,并非所有非UNIX®文本文件都以回车符/换行符序列结尾。有些使用回车符而不使用换行符。其他一些将多行空白行组合成一个回车符,后面跟着几个换行符。等等。
因此,文本文件转换器必须能够处理任何可能的行尾
回车符/换行符
回车符
换行符/回车符
换行符
它还应该处理使用某种上述组合的文件(例如,回车符后面跟着几个换行符)。
A.11.1. 有限状态机
这个问题可以通过使用一种称为有限状态机的技术轻松解决,该技术最初由数字电子电路的设计者开发。有限状态机是一种数字电路,其输出不仅取决于其输入,还取决于其先前的输入,即其状态。微处理器是有限状态机的一个例子:我们的汇编语言代码被汇编成机器语言,其中一些汇编语言代码生成一个字节的机器语言,而其他一些则生成多个字节。当微处理器逐个从内存中获取字节时,其中一些字节只是改变其状态而不是产生某些输出。当获取到操作码的所有字节后,微处理器会产生一些输出,或者更改寄存器的值,等等。
因此,所有软件本质上都是微处理器的一系列状态指令。尽管如此,有限状态机的概念在软件设计中也很有用。
我们的文本文件转换器可以设计为一个具有三种可能状态的有限状态机。我们可以称它们为状态0-2,但如果我们为它们赋予符号名称,则会使我们的生活更轻松
普通
cr
lf
我们的程序将从普通状态开始。在此状态下,程序操作取决于其输入,如下所示
如果输入不是回车符或换行符,则该输入将简单地传递到输出。状态保持不变。
如果输入是回车符,则状态将更改为cr。然后丢弃输入,即不产生输出。
如果输入是换行符,则状态将更改为lf。然后丢弃输入。
无论何时处于cr状态,都是因为最后一个输入是回车符,该回车符未处理。我们的软件在此状态下的操作再次取决于当前输入
如果输入不是回车符或换行符,则输出换行符,然后输出输入,然后将状态更改为普通。
如果输入是回车符,则我们已经连续收到两个(或更多)回车符。我们丢弃输入,输出换行符,并保持状态不变。
如果输入是换行符,则输出换行符并将状态更改为普通。请注意,这与上面第一个情况不同——如果我们尝试将它们组合起来,我们将输出两个换行符而不是一个。
最后,在我们收到一个未以回车符开头的换行符后,我们将处于lf状态。当我们的文件已经采用UNIX®格式时,或者当连续多行由一个回车符后跟几个换行符表示时,或者当行以换行符/回车符序列结尾时,就会发生这种情况。以下是我们需要在此状态下处理输入的方式
如果输入不是回车符或换行符,则输出换行符,然后输出输入,然后将状态更改为普通。这与在cr状态下收到相同类型的输入时的操作完全相同。
如果输入是回车符,则我们丢弃输入,输出换行符,然后将状态更改为普通。
如果输入是换行符,则输出换行符,并保持状态不变。
A.11.2. 在软件中实现 FSM
使用有限状态机最困难的部分是分析问题并将其表示为有限状态机。一旦完成,软件几乎可以自行编写。
在高级语言(如 C)中,有几种主要方法。一种是使用switch
语句来选择应该运行哪个函数。例如,
switch (state) { default: case REGULAR: regular(inputchar); break; case CR: cr(inputchar); break; case LF: lf(inputchar); break; }
另一种方法是使用函数指针数组,类似于这样
(output[state])(inputchar);
另一种方法是让state
成为一个函数指针,指向相应的函数
(*state)(inputchar);
这将是我们程序中使用的方法,因为它在汇编语言中非常容易实现,而且速度很快。我们只需将正确过程的地址保存在EBX
中,然后只需发出
call ebx
这可能比在代码中硬编码地址更快,因为微处理器不必从内存中获取地址——它已经存储在其寄存器之一中。我说可能是因为现代微处理器进行缓存,无论哪种方式都可能同样快。
A.11.3. 内存映射文件
因为我们的程序处理单个文件,所以我们不能使用之前对我们有效的那个方法,即从输入文件读取并写入输出文件。
UNIX®允许我们将文件或文件的一部分映射到内存中。为此,我们首先需要使用适当的读/写标志打开文件。然后,我们使用mmap
系统调用将其映射到内存中。mmap
的一个优点是它可以自动与虚拟内存一起工作:我们可以将比物理内存更多的文件映射到内存中,但仍然可以通过常规内存操作码(如mov
、lods
和stos
)访问它。我们对文件内存映像所做的任何更改都将由系统写入文件。我们甚至不必保持文件打开状态:只要它保持映射状态,我们就可以读取和写入它。
32位英特尔微处理器可以访问多达4GB的内存——物理内存或虚拟内存。FreeBSD系统允许我们使用最多一半的内存进行文件映射。
为简单起见,在本教程中,我们只转换可以完整映射到内存中的文件。可能没有太多文本文件的大小超过2GB。如果我们的程序遇到一个,它将简单地显示一条消息,建议我们使用原始的 tuc。
如果您检查您的syscalls.master副本,您会发现两个名为mmap
的单独系统调用。这是因为UNIX®的演变:有传统的BSD mmap
,系统调用71。那个被POSIX® mmap
,系统调用197取代了。FreeBSD系统同时支持两者,因为旧程序是使用原始的BSD版本编写的。但新的软件使用POSIX®版本,这就是我们将要使用的版本。
syscalls.master列出了POSIX®版本,如下所示
197 STD BSD { caddr_t mmap(caddr_t addr, size_t len, int prot, \ int flags, int fd, long pad, off_t pos); }
区别在于long pad
参数,它在C版本中不存在。但是,FreeBSD系统调用在push
一个64位参数后添加了一个32位的填充。在这种情况下,off_t
是一个64位值。
当我们完成对内存映射文件的操作后,我们使用munmap
系统调用取消映射它
有关 |
A.11.4. 确定文件大小
因为我们需要告诉mmap
将文件多少字节映射到内存中,并且因为我们希望映射整个文件,所以我们需要确定文件的大小。
我们可以使用fstat
系统调用获取系统可以提供给我们的有关打开文件的全部信息。其中包括文件大小。
同样,syscalls.master列出了两个版本的fstat
,一个传统的(系统调用62)和一个POSIX®的(系统调用189)。自然,我们将使用POSIX®版本
189 STD POSIX { int fstat(int fd, struct stat *sb); }
这是一个非常简单的调用:我们将stat
结构的地址和打开文件的描述符传递给它。它将填写stat
结构的内容。
但是,我必须说我尝试在.bss
段中声明stat
结构,fstat
不喜欢它:它设置了进位标志,表示错误。在我将代码更改为在堆栈上分配结构后,一切正常。
A.11.5. 更改文件大小
因为我们的程序可能会将回车/换行序列组合成直的换行符,所以我们的输出可能比输入小。但是,由于我们将输出放置到我们读取输入的同一个文件中,因此我们可能需要更改文件的大小。
ftruncate
系统调用允许我们做到这一点。尽管名称有些误导,但ftruncate
系统调用既可以用来截断文件(使其变小),也可以用来扩展文件。
是的,我们将在syscalls.master中找到两个版本的ftruncate
,一个旧的(130)和一个新的(201)。我们将使用新的那个
201 STD BSD { int ftruncate(int fd, int pad, off_t length); }
请注意,这个也包含一个int pad
。
A.11.6. ftuc
现在我们知道了编写ftuc所需的一切。我们首先在system.inc中添加一些新行。首先,我们在文件开头或附近定义一些常量和结构
;;;;;;; open flags %define O_RDONLY 0 %define O_WRONLY 1 %define O_RDWR 2 ;;;;;;; mmap flags %define PROT_NONE 0 %define PROT_READ 1 %define PROT_WRITE 2 %define PROT_EXEC 4 ;; %define MAP_SHARED 0001h %define MAP_PRIVATE 0002h ;;;;;;; stat structure struc stat st_dev resd 1 ; = 0 st_ino resd 1 ; = 4 st_mode resw 1 ; = 8, size is 16 bits st_nlink resw 1 ; = 10, ditto st_uid resd 1 ; = 12 st_gid resd 1 ; = 16 st_rdev resd 1 ; = 20 st_atime resd 1 ; = 24 st_atimensec resd 1 ; = 28 st_mtime resd 1 ; = 32 st_mtimensec resd 1 ; = 36 st_ctime resd 1 ; = 40 st_ctimensec resd 1 ; = 44 st_size resd 2 ; = 48, size is 64 bits st_blocks resd 2 ; = 56, ditto st_blksize resd 1 ; = 64 st_flags resd 1 ; = 68 st_gen resd 1 ; = 72 st_lspare resd 1 ; = 76 st_qspare resd 4 ; = 80 endstruc
我们定义新的系统调用
%define SYS_mmap 197 %define SYS_munmap 73 %define SYS_fstat 189 %define SYS_ftruncate 201
我们添加用于它们使用的宏
%macro sys.mmap 0 system SYS_mmap %endmacro %macro sys.munmap 0 system SYS_munmap %endmacro %macro sys.ftruncate 0 system SYS_ftruncate %endmacro %macro sys.fstat 0 system SYS_fstat %endmacro
这是我们的代码
;;;;;;; Fast Text-to-Unix Conversion (ftuc.asm) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Started: 21-Dec-2000 ;; Updated: 22-Dec-2000 ;; ;; Copyright 2000 G. Adam Stanislav. ;; All rights reserved. ;; ;;;;;;; v.1 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; %include 'system.inc' section .data db 'Copyright 2000 G. Adam Stanislav.', 0Ah db 'All rights reserved.', 0Ah usg db 'Usage: ftuc filename', 0Ah usglen equ $-usg co db "ftuc: Can't open file.", 0Ah colen equ $-co fae db 'ftuc: File access error.', 0Ah faelen equ $-fae ftl db 'ftuc: File too long, use regular tuc instead.', 0Ah ftllen equ $-ftl mae db 'ftuc: Memory allocation error.', 0Ah maelen equ $-mae section .text align 4 memerr: push dword maelen push dword mae jmp short error align 4 toolong: push dword ftllen push dword ftl jmp short error align 4 facerr: push dword faelen push dword fae jmp short error align 4 cantopen: push dword colen push dword co jmp short error align 4 usage: push dword usglen push dword usg error: push dword stderr sys.write push dword 1 sys.exit align 4 global _start _start: pop eax ; argc pop eax ; program name pop ecx ; file to convert jecxz usage pop eax or eax, eax ; Too many arguments? jne usage ; Open the file push dword O_RDWR push ecx sys.open jc cantopen mov ebp, eax ; Save fd sub esp, byte stat_size mov ebx, esp ; Find file size push ebx push ebp ; fd sys.fstat jc facerr mov edx, [ebx + st_size + 4] ; File is too long if EDX != 0 ... or edx, edx jne near toolong mov ecx, [ebx + st_size] ; ... or if it is above 2 GB or ecx, ecx js near toolong ; Do nothing if the file is 0 bytes in size jecxz .quit ; Map the entire file in memory push edx push edx ; starting at offset 0 push edx ; pad push ebp ; fd push dword MAP_SHARED push dword PROT_READ | PROT_WRITE push ecx ; entire file size push edx ; let system decide on the address sys.mmap jc near memerr mov edi, eax mov esi, eax push ecx ; for SYS_munmap push edi ; Use EBX for state machine mov ebx, ordinary mov ah, 0Ah cld .loop: lodsb call ebx loop .loop cmp ebx, ordinary je .filesize ; Output final lf mov al, ah stosb inc edx .filesize: ; truncate file to new size push dword 0 ; high dword push edx ; low dword push eax ; pad push ebp sys.ftruncate ; close it (ebp still pushed) sys.close add esp, byte 16 sys.munmap .quit: push dword 0 sys.exit align 4 ordinary: cmp al, 0Dh je .cr cmp al, ah je .lf stosb inc edx ret align 4 .cr: mov ebx, cr ret align 4 .lf: mov ebx, lf ret align 4 cr: cmp al, 0Dh je .cr cmp al, ah je .lf xchg al, ah stosb inc edx xchg al, ah ; fall through .lf: stosb inc edx mov ebx, ordinary ret align 4 .cr: mov al, ah stosb inc edx ret align 4 lf: cmp al, ah je .lf cmp al, 0Dh je .cr xchg al, ah stosb inc edx xchg al, ah stosb inc edx mov ebx, ordinary ret align 4 .cr: mov ebx, ordinary mov al, ah ; fall through .lf: stosb inc edx ret
不要在MS-DOS®或Windows®格式化的磁盘上存储的文件上使用此程序。在FreeBSD系统上挂载这些驱动器时,使用 |
A.12. 一心一意
作为禅宗的学生,我喜欢一心一意的想法:一次只做一件事,并且做好它。
这确实是UNIX®的工作方式。虽然典型的Windows®应用程序试图做所有可以想象的事情(因此充满了错误),但典型的UNIX®程序只做一件事,并且做好它。
然后,典型的UNIX®用户基本上通过编写shell脚本将各种现有程序组合起来,并将一个程序的输出管道到另一个程序的输入来组装自己的应用程序。
在编写您自己的UNIX®软件时,通常最好查看问题中哪些部分可以由现有程序处理,并且只为您没有现有解决方案的那部分问题编写您自己的程序。
A.12.1. CSV
我将用我最近遇到的一个具体的现实生活中的例子来说明这一原则
我需要从我从网站下载的数据库中提取每个记录的第11个字段。该数据库是一个CSV文件,即逗号分隔值列表。这是一种在可能使用不同数据库软件的人员之间共享数据的非常标准的格式。
文件的第一行包含用逗号分隔的各种字段列表。文件的其余部分包含逐行列出的数据,值用逗号分隔。
我尝试使用awk,使用逗号作为分隔符。但是由于几行包含带引号的逗号,awk提取了这些行的错误字段。
因此,我需要编写自己的软件来从CSV文件中提取第11个字段。但是,遵循UNIX®的精神,我只需要编写一个简单的过滤器来执行以下操作
从文件中删除第一行;
将所有未带引号的逗号更改为不同的字符;
删除所有引号。
严格来说,我可以使用sed从文件中删除第一行,但在自己的程序中这样做非常容易,所以我决定这样做并减少管道的规模。
无论如何,编写这样的程序大约花了20分钟。编写一个从CSV文件中提取第11个字段的程序需要更长的时间,而且我无法重用它来从其他数据库中提取其他字段。
这次我决定让它做比典型的教程程序多一点的工作
它解析其命令行以获取选项;
如果发现错误的参数,它会显示正确的用法;
它会生成有意义的错误消息。
这是它的用法消息
Usage: csv [-t<delim>] [-c<comma>] [-p] [-o <outfile>] [-i <infile>]
所有参数都是可选的,并且可以按任何顺序出现。
-t
参数声明用什么替换逗号。这里的默认值为tab
。例如,-t;
将用分号替换所有未带引号的逗号。
我不需要-c
选项,但它将来可能会有用。它允许我声明我希望将除逗号以外的其他字符替换为其他内容。例如,-c@
将替换所有at符号(如果您想将电子邮件地址列表拆分为其用户名和域名,这很有用)。
-p
选项保留第一行,即不删除它。默认情况下,我们会删除第一行,因为在 CSV 文件中,它包含字段名称而不是数据。
-i
和 -o
选项允许我指定输入和输出文件。默认值为 stdin 和 stdout,因此这是一个常规的 UNIX® 过滤器。
我确保接受 -i filename
和 -ifilename
两种格式。我还确保只能指定一个输入文件和一个输出文件。
要获取每个记录的第 11 个字段,我现在可以执行以下操作:
% csv '-t;' data.csv | awk '-F;' '{print $11}'
代码将选项(文件描述符除外)存储在 EDX
中:DH
中的逗号,DL
中的新分隔符,以及 -p
选项的标志存储在 EDX
的最高位,因此检查其符号将快速告诉我们该做什么。
以下是代码:
;;;;;;; csv.asm ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; ; Convert a comma-separated file to a something-else separated file. ; ; Started: 31-May-2001 ; Updated: 1-Jun-2001 ; ; Copyright (c) 2001 G. Adam Stanislav ; All rights reserved. ; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; %include 'system.inc' %define BUFSIZE 2048 section .data fd.in dd stdin fd.out dd stdout usg db 'Usage: csv [-t<delim>] [-c<comma>] [-p] [-o <outfile>] [-i <infile>]', 0Ah usglen equ $-usg iemsg db "csv: Can't open input file", 0Ah iemlen equ $-iemsg oemsg db "csv: Can't create output file", 0Ah oemlen equ $-oemsg section .bss ibuffer resb BUFSIZE obuffer resb BUFSIZE section .text align 4 ierr: push dword iemlen push dword iemsg push dword stderr sys.write push dword 1 ; return failure sys.exit align 4 oerr: push dword oemlen push dword oemsg push dword stderr sys.write push dword 2 sys.exit align 4 usage: push dword usglen push dword usg push dword stderr sys.write push dword 3 sys.exit align 4 global _start _start: add esp, byte 8 ; discard argc and argv[0] mov edx, (',' << 8) | 9 .arg: pop ecx or ecx, ecx je near .init ; no more arguments ; ECX contains the pointer to an argument cmp byte [ecx], '-' jne usage inc ecx mov ax, [ecx] .o: cmp al, 'o' jne .i ; Make sure we are not asked for the output file twice cmp dword [fd.out], stdout jne usage ; Find the path to output file - it is either at [ECX+1], ; i.e., -ofile -- ; or in the next argument, ; i.e., -o file inc ecx or ah, ah jne .openoutput pop ecx jecxz usage .openoutput: push dword 420 ; file mode (644 octal) push dword 0200h | 0400h | 01h ; O_CREAT | O_TRUNC | O_WRONLY push ecx sys.open jc near oerr add esp, byte 12 mov [fd.out], eax jmp short .arg .i: cmp al, 'i' jne .p ; Make sure we are not asked twice cmp dword [fd.in], stdin jne near usage ; Find the path to the input file inc ecx or ah, ah jne .openinput pop ecx or ecx, ecx je near usage .openinput: push dword 0 ; O_RDONLY push ecx sys.open jc near ierr ; open failed add esp, byte 8 mov [fd.in], eax jmp .arg .p: cmp al, 'p' jne .t or ah, ah jne near usage or edx, 1 << 31 jmp .arg .t: cmp al, 't' ; redefine output delimiter jne .c or ah, ah je near usage mov dl, ah jmp .arg .c: cmp al, 'c' jne near usage or ah, ah je near usage mov dh, ah jmp .arg align 4 .init: sub eax, eax sub ebx, ebx sub ecx, ecx mov edi, obuffer ; See if we are to preserve the first line or edx, edx js .loop .firstline: ; get rid of the first line call getchar cmp al, 0Ah jne .firstline .loop: ; read a byte from stdin call getchar ; is it a comma (or whatever the user asked for)? cmp al, dh jne .quote ; Replace the comma with a tab (or whatever the user wants) mov al, dl .put: call putchar jmp short .loop .quote: cmp al, '"' jne .put ; Print everything until you get another quote or EOL. If it ; is a quote, skip it. If it is EOL, print it. .qloop: call getchar cmp al, '"' je .loop cmp al, 0Ah je .put call putchar jmp short .qloop align 4 getchar: or ebx, ebx jne .fetch call read .fetch: lodsb dec ebx ret read: jecxz .read call write .read: push dword BUFSIZE mov esi, ibuffer push esi push dword [fd.in] 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 ; close files push dword [fd.in] sys.close push dword [fd.out] sys.close ; return success push dword 0 sys.exit align 4 putchar: stosb inc ecx cmp ecx, BUFSIZE je write ret align 4 write: jecxz .ret ; nothing to write sub edi, ecx ; start of buffer push ecx push edi push dword [fd.out] sys.write add esp, byte 12 sub eax, eax sub ecx, ecx ; buffer is empty now .ret: ret
其中大部分取自上面的 hex.asm。但有一个重要的区别:当输出换行符时,我不再调用 write
。然而,代码可以交互使用。
自从我开始撰写本章以来,我发现了一种更好的交互式问题的解决方案。我想确保每行仅在需要时才单独打印出来。毕竟,当非交互使用时,没有必要刷新每一行。
我现在使用的新的解决方案是在每次发现输入缓冲区为空时调用 write
。这样,在交互模式下运行时,程序会从用户的键盘读取一行,处理它,并发现其输入缓冲区为空。它会刷新其输出并读取下一行。
A.12.1.1. 缓冲区的阴暗面
此更改防止了在非常特定的情况下出现神秘的死锁。我将其称为缓冲区的“阴暗面”,主要是因为它带来了一个不太明显的危险。
对于像上面 csv 这样的程序,这种情况不太可能发生,因此让我们考虑另一个过滤器:在这种情况下,我们期望我们的输入是表示颜色值的原始数据,例如像素的红色、绿色和蓝色强度。我们的输出将是我们输入的负数。
这样的过滤器编写起来非常简单。大部分内容看起来都与我们迄今编写的其他过滤器一样,所以我只会向您展示其内部循环:
.loop: call getchar not al ; Create a negative call putchar jmp short .loop
由于此过滤器使用原始数据,因此不太可能以交互方式使用。
但它可以由图像处理软件调用。并且,除非它在每次调用 read
之前调用 write
,否则很可能会死锁。
以下是可能发生的情况:
图像编辑器将使用 C 函数
popen()
加载我们的过滤器。它将从位图或像素图读取第一行像素。
它将第一行像素写入通向我们过滤器
fd.in
的管道。我们的过滤器将从其输入读取每个像素,将其转换为负数,并将其写入其输出缓冲区。
我们的过滤器将调用
getchar
以获取下一个像素。getchar
将发现输入缓冲区为空,因此它将调用read
。read
将调用SYS_read
系统调用。内核将挂起我们的过滤器,直到图像编辑器向管道发送更多数据。
图像编辑器将从连接到我们过滤器
fd.out
的另一个管道读取,以便它可以在向我们发送输入的第二行之前设置输出图像的第一行。内核挂起图像编辑器,直到它从我们的过滤器接收到一些输出,以便它可以将其传递给图像编辑器。
此时,我们的过滤器等待图像编辑器发送更多数据以供处理,而图像编辑器则等待我们的过滤器发送第一行处理结果。但是结果位于我们的输出缓冲区中。
过滤器和图像编辑器将永远相互等待(或者至少直到它们被杀死)。我们的软件刚刚进入了竞争条件。
如果我们的过滤器在请求内核获取更多输入数据之前刷新其输出缓冲区,则不存在此问题。
A.13. 使用 FPU
奇怪的是,大多数汇编语言文献甚至都没有提及 FPU(浮点单元)的存在,更不用说讨论对其进行编程了。
然而,当我们通过执行仅可以在汇编语言中执行的操作来创建高度优化的 FPU 代码时,汇编语言的光芒从未如此耀眼。
A.13.1. FPU 的组织
FPU 由 8 个 80 位浮点寄存器组成。这些寄存器以堆栈方式组织——您可以将值push
到 TOS(堆栈顶部),也可以将其pop
出来。
也就是说,汇编语言操作码不是 push
和 pop
,因为这些操作码已经被使用了。
您可以使用 fld
、fild
和 fbld
将值push
到 TOS。其他一些操作码允许您将许多常见的常量(例如pi)push
到 TOS 上。
类似地,您可以使用 fst
、fstp
、fist
、fistp
和 fbstp
将值pop
出来。实际上,只有以p结尾的操作码才会真正pop
出该值,其余操作码会将其存储
到其他位置,而不会将其从 TOS 中删除。
我们可以将数据在 TOS 和计算机内存之间传输,可以是 32 位、64 位或 80 位实数、16 位、32 位或 64 位整数,或者 80 位压缩十进制数。
80 位压缩十进制数是二进制编码十进制数的一种特殊情况,在将数据的 ASCII 表示形式与 FPU 的内部数据进行转换时非常方便。它允许我们使用 18 位有效数字。
无论我们在内存中如何表示数据,FPU 始终将其以 80 位实数格式存储在其寄存器中。
其内部精度至少为 19 位十进制数字,因此即使我们选择以完整的 18 位精度以 ASCII 格式显示结果,我们仍然显示的是正确的结果。
我们可以在 TOS 上执行数学运算:我们可以计算其正弦,我们可以对其进行缩放(即,我们可以将其乘以或除以 2 的幂),我们可以计算其以 2 为底的对数,以及许多其他操作。
我们还可以将其乘以或除以、加到或减去任何 FPU 寄存器(包括自身)。
TOS 的官方英特尔操作码为 st
,而寄存器为 st(0)
-st(7)
。然后,st
和 st(0)
指的是同一个寄存器。
出于某种原因,nasm 的原始作者决定使用不同的操作码,即 st0
-st7
。换句话说,没有括号,TOS 始终为 st0
,而不是 st
。
A.13.1.1. 压缩十进制格式
压缩十进制格式使用 10 个字节(80 位)的内存来表示 18 位数字。那里表示的数字始终为整数。
您可以通过首先将 TOS 乘以 10 的幂来使用它获取小数位。 |
最高字节(字节 9)的最高位是符号位:如果它被设置,则数字为负数,否则为正数。此字节的其余位未使用/忽略。
其余 9 个字节存储数字的 18 位数字:每个字节 2 位数字。
更重要的数字存储在高nibble(4 位)中,不太重要的数字存储在低nibble中。
也就是说,您可能会认为 -1234567
将在内存中以这种方式存储(使用十六进制表示法):
80 00 00 00 00 00 01 23 45 67
唉,事实并非如此!与英特尔制造的所有其他东西一样,即使是压缩十进制数也是小端的。
这意味着我们的 -1234567
存储方式如下:
67 45 23 01 00 00 00 00 00 80
记住这一点,否则您将绝望地抓耳挠腮!
如果您能找到的话,值得一读的书是 Richard Startz 的 8087/80287/80387 for the IBM PC & Compatibles。尽管它似乎认为压缩十进制数的小端存储是理所当然的。我并不是在开玩笑,在意识到我应该即使对于这种类型的数据也尝试使用小端顺序之前,我尝试找出下面我显示的过滤器出了什么问题,真是令人绝望。 |
A.13.2. 针孔摄影的游览
为了编写有意义的软件,我们不仅必须了解我们的编程工具,还必须了解我们正在为其创建软件的领域。
我们的下一个过滤器将帮助我们在想要构建针孔相机时提供帮助,因此,在我们继续之前,我们需要了解一些针孔摄影的背景知识。
A.13.2.1. 相机
描述任何相机最简单的方法是,它是一些封闭在某种防光材料中的空腔,外壳上有一个小孔。
外壳通常很坚固(例如,一个盒子),尽管有时它是灵活的(波纹管)。相机内部非常黑暗。但是,该孔允许光线穿过单个点(尽管在某些情况下可能有多个)进入。这些光线形成一个图像,即相机外部、孔前任何物体的表示形式。
如果将某些感光材料(如胶片)放置在相机内部,它可以捕捉图像。
该孔通常包含一个镜头或镜头组件,通常称为物镜。
A.13.2.2. 针孔
但是,严格来说,镜头不是必需的:最初的相机没有使用镜头,而是使用针孔。即使在今天,针孔也被用作研究相机工作原理的工具以及实现特殊类型的图像。
针孔产生的图像都同样清晰。或模糊。针孔有一个理想的尺寸:如果它更大或更小,图像就会失去清晰度。
A.13.2.3. 焦距
这个理想的小孔直径是焦距平方根的函数,焦距是指小孔到胶片的距离。
D = PC * sqrt(FL)
这里,D
是小孔的理想直径,FL
是焦距,PC
是小孔常数。根据 Jay Bender 的说法,其值为 0.04
,而 Kenneth Connors 则确定其值为 0.037
。其他人也提出了其他的值。此外,此值仅适用于日光:其他类型的灯光需要不同的常数,其值只能通过实验确定。
A.13.2.4. F 数
F 数是衡量到达胶片的光量的一个非常有用的指标。例如,测光表可以确定,要曝光特定感光度的胶片,使用 f5.6 光圈可能需要曝光时间为 1/1000 秒。
无论是 35 毫米相机还是 6x9 厘米相机等,都没有关系。只要我们知道 F 数,我们就可以确定正确的曝光时间。
F 数很容易计算。
F = FL / D
换句话说,F 数等于焦距除以小孔直径。这也意味着较高的 F 数意味着较小的小孔或较大的焦距,或者两者兼而有之。这反过来又意味着,F 数越高,曝光时间就越长。
此外,虽然小孔直径和焦距是一维测量,但胶片和小孔都是二维的。这意味着,如果您在 F 数为 A
时测得的曝光时间为 t
,则在 F 数为 B
时的曝光时间为
t * (B / A)²
A.13.3. 设计小孔软件
现在我们准备决定我们到底希望我们的针孔软件做什么。
A.13.3.1. 处理程序输入
由于其主要目的是帮助我们设计一个工作的小孔相机,我们将使用焦距作为程序的输入。这是我们可以不用软件就能确定的东西:正确的焦距由胶片的大小和拍摄“普通”照片、广角照片或长焦照片的需要决定。
到目前为止,我们编写的许多程序都将单个字符或字节作为输入:hex 程序将单个字节转换为十六进制数,csv 程序要么让字符通过,要么删除它,要么将其更改为不同的字符,等等。
一个程序 ftuc 使用状态机一次最多考虑两个输入字节。
但是我们的针孔程序不能只处理单个字符,它必须处理更大的语法单元。
例如,如果我们希望程序在 100 mm
、150 mm
和 210 mm
的焦距下计算针孔直径(以及我们稍后将讨论的其他值),我们可能希望输入如下内容
100, 150, 210
我们的程序需要一次考虑多个字节的输入。当它看到第一个 1
时,它必须理解它正在看到一个十进制数的第一位数字。当它看到 0
和另一个 0
时,它必须知道它正在看到同一个数的更多位数字。
当它遇到第一个逗号时,它必须知道它不再接收第一个数字的位数了。它必须能够将第一个数字的位数转换为 100
的值。并将第二个数字的位数转换为 150
的值。当然,还要将第三个数字的位数转换为 210
的数值。
我们需要决定接受哪些分隔符:输入数字必须用逗号分隔吗?如果是,我们如何处理用其他字符分隔的两个数字?
就我个人而言,我喜欢保持简单。某个东西要么是一个数字,所以我处理它。要么它不是一个数字,所以我丢弃它。我不喜欢电脑因为我输入了一个额外的字符而抱怨我,因为很明显这是一个额外的字符。真是的!
此外,它允许我打破计算的单调性,并输入查询而不是仅仅输入一个数字。
What is the best pinhole diameter for the
focal length of 150?
没有理由让电脑发出很多抱怨。
Syntax error: What
Syntax error: is
Syntax error: the
Syntax error: best
等等,等等,等等。
其次,我喜欢用 #
字符表示注释的开始,该注释一直延伸到行尾。这不需要花费太多精力来编码,并允许我将软件的输入文件视为可执行脚本。
在我们的例子中,我们还需要决定输入应该使用什么单位:我们选择毫米,因为大多数摄影师都是这样测量焦距的。
最后,我们需要决定是否允许使用小数点(在这种情况下,我们还必须考虑世界上许多地方使用小数逗号)。
在我们的例子中,允许使用小数点/逗号会产生一种虚假的精确感:50
和 51
的焦距之间几乎没有明显的区别,因此允许用户输入类似 50.5
的内容不是一个好主意。这是我的意见,请注意,但我才是编写这个程序的人。当然,你可以在自己的程序中做出其他选择。
A.13.3.2. 提供选项
构建针孔相机时,我们需要知道的最重要的事情是小孔的直径。由于我们希望拍摄清晰的图像,我们将使用上述公式根据焦距计算小孔直径。由于专家们对 PC
常数提出了几个不同的值,因此我们需要进行选择。
在 UNIX® 编程中,传统上使用两种主要方法来选择程序参数,并在用户不进行选择时提供默认值。
为什么要使用两种选择方法?
一种是允许进行永久性的选择,该选择在每次运行软件时都会自动应用,而无需我们一遍又一遍地告诉它我们想要它做什么。
永久性选择可以存储在配置文件中,该文件通常位于用户的 home 目录中。该文件通常与应用程序具有相同的名称,但以点开头。通常还会添加“rc”到文件名中。因此,我们的文件可以是 ~/.pinhole 或 ~/.pinholerc。(~/ 表示当前用户的 home 目录。)
配置文件主要由具有许多可配置参数的程序使用。那些只有一个(或几个)参数的程序通常使用不同的方法:它们期望在环境变量中找到该参数。在我们的例子中,我们可能会查看名为 PINHOLE
的环境变量。
通常,程序使用上述方法之一。否则,如果配置文件说了一件事,但环境变量说了另一件事,程序可能会感到困惑(或者变得过于复杂)。
因为我们只需要选择一个这样的参数,所以我们将使用第二种方法,并在环境中搜索名为 PINHOLE
的变量。
另一种方法允许我们做出临时的决定:“虽然我通常希望你使用 0.039,但这次我想要 0.03872。”换句话说,它允许我们覆盖永久性选择。
这种类型的选择通常通过命令行参数完成。
最后,程序始终需要一个默认值。用户可能不会做出任何选择。也许他不知道该选择什么。也许他只是“浏览”。最好是默认值是大多数用户无论如何都会选择的值。这样他们就不需要选择。或者,更确切地说,他们可以在不额外努力的情况下选择默认值。
鉴于此系统,程序可能会发现冲突的选项,并以这种方式处理它们
如果它发现一个临时选择(例如,命令行参数),它应该接受该选择。它必须忽略任何永久性选择和任何默认值。
否则,如果它找到一个永久性选项(例如,环境变量),它应该接受它,并忽略默认值。
否则,它应该使用默认值。
我们还需要决定我们的 PC
选项应该采用什么格式。
乍一看,使用 PINHOLE=0.04
格式作为环境变量,并使用 -p0.04
作为命令行似乎很明显。
实际上,允许这样做存在安全风险。PC
常数是一个非常小的数字。自然,我们将使用各种 PC
的小值来测试我们的软件。但是,如果有人运行程序并选择一个很大的值会发生什么?
它可能会导致程序崩溃,因为我们没有设计它来处理大数字。
或者,我们可能会在程序上花费更多时间,以便它能够处理大数字。如果我们为电脑文盲用户编写商业软件,我们可能会这样做。
或者,我们可能会说,“太糟糕了!用户应该知道得更好。”
或者,我们只是可能使用户无法输入大数字。这就是我们将采取的方法:我们将使用一个隐含的 0. 前缀。
换句话说,如果用户想要 0.04
,我们将期望他输入 -p04
,或在他的环境中设置 PINHOLE=04
。因此,如果他说 -p9999999
,我们将将其解释为 0.9999999
——仍然很荒谬,但至少更安全。
其次,许多用户只想使用 Bender 的常数或 Connors 的常数。为了方便他们,我们将把 -b
解释为与 -p04
相同,并将 -c
解释为与 -p037
相同。
A.13.3.3. 输出
我们需要决定我们希望我们的软件发送什么内容到输出,以及以什么格式发送。
由于我们的输入允许输入不确定的焦距条目数量,因此使用传统的数据库风格输出在单独的行上显示每个焦距的计算结果,同时用 tab
字符分隔一行上的所有值是有意义的。
或者,我们还应该允许用户指定使用我们之前学习过的 CSV 格式。在这种情况下,我们将打印出一行用逗号分隔的名称来描述每一行的每个字段,然后像以前一样显示我们的结果,但用 comma
替换 tab
。
我们需要一个用于 CSV 格式的命令行选项。我们不能使用-c
,因为它已经表示使用康纳斯常数。出于某种奇怪的原因,许多网站将 CSV 文件称为“Excel 电子表格”(尽管 CSV 格式早于 Excel)。因此,我们将使用-e
开关来告知我们的软件我们希望输出为 CSV 格式。
我们将以焦距开头输出的每一行。这乍一看可能显得重复,尤其是在交互模式下:用户输入焦距,而我们又重复了一遍。
但用户可以在一行中输入多个焦距。输入也可以来自文件或另一个程序的输出。在这种情况下,用户根本看不到输入。
同样地,输出可以到一个我们稍后要检查的文件,或者到打印机,或者成为另一个程序的输入。
因此,以用户输入的焦距开头每一行是完全合理的。
不,等等!不是用户输入的。如果用户输入类似这样的内容呢
00000000150
显然,我们需要去除这些前导零。
因此,我们可以考虑按原样读取用户输入,将其转换为 FPU 内部的二进制,然后从那里打印出来。
但是……
如果用户输入类似这样的内容呢
17459765723452353453534535353530530534563507309676764423
哈!打包十进制 FPU 格式允许我们输入 18 位数字。但用户输入了超过 18 位数字。我们如何处理这种情况?
好吧,我们可以修改我们的代码以读取前 18 位数字,将其输入到 FPU,然后读取更多数字,将我们已经在 TOS 上的数字乘以 10 的额外数字次幂,然后add
到它。
是的,我们可以这样做。但在这个程序中,这将是荒谬的(在另一个程序中,这可能是恰当的做法):即使是用毫米表示的地球周长也只需要 11 位数字。显然,我们无法制造那么大的相机(至少现在还不行)。
因此,如果用户输入这么大的数字,他要么无聊,要么在测试我们,要么试图侵入系统,要么在玩游戏——总之不是在设计针孔相机。
我们将怎么做?
我们将打他一巴掌,打个比方说
17459765723452353453534535353530530534563507309676764423 ??? ??? ??? ??? ???
为了实现这一点,我们将简单地忽略任何前导零。一旦我们找到一个非零数字,我们将把计数器初始化为0
并开始执行三个步骤
将数字发送到输出。
将数字附加到一个缓冲区,我们稍后将使用它来生成我们可以发送到 FPU 的打包十进制数。
增加计数器。
现在,在我们执行这三个步骤的同时,我们还需要注意两种情况之一
如果计数器增长到超过 18,我们将停止附加到缓冲区。我们继续读取数字并将它们发送到输出。
如果,或者更确切地说当,下一个输入字符不是数字时,我们就完成了现在的输入。
顺便说一句,我们可以简单地丢弃非数字,除非它是一个
#
,我们必须将其返回到输入流。它开始一个注释,所以我们必须在完成输出并开始寻找更多输入后才能看到它。
这仍然留下了一种可能性未被覆盖:如果用户输入的全部是一个零(或多个零),我们将永远找不到要显示的非零数字。
每当我们的计数器保持在0
时,我们都可以确定这种情况发生了。在这种情况下,我们需要将0
发送到输出,并执行另一个“打脸”操作
0 ??? ??? ??? ??? ???
一旦我们显示了焦距并确定它是有效的(大于0
但不超过 18 位数字),我们就可以计算针孔直径。
针孔包含单词针并非巧合。事实上,许多针孔从字面上讲就是一个针孔,用针尖小心地打出的孔。
这是因为典型的针孔非常小。我们的公式以毫米为单位得到结果。我们将将其乘以1000
,以便我们可以以微米为单位输出结果。
在这一点上,我们还有另一个陷阱需要面对:过高的精度。
是的,FPU 是为高精度数学设计的。但我们处理的不是高精度数学。我们处理的是物理学(具体来说是光学)。
假设我们想将一辆卡车改装成针孔相机(我们不会是第一个这样做的人!)。假设它的箱子长12
米,所以焦距为12000
。好吧,使用本德常数,它给我们12000
的平方根乘以0.04
,即4.381780460
毫米,或4381.780460
微米。
无论哪种方式,结果都精确得荒谬。我们的卡车长度并不完全是12000
毫米。我们没有以如此高的精度测量它的长度,因此说我们需要直径为4.381780460
毫米的针孔,嗯,具有误导性。4.4
毫米就足够了。
在上面的示例中,我“只”使用了十位数字。想象一下追求所有 18 位数字的荒谬性! |
我们需要限制结果的有效数字位数。一种方法是使用一个表示微米的整数。因此,我们的卡车需要一个直径为4382
微米的针孔。看着这个数字,我们仍然认为4400
微米,或4.4
毫米足够接近。
此外,我们可以决定,无论我们得到的结果有多大,我们都只希望显示四位有效数字(当然,也可以是任何其他数字)。唉,FPU 不提供四舍五入到特定位数的功能(毕竟,它不将数字视为十进制,而是视为二进制)。
因此,我们必须设计一个算法来减少有效数字的位数。
这是我的算法(我认为它很笨拙——如果你知道更好的算法,请告诉我)
将计数器初始化为
0
。当数字大于或等于
10000
时,将其除以10
并增加计数器。输出结果。
当计数器大于
0
时,输出0
并减少计数器。
|
然后,我们将输出以微米为单位的针孔直径,四舍五入到四位有效数字。
在这一点上,我们知道焦距和针孔直径。这意味着我们有足够的信息来计算f 数。
我们将显示 f 数,四舍五入到四位有效数字。f 数很可能告诉我们很少的信息。为了使其更有意义,我们可以找到最接近的标准化 f 数,即最接近 2 的平方根的幂。
我们通过将实际 f 数自身相乘来做到这一点,这当然会给出它的平方
。然后,我们将计算其以 2 为底的对数,这比计算以 2 的平方根为底的对数容易得多!我们将结果四舍五入到最接近的整数。接下来,我们将 2 提升到结果的幂。实际上,FPU 为我们提供了一个很好的捷径来做到这一点:我们可以使用fscale
操作码来“缩放”1,这类似于将整数左移。最后,我们计算所有结果的平方根,我们就得到了最接近的标准化 f 数。
如果所有这些听起来都让人不知所措——或者工作量太大,也许——如果你看到代码,它可能会变得更加清晰。它总共需要 9 个操作码
fmul st0, st0 fld1 fld st1 fyl2x frndint fld1 fscale fsqrt fstp st1
第一行,fmul st0, st0
,将 TOS(堆栈顶部,与st
相同,由 nasm 称为st0
)的内容平方。fld1
将1
压入 TOS。
下一行,fld st1
,将平方推回 TOS。此时,平方同时存在于st
和st(2)
中(稍后将清楚为什么我们在堆栈上保留第二个副本)。st(1)
包含1
。
接下来,fyl2x
计算st
乘以st(1)
的以 2 为底的对数。这就是我们在之前将1
放在st(1)
上的原因。
此时,st
包含我们刚刚计算的对数,st(1)
包含我们为以后保存的实际 f 数的平方。
frndint
将 TOS 四舍五入到最接近的整数。fld1
压入一个1
。fscale
将 TOS 上的1
左移st(1)
中的值,有效地将 2 提升到st(1)
的幂。
最后,fsqrt
计算结果的平方根,即最接近的标准化 f 数。
现在,我们在 TOS 上有了最接近的标准化 f 数,在st(1)
中有了四舍五入到最接近的整数的以 2 为底的对数,在st(2)
中有了实际 f 数的平方。我们正在为以后保存st(2)
中的值。
但我们不再需要st(1)
的内容。最后一行,fstp st1
,将st
的内容放置到st(1)
,并弹出。结果,原来的st(1)
现在是st
,原来的st(2)
现在是st(1)
,等等。新的st
包含标准化 f 数。新的st(1)
包含我们为后代存储在那里的实际 f 数的平方。
此时,我们已准备好输出标准化 f 数。因为它已标准化,所以我们不会将其四舍五入到四位有效数字,而是以其全部精度发送出去。
只要标准化 f 数合理地小且可以在我们的测光表上找到,它就很有用。否则,我们需要另一种方法来确定正确的曝光。
之前,我们已经计算出了从不同 f 数下测量的曝光计算任意 f 数下正确曝光的公式。
我见过的每个测光表都可以在 f5.6 下确定正确的曝光。因此,我们将计算一个“f5.6 倍数”,即我们需要将 f5.6 下测量的曝光乘以多少才能确定我们针孔相机的正确曝光。
从上面的公式我们知道,这个因子可以通过将我们的 f 数(实际的,而不是标准化的)除以5.6
,并将结果平方来计算。
在数学上,将我们 f 数的平方除以5.6
的平方将给出相同的结果。
在计算上,当我们只能对一个数字平方时,我们不想对两个数字平方。因此,第一个解决方案起初看起来更好。
但是……
5.6
是一个常数。我们不必让我们的 FPU 浪费宝贵的周期。我们可以直接告诉它将 f 数的平方除以5.6²
等于的值。或者我们可以将 f 数除以5.6
,然后对结果平方。这两种方法现在看起来一样了。
但是,它们不一样!
在学习了上面的摄影原理后,我们记得5.6
实际上是 2 的平方根的五次幂。一个无理数。这个数字的平方正好是32
。
32
不仅是整数,而且是 2 的幂。我们不需要将 f 数的平方除以32
。我们只需要使用fscale
将其右移五位。在 FPU 术语中,这意味着我们将使用st(1)
等于-5
来fscale
它。这比除法快得多。
因此,现在已经清楚为什么我们将 f 数的平方保存在 FPU 堆栈的顶部。f5.6 倍数的计算是整个程序中最简单的计算!我们将将其四舍五入到四位有效数字并输出。
还有一个有用的数字我们可以计算:我们的f值与f5.6相差多少档。如果我们的f值正好超出测光表的范围,但我们有一个可以设置不同快门的快门,并且这个快门使用光圈档,那么这可能对我们有所帮助。
例如,我们的f值与f5.6相差5档,测光表显示我们应该使用1/1000秒。那么我们可以先将快门速度设置为1/1000,然后将拨盘转动5档。
这个计算也很简单。我们只需要计算我们刚刚计算出的f5.6倍数的以2为底的对数(虽然我们需要它在四舍五入之前的值)。然后我们将结果四舍五入到最接近的整数。我们不需要担心在这个计算中保留超过四位有效数字:结果很可能只有一位或两位数字。
A.13.4. FPU优化
在汇编语言中,我们可以以高级语言(包括C语言)无法实现的方式优化FPU代码。
每当C函数需要计算浮点数时,它都会将所有必要的变量和常数加载到FPU寄存器中。然后它执行所需的任何计算以获得正确的结果。优秀的C编译器可以很好地优化代码的这一部分。
它通过将结果保留在TOS上“返回”该值。但是,在返回之前,它会进行清理。它在计算中使用的任何变量和常数现在都已从FPU中消失。
它不能做我们上面刚做的事情:我们计算了f值的平方并将它保存在栈中,以便稍后被另一个函数使用。
我们知道我们以后会需要那个值。我们也知道我们在栈上(栈上只有8个数字的空间)有足够的空间来存储它。
C编译器无法知道它栈上的一个值在不久的将来是否会被再次需要。
当然,C程序员可能知道。但他唯一的选择是将该值存储在内存变量中。
这意味着,一方面,该值将从FPU内部使用的80位精度更改为Cdouble(64位)甚至single(32位)。
这也意味着该值必须从TOS移动到内存,然后再移回。唉,在所有FPU操作中,访问计算机内存的操作是最慢的。
因此,每当使用汇编语言编程FPU时,都要寻找将中间结果保留在FPU栈上的方法。
我们可以更进一步!在我们的程序中,我们使用了一个常量(我们命名为PC
)。
我们计算多少个针孔直径并不重要:1、10、20、1000,我们始终使用相同的常量。因此,我们可以通过始终将常量保存在栈中来优化我们的程序。
在程序的早期,我们正在计算上述常量的值。对于常量中的每个数字,我们需要将我们的输入除以10
。
乘法比除法快得多。因此,在程序的开始,我们将10
除以1
以获得0.1
,然后将其保存在栈中:而不是为每个数字将输入除以10
,我们将其乘以0.1
。
顺便说一下,我们不会直接输入0.1
,即使我们可以。我们这么做是有原因的:虽然0.1
可以用一位小数表示,但我们不知道它需要多少个二进制位。因此,我们让FPU以其自身的高精度计算其二进制值。
我们使用其他常量:我们将针孔直径乘以1000
将其从毫米转换为微米。当我们将数字四舍五入到四位有效数字时,我们将数字与10000
进行比较。因此,我们将1000
和10000
都保存在栈中。当然,我们在将数字四舍五入到四位数字时会重复使用0.1
。
最后但并非最不重要的是,我们将-5
保存在栈中。我们需要它来缩放f值的平方,而不是将其除以32
。我们最后加载此常量并非巧合。当只有常量在栈上时,这使得它成为栈顶。因此,当f值的平方被缩放时,-5
位于st(1)
,正好是fscale
期望它所在的位置。
通常从头创建某些常量,而不是从内存中加载它们。这就是我们对-5
所做的。
fld1 ; TOS = 1 fadd st0, st0 ; TOS = 2 fadd st0, st0 ; TOS = 4 fld1 ; TOS = 1 faddp st1, st0 ; TOS = 5 fchs ; TOS = -5
我们可以将所有这些优化归纳为一条规则:将重复值保存在栈中!
PostScript®是一种面向栈的编程语言。关于PostScript®的书籍比关于FPU汇编语言的书籍多得多:掌握PostScript®将帮助你掌握FPU。 |
A.13.5. pinhole-代码
;;;;;;; pinhole.asm ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; ; Find various parameters of a pinhole camera construction and use ; ; Started: 9-Jun-2001 ; Updated: 10-Jun-2001 ; ; Copyright (c) 2001 G. Adam Stanislav ; All rights reserved. ; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; %include 'system.inc' %define BUFSIZE 2048 section .data align 4 ten dd 10 thousand dd 1000 tthou dd 10000 fd.in dd stdin fd.out dd stdout envar db 'PINHOLE=' ; Exactly 8 bytes, or 2 dwords long pinhole db '04,', ; Bender's constant (0.04) connors db '037', 0Ah ; Connors' constant usg db 'Usage: pinhole [-b] [-c] [-e] [-p <value>] [-o <outfile>] [-i <infile>]', 0Ah usglen equ $-usg iemsg db "pinhole: Can't open input file", 0Ah iemlen equ $-iemsg oemsg db "pinhole: Can't create output file", 0Ah oemlen equ $-oemsg pinmsg db "pinhole: The PINHOLE constant must not be 0", 0Ah pinlen equ $-pinmsg toobig db "pinhole: The PINHOLE constant may not exceed 18 decimal places", 0Ah biglen equ $-toobig huhmsg db 9, '???' separ db 9, '???' sep2 db 9, '???' sep3 db 9, '???' sep4 db 9, '???', 0Ah huhlen equ $-huhmsg header db 'focal length in millimeters,pinhole diameter in microns,' db 'F-number,normalized F-number,F-5.6 multiplier,stops ' db 'from F-5.6', 0Ah headlen equ $-header section .bss ibuffer resb BUFSIZE obuffer resb BUFSIZE dbuffer resb 20 ; decimal input buffer bbuffer resb 10 ; BCD buffer section .text align 4 huh: call write push dword huhlen push dword huhmsg push dword [fd.out] sys.write add esp, byte 12 ret align 4 perr: push dword pinlen push dword pinmsg push dword stderr sys.write push dword 4 ; return failure sys.exit align 4 consttoobig: push dword biglen push dword toobig push dword stderr sys.write push dword 5 ; return failure sys.exit align 4 ierr: push dword iemlen push dword iemsg push dword stderr sys.write push dword 1 ; return failure sys.exit align 4 oerr: push dword oemlen push dword oemsg push dword stderr sys.write push dword 2 sys.exit align 4 usage: push dword usglen push dword usg push dword stderr sys.write push dword 3 sys.exit align 4 global _start _start: add esp, byte 8 ; discard argc and argv[0] sub esi, esi .arg: pop ecx or ecx, ecx je near .getenv ; no more arguments ; ECX contains the pointer to an argument cmp byte [ecx], '-' jne usage inc ecx mov ax, [ecx] inc ecx .o: cmp al, 'o' jne .i ; Make sure we are not asked for the output file twice cmp dword [fd.out], stdout jne usage ; Find the path to output file - it is either at [ECX+1], ; i.e., -ofile -- ; or in the next argument, ; i.e., -o file or ah, ah jne .openoutput pop ecx jecxz usage .openoutput: push dword 420 ; file mode (644 octal) push dword 0200h | 0400h | 01h ; O_CREAT | O_TRUNC | O_WRONLY push ecx sys.open jc near oerr add esp, byte 12 mov [fd.out], eax jmp short .arg .i: cmp al, 'i' jne .p ; Make sure we are not asked twice cmp dword [fd.in], stdin jne near usage ; Find the path to the input file or ah, ah jne .openinput pop ecx or ecx, ecx je near usage .openinput: push dword 0 ; O_RDONLY push ecx sys.open jc near ierr ; open failed add esp, byte 8 mov [fd.in], eax jmp .arg .p: cmp al, 'p' jne .c or ah, ah jne .pcheck pop ecx or ecx, ecx je near usage mov ah, [ecx] .pcheck: cmp ah, '0' jl near usage cmp ah, '9' ja near usage mov esi, ecx jmp .arg .c: cmp al, 'c' jne .b or ah, ah jne near usage mov esi, connors jmp .arg .b: cmp al, 'b' jne .e or ah, ah jne near usage mov esi, pinhole jmp .arg .e: cmp al, 'e' jne near usage or ah, ah jne near usage mov al, ',' mov [huhmsg], al mov [separ], al mov [sep2], al mov [sep3], al mov [sep4], al jmp .arg align 4 .getenv: ; If ESI = 0, we did not have a -p argument, ; and need to check the environment for "PINHOLE=" or esi, esi jne .init sub ecx, ecx .nextenv: pop esi or esi, esi je .default ; no PINHOLE envar found ; check if this envar starts with 'PINHOLE=' mov edi, envar mov cl, 2 ; 'PINHOLE=' is 2 dwords long rep cmpsd jne .nextenv ; Check if it is followed by a digit mov al, [esi] cmp al, '0' jl .default cmp al, '9' jbe .init ; fall through align 4 .default: ; We got here because we had no -p argument, ; and did not find the PINHOLE envar. mov esi, pinhole ; fall through align 4 .init: sub eax, eax sub ebx, ebx sub ecx, ecx sub edx, edx mov edi, dbuffer+1 mov byte [dbuffer], '0' ; Convert the pinhole constant to real .constloop: lodsb cmp al, '9' ja .setconst cmp al, '0' je .processconst jb .setconst inc dl .processconst: inc cl cmp cl, 18 ja near consttoobig stosb jmp short .constloop align 4 .setconst: or dl, dl je near perr finit fild dword [tthou] fld1 fild dword [ten] fdivp st1, st0 fild dword [thousand] mov edi, obuffer mov ebp, ecx call bcdload .constdiv: fmul st0, st2 loop .constdiv fld1 fadd st0, st0 fadd st0, st0 fld1 faddp st1, st0 fchs ; If we are creating a CSV file, ; print header cmp byte [separ], ',' jne .bigloop push dword headlen push dword header push dword [fd.out] sys.write .bigloop: call getchar jc near done ; Skip to the end of the line if you got '#' cmp al, '#' jne .num call skiptoeol jmp short .bigloop .num: ; See if you got a number cmp al, '0' jl .bigloop cmp al, '9' ja .bigloop ; Yes, we have a number sub ebp, ebp sub edx, edx .number: cmp al, '0' je .number0 mov dl, 1 .number0: or dl, dl ; Skip leading 0's je .nextnumber push eax call putchar pop eax inc ebp cmp ebp, 19 jae .nextnumber mov [dbuffer+ebp], al .nextnumber: call getchar jc .work cmp al, '#' je .ungetc cmp al, '0' jl .work cmp al, '9' ja .work jmp short .number .ungetc: dec esi inc ebx .work: ; Now, do all the work or dl, dl je near .work0 cmp ebp, 19 jae near .toobig call bcdload ; Calculate pinhole diameter fld st0 ; save it fsqrt fmul st0, st3 fld st0 fmul st5 sub ebp, ebp ; Round off to 4 significant digits .diameter: fcom st0, st7 fstsw ax sahf jb .printdiameter fmul st0, st6 inc ebp jmp short .diameter .printdiameter: call printnumber ; pinhole diameter ; Calculate F-number fdivp st1, st0 fld st0 sub ebp, ebp .fnumber: fcom st0, st6 fstsw ax sahf jb .printfnumber fmul st0, st5 inc ebp jmp short .fnumber .printfnumber: call printnumber ; F number ; Calculate normalized F-number fmul st0, st0 fld1 fld st1 fyl2x frndint fld1 fscale fsqrt fstp st1 sub ebp, ebp call printnumber ; Calculate time multiplier from F-5.6 fscale fld st0 ; Round off to 4 significant digits .fmul: fcom st0, st6 fstsw ax sahf jb .printfmul inc ebp fmul st0, st5 jmp short .fmul .printfmul: call printnumber ; F multiplier ; Calculate F-stops from 5.6 fld1 fxch st1 fyl2x sub ebp, ebp call printnumber mov al, 0Ah call putchar jmp .bigloop .work0: mov al, '0' call putchar align 4 .toobig: call huh jmp .bigloop align 4 done: call write ; flush output buffer ; close files push dword [fd.in] sys.close push dword [fd.out] sys.close finit ; return success push dword 0 sys.exit align 4 skiptoeol: ; Keep reading until you come to cr, lf, or eof call getchar jc done cmp al, 0Ah jne .cr ret .cr: cmp al, 0Dh jne skiptoeol ret align 4 getchar: or ebx, ebx jne .fetch call read .fetch: lodsb dec ebx clc ret read: jecxz .read call write .read: push dword BUFSIZE mov esi, ibuffer push esi push dword [fd.in] sys.read add esp, byte 12 mov ebx, eax or eax, eax je .empty sub eax, eax ret align 4 .empty: add esp, byte 4 stc ret align 4 putchar: stosb inc ecx cmp ecx, BUFSIZE je write ret align 4 write: jecxz .ret ; nothing to write sub edi, ecx ; start of buffer push ecx push edi push dword [fd.out] sys.write add esp, byte 12 sub eax, eax sub ecx, ecx ; buffer is empty now .ret: ret align 4 bcdload: ; EBP contains the number of chars in dbuffer push ecx push esi push edi lea ecx, [ebp+1] lea esi, [dbuffer+ebp-1] shr ecx, 1 std mov edi, bbuffer sub eax, eax mov [edi], eax mov [edi+4], eax mov [edi+2], ax .loop: lodsw sub ax, 3030h shl al, 4 or al, ah mov [edi], al inc edi loop .loop fbld [bbuffer] cld pop edi pop esi pop ecx sub eax, eax ret align 4 printnumber: push ebp mov al, [separ] call putchar ; Print the integer at the TOS mov ebp, bbuffer+9 fbstp [bbuffer] ; Check the sign mov al, [ebp] dec ebp or al, al jns .leading ; We got a negative number (should never happen) mov al, '-' call putchar .leading: ; Skip leading zeros mov al, [ebp] dec ebp or al, al jne .first cmp ebp, bbuffer jae .leading ; We are here because the result was 0. ; Print '0' and return mov al, '0' jmp putchar .first: ; We have found the first non-zero. ; But it is still packed test al, 0F0h jz .second push eax shr al, 4 add al, '0' call putchar pop eax and al, 0Fh .second: add al, '0' call putchar .next: cmp ebp, bbuffer jb .done mov al, [ebp] push eax shr al, 4 add al, '0' call putchar pop eax and al, 0Fh add al, '0' call putchar dec ebp jmp short .next .done: pop ebp or ebp, ebp je .ret .zeros: mov al, '0' call putchar dec ebp jne .zeros .ret: ret
代码遵循与我们之前看到的其他所有过滤器相同的格式,但有一个细微的例外。
我们不再假设输入的结束意味着要执行的操作的结束,这是我们在面向字符的过滤器中认为理所当然的事情。
此过滤器不处理字符。它处理一种语言(尽管它非常简单,仅由数字组成)。
当我们没有更多输入时,它可能意味着两件事之一。
我们完成了,可以退出。这与之前相同。
我们读取的最后一个字符是数字。我们已将其存储在ASCII到浮点数转换缓冲区的末尾。现在我们需要将缓冲区的内容转换为数字并写入输出的最后一行。
为此,我们修改了
getchar
和read
例程,以便在每次从输入中获取另一个字符时,都以清除的进位标志
返回,或者在没有更多输入时,都以设置的进位标志
返回。当然,我们仍然使用汇编语言的技巧来做到这一点!仔细看看
getchar
。它始终以清除的进位标志
返回。然而,我们的主代码依赖于
进位标志
来告诉它何时退出——并且它有效。技巧在于
read
。每当它从系统接收更多输入时,它就会返回到getchar
,后者从输入缓冲区中获取一个字符,清除进位标志
并返回。但是当
read
从系统接收不到更多输入时,它根本不会返回到getchar
。相反,add esp, byte 4
操作码将4
添加到ESP
,设置进位标志
并返回。那么它返回到哪里?每当程序使用
call
操作码时,微处理器都会push
返回地址,即将其存储在栈顶(不是FPU栈,而是系统栈,它位于内存中)。当程序使用ret
操作码时,微处理器会从栈中pop
返回地址,并跳转到存储在那里的地址。但是由于我们将
4
添加到ESP
(它是栈指针寄存器),因此我们实际上使微处理器出现了轻微的健忘症:它不再记得是getchar
调用了read
。并且由于
getchar
在调用read
之前从未push
任何内容,因此栈顶现在包含返回到调用getchar
的任何内容或任何人的返回地址。就该调用者而言,他调用了getchar
,后者以设置的进位标志
返回!
除此之外,bcdload
例程陷入大端和小端之间的小人国冲突之中。
它将数字的文本表示转换为该数字:文本以大端顺序存储,但打包十进制是小端。
为了解决冲突,我们在早期使用std
操作码。我们稍后用cld
取消它:在我们启用std
时,我们绝对不能调用任何可能依赖于方向标志默认设置的内容。
如果阅读了前面整章内容,此代码中的其他所有内容都应该非常清楚。
这是一个编程需要大量思考,而只需要少量编码的格言的经典例子。一旦我们仔细思考了每一个细节,代码几乎就会自动编写。
A.13.6. 使用pinhole
因为我们决定让程序忽略除数字(甚至注释中的数字)之外的任何输入,所以我们实际上可以执行文本查询。我们不必,但我们可以。
恕我直言,形成文本查询,而不是必须遵循非常严格的语法,使得软件更加用户友好。
假设我们想制造一个针孔相机来使用4x5英寸胶片。该胶片的标准焦距约为150毫米。我们希望微调我们的焦距,使针孔直径尽可能接近一个圆数。让我们也假设我们对相机非常熟悉,但对电脑有些畏惧。我们不想仅仅输入一堆数字,而是想询问几个问题。
我们的会话可能如下所示
% pinhole
Computer,
What size pinhole do I need for the focal length of 150?
150 490 306 362 2930 12
Hmmm... How about 160?
160 506 316 362 3125 12
Let's make it 155, please.
155 498 311 362 3027 12
Ah, let's try 157...
157 501 313 362 3066 12
156?
156 500 312 362 3047 12
That's it! Perfect! Thank you very much!
^D
我们发现,对于150的焦距,我们的针孔直径应为490微米,或0.49毫米,如果我们使用几乎相同的156毫米焦距,我们可以使用正好0.5毫米的针孔直径。
A.13.7. 脚本
因为我们选择#
字符来表示注释的开头,所以我们可以将我们的pinhole软件视为一种脚本语言。
你可能见过以以下内容开头的shell脚本
#! /bin/sh
…或…
#!/bin/sh
…因为#!
后面的空格是可选的。
每当UNIX®被要求运行以#!
开头的可执行文件时,它都会假设该文件是一个脚本。它将命令添加到脚本的第一行的其余部分,并尝试执行该命令。
现在假设我们已将pinhole安装在/usr/local/bin/中,我们现在可以编写一个脚本,计算适用于120胶片常用焦距的各种针孔直径。
脚本可能如下所示
#! /usr/local/bin/pinhole -b -i # Find the best pinhole diameter # for the 120 film ### Standard 80 ### Wide angle 30, 40, 50, 60, 70 ### Telephoto 100, 120, 140
因为 120 是一种中等尺寸的胶片,所以我们可以将此文件命名为 medium。
我们可以将其权限设置为可执行,并像运行程序一样运行它。
% chmod 755 medium
% ./medium
UNIX® 将把最后一个命令解释为
% /usr/local/bin/pinhole -b -i ./medium
它将运行该命令并显示
80 358 224 256 1562 11
30 219 137 128 586 9
40 253 158 181 781 10
50 283 177 181 977 10
60 310 194 181 1172 10
70 335 209 181 1367 10
100 400 250 256 1953 11
120 438 274 256 2344 11
140 473 296 256 2734 11
现在,让我们输入
% ./medium -c
UNIX® 将将其视为
% /usr/local/bin/pinhole -b -i ./medium -c
这给了它两个冲突的选项:-b
和 -c
(使用 Bender 常数和使用 Connors 常数)。我们已经对其进行了编程,以便后面的选项覆盖前面的选项——我们的程序将使用 Connors 常数计算所有内容。
80 331 242 256 1826 11
30 203 148 128 685 9
40 234 171 181 913 10
50 262 191 181 1141 10
60 287 209 181 1370 10
70 310 226 256 1598 11
100 370 270 256 2283 11
120 405 296 256 2739 11
140 438 320 362 3196 12
我们决定最终使用 Bender 常数。我们希望将其值保存为逗号分隔的文件。
% ./medium -b -e > bender
% cat bender
focal length in millimeters,pinhole diameter in microns,F-number,normalized F-number,F-5.6 multiplier,stops from F-5.6
80,358,224,256,1562,11
30,219,137,128,586,9
40,253,158,181,781,10
50,283,177,181,977,10
60,310,194,181,1172,10
70,335,209,181,1367,10
100,400,250,256,1953,11
120,438,274,256,2344,11
140,473,296,256,2734,11
%
A.14. 警告
在 MS-DOS® 和 Windows® 下“成长”起来的汇编语言程序员往往倾向于走捷径。读取键盘扫描码和直接写入视频内存是两种典型的做法,在 MS-DOS® 下,这些做法并不被反对,而是被认为是正确的事情。
原因是什么?PC BIOS 和 MS-DOS® 在执行这些操作时都非常慢。
您可能会倾向于在 UNIX® 环境中继续类似的做法。例如,我见过一个网站解释如何在流行的 UNIX® 克隆上访问键盘扫描码。
这在 UNIX® 环境中通常是一个非常糟糕的主意!让我解释一下原因。
A.14.1. UNIX® 受保护
首先,这可能根本不可能。UNIX® 在保护模式下运行。只有内核和设备驱动程序才能直接访问硬件。也许某个特定的 UNIX® 克隆会允许您读取键盘扫描码,但很有可能真正的 UNIX® 操作系统不会。即使一个版本允许您这样做,下一个版本可能不允许,因此您精心制作的软件可能会一夜之间变成恐龙。
A.14.2. UNIX® 是一个抽象
但是,有一个更重要的理由不尝试直接访问硬件(除非,当然,您正在编写设备驱动程序),即使在允许您这样做的类 UNIX® 系统上也是如此。
UNIX® 是一个抽象!
MS-DOS® 和 UNIX® 的设计理念存在重大差异。MS-DOS® 被设计为单用户系统。它运行在连接到该计算机的键盘和视频屏幕的计算机上。用户输入几乎可以保证来自该键盘。您的程序输出几乎总是最终显示在该屏幕上。
在 UNIX® 下,这绝不能保证。UNIX® 用户经常使用管道和重定向程序输入和输出。
% program1 | program2 | program3 > file1
如果您编写了 program2,则您的输入不是来自键盘,而是来自 program1 的输出。类似地,您的输出不会发送到屏幕,而是成为 program3 的输入,其输出依次发送到 file1。
但还有更多!即使您确保输入来自终端,输出也发送到终端,也不能保证终端是 PC:它可能没有您期望的视频内存,其键盘也可能不会产生 PC 风格的扫描码。它可能是 Macintosh® 或任何其他计算机。
现在您可能在摇头:我的软件是用 PC 汇编语言编写的,它如何在 Macintosh® 上运行?但我并没有说您的软件将在 Macintosh® 上运行,只是说它的终端可能是 Macintosh®。
在 UNIX® 下,终端不必直接连接到运行软件的计算机,它甚至可以位于另一个大陆,或者在另一个星球上。Macintosh® 用户在澳大利亚通过 telnet 连接到北美(或任何其他地方)的 UNIX® 系统是完全可能的。然后软件在一个计算机上运行,而终端在另一台计算机上:如果您尝试读取扫描码,您将获得错误的输入!
任何其他硬件也是如此:您正在读取的文件可能位于您无法直接访问的磁盘上。您正在读取图像的相机可能位于航天飞机上,通过卫星连接到您。
这就是为什么在 UNIX® 下,您绝不能对数据来自哪里以及去往哪里做出任何假设。始终让系统处理对硬件的物理访问。
这些是警告,而不是绝对规则。例外情况是可能的。例如,如果文本编辑器已确定它在本地机器上运行,它可能希望直接读取扫描码以获得更好的控制。我提到这些警告并不是要告诉您该做什么或不该做什么,只是让您意识到如果您刚从 MS-DOS® 迁移到 UNIX®,那么您将面临哪些陷阱。当然,有创意的人经常打破规则,只要他们知道自己在打破规则以及原因,这就可以了。 |
A.15. 致谢
如果没有来自 FreeBSD 技术讨论邮件列表 的许多经验丰富的 FreeBSD 程序员的帮助,本教程将永远不可能完成,他们中的许多人耐心解答了我的问题,并在我尝试探索 UNIX® 系统编程(一般而言)和 FreeBSD(特别而言)的内部工作原理时指引我走上了正确的方向。
Thomas M. Sommers 为我打开了大门。他的 如何在 FreeBSD 汇编程序中编写“Hello, world”? 网页是我第一次接触 FreeBSD 下的汇编语言编程示例。
Jake Burkholder 通过自愿回答我所有的问题并向我提供汇编语言示例源代码,一直为我敞开大门。
版权所有 ® 2000-2001 G. Adam Stanislav。保留所有权利。
上次修改时间:2024 年 8 月 11 日,作者 Fernando Apesteguía