Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
版权 © 1995-2023 FreeBSD 文档计划
所以在这里。系统已安装完成,您准备开始编程了。但从哪开始呢?FreeBSD 提供了什么?作为一名程序员,它能为我做些什么?
这些是本章试图回答的一些问题。当然,编程像其他行业一样有不同水平的熟练程度。对于有些人来说,这是一种爱好,对于其他人来说,这是他们的职业。本章的信息可能针对初学者程序员;确实,对于不熟悉 FreeBSD 平台的程序员也会有用。
为了尽可能生产最好的类 UNIX®操作系统软件包,同时尊重原始软件工具思想,以及可用性,性能和稳定性。
我们的意识形态可以通过以下准则来描述
不要添加新功能,除非实施者无法在没有它的情况下完成真正的应用程序。
决定系统是什么和决定系统不是什么一样重要。不要满足世界上所有的需求;相反,使系统具有可扩展性,以便可以以向上兼容的方式满足额外的需求。
从一个例子泛化的唯一更糟糕的事情是从没有例子泛化。
如果问题没有完全被理解,最好根本不提供解决方案。
如果您可以用 10%的工作获得所需效果的 90%,请使用更简单的解决方案。
尽量将复杂性隔离。
提供机制,而不是策略。特别是,将用户界面策略交由客户端处理。
来自 Scheifler & Gettys:“X Window System”
FreeBSD 的完整源代码可从我们的公共 Git 存储库获取。源代码通常安装在 /usr/src 中。源树的布局由顶层的 README.md 文件描述。
欢迎来到开发者手册。本手册还在不断完善中,是许多人共同努力的成果。许多部分尚不存在,一些已存在的部分也需要更新。如果您有兴趣参与这个项目,请发送电子邮件至 FreeBSD 文档项目邮件列表。
本文档的最新版本始终可从 FreeBSD 全球网络服务器获取。也可以从 FreeBSD 下载服务器或众多镜像站点之一以各种格式和压缩选项下载。
本章记录了适用于 FreeBSD 源代码树的各种准则和政策。
一致的编码风格非常重要,特别是对于像 FreeBSD 这样的大型项目。代码应该遵循在 style(9)和 style.Makefile(5)中描述的 FreeBSD 编码风格。
如果 FreeBSD src/发行版的特定部分由一个或一组人维护,通过在 src/MAINTAINERS 中的条目进行通信传达。在Ports集合中维护者通过向有问题的port的 Makefile 添加 MAINTAINER 行向世界表达他们的维护权:
维护者的作用如下:
维护者拥有并负责该代码。这意味着他或她负责修复有关该代码部分的错误并回答问题报告,在涉及贡献的软件的情况下,需适时追踪新版本。
应该在提交变更到已定义维护者的目录之前将其发送给维护者进行审核。只有在维护者在不可接受的时间内未做出回应,即多封电子邮件后才允许在没有经过维护者审查的情况下提交更改。然而,建议尽可能让其他人审查这些更改。
当然,未经同意不可将个人或组添加为维护者来承担这一责任。另一方面,它不必是一个提交者,可以很容易地是一组人。
FreeBSD 发行版的某些部分由 FreeBSD 项目之外积极维护的软件组成。出于历史原因,我们称之为贡献软件。一些例子包括 LLVM、zlib(3)和 awk(1)。
管理贡献软件的接受流程涉及创建供应商分支,可以在其中干净地导入软件(无需修改),并可以以版本化的方式跟踪更新。然后,将供应商分支的内容应用于源代码树,可能会进行本地修改。FreeBSD 特定的构建粘合剂在源代码树中维护,而不是在供应商分支中。
根据其需求和复杂性,个别软件项目可能会根据维护者的判断偏离此过程。更新特定贡献软件的确切步骤应记录在一个名为 FREEBSD-upgrade 的文件中;例如,libarchive 的 FREEBSD-upgrade 文件。
贡献的软件通常放置在源代码树的 contrib/子目录中,但也有一些例外情况。仅由内核使用的贡献软件位于 sys/contrib/下。
管理贡献软件和供应商分支的标准流程在 Committer's Guide 中有详细描述。
偶尔可能需要在 FreeBSD 源代码树中包含一个受限制的文件。例如,如果设备在操作之前需要加载一小段二进制代码,并且我们没有该代码的源代码,那么该二进制文件就被称为受限制的。以下政策适用于在 FreeBSD 源代码树中包含受限制文件。
任何由系统 CPU 解释或执行且不以源代码格式存在的文件都是受限制的。
任何许可证比 BSD 或 GNU 更具限制性的文件都是受限制的。
一个包含可下载二进制数据供硬件使用的文件不受限制,除非(1)或(2)适用于它。
任何受限制的文件在添加到存储库之前都需要核心团队的特定批准。
受限的文件放在 src/contrib 或 src/sys/contrib 中。
整个模块应该保持在一起。除非存在与非受限制代码的代码共享,否则没有拆分的必要。
在过去,二进制文件通常会被 uuencode,然后命名为 arch/filename.o.uu。这已不再必要,二进制文件可以无修改地添加到代码库中。
内核文件:
在构建简易性时,应始终引用 conf/files.*中的内容。
应始终包含在 LINT 中,但核心团队根据情况决定是否应将其注释掉。核心团队当然可以随后改变他们的想法。
发布工程师决定是否将其包含在发布中。
用户空间文件:
核心团队决定代码是否应该成为 make world 的一部分。
发行工程团队决定是否将其纳入发布版。
如果您正在为port或其他没有共享库支持的软件添加支持,则版本号应遵循以下规则。通常,生成的版本号与软件的发布版本无关。
对于ports:
优先使用上游已选择的数字
如果上游提供符号版本控制,请确保我们使用他们的脚本
对于基本系统:
从 1 开始启动库版本
强烈建议为新库添加符号版本
如果有不兼容的更改,请使用符号版本处理,保持向后 ABI 兼容
如果这是不可能的,或者库不使用符号版本控制,请提升库版本
即使考虑为符号版本化库提升库版本之前,请与发布工程团队协商,提供改变如此重要以至于应该允许破坏 ABI 的原因
例如,添加函数和修复不改变接口是可以的,而删除函数,更改函数调用语法等应提供向后兼容符号,否则将强制更改主版本号。
进行更改的提交者有责任处理库版本控制。
ELF 动态链接器会严格匹配库名称。一个流行的约定是将库版本写成 libexample.so.x.y 的形式,其中 x 是主版本,y 是次版本。常见的做法是将库的 soname ( DT_SONAME ELF 标签) 设置为 libexample.so.x ,并在安装库时设置 libexample.so.x→libexample.so.x.y 和 libexample.so→libexample.so.x 的符号链接以对应最新的次版本 y。因为静态链接器在指定 -lexample 命令行选项时会搜索 libexample.so ,所以与 libexample 链接的对象会依赖于正确的库。几乎所有流行的构建系统都会自动使用这个方案。
本章是介绍如何使用 FreeBSD 提供的一些编程工具的简介,尽管其中的大部分内容也适用于许多其他版本的 UNIX®。它并不试图详细描述编码。本章的大部分内容假定读者几乎没有编程知识,尽管希望大多数程序员会在其中找到一些有价值的东西。
FreeBSD 提供了一个出色的开发环境。C 和 C++的编译器以及汇编器与基本系统一起提供,更不用说经典的 UNIX 工具如 sed 和 awk 了。如果这还不够,还有许多编译器和解释器在Ports集合中。下面的部分,编程入门,列出了一些可用的选项。FreeBSD 与 POSIX 和 ANSI C 等标准非常兼容,同时也与其自己的 BSD 传统兼容,因此可以编写在各种平台上几乎不经过或经过很少修改就能编译和运行的应用程序。
但是,如果您以前从未在 UNIX 平台上编写程序,所有这些功能可能会让人感到非常不知所措。本文档旨在帮助您快速上手,而不深入探讨更高级的主题。本文档的目的是给您提供足够的基础知识,以便能够理解文档的内容。
大多数文档无需或几乎无需编程知识,尽管它确实假定您具备使用 UNIX 和学习的基本能力!
程序是一组指令,告诉计算机做各种事情;有时候,它必须执行的指令取决于它执行前一个指令时发生了什么。本节概述了您可以给出这些指令的两种主要方式,通常称为“命令”。一种方式使用解释器,另一种方式使用编译器。由于人类语言对计算机来说太难以以一种明确的方式理解,因此通常会用专门设计用于此目的的语言编写命令。
有了解释器,语言就成为了一个环境,在这里你在提示符下键入命令,环境会为你执行这些命令。对于更复杂的程序,你可以把命令键入文件,并让解释器加载该文件并执行其中的命令。如果出了问题,许多解释器会将你转到调试器以帮助你跟踪问题。
其优点在于你可以立即看到命令的结果,错误可以很容易地纠正。最大的缺点是当你想与他人共享你的程序时。他们必须有相同的解释器,或者你必须有某种方法将其提供给他们,并且他们需要理解如何使用它。此外,用户可能不喜欢如果他们按错键就被送入调试器!从性能角度来看,解释器可能会使用大量内存,并且通常不像编译器那样高效地生成代码。
在我看来,如果你以前没有进行过任何编程,解释性语言是开始的最佳方式。这种环境通常在诸如 Lisp、Smalltalk、Perl 和 Basic 等语言中找到。也可以说 UNIX®shell ( sh , csh )本身就是一个解释器,并且事实上许多人确实编写shell "脚本"来帮助执行各种在他们的机器上的"家务"任务。事实上,UNIX®原始哲学的一部分是提供许多可以在shell脚本中链接在一起以执行有用任务的小型实用程序。
这里是从 FreeBSD Ports Collection 可用的解释器列表,简要讨论了一些更受欢迎的解释型语言。
如何从手册的Ports部分找到并安装Ports Collection 中的应用程序的说明。
BASIC 是“初学者通用符号指令代码”的缩写。在上世纪 50 年代为大学生编程教学开发,80 年代随着每台值得尊敬的个人电脑提供,BASIC 是许多程序员的第一种编程语言。它也是 Visual Basic 的基础。
Bywater Basic 解释器可以在Ports集合中的 lang/bwbasic 找到,Phil Cockroft 的 Basic 解释器(前身为 Rabbit Basic)可在 lang/pbasic 中找到。
Lisp 是一种在上世纪 50 年代开发的语言,作为当时流行的“数值计算”语言的替代方案。与基于数字不同,Lisp 是基于列表的;事实上,名称是“List Processing”的缩写。在人工智能领域非常流行。
Lisp 是一种非常强大和复杂的语言,但可能相当庞大和笨重。
可在 FreeBSD 的Ports集合中找到可在 UNIX®系统上运行的各种 Lisp 实现。GNU Common Lisp 可以在 lang/gcl 中找到。由 Bruno Haible 和 Michael Stoll 开发的 CLISP 可作为 lang/clisp 使用。对于 CMUCL,其中还包括一个高度优化的编译器,或者像 SLisp 这样的简单的 Lisp 实现,它在几百行 C 代码中实现了大部分 Common Lisp 构造,分别可以在 lang/cmucl 和 lang/slisp 中找到。
Perl 非常受系统管理员欢迎,用于编写脚本;也经常用于编写 CGI 脚本的 World Wide Web 服务器。
Perl 在Ports集合中作为 lang/perl5.24 对所有 FreeBSD 版本可用。
Scheme 是 Lisp 的一个方言,比 Common Lisp 更紧凑、更干净。在大学中很受欢迎,因为它简单到可以教给本科生作为第一门语言,同时又具有足够高的抽象级别可用于研究工作。
Scheme 可从Ports集合中作为 lang/elk 获取 Elk Scheme 解释器。MIT Scheme 解释器可在 lang/mit-scheme 找到,SCM Scheme 解释器在 lang/scm 中。
IconIcon 是一种具有广泛处理字符串和结构的高级语言。FreeBSD 上的 Icon 版本可以在 Ports 集合中找到,位置为 lang/icon。
LuaLua 是一种轻量级的可嵌入脚本语言。它具有广泛的可移植性,相对简单。Lua 可以在 lang/lua 中的 Ports 集合中找到。它也作为 /usr/libexec/flua 包含在基本系统中,供基本系统组件使用。第三方软件不应依赖 flua。
PythonPython 是一种面向对象的解释性语言。其支持者认为它是开始编程的最佳语言之一,因为相对容易入门,但与其他用于开发大型复杂应用程序的流行解释性语言相比并不受限制(Perl 和 Tcl 是用于此类任务的另外两种流行语言)。
Python 的最新版本可从 lang/python 的Ports集合中获取。
RubyRuby 是一种解释型的、纯面向对象的编程语言。由于其易于理解的语法、编写代码时的灵活性以及轻松开发和维护大型复杂程序的能力,它已经变得非常流行。
Ruby 可从 lang/ruby32 的Ports集合中获取。
Tcl 和 TkTcl 是一种可嵌入的解释性语言,因其在许多平台上的可移植性而变得广泛使用并因此变得流行。它可用于快速编写小型原型应用程序,或者(与 Tk,一个 GUI 工具包相结合)用于编写功能丰富的完整程序。
FreeBSD 提供各种版本的 Tcl 作为 ports。最新版本 Tcl 8.7 可在 lang/tcl87 中找到。
编译器有所不同。首先,您使用编辑器在文件(或文件)中编写代码。然后运行编译器,看看它是否接受您的程序。如果没有编译成功,请咬紧牙关,回到编辑器;如果编译成功并给您一个程序,您可以在shell命令提示符或调试器中运行它,以查看它是否正常工作。^[ 1]^
很明显,这并不像使用解释器那样直接。然而,它使您可以做很多解释器很难甚至不可能做到的事情,比如编写与操作系统密切交互的代码-甚至编写您自己的操作系统!如果您需要编写非常高效的代码,则编译器非常有用,因为编译器可以花费时间优化代码,而这在解释器中是不可接受的。此外,为编译器编写的程序通常比为解释器编写的程序更简单-您可以简单地给他们一个可执行文件副本,假设他们使用与您相同的操作系统。
由于在使用单独的程序时编辑-编译-运行-调试周期相当乏味,许多商业编译器制造商已经生产了集成开发环境(简称 IDE)。FreeBSD 在基本系统中不包括 IDE,但在Ports集合中提供了 devel/kdevelop,并且许多人使用 Emacs 以此为目的。有关将 Emacs 用作 IDE,请参阅《使用 Emacs 作为开发环境》。
本节介绍用于 C 和 C ++的 clang 编译器,因为它安装在 FreeBSD 基本系统中。 clang 安装为 cc ;GNU 编译器 gcc 可在Ports Collection 中获得。 使用解释器生成程序的细节在解释器的文档和在线帮助中通常有很好的介绍。
一旦您完成了您的杰作,下一步是将其转换为可以(希望!)在 FreeBSD 上运行的东西。 这通常涉及几个步骤,每个步骤由一个单独的程序完成。
预处理您的源代码,删除注释并执行其他技巧,如在 C 中展开宏。
检查代码的语法,看看您是否遵守了语言的规则。如果没有,它会抱怨!
将源代码转换为汇编语言-这非常接近机器代码,但仍可被人类理解。据说。
将汇编语言转换为机器码-是的,我们在谈论位和字节,这里是一和零。
检查您是否以一致的方式使用了诸如函数和全局变量之类的东西。例如,如果您调用了一个不存在的函数,它会报错。
如果您正在尝试从几个源代码文件生成可执行文件,请想办法将它们全部组合在一起。
弄清楚如何生成系统运行时加载程序能够加载到内存并运行的内容。
最后,在文件系统上编写可执行文件。
编译一词通常用于指代步骤 1 至 4,其他步骤则称为链接。有时步骤 1 被称为预处理,步骤 3 至 4 被称为汇编。
幸运的是,几乎所有这些细节对您而言都是隐藏的,因为 cc 是一个前端,它会为您调用所有这些程序并使用正确的参数;只需键入
将导致 foobar.c 通过上述所有步骤进行编译。如果您有多个文件要编译,只需执行类似操作
请注意,语法检查只是检查语法而已。它不会检查您可能犯的任何逻辑错误,比如将程序放入无限循环中,或者在本应使用二进制排序时使用冒泡排序。^[ 2]^
有很多选项可供 cc 使用,所有这些选项都在手册页中。这里是一些最重要的选项,以及如何使用它们的示例。
-o filename 文件的输出名称。如果你不使用这个选项, cc 将生成一个名为 a.out 的可执行文件。
-c 只编译文件,不链接它。这对于你只想检查语法的玩具程序,或者如果你在使用 Makefile 时非常有用。
这将产生一个对象文件(而不是可执行文件)称为 foobar.o。这可以与其他对象文件链接在一起成为一个可执行文件。
-g 创建可执行文件的调试版本。这使编译器将关于哪个源文件的哪一行对应于哪个函数调用的信息放入可执行文件中。调试器可以使用这些信息在您逐步执行程序时显示源代码,这非常有用;缺点是所有这些额外信息使程序变得更大。通常,在开发程序时您使用 -g 进行编译,然后在满意程序正常工作时,不使用 -g 进行编译生成“发布版本”。
这将产生程序的调试版本。 ^[4]^
-O 创建可执行文件的优化版本。编译器会尝试执行各种巧妙的技巧,以产生比正常情况下运行更快的可执行文件。您可以在 -O 后面添加一个数字来指定更高级别的优化,但这经常会暴露编译器优化器中的错误。
这将生成 foobar 的优化版本。
以下三个标志将强制 cc 检查您的代码是否符合相关的国际标准,通常称为 ANSI 标准,严格来说它是 ISO 标准。
-Wall 启用 cc 的作者认为值得的所有警告。尽管名称如此,它不会启用 cc 能够的所有警告。
-ansi 关闭 cc 提供的大多数非 ANSI C 特性。尽管名称如此,它并不能严格保证您的代码符合标准。
-pedantic 关闭所有 cc 的非 ANSI C 特性。
没有这些标志, cc 将允许您使用其标准之外的一些非标准扩展。其中一些非常有用,但无法与其他编译器一起使用 - 实际上,标准的主要目标之一是允许人们编写可以在任何系统上的任何编译器上运行的代码。这被称为可移植代码。
通常,您应该尽量使您的代码尽可能具有可移植性,否则您以后可能不得不完全重写程序才能使其在其他地方运行 - 谁知道您未来几年可能会使用什么?
在检查 foobar.c 是否符合标准后,这将生成一个名为 foobar 的可执行文件。
-llibrary 指定在链接时要使用的函数库。
最常见的例子是编译使用 C 语言中的一些数学函数的程序。与大多数其他平台不同,这些函数在与标准 C 库不同的库中,您必须告诉编译器将其添加进去。
规则是,如果库被称为 libsomething.a,你给 cc 参数 -lsomething 。例如,数学库是 libm.a,所以你给 cc 参数 -lm 。数学库的一个常见问题是它必须是命令行中的最后一个库。
这将链接数学库函数到 foobar 中。
如果您正在编译 C++ 代码,请使用 c++。在 FreeBSD 上也可以使用 clang++ 调用 c++。
这将从 C++ 源文件 foobar.cc 生成可执行文件 foobar。
记住, cc 会调用名为 a.out 的可执行文件,除非你另行指定。使用 -o filename 选项:
与 MS-DOS®不同,UNIX®在尝试找出您要运行的可执行文件时不会查找当前目录,除非您告诉它。键入 ./foobar ,这意味着“运行当前目录中名为 foobar 的文件”。
大多数 UNIX®系统都有一个名为 test 的程序在/usr/bin 中,而shell会在检查当前目录之前先运行它。要么输入:
要么为您的程序选择一个更好的名称!
核心转储的名称可以追溯到 UNIX® 的早期,当时机器使用核心存储数据。 基本上,如果程序在某些条件下失败,系统会将核心存储器的内容写入一个名为 core 的文件中,程序员随后可以查看该文件以找出问题所在。
使用调试器分析核心(请参阅调试)。
这基本上意味着您的程序尝试在内存上执行某种非法操作;UNIX®旨在保护操作系统和其他程序免受恶意程序的侵害。
这种情况的常见原因有:
尝试写入空指针,例如
使用未初始化的指针,例如
指针将具有一些随机值,幸运的话,它将指向内存中不可用于您的程序的区域,内核将在您的程序造成任何损害之前终止您的程序。如果您不幸,它将指向您自己程序内的某个位置,并损坏您的数据结构之一,导致程序神秘地失败。
尝试访问数组末尾之后的内容,例如
尝试将某物存储在只读内存中,例如
UNIX® 编译器通常将类似 "My string" 的字符串文本放入只读内存区域。
与 malloc() 和 free() 一起做坏事,例如
要么
做这些错误中的一个通常不会导致错误,但这总是一个不好的习惯。一些系统和编译器比其他系统更宽容,这就是为什么在一个系统上运行良好的程序在另一个系统上尝试时可能会崩溃。
不,幸运的是不会(除非当然你真的遇到了硬件问题...)。这通常是另一种说法,即您以不应该的方式访问了内存。
是的,只需转到另一个控制台或 xterm,执行
以查找您的程序的进程 ID,并执行
其中 pid 是您查找的进程 ID。
如果您的程序陷入无限循环,这将非常有用。例如,如果您的程序恰好陷入 SIGABRT 陷阱,还有其他几个信号会产生类似的效果。
或者,您可以通过调用 abort() 函数从程序内部创建核心转储。查看 abort(3)的手册页以了解更多信息。
如果您想从程序外部创建核心转储,但又不希望进程终止,可以使用 gcore 程序。有关更多信息,请参阅 gcore(1)的手册页。
当你在处理一个只有一个或两个源文件的简单程序时,键入
不是太糟糕,但当有多个文件时很快变得非常乏味,而且编译也可能需要一段时间。
一个解决方法是使用目标文件,只有在源代码发生更改时才重新编译源文件。所以我们可以有类似这样的东西:
如果我们已经更改了 file37.c,但没有更改其他任何文件,自上次编译以来。这可能会大大加快编译速度,但并不能解决输入问题。
或者我们可以编写一个shell脚本来解决输入问题,但这将需要重新编译所有内容,在大型项目上效率会很低。
如果我们有成百上千的源文件散落在周围会发生什么?如果我们和其他人合作,在使用其他源文件时他们忘记告诉我们已经更改了其中一个源文件,会怎样?
或许我们可以将两种解决方案结合起来,编写类似shell脚本的东西,其中包含一些神奇的规则,规定何时需要编译源文件。现在我们所需要的是一个能够理解这些规则的程序,因为这对shell来说有点太复杂了。
这个程序叫做 make 。它读取一个名为 makefile 的文件,该文件告诉它不同文件之间的依赖关系,并计算出哪些文件需要重新编译,哪些不需要。例如,一条规则可以说“如果 fromboz.o 比 fromboz.c 旧,这意味着有人修改了 fromboz.c,所以需要重新编译。” makefile 还包含告诉 make 如何重新编译源文件的规则,使其成为一个更强大的工具。
Makefile 通常与其所适用的源代码保存在同一个目录中,可以命名为 makefile、Makefile 或 MAKEFILE。大多数程序员使用名称 Makefile,因为这样可以将其放在目录列表的顶部,便于查看。^[ 5]^
这是一个非常简单的 make 文件:
它由两行组成,一个依赖行和一个创建行。
这里的依赖行由程序的名称(称为目标),后跟一个冒号,然后是空格,然后是源文件的名称组成。当 make 读取这行时,它会查看 foo 是否存在;如果存在,它会比较 foo 上次修改的时间和 foo.c 上次修改的时间。如果 foo 不存在,或者比 foo.c 旧,那么它会查看创建行以找出该执行什么操作。换句话说,这是确定何时需要重新编译 foo.c 的规则。
创建行以制表符开头(按制表符键),然后是您在命令提示符处创建 foo 时要键入的命令。如果 foo 已过时或不存在,则 make 会执行此命令以创建它。换句话说,这是告诉 make 如何重新编译 foo.c 的规则。
因此,当您键入 make 时,它将确保 foo 相对于您对 foo.c 的最新更改是最新的。这个原则可以扩展到具有数百个目标的 Makefile-事实上,在 FreeBSD 上,只需在适当的目录中键入 make world 就可以编译整个操作系统!
Makefile 的另一个有用属性是目标不必是程序。例如,我们可以有一个看起来像这样的 make 文件:
我们可以通过输入来告诉 make 我们要制作哪个目标:
make 然后只会查看该目标,忽略其他任何目标。例如,如果我们在上述 makefile 中键入 make foo ,make 将忽略 install 目标。
如果我们只输入 make ,make 将始终查看第一个目标,然后停止而不查看其他任何目标。因此,如果我们在这里输入 make ,它将直接转到 foo 目标,如果需要的话重新编译 foo,然后停止而不继续到 install 目标。
注意, install 目标实际上并不依赖于任何东西!这意味着当我们尝试通过键入 make install 来生成该目标时,下一行上的命令总是被执行。在这种情况下,它将 foo 复制到用户的主目录中。这在应用程序的 makefile 中经常被使用,这样当应用程序被正确编译后,可以安装在正确的目录中。
这是一个稍微令人困惑的主题要去解释。如果你不太理解 make 的工作原理,最好的方法是编写一个简单的程序像“hello world”,以及一个像上面那样的 make 文件,并进行实验。然后逐渐使用多个源文件,或者将源文件包含一个头文件。 touch 在这里非常有用-它可以更改文件的日期而无需手动编辑。
C 代码通常以要包含的文件列表开头,例如 stdio.h。其中一些文件是系统包含文件,另一些来自您目前正在工作的项目:
为了确保在 foo.h 更改时重新编译此文件,您必须在 Makefile 中添加它:
当您的项目变得越来越庞大,您有越来越多自己维护的包含文件时,跟踪所有包含文件以及依赖于它的文件将是一种痛苦。如果更改一个包含文件但忘记重新编译所有依赖于它的文件,结果将是灾难性的。 clang 有一个选项来分析您的文件并生成包含文件及其依赖关系的列表: -MM 。
如果您将此添加到您的 Makefile 中:
并运行 make depend ,文件.depend 将显示一个对象文件列表,C 文件和包含文件:
如果您更改 foo.h,下次运行 make 时,所有依赖于 foo.h 的文件将被重新编译。
每次向文件中添加包含文件时,不要忘记运行 make depend 。
编写 Makefiles 可能会相当复杂。幸运的是,像 FreeBSD 这样的基于 BSD 的系统自带一些非常强大的 Makefiles 作为系统的一部分。一个很好的例子是 FreeBSD ports 系统。这是典型 ports Makefile 的基本部分。
现在,如果我们转到此port的目录并键入 make ,将会发生以下情况:
将检查系统上是否已存在此port的源代码。
如果不存在,则将建立到 MASTER_SITES 中 URL 的 FTP 连接以下载源代码。
源代码的校验和将被计算并与已知的良好源代码的校验和进行比较。这是为了确保源代码在传输过程中没有损坏。
适用于 FreeBSD 的源代码所需的任何更改都会被应用-这被称为打补丁。
源代码所需的任何特殊配置都将完成。(许多 UNIX®程序发行版尝试确定它们正在编译的 UNIX®版本以及哪些可选的 UNIX®功能存在-这就是在 FreeBSD ports场景中提供信息的地方)。
编译程序的源代码。实际上,我们转到解压源代码的目录,执行 make - 程序的 make 文件包含构建程序所需的必要信息。
现在我们有了程序的已编译版本。如果希望,现在可以测试它;当对程序感到满意时,可以输入 make install 。这将导致程序及其所需的任何支持文件被复制到正确的位置;还会在 package database 中进行记录,以便稍后可以轻松卸载port,如果我们改变主意的话。
现在我认为您会同意,这对于一个四行脚本来说相当令人印象深刻!
秘密在最后一行,告诉 make 查找名为 bsd.port.mk 的系统 makefile。很容易忽略这一行,但这是所有聪明技巧的来源-有人编写了一个 makefile,告诉 make 执行上述所有操作(还有一些我没有提到的其他操作,包括处理可能发生的任何错误),任何人都可以通过在自己的 makefile 中加入一行来访问它!
如果您想查看这些系统 makefile,它们位于 /usr/share/mk,但最好等到您对 makefile 有了一些实践后再查看,因为它们非常复杂(如果您确实查看它们,请确保您随身携带一瓶浓咖啡!)
Make 是一个非常强大的工具,可以做比上面简单示例展示的更多事情。不幸的是,有几个不同版本的 make ,它们之间有很大的区别。了解它们能做什么的最好方法可能是阅读文档-希望这个介绍能为您提供一个基础,让您可以做到这一点。make(1) 手册页面提供了关于变量、参数以及如何使用 make 的全面讨论。
在 ports 中,许多应用程序使用 GNU make,它有一套非常好的 "info" 页面。如果您安装了其中任何一个 ports,GNU make 将自动安装为 gmake 。它也作为一个 port 和独立软件包提供。
要查看 GNU make 的 info 页面,您需要编辑 /usr/local/info 目录中的 dir,以添加一个条目。这涉及添加一行,例如
到文件中。一旦完成此操作,您可以键入 info ,然后从菜单中选择 make(或者在 Emacs 中,执行 C-h i )。
使用调试器允许在更受控制的情况下运行程序。通常,可以逐行执行程序,检查变量的值,更改它们,告诉调试器运行到某个特定点然后停止,等等。还可以附加到已经运行的程序,或加载核心文件以调查程序崩溃的原因。
本节旨在快速介绍使用调试器,并不涵盖诸如调试内核之类的专业主题。有关更多信息,请参阅内核调试。
FreeBSD 提供的标准调试器称为 lldb (LLVM 调试器)。由于它是该版本的标准安装的一部分,因此无需采取任何特殊操作即可使用它。它具有良好的命令帮助,可通过 help 命令访问,以及网页教程和文档。
FreeBSD 中另一个可用的调试器称为 gdb (GNU 调试器)。与 lldb 不同,它不会默认安装在 FreeBSD 上;要使用它,请从 ports 或软件包中安装 devel/gdb。它具有出色的在线帮助,以及一组信息页面。
这两个调试器具有类似的功能集,因此使用哪一个在很大程度上取决于个人口味。如果只熟悉其中一个,请使用该调试器。对于既不熟悉也不熟悉但想要在 Emacs 中使用一个调试器的人,需要使用 gdb ,因为 Emacs 不支持 lldb 。否则,请尝试两者,看看您更喜欢哪一个。
通过输入启动 lldb
使用 -g 编译程序,以充分利用 lldb 的功能。没有也可以运行,但只会显示当前运行函数的名称,而不是源代码。如果显示类似以下行:
(在设置断点时没有显示源代码文件名和行号),这意味着程序没有使用 -g 编译。
在 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 。
核心文件基本上是一个文件,其中包含进程在崩溃时的完整状态。在“旧日里”,程序员们不得不打印核心文件的十六进制列表,并苦苦钻研机器码手册,但现在生活变得更容易了。顺便说一句,在 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 的一个最棒的功能是它可以附加到已经运行的程序。当然,这需要足够的权限才能这样做。一个常见的问题是在一个分叉的程序中单步执行,并希望跟踪子进程,但调试器只会跟踪父进程。
要做到这一点,启动另一个 lldb ,使用 ps 找到子进程的进程 ID,然后执行
在 lldb 中,然后像往常一样进行调试。
要让这个功能正常工作,调用 fork 创建子元素的代码需要像以下这样做(感谢 gdb 信息页面):
现在需要做的只是附加到子元素,将 PauseMode 设置为 0 ,使用 expr PauseMode = 0 等待 sleep() 调用返回。
从 LLDB 12.0.0 开始,FreeBSD 支持远程调试。这意味着 lldb-server 可以在一个主机上启动以调试程序,而交互式 lldb 客户端可以从另一个主机连接到它。
要启动新的进程以进行远程调试,请在远程服务器上运行 lldb-server ,然后输入。
启动后立即停止该进程,并 lldb-server 等待客户端连接。
在本地启动 lldb ,然后输入以下命令以连接到远程服务器:
lldb-server 也可以连接到正在运行的进程。要执行此操作,请在远程服务器上输入以下内容:
通过输入以下内容启动 gdb
尽管许多人更喜欢在 Emacs 中运行它。要做到这一点,请输入:
最后,对于那些觉得文本命令提示风格令人反感的人,Ports集合中有一个图形化界面 (devel/xxgdb)可用。
使用 -g 编译程序,以充分利用 gdb 。没有也能运行,但只会显示当前运行函数的名称,而不是源代码。像这样的一行:
当 gdb 启动时,意味着程序未使用 -g 编译。
在 gdb 提示符下,输入 break main 。这将告诉调试器跳过正在运行程序中的预备设置代码,并在程序代码开头停止执行。现在输入 run 启动程序-它将从设置代码开头开始运行,然后在调用 main() 时被调试器停止。
逐行执行程序,按 n 。在函数调用时,按 s 进入函数。在函数调用中,按 f 返回,或使用 up 和 down 快速查看调用者。
这里是一个简单的示例,演示如何使用 gdb 在程序中发现错误。这是我们的程序(带有一个故意的错误):
该程序将 i 设置为 5 ,并将其传递给一个函数 bazz() ,该函数打印出我们给它的数字。
编译和运行程序显示
那不是我们所期望的!是时候看看发生了什么!
等一下!anint 是怎么变成 4231 的?它不是应该设为 5 在 main() 中吗?让我们上到 main() 看一看。
哦亲爱的!看着这段代码,我们忘了初始化 i。我们本打算放
但我们遗漏了 i=5; 这一行。由于我们没有初始化 i,在程序运行时,它拥有那个区域内存中的任意数字,在这种情况下恰好是 4231 。
核心文件基本上是一个包含进程在崩溃时完整状态的文件。在“往昔”,程序员必须打印出核心文件的十六进制列表,并苦思冥想于机器码手册,但现在生活变得容易一些。顺便说一句,在 FreeBSD 和其他 4.4BSD 系统下,核心文件称为 progname.core 而不仅仅是 core,以便清楚地表示核心文件属于哪个程序。
要检查一个核心文件,以通常的方式启动 gdb 。而不是输入 break 或 run ,输入
如果核心文件不在当前目录中,请首先键入 dir /path/to/core/file 。
调试器应该显示类似以下内容:
在这种情况下,程序被称为 progname,因此核心文件称为 progname.core。我们可以看到程序由于尝试访问一个在内存中对它不可用的区域而崩溃,此区域位于一个名为 bazz 的函数中。
有时候,能够查看函数的调用方式是很有用的,因为问题可能出现在复杂程序的调用堆栈中很长的地方。 bt 导致 gdb 打印出调用堆栈的回溯:
当程序崩溃时,会调用 end() 函数;在这种情况下, bazz() 函数是从 main() 调用的。
gdb 最好的功能之一是它可以附加到已经运行的程序。当然,这需要足够的权限才能这样做。一个常见的问题是在一个分叉并希望跟踪子进程的程序中进行步进,但调试器只会跟踪父进程。
要做到这一点,启动另一个 gdb ,使用 ps 查找子进程的进程 ID,然后执行
在 gdb 中,然后像往常一样进行调试。
为了使其正常工作,调用 fork 创建子项的代码需要执行类似以下操作(感谢 gdb 信息页面):
现在所需的只是附加到子项,将 PauseMode 设置为 0 ,并等待 sleep() 调用返回!
Emacs 是一个高度可定制的编辑器-事实上,它已经被定制到了一个程度,更像是一个操作系统而不是一个编辑器!许多开发人员和系统管理员实际上几乎所有的时间都在 Emacs 中工作,只是偶尔登出一下。
在这里是不可能甚至仅概括 Emacs 能做的一切的,但下面是一些对开发人员感兴趣的功能:
非常强大的编辑器,允许对字符串和正则表达式(模式)进行查找和替换,跳转到块表达式的开始/结束等。
下拉菜单和在线帮助。
语言相关的语法高亮和缩进。
完全定制化。
您可以在 Emacs 中编译和调试程序。
在编译错误时,您可以跳转到有问题的源代码行。
用于阅读 GNU 超文本文档的 info 程序的友好前端,包括 Emacs 本身的文档。
友好的 gdb 前端,允许您在逐步执行程序时查看源代码。
肯定还有许多被忽视的。
Emacs 可以使用 editors/emacs port在 FreeBSD 上进行安装。
安装完成后,启动它,按下 C-h t 以阅读 Emacs 教程-这意味着按住 Ctrl 键,按下 h 键,释放 Ctrl 键,然后按下 t 键。(或者,您也可以使用鼠标从帮助菜单中选择 Emacs 教程。)
尽管 Emacs 有菜单,但学习按键绑定是非常值得的,因为在编辑时按下几个键比试图找到鼠标然后点击正确位置要快得多。当您与经验丰富的 Emacs 用户交谈时,您会发现他们经常轻松地抛出像“M-x replace-s RET foo RET bar RET”这样的表达,所以知道它们是什么意思是有用的。而且无论如何,Emacs 有太多有用的功能,以至于它们无法全部适合菜单栏中。
幸运的是,很容易掌握组合键,因为它们显示在菜单项旁边。我的建议是,使用菜单项,比如打开一个文件,直到你理解它是如何工作的并且感到自信,然后尝试按下 C-x C-f。当你对此感到满意时,再转向另一个菜单命令。
如果你记不住特定组合键的作用,可以从帮助菜单中选择描述键,并输入它-Emacs 会告诉你它的作用。你也可以使用命令 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 的特性,尽管它相对较小(因此更容易掌握)。
学习 Emacs Lisp 的最佳途径是阅读在线的 Emacs 参考手册。
然而,不需要实际了解任何 Lisp 就可以开始配置 Emacs,因为我已经包含了一个示例.emacs,这应该足以让您开始。只需将其复制到您的主目录并重新启动 Emacs(如果它已经在运行);它将从该文件中读取命令,并(希望)为您提供一个有用的基本设置。
不幸的是,在这里有太多内容要详细解释;然而,有一两点值得一提。
一切以 ; 开头的内容都是注释,Emacs 会忽略它。
在第一行, -- Emacs-Lisp -- 是为了让我们可以在 Emacs 中编辑.emacs 本身,并获得用于编辑 Emacs Lisp 的所有花哨功能。Emacs 通常会根据文件名来猜测这一点,对于.emacs 可能猜测不正确。
在某些模式下,Tab 键绑定到缩进功能,因此当您按 Tab 键时,它会缩进当前行的代码。如果您想在您正在编写的任何内容中放置一个制表符,请在按 Tab 键时按住 Control 键。
该文件通过从文件名猜测语言来支持 C、C++、Perl、Lisp 和 Scheme 的语法高亮显示。
Emacs 已经有一个预定义的函数称为 next-error 。在编译输出窗口中,这允许您通过执行 M-n 从一个编译错误移动到下一个;我们定义了一个补充函数 previous-error ,它允许您通过执行 M-p 转到上一个错误。最好的功能是 C-c C-c 将打开发生错误的源文件并跳转到适当的行。
我们启用了 Emacs 作为服务器的功能,这样,如果您在 Emacs 之外做一些事情并且想要编辑一个文件,您只需输入
然后您可以在 Emacs 中编辑文件!^[ 6]^
示例 1. 一个示例 .emacs
现在,如果您只想在已经适应的语言(C、C++、Perl、Lisp 和 Scheme)中编程,那么这一切都很顺利,但是如果一个名为"whizbang"的新语言出现了,充满了令人兴奋的特性,会发生什么呢?
首先要做的是找出 whizbang 是否附带告诉 Emacs 有关该语言的任何文件。这些文件通常以.el 结尾,缩写为"Emacs Lisp"。例如,如果 whizbang 是一个 FreeBSD port,我们可以通过以下方式找到这些文件
并将它们复制到 Emacs 站点 Lisp 目录中进行安装。在 FreeBSD 上,这个目录是/usr/local/share/emacs/site-lisp。
所以例如,如果来自 find 命令的输出是
我们会执行
接下来,我们需要决定 whizbang 源文件具有什么扩展名。假设为了论证的目的,它们都以 .wiz 结尾。我们需要向我们的 .emacs 添加一个条目,以确保 Emacs 能够使用 whizbang.el 中的信息。
在.emacs 中找到 auto-mode-alist 条目,并添加一行 whizbang,例如:
这意味着当您编辑以.wiz 结尾的文件时,Emacs 会自动进入 whizbang-mode 模式。
在此下方,您会找到 font-lock-auto-mode-list 条目。像这样将 whizbang-mode 添加到其中:
这意味着当编辑.wiz 文件时,Emacs 将始终启用 font-lock-mode (即语法高亮显示)。
这就是所需的全部。如果在打开.wiz 文件时还有其他自动完成的事情,您可以添加 whizbang-mode hook (请参阅 my-scheme-mode-hook 以查看一个添加 auto-indent 的简单示例)。
有关设置开发环境以贡献 FreeBSD 本身修复的信息,请参阅 development(7)。
Brian Harvey and 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 and Berthold Klaus Paul Horn Lisp (3rd Edition) Addison-Wesley 1989 ISBN 0-201-08319-1
Brian W. Kernighan and Rob Pike The Unix Programming Environment Prentice-Hall 1984 ISBN 0-13-937681-X
Brian W. Kernighan and 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
如果您在shell中运行它,可能会导致核心转储。
如果您不知道,二进制排序是一种有效的排序方法,而冒泡排序则不是。
其原因根植于历史的尘埃之中。
注意,我们没有使用 -o 标志来指定可执行文件的名称,因此我们将得到一个名为 a.out 的可执行文件。生成一个名为 foobar 的调试版本留给读者作为练习!
他们不使用 MAKEFILE 形式,因为大写字母通常用于 README 等文档文件。
许多 Emacs 用户将他们的 EDITOR 环境设置为 emacsclient,因此每次需要编辑文件时都会发生这种情况。
本章描述了困扰 UNIX®程序员数十年的一些安全问题,以及可用于帮助程序员避免编写易受攻击代码的新工具。
编写安全应用程序需要对生活持非常谨慎和悲观的态度。应用程序应该以“最小权限”原则运行,以确保没有任何进程以超出其完成功能所需的最低访问权限。应尽可能重用先前经过测试的代码,以避免他人可能已经修复的常见错误。
UNIX®环境的一个陷阱是很容易对环境的健全性做出假设。应用程序永远不应信任用户输入(以各种形式出现)、系统资源、进程间通信或事件的定时。UNIX®进程不是同步执行的,因此逻辑操作很少是原子的。
缓冲区溢出自冯·诺伊曼 1 架构的最初阶段就存在。它们首次在 1988 年与莫里斯互联网蠕虫一起广为人知。不幸的是,同一基本攻击手法至今仍然有效。迄今为止,最常见的缓冲区溢出攻击类型是基于破坏堆栈。
大多数现代计算机系统使用栈来传递参数给过程并存储局部变量。栈是进程映像的高内存区域中的后进先出(LIFO)缓冲区。当程序调用函数时,将创建一个新的“栈帧”。这个栈帧包括传递给函数的参数以及动态数量的局部变量空间。 “栈指针”是一个保存栈顶当前位置的寄存器。由于随着新值被推送到栈顶,该值不断改变,因此许多实现还提供了一个“帧指针”,该指针位于栈帧的开始附近,以便局部变量可以更容易地相对于此值进行寻址。函数调用的返回地址也存储在栈中,这是堆栈溢出攻击的原因,因为溢出函数中的局部变量可能会覆盖该函数的返回地址,从而使恶意用户可以执行任何他或她想要的代码。
尽管基于栈的攻击远远是最常见的,但也可能使用基于堆的(malloc/free)攻击来溢出堆栈。
C 语言不像许多其他语言那样对数组或指针执行自动边界检查。此外,标准 C 库中充斥着一些非常危险的函数。
strcat (char *dest,const char *src)
可能会溢出目标缓冲区
getwd (char *buf)
可能会溢出缓冲区 buf
gets (字符 *s)
可能会溢出 s 缓冲区
[vf]scanf (常量字符 *format, …)
可能会溢出其参数。
realpath (char *路径,char resolved_path[])
可能会溢出路径缓冲区。
[v]sprintf
(char *str, const char *format, …)
可能导致 str 缓冲区溢出。
以下示例代码包含一个缓冲区溢出,旨在覆盖返回地址并跳过函数调用后紧接的指令。(受 4 启发)
让我们看看如果我们在按回车键之前向我们的小程序输入 160 个空格会导致该进程的内存映像是什么样子。
显然可以设计更恶意的输入来执行实际的编译指令(比如 exec(/bin/sh))。
解决栈溢出问题最直接的方法是始终使用长度限制的内存和字符串复制函数。 strncpy 和 strncat 是标准 C 库的一部分。这些函数接受一个长度值作为参数,该值不应大于目标缓冲区的大小。然后,这些函数将从源复制最多'长度'字节到目的地。然而,这些函数存在一些问题。如果输入缓冲区的大小与目标相同大,这两个函数都不保证 NUL 终止。长度参数在 strncpy 和 strncat 之间也使用不一致,因此程序员很容易混淆它们的正确用法。与 strcpy 相比,当将短字符串复制到大缓冲区时,性能损失也很大,因为 strncpy NUL 填充了指定的大小。
另一个内存复制实现存在以解决这些问题。 strlcpy 和 strlcat 函数保证在给定非零长度参数时始终将目标字符串以 NUL 终止。
不幸的是,仍然有大量的公共代码在使用中,它们在内存中盲目复制数据而不使用我们刚讨论过的任何有界复制例程。幸运的是,有一种方法可以帮助防止这种攻击 - 运行时边界检查,这是由几个 C/C++编译器实现的。
ProPolice 是其中一个编译器特性,已集成到 gcc(1)版本 4.1 及更高版本中。它取代并扩展了早期的 StackGuard gcc(1)扩展。
ProPolice 通过在调用任何函数之前在堆栈的关键区域放置伪随机数来帮助防护堆栈型缓冲区溢出和其他攻击。当函数返回时,这些“canaries”会被检查,如果发现它们已被更改,则立即中止可执行文件。因此,任何试图修改返回地址或在堆栈上存储其他变量以运行恶意代码的尝试都不太可能成功,因为攻击者还必须设法保持伪随机 canaries 不变。
使用 ProPolice 重新编译应用是阻止大多数缓冲区溢出攻击的有效手段,但仍可能会被破坏。
编译器基于机制对于只有二进制可用的软件是完全无用的,对于这种情况下你无法重新编译软件。针对这些情况,有许多库重新实现 C 库的不安全函数 ( strcpy , fscanf , getwd , 等等..) 并确保这些函数永远不会写出栈指针。
libsafe
libverify
自卫
不幸的是,这些基于库的防御措施存在许多缺点。这些库只保护非常少量与安全相关的问题,而忽略了修复实际问题。如果应用程序是使用 -fomit-frame-pointer 编译的,这些防御可能会失败。此外,LD_PRELOAD 和 LD_LIBRARY_PATH 环境变量可能会被用户覆盖/取消设置。
任何给定进程都关联至少 6 个不同的 ID,因此您必须非常小心处理进程在任何给定时间具有的访问权限。特别是,所有 seteuid 应用程序应在不再需要特权时放弃特权。
只有超级用户进程才能更改真实用户 ID。登录程序在用户最初登录时设置此 ID,很少更改。
如果程序设置了其 seteuid 位, exec() 函数将设置有效用户 ID。应用程序可以随时调用 seteuid() 将有效用户 ID 设置为真实用户 ID 或保存的设置用户 ID。当有效用户 ID 由 exec() 函数设置时,先前的值将保存在保存的设置用户 ID 中。
限制进程的传统方法是使用 chroot() 系统调用。该系统调用会改变进程及其子进程引用的所有其他路径的根目录。要使此调用成功,进程必须对被引用的目录具有执行(搜索)权限。新环境实际上直到您切换到新环境后才生效。还应该注意,如果进程具有 root 特权,它可以很容易地跳出 chroot 环境。可以通过创建设备节点来读取内核内存,将调试器连接到 chroot 环境外的进程,或以许多其他创造性的方式来实现这一点。
chroot() 系统调用的行为可以通过 kern.chroot_allow_open_directories sysctl 变量来进行一定程度的控制。当此值设置为 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 的属性,如文件权限、所有者、组、大小、访问时间和修改时间。
绑定到互联网域中的特权ports(ports < 1024)
Jail 是一个非常有用的工具,可在安全环境中运行应用程序,但它确实有一些缺点。当前,IPC 机制尚未转换为 suser_xxx ,因此无法在jail中运行诸如 MySQL 之类的应用程序。超级用户访问可能在jail中意味非常有限,但无法精确指定"非常有限"具体意味着什么。
POSIX®发布了一个工作草案,其中添加了事件审计、访问控制列表、细粒度特权、信息标记和强制访问控制。
这是一个正在进行中的工作,是 TrustedBSD 项目的重点。一些最初的工作已经提交到 FreeBSD-CURRENT(cap_set_proc(3))。
应用程序永远不应假设用户环境的任何内容是健全的。这包括(但绝对不限于):用户输入、信号、环境变量、资源、IPC、内存映射、文件系统工作目录、文件描述符、打开文件的数量等。
您永远不应假设自己可以捕获用户可能提供的所有形式的无效输入。相反,您的应用程序应使用积极过滤,只允许您认为安全的特定输入子集。不正确的数据验证是许多漏洞的根源,特别是在全球网络上的 CGI 脚本中。对于文件名,您需要特别注意路径("../","/"),符号链接和shell转义字符。
Perl 有一个非常酷的功能称为“Taint”模式,可用于防止脚本以不安全的方式使用程序外部获取的数据。此模式将检查命令行参数、环境变量、区域设置信息、某些系统调用的结果( readdir() , readlink() , getpwxxx() )以及所有文件输入。
竞争条件是由事件的相对时序的意外依赖引起的异常行为。换句话说,程序员错误地假定一个特定事件总会在另一个事件之前发生。
竞争条件的一些常见原因是信号、访问检查和文件打开。信号本质上是异步事件,因此在处理它们时必须格外小心。用 access(2) 和 open(2) 检查访问明显不是原子操作。用户可以在两次调用之间移动文件。而是,特权应用程序应该首先 seteuid() ,然后直接调用 open() 。沿着同样的思路,应用程序在 open() 之前应该始终设置适当的 umask,以避免产生不必要的 chmod() 调用。
为了使您的应用程序对其他语言的使用者更有用,我们希望您编写符合 I18N 的代码。GNU gcc 编译器和诸如 QT 和 GTK 之类的 GUI 库通过对字符串的特殊处理来支持 I18N。使程序符合 I18N 非常容易。它允许贡献者快速地将您的应用程序翻译成其他语言。有关更多详细信息,请参考特定库的 I18N 文档。
与普遍看法相反,符合 I18N 的代码很容易编写。通常,只需要使用特定库函数包装您的字符串即可。此外,请确保支持宽字符或多字节字符。
我们注意到,每个国家的个别 I18N/L10N 工作一直在重复彼此的努力。我们许多人一再且低效地重复造轮子。我们希望 I18N 中的各个主要团体能够聚集到一个类似核心团队责任的团体努力中。
目前,我们希望当您编写或 port I18N 程序时,您可以将其发送到每个国家相关的 FreeBSD 邮件列表进行测试。未来,我们希望创建能够在所有语言中即插即用而无需肮脏的黑客技巧的应用程序。
已建立 FreeBSD 国际化邮件列表。如果您是 I18N/L10N 开发人员,请发送您的评论、想法、问题以及您认为与之相关的任何内容。
Perl 和 Python 都具有 I18N 和宽字符处理库。请使用它们来实现 I18N 合规性。
超越基本的 I18N 功能,比如支持各种输入编码或支持国家约定,比如不同的十进制分隔符,在更高级别的 I18N 中,可以将各种程序写入输出的消息本地化。 做到这一点的常见方法是使用 POSIX.1 NLS 功能,这些功能作为 FreeBSD 基本系统的一部分提供。
POSIX.1 NLS 基于目录文件,其中包含所需编码的本地化消息。 消息被组织成集合,每条消息在所包含集合中都用整数编号标识。 目录文件通常以包含本地化消息的区域设置命名,后跟 .msg 扩展名。 例如,ISO8859-2 编码的匈牙利消息应存储在名为 hu_HU.ISO8859-2 的文件中。
这些目录文件是常见的文本文件,包含编号的消息。可以通过在行首使用 $ 符号来编写注释。集合边界也由特殊注释分隔,其中关键字 set 必须直接跟在 $ 符号后面。然后 set 关键字跟着集合编号。例如:
实际消息条目以消息编号开头,后跟本地化消息。来自 printf(3)的著名修饰符会被接受:
语言目录文件在能够从程序中打开之前必须被编译成二进制形式。这种转换通过 gencat(1)实用程序完成。它的第一个参数是编译后目录的文件名,后续参数是输入目录。本地化消息也可以组织成多个目录文件,然后所有这些文件都可以通过 gencat(1)处理。
使用目录文件很简单。要使用相关函数,必须包含 nl_types.h。在使用目录之前,必须使用 catopen(3) 打开它。该函数接受两个参数。第一个参数是已安装和编译的目录的名称。通常使用程序的名称,例如 grep。在查找已编译的目录文件时将使用此名称。catopen(3) 调用在 /usr/share/nls/locale/catname 和 /usr/local/share/nls/locale/catname 中查找此文件,其中 locale 是区域设置, catname 是正在讨论的目录名称。第二个参数是一个常量,可以有两个值:
NL_CAT_LOCALE ,这意味着所使用的目录文件将基于 LC_MESSAGES 。
0 ,这意味着必须使用 LANG 打开正确的目录。
catopen(3) 调用返回一个 nl_catd 类型的目录标识符。请参阅手册页以获取可能返回的错误代码列表。
打开目录后,catgets(3) 可用于检索消息。第一个参数是 catopen(3) 返回的目录标识符,第二个是集编号,第三个是消息编号,第四个是回退消息,如果无法从目录文件中检索到请求的消息,将返回该回退消息。
使用目录文件后,必须通过调用 catclose(3)来关闭它,catclose(3)有一个参数,即目录 id。
以下示例将演示如何以灵活的方式使用 NLS 目录的简单解决方案。
下面的行需要放入程序的公共头文件中,该头文件包含所有需要本地化消息的源文件中:
接下来,将这些行放入主源文件的全局声明部分:
接下来是真正的代码片段,打开,读取和关闭目录:
通过使用 libc 错误消息,可以很好地减少需要本地化的字符串。这对于避免重复并为可能遇到的许多程序的常见错误提供一致的错误消息也非常有用。
首先,这里有一个不使用 libc 错误消息的示例:
这可以通过阅读 errno 并相应地打印错误消息来转换为打印错误消息:
在这个例子中,自定义字符串被消除,因此在本地化程序时,翻译人员的工作量会减少,当用户遇到此错误时,他们将看到通常的"不是目录"错误消息。这个消息对他们来说可能更为熟悉。请注意,为了直接访问 errno ,有必要包含 errno.h。
值得注意的是,有些情况下, errno 是由前面的调用自动设置的,因此不需要显式设置它:
使用目录文件需要一些重复的步骤,比如编译目录文件并将其安装到适当的位置。为了进一步简化这个过程,bsd.nls.mk 引入了一些宏。不需要显式包含 bsd.nls.mk,它是从通用的 Makefiles 中引入的,比如 bsd.prog.mk 或 bsd.lib.mk。
通常只需要定义 NLSNAME ,其中应该将作为 catopen(3) 第一个参数提到的目录名称列出,并在 NLS 中列出目录文件,但不包括它们的 .msg 扩展名。这里有一个示例,使得在之前的代码示例中与 NLS 一起使用时可以禁用 NLS。必须定义 WITHOUT_NLS make(1) 变量,以便在没有 NLS 支持的情况下构建程序。
传统上,目录文件放置在 nls 子目录下,这是 bsd.nls.mk 的默认行为。但是,可以通过 NLSSRCDIR make(1)变量覆盖目录的位置。预编译目录文件的默认名称也遵循之前提到的命名约定。可以通过设置 NLSNAME 变量来覆盖它。还有其他选项可以微调目录文件的处理,但通常不需要,因此这里不进行描述。有关 bsd.nls.mk 的更多信息,请参考文件本身,它简短易懂。
[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.
回归测试用于测试系统的特定部分,以确保其按预期工作,并确保旧漏洞不会再次引入。
FreeBSD 回归测试工具可以在 FreeBSD 源码树中的目录 src/tools/regression 找到。
本节包含在 FreeBSD 或 FreeBSD 本身上进行正确微基准测试的提示。
使用所有建议并非每次都可能实现,但使用越多,则基准测试测试小差异的能力就越好。
禁用 APM 和任何其他形式的时钟操作(ACPI?)。
在单用户模式下运行。例如,cron(8)和其他守护程序只会增加噪音。sshd(8)守护程序也可能会引起问题。如果在测试期间需要 ssh 访问,则要么禁用 SSHv1 密钥重新生成,要么在测试期间终止父 sshd 守护程序。
不要运行 ntpd(8)。
如果生成 syslog(3)事件,请使用空的/etc/syslogd.conf 运行 syslogd(8),否则不要运行它。
最小化磁盘 I/O,尽量避免。
不要挂载不需要的文件系统。
如果可能的话,将 /、/usr 和任何其他文件系统挂载为只读。这样可以从 I/O 操作中移除对磁盘的访问时间更新等影响。
在每次运行之前使用 newfs(8) 重新初始化读写测试文件系统,并从 tar(1) 或 dump(8) 文件填充它。开始测试前卸载并挂载它。这将产生一致的文件系统布局。对于 worldstone 测试,这将适用于 /usr/obj(只需使用 newfs 重新初始化并挂载)。为了获得 100% 的重现性,可以从 dd(1) 文件填充文件系统(即: dd if=myimage of=/dev/ad0s1h bs=1m )。
使用 malloc 支持或预加载 md(4)分区。
在测试的各个迭代之间重新启动,这会提供更一致的状态。
从内核中删除所有非必要的设备驱动程序。例如,如果测试不需要 USB,请勿将 USB 放入内核。经常挂接的驱动程序会有计时器正在计时。
卸载未使用的硬件。如果磁盘没有用于测试,请使用 atacontrol(8)和 camcontrol(8)卸载磁盘。
除非正在测试网络,否则不要配置网络,或者在测试完成后将结果发送到另一台计算机。
禁用“涡轮增压模式”,因为它们使时钟频率明确依赖于环境。这意味着在完全相同的代码上运行基准测试可能取决于一天中的时间、咖啡与苏打水的对比,甚至办公室里有多少其他人。
一个很好的模式是: bababa{bbbaaa}* 。这在第 1+1 次运行后给出提示(这样如果测试完全走错了,就可以停止测试),在前 3+3 次运行后给出标准偏差(给出一个很好的指示,是否值得长时间运行),以及稍后的趋势和交互数。* 使用 ministat(1) 看看这些数字是否显著。如果你忘记了或从未学习过标准偏差和学生 T 检验,考虑购买《统计学漫画指南》ISBN: 0062731025,强烈推荐。* 除非测试是后台 fsck 的基准测试,否则不要使用后台 fsck(8)。同样,在启动后至少 60+ fsck 运行时间秒后,在 /etc/rc.conf 中禁用 background_fsck ,因为 rc(8) 会唤醒并检查是否需要在启用后台 fsck 时在任何文件系统上运行 fsck 。同样,确保没有闲置的快照,除非基准测试是带有快照的测试。* 如果基准测试显示意外的糟糕性能,请检查诸如来自意想不到的来源的高中断量之类的问题。有报道称某些版本的 ACPI 会“表现不端”并生成过多的中断。为帮助诊断奇怪的测试结果,拍摄一些 vmstat -i 的快照并寻找任何异常情况。* 一定要小心内核和用户空间的优化参数,以及调试。很容易让一些东西溜走,后来才意识到测试并非在比较相同的事物。* 除非测试感兴趣对这些功能进行基准测试,否则绝不能启用 WITNESS 和 INVARIANTS 内核选项进行基准测试。 WITNESS 可导致性能下降 400%+。同样,在 -CURRENT 中,默认的用户空间 malloc(3) 参数与它们在生产版本中的提供方式不同。
源代码搭配盒包括:
一个构建脚本,燃点盒子(tinderbox),用于自动检出特定版本的 FreeBSD 源代码树并构建它。
一个监督脚本,tbmaster,用于监视各个 Tinderbox 实例,记录它们的输出,并发送失败通知邮件。
一个名为 index.cgi 的 CGI 脚本,读取一组 tbmaster 日志,并呈现这些日志的易读 HTML 摘要。
一组构建服务器,不断测试最重要的 FreeBSD 代码分支的最新版本。
一个 Web 服务器,保留完整的 Tinderbox 日志并显示最新摘要。
对于在这个阶段有关火柴盒和 tbmaster 脚本更多信息,请参阅它们各自的手册页:tinderbox(1)和 tbmaster(1)。
index.cgi 脚本生成火柴盒和 tbmaster 日志的 HTML 摘要。尽管最初设计用作 CGI 脚本,如其名称所示,但该脚本也可以从命令行或 cron(8)作业中运行,在这种情况下,它将查找位于脚本所在目录的日志。当作为 CGI 脚本运行时,它将自动检测上下文,生成 HTTP 头。它符合 XHTML 标准,并使用 CSS 进行样式设置。
脚本在 main() 块开始运行,通过尝试验证它是否正在官方 Tinderbox 网站上运行。如果不是,则生成一个页面,指示这不是官方网站,并提供官方网站的网址。
接下来,它扫描日志目录以获取配置、分支和体系结构的清单,以避免在脚本中硬编码列表,并可能导致空白行或列。这些信息是从日志文件的名称匹配以下模式中获得的:
在官方 Tinderbox 构建服务器上使用的配置是根据它们构建的分支命名的。例如, releng_8 配置用于构建 RELENG_8 以及所有仍受支持的发布分支。
一旦所有这些启动程序都成功完成,每个配置都调用 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 分支排名都高于从中分叉出的发行分支。例如,对于 FreeBSD 8,由高到低的顺序将是:
RELENG_8 (分支等级 899)。
RELENG_8_3 (分支等级 803)。
RELENG_8_2 (分支等级 802)。
RELENG_8_1 (分支等级 801)。
RELENG_8_0 (分支等级 800)。
Tinderbox 在表格中每个单元格使用的颜色由 CSS 定义。成功构建显示为绿色文本;不成功的构建显示为红色文本。随着时间的推移,颜色会逐渐变为灰色,每半小时颜色会更接近灰色。
官方 Tinderbox 构建服务器由 Sentex Data Communications 托管,他们也托管 FreeBSD Netperf 集群。
目前存在三台构建服务器:
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。
免费加拿大(sentex.ca)的构建:
RELENG_7 和支持的 7.X 分支,适用于 amd64、i386、i386/pc98、ia64、powerpc 和 sparc64。
定制官方构建服务器的摘要和日志可在线查看,网址为 http://tinderbox.FreeBSD.org,由 Dag-Erling Smørgrav < des@FreeBSD.org> 托管并设置如下:
一个 cron(8) 作业定期检查构建服务器,并使用 rsync(1) 下载任何新的日志文件。
Apache 被设置为使用 index.cgi 作为 DirectoryIndex 。
BSD sockets 将进程间通信提升到一个新水平。通信进程不再需要在同一台机器上运行。它们仍然可以,但不一定要这样。
这些进程不仅无需在同一台机器上运行,它们也无需在同一操作系统下运行。多亏了 BSD sockets,您的 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 协议中。我们如何从服务器获取它呢?
通过 TCP/IP 在以太网上,就是这样。确实,这是另外三个协议。而不是继续从内到外讲述,我现在要谈论以太网,因为这样更容易解释其余部分。
以太网是一种连接局域网(LAN)中计算机的有趣系统。每台计算机都有一个网络接口卡(NIC),其中包含一个称为地址的独特的 48 位 ID。世界上没有两个以太网 NIC 具有相同的地址。
这些 NIC 都彼此连接在一起。每当一台计算机想要与同一以太网 LAN 中的另一台计算机通信时,它会通过网络发送一条消息。每个 NIC 都会看到这条消息。但作为以太网协议的一部分,数据包含目标 NIC 的地址(以及其他内容)。因此,所有网络接口卡中只有一个会注意到它,其余的会忽略它。
但并非所有计算机都连接到同一网络。仅仅因为我们通过以太网接收到数据并不意味着它起源于我们自己的局域网。它可能来自我们的网络通过互联网连接的其他网络(甚至可能不是基于以太网的网络)。
所有数据都是使用 IP(即互联网协议)在互联网上传输的。它的基本作用是让我们知道数据是从世界上的哪里到达的,以及它应该去哪里。它不能保证我们会接收到数据,只能告诉我们如果接收到数据的话它是从哪里来的。
即使我们接收到数据,IP 也不能保证我们会按照其他计算机发送给我们的顺序接收到不同的数据块。因此,我们可以在接收到图像的左上角之前接收到图像的中心,在接收到图像的右下角之后接收到它,例如。
是 TCP(即传输控制协议)要求发送方重新发送任何丢失的数据,并将其全部按正确的顺序放置。
总而言之,一个计算机向另一个计算机传输图像的过程需要五种不同的协议。我们接收到的数据被包装成 PNG 协议,然后被包装成 HTTP 协议,再被包装成 TCP 协议,接着是 IP 协议,最后是以太网协议。
哦,顺便提一下,可能还涉及到其他几种协议。例如,如果我们的局域网通过拨号连接到互联网,那么会使用调制解调器上的 PPP 协议,该调制解调器又使用各种调制解调器协议,等等,等等,等等……
作为开发者,你现在可能会问,“我应该如何处理这一切?”
幸运的是,您不必处理所有这些。 您只需要处理其中的一部分,而不是全部。 具体来说,您无需担心物理连接(在我们的情况下是以太网和可能的 PPP 等)。 您也无需处理 Internet 协议或传输控制协议。
换句话说,您无需做任何事情来接收来自其他计算机的数据。 好吧,您确实需要请求数据,但这几乎和打开文件一样简单。
一旦您收到数据,就由您决定如何处理它。 在我们的情况下,您需要了解 HTTP 协议和 PNG 文件结构。
使用类比,所有的互联网协议都变成了一个灰色地带:并不是因为我们不理解它是如何工作的,而是因为我们不再关心它。套接字接口为我们处理了这个灰色地带:
图 2. 套接字覆盖的协议层
我们只需要理解告诉我们如何解释数据的任何协议,而不需要了解如何从另一个进程接收数据,也不需要了解如何将数据发送到另一个进程。
BSD 套接字建立在基本的 UNIX® 模型之上:一切皆为文件。在我们的例子中,套接字允许我们接收一个类似 HTTP 文件的内容。然后我们需要从中提取 PNG 文件。
由于互联网工作的复杂性,我们不能仅仅使用 open 系统调用,或者 open() C 函数。相反,我们需要采取几个步骤来“打开”一个套接字。
Once we do, however, we can start treating the socket the same way we treat any file descriptor: We can read
from it, write
to it, pipe
it, and, eventually, close
it.
While FreeBSD offers different functions to work with sockets, we only need four to "open" a socket. And in some cases we only need two.
通常,基于套接字的数据通信的一端是服务器,另一端是客户端。
7.5.1.1.1. socket
客户端和服务器都使用的一个函数是 socket(2)。它是这样声明的:
返回值的类型与 open 相同,都是整数。FreeBSD 从与文件句柄相同的池中分配其值。这使得套接字可以像文件一样对待。
domain 参数告诉系统您希望使用哪种协议族。它们中有很多,有些是特定供应商的,其他则非常常见。它们在 sys/socket.h 中声明。
使用 PF_INET 用于 UDP、TCP 和其他互联网协议(IPv4)。
为 type 参数定义了五个值,在 sys/socket.h 中再次定义。所有这些值都以“SOCK_”开头。最常见的是 SOCK_STREAM ,它告诉系统您正在请求可靠的流传递服务(与 PF_INET 一起使用时为 TCP)。
如果您请求 SOCK_DGRAM ,您将请求无连接的数据报传递服务(在我们的情况下为 UDP)。
如果您想要负责低层协议(如 IP),甚至是网络接口(例如以太网),您需要指定 SOCK_RAW 。
最后, protocol 参数取决于前两个参数,并非始终有意义。在这种情况下,请使用 0 作为其值。
7.5.1.1.2. sockaddr
sockets 系列的各种函数期望内存中的一个小区域的地址(或指针,用 C 术语来说)。sys/socket.h 中的各种 C 声明将其称为 struct sockaddr 。这个结构在同一个文件中声明:
请注意 sa_data 字段声明的模糊性,就像一个 14 字节的数组一样,注释暗示可能不止 14 个。
这种模糊性是有意的。套接字是一个非常强大的接口。尽管大多数人可能认为它只是互联网接口而已 - 现在大多数应用程序可能仅用于此目的 - 套接字可以用于几乎任何类型的进程间通信,其中互联网(或更准确地说是 IP)只是其中之一。
sys/socket.h 是指套接字将处理的各种协议类型作为地址族,并在 sockaddr 的定义之前列出它们。
用于 IP 的一个是 AF_INET。 它是常量 2 的符号。
它是 sockaddr 的 sa_family 字段中列出的地址族,决定了如何使用 sa_data 这些名义模糊的字节。
具体来说,每当地址族是 AF_INET 时,我们可以使用 netinet/in.h 中找到的 struct sockaddr_in ,无论 sockaddr 期望什么:
我们可以这样来可视化它的组织结构:
图 3. sockaddr_in 结构
三个重要字段分别是 sin_family ,即结构的第一个字节, sin_port ,一个在第 2 和第 3 字节中找到的 16 位值,以及 sin_addr ,一个 32 位整数表示的 IP 地址,存储在第 4-7 字节中。
现在,让我们试着填写它。假设我们正在尝试为白天协议编写客户端,该协议简单地表示其服务器将向 port 13 写入表示当前日期和时间的文本字符串。我们想使用 TCP/IP,因此需要在地址族字段中指定 AF_INET 。 AF_INET 被定义为 2 。让我们使用 192.43.244.18 的 IP 地址,这是美国联邦政府的时间服务器( time.nist.gov )。
sockaddr_in 的特定示例图 4。
顺便说一下, sin_addr 字段被声明为 struct in_addr 类型,在 netinet/in.h 中定义:
除此之外, in_addr_t 是一个 32 位整数。
192.43.244.18 仅仅是一个便利的表示法,以列举所有 8 位字节,从最高有效位开始,来表达 32 位整数。
到目前为止,我们把 sockaddr 看作一个抽象概念。 我们的计算机不把 short 整数作为一个单独的 16 位实体来存储,而是作为 2 个字节的序列。类似地,它将 32 位整数存储为 4 个字节的序列。
假设我们编写了这样的代码:
结果会是什么样子呢?
当然,这取决于情况。在基于 Pentium®或其他 x86 的计算机上,它会是这样的:
Intel 系统上的 sockaddr_in 图 5。
在另一个系统上,它可能看起来像这样:
MSB 系统上的 sockaddr_in 图 6。
在 PDP 上可能会看起来有所不同。但以上两种是今天最常见的使用方式。
通常,希望编写可移植代码的程序员会假装这些差异不存在。他们可以逃脱(除非他们用汇编语言编码)。然而,当编写套接字时,你不能那么轻易地逃脱。
为什么?
因为在与另一台计算机通信时,通常不知道它是先存储数据的最高有效字节(MSB)还是最低有效字节(LSB)。
也许你在想,“所以,套接字不会帮我处理吗?”
不会。
While that answer may surprise you at first, remember that the general sockets interface only understands the sa_len
and sa_family
fields of the sockaddr
structure. You do not have to worry about the byte order there (of course, on FreeBSD sa_family
is only 1 byte anyway, but many other UNIX® systems do not have sa_len
and use 2 bytes for sa_family
, and expect the data in whatever order is native to the computer).
But the rest of the data is just sa_data[14]
as far as sockets goes. Depending on the address family, sockets just forwards that data to its destination.
Indeed, when we enter a port number, it is because we want the other computer to know what service we are asking for. And, when we are the server, we read the port number so we know what service the other computer is expecting from us. Either way, sockets only has to forward the port number as data. It does not interpret it in any way.
同样,我们输入 IP 地址告诉路上的每个人数据应该发送到哪里。套接字只是将其作为数据转发。
这就是为什么我们(程序员,而不是套接字)必须区分我们计算机使用的字节顺序和发送数据给其他计算机的传统字节顺序之间的差异。
我们将我们计算机使用的字节顺序称为主机字节顺序,或者只是主机顺序。
在 IP 上发送多字节数据的惯例是先发送最高有效字节。我们将其称为网络字节顺序,或简称网络顺序。
现在,如果我们为基于英特尔的计算机编译上述代码,我们的主机字节顺序将产生:
图 7. 英特尔系统上的主机字节顺序
但网络字节顺序要求我们先将数据存储为 MSB:
图 8. 网络字节顺序
不幸的是,我们的主机顺序恰好与网络顺序相反。
我们有几种处理它的方法。一种方法是在我们的代码中反转值:
这将欺骗我们的编译器将数据存储为网络字节顺序。在某些情况下,这确实是解决问题的方法(例如,在汇编语言中编程时)。然而,在大多数情况下,这可能会导致问题。
假设你用 C 编写了一个基于套接字的程序。你知道它将在 Pentium® 上运行,所以你把所有常量反向输入,并强制它们符合网络字节顺序。这运行良好。
然后,有一天,您信任的旧奔腾®变成了生锈的旧奔腾®。您用一个主机顺序与网络顺序相同的系统来替换它。您需要重新编译所有的软件。除了您写的那个程序之外,所有软件继续表现良好。
自那以后,您已经忘记了您曾经强制所有常量为相反的主机顺序。您花费一些质量时间拔掉您的头发,叫出您听说过的所有神明的名字(和一些您编造的),用软皮球棒击打您的监视器,并执行尝试弄清为什么一切运作良好的事情突然完全不起作用的所有其他传统仪式。
最终,您弄清楚了,说了几句脏话,然后开始重新编写代码。
幸运的是,你不是第一个面对这个问题的人。其他人已经创建了 htons(3)和 htonl(3) C 函数,用于将主机字节顺序转换为网络字节顺序中的 short 和 long ,以及用于另一种方式的 ntohs(3)和 ntohl(3) C 函数。
在 MSB 优先系统上,这些函数不起作用。在 LSB 优先系统上,它们会将值转换为正确的顺序。
因此,无论你的软件在哪种系统上编译,如果你使用这些函数,你的数据都将按正确的顺序排列。
通常情况下,客户端会发起与服务器的连接。客户端知道它要调用哪个服务器:它知道服务器的 IP 地址,并且知道服务器所在的 port。这类似于你拿起电话拨打号码(地址),然后在有人接听后,询问负责 wingdings 的人(port)。
7.5.1.2.1. connect
一旦客户端创建了一个套接字,它需要将其连接到远程系统上的特定 port。它使用 connect(2):
s 参数是套接字,即 socket 函数返回的值。 name 是指向我们广泛讨论过的 sockaddr 结构的指针。最后, namelen 通知系统我们的 sockaddr 结构中有多少字节。
如果 connect 成功,则返回 0 。否则返回 -1 并将错误代码存储在 errno 中。
有许多原因可能导致 connect 失败。例如,尝试连接到互联网时,IP 地址可能不存在,或者它可能已关闭,或者忙得不可开交,或者在指定的 port 处没有服务器在监听。或者它可能直接拒绝对特定代码的任何请求。
7.5.1.2.2. 我们的第一个客户
现在我们已经了解足够的信息,可以编写一个非常简单的客户端,该客户端将从 192.43.244.18 获取当前时间并将其打印到标准输出。
继续,在您的编辑器中输入它,保存为 daytime.c,然后编译并运行它:
在这种情况下,日期是 2001 年 6 月 19 日,时间是 02:29:25 UTC。当然,您的结果会有所不同。
典型的服务器不会发起连接。相反,它等待客户端调用并请求服务。它不知道客户端何时会调用,也不知道会有多少客户端会调用。它可能会静静地坐在那里等待,一会儿的功夫,下一刻可能会发现自己被来自多个客户端的请求淹没,所有客户端都在同一时间内呼叫。
套接字接口提供了三个基本函数来处理这个。
7.5.1.3.1. bind
Ports 就像是电话线的分机:拨打号码后,您可以拨分机号码来联系特定的人员或部门。
IP ports 有 65535 个,但服务器通常只处理其中一个进来的请求。这就像告诉电话室操作员我们已经上班,并且可以在特定的分机上接听电话。我们使用 bind(2)告诉套接字我们要服务的port。
除了在 addr 中指定port外,服务器可能还包括其 IP 地址。但是,它可以只使用象征常数 INADDR_ANY 来指示它将为指定的port处理所有请求,而不管其 IP 地址是什么。这个符号以及其他几个类似的符号在 netinet/in.h 中声明。
假设我们正在为 TCP/IP 上的白天协议编写服务器。回想一下,它使用端口port 13。我们的 sockaddr_in 结构将如下所示:
图 9. 示例服务器 sockaddr_in
7.5.1.3.2. listen
继续我们办公电话类比,告诉电话总机您将在哪个分机后,您现在走进办公室,确保自己的电话已插好线并且铃声已打开。此外,确保您已激活呼叫等待功能,这样您在与某人通话时也能听到电话响铃。
服务器通过 listen(2) 函数确保所有这些。
在这里, backlog 变量告诉套接字在您忙于处理上一个请求时要接受多少传入请求。换句话说,它确定了挂起连接队列的最大大小。
7.5.1.3.3. accept
在你听到电话响的声音之后,通过接听电话来接受通话。您现在已与您的客户建立了连接。此连接保持活动状态,直到您或您的客户挂断电话。
服务器使用 accept(2)函数接受连接。
请注意,此时 addrlen 是一个指针。这是必要的,因为在这种情况下,是套接字填充 addr , sockaddr_in 结构。
返回值是一个整数。实际上, accept 返回一个新的套接字。您将使用这个新套接字与客户端通信。
旧套接字会发生什么?它会继续监听更多请求(记住我们传递给 listen 的 backlog 变量吗?)直到我们 close 它。
现在,新套接字仅用于通信。它已完全连接。我们不能再次将其传递给 listen ,尝试接受其他连接。
7.5.1.3.4. 我们的第一个服务器
我们的第一个服务器将比我们的第一个客户端复杂一些:我们不仅有更多的套接字函数可用,而且需要将其编写为守护进程。
最好的方法是在绑定port后创建一个子进程。然后主进程退出并将控制权返回给调用它的shell(或任何其他调用它的程序)。
该子项调用 listen ,然后启动一个无限循环,该循环接受连接、为其提供服务,最终关闭套接字。
我们首先创建一个套接字。然后在 sa 中填写 sockaddr_in 结构。注意对 INADDR_ANY 的条件性使用:
它的值是 0 。由于我们刚刚使用 bzero 的整个结构,将其再次设置为 0 是多余的。但是如果我们将我们的代码移植到另一个系统,其中 INADDR_ANY 或许不是零,那么我们需要将其分配给 sa.sin_addr.s_addr 。大多数现代 C 编译器足够聪明,注意到 INADDR_ANY 是一个常量。只要它是零,它们就会优化整个条件语句从代码中移除。
在我们成功地调用 bind 后,我们准备成为守护程序:我们使用 fork 来创建一个子进程。在父进程和子进程中, s 变量是我们的套接字。父进程不需要它,所以它调用 close ,然后返回 0 以通知它自己的父进程它已成功终止。
与此同时,子进程继续在后台工作。它调用 listen 并将其后备设置为 4 。这里不需要一个很大的值,因为白天不是许多客户端一直请求的协议,并且因为它可以立即处理每个请求。
最后,守护程序启动一个无休止的循环,执行以下步骤:
调用 accept 。它在这里等待,直到客户端联系。此时,它会接收一个新的套接字 c ,用于与特定客户端通信。
使用 C 函数 fdopen 将套接字从低级文件描述符转换为 C 风格的 FILE 指针。这将允许稍后使用 fprintf 。
检查时间,并以 ISO 8601 格式打印到 client "文件"。然后使用 fclose 关闭文件。这将自动关闭套接字。
我们可以概括这一点,并将其用作许多其他服务器的模型:
图 10. 顺序服务器
这个流程图适用于顺序服务器,即一次只能为一个客户提供服务的服务器,就像我们之前在白天服务器中所能做的一样。只有在客户端和服务器之间没有真正的“对话”时才有可能:一旦服务器检测到与客户端的连接,它就会发送一些数据然后关闭连接。整个操作可能只需几纳秒,就完成了。
这个流程图的优点是,除了父 fork 退出之前的短暂时刻外,始终只有一个进程处于活动状态:我们的服务器不会占用太多内存和其他系统资源。
请注意,我们在流程图中添加了初始化守护程序。我们不需要初始化自己的守护程序,但这是程序流程中设置任何 signal 处理程序、打开可能需要的任何文件等的好地方。
流程图中的几乎所有内容都可以在许多不同的服务器上直接使用。serve 条目是个例外。我们将其视为一个“黑匣子”,即,您专门为自己的服务器设计的东西,只需“将其插入其余部分”即可。
并非所有协议都那么简单。许多协议接收来自客户端的请求,然后回复该请求,接着再次接收来自同一客户端的请求。因此,它们事先无法知道为客户端提供服务的时长。这类服务器通常为每个客户端启动一个新进程。在新进程为其客户端提供服务时,守护程序可以继续侦听更多连接。
现在,继续吧,将上述源代码保存为 daytimed.c(以字母 0 结尾的守护程序名称是惯例)。编译后,尝试运行它:
发生了什么?正如你所记得的,白天协议使用 13。但是 1024 以下的所有端口都保留给超级用户(否则,任何人都可以启动一个假装为常用端口提供服务的守护程序,从而造成安全漏洞)。
请再试一次,这次以超级用户身份:
什么... 什么也没有?让我们再试一次:
每个port 一次只能由一个程序绑定。我们的第一次尝试确实成功了:它启动了子守护程序并静静地返回。它仍在运行,并将继续运行,直到您杀死它,或者它的任何系统调用失败,或者您重新启动系统。
好吧,我们知道它在后台运行。但它是否正常工作呢?我们如何知道它是一个适合白天运行的服务器呢?简单:
telnet 尝试了新的 IPv6,但失败了。它改用 IPv4 再次尝试,成功了。守护进程有效。
如果您可以通过 telnet 访问另一个 UNIX®系统,则可以使用它来测试远程访问服务器。我的计算机没有静态 IP 地址,所以我这样做:
再次,它有效。使用域名会有效吗?
顺便说一下,telnet 在我们的守护程序关闭套接字后打印“连接由外部主机关闭”消息。这向我们表明,确实,在我们的代码中使用 fclose(client); 是有效的。
FreeBSD C 库包含许多用于套接字编程的辅助函数。例如,在我们的示例客户端中,我们硬编码了 time.nist.gov IP 地址。但我们并不总是知道 IP 地址。即使我们知道,如果软件允许用户输入 IP 地址甚至域名,它会更加灵活。
gethostbyname
虽然没有办法将域名直接传递给任何套接字函数,但 FreeBSD C 库带有在 netdb.h 中声明的 gethostbyname(3) 和 gethostbyname2(3) 函数。
两者都返回指向 hostent 结构的指针,其中包含许多关于域的信息。就我们的目的而言,结构的 h_addr_list[0] 字段指向已存储在网络字节顺序中的 h_length 字节的正确地址。
这使我们能够创建一个更加灵活和更有用的白天程序的版本:
现在,我们可以在命令行上输入域名(或 IP 地址,两者均可),程序将尝试连接到其白天服务器。否则,它仍将默认为 time.nist.gov 。但是,即使在这种情况下,我们也将使用 gethostbyname 而不是硬编码 192.43.244.18 。这样,即使其 IP 地址在未来发生变化,我们仍然可以找到它。
由于从您的本地服务器获取时间几乎不需要时间,您可以连续两次运行白天:首先从 time.nist.gov 获取时间,第二次从您自己的系统获取时间。然后,您可以比较结果,看看您的系统时钟是多么精确。
正如您所看到的,我的系统比 NIST 时间提前两秒。
getservbyname
有时候,您可能不确定某个服务使用了什么port。在这种情况下,getservbyname(3)函数非常方便,该函数也在 netdb.h 中声明:
结构体 servent 包含了 s_port ,其中包含了已经按网络字节顺序排列的正确port。
如果我们不知道白天服务的正确port,我们可以通过这种方式找到它:
通常您确实知道port。 但是,如果您正在开发新协议,可能会在非官方port上进行测试。 将来的某一天,您将会注册协议及其port(如果不在别处,在您的 / etc / services 中至少会有 getservbyname )。 在上述代码中,而不是返回错误,您只需使用临时port号码。 一旦您在 / etc / services 中列出了协议,您的软件将自动找到其port,而无需重新编写代码。
与顺序服务器不同,并发服务器必须能够同时为多个客户端提供服务。例如,聊天服务器可能会为特定客户端提供几个小时的服务,它不能等到停止为一个客户端提供服务后再为下一个客户端提供服务。
这要求我们的流程图有显著变化:
图 11. 并发服务器
我们将服务器从守护进程移动到自己的服务器进程中。但是,由于每个子进程继承所有打开的文件(而套接字被视为文件),新进程不仅继承了“已接受句柄”即 accept 调用返回的套接字,还继承了顶部套接字,即顶部进程在一开始打开的套接字。
但是,服务器进程不需要此套接字,应立即 close 。同样,守护进程不再需要已接受的套接字,不仅应该,而且必须 close 它,否则,迟早会耗尽可用的文件描述符。
在服务器进程完成服务后,应关闭已接受的套接字。它现在退出,而不是返回 accept 。
在 UNIX®下,一个进程并不真正退出。相反,它会返回给其父进程。通常,父进程会等待其子进程,并获取返回值。然而,我们的守护进程不能简单地停止并等待。那样会背离创建额外进程的初衷。但如果它永远不这样做,其子进程将变成僵尸-不再起作用但仍然在系统中游荡。
出于这个原因,守护进程需要在其初始化守护进程阶段设置信号处理程序。至少要处理一个 SIGCHLD 信号,这样守护进程可以从系统中移除僵尸返回值,并释放它们占用的系统资源。
这就是为什么我们的流程图现在包含一个处理信号的框,它不连接到任何其他框。顺便说一句,许多服务器也处理 SIGHUP 信号,并通常解释为超级用户发出的信号,告诉它们应重新读取其配置文件。这使我们能够在无需终止和重新启动这些服务器的情况下更改设置。
运行开发内核(例如 FreeBSD-CURRENT)时,例如在极端条件下的内核(例如非常高的负载平均值,成千上万的连接,极高数量的并发用户,数百个jail(8)等),或者在 FreeBSD-STABLE 上使用新功能或设备驱动程序(例如 PAE),有时内核会发生崩溃。如果发生崩溃,本章将演示如何从崩溃中提取有用信息。
一旦内核发生崩溃,系统重启是不可避免的。一旦系统重新启动,系统的物理内存(RAM)的内容将丢失,以及在崩溃之前位于交换设备上的任何位。为了保留物理内存中的位,内核利用交换设备作为在崩溃后跨重启存储在 RAM 中的位的临时位置。通过这种方式,当 FreeBSD 在崩溃后启动时,现在可以提取内核映像,并进行调试。
可用几种类型的内核崩溃转储:
完整内存转储保存物理内存的完整内容。
迷你转储仅保存内核正在使用的内存页面(FreeBSD 6.2 及更高版本)。
TextdumpsHold 捕获、脚本化或交互式调试器输出(FreeBSD 7.1 及更高版本)。
Minidumps 是 FreeBSD 7.0 的默认转储类型,在大多数情况下将捕获完整内存转储中存在的所有必要信息,因为大多数问题只能使用内核状态来隔离。
内核将转储其物理内存的内容到转储设备之前,必须配置转储设备。 使用 dumpon(8)命令指定转储设备,告诉内核在哪里保存内核崩溃转储。 在交换分区使用 swapon(8)配置后,必须调用 dumpon(8)程序。 通常通过在 rc.conf(5)中设置 dumpdev 变量为交换设备路径(提取内核转储的推荐方式)或 AUTO 以使用第一个配置的交换设备来处理。 在 HEAD 中,默认为 dumpdev ,在 RELENG_*分支上更改为 NO (除了 RELENG_7,它保持设置为 AUTO )。 在 FreeBSD 9.0-RELEASE 和更高版本中,bsdinstall 将在安装过程中询问是否应在目标系统上启用崩溃转储。
此外,请记住/var/crash 的内容是敏感的,很可能包含诸如密码之类的机密信息。
一旦将转储写入转储设备,必须在挂载交换设备之前提取转储。要从转储设备中提取转储,请使用 savecore(8)程序。如果在 rc.conf(5)中设置了 dumpdev ,则在崩溃后的第一次多用户引导时,将自动调用 savecore(8),在交换设备挂载之前。提取的核心位置放置在 rc.conf(5)值 dumpdir 中,默认为/var/crash,并将命名为 vmcore.0。
如果/var/crash 中已经存在名为 vmcore.0 的文件(或者设置为 dumpdir 的任何内容),内核将为每次崩溃递增尾随数字,以避免覆盖现有的 vmcore(例如,vmcore.1)。在保存转储后,savecore(8)将始终在/var/crash 中创建一个名为 vmcore.last 的符号链接。此符号链接可用于查找最近转储的名称。
crashinfo(8) 实用程序生成一个文本文件,其中包含来自完整内存转储或迷你转储的信息摘要。如果在 rc.conf(5) 中设置了 dumpdev ,则在 savecore(8) 之后会自动调用 crashinfo(8)。输出保存在名为 core.txt.N 的文件中。
这会指示 savecore(8) 从 /dev/ad0s1b 中提取一个内核转储,并将内容放在 /var/crash 中。不要忘记确保目标目录 /var/crash 有足够的空间来存储转储。还要记得指定正确的交换设备路径,因为它很可能与 /dev/ad0s1b 不同!
内核包含一个 sysctl(8) 节点,用于请求内核崩溃。这可用于验证系统是否正确配置以保存内核崩溃转储。在触发崩溃之前,您可能希望在单用户模式下将现有文件系统重新挂载为只读,以避免数据丢失。
重新启动后,您的系统应在 /var/crash 中保存一个转储文件,并附有来自 crashinfo(8) 的匹配摘要。
要进入调试器并开始从转储中获取信息,请启动 kgdb:
其中 N 是要检查的 vmcore.N 的后缀。要打开最近的转储,请使用:
通常情况下,kgdb(1) 应该能够定位在生成转储时运行的内核。如果无法定位正确的内核,请将内核的路径名和转储作为两个参数传递给 kgdb:
您可以像调试任何其他程序一样使用内核源代码来调试崩溃转储。
这个转储文件来自于一个 5.2-BETA 内核,崩溃是在内核深处发生的。下面的输出已经经过修改,在左侧包含了行号。这个第一次跟踪检查指令指针并获得一个回溯。在第 41 行使用的地址已经在第 17 行找到,用于 list 命令的指令指针。如果您无法自行调试出问题,大多数开发人员会要求至少要将这些信息发送给他们。但是,如果您解决了问题,请确保您的补丁通过问题报告、邮件列表或能够提交它的方式进入源码树!
虽然 kgdb 作为一种脱机调试器提供了非常高级的用户界面,但它无法做一些事情。最重要的是断点设置和单步执行内核代码。
如果您需要在内核上进行低级调试,可以使用名为 DDB 的在线调试器。它允许设置断点,逐步执行内核函数,检查和更改内核变量等。但是,它无法访问内核源文件,只能访问全局和静态符号,而不能像 kgdb 那样访问完整的调试信息。
要配置内核以包含 DDB,请添加选项
到您的配置文件,并重新构建。(有关配置 FreeBSD 内核的详细信息,请参阅 FreeBSD 手册)。
一旦您的 DDB 内核运行起来,有几种方法可以进入 DDB。第一种,也是最早的方法是使用引导标志 -d 。内核将以调试模式启动,并在任何设备探测之前进入 DDB。因此,您甚至可以调试设备的探测/附加功能。要使用此功能,请退出加载程序的引导菜单,并在加载程序提示符处输入 boot -d 。
第二种情况是在系统引导后立即转到调试器。有两种简单的方法可以实现这一点。如果您想从命令提示符中断到调试器,请简单地键入以下命令:
或者,如果您在系统控制台上,可以使用键盘上的热键。默认的中断到调试器序列是 Ctrl+Alt+ESC。对于 syscons,此序列可以重新映射,一些分发的映射已经这样做了,因此请确保您知道要使用的正确序列。对于允许在控制台线上使用串行线 BREAK 进入 DDB 的串行控制台,有一个选项( options BREAK_TO_DEBUGGER 在内核配置文件中)。这不是默认选项,因为周围有很多不必要生成 BREAK 条件的串行适配器,例如在拔下电缆时。
第三种方式是,如果内核配置为使用它,任何恐慌条件都将分支到 DDB。出于这个原因,配置一个带有 DDB 的内核不明智,用于运行无人看管的机器。
要获得无人看管功能,请添加:
到内核配置文件中并重新构建/重新安装。
DDB 命令大致类似于某些 gdb 命令。您可能需要做的第一件事情是设置断点:
默认情况下,数字被视为十六进制,但为了使它们与符号名称区分开;以字母 a-f 开头的十六进制数需要在前面加上 0x (对于其他数字而言,这是可选的)。可以使用简单的表达式,例如: function-name + 0x103 .
要退出调试器并继续执行,请键入:
要获取当前线程的堆栈跟踪,请使用:
要获取任意线程的堆栈跟踪,请将进程 ID 或线程 ID 指定为第二个参数 trace 。
如果要删除断点,请使用
第一种形式在断点命中后立即被接受,并删除当前断点。第二种形式可以移除任何断点,但您需要指定确切的地址;这可以从中获得:
或:
要单步执行内核,请尝试:
这将进入函数,但你可以让 DDB 追踪它们,直到达到匹配的返回语句:
要从内存中检查数据,请使用(例如):
用于字/半字/字节访问,以及十六进制/十进制/字符/字符串显示。逗号后面的数字是对象计数。要显示接下来的 0x10 个项目,只需使用:
同样,使用
来反汇编 foofunc 的前 0x10 条指令,并显示它们以及它们距离 foofunc 起始处的偏移量。
用 write 命令修改内存:
命令修饰符 ( b / h / w ) 指定要写入的数据的大小,随后的第一个表达式是要写入的地址,其余被解释为要写入连续内存位置的数据。
如果你需要了解当前寄存器,请使用:
或者,您可以通过例如显示单个寄存器值
并通过修改它
如果您需要从 DDB 调用一些内核函数,只需说
返回值将被打印。
要获取所有运行进程的 ps(1) 风格摘要,请使用:
现在您已经检查了内核失败的原因,并希望重新启动。请记住,根据先前故障的严重程度,内核的某些部分可能仍无法正常工作。执行以下操作之一以关闭并重新启动系统:
这将导致您的内核转储核心并重新启动,因此您可以稍后使用 kgdb(1)在更高级别上分析核心。
这可能是一个干净关闭运行系统的好方法, sync() 所有磁盘,并最终,在某些情况下,重新启动。只要内核的磁盘和文件系统接口没有损坏,这可能是一个几乎干净的关机方式。
这是灾难中的最后一招,几乎与按下大红按钮相同。
如果您需要简短的命令摘要,请输入:
强烈建议在调试会话中准备打印版本的 ddb(4) 手册页面。请记住,在单步执行内核时很难阅读在线手册。
FreeBSD 内核为在线调试提供了第二个 KDB 后端:gdb(4)。自 FreeBSD 2.2 以来就支持此功能,实际上非常方便。
GDB 长期以来一直支持远程调试。这是通过串行线路沿着一个非常简单的协议完成的。与上述其他调试方法不同,您需要两台机器来执行此操作。一台是提供调试环境的主机,其中包括所有源代码以及一个包含所有符号的内核二进制副本。另一台是运行完全相同内核副本(可选地删除了调试信息)的目标机器。
要使用远程 GDB,请确保您的内核配置中存在以下选项:
请注意,在-STABLE 和-RELEASE 分支的内核中, GENERIC 默认情况下关闭了 GDB 选项,但在-CURRENT 上启用。
构建完成后,将内核复制到目标机器,并引导它。将目标机器的串行线连接到调试主机的任何串行线,其中该串行线上的 uart 设备设置为"flags 080"。有关如何设置 uart 设备上标志的信息,请参阅 uart(4)。
目标机器必须使其进入 GDB 后端,要么由于发生紧急情况,要么通过有意诱发陷阱进入调试器。在执行此操作之前,选择 GDB 调试器后端:
然后,强制进入调试器:
目标机现在等待远程 GDB 客户端的连接。在调试机上,前往目标内核的编译目录,并启动 gdb :
通过以下方式启动远程调试会话(假设正在使用第一个串行port):
您的主机 GDB 现在将控制目标内核:
您几乎可以像使用其他 GDB 会话一样使用此会话,包括完全访问源代码,在 Emacs 窗口中以 gud 模式运行它(这将在另一个 Emacs 窗口中自动显示源代码等)。
由于您需要一个控制台驱动程序来运行 DDB,如果控制台驱动程序本身出现故障,情况就会变得更加复杂。您可能会记得使用串行控制台(要么使用修改后的引导块,要么在提示符处指定 -h ),并将标准终端连接到您的第一个串行port。DDB 可在任何配置的控制台驱动程序上运行,包括串行控制台。
您可能会遇到所谓的死锁,这是一个系统停止执行有用工作的情况。在这种情况下,为了提供有用的 bug 报告,请使用上一节中描述的 ddb(4)。在报告中包含 ps 和 trace 的疑似进程的输出。
如果可能的话,考虑进行进一步调查。如果您怀疑死锁发生在 VFS 层,请使用下面的方法。将这些选项添加到内核配置文件中。
当发生死锁时,除了执行 ps 命令的输出外,还提供来自 show pcpu , show allpcpu , show locks , show alllocks , show lockedvnods 和 alltrace 的信息。
要为线程化进程获取有意义的回溯信息,请使用 thread thread-id 切换到线程堆栈,并使用 where 进行回溯。
dcons(4)是一个非常简单的控制台驱动程序,不直接与任何物理设备连接。它只是从内核或加载程序中的缓冲区读取和写入字符。由于其简单的特性,它在内核调试中非常有用,特别是与 FireWire®设备一起使用。目前,FreeBSD 提供了两种与缓冲区进行外部交互的方式,使用 dconschat(8)。
大多数 FireWire®(IEEE1394)主机控制器都基于支持对主机内存进行物理访问的 OHCI 规范。这意味着一旦主机控制器初始化,我们可以在没有软件(内核)帮助的情况下访问主机内存。我们可以利用这个功能与 dcons(4)进行交互。dcons(4)提供类似于串行控制台的功能。它模拟两个串行端口,一个用于控制台和 DDB,另一个用于 GDB。由于远程内存访问完全由硬件处理,即使系统崩溃,dcons(4)缓冲区也是可访问的。
FireWire® 设备不仅限于集成到主板中。桌面上有 PCI 卡可用,笔记本电脑可以购买 CardBus 接口。
要在目标机器的内核中启用 FireWire®和 Dcons 支持:
确保您的内核支持 dcons , dcons_crom 和 firewire 。 Dcons 应该与内核静态链接。对于 dcons_crom 和 firewire ,模块应该没问题。
确保启用物理 DMA。您可能需要将 hw.firewire.phydma_enable=1 添加到 /boot/loader.conf 中。
添加用于调试的选项。
如果您使用 GDB 在 FireWire®上,请在/boot/loader.conf 中添加 dcons_gdb=1 。
在/etc/ttys 中启用 dcons 。
可选地,要强制 dcons 成为高级控制台,请将 hw.firewire.dcons_crom.force_console=1 添加到 loader.conf 中。
在 i386 或 amd64 上启用 FireWire® 和 Dcons 支持,请在 loader(8) 中添加以下内容:
在 /etc/make.conf 中添加 LOADER_FIREWIRE_SUPPORT=YES 并重新构建 loader(8):
要将 dcons(4) 作为主动低级控制台启用,请将 boot_multicons="YES" 添加到 /boot/loader.conf。
这里有一些配置示例。一个样本内核配置文件将包含:
一个样本 /boot/loader.conf 将包含:
在主机上的内核中启用 FireWire® 支持:
找出 FireWire® 主机控制器的唯一 64 位标识符(EUI64),并使用 fwcontrol(8) 或 dmesg 找到目标机器的 EUI64。
运行 dconschat(8),使用:
一旦 dconschat(8) 运行,可以使用以下组合键:
使用远程调试会话启动 kgdb(1)以附加远程 GDB:
这里有一些一般提示:
利用 FireWire® 的速度优势,禁用其他慢速控制台驱动程序:
emacs(1) 存在一个 GDB 模式;这是您需要添加到您的 .emacs 的内容:
以及 DDD(devel/ddd):
我们可以直接通过 /dev/mem 读取 dcons(4) 缓冲区以获取活动系统的信息,并在崩溃系统的核心转储中获取。这些为您提供类似于 dmesg -a 的输出,但 dcons(4) 缓冲区包含更多信息。
使用 dcons(4) 与 KVM:
转储活动系统的 dcons(4) 缓冲区:
转储崩溃转储的 dcons(4) 缓冲区:
可以通过实时核心调试进行调试:
本节简要介绍了用于调试的编译时内核选项的术语表
options KDB :在内核调试器框架中编译。对 options DDB 和 options GDB 必需。几乎没有性能开销。默认情况下,在发生紧急情况时会进入调试器,而不是自动重新启动。
options KDB_UNATTENDED :将 debug.debugger_on_panic sysctl 的默认值更改为 0,该值控制发生紧急情况时是否进入调试器。当 options KDB 未编译到内核中时,行为是在发生紧急情况时自动重新启动;当它编译到内核中时,默认行为是进入调试器,除非 options KDB_UNATTENDED 也编译进去。如果您想将内核调试器编译到内核中,但希望系统在您未使用调试器进行诊断时重新启动,请使用此选项。
options KDB_TRACE :将 debug.trace_on_panic sysctl 的默认值更改为 1,该值控制发生紧急情况时调试器是否自动打印堆栈跟踪。特别是在运行时,这对于在串行或 firewire 控制台上收集基本调试信息并仍然重新启动以恢复可能很有帮助。
options DDB :在系统的活动低级控制台上运行的交互式调试器,DDB 的支持编译。这包括视频控制台、串行控制台或 FireWire 控制台。它提供基本的集成调试功能,如堆栈跟踪、进程和线程列表、锁状态转储、VM 状态、文件系统状态和内核内存管理。DDB 不需要在第二台机器上运行软件或能够生成核心转储或完整的调试内核符号,并提供运行时内核的详细诊断。许多错误可以仅使用 DDB 输出进行完全诊断。此选项取决于 options KDB 。
options GDB :编译支持远程调试器 GDB,它可以通过串行电缆或 FireWire 操作。当进入调试器时,GDB 可以附加以检查结构内容、生成堆栈跟踪等。有些内核状态比在 DDB 中更难访问,DDB 能够自动生成内核状态的有用摘要,如自动遍历锁调试或内核内存管理结构,需要第二台运行调试器的机器。另一方面,GDB 结合了内核源代码和完整的调试符号的信息,并且了解完整的数据结构定义、本地变量,并且可编写脚本。此选项不需要在内核核心转储上运行 GDB。此选项取决于 options KDB 。
options BREAK_TO_DEBUGGER , options ALT_BREAK_TO_DEBUGGER :允许在控制台上发送中断信号或替代信号进入调试器。如果系统在没有恐慌的情况下挂起,这是一个有用的进入调试器的方法。由于当前内核锁定,在串行控制台上生成的中断信号更可靠地进入调试器,并且通常建议使用。此选项几乎不会对性能产生影响。
options INVARIANTS :将大量运行时断言检查和测试编译到内核中,不断测试内核数据结构的完整性和内核算法的不变性。这些测试可能很昂贵,因此默认情况下不会编译进去,但有助于提供有用的“故障停止”行为,在内核数据损坏发生之前,某些类别的不良行为会进入调试器,使它们更容易调试。测试包括内存擦除和使用后释放测试,这是开销较大的重要开销来源之一。此选项取决于 options INVARIANT_SUPPORT 。
options INVARIANT_SUPPORT : options INVARIANTS 中的许多测试需要修改的数据结构或额外的内核符号来定义。
options WITNESS :此选项启用运行时锁定顺序跟踪和验证,是死锁诊断的宝贵工具。WITNESS 通过锁类型维护已获取的锁定顺序图,并在每次获取时检查图表中的循环(隐式或显式)。如果检测到循环,将向控制台生成警告和堆栈跟踪,指示可能发生死锁。为了使用 show locks 、 show witness 和 show alllocks DDB 命令,需要 WITNESS。此调试选项具有显着的性能开销,可以通过使用 options WITNESS_SKIPSPIN 来在一定程度上减轻。详细文档可在 witness(4)中找到。
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 :跟踪锁管理器/ vnode 锁的锁获取点,扩展了 DDB 中显示的信息量。此选项会对性能产生可衡量的影响。
options DEBUG_MEMGUARD :malloc(9) 内核内存分配器的替代品,使用 VM 系统来检测释放后分配内存的读取或写入。详细信息可在 memguard(9) 中找到。此选项会产生显著的性能影响,但在调试内核内存损坏错误时非常有帮助。
options DIAGNOSTIC :启用额外的、更昂贵的诊断测试,沿着 options INVARIANTS 的方向。
options KASAN :启用内核地址消毒剂。这将启用编译器插桩,可用于检测内核中的无效内存访问,如释放后使用和缓冲区溢出。这在很大程度上取代了 options DEBUG_MEMGUARD 。有关详细信息,请参阅 kasan(9),以及当前支持的平台。
options KMSAN :启用内核内存消毒剂。这将启用编译器插桩,可用于检测未初始化内存的使用。有关详细信息,请参阅 kmsan(9),以及当前支持的平台。
如果系统必须连接到公共网络,请注意广播流量的突发增加。即使几乎察觉不到,它也会占用 CPU 周期。多播有类似的注意事项。* 将每个文件系统放在自己的磁盘上。这样可以最大程度地减少由于磁头寻道优化而产生的抖动。* 尽量减少输出到串行或 VGA 控制台。将输出运行到文件中可以减少抖动。(串行控制台很容易成为瓶颈。)在测试运行时不要触摸键盘,即使是空格键或退格键也会在数字中显示出来。* 确保测试时间足够长,但不要太长。如果测试时间太短,时间戳会成为问题。如果时间太长,温度变化和漂移将影响计算机中石英晶体的频率。经验法则:超过一分钟,少于一小时。* 尽量保持机器周围的温度尽可能稳定。这会影响石英晶体和磁盘驱动器算法。要获得真正稳定的时钟,请考虑稳定的时钟注入。例如,获取 OCXO + PLL,将输出注入时钟电路而不是主板晶体。有关更多信息,请联系 Poul-Henning Kamp 。* 至少运行测试 3 次,但最好运行 20 次以上,分别对“之前”和“之后”的代码。尽可能交错运行(即:不要先运行 20 次之前,然后再运行 20 次之后),这样可以发现环境影响。不要 1:1 交错,而是 3:3,这样可以发现交互效应。
这些脚本由 Dag-Erling Smørgrav 维护和开发,现在使用 Perl 编写,而不是原始版本中的shell脚本。所有脚本和配置文件都保存在/projects/tinderbox/中。
未连接的套接字 现在,在 socket 函数中,我们并没有指定连接到哪个其他系统。我们新创建的套接字仍然保持未连接状态。 这是有意为之:用电话类比来说,我们只是把调制解调器连接到了电话线上。我们既没有告诉调制解调器拨号,也没有告诉它在电话响时接听。
根据使用的编译器,一些局部变量可能显示为 ,直接通过 gdb 检查可能会有问题。如果这在调试时造成问题,则可以通过向 make(1)传递 COPTFLAGS=-O1 来以降低的优化级别构建内核。然而,当优化级别更改时,某些内核错误类别可能会以不同的方式(或根本不会)显现。
~
ALT 断开
~
重置目标
~
挂起 dconschat
本节应解释与 IPv6 和 IPsec 相关的实现内部。这些功能源自 KAME 项目
IPv6 相关功能符合或试图符合最新的 IPv6 规范集。为了以后参考,我们列出以下一些相关文件(注意:这不是一个完整的列表——这太难维护了……)。
详情请参阅文档中的具体章节、RFCs、手册页面或源代码中的注释。
在 KAME 稳定套件上进行了符合性测试,结果可以在 TAHI 项目的网站上查看。我们过去也参加过新罕布什尔大学 IOL 测试(http://www.iol.unh.edu/),使用我们以前的快照。
RFC1639:FTP 大地址记录上的操作(FOOBAR)
RFC2428 优先于 RFC1639。FTP 客户端将首先尝试 RFC2428,如果失败则尝试 RFC1639。
RFC1886:支持 IPv6 的 DNS 扩展
RFC1933:IPv6 主机和路由器的过渡机制
不支持 IPv4 兼容地址。
不支持自动隧道(RFC 中第 4.3 节描述)。
gif(4)接口以通用方式实现 IPv[46]隧道,并涵盖规范中描述的"配置隧道"。有关详细信息,请参见本文档中的 23.5.1.5。
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 邻居发现
查看本文档中的 23.5.1.2 节以获取详细信息。
RFC2462:IPv6 无状态地址自动配置
查看本文档中的 23.5.1.4 节以获取详细信息。
RFC2463:IPv6 规范的 ICMPv6
有关详细信息,请参阅本文档中的 23.5.1.9。
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 的基本套接字接口扩展
IPv4 映射地址(3.7)和 IPv6 通配符绑定套接字(3.8)的特殊行为都受支持。详情请参阅本文档中的 23.5.1.12。
RFC2675:IPv6 巨字节
查看本文档中的 23.5.1.7 以获取详细信息。
RFC2710:IPv6 的组播监听者发现
RFC2711:IPv6 路由器警报选项
IPv6 路由器重新编号
通过 ICMP 进行 IPv6 名称查找
通过 ICMP 进行 IPv6 名称查找
draft-ietf-pim-ipv6-01.txt:IPv6 的 PIM
pim6dd(8)实现密集模式。pim6sd(8)实现稀疏模式。
draft-itojun-ipv6-tcp-to-anycast-00:断开面向 IPv6 任播地址的 TCP 连接
草案 yamamoto-wideipv6-comm-model-00
有关详细信息,请参阅本文档中的 23.5.1.6。
draft-ietf-ipngwg-scopedaddr-format-00.txt:IPv6 范围地址格式的扩展
邻居发现相当稳定。当前支持地址解析、重复地址检测和邻居不可达检测。在不久的将来,我们将在内核中添加代理邻居通告支持,并作为管理员工具添加不经请求的邻居通告传输命令。
如果重复地址检测失败,地址将被标记为“重复”,并生成消息到系统日志(通常也会到控制台)。管理员有责任检查并从重复地址检测失败中恢复。未来应该改进这种行为。
有些网络驱动程序会将多播数据包环回到自身,即使被指示不这样做(特别是在混杂模式下)。在这种情况下,DAD 可能会失败,因为 DAD 引擎看到入站的 NS 数据包(实际上来自节点本身),并将其视为重复的标志。您可能希望查看 sys/netinet6/nd6_nbr.c:nd6_dad_timer() 中标记为“启发式”条件的 #if 语句作为解决方法(请注意,“启发式”部分中的代码片段不符合规范)。
邻居发现规范(RFC2461)未涉及以下情况中的邻居缓存处理:
当没有邻居缓存条目时,节点收到了未经请求的 RS/NS/NA/重定向数据包而没有链路层地址
在没有链路层地址的中介上处理邻居缓存(我们需要一个带有 IsRouter 位的邻居缓存条目)
对于第一种情况,我们根据 IETF ipngwg 邮件列表上的讨论实施了基于临时解决方案。有关详细信息,请参阅源代码中的注释和从(IPng 7155)开始的电子邮件线程,日期为 1999 年 2 月 6 日。
IPv6 的本地链路确定规则(RFC2461)与 BSD 网络代码中的假设有很大不同。目前,不支持当默认路由器列表为空时的本地链路确定规则(RFC2461,第 5.2 节,第二段的最后一句 - 请注意,规范在该节中在几个地方误用了“主机”和“节点”这两个词)。
为避免可能的 DoS 攻击和无限循环,现在仅接受 ND 数据包上的 10 个选项。因此,如果您有 20 个前缀选项附加到 RA 上,只有前 10 个前缀将被识别。如果这给您带来麻烦,请在 FREEBSD-CURRENT 邮件列表上提出,并/或修改 sys/netinet6/nd6.c 中的 nd6_maxndopt。如果有很高的需求,我们可能会为该变量提供 sysctl 旋钮。
IPv6 使用范围地址。因此,对于 IPv6 地址,指定范围索引(链路本地地址的接口索引,或站点本地地址的站点索引)非常重要。没有范围索引,范围 IPv6 地址对内核来说是模棱两可的,内核将无法确定数据包的出站接口。
普通用户空间应用程序应使用高级 API(RFC2292)来指定范围索引或接口索引。为了类似的目的,在 sockaddr_in6 结构中定义了 sin6_scope_id 成员,该结构在 RFC2553 中定义。然而,对于 sin6_scope_id 的语义相当模糊。如果您关心应用程序的可移植性,我们建议您使用高级 API 而不是 sin6_scope_id。
在内核中,用于链路本地范围地址的接口索引嵌入到 IPv6 地址的第 2 个 16 位字(第 3 个和第 4 个字节)中。例如,您可能会看到类似以下内容:
在路由表和接口地址结构(struct in6_ifaddr)中。上面的地址是一个属于接口标识符为 1 的网络接口的链路本地单播地址。嵌入的索引使我们能够有效地识别多个接口上的 IPv6 链路本地地址,并且只需进行少量代码更改。
路由守护程序和配置程序,如 route6d(8) 和 ifconfig(8),需要操作 "嵌入式" 范围索引。这些程序使用路由套接字和 ioctls(如 SIOCGIFADDR_IN6),内核 API 将返回填入第二个 16 位字的 IPv6 地址。这些 API 用于操作内核的内部结构。使用这些 API 的程序必须准备处理内核版本之间的差异。
当您在命令行中指定范围地址时,永远不要写入嵌入式形式(例如 ff02:1::1 或 fe80:2::fedc)。这不应该起作用。始终使用标准形式,例如 ff02::1 或 fe80::fedc,并使用命令行选项来指定接口(例如 ping -6 -I ne0 ff02::1 )。通常,如果一个命令没有命令行选项来指定出口接口,那么该命令就没有准备好接受范围地址。这可能与 IPv6 支持 "牙医诊所" 情况的前提相反。我们认为,规范需要一些改进。
一些用户空间工具支持扩展的数值 IPv6 语法,如 draft-ietf-ipngwg-scopedaddr-format-00.txt 中所述。您可以通过使用出口接口的名称,例如 "fe80::1%ne0",来指定出口链路。这样,您就能够轻松地指定链路本地范围的地址。
要在程序中使用这个扩展,你需要使用 getaddrinfo(3) 和带有 NI_WITHSCOPEID 的 getnameinfo(3)。目前的实现假设链路和接口之间存在 1 对 1 的关系,这比规范所要求的更严格。
大多数 IPv6 无状态地址自动配置是在内核中实现的。邻居发现功能作为一个整体在内核中实现。主机的路由器通告 (RA) 输入在内核中实现。终端主机的路由器请求 (RS) 输出、路由器的 RS 输入以及路由器的 RA 输出是在用户空间中实现的。
8.1.1.4.1. 链路局域网和特殊地址的分配
IPv6 链路局域网地址是根据 IEEE802 地址(以太网 MAC 地址)生成的。每个接口在变为上行接口(IFF_UP)时会自动分配一个 IPv6 链路局域网地址。此外,链路局域网地址的直接路由也会添加到路由表中。
这是 netstat 命令的输出:
无 IEEE802 地址的接口(伪接口如隧道接口或 ppp 接口)将尝试从其他接口(如以太网接口)借用 IEEE802 地址。如果没有附加 IEEE802 硬件,将使用最后的备选伪随机值 MD5(hostname) 作为链路本地地址的来源。如果这对您的使用不适合,您将需要手动配置链路本地地址。
如果接口无法处理 IPv6(如缺乏组播支持),则不会为该接口分配链路本地地址。详细信息请参阅第 2 节。
每个接口都加入了被请求的多播地址和链路本地所有节点多播地址(例如,fe80::1:ff01:6317 和 ff02::1,在接口所连接的链路上)。除了链路本地地址之外,环回地址(::1)将被分配给环回接口。此外,::1/128 和 ff01::/32 会自动添加到路由表中,环回接口还加入了节点本地多播组 ff01::1。
主机上的无状态地址自动配置
在 IPv6 规范中,节点分为两类:路由器和主机。路由器转发发送给其他节点的数据包,主机不转发数据包。net.inet6.ip6.forwarding 定义了该节点是路由器还是主机(如果为 1,则为路由器;如果为 0,则为主机)。
当主机从路由器那里收到路由器通告时,主机可以通过无状态地址自动配置来自动配置自身。此行为可以通过 net.inet6.ip6.accept_rtadv 来控制(如果设置为 1,则主机会自动配置自身)。通过自动配置,接收接口的网络地址前缀(通常是全局地址前缀)被添加。默认路由也被配置。路由器定期生成路由器通告数据包。要请求相邻路由器生成 RA 数据包,主机可以发送路由器请求。要随时生成 RS 数据包,请使用 rtsol 命令。也可以使用 rtsold(8)守护程序。rtsold(8)在必要时生成路由器请求,并且非常适合移动使用(笔记本电脑)。如果希望忽略路由器通告,请使用 sysctl 将 net.inet6.ip6.accept_rtadv 设置为 0。
从路由器生成路由器通告,请使用 rtadvd(8)守护程序。
请注意,IPv6 规范假定以下项目,不符合规范的情况未指定:
只有主机会收听路由器通告
主机只有单个网络接口(除回环接口外)
因此,在路由器或多接口主机上启用 net.inet6.ip6.accept_rtadv 是不明智的。配置错误的节点可能表现出奇怪的行为(允许不符合规范的配置,适合那些希望进行一些实验的人)。
总结一下 sysctl 开关:
RFC2462 对传入 RA 前缀信息选项的验证规则在 5.5.3(e)中。这是为了保护主机免受恶意(或配置错误)路由器广告非常短的前缀生存期。Jim Bound 向 ipngwg 邮件列表进行了更新(在存档中查找“(ipng 6712)”),并实施了 Jim 的更新。
查看文档中的 23.5.1.2,了解 DAD 与自动配置之间的关系。
GIF(通用接口)是用于配置隧道的伪接口。详细信息请参阅 gif(4)。当前
v6 in v6
v6 in v4
在 v6 中的 v4
在 v4 中的 v4
可用。使用 gifconfig(8)为 gif 接口分配物理(外部)源和目标地址。在内部和外部 IP 头部使用相同地址族(v4 中的 v4,或 v6 中的 v6)的配置是危险的。很容易配置接口和路由表以执行无限级别的隧道。请注意。
gif 可以配置为 ECN-friendly。请查看章节 23.5.4.5 了解隧道的 ECN-friendly 设置,以及如何配置 gif(4)。
如果您想要使用 gif 接口配置一个 IPv4-in-IPv6 隧道,请仔细阅读 gif(4)。您需要自动删除分配给 gif 接口的 IPv6 链路本地地址。
当前的源选择规则是面向范围的(有一些例外情况-请参见下文)。 对于给定的目的地,将通过以下规则选择源 IPv6 地址:
如果用户明确指定了源地址(例如,通过高级 API),则使用指定的地址。
如果对于外发接口分配了一个具有与目的地地址相同范围的地址(通常通过查找路由表确定),则使用该地址。 这是最典型的情况。
如果没有满足上述条件的地址,请选择发送节点上一个接口分配的全局地址。
如果没有满足上述条件的地址,并且目标地址是站点本地范围,请选择发送节点上一个接口分配的站点本地地址。
如果没有满足上述条件的地址,请选择与目标的路由表条目关联的地址。这是最后的手段,可能会导致范围违规。
例如,对于 ff01::1,为 fe80:1::2a0:24ff:feab:839b 选择 fe80:1::200:f8ff:fe01:6317(请注意,嵌入式接口索引 - 描述在 23.5.1.3 中 - 帮助我们选择正确的源地址。这些嵌入索引不会传输到网络)。如果出站接口具有适用于范围的多个地址,则根据最长匹配原则选择源地址(规则 3)。假设出站接口提供了 2001:0DB8:808:1:200:f8ff:fe01:6317 和 2001:0DB8:9:124:200:f8ff:fe01:6317。将选择 2001:0DB8:808:1:200:f8ff:fe01:6317 作为目标 2001:0DB8:800::1 的源。
请注意,上述规则未在 IPv6 规范中记录。它被视为“由实现决定”的项目。有些情况下,我们不遵循上述规则。一个例子是连接的 TCP 会话,我们使用在 tcb 中保存的地址作为源地址。另一个例子是 Neighbor Advertisement 的源地址。根据规范(RFC2461 7.2.2),NA 的源地址应该是相应 NS 的目标地址。在这种情况下,我们遵循规范而不是上述最长匹配规则。
对于新连接(不适用规则 1 时),如果有其他选择可用,则不会选择已弃用地址(首选生存时间=0)作为源地址。如果没有其他选择可用,则废弃地址将被用作最后的选择。如果有多个废弃地址可选,则将使用上述范围规则从这些废弃地址中进行选择。如果出于某种原因希望禁止使用废弃地址,请将 net.inet6.ip6.use_deprecated 配置为 0。有关废弃地址的问题在 RFC2462 5.5.4 中有描述(注意:IETF ipngwg 正在就如何使用“废弃”地址进行一些讨论)。
巨型负载逐跳选项已实现,并可用于发送载荷超过 65,535 八位组的 IPv6 数据包。但目前不支持 MTU 超过 65,535 的物理接口,因此这样的负载只能在环回接口(即 lo0)上看到。
如果您想尝试巨型负载,首先必须重新配置内核,使环回接口的 MTU 超过 65,535 字节;将以下内容添加到内核配置文件中:
options "LARGE_LOMTU" #To test jumbo payload
然后重新编译新内核。
然后,您可以使用 ping(8) 命令的 -6、-b 和 -s 选项来测试巨型负载。必须指定 -b 选项以扩大套接字缓冲区的大小,-s 选项指定数据包的长度,应大于 65,535。例如,输入以下命令:
IPv6 规范要求巨型负载选项不能用于携带片段头的数据包中。如果违反此条件,将向发送方发送 ICMPv6 参数问题消息。虽然遵循规范,但通常不会因此要求导致 ICMPv6 错误消息。
当接收到一个 IPv6 包时,会检查帧长度并将其与 IPv6 报头中的有效载荷长度字段或 Jumbo Payload 选项中的值(如果有的话)进行比较。如果前者短于后者,则丢弃该包并增加统计数据。你可以使用 -s -p ip6
选项查看 netstat(8)命令的输出统计数据:
因此,除非错误的数据包是真正的 Jumbo Payload,即其数据包大小超过 65,535 字节,否则内核不会发送 ICMPv6 错误。如上所述,目前不支持具有如此巨大 MTU 的物理接口,因此很少返回 ICMPv6 错误。
目前不支持 TCP/UDP over jumbogram。这是因为我们没有介质(除了回环)来测试这个。如果你需要这个功能,请联系我们。
IPsec 不能在超大数据包上工作。这是由于支持 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 代码定义了自己的协议开关结构,称为"netinet6/ip6protosw.h"中的"struct ip6protosw"。 对于与 IPv4 部分(sys/netinet)兼容性的更新,没有进行这样的更新,但在其 pr_input()原型中添加了小更改。 因此,如果接收到具有大量 IPsec 标头的 IPsec-over-IPv4 数据包,则内核堆栈可能会崩溃。 IPsec-over-IPv6 没问题。 (当然,要处理所有这些 IPsec 标头,每个 IPsec 标头都必须通过每个 IPsec 检查。 因此,匿名攻击者将无法执行此类攻击。)
RFC2463 发布后,IETF ipngwg 已决定禁止针对 ICMPv6 重定向的 ICMPv6 错误数据包,以防止网络介质上的 ICMPv6 风暴。这已经实现在内核中。
对于用户空间编程,我们支持 RFC2553、RFC2292 和即将发布的互联网草案中规定的 IPv6 套接字 API。
TCP/UDP over IPv6 is available and quite stable. You can enjoy telnet(1), ftp(1), rlogin(1), rsh(1), ssh(1), etc. These applications are protocol independent. That is, they automatically chooses IPv4 or IPv6 according to DNS.
While ip_forward() calls ip_output(), ip6_forward() directly calls if_output() since routers must not divide IPv6 packets into fragments.
ICMPv6 应尽可能包含原始数据包,最大长度为 1280 字节。例如,UDP6/IP6 不可达错误应包含所有扩展头部和未更改的 UDP6 和 IP6 头部。因此,除 TCP 外,所有 IP6 功能均不将网络字节顺序转换为主机字节顺序,以保存原始数据包。
tcp_input()、udp6_input() 和 icmp6_input() 不能假设 IP6 头部紧随传输头部之后,因为存在扩展头部。因此,in6_cksum() 被实现用于处理 IP6 头部和传输头部不连续的数据包。TCP/IP6 和 UDP6/IP6 头部结构不存在于校验和计算中。
为了更轻松地处理 IP6 头部、扩展头部和传输头部,网络驱动程序现在要求将数据包存储在一个或多个内部 mbuf 中。典型的旧驱动程序为 96 到 204 字节的数据准备两个内部 mbuf,但现在此类数据包数据存储在一个外部 mbuf 中。
netstat -s -p ip6 告诉您您的驱动程序是否符合此要求。在以下示例中,“cce0” 违反了该要求。(有关更多信息,请参阅第 2 节。)
每个输入函数在开始时调用 IP6_EXTHDR_CHECK 来检查 IP6 和其标头之间的区域是否连续。IP6_EXTHDR_CHECK 仅在 mbuf 具有 M_LOOP 标志时调用 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 数据包。
但规范本身非常复杂,并未指定套接字层应如何行为。在这里,我们将前者称为“监听端”,将后者称为“发起端”,供参考之用。
您可以在相同的 port 上为两个地址族执行通配符绑定。
以下表格显示 FreeBSD 4.x 的行为。
以下各节将为您提供更多详细信息,以及如何配置行为。
关于监听端的评论:
看起来 RFC2553 对通配符绑定问题讨论得太少,特别是在port空间问题、故障模式和 AF_INET/INET6 通配符绑定之间的关系方面。对于这个 RFC 可能会有几种不同的解释,符合它但行为不同。因此,为了实现可移植的应用程序,您应该不假思索地假定内核的行为。使用 getaddrinfo(3)是最安全的方式。Port号空间和通配符绑定问题在 ipv6imp 邮件列表中详细讨论过,1999 年 3 月中旬,看起来没有明确的共识(即,直到实现者)。您可能想要查看邮件列表存档。
如果服务器应用程序希望接受 IPv4 和 IPv6 连接,将有两种选择。
一种是使用 AF_INET 和 AF_INET6 套接字(您将需要两个套接字)。使用 getaddrinfo(3)将 AI_PASSIVE 传递给 ai_flags,并使用 socket(2)和 bind(2)绑定返回的所有地址。通过打开多个套接字,您可以接受具有适当地址族的套接字的连接。IPv4 连接将由 AF_INET 套接字接受,IPv6 连接将由 AF_INET6 套接字接受。
另一种方法是使用一个 AF_INET6 通配符绑定套接字。使用 getaddrinfo(3)将 AI_PASSIVE 置入 ai_flags,并将 AF_INET6 置入 ai_family,并将第一个参数主机名设置为 NULL。然后使用 socket(2)和 bind(2)绑定返回的地址。(应为 IPv6 未指定地址)。您可以通过这一个套接字接受 IPv4 和 IPv6 数据包。
为了支持在 AF_INET6 通配符绑定套接字上仅支持 IPv6 流量,始终在向 AF_INET6 监听套接字发起连接时检查对等地址。如果地址是 IPv4 映射地址,则可能希望拒绝连接。可以使用 IN6_IS_ADDR_V4MAPPED()宏来检查条件。
为了更容易解决这个问题,有一个系统相关的 setsockopt(2)选项,IPV6_BINDV6ONLY,使用如下。
当此调用成功时,此套接字只接收 IPv6 数据包。
关于启动方面的评论:
给应用程序实现者的建议:为了实现一个可移植的 IPv6 应用程序(可在多个 IPv6 内核上运行),我们认为以下是成功的关键:
绝不要在代码中硬编码 AF_INET 或 AF_INET6。
在整个系统中使用 getaddrinfo(3) 和 getnameinfo(3)。永远不要使用 gethostby()、getaddrby()、inet_() 或 getipnodeby()。(为了轻松地使现有应用程序具备 IPv6 意识,有时 getipnodeby*() 会很有用。但如果可能的话,尽量重写代码以使用 getaddrinfo(3) 和 getnameinfo(3)。)
如果希望连接到目的地,请使用 getaddrinfo(3) 并尝试连接返回的所有目的地,就像 telnet(1) 一样。
一些 IPv6 堆栈带有有缺陷的 getaddrinfo(3)。将一个与您的应用程序一起提供的最小工作版本,并将其作为最后手段使用。
如果您想要为 IPv4 和 IPv6 的出站连接都使用 AF_INET6 套接字,您将需要使用 getipnodebyname(3)。当您希望尽可能少的工作将现有应用程序升级为 IPv6 兼容时,可能会选择这种方法。但请注意,这只是一个临时解决方案,因为 getipnodebyname(3)本身并不推荐,因为它根本不处理范围 IPv6 地址。对于 IPv6 名称解析,getaddrinfo(3)是首选的 API。因此,当您有时间这样做时,应重写您的应用程序以使用 getaddrinfo(3)。
在编写制作输出连接的应用程序时,如果您将 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 目的地中的 IPv4 映射 IPv6 地址。
您可以像下面这样在整个系统上禁用它的 sysctl。
8.1.1.12.1.1. 监听端
每个套接字都可以配置为支持特殊的 AF_INET6 通配符绑定(默认情况下已启用)。您可以像下面这样在每个套接字上禁用它。
通配符 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 如下定义结构 sockaddr_storage:
相反,XNET 草案如下定义:
1999 年 12 月达成共识,RFC2553bis 应选择后者(XNET)的定义。
当前实现符合 XNET 定义,基于 RFC2553bis 讨论。
如果你查看多个 IPv6 实现,你将能够看到两种定义。作为一个用户态程序员,最便捷的处理方式是:
通过使用 GNU autoconf 确保 ss_family 和/或 ss_len 在平台上可用。
将-Dss_family=ss_family 统一所有出现的地方(包括头文件)为 ss_family,或
永远不要触碰__ss_family。转换为 sockaddr *并使用 sa_family,如:
现在,标准驱动程序需要支持以下两个项目:
mbuf 集群要求。在此稳定版本中,我们将 MINCLSIZE 更改为 MHLEN+1,以便使所有驱动程序的行为符合我们的预期。
多播。如果 ifmcstat(8)未为接口提供任何多播组,则必须对该接口进行打补丁。
如果任何驱动程序不支持要求,则这些驱动程序不能用于 IPv6 和/或 IPsec 通信。如果您发现使用 IPv6/IPsec 时遇到任何问题,请将其报告给 FreeBSD 问题报告邮件列表。
(注意:过去我们要求所有 PCMCIA 驱动程序调用 in6_ifattach()。我们不再有此要求)
我们将 IPv4/IPv6 转换器分类为 4 种类型:
转换器 A --- 它用于过渡的早期阶段,使得从 IPv6 岛屿中的 IPv6 主机到 IPv4 海洋中的 IPv4 主机建立连接成为可能。
转换器 B --- 它用于过渡的早期阶段,使得从 IPv4 海洋中的 IPv4 主机到 IPv6 岛屿中的 IPv6 主机建立连接成为可能。
翻译者 C --- 它用于过渡的后期阶段,使得在 IPv4 岛中的 IPv4 主机与 IPv6 海洋中的 IPv6 主机建立连接成为可能。
翻译者 D --- 它用于过渡的后期阶段,使得在 IPv6 海洋中的 IPv6 主机与 IPv4 岛中的 IPv4 主机建立连接成为可能。
IPsec 主要由三个组件组织。
策略管理
密钥管理
AH 和 ESP 处理
内核实现了实验性的策略管理代码。有两种管理安全策略的方式。一种是使用 setsockopt(2) 配置每个套接字的策略。在这种情况下,策略配置在 ipsec_set_policy(3) 中描述。另一种方式是使用 PF_KEY 接口配置基于内核数据包过滤器的策略,通过 setkey(8)。
策略条目未按索引重新排序,因此在添加时条目的顺序非常重要。
此工具包中实现的密钥管理代码(sys/netkey)是自制的 PFKEY v2 实现。这符合 RFC2367。
用于 IPsec 协议的自制 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 隧道的认证模型必须重新审视。我们将需要改进策略管理引擎,最终。
The IPsec code in the kernel conforms (or, tries to conform) to the following standards:
"old IPsec" specification documented in rfc182[5-9].txt
在 rfc240[1-6].txt、rfc241[01].txt、rfc2451.txt 和 draft-mcdonald-simple-ipsec-api-01.txt(草案已过期,但您可以从 ftp://ftp.kame.net/pub/internet-drafts/下载)中记录了新 IPsec 规范。 (注意:IKE 规范,rfc241[7-9].txt 在用户空间中实现为"racoon" IKE 守护程序)
目前支持的算法有:
旧 IPsec AH
空 crypto 校验和(无文档,仅用于调试)
带有 128 位 crypto 校验和的 keyed MD5(rfc1828.txt)
带有 128 位 crypto 校验和的 keyed SHA1(无文档)
HMAC MD5 具有 128 位加密校验和 (rfc2085.txt)
HMAC SHA1 具有 128 位加密校验和 (没有文档)
旧的 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(96 位)的 ESP 认证
ESP 使用 HMAC-SHA1(96 位)进行身份验证
不支持以下算法:
老旧的 IPsec AH
HMAC MD5 带 128 位密码校验和+64 位重放预防(rfc2085.txt)
带 160 位密码校验和的密钥 SHA1+32 位填充(rfc1852.txt)
IPsec(在内核中)和 IKE(在用户空间中作为"racoon")已在多个互操作性测试活动中进行了测试,并且已知与许多其他实现很好地互操作。此外,当前的 IPsec 实现对 RFC 中记录的 IPsec 密码算法具有相当广泛的覆盖范围(我们仅涵盖没有知识产权问题的算法)。
支持与 draft-ipsec-ecn-00.txt 中描述的 ECN 友好的 IPsec 隧道。
RFC2401 中描述了普通的 IPsec 隧道。在封装时,IPv4 的 TOS 字段(或 IPv6 的流量类字段)将从内部 IP 头复制到外部 IP 头。在解封装时,外部 IP 头将被简单丢弃。解封装规则与 ECN 不兼容,因为外部 IP 的 TOS/流量类字段上的 ECN 位将丢失。
要使 IPsec 隧道支持 ECN,我们应修改封装和解封装过程。这在 http://www.aciri.org/floyd/papers/draft-ipsec-ecn-00.txt 的第 3 章中有描述。
通过将 net.inet.ipsec.ecn(或 net.inet6.ipsec6.ecn)设置为某个值,IPsec 隧道实现可以提供三种行为:
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)。
有关更多信息,请参阅:
http://www.aciri.org/floyd/papers/draft-ipsec-ecn-00.txt,RFC2481(显式拥塞通知),src/sys/netinet6/{ah,esp}_input.c
(感谢 Kenjiro Cho kjc@csl.sony.co.jp 进行详细分析)
这里是 KAME 代码过去测试 IPsec/IKE 互操作性的一些平台。请注意,双方可能已经修改了它们的实现,所以只需将以下列表用于参考目的。
Altiga, Ashley-laurent (vpcom.com), Data Fellows (F-Secure), Ericsson ACC, FreeS/WAN, 日立, IBM AIX®, IIJ, Intel, Microsoft® Windows NT®, NIST (Linux IPsec + plutoplus), Netscreen, OpenBSD, RedCreek, Routerware, SSH, Secure Computing, Soliton, 东芝, VPNet, 雅马哈 RT100i
这一章由 G. Adam Stanislav 撰写。
UNIX® 下的汇编语言编程是高度未记录的。一般认为没有人会想要使用它,因为各种 UNIX® 系统运行在不同的微处理器上,所以一切都应该用 C 语言编写以实现可移植性。
实际上,C 的可移植性相当于神话。即使是 C 程序在从一个 UNIX® 移植到另一个 UNIX® 时也需要进行修改,而不管每个系统运行在哪个处理器上。通常,这样的程序充满了依赖于其编译系统的条件语句。
即使我们相信所有的 UNIX®软件都应该用 C 语言或其他高级语言编写,我们仍然需要汇编语言程序员:谁会写访问内核的 C 库部分呢?
在本章中,我将尝试向您展示如何在 FreeBSD 下使用汇编语言编写 UNIX®程序。
本章不解释汇编语言的基础知识。关于这方面有足够的资源(要了解汇编语言的完整在线课程,请参阅 Randall Hyde 的《汇编语言艺术》;或者如果您更喜欢纸质书籍,请查看 Jeff Duntemann 的《逐步学习汇编语言》(ISBN:0471375233)。然而,一旦本章完成,任何汇编语言程序员都将能够快速高效地为 FreeBSD 编写程序。
版权 © 2000-2001 G. Adam Stanislav。保留所有权利。
汇编语言编程最重要的工具是汇编器,这种软件将汇编语言代码转换为机器语言。
FreeBSD 提供了三种非常不同的汇编器。llvm-as(1)(包含在 devel/llvm 中)和 as(1)(包含在 devel/binutils 中)都使用传统的 UNIX®汇编语言语法。
另一方面,nasm(1)(通过 devel/nasm 安装)使用 Intel 语法。它的主要优势是可以为许多操作系统汇编代码。
本章使用 nasm 语法,因为大多数来自其他操作系统的汇编语言程序员会发现这样更容易理解。而且,坦率地说,这是我习惯于的。
汇编器的输出,就像任何编译器的输出一样,需要链接以形成可执行文件。
标准的 ld(1)链接器随 FreeBSD 提供。它可以与用汇编器组装的代码一起工作。
默认情况下,FreeBSD 内核使用 C 调用约定。此外,尽管内核是使用 int 80h 访问的,但假定程序将调用发出 int 80h 的函数,而不是直接发出 int 80h 。
这种约定非常方便,比 MS-DOS®使用的 Microsoft®约定要好得多。为什么?因为 UNIX®约定允许任何用任何语言编写的程序访问内核。
汇编语言程序也可以做到。例如,我们可以打开一个文件:
这是一种非常干净和便携的编码方式。如果您需要将代码转移到一个使用不同中断或不同参数传递方式的 UNIX®系统,您需要更改的只是内核过程。
但是汇编语言程序员喜欢节省周期。上面的示例需要一个 call/ret 组合。我们可以通过 push 一个额外的双字来消除它:
我们放置在 EAX 中的 5 标识内核函数,在本例中为 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 中。 当然,您需要知道编号是多少。
编号列在系统调用中。 locate syscalls 可以找到此文件的几种不同格式,这些格式都是从 syscalls.master 自动生成的。
您可以在 /usr/src/sys/kern/syscalls.master 找到默认 UNIX®调用约定的主文件。如果您需要使用在 Linux 仿真模式中实现的其他约定,请阅读 /usr/src/sys/i386/linux/syscalls.master。
syscalls.master 描述了调用的执行方式:
它是最左边的列,告诉我们要放在 EAX 中的数字。
最右边的列告诉我们要 push 什么参数。它们从右到左 push 。
例如,要 open 文件,我们需要先 push {{$2}},然后 flags ,然后存储 path 的地址。
大多数情况下,如果系统调用不返回某种值,它将毫无用处:打开文件的文件描述符、读取到缓冲区的字节数、系统时间等。
另外,系统需要告知我们是否发生错误:文件不存在、系统资源耗尽、我们传递了无效参数等。
传统查找 UNIX® 系统下各种系统调用信息的地方是手册页。FreeBSD 在第 2 节中描述其系统调用,有时在第 3 节中。
例如,open(2) 说:
如果成功, open() 返回一个非负整数,称为文件描述符。如果失败,它返回 -1 并设置 errno 以指示错误。
初次接触 UNIX® 和 FreeBSD 的汇编语言程序员会立即问出令人困惑的问题: errno 在哪里,我该如何到达那里呢?
不幸的是,这取决于...对于大多数系统调用,它在 EAX 中,但不是全部。一个好的经验法则是,在第一次使用系统调用时,要在 EAX 中查找返回值。如果那里没有,你需要进一步研究。
实际上,不存在…
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。随着我们讨论更多系统调用,我们将添加更多内容。
我们现在准备好进行我们的第一个程序,必不可少的 Hello, World!
这里是它的功能:第 1 行包括了定义、宏以及来自 system.inc 的代码。
第 3-5 行是数据:第 3 行开始数据部分/段。第 4 行包含字符串"Hello, World!",后跟一个换行符( 0Ah )。第 5 行创建一个包含第 4 行字符串长度(以字节计)的常量。
第 7-16 行包含代码。请注意,FreeBSD 使用 elf 文件格式作为其可执行文件,这要求每个程序都从标记为 _start 的点开始(更准确地说,链接器期望如此)。此标签必须是全局的。
第 10-13 行要求系统将 hello 字符串的 hbytes 字节写入 stdout 。
第 15-16 行要求系统以 0 的返回值结束程序。 SYS_exit 系统调用永远不会返回,因此代码在那里结束。
在编辑器中输入代码,并将其保存在名为 hello.asm 的文件中。您需要 nasm 来进行汇编。
如果您没有 nasm,请键入:
如果你不想保留 nasm 源代码,可以键入 make install clean 而不仅仅是 make install 。
无论如何,FreeBSD 都将自动从互联网下载 nasm,并在您的系统上进行编译和安装。
现在您可以汇编,链接和运行代码:
常见的 UNIX® 应用程序类型是过滤器——一种从 stdin 读取数据,进行某种处理,然后将结果写入 stdout 的程序。
在本章中,我们将开发一个简单的过滤器,并学习如何从 stdin 读取和写入 stdout。这个过滤器将把输入的每个字节转换成一个十六进制数,并在其后跟一个空格。
在数据部分,我们创建一个名为 hex 的数组。它按升序包含 16 个十六进制数字。该数组后面是一个缓冲区,我们将用于输入和输出。缓冲区的前两个字节最初设置为 0 。这是我们将写入两个十六进制数字的地方(第一个字节也是我们将读取输入的地方)。第三个字节是一个空格。
代码部分包括四个部分:读取字节、将其转换为十六进制数、写入结果,并最终退出程序。
要读取字节,我们要求系统从标准输入中读取一个字节,并将其存储在 buffer 的第一个字节中。系统返回读取的字节数为 EAX 。当数据正在传输时,这将是 1 ,或者当没有更多的输入数据可用时,这将是 0 。因此,我们检查 EAX 的值。如果是 0 ,我们跳转到 .done ,否则我们继续。
十六进制转换从 buffer 读取字节到 EAX ,或者实际上只是 AL ,同时将 EAX 的其余位清零。我们还将字节复制到 EDX ,因为我们需要分别转换上四位(半字节)和下四位。我们将结果存储在缓冲区的前两个字节中。
接下来,我们要求系统将缓冲区的三个字节,即两个十六进制数字和空格,写入标准输出。然后我们跳回程序的开头并处理下一个字节。
一旦没有更多的输入,我们要求系统退出我们的程序,返回零,这是传统意义上表示程序成功的值。
继续,将代码保存在名为 hex.asm 的文件中,然后输入以下内容( ^D 表示按住控制键并在按住控制键的同时按 D ):
我们能改进这个吗?首先,有点令人困惑,因为一旦我们转换了一行文本,我们的输入就不再从行的开头开始了。我们可以修改它,在每个 0A 后打印一个新行而不是一个空格。
我们已经将空格存储在 CL 寄存器中。我们可以这样做是因为,与 Microsoft® Windows®不同,UNIX®系统调用不会修改任何未使用的寄存器的值来返回值。
这意味着我们只需要设置 CL 一次。因此,我们添加了一个新标签 .loop 并跳转到下一个字节,而不是跳转到 _start 。我们还添加了 .hex 标签,这样我们可以在 buffer 的第三个字节中有一个空格或一个新行。
一旦您已经更改了 hex.asm 以反映这些更改,请键入:
看起来更好了。但这段代码效率很低!我们为每个字节两次(一次读取,一次写入输出)进行系统调用。
通过缓冲输入和输出,我们可以提高代码的效率。我们创建一个输入缓冲区,并一次性读取一整个字节序列。然后我们逐个从缓冲区中获取它们。
我们还创建一个输出缓冲区。我们将输出存储在其中,直到它满为止。在那时,我们要求内核将缓冲区的内容写入标准输出。
当没有更多输入时,程序结束。但我们仍然需要要求内核最后一次将输出缓冲区的内容写入标准输出,否则一些输出将进入输出缓冲区,但永远不会被发送出去。不要忘记这一点,否则你会想知道为什么有些输出丢失了。
我们现在在源代码中有第三个部分,名为 .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 正常工作,但在那之前读取的任何内容上不能。
如果您的程序向前读取超过一个字节,您至少有两个选择:
如果可能,修改程序,使其只向前读取一个字节。这是最简单的解决方案。
如果该选项不可用,首先确定您的程序需要一次返回输入流的最大字符数。将该数字略微增加,以确保,最好是 16 的倍数,这样它就会很好地对齐。然后修改您的代码 .bss 部分,并在输入缓冲区之前创建一个小的“备用”缓冲区,类似于这样:
您还需要修改 ungetc 以将要传递给 AL 的字节值传递进去:
通过这种修改,您可以安全地连续调用 ungetc 高达 17 次(第一次调用仍在缓冲区内,其余 16 次可以在缓冲区内或“备用”内)。
如果我们的十六进制程序能够从命令行读取输入和输出文件的名称,那么它将会更加有用。但是...它们在哪里呢?
在 UNIX® 系统启动程序之前,它会在堆栈上存储一些数据,然后跳转到程序的标签处。是的,我说的是跳转,而不是调用。这意味着数据可以通过读取来访问,或者简单地 ping 它。
栈顶的值包含命令行参数的数量。传统上称为 argc ,代表“参数计数”。
接下来是命令行参数,共 argc 个。这些通常被称为 argv ,代表“参数值”。也就是说,我们得到 argv[0] , argv[1] , … , argv[argc-1] 。这些不是实际的参数,而是参数的指针,即实际参数的内存地址。参数本身是以 NUL 结尾的字符串。
argv 列表后面跟着一个空指针,简单地说就是一个 0 。还有更多内容,但对我们目前的目的来说就足够了。
有了这些知识,我们几乎已经准备好迎接 hex.asm 的下一个版本。但首先,我们需要向 system.inc 添加几行代码:
首先,我们需要向我们的系统调用号列表添加两个新条目:
然后我们在文件末尾添加两个新的宏:
因此,这是我们修改过的源代码:
在我们的 .data 部分,现在有两个新变量, fd.in 和 fd.out 。我们在这里存储输入和输出文件描述符。
在 .text 部分,我们已用 [fd.in] 和 [fd.out] 替换了对 stdin 和 stdout 的引用。
.text 部分现在以一个简单的错误处理程序开始,该处理程序只是以 1 的返回值退出程序。错误处理程序位于 _start 之前,因此我们离错误发生的地方不远。
当然,程序执行仍然从 _start 开始。首先,我们从堆栈中移除 argc 和 argv[0] :它们对我们没有兴趣(至少在这个程序中是这样)。
我们将 argv[1] 弹出到 ECX 。这个寄存器特别适用于指针,因为我们可以处理空指针 jecxz 。如果 argv[1] 不为空,我们尝试打开第一个参数中命名的文件。否则,我们继续程序如前:从 stdin 读取,写入 stdout 。如果我们无法打开输入文件(例如,文件不存在),我们跳转到错误处理程序并退出。
如果一切顺利,我们现在检查第二个参数。如果存在,我们打开输出文件。否则,我们将输出发送到 stdout 。如果我们无法打开输出文件(例如,文件已存在且我们没有写入权限),我们再次跳转到错误处理程序。
代码的其余部分与之前相同,除了在退出之前关闭输入和输出文件,并且如前所述,我们使用 [fd.in] 和 [fd.out] 。
我们的可执行文件现在长达 768 字节。
我们还能改进它吗?当然可以!每个程序都可以改进。以下是我们可以做的一些想法:
让我们的错误处理程序向 stderr 打印一条消息。
将错误处理程序添加到 read 和 write 函数中。
当打开输入文件时关闭 stdin ,当打开输出文件时关闭 stdout 。
添加命令行开关,比如 -i 和 -o ,这样我们可以以任何顺序列出输入和输出文件,或者从 stdin 读取并写入文件。
如果命令行参数不正确,请打印使用消息。
我将把这些增强功能留给读者练习:你已经知道实现它们所需的一切。
UNIX®的一个重要概念是环境,由环境变量定义。一些是系统设置的,另一些是您设置的,还有一些是由shell或加载另一个程序的任何程序设置的。
我之前说过,当一个程序开始执行时,堆栈包含 argc ,后跟以 NULL 结尾的 argv 数组,然后是其他内容。这个“其他内容”是环境,或者更准确地说,是指向环境变量的指针的以 NULL 结尾的数组。这经常被称为 env 。
env 的结构与 argv 的结构相同,都是一系列内存地址,后跟一个空值( 0 )。在这种情况下,没有 "envc" -我们通过搜索最终的空值来确定数组的结束位置。
变量通常以 name=value 格式出现,但有时 =value 部分可能丢失。我们需要考虑这种可能性。
我可以展示一些代码,以与 UNIX® env 命令相同的方式打印环境。但我觉得编写一个简单的汇编语言 CGI 实用程序会更有趣。
我在我的网站上有一个详细的 CGI 教程,但这里是 CGI 的一个非常快速概述:
Web 服务器通过设置环境变量与 CGI 程序通信。
CGI 程序将其输出发送到 stdout。Web 服务器从那里读取。
必须以 HTTP 标头开头,然后是两个空行。
然后打印 HTML 代码,或者它正在生成的其他类型的数据。
因此,我们的 webvars 程序必须发送 HTTP 头,然后跟一些 HTML 标记。然后,它必须逐个读取环境变量,并将它们作为 HTML 页面的一部分发送出去。
代码如下。我将注释和解释直接放在代码内部:
这段代码生成了一个 1,396 字节的可执行文件。其中大部分是数据,即我们需要发送的 HTML 标记。
照常组装和链接:
要使用它,您需要将 webvars 上传到您的 Web 服务器。根据您的 Web 服务器设置的方式,您可能需要将其存储在一个特殊的 cgi-bin 目录中,或者可能需要使用 .cgi 扩展名重新命名它。
然后您需要使用浏览器查看其输出。要在我的 Web 服务器上查看其输出,请访问 http://www.int80h.org/webvars/。如果对密码保护的 Web 目录中存在的其他环境变量感到好奇,请访问 http://www.int80h.org/private/,使用用户名 asm 和密码 programmer 。
我们已经完成了一些基本的文件操作:我们知道如何打开和关闭它们,如何使用缓冲区读取和写入它们。但是在涉及文件时,UNIX®提供了更多功能。在本节中,我们将研究其中一些功能,并最终得到一个不错的文件转换实用程序。
的确,让我们从最后开始,也就是从文件转换实用程序开始。当我们从一开始就知道最终产品应该做什么时,编程变得更加容易。
我为 FreeBSD 编写的最早的程序之一是 tuc,一个文本到 FreeBSD 文件转换器。它将来自其他操作系统的文本文件转换为 FreeBSD 文本文件。换句话说,它会将不同类型的行结束符更改为 FreeBSD 的换行约定。它将输出保存在另一个文件中。可选地,它将 UNIX 文本文件转换为 DOS 文本文件。
我广泛使用 tuc,但始终只是将其他操作系统转换为 FreeBSD,从未反过来。我一直希望它可以直接覆盖文件,而不是我必须将输出发送到另一个文件。大多数时候,我最终会这样使用它:
拥有一个 ftuc,即快速 tuc,然后像这样使用它:
在这一章中,我们将用汇编语言编写 ftuc(原始 tuc 是用 C 编写的),并在此过程中研究各种面向文件的内核服务。
乍一看,这样的文件转换非常简单:你所要做的就是去掉回车,对吗?
如果你回答是,再想想:这种方法大多数时候都有效(至少对于 MS DOS 文本文件),但偶尔会失败。
问题在于,并非所有非 UNIX®文本文件都以回车/换行序列结束其行。 有些使用带有回车符但不带换行符。 其他将多个空行组合为单个回车符,然后是多个换行符。 诸如此类。
因此,文本文件转换器必须能够处理任何可能的行结束方式:
回车/换行
回车符
换行符 / 回车符
换行符
它还应该处理使用上述某种组合的文件(例如,回车符后面跟着几个换行符)。
问题很容易通过一种称为有限状态机的技术来解决,这种技术最初是由数字电子电路的设计者开发的。有限状态机是依赖于其上一个输入而不仅仅是依赖于其输入的数字电路,即依赖于其状态的数字电路。微处理器是有限状态机的一个例子:我们的汇编语言代码被组装成机器语言,其中一些汇编语言代码产生一个字节的机器语言,而其他则产生几个字节。当微处理器逐个从内存中提取字节时,其中一些字节仅仅改变其状态而不产生任何输出。当操作码的所有字节都被提取时,微处理器将产生一些输出,或者改变寄存器的值等。
由于这个原因,所有软件本质上都是微处理器的状态指令序列。然而,在软件设计中,有限状态机的概念也是有用的。
我们的文本文件转换器可以被设计为一个具有三种可能状态的有限状态机。我们可以称它们为状态 0-2,但如果我们给它们符号名称,将会让我们的生活更轻松:
普通
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® 版本的方式如下:
这与 mmap(2) 所说的有轻微不同。这是因为 mmap(2) 描述的是 C 版本。
差别在于参数 long pad ,这在 C 版本中不存在。然而,FreeBSD syscalls 在 push 之后增加了一个 32 位的填充,用于填充 64 位参数。在这种情况下, off_t 是一个 64 位值。
当我们完成使用内存映射文件后,我们使用 munmap 系统调用取消映射:
因为我们需要告诉 mmap 要映射文件中多少字节到内存中,而且因为我们想要映射整个文件,我们需要确定文件的大小。
我们可以使用 fstat 系统调用来获取系统可以提供的有关打开文件的所有信息。这包括文件大小。
同样,syscalls.master 列出了 fstat 的两个版本,一个是传统版本(系统调用 62),另一个是 POSIX®版本(系统调用 189)。当然,我们将使用 POSIX®版本:
这是一个非常直接的调用: 我们向它传递一个 stat 结构的地址和一个打开文件的描述符。它将填充 stat 结构的内容。
但是,我必须说,我试图在 .bss 部分声明 stat 结构,并且 fstat 不喜欢它: 它设置了指示错误的进位标志。在我将代码更改为在堆栈上分配结构之后,一切都正常了。
由于我们的程序可能会将回车/换行序列合并为直接换行符,因此我们的输出可能会比输入小。但是,由于我们将输出放入与读取输入相同的文件中,我们可能需要更改文件的大小。
ftruncate 系统调用允许我们做到这一点。尽管其名称有些误导, ftruncate 系统调用既可以用于截断文件(使其变小),也可以用于扩展文件。
是的,我们将在 syscalls.master 中找到 ftruncate 的两个版本,一个是旧版本(130),另一个是新版本(201)。我们将使用新版本:
请注意,这个再次包含一个 int pad 。
我们现在知道写入 ftuc 所需的一切。我们首先在 system.inc 中添加一些新行。首先,在文件的开头或附近定义一些常量和结构:
我们定义新的系统调用:
我们添加了宏以供使用:
这是我们的代码:
作为禅宗的学生,我喜欢一心一意的概念:一次只做一件事,并且做得好。
事实上,这正是 UNIX®的工作原理。虽然典型的 Windows®应用程序试图做任何可能的事情(因此充满错误),典型的 UNIX®程序只做一件事,并且做得很好。
典型的 UNIX®用户通过编写一个shell脚本,将各种现有程序组合在一起,将一个程序的输出导入到另一个程序的输入来实现自己的应用程序。
在编写您自己的 UNIX®软件时,通常建议查看您需要解决的问题的哪些部分可以通过现有程序处理,只为您没有现有解决方案的问题部分编写自己的程序。
我将用我最近面对的一个具体的现实例子来说明这个原则:
我需要从我从一个网站下载的数据库中提取每条记录的第 11 个字段。该数据库是一个 CSV 文件,即一个逗号分隔值列表。这是一个在可能使用不同数据库软件的人之间共享数据的标准格式。
文件的第一行包含由逗号分隔的各种字段列表。文件的其余部分包含逐行列出的数据,值之间用逗号分隔。
我尝试使用逗号作为分隔符来使用 awk。但由于有几行包含带引号的逗号,awk 从这些行中提取了错误的字段。
因此,我需要编写自己的软件来从 CSV 文件中提取第 11 个字段。然而,遵循 UNIX® 精神,我只需要编写一个简单的过滤器,该过滤器将执行以下操作:
删除文件的第一行;
将所有未加引号的逗号更改为不同的字符;
删除所有引号。
严格来说,我可以使用 sed 从文件中删除第一行,但在我自己的程序中这样做非常容易,所以我决定这样做并减少流水线的大小。
无论如何,编写这样的程序大约花了我 20 分钟的时间。编写一个从 CSV 文件中提取第 11 个字段的程序会需要更长的时间,并且我无法重用它来从其他数据库提取其他字段。
这次我决定让它比典型的教程程序多做一点工作:
它解析其命令行以获取选项;
如果找到错误的参数,它会显示正确的用法;
它生成有意义的错误消息.
这是它的使用消息:
所有参数都是可选的,可以以任何顺序出现。
-t 参数声明用于替换逗号的内容。 tab 是默认设置。例如, -t; 将用分号替换所有未引用的逗号。
我不需要 -c 选项,但它将来可能会派上用场。它让我声明要用其他字符替换逗号。例如, -c@ 将替换所有的 at 符号(如果你想将电子邮件地址列表拆分为用户名和域名,这是很有用的)。
-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() 加载我们的滤镜。
它将从位图或像素图中读取第一行像素。
它将将第一行像素写入到通往我们滤波器的管道。
我们的滤波器将从其输入中读取每个像素,将其转换为负像素,并将其写入其输出缓冲区。
我们的过滤器将调用 getchar 来获取下一个像素。
getchar 将找到一个空的输入缓冲区,因此它将调用 read 。
read 将调用 SYS_read 系统调用。
内核将暂停我们的过滤器,直到图像编辑器向管道发送更多数据。
图像编辑器将从连接到我们过滤器的另一个管道中读取,以便在向我们发送输入的第二行之前,它可以设置输出图像的第一行。
内核暂停图像编辑器,直到它从我们的过滤器接收到一些输出,以便将其传递给图像编辑器。
在这一点上,我们的过滤器等待图像编辑器发送更多数据以进行处理,而图像编辑器正在等待我们的过滤器发送第一行处理结果。但结果存储在我们的输出缓冲区中。
过滤器和图像编辑器将继续无限期地等待彼此(或者至少直到它们被终止)。我们的软件刚刚进入了竞争条件。
如果我们的过滤器在请求内核提供更多输入数据之前刷新其输出缓冲区,则不会出现此问题。
奇怪的是,大部分汇编语言文献甚至没有提到 FPU(浮点运算单元)的存在,更不用说讨论如何编程了。
然而,当我们通过汇编语言做一些只有汇编语言才能做到的事情,创建高度优化的 FPU 代码时,汇编语言的光芒就会显现出来。
FPU 由 8 个 80 位浮点寄存器组成。这些寄存器以堆栈方式组织-您可以在 TOS(堆栈顶部)上 push 一个值,也可以 pop 它。
也就是说,汇编语言操作码不是 push 和 pop ,因为它们已经被占用。
您可以通过使用 fld 、 fild 和 fbld 在 TOS 上设置一个值。 几个其他操作码让您在 TOS 上设置许多常见的常量-比如 pi。
类似地,您可以通过使用 fst 、 fstp 、 fist 、 fistp 和 fbstp 来设置一个值。 实际上,只有以 p 结尾的操作码才会直接设置该值, 其他操作码会将其移动到另一个地方而不从 TOS 中移除。
我们可以在 TOS 和计算机内存之间传输数据,格式可以是 32 位、64 位或 80 位实数,16 位、32 位或 64 位整数,或 80 位打包十进制数。
80 位打包十进制是二进制编码十进制的特例,在将数据的 ASCII 表示和 FPU 内部数据之间转换时非常方便。它允许我们使用 18 个有效数字。
无论我们如何在内存中表示数据,FPU 始终将其存储在其寄存器中的 80 位实数格式中。
其内部精度至少为 19 位十进制数字,因此即使我们选择以 ASCII 形式以完整的 18 位精度显示结果,我们仍在显示正确的结果。
我们可以对 TOS 执行数学运算:我们可以计算它的正弦,我们可以缩放它(即,我们可以乘以或除以 2 的幂),我们可以计算它的以 2 为底的对数,以及许多其他事情。
我们还可以将其乘以或除以,加到或从 FPU 寄存器中减去(包括它本身)。
TOS 的官方英特尔操作码是 st ,寄存器 st(0) - st(7) 的操作码是 st 和 st(0) ,因此,它们指的是同一个寄存器。
无论出于何种原因,nasm 的原始作者决定使用不同的操作码,即 st0 - st7 。换句话说,没有括号,TOS 总是 st0 ,而不是仅仅 st 。
压缩十进制格式使用 10 字节(80 位)的内存来表示 18 位数。那里代表的数字总是一个整数。
最高字节(第 9 字节)的最高位是符号位:如果设置了,数字为负,否则为正。此字节的其余位未使用/被忽略。
剩余的 9 个字节存储数字的 18 位数:每个字节 2 位数。
更高位的数字存储在高半字节(4 位)中,较低位的数字存储在低半字节中。
也就是说,您可能会认为 -1234567 会以这种方式存储在内存中(使用十六进制表示法):
可惜不是!与其他所有英特尔制造的东西一样,即使是打包的十进制数也是小端序的。
这意味着我们的 -1234567 存储方式如下:
记住这一点,否则你会绝望得抓狂!
要写出有意义的软件,我们不仅必须了解我们的编程工具,还必须了解我们为之创建软件的领域。
我们下一个滤镜会在我们想要构建针孔相机时帮助我们,因此在继续之前,我们需要一些针孔摄影方面的背景知识。
描述任何已建造的相机最简单的方法是将其描述为一些空间被一些防光材料包围,包围物中有一个小孔。
包围物通常很坚固(例如,一个盒子),但有时它是灵活的(比如折叠式相机)。相机内部非常黑暗。然而,孔让光线通过单个点进入(尽管在某些情况下可能有几个)。这些光线形成一个图像,在孔前面呈现相机外部的任何东西的表示。
如果将一些光敏材料(如胶片)放入相机中,它可以捕捉图像。
孔往往包含一个透镜,或一个透镜组件,通常称为物镜。
但严格来说,镜头并非必需:原始相机并不使用镜头,而是针孔。即使在今天,针孔仍然被用作研究相机工作原理的工具,并实现特殊类型的图像。
针孔产生的图像完全清晰。或模糊。针孔的理想尺寸是有的:如果太大或太小,图像就会失去清晰度。
这个理想的小孔直径是焦距的平方根的函数,焦距是小孔到胶片的距离。
在这里, D 是小孔的理想直径, FL 是焦距, PC 是小孔常数。根据杰伊·本德的说法,其值为 0.04 ,而肯尼斯·康纳斯确定为 0.037 。其他人提出了其他值。此值仅适用于白天:其他类型的光将需要不同的常数,其值只能通过实验确定。
f 数是测量光线照射胶片的非常有用的指标。例如,光度计可以确定,为了以 f5.6 的光圈值暴露具有特定感光度的胶片,可能需要曝光持续 1/1000 秒。
无论是 35 毫米相机、6x9 厘米相机等,都无关紧要。只要我们知道 f 数,就可以确定适当的曝光。
f 数很容易计算:
换句话说,焦距除以针孔直径等于光圈数。这也意味着更高的光圈数意味着更小的针孔或更大的焦距,或者两者都有。这反过来暗示,光圈数越高,曝光时间就越长。
此外,虽然针孔直径和焦距是一维测量,但胶片和针孔都是二维的。这意味着如果您以 A 的光圈数测量了曝光为 t ,那么 B 的曝光为:
尽管许多现代相机可以平滑而逐渐地改变他们的针孔直径,从而改变其光圈值,但并非总是如此。
为了允许不同的光圈值,相机通常包含一个金属板,上面钻有几个不同尺寸的孔。
这些尺寸是根据上述公式选择的,以使得最终的光圈值是所有相机上都使用的标准光圈值之一。例如,我手头上有一台非常老旧的柯达 Duaflex IV 相机,它有三个这样的孔,用于光圈值 8、11 和 16。
最近制造的相机可能提供 2.8、4、5.6、8、11、16、22 和 32(以及其他)的光圈值。这些数值并非随意选择:它们都是 2 的平方根的幂,尽管它们可能会有所四舍五入。
典型相机设计成设置任何标准化光圈值会改变拨盘的感觉。它会自然停在那个位置。因此,这些拨盘位置被称为光圈值。
由于每个停止点的 f 数是 2 的平方根的幂,将表盘移动 1 个停止点将使所需的适当曝光量加倍。将其移动 2 个停止点将使所需的曝光量增加 4 倍。将表盘移动 3 个停止点将使曝光量增加 8 倍,依此类推。
现在我们准备决定我们的针孔软件究竟应该做什么。
由于其主要目的是帮助我们设计一个有效的针孔相机,我们将焦距作为程序的输入。这是我们可以在没有软件的情况下确定的事情:适当的焦距由胶片的大小和拍摄“常规”照片、广角照片或长焦照片的需要确定。
到目前为止,我们编写的大多数程序都是使用单个字符或字节作为它们的输入:十六进制程序将单个字节转换为十六进制数,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 常数是一个非常小的数字。当然,我们将使用各种小值的 PC 来测试我们的软件。但是如果有人选择一个巨大的值来运行程序会发生什么?
它可能会使程序崩溃,因为我们没有设计它来处理大数字。
或者,我们可能会花更多的时间来让程序能够处理大数字。如果我们为计算机文盲受众编写商业软件,我们可能会这样做。
或者,我们可以说,“难道不应该是用户更了解吗?”
或者,我们可能会让用户无法输入一个巨大的数字。这是我们将采取的方法:我们将使用一个隐含的 0. 前缀。
换句话说,如果用户想要 0.04 ,我们将期望他输入 -p04 ,或在他的环境中设置 PINHOLE=04 。所以,如果他说 -p9999999 ,我们将解释为 0.9999999 - 仍然荒谬,但至少更安全。
其次,许多用户只想选择贝德常数或康纳斯常数。为了让他们更容易,我们将解释 -b 为与 -p04 相同, -c 为与 -p037 相同。
我们需要决定我们希望软件发送到输出的内容,以及以何种格式。
由于我们的输入允许未指定数量的焦距条目,因此最好使用传统的数据库风格输出,即在单独的行上显示每个焦距的计算结果,同时通过 tab 字符在一行上分隔所有值。
可选的,我们还应该允许用户指定我们之前研究过的 CSV 格式的使用。在这种情况下,我们将打印出一行逗号分隔的名称,描述每行的每个字段,然后像以前一样显示我们的结果,但用 comma 替换 tab 。
我们需要一个用于 CSV 格式的命令行选项。我们不能使用 -c ,因为那已经意味着使用康纳斯常数。出于某种奇怪的原因,许多网站将 CSV 文件称为“Excel 电子表格”(尽管 CSV 格式比 Excel 更早)。因此,我们将使用 -e 开关通知我们的软件我们希望以 CSV 格式输出。
我们将在输出的每一行开头写上焦距。起初,这可能听起来有些重复,特别是在交互模式中:用户输入焦距,我们正在重复它。
但用户可以在一行上输入多个焦距。输入也可以来自文件或另一个程序的输出。在这种情况下,用户根本看不到输入。
同样,输出可以写入文件,我们将稍后检查,或者可以发送到打印机,或成为另一个程序的输入。
因此,每一行以用户输入的焦距为开头是完全合理的。
不,等等!不是由用户输入的。如果用户输入类似这样的东西:
显然,我们需要去掉那些前导零。
因此,我们可以考虑原样读取用户输入,在 FPU 内将其转换为二进制,然后从那里打印出来。
但是...
如果用户输入类似这样的内容:
哈!打包的十进制浮点数格式让我们能输入 18 位数。但用户输入了超过 18 位数。我们该如何处理?
好吧,我们可以修改我们的代码,读取前 18 位数字,输入到 FPU 中,然后读取更多,将我们已经在 TOS 上拥有的数字乘以 10 的附加数字数量,然后 add 到它。
是的,我们可以这样做。但在这个程序中这将是荒谬的(在另一个程序中可能是应该做的事情):即使以毫米表示的地球周长只有 11 位数字。显然,我们无法制造那么大的相机(至少目前还不能)。
因此,如果用户输入如此巨大的数字,他要么是无聊的,要么是在测试我们,要么是试图入侵系统,要么是在玩游戏——做任何事情,但不是设计针孔相机。
我们将做什么?
我们会打他的脸,就说的方式:
为了实现这一点,我们将简单地忽略任何前导零。一旦我们找到一个非零数字,我们将初始化一个计数器为 0 ,并开始采取三个步骤:
发送数字到输出。
将数字附加到缓冲区,稍后我们将使用它来生成发送至 FPU 的打包十进制数。
增加计数器。
现在,在我们采取这三个步骤的同时,我们也需要注意两种情况之一:
如果计数器增长超过 18,我们停止将内容附加到缓冲区。我们继续阅读数字并将它们发送到输出。
如果,或者更准确地说,下一个输入字符不是数字,那么我们暂时停止输入。顺便说一句,我们可以简单地丢弃非数字,除非它是 # ,这时我们必须返回到输入流。它标志着一条评论,因此在我们生成输出并开始查找更多输入之后,必须看到它。
这仍然留下了一个未被发现的可能性:如果用户输入的全部是零(或者是多个零),我们将永远找不到一个非零数来显示。
每当我们的计数器停留在 0 时,我们可以确定发生了这种情况。在这种情况下,我们需要将 0 发送到输出,并执行另一个“打击面部”:
一旦我们显示了焦距并确定它有效(大于 0 但不超过 18 位数),我们可以计算针孔直径。
并非巧合,针孔中包含“针”这个词。实际上,许多针孔确实是针孔,是用针尖小心打孔的。
那是因为典型的针孔非常小。我们的公式得到的结果是毫米。我们将其乘以 1000 ,以便我们可以输出微米的结果。
此时,我们面临另一个陷阱:过多的精度。
是的,FPU 是为高精度数学设计的。但我们不是在处理高精度数学。我们正在处理物理学(特别是光学)。
假设我们想要将一辆卡车改装成针孔相机(我们不会是第一个这样做的人!)。假设它的箱子长 12 米,那么我们有焦距 12000 。好吧,使用贝德尔常数,它给出了 12000 的平方根乘以 0.04 ,这是 4.381780460 毫米,或 4381.780460 微米。
无论如何陈述,结果都是荒谬地精确。我们的卡车不确切是 12000 毫米长。我们没有用如此精确的方式测量它的长度,所以声明我们需要直径为 4.381780460 毫米的针孔是,嗯,具有欺骗性的。 4.4 毫米完全足够。
我们需要限制结果的有效数字位数。一种方法是使用表示微米的整数。因此,我们的卡车需要直径为 4382 微米的针孔。看着那个数字,我们仍然决定 4400 微米,或 4.4 毫米足够接近。
另外,我们可以决定无论结果有多大,我们只想显示四个有效数字(当然也可以是其他数字)。遗憾的是,FPU 不提供将数字四舍五入到特定位数的功能(毕竟,它不将数字视为十进制,而是视为二进制)。
因此,我们必须设计一种算法来减少有效数字的数量。
这是我的(我觉得很尴尬-如果您知道一个更好的,请告诉我):
将计数器初始化为 0 。
当数字大于或等于 10000 时,将其除以 10 并增加计数器。
输出结果。
当计数器大于 0 时,输出 0 并减少计数器。
然后,我们将输出以微米为单位四个有效数字的针孔直径。
此时,我们已知焦距和针孔直径。这意味着我们有足够的信息来计算光圈值。
我们将显示四个有效数字的 f 数,四舍五入。f 数很可能告诉我们很少。为了使其更有意义,我们可以找到最接近的标准化 f 数,即最接近的平方根 2 的幂。
我们通过将实际 f 数乘以自身来做到这一点,这当然会给我们 square 。然后我们将计算其以 2 为底的对数,这比计算以平方根 2 为底的对数要容易得多!我们将结果四舍五入到最接近的整数。接下来,我们将 2 提高到结果。实际上,FPU 为我们提供了一个很好的快捷方式来做到这一点:我们可以使用 fscale op 代码来“缩放”1,这类似于 shift 一个整数向左。最后,我们计算所有这些的平方根,然后我们就有了最接近的标准化 f 数。
如果所有这些听起来令人不知所措——或者工作太多,也许——如果您看到代码,一切都会变得更加清晰。总共需要 9 个操作码:
第一行, fmul st0, st0 ,平方了 TOS(堆栈顶部,与 st 相同,由 nasm 称为 st0 )。 fld1 将 1 推送到 TOS 上。
接下来一行, fld st1 ,将平方推送回 TOS。此时,平方同时位于 st 和 st(2) 中(为什么我们在堆栈上留下第二个副本将很快明白)。 st(1) 包含 1 。
接下来, fyl2x 计算 st 乘以 st(1) 的以 2 为底的对数。这就是为什么我们之前将 1 放在 st(1) 上的原因。
到这一点, st 包含我们刚刚计算的对数, st(1) 包含我们以后保存的实际 f-number 的平方。
frndint 将 TOS 四舍五入到最近的整数。 fld1 推一个 1 。 fscale 将 TOS 上的 1 按 st(1) 中的值移位,有效地将 2 提高到 st(1) 次方。
最后, fsqrt 计算结果的平方根,即最近的归一化 f-number。
我们现在在 TOS 上有了最接近标准化的 f-数,以 st(1) 为底的对数四舍五入到最接近的整数,实际 f-数的平方取值为 st(2) 。我们将值保存在 st(2) 中以待后用。
但我们不再需要 st(1) 的内容。最后一行, fstp st1 ,将 st 的内容放入 st(1) 中,并弹出。结果,原本是 st(1) 的现在是 st ,原本是 st(2) 的现在是 st(1) ,依此类推。新的 st 包含了标准化的 f-数。新的 st(1) 包含了我们为后人存储的实际 f-数的平方。
此时,我们已经准备好输出标准化的 f-数。由于它已经标准化,我们不会将其四舍五入到四个有效数字,而是以完整精度发送出去。
标准化光圈值在光度计上很有用,只要它足够小并且可以找到。否则,我们需要另一种方法来确定适当的曝光。
我们之前已经找出了在任意光圈值处计算适当曝光的公式,该公式是根据在不同光圈值处测得的曝光值得出的。
我见过的每个光度计都可以确定 f5.6 处的适当曝光。因此,我们将计算“f5.6 倍增器”,即我们需要将在 f5.6 处测得的曝光乘以多少来确定我们针孔相机的适当曝光。
根据上述公式,我们知道这个因子可以通过将我们的 f 数(实际的数,而不是标准化的数)除以 5.6 ,然后将结果平方来计算。
从数学上讲,将我们的 f 数的平方除以 5.6 的平方将给我们相同的结果。
在计算上,当我们只能平方一个数字时,我们不想平方两个数字。因此,第一个解决方案一开始似乎更好。
但是…
5.6 是一个常数。我们不必让我们的 FPU 浪费宝贵的周期。我们可以告诉它将 f-数的平方除以 5.6² 等于多少。或者我们可以将 f-数除以 5.6 ,然后平方结果。现在这两种方法看起来是相等的。
但是,它们并不相等!
经过以上摄影原理的研究,我们记得 5.6 实际上是 2 的平方根的五次方。一个无理数。这个数的平方恰好是 32 。
32 不仅是一个整数,它是 2 的幂。我们不需要将光圈值的平方除以 32 。我们只需要用 fscale 右移五位。在 FPU 计算中,这意味着我们将{{$3}}与 st(1) 相乘,等于 -5 。这比除法快得多。
现在我们清楚为什么将光圈值的平方保存在 FPU 堆栈的顶部。计算 f5.6 倍率是整个程序中最容易的计算!我们将输出它四个有效数字四舍五入。
我们可以计算一个更有用的数字:我们的光圈数与 f5.6 相差的档数。如果我们的光圈数恰好在测光表范围之外,但我们的快门可以设置不同的速度,并且这个快门使用档位,这个数字可能会对我们有所帮助。
假设我们的光圈数与 f5.6 相差 5 档,测光表显示我们应该使用 1/1000 秒。那么我们可以先将快门速度设置为 1/1000,然后将刻度拨动 5 档。
这个计算也相当简单。我们只需要计算刚刚计算出的 f5.6 倍数的以 2 为底的对数(尽管我们需要在四舍五入之前知道它的值)。然后将结果四舍五入到最接近的整数。在这个计算中,我们不需要担心有超过四个有效数字:结果很可能只有一到两位数字。
在汇编语言中,我们可以优化 FPU 代码,而这在高级语言(包括 C 语言)中是不可能的。
每当 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 以自己的高精度计算它的二进制值。
我们在使用其他常数:我们将针孔直径乘以 1000 ,将其从毫米转换为微米。当我们将数字四舍五入到四个有效数字时,我们将其与 10000 进行比较。因此,我们在堆栈上保留 1000 和 10000 。当将数字四舍五入到四位数时,当然我们会重复使用 0.1 。
最后一点,我们在堆栈上保留 -5 。我们需要它来缩放光圈数的平方,而不是将其除以 32 。我们最后加载这个常数并非巧合。这使得它成为堆栈顶部,当堆栈上只有常数时。因此,当光圈数的平方正在被缩放时, -5 位于 st(1) ,正好是 fscale 所期望的位置。
通常,我们会从头开始创建某些常量,而不是从内存中加载它们。这就是我们正在用 -5 做的事情:
我们可以将所有这些优化归纳为一个规则:将重复值保留在堆栈上!
代码遵循与我们之前看到的所有其他过滤器相同的格式,只有一个细微的例外:
我们不再假设输入结束意味着事情的结束,这在面向字符的过滤器中我们认为是理所当然的。
此过滤器不处理字符。它处理一种语言(尽管非常简单,仅由数字组成)。
当我们没有更多输入时,可能意味着两种情况之一:
我们已经完成并可以退出。这与以前一样。
我们读取的最后一个字符是一个数字。我们已将它存储在我们的 ASCII 转浮点转换缓冲区的末尾。现在我们需要将该缓冲区的内容转换为数字,并写入我们输出的最后一行。
由于这个原因,我们已经修改了我们的 getchar 和我们的 read 例程,每当我们从输入中获取另一个字符时返回 carry flag 清除,或者每当没有更多输入时设置 carry flag 。
当然,我们仍然在使用汇编语言魔法来实现这一点!好好看看 getchar 。它总是返回与 carry flag 清除。
然而,我们的主要代码依赖于 carry flag 来告诉它何时退出-而它起作用。
魔法就在于 read 。每当它从系统接收到更多输入时,它只是返回到 getchar ,后者从输入缓冲区中获取一个字符,清除 carry flag 并返回。
但是当 read 不再从系统接收到更多输入时,它根本不返回到 getchar 。相反, add esp, byte 4 操作码将 4 加到 ESP 上,设置 carry flag ,然后返回。
所以,它返回到哪里呢?每当一个程序使用 call 操作码时,微处理器 push 返回地址,即将其存储在堆栈顶部(不是 FPU 堆栈,而是内存中的系统堆栈)。当一个程序使用 ret 操作码时,微处理器 pop 从堆栈中取回返回值,并跳转到存储在那里的地址。
但是,由于我们将 4 添加到 ESP (即堆栈指针寄存器),实际上给微处理器带来了轻微的健忘症:它不再记得是 getchar 使 call 了 read 。
而且,由于 getchar 在 call 之前从未 push 过任何东西 read ,堆栈顶部现在包含了返回地址,指向 call 或 getchar 的任何内容。就那个调用者而言,他 call 了 getchar ,并带着 ret 返回!
除此之外, bcdload 例程陷入了大端和小端之间的利利普特冲突中。
它正在将数字的文本表示转换为该数字:文本以大端顺序存储,但打包的十进制是小端的。
为了解决冲突,我们在早期使用 std 操作码。我们稍后用 cld 取消它:在 std 激活时,我们不要做任何可能依赖方向标志默认设置的事情是非常重要的。
这段代码中的其他内容应该很清楚,只要你已经阅读完前面的整章。
这是编程需要大量思考而只需少量编码的经典例子。一旦我们仔细考虑了每一个细节,代码几乎可以自己写出来。
因为我们决定让程序忽略除数字之外的任何输入(甚至包括在注释中的数字),我们实际上可以执行文本查询。我们不必这样做,但我们可以。
依我拙见,形成文本查询,而不必遵循非常严格的语法,会让软件更加用户友好。
假设我们想要制作一个用于使用 4x5 英寸胶片的小孔相机。该胶片的标准焦距约为 150 毫米。我们想要微调我们的焦距,以使小孔直径尽可能为一个圆整数。我们还假设我们对相机非常熟悉,但对计算机有些畏惧。与其只需输入一堆数字,我们想问几个问题。
我们的会话可能是这样的:
我们发现,对于焦距为 150 的情况,我们的针孔直径应为 490 微米,或 0.49 毫米,如果我们选择几乎相同的焦距为 156 毫米,我们可以使用直径正好为半毫米的针孔。
因为我们选择了 # 字符来表示评论的开头,所以我们可以将我们的小孔软件视为一种脚本语言。
你可能见过以shell开头的脚本。
…或…
因为 #! 后的空格是可选的。
每当 UNIX®被要求运行以 #! 开头的可执行文件时,它会假定该文件是一个脚本。它会将该命令添加到脚本第一行的其余部分,并尝试执行该命令。
现在假设我们已经在/usr/local/bin/中安装了 pinhole,我们现在可以编写一个脚本,以计算适用于 120 胶片常用的各种焦距的不同孔径。
脚本可能看起来像这样:
因为 120 是一部中等大小的电影,我们可以将此文件命名为 medium。
我们可以将其权限设置为执行,并像运行程序一样运行它:
UNIX® 将解释最后一个命令为:
它将运行该命令并显示:
现在,让我们输入:
UNIX® 将把它视为:
这给它两个冲突的选项: -b 和 -c (使用贝德尔常数和使用康纳斯常数)。我们已编程,所以后面的选项将覆盖前面的选项-我们的程序将使用康纳斯常数来计算所有内容:
我们决定毕竟使用贝德尔常数。我们想将其值保存为逗号分隔的文件:
在 MS-DOS® 和 Windows® 下“成长”的汇编语言程序员往往倾向于走捷径。阅读键盘扫描码并直接写入视频内存是两个经典的例子,在 MS-DOS® 下这些做法并不受到指责,反而被认为是正确的做法。
原因是什么?PC BIOS 和 MS-DOS® 在执行这些操作时都特别慢。
您可能会被诱惑在 FreeBSD 环境中继续类似的做法。例如,我曾看到一个网站,解释如何访问流行的 FreeBSD 克隆版本上的键盘扫描码。
这在 FreeBSD 环境中通常是一个非常糟糕的主意!让我解释为什么。
首先,这可能根本不可能。UNIX®在受保护模式下运行。只有内核和设备驱动程序被允许直接访问硬件。也许某个特定的 UNIX®克隆版允许您读取键盘的扫描码,但现实情况是真正的 UNIX®操作系统可能不会允许。而且,即使某个版本可能让您这样做,下一个版本可能不会,因此您精心编写的软件可能会一夜之间变成一只恐龙。
但是有一个更重要的原因不要尝试直接访问硬件(当然,除非您正在编写设备驱动程序),即使是在让您这样做的 UNIX®类似系统中。
UNIX® 是一个抽象!*
在设计哲学上,MS-DOS® 和 UNIX® 之间存在重大差异。MS-DOS® 被设计为单用户系统。它在连接键盘和视频屏幕的计算机上运行。用户输入几乎肯定来自该键盘。您程序的输出几乎总是显示在那个屏幕上。
在 UNIX® 下,这绝对不是保证。UNIX® 用户经常会使用管道和重定向程序的输入和输出:
如果您已经编写了程序 2,则您的输入不来自键盘,而来自程序 1 的输出。同样,您的输出不会显示在屏幕上,而会成为程序 3 的输入,该程序的输出又会写入 file1。
但还有更多!即使您确保您的输入来自终端,您的输出传送到终端,也不能保证该终端是 PC:它的视频内存可能不在您期望的位置,键盘也可能不会产生 PC 风格的扫描码。它可能是苹果电脑®,或任何其他计算机。
现在您可能会摇头:我的软件是使用 PC 汇编语言编写的,它怎么可能在苹果电脑®上运行?但我并没有说您的软件会在苹果电脑®上运行,只是说它的终端可能是苹果电脑®。
在 UNIX® 下,终端不必直接连接到运行您软件的计算机,它甚至可以在另一个大陆,又或者在另一个行星上。很可能一名澳大利亚的 Macintosh® 用户通过 telnet 连接到北美(或其他任何地方)的 UNIX® 系统。软件在一个计算机上运行,而终端在另一台计算机上:如果您尝试读取扫描码,您将得到错误的输入!
同样适用于任何其他硬件:您正在阅读的文件可能在您无法直接访问的磁盘上。您正在从一个太空船上连接的相机中读取图像,通过卫星与您连接。
这就是为什么在 UNIX® 下您绝对不应该对于数据从何处来源和到何处去做出任何假设。永远让系统处理对硬件的物理访问。
没有来自 FreeBSD 技术讨论邮件列表中许多经验丰富的 FreeBSD 程序员的帮助,这个教程将是不可能的。他们中的许多人耐心地回答了我的问题,并在我尝试探索 UNIX® 系统编程的内部工作以及 FreeBSD 特别是正确的方向。
Thomas M. Sommers 为我开了门。他的《在 FreeBSD 汇编语言中如何编写“Hello, world”》网页是我第一次接触在 FreeBSD 下汇编语言编程示例。
Jake Burkholder 一直保持着门敞开,乐意回答我所有的问题,并提供给我示例汇编语言源代码。
版权所有 © 2000-2001 G. Adam Stanislav。保留所有权利。