第 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.2.2. 链接器

汇编器的输出,就像任何编译器的输出一样,都需要链接才能形成可执行文件。

标准的 ld(1) 链接器随 FreeBSD 提供。它可以与任何汇编器汇编的代码一起使用。

A.3. 系统调用

A.3.1. 默认调用约定

默认情况下,FreeBSD 内核使用 C 调用约定。此外,虽然内核是使用 int 80h 访问的,但假设程序会调用发出 int 80h 的函数,而不是直接发出 int 80h

这种约定非常方便,并且优于 MS-DOS® 使用的 Microsoft® 约定。为什么?因为 UNIX® 约定允许任何语言编写的任何程序访问内核。

汇编语言程序也可以这样做。例如,我们可以打开一个文件

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

这是一种非常简洁且可移植的编码方式。如果您需要将代码移植到使用不同中断或不同参数传递方式的 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中查找返回值。如果不在那里,则需要进一步研究。

我知道有一个系统调用将返回值放在EDX中:SYS_fork。我使用过的其他所有系统调用都使用EAX。但我还没有使用过所有系统调用。

如果在这里或其他任何地方都找不到答案,请研究libc源代码,看看它是如何与内核交互的。

A.4.3. errno在哪里?

实际上,任何地方都没有……

errno是C语言的一部分,而不是UNIX®内核的一部分。当直接访问内核服务时,错误代码返回到EAX中,通常正确的返回值也会存储到同一个寄存器中。

这是非常合理的。如果没有错误,则没有错误代码。如果有错误,则没有返回值。一个寄存器可以包含两者之一。

A.4.4. 如何确定发生了错误?

当使用标准的FreeBSD调用约定时,成功时进位标志被清除,失败时被设置。

当使用Linux仿真模式时,EAX中的有符号值在成功时是非负的,并包含返回值。如果发生错误,则值为负数,即-errno

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而不是0D 0A结尾。这是因为UNIX®不使用cr/lf约定,而是使用“换行”约定,在十六进制中为0A

我们能改进这一点吗?好吧,一方面,它有点令人困惑,因为一旦我们转换了一行文本,我们的输入就不再从行的开头开始。我们可以修改它,在每个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将参数传递给寄存器的约定的原因:我们可以将寄存器保留供自己使用。

我们使用EDIESI作为指向要读取或写入的下一个字节的指针。我们使用EBXECX来记录两个缓冲区中的字节数,因此我们知道何时将输出转储到系统或从系统读取更多输入。

让我们看看它现在是如何工作的

% 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.infd.out。我们在这里存储输入和输出文件描述符。

.text段中,我们用[fd.in][fd.out]替换了对stdinstdout的引用。

.text段现在以一个简单的错误处理程序开始,它除了以返回值1退出程序之外什么也不做。错误处理程序在_start之前,因此我们距离错误发生的地方很近。

当然,程序执行仍然从_start开始。首先,我们从堆栈中移除argcargv[0]:它们对我们来说没有兴趣(在这个程序中是这样)。

我们将argv[1]弹出到ECX。这个寄存器特别适合指针,因为我们可以用jecxz处理空指针。如果argv[1]不为空,我们尝试打开第一个参数中命名的文件。否则,我们像以前一样继续程序:从stdin读取,写入stdout。如果我们无法打开输入文件(例如,它不存在),我们将跳转到错误处理程序并退出。

如果一切顺利,我们现在检查第二个参数。如果它存在,我们打开输出文件。否则,我们将输出发送到stdout。如果我们无法打开输出文件(例如,它存在并且我们没有写权限),我们再次跳转到错误处理程序。

其余代码与以前相同,除了我们在退出之前关闭输入和输出文件,并且如前所述,我们使用[fd.in][fd.out]

我们的可执行文件现在长达768字节。

我们还能改进它吗?当然!每个程序都可以改进。以下是一些我们可以做的事情的想法

  • 让我们的错误处理程序向stderr打印一条消息。

  • readwrite函数添加错误处理程序。

  • 当我们打开输入文件时关闭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.1.1. 结束状态

上面这个有限状态机适用于整个文件,但存在最终行尾可能被忽略的可能性。只要文件以单个回车符或单个换行符结尾,就会发生这种情况。我在编写 tuc 时没有考虑到这一点,只是后来发现它偶尔会去除最后一行结尾。

这个问题很容易解决,只需在处理完整个文件后检查状态即可。如果状态不是普通状态,则只需输出一个最后的换行符。

现在我们已经将算法表示为有限状态机,我们可以很容易地设计一个专用的数字电子电路(一个“芯片”)来为我们进行转换。当然,这样做比编写汇编语言程序要昂贵得多。

A.11.1.2. 输出计数器

因为我们的文件转换程序可能会将两个字符合并成一个,所以我们需要使用一个输出计数器。我们将其初始化为0,并在每次向输出发送字符时递增它。在程序结束时,计数器将告诉我们需要将文件设置为多大。

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的一个优点是它可以自动与虚拟内存一起工作:我们可以将比物理内存更多的文件映射到内存中,但仍然可以通过常规内存操作码(如movlodsstos)访问它。我们对文件内存映像所做的任何更改都将由系统写入文件。我们甚至不必保持文件打开状态:只要它保持映射状态,我们就可以读取和写入它。

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); }

这与mmap(2)所说的略有不同。这是因为mmap(2)描述的是C版本。

区别在于long pad参数,它在C版本中不存在。但是,FreeBSD系统调用在push一个64位参数后添加了一个32位的填充。在这种情况下,off_t是一个64位值。

当我们完成对内存映射文件的操作后,我们使用munmap系统调用取消映射它

有关mmap的深入处理,请参阅W. Richard Stevens的Unix网络编程,第2卷,第12章

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系统上挂载这些驱动器时,使用mmap的FreeBSD代码中似乎存在一个细微的错误:如果文件超过一定大小,mmap将只用零填充内存,然后将它们复制到文件,覆盖其内容。

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 选项允许我指定输入和输出文件。默认值为 stdinstdout,因此这是一个常规的 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,否则很可能会死锁。

以下是可能发生的情况:

  1. 图像编辑器将使用 C 函数 popen() 加载我们的过滤器。

  2. 它将从位图或像素图读取第一行像素。

  3. 它将第一行像素写入通向我们过滤器 fd.in 的管道。

  4. 我们的过滤器将从其输入读取每个像素,将其转换为负数,并将其写入其输出缓冲区。

  5. 我们的过滤器将调用 getchar 以获取下一个像素。

  6. getchar 将发现输入缓冲区为空,因此它将调用 read

  7. read 将调用 SYS_read 系统调用。

  8. 内核将挂起我们的过滤器,直到图像编辑器向管道发送更多数据。

  9. 图像编辑器将从连接到我们过滤器 fd.out 的另一个管道读取,以便它可以在向我们发送输入的第二行之前设置输出图像的第一行。

  10. 内核挂起图像编辑器,直到它从我们的过滤器接收到一些输出,以便它可以将其传递给图像编辑器。

此时,我们的过滤器等待图像编辑器发送更多数据以供处理,而图像编辑器则等待我们的过滤器发送第一行处理结果。但是结果位于我们的输出缓冲区中。

过滤器和图像编辑器将永远相互等待(或者至少直到它们被杀死)。我们的软件刚刚进入了竞争条件

如果我们的过滤器在请求内核获取更多输入数据之前刷新其输出缓冲区,则不存在此问题。

A.13. 使用 FPU

奇怪的是,大多数汇编语言文献甚至都没有提及 FPU(浮点单元)的存在,更不用说讨论对其进行编程了。

然而,当我们通过执行可以在汇编语言中执行的操作来创建高度优化的 FPU 代码时,汇编语言的光芒从未如此耀眼。

A.13.1. FPU 的组织

FPU 由 8 个 80 位浮点寄存器组成。这些寄存器以堆栈方式组织——您可以将值push到 TOS(堆栈顶部),也可以将其pop出来。

也就是说,汇编语言操作码不是 pushpop,因为这些操作码已经被使用了。

您可以使用 fldfildfbld 将值push到 TOS。其他一些操作码允许您将许多常见的常量(例如pipush到 TOS 上。

类似地,您可以使用 fstfstpfistfistpfbstp 将值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)。然后,stst(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.2.5. 标准化 F 数

虽然许多现代相机可以相当平滑且逐渐地改变其小孔直径,从而改变其 F 数,但情况并非总是如此。

为了允许不同的 F 数,相机通常包含一块金属板,上面钻有几个不同尺寸的孔。

它们的尺寸是根据上述公式选择的,这样得到的 F 数就是所有相机都使用的标准 F 数之一。例如,我拥有的一个非常旧的柯达 Duaflex IV 相机就有三个这样的孔,分别对应 F 数 8、11 和 16。

最近生产的相机可能会提供 2.8、4、5.6、8、11、16、22 和 32 等 F 数(以及其他一些)。这些数字并非随意选择的:它们都是 2 的平方根的幂,尽管可能略有舍入。

A.13.2.6. 光圈档

典型的相机设计使得设置任何标准化 F 数都会改变旋钮的手感。它会自然地在那个位置。因此,旋钮的这些位置被称为光圈档。

由于每个光圈档的 F 数都是 2 的平方根的幂,因此将旋钮移动 1 档会使正确曝光所需的光量加倍。移动 2 档会使所需曝光量增加 4 倍。移动 3 档将需要曝光量增加 8 倍,依此类推。

A.13.3. 设计小孔软件

现在我们准备决定我们到底希望我们的针孔软件做什么。

A.13.3.1. 处理程序输入

由于其主要目的是帮助我们设计一个工作的小孔相机,我们将使用焦距作为程序的输入。这是我们可以不用软件就能确定的东西:正确的焦距由胶片的大小和拍摄“普通”照片、广角照片或长焦照片的需要决定。

到目前为止,我们编写的许多程序都将单个字符或字节作为输入:hex 程序将单个字节转换为十六进制数,csv 程序要么让字符通过,要么删除它,要么将其更改为不同的字符,等等。

一个程序 ftuc 使用状态机一次最多考虑两个输入字节。

但是我们的针孔程序不能只处理单个字符,它必须处理更大的语法单元。

例如,如果我们希望程序在 100 mm150 mm210 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

等等,等等,等等。

其次,我喜欢用 # 字符表示注释的开始,该注释一直延伸到行尾。这不需要花费太多精力来编码,并允许我将软件的输入文件视为可执行脚本。

在我们的例子中,我们还需要决定输入应该使用什么单位:我们选择毫米,因为大多数摄影师都是这样测量焦距的。

最后,我们需要决定是否允许使用小数点(在这种情况下,我们还必须考虑世界上许多地方使用小数逗号)。

在我们的例子中,允许使用小数点/逗号会产生一种虚假的精确感:5051 的焦距之间几乎没有明显的区别,因此允许用户输入类似 50.5 的内容不是一个好主意。这是我的意见,请注意,但我才是编写这个程序的人。当然,你可以在自己的程序中做出其他选择。

A.13.3.2. 提供选项

构建针孔相机时,我们需要知道的最重要的事情是小孔的直径。由于我们希望拍摄清晰的图像,我们将使用上述公式根据焦距计算小孔直径。由于专家们对 PC 常数提出了几个不同的值,因此我们需要进行选择。

在 UNIX® 编程中,传统上使用两种主要方法来选择程序参数,并在用户不进行选择时提供默认值。

为什么要使用两种选择方法?

一种是允许进行永久性的选择,该选择在每次运行软件时都会自动应用,而无需我们一遍又一遍地告诉它我们想要它做什么。

永久性选择可以存储在配置文件中,该文件通常位于用户的 home 目录中。该文件通常与应用程序具有相同的名称,但以点开头。通常还会添加“rc”到文件名中。因此,我们的文件可以是 ~/.pinhole~/.pinholerc。(~/ 表示当前用户的 home 目录。)

配置文件主要由具有许多可配置参数的程序使用。那些只有一个(或几个)参数的程序通常使用不同的方法:它们期望在环境变量中找到该参数。在我们的例子中,我们可能会查看名为 PINHOLE 的环境变量。

通常,程序使用上述方法之一。否则,如果配置文件说了一件事,但环境变量说了另一件事,程序可能会感到困惑(或者变得过于复杂)。

因为我们只需要选择一个这样的参数,所以我们将使用第二种方法,并在环境中搜索名为 PINHOLE 的变量。

另一种方法允许我们做出临时的决定:“虽然我通常希望你使用 0.039,但这次我想要 0.03872。”换句话说,它允许我们覆盖永久性选择。

这种类型的选择通常通过命令行参数完成。

最后,程序始终需要一个默认值。用户可能不会做出任何选择。也许他不知道该选择什么。也许他只是“浏览”。最好是默认值是大多数用户无论如何都会选择的值。这样他们就不需要选择。或者,更确切地说,他们可以在不额外努力的情况下选择默认值。

鉴于此系统,程序可能会发现冲突的选项,并以这种方式处理它们

  1. 如果它发现一个临时选择(例如,命令行参数),它应该接受该选择。它必须忽略任何永久性选择和任何默认值。

  2. 否则,如果它找到一个永久性选项(例如,环境变量),它应该接受它,并忽略默认值。

  3. 否则,它应该使用默认值。

我们还需要决定我们的 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并开始执行三个步骤

  1. 将数字发送到输出。

  2. 将数字附加到一个缓冲区,我们稍后将使用它来生成我们可以发送到 FPU 的打包十进制数。

  3. 增加计数器。

现在,在我们执行这三个步骤的同时,我们还需要注意两种情况之一

  • 如果计数器增长到超过 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 不提供四舍五入到特定位数的功能(毕竟,它不将数字视为十进制,而是视为二进制)。

因此,我们必须设计一个算法来减少有效数字的位数。

这是我的算法(我认为它很笨拙——如果你知道更好的算法,告诉我)

  1. 将计数器初始化为0

  2. 当数字大于或等于10000时,将其除以10并增加计数器。

  3. 输出结果。

  4. 当计数器大于0时,输出0并减少计数器。

10000仅在您想要位有效数字时才有效。对于任何其他有效数字位数,将10000替换为10的有效数字位数次幂。

然后,我们将输出以微米为单位的针孔直径,四舍五入到四位有效数字。

在这一点上,我们知道焦距针孔直径。这意味着我们有足够的信息来计算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)的内容平方。fld11压入 TOS。

下一行,fld st1,将平方推回 TOS。此时,平方同时存在于stst(2)中(稍后将清楚为什么我们在堆栈上保留第二个副本)。st(1)包含1

接下来,fyl2x计算st乘以st(1)的以 2 为底的对数。这就是我们在之前将1放在st(1)上的原因。

此时,st包含我们刚刚计算的对数,st(1)包含我们为以后保存的实际 f 数的平方。

frndint将 TOS 四舍五入到最接近的整数。fld1压入一个1fscale将 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)等于-5fscale它。这比除法快得多

因此,现在已经清楚为什么我们将 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进行比较。因此,我们将100010000都保存在栈中。当然,我们在将数字四舍五入到四位数字时会重复使用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到浮点数转换缓冲区的末尾。现在我们需要将缓冲区的内容转换为数字并写入输出的最后一行。

为此,我们修改了getcharread例程,以便在每次从输入中获取另一个字符时,都以清除进位标志返回,或者在没有更多输入时,都以设置进位标志返回。

当然,我们仍然使用汇编语言的技巧来做到这一点!仔细看看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