Linux系统编程(第2版)

978-7-115-34635-3
作者: 【美】Robert Love
译者: 祝洪凯李妹芳付途
编辑: 陈冀康

图书目录:

详情

本书将帮助读者关注关于系统内核的方方面面,包括如何使用和调用各种各样的应用程序,如Apache、Bash、CP、Vim等等。本书帮助读者理解系统的核心内容,以便编写出更好的代码。本书将带领读者从理论和应用实践的观点,了解Linux系统下编程的广泛话题。本书是畅销图书的新版,内容针对Linux系统最新内核版本进行了全面更新。

图书摘要

版权信息

书名:Linux系统编程(第2版)

ISBN:978-7-115-34635-3

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

• 著    [美] Robert Love

  译    祝洪凯 李妹芳 付 途

  责任编辑 陈冀康

• 人民邮电出版社出版发行  北京市丰台区成寿寺路11号

  邮编 100164  电子邮件 315@ptpress.com.cn

  网址 http://www.ptpress.com.cn

• 读者服务热线:(010)81055410

  反盗版热线:(010)81055315


Copyright© 2013 by O’Reilly Media, Inc.

Simplified Chinese Edition, jointly published by O’Reilly Media, Inc. and Posts & Telecom Press, 2014. Authorized translation of the English edition, 2013 O’Reilly Media, Inc., the owner of all rights to publish and sell the same.

All rights reserved including the rights of reproduction in whole or in part in any form.

本书中文简体版由O’Reilly Media, Inc.授权人民邮电出版社出版。未经出版者书面许可,对本书的任何部分不得以任何方式复制或抄袭。

版权所有,侵权必究。


系统编程是指编写系统软件,其代码在底层运行,直接跟内核和核心系统库对话。

本书是一本关于Linux系统编程的教程,也是一本介绍Linux系统编程的手册,还是一本如何实现更优雅更快代码的内幕指南。全书分为11章和2个附录,详细介绍了Linux系统编程基本概念、文件I/O、缓冲I/O、高级文件I/O、进程管理、高级进程管理、线程、文件和目录管理、信号和时间等主题。附录给出了gcc和GNU C提供的很多语言扩展,以及推荐阅读的相关书目。

本书的作者是知名的Linux内核专家,多本畅销技术图书的作者。本书需要在C编程和Linux编程环境下工作的程序员阅读,对于想要巩固基础或了解内核的高级编程人员,本书也很有参考价值。


本书可以作为Linux系统编程的指南和手册,但它又不仅仅是简单的man手册。作为一名狂热的内核爱好者,Robert在书中倾注了很多自己的分析和思考。也许正因为其对Linux系统理解之深,在介绍系统编程涉及的方方面面时,才能够如此驾轻就熟,并分享很多实用的技巧。

本书对Linux文件系统、I/O、缓存、进程、内存等的描述深入到位,处处渗透着作者的理解和经验。系统编程涉及的函数之多,很容易让初学者感觉置身于一片浩渺的天地,无所是从。本书无疑是他们的福音。一本书能够化繁为简,能够非常清晰地描述相关领域功能的各函数(以及如何用好的谆谆告诫),能够非常自然有条理地把它们组织起来,读起来很顺畅,如果作者没有十足的“功力”,是做不到的。这让我想起Robert在附录B中提到“如果你写C代码不能够像说母语那样流利……”。Robert也是80后,年纪和我们相仿,其对技术的驾驭之深,让我无限敬慕。

本书可以作为枕边书,经常翻阅。在今天这个互联网时代,“不会就问Google”可能已经成为技术人员解决问题最常用的诉求。他山之石,可以攻玉。这种方式简单、便捷、高效,我也经常这么做。但从个人经验看,这种方式虽然可以快速解决问题,但其知识积累较难成体系,或者不够全面。比如,想把时间转换成本地时间,了解到localtime函数可以实现时,就轻松地实现了,却不曾料到localtime函数不是线程安全的,埋下了隐患。其实这样的例子很多。现在,有这么一本书,能够帮助我们避免许多坑,为什么不看看呢?

更可贵的是,本书第2版增加了多线程的介绍,这些内容非常实用。因为多线程是个比较难的专题,令多少程序员望而生畏。感谢Robert在这些深度领域的指引,由于篇幅所限,虽不详尽,却简单扼要,简单的描述加上示例代码说明,让人一下明白多线程是怎么回事,从此不再畏惧,更多细节自会探究。

总体而言,本书内容顺畅,我觉得不但可以作为枕边书多读几遍深度了解,也可以作为手册指南经常查阅参考。

本书在翻译过程中参考了吴晋老师指导的哈尔滨工业大学浮图开放实验室为交流、学习而翻译的本书第1版。饮水思源,这里谨向参与该第1版的所有翻译人员深表敬意和致谢。此外,也深深感谢婆婆韩学美,感谢编辑陈冀康先生以及所有其他为本书付出努力的人们。

由于译者水平有限,错漏之处在所难免,请各位读者不吝批评指正。

李妹芳

2013年11月24日


Linux内核开发人员在抱怨时,经常会抛出这么一句话:“用户空间只不过是给内核玩玩而已。”[1]

内核开发人员咕哝这句话,是想尽可能摆脱用户空间代码运行失败的责任。对他们而言,用户空间开发人员应该负责解决自己的代码bug,因为内核绝对不会有任何问题。

为了证明往往不是内核问题,一名资深的Linux内核开发人员在3年前曾在会议上分享了一个关于“为何用户空间这么让人讨厌?”的讲座,指出真实世界中很多人每天都依赖的一些非常糟糕的用户空间代码。有些内核开发人员甚至创建了一些工具,来说明用户空间代码是如何滥用硬件和白白消耗笔记本电池的。

然而,虽然内核开发人员可以不无轻蔑地认为用户空间代码只是给内核玩玩(test load),而实际上所有的内核开发人员每天也都在依赖用户空间代码。否则,他们在屏幕上只能看到内核输出ABABAB这类东西。

现在,Linux已经成为有史以来最灵活最强大的操作系统,不仅运行在最小的手机设备和嵌入设备上,而且运行在世界上超过90%的最强大的超级计算机上。和Linux相比,没有其他任何一个操作系统可以扩展得这么好,并能够解决不同的硬件类型和环境所面临的所有挑战。

有了Linux内核,在Linux的用户空间上运行的代码也可以在其他平台上运行,提供人们所依赖的真正应用和工具。

在这本书中,Robert Love真正做到“诲人不倦”,告诉读者关于Linux系统的所有系统调用。因此,他写下本书,从而可以帮助你从用户空间角度看,深入理解Linux内核是如何工作的,以及如何充分利用系统提供的各种功能。

这本书的内容有助于你编写可以运行在不同版本的Linux和不同硬件类型上的代码。通过这本书,你可以理解Linux是如何工作的,以及如何有效利用其灵活性。

最后,它还教你如何实现“不让人讨厌”的代码,这一点非常重要。

——Greg Kroah-Hartman

[1] 译注:原文是:“User space is just a test load for the kernel.”,实在觉得很难表达出“抱怨”的味道。


谨致Doris和Helen。


这本书是关于Linux上的系统编程。“系统编程”是指编写系统软件,其代码在底层运行,直接跟内核和核心系统库对话。换句话说,本书的主题是Linux系统调用和底层函数说明,如C库定义的函数。

虽然已经有很多书探讨UNIX上的系统编程,却很少有专注于探讨Linux方面的书籍,而探讨最新版本的Linux以及Linux特有的高级接口的书籍更是凤毛麟角。此外,本书还有一个优势:我为Linux贡献了很多代码,包括内核及其上面的系统软件。实际上,本书中提到的一些系统调用和系统软件就是我实现的。因此,本书涉及很多内幕资料,不仅介绍系统接口如何工作,还阐述它们实际上是如何工作,以及如何高效利用这些接口。因此,本书既是一本关于Linux系统编程的教程,也是一本介绍Linux系统调用的手册,同时还是一本如何实现更优雅、更快代码的内幕指南。这本书内容翔实,不管你是否每天都在编写系统级代码,本书给出的很多技巧都有助于你成为更优秀的软件工程师。

本书假定读者熟悉C编程和Linux编程环境——不要求很精通,但至少比较熟悉。如果你不习惯于UNIX文本编辑器——Emacs和vim(后者成为最广泛使用的编辑器,而且评价很高),那么至少应该熟悉一个。你还应该对如何使用gcc、gdb、make等工具很熟悉。已经有很多书籍介绍了关于Linux编程的工具和实践,本书最后的附录B给出一些有用的资源。

我并没有假设用户了解UNIX或Linux系统编程。本书是从零开始,从最基本的开始介绍,一直到高级接口和一些优化技巧。我希望不同层次的读者都能够从本书学到一些新东西,觉得本书有价值。在写本书过程中,我自己就感觉颇有收获。

同样,我并不想去说服或鼓励读者做什么。目标读者显然是那些希望能够(更好地)在系统上编程的工程师,但是希望奠定更坚实的基础的高级编程人员还可以找到很多其他有趣的资料。本书也适合那些只是出于好奇的黑客,它应该能够满足他们的好奇心。本书目标是希望能够满足大部分的编程人员。

不管出于什么目的,最重要的是,希望你会觉得本书很有意思。

本书共包含11章和2个附录。

第1章,入门和基本概念

本章是入门介绍,简要介绍了Linux、系统编程、内核、C库和C编译器。即使是高级用户也应该看看本章内容。

第2章,文件I/O

本章介绍文件,它是UNIX环境的最重要的抽象,介绍文件I/O,它是Linux编程模型的基础。其内容涉及读写文件以及一些其他基础的文件I/O操作。最后还探讨了Linux内核是如何实现和管理文件的。

第3章,缓冲I/O

本章探讨了基础文件I/O接口的一个方面:缓存大小管理,它从解决方案角度探讨了缓冲I/O和标准I/O。

第4章,高级文件I/O

本章阐述了高级I/O接口、存储映射和优化机制。它探讨了如何避免查找的很多技巧,并介绍了Linux内核I/O调度器。

第5章,进程管理

本章介绍了UNIX第二大重要抽象:进程,以及与基础进程管理相关的一系列系统调用,包括“久经风霜”的fork调用。

第6章,高级进程管理

本章继续探讨高级进程管理,包括实时进程。

第7章,线程

本章探讨了线程和多线程编程。它重点讨论高级设计概念,包括对POSIX线程API(即Pthreads)的介绍。

第8章,文件和目录管理

本章阐述了文件和目录的创建、移动、拷贝、删除以及其他操作。

第9章,内存管理

本章探讨内存管理。它首先介绍内存的UNIX概念,比如进程地址空间、分页,然后探讨了从内核获取内存以及把内存返还给内核的接口,最后介绍高级内存相关的接口。

第10章,信号

本章涉及信号。它首先介绍信号及其在UNIX系统中的作用,然后阐述信号接口,从最基础的接口开始探讨,一直到高级接口。

第11章,时间

本章探讨了时间、睡眠和锁管理。它从基础的接口开始介绍,一直到POSIX时钟和高精度的定时器。

附录A

附录A给出了gcc和GNU C提供的很多语言扩展,比如把函数标识为常函数、纯函数和内联函数的属性。

附录B

附录B给出了推荐阅读书目,它们不但是本书很好的补充,而且也涵盖本书没有涉及的前提背景知识。

Linux系统接口是可以定义为由Linux内核(操作系统内核)、GNU C库(glibc)和GNU C编译器(gcc,正式命名为GNU Compiler Collection,不过我们只关注与C相关的)提供的应用二进制接口和应用编程接口。本书探讨的系统接口是分别由以下版本定义的:Linux内核3.9、glibc 2.17和gcc 4.8。本书提到的接口应该可以向前兼容,即可以兼容更高版本的内核、glibc和gcc。也就是说,新版本的组件应该继续遵循本书阐述的接口和行为。此外,本书探讨的很多接口一直是Linux的一部分,因此它们对于老版本的内核、glibc和gcc也是向后兼容的。

如果把一个不断发展的操作系统比作运行的目标,那Linux就是个快速奔跑的猎豹。Linux发展是按天衡量的,而不是按年,内核和其他组件的频繁发布不断改变Linux的方方面面。没有一本书可以不断地追赶上这么快的节奏。

然而,系统编程定义的编程环境是不变的。内核开发人员尽了最大努力不要打破系统调用,glibc开发人员非常在乎向前兼容和向后兼容。Linux工具链生成了跨版本的可兼容代码。因此,虽然Linux本身可能变化很快,Linux系统编程还是很稳定,而一本基于该系统的书,尤其在Linux生命周期的这个时期,却是具有极大持久性的。我想说得很简单:不要担心系统接口的变化,可以下决心购买本书!

本书遵循以下字体体例:

斜体(Italic

表示新的术语、URL、E-mail地址、文件名和文件扩展名。

等宽字体(Constant width)

用于程序清单以及段落中的程序单元,如变量或函数名称、数据库、数据类型、环境变量、声明和关键字。

等宽粗体字(Constant width bold

显示命令或者其他由用户输入的文本。

等宽斜体字(Constant width italic

表示必须根据用户提供的值或者由上下文决定的值进行替代的文本。

该图标表示提示、建议或普通注意事项。

该图标表示告警或警告。

本书中的大部分代码片段形式简单、可重用。它们看起来如下:

为了使代码片段既看起来简洁,又是可用的,我们做了很大努力。不要特殊的头文件、各种宏定义以及不可识别的简写。我们并没有构建非常巨型的程序,而是给出很多简单的示例。由于示例必须易于描述、可用,并且简单清晰,我希望这些示例可以作为第一次阅读本书的有用教程,而在后续阅读过程中,依然可以作为参考手册。

本书中几乎所有的示例都是自包含的。这意味你可以把这些示例代码复制到自己的文本编辑器中,并利用这些代码片段。除非特别提出,所有代码片段都应该不需要任何特殊的编译器标志位就可以编译通过(在极少数情况下,需要链接到某个特定的库)。我建议通过以下命令来编译一个源文件:

通过以上命令,会把源文件snippet.c编译成可执行的二进制文件snippet,支持很多告警检查、重要明智的优化以及调试。本书提供的代码通过这种编译方式应该可以编译通过,而不会生成错误和告警信息——虽然你可能需要首先把代码补充完整。

当一节介绍新的函数时,该函数是以UNIX的man页面格式给出,看起来如下:

需要包含的头文件,以及所有定义,都是在最上方,然后是该调用的完整形式。

本书是为了帮助你完成工作。通常来说,你可以在自己的程序和文档中使用本书的代码。除非你使用了本书的大量代码,否则你无需联系我们获取许可。例如,写一个程序用到本书的几段代码不需要获得许可,销售和分发O’Reilly丛书的代码需要获得许可;引用本书的样例代码来解决一个问题不需要获取许可,使用本书的大量代码到你的产品文档中需要获得许可。

我们不要求你(引用本书时)给出出处,但是如果你这么做,我们对此表示感谢。出处通常包含标题、作者、出版社和ISBN。例如“Linux System Programming, Second Edition,by Robert Love (O’ Reilly). Copyright 2013 Robert Love, 978-1-449-33953-1.”。

如果你觉得你对本书样例代码的使用超出了这里给出的许可范围,请与我们联系:permissions@oreilly.com。

由于本书的示例代码非常多而且很短,所以没有提供在线资源。

本书初稿得到了很多人的帮助。由于无法一一列出,在这里我谨向那些一路给予帮助、鼓励、认可和支持的朋友们致以诚挚的谢意。

Andy Oram是一位非常优秀的编辑。如果没有他的努力工作,就不会有本书。Andy不但对技术了解非常深入,而且有诗人般的英语表达能力。

本书还得到一些非常资深的技术专家帮助审查,他们是自己所在领域的真正专家,如果没有他们的帮助,本书的最终版本和你现在看到的相比会失色很多。这些技术专家包括Jeremy Allison、Robert P. J. Day、Kenneth Geisshirt、Joey Shaw和James Willcox。虽然他们给了非常大的帮助,但本书还是难免有些错误。

在Google,和同事们一起工作是非常快乐的事情,他们是我遇到的最聪明、最专注的工程师。每天都是挑战,这是我们工作状态的最佳描述。感谢系统方面的项目,帮助我写下很多东西,以及有种氛围,鼓励着我完成本书。

特别感谢Paul Amici、Mikey Babbitt、Nat Friedman、Miguel de Icaza、Greg Kroah-Hartman、Doris Love、Linda Love、Tim O’Reilly、Salvatore Ribaudo及其家属、Chris Rivera、Carolyn Rodon、Joey Shaw、Sarah Stewart、Peter Teichman、Linus Torvalds、Jon Trowbridge、Jeremy VanDoren及其家属、Luis Villa、Steve Weisberg及其家属和Helen Whisnant。

最后,感谢我的父母Bob和Elaine。

——Robert Love,Boston


摆在你面前的是一本关于系统编程的书,你将在本书中学习到编写系统软件的相关技术和技巧。系统软件运行在系统的底层,与内核和系统核心库进行交互。常见的系统软件包括Shell、文本编辑器、编译器、调试器、核心工具(GNU Core Utilities)以及系统守护进程。此外,网络服务、Web服务和数据库也属于系统软件的范畴。这些程序都是基于内核和C库实现的,可以称为“纯”系统软件。相对地,其他软件(如高级GUI应用),很少和底层直接交互。有些程序员一直在编写系统软件,而有些程序员则只投入了很少一部分时间。不管怎样,深入理解系统编程都能让他受益匪浅,不管你是把系统编程作为制胜法宝,还是认为它仅仅作为高层次概念的基础,系统编程是编写所有软件的灵魂。

确切地说,这是一本关于Linux的系统编程的书。Linux是类UNIX的现代操作系统,由Linus Torvalds和全球松散的程序员社区从零开始实现。尽管Linux和UNIX有着共同的目标和理念,但Linux并不是UNIX。Linux遵循自己的原则,关注方方面面的需求,专注于实用功能的开发。总的来说,Linux系统编程的核心和任何其他UNIX系统并没有区别。然而,除了这些基本点,和传统的UNIX系统相比,Linux有其自身的特点Linux支持更多的系统调用,有不同的行为和新的特性。

从传统角度而言,所有的UNIX编程都属于系统级编程的范畴。这是由于UNIX系统并没有提供很多高级抽象,甚至是在如X Windows这样的系统上开发应用,也会涉及大量的UNIX的核心API。因此,可以说本书是通用的Linux编程指南。然而,本书并不涉及Linux编程环境——比如,书中没有任何关于如何使用make的说明。本书涵盖的是现代Linux机器上所使用的系统编程API。

系统编程和应用编程存在一些区别,但也有很多共性。系统编程最突出的特点在于要求系统程序员必须对其工作的硬件和操作系统有深入全面的了解。系统程序主要是与内核和系统库打交道,而应用程序还需要与更高层次的库进行交互,这些库把硬件和操作系统的细节抽象封装起来。这种抽象有以下几种目的:一是增强系统的可移植性,二是便于实现不同系统版本间的兼容,三是可以构建更易于使用、功能更强大或二者兼而有之的高级工具箱。对于一个应用,使用多少系统库和高级库取决于应用的运行层次。即使是开发那些基本不用系统库的应用,也能够通过了解系统编程而受益匪浅。对底层系统的深入理解和良好实践,对任何形式的编程都大有裨益。

最近10年,不管是Web开发(如JavaScript)还是托管代码(如Java),应用编程的趋势都是逐渐远离系统级编程向高级开发发展。然而,这种开发趋势并非意味着系统编程的终结。实际上,依然需要有人来开发JavaScript解释器和Java虚拟机,这本身就是系统编程。此外,Python、Ruby或Scala程序员还是可以从系统编程中受益的,因为深入了解计算机灵魂的程序员在任何层次都能够编写出更好的代码。

虽然应用编程的趋势是逐渐远离系统级编程,绝大部分的UNIX和Linux代码还是属于系统级编程范畴,其中大部分是用C和C++实现的,主要是C库和内核的接口。另外,传统的系统编程——如Apache、bash、cp、Emacs、init、gcc、gdb、glibc、ls、mv、vim和X,也都不会很快过时。

系统编程通常包含内核开发,至少包括设备驱动编程。但是,和多数系统编程的书籍一样,本书并不讨论内核开发,而是专注于用户空间的系统级编程——即内核之上的所有内容(尽管了解内核对于理解本书大有裨益)。设备驱动编程是个很宽泛博大的主题,已经有很多书籍对此做了专门而又深入的探讨。

什么是系统级应用接口?在Linux上如何编写系统级应用?内核和C库到底提供了什么?如何优化代码?Linux上编程有什么技巧?和其他的UNIX版本相比,Linux提供了哪些精巧的系统调用?这些系统调用是如何工作的?本书将对这些问题一一进行探讨。

Linux系统编程有3大基石:系统调用、C库和C编译器,每个都值得深入探讨。

系统编程始于系统调用,也终于系统调用。系统调用(通常简称为syscall)是为了从操作系统请求一些服务或资源,是从用户空间如文本编辑器、游戏等向内核(系统的核心)发起的函数调用。系统调用范围很广,从大家都熟悉的如read()和write(),到罕见的如get_thread_area()和set_tid_address()都在其范畴之内。

Linux实现的系统调用远远少于其他内核。举例来说,微软的Windows,其系统调用号称有几千个,而Linux x86-64体系结构的系统调用大概只有300个。在Linux内核中,每种体系结构(Alpha、x86-64或PowerPC)各自实现了标准系统调用。因此,不同体系结构支持的系统调用可能存在区别。然而,超过90%的系统调用在所有的体系结构上都实现了。本书所探讨的正是这部分共有的内容。

调用系统调用

位于用户空间的应用程序无法直接访问内核空间。从安全和可靠性角度考虑,也需要禁止用户空间的应用程序直接执行内核代码或操纵内核数据。但从另外一个角度看,内核也必须提供这样一种机制,当用户空间的应用希望执行系统调用时,可以通过该机制通知内核。有了这种机制,应用程序就可以“深入”内核,执行内核允许的代码。这种机制在不同的体系结构上又各不相同。举个例子,在i386微处理器上,用户空间的应用需要执行参数值为0x80的软件中断指令int。该指令会把当前运行环境从用户空间切换成内核空间,即内核的保护区域,内核在该区域执行中断处理函数——中断0x80的处理函数是什么呢?只能是系统调用处理函数!

应用程序通过寄存器告诉内核调用哪个系统调用以及传递什么参数。系统调用以数值表示,从0开始。举个例子,在i386微处理器体系结构上,要请求系统调用5(即open()),用户空间在发送int指令前,需要把5写到寄存器eax中。

参数传递也以类似的方式处理。还是以i386为例,为每个可能的参数指定一个寄存器——寄存器ebx、ecx、edx、esi和edi顺序存储前5个参数。对于极少数参数超过5个的系统调用,则使用单个寄存器指向保存所有参数的用户空间缓存。当然,大部分系统调用只包含几个参数。

虽然基本思想是一致的,但不同体系结构处理系统调用的方式不同。作为一名系统程序员,通常不需要了解内核是如何处理系统调用的。系统调用已经集成到各种体系结构的标准调用规范中,并通过编译器和C库自动处理。

C库(libc)是UNIX应用程序的核心。即使你是使用其他语言编程,通常还是会通过高级语言封装的C库来提供核心服务,以方便系统调用。在现代Linux系统中,C库由GNU libc提供,简称glibc,发音是[gee-lib-see],或者有时发作[glib-see]。

GNU C库的功能远远超出了其名字的范畴。glibc中,除了标准C库,还提供了系统调用封装、线程支持和基本应用工具。

在Linux中,标准C编译器是由GNU编译器工具集(GNU Compiler Collection,gcc)提供的。最初,gcc是GNU版的C编译器cc,因此,gcc表示GNU C编译器(GNU C Compiler)。随着时间推移,gcc支持越来越多的语言。时至今日,gcc已经成了GNU编译器家族的代名词。此外,gcc还表示C编译器二进制程序。除非特别指明,本书中提到gcc时,都是指gcc应用程序。

因为编译器辅助实现了标准C(参阅1.3.2小节)和系统ABI(参阅1.2.1小节和1.2.2小节),在UNIX系统(包括Linux)中所使用的编译器和系统编程紧密相关。

C++

本章把C语言作为系统编程的通用语言,但是C++语言也功不可没。

今天,C++在系统编程中的地位仅次于C语言。由于历史原因,比起C++,Linux开发人员更倾向于使用C语言:核心库、守护进程、工具箱以及Linux内核都是用C语言实现的。在非Linux环境中,C++语言作为“C语言的升级”,其优势是显而易见的,但是在Linux环境中,C++的地位还是逊于C。

尽管如此,本书给出的大部分相关的C语言代码都可以替换成C++。C++确实可以作为C语言的替代,适合任何系统编程工作:C++代码可以链接C代码,调用Linux系统调用,还可以充分利用glibc。

比起C,C++还为系统编程奠定了另外两块基石:标准的C++库和GNU C++编译器。标准的C++库实现了C++系统接口以及ISO C++11标准,由libstdc++库提供(有时写作libstdcxx)。GNU C++编译器是Linux系统为C++提供的标准编译器,由二进制程序g++提供。

程序员都希望自己实现的程序能够一直运行在其声明支持的所有系统上。他们希望能在自己的Linux版本上运行的程序也能够运行于其他Linux版本,同时还可以运行在其他支持Linux体系结构的更新(或更老)的Linux版本上。

在系统层,有两组独立的影响可移植性的定义和描述。一是应用程序编程接口(Application Programming Interface,API),二是应用程序二进制接口(Application Binary Interface,ABI),它们都是用来定义和描述计算机软件的不同模块间的接口的。

API定义了软件模块之间在源代码层交互的接口。它提供一组标准的接口(通常以函数的方式)实现了如下抽象:一个软件模块(通常是较高层的代码)如何调用另一个软件模块(通常位于较低层)。举个例子,API可以通过一组绘制文本函数,对在屏幕上绘制文本的概念进行抽象。API仅仅是定义接口,真正提供API的软件模块称为API的实现。

通常,人们把API称为“约定”,这并不合理,至少从API这个术语角度来讲,它并非一个双向协议。API用户(通常是高级软件)并没有对API及其实现提供任何贡献。API用户可以使用API,也可以完全不用它:用或不用,仅此而已!API的职能只是保证如果两个软件模块都遵循API,那么它们是“源码兼容”(source compatible),也就是说,不管API如何实现,API用户都能够成功编译。

API的一个实际例子就是由C标准定义的接口,通过标准C库实现。该API定义了一组基础函数,比如内存管理和字符串处理函数。

在本书中,我们会经常提到各种API,比如第3章将要讨论的标准I/O库。1.3节给出了Linux系统编程中最重要的API。

API定义了源码接口,而ABI定义了两个软件模块在特定体系结构上的二进制接口。它定义了应用内部如何交互,应用如何与内核交互,以及如何和库交互。API保证了源码兼容,而ABI保证了“二进制兼容(binary compatibility)”,确保对于同一个ABI,目标代码可以在任何系统上正常工作,而不需要重新编译。

ABI主要关注调用约定、字节序、寄存器使用、系统调用、链接、库的行为以及二进制目标格式。例如,调用约定定义了函数如何调用,参数如何传递,分别保留和使用哪些寄存器,调用方如何获取返回值。

尽管曾经在不同操作系统上为特定的体系结构定义一套唯一的ABI,做了很多努力,但是收效甚微。相反地,操作系统(包括Linux)往往会各自定义自己独立的ABI,这些ABI和体系结构紧密关联,绝大部分ABI表示了机器级概念,比如特定的寄存器或汇编指令。因此,在Linux,每个计算机体系结构都定义了自己的ABI。实际上,我们往往通过机器体系结构名称来称呼这些ABI,如Alpha或x86-64。因此,ABI是操作系统(如Linux)和体系结构(如x86-64)共同提供的功能。

系统编程需要有ABI意识,但通常没有必要记住它。ABI并没有提供显式接口,而是通过工具链(toolchain),如编译器、链接器等来实现。尽管如此,了解ABI可以帮助你写出更优化的代码,而如果你的工作就是编写汇编代码或开发工具链(也属于系统编程范畴),了解ABI就是必需的。

ABI是由内核和工具链定义和实现的。

UNIX系统编程是门古老的艺术。UNIX编程的基础理念在几十年来一直根深蒂固。但是,对于UNIX系统,变化却是无处不在。各种行为不断变化,特性不断增加。为了使UNIX世界变得有序,标准化组织为系统接口定义了很多套官方标准。虽然存在很多这样的官方标准,但是Linux没有遵循任何一个标准。相反地,Linux致力于和两大主流标准兼容:POSIX和单一UNIX规范(Single UNIX Specification,SUS)。

除了其他内容,POSIX和SUS为类UNIX操作系统定义了一套C API。该C API为兼容的UNIX系统定义了系统编程接口,至少从中抽取出了通用的API集。

在20世纪80年代中期,电气电子工程师协会(IEEE)开启了UNIX系统上的系统级接口的标准化工作。自由软件运动(Free Software Movement)的创始人Richard Stallman建议把该标准命名成POSIX(发音[pahz-icks]),其全称是Portable Operating System Interface(可移植操作系统接口)。

该工作的第一成果是在1988年获得通过的IEEE std 1003.1-1988(简称POSIX 1988)。1990年,IEEE对 POSIX标准进行了修订,通过了IEEE std 1003.1-1990(POSIX 1990)。后续的修订IEEE Std 1003.1b-1993(POSIX 1993或称POSIX.1b)和IEEE Std 1003.1c-1995(POSIX 1995或称POSIX.1c)分别描述了非强制性的实时和线程支持。2001年,这些非强制性标准在POSIX 1990的基础上进行整合,形成单一标准IEEE Std 1003.1-2001(POSIX 2001)。最新的标准IEEE Std 1003.1-2008 (POSIX 2008)在2008年12月发布。所有的核心POSIX标准都简称为POSIX.1,其中2008年的版本为最新版。

从20世纪80年代后期到20世纪90年代初期,UNIX系统厂商卷入了一场“UNIX之战”中,每家厂商都处心积虑地想将自己的UNIX变体定义成真正的“UNIX”操作系统。几大主要的UNIX厂商聚集在了工业联盟The Open Group周围,The Open Group是由开放软件基金会(Open Software Foundation,OSF)和X/Open合并组成。The Open Group提供证书、白皮书和兼容测试。在20世纪90年代初,正值UNIX之战如火如荼,The Open Group发布了单一UNIX规范(SUS)。SUS广受欢迎,很大原因归于SUS是免费的,而POSIX标准成本很高。今天,SUS合并了最新的POSIX标准。

第一个版本的SUS发布于1994年,然后在1997年和2002年分别发布了两个修订版SUSv2和SUSv3。最新的SUSv4在2008年发布。SUSv4修订结合了IEEE Std 1003.1-2008标准以及一些其他标准。本书将以POSIX标准介绍系统调用和其他接口,原因是SUS是对POSIX的扩展。

Dennis Ritchie和Brian Kernighan的经典著作《C程序设计语言》(Prentice Hall)自1978年首次出版后,一直扮演着非正式的C语言规范的角色。这个版本的C语言俗称K&R C。C语言很快替代了Basic语言和其他语言,成为微型计算机编程的通用语言。因此,为了对当时已经非常流行的C语言进行标准化,美国国家标准协会(ANSI)成立了委员会制定C语言的官方版本。该版本集成了各个厂商的特性和改进,并借鉴了新兴的C++语言的一些经验。这个标准化过程漫长而又艰辛,但是ANSI C在1989年最终顺利完成。1990年,国际标准化组织(ISO)基于ANSI C做了一些有效修改,批准了ISO C90。

1995年,ISO发布了新版的C语言标准ISO C95,虽然该标准很少被执行。在1999年,对C语言做了很多修订,形成了ISO C99标准,它引入了很多新的特征,包括inline函数、新的数据类型、变长数组、C++风格的注释以及新的库函数。该标准的最新版本是ISO C11,该版本最重要的功能是格式化的内存模型,支持跨平台的线程可移植性。

对于C++,ISO标准化进展却非常缓慢。经过几年的发展以及非向前兼容的编译器的发布,通过了第一代C++标准ISO C98。虽然该标准极大地提高了编译器之间的兼容性,但在某些方面限制了一致性和可移植性。2003年通过了ISO C++03标准。它修复了编译器开发人员遇到的一些bug,但是没有用户可见的变化。下一个是目前最新的ISO标准C++11(之前的版本都是C++0x,C++11意味着该版本发布更令人期待),有更多的语言和标准的库附加组件及改进——由于修改非常多,很多人建议C++11作为一门不同的语言,和之前的C++版本区别开。

正如前面所述,Linux旨在达到兼容POSIX和SUS。SUSv4和POSIX 2008描述了Linux提供的接口,包括支持实时(POSIX.1b)和线程(POSIX.1c)。更重要的是,Linux努力与POSIX与SUS需求兼容。一般来说,如果和标准不一致,就认为是个bug。人们认为Linux与POSIX.1和SUSv3兼容,但是由于没有经过POSIX或SUS的官方认证(尤其是Linux的每次修订),所以无法官方宣布Linux兼容POSIX或SUS。

关于语言标准,Linux很幸运。gcc C编译器兼容ISO C99,而且正在努力支持C11。g++ C++编译器兼容ISO C++03,正在努力支持C++11。此外,gcc和g++_实现了C语言和C++语言的扩展。这些扩展统称为GNU C,在附录A中有相关描述。

Linux的前向兼容做得不是很好[1],虽然近期这方面已经好多了。接口是通过标准说明的,如标准的C库,总是可以保持源码兼容。不同版本之间的二进制代码兼容是由glibc来保证的。由于C语言是标准化的,gcc总是能够准确编译合法的C程序,尽管gcc相关的扩展可能会废弃掉甚至从新的gcc发布版本中删除。最重要的是,Linux内核保证了系统调用的稳定性。一旦系统调用是在Linux内核的稳定版本上实现的,它就不会改变了。

在各种Linux发布版中,Linux标准规范(Linux Standard Base,LSB)对大部分的Linux系统进行了标准化。LSB是几大Linux厂商在Linux基金会(前身是自由标准组织)推动下的联合项目。LSB扩展了POSIX和SUS,添加了一些自己的标准;它尝试提供二进制标准,支持目标代码在兼容系统上无需修改即可运行。大多数Linux厂商都在一定程度上遵循了LSB标准。

本书有意避免对任何标准的介绍“夸夸其谈”。大多数情况下,UNIX系统编程相关的书籍都不应该浪费篇幅探讨以下内容:如某个接口在不同标准下行为有何不同,特定的系统调用在各个系统上的实现情况,以及类似的口舌之战。本书仅涉及在现代Linux系统上的系统编程,它是通过最新版本的Linux内核(3.9)、gcc编译器(4.8)和C库(2.17)来实现的。

因为系统接口通常是固定不变的(Linux内核开发人员尽力避免破坏系统调用接口),并且支持一定程度的源码和二进制兼容性。因此,我们可以深入探索Linux系统接口的细节,不必关心与各种其他的UNIX系统和标准的兼容性问题。专注于探讨Linux也使得本书能够深入探讨Linux最前沿的,并且在未来很长时间依然举足轻重的接口。本书阐述了Linux的相关知识,一些组件如gcc和内核的实现和行为,从专业角度洞察Linux的最佳实践和优化技巧。

本节给出了Linux系统提供的服务的简要概述。所有的UNIX系统,包括Linux,提供了共同的抽象和接口集合。实际上,UNIX本身就是由这些共性定义的,比如对文件和进程的抽象、管道和socket的管理接口等等,都构成了UNIX系统的核心。

本概述假定你对Linux环境很熟悉:会使用shell的基础命令、能够编译简单的C程序。它不是关于Linux或其编程环境的,而是关于Linux系统编程的基础。

文件是Linux系统中最基础最重要的抽象。Linux遵循一切皆文件的理念(虽然没有某些其他系统如Plan 9那么严格)[2]。因此,很多交互操作是通过读写文件来完成,即使所涉及的对象看起来并非普通文件。

文件必须先打开才能访问。文件打开方式有只读、只写和读写模式。文件打开后是通过唯一描述符来引用,该描述符是从打开文件关联的元数据到文件本身的映射。在Linux内核中,文件用一个整数表示(C语言的int类型),称为文件描述符(file descriptor,简称fd)。文件描述符在用户空间共享,用户程序通过文件描述符可以直接访问文件。Linux系统编程的大部分工作都会涉及打开、操纵、关闭以及其他文件描述符操作。

普通文件

我们经常提及的“文件”即Linux中的普通文件(regular files)。普通文件包含以字节流(即线性数组)组织的数据。在Linux中,文件没有高级组织结构或格式。文件中包含的字节可以是任意值,可以以任意方式进行组织。在系统层,除了字节流,Linux对文件结构没有特定要求。有些操作系统,如VMS,提供高度结构化的文件,支持如records(记录)这样的概念,而Linux没有这么处理。

在Linux中,可以从文件中的任意字节开始读写。对文件的操作是从某个字节开始,即文件“地址”。该地址称为文件位置(file location)或文件偏移(file offset)。文件位置是内核中与每个打开的文件关联的元数据中很重要的一项。第一次打开文件时,其偏移为0。通常,随着按字节对文件的读写,文件偏移也随之增加。文件偏移还可以手工设置成给定值,该值甚至可以超出文件结尾。在文件结尾后面追加一个字节会使得中间字节都被填充为0。虽然支持通过这种在文件末尾追加字节的操作,但是不允许在文件的起始位置之前写入字节。这种操作看起来就很荒谬,实际上也并无用处。文件位置的起始值为0,不能是负数。在文件中间位置写入字节会覆盖该位置原来的数据。因此,在中间写入数据并不会导致原始数据向后偏移。绝大多数文件写操作都是发生在文件结尾。文件位置的最大值只取决于存储该值的C语言类型的大小,在现代Linux操作系统上,该值是64位。

文件的大小是通过字节来计算,称为文件长度。换句话说,文件长度即组成文件的线性数组的字节数。文件长度可以通过truncation(截断)操作进行改变。比起原始文件大小,文件被截断后的大小可以更小,这相当于删除文件末尾字节。容易让人困惑的是,从truncation操作的名称而言,文件被截断后的大小可以大于原始文件大小。在这种情况下,新增的字节(附加到文件末尾)是以“0”来填充。文件可以为空(即长度为0),不含任何可用字节。如同文件位置的最大值,文件长度的最大值只受限于Linux内核用于管理文件的C语言类型的大小。但是,不同的文件系统也可能规定自己的文件长度最大值,即为文件长度限制设置更小值。

同一个文件可以由多个进程或同一个进程多次打开。系统会为每个打开的文件实例提供唯一文件描述符。因此,进程可以共享文件描述符,支持多个进程使用同一个文件描述符。Linux内核没有限制文件的并发访问。不同的进程可以同时读写同一个文件。对文件并发访问的结果取决于这些操作的顺序,通常是不可预测的。用户空间的程序往往需要自己协调,确保对文件的同步访问是合理的。

文件虽然是通过文件名访问,但文件本身其实并没有直接和文件名关联。相反地,与文件关联的是索引节点inode(最初称为信息节点 ,是information node的缩写),inode是文件系统为该文件分配的唯一整数值(但是在整个系统中不一定是唯一的)。该整数值称为inode number,通常简称为i-number或ino。索引节点中会保存和文件相关的元数据,如文件修改时间戳、所有者、类型、长度以及文件数据的位置——但不含文件名!索引节点就是UNIX文件在磁盘上的实际物理对象,也是在Linux内核中通过数据结构表示的概念实体。

目录和链接

通过索引节点编号访问文件很繁琐(而且潜在安全漏洞),因此文件通常是通过文件名(而不是索引节点号)从用户空间打开。目录用于提供访问文件需要的名称。目录是可读名称到索引编号之间的映射。名称和索引节点之间的配对称为链接(link)。映射在物理磁盘上的形式,如简单的表或散列,是通过特定文件系统的内核代码来实现和管理的。从概念上看,可以把目录看作普通文件,其区别在于它包含文件名称到索引节点的映射。内核直接通过该映射把文件名解析为索引节点。

如果用户空间的应用请求打开指定文件,内核会打开包含该文件名的目录,搜索该文件。内核根据文件名获取索引节点编号。通过索引节点编号可以找到该节点。索引节点包含和文件关联的元数据,其中包括文件数据在磁盘上的存储位置。

刚开始,磁盘上只有一个目录,称为根目录,以路径/表示。然而,系统上通常有很多目录,内核怎么知道到哪个目录查找指定文件呢?

如前所述,目录和普通文件相似。实际上,它们有关联的索引节点。因此,目录内的链接可以指向其他目录的索引节点。这表示目录可以嵌套到其他目录中,形成目录层。这样,就可以支持使用UNIX用户都熟悉的路径名来查找文件,如/home/blackbeard/landscaping.txt。

当内核打开类似的路径名时,它会遍历路径中的每个目录项(directory entry,在内核中称为dentry),查找下一个入口项的索引节点。在前面的例子中,内核起始项是/,先获取home的索引节点,然后获取blackbeard的索引节点,最后获取concorde.png的索引节点。该操作称为目录解析或路径解析。Linux内核也采用缓存(称为dentry cache)储存目录的解析结果,基于时间局部性原理,可以为后续访问更快地提供查询结果。

从根目录开始的路径称为完整路径,也叫绝对路径。有些路径不是绝对路径,而是相对路径(如todo/plunder)。当提供相对路径时,内核会在当前工作目录下开始路径解析。内核在当前工作目录中查找todo目录。在这里,内核获取索引节点plunder。相对路径和当前工作目录的组合得到绝对路径。

虽然目录是作为普通文件存储的,但内核不支持像普通文件那样打开和操作目录。相反地,目录必须通过特殊的系统调用来操作。这些系统调用只支持两类操作:添加链接和删除链接。如果支持用户空间绕过内核操作目录,有可能出现一个简单的错误就会造成文件系统崩溃的巨大悲剧。

硬链接

从概念上看,以上介绍的内容都无法避免多个名字解析到同一个索引节点上。而事实上,多个名字确实可以解析到同一个索引节点。当不同名称的多个链接映射到同一个索引节点时,我们称该链接为硬链接(hard links)。

在复杂的文件系统结构中,硬链接支持多个路径指向同一份数据。硬链接可以在同一个目录下,也可以在不同的目录中。不管哪一种情况,内核都可以把路径名解析到正确的索引节点。举个例子,某个指向特定数据块的索引节点,其硬链接可以是/home/bluebeard/treasure.txt 和/home/blackbeard/to_steal.txt。

要从目录中删除文件,需要从目录结构中取消链接(unlink)该文件,这只需要从目录中删除该文件名和索引节点就可以。然而,由于Linux支持硬链接,文件系统不能对每个unlink操作执行删除索引节点及其关联数据的操作。否则,如果该索引节点在文件系统中还有其他的硬链接怎么办?为了确保在删除所有的链接之前不会删除文件,每个索引节点包含链接计数(link count),记录该索引节点在文件系统中的链接数。当unlink某个路径时,其链接计数会减1;只有当链接计数为0时,索引节点及其关联的数据才会从文件系统中真正删除。

符号链接

硬链接不能跨越多个文件系统,因为索引节点编号在自己的文件系统之外没有任何意义。为了跨越文件系统建立链接,UNIX系统实现了符号链接(symbolic links,简称symlinks)。

符号链接类似于普通文件,每个符号链接有自己的索引节点和数据块,包含要链接的文件的绝对路径。这意味着符号链接可以指向任何地方,包括不同的文件系统上的文件和路径,甚至指向不存在的文件和目录。指向不存在的文件的符号链接称为坏链接(broken link)。

比起硬链接,符号链接会带来更多的开销,因为有效解析符号链接需要解析两个文件:一是符号链接本身,二是该链接所指向的文件。硬链接不会带来这些额外开销——因为访问在文件系统中被多次链接的文件和单次链接的文件没有区别。虽然符号链接的开销很小,但还是被认为是个负面因素。

符号链接没有硬链接那么“透明”。使用硬链接是完全透明的——所需要做的仅仅是确定文件是否被多次链接!但是,操作符号链接需要特定的系统调用。由于符号链接的结构很简单,它通常是作为文件访问的快捷方式,而不是作为文件系统内部链接,因此这种缺乏透明性通常被认为是个正面因素。

特殊文件

特殊文件(special file)是指以文件来表示的内核对象。这些年来,UNIX系统支持了不少不同的特殊文件。Linux只支持四种特殊文件:块设备文件、字符设备文件、命名管道以及UNIX域套接字。特殊文件是使得某些抽象可以适用于文件系统,贯彻一切皆文件的理念。Linux提供了系统调用来创建特殊文件。

在UNIX系统中,访问设备是通过设备文件来实现,把设备当作文件系统中的普通文件。设备文件支持打开、读和写操作,允许用户空间程序访问和控制系统上的(物理和虚拟)设备。UNIX设备通常可以划分成两组:字符设备(character devices)和块设备(block device)。每种设备都有自己的特殊文件。

字符设备是作为线性字节队列来访问。设备驱动程序把字节按顺序写入队列,用户空间程序按照写入队列的顺序读取数据。键盘就是典型的字符设备。举个例子,当用户输入“peg”,应用程序将顺序从键盘设备中读取p、e和g。如果没有更多的字符读取时,设备会返回end-of-file(EOF)。漏读数据或以其他顺序读取都是不可能的。字符设备通过字符设备文件(character device file)进行访问。

和字符设备不同,块设备是作为字节数组来访问。设备驱动把字节映射到可寻址的设备上,用户空间可以按任意顺序随意访问数组中的任何字节——可能读取字节12,然后读取字节7,然后又读取字节12。块设备通常是存储设备。硬盘、软盘、CD-ROM驱动和闪存都是典型的块设备。这些块设备通过块设备文件(block device file)来访问。

命名管道(named pipes),通常称为FIFO(是“先进先出first in, first out”的简称),是以文件描述符作为通信信道的进程间通信(IPC)机制,它可以通过特殊文件来访问。普通管道是将一个程序的输出以“管道”形式作为另一个程序的输入,普通管道是通过系统调用在内存中创建的,并不存在于任何文件系统中。命名管道和普通管道一样,但是它是通过FIFO特殊文件来访问的。不相关的进程可以访问该文件并进行交互。

套接字(socket)是最后一种特殊文件。socket是进程间通信的高级形式,支持不同进程间的通信,这两个进程可以在同一台机器,也可以在不同机器。实际上,socket是网络和互联网编程的基础。socket演化出很多不同的变体,包括UNIX域套接字,它是本地机器进行交互的socket格式。虽然socket在互联网上的通信会使用主机名和端口号来标识通信目标,UNIX域套接字使用文件系统上的特殊文件进行交互,该文件称为socket文件。

文件系统和命名空间

如同所有的UNIX系统,Linux提供了全局统一的文件和目录命名空间。有些操作系统会把不同的磁盘和驱动划分成独立的命名空间——比如,通过路径A:\plank.jpg可以访问软盘上的文件,虽然硬盘驱动安装在C:\目录下。在UNIX,该软盘上的文件可以在其他介质上,通过路径/media/floppy/plank.jpg访问,甚至可以通过/home/captain/stuff/plank.jpg访问。也就是说,在UNIX系统中,命名空间是统一的。

文件系统是以合理有效的层次结构组织的文件和目录的集合。在文件和目录的全局命名空间中,可以分别添加和删除文件系统,这些操作称为挂载(mounting)和卸载(unmounting)。每个文件系统都需要挂载到命名空间的特定位置,该位置即挂载点(mount point)。在挂载点可以访问文件系统的根目录。举个例子,把CD挂载到/media/cdrom,CD上文件系统的根目录就可以通过/media/cdrom访问。第一个被挂载的文件系统是在命名空间的根目录/下,称为根文件系统(root filesystem)。Linux系统必定有个根文件系统,而其他文件系统的挂载点则是可选的。

通常而言,文件系统都是存在物理介质上(即保存在磁盘上),不过Linux还支持只保存在内存上的虚拟文件系统,以及存在于网络中的其他机器上的网络文件系统。物理文件系统保存在块存储设备中,如CD、软盘、闪存或硬盘中。在这些设备中,有些是可以分区的,表示可以切分成可独立操作的多个文件系统。Linux支持的文件系统类型很宽泛,囊括所有一般用户有可能遇到的——包括媒体文件系统(如ISO9660)、网络文件系统(NFS)、本地文件系统(ext4)、其他UNIX系统的文件系统(XFS)以及非UNIX系统的文件系统(FAT)。

块设备的最小寻址单元称为扇区(sector)。扇区是设备的物理属性。扇区大小一般是2的指数倍,通常是512字节。块设备无法访问比扇区更小的数据单元,所有的I/O操作都发生在一个或多个扇区上。

文件系统中的最小逻辑寻址单元是块(block)。块是文件系统的抽象,而不是物理介质的抽象。块大小一般是2的指数倍乘以扇区大小。在Linux,块通常比扇区大,但是必须小于页(page),页是内存的最小寻址单元(内存管理单元是个硬件)[3]。常见的块大小是512B、1KB和4KB。

从历史角度看,UNIX系统只有一个共享的命名空间,对系统上所有的用户和进程都可见。Linux独辟蹊径,支持进程间独立的命名空间,允许每个进程都可以持有系统文件和目录层次的唯一视图[4]。默认情况下,每个进程都继承父进程的命名空间,但是进程也可以选择创建自己的命名空间,包含通过自己的挂载点集和独立的根目录。

如果说文件是UNIX系统最重要的抽象概念,进程则仅次于文件。进程是执行时的目标代码:活动的、正在运行的程序。但是进程不仅包含目标代码,它还包含数据、资源、状态和虚拟计算机。

进程的生命周期是从可执行目标代码开始,这些机器可运行的代码是以内核能够理解的形式存在,在Linux下,最常见的格式称为“可执行和可链接的格式(Executable and Linkable Format,ELF)”。可执行性格式包含元数据、多个代码段和数据段。代码段是线性目标代码块,可以加载到线性内存块中。数据段中的所有数据都一视同仁,有相同的权限,通常也用于相同的目的。

最重要和通用的段是文本段、数据段和bss段。文本段包含可执行代码和只读数据如常量,通常标记为只读和可执行。数据段包含初始化的数据,如包含给定值的C变量,通常标记为可读写。bss段包含未初始化的全局数据。因为C标准规定了C变量的默认值全部为0,因此没有必要在磁盘上把0保存到目标代码中。相反地,根据目标代码可以很容易地列举出bss段中未初始化的变量,内核在加载到内存时可以映射bss段中的全0页面(页面中全部都是0),bss段的设计完全是出于性能优化。bss这个取名存在历史遗留原因,是block started by symbol的简称。ELF可执行性程序的其他通用段都是绝对地址段(包含不可再定位的符号)和未定义地址段(包罗万象)。

进程还和系统资源关联,系统资源是由内核决定和管理的。一般来说,进程只通过系统调用请求和管理资源。资源包括计时器、挂起的信号量、打开的文件、网络连接、硬件和IPC机制。进程资源以及该进程相关的数据和统计保存在内核中该进程的进程描述符中。

进程是一种虚拟抽象。进程内核同时支持抢占式多任务和虚拟内存,为每个进程提供虚拟处理器和虚拟内存视图。从进程角度看,系统看起来好像完全由进程控制。也就是说,虽然某个进程可以和其他进程一起调度,该进程在运行时看起来似乎独立控制整个系统。系统内核会无缝、透明地抢占和重新调度进程,所有进程共享系统处理器,而进程感不到其中的区别。同样,每个进程都获得独立的线性地址空间,好像它独立控制整个系统内存。通过虚拟内存和分页,内核支持多个进程共享系统,每个进程的操作都运行在独立的地址空间中。内核通过现代处理器的硬件支持来管理这种虚拟化方式,支持操作系统并发管理多个独立的进程的状态。

线程

每个进程包含一个或多个执行线程(通常简称线程threads)。线程是进程内的活动单元,换句话说,线程是负责执行代码和管理进程运行状态的抽象。

绝大多数进程只包含一个线程,这些进程被称为单线程;包含多个线程的进程称为多线程。从传统上讲,由于UNIX系统一直很简洁,进程创建很快并拥有健壮的IPC机制,这些都减少了对线程的需求。因此,UNIX进程绝大部分是单线程的。

线程包括栈(正如非线程系统的进程栈一样,用于存储局部变量)、处理器状态、目标代码的当前位置(通常是保存在处理器的指令指针中)。进程的其他部分由所有线程共享,最主要是进程地址空间。在这种情况下,线程在维护虚拟进程抽象时,也共享虚拟内存抽象。

在Linux系统内部,Linux内核实现了独特的线程模型:它们其实是共享某些资源的普通进程。在用户空间,Linux依据POSIX 1003.1c实现线程模型(称为Pthreads)。目前Linux线程实现称为POSIX Threading Library(NPTL),它是glibc的一部分。我们将在第7章对线程进行更多的讨论。

进程层次结构

每个进程都由唯一的正整数标识,称为进程ID(pid)。第一个进程的pid是1,后续的每个进程都有一个新的、唯一的pid。

在Linux中,进程有严格的层次结构,即进程树。进程树的根是第一个进程,称为init进程,通常是init程序。新的进程是通过系统调用fork()创建的。fork()会创建调用进程的副本。原进程称为父进程,fork()创建的新进程称为子进程。除了第一个进程外,每个进程都有父进程。如果父进程先于子进程终止,内核会将init进程指定为它的父进程。

当进程终止时,并不会立即从系统中删除。相反地,内核将在内存中保存该进程的部分内容,允许父进程查询其状态,这被称为等待终止进程。一旦父进程确定某个子进程已经终止,该子进程就会完全被删除。如果一个进程已经终止,但是父进程不知道其状态,该进程称为僵尸进程(zombie)。init进程会等待所有的子进程结束,确保子进程永远不会处于僵死状态。

Linux中通过用户和组进行权限认证,每个用户和一个唯一的正整数关联,该整数称为用户ID(uid)。相应地,每个进程和一个uid关联,用来识别运行这个进程的用户,称为进程的真实uid(real uid)。在Linux内核中,uid是用户的唯一标识。但是,用户一般通过用户名而不是id来表示。用户名及其对应的uid保存在/etc/passwd中,而系统库会把用户名映射到对应的uid上。

在登录过程中,用户向login程序提供用户名和密码。如果提供的用户名和密码都正确,login程序会根据/etc/passwd为用户生成login shell,并把用户id作为该shell进程的uid。子进程继承父进程的uid。

超级用户root的uid是0。root用户有特殊的权限,几乎可以执行所有的操作。举个例子,只有root用户可以修改进程的uid。因此,login进程是以root身份运行的。

除了真实uid以外,每个进程还包含有效的uid(effective uid),保留uid(saved uid)和文件系统uid(filesystem uid)。真实uid总是启动进程的用户uid,有效的uid在不同情况下会发生变化,从而支持进程切换成其他用户权限来执行。保留uid保存原来的有效uid,其值决定了用户将切换成哪个有效uid。文件系统uid通常和有效uid等效,用于检测文件系统的访问权限。

每个用户属于一个或多个组,包括在/etc/passwd中给出的基础组(primary group)或登录组(login group),也可能是/etc/group中给出的很多其他附加组(supplemental group)。因此,每个进程和相应的组ID(gid)关联,也包括真实gid、有效gid、保留gid、文件系统gid。进程通常是和用户的登录组关联,而不是和附加组关联。

一些安全机制只允许进程在满足特定标准时才执行某些操作。对于这一点,UNIX的安全机制非常简单:uid为0的进程可以访问,而其他进程不能访问。最近,Linux采用更通用的安全系统来取代UNIX这种安全机制。通过安全系统,不是做简单的二元判断,而是允许内核执行更细粒度的访问控制。

Linux的标准文件权限和安全机制与UNIX的一致。

每个文件都有文件所有者、所属组以及三个权限位集合。每个权限位描述了所有者、所属组以及其他人对文件的读、写和执行的权限。这三类每个对应3个位,共9位。文件所有者和权限信息保存在文件的索引节点中。

对于普通文件,权限非常清晰:三位分别表示读、写和执行权限。特殊文件的读写权限和普通文件的一样,虽然特殊文件的读写内容由特殊文件自己确定。特殊文件忽略执行权限。对于目录,读权限表示允许列出目录的内容,写权限表示允许在目录中添加新的链接,执行权限表示允许在路径中输入和使用该目录。表1-1列出了9个权限位、其八进制值(常见的表示9位的方式)、文本值(如ls显示结果),以及对应的含义。

表1-1 权限位及其值

八进制值

文 本 值

对应的权限

 8 

400

r-------- 

所有者可读

 7 

200

-w-------

所有者可写

 6 

100

--x------ 

所有者可执行

 5 

040

---r----- 

组用户可读

 4 

020

----w----

组用户可写

 3 

010

-----x--- 

组用户可执行

 2 

004

------r-- 

所有用户可读

 1 

002

-------w-

所有用户可写

 0 

001

--------x 

所有用户可执行

除了UNIX权限外,Linux还支持访问控制表(ACL)。ACL支持更详细更精确的权限和安全控制方式,其代价是复杂度变大以及更大的磁盘存储开销。

信号是一种单向异步通知机制。信号可能是从内核发送到进程,也可能是从进程到进程,或者进程发送给自己。信号一般用于通知进程发生了某些事件,如段错误或用户按下Ctrl-C。

Linux内核实现了约30种信号(准确数值和每个体系结构有关)。每个信号是由一个数值常量和文本名表示。举个例子,SIGHUP用于表示终端挂起,在x86-64体系结构上值为1。

信号会“干扰”正在执行的进程,不管当前进程正在做什么,都会立即执行预定义的操作。除了SIGKILL(进程中断)和SIGSTOP(进程停止),当进程接收到信号时,可以控制正在执行的操作。进程可以接受默认的信号处理操作,可能是中断进程、中断并coredump进程、停止进程或者什么都不做,具体的操作取决于信号值。此外,进程还可以选择显式忽略或处理信号。忽略的信号会被丢弃,不做处理。处理信号会执行用户提供的信号处理函数,程序接收到信号时会立即跳到处理函数执行。当信号处理函数返回时,程序控制逻辑将返回之前终端的指令处继续执行。由于信号的异步性,信号处理函数需要注意不要破坏之前的代码,只执行异步安全(async-safe,也称为信号安全)的函数。

允许进程交换信息并通知彼此所发生的事件是操作系统最重要的工作之一。Linux内核实现了绝大多数UNIX进程间通信(IPC)机制——包括System V和POSIX共同定义和标准化的机制——实现自定义的机制。

Linux支持的进程间通信机制包括管道、命名管道、信号量、消息队列、共享内存和快速用户空间互斥(futex)。

Linux系统编程离不开大量的头文件。内核本身和glibc都提供了用于系统编程的头文件。这些头文件包括标准C库(如<string.h>)以及一些UNIX的贡献(如<unistd.h>)。

毋庸置疑,检测错误和处理错误都是极其重要的。在系统编程中,错误是通过函数的返回值和特殊变量errno描述。glibc为库函数和系统调用提供透明errno支持。本书中给出的绝大多数接口都使用这种机制来报告错误。

函数通过特殊返回值(通常是-1,具体值取决于函数)通知调用函数发生了错误。错误值告诉调用函数发生了错误,但是并没有给出错误发生的原因。变量errno用于定位错误的原因。

变量errno在<errno.h>中定义如下:

errno的值只有当errno设置函数显示错误后(通常返回-1)才生效,而在程序的后续执行过程中都可以修改其值。

可以直接读写errno变量,它是可修改的左值。errno的值和特定错误的文本描述一一对应。预处理器#define也和数值errno值一一对应。举个例子,预处理器定义EACCES等于1,表示“权限不足”。表1-2给出了标准定义和相应的错误描述列表。

表1-2 错误代码及其描述

处理器预定义

描述

 E2BIG

参数列表太长

 EACCES

权限不足

 EAGAIN

重试

 EBADF 

文件号错误 

 EBUSY 

设备或资源忙 

 ECHILD

无子进程

 EDOM 

数学参数不在函数域内

 EEXIST 

文件已存在 

 EFAULT

地址错误 

 EFBIG

文件太大

 EINTR 

系统调用被中断 

 EINVAL

参数无效

 EIO

I/O错误 

 EISDIR 

是目录 

 EMFILE 

打开文件太多 

 EMLINK

太多链接

 ENFILE 

文件表溢出 

 ENODEV 

无此设备

 ENOENT 

无此文件或目录 

 ENOEXEC 

执行格式错误 

 ENOMEM

内存用尽

 ENOSPC

设备无剩余空间 

 ENOTDIR 

非目录

 ENOTTY

不合理I/O控制操作 

 ENXIO 

无此设备或地址 

 EPERM 

操作不允许 

 EPIPE

管道损坏

 ERANGE

结果范围太大 

 EROFS 

只读文件系统 

 ESPIPE 

非法定位

 ESRCH 

无此进程

 ETXTBSY 

文本文件忙 

 EXDEV 

跨文件系统链接 

C库提供了很多函数,可以把errno值转换成对应的文本。只有错误报告以及类似的操作时才需要。检测错误和处理错误可以直接通过预处理器定义和errno进行处理。

第一个这样的函数是perror():

该函数向stderr(标准错误输出)打印以str指向的字符串为前缀,紧跟着一个冒号,然后是由errno表示的当前错误的字符串。为了使输出的错误信息有用,执行失败的函数名称应该包含在字符串中。例如:

C库还提供了strerror()和strerror_r()函数,原型如下:

前一个函数返回由errnum描述的错误的字符串指针。字符串可能不会被应用程序修改,但是会被后续的perror()和strerror()函数调用修改。因此,strerror函数不是线程安全的。

相反,strerror_r()函数是线程安全的。它向buf指向的长度为len的缓冲区中写入数据。strerror_r()函数在成功时返回1,失败时返回-1。有意思的是,这个函数在错误时也设置errno。

对于某些函数,在返回类型范围内返回的值都是合法的。在这些情况下,在调用前,errno必须设置成0,且调用后还会检查(这些函数保证在真正错误时返回非0的errno值)。例如:

检查errno时常犯的一个错误是忘记任何库函数或系统调用都可能修改它。举个例子,以下代码是有bug的:

在跨函数调用时,如果需要保留errno值,就需要保存该值:

如本节前面所介绍的,在单线程程序中,errno是个全局变量。然而,在多线程程序中,每个线程都有自己的errno,因此它是线程安全的。

这一章着眼于Linux系统编程的基础概念并从程序员视角探索Linux系统。下一章将讨论基本的文件I/O,这当然包括读写文件,但是由于Linux把很多接口以文件形式实现,因此文件I/O的至关重要性不仅仅是对于文件而言,对于Linux系统的很多其他方面亦是如此。

了解了这些基础知识后,可以开始深入探索真正的系统编程了。我们一起动手吧。

[1] 高级Linux用户可能还记得当时面临的一些困境,如从a.out 切换到ELF,libc5切换到glibc,gcc发生的变化,C++模板ABI的破坏等。幸运的是,这些都一去不复返了。

[2] 2Plan 9是诞生于贝尔实验室的操作系统,通常认为是新型的Unix。它融合了一些创造性思想,严格遵循一切皆文件的理念。

[3] 3 这是为了简单而人工设置的内核选项,以后可能会废除。

[4] 4 这种方式最早是由贝尔实验室的Plan 9推出的。


本章以及后续的3个章节将介绍文件相关的内容。UNIX系统主要是通过文件表示的,因此这些章节的探讨会涉及UNIX系统的核心。本章介绍了文件I/O的基本要素,详细阐述了最简单也是最常见的文件交互方式——系统调用。第3章基于标准C库描述标准I/O,第4章继续探讨更高级和专业的文件I/O接口。第8章以文件和目录操作为主题结束了文件相关的探讨。

在对文件进行读写操作之前,首先需要打开文件。内核会为每个进程维护一个打开文件的列表,该列表称为文件表(file table)。文件表是由一些非负整数进行索引,这些非负整数称为文件描述符(file descriptors,简称fds)。列表的每一项是一个打开文件的信息,包括指向该文件索引节点(inode)内存拷贝的指针以及关联的元数据,如文件位置指针和访问模式。用户空间和内核空间都使用文件描述符作为唯一cookies:打开文件会返回文件描述符,而后续操作(读写等)都把文件描述符作为基本参数。

文件描述符使用C语言的int类型表示。没有使用特殊类型来表示文件描述符显得有些怪异,但实际上,这种表示方式正是继承了UNIX传统。每个Linux进程可打开的文件数是有上限的。文件描述符的范围从0开始,到上限值减1。默认情况下,上限值为1 024,但是可以对它进行配置,最大为1 048 576。因为负数不是合法的文件描述符,所以当函数出错不能返回有效的文件描述符时,通常会返回-1。

按照惯例,每个进程都至少包含三个文件描述符:0、1和2,除非显式关闭这些描述符。文件描述符0表示标准输入(sdtin),1表示标准输出(stdout),2表示标准错误(stderr)。Linux C标准库没有直接引用这些整数,而是提供了三个宏,分别是:STDIN_FILENO, STDOUT_FILENO和STDERR_FILENO。一般而言,stdin是连接到终端的输入设备(通常是用户键盘),而stdout和stderr是终端的屏幕。用户可以重定向这些文件描述符,甚至可以通过管道把一个程序的输出作为另一个程序的输入。shell正是通过这种方式实现重定向和管道的。

值得注意的是,文件描述符并非局限于访问普通文件。实际上,文件描述符也可以访问设备文件、管道、快速用户空间互斥(futexes)[1]、先进先出缓冲区(FIFOs)和套接字(socket)。遵循一切皆文件的理念,几乎任何能够读写的东西都可以通过文件描述符来访问。

默认情况下,子进程会维护一份父进程的文件表副本。在该副本中,打开文件列表及其访问模式、当前文件位置以及其他元数据,都和父进程维护的文件表相同,但是存在一点区别:即当子进程关闭一个文件时,不会影响到父进程的文件表。虽然一般情况下子进程会自己持有一份文件表,但是子进程和父进程也可以共享文件表(类似于线程间共享),在第5章将对此进行更详细的介绍。

最基本的文件访问方法是系统调用read()和write()。但是,在访问文件之前,必须先通过open()或creat()打开该文件。一旦完成文件读写,还应该调用系统调用close()关闭该文件。

通过系统调用open(),可以打开文件并获取其文件描述符:

如果系统调用open()执行成功,会返回文件描述符,指向路径名name所指定的文件。文件位置即文件的起始位置(0),文件打开方式是根据参数flags值来确定的。

open()的flags参数

flags参数是由一个或多个标志位的按位或组合。它支持三种访问模式:O_RDONLY、O_WRONLY或O_RDWR,这三种模式分别表示以只读、只写或读写模式打开文件。

举个例子,以下代码以只读模式打开文件/home/kidd/madagascar:


不能对以只读模式打开的文件执行写操作,反之亦然。进程必须有足够的权限才能调用系统调用来打开文件。举个例子,假设用户对某个文件只有只读权限,该用户的进程只能以O_RDONLY模式打开文件,而不能以O_WRONLY或O_RDWR模式打开。

flags参数还可以和下面列出的这些值进行按位或运算,修改打开文件的行为:

O_APPEND

文件将以追加模式打开。也就是说,在每次写操作之前,将会更新文件位置指针,指向文件末尾。即使有另一个进程也在向该文件写数据,以追加模式打开的进程在最后一次写操作时,还是会更新文件位置指针,指向文件末尾(参见2.3.2小节)。

O_ASYNC

当指定的文件可读或可写时,会生成一个信号(默认是SIGIO)。O_ASYNC标志位只适用于FIFO、管道、socket和终端,不适用于普通文件。

O_CLOEXEC

在打开的文件上设置“执行时关闭”标志位。在执行新的进程时,文件会自动关闭。设置O_CLOEXEC标志位可以省去调用fcntl()来设置标志位,且避免出现竞争。Linux内核2.6.23以上的版本才提供该标志位。

O_CREAT

当参数name指定的文件不存在时,内核自动创建。如果文件已存在,除非指定了标志位O_EXCL,否则该标志位无效。

O_DIRECT

打开文件用于直接I/O(参见2.5节)。

O_DIRECTORY

如果参数name不是目录,open()调用会失败。该标志位被置位时,内部会调用opendir()。

O_EXCL

当和标志位O_CREAT一起使用时,如果参数name指定的文件已经存在,会导致open()调用失败。用于防止创建文件时出现竞争。如何没有和标志位O_CREAT一起使用,该标志位就没有任何含义。

O_LARGEFILE

文件偏移使用64位整数表示,可以支持大于2GB的文件。64位操作系统中打开文件时,默认使用该参数。

O_NOATIME+

在读文件时,不会更新该文件的最后访问时间。该标志位适用于备份、索引以及类似的读取系统上所有文件的程序,它可以避免为了更新每个文件的索引节点而导致的大量写操作。Linux内核2.6.8以上的版本才提供该标志位。

O_NOCTTY

如果给定的参数name指向终端设备(比如/dev/tty),它不会成为这个进程的控制终端,即使该进程当前没有控制终端。该标志位很少使用。

O_NOFOLLOW

如果参数name指向一个符号链接,open()调用会失败。正常情况下,指定该标志位会解析链接并打开目标文件。如果给定路径的子目录也是链接,open()调用还是会成功。举个例子,假设name值为/etc/ship/plank.txt,如果plank.txt是个符号链接, open()会失败;然而,如果etcship是符号链接,只要plank.txt不是符号链接,调用就会成功。

O_NONBLOCK

文件以非阻塞模式打开。不管是open()调用还是其他操作,都不会导致进程在I/O中阻塞(sleep)。这种情况只适用于FIFO。

O_SYNC

打开文件用于同步I/O。在数据从物理上写到磁盘之前,写操作都不会完成;普通的读操作已经是同步的,因此该标志位对读操作无效。POSIX还另外定义了两个标志位O_DSYNC和O_RSYNC,在Linux系统中,这些标志位和O_SYNC含义相同(参见2.4.3节)。

O_TRUNC

如果文件存在,且是普通文件,并且有写权限,该标志位会把文件长度截断为0。对于FIFO或终端设备,该标志位无效,对于其他文件类型,其行为是未定义。因为对文件执行截断操作,需要有写权限,所以如果文件是只读,指定标志位O_TRUNC,行为也是未定义的。

举个例子,以下代码会打开文件/home/teach/pearl,用于写操作。如果文件已经存在,该文件长度会被截断为0;由于没有指定标志位O_CREAT,如果文件不存在,该open调用会失败:

确定新建文件的所有者很简单:文件所有者的uid即创建该文件的进程的有效uid。

确定新建文件的用户组则相对复杂些。默认情况下,使用创建进程的有效gid。System V是通过这种方式确定,Linux的很多行为都是以System V为模型,因此标准Linux也采用这种处理方式。

但是问题在于,BSD定义了自己的行为方式:新建文件的用户组会被设置成其父目录的gid。在Linux上可以通过挂载选项[2]实现这一点——在Linux上,如果文件的父目录设置了组ID(setgid)位,默认也是这种行为。虽然大多数Linux系统会采用System V行为(新建的文件使用创建进程的gid),但BSD行为(新建文件接收父目录的gid)也有存在的可能,这意味着对于那些对新建文件的所属组非常关注的代码,需要通过系统调用fchown()手动设置所属组(参见第8章)。

幸运的是,大部分时候不需要关心文件的所属组。

前面给出的两种open()系统调用方式都是合法的。除非创建了新文件,否则会忽略参数mode;如果给定O_CREAT参数,则需要该参数。在使用O_CREAT参数时如果没有提供参数mode,结果是未定义的,而且通常会很糟糕——所以千万不要忘记!

当创建文件时,参数mode提供了新建文件的权限。对于新建的文件,打开文件时不会检查权限,因此可以执行与权限相反的操作,比如以只读模式打开文件,却在打开后执行写操作。

参数mode是常见的UNIX权限位集合,比如八进制数0644(文件所有者可以读写,其他人只能读)。从技术层面看,POSIX是根据具体实现确定值,支持不同的UNIX系统设置自己想要的权限位。但是,每个UNIX系统对权限位的实现都采用了相同的方式。因此,虽然技术上不可移植,但在任何系统上指定0644或0700效果都是一样的。

为了弥补mode中比特位的不可移植性,POSIX引入了一组可以按位操作的常数,按位结果提供给参数mode:

S_IRWXU

文件所有者有读、写和执行的权限。

S_IRUSR

文件所有者有读权限。

S_IWUSR

文件所有者有写权限。

S_IXUSR

文件所有者有执行权限。

S_IRWXG

组用户有读、写和执行权限。

S_IRGRP

组用户有读权限。

S_IWGRP

组用户有写权限。

S_IXGRP

组用户有执行权限。

S_IRWXO

任何人都有读、写和执行的权限。

S_IROTH

任何人都有读权限。

S_IWOTH

任何人都有写权限。

S_IXOTH

任何人都有执行权限。

实际上,最终写入磁盘的权限位是由mode参数和用户的文件创建掩码(umask)执行按位与操作而得到。umask是进程级属性,通常是由login shell设置,通过调用umask()来修改,支持用户修改新创建的文件和目录的权限。在系统调用open()中,umask位要和参数mode取反。因此,umask 022和mode参数0666取反后,结果是0644。对于系统程序员,在设置权限时通常不需要考虑umask——umask是为了支持用户限制程序对于新建文件的权限设置。

举个例子,以下代码会对文件file执行写操作。如果文件不存在,假定umask值为022,文件在创建时指定权限为0644(虽然参数mode值为0664)。如果文件已存在,其长度会被截断为0:

为了代码可读性(以可移植性为代价,至少理论上如此),这段代码可以改写成如下,其效果完全相同:

OWRONLY | O_CREAT | O****TRUNC 的组合经常被使用,因而专门有个系统调用提供这个功能:

诚如你所看到的,函数名creat的确少了个e。UNIX之父Ken Thompson曾开玩笑说他在UNIX设计中感到最遗憾的就是漏掉了这个字母。

典型的creat()调用如下:

这段代码等效于:

在绝大多数Linux架构[3]中,creat()是个系统调用,虽然在用户空间也很容易实现:

这是一个历史遗留问题,因为之前open()函数只有两个参数,所以也设计了creat()函数。当前,为了向后兼容,仍然保留creat()这个系统调用。在新的体系结构中,creat()可以实现成调用open()的库函数调用,如上所示。

系统调用open()和creat()在成功时都会返回文件描述符。出错时,返回-1,并把errno设置成相应的错误值(第1章讨论了errno并列出了可能的错误值)。处理文件打开的错误并不复杂,一般来说,在打开文件之前没有什么操作,因此不太需要执行撤销。典型的处理方式是提示用户换个文件名或直接终止程序。

前面讨论了如何打开文件,现在一起来看如何读文件。在接下来的一节中,我们将讨论写操作。

最基础、最常见的读取文件机制是调用read(),该系统调用在POSIX.1中定义如下:

每次调用read()函数,会从fd指向的文件的当前偏移开始读取len字节到buf所指向的内存中。执行成功时,返回写入buf中的字节数;出错时,返回-1,并设置errno值。fd的文件位置指针会向前移动,移动的长度由读取到的字节数决定。如果fd所指向的对象不支持seek操作(比如字符设备文件),则读操作总是从“当前”位置开始。

基本用法很简单。下面这个例子就是从文件描述符fd所指向的文件中读取数据并保存到word中。读取的字节数即unsigned long类型的大小,在Linux的32位系统上是4字节,在64位系统上是8字节。成功时,返回读取的字节数;出错时,返回-1:

这个简单的实现存在两个问题:可能还没有读取len字节,调用就返回了,而且可能产生某些可操作的错误,但这段代码没有检查和处理。不幸的是,类似这样的代码非常普遍。我们一起看看如何改进它。

对于read()而言,返回小于len的非零正整数是合法的。在很多情况下会出现该现象:可用的字节数少于len,系统调用可能被信号打断,管道可能被破坏(如果fd指向的是管道)等。

使用read()时,还需要考虑返回值为0的情况。当到达文件末尾(end-of-file, EOF)时,read()返回0,在这种情况下,没有读取任何字节。EOF并不表示出错(因此返回值不是-1),它仅仅表示文件位置已经到达文件结尾,因此没有数据可读了。但是,如果调用是要读取len个字节,但是没有一个字节可读,调用会阻塞(sleep),直到有数据可读(假定文件描述符不是以非阻塞模式打开的,参见2.2.3小节)。注意,这种阻塞模式和返回EOF不同。也就是说,“没有数据可读”和“到达数据结尾”是两个不同的概念。对于EOF,表示到达了文件的结尾。对于阻塞模式,表示读操作在等待更多的数据——例如从socket或设备文件读取数据。

有些错误是可以恢复的。比如,当read()调用在读取任何字节之前被信号打断,它会返回-1(如果返回0,则无法和EOF的情况区分开),并把errno值设置成EINTR。在这种情况下,可以而且应该重新提交read请求。

实际上,调用read()有很多可能结果:

诚如前面所描述的,由于调用read()会有很多不同情况,如果希望处理所有错误并且真正每次读入len个字节(至少读到EOF),那么之前简单“粗暴”的read()调用并不合理。要实现这一点,需要有个循环和一些条件语句,如下:

这段代码判断处理了五种情况。循环从fd所指向的当前文件位置读入len个字节到buf中,一直读完所有len个字节或者EOF为止。如果读入的字节数大于0但小于len,就从len中减去已读字节数,buf增加相应的字节数,并重新调用read()。如果调用返回-1,并且errno值为EINTR,会重新调用且不更新参数。如果调用返回-1,并且errno设置为其他值,会调用perror(),向标准错误打印一条描述,循环结束。

读取数据采用部分读入的方式不但可行,而且还很常见。但是,由于很多开发人员没有正确检查处理这种很短的读入请求,带来了无数bug。请不要成为其中一员!

有时,开发人员不希望read()调用在没有数据可读时阻塞在那里。相反地,他们希望调用立即返回,表示没有数据可读。这种方式称为非阻塞I/O,它支持应用以非阻塞模式执行I/O操作,因而如果是读取多个文件,以防错过其他文件中的可用数据。

因此,需要额外检查errno值是否为EAGAIN。正如前面所讨论的,如果文件描述符以非阻塞模式打开(即open()调用中指定参数为O_NONBLOCK,参见2.1.1节中的“open()调用的参数”),并且没有数据可读,read()调用会返回-1,并设置errno值为EAGAIN,而不是阻塞模式。当以非阻塞模式读文件时,必须检查EAGAIN,否则可能因为丢失数据导致严重错误。你可能会用到如下代码:

处理EAGAIN的情况和处理EINTR的方式不同(用了goto start)。也许你并不需要采用非阻塞I/O。非阻塞I/O的意义在于捕捉EAGAIN的情况,并执行其他逻辑。

其他错误码指的是编程错误或(对EIO而言)底层问题。read()调用执行失败后,可能的errno值包括:

EBADF

给定的文件描述符非法或不是以可读模式打开。

EFAULT

buf指针不在调用进程的地址空间内。

EINVAL

文件描述符所指向的对象不允许读。

EIO

底层I/O错误。

类型size_t和ssize_t是由POSIX确定的。类型size_t保存字节大小,类型ssize_t是有符号的size_t(负值用于表示错误)。在32位系统上,对应的C类型通常是unsigned int和int。因为这两种类型常常一起使用,ssize_t的范围更小,往往限制了size_t的范围。

size_t的最大值是SIZE_MAX,ssize_t的最大值是SSIZE_MAX。如果len值大于SSIZE_MAX,read()调用的结果是未定义的。在大多数Linux系统上,SSIZE_MAX的值是LONG_MAX,在32位系统上这个值是2 147 483 647。这个数值对于一次读操作而言已经很大了,但还是需要留心它。如果使用之前的读循环作为通用的读方式,可能需要给它增加以下代码:

调用read()时如果len参数为0,会立即返回,且返回值为0。

写文件,最基础最常见的系统调用是write()。和read()一样,write()也是在POSIX.1中定义的:

write()调用会从文件描述符fd指向的文件的当前位置开始,将buf中至多count个字节写入到文件中。不支持seek的文件(如字符设备)总是从起始位置开始写。

write()执行成功时,会返回写入的字节数,并更新文件位置。出错时,返回-1,并设置errno值。调用write()会返回0,但是这种返回值没有任何特殊含义,它只是表示写入了零个字节。

和read()一样,write()调用的最基本用法也很简单:

还是和read()一样,以上这种用法不太正确。调用方还需要检查各种“部分写(partial write)”的场景:

和read()调用的部分读场景相比,write()调用不太可能会返回部分写。此外,write()系统调用不存在EOF的场景。对于普通文件,除非发生错误,write()操作保证会执行整个写请求。

因此,对于普通文件,不需要执行循环写操作。但是,对于其他的文件类型,比如socket,需要循环来保证写了所有请求的字节。使用循环的另一个好处是第二次调用write()可能会返回错误值,说明第一次调用为什么只执行了部分写(虽然这种情况并不常见)。以下是write()调用示例代码:


当以Append模式(参数设置O_APPEND)打开文件描述符时,写操作不是从文件描述符的当前位置开始,而是从当前文件的末尾开始。

举个例子,假设有两个进程都想从文件的末尾开始写数据。这种场景很常见:比如很多进程共享的事件日志。刚开始,这两个进程的文件位置指针都正确地指向文件末尾。第一个进程开始写,如果不采用Append模式,一旦第二个进程也开始写,它就不是从“当前”文件末尾开始写,而是从“之前”文件末尾(刚开始指向的文件末尾,即第一个进程开始写数据之前)开始写。这意味着如果缺乏显式的同步机制,多个进程由于会发生竞争问题,不能同时向同一个文件追加写。

Append模式可以避免这个问题。它保证了文件位置指针总是指向文件末尾,因此即使存在多个写进程,所有的写操作还是能够保证是追加写。Append模式可以理解成在每次写请求之前的文件位置更新操作是个原子操作。更新文件位置,指向新写入的数据末尾。这和下一次write()调用无关,因为更新文件位置是自动完成的,但如果由于某些原因下一次执行的是read()调用,那会有些影响。

Append模式对于某些任务很有用,比如之前提到的日志文件更新,但对其他很多操作意义不大。

以非阻塞模式(参数设置O_NONBLOCK)打开文件,当发起写操作时,系统调用write()会返回-1,并设置errno值为EAGAIN。请求可以稍后重新发起。一般而言,对于普通文件,不会出现这种情况。

其他值得注意的errno值包括:

EBADF

给定的文件描述符非法或不是以写方式打开。

EFAULT

buf指针指向的位置不在进程的地址空间内。

EFBIG

写操作将使文件大小超过进程的最大文件限制或内部设置的限制。

EINVAL

给定文件描述符指向的对象不支持写操作。

EIO

底层I/O错误。

ENOSPC

给定文件描述符所在的文件系统没有足够的空间。

EPIPE

给定的文件描述符和管道或socket关联,读端被关闭。进程还接收SIGPIPE信号。SIGPIPE信号的默认行为是终止信号接收进程。因此,只有当进程显式选择忽略、阻塞或处理该信号时,才会接收到该errno值。

如果count值大于SSIZE_MAX,调用write()的结果是未定义的。

调用write()时,如果count值为零,会立即返回,且返回值为0。

当write()调用返回时,内核已经把数据从提供的缓冲区中拷贝到内核缓冲区中,但不保证数据已经写到目的地。实际上,write调用执行非常快,因此不可能保证数据已经写到目的地。处理器和硬盘之间的性能差异使得这种情况非常明显。

相反,当用户空间发起write()系统调用时,Linux内核会做几项检查,然后直接把数据拷贝到缓冲区中。然后,在后台,内核收集所有这样的“脏”缓冲区(即存储的数据比磁盘上的数据新),进行排序优化,然后把这些缓冲区写到磁盘上(这个过程称为回写writeback)。通过这种方式,write()可以频繁调用并立即返回。这种方式还支持内核把写操作推迟到系统空闲时期,批处理很多写操作。

延迟写并没有改变POSIX语义。举个例子,假设要对一份刚写到缓冲区但还没写到磁盘的数据执行读操作,请求响应时会直接读取缓冲区的数据,而不是读取磁盘上的“陈旧”数据。这种方式进一步提高了效率,因为对于这个读请求,是从内存缓冲区而不是从硬盘中读的。如期望的那样,读写请求相互交织,而结果也和预期一致——当然,前提是在数据写到磁盘之前,系统没有崩溃!虽然应用可能认为写操作已经成功了,在系统崩溃情况下,数据却没有写入到磁盘。

延迟写的另一个问题在于无法强制“顺序写(write ordering)”。虽然应用可能会考虑对写请求进行排序,按特定顺序写入磁盘;而内核主要是出于性能考虑,按照合适的方式对写请求重新排序。只有当系统出现崩溃时,延迟写才会有问题,因为最终所有的缓冲区都会写回,而且文件的最终状态和预期的一致。实际上绝大多数应用并不关心写顺序。数据库是少数几个关心顺序的,它们希望写操作有序,确保数据库不会处于不一致状态。

延迟写的最后一个问题是对某些I/O错误的提示信息不准确。在回写时产生的任何I/O错误,比如物理磁盘驱动出错,都不能报告给发起写请求的进程。实际上,内核内“脏”缓冲区和进程无关。多个进程可能会“弄脏”(即更新)同一片缓冲区中的数据,进程可能在数据仅被写到缓冲区尚未写到磁盘的时候就退出了。进程操作失败,如何“事后”与之通信呢?

对于这些潜在问题,内核试图最小化延迟写带来的风险。为了保证数据按时写入,内核设置了“最大缓存时效”(maximum buffer age),并在超出给定时效前将所有脏缓存的数据写入磁盘。用户可以用过/proc/sys/vm/dirty_expire_centisecs来配置这个值,该值单位是厘秒(0.01秒)。

Linux系统也支持强制文件缓存写回,甚至是将所有的写操作同步。这些主题将在2.4节中详细探讨。

在本章后续部分,2.11节将深入探讨Linux内核缓存回写子系统。

虽然同步I/O是个很重要的主题,但不必过于担心延迟写的问题。写缓冲带来了极大的性能提升,因此,任何操作系统,甚至是那些“半吊子”的操作系统,都因支持缓冲区实现了延迟写而可以称为“现代”操作系统。然而,有时应用希望能够控制何时把数据写到磁盘。在这种场景下,Linux内核提供了一些选择,可以牺牲性能换来同步操作。

为了确保数据写入磁盘,最简单的方式是使用系统调用fsync(),在POSIX.1b标准中定义如下:

系统调用fsync()可以确保和文件描述符fd所指向的文件相关的所有脏数据都会回写到磁盘上。文件描述符fd必须以写方式打开。该调用会回写数据和元数据,如创建的时间戳以及索引节点中的其他属性。该调用在硬件驱动器确认数据和元数据已经全部写到磁盘之前不会返回。

对于包含写缓存的硬盘,fsync()无法知道数据是否已经真正在物理磁盘上了。硬盘会报告说数据已经写完了,但是实际上数据还在硬盘驱动器的写缓存上。好在,在硬盘驱动器缓存中的数据会很快写入到磁盘上。

Linux还提供了系统调用fdatasync():

fdatasync()的功能和fsync()类似,其区别在于fdatasync()只会写入数据以及以后要访问文件所需要的元数据。例如,调用fdatasync()会写文件的大小,因为以后要读该文件需要文件大小这个属性。fdatasync()不保证非基础的元数据也写到磁盘上,因此一般而言,它执行更快。对于大多数使用场景,除了最基本的事务外,不会考虑元数据如文件修改时间戳,因此fdatasync()就能够满足需求,而且执行更快。

fsync()通常会涉及至少两个I/O操作:一是回写修改的数据,二是更新索引节点的修改时间戳。因为索引节点和文件数据在磁盘上可能不是紧挨着——因而会带来代价很高的seek操作——在很多场景下,关注正确的事务顺序,但不包括那些对于以后访问文件无关紧要的元数据(比如修改时间戳),使用fdatasync()是提高性能的简单方式。

fsync()和fdatasync()这两个函数用法一样,都很简单,如下:


而fdatasync()的使用方式如下:

这两个函数都不保证任何已经更新的包含该文件的目录项会同步到磁盘上。这意味着如果文件链接最近刚更新,文件数据可能会成功写入磁盘,但是却没有更新到相关的目录中,导致文件不可用。为了保证对目录项的更新也都同步到磁盘上,必须对文件目录也调用fsync()进行同步。

返回值和错误码

成功时,两个调用都返回0。失败时,都返回-1,并设置errno值为以下三个值之一:

EBADF

给定文件描述符不是以写方式打开的合法描述符。

EINVAL

给定文件描述符所指向的对象不支持同步。

EIO

在同步时底层I/O出现错误。这表示真正的I/O错误,经常在发生错误处被捕获。

对于某些Linux版本,调用fsync()可能会失败,因为文件系统没有实现fsync(),即使实现了fdatasync()。某些“固执”的应用可能会在fsync()返回EINVAL时尝试使用fdatasync()。代码如下:

在POSIX标准中,fsync()是必要的,而fdatasync()是可选的,因此在所有常见的Linux文件系统上,都应该为普通文件实现fsync()系统调用。但是,特殊的文件类型(比如那些不需要同步元数据的)或不常见的文件系统可能只实现了fdatasync()系统调用。

sync()系统调用用来对磁盘上的所有缓冲区进行同步,虽然它效率不高,但还是被广泛应用:

该函数没有参数,也没有返回值。它总是成功返回,并确保所有的缓冲区——包括数据和元数据——都能够写入磁盘[4]

POSIX标准并不要求sync()一直等待所有缓冲区都写到磁盘后才返回,只需要调用它来启动把所有缓冲区写到磁盘上即可。因此,一般建议多次调用sync(),确保所有数据都安全地写入磁盘。但是对于Linux而言,sync()一定是等到所有缓冲区都写入了才返回,因此调用一次sync()就够了。

sync()的真正用途在于同步功能的实现。应用应该使用fsync()和fdatasync()将文件描述符指定的数据同步到磁盘中。注意,当系统繁忙时,sync()操作可能需要几分钟甚至更长的时间才能完成。

系统调用open()可以使用O_SYNC标志位,表示该文件的所有I/O操作都需要同步:

读请求总是同步操作。如果不同步,无法保证读取缓冲区中的数据的有效性。但是,正如前面所提到的,write()调用通常是非同步操作。调用返回和把数据写入磁盘没有什么关系,而标志位O_SYNC则将二者强制关联,从而保证write()调用会执行I/O同步。

O_SYNC标志位的功能可以理解成每次调用write()操作后,隐式执行fsync(),然后才返回。这就是O_SYNC的语义,虽然Linux内核在实现上做了优化。

对于写操作,O_SYNC对用户时间和内核时间(分别指用户空间和内核空间消耗的时间)有些负面影响。此外,根据写入文件的大小,O_SYNC可能会使进程消耗大量的时间在I/O等待时间,因而导致总耗时增加一两个数量级。O_SYNC带来的时间开销增长是非常可观的,因此一般只在没有其他方式下才选择同步I/O。

一般来说,应用要确保通过fsync()或fdatasync()写数据到磁盘上。和O_SYNC相比,调用fsync()和fdatasync()不会那么频繁(只在某些操作完成之后才会调用),因此其开销也更低。

POSIX标准为open()调用定义了另外两个同步I/O相关的标志位:O_DSYNC和O_RSYNC。在Linux上,这些标志位的定义和O_SYNC一致,其行为完全相同。

O_DSYNC标志位指定每次写操作后,只同步普通数据,不同步元数据。O_DSYNC的功能可以理解为在每次写请求后,隐式调用fdatasync()。因为O_SYNC提供了更严格的限制,把O_DSYNC替换成O_SYNC在功能上完全没有问题,只有在某些严格需求场景下才会有性能损失。

O_RSYNC标志位指定读请求和写请求之间的同步。该标志位必须和O_SYNC或O_DSYNC一起使用。正如前面所提到的,读操作总是同步的——只有当有数据返回给用户时,才会返回。O_RSYNC标志位保证读操作带来的任何影响也是同步的。也就是说,由于读操作导致的元数据更新必须在调用返回前写入磁盘。在实际应用中,可以理解成在read()调用返回前,文件访问时间必须更新到磁盘索引节点的副本中。在Linux中,O_RSYNC和O_SYNC的含义相同,虽然这没有什么意义(与O\_SYNC和O\_DSYNC的子集关系不同)。在Linux中,O_RSYNC无法通过当前行为来解释,最接近的理解是在每次read()调用后,再调用fdatasync()。实际上,这种行为极少发生。

和其他现代操作系统内核一样,Linux内核实现了复杂的缓存、缓冲以及设备和应用之间的I/O管理的层次结构(参见2.11节)。高性能的应用可能希望越过这个复杂的层次结构,进行独立的I/O管理。但是,创建一个自己的I/O系统往往会事倍功半,实际上,操作系统层的工具往往比应用层的工具有更好的性能。此外,数据库系统往往倾向于使用自己的缓存,以尽可能减少操作系统带来的开销。

在open()中指定O_DIRECT标志位会使得内核对I/O管理的影响最小化。如果提供O_DIRECT标志位,I/O操作会忽略页缓存机制,直接对用户空间缓冲区和设备进行初始化。所有的I/O操作都是同步的,操作在完成之前不会返回。

使用直接I/O时,请求长度、缓冲区对齐以及文件偏移都必须是底层设备扇区大小(通常是512字节)的整数倍。在Linux内核2.6以前,这项要求更加严格:在Linux内核2.4中,所有的操作都必须和文件系统的逻辑块大小对齐(一般是4KB)。为了保持兼容性,应用需要对齐到更大(而且操作更难)的逻辑块大小。

当程序完成对某个文件的操作后,可以通过系统调用close()取消文件描述符到对应文件的映射:

系统调用close()会取消当前进程的文件描述符fd与其关联的文件之间的映射。调用后,先前给定的文件描述符fd不再有效,内核可以随时重用它,当后续有open()调用或creat()调用时,重新把它作为返回值。close()调用在成功时返回0,出错时返回-1,并相应设置errno值。close()的用法很简单:

值得一提的是,关闭文件操作并非意味着该文件的数据已经被写到磁盘。如果应用希望保证关闭文件之前数据已经写入磁盘,它需要使用先前在2.4节中讨论的同步选项。

关闭文件虽然操作上很简单,但是也会带来一些影响。当关闭指向某个文件的最后一个文件描述符时,内核中表示该文件的数据结构就释放了。如果释放了数据结构,会清除和文件相关的索引节点的内存拷贝。如果已经没有内存和索引节点关联,该索引节点也会被从内存中清除(出于性能考虑,也可能会保存在内核中,但也可能不需要)。如果文件已经从磁盘上解除链接,但是解除之前还一直打开,在文件被关闭并且其索引节点从内存中删除之后,该文件才会真正从物理磁盘上删除。因此,调用close()可能会使得一个已解除链接的文件最终从磁盘上删除。

一个常见的错误是没有检查close()的返回值。这样做可能会遗漏严重错误,因为延迟操作相关的错误可能到了后期才出现,而close()的返回值早就给出了这些错误信息。在失败时,有很多可能的errno值。除了EBADF(给定的文件描述符不合法),最重要的错误码是EIO,表示底层I/O错误,该错误很可能和实际的close操作并不相关。如果忽略出现的错误,在合法情况下,文件描述符总是关闭的,而且相关的数据结构也都释放了。

close()调用绝不会返回EINTR,虽然POSIX标准允许。Linux内核开发者可能很清楚,返回EINTR并不合适。

一般情况下,I/O是线性的,由于读写引发的隐式文件位置更新都需要seek操作。但是,某些应用要跳跃式读取文件,需要随机访问而不是线性访问。lseek()系统调用能够将文件描述符的位置指针设置成指定值。lseek()只更新文件位置,没有执行其他操作,也并不初始化任何I/O:

lseek()调用的行为依赖于origin参数,该参数可以是以下任意值之一:

SEEK_CUR

将文件位置设置成当前值再加上pos个偏移值,pos可以是负值、0或正值。如果pos值为0,返回当前文件位置值。

SEEK_END

将文件位置设置成文件长度再加上pos个偏移值,pos可以是负值、0或正值。如果pos值为0,就设置成文件末尾。

SEEK_SET

将文件位置设置成pos值。如果pos值为0,就设置成文件开始。

调用成功时返回新的文件位置,错误时返回-1,并相应设置errno值。

举个例子,以下代码把fd的文件位置指针设置为1825:

下面是把fd的文件位置设置成文件末尾:

由于lseek()返回更新后的文件位置,可以通过SEEK_CUR,把偏移pos设置成0,确定当前文件位置:

到目前为止,lseek()调用最常见的用法是将指针定位到文件的开始、末尾或确定文件描述符的当前文件位置。

lseek()支持在文件末尾之后进行查找。例如,以下代码会定位到fd指向文件末尾之后的1688字节。

对这种用法本身而言,查找到文件末尾之后没什么意义——对该新的文件位置的读请求会返回EOF。但是,如果在该位置有个写请求,在文件的旧长度和新长度之间的空间会用0来填充。

这种零填充区间称为“空洞(hole)”。在UNIX系文件系统上,空洞不占用任何物理磁盘空间。这意味着文件系统上所有文件的大小加起来可以超过磁盘的物理大小。包含空洞的文件称为“稀疏文件(sparse file)”。稀疏文件可以节省很多空间,并提升性能,因为操作空洞不会产生任何物理I/O。

对文件空洞部分的读请求会返回相应的二进制0。

lseek()调用出错时,返回-1,并将errno值设置成如下四个值之一:

EBADF

给定的文件描述符没有指向任何打开的文件描述符。

EINVAL

origin的值不是设置成SEEK_SET、SEEK_CUR或SEEK_END,或者结果文件位置是负值。对于EINVAL,如果同时出现以上两种错误就太糟了。前者几乎可以肯定是个编译时错误,后者则是不太明显的运行时逻辑错误。

EOVERFLOW

结果文件偏移不能通过off_t表示。只有在32位的体系结构上才会发生这种错误。当前文件位置已经更新,该错误表示无法返回更新的值。

ESPIPE

给出的文件描述符和不支持查找操作的对象关联,比如管道、FIFO或socket。

最大文件位置是受限于off_t类型的大小。大部分计算机体系结构定义该值为C long类型,在Linux上是指字长(word size)(即计算机的通用寄存器大小)。但是,内核在内部实现上是把偏移存储成C long long类型。这对于64位计算机没有什么问题,但是对于32位计算机,当执行查找操作时,可能会产生溢出EOVERFLOW的错误。

Linux提供了两种read()和write()系统调用的变体来替代lseek(),每次读写操作时,都把文件位置作为参数,在完成时,不会更新文件位置。

read()的变体是pread():

该调用会从文件描述符fd的pos位置开始读取,共读取count个字节到buf中。

write()的变体是pwrite():

该调用从文件描述符fd的pos位置开始,从buf中写count字节到文件中。

这两个调用和read()、write()调用的最主要区别在于它们完全忽略了当前文件位置;相反,pread()和pwrite()调用用的是参数pos值。此外,当调用完成时,它们不会更新文件位置指针。换句话说,任何read()和write()交替调用可能会破坏定位读写的结果。

定位读写只适用于可查找的文件描述符,包括普通文件。pread()和pwrite()调用的语义相当于在read()或write()调用之前执行lseek()调用,但仍然存在以下三点区别。

1.pread()和pwrite()调用更易于使用,尤其是对于一些复杂的操作,比如在文件中反向或随机查找定位。

2.pread()和pwrite()调用在结束时不会修改文件位置指针。

3.最重要的一点,pread()和pwrite()调用避免了在使用lseek()时会出现的竞争。

由于线程共享文件表,而当前文件位置保存在共享文件表中,可能会发生这样的情况:进程中的一个线程调用lseek()后,在执行读写操作之前,另一个线程更新了文件位置。也就是说,当进程中存在多个线程操作同一个文件描述符时,lseek()有潜在竞争可能。这些竞争场景可以通过pread()和pwrite()调用来避免。

pread()和pwrite()调用执行成功时,分别返回读或写的字节数。如果pread()返回0,表示EOF;如果pwrite()返回0,表示什么都没写。出错时,两个调用都返回-1,并相应设置errno值。对于pread(),可能出现任何有效的read()或lseek() 的errno值。对于pwrite(),也可能出现任何有效的write ()或lseek()的errno值

Linux提供了两个系统调用支持文件长度截短,各个POSIX标准都(不同程度地)定义了它们,分别是:

和:

这两个系统调用都将给定文件截短为参数len指定的长度。ftruncate()系统调用在已经以可写方式打开的文件描述符fd上操作。truncate()系统调用在path指定的可写文件上操作。成功时都返回0,出错时都返回-1并相应设置errno值。

这些系统调用最常见的用法是把文件大小截短成比当前文件长度小。成功返回时,文件长度变成len,介于之前len和老的文件长度之间的数据会被丢弃,并不再可读。

这两个函数还可以把文件“截短”为比原长度更大,这和2.7.1小节中描述的查找写例子很相似。扩展出的字节都是用0填充。

这两个操作都不会修改当前文件位置。

举个例子,假设文件pirate.txt的长度是74字节,内容如下:

在相同目录下,运行以下代码:

其执行结果是生成了一个45字节的文件,内容如下:

应用通常需要在多个文件描述符上阻塞:在键盘输入(stdin)、进程间通信以及很多文件之间协调I/O。基于事件驱动的图形用户界面(GUI)应用可能会和成百上千个事件的主循环竞争[5]

如果不使用线程,而是独立处理每个文件描述符,单个进程无法同时在多个文件描述符上阻塞。只要这些描述符已经有数据可读写,也可以采用多个文件描述符的方式。但是,要是有个文件描述符数据还没有准备好——比如发送了read()调用,但是还没有任何数据——进程会阻塞,而且无法对其他的文件描述符提供服务。该进程可能只是阻塞几秒钟,导致应用效率变低,影响用户体验。然而,如果该文件描述符一直没有数据,进程就会一直阻塞。因为文件描述符的I/O总是关联的(比如管道),很可能一个文件描述符依赖另一个文件描述符,在后者可用前,前者一直处于不可用状态。尤其是对于网络应用而言,可能同时会打开多个socket,从而引发很多问题。

试想一下如下场景:当标准输入设备(stdin)挂起,没有数据输出,应用在和进程间通信(IPC)相关的文件描述符上阻塞。只有当阻塞的IPC文件描述符返回数据后,进程才知道键盘输入挂起——但是如果阻塞的操作一直没有返回,又会发生什么呢?

如前所述,非阻塞I/O 是这种问题的一个解决方案。使用非阻塞I/O,应用可以发送I/O请求,该请求返回特定错误,而不是阻塞。但是,该方案效率不高,主要有两个原因:首先,进程需要连续随机发送I/O操作,等待某个打开的文件描述符可以执行I/O操作。这种设计很糟糕。其次,如果进程睡眠则会更高效,睡眠可以释放CPU资源,使得CPU可以处理其他任务,直到一个或多个文件描述符可以执行I/O时再唤醒进程。

下面我们一起来探讨I/O多路复用。

I/O多路复用支持应用同时在多个文件描述符上阻塞,并在其中某个可以读写时收到通知。因此,I/O多路复用成为应用的关键所在,在设计上遵循以下原则。

1.I/O多路复用:当任何一个文件描述符I/O就绪时进行通知。

2.都不可用?在有可用的文件描述符之前一直处于睡眠状态。

3.唤醒:哪个文件描述符可用了?

4.处理所有I/O就绪的文件描述符,没有阻塞。

5.返回第1步,重新开始。

Linux提供了三种I/O多路复用方案:select、poll和epoll。本章先探讨select和poll,epoll是Linux特有的高级解决方案,将在第4章详细说明。

select()系统调用提供了一种实现同步I/O多路复用的机制:


在给定的文件描述符I/O就绪之前并且还没有超出指定的时间限制,select()调用就会阻塞。

监视的文件描述符可以分为3类,分别等待不同的事件。对于readfds集中的文件描述符,监视是否有数据可读(即某个读操作是否可以无阻塞完成);对于writefds集中的文件描述符,监视是否有某个写操作可以无阻塞完成;对于exceptfds中的文件描述符,监视是否发生异常,或者出现带外(out-of-band)数据(这些场景只适用于socket)。指定的集合可能是NULL,在这种情况下,select()不会监视该事件。

成功返回时,每个集合都修改成只包含相应类型的I/O就绪的文件描述符。举个例子,假定readfds集中有两个文件描述符7和9。当调用返回时,如果描述符7还在集合中,它在I/O读取时不会阻塞。如果描述符9不在集合中,它在读取时很可能会发生阻塞。(这里说的是“很可能”是因为在调用完成后,数据可能已经就绪了。在这种场景下,下一次调用select()就会返回描述符可用。)[6]

第一个参数n,其值等于所有集合中文件描述符的最大值加1。因此,select()调用负责检查哪个文件描述符值最大,将该最大值加1后传给第一个参数。

参数timeout是指向timeval结构体的指针,定义如下:

如果该参数不是NULL,在tv_sec秒tv_usec微秒后。select()调用会返回,即使没有一个文件描述符处于I/O就绪状态。返回时,在不同的UNIX系统中,该结构体是未定义的,因此每次调用必须(和文件描述符集一起)重新初始化。实际上,当前Linux版本会自动修改该参数,把值修改成剩余的时间。因此,如果超时设置是5秒,在文件描述符可用之前已逝去了3秒,那么在调用返回时,tv.tv_sec的值就是2。

如果超时值都是设置成0,调用会立即返回,调用时报告所有事件都挂起,而不会等待任何后续事件。

不是直接操作文件描述符集,而是通过辅助宏来管理。通过这种方式,UNIX系统可以按照所希望的方式来实现。不过,大多数系统把集合实现成位数组。

FD_ZERO从指定集合中删除所有的文件描述符。每次调用select()之前,都应该调用该宏。

FD_SET向指定集中添加一个文件描述符,而FD_CLR则从指定集中删除一个文件描述符。

设计良好的代码应该都不需要使用FD_CLR,极少使用该宏。

FD_ISSET检查一个文件描述符是否在给定集合中。如果在,则返回非0值,否则返回0。当select()调用返回时,会通过FD_ISSET来检查文件描述符是否就绪:

由于文件描述符集是静态建立的,所以文件描述符数存在上限值,而且存在最大文件描述符值,这两个值都是由FD_SETSIZE设置。在Linux,该值是1024。我们将在本章稍后一起来看各种不同限制。

返回值和错误码

select()调用成功时,返回三个集合中I/O就绪的文件描述符总数。如果给出了超时设置,返回值可能是0。出错时,返回-1,并把errno值设置成如下值之一:

EBADF

某个集合中存在非法文件描述符。

EINTR

等待时捕获了一个信号,可以重新发起调用。

EINVAL

参数n是负数,或者设置的超时时间值非法。

ENOMEM

没有足够的内存来完成该请求。

select()示例

我们来看看下面的示例代码,虽然简单但对select()用法的说明却非常实用。在这个例子中,会阻塞等待stdin的输入,超时设置是5秒。由于只监视单个文件描述符,该示例不算I/O多路复用,但它很清晰地说明了如何使用系统调用:



用select()实现可移植的sleep功能

在各个UNIX系统中,相比微秒级的sleep功能,对select()的实现更普遍,因此select()调用常常被作为可移植的sleep实现机制:把所有三个集都设置NULL,超时值设置为非NULL。如下:

Linux提供了高精度的sleep机制。在第11章中,我们将详细说明它。

pselect()

select()系统调用很流行,它最初是在4.2BSD中引入的,但是POSIX标准在POSIX 1003.1g-2000和后来的POSIX 1003.1-2001中定义了自己的pselect()方法:



pselect()和select()存在三点区别:

timespec结构体定义如下:

把pselect()添加到UNIX工具箱的主要原因是为了增加sigmask参数,该参数是为了解决文件描述符和信号之间等待而出现竞争条件(在第10章将深入讨论信号)。假设信号处理程序设置了全局标志位(大部分都如此),进程每次调用select()之前会检查该标志位。现在,假定在检查标志位和调用之间收到信号,应用可能会一直阻塞,永远都不会响应该信号。pselect()提供了一组可阻塞信号,应用在调用时可以设置这些信号来解决这个问题。阻塞的信号要等到解除阻塞才会处理。一旦pselect()返回,内核就会恢复老的信号掩码。

在Linux内核2.6.16之前,pselect()还不是系统调用,而是由glibc提供的对select()调用的简单封装。该封装对出现竞争的风险最小化,但是并没有完全消除竞争。当真正引入了新的系统调用pselect()之后,才彻底解决了竞争问题。

虽然和select()相比,pselect()有一定的改进,但大多数应用还是使用select(),有的是出于习惯,也有的是为了更好的可移植性。

poll()系统调用是System V的I/O多路复用解决方案。它解决了一些select()的不足,不过select()还是被频繁使用(还是出于习惯或可移植性的考虑):

select()使用了基于文件描述符的三位掩码的解决方案,其效率不高;和它不同,poll()使用了由nfds个pollfd结构体构成的数组,fds指针指向该数组。pollfd结构体定义如下:

每个pollfd结构体指定一个被监视的文件描述符。可以给poll()传递多个pollfd结构体,使它能够监视多个文件描述符。每个结构体的events变量是要监视的文件描述符的事件的位掩码。用户可以设置该变量。revents变量是该文件描述符的结果事件的位掩码。内核在返回时会设置revents变量。events变量中请求的所有事件都可能在revents变量中返回。以下是合法的events值:

POLLIN

有数据可读。

POLLRDNORM

有普通数据可读。

POLLRDBAND

有优先数据可读。

POLLPRI

有高优先级数据可读。

POLLOUT

写操作不会阻塞。

POLLWRNORM

写普通数据不会阻塞。

POLLBAND

写优先数据不会阻塞。

POLLMSG

有SIGPOLL消息可用。

此外,revents变量可能会返回如下事件:

POLLER

给定的文件描述符出现错误。

POLLHUP

给定的文件描述符有挂起事件。

POLLNVAL

给定的文件描述符非法。

对于events变量,这些事件没有意义,events参数不要传递这些变量,它们会在revents变量中返回。poll()和select()不同,不需要显式请求异常报告。

POLLIN | POLLPRI等价于select()的读事件,而POLLOUT | POLLWRBAND等价于select()的写事件。POLLIN等价于POLLRDNORM | POLLRDBAND,而POLLOUT等价于POLLWRNORM。

举个例子,要监视某个文件描述符是否可读写,需要把events设置成POLLIN | POLLOUT。返回时,会检查revents中是否有相应的标志位。如果设置了POLLIN,文件描述符可非阻塞读;如果设置了POLLOUT,文件描述符可非阻塞写。标志位并不是相互排斥的:可以同时设置,表示可以在该文件描述符上读写,而且都不会阻塞。

timeout参数指定等待的时间长度,单位是毫秒,不论是否有I/O就绪,poll()调用都会返回。如果timeout值为负数,表示永远等待;timeout为0表示poll()调用立即返回,并给出所有I/O未就绪的文件描述符列表,不会等待更多事件。在这种情况下,poll()调用如同其名,轮询一次后立即返回。

返回值和错误码

poll()调用成功时,返回revents变量不为0的所有文件描述符个数;如果没有任何事件发生且未超时,返回0。失败时,返回-1,并相应设置errno值如下:

EBADF

一个或多个结构体中存在非法文件描述符。

EFAULT

fds指针指向的地址超出了进程地址空间。

EINTR

在请求事件发生前收到了一个信号,可以重新发起调用。

EINVAL

nfds参数超出了RLIMIT_NOFILE值。

ENOMEM

可用内存不足,无法完成请求。

poll()示例

我们一起来看一下poll()的示例程序,它同时检测stdin读和stdout写是否会发生阻塞:



运行后,生成结果如下(和期望一致):

再次运行,这次把一个文件重定向到标准输入,可以看到两个事件:

如果在实际应用中使用poll(),不需要在每次调用时都重新构建pollfd结构体。该结构体可能会被重复传递多次,内核会在必要时把revents清空。

ppoll()

类似于pselect()和select(),Linux也为poll()提供了ppoll()。然而,和pselect()不同,ppoll()是Linux特有的调用:

类似于pselect(),timeout参数指定的超时时间是秒和纳秒,sigmask参数提供了一组等待处理的信号。

虽然poll()和select()完成相同的工作,但poll()调用在很多方面仍然优于select()调用:

不过,select()系统调用也有些优点:

比起poll()调用和select()调用,epoll()调用更优,epoll()是Linux特有的I/O多路复用解决方案,我们将在第4章详细探讨。

这一节将介绍Linux内核如何实现I/O,重点说明内核的三个主要的子系统:虚拟文件系统(VFS)、页缓存(page cache)和页回写(page writeback)。通过这些子系统间的协作,Linux I/O看起来无缝运行且更加高效。

在第4章,我们将阐述第四个子系统,I/O调度器(I/O scheduler)。

虚拟文件系统,有时也称虚拟文件交换(virtual file switch),是一种抽象机制,支持Linux内核在不了解(甚至不需要了解)文件系统类型的情况下,调用文件系统函数并操作文件系统的数据。

虚拟文件系统通过通用文件模型(common file model)实现这种抽象,它是Linux上所有文件系统的基础。通过函数指针以及各种面向对象方法,通用文件模型提供了一种Linux内核文件系统必须遵循的框架。它支持虚拟文件系统向文件系统发起请求。框架提供了钩子(hook),支持读、建立链接、同步等功能。然后,每个文件系统注册函数,处理相应的操作。

这种方式的前提是文件系统之间必须具有一定的共性。比如,虚拟文件系统是基于索引节点、superblock(超级块)和目录项,而非UNIX系的文件系统可能根本就没有索引节点的概念,需要特殊处理。而事实上,Linux确实做到了:它可以很好地支持如FAT和NTFS这样的文件系统。

虚拟文件系统的优点真是“不胜数”:系统调用可以在任意媒介的任意文件系统上读,工具可以从任何一个文件系统拷贝到另一个上。所有的文件系统都支持相同的概念、相同接口和相同调用。一切都可以正常工作——而且工作得很好。

当应用发起read()系统调用时,其执行过程是非常奇妙的:从C库获取系统调用的定义,在编译时转化为相应的trap语句[8]。一旦进程从用户空间进入内核,交给系统调用handler处理,然后交给read()系统调用。内核确定给定的文件描述符所对应的对象类型,并调用相应的read()函数。对于文件系统而言,read()函数是文件系统代码的一部分。然后,该函数继续后续操作——比如从文件系统中读取数据,并把数据返回给用户空间的read()调用,该调用返回系统调用handler,它把数据拷贝到用户空间,最后read()系统调用返回,程序继续执行。

对于系统程序员来说,虚拟文件系统带来的“变革”是很深刻的。程序员不需要担心文件所在的文件系统及其存储介质。通用的系统调用——比如read()、write()等,可以在任意支持的文件系统和存储介质上操作文件。

页缓存是通过内存保存最近在磁盘文件系统上访问过的数据的一种方式。相对于当前的处理器速度而言,磁盘访问速度过慢。通过在内存中保存被请求数据,内核后续对相同数据的请求就可以直接从内存中读取,避免了重复访问磁盘。

页缓存利用了“时间局部性(temporal locality)”原理,它是一种“引用局部性(locality of reference)”,时间局部性的理念基础是认为刚被访问的资源在不久后再次被访问的概率很高。在第一次访问时对数据进行缓存,虽然消耗了内存,但避免了后续代价很高的磁盘访问。

内核查找文件系统数据时,会首先查找页缓存。只有在缓存中找不到数据时,内核才会调用存储子系统从磁盘读取数据。因此,第一次从磁盘中读取数据后,就会保存到页缓存中,应用在后续读取时都是直接从缓存中读取并返回。页缓存上的所有操作都是透明的,从而保证其数据总是有效的。

Linux页缓存大小是动态变化的。随着I/O操作把越来越多的数据存储到内存中,页缓存也变得越来越大,消耗掉空闲的内存。如果页缓存最终消耗掉了所有的空闲内存,而且有新的请求要求分配内存,页缓存就会被“裁剪”,释放它最少使用的页,让出空间给“真正的”内存使用。这种“裁剪”是无缝自动处理的。通过这种动态变化的缓存,Linux可以使用所有的系统内存,并缓存尽可能多的数据。

一般来说,把进程内存中很少使用的页缓存“交换(swap)”给磁盘要比清理经常使用的页缓存更有意义,因为如果清理掉经常使用的,下一次读请求时又得把它读到内存中。(交换支持内核在磁盘上存储数据,得到比机器RAM更大的内存空间。)Linux内核实现了一些启发式算法,来平衡交换数据和清理页缓存(以及其他驻存项)。这些启发式算法可能会决定通过交换数据到磁盘来替代清理页缓存,尤其当被交换的数据没有在使用时。

交换-缓存之间的平衡可以通过 /proc/sys/vm/swappiness 来调整。虚拟文件数可以是0到100,默认是60。值越大,表示优先保存页缓存,交换数据;值越小,表示优先清理页缓存,而不是交换数据。

另一种引用局部性是“空间局部性(sequential locality)”,其理论基础是认为数据往往是连续访问的[9]。基于这个原理,内核实现了页缓存预读技术。预读是指在每次读请求时,从磁盘数据中读取更多的数据到页缓存中——实际上,就是多读几个比特。当内核从磁盘中读取一块数据时,它还会读取接下来一两块数据。一次性读取较大的连续数据块会比较高效,因为通常不需要磁盘寻址。此外,由于在进程处理第一块读取到的数据时,内核可以完成预读请求。正如经常发生的那样,如果进程继续对接下来连续块提交新的读请求,内核就可以直接返回预读的数据,而不用再发起磁盘I/O请求。

和页缓存类似,内核对预读的管理也是动态变化的。如果内核发现一个进程一直使用预读的数据,它就会增加预读窗口,从而预读进更多的数据。预读窗口最小16KB,最大128KB。反之,如果内核发现预读没有带来任何有效命中——也就是说,应用随机读取文件,而不是连续读——它可以把预读完全关闭掉。

页缓存对程序员而言是透明的。一般来说,系统程序员无法优化代码以更好地利用页缓存机制——除非自己在用户空间实现这样一种缓存。通常情况下,要最大限度利用页缓存,唯一要做的就是代码实现高效。此外,如果代码高效,还可以利用预读机制。虽然并不总是如此,连续I/O访问通常还是远远多于随机访问。

正如2.3.6小节介绍的那样,内核通过缓冲区来延迟写操作。当进程发起写请求,数据被拷贝到缓冲区,并将该该缓冲区标记为“脏”缓冲区,这意味着内存中的拷贝要比磁盘上的新。此时,写请求就可以返回了。如果后续对同一份数据块有新的写请求,缓冲区就会更新成新的数据。对该文件中其他部分的写请求则会开辟新的缓冲区。

最后,那些“脏”缓冲区需要写到磁盘,将磁盘文件和内存数据同步。这个过程就是所谓的“回写(writeback)”。以下两个情况会触发回写:

回写是由一组称为flusher的内核线程来执行的。当出现以上两种场景之一时,flusher线程被唤醒,并开始将“脏”缓冲区写到磁盘,直到不满足回写的触发条件。

可能存在多个flusher线程同时执行回写。多线程是为了更好地利用并行性,避免拥塞。拥塞避免(congestion avoidance)机制确保在等待向某个块设备进行写操作时,还能够支持其他的写操作。如果其他块设备存在脏缓冲区,不同flusher线程会充分利用每一块设备。这种方式解决了之前内核的一处不足: 先前版本的flusher线程(pdflush以及bdflush)会一直等待某个块设备,而其他块设备却处于空闲状态。在现代计算机上,Linux内核可以使多个磁盘处于饱和状态。

缓冲区在内核中是通过buffer_head数据结构来表示的。该数据结构跟踪和缓冲区相关的各种元数据,比如缓冲区是否是“脏”缓冲区。同时,它还维护了一个指向真实数据的指针。这部分数据保存在页缓存中。通过这种方式,实现了缓冲子系统(buffer subsystem)和页缓存之间的统一。

在早期的Linux内核版本中(2.4以前),缓冲子系统与页缓存是分离的,因此同时存在页缓存和缓冲缓存。这意味着数据可以同时在缓冲缓存(作为“脏”缓冲区)和页缓存(用来缓存数据)中存在。不可避免地,对这两个独立缓存进行同步需要很多工作。因此,在2.4 Linux内核中引入了统一的页缓存,这个改进很不错!

在Linux中,延迟写和缓冲子系统使得写操作性能很高,其代价是如果电源出现故障,可能会丢失数据。为了避免这种风险,关键应用可以使用同步I/O来保证(在本章先前讨论过)。

本章讨论了Linux系统编程的基础:文件I/O。在Linux这样遵循一切皆文件的操作系统中,了解如何打开、读、写和关闭文件是非常重要的。所有这些操作都是传统的UNIX方式,很多标准都涵盖它们。

下一章将重点探讨缓冲I/O,以及标准C库的标准I/O接口。标准C库不仅仅是出于方便考虑,用户空间的缓冲I/O提供了关键的性能改进。

[1] 译注:快速用户空间互斥(fast userspace mutex,futex)是在linux上实现锁的基本对象。Futex的操作几乎全部在用户空间完成,可以避免锁竞争,执行效率非常高。参考[http://en.wikipedia.org/wiki/Futex](http://en.wikipedia.org/wiki/Futex)。

[2] 对应的挂载选项是bsdgroups或sysvgroups。

[3] 前面提过,系统架构是基于体系结构定义的。因此,虽然x86-64有系统调用creat(),Alpha没有。当然,可以在任何体系结构上调用creat(),但是在某些体系结构上,creat()可能是个库函数,而不是系统调用。

[4] 还是需要提一下:硬盘可能会“撒谎”,通知内核缓冲区已经写到磁盘上了,而实际上它们还在磁盘的缓存中。

[5] 对于那些编写GUI应用的人来说,对于主循环(Mainloop)应该并不陌生——比如GNOME应用使用了其基础库GLib提供的一个主循环。主循环支持监控多个事件,并从单个阻塞点响应。

[6] 这是因为select()操作和poll()操作都是条件触发(level-triggered),而不是边缘触发(edge-triggered)(译者注:条件触发是指当条件满足时发生一个I/O事件,边缘触发是指当状态改变时发生一个I/O事件。我们将在第4章探讨的epoll()这两种模式都支持。边缘触发更简单,但是不注意的话很容易丢失I/O事件)。

[7] 如果位掩码是稀疏的,组成该掩码的每个值都可以用0来检测;只有当检测返回失败时,才需要对每个位进行检测。但是,如果掩码不是稀疏的,这项工作就很耗资源。

[8] 译注:trap语句是用于指定在接收到信号后要采取的操作。

[9] 译注:即如果某个存储单元被访问,它附近的存储单元很可能也会很快被访问。


相关图书

Linux常用命令自学手册
Linux常用命令自学手册
庖丁解牛Linux操作系统分析
庖丁解牛Linux操作系统分析
Linux后端开发工程实践
Linux后端开发工程实践
轻松学Linux:从Manjaro到Arch Linux
轻松学Linux:从Manjaro到Arch Linux
Linux高性能网络详解:从DPDK、RDMA到XDP
Linux高性能网络详解:从DPDK、RDMA到XDP
跟老韩学Linux架构(基础篇)
跟老韩学Linux架构(基础篇)

相关文章

相关课程