Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
欢迎来到开发者手册。本手册还在不断完善中,是许多人共同努力的成果。许多部分尚不存在,一些已存在的部分也需要更新。如果您有兴趣参与这个项目,请发送电子邮件至 FreeBSD 文档项目邮件列表。
本文档的最新版本始终可从 FreeBSD 全球网络服务器获取。也可以从 FreeBSD 下载服务器或众多镜像站点之一以各种格式和压缩选项下载。
版权 © 1995-2023 FreeBSD 文档计划
适用于希望为 FreeBSD 开发软件的人(而不仅仅是开发 FreeBSD 本身的人)
本章是关于如何使用 FreeBSD 附带的一些编程工具的介绍,尽管其中大部分内容同样适用于许多其他版本的 UNIX®。本章不会尝试详细描述编码过程。大多数内容假设读者几乎没有或根本没有编程经验,尽管希望大多数程序员仍能从中找到一些有价值的内容。
然而,如果你从未在 UNIX® 平台上编写过程序,这些强大工具在最初可能会令人感到困惑。本文档的目标是帮助你快速上手,而不深入涉及高级主题。我们希望本文档能为你提供足够的基础知识,使你能够理解相关文档的内容。
大多数内容几乎不需要任何编程知识,尽管它假设你对 UNIX® 的基本操作已有一定掌握,并且愿意学习!
程序是一组指令,告诉计算机执行各种操作;有时它要执行的指令还取决于之前执行某条指令时发生了什么。本节将概述你可以给出这些指令(通常称为“命令”)的两种主要方式:一种是使用 解释器,另一种是使用 编译器。由于人类语言对于计算机来说太复杂,难以实现明确理解,因此这些命令通常以专门为此设计的语言编写。
使用解释器时,语言本身是作为一个环境存在的,你可以在提示符下输入命令,解释器会立即执行这些命令。对于更复杂的程序,你可以把命令写进一个文件,然后让解释器加载并执行这个文件中的命令。如果出错,许多解释器会将你带入调试器,以帮助你查找问题。
这种方式的优点在于你可以立即看到命令的执行结果,错误也可以快速修正。最大的缺点是在你想与他人分享程序时,对方必须拥有相同的解释器,或者你必须以某种方式提供给他们这个解释器,并且他们还需要知道如何使用它。此外,用户在按错键后直接进入调试器的情况,可能也会让人不太舒服。从性能角度来看,解释器通常消耗较多内存,生成的代码效率也不如编译器高。
我认为,如果你从未编程过,解释型语言是最好的入门方式。这类环境典型地见于 Lisp、Smalltalk、Perl 和 Basic 等语言。也有人认为 UNIX® 的 shell(如 sh
、csh
)本身就是一种解释器,事实上很多人确实会编写 shell “脚本”来辅助完成他们机器上的各种“维护”任务。实际上,UNIX® 最初的理念之一就是提供许多可以在 shell 脚本中组合使用的小型实用程序,以完成有用的任务。
下面是一些可以通过 FreeBSD Ports 获取的解释器列表,并简要介绍了一些较为流行的解释型语言。
BASIC BASIC 是 “Beginner’s All-purpose Symbolic Instruction Code”(初学者通用符号指令代码) 的缩写。它在 20 世纪 50 年代被开发出来,用于教授大学生编程;而在 20 世纪 80 年代,每台自重的个人计算机都配备了 BASIC,使它成为许多程序员的第一门编程语言。它也是 Visual Basic 的基础。
Lisp Lisp 是在 20 世纪 50 年代末期开发的一种语言,用于替代当时流行的“数值计算”语言。它不是基于数字,而是基于列表;事实上,其名称就是 “List Processing”(列表处理) 的缩写。它在人工智能(AI)领域中非常流行。
Lisp 是一种极其强大而复杂的语言,但可能显得庞大且不易掌握。
Perl Perl 在系统管理员中非常流行,用于编写脚本;它也常用于 Web 服务器上编写 CGI 脚本。
Scheme Scheme 是 Lisp 的方言,它比 Common Lisp 更紧凑、更清晰。在大学中很受欢迎,因为它足够简单,可以作为初学者的第一门语言教学,同时抽象程度也高,适合用于科研工作。
Python Python 是一种面向对象的解释型语言。它的支持者认为这是一门非常适合初学者的语言,因为它容易上手,但并不像其他用于开发大型复杂应用的解释型语言那样受到限制(Perl 和 Tcl 是另外两种常用于此类开发的语言)。
Ruby Ruby 是一种解释型的纯面向对象编程语言。它因其易于理解的语法、编写灵活性强以及便于开发与维护大型复杂程序而广受欢迎。
Tcl 和 Tk Tcl 是一种可嵌入的解释型语言,因其良好的跨平台特性而得到广泛应用和普及。它既可用于快速编写小型原型应用,也可与 Tk(一个图形用户界面工具包)结合开发功能完备的正式程序。
显然,这种方式不像使用解释器那样直接。然而,它允许你做很多用解释器很难甚至不可能完成的事情,比如编写与操作系统密切交互的代码——甚至编写你自己的操作系统!如果你需要编写高效的代码,它也非常有用,因为编译器可以花时间优化代码,而解释器则无法接受这种优化。此外,为编译器编写的程序通常比为解释器编写的程序更易于分发——你只需要给他们一个可执行文件副本,前提是他们使用的操作系统与你相同。
cc
编译一旦你编写完你的杰作,下一步就是将它转换成能够(希望!)在 FreeBSD 上运行的形式。这通常涉及几个步骤,每个步骤都由一个独立的程序完成。
预处理你的源代码,移除注释并进行其他操作,如在 C 中展开宏。
检查你的代码的语法,查看你是否遵循了语言的规则。如果没有,它会报错!
将源代码转换为汇编语言——这非常接近机器代码,但仍然可以被人理解。据说。
将汇编语言转换为机器代码——是的,我们在谈论的是比特和字节,1 和 0。
检查你是否以一致的方式使用了诸如函数和全局变量之类的东西。例如,如果你调用了一个不存在的函数,它会报错。
如果你试图从多个源代码文件生成可执行文件,计算如何将它们组合在一起。
计算如何生成一个系统的运行时加载器能够加载到内存并运行的程序。
最后,将可执行文件写入文件系统。
“编译”一词通常仅指步骤 1 到 4,其余步骤被称为 链接。有时步骤 1 被称为 预处理,步骤 3-4 被称为 汇编。
幸运的是,几乎所有的细节都被隐藏了,因为 cc
是一个前端,它为你管理调用所有这些程序并传递正确的参数;只需输入
就会将 foobar.c 按照上述步骤进行编译。如果你有多个文件需要编译,只需像这样操作:
cc
有很多选项,都可以在手册页中找到。以下是一些最重要的选项,并附有如何使用它们的示例。
-c
仅编译文件,不进行链接。对于只想检查语法的简单程序,或使用 Makefile 的情况非常有用。
这将生成一个名为 foobar.o 的 目标文件(而不是可执行文件)。可以将该目标文件与其他目标文件一起链接,生成可执行文件。
-g
生成可调试版本的可执行文件。这会使编译器将有关源文件和函数调用行的信息添加到可执行文件中。调试器可以利用这些信息,在你单步调试程序时显示源代码,这 非常 有用;缺点是这些额外的信息会使程序变得更大。通常,在开发程序时使用 -g
编译,而在确认程序正常工作后,则不使用 -g
编译“发布版本”。
-O
生成优化版本的可执行文件。编译器执行各种巧妙的操作,尽力生成比普通版本运行更快的可执行文件。你可以在 -O
后添加一个数字,以指定更高等级的优化,但这往往会暴露编译器优化器中的 bug。
这将生成优化版的 foobar。
以下三个标志会强制 cc
检查你的代码是否符合相关的国际标准,通常称为 ANSI 标准,严格来说是 ISO 标准。
-Wall
启用 cc
作者认为值得启用的所有警告。尽管名称为 “Wall”,但它并不会启用 cc
能够生成的所有警告。
-ansi
关闭 cc
提供的大多数非 ANSI C 特性。尽管名称为 “ansi”,但它并不能严格保证你的代码符合标准。
-pedantic
关闭 cc
的 所有 非 ANSI C 特性。
没有这些标志,cc
将允许你使用一些其非标准的扩展功能。这些扩展虽然非常有用,但可能无法与其他编译器兼容——事实上,标准的主要目的之一就是允许人们编写能够在任何编译器和系统上运行的代码。这被称为 可移植代码。
通常,你应该尽量使代码具有可移植性,否则你可能需要在以后完全重写程序,以便它能够在其他地方工作——谁知道你几年后会使用什么呢?
这将在检查 foobar.c 是否符合标准后生成一个名为 foobar 的可执行文件。
-l <library>
指定在链接时使用的函数库。
最常见的例子是在编译一个使用 C 中一些数学函数的程序时。与大多数其他平台不同,这些数学函数被放在一个与标准 C 库分开的库中,你需要告诉编译器将其添加进去。
规则是,如果库的名称是 libsomething.a,你需要给 cc
传递 -l<something>
参数。例如,数学库是 libm.a,因此你需要传递 -lm
给 cc
。关于数学库的一个常见“陷阱”是,它必须是命令行中最后一个库。
这将把数学库的函数链接到 foobar 中。
如果你正在编译 C++ 代码,使用 c++
。在 FreeBSD 上,c++
也可以通过 clang++
调用。
这将从 C++ 源文件 foobar.cc 生成一个可执行文件 foobar。
cc
查询和问题记住,除非你特别告诉它,否则 cc
会将可执行文件命名为 a.out。使用 -o <filename>
选项:
ls
时能看到它,但当我在命令行中输入 foobar 时,告诉我没有这样的文件。为什么它找不到?与 MS-DOS® 不同,UNIX® 在查找你要运行的可执行文件时,不会自动在当前目录中查找,除非你告诉它。输入 ./foobar
,意思是“运行当前目录下名为 foobar 的文件”。
大多数 UNIX® 系统都有一个名为 test
的程序,它位于 /usr/bin 目录,Shell 在检查当前目录之前会先找到它。你可以输入:
或者给你的程序取个更好的名字!
core dump 这个名字来源于 UNIX® 初期,当时计算机使用核心内存来存储数据。基本上,如果程序在某些条件下失败,系统会将核心内存的内容写入一个名为 core 的文件,程序员可以查看该文件以找出问题所在。
这基本上意味着你的程序尝试对内存执行某种非法操作;UNIX® 设计的目的是保护操作系统和其他程序免受恶意程序的影响。
常见的原因包括:
尝试写入 NULL 指针,例如:
使用未初始化的指针,例如:
指针将具有一些随机值,运气好的话,它会指向一个程序无法访问的内存区域,内核会在程序产生任何损害之前终止它。如果运气不好,它可能会指向你程序内部的某个地方,破坏你的数据结构,导致程序神秘地失败。
尝试访问数组末尾之外的元素,例如:
尝试存储到只读内存中,例如:
UNIX® 编译器通常会将类似 "My string"
的字符串字面量放入只读内存区域。
对 malloc()
和 free()
做不当操作,例如:
或者
做出这些错误并不总是会导致程序出错,但它们总是糟糕的实践。某些系统和编译器对这些错误的容忍度不同,这就是为什么在一个系统上运行良好的程序,在另一个系统上可能会崩溃的原因。
不,幸运的是不是(当然,除非你真的遇到了硬件问题…)。这通常是指你以不应有的方式访问了内存。
是的,你可以这样做,只需去另一个控制台或 xterm,执行
找出你的程序的进程 ID,然后执行
其中 <pid>
是你查找的进程 ID。
如果你的程序陷入了死循环,这会很有用。如果程序捕获了 SIGABRT 信号,还有其他一些信号也有类似的效果。
make
?当你在处理一个简单的程序,只有一两个源文件时,输入
还算可以,但当有多个文件时,输入命令会变得非常繁琐——而且编译可能也会花费很长时间。
解决这个问题的一种方法是使用目标文件,并且只有在源代码发生变化时才重新编译源文件。所以我们可能会像这样:
如果我们只修改了 file37.c,而其他文件没有变化,则可以这样做。这样可以加快编译速度,但依然不能解决输入命令的问题。
或者我们可以写一个 shell 脚本来解决输入命令的问题,但它会重新编译所有文件,这在大型项目中非常低效。
如果我们有数百个源文件散布在不同地方呢?如果我们在一个团队中工作,而其他人没有告诉我们他们修改了我们使用的某个源文件怎么办?
也许我们可以将这两种方法结合起来,写一个 shell 脚本,其中包含某种规则,指示何时需要编译源文件。现在,我们需要一个可以理解这些规则的程序,因为这些规则对于 shell 来说有些复杂。
这个程序就是 make
。它读取一个名为 makefile 的文件,文件中指定了不同文件之间的依赖关系,并根据这些规则计算哪些文件需要重新编译,哪些不需要。例如,某个规则可能会说:“如果 fromboz.o 比 fromboz.c 旧,说明 fromboz.c 可能被修改过,所以需要重新编译。”makefile 还会包含告诉 make 如何重新编译源文件的规则,这使它成为一个非常强大的工具。
makefile 通常保存在与其适用的源文件相同的目录中,并且可以命名为 makefile、Makefile 或 MAKEFILE。大多数程序员使用 Makefile 这个名字,因为它在目录列表的顶部,更容易被发现。
make
的示例这是一个非常简单的 makefile:
它由两行组成,一行是依赖关系行,另一行是创建行。
依赖关系行由程序的名称(即 目标)组成,后面跟着一个冒号,空格,再跟上源文件的名称。当 make
读取这一行时,它会查看 foo 是否存在;如果存在,它会比较 foo 的最后修改时间和 foo.c 的最后修改时间。如果 foo 不存在,或者比 foo.c 旧,它就会查看创建行,了解该做什么。换句话说,这就是判断 foo.c 是否需要重新编译的规则。
创建行以一个制表符开始(按下 tab 键),然后是你在命令行中输入的命令,来创建 foo。如果 foo 已过期,或者不存在,make
就会执行这个命令来创建它。换句话说,这就是告诉 make 如何重新编译 foo.c 的规则。
因此,当你输入 make
时,make
会确保 foo 与你对 foo.c 的最新更改保持同步。这个原理可以扩展到有数百个目标的 Makefile——实际上,在 FreeBSD 上,你只需在合适的目录中输入 make world
就可以编译整个操作系统!
makefile 的另一个有用特点是,目标不一定非得是程序。例如,我们可以有一个像这样的 makefile:
我们可以通过输入以下命令告诉 make
我们想要创建哪个目标:
make
会只查看该目标并忽略其他目标。例如,如果我们输入 make foo
,make
会忽略 install
目标。
如果我们只输入 make
,make
将始终查看第一个目标,并在查看完该目标后停止,而不会查看其他目标。所以如果我们输入 make
,它会先处理 foo
目标,必要时重新编译 foo,然后停止,而不会继续处理 install
目标。
请注意,install
目标实际上并不依赖任何东西!这意味着,当我们输入 make install
来制作该目标时,接下来的命令始终会执行。在这种情况下,它会将 foo 复制到用户的家目录。这通常在应用程序的 makefile 中使用,以便在程序正确编译后,将应用程序安装到正确的目录中。
这个话题有些难以解释。如果你不完全理解 make
是如何工作的,最好的方法是编写一个简单的程序,如 hello world
,以及像上面那样的 makefile,并进行实验。然后,逐步尝试使用多个源文件,或者让源文件包含一个头文件。touch
命令在这里非常有用——它可以更改文件的日期,而不需要编辑它。
make
和包含文件C 代码通常以一系列要包含的文件开始,例如 stdio.h
。其中一些是系统包含文件,有些则是当前项目中的文件:
为了确保一旦 foo.h 被修改,这个文件会立刻重新编译,你需要在 Makefile 中添加它:
当你的项目变大,有越来越多的自定义包含文件时,跟踪所有包含文件及其依赖的文件将变得非常麻烦。如果你修改了一个包含文件,却忘记重新编译所有依赖于它的文件,结果可能会非常糟糕。clang
提供了一个选项来分析你的文件并生成包含文件及其依赖关系的列表:-MM
。
如果你在 Makefile 中添加以下内容:
并运行 make depend
,那么会生成一个 .depend 文件,内容包含对象文件、C 文件和包含文件的依赖关系:
如果你修改了 foo.h,下次运行 make
时,所有依赖于 foo.h 的文件都会重新编译。
每次添加包含文件时,别忘了运行 make depend
。
编写 Makefile 可能相当复杂。幸运的是,基于 BSD 的系统,如 FreeBSD,提供了一些非常强大的 Makefile,这些文件是系统的一部分。一个很好的例子就是 FreeBSD 的 ports 系统。以下是一个典型的 ports Makefile 的核心部分:
现在,如果我们进入该端口的目录并输入 make
,会发生以下几件事:
系统检查此端口的源代码是否已经存在。
如果不存在,将建立与 MASTER_SITES 中指定的 URL 的 FTP 连接来下载源代码。
系统计算源代码的校验和,并与已知的源代码校验和进行比较,确保源代码在传输过程中没有损坏。
应用所需的任何更改,使源代码能够在 FreeBSD 上正常工作——这称为 patching。
进行源代码所需的特殊配置。(许多 UNIX® 程序在编译时会试图找出它们运行的 UNIX® 版本和所支持的 UNIX® 特性——在 FreeBSD 的 ports 系统中,这些信息会被提供给源代码。)
编译程序的源代码。实际上,我们进入源代码解压的目录并执行 make
——程序自己的 makefile 已包含构建程序所需的信息。
我们现在得到了编译好的程序。如果需要,可以进行测试;当我们确认程序正常工作时,可以输入 make install
,这会将程序和任何需要的支持文件复制到正确的位置,并在 package database
中创建条目,以便以后如果改变主意,可以轻松卸载该端口。
现在你应该会同意,这个四行的脚本非常强大!
其中的秘密就在于最后一行,它告诉 make
查找系统的 makefile 文件 bsd.port.mk。这一行很容易被忽视,但正是它包含了所有的巧妙内容——有人编写了一个 makefile,告诉 make
执行上述所有操作(包括一些我没有提到的内容,如处理可能发生的错误),任何人只需要在自己的 makefile 中加上这一行,就可以使用这些功能!
如果你想查看这些系统的 makefile,它们位于 /usr/share/mk,但最好等你熟悉了 makefile 的使用后再去查看,因为它们非常复杂(如果查看时,记得准备好一瓶浓咖啡!)
make
的高级用法许多 ports 应用程序使用 GNU make,它提供了非常好的 "info" 页面。如果你已经安装了这些 ports,GNU make 会自动安装为 gmake
。它也可以作为一个独立的端口或包安装。
要查看 GNU make 的 info 页面,你需要编辑 /usr/local/info 目录下的 dir 文件,添加一行:
添加后,你可以输入 info
并从菜单中选择 make(或者在 Emacs 中,使用 C-h i
)。
使用调试器可以在更受控的环境下运行程序。通常,您可以逐行执行程序,检查变量的值,修改变量,指示调试器运行到某个特定位置后停止,等等。还可以附加到一个正在运行的程序,或加载核心文件以调查程序崩溃的原因。
注意
这两个调试器具有相似的功能集,因此选择使用哪个调试器很大程度上取决于个人喜好。如果只熟悉其中一个,可以使用该调试器。如果对两者都不熟悉,或者都熟悉但希望在 Emacs 中使用其中一个,应该选择 gdb
,因为 lldb
不支持 Emacs。否则,尝试两者并看看哪个更适合自己。
通过输入以下命令启动 lldb:
使用 -g
编译程序,以便充分利用 lldb
。即使不加 -g
也可以使用,但它将只显示当前正在运行的函数的名称,而不是源代码。如果它显示类似以下的行:
(没有源代码文件名和行号的指示)表示程序没有使用 -g
编译。
注意
大多数
lldb
命令都有可以替代的简短形式,这里使用了较长的形式以便更清晰。
在 lldb
提示符下,输入 breakpoint set -n main
。这将告诉调试器不要显示程序运行中的初始设置代码,并在程序的代码开始时停止执行。然后输入 process launch
以实际启动程序——它将从初始设置代码开始,然后在调用 main()
时被调试器停止。
要逐行执行程序,输入 thread step-over
。当程序进入函数调用时,输入 thread step-in
进入函数。一旦进入函数调用,输入 thread step-out
退出函数,或使用 up
和 down
快速查看调用者。
以下是如何使用 lldb
查找程序错误的一个简单示例。我们有一个故意出错的程序:
此程序将 i
设置为 5
并将其传递给函数 bazz()
,然后打印出我们给它的数字。
编译并运行该程序将显示:
这不是预期的结果!该是时候看看发生了什么了!
等一下!anint
怎么变成了 -5360
?它不是在 main()
中被设置为 5
吗?让我们回到 main()
看看。
哦,糟糕!看看代码,我们忘记初始化 i
了。我们本应写成:
但我们忘记了 i=5;
这一行。由于没有初始化 i
,它就有了程序运行时该内存位置上的任意值,在这种情况下是 -5360
。
技巧
每次我们进入或退出一个函数时,
lldb
命令都会显示堆栈帧,即使我们使用up
和down
移动调用栈时也是如此。这会显示函数的名称和其参数的值,帮助我们跟踪程序的运行情况。(调用栈是程序存储传递给函数的参数和返回时要跳转的位置的存储区域。)
核心文件基本上是包含程序崩溃时完整状态的文件。在“好老的日子里”,程序员需要打印出核心文件的十六进制清单,并为机器代码手册而苦苦挣扎,但现在生活变得容易多了。顺便提一下,在 FreeBSD 和其他 4.4BSD 系统中,核心文件被称为 progname.core,而不是仅仅叫 core,以便更清楚地表明核心文件属于哪个程序。
要检查核心文件,需要在指定程序的同时指定核心文件的名称。不要像通常那样启动 lldb
,而是输入 lldb -c <progname>.core -- <progname>
。
调试器将显示如下内容:
在这个例子中,程序被命名为 progname,因此核心文件名为 progname.core。调试器不会显示程序崩溃的原因或位置。为此,可以使用 thread backtrace all
。这也会显示导致程序崩溃的函数是如何被调用的。
SIGSEGV
表示程序试图访问其不属于自己的内存(通常是运行代码或读写数据),但没有给出具体细节。为此,可以查看 temp2.c
文件中第 10 行的源代码,查看 bazz()
中的代码。回溯还表明,在此情况下,bazz()
是从 main()
中调用的。
lldb
的一大亮点功能是它可以附加到一个已经在运行的程序上。当然,这要求有足够的权限才能执行此操作。一个常见问题是当程序进行 fork
操作并希望跟踪子进程时,调试器通常只会跟踪父进程。
为此,启动另一个 lldb
,使用 ps
查找子进程的进程 ID,然后在 lldb
中执行
然后像往常一样进行调试。
为了让这个过程顺利工作,调用 fork
创建子进程的代码需要做如下处理(摘自 gdb
的信息页面):
现在,只需要附加到子进程,在 lldb
中执行 expr PauseMode = 0
,并等待 sleep()
调用返回。
注意
从 LLDB 12.0.0 开始,FreeBSD 支持远程调试。这意味着可以在一台主机上启动 lldb-server
来调试程序,而交互式的 lldb
客户端则可以从另一台主机连接到它。
要启动一个需要远程调试的程序,请在远程服务器上运行 lldb-server
,命令如下:
程序启动后会立即停止,lldb-server
会等待客户端的连接。
然后,在本地启动 lldb
,并输入以下命令来连接到远程服务器:
lldb-server
也可以附加到一个正在运行的进程。要做到这一点,在远程服务器上输入以下命令:
通过输入以下命令启动 gdb:
不过许多人更喜欢在 Emacs 中运行它。要在 Emacs 中运行,输入:
使用 -g
选项编译程序,以便最大限度地发挥 gdb 的功能。即使不加 -g
选项,gdb 也能工作,但它只会显示当前运行的函数名称,而不是源代码。如果在启动 gdb 时看到类似以下内容:
这意味着程序没有使用 -g
编译。
在 gdb 提示符下,输入 break main
。这将告诉调试器跳过程序中的初步设置代码,并在程序代码开始时停止执行。接着输入 run
启动程序,程序会从设置代码开始执行,并在调用 main()
时被调试器停止。
要逐行调试程序,可以按 n
。当遇到函数调用时,按 s
步入该函数。进入函数后,按 f
返回,或者使用 up
和 down
快速查看调用者。
以下是使用 gdb 找到程序错误的一个简单示例。我们有如下程序(包含一个故意的错误):
该程序将 i
设置为 5
,并将其传递给函数 bazz()
,该函数打印出我们给它的数字。
编译并运行该程序,输出为:
这不是我们期望的结果!是时候看看发生了什么!
等一下!anint
怎么成了 4231
?它不是在 main()
中被设置为 5
吗?让我们回到 main()
,看看。
哦,天哪!查看代码,我们忘记初始化 i
了。我们本来应该写:
但我们忘了写 i=5;
这一行。由于没有初始化 i
,它包含了程序运行时该内存区域的任意值,而在这个情况下,恰好是 4231
。
注意
每次进入或退出一个函数时,
gdb
命令都会显示堆栈帧,即使我们使用up
和down
来在调用栈中移动。这显示了函数的名称和其参数的值,这有助于我们跟踪当前的位置和发生了什么。(堆栈是程序存储有关传递给函数的参数以及返回时应该去哪里的信息的区域。)
Core 文件基本上是一个包含程序崩溃时完整状态的文件。在“好久以前”,程序员们不得不打印出 core 文件的十六进制清单,并靠着机器代码手册来调试,但现在生活变得轻松一些。顺便提一下,在 FreeBSD 和其他 4.4BSD 系统中,core 文件被称为 progname.core,而不仅仅是 core,这样可以更清楚地标明哪个程序的 core 文件。
要检查一个 core 文件,像平常一样启动 gdb
。不过,不需要输入 break
或 run
,而是输入:
如果 core 文件不在当前目录中,首先输入 dir /path/to/core/file
。
调试器应该会显示如下信息:
在这个例子中,程序名为 progname,因此 core 文件名为 progname.core。我们可以看到程序因为尝试访问一个无法使用的内存区域而崩溃,崩溃发生在 bazz
函数中。
有时查看函数是如何被调用的很有用,因为问题可能出现在复杂程序中的调用栈的更高层。bt
命令会让 gdb
打印出调用栈的回溯信息:
end()
函数在程序崩溃时被调用;在这种情况下,bazz()
函数是从 main()
被调用的。
gdb
最酷的功能之一就是它可以附加到一个已经在运行的程序。当然,这需要足够的权限才能做到这一点。一个常见的问题是,在调试一个 fork 的程序时,想要追踪子进程,但调试器只会追踪父进程。
为此,可以启动另一个 gdb
,使用 ps
查找子进程的 PID,然后在 gdb
中执行:
然后像平常一样调试。
为了让这个过程顺利工作,调用 fork
来创建子进程的代码需要像以下这样写(摘自 gdb
的信息页面):
现在,只需附加到子进程,将 PauseMode
设置为 0
,并等待 sleep()
调用返回即可!
Emacs 是一个高度可定制的编辑器——事实上,它已经被定制到几乎像一个操作系统而不是编辑器的程度!许多开发者和系统管理员确实几乎把所有的时间都花在 Emacs 中,只有在注销时才会离开它。
在这里简要总结 Emacs 能做的所有事情几乎是不可能的,但以下是一些对开发者有用的功能:
非常强大的编辑器,支持对字符串和正则表达式(模式)进行搜索和替换,跳转到代码块的开始/结束等。
下拉菜单和在线帮助。
语言相关的语法高亮和缩进。
完全可定制。
你可以在 Emacs 中编译和调试程序。
当编译出错时,你可以跳转到源代码中的错误行。
提供一个友好的前端来使用 info
程序,阅读 GNU 超文本文档,包括 Emacs 本身的文档。
提供一个友好的前端来使用 gdb
,允许你在程序调试时查看源代码。
当然,还有许多其他功能未被列出。
安装完成后,启动 Emacs,输入 C-h t
阅读 Emacs 教程——这意味着按住控制键,按 h 键,松开控制键,然后按 t 键。(或者,你可以使用鼠标从 Help 菜单中选择 Emacs Tutorial。)
尽管 Emacs 有菜单,但学习键绑定非常值得,因为编辑时按几个键比寻找鼠标并点击正确的地方要快得多。而且,当你与经验丰富的 Emacs 用户交谈时,你会发现他们常常随意地说出像“M-x replace-s RET foo RET bar RET
”这样的表达方式,所以了解它们的意思很有用。无论如何,Emacs 有太多有用的功能,菜单栏根本容不下所有功能。
幸运的是,学习键绑定非常容易,因为它们会显示在菜单项旁边。我的建议是,首先使用菜单项打开文件,直到你了解它是如何工作的并且对其有信心,然后尝试使用 C-x C-f
。当你熟悉这个操作后,再尝试其他菜单命令。
如果你记不住某个特定的键组合,可以从 Help 菜单中选择 Describe Key,然后输入它——Emacs 会告诉你它的功能。你还可以使用 Command Apropos 菜单项,查找包含某个特定单词的所有命令,旁边会显示其键绑定。
顺便说一下,前面的表达式意味着按住 Meta 键,按下 x 键,松开 Meta 键,输入 replace-s
(replace-string
的缩写——Emacs 的另一个特点是你可以缩写命令),按回车键,输入 foo
(你要替换的字符串),按回车键,输入 bar
(你希望用来替换 foo
的字符串),再次按回车。Emacs 会执行你刚刚请求的查找和替换操作。
如果你在想 Meta 键到底是什么,它是许多 UNIX® 工作站上都有的一个特殊键。不幸的是,PC 没有这个键,因此通常使用 alt 键(如果不幸的话,使用 escape 键)。
哦,要退出 Emacs,输入 C-x C-c
(这意味着按住控制键,按 x 键,按 c 键,释放控制键)。如果你有任何未保存的文件,Emacs 会询问你是否保存它们。(忽略文档中提到的 C-z
是退出 Emacs 的常用方式——那样会让 Emacs 在后台挂着,只有在没有虚拟终端的系统上才有用。)
Emacs 有许多奇妙的功能;其中一些是内建的,有些则需要配置。
Emacs 并没有使用专有的宏语言来进行配置,而是使用了一种特别为编辑器改编的 Lisp 版本,称为 Emacs Lisp。如果你想学习像 Common Lisp 这样的语言,学习 Emacs Lisp 是非常有帮助的。Emacs Lisp 具有许多 Common Lisp 的特性,尽管它要小得多(因此更容易掌握)。
不过,实际上并不需要懂 Lisp 就可以开始配置 Emacs,因为我提供了一个示例 .emacs 文件,应该足以让你入门。只需将它复制到你的主目录中,并重新启动 Emacs(如果已经运行的话);它会读取文件中的命令,并(希望)为你提供一个有用的基本设置。
不幸的是,这里有太多内容需要详细解释;然而,有一两个值得一提的要点。
以 ;
开头的所有内容都是注释,Emacs 会忽略它们。
在第一行,-<strong>- Emacs-Lisp -</strong>-
是为了使我们能够在 Emacs 内部编辑 .emacs 文件,并获得所有编辑 Emacs Lisp 的高级功能。Emacs 通常会根据文件名尝试猜测这一点,但可能不会为 .emacs 文件正确识别。
Tab 键在某些模式下绑定到缩进功能,因此当你按下 Tab 键时,它会缩进当前的代码行。如果你想在写的内容中插入一个 Tab 字符,可以在按 Tab 键时按住控制键。
该文件支持 C、C++、Perl、Lisp 和 Scheme 的语法高亮,通过从文件名猜测语言来启用。
Emacs 已经有一个预定义的函数 next-error
。在编译输出窗口中,它允许你通过执行 M-n
从一个编译错误跳到下一个;我们定义了一个互补函数 previous-error
,允许你通过执行 M-p
跳转到前一个错误。最好的功能是,C-c C-c
会打开发生错误的源文件并跳转到相应的行。
我们启用 Emacs 的服务器功能,这样,如果你在 Emacs 之外做一些事情,想要编辑一个文件,只需输入
示例 1 .emacs
如果你只想在 .emacs 中使用已经支持的语言(C、C++、Perl、Lisp 和 Scheme),那是很好,但如果有一种新的语言叫做 "whizbang" 出现,充满了激动人心的功能,该怎么办呢?
首先要做的是查看 whizbang 是否附带了任何可以让 Emacs 了解该语言的文件。通常这些文件的扩展名是 .el,即 "Emacs Lisp" 的缩写。例如,如果 whizbang 是一个 FreeBSD port,我们可以通过以下命令来查找这些文件:
然后将这些文件复制到 Emacs 的 site-lisp 目录中进行安装。在 FreeBSD 中,site-lisp 目录是 /usr/local/share/emacs/site-lisp。
例如,如果 find 命令的输出是:
那么我们应该执行:
接下来,我们需要决定 whizbang 源文件的扩展名是什么。假设它们都以 .wiz 结尾。我们需要在 .emacs 中添加一条记录,确保 Emacs 能够使用 whizbang.el 中的信息。
找到 .emacs 中的 auto-mode-alist
条目,然后添加一行,如下所示:
这意味着当你编辑一个以 .wiz 结尾的文件时,Emacs 会自动进入 whizbang-mode
。
接下来,在 .emacs 中找到 font-lock-auto-mode-list
条目。像这样将 whizbang-mode
添加到其中:
这意味着当编辑一个 .wiz 文件时,Emacs 会始终启用 font-lock-mode
(即语法高亮)。
就这样,完成了所有必要的设置。如果你希望在打开 .wiz 文件时自动执行其他操作,可以添加一个 whizbang-mode hook
(参见 my-scheme-mode-hook
,这是一个简单的例子,添加了 auto-indent
)。
Brian Harvey 和 Matthew Wright Simply Scheme MIT 1994. ISBN 0-262-08226-8
Randall Schwartz Learning Perl O’Reilly 1993 ISBN 1-56592-042-2
Patrick Henry Winston 和 Berthold Klaus Paul Horn Lisp (3rd Edition) Addison-Wesley 1989 ISBN 0-201-08319-1
Brian W. Kernighan 和 Rob Pike The Unix Programming Environment Prentice-Hall 1984 ISBN 0-13-937681-X
Brian W. Kernighan 和 Dennis M. Ritchie The C Programming Language (2nd Edition) Prentice-Hall 1988 ISBN 0-13-110362-8
Bjarne Stroustrup The C++ Programming Language Addison-Wesley 1991 ISBN 0-201-53992-6
W. Richard Stevens Advanced Programming in the Unix Environment Addison-Wesley 1992 ISBN 0-201-56317-7
W. Richard Stevens Unix Network Programming Prentice-Hall 1990 ISBN 0-13-949876-1
我们现在准备开始了。系统已经安装好,你也准备开始编程了。但是从哪里开始呢?FreeBSD 提供了什么?作为程序员,它能为我做什么?
本章试图回答这些问题。当然,像其他任何技能一样,编程也有不同的熟练程度。对一些人来说,这是业余爱好;对另一些人来说,这是职业。本章的信息可能更偏向初学者;事实上,它对那些不熟悉 FreeBSD 平台的程序员可能非常有帮助。
打造最佳的类 UNIX® 操作系统套件,既尊重最初的软件工具理念,也兼顾可用性、性能与稳定性。
我们的理念可通过以下指导方针来描述:
除非实现者无法完成一个真实的应用,否则不要添加新功能。
明确一个系统不是什么,与定义它是什么同样重要。不要试图满足全世界的所有需求;相反,应使系统具有可扩展性,以便以向后兼容的方式满足额外需求。
唯一比根据一个例子泛化更糟的,是在没有任何例子的情况下泛化。
如果一个问题尚未完全理解,最好根本不要提供解决方案。
如果用 10% 的工作量就能实现 90% 的效果,那就选更简单的方案。
尽可能将复杂性隔离开来。
提供机制,而不是政策。特别是,将用户界面策略交给客户端掌控。
摘自 Scheifler 与 Gettys:《X Window System》
该章节记录了 FreeBSD 源码树中施行的各种指南和政策。
MAINTAINER
如果 FreeBSD src/ 分发中的某个部分由某个人或一组人维护,这会通过 src/MAINTAINERS 文件中的一条记录来表达。Ports Collection 中某个 Port 的维护者通过在该 Port 的 Makefile 中添加一行 MAINTAINER
来向外界表明其维护权:
技巧
对于仓库中的其他部分,或没有指定维护者的部分,或当你不确定谁是活跃的维护者时,可以尝试查看源码树相关部分的最近提交历史。很多时候,并没有明确指定某个维护者,但过去几年中持续活跃于某个源码树部分的人通常也愿意审阅更改。即使文档或源码中没有特别说明,请求审阅作为一种礼貌行为也是非常合理的。
维护者的职责如下:
维护者拥有该代码的所有权,并对其负责。这意味着他/她需要修复与该部分代码相关的 bug 并回应问题报告;如果是引入的第三方软件,还需按需跟踪新版本。
如果某个目录指定了维护者,那么对该目录的更改在提交前应送审给维护者。只有在多次发送邮件仍长时间未收到回复时,才可在未经审阅的情况下提交。但建议尽可能还是请其他人进行审阅。
当然,未经同意不能将某个人或团队添加为维护者。另一方面,维护者不必是 committer,也可以是一个团队。
管理引入软件的标准做法是创建一个 vendor 分支,在该分支中可以以“干净”方式导入软件(即不加修改),并以版本控制的方式跟踪更新。然后将 vendor 分支中的内容应用到源码树中,并可进行本地修改。FreeBSD 专属的构建集成代码应保存在源码树中,而非 vendor 分支中。
引入软件通常放置在源码树的 contrib/ 子目录中,也有一些例外。仅由内核使用的引入软件位于 sys/contrib/ 之下。
注意
由于会增加后续版本导入的难度,因此在仍然跟踪 vendor 分支的文件上,强烈不建议 进行次要的、无关紧要的或纯粹为美观的修改。
有时可能需要将受限文件添加到 FreeBSD 源码树中。例如,如果某个设备在运行前需要加载一段我们没有源代码的小型二进制代码,那么这个二进制文件就被视为受限文件。下面是将受限文件纳入 FreeBSD 源码树所需遵循的政策:
任何由系统 CPU 执行或解释、且不是源代码格式的文件都属于受限文件。
任何授权比 BSD 或 GNU 更严格的文件都属于受限文件。
包含供硬件使用的可下载二进制数据的文件不被视为受限文件,除非第 (1) 或 (2) 条适用于它。
受限文件应放在 src/contrib 或 src/sys/contrib。
整个模块应保持完整。除非与非受限代码有代码共享,否则无需拆分。
过去二进制文件通常是 uuencode 编码的,并命名为 arch/filename.o.uu。这已不再必要,现在可以直接将二进制文件原样加入仓库。
内核文件:
应始终在 conf/files.* 中列出(为简化构建)。
是否纳入发行版由 Release Engineer 决定。
用户态文件:
如果你要为某个 Port 或其他软件添加共享库支持,而该软件原本并不使用共享库,那么其版本号应遵循以下规则。通常,这些版本号与软件的发行版本无关。
对于 Port:
优先使用上游已经指定的版本号。
如果上游提供了符号版本控制(symbol versioning),应确保我们也使用其脚本。
对于 base 系统:
库版本号从 1 开始。
强烈建议为新库添加符号版本控制。
若存在不兼容的更改,应使用符号版本控制并保持向后 ABI 兼容性。
如果无法做到这一点,或者库没有使用符号版本控制,则需要提升库的版本号。
若需提升 symbol-versioned 库的版本号,必须事先与 Release Engineering 团队协商,说明为何这一更改如此重要,以致必须突破 ABI 兼容限制。
例如,添加函数或修复接口不变的 bug 都可以接受;但删除函数、改变函数调用语法等行为要么需要提供向后兼容的符号版本,要么需要提升主版本号。
更改库版本是提交者的责任。
ELF 动态链接器会精确匹配库名。目前的流行做法是将库名设为 libexample.so.x.y
,其中 x 为主版本号,y 为次版本号。惯例是将库的 soname(ELF 中的 DT_SONAME
标签)设为 libexample.so.x
,并在安装时建立符号链接:libexample.so.x → libexample.so.x.y
,libexample.so → libexample.so.x
。这样,静态链接器在使用 -lexample
选项时,会自动链接到正确的库。几乎所有流行的构建系统都会自动采用这种方式。
BSD socket 将进程间通信提升到了一个新的层次。通信的进程不再必须运行在同一台机器上。它们可以运行在同一台机器上,但不再是必须的。
不仅如此,这些进程也不需要运行在相同的操作系统下。多亏了 BSD socket,你的 FreeBSD 软件可以顺利地与一台 Macintosh® 上运行的程序协作,与一台 Sun™ 工作站上的程序协作,甚至与一台运行 Windows® 2000 的程序协作,所有这些通过基于以太网的局域网连接在一起。
而你的软件同样可以与运行在另一栋楼、另一个大陆、甚至潜艇或航天飞机中的进程协作。
它也可以与不是计算机(至少不是严格意义上的计算机)中的进程协作,比如打印机、数码相机、医疗设备等设备中的进程。几乎任何能进行数字通信的东西。
我们已经暗示过网络的多样性。许多不同的系统必须彼此通信。而它们必须讲相同的语言。它们还必须以相同的方式理解这种语言。
人们常常以为肢体语言是通用的。但事实并非如此。在我十几岁的时候,我父亲带我去了保加利亚。我们在索非亚的一座公园里坐在一张桌边,一个小贩走近我们,试图卖我们一些烤杏仁。
那时我还没怎么学过保加利亚语,于是,我没有说“不”,而是左右摇头,这是“不”这个意思的“通用”肢体语言。结果小贩立刻开始给我们装杏仁。
这时我才记起有人告诉过我,在保加利亚,摇头表示“是”。我赶紧改为上下点头。小贩注意到了,拿起杏仁,离开了。对一个不知情的旁观者来说,我的肢体语言没有变化:我继续摇头点头。变的是肢体语言的含义。起初,小贩和我对相同的语言作出了完全不同的解释。我必须调整自己对这种语言的解释,才能让小贩理解。
计算机之间也是如此:相同的符号可能具有不同,甚至完全相反的含义。因此,两个计算机要能互相理解,不仅要使用相同的语言,还要以相同的方式解释这种语言。
尽管各种编程语言通常具有复杂的语法,并使用许多多字母的保留字(这使得它们对程序员更易于理解),数据通信的语言通常非常简洁。它们不使用多字节的单词,而是经常使用单个的比特。这样做有一个非常令人信服的理由:虽然数据在计算机内部的传输速度接近光速,但在两台计算机之间的传输速度往往要慢得多。
由于数据通信中使用的语言非常简洁,我们通常将其称为协议,而不是语言。
当数据从一台计算机传输到另一台计算机时,它总是使用多个协议。这些协议是分层的。数据可以比作洋葱的内部:你需要剥去几层“皮”才能获得数据。用一张图片来说明这一点会更好:
图 1. 协议层
在这个例子中,我们正在尝试从一个通过以太网连接的网页中获取一张图片。
图片由原始数据组成,原始数据只是一个 RGB 值的序列,我们的软件可以处理这些值,即将其转换为图像并显示在显示器上。
遗憾的是,我们的软件无法知道这些原始数据是如何组织的:它是 RGB 值的序列,还是灰度强度的序列,或者是 CMYK 编码的颜色?数据是用 8 位量表示,还是 16 位,或者是 4 位?图片由多少行和列组成?某些像素是否需要透明?
我想你已经明白了……
为了告诉我们的软件如何处理这些原始数据,它被编码为 PNG 文件。它也可以是 GIF 文件,或者 JPEG 文件,但它是 PNG。
而 PNG 就是一个协议。
在这个时候,我听到有些人喊道:“不,它不是!它是文件格式!”
当然,它是文件格式。但从数据通信的角度来看,文件格式就是协议:文件结构是一个语言,它简洁到极点,告诉我们的进程数据是如何组织的。因此,它是一个协议。
然而,如果我们收到的只是 PNG 文件,我们的软件将面临一个严重的问题:它怎么知道数据表示的是图像,而不是某些文本,或者是音频,或者其他什么?其次,怎么知道图像是 PNG 格式而不是 GIF、JPEG 或其他图像格式?
为了获得这些信息,我们使用了另一个协议:HTTP。这个协议可以准确地告诉我们,数据表示的是图像,并且它使用的是 PNG 协议。它还可以告诉我们其他信息,但我们现在还是集中在协议层次上。
所以,现在我们收到了一个包裹在 PNG 协议中的数据,再包裹在 HTTP 协议中。那我们是如何从服务器获得它的呢?
是通过 Ethernet 上的 TCP/IP 来的,实际上那是三个协议。为了更容易解释接下来的内容,我现在将重点讲解 Ethernet。
以太网是一个有趣的局域网(LAN)计算机连接系统。每台计算机都有一个网络接口卡(NIC),该卡具有一个唯一的 48 位 ID,称为地址。世界上没有两台 Ethernet NIC 拥有相同的地址。
这些 NIC 彼此连接。当一台计算机想要与同一 Ethernet LAN 中的另一台计算机通信时,它会通过网络发送消息。每个 NIC 都能看到这条消息。但作为以太网协议的一部分,数据中包含了目标 NIC 的地址(以及其他信息)。因此,只有其中一块网络接口卡会注意到这条消息,其他的都会忽略它。
但并不是所有的计算机都连接在同一个网络上。仅仅因为我们通过以太网接收到数据,并不意味着它来自我们自己的局域网。它可能来自其他通过 Internet 连接的网络(可能并非基于以太网的网络)。
所有数据通过互联网传输都使用 IP,即互联网协议。它的基本作用是告诉我们数据来自哪里,应该到达哪里。它不保证我们会收到数据,只是保证如果我们收到了数据,我们会知道它来自哪里。
即使我们收到了数据,IP 也不保证我们会按发送方发送的顺序接收到不同的数据块。例如,我们可能会先收到图像的中央部分,然后才收到左上角,或者右下角。
正是 TCP(传输控制协议)要求发送方重新发送任何丢失的数据,并将所有数据按正确的顺序排列。
总之,从一台计算机传输到另一台计算机,告诉它一张图像是什么样子的,竟然用了五个不同的协议。我们收到了包裹在 PNG 协议中的数据,再包裹在 HTTP 协议中,再包裹在 TCP 协议中,再包裹在 IP 协议中,最后包裹在以太网协议中。
哦,顺便说一句,可能还有许多其他协议参与了这个过程。例如,如果我们的局域网通过拨号连接到 Internet,那么它就用了 PPP 协议,通过调制解调器传输,调制解调器又使用了一个(或多个)不同的调制解调协议,等等,等等……
作为开发人员,你现在应该会问:“我该如何处理这一切?”
幸运的是,你并不需要处理所有这些内容。你需要处理其中的一部分,但不是全部。具体来说,你不必担心物理连接(在我们这个例子中是 Ethernet 和可能的 PPP 等)。你也不必处理互联网协议或传输控制协议。
换句话说,你不需要做任何事情来接收来自另一台计算机的数据。嗯,你确实需要请求它,但这几乎就像打开一个文件一样简单。
一旦你接收到数据,就由你来决定如何处理它。在我们的例子中,你需要理解 HTTP 协议和 PNG 文件结构。
举个比喻,所有的网络协议变成了一个灰色地带:这不仅仅是因为我们不理解它是如何工作的,而是因为我们不再关注它。套接字接口为我们处理了这一灰色地带:
图 2. 套接字覆盖的协议层
我们只需要理解任何告诉我们如何解释数据的协议,而不是如何从另一个进程接收数据,或如何发送数据到另一个进程。
BSD 套接字是建立在基本 UNIX® 模型之上的:一切皆文件。在我们的例子中,套接字可以让我们接收一个HTTP 文件,可以这么说。然后,剩下的工作就是从中提取出PNG 文件。
由于互联网的复杂性,我们不能简单地使用 open
系统调用或 open()
C 函数。相反,我们需要采取多个步骤来“打开”一个套接字。
然而,一旦我们完成这些步骤,我们就可以开始像处理任何文件描述符一样处理套接字:我们可以从中read
、write
、pipe
,最后close
它。
虽然 FreeBSD 提供了多种函数来操作套接字,但我们只需要四个来“打开”一个套接字。在某些情况下,我们甚至只需要两个。
通常,套接字数据通信的一端是服务器,另一端是客户端。
7.5.1.1.1. socket
返回值与 open
相同,都是整数。FreeBSD 从与文件句柄相同的池中分配它的值。这使得套接字可以像文件一样被处理。
domain
参数告诉系统你希望使用的协议族。有许多协议族,其中一些是厂商特定的,其他的则是常见的。它们在 sys/socket.h 中声明。
对于 UDP、TCP 和其他 Internet 协议(IPv4),使用 PF_INET
。
type
参数有五个定义的值,也在 sys/socket.h 中声明。所有这些值都以 “SOCK_” 开头。最常见的是 SOCK_STREAM
,它告诉系统你请求一个可靠的流式传输服务(与 PF_INET
一起使用时是 TCP)。
如果你请求的是 SOCK_DGRAM
,你将请求一个无连接的数据报传输服务(在我们的例子中是 UDP)。
如果你想控制底层协议(如 IP),甚至是网络接口(如以太网),你需要指定 SOCK_RAW
。
最后,protocol
参数取决于前两个参数,并非总是有意义。在这种情况下,可以将其值设置为 0
。
注意
未连接的套接字在
socket
函数中,我们并没有指定该套接字要连接到哪个其他系统。我们新创建的套接字仍然是未连接的。这是故意的:用电话的类比来说,我们刚刚将调制解调器接入电话线。我们既没有告诉调制解调器拨打电话,也没有告诉它在电话响起时接听。
7.5.1.1.2. sockaddr
各种套接字系列的函数期望得到(或使用 C 术语的指针)内存中一小块区域的地址。这些 C 声明在 sys/socket.h 中将其称为 struct sockaddr
。这个结构在同一个文件中声明:
请注意 sa_data
字段的模糊性,它仅声明为 14
字节的数组,注释提示它可能包含超过 14
字节的数据。
这种模糊性是故意的。套接字是一个非常强大的接口。虽然大多数人可能认为它不过是一个 Internet 接口——并且大多数应用程序现在可能就是这样使用它——套接字几乎可以用于任何形式的进程间通信,其中互联网(或更确切地说是 IP)只是其中之一。
sys/socket.h 将套接字所处理的各种协议称为地址族,并在 sockaddr
定义之前列出它们:
用于 IP 的是 AF_INET,它是常量 2
的符号表示。
正是 sockaddr
中列出的 地址族 决定了如何使用 sa_data
字段中那些模糊命名的字节。
具体来说,当地址族为 AF_INET 时,我们可以在需要 sockaddr
的地方使用 netinet/in.h 中的 struct sockaddr_in
:
我们可以通过以下方式可视化它的组织结构:
图 3. sockaddr_in
结构
三个重要的字段是 sin_family
,它是结构的第 1 字节;sin_port
,它是 16 位的值,存储在第 2 和第 3 字节中;以及 sin_addr
,它是一个 32 位的 IP 地址整数表示,存储在第 4 至第 7 字节中。
现在,让我们尝试填充它。假设我们正在为 daytime 协议编写客户端,该协议简单地规定其服务器会将当前的日期和时间的文本字符串写入端口 13。我们希望使用 TCP/IP,因此我们需要在地址族字段中指定 AF_INET
。AF_INET
被定义为 2
。让我们使用 IP 地址 192.43.244.18
,这是美国联邦政府的时间服务器(time.nist.gov
)。
图 4. sockaddr_in
的具体示例
顺便提一下,sin_addr
字段被声明为 struct in_addr
类型,它在 netinet/in.h 中定义:
此外,in_addr_t
是一个 32 位的整数。
192.43.244.18
只是通过列出其所有 8 位字节(从最重要的字节开始)来表示一个 32 位整数的便捷表示法。
到目前为止,我们已经将 sockaddr
视为一种抽象。我们的计算机并不会将 short
整数存储为单一的 16 位实体,而是作为一系列的 2 个字节。类似地,它将 32 位整数存储为一系列 4 个字节。
假设我们编写了如下代码:
结果会是什么样子呢?
当然,这取决于具体的计算机系统。在一个奔腾或其他 x86 系统上,它会像这样显示:
图 5. 在 Intel 系统上的 sockaddr_in
在不同的系统上,可能会是这样的:
图 6. 在 MSB 系统上的 sockaddr_in
在 PDP 上,它的表现可能又会不同。但上述两种方式是如今最常见的两种实现方式。
通常,为了编写可移植的代码,程序员会假装这些差异并不存在。而他们确实也能蒙混过关(除非他们在写汇编语言)。不过,当你在为 sockets 编程时,你就不能这么轻松地蒙混过关了。
为什么?
因为在与另一台计算机通信时,你通常不知道它是以 最高有效字节(MSB)优先还是 最低有效字节(LSB)优先来存储数据的。
你可能会想,“那 sockets 难道不会替我处理这些吗?”
不会。
这个答案可能一开始让你感到惊讶,但请记住,通用的 sockets 接口只理解 sockaddr
结构中的 sa_len
和 sa_family
字段。你不必担心这些字段的字节序(当然,在 FreeBSD 上 sa_family
反正只有 1 个字节,但许多其他 UNIX® 系统没有 sa_len
字段,并且使用 2 字节的 sa_family
字段,并且期望数据以该计算机的本地顺序存储)。
但对 sockets 而言,其余的数据就只是 sa_data[14]
。根据 地址族 的不同,sockets 只是将这些数据转发到目标地。
事实上,当我们输入一个端口号时,是为了让另一台计算机知道我们请求的是什么服务。而当我们是服务器时,我们读取端口号,是为了知道对方期望我们提供什么服务。无论是哪种情况,sockets 只是将端口号当作数据转发。它不会对其进行任何解释。
同样,我们输入 IP 地址,是为了告诉网络上的所有中间设备数据应该被送往何处。sockets 依旧只是将其当作数据转发。
这就是为什么,我们(程序员,而不是 sockets)必须区分我们的计算机使用的字节序与用于发送给另一台计算机的标准字节序。
我们称我们的计算机使用的字节序为 主机字节序,简称 主机序。
而在 IP 上传送多字节数据有一个约定,就是以 MSB 优先 的方式传送。我们称这种顺序为 网络字节序,简称 网络序。
现在,如果我们将上面的代码编译为运行在 Intel 架构的计算机上,我们的 主机字节序 将会产生如下结果:
图 7. Intel 系统上的主机字节序
但 网络字节序 要求我们以 MSB 优先的方式存储数据:
图 8. 网络字节序
不幸的是,我们的 主机序 与 网络序 完全相反。
我们有几种方式可以应对这种情况。其中一种方法是在代码中 反转 这些值:
这样可以“骗过”我们的编译器,让它以 网络字节序 存储这些数据。在某些情况下,这确实是正确的做法(比如当你在写汇编语言的时候)。但在大多数情况下,这会引起问题。
假设你用 C 写了一个基于 sockets 的程序。你知道它会运行在奔腾上,于是你将所有常量反转后强行设为 网络字节序。一切正常。
直到有一天,你信赖的旧奔腾变成了锈迹斑斑的老古董。你换了一台新机器,它的 主机序 正好和 网络序 一致。你重新编译了你的所有软件。所有程序都运作良好,除了你那一个程序。
你已经忘了当初你曾经强行把所有常量写成了和 主机序 相反的顺序。你开始拔头发,呼唤你听过和没听过的所有神的名字,用海绵棒猛击显示器,进行各种传统的“调试祭祀仪式”,试图搞清楚为什么一个一直工作良好的程序突然就失效了。
最终,你搞清楚了问题的根源,骂了几句脏话,然后开始重写你的代码。
在 MSB 优先 的系统上,这些函数不会进行任何操作。而在 LSB 优先 的系统上,它们会将值转换为正确的顺序。
所以,无论你的软件是在哪个系统上编译的,只要使用这些函数,你的数据最终就会以正确的顺序被传送出去。
通常,客户端负责发起与服务器的连接。客户端知道它要联系哪个服务器:它知道服务器的 IP 地址,也知道服务器所监听的 端口。这就像你拿起电话拨号(这个 地址),然后在有人接听后请求找负责“wingdings”的人(这个 端口)。
7.5.1.2.1. connect
参数 s
是 socket,也就是 socket
函数返回的值。name
是一个指向 sockaddr
的指针,我们前面已经详细讨论过该结构。最后,namelen
用于告知系统我们的 sockaddr
结构体的字节数。
如果 connect
调用成功,它会返回 0
。否则返回 -1
,并将错误代码存储在 errno
中。
connect
可能失败的原因有很多。例如,在尝试连接到某个互联网地址时,对方的 IP 地址可能根本不存在,或者对方主机宕机了,或者太忙,或者根本没有在指定端口监听任何服务。也有可能直接 拒绝 来自特定代码的任何请求。
7.5.1.2.2. 我们的第一个客户端
现在我们已经掌握足够知识,来编写一个非常简单的客户端程序,它将从 192.43.244.18
获取当前时间并打印到 stdout。
现在请打开编辑器,输入上述内容,保存为 daytime.c,然后编译并运行它:
在本例中,日期是 2001 年 6 月 19 日,时间是 UTC 时间 02:29:25。当然,你运行程序时的输出会有所不同。
典型的服务器不会主动发起连接。它会等待客户端来调用它、请求服务。它不知道客户端什么时候会来,也不知道会有多少客户端会来。有时候它只是静静地坐在那里等待,而下一刻,可能就会突然被大量同时请求的客户端淹没。
sockets 接口提供了三个基本函数来处理这一情况。
7.5.1.3.1. bind
端口就像电话线路上的分机:拨通一个号码后,还需要拨分机号才能联系到特定的人或部门。
除了在 addr
中指定端口,服务器也可以包括它自己的 IP 地址。然而,它也可以使用符号常量 INADDR_ANY 来表示将接收发往该端口的所有请求,而不管目标 IP 是什么。这个符号常量和其他几个类似常量都定义在 netinet/in.h 中:
假设我们要写一个基于 TCP/IP 的 daytime 协议服务器。回忆一下,它使用端口 13。我们的 sockaddr_in
结构将如下所示:
图 9. 示例服务器 sockaddr_in
7.5.1.3.2. listen
继续我们之前的电话比喻:当你告诉总机你在哪个分机接电话后,你就走进办公室,确保电话插好了,铃声打开了。此外,你还要确保电话支持“通话中等待”,以便即使你正在通话中也能听到新的来电。
这里的 backlog
参数告诉 sockets:在你还在处理上一个请求时,最多可以接受多少个挂起的请求。换句话说,它决定了待处理连接队列的最大长度。
7.5.1.3.3. accept
电话铃响之后,你接起电话,就建立了与客户端的连接。该连接会一直保持,直到你或客户端挂断为止。
注意这次 addrlen
是一个指针。这是因为在这个调用中由 socket 来填写 addr
,也就是 sockaddr_in
结构。
返回值是一个整数。实际上,accept
返回的是一个 新 socket。你将使用这个新 socket 来与客户端通信。
那旧的 socket 呢?它仍然监听更多的请求(还记得我们传给 listen
的 backlog
吗?),直到我们调用 close
。
而新的 socket 仅用于通信。它是完全连接的,不能再传给 listen
去接受其他连接。
7.5.1.3.4. 我们的第一个服务器
我们的第一个服务器比我们的第一个客户端要复杂一些:不仅使用了更多的 sockets 函数,而且我们需要将它写成一个守护进程(daemon)。
最好的方式是,在绑定端口之后创建一个 子进程。主进程随即退出,将控制权还给 shell(或调用它的其他程序)。
子进程调用 listen
,然后进入一个无限循环,接受连接、提供服务、最终关闭该连接的 socket。
我们首先创建一个 socket。然后填写 sa
中的 sockaddr_in
结构体。注意对 INADDR_ANY 的条件使用:
其值为 0
。由于我们刚刚对整个结构体使用了 bzero
,再次将其设为 0
是多余的。但如果我们将代码移植到某个 INADDR_ANY
也许不是零的系统上,就必须显式地将它赋值给 sa.sin_addr.s_addr
。多数现代 C 编译器足够聪明,它们会发现 INADDR_ANY
是一个常量,只要它的值是零,就会自动优化掉整个条件语句。
成功调用 bind
之后,我们就准备好变成一个 守护进程(daemon):我们使用 fork
创建一个子进程。在父进程和子进程中,变量 s
都是我们的 socket。父进程不再需要它,于是它调用 close
,然后返回 0
,告知其父进程它已经成功终止。
与此同时,子进程继续在后台运行。它调用 listen
,将 backlog
设置为 4
。这个值不需要太大,因为 daytime 并不是一个有很多客户端频繁请求的协议,而且每个请求也能被瞬间处理完毕。
最后,守护进程启动一个无限循环,按以下步骤操作:
调用 accept
。它会阻塞,直到有客户端连接。此时,它会获得一个新的 socket c
,用于与这个特定客户端通信。
使用 C 函数 fdopen
将 socket 从底层 文件描述符 转换为 C 风格的 FILE
指针,这样可以后续使用 fprintf
。
获取当前时间,并用 ISO 8601 格式打印到 client
“文件”中。随后使用 fclose
关闭该文件,这也会自动关闭对应的 socket。
我们可以将这个模式 泛化,作为许多其他服务器的模板:
图 10. 顺序服务器(Sequential Server)
这个流程图适用于 顺序服务器,即一次只能服务一个客户端的服务器,就像我们的 daytime 服务器一样。只有在客户端和服务器之间没有真正的“对话”时,这种方式才是可行的:一旦服务器检测到客户端连接,它便立即发送一些数据,然后关闭连接。整个过程可能只需几纳秒便完成。
这种流程图的优点在于,除了 fork
之后父进程退出之前那一瞬间,始终只有一个 进程 活跃:服务器不会占用太多内存和系统资源。
注意,我们在流程图中添加了 初始化守护进程 的步骤。虽然我们的例子中不需要初始化守护进程,但这是在程序流程中设置 signal
信号处理器、打开可能用到的文件等的良好位置。
几乎流程图中所有内容都可以原样用于许多不同的服务器中,唯独 serve 部分是个例外。我们可以把它当作一个 “黑箱”,即根据自己的服务器需求特别设计的部分,然后“插入”到其余部分中即可。
并非所有协议都如此简单。很多协议都需要从客户端接收请求、回复请求,然后再次接收同一客户端的新请求。因此,它们无法预先知道需要服务多长时间。这类服务器通常会为每个客户端创建一个新进程。在新进程服务其客户端的同时,守护进程仍可继续监听新的连接。
现在,请将上述源代码保存为 daytimed.c(按照惯例,守护进程的程序名以字母 d
结尾)。编译后尝试运行它:
发生了什么?如你所知,daytime 协议使用的是端口 13。但所有小于 1024 的端口都是保留给超级用户的(否则任何人都可以伪装成一个守护进程,服务一个常见端口,从而造成安全漏洞)。
这次以超级用户身份再试一次:
什么…… 没有任何输出?我们再试一次:
每个端口在同一时间只能被一个程序绑定。我们的第一次尝试实际上是成功的:它启动了子守护进程并静默返回。它仍在后台运行,并将一直运行,直到你终止它、它的系统调用失败,或者你重启系统。
很好,我们知道它在后台运行。但它真的 工作 吗?怎么知道它是一个正确的 daytime 服务器?很简单:
telnet 先尝试使用新的 IPv6,失败后改用 IPv4 并成功连接。守护进程正常运行。
如果你能通过 telnet 访问另一台 UNIX® 系统,也可以用它来远程测试服务器。我所用的计算机没有静态 IP 地址,因此我做了如下测试:
它确实工作了。那么用域名也行吗?
顺便说一句,telnet 在我们的守护进程关闭 socket 后打印 Connection closed by foreign host 消息,这证明我们代码中使用 fclose(client);
的做法确实起到了作用。
FreeBSD 的 C 标准库包含许多用于 socket 编程的辅助函数。例如,在我们的示例客户端中,我们是将 time.nist.gov
的 IP 地址硬编码进程序的。但我们并不总是知道 IP 地址。即使知道,如果程序允许用户输入 IP 地址,甚至是域名,它也会更灵活。
gethostbyname
这两个函数都会返回一个指向 hostent
结构体的指针,该结构体中包含大量关于该域名的信息。对我们的用途来说,该结构体中的 h_addr_list[0]
字段指向正确地址的 h_length
字节,这些字节已经是 网络字节序。
这使得我们可以创建一个更加灵活——也更加实用——的 daytime 程序版本:
现在我们可以在命令行中输入域名(或 IP 地址,二者皆可),程序就会尝试连接该地址的 daytime 服务器。否则,它仍然会默认连接 time.nist.gov
。不过即便是这种情况,我们也使用了 gethostbyname
,而不是硬编码 192.43.244.18
。这样一来,即使它将来更换了 IP 地址,我们依然可以找到它。
由于从本地服务器获取时间几乎不花什么时间,你可以连续运行两次 daytime:第一次从 time.nist.gov
获取时间,第二次从你自己的系统中获取。然后你就可以比较两者的结果,看看你的系统时钟有多精确:
如你所见,我的系统时间比 NIST 时间快了两秒。
getservbyname
servent
结构体包含 s_port
字段,其中保存了正确的端口号,且已经是 网络字节序。
如果我们事先不知道 daytime 服务使用的端口,可以这样获取:
通常你是知道端口号的。但如果你正在开发一个新的协议,可能会在一个非官方端口上测试它。某一天,你会为该协议和它的端口注册编号(哪怕只是写进你的 /etc/services 文件中,getservbyname
正是查这个文件)。在上述代码中你也可以不返回错误,而是临时指定一个端口号。一旦你将协议列入 /etc/services,你的软件就能自动找到对应端口,而无需重写代码。
与顺序服务器不同,并发服务器 必须能够同时为多个客户端提供服务。例如,一个 聊天服务器 可能会为某个特定客户端服务几个小时——它不能等这个客户端断开了,才去服务下一个客户端。
这就要求我们对流程图进行重大改动:
图 11. 并发服务器
我们将 服务逻辑 从 守护进程 中移到了独立的 服务进程 中。但由于每个子进程会继承所有已打开的文件(socket 被视为一种文件),新进程不仅会继承由 accept
返回的 连接句柄,也会继承由顶层进程最初创建的 监听 socket。
但 服务进程 并不需要这个监听 socket,因此应立即对它执行 close
操作。同样地,守护进程 也不再需要连接 socket,不仅应当关闭它,而且 必须 关闭——否则迟早会耗尽可用的 文件描述符。
当 服务进程 完成服务后,它应关闭连接 socket。此时不再返回 accept
,而是直接退出。
在 UNIX® 中,进程实际上并不会真正 退出,而是会 返回 给它的父进程。通常父进程会调用 wait
来等待其子进程,并获取其返回值。但我们的 守护进程 不能就此停止并等待子进程完成。否则就违背了创建多个子进程的初衷。但如果它永远不调用 wait
,子进程就会变成 *僵尸进程(zombie)*——虽然不再起作用,但仍残留在系统中。
因此,守护进程 需要在 初始化阶段 设置 信号处理器。至少要处理 SIGCHLD 信号,以便清除子进程的返回值,并释放它们占用的系统资源。
这也正是为什么流程图中多了一个不连接任何其他模块的 处理信号 方框。顺便说一句,许多服务器还会处理 SIGHUP 信号,并通常将其解释为超级用户发出的“重新读取配置文件”的信号。这样我们就可以更改设置,而不必杀掉并重启这些服务器。
为了让您的程序对其他语言用户更加实用,我们希望您能够按照 I18N 标准进行编程。GNU 的 gcc 编译器和像 QT、GTK 这样的图形界面库通过对字符串的特殊处理来支持 I18N。编写符合 I18N 的程序非常简单,这也使得其他人可以迅速将您的程序移植到其他语言。请参考特定库的 I18N 文档以获取更多细节。
与普遍认知相反,编写符合 I18N 的代码其实很容易。通常这只是将您的字符串包装在特定库的函数中。此外,请确保支持宽字符或多字节字符。
我们注意到,各个国家的 I18N/L10N 工作经常在重复彼此的劳动。我们中很多人一次又一次地在低效地重复造轮子。我们希望 I18N 领域的主要群体能聚集起来,形成类似 Core Team 所负责的那种统一协作。
当前,我们希望您在编写或移植 I18N 程序时,能将其发送到每个国家相关的 FreeBSD 邮件列表中进行测试。未来,我们希望创建出无需任何脏补丁即可在所有语言中工作的应用程序。
Perl 和 Python 拥有 I18N 和宽字符处理库。请在进行 I18N 编程时使用它们。
在支持各种输入编码和国家惯例(如不同的小数点符号)这些基础 I18N 功能之上,更高级的 I18N 还可以对程序输出的消息进行本地化。一种常见的做法是使用 POSIX.1 NLS 函数,这些函数作为 FreeBSD 基本系统的一部分提供。
POSIX.1 NLS 基于目录文件(catalog file),这些文件使用所需编码格式包含本地化的消息。消息被组织成若干集合,每条消息在其所属集合中通过一个整数编号标识。目录文件的命名惯例是使用包含的语言环境名,加上 .msg
扩展名。例如,ISO8859-2 编码的匈牙利语消息应该存储在名为 hu_HU.ISO8859-2 的文件中。
这些目录文件是普通文本文件,包含带编号的消息。可以通过在行首添加 $
符号写注释。集合边界也通过特殊注释分隔,其中 set
关键字必须紧跟在 $
符号之后,然后是集合编号。例如:
第二个参数是一个常量,有两个取值:
NL_CAT_LOCALE
,表示目录文件基于 LC_MESSAGES
。
0
,表示使用 LANG
环境变量查找目录文件。
以下示例展示了如何以灵活方式使用 NLS 目录。
首先,将以下代码行放入程序的公共头文件中,并在所有需要本地化消息的源文件中包含该头文件:
接着,将以下代码放入主源文件的全局声明部分:
最后是打开、读取和关闭目录文件的实际代码片段:
有一个很好的方法可以减少需要本地化的字符串数量,就是使用 libc 的错误信息。这也有助于避免重复,并为许多程序可能遇到的常见错误提供一致的错误信息。
首先,下面是一个没有使用 libc 错误信息的例子:
这个例子可以改写为通过读取 errno
并据此打印错误信息来输出错误:
在这个例子中,自定义字符串被省略了,因此翻译人员在本地化程序时的工作量会更少,用户在遇到这个错误时将看到熟悉的 “Not a directory” 错误信息。这条信息对他们来说可能更为熟悉。请注意,为了直接访问 errno
,必须包含 errno.h。
值得注意的是,有些情况下 errno
会由前面的调用自动设置,因此不需要显式设置:
使用目录文件需要一些重复的步骤,例如编译目录文件并将其安装到正确的位置。为了进一步简化这一过程,bsd.nls.mk 引入了一些宏。无需显式包含 bsd.nls.mk,它会由常见的 Makefile(例如 bsd.prog.mk 或 bsd.lib.mk)自动包含进来。
回归测试用于针对系统中的特定部分进行测试,以确认其按预期运行,并确保旧的错误不会被重新引入。
FreeBSD 的回归测试工具可以在 FreeBSD 源码树中的 src/tools/regression 目录下找到。
本节包含在 FreeBSD 上或对 FreeBSD 本身进行正确微基准测试的建议。
并非每次测试都能用到以下所有建议,但使用得越多,基准测试检测微小差异的能力就越强。
禁用 APM 以及任何其他形式的时钟干预(ACPI?)。
最小化磁盘 I/O,若可行则完全避免。
不要挂载不需要的文件系统。
如果可能,将 /、/usr 以及其他文件系统挂载为只读。这可以避免因访问时间(atime)更新而引起的磁盘 I/O 干扰。
在每次测试迭代之间重启系统,以保证状态一致。
从内核中移除所有非必要的设备驱动程序。例如,如果测试中不需要 USB,就不要在内核中加入 USB。驱动程序往往有计时器在运行。
如果不是测试网络,测试前不要配置网络,或者测试结束后再将结果发出。
禁用“Turbo 模式”,因为它会根据环境调整时钟频率。这意味着即使代码完全相同,基准测试结果也可能因时间、饮品甚至办公室里其他人的存在而不同。
如果系统必须连接公共网络,要注意广播流量的突发峰值。虽然几乎察觉不到,但它仍然会占用 CPU 周期。多播(Multicast)也存在类似问题。
将每个文件系统放在单独的磁盘上,减少因磁头寻道优化带来的抖动。
尽量减少串口或 VGA 控制台的输出。将输出写入文件可减少抖动。(串口控制台容易成为瓶颈。)测试过程中不要触碰键盘,哪怕是空格或退格键也会影响结果。
保证测试时间足够长,但不要太长。若测试太短,时间戳精度不足;若太长,温度变化和晶体频率漂移会影响结果。经验法则:不少于 1 分钟,不超过 1 小时。
每个测试至少运行 3 次,最好是“修改前”和“修改后”都运行超过 20 次。如有可能,交叉运行(例如:不要先运行 20 次“前”,再运行 20 次“后”),这样有助于识别环境因素。不要按 1:1 交替运行,而是 3:3,有助于发现交互效应。
推荐模式为:bababa{bbbaaa}*
。
这样,前 1+1 次就能初步判断趋势(若完全跑偏可及时终止测试),3+3 次可以估算标准差(决定是否值得长时间运行),后续数据可用于趋势和交互分析。
如果基准测试表现异常差,检查是否有异常的中断源导致的中断量暴增。有报告指出部分 ACPI 版本存在“异常行为”,会产生过多中断。为诊断异常测试结果,可以用 vmstat -i
拍几张快照,看看有没有异常。
注意内核和用户空间的优化参数,以及调试选项。很容易不小心遗漏某项,最后发现测试内容不一致。
Tinderbox 包括以下部分:
一个构建脚本 tinderbox,自动签出指定版本的 FreeBSD 源码树并构建。
一个监督脚本 tbmaster,监视各个 Tinderbox 实例、记录输出,并发送失败通知邮件。
一个名为 index.cgi 的 CGI 脚本,用于读取 tbmaster 日志并以 HTML 格式生成简洁易读的摘要。
一组持续构建 FreeBSD 各主要代码分支最新状态的构建服务器。
一个网页服务器,用于保存完整的 Tinderbox 日志,并显示最新摘要信息。
关于 tinderbox 和 tbmaster 脚本的更多信息,请参见它们的手册页:tinderbox(1) 和 tbmaster(1)。
脚本从 main()
开始,首先验证是否在官方 Tinderbox 网站上运行。如果不是,则会显示一页提示,并给出官方网站的链接。
接着,它扫描日志目录,获取存在日志文件的配置项、分支和架构清单,避免在脚本中硬编码这些列表,造成空行或空列。该信息由日志文件名提取,文件名需匹配以下格式:
官方 Tinderbox 构建服务器使用的配置名称与其构建的分支一致。例如,releng_8
配置用于构建 RELENG_8
以及所有仍受支持的 8.x 发布分支。
完成启动过程后,会为每个配置调用 do_config()
。
do_config()
会为单个 Tinderbox 配置生成 HTML 内容。
它先生成表头行,然后遍历指定配置下的每个分支构建,每个分支输出一行,流程如下:
对每个项目:
对该架构下的每台机器:
若存在简略日志文件:
调用 success()
判断构建是否成功。
输出修改大小。
输出简略日志文件大小并附带链接。
如果存在完整日志文件:
输出完整日志文件大小并附带链接。
否则:
不输出内容。
上述 success()
函数会扫描简略日志文件中是否含有 “tinderbox run completed” 字样,以判断构建是否成功。
配置和分支按其“分支等级”排序,排序规则如下:
HEAD
和 CURRENT
的等级为 9999。
RELENG_x
的等级为 *xx
*99。
RELENG_x_y
的等级为 xxyy。
这意味着 HEAD
总是最高等级,RELENG
分支按数字升序排列,每个 STABLE
分支高于其派生的 release 分支。例如,FreeBSD 8 中的顺序为:
RELENG_8
(等级 899)
RELENG_8_3
(等级 803)
RELENG_8_2
(等级 802)
RELENG_8_1
(等级 801)
RELENG_8_0
(等级 800)
Tinderbox 用 CSS 定义表格中每个单元格的颜色。构建成功显示绿色文字,构建失败显示红色文字。颜色会随时间推移逐渐变灰,每过半小时颜色就更趋向灰色。
目前有三个构建服务器:
freebsd-current.sentex.ca 构建:
HEAD
,适用于 amd64、arm、i386、i386/pc98、ia64、mips、powerpc、powerpc64 和 sparc64。
RELENG_9
以及受支持的 9.X 分支,适用于 amd64、arm、i386、i386/pc98、ia64、mips、powerpc、powerpc64 和 sparc64。
freebsd-stable.sentex.ca 构建:
RELENG_8
以及受支持的 8.X 分支,适用于 amd64、i386、i386/pc98、ia64、mips、powerpc 和 sparc64。
freebsd-legacy.sentex.ca 构建:
RELENG_7
以及受支持的 7.X 分支,适用于 amd64、i386、i386/pc98、ia64、powerpc 和 sparc64。
Apache 被设置为使用 index.cgi 作为 DirectoryIndex
。
本章描述了一些困扰 UNIX® 程序员数十年的安全问题,以及一些帮助程序员避免编写可被利用代码的新工具。
编写安全的应用程序需要一种极其谨慎和悲观的世界观。应用程序应遵循“最小权限”原则运行,使得任何进程运行时都不拥有超过其完成功能所需的最小访问权限。应尽可能重用之前经过测试的代码,以避免他人已经修复过的常见错误。
UNIX® 环境的一个陷阱是,很容易对环境的健全性做出假设。应用程序绝不应信任用户输入(无论以何种形式)、系统资源、进程间通信或事件的时序。UNIX® 进程不是同步执行的,因此逻辑操作很少是原子的。
缓冲区溢出从冯·诺依曼架构诞生之初便已存在。它们在 1988 年因 Morris Internet 蠕虫而首次广泛引起关注。不幸的是,这种基本攻击方式至今仍然有效。迄今为止,最常见的缓冲区溢出攻击类型是基于破坏栈的攻击。
大多数现代计算机系统使用栈来向过程传递参数并存储局部变量。栈是一种先进后出(LIFO)的缓冲区,位于进程映像的高地址内存区域。当程序调用一个函数时,会创建一个新的“栈帧”。这个栈帧由传递给函数的参数以及一块动态大小的局部变量空间组成。“栈指针”是一个寄存器,保存当前栈顶的位置。由于这个值在新数据不断压入栈顶时不断变化,许多实现还提供一个“帧指针”,它位于栈帧的开头附近,以便更容易以它为基准寻址局部变量。函数调用的返回地址也存储在栈上,这正是栈溢出攻击的根源 —— 因为函数中局部变量的溢出可以覆盖该函数的返回地址,可能允许恶意用户执行任意代码。
虽然基于栈的攻击最为常见,但也有可能通过堆(malloc/free)实现栈的溢出。
C 编程语言不像许多其他语言那样对数组或指针执行自动边界检查。此外,标准 C 库中充斥着一些非常危险的函数:
我们来看看,如果向这个小程序输入 160 个空格再按回车,这个进程的内存映像会是什么样子。
显然,可以设计出更具恶意的输入来执行实际的已编译指令(例如执行 exec(/bin/sh)
)。
解决栈溢出问题最直接的办法是始终使用带有长度限制的内存和字符串复制函数。strncpy
和 strncat
是标准 C 库的一部分。这些函数接受一个长度参数,该参数应不大于目标缓冲区的大小。这些函数会从源复制最多 “length” 个字节到目标中。但这些函数存在多个问题。若输入数据的长度与目标缓冲区相等,它们都不保证以 NUL 结尾。此外,这两个函数在长度参数上的语义不一致,程序员很容易混淆使用方法。若将短字符串复制到一个大缓冲区,与 strcpy
相比,这些函数的性能也大打折扣,因为 strncpy
会用 NUL 填满指定大小。
为了解决这些问题,出现了另一组内存复制实现:strlcpy
和 strlcat
。这些函数在传入非零长度参数时,始终保证目标字符串以 null 结尾。
不幸的是,目前仍有大量代码在不使用任何边界限制复制函数的情况下盲目地进行内存拷贝。幸运的是,存在一种方式可以帮助防止此类攻击 —— 编译器实现的运行时边界检查。
ProPolice 通过在调用函数之前在栈的关键区域放置伪随机数来防止基于栈的缓冲区溢出及其他攻击。当函数返回时,这些“金丝雀值”会被检查,如果发现已被更改,程序会立即中止。因此,任何试图修改返回地址或栈上其他变量以执行恶意代码的行为都极不可能成功,因为攻击者还必须设法保持这些伪随机金丝雀值不被破坏。
使用 ProPolice 重新编译应用程序是防止大多数缓冲区溢出攻击的有效手段,尽管仍有被绕过的可能。
对于无法重新编译的二进制软件,编译器机制完全无效。在这种情况下,有一些库对 C 库中的不安全函数(如 strcpy
、fscanf
、getwd
等)进行了重新实现,并确保这些函数绝不会写越过栈指针。
libsafe
libverify
libparanoia
不幸的是,这些基于库的防护措施存在一些缺陷。这些库仅能防御极少数与安全相关的问题,并未修复实际根本问题。如果应用程序使用了 -fomit-frame-pointer
编译选项,这些防护可能会失效。此外,用户可以覆盖或取消设置 LD_PRELOAD
和 LD_LIBRARY_PATH
环境变量,从而绕过这些防护。
每个进程至少关联有 6 个不同的 ID,因此必须非常谨慎地控制进程在任意时刻所拥有的访问权限。尤其是,所有调用 seteuid
的应用程序应在权限不再需要时立即放弃这些权限。
真实用户 ID 只能由超级用户进程更改。用户初次登录时由登录程序设置此 ID,之后很少再改变。
有效用户 ID 是在程序具有 setuid 位时由 exec()
函数设置的。应用程序可以随时调用 seteuid()
,将有效用户 ID 设置为真实用户 ID 或保存的 set-user-ID。当 exec()
函数设置有效用户 ID 时,先前的值会保存在保存的 set-user-ID 中。
限制进程的传统方法是使用 chroot()
系统调用。此调用会改变进程及其所有子进程所引用路径的根目录。要使该调用成功,进程必须对所引用目录具有执行(搜索)权限。新环境实际上要等到调用 chdir()
进入新目录后才会生效。还应注意,如果进程拥有 root 权限,是可以轻易逃出 chroot 环境的。例如可以通过创建设备节点读取内核内存,或将调试器附加到 chroot 环境之外的进程,或用其他多种创造性方式实现逃脱。
可以通过 sysctl
变量 kern.chroot_allow_open_directories
对 chroot()
系统调用的行为进行一定控制。当此值设置为 0 时,如果有任何目录是打开的,chroot()
将以 EPERM 失败。若设置为默认值 1,则在进程已经处于 chroot()
环境下且存在任何打开的目录时,chroot()
将以 EPERM 失败。若设置为其他值,则完全跳过对打开目录的检查。
Jail 的概念在 chroot()
的基础上扩展,通过限制超级用户的权限来创建一个真正的“虚拟服务器”。一旦设置了 jail,所有网络通信都必须通过指定的 IP 地址进行,而该 jail 中的“root 权限”被大大限制。
在 jail 中,任何在内核中使用 suser()
调用进行的超级用户权限测试都会失败。不过,一些对 suser()
的调用已经被替换成了新的接口 suser_xxx()
。这个函数负责识别或拒绝 jail 中进程对超级用户权限的访问。
处于 jail 环境中的超级用户进程仍然拥有以下权限:
使用 setuid
、seteuid
、setgid
、setegid
、setgroups
、setreuid
、setregid
、setlogin
操作凭据;
使用 setrlimit
设置资源限制;
修改某些 sysctl 节点(如 kern.hostname);
执行 chroot()
;
设置 vnode 的标志位:chflags
、fchflags
;
设置 vnode 的属性,如文件权限、所有者、组、大小、访问时间和修改时间;
在 Internet 域中绑定特权端口(端口号小于 1024)。
Jail
是一个非常有用的工具,可以在安全的环境中运行应用程序,但它也有一些不足。目前,IPC 机制尚未转换为 suser_xxx
接口,因此某些应用(如 MySQL)无法在 jail 中运行。虽然 jail 中的超级用户权限被大大限制,但目前还无法精确地定义“限制到什么程度”。
POSIX® 已发布一份工作草案,添加了事件审计、访问控制列表、细粒度权限、信息标记和强制访问控制等内容。
应用程序永远不应该假设用户环境中的任何内容是可靠的。这包括(但不限于):用户输入、信号、环境变量、资源、IPC、内存映射、文件系统工作目录、文件描述符、打开的文件数量等。
你永远不应假设可以捕获用户可能提供的所有非法输入。相反,应用程序应当使用正向过滤,仅允许你认为安全的特定输入子集。数据验证不当已经成为许多漏洞的根源,尤其是在面向万维网的 CGI 脚本中。对于文件名,应当格外注意路径(如 "../"
、"/"
)、符号链接和 shell 转义字符。
Perl 有一个非常强大的特性叫做 “Taint 模式”,可以防止脚本在不安全的方式下使用来自程序外部的数据。该模式会检查命令行参数、环境变量、本地化信息、某些系统调用的返回值(如 readdir()
、readlink()
、getpwxxx()
)以及所有文件输入。
竞争条件是一种由于对事件相对时序的意外依赖而导致的异常行为。换句话说,程序员错误地假设某个事件总是会在另一个事件之前发生。
竞争条件的一些常见原因包括信号、访问检查和文件打开操作。信号本质上是异步事件,因此在处理它们时必须特别小心。使用 access(2)
进行权限检查后再调用 open(2)
显然不是原子操作。用户可能会在两次调用之间移动文件。因此,有特权的应用程序应该先调用 seteuid()
,然后直接使用 open()
。同理,应用程序在调用 open()
前应始终设置合适的 umask,从而避免不必要的 chmod()
调用。
FreeBSD 提供了一个优秀的开发环境。C 和 C++ 编译器以及汇编器随基本系统一同提供,更不用说像 sed
和 awk
这样的经典 UNIX® 工具了。如果这还不够,Ports 中还有许多其他编译器和解释器可供选择。下一节 列出了一些可用的选项。FreeBSD 与 POSIX®、ANSI C 等标准以及自身的 BSD 传统高度兼容,因此你可以编写在多种平台上几乎无需修改即可编译和运行的应用程序。
关于如何获取和安装 Ports 中的应用程序,可以参考手册中的 。
Bywater Basic 解释器可以在 FreeBSD 的 Port 中找到,位置是 ,Phil Cockroft 编写的 Basic 解释器(原名 Rabbit Basic)则位于 。
在 FreeBSD 的 Port 中提供了多种可在 UNIX® 系统上运行的 Lisp 实现。Bruno Haible 和 Michael Stoll 编写的 CLISP 可在 找到;一个更为简化的 Lisp 实现 SLisp 可在 找到。
Perl 可在 FreeBSD 的 Port 中找到,位置是 ,适用于所有 FreeBSD 发行版。
可在 Port 中的 找到 Elk Scheme 解释器;MIT Scheme 解释器位于 ,SCM Scheme 解释器则位于 。
Lua Lua 是一种轻量级的可嵌入脚本语言。它具有良好的可移植性,结构也相对简单。Lua 可在 Port 中通过 获取。它也被包含在 base 系统中,路径为 /usr/libexec/flua,用于 base 系统组件。第三方软件不应依赖 flua。
可在 Port 中通过 获取Python 的最新版本。
可在 Port 中通过 获取 Ruby。
多个版本的 Tcl 可作为 FreeBSD 的 Port 提供。最新版本 Tcl 8.7 可在 找到。
编译器与解释器有很大不同。首先,你需要使用编辑器在文件中编写代码(一个或多个文件)。然后运行编译器,看看它是否接受你的程序。如果没有编译成功,咬紧牙关,返回编辑器进行修改;如果编译成功并生成了程序,你可以在 shell 命令提示符下运行它,或者在调试器中运行,以查看它是否正常工作。^[]^
由于使用单独程序进行编辑-编译-运行-调试的周期相当繁琐,许多商业编译器制造商已经开发了集成开发环境(简称 IDE)。FreeBSD 的基础系统中不包括 IDE,但 在 Ports Collection 中可以找到,许多人也使用 Emacs 来实现这个目的。关于如何使用 Emacs 作为 IDE,请参见 。
本节介绍了 FreeBSD 基础系统中安装的 clang 编译器用于 C 和 C++ 的情况。Clang 被安装为 cc
;GNU 编译器 在 Ports Collection 中也可以找到。使用解释器生成程序的详细过程因解释器而异,通常在解释器的文档和在线帮助中有很好的介绍。
请注意,语法检查只是检查语法。它不会检查你可能犯的任何逻辑错误,比如将程序写入死循环,或者使用了冒泡排序而你本该使用二分排序。^[]^
-o filename
指定输出文件的名称。如果不使用此选项,cc
将生成一个名为 a.out 的可执行文件。^[]^
这将生成程序的调试版本。^[]^
使用调试器分析 core 文件(请参见 )。
另外,你还可以通过调用 abort()
函数,在程序内部创建 core dump。有关更多信息,请参阅 的手册页。
如果你想从程序外部创建 core dump,但又不希望程序终止,可以使用 gcore
程序。有关更多信息,请参阅 的手册页。
make
是一个非常强大的工具,能做的事情远远超过上面简单示例所展示的内容。不幸的是,存在多种不同版本的 make
,它们之间有很大差异。学习它们能做什么的最佳方式可能是阅读文档——希望本介绍已经为你提供了一个良好的基础。可以通过 手册页,了解更多关于变量、参数及如何使用 make 的全面讨论。
本节旨在提供使用调试器的简要介绍,不涵盖诸如内核调试等专业话题。有关详细信息,请参阅 。
FreeBSD 提供的标准调试器是 lldb
(LLVM 调试器)。由于它是该版本的标准安装的一部分,因此无需做任何特殊的操作即可使用它。它提供了很好的命令帮助,可以通过 help
命令访问,还有 。
也可以通过 从 获取 lldb
命令。
FreeBSD 还提供了另一个调试器 gdb
(GNU 调试器)。与 lldb 不同,gdb
并不是 FreeBSD 的默认安装,若要使用它,请从 Ports 或 Packages 中安装 。它提供了很好的在线帮助和一套 info 页面。
从 LLDB 版本 12.0.0 开始,支持在 FreeBSD 上进行远程调试。使用早期 LLDB 版本的 FreeBSD 版本的用户可能希望使用 中提供的快照,如 。
最后,对于那些觉得文本命令提示风格不太友好的人,Ports 集合中有一个图形前端()可以使用。
Emacs 可以通过 FreeBSD 的 端口进行安装。
学习 Emacs Lisp 的最佳方式是阅读在线的 手册。
然后你就可以在 Emacs 中编辑该文件!^[]^
关于如何设置开发环境以便为 FreeBSD 本身贡献修复,请参阅 。
. 如果你在 shell 中运行它,可能会得到核心转储。
. 如果你不知道,二进制排序是一种高效的排序方式,而冒泡排序则不是。
. 这背后的原因深藏在历史的迷雾中。
. 请注意,我们没有使用 -o 标志来指定可执行文件名,所以我们将得到一个名为 a.out 的可执行文件。生成一个名为 foobar 的调试版本留给读者自己完成!
. 它们不使用 MAKEFILE 格式,因为大写字母通常用于文档文件,比如 README。
. 许多 Emacs 用户将他们的 EDITOR 环境设置为 emacsclient,这样每当他们需要编辑文件时,Emacs 就会启动。
FreeBSD 的完整源代码可从我们的 获取。源代码通常安装在 /usr/src。源代码树的结构可通过顶层的 文件了解。
一致的编码风格极为重要,尤其是在 FreeBSD 这样的大型项目中。代码应遵循 FreeBSD 编码风格,如 和 所述。
FreeBSD 的一部分分发内容是由 FreeBSD 项目之外的团队维护的。出于历史原因,我们称这类软件为 引入的软件(contributed software)。典型例子有 LLVM、 和 。
根据具体需求与复杂程度,个别软件项目可以在维护者酌情判断下偏离此流程。更新特定引入软件所需的具体步骤应记录在名为 FREEBSD-upgrade
的文件中,例如 。
关于引入软件与 vendor 分支的标准管理流程详见 。
添加任何受限文件必须获得 的特别批准。
应始终包含在 LINT 中,但是否注释由 个案决定。该团队以后可以更改决定。
是否加入 make world
由 决定。
是否进入发行版由 决定。
客户端和服务器都使用的一个函数是 。它的声明如下:
幸运的是,你不是第一个遇到这个问题的人。早有人已经创建了 和 这两个 C 函数,分别用于将 short
和 long
从 主机字节序 转换为 网络字节序;还有 和 函数,用于反向转换。
客户端创建了 socket 后,就需要将其连接到远程系统的一个特定端口。它会使用 :
IP 端口总共有 65535 个,但服务器通常只处理来自其中某一个端口的请求。这就像告诉电话总机我们正在工作,可以在某个特定分机上接电话。我们使用 告诉 sockets 我们要监听哪个端口。
服务器使用 函数来确保这一切。
服务器使用 函数来接收连接:
虽然没有办法将域名直接传递给任何 socket 函数,但 FreeBSD 的 C 库提供了 和 这两个函数,它们在 netdb.h 中声明。
有时候你可能不确定某个服务使用的端口号。此时, 函数就非常有用,它同样在 netdb.h 中声明:
已经建立 。如果您是 I18N/L10N 的开发者,请将您的评论、想法、问题或您认为相关的一切内容发送到该邮件列表。
实际的消息条目以消息编号开头,后跟本地化的消息。支持 中常见的格式修饰符:
语言目录文件在程序使用之前必须编译成二进制格式。这个转换通过 工具完成。它的第一个参数是生成的目录文件名,后续参数为输入的目录文件。也可以将本地化消息组织成多个目录文件,然后通过 一起处理。
使用这些目录文件很简单。为了使用相关函数,必须包含头文件 nl_types.h。在使用目录前,需使用 打开目录文件。该函数接受两个参数,第一个是安装并编译后的目录文件名,通常使用程序名(如 grep),此名将用于查找编译目录文件。 会在 /usr/share/nls/locale/catname 和 /usr/local/share/nls/locale/catname 中查找此文件,其中 locale
是设置的语言环境,catname
是目录名。
返回一个类型为 nl_catd
的目录标识符。可能的返回错误代码请参阅其手册页。
打开目录文件后,可以使用 来检索消息。第一个参数是 catopen()
返回的目录标识符,第二个参数是集合编号,第三个是消息编号,第四个是备用消息,当无法从目录文件中取出所请求消息时将返回此备用消息。
使用完目录文件后,必须调用 关闭该文件,此函数有一个参数,即目录标识符。
通常,只需定义 NLSNAME
(应与 第一个参数中提到的目录名一致),并在 NLS
中列出目录文件名(不带 .msg
扩展名)即可。下面是一个示例,它使得可以在结合前面的代码示例时禁用 NLS。若要构建不支持 NLS 的程序,只需定义 WITHOUT_NLS
变量即可。
通常,目录文件放在 nls 子目录下,这是 bsd.nls.mk 的默认行为。不过,也可以通过设置 NLSSRCDIR
变量来覆盖目录文件的位置。预编译的目录文件的默认命名方式也遵循前面提到的命名规范,但也可以通过设置 NLSNAME
变量来覆盖。还有一些其它选项可以微调目录文件的处理过程,不过通常不需要,因此此处未作介绍。要了解 bsd.nls.mk 的更多信息,请直接查阅该文件,它很简短,也很容易理解。
以单用户模式运行。例如, 和其他守护进程只会增加干扰。 守护进程也可能造成问题。如果测试时需要 ssh 访问,可以禁用 SSHv1 密钥重新生成,或者在测试过程中杀死 sshd
的父进程。
不要运行 。
如果会产生 事件,可以让 读取一个空的 /etc/syslogd.conf,否则就不要运行它。
每次运行前用 重新初始化读写测试使用的文件系统,并用 或 文件进行填充。在测试开始前卸载并重新挂载,这样可以获得一致的文件系统布局。对于 worldstone 测试,这适用于 /usr/obj(只需用 newfs
重新初始化并挂载即可)。若想实现 100% 可重现性,可使用 命令从映像填充文件系统(例如:dd if=myimage of=/dev/ad0s1h bs=1m
)。
使用由 malloc 支持或预加载的 分区。
卸载不使用的硬件。使用 和 卸载不参与测试的磁盘。
保持测试环境温度尽量稳定。这会影响晶体振荡器和硬盘算法。如需稳定时钟,可考虑注入稳定时钟信号。例如,使用 OCXO + PLL,并将输出注入主板时钟电路以替代晶振。联系 Poul-Henning Kamp()获取更多信息。
使用 判断数值是否有统计意义。如果你忘记或从未学过标准差和 Student's T 分布,强烈推荐《Cartoon guide to statistics》,ISBN: 0062731025。
不要使用后台 ,除非正在基准测试该功能。同时,在 /etc/rc.conf 中禁用 background_fsck
,除非基准测试是在启动后至少延迟了 60 秒加 fsck
运行时间之后进行的,因为 会在启动时检查是否需要运行后台 fsck
。
同样,除非是测试快照功能,否则确保没有残留快照存在。
除非专门测试这些功能,不要在启用了 WITNESS
和 INVARIANTS
的内核下进行基准测试。WITNESS
可能导致性能下降 400% 以上。类似地,-CURRENT 中用户空间的 参数默认与生产版不同。
这些脚本由 Dag-Erling Smørgrav()开发并维护,现已从最初的 shell 脚本迁移为 Perl 编写。所有脚本与配置文件存放在 。
index.cgi 脚本会生成 Tinderbox 和 tbmaster 日志的 HTML 摘要。虽然名字显示这是一个 CGI 脚本,但它也可以从命令行或 作业中运行,此时会在脚本所在目录查找日志。它可自动检测运行上下文,在作为 CGI 脚本运行时会生成 HTTP 头信息。它符合 XHTML 标准并使用 CSS 样式。
官方 Tinderbox 构建服务器由 托管,该公司同时也托管 FreeBSD Netperf 集群。
来自官方构建服务器的摘要与日志可通过 在线访问,由 Dag-Erling Smørgrav()托管并按如下方式设置:
一个 任务定期检查构建服务器,并使用 下载任何新的日志文件。
下面的示例代码中包含了一个缓冲区溢出,旨在覆盖返回地址并跳过函数调用后紧接着的指令。(灵感来源于 )
ProPolice 就是一种这样的编译器特性,它集成于 4.1 及更高版本中。它取代并扩展了早期的 StackGuard 扩展。
该草案仍在完善中,是 项目的研究重点。部分初期工作(如 cap_set_proc(3)
)已经被提交到 FreeBSD-CURRENT 中。
strcpy
(char *dest, const char *src)
可能溢出 dest 缓冲区
strcat
(char *dest, const char *src)
可能溢出 dest 缓冲区
getwd
(char *buf)
可能溢出 buf 缓冲区
gets
(char *s)
可能溢出 s 缓冲区
[vf]scanf
(const char *format, …)
可能溢出其参数
realpath
(char *path, char resolved_path[])
可能溢出 path 缓冲区
[v]sprintf
(char *str, const char *format, …)
可能溢出 str 缓冲区
[1] Dave A Patterson and John L Hennessy. Copyright® 1998 Morgan Kaufmann Publishers, Inc. 1-55860-428-6. Morgan Kaufmann Publishers, Inc. Computer Organization and Design. The Hardware / Software Interface. 1-2.
[2] W. Richard Stevens. Copyright® 1993 Addison Wesley Longman, Inc. 0-201-56317-7. Addison Wesley Longman, Inc. Advanced Programming in the Unix Environment. 1-2.
[3] Marshall Kirk McKusick and George Neville-Neil. Copyright® 2004 Addison-Wesley. 0-201-70245-2. Addison-Wesley. The Design and Implementation of the FreeBSD Operating System. 1-2.
[4] Aleph One. Phrack 49; "Smashing the Stack for Fun and Profit".
[5] Chrispin Cowan, Calton Pu, and Dave Maier. StackGuard; Automatic Adaptive Detection and Prevention of Buffer-Overflow Attacks.
[6] Todd Miller and Theo de Raadt. strlcpy and strlcat — consistent, safe string copy and concatenation.
一旦内核发生 panic,系统重启是不可避免的。一旦系统重启,系统物理内存(RAM)的内容将丢失,以及在崩溃前交换设备上的任何数据。为了保存物理内存中的数据,内核使用交换设备作为在崩溃后重启时存储 RAM 中数据的临时位置。这样,在 FreeBSD 崩溃后重新启动时,可以提取内核映像并进行调试。
注意
已配置为转储设备的交换设备仍然充当交换设备。当前不支持将转储写入非交换设备(例如磁带或 CDRW)。 “交换设备”等同于“交换分区”。
有几种类型的内核崩溃转储可供选择:
完整内存转储:包含物理内存的完整内容。
小型转储:仅包含内核使用的内存页(FreeBSD 6.2 及更高版本)。
文本转储:包含捕获的脚本或交互式调试器输出(FreeBSD 7.1 及更高版本)。
自 FreeBSD 7.0 起,小型转储是默认的转储类型,在大多数情况下,它会捕获完整内存转储中所有必要的信息,因为大多数问题只需要使用内核状态来进行隔离。
技巧
重要
在内核崩溃之前,确保在 rc.conf(5) 中指定的 dumpdir 已经存在!
另外,请记住,/var/crash 目录的内容是敏感的,极有可能包含诸如密码等机密信息。
技巧
如果你正在测试一个新的内核,但需要启动不同的内核才能使系统恢复正常,使用引导提示符中的 -s 标志将其仅引导到单用户模式,然后执行以下步骤:
kgdb
调试内核崩溃转储注意
要进入调试器并开始从转储中获取信息,启动 kgdb:
其中 N 是要检查的 vmcore.N 后缀。要打开最近的转储,可以使用:
你可以像调试其他程序一样,使用内核源代码调试崩溃转储。
此转储来自 5.2-BETA 内核,崩溃发生在内核的深处。下面的输出已修改,左侧包含行号。此第一个跟踪检查指令指针并获得回溯。第 41 行用于 list
命令的地址是指令指针,可以在第 17 行找到。如果你无法自己调试问题,大多数开发人员会要求至少将这些信息发送给他们。如果你能够解决问题,确保通过问题报告、邮件列表或提交代码的方式将你的补丁合并到源代码树中!
技巧
如果你的系统经常崩溃并且磁盘空间不足,删除 /var/crash 中的旧 vmcore 文件可以节省相当多的磁盘空间!
虽然 kgdb
作为离线调试器提供了非常高级的用户界面,但它有一些无法完成的任务。最重要的两个是设置断点和单步执行内核代码。
如果你需要对内核进行低级调试,可以使用一个在线调试器 DDB。它允许设置断点、单步执行内核函数、检查和修改内核变量等。然而,它无法访问内核源文件,且只能访问全局和静态符号,而不像 kgdb
那样拥有完整的调试信息。
要配置内核以包含 DDB,请在配置文件中添加以下选项:
待 DDB 内核启动运行,你可以通过多种方式进入 DDB。首先,最早的方式是使用启动标志 -d
。这样,内核将在调试模式下启动,并在任何设备探测之前进入 DDB。因此,你甚至可以调试设备探测/附加函数。要使用此方法,请退出加载器的启动菜单并在加载器提示符下输入 boot -d
。
第二种方式是在系统启动后进入调试器。有两种简单的方法可以实现这一点。如果你希望从命令提示符进入调试器,只需键入以下命令:
或者,如果你在系统控制台上,可以使用键盘上的热键。默认的断点调试器快捷键是 Ctrl+Alt+ESC。对于 syscons,可以重新映射此快捷键,且一些分发的映射已经这样做,所以确保你知道正确的快捷键序列。如果你使用串行控制台,则可以通过串行线路上的 BREAK 信号进入 DDB(在内核配置文件中使用 options BREAK_TO_DEBUGGER
)。这不是默认设置,因为很多串行适配器会无故生成 BREAK 信号,例如拔掉电缆时。
第三种方式是任何 panic 条件都会跳转到 DDB,如果内核已配置为使用它。因此,为无人值守的机器配置带有 DDB 的内核并不明智。
为了获得无人值守功能,可以在内核配置文件中添加:
然后重新构建/重新安装内核。
DDB 的命令大致类似于一些 gdb
命令。你可能首先需要做的是设置断点:
数字默认为十六进制,但为了与符号名称区分开来,十六进制数字中以字母 a-f
开头的需要前面加 0x
(其他数字则不需要)。简单的表达式也可以,例如:function-name + 0x103
。
要退出调试器并继续执行,输入:
要获取当前线程的堆栈跟踪,使用:
要获取任意线程的堆栈跟踪,可以将进程 ID 或线程 ID 作为第二个参数传递给 trace
。
如果要删除一个断点,使用:
第一个形式会在断点命中后立即接受并删除当前断点。第二个形式可以删除任何断点,但需要指定确切的地址;可以通过以下命令获取该地址:
或:
要单步执行内核,可以尝试:
这会进入函数,但你可以让 DDB 跟踪这些函数,直到匹配的返回语句被达到,使用:
注意
这与
gdb
的next
语句不同;它类似于gdb
的finish
。多次按下 n 将导致继续执行。
要检查内存中的数据,可以使用(例如):
用于字/半字/字节访问,以及十六进制/十进制/字符/字符串显示。逗号后的数字是对象的数量。要显示接下来的 0x10 项,只需使用:
类似地,使用
来反汇编 foofunc
的前 0x10 条指令,并显示它们及其相对于 foofunc
开始位置的偏移。
要修改内存,使用写入命令:
命令修饰符(b
/h
/w
)指定要写入的数据大小,第一个跟随的表达式是写入地址,剩下的被解释为写入到连续内存位置的数据。
如果需要查看当前的寄存器,可以使用:
或者,你可以通过例如以下命令显示单个寄存器的值:
并通过以下命令修改它:
如果需要从 DDB 调用一些内核函数,只需输入:
返回值将被打印出来。
现在,你已经检查了内核崩溃的原因,想要重启系统。记住,根据先前故障的严重程度,并非所有内核部分仍然按预期工作。执行以下操作之一来关闭并重启系统:
可能是一个干净地关闭系统、sync()
所有磁盘并在某些情况下重启的好方法。只要内核的磁盘和文件系统接口没有损坏,这可能是一个几乎干净的关闭方式。
这是灾难的最后逃生方式,几乎等同于按下大红按钮。
如果需要简短的命令总结,只需输入:
GDB 长期以来一直支持 远程调试。这通过一个非常简单的协议沿着串行线进行。与上述其他调试方法不同,使用远程 GDB 需要两台机器。一台是提供调试环境的主机,包括所有源代码和带有所有符号的内核二进制文件。另一台是运行相同内核副本的目标机器(可以选择剥离调试信息)。
为了使用远程 GDB,请确保在内核配置中包含以下选项:
请注意,GDB
选项在 -STABLE 和 -RELEASE 分支的 GENERIC
内核中默认关闭,但在 -CURRENT 中已启用。
目标机器必须进入 GDB 后端,可以是由于 panic 或者通过故意触发进入调试器。在执行此操作之前,选择 GDB 调试后端:
注意
然后,强制进入调试器:
目标机器现在等待来自远程 GDB 客户端的连接。在调试机器上,进入目标内核的编译目录,并启动 gdb
:
通过以下命令初始化远程调试会话(假设使用的是第一个串口):
现在,主机 GDB 将控制目标内核:
技巧
您可以像使用任何其他 GDB 会话一样使用此会话,包括完全访问源代码,在 Emacs 窗口中以 gud-mode 运行它(这会在另一个 Emacs 窗口中自动显示源代码)等。
由于需要一个控制台驱动程序才能运行 DDB,如果控制台驱动程序本身出现故障,事情会变得更加复杂。您可能还记得使用串行控制台(通过修改启动块,或在 Boot:
提示符下指定 -h
),并将标准终端连接到您的第一个串行端口。DDB 在任何配置了的控制台驱动程序上都能工作,包括串行控制台。
如果可能,考虑进行进一步调查。如果您怀疑死锁发生在 VFS 层,下面的步骤尤其有用。请将这些选项添加到内核配置文件中。
当死锁发生时,除了 ps
命令的输出外,还应提供来自 show pcpu
、show allpcpu
、show locks
、show alllocks
、show lockedvnods
和 alltrace
的信息。
为了获得线程进程的有意义的回溯,可以使用 thread thread-id
切换到线程栈,然后使用 where
进行回溯。
FireWire® 设备不限于集成在主板中的设备。桌面电脑可以使用 PCI 卡,笔记本电脑可以购买卡总线接口。
要在目标机器的内核中启用 FireWire® 和 Dcons 支持:
确保您的内核支持 dcons
、dcons_crom
和 firewire
。Dcons
应该与内核静态链接。对于 dcons_crom
和 firewire
,模块应该是可以的。
确保启用了物理 DMA。您可能需要在 /boot/loader.conf 中添加 hw.firewire.phydma_enable=1
。
添加调试选项。
如果使用 GDB 通过 FireWire® 调试,请在 /boot/loader.conf 中添加 dcons_gdb=1
。
在 /etc/ttys 中启用 dcons
。
可选地,要强制将 dcons
设置为高级控制台,请在 loader.conf 中添加 hw.firewire.dcons_crom.force_console=1
。
以下是一些配置示例。一个示例内核配置文件应该包含:
示例 /boot/loader.conf 文件应包含:
要在主机机器的内核中启用 FireWire® 支持:
~+.
断开连接
~
ALT BREAK
~
重置目标
~
暂停 dconschat
以下是一些常规提示:
为了充分利用 FireWire® 的速度,禁用其他较慢的控制台驱动程序:
对于 DDD (devel/ddd),您可以使用以下命令:
可以通过以下方式进行实时核心调试:
本节提供了用于调试的编译时内核选项的简要词汇表:
options KDB
:编译内核调试器框架。options DDB
和 options GDB
需要此选项。几乎没有性能开销。默认情况下,当系统发生 panic 时,调试器会被触发,而不是自动重启。
options KDB_UNATTENDED
:将 debug.debugger_on_panic
sysctl 的默认值更改为 0,该 sysctl 控制系统在 panic 时是否进入调试器。如果内核中未编译 options KDB
,则默认行为是在 panic 时自动重启;如果编译了 options KDB
,默认行为是在没有编译 options KDB_UNATTENDED
的情况下进入调试器。如果希望将内核调试器保留在内核中,但希望系统在不进行诊断时能够恢复,除非您能使用调试器,使用此选项。
options KDB_TRACE
:将 debug.trace_on_panic
sysctl 的默认值更改为 1,该 sysctl 控制是否在 panic 时自动打印堆栈跟踪。尤其是在运行 options KDB_UNATTENDED
时,这对于在串行或 FireWire 控制台上收集基本调试信息非常有帮助,同时仍然能进行重启恢复。
options DDB
:编译支持控制台调试器 DDB。此交互式调试器可以在系统的任何活动低级控制台上运行,包括视频控制台、串行控制台或 FireWire 控制台。它提供基本的集成调试功能,如堆栈跟踪、进程和线程列表、锁状态转储、虚拟内存状态、文件系统状态和内核内存管理。DDB 不需要在第二台机器上运行软件,也不需要生成核心转储或完整的调试内核符号,提供实时的内核诊断。许多错误可以仅通过 DDB 输出完全诊断。此选项依赖于 options KDB
。
options GDB
:编译支持远程调试器 GDB,可以通过串行电缆或 FireWire 进行操作。当调试器被触发时,可以附加 GDB 来检查结构内容、生成堆栈跟踪等。某些内核状态比在 DDB 中更难访问,因为 DDB 可以自动生成有用的内核状态摘要,如自动遍历锁调试或内核内存管理结构,而 GDB 需要在第二台机器上运行。另一方面,GDB 结合了内核源代码和完整的调试符号,并且能够了解完整的数据结构定义、局部变量,且可以编写脚本。此选项不要求在内核核心转储上运行 GDB。此选项依赖于 options KDB
。
options BREAK_TO_DEBUGGER
,options ALT_BREAK_TO_DEBUGGER
:允许在控制台上使用中断信号或替代信号进入调试器。如果系统在没有 panic 的情况下挂起,这是进入调试器的一种有用方法。由于当前内核锁定的原因,通过串行控制台生成的中断信号在进入调试器时更为可靠,因此通常推荐使用这种方式。此选项对性能的影响很小或没有影响。
options INVARIANTS
:将大量运行时断言检查和测试编译到内核中,这些检查和测试不断验证内核数据结构的完整性和内核算法的不变性。由于这些测试可能会比较耗费资源,因此默认情况下不编译,但它们有助于提供有用的“故障停止”行为,在内核数据损坏发生之前,某些类别的非预期行为会先进入调试器,使其更容易调试。这些测试包括内存擦洗和使用后释放的测试,这是影响性能的一个重要因素。此选项依赖于 options INVARIANT_SUPPORT
。
options INVARIANT_SUPPORT
:options INVARIANTS
中的许多测试需要修改的数据结构或需要定义额外的内核符号。
options WITNESS_SKIPSPIN
:禁用 WITNESS 的自旋锁顺序检查。由于调度器中最常频繁地获取自旋锁,并且调度事件频繁发生,此选项可以显著加快运行 WITNESS 的系统。此选项依赖于 options WITNESS
。
options WITNESS_KDB
:将 debug.witness.kdb
sysctl 的默认值更改为 1,这会使 WITNESS 在检测到锁定顺序违规时进入调试器,而不仅仅是打印警告。此选项依赖于 options WITNESS
。
options SOCKBUF_DEBUG
:对套接字缓冲区执行广泛的运行时一致性检查,这对于调试套接字错误和协议以及与套接字交互的设备驱动中的竞争条件非常有用。此选项对网络性能有显著影响,可能会改变设备驱动中的时序。
options DEBUG_VFS_LOCKS
:跟踪 lockmgr/vnode 锁的锁定获取点,扩展 DDB 中 show lockedvnods
显示的内容。此选项对性能有可测量的影响。
options DIAGNOSTIC
:启用附加的、较为昂贵的诊断测试,类似于 options INVARIANTS
。
在 UNIX® 下进行汇编语言编程的文献资料非常有限。通常认为没有人会使用汇编语言,因为各种 UNIX® 系统运行在不同的微处理器上,所以一切应该使用 C 语言编写以保证可移植性。
实际上,C 语言的可移植性实际上是个神话。无论它们运行在哪种处理器上,即使是 C 程序在从一款 UNIX® 移植到另一款 UNIX® 时,也需要修改。通常,这样的程序充满了依赖于其编译系统的条件语句。
即使我们相信所有 UNIX® 软件都应该使用 C 或其他高级语言编写,我们仍然需要汇编语言程序员:谁来编写访问内核的 C 库部分呢?
在本章中,我将尝试向您展示如何使用汇编语言编写 UNIX® 程序,特别是在 FreeBSD 下。
版权® 2000-2001 G. Adam Stanislav。保留所有权利。
进行汇编语言编程最重要的工具是汇编器,它是将汇编语言代码转换为机器语言的软件。
本章使用 nasm 语法,因为大多数从其他操作系统转到 FreeBSD 的汇编语言程序员会觉得这种语法更易理解。而且,坦率地说,这也是我习惯的语法。
汇编器的输出文件与任何编译器的输出文件一样,需要通过链接器来生成可执行文件。
FreeBSD 提供了标准的 ld(1)
链接器。它可以与任何汇编器生成的代码一起使用。
默认情况下,FreeBSD 内核使用 C 调用约定。此外,虽然内核是通过 int 80h
进行访问的,但程序会调用一个发出 int 80h
的函数,而不是直接发出 int 80h
。
这种约定非常方便,并且比 MS-DOS® 使用的 Microsoft® 调用约定更为优越。为什么?因为 UNIX® 约定允许任何用任何语言编写的程序访问内核。
汇编语言程序也可以这样做。例如,我们可以打开一个文件:
这是非常简洁和可移植的编码方式。如果您需要将代码移植到使用不同中断或不同传递参数方式的 UNIX® 系统,只需要修改内核程序即可。
但汇编语言程序员通常喜欢优化性能。上面的例子需要 call/ret
组合。我们可以通过 push
一个额外的 dword 来消除它:
我们将 5
放入 EAX
寄存器中,以标识内核函数,此处为 open
。
FreeBSD 是一款非常灵活的系统。它提供了其他访问内核的方式。然而,系统必须安装 Linux 模拟才能正常工作。
Linux 是一款类 UNIX® 系统。然而,它的内核使用与 MS-DOS® 相同的系统调用约定,即通过寄存器传递参数。与 UNIX® 约定类似,函数编号放在 EAX
中,参数则不通过堆栈传递,而是放在 EBX
、ECX
、EDX
、ESI
、EDI
和 EBP
中:
这种约定相对于 UNIX® 方式有一个很大的缺点,至少对汇编语言编程来说:每次进行内核调用时,您必须 push
寄存器,然后稍后再 pop
它们。这使得代码更庞大且运行较慢。尽管如此,FreeBSD 仍然提供了选择权。
如果您选择了 Linux 调用约定,您必须告诉系统。程序汇编并链接后,您需要为可执行文件打上品牌:
如果您专门为 FreeBSD 编程,您应该始终使用 UNIX® 约定:它更快,您可以将全局变量存储在寄存器中,不需要对可执行文件进行品牌化,也不需要在目标系统上安装 Linux 模拟包。
如果您希望创建可以在 Linux 上运行的可移植代码,您可能仍然希望为 FreeBSD 用户提供尽可能高效的代码。我将在解释基本内容之后,向您展示如何实现这一点。
要告诉内核您正在调用哪个系统服务,请将其编号放入 EAX
中。当然,您需要知道这个编号是什么。
这些编号列在 syscalls 文件中。使用 locate syscalls
可以找到这个文件的多个不同格式,所有格式都从 syscalls.master 自动生成。
您可以在 /usr/src/sys/kern/syscalls.master 中找到默认 UNIX® 调用约定的主文件。如果您需要使用 Linux 模拟模式中实现的另一种约定,请阅读 /usr/src/sys/i386/linux/syscalls.master。
注意
不仅 FreeBSD 和 Linux 使用不同的调用约定,它们有时对相同的功能使用不同的编号。
syscalls.master 描述了如何进行调用:
最左边的列告诉我们将哪个数字放入 EAX
。
最右边的列告诉我们需要 push
什么参数。它们是从右到左依次 push
的。
例如,要 open
一个文件,我们需要首先 push
mode
,然后是 flags
,最后是存储 path
地址的变量。
如果系统调用没有返回某种类型的值,大多数情况下是没有用的:例如打开文件的文件描述符、读取到缓冲区的字节数、系统时间等。
此外,系统还需要告知我们是否发生了错误:例如文件不存在、系统资源耗尽、传递了无效参数等。
在 UNIX® 系统下,传统的查看各种系统调用信息的地方是手册页。FreeBSD 在第 2 节中描述其系统调用,有时在第 3 节中。
如果成功,open()
返回一个非负整数,称为文件描述符。如果失败,返回 -1
,并设置 errno
来指示错误。
对于刚接触 UNIX® 和 FreeBSD 的汇编语言程序员来说,立刻会产生一个令人困惑的问题:errno
到底在哪里,如何访问它?
注意
手册页中提供的信息适用于 C 程序。汇编语言程序员需要额外的信息。
不幸的是,这取决于……对于大多数系统调用,返回值在 EAX
中,但并非所有系统调用都如此。一个好的经验法则是,当首次处理一个系统调用时,先检查返回值是否在 EAX
中。如果不在那儿,您需要进一步的研究。
注意
我知道有一个系统调用将值返回在
EDX
中:SYS_fork
。其他我处理过的系统调用都使用EAX
。但我还没有处理所有系统调用。
技巧
如果您在这里找不到答案或其他地方没有答案,可以研究 libc 源代码,看看它是如何与内核交互的。
errno
在哪里?实际上,errno
根本不存在……
errno
是 C 语言的一部分,而不是 UNIX® 内核的一部分。当直接访问内核服务时,错误代码会返回到 EAX
中,这与通常存放返回值的寄存器相同。
这完全合理。如果没有错误,就没有错误代码。如果发生了错误,就没有返回值。一个寄存器可以同时存放这两者。
在使用标准 FreeBSD 调用约定时,成功时 carry flag
会被清除,失败时会被设置。
在使用 Linux 模拟模式时,EAX
中的有符号值在成功时为非负值,并包含返回值。如果发生错误,值为负数,即 -errno
。
可移植性通常不是汇编语言的强项。然而,编写适用于不同平台的汇编语言程序是可能的,尤其是使用 nasm。我已经编写了可以在多个操作系统(如 Windows® 和 FreeBSD)上汇编的汇编语言库。
当您希望代码能够在两个不同平台上运行时,这尤其可行,这两个平台尽管有所不同,但基于类似的架构。
例如,FreeBSD 是 UNIX®,Linux 是 UNIX® 类似的操作系统。我只提到了它们之间的三个区别(从汇编语言程序员的角度看):调用约定、函数号和返回值的方式。
在许多情况下,函数号是相同的。然而,即使它们不相同,问题也很容易处理:不要在代码中直接使用数字,而是使用常量,根据目标架构不同进行定义:
调用约定和返回值(errno
问题)都可以通过宏来解决:
上述解决方案可以处理大部分在 FreeBSD 和 Linux 之间编写可移植代码的情况。然而,对于某些内核服务,差异更为深入。
在这种情况下,您需要为那些特定的系统调用编写两个不同的处理程序,并使用条件汇编。幸运的是,您的大部分代码做的工作与调用内核无关,因此通常您只需要在代码中添加几个这样的条件部分。
您可以通过编写一个系统调用库,完全避免主代码中的可移植性问题。为 FreeBSD 编写一个单独的库,为 Linux 编写另一个不同的库,甚至为更多操作系统编写其他库。
在您的库中,为每个系统调用编写一个单独的函数(或者,如果您喜欢传统的汇编语言术语,可以称之为过程)。使用 C 调用约定来传递参数,但仍然使用 EAX
来传递调用号。在这种情况下,您的 FreeBSD 库可以非常简单,因为许多看似不同的函数实际上只是指向相同代码的标签:
您的 Linux 库将需要更多不同的函数。但是即便如此,您也可以根据相同的参数数量来分组系统调用:
最初,使用库的方法可能看起来不方便,因为它需要您生成一个代码依赖的单独文件。但它有许多优点:首先,您只需要编写一次,并且可以将其用于所有程序。您甚至可以让其他汇编语言程序员使用它,或者使用由他人编写的库。但或许库的最大优势是,您的代码可以通过简单编写一个新的库而无需更改代码,即可移植到其他系统,甚至其他程序员也能完成此操作。
如果您不喜欢使用库的想法,您至少可以将所有系统调用放在一个单独的汇编语言文件中,并将其与主程序链接。在这种情况下,移植者只需要创建一个新的目标文件来与主程序链接。
如果您将软件作为(或与)源代码一起发布,您可以使用宏并将它们放在一个单独的文件中,然后在代码中包含这个文件。
您的软件移植者只需编写一个新的包含文件。这样就不需要库或外部目标文件,但您的代码依然可以在无需编辑代码的情况下实现可移植性。
注意
这是我们将在本章中使用的方法。我们将把包含文件命名为 system.inc,并在处理新的系统调用时不断添加内容。
我们可以通过声明标准的文件描述符来开始我们的 system.inc:
接下来,为每个系统调用创建一个符号名称:
我们添加一个短小、非全局的过程,命名为长名称,这样我们就不会在代码中不小心重复使用它:
然后,我们创建一个宏,它接收一个参数,即系统调用编号:
最后,我们为每个系统调用创建宏,这些宏不接受任何参数。
接下来,输入并保存它为 system.inc。随着我们讨论更多的系统调用,内容还将继续添加到其中。
现在,我们准备好编写第一个程序——必备的“Hello, World!”程序。
这段代码的功能如下:第一行包含了 system.inc 中的定义、宏和代码。
第3-5行是数据部分:第3行开始了数据段。第4行包含了字符串“Hello, World!”以及一个换行符 (0Ah
)。第5行创建了一个常量,表示第4行字符串的字节长度。
第7-16行是代码部分。需要注意的是,FreeBSD 使用 elf 文件格式来处理其可执行文件,这要求每个程序都从标签 _start
开始(更准确地说,链接器期望这样做)。该标签必须是全局的。
第10-13行请求系统将 hbytes
字节的 hello
字符串写入 stdout
。
第15-16行请求系统用返回值 0
结束程序。由于 SYS_exit
系统调用不会返回,因此代码在此结束。
注意
如果你是从 MS-DOS® 汇编语言背景转到 UNIX®,你可能习惯了直接写入视频硬件。在 FreeBSD 或任何其他 UNIX® 系统中,你不必担心这个问题。对你来说,你是在写入一个名为 stdout 的文件。这个文件可以是视频屏幕、telnet 终端、实际文件,甚至是另一个程序的输入。至于它是什么,交给系统来处理。
将代码输入编辑器,并将其保存为 hello.asm 文件。你需要使用 nasm 来汇编它。
如果你没有安装 nasm,可以输入:
如果你不想保留 nasm 源代码,可以输入 make install clean
,而不是单纯的 make install
。
无论哪种方式,FreeBSD 会自动从互联网上下载 nasm,进行编译,并安装到你的系统上。
注意
现在你可以汇编、链接并运行代码:
一种常见的 UNIX® 应用程序类型是过滤器——它是一个从 stdin 读取数据,进行某种处理,然后将结果写入 stdout 的程序。
在本章中,我们将开发一个简单的过滤器,学习如何从 stdin 读取数据并写入 stdout。这个过滤器将把输入的每个字节转换为一个十六进制数,并在后面加上一个空格。
在数据部分,我们创建了一个名为 hex
的数组,包含了 16 个十六进制数字,按升序排列。数组后面是一个缓冲区,我们将用它来存储输入和输出。缓冲区的前两个字节最初设置为 0
,用于存储两个十六进制数字(第一个字节同时用于读取输入)。第三个字节是一个空格。
代码部分包含了四个部分:读取字节、将其转换为十六进制、写入结果,以及最终退出程序。
为了读取字节,我们请求系统从 stdin 读取一个字节,并将其存储在 buffer
的第一个字节中。系统返回读取的字节数,存储在 EAX
中。当有数据时,它的值为 1
,而当没有更多输入数据时,它的值为 0
。因此,我们检查 EAX
的值。如果它为 0
,则跳转到 .done
,否则继续执行。
注意
为了简单起见,我们暂时忽略了错误条件。
十六进制转换部分将字节从 buffer
读入 EAX
(实际上只读 AL
),同时将 EAX
的其余位清零。我们还将字节复制到 EDX
中,因为我们需要分别处理高四位(nibble)和低四位。转换结果存储在缓冲区的前两个字节中。
接下来,我们请求系统将缓冲区的三个字节(即两个十六进制数字和空格)写入 stdout。然后,我们跳转回程序的开始,处理下一个字节。
一旦没有更多输入数据,我们请求系统退出程序,返回值为 0
,这是表示程序成功的传统值。
接下来,保存代码为 hex.asm,然后输入以下命令(^D
代表按住控制键并同时按 D
):
注意
如果你是从 MS-DOS® 迁移到 UNIX®,你可能会好奇为什么每行以
0A
结尾,而不是0D 0A
。这是因为 UNIX® 不使用 CR/LF(回车/换行)约定,而是使用“新行”约定,该新行用十六进制0A
表示。
我们能改进这个程序吗?首先,它有点混乱,因为一旦我们转换了一行文本,输入就不再从行首开始了。我们可以修改它,在每个 0A
后打印一个新行,而不是空格:
我们将空格存储在 CL
寄存器中。我们这样做是安全的,因为与 Microsoft® Windows® 不同,UNIX® 系统调用不会修改它们没有使用来返回值的寄存器。
这意味着我们只需设置一次 CL
寄存器。因此,我们添加了一个新的标签 .loop
,并跳转到它以处理下一个字节,而不是跳转到 _start
。我们还添加了 .hex
标签,这样我们就可以在 buffer
的第三个字节中放置一个空格或一个新行。
修改 hex.asm 后,再次执行:
这看起来好多了。但这个程序效率不高!我们对每个字节都进行了两次系统调用(一次读取,另一次写入输出)。
通过对输入和输出进行缓冲,我们可以提高代码的效率。我们创建一个输入缓冲区,一次读取一整段字节,然后逐个从缓冲区中获取这些字节。
我们还创建一个输出缓冲区。我们将输出存储在缓冲区中,直到它满了。这时,我们请求内核将缓冲区的内容写入 stdout。
程序在没有更多输入时结束。但我们仍然需要请求内核最后一次将输出缓冲区的内容写入 stdout,否则一些输出可能会被写入输出缓冲区,但永远不会被发送出去。不要忘记这一点,否则你会发现某些输出丢失了。
现在,我们的源代码中有了第三个部分,命名为 .bss
。该部分不包含在可执行文件中,因此不能初始化。我们使用 resb
而不是 db
,它仅为我们保留了请求的大小的未初始化内存。
我们利用了系统不会修改寄存器的特性:我们使用寄存器来存储本应作为全局变量存储在 .data
部分的值。这也是 UNIX® 在系统调用中通过栈传递参数的约定优于 Microsoft® 约定的原因:我们可以将寄存器保留给自己的使用。
我们使用 EDI
和 ESI
作为指向下一个要读取或写入字节的指针。我们使用 EBX
和 ECX
来保持两个缓冲区中字节的计数,以便知道何时将输出转储到系统中,或从系统中读取更多输入。
让我们看看现在它是如何工作的:
不是你预期的结果吗?程序直到我们按下 ^D
后才打印输出。这很容易修复,只需插入三行代码,每次我们将一行转换为 0A
时就写入输出。我已用 >
标记了三行(不要在你的 hex.asm 中复制 >
)。
现在,让我们看看它是如何工作的:
对于一个 644 字节的可执行文件,这还不错,对吧!
注意
警告
虽然我们的示例程序并不需要这个功能,但更复杂的过滤器通常需要进行前瞻处理。换句话说,它们可能需要查看下一个字符是什么(甚至是几个字符)。如果下一个字符是某个特定值,它就是当前正在处理的标记的一部分,否则就不是。
例如,在解析输入流中的文本字符串时(例如,编写语言编译器时):如果一个字符后面跟着另一个字符,或者是一个数字,它就是正在处理的标记的一部分。如果后面跟着空白字符或其他值,那么它就不属于当前的标记。
这就引出了一个有趣的问题:如何将下一个字符放回输入流中,以便稍后可以再次读取?
一种可能的解决方案是将其存储在一个字符变量中,然后设置一个标志。我们可以修改 getchar
,使其检查标志,如果标志被设置,就从该变量中获取字节,而不是从输入缓冲区读取,并重置标志。但是,当然,这会导致程序变慢。
C 语言有一个 ungetc()
函数,正是为此目的设计的。那么,我们在代码中有没有什么快速实现的方法呢?我建议你先回头看看 getchar
程序,看看你能否在读下一段之前找到一个简洁快速的解决方案。然后再回来看看我自己的解决方案。
将字符重新放回输入流的关键在于我们最初是如何获取字符的:
首先,我们通过检查 EBX
的值来确认缓冲区是否为空。如果为零,我们就调用 read
程序。
如果确实有字符可用,我们使用 lodsb
,然后减少 EBX
的值。lodsb
指令实际上等同于:
我们提取的字节将保留在缓冲区中,直到下一次调用 read
。我们不知道何时会发生,但我们知道直到下一次调用 getchar
时,它才会发生。因此,要“返回”上次读取的字节,我们只需减少 ESI
的值并增加 EBX
的值:
但是,注意!如果我们的前瞻只检查一个字符,这样做是完全安全的。如果我们检查多个即将到来的字符并连续多次调用 ungetc
,它通常能正常工作,但并非每次都能成功(而且很难调试)。为什么呢?
因为只要 getchar
不需要调用 read
,所有提前读取的字节仍然保存在缓冲区中,我们的 ungetc
就可以顺利工作。但是一旦 getchar
调用了 read
,缓冲区的内容就会发生变化。
我们可以始终依赖 ungetc
正确工作在最后一个通过 getchar
读取的字符上,但不能依赖它处理之前读取的字符。
如果你的程序需要读取多个字节,你至少有两种选择:
如果可能,修改程序,使其只读取一个字节。这是最简单的解决方案。
如果无法选择此方案,首先确定程序一次需要返回输入流的最大字符数。稍微增加这个值,确保它足够大,最好是 16 的倍数——这样它就可以很好地对齐。然后修改代码的 .bss
部分,在输入缓冲区之前创建一个小的“备用”缓冲区,例如:
你还需要修改 ungetc
,将要重新放回的字节值传递给 AL
:
通过这种修改,你可以安全地调用 ungetc
多达 17 次(第一次调用仍然在缓冲区内,剩余的 16 次可以在缓冲区内或在“备用”缓冲区内)。
如果我们的 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 文件中添加几行内容:
首先,我们需要向系统调用号列表中添加两个新的条目:
接着,在文件末尾添加两个新的宏:
以下是我们修改后的源代码:
在我们的 .data
部分,现在有了两个新变量,fd.in
和 fd.out
。我们在这里存储输入和输出的文件描述符。
在 .text
部分,我们将对 stdin
和 stdout
的引用替换为 [fd.in]
和 [fd.out]
。
.text
部分现在以一个简单的错误处理程序开始,它仅仅是退出程序并返回值 1
。这个错误处理程序位于 _start
之前,因此我们可以很接近错误发生的地方。
自然,程序的执行仍然从 _start
开始。首先,我们从栈中移除 argc
和 argv[0]
:它们对我们来说不重要(在这个程序中是这样)。
我们将 argv[1]
弹出到 ECX
寄存器。这个寄存器特别适合存储指针,因为我们可以通过 jecxz
来处理 NULL 指针。如果 argv[1]
不是 NULL,我们尝试打开第一个参数指定的文件。否则,我们继续像之前一样操作:从 stdin
读取,写入 stdout
。如果我们无法打开输入文件(例如它不存在),我们跳转到错误处理程序并退出。
如果一切顺利,我们接着检查第二个参数。如果它存在,我们打开输出文件。否则,我们将输出发送到 stdout
。如果我们无法打开输出文件(例如它存在但我们没有写入权限),我们再次跳转到错误处理程序。
其余的代码与之前相同,唯一不同的是我们在退出之前关闭输入和输出文件,并且,如前所述,我们使用 [fd.in]
和 [fd.out]
。
我们的可执行文件现在已经达到 768 字节。
我们还能改进它吗?当然!每个程序都可以改进。以下是一些可以做的改进:
让我们的错误处理程序打印一条消息到 stderr
。
为 read
和 write
函数添加错误处理程序。
当我们打开输入文件时关闭 stdin
,当我们打开输出文件时关闭 stdout
。
添加命令行开关,比如 -i
和 -o
,这样我们就可以任意顺序列出输入和输出文件,或者从 stdin
读取并写入到文件。
如果命令行参数不正确,打印使用帮助信息。
我将把这些改进留给读者:你已经知道实现它们所需要了解的一切。
UNIX® 中一个重要的概念是环境,它由 环境变量 定义。有些环境变量由系统设置,有些由你设置,还有一些由 shell 或任何加载其他程序的程序设置。
我之前提到,当程序开始执行时,栈中包含 argc
后跟一个以 NULL 结束的 argv
数组,接着还有其他内容。这个“其他内容”就是 环境,更确切地说,是一个以 NULL 结束的指针数组,指向 环境变量。这通常被称为 env
。
env
的结构与 argv
相同,是一系列内存地址后跟一个 NULL(0
)。在这种情况下,没有 "envc"
—— 我们通过查找最终的 NULL 来确定数组的结束。
这些变量通常以 name=value
格式出现,但有时 =value
部分可能缺失。我们需要考虑到这种可能性。
我本可以直接展示一些代码,像 UNIX® 的 env
命令那样打印环境变量。但我认为编写一个简单的汇编语言 CGI 工具会更有趣。
Web 服务器通过设置 环境变量 与 CGI 程序通信。
CGI 程序将输出发送到 stdout。Web 服务器从那里读取输出。
它必须以 HTTP 头开始,后面跟着两个空行。
然后,它打印 HTML 代码或它正在生成的其他类型的数据。
注意
虽然某些 环境变量 使用标准名称,但其他变量会有所不同,具体取决于 Web 服务器。这使得 webvars 成为一个非常有用的诊断工具。
我们的 webvars 程序必须先发送 HTTP 头,接着是一些 HTML 标记。然后它必须逐个读取 环境变量 并将其作为 HTML 页面的一部分输出。
以下是代码。我在代码中直接插入了注释和解释:
这段代码生成了一个 1,396 字节的可执行文件。大部分内容是数据,即我们需要发送的 HTML 标记。
按常规方法进行汇编和链接:
要使用它,你需要将 webvars 上传到你的 Web 服务器。根据你的 Web 服务器配置,可能需要将它存储在一个特殊的 cgi-bin 目录中,或者可能需要将其重命名为 .cgi 扩展名。
我们已经做了一些基本的文件操作:我们知道如何打开和关闭文件,如何使用缓冲区读取和写入文件。但 UNIX® 在处理文件时提供了更多的功能。在本节中,我们将研究其中的一些,并最终编写一个很好的文件转换工具。
事实上,让我们从结果开始,也就是文件转换工具。在开始编程时,知道最终产品应该做什么总是能让编程变得更容易。
我广泛使用 tuc,但始终只是从其他操作系统转换为 UNIX®,从未反过来。我一直希望它能直接覆盖文件,而不是我必须将输出发送到另一个文件。大多数时候,我最终这样使用它:
有了一个名为 ftuc
的工具,即 快速 tuc,就好了,我可以这样使用:
因此,在这一章中,我们将用汇编语言编写 ftuc
(原始的 tuc 是用 C 编写的),并在此过程中研究各种与文件相关的内核服务。
乍一看,文件转换似乎非常简单:你只需要去除回车符,对吗?
如果你回答是的,那就再想一想:这种方法大部分时间有效(至少对于 MS DOS 文本文件),但偶尔会失败。
问题在于,并不是所有非 UNIX® 文本文件的行都以回车符/换行符序列结束。有些文件使用仅回车符而没有换行符。其他文件将几个空行合并为一个回车符后接几个换行符。等等。
因此,文本文件转换器必须能够处理所有可能的行结束符:
回车符 / 换行符
回车符
换行符 / 回车符
换行符
它还应该处理使用上述某种组合的文件(例如,回车符后跟几个换行符)。
这个问题可以通过一种叫做 有限状态机(finite state machine)的技术轻松解决,这种技术最初由数字电子电路的设计师们开发。有限状态机 是一种数字电路,其输出不仅依赖于输入,还依赖于其先前的输入,即它的状态。微处理器就是一个 有限状态机 的例子:我们的汇编语言代码被组装成机器语言,其中一些汇编语言代码产生一个字节的机器语言,而其他一些则产生多个字节。当微处理器一个一个地从内存中获取字节时,有些字节仅仅改变其状态,而不是产生任何输出。当所有的操作码字节被获取后,微处理器才会产生输出,或者改变寄存器的值,等等。
因此,所有软件本质上都是一系列为微处理器编写的状态指令。尽管如此,有限状态机 的概念在软件设计中也非常有用。
我们的文本文件转换器可以被设计成一个 有限状态机,有三种可能的状态。我们可以将它们称为状态 0 到 2,但如果我们为它们起个符号名字会更容易:
ordinary(普通)
cr(回车)
lf(换行)
我们的程序将从普通状态开始。在这个状态下,程序的动作取决于输入,具体如下:
如果输入是回车符或换行符以外的字符,输入会被直接传递到输出,状态保持不变。
如果输入是回车符,状态将切换到 cr,输入将被丢弃,即不输出任何内容。
如果输入是换行符,状态将切换到 lf,输入将被丢弃。
当我们处于 cr 状态时,意味着最后的输入是一个未处理的回车符。此时软件的行为依赖于当前输入:
如果输入是回车符或换行符以外的字符,则输出一个换行符,然后输出该输入,再将状态切换为普通状态。
如果输入是回车符,表示我们接收到了两个(或更多)连续的回车符。我们丢弃输入,输出一个换行符,并保持状态不变。
如果输入是换行符,我们输出换行符,并将状态切换为普通状态。注意,这与上面的第一种情况不同——如果我们尝试将它们合并,会导致输出两个换行符而不是一个。
最后,当我们接收到一个没有前置回车符的换行符时,我们会进入 lf 状态。这种情况发生在我们的文件已经是 UNIX® 格式,或者连续几行用一个回车符后跟多个换行符表示,或者行结束符是换行符/回车符序列时。在这个状态下,我们需要按照以下方式处理输入:
如果输入是回车符或换行符以外的字符,则输出一个换行符,然后输出该输入,再将状态切换为普通状态。这个操作与我们在 cr 状态下收到相同输入时的行为完全相同。
如果输入是回车符,我们丢弃输入,输出一个换行符,然后将状态切换为普通状态。
如果输入是换行符,我们输出换行符,并保持状态不变。
上述 有限状态机 适用于整个文件,但存在一个问题,即可能忽略最后一行的结束符。每当文件以单一的回车符或换行符结尾时,就会发生这种情况。我在编写 tuc 时没有考虑到这一点,后来才发现偶尔会剥离掉最后的行结束符。
这个问题可以通过在整个文件处理完毕后检查状态来轻松修复。如果状态不是普通状态,我们只需要输出一个最后的换行符。
注意
现在我们已经将算法表示为 有限状态机,我们完全可以设计一个专用的数字电子电路("芯片")来为我们执行转换。当然,这样做的成本要比编写汇编语言程序高得多。
由于我们的文件转换程序可能会将两个字符合并为一个字符,因此我们需要使用一个输出计数器。我们将其初始化为 0
,并在每次将字符发送到输出时增加计数。程序结束时,计数器将告诉我们需要设置文件的大小。
使用 有限状态机 的最大难点在于分析问题并将其表示为 有限状态机。一旦完成,软件几乎是自动生成的。
在高级语言中(如 C),有几种主要的方法。一个方法是使用 switch
语句来选择应该运行哪个函数。例如:
另一种方法是使用一个函数指针数组,类似于这样:
还有一种方法是让 state
成为一个函数指针,指向适当的函数:
我们在程序中将使用这种方法,因为它在汇编语言中非常容易实现,并且非常快速。我们将简单地将正确的程序地址存储在 EBX
中,然后执行:
这可能比硬编码地址更快,因为微处理器不需要从内存中获取地址——它已经存储在其寄存器之一中。我说 可能 更快,因为现代微处理器的缓存技术,任意一种方式可能同样快速。
由于我们的程序操作的是单个文件,因此不能使用之前的方式,即从输入文件读取并将其写入输出文件。
UNIX® 允许我们将一个文件,或文件的某个部分,映射到内存中。为此,我们首先需要使用适当的读写标志打开文件。然后我们使用 mmap
系统调用将其映射到内存中。mmap
的一个优点是它自动与虚拟内存协作:我们可以将文件的更多部分映射到内存中,即使我们没有足够的物理内存,但仍然可以通过常规的内存操作码,如 mov
、lods
和 stos
,访问它。我们对文件内存映像所做的任何更改将由系统写入文件中。我们甚至不需要保持文件打开:只要它保持映射,我们就可以读取和写入它。
32 位的英特尔微处理器可以访问最多四千兆字节的内存——无论是物理内存还是虚拟内存。FreeBSD 系统允许我们使用最多一半的内存进行文件映射。
为了简化起见,在本教程中,我们将仅转换那些能够完全映射到内存中的文件。很少有文本文件的大小超过两千兆字节。如果我们的程序遇到这样的文件,它将简单地显示一条消息,建议我们使用原始的 tuc。
如果您检查 syscalls.master,您将找到两个名为 mmap
的独立系统调用。这是因为 UNIX® 的发展:有传统的 BSD mmap
,即系统调用 71。这个版本被 POSIX® 的 mmap
(系统调用 197)所取代。FreeBSD 系统同时支持两者,因为较旧的程序是使用原始的 BSD 版本编写的。但新软件使用的是 POSIX® 版本,这也是我们将使用的版本。
syscalls.master 列出了 POSIX® 版本,如下所示:
区别在于 long pad
参数,这在 C 版本中没有出现。然而,FreeBSD 系统调用会在 push
一个 64 位参数后,添加一个 32 位的填充。此时,off_t
是一个 64 位值。
当我们完成对内存映射文件的操作时,我们使用 munmap
系统调用来取消映射:
技巧
因为我们需要告诉 mmap
要将多少字节的文件映射到内存中,并且我们希望将整个文件映射到内存中,所以我们需要确定文件的大小。
我们可以使用 fstat
系统调用获取有关打开文件的所有信息,其中就包括文件大小。
同样,syscalls.master 列出了两个版本的 fstat
,一个是传统的版本(系统调用 62),另一个是 POSIX® 版本(系统调用 189)。显然,我们将使用 POSIX® 版本:
这是一个非常简单的调用:我们传入一个 stat
结构体的地址和一个打开文件的文件描述符。它会填充 stat
结构体的内容。
然而,我必须说我曾尝试将 stat
结构体声明在 .bss
区域,但 fstat
并不喜欢这种做法:它设置了进位标志,表示发生了错误。当我将代码更改为在堆栈上分配该结构体时,一切工作正常。
由于我们的程序可能会将回车/换行序列合并为单一的换行符,因此我们的输出可能会比输入小。然而,由于我们将输出放入与输入文件相同的文件中,我们可能需要更改文件的大小。
ftruncate
系统调用允许我们做到这一点。尽管其名称可能会让人误解,但 ftruncate
系统调用可以用来既截断文件(使其变小),也可以将文件扩展。
是的,我们会在 syscalls.master 中找到两个版本的 ftruncate
,一个较旧的版本(130),一个较新的版本(201)。我们将使用较新的版本:
请注意,这里再次包含了 int pad
。
现在我们已经知道了编写 ftuc 所需的一切。我们首先在 system.inc 中添加一些新行。首先,我们在文件的开始部分或接近开始的位置定义一些常量和结构体:
我们定义新的系统调用:
我们为它们的使用添加宏:
以下是我们的代码:
警告
请勿在由 MS-DOS® 或 Windows® 格式化的磁盘上的文件上使用此程序。当在 FreeBSD 下使用
mmap
挂载这些磁盘时,FreeBSD 代码似乎存在一个微妙的 bug:如果文件超过某个大小,mmap
会将内存填充为零,然后将这些零复制到文件中,覆盖其内容。
作为禅宗的学生,我喜欢“一心一意”的想法:一次做一件事,并且做到最好。
实际上,这正是 UNIX® 的工作方式。典型的 Windows® 应用程序尝试做所有可以想象的事情(因此,充满了 bug),而典型的 UNIX® 程序只做一件事,而且做得很好。
典型的 UNIX® 用户基本上是通过编写一个 shell 脚本,将不同程序的输出通过管道连接起来,从而组装自己的应用程序。
在编写自己的 UNIX® 软件时,通常的好方法是,先看看现有的程序中有哪些部分可以帮助解决问题,然后只为那些没有现成解决方案的部分编写自己的程序。
我将通过一个具体的实际例子来说明这个原则:
我需要提取从网站下载的数据库中每条记录的第 11 个字段。这个数据库是一个 CSV 文件,即一个逗号分隔值的列表。这是一种常见的数据共享格式,用于不同数据库软件之间的数据交换。
文件的第一行包含以逗号分隔的各种字段列表。文件的其余部分包含逐行列出的数据,每行的值通过逗号分隔。
我尝试使用 awk,将逗号作为分隔符。但因为某些行中包含了带引号的逗号,awk 从这些行中提取到了错误的字段。
因此,我需要编写自己的软件来提取 CSV 文件中的第 11 个字段。然而,遵循 UNIX® 精神,我只需要编写一个简单的过滤程序,完成以下操作:
删除文件的第一行;
将所有未加引号的逗号替换为其他字符;
删除所有引号。
严格来说,我可以使用 sed 删除文件的第一行,但自己编写这个程序非常简单,因此我决定这么做,并减少管道的复杂性。
无论如何,编写这样的程序大约花了我 20 分钟。编写一个提取 CSV 文件第 11 个字段的程序会花费更长时间,而且我无法重用它来提取其他数据库中的其他字段。
这一次,我决定让程序做得比典型的教程程序多一些工作:
它解析命令行参数;
如果发现错误参数,它会显示正确的用法;
它会产生有意义的错误信息。
以下是它的用法信息:
所有参数都是可选的,可以按任何顺序出现。
-t
参数声明用来替换逗号的字符。默认情况下是使用 tab
。例如,-t;
会将所有未加引号的逗号替换为分号。
我并不需要 -c
选项,但将来可能会用到。它允许我声明要用其他字符替换逗号。比如,-c@
会将所有的 @ 符号替换为其他字符(如果你想将一组电子邮件地址分割成用户名和域名,这非常有用)。
-p
选项保留第一行,即不删除它。默认情况下,我们会删除第一行,因为在 CSV 文件中,它包含的是字段名而不是数据。
-i
和 -o
选项让我指定输入文件和输出文件。默认值是 stdin 和 stdout,因此它是一个常规的 UNIX® 过滤器。
我确保 -i filename
和 -ifilename
都可以接受。我还确保只能指定一个输入文件和一个输出文件。
要获取每条记录的第 11 个字段,现在可以这样做:
该代码将选项(文件描述符除外)存储在 EDX
寄存器中:逗号存储在 DH
中,新的分隔符存储在 DL
中,-p
选项的标志存储在 EDX
的最高位中,因此检查其符号将快速决定我们应该执行什么操作。
下面是代码:
其中许多内容取自上文的 hex.asm。但有一个重要的不同之处:我不再在输出换行符时每次调用 write
。然而,这段代码仍然可以交互式使用。
自从我开始写这一章以来,我找到了解决交互式问题的更好方法。我希望确保每一行只在需要时才单独打印出来。毕竟,在非交互式使用时,没有必要每次都刷新每一行。
我现在使用的新解决方案是在发现输入缓冲区为空时每次调用 write
。这样,当程序在交互模式下运行时,它会从用户的键盘读取一行,处理它,然后发现输入缓冲区为空。接着,它刷新输出并读取下一行。
这个改变避免了在某些特定情况下出现的神秘锁死问题。我将其称为 缓冲的黑暗面,主要是因为它存在一个并不明显的危险。
这种情况在像上面提到的 CSV 程序中不太可能发生,所以我们来考虑另一个过滤器:在这种情况下,我们预期输入是表示颜色值的原始数据,如像素的 红色、绿色 和 蓝色 强度。我们的输出将是输入的负值。
这样的过滤器非常简单。它的大部分代码将与我们之前编写的其他过滤器非常相似,所以我只会展示其内部循环部分:
因为这个过滤器处理的是原始数据,它不太可能在交互模式下使用。
但它可能会被图像处理软件调用。如果没有在每次调用 read
之前调用 write
,它很可能会锁死。
以下是可能发生的情况:
图像编辑器使用 C 函数 popen()
加载我们的过滤器。
它从位图或像素图中读取第一行像素。
它将第一行像素写入到连接到我们过滤器的 fd.in
的 管道 中。
我们的过滤器从输入中读取每个像素,将其转为负值,并写入输出缓冲区。
我们的过滤器调用 getchar
来获取下一个像素。
getchar
发现输入缓冲区为空,于是它调用 read
。
read
调用 SYS_read
系统调用。
内核 将暂停我们的过滤器,直到图像编辑器将更多数据发送到管道。
图像编辑器从连接到我们过滤器的 fd.out
的另一个管道中读取,以便在发送第二行输入之前先设置第一行输出图像。
内核 暂停图像编辑器,直到它收到来自我们过滤器的某些输出,以便可以将其传递给图像编辑器。
此时,我们的过滤器等待图像编辑器发送更多数据供其处理,而图像编辑器在等待我们过滤器发送处理后的第一行结果。可是,结果仍然停留在输出缓冲区中。
如果我们的过滤器在请求 内核 获取更多输入数据之前刷新其输出缓冲区,这个问题就不会发生。
奇怪的是,大多数汇编语言文献甚至没有提到 FPU(浮点单元)的存在,更不用说讨论如何编程它了。
然而,汇编语言的光辉从未如此闪耀,尤其是在我们通过做一些只有 汇编语言 才能完成的事情来创建高度优化的 FPU 代码时。
FPU 包含 8 个 80 位的浮点寄存器。这些寄存器以栈的方式组织——你可以将一个值 push
到栈顶(TOS,top of stack),也可以将其 pop
出来。
不过,汇编语言中的操作码不是 push
和 pop
,因为这些操作码已经被占用了。
你可以通过使用 fld
、fild
和 fbld
将值 push
到 TOS。还有一些其他的操作码允许你将一些常见的 常量(例如 π)推送到 TOS。
类似地,你可以使用 fst
、fstp
、fist
、fistp
和 fbstp
来将值 pop
出来。实际上,只有那些以 p 结尾的操作码才会真正“弹出”该值,其余的则会将值存储到其他地方,而不会将其从 TOS 中移除。
我们可以将数据在 TOS 和计算机内存之间传输,无论是作为 32 位、64 位或 80 位的 实数,16 位、32 位或 64 位的 整数,还是 80 位的 打包十进制。
80 位 打包十进制 是一种特殊的 二进制编码十进制,在将数据的 ASCII 表示与 FPU 内部数据进行转换时非常方便。它允许我们使用 18 位有效数字。
无论我们如何在内存中表示数据,FPU 始终将其以 80 位的 实数 格式存储在寄存器中。
它的内部精度至少为 19 位十进制数字,因此即使我们选择以完整的 18 位精度显示结果,我们仍然能够显示正确的结果。
我们可以在 TOS 上执行数学运算:我们可以计算其 正弦,可以对其进行 缩放(即可以将其乘以或除以 2 的幂),我们可以计算其以 2 为底的 对数,以及许多其他操作。
我们还可以将其 乘以 或 除以,加 或 减,任何 FPU 寄存器中的值(包括它自身)。
官方的 Intel 操作码为 TOS 是 st
,而寄存器为 st(0)
至 st(7)
。因此,st
和 st(0)
指代的是同一个寄存器。
出于某些原因,nasm 的原作者决定使用不同的操作码,即 st0
至 st7
。换句话说,没有圆括号,TOS 始终是 st0
,从不单独使用 st
。
打包十进制 格式使用 10 字节(80 位)内存来表示 18 位数字。所表示的数字始终是 整数。
技巧
你可以通过先将 TOS 乘以 10 的幂来获得小数位。
最高字节(字节 9)的最高位是 符号位:如果设置为 1,表示数字为 负数;否则为 正数。该字节的其余位未使用/忽略。
剩余的 9 个字节存储数字的 18 位:每个字节存储 2 位数字。
更高位的数字 存储在高 半字节(4 位),较低位的数字 存储在低 半字节 中。
话虽如此,你可能会认为 -1234567
会以如下方式存储在内存中(使用十六进制表示):
可惜并不是!像所有其他 Intel 的东西一样,即使是 打包十进制 也是 小端 存储的。
这意味着我们的 -1234567
是这样存储的:
记住这一点,否则你会因绝望而拔掉头发!
注意
为了编写有意义的软件,我们不仅需要理解我们的编程工具,还需要理解我们为其开发软件的领域。
我们的下一个过滤器将帮助我们在构建 针孔相机 时,所以在继续之前,我们需要了解一些 针孔摄影 的背景知识。
描述任何相机最简单的方法就是将其视为一个被某种防光材料包围的空腔,腔体上有一个小孔。
这个外壳通常是坚固的(例如一个盒子),有时也可能是柔性的(如伸缩筒)。相机内部相当黑暗。然而,小孔允许光线通过一个点进入(尽管在某些情况下可能有多个点)。这些光线形成了一个图像,表示相机外部的景物,位于小孔前面。
如果相机内部放置一些感光材料(例如胶片),它就能捕捉到图像。
小孔常常包含一个 镜头,或镜头组件,通常称为 物镜。
但严格来说,镜头并不是必须的:最初的相机并没有使用镜头,而是使用了 针孔。即便今天,针孔 仍然被用作研究相机工作原理的工具,并用来实现一种特殊的图像效果。
针孔产生的图像是均匀清晰的,或者是 模糊的。针孔有一个理想的大小:如果它过大或过小,图像会失去锐度。
这个理想的针孔直径是 焦距 的平方根的函数,焦距是针孔到胶片的距离。
其中,D
是理想的针孔直径,FL
是焦距,PC
是针孔常数。根据 Jay Bender 的说法,常数的值为 0.04
,而 Kenneth Connors 确定其值为 0.037
。其他人也提出了不同的值。而且,这个常数仅适用于日光:其他类型的光线将需要不同的常数,其值只能通过实验确定。
光圈数是衡量光线达到胶片的多少的一个非常有用的指标。一个光度计可以确定,例如,为了曝光某种特定灵敏度的胶片,f5.6 光圈可能需要曝光 1/1000 秒。
无论是 35 毫米相机,还是 6x9cm 相机等等,只要知道光圈数,我们就能确定适当的曝光时间。
光圈数的计算很简单:
换句话说,光圈数等于焦距除以针孔直径。这也意味着较高的光圈数要么意味着较小的针孔,要么意味着较大的焦距,或者两者兼有。反过来,这意味着光圈数越高,曝光时间需要越长。
此外,虽然针孔直径和焦距是单维度的度量,但胶片和针孔都是二维的。这意味着,如果你在光圈数 A
下测量的曝光时间是 t
,那么在光圈数 B
下的曝光时间就是:
虽然许多现代相机可以平滑而逐渐地改变针孔的直径,从而改变其光圈数,但并非总是如此。
为了适应不同的光圈数,相机通常包含一块金属板,上面钻有几个不同大小的孔。
这些孔的大小是根据上述公式选择的,以使得最终的光圈数是所有相机上使用的标准光圈数之一。例如,我拥有的一台非常旧的 Kodak Duaflex IV 相机就有三个这样的孔,光圈数分别为 8、11 和 16。
一台较新的相机可能提供的光圈数包括 2.8、4、5.6、8、11、16、22 和 32(以及其他值)。这些数字并不是随意选择的:它们都是 2 的平方根的幂,尽管它们可能被四舍五入了一些。
典型的相机设计方式是,设置任何标准化的光圈数都会改变转盘的感觉。它会自然地在那个位置 停止。因此,这些转盘的位置被称为光圈档。
由于每个档的光圈数都是 2 的平方根的幂,因此将转盘移动 1 个档位将使所需的光线量翻倍。移动 2 个档位将使所需的曝光量增加 4 倍。移动 3 个档位则会使曝光量增加 8 倍,依此类推。
现在,我们可以决定我们的针孔软件到底要做什么。
由于其主要目的是帮助我们设计一个工作的针孔相机,我们将使用 焦距 作为程序的输入。这是我们可以在没有软件的情况下确定的:合适的焦距由胶片的大小以及拍摄“常规”照片、广角照片或远摄照片的需求决定。
到目前为止,我们编写的大多数程序都处理单个字符或字节作为输入:hex 程序将单个字节转换为十六进制数字,csv 程序则让一个字符通过,或删除它,或将其转换为另一个字符,等等。
有一个程序,ftuc 使用状态机最多处理两个输入字节。
但是,我们的针孔程序不能仅仅处理单个字符,它必须处理更大的语法单元。
例如,如果我们希望程序在焦距为 100 mm
、150 mm
和 210 mm
时计算针孔直径(以及后续讨论的其他值),我们可能希望输入如下内容:
我们的程序需要同时处理多个字节的输入。当它看到第一个 1
时,它必须理解这是看到一个十进制数字的第一个数字。当它看到 0
和另一个 0
时,它必须知道这些是同一数字的后续数字。
当它遇到第一个逗号时,它必须知道不再接收第一个数字的数字。它必须能够将第一个数字的数字转换为值 100
,第二个数字转换为值 150
,当然,第三个数字转换为数值 210
。
我们需要决定接受哪些分隔符:输入的数字必须用逗号分隔吗?如果是这样,如何处理由其他字符分隔的两个数字?
就我个人而言,我喜欢保持简单。要么是数字,我就处理它;要么不是数字,我就丢弃它。我不喜欢计算机抱怨我输入了一个额外的字符,特别是当那个字符 显然 是多余的时。天呐!
此外,这样做还可以打破计算的单调性,让我输入一个查询,而不仅仅是一个数字:
没有理由让计算机输出一堆抱怨:
等等,等等,等等。
其次,我喜欢使用 #
字符来表示从此开始到行尾的注释。这不需要太多编码工作,并且允许我将输入文件当作可执行脚本来处理。
在我们的案例中,我们还需要决定输入应使用什么单位:我们选择 毫米,因为大多数摄影师都使用这个单位来测量焦距。
最后,我们需要决定是否允许使用小数点(在这种情况下,我们还必须考虑到许多国家使用小数 逗号)。
在我们这个情况下,允许使用小数点或逗号会带来一种虚假的精度感:焦距 50
和 51
之间几乎没有明显的差别,所以允许用户输入像 50.5
这样的数值并不是一个好主意。这是我的个人观点,毕竟我是写这个程序的人。当然,你可以在自己的程序中做出不同的选择。
在构建针孔相机时,我们最需要知道的是针孔的直径。由于我们希望拍摄清晰的图像,我们将使用上述公式根据焦距来计算针孔的直径。由于专家们提供了不同的 PC
常数值,我们需要能够选择。
在 UNIX® 编程中,传统上有两种主要的选择程序参数的方法,并且在用户未做选择时,会有一个默认值。
为什么要有两种选择方式?
其中一种是允许(相对)永久的选择,这种选择每次运行软件时都会自动应用,而无需我们一遍又一遍地告诉它我们希望它做什么。
永久选择通常保存在配置文件中,通常位于用户的主目录中。该文件通常与应用程序同名,但前面加上一个点。通常会在文件名后加上 "rc"。因此,我们的文件可以是 ~/.pinhole 或 ~/.pinholerc。(~/ 表示当前用户的主目录。)
配置文件主要由具有多个可配置参数的程序使用。那些只有一个(或少量)参数的程序则常常使用另一种方法:它们期望在 环境变量 中找到该参数。在我们的情况下,我们可以查看名为 PINHOLE
的环境变量。
通常,一个程序只会使用上述两种方法之一。如果同时配置文件和环境变量中指定了不同的内容,程序可能会感到困惑(或者变得过于复杂)。
由于我们只需要选择 一个 这样的参数,我们将采用第二种方法,查找名为 PINHOLE
的环境变量。
另一种方式允许我们做 临时 的决策:“虽然我通常希望你使用 0.039,但这次我想要 0.03872。” 换句话说,它允许我们 覆盖 永久选择。
这种类型的选择通常通过命令行参数来完成。
最后,程序 总是 需要一个 默认值。用户可能不会做出任何选择。也许他不知道该选择什么,也许他只是“随便看看”。理想情况下,默认值将是大多数用户会选择的值。这样,他们就不需要选择。或者,准确地说,他们可以不做额外的努力而选择默认值。
在这种系统下,程序可能会找到相互冲突的选项,并以以下方式处理它们:
如果它找到一个 临时 选择(例如,命令行参数),它应该接受该选择。它必须忽略任何永久选择和默认值。
否则,如果它找到一个永久选项(例如,环境变量),它应该接受该选项,并忽略默认值。
否则,它应该使用默认值。
我们还需要决定 PC
选项的 格式 应该是什么。
乍一看,使用 PINHOLE=0.04
格式的环境变量和 -p0.04
格式的命令行似乎是显而易见的。
然而,允许这样做实际上是一个安全隐患。PC
常数是一个非常小的数字。我们自然会使用不同的小值来测试我们的软件。但是,如果有人运行程序时选择了一个非常大的值,会发生什么呢?
程序可能会崩溃,因为我们没有设计它来处理巨大的数字。
或者,我们可能会花更多时间在程序上,使其能够处理巨大的数字。如果我们是在为计算机文盲的用户编写商业软件,可能会这样做。
或者,我们可能会说:“真倒霉!用户应该更懂得分寸。”
或者,我们可能干脆让用户无法输入巨大的数字。这就是我们要采取的做法:我们将使用一个 隐式 0. 前缀。
换句话说,如果用户希望输入 0.04
,我们将期望他输入 -p04
,或者在他的环境变量中设置 PINHOLE=04
。因此,如果他说 -p9999999
,我们将把它解释为 0.9999999
——尽管仍然荒谬,但至少更加安全。
其次,许多用户可能只是想使用 Bender 的常数或 Connors 的常数。为了让他们更方便,我们将解释 -b
为与 -p04
相同,-c
为与 -p037
相同。
我们需要决定我们的软件要发送什么内容到输出,以及使用何种格式。
由于我们的输入允许不指定焦距条目的数量,因此使用传统的数据库样式输出每个焦距计算结果在一行中显示,且将每行中的所有值通过 tab
字符分隔是合乎逻辑的。
可选地,我们还应该允许用户指定使用我们之前学习过的 CSV 格式。在这种情况下,我们将首先输出一行由逗号分隔的名称,描述每一行的每个字段,然后像之前一样显示我们的结果,但用 comma
替换 tab
。
我们需要为 CSV 格式提供一个命令行选项。我们不能使用 -c
,因为它已经意味着 使用 Connors' 常数。由于某些奇怪的原因,许多网站将 CSV 文件称为 “Excel 电子表格”(尽管 CSV 格式早于 Excel)。因此,我们将使用 -e
选项来通知我们的软件我们希望输出为 CSV 格式。
我们将从输出的每一行的焦距开始。这乍一看可能会显得重复,尤其是在交互模式下:用户输入焦距,而我们又重复一遍。
但用户可以在一行中输入多个焦距。输入也可以来自文件,或者来自其他程序的输出。在这种情况下,用户根本看不到输入。
同样,输出可以被保存到一个文件中,我们之后可能会查看它,或者它可以打印出来,或者成为另一个程序的输入。
因此,从每一行开始都显示用户输入的焦距是完全合适的。
等等!不,不能直接按用户输入的方式显示。如果用户输入了像这样的内容:
显然,我们需要去掉这些前导零。
所以,我们可能考虑按原样读取用户输入,在 FPU 中将其转换为二进制,然后从那里打印出来。
但是……
如果用户输入了像这样的内容:
哈哈!打包十进制 FPU 格式允许我们输入 18 位数字。但是用户输入了超过 18 位的数字。我们该如何处理?
嗯,我们 可以 修改代码,读取前 18 位数字,将其输入到 FPU,然后读取更多数字,将我们已经在 TOS 上的结果乘以 10 的幂,然后 add
到它。
是的,我们可以这么做。但是在 这个 程序中这是荒谬的(在另一个程序中可能正合适):即使地球的周长用毫米表示也只需要 11 位数字。显然,我们不可能制造出这么大的相机(至少现在不行)。
所以,如果用户输入了如此巨大的数字,他要么是无聊,要么是在测试我们,要么是在尝试破坏系统,或者在玩游戏——总之,做的不是设计一个针孔相机。
我们该怎么做?
从某种意义上说,我们会给他一巴掌:
为了实现这一点,我们将简单地忽略所有前导零。一旦我们找到一个非零数字,我们将初始化一个计数器为 0
,并开始执行三个步骤:
将数字发送到输出。
将数字附加到一个缓冲区,稍后我们将用它来生成可以发送到 FPU 的打包十进制。
增加计数器。
现在,在执行这三个步骤时,我们还需要警惕以下两种情况之一:
如果计数器超过 18,我们停止将数字附加到缓冲区。我们继续读取数字并发送它们到输出。
如果,或者说 当,下一个输入字符不是数字时,我们就完成输入了。
顺便提一句,我们可以简单地丢弃非数字字符,除非它是 #
,这种字符必须返回到输入流中。它表示开始一个注释,所以我们必须在完成输出生成后看到它,并开始查找更多的输入。
这仍然有一个未覆盖的情况:如果用户输入的只是零(或多个零),我们将永远找不到非零数字来显示。
我们可以在计数器保持为 0
时确定已经发生了这种情况。在这种情况下,我们需要将 0
输出,并进行另一次“巴掌”:
一旦我们显示了焦距并确认其有效(大于 0
且不超过 18 位数字),我们就可以计算针孔直径。
并非巧合的是,pinhole 包含了 pin 一词。实际上,许多针孔字面上就是 pin hole,即用针尖小心打孔的孔。
这就是因为典型的针孔非常小。我们的公式给出的结果是以毫米为单位的。我们将其乘以 1000
,以便将结果以 微米 为单位输出。
在这时,我们将面临另一个问题:过高的精度。
是的,FPU 是为高精度数学设计的。但我们并不是在进行高精度数学计算。我们在处理的是物理学(特别是光学)。
假设我们想把一辆卡车改造成一个针孔相机(我们不会是第一个这么做的人!)。假设它的箱体长度为 12
米,所以焦距是 12000
。使用 Bender 的常数,得出的是 12000
的平方根乘以 0.04
,即 4.381780460
毫米,或者 4381.780460
微米。
无论哪种方式,结果都显得极为精确。我们的卡车不可能恰好是 12000
毫米长。我们没有用如此精确的标准来测量它的长度,因此说我们需要一个直径为 4.381780460
毫米的针孔是有误导性的。4.4
毫米就足够了。
注意
我在上面的例子中只用了十个数字。想象一下,如果我们追求所有 18 位数字的精度,那会有多荒谬!
我们需要限制结果的有效数字位数。一种方法是使用一个整数表示微米。所以,我们的卡车需要一个直径为 4382
微米的针孔。看看这个数字,我们仍然可以决定 4400
微米或 4.4
毫米就足够接近。
此外,我们还可以决定,无论结果多么大,我们只想显示四个有效数字(当然,也可以选择其他数字)。然而,FPU 并不提供四舍五入到特定数字位数的功能(毕竟,它并不是将数字视为十进制,而是视为二进制)。
因此,我们必须设计一个算法来减少有效数字位数。
这是我的算法(我觉得它有点笨拙——如果你知道一个更好的,请告诉我):
将计数器初始化为 0
。
当数字大于或等于 10000
时,将其除以 10
并增加计数器。
输出结果。
当计数器大于 0
时,输出 0
并减少计数器。
注意
如果你想要 四个 有效数字,
10000
就合适。如果需要其他数量的有效数字,请将10000
替换为10
的对应幂。 然后,我们将输出以微米为单位的针孔直径,四舍五入到四个有效数字。
此时,我们已经知道了 焦距 和 针孔直径,这意味着我们也有足够的信息来计算 f 值。
我们将显示 f 值,四舍五入到四个有效数字。f 值可能不会告诉我们太多信息。为了让它更有意义,我们可以找出最接近的 归一化 f 值,即最接近的平方根 2 的幂。
我们通过将实际的 f 值自乘来实现这一点,这当然会给我们它的 平方。然后,我们计算它的以 2 为底的对数,这比计算以平方根 2 为底的对数要容易得多!我们将结果四舍五入到最接近的整数。接下来,我们将 2 乘以这个结果,实际上,FPU 为我们提供了一个很好的捷径:我们可以使用 fscale
操作码来“缩放” 1,这类似于将整数左移。最后,我们计算它的平方根,我们就得到了最接近的归一化 f 值。
如果以上内容听起来让人不知所措——或者感觉工作量太大——也许看到代码后会变得更清晰。总共需要 9 条操作码:
第一行,fmul st0, st0
,将 TOS(栈顶,称为 st0
)的内容平方。fld1
将 1
推送到 TOS。
接下来,fld st1
将平方值再次推送到 TOS。此时,平方值既在 st
中,也在 st(2)
中(稍后会清楚为什么我们在栈上保留第二个副本)。st(1)
包含 1
。
接下来,fyl2x
计算 st
与 st(1)
相乘后的以 2 为底的对数。这就是为什么我们在 st(1)
上放置 1
的原因。
此时,st
包含我们刚刚计算出的对数,st(1)
包含我们保存待用的实际 f 值的平方。
frndint
将 TOS 四舍五入到最接近的整数。fld1
再次推送 1
。fscale
通过 st(1)
中的值来移动 TOS 上的 1
,有效地将 2 的 st(1)
次幂。
最后,fsqrt
计算结果的平方根,即最接近的归一化 f 值。
现在,我们在 TOS 上有了最接近的归一化 f 值,st(1)
中有四舍五入后的以 2 为底的对数,而 st(2)
中仍然保存着我们实际的 f 值的平方。
但是我们不再需要 st(1)
中的内容。最后一行,fstp st1
将 st
中的内容存放到 st(1)
中,并将其弹出。结果,st(1)
的内容现在变成了 st
,st(2)
变成了 st(1)
,依此类推。新的 st
包含归一化 f 值,新的 st(1)
包含我们存储的实际 f 值的平方。
此时,我们准备输出归一化的 f 值。由于它是归一化的,我们将不对其进行四舍五入到四个有效数字,而是将其以完整的精度输出。
归一化的 f 值在它足够小并且可以在光照计上找到的情况下非常有用。否则,我们需要另一种确定合适曝光的方法。
之前我们已经弄清楚了如何从在不同 f 值下测量的曝光中计算适当的曝光。
我见过的所有光照计都能确定在 f5.6 下的适当曝光。因此,我们将计算一个 “f5.6 乘数”,即我们需要将 f5.6 下测得的曝光乘以多少,才能确定我们针孔相机的合适曝光。
根据上面的公式,我们知道这个乘数可以通过将我们的 f 值(实际值,而不是归一化值)除以 5.6
并平方来计算。
在数学上,将我们的 f 值的平方除以 5.6
的平方会得到相同的结果。
从计算的角度看,我们不希望平方两个数字,尤其是当我们可以只平方一个数字时。因此,第一种解决方案看起来更好。
但是……
5.6
是一个 常数。我们不需要让 FPU 浪费宝贵的周期。我们可以直接告诉它除以 5.6²
的结果,或者我们可以先将 f 值除以 5.6
,然后平方结果。两者现在看起来差不多。
但它们并不相同!
通过上述摄影原理的学习,我们记得 5.6
实际上是 2 的平方根的五次方。一个 无理数。这个数字的平方正好是 32
。
不仅 32
是一个整数,而且它是 2 的幂。我们不需要将 f 值的平方除以 32
。我们只需要使用 fscale
将其右移五位。用 FPU 术语来说,就是我们将 fscale
它,st(1)
为 -5
。这比除法要 快得多。
所以,现在已经清楚为什么我们在 FPU 栈上保存了 f 值的平方。计算 f5.6 乘数是这个程序中最简单的计算!我们将将它四舍五入到四个有效数字并输出。
还有一个有用的数字可以计算:我们的 f 值与 f5.6 之间的停距。这可能会帮助我们,如果我们的 f 值刚好超出光照计的范围,但我们有一个可以设置不同速度的快门,并且这个快门使用停距。
假设我们的 f 值与 f5.6 相差 5 停距,而光照计显示我们应该使用 1/1000 秒。那么我们可以首先设置快门速度为 1/1000,然后将拨盘调节 5 停距。
这个计算也很简单。我们所要做的就是计算我们刚刚计算的 f5.6 乘数的以 2 为底的对数(但我们需要的是它的未四舍五入的值)。然后我们将结果四舍五入到最接近的整数。我们不需要担心它有超过四个有效数字,因为结果很可能只有一位或两位数字。
在汇编语言中,我们可以通过一些高语言(包括 C)无法做到的方式优化 FPU 代码。
每当 C 函数需要计算一个浮点值时,它会将所有必要的变量和常量加载到 FPU 寄存器中。然后,它执行所需的计算以获得正确的结果。优秀的 C 编译器可以很好地优化代码的这一部分。
它通过将结果保留在 TOS(栈顶)上来“返回”值。然而,在返回之前,它会进行清理。它所使用的任何变量和常量都会从 FPU 中移除。
它不能像我们刚才所做的那样:我们计算了 f 值的平方,并将其保留在栈上,以便稍后由另一个函数使用。
我们 知道 我们稍后会需要这个值。我们还知道栈上有足够的空间(栈上最多能存放 8 个数字)来存储这个值。
而 C 编译器无法知道它在栈上的某个值会在不久的将来再次需要。
当然,C 程序员可能知道这一点,但唯一的解决方法就是将值存储到内存变量中。
这意味着:首先,值将从 FPU 内部使用的 80 位精度转换为 C 中的 double(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 计算其二进制值,精度由 FPU 自行决定。
我们还使用了其他常量:我们将针孔直径乘以 1000
,以将其从毫米转换为微米;在将数字四舍五入到四个有效数字时,我们用 10000
来进行比较。所以,我们将 1000
和 10000
都保留在栈上。当然,我们在将数字四舍五入到四位时,也会重新使用 0.1
。
最后但同样重要的是,我们将 -5
保留在栈上。我们需要它来缩放 f 值的平方,而不是将其除以 32
。并且并非巧合,我们最后加载这个常量。这使得它在栈上是最顶层的常量。当 f 值的平方被缩放时,-5
就在 st(1)
上,正是 fscale
所期望的位置。
通常我们会从头创建某些常量,而不是从内存中加载它们。这就是我们对 -5
所做的事情:
我们可以将这些优化总结为一个规则:将重复的值保留在栈上!
注意
PostScript® 是一种基于栈的编程语言。关于 PostScript® 的书籍要比关于 FPU 汇编语言的书籍多得多:掌握 PostScript® 将帮助你掌握 FPU。
这段代码遵循了与我们之前看到的其他过滤器相同的格式,唯一的微妙例外是:
我们不再假设输入的结束意味着所有任务都已完成,这是在 面向字符 的过滤器中我们习以为常的做法。
这个过滤器不处理字符。它处理的是一种 语言(尽管是一个非常简单的语言,仅由数字组成)。
当没有更多输入时,可能意味着两件事之一:
我们完成了,可以退出。这与之前相同。
我们读取的最后一个字符是一个数字。我们已经将它存储在我们的 ASCII 到浮点数转换缓冲区的末尾。现在,我们需要将该缓冲区的内容转换为一个数字,并写出最后一行输出。
因此,我们修改了
getchar
和read
例程,使得在我们从输入中获取另一个字符时,carry flag
始终为 清除,或者在没有更多输入时,carry flag
为 设置。当然,我们仍然使用汇编语言魔法来实现这一点!仔细看看
getchar
。它 总是 在返回时将carry flag
清除。然而,我们的主代码依赖于
carry flag
来告诉它何时退出——并且它工作得很好。这个魔法出现在
read
中。每当它从系统接收到更多输入时,它会返回到getchar
,getchar
从输入缓冲区获取一个字符,清除carry flag
并返回。但当
read
从系统接收到没有更多的输入时,它 不会 返回到getchar
。相反,add esp, byte 4
操作码将4
加到ESP
中,设置carry flag
并返回。那么,它返回到哪里呢?每当程序使用
call
操作码时,微处理器会将返回地址 压入 栈顶(即将其存储在系统栈中,而不是 FPU 栈中)。当程序使用ret
操作码时,微处理器会从栈中 弹出 返回地址,并跳转到存储在该地址的地方。但是,由于我们将
4
加到ESP
(栈指针寄存器),我们实际上给微处理器带来了轻微的 失忆症:它不再记得是getchar
调用了read
。由于
getchar
在调用read
之前并没有压入任何内容,因此栈顶现在包含了调用getchar
的程序的返回地址。就该调用者而言,它调用了getchar
,而getchar
返回时将carry flag
设置好了!
除此之外,bcdload
例程处于大端和小端之间的一个小冲突之中。
它正在将数字的文本表示转换为该数字:文本以大端顺序存储,但 打包的十进制 是小端顺序。
为了解决这个冲突,我们在开始时使用了 std
操作码。稍后我们使用 cld
来取消它:在 std
活跃时,我们非常重要的一点是不要调用任何可能依赖于 方向标志 默认设置的内容。
代码中的其他部分应该很清晰,前提是你已经阅读了之前的整个章节。
这是一个经典的例子,证明了编程需要大量的思考和很少的编码。只要我们将每个细节都考虑清楚,代码几乎就会自己写出来。
由于我们决定让程序 忽略 除数字之外的任何输入(即使是在注释中的数字),我们实际上可以进行 文本查询。我们并不 必须 这样做,但我们 可以 这样做。
在我看来,进行文本查询,而不是必须遵循非常严格的语法,使得软件更加用户友好。
假设我们想要制作一台针孔相机,使用 4x5 英寸的胶片。对于这种胶片,标准的焦距大约是 150 毫米。我们希望 微调 焦距,使得针孔直径尽可能接近整数。我们还假设我们对相机比较熟悉,但对计算机有些许畏惧。与其直接输入一堆数字,我们更希望 提出 一些问题。
我们的会话可能如下所示:
我们发现,虽然对于焦距为 150 毫米时,针孔直径应该是 490 微米,或者 0.49 毫米,但如果我们选择几乎相同的焦距 156 毫米,我们就可以将针孔直径设为恰好半毫米。
由于我们选择了 #
字符来表示注释的开始,我们可以将我们的针孔软件视为一个 脚本语言。
你可能见过以以下方式开始的 shell 脚本:
… 或者 …
因为 #!
后的空格是可选的。
每当 UNIX® 被要求运行一个以 #!
开头的可执行文件时,它会假定该文件是一个脚本。它将命令与脚本的第一行剩余部分结合起来,并尝试执行。
假设我们现在将针孔程序安装在 /usr/local/bin/
目录下,我们现在可以编写一个脚本,用来计算适合 120 胶片常用的各种焦距的针孔直径。
这个脚本可能看起来像这样:
由于 120 胶片是一种中等大小的胶片,我们可以将此文件命名为 medium。 我们可以设置它的执行权限,并像运行程序一样运行它:
UNIX® 会将最后一个命令解释为:
它将运行该命令并显示:
现在,我们输入:
UNIX® 会将其视为:
这会给它两个冲突的选项:-b
和 -c
(使用 Bender 常数和使用 Connors 常数)。我们已经编程使得后面的选项覆盖前面的选项——我们的程序将使用 Connors 常数来计算所有内容:
我们决定还是使用 Bender 常数。我们想将其值保存为逗号分隔的文件:
在 MS-DOS® 和 Windows® 下“成长”的汇编语言程序员通常倾向于走捷径。读取键盘扫描码和直接写入视频内存是两个经典的做法,在 MS-DOS® 下,这些做法并不被批评,而是被认为是正确的做法。
原因是什么?因为在执行这些操作时,PC BIOS 和 MS-DOS® 是非常慢的。
你可能会想继续在 UNIX® 环境中采用类似的做法。例如,我曾看到一个网站,解释如何在一个流行的 UNIX® 克隆系统中访问键盘扫描码。
但这通常是在 UNIX® 环境中 非常糟糕的做法!让我解释一下为什么。
首先,可能根本不可能这样做。UNIX® 运行在受保护模式下,只有内核和设备驱动程序才允许直接访问硬件。也许某个特定的 UNIX® 克隆系统会让你读取键盘扫描码,但一个真正的 UNIX® 操作系统很可能不会。而且即使某个版本允许这样做,下一版本可能就不允许了,所以你精心编写的软件可能会一夜之间变得过时。
但是,还有一个更重要的原因,不要试图直接访问硬件(除非,当然,你正在编写设备驱动程序),即使在允许你这样做的 UNIX® 类系统中:
UNIX® 是一种抽象!
MS-DOS® 和 UNIX® 在设计哲学上有一个根本的区别。MS-DOS® 被设计为一个单用户系统,运行在配有键盘和视频显示器的计算机上。用户输入几乎可以保证来自该键盘。你的程序输出几乎总是显示在该屏幕上。
而在 UNIX® 下,这一点从来没有被保证。UNIX® 用户很常见的做法是管道和重定向程序的输入和输出:
如果你编写了 program2,你的输入就不是来自键盘,而是来自 program1 的输出。同样,你的输出也不是显示在屏幕上,而是成为 program3 的输入,后者的输出最终被写入 file1。
而且还有更多!即使你确保了输入来自终端,输出也去往终端,但终端也不一定是 PC:它可能没有你期望的那种视频内存,或者它的键盘也可能不是生产 PC 风格扫描码的键盘。它可能是 Macintosh®,或者任何其他计算机。
现在你可能会摇头想:我的软件是用 PC 汇编语言编写的,怎么能在 Macintosh® 上运行?但我并不是说你的软件会在 Macintosh® 上运行,我只是说它的终端可能是一个 Macintosh®。
在 UNIX® 下,终端不一定要直接连接到运行你软件的计算机,它甚至可能位于另一个大陆,或者,事实上,可能在另一个星球上。完全有可能一个澳大利亚的 Macintosh® 用户通过 telnet 连接到位于北美(或其他地方)的 UNIX® 系统。此时,软件在一台计算机上运行,而终端则位于另一台计算机上:如果你试图读取扫描码,你将得到错误的输入!
关于任何其他硬件也是一样:你正在读取的文件可能存储在你无法直接访问的磁盘上。你正在读取图像的相机可能位于太空飞船上,通过卫星与你连接。
这就是为什么在 UNIX® 下,你永远不应假设你的数据来自哪里、去向哪里。始终让系统处理对硬件的物理访问。
注意
这些是注意事项,而不是绝对规则。也可能存在例外。例如,如果一个文本编辑器确定它正在本地计算机上运行,它可能希望直接读取扫描码以提高控制精度。我提到这些注意事项并不是告诉你该做什么或不该做什么,而是让你意识到,如果你刚从 MS-DOS® 转到 UNIX®,可能会遇到的一些陷阱。当然,创造性的人通常会打破规则,只要他们知道自己在打破规则,并且理解原因,这是可以接受的。
Jake Burkholder 则通过愿意回答我的所有问题,并提供示例汇编语言源代码,继续为我打开这扇大门。
版权所有 ® 2000-2001 G. Adam Stanislav。保留所有权利。
与 IPv6 相关的功能符合,或尽量符合最新的 IPv6 标准。为了将来参考,我们在下方列出一些相关文档(注意:这不是完整列表 —— 太难维护了……)。
如需详细信息,请参阅文档中相应章节、RFC、手册页或源代码中的注释。
RFC1639:FTP 在大地址记录上的操作(FOOBAR)
推荐使用 RFC2428 代替 RFC1639。FTP 客户端会首先尝试 RFC2428,失败后才尝试 RFC1639。
RFC1886:支持 IPv6 的 DNS 扩展
RFC1933:IPv6 主机与路由器的过渡机制
不支持 IPv4 兼容地址。
不支持自动隧道(RFC 第 4.3 节中描述)。
RFC1981:IPv6 的路径 MTU 发现
RFC2080:IPv6 的 RIPng
usr.sbin/route6d 提供支持。
RFC2292:IPv6 的高级套接字 API
有关支持的库函数/内核 API,请见 sys/netinet6/ADVAPI。
RFC2362:协议无关多播 - 稀疏模式(PIM-SM)
RFC2362 定义了 PIM-SM 的数据包格式。draft-ietf-pim-ipv6-01.txt 基于此撰写。
RFC2373:IPv6 地址结构
支持节点所需地址,并符合作用域要求。
RFC2374:IPv6 可聚合全局单播地址格式
支持 64 位接口 ID 长度。
RFC2375:IPv6 多播地址分配
用户空间应用程序使用 RFC 中分配的知名地址。
RFC2428:IPv6 和 NAT 的 FTP 扩展
推荐使用 RFC2428 代替 RFC1639。FTP 客户端会首先尝试 RFC2428,失败后再尝试 RFC1639。
RFC2460:IPv6 规范
RFC2461:IPv6 邻居发现
RFC2462:IPv6 无状态地址自动配置
RFC2463:IPv6 的 ICMPv6 规范
RFC2464:以太网上的 IPv6 分组传输
RFC2465:IPv6 的 MIB:文本约定与常规组
所需统计数据由内核收集。实际的 IPv6 MIB 支持通过 ucd-snmp 补丁包提供。
RFC2466:IPv6 的 MIB:ICMPv6 组
所需统计数据由内核收集。实际的 IPv6 MIB 支持通过 ucd-snmp 补丁包提供。
RFC2467:FDDI 网络上的 IPv6 分组传输
RFC2497:ARCnet 网络上的 IPv6 分组传输
RFC2553:IPv6 的基本套接字接口扩展
RFC2675:IPv6 Jumbo 报文
RFC2710:IPv6 的多播监听器发现(MLD)
RFC2711:IPv6 路由器提醒选项
draft-ietf-ipngwg-router-renum-08:IPv6 路由器重新编号
draft-ietf-ipngwg-icmp-namelookups-02:通过 ICMP 的 IPv6 名称查询
draft-ietf-ipngwg-icmp-name-lookups-03:通过 ICMP 的 IPv6 名称查询
draft-ietf-pim-ipv6-01.txt:IPv6 的 PIM
draft-itojun-ipv6-tcp-to-anycast-00:断开面向 IPv6 Anycast 地址的 TCP 连接
draft-yamamoto-wideipv6-comm-model-00
draft-ietf-ipngwg-scopedaddr-format-00.txt:IPv6 作用域地址格式扩展
邻居发现相当稳定。目前支持地址解析、重复地址检测(DAD)和邻居不可达检测。近期我们将会向内核中添加代理邻居通告支持,并提供无请求邻居通告的发送命令,作为管理员工具。
部分网络驱动在混杂模式下会将多播数据包回送给自身,即使指示不要这样做。在这种情况下,DAD 可能会失败,因为 DAD 引擎会看到来自本节点的 NS 入站数据包,并将其视为重复地址的信号。你可以查看 sys/netinet6/nd6_nbr.c 文件中的 nd6_dad_timer() 函数里用 #if
标记为“heuristics”的代码作为变通方案(注意,“heuristics”部分的代码不符合规范)。
邻居发现规范(RFC2461)没有说明以下情况中邻居缓存的处理方式:
尚无邻居缓存条目时,节点收到未携带链路层地址的未经请求的 RS/NS/NA/重定向数据包;
在无链路层地址的介质上如何处理邻居缓存(我们仍需要邻居缓存条目来存储 IsRouter 位)。
对于第一种情况,我们根据 IETF ipngwg 邮件列表中的讨论实现了一个变通方案。详情见源代码中的注释和邮件列表中从 1999 年 2 月 6 日(IPng 7155)开始的讨论线程。
IPv6 的“链路内”判断规则(RFC2461)与 BSD 网络代码中的假设大相径庭。目前尚不支持默认路由器列表为空时的“链路内”判断规则(RFC2461,第 5.2 节,第 2 段最后一句 —— 请注意,该规范在多个地方混用了“host”和“node”的含义)。
为避免可能的 DoS 攻击与无限循环,我们目前仅接受 ND 数据包中的前 10 个选项。因此,如果 RA 附带了 20 个前缀选项,只有前 10 个会被识别。如果这对你造成困扰,请在 FREEBSD-CURRENT 邮件列表中提出,或自行修改 sys/netinet6/nd6.c 中的 nd6_maxndopt 变量。如果用户需求足够强烈,我们也可能为此变量提供 sysctl 控制项。
IPv6 使用具有限定作用域的地址。因此,在使用 IPv6 地址时,指定作用域索引(对于链路本地地址是接口索引,对于站点本地地址是站点索引)非常重要。如果没有作用域索引,具有限定作用域的 IPv6 地址对于内核来说是模糊的,内核将无法确定数据包的出接口。
普通的用户态应用程序应该使用高级 API(RFC2292)来指定作用域索引或接口索引。为实现类似目的,RFC2553 在 sockaddr_in6
结构中定义了 sin6_scope_id
成员。然而,sin6_scope_id
的语义相当模糊。如果你关心应用程序的可移植性,我们建议你使用高级 API 而不是 sin6_scope_id
。
在内核中,对于链路本地作用域的地址,接口索引嵌入在 IPv6 地址的第二个 16 位字(即第 3 和第 4 字节)中。例如,你可能会在路由表和接口地址结构(struct in6_ifaddr
)中看到如下形式的地址:
上面的地址是一个链路本地单播地址,属于接口标识符为 1 的网络接口。嵌入索引的方式使我们能够在多个接口上有效地识别 IPv6 链路本地地址,同时只需很少的代码修改。
当你在命令行中指定具有作用域的地址时,切勿使用嵌入形式(例如 ff02:1::1
或 fe80:2::fedc
)。这是不应该被支持的。应始终使用标准形式,如 ff02::1
或 fe80::fedc
,并使用命令行选项指定接口(如 ping -6 -I ne0 ff02::1
)。一般而言,如果某个命令没有用于指定出接口的命令行选项,那么它尚未准备好处理具有作用域的地址。这似乎与 IPv6 支持“牙医办公室”(dentist office)情形的初衷相悖。我们认为这些规范仍需改进。
部分用户态工具支持扩展的 IPv6 数值语法,如 draft-ietf-ipngwg-scopedaddr-format-00.txt 中所述。你可以通过使用出接口的名称来指定出链路,例如 fe80::1%ne0
。通过这种方式,你可以轻松指定链路本地作用域地址。
大多数 IPv6 的无状态地址自动配置功能是在内核中实现的。邻居发现(Neighbor Discovery)功能整体上由内核实现。主机端对路由通告(RA)的输入在内核中实现;终端主机的路由请求(RS)输出、路由器的 RS 输入和 RA 输出则在用户态中实现。
8.1.1.4.1. 链路本地地址和特殊地址的分配
IPv6 的链路本地地址是根据 IEEE802 地址(即以太网 MAC 地址)生成的。当接口变为激活状态(IFF_UP
)时,会自动分配 IPv6 链路本地地址。同时,会将链路本地地址的直接路由加入路由表。
以下是 netstat
命令的输出示例:
对于没有 IEEE802 地址的接口(如隧道接口或 ppp 接口等伪接口),将尽可能借用其他接口(如以太网接口)的 IEEE802 地址。如果没有任何 IEEE802 硬件可用,则会以 MD5(主机名)
的伪随机值作为链路本地地址的来源作为最后手段。如果这种方式不适用于你的需求,你需要手动配置链路本地地址。
如果某个接口无法处理 IPv6(如不支持多播),则不会为该接口分配链路本地地址。详情请参见第 2 节。
每个接口都会加入 solicited 多播地址和链路本地 all-nodes 多播地址(例如接口所在链路上的 fe80::1:ff01:6317
和 ff02::1
)。除了链路本地地址外,还会将环回地址(::1
)分配给环回接口。同时,::1/128
和 ff01::/32
会自动添加到路由表中,环回接口也会加入节点本地多播组 ff01::1
。
8.1.1.4.2. 主机上的无状态地址自动配置
在 IPv6 规范中,节点分为两类:路由器(router) 和 主机(host)。路由器转发目的地为其他节点的数据包,主机则不转发数据包。net.inet6.ip6.forwarding
控制该节点的角色(若为 1 则为路由器,若为 0 则为主机)。
请注意,IPv6 规范默认以下前提条件,对于不符合这些前提的情况,规范未做详细定义:
只有主机会监听路由通告;
主机仅有一个网络接口(不包括环回接口)。
因此,不建议在路由器或多接口主机上启用 net.inet6.ip6.accept_rtadv
。配置错误的节点可能会表现异常(对于想做实验的人来说,这种非规范配置是允许的)。
以下是 sysctl 控制选项的总结:
RFC2462 第 5.5.3 (e) 节对收到的 RA 中前缀信息选项规定了验证规则,以防止主机受到恶意(或配置错误)的路由器发送极短的前缀生命周期影响。Jim Bound 曾在 ipngwg 邮件列表中提出一项更新(可在归档中查找 “(ipng 6712)”),此更新已被实现。
v6 封装于 v6(v6 in v6)
v6 封装于 v4(v6 in v4)
v4 封装于 v6(v4 in v6)
v4 封装于 v4(v4 in v4)
当前的源地址选择规则是基于作用域(scope)优先的(但也有一些例外,见下文)。对于一个给定的目的地址,IPv6 源地址的选择遵循以下规则:
如果用户显式指定了源地址(例如通过高级 API),则使用指定的地址。
如果出口接口(通常通过查询路由表确定)上分配有与目的地址具有相同作用域的地址,则使用该地址。 这是最常见的情况。
如果没有符合上述条件的地址,则选择发送节点的任一接口上分配的全局地址。
如果仍没有符合条件的地址,且目的地址是站点本地(site local)作用域,则选择任一接口上分配的站点本地地址。
如果仍无法满足条件,则选择与目标地址的路由表项相关联的地址。此为最后手段,可能导致作用域违规。
需要注意的是,上述规则未在 IPv6 规范中定义,属于“由实现决定”的项目。以下是一些不遵循上述规则的情况: 例如建立 TCP 连接时,会使用保存在 tcb 中的地址作为源地址;再如发送邻居通告(Neighbor Advertisement)时,规范(RFC2461 第 7.2.2 节)要求 NA 的源地址为对应 NS 的目标地址。在这种情况下我们遵循规范,而非最长匹配规则。
对于新连接(规则 1 不适用的情况),如果存在已弃用的地址(preferred lifetime = 0),则在其他地址可用时不会选取它们作为源地址;若无其他选择,则会作为最后手段使用。如果存在多个可用的已弃用地址,将依照上述作用域规则选择其中之一。若你希望禁止使用已弃用的地址,可将 sysctl 参数 net.inet6.ip6.use_deprecated
设置为 0。与已弃用地址相关的问题详见 RFC2462 第 5.5.4 节(注意:IETF ipngwg 正在讨论如何使用“已弃用”地址)。
已实现了巨大负载跳跃式选项(Jumbo Payload hop-by-hop option),可以用于发送负载大于 65,535 字节的 IPv6 数据包。但目前没有支持超过 65,535 字节 MTU 的物理接口,因此此类负载仅能在环回接口(即 lo0)上看到。
如果你希望尝试巨大负载,首先需要重新配置内核,使环回接口的 MTU 大于 65,535 字节;在内核配置文件中添加以下内容:
options "LARGE_LOMTU" # 测试巨大负载
然后重新编译新内核。
IPv6 规范要求,如果数据包中携带了分片头,则不得使用巨大负载选项。如果违反此规定,必须向发送方发送 ICMPv6 参数问题消息。尽管规范如此,但通常无法看到由于此要求引发的 ICMPv6 错误。
因此,内核不会发送 ICMPv6 错误,除非数据包确实是一个巨大负载,即其包大小大于 65,535 字节。如上所述,目前没有支持如此大 MTU 的物理接口,因此 ICMPv6 错误的返回几乎不可能发生。
目前不支持在巨大负载(jumbogram)上使用 TCP/UDP。这是因为除了环回接口外,我们没有其他介质来进行测试。如果你有此需求,请联系我们。
IPsec 不支持巨大负载。这是因为在支持 AH(认证头)与巨大负载时存在一些规范上的问题(AH 头大小影响负载长度,这使得认证传入数据包时处理巨大负载选项以及 AH 变得非常困难)。
在 *BSD 系统中,支持巨大负载存在一些根本性问题。我们希望解决这些问题,但需要更多时间来完成。列举几个问题:
在 4.4BSD 中,mbuf 的 pkthdr.len 字段被定义为“int”,因此它无法在 32 位架构的 CPU 上保存大于 2G 的巨大负载。如果我们希望正确支持巨大负载,必须扩展该字段以支持 4G + IPv6 头 + 链路层头。因此,该字段必须扩展为至少 int64_t
(u_int32_t
不足够)。
我们错误地在许多地方使用“int”来保存数据包长度。需要将它们转换为更大的整数类型,这需要非常小心,因为在数据包长度计算过程中可能会发生溢出。
我们错误地在多个地方检查 IPv6 头的 ip6_plen
字段来确定数据包负载长度。实际上应检查 mbuf 的 pkthdr.len。ip6_input()
会在输入时对巨大负载选项进行合理性检查,之后我们可以安全地使用 mbuf 的 pkthdr.len。
当然,TCP 代码也需要在许多地方进行仔细更新。
IPv6 规范允许将任意数量的扩展头添加到数据包中。如果我们按照 BSD 的 IPv4 代码实现 IPv6 数据包处理,内核堆栈可能会因为长时间的函数调用链而导致溢出。sys/netinet6 代码经过精心设计,避免了内核堆栈溢出,因此 sys/netinet6 代码定义了自己的协议切换结构,即 "struct ip6protosw"(参见 netinet6/ip6protosw.h)。IPv4 部分(sys/netinet)没有进行类似的更新以保持兼容性,但它对 pr_input() 原型进行了小幅修改。因此,"struct ipprotosw" 也被定义了。结果是,如果接收到带有大量 IPsec 头的 IPsec-over-IPv4 数据包,内核堆栈可能会溢出。但 IPsec-over-IPv6 是安全的。(当然,为了处理这些 IPsec 头,每个 IPsec 头必须通过每个 IPsec 检查,因此匿名攻击者无法利用这种攻击。)
RFC2463 发布后,IETF ipngwg 决定禁止对 ICMPv6 重定向消息发送 ICMPv6 错误数据包,以防止网络介质上发生 ICMPv6 风暴。此项已在内核中实现。
对于用户空间编程,我们支持 IPv6 套接字 API,符合 RFC2553、RFC2292 和即将发布的互联网草案。
虽然 ip_forward()
调用 ip_output()
,但 ip6_forward()
直接调用 if_output()
,因为路由器不应将 IPv6 数据包分割成分片。
ICMPv6 应尽可能包含原始数据包,直到 1280 字节。例如,UDP6/IP6 端口不可达错误应该包含所有扩展头和 未修改 的 UDP6 和 IP6 头。因此,除了 TCP 之外,所有 IP6 函数都不会将网络字节序转换为主机字节序,以保留原始数据包。
tcp_input()
、udp6_input()
和 icmp6_input()
不能假设 IP6 头部紧接着传输层头部,因为存在扩展头。因此,in6_cksum()
被实现用于处理 IP6 头部和传输头部不连续的数据包。TCP/IP6 和 UDP6/IP6 头结构并不存在于校验和计算中。
为了便于处理 IP6 头部、扩展头部和传输头部,网络驱动程序现在要求将数据包存储在一个内部 mbuf 或一个或多个外部 mbuf 中。旧版驱动程序通常为 96 - 204 字节的数据准备两个内部 mbuf,但现在这类数据包数据会存储在一个外部 mbuf 中。
netstat -s -p ip6
命令可以告诉你你的驱动程序是否符合这一要求。在下面的例子中,"cce0" 不符合要求。(更多信息,请参阅第 2 节。)
每个输入函数在开始时调用 IP6_EXTHDR_CHECK
来检查 IP6 头与其扩展头之间的区域是否连续。如果 mbuf 设置了 M_LOOP
标志,即数据包来自环回接口,则 IP6_EXTHDR_CHECK
会调用 m_pullup()
;否则,来自物理网络接口的数据包则不会调用 m_pullup()
。
IP 和 IP6 的重组函数都不会调用 m_pullup()
。
RFC2553 描述了 IPv4 映射地址(3.7)和 IPv6 通配符绑定套接字的特殊行为(3.8)。该规范允许你:
通过 AF_INET6 通配符绑定套接字接收 IPv4 连接。
通过使用类似 ::ffff:10.1.1.1
这样的地址格式,通过 AF_INET6 套接字传输 IPv4 数据包。
但该规范本身非常复杂,并未明确指定套接字层应该如何处理。我们在此将前者称为“监听端”,将后者称为“发起端”,以供参考。
你可以在同一个端口上对两个地址族进行通配符绑定。
下表展示了 FreeBSD 4.x 的行为:
接下来的章节将提供更多详细信息,以及如何配置这些行为。
关于监听端的注释:
如果一个服务器应用程序希望同时接受 IPv4 和 IPv6 连接,则有两种选择。
为了便于便捷地仅支持 IPv6 流量,使用 AF_INET6 通配符绑定套接字时,始终在连接建立时检查对端地址。如果地址是 IPv4 映射地址,你可能希望拒绝该连接。你可以通过使用 IN6_IS_ADDR_V4MAPPED()
宏来检查这一条件。
当此调用成功时,套接字将仅接收 IPv6 数据包。
建议应用程序实现者:为了实现一个可移植的 IPv6 应用程序(在多个 IPv6 内核上工作),我们认为以下几点是成功的关键:
永远不要硬编码 AF_INET 或 AF_INET6。
在编写需要发起连接的应用程序时,如果你将 AF_INET 和 AF_INET6 视为完全独立的地址族,事情会变得更简单。{set,get}sockopt 问题会更简单,DNS 问题也会简化。我们不建议依赖 IPv4 映射地址。
8.1.1.12.1. 统一的 TCP 和 inpcb 代码
FreeBSD 4.x 在 IPv4 和 IPv6 之间共享 TCP 代码(来自 sys/netinet/tcp*),并分开处理 UDP4/6 代码。它使用统一的 inpcb 结构。
该平台可以配置为支持 IPv4 映射地址。内核配置总结如下:
默认情况下,AF_INET6 套接字将在某些条件下接管 IPv4 连接,并且可以发起到嵌入在 IPv4 映射 IPv6 地址中的 IPv4 目标的连接。
你可以通过 sysctl 禁用整个系统的 IPv4 映射地址,方法如下:sysctl net.inet6.ip6.mapped_addr=0
8.1.1.12.1.1. 监听端
只有在以下条件满足时,通配符 AF_INET6 套接字才会接管 IPv4 连接:
没有与 IPv4 连接匹配的 AF_INET 套接字。
AF_INET6 套接字配置为接受 IPv4 流量,即 getsockopt(IPV6_BINDV6ONLY) 返回 0。
打开/关闭顺序没有问题。
8.1.1.12.1.2. 发起端
FreeBSD 4.x 支持向 IPv4 映射地址(::ffff:10.1.1.1)发起连接,如果节点配置为支持 IPv4 映射地址。
当 RFC2553 即将定稿时,关于如何命名 struct sockaddr_storage
成员有过讨论。一个提案是将 *
前缀加到成员名上(例如“*ss_len”),因为它们不应被直接修改。另一个提案是不加前缀(例如“ss_len”),因为我们需要直接操作这些成员。对此没有明确的共识。
因此,RFC2553 定义了 struct sockaddr_storage
如下:
相反,XNET 草案定义如下:
在 1999 年 12 月,达成了一致意见,RFC2553bis 应该采纳后者(XNET)定义。
当前实现遵循 XNET 定义,基于 RFC2553bis 的讨论。
如果你查看多个 IPv6 实现,你会看到这两种定义。作为用户空间程序员,处理它的最便捷方式是:
确保平台上有 ss_family
和/或 ss_len
,可以使用 GNU autoconf,
使用 -Dss_family=*ss_family
统一所有实例(包括头文件)为 *ss_family
,或者
永远不要触碰 __ss_family
,将其转换为 sockaddr *
并使用 sa_family
,如:
以下两个项目是标准驱动程序需要支持的:
mbuf 聚类要求。在这个稳定版本中,我们将 MINCLSIZE
改为 MHLEN+1
,以便所有操作系统的驱动程序都能按照预期行为工作。
(注意:过去我们要求所有 PCMCIA 驱动程序必须调用 in6_ifattach()
。现在我们不再有这样的要求。)
我们将 IPv4/IPv6 翻译器分为四种类型:
翻译器 A --- 用于过渡的早期阶段,使得 IPv6 主机能够在 IPv6 网络中与 IPv4 主机建立连接。
翻译器 B --- 用于过渡的早期阶段,使得 IPv4 主机能够在 IPv4 网络中与 IPv6 主机建立连接。
翻译器 C --- 用于过渡的后期阶段,使得 IPv4 主机能够在 IPv4 网络中与 IPv6 主机建立连接。
翻译器 D --- 用于过渡的后期阶段,使得 IPv6 主机能够在 IPv6 网络中与 IPv4 主机建立连接。
IPsec 主要由三个组件组成:
策略管理
密钥管理
AH 和 ESP 处理
策略条目不会与其索引重新排序,因此添加条目的顺序非常重要。
该工具包(sys/netkey)实现的密钥管理代码是一个自制的 PFKEY v2 实现,符合 RFC2367。
自制的 IKE 守护进程 "racoon" 包含在该工具包中(kame/kame/racoon)。基本上,你需要将 racoon 作为守护进程运行,然后设置一个策略以要求密钥(如 ping -P 'out ipsec esp/transport//use'
)。内核将在必要时联系 racoon 守护进程以交换密钥。
IPsec 模块作为标准 IPv4/IPv6 处理的“钩子”实现。当发送数据包时,ip{,6}_output()
会检查是否需要 ESP/AH 处理,通过检查是否找到匹配的 SPD(安全策略数据库)。如果需要 ESP/AH,{esp,ah}{4,6}_output()
会被调用,并且 mbuf 将相应更新。当数据包接收时,{esp,ah}4_input()
会根据协议号(即 (*inetsw[proto])()
)被调用。{esp,ah}4_input()
会解密/检查数据包的真实性,并去除 ESP/AH 的链式头部和填充。由于我们在接收时不会直接使用收到的数据包,因此去除 ESP/AH 头部是安全的。
通过使用 ESP/AH,TCP4/6 的有效数据段大小将受到 ESP/AH 插入的额外链式头部的影响。我们的代码已经处理了这一情况。
基本的加密功能可以在 "sys/crypto" 目录中找到。ESP/AH 转换列在 {esp,ah}_core.c
文件中,包含包装函数。如果你希望添加某个算法,可以在 {esp,ah}_core.c
中添加包装函数,并将加密算法代码加入 sys/crypto
。
隧道模式在本版本中部分支持,具有以下限制:
IPsec 隧道不能与 GIF 泛型隧道接口结合使用。需要特别小心,因为我们可能会在 ip_output()
和 tunnelifp→if_output()
之间创建一个无限循环。关于是否统一它们,意见不一。
MTU 和不分段位(IPv4)需要更多的检查,但基本上可以正常工作。
AH 隧道的身份验证模型需要重新审视。最终,我们需要改进策略管理引擎。
内核中的 IPsec 代码符合(或尽力符合)以下标准:
“旧版 IPsec”规范在 rfc182[5-9].txt 中有文档说明。
当前支持的算法有:
旧版 IPsec AH
空加密校验和(无文档,仅用于调试)
使用 128 位加密校验和的键控 MD5(rfc1828.txt)
使用 128 位加密校验和的键控 SHA1(无文档)
使用 128 位加密校验和的 HMAC MD5(rfc2085.txt)
使用 128 位加密校验和的 HMAC SHA1(无文档)
旧版 IPsec ESP
空加密(无文档,类似于 rfc2410.txt)
DES-CBC 模式(rfc1829.txt)
新版 IPsec AH
空加密校验和(无文档,仅用于调试)
使用 96 位加密校验和的键控 MD5(无文档)
使用 96 位加密校验和的键控 SHA1(无文档)
使用 96 位加密校验和的 HMAC MD5(rfc2403.txt)
使用 96 位加密校验和的 HMAC SHA1(rfc2404.txt)
新版 IPsec ESP
空加密(rfc2410.txt)
使用派生 IV 的 DES-CBC(draft-ietf-ipsec-ciph-des-derived-01.txt,草案已过期)
使用显式 IV 的 DES-CBC(rfc2405.txt)
使用显式 IV 的 3DES-CBC(rfc2451.txt)
BLOWFISH CBC(rfc2451.txt)
CAST128 CBC(rfc2451.txt)
RC5 CBC(rfc2451.txt)
以上每种都可以与以下认证组合使用:
使用 HMAC-MD5(96bit) 的 ESP 身份验证
使用 HMAC-SHA1(96bit) 的 ESP 身份验证
不支持的算法:
旧版 IPsec AH
使用 128 位加密校验和 + 64 位重放防止的 HMAC MD5(rfc2085.txt)
使用 160 位加密校验和 + 32 位填充的键控 SHA1(rfc1852.txt)
IPsec(内核中)和 IKE(作为用户空间的“racoon”)已在多个互操作性测试活动中进行了测试,并且已知与许多其他实现良好互操作。此外,目前的 IPsec 实现覆盖了 RFC 中文档化的 IPsec 加密算法(仅涵盖没有知识产权问题的算法)。
如 draft-ipsec-ecn-00.txt 中所述,支持 ECN 友好的 IPsec 隧道。
正常的 IPsec 隧道在 RFC2401 中有描述。在封装时,IPv4 TOS 字段(或 IPv6 流量类别字段)将从内层 IP 头复制到外层 IP 头。在解封装时,外层 IP 头将被简单地丢弃。解封装规则与 ECN 不兼容,因为外层 IP TOS/流量类别字段中的 ECN 位将丢失。
IPsec 隧道实现可以通过设置 net.inet.ipsec.ecn
(或 net.inet6.ipsec6.ecn
)为某个值来实现三种行为:
RFC2401:不考虑 ECN(sysctl 值为 -1)
禁止 ECN(sysctl 值为 0)
允许 ECN(sysctl 值为 1)
请注意,行为是按节点进行配置的,而不是按安全关联(SA)配置的(draft-ipsec-ecn-00 提出了按 SA 配置,但我认为这过于复杂)。
行为总结如下(更多细节请参阅源代码):
配置的通用策略如下:
如果两个 IPsec 隧道端点都支持 ECN 友好的行为,最好将两端都配置为“允许 ECN”(sysctl 值为 1)。
如果另一端对 TOS 位要求非常严格,则使用“RFC2401”(sysctl 值为 -1)。
在其他情况下,使用“禁止 ECN”(sysctl 值为 0)。
默认行为是“禁止 ECN”(sysctl 值为 0)。
有关更多信息,请参阅:
以下是 KAME 代码过去在多个平台上测试 IPsec/IKE 互操作性的情况。请注意,双方可能已修改过其实现,因此请仅将以下列表作为参考。
Altiga、Ashley-laurent(vpcom.com)、Data Fellows(F-Secure)、Ericsson ACC、FreeS/WAN、HITACHI、IBM AIX®、IIJ、Intel、Microsoft® Windows NT®、NIST(Linux IPsec + plutoplus)、Netscreen、OpenBSD、RedCreek、Routerware、SSH、Secure Computing、Soliton、Toshiba、VPNet、Yamaha RT100i
支持的构建和安装内核的过程已在 FreeBSD 手册的 章节中进行了详细说明。
本章节假设读者已经熟悉 FreeBSD 手册中的 章节中描述的信息。如果尚不熟悉,请阅读上述章节,了解构建过程的工作原理。
运行 来生成内核源代码:
进入构建目录。运行 后,会打印出此目录的名称。
当运行开发版内核(例如,FreeBSD-CURRENT)时,或者在极端条件下运行内核(例如,极高的负载平均值、数万个连接、异常高的并发用户数、数百个 等),或者在使用 FreeBSD-STABLE 上的新特性或设备驱动(例如 PAE)时,内核有时会发生 panic。在发生 panic 时,本章节将展示如何从崩溃中提取有用的信息。
在内核将其物理内存内容转储到转储设备之前,必须先配置转储设备。可以使用 命令告诉内核将内核崩溃转储保存到哪里。必须在使用 配置交换分区后调用 程序。这通常通过在 中设置 dumpdev
变量来处理,dumpdev
变量指定交换设备的路径(这是提取内核转储的推荐方式),或者设置为 AUTO
,以使用第一个配置的交换设备。dumpdev
的默认值在 HEAD 中为 AUTO
,在 RELENG_* 分支中更改为 NO
(除了 RELENG_7,仍然设置为 AUTO
)。在 FreeBSD 9.0-RELEASE 及更高版本中,安装过程中,bsdinstall 会询问是否启用目标系统的崩溃转储。
检查 /etc/fstab 或 ,以查看交换设备的列表。
若内核转储已写入转储设备,必须在挂载交换设备之前提取该转储。要从转储设备提取转储,请使用 程序。如果在 中设置了 dumpdev
,则在崩溃后的第一次多用户启动时, 将自动调用,并且在交换设备挂载之前会被调用。提取的核心文件位置由 中的 dumpdir
值指定,默认位置是 /var/crash,并且文件名为 vmcore.0。
如果 /var/crash 中已经存在一个名为 vmcore.0 的文件(或 dumpdir
设置的任何其他目录中),内核将在每次崩溃后递增尾部的数字,以避免覆盖现有的 vmcore 文件(例如:vmcore.1)。在转储保存之后, 将始终在 /var/crash 中创建一个名为 vmcore.last 的符号链接。此符号链接可用于查找最新转储的文件名。
工具生成一个包含完整内存转储或小型转储摘要的文本文件。如果在 中设置了 dumpdev
,则在 执行后, 将自动调用。输出将保存到 dumpdir
中,文件名为 core.txt.N。
内核包含一个 节点,用于请求内核 panic。此功能可用于验证系统是否已正确配置以保存内核崩溃转储。你可能希望在触发崩溃之前,在单用户模式下将现有文件系统重新挂载为只读,以避免数据丢失。
重新启动后,系统应将转储保存在 /var/crash 目录,并附带一个来自 的匹配摘要。
本节介绍 。最新版本包含在 中。FreeBSD 11 及更早版本中也包含一个旧版本。
通常, 应该能够找到生成转储时运行的内核。如果无法找到正确的内核,可以将内核和转储的路径作为两个参数传递给 kgdb:
然后重新构建内核。(有关如何配置 FreeBSD 内核的详细信息,请参阅 )。
要查看所有正在运行的进程的 风格总结,使用:
这将导致内核转储并重启,因此你可以稍后使用 在更高层次分析转储。
强烈建议在调试会话中准备一份 手册的打印版。记住,在单步调试内核时,很难阅读在线手册。
这将指示 从 /dev/ad0s1b 提取内核转储并将内容放入 /var/crash。不要忘记确保目标目录 /var/crash 有足够的空间来存储转储。同时,也不要忘记指定正确的交换设备路径,因为它可能不同于 /dev/ad0s1b!
FreeBSD 内核提供了第二种 KDB 后端用于在线调试: 。这一功能自 FreeBSD 2.2 以来一直被支持,并且实际上是一个非常棒的功能。
构建完成后,将内核复制到目标机器并启动它。将目标机器的串行线(其 uart 设备上设置了 "flags 080")连接到调试主机的任何串行端口。有关如何在 uart 设备上设置标志的信息,请参见 。
可以通过 debug.kdb.available
sysctl 列出支持的后端。如果内核配置中包含 options DDB
,则默认选择 。如果 gdb
没有出现在可用后端列表中,则可能未正确配置调试串口。
根据所使用的编译器,一些局部变量可能会显示为空,从而无法被 gdb
直接检查。如果在调试过程中出现此问题,可以通过以较低的优化级别构建内核来改善某些变量的可见性。这可以通过将 COPTFLAGS=-O1
传递给 来完成。然而,某些类型的内核错误在更改优化级别时可能会表现不同(或根本不再出现)。
您可能会遇到所谓的死锁,即系统停止执行有用的工作。在这种情况下,为了提供有帮助的错误报告,请按照前一节中所述使用 。在报告中包括 ps
和 trace
的输出,针对怀疑的进程。
是一个非常简单的控制台驱动程序,未直接与任何物理设备连接。它仅从内核或加载程序的缓冲区读取和写入字符。由于其简单性,它在内核调试中非常有用,特别是在使用 FireWire® 设备时。目前,FreeBSD 提供了两种方式与缓冲区进行交互,使用 。
大多数 FireWire®(IEEE1394)主机控制器基于 OHCI 规范,支持物理访问主机内存。这意味着一旦主机控制器初始化完成,我们可以在不依赖软件(内核)的情况下访问主机内存。我们可以利用这一功能与 进行交互。 提供类似于串行控制台的功能。它模拟两个串行端口,一个用于控制台和 DDB,另一个用于 GDB。由于远程内存访问完全由硬件处理,即使系统崩溃, 缓冲区仍然可以访问。
要在 i386 或 amd64 上启用 FireWire® 和 Dcons 支持,首先在 中启用:
在 /etc/make.conf 中添加 LOADER_FIREWIRE_SUPPORT=YES
,然后重新构建 :
要启用 作为活动的低级控制台,请在 /boot/loader.conf 中添加 boot_multicons="YES"
。
查找 FireWire® 主机控制器的 EUI64(唯一的 64 位标识符),并使用 或 dmesg
查找目标机器的 EUI64。
运行 ,使用以下命令:
以下是运行 后可以使用的键盘组合:
通过启动 并进行远程调试会话来附加远程 GDB:
存在一个适用于 的 GDB 模式;您需要在 .emacs 中添加以下内容:
我们可以通过 /dev/mem 直接读取 缓冲区,适用于运行中的系统,也适用于崩溃后的核心转储。这些输出类似于 dmesg -a
,但 缓冲区包含更多信息。
要与 KVM 一起使用 :
转储运行中系统的 缓冲区:
转储崩溃转储的 缓冲区:
options WITNESS
:此选项启用运行时锁定顺序跟踪和验证,是诊断死锁的宝贵工具。WITNESS 维护一个按锁类型获取的锁定顺序图,并在每次获取时检查图中是否存在循环(隐式或显式)。如果检测到循环,会生成警告并打印堆栈跟踪到控制台,表明可能发生了死锁。使用 show locks
、show witness
和 show alllocks
DDB 命令需要启用 WITNESS。此调试选项具有显著的性能开销,但可以通过使用 options WITNESS_SKIPSPIN
来缓解。详细文档请参见 。
options DEBUG_MEMGUARD
:这是一个替代 的内核内存分配器,使用 VM 系统检测从分配的内存中读取或写入已释放内存。详细信息请参见 。此选项对性能有显著影响,但在调试内核内存损坏错误时非常有用。
options KASAN
:启用内核地址消毒器(Kernel Address Sanitizer)。这启用编译器插桩,用于检测内核中的无效内存访问,如使用后释放和缓冲区溢出。此选项在很大程度上取代了 options DEBUG_MEMGUARD
。有关详细信息,请参见 ,以及当前支持的平台。
options KMSAN
:启用内核内存消毒器(Kernel Memory Sanitizer)。这启用编译器插桩,用于检测未初始化内存的使用。有关详细信息,请参见 ,以及当前支持的平台。
本章由 G. Adam Stanislav 编写 []。
本章并不解释汇编语言的基础知识。关于这一点,有足够的资源(想要学习完整的在线汇编语言课程,请参见 Randall Hyde 的 ;如果您更喜欢出版书籍,请查看 Jeff Duntemann 的《汇编语言-基于 Linux 环境》(ISBN: 0471375233))。然而,完成本章后,任何汇编语言程序员都将能够快速高效地为 FreeBSD 编写程序。
FreeBSD 提供了三种非常不同的汇编器。llvm-as(1)
(包含在 中)和 as(1)
(包含在 中)使用传统的 UNIX® 汇编语言语法。
另一方面,nasm(1)
(通过 安装)使用 Intel 语法。它的主要优点是可以为许多操作系统生成汇编代码。
例如, 说:
如果你的系统不是 FreeBSD,你可以从其 获取 nasm。你仍然可以使用它来汇编 FreeBSD 的代码。
这种缓冲输入/输出的方法仍然存在一个隐藏的危险。我将在稍后讨论并修复它,当我谈到 时。
这是一个相对高级的话题,主要对熟悉编译器理论的程序员感兴趣。如果你愿意,可以 ,稍后再阅读。
我在我的网站上有一个,但这里有一个非常简短的概述:
然后,你需要使用浏览器查看它的输出。要查看我服务器上的输出,请访问 。如果你对密码保护的 Web 目录中的附加环境变量感到好奇,可以访问 ,并使用用户名 asm
和密码 programmer
。
我为 UNIX® 编写的第一个程序之一是 ,这是一个文本到 UNIX® 文件的转换器。它将来自其他操作系统的文本文件转换为 UNIX® 文本文件。换句话说,它将不同的行结束符转换为 UNIX® 的换行符约定。它将输出保存到一个不同的文件中。可选地,它也可以将 UNIX® 文本文件转换为 DOS 文本文件。
这与 中的描述略有不同。那是因为 描述的是 C 版本。
若想深入了解 mmap
,请参阅 W. Richard Stevens 的 。
过滤器和图像编辑器将永远互相等待(或者至少,直到它们被终止)。我们的软件已进入一个 。
你可以阅读的书——如果你能找到的话——是 Richard Startz 的 。尽管它似乎理所当然地假设了 打包十进制 的小端存储。我并不夸张地说,在我发现我应该尝试小端顺序来处理这种数据之前,我在搞清楚下面展示的过滤器问题时几乎快要疯掉了。
本教程的完成离不开许多经验丰富的 FreeBSD 程序员的帮助,他们大多来自 ,许多人耐心地回答了我的问题,并在我试图探索 UNIX® 系统编程(尤其是 FreeBSD)内部工作原理的过程中,指引我走上正确的道路。
Thomas M. Sommers 为我打开了大门。他的 网页是我第一次接触 FreeBSD 汇编语言编程示例的地方。
本节将说明与 IPv6 和 IPsec 相关的实现细节。这些功能源自 。
TAHI 项目在 KAME STABLE 套件上进行了标准符合性测试。测试结果可见于 。我们也曾参与新罕布什尔大学 IOL 的测试(),测试对象为旧版本快照。
接口以通用方式实现了 IPv[46]-over-IPv[46] 隧道,覆盖了该规范中的“配置隧道”。详情见本文件第 节。
详情见本文件第 节。
详情见本文件第 节。
详情见本文件第 节。
支持 IPv4 映射地址(第 3.7 节)和 IPv6 通配绑定套接字的特殊行为(第 3.8 节)。详情见本文件第 节。
详情见本文件第 节。
实现了密集模式。 实现了稀疏模式。
详情见本文件第 节。
如果 DAD 失败,该地址将被标记为“重复”,并产生日志信息输出到 syslog(通常也会输出到控制台)。可以使用 查看“重复”标记。检查并处理 DAD 失败是管理员的责任。该行为应在近期得到改进。
路由守护进程和配置程序,例如 和 ,需要处理“嵌入的”作用域索引。这些程序使用路由套接字和 ioctl(如 SIOCGIFADDR_IN6),内核 API 将返回第二个 16 位字已填充的 IPv6 地址。这些 API 用于操作内核内部结构。使用这些 API 的程序必须为内核间差异做好准备。
要在程序中使用此扩展,你需要使用 和 ,并带上 NI_WITHSCOPEID
。当前的实现假设链路与接口之间是一对一的关系,这一假设比规范中的要求更为严格。
当主机接收到来自路由器的路由通告(RA)时,主机可以通过无状态地址自动配置为自己配置地址。该行为由 net.inet6.ip6.accept_rtadv
控制(设为 1 时,主机启用自动配置)。通过自动配置,会为接收接口添加网络地址前缀(通常是全局地址前缀),并配置默认路由。路由器会周期性地发送 RA 包。若希望请求邻居路由器发送 RA 包,主机可以发送路由请求(RS)。可随时使用 rtsol
命令发送 RS 包。系统也提供了 守护进程。[rtsold(8)] 会在需要时自动发送路由请求,非常适合移动设备(如笔记本电脑)的使用场景。如果希望忽略路由通告,可以通过 sysctl
将 net.inet6.ip6.accept_rtadv
设为 0。
若需从路由器生成路由通告,请使用 守护进程。
关于 DAD(重复地址检测)与自动配置的关系,请参见本手册的 节。
GIF(通用接口,Generic InterFace)是一种用于配置隧道的伪接口。详情请参见 。目前支持以下配置:
可以使用 为 gif 接口分配物理(外层)源地址和目标地址。若内外层 IP 头使用相同地址族(如 v4 封装于 v4,或 v6 封装于 v6),这种配置是危险的。非常容易配置出无限层隧道的接口与路由表。请务必注意。
gif 可以配置为支持 ECN(显式拥塞通知)友好行为。关于隧道的 ECN 友好性详见 ,以及如何配置详见 。
如果你希望使用 gif 接口配置 IPv4 封装于 IPv6 的隧道,请仔细阅读 。你需要手动移除自动分配给 gif 接口的 IPv6 链路本地地址。
例如,目的地址为 ff01::1 时会选取 ::1 作为源地址;目的地址为 fe80:1::2a0:24ff:feab:839b 时会选取 fe80:1::200:f8ff:fe01:6317(注意嵌入的接口索引,详见 ,帮助我们选择正确的源地址。这些嵌入索引不会出现在网络数据包中)。若出口接口上存在多个具有相同作用域的地址,将基于最长匹配原则选择(规则 3)。假设出口接口上配置了 2001:0DB8:808:1:200:f8ff:fe01:6317 与 2001:0DB8:9:124:200:f8ff:fe01:6317,那么对于目的地址 2001:0DB8:800::1,将选用 2001:0DB8:808:1:200:f8ff:fe01:6317 作为源地址。
之后,你可以通过 命令测试巨大负载,使用 -6、-b 和 -s 选项。必须指定 -b 选项以增大套接字缓冲区大小,-s 选项指定数据包的长度,应该大于 65,535 字节。例如,输入以下命令:
当收到 IPv6 数据包时,会检查数据帧长度并与 IPv6 头中的负载长度字段或巨大负载选项中的长度(如果有)进行比较。如果前者小于后者,则丢弃数据包并增加统计计数。你可以通过 命令与 -s -p ip6
选项查看统计信息:
IPv6 上的 TCP/UDP 可用且相当稳定。你可以使用 、、、、 等应用程序。这些应用程序是协议独立的,即它们会根据 DNS 自动选择 IPv4 或 IPv6。
看起来 RFC2553 对于通配符绑定问题,尤其是端口空间问题、失败模式以及 AF_INET/INET6 通配符绑定之间的关系,讨论得较少。对于此 RFC,可能有多个不同的解释,它们符合规范但行为不同。因此,为了实现可移植的应用程序,你应该对内核中的行为不作假设。使用 是最安全的方法。端口号空间和通配符绑定问题曾在 1999 年 3 月中旬的 ipv6imp 邮件列表中详细讨论过,似乎并没有达成明确的共识(意味着取决于实现者)。你可能想查阅该邮件列表的归档。
一种方法是使用 AF_INET 和 AF_INET6 套接字(你需要两个套接字)。使用 和 AI_PASSIVE 作为 ai_flags,然后使用 和 绑定所有返回的地址。通过打开多个套接字,你可以接受连接并将其绑定到适当的地址族。IPv4 连接将由 AF_INET 套接字接收,IPv6 连接将由 AF_INET6 套接字接收。
另一种方法是使用一个 AF_INET6 通配符绑定套接字。使用 和 AI_PASSIVE 设置 ai_flags,使用 AF_INET6 设置 ai_family,并将主机名参数设置为 NULL。然后使用 和 绑定返回的地址(应为 IPv6 不指定地址)。通过这个单一套接字,你可以同时接收 IPv4 和 IPv6 数据包。
为了更轻松地解决此问题,系统提供了一个与系统相关的 选项 IPV6_BINDV6ONLY
,用法如下:
在系统中始终使用 和 。永远不要使用 gethostby*()、getaddrby*()、inet_() 或 getipnodeby()。(为了轻松更新现有应用程序以支持 IPv6,有时 getipnodeby*() 会很有用。但如果可能,请尝试重写代码以使用 和 。)
如果你想连接到目标,请使用 ,并尝试所有返回的目标地址,就像 所做的那样。
某些 IPv6 栈附带了有缺陷的 。将一个最小可工作的版本随你的应用程序一起发布,并在必要时作为最后的选择使用。
如果你希望使用 AF_INET6 套接字来处理 IPv4 和 IPv6 的外发连接,你将需要使用 。如果你希望以最小的工作量更新现有应用程序以支持 IPv6,可以选择这种方法。但请注意,这是一个临时解决方案,因为 本身并不推荐使用,因为它根本不处理作用域 IPv6 地址。对于 IPv6 名称解析,推荐使用 API。因此,当你有时间时,应重写应用程序,使用 。
每个套接字可以配置为支持特殊的 AF_INET6 通配符绑定(默认启用)。你可以通过 禁用它,方法如下:
多播。如果 显示某个接口没有多播组,那么该接口必须进行修补。
如果任何驱动程序不支持这些要求,那么该驱动程序将无法用于 IPv6 和/或 IPsec 通信。如果你在使用 IPv6/IPsec 时发现任何问题,请报告给 。
内核实现了实验性的策略管理代码。有两种方式可以管理安全策略。一种是通过 配置每个套接字的策略。在这种情况下,策略配置在 中描述。另一种是通过 PF_KEY 接口配置基于内核包过滤器的策略,使用 。
“新版 IPsec”规范在 rfc240[1-6].txt、rfc241[01].txt、rfc2451.txt 和 draft-mcdonald-simple-ipsec-api-01.txt(草案已过期,但可以从 获取)中有文档说明。 (注:IKE 规范 rfc241[7-9].txt 在用户空间实现,作为 “racoon” IKE 守护进程)
为了使 IPsec 隧道支持 ECN,我们应修改封装和解封装过程。这在 第 3 章中有所描述。
、RFC2481(显式拥塞通知)、src/sys/netinet6/{ah,esp}_input.c
(感谢 Kenjiro Cho 提供详细分析)