POSIX信号量机制是3种IPC机制之一,3种IPC机制源于POSIX.1的实时扩展。Single UNIX Specification将3种机制(消息队列、信号量和共享存储)置于可选部分中。在SUSv4之前,POSIX信号量接口已经被包含在信号量选项中。在SUSv4中,这些接口被移至了基本规范,而消息队列和共享存储接口依然是可选的。
POSIX信号量接口意在解决XSI信号量接口的几个缺陷。
•相比于XSI接口,POSIX信号量接口考虑到了更高性能的实现。
•POSIX 信号量接口使用更简单:没有信号量集,在熟悉的文件系统操作后一些接口被模式化了。尽管没有要求一定要在文件系统中实现,但是一些系统的确是这么实现的。
•POSIX信号量在删除时表现更完美。回忆一下,当一个XSI信号量被删除时,使用这个信号量标识符的操作会失败,并将errno设置成EIDRM。使用POSIX信号量时,操作能继续正常工作直到该信号量的最后一次引用被释放。
POSIX信号量有两种形式:命名的和未命名的。它们的差异在于创建和销毁的形式上,但其他工作一样。未命名信号量只存在于内存中,并要求能使用信号量的进程必须可以访问内存。这意味着它们只能应用在同一进程中的线程,或者不同进程中已经映射相同内存内容到它们的地址空间中的线程。相反,命名信号量可以通过名字访问,因此可以被任何已知它们名字的进程中的线程使用。
我们可以调用sem_open函数来创建一个新的命名信号量或者使用一个现有信号量。
#include <semaphore.h>
sem_t *sem_open(const char *name, int oflag, ... /* mode_t mode,
unsigned int value */ );
返回值:若成功,返回指向信号量的指针;若出错,返回SEM_FAILED
当使用一个现有的命名信号量时,我们仅仅指定两个参数:信号量的名字和 oflag 参数的 0值。当这个oflag参数有O_CREAT标志集时,如果命名信号量不存在,则创建一个新的。如果它已经存在,则会被使用,但是不会有额外的初始化发生。
当我们指定O_CREAT标志时,需要提供两个额外的参数。mode参数指定谁可以访问信号量。mode的取值和打开文件的权限位相同:用户读、用户写、用户执行、组读、组写、组执行、其他读、其他写和其他执行。赋值给信号量的权限可以被调用者的文件创建屏蔽字修改(见 4.5 节和4.8节)。注意,只有读和写访问要紧,但是当我们打开一个现有信号量时接口不允许指定模式。实现经常为读和写打开信号量。
在创建信号量时,value参数用来指定信号量的初始值。它的取值是0~SEM_VALUE_MAX(见图2-9)。
如果我们想确保创建的是信号量,可以设置oflag参数为O_CREAT|O_EXCL。如果信号量已经存在,会导致sem_open失败。
为了增加可移植性,在选择信号量命名时必须遵循一定的规则。
•名字的第一个字符应该为斜杠(/)。尽管没有要求POSIX信号量的实现要使用文件系统,但是如果使用了文件系统,我们就要在名字被解释时消除二义性。
•名字不应包含其他斜杠以此避免实现定义的行为。例如,如果文件系统被使用了,那么名字/mysem和//mysem会被认定为是同一个文件名,但是如果实现没有使用文件系统,那么这两种命名可以被认为是不同的(考虑下如果实现把名字哈希运算转换成一个用来识别信号量的整数值会发生什么)。
•信号量名字的最大长度是实现定义的。名字不应该长于_POSIX_NAME_MAX(见图 2-8)个字符长度。因为这是使用文件系统的实现能允许的最大名字长度的限制。
如果想在信号量上进行操作,sem_open函数会为我们返回一个信号量指针,用于传递到其他信号量函数上。当完成信号量操作时,可以调用sem_close函数来释放任何信号量相关的资源。
#include <semaphore.h>
int sem_close(sem_t *sem);
返回值:若成功,返回0;若出错,返回-1
如果进程没有首先调用sem_close而退出,那么内核将自动关闭任何打开的信号量。注意,这不会影响信号量值的状态—如果已经对它进行了增1操作,这并不会仅因为退出而改变。类似地,如果调用sem_close,信号量值也不会受到影响。在XSI信号量中没有类似SEM_UNDO标志的机制。
可以使用sem_unlink函数来销毁一个命名信号量。
#include <semaphore.h>
int sem_unlink(const char *name);
返回值:若成功,返回0;若出错,返回-1
sem_unlink函数删除信号量的名字。如果没有打开的信号量引用,则该信号量会被销毁。否则,销毁将延迟到最后一个打开的引用关闭。
不像XSI信号量,我们只能通过一个函数调用来调节POSIX信号量的值。计数减1和对一个二进制信号量加锁或者获取计数信号量的相关资源是相类似的。
注意,信号量和POSIX信号量之间是没有差别的。是采用二进制信号量还是用计数信号量取决于如何初始化和使用信号量。如果一个信号量只是有值 0 或者 1,那么它就是二进制信号量。当二进制信号量是1时,它就是“解锁的”,如果它的值是0,那就是“加锁的”。
可以使用sem_wait或者sem_trywait函数来实现信号量的减1操作。
#include <semaphore.h>
int sem_trywait(sem_t *sem);
int sem_wait(sem_t *sem);
两个函数的返回值:若成功,返回0;若出错则,返回−1
使用sem_wait函数时,如果信号量计数是0就会发生阻塞。直到成功使信号量减1或者被信号中断时才返回。可以使用sem_trywait函数来避免阻塞。调用sem_trywait时,如果信号量是0,则不会阻塞,而是会返回−1并且将errno置为EAGAIN。
第三个选择是阻塞一段确定的时间。为此,可以使用sem_timewait函数。
#include <semaphore.h>
#include <time.h>
int sem_timedwait(sem_t *restrict sem,
const struct timespec *restrict tsptr);
返回值:若成功,返回0;若出错,返回−1
想要放弃等待信号量的时候,可以用tsptr参数指定绝对时间。超时是基于CLOCK_REALTIME时钟的(回忆图6-8)。如果信号量可以立即减1,那么超时值就不重要了,尽管指定的可能是过去的某个时间,信号量的减 1 操作依然会成功。如果超时到期并且信号量计数没能减 1, sem_timedwait将返回-1且将errno设置为ETIMEDOUT。
可以调用sem_post函数使信号量值增1。这和解锁一个二进制信号量或者释放一个计数信号量相关的资源的过程是类似的。
#include <semaphore.h>
int sem_post(sem_t *sem);
返回值:若成功,返回0;若出错,返回−1
调用sem_post时,如果在调用sem_wait(或者sem_timedwait)中发生进程阻塞,那么进程会被唤醒并且被sem_post增1的信号量计数会再次被sem_wait(或者sem_timedwait)减1。
当我们想在单个进程中使用POSIX信号量时,使用未命名信号量更容易。这仅仅改变创建和销毁信号量的方式。可以调用sem_init函数来创建一个未命名的信号量。
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
返回值:若成功,返回0;若出错,返回−1
pshared参数表明是否在多个进程中使用信号量。如果是,将其设置成一个非0值。value参数指定了信号量的初始值。
需要声明一个sem_t类型的变量并把它的地址传递给sem_init来实现初始化,而不是像sem_open函数那样返回一个指向信号量的指针。如果要在两个进程之间使用信号量,需要确保sem参数指向两个进程之间共享的内存范围。
对未命名信号量的使用已经完成时,可以调用sem_destroy函数丢弃它。
#include <semaphore.h>
int sem_destroy(sem_t *sem);
返回值:若成功,返回0;若出错,返回−1
调用sem_destroy后,不能再使用任何带有 sem 的信号量函数,除非通过调用 sem_init重新初始化它。
sem_getvalue函数可以用来检索信号量值。
#include <semaphore.h>
int sem_getvalue(sem_t *restrict sem, int *restrict valp);
返回值:若成功,返回0;若出错,返回−1
成功后,valp指向的整数值将包含信号量值。但是请注意,我们试图要使用我们刚读出来的值的时候,信号量的值可能已经变了。除非使用额外的同步机制来避免这种竞争,否则 sem_getvalue函数只能用于调试。
Mac OS X 10.6.8不支持sem_getvalue函数。
实例
介绍POSIX接口的动机之一就是,通过设计,它们的性能要明显好于现有XSI信号量接口。下面将了解现有系统是否达到了这个目标,尽管这些系统没有设计支持实时的应用。
在图15-34中,让3个进程在两种平台(Linux 3.2.0和Solaris 10)上竞争分配和释放信号量1 000 000次,比较了分别使用XSI信号量(不带SEM_UNDO)和POSIX信号量时的性能。
图15-34 信号量实现的时间比较
在图15-34中可以看到,在Solaris系统中,POSIX信号量相对于XSI信号量在时间上仅提高了12%,但是在Linux系统中却提高了94%(近18倍的速度)。如果跟踪程序,我们会发现,POSIX信号量的Linux实现将文件映射到了进程地址空间中,并且没有使用系统调用来操作各自的信号量。
实例
回忆图12-5,Single UNIX Specification并没用定义当一个线程对一个普通互斥量加锁,而另一个线程试图去解锁它的情况,但是这种情况下错误检查互斥量和递归互斥量会产生错误。因为二进制信号量可以像互斥量一样来使用,我们可以使用信号量来创建自己的锁原语从而提供互斥。
假设我们将要创建自己的锁,这种锁能被一个线程加锁而被另一线程解锁,那么它的结构可能是这样的:
struct slock {
sem_t *semp;
char name[_POSIX_NAME_MAX];
};
图15-35中的程序展示了基于信号量的互斥原语的实现。
图15-35 使用POSIX信号量的互斥
根据进程 ID 和计数器来创建名字。我们不会刻意用互斥量去保护计数器,因为当两个竞争的线程同时调用s_alloc并以同一个名字结束时,在调用sem_open中使用O_EXCL标志将会使其中一个线程成功而另一个线程失败,失败的线程会将errno设置成EEXIST,所以对于这种情况,我们只是再次尝试。注意,我们打开一个信号量后断开了它的连接。这销毁了名字,所以导致其他进程不能再次访问它,这也简化了进程结束时的清理工作。
下面详细说明客户进程和服务器进程的某些属性,这些属性受到它们之间所使用的各种 IPC类型的影响。最简单的关系类型是使客户进程 fork 然后 exec 所希望的服务器进程。在 fork之前先创建两个半双工管道使数据可在两个方向传输。图15-16是这种安排的一个例子。所执行的服务器进程可能是一个设置用户 ID 的程序,这使它具有了特权。另外,服务器进程查看客户进程的实际用户ID就可以决定客户进程的真实身份。(回忆8.10节,从中可了解到在exec前后实际用户ID和实际组ID并没有改变。)
在这种安排下,可以构建一个open服务器进程(open server)。(17.5节提供了这种客户进程-服务器进程机制的一种实现。)它为客户进程打开文件而不是客户进程自己调用 open 函数。这样就可以在正常的UNIX用户权限、组权限以及其他权限之上或之外,增加附加的权限检查。假定服务器进程执行的是设置用户ID程序,这给予了它附加的权限(很可能是root权限)。服务器进程用客户进程的实际用户 ID 来决定是否给予它对所请求文件的访问权限。使用这种方式,可以构建一个服务器进程,它允许某些用户获得通常没有的访问权限。
在此例子中,因为服务器进程是父进程的子进程,所以它所能做的就是将文件内容传送给父进程。尽管这种方式对普通文件工作得很好,但是对有些文件却不能工作,如特殊设备文件。我们希望能做的是使服务器进程打开所要求的文件,并传回文件描述符。但是实际情况却是父进程可向子进程传送打开文件描述符,而子进程却不能向父进程传回文件描述符(除非使用专门的编程技术,这将在第17章介绍)。
图 15-23 中展示了另一种类型的服务器进程。这种服务器进程是一个守护进程,所有客户进程用某种形式的 IPC 与其联系。对于这种形式的客户进程-服务器进程关系,不能使用管道。需要使用一种形式的命名IPC,如FIFO或消息队列。使用FIFO时,如果服务器进程必需将数据送回客户进程,则对每个客户进程都要有单独使用的 FIFO。如果客户进程-服务器进程应用程序只有客户进程向服务器进程发送数据,则只需要一个众所周知的FIFO。(System V行式打印机假脱机程序使用这种形式的客户进程-服务器进程。客户进程是 lp(1)命令,服务器进程是 lpsched守护进程。因为只有从客户进程到服务器进程的数据流,所有只需使用一个FIFO。没有需要送回客户进程的数据。)
使用消息队列则存在多种可能性。
(1)在服务器进程和所有客户进程之间只使用一个队列,使用每个消息的类型字段指明谁是消息的接受者。例如,客户进程可以用设置为1的类型字段来发送它们的消息。在请求之中应包括客户进程的进程ID。此后,服务器进程在发送响应消息时,将类型字段设置为客户进程的进程ID。服务器进程只接受类型字段为1的消息(msgrcv的第4个参数),客户进程则只接受类型字段等于它们进程ID的消息。
(2)另一种方法是每个客户进程使用一个单独的消息队列。在向服务器进程发送第一个请求之前,每个客户进程先使用键IPC_PRIVATE创建它自己的消息队列。服务器进程也有它自己的队列,其键或标识符是所有客户进程都知道的。客户进程将其第一个请求发送到服务器进程的众所周知的队列上,该请求中应包含其客户进程消息队列的队列ID。服务器进程将其第一个响应发送到此客户进程队列,此后的所有请求和响应都在此队列上交换。
使用消息队列的这两种技术都可以用共享内存段和同步方法(信号量或记录锁)来实现。
使用这种类型的客户进程-服务器进程关系(客户进程和服务器进程是无关进程)的问题是服务器进程如何准确地标识客户进程。除非服务器进程正在执行一种非特权操作,否则服务器进程知道客户进程的身份是很重要的。例如,若服务器进程是一个设置用户 ID 程序,就有这种要求。虽然所有这几种形式的IPC都经由内核,但是它们并未提供任何设施使内核能够标识发送者。
对于消息队列,如果在客户进程和服务器进程之间使用一个专用队列(于是一次只有一个消息在该队列上),那么队列的 msg_lspid 包含了对方进程的进程 ID。但是当客户进程将请求发送给服务器进程时,我们想要的是客户进程的有效用户 ID,而不是它的进程 ID。现在还没有一种可移植的方法,在已知进程ID情况下可以得到有效用户ID。(自然地,内核在进程表项中保持有这两种值,但是除非彻底检查内核存储空间,否则已知一个,无法得到另一个。)
我们将在17.2节中使用下列技术,使服务器进程可以标识客户进程。这一技术可使用FIFO、消息队列、信号量以及共享存储。在下面的说明中假定按图15-23使用了FIFO。客户进程必须创建它自己的FIFO,并且设置该FIFO的文件访问权限,使得只允许用户读和用户写。假定服务器进程具有超级用户特权(或者它很可能并不关心客户进程的真实标识),那么服务器进程仍可读、写此FIFO。当服务器进程在众所周知的FIFO上接收到客户进程的第一个请求时(它应当包含客户进程专用FIFO的标识),服务器进程调用针对客户进程专用FIFO的stat或fstat。服务器进程假设:客户进程的有效用户ID是FIFO的所有者(stat结构的st_uid字段)。服务器进程验证该FIFO只有用户读和用户写权限。服务器进程还应检查与该 FIFO 有关的 3 个时间量(stat 结构的 st_atime、st_mtime和st_ctime字段),要检查它们与当前时间是否很接近(如不早于当前时间15秒或30秒)。如果一个恶意客户进程可以创建一个FIFO,使另一个用户成为其所有者,并且设置该文件的权限位为用户读和用户写,那么在系统中就存在了其他基础性的安全问题。
为了用XSI IPC实现这种技术,回想一下与每个消息队列、信号量以及共享存储段相关的ipc_perm结构,它标识了IPC结构的创建者(cuid和cgid字段)。和使用FIFO的实例一样,服务器进程应当要求客户进程创建该IPC结构,并使客户进程将访问权设置为只允许用户读和用户写。服务器进程也应检验与该IPC相关的时间值与当前时间是否很接近(因为这些IPC结构在显式地删除之前一直存在)。
在17.3节中,将会看到进行这种身份验证的一种更好的方法,就是内核提供客户进程的有效用户ID和有效组ID。套接字子系统在两个进程之间传送文件描述符时可以做到这一点。
本章详细说明了进程间通信的多种形式:管道、命名管道(FIFO)、通常称为 XSI IPC 的 3种形式的IPC(消息队列、信号量和共享存储),以及POSIX提供的替代信号量机制。信号量实际上是同步原语而不是 IPC,常用于共享资源(如共享存储段)的同步访问。对于管道,我们说明了popen函数的实现、协同进程以及使用标准I/O库缓冲机制时可能遇到的问题。
经过分别对消息队列与全双工管道的时间以及信号量与记录锁的时间进行比较,提出了下列建议:要学会使用管道和FIFO,因为这两种基本技术仍可有效地应用于大量的应用程序。在新的应用程序中,要尽可能避免使用消息队列以及信号量,而应当考虑全双工管道和记录锁,它们使用起来会简单得多。共享存储仍然有它的用途,虽然通过mmap函数(见14.8节)也能提供同样的功能。
下一章将介绍网络IPC,它们使进程能够跨越计算机的边界进行通信。
15.1 在图15-6的程序中,在父进程代码的末尾删除waitpid前的close,结果将如何?
15.2 在图15-6的程序中,在父进程代码的末尾删除waitpid,结果将如何?
15.3 如果 popen 函数的参数是一个不存在的命令,会造成什么结果?编写一段小程序对此进行测试。
15.4 在图15-18 的程序中,删除信号处理程序,执行该程序,然后终止子进程。输入一行输入后,怎样才能说明父进程是由SIGPIPE终止的?
15.5 在图15-18的程序中,用标准I/O库代替进行管道读、写的read和write。
15.6 POSIX.1加入waitpid函数的理由之一是,POSIX.1之前的大多数系统不能处理下面的代码。
if ( (fp = popen("/bin/true", "r")) == NULL )
.
if ( (rc = system("sleep 100")) == -1)
.
if (pclose(fp) == -1)
...
若在这段代码中不使用waitpid函数会如何?用wait代替呢?
15.7 当一个管道被写者关闭后,解释 select 和 poll 是如何处理该管道的输入描述符的。为了确定答案是否正确,编两个小测试程序,一个用select,另一个用poll。
当一个管道的读端被关闭时,请重做此习题以查看该管道的输出描述符。
15.8 如果popen以type为"r"执行cmdstring,并将结果写到标准错误输出,结果会如何?
15.9 既然popen函数能使shell执行它的cmdstring参数,那么cmdstring终止时会产生什么结果?(提示:画出与此相关的所有进程。)
15.10 POSIX.1特别声明没有定义为读写而打开FIFO。虽然大多数UNIX系统允许读写FIFO,但是请用非阻塞方法实现为读写而打开FIFO。
15.11 除非文件包含敏感数据或机密数据,否则允许其他用户读文件不会造成损害。但是,如果一个恶意进程读取了被一个服务器进程和几个客户进程使用的消息队列中的一条消息后,会产生什么后果?恶意进程需要知道哪些信息就可以读消息队列?
15.12 编写一段程序完成下面的工作。执行一个循环5次,在每次循环中,创建一个消息队列,打印该队列的标识符,然后删除队列。接着再循环5次,在每次循环中利用键IPC_PRIVATE创建消息队列,并将一条消息放在队列中。程序终止后用 ipcs(1)查看消息队列。解释队列标识符的变化。
15.13 描述如何在共享存储段中建立一个数据对象的链接列表。列表指针如何存储?
15.14 画出图15-33 中的程序运行时下列值随时间变化的曲线图:父进程和子进程中的变量 i、共享存储区中的长整型值以及update函数的返回值。假设子进程在fork后先运行。
15.15 使用15.9节中的XSI共享存储函数代替共享存储映射区,改写图15-33中的程序。
15.16 使用15.8节中的XSI信号量函数改写图15-33中的程序,实现父进程与子进程间的交替。
15.17 使用建议性记录锁改写图15-33中的程序,实现父进程与子进程间的交替。
15.18 使用15.10节中的POSIX信号量函数改写图15-33中的程序,实现父进程与子进程间的交替。
上一章我们考察了各种UNIX系统所提供的经典进程间通信机制(IPC):管道、FIFO、消息队列、信号量以及共享存储。这些机制允许在同一台计算机上运行的进程可以相互通信。本章将考察不同计算机(通过网络相连)上的进程相互通信的机制:网络进程间通信(network IPC)。
在本章中,我们将描述套接字网络进程间通信接口,进程用该接口能够和其他进程通信,无论它们是在同一台计算机上还是在不同的计算机上。实际上,这正是套接字接口的设计目标之一:同样的接口既可以用于计算机间通信,也可以用于计算机内通信。尽管套接字接口可以采用许多不同的网络协议进行通信,但本章的讨论限制在因特网事实上的通信标准:TCP/IP协议栈。
POSIX.1中指定的套接字API是基于4.4 BSD套接字接口的。尽管这些年套接字接口有些细微的变化,但是当前的套接字接口与20世纪80年代早期4.2BSD所引入的接口很类似。
本章仅是一个套接字API的概述。Stevens、Fenner和Rudoff[2004]在有关UNIX系统网络编程的权威性文献中详细讨论了套接字接口。
套接字是通信端点的抽象。正如使用文件描述符访问文件,应用程序用套接字描述符访问套接字。套接字描述符在UNIX系统中被当作是一种文件描述符。事实上,许多处理文件描述符的函数(如read和write)可以用于处理套接字描述符。
为创建一个套接字,调用socket函数。
#include <sys/socket.h>
int socket (int domain, int type, int protocol);
返回值:若成功,返回文件(套接字)描述符;若出错,返回−1
参数domain(域)确定通信的特性,包括地址格式(在下一节详细描述)。图16-1总结了由POSIX.1指定的各个域。各个域都有自己表示地址的格式,而表示各个域的常数都以AF_开头,意指地址族(address family)。
我们将在17.2节讨论UNIX域。大多数系统还定义了AF_LOCAL域,这是AF_UNIX的别名。AF_UNSPEC 域可以代表“任何”域。历史上,有些平台支持其他网络协议,如 AF_IPX 域代表的NetWare协议族,但这些协议的域常数没有被POSIX.1标准定义。
图16-1 套接字通信域
参数type确定套接字的类型,进一步确定通信特征。图16-2总结了由POSIX.1定义的套接字类型,但在实现中可以自由增加其他类型的支持。
图16-2 套接字类型
参数protocol通常是 0,表示为给定的域和套接字类型选择默认协议。当对同一域和套接字类型支持多个协议时,可以使用protocol 选择一个特定协议。在 AF_INET 通信域中,套接字类型SOCK_STREAM的默认协议是传输控制协议(Transmission Control Protocol,TCP)。在AF_INET通信域中,套接字类型SOCK_DGRAM的默认协议是UDP。图16-3列出了为因特网域套接字定义的协议。
图16-3 为因特网域套接字定义的协议
对于数据报(SOCK_DGRAM)接口,两个对等进程之间通信时不需要逻辑连接。只需要向对等进程所使用的套接字送出一个报文。
因此数据报提供了一个无连接的服务。另一方面,字节流(SOCK_STREAM)要求在交换数据之前,在本地套接字和通信的对等进程的套接字之间建立一个逻辑连接。
数据报是自包含报文。发送数据报近似于给某人邮寄信件。你能邮寄很多信,但你不能保证传递的次序,并且可能有些信件会丢失在路上。每封信件包含接收者地址,使这封信件独立于所有其他信件。每封信件可能送达不同的接收者。
相反,使用面向连接的协议通信就像与对方打电话。首先,需要通过电话建立一个连接,连接建立好之后,彼此能双向地通信。每个连接是端到端的通信链路。对话中不包含地址信息,就像呼叫两端存在一个点对点虚拟连接,并且连接本身暗示特定的源和目的地。
SOCK_STREAM 套接字提供字节流服务,所以应用程序分辨不出报文的界限。这意味着从SOCK_STREAM 套接字读数据时,它也许不会返回所有由发送进程所写的字节数。最终可以获得发送过来的所有数据,但也许要通过若干次函数调用才能得到。
SOCK_SEQPACKET 套接字和 SOCK_STREAM 套接字很类似,只是从该套接字得到的是基于报文的服务而不是字节流服务。这意味着从SOCK_SEQPACKET套接字接收的数据量与对方所发送的一致。流控制传输协议(Stream Control Transmission Protocol,SCTP)提供了因特网域上的顺序数据包服务。
SOCK_RAW 套接字提供一个数据报接口,用于直接访问下面的网络层(即因特网域中的 IP层)。使用这个接口时,应用程序负责构造自己的协议头部,这是因为传输协议(如TCP和UDP)被绕过了。当创建一个原始套接字时,需要有超级用户特权,这样可以防止恶意应用程序绕过内建安全机制来创建报文。
调用socket与调用open相类似。在两种情况下,均可获得用于I/O的文件描述符。当不再需要该文件描述符时,调用close来关闭对文件或套接字的访问,并且释放该描述符以便重新使用。
虽然套接字描述符本质上是一个文件描述符,但不是所有参数为文件描述符的函数都可以接受套接字描述符。图16-4总结了到目前为止所讨论的大多数以文件描述符为参数的函数使用套接字描述符时的行为。未指定和由实现定义的行为通常意味着该函数对套接字描述符无效。例如, lseek不能以套接字描述符为参数,因为套接字不支持文件偏移量的概念。
图16-4 文件描述符函数使用套接字时的行为
套接字通信是双向的。可以采用shutdown函数来禁止一个套接字的I/O。
#include <sys/socket.h>
int shutdown (int sockfd, int how);
返回值:若成功,返回0;若出错,返回−1
如果how是SHUT_RD(关闭读端),那么无法从套接字读取数据。如果how是SHUT_WR(关闭写端),那么无法使用套接字发送数据。如果how是SHUT_RDWR,则既无法读取数据,又无法发送数据。
能够关闭(close)一个套接字,为何还使用shutdown呢?这里有若干理由。首先,只有最后一个活动引用关闭时,close才释放网络端点。这意味着如果复制一个套接字(如采用dup),要直到关闭了最后一个引用它的文件描述符才会释放这个套接字。而 shutdown 允许使一个套接字处于不活动状态,和引用它的文件描述符数目无关。其次,有时可以很方便地关闭套接字双向传输中的一个方向。例如,如果想让所通信的进程能够确定数据传输何时结束,可以关闭该套接字的写端,然而通过该套接字读端仍可以继续接收数据。
上一节学习了如何创建和销毁一个套接字。在学习用套接字做一些有意义的事情之前,需要知道如何标识一个目标通信进程。进程标识由两部分组成。一部分是计算机的网络地址,它可以帮助标识网络上我们想与之通信的计算机;另一部分是该计算机上用端口号表示的服务,它可以帮助标识特定的进程。
与同一台计算机上的进程进行通信时,一般不用考虑字节序。字节序是一个处理器架构特性,用于指示像整数这样的大数据类型内部的字节如何排序。图16-5显示了一个32位整数中的字节是如何排序的。
图16-5 一个32位整数的字节序
如果处理器架构支持大端(big-endian)字节序,那么最大字节地址出现在最低有效字节(Least Significant Byte,LSB)上。小端(little-endian)字节序则相反:最低有效字节包含最小字节地址。注意,不管字节如何排序,最高有效字节(Most Significant Byte,MSB)总是在左边,最低有效字节总是在右边。因此,如果想给一个32 位整数赋值0x04030201,不管字节序如何,最高有效字节都将包含4,最低有效字节都将包含1。如果接下来想将一个字符指针(cp)强制转换到这个整数地址,就会看到字节序带来的不同。在小端字节序的处理器上,cp[0]指向最低有效字节因而包含1,cp[3]指向最高有效字节因而包含 4。相比较而言,在大端字节序的处理器上,cp[0]指向最高有效字节因而包含4,cp[3]指向最低有效字节因而包含1。图16-6总结了本文所讨论的4种平台的字节序。
图16-6 测试平台的字节序
有些处理器可以配置成大端,也可以配置成小端,因而使问题变得更让人困惑。
网络协议指定了字节序,因此异构计算机系统能够交换协议信息而不会被字节序所混淆。TCP/IP协议栈使用大端字节序。应用程序交换格式化数据时,字节序问题就会出现。对于TCP/IP,地址用网络字节序来表示,所以应用程序有时需要在处理器的字节序与网络字节序之间转换它们。例如,以一种易读的形式打印一个地址时,这种转换很常见。
对于TCP/IP应用程序,有4个用来在处理器字节序和网络字节序之间实施转换的函数。
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostint32);
返回值:以网络字节序表示的32位整数
uint16_t htons(uint16_t hostint16);
返回值:以网络字节序表示的16位整数
uint32_t ntohl(uint32_t netint32);
返回值:以主机字节序表示的32位整数
uint16_t ntohs(uint16_t netint16);
返回值:以主机字节序表示的16位整数
h表示“主机”字节序,n表示“网络”字节序。l表示“长”(即4字节)整数,s表示“短”(即4字节)整数。虽然在使用这些函数时包含的是<arpa/inet.h>头文件,但系统实现经常是在其他头文件中声明这些函数的,只是这些头文件都包含在<arpa/inet.h>中。对于系统来说,把这些函数实现为宏也是很常见的。
一个地址标识一个特定通信域的套接字端点,地址格式与这个特定的通信域相关。为使不同格式地址能够传入到套接字函数,地址会被强制转换成一个通用的地址结构sockaddr:
struct sockaddr {
sa_family_t sa_family; /* address family */
char sa_data[]; /* variable-length address */
┇
};
套接字实现可以自由地添加额外的成员并且定义 sa_data 成员的大小。例如,在 Linux 中,该结构定义如下:
struct sockaddr {
sa_family_t sa_family; /* address family */
char sa_data[14]; /* variable-length address */
};
但是在FreeBSD中,该结构定义如下:
struct sockaddr {
unsigned char sa_len; /* total length */
sa_family_t sa_family; /* address family */
char sa_data[14]; /* variable-length address */
};
因特网地址定义在<netinet/in.h>头文件中。在IPv4因特网域(AF_INET)中,套接字地址用结构sockaddr_in表示:
struct in_addr {
in_addr_t s_ addr; /* IPv4 address */
};
struct sockaddr_in {
sa_family_t sin_family; /* address family */
in_port_t sin_port; /* port number */
struct in_addr sin_addr; /* IPv4 address */
};
数据类型in_port_t定义成uint16_t。数据类型in_addr_t定义成uint32_t。这些整数类型在<stdint.h>中定义并指定了相应的位数。
与AF_INET域相比较,IPv6因特网域(AF_INET6)套接字地址用结构sockaddr_in6表示:
struct_in6_addr {
uint8_t s6_addr[16]; /* IPv6 address */
};
struct sockaddr_in6 {
sa_family_t sin6_family; /* address family */
in_port_t sin6_port; /* port number */
uint32_t sin6_flowinfo; /* traffic class and flow info */
struct in6_addr sin6_addr; /* IPv6 address*/
uint32_t sin6_scope_id; /* set of interfaces for scope */
};
这些都是Single UNIX Specification要求的定义。每个实现可以自由添加更多的字段。例如,在Linux中,sockaddr_in定义如下:
struct sockaddr_in {
sa_family_t sin_family; /* address family */
in_port_t sin_port; /* port number */
struct in6_addr sin6_addr; /* IPv4 address */
unsigned char sin_zero[8]; /* filler */
};
其中成员sin_zero为填充字段,应该全部被置为0。
注意,尽管 sockaddr_in 与 sockaddr_in6 结构相差比较大,但它们均被强制转换成sockaddr结构输入到套接字例程中。在17.2节,将会看到UNIX域套接字地址的结构与上述两个因特网域套接字地址格式的不同。
有时,需要打印出能被人理解而不是计算机所理解的地址格式。BSD 网络软件包含函数inet_addr 和 inet_ntoa,用于二进制地址格式与点分十进制字符表示(a.b.c.d)之间的相互转换。但是这些函数仅适用于IPv4地址。有两个新函数inet_ntop和inet_pton具有相似的功能,而且同时支持IPv4地址和IPv6地址。
#include <arpa/inet.h>
const char *inet_ntop(int domain, const void *restrict addr,
char *restrict str, socklen_t size);
返回值:若成功,返回地址字符串指针;若出错,返回NULL
int inet_pton(int domain, const char * restrict str,
void *restrict addr);
返回值:若成功,返回1;若格式无效,返回0;若出错,返回−1
函数 inet_ntop 将网络字节序的二进制地址转换成文本字符串格式。inet_pton 将文本字符串格式转换成网络字节序的二进制地址。参数domain仅支持两个值:AF_INET和AF_INET6。
对于 inet_ntop,参数size指定了保存文本字符串的缓冲区(str)的大小。两个常数用于简化工作:INET_ADDRSTRLEN 定义了足够大的空间来存放一个表示 IPv4 地址的文本字符串;INET6_ADDRSTRLEN 定义了足够大的空间来存放一个表示 IPv6 地址的文本字符串。对于inet_pton,如果 domain是AF_INET,则缓冲区addr需要足够大的空间来存放一个32位地址,如果domain是AF_INET6,则需要足够大的空间来存放一个128位地址。
理想情况下,应用程序不需要了解一个套接字地址的内部结构。如果一个程序简单地传递一个类似于sockaddr结构的套接字地址,并且不依赖于任何协议相关的特性,那么可以与提供相同类型服务的许多不同协议协作。
历史上,BSD 网络软件提供了访问各种网络配置信息的接口。6.7 节简要讨论了网络数据文件和用来访问这些文件的函数。本节将更详细地讨论一些细节,并且引入新的函数来查询寻址信息。
这些函数返回的网络配置信息被存放在许多地方。这个信息可以存放在静态文件(如/etc/hosts 和/etc/services)中,也可以由名字服务管理,如域名系统(Domain Name System,DNS)或者网络信息服务(Network Information Service,NIS)。无论这个信息放在何处,都可以用同样的函数访问它。
通过调用gethostent,可以找到给定计算机系统的主机信息。
#include <netdb.h>
struct hostent *gethostent(void);
返回值:若成功,返回指针;若出错,返回NULL
void sethostent(int stayopen);
void endhostent(void);
如果主机数据库文件没有打开,gethostent会打开它。函数gethostent返回文件中的下一个条目。函数sethostent会打开文件,如果文件已经被打开,那么将其回绕。当stayopen参数设置成非0值时,调用gethostent之后,文件将依然是打开的。函数endhostent可以关闭文件。
当gethostent返回时,会得到一个指向hostent结构的指针,该结构可能包含一个静态的数据缓冲区,每次调用gethostent,缓冲区都会被覆盖。hostent结构至少包含以下成员:struct hostent{
char *h_name; /* name of host */
char **h_aliases; /* pointer to alternate host name array */
int h_addrtype; /* address type */
int h_length; /* length in bytes of address */
char **h_addr_list; /* pointer to array of network addresses */
┇
};
返回的地址采用网络字节序。
另外两个函数gethostbyname和gethostbyaddr,原来包含在hostent函数中,现在则被认为是过时的。SUSv4已经删除了它们。马上将会看到它们的替代函数。
能够采用一套相似的接口来获得网络名字和网络编号。
#include <netdb.h>
struct netent *getnetbyaddr (uint32_t net, int type);
struct netent *getnetbyname(const char *name);
struct netent *getnetent(void);
3个函数的返回值:若成功,返回指针;若出错,返回NULL
void setnetent(int stayopen);
void endnetent(void);
netent结构至少包含以下字段:
struct netent {
char *n_name; /* network name */
char **n_aliases; /* alternate network name array pointer */
int n_addrtype; /* address type */
uint32_t n_net; /* network number */
┇
};
网络编号按照网络字节序返回。地址类型是地址族常量之一(如AF_INET)。
我们可以用以下函数在协议名字和协议编号之间进行映射。
#include <netdb.h>
struct protoent *getprotobyname(const char *name);
struct protoent *getprotobynumber(int proto);
struct protoent *getprotoent(void);
3个函数的返回值:若成功,返回指针;若出错,返回NULL
void setprotoent(int stayopen);
void endprotoent(void);
POSIX.1定义的protoent结构至少包含以下成员:
struct protoent {
char *p_name; /* protocol name */
char **p_ aliases; /* pointer to altername protocol name array */
int p_proto; /* protocol number */
┇
};
服务是由地址的端口号部分表示的。每个服务由一个唯一的众所周知的端口号来支持。可以使用函数getservbyname将一个服务名映射到一个端口号,使用函数getservbyport将一个端口号映射到一个服务名,使用函数getservent顺序扫描服务数据库。
#include <netdb.h>
struct servent *getservbyname(const char *name, const char *proto);
struct servent *getserbyport(int port, const char *proto);
struct servent *getservent(void);
3个函数的返回值:若成功,返回指针,若出错,返回NULL
void setservent(int stayopen);
void endservent(void);
servent结构至少包含以下成员:
struct servent{
char *s_name; /* service name */
char **s_aliases; /* pointer to alternate service name array */
int s_port; /* port number */
char *s_proto; /* name of protocol */
┇
};
POSIX.1定义了若干新的函数,允许一个应用程序将一个主机名和一个服务名映射到一个地址,或者反之。这些函数代替了较老的函数gethostbyname和gethostbyaddr。
getaddrinfo函数允许将一个主机名和一个服务名映射到一个地址。
#include <sys/socket.h>
#include <netdb.h>
int getaddrinfo(const char *restrict host,
const char *restrict service,
const struct addrinfo *restrict hint,
struct addrinfo **restrict res);
返回值:若成功,返回0;若出错,返回非0错误码
void freeaddrinfo(struct addrinfo *ai);
需要提供主机名、服务名,或者两者都提供。如果仅仅提供一个名字,另外一个必须是一个空指针。主机名可以是一个节点名或点分格式的主机地址。
getaddrinfo函数返回一个链表结构addrinfo。可以用freeaddrinfo 来释放一个或多个这种结构,这取决于用ai_next字段链接起来的结构有多少。
addrinfo结构的定义至少包含以下成员:
struct addrinfo {
int ai_flags; /* customize behavior */
int ai_family; /* address family */
int ai_socktype; /* socket type */
int ai_protocol; /* protocol */
socklen_t ai_addrlen; /* length in bytes of address */
struct sockaddr *ai_addr; /* address */
char *ai_canonname; /* canonical name of host */
struct addrinfo *ai_next; /* next in list */
};
┇
可以提供一个可选的hint来选择符合特定条件的地址。hint是一个用于过滤地址的模板,包括ai_family、ai_flags、ai_protocol和ai_socktype字段。剩余的整数字段必须设置为0,指针字段必须为空。图16-7总结了ai_flags字段中的标志,可以用这些标志来自定义如何处理地址和名字。
图16-7 addrinfo结构的标志
如果getaddrinfo失败,不能使用perror或strerror来生成错误消息,而是要调用gai_strerror将返回的错误码转换成错误消息。
#include <netdb.h>
const char *gai_strerror(int error);
返回值:指向描述错误的字符串的指针
getnameinfo函数将一个地址转换成一个主机名和一个服务名。
#include <sys/socket.h>
#include <netdb.h>
int getnameinfo(const struct sockaddr *restrict addr, socklen_t alen,
char *restrict host, socklen_t hostlen,
char *restrict service, socklen_t servlen, int flags);
返回值:若成功,返回0;若出错,返回非0值
套接字地址(addr)被翻译成一个主机名和一个服务名。如果host非空,则指向一个长度为hostlen字节的缓冲区用于存放返回的主机名。同样,如果service非空,则指向一个长度为servlen字节的缓冲区用于存放返回的主机名。
flags参数提供了一些控制翻译的方式。图16-8总结了支持的标志。
图16-8 getnameinfo函数的标志
实例
图16-9说明了getaddrinfo函数的使用方法。
图16-9 打印主机和服务信息
这个程序说明了 getaddrinfo 函数的使用方法。如果有多个协议为指定的主机提供给定的服务,程序会打印出多条信息。本实例仅打印了与IPv4一起工作的那些协议(ai_family为AF_INET)的地址信息。如果想将输出限制在AF_INET协议族,可以在提示中设置ai_family字段。
在一个测试系统上运行这个程序时,得到了以下输出:
$ ./a.out harry nfs
flags canon family inet type stream protocol TCP
host harry address 192.168.1.99 port 2049
flags canon family inet type datagram protocol UDP
host harry address 192.168.1.99 port 2049
将一个客户端的套接字关联上一个地址没有多少新意,可以让系统选一个默认的地址。然而,对于服务器,需要给一个接收客户端请求的服务器套接字关联上一个众所周知的地址。客户端应有一种方法来发现连接服务器所需要的地址,最简单的方法就是服务器保留一个地址并且注册在/etc/services或者某个名字服务中。
使用bind函数来关联地址和套接字。
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t len);
返回值:若成功,返回0;若出错,返回−1
对于使用的地址有以下一些限制。
•在进程正在运行的计算机上,指定的地址必须有效;不能指定一个其他机器的地址。
•地址必须和创建套接字时的地址族所支持的格式相匹配。
•地址中的端口号必须不小于1 024,除非该进程具有相应的特权(即超级用户)。
•一般只能将一个套接字端点绑定到一个给定地址上,尽管有些协议允许多重绑定。
对于因特网域,如果指定IP地址为INADDR_ANY(<netinet/in.h>中定义的),套接字端点可以被绑定到所有的系统网络接口上。这意味着可以接收这个系统所安装的任何一个网卡的数据包。在下一节中可以看到,如果调用 connect 或 listen,但没有将地址绑定到套接字上,系统会选一个地址绑定到套接字上。
可以调用getsockname函数来发现绑定到套接字上的地址。
#include <sys/socket.h>
int getsockname(int sockfd, struct sockaddr *restrict addr,
socklen_t *restrict alenp);
返回值:若成功,返回0;若出错,返回−1
调用 getsockname 之前,将 alenp 设置为一个指向整数的指针,该整数指定缓冲区sockaddr 的长度。返回时,该整数会被设置成返回地址的大小。如果地址和提供的缓冲区长度不匹配,地址会被自动截断而不报错。如果当前没有地址绑定到该套接字,则其结果是未定义的。
如果套接字已经和对等方连接,可以调用getpeername函数来找到对方的地址。
#include <sys/socket.h>
int getpeername(int sockfd, struct sockaddr *restrict addr,
socklen_t *restrict alenp);
返回值:若成功,返回0;若出错,返回−1
除了返回对等方的地址,函数getpeername和getsockname一样。
如果要处理一个面向连接的网络服务(SOCK_STREAM或SOCK_SEQPACKET),那么在开始交换数据以前,需要在请求服务的进程套接字(客户端)和提供服务的进程套接字(服务器)之间建立一个连接。使用connect函数来建立连接。
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t len);
返回值:若成功,返回0;若出错,返回−1
在connect中指定的地址是我们想与之通信的服务器地址。如果sockfd没有绑定到一个地址,connect会给调用者绑定一个默认地址。
当尝试连接服务器时,出于一些原因,连接可能会失败。要想一个连接请求成功,要连接的计算机必须是开启的,并且正在运行,服务器必须绑定到一个想与之连接的地址上,并且服务器的等待连接队列要有足够的空间(后面会有更详细的介绍)。因此,应用程序必须能够处理connect返回的错误,这些错误可能是由一些瞬时条件引起的。
实例
图 16-10 显示了一种如何处理瞬时 connect 错误的方法。如果一个服务器运行在一个负载很重的系统上,就很有可能发生这些错误。
图16-10 支持重试的connect
这个函数展示了指数补偿(exponential backoff)算法。如果调用connect失败,进程会休眠一小段时间,然后进入下次循环再次尝试,每次循环休眠时间会以指数级增加,直到最大延迟为2分钟左右。
然而图16-10中的代码存在一个问题:代码是不可移植的。它在Linux和Solaris上可以工作,但是在FreeBSD和Mac OS X上却不能按预期工作。在基于BSD的套接字实现中,如果第一次连接尝试失败,那么在TCP中继续使用同一个套接字描述符,接下来仍旧会失败。这就是一个协议相关的行为从(协议无关的)套接字接口中显露出来变得应用程序可见的例子。这些都是历史原因,因此Single UNIX Specification警告,如果connect失败,套接字的状态会变成未定义的。
因此,如果 connect 失败,可迁移的应用程序需要关闭套接字。如果想重试,必须打开一个新的套接字。这种更易于迁移的技术如图16-11所示。
图16-11 可迁移的支持重试的连接代码
需要注意的是,因为可能要建立一个新的套接字,给connect_retry函数传递一个套接字描述符参数是没有意义。我们现在返回一个已连接的套接字描述符给调用者,而并非返回一个表示调用成功的值。
如果套接字描述符处于非阻塞模式(该模式将在 16.8 节中进一步讨论),那么在连接不能马上建立时,connect将会返回−1并且将errno设置为特殊的错误码EINPROGRESS。应用程序可以使用poll或者select来判断文件描述符何时可写。如果可写,连接完成。
connect函数还可以用于无连接的网络服务(SOCK_DGRAM)。这看起来有点矛盾,实际上却是一个不错的选择。如果用SOCK_DGRAM套接字调用connect,传送的报文的目标地址会设置成connect调用中所指定的地址,这样每次传送报文时就不需要再提供地址。另外,仅能接收来自指定地址的报文。
服务器调用listen函数来宣告它愿意接受连接请求。
#include <sys/socket.h>
int listen(int sockfd, int backlog);
返回值:若成功,返回0;若出错,返回−1
参数backlog提供了一个提示,提示系统该进程所要入队的未完成连接请求数量。其实际值由系统决定,但上限由<sys/socket.h>中的SOMAXCONN指定。
Solaris系统忽略了<sys/socket.h>中的SOMAXCONN。具体的最大值取决于每个协议的实现。对于TCP,其默认值为128。
一旦队列满,系统就会拒绝多余的连接请求,所以backlog的值应该基于服务器期望负载和处理量来选择,其中处理量是指接受连接请求与启动服务的数量。
一旦服务器调用了listen,所用的套接字就能接收连接请求。使用accept函数获得连接请求并建立连接。
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *restrict addr,
socklen_t *restrict len);
返回值:若成功,返回文件(套接字)描述符;若出错,返回−1
函数accept所返回的文件描述符是套接字描述符,该描述符连接到调用connect的客户端。这个新的套接字描述符和原始套接字(sockfd)具有相同的套接字类型和地址族。传给accept的原始套接字没有关联到这个连接,而是继续保持可用状态并接收其他连接请求。
如果不关心客户端标识,可以将参数addr和len设为NULL。否则,在调用accept之前,将addr参数设为足够大的缓冲区来存放地址,并且将len指向的整数设为这个缓冲区的字节大小。返回时,accept会在缓冲区填充客户端的地址,并且更新指向len的整数来反映该地址的大小。
如果没有连接请求在等待,accept会阻塞直到一个请求到来。如果sockfd处于非阻塞模式, accept会返回−1,并将errno设置为EAGAIN或EWOULDBLOCK。
本文中讨论的所有平台都将EAGAIN定义为EWOULDBLOCK。
如果服务器调用accept,并且当前没有连接请求,服务器会阻塞直到一个请求到来。另外,服务器可以使用poll或select来等待一个请求的到来。在这种情况下,一个带有等待连接请求的套接字会以可读的方式出现。
实例
图16-12显示了一个函数,可以用来分配和初始化套接字供服务器进程使用。
图16-12 初始化一个套接字端点供服务器进程使用
可以看到,TCP有一些奇怪的地址复用规则,这使得这个例子不完备。图16-22显示了有关这个函数的另一个版本,可以绕过这些规则,解决此版本的主要缺陷。
既然一个套接字端点表示为一个文件描述符,那么只要建立连接,就可以使用read和write来通过套接字通信。回忆前面所讲,通过在 connect 函数里面设置默认对等地址,数据报套接字也可以被“连接”。在套接字描述符上使用read和write是非常有意义的,因为这意味着可以将套接字描述符传递给那些原先为处理本地文件而设计的函数。而且还可以安排将套接字描述符传递给子进程,而该子进程执行的程序并不了解套接字。
尽管可以通过read和write交换数据,但这就是这两个函数所能做的一切。如果想指定选项,从多个客户端接收数据包,或者发送带外数据,就需要使用6个为数据传递而设计的套接字函数中的一个。
3个函数用来发送数据,3个用于接收数据。首先,考查用于发送数据的函数。
最简单的是send,它和write很像,但是可以指定标志来改变处理传输数据的方式。
#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t nbytes, int flags);
返回值:若成功,返回发送的字节数;若出错,返回−1
类似write,使用send时套接字必须已经连接。参数buf和nbytes的含义与write中的一致。
然而,与write不同的是,send支持第4个参数flags。3个标志是由Single UNIX Specification定义的,但是具体系统实现支持其他标志的情况也是很常见的。图16-13总结了这些标志。
图16-13 send套接字调用标志
即使send成功返回,也并不表示连接的另一端的进程就一定接收了数据。我们所能保证的只是当send成功返回时,数据已经被无错误地发送到网络驱动程序上。
对于支持报文边界的协议,如果尝试发送的单个报文的长度超过协议所支持的最大长度,那么send会失败,并将errno设为EMSGSIZE。对于字节流协议,send会阻塞直到整个数据传输完成。函数sendto和send很类似。区别在于sendto可以在无连接的套接字上指定一个目标地址。
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *buf, size_t nbytes, int flags,
const struct sockaddr *destaddr, socklen_t destlen);
返回值:若成功,返回发送的字节数;若出错,返回−1
对于面向连接的套接字,目标地址是被忽略的,因为连接中隐含了目标地址。对于无连接的套接字,除非先调用connect设置了目标地址,否则不能使用send。sendto提供了发送报文的另一种方式。
通过套接字发送数据时,还有一个选择。可以调用带有msghdr结构的sendmsg来指定多重缓冲区传输数据,这和writev函数很相似(见14.6节)。
#include <sys/socket.h>
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
返回值:若成功,返回发送的字节数;若出错,返回−1
POSIX.1定义了msghdr结构,它至少有以下成员:
struct msghdr {
void *msg_name; /* optional address */
socklen_t msg_namelen; /* address size in bytes */
struct iovec *msg_iov; /* array of I/O buffers */
int msg_iovlen; /* number of elements in array */
void *msg_control; /* ancillary data */
socklen_t msg_controllen; /* number of ancillary bytes */
int msg_flags; /* flags for received message */
};
在14.6节中可以看到iovec结构。在17.4节中可以看到辅助数据的使用。
函数recv和read相似,但是recv可以指定标志来控制如何接收数据。
#include <sys/socket.h>
┇
ssize_t recv(int sockfd, void *buf, size_t nbytes, int flags);
返回值:返回数据的字节长度;若无可用数据或对等方已经按序结束,返回0;若出错,返回−1
图16-14总结了这些标志。仅有3个标志是Single UNIX Specification定义的。
图16-14 recv套接字调用标志
当指定MSG_PEEK标志时,可以查看下一个要读取的数据但不真正取走它。当再次调用read或其中一个recv函数时,会返回刚才查看的数据。
对于SOCK_STREAM套接字,接收的数据可以比预期的少。MSG_WAITALL标志会阻止这种行为,直到所请求的数据全部返回,recv函数才会返回。对于SOCK_DGRAM和SOCK_SEQPACKET套接字,MSG_WAITALL 标志没有改变什么行为,因为这些基于报文的套接字类型一次读取就返回整个报文。
如果发送者已经调用shutdown(见16.2节)来结束传输,或者网络协议支持按默认的顺序关闭并且发送端已经关闭,那么当所有的数据接收完毕后,recv会返回0。
如果有兴趣定位发送者,可以使用recvfrom来得到数据发送者的源地址。
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *restrict buf, size_t len, int flags,
struct sockaddr *restrict addr,
socklen_t *restrict addrlen);
返回值:返回数据的字节长度;若无可用数据或对等方已经按序结束,返回0;若出错,返回−1
如果addr非空,它将包含数据发送者的套接字端点地址。当调用recvfrom时,需要设置addrlen参数指向一个整数,该整数包含addr所指向的套接字缓冲区的字节长度。返回时,该整数设为该地址的实际字节长度。
因为可以获得发送者的地址,recvfrom通常用于无连接的套接字。否则,recvfrom等同于recv。
为了将接收到的数据送入多个缓冲区,类似于readv(见14.6节),或者想接收辅助数据(见17.4节),可以使用recvmsg。
#include <sys/socket.h>
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
返回值:返回数据的字节长度;若无可用数据或对等方已经按序结束,返回0;若出错,返回−1
recvmsg用msghdr结构(在sendmsg中见到过)指定接收数据的输入缓冲区。可以设置参数flags来改变recvmsg的默认行为。返回时,msghdr结构中的msg_flags字段被设为所接收数据的各种特征。(进入recvmsg时msg_flags被忽略。)recvmsg中返回的各种可能值总结在图16-15中。我们将在第17章看到使用recvmsg的实例。实例:面向连接的客户端
图16-15 从recvmsg中返回的msg_flags标志
图16-16显示了一个与服务器通信的客户端从系统的uptime命令获得输出。我们把这个服务称为“远程正常运行时间”(remote uptime)(简写为“ruptime”)。
图16-16 用于从服务器获取正常运行时间的客户端命令
这个程序连接服务器,读取服务器发送过来的字符串并将其打印到标准输出。因为使用的是SOCK_STREAM 套接字,所以不能保证调用一次recv 就会读取整个字符串,因此需要重复调用直到它返回0。
如果服务器支持多重网络接口或多重网络协议,函数getaddrinfo可能会返回多个候选地址供使用。轮流尝试每个地址,当找到一个允许连接到服务的地址时便可停止。使用图16-11中的connect_retry函数来与服务器建立一个连接。
实例:面向连接的服务器
图16-17展示了服务器程序,用来提供uptime命令的输出到图16-16所示的客户端程序。
图16-17 提供系统正常运行时间的服务器程序
为了找到它的地址,服务器需要获得其运行时的主机名。如果主机名的最大长度不确定,可以使用HOST_NAME_MAX代替。如果系统没定义HOST_NAME_MAX,可以自己定义。POSIX.1要求主机名的最大长度至少为255字节,不包括终止null字符,因此定义HOST_NAME_MAX为256来包括终止null字符。
服务器调用gethostname获得主机名,查看远程正常运行时间服务的地址。可能会有多个地址返回,但我们简单地选择第一个来建立被动套接字端点(即一个只用于监听连接请求的地址)。处理多个地址作为习题留给读者。
使用图16-12的initserver函数来初始化套接字端点,在这个端点上等待到来的连接请求。(实际上,使用的是图16-22的版本;在16.6节中讨论套接字选项时,可以了解其中的原因。)
实例:另一个面向连接的服务器
前面说过,采用文件描述符来访问套接字是非常有意义的,因为它允许程序对联网环境的网络访问一无所知。图16-18中所示的服务器程序版本说明了这一点。服务器没有从uptime命令中读取输出并发送到客户端,而是将uptime命令的标准输出和标准错误安排成为连接到客户端的套接字端点。
图16-18 用于说明命令直接写到套接字的服务器程序
我们没有采用popen来运行uptime命令,并从连接到命令标准输出的管道读取输出,而是采用fork创建了一个子进程,然后使用dup2使STDIN_FILENO的子进程副本对/dev/null开放,使STDOUT_FILENO 和STDERR_FILENO 的子进程副本对套接字端点开放。当执行uptime时,命令将结果写到它的标准输出,该标准输出是连接到套接字的,所以数据被送到ruptime客户端命令。
父进程可以安全地关闭连接到客户端的文件描述符,因为子进程仍旧让它打开着。父进程会等待子进程处理完毕再继续,所以子进程不会变成僵死进程。由于运行uptime命令不会花费太长的时间,所以父进程在接受下一个连接请求之前,可以等待子进程退出。然而,如果子进程运行的时间比较长的话,这种策略就未必适合了。
前面的实例采用的都是面向连接的套接字。但如何选择合适的套接字类型呢?何时采用面向连接的套接字,何时采用无连接的套接字呢?答案取决于我们要做的工作量和能够容忍的出错程度。
对于无连接的套接字,数据包到达时可能已经没有次序,因此如果不能将所有的数据放在一个数据包里,则在应用程序中就必须关心数据包的次序。数据包的最大尺寸是通信协议的特征。另外,对于无连接的套接字,数据包可能会丢失。如果应用程序不能容忍这种丢失,必须使用面向连接的套接字。
容忍数据包丢失意味着两种选择。一种选择是,如果想和对等方可靠通信,就必须对数据包编号,并且在发现数据包丢失时,请求对等应用程序重传,还必须标识重复数据包并丢弃它们,因为数据包可能会延迟或疑似丢失,可能请求重传之后,它们又出现了。
另一种选择是,通过让用户再次尝试那个命令来处理错误。对于简单的应用程序,这可能就足够了,但对于复杂的应用程序,这种选择通常不可行。因此,一般在这种情况下使用面向连接的套接字比较好。
面向连接的套接字的缺陷在于需要更多的时间和工作来建立一个连接,并且每个连接都需要消耗较多的操作系统资源。
实例:无连接的客户端
图16-19中的程序是采用数据报套接字接口的uptime客户端命令版本。
图16-19 采用数据报服务的客户端命令
除了增加安装一个SIGALRM的信号处理程序以外,基于数据报的客户端中的main函数和面向连接的客户端中的类似。使用alarm函数来避免调用recvfrom时的无限期阻塞。
对于面向连接的协议,需要在交换数据之前连接到服务器。对于服务器来说,到来的连接请求已经足够判断出所需提供给客户端的服务。但是对于基于数据报的协议,需要有一种方法通知服务器来执行服务。本例中,只是简单地向服务器发送了 1 字节的数据。服务器将接收它,从数据包中得到地址,并使用这个地址来传送它的响应。如果服务器提供多个服务,可以使用这个请求数据来表示需要的服务,但由于服务器只做一件事情,1字节数据的内容是无关紧要的。
如果服务器不在运行状态,客户端调用recvfrom便会无限期阻塞。对于这个面向连接的实例,如果服务器不运行,connect 调用会失败。为了避免无限期阻塞,可以在调用 recvfrom之前设置警告时钟。
实例:无连接的服务器
图16-20所示的程序是uptime服务器的数据报版本。
图16-20 基于数据报提供系统正常运行时间的服务器
服务器在recvfrom阻塞等待服务请求。当一个请求到达时,保存请求者地址并使用popen来运行uptime命令。使用sendto函数将输出发送到客户端,将目标地址设置成刚才的请求者地址。
套接字机制提供了两个套接字选项接口来控制套接字行为。一个接口用来设置选项,另一个接口可以查询选项的状态。可以获取或设置以下3种选项。
(1)通用选项,工作在所有套接字类型上。
(2)在套接字层次管理的选项,但是依赖于下层协议的支持。
(3)特定于某协议的选项,每个协议独有的。
Single UNIX Specification定义了套接字层的选项(上述选项中的前两个选项类型)。
可以使用setsockopt函数来设置套接字选项。
#include <sys/socket.h>
int setsockopt(int sockfd, int level, int option, const void *val,
socklen_t len);
返回值:若成功,返回0;若出错,返回−1
参数 level 标识了选项应用的协议。如果选项是通用的套接字层次选项,则 level 设置成SOL_SOCKET。否则,level设置成控制这个选项的协议编号。对于TCP选项,level是IPPROTO_TCP,对于IP,level是IPPROTO_IP。图16-21总结了Single UNIX Specification中定义的通用套接字层次选项。
图16-21 套接字选项
参数val根据选项的不同指向一个数据结构或者一个整数。一些选项是on/off开关。如果整数非0,则启用选项。如果整数为0,则禁止选项。参数len指定了val指向的对象的大小。
可以使用getsockopt函数来查看选项的当前值。
#include <sys/socket.h>
int getsockopt(int sockfd, int level, int option, void *restrict val,
socklen_t *restrict lenp);
返回值:若成功,返回0;若出错,返回−1
参数lenp是一个指向整数的指针。在调用getsockopt之前,设置该整数为复制选项缓冲区的长度。如果选项的实际长度大于此值,则选项会被截断。如果实际长度正好小于此值,那么返回时将此值更新为实际长度。
实例
当服务器终止并尝试立即重启时,图16-12中的函数将无法正常工作。通常情况下,除非超时(超时时间一般是几分钟),否则TCP的实现不允许绑定同一个地址。幸运的是,套接字选项SO_REUSEADDR可以绕过这个限制,如图16-22所示。
图16-22 采用地址复用初始化套接字端点供服务器使用
为了启用SO_REUSEADDR选项,设置了一个非0值的整数,并把这个整数地址作为val参数传递给了setsockopt。将len参数设置成了一个整数大小来表明val所指的对象的大小。
带外数据(out-of-band data)是一些通信协议所支持的可选功能,与普通数据相比,它允许更高优先级的数据传输。带外数据先行传输,即使传输队列已经有数据。TCP 支持带外数据,但是UDP不支持。套接字接口对带外数据的支持很大程度上受TCP带外数据具体实现的影响。
TCP将带外数据称为紧急数据(urgent data)。TCP仅支持一个字节的紧急数据,但是允许紧急数据在普通数据传递机制数据流之外传输。为了产生紧急数据,可以在3个send函数中的任何一个里指定MSG_OOB标志。如果带MSG_OOB标志发送的字节数超过一个时,最后一个字节将被视为紧急数据字节。
如果通过套接字安排了信号的产生,那么紧急数据被接收时,会发送SIGURG信号。在3.14节和14.5.2节中可以看到,在fcntl中使用F_SETOWN命令来设置一个套接字的所有权。如果fcntl中的第三个参数为正值,那么它指定的就是进程ID。如果为非-1的负值,那么它代表的就是进程组ID。因此,可以通过调用以下函数安排进程接收套接字的信号:
fcntl(sockfd, F_SETOWN, pid);
F_GETOWN命令可以用来获得当前套接字所有权。对于F_SETOWN命令,负值代表进程组ID,正值代表进程ID。因此,调用
owner = fcntl(sockfd, F_GETOWN, 0);
将返回owner,如果owner为正值,则等于配置为接收套接字信号的进程的ID。如果owner为负值,其绝对值为接收套接字信号的进程组的ID。
TCP支持紧急标记(urgent mark)的概念,即在普通数据流中紧急数据所在的位置。如果采用套接字选项SO_OOBINLINE,那么可以在普通数据中接收紧急数据。为帮助判断是否已经到达紧急标记,可以使用函数sockatmark。
#include <sys/socket.h>
int sockatmark(int sockfd);
返回值:若在标记处,返回1;若没在标记处,返回0;若出错,返回−1
当下一个要读取的字节在紧急标志处时,sockatmark返回1。
当带外数据出现在套接字读取队列时,select函数(见14.4.1节)会返回一个文件描述符并且有一个待处理的异常条件。可以在普通数据流上接收紧急数据,也可以在其中一个recv函数中采用MSG_OOB标志在其他队列数据之前接收紧急数据。TCP队列仅用一个字节的紧急数据。如果在接收当前的紧急数据字节之前又有新的紧急数据到来,那么已有的字节会被丢弃。
通常,recv 函数没有数据可用时会阻塞等待。同样地,当套接字输出队列没有足够空间来发送消息时,send 函数会阻塞。在套接字非阻塞模式下,行为会改变。在这种情况下,这些函数不会阻塞而是会失败,将errno设置为EWOULDBLOCK或者EAGAIN。当这种情况发生时,可以使用poll或select来判断能否接收或者传输数据。
Single UNIX Specification包含通用异步I/O机制(见14.5节)的支持。套接字机制有其自己的处理异步I/O的方式,但是这在Single UNIX Specification中没有标准化。一些文献把经典的基于套接字的异步I/O机制称为“基于信号的I/O”,区别于Single UNIX Specification中的通用异步I/O机制。
在基于套接字的异步I/O中,当从套接字中读取数据时,或者当套接字写队列中空间变得可用时,可以安排要发送的信号SIGIO。启用异步I/O是一个两步骤的过程。
(1)建立套接字所有权,这样信号可以被传递到合适的进程。
(2)通知套接字当I/O操作不会阻塞时发信号。
可以使用3种方式来完成第一个步骤。
(1)在fcntl中使用F_SETOWN命令。
(2)在ioctl中使用FIOSETOWN命令。
(3)在ioctl中使用SIOCSPGRP命令。
要完成第二个步骤,有两个选择。
(1)在fcntl中使用F_SETFL命令并且启用文件标志O_ASYNC。
(2)在ioctl中使用FIOASYNC命令。
虽然有多种选项,但它们没有得到普遍支持。图16-23总结了本文讨论的平台支持这些选项的情况。
图16-23 套接字异步I/O管理命令
本章考察了IPC机制,这些机制允许进程与不同计算机上的以及同一计算机上的其他进程通信。我们讨论了套接字端点如何命名,在连接服务器时,如何发现所用的地址。
我们给出了采用无连接的(即基于数据报的)套接字和面向连接的套接字的客户端和服务器的实例,还简要讨论了异步和非阻塞的套接字I/O,以及用于管理套接字选项的接口。
下一章将会考察一些高级IPC主题,包括在同一台计算机上如何使用套接字在两个进程之间传送文件描述符。
16.1 写一个程序判断所使用系统的字节序。
16.2 写一个程序,在至少两种不同的平台上打印出所支持套接字的 stat 结构成员,并且描述这些结果的不同之处。
16.3 图16-17的程序只在一个端点上提供了服务。修改这个程序,同时支持多个端点(每个端点具有一个不同的地址)上的服务。
16.4 写一个客户端程序和服务端程序,返回指定主机上当前运行的进程数量。
16.5 在图16-18的程序中,服务器等待子进程执行uptime,子进程完成后退出,服务器才接受下一个连接请求。重新设计服务器,使得处理一个请求时并不拖延处理到来的连接请求。
16.6 写两个库例程:一个在套接字上允许异步I/O,一个在套接字上不允许异步I/O。使用图16-23来保证函数能够在所有平台上运行,并且支持尽可能多的套接字类型。
前面两章讨论了 UNIX 系统提供的各种 IPC,其中包括管道和套接字。本章介绍一种高级IPC—UNIX域套接字机制,并说明它的应用方法。这种形式的IPC可以在同一计算机系统上运行的两个进程之间传送打开文件描述符。服务进程可以使它们的打开文件描述符与指定的名字相关联,同一系统上运行的客户进程可以使用这些名字与服务器进程汇聚。我们还会了解到操作系统如何为每一个客户进程提供一个独用的IPC通道。
UNIX 域套接字用于在同一台计算机上运行的进程之间的通信。虽然因特网域套接字可用于同一目的,但 UNIX 域套接字的效率更高。UNIX 域套接字仅仅复制数据,它们并不执行协议处理,不需要添加或删除网络报头,无需计算校验和,不要产生顺序号,无需发送确认报文。
UNIX 域套接字提供流和数据报两种接口。UNIX 域数据报服务是可靠的,既不会丢失报文也不会传递出错。UNIX 域套接字就像是套接字和管道的混合。可以使用它们面向网络的域套接字接口或者使用socketpair函数来创建一对无命名的、相互连接的UNIX域套接字。
#include <sys/socket.h>
int socketpair(int domain, int type, int protocol, int sockfd[2]);
返回值:若成功,返回0;若出错,返回-1
虽然接口足够通用,允许socketpair用于其他域,但一般来说操作系统仅对UNIX域提供支持。
一对相互连接的UNIX域套接字可以起到全双工管道的作用:两端对读和写开放(见图17-1)。我们将其称为 fd 管道(fd-pipe),以便与普通的半双工管道区分开来。
图17-1 套接字对
实例:fd_pipe函数
图17-2展示了fd_pipe函数,它使用socketpair函数来创建一对相互连接的UNIX域流套接字。
图17-2 创建一个全双工管道
某些基于BSD的系统使用UNIX域套接字来实现管道。但当调用pipe时,第一描述符的写端和第二描述符的读端都是关闭的。为了得到全双工管道,必须直接调用socketpair。
实例:借助UNIX域套接字轮询XSI消息队列
15.6.4节曾经提到XSI消息队列的使用存在一个问题,即不能将它们和poll或者select一起使用,这是因为它们不能关联到文件描述符。然而,套接字是和文件描述符相关联的,消息到达时,可以用套接字来通知。对每个消息队列使用一个线程。每个线程都会在msgrcv调用中阻塞。当消息到达时,线程会把它写入一个UNIX域套接字的一端。当poll指示套接字可以读取数据时,应用程序会使用这个套接字的另外一端来接收这个消息。
图17-3中的程序说明了这个技术。main函数中创建了一些消息队列和UNIX域套接字,并为每个消息队列开启了一个新线程。然后它在一个无限循环中用poll来轮询选择一个套接字端点。当某个套接字可读时,程序可以从套接字中读取数据并把消息打印到标准输出上。
图17-3 使用UNIX域套接字轮询XSI消息队列
注意,我们使用的是数据报(SOCK_DGRAM)套接字而不是流套接字。这样做可以保持消息边界,以保证从套接字里一次只读取一条消息。
这种技术可以(非直接地)在消息队列中运用poll或者select。只要为每个队列分配一个线程的开销以及每个消息额外复制两次(一次写入套接字,另一次从套接字里读取出来)的开销是可接受的,这种技术就会使XSI消息队列的使用更加容易。
使用图17-4中所示的程序给图17-3中所示的测试程序发送消息。
图17-4 给XSI消息队列发送消息
这个程序需要两个参数:消息队列关联的键值以及一个包含消息主体的字符串。发送消息到服务器端时,它会打印如下信息:
$ ./pollmsg & 在后台运行服务器[1] 12814
$ queue ID 0 is 196608
queue ID 1 is 196609
queue ID 2 is 196610
$ ./sendmsg 0x123 "hello, world" 给第一个队列发送一条消息
queue id 196608, message hello, world
$ ./sendmsg 0x124 "just a test" 给第二个队列发送一条消息
queue id 196609, message just a test
$ ./sendmsg 0x125 "bye" 给第三个队列发送一条消息
queue id 196610, message bye
命名UNIX域套接字
虽然 socketpair 函数能创建一对相互连接的套接字,但是每一个套接字都没有名字。这意味着无关进程不能使用它们。
在16.3.4节中学习了如何将一个地址绑定到一个因特网域套接字上。恰如因特网域套接字一样,可以命名UNIX域套接字,并可将其用于告示服务。但是要注意,UNIX域套接字使用的地址格式不同于因特网域套接字。
回忆 16.3 节,套接字地址格式会随实现而变。UNIX 域套接字的地址由sockaddr_un结构表示。在Linux 3.2.0和Solaris 10中,sockaddr_un结构在头文件<sys/un.h>中的定义如下:
struct sockaddr_un {
sa_family_t sun_family; /* AF_UNIX */
char sun_path[108]; /* pathname */
};
但是在FreeBSD 8.0和Mac OS X 10.6.8中,sockaddr_un结构的定义如下:
struct sockaddr_un {
unsigned char sun_len; /* sockaddr length */
sa_family_t sun_family; /* AF_UNIX */
char sun_path[104]; /* pathname */
};
sockaddr_un结构的sun_path成员包含一个路径名。当我们将一个地址绑定到一个UNIX域套接字时,系统会用该路径名创建一个S_IFSOCK类型的文件。
该文件仅用于向客户进程告示套接字名字。该文件无法打开,也不能由应用程序用于通信。
如果我们试图绑定同一地址时,该文件已经存在,那么bind请求会失败。当关闭套接字时,并不自动删除该文件,所以必须确保在应用程序退出前,对该文件执行解除链接操作。
实例
图17-5所示的程序是一个将地址绑定到UNIX域套接字的例子。
运行此程序时,bind 请求成功执行。但是,若第二次运行该程序,则出错返回,其原因是该文件已经存在。在删除该文件之前,该程序不会再成功运行。
$ ./a.out 运行该程序
UNIX domain socket bound
$ ls -l foo.socket 查看套接字文件
srwxrw-xr-x 1 sar 0 May 18 00:44 foo.socket
$ ./a.out 试图再次运行该程序
bind failed: Address already in use
$ rm foo.socket 刪除该套接字文件
$ ./a.out 第三次运行该程序
UNIX domain socket bound 现在成功啦
图17-5 将地址绑定到UNIX域套接字
确定绑定地址长度的方法是,先计算sun_path成员在sockaddr_un结构中的偏移量,然后将结果与路径名长度(不包括终止null字符)相加。因为sockaddr_un结构中sun_path之前的成员与实现相关,所以我们使用<stddef.h>头文件(包括在apue.h中)中的offsetof宏计算sun_path成员从结构开始处的偏移量。如果查看<stddef.h>,则可见到类似于下列形式的定义:
#define offsetof(TYPE, MEMBER) ((int)&((TYPE *)0)->MEMBER)
假定该结构从地址0开始,此表达式求得成员起始地址的整型值。
服务器进程可以使用标准bind、listen和accept函数,为客户进程安排一个唯一UNIX域连接。客户进程使用connect与服务器进程联系。在服务器进程接受了connect请求后,在服务器进程和客户进程之间就存在了唯一连接。这种风格的操作与我们在图16-16和图16-17中所示的对因特网域套接字的操作相同。
图17-6展示了客户进程和服务器进程存在连接之前二者的情形。服务器端把它的套接字绑定到sockaddr_un的地址并监听新的连接请求。图17-7展示了在服务器端接受客户端连接请求后,客户端和服务器端之间建立的唯一的连接。
现在,我们将开发3个函数,使用这些函数可以在运行于同一台计算机上的两个无关进程之间创建唯一连接。这些函数模仿了在 16.4 节中讨论过的面向连接的套接字函数。这里,我们将UNIX域套接字应用于底层通信机制。
图17-6 connect之前的客户端
套接字和服务器端套接字
图17-7 connect之后的客户端
套接字和服务器端套接字
#include "apue.h"
int serv_listen(const char *name);
返回值:若成功,返回要监听的文件描述符;若出错,返回负值
int serv_accept(int listenfd, uid_t *uidptr);
int cli_conn(const char *name);
返回值:若成功,返回新文件描述符;若出错,返回负值
返回值:若成功,返回文件描述符;若出错,返回负值
服务器进程可以调用serv_listen函数(见图17-8)声明它要在一个众所周知的名字(文件系统中的某个路径名)上监听客户进程的连接请求。当客户进程想要连接至服务器进程时,它们将使用该名字。serv_listen函数的返回值是用于接收客户进程连接请求的服务器UNIX域套接字。
服务器进程可以使用serv_accept函数(见图17-9)等待客户进程连接请求的到达。当一个请求到达时,系统自动创建一个新的UNIX域套接字,并将它与客户端套接字连接,最后将这个新套接字返回给服务器。此外,客户进程的有效用户ID存放在uidptr指向的存储区中。
客户进程调用cli_conn函数(见图17-10)连接至服务器进程。客户进程指定的name参数必须与服务器进程调用serv_listen函数时所用的名字相同。函数返回时,客户进程得到接连至服务器进程的文件描述符。
图17-8给出了serv_listen函数。
图17-8 serv_listen函数
首先,调用socket创建一个UNIX域套接字。然后将欲赋给套接字的众所周知的路径名填入sockaddr_un结构。该结构是调用bind的参数。注意,不需要设置某些平台提供的sun_len字段,因为操作系统会用传送给bind函数的地址长度设置该字段。
最后,调用listen函数(见16.4节)来通知内核该进程将作为服务器进程等待客户进程的连接请求。当收到一个客户进程的连接请求后,服务器进程调用serv_accept函数(见图17-9)。
图17-9 serv_accept函数
服务器进程在调用serv_accept中阻塞,等待一个客户进程调用cli_conn。从accept返回时,返回值是连接到客户进程的崭新的描述符。另外,accept函数也经由其第二个参数(指向sockaddr_un结构的指针)返回客户进程赋给其套接字的路径名(包含客户进程ID的名字)。接着,程序复制这个路径名,并确保它是以null终止的(如果路径名占用了sockaddr_un结构里的sun_path成员所有的可用空间,那就没有空间存放终止null字符)。然后,调用stat函数验证:该路径名确实是一个套接字;其权限仅允许用户读、用户写以及用户执行。还要验证与套接字相关联的3个时间参数不比当前时间早30秒。(回忆6.10节,time函数返回当前时间和日期,用公元1970年1月1日00:00:00以来经过的秒数表示。)
如若通过了所有这些检验,则可认为客户进程的身份(其有效用户ID)是该套接字的所有者。虽然这种检验并不完善,但这是对当前系统所能做到的最佳方案。(如若内核能通过 accept 的参数返回有效用户ID,则会更好一些。)
客户进程调用cli_conn函数(见图17-10)对连到服务器进程的连接进行初始化。
图17-10 cli_conn函数
调用 socket 函数创建 UNIX 域套接字的客户进程端,然后用客户进程专有的名字填入sockaddr_un结构。
此例中没让系统选择默认地址,其原因是,如果这样处理,服务器进程将不能区分各个客户进程(如果不为UNIX域套接字显式地绑定名字,内核会代表我们隐式地绑定一个地址且不会在文件系统创建文件来表示这个套接字)。于是,我们绑定自己的地址,但在开发使用套接字的客户端程序时通常并不采用这一步骤。
绑定的路径名的最后5 个字符来自客户进程ID。仅在该路径名已存在时调用unlink。然后,调用bind将名字赋给客户进程套接字。这在文件系统中创建了一个套接字文件,所用的名字与被绑定的路径名一样。接着,调用chmod 关闭除用户读、用户写以及用户执行以外的其他权限。在serv_accept中,服务器进程检验这些权限以及套接字用户ID以验证客户进程的身份。
然后,必须填充另一个sockaddr_un结构,这次用的是服务进程众所周知的路径名。最后,调用connect函数初始化与服务进程的连接。
在两个进程之间传送打开文件描述符的技术是非常有用的。因此可以对客户进程-服务器进程应用进行不同的设计。它使一个进程(通常是服务器进程)能够处理打开一个文件所要做的一切操作(包括将网络名翻译为网络地址、拨号调制解调器、协商文件锁等)以及向调用进程送回一个描述符,该描述符可被用于以后的所有I/O函数。涉及打开文件或设备的所有细节对客户进程而言都是透明的。
下面进一步说明从一个进程向另一个进程“传送一个打开文件描述符”的含义。回忆图 3-8,其中显示了两个进程,它们打开了同一文件。虽然它们共享同一个v节点,但每个进程都有它自己的文件表项。
当一个进程向另一个进程传送一个打开文件描述符时,我们想让发送进程和接收进程共享同一文件表项。图17-11显示了所期望的安排。
图17-11 从顶部进程传送一个打开文件至底部进程
在技术上,我们是将指向一个打开文件表项的指针从一个进程发送到另外一个进程。该指针被分配存放在接收进程的第一个可用描述符项中。(注意,不要造成错觉,以为发送进程和接收进程中的描述符编号是相同的,它们通常是不同的。)两个进程共享同一个打开文件表,这与fork之后的父进程和子进程共享打开文件表的情况完全相同(见图8-2)。
当发送进程将描述符传送给接收进程后,通常会关闭该描述符。发送进程关闭该描述符并不会真的关闭该文件或设备,其原因是该描述符仍被视为由接收进程打开(即使接收进程尚未接收到该描述符)。
下面定义本章用以发送和接收文件描述符的3个函数。本节后面会给出这3个函数的代码。
#include "apue.h"
int send_fd(int fd, int fd_to_send);
int send_err(int fd, int status, const char *errmsg);
两个函数的返回值:若成功,返回0;若出错,返回-1
int recv_fd(int fd, ssize_t (*userfunc)(int, const void *, size_t));
返回值:若成功,返回文件描述符;若出错,返回负值
当一个进程(通常是服务器进程)想将一个描述符传送给另一个进程时,可以调用send_fd或send_err。等待接收描述符的进程(客户进程)调用recv_fd。
send_fd 使用fd代表的 UNIX 域套接字发送描述符fd_to_send。send_err 使用fd发送errmsg以及后随的status字节。status的值应在-1~-255。
客户进程调用 recv_fd 接收描述符。如果一切正常(发送者调用了 send_fd),则函数返回值为非负描述符。否则,返回值是由send_err发送的status(-1~-255 的一个负值)。另外,如果服务器进程发送了一条出错消息,则客户进程调用它自己的userfunc 函数处理该消息。userfunc的第一个参数是常量 STDERR_FILENO,然后是指向出错消息的指针及其长度。userfunc函数的返回值是已写的字节数或负的出错编号值。客户进程常将普通的write函数指定为userfunc。
我们实现用于这 3 个函数的我们自己制定的协议。为发送一个描述符,send_fd先发送2字节0,然后是实际描述符。为了发送一条出错消息,send_err发送errmsg,然后是1字节0,最后是status字节的绝对值(1~255)。recv_fd函数读取套接字中所有字节直至遇到null字符。null字符之前的所有字符都传送给调用者的userfunc。recv_fd读取的下一个字节是状态(status)字节。若状态字节为0,则表示一个描述符已传送过来,否则表示没有描述符可接收。
send_err函数在将出错消息写到套接字后,即调用send_fd函数,如图17-12所示。
图17-12 send_err函数
为了用UNIX域套接字交换文件描述符,调用sendmsg(2)和recvmsg(2)函数(见16.5节)。这两个函数的参数中都有一个指向msghdr结构的指针,该结构包含了所有关于要发送或要接收的消息的信息。该结构的定义大致如下:
struct msghdr {
void *msg_name; /* optional address */
socklen_t msg_namelen; /* address size in bytes */
struct iovec *msg_iov; /* array of I/O buffers */
int msg_iovlen; /* number of elements in array */
void *msg_control; /* ancillary data */
socklen_t msg_controllen; /* number of ancillary bytes */
int msg_flags; /* flags for received message */
};
前两个元素通常用于在网络连接上发送数据报,其中目的地址可以由每个数据报指定。接下来的两个元素使我们可以指定一个由多个缓冲区构成的数组(散布读和聚集写),这与对 readv和writev函数(见14.6节)的说明一样。 msg_flags字段包含了描述接收到的消息的标志,图16-15总结了这些标志。
两个元素处理控制信息的传送和接收。msg_control字段指向cmsghdr(控制信息头)结构,msg_controllen字段包含控制信息的字节数。
struct cmsghdr {
socklen_t cmsg_len; /* data byte count, including header */
int cmsg_level; /* originating protocol */
int cmsg_type; /* protocol-specific type */
/* followed by the actual control message data */
};
为了发送文件描述符,将cmsg_len设置为cmsghdr结构的长度加一个整型的长度(描述符的长度),cmg_level字段设置为SOL_SOCKET,cmsg_type字段设置为SCM_RIGHTS,用以表明在传送访问权。(SCM是Socket-level Control Message的缩写,即套接字级控制消息。)访问权仅能通过UNIX域套接字传送。描述符紧随cmsg_type字段之后存储,用CMSG_DATA宏获得该整型量的指针。
在此定义3个宏,用于访问控制数据,一个宏用于帮助计算cmsg_len所使用的值。
#include <sys/socket.h>
unsigned char *CMSG_DATA(struct cmsghdr *cp);
返回值:返回一个指针,指向与cmsghdr结构相关联的数据
struct cmsghdr *CMSG_FIRSTHDR(struct msghdr *mp);
返回值:返回一个指针,指向与msghdr结构相关联的第一个cmsghdr结构;
若无这样的结构,返回NULL
struct cmsghdr *CMSG_NXTHDR(struct msghdr *mp,
struct cmsghdr *cp);
返回值:返回一个指针,指向与msghdr结构相关联的下一个cmsghdr结构,该msghdr结构给出了当前的cmsghdr结构;若当前cmsghdr结构已是最后一个,返回NULL
unsigned int CMSG_LEN(unsigned int nbytes);
返回值:返回为nbytes长的数据对象分配的长度
Single UNIX Specification定义了前3个宏,但没有定义CMSG_LEN。
CMSG_LEN宏返回存储nbytes长的数据对象所需的字节数,它先将nbytes加上cmsghdr结构的长度,然后按处理器体系结构的对齐要求进行调整,最后再向上取整。
图17-13中的程序是UNIX域套接字的send_fd函数,它通过UNIX域套接字传递文件描述符。sendmsg调用被用来传送协议数据(包括null字节和状态字节)以及描述符。
图17-13 通过UNIX域套接字发送文件描述符
为了接收一个文件描述符(见图17-14),我们为cmsghdr结构和描述符分配了足够大的空间,设置msg_control指向该分配到的存储区,然后调用了recvmsg。使用CMSG_LEN宏计算所需的空间总量。
读取UNIX域套接字,直至读到null字节,它位于最后的状态字节之前。null字节之前是一条来自发送者的出错消息。
图17-14 通过UNIX域套接字接收文件描述符
注意,该程序总是准备接收一个描述符(在每次调用 recvmsg 之前,设置 msg_control和msg_controllen),但是仅当msg_controllen返回的是非0值时,才确实接收到描述符。
回忆serv_accept函数(见图17-9)确定调用者身份的步骤。如果内核能够把调用者的证书在调用accept之后返回给调用处会更好。某些UNIX域套接字的实现提供类似的功能,但它们的接口不同。
FreeBSD 8.0和Linux 3.2.0都支持通过UNIX域套接字发送证书,但它们的实现方式不同。Mac OS X 10.6.8是部分从FreeBSD派生出来的,但禁止传送证书。Solaris 10不支持通过UNIX域套接字传送证书,然而它支持从一个通过STREAMS管道传输文件描述符的进程中获得证书,这里我们不讨论它的细节。
在FreeBSD中,将证书作为cmsgcred结构传送。
#define CMGROUP_MAX 16
struct cmsgcred {
pid_t cmcred_pid; /* sender's process ID */
uid_t cmcred_uid; /* sender's real UID */
uid_t cmcred_euid; /* sender's effective UID */
gid_t cmcred_gid; /* sender's real GID */
short cmcred_ngroups; /* number of groups */
gid_t cmcred_groups[CMGROUP_MAX]; /* groups */
};
在传送证书时,仅需为cmsgcred结构保留存储空间。内核将填充该结构以防止应用程序伪装成具有另一种身份。
在Linux中,将证书作为ucred结构传送。
struct ucred {
pid_t pid; /* sender's process ID */
uid_t uid; /* sender's user ID */* sender's group ID */
gid_t gid;
};
与FreeBSD不同,Linux需要在传输前初始化这个结构。内核会确保应用程序要么能够使用对应调用者的值,要么有使用其他值的合适权限。
图17-15显示了更新过后的send_fd函数,它包含了发送进程的证书。
图17-15 通过UNIX域套接字发送证书
注意,只有在Linux上才需要初始化证书结构。
图17-16中的recv_ufd函数是recv_fd的修改版,它通过一个引用参数返回发送者的用户ID。
图17-16 通过UNIX域套接字接收证书
在FreeBSD中,指定SCM_CREDS表示要传送证书。在Linux中,则使用SCM_CREDENTIALS。
使用文件描述符传送技术开发一个 open 服务器进程—一个由一个进程执行以打开一个或多个文件。该服务器进程不是将文件内容送回调用进程,而是送回一个打开文件描述符。这使该服务器进程对任何类型的文件(如设备或套接字)而不单是普通文件都能起作用。客户进程和服务器进程用IPC交换最小量的信息:从客户进程到服务器进程传送文件名和打开模式,而从服务器进程到客户进程返回描述符。文件内容不需通过IPC交换。
将服务器进程设计成一个单独的可执行程序(或者是由客户进程执行的,这正是本节所说明的;或者是由守护服务器进程执行的,将在下一节进行说明)有很多优点。
•任何客户进程都能很容易地和服务器进程联系,这类似于客户进程调用一个库函数。我们没有将特定服务硬编码在应用程序中,而是设计了一种可供重用的设施。
•如若需要更改服务器进程,那么也只影响一个程序。相反,更新一个库函数可能需要更新调用此库函数的所有程序(即用连接编辑器重新连接)。共享库函数可以简化这种更新(见7.7节)。
•服务器进程可以是一个设置用户ID 程序,于是使其具有客户进程没有的附加权限。注意,库函数(或共享库函数)不能提供这种能力。
客户进程创建一个fd管道,然后调用fork和exec来调用服务器进程。客户进程使用一端经fd管道发送请求,服务器进程使用另一端经fd管道回送响应。
定义客户进程和服务器进程间的应用程序协议如下。
(1)客户进程通过fd管道向服务器进程发送“open <pathname> <openmode>\0”形式的请求。<openmode>是数值,以ASCII十进制数表示,是open函数的第二个参数。该请求字符串以null字符终止。
(2)服务器进程调用send_fd或send_err回送打开描述符或出错消息。
这是一个进程向其父进程发送打开描述符的实例。17.6节将修改此实例来使用一个守护服务器进程,它的服务器进程将一个描述符发送给一个完全无关的进程。
首先要有一个头文件open.h(见图17-17),它包括标准头文件,并且定义了函数原型。
图17-17 open.h头文件
main函数(见图17-18)是一个循环,它先从标准输入读一个路径名,然后将该文件复制到标准输出。它调用csopen函数来联系open服务器进程,从其返回一个打开描述符。
图17-18 main函数
函数csopen(见图17-19)在创建了fd管道之后,进行了服务器进程的fork和exec操作。
图17-19 csopen函数
子进程关闭fd管道的一端,父进程关闭另一端。作为服务器进程,子进程也将fd管道的一端复制到其标准输入和标准输出。(另一种可选择的方案是,将描述符fd[1]的ASCII表示形式作为一个参数传送给服务器进程。)
父进程将包含路径名和打开模式的请求发送给服务器进程。最后,父进程调用recv_fd返回描述符或出错消息。如果服务器进程返回出错消息,那么父进程调用write,向标准错误输出该消息。
现在,让我们来看看open服务器进程。其程序是opend,由图17-19中的子进程执行。首先,要有一个opend.h头文件(见图17-20),它包括标准头文件,并且声明了全局变量和函数原型。
图17-20 opend.h头文件
main 函数(见图17-21)经fd 管道(它的标准输入)读来自客户进程的请求,然后调用函数handle_request。
图17-21 服务器进程main函数第1版
图17-22中的handle_request函数承担了全部工作。它调用函数buf_args将客户进程请求分解成标准argv型的参数表,然后调用函数cli_args处理客户进程的参数。如果一切正常,则调用open打开相应文件,接着调用send_fd,经由fd管道(它的标准输出)将描述符回送给客户进程。如果出错则调用send_err回送一则出错消息,其中使用了前面说明的客户进程-服务器进程协议。
图17-22 handle_request函数第1版
客户进程请求是一个以 null 终止的字符串,它包含由空格分隔的参数。图 17-23 中的 buf_args函数将字符串分解成标准argv型参数表,并调用用户函数处理参数。我们使用ISO C函数strtok将字符串分割成独立的参数。
图17-23 buf_args函数
buf_args调用的服务器进程函数是cli_args(见图17-24)。它验证客户进程发送的参数个数是否正确,然后将路径名和打开模式存储在全局变量中。
图17-24 cli_args函数
这样也就完成了open服务器进程,它由客户进程执行fork和exec来调用。在fork之前创建了一个fd管道,然后客户进程和服务器进程用其进行通信。在这种安排下,每个客户进程都有一个服务器进程。
在上一节中,我们开发了一个open服务器进程,由客户进程执行fork和exec调用,它说明了如何从子程序向父程序传送文件描述符。本节将开发一个守护进程方式的 open 服务器进程。一个服务器进程处理所有客户进程的请求。由于避免了使用 fork 和 exec,我们期望这个设计会更有效。在客户进程和服务器进程之间仍使用UNIX域套接字连接,并用实例说明在两个无关进程之间如何传送文件描述符。我们将使用 17.3 节引入的 3 个函数:serv_listen、serv_accept和cli_conn。这个服务器进程还将演示一个服务器进程如何处理多个客户进程,为此要用到14.4节中说明的select和poll函数。
本节所述的客户进程类似于17.5节中的客户进程。实际上,文件main.c是完全相同的(见图17-18)。我们将在open.h头文件(见图17-17)中加入下面这行:
#define CS_OPEN "/tmp/opend.socket" /* server's well-known name */
因为在此例中调用的是cli_conn而非fork和exec,所以文件open.c与图17-19中的不同。修改后如图17-25所示。
图17-25 csopen函数第2版
客户进程与服务器进程之间使用的协议仍然相同。
接下来再看服务器进程。头文件opend.h(见图17-26)包括了标准头文件,并且声明了全局变量和函数原型。
图17-26 opend.h头文件第2版
因为此服务器进程处理所有客户进程,所以它必须保存每个客户进程连接的状态。这是用在opend.h头文件中声明的client数组实现的。图17-27定义了3个处理此数组的函数。
图17-27 处理client数组的3个函数
第一次调用client_add时,它调用client_alloc,client_alloc又调用malloc为该数组的10个登记项分配空间。在这10个登记项全部用完后,如若再调用client_add,那么client_alloc函数将调用realloc来分配附加空间。依靠这种动态空间分配,我们无需在编译时将估计的数组长度值放入头文件中从而限制client数组的长度。如果出错,这些函数将调用log_函数(见附录B),因为我们假定服务器进程是守护进程。
通常服务器进程会作为守护进程运行,但我们想提供一个让其前台运行的选项,同时能够把分析信息发送到标准错误输出。这应该能使服务器更容易评测和调试,特别是当用户没有权限读取那些分析信息经常写入的日志文件时。可以使用一个命令行选项来控制服务器是否在前台运行或者作为守护进程在后台运行。
一个系统的所有命令遵循相同的约定是非常重要的,因为这会提高它的易用性。如果有人熟悉某条命令的选项风格,那么若后面的命令使用了其他的风格,他就很容易犯错。
处理命令行空格就很容易发生这样的问题。有些命令需要它的选项和其参数以空格隔开,而另一些则希望它的参数直接跟在它的选项之后。如果没有遵循一个一致的规则,用户就得记住所有命令的语法,或者在尝试和调错中调用这些命令。
Single UNIX Specification包括了一系列的约定和规范来保证命令行语法的一致性,其中包括一些建议,如“限制每个命令行选项为一个单一的阿拉伯字符”以及“所有选项必须以‘−’作为开头字符”。
幸运的是,getopt函数能够帮助命令开发者以一致的方式处理命令行选项。
#include <unistd.h>
int getopt(int argc, char * const argv[], const char *options);
extern int optind, opterr, optopt;
extern char *optarg;
返回值:若所有选项被处理完,返回-1;否则,返回下一个选项字符
参数argc和argv与传入main函数的一样。options参数是一个包含该命令支持的选项字符的字符串。如果一个选项字符后面接了一个冒号,则表示该选项需要参数;否则,该选项不需要额外参数。举例来说,如果一条命令的用法说明如下:
command [-i] [-u username] [-z] filename
则我们可以给getopt传送一个"iu:z"作为options字符串。
函数getopt一般用在循环体内,循环直到getopt返回-1时退出。每次迭代中,getopt会返回下一个选项。应用程序负责筛选这些选项,判断是否有冲突,getopt 仅负责解释选项并保证一个标准的格式。
当遇到无效的选项时,getopt返回一个问题标记(question mark)而不是这个字符。如果选项缺少参数,getopt也会返回一个问题标记,但如果选项字符串的第一个字符是冒号,getopt会直接返回冒号。而特殊的“--”格式则会导致getopt停止处理选项并返回-1。这允许用户传递以“-”开头但不是选项的参数。例如,如果有一个名字为“-bar”的文件,下面的命令行是无法删除这个文件的:
rm –bar
因为rm会试图把-bar解释为选项。正确的删除文件的命令应该是:
rm -- -bar
getopt函数支持以下4个外部变量。
optarg 如果一个选项需要参数,在处理该选项时,getopt会设置optarg指向该选项的参数字符串。
opterr 如果一个选项发生了错误,getopt会默认打印一条出错消息。应用程序可以通过设置opterr参数为0来禁止这个行为。
optind 用来存放下一个要处理的字符串在argv数组里的下标。它从1开始,每处理一个参数,getopt都会对其递增1。
optopt 如果处理选项时发生了错误,getopt会设置optopt指向导致出错的选项字符串。
open服务器进程的main函数(见图17-28)定义全局变量,处理命令行选项,并且调用loop函数。如果以-d选项调用服务器进程,则服务器进程将以交互方式运行而非守护进程方式。测试服务器进程时会用到这个选项。
图17-28 服务器进程main函数第2版
loop函数是服务器进程的无限循环。我们将给出该函数的两种版本。图17-29是使用select的一种版本。图17-30所示的程序是使用poll的另一种版本。
图17-29 使用select的loop函数
此函数调用serv_listen(见图17-8)创建服务器进程与客户进程连接的端点。此函数的其余部分是一个循环,它从 select 调用开始。在 select 返回后,可能会发生下面两种情况。
(1)描述符listenfd可以随时读取,这意味着一个新客户进程已调用了cli_conn。为了处理这种情况,我们将调用serv_accept(见图17-9),然后为新客户进程更新client数组以及与该新客户进程相关的簿记信息。(我们要跟踪 select 的第一个参数的最高描述符编号,还要跟踪使用中的client数组的最高下标。)
(2)一个现有的客户进程的连接可以随时读取。这意味着该客户进程已经终止,或者该客户进程已发送一个新请求。如果read返回0(文件结束),则表示客户进程已终止。如果read返回的值大于0,则表示有一个新请求需处理,可以调用request来处理。
用allset描述符集跟踪当前使用的描述符。当新客户进程连接至服务器进程时,会打开此描述符集的相应位。当该客户进程终止时,会关闭相应位。
因为客户进程的所有描述符都由内核自动关闭(包括与服务器进程的连接),所以我们总能知道什么时候客户进程终止了,该终止是否是自愿的。这与XSI IPC机制不同。
使用poll函数的loop函数如图17-30所示。
图17-30 使用poll的loop函数
为使打开描述符的数量能与客户进程数量相当,我们动态地为pollfd结构的数字分配空间,所使用的策略与client_alloc函数分配client数组(见图17-27)时所使用的相同。
pollfd数组中的第一个登记项(下标号为0)用于listenfd描述符。新客户进程连接的到达由listenfd描述符中的POLLIN指示。如同前述,调用serv_accept来接受该连接。
对于一个现有的客户进程,应当处理来自poll的两个不同事件:由POLLHUP指示的客户进程终止,由POLLIN指示的来自现有客户进程的一个新请求。即使连接的服务器端还在读取数据,客户端也能够关闭它这端的连接。即使连接的一端已经被标记为挂起状态,服务器仍然可以读取在它那端队列里的数据。当然,服务器在收到客户端的挂起消息时用close关闭到客户端的连接,可有效地抛弃所有队列里的数据。剩下的请求也没必要处理,因为我们已经无法发回响应的信息。
如同此函数的select版本,调用request函数(见图17-31)处理来自客户进程的新请求。此函数类似于其早期版本(见图17-22)。它调用同一函数buf_args(见图17-23),buf_args又调用cli_args(见图17-24),但是,因为它是在一个守护进程中运行的,所以它在日志文件中记录出错消息,而不是在标准错误上打印它们。
图17-31 request函数
这就完成了open服务器进程第2版,它仅使用一个守护进程就处理了所有的客户进程请求。
本章的关键点是如何在两个进程之间传送文件描述符,以及服务器进程如何接受来自客户进程的唯一连接。虽然所有平台都支持UNIX域套接字(见图15-1),但是各种实现都有不同之处,这使我们很难开发可移植的应用程序。
整章都使用了UNIX域套接字。我们了解了如何用它们来实现一个全双工的管道以及如何利用它们来适应14.4节的I/O多路转接函数以间接地用于XSI消息队列中。
本章给出了open服务器进程的两个版本。一个版本由客户进程用fork和exec直接调用,另一版本是一个守护服务器进程处理所有客户进程请求。这两个版本均采用文件描述符传送和接收函数。
我们还展示了如何使用getopt 函数来保证命令行参数处理的一致性。最终的 open 服务器进程版本使用了getopt函数、17.3节中引入的客户进程-服务器进程连接函数和14.4节中的I/O多路转接函数。
17.1 我们选择使用图17-3中的UNIX域数据报套接字,因为它们能够保留消息边界。描述如果使用常规的管道实现需要哪些必要的改动。我们应当如何避免额外的两次消息复制呢?
17.2 使用本章描述的文件描述符传送函数以及8.9节中描述的父进程和子进程同步例程,编写具有下列功能的程序。该程序调用fork,子进程打开一个现有的文件并将打开文件描述符传送给父进程。然后,子进程调用lseek确定该文件的当前读、写位置,通知父进程。父进程读该文件的当前偏移量,并打印它以便验证。若此文件按上述方式从子进程传递到父进程,则父进程和子进程应共享同一个文件表项,所以当子进程每次更改该文件当前偏移量时,这种更改应该也会影响父进程的描述符。使子进程将该文件定位至一个不同偏移量,并再次通知父进程。
17.3 图17-20和图17-21中的程序分别定义和声明了全局变量,两者的区别是什么?
17.4 改写buf_args函数(见图17-23),删除其中对argv数组长度的编译时限制。请用动态存储分配。
17.5 描述优化图17-29和图17-30中的loop函数的方法,并实现之。
17.6 在serv_listen函数(见图17-8)中,如果文件已经存在,我们要先对代表UNIX域套接字的文件名解除链接。为了防止误删除不是套接字的文件,我们可以先调用stat来验证文件类型。解释这种做法存在的两个问题。
17.7 请给出两种可能的方法,使得单次调用sendmsg可以传递多个文件描述符。尝试实现你的方法并验证你的操作系统是否支持这样的方法。
无论在哪种操作系统中,终端 I/O 的处理都是非常繁琐的一部分,UNIX 系统也不例外。在大多数版本的编程手册中,终端I/O手册页常常是最长的几个部分之一。
在20世纪70年代后期,系统Ⅲ在V7的基础上发展出一套不同的终端例程,由此使得UNIX终端 I/O 处理分立为两种不同的风格。一种是系统Ⅲ的风格,由 System V 沿续下来,另一种是V7 的风格,它成为BSD派生的系统终端I/O处理的标准。如同信号一样,POSIX.1在这两种风格的基础上制定了终端I/O标准。本章将介绍POSIX.1的所有终端函数,以及某些平台特有的增加部分。
终端I/O系统之所以如此复杂,部分原因是人们将其应用在众多的事物上:终端、计算机之间的直接连接、调制解调器以及打印机等。
终端I/O有两种不同的工作模式。
(1)规范模式输入处理。在这种模式中,对终端输入以行为单位进行处理。对于每个读请求,终端驱动程序最多返回一行。
(2)非规范模式输入处理。输入字符不装配成行。
如果不做特殊处理,则默认模式是规范模式。例如,若shell将标准输入重定向到终端,并用read和write将标准输入复制到标准输出,则终端以规范模式进行工作,每次read最多返回一行。处理整个屏幕的程序(如 vi 编辑器)使用非规范模式,原因是它的命令可能是由单个字符组成的,并且不以换行符终止。另外,该编辑器并不希望系统对特殊字符进行处理,因为这些字符很可能与编辑命令中使用的字符重叠。例如,Ctrl+D字符通常是终端的文件结束符,但在vi中它是向下滚动半个屏幕的命令。
V7和较早的BSD风格类的终端驱动程序支持3种终端输入模式:(a)精细加工模式(输入装配成行,并对特殊字符进行处理);(b)原始模式(输入不装配成行,也不对特殊字符进行处理);(c)cbreak模式(输入不装配成行,但对某些特殊字符进行处理)。图18-20显示了将终端设置为cbreak或原始模式的POSIX.1函数。
POSIX.1定义了11个特殊输入字符,其中9个可以更改。本书已经用到了其中几个,例如文件结束符(通常是Ctrl+D)和挂起字符(通常是Ctrl+Z)。18.3节将对这些字符逐一进行说明。
可以认为终端设备是由通常位于内核中的终端驱动程序控制的。每个终端设备都有一个输入队列和一个输出队列,如图18-1所示。
图18-1 终端设备的输入、输出队列的逻辑结构
对此图要说明以下几点。
•如果打开了回显功能,则在输入队列和输出队列之间有一个隐含的连接。
•输入队列的长度MAX_INPUT(见图2-11)是有限值。当一个特定设备的输入队列已经填满时,系统的行为将依赖于实现。这种情况发生时大多数UNIX系统回显响铃字符。
•图中没有显示另一个输入限制 MAX_CANON。这个限制是一个规范输入行的最大字节数。
•虽然输出队列的长度通常也是有限的,但是程序并不能获得这个定义其长度的常量,因为当输出队列将要填满时,内核便直接使写进程休眠,直至写队列中有可用的空间。
•我们将说明如何使用冲洗函数 tcflush 冲洗输入或输出队列。与此类似,在说明 tcsetattr 函数时,将会了解到如何通知系统只有在输出队列为空时,才能改变一个终端的属性。(例如,想要改变输出属性时就要这样做。)也可以通知系统,让它在改变终端属性时丢弃输入队列中的所有东西。(如果正在改变输入属性,或者在规范模式和非规范模式之间进行转换,就需要这样做,以免以错误的模式对以前输入的字符进行解释。)
大多数 UNIX 系统在一个称为终端行规程(terminal line discipline)的模块中进行全部的规范处理。可以将这个模块设想成一个盒子,位于内核通用读、写函数和实际设备驱动程序之间(见图18-2)。
图18-2 终端行规程
由于将规范处理分离为单独的模块,所有的终端驱动程序都能够一致地支持规范处理。在第19章讨论伪终端时还将使用此图。
所有可以检测和更改的终端设备特性都包含在 termios 结构中。该结构定义在头文件<termios.h>中,本章使用这一头文件。
cc_t c_cc[NCCS]; /* control characters */
tcflag_t c_lflag; /* local flags */
tcflag_t c_cflag; /* control flags */
tcflag_t c_oflag; /* output flags */
tcflag_t c_iflag; /* input flags */
struct termios {
};
粗略地说,输入标志通过终端设备驱动程序控制字符的输入(例如,剥除输入字节的第8位,允许输入奇偶校验),输出标志则控制驱动程序输出(例如,执行输出处理、将换行符转换为CR/LF),控制标志影响RS-232串行线(例如,忽略调制解调器的状态线、每个字符的一个或两个停止位),本地标志影响驱动程序和用户之间的接口(例如,回显打开或关闭、可视地擦除字符、允许终端产生的信号以及对后台输出的作业控制停止信号)。
类型tcflag_t的长度足以保存每个标志值,它经常被定义为unsigned int或者unsigned long。c_cc数组包含了所有可以更改的特殊字符。NCCS是该数组中元素的数量,其典型值在15~20(因为大多数UNIX实现支持的特殊字符都比POSIX.1所定义的11个要多)。cc_t类型的长度足以保存每个特殊字符,典型的是unsigned char。
POSIX标准之前的System V版本有一个名为<termio.h>的头文件和一个名为termio的数据结构。为了与先前版本有所区别,POSIX.1在这些名字后加了一个s。
图 18-3 至图 18-6 列出了所有可以更改以影响终端设备特性的终端标志。注意,虽然 Single UNIX Specification定义了供所有平台启动所用的公共子集,但所有实现都有自己的扩充部分。这些扩充部分大多来自各系统之间的历史差异。18.5节将对这些标志值进行详细的讨论。
图18-3 c_cflag终端标志
图18-4 c_iflag终端标志
图18-5 c_lflag终端标志
给出了所有可用的选项后,如何才能检测和更改终端设备的这些特性呢?图18-7总结并列出了Single UNIX Specification所定义的对终端设备进行操作的各个函数。(列出的所有函数都是 POSIX 基本规范的组成部分。9.7 节已说明了 tcgetpgrp、tcgetsid 和 tcsetpgrp函数。)
注意,对终端设备,Single UNIX Specification没有使用经典的ioctl,而是使用了图18-7中列出的13个函数。这样做的理由是:对于终端设备的ioctl函数,其最后一个参数的数据类型随执行动作的不同而改变。因此,不可能对参数进行类型检查。
图18-6 c_oflag终端标志
图18-7 终端I/O函数汇总
虽然在终端设备上进行操作的只有13个函数,但是图18-7中的前两个函数(tcgetattr和tcsetattr)能处理大约 70种不同的标志(见图 18-3至图 18-6)。终端设备有大量选项可供使用,此外,对于某个特定设备(假设其为终端、调制解调器、打印机或任何其他设备),决定其需要哪些选项对我们来说也是一种挑战,这些都使得对终端设备的处理变得异常复杂。
图18-7中列出的13个函数之间的关系如图18-8所示。
POSIX.1没有指定将波特率信息存储在termios结构中的什么地方,它依赖于实现的细节。某些系统,如Solaris,将此信息存储在c_cflag字段中。Linux和BSD派生的系统,如FreeBSD和Mac OS X,则在此结构中有两个分开的字段:一个存储输入速度,另一个存储输出速度。
图18-8 与终端有关的各函数之间的关系
POSIX.1 定义了 11 个在输入时要特殊处理的字符。实现定义了另外一些特殊字符。图 18-9总结并列出了这些特殊字符。
图18-9 终端特殊输入字符汇总
图18-9 终端特殊输入字符汇总(续)
在POSIX.1的11个特殊字符中,其中有9个字符的值可以任意更改。不能更改的两个特殊字符是换行符和回车符(分别是\n和\r),也可能是STOP和START字符(依赖于实现)。为了更改,只需要修改 termios 结构中 c_cc 数组的相应项。该数组中的元素都用名字作为下标进行引用,每个名字都以字母V开头(见图18-9中的第3列)。
POSIX.1允许禁止使用这些字符。若将c_cc数组中的某项设置为_POSIX_VDISABLE的值,则禁止使用相应特殊字符。
在Single UNIX Specification的早期版本中,支持_POSIX_VDISABLE是可选项,现在则是必选项。
本书讨论的4种平台都支持此特性。Linux 3.2.0和Solaris 10将_POSIX_VDISABLE定义为0,而FreeBSD 8.0和Mac OS X 10.6.8则将其定义为0xff。
某些早期的UNIX系统所用的方法是:若与某一特性相应的特殊输入字符是0,则禁止使用该特性。
实例
在详细说明各特殊字符之前,先看一个更改特殊字符的小程序。图18-10所示的程序禁用中断字符,并将文件结束符设置为Ctrl+B。
图18-10 禁用中断字符并更改文件结束符
对此程序要说明以下几点。
•仅当标准输入是终端设备时才修改终端特殊字符。调用isatty(见18.9节)对此进行检测。
•用fpathconf获取_POSIX_VDISABLE值。
•函数 tcgetattr(见 18.4 节)从内核获取 termios 结构。在修改了此结构后,调用 tcsetattr 函数设置属性,只有我们所希望修改的属性被更改了,而其他属性保持不变。
•禁用中断键与忽略中断信号是不同的。图 18-10 中的程序所做的只是禁用使终端驱动程序产生SIGINT信号的特殊字符。我们仍可使用kill函数将该信号发送至进程。
下面较详细地说明各个特殊字符。我们称这些字符为特殊输入字符,但是其中有两个字符—STOP 和 START(Ctrl+S和Ctrl+Q),在输出时也要进行特殊处理。注意,这些字符中的大多数在被终端驱动程序识别并进行特殊处理后会被丢弃,并不将它们返回给执行读终端操作的进程。返回给读进程的例外字符是换行符(NL、EOL、EOL2)和回车符(CR)。
CR 回车符。不能更改此字符。以规范模式进行输入时识别此字符。在已设置ICANON (规范模式)和ICRNL(将CR映射为NL)但并未设置IGNCR(忽略CR)时,CR字符会被转换成 NL,并具有与 NL 字符相同的作用。此字符返回给读进程(很可能是在转换为NL之后)。
DISCARD 丢弃符。在扩充模式(IEXTEN)下进行输入时识别此字符。在输入另一个DISCARD字符之前或在丢弃条件被清除之前(见FLUSHO 选项),此字符使后续输出都被丢弃。此字符在处理后即被丢弃(即不传送给读进程)。
DSUSP 延迟挂起作业控制字符(delayed-suspend job-control character)。在扩充模式(IEXTEN)下,若支持作业控制,并且已设置ISIG标志,则在输入时识别此字符。与SUSP字符的相同之处是:延迟挂起字符产生SIGTSTP信号,该信号被发送至前台进程组中的所有进程(见图9-7)。但是,信号产生的时间并不是在键入延迟挂起字符之时,而是在某个进程从控制终端读到此字符时才产生。此字符在处理后即被丢弃(即不传送给读进程)。
EOF 文件结束符。以规范模式(ICANON)进行输入时识别此字符。当键入此字符时,等待被读的所有字节都被立即传送给读进程。如果没有字节等待读,则返回0。在行首输入一个 EOF 字符是向程序指示文件结束的正常方式。此字符在规范模式下处理后即被丢弃(即不传送给读进程)。
EOL 附加的行定界符,与 NL 作用相同。以规范模式(ICANON)进行输入时识别此字符,并将此字符返回给读进程。但是此字符不常用。
EOL2 另一个行定界符,与NL作用相同。对此字符的处理方式与EOL字符相同。
ERASE 向前擦除字符(退格)。以规范模式(ICANON)输入时识别此字符。它擦除行中的前一个字符,但不会超越行首字符擦除上一行中的字符。此字符在规范模式下处理后即被丢弃(即不传送给读进程)。
ERASE2 供替换的向前擦除字符(退格)。对此字符的处理与向前擦除字符(ERASE)完全相同。
INTR 中断字符。若已设置ISIG标志,则在输入中识别此字符。它产生SIGINT信号,该信号被送至前台进程组中的所有进程(见图9-7)。此字符在处理后即被丢弃(即不传送给读进程)。
KILL 杀死字符。(名字“杀死”在这里又一次被误用,kill函数是用来将某一信号发送给进程的,而此字符应被称为行擦除符,它与信号毫无关系。)以规范模式(ICANON)输入时识别此字符。它擦除一整行,并在处理后即被丢弃(即不传送给读进程)。
LNEXT 下一个字符的字面值(literal-next character)。以扩充方式(IEXTEN)输入时识别此字符,它使下一个字符的任何特殊含意都被忽略。这对本节提及的所有特殊字符都起作用。使用这一字符可向程序键入任何字符。LNEXT字符在处理后即被丢弃,但输入的下一个字符被传送给读进程。
NL 换行字符,也被称为行定界符。不能更改此字符。以规范模式(ICANON)输入时识别此字符。此字符返回给读进程。
QUIT 退出字符。若已设置ISIG标志,则在输入中识别此字符。它产生SIGQUIT信号,该信号又被送至前台进程组中的所有进程(见图9-7)。此字符在处理后即被丢弃(即不传送给读进程)。
回忆图10-1,INTR和QUIT的区别是:QUIT字符不仅按默认规则终止进程,而且还产生一个core文件。
REPRINT 再打印字符。以扩充规范模式(设置了 IEXTEN和ICANON标志)进行输入时识别此字符。它使所有未读的输入被输出(再回显)。此字符在处理后即被丢弃(即不传送给读进程)。
START 启动字符。若已设置IXON标志,则在输入中识别此字符。若已设置IXOFF标志,则自动产生此字符作为输出。已设置IXON时,接收到的START 字符使停止的输出(由以前输入的STOP字符造成)重新启动。在此情形下,此字符在处理后即被丢弃(即不传送给读进程)。
STATUS BSD 的状态请求字符。以扩充规范模式(设置了 IEXTEN 和 ICANON 标志)进行输入时识别此字符。它产生 SIGINFO信号,该信号又被送至前台进程组中的所有进程(见图 9-7)。另外,如果没有设置NOKERNINFO标志,则有关前台进程组的状态信息也显示在终端上。此字符在处理后即被丢弃(即不传送给读进程)。
STOP 停止字符。若已设置IXON标志,则在输入中识别此字符。若已设置IXOFF标志,则自动产生此字符作为输出。已设置IXON时,接收到STOP字符则停止输出。在此情形下,此字符在处理后即被丢弃(即不传送给读进程)。当输入一个START字符后,被停止的输出重新启动。
SUSP 挂起作业控制字符。若支持作业控制并且已设置ISIG标志,则在输入中识别此字符。它产生SIGTSTP信号,该信号又被送至前台进程组的所有进程(见图9-7)。此字符在处理后即被丢弃(即不传送给读进程)。
已设置 IXOFF 标志时,若新的输入不会使输入缓冲区溢出,则终端驱动程序自动产生一个START字符来恢复以前被停止的输入。
已设置IXOFF时,终端驱动程序自动产生一个STOP字符以防止输入缓冲区溢出。
WERASE 字擦除字符。以扩充规范模式(设置了IEXTEN和ICANON标志)进行输入时识别此字符。它使前一个字被擦除。首先,它向前跳过任意一个空白字符(空格或制表符),然后再向前跃过前一记号,使光标处在前一个记号的第一个字符位置上。通常,前一个记号在碰到一个空白字符时即终止。但是,可通过设置ALTWERASE标志来改变这个行为。此标志使前一个记号在碰到第一个非字母、非数字字符时即终止。此字符在处理后即被丢弃(即不传送给读进程)。
需要为终端设备定义的另一个“字符”是 BREAK 字符。BREAK 实际上并不是一个字符,而是在异步串行数据传送时发生的一个条件。根据串行接口的不同,可以有多种方式通知设备驱动程序发生了BREAK条件。
大多数早期的串行终端都有一个标记为BREAK的键,用其可以产生BREAK条件,这就是为什么大多数人认为BREAK就是一个字符的原因。某些较新的终端键盘没有BREAK键。在PC上,BREAK键可能有其他用途。例如,键入Ctrl+BREAK可中断Windows命令解释器。
对于异步串行数据传送,BREAK是一个0值的位序列,其持续时间长于要求发送一个字节的时间。整个0值位序列被视为是一个BREAK。18.8节将说明如何用tcsendbreak函数发送一个BREAK。
为了获得和设置termios结构,可以调用tcgetattr和tcsetattr函数。这样就可以检测和修改各种终端选项标志和特殊字符,使终端按我们所希望的方式进行操作。
#include <termios.h>
int tcgetattr(int fd, struct termios *termptr);
int tcsetattr(int fd, int opt, const struct termios *termptr);
两个函数的返回值:若成功,返回0;若出错,返回-1
这两个函数都有一个指向termios结构的指针作为其参数,它们或者返回当前终端的属性,或者设置该终端的属性。因为这两个函数只对终端设备进行操作,所以若fd没有引用终端设备则出错返回-1,errno设置为ENOTTY。
tcsetattr的参数opt使我们可以指定在什么时候新的终端属性才起作用。opt可以指定为下列常量中的一个。
TCSANOW 更改立即发生。
TCSADRAIN 发送了所有输出后更改才发生。若更改输出参数则应使用此选项。
TCSAFLUSH 发送了所有输出后更改才发生。更进一步,在更改发生时未读的所有输入数据都被丢弃(冲洗)。
Tcsetattr 函数的返回状态在使用时易产生混淆。如果它执行了任意一种所要求的动作,即使未能执行所有要求的动作,它也返回OK(表示成功)。如果该函数返回OK,则我们有责任检查该函数是否执行了所有要求的动作。这就意味着,在调用tcsetattr设置所希望的属性后,需调用tcgetattr,然后将实际终端属性与所希望的属性相比较,以检测两者是否有区别。
在终端第一次被打开时,其属性视具体情况而定。一些系统可能会将终端属性初始化为具体实现所定义的值,另一些系统可能会保留并使用最后一次使用终端时的属性值。通过打开一个带有O_TTY_INIT标志(见3.3节)的驱动设备,可以确认终端的行为是否遵循标准,这样就能在调用tcgetattr 时,确保初始化termios结构中的任何非标准部分,使得在修改属性和调用tcgetattr时,终端的表现符合预期。
本节将列出所有不同的终端选项标志,扩展图18-3至图18-6中的说明。我们将按字母顺序列出各个选项并指出每个选项出现在 4 个终端标志字段中的哪一个。(从选项名字中看不出它所处的字段。)还将说明每个选项是否是Single UNIX Specification定义的,并列出了支持该选项的平台。
列出的所有选项标志(除所谓的屏蔽字标志外)都用一位或多位(设置或清除)表示。屏蔽字标志定义多个位,它们组合在一起,可以定义一组值。屏蔽字标志有一个定义名,每个值也有一个名字。例如,为了设置字符长度,首先用字符长度屏蔽字标志 CSIZE 将表示字符长度的位清0,然后设置下列值之一:CS5、CS6、CS7或CS8。
由Linux和Solaris支持的6个延迟值也有屏蔽字标志:BSDLY、CRDLY、FFDLY、NLDLY、TABDLY和VTDLY。对于每个延迟值的长度请参阅Solaris中的termio(7I)手册页。在所有情况下,延迟屏蔽字为0就表示没有延迟。如果指定了延迟,则由OFILL和OFDEL标志决定是由驱动器进行实际延迟还是只传输填充字符。
实例
图18-11演示了如何使用这些屏蔽字标志取一个值或者设置一个值。
图18-11 tcgetattr和tcsetattr实例
下面说明各选项标志。
ALTWERASE (c_lflag,FreeBSD、Mac OS X)已设置此标志时,若输入WERASE字符,则使用一个替换的字擦除算法。它不是向前移动到前一个空白字符为止,而是向前移动到第一个非字母、非数字字符为止。
BRKINT (c_iflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)若已设置此标志,而未设置 IGNBRK,则在接到 BREAK 时,冲洗输入、输出队列,并产生一个SIGINT 信号。如果此终端设备是一个控制终端,则此信号就是为前台进程组产生的。
若未设置IGNBRK和BRKINT,但是设置了PARMRK,则BREAK被读作一个3字节序列\377、\0和\0;若也未设置PARMRK,则BREAK被读作单个字符\0。
BSDLY (c_oflag,XSI、Linux、Solaris)退格延迟屏蔽字。此屏蔽字的值是BS0或BS1。(c_cflag,Solaris)扩充的波特率。用于允许大于 B38400 的波特率。(将在18.7节讨论波特率。) CBAUDEXT
CCAR_OFLOW (c_cflag,FreeBSD、Mac OS X)使用 RS-232调制解调器DCD(Data-Carrier-Detect,数据载波检测)信号打开输出的硬件流控制。这与早期的MDMBUF标志相同。
CCTS_OFLOW (c_cflag,FreeBSD、Mac OS X、Solaris)使用RS-232 CTS(Clear-To-Send,清除发送)信号打开输出的硬件流控制。
CDSR_OFLOW (c_cflag,FreeBSD、Mac OS X)根据RS-232 DSR(Data-Set-Ready,数据准备就绪)信号进行输出的流控制。
CDTR_IFLOW (c_cflag,FreeBSD,Mac OS X)根据RS-232 DTR(Data-Terminal-Ready,数据终端就绪)信号进行输入的流控制。
CIBAUDEXT (c_cflag,Solaris)扩充的输入波特率。用于允许大于B38400的输入波特率。
(将在18.7节讨论波特率。)
CIGNORE (c_cflag,FreeBSD、Mac OS X)忽略控制标志。
CLOCAL (c_cflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)若设置,则忽略调制解调器状态线。这通常意味着该设备是直接连接的。例如,若未设置此标志,则打开一个终端设备常常会遭遇阻塞,直到调制解调器回应呼叫并建立连接。
CMSPAR (c_oflag,Linux)选择标记或空奇偶校验。若已设置 PARODD,则奇偶校验位总是1(标记奇偶校验)。否则奇偶校验位总是0(空奇偶校验)。
CRDLY (c_oflag,XSI、Linux、Solaris)回车延迟屏蔽字。此屏蔽字的可能值是CR0、CR1、CR2和CR3。
CREAD (c_cflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)若设置,则接收者被启用,可以接收字符。
CRTSCTS (c_cflag,FreeBSD、Linux、Mac OS X、Solaris)其行为依赖于平台。对于Solaris,若设置该标志,则允许带外硬件流控制。在另外 3 个平台上,则既允许带内硬件流控制,又允许带外硬件流控制(等价于 CCTS_OFLOW|CRTS_IFLOW)。
CRTS_IFLOW (c_cflag,FreeBSD、Mac OS X、Solaris)输入的RTS(Request-To-Send,请求发送)流控制。
CRTSXOFF (c_cflag,Solaris)若设置,则允许带内硬件流控制,RS-232 RTS信号的状态控制了流控制。
CSIZE (c_cflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)此字段是一个屏蔽字标志,它指定发送和接收的每个字节的位数。此长度不包括可能有的奇偶校验位。由此屏蔽字定义的字段值是 CS5、CS6、CS7 和CS8,分别表示每个字节包含5位、6位、7位和8位。
CSTOPB (c_cflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)若设置,则使用两个停止位,否则只使用一个停止位。
ECHO (c_lflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)若设置,则将输入字符回显到终端设备。在规范模式和非规范模式下都可以回显输入字符。
ECHOCTL (c_lflag,FreeBSD、Linux、Mac OS X、Solaris)若设置并且也设置ECHO,则除ASCII TAB、ASCII NL以及START和STOP字符外,其他ASCII控制字符(ASCII字符集中0至八进制37对应的字符)都被回显为^X,其中,X是相应控制字符加上八进制100所构成的字符。例如,ASCII Ctrl+A字符(八进制1)被回显为^A。ASCII DELETE字符(八进制177)则回显为^?。若未设置此标志,则ASCII控制字符按其原样回显。如同ECHO标志,在规范模式和非规范模式下,此标志对控制字符回显都起作用。
应当了解的是,某些系统以不同方式回显EOF字符,因为EOF的典型值是Ctrl+D (而Ctrl+D是ASCII EOT字符,它可能使某些终端挂断)。请查看有关手册。
ECHOE (c_lflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)若设置并且也设置ICANON,则ERASE字符从显示中擦除当前行中的最后一个字符。这通常是在终端驱动程序中写一个3字符序列实现的,该序列是:退格、空格、退格。若支持WERASE字符,则ECHOE用一个或若干个上述3字符序列擦除前一个字。若支持 ECHOPRT 标志,则这里说明的关于 ECHOE 的动作是在假定未设置ECHOPRT标志的条件下得出的。
ECHOK (c_lflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)若设置并且也设置ICANON,则KILL字符从显示中擦除当前行,或者输出NL字符(用以强调已擦除整个行)。
若支持ECHOKE标志,则关于ECHOK的说明是在假定未设置ECHOKE标志的条件下得出的。
ECHOKE (c_lflag,FreeBSD、Linux、Mac OS X、Solaris)若设置并且也设置ICANON,则回显 KILL 字符的方式是擦除行中的每一个字符。擦除每个字符的方法则由ECHOE和ECHOPRT标志选择。
ECHONL (c_lflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)若设置并且也设置ICANON,即使没有设置ECHO,也回显NL字符。
ECHOPRT (c_lflag,FreeBSD、Linux、Mac OS X、Solaris)若设置并且也设置ICANON和ECHO,则ERASE字符(以及WERASE字符,若受到支持)使所有正被擦除的字符按它们被擦除的方式被打印。这一方法常在硬拷贝终端上显示其作用,它可以使我们确切地看到哪些字符正被刪除。
EXTPROC (c_lflag,FreeBSD、Linux、Mac OS X)若设置,规范字符处理在操作系统之外执行。如果串行通信外设卡能够通过执行某些行规程处理减轻主机处理器负载,那么就可以这样设置。在使用伪终端时(见第19章),也可以这样设置。
FFDLY (c_oflag,XSI、Linux、Solaris)换页延迟屏蔽字。此屏蔽字标志值是FF0或FF1。
FLUSHO (c_lflag,FreeBSD、Linux、Mac OS X、Solaris)若设置,则冲洗输出。当键入 DISCARD 字符时设置此标志。当键入另一个 DISCARD 字符时,此标志被清除。可以通过设置或清除此终端标志来设置或清除此条件。
HUPCL (c_cflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)若设置,则当最后一个进程关闭设备时,调制解调器控制线降至低电平(也就是调制解调器的连接断开)。
ICANON (c_lflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)若设置,则按规范模式工作(见18.10节)。这使下列字符起作用:EOF、EOL、EOL2、ERASE、KILL、REPRINT、STATUS和WERASE。输入字符被装配成行。
ICRNL (c_iflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)若设置并且未设置IGNCR,则将接收到的CR字符转换成NL字符。
IEXTEN (c_lflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)若设置,则识别并处理扩展的、由实现定义的特殊字符。
IGNBRK (c_iflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)在已设置时,忽略输入中的BREAK条件。关于BREAK条件是产生SIGINT信号还是被作为数据读取,见BRKINT。
IGNCR (c_iflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)若设置,则忽略接收到的CR字符。若未设置此标志,而设置了ICRNL标志,则有可能将接收到的CR字符转换成NL字符。
IGNPAR (c_iflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)在已设置时,忽略带有结构出错(非BREAK)或奇偶出错的输入字节。
IMAXBEL (c_iflag,FreeBSD、Linux、Mac OS X、Solaris)当输入队列满时响铃。
INLCR (c_iflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)若设置,则将接收到的NL字符转换成CR字符。
如果不以规范模式工作,则读请求直接从输入队列取字符。在至少接到MIN个字节或两个字节之间的超时值TIME到期时,read才返回。详细情况参见18.11节。
INPCK (c_iflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)在已设置时,使输入奇偶校验起作用。若未设置INPCK,则使输入奇偶校验不起作用。
奇偶“产生和检测”和“输入奇偶校验”是两件不同的事。奇偶位的产生和检测是由PARENB标志控制的。设置该标志后通常会使串行接口的设备驱动程序对输出字符产生奇偶位,对输入字符则验证其奇偶性。PARODD 标志决定该奇偶性应当是奇还是偶。如果一个其奇偶性错误的输入字符到来,则检查INPCK标志的状态。若已设置此标志,则检查IGNPAR标志(以决定是否应忽略带奇偶出错的输入字节);若不应忽略此输入字节,则检查PARMRK标志以决定应该向读进程传送哪些字符。
ISIG (c_lflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)若设置,则判别输入字符是否是要产生终端信号的特殊字符(INTR、QUIT、SUSP和DSUSP);若是,则产生相应信号。
ISTRIP (c_iflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)在已设置此标志时,有效输入字节被剥离为7位。在未设置时,则处理全部8位。
IUCLC (c_iflag,Linux、Solaris)将输入的大写字符转换成小写字符。
IUTF8 (c_iflag,Linux、Mac OS X)允许使用UTF-8多字节字符进行字符擦除处理。
IXANY (c_iflag,XSI、FreeBSD、Linux、Mac OS X、Solaris)使任何字符都能重新启动输出。
IXOFF (c_iflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)若设置,则使启动-停止输入控制起作用。当终端驱动程序发现输入队列将要填满时,输出一个STOP字符。此字符应当由发送数据的设备识别,并使该设备停止。此后,当把输入队列中的字符处理完毕之后,终端驱动程序将输出一个START字符,使该设备恢复发送数据。
IXON (c_iflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)若设置,则使启动-停止输出控制起作用。当终端驱动程序接收到一个STOP字符时,输出停止。在输出停止时,下一个START字符恢复输出。若未设置此标志,则START和STOP字符由进程作为一般字符读取。
MDMBUF (c_cflag,FreeBSD、Mac OS X)按照调制解调器的载波标志进行输出流控制。这是CCAR_OFLOW标志的曾用名。
NLDLY (c_oflag,XSI、Linux、Solaris)换行延迟屏蔽字。此屏蔽字的值是NL0或NL1。
NOFLSH (c_lflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)按系统默认,当终端驱动程序产生 SIGINT 和 SIGQUIT 信号时,输入和输出队列都被冲洗。另外,当它产生SIGSUSP信号时,输入队列被冲洗。若已设置NOFLSH标志,则在这些信号产生时,不对输入、输出队列进行常规冲洗。
NOKERNINFO (c_lflag,FreeBSD、Mac OS X)在已设置时,此标志阻止STATUS字符打印前台进程组的信息。但是无论是否设置此标志,STATUS 字符都会使 SIGINFO信号被发送至前台进程组。
OCRNL (c_oflag,XSI、FreeBSD、Linux、Solaris)若设置,则将输出的 CR 字符转换成NL字符。
OFDEL (c_oflag,XSI、Linux、Solaris)若设置,则输出填充字符是ASCII DEL;否则是ASCII NUL。见OFILL标志。
OFILL (c_oflag,XSI、Linux、Solaris)若设置,则传递填充字符(ASCII DEL 或ASCII NUL,见OFDEL标志)以实现延迟,而不使用时间延迟。见6个延迟屏蔽字标志:BSDLY、CRDLY、FFDLY、NLDLY、TABDLY和VTDLY。
OLCUC (c_oflag,Linux、Solaris)若设置,则将小写字符转换成大写字符。
NLCR (c_oflag,XSI、FreeBSD、Linux、Mac OS X、Solaris)若设置,将输出的NL字符转换成CR-NL字符。
ONLRET (c_oflag,XSI、FreeBSD、Linux、Solaris)若设置,则假定输出的 NL 字符执行回车功能。
ONOCR (c_oflag,XSI、FreeBSD、Linux、Solaris)若设置,则在0列不输出CR字符。
ONOEOT (c_oflag,FreeBSD、Mac OS X)若设置,则在输出中丢弃EOT(^D)字符。在某些将Ctrl+D解释为挂断的终端上,设置此标志可能是必需的。
OPOST (c_oflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)若设置,则进行实现定义的输出处理。关于c_oflag字段的各种实现定义标志,见图18-6。
OXTABS (c_oflag,FreeBSD、Mac OS X)若设置,则制表符在输出中被扩展为空格。这与将水平制表符延迟(TABDLY)设置为XTABS或TAB3所产生的效果相同。
PARENB (c_cflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)若设置,则对输出字符产生奇偶位,对输入字符执行奇偶校验。若已设置PARODD,则奇偶校验是奇校验;否则是偶校验。另见对INPCK、IGNPAR和PARMRK标志的讨论。
PAREXT (c_cflag,Solaris)选择标记或空奇偶性。若PARODD设置,则奇偶位总是1 (标记奇偶性);否则,奇偶位总是0(空奇偶性)。
PARMRK (c_iflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)在已设置时,若未设置IGNPAR,则带有结构出错(非BREAK)的字节或带有奇偶出错的字节将被进程读作一个3字符序列\377、\0和X,其中X是接收到的出错字节。若未设置ISTRIP,则一个有效的\377被传送给进程时为\377, \377。若未设置IGNPAR和PARMRK,则带有结构出错误或奇偶出错的字节都被读作一个字符\0。
PARODD (c_cflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)若设置,则输出和输入字符的奇偶性都是奇,否则为偶。注意,PARENB 标志控制奇偶性的产生和检测。
PENDIN (c_lflag,FreeBSD、Linux、Mac OS X、Solaris)若设置,则在下一个字符输入时,尚未读的任何输入都由系统重新打印。这一动作与键入REPRINT字符时的作用相类似。
TABDLY (c_oflag,XSI、Linux、Mac OS X、Solaris)水平制表符延迟屏蔽字。此屏蔽字的值是TAB0、TAB1、TAB2或TAB3。
在已设置CMSPAR或PAREXT标志时,PARODD标志也控制是否使用标记或空奇偶性。
XTABS 的值等于 TAB3。此值使系统将制表符扩展成空格。系统假定制表符的长度为8个空格,不能更改此假定。
TOSTOP (c_lflag,POSIX.1、FreeBSD、Linux、Mac OS X、Solaris)若设置,并且该实现支持作业控制,则将信号SIGTTOU 送到试图写控制终端的一个后台进程的进程组。按默认,此信号暂停该进程组中所有进程。如果写控制终端的后台进程忽略或阻塞此信号,则终端驱动程序不产生此信号。
VTDLY (c_oflag,XSI、Linux、Solaris)垂直制表延迟屏蔽字。此屏蔽字的值是VT0和VT1。
XCASE (c_lflag,Linux、Solaris)若设置,并且也设置ICANON,则终端被假定为只支持大写字符,全部输入转换为小写字符。要想输入一个大写字符,要在其前面加一个反斜杠。与之类似,系统输出大写字符时,也要在其前面加一个反斜杠。(如今这个选项标志已弃用,因为只支持大写字符的终端即使不是全部,也是绝大部分都已经不存在了。)
上节说明的所有选项都可以被检查和更改:在程序中用 tcgetattr 和 tcsetattr 函数(见18.4节)进行检查和更改;在命令行(或shell脚本)中用stty(1)命令进行检查和更改。简单地说,stty(1)命令就是图18-7中所列的前6个函数的接口。如果以-a选项执行此命令,则显示终端的所有选项:
$ stty -a
speed 9600 baud; 25 rows; 80 columns;
lflags: icanon isig iexten echo echoe -echok echoke -echonl echoctl
-echoprt -altwerase -noflsh -tostop -flusho pendin -nokerninfo
-extproc
iflags: -istrip icrnl -inlcr -igncr ixon -ixoff ixany imaxbel -ignbrk
brkint -inpck -ignpar -parmrk
oflags: opost onlcr -ocrnl -oxtabs -onocr -onlret
cflags: cread cs8 -parenb -parodd hupcl -clocal -cstopb -crtscts
-dsrflow -dtrflow -mdmbuf
cchars: discard = ^O; dsusp = ^Y; eof = ^D; eol = <undef>;
eol2 = <undef>; erase = ^H; erase2 = ^?; intr = ^C; kill = ^U;
lnext = ^V; min = 1; quit = ^; reprint = ^R; start = ^Q;
status = ^T; stop = ^S; susp = ^Z; time = 0; werase = ^W;
若在选项名前有一个连字符,表示该选项禁用。最后4行显示各终端特殊字符(见18.3节)的当前设置。第1行显示当前终端窗口的行数和列数,18.12节将对终端窗口大小进行讨论。
stty命令使用它的标准输入获得和设置终端的选项标志。虽然,某些较早的实现使用标准输出,但POSIX.1要求使用标准输入。本书讨论的4种实现提供了在标准输入上操作的stty版本。
这意味着如果希望了解名为ttyla的终端的设置,那么可以键入
stty -a </dev/ttyla
术语波特率(baud rate)是一个历史沿用的术语,现在它指的是“位/秒”(bit per second)。虽然大多数终端设备对输入和输出使用同一波特率,但是只要硬件许可,可以将它们设置为两个不同值。
#include <termios.h>
speed_t cfgetispeed(const struct termios *termptr);
speed_t cfgetospeed(const struct termios *termptr);
两个函数的返回值:波特率值
int cfsetispeed(struct termios *termptr, speed_t speed);
int cfsetospeed(struct termios *termptr, speed_t speed);
两个函数的返回值:若成功,返回0;出错,返回-1
两个cfget函数的返回值,以及两个cfset函数的speed参数都是下列常量之一:B50、B75、B110、B134、B150、B200、B300、B600、B1200、B1800、B2400、B4800、B9600、B19200或B38400。常量B0表示“挂断”。在调用tcsetattr时,如若将输出波特率指定为B0,则调制解调器的控制线就不再起作用。
大多数系统定义了另外的波特率值,如B57600以及B115250。
使用这些函数时,必须认识到输入、输出波特率是存储在设备的termios结构中的,如图18-8所示。在调用两个cfget函数中的任意一个之前,要先用tcgetattr获得设备的termios结构。与此类似,在调用两个cfset函数中的任意一个之后,要做的就是在termios结构中设置波特率。为使这种更改影响到设备,应当调用tcsetattr函数。即使所设置的两个波特率中的任意一个出错,在调用tcsetattr之前可能也不会发现这个错误。
这4个波特率函数的存在使应用程序不必考虑具体实现在termios结构中表示波特率的不同方法。Linux和BSD派生的平台趋向于存储波特率的数值。(即9 600波特率存储成值9 600),然而,System V派生的平台(如Solaris)趋向于以位屏蔽方式编码波特率。从cfget函数得到的速度值以及向cfset函数传送的速度值都未转换,与它们存储在termios结构中的表示形式一样。
下列4个函数提供了终端设备的行控制能力。4个函数都要求参数fd引用一个终端设备,否则出错返回-1,errno设置为ENOTTY。
#include <termios.h>
int tcdrain(int fd);
int tcflow(int fd, int action);
int tcflush(int fd, int queue);
int tcsendbreak(int fd, int duration);
4个函数的返回值:若成功,返回0;若出错,返回-1
tcdrain 函数等待所有输出都被传递。tcflow 函数用于对输入和输出流控制进行控制。action参数必定是下列4个值之一。
TCOOFF 输出被挂起。
TCOON 重新启动以前被挂起的输出。
TCIOFF 系统发送一个STOP字符,这将使终端设备停止发送数据。
TCION 系统发送一个START字符,这将使终端设备恢复发送数据。
tcflush函数冲洗(抛弃)输入缓冲区(其中的数据是终端驱动程序已接收到,但用户程序尚未读取的)或输出缓冲区(其中的数据是用户程序已经写入,但尚未被传递的)。queue参数必定是下列3个常量之一。
TCIFLUSH 冲洗输入队列。
TCOFLUSH 冲洗输出队列。
TCIOFLUSH 冲洗输入队列和输出队列。
tcsendbreak函数在一个指定的时间区间内发送连续的0值位流。若duration参数为0,则此种传递延续0.25~0.5秒。POSIX.1说明若duration非0,则传递时间依赖于实现。
历史上,在大多数UNIX系统版本中,控制终端的名字一直是/dev/tty。POSIX.1提供了一个运行时函数,可用来确定控制终端的名字。
#include <stdio.h>
char *ctermid(char *ptr);
返回值:若成功,返回指向控制终端名的指针;若出错,返回指向空字符串的指针
如果ptr非空,则被认为是一个指针,指向长度至少为 L_ctermid 字节的数组,进程的控制终端名存储在该数组中。常量L_ctermid被定义在<stdio.h>中。若ptr是一个空指针,则该函数为数组(通常作为静态变量)分配空间。同样,进程的控制终端名存储在该数组中。
在这两种情况中,该数组的起始地址都被作为函数值返回。因为大多数 UNIX 系统都使用/dev/tty作为控制终端名,所以此函数的主要作用是改善向其他操作系统的可移植性。
当调用ctermid函数时,本书说明的所有4种平台都返回字符串/dev/tty。
实例:ctermid函数
图18-12给出的是POSIX.1 ctermid函数的一个实现。
图18-12 POSIX.1 ctermid函数的实现
注意,因为我们无法确定调用者的缓冲区大小,所以也就不能防止过度使用该缓冲区。
另外还有两个UNIX 系统比较感兴趣的函数:isatty 和ttyname。如果文件描述符引用一个终端设备,则isatty返回真。ttyname返回的是在该文件描述符上打开的终端设备的路径名。
#include <unistd.h>
int isatty(int fd);
返回值:若为终端设备,返回1(真);否则,返回0(假)
char *ttyname(int fd);
返回值:指向终端路径名的指针;若出错,返回NULL
实例:isatty函数
如图18-13 所示,isatty 函数很容易实现。我们只尝试使用了其中一个终端专用函数(如果成功执行,它不改变任何东西),并查看了其返回值。
图18-13 POSIX.1 isatty函数的实现
使用图18-14中的程序测试isatty函数。
图18-14 测试isatty函数
运行图18-14中的程序,得到如下输出:
$ ./a.out
fd 0: tty
fd 1: tty
fd 2: tty
$ ./a.out </etc/passwd 2>/dev/null
fd 0: not a tty
fd 1: tty
fd 2: not a tty
实例:ttyname函数
ttyname函数(见图18-15)比较长,因为它要搜索所有设备表项,寻找匹配项。
图18-15 POSIX.1 ttyname函数的实现
此处使用的技术是读/dev目录,寻找具有相同设备号和i节点编号的表项。回忆4.24节,每个文件系统都有一个唯一的设备号(stat 结构中的 st_dev 字段,见 4.2 节),文件系统中的每个目录项都有一个唯一的 i 节点编号(stat 结构中的 st_ino 字段)。在此函数中,假定在找到一个匹配的设备号和匹配的i节点号时,就能找到所希望的目录项。也能验证这两个表项与 st_rdev 字段(终端设备的主设备号和次设备号)相匹配,还能验证该目录项是一个字符特殊文件。但是,因为已经验证了文件描述符参数既是一个终端设备,又是一个字符特殊文件,而且因为在UNIX系统中,匹配的设备号和i节点编号是唯一的,所以不再需要进行另外的比较。
终端名可能在/dev的子目录中。于是,需要搜索/dev下的整个文件系统树。我们跳过了少数几个可能会产生不正确结果或奇怪结果的目录:/dev/.、/dev/..和/dev/fd。我们也跳过了一些别名:/dev/stdin、/dev/stdout以及/dev/stderr,因为它们是/dev/fd目录中文件的符号链接。
使用图18-16中的程序测试这一实现。
图18-16 测试ttyname函数
运行图18-16中的程序,得到:
$ ./a.out < /dev/console 2> /dev/null
fd 0: /dev/console
fd 1: /dev/ttys001
fd 2: not a tty
规范模式很简单:发一个读请求,当一行已经输入后,终端驱动程序即返回。以下几个条件造成读返回。
•所请求的字节数已读到时,读返回。无需读一个完整的行。如果读了部分行,那么也不会丢失任何信息,下一次读从前一次读的停止处开始。
•当读到一个行定界符时,读返回。回忆 18.3 节,在规范模式中,下列字符被解释为“行结束”:NL、EOL、EOL2和EOF。另外,在18.5节中也曾说明,如若已设置ICRNL,但未设置IGNCR,则CR字符的作用与NL字符一样,也终止一行。
在这5个行界定符中,只有一个EOF符在终端驱动程序对其进行处理后即被丢弃。其他4个字符则作为其所处行的最后一个字符返回给调用者。
•如果捕捉到信号,并且该函数不再自动重启(见10.5节),则读也返回。
实例:getpass函数
下面说明getpass函数,它读入用户在终端上键入的口令。此函数由login(1)和crypt(1)程序调用。为了读取口令,该函数必须关闭回显,但仍可使终端以规范模式进行工作,因为不管键入什么作为口令都能构成一个完整行。图18-17显示了UNIX系统中的一个典型实现。
图18-17 getpass函数的实现
在此例中,应当考虑以下几个方面。
•调用ctermid函数打开控制终端,而不是直接将/dev/tty写在程序中。
•只是读、写控制终端,如果不能以读、写模式打开此设备则出错返回。还有一些其他的使用约定。在GNU C函数库版本中,如果不能以读、写模式打开控制终端,则getpass读取标准输入,写到标准错误。在Solaris版本中,如果不能打开控制终端,则getpass失败。
•阻塞两个信号SIGINT和SIGTSTP。如果不这样做,在输入INTR字符时就会使程序异常中止,并使终端仍处于禁止回显状态。与此相类似,输入 SUSP 字符时将使程序停止,并且在禁止回显状态下返回到 shell。在禁止回显时,我们选择了阻塞这两个信号。如果这两个信号是在读取口令期间产生的,则它们会一直被保持,直到getpass返回,阻塞才会解除。也有其他方法来处理这些信号。有些getpass版本忽略SIGINT(保存它以前的动作),在返回前将其动作恢复为以前的值。这就意味着,在该信号被忽略期间所发生的这种信号都会丢失。其他版本捕捉 SIGINT(保存它以前的动作),如果捕捉到此信号,则在恢复终端状态和信号动作后,用kill函数发送此信号。没有一个getpass版本捕捉、忽略或阻塞SIGQUIT,所以输入QUIT字符就会使程序异常中止,并且很可能使终端保持在禁止回显状态。
• 请注意,某些shell,尤其是Korn shell,在以交互方式读输入时都使终端处于回显状态。这些shell是提供命令行编辑的shell,因此在每次输入一条交互命令时都处理终端状态。所以如果在这种shell下调用此程序,并且用QUIT字符使其异常中止,则这种shell可能会恢复回显状态。其他不提供命令行编辑的shell(如Bourne shell)将使程序异常中止,并使终端保持在不回显状态。如果对终端做了这种操作,则stty命令能使终端恢复到回显状态。
•使用标准I/O读、写控制终端。我们特地将流设置为不带缓冲的,否则在流的读、写之间可能会有某些交叉(这样就需要多次调用 fflush)。也可使用不带缓冲的 I/O(见第 3章),但是在这种情况下就只能用read来模仿getc函数。
•最多只存储8个字符作为口令。输入的其他多余字符则全部被忽略。
图18-18中的程序调用getpass并且打印我们输入的内容。这是为了验证ERASE和KILL字符能否正常工作(如同它们在规范模式下应该表现的那样)。
图18-18 调用getpass函数
如果调用 getpass 函数的程序使用的是明文口令,那么为了安全起见,在程序完成后应在内存中清除它。如果该程序会产生其他用户可能读取的core文件(回忆10.2节,core的系统默认许可权使每个用户都能读它),或者如果某个其他进程能够设法读该进程的存储空间,则它们就可能会读到这个明文口令。(“明文”是指我们在 getpass 打印的提示符处键入的口令。大多数UNIX系统程序会对这个明文口令进行修改,将它转换成一个“加密”口令。例如,口令文件(见6.2节)中的pw_passwd字段包含的是加密口令,而不是明文口令。)