首页资源分类嵌入式系统 > Linux_C编程一站式学习.pdf

Linux_C编程一站式学习.pdf

已有 445464个资源

下载专区

上传者其他资源

    文档信息举报收藏

    标    签:LinuxC编程

    分    享:

    文档简介

    Linux_C编程一站式学习

    入门嵌入式linux的第一本书,简明易懂

    文档预览

    Linux C编程一站式学习 下一页 Linux C编程一站式学习 宋劲杉 北京亚嵌教育研究中心 版权 © 2008, 2009 宋劲杉, 北京亚嵌教育研究中心 Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with the Invariant Sections being 前言, with no Front-Cover Texts, and no Back-Cover Texts. A copy of the license is included in 附录 C, GNU Free Documentation License Version 1.3, 3 November 2008. 2009.5.4 修订历史 修订 0.6 2009.2.27 添加了GFDL许可证,正式网络发布。第三部分还很粗糙,错误也有不少,有待改进。第一部 分和第二部分已经比较成熟,第二部分还差三章没写。 修订 0.7 2009.4.24 全书的章节基本完成,但有些章节还很不完善。 目录 历史 前言 I. C语言入门 1. 程序的基本概念 1. 程序和编程语言 2. 自然语言和形式语言 3. 程序的调试 4. 第一个程序 2. 常量、变量和表达式 1. 继续Hello World 2. 常量 3. 变量 4. 赋值 5. 表达式 6. 字符类型与字符编码 3. 简单函数 1. 数学函数 2. 自定义函数 3. 形参和实参 4. 局部变量与全局变量 4. 分支语句 1. if语句 2. if/else语句 3. 布尔代数 4. switch语句 5. 深入理解函数 1. return语句 2. 增量式开发 3. 递归 6. 循环语句 1. while语句 2. do/while语句 3. for语句 4. break和continue语句 5. 嵌套循环 6. goto语句 7. 结构体 1. 复合数据类型--结构体 2. 数据抽象 3. 数据类型标志 4. 嵌套结构体 8. 数组 1. 数组的基本操作 2. 数组应用实例:统计随机数 3. 数组应用实例:直方图 4. 字符串 5. 多维数组 9. 编码风格 1. 缩进和空白 2. 注释 3. 标识符命名 4. 函数 5. indent工具 10. gdb 1. 单步执行和跟踪函数调用 2. 断点 3. 观察点 4. 段错误 11. 排序与查找 1. 算法的概念 2. 插入排序 3. 算法的时间复杂度分析 4. 归并排序 5. 线性查找 6. 折半查找 12. 栈与队列 1. 数据结构的概念 2. 堆栈 3. 深度优先搜索 4. 队列与广度优先搜索 5. 环形队列 13. 本阶段总结 1. C语言基本语法 2. 思维方法与编程思想 3. 调试方法 II. C语言本质 14. 计算机中数的表示 1. 为什么计算机用二进制计数 2. 不同进制之间的换算 3. 整数的加减运算 4. 浮点数 15. 数据类型详解 1. 整型 2. 浮点型 3. 类型转换 3.1. Integer Promotion 3.2. Usual Arithmetic Conversion 3.3. 由赋值产生的类型转换 3.4. 强制类型转换 3.5. 编译器如何处理类型转换 16. 运算符详解 1. 位运算 1.1. 按位与、或、异或、取反运算 1.2. 移位运算 1.3. 掩码 1.4. 异或运算的一些特性 2. 其它运算符 2.1. 复合赋值运算符 2.2. 条件运算符 2.3. 逗号运算符 2.4. sizeof运算符与typedef类型声明 3. Side Effect与Sequence Point 4. 运算符总结 17. 计算机体系结构基础 1. 内存与地址 2. CPU 3. 设备 4. MMU 5. Memory Hierarchy 18. x86汇编程序基础 1. 最简单的汇编程序 2. x86的寄存器 3. 第二个汇编程序 4. 寻址方式 5. ELF文件 5.1. 目标文件 5.2. 可执行文件 19. 汇编与C之间的关系 1. 函数调用 2. main函数和启动例程 3. 变量的存储布局 4. 结构体和联合体 5. C内联汇编 6. volatile限定符 20. 链接详解 1. 多目标文件的链接 2. 定义和声明 2.1. extern和static关键字 2.2. 头文件 2.3. 定义和声明的详细规则 3. 静态库 4. 共享库 4.1. 编译、链接、运行 4.2. 动态链接的过程 4.3. 共享库的命名惯例 5. 虚拟内存管理 21. 预处理 1. 预处理的步骤 2. 宏定义 2.1. 函数式宏定义 2.2. 内联函数 2.3. #、##运算符和可变参数 2.4. 宏展开的步骤 3. 条件预处理指示 4. 其它预处理特性 22. Makefile基础 1. 基本规则 2. 隐含规则和模式规则 3. 变量 4. 自动处理头文件的依赖关系 5. 常用的make命令行选项 23. 指针 1. 指针的基本操作 2. 指针类型的参数和返回值 3. 指针与数组 4. 指针与const限定符 5. 指针与结构体 6. 指向指针的指针与指针数组 7. 指向数组的指针与多维数组 8. 函数类型和函数指针类型 9. 不完全类型和复杂声明 24. 函数接口 1. 本章的预备知识 1.1. strcpy与strncpy 1.2. malloc与free 2. 传入参数与传出参数 3. 两层指针的参数 4. 返回值是指针的情况 5. 回调函数 6. 可变参数 25. C标准库 1. 字符串操作函数 1.1. 初始化字符串 1.2. 取字符串的长度 1.3. 拷贝字符串 1.4. 连接字符串 1.5. 比较字符串 1.6. 搜索字符串 1.7. 分割字符串 2. 标准I/O库函数 2.1. 文件的基本概念 2.2. fopen/fclose 2.3. stdin/stdout/stderr 2.4. errno与perror函数 2.5. 以字节为单位的I/O函数 2.6. 操作读写位置的函数 2.7. 以字符串为单位的I/O函数 2.8. 以记录为单位的I/O函数 2.9. 格式化I/O函数 2.10. C标准库的I/O缓冲区 3. 数值字符串转换函数 4. 分配内存的函数 5. 本章综合练习 26. 链表、二叉树和哈希表 1. 链表 1.1. 单链表 1.2. 双向链表 1.3. 静态链表 2. 二叉树 2.1. 二叉树的基本概念 2.2. 排序二叉树 3. 哈希表 27. 本阶段总结 III. Linux系统编程 28. 文件与I/O 1. 汇编程序的Hello world 2. C标准I/O库函数与Unbuffered I/O函数 3. open/close 4. read/write 5. lseek 6. fcntl 7. ioctl 8. mmap 29. 文件系统 1. 引言 2. ext2文件系统 2.1. 总体存储布局 2.2. 实例剖析 2.3. 数据块寻址 2.4. 文件和目录操作的系统函数 3. VFS 3.1. 内核数据结构 3.2. dup和dup2函数 30. 进程 1. 引言 2. 环境变量 3. 进程控制 3.1. fork函数 3.2. exec函数 3.3. wait和waitpid函数 4. 进程间通信 4.1. 管道 4.2. 其它IPC机制 5. 练习:实现简单的Shell 31. Shell脚本 1. Shell的历史 2. Shell如何执行命令 2.1. 执行交互式命令 2.2. 执行脚本 3. Shell的基本语法 3.1. 变量 3.2. 文件名代换(Globbing):* ? [] 3.3. 命令代换:`或 $() 3.4. 算术代换:$(()) 3.5. 转义字符\ 3.6. 单引号 3.7. 双引号 4. bash启动脚本 4.1. 作为交互登录Shell启动,或者使用--login参数启动 4.2. 以交互非登录Shell启动 4.3. 非交互启动 4.4. 以sh命令启动 5. Shell脚本语法 5.1. 条件测试:test [ 5.2. if/then/elif/else/fi 5.3. case/esac 5.4. for/do/done 5.5. while/do/done 5.6. 位置参数和特殊变量 5.7. 函数 6. Shell脚本的调试方法 32. 正则表达式 1. 引言 2. 基本语法 3. sed 4. awk 5. 练习:在C语言中使用正则表达式 33. 信号 1. 信号的基本概念 2. 产生信号 2.1. 通过终端按键产生信号 2.2. 调用系统函数向进程发信号 2.3. 由软件条件产生信号 3. 阻塞信号 3.1. 信号在内核中的表示 3.2. 信号集操作函数 3.3. sigprocmask 3.4. sigpending 4. 捕捉信号 4.1. 内核如何实现信号的捕捉 4.2. sigaction 4.3. pause 4.4. 可重入函数 4.5. sig_atomic_t类型与volatile限定符 4.6. 竞态条件与sigsuspend函数 4.7. 关于SIGCHLD信号 34. 终端、作业控制与守护进程 1. 终端 1.1. 终端的基本概念 1.2. 终端登录过程 1.3. 网络登录过程 2. 作业控制 2.1. Session与进程组 2.2. 与作业控制有关的信号 3. 守护进程 35. 线程 1. 线程的概念 2. 线程控制 2.1. 创建线程 2.2. 终止线程 3. 线程间同步 3.1. mutex 3.2. Condition Variable 3.3. Semaphore 3.4. 其它线程间同步机制 4. 编程练习 36. TCP/IP协议基础 1. TCP/IP协议栈与数据包封装 2. 以太网(RFC 894)帧格式 3. ARP数据报格式 4. IP数据报格式 5. IP地址与路由 6. UDP段格式 7. TCP协议 7.1. 段格式 7.2. 通讯时序 7.3. 流量控制 37. socket编程 1. 预备知识 1.1. 网络字节序 1.2. socket地址的数据类型及相关函数 2. 基于TCP协议的网络程序 2.1. 最简单的TCP网络程序 2.2. 错误处理与读写控制 2.3. 把client改为交互式输入 2.4. 使用fork并发处理多个client的请求 2.5. setsockopt 2.6. 使用select 3. 基于UDP协议的网络程序 4. UNIX Domain Socket IPC 5. 练习:实现简单的Web服务器 5.1. 基本HTTP协议 5.2. 执行CGI程序 A. 字符编码 1. ASCII码 2. Unicode和UTF-8 3. 在Linux C编程中使用Unicode和UTF-8 B. 编译开发工具小结 1. gcc常用选项 2. gcc常见错误信息 3. binutils常用命令 C. GNU Free Documentation License Version 1.3, 3 November 2008 参考书目 索引 下一页 历史 上一页 历史 下一页 历史 本书改编和包含了以下两本书的部分章节,这两本书均以GNU Free Documentation License发 布。 How To Think Like A Computer Scientist: Learning with C++ 作者Allen B. Downey。原书由Green Tea Press发行,可以 从http://www.greenteapress.com/下载到。 Programming from the Ground Up: An Introduction to Programming using Linux Assembly Language 作者Jonathan Bartlett。原书由Bartlett Publishing发行,可以 从http://savannah.nongnu.org/projects/pgubook/下载到。 上一页 Linux C编程一站式学习 起始页 下一页 前言 上一页 附录 C. GNU Free Documentation License Version 1.3, 3 November 2008 下一页 附录 C. GNU Free Documentation License Version 1.3, 3 November 2008 Copyright © 2000, 2001, 2002, 2007, 2008 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. 0. PREAMBLE The purpose of this License is to make a manual, textbook, or other functional and useful document "free" in the sense of freedom: to assure everyone the effective freedom to copy and redistribute it, with or without modifying it, either commercially or noncommercially. Secondarily, this License preserves for the author and publisher a way to get credit for their work, while not being considered responsible for modifications made by others. This License is a kind of "copyleft", which means that derivative works of the document must themselves be free in the same sense. It complements the GNU General Public License, which is a copyleft license designed for free software. We have designed this License in order to use it for manuals for free software, because free software needs free documentation: a free program should come with manuals providing the same freedoms that the software does. But this License is not limited to software manuals; it can be used for any textual work, regardless of subject matter or whether it is published as a printed book. We recommend this License principally for works whose purpose is instruction or reference. 1. APPLICABILITY AND DEFINITIONS This License applies to any manual or other work, in any medium, that contains a notice placed by the copyright holder saying it can be distributed under the terms of this License. Such a notice grants a world-wide, royalty-free license, unlimited in duration, to use that work under the conditions stated herein. The "Document", below, refers to any such manual or work. Any member of the public is a licensee, and is addressed as "you". You accept the license if you copy, modify or distribute the work in a way requiring permission under copyright law. A "Modified Version" of the Document means any work containing the Document or a portion of it, either copied verbatim, or with modifications and/or translated into another language. A "Secondary Section" is a named appendix or a front-matter section of the Document that deals exclusively with the relationship of the publishers or authors of the Document to the Document's overall subject (or to related matters) and contains nothing that could fall directly within that overall subject. (Thus, if the Document is in part a textbook of mathematics, a Secondary Section may not explain any mathematics.) The relationship could be a matter of historical connection with the subject or with related matters, or of legal, commercial, philosophical, ethical or political position regarding them. The "Invariant Sections" are certain Secondary Sections whose titles are designated, as being those of Invariant Sections, in the notice that says that the Document is released under this License. If a section does not fit the above definition of Secondary then it is not allowed to be designated as Invariant. The Document may contain zero Invariant Sections. If the Document does not identify any Invariant Sections then there are none. The "Cover Texts" are certain short passages of text that are listed, as Front-Cover Texts or Back-Cover Texts, in the notice that says that the Document is released under this License. A Front-Cover Text may be at most 5 words, and a Back-Cover Text may be at most 25 words. A "Transparent" copy of the Document means a machinereadable copy, represented in a format whose specification is available to the general public, that is suitable for revising the document straightforwardly with generic text editors or (for images composed of pixels) generic paint programs or (for drawings) some widely available drawing editor, and that is suitable for input to text formatters or for automatic translation to a variety of formats suitable for input to text formatters. A copy made in an otherwise Transparent file format whose markup, or absence of markup, has been arranged to thwart or discourage subsequent modification by readers is not Transparent. An image format is not Transparent if used for any substantial amount of text. A copy that is not "Transparent" is called "Opaque". Examples of suitable formats for Transparent copies include plain ASCII without markup, Texinfo input format, LaTeX input format, SGML or XML using a publicly available DTD, and standard-conforming simple HTML, PostScript or PDF designed for human modification. Examples of transparent image formats include PNG, XCF and JPG. Opaque formats include proprietary formats that can be read and edited only by proprietary word processors, SGML or XML for which the DTD and/or processing tools are not generally available, and the machine-generated HTML, PostScript or PDF produced by some word processors for output purposes only. The "Title Page" means, for a printed book, the title page itself, plus such following pages as are needed to hold, legibly, the material this License requires to appear in the title page. For works in formats which do not have any title page as such, "Title Page" means the text near the most prominent appearance of the work's title, preceding the beginning of the body of the text. The "publisher" means any person or entity that distributes copies of the Document to the public. A section "Entitled XYZ" means a named subunit of the Document whose title either is precisely XYZ or contains XYZ in parentheses following text that translates XYZ in another language. (Here XYZ stands for a specific section name mentioned below, such as "Acknowledgements", "Dedications", "Endorsements", or "History".) To "Preserve the Title" of such a section when you modify the Document means that it remains a section "Entitled XYZ" according to this definition. The Document may include Warranty Disclaimers next to the notice which states that this License applies to the Document. These Warranty Disclaimers are considered to be included by reference in this License, but only as regards disclaiming warranties: any other implication that these Warranty Disclaimers may have is void and has no effect on the meaning of this License. 2. VERBATIM COPYING You may copy and distribute the Document in any medium, either commercially or noncommercially, provided that this License, the copyright notices, and the license notice saying this License applies to the Document are reproduced in all copies, and that you add no other conditions whatsoever to those of this License. You may not use technical measures to obstruct or control the reading or further copying of the copies you make or distribute. However, you may accept compensation in exchange for copies. If you distribute a large enough number of copies you must also follow the conditions in section 3. You may also lend copies, under the same conditions stated above, and you may publicly display copies. 3. COPYING IN QUANTITY If you publish printed copies (or copies in media that commonly have printed covers) of the Document, numbering more than 100, and the Document's license notice requires Cover Texts, you must enclose the copies in covers that carry, clearly and legibly, all these Cover Texts: Front-Cover Texts on the front cover, and BackCover Texts on the back cover. Both covers must also clearly and legibly identify you as the publisher of these copies. The front cover must present the full title with all words of the title equally prominent and visible. You may add other material on the covers in addition. Copying with changes limited to the covers, as long as they preserve the title of the Document and satisfy these conditions, can be treated as verbatim copying in other respects. If the required texts for either cover are too voluminous to fit legibly, you should put the first ones listed (as many as fit reasonably) on the actual cover, and continue the rest onto adjacent pages. If you publish or distribute Opaque copies of the Document numbering more than 100, you must either include a machine-readable Transparent copy along with each Opaque copy, or state in or with each Opaque copy a computer-network location from which the general network-using public has access to download using publicstandard network protocols a complete Transparent copy of the Document, free of added material. If you use the latter option, you must take reasonably prudent steps, when you begin distribution of Opaque copies in quantity, to ensure that this Transparent copy will remain thus accessible at the stated location until at least one year after the last time you distribute an Opaque copy (directly or through your agents or retailers) of that edition to the public. It is requested, but not required, that you contact the authors of the Document well before redistributing any large number of copies, to give them a chance to provide you with an updated version of the Document. 4. MODIFICATIONS You may copy and distribute a Modified Version of the Document under the conditions of sections 2 and 3 above, provided that you release the Modified Version under precisely this License, with the Modified Version filling the role of the Document, thus licensing distribution and modification of the Modified Version to whoever possesses a copy of it. In addition, you must do these things in the Modified Version: * A. Use in the Title Page (and on the covers, if any) a title distinct from that of the Document, and from those of previous versions (which should, if there were any, be listed in the History section of the Document). You may use the same title as a previous version if the original publisher of that version gives permission. * B. List on the Title Page, as authors, one or more persons or entities responsible for authorship of the modifications in the Modified Version, together with at least five of the principal authors of the Document (all of its principal authors, if it has fewer than five), unless they release you from this requirement. * C. State on the Title page the name of the publisher of the Modified Version, as the publisher. * D. Preserve all the copyright notices of the Document. * E. Add an appropriate copyright notice for your modifications adjacent to the other copyright notices. * F. Include, immediately after the copyright notices, a license notice giving the public permission to use the Modified Version under the terms of this License, in the form shown in the Addendum below. * G. Preserve in that license notice the full lists of Invariant Sections and required Cover Texts given in the Document's license notice. * H. Include an unaltered copy of this License. * I. Preserve the section Entitled "History", Preserve its Title, and add to it an item stating at least the title, year, new authors, and publisher of the Modified Version as given on the Title Page. If there is no section Entitled "History" in the Document, create one stating the title, year, authors, and publisher of the Document as given on its Title Page, then add an item describing the Modified Version as stated in the previous sentence. * J. Preserve the network location, if any, given in the Document for public access to a Transparent copy of the Document, and likewise the network locations given in the Document for previous versions it was based on. These may be placed in the "History" section. You may omit a network location for a work that was published at least four years before the Document itself, or if the original publisher of the version it refers to gives permission. * K. For any section Entitled "Acknowledgements" or "Dedications", Preserve the Title of the section, and preserve in the section all the substance and tone of each of the contributor acknowledgements and/or dedications given therein. * L. Preserve all the Invariant Sections of the Document, unaltered in their text and in their titles. Section numbers or the equivalent are not considered part of the section titles. * M. Delete any section Entitled "Endorsements". Such a section may not be included in the Modified Version. * N. Do not retitle any existing section to be Entitled "Endorsements" or to conflict in title with any Invariant Section. * O. Preserve any Warranty Disclaimers. If the Modified Version includes new front-matter sections or appendices that qualify as Secondary Sections and contain no material copied from the Document, you may at your option designate some or all of these sections as invariant. To do this, add their titles to the list of Invariant Sections in the Modified Version's license notice. These titles must be distinct from any other section titles. You may add a section Entitled "Endorsements", provided it contains nothing but endorsements of your Modified Version by various parties—for example, statements of peer review or that the text has been approved by an organization as the authoritative definition of a standard. You may add a passage of up to five words as a Front-Cover Text, and a passage of up to 25 words as a Back-Cover Text, to the end of the list of Cover Texts in the Modified Version. Only one passage of Front-Cover Text and one of Back-Cover Text may be added by (or through arrangements made by) any one entity. If the Document already includes a cover text for the same cover, previously added by you or by arrangement made by the same entity you are acting on behalf of, you may not add another; but you may replace the old one, on explicit permission from the previous publisher that added the old one. The author(s) and publisher(s) of the Document do not by this License give permission to use their names for publicity for or to assert or imply endorsement of any Modified Version. 5. COMBINING DOCUMENTS You may combine the Document with other documents released under this License, under the terms defined in section 4 above for modified versions, provided that you include in the combination all of the Invariant Sections of all of the original documents, unmodified, and list them all as Invariant Sections of your combined work in its license notice, and that you preserve all their Warranty Disclaimers. The combined work need only contain one copy of this License, and multiple identical Invariant Sections may be replaced with a single copy. If there are multiple Invariant Sections with the same name but different contents, make the title of each such section unique by adding at the end of it, in parentheses, the name of the original author or publisher of that section if known, or else a unique number. Make the same adjustment to the section titles in the list of Invariant Sections in the license notice of the combined work. In the combination, you must combine any sections Entitled "History" in the various original documents, forming one section Entitled "History"; likewise combine any sections Entitled "Acknowledgements", and any sections Entitled "Dedications". You must delete all sections Entitled "Endorsements". 6. COLLECTIONS OF DOCUMENTS You may make a collection consisting of the Document and other documents released under this License, and replace the individual copies of this License in the various documents with a single copy that is included in the collection, provided that you follow the rules of this License for verbatim copying of each of the documents in all other respects. You may extract a single document from such a collection, and distribute it individually under this License, provided you insert a copy of this License into the extracted document, and follow this License in all other respects regarding verbatim copying of that document. 7. AGGREGATION WITH INDEPENDENT WORKS A compilation of the Document or its derivatives with other separate and independent documents or works, in or on a volume of a storage or distribution medium, is called an "aggregate" if the copyright resulting from the compilation is not used to limit the legal rights of the compilation's users beyond what the individual works permit. When the Document is included in an aggregate, this License does not apply to the other works in the aggregate which are not themselves derivative works of the Document. If the Cover Text requirement of section 3 is applicable to these copies of the Document, then if the Document is less than one half of the entire aggregate, the Document's Cover Texts may be placed on covers that bracket the Document within the aggregate, or the electronic equivalent of covers if the Document is in electronic form. Otherwise they must appear on printed covers that bracket the whole aggregate. 8. TRANSLATION Translation is considered a kind of modification, so you may distribute translations of the Document under the terms of section 4. Replacing Invariant Sections with translations requires special permission from their copyright holders, but you may include translations of some or all Invariant Sections in addition to the original versions of these Invariant Sections. You may include a translation of this License, and all the license notices in the Document, and any Warranty Disclaimers, provided that you also include the original English version of this License and the original versions of those notices and disclaimers. In case of a disagreement between the translation and the original version of this License or a notice or disclaimer, the original version will prevail. If a section in the Document is Entitled "Acknowledgements", "Dedications", or "History", the requirement (section 4) to Preserve its Title (section 1) will typically require changing the actual title. 9. TERMINATION You may not copy, modify, sublicense, or distribute the Document except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, or distribute it is void, and will automatically terminate your rights under this License. However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, receipt of a copy of some or all of the same material does not give you any rights to use it. 10. FUTURE REVISIONS OF THIS LICENSE The Free Software Foundation may publish new, revised versions of the GNU Free Documentation License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. See http://www.gnu.org/copyleft/. Each version of the License is given a distinguishing version number. If the Document specifies that a particular numbered version of this License "or any later version" applies to it, you have the option of following the terms and conditions either of that specified version or of any later version that has been published (not as a draft) by the Free Software Foundation. If the Document does not specify a version number of this License, you may choose any version ever published (not as a draft) by the Free Software Foundation. If the Document specifies that a proxy can decide which future versions of this License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Document. 11. RELICENSING "Massive Multiauthor Collaboration Site" (or "MMC Site") means any World Wide Web server that publishes copyrightable works and also provides prominent facilities for anybody to edit those works. A public wiki that anybody can edit is an example of such a server. A "Massive Multiauthor Collaboration" (or "MMC") contained in the site means any set of copyrightable works thus published on the MMC site. "CC-BY-SA" means the Creative Commons Attribution-Share Alike 3.0 license published by Creative Commons Corporation, a not-for-profit corporation with a principal place of business in San Francisco, California, as well as future copyleft versions of that license published by that same organization. "Incorporate" means to publish or republish a Document, in whole or in part, as part of another Document. An MMC is "eligible for relicensing" if it is licensed under this License, and if all works that were first published under this License somewhere other than this MMC, and subsequently incorporated in whole or in part into the MMC, (1) had no cover texts or invariant sections, and (2) were thus incorporated prior to November 1, 2008. The operator of an MMC Site may republish an MMC contained in the site under CC-BY-SA on the same site at any time before August 1, 2009, provided the MMC is eligible for relicensing. ADDENDUM: How to use this License for your documents To use this License in a document you have written, include a copy of the License in the document and put the following copyright and license notices just after the title page: Copyright (C) YEAR YOUR NAME. Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. A copy of the license is included in the section entitled "GNU Free Documentation License". If you have Invariant Sections, Front-Cover Texts and Back-Cover Texts, replace the "with … Texts." line with this: with the Invariant Sections being LIST THEIR TITLES, with the Front-Cover Texts being LIST, and with the Back-Cover Texts being LIST. If you have Invariant Sections without Cover Texts, or some other combination of the three, merge those two alternatives to suit the situation. If your document contains nontrivial examples of program code, we recommend releasing these examples in parallel under your choice of free software license, such as the GNU General Public License, to permit their use in free software. 上一页 3. binutils常用命令 起始页 下一页 参考书目 上一页 前言 下一页 前言 这本书有什么特点?面向什么样的读者? 这本书最初是为北京亚嵌教育研究中心的嵌入式Linux系统工程师就业班课程量身定做的教材之 一。该课程是为期四个月的全日制职业培训,要求学员毕业时具备非常Solid的C编程能力,能熟 练地使用Linux系统,同时对计算机体系结构与指令集、操作系统原理和设备驱动程序都有较深 入的了解。然而学员入学时的水平是非常初级而且参差不齐的:学历有专科、本科也有研究生, 专业有和计算机相关的也有很不相关的(例如会计专业),以前从事的职业有和技术相关的也有 完全不相关的(例如HR),年龄从二十出头到三十五六岁的都有。这么多背景完全不同、基础 完全不同、思维习惯和理解能力完全不同的人来听同一堂课,大家都迫切希望学会嵌入式开发技 术,投身IT行业,这就是职业教育的特点,也是我编这本书时需要考虑的主要问题。 学习编程绝不是一件简单的事,尤其是对于零基础的初学者来说。大学的计算机专业有四年时间 从零基础开始培养一个人,微积分、线代、随机、离散、组合、自动机、编译原理、操作系统、 计算机组成原理等等一堆基础课,再加上C/C++、Java、数据库、网络、软件工程、计算机图形 学等等一堆专业课,最后培养出一个能找到工作的学生。很遗憾这最后一条很多学校没有做好, 来亚嵌培训的很多学生就是四年这么学过来的,但据我们考查他们的基础几乎为零,我不知道为 什么。与之形成鲜明对比的是,只给我们四个月的时间,同样要求从零基础开始,最后培养出一 个能找到工作的学生,而且还要保证他找到工作,这就是职业教育的特点。 为什么我说“只给我们四个月的时间”?我们倒是想教四年呢,但学时的长短我们做不了主,是由 市场规律决定的。四年的任务要求四个月做好,要怎么完成这样一个几乎不可能的任务?有些职 业教育给出的答案是“实用主义”,打出了“有用就学,没有用就不学”的口号,大肆贬低说大学里 教的基础课都是过时的、无用的,只有他们教的技术才是实用的,这种炒作很不好,我认为大学 里教的每一门课都是非常有用的,基础知识在任何时候都不会过时,倒是那些时髦的“实用技 术”有可能很快就过时了。 四年的任务怎么才能用四个月做好?我们给出的答案是“优化”。现在大学里安排的课程体系最大 的缺点就是根本不考虑优化。每个过来人都会有这样的感觉:大一大二学了好多数学课,却不知 道都是干什么用的,为什么要学。连它有什么用都不知道怎么能有兴趣学好呢?然后到大三大四 学专业课时,用到以前的知识了,才发现以前学的数学是多么有用,然而早就忘得一干二净了, 考完试都还给老师了,回头重新学吧,这时候才发现很多东西以前根本没学明白,现在才真的学 明白了,那么前两年的时间岂不是都浪费了?大学里的课程体系还有一个缺点就是不灵活,每门 课必须占一个学期,必须由一个老师教,不同课程的老师之间没有任何沟通和衔接,其实这些课 程之间是相互依赖的,把它们强行拆开是不符合人的认知规律的。比如我刚上大学的时候,大一 上半学期就被逼着学C语言,其实C语言是一门很难的编程语言,不懂编译原理、操作系统和计 算机体系结构根本不可能学明白,那半个学期自然就浪费掉了。当时几乎所有学校的计算机相关 专业都是这样,大一上来就学C语言,有的学校更疯狂,上来就学C++,导致大多数学生都以为 自己会C语言,但其实都是半吊子水平,到真正写代码的时候经常为一个Bug搞得焦头烂额,却 没有机会再系统地学一遍C语言,因为在学校看来,C语言课早在大一就给你“上完了”,就像一 顿饭已经吃完了,不管你吃饱没吃饱,不会再让你重吃一遍了。显而易见,如果要认真地对这些 课程做优化,的确是有很多水份可以挤的。 本书有以下特点: 不是孤立地讲C语言,而是和编译原理、操作系统、计算机体系结构结合起来讲。或者 说,本书的内容只是以C语言为载体,真正讲的是计算机的原理和程序的原理。 强调基本概念和基本原理,在编排顺序上非常重视概念之间的依赖关系,每次引入一个新 的概念,只依赖于前面章节已经讲过的概念,而绝不会依赖后面章节要讲的概念。有些地 方为了叙述得完整,也会引用后面要讲的内容,比如说“有关XX我们到XX章再仔细讲解”, 凡是这种引用都不是必要的依赖,可以当它不存在,只管继续往下看就行了。 尽量做到每个知识点直到要用的时候才引入。过早引入一个知识点,讲完了又不用它,读 者很快就会遗忘,这是不符合认知规律的。 这是一本从零基础开始学习编程的书,不要求读者有任何编程经验,但读者至少需要具备以下素 质: 熟悉Linux系统的基本操作。如果不具备这一点,请先参考其它教材学习Linux系统的基本 操作,熟练之后再学习本书,《鸟哥的Linux私房菜》据说是Linux系统管理和应用方面比 较好的一本书。但学习本书并不需要会很多系统管理技术,只要会用基本命令,会自己安 装系统和常用软件就足够了。 具有高中毕业的数学水平。本书会用到高中的数学知识,事实上,如果不具有高中毕业的 数学水平,也不必考虑做程序员了。但并不是说只要具有高中毕业的数学水平就足够做程 序员了,只能说看这本书应该没有问题,数学是程序员最重要的修养,计算机科学其实就 是数学的一个分支,如果你的数学功底很差,日后还需恶补一下。 具有高中毕业的英文水平。理由同上。 最重要的是对计算机的原理和本质深感兴趣,不是为就业而学习,不是为拿高薪而学习, 而是真的感兴趣,想把一切来龙去脉搞得清清楚楚而学习。 勤于思考。本书尽最大努力理清概念之间的依赖关系,力求一站式学习,读者不需要为了 找一个概念的定义去翻其它书,也不需要为了搞清楚一个概念在本书中前后一通乱翻,只 需从前到后按顺序学习即可。但一站式学习并不等于傻瓜式学习,有些章节有一定的难 度,需要积极思考才能领会。本书可以替你节省时间,但不能替你思考,不要指望像看小 说一样走马观花看一遍就能学会。 又是一本C语言书。好吧,为什么我要学这本书而不是谭浩强或 者K&R? 谭浩强的书我就不说什么了。居然教学生include一个.c文件。 K&R是公认的世界上最经典的C语言教程,这点毫无疑问。在C标准出台之前,K&R第一版就是 事实上的C标准。C89标准出台之后,K&R跟着标准推出了第二版,可惜此后就没有更新过了, 所以不能反映C89之后C语言的发展以及最新的C99标准,本书在这方面做了很多补充。上面我 说过了,这本书与其说是讲C语言,不如说是以C语言为载体讲计算机和操作系统的原理, 而K&R就是为了讲C语言而讲C语言,侧重点不同,内容编排也很不相同。K&R写得非常好,代 码和语言都非常简洁,但很可惜,只有会C语言的人才懂得欣赏它,K&R是非常不适合入门学习 的,尤其不适合零基础的学生入门学习。 这本书“是什么”和“不是什么” 本书包括三大部分: C语言入门。介绍基本的C语法,帮助没有任何编程经验的读者理解什么是程序,怎么写程 序,培养程序员的思维习惯,找到编程的感觉。前半部分改编自[ThinkCpp]。 C语言本质。结合计算机和操作系统的原理讲解C程序是怎么编译、链接、运行的,同时全 面介绍C的语法。位运算的章节改编自亚嵌教育林小竹老师的讲义,链表和二叉树的章节 改编自亚嵌教育朱老师的讲义。汇编语言的章节改编自[GroudUp],在这本书的最后一章 提到,学习编程有两种Approach,一种是Bottom Up,一种是Top Down,各有优缺点,需 要两者结合起来。所以我编这本书的思路是,第一部分Top Down,第二部分Bottom Up, 第三部分可以算填了中间的空隙,三部分全都围绕C语言展开。 Linux系统编程。介绍各种Linux系统函数和内核的工作原理。Socket编程的章节改编自亚 嵌教育卫剑钒老师的讲义。 这本书定位在入门级,虽然内容很多,但不是一本百科全书,除了C语言基本要讲透之外其它内 容都不深入,书中列出了很多参考资料,是读者进一步学习的起点。K&R的第一章是一 个Whirlwind Tour,把全书的内容简单过了一遍,然后再逐个深入进去讲解。本书也可以看作是 计算机专业课程体系的一个Whirlwind Tour,学习完本书之后有了一个全局观,再去学习那些参 考资料就应该很容易上手了。 为什么要在Linux平台上学C语言?用Windows学C语言不好 吗? 用Windows还真的是学不好C语言。C语言是一种面向底层的编程语言,要写好C程序,必须对 操作系统的工作原理非常清楚,因为操作系统也是用C写的,我们用C写应用程序直接使用操作 系统提供的接口。既然你选择了看这本书,你一定了解:Linux是一种开源的操作系统,你有任 何疑问都可以从源代码和文档中找到答案,即使你看不懂源代码,也找不到文档,也很容易找个 高手教你,各种邮件列表、新闻组和论坛上从来都不缺乐于助人的高手;而Windows是一种封闭 的操作系统,除了微软的员工别人都看不到它的源代码,只能通过文档去猜测它的工作原理,更 糟糕的是,微软向来喜欢藏着揶着,好用的功能留着自己用,而不会写到文档里公开。本书的第 一部分在Linux或Windows平台上学习都可以,但第二部分和第三部分介绍了很多Linux操作系统 的原理以帮助读者更深入地理解C语言,只能在Linux平台上学习。 Windows平台上的开发工具往往和各种集成开发环境(IDE,Integrated Development Environment)绑在一起,例如Visual Studio、Eclipse等。使用IDE确实很便捷,但IDE对于初学 者绝对不是好东西。微软喜欢宣扬傻瓜式编程的理念,告诉你用鼠标拖几个控件,然后点一个按 钮就可以编译出程序来,但是真正有用的程序有哪个是这么拖出来的?很多从Windows平台入门 学编程的人,编了好几年程序,还是只知道编完程序点一个按钮就可以跑了,把几个源文件拖到 一个项目里就可以编译到一起了,如果有更复杂的需求他们就傻眼了,因为他们脑子里只有按 钮、菜单的概念,根本没有编译器、链接器、Makefile的概念,甚至连命令行都没用过,然而这 些都是初学编程就应该建立起来的基本概念。另一方面,编译器、链接器和C语言的语法有密切 的关系,不了解编译器、链接器的工作原理,也不可能真正掌握C的语法。所以,IDE并没有帮 助你学习,而是阻碍了你学习,本来要学好C编程只要把语法和编译命令学会就行了,现在有 了IDE,除了学会语法和编译命令,你还得弄清楚编译命令和IDE是怎么集成的,这才算学明白 了,本来就很复杂的学习任务被IDE搞得更加复杂了。Linux用户的使用习惯从来都是以敲命令为 主,以鼠标操作为辅,从学编程的第一天起就要敲命令编译程序,等到你把这些基本概念都搞清 楚了,你觉得哪个IDE好用你再去用,不过到那时候你可能会更喜欢vi或emacs而不是IDE了。 致谢 本书的写作得到北京亚嵌教育研究中心的全力支持,尤其感谢李明老师和何家胜老师,没有公司 的支持,我不可能有时间有条件写这本书,也不可能有机会将这本书公开在网上。 然后要感谢亚嵌教育的历届学员和各位老师,在教学和讨论过程中我经常会得到有益的启发,这 些都促使这本书更加完善。在本书的写作过程中,很多读者为本书提出很有价值的建议,很多建 议是热心网友通过在线评论提的,有些网友我只知道id或email。都列在下面,排名不分先后。 感谢北京亚嵌教育研究中心的老师们:李明,何家胜,邸海霞,郎铁山,朱仲涛,廖文江,韩 超,吴岳,邢文鹏,何晓龙,林小竹,卫剑钒。 感谢热心网 友:ddd(ddd@clf.net),wuyulei(wuyulei0210@163.com),comma(commapopo@hotmail.com ),田伟(sioungiep@zzxy.org),田雨(tianyu_1123@hotmail.com ),daidai(daidai0628@sina.com),邓楠(monnand@gmail.com),杜朴风 (cplusplus@zzxy.org),Zoom.Quiet(zoom.quiet@gmail.com),陈老师 (cljcore@gmail.com),杨景(yangbajing@gmail.com),章钰 (buptzhangyu@163.com),chen(cry2133@gmail.com),Jiawei Zhang(rhythm.zhang@gmail.com),waterloo(waterloo2005@gmail.com),张现超 (zxqianrong@gmail.com,http://zxqianrong.is-programmer.com/),曾宇 (uyucn@163.com),董俊波 (dongjunbo@gmail.com),RobinXiang(dancelinux@gmail.com),刘艳明 (lonny_liu@hotmail.com),付 (been2100@163.com),cleverd(crossie@qq.com),orange(juicerococo@hotmail.com ),徐斌(simlink_xub@163.com),cyy(cyy198767@hotmail.com)。 在写作过程中我遇到过很多困难,工作繁忙,对未来迷茫,生活压力大,缺乏安全感,个人琐事 等等。然而有这么多热心的同学、老师、朋友、网友在等着看我的书更新,给我提建议希望我把 书改得更完善,这是我坚持写下去的最大的动力。谢谢你们! 上一页 历史 起始页 下一页 部分 I. C语言入门 上一页 部分 I. C语言入门 部分 I. C语言入门 目录 1. 程序的基本概念 1. 程序和编程语言 2. 自然语言和形式语言 3. 程序的调试 4. 第一个程序 2. 常量、变量和表达式 1. 继续Hello World 2. 常量 3. 变量 4. 赋值 5. 表达式 6. 字符类型与字符编码 3. 简单函数 1. 数学函数 2. 自定义函数 3. 形参和实参 4. 局部变量与全局变量 4. 分支语句 1. if语句 2. if/else语句 3. 布尔代数 4. switch语句 5. 深入理解函数 1. return语句 2. 增量式开发 3. 递归 6. 循环语句 1. while语句 2. do/while语句 3. for语句 4. break和continue语句 下一页 5. 嵌套循环 6. goto语句 7. 结构体 1. 复合数据类型--结构体 2. 数据抽象 3. 数据类型标志 4. 嵌套结构体 8. 数组 1. 数组的基本操作 2. 数组应用实例:统计随机数 3. 数组应用实例:直方图 4. 字符串 5. 多维数组 9. 编码风格 1. 缩进和空白 2. 注释 3. 标识符命名 4. 函数 5. indent工具 10. gdb 1. 单步执行和跟踪函数调用 2. 断点 3. 观察点 4. 段错误 11. 排序与查找 1. 算法的概念 2. 插入排序 3. 算法的时间复杂度分析 4. 归并排序 5. 线性查找 6. 折半查找 12. 栈与队列 1. 数据结构的概念 2. 堆栈 3. 深度优先搜索 4. 队列与广度优先搜索 5. 环形队列 13. 本阶段总结 1. C语言基本语法 2. 思维方法与编程思想 3. 调试方法 上一页 前言 起始页 下一页 第 1 章 程序的基本概念 上一页 第 1 章 程序的基本概念 部分 I. C语言入门 第 1 章 程序的基本概念 目录 1. 程序和编程语言 2. 自然语言和形式语言 3. 程序的调试 4. 第一个程序 上一页 部分 I. C语言入门 上一级 起始页 下一页 下一页 1. 程序和编程语言 上一页 1. 程序和编程语言 第 1 章 程序的基本概念 下一页 1. 程序和编程语言 程序(Program)是一个精确说明如何进行计算的指令序列。这里的计算可以是一些数学上的计 算,比如解方程或者求多项式的根,也可以是符号运算,一个简单的例子是查找和替换文档中 的词,一个复杂的例子是搜索引擎。从根本上说,计算机是由数字电路组成的运算机器,只能 对数字做运算,程序之所以能做符号运算是因为符号在计算机内部也是用数字来表示的。此 外,程序还可以处理声音和图像,同样因为声音和图像在计算机内部是用数字来表示的,这些 数字再通过专门的硬件设备转换成人可以听到、看到的声音和图像。 程序由一系列指令(Instruction)组成,指令是指示计算机做某种运算的命令,通常包括以下几 类: 输入(Input) 从键盘、文件或者其它设备获取数据。 输出(Output) 把数据显示到屏幕,或者存入一个文件,或者发送到其它设备。 基本运算 执行最基本的数学运算(加减乘除)和数据存取,其实输入和输出也属于数据存取。 测试和分支(Branch) 测试某个条件,然后根据不同的测试结果执行不同的后续指令。 循环(Loop) 重复执行一系列操作。 对于程序来说,有上面这几类指令就足够了。你曾用过的任何一个程序,不管它有多么复杂, 都是由上面这几类指令组成的。程序是那么的复杂,而编写程序可以用的指令却只有这么简单 的几种,这中间巨大的落差就要由程序员去填了,所以编写程序理应是一件相当复杂的工 作。编写程序可以说就是这样一个过程:把复杂的任务分解成子任务,把子任务再分解成更简 单的任务,层层分解,直到最后简单得可以用以上指令来完成。 在不同的编程语言(Programming Language)中,以上几种指令具有不同的形式。通常“指 令”这个词专指机器语言(Machine Language)或者汇编语言(Assembly Language)等低级 语言(Low-level Language)中的指令,而在C语言、C++、Java、Python等高级语言(Highlevel Language)中通常称为语句(Statement)或表达式(Expression)[1]。举个例子,同样 一个语句用C语言、汇编语言和机器语言表示如下: 表 1.1. 同一个语句的三种表示 编程语言 表示形式 C语言 a=b+1; mov -0xc(%ebp),%eax 汇编语言 add $0x1,%eax mov %eax,-0x8(%ebp) 8b 45 f4 机器语言 83 c0 01 89 45 f8 计算机只能对数字做运算,虽然高级语言中有大量的符号,但这些符号都是人为定义的,最终 转换成计算机可以直接处理的机器语言仍然是数字,上表中的机器语言完全由十六进制数字组 成。最早的程序员都是直接用机器语言编程,但是很麻烦,需要查大量的表格来确定每个数字 表示什么意思,编写出来的程序很不直观,很容易出错,于是有了汇编语言,把机器语言中的 一组一组数字用助记符(Mnemonic)来表示,直接用这些助记符写出汇编程序,然后让汇编器 (Assembler)去查表把助记符替换成数字,也就把汇编语言翻译成了机器语言。从上面的例子 可以看出,汇编语言和机器语言的指令是一一对应的,汇编语言有三条指令机器语言也有三条 指令,汇编器就是做一个简单的替换工作,例如在第一条指令中,把movl ?(%ebp),%eax这种格 式的指令替换成机器码8b 45,把指令中的-0xc替换成机器码f4(这是补码表示)。 从上面的例子还可以看出,C语言的语句和低级语言的指令之间不是简单的一一对应关系,一 条a=b+1语句要翻译成三条汇编或机器指令,这个过程称为编译(Compile),由编译器 (Compiler)来完成,显然编译器的功能比汇编器要复杂得多。用C语言编写的程序必须经过编 译转成机器指令才能被计算机执行,运行编译器程序要消耗一些时间,这是一个小小的缺点, 而优点则是不可胜数的。首先,用C语言编程更容易,写出来的代码更紧凑,可读性更强,也更 容易改正。其次,C语言是可移植的(Portable)或者称为平台无关的(Platform Independent),平台这个词有很多种解释,可以指计算机体系结构(Architecture),也可以 指操作系统(Operating System),也可以指两者的组合。不同的计算机体系结构有不同的指 令集(Instruction Set),可以识别的机器指令格式是不同的,直接用某种计算机的汇编或机器 指令写出来的程序只能在这种计算机上执行,然而各种计算机上都有C编译器,可以把C程序编 译成该计算机自己的(Native)机器指令,这意味着用C语言写出来的程序只需要稍加修改甚至 不用修改就可以在不同的计算机上编译执行。各种高级语言都具有C语言的这些优点,所以绝大 部分程序是用高级语言编写的,只有和硬件关系密切的少数程序(例如驱动程序)才会用到低 级语言。 总结一下编译执行的过程,首先你用文本编辑器写一个C程序,然后保存成一个文件,例 如program.c(通常C程序的文件名后缀是.c),这称为源代码(Source Code)或源文件,然 后运行编译器对它进行编译,编译的过程并不执行程序,而是把源代码全部翻译成机器指令, 再加上一些描述信息,生成一个新的文件,例如a.out,这称为目标文件(Object File)或可执 行文件(Executable)。其实可执行文件只是目标文件的一种类型,以后我们会详细介绍其它 类型的目标文件。可执行文件代码才是计算机可以执行的程序,如下图所示: 图 1.1. 编译执行过程 有些高级语言以解释(Interpret)的方式执行,解释执行的过程和C语言的编译执行过程很不一 样,例如写一个Python源代码,保存成program.py(通常Python程序的文件名后缀是.py),然 后,并不需要生成目标代码,而是直接运行解释器(Interpreter)执行该源代码,解释器是一行 一行地翻译源代码,边翻译边执行的。如下图所示: 图 1.2. 解释执行过程 编程语言仍在发展演化。以上介绍的机器语言称为第一代语言(1GL,1st Generation Programming Language),汇编语言称为第二代语言(2GL,2nd Generation Programming Language),C、C++、Java、Python等可以称为第三代语言(3GL,3rd Generation Programming Language)。目前已经有了4GL(4th Generation Programming Language)和5GL(5th Generation Programming Language)的概念,主要区别在 于,4GL以后的语言主要不是通过输入、输出、基本运算、测试分支和循环这些基本指令来编 程的,4GL以后的语言更多是在描述要做什么(Declarative)而不是描述具体一步一步怎么做 (Imperative),具体一步一步怎么做完全交由编译器或解释器决定,例如SQL语言 (SQL,Structured Query Language,结构化查询语言)就是这样的例子。 习题 1、解释执行的语言相比编译执行的语言有什么优缺点? 这是我们的第一个思考题。本书的思考题通常要求读者系统地总结当前小节的知识,结合以前 的知识,并经过一定的推理,然后作答。本书强调的是基本概念,读者应该抓住概念的定义和 概念之间的关系来总结,比如本节介绍了很多概念:程序由语句或指令组成,在高级语言写的 程序中通常叫语句,在低级语言写的程序中通常叫指令,计算机只能执行低级语言中的指 令,高级语言要执行就必须先翻译成低级语言,翻译的方法有两种--编译和解释,虽然有这 样的不便,但高级语言有一个好处是平台无关性。什么是平台?一种平台,就是一种体系结 构,就是一种指令集,就是一种机器语言,这些都可看作是一一对应的,上文没有明确讲它们 之间是一一对应的但读者应该能推理出这个结论,而高级语言和它们不是一一对应的,因此高 级语言是平台无关的,概念之间像这样的数量对应关系尤其重要。那么编译和解释的过程有哪 些不同?主要的不同在于什么时候翻译和什么时候执行。 现在回答这个思考题,根据编译和解释的不同原理,能否在执行效率和平台无关性等方面做一 下比较? 希望读者掌握以概念为中心的阅读思考习惯,每读一节就总结一套概念之间的关系图画在书上 空白处。如果读到后面某一节看到一个讲过的概念,但是记不清在哪一节讲过了,没关系,书 后的索引可以帮你找到它是在哪一节定义的。 [1] 语句和表达式之间的划分在不同的编程语言中有不同的规定,例如赋值在C语言中是表达 式,而在Python中就是语句。 上一页 第 1 章 程序的基本概念 上一级 起始页 下一页 2. 自然语言和形式语言 上一页 2. 自然语言和形式语言 第 1 章 程序的基本概念 下一页 2. 自然语言和形式语言 自然语言(Natural Language)就是人类讲的语言,比如汉语、英语和法语。这类语言不是人 为设计(虽然有人试图强加一些规则)而是自然进化的。形式语言(Formal Language)是为了 特定应用而人为设计的语言。例如数学家用的数字和运算符号、化学家用的分子式等。编程语 言也是一种形式语言,是专门设计用来表达计算过程的形式语言。 形式语言有严格的语法(Syntax)规则,例如,3+3=6是一个语法正确的数学等式,而3=+6$则 不是,H2O是一个正确的分子式,而2Zz则不是。语法规则是由关于符号(Token)和结构 (Structure)的规则所组成的。Token的概念相当于自然语言中的单词和标点、数学式中的数和 运算符、化学分子式中的元素名和数字,例如3=+6$的问题之一在于$不是一个合法的数也不是 一个事先定义好的运算符,而2Zz的问题之一在于没有一种元素的缩写是Zz。语法规则的第二个 范畴是结构,也就是Token的排列方式。3=+6$还有一个结构上的错误,虽然加号和等号都是合 法的运算符,但是不能在等号之后紧跟加号,而2Zz的另一个问题在于分子式中必须把下标写在 化学元素名称之后而不是前面。关于Token的规则称为词法(Lexical)规则,而关于语句结构 的规则称为语法(Grammar)规则[2]。 当阅读一个自然语言的句子或者一种形式语言的语句时,你不仅要搞清楚每个词(或Token)是 什么意思,而且必须搞清楚整个句子的结构是什么样的(在自然语言中你只是没有意识到,但 确实这样做了,尤其是在读外语时你肯定也意识到了)。这个分析句子结构的过程称为解析 (Parse)。例如,当你听到“The other shoe fell.”这个句子时,你理解the other shoe是主语 而fell是谓语动词,一旦解析完成,你就搞懂了句子的意思,如果知道shoe是什么东西,fall意味 着什么,这句话是在什么上下文(Context)里说的,你还能理解这个句子主要暗示的内容,这 些都属于语义(Semantic)的范畴。 虽然形式语言和自然语言有很多共同之处,包括Token、结构和语义,但是也有很多不一样的地 方。 歧义性(Ambiguity) 自然语言充满歧义,人们通过上下文的线索和其它一些信息来解决这个问题。形式语言的 设计要求是清晰的、毫无歧义的,这意味着每一个语句必须有确切的含义而不管上下文如 何。 冗余性(Redundancy) 为了消除歧义减少误解,自然语言引入了相当多的冗余。结果是自然语言经常变得啰里啰 嗦,而形式语言则更加紧凑,极少有冗余。 与字面意思的一致性 自然语言充斥着成语和隐喻(Metaphor),我在某种场合下说“The other shoe fell”,可能 并不是说谁的鞋掉了。而形式语言中字面(Literal)意思基本上就是真实意思,也有些特 殊情况,例如C语言的转义序列(Escape Sequence),但也都会明确规定哪些字面意思 不是真实意思,它们所表示的真实意思又是什么。 说自然语言长大的人(实际上没有人例外),往往有一个适应形式语言的困难过程。某种意义 上,形式语言和自然语言之间的不同正像诗歌和说明文的区别,当然,前者的区别比后者更明 显: 诗歌 词语的发音和意思一样重要,全诗作为一个整体创造出一种效果或者表达出一种感情。歧 义和非字面意思不仅是常见的而且是刻意使用的。 说明文 词语的字面意思显得更重要,而且结构能传达出更多的信息。诗歌只能看一个整体,而说 明文更适合逐字句分析,但仍然充满歧义。 程序 计算机程序是毫无歧义的,字面和本意高度一致的,能够完全通过对Token和结构的分析 加以理解。 现在给出一些关于阅读程序(包括其它形式语言)的建议。首先请记住形式语言远比自然语言 紧凑,所以要多花点时间来读。其次,结构很重要,从上到下从左到右地读往往不是一个好办 法,而应该学会在大脑里解析:识别Token,分解结构。最后,请记住细节的影响,诸如拼写错 误和标点错误这些在自然语言中可以忽略的小毛病会把形式语言搞得面目全非。 [2] 很不幸,Syntax和Grammar通常都翻译成“语法”,这让初学者非常混乱,Syntax的含义其实 包含了Lexical和Grammar,还包含一部分语义(Semantic),例如变量应先声明后使用。即使 在英文的文献中Syntax和Grammar也常混用,有些时候Syntax不包括Lexical。不过也没什么影 响,只要结合上下文去看就不会误解。本书中在容易引起混淆的地方通常直接用英文名称,例 如Token没有十分好的翻译,直接用英文名称。 上一页 1. 程序和编程语言 上一级 起始页 下一页 3. 程序的调试 上一页 3. 程序的调试 第 1 章 程序的基本概念 下一页 3. 程序的调试 编程是一个复杂的过程,因为是人做的事情,所以难免经常出错。据说有这样一个典故:早期 的计算机体积都很大,有一次一台计算机不能正常工作了,工程师们找了半天原因最后发现是 一只臭虫钻进计算机中造成的。从此以后,程序中的错误被叫做臭虫(Bug),而找到这 些Bug并加以纠正的过程就叫做调试(Debug)。有时候调试是一件非常复杂的工作,要求程序 员概念明确、逻辑清晰、性格沉稳,还需要一点运气。调试的技能我们在后续的学习中慢慢培 养,但首先我们要区分清楚程序中的Bug分为哪几类。 编译时错误 编译器只能翻译语法正确的程序,否则将导致编译失败,无法产生目标代码。对于自然语 言来说,一点语法错误不是很严重的问题,因为我们仍然可以读懂句子。可惜编译器就没 那么宽容了,只要有哪怕一个很小的语法错误,编译器就会输出一条错误提示信息然后罢 工,你就得不到你想要的目标代码。虽然大部分情况下编译器给出的错误提示信息就是你 出错的代码行,但也有个别时候编译器给出的错误提示信息帮助不大,甚至会误导你。在 开始学习编程的前几个星期,你可能会花大量的时间来纠正语法错误。等到有了一些经验 之后,还是会犯这样的错误,不过会少得多,而且你能更快地发现错误原因。等到经验更 丰富之后你就会觉得,语法错误是最简单最低级的错误,编译器的错误提示也就那么几 种,即使错误提示是误导的,也能够立刻找出错误原因是什么。相比下面两种错误,语法 错误解决起来要容易得多。 运行时错误 编译器检查不出这类错误,仍然可以生成目标代码,但在运行时会出错而导致程序崩溃。 对于我们接下来的几章将编写的简单程序来说,运行时错误很少见,不过到了后面的章节 你可能会开始遇到很多运行时错误,例如每个初学者都会遇到的段错误(Segmentation Fault)。希望读者在以后的学习中时刻注意区分编译时和运行时(Run-time)这两个概 念,不仅是调试,在掌握C语言的很多特性时都需要区分这两个概念,有些事情在编译时 做,有些事情则在运行时做。 逻辑错误和语义错误 第三类错误是逻辑错误和语义错误。如果程序里有逻辑错误,编译和运行都会很顺利,看 上去也不产生任何错误信息,但是程序没有干它该干的事情,而是干了些别的什么。当然 不管怎么样,计算机只会按你写的程序去做,问题出在你写的程序不是你真正想要的,这 意味着程序的意思(即语义)是错的。找到逻辑错误在哪需要十分清醒的头脑,因为要通 过观察程序的输出而回过头来判断它到底在做什么。 通过本书你将掌握的最重要的技巧之一就是调试。调试的过程可能会让你感到一些沮丧,但调 试也是编程中最需要动脑的、最有挑战和乐趣的部分。从某种角度看调试就像侦探工作,根据 掌握的线索来推断是什么过程和事件导致了你所看到的结果。调试也像是一门实验科学,每次 想到哪里可能有错,就修改程序然后再试一次。如果假设是对的,就能得到预期的正确结果, 就可以接着调试下一个Bug,一步一步地逼近正确的程序;如果假设错误,只好另外再找思路再 做假设。“当你把不可能的全部剔除,剩下的——即使看起来再怎么不可能——就一定是事 实。”(即使你没看过福尔摩斯也该看过柯南吧)。 也有一种观点认为,编程和调试是一回事,编程就是逐步调试直到获得期望结果为止的过程。 你应该总是从一个能正确运行的小规模程序开始,每做一步小的改动就立刻进行调试,这样的 好处是总有一个正确的程序做参考:如果正确就继续编程,如果不正确,那么一定是刚才的小 改动出了问题。例如,Linux操作系统包含了成千上万行代码,但它也不是一开始就规划好了内 存管理、设备管理、文件系统、网络等等大的模块,一开始它仅仅是Linus Torvalds用来琢 磨Intel的80386芯片而写的小程序。据Larry Greenfield 说,“Linus的早期工程之一是编写一个 交替打印AAAA和BBBB的程序,这玩意儿后来进化成了Linux。”(引自The Linux User's Guide Beta1版)在后面的章节中会给出更多关于调试和编程实践的建议。 上一页 2. 自然语言和形式语言 上一级 起始页 下一页 4. 第一个程序 上一页 4. 第一个程序 第 1 章 程序的基本概念 下一页 4. 第一个程序 通常一本教编程的书中的第一个例子都是打印“Hello, World.”,这个传统源自[K&R],用C语言 写这个程序可以这样写: 例 1.1. Hello World #include /* main: generate some simple output */ int main(void) { printf("Hello, world.\n"); return 0; } 将这个程序保存成main.c,然后编译执行: $ gcc main.c $ ./a.out Hello, world. gcc是Linux平台的C编译器,编译后在当前目录下生成可执行文件a.out,直接在命令行输入这 个可执行文件的路径就可以执行它。如果不想把文件名叫a.out可以用gcc的-o参数自己指定文件 名: $ gcc main.c -o main $ ./main Hello, world. 虽然这只是一个很小的程序,但我们目前暂时还不具备相关的知识来完全理解这个程序,比如 程序的第一行,还有程序主体的int main(void){...return 0;}结构,这些部分我们暂时不详细 解释,读者现在只需要把它们看成是每个程序按惯例必须要写的部分(Boilerplate)。但要注 意main是一个特殊的名字,C程序总是从main里面的第一条语句开始执行的,在这个程序中是 指printf这条语句。 第3行的/* ... */结构是一个注释(Comment),其中可以写一些描述性的话,解释这一段程 序在做什么,注释只是写给程序员看的,编译器会忽略从/*到*/的所有字符,所以写注释没有 语法规则,爱怎么写就怎么写,并且不管写多少都不会被编译进目标代码。 printf的作用是把消息打印到屏幕,注意这条语句的末尾有一个;号(Semicolon),C语言规定 每条语句末尾都要有一个;号,printf的下一条语句也是如此。 C语言用{}号(Brace或Curly Brace)把语法结构分成组,在上面的程序中printf和return语句 套在main的{}号中,表示它们属于main的定义之中。我们看到这两句相比main那一行都缩进 (Indent)了一些,在代码中可以用若干个空格(Blank)和Tab字符来缩进,缩进不是必须 的,但这样使我们更容易看出这两行是属于main的定义之中的,要写出漂亮的程序必须要有整 齐的缩进。 正如前面所说,编译器对于语法错误是毫不留情的,如果你的程序有一点拼写错误,例如第一 行写成了stdoi.h,在编译时会得到错误提示: $ gcc main.c main.c:1:19: error: stdoi.h: No such file or directory ...... 这个错误提示非常紧凑,初学者往往不容易明白出了什么错误,即使知道这个错误提示说的是 第1行有错误,很多初学者对照着书看好几遍也看不出自己这一行哪里有错误,因为他们对符 号、拼写不敏感(尤其是英文较差的初学者),他们还不知道这些符号是什么意思又如何能记 住正确的拼写?对于初学者来说,最想看到的错误提示其实是这样的:“在main.c程序第1行的 第19列,您试图包含一个叫做stdoi.h的文件,可惜我没有找到这个文件,但我却找到了一个叫 做stdio.h的文件,我猜这个才是您想要的,对吗?”可惜没有任何编译器会友善到这个程度,大 多数时候你所得到的错误提示并不能直接指出谁是犯人,而只是一个线索,你需要根据这个线 索做一些侦探和推理。 有些时候编译器的提示信息不是error而是warning,例如把上例中的printf("Hello, world.\n");改成printf(1); 然后编译运行: $ gcc main.c main.c: In function ‘main’: main.c:7: warning: passing argument 1 of ‘printf’ makes pointer from integer without a cast $ ./a.out Segmentation fault 这个警告信息是说类型不匹配,但勉强还能配得上。警告信息不是致命错误,编译仍然可以继 续,如果整个编译过程只有警告信息而没有错误信息,仍然可以生成目标代码。但是,警告信 息也是不可忽视的。出警告信息说明你的程序有Bug或者写得不够规范,虽然能编译生成目标代 码,但程序的运行结果往往是不正确的,例如上面的程序运行时就出了一个段错误,这属于运 行时错误。各种警告信息的严重程度不同,像上面这种警告几乎一定是表明程序中有Bug,而另 外一些警告只表明程序写得不够规范,一般还是能正确运行的,有些不重要的警告信息gcc默认 是不提示的,但这些警告信息也有可能表明程序中有Bug。一个好的习惯是打开gcc的-Wall选 项,也就是让gcc提示所有的警告信息,不管是严重的还是不严重的,然后把这些问题从代码中 全部消灭。比如把上例中的printf("Hello, world.\n");改成printf(0);然后编译运行: $ gcc main.c $ ./a.out 编译既不报错也不报警告,一切正常,但是运行程序什么也不打印。如果打开-Wall选项编译就 会报警告了: $ gcc -Wall main.c main.c: In function ‘main’: main.c:7: warning: null argument where non-null required (argument 1) 如果printf中的0是你不小心写上去的(例如错误地使用了编辑器的查找替换功能),这个警告 就能帮助你发现错误。虽然本书的命令行为了突出重点通常省略-Wall选项,但是强烈建议你写 每一个编译命令时都加上-Wall选项。 习题 1、尽管编译器的错误提示不够友好,但仍然是学习过程中的一个很有用的工具。你可以像上面 那样,从一个正确的程序开始每次改动一小点,然后编译看是什么结果,如果出错了,就尽量 记住编译器给出的错误提示并把改动还原。因为错误是你改出来的,你已经知道错误原因是什 么了,就可以很容易地把错误原因和错误提示信息对应起来记住,这样下次你在毫无防备的情 况下撞到这个错误提示就会很容易想到错误原因是什么了。这样反复练习,有了一定的经验积 累之后面对编译器的错误提示就会从容得多了。 上一页 3. 程序的调试 上一级 起始页 下一页 第 2 章 常量、变量和表达式 上一页 第 2 章 常量、变量和表达式 部分 I. C语言入门 第 2 章 常量、变量和表达式 目录 1. 继续Hello World 2. 常量 3. 变量 4. 赋值 5. 表达式 6. 字符类型与字符编码 上一页 4. 第一个程序 上一级 起始页 下一页 下一页 1. 继续Hello World 上一页 1. 继续Hello World 第 2 章 常量、变量和表达式 下一页 1. 继续Hello World 在第 4 节 “第一个程序”中,读者应该已经尝试对Hello world程序做各种改动看编译运行结果, 其中有些改动会导致编译出错,有些改动会影响程序的输出,有些改动则不影响程序的输出, 下面我们总结一下。首先,注释可以跨行,也可以穿插在程序之中,看下面的例子。 例 2.1. 带更多注释的Hello World #include /* * comment1 * main: generate some simple output */ int main(void) { printf(/* comment2 */"Hello, world.\n"); /* comment3 */ return 0; } 第一个注释跨了四行,头尾两行是表示注释的/*和*/,中间的两行开头的*号(Asterisk)并没 有特殊含义,只是为了看起来整齐,这不是语法规则而是大家都遵守的C代码风格(Coding Style)之一。 使用注释需要注意两点: 1. 注释不能嵌套(Nest)使用,就是说一个注释的文字中不能再出现/*和*/了,例如/* text1 /* text2 */ text3 */是错误的,编译器只把/* text1 /* text2 */看成注释,后 面的 text3 */无法解析,因而会报错。 2. 有的C代码中有类似// comment的注释,两个/斜线(Slash)表示从这里直到该行末尾的 所有字符都属于注释,这种注释不能跨行,也不能穿插在一行代码中间。这是从C++借鉴 的语法,在C99中被标准化,凡是C99新增的特性在本书中都会提醒读者,使用这些特性 须谨慎,因为不是所有的C编译器都能很好地支持C99标准,使用C89的特性是比较保险 的。 关于C语言标准 C语言的发展历史大致上分为三个阶段:Old Style C、C89和C99。Ken Thompson和Dennis Ritchie发明C语言时有 很多语法和现在并不一样,但为了向后兼容性(Backward Compatibility),这些语法仍然在C89和C99中保留下来了,本书 不使用Old Style C,但在必要的地方会加以说明。C89是最早 的C语言规范,于1989年提出,1990年先由ANSI(美国国家标准 委员会,American National Standards Institute)推出ANSI版 本,后来被接纳为ISO国际标准(ISO/IEC 9899:1990),因而有 时也称为C90,最经典的C语言教材[K&R]就是基于这个版本 的,C89是目前最广泛采用的C语言标准,大多数编译器都完全支 持C89。C99标准(ISO/IEC 9899:1999)是在1999年推出的,加 入了许多新的特性,但目前仍没有得到广泛支持,在C99推出之后 相当长的一段时间里,连gcc也没有完全实现C99的所有特 性。C99标准详见[C99]。本书内容以C89为主,在必要的地方会 说明一下C99的新特性,但是不建议使用。 C标准的目的是为了精确定义C语言,而不是为了教别人怎么编 程,C标准在表达上追求准确和无歧义,却十分不容易看 懂,[Standard C]和[Standard C Library]是对C89及其修订版本的 阐释(可惜作者没有随C99更新),比C标准更容易看懂,另外, 参考[C99 Rationale]也有助于加深对C标准的理解。 像"Hello, world.\n"这种由双引号(Double Quote)引起来的一串字符称为字符串字面值 (String Literal),或者简称字符串。注意,程序的运行结果并没有双引号,printf打印出来的 只是里面的一串字符Hello, world.,因此双引号是字符串字面值的界定符(Delimiter),夹在 双引号中间的一串字符才是它的内容。注意,打印出来的结果也没有\n这两个字符,这是为什 么呢?在第 2 节 “自然语言和形式语言”中提到过,C语言规定了一些转义序列(Escape Sequence),这里的\n并不表示它的字面意思,也就是说并不表示\和n这两个字符本身,而是 合起来表示一个换行符(Line Feed)。例如我们写三条打印语句: printf("Hello, world.\n"); printf("Goodbye, "); printf("cruel world!\n"); 运行的结果是第一条语句单独打到第一行,后两条语句都打到第二行。为了节省篇幅突出重 点,以后的例子通常省略include和main这些Boilerplate,但读者在练习时需要加上这些构成一 个完整的程序才能编译通过。C标准规定的转义字符有以下这些: 表 2.1. C标准规定的转义字符 \' 单引号'(Single Quote,或Apostrophe) \" 双引号" \? 问号?(Question Mark) \\ 反斜线\(Backslash) \a 响铃(Alert,或Bell) \b 退格(Backspace) \f 分页符(Form Feed) \n 换行(Line Feed) \r 回车(Carriage Return) \t 水平制表符(Horizontal Tab) \v 垂直制表符(Vertical Tab) 如果在字符串字面值中要表示单引号'和问号?,既可以使用转义序列\'和\?,也可以直接用字 符'和?,而要表示\或"则必须使用转义序列,因为\字符表示转义而不表示它的字面含义,"表示 字符串的Delimiter而不表示它的字面含义。可见转义序列有两个作用:一是把普通字符转义成 特殊字符,例如把字母n转义成换行符;二是把特殊字符转义成普通字符,例如\和"是特殊字 符,转义后取它的字面值。 C语言规定了几个控制字符,不能用键盘直接输入,因此采用\加字母的转义序列表示。\a是响 铃字符,在字符终端下显示这个字符的效果是PC喇叭发出嘀的一声,在图形界面终端下的效果 取决于终端的实现。在终端下显示\b和按下退格键的效果相同。\f是分页符,主要用于控制打印 机在打印源代码时提前分页,这样可以避免一个函数跨两页打印。\n和\r分别表示Line Feed和Carriage Return,这两个词来自老式的英文打字机,Line Feed是跳到下一行(进纸, 喂纸,有个喂的动作所以是feed),Carriage Return是回到本行开头(Carriage是卷着纸的 轴,随着打字慢慢左移,打完一行就一下子移回最右边),如果你看过欧美的老电影应该能想 起来这是什么。用老式打字机打完一行之后需要这么两个动作,\r\n,所以现在Windows上的文 本文件用\r\n做行分隔符,许多应用层网络协议(如HTTP)也用\r\n做行分隔符,而Linux和各 种UNIX上的文本文件只用\n做行分隔符,所以很多初学者弄不清楚这两个字符有什么区别。在 终端下显示\t和按下Tab键的效果相同,用于在终端下定位表格的下一列,\v用于在终端下定位 表格的下一行。\v比较少用,\t比较常用,以后将“水平制表符”简称“制表符”或Tab。请读者 用printf语句试试这几个控制字符的作用。 注意"Goodbye, "末尾的空格,字符串字面值中的空格也算一个字符,也会出现在输出结果中, 而程序中别处的空格和Tab多一个少一个往往是无关紧要的,不会对编译的结果产生任何影响, 例如不缩进不会影响程序的结果,main后面多几个空格也没影响,但是int和main之间至少要有 一个空格分隔开: int main (void) { printf("Hello, world.\n"); return 0; } 不仅空格和Tab是无关紧要的,换行也是如此,我甚至可以把整个程序写成一行,但 是include必须单独占一行: #include int main(void){printf("Hello, world.\n");return 0;} 这样也行,但肯定不是好的代码风格,去掉缩进已经很影响可读性了,写成现在这个样子可读 性更差。如果编译器说第2行有错误,也很难判断是哪个语句有错误。所以,好的代码风格要求 缩进整齐,每个语句一行,适当留空行。 上一页 第 2 章 常量、变量和表达式 上一级 起始页 下一页 2. 常量 上一页 2. 常量 第 2 章 常量、变量和表达式 下一页 2. 常量 常量(Constant)是程序中最基本的元素,有字符常量(Character Constant)、数字常量和枚 举常量。枚举常量以后再介绍,现在我们看看如何使用字符常量和数字常量: printf("character: %c\ninteger: %d\nfloating point: %f\n", '}', 34, 3.14); 字符常量要用单引号括起来,例如上面的'}',注意单引号只能括一个字符而不能像双引号那样 括一串字符,字符常量也可以是一个转义序列,例如'\n',这时虽然单引号括了两个字符,但 实际上只表示一个字符。和字符串字面值中使用转义序列有一点区别,如果在字符常量中要表 示双引号"和问号?,既可以使用转义序列\"和\?,也可以直接用字符"和?,而要表示'和\则必须 使用转义序列。[3] 计算机中整数和小数的内部表示方式不同,因而在C语言中是两种不同的类型,例如上例 的34和3.14,小数在计算机术语中称为浮点数(Floating Point)。这个语句的输出结果和Hello world不太一样,字符串"character: %c\ninteger: %d\nfloating point: %f\n"并不是按原样 打印输出的,而是输出成这样: character: } integer: 34 floating point: 3.14 printf中的这个字符串称为格式化字符串(Format String),它规定了后面几个数据以何种格式 插入到这个字符串中,%号(Percent Sign)后面加个字母c、d、f在printf中分别解释成字符 型、整型和浮点型的转换说明(Conversion Specification),分别用后面的三个常量来替换它 们,也就是说它们只是在格式化字符串中占个位置,并不出现在最终的打印结果中,这种用法 通常叫做占位符(Placeholder)。这也是一种字面意思与真实意思不同的情况,但是和转义序 列又有区别:转义序列是编译器在处理字符串字面值时转义的,而占位符是由printf解释的, 格式化字符串实际包含的字符是character: %c换行integer: %d换行floating point: %f换行, 其中的%c仍然是字符串中的两个普通字符,而当字符串传给printf处理时,printf却不把它当成是 普通字符,而是解释成占位符。事实上前面例子中的"Hello, world.\n"也是格式化字符串,只 不过其中不包含占位符。 有时候不同类型的数据很容易弄混,例如"5"、'5'、5,如果你注意了Delimiter就会很清楚,第一 个是字符串,第二个是字符,第三个是整数,看了下文你很快就会知道为什么一定要严格区分 它们之间的差别了。 习题 1、总结前面介绍的转义序列的规律,想想在printf的格式化字符串中怎么表示一个%字符?写个 小程序试验一下。 [3] 读者可能会奇怪,为什么需要规定一个转义序列\?呢?因为C语言规定了一些三连符 (Trigraph),在某些特殊的终端上缺少某些字符,需要用Trigraph输入,例如??=表 示#。Trigraph极不常用,介绍这个只是为了让读者理解C语言规定转义序列的作用,即特殊字 符转普通字符,普通字符转特殊字符,\?属于前者。极不常用的C语法在本书中通常不会介绍。 上一页 1. 继续Hello World 上一级 起始页 下一页 3. 变量 上一页 3. 变量 第 2 章 常量、变量和表达式 下一页 3. 变量 变量(Variable)是编程语言中最重要的概念之一,变量是计算机存储器中的一块命名的空间, 可以在里面存储一个值(Value),存储的值是可以随时变的,比如这次存个字符'a'下次存个 字符'b',正因为变量的值可以随时变所以才叫变量。 常量有不同的类型,因此变量也有不同的类型,变量的类型也决定了它所占的存储空间的大 小。例如,以下语句定义了四个变量fred、bob、jimmy和tom[4],它们的类型分别是字符型、整 型、浮点型: char fred; int bob; float jimmy; double tom; 浮点型有两种,float是单精度浮点型,double是双精度浮点型,它们之间的区别和转换规则我 们将在第 15 章 数据类型详解介绍,在随后的几章中我们只使用double类型,上一节介绍的常 量3.14应该看作double类型的常量,printf的%f也应该看作格式化double类型的占位符。给变量 起名不能太随意了,以上四个变量的名字就不够好,我们猜不出这些变量里可能存的是关于什 么的数据。而像下面这样起名就很好: char firstletter; char lastLetter; int hour, minute; 我们可以猜得到这些变量是用来存什么的,前两个变量的取值范围应该是'A'-'Z'或'a'-'z', 变量hour的取值范围应该是0-23,变量minute的取值范围应该是0-59。所以,应该给变量起有 意义的名字。从这个例子中我们也看到两个同样类型的变量(hour和minute)可以定义在同一 行。需要注意,变量的命名有一定限制,规定必须以字母或下划线_(Underscore)开头,后面 可以跟若干个字母、数字、下划线,但不能有其它字符。例如这些是合法的变量 名:Abc、__abc__、_123。但这些是不合法的变量名:3abc、ab$。其实这个规则不仅适用于变 量名,也适用于所有可以由程序员起名字的语法元素,例如以后要讲的函数名、宏定义、结构 体成员名等等,在C语言中这些统称为标识符(Identifier)。 另外要注意,表示类型的char、int、float、double等等虽然符合上述规则,但也不能用作标 识符。C语言用这些单词做特殊用途,如果你起个变量名也叫这个就会让编译器无法区分,所 以C语言规定了一些单词不允许用作标识符,这些单词称为关键字(Keyword)或保留字 (Reserved Word)。通常用于编程的文本编辑器都会高亮显示(Highlight)这些关键字,所 以只要小心一点通常不会当作标识符误用了。C99规定的关键字有: auto break case char const continue default do double else enum extern float for goto if inline int long register restrict return short signed sizeof static struct switch typedef union unsigned void volatile while _Bool _Complex _Imaginary 还有一点要注意,一般来说应避免使用以下划线开头的标识符,以下划线开头的标识符只要不 和C语言关键字冲突的都是合法的,但是往往被编译器用作一些功能扩展,C语言库的实现也定 义了很多以下划线开头的名字,很容易造成名字冲突,所以除非你对编译器和C语言库特别清 楚,一般应避免使用这种标识符。 请记住:理解一个概念不是把定义背下来就行了,一定要理解它的外延和内涵,也就是什么情 况属于这个概念,什么情况不属于这个概念,什么情况虽然属于这个概念但作为一种最佳实践 (Best Practice)应该避免这种情况,这才算是真正理解了。 [4] 更准确地说是声明了四个变量,定义(Definition)和声明(Declaration)之间的关系是:定 义是声明的一种,如果一个声明要求分配存储空间,则称为定义。在下一章我们会看到哪些函 数声明同时也是定义,哪些不是定义,以后我们还会看到哪些变量声明不分配存储空间因而不 是定义。接下来几章的例子中声明的变量都是分配存储空间的,都用“定义”这个词。 上一页 2. 常量 上一级 起始页 下一页 4. 赋值 上一页 4. 赋值 第 2 章 常量、变量和表达式 下一页 4. 赋值 定义了变量之后,我们要把值存到它们的存储空间里,可以用赋值(Assignment)语句实现: char firstletter; int hour, minute; firstletter = 'a'; hour = 11; minute = 59; /* give firstletter the value 'a' */ /* assign the value 11 to hour */ /* set minute to 59 */ 注意,变量一定要先定义再使用,编译器必须先看到你的定义语句,才知 道firstletter、hour和minute是变量名,代表一块存储空间,下面使用时才知道去哪里找这个 变量的存储空间。还要注意,这里的等号不表示数学里的相等关系,和1+1=2的等号是不同的, 这里的等号表示赋值。在数学上不会有i=i+1这种等式成立,而在C语言中,这个语句表示把变 量i的存储空间中的值取出来,再加上1,得到的结果再存回i的存储空间中。再比如,在数学 上a=7和7=a是一样的,而在C语言中,后者是不合法的。总结一下:定义一个变量,就是分配 一块存储空间并给它命名;给一个变量赋值,就是把一个值存到了这块存储空间中。变量的定 义和赋值也可以一步完成,这称为变量的初始化(Initialization),例如要达到上面代码的效果 也可以这样写: char firstletter = 'a'; int hour = 11, minute = 59; 在初始化语句中,等号右边的值叫做Initializer,例如上面的'a'、11和59。注意,初始化是一种 特殊的变量定义语句,而不是一种赋值语句。就目前来看,先定义一个变量再给它赋值和定义 这个变量的同时给它初始化所达到的效果是一样的,事实上C语言的很多语法规则既适用于赋值 也适用于初始化,但在以后的学习中你也会了解到它们之间的不同,请在学习过程中注意总结 赋值和初始化的相同之处和不同之处。如果在纸上“跑”一个程序(每个学编程的人都要练这项基 本功),可以用一个框表示一个变量的存储空间,在框的外边标上变量名,在框里存上它的 值,这也是本书常用的表示方法,如下图所示。 图 2.1. 在纸上表示变量 你可以用不同形状的框表示不同类型的变量,这样可以提醒你给变量赋的值必须符合它的类 型。如果所赋的值和变量的类型不符会导致编译器报警或报错(这是一种语义错误),例如: int hour, minute; hour = "Hello."; minute = "59"; /* WRONG ! */ /* WRONG !! */ 注意第3个语句,把"59"赋给minute看起来像是对的,但是类型不对,字符串不能赋给整型变 量。既然可以为变量的存储空间赋值,就应该可以把值取出来用,现在我们取出这些变量的值 用printf打印: printf("Current time is %d:%d", hour, minute); 也就是说,变量名除了用在等号左边表示赋值之外,用在别的地方都表示把它的值取出来替换 在那里。在计算机中不同类型的变量所占的存储空间大小是不同的,变量的最小存储单位是字 节(Byte),在C语言中char型变量的存储空间是一个字节,其它类型的变量占多少个字节在不 同平台上有不同的规定,以后再详细讨论。 上一页 3. 变量 上一级 起始页 下一页 5. 表达式 上一页 5. 表达式 第 2 章 常量、变量和表达式 下一页 5. 表达式 常量和变量都可以参与加减乘除运算,例如1+1、hour-1、hour * 60 + minute、minute/60等。 这里的+-*/称为运算符(Operator),而参与运算的变量和常量称为操作数(Operand),上 面四个由运算符和操作数所组成的算式称为表达式(Expression)。 和数学上规定的一样,hour * 60 + minute这个表达式应该先算乘再算加,也就是说运算符是 有优先级(Precedence)的,*和/是同一优先级,+和-是同一优先级,*和/的优先级高于+和-。 对于同一优先级的运算从左到右计算,如果不希望按默认的优先级运算则要加括号 (Parenthesis)。例如(3+4)*5/6,应先算3+4,再算*5,再算/6。 我们前面讲了打印语句、变量定义语句、赋值语句,在任意一个表达式后面加个;号也成为一个 表达式语句,例如: hour * 60 + minute; 但是这个语句在程序中起不到任何作用,把hour的值和minute的值取出来加乘,得到的计算结 果却没有保存,白算了一通。事实上赋值语句就是一种表达式语句,因为等号也是一种运算 符,例如: int total_minute; total_minute = hour * 60 + minute; 这个语句就很有意义,把计算结果保存在另一个变量total_minute里,等号的优先级比+和*都 要低,所以先算出等号右边的结果然后才做赋值操作。任何一个表达式都能求出一个值来,表 达式hour * 60 + minute能算出一个值来,那个整个赋值表达式total_minute = hour * 60 + minute的值是什么呢?C语言规定等号运算符的计算结果就是等号左边被赋予的那个值。等号还 有一个和+-*/不同的特性,如果一个表达式中出现多个等号,不是从左到右计算而是从右到左计 算,例如: int total_minute, total; total = total_minute = hour * 60 + minute; 计算顺序是先算hour * 60 + minute得到一个结果,然后算右边的等号,就是把hour * 60 + minute的结果赋给变量total_minute,这个结果同时也是整个表达式total_minute = hour * 60 + minute的值,再算左边的等号,把这个值赋给变量total。同样优先级的运算符是从左到右计 算还是从右到左计算,这称为运算符的结合性(Associativity)。+-*/是左结合的,等号是右结 合的。 现在我们把常量、变量、表达式和语句统一起来了:常量可以赋值给变量,也可以和变量、运 算符一起组成表达式,最简单的表达式由单个常量或变量组成,任何表达式都有一个值,表达 式可以加个;号构成表达式语句。以前我们在程序中的很多地方使用常量或变量,其实这些地方 也可以使用表达式。例如,我们可以这样写: total_minute = hour * 60 + minute; printf("%d:%d is %d minutes after 00:00\n", hour, minute, total_minute); 也可以写得更简洁: printf("%d:%d is %d minutes after 00:00\n", hour, minute, hour * 60 + minute); 这个语句的执行顺序是:先求表达式的值,然后printf把表达式的值打印出来。printf可以打印表 达式,表达式不仅可以是单个的常量变量也可以是一个算式,第二条语句的写法就是这两条规 则的组合(Composition)。C语言规定了一组语法规则,只要符合它的规则,就可以写出任意 复杂的组合,比如以下一条语句同时完成了计算、赋值和打印的功能: printf("%d:%d is %d minutes after 00:00\n", hour, minute, total_minute = hour * 60 + minute); 理解组合这个概念是理解语法规则的关键所在,正因为可以对语法规则进行任意组合,所以我 们才可以用简单的常量、变量、表达式、语句搭建出任意复杂的程序,以后我们学习新的语法 规则时会进一步体会到这一点。从上面的例子可以看出,表达式不宜过度组合,否则会给阅读 和调试带来困难。 我们看到等号的右边可以是任意组合的表达式,但要注意等号左边不能是任意组合的表达式, 因为等号左边表示的不是一个值而是一个存储位置,例如下面的赋值语句是错误的: minute + 1 = hour; 这是等号运算符和+-*/运算符的又一个显著不同。等号左边表示存储位置,称为左值 (lvalue)。等号右边表示要存储的值,可以是任意组合的表达式,所以通常所说的表达式的值 也称为右值(rvalue)。 关于整数除法运算有一点特殊之处: hour = 11; minute = 59; printf("%d and %d hours\n", hour, minute / 60); 执行结果是11 and 0 hours,也就是说59/60得到0,这是因为两个整数相除的结果仍为整数, 并且总是舍去小数部分,即使小数部分是0.98也要舍去。向下取整的运算称为Floor,用数学符 号 表示,与之相对的,向上取整的运算称为Ceiling,用数学符号 表示。例如: 59/60 =0 59/60 =1 -59/60 =-1 -59/60 =0 C语言定义的取整运算既不是Floor也不是Ceiling,无论操作数是正是负总是把小数部分截断 (Truncate),所以当操作数为正的时候相当于Floor,当操作符为负的时候相当于Ceiling。回 到先前的例子,要得到更精确的结果可以这样: printf("%d hours and %d percent of an hour\n", hour, minute * 100 / 60); printf("%d and %f hours\n", hour, minute / 60.0); 第二个printf中,表达式是minute / 60.0,60.0是一个浮点数,/运算都要求左右两边的操作数 类型一致,而现在并不一致。事实上C语言定义一系列隐式类型转换(Implicit Conversion)规 则,在这里编译器自动把左边的minute也转换成浮点数来计算,得到的值仍然是浮点数,在格 式化字符串中应该用%f占位符。本来编程语言作为一种形式语言要求有简单而严格的规则,自 动类型转换规则不仅很复杂,而且使C语言的形式看起来也不那么严格了,C语言这么设计是为 了书写程序简便而做的折衷,有些事情编译器可以自动做掉,程序员就不必每次都写一堆繁琐 的代码。然而对初学者来说这是个坏消息,类型转换规则非常不容易掌握,在本书的前几章里 将会避免使用,等后面讲了相关的基础知识后再集中解决这个问题。 习题 1、假设变量x和n是两个正整数,我们知道x/n这个表达式的结果是取Floor,例 如x是17,n是4,则结果是4。如果希望结果取Ceiling应该怎么写表达式呢?例 如x是17,n是4,则结果是5,而x是16,n是4,则结果是4。 上一页 4. 赋值 上一级 起始页 下一页 6. 字符类型与字符编码 上一页 6. 字符类型与字符编码 第 2 章 常量、变量和表达式 下一页 6. 字符类型与字符编码 字符型常量或变量也可以参与运算,例如: printf("%c\n", 'a'+1); 执行结果是b。 我们在第 1 节 “程序和编程语言”讲过,计算机之所以能处理符号,是因为符号在计算机内部也 用数字来表示,每个字符在计算机内部都用一个整数来表示,称为字符编码(Character Encoding),目前大部分平台通用的是ASCII码(American Standard Code for Information Interchange,美国信息交换标准码),详见图 A.1 “ASCII码表”。表中每一栏的最后一列是字 符,前三列分别是用十进制(Dec)、十六进制(Hx)和八进制(Oct)表示的字符编码,有关 各种进制之间的换算以后再讲,从十进制那一列可以看出ASCII码的取值范围是0~127。表中的 很多字符是不可见字符(Non-printable Character)和空白字符(Whitespace)[5],不能 像'a'这样把字符本身填在表中,而是要用一个名字来表示,例如CR(carriage return)、LF(NL line feed,newline)、DEL等等。作为练习,请读者查一查表 2.1 “C标准规定的转义字符”中的 字符都在ASCII码表的什么位置。 回到刚才的例子,在ASCII码中字符'a'是97,字符b是98。'a'+1这个表达式,根据隐式类型转 换规则要把字符型转成整型再做计算,也就是把'a'按ASCII码转成整型的97,然后加1,得 到98,现在表达式的值是一个整型,而printf却以%c的格式打印它,于是printf把这个整数当 作ASCII码来解释,打印出相应的字符'b'。 之前我们说“整型”是指int型,而现在我们知道char型本质上就是整数,只不过取值范围比int型 小,所以以后我们把char型和int型统称为整数类型(Integer Type)或简称整型。其实还有几 种我们没学到的类型也属于整型,我们将在第 15 章 数据类型详解总结一下整型包括哪些类型。 在ASCII码表中,字符'a'~'z'、'A'~'Z'、'0'~'9'的ASCII码都是连续的,例如'a'+1和'b'的值相 等,'0'+9和'9'的值相等。注意'0'~'9'的ASCII码是十六进制的30~39,这是字符型'0'~'9'和整 数0~9的区别。 字符也可以用ASCII码的转义序列表示,这种表示可以用在字符常量或字符串字面值中,例 如'\0'表示NUL字符,'\11'或'\x9'表示Tab字符,"\11"或"\x9"表示由Tab字符组成的字符串。这种 转义序列由\加上1~3个八进制数字组成,或者由\x(大写的\X也可以)加上1~2个十六进制数字 组成。 [5] Whitespace在不同的上下文中有不同的含义,在C语言中Whitespace定义为空格、水 平Tab、垂直Tab、换行和分页符以及这些字符的某些组合,本书在使用Whitespace这个词时会 明确说明指的是哪些字符。 上一页 5. 表达式 上一级 起始页 下一页 第 3 章 简单函数 上一页 第 3 章 简单函数 目录 1. 数学函数 2. 自定义函数 3. 形参和实参 4. 局部变量与全局变量 上一页 6. 字符类型与字符编码 第 3 章 简单函数 部分 I. C语言入门 上一级 起始页 下一页 下一页 1. 数学函数 上一页 1. 数学函数 第 3 章 简单函数 下一页 1. 数学函数 在数学中我们用过sin和ln这样的函数,例如sin(π/2)=1,ln1=0等等,在C语言中也可以使用这 些函数: 例 3.1. 在C语言中使用数学函数 #include #include int main(void) { double pi = 3.1416; printf("sin(pi/2)=%f\nln1=%f\n", sin(pi/2), log(1.0)); return 0; } 编译运行这个程序,结果如下: $ gcc main.c -lm $ ./a.out sin(pi/2)=1.000000 ln1=0.000000 在数学中使用函数有时候书写可以省略括号,而C语言要求一定要加上括号,例如sin(pi/2)这 种形式。在C语言的术语中,pi/2是参数(Argument),sin是函数 (Function),sin(pi/2)是函数调用(Function Call)。这个函数调用在我们的printf语句中 处于什么位置呢?通过第 5 节 “表达式”的学习我们知道,这应该是放表达式的位置。因此,函 数调用也是一种表达式。这个表达式由函数调用运算符(也就是括号)和两个操作数组成,操 作数sin称为Function Designator,是函数类型(Function Type)的,操作数pi/2是double型 的。这个表达式的值就是sin(pi/2)的计算结果,在C语言的术语中称为函数的返回值(Return Value)。 现在我们可以完全理解printf语句了:原来printf也是一个函数,上例的printf语句中有三个参 数,第一个参数是格式化字符串,是字符串类型的,第二个和第三个参数是要打印的值,是浮 点型的,整个printf就是一个函数调用,也就是一个表达式,因此printf语句也是表达式语句的 一种。由于表达式可以传给printf做参数,而sin(pi/2)这个函数调用就是一个表达式,所以根 据组合规则,我们可以把sin调用套在printf调用里面,同理log调用也是如此。但是printf感觉 不像一个数学函数,为什么呢?因为像sin这种函数,我们传进去一个参数会得到一个返回值, 我们用sin函数就是为了用它的返回值,至于printf,我们并不关心返回值(事实上它也有返回 值,表示实际打印的字符数),我们用printf不是为了用它的返回值,而是为了利用它所产生 的副作用(Side Effect)--打印。C语言的函数可以有Side Effect,这一点是它和数学函数在 概念上的根本区别。 Side Effect这个概念也适用于运算符组成的表达式。比如a + b这个表达式也可以看成一个函数 调用,运算符+是一个函数,它的两个参数是a和b,返回值是两个参数的和,传入两个参数,得 到一个返回值,并没有产生任何Side Effect。而赋值运算符是产生Side Effect的,如果把a = b这个表达式看成函数调用,传入两个参数a和b分别做左值和右值使用,返回值就是所赋的值, 既是b的值也是a的值,但除此之外还产生了Side Effect,就是a的值被改变了,改变计算机存储 单元里的数据或者做输入或输出操作,这些都算Side Effect。 回想一下我们的学习过程,一开始我们说赋值是一种语句,后来学了表达式,我们说赋值语句 是表达式语句的一种,一开始我们说printf是一种语句,现在学了函数,我们又说printf也是表 达式语句的一种。随着我们一步步的学习,把原来看似不同类型的语句统一成一种语句了。学 习的过程总是这样,初学者一开始接触的很多概念从严格意义上说是错的,但是很容易理解, 随着一步步学习,在理解原有概念的基础上不断纠正,不断泛化(Generalize)。比如上一年级 老师说,小数不能减大数,其实这个概念是错的,后来引入了负数就可以减了,后来接触了分 数,原来的正数和负数的概念就泛化为和分数相对的整数,上初中学了无理数,原来的整数和 分数的概念就泛化为有理数,再上高中学了复数,有理数和无理数的概念就泛化为实数。坦白 说,到目前为止本书的很多说法是不完全正确的,但这是学习理解的必经阶段,到后面的章节 都会逐步纠正的。 现在也可以详细解释程序第一行#号(Pound Sign,Number Sign或Hash Sign)后面加 个include的确切含义了,它后面写在尖括号(Angel Bracket)中的是一个文件名,称为头文件 (Header File),其中描述了我们程序中使用的系统函数,因此要使用printf就必须包 含stdio.h,要使用数学函数就必须包含math.h,如果什么系统函数都不用就不必包含任何头文 件,例如写一个程序int main(void){int a;a=2;return 0;},不需要包含头文件可以编译通 过,当然这个程序什么也做不了。 使用math.h中的函数还有一点特殊之处,gcc命令行必须加-lm选项,因为数学函数位 于libm.so库文件中(通常在/lib目录下),-lm选项告诉编译器,我们程序中用到的数学函数 要到这个库文件里找。本书用到的大部分库函数(例如printf)位于libc.so库文件中,以后称 为libc,使用libc中的库函数在编译时不需要加-lc选项,当然加了也不算错,因为这个选项 是gcc默认的。关于头文件和函数库目前理解这么多就可以了,以后再详细解释。 上一页 第 3 章 简单函数 上一级 起始页 下一页 2. 自定义函数 上一页 2. 自定义函数 第 3 章 简单函数 下一页 2. 自定义函数 目前为止我们都在用现有的系统函数,但我们也可以定义自己的函数来用,事实上我们已经这 么做了:我们定义了main这个函数。main函数的特殊之处在于执行程序时它自动被系统调用, 系统就认准了“main”这个名字,除了名字特殊之外,main函数和别的函数没有区别。通 过main函数的定义我们已经了解函数定义的语法了: 返回值类型 函数名(参数列表) { 语句列表 } 其中函数名的命名规则也遵循标识符的命名规则(见第 3 节 “变量”),注意自己定义的函数不 能跟main函数重名。由于我们定义的main函数不带任何参数,参数列表应写成void,main函数 的返回值是int类型的,return 0这个语句就表示返回值是0,main函数的返回值是返回给操作 系统看的,因为main函数是被操作系统调用的,通常程序执行成功就返回0,在执行过程中出错 就返回一个非零值。比如我们将main函数中的return语句改为return 4;再执行它,执行结束后 可以在Shell中看到它的退出状态: $ ./hello $ echo $? 4 $?是Shell中的一个特殊变量,表示上一个运行结束的程序的退出状态。关于main函数需要注意 两点: 1. [K&R]书上的main函数定义写成main(){...}形式,不写返回值类型也不写参数列表,这 是Old Style C的风格。Old Style C规定不写返回值类型就表示返回int型,不写参数列表 就表示参数类型和个数没有明确指出。这种宽松的规定会导致很多复杂的Bug产生,不幸 的是现在的C标准为了兼容旧的代码仍然保留了这种语法,但是读者绝不应该继续使用这 种语法。 2. 其实系统在调用main函数时是传参数的,以后会详细解释,所以main函数最标准的形式应 该是int main(int argc, char *argv[])。C标准也规定了int main(void)这种形式,如 果不使用系统传进来的两个参数也可以写成这种形式。但除了这两种形式之外,以其它形 式定义main函数都是错误的或不可移植的。 关于返回值和return语句我们将在第 1 节 “return语句”详细讨论,我们先从不带参数也没有返回 值的函数开始学习定义和使用函数: 例 3.2. 最简单的自定义函数 #include void newline(void) { printf("\n"); } int main(void) { printf("First Line.\n"); newline(); printf("Second Line.\n"); return 0; } 执行结果是: First Line. Second Line. 我们定义了一个newline函数给main函数调用,它的作用是打印一个换行,所以执行结果中间多 了一个空行。newline函数不仅不带参数,也没有返回值,返回值类型为void表示没有返回 值[6],这说明我们用这个函数完全是为了利用它的Side Effect。如果我们想要多次插入空行就可 以多次调用newline函数: int main(void) { printf("First Line.\n"); newline(); newline(); newline(); printf("Second Line.\n"); return 0; } 如果我们总需要三个三个地插入空行,我们可以再定义一个threeline函数每次插入三个空行: 例 3.3. 较简单的自定义函数 #include void newline(void) { printf("\n"); } void threeline(void) { newline(); newline(); newline(); } int main(void) { printf("Three lines:\n"); threeline(); printf("Another three lines.\n"); threeline(); return 0; } 从这个简单的例子中可以体会到: 1. 同一个函数可以被多次调用。 2. 可以用一个函数调用另一个函数,后者再去调第三个函数。 3. 通过自定义函数可以给一组复杂的操作起一个简单的名字,例如threeline。对于main函 数来说,只需要通过threeline这个简单的名字来调用就行了,不必知道打印三个空行具 体怎么做,所有的复杂操作都被隐藏在threeline这个名字后面。 4. 使用自定义函数可以使代码更简洁,main函数在任何地方想打印三个空行只需调用一个简 单的threeline(),而不必每次都写三个printf("\n")。 读代码和读文章不一样,按从上到下从左到右的顺序读代码未必是最好的。比如上面的例子, 按顺序应该是先看newline再看threeline再看main。如果你换一个角度,按代码的执行顺序来读 也许会更好:首先执行的是main函数中的语句,在一条printf之后调用了threeline,这时再去 看threeline的定义,其中又调用了newline,这时再去看newline的定义,newline里面有一 条printf,执行完成后返回threeline,这里还剩下两次newline调用,效果也都一样,这个执行 完之后返回main,接下来又是一条printf和一条threeline。如下图所示: 图 3.1. 函数调用的执行顺序 在这个过程中,我们就在模仿计算机执行这个程序,我们不仅要记住当前读到了哪一行代码, 还要记住现在读的代码是被哪个函数调用的,还要记住当前这段代码返回后应该从上一个函数 的什么地方接着往下读。 现在澄清一下函数声明、函数定义、函数原型(Prototype)这几个概念。比如void threeline(void)这一行,声明了一个函数的名字、参数类型和个数、返回值类型,这称为函数 原型。在代码中可以单独写一个函数原型,后面加;号结束,而不写函数体,例如: void threeline(void); 这种只能叫函数声明而不能叫函数定义,只有带函数体的声明才叫定义。上一章讲过,只有分 配存储空间的变量声明才叫变量定义,其实函数也是一样,编译器只有见到函数定义才会生成 指令,而指令在程序运行时当然也是要占存储空间的。那么没有函数体的函数声明有什么用 呢?它为编译器提供了有用信息,编译器在处理代码的过程中,只有见到函数原型(不管带不 带函数体)之后才知道这个函数的名字、参数类型和返回值,然后在碰到函数调用时才知道怎 么生成相应的指令,所以函数原型必须出现在函数调用之前,这也是遵循“先声明后使用”的原 则。 在上面的例子中,main调用threeline,threeline再调用newline,要保证每个函数的原型出现 newline threeline main 在调用之前,就只能按先 再 明,则可以改变函数的定义顺序: #include void newline(void); void threeline(void); int main(void) { ... } void newline(void) { ... } void threeline(void) { ... } 再 的顺序定义了。如果使用不带函数体的声 这样仍然遵循了先声明后使用的原则。 由于有Old Style C语法的存在,并不是所有函数声明都包含函数原型,例如声明void threeline();没有明确指出参数类型和个数,所以不算函数原型,这个声明提供给编译器的信息 只有函数名和返回值类型。如果在这样的声明之后调用函数,编译器将不做参数类型检查和自 动转换,所以很容易引入Bug。读者需要了解这个知识点以便维护旧的代码,但绝不应该按这种 风格写代码。 如果在调用函数之前没有声明它会怎么样呢?有的读者也许碰到过这种情况,我可以解释一 下,但纯粹是为了满足某些人的好奇心,认真写代码绝不应该这么写的。比如按上面的顺序定 义这三个函数,但是把开头的两行声明去掉: #include int main(void) { printf("Three lines:\n"); threeline(); printf("Another three lines.\n"); threeline(); return 0; } void newline(void) { printf("\n"); } void threeline(void) { newline(); newline(); newline(); } 编译时会报警告: $ gcc main.c main.c:17: warning: conflicting types for ‘threeline’ main.c:6: warning: previous implicit declaration of ‘threeline’ was here 但仍然能编译通过,运行结果也对。这里涉及到的规则称为函数的隐式声明(Implicit Declaration),在main函数中调用threeline时并没有声明它,则编译器认为此处隐式声明 了int threeline(void);,然后为这个调用生成相应的指令,隐式声明的参数类型和个数根据函 数调用代码来确定,隐式声明的返回值类型总是int。然后编译器接着往下看,看 到threeline函数的原型是void threeline(void),和先前的隐式声明的返回值类型不符,所以 才报这个警告。好在我们也没用到这个函数的返回值,所以执行结果仍然正确。 [6] 敏锐的读者可能会发现一个矛盾:如果函数newline没有返回值,那么表达式newline()不就 没有值了吗?然而我在前面却说任何表达式都有一个值。其实这正是设计void这么一个关键字 的原因:让没有返回值的函数调用有一个void值。然后再规定,表达式的计算结果可以 是void值,但表达式不能使用void值做计算,也就是说,如果一个表达式的值为void,就不能 把它当作另一个表达式的一部分来用。从而兼顾语法上的一致(任何表达式都有值,如果一个 表达式实在没有值就说它有void值)和语义上的不矛盾(void值是虚构出来的,不能做计 算)。 上一页 1. 数学函数 上一级 起始页 下一页 3. 形参和实参 上一页 3. 形参和实参 第 3 章 简单函数 下一页 3. 形参和实参 下面我们定义一个带参数的函数,我们需要在函数定义中指明参数的个数和每个参数的类型, 定义参数就像定义变量一样,需要为每个参数指明类型,并起一个符合标识符命名规则的名 字。例如: 例 3.4. 带参数的自定义函数 #include void print_time(int hour, int minute) { printf("%d:%d\n", hour, minute); } int main(void) { print_time(23, 59); return 0; } 需要注意的是,定义变量时可以把同样类型的变量列在一起,而定义参数却不可以,例如下面 这样的定义是错误的: void print_time(int hour, minute) { printf("%d:%d\n", hour, minute); } 学习C语言的人肯定都乐意看到这句话:“变量是这样定义的,参数也是这样定义的,一模一 样”,这意味着不用专门去记住参数应该怎么定义了。谁也不愿意看到这句话:“变量可以这样, 而参数却不可以”。C语言的设计者也不希望自己设计的语法规则里到处都是这种例外,一个容 易被用户接受的设计应该遵循最少例外原则(Rule of Least Surprise)。其实这里的这个规定 也不算十分的例外,并不是C语言的设计者故意找茬,而是不得不这么规定,读者想想为什么 呢?学习编程语言不应该死记各种语法规定,如果能够想清楚设计者这么规定的原因 (Rationale),不仅有助于记忆,而且会有更多收获。本书在必要的地方会解释一 些Rationale,或者启发读者自己去思考,例如先前在脚注中解释了void关键字的Rationale。 总的来说,C语言的设计是非常优美的,只要理解了少数的基本概念、基本原则就可以根据组合 规则写出任意复杂的程序,极少有例外的规定说这样组合是不允许的,或者那样类推是错误 的。相反,C++的设计就非常复杂,处处充满了例外,全世界没有几个人能把C++所有的规则都 牢记于心的,因而不被人广泛接受。这个观点在[UNIX编程艺术]一书中有详细阐述。 在本书中,凡是提醒读者注意的地方都是多少有些Surprise的地方,初学者如果按常理来想很可 能要出错,所以需要特别提醒一下。而初学者容易犯的另外一些错误,完全是因为没有掌握好 基本概念和基本原理,或者根本无视组合规则而全凭自己主观臆断所致,对于这一类问题本书 不会做特别的提醒,例如有的初学者看完第 2 章 常量、变量和表达式之后会这样打印π的值: double pi=3.1416; printf("pi\n"); 之所以会犯这种错误,一是不理解Literal的含义,二是自己想当然地把变量名组合到字符串里 去,而事实上根本没有这条组合规则。如果连这样的错误都需要在书上专门提醒,就好比提醒 小孩吃饭一定要吃到嘴里去,不要吃到鼻子里去,更不要吃到耳朵里去一样。 回到正题。我们调用print_time(23, 59)时,函数中的参数hour就代表23,参数minute就代 表59。确切地说,当我们讨论函数中的hour这个参数时,我们所说的“参数”是指形参 (Parameter),当我们讨论传一个参数23给函数时,我们所说的“参数”是指实参 (Argument),但是我习惯都叫参数而不习惯总把形参、实参这两个文绉绉的词挂在嘴边儿 (事实上大多数人都不习惯),读者可根据上下文判断我指的到底是形参还是实参。记住这条 基本原理:形参相当于函数中定义的变量,调用函数传递参数的过程相当于定义形参变量并且 用实参的值来初始化。例如这样调用: void print_time(int hour, int minute) { printf("%d:%d\n", hour, minute); } int main(void) { int h = 23, m = 59; print_time(h, m); return 0; } 相当于在函数print_time中执行了这样一些语句: int hour = h; int minute = m; printf("%d:%d\n", hour, minute); main函数的变量h和print_time函数的参数hour是两个不同的变量,只不过它们各自的存储空间 中存了相同的值23,因为变量h的值赋给了参数hour。同理,变量m的值赋给了参 数minute。C语言的这种传递参数的方式称为Call by Value。在调用函数时,每个参数都需要得 到一个值,函数定义中有几个Parameter,在调用中就需要传几个Argument,不能多也不能 少,每个参数的类型也必须对应上。但是为什么我们调用printf函数时传的Argument数目是变 化的,有时一个有时两个甚至更多个?这是因为C语言规定了一种特殊的参数列表格式,例 如printf的原型是这样的: int printf(const char *format, ...); 第一个参数是const char *类型的,后面的...可以代表0个或任意多个参数,这些参数的类型也是 不确定的,这称为可变参数(Variable Argument),以后我们再详细讨论这种格式。总之,任 何函数的定义既规定了返回值的类型,也规定了参数的类型和个数,即使像printf这样规定 为“不确定”也是一种明确的规定,调用函数就要严格遵守这些规定,通常我们说函数提供了一个 接口(Interface),调用函数就是使用这个接口,使用的前提是必须和接口保持一致。 习题 1、定义一个函数increment,它的作用是将传进来的参数加1,然后在main函数中 用increment函数来增加变量的值: void increment(int x) { x = x + 1; } int main(void) { int i = 1, j = 2; increment(i); /* now i becomes 2 */ increment(j); /* now j becomes 3 */ return 0; } 这个increment函数能奏效吗?为什么? 上一页 2. 自定义函数 上一级 起始页 下一页 4. 局部变量与全局变量 上一页 4. 局部变量与全局变量 第 3 章 简单函数 下一页 4. 局部变量与全局变量 我们把函数中定义的变量称为局部变量(Local Variable),由于形参相当于函数中定义的变 量,所以形参也相当于局部变量。在这里“局部”有两个含义: 1、某个函数中定义的变量不能被另一个函数使用。例如print_time中的hour和minute在main函 数中没有定义,不能使用,同样main函数中的局部变量也不能被print_time函数使用。如果这样 定义: void print_time(int hour, int minute) { printf("%d:%d\n", hour, minute); } int main(void) { int hour = 23, minute = 59; print_time(hour, minute); return 0; } main函数中定义了局部变量hour,print_time函数中也有参数hour,虽然它们名称相同,但仍 然是不同的变量,仍然代表不同的存储空间,只不过各自的存储空间中存了相同的 值23。main函数的局部变量minute和print_time函数的参数minute也是如此。 2、每次调用函数时局部变量都表示不同的存储空间。局部变量是在每次函数调用时分配存储空 间,每次函数返回时释放存储空间的,例如调用print_time(23, 59)时,分配hour和minute两个 变量的存储空间,在里面分别存上23和59,函数返回时释放它们的存储空间,下次再调 用print_time(12, 20)时,又分配hour和minute两个变量的存储空间,在里面分别存 上12和20。 我们知道,函数体可以由很多条语句组成,现在学过的有变量定义语句和表达式语句。在函数 体中,通常把所有的变量定义语句放在最前面,然后才是其它语句,这是传统C的规定,我们之 前举的所有例子都遵守这一规定。C99允许变量定义穿插在其它语句之中,只要对于每个变量都 遵循先定义后使用的原则就可以,不管怎么样,使用传统C的特性总是比较保险的。 与局部变量的概念相对的是全局变量(Global Variable),全局变量定义在所有的函数体之外, 它们在整个程序开始之前分配存储空间,在程序结束时释放存储空间,所有函数都可以通过全 局变量名访问它们,例如: 例 3.5. 全局变量 #include int hour = 23, minute = 59; void print_time(void) { printf("%d:%d in print_time\n", hour, minute); } int main(void) { print_time(); printf("%d:%d in main\n", hour, minute); return 0; } 全局变量在整个程序的所有函数中都可以访问,所以在整个程序运行过程中全局变量被读写的 顺序从源代码中看不出来(源代码的书写顺序并不能反映函数的调用顺序),出现了Bug往往就 是因为在某个不起眼的地方对全局变量的读写顺序不正确,如果代码规模很大,这种错误是很 难找出来的。另一方面,对于局部变量的访问不仅局限在一个函数内部,而且局限在一次函数 调用之中,从函数的源代码也很容易看出访问的先后顺序是怎样的,所以比较容易找到Bug。因 此,虽然全局变量用起来很方便,但一定要慎用,能用函数传参代替的就不要用全局变量。 如果全局变量和局部变量重名了会怎么样呢?如果上面的例子改为: 例 3.6. 作用域 则第一次调用print_time打印的是全局变量的值,第二次直接调用printf打印的则是main函数的 局部变量的值。在C语言中,每个标识符都有特定的作用域(Scope),全局变量是定义在所有 函数体之外的标识符,它的作用域从定义的位置开始直到源文件结束,而main函数局部变量的 作用域仅限于main函数之中。如上图所示,设想整个源文件是一张大纸,也就是全局变量的作 用域,而main函数是贴在这张大纸上的一张小纸,也就是main函数局部变量的作用域。在小纸 上用到标识符hour和minute时应该参考小纸上的定义,因为大纸(全局变量的作用域)被盖住 了,如果在小纸上用到某个标识符却没有找到它的定义,那么再去翻看下面的大纸,例如上图 中的变量x。 到目前为止我们在初始化一个变量时都是用常量做Initializer,其实也可以用表达式 做Initializer,但要注意一点:局部变量可以用任意类型相符的表达式来初始化,而全局变量只 能用常量表达式初始化。例如,全局变量pi这样初始化是合法的: double pi = 3.14 + 0.0016; 但这样初始化是不合法的: double pi = acos(-1.0); 然而局部变量这样初始化却是可以的。全局变量的初始值要求保存在编译生成的目标代码中, 所以必须在编译时就能计算出来,然而上面第二种Initializer的值必须在生成了目标代码之后 在运行时调用acos函数才能知道,所以不能用来初始化全局变量。请注意区分编译时和运行时 的概念。为了方便编译器实现这一限制,C语言从语法上规定了全局变量只能用常量表达式来初 始化,因此下面这种全局变量初始化也是不合法的: int minute = 360; int hour = minute / 60; 虽然在编译时也可以计算出hour的初始值,但minute / 60不是常量表达式,不符合语法规定。 如果全局变量在定义时不初始化,则初始值是0,也就是说,整型的就是0,字符型的就是'\0', 浮点型的就是0.0。如果局部变量在定义时不初始化,则初始值是不确定的,所以,局部变量在 使用前一定要先赋值,不管是通过初始化还是赋值运算符,如果读取一个不确定的值来使用肯 定会引入Bug。至于为什么这样规定,以后会讲到的。 如何证明“局部变量的存储空间在每次函数调用时分配,在函数返回时释放”?当我们想要确认某 些语法规则时,可以查教材,也可以查C99,但最快捷的办法就是编个小程序验证一下: 例 3.7. 验证局部变量存储空间的分配和释放 #include int foo(void) { int i; printf("%d\n", i); i = 777; } int main(void) { foo(); foo(); return 0; } 第一次调用foo函数,分配变量i的存储空间,然后打印i的值,由于i未初始化,打出来应该是一 个不确定的值,然后把i赋值为777,函数返回,释放i的存储空间。第二次调用foo函数,分配变 量i的存储空间,然后打印i的值,由于i未初始化,如果打出来的又是一个不确定的值,就证明 了“局部变量的存储空间在每次函数调用时分配,在函数返回时释放”。分析完了,我们运行程序 看看是不是像我们分析的这样: 134518128 777 结果出乎我们意料,第二次调用打出来的i值正是第一次调用末尾给i赋的值777。有一种初学者 是这样,原本就没有把这条语法规则记牢,或者对自己的记忆力没信心,看到这个结果就会 想:哦那肯定是我记错了,改过来记吧,应该是“函数中的局部变量具有一直存在的固定的存储 空间,每次函数调用时使用它,返回时也不释放,再次调用函数时它应该还能保持上次的值”。 还有一种初学者是怀疑论者或不可知论者,看到这个结果就会想:教材上明明说“局部变量的存 储空间在每次函数调用时分配,在函数返回时释放”,那一定是教材写错了,教材也是人写的, 是人写的就难免出错,哦,连C99也这么写的啊,C99也是人写的,也难免出错,或者C99也许 没错,但是反正运行结果就是错了,计算机这东西真靠不住,太容易受电磁干扰和宇宙射线影 响了,我的程序写得再正确也有可能被干扰得不能正确运行。 这是初学者最常见的两种心态。不从客观事实和逻辑推理出发分析问题的真正原因,而仅凭主 观臆断胡乱给问题定性,“说你有罪你就有罪”。先不要胡乱怀疑,我们再做一次实验,在两 个foo调用之间插一个别的调用,结果就大不相同了: int main(void) { foo(); printf("hello\n"); foo(); return 0; } 结果是: 134518200 hello 0 这一回,第二次调用foo打出来的i值又不是777了而是0,“局部变量的存储空间在每次函数调用 时分配,在函数返回时释放”这个结论似乎对了,但另一个结论又不对了:全局变量不初始化才 是0啊,不是说“局部变量不初始化则初值不确定”吗? 关键的一点是,我说“初值不确定”,有没有说这个不确定值不能是0?有没有说这个不确定值不 能是上次调用赋的值?在这里“不确定”的准确含义是:每次调用这个函数时局部变量的初值可能 不一样,运行环境不同,函数的调用次序不同,都会影响到局部变量的初值。在运用逻辑推理 时一定要注意,不要把必要条件(Necessary Condition)当充分条件(Sufficient Condition),这一点在Debug时尤其重要,看到错误现象不要轻易断定原因是什么,一定要考 虑再三,找出它的真正原因。例如,不要看到第二次调用打印出777就断定“函数中的局部变量 具有一直存在的固定的存储空间,每次函数调用时使用它,返回时也不释放,再次调用函数时 它应该还能保持上次的值”,777这个结果是结论的必要条件,但不充分。也不要看到第二次调 用打印出0就断定“局部变量未初始化则初值为0”,0这个结果是结论的必要条件,但不充分。至 于为什么会有这样的现象,这个不确定值刚好是777,刚好是0,以后我们再分析。 上一页 3. 形参和实参 上一级 起始页 下一页 第 4 章 分支语句 上一页 第 4 章 分支语句 目录 1. if语句 2. if/else语句 3. 布尔代数 4. switch语句 上一页 4. 局部变量与全局变量 第 4 章 分支语句 部分 I. C语言入门 上一级 起始页 下一页 下一页 1. if语句 上一页 1. if语句 第 4 章 分支语句 下一页 1. if语句 目前我们写的简单函数中可以有多条语句,但这些语句总是从前到后顺序执行的。除了从前到 后顺序执行之外,有时候我们需要检查一个条件,然后根据检查的结果执行不同的后续代码, 在C语言中可以用分支语句(Selection Statement)实现,比如: if (x != 0) { printf("x is nonzero.\n"); } 其中x != 0表示“x不等于0”这个条件,这个表达式称为控制表达式(Controlling Expression)如 果条件成立,则{}中的语句被执行,否则{}中的语句不执行,直接跳到}后面。if和控制表达式改 变了程序的控制流程(Control Flow),不再是从前到后顺序执行,而是根据不同的条件执行不 同的语句,这种控制流程称为分支(Branch)。上例中的!=号表示“不等于”,像这样的运算符 有: 表 4.1. 关系运算符和相等性运算符 运算符 含义 == 等于 != 不等于 > 大于 < 小于 >= 大于或等于 <= 小于或等于 注意以下几点: 1. 这里的==表示数学中的相等关系,相当于数学中的=号,初学者常犯的错误是在控制表达 式中把==写成=,在C语言中=号是赋值运算符,两者的含义完全不同。 2. 如果表达式所表示的比较关系成立则值为真(True),否则为假(False),在C语言中分 别用1和0表示。例如x是-1,那么x>0这个表达式的值为0,x>-2这个表达式的值为1。 3. 在数学中a 0); printf("x is positive.\n"); 上一页 第 4 章 分支语句 上一级 起始页 下一页 2. if/else语句 上一页 2. if/else语句 第 4 章 分支语句 下一页 2. if/else语句 if语句还可以带一个else子句(Clause),例如: if (x % 2 == 0) printf("x is even.\n"); else printf("x is odd.\n"); 这里的%是取模(Modulo)运算符,x%2表示x除以2所得的余数(Remainder),%运算符的两 个操作数必须是整型。第 5 节 “表达式”讲过,如果/运算符的两个操作数都是整数,则结果是两 数的商(Quotient),余数总是舍去,因此有如下结论成立:a和b是两个整数,b不等 于0,(a/b)*b+a%b总是等于a。 取模运算在程序中是非常有用的,例如上面的例子判断x的奇偶性(Parity),看x除以2的余数 是不是0,如果是0则打印x is even.,如果不是0则打印x is odd.,读者应该能看出else在这 里的作用了,如果上面的例子去掉else,不管x是奇是偶,x is odd.这一句总是被打印。为了 让这条语句更有用,可以把它封装(Encapsulate)成一个函数: void print_parity(int x) { if (x % 2 == 0) printf("x is even.\n"); else printf("x is odd.\n"); } 把语句封装成函数的基本步骤是:把语句放到函数体中,把变量改成函数的参数。这样,以后 要检查一个数的奇偶性只需调用这个函数而不必重复写这条语句了,例如: print_parity(17); print_parity(18); if/else语句的格式为: if (控制表达式) 语句 else 语句 同样道理,其中的“语句”既可以是一条语句,也可以是由{}括起来的语句块。从这里我们又看到 了组合规则:一条if语句中包含一条子语句,一条if/else语句中包含两条子语句,子语句可以 是任何语句,当然也可以是另外一条if或if/else,子语句还可以是{}括起来的语句块,其中又 可以包含任何语句,当然也可以包含另外一条if或if/else。根据组合规则,if或if/else可以嵌 套使用。例如可以这样: if (x > 0) printf("x is positive.\n"); else if (x < 0) else printf("x is negative.\n"); printf("x is zero.\n"); 也可以这样: if (x > 0) { printf("x is positive.\n"); } else { if (x < 0) printf("x is negative.\n"); else printf("x is zero.\n"); } 现在有一个问题,类似if (A) if (B) C; else D;形式的语句怎么理解呢?可以理解成 if (A) if (B) C; else D; 也可以理解成 if (A) if (B) C; else D; 在第 1 节 “继续Hello World”中讲过,在C语言中缩进只是为了程序员看起来方便,实际上对编 译器不起任何作用,你的代码不管写成上面哪一种缩进格式,在编译器看起来都是一样的。那 么编译器到底认为是哪一种理解呢?也就是说,else到底是和if (A)配对还是和if (B)配对? 很多编程语言都有这个问题,这称为Dangling-else问题。C语言规定,else总是和它上面最近 的一个if配对,因此应该理解成else和if (B)配对,也就是上面第二种理解。如果你写成上面 第一种缩进的格式就很危险了:你看到的是这样,而编译器理解的却是那样。如果你希望的是 第一种理解,应该明确加上{}: if (A) { if (B) C; } else D; 习题 1、写两个表达式,分别取整数x的个位和十位 2、写一个函数,参数是整数x,功能是打印参数x的个位和十位 上一页 1. if语句 上一级 起始页 下一页 3. 布尔代数 上一页 3. 布尔代数 第 4 章 分支语句 下一页 3. 布尔代数 在第 1 节 “if语句”讲过,a、<、>=、<=,高 于==、!=,高于&&,高于||。写一个控制表达式很可能同时用到这些运算符中的多个,如果记 不清楚运算符的优先级顺序一定要套括号。不过这几个运算符的优先级顺序是应该记住的,因 为你需要看懂别人写的不套括号的代码。 习题 1、把代码段 if (x > 0 && x < 10); else printf("x is out of range.\n"); 改写成下面这种形式: if (____ || ____) printf("x is out of range.\n"); ____应该怎么填? 2、把代码段: if (x > 0) printf("Test OK!\n"); else if (x <= 0 && y > 0) printf("Test OK!\n"); else printf("Test failed!\n"); 改写成下面这种形式: if (____ && ____) printf("Test failed!\n"); else printf("Test OK!\n"); ____应该怎么填? 3、有这样一段代码: if (x > 1 && y != 1) { ...... } else if (x < 1 && y != 1) { ...... } else { ...... } 要进入最后一个else,x和y需要满足条件____ || ____。这里应该怎么填? 4、以下哪一个if判断条件是多余的可以去掉?这里所谓的“多余”是指,某种情况下如果本来应该 打印Test OK!,去掉这个多余条件后仍然打印Test OK!,如果本来应该打印Test failed!,去 掉这个多余条件后仍然打印Test failed!。 if (x<3 && y>3) printf("Test OK!\n"); else if (x>=3 && y>=3) printf("Test OK!\n"); else if (z>3 && x>=3) printf("Test OK!\n"); else if (z<=3 && y>=3) printf("Test OK!\n"); else printf("Test failed!\n"); [7] C99也定义了专门的布尔类型_Bool,但是没有广泛应用。 上一页 2. if/else语句 上一级 起始页 下一页 4. switch语句 上一页 4. switch语句 第 4 章 分支语句 4. switch语句 switch语句可以产生具有多个分支的控制流程。它的格式是: switch(控制表达式) { case 常量表达式:语句序列 case 常量表达式:语句序列 default:语句序列 } 例如以下程序根据传入的参数1-7分别打印Monday-Sunday: 例 4.1. switch语句 下一页 如果传入的参数是2,则day==2,从case 2开始执行,也就是从控制表达式==常量表达式的那 条case开始执行,先是打印相应的信息,然后遇到break语句,它的作用是跳出整个switch语句 块。C语言规定各case的常量表达式必须互不相同,如果控制表达式不等于任何一个常量表达 式,则从default分支开始执行,通常把default分支写在最后,但不是必须的。使用switch语句 要注意几点: 1. case后面跟的必须是常量表达式,原因同第 4 节 “局部变量与全局变量”一样,因为这个值 必须在编译时计算出来。 2. 以后我们会讲到,浮点型是不能精确比较相等不相等的。因此C语言规定case后面跟的常 量表达式的值必须是可以精确比较的整型或字符型。 3. 进入case后如果没有遇到break语句就会一直往下执行,后面其它case或default下面的语 句也会被执行到,直到遇到break,或者执行到整个switch语句块的末尾。通常每 个case后面都要加上break语句,但有时候故意不加break来利用这个特性,例如: 例 4.2. 缺break的switch语句 switch语句不是必不可缺的,显然可以用一组if..else if...else if...else代替,但是一方面 用switch语句会使代码更清晰,另一方面,有时候编译器会对switch语句进行整体优化,使它比 等价的if/else语句所生成的目标代码效率更高。 上一页 3. 布尔代数 上一级 起始页 下一页 第 5 章 深入理解函数 上一页 第 5 章 深入理解函数 部分 I. C语言入门 第 5 章 深入理解函数 目录 1. return语句 2. 增量式开发 3. 递归 上一页 4. switch语句 上一级 起始页 下一页 下一页 1. return语句 上一页 1. return语句 第 5 章 深入理解函数 下一页 1. return语句 之前我们一直在main函数中使用return语句,现在是时候全面深入地学习一下了。在有返回值 的函数中,return语句的作用是提供整个函数的返回值,并结束当前函数的执行。在没有返回 值的函数中也可以使用return语句,例如当检查到一个错误时提前结束当前函数的执行: #include void print_logarithm(double x) { if (x <= 0.0) { printf("Positive numbers only, please.\n"); return; } printf("The log of x is %f", log(x)); } 这个函数首先检查参数x是否大于0,如果x不大于0就打印错误提示,然后提前结束函数的执行 返回到调用者,只有当x大于0时才能求对数,在打印了对数结果之后到达函数体的末尾,自然 地结束执行返回调用者。注意,使用数学函数log需要包含头文件math.h,由于x是浮点数,应 该与同类型的数做比较,所以写成0.0。 在第 2 节 “if/else语句”中我们定义了一个检查奇偶性的函数,如果是奇数就打印x is odd.,如 果是偶数就打印x is even.。事实上这个函数并不十分好用,我们定义一个检查奇偶性的函数 往往不是为了打印两个字符串就完了,而是为了根据奇偶性的不同分别执行不同的后续动作。 我们可以把它改成一个返回布尔值的函数: int is_even(int x) { if (x % 2 == 0) return 1; else return 0; } 有些人喜欢写成return(1);这种形式也可以,表达式外面套括号表示改变运算符优先级,在这 里没有任何作用。我们可以这样调用这个函数: int i = 19; if (is_even(i)) { /* do something */ } else { /* do some other thing */ } 返回布尔值的函数是一类非常有用的函数,在程序中通常充当控制表达式,函数名通常带 有is或if等表示判断的词,这类函数也叫做谓词(Predicate)。is_even这个函数写得有点啰 嗦,x % 2这个表达式本来就有0或非0值,直接把这个值当作布尔值返回就可以了: int is_even(int x) { return !(x % 2); } 记住这条基本原理:函数返回一个值相当于定义一个和函数返回值类型相同的临时变量并 用return后面的表达式来初始化。例如上面的函数调用相当于这样的过程: int 临时变量 = !(x % 2); 函数退出,局部变量x的存储空间释放; if (临时变量) { /* 临时变量用完就释放 */ /* do something */ } else { /* do some other thing */ } 当if语句对函数的返回值做判断时,函数已经退出,局部变量x已经释放,所以不可能是在这时 候才计算表达式!(x % 2)的值的,表达式的值必然是事先计算好了存在一个临时变量里的,然 后函数退出,局部变量释放,if语句对这个临时变量的值做判断。注意,虽然函数的返回值可 以看作是一个临时变量,但我们只是读一下它的值,读完值就释放它,而不能往它里面存新的 值,换句话说,函数的返回值不是左值,也就是说下面的赋值语句是非法的: is_even(20) = 1; 在第 3 节 “形参和实参”中讲过,C语言的传参规则是Call by Value,按值传递,现在我们知道返 回值也是按值传递的,即便返回语句写成return x;,返回的也是变量x的值,而非变量x本身, 因为变量x马上就要被释放了。 在写带有return语句的函数时要小心检查所有的代码路径(Code Path)。有些代码路径在任何 条件下都执行不到,这称为Dead Code,例如,如果把&&和||运算符记混了(我所了解的初学 者犯这个低级错误的不在少数),写出如下代码: void foo(int x, int y) { if (x >= 0 || y >= 0) { printf("both x and y are positive.\n"); return; } else if (x < 0 || y < 0) { printf("both x and y are negetive.\n"); return; } printf("x has a different sign from y.\n"); } 最后一行printf永远都没机会被执行到,是一行Dead Code。有Dead Code就一定有Bug,你 写的每一行代码都是想让程序在某种情况下去执行的,你不可能故意写出一些永远不会被执行 的代码,如果程序在任何情况下都不会去执行它,说明跟你预想的情况不一样,要么是你对所 有可能的情况分析得不正确,也就是逻辑错误,要么就是像上例这样的笔误,语义错误。还有 一些时候,对程序中所有可能的情况分析得不够全面将导致漏掉一些必需的代码路径,例如: int absolute_value(int x) { if (x < 0) { return -x; } else if (x > 0) { return x; } } 这个函数被定义为返回int,就应该在任何情况下都返回int,但是上面这个程序在x==0时安静 地退出函数,什么也不返回,C语言对于这种情况会返回什么结果是未定义的(Undefined), 通常返回不确定的值,并且在不同的平台和环境下返回值会很不一样。另外,注意这个例子中 把-当负号用了而不是当减号用,事实上+号也可以这么用。正/负号是单目运算符,而加减号是 双目运算符,正负号的优先级和第 3 节 “布尔代数”讲的逻辑非运算符相同,比加减的优先级要 高。 以上两段代码都不会产生编译错误,编译器只做语法检查和最简单的语义检查,而不检查程序 的逻辑[8]。虽然到现在为止你见到了各种各样的编译器错误提示,也许你已经十分讨厌编译器 报错了,但是很快你就会认识到,如果程序中有错误编译器还不报错,那一定比报错更糟糕。 比如上面的绝对值函数,在你测试的时候运行得很好,也许是你没有测到x==0的情况,也许刚 好在你的环境中x==0时返回的不确定值就是0,然后你放心地把它集成到一个数万行的程序之 中。然后你把这个程序交给用户,起初的几天里相安无事,之后每过几个星期就有用户报告说 程序出错,但每次出错的现象都不一样,而且这个错误很难复现,你想让它出现时它就不出 现,在你毫无防备时它突然又冒出来了。然后你花了大量的时间在数万行的程序中排查哪里错 了,几天之后终于幸运地找到了这个函数的问题,这时候你就会想,如果当初编译器能报个错 多好啊!所以,如果编译器报错了,不要责怪编译器太过于挑剔,它是在帮你节省大量的调试 时间。另外,在math.h中有一个fabs函数就是求绝对值的,我们通常不必自己写绝对值函数。 习题 1、编写一个布尔函数int is_leap_year(int year),判断参数year是不是闰年。如果某一年的 年份能被4整除,但不能被100整除,那么这一年就是闰年,此外,能被400整除的年份也是闰 年。 [8] 有的代码路径没有返回值的问题编译器是可以检查出来的,如果编译时加-Wall选项会报警 告。 上一页 第 5 章 深入理解函数 上一级 起始页 下一页 2. 增量式开发 上一页 2. 增量式开发 第 5 章 深入理解函数 下一页 2. 增量式开发 目前为止你看到了很多程序例子,也在它们的基础上做了很多改动,在这个过程中巩固所学的 知识。但是如果从头开始编写一个程序解决某个问题,应该按什么步骤来写呢?本节提出一种 增量式(Incremental)开发的思路,很适合初学者。 现在问题来了:我们要编一个程序求平面上的圆的面积,圆的半径以两个端点的座标(x1, y1)和(x2, y2)给出。首先分析和分解问题,把大问题分解成小问题,再对小问题分别求解。这个 问题可分为两步: 1. 由两端点座标求半径的长度,我们知道平面上两点间距离的公式是: distance = √((x2-x1)2+(y2-y1)2) 括号里的部分都可以用我们学过的C语言表达式来表示,求平方根可以用math.h中 的sqrt函数,因此这个小问题全部都可以用我们学过的知识解决。这个公式可以实现为一 个函数,参数是两点的座标,返回值是distance。 2. 上一步算出的距离是圆的半径,已知圆的半径之后求面积的公式是: area = π·radius2 也可以用我们学过的C语言表达式来解决,这个公式也可以实现为一个函数,参数 是radius,返回值是area。 首先编写distance这个函数,我们已经明确了它的参数是两点的座标,返回值是两点间距离, 可以先写一个简单的函数定义: double distance(double x1, double y1, double x2, double y2) { return 0.0; } 初学者写到这里就已经不太自信了:这个函数定义写得对吗?虽然我是按我理解的语法规则写 的,但书上没有和这个一模一样的例子,万一不小心遗漏了什么呢?既然不自信就不要再往下 写了,没有一个平稳的心态来写程序很可能会引入Bug。所以在函数定义中插一个return 0.0立 刻结束掉它,然后立刻测试这个函数定义得有没有错误: int main(void) { printf("distance is %f\n", distance(1.0, 2.0, 4.0, 6.0)); return 0; } 编译,运行,一切正常。这时你就会建立起信心了:既然没问题,就不用管它了,继续往下 写。在测试时给这个函数的参数是(1.0, 2.0)和(4.0, 6.0),两点的x座标距离是3.0,y座标距离 是4.0,因此两点间的距离应该是5.0,你必须事先知道正确答案是5.0,这样你才能测试程序运 算的结果对不对。当然,现在函数还没实现,运算结果肯定是不对的。现在我们再往函数里添 一点代码: double distance(double x1, double y1, double x2, double y2) { double dx = x2 - x1; double dy = y2 - y1; printf("dx is %f\ndyis %f\n", dx, dy); return 0.0; } 如果你不确定dx和dy这样初始化行不行,那么就此打住,在函数里插一条打印语句把dx和dy的 值打出来看看。把它和上面的main函数一起编译运行,由于我们事先知道结果应该是3.0和4.0, 因此能够验证程序算得对不对。一旦验证无误,函数里的这句打印就可以撤掉了,像这种打印 语句,以及我们用来测试的main函数,都起到了类似脚手架(Scaffold)的作用:在盖房子时很 有用,但它不是房子的一部分,房子盖好之后就可以拆掉了。房子盖好之后可能还需要维修、 加盖、翻新,又要再加上脚手架,这很麻烦,要是当初不用拆就好了,可是不拆不行,不拆多 难看啊。写代码却可以有一个更高明的解决办法:把Scaffolding的代码注释掉。 double distance(double x1, double y1, double x2, double y2) { double dx = x2 - x1; double dy = y2 - y1; /* printf("dx is %f\ndyis %f\n", dx, dy); */ return 0.0; } 这样如果以后出了新的Bug又需要跟踪调试时,还可以把这句重新加进代码中使用。两点的x座 标和y座标距离都没问题了,下面求它们的平方和: double distance(double x1, double y1, double x2, double y2) { double dx = x2 - x1; double dy = y2 - y1; double dsquared = dx * dx + dy * dy; printf("dsquared is %f\n", dsquared); return 0.0; } 然后再编译、运行,看看是不是得25.0。这样增量式地开发非常适合初学者,每写一行代码都 编译运行,确保没问题了再写一下行,这样一方面在写代码时更有自信,另一方面便于调试: 总是有一个先前的正确版本做参照,一旦运行出了问题,几乎可以肯定是刚才添的那一行代码 出了问题,避免了从很多行代码中去查找分析到底是哪儿出的问题。在这个过程中printf功不 可没,你怀疑哪一行代码有问题,就插一个printf进去看看中间的计算结果,任何错误都可以 通过这个办法找出来。以后我们会介绍程序调试工具gdb,它提供了更多的调试功能帮你分析更 隐蔽的错误。但即使有了gdb,printf这个最原始的办法仍然是最简单、最有效率的。最后一 步,我们完成这个函数: 例 5.1. distance函数 #include #include double distance(double x1, double y1, double x2, double y2) { double dx = x2 - x1; double dy = y2 - y1; double dsquared = dx * dx + dy * dy; double result = sqrt(dsquared); return result; } int main(void) { printf("distance is %f\n", distance(1.0, 2.0, 4.0, 6.0)); return 0; } 然后编译运行,看看是不是得5.0。随着编程经验越来越丰富,你可能每次写若干行代码再一起 测试,而不是像现在这样每写一行就测试一次,但不管怎么样,增量式开发的思路是很有用 的,它可以节省你大量的调试时间,不管你有多强,都不应该一口气写完整个程序再编译运 行,那几乎是一定会有Bug的,到那时候再找Bug就很难找了。 这个程序中引入了很多临时变量:dx、dy、dsquared、result,如果你有信心把整个表达式一次 性写好,也可以这样: double distance(double x1, double y1, double x2, double y2) { return sqrt((x2-x1) * (x2-x1) + (y2-y1) * (y2-y1)); } 这样写简洁得多了。但是如果出错了呢?只知道是这一长串表达式有错,但根本不知道错在 哪,而且整个函数就一个语句,插printf都没地方插。所以用临时变量有它的好处,程序更清 晰,调试更方便,而且有时候可以避免不必要的计算,例如上面这一行表达式要把(x2-x1)计算 两遍,如果算完(x2-x1)后把结果存在一个临时变量dx里,就不需要再算第二遍了。 接下来编写area这个函数: double area(double radius) { return 3.1416 * radius * radius; } 给两点的座标求距离,给半径求圆的面积,这两个子问题都解决了,如何把它们组合起来解决 整个问题呢?给出半径的两端点座标(1.0, 2.0)和(4.0, 6.0)求圆的面积,先用distance函数求出 半径的长度,再把这个长度传给area函数: double radius = distance(1.0, 2.0, 4.0, 6.0); double result = area(radius); 也可以这样: double result = area(distance(1.0, 2.0, 4.0, 6.0)); 我们一直把“给半径两端点的座标求圆面积”这个问题当作整个问题来看,如果它也是一个更大的 程序当中的子问题呢?我们可以把先前的两个函数组合起来做成一个新的函数以便日后使用: double area_point(double x1, double y1, double x2, double y2) { return area(distance(x1, y1, x2, y2)); } 还有一种组合的思路:不是把distance和area两个函数调用组合起来,而是把那两个函数中的 语句组合到一起: double area_point(double x1, double y1, double x2, double y2) { double dx = x2 - x1; double dy = y2 - y1; double radius = sqrt(dx * dx + dy * dy); return 3.1416 * radius * radius; } 这样组合是不理想的。这样组合了之后,原来写的distance和area两个函数还要不要了呢?如 果不要了删掉,那么如果有些情况只需要求两点间的距离,或者只需要给定半径长度求圆面积 呢?area_point把所有语句都写在一起,太不灵活了,满足不了这样的要求。如果保 留distance 和area 同时也保留这个area_point 怎么样呢?area_point 和distance 有相同的代码, 一旦在distance函数中发现了Bug,或者要升级distance这个函数采用更高的计算精度,那么不 仅要修改distance,还要记着修改area_point,同理,要修改area也要记着修改area_point,维 护重复的代码是非常容易出错的,在任何时候都要尽量避免。因此,尽可能复用(Reuse)以 前写的代码,避免写重复的代码。封装就是为了复用,把解决各种小问题的代码封装成函数, 在解决第一个大问题时可以用这些函数,在解决第二个大问题时可以复用这些函数。 解决问题的过程是把大的问题分成小的问题,小的问题再分成更小的问题,这个过程在代码中 的体现就是:函数是分层设计的。distance和area是两个底层函数,解决一些很小的问题, 而area_point是一个上层函数,上层函数通过调用底层函数来解决更大的问题,底层和上层函 数都可以被更上一层的函数调用,最终所有的函数都直接或间接地被main函数调用。如下图所 示: 图 5.1. 函数的分层设计 上一页 1. return语句 上一级 起始页 下一页 3. 递归 上一页 3. 递归 第 5 章 深入理解函数 下一页 3. 递归 如果定义一个概念需要用到这个概念本身,我们称它的定义是递归的(Recursive)。例如: frabjuous an adjective used to describe something that is frabjuous. 这只是一个玩笑,如果你在字典上看到这么一个词条肯定要怒了。然而数学上确实有很多概念 是用它自己来定义的,比如n的阶乘(Factorial)是这样定义的:n的阶乘等于n乘以n-1的阶 乘。如果这样就算定义完了,恐怕跟上面那个词条有异曲同工之妙了:n-1的阶乘又是什么? 是n-1乘以n-2的阶乘。那n-2的阶乘呢?这样下去永远也没完。因此需要定义一个最关键的基础 条件(Base Case):0的阶乘等于1。 0! = 1 n! = n · (n-1)! 因此,3!=3*2!,2!=2*1!,1!=1*0!=1*1=1,正因为有了Base Case,才不会永远没完地数下去, 有了1!的结果我们再反过来算回去,2!=2*1!=2*1=2,3!=3*2!=3*2=6。下面我们用程序来完成这 一计算过程。我们要写一个计算阶乘的函数factorial,和以前一样,首先确定参数应该是int型 的,返回值也就是计算结果也应该是int型的。先把Base Case这种最简单的情况写进去: int factorial(int n) { if (n == 0) return 1; } 如果参数n不是0应该return什么呢?根据定义,应该return n*factorial(n-1);,为了下面的分 析方便我们引入几个临时变量把这个语句拆分一下: int factorial(int n) { if (n == 0) return 1; else { int recurse = factorial(n-1); int result = n * recurse; return result; } } factorial这个函数居然可以自己调用自己?是的。自己直接或间接调用自己的函数称为递归函 数。这里的factorial是直接调用自己,有些时候函数A调用函数B,函数B又调用函数A,也就 是函数A间接调用自己,这也是递归函数[9]。如果你觉得迷惑,可以把factorial(n-1)这一步看 成是在调用另一个函数--另一个有着相同函数名和相同代码的函数,调用它就是跳到它的代 码里执行,然后再返回factorial(n-1)这个调用的下一步继续执行。我们以factorial(3)为例分 析整个调用过程,如下图所示: 图 5.2. factorial(3)的调用过程 图中用实线箭头表示调用,用虚线箭头表示返回,右边的框表示在调用和返回过程中各函数调 用的局部变量的变化情况。 1. main()有一个局部变量result,用一个框表示。 2. 调用factorial(3)时要分配参数和局部变量的存储空间,于是在main()的下面又多了一个 框表示factorial(3)的参数和局部变量,其中n已初始化为3。 3. factorial(3)又调用factorial(2),又要分配factorial(2)的参数和局部变量,于是 在main()和factorial(3)下面又多了一个框。第 4 节 “局部变量与全局变量”讲过,每次调 用函数时分配参数和局部变量的存储空间,退出函数时释放它们的存储空 间。factorial(3)和factorial(2)是两次不同的调用,factorial(3)的参 数n和factorial(2)的参数n各自有各自的空间,虽然它们的变量名相同都是n,虽然我们 写代码时只写了一次参数n,但运行时却是两个不同的参数n。并且由于调 用factorial(2)时factorial(3)还没退出,所以两个函数的参数n同时存在,所以在原来的 基础上多画一个框。 4. 依此类推,请读者对照着图自己分析整个调用过程。读者会发现这个过程和前面我们用数 学公式计算3!的过程是一样的,都是先一步步展开然后再一步步收回去。 我们看图右边表示存储空间的框的变化过程,随着函数调用的层层深入,存储空间的一端逐渐 增长,然后随着函数的层层退出,存储空间的这一端又逐渐缩短,这是一种具有特定性质的数 据结构。它的特性就是只能在某一端增长或缩短,并且每次访问参数和局部变量时只能访问这 一末端的单元,而不能访问内部的单元,比如当factorial(2)的存储空间位于末端时,只能访问 它的参数和局部变量,而不能访问factorial(3)和main()的参数和局部变量。具有这种性质的数 据结构称为堆栈或栈(Stack)。每个函数调用的参数和局部变量的存储空间(图里的一个小方 框)称为一个栈帧(Stack Frame)。系统为每个程序的运行预留了栈空间,函数调用时就在这 个栈空间里分配栈帧,函数返回时就释放栈帧。 在写一个递归函数时,你如何证明它是正确的?像上面那样跟踪函数的调用和返回过程算是一 种办法,但只是factorial(3)就已经这么麻烦了,如果是factorial(100)呢?虽然我们已经证明 了factorial(3)是正确的,因为它跟我们用数学公式计算的过程一样,结果也一样,但这不能代 替factorial(100)的证明,你怎么办?别的函数你可以跟踪它的调用过程去证明它的正确性,因 为每个函数都调用一次就返回了,但是对于递归函数,这么跟下去只会跟得你头都大了。事实 上并不是每个函数调用都需要钻进去看的。我们在调用printf时没有钻进去看它是怎么打印 的,我们只是相信它能打印,能正确完成它的工作,然后就继续写下面的代码了。在上一节 中,我们写了distance和area函数,然后立刻测试证明了这两个函数是正确的,然后我们 写area_point时调用了这两个函数: return area(distance(x1, y1, x2, y2)); 在写这一句的时候,我们需要钻进distance和area函数中去走一遍才知道我们调用得是否正确 吗?不需要,因为我们已经相信这两个函数能正确工作了,也就是把座标传给distance它能返 回正确的距离,把半径传给area它能返回正确的面积,因此调用它们去完成另外一件工作也应 该是正确的。这种“相信”称为Leap of Faith,首先相信一些结论,然后再用它们去证明另外一些 结论。 在写factorial(n)的代码时写到这个地方: ...... int recurse = factorial(n-1); int result = n * recurse; ...... 这时,如果我们相信factorial(n-1)是正确的,也就是传给它n-1它就能返回(n-1)!,那 么recurse就是(n-1)!,那么result就是n*(n-1)!,也就是n!,这正是我们要返回 的factorial(n)的结果。当然这有点奇怪:我们还没写完factorial这个函数,凭什么要相 信factorial(n-1)是正确的?可Leap of Faith本身就是Leap(跳跃)的,不是吗?如果你相信 你正在写的递归函数是正确的,并调用它,然后在此基础上写完这个递归函数,那么它就会是 正确的,从而值得你相信它正确。 这么说好像有点儿玄,我们从数学上严格证明一下factorial函数的正确性。刚才说 了,factorial(n)的正确性依赖于factorial(n-1)的正确性,只要后者正确,在后者的结果上乘 个n返回这一步显然也没有疑问,那么我们的函数实现就是正确的。因此要证明factorial(n)的 正确性就是要证明factorial(n-1)的正确性,同理,要证明factorial(n-1)的正确性就是要证 明factorial(n-2)的正确性,依此类推下去,最后是:要证明factorial(1)的正确性就是要证 明factorial(0)的正确性。而factorial(0)的正确性不依赖于别的函数,它就是程序中的一个小 的分支return 1;,这个1是我们根据阶乘的定义写的,肯定是正确的,因此factorial(1)也正 确,因此factorial(2)也正确,依此类推,最后factorial(n)也是正确的。其实这就是中学时讲 的数学归纳法(Mathematical Induction),用数学归纳法来证明只需要证明两点:Base Case正确,递推关系正确。写递归函数时一定要记得写Base Case,否则即使递推关系正确, 整个函数也不正确。如果factorial函数漏掉了Base Case: int factorial(int n) { int recurse = factorial(n-1); int result = n * recurse; return result; } 那么这个函数就会永远调用下去,直到系统为程序预留的栈空间耗尽程序崩溃(段错误)为 止,这称为无穷递归(Infinite recursion)。 到目前为止我们只学习了全部C语言语法的一个小的子集,但是现在应该告诉你:这个子集是完 备的,它本身就可以作为一门编程语言了,以后还要学很多的C语言特性,但全部都可以用已经 学的这些特性来代替。也就是说,以后要学的C语言特性使得写程序更加方便,但不是必不可少 的,现在学的这些已经完全覆盖了第 1 节 “程序和编程语言”讲的五种基本指令了。有的读者会 说循环还没讲到呢,是的,循环下一章才讲,但有一个重要的结论就是递归和循环是等价的, 用循环能做的事用递归都能做,反之亦然,事实上有的编程语言(如某些LISP)只有递归而没 有循环。计算机硬件能做的所有事情就是数据存取、运算、测试和分支、循环(或递归),在 计算机上运行的高级语言写的程序当然也不可能做到更多的事情,虽然高级语言有丰富的语法 特性,但也只是为做这些事情提供一些方便。那么,为什么计算机是这样设计的?为什么想到 计算机需要具有这几种功能,而不是更多或者更少?这些要归功于早期的计算机科学家,例 如Alan Turing,他们在计算机还没有诞生的年代从数学理论上为计算机的设计指明了方向。有 兴趣的读者可以参考有关计算理论的教材,例如[IATLC]。 递归绝不只是为解决一些奇技淫巧的数学题用的(例如很多编程书都能查到的汉诺塔问题,本 书不打算讲这种无实际意义的题目),它是计算机的精髓所在,也是编程语言的精髓所在。我 们学习C语言的语法时已经看到很多的递归定义了,例如: 函数调用的语法是用实参定义的,实参是用表达式定义的,而表达式又是用函数调用定义 的,因为函数调用也是表达式的一种。 if/else是用两个子语句定义的,子语句又是用if/else定义的,因为if/else也是语句的一种。 可见编译器在翻译我们写的程序时一定也用了大量的递归。有关编译器的原理最经典的参考书 是[Dragon Book]。 习题 1、编写递归函数求两个正整数a和b的最大公约数(GCD,Greatest Common Divisor),使 用Euclid算法: 1. 如果a除以b能整除,则最大公约数是b。 2. 否则,最大公约数等于b和a%b的最大公约数。 Euclid算法是很容易证明的,请读者自己证明一下为什么这么算就能算出最大公约数。 2、编写递归函数求Fibonacci数列的第n项,这个数列是这样定义的: fib(0)=1 fib(1)=1 fib(n)=fib(n-1)+fib(n-2) 上面两个看似毫不相干的问题之间却有一个有意思的联系: Lamé定理 如果Euclid算法需要k步来计算两个数的GCD,那么这两个数之中较小的一个必然大于等 于Fibonacci数列的第k项。 感兴趣的读者可以参考[SICP]第1.2节的简略证明。 [9] A调用B,B又调用A,两个函数都要用到对方,把哪个函数定义在前面也不对,那怎么办 呢?以后讲到函数的声明就可以解决这个问题。 上一页 2. 增量式开发 上一级 起始页 下一页 第 6 章 循环语句 上一页 第 6 章 循环语句 目录 1. while语句 2. do/while语句 3. for语句 4. break和continue语句 5. 嵌套循环 6. goto语句 上一页 3. 递归 第 6 章 循环语句 部分 I. C语言入门 上一级 起始页 下一页 下一页 1. while语句 上一页 1. while语句 第 6 章 循环语句 下一页 1. while语句 在第 3 节 “递归”中,我们介绍了用递归求n!的方法,其实每次递归调用都是在重复做同样一件 事,就是把n乘到(n-1)!上然后把结果返回。虽说是重复,但每次做都稍微有一点区别(n的值不 一样),这种每次都有点区别的重复工作称为迭代(Iteration)。我们使用计算机的主要目的之 一就是让它做重复迭代的工作,因为把一件工作重复做成千上万次而不出错正是计算机最擅长 的,也是人类最不擅长的。虽然迭代用递归来做就够了,但C语言提供了循环语句使迭代程序写 起来更方便。例如factorial用while语句可以写成: int factorial(int n) { int result = 1; while (n > 0) { result = result * n; n = n - 1; } return result; } 像if语句一样,while由一个控制表达式和一个子语句组成,子语句可以是由若干条语句组成的 语句块。如果控制表达式的值为真,子语句就被执行,然后再次测试控制表达式的值,如果还 是真,就把子语句再执行一遍,再测试控制表达式的值……这种控制流程称为循环(Loop), 子语句称为循环体。如果某一次测试控制表达式的值为假,就跳出循环执行后面的return语 句,如果第一次测试控制表达式的值就是假,那么直接跳到return语句,循环体一次都不执 行。 变量result在这个循环中的作用是累加器(Accumulator),把每次循环的中间结果累积起来, 循环结束后得到的累积值就是最终结果,由于这个例子是用乘法来累积的,所以result初值 为1,如果是用加法来累积那么result初值应该是0。变量n是循环变量(Loop Variable),每次 循环要改变它的值,在控制表达式中要测试它的值,这两点合起来起到控制循环的次数的作 用,在这个例子中n的值是递减的,有些循环则采用递增的循环变量。这个例子具有一定的典型 性,累加器和循环变量这两种用法在循环中都很常见。 可见,递归能解决的问题用循环也能解决,但解决问题的思路不一样。用递归解决这个问题靠 的是递推关系n!=n·(n-1)!,用循环解决这个问题则更像是把这个公式展开了:n!=n·(n-1)·(n2)·…·3·2·1。把公式展开了理解会更直观一些,所以有些时候循环程序比递归程序更容易理解。 但有些时候要把公式展开是非常复杂的甚至是不可能的,反倒是递推关系更直观一些,这种情 况下递归程序比循环程序更容易理解。此外还有一点不同:看图 5.2 “factorial(3)的调用过程”, 在整个递归调用过程中,虽然分配和释放了很多变量,但是所有的变量都只在初始化时赋值, 没有任何变量的值发生过改变,而上面的循环程序则是通过对n和result这两个变量多次赋值来 达到同样目的的。前一种思路称为函数式编程(Functional Programming),而后一种思路称 为命令式编程(Imperative Programming),这个区别类似于第 1 节 “程序和编程语言”讲 的Declarative和Imperative的区别。函数式编程的“函数”类似于数学函数的概念,回顾一 下第 1 节 “数学函数”所讲的,数学函数是没有Side Effect的,而C语言的函数可以有Side Effect,比如在一个函数中修改某个全局变量的值就是一种Side Effect。第 4 节 “局部变量与全 局变量”指出,全局变量被多次赋值会给调试带来麻烦,如果一个函数体很长,控制流程很复 杂,那么局部变量被多次赋值也会有同样的问题。此外,以后我们会讲到,对全局变量多次赋 值会影响代码的线程安全性。因此,不要以为“变量可以多次赋值”是天经地义的,很多编程语言 都在避免Imperative的方式,例如Erlang语言规定变量的值不允许改变。用C语言编程主要还是 采用Imperative的方式,但是要记住,为变量多次赋值时要格外小心,在代码中多次读写同一变 量应该以一种一致的方式进行,至于什么才算是“一致的方式”很难定义,也有个人风格的因素, 需要读者在编程中自己体会。 正如递归函数如果写得不小心就会变成无穷递归一样,循环如果写得不小心就会变成无限循环 (Infinite Loop)或者叫死循环。如果while语句的控制表达式永远为真就是一个死循环,例 如while(1){...}。在写循环时要小心检查你写的控制表达式有没有可能取值为假,除非你故意 写死循环(有的时候这是必要的)。在上面的例子中,不管n一开始是几,每次循环都会把n减 掉1,n越来越小最后必然等于0,所以控制表达式最后必然取值为假,但如果把n = n - 1;那句 漏掉就成死循环了。有的时候是不是死循环并不那么一目了然: while (n != 1) { if (n % 2 == 0) { n = n / 2; } else { n = n * 3 + 1; } } 如果n为正整数,这个循环能跳出来吗?循环体所做的事情是:如果n是偶数,就把n除以2,如 果n是奇数,就把n乘3加1。一般的循环变量要么递增要么递减,可是这个例子中的n一会儿变大 一会儿变小,最终会不会变成1呢?可以找个数试试,例如一开始n等于7,每次循环后n的值依 次是:7、22、11、34、17、52、26、13、40、20、10、5、16、8、4、2、1。最后n确实等 于1了。读者可以再试几个数都是如此,但无论试多少个数也不能代替证明,这个循环有没有可 能对某些正整数n是死循环呢?其实这个例子只是给读者提提兴趣,同时提醒读者写循环时要有 意识地检查控制表达式。至于这个循环有没有可能是死循环,这是著名的3x+1问题,目前世界 上还无人能证明。许多世界难题都是这样的:描述无比简单,连小学生都能看懂,但证明却无 比困难。 习题 1、用循环来解决第 3 节 “递归”的练习题,体会递归和循环这两种不同的思路。 2、编写程序数一下1到100的所有整数中出现多少次数字9。在写程序之前先把这些问题考虑清 楚: 1. 这个问题中的循环变量是什么? 2. 这个问题中的累加器是什么?用加法还是用乘法累积? 3. 取一个整数的个位和十位在第 2 节 “if/else语句”的练习中已经练过了,这两个表达式应该 怎样用在程序中? 上一页 第 6 章 循环语句 上一级 起始页 下一页 2. do/while语句 上一页 2. do/while语句 第 6 章 循环语句 下一页 2. do/while语句 do/while语句的格式是: do 语句 while(控制表达式); 它和while类似,其中的语句可以是一个语句块,构成循环体。只不过while是先测试控制表达 式的值再执行循环体,而do/while是先执行循环体再测试控制表达式的值。如果控制表达式的 值一开始就是假,while的循环体一次都不执行,而do/while的循环体至少会执行一次。其实只 要有while这一种循环就足够了,do/while循环和后面要讲的for循环都可以改写成while循环, 只不过有些情况下用do/while或for循环写起来更简便,代码更易读。上面的factorial也可以改 用do/while来写: int factorial(int n) { int result = 1; int i = 1; do { result = result * i; i = i + 1; } while (i <= n); return result; } 注意do/while这种形式在while(控制表达式)后面一定要加;号,否则编译器无法判断这是一 个do/while循环的结尾还是另一个while循环的开头。写循环时一定要注意循环即将结束时控制 表达式的临界条件是否准确,上面的循环结束条件如果写成i int is_prime(int n) { int i; for (i = 2; i < n; i++) if (n % i == 0) break; if (i == n) return 1; else return 0; } int main(void) { int i; for (i = 1; i <= 100; i++) { if (!is_prime(i)) continue; printf("%d\n", i); } return 0; } is_prime函数从2到n-1依次检查有没有能被n整除的数,如果有就说明n不是素数,立刻跳出循 环而不执行++i。因此,如果n不是素数,则循环结束后i一定小于n,如果n是素数,则循环结束 后i一定等于n。注意检查临界条件:2应该是素数,如果n是2,则循环体一次也不执行,但是i的 初值就是2,也等于n,在程序中也判定为素数。其实没有必要从2一直检查到n-1,只需要从2检 查到sqrt(n),全都不能整除就足以证明n是素数了,请读者想一想为什么。 在主程序中,从1到100依次检查每个数是不是素数,如果不是素数,并不直接跳出循环,而 是++i后继续执行下一次循环,因此用continue语句。注意主程序的局部变量i和is_prime中的局 部变量i是不同的两个变量,其实在调用is_prime函数时,主程序中的局部变量i的值和参数n的 值相等。 习题 1、求素数这个程序只是为了说明break和continue的用法才这么写的,其实完全可以不 用break和continue,请读者修改一下循环的结构,去掉break和continue而保持功能不变。 2、在上一节中讲过怎样把for语句写成等价的while语句,但也提到如果循环体中有continue语 句,这两种形式就不等价了,想一想为什么不等价了? 上一页 3. for语句 上一级 起始页 下一页 5. 嵌套循环 上一页 5. 嵌套循环 第 6 章 循环语句 下一页 5. 嵌套循环 上一节求素数的例子在循环中调用一个函数,而那个函数又是一个循环,这其实是一种嵌套循 环。如果不是调用函数而是写在一起就更清楚了: 例 6.2. 用嵌套循环求1-100的素数 #include int main(void) { int i, j; for (i = 1; i <= 100; i++) { for (j = 2; j < i; j++) if (i % j == 0) break; if (j == i) printf("%d\n", i); } return 0; } 现在内循环的循环变量就不能再用i了,而是改用j,原来程序中is_prime函数的参数n现在直接 用i代替。在有嵌套循环的情况下,break只能跳出最内层的循环或switch语句,continue也只能 终止最内层循环并回到该循环的开头。 除了打印一列数据之外,用循环还可以打印表格式的数据,比如打印小九九乘法表: 例 6.3. 打印小九九 #include int main(void) { int i, j; for (i=1; i<=9; i++) { for (j=1; j<=9; j++) printf("%d ", i*j); printf("\n"); } return 0; } 内循环每次打印一个数,数与数之间用两个空格隔开,外循环每次打印一行。结果如下: 123456789 2 4 6 8 10 12 14 16 18 3 6 9 12 15 18 21 24 27 4 8 12 16 20 24 28 32 36 5 10 15 20 25 30 35 40 45 6 12 18 24 30 36 42 48 54 7 14 21 28 35 42 49 56 63 8 16 24 32 40 48 56 64 72 9 18 27 36 45 54 63 72 81 有一位数的有两位数的,这个表格很不整齐,如果把打印语句改为printf("%d\t", i*j);就整齐 了,所以才需要有Tab(制表符)这么个字符。 习题 1、上面打印的小九九有一半数据是重复的,因为8*9和9*8的结果一样。请修改程序打印这样的 小九九: 1 2 4 3 6 9 4 8 12 16 5 10 15 20 25 6 12 18 24 30 36 7 14 21 28 35 42 49 8 16 24 32 40 48 56 64 9 18 27 36 45 54 63 72 81 2、编写函数diamond打印一个菱形。如果调用diamond(3, '*'),则打印: * * * * * 如果调用diamond(5, '+'),则打印: + + + + + + + + + + + + + 如果用偶数做参数则打印错误信息。 上一页 4. break和continue语句 上一级 起始页 下一页 6. goto语句 上一页 6. goto语句 第 6 章 循环语句 下一页 6. goto语句 分支、循环都讲完了,现在只剩下最后一种影响控制流程的语句了,就是goto语句,实现无条 件跳转。我们知道break只能跳出最内层的循环,如果在一个嵌套循环中遇到某个错误条件需要 立即跳到循环之外的某个地方做出错处理,就可以用goto语句,例如: for (...) for (...) { ... if (出现错误条件) goto error; } error: 出错处理; 这里的error:叫做标号(Label),给标号起名字也遵循标识符的命名规则。事实上我们 在第 4 节 “switch语句”学过的case和default后面也是跟一个冒号,在语法结构中也起标号的作 用。 goto语句过于强大了,从程序中的任何地方都可以无条件跳转到任何其它地方,只要给那个地 方起个标号就行,唯一的限制是goto只能跳到同一个函数的某个标号处,而不能跳到别的函数 里。所以,滥用goto语句会使程序的控制流程非常复杂,可读性很差。著名的计算机科学 家Edsger W. Dijkstra最早指出编程语言中goto语句的危害,提倡取消goto语句。goto语句不是 必须存在的,显然可以用别的办法替代,比如上面的代码段可以改写为: int cond = 0; /* bool variable indicating error condition */ for (...) { for (...) { ... if (出现错误条件) { cond = 1; break; } } if (cond) break; } if (cond) 出错处理; 通常goto语句只用于在函数末尾做出错处理(例如释放先前分配的资源、恢复先前改动过的全 局变量等),函数中任何地方出现了错误条件都可以立即跳到函数末尾,处理完之后函数返 回。比较上面两种写法,用goto语句还是方便很多。但是除了这个用途之外,在任何场合都不 要轻易考虑使用goto语句。 上一页 5. 嵌套循环 上一级 起始页 下一页 第 7 章 结构体 上一页 第 7 章 结构体 目录 1. 复合数据类型--结构体 2. 数据抽象 3. 数据类型标志 4. 嵌套结构体 上一页 6. goto语句 第 7 章 结构体 部分 I. C语言入门 上一级 起始页 下一页 下一页 1. 复合数据类型--结构体 上一页 1. 复合数据类型--结构体 第 7 章 结构体 下一页 1. 复合数据类型--结构体 到目前为止我们使用的大多数数据类型都具有单一的值,例如整数、字符、布尔值、浮点数, 这些可称为基本数据类型(Primitive Type)。但字符串是一个例外,它由很多字符组成,像这 种由基本类型组成的数据类型称为复合数据类型(Compound Type),正如表达式和语句有组 合规则一样,由基本类型组成复合类型也有一些组合规则,例如本章要讲的结构体,以 及第 8 章 数组要讲的数组和字符串。复合数据类型一方面可以从整体上当作一个数据使用,另 一方面也可以分别访问它的各组成单元,复合数据类型的这种两面性提供了一种数据抽象 (Data Abstraction)的方法。[SICP]指出,在学习一门编程语言时,要特别注意以下三方面: 1. 这门语言提供了哪些Primitive,比如基本数据类型,比如基本的运算符、表达式和语句。 2. 这门语言提供了哪些组合规则,比如复合数据类型,比如表达式和语句的组合规则。 3. 这门语言提供了哪些抽象机制,例如数据抽象和过程抽象(Procedure Abstraction)。 本节将以结构体为例来讲解数据类型的组合和抽象。至于过程抽象我们已经见过最简单的形 式,就是把一组语句用一个函数名封装起来,当作一个整体使用,以后我们还会介绍更复杂的 过程抽象。 现在我们用C语言表示一个复数。如果从直角座标系来看,复数由实部和虚部组成,如果从极座 标系来看,复数由模和辐角组成,两种座标系可以相互转换。如下图所示 图 7.1. 复数 比如用实部和虚部表示一个复数,我们可以采用两个double型组成的结构体: struct complex_struct { double x, y; }; 这样定义了complex_struct这个标识符,既然是标识符,那么它的命名规则就和变量一样,但它 不表示一个变量,而表示一个类型,这种标识符在C语言中称为Tag,struct complex_struct { double x, y; }整个可以看作一个类型名,就像int或double一样,只不过它是一个复合类 型[11],如果用这个类型名来定义变量,可以这样写: struct complex_struct { double x, y; } z1, z2; 这样z1和z2就是两个变量名,变量定义后面带个;号是我们早就习惯的。但即使像上面那样只定 义了complex_struct这个Tag而不定义变量,后面的;号也不能少。这点一定要注意,结构体定 义后面少;号是初学者很常犯的错误。不管是用上面两种形式的哪一种形式定义 了complex_struct这个Tag,以后都可以直接用struct complex_struct来代替类型名了。例如 可以这样定义另外两个复数变量: struct complex_struct z3, z4; 如果在定义结构体类型的同时定义了变量,也可以不必写Tag,例如: struct { double x, y; } z1, z2; 但这样就没有办法再次引用这个结构体类型了,因为它没有名字。每个复数变量都有两个成员 (Member)x和y,可以用.运算符(.号,Period)来访问,这两个成员的存储空间是相邻 的[12],合在一起组成复数变量的存储空间。看下面的例子: 例 7.1. 定义和访问结构体 #include int main(void) { struct complex_struct { double x, y; } z; double x = 3.0; z.x = x; z.y = 4.0; if (z.y < 0) printf("z=%f%fi\n", z.x, z.y); else printf("z=%f+%fi\n", z.x, z.y); return 0; } 注意上例中变量x和变量z的成员x的名字并不冲突,因为变量z的成员x总是用.运算符来访问的, 编译器可以区分开哪个x是变量x,哪个x是变量z的成员x,它们属于不同的命名空间(Name Space)。Tag也可以定义在函数外面,就像全局变量一样,这样定义的Tag在其定义之后的各 函数中都可以使用。例如: struct complex_struct { double x, y; }; int main(void) { struct complex_struct z; ...... 结构体变量也可以在定义时初始化,例如: struct complex_struct z = { 3.0, 4.0 }; Initializer中的数据依次赋给结构体的成员。如果Initializer中的数据比结构体的成员多,编译器 会报错,但如果只是末尾多个逗号不算错。如果Initializer中的数据比结构体的成员少,未指定 的成员将用0来初始化,就像未初始化的全局变量一样。例如以下几种形式的初始化都是合法 的: double x = 3.0; struct complex_struct z1 = { x, 4.0, }; /* z1.x=3.0, z1.y=4.0 */ struct complex_struct z2 = { 3.0, }; /* z2.x=3.0, z2.y=0.0 */ struct complex_struct z3 = { }; /* z3.x=0.0, z3.y=0.0 */ 其中,z1必须是函数的局部变量才能用变量x来初始化,如果是全局变量就只能用常量表达式来 初始化。尽管结构体的初始化可以用这种语法,结构体赋值却不行,例如这样是错误的: struct complex_struct z1; z1 = { 3.0, 4.0 }; 以前使用基本数据类型时,能用来初始化的表达式就能用来赋值,在这一点上结构体的语法规 则有点不同[13]。结构体类型的值用在表达式中有很多限制,不像基本数据类型那么自由,比 如+-*/等算术运算符和&&、||、!等逻辑运算符都不能作用于结构体类型,if、while的控制表达 式的值也不能是结构体类型。严格来说,可以做算术运算的类型称为算术类型(Arithmetic Type),算术类型包括整型和浮点型。可以做逻辑与、或、非运算的操作数或 者if、for、while的控制表达式的类型称为标量类型(Scalar Type),标量类型包括算术类型 和以后要讲的指针类型。 结构体类型之间用赋值运算符是允许的,用一个结构体初始化另一个结构体也是允许的,例 如: struct complex_struct z1 = { 3.0, 4.0 }; struct complex_struct z2 = z1; z1 = z2; 同样地,z2必须是局部变量才能用变量z1来初始化。既然可以这样用,那么结构体可以当作函 数的参数和返回值来传递就在意料之中了: struct complex_struct add_complex(struct complex_struct z1, struct complex_struct z2) { z1.x = z1.x + z2.x; z1.y = z1.y + z2.y; return z1; } 这个函数实现了两个复数相加,如果在main函数中这样调用: struct complex_struct z = { 3.0, 4.0 }; z = add_complex(z, z); 那么调用传参的过程如下图所示: 图 7.2. 结构体传参 变量z在main函数的栈帧中,参数z1和z2在add_complex函数的栈帧中,z的值分别赋给z1和z2。 在这个函数里,z2的实部和虚部被累加到z1中,然后return z1;,可以看成是: 1. 把z1拷到一个临时变量里。 2. 函数返回并释放栈帧。 3. 把临时变量的值拷给变量z,释放临时变量。 [11] 其实C99已经定义了复数类型_Complex。如果包含C标准库的头文件complex.h,也可以 用complex做类型名。当然,只要不包含头文件complex.h就可以自己定义complex这个名字,但 为了尽量减少混淆,本节的例子都用complex_struct这个名字。 [12] 以后我们会看到,结构体成员之间也可能有若干个填充字节(Padding)。 [13] C99引入一种新的语法Compound Literal,这个赋值写成z1 = (struct complex_struct){ 3.0, 4.0 };就对了,不过不建议读者使用C99的新特性。 上一页 第 7 章 结构体 上一级 起始页 下一页 2. 数据抽象 上一页 2. 数据抽象 第 7 章 结构体 下一页 2. 数据抽象 现在我们来实现一个完整的复数运算的程序。在上一节我们已经定义了复数的结构体,现在需 要围绕它定义一些函数。复数可以用直角座标或极座标表示,直角座标做加减法比较方便,极 座标做乘除法比较方便。如果我们定义的复数结构体是直角座标的,那么应该提供极座标的转 换函数,以便在需要的时候可以方便地取它的模和辐角: struct complex_struct { double x, y; }; double real_part(struct complex_struct z) { return z.x; } double img_part(struct complex_struct z) { return z.y; } double magnitude(struct complex_struct z) { return sqrt(z.x * z.x + z.y * z.y); } double angle(struct complex_struct z) { double PI = acos(-1.0); if (z.x > 0) return atan(z.y / z.x); else return atan(z.y / z.x) + PI; } 此外,我们还提供两个函数用来构造复数变量,既可以提供直角座标也可以提供极座标,在函 数中自动做相应的转换然后返回构造的复数变量: struct complex_struct make_from_real_img(double x, double y) { struct complex_struct z; z.x = x; z.y = y; return z; } struct complex_struct make_from_mag_ang(double r, double A) { struct complex_struct z; z.x = r * cos(A); z.y = r * sin(A); return z; } 在此基础上就可以实现复数的加减乘除运算了: struct complex_struct add_complex(struct complex_struct z1, struct complex_struct z2) { return make_from_real_img(real_part(z1) + real_part(z2), img_part(z1) + img_part(z2)); } struct complex_struct sub_complex(struct complex_struct z1, struct complex_struct z2) { return make_from_real_img(real_part(z1) - real_part(z2), img_part(z1) - img_part(z2)); } struct complex_struct mul_complex(struct complex_struct z1, struct complex_struct z2) { return make_from_mag_ang(magnitude(z1) * magnitude(z2), angle(z1) + angle(z2)); } struct complex_struct div_complex(struct complex_struct z1, struct complex_struct z2) { return make_from_mag_ang(magnitude(z1) / magnitude(z2), angle(z1) - angle(z2)); } 可以看出,复数加减乘除运算的实现并没有直接访问结构体complex_struct的成员x和y,而是把 它看成一个整体,通过调用相关函数来取它的直角座标和极座标。这样就可以非常方便地替换 掉结构体complex_struct的存储表示,例如改为用极座标来存储: struct complex_struct { double r, A; }; double real_part(struct complex_struct z) { return z.r * cos(z.A); } double img_part(struct complex_struct z) { return z.r * sin(z.A); } double magnitude(struct complex_struct z) { return z.r; } double angle(struct complex_struct z) { return z.A; } struct complex_struct make_from_real_img(double x, double y) { struct complex_struct z; double PI = acos(-1.0); z.r = sqrt(x * x + y * y); if (x > 0) z.A = atan(y / x); else z.A = atan(y / x) + PI; return z; } struct complex_struct make_from_mag_ang(double r, double A) { struct complex_struct z; z.r = r; z.A = A; return z; } 虽然结构体complex_struct的存储表示做了这样的改 动,add_complex、sub_complex、mul_complex、div_complex这几个复数运算的函数却不需要做 任何改动,仍可以使用,原因在于这几个函数只把结构体complex_struct当作一个整体来使用, 而没有直接访问它的成员,因此也不依赖于它有哪些成员。我们结合下图具体分析一下。 图 7.3. 数据抽象 这里要介绍的编程思想称为抽象。其实“抽象”这个概念并没有那么抽象,简单地说就是“提取公 因式”:ab+ac=a(b+c)。如果a变了,ab和ac这两项都需要改,但如果写成a(b+c)的形式就只需 要改其中一个因子。 在我们的复数运算程序中,复数有可能用直角座标或极座标表示,我们把这个有可能变动的因 素提取出来组成复数存储表示 层:real_part 、img_part 、magnitude 、angle 、make_from_real_img 、make_from_mag_ang。这 一层看到的是数据是结构体的两个成员x和y,或者r和A,如果改变了结构体的实现就要改变这 一层函数的实现,但函数接口不改变,因此调用这一层函数接口的复数运算层也不需要改变。 复数运算层看到的数据只是一个抽象的“复数”的概念,知道它有直角座标和极座标,可以调用复 数存储表示层的函数得到这些座标。再往上看,其它使用复数运算的程序看到的数据是一个更 为抽象的“复数”的概念,只知道它是一个数,像整数、小数一样可以加减乘除,甚至连它有直角 座标和极座标也不需要知道。 这里的复数存储表示层和复数运算层称为抽象层(Abstraction Layer),从底层往上层来 看,“复数”这种数据越来越抽象了,把所有这些层组合在一起就是一个完整的系统。组合使得系 统可以任意复杂,而抽象使得系统的复杂性是可以控制的,任何改动都只局限在某一层,而不 会波及整个系统。著名的计算机科学家Butler Lampson说过:“All problems in computer science can be solved by another level of indirection.”这里的indirection其实就是abstraction的 意思。 习题 1、在本节的基础上实现一个打印复数的函数,打印的格式是x+yi,如果实部或虚部为0则省 略,例如:1.0、-2.0i、-1.0+2.0i、1.0-2.0i。最后编写一个main函数测试本节的所有代码。想一 想这个打印函数应该属于上图中的哪一层? 2、实现一个用分子分母的格式来表示有理数的结构体Rational及相关的函数,Rational之间可 以做加减乘除运算,运算的结果仍然是Rational。测试代码如下: int main(void) { struct Rational a = make_rational(1, 8); /* a=1/8 */ struct Rational b = make_rational(-1, 8); /* b=-1/8 */ print_rational(add_rational(a, b)); print_rational(sub_rational(a, b)); print_rational(mul_rational(a, b)); print_rational(div_rational(a, b)); return 0; } 注意要约分为最简分数,例如1/8和-1/8相减的打印结果应该是1/4而不是2/8,可以利用第 3 节 “递归”练习题中的Euclid算法来约分。在动手编程之前先思考一下这个问题实现了什么样的数据 抽象,抽象层应该由哪些函数组成。 上一页 1. 复合数据类型--结构体 上一级 起始页 下一页 3. 数据类型标志 上一页 3. 数据类型标志 第 7 章 结构体 下一页 3. 数据类型标志 在上一节中,我们通过一个复数存储表示抽象层把complex_struct结构体的存储格式和上层的复 数运算函数隔开,complex_struct结构体既可以采用直角座标也可以采用极座标存储。但有时候 需要同时支持两种存储格式,比如先前已经采集了一些数据存在计算机中,有些数据是以极座 标存储的,有些数据是以直角座标存储的,如果要把这些数据都存到complex_struct结构体中怎 么办?一种办法是complex_struct结构体采用直角座标格式,直角座标的数据可以直接存 入complex_struct结构体,极座标的数据先用make_from_mag_ang函数转成直角座标再存,但转 换总是会损失精度的。这里介绍另一种办法,complex_struct结构体由一个数据类型标志和两个 浮点数组成,如果数据类型标志为0,那两个浮点数就表示直角座标,如果数据类型标志为1, 那两个浮点数就表示极座标。这样,直角座标和极座标的数据都可以适配 (Adapt)到complex_struct结构体中,无需转换和损失精度: enum coordinate_type { RECTANGULAR, POLAR }; struct complex_struct { enum coordinate_type t; double a, b; }; enum关键字的作用和struct关键字类似,把coordinate_type这个标识符定义为一个Tag,只不 过struct complex_struct表示一个结构体类型,而enum coordinate_type表示一个枚举 (Enumeration)类型。枚举类型的成员是常量,它们的值编译器自动分配,例如定义了上面的 枚举类型之后,RECTANGULAR就表示常量0,POLAR就表示常量1。如果不希望从0开始分 配,可以这样定义: enum coordinate_type { RECTANGULAR = 1, POLAR }; 这样,RECTANGULAR就表示常量1,而POLAR就表示常量2,这些常量的类型就是int。有一 点需要注意,结构体的成员名和变量名不在同一命名空间,但枚举的成员名和变量名却在同一 命名空间,所以会出现命名冲突。例如这样是不合法的: int main(void) { enum coordinate_type { RECTANGULAR = 1, POLAR }; int RECTANGULAR; printf("%d %d\n", RECTANGULAR, POLAR); return 0; } complex_struct结构体的格式变了,就需要修改复数存储表示层的函数,但只要保持函数接口不 变就不会影响到上层函数。例如: struct complex_struct make_from_real_img(double x, double y) { struct complex_struct z; z.t = RECTANGULAR; z.a = x; z.b = y; return z; } struct complex_struct make_from_mag_ang(double r, double A) { struct complex_struct z; z.t = POLAR; z.a = r; z.b = A; return z; } 习题 1、本节只给出了make_from_real_img和make_from_mag_ang函数的实现,请读者自己实 现real_part 、img_part 、magnitude 、angle 这些函数。 2、编译运行下面这段程序: #include enum coordinate_type { RECTANGULAR = 1, POLAR }; int main(void) { int RECTANGULAR; printf("%d %d\n", RECTANGULAR, POLAR); return 0; } 结果是什么?并解释一下为什么是这样的结果。 上一页 2. 数据抽象 上一级 起始页 下一页 4. 嵌套结构体 上一页 4. 嵌套结构体 第 7 章 结构体 下一页 4. 嵌套结构体 结构体也是一种递归定义:结构体由数据类型定义,因为结构体的成员具有数据类型,而数据 类型由结构体定义,因为结构体本身也是一种数据类型。换句话说,结构体也可以嵌套。例如 我们在复数的基础上定义复平面上的线段: struct Segment { struct complex_struct start; struct complex_struct end; }; 嵌套结构体可以嵌套地初始化。例如: struct Segment s = {{ 1.0, 2.0 }, { 4.0, 6.0 }}; 也可以平坦地初始化。例如: struct Segment s = { 1.0, 2.0, 4.0, 6.0 }; 甚至可以混合地初始化(这样可读性很差,应避免使用): struct Segment s = {{ 1.0, 2.0 }, 4.0, 6.0 }; 访问嵌套结构体的成员应该用多个.运算符,这也是意料之中的: s.start.t = RECTANGULAR; s.start.a = 1.0; s.start.b = 2.0; 上一页 3. 数据类型标志 上一级 起始页 下一页 第 8 章 数组 上一页 第 8 章 数组 目录 1. 数组的基本操作 2. 数组应用实例:统计随机数 3. 数组应用实例:直方图 4. 字符串 5. 多维数组 上一页 4. 嵌套结构体 第 8 章 数组 部分 I. C语言入门 上一级 起始页 下一页 下一页 1. 数组的基本操作 上一页 1. 数组的基本操作 第 8 章 数组 下一页 1. 数组的基本操作 和结构体类似,数组(Array)也是一种复合数据类型,它由一系列相同类型的元素 (Element)组成。例如定义一个由4个整数组成的数组count: int count[4]; 和结构体成员类似,数组count的4个元素的存储空间也是相邻的。结构体的成员可以是基本数 据类型,也可以是复合数据类型,数组中的元素也是如此。根据组合规则,我们可以定义一个 由4个结构体元素组成的数组: struct Complex { double x, y; } a[4]; 也可以定义一个包含数组成员的结构体: struct { double x, y; int count[4]; } s; 数组类型的长度应该用一个常量表达式来指定[14],而且这个常量表达式的值必须是整数类型 的,这一点和case后面跟的常量表达式的要求相同。数组中的元素通过下标(或者叫索 引,Index)来访问。例如前面定义的由4个整数组成的数组count图示如下: 图 8.1. 数组count 整个数组占了4个整数的存储单元,存储单元用小方框表示,里面的数字是存储在这个单元中的 整数(假设都是0),而框外面的数字是下标,这四个单元分别 用count[0]、count[1]、count[2]、count[3]来访问。注意,在定义数组int count[4];时,方 括号(Bracket)中的数字4表示数组的长度,而在访问数组时,方括号中的数字表示访问数组 的第几个元素。和我们平常数数不同,数组元素是从“第0个”开始数的,大多数编程语言都是这 么规定的,所以计算机术语中有Zeroth这个词。这样规定使得访问数组元素非常方便,比 如count数组中的每个元素占4个字节,则count[i]位于从数组开头跳过4*i个字节的存储位置。这 种数组下标的表达式不仅可以表示存储位置中的值,也可以表示存储位置本身,也就是说可以 做左值,因此以下语句都是正确的: count[0] = 7; count[1] = count[0] * 2; ++count[2]; 数组的下标也可以是表达式,但表达式的值必须是整型或字符型的。例如: int i = 10; count[i] = count[i+1]; 使用数组下标不能超出数组的长度范围,这一点在使用变量做数组下标时尤其要注意。C编译器 并不检查count[-1]或是count[100]这样的访问越界错误,编译时能顺利通过,所以属于运行时 错误[15]。但有时候这种错误很隐蔽,发生访问越界时程序可能并不会立即崩溃,而执行到后面 某个正确的语句时却有可能突然崩溃(在第 4 节 “段错误”中我们会看到这样的例子)。所以, 从一开始写代码时就要小心避免出问题,事后依靠调试来解决问题的成本是很高的。 数组也可以像结构体一样初始化,未赋初值的元素也是用0来初始化,例如: int count[4] = { 3, 2, }; 则count[0]等于3, count[1]等于2,后面两个元素等于0。如果定义数组的同时初始化它,也可 以不指定数组的长度,例如: int count[] = { 3, 2, 1, }; 编译器会根据Initializer有三个元素确定数组的长度为3。下面举一个完整的例子: 例 8.1. 定义和访问数组 #include int main(void) { int count[4] = { 3, 2, }, i; for (i = 0; i < 4; i++) printf("count[%d]=%d\n", i, count[i]); return 0; } 这个例子通过循环把数组中的每个元素依次访问一遍,在计算机术语中称为遍历 (Traversal)。注意控制表达式i < 4,如果写成i <= 4就错了,因为count[4]是访问越界。 数组和结构体虽然有很多相似之处,但也有一个显著的不同:数组不能互相赋值。例如这样是 错误的: int a[5], b[5] = { 4, 3, 2, 1 }; a = b; 既然不能互相赋值,也就不能用数组类型作为函数的参数或返回值。如果写出这样的函数定 义: void foo(int a[5]) { ... } 然后这样调用: int array[5] = {}; foo(array); 编译器也不会报错,但这样写并不是传一个数组类型参数的意思。对于数组类型有一条特殊规 则:数组名做右值使用时,自动转换成指向数组首元素的指针。所以上面的函数调用其实是传 一个指针类型的参数,而不是数组类型的参数。接下来的几章里有的函数需要访问数组,我们 就把数组定义为全局变量给函数访问,等以后我们讲了指针再使用传参的办法。这也解释了为 什么数组类型不能互相赋值,上面提到的a = b这个表达式,a和b都是数组类型的变量,但 是b做右值使用,自动转换成指针类型,而左边仍是数组类型,所以编译器报的错误信息 是error: incompatible types in assignment。 习题 1、编写一个程序,定义两个类型和长度都相同的数组,将其中一个数组的所有元素拷贝给另一 个。既然数组不能直接赋值,想想应该怎么实现。 [14] C99引入了新的特性,规定数组长度表达式也可以包含变量,称为变长数组 (VLA,Variable Length Array),VLA只能定义为函数的局部变量,而不能定义为全局变量, 与VLA有关的语法规则非常复杂,而且很多编译器不支持这种新特性,不建议使用。 [15] 你可能会想为什么编译器对于这么明显的错误都视而不见?理由一,这种错误并不总是显而 易见的,以后会讲到通过指针而不是数组名来访问数组的情况,指针指向数组的什么位置只有 运行时才知道,编译时无法检查是否越界,而运行时检查数组访问越界会影响性能,所以干脆 不检查了;理由二,[C99 Rationale]指出,C语言的设计精神是:相信每个C程序员都是高手, 不要阻止程序员去干他们需要干的事,高手们使用count[-1]这种技巧其实并不少见,不能当作 错误。 上一页 第 8 章 数组 上一级 起始页 下一页 2. 数组应用实例:统计随机数 上一页 2. 数组应用实例:统计随机数 第 8 章 数组 下一页 2. 数组应用实例:统计随机数 本节通过一个实例介绍使用数组的一些基本模式。问题是这样的:首先生成一列0-9的随机数保 存在数组中,然后统计其中每个数字出现的次数并打印,检查这些数字的随机性如何。随机数 在某些场合(例如游戏程序)中是非常有用的,但是用计算机生成完全随机的数却不是那么容 易的。计算机执行每一条指令的结果都是确定的,没有一条指令产生的是随机数,调用C标准库 得到的随机数其实是伪随机(Pseudorandom)数,是用数学公式算出来的确定的数,只不过这 些数看起来很随机,并且从统计意义上也很接近均匀分布(Uniform Distribution)的随机数。 C标准库中生成伪随机数的是rand函数,使用这个函数需要包含头文件stdlib.h,它没有参数, 返回值是一个介于0和RAND_MAX之间的接近均匀分布的整数。RAND_MAX是头文件中定义的一个常 量,在不同的平台上有不同的取值,但可以肯定它是一个非常大的整数。通常我们用到的随机 数是限定在某个范围之中的,例如0~9,而不是0~RAND_MAX,我们可以用%运算符将rand函 数的返回值处理一下: int x = rand() % 10; 完整的程序如下: 例 8.2. 生成并打印随机数 #include #include #define N 20 int a[N]; void gen_random(int upper_bound) { int i; for (i = 0; i < N; i++) a[i] = rand() % upper_bound; } void print_random() { int i; for (i = 0; i < N; i++) printf("%d ", a[i]); printf("\n"); } int main(void) { gen_random(10); print_random(); return 0; } 这里介绍一种新的语法:用#define定义一个常量。实际上编译器的工作分为两个步骤,先是预 处理(Preprocess),然后才是编译,用gcc的-E选项可以看到预处理之后、编译之前的程序, 例如: $ gcc -E main.c ......(这里省略了很多行stdio.h和stdlib.h的代码) int a[20]; void gen_random(int upper_bound) { int i; for (i = 0; i < 20; i++) a[i] = rand() % upper_bound; } void print_random() { int i; for (i = 0; i < 20; i++) printf("%d ", a[i]); printf("\n"); } int main(void) { gen_random(10); print_random(); return 0; } 可见在这里预处理器做了两件事情,一是把头文件stdio.h和stdlib.h在代码中展开,二是 把#define定义的标识符N替换成它的定义20(在代码中做了三处替换,分别位于数组的定义中 和两个函数中)。像#include和#define这种以#号开头的语法元素称为预处理指示 (Preprocessing Directive),以后我们还要学习一些预处理指示。此外,用cpp main.c命令也 可以达到同样的效果,只做预处理而不编译,cpp表示C preprocessor。 那么#define定义的常量和第 3 节 “数据类型标志”讲枚举定义的常量有什么区别呢?首 先,define不仅用于定义常量,也可以定义更复杂的语法结构,称为宏(Macro)定义,以后会 讲到。其次,define定义是在预处理阶段处理的,而枚举是在编译阶段处理的,例如把第 3 节 “数据类型标志”习题2中的程序改成这样: #include #define RECTANGULAR 1 #define POLAR 2 int main(void) { int RECTANGULAR; printf("%d %d\n", RECTANGULAR, POLAR); return 0; } 读者可以试试看,比较分析一下这个程序和原来的程序有什么区别。回到随机数这个程序,一 开始为了便于分析和调试,我们取小一点的数组长度,只生成20个随机数,这个程序的运行结 果为: 36753562912709360626 看起来很随机了。但随机性如何呢?分布得均匀吗?所谓均匀分布,应该每个数出现的概率是 一样的。在上面的20个结果中,6出现了5次,而4和8一次也没出现过。但这说明不了什么问 题,毕竟我们的样本太小了,才20个数,如果样本足够大,比如说100000个数,统计一下其中 每个数出现的次数也许能说明问题。但是总不能把100000个数都打印出来然后挨个去数吧?我 们需要写一个函数统计每个数字出现的次数。完整的程序如下: 例 8.3. 统计随机数的分布 #include #include #define N 100000 int a[N]; void gen_random(int upper_bound) { int i; for (i = 0; i < N; i++) a[i] = rand() % upper_bound; } int howmany(int value) { int count = 0, i; for (i = 0; i < N; i++) if (a[i] == value) ++count; return count; } int main(void) { int i; gen_random(10); printf("value\thow many\n"); for (i = 0; i < 10; i++) printf("%d\t%d\n", i, howmany(i)); return 0; } 注意,我们把#define N的值改为100000,相当于把整个程序中所有用到N的地方都改 为100000了。与之相反的做法称为硬编码(Hard coding):在定义数组时直接写成int a[20],在每个循环中也直接使用20这个值。如果原来的代码是硬编码的,那么一旦需要把20改 成100000就非常麻烦,你需要找遍整个代码,判断哪些20表示这个数组的长度就改为100000, 哪些20表示别的数量则不做改动,如果代码很长,这是很容易出错的。所以,写代码时应尽可 能避免硬编码,这其实也是一个“提取公因式”的过程,和第 2 节 “数据抽象”讲的抽象具有相同的 作用,就是避免一个地方的改动波及到大的范围。这个程序的运行结果如下: value 0 1 2 3 4 5 6 7 8 9 how many 10130 10072 9990 9842 10174 9930 10059 9954 9891 9958 各数字出现的次数都在10000次左右,可见是比较均匀的。 习题 1、用rand函数生成10~20之间的随机整数,表达式应该怎么写? 上一页 1. 数组的基本操作 上一级 起始页 下一页 3. 数组应用实例:直方图 上一页 3. 数组应用实例:直方图 第 8 章 数组 下一页 3. 数组应用实例:直方图 继续上面的例子。我们统计一列0~9的随机数,打印每个数字出现的次数,像这样的统计结果称 为直方图(Histogram)。有时候我们并不只是想打印,更想把统计结果保存下来以便做后续处 理。我们可以把程序改成这样: int main(void) { int howmanyones = howmany(1); int howmanytwos = howmany(2); ...... 这显然太繁琐了。要是这样的随机数有100个呢?显然这里用数组最合适不过了: int main(void) { int i, histogram[10]; gen_random(10); for (i = 0; i < 10; i++) histogram[i] = howmany(i); ...... 有意思的是,这里的循环变量i有两个作用,一是作为参数传给howmany函数,统计数字i出现的 次数,二是做histogram的下标,也就是“把数字i出现的次数保存在数组histogram的第i个位 置”。 尽管上面的方法可以准确地得到统计结果,但是效率很低,这100000个随机数需要从头到尾检 查十遍,每一遍检查只统计一种数字的出现次数。其实可以把histogram中的存储单元当作累加 器来用,这些随机数只需要从头到尾检查一遍(Single Pass)就可以得出结果: int main(void) { int i, histogram[10] = {}; gen_random(10); for (i = 0; i < N; i++) ++histogram[a[i]]; ...... 首先把histogram的所有元素初始化为0,注意使用局部变量的值之前一定要初始化,否则值是 不确定的。接下来的代码很有意思,在每次循环中,a[i]就是出现的随机数,而这个随机数同 时也是histogram的下标,这个随机数每出现一次就把histogram中相应的元素加1。 把上面的程序运行几遍,你就会发现每次产生的随机数都是一样的,不仅如此,在别的计算机 上运行该程序产生的随机数很可能也是这样的。这正说明了这些数是伪随机数,是用一套确定 的公式基于某个初值算出来的,只要初值相同,随后的整个数列就都相同。实际应用中不可能 使用每次都一样的随机数,例如开发一个麻将游戏,每次运行这个游戏摸到的牌不应该是一样 的。因此,C标准库允许我们自己指定一个初值,然后在此基础上生成伪随机数,这个初值称 为Seed,可以用srand函数指定Seed。通常我们通过别的途径得到一个不确定的数作为Seed, [16] 例如调用time函数得到当前系统时间距1970年1月1日00:00:00 的秒钟数,然后传给srand: srand(time(NULL)); 然后再调用rand,得到的随机数就和刚才完全不同了。调用time函数需要包含头文件time.h, 这里的NULL表示空指针,以后再详细解释。 习题 1、补完本节直方图程序的main函数,以可视化的形式打印直方图。例如上一节统计20个随机数 的结果是: 0123456789 **** *** * * ** *** * ** * * * [16] 各种派生自UNIX的系统都把这个时刻称为Epoch,因为UNIX系统最早发明于1969年。 上一页 2. 数组应用实例:统计随机数 上一级 起始页 下一页 4. 字符串 上一页 4. 字符串 第 8 章 数组 下一页 4. 字符串 之前我一直对字符串避而不谈,不做详细解释,现在已经具备了必要的基础知识,可以深入讨 论一下字符串了。字符串可以看作一个数组,它的元素是字符型的,例如字符串"Hello, world.\n"图示如下: 图 8.2. 字符串 注意末尾有一个字符'\0'表示字符串结束。这里的\0是ASCII码的八进制表示,也就是ASCII码 为0的那个字符。前面用过的数组都有一个数组名,数组元素可以通过数组名加下标的方式访 问。而字符串字面值也可以像数组名一样使用,可以加下标访问其中的字符: char c = "Hello, world.\n"[0]; 但是通过下标修改其中的字符却是不允许的: "Hello, world.\n"[0] = 'A'; 这行代码会产生编译错误,说字符串字面值是只读的,不允许修改。字符串字面值还有一点和 数组名类似,做右值使用时自动转换成指向首元素的指针,所以printf("hello world")其实是 传一个指针参数给printf。 前面讲过数组可以像结构体一样初始化,如果是字符数组,也可以用一个字符串字面值来初始 化: char str[10] = "Hello"; 相当于: char str[10] = { 'H', 'e', 'l', 'l', 'o', '\0' }; str的后四个元素没有指定,自动初始化为0,即'\0'字符。注意,虽然字符串字面值"Hello"是只 读的,但用它初始化的数组str却是可读可写的。数组str保存了一串字符,以'\0'结尾,也可以 叫字符串。在本书中字符串这个概念指的是以'\0'结尾的一串字符,可能是像str这种数组,也可 能是像"Hello"这种字符串字面值。 如果用于初始化的字符串字面值比数组还长,比如: char str[10] = "Hello, world.\n"; 则数组str只包含字符串的前10个字符,不包含'\0'。这种情况编译器一般会给出警告。如果要用 一个字符串字面值准确地初始化一个字符数组,最好的办法是不指定数组的长度,而让编译器 自动计算: char str[] = "Hello, world.\n"; 字符串字面值的长度包括'\0'在内一共15个字符,编译器会确定数组str的长度为15。 补充一点,printf函数的格式化字符串中可以用%s表示字符串的占位符。在学字符数组以前, 我们用%s没什么意义,因为 printf("string: %s\n", "Hello"); 还不如写成 printf("string: Hello\n"); 但现在字符串可以保存在一个数组里面,用%s来打印就很有必要了: printf("string: %s\n", str); printf会从数组str的开头一直打印到'\0'字符为止('\0'本身不打印)。这其实是一个危险的信 号:如果数组str中没有'\0',那么printf就会打印出界,后果和前面讲的数组访问越界一样诡 异:有时候打印出乱码,有时候看起来没错误,有时候引起程序崩溃。 上一页 3. 数组应用实例:直方图 上一级 起始页 下一页 5. 多维数组 上一页 5. 多维数组 第 8 章 数组 下一页 5. 多维数组 就像结构体可以嵌套一样,数组也可以嵌套,一个数组的元素可以是另外一个数组,这样就构 成了多维数组(Multi-dimensional Array)。例如定义并初始化一个二维数组: int a[3][2] = { 1, 2, 3, 4, 5 }; 数组a有3个元素,a[0]、a[1]、a[2]。每个元素也是一个数组,例如a[0]是一个数组,它有两个元 素a[0][0]、a[0][1],这两个元素的类型是int,值分别是1、2,同理,数组a[1]的两个元素 是3、4,数组a[2]的两个元素是5、0。如下图所示: 图 8.3. 多维数组 从概念模型上看,这个二维数组是三行两列的表格,元素的两个下标分别是行号和列号。从物 理模型上看,这六个元素在存储器中仍然是连续存储的,就像一维数组一样,相当于把概念模 型的表格一行一行接起来拼成一串,C语言的这种存储方式称为Row-major方式,而有些编程语 言(例如FORTRAN)是把概念模型的表格一列一列接起来拼起一串存储的,称为Columnmajor方式。 多维数组也可以像嵌套结构体一样,用嵌套Initializer初始化,例如上面的二维数组也可以这样 初始化: int a[][2] = { { 1, 2 }, { 3, 4 }, { 5, } }; 注意,除了第一维的长度可以由编译器自动计算而不需要指定,其余各维都必须明确指定长 度。如果是字符数组,也可以嵌套使用字符串字面值做Initializer,例如: 例 8.4. 多维字符数组 #include void print_day(int day) { char days[8][10] = { "", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" }; if (day < 1 || day > 7) printf("Illegal day number!\n"); printf("%s\n", days[day]); } int main(void) { print_day(2); return 0; } 这个程序和例 4.1 “switch语句”的功能其实是一样的,但是代码简洁多了。简洁的代码不仅可读 性强,而且维护成本也低,像例 4.1 “switch语句”那样一堆case、printf和break,如果漏写了一 个break就要出Bug。这个程序之所以简洁,是因为用数据代替了代码。具体来说,通过下标访 问字符串组成的数组可以代替一堆case分支判断,这样就可以把每个case里重复的代码 (printf调用)提取出来,从而又一次达到了“提取公因式”的效果。这种方法称为数据驱动的编 程(Data-driven Programming),写代码最重要的是选择正确的数据结构来组织信息,设计控 制流程和算法尚在其次,只要数据结构选择得正确,其它代码自然而然就变得容易理解和维护 了,就像这里的printf自然而然就被提取出来了。[人月神话]中说过:“Show me your flowcharts and conceal your tables, and I shall continue to be mystified. Show me your tables, and I won't usually need your flowcharts; they'll be obvious.” 最后,综合本章的知识,我们来写一个最简单的小游戏--剪刀石头布: 例 8.5. 剪刀石头布 #include #include #include int main(void) { char gesture[3][10] = { "scissor", "stone", "cloth" }; int man, computer, result, ret; srand(time(NULL)); while (1) { computer = rand() % 3; printf("\nInput your gesture (0-scissor 1-stone 2-cloth):\n"); ret = scanf("%d", &man); if (ret != 1 || man < 0 || man > 2) { printf("Invalid input! Please input 0, 1 or 2.\n"); continue; } printf("Your gesture: %s\tComputer's gesture: %s\n", gesture[man], gesture[computer]); result = (man - computer + 4) % 3 - 1; if (result > 0) printf("You win!\n"); else if (result == 0) printf("Draw!\n"); else printf("You lose!\n"); } return 0; } 0、1、2三个整数分别是剪刀石头布在程序中的内部表示,用户也要求输入0、1或2,然后和计 算机随机生成的0、1或2比胜负。这个程序的主体是一个死循环,需要按Ctrl-C退出程序。以往 我们写的程序都只有打印输出,在这个程序中我们第一次碰到处理用户输入的情况。在这里只 是简单解释一下,以后再细讲。scanf("%d", &man)这个调用的功能是等待用户输入一个整数并 回车,这个整数会被scanf函数保存在man这个整型变量里。如果用户输入合法(输入的确实是 整数而不是字符串),则scanf函数返回1,表示成功读入一个数据。但即使用户输入的是整 数,我们还需要进一步检查是不是在0~2的范围内,写程序时对用户输入要格外小心,用户有可 能输入任何数据,他才不管游戏规则是什么。 和printf类似,scanf也可以用%c、%f、%s等转换说明。如果在传给scanf的第一个参数中 用%d、%f或%c表示读入一个整数、浮点数或字符,则第二个参数的形式应该是&运算符加一个相 应类型的变量名,表示读进来的数存到这个变量中;如果在第一个参数中用%s读入一个字符 串,则第二个参数应该是数组名,数组名前面不加&,因为数组类型做右值时自动转换成指针类 型,而scanf后面这个参数要的就是指针类型,在第 10 章 gdb有scanf读入字符串的例子。&运 算符的作用也是得到一个指针类型,这个运算符以后再详细解释。 留给读者的思考问题是:(man - computer + 4) % 3 - 1这个神奇的表达式是如何比较 出0、1、2这三个数字在“剪刀石头布”意义上的大小的? 上一页 4. 字符串 上一级 起始页 下一页 第 9 章 编码风格 上一页 第 9 章 编码风格 部分 I. C语言入门 下一页 第 9 章 编码风格 目录 1. 缩进和空白 2. 注释 3. 标识符命名 4. 函数 5. indent工具 代码风格好不好就像字写得好不好看一样,如果一个公司招聘秘书,肯定不要字写得难看的, 同理,代码风格糟糕的程序员肯定也是不称职的。虽然编译器不会挑剔难看的代码,照样能编 译通过,但是和你一个team的其他程序员肯定受不了,你自己也受不了,写完代码几天之后再 来看,自己都不知道自己写的是什么。[SICP]里有句话说得好:“Thus, programs must be written for people to read, and only incidentally for machines to execute.”代码主要为了是写给 人看的,而不是写给机器看的,只是顺便也能用机器执行而已,如果是为了写给机器看那直接 写机器码就好了,没必要用高级语言了。代码和语言文字一样是为了表达思想、记载信息,所 以一定要写得清楚整洁才能有效地表达。正因为如此,在一个软件项目中,代码风格一般都用 文档规定死了,所有参与项目的人不管他自己原来是什么风格,都要遵守统一的风格,例 如Linux内核的[CodingStyle]就是这样一个文档。本章我们以内核的代码风格为基础来讲解好的 编码风格都有哪些规定,这些规定的Rationale是什么。我只是以内核为例来讲解编码风格的概 念,并没有说内核风格就一定是最好的编码风格,但Linux内核项目如此成功,足以说明它的编 码风格是最好的C语言编码风格之一了。 上一页 5. 多维数组 上一级 起始页 下一页 1. 缩进和空白 上一页 1. 缩进和空白 第 9 章 编码风格 下一页 1. 缩进和空白 我们知道C语言的语法对编码风格并没有要求,空格、Tab和换行都可以自己随意写,实现同样 功能的代码可以写得很好看,也可以写得很难看。例如例 8.5 “剪刀石头布”那个程序如果写成这 样就很难看了: 例 9.1. 缺少缩进和空白的代码 #include #include #include int main(void) { char gesture[3][10]={"scissor","stone","cloth"}; int man,computer,result, ret; srand(time(NULL)); while(1){ computer=rand()%3; printf("\nInput your gesture (0-scissor 1-stone 2cloth):\n"); ret=scanf("%d",&man); if(ret!=1||man<0||man>2){ printf("Invalid input! Please input 0, 1 or 2.\n"); continue; } printf("Your gesture: %s\tComputer's gesture: %s\n",gesture[man],gesture[computer]); result=(man-computer+4)%3-1; if(result>0)printf("You win!\n"); else if(result==0)printf("Draw!\n"); else printf("You lose!\n"); } return 0; } 一是没有空白符(包括必要的换行),代码密度太大,看着很费劲。二是没有缩进,看不出来 哪个{和哪个}配对,像这么短的代码还能凑合着看,如果代码超过一屏就完全不可读 了。[CodingStyle]中关于空白符并没有特别规定,因为基本上所有的C代码风格对于空白符的规 定都差不多,主要有以下几条。 1、关键字if, while, for与其后的控制表达式的(括号之间插入一个空格分隔,但括号内的表达式 应紧贴括号。例如: while (1); 2、双目运算符的两侧插入一个空格分隔,单目运算符和操作数之间不加空格,例 如i = i + 1、++i、!(i < 1)、-x、&a[1]等。 3、后缀运算符和操作数之间也不加空格,例如取结构体成员s.a、函数调用foo(arg1)、取数组 成员a[i]。 4、,号和;号之后要加空格,这是英文的书写习惯,例 如for (i = 1; i < 10; i++)、foo(arg1, arg2)。 5、以上关于双目运算符和后缀运算符的规则不是严格要求,有时候为了突出优先级也可以写得 更紧凑一些,例如for (i=1; i<10; i++)、distance = sqrt(x*x + y*y)等。但是省略的 空格一定不要误导了读代码的人,例如a||b && c很容易让人理解成错误的优先级。 6、由于标准的Linux终端是24行80列的,接近或大于80个字符的较长语句要折行写,折行后用 空格和上面的表达式或参数对齐,例如: if (sqrt(x*x + y*y) > 5.0 && x < 0.0 && y > 0.0) 再比如: foo(sqrt(x*x + y*y), a[i-1] + b[i-1] + c[i-1]) 7、较长的字符串可以断成多个字符串然后分行书写,例如: printf("This is such a long sentence that " "it cannot be held within a line\n"); C编译器会自动把相邻的多个字符串接在一起,以上两个字符串相当于一个字符串"This is such a long sentence that it cannot be held within a line\n"。 8、有的人喜欢在变量定义语句中用Tab字符,使变量名对齐,这样看起来也很好,但不是严格 要求的。 →int →a, b; →double →c; 内核关于缩进的规则有以下几条。 1、要用缩进体现出语句块的层次关系,使用Tab字符缩进,不能用空格代替Tab。在标准 的Linux终端上,一个Tab看起来是8个空格的宽度,有些编辑器可以设置一个Tab看起来是几个 空格的宽度,建议设成8,这样大的缩进使代码看起来非常清晰。规定不能用空格代替Tab主要 是不希望空格和Tab混在一起做缩进,如果混在一起用了,在某些编辑器里把Tab的宽度改了就 会看起来非常混乱。 2、if/else、while、do/while、for、switch这些可以带语句块的语句,语句块的{和}应该和关 键字写在一起,用空格隔开,而不是单独占一行。例如应该这样写: if (...) { →语句列表 } else if (...) { →语句列表 } 当然,其实更多人习惯这样写: if (...) { →语句列表 } else { } if (...) →语句列表 内核的写法和[K&R]一致,好处是不必占用太多空行,使得一屏能显示更多代码。这两种写法用 得都很广泛,只要在同一个项目中能保持统一就可以了。 3、函数定义的{和}单独占一行,这一点和语句块的规定不同,例如: int foo(int a, int b) { →语句列表 } 4、switch和语句块里的case、default对齐写,也就是说语句块里的case、default相对 于switch不往里缩进。例如: →switch (c) { →case 'A': → →语句列表 →case 'B': → →语句列表 →default: → →语句列表 →} 自己命名的标号(用于goto)必须顶头写不缩进,而不管标号下的语句缩进到第几层。 5、代码中每个逻辑段落之间应该用一个空行分隔开。例如每个函数定义之间应该插入一个空 行,头文件、全局变量定义和函数定义之间也应该插入空行,例如: #include #include int g; double h; int foo(void) { →语句列表 } int bar(int a) { →语句列表 } int main(void) { →语句列表 } 6、一个函数的语句列表如果很长,也可以根据相关性分成若干组,用空行分隔,这条规定不是 严格要求,一般变量定义语句组成一组,后面要加空行,return之前要加空行,例如: int main(void) { →int →a, b; →double →c; →语句组1 →语句组2 →return 0; } 上一页 第 9 章 编码风格 上一级 起始页 下一页 2. 注释 上一页 2. 注释 第 9 章 编码风格 下一页 2. 注释 单行注释应采用/* comment */的形式,用空格把界定符和文字分开。多行注释最常见的是这 种形式: /* * Multi-line * comment */ 也有一些更花哨的形式: /************ Multi - line comment ************/ 或者: /*************\ * Multi-line * * comment * \*************/ 使用注释的场合主要有以下几种。 1、整个源文件的顶部注释。说明此模块的相关信息,例如文件名、作者和版本历史等,顶头写 不缩进。例如内核源代码kernel/sched.c的开头: /* * kernel/sched.c * * Kernel scheduler and related syscalls * * Copyright (C) 1991-2002 Linus Torvalds * * 1996-12-23 Modified by Dave Grothe to fix bugs in semaphores and * make semaphores SMP safe * 1998-11-19 Implemented schedule_timeout() and related stuff * by Andrea Arcangeli * 2002-01-04 New ultra-scalable O(1) scheduler by Ingo Molnar: * hybrid priority-list and round-robin design with * an array-switch method of distributing timeslices * and per-CPU runqueues. Cleanups and useful suggestions * by Davide Libenzi, preemptible kernel bits by Robert Love. * 2003-09-03 Interactivity tuning by Con Kolivas. * 2004-04-02 Scheduler domains code by Nick Piggin */ 2、函数注释。说明此函数的功能、参数、返回值、错误码等,写在函数定义上侧,和此函数定 义之间不留空行,顶头写不缩进。 3、相对独立的语句组注释。对这一组语句做特别说明,写在语句组上侧,和此语句组之间不留 空行,与当前语句组的缩进一致。注意,说明语句组的注释一定要写在语句组上面,不能写在 语句组下面。 4、代码行右侧的简短注释。对当前代码行做特别说明,一般为单行注释,和代码之间至少用一 个空格隔开,一个源文件中所有的右侧注释最好能上下对齐。尽管例 2.1 “带更多注释的Hello World”讲过注释可以穿插在一行代码中间,但是不建议这么用。内核源代码lib/radix-tree.c中的 一个函数包含了上述三种注释: /** * radix_tree_insert - insert into a radix tree * @root: radix tree root * @index: index key * @item: item to insert * * Insert an item into the radix tree at position @index. */ int radix_tree_insert(struct radix_tree_root *root, unsigned long index, void *item) { struct radix_tree_node *node = NULL, *slot; unsigned int height, shift; int offset; int error; /* Make sure the tree is high enough. */ if ((!index && !root->rnode) || index > radix_tree_maxindex(root->height)) { error = radix_tree_extend(root, index); if (error) return error; } slot = root->rnode; height = root->height; shift = (height-1) * RADIX_TREE_MAP_SHIFT; offset = 0; /* uninitialised var warning */ do { if (slot == NULL) { /* Have to add a child node. */ if (!(slot = radix_tree_node_alloc(root))) return -ENOMEM; if (node) { node->slots[offset] = slot; node->count++; } else root->rnode = slot; } /* Go a level down */ offset = (index >> shift) & RADIX_TREE_MAP_MASK; node = slot; slot = node->slots[offset]; shift -= RADIX_TREE_MAP_SHIFT; height--; } while (height > 0); if (slot != NULL) return -EEXIST; BUG_ON(!node); node->count++; node->slots[offset] = item; BUG_ON(tag_get(node, 0, offset)); BUG_ON(tag_get(node, 1, offset)); return 0; } [CodingStyle]中特别指出,函数内的注释要尽可能少用。注释只是用来说明你的代码能做什 么(比如函数接口定义),而不是说明怎样做的,只要代码写得足够清晰,怎样做是一目了然 的,如果你需要用注释才能解释清楚,那就表示你的代码可读性很差,除非是特别需要提醒注 意的地方才使用函数内注释。 5、复杂的结构体定义比函数更需要注释。例如内核源代码kernel/sched.c中定义了这样一个结 构体: /* * This is the main, per-CPU runqueue data structure. * * Locking rule: those places that want to lock multiple runqueues * (such as the load balancing or the thread migration code), lock * acquire operations must be ordered by ascending &runqueue. */ struct runqueue { spinlock_t lock; /* * nr_running and cpu_load should be in the same cacheline because * remote CPUs use both these fields when doing load calculation. */ unsigned long nr_running; #ifdef CONFIG_SMP unsigned long cpu_load[3]; #endif unsigned long long nr_switches; /* * This is part of a global counter where only the total sum * over all CPUs matters. A task can increase this counter on * one CPU and if it got migrated afterwards it may decrease * it on another CPU. Always updated under the runqueue lock: */ unsigned long nr_uninterruptible; unsigned long expired_timestamp; unsigned long long timestamp_last_tick; task_t *curr, *idle; struct mm_struct *prev_mm; prio_array_t *active, *expired, arrays[2]; int best_expired_prio; atomic_t nr_iowait; #ifdef CONFIG_SMP struct sched_domain *sd; /* For active balancing */ int active_balance; int push_cpu; task_t *migration_thread; struct list_head migration_queue; int cpu; #endif #ifdef CONFIG_SCHEDSTATS /* latency stats */ struct sched_info rq_sched_info; /* sys sched yield() stats */ unsigned long yld_exp_empty; unsigned long yld_act_empty; unsigned long yld_both_empty; unsigned long yld_cnt; /* schedule() stats */ unsigned long sched_switch; unsigned long sched_cnt; unsigned long sched_goidle; #endif }; /* try_to_wake_up() stats */ unsigned long ttwu_cnt; unsigned long ttwu_local; 6、复杂的宏定义和变量定义也需要注释。例如内核源代码include/linux/jiffies.h中的定义: /* TICK_USEC_TO_NSEC is the time between ticks in nsec assuming real ACTHZ and */ /* a value TUSEC for TICK_USEC (can be set bij adjtimex) */ #define TICK_USEC_TO_NSEC(TUSEC) (SH_DIV (TUSEC * USER_HZ * 1000, ACTHZ, 8)) /* some arch's have a small-data section that can be accessed register-relative * but that can only take up to, say, 4-byte variables. jiffies being part of * an 8-byte variable may not be correctly accessed unless we force the issue */ #define __jiffy_data __attribute__((section(".data"))) /* * The 64-bit value is not volatile - you MUST NOT read it * without sampling the sequence number in xtime_lock. * get_jiffies_64() will do this for you as appropriate. */ extern u64 __jiffy_data jiffies_64; extern unsigned long volatile __jiffy_data jiffies; 上一页 1. 缩进和空白 上一级 起始页 下一页 3. 标识符命名 上一页 3. 标识符命名 第 9 章 编码风格 下一页 3. 标识符命名 标识符命名应遵循以下原则: 1. 标识符的命名要清晰明了,可以使用完整的单词和大家易于理解的缩写。短的单词可以通 过去元音形成缩写,较长的单词可以取单词的头几个字母形成缩写,也可以采用大家基本 认同的缩写。例如count写成cnt,block写成blk,length写成len,window写 成win,message写成msg,temporary可以写成temp,也可以进一步写成tmp。 2. 内核风格规定变量、函数和类型采用全小写加下划线的方式命名,常量(宏定义和枚举常 量)采用全大写加下划线的方式命名。上面举例的函数名radix_tree_insert、类型 名struct radix_tree_root、常量名RADIX_TREE_MAP_SHIFT等。有一种变量命名风格叫匈 牙利命名法(Hungarian notation),用变量名的前缀记录变量的类型,例 如iCnt、pMsg、lpszBlk等,Linus在[CodingStyle]中毫不客气地讽刺了微软发明的这一风 格:“Encoding the type of a function into the name (so-called Hungarian notation) is brain damaged - the compiler knows the types anyway and can check those, and it only confuses the programmer. No wonder MicroSoft makes buggy programs.”代码风格本来 就是一个很有争议的问题,如果你接受本章介绍的内核风格(也是本书所有范例代码的风 格),就不要使用大小写混合的变量命名方式[17],更不要使用匈牙利命名法。 3. 全局变量和全局函数的命名一定要详细,不惜多用几个单词多写几个下划线,例如函数 名radix_tree_insert,因为它们在整个项目的许多源文件中都会用到,必须让使用者明确 这个变量或函数是干什么用的。局部变量和只在一个源文件中调用的内部函数的命名可以 简略一些,但不能太短,不要使用单个字母做变量名,只有一个例外:用i、j、k做循环变 量是可以的。 4. 针对中国程序员的一条特别规定:禁止用汉语拼音作为标识符名称,可读性极差。 [17] 大小写混合的命名方式是Modern C++风格所提倡的,在C++代码中很普遍,称 为CamelCase),大概是因为有高有低像驼峰一样。 上一页 2. 注释 上一级 起始页 下一页 4. 函数 上一页 4. 函数 第 9 章 编码风格 下一页 4. 函数 每个函数都应该设计得尽可能简单,简单的函数才容易维护。应遵循以下原则: 1. 实现一个函数只是为了做好一件事情,不要把函数设计成用途广泛、面面俱到的,这样的 函数肯定会超长,而且往往不可重用,维护困难。 2. 函数内部的缩进层次不宜过多,一般以少于4层为宜。如果缩进层次太多就说明设计得太 复杂了,应该考虑分割成更小的函数来调用(这称为Helper Function)。 3. 函数不要写得太长,建议在24行的标准终端上不超过两屏,太长会造成阅读困难,如果一 个函数超过两屏就应该考虑分割函数了。[CodingStyle]中特别说明,如果一个函数在概念 上是简单的,只是长度很长,这倒没关系。例如函数由一个大的switch组成,其中有非常 多的case,这是可以的,因为各个case之间互不影响,整个函数的复杂度只等于其中一 个case的复杂度,这种情况很常见,例如TCP协议的状态机实现。 4. 执行函数就是执行一个动作,函数名通常应包含动词,例 如get_current、radix_tree_insert。 5. 比较重要的函数定义上面必须加注释,说此函数的功能、参数、返回值、错误码等。 6. 另一种度量函数复杂度的办法是看有多少个局部变量,5到10个局部变量就已经很多了, 局部变量再多就很难维护了,应该考虑分割函数。 上一页 3. 标识符命名 上一级 起始页 下一页 5. indent工具 上一页 5. indent工具 第 9 章 编码风格 下一页 5. indent工具 indent工具可以把代码格式化成某种风格,例如把例 9.1 “缺少缩进和空白的代码”格式化成内核 风格: $ indent -kr -i8 main.c $ cat main.c #include #include #include int main(void) { char gesture[3][10] = { "scissor", "stone", "cloth" }; int man, computer, result, ret; srand(time(NULL)); while (1) { computer = rand() % 3; printf ("\nInput your gesture (0-scissor 1-stone 2cloth):\n"); ret = scanf("%d", &man); if (ret != 1 || man < 0 || man > 2) { printf("Invalid input! Please input 0, 1 or 2.\n"); continue; } printf("Your gesture: %s\tComputer's gesture: %s\n", gesture[man], gesture[computer]); result = (man - computer + 4) % 3 - 1; if (result > 0) printf("You win!\n"); else if (result == 0) printf("Draw!\n"); else printf("You lose!\n"); } return 0; } -kr选项表示K&R风格,-i8表示缩进8个空格的长度。如果没有指定-nut选项,则每8个缩进空格 会自动用一个Tab代替。注意indent命令会直接修改原文件,而不是打印到屏幕上或者输出到另 一个文件,这一点和很多UNIX命令不同。可以看出,-kr -i8两个选项格式化出来的代码已经很 符合本章介绍的代码风格了,添加了必要的缩进和空白,较长的代码行也会自动折行。美中不 足的是没有添加适当的空行,因为indent工具也不知道哪几行代码在逻辑上是一组的,空行还是 需要自己动手添,当然,原有的空行肯定不会被indent删去的。 如果你采纳本章介绍的内核风格,基本上-kr -i8这两个参数就够用了。indent工具也支持其它的 风格和选项,具体请参考man page。有些时候indent工具的确非常有用,比如某个项目中途决 定改变编码风格(这很少见),或者往某个项目中添加几个代码文件来自另一个编码风格不同 的项目,但是不能因为有了indent就不遵守编码风格,决不能一开始把代码写得乱七八糟然后依 靠indent去格式化,。 上一页 4. 函数 上一级 起始页 下一页 第 10 章 gdb 上一页 第 10 章 gdb 部分 I. C语言入门 下一页 第 10 章 gdb 目录 1. 单步执行和跟踪函数调用 2. 断点 3. 观察点 4. 段错误 程序中除了一目了然的Bug之外都需要一定的调试手段来分析到底错在哪。到目前为止我们的调 试手段只有一种:根据程序执行时的出错现象假设错误原因,然后在代码中适当的位置插 入printf,执行程序并分析打印结果,如果结果和预期的一样,就基本上证明了自己假设的错 误原因,就可以动手修正Bug了,如果结果和预期的不一样,就根据结果做进一步的假设和分 析。本章我们介绍一种非常强大的调试工具gdb,可以完全操控程序的运行,使得程序就像你手 里的玩具一样,叫它走就走,叫它停就停,并且随时可以查看程序中所有的内部状态,比如各 变量的值、传给函数的参数、当前执行的语句位置等。掌握了gdb的用法以后,调试的手段就更 加丰富了。但要注意,即使调试的手段非常丰富了,其基本思想仍然是“分析现象->假设错误原 因->产生新的现象去验证假设”这样一个循环,根据现象如何假设错误原因,以及如何设计新的 现象去验证假设,这都需要非常严密的分析和思考,如果因为手里有了强大的工具就滥用,而 忽视了严谨的思维,往往会治标不治本地修正Bug,导致一个错误现象消失了但Bug仍然存在, 甚至是把程序越改越错。本章通过几个初学者易犯的错误实例来讲解如何使用gdb调试程序,在 每个实例后面总结一部分常用的gdb命令。 上一页 5. indent工具 上一级 起始页 下一页 1. 单步执行和跟踪函数调用 上一页 1. 单步执行和跟踪函数调用 第 10 章 gdb 1. 单步执行和跟踪函数调用 看下面的程序: 例 10.1. 函数调试实例 #include int add_range(int low, int high) { int i, sum; for (i = low; i <= high; i++) sum = sum + i; return sum; } int main(void) { int result[100]; result[0] = add_range(1, 10); result[1] = add_range(1, 100); printf("result[0]=%d\nresult[1]=%d\n", result[0], result[1]); return 0; } 下一页 add_range函数从low加到high,在main函数中首先从1加到10,把结果保存下来,然后从1加 到100,再把结果保存下来,最后打印出的两个结果是: result[0]=55 result[1]=5105 第一个结果正确[18]。第二个结果显然不正确,在小学我们就学了高斯小时候的故事,从1加 到100应该是5050。一段代码,第一次运行结果是对的,第二次运行却不对,这是很常见的一 类错误现象,这种情况不应该怀疑代码而应该怀疑数据,因为第一次和第二次运行的都是同一 段代码,如果代码是错的,为什么第一次的结果能对呢?然而第一次和第二次运行时,相关的 数据却有可能不同,错误的数据会导致错误的结果。在动手调试之前,读者先试试只看代码能 不能看出错误原因,只要前面学得扎实就应该能看出来。 在编译时要加上-g选项,生成的目标文件才能用gdb进行调试: $ gcc -g main.c -o main $ gdb main GNU gdb 6.8-debian Copyright (C) 2008 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. Type "show copying" and "show warranty" for details. This GDB was configured as "i486-linux-gnu"... (gdb) -g选项的作用是在目标文件中加入源代码的信息,比如目标文件中第几条机器指令对应源代码 的第几行,但并不是把整个源文件嵌入到目标文件中,所以在调试时目标文件时必须保证gdb也 能找到源文件。gdb提供一个类似shell的命令行环境,上面的(gdb)就是提示符,在这个提示符 下输入help可以查看命令的类别: (gdb) help List of classes of commands: aliases -- Aliases of other commands breakpoints -- Making program stop at certain points data -- Examining data files -- Specifying and examining files internals -- Maintenance commands obscure -- Obscure features running -- Running the program stack -- Examining the stack status -- Status inquiries support -- Support facilities tracepoints -- Tracing of program execution without stopping the program user-defined -- User-defined commands Type "help" followed by a class name for a list of commands in that class. Type "help all" for the list of all commands. Type "help" followed by command name for full documentation. Type "apropos word" to search for commands related to "word". Command name abbreviations are allowed if unambiguous. 可以进一步查看某一类别中有哪些命令,例如查看files类别下有哪些命令可以用: (gdb) help files Specifying and examining files. List of commands: add-shared-symbol-files -- Load the symbols from shared objects in the dynamic linker's link map add-symbol-file -- Load symbols from FILE add-symbol-file-from-memory -- Load the symbols out of memory from a dynamically loaded object file cd -- Set working directory to DIR for debugger and program being debugged core-file -- Use FILE as core dump for examining memory and registers directory -- Add directory DIR to beginning of search path for source files edit -- Edit specified file or function exec-file -- Use FILE as program for getting contents of pure memory file -- Use FILE as program to be debugged forward-search -- Search for regular expression (see regex(3)) from last line listed generate-core-file -- Save a core file with the current state of the debugged process list -- List specified function or line ...... 现在试试用list命令从第一行开始列出源代码: (gdb) list 1 1 #include 2 3 int add_range(int low, int high) 4 { 5 6 7 8 9 } 10 int i, sum; for (i = low; i <= high; i++) sum = sum + i; return sum; 一次只列10行,如果要从11行开始继续列源代码可以输入 (gdb) list 也可以什么都不输直接敲回车,gdb提供了一个很方便的功能,在提示符下直接敲回车表示用适 当的参数重复上一条命令。 (gdb) (直接回车) 11 int main(void) 12 { 13 int result[100]; 14 result[0] = add_range(1, 10); 15 result[1] = add_range(1, 100); 16 printf("result[0]=%d\nresult[1]=%d\n", result[0], result[1]); 17 return 0; 18 gdb的很多常用命令有简写形式,例如list命令可以写成l,要列一个函数的源代码也可以用函数 名做参数: (gdb) l add_range 1 #include 2 3 int add_range(int low, int high) 4 { 5 int i, sum; 6 for (i = low; i <= high; i++) 7 sum = sum + i; 8 return sum; 9 } 10 现在退出gdb的环境: (gdb) quit 现在把源代码改名或移到别处,再用gdb调试目标文件,就列不出源代码了: $ mv main.c mian.c $ gdb main ...... (gdb) l 5 main.c: No such file or directory. in main.c 可见gcc的-g选项并不是把源代码嵌入到目标文件中的,在调试目标文件时也需要源文件。现在 把源代码恢复原样,我们继续调试。首先用start命令开始执行程序: $ gdb main ...... (gdb) start Breakpoint 1 at 0x80483ad: file main.c, line 14. Starting program: /home/akaedu/main main () at main.c:14 14 result[0] = add_range(1, 10); (gdb) 这表示停在main函数中变量定义之后的第一条语句处等待我们发命令,gdb列出这条语句表示它 还没执行,并且马上要执行。我们可以用next命令(简写为n)控制这些语句一条一条地执行: (gdb) n 15 result[1] = add_range(1, 100); (gdb) (直接回车) 16 printf("result[0]=%d\nresult[1]=%d\n", result[0], result[1]); (gdb) (直接回车) result[0]=55 result[1]=5105 17 return 0; 用n命令依次执行两行赋值语句和一行打印语句,在执行打印语句时结果立刻打出来了,然后停 在return语句之前等待我们发命令。虽然我们完全控制了程序的执行,但仍然看不出哪里错了, 因为错误不在main函数而在add_range函数,现在用start命令重新来过,这次用step命令(简 写为s)进入函数中去执行: (gdb) start The program being debugged has been started already. Start it from the beginning? (y or n) y Breakpoint 2 at 0x80483ad: file main.c, line 14. Starting program: /home/akaedu/main main () at main.c:14 14 result[0] = add_range(1, 10); (gdb) s add_range (low=1, high=10) at main.c:6 6 for (i = low; i <= high; i++) 这次停在了函数中变量定义之后的第一条语句处。在函数中有几种查看状态的办 法,backtrace命令(简写为bt)可以查看函数调用的栈帧: (gdb) bt #0 add_range (low=1, high=10) at main.c:6 #1 0x080483c1 in main () at main.c:14 可见当前的add_range函数是被main函数调用的,main传进来的参数 是low=1,high=10。main函数的栈帧编号为1,add_range的栈帧编号为0。现在可以用info命 令(简写为i)查看add_range局部变量的值: (gdb) i locals i=0 sum = 0 如果想查看main函数当前局部变量的值也可以做到,先用frame命令(简写为f)选择1号栈帧然 后再查看局部变量: (gdb) f 1 #1 0x080483c1 in main () at main.c:14 14 result[0] = add_range(1, 10); (gdb) i locals result = {0, 0, 0, 0, 0, 0, 134513196, 225011984, -1208685768, - 1081160480, ...... -1208623680} 注意到result数组中有很多元素具有杂乱无章的值,我们知道,未经初始化的局部变量具有不确 定的值。到目前为止一切正常。用s或n往下走几步,然后用print命令(简写为p)打出变 量sum的值: (gdb) s 7 sum = sum + i; (gdb) (直接回车) 6 for (i = low; i <= high; i++) (gdb) (直接回车) 7 sum = sum + i; (gdb) (直接回车) 6 for (i = low; i <= high; i++) (gdb) p sum $1 = 3 第一次循环i是1,第二次循环i是2,加起来是3,没错。这里的$1表示gdb保存着这些中间结 果,$后面的编号会自动增长,在命令中可以用$1、$2、$3等编号代替相应的值。由于我们本 来就知道第一次调用的结果是正确的,再往下跟也没意义了,可以用finish命令让程序一直运行 到从当前函数返回为止: (gdb) finish Run till exit from #0 add_range (low=1, high=10) at main.c:6 0x080483c1 in main () at main.c:14 14 result[0] = add_range(1, 10); Value returned is $2 = 55 返回值是55,当前正准备执行赋值操作,用s命令赋值,然后查看result数组: (gdb) s 15 result[1] = add_range(1, 100); (gdb) p result $3 = {55, 0, 0, 0, 0, 0, 134513196, 225011984, -1208685768, - 1081160480, ...... -1208623680} 第一个值55确实赋给了result数组的第0个元素。下面用s命令进入第二次add_range调用,进入 之后首先查看参数和局部变量: (gdb) s add_range (low=1, high=100) at main.c:6 6 for (i = low; i <= high; i++) (gdb) bt #0 add_range (low=1, high=100) at main.c:6 #1 0x080483db in main () at main.c:15 (gdb) i locals i = 11 sum = 55 由于局部变量i和sum没初始化,所以具有不确定的值,又由于两次调用是挨着的,i和sum正好 取了上次调用时的值,原来这跟例 3.7 “验证局部变量存储空间的分配和释放”是一样的道理,只 不过我这次举的例子设法让局部变量sum在第一次调用时初值为0了。i的初值不是0倒没关系, 在for循环中会赋值为0的,但sum如果初值不是0,累加得到的结果就错了。好了,我们已经找 到错误原因,可以退出gdb修改源代码了。如果我们不想浪费这一次调试机会,可以在gdb中马 上把sum的初值改为0继续运行,看看这一处改了之后还有没有别的Bug: (gdb) set var sum=0 (gdb) finish Run till exit from #0 add_range (low=1, high=100) at main.c:6 0x080483db in main () at main.c:15 15 result[1] = add_range(1, 100); Value returned is $4 = 5050 (gdb) n 16 printf("result[0]=%d\nresult[1]=%d\n", result[0], result[1]); (gdb) (直接回车) result[0]=55 result[1]=5050 17 return 0; 这样结果就对了。修改变量的值除了用set命令之外也可以用print命令,因为print命令后面跟的 是表达式,而我们知道赋值和函数调用也都是表达式,所以还可以用print来修改变量的值,或 者调用函数: (gdb) p result[2]=33 $5 = 33 (gdb) p printf("result[2]=%d\n", result[2]) result[2]=33 $6 = 13 我们讲过,printf的返回值表示实际打印的字符数,所以$6的结果是13。总结一下本节用到 的gdb命令: 表 10.1. gdb基本命令1 命令 描述 backtrace(或bt) 查看各级函数调用及参数 finish 执行到当前函数返回,然后停下来等待命令 frame(或f) 帧编 选择栈帧 号 info(或i) locals 查看当前栈帧局部变量的值 list(或l) 列出源代码,接着上次的位置往下列,每次列10行 list 行号 列出从第几行开始的源代码 list 函数名 列出某个函数的源代码 next(或n) 执行下一行语句 print(或p) 打印表达式的值,通过表达式可以修改变量的值或者调用函 数 set var 修改变量的值 start 开始执行程序,停在main函数第一行语句前面等待命令 step(或s) 执行下一行语句,如果有函数调用则进入到函数中 [18] 好吧,也许我错了,在有些平台和有些操作系统上第一个结果也未必正确,如果在你机器上 运行的第一个结果也不正确,首先检查一下程序有没有抄错,如果没抄错那就没关系了,顺着 我的讲解往下看就好了,结果是多少都无关紧要。 上一页 第 10 章 gdb 上一级 起始页 下一页 2. 断点 上一页 2. 断点 看以下程序: 2. 断点 第 10 章 gdb 例 10.2. 断点调试实例 #include int main(void) { int sum = 0, i = 0; char input[5]; while (1) { scanf("%s", input); for (i = 0; input[i] != '\0'; i++) sum = sum*10 + input[i] - '0'; printf("input=%d\n", sum); } return 0; } 下一页 这个程序的作用是:首先从键盘读入一串数字存到字符数组input中,然后转换成整型存 到sum中,然后打印出来,一直这样循环下去。scanf("%s", input);这个调用的功能是等待用 户输入一个字符串并回车,scanf把其中第一段非空白(非空格、Tab、换行)的字符串放 到input数组中,并自动在末尾添加'\0'。接下来的循环从左到右扫描字符串并把每个数字累加到 结果中,例如输入是"2345",则循环累加的过程是(((0*10+2)*10+3)*10+4)*10+5=2345。注意字 符型的'2'要减去'0'的ASCII码才能转换成整数值的2,'0'的ASCII码是48,而'\0'的ASCII码是0, 二者是不同的。下面编译运行程序看看有什么问题: $ gcc main.c -g -o main $ ./main 123 input=123 234 input=123234 (Ctrl-C退出程序) $ 又是这种现象,每一次是对的,第二次就不对。可是这个程序我们并没有忘了赋初值,不 仅sum赋了初值,连不必赋初值的i都赋了初值。读者能看出哪里错了吗?下面来调试: $ gdb main ...... (gdb) start Breakpoint 1 at 0x80483b5: file main.c, line 5. Starting program: /home/akaedu/main main () at main.c:5 5 int sum = 0, i = 0; 可见,如果变量要赋初值,start不会跳过变量定义语句。有了上一次的经验,sum被列为重点 怀疑对象,我们可以用display命令使得每次停下来的时候都显示当前sum值,然后继续往下 走: (gdb) display sum 1: sum = -1208103488 (gdb) n 9 1: sum = 0 (gdb) 123 10 1: sum = 0 scanf("%s", input); for (i = 0; input[i] != '\0'; i++) 用undisplay可以取消对先前设置的那些变量的跟踪。这个循环应该是没有问题的,因为第一次 的结果正确。如果不想一步一步走这个循环,可以用break命令(简写为b)在第9行设一个断点 (Breakpoint): (gdb) l 5 int sum = 0, i; 6 char input[5]; 7 8 while (1) { 9 scanf("%s", input); 10 for (i = 0; input[i] != '\0'; i++) 11 sum = sum*10 + input[i] - '0'; 12 printf("input=%d\n", sum); 13 } 14 return 0; (gdb) b 9 Breakpoint 2 at 0x80483bc: file main.c, line 9. break命令的参数也可以是函数名,表示在某一个函数开头设断点。现在用continue命令(简写 为c)连续运行而非单步运行,程序到达断点会自动停下来,这样就可以停在下一次循环的开 头: (gdb) c Continuing. input=123 Breakpoint 2, main () at main.c:9 9 scanf("%s", input); 1: sum = 123 然后输入新的字符串准备转换: (gdb) n 234 10 1: sum = 123 for (i = 0; input[i] != '\0'; i++) 问题暴露出来了,新的转换应该再次从0开始累加,而sum现在已经是123了,原因在于新的循 环没有把sum归零。可见断点有助于快速跳过与问题无关的代码,然后在有问题的代码上慢慢 走慢慢分析,“断点加单步”是使用调试器的基本方法。至于应该在哪里设置断点,怎么知道哪些 代码可以跳过而哪些代码要慢慢走,也要通过对错误现象的分析和假设来确定,就像以前分析 确定在哪里插入printf语句一样。一次调试可以设置多个断点,用info命令可以查看已经设置的断 点: (gdb) b 12 Breakpoint 3 at 0x8048411: file main.c, line 12. (gdb) i breakpoints Num Type Disp Enb Address What 2 breakpoint keep y 0x080483c3 in main at main.c:9 breakpoint already hit 1 time 3 breakpoint keep y 0x08048411 in main at main.c:12 每个断点都有一个编号,可以用编号指定删除某个断点: (gdb) delete breakpoints 2 (gdb) i breakpoints Num Type Disp Enb Address What 3 breakpoint keep y 0x08048411 in main at main.c:12 有时候一个断点暂时不想用可以禁用掉而不必删除,这样以后想用的时候可以直接启用,而不 必重新从代码里找应该在哪一行设断点: (gdb) disable breakpoints 3 (gdb) i breakpoints Num Type Disp Enb Address What 3 breakpoint keep n 0x08048411 in main at main.c:12 (gdb) enable breakpoints 3 (gdb) i breakpoints Num Type Disp Enb Address What 3 breakpoint keep y 0x08048411 in main at main.c:12 (gdb) delete breakpoints Delete all breakpoints? (y or n) y (gdb) i breakpoints No breakpoints or watchpoints. gdb的断点功能非常灵活,还可以设置断点在满足某个条件时才激活,例如我们仍然在循环开头 设置断点,但是仅当sum不等于0时才中断,然后用run命令(简写为r)重新从程序开头连续执 行: (gdb) break 9 if sum != 0 Breakpoint 5 at 0x80483c3: file main.c, line 9. (gdb) i breakpoints Num Type Disp Enb Address What 5 breakpoint keep y 0x080483c3 in main at main.c:9 stop only if sum != 0 (gdb) r The program being debugged has been started already. Start it from the beginning? (y or n) y Starting program: /home/akaedu/main 123 input=123 Breakpoint 5, main () at main.c:9 9 scanf("%s", input); 1: sum = 123 结果是第一次执行scanf之前没有中断,第二次却中断了。总结一下本节用到的gdb命令: 表 10.2. gdb基本命令2 命令 break(或b) 行号 break 函数名 break...if... continue(或c) delete breakpoints 描述 在某一行设置断点 在某个函数开头设置断点 设置条件断点 从当前位置开始连续而非单步执行程序 删除断点 display 变量名 跟踪查看一个变量,每次停下来都显示它的值 disable breakpoints 禁用断点 enable breakpoints 启用断点 info(或i) breakpoints 查看当前设置了哪些断点 run(或r) 从头开始连续而非单步执行程序 undisplay 取消对先前设置的那些变量的跟踪 习题 1、看下面的程序: #include int main(void) { int i; char str[6] = "hello"; char reverse_str[6] = ""; printf("%s\n", str); for (i = 0; i < 5; i++) reverse_str[5-i] = str[i]; printf("%s\n", reverse_str); return 0; } 首先用字符串"hello"初始化一个字符数组str(算上'\0'共6个字符)。然后用空字符串""初始化一 个同样长的字符数组reverse_str,相当于所有元素用'\0'初始化。然后打印str,把str倒序存 入reverse_str,再打印reverse_str。然而结果并不正确: $ ./main hello 我们本来希望reverse_str打出来是olleh的,结果什么都没有。重点怀疑对象肯定是循环,那么 简单验算一下,i=0时,reverse_str[5] = str[0],也就是'h',i=1时,reverse_str[4] = str[1],也就 是'e',依此类推,i=0,1,2,3,4,共5次循环,正好把h,e,l,l,o五个字母给倒过来了,哪里不对了? 请调试修正这个Bug。 上一页 1. 单步执行和跟踪函数调用 上一级 起始页 下一页 3. 观察点 上一页 3. 观察点 第 10 章 gdb 下一页 3. 观察点 接着上一节的步骤,经过调试我们知道,虽然sum已经赋了初值0,仍需要在while (1)循环开 头加上sum = 0;: 例 10.3. 观察点调试实例 #include int main(void) { int sum = 0, i = 0; char input[5]; while (1) { sum = 0; scanf("%s", input); for (i = 0; input[i] != '\0'; i++) sum = sum*10 + input[i] - '0'; printf("input=%d\n", sum); } return 0; } 使用scanf函数是非常凶险的,即使修正了这个Bug还存在很多问题。如果输入的 字符串超长了会怎么样?我们知道数组访问越界是不会检查的,所以scanf会写出 界。现象是这样的: $ ./main 123 input=123 67 input=67 12345 input=123407 下面用调试器看看最后这个诡异的结果是怎么出来的。 $ gdb main ...... (gdb) start Breakpoint 1 at 0x80483b5: file main.c, line 5. Starting program: /home/akaedu/main main () at main.c:5 5 int sum = 0, i = 0; (gdb) n 9 sum = 0; (gdb) (直接回车) 10 scanf("%s", input); (gdb) (直接回车) 12345 11 for (i = 0; input[i] != '\0'; i++) (gdb) p input $1 = "12345" input数组只有5个元素,写出界的是scanf自动添的'\0',用x命令看会更清楚一 些: (gdb) x/7b input 0xbfb8f0a7: 0x31 0x00 0x00 0x32 0x33 0x34 0x35 x命令打印存储器中的内容。7b是打印格式,b表示每个字节一组,7表示打 印7组[19]。前五个字节是input数组的存储单元,打印的正是十六进制ASCII码 的'1'到'5',最后一个是写出界的'\0'。根据运行结果,前四个字符转成数字都没 错,第5个错了,也就是i从0到3的循环都没错,我们设一个条件断点从i等于4开始 单步调试: (gdb) l 6 char input[5]; 7 8 while (1) { 9 sum = 0; 10 scanf("%s", input); 11 for (i = 0; input[i] != '\0'; i++) 12 sum = sum*10 + input[i] - '0'; 13 printf("input=%d\n", sum); 14 } 15 return 0; (gdb) b 12 if i == 4 Breakpoint 2 at 0x80483e6: file main.c, line 12. (gdb) c Continuing. Breakpoint 2, main () at main.c:12 12 sum = sum*10 + input[i] - '0'; (gdb) p sum $2 = 1234 现在sum是1234没错,根据运行结果是123407我们知道即将进行的这步计算肯定 要出错,算出来应该是12340,那就是说input[4]肯定不是'5'了,事实证明这个推 理是不严谨的: (gdb) x/7b input 0xbfb8f0a7: 0x31 0x04 0x00 0x32 0x33 0x34 0x35 input[4]仍然是0x35,产生123407还有另外一种可能,就是在下一次循环 中123450不是加上而是减去一个数得到123407。可现在不是到字符串末尾了吗? 怎么会有下一次循环呢?注意到循环控制条件是input[i] != '\0',而本来应该 是0x00的位置现在莫名其妙地变成了0x04,因此循环不会结束。继续单步: (gdb) n 11 i++) (gdb) p sum $3 = 12345 (gdb) n 12 - '0'; (gdb) x/7b input 0xbfb8f0a7: 0x31 0x05 0x00 for (i = 0; input[i] != '\0'; sum = sum*10 + input[i] 0x32 0x33 0x34 0x35 进入下一次循环,原来的0x04又莫名其妙地变成了0x05,这是怎么回事?这个暂 时解释不了,但123407这个结果可以解释了,是12345*10 + 0x05 - 0x30得到 的,虽然多循环了一次,但下次一定会退出循环了,因为0x05的后面是'\0'。 input[4]后面那个字节到底是什么时候变的?可以用观察点(Watchpoint)来跟 踪。我们知道断点是当程序执行到某一代码行时中断,而观察点是当程序访问某 一存储单元时中断,如果我们不知道某一存储单元是在哪里被改动的,这时候观 察点尤其有用。下面删除原来设的断点,从头执行程序,重复上次的输入, 用watch命令设置观察点,跟踪input[4]后面那个字节(可以用input[5]表示,虽然 这是访问越界): (gdb) delete breakpoints Delete all breakpoints? (y or n) y (gdb) start Breakpoint 1 at 0x80483b5: file main.c, line 5. Starting program: /home/akaedu/main main () at main.c:5 5 int sum = 0, i = 0; (gdb) n 9 sum = 0; (gdb) (直接回车) 10 scanf("%s", input); (gdb) (直接回车) 12345 11 for (i = 0; input[i] != '\0'; i++) (gdb) watch input[5] Hardware watchpoint 2: input[5] (gdb) i watchpoints Num Type Disp Enb Address What 2 hw watchpoint keep y input[5] (gdb) c Continuing. Hardware watchpoint 2: input[5] Old value = 0 '\0' New value = 1 '\001' 0x0804840c in main () at main.c:11 11 for (i = 0; input[i] != '\0'; i++) (gdb) c Continuing. Hardware watchpoint 2: input[5] Old value = 1 '\001' New value = 2 '\002' 0x0804840c in main () at main.c:11 11 for (i = 0; input[i] != '\0'; i++) (gdb) c Continuing. Hardware watchpoint 2: input[5] Old value = 2 '\002' New value = 3 '\003' 0x0804840c in main () at main.c:11 11 for (i = 0; input[i] != '\0'; i++) 已经很明显了,每次都是for这句改变了input[5]的值,而且是每次加1,而for这句 里的i正是每次加1的,原来input[5]就是i的存储单元,换句话说,i的存储单元是紧 跟在input数组后面的。 修正这个Bug对初学者来说有一定难度。如果你发现了这个Bug却没想到数组访问 越界这一点,也许一时想不出原因,就会先去处理另外一个更容易修正的Bug:如 果输入的不是数字而是字母或别的符号也能算出结果来,这显然是不对的,可以 在循环中加上判断条件: while (1) { sum = 0; scanf("%s", input); for (i = 0; input[i] != '\0'; i++) { if (input[i] < '0' || input[i] > '9') { printf("Invalid input!\n"); sum = -1; break; } sum = sum*10 + input[i] - '0'; } printf("input=%d\n", sum); } 然后你会惊喜地发现,不仅输入字母会报错退出,输入超长也会报错退出: $ ./main 123a Invalid input! input=-1 dead Invalid input! input=-1 1234578 Invalid input! input=-1 1234567890abcdef Invalid input! input=-1 23 input=23 似乎是两个Bug一起解决掉了,想想为什么输入超长也会报错退出。总结一下本节用到的gdb命 令: 表 10.3. gdb基本命令3 命令 watch info(或i) watchpoints x 设置观察点 描述 查看当前设置了哪些观察点 从某个位置开始打印存储器的一段内容,全部当成字节来看,而 不区分哪些字节属于哪些变量 [19] 打印结果最左边的一长串数字是存储单元的地址,以后再讲,目前可以无视。 上一页 2. 断点 上一级 起始页 下一页 4. 段错误 上一页 4. 段错误 第 10 章 gdb 下一页 4. 段错误 如果程序运行时出现段错误,用gdb可以很容易定位到究竟是哪一行引发的段错误,例如这个小 程序: 例 10.4. 段错误调试实例一 #include int main(void) { int man = 0; scanf("%d", man); return 0; } 调试过程如下: $ gdb main ...... (gdb) r Starting program: /home/akaedu/main 123 Program received signal SIGSEGV, Segmentation fault. 0xb7e1404b in _IO_vfscanf () from /lib/tls/i686/cmov/libc.so.6 (gdb) bt #0 0xb7e1404b in _IO_vfscanf () from /lib/tls/i686/cmov/libc.so.6 #1 0xb7e1dd2b in scanf () from /lib/tls/i686/cmov/libc.so.6 #2 0x0804839f in main () at main.c:6 在gdb中运行,遇到段错误会自动停下来,这时可以用命令查看当前执行到哪一行代码 了。gdb显示段错误出现在_IO_vfscanf函数中,用bt命令可以看到这个函数是被我们的scanf函 数调用的,所以是scanf这一行代码引发的段错误。仔细观察程序发现是man前面少了个&。 上一节我们调试了一个输入字符串转整数的程序,最后提出修正Bug的方法是在循环中加上判断 条件,如果不是数字就报错退出,不仅输入字母可以报错退出,输入超长的字符串也会报错退 出。然而真的把两个Bug一起解决了吗?这其实是一种治标不治本的办法,因为并没有制 止scanf的访问越界。我说过了,使用scanf函数是非常凶险的。表面上看这个程序无论怎么运行 都不出错了,但假如我们把while (1)循环去掉,每次执行程序只转换一个数: 例 10.5. 段错误调试实例二 #include int main(void) { int sum = 0, i = 0; char input[5]; scanf("%s", input); for (i = 0; input[i] != '\0'; i++) { if (input[i] < '0' || input[i] > '9') { printf("Invalid input!\n"); sum = -1; break; } sum = sum*10 + input[i] - '0'; } printf("input=%d\n", sum); return 0; } 然后输入一个超长的字符串,看看会发生什么: $ ./main 1234567890 Invalid input! input=-1 看起来正常。再来一次,这次输个更长的: $ ./main 1234567890abcdef Invalid input! input=-1 Segmentation fault 又段错误了。我们按同样的方法用gdb调试看看: $ gdb main ...... (gdb) r Starting program: /home/akaedu/main 1234567890abcdef Invalid input! input=-1 Program received signal SIGSEGV, Segmentation fault. 0x0804848e in main () at main.c:19 19 } (gdb) l 14 } 15 sum = sum*10 + input[i] - '0'; 16 } 17 printf("input=%d\n", sum); 18 return 0; 19 } gdb指出,段错误发生在第19行。可是这一行什么都没有啊,只有表示main函数结束的}括号。 这可以算是一条规律,如果某个函数中发生访问越界,很可能并不立即产生段错误,而在函数 返回时却产生段错误。 想要写出Bug-free的程序是非常不容易的,即使scanf读入字符串这么一个简单的函数调用都会 隐藏着各种各样的错误,有些错误现象是我们暂时没法解释的:为什么变量i的存储单元紧跟 在input数组后面?为什么同样是访问越界,有时出段错误有时不出段错误?为什么访问越界的 段错误在函数返回时才出现?还有最基本的问题,为什么scanf输入整型变量就必须要加&,否 则就出段错误,而输入字符串就不要加&?这些问题在后续章节中都会解释清楚。其实现在 讲scanf这个函数为时过早,读者还不具备充足的基础知识。但仍然是有必要讲的,学完这一阶 段之后读者应该能写出有用的程序了,然而一个只有输出而没有输入的程序算不上是有用的程 序,另一方面也让读者认识到,学C语言不可能不去了解底层计算机体系结构和操作系统的原 理,不了解底层原理连一个scanf函数都没办法用好,更没有办法保证写出正确的程序。 上一页 3. 观察点 上一级 起始页 下一页 第 11 章 排序与查找 上一页 第 11 章 排序与查找 目录 1. 算法的概念 2. 插入排序 3. 算法的时间复杂度分析 4. 归并排序 5. 线性查找 6. 折半查找 上一页 4. 段错误 第 11 章 排序与查找 部分 I. C语言入门 上一级 起始页 下一页 下一页 1. 算法的概念 上一页 1. 算法的概念 第 11 章 排序与查找 下一页 1. 算法的概念 算法(Algorithm)是将一组输入转化成一组输出的一系列计算步骤,其中每个步骤必须能在有 限时间内完成。比如第 3 节 “递归”习题1中的Euclid算法,输入是两个正整数,输出是它们的最 大公约数,计算步骤是取模、比较等操作,这个算法一定能在有限的步骤和时间内完成(想一 想为什么?)。再比如,将一组数按从小到大排序,输入是一组原始数据,输出是排序之后的 数据,计算步骤包括比较、移动数据等操作。 算法是用来解决一类计算问题的,注意是一类问题,而不是一个特定的问题。例如,一个排序 算法应该能对任意一组数据进行排序,而不是仅对int a[] = { 1, 3, 4, 2, 6, 5 };这样一组 数据排序,如果只需要对这一组数据排序可以写这样一个函数来做: void sort(void) { a[0] = 1; a[1] = 2; a[2] = 3; a[3] = 4; a[4] = 5; a[5] = 6; } 这显然不叫算法,因为不具有通用性。由于算法是用来解决一类问题的,它必须能够正确地解 决这一类问题中的任何一个实例,这个算法才是正确的。对于排序算法,任意输入一组数据, 它必须都能输出正确的排序结果,这个排序算法才是正确的。不正确的算法有两种可能,一是 对于该问题的某些输入,该算法会无限计算下去,不会终止,二是对于该问题的某些输入,该 算法终止时输出的是错误的结果。有时候不正确的算法也是有用的,如果对于某个问题寻求正 确的算法很困难,而某个不正确的算法可以在有限时间内终止,并且能把误差控制在一定范围 内,那么这样的算法也是有实际意义的。例如有时候寻找最优解的开销很大,往往会选择能给 出次优解的算法。 本节介绍几种典型的排序和查找算法,并围绕这几种算法讲解算法的时间复杂度分析。读者可 参考一些全面系统地介绍算法的书,例如[TAOCP]和[算法导论]等。 上一页 第 11 章 排序与查找 上一级 起始页 下一页 2. 插入排序 上一页 2. 插入排序 第 11 章 排序与查找 下一页 2. 插入排序 插入排序算法类似于玩扑克时抓牌的过程,玩家每拿到一张牌都要插入到手中已有的牌里,使 之从小到大排好序。例如(该图出自[算法导论]): 图 11.1. 扑克牌的插入排序 也许你没有意识到,但其实你的思考过程是这样的:现在抓到一张7,把它和手里的牌从右到左 依次比较,7比10小,应该再往左插,7比5大,好,就插这里。为什么比较了10和5就可以确 定7的位置?为什么不用再比较左边的4和2呢?因为这里有一个重要的前提:手里的牌已经是排 好序的。现在我插了7之后,手里的牌仍然是排好序的,下次再抓到的牌还可以用这个方法插 入。 编程对一个数组进行插入排序也是同样道理,但和插入扑克牌有一点不同,不可能在两个相邻 的存储单元之间再插入一个单元,因此要将插入点之后的数据依次往后移动一个单元。排序算 法如下: 例 11.1. 插入排序 #include #define LEN 5 int a[LEN] = { 10, 5, 2, 4, 7 }; void insertion_sort(void) { int i, j, key; for (j = 1; j < LEN; ++j) { printf("%d, %d, %d, %d, %d\n", a[0], a[1], a[2], a[3], a[4]); key = a[j]; i = j - 1; while (i >= 0 && a[i] > key) { a[i+1] = a[i]; --i; } a[i+1] = key; } printf("%d, %d, %d, %d, %d\n", a[0], a[1], a[2], a[3], a[4]); } int main(void) { insertion_sort(); return 0; } 为了更清楚地观察排序过程,我们在每次循环开头插了打印语句,在排序结束后也插了打印语 句。程序运行结果是: 10, 5, 2, 4, 7 5, 10, 2, 4, 7 2, 5, 10, 4, 7 2, 4, 5, 10, 7 2, 4, 5, 7, 10 如何严格证明这个算法是正确的?换句话说,只要反复执行该算法的for循环体,执行LEN1次,就一定能把数组a排好序,而不管数组a的原始数据是什么,如何证明这一点呢?我们可以 借助Loop Invariant的概念和数学归纳法来理解循环结构的算法,假如某个判断条件满足以下三 条准则,它就称为Loop Invariant: 1. 第一次执行循环体之前该判断条件为真 2. 如果“第N-1次循环之后(或者说第N次循环之前)该判断条件为真”这个前提可以成立,那 么就有办法证明第N次循环之后该判断条件仍为真 3. 如果在所有循环结束后该判断条件为真,那么就有办法证明该算法正确地解决了问题 只要我们找到了这个Loop Invariant,就可以证明一个循环结构的算法是正确的。上面插入排序 算法的Loop Invariant是这样的判断条件:第j次循环之前,子序列a[0..j-1]是排好序的。在上面 的打印结果中,我把子序列a[0..j-1]加粗表示。下面我们验证一下Loop Invariant的三条准则: 1. 第一次执行循环之前,j=1,子序列a[0..j-1]只有一个元素a[0],只有一个元素的序列显然 是排好序的。 2. 第j次循环之前,如果“子序列a[0..j-1]是排好序的”这个前提成立,现在要把key=a[j]插进 去,按照该算法的步骤,把a[j-1]、a[j-2]、a[j-3]等等比key大的元素都依次往后移一个, 直到找到合适的位置给key插入,就能证明循环结束时子序列a[0..j]是排好序的。就像插扑 克牌一样,“手中已有的牌是排好序的”这个前提很重要,如果没有这个前提,就不能证明 再插一张牌之后也是排好序的。 3. 当循环结束时,j=LEN,如果“子序列a[0..j-1]是排好序的”这个前提成立,那就是 说a[0..LEN-1]是排好序的,也就是说整个数组a的LEN个元素都排好序了。 可见,有了这三条,就可以用数学归纳法证明这个循环是正确的。这和第 3 节 “递归”证明递归 程序正确性的思想是一致的,这里的第一条就相当于递归的Base Case,第二条就相当于递归 的递推关系。这再次说明了递归和循环是等价的。 上一页 1. 算法的概念 上一级 起始页 下一页 3. 算法的时间复杂度分析 上一页 3. 算法的时间复杂度分析 第 11 章 排序与查找 下一页 3. 算法的时间复杂度分析 解决同一个问题可以有很多种算法,比较评价算法的好坏,一个重要的标准就是算法的时间复 杂度。现在研究一下插入排序算法的执行时间,按照习惯,输入长度LEN以下用n表示。假设循 环中各条语句的执行时间分别是c1、c2、c3、c4、c5这样五个常数[20]: void insertion_sort(void) 执行时间 { int i, j, key; for (j = 1; j < LEN; ++j) { key = a[j]; c1 i = j - 1; c2 while (i >= 0 && a[i] > key) { a[i+1] = a[i]; c3 --i; c4 } a[i+1] = key; c5 } } 显然外层for循环执行次数是n-1次,假设内层的while循环执行m次,则总的执行时间粗略估计 是(n-1)*(c1+c2+c5+m*(c3+c4))。当然,for和while后面()括号中的赋值和条件判断也需要时 间,而我没有设一个常数来表示,这不影响我们的粗略估计。 这里有一个问题,m不是个常数,也不取决于输入长度n,而是取决于具体的输入数据。在最好 情况下,数组a的原始数据已经排好序了,while循环一次也不执行,总的执行时间 是(c1+c2+c5)*n-(c1+c2+c5),可以表示成an+b的形式,是n的线性函数(Linear Function)。 那么在在最坏情况(Worst Case)下又如何呢?所谓最坏情况是指数组a的原始数据正好是从大 到小排好序的,请读者想一想为什么这是最坏情况,然后把上式中的m替换掉算一下执行时间是 多少。 数组a的原始数据属于最好和最坏情况都比较少见,如果原始数据是随机的,可称为平均情况 (Average Case)。如果原始数据是随机的,那么每次循环将已排序的子序列a[1..j-1]与新插入 的元素key相比较,子序列中平均都有一半的元素比key大而另一半比key小,请读者把上式中 的m替换掉算一下执行时间是多少。最后的结论是:在最坏情况和平均情况下,总的执行时间都 可以表示成an2+bn+c的形式,是n的二次函数(Quadratic Function)。 在分析算法的时间复杂度时,我们更关心最坏情况而不是最好情况,理由如下: 1. 最坏情况给出了算法执行时间的上界,我们可以确信,无论给什么输入,算法的执行时间 不会超过这个上界,为比较和分析提供了便利。 2. 对于某些算法,最坏情况是最常发生的情况,例如在数据库中查找某个信息的算法,最坏 情况就是数据库中根本不存在该信息,都找遍了也没有,而某些应用场合经常要查找一个 信息在数据库中存在不存在。 3. 虽然最坏情况是一种悲观估计,但是对于很多问题,平均情况和最坏情况的时间复杂度差 不多,比如插入排序这个例子,平均情况和最坏情况的时间复杂度都是输入长度n的二次 函数。 比较两个多项式a1n+b1和a2n2+b2n+c2的值(n取正整数)可以得出结论:n的最高次指数是最 主要的决定因素,常数项、低次幂项和系数都是次要的。比如100n+1和n2+1,虽然后者的系数 小,当n较小时前者的值较大,但是当n>100时,后者的值就远远大于前者了。如果同一个问题 可以用两种算法解决,其中一种算法的时间复杂度为线性函数,另一种算法的时间复杂度为二 次函数,当问题的输入长度n足够大时,前者明显优于后者。因此我们可以用一种更粗略的方式 表示算法的时间复杂度,把系数和低次幂项都省去,线性函数记作Θ(n),二次函数记作Θ(n2)。 Θ(g(n))表示和g(n)同一量级的一类函数,例如所有的二次函数f(n)都和g(n)=n2属于同一量级, 都可以用Θ(n2)来表示,甚至有些不是二次函数的也和n2属于同一量级,例如2n2+3lgn。“同一 量级”这个概念可以用下图来说明(该图出自[算法导论]): 图 11.2. Θ-notation 如果可以找到两个正的常数c1和c2,使得n足够大的时候(也就是n≥n0的时候)f(n)总是夹 在c1g(n)和c2g(n)之间,就说f(n)和g(n)是同一量级的,f(n)就可以用Θ(g(n))来表示。 以二次函数为例,比如1/2n2-3n,要证明它是属于Θ(n2)这个集合的,我们必须确 定c1、c2和n0,这些常数不随n而改变,并且当n≥n0以后,c1n2≤1/2n2-3n≤c2n2总是成立的。 为此我们从不等式的每一边都除以n2,得到c1≤1/2-3/n≤c2。见下图: 图 11.3. 1/2-3/n 这样就很容易看出来,无论n取多少,该函数一定小于1/2,因此c2=1/2,当n=6时函数值 为0,n>6时该函数都大于0,可以取n0=7,c1=1/14,这样当n≥n0时都有1/2-3/n≥c1。通过这个 证明过程可以得出结论,当n足够大时任何an2+bn+c都夹在c1n2和c2n2之间,相对于n2项来 说bn+c的影响可以忽略,a可以通过选取合适的c1、c2来补偿。 几种常见的时间复杂度函数按数量级从小到大的顺序依次 是:Θ(lgn),Θ(sqrt(n)),Θ(n),Θ(nlgn),Θ(n2),Θ(n3),Θ(2n),Θ(n!)。其中,lgn通常表示 以10为底n的对数,但是对于Θ-notation来说,Θ(lgn)和Θ(log2n)并无区别(想一想这是为什 么),在算法分析中lgn通常表示以2为底n的对数。可是什么算法的时间复杂度里会出现lgn呢? 回顾插入排序的时间复杂度分析,无非是循环体的执行时间乘以循环次数,只有加和乘运算, 怎么会出来lg呢?下一节归并排序的时间复杂度里面就有lg,请读者留心lg运算是从哪出来的。 除了Θ-notation之外,表示算法的时间复杂度常用的还有一种Big-O notation。我们知道插入排 序在最坏情况和平均情况下时间复杂度是Θ(n2),在最好情况下是Θ(n),数量级比Θ(n2)要小, 那么总结起来在各种情况下插入排序的时间复杂度是O(n2)。Θ的含义和“等于”类似,而大O的 含义和“小于等于”类似。 [20] 受计算机内存管理机制的影响,指令的执行时间不一定是常数,但执行时间的上界(Upper Bound)肯定是常数,我们这里假设语句的执行时间是常数只是一个粗略估计。 上一页 2. 插入排序 上一级 起始页 下一页 4. 归并排序 上一页 4. 归并排序 第 11 章 排序与查找 下一页 4. 归并排序 插入排序算法采取增量式(Incremental)的策略解决问题,每次添一个元素到已排序的子序列 中,逐渐将整个数组排序完毕,它的时间复杂度是Θ(n2)。下面介绍另一个典型的排序算法-- 归并排序,它采取分而治之(Divide-and-Conquer)的策略,时间复杂度是Θ(nlgn),优于插入 排序算法。归并排序的步骤如下: 1. Divide: 把长度为n的输入序列分成两个长度为n/2的子序列。 2. Conquer: 对这两个子序列分别采用归并排序。 3. Combine: 将两个排序好的子序列合并成一个最终的排序序列。 在描述归并排序的步骤时又调用了归并排序本身,可见这是一个递归的过程。 例 11.2. 归并排序 #include #define LEN 8 int a[LEN] = { 5, 2, 4, 7, 1, 3, 2, 6 }; void merge(int start, int mid, int end) { int n1 = mid - start + 1; int n2 = end - mid; int left[n1], right[n2]; int i, j, k; for (i = 0; i < n1; i++) /* left holds a[start..mid] */ left[i] = a[start+i]; for (j = 0; j < n2; ++j) /* right holds a[mid+1..end] */ right[j] = a[mid+1+j]; i = j = 0; for (k = start; i < n1 && j < n2; ++k) { if (left[i] < right[j]) { a[k] = left[i]; ++i; } else { a[k] = right[j]; ++j; } } if (i < n1) /* left[] is not exhausted */ for (; i < n1; i++) { a[k] = left[i]; ++k; } if (j < n2) /* right[] is not exhausted */ for (; j < n2; ++j) { a[k] = right[j]; ++k; } } void sort(int start, int end) { int mid; if (start < end) { mid = (start + end) / 2; printf("sort (%d-%d, %d-%d) %d %d %d %d %d %d %d %d\n", start, mid, mid+1, end, a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7]); sort(start, mid); sort(mid + 1, end); merge(start, mid, end); printf("merge (%d-%d, %d-%d) to %d %d %d %d %d %d %d %d\n", start, mid, mid+1, end, a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7]); } } int main(void) { sort(0, LEN-1); return 0; } 执行结果是: sort (0-3, 4-7) 5 2 4 7 1 3 2 6 sort (0-1, 2-3) 5 2 4 7 1 3 2 6 sort (0-0, 1-1) 5 2 4 7 1 3 2 6 merge (0-0, 1-1) to 2 5 4 7 1 3 2 6 sort (2-2, 3-3) 2 5 4 7 1 3 2 6 merge (2-2, 3-3) to 2 5 4 7 1 3 2 6 merge 0-1, 2-3) to 2 4 5 7 1 3 2 6 sort (4-5, 6-7) 2 4 5 7 1 3 2 6 sort (4-4, 5-5) 2 4 5 7 1 3 2 6 merge (4-4, 5-5) to 2 4 5 7 1 3 2 6 sort (6-6, 7-7) 2 4 5 7 1 3 2 6 merge (6-6, 7-7) to 2 4 5 7 1 3 2 6 merge (4-5, 6-7) to 2 4 5 7 1 2 3 6 merge (0-3, 4-7) to 1 2 2 3 4 5 6 7 sort函数把a[start..end]平均分成两个子序列,分别是a[start..mid]和a[mid+1..end],对这两个子 序列分别递归调用sort函数进行排序,然后调用merge函数将排好序的两个子序列合并起来,由 于两个子序列都已经排好序了,合并的过程很简单,每次循环取两个子序列中最小的元素进行 比较,将较小的元素取出放到最终的排序序列中,如果其中一个子序列的元素已取完,就把另 一个子序列剩下的元素都放到最终的排序序列中。为了便于理解程序,我在sort函数开头和结尾 插了打印语句,可以看出调用过程是这样的: 图 11.4. 归并排序调用过程 图中S表示sort函数,M表示merge函数,整个控制流程沿虚线所示的方向调用和返回。由 于sort函数递归调用了自己两次,所以各函数之间调用关系呈树状结构。画这个图只是为了更清 楚地展现归并排序的过程,读者在理解递归函数时一定不要全部展开来看,而是要抓住Base Case和递推关系来理解。我们分析一下归并排序的时间复杂度,以下分析出自[算法导论]。 首先分析merge函数的时间复杂度。在merge函数中演示了C99的新特性--可变长数组,当然 也可以避免使用这一特性,比如把left和right都按最大长度LEN分配。不管用哪种办法,定义数 组并分配存储空间的语句执行时间可以看作常数,而不管数组有多长,常数用Θ-notation记 作Θ(1)。设子序列a[start..mid]的长度为n1,子序列[mid+1..end]的长度为n2,a[start..end]的总 长度为n=n1+n2,则前两个for循环的执行时间是Θ(n1+n2),也就是Θ(n),后面三个for循环合在 一起看,每走一次循环就会在最终的排序序列中确定一个元素,最终的排序序列共有n个元素, 所以执行时间也是Θ(n)。两个Θ(n)再加上若干常数项,merge函数总的执行时间仍是Θ(n),其 中n=end-start+1。 然后分析sort()函数的时间复杂度,当输入长度n=1,也就是start=end时,if条件不成立,执行时 间为常数Θ(1),当输入长度n>1时: 总的执行时间 = 2 × 输入长度为n/2的sort函数执行时间 + merge函数的执行时间Θ(n) 设输入长度为n的sort函数执行时间为T(n),综上所述: 这是一个递推公式(Recurrence)。我们需要把把T(n)从等号右侧消去,写成n的函数。其实, 符合一定条件的Recurrence展开有数学公式可以套。这里我们略去严格的数学证明,只是从直 观上看一下这个递推公式的结果。当n=1时可以设T(1)=c1,当n>1时可以设T(n)=2T(n/2)+c2n, 我们取c1和c2中较大的一个设为c,把原来的公式改为: 这样计算出的结果应该是T(n)的上界。下面我们把T(n/2)的项展开成2T(n/4)+cn/2(下图中 的(c)),然后再把T(n/4)进一步展开,直到最后全部变成T(1)=c的项(下图中的(d)): 把图(d)中所有的项加起来就是总的执行时间。这是一个树状结构,每一层的和都是cn,共 有lgn+1层,因此总的执行时间是cnlgn+cn,相比nlgn项来说,cn项可以忽略,因此T(n)的上界 是Θ(nlgn)。 如果先前取c1和c2中较小的一个设为c,计算出的结果应该是T(n)的下界,然而推导过程一样, 结果也是Θ(nlgn)。既然T(n)的上下界都是Θ(nlgn),显然T(n)就是Θ(nlgn)。 可见,归并排序是比插入排序更好的算法,虽然merge函数的步骤较多,引入了较大的常数、系 数和低次项,但是对于较大的输入长度n,这些都不是主要因素,归并排序是Θ(nlgn),插入排 序的平均情况是Θ(n2),这就决定了归并排序是更快的算法。但是不是任何情况下归并排序都优 于插入排序呢?哪些情况适用插入排序而不适用归并排序?留给读者思考。 习题 1、为了便于初学者理解,本节的merge函数写得有些啰嗦,你能想出哪些办法将它化简? 2、快速排序是另外一种采用分而治之策略的排序算法,平均情况下时间复杂度也是Θ(nlgn), 但可以比归并排序有更小的时间常数。它的基本思想是这样的: int partition(int start, int end) { 从a[start..end] 中选取一个pivot 元素(比如选a[start] 为pivot ); 在一个循环中移动a[start..end]的数据,将a[start..end]分成两半, 使a[start..mid- 1]比pivot 元素小,a[mid+1..end] 比pivot 元素大,而a[mid] 就 是pivot 元素; return mid; } void quicksort(int start, int end) { int mid; if (end > start) { mid = partition(start, end); quicksort(start, mid-1); quicksort(mid+1, end); } } 请补完partition函数,这个函数有多种实现方法,请选择时间常数尽可能小的实现。然后分析为 什么快速排序在平均情况下时间复杂度是Θ(nlgn),在最好和最坏情况下时间复杂度又是什么样 的? 上一页 3. 算法的时间复杂度分析 上一级 起始页 下一页 5. 线性查找 上一页 5. 线性查找 第 11 章 排序与查找 下一页 5. 线性查找 有些查找问题可以用Θ(n)的算法来解决。例如写一个indexof函数,从任意输入字符串中找出某 个字母的位置并返回这个位置,如果找不到就返回-1: 例 11.3. 线性查找 #include char a[]="hello world"; int indexof(char letter) { int i = 0; while (a[i] != '\0') { if (a[i] == letter) return i; i++; } return -1; } int main(void) { printf("%d %d\n", indexof('o'), indexof('z')); return 0; } 这个实现是最直观和最容易想到的,但它是不是最快的算法呢?我们知道插入排序也比归并排 序更容易想到,但通常不如归并排序快。那么现在这个问题--给定一个随机排列的序列,找 出其中某个元素的位置--有没有比Θ(n)更快的算法?比如Θ(lgn)?请读者思考一下。 习题 1、实现一个算法,在一组随机排列的数中找到最小的一个。你能想到的最直观的算法一定也 是Θ(n)的,有没有比Θ(n)更快的算法? 2、在一组随机排列的数中找出第二小的,这个问题比上一个稍复杂,你能不能想出Θ(n)的算 法? 3、进一步泛化,在一组随机排列的数中找出第k小的,这个元素称为k-th Order Statistic。能想 到的最直观的算法肯定是先把这些数排序,然后取第k个,时间复杂度和排序算法相同,可以 是Θ(nlgn)。这个问题虽然比上两个问题复杂,但它也有Θ(n)的算法,将第 4 节 “归并排序”习 题2的快速排序算法稍加修改就可以解决这个问题: /* 从start到end之间找出第k小的元素 */ int order_statistic(int start, int end, int k) { 用partition函数把序列分成两半,中间的pivot 元素是序列中的第i 个; if (k == i) 返回找到的元素; else if (k > i) 从后半部分找出第k-i小的元素并返回; else 从前半部分找出第k小的元素并返回; } 编程实现这个算法,注意检查各种情况下的Base Case。 上一页 4. 归并排序 上一级 起始页 下一页 6. 折半查找 上一页 6. 折半查找 第 11 章 排序与查找 下一页 6. 折半查找 如果不是从一组随机的序列里查找,而是从一组排好序的序列里找出某个元素的位置,则可以 有更快的算法: 例 11.4. 折半查找 #include #define LEN 8 int a[LEN] = { 1, 3, 3, 3, 4, 5, 6, 7 }; int binarysearch(int number) { int mid, start = 0, end = LEN - 1; while (start <= end) { mid = (start + end) / 2; if (a[mid] < number) start = mid + 1; else if (a[mid] > number) end = mid - 1; else return mid; } return -1; } int main(void) { printf("%d\n", binarysearch(3)); return 0; } 由于这个序列已经从小到大排好序了,每次取中间的元素和待查找的元素比较,如果中间的元 素比待查找的元素小,就说明“如果待查找的元素存在,一定位于序列的后半部分”,这样可以把 搜索范围缩小到后半部分,然后再次使用这种算法迭代。这种“每次将搜索范围缩小一半”的思想 称为折半查找(Binary Search)。思考一下,这个算法的时间复杂度怎么表示? 这个算法的思想很简单,不是吗?可是[编程珠玑]上说作者在课堂上讲完这个算法的思想然后让 学生写程序,有90%的人写出的程序中有各种各样的Bug,读者不信的话可以不看书自己写一遍 试试。这个算法容易出错的地方很多,比如mid = (start + end) / 2;这一句,在数学概念上 其实是mid = (start + end) / 2 ,还有start = mid + 1;和end = mid - 1;,如果前者写 成了start = mid;或后者写成了end = mid;那么很可能会导致死循环(想一想为什么)。 怎样才能尽可能保证程序的正确性呢?在第 2 节 “插入排序”我们讲过借助Loop Invariant检验循 环的正确性,binarysearch这个函数的主体也是一个循环,它的Loop Invariant可以这样描 述:待查找的元素number如果存在于数组a之中,那么一定存在于a[start..end]这个范围之 间,换句话说,在这个范围之外的数组a的元素中一定不存在number这个元素。以下为了书写 方便,我们把这句话表示成mustbe(start, end, number)。可以一边看算法一边做推理: int binarysearch(int number) { int mid, start = 0, end = LEN - 1; /* 假定a是排好序的 */ /* mustbe(start, end, number),因为a[start..end]就是整个数 组a[0..LEN-1] */ while (start <= end) { /* mustbe(start, end, number),因为一开始进入循环时是正确的,每 次循环也都维护了这个条件 */ mid = (start + end) / 2; if (a[mid] < number) /* 既然a是排好序的,a[start..mid]应该都 比number小,所以mustbe(mid+1, end, number) */ start = mid + 1; /* 维护了mustbe(start, end, number) */ else if (a[mid] > number) /* 既然a是排好序的,a[mid..end]应该都 比number大,所以mustbe(start, mid-1, number) */ end = mid - 1; /* 维护了mustbe(start, end, number) */ else /* a[mid] == number,说明找到了 */ return mid; } /* * mustbe(start, end, number)一直被循环维护着,到这里应该仍然成 立,在a[start..end]范围之外一定不存在number, * 但现在a[start..end]是空序列,在这个范围之外的正是整个数组a,因 此整个数组a中都不存在number */ return -1; } 注意这个算法有一个非常重要的前提--a是排好序的,如果没有了这个前提,“如果a[mid] < number,则a[start..mid]应该都比number小”这一步推理就不成立。从更普遍的意义上说,调用 者(Caller)和被调用者(或者叫函数的实现者,Callee)之间订立了一个契约(Contract), 在调用函数之前,Caller需要对Callee尽到某些义务,比如确保a是排好序的,确 保a[start..end]都是有效的数组元素而没有访问越界,这称为Precondition,然后在Callee中对一 些Invariant进行维护(Maintenance),这些Invariant保证了Callee在结束时能够对Caller尽到 某些义务,比如确保“如果number在数组a中存在,一定能找出来并返回它的位置,如 果number在数组a中不存在,一定能返回-1”,这称为Postcondition。如果每个函数的文档都非 常清楚地记录了Precondition、Maintenance和Postcondition是什么,那么每个函数都可以独立 地编写和测试,整个系统就会易于维护。这种编程思想是由Eiffel语言的设计者Bertrand Meyer提出来的,称为Design by Contract(DbC)。 测试一个函数是否正确需要把Precondition、Maintenance和Postcondition这三方面都测试到, 比如binarysearch这个函数,即使它写得非常正确,既维护了Invariant也保证了Postcondition, 如果调用它的Caller没有保证Precondition,最后的结果也还是错的。我们编写两个测试用 的Predicate函数,然后把相关的测试插入到binarysearch函数中: 例 11.5. 带有测试代码的折半查找 #include #include #define LEN 8 int a[LEN] = { 1, 3, 3, 3, 4, 5, 6, 7 }; int is_sorted() { int i, sorted = 1; for (i = 1; i < LEN; i++) sorted = sorted && a[i-1] <= a[i]; return sorted; } int mustbe(int start, int end, int number) { int i; for (i = 0; i < LEN; i++) { if (i >= start && i <= end) continue; if (a[i] == number) return 0; } return 1; } int binarysearch(int number) { int mid, start = 0, end = LEN - 1; assert(is_sorted()); /* Precondition */ while (start <= end) { assert(mustbe(start, end, number)); /* Maintenance */ mid = (start + end) / 2; if (a[mid] < number) start = mid + 1; else if (a[mid] > number) end = mid - 1; else return mid; } assert(mustbe(start, end, number)); /* Postcondition */ return -1; } int main(void) { printf("%d\n", binarysearch(3)); return 0; } assert是头文件assert.h中的一个宏定义,执行到assert(is_sorted())这句时,如 果is_sorted()返回值为真,则当什么事都没发生过,继续往下执行,如果is_sorted()返回值为 假(例如把数组的排列顺序改一改),则报错退出程序: main: main.c:33: binarysearch: Assertion `is_sorted()' failed. Aborted 在代码中适当的地方使用断言(Assertion)可以有效地帮助我们测试程序。也许有人会 问:binarysearch这个函数我们用两个测试函数is_sorted和mustbe来测试,那么这两个测试函 数又用什么来测试呢?在实际工作中我们要测试的代码绝不会像binarysearch这么简单,而我们 专为测试目的编写的测试函数往往都比较简单,比较容易保证正确性,也可以用一些简单的方 法测试,这样就把测试复杂系统的问题转化为测试一些简单的测试函数的问题。 测试代码只在开发和调试时有用,如果已经发布(Release)的软件还要运行这些测试代码就会 严重影响性能了,所以C语言规定,如果在包含assert.h之前定义一个NDEBUG宏(表示No Debug),就可以禁用assert.h中的assert宏定义,代码中的assert就不起任何作用了: #define NDEBUG #include #include ...... 还有另一种办法,不必修改源文件,直接在编译时加上选项-DNDEBUG,相当于在文件开头定 义NDEBUG宏。有关宏定义和预处理以后会更详细解释。 习题 1、编写一个函数求平方根。相当于x2-y=0,正实数y是已知的,求方程的根。x在从0到y之间必 定有一个取值是方程的根,x比根小的时候方程的左边都小于0,x比根大的时候方程的左边都大 于0,可以采用折半查找的思想。注意,由于计算机浮点运算的精度有限,只能求一个近似解, 比如满足|x2-y|<0.001就可以认为这个值是方程的根。思考一下这个算法需要迭代多少次?迭代 次数的多少由什么因素决定? 折半查找的思想有非常广泛的应用,不仅限于从一组排好序的元素中找出某个元素的位置,还 可以解决很多类似的问题。[编程珠玑]对于折半查找的各种应用和优化技巧有非常详细的介绍。 上一页 5. 线性查找 上一级 起始页 下一页 第 12 章 栈与队列 上一页 第 12 章 栈与队列 目录 1. 数据结构的概念 2. 堆栈 3. 深度优先搜索 4. 队列与广度优先搜索 5. 环形队列 上一页 6. 折半查找 第 12 章 栈与队列 部分 I. C语言入门 上一级 起始页 下一页 下一页 1. 数据结构的概念 上一页 1. 数据结构的概念 第 12 章 栈与队列 下一页 1. 数据结构的概念 数据结构(Data Structure)是数据的组织方式。程序中用到的数据都不是孤立的,而是有相互 联系的,根据访问数据的需求不同,同样的数据可以有多种不同的组织方式。以前学过的复合 类型也可以看作数据的组织方式,把同一类型的数据组织成数组,或者把描述同一对象的各成 员组织成结构体。数据的组织方式包含了存储方式和访问方式这两层意思,二者是紧密联系 的。例如,数组的各元素是一个挨一个存储的,并且每个元素的大小相同,因此数组可以提供 按下标访问的方式,结构体的各成员也是一个挨一个存储的,但是每个成员的大小不同,所以 只能用.运算符加成员名来访问,而不能按下标访问。 本章主要介绍栈和队列这两种数据结构以及它们的应用。从本章的应用实例可以看出,一个问 题中数据的存储方式和访问方式就决定了解决问题可以采用什么样的算法,要设计一个算法就 要同时设计相应的数据结构来支持这种算法。所以Pascal语言的设计者Niklaus Wirth提出算 法+数据结构=程序(详见[算法+数据结构=程序])。 上一页 第 12 章 栈与队列 上一级 起始页 下一页 2. 堆栈 上一页 2. 堆栈 第 12 章 栈与队列 下一页 2. 堆栈 在第 3 节 “递归”中我们已经对栈这种数据结构有了初步认识。堆栈是一组元素的集合,类似于 数组,不同之处在于,数组可以按下标随机访问,这次访问a[5]下次可以访问a[1],但是堆栈的 访问规则被限制为Push和Pop两种操作,Push(入栈或压栈)向栈顶添加元素,Pop(出栈或 弹出)则取出当前栈顶的元素,也就是说,只能访问栈顶元素而不能访问栈中其它元素。如果 所有元素的类型相同,堆栈的存储也可以用数组来实现,访问操作可以通过函数接口提供。看 以下的示例程序。 例 12.1. 用堆栈实现倒序打印 #include char stack[512]; int top = 0; void push(char c) { stack[top] = c; top++; } char pop(void) { top--; return stack[top]; } int is_empty(void) { return top == 0; } int main(void) { push('a'); push('b'); push('c'); while(!is_empty()) putchar(pop()); putchar('\n'); return 0; } 运行结果是cba。运行过程图示如下: 图 12.1. 用堆栈实现倒序打印 数组stack是堆栈的存储空间,top用作数组stack的索引,注意top总是指向栈顶元素的下一个 元素,可以把它称为指针(Pointer)。在第 2 节 “插入排序”中介绍了Loop Invariant的概念,可 以用它检验循环的正确性,这里的“top总是指向栈顶元素的下一个元素”其实也是一 种Invariant,可以检验Push和Pop操作是否正确实现了,这种Invariant表示一个数据结构的状态 总是维持某个条件,在DbC中称为Class Invariant。Pop操作的语义是取出栈顶元素,但上例的 实现其实并没有清除原来的栈顶元素,只是把top指针移动了一下,原来的栈顶元素仍然存在那 里。这就足够了,因为此后通过Push和Pop操作不可能再访问到已经取出的元素了,下 次Push操作就会覆盖它。putchar函数的作用是把一个字符打印到屏幕上,和printf的%c作用 相同。布尔函数is_empty的作用是防止Pop操作访问越界。这里我们把栈的空间取得足够大 (512个元素),其实严格来说Push操作也应该检查是否越过上界。 在main函数中,入栈的顺序是'a'、'b'、'c',而出栈打印的顺序却是'c'、'b'、'a',最后入栈的'c'最 早出来,因此堆栈这种数据结构的特点可以概括为LIFO(Last In First Out,后进先出)。我们 也可以写一个递归函数来倒序打印,这是利用函数调用的栈帧实现后进先出的: 例 12.2. 用递归实现倒序打印 #include #define LEN 3 char buf[LEN]={'a', 'b', 'c'}; void print_backward(int pos) { if(pos == LEN) return; print_backward(pos+1); putchar(buf[pos]); } int main(void) { print_backward(0); putchar('\n'); return 0; } 也许你会说,又是堆栈又是递归的,倒序打印一个数组犯得着这么大动干戈吗?写一个简单的 循环不就行了: for (i = LEN-1; i >= 0; i--) putchar(buf[i]); 对于数组来说确实没必要搞这么复杂,但对于某些数据结构就没这么简单了,下一节你就会看 到这种思想的实际应用了。 上一页 1. 数据结构的概念 上一级 起始页 下一页 3. 深度优先搜索 上一页 3. 深度优先搜索 第 12 章 栈与队列 下一页 3. 深度优先搜索 现在我们用堆栈解决一个有意思的问题,定义一个二维数组: int maze[5][5] = { 0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, }; 它表示一个迷宫,其中的1表示墙壁,0表示可以走的路,只能横着走或竖着走,不能斜着走, 要求编程序找出从左上角到右下角的路线。程序如下: 例 12.3. 用深度优先搜索解迷宫问题 #include #define MAX_ROW 5 #define MAX_COL 5 struct point { int row, col; } stack[512]; int top = 0; void push(struct point p) { stack[top] = p; top++; } struct point pop(void) { top--; return stack[top]; } int is_empty(void) { return top == 0; } int maze[MAX_ROW][MAX_COL] = { 0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, }; void print_maze(void) { int i, j; for (i = 0; i < MAX_ROW; i++) { for (j = 0; j < MAX_COL; j++) printf("%d ", maze[i][j]); putchar('\n'); } printf("*********\n"); } struct point predecessor[MAX_ROW][MAX_COL] = { {{-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}, {-1,- 1}}, {{-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}, {-1,- 1}}, {{-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}, {-1,- 1}}, {{-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}, {-1,- 1}}, {{-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}, {-1,- 1}}, }; void visit(int row, int col, struct point pre) { struct point visit_point = { row, col }; maze[row][col] = 2; predecessor[row][col] = pre; push(visit_point); } int main(void) { struct point p = { 0, 0 }; maze[p.row][p.col] = 2; push(p); while (!is_empty()) { p = pop(); if (p.row == MAX_ROW - 1 /* goal */ && p.col == MAX_COL - 1) break; if (p.col+1 < MAX_COL /* right */ && maze[p.row][p.col+1] == 0) visit(p.row, p.col+1, p); if (p.row+1 < MAX_ROW /* down */ && maze[p.row+1][p.col] == 0) visit(p.row+1, p.col, p); if (p.col-1 >= 0 /* left */ && maze[p.row][p.col-1] == 0) visit(p.row, p.col-1, p); if (p.row-1 >= 0 /* up */ && maze[p.row-1][p.col] == 0) visit(p.row-1, p.col, p); print_maze(); } if (p.row == MAX_ROW - 1 && p.col == MAX_COL - 1) { printf("(%d, %d)\n", p.row, p.col); while (predecessor[p.row][p.col].row != -1) { p = predecessor[p.row][p.col]; printf("(%d, %d)\n", p.row, p.col); } } else printf("No path!\n"); return 0; } 运行结果如下: 21000 21010 00000 01110 00010 ********* 21000 21010 20000 01110 00010 ********* 21000 21010 22000 21110 00010 ********* 21000 21010 22000 21110 20010 ********* 21000 21010 22000 21110 22010 ********* 21000 21010 22000 21110 22210 ********* 21000 21010 22000 21110 22210 ********* 21000 21010 22200 21110 22210 ********* 21000 21210 22220 21110 22210 ********* 21200 21210 22220 21110 22210 ********* 21220 21210 22220 21110 22210 ********* 21222 21210 22220 21110 22210 ********* 21222 21212 22220 21110 22210 ********* 21222 21212 22222 21110 22210 ********* 21222 21212 22222 21112 22210 ********* 21222 21212 22222 21112 22212 ********* (4, 4) (3, 4) (2, 4) (1, 4) (0, 4) (0, 3) (0, 2) (1, 2) (2, 2) (2, 1) (2, 0) (1, 0) (0, 0) 这次堆栈里的元素是结构体类型的,用来表示迷宫中一个点的x和y座标。我们用一个新的数据 结构保存走迷宫的路线,每个走过的点都有一个前趋(Predecessor)的点,表示是从哪儿走到 当前点的,比如predecessor[4][4]是座标为(3, 4)的点,就表示从(3, 4)走到了(4, 4),一开 始predecessor的各元素初始化为无效座标(-1, -1)。在迷宫中探索路线的同时就把路线保存 在predecessor数组中,已经走过的点在maze数组中记为2防止重复走,最后找到终点时就根 据predecessor数组保存的路线从终点打印到起点。为了帮助理解,我把这个算法改写成伪代码 如下: 将起点标记为已走过并压栈; while (栈非空) { 从栈顶弹出一个点p; if (p这个点是终点) break; 否则沿右、下、左、上四个方向探索相邻的点,if (和p相邻的点有路可走,并 且还没走过) 将相邻的点标记为已走过并压栈,它的前趋就是p点; } if (p点是终点) { 打印p点的座标; while (p点有前趋) { p点=p点的前趋; 打印p点的座标; } } else 没有路线可以到达终点; 我在while循环的末尾插了打印语句,每探索一步都打印出当前标记了哪些点,从打印结果可看 出这种搜索算法的特点:每次取一个相邻的点走下去,一直走到无路可走了再退回来,取另一 个相邻的点再走下去。这称为深度优先搜索(DFS,Depth First Search)。探索迷宫和堆栈变 化的过程如下图所示。 图 12.2. 深度优先搜索 图中各点的编号反映出探索的顺序,堆栈中的数字就是图中点的编号,可见正是因为堆栈后进 先出的性质使这个算法具有了深度优先的特点。如果在探索问题的解时走进了死胡同,则需要 退回来从另一条路继续探索,这种思想称为回溯(Backtrack),一个典型的例子是很多编程书 上都会讲的八皇后问题。 最后我们打印终点的座标并通过predecessor数据结构找到它的前趋,这样顺藤摸瓜一直打印到 起点。那么能不能从起点到终点正向打印路线呢?在上一节我们看到,如果是在一个循环里打 印数组,既可以正向打印也可以反向打印,因为数组这种数据结构是支持随机访问的,当然也 支持顺序访问,并且既可以是正向的也可以是反向的。但现在predecessor这种数据结构的每个 元素只知道它的前趋是谁,而不知道它的后继(Successor)是谁,所以在循环里只能反向打 印。由此可见,有什么样的数据结构就决定了可以用什么样的算法。那么,为什么不再建一 个successor数组来保存每个点的后继呢?虽然每个点的前趋只有一个,后继却不止一个, 从DFS算法的过程可以看出,如果每次在保存前趋的同时也保存后继,后继不一定会指向正确 的路线,请读者想一想为什么。由此可见,有什么样的算法就决定了可以用什么样的数据结 构。设计算法和设计数据结构这两件工作是紧密联系的。 习题 1、修改本节的程序,最后从起点到终点正向打印路线。你能想出几种办法? 2、本节程序中predecessor这个数据结构占用的存储空间太多了,可以改变它的存储方式以节 省空间,想一想该怎么做。 3、上一节我们实现了一个基于堆栈的程序,然后用递归改写了它,用函数调用的栈帧实现同样 的功能。本节的DSF算法是基于堆栈的,请把它改写成递归的程序。改写成递归程序是可以避 免使用predecessor数据结构的,想想该怎么做。 上一页 2. 堆栈 上一级 起始页 下一页 4. 队列与广度优先搜索 上一页 4. 队列与广度优先搜索 第 12 章 栈与队列 下一页 4. 队列与广度优先搜索 队列也是一组元素的集合,也提供两种基本操作:Enqueue(入队)将元素添加到队 尾,Dequeue(出队)从队头取出元素并返回。就像排队买票一样,先来先服务,先入队的人 也是先出队的,这种方式称为FIFO(First In First Out,先进先出),有时候队列本身也被称 为FIFO。 下面我们用队列解决迷宫问题。程序如下: 例 12.4. 用广度优先搜索解迷宫问题 #include #define MAX_ROW 5 #define MAX_COL 5 struct point { int row, col, predecessor; } queue[512]; int head = 0, tail = 0; void enqueue(struct point p) { queue[tail] = p; tail++; } struct point dequeue(void) { head++; return queue[head-1]; } int is_empty(void) { return head == tail; } int maze[MAX_ROW][MAX_COL] = { 0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, }; void print_maze(void) { int i, j; for (i = 0; i < MAX_ROW; i++) { for (j = 0; j < MAX_COL; j++) printf("%d ", maze[i][j]); putchar('\n'); } printf("*********\n"); } void visit(int row, int col) { struct point visit_point = { row, col, head-1 }; maze[row][col] = 2; enqueue(visit_point); } int main(void) { struct point p = { 0, 0, -1 }; maze[p.row][p.col] = 2; enqueue(p); while (!is_empty()) { p = dequeue(); if (p.row == MAX_ROW - 1 /* goal */ && p.col == MAX_COL - 1) break; if (p.col+1 < MAX_COL /* right */ && maze[p.row][p.col+1] == 0) visit(p.row, p.col+1); if (p.row+1 < MAX_ROW /* down */ && maze[p.row+1][p.col] == 0) visit(p.row+1, p.col); if (p.col-1 >= 0 /* left */ && maze[p.row][p.col-1] == 0) visit(p.row, p.col-1); if (p.row-1 >= 0 /* up */ && maze[p.row-1][p.col] == 0) visit(p.row-1, p.col); print_maze(); } if (p.row == MAX_ROW - 1 && p.col == MAX_COL - 1) { printf("(%d, %d)\n", p.row, p.col); while (p.predecessor != -1) { p = queue[p.predecessor]; printf("(%d, %d)\n", p.row, p.col); } } else printf("No path!\n"); return 0; } 运行结果如下: 21000 21010 00000 01110 00010 ********* 21000 21010 20000 01110 00010 ********* 21000 21010 22000 21110 00010 ********* 21000 21010 22200 21110 00010 ********* 21000 21010 22200 21110 20010 ********* 21000 21210 22220 21110 20010 ********* 21000 21210 22220 21110 22010 ********* 21000 21210 22222 21110 22010 ********* 21200 21210 22222 21110 22010 ********* 21200 21210 22222 21110 22210 ********* 21200 21212 22222 21112 22210 ********* 21220 21212 22222 21112 22210 ********* 21220 21212 22222 21112 22210 ********* 21220 21212 22222 21112 22212 ********* 21222 21212 22222 21112 22212 ********* 21222 21212 22222 21112 22212 ********* (4, 4) (3, 4) (2, 4) (2, 3) (2, 2) (2, 1) (2, 0) (1, 0) (0, 0) 其实仍然可以像例 12.3 “用深度优先搜索解迷宫问题”一样用predecessor数组表示每个点的前 趋,但是我想换一种更方便的数据结构,直接在每个点的结构体中加一个成员表示前趋: struct point { int row, col, predecessor; } queue[512]; int head = 0, tail = 0; 变量head、tail就像前两节用来表示栈顶的top一样,是queue数组的索引或者叫指针,分别指向 队头和队尾。每个点的predecessor成员也是一个指针,指向它的前趋在queue数组中的位置。 如下图所示: 图 12.3. 广度优先搜索的队列数据结构 为了帮助理解,我把这个算法改写成伪代码如下: 将起点标记为已走过并入队; while (队列非空) { 出队一个点p; if (p这个点是终点) break; 否则沿右、下、左、上四个方向探索相邻的点,if (和p相邻的点有路可走,并 且还没走过) 将相邻的点标记为已走过并入队,它的前趋就是刚出队的p点; } if (p点是终点) { 打印p点的座标; while (p点有前趋) { p点=p点的前趋; 打印p点的座标; } } else 没有路线可以到达终点; 从打印的搜索过程可以看出,这个算法的特点是沿各个方向同时展开搜索,每个可以走通的方 向轮流往前走一步,这称为广度优先搜索(BFS,Breadth First Search)。探索迷宫和队列变 化的过程如下图所示。 图 12.4. 广度优先搜索 广度优先是一种步步为营的策略,每次都从各个方向探索一步,将前线推进一步,图中的虚线 就表示这个前线,队列中的元素总是由前线的点组成的,可见正是因为队列先进先出的性质使 这个算法具有了广度优先的特点。广度优先搜索还有一个特点是可以找到从起点到终点的最短 路径,而深度优先搜索找到的不一定是最短路径,比较本节和上一节程序的运行结果可以看出 这一点,想一想为什么。 习题 1、本节的例子直接在队列元素中加一个指针成员表示前趋,想一想为什么上一节的例 12.3 “用 深度优先搜索解迷宫问题”不能采用这种方法表示前趋? 2、在讲解例 12.1 “用堆栈实现倒序打印”时我们说“top总是指向栈顶元素的下一个元素”是堆栈 操作的Class Invariant,那么本节实现的队列操作的Invariant应该怎么描述? 上一页 3. 深度优先搜索 上一级 起始页 下一页 5. 环形队列 上一页 5. 环形队列 第 12 章 栈与队列 下一页 5. 环形队列 比较例 12.3 “用深度优先搜索解迷宫问题”的栈操作和例 12.4 “用广度优先搜索解迷宫问题”的队 列操作可以发现,栈操作的top指针在Push时增大而在Pop时减小,栈空间是可以重复利用的, 而队列的head、tail指针都在一直增大,虽然前面的元素已经出队了,但它所占的存储空间却不 能重复利用,这样对存储空间的利用效率很低,在问题的规模较大时(比如100×100的迷宫) 需要非常大的队列空间。为了解决这个问题,我们介绍一种新的数据结构--环形队列 (Circular Queue)。把queue数组想像成一个圈,head和tail指针仍然是一直增大的,当指到 数组末尾时就自动回到数组开头,就像两个人围着操场赛跑,沿着它们跑的方向看, 从head到tail之间是队列的有效元素,从tail到head之间是空的存储位置,head追上tail就表示队 列空了,tail追上head就表示队列的存储空间满了。如下图所示: 图 12.5. 环形队列 习题 1、将例 12.4 “用广度优先搜索解迷宫问题”改用环形队列实现。然后回答: 运行原来的程序要求queue数组至少有多长?不用跟踪程序的运行过程,你能很快答上来 吗? 改为环形队列之后要求queue数组至少有多长? 上一页 4. 队列与广度优先搜索 上一级 起始页 下一页 第 13 章 本阶段总结 上一页 第 13 章 本阶段总结 部分 I. C语言入门 下一页 第 13 章 本阶段总结 目录 1. C语言基本语法 2. 思维方法与编程思想 3. 调试方法 善于学习的人都应该善于总结。本书的编排顺序充分考虑到知识的前后依赖关系,保证在讲解 每个新知识点的时候都只用到前面章节讲过的知识,但正因为如此,很多相互关联的知识点被 拆散到多个章节中了。我们一章一章地纵向学习过来之后,应该理出几个横切面,把拆散到各 章节中的知识点串起来。 上一页 5. 环形队列 上一级 起始页 下一页 1. C语言基本语法 上一页 1. C语言基本语法 1. C语言基本语法 第 13 章 本阶段总结 C源代码文件可以包含: #include <头文件> #define宏定义 类型定义(如struct和enum) 全局变量定义和初始化 函数定义(其中有一个是main函数) 函数定义: 函数接口定义,由函数名、参数和返回值类型组成 函数体语句块,由若干条语句套在{}里组成 每条语句开头可以有一个标号,语句有以下几种: 语句块,由若干条语句套在{}里组成 类型定义语句(如struct和enum) 变量定义和初始化语句 表达式语句 if/else语句 do/while语句 while 语句 for语句 goto语句 break 语句 continue语句 return语句 标号有以下几种: 自定义标号 case标号 default标号 表达式由操作数、运算符和()括号组成,操作数有以下几种: 宏定义 枚举常量 字符常量 整数 浮点数 字符串 变量名 函数名 表达式 已学过的运算符按优先级从高到低的顺序是: 后缀运算符:数组下标[]、函数调用()、结构体取成员. 单目运算符:++、--、&、正号+、负号-、逻辑非! 乘除运算符:*、/、% 加减运算符:+、关系运算符:<、>、<=、>= 相等性运算符:==、!= 逻辑与:&& 逻辑或:|| 赋值运算符:= 下一页 有些表达式可以取左值也可以取右值,其它表达式则只能取右值,可以取左值的有: 变量名 数组下标,例如a[i+1] 结构体取成员,例如p.x 在C语言中凡是需要程序员起名字的语法元素都遵循标识符的命名规则,包括: 变量名 函数名 形参名 宏定义名 自定义标号 结构体和枚举的类型Tag 结构体和枚举的成员名 上一页 第 13 章 本阶段总结 上一级 起始页 下一页 2. 思维方法与编程思想 上一页 2. 思维方法与编程思想 第 13 章 本阶段总结 2. 思维方法与编程思想 以概念为中心,第 1 节 “程序和编程语言” 组合规则,第 5 节 “表达式” Least Surprise,第 3 节 “形参和实参” 充分条件与必要条件,第 4 节 “局部变量与全局变量” 封装,第 2 节 “if/else语句” 布尔逻辑,第 3 节 “布尔代数” 递归,第 3 节 “递归” 函数式编程,第 1 节 “while语句” 迭代(第 6 章 循环语句)与增量式求解(第 2 节 “插入排序”) 抽象,第 2 节 “数据抽象” 数据驱动,第 5 节 “多维数组” 分而治之,第 4 节 “归并排序” 折半求解,第 6 节 “折半查找” 回溯,例 12.3 “用深度优先搜索解迷宫问题” 上一页 1. C语言基本语法 上一级 起始页 下一页 下一页 3. 调试方法 上一页 3. 调试方法 第 13 章 本阶段总结 3. 调试方法 编译错误、运行时错误与语义错误,第 3 节 “程序的调试” 增量式开发,第 2 节 “增量式开发” 打印语句与Scaffold,第 2 节 “增量式开发” gdb,第 10 章 gdb DbC与Assertion,第 6 节 “折半查找” 上一页 2. 思维方法与编程思想 上一级 起始页 下一页 下一页 部分 II. C语言本质 上一页 部分 II. C语言本质 部分 II. C语言本质 目录 14. 计算机中数的表示 1. 为什么计算机用二进制计数 2. 不同进制之间的换算 3. 整数的加减运算 4. 浮点数 15. 数据类型详解 1. 整型 2. 浮点型 3. 类型转换 3.1. Integer Promotion 3.2. Usual Arithmetic Conversion 3.3. 由赋值产生的类型转换 3.4. 强制类型转换 3.5. 编译器如何处理类型转换 16. 运算符详解 1. 位运算 1.1. 按位与、或、异或、取反运算 1.2. 移位运算 1.3. 掩码 1.4. 异或运算的一些特性 2. 其它运算符 2.1. 复合赋值运算符 2.2. 条件运算符 2.3. 逗号运算符 2.4. sizeof运算符与typedef类型声明 3. Side Effect与Sequence Point 4. 运算符总结 17. 计算机体系结构基础 1. 内存与地址 2. CPU 下一页 3. 设备 4. MMU 5. Memory Hierarchy 18. x86汇编程序基础 1. 最简单的汇编程序 2. x86的寄存器 3. 第二个汇编程序 4. 寻址方式 5. ELF文件 5.1. 目标文件 5.2. 可执行文件 19. 汇编与C之间的关系 1. 函数调用 2. main函数和启动例程 3. 变量的存储布局 4. 结构体和联合体 5. C内联汇编 6. volatile限定符 20. 链接详解 1. 多目标文件的链接 2. 定义和声明 2.1. extern和static关键字 2.2. 头文件 2.3. 定义和声明的详细规则 3. 静态库 4. 共享库 4.1. 编译、链接、运行 4.2. 动态链接的过程 4.3. 共享库的命名惯例 5. 虚拟内存管理 21. 预处理 1. 预处理的步骤 2. 宏定义 2.1. 函数式宏定义 2.2. 内联函数 2.3. #、##运算符和可变参数 2.4. 宏展开的步骤 3. 条件预处理指示 4. 其它预处理特性 22. Makefile基础 1. 基本规则 2. 隐含规则和模式规则 3. 变量 4. 自动处理头文件的依赖关系 5. 常用的make命令行选项 23. 指针 1. 指针的基本操作 2. 指针类型的参数和返回值 3. 指针与数组 4. 指针与const限定符 5. 指针与结构体 6. 指向指针的指针与指针数组 7. 指向数组的指针与多维数组 8. 函数类型和函数指针类型 9. 不完全类型和复杂声明 24. 函数接口 1. 本章的预备知识 1.1. strcpy与strncpy 1.2. malloc与free 2. 传入参数与传出参数 3. 两层指针的参数 4. 返回值是指针的情况 5. 回调函数 6. 可变参数 25. C标准库 1. 字符串操作函数 1.1. 初始化字符串 1.2. 取字符串的长度 1.3. 拷贝字符串 1.4. 连接字符串 1.5. 比较字符串 1.6. 搜索字符串 1.7. 分割字符串 2. 标准I/O库函数 2.1. 文件的基本概念 2.2. fopen/fclose 2.3. stdin/stdout/stderr 2.4. errno与perror函数 2.5. 以字节为单位的I/O函数 2.6. 操作读写位置的函数 2.7. 以字符串为单位的I/O函数 2.8. 以记录为单位的I/O函数 2.9. 格式化I/O函数 2.10. C标准库的I/O缓冲区 3. 数值字符串转换函数 4. 分配内存的函数 5. 本章综合练习 26. 链表、二叉树和哈希表 1. 链表 1.1. 单链表 1.2. 双向链表 1.3. 静态链表 2. 二叉树 2.1. 二叉树的基本概念 2.2. 排序二叉树 3. 哈希表 27. 本阶段总结 上一页 3. 调试方法 起始页 下一页 第 14 章 计算机中数的表示 上一页 第 14 章 计算机中数的表示 部分 II. C语言本质 下一页 第 14 章 计算机中数的表示 目录 1. 为什么计算机用二进制计数 2. 不同进制之间的换算 3. 整数的加减运算 4. 浮点数 上一页 部分 II. C语言本质 上一级 起始页 下一页 1. 为什么计算机用二进制计数 上一页 1. 为什么计算机用二进制计数 第 14 章 计算机中数的表示 下一页 1. 为什么计算机用二进制计数 人类的计数方式通常是“逢十进一”,称为十进制(Decimal),大概因为人有十个手指,所以十 进制是最自然的计数方式,各民族的文字中都有十个数字,而阿拉伯数字0-9是目前最广泛采用 的。 计算机是采用数字电路搭成的,数字电路中只有1和0两种状态,或者可以说计算机只有两个手 指,所以对计算机来说二进制(Binary)是最自然的计数方式。应用“逢二进一”的原则,十进制 的1、2、3、4分别对应二进制的1、10、11、100。二进制的一位数字称为一个位(Bit),三 个bit能够表示的最大的二进制数是111,也就是十进制的7。不管用哪种计数方式,数的大小并 没有变,十进制的1+1等于2,二进制的1+1等于10,但二进制的10和十进制的2大小是相等的。 事实上,计算机采用如下的逻辑电路计算两个bit的加法: 图 14.1. 1-bit Full Adder 图的上半部分(出自Wikipedia)的电路称为一位全加器(1-bit Full Adder),图的下半部分是 一些逻辑电路符号的图例,我们首先解释这些图例。逻辑电路由门电路(Gate)和导线 (Wire)组成,同一条导线上在某一时刻的电压值只能是高和低两种状态之一,分别用0和1表 示。如果两条导线接在一起则它们的电压值相同,在接点处画一个黑点,如果接点处没有画黑 点则表示这两条线并没有接在一起,只是在画图时无法避免交叉。导线的电压值进入门电路的 输入端,经过逻辑运算后在门电路的输出端输出运算结果的电压值,任何复杂的加减乘除运算 都可以分解成简单的逻辑运算。AND、OR和NOT运算在第 3 节 “布尔代数”中讲过了,这三种逻 辑运算分别用与门、或门和反相器(Inverter)实现。另外几种逻辑运算在这里补充一下。异或 (XOR,eXclusive OR)运算的真值表如下: 表 14.1. XOR的真值表 A B A XOR B 000 011 101 110 一句话概括就是:两个操作数相同则结果为0,两个操作数不同则结果为1。与非(NAND)和 或非(NOR)运算就是在与、或运算的基础上取反: 表 14.2. NAND的真值表 A B A NAND B 001 011 101 110 表 14.3. NOR的真值表 A B A NOR B 001 010 100 110 如果把与门、或门和反相器组合来实现NAND和NOR运算,则电路过于复杂了,因此逻辑电路 中通常有专用的与非门和或非门。现在我们看看上图中的AND、OR、XOR是怎么实现两个bit的 加法的。A、B是两个加数,Cin是低位传上来的进位(Carry),相当于三个加数求和,三个加 数都是0则结果为0,三个加数都是1则结果为11,也就是说输出位S是1,产生的进位Cout也 是1。下面根据加法的规则用真值表列出所有可能的情况: 表 14.4. 1-bit Full Adder的真值表 A B Cin Cout S 000 0 0 001 0 1 010 0 1 011 1 0 100 0 1 101 1 0 110 1 0 111 1 1 请读者对照电路图验证一下真值表是否正确。如果把很多个一位全加器串接起来,就成了多位 加法器,如下图所示(该图出自Wikipedia): 图 14.2. 4-bit Ripple Carry Adder 图中为了简单起见,把一位全加器用方框来表示。上一级全加器的Cout连接到下一级全加器 的Cin,让进位像涟漪一样一级一级传开,所以叫做Ripple Carry Adder,这样就可以把两个4 bit二进制数A3A2A1A0和B3B2B1B0加起来了。在这里介绍Ripple Carry Adder只是为了让读者 理解计算机是怎么通过逻辑运算来做算术运算的,实际上这种加法器效率很低,只能加完了一 位再加下一位,更实用、更复杂的加法器可以多个位一起计算,有兴趣的读者可参考[数字逻辑 基础]。 上一页 第 14 章 计算机中数的表示 上一级 起始页 下一页 2. 不同进制之间的换算 上一页 2. 不同进制之间的换算 第 14 章 计算机中数的表示 下一页 2. 不同进制之间的换算 在十进制中,个位的1代表100=1,十位的1代表101=10,百位的1代表102=100,所以 123=1×102+2×101+3×100 同样道理,在二进制中,个位的1代表20=1,十位的1代表21=2,百位的1代表22=4,所以 (A3A2A1A0)2=A3×23+A2×22+A1×21+A0×20 如果二进制和十进制数出现在同一个等式中,为了区别我们用(A3A2A1A0)2这种形式表 示A3A2A1A0是二进制数,每个数字只能是0或1,其它没有套括号加下标的数仍表示十进制数。 对于(A3A2A1A0)2这样一个二进制数,最左边的A3位称为最高位(MSB,Most Significant Bit),最右边的A0位称为最低位(LSB,Least Significant Bit)。以后我们遵循这样的惯 例:LSB称为第0位而不是第1位,所以如果一个数是32位的,则MSB是第31位。上式就是从二 进制到十进制的换算公式。作为练习,请读者算一下(1011)2和(1111)2换算成十进制分别是多 少。 下面来看十进制怎么换算成二进制。我们知道 13=1×23+1×22+0×21+1×20 所以13换算成二进制应该是(1101)2。问题是怎么把13分解成等号右边的形式呢?注意到等号右 边可以写成 13=((((0×2+13)×2+12)×2+01)×2+10 我们将13反复除以2取余数就可以提取出上式中的1101四个数字,为了让读者更容易看清楚是 哪个1和哪个0,上式和下式中对应的数字都加了下标: 13÷2=6...10 6÷2=3...01 3÷2=1...12 1÷2=0...13 把这四步得到的余数按相反的顺序排列就是13的二进制表示,因此这种方法称为除二反序取余 法。 计算机是用二进制表示数据的,因此程序员也必须习惯使用二进制,但二进制写起来太啰嗦 了,所以通常将二进制数分成每三位一组或者每四位一组,每组用一个数字表示。比如 把(10110010)2从最低位开始每三位分成一组,10、110、010,然后把每一组写成一个十进制 数字,就是(262)8,这种表示方式数字的取值范围是0~7,逢八进一,称为八进制(Octal)。 类似地,把(10110010)2分成每四位一组,1011、0010,然后把每一组写成一个数字,这个数 的低位是2,高位已经大于9了,我们规定用字母A~F表示10~15,则这个数可以写成(B2)16,这 种表示方式数字的取值范围是0~F,逢十六进一,称为十六进制(Hexadecimal)。所以,八进 制和十六进制是程序员为了书写二进制方便而发明的简便写法,好比草书和正楷的关系一样。 习题 1、二进制小数可以这样定义: (0.A1A2A3...)2=A1×2-1+A2×2-2+A3×2-3+... 这也是从二进制小数到十进制小数的换算公式。从本节讲的十进制转二进制的推导过程出发, 类比一下,十进制小数换算成二进制小数应该怎么计算? 2、再类比一下,八进制(或十六进制)与十进制之间如何相互换算? 上一页 1. 为什么计算机用二进制计数 上一级 起始页 下一页 3. 整数的加减运算 上一页 3. 整数的加减运算 第 14 章 计算机中数的表示 下一页 3. 整数的加减运算 我们已经了解了计算机中正整数如何表示,加法如何计算,那么负数如何表示呢?减法又如何 计算呢?本节来讨论这个问题。为了书写方便,本节举的例子都用8个bit表示一个数,实际计算 机算术运算的操作数可以是8位、16位、32位甚至64位的。 要用8个bit表示正数和负数,一种简单的思路是把最高位当作符号位(Sign Bit),0表示正1表 示负,剩下的七位表示绝对值的大小,这称为Sign and Magnitude表示法。例如-1表示 成10000001,+1表示成00000001。思考一下,N个bit的Sign and Magnitude表示法能够表示 的最大整数和最小整数分别是多少?请写出算式。 计算机要对这样的两个数做加法运算需要处理以下逻辑: 1. 如果两数符号位相同,就把它们的低7位相加,符号位不变。如果低7位相加时在最高位产 生进位,则结果超出7位所能表示的数值范围,这称为溢出(Overflow),通常把计算机 中的一个标志位置1表示产生溢出。 2. 如果两数符号位不同,首先比较它们的低7位谁大,然后用大数减小数,结果的符号位和 大数相同。 减法运算需要处理以下逻辑: 1. 如果两数符号位相同,并且低7位是大数减小数,则符号位不变,如果低7位是小数减大 数,则按大数减小数计算,结果要变号。 2. 如果两数符号位不同,把低7位相加,如果是正数减负数则结果为正,如果是负数减正数 则结果为负,低7位在相加时可能产生溢出。 这其实和手算加减法的逻辑是相同的。算加减法需要处理这么多逻辑:比较符号位,比较绝对 值,加法改减法,减法改加法,小数减大数改成大数减小数……这是非常低效率的。还有一个 缺点是0的表示不唯一,既可以表示成10000000也可以表示成00000000,进一步增加了逻辑的 复杂性,所以我们迫切需要重新设计数的表示方法,以使计算过程更简单。 有一种方法可以把减法全部转化成加法来计算,这样就不必设计加法器和减法器两套电路了。 我们以十进制减法为例来理解一下这种方法。比如 167-52=167+(999-52)-1000+1=167+947-1000+1=1114-1000+1=114+1=115 首先把52换成999-52,也就是947,这称为取9的补码(9's Complement),虽然这也是减法但 它不需要借位,只需要对每一位数字分别取补码,所以比一般的减法要简单得多。然后 把167和947相加,百位上的进位舍去,得到114,然后再加1得到115[21],这就是最终结果了。 一句话概括就是:减去一个数等于加上这个数取9的补码再加1(忽略最高位的进位)。 这种方法也可以类推到二进制加减法:减去一个数等于加上这个数取1的补码(1's Complement)再加1(忽略MSB的进位)。取1的补码就是1-1=0,1-0=1,其实相当于把每一 位数字取反了,以后将1的补码简称为反码。比如 00001000-00000100->00001000+11111011+1->00000011+1=00000100 上式的前两步不是等价变换,所以没有用=号而是用->表示,第一步多加了一个100000000,第 二步少加了一个100000000,效果相互抵消,所以最终结果正是00001000-00000100的结果。 现在我们发现,如果把第一步写成00001000+(-00000100)->00001000+11111011+1, 则11111011+1就可以用来表示负数-00000100。所以,补码表示法不仅可以把减法转化为加 法,而且合理地规定了负数的表示方法,就是“先取反码再加1”。负数的这种表示称为2的补码 (2's Complement),以后简称为补码。为什么称为2的补码呢?因为如果对一位数取补码, 则1的补码是1-1+1=10-1=1,相当于从2里面减去1。类似地,对00000100取补码是1111111100000100+1=100000000-00000100,相当于从100000000(十进制的256)里面减 去00000100。 将负数全部用补码表示之后,8个bit可以表示的正数有00000000~01111111(十进制 的0~127),负数有10000000~11111111(十进制的-128~-1),合起来是十进制的128~127,一共256个数,而8个bit最多可以表示28=256个不同的数,所以已经充分利用了 这8个bit,每个数都只有一种表示,0也只有一种表示就是00000000。我们还发现,所有正数的 最高位是0,所有负数的最高位是1,因此最高位仍然具有符号位的含义,要检查一个数是正是 负只要看最高位就可以了,但在计算时却可以把符号位和数放在一起做加法运算,而不必 像Sign and Magnitude表示法那样对符号位单独处理。 采用补码做加减运算时总是忽略MSB的进位,这让人很不放心:如果在计算过程中忽略进位的 效果没有相互抵消怎么办?如果没有相互抵消,最后的结果肯定是错的,这种情况一定是由溢 出引起的。只要我们有办法判断哪些情况会产生溢出,其它情况下都可以放心地忽略MSB的进 位。判断溢出的办法是这样的:在相加过程中最高位产生的进位和次高位产生的进位如果相同 则没有溢出,否则就说明产生了溢出。逻辑电路的实现可以把这两个进位连接到一个异或门, 把异或门的输出连接到溢出标志位。对于8位二进制数的加减运算来说,当计算结果超出128~127的范围时就会溢出,例如: 图 14.3. 有符号数加法溢出 最高位产生的进位是1,次高位产生的进位是0,说明溢出了,计算结果换算成十进制是122,这 显然不对,根本原因是(-126)+(-8)=-134超出了8位二进制数能表示的范围。 用8个bit既表示正数又表示负数,则能够表示的范围是-128~127,如果8个bit全部表示正数,则 能够表示的范围是0~255,前者称为有符号数(Signed Number),后者称为无符号数 (Unsigned Number)。但是计算机在做加法时并不区分操作数是有符号数还是无符号数,计 算过程都是一样的,所以上面的例子也可以看作无符号数的加法: 图 14.4. 无符号数加法进位 把两个操作数看作无符号数分别是130和248,计算结果换算成十进制是122,最高位的一个进 位相当于256,122+256这个结果是对的。计算机的加法器在做完计算之后,根据最高位产生的 进位设置进位标志,同时根据最高位和次高位产生的进位的异或设置溢出标志。至于这个加法 到底是有符号数加法还是无符号数加法则取决于程序怎么理解了,如果程序把它理解成有符号 数加法,就去检查溢出标志,如果程序把它理解成无符号数加法,就去检查进位标志。通常计 算机在做算术运算之后还可能设置另外两个标志,如果结果为零则设置零标志,如果结果的最 高位是1则设置负数标志(只有当理解成有符号数运算时才去检查这个标志)。 [21] 也可以看作是把百位上的进位加回到个位上去,本来应该加1000,结果加了1,少加 了999,正好把先前多加的999抵消了。 上一页 2. 不同进制之间的换算 上一级 起始页 下一页 4. 浮点数 上一页 4. 浮点数 第 14 章 计算机中数的表示 下一页 4. 浮点数 浮点数在计算机中的表示是基于科学计数法(Scientific Notation)的,我们知道32767这个数用 科学计数法可以写成3.2767×104,3.2767称为尾数(Mantissa,或者叫Significand),4称为 指数(Exponent)。浮点数在计算机中的表示与此类似,只不过基数(Radix)是2而不是10。 下面我们用一个简单的模型来解释浮点数的基本概念。我们的模型由三部分组成:符号位、指 数部分(表示2的多少次方)和尾数部分(只表示小数点后的数字)。 图 14.5. 一种浮点数格式 如果要表示17这个数,我们知道17=17.0×100=1.7×101=0.17×102,类似 地,17=(10001)2×20=(0.10001)2×25,把尾数的有效数字全部移到小数点后,这样就可以表示 为: 图 14.6. 17的浮点数表示 如果我们要表示0.25就遇到新的困难了,因为0.25=1×2-2=(0.1)2×2-1,而我们的模型中指数部 分没有规定如何表示负数。我们可以在指数部分里规定一个符号位,然而更有效和广泛采用的 办法是使用偏移的指数(Biased Exponent)。规定一个偏移值,比如16,实际的指数要加上这 个偏移值再填写到指数部分,这样,比16大的就表示正指数,比16小的就表示负指数。要表 示0.25,指数部分应该填16-1=15: 图 14.7. 0.25的偏移指数浮点数表示 现在还有一个问题需要解决:每个浮点数的表示方法都不唯一,例 如17=(0.10001)2×25=(0.010001)2×26,这样给计算机处理增添了复杂性。为了解决这个问题, 我们规定尾数部分的最高位必须是1,也就是说尾数必须以0.1开头,对指数做相应的调整,这 称为正规化(Normalize)。由于尾数部分的最高位必须是1,这个1就不必保存了,可以节省出 一位来用于提高精度,我们说最高位的1是隐含的(Implied)。这样17就只有一种表示方法 了,指数部分应该是16+5=21=(10101)2,尾数部分去掉最高位的1是0001: 图 14.8. 17的正规化尾数浮点数表示 两个浮点数相加,首先把小数点对齐然后相加: 图 14.9. 浮点数相加 由于计算机浮点数表示的精度有限,计算结果末尾的10两位被舍去了。做浮点运算时要注意精 度问题,有时候计算顺序不同也会导致不同的结果,把上面的例子改一 下,11.0010000+0.00000001+0.00000001=11.0010000+0.00000001=11.0010000,后面加的 两个很小的数全被舍去了,没有起任何作用,但如果换一下计算顺序就能影响到计算结果 了:0.00000001+0.00000001+11.0010000=0.00000010+11.0010000=11.0010001。再比 如128.25=(10000000.01)2,需要10个有效位,而我们的模型中尾数部分是8位,算上隐含的最 高位1一共有9个有效位,那么128.25的浮点数表示只能舍去末尾的1,表示成(10000000.0)2, 其实跟128相等了。 浮点数是一个相当复杂的话题,本节只是通过这个简单的模型介绍一些基本概念而不深入讨 论,理解了这些基本概念有助于你理解浮点数标准,目前业界广泛采用的符点数标准是 由IEEE(Institute of Electrical and Electronics Engineers)制定的IEEE 754。 上一页 3. 整数的加减运算 上一级 起始页 下一页 第 15 章 数据类型详解 上一页 第 15 章 数据类型详解 部分 II. C语言本质 第 15 章 数据类型详解 目录 1. 整型 2. 浮点型 3. 类型转换 3.1. Integer Promotion 3.2. Usual Arithmetic Conversion 3.3. 由赋值产生的类型转换 3.4. 强制类型转换 3.5. 编译器如何处理类型转换 上一页 4. 浮点数 上一级 起始页 下一页 下一页 1. 整型 上一页 1. 整型 第 15 章 数据类型详解 下一页 1. 整型 计算机存储的最小单位是字节(Byte),一个字节通常是8个bit。C语言规定char型占一个字节 的存储空间。如果这8个bit按无符号整数来解释,则取值范围是0~255,如果按有符号整数来解 释,则取值范围是-128~127。C语言规定了signed和unsigned两个关键字,unsigned char型表 示无符号数,signed char型表示有符号数。 那么不带signed或unsigned关键字的char型呢?C标准规定这是Implementation Defined,编译 器可以定义char型是无符号的,也可以定义char型是有符号的,在该编译器所对应的体系结构 上哪种实现效率高就可以采用哪种实现,x86平台的gcc定义char是有符号的。这也是C标准 的Rationale之一:优先考虑效率,而可移植性尚在其次。这就要求程序员非常清楚这些规则, 如果你要写可移植的代码,就必须清楚哪些写法是不可移植的,应该避免使用。另一方面,写 不可移植的代码有时候也是必要的,比如Linux内核代码使用了很多gcc特性以得到最佳的执行 效率,在写的时候就没打算用别的编译器编译,也就没考虑可移植性的问题。如果要写不可移 植的代码,你也必须清楚代码中的哪些部分是不可移植的,以及为什么要这样写,如果不是为 了效率,一般来说就没有理由故意写不可移植的代码。从现在开始,我们会接触到很 多Implementation Defined的特性,C语言与平台和编译器是密不可分的,离开了具体的平台和 编译器讨论C语言,就只能讨论到本书第一部分的程度了。注意,ASCII码的取值范围 是0~127,所以不管char型是有符号的还是无符号的,存一个ASCII码都没有问题,一般来说, 如果用char型存ASCII码字符,就不必明确写signed还是unsigned,如果把char型当作8位的整 数来用,为了可移植性就必须写明是signed还是unsigned。 Implementationdefined、Unspecified和Undefined 在C标准中没有做明确规定的地方会用Implementationdefined、Unspecified或Undefined来表述,在本书中有时把这三 种情况统称为“未明确定义”的。这三种情况到底有什么不同呢? 我们刚才看到一种Implementation-defined的情况,C标准没有明 确规定char是有符号的还是无符号的,但是要求编译器必须对此 做出明确规定,并写在编译器的文档中。 而对于Unspecified的情况,往往有几种可选的处理方式,C标准 没有明确规定按哪种方式处理,编译器可以自己决定,并且也不 必写在编译器的文档中,这样即使用同一个编译器的不同版本来 编译也可能得到不同的结果,因为编译器没有在文档中明确写它 会怎么处理,那么不同版本的编译器就可以选择不同的处理方 式,比如下一章我们会讲到一个函数调用的各个实参表达式按什 么顺序求值是Unspecified的。 Undefined的情况则是完全不确定的,C标准没规定怎么处理,编 译器很可能也没规定,甚至也没做出错处理,有很多Undefined的 情况是编译器是检查不出来的,最终会导致运行时错误,比如数 组访问越界就是Undefined的。 初学者看到这些规则通常会很不舒服,觉得这不是在学编程而是 在啃法律条文,结果越学越泄气。是的,C语言并不像一个数学定 理那样完美,现实世界里的东西总是不够完美的。但还好啦,C程 序员已经很幸福了,只要严格遵照C标准来写代码,不要去触碰那 些阴暗角落,写出来的代码就有很好的可移植性。想想那些可怜 的JavaScript程序员吧,他们甚至连一个可以遵照的标准都没有, 一个浏览器一个样,因而不得不为每一种浏览器的每一个版本分 别写不同的代码。 除了char型之外,整数类型还有short int(或者简写为short)、int、long int(或者简写 为long)、long long int(或者简写为long long)几种,这些类型都可以加 上signed或unsigned关键字表示有符号或无符号数。那么有符号数在计算机中的表示形式 是Sign and Magnitude、1's Complement还是2's Complement?C标准也没有明确规定,也 是Implementation Defined。大多数体系结构都采用2's Complement表示形式和加减运算规 则,x86平台也是如此。还有一点要注意,除了char型以外的这些整数类型如果不明确 写signed或unsigned关键字都表示有符号数,这一点是C标准明确规定的,不是Implementation Defined。 除了char型在C标准中明确规定占一个字节之外,其它整数类型占几个字节都是Implementation Defined。通常的编译器实现遵守ILP32或LP64规范,如下表所示。 表 15.1. ILP32和LP64 类型 ILP32(位数) LP64(位数) char 8 8 short 16 16 int 32 32 long 32 64 long long 64 64 指针 32 64 ILP32这个缩写的意思是int(I)、long(L)和指针(P)类型都占32位,通常32位计算机 的C编译器采用这种规范,x86平台的gcc也是如此。LP64是指long(L)和指针占64位,通 常64位计算机的C编译器采用这种规范。指针类型的长度总是和计算机的位数一致,至于什么是 计算机的位数,指针又是什么,以后再详细解释。从现在开始本书做以下约定:在以后的陈述 中,缺省平台是x86/Linux/gcc,遵循ILP32,并且char是有符号的,我不会每次都加以说明, 但说到其它平台时我会明确指出是什么平台。 以前我们只用到10进制的整数常量,其实在C语言中也可以用八进制和十六进制的整数常 量[22]。八进制整数常量以0开头,后面的数字只能是0~7,例如022,因此十进制的整数常量就 不能以0开头了,否则无法和八进制区分。十六进制整数常量以0x或0X开头,后面的数字可以 是0~9、a~f和A~F。在第 6 节 “字符类型与字符编码”讲过一种转义序列,以\或\x加八进制或十 六进制数字表示,这种表示方式相当于把八进制和十六进制整数常量开头的0替换成\了。 整数常量还可以在末尾在加u或U表示“unsigned”,加l或L表示“long”,加ll或LL表示“long long”,例如0x1234U,98765ULL等。但事实上u、l、ll这几种后缀和上面讲 的unsigned、long、long long关键字并不是一一对应的。这个对应关系比较复杂,准确的描述 如下图所示(出自[C99]条款6.4.4.1)。 表 15.2. 整数常量的类型 后缀 十进制常量 int 无 long int long long int 八进制或十六进制常量 int unsigned int long int unsigned long int long long int unsigned long long int u或U unsigned int unsigned int unsigned long int unsigned long int unsigned long long int unsigned long long int long int l或L long int long long int unsigned long int long long int unsigned long long int 既有u或U,又有l或L unsigned long int unsigned long int unsigned long long int unsigned long long int ll或LL long long int long long int unsigned long long int 既有u或U,又有ll或LL unsigned long long int unsigned long long int 给定一个整数常量,比如1234U,那么它应该属于“u或U”这一行的“十进制常量”这一列,这个表 格单元中列了三种类型unsigned int、unsigned long int、unsigned long long int,从上到 下找出第一个足够长的类型可以表示1234这个数,那么它就是这个整数常量的类型,如 果int是32位的那么unsigned int就可以表示。 再比如0xffff0000,应该属于第一行无的第二列八进制或十六进制常量,这一列有六种类 型int、unsigned int、long int、unsigned long int、long long int、unsigned long long int,第一个类型int表示不了0xffff0000这么大的数,我们写这个十六进制常量是要表示一个正 数,而它的MSB(第31位)是1,如果按有符号int类型来解释就成了负数了,第二个类 型unsigned int可以表示这个数,所以这个十六进制常量的类型应该算unsigned int。 最后总结一下哪些类型属于整型这个大的概念。整型包括本节讲的有符号和无符号 的char、int、long、long long,还包括以后要讲的Bit-field,此外,枚举常量就是int型的, 所以也属于整型。 [22] 有些编译器(比如gcc)也支持二进制的整数常量,以0b或0B开头,比如0b0001111,但二 进制的整数常量从未进入C标准,只是某些编译器的扩展,所以不建议使用,由于二进制和八进 制、十六进制的对应关系非常明显,用八进制或十六进制常量完全可以代替使用二进制常量。 上一页 第 15 章 数据类型详解 上一级 起始页 下一页 2. 浮点型 上一页 2. 浮点型 第 15 章 数据类型详解 下一页 2. 浮点型 C标准规定的浮点型有float、double、long double,和整数类型一样,既没有规定每种类型占 多少字节,也没有规定采用哪种表示形式。浮点数的实现在各种平台上差异很大,有的处理器 有浮点运算单元(称为硬件实现),有的处理器没有,只能做整数运算,那么就要用整数运算 来模拟浮点运算(称为软件实现),虽然大部分平台的浮点数实现(硬件或软件实现)是遵 循IEEE 754的,但仍有很多平台的实现没有遵循IEEE 754。x86处理器通常是有浮点运算单元 的,遵循IEEE 754,float型通常是32位,double型通常是64位。 以前我们只用到最简单的浮点数常量,例如3.14,现在看看浮点数常量还有哪些写法。由于浮 点数在计算机中的表示是基于科学计数法的,所以浮点数常量也可以写成科学计数法的形式, 尾数和指数之间用e或E隔开,例如314e-2表示314×10-2,注意这种表示形式基数是10[23],如 果尾数的小数点左边或右边没有数字则表示这一部分为零,例如3.e-1,.987等等。浮点数也可 以加一个后缀,例如3.14f、.01L,浮点数的后缀和类型之间的对应关系比较简单,没有后缀的 浮点数常量是double型的,有后缀f或F的浮点数常量是float型的,有后缀l或L的浮点数常量 是long double型的。 [23] C99引入一种新的十六进制浮点数表示,基数是2,本书不做详细介绍。 上一页 1. 整型 上一级 起始页 下一页 3. 类型转换 上一页 3. 类型转换 第 15 章 数据类型详解 下一页 3. 类型转换 如果有人问C语法规则中最复杂的是哪一部分,我一定会说是类型转换。从上面两节可以看出, 有符号、无符号整数和浮点数加起来有那么多种类型,每两种类型之间都要定义一个转换规 则,转换规则的数量自然很庞大,更何况由于各种体系结构对于整数和浮点数的实现很不相 同,很多类型转换的情况都是C标准未做明确规定的阴暗角落。虽然我们写代码时不会故意去触 碰这些阴暗角落,但仍然有时候会不小心犯错,所以了解一些未明确规定的情况还是有必要 的,可以在出错时更容易分析错误原因。本节分成几小节介绍哪些情况下会发生类型转换,会 发生什么样的类型转换,然后介绍编译器如何处理这样的类型转换。 3.1. Integer Promotion 在一个表达式中,凡是可以使用int或unsigned int类型做右值的地方也都可以使用有符号或无 符号的char型、short型和Bit-field。如果原始类型的取值范围都能用int型表示,则其值被提升 为int型,如果表示不了就提升为unsigned int型,这称为Integer Promotion。做Integer Promotion只影响上述几种类型的值,对其它类型无影响。C99规定Integer Promotion适用于以 下几种情况: 1、如果一个函数的形参类型未知,例如使用了Old Style C风格的函数声明(详见第 2 节 “自定 义函数”),或者函数的参数列表中有...,那么调用函数时要对相应的实参做Integer Promotion,此外,相应的实参如果是float型的也要被提升为double型,这条规则称为Default Argument Promotion。我们知道printf的参数列表中有...,除了第一个形参之外,其它形参的 类型都是未知的,因此我们在调用printf("%c", 'A')时,'A'其实被提升为int型之后才传给 了printf。 2、算术运算中的类型转换。有符号或无符号的char型、short型和Bit-field在做算术运算之前首 先要做Integer Promotion,然后才能参与计算。例如: unsigned char c1 = 255, c2 = 2; int n = c1 + c2; 计算表达式c1 + c2的过程其实是先把c1和c2提升为int类型然后相加(unsigned char的取值范 围是0~255,完全可以用int表示,所以提升为int就可以了,不需要提升为unsigned int),整 个表达式的值也是int型,最后的结果是257。假如没有这个提升的过程,c1 + c2就溢出了,最 后的结果应该是1。 那么除了+号之外,还有哪些运算符在计算之前需要做Integer Promotion呢?我们在下一小节先 介绍Usual Arithmetic Conversion规则,然后再解答这个问题。 3.2. Usual Arithmetic Conversion 两个算术类型的操作数做算术运算,比如a + b,如果两边操作数的类型不同,编译器会自动做 类型转换,使两边类型相同之后才做运算,这称为Usual Arithmetic Conversion。转换规则如 下: 1. 如果有一边的类型是long double,则把另一边也转成long double。 2. 否则,如果有一边的类型是double,则把另一边也转成double。 3. 否则,如果有一边的类型是float,则把另一边也转成float。 4. 否则,两边应该都是整数类型,首先按上一小节讲过的规则对a和b做Integer Promotion, 然后如果类型仍不相同,则需要继续转换。首先规定char、short、int、long、long long的转换级别(Integer Conversion Rank)一个比一个高,同一类型的有符号和无符号 数具有相同的Rank,然后有如下转换规则: a. 如果两边都是有符号数,或者都是无符号数,那么较低Rank的类型转换成较 高Rank的类型。例如unsigned int和unsigned long做算术运算时都转成unsigned long。 b. 否则,如果一边是无符号数另一边是有符号数,无符号数的Rank不低于有符号数 的Rank,则把有符号数转成另一边的无符号类型。例如unsigned long和int做算术 运算时都转成unsigned long,unsigned long和long做算术运算时也都转成unsigned long。 c. 剩下的情况就是:一边是无符号数另一边是有符号数,并且无符号数的Rank低于有 符号数的Rank。这时又分为两种情况,如果这个有符号数类型能够覆盖这个无符号 数类型的取值范围,则把无符号数转成另一边的有符号类型。例如遵循LP64的平台 上unsigned int和long在做算术运算时都转成long。 d. 否则,也就是这个符号数类型不足以覆盖这个无符号数类型的取值范围,则把两边 都转成两者之中较高Rank的无符号类型。例如遵循ILP32的平台上unsigned int和long在做算术运算时都转成unsigned long。 可见有符号和无符号整数的转换规则是十分复杂的,虽然这是有明确定义的,不属于阴暗角 落,但为了程序的可读性,不应该依赖这些规则来写代码。我讲这些规则,不是为了让你用 的,而是为了让你在出错时更容易分析错误原因,所以这些规则不需要记住,但要知道有这么 回事,以便用到的时候能找到这一段。 到目前为止我们学过的+ - * / % > < >= <= == !=运算符都需要做Usual Arithmetic Conversion, 因为都要求两边操作数的类型一致,在下一章会介绍几种新的运算符也需要做Usual Arithmetic Conversion。单目运算符+ - ~只有一个操作数,移位运算符<< >>两边的操作数类型不要求一 致,这些运算不需要做Usual Arithmetic Conversion,但也需要做Integer Promotion,运算符~ << >>将在下一章介绍。 3.3. 由赋值产生的类型转换 如果赋值或初始化时等号两边的类型不相同,则编译器会把等号右边的类型转换成等号左边的 类型再做赋值。例如int c = 3.14;,编译器会把右边的double型转成int型再赋给变量c。 我们知道,函数调用传参的过程相当于定义形参并且用实参对其做初始化,函数返回的过程相 当于定义一个临时变量并且用return的表达式对其做初始化,所以由赋值产生的类型转换也适 用于这两种情况。例如一个函数的原型是int foo(int, int);,则调用foo(3.1, 4.2)时会自动 把两个double型的实参转成int型赋给形参,如果这个函数定义中有返回语句return 1.2;,则返 回值1.2会自动转成int型再返回。 在函数调用和返回过程中发生的类型转换往往容易被忽视,因为函数原型和函数调用并没有写 在一起。例如char c = getchar();,看到这一句,往往想当然地认为getchar的返回值是char型 的,而事实上getchar的返回值是int型的,这样赋值会引起一个类型转换,我们以后会详细解 释使用这个函数需要注意的问题。 3.4. 强制类型转换 以上三种情况通称为隐式类型转换(Implicit Conversion,或者叫Coercion),编译器根据它自 己的一套规则将一种类型自动转换为另一种类型。除此之外,程序员也可以通过类型转换运算 符(Cast Operator)自己规定某个值要转换成何种类型,这称为显式类型转换(Explicit Conversion)或强制类型转换(Type Cast)。例如计算表达式(double)3 + i,首先将整数3强 制转换成double型3.0,然后和整型变量i相加,这时适用Usual Arithmetic Conversion规则,首 先把i也转成double型,然后两者相加,最后整个表达式的值也是double型的。这里 的(double)就是一个类型转换运算符,这种运算符由一个类型名加()括号组成,后面的3是这个 运算符的操作数。 3.5. 编译器如何处理类型转换 以上几小节介绍了哪些情况会产生类型转换,并且明确了每种情况下应该把什么类型转成什么 类型,至于这两种类型之间的转换具体怎么做则是本节的内容。本节的主要内容出自[Standard C]。 在两种类型之间做转换,转换结果将取决于两种类型的精度。那么类型的精度怎么定义呢?我 们分三种情况讨论: 1. 精度是N的有符号整数类型应该用N个Bit表示,取值范围至少应该覆盖(-2N-1, 2N-1)之间的 整数。我们用()括号表示开区间,不包含端点,用[]括号表示闭区间,包含端点。例 如signed char型用8个Bit表示,按2's Complement表示法的取值范围是[-128, 127],也可 以说是覆盖了(-128, 128)之间的整数,所以这种类型的精度是8。 2. 精度是N的无符号整数类型应该用N个Bit表示,取值范围是[0, 2N-1]。 3. 精度是N的浮点数类型的取值范围至少应该覆盖(-2N-1, 2N-1)的整数值。 现在要把一个精度是M的类型(值为X)转换成一个精度是N的类型,所有可能的情况如下表所 示。 表 15.3. 如何做类型转换 待转换的类型 N < M的情况 N == M的情况 N > M的情况 signed integer to signed integer discard m.s. M-N bits (can overflow) same value same value unsigned integer to signed integer if (X < 2N-1) same if (X < 2N-1) same value else impl.-def. value else impl.-def. same value (can overflow) (can overflow) floating-point if (|X| < 2N-1) if (|X| < 2N-1) if (|X| < 2N-1) to signed trunc(X) else imple.- trunc(X) else imple.- trunc(X) else imple.- integer def. (can overflow) def. (can overflow) def. (can overflow) signed integer to unsigned integer if (0 <= X) X % 2N else impl.-def. if (0 <= X) same value else X + 2N if (0 <= X) same value else X + 2N unsigned integer to unsigned integer X % 2N same value same value floating-point if (0 <= X < 2N) if (0 <= X < 2N) if (0 <= X < 2N) to unsigned trunc(X) else imple.- trunc(X) else imple.- trunc(X) else imple.- integer def. (can overflow) def. (can overflow) def. (can overflow) signed integer to floating-point keep sign, keep m.s. N-1 bits same value same value unsigned integer to floating-point + sign, keep m.s. N1 bits + sign, keep m.s. N1 bits same value floating-point to floatingpoint keep m.s. N-1 bits (can overflow) same value same value 上表中的一些缩写说明如下:impl.-def.表示Implementation-defined;m.s. bit表示Most Significant Bit;trunc(X)表示取X的整数部分,即Truncate Toward Zero;X % Y就是取模,上 表中用到取模运算时X和Y都是正整数。同样地,这个表不是为了让你故意去用的,而是为了让 你出错时分析错误原因的。 下面举几个例子说明这个表的用法。比如要把float型转short型,对应表中的floating-point to signed integer一行,可以看到,不管两种类型的精度如何,处理方式是一样的,如 果float类型的值在(-32768.0, 32768.0)之间,则截掉小数部分就可以了,如果float类型的值 超出了这个范围,则转换结果是未明确定义的,有可能会产生溢出,例如对于short s = 32768.4;这个语句gcc会报警告。 再比如把int类型转换成unsigned short类型,对应表中的signed integer to unsigned integer一行,如果int类型的值是正的,则把它除以216取模,其实就是取它的低16位,如 果int类型的值是负的,则转换结果是未明确定义的。 再比如把int类型转换成short类型,对应表中的第一行signed integer to signed integer, 把int类型值的高16位丢掉(这里的m.s.包括符号位在内,上表中另外几处提到的m.s.应该是不 算符号位在内),只留低16位,这种情况也有可能溢出,例如对于short s = -32769;这个语 句gcc会报警告,而对于short s = -32768;则不会报警告。 最后一个例子,把short型转换成int型,仍然对应表中第一行,转换之后应该是same value。 那怎么维持值不变呢?是不是在高位补16个0就行了呢?如果short型的值是-1,按补码表示就 是十六进制ffff,要转成int型的-1需要变成ffffffff,因此需要在高位补16个1而不是16个0。换句 话说,要维持值不变,在高位补1还是补0取决于原来的符号位,这称为符号扩展(Sign Extension)。 上一页 2. 浮点型 上一级 起始页 下一页 第 16 章 运算符详解 上一页 第 16 章 运算符详解 部分 II. C语言本质 下一页 第 16 章 运算符详解 目录 1. 位运算 1.1. 按位与、或、异或、取反运算 1.2. 移位运算 1.3. 掩码 1.4. 异或运算的一些特性 2. 其它运算符 2.1. 复合赋值运算符 2.2. 条件运算符 2.3. 逗号运算符 2.4. sizeof运算符与typedef类型声明 3. Side Effect与Sequence Point 4. 运算符总结 本章介绍很多前面没有讲过的运算符,重点是位运算,然后引出一个重要的概念Sequence Point,最后总结一下各种运算符的优先级和结合性。 上一页 3. 类型转换 上一级 起始页 下一页 1. 位运算 上一页 1. 位运算 第 16 章 运算符详解 下一页 1. 位运算 整数在计算机中用二进制的位来表示,C语言提供一些运算符可以直接操作整数中的位,称为位 运算,这些运算符的操作数都必须是整型的。在以后的学习中你会发现,很多信息利用整数中 的某几个位来存储,要访问这些位,仅仅有对整数的操作是不够的,必须借助位运算,例 如第 2 节 “Unicode和UTF-8”介绍的UTF-8编码就是如此,学完本节之后,你应该能自己写 出UTF-8的编码和解码程序。本节首先介绍各种位运算符,然后介绍位运算的各种编程技巧。 1.1. 按位与、或、异或、取反运算 在第 3 节 “布尔代数”讲过逻辑与、或、非运算,并列出了真值表,对于整数中的位也可以做 与、或、非运算,C语言提供了按位与(Bitwise AND)运算符&、按位或(Bitwise OR)运算 符|和按位取反(Bitwise NOT)运算符~,此外还有按位异或(Bitwise XOR)运算符^,我们 在第 1 节 “为什么计算机用二进制计数”讲过异或运算。下面用二进制的形式举几个例子。 图 16.1. 位运算 注意,&、|、^运算符都是要做Usual Arithmetic Conversion的,~运算符也要做Integer Promotion,所以在C语言中其实并不存在8位整数的位运算,操作数在做位运算之前都至少被提 升为int型了,上面用8位整数举例只是为了书写方便。比如: unsigned char c = 0xfc; unsigned int i = ~c; 计算过程是这样的:常量0xfc是int型的,赋给c要转成unsigned char,值不变,c的十六进制表 示就是fc,计算~c时先提升为整型(000000fc)然后取反,最后结果是ffffff03。注意,如果 把~c看成是8位整数的取反,最后结果就得3了,这就错了。为了避免出错,一是尽量避免不同 类型之间的赋值,二是每一步计算都要按上一章讲的类型转换规则仔细检查。 1.2. 移位运算 移位运算符(Bitwise Shift)包括左移<<和右移>>。左移将一个整数的各二进制位全部左移若干 位,例如0xcfffffff3<<2得到0x3fffffcc: 图 16.2. 左移运算 最高两位的11被移出去了,最低两位又补了两个0,其它位依次左移两位。但要注意,移动的位 数必须小于左操作数的总位数,比如上面的例子,左边是unsigned int型,如果左移的位数大 于等于32位,则结果是Undefined的。 复习一下第 2 节 “不同进制之间的换算”讲过的知识可以得出结论,在一定的取值范围内,将一 个整数左移1位相当于乘以2。比如二进制11(十进制3)左移一位变成110,就是6,再左移一 位变成1100,就是12。读者可以验证这条规律对负数也成立。当然,如果左移改变了符号位, 或者最高位是1被移出去了,那么结果肯定不是乘以2了,所以我说“在一定的取值范围内”。由于 计算机做移位比做乘法快得多,编译器可以利用这一点做优化,比如看到源代码中有i * 8,可 以编译成移位指令而不是乘法指令。 当操作数是无符号数时,右移运算的规则和左移类似,例如0xcfffffff3>>2得到0x33fffffc: 图 16.3. 右移运算 最低两位的11被移出去了,最高两位又补了两个0,其它位依次右移两位。和左移类似,移动的 位数也必须小于左操作数的总位数,否则结果是Undefined的。在一定的取值范围内,将一个整 数右移1位相当于除以2,小数部分截掉。 当操作数是有符号数时,右移运算的规则比较复杂: 如果是正数,那么高位移入0 如果是负数,那么高位移入1还是0不一定,这是Implementation-defined的。对于x86平台 的gcc编译器,最高位移入1,也就是仍保持负数的符号位,这种处理方式对负数仍然保持 了“右移1位相当于除以2”的性质。 综上所述,由于类型转换和移位等问题,使用有符号数做位运算是很不方便的,所以,建议只 对无符号数做位运算,以减少出错的可能。 习题 1、下面两行printf打印的结果有何不同?请读者比较分析一下。 int i = 0xcffffff3; printf("%x\n", 0xcffffff3>>2); printf("%x\n", i>>2); 1.3. 掩码 如果要对一个整数中的某些位进行操作,怎样表示这些位在整数中的位置呢?可以用掩码 (Mask)来表示,比如掩码0x0000ff00表示对一个32位整数的8~15位进行操作,举例如下。 1、取出8~15位。 unsigned int a, b, mask = 0x0000ff00; a = 0x12345678; b = (a & mask) >> 8; /* 0x00000056 */ 这样也可以达到同样的效果: b = (a >> 8) & ~(~0 << 8); 2、将8~15位清0。 unsigned int a, b, mask = 0x0000ff00; a = 0x12345678; b = a & ~mask; /* 0x12340078 */ 3、将8~15位置1。 unsigned int a, b, mask = 0x0000ff00; a = 0x12345678; b = a | mask; /* 0x1234ff78 */ 1.4. 异或运算的一些特性 1、一个数和自己做异或的结果是0。如果需要一个常数0,x86平台的编译器可能会生成这样的 指令:xorl %eax, %eax。不管eax寄存器里的值原来是多少,做异或运算都能得到0,这条指令 比同样效果的movl $0, %eax指令快。 2、从异或的真值表可以看出,不管是0还是1,和0做异或值不变,和1做异或得到原值的相反 值。可以利用这个特性配合掩码实现某些位的翻转,例如: unsigned int a, b, mask = 1 << 6; a = 0x12345678; b = a ^ mask; /* flip the 6th bit */ 3、如果a1 ^ a2 ^ a3 ^ ... ^ an的结果是1,则表示a1、a2、a3...an之中1的个数为奇数个,否则 为偶数个。这条性质可用于奇偶校验(Parity Check),比如在串口通信过程中,每个字节的数 据都计算一个校验位,数据和校验位一起发送出去,这样接收方可以根据校验位粗略地判断接 收到的数据是否有误。 4、x ^ x ^ y == y,因为x ^ x == 0,0 ^ y == y。这个性质有什么用呢?我们来看这样一个问题: 交换两个变量的值,不得借助于额外的存储空间,所以就不能采用temp = a; a = b; b = temp;的办法了。利用位运算可以这样做交换: a = a ^ b; b = b ^ a; a = a ^ b; 分析一下这个过程。为了避免混淆,把a和b的初值分别记为a0和b0。第一行,a = a0 ^ b0;第 二行,把a的新值代入,得到b = b0 ^ a0 ^ b0,等号右边的b0相当于上面公式中的x,a0相当 于y,所以结果为a0;第三行,把a和b的新值代入,得到a = a0 ^ b0 ^ a0,结果为b0。 习题 1、请在网上查找有关RAID(Redundant Array of Independent Disks,独立磁盘冗余阵列)的 资料,理解其实现原理,其实就是利用了本节的性质3和4。 上一页 第 16 章 运算符详解 上一级 起始页 下一页 2. 其它运算符 上一页 2. 其它运算符 第 16 章 运算符详解 下一页 2. 其它运算符 2.1. 复合赋值运算符 复合赋值运算符(Compound Assignment Operator)包括*= /= %= += -= <<= >>= &= ^= |=,在 赋值的同时做一个运算。例如a += 1相当于a = a + 1。但有一点细微的区别,前者对表达 式a只求值一次,而后者求值两次,如果a是一个复杂的表达式,求值一次和求值两次的效率是 不同的,例如a[i+j] += 1和a[i+j] = a[i+j] + 1。仅仅是效率上的差别吗?对于没有Side Effect的表达式,求值一次和求值两次的结果是一样的,但对于有Side Effect的表达式则不一 定,例如a[foo()] += 1和a[foo()] = a[foo()] + 1,如果foo()函数调用有Side Effect,比如 会打印一条消息,那么前者只打印一次,而后者打印两次。 在第 3 节 “for语句”讲自增、自减运算符时说++i相当于i = i + 1,其实更准确地说应该是等价 于i += 1,而--i等价于i -= 1。 2.2. 条件运算符 条件运算符(Conditional Operator)是C语言中唯一一个三目运算符(Ternary Operator),带 三个操作数,它的形式是表达式1 ? 表达式2 : 表达式3,这个运算符所组成的整个表达式的值等于 表达式2或表达式3的值,取决于表达式1的值是否为真,可以把它想像成这样的函数: if (表达式1) return 表达式2; else return 表达式3; 表达式1相当于if语句的控制表达式,因此它的值必须是标量类型,而表达式2和3相当于同一个 函数在不同情况下的返回值,因此它们的类型要求一致,也要做Usual Arithmetic Conversion。 下面举个例子,定义一个函数求两个参数中较大的一个。 int max(int a, int b) { return (a > b) ? a : b; } 2.3. 逗号运算符 逗号运算符(Comma Operator)也是一种双目运算符,它的形式是表达式1, 表达式2,两个表达 式不要求类型一致,左边的表达式1先求值,求完了直接把值丢掉,再求右边表达式2的值作为 整个表达式的值。逗号运算符是左结合的,类似于+-*/运算符,根据组合规则可以写出表达式1, 表达式2, 表达式3, ..., 表达式n这种形式,表达式1, 表达式2可以看作一个子表达式,先求表达 式1的值,然后求表达式2的值作为这个子表达式的值,然后这个值再和表达式3组成一个更大的 表达式,求表达式3的值作为这个更大的表达式的值,依此类推,整个计算过程就是从左到右依 次求值,最后一个表达式的值成为整个表达式的值。 注意,函数调用时各实参也是用逗号隔开,那个逗号不能看作逗号运算符。但可以这样: f(a, (t=3, t+2), c) 传给函数f的有三个参数,第二个参数的值是表达式t+2的值。 2.4. sizeof运算符与typedef类型声明 sizeof是一个很特殊的运算符,它有两种形式:sizeof 表达式和sizeof(类型名)。它的特殊之处 在于,sizeof 表达式中的表达式并不求值,只是根据类型转换规则求得该表达式的类型,然后把 这种类型所占的字节数作为sizeof 表达式这整个表达式的值。有些人喜欢写成sizeof(表达式)的 形式也可以,这里的括号和return(1);的括号一样,没有任何作用。但另外一种形式sizeof(类 型名)的括号则是必须写的,整个表达式的值也是这种类型所占的字节数。 比如用sizeof运算符求一个数组的长度: int a[12]; printf("%d\n", sizeof a/sizeof a[0]); 在上面这个例子中,由于sizeof 表达式中的表达式不需要求值,所以不需要到运行时才计算,事 实上,在编译时就知道sizeof a的值是48,sizeof a[0]的值是4,所以在编译时就已经 把sizeof a/sizeof a[0]替换成常量12了,这是一个常量表达式。 准确地说,sizeof表达式的值是size_t类型的,这个类型定义在stddef.h头文件中,不过你的代 码中只要不出现size_t这个类型名就不用包含这个头文件,比如像上面的例子就不用包含这个 头文件。size_t这个类型是我们讲过的整型中的某一种,编译器可能会用typedef做一个类型声 明: typedef unsigned long size_t; 那么size_t类型就是unsigned long类型。之所以不直接规定sizeof的值是unsigned long型的, 而要规定一个size_t类型,是为了允许不同的编译器根据自己平台的情况定义size_t为不同的类 型,这样使用size_t类型的代码就具有很好的可移植性,但不管编译器怎么实现,C标准明确规 定sizeof的值是无符号整型的。 typedef这个关键字用于给一个类型起个新的名字,上面的声明可以这么看:去掉typedef就成了 一个变量声明unsigned long size_t;,size_t是一个变量名,类型是unsigned long,那么加 上typedef之后,size_t就是一个类型名,就代表unsigned long类型。再举个例子: typedef char array_t[10]; array_t a; 就相当于定义char a[10];。类型名也遵循标识符的命名规则,并且通常加个_t后缀,表 示Type。 上一页 1. 位运算 上一级 起始页 下一页 3. Side Effect与Sequence Point 上一页 3. Side Effect与Sequence Point 第 16 章 运算符详解 下一页 3. Side Effect与Sequence Point 如果你只想规规矩矩地写代码,那么基本用不着看这一节。本节的内容基本上是钻牛角尖儿 的,除了Short-circuit比较实用,其它写法都应该避免使用。但没办法,有时候不是你想钻牛角 尖儿,而是有人逼你去钻牛角尖儿。这是我们的学员在找工作笔试时碰到的问题: int a=0; a = (++a)+(++a)+(++a)+(++a); 据我了解,似乎很多公司都有出这种笔试题的恶趣味。答案应该是Undefined,我甚至有些怀疑 出题的人是否真的知道答案。下面我来解释为什么是Undefined。 我们知道,调用一个函数可能产生Side Effect,使用某些运算符(++、--、=、复合赋值)也会 产生Side Effect,如果一个表达式中隐含着多个Side Effect,究竟哪个先发生哪个后发生 呢?C标准规定代码执行过程中的某些时刻是Sequence Point,当到达一个Sequence Point时,在此之前的Side Effect必须全部作用完毕,在此之后的Side Effect必须一个都没发 生。至于两个Sequence Point之间的多个Side Effect哪个先发生哪个后发生则没有规定,编译 器可以任意选择各Side Effect的作用顺序。下面详细解释各种Sequence Point(出 自[C99]的Annex C)。 1、调用一个函数时,在所有准备工作做完之后、函数调用开始之前是Sequence Point。比如调 用foo(f(), g())时,foo、f()、g()这三个表达式哪个先求值哪个后求值是Unspecified,但是 必须都求值完了才能做最后的函数调用,所以f()和g()的Side Effect按什么顺序发生不一定,但 必定在这些Side Effect全部作用完之后才开始调用foo函数。 2、条件表达式?:、逗号运算符,、逻辑与&&、逻辑或||的第一个操作数求值之后是Sequence Point。我们刚讲过条件表达式和逗号运算符,条件表达式要根据表达式1的值是否为真决定下一 步求表达式2还是表达式3的值,如果决定求表达式2的值,表达式3就不会被求值了,反之也一 样,逗号运算符也是这样,表达式1求值结束才继续求表达式2的值。 逻辑与和逻辑或早在第 3 节 “布尔代数”就讲了,但在初学阶段我一直回避它们的操作数求值顺 序的问题。这两个运算符和条件表达式类似,先求左操作数的值,然后根据这个值是否为真, 右操作数可能被求值,也可能不被求值。比如例 8.5 “剪刀石头布”这个程序中的这几句: ret = scanf("%d", &man); if (ret != 1 || man < 0 || man > 2) { printf("Invalid input! Please input 0, 1 or 2.\n"); continue; } 其实可以写得更简单([K&R]书上的代码风格就是这样): if (scanf("%d", &man) != 1 || man < 0 || man > 2) { printf("Invalid input! Please input 0, 1 or 2.\n"); continue; } 这个控制表达式的求值顺序是:先求scanf("%d", &man) = 1的值,如果scanf调用失败,则返回 值不等于1成立,||运算有一个操作数为真则整个表达式为真,这时直接执行下一句printf,根 本不会再去求man < 0或man > 2的值;如果scanf调用成功,则读入的数保存在变量man中,并 且返回值等于1,那么说它不等于1就不成立了,第一个||运算的左操作数为假,就会去求右操作 数man < 0的值作为整个表达式的值,这时变量man的值正是scanf读上来的值,我们判断它是否 在[0, 2]之间,如果man < 0不成立,则整个表达式scanf("%d", &man) != 1 || man < 0 的值为 假,也就是第二个||运算的左操作数为假,所以最后求右操作数man > 2的值作为整个表达式的 值。 &&运算与此类似,a && b的计算过程是:首先求a,如果a的值是假则整个表达式的值是假,不 会再去求b;如果a的值是真,则下一步求b的值作为整个表达式的值。所以,a && b相当于if (a) b;,而a || b相当于“if (!a) b;”。这种特性称为Short-circuit,很多人喜欢利用Shortcircuit特性使代码更加简洁。 3、在一个完整的声明末尾是Sequence Point,所谓完整的声明是指这个声明不是另外一个声明 的一部分。比如声明int a[10], b[20];,在a[10]末尾是Sequence Point,在b[20]末尾也是。 4、在一个完整的表达式末尾是Sequence Point,所谓完整的表达式是指这个表达式不是另外一 个表达式的一部分。所以如果有f(); g();这样两条语句,f()和g()是两个完整的表达 式,f()的Side Effect必定在g()之前发生。 5、在库函数返回时是Sequence Point。这似乎可以包含在上一条规则里面,因为函数返回必然 会结束掉一个表达式,开始一个新的表达式。事实上以后我们会讲到,很多库函数是以宏定义 的形式实现的,并不是真的函数,所以才需要有这条规则。 6、像printf、scanf这种带转换说明的输入/输出库函数,在处理完每一个转换说明相关的输 入/输出操作时是一个Sequence Point。 7、库函数bsearch和qsort在查找和排序过程中的每一步比较或移动操作之间是一个Sequence Point。 现在可以分析一下本节开头的例子了。a = (++a)+(++a)+(++a)+(++a);的结果之所 以Undefined,是因为在这个表达式中对变量a的Side Effect有五次,这些Side Effect何时发生、 按什么顺序发生是不一定的,只知道在整个表达式结束时一定都发生了,但在计算过程中要用 到a的值时,能取出什么值就不确定了。这行代码用不同平台的不同编译器来编译,结果是不同 的,甚至在同一平台上用同一编译器的不同版本来编译也可能不同。 写表达式应遵循的原则一:在两个Sequence Point之间,同一个变量的值只允许被改变一次。 仅有这一条原则还不够,例如a[i++] = i;的变量i只改变了一次,但结果仍是Undefined,因为 等号左边改i的值,等号右边读i的值,到底是先改还是先读?这个读写顺序是不确定的。但为 什么i = i + 1;就没有歧义呢?虽然也是等号左边改i的值,等号右边读i的值,但你不读出i的 值就没法计算i + 1,那拿什么去改i的值呢?所以这个读写顺序是确定的。所以,写表达式应 遵循的原则二:如果在两个Sequence Point之间既要读一个变量的值又要改它的值,只有在读 写顺序确定的情况下才可以这么写。 上一页 2. 其它运算符 上一级 起始页 下一页 4. 运算符总结 上一页 4. 运算符总结 第 16 章 运算符详解 下一页 4. 运算符总结 到此之止,除了和指针相关的运算符还没讲之外,其它运算符都讲过了,是时候做一个总结 了。 运算符+ - * / % > < >= <= == != & | ^ 以及各种复合赋值运算符要求两边的操作数类型一致,条 件运算符?:要求后两个操作数类型一致,这些运算符在计算之前都需要做Usual Arithmetic Conversion。 下面按优先级从高到低的顺序总结一下各种运算符,每一条所列的各运算符具有相同的优先 级,对于同一优先级的多个运算符按什么顺序计算也有说明,双目运算符就简单地用“左结 合”或“右结合”来说明了。和指针有关的运算符* & ->也在这里列出来了,以后再详细解释。 1、标识符、常量、字符串和用()括号套起来的表达式是组成表达式的最基本单元,在运算中做 操作数,优先级最高。 2、后缀运算符,包括数组取下标[]、函数调用()、结构体取成员.、指向结构体的指针取成员>、后缀自增++、后缀自减--。如果一个操作数后面有多个后缀,按照离操作数从近到远的顺序 (也就是从左到右)依次运算,比如a.name++,先算a.name,再++,这里的.name应该看成a的 一个后缀,而不是把.看成双目运算符。 3、单目运算符,包括前缀自增++、前缀自减--、sizeof、类型转换()、取地址运算&、指针间接 寻址*、正号+、负号-、按位取反~、逻辑非! 。如果一个操作数前面有多个前缀,按照离操作数 从近到远的顺序(也就是从右到左)依次运算,比如!~a,先算~a,再求!。 4、乘*、除/、模%运算符。这三个运算符是右结合的。 5、加+、减-运算符。右结合。 6、移位运算符<<和>>。右结合。 7、关系运算符< > <= >=。右结合。 8、相等性运算符==和!=。右结合。 9、按位与&。右结合。 10、按位异或^。右结合。 11、按位或|。右结合。 12、逻辑与&&。右结合。 13、逻辑或||。右结合。 14、条件运算符:?。在第 2 节 “if/else语句”讲过Dangling-else问题,条件运算符也有类似的问 题。例如a ? b : c ? d : e是看成(a ? b : c) ? d : e还是a ? b : (c ? d : e)?C语言规 定是后者。 15、赋值=和各种复合赋值(*= /= %= += -= <<= >>= &= ^= |=)。左结合。 16、逗号运算符。右结合。 [K&R]第2章也有这样一个列表,但是对于结合性解释得非常不清楚。左结合和右结合这两个概 念应该只对双目运算符有意义。对于前缀、后缀和三目运算符我单独做了说明。C语言表达式的 详细语法规则可以参考[C99]的Annex A.2,其实语法规则并不是用优先级和结合性这两个概念 来表述的,有一些细节用优先级和结合性是表达不了的,所以只有看C99才能了解完整的语法规 则。 习题 1、以下代码查找和打印0~1024之间所有256的倍数,对吗? int i = 0; for (; i <= 1024; ++i) { if (i & 0xff == 0) { printf("%d\n",i); } } 上一页 3. Side Effect与Sequence Point 上一级 起始页 下一页 第 17 章 计算机体系结构基础 上一页 第 17 章 计算机体系结构基础 部分 II. C语言本质 下一页 第 17 章 计算机体系结构基础 目录 1. 内存与地址 2. CPU 3. 设备 4. MMU 5. Memory Hierarchy 现代计算机都是基于Von Neumann体系结构的,不管是嵌入式系统、PC还是服务器。这种体系 结构的主要特点是:CPU(CPU,Central Processing Unit,中央处理器,或简称处理 器Processor)和内存(Memory)是计算机的两个主要组成部分,内存中保存着数据和指 令,CPU从内存中取指令(Fetch)执行,其中有些指令让CPU做运算,有些指令让CPU读写 内存中的数据。本章简要介绍组成计算机的CPU、内存、设备以及它们之间的关系,为后续章 节的学习打下基础。 上一页 4. 运算符总结 上一级 起始页 下一页 1. 内存与地址 上一页 1. 内存与地址 第 17 章 计算机体系结构基础 1. 内存与地址 我们都见过像这样挂在墙上的很多个邮箱,每个邮箱有一个房间编号。 图 17.1. 邮箱的地址 下一页 使用时根据房间编号找到相应的邮箱,然后投入信件或取出信件。内存与此类似,每个存储单 元有一个地址(Address),CPU通过地址找到相应的存储单元,取其中的指令,或者读写其中 的数据。与邮箱不同的是,一个地址所对应的存储单元不能存很多东西,只能存一个字节,所 以以前讲过的int、float等多字节的数据类型保存在内存中要占用多个地址,这种情况下把起 始地址当作这个数据的地址。 内存地址是从0开始编号的整数,最大编到多少取决于CPU的地址空间(Address Space)有多 大。目前主流的处理器是32位或64位的,本书主要以32位的x86平台为例,所谓32位就是指地 址是32位的,从0x0000 0000到0xffff ffff。 上一页 第 17 章 计算机体系结构基础 上一级 起始页 下一页 2. CPU 上一页 2. CPU 第 17 章 计算机体系结构基础 下一页 2. CPU CPU总是周而复始地做同一件事:从内存取指令,然后解释执行它,然后再取下一条指令,再 解释执行。CPU包含以下功能单元: 寄存器(Register),是CPU内部的高速存储器,像内存一样可以存取数据,但比访问内 存快得多。我们马上会讲到x86的寄存器如eax、ebp、eip等等,有些寄存器保存的数据只 能用于某种特定的用途,比如eip寄存器用作程序计数器,这称为特殊寄存器(Specialpurpose Register),而另外一些寄存器保存的数据可以用在各种运算和读写内存的指令 中,比如eax寄存器,这称为通用寄存器(General-purpose Register)。 程序计数器(PC,Program Counter),保存着CPU取指令的地址,每次CPU读出程序计 数器中保存的地址,然后按这个地址去内存中取指令,这时程序计数器保存的地址会自动 加上该指令的长度,指向内存中的下一条指令。 程序计数器通常是CPU的一个特殊寄存器,x86的程序计数器是特殊寄存器eip,由于地址 是32位的,所以这个寄存器也是32位的,事实上通用寄存器也是32位的,所以也可以说处 理器的位数是指它的寄存器的位数。处理器的位数也叫做字长,字(Word)这个概念用 得比较混乱,在有些上下文中指16位,在有些上下文中指32位(这种情况下16位被称为半 字Half Word),在有些上下文中指处理器的字长,如果处理器是32位那么一个字就 是32位,如果处理器是64位那么一个字就是64位。 指令解码器(Instruction Decoder)。CPU取上来的指令由若干个字节组成,这些字节中 有些位表示内存地址,有些位表示寄存器编号,有些位表示这种指令做什么操作,是加、 减、乘、除还是读、写,指令解码器负责解释这条指令的含义,然后调动相应的执行单元 去执行它。 算术逻辑单元(ALU,Arithmetic and Logic Unit)。如果解码器将一条指令解释为运算指 令,就调动算术逻辑单元去做运算,比如加减乘除、位运算、判断一个条件是否成立等。 运算结果可能保存在寄存器中,也可能保存到内存中。 地址和数据总线(Bus)。CPU和内存之间用地址总线、数据总线和控制线连接起 来,32位处理器有32条地址线和32条数据线[24],每条线上有1和0两种状态,32条线的状 态就可以表示一个32位的数。如果在执行指令过程中需要访问内存,比如从内存读一个数 到寄存器,则执行过程可以想像成这样: 图 17.2. 访问内存读数据的过程 1. CPU内部将寄存器对接到数据总线上,使寄存器的每一位对接到一条数据线,等待 接收数据。 2. CPU将内存地址通过地址线发给内存,然后通过另外一条控制线发一个读请求。 3. 内存收到地址和读请求之后,将相应的存储单元对接到数据总线的另一端,这样, 存储单元每一位的1或0状态通过一条数据线到达CPU寄存器中相应的位,就完成了 数据传送。 往内存里写数据的过程与此类似,只是数据线上的传输方向相反。 [24] 这个说法不够准确,你可以先这么理解,稍后在介绍MMU时再详细说明。 上一页 1. 内存与地址 上一级 起始页 下一页 3. 设备 上一页 3. 设备 第 17 章 计算机体系结构基础 下一页 3. 设备 CPU执行指令除了访问内存之外还要访问很多设备(Device),如键盘、鼠标、硬盘、显示器 等,那么它们和CPU之间如何连接呢?如下图所示。 图 17.3. 设备 有些设备像内存芯片一样连接到处理器的地址总线和数据总线,正因为地址线和数据线上可以 挂多个设备和内存芯片所以才叫“总线”,但不同的设备和内存应该占不同的地址范围。访问这种 设备就像访问内存一样,按地址读写即可,和访问内存不同的是,往一个地址写数据只是给设 备发一个命令,数据不一定要保存,从一个地址读出的数据也不一定是先前保存在这个地址的 数据,而是设备的某个状态。设备中可供读写访问的单元通常称为设备寄存器(注意和CPU的 寄存器不是一回事),操作设备的过程就是对这些设备寄存器做读写操作的过程,比如向串口 发送寄存器里写数据,串口设备就会把数据发送出去,读串口接收寄存器的值,就可以读取串 口设备接收到的数据。 还有一些设备是集成在处理器芯片中。在上图中,从CPU核引出的地址和数据总线有一端经总 线接口引出到芯片引脚上了,还有一端没有引出,而是接到芯片内部集成的设备上,这些设备 都有各自的内存地址范围,也可以像访问内存一样访问,很多体系结构(比如ARM)采用这种 方式操作设备,称为内存映射I/O(Memory-mapped I/O)。但是x86比较特殊,x86对于设备有 独立的端口地址空间,CPU核需要引出额外的地址线来连接片内设备,访问设备寄存器时用特 殊的in/out指令,而不是和访问内存用同样的指令,这种方式称为端口I/O(Port I/O)。 从CPU的角度来看,访问设备只有内存映射I/O和端口I/O两种,要么像内存一样访问,要么用一 种专用的指令访问。其实访问设备是相当复杂的,由于计算机的设备五花八门,各种设备的性 能要求都不一样,有的要求带宽大,有的要求响应快,有的要求热插拔,于是出现了各种适应 不同要求的设备总线,比如PCI、AGP、USB、1394、SATA等等,这些设备总线并不直接 和CPU相连,CPU通过内存映射I/O或端口I/O访问相应的总线控制器,通过它再去访问挂在总 线上的设备。所以上图中标有“设备”的框,可能是实际的设备,也可能是设备总线的控制器。 在x86平台上,硬盘是ATA、SATA或SCSI总线上的设备,保存在硬盘上的程序是不能被CPU直 接取指令执行的,操作系统在执行程序时会把它从硬盘拷到内存,这样CPU才可以取指令执 行,这个过程称为加载(Load)。程序加载到内存之后,成为操作系统调度执行的一个任务, 就称为进程(Process)。进程和程序不是一一对应的。一个程序可以多次加载到内存,成为同 时运行的多个进程,例如可以同时开多个终端窗口,每个窗口都运行一个Shell进程,而它们对 应的程序都是磁盘上的/bin/bash。 访问设备还有一点和访问内存不同。内存只是保存数据而不会产生新的数据,如果CPU不去读 它,它也不需要主动提供数据给CPU,所以内存总是被动地等待被读或被写。而设备往往会自 己产生数据,并且需要主动通知CPU来读这些数据,例如敲键盘产生一个输入字符,用户希望 计算机马上响应自己的输入,这就要求键盘设备主动通知CPU来读这个字符并做相应处理,给 用户响应。这是由中断(Interrupt)机制实现的,每个设备都有一条中断线,通过中断控制器连 接到CPU,当设备需要主动通知CPU时就引发一个中断信号,CPU正在执行的指令将被打断, 程序计数器会设置成某个固定的地址(这个地址由体系结构定义),于是CPU从这个地址开始 取指令(或者说跳转到这个地址),执行中断服务程序(ISR,Interrupt Service Routine),完 成中断处理之后再返回先前被打断的地方执行后续指令。比如某种体系结构规定发生中断时跳 转到地址0x0000 0010执行,那么就要事先把一段ISR程序加载到这个地址,ISR程序是由内核 代码提供的,中断处理的步骤通常是先判断哪个设备引发了中断,然后调用该设备驱动程序提 供的中断处理函数(Interrupt Handler)做进一步处理。 由于各种设备的用途各不相同,设备寄存器中每个位的定义和操作方法也各不相同,所以每种 设备都需要专门的设备驱动程序(Device Driver),一个操作系统为了支持广泛的设备就需要 有大量的设备驱动程序,事实上,Linux内核源代码中绝大部分是设备驱动程序。设备驱动程序 通常是操作系统内核里的一组函数,主要是通过对设备寄存器的读写实现对设备的初始化、 读、写等操作,有些设备还要提供一个中断处理函数供ISR调用。 上一页 2. CPU 上一级 起始页 下一页 4. MMU 上一页 4. MMU 第 17 章 计算机体系结构基础 下一页 4. MMU 现代操作系统普遍采用虚拟内存管理(Virtual Memory Management)机制,这需 要MMU(Memory Management Unit,内存管理单元)的支持。有些嵌入式处理器没有MMU, 则不能运行依赖于虚拟内存管理的操作系统。本节简要介绍MMU的作用和操作系统的虚拟内存 管理机制。 首先引入两个概念,虚拟地址和物理地址。如果处理器没有MMU,或者有MMU但没有启 用,CPU执行单元发出的内存地址将直接传到芯片引脚上,被内存芯片(以下称为物理内存, 以便与虚拟内存区分)接收,这称为物理地址(Physical Address,以下简称PA),如下图所 示。 图 17.4. 物理地址 如果处理器启用了MMU,CPU执行单元发出的内存地址将被MMU截获,从CPU到MMU的地址 称为虚拟地址(Virtual Address,以下简称VA),而MMU将这个地址翻译成另一个地址发 到CPU芯片的外部地址引脚上,也就是将虚拟地址映射成物理地址,如下图所示。 图 17.5. 虚拟地址 注意,对于32位的CPU,从CPU执行单元这边看地址线是32条(图中只是示意性地画了4条地 址线),可寻址空间是4GB,但是通常嵌入式处理器的地址引脚不会有这么多条地址线,因为 引脚是芯片上十分有限而宝贵的资源,而且也不太可能用到4GB这么大的物理内存。事实上, 在启用MMU的情况下虚拟地址空间和物理地址空间是完全独立的,物理地址空间既可以小于也 可以大于虚拟地址空间,例如有些32位的服务器可以配置大于4GB的物理内存。我们说32位 的CPU,是指CPU寄存器是32位的,数据总线是32位的,虚拟地址空间是32位的,而物理地址 空间则不一定是32位的。物理地址的范围是多少,取决于处理器引脚上有多少条地址线,也取 决于这些地址线上实际连接了多大的内存芯片。 MMU将虚拟地址映射到物理地址是以页(Page)为单位的,对于32位CPU通常一页为4KB。 例如,MMU可以通过一个映射项将虚拟地址的一页0xb7001000~0xb7001fff映射到物理地址的 一页0x2000~0x2fff,物理内存中的页称为物理页面或页帧(Page Frame)。至于虚拟内存的哪 个页面映射到物理内存的哪个页帧,这是通过页表(Page Table)来描述的,页表保存在物理 内存中,MMU会查找页表来确定一个虚拟地址应该映射到什么物理地址。总结一下这个过程: 1. 在操作系统初始化或者分配、释放内存时,会执行一些指令在物理内存中填写页表,然后 用指令设置MMU,告诉MMU页表在物理内存中的什么位置。 2. 设置好之后,CPU每次执行访问内存的指令都会自动引发MMU做查表和地址转换的操 作,地址转换操作完全由硬件完成,不需要用指令控制MMU去做。 我们在程序中使用的变量和函数都有各自的地址,程序被编译后,这些地址就成了指令中的地 址,指令中的地址被CPU解释执行,就成了CPU执行单元发出的内存地址,所以在启用MMU的 情况下,程序中使用的地址都是虚拟地址。一个操作系统中同时运行着很多进程,通常桌面上 的每个窗口都是一个进程,Shell是一个进程,在Shell下敲命令运行的程序又是一个新的进程, 此外还有很多系统服务和后台进程在默默无闻地工作着。由于有了虚拟内存管理机制,各进程 不必担心自己使用的地址范围会不会和别的进程冲突,比如两个进程都使用了虚拟地址0x0804 8000,操作系统可以设置MMU的映射项把它们映射到不同的物理地址,它们通过同样的虚拟地 址访问不同的物理页面,就不会冲突了。虚拟内存管理机制还会在后面进一步讨论。 MMU除了做地址转换之外,还提供内存保护机制。各种体系结构都有用户模式(User Mode)和特权模式(Privileged Mode)之分,操作系统可以设定每个内存页面的访问权限,有 些页面不允许访问,有些页面只有在CPU处于特权模式时才允许访问,有些页面在用户模式和 特权模式都可以访问,允许访问的权限又分为可读、可写和可执行三种。这样设定好之后, 当CPU要访问一个VA时,MMU会检查CPU当前处于用户模式还是特权模式,访问内存的目的 是读数据、写数据还是取指令,如果和操作系统设定的页面权限相符,就允许访问,把它转换 成PA,否则不允许访问,产生一个异常(Exception)。异常的处理过程和中断类似,只不过中 断是由外部设备产生的,而异常是由CPU内部产生的,中断产生的原因和CPU当前执行的指令 无关,而异常的产生就是由于CPU当前执行的指令出了问题,例如访问内存的指令被MMU检查 出权限错误,除法指令的除数为0等。 “中断”和“异常”这两个名词用得也比较混乱,不同的体系结构有不同的定义,有时候中断和异常 不加区分,有时候异常包括中断,有时候中断包括异常。在本书中按上述定义使用这两个名 词,中断的产生与指令的执行是异步(Asynchronous)的,异常的产生与指令的执行是同步 (Synchronous)的。 图 17.6. 处理器模式 通常操作系统把虚拟地址空间划分为用户空间和内核空间,例如x86平台的虚拟地址空间 是0x0000 0000~0xffff ffff,大致上前3GB(0x0000 0000~0xbfff ffff)是用户空间, 后1GB(0xc000 0000~0xffff ffff)是内核空间。用户程序在用户模式下执行,不能访问内核中 的数据,也不能跳转到内核代码中执行。这样可以保护内核,如果一个进程访问了非法地址, 顶多这一个进程崩溃,而不会影响到内核和其它进程。CPU在产生中断或异常时会自动切换模 式,由用户模式切换到特权模式,因此跳转到内核代码中执行中断或异常服务程序就被允许 了。事实上,所有内核代码的执行都是从中断或异常服务程序开始的,整个内核就是由各种中 断处理和异常处理程序组成。 我们已经遇到过很多次的段错误是这样产生的: 1. 用户程序要访问的一个VA,经MMU检查无权访问。 2. MMU产生一个异常,CPU从用户模式切换到特权模式,跳转到内核代码中执行异常服务 程序。 3. 内核把这个异常解释为段错误,把引发异常的进程终止掉。 访问权限也是在页表中设置的,可以设定哪些页面属于用户空间,哪些页面属于内核空间,哪 些页面可读,哪些页面可写,哪些页面的数据可以当作指令执行等等。MMU在做地址转换时顺 便检查访问权限。 上一页 3. 设备 上一级 起始页 下一页 5. Memory Hierarchy 上一页 5. Memory Hierarchy 第 17 章 计算机体系结构基础 下一页 5. Memory Hierarchy 硬盘、内存、CPU寄存器,还有本节要讲的Cache,这些都是存储器,计算机为什么要有这么 多种存储器呢?这些存储器各自有什么特点?这是本节要讨论的问题。 由于硬件技术的限制,我们可以制造出容量很小但很快的存储器,也可以制造出容量很大但很 慢的存储器,但不可能两边的好处都占着,不可能制造出访问速度又快容量又大的存储器。因 此,现代计算机都把存储器分成若干级,称为Memory Hierarchy,按照离CPU由近到远的顺序 依次是CPU寄存器、Cache、内存、硬盘,越靠近CPU的存储器容量越小但访问速度越快,下 图给出了各种存储器的容量和访问速度的典型值。 图 17.7. Memory Hierarchy 表 17.1. Memory Hierarchy 存储器 类型 位于哪里 存储容量 半导体工艺 访问时间 如何访问 CPU寄存 器通常只 有几个到 几十个, 每个寄存 CPU寄 存器 位于CPU执 行单元中。 器的容量 取决 于CPU的 字长,所 “寄存器”这个名字就 是一种数字电路的 名字,它由一组触 发器(Flip-flop)组 成,每个触发器保 存一个Bit的数据, 可以做存取和移位 等操作。计算机掉 寄存器是 访问速度 最快的存 储器,典 型的访问 时间是几 使用哪个寄存 器,如何使用寄 存器,这些都是 由指令决定的。 和MMU一 Cache 样位 于CPU核 中。 以一共只 有几十到 几百字 节。 电时寄存器中保存 的数据会丢失。 纳秒。 Cache通常 分为几 Cache和内存都是 级,最典 由RAM(Random 型的是如 Access 上图所示 Memory)组成的, 的两 可以根据地址随机 级Cache, 访问,计算机掉电 一 时RAM中保存的数 级Cache更 据会丢失。不同的 靠 是,Cache通常 近CPU执 由SRAM(Static 行单元, RAM,静 二 态RAM)组成,而 级Cache更 内存通常 典型的访 问时间是 几十纳 秒。 靠近物理 由DRAM(Dynamic 内存,通 RAM,动 常一 态RAM)组 级Cache有 成。DRAM电路 几十到几 比SRAM简单,存储 百KB,二 容量可以做得更 级Cache有 大,但DRAM的访 几百KB到 问速度比SRAM慢。 几MB。 Cache缓存最近 访问过的内存数 据,由 于Cache的访问 速度是内存的几 十倍,所以有效 地利用Cache可 以大大提高计算 机的整体性能。 一级Cache是这 样工作 的:CPU执行单 元要访问内存时 首先发 出VA,Cache利 用VA查找相应 的数据有没有被 缓存,如 果Cache中有就 不需要访问物理 内存了,是读操 作就直接 将Cache中的数 据传给CPU寄存 器,是写操作就 直接在Cache中 改写数据;如 果Cache中没 有,就去物理内 存中取数据,但 并不是要哪个字 节就取哪个字 节,而是把相邻 的几十个字节都 取上来缓存着, 以备下次用到, 这称为一 个Cache Line, 典型的Cache Line大小 是32~256字 节。如果计算机 还配置了二级缓 存,则在访问物 理内存之前先 用PA去二级缓 存中查找。一级 内存 硬盘 缓存是用VA寻 址的,二级缓存 是用PA寻址 的,这是它们的 区别。Cache所 做的工作是由硬 件自动完成的, 而不是像寄存器 一样由指令决定 先做什么后做什 么。 位于CPU外 的芯片, 与CPU通过 地址和数据 总线相连。 典型的存 储容量是 几百MB到 几GB。 由DRAM组成,详 见上面关于Cache的 说明。 典型的访 问时间是 几百纳 秒。 内存是通过地址 来访问的,但是 在启用MMU的 情况下,程序指 令中的地址 是VA,而访问 内存用的 是PA,并无直 接关系,这种情 况下内存的分配 和使用由操作系 统通过修 改MMU的映射 项来协调。 硬盘由磁性介质和 位于设备总 线上,并不 直接 和CPU相 连,CPU通 过设备总线 典型的存 储容量是 几百GB。 的控制器访 问硬盘。 磁头组成,访问硬 盘时存在机械运 动,磁头要移动, 磁性介质要旋转, 机械运动的速度很 难提高到电子的速 度,所以访问速度 很受限制。但是保 存在硬盘上的数据 掉电后不会丢失。 由驱动程序操作 设备总线控制器 去访问。由于硬 盘的访问速度较 慢,操作系统通 常在一次从硬盘 典型的访 上读几个页面 问时间是 (典型值 几毫秒, 是4KB)到内存 是寄存器 中缓存起来,如 的106倍。 果这些数据后来 都被程序访问到 了,那么这一次 硬盘访问的时间 就可以分摊 (Amortize)给 多次数据访问 了。 对这个表格总结如下。 寄存器、Cache和内存中的数据都是掉电丢失的,这称为易失性存储器(Volatile Memory),与之相对的,硬盘是一种非易失性存储器(Non-volatile Memory)。 除了访问寄存器由程序指令直接控制之外,访问其它存储器都不是由指令直接控制的,有 些是硬件自动完成的,有些是操作系统配合硬件完成的。 Cache从内存取数据时一次取一个Cache Line缓存起来,操作系统从硬盘取数据时一次取 几KB缓存起来,都是希望这些数据以后会被访问到。大多数程序的行为都具有局部性 (Locality)的特点:它们会花费大量的时间反复执行一小段代码(例如循环),或者反 复访问一个很小的地址范围中的数据(例如访问一个数组)。所以预读缓存的办法是很有 效的:CPU取一条指令,我把它相邻的指令也都缓存起来,CPU很可能马上就会取 到;CPU访问一个数据,我把它相邻的数据也都缓存起来,CPU很可能马上就会访问到。 设想有两台计算机,一台有32KB的Cache,另一台没有Cache,而内存都是512MB的, 硬盘都是100GB的,虽然多出来32KB的Cache和内存、硬盘的容量相比微不足道,但由 于局部性原理,有Cache的计算机明显会快很多。高速存储器即使容量只能做得很小也能 显著提升计算机的性能,这就是Memory Hierarchy的意义所在。 上一页 4. MMU 上一级 起始页 下一页 第 18 章 x86汇编程序基础 上一页 第 18 章 x86汇编程序基础 部分 II. C语言本质 下一页 第 18 章 x86汇编程序基础 目录 1. 最简单的汇编程序 2. x86的寄存器 3. 第二个汇编程序 4. 寻址方式 5. ELF文件 5.1. 目标文件 5.2. 可执行文件 要彻底搞清楚C语言的原理,必须要深入到指令一层去理解。你写一行C代码,编译器会生成什 么样的指令,要做到心中有数。本章介绍汇编程序的一些基础知识。汇编程序不是本书的重 点,不要求学会写汇编程序,只要能理解和能看懂基本的汇编代码就可以了,后面的章节会在 此基础上讨论C语言的原理。 上一页 5. Memory Hierarchy 上一级 起始页 下一页 1. 最简单的汇编程序 上一页 1. 最简单的汇编程序 第 18 章 x86汇编程序基础 1. 最简单的汇编程序 例 18.1. 最简单的汇编程序 #PURPOSE: Simple program that exits and returns a # status code back to the Linux kernel # #INPUT: none # #OUTPUT: returns a status code. This can be viewed # by typing # # echo $? # # after running the program # #VARIABLES: # %eax holds the system call number # %ebx holds the return status # .section .data .section .text .globl _start _start: movl $1, %eax # this is the linux kernel command # number (system call) for exiting # a program movl $4, %ebx # this is the status number we will # return to the operating system. # Change this around and it will # return different things to # echo $? int $0x80 # this wakes up the kernel to run # the exit command 下一页 把这个程序保存成文件hello.s(汇编程序通常以.s作为文件名后缀),然后用汇编器 (Assembler)as把汇编程序中的助记符翻译成机器指令,生成目标文件hello.o: $ as hello.s -o hello.o 然后用链接器(Linker,或Link Editor)ld把目标文件hello.o链接成可执行文件hello: $ ld hello.o -o hello 在第 2 节 “main函数和启动例程”我们会讲到把多个目标文件链接成一个可执行文件的过程,这 是链接的主要作用。我们这个例子虽然只有一个目标文件,但也需要经过链接才能成为可执行 文件,因为链接器要修改目标文件中的一些信息,这个将在第 5.2 节 “可执行文件”详细解释。 现在执行这个程序,它只做了一件事就是退出,退出状态(Exit Status)为4,在Shell中可以用 特殊变量$?得到上一条命令的退出状态: $ ./hello $ echo $? 4 程序中的#号表示单行注释,类似于C语言的//注释。下面逐行解释非注释的代码。 .section .data 汇编程序中以.开头的名称并不是指令的助记符,不会被翻译成机器指令,而是给汇编器一些特 殊的指示,称为汇编指示(Assembler Directive)或伪操作(Pseudo-operation),由于它不 是真正的指令所以加个“伪”字。.section指示把代码划分成若干个段(Section),程序被操作 系统加载执行时,每个段被加载到不同的地址,具有不同的读、写、执行权限。.data段保存程 序的数据,是可读可写的,C程序的全局变量也属于.data段。本程序中没有定义数据,所 以.data段是空的。 .section .text .text段保存代码,是只读和可执行的,后面那些指令都属于这个.text段。 .globl _start _start是一个符号(Symbol),符号在汇编程序中代表一个地址,可以用在指令中,汇编程序 经过汇编器的处理之后,所有的符号都被替换成它所代表的地址值。在C语言中我们通过变量名 访问一个变量,其实就是读写某个地址的内存单元,我们通过函数名调用一个函数,其实就是 跳转到该函数第一条指令所在的地址,所以变量名和函数名都是符号,本质上是代表内存地址 的。 .globl指示告诉汇编器,_start这个符号要被链接器用到,所以要在目标文件的符号表中给它特 殊标记(在第 5.1 节 “目标文件”会讲到)。_start就像C程序的main函数一样特殊,是整个程序 的入口,链接器在链接时会查找目标文件中的_start符号代表的地址,把它设置为整个程序的 入口地址,所以每个汇编程序都要提供一个_start符号并且用.globl声明。如果一个符号没有 用.globl指示声明,就表示这个符号不会被链接器用到。 _start: _start在这里就像C语言的语句标号一样。汇编器在处理汇编程序时会计算每个数据对象和每条 指令的地址,当汇编器看到这样一个标号时,就把它下面一条指令的地址作为_start这个符号 所代表的地址。而_start这个符号又比较特殊,它所代表的地址是整个程序的入口地址,所以 下一条指令movl $1, %eax就成了程序中第一条被执行的指令。 movl $1, %eax 这是一条数据传送指令,CPU内部产生一个数字1,然后传送到eax寄存器中。mov后面的l表 示long,说明是32位的传送指令。CPU内部产生的数称为立即数(Immediate),在汇编程序 中,立即数前面要加$,寄存器名前面要加%,以便跟符号名区分开。 movl $4, %ebx 和上一条指令类似,生成一个立即数4,传送到ebx寄存器中。 int $0x80 前两条指令都是为这条指令做准备的,执行这条指令时发生以下动作: 1. int指令称为软中断指令,可以用这条指令故意产生一个异常,上一章讲过,异常的处理 和中断类似,CPU从用户模式切换到特权模式,然后跳转到内核代码中执行异常处理程 序。 2. int指令中的立即数0x80是一个参数,在异常处理程序中要根据这个参数决定如何处理, 在Linux内核中,int $0x80这种异常称为系统调用(System Call)。内核提供了很多系统 服务供用户程序使用,但这些系统服务不能像库函数(比如printf)那样调用,因为在执 行用户程序时CPU处于用户模式,不能直接调用内核函数,所以需要通过系统调用切 换CPU模式,通过异常处理程序进入内核,用户程序只能通过寄存器传几个参数,之后就 要按内核设计好的代码路线走,而不能由用户程序随心所欲,想调哪个内核函数就调哪个 内核函数,这样保证了系统服务被安全地调用。在调用结束之后,CPU再切换回用户模 式,继续执行int指令后面的指令,在用户程序看来就像函数的调用和返回一样。 3. eax和ebx寄存器的值是传递给系统调用的两个参数,eax的值是系统调用号,1表 示_exit系统调用,ebx的值则是传给_exit系统调用的参数,也就是退出状态。_exit这个 系统调用会终止掉当前进程,而不会返回它继续执行。以后我们会讲到其它系统调用,也 是由int $0x80指令引发的,eax的值是系统调用的编号,不同的系统调用需要的参数个数 也不同,比如有的需要ebx、ecx、edx三个寄存器的值做参数,大多数系统调用完成之后 是会返回用户程序继续执行的,本例的_exit系统调用比较特殊。 x86汇编的两种语法:intel语法和AT&T语法 x86汇编一直存在两种不同的语法,在intel的官方文档中使 用intel语法,Windows也使用intel语法,而UNIX平台的汇编器一 直使用AT&T语法,所以本书使用AT&T语法。mov %edx,%eax这条 指令如果用intel语法来写,就是mov eax,edx,寄存器名不加%号, 并且源操作数和目标操作数的位置互换。本书不详细讨论这两种 语法之间的区别,读者可以参考[AssemblyHOWTO]。 介绍x86汇编的书很多,UNIX平台的书都采用AT&T语法,例 如[GroudUp],其它书一般采用intel语法,例如[x86Assembly]。 习题 1、把本节例子中的int $0x80指令去掉,汇编、链接也能通过,但是执行的时候出现段错误。 你能解释其原因吗? 上一页 第 18 章 x86汇编程序基础 上一级 起始页 下一页 2. x86的寄存器 上一页 2. x86的寄存器 第 18 章 x86汇编程序基础 下一页 2. x86的寄存器 x86的通用寄存器有eax、ebx、ecx、edx、edi、esi。这些寄存器在大多数指令中是可以任意选 用的,比如movl指令可以把一个立即数传送到eax中,也可传送到ebx中。但也有一些指令规定 只能用其中某些寄存器做某种用途,例如除法指令idivl要求被除数在eax寄存器中,edx寄存器 必须是0,而除数可以在任意寄存器中,计算结果的商数保存在eax寄存器中(覆盖原来的被除 数),余数保存在edx寄存器中。也就是说,通用寄存器对于某些指令而言不是通用的。 x86的特殊寄存器有ebp、esp、eip、eflags。eip是程序计数器,eflags保存着计算过程中产生 的标志位,包括第 3 节 “整数的加减运算”讲过的进位、溢出、零、负数四个标志位,在x86的文 档中这几个标志位分别称为CF、OF、ZF、SF。ebp和esp用于维护函数调用的栈帧,我们以后 再详细讨论。 上一页 1. 最简单的汇编程序 上一级 起始页 下一页 3. 第二个汇编程序 上一页 3. 第二个汇编程序 3. 第二个汇编程序 第 18 章 x86汇编程序基础 例 18.2. 求一组数的最大值的汇编程序 #PURPOSE: This program finds the maximum number of a # set of data items. # #VARIABLES: The registers have the following uses: # # %edi - Holds the index of the data item being examined # %ebx - Largest data item found # %eax - Current data item # # The following memory locations are used: # # data_items - contains the item data. A 0 is used # to terminate the data # .section .data data_items: #These are the data items .long 3,67,34,222,45,75,54,34,44,33,22,11,66,0 .section .text .globl _start _start: movl $0, %edi # move 0 into the index register movl data_items(,%edi,4), %eax # load the first byte of data movl %eax, %ebx # since this is the first item, %eax is # the biggest start_loop: # start loop cmpl $0, %eax # check to see if we've hit the end je loop_exit incl %edi # load next value movl data_items(,%edi,4), %eax cmpl %ebx, %eax # compare values jle start_loop # jump to loop beginning if the new # one isn't bigger movl %eax, %ebx # move the value as the largest jmp start_loop # jump to loop beginning loop_exit: # %ebx is the status code for the _exit system call # and it already has the maximum number movl $1, %eax #1 is the _exit() syscall int $0x80 下一页 汇编、链接、执行: $ as max.s -o max.o $ ld max.o -o max $ ./max $ echo $? 这个程序在一组数中找到一个最大的数,并把它作为程序的退出状态。这组数在.data段给出: data_items: .long 3,67,34,222,45,75,54,34,44,33,22,11,66,0 .long指示声明一组数,每个数占32位,相当于C语言中的数组。这个数组开头有一个标 号data_items,汇编器会把数组的首地址作为data_items符号所代表的地址,data_items类似 于C语言中的数组名。data_items这个标号没有用.globl声明,因为它只在这个汇编程序内部使 用,链接器不需要知道这个名字的存在。除了.long之外,常用的数据声明还有: .byte,也是声明一组数,每个数占8位 .ascii,例如.ascii "Hello world",声明了11个数,取值为相应字符的ASCII码。注 意,和C语言不同,这样声明的字符串末尾是没有'\0'字符的,如果需要以'\0'结尾可以声明 为.ascii "Hello world\0"。 data_items数组的最后一个数是0,我们在一个循环中依次比较每个数,碰到0的时候让循环终 止。在这个循环中: edi寄存器保存数组中的当前位置,每次比较完一个数就把edi的值加1,指向数组中的下 一个数。 ebx寄存器保存到目前为止找到的最大值,如果发现有更大的数就更新ebx的值。 eax寄存器保存当前要比较的数,每次更新edi之后,就把下一个数读到eax中。 _start: movl $0, %edi 初始化edi,指向数组的第0个元素。 movl data_items(,%edi,4), %eax 这条指令把数组的第0个元素传送到eax寄存器中。data_items是数组的首地址,edi的值是数组 的下标,4表示数组的每个元素占4字节,那么数组中第edi个元素的地址应该是data_items + edi * 4,从这个地址读数据,写成指令就是上面那样,这种地址的表示方式在下一节还会详细 解释。 movl %eax, %ebx ebx的初始值也是数组的第0个元素。下面我们进入一个循环,在循环的开头用标 号start_loop表示,循环的末尾之后用标号loop_exit表示。 start_loop: cmpl $0, %eax je loop_exit 比较eax的值是不是0,如果是0就说明到达数组末尾了,就要跳出循环。cmpl指令将两个操作数 相减,但计算结果并不保存,只是根据计算结果改变eflags寄存器中的标志位。如果两个操作 数相等,则计算结果为0,eflags中的ZF位置1。je是一个条件跳转指令,它检查eflags中 的ZF位,ZF位为1则发生跳转,ZF位为0则不跳转,继续执行下一条指令。可见比较指令和条件 跳转指令是配合使用的,前者改变标志位,后者根据标志位做判断,如果参与比较的两数相等 则跳转,je的e就表示equal。 incl %edi movl data_items(,%edi,4), %eax 将edi的值加1,把数组中的下一个数传送到eax寄存器中。 cmpl %ebx, %eax jle start_loop 把当前数组元素eax和目前为止找到的最大值ebx做比较,如果前者小于等于后者,则最大值没 有变,跳转到循环开头比较下一个数,否则继续执行下一条指令。jle也是一个条件跳转指 令,le表示less than or equal。 movl %eax, %ebx jmp start_loop 更新了最大值ebx然后跳转到循环开头比较下一个数。jmp是一个无条件跳转指令,什么条件也 不判断,直接跳转。loop_exit标号后面的指令用_exit系统调用退出程序。 上一页 2. x86的寄存器 上一级 起始页 下一页 4. 寻址方式 上一页 4. 寻址方式 第 18 章 x86汇编程序基础 下一页 4. 寻址方式 通过上一节的例子我们了解到,访问内存时在指令中可以用多种方式表示内存地址,比如可以 用数组基地址、元素长度和下标三个量来表示,增加了寻址的灵活性。本节介绍x86常用的几种 寻址方式(Addressing Mode)。内存寻址在指令中可以表示成如下的通用格式: ADDRESS_OR_OFFSET(%BASE_OR_OFFSET,%INDEX,MULTIPLIER) 它所表示的地址可以这样计算出来: FINAL ADDRESS = ADDRESS_OR_OFFSET + BASE_OR_OFFSET + MULTIPLIER * INDEX 其中ADDRESS_OR_OFFSET和MULTIPLIER必须是常数,BASE_OR_OFFSET和INDEX必须 是寄存器。在有些寻址方式中会省略这4项中的某些项,相当于这些项是0。 直接寻址(Direct Addressing Mode)。只使用ADDRESS_OR_OFFSET寻址,例如movl ADDRESS, %eax把ADDRESS地址处的32位数传送到eax寄存器。 变址寻址(Indexed Addressing Mode) 。上一节的movl data_items(,%edi,4), %eax就 属于这种寻址方式,用于访问数组元素比较方便。 间接寻址(Indirect Addressing Mode)。只使用BASE_OR_OFFSET寻址,例如movl (%eax), %ebx,把eax寄存器的值看作地址,把这个地址处的32位数传送到ebx寄存器。注 意和movl %eax, %ebx区分开。 基址寻址(Base Pointer Addressing Mode)。只使 用ADDRESS_OR_OFFSET和BASE_OR_OFFSET寻址,例如movl 4(%eax), %ebx,用 于访问结构体成员比较方便,例如一个结构体的基地址保存在eax寄存器中,其中一个成 员在结构体内的偏移量是4字节,要把这个成员读上来就可以用这条指令。 立即数寻址(Immediate Mode)。就是指令中有一个操作数是立即数,例如movl $12, %eax中的$12,这其实跟寻址没什么关系,但也算作一种寻址方式。 寄存器寻址(Register Addressing Mode)。就是指令中有一个操作数是寄存器,例 如movl $12, %eax中的%eax,这跟内存寻址没什么关系,但也算作一种寻址方式。在汇编 程序中寄存器用助记符来表示,在机器指令中则要用几个Bit表示寄存器的编号,这几 个Bit也可以看作寄存器的地址,但是和内存地址不在一个地址空间。 上一页 3. 第二个汇编程序 上一级 起始页 下一页 5. ELF文件 上一页 5. ELF文件 第 18 章 x86汇编程序基础 下一页 5. ELF文件 ELF文件格式是一个开放标准,各种UNIX系统的可执行文件都采用ELF格式,它有三种不同的 类型: 可重定位的目标文件(Relocatable) 可执行文件(Executable) 共享库(Shared Object) 现在我们分析例 18.2 “求一组数的最大值的汇编程序”经过汇编之后生成的目标文件max.o和链接 之后生成的可执行文件max的格式,从而理解汇编、链接和加载执行的过程。共享库以后再详细 介绍。 5.1. 目标文件 ELF文件格式提供了两种不同的视角,在汇编器和链接器看来,ELF文件是由Section Header Table描述的一系列Section的集合,而执行一个ELF文件时,在加载器(Loader)看来它是 由Program Header Table描述的一系列Segment的集合[25]。如下图所示。 图 18.1. ELF文件 左边是从汇编器和链接器的视角来看这个文件,开头的ELF Header描述了体系结构和操作系统 等基本信息,并指出Section Header Table和Program Header Table在文件中的什么位 置,Program Header Table在汇编和链接过程中没有用到,所以是可有可无的,Section Header Table中保存了所有Section的描述信息。右边是从加载器的视角来看这个文件,开头 是ELF Header,Program Header Table中保存了所有Segment的描述信息,Section Header Table在加载过程中没有用到,所以是可有可无的。注意Section Header Table和Program Header Table并不是一定要位于文件开头和结尾的,其位置由ELF Header指出,上图这么画只 是为了清晰。 我们在汇编程序中用.section声明的Section会成为目标文件中的Section,此外汇编器还会自动 添加一些Section(比如符号表)。Segment是指在程序运行时加载到内存的具有相同属性的区 域,由一个或多个Section组成,比如有两个Section都要求加载到内存后可读可写,就属于同一 个Segment。有些Section只对汇编器和链接器有意义,在运行时用不到,也不需要加载到内 存,那么就不属于任何Segment。 目标文件需要链接器做进一步处理,所以一定有Section Header Table;可执行文件需要加载运 行,所以一定有Program Header Table;而共享库既要加载运行,又要在加载时做动态链接, 所以既有Section Header Table又有Program Header Table。 下面用readelf工具读出目标文件max.o的ELF Header和Section Header Table,然后我们逐段 分析。 $ readelf -a max.o ELF Header: Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 Class: ELF32 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: REL (Relocatable file) Machine: Intel 80386 Version: 0x1 Entry point address: 0x0 Start of program headers: 0 (bytes into file) Start of section headers: 200 (bytes into file) Flags: 0x0 Size of this header: 52 (bytes) Size of program headers: 0 (bytes) Number of program headers: 0 Size of section headers: 40 (bytes) Number of section headers: 8 Section header string table index: 5 ...... ELF Header中描述了操作系统是UNIX,体系结构是80386。Section Header Table中 有8个Section Header,在文件中的位置(或者叫文件地址)从200(0xc8)开始,每个40字 节,共320字节,到文件地址0x207结束。这个目标文件没有Program Header。 ...... Section Headers: [Nr] Name Type Addr Off Size ES Flg Lk Inf Al [ 0] NULL 00000000 000000 000000 00 0 00 [ 1] .text PROGBITS 00000000 000034 00002a 00 AX 0 0 4 [ 2] .rel.text REL 00000000 0002b0 000010 08 6 14 [ 3] .data PROGBITS 00000000 000060 000038 00 WA 0 0 4 [ 4] .bss NOBITS 00000000 000098 000000 00 WA 0 0 4 [ 5] .shstrtab STRTAB 00000000 000098 000030 00 0 01 [ 6] .symtab SYMTAB 00000000 000208 000080 10 7 74 [ 7] .strtab STRTAB 00000000 000288 000028 00 0 01 Key to Flags: W (write), A (alloc), X (execute), M (merge), S (strings) I (info), L (link order), G (group), x (unknown) O (extra OS processing required) o (OS specific), p (processor specific) There are no section groups in this file. There are no program headers in this file. ...... 从Section Header中读出各Section的描述信息,其中.text和.data是我们在汇编程序中声明 的Section,而其它Section是汇编器自动添加的。Addr是这些段加载到内存中的地址(我们讲过 程序中的地址都是虚拟地址),加载地址要在链接时填写,现在空缺,所以是 全0。Off和Size两列指出了各Section的文件地址,比如.data从文件地址0x60开始,一 共0x38个字节,回去翻一下程序,.data中定义了14个4字节的整数,一共是56个字节,也就 是0x38个。根据以上信息可以描绘出整个目标文件的布局。 表 18.1. 目标文件的布局 起始文件地址 Section或Header 0 ELF Header 0x34 .text 0x60 .data 0x98 .bss(此段为空) 0x98 .shstrtab 0xc8 Section Header Table 0x208 .symtab 0x288 .strtab 0x2b0 .rel.text 这个文件不大,我们直接用hexdump工具把目标文件的字节全部打印出来看。 $ hexdump -C max.o 00000000 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 |.ELF............| 00000010 01 00 03 00 01 00 00 00 00 00 00 00 00 00 00 00 |................| 00000020 c8 00 00 00 00 00 00 00 34 00 00 00 00 00 28 00 |........4.....(.| 00000030 08 00 05 00 bf 00 00 00 00 8b 04 bd 00 00 00 00 |................| 00000040 89 c3 83 f8 00 74 10 47 8b 04 bd 00 00 00 00 39 |.....t.G.......9| 00000050 d8 7e ef 89 c3 eb eb b8 01 00 00 00 cd 80 00 00 |.~..............| 00000060 03 00 00 00 43 00 00 00 22 00 00 00 de 00 00 00 |....C...".......| 00000070 2d 00 00 00 4b 00 00 00 36 00 00 00 22 00 00 00 |...K...6..."...| 00000080 2c 00 00 00 21 00 00 00 16 00 00 00 0b 00 00 00 |,...!...........| 00000090 42 00 00 00 00 00 00 00 00 2e 73 79 6d 74 61 62 |B.........symtab| 000000a0 00 2e 73 74 72 74 61 62 00 2e 73 68 73 74 72 74 |..strtab..shstrt| 000000b0 61 62 00 2e 72 65 6c 2e 74 65 78 74 00 2e 64 61 |ab..rel.text..da| 000000c0 74 61 00 2e 62 73 73 00 00 00 00 00 00 00 00 00 |ta..bss.........| 000000d0 00 00 00 00 00 00 00 00 |................| * 000000f0 1f 00 00 00 01 00 00 00 |................| 00000100 34 00 00 00 2a 00 00 00 |4...*...........| 00000110 04 00 00 00 00 00 00 00 |................| 00000120 00 00 00 00 00 00 00 00 |................| 00000130 06 00 00 00 01 00 00 00 |................| 00000140 25 00 00 00 01 00 00 00 |%...............| 00000150 60 00 00 00 38 00 00 00 |`...8...........| 00000160 04 00 00 00 00 00 00 00 |........+.......| 00000170 03 00 00 00 00 00 00 00 |................| 00000180 00 00 00 00 00 00 00 00 |................| 00000190 11 00 00 00 03 00 00 00 |................| 000001a0 98 00 00 00 30 00 00 00 |....0...........| 000001b0 01 00 00 00 00 00 00 00 |................| 000001c0 00 00 00 00 00 00 00 00 |................| 000001d0 07 00 00 00 07 00 00 00 |................| 000001e0 09 00 00 00 03 00 00 00 |................| 000001f0 88 02 00 00 28 00 00 00 |....(...........| 00000200 01 00 00 00 00 00 00 00 |................| 00000210 00 00 00 00 00 00 00 00 |................| 00000220 00 00 00 00 03 00 01 00 |................| 00000230 00 00 00 00 03 00 03 00 |................| 00000240 00 00 00 00 03 00 04 00 |................| 00000250 00 00 00 00 00 00 03 00 |................| 00000260 00 00 00 00 00 00 01 00 |............#...| 00000270 00 00 00 00 00 00 01 00 |........!.......| 00000280 00 00 00 00 10 00 01 00 |.........data_it| 00000290 65 6d 73 00 73 74 61 72 |ems.start_loop.l| 000002a0 6f 6f 70 5f 65 78 69 74 |oop_exit._start.| 000002b0 08 00 00 00 01 02 00 00 |................| 000002c0 00 00 00 00 00 00 00 00 06 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1b 00 00 00 09 00 00 00 b0 02 00 00 10 00 00 00 04 00 00 00 08 00 00 00 03 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 2b 00 00 00 08 00 00 00 98 00 00 00 00 00 00 00 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01 00 00 00 02 00 00 00 08 02 00 00 80 00 00 00 04 00 00 00 10 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00 0c 00 00 00 0e 00 00 00 17 00 00 00 23 00 00 00 21 00 00 00 00 00 00 00 00 64 61 74 61 5f 69 74 74 5f 6c 6f 6f 70 00 6c 00 5f 73 74 61 72 74 00 17 00 00 00 01 02 00 00 左边一列是文件中的地址,中间是每个字节的16进制表示,右边是把这些字节解释成ASCII码所 对应的字符。中间有一个*号表示省略的部分全是0。.data段对应的是这一块: ...... 00000060 03 00 00 00 43 00 00 00 22 00 00 00 de 00 00 00 |....C...".......| 00000070 2d 00 00 00 4b 00 00 00 36 00 00 00 22 00 00 00 |...K...6..."...| 00000080 2c 00 00 00 21 00 00 00 16 00 00 00 0b 00 00 00 |,...!...........| 00000090 42 00 00 00 00 00 00 00 ...... 这一段将来要原封不动地加载到内存中,比如加载到内存地址的0x0804 90a0~0x0804 90d7。 第一个数3由四个字节组成: 表 18.2. 一个4字节整数的字节序 地址 字节 0x0804 90a0 0x03 0x0804 90a1 0x00 0x0804 90a2 0x00 0x0804 90a3 0x00 这说明什么呢?说明这四个字节不能按地址从低到高的顺序看成0x03000000,而要按地址从高 到低的顺序看成0x00000003。也就是说,低地址保存的是整数的低位,这种字节序(Byte Order)称为小端(Little Endian)。翻回上面看看,我们的ELF Header里面也提到了little endian,这说明另外一些平台不是这样规定的,而是低地址保存整数的高位,称为大端(Big Endian)。我们在后面章节中还会碰到字节序的问题。 .shstrtab和.strtab这两个Section中存放的都是ASCII码: ...... 00 2e 73 79 6d 74 61 62 |B.........symtab| 000000a0 00 2e 73 74 72 74 61 62 00 2e 73 68 73 74 72 74 |..strtab..shstrt| 000000b0 61 62 00 2e 72 65 6c 2e 74 65 78 74 00 2e 64 61 |ab..rel.text..da| 000000c0 74 61 00 2e 62 73 73 00 |ta..bss.........| ...... 00 64 61 74 61 5f 69 74 |.........data_it| 00000290 65 6d 73 00 73 74 61 72 74 5f 6c 6f 6f 70 00 6c |ems.start_loop.l| 000002a0 6f 6f 70 5f 65 78 69 74 00 5f 73 74 61 72 74 00 |oop_exit._start.| ...... 可见.shstrtab中保存着各Section的名字,.strtab中保存着程序中用到的符号的名字。每个名 字都是以'\0'结尾的字符串。 我们知道,C语言的全局变量如果在代码中没有初始化,就会在程序加载时用0初始化。这种数 据属于.bss段,在加载时它和.data段一样都是可读可写的数据,但是在ELF文件中.data段需要 占用一部分空间保存初始值,而.bss段则不需要。也就是说,.bss段在文件中只占一个Section Header而没有对应的Section,程序加载时.bss段占多大内存空间在Section Header中描述。在 我们这个例子中没有用到.bss段,以后我们会看到这样的例子。 我们继续分析readelf输出的最后一部分,是从.rel.text和.symtab这两个Section中读出的信 息。 ...... Relocation section '.rel.text' at offset 0x2b0 contains 2 entries: Offset Info Type Sym.Value Sym. Name 00000008 00000201 R_386_32 00000000 .data 00000017 00000201 R_386_32 00000000 .data There are no unwind sections in this file. Symbol table '.symtab' contains 8 entries: Num: Value Size Type Bind Vis 0: 00000000 0 NOTYPE LOCAL DEFAULT 1: 00000000 0 SECTION LOCAL DEFAULT 2: 00000000 0 SECTION LOCAL DEFAULT 3: 00000000 0 SECTION LOCAL DEFAULT 4: 00000000 0 NOTYPE LOCAL DEFAULT 5: 0000000e 0 NOTYPE LOCAL DEFAULT 6: 00000023 0 NOTYPE LOCAL DEFAULT 7: 00000000 0 NOTYPE GLOBAL DEFAULT Ndx Name UND 1 3 4 3 data_items 1 start_loop 1 loop_exit 1 _start No version information found in this file. .rel.text告诉链接器指令中的哪些地方需要重定位,我们在下一节讨论。 .symtab是符号表。Ndx列是每个符号所在的Section编号,例如data_items在第3个Section里 (也就是.data),各Section的编号见Section Header Table。Value列是每个符号所代表的地 址,在目标文件中,符号地址都是相对于该符号所在Section的相对地址,比如data_items位 于.data段的开头,所以地址是0,_start位于.text段的开头,所以地址也是0,但 是start_loop 和loop_exit 相对于.text 段的地址就不是0了。从Bind 这一列可以看出_start这个 符号是GLOBAL的,而其它符号是LOCAL的,GLOBAL符号是在汇编程序中用.globl指示声明过的符 号。 现在剩下.text段没有分析,objdump工具可以把程序中的机器指令反汇编(Disassemble),那 么反汇编的结果是否跟原来写的汇编代码一模一样呢?我们对比分析一下。 $ objdump -d max.o max.o: file format elf32-i386 Disassembly of section .text: 00000000 <_start>: 0: bf 00 00 00 00 5: 8b 04 bd 00 00 00 00 c: 89 c3 mov mov mov 0000000e : e: 83 f8 00 11: 74 10 13: 47 14: 8b 04 bd 00 00 00 00 1b: 39 d8 1d: 7e ef 1f: 89 c3 21: eb eb cmp je inc mov cmp jle mov jmp 00000023 : 23: b8 01 00 00 00 28: cd 80 mov int $0x0,%edi 0x0(,%edi,4),%eax %eax,%ebx $0x0,%eax 23 %edi 0x0(,%edi,4),%eax %ebx,%eax e %eax,%ebx e $0x1,%eax $0x80 左边是机器指令的字节,右边是反汇编结果。显然,所有的符号都被替换成地址了,比如je 23,注意没有加$的数表示内存地址,而不表示立即数。这条指令后面的并不是指令 的一部分,而是反汇编器从.symtab和.strtab查到的符号名称,写在后面是为了有更好的可读 性。目前所有的跳转指令和内存访问指令(mov 0x0(,%edi,4),%eax)中的地址都是符号的相对 地址,下一步链接器要修改这些指令,把其中的地址都改成加载时的内存地址,这些指令才能 正确执行。 5.2. 可执行文件 现在我们按上一节的步骤分析可执行文件max,看看链接器都做了什么改动。 $ readelf -a max ELF Header: Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 Class: ELF32 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: EXEC (Executable file) Machine: Intel 80386 Version: 0x1 Entry point address: 0x8048074 Start of program headers: 52 (bytes into file) Start of section headers: 256 (bytes into file) Flags: 0x0 Size of this header: 52 (bytes) Size of program headers: 32 (bytes) Number of program headers: 2 Size of section headers: 40 (bytes) Number of section headers: 6 Section header string table index: 3 Section Headers: [Nr] Name Type Addr Off Size ES Flg Lk Inf Al [ 0] NULL 00000000 000000 000000 00 0 00 [ 1] .text PROGBITS 08048074 000074 00002a 00 AX 0 0 4 [ 2] .data PROGBITS 080490a0 0000a0 000038 00 WA 0 0 4 [ 3] .shstrtab STRTAB 00000000 0000d8 000027 00 0 01 [ 4] .symtab SYMTAB 00000000 0001f0 0000a0 10 5 64 [ 5] .strtab STRTAB 00000000 000290 000040 00 0 01 Key to Flags: W (write), A (alloc), X (execute), M (merge), S (strings) I (info), L (link order), G (group), x (unknown) O (extra OS processing required) o (OS specific), p (processor specific) There are no section groups in this file. Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align LOAD 0x000000 0x08048000 0x08048000 0x0009e 0x0009e R E 0x1000 LOAD 0x0000a0 0x080490a0 0x080490a0 0x00038 0x00038 RW 0x1000 Section to Segment mapping: Segment Sections... 00 .text 01 .data There is no dynamic section in this file. There are no relocations in this file. There are no unwind sections in this file. Symbol table '.symtab' contains 10 entries: Num: Value 0: 00000000 1: 08048074 2: 080490a0 3: 080490a0 4: 08048082 5: 08048097 6: 08048074 7: 080490d8 8: 080490d8 9: 080490d8 Size Type Bind Vis 0 NOTYPE LOCAL DEFAULT 0 SECTION LOCAL DEFAULT 0 SECTION LOCAL DEFAULT 0 NOTYPE LOCAL DEFAULT 0 NOTYPE LOCAL DEFAULT 0 NOTYPE LOCAL DEFAULT 0 NOTYPE GLOBAL DEFAULT 0 NOTYPE GLOBAL DEFAULT 0 NOTYPE GLOBAL DEFAULT 0 NOTYPE GLOBAL DEFAULT Ndx Name UND 1 2 2 data_items 1 start_loop 1 loop_exit 1 _start ABS __bss_start ABS _edata ABS _end No version information found in this file. 在ELF Header中,Type改成了EXEC,由目标文件变成可执行文件了,Entry point address改 成了0x8048074(这是_start符号的地址),还可以看出,多了两个Program Header,少了两 个Section Header。 在Section Header Table中,.text和.data的加载地址分别改成了0x0804 8074和0x0804 90a0。.bss段没有用到,所以被删掉了。.rel.text段就是用于链接过程的,链接完了就没用 了,所以也删掉了。 多出来的Program Header Table描述了两个Segment的信息。.text段和前面的ELF Header、Program Header Table一起组成一个Segment(FileSiz指出总长度 是0x9e),.data段组成另一个Segment(总长度是0x38)。VirtAddr列指出第一 个Segment加载到虚拟地址0x0804 8000(注意在x86平台上后面的PhysAddr列是没有意义 的),第二个Segment加载到地址0x0804 90a0。Flg列指出第一个Segment的访问权限是可读 可执行,第二个Segment的访问权限是可读可写。最后一列Align的值0x1000(4K)是x86平台 的内存页面大小。在加载时文件也要按内存页面大小分成若干页,文件中的一页对应内存中的 一页,对应关系如下图所示。 图 18.2. 文件和加载地址的对应关系 这个可执行文件很小,总共也不超过一页大小,但是两个Segment必须加载到内存中两个不同 的页面,因为MMU的权限保护机制是以页为单位的,一个页面只能设置一种权限。此外还规定 每个Segment在文件页面内偏移多少加载到内存页面仍然偏移多少,比如第二个Segment在文 件中的偏移是0xa0,在内存页面0x0804 9000中的偏移仍然是0xa0,所以是从0x0804 90a0开 始,这样规定是为了简化链接器和加载器的实现。从上图也可以看出.text段的加载地址应该 是0x0804 8074,也正是_start符号的地址和程序的入口地址。 原来目标文件符号表中的Value都是相对地址,现在都改成绝对地址了。此外还多了三个符 号__bss_start、_edata和_end,这些是在链接过程中添进去的,加载器可以利用这些信息 把.bss段初始化为0。 再看一下反汇编的结果: $ objdump -d max max: file format elf32-i386 Disassembly of section .text: 08048074 <_start>: 8048074: bf 00 00 00 00 8048079: 8b 04 bd a0 90 04 08 0x80490a0(,%edi,4),%eax 8048080: 89 c3 08048082 : 8048082: 83 f8 00 8048085: 74 10 8048087: 47 8048088: 8b 04 bd a0 90 04 08 0x80490a0(,%edi,4),%eax 804808f: 39 d8 8048091: 7e ef 8048093: 89 c3 8048095: eb eb 08048097 : 8048097: b8 01 00 00 00 804809c: cd 80 mov mov mov cmp je inc mov cmp jle mov jmp mov int $0x0,%edi %eax,%ebx $0x0,%eax 8048097 %edi %ebx,%eax 8048082 %eax,%ebx 8048082 $0x1,%eax $0x80 指令中的相对地址都改成绝对地址了。我们仔细检查一下都改了哪些地方。首先看跳转指令, 原来目标文件的指令是这样: ...... 11: ...... 1d: ...... 21: ...... 74 10 7e ef eb eb je 23 jle e jmp e 现在改成了这样: ...... 8048085: ...... 8048091: ...... 8048095: ...... 74 10 7e ef eb eb je 8048097 jle 8048082 jmp 8048082 改了吗?其实只是反汇编的结果不同了,指令根本没改。为什么不用改指令就能跳转到新的地 址呢?因为跳转指令中指定的是相对于当前指令向前或向后跳多少字节,而不是指定一个完整 的内存地址,内存地址有32位,这些跳转指令只有16位,显然也不可能指定一个完整的内存地 址,这称为相对跳转。 再看内存访问指令,原来目标文件的指令是这样: ...... 5: 8b 04 bd 00 00 00 00 mov 0x0(,%edi,4),%eax ...... 14: 8b 04 bd 00 00 00 00 mov 0x0(,%edi,4),%eax ...... 现在改成了这样: ...... 8048079: 8b 04 bd a0 90 04 08 mov 0x80490a0(,%edi,4),%eax ...... 8048088: 8b 04 bd a0 90 04 08 mov 0x80490a0(,%edi,4),%eax ...... 指令中的地址原本是0x0000 0000,现在改成了0x0804 09a0(注意是小端字节序)。那么链接 器怎么知道要改这两处呢?是根据原来目标文件中的.rel.text段提供的重定位信息来改的: ...... Relocation section '.rel.text' at offset 0x2b0 contains 2 entries: Offset Info Type Sym.Value Sym. Name 00000008 00000201 R_386_32 00000000 .data 00000017 00000201 R_386_32 00000000 .data ...... 第一列Offset的值就是.text段需要改的地方,在.text段中的相对地址是8和0x17,正是这两条 指令中00 00 00 00的位置。 [25] Segment也可以翻译成“段”,为了避免混淆,在本书中只把Section称为段,而Segment直 接用英文。 上一页 4. 寻址方式 上一级 起始页 下一页 第 19 章 汇编与C之间的关系 上一页 第 19 章 汇编与C之间的关系 部分 II. C语言本质 下一页 第 19 章 汇编与C之间的关系 目录 1. 函数调用 2. main函数和启动例程 3. 变量的存储布局 4. 结构体和联合体 5. C内联汇编 6. volatile限定符 上一章我们学习了汇编的一些基础知识,本章我们进一步研究C程序编译之后的汇编是什么样 的,C语言的各种语法分别对应什么样的指令,从而更深入地理解C语言。gcc还提供了一种语 法可以在C语言中内嵌汇编指令,这在内核代码中很常见,本章也会简要介绍这种用法。 上一页 5. ELF文件 上一级 起始页 下一页 1. 函数调用 上一页 1. 函数调用 第 19 章 汇编与C之间的关系 1. 函数调用 我们用下面的代码来研究函数调用的过程。 例 19.1. 研究函数的调用过程 int bar(int c, int d) { int e = c + d; return e; } int foo(int a, int b) { return bar(a, b); } int main(void) { foo(2, 3); return 0; } 下一页 如果在编译时加上-g选项(在第 10 章 gdb讲过-g选项),那么用objdump反汇编时可以把C代码 和汇编代码穿插起来显示,这样C代码和汇编代码的对应关系看得更清楚。反汇编的结果很长, 以下只列出我们关心的部分。 $ gcc main.c -g $ objdump -dS a.out ...... 08048394 : int bar(int c, int d) { 8048394: 55 8048395: 89 e5 8048397: 83 ec 10 int e = c + d; 804839a: 8b 55 0c 804839d: 8b 45 08 80483a0: 01 d0 80483a2: 89 45 fc return e; 80483a5: 8b 45 fc } 80483a8: c9 80483a9: c3 080483aa : int foo(int a, int b) { 80483aa: 55 80483ab: 89 e5 80483ad: 83 ec 08 return bar(a, b); 80483b0: 8b 45 0c push mov sub %ebp %esp,%ebp $0x10,%esp mov 0xc(%ebp),%edx mov 0x8(%ebp),%eax add %edx,%eax mov %eax,-0x4(%ebp) mov -0x4(%ebp),%eax leave ret push mov sub mov %ebp %esp,%ebp $0x8,%esp 0xc(%ebp),%eax 80483b3: 80483b7: 80483ba: 80483bd: } 80483c2: 80483c3: 89 44 24 04 8b 45 08 89 04 24 e8 d2 ff ff ff c9 c3 080483c4
    : int main(void) { 80483c4: 8d 4c 24 04 80483c8: 83 e4 f0 80483cb: ff 71 fc 80483ce: 55 80483cf: 89 e5 80483d1: 51 80483d2: 83 ec 08 foo(2, 3); 80483d5: c7 44 24 04 03 00 00 80483dc: 00 80483dd: c7 04 24 02 00 00 00 80483e4: e8 c1 ff ff ff return 0; 80483e9: b8 00 00 00 00 } 80483ee: 83 c4 08 80483f1: 59 80483f2: 5d 80483f3: 8d 61 fc 80483f6: c3 ...... mov mov mov call %eax,0x4(%esp) 0x8(%ebp),%eax %eax,(%esp) 8048394 leave ret lea and pushl push mov push sub 0x4(%esp),%ecx $0xfffffff0,%esp -0x4(%ecx) %ebp %esp,%ebp %ecx $0x8,%esp movl $0x3,0x4(%esp) movl $0x2,(%esp) call 80483aa mov $0x0,%eax add $0x8,%esp pop %ecx pop %ebp lea -0x4(%ecx),%esp ret 要查看编译后的汇编代码,其实还有一种办法是gcc -S main.c,这样只生成汇编代码main.s, 而不生成二进制的目标文件。 整个程序的执行过程是main调用foo,foo调用bar,我们用gdb跟踪程序的执行,直到bar函数中 的int e = c + d;语句执行完毕准备返回时,这时在gdb中打印函数栈帧。 (gdb) start ...... main () at main.c:14 14 foo(2, 3); (gdb) s foo (a=2, b=3) at main.c:9 9 return bar(a, b); (gdb) s bar (c=2, d=3) at main.c:3 3 int e = c + d; (gdb) disassemble Dump of assembler code for function bar: 0x08048394 : push %ebp 0x08048395 : mov %esp,%ebp 0x08048397 : sub $0x10,%esp 0x0804839a : mov 0xc(%ebp),%edx 0x0804839d : mov 0x8(%ebp),%eax 0x080483a0 : add %edx,%eax 0x080483a2 : mov %eax,-0x4(%ebp) 0x080483a5 : mov -0x4(%ebp),%eax 0x080483a8 : leave 0x080483a9 : ret End of assembler dump. (gdb) si 0x0804839d 3 int e = c + d; (gdb) si 0x080483a0 3 int e = c + d; (gdb) si 0x080483a2 3 int e = c + d; (gdb) si 4 return e; (gdb) si 5 } (gdb) bt #0 bar (c=2, d=3) at main.c:5 #1 0x080483c2 in foo (a=2, b=3) at main.c:9 #2 0x080483e9 in main () at main.c:14 (gdb) info registers eax 0x5 5 ecx 0xbff1c440 -1074674624 edx 0x3 3 ebx 0xb7fe6ff4 -1208061964 esp 0xbff1c3f4 0xbff1c3f4 ebp 0xbff1c404 0xbff1c404 esi 0x8048410 134513680 edi 0x80482e0 134513376 eip 0x80483a8 0x80483a8 eflags 0x200206 [ PF IF ID ] cs 0x73 115 ss 0x7b 123 ds 0x7b 123 es 0x7b 123 fs 0x0 0 gs 0x33 51 (gdb) x/20 $esp 0xbff1c3f4: 0x00000000 0xbff1c6f7 0xb7efbdae 0x00000005 0xbff1c404: 0xbff1c414 0x080483c2 0x00000002 0x00000003 0xbff1c414: 0xbff1c428 0x080483e9 0x00000002 0x00000003 0xbff1c424: 0xbff1c440 0xbff1c498 0xb7ea3685 0x08048410 0xbff1c434: 0x080482e0 0xbff1c498 0xb7ea3685 0x00000001 (gdb) 这里又用到几个新的gdb命令。disassemble可以反汇编当前函数或者指定的函数,单独 用disassemble命令是反汇编当前函数,如果disassemble命令后面跟函数名或地址则反汇编指定 的函数。以前我们讲过step命令可以一行代码一行代码地单步调试,而这里用到的si命令可以 一条指令一条指令地单步调试。info registers可以显示所有寄存器的当前值。在gdb中表示寄 存器名时前面要加个$,例如p $esp可以打印esp寄存器的值,在上例中esp寄存器的值 是0xbff1c3f4,所以x/20 $esp命令查看内存中从0xbff1c3f4地址开始的20个32位数。在执行程 序时,操作系统为进程分配一块栈空间来存储函数栈帧,esp寄存器总是指向栈顶,在x86平台 上这个栈是从高地址向低地址增长的,我们知道每次调用一个函数都要分配一个栈帧来存储参 数和局部变量,现在我们详细分析这些数据是怎么存储的,根据gdb的输出结果图示如下[26]: 图 19.1. 函数栈帧 图中每个小方格占4个字节,例如b: 3这个方格占的内存地址是0xbf822d20~0xbf822d23。我们 从main函数的这里开始看起: foo(2, 3); 80483d5: c7 44 24 04 03 00 00 80483dc: 00 80483dd: c7 04 24 02 00 00 00 80483e4: e8 c1 ff ff ff return 0; 80483e9: b8 00 00 00 00 movl movl call mov $0x3,0x4(%esp) $0x2,(%esp) 80483aa $0x0,%eax 要调用函数foo先要把参数准备好,第二个参数保存在esp+4所指向的内存位置,第一个参数保 存在esp所指向的内存位置,可见参数是从右向左依次压栈的。然后执行call指令,这个指令有 两个作用: 1. foo函数调用完之后要返回call的下一条指令继续执行,所以把call的下一条指令的地 址0x80483e9压栈,同时把esp的值减4,esp的值现在是0xbf822d18。 2. 修改程序计数器eip,跳转到foo函数的开头执行。 现在看foo函数的汇编代码: int foo(int a, int b) { 80483aa: 55 80483ab: 89 e5 80483ad: 83 ec 08 push mov sub %ebp %esp,%ebp $0x8,%esp 首先将ebp寄存器的值压栈,同时把esp的值再减4,esp的值现在是0xbf822d14,然后把这个值 传送给ebp寄存器。换句话说就是把原来ebp的值保存在栈上,然后又给ebp赋了新值。在每个函 数的栈帧中,ebp指向栈底,而esp指向栈顶,在函数执行过程中esp随着压栈和出栈操作随时变 化,而ebp是不动的,函数的参数和局部变量都是通过ebp的值加上一个偏移量来访问的,例 如foo函数的参数a和b分别通过ebp+8和ebp+12来访问,所以下面的指令把参数a和b再次压栈, 为调用bar函数做准备,然后把返回地址压栈,调用bar函数: return bar(a, b); 80483b0: 8b 45 0c 80483b3: 89 44 24 04 mov 0xc(%ebp),%eax mov %eax,0x4(%esp) 80483b7: 80483ba: 80483bd: 8b 45 08 89 04 24 e8 d2 ff ff ff mov 0x8(%ebp),%eax mov %eax,(%esp) call 8048394 现在看bar函数的指令: int bar(int c, int d) { 8048394: 55 8048395: 89 e5 8048397: 83 ec 10 int e = c + d; 804839a: 8b 55 0c 804839d: 8b 45 08 80483a0: 01 d0 80483a2: 89 45 fc push mov sub mov mov add mov %ebp %esp,%ebp $0x10,%esp 0xc(%ebp),%edx 0x8(%ebp),%eax %edx,%eax %eax, - 0x4(%ebp) 这次又把foo函数的ebp压栈保存,然后给ebp赋了新值,指向bar函数栈帧的栈底,通 过ebp+8和ebp+12分别可以访问参数c和d。bar函数还有一个局部变量e,可以通过ebp-4来访 问。所以后面几条指令的意思是把参数c和d取出来存在寄存器中做加法,add指令的计算结果保 存在eax寄存器中,再把eax寄存器存回局部变量e的内存单元。 现在可以解释为什么在gdb中可以用bt命令和frame命令查看每个栈帧上的参数和局部变量了: 如果我当前在bar函数中,我可以通过ebp找到bar函数的参数和局部变量,也可以找到foo函数 的ebp保存在栈上的值,有了foo函数的ebp,又可以找到它的参数和局部变量,也可以找 到main函数的ebp保存在栈上的值,因此各函数的栈帧通过保存在栈上的ebp的值串起来了。 现在看bar函数的返回指令: return e; 80483a5: 8b 45 fc } 80483a8: c9 80483a9: c3 mov -0x4(%ebp),%eax leave ret bar函数有一个int型的返回值,这个返回值是通过eax寄存器传递的,所以首先把e的值读 到eax寄存器中。然后执行leave指令,这个指令是函数开头的push %ebp和mov %esp,%ebp的逆 操作: 1. 把ebp的值赋给esp,现在esp的值是0xbf822d04。 2. 现在esp所指向的栈顶保存着foo函数栈帧的ebp,把这个值恢复给ebp,同时esp增加4,现 在esp的值是0xbf822d08。 最后是ret指令,它是call指令的逆操作: 1. 现在esp所指向的栈顶保存着返回地址,把这个值恢复给eip,同时esp增加4,现在esp的 值是0xbf822d0c。 2. 修改了程序计数器eip,因此跳转到返回地址0x80483c2继续执行。 地址0x80483c2处是foo函数的返回指令: 80483c2: c9 80483c3: c3 leave ret 重复同样的过程,就又返回到了main函数。注意函数调用和返回过程中的这些规则: 1. 参数压栈传递,并且是从右向左依次压栈。 2. ebp总是指向栈帧的栈底。 3. 返回值通过eax寄存器传递。 这些规则并不是体系结构所强加的,ebp寄存器并不是必须这么用,函数的参数和返回值也不是 必须这么传,只是操作系统和编译器选择了以这样的方式实现C代码中的函数调用,这称 为Calling Convention,除了Calling Convention之外,操作系统还需要规定许多C代码和二进制 指令之间的接口规范,统称为ABI(Application Binary Interface)。 习题 1、在第 2 节 “自定义函数”讲过,Old Style C风格的函数声明可以不指定参数个数和类型,这样 编译器不会对函数调用做检查,那么如果调用时的参数类型不对或者参数个数不对会怎么样 呢?比如把本节的例子改成这样: int foo(); int bar(); int main(void) { foo(2, 3, 4); return 0; } int foo(int a, int b) { return bar(a); } int bar(int c, int d) { int e = c + d; return e; } main函数调用foo时多了一个参数,那么参数a和b分别取什么值?多的参数怎么办?foo调 用bar时少了一个参数,那么参数d的值从哪里取得?请读者利用反汇编和gdb自己分析一下。 [26] Linux为每一个新进程指定的栈空间的起始地址都会有些不同,所以每次运行这个程序得到 的地址都不一样,但通常都是0xbf??????这样一个地址。 上一页 第 19 章 汇编与C之间的关系 上一级 起始页 下一页 2. main函数和启动例程 上一页 2. main函数和启动例程 第 19 章 汇编与C之间的关系 下一页 2. main函数和启动例程 为什么汇编程序的入口是_start,而C程序的入口是main函数呢?本节就来解释这个问题。在 讲例 18.1 “最简单的汇编程序”时,我们的汇编和链接步骤是: $ as hello.s -o hello.o $ ld hello.o -o hello 以前我们常用gcc main.c -o main命令编译一个程序,其实也可以分三步做,第一步生成汇编 代码,第二步生成目标文件,第三步生成可执行文件: $ gcc -S main.c $ gcc -c main.s $ gcc main.o -S选项生成汇编代码,-c选项生成目标文件,此外在第 2 节 “数组应用实例:统计随机数”还讲 过-E选项只做预处理而不编译,如果不加这些选项则gcc执行完整的编译步骤,直到最后链接生 成可执行文件为止。如下图所示。 图 19.2. gcc命令的选项 这些选项都可以和-o搭配使用,给输出的文件重新命名而不使用gcc默认的文件名 (xxx.c、xxx.s、xxx.o和a.out),例如gcc main.o -o main将main.o链接成可执行文件main。 先前由汇编代码例 18.1 “最简单的汇编程序”生成的目标文件hello.o我们是用ld来链接的,可不 可以用gcc链接呢?试试看。 $ gcc hello.o -o hello hello.o: In function `_start': (.text+0x0): multiple definition of `_start' /usr/lib/gcc/i486- linux gnu/4.3.2/../../../../lib/crt1.o:(.text+0x0): first defined here /usr/lib/gcc/i486-linux-gnu/4.3.2/../../../../lib/crt1.o: In function `_start': (.text+0x18): undefined reference to `main' collect2: ld returned 1 exit status 提示两个错误:一是_start有多个定义,一个定义是由我们的汇编代码提供的,另一个定义来 自/usr/lib/crt1.o;二是crt1.o的_start函数要调用main函数,而我们的汇编代码中没有提 供main函数的定义。从最后一行还可以看出这些错误提示是由ld给出的。由此可见,如果我们 用gcc做链接,gcc其实是调用ld将目标文件crt1.o和我们的hello.o链接在一起。crt1.o里面已 经提供了_start入口点,我们的汇编程序中再实现一个_start就是多重定义了,链接器不知道该 用哪个,只好报错。另外,crt1.o提供的_start需要调用main函数,而我们的汇编程序中没有实 现main函数,所以报错。 如果目标文件是由C代码编译生成的,用gcc做链接就没错了,整个程序的入口点是crt1.o中提 供的_start,它首先做一些初始化工作(以下称为启动例程,Startup Routine),然后调用C代 码中提供的main函数。所以,以前我们说main函数是程序的入口点其实不准确,_start才是真 正的入口点,而main函数是被_start调用的。 我们继续研究上一节的例 19.1 “研究函数的调用过程”。如果分两步编译,第二步gcc main.o -o main其实是调用ld做链接的,相当于这样的命令: $ ld /usr/lib/crt1.o /usr/lib/crti.o main.o -o main -lc -dynamiclinker /lib/ld-linux.so.2 也就是说,除了crt1.o之外其实还有crti.o,这两个目标文件和我们的main.o链接在一起生成可 执行文件main。-lc表示需要链接libc库,在第 1 节 “数学函数”讲过-lc选项是gcc默认的,不用 写,而对于ld则不是默认选项,所以要写上。-dynamic-linker /lib/ld-linux.so.2指定动态链 接器是/lib/ld-linux.so.2,稍后会解释什么是动态链接。 那么crt1.o和crti.o里面都有什么呢?我们可以用readelf命令查看。在这里我们只关心符号 表,如果只看符号表,可以用readelf命令的-s选项,也可以用nm命令。 $ nm /usr/lib/crt1.o 00000000 R _IO_stdin_used 00000000 D __data_start U __libc_csu_fini U __libc_csu_init U __libc_start_main 00000000 R _fp_hw 00000000 T _start 00000000 W data_start U main $ nm /usr/lib/crti.o U _GLOBAL_OFFSET_TABLE_ w __gmon_start__ 00000000 T _fini 00000000 T _init U main这一行表示main这个符号在crt1.o中用到了,但是没有定义(U表示Undefined),因此 需要别的目标文件提供一个定义并且和crt1.o链接在一起。具体来说,在crt1.o中要用到main这 个符号所代表的地址,例如有一条指令是push $符号main所代表的地址,但不知道这个地址是多 少,所以在crt1.o中这条指令暂时写成push $0x0,等到和main.o链接成可执行文件时就知道这 个地址是多少了,比如是0x80483c4,那么可执行文件main中的这条指令就被链接器改成 了push $0x80483c4。链接器在这里起到符号解析(Symbol Resolution)的作用,在第 5.2 节 “可执行文件”我们看到链接器起到重定位的作用,这两种作用都是通过修改指令中的地址实现 的,链接器也是一种编辑器,vi和emacs编辑的是源文件,而链接器编辑的是目标文件,所以链 接器也叫Link Editor。T _start这一行表示_start这个符号在crt1.o中提供了定义,这个符号的 类型是代码(T表示Text)。我们从上面的输出结果中选取几个符号用图示说明它们之间的关 系: 图 19.3. C程序的链接过程 其实上面我们写的ld命令做了很多简化,gcc在链接时还用到了另外几个目标文件,所以上图多 画了一个框,表示组成可执行文件main的除了main.o、crt1.o和crti.o之外还有其它目标文件, 本书不做深入讨论,用gcc的-v选项可以了解详细的编译过程: $ gcc -v main.c -o main Using built-in specs. Target: i486-linux-gnu ...... /usr/lib/gcc/i486-linux-gnu/4.3.2/cc1 -quiet -v main.c D_FORTIFY_SOURCE=2 -quiet -dumpbase main.c -mtune=generic auxbase main -version -fstack-protector -o /tmp/ccRGDpua.s ...... as -V -Qy -o /tmp/ccidnZ1d.o /tmp/ccRGDpua.s ...... /usr/lib/gcc/i486-linux-gnu/4.3.2/collect2 --eh-frame-hdr -m elf_i386 --hash-style=both -dynamic-linker /lib/ld-linux.so.2 -o main -z relro /usr/lib/gcc/i486-linuxgnu/4.3.2/../../../../lib/crt1.o /usr/lib/gcc/i486-linuxgnu/4.3.2/../../../../lib/crti.o /usr/lib/gcc/i486-linuxgnu/4.3.2/crtbegin.o -L/usr/lib/gcc/i486-linux-gnu/4.3.2 L/usr/lib/gcc/i486-linux-gnu/4.3.2 -L/usr/lib/gcc/i486-linuxgnu/4.3.2/../../../../lib -L/lib/../lib -L/usr/lib/../lib L/usr/lib/gcc/i486-linux-gnu/4.3.2/../../.. /tmp/ccidnZ1d.o -lgcc --as-needed -lgcc_s --no-as-needed -lc -lgcc --as-needed -lgcc_s --no-as-needed /usr/lib/gcc/i486-linux-gnu/4.3.2/crtend.o /usr/lib/gcc/i486- linux - gnu/4.3.2/../../../../lib/crtn.o 链接生成的可执行文件main中包含了各目标文件所定义的符号,通过反汇编可以看到这些符号 的定义: $ objdump -d main main: file format elf32-i386 Disassembly of section .init: 08048274 <_init>: 8048274: 55 push %ebp 8048275: 89 e5 8048277: 53 ...... Disassembly of section .text: 080482e0 <_start>: 80482e0: 31 ed 80482e2: 5e 80482e3: 89 e1 ...... 08048394 : 8048394: 55 8048395: 89 e5 8048397: 83 ec 10 ...... 080483aa : 80483aa: 55 80483ab: 89 e5 80483ad: 83 ec 08 ...... 080483c4
    : 80483c4: 8d 4c 24 04 80483c8: 83 e4 f0 80483cb: ff 71 fc ...... Disassembly of section .fini: 0804849c <_fini>: 804849c: 55 804849d: 89 e5 804849f: 53 mov %esp,%ebp push %ebx xor %ebp,%ebp pop %esi mov %esp,%ecx push mov sub %ebp %esp,%ebp $0x10,%esp push mov sub %ebp %esp,%ebp $0x8,%esp lea 0x4(%esp),%ecx and $0xfffffff0,%esp pushl -0x4(%ecx) push mov push %ebp %esp,%ebp %ebx crt1.o中的未定义符号main在main.o中定义了,所以链接在一起就没问题了。crt1.o还有一个未 定义符号__libc_start_main在其它几个目标文件中也没有定义,所以在可执行文件main中仍然 是个未定义符号。这个符号是在libc中定义的,libc并不像其它目标文件一样链接到可执行文 件main中,而是在运行时做动态链接: 1. 操作系统在加载执行main这个程序时,首先查看它有没有需要动态链接的未定义符号。 2. 如果需要做动态链接,就查看这个程序指定了哪些共享库(我们用-lc指定了libc)以及 用什么动态链接器来做动态链接(我们用-dynamic-linker /lib/ld-linux.so.2指定了动 态链接器)。 3. 动态链接器在共享库中查找这些符号的定义,完成链接过程。 了解了这些原理之后,现在我们来看_start的反汇编: ...... Disassembly of section .text: 080482e0 <_start>: 80482e0: 31 ed 80482e2: 5e 80482e3: 89 e1 80482e5: 83 e4 f0 80482e8: 50 80482e9: 54 80482ea: 52 80482eb: 68 00 84 04 08 80482f0: 68 10 84 04 08 80482f5: 51 80482f6: 56 80482f7: 68 c4 83 04 08 80482fc: e8 c3 ff ff ff <__libc_start_main@plt> ...... xor pop mov and push push push push push push push push call %ebp,%ebp %esi %esp,%ecx $0xfffffff0,%esp %eax %esp %edx $0x8048400 $0x8048410 %ecx %esi $0x80483c4 80482c4 首先将一系列参数压栈,然后调用libc的库函数__libc_start_main做初始化工作,其中最后一 个压栈的参数push $0x80483c4是main函数的地址,__libc_start_main在完成初始化工作之后会 调用main函数。由于__libc_start_main需要动态链接,所以这个库函数的指令在可执行文 件main的反汇编中肯定是找不到的,然而我们找到了这个: Disassembly of section .plt: ...... 080482c4 <__libc_start_main@plt>: 80482c4: ff 25 04 a0 04 08 80482ca: 68 08 00 00 00 80482cf: e9 d0 ff ff ff jmp push jmp *0x804a004 $0x8 80482a4 <_init+0x30> 这三条指令位于.plt段而不是.text段,.plt段协助完成动态链接的过程。我们将在下一章详细 讲解动态链接的过程。 main函数最标准的原型应该是int main(int argc, char *argv[]),也就是说启动例程会传两个 参数给main函数,这两个参数的含义我们学了指针以后再解释。我们到目前为止都把main函数 的原型写成int main(void),这也是C标准允许的,如果你认真分析了上一节的习题,你就应该 知道,多传了参数而不用是没有问题的,少传了参数却用了则会出问题。 由于main函数是被启动例程调用的,所以从main函数return时仍返回到启动例程中,main函数 的返回值被启动例程得到,如果将启动例程表示成等价的C代码(实际上启动例程一般是直接用 汇编写的),则它调用main函数的形式是: exit(main(argc, argv)); 也就是说,启动例程得到main函数的返回值后,会立刻用它做参数调用exit函数。exit也 是libc中的函数,它首先做一些清理工作,然后调用上一章讲过的_exit系统调用终止进 程,main函数的返回值最终被传给_exit系统调用,成为进程的退出状态。我们也可以在main函 数中直接调用exit函数终止进程而不返回到启动例程,例如: #include int main(void) { exit(4); } 这样和int main(void) { return 4; }的效果是一样的。在Shell中运行这个程序并查看它的退 出状态: $ ./a.out $ echo $? 4 按照惯例,退出状态为0表示程序执行成功,退出状态非0表示出错。注意,退出状态只有8位, 而且被Shell解释成无符号数,如果将上面的代码改为exit(-1);或return -1;,则运行结果为 $ ./a.out $ echo $? 255 注意,如果声明一个函数的返回值类型是int,函数中每个分支控制流程必须写return语句指定 返回值,如果缺了return则返回值不确定(想想这是为什么),编译器通常是会报警告的,但 如果某个分支控制流程调用了exit或_exit而不写return,编译器是允许的,因为它都没有机会 exit stdlib.h 返回了,指不指定返回值也就无所谓了。使用 函数需要包含头文件 用_exit函数需要包含头文件unistd.h,以后还要详细解释这两个函数。 上一页 1. 函数调用 上一级 起始页 ,而使 下一页 3. 变量的存储布局 上一页 3. 变量的存储布局 首先看下面的例子: 3. 变量的存储布局 第 19 章 汇编与C之间的关系 例 19.2. 研究变量的存储布局 #include const int A = 10; int a = 20; static int b = 30; int c; int main(void) { static int a = 40; char b[] = "Hello world"; register int c = 50; printf("Hello world %d\n", c); return 0; } 下一页 我们在全局作用域和main函数的局部作用域各定义了一些变量,并且引入一些新的关键 字const、static、register来修饰变量,那么这些变量的存储空间是怎么分配的呢?我们编译 之后用readelf命令看它的符号表,了解各变量的地址分布。注意在下面的清单中我把符号表按 地址从低到高的顺序重新排列了,并且只截取我们关心的那几行。 $ gcc main.c -g $ readelf -a a.out ...... 68: 08048540 69: 0804a018 52: 0804a01c 53: 0804a020 81: 0804a02c ...... 4 OBJECT 4 OBJECT 4 OBJECT 4 OBJECT 4 OBJECT GLOBAL DEFAULT GLOBAL DEFAULT LOCAL DEFAULT LOCAL DEFAULT GLOBAL DEFAULT 15 A 23 a 23 b 23 a.1589 24 c 变量A用const修饰,表示A是只读的,不可修改,它被分配的地址是0x8048540,从readelf的 输出可以看到这个地址位于.rodata段: Section Headers: [Nr] Name Flg Lk Inf Al ...... [13] .text AX 0 0 16 ...... [15] .rodata A0 04 ...... [23] .data Type PROGBITS PROGBITS PROGBITS Addr Off Size ES 08048360 000360 0001bc 00 08048538 000538 00001c 00 0804a010 001010 000014 00 WA 0 0 4 [24] .bss WA 0 0 4 ...... NOBITS 0804a024 001024 00000c 00 它在文件中的地址是0x538~0x554,我们用hexdump命令看看这个段的内容: $ hexdump -C a.out ...... 00000530 5c fe ff ff 59 5b c9 c3 |\...Y[..........| 00000540 0a 00 00 00 48 65 6c 6c |....Hello world | 00000550 25 64 0a 00 00 00 00 00 |%d..............| ...... 03 00 00 00 01 00 02 00 6f 20 77 6f 72 6c 64 20 00 00 00 00 00 00 00 00 其中0x540地址处的0a 00 00 00就是变量A。我们还看到程序中的字符串字面值"Hello world %d\n"分配在.rodata段的末尾,在第 4 节 “字符串”说过字符串字面值是只读的,相当于在全局 作用域定义了一个const数组: const char helloworld[] = {'H', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd', ' ', '%', 'd', '\n', '\0'}; 程序加载运行时,.rodata段和.text段通常合并到一个Segment中,操作系统将这 个Segment的页面只读保护起来,防止意外的改写。这一点从readelf的输出也可以看出来: Section to Segment mapping: Segment Sections... 00 01 .interp 02 .interp .note.ABI-tag .hash .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .text .fini .rodata .eh_frame 03 .ctors .dtors .jcr .dynamic .got .got.plt .data .bss 04 .dynamic 05 .note.ABI-tag 06 07 .ctors .dtors .jcr .dynamic .got 注意,像A这种const变量在定义时必须初始化。因为只有初始化时才有机会给它一个值,一旦 定义之后就不能再改写了,也就是不能再赋值了。 从上面readelf的输出可以看到.data段从地址0x804a010开始,长度是0x14,也就是到地 址0x804a024结束。在.data段中有三个变量,a,b和a.1589。 a是一个GLOBAL的符号,而b被static关键字修饰了,导致它成为一个LOCAL的符号,所 以static在这里的作用是声明b这个符号为LOCAL的,不被链接器处理,在下一章我们会看到, 如果把多个目标文件链接在一起,LOCAL的符号只能在某一个目标文件中定义和使用,而不能定 义在一个目标文件中却在另一个目标文件中使用。一个函数定义前面也可以用static修饰,表 示这个函数名符号是LOCAL的。 还有一个a.1589是什么呢?它就是main函数中的static int a。函数中的static变量不同于以前 我们讲的局部变量,它并不是在调用函数时分配,在函数返回时释放,而是像全局变量一样静 态分配,所以用“static”(静态)这个词。另一方面,函数中的static变量的作用域和以前讲的 局部变量一样,只在函数中起作用,比如main函数中的a这个变量名只在main函数中起作用,在 别的函数中说变量a就不是指它了,所以编译器给它的符号名加了一个后缀,变成a.1589,以便 和全局变量a以及其它函数的变量a区分开。 .bss段从地址0x804a024开始(紧挨着.data段),长度为0xc,也就是到地址0x804a030结 束。变量c位于这个段。从上面的readelf输出可以看到,.data和.bss在加载时合并到一 个Segment中,这个Segment是可读可写的。.bss段和.data段的不同之处在于,.bss段在文件 中不占存储空间,在加载时这个段用0填充。所以我们在第 4 节 “局部变量与全局变量”讲过,全 局变量如果不初始化则初值为0,同理可以推断,static变量(不管是函数里的还是函数外的) 如果不初始化则初值也是0,也分配在.bss段。 现在还剩下函数中的b和c这两个变量没有分析。上一节我们讲过函数的参数和局部变量是分配 在栈上的,b是数组也一样,也是分配在栈上的,我们看main函数的反汇编代码: $ objdump -dS a.out ...... char b[]="Hello world"; 8048430: c7 45 ec 48 65 6c 6c 0x14(%ebp) 8048437: c7 45 f0 6f 20 77 6f 0x10(%ebp) 804843e: c7 45 f4 72 6c 64 00 0xc(%ebp) register int c = 50; 8048445: b8 32 00 00 00 movl movl movl $0x6c6c6548,$0x6f77206f,$0x646c72, - mov $0x32,%eax printf("Hello world %d\n", c); 804844a: 89 44 24 04 mov 804844e: c7 04 24 44 85 04 08 movl 8048455: e8 e6 fe ff ff call ...... %eax,0x4(%esp) $0x8048544,(%esp) 8048340 可见,给b初始化用的这个字符串"Hello world"并没有分配在.rodata段,而是直接写在指令里 了,通过三条movl指令把12个字节写到栈上,这就是b的存储空间,如下图所示。 图 19.4. 数组的存储布局 注意,虽然栈是从高地址向低地址增长的,但数组总是从低地址向高地址排列的,按从低地址 到高地址的顺序依次是b[0]、b[1]、b[2]……这样, 数组元素b[n]的地址 = 数组的基地址(b做右值就表示这个基地址) + n × 每个元素的字节数 当n=0时,元素b[0]的地址就是数组的基地址,因此数组下标要从0开始而不是从1开始。 变量c并没有在栈上分配存储空间,而是直接存在eax寄存器里,后面调用printf也是直接 从eax寄存器里取出c的值当参数压栈,这就是register关键字的作用,指示编译器尽可能分配 一个寄存器来存储这个变量。我们还看到调用printf时对于"Hello world %d\n"这个参数压栈的 是它在.rodata段中的首地址,而不是把整个字符串压栈,所以在第 4 节 “字符串”中说过,字符 串在使用时可以看作数组名,如果做右值则表示数组首元素的地址(或者说指向数组首元素的 指针),我们以后讲指针还要继续讨论这个问题。 以前我们用“全局变量”和“局部变量”这两个概念,主要是从作用域上区分的,现在看来用这两个 概念给变量分类太笼统了,需要进一步细分。我们总结一下相关的C语法。 作用域(Scope)这个概念适用于所有标识符,而不仅仅是变量,C语言的作用域分为以下几 类: 函数作用域(Function Scope),标识符在整个函数中都有效。只有语句标号属于函数作 用域。标号在函数中不需要先声明后使用,在前面用一个goto语句也可以跳转到后面的某 个标号,但仅限于同一个函数之中。 文件作用域(File Scope),标识符从它声明的位置开始直到这个程序文件[27]的末尾都有 效。例如上例中main函数外面的A、a、b、c,还有main也算,printf其实是在stdio.h中声 明的,被包含到这个程序文件中了,所以也算文件作用域的。 块作用域(Block Scope),标识符位于一对{}括号中(函数体或语句块),从它声明的位 置开始到右}括号之间有效。例如上例中main函数里的a、b、c。此外,函数定义中的形参 也算块作用域的,从声明的位置开始到函数末尾之间有效。 函数原型作用域(Function Prototype Scope),标识符出现在函数原型中,这个函数原 型只是一个声明而不是定义(没有函数体),那么标识符从声明的位置开始到在这个原型 末尾之间有效。例如int foo(int a, int b);中的a和b。 对属于同一命名空间(Name Space)的重名标识符,内层作用域的标识符将覆盖外层作用域的 标识符,例如局部变量名在它的函数中将覆盖重名的全局变量。命名空间可分为以下几类: 语句标号单独属于一个命名空间。例如在函数中局部变量和语句标号可以重名,互不影 响。由于使用标号的语法和使用其它标识符的语法都不一样,编译器不会把它和别的标识 符弄混。 struct,enum和union(下一节介绍union)的类型Tag属于一个命名空间。由于Tag前面 总是带struct,enum或union关键字,所以编译器不会把它和别的标识符弄混。 struct和union的成员名属于一个命名空间。由于成员名总是通过.或->运算符来访问而不 会单独使用,所以编译器不会把它和别的标识符弄混。 所有其它标识符,例如变量名、函数名、宏定义、typedef的类型名、enum成员等等都属 于同一个命名空间。如果有重名的话,宏定义覆盖所有其它标识符,因为它在预处理阶段 而不是编译阶段处理,除了宏定义之外其它几类标识符按上面所说的规则处理,内层作用 域覆盖外层作用域。 标识符的链接属性(Linkage)有三种: 外部链接(External Linkage),如果最终的可执行文件由多个程序文件链接而成,一个 标识符在任意程序文件中即使声明多次也都代表同一个变量或函数,则这个标识符具 有External Linkage。具有External Linkage的标识符编译后在符号表中是GLOBAL的符号。 例如上例中main函数外面的a和c,main和printf也算。 内部链接(Internal Linkage),如果一个标识符在某个程序文件中即使声明多次也都代表 同一个变量或函数,则这个标识符具有Internal Linkage。例如上例中main函数外面的b。 如果有另一个foo.c程序和main.c链接在一起,在foo.c中也声明一个static int b;,则那 个b和这个b不代表同一个变量。具有Internal Linkage的标识符编译后在符号表中 是LOCAL的符号,但main函数里面那个a不能算Internal Linkage的,因为即使在同一个程序 文件中,在不同的函数中声明多次,也不代表同一个变量。 无链接(No Linkage)。除以上情况之外的标识符都属于No Linkage的,例如函数的局部 变量,以及不表示变量和函数的其它标识符。 存储类修饰符(Storage Class Specifier)有以下几种关键字,可以修饰变量或函数声明: static,用它修饰的变量的存储空间是静态分配的,用它修饰的文件作用域的变量或函数 具有Internal Linkage。 auto,用它修饰的变量在函数调用时自动在栈上分配存储空间,函数返回时自动释放,例 如上例中main函数里的b其实就是用auto修饰的,只不过auto可以省略不写,auto不能修 饰文件作用域的变量。 register,编译器对于用register修饰的变量会尽可能分配一个专门的寄存器来存储,但 如果实在分配不开寄存器,编译器就把它当auto变量处理了,register不能修饰文件作用 域的变量。现在一般编译器的优化都做得很好了,它自己会想办法有效地利用CPU的寄存 器,所以现在register关键字也用得比较少了。 extern,上面讲过,链接属性是根据一个标识符多次声明时是不是代表同一个变量或函数 来分类的,extern关键字就用于多次声明同一个标识符,下一章再详细介绍它的用法。 typedef,在第 2.4 节 “sizeof运算符与typedef类型声明”讲过这个关键字,它并不是用来修 饰变量的,而是定义一个类型名。在那一节也讲过,看typedef声明怎么看呢,首先去 掉typedef把它看成变量声明,看这个变量是什么类型的,那么typedef就定义了一个什么 类型,也就是说,typedef在语法结构中出现的位置和是面几个关键字一样,也是修饰变 量定义的,所以从语法(而不是语义)的角度把它和前面几个关键字归类到一起。 注意,上面介绍的const关键字不是一个Storage Class Specifier,虽然看起来它也修饰一个变 量声明,但是在以后介绍的更复杂的声明中const在语法结构中允许出现的位置和Storage Class Specifier是不完全相同的。const和以后要介绍的restrict和volatile关键字属于同一类语法元 素,称为类型限定符(Type Qualifier)。 变量的生存期(Storage Duration,或者Lifetime)分为以下几类: 静态生存期(Static Storage Duration),具有外部或内部链接属性,或者被static修饰的 变量,在程序开始执行时分配和初始化一次,此后便一直存在直到程序结束。这种变量通 常位于.rodata,.data或.bss段,例如上例中main函数外的A,a,b,c,以及main函数里 的a。 自动生存期(Automatic Storage Duration),链接属性为无链接并且没有被static修饰的 变量,这种变量在进入块作用域时在栈上或寄存器中分配,在退出块作用域时释放。例如 上例中main函数里的b和c。 动态分配生存期(Allocated Storage Duration),以后会讲到调用malloc函数在进程的堆 空间中分配内存,调用free函数可以释放这种存储空间。 [27] 为了容易阅读,这里我用了“程序文件”这个不严格的叫法。如果有文件a.c包含 了b.h和c.h,那么我所说的“程序文件”指的是经过预处理把b.h和c.h在a.c中展开之后生成的代 码,在C标准中称为编译单元(Translation Unit)。每个编译单元可以分别编译成一个.o目标文 件,最后这些目标文件用链接器链接到一起,成为一个可执行文件。C标准中大量使用一些非常 不通俗的名词,除了编译单元之外,还有编译器叫Translator,变量叫Object,本书不会采用这 些名词,因为我不是在写C标准。 上一页 2. main函数和启动例程 上一级 起始页 下一页 4. 结构体和联合体 上一页 4. 结构体和联合体 第 19 章 汇编与C之间的关系 4. 结构体和联合体 我们继续用反汇编的方法研究一下C语言的结构体: 例 19.3. 研究结构体 #include int main(int argc, char** argv) { struct { char a; short b; int c; char d; } s; s.a = 1; s.b = 2; s.c = 3; s.d = 4; printf("%u\n", sizeof(s)); return 0; } 下一页 main函数中几条语句的反汇编结果如下: s.a = 1; 80483d5: c6 45 f0 01 s.b = 2; 80483d9: 66 c7 45 f2 02 00 s.c = 3; 80483df: c7 45 f4 03 00 00 00 s.d = 4; 80483e6: c6 45 f8 04 movb $0x1,-0x10(%ebp) movw $0x2,-0xe(%ebp) movl $0x3,-0xc(%ebp) movb $0x4,-0x8(%ebp) 从访问结构体成员的指令可以看出,结构体的四个成员在栈上是这样排列的: 图 19.5. 结构体的存储布局 虽然栈是从高地址向低地址增长的,但结构体成员也是从低地址向高地址排列的,这一点和数 组类似。但有一点和数组不同,结构体的各成员并不是一个紧挨一个排列的,中间有空隙,称 为填充(Padding),不仅如此,在这个结构体的末尾也有三个字节的填充,所以sizeof(s)的 值是12。注意,printf的%u转换说明表示无符号数,sizeof的值是size_t类型的,是某种无符 号整型。 为什么编译器要这样处理呢?有一个知识点我此前一直回避没讲,那就是大多数计算机体系统 结构对于访问内存的指令是有限制的,在32位平台上,访问4字节的指令(比如上面的movl)所 访问的内存地址应该是4的整数倍,访问两字节的指令(比如上面的movw)所访问的内存地址应 该是两字节的整数倍,这称为对齐(Alignment)。以前举的所有例子中的内存访问指令都满足 这个限制条件,读者可以回头检验一下。如果指令所访问的内存地址没有正确对齐会怎么样 呢?在有些平台上将不能访问内存,而是引发一个异常,在x86平台上倒是仍然能访问内存,但 是不对齐的指令执行效率比对齐的指令要低,所以编译器在安排各种变量的地址时都会考虑到 对齐的问题。对于本例中的结构体,编译器会把它的基地址对齐到4字节边界,也就是说,ebp0x10这个地址一定是4的整数倍。s.a占一个字节,没有对齐的问题。s.b占两个字节,如 果s.b紧挨在s.a后面,它的地址就不能是两字节的整数倍了,所以编译器会在结构体中插入一 个填充字节,使s.b的地址也是两字节的整数倍。s.c占4字节,紧挨在s.b的后面就可以了,因 为ebp-0xc这个地址也是4的整数倍。那么为什么s.d的后面也要有填充位填充到4字节边界呢? 这是为了便于安排这个结构体后面的变量的地址,假如用这种结构体类型组成一个数组,那么 后一个结构体只需和前一个结构体紧挨着排列就可以保证它的基地址仍然对齐到4字节边界了, 因为在前一个结构体的末尾已经有了填充字节。事实上,C标准规定数组元素必须紧挨着排列, 不能有空隙,这样才能保证每个元素的地址可以按“基地址+n×元素大小”简单计算出来。 合理设计结构体各成员的排列顺序可以节省存储空间,例如上例中的结构体改成这样就可以避 免产生填充字节: struct { char a; char d; short b; int c; } s; 此外,gcc提供了一种扩展语法可以消除结构体中的填充字节: struct { char a; short b; int c; char d; } __attribute__((packed)) s; 这样就不能保证结构体成员的对齐了,在访问b和c的时候可能会有效率问题,所以除非有特别 的理由,一般不要使用这种语法。 以前我们使用的数据类型都是占几个字节,最小的类型也要占一个字节,而在结构体中还可以 使用Bit Field语法定义只占几个Bit的成员。下面这个例子出自王聪的网站 (www.wangcong.org): 例 19.4. Bit Field #include typedef struct { unsigned int one:1; unsigned int two:3; unsigned int three:10; unsigned int four:5; unsigned int :2; unsigned int five:8; unsigned int six:8; } demo_type; int main(void) { demo_type s = { 1, 5, 513, 17, 129, 0x81 }; printf("sizeof demo_type = %u\n", sizeof(demo_type)); printf("values: s=%u,%u,%u,%u,%u,%u\n", s.one, s.two, s.three, s.four, s.five, s.six); return 0; } s这个结构体的布局如下图所示: 图 19.6. Bit Field的存储布局 Bit Field成员的类型可以是int或unsigned int,表示有符号数或无符号数,但不表示它像普通 的int型一样占4个字节,它后面的数字是几就表示它占多少个Bit,也可以像unsigned int :2;这样定义一个未命名的Bit Field,即使不写未命名的Bit Field,编译器也有可能在两个成员之 间插入填充位,如上图的five和six之间,这样six这个成员就刚好单独占一个字节了,访问效 率会比较高,这个结构体的末尾还填充了3个字节,以便对齐到4字节边界。以前我们说 过x86的Byte Order是小端的,从上图中one和two的排列顺序可以看出,如果对一个字节再细 分,则字节中的Bit Order也是小端的,因为排在结构体前面的成员(靠近低地址一边的成员) 取字节中的低位。关于如何排列Bit Field在C标准中没有详细的规定,这跟Byte Order、Bit Order、对齐等问题都有关,不同的平台和编译器可能会排列得很不一样,要编写可移植的代码 就不能假定Bit Field是按某一种固定方式排列的。Bit Field在驱动程序中是很有用的,因为经常 需要单独操作设备寄存器中的一个或几个Bit,但一定要小心使用,首先弄清楚每个Bit Field和实 际Bit的对应关系。 和前面几个例子不一样,在上例中我没有给出反汇编结果,直接画了个图说这个结构体的布局 是这样的,那我有什么证据这么说呢?上例的反汇编结果比较繁琐,我们可以通过另一种手段 得到这个结构体的内存布局。C语言还有一种类型叫联合体,用关键字union定义,其语法类似 于结构体,例如: 例 19.5. 联合体 #include typedef union { struct { unsigned int one:1; unsigned int two:3; unsigned int three:10; unsigned int four:5; unsigned int :2; unsigned int five:8; unsigned int six:8; } bitfield; unsigned char byte[8]; } demo_type; int main(void) { demo_type u = {{ 1, 5, 513, 17, 129, 0x81 }}; printf("sizeof demo_type = %u\n", sizeof(demo_type)); printf("values: u=%u,%u,%u,%u,%u,%u\n", u.bitfield.one, u.bitfield.two, u.bitfield.three, u.bitfield.four, u.bitfield.five, u.bitfield.six); printf("hex dump of u: %x %x %x %x %x %x %x %x \n", u.byte[0], u.byte[1], u.byte[2], u.byte[3], u.byte[4], u.byte[5], u.byte[6], u.byte[7]); return 0; } 一个联合体的各个成员占用相同的内存空间,联合体的长度等于其中最长成员的长度。比如u这 个联合体占8个字节,如果访问成员u.bitfield,则把这8个字节看成一个由Bit Field组成的结构 体,如果访问成员u.byte,则把这8个字节看成一个数组。联合体如果用Initializer初始化,则只 初始化它的第一个成员,例如demo_type u = {{ 1, 5, 513, 17, 129, 0x81 }};初始化的 是u.bitfield,但是通过u.bitfield的成员看不出这8个字节的内存布局,而通过u.byte数组就 可以看出每个字节分别是多少了。 习题 1、编写一个程序,测试运行它的平台是大端还是小端字节序。 上一页 3. 变量的存储布局 上一级 起始页 下一页 5. C内联汇编 上一页 5. C内联汇编 第 19 章 汇编与C之间的关系 下一页 5. C内联汇编 用C写程序比直接用汇编写程序更简洁,可读性更好,但效率可能不如汇编程序,因为C程序毕 竟要经由编译器生成汇编代码,尽管现代编译器的优化已经做得很好了,但还是不如手写的汇 编代码。另外,有些平台相关的指令必须手写,在C语言中没有等价的语法,因为C语言的语法 和概念是对各种平台的抽象,而各种平台特有的一些东西就不会在C语言中出现了,例如x86是 端口I/O,而C语言就没有这个概念,所以in/out指令必须用汇编来写。 C语言简洁易读,容易组织规模较大的代码,而汇编效率高,而且写一些特殊指令必须用汇编, 为了把这两方面的好处都占全了,gcc提供了一种扩展语法可以在C代码中使用内联汇编(Inline Assembly)。最简单的格式是__asm__("assembly code");,例如__asm__("nop"); ,nop 这条 指令什么都不做,只是让CPU空转一个指令执行周期。如果需要执行多条汇编指令,则应该 用\n\t将各条指令分隔开,例如: __asm__("movl $1, %eax\n\t" "movl $4, %ebx\n\t" "int $0x80"); 通常 C 代码中的内联汇编需要和C的变量建立关联,需要用到完整的内联汇编格式: __asm__(assembler template : output operands : input operands : list of clobbered registers ); /* optional */ /* optional */ /* optional */ 这种格式由四部分组成,第一部分是汇编指令,和上面的例子一样,第二部分和第三部分是约 束条件,第二部分指示汇编指令的运算结果要输出到哪些C操作数中,C操作数应该是左值表达 式,第三部分指示汇编指令需要从哪些C操作数获得输入,第四部分是在汇编指令中被修改过的 寄存器列表,指示编译器哪些寄存器的值在执行这条__asm__语句时会改变。后三个部分都是可 选的,如果有就填写,没有就空着只写个:号。例如: 例 19.6. 内联汇编 #include int main() { int a = 10, b; __asm__("movl %1, %%eax\n\t" "movl %%eax, %0\n\t" :"=r"(b) /* output */ :"r"(a) /* input */ :"%eax" /* clobbered register */ ); printf("Result: %d, %d\n", a, b); return 0; } 这个程序将变量a的值赋给b。"r"(a)指示编译器分配一个寄存器保存变量a的值,作为汇编指令 的输入,也就是指令中的%1(按照约束条件的顺序,b对应%0,a对应1%),至于%1究竟代表哪 个寄存器则由编译器自己决定。汇编指令首先把%1所代表的寄存器的值传给eax(为了和%1这种 占位符区分,eax前面要求加两个%号),然后把eax的值再传给%0所代表的寄存器。"=r"(b)就 表示把%0所代表的寄存器的值输出给变量b。在执行这两条指令的过程中,寄存器eax的值被改 变了,所以把"%eax"写在第四部分,告诉编译器在执行这条__asm__语句时eax要被改写,所以在 此期间不要用eax保存其它值。 我们看一下这个程序的反汇编结果: __asm__("movl %1, %%eax\n\t" 80483dc: 8b 55 f8 mov -0x8(%ebp),%edx 80483df: 89 d0 mov %edx,%eax 80483e1: 89 c2 mov %eax,%edx 80483e3: 89 55 f4 mov %edx,-0xc(%ebp) "movl %%eax, %0\n\t" :"=r"(b) /* output */ :"r"(a) /* input */ :"%eax" /* clobbered register */ ); 可见%0和%1都代表edx寄存器,首先把变量a(位于ebp-8的位置)的值传给edx然后执行内联汇 编的两条指令,然后把edx的值传给b(位于ebp-12的位置)。 关于内联汇编就介绍这么多,本书不做深入讨论。 上一页 4. 结构体和联合体 上一级 起始页 下一页 6. volatile限定符 上一页 6. volatile限定符 第 19 章 汇编与C之间的关系 下一页 6. volatile限定符 现在探讨一下编译器优化会对生成的指令产生什么影响,在此基础上介绍C语言的volatile限定 符。看下面的例子。 例 19.7. volatile限定符 /* artificial device registers */ unsigned char recv; unsigned char send; /* memory buffer */ unsigned char buf[3]; int main(void) { buf[0] = recv; buf[1] = recv; buf[2] = recv; send = ~buf[0]; send = ~buf[1]; send = ~buf[2]; return 0; } 我们用recv和send这两个全局变量来模拟设备寄存器。假设某种平台采用内存映射I/O,串口发 送寄存器和串口接收寄存器位于固定的内存地址,而recv和send这两个全局变量也有固定的内 存地址,所以在这个例子中把它们假想成串口接收寄存器和串口发送寄存器。在main函数中, 首先从串口接收三个字节存到buf中,然后把这三个字节取反,依次从串口发送出去[28]。我们 查看这段代码的反汇编结果: buf[0] = recv; 80483a2: 0f b6 05 19 a0 04 08 80483a9: a2 1a a0 04 08 buf[1] = recv; 80483ae: 0f b6 05 19 a0 04 08 80483b5: a2 1b a0 04 08 buf[2] = recv; 80483ba: 0f b6 05 19 a0 04 08 80483c1: a2 1c a0 04 08 send = ~buf[0]; 80483c6: 0f b6 05 1a a0 04 08 80483cd: f7 d0 80483cf: a2 18 a0 04 08 send = ~buf[1]; 80483d4: 0f b6 05 1b a0 04 08 80483db: f7 d0 80483dd: a2 18 a0 04 08 send = ~buf[2]; 80483e2: 0f b6 05 1c a0 04 08 80483e9: f7 d0 80483eb: a2 18 a0 04 08 movzbl 0x804a019,%eax mov %al,0x804a01a movzbl 0x804a019,%eax mov %al,0x804a01b movzbl 0x804a019,%eax mov %al,0x804a01c movzbl 0x804a01a,%eax not %eax mov %al,0x804a018 movzbl 0x804a01b,%eax not %eax mov %al,0x804a018 movzbl 0x804a01c,%eax not %eax mov %al,0x804a018 movz指令把字长较短的值存到字长较长的存储单元中,存储单元的高位用0填充。该指令可以 有b(byte)、w(word)、l(long)三种后缀,分别表示单字节、两字节和四字节。比 如movzbl 0x804a019,%eax表示把地址0x804a019处的一个字节存到eax寄存器中,而eax寄存器 是四字节的,高三字节用0填充,而下一条指令mov %al,0x804a01a中的al寄存器正是eax寄存器 的低字节,把这个字节存到地址0x804a01a处的一个字节中。可以用不同的名字单独访问x86寄 存器的低8位、次低8位、低16位或者完整的32位,以eax为例,al表示低8位,ah表示次 低8位,ax表示低16位,如下图所示。 图 19.7. eax寄存器 但如果指定优化选项-O编译,反汇编的结果就不一样了: $ gcc main.c -g -O $ objdump -dS a.out|less ... buf[0] = recv; 80483ae: 0f b6 05 19 a0 04 08 80483b5: a2 1a a0 04 08 buf[1] = recv; 80483ba: a2 1b a0 04 08 buf[2] = recv; 80483bf: a2 1c a0 04 08 send = ~buf[0]; send = ~buf[1]; send = ~buf[2]; 80483c4: f7 d0 80483c6: a2 18 a0 04 08 ... movzbl 0x804a019,%eax mov %al,0x804a01a mov %al,0x804a01b mov %al,0x804a01c not %eax mov %al,0x804a018 前三条语句从串口接收三个字节,而编译生成的指令显然不符合我们的意图:只有第一条语句 从内存地址0x804a019读一个字节到寄存器eax中,然后从寄存器al保存到buf[0],后两条语句 就不再从内存地址0x804a019读取,而是直接把寄存器al的值保存到buf[1]和buf[2]。后三条语 句把buf中的三个字节取反再发送到串口,编译生成的指令也不符合我们的意图:只有最后一条 语句把eax的值取反写到内存地址0x804a018了,前两条语句形同虚设,根本不生成指令。 为什么编译器优化的结果会错呢?因为编译器并不知道0x804a018和0x804a019是设备寄存器 的地址,把它们当成普通的内存单元了。如果是普通的内存单元,只要程序不去改写它,它就 不会变,可以先把内存单元里的值读到寄存器缓存起来,以后每次用到这个值就直接从寄存器 读取,这样效率更高,我们知道读寄存器远比读内存要快。另一方面,如果对一个普通的内存 单元连续做三次写操作,只有最后一次的值会保存到内存单元中,所以前两次写操作是多余 的,可以优化掉。访问设备寄存器的代码这样优化就错了,因为设备寄存器往往具有以下特 性: 设备寄存器中的数据不需要改写就可以自己发生变化,每次读上来的值都可能不一样。 连续多次向设备寄存器中写数据并不是在做无用功,而是有特殊意义的。 用优化选项编译生成的指令明显效率更高,但使用不当会出错,为了避免编译器自作聪明,把 不该优化的也优化了,程序员应该明确告诉编译器哪些内存单元的访问是不能优化的,在C语言 中可以用volatile限定符修饰变量,就是告诉编译器,即使在编译时指定了优化选项,每次读 这个变量仍然要老老实实从内存读取,每次写这个变量也仍然要老老实实写回内存,不能省略 任何步骤。我们把代码的开头几行改成: /* artificial device registers */ volatile unsigned char recv; volatile unsigned char send; 然后指定优化选项-O编译,查看反汇编的结果: buf[0] = recv; 80483a2: 0f b6 05 19 a0 04 08 80483a9: a2 1a a0 04 08 buf[1] = recv; 80483ae: 0f b6 15 19 a0 04 08 80483b5: 88 15 1b a0 04 08 buf[2] = recv; 80483bb: 0f b6 0d 19 a0 04 08 80483c2: 88 0d 1c a0 04 08 send = ~buf[0]; 80483c8: f7 d0 80483ca: a2 18 a0 04 08 send = ~buf[1]; 80483cf: f7 d2 80483d1: 88 15 18 a0 04 08 send = ~buf[2]; 80483d7: f7 d1 80483d9: 88 0d 18 a0 04 08 movzbl 0x804a019,%eax mov %al,0x804a01a movzbl 0x804a019,%edx mov %dl,0x804a01b movzbl 0x804a019,%ecx mov %cl,0x804a01c not %eax mov %al,0x804a018 not %edx mov %dl,0x804a018 not %ecx mov %cl,0x804a018 确实每次读recv都从内存地址0x804a019读取,每次写send也都写到内存地址0x804a018了。 值得注意的是,每次写send并不需要取出buf中的值,而是取出先前缓存在寄存 器eax、edx、ecx中的值,做取反运算然后写下去,这是因为buf并没有用volatile限定,读者 可以试着在buf的定义前面也加上volatile,再优化编译,再查看反汇编的结果。 gcc的编译优化选项有-O0、-O、-O1、-O2、-O3、-Os几种。-O0表示不优化,这是缺省的选 项。-O1、-O2和-O3这几个选项一个比一个优化得更多,编译时间也更长。-O和-O1相同。-Os表 示为缩小目标代码尺寸而优化。具体每种选项做了哪些优化请参考gcc(1)的Man Page。 从上面的例子还可以看到,如果在编译时指定了优化选项,源代码和生成指令的次序可能无法 对应,甚至有些源代码可能不对应任何指令,被彻底优化掉了。这一点在用gdb做源码级调试时 尤其需要注意(做指令级调试没关系),在为调试而编译时不要指定优化选项,否则可能无法 一步步跟踪源代码的执行过程。 有了volatile限定符,是可以防止编译器优化对设备寄存器的访问,但是对于有Cache的平台, 仅仅这样还不够,还是无法防止Cache优化对设备寄存器的访问。在访问普通的内存单元 时,Cache对程序员是透明的,比如执行了movzbl 0x804a019,%eax这样一条指令,我们并不知 道eax的值是真的从内存地址0x804a019读到的,还是从Cache中读到的,如果Cache已经缓存 了这个地址的数据就从Cache读,如果Cache没有缓存就从内存读,这些步骤都是硬件自动做 的,而不是用指令控制Cache去做的,程序员写的指令中只有寄存器、内存地址,而没 有Cache,程序员甚至不需要知道Cache的存在。同样道理,如果执行了mov %al,0x804a01a这 样一条指令,我们并不知道寄存器的值是真的写回内存了,还是只写到了Cache中,以后再 由Cache写回内存,即使只写到了Cache中而暂时没有写回内存,下次读0x804a01a这个地址时 仍然可以从Cache中读到上次写的数据。然而,在读写设备寄存器时Cache的存在就不容忽视 了,如果串口发送和接收寄存器的内存地址被Cache缓存了会有什么问题呢?如下图所示。 图 19.8. 串口发送和接收寄存器被Cache缓存会有什么问题 如果串口发送寄存器的地址被Cahce缓存,CPU执行单元对串口发送寄存器做写操作都写 到Cache中去了,串口发送寄存器并没有及时得到数据,也就不能及时发送,CPU执行单元先 后发出的1、2、3三个字节都会写到Cache中的同一个单元,最后Cache中只保存了第3个字 节,如果这时Cache把数据写回到串口发送寄存器,只能把第3个字节发送出去,前两个字节就 丢失了。与此类似,如果串口接收寄存器的地址被Cache缓存,CPU执行单元在读第1个字节 时,Cache会从串口接收寄存器读上来缓存,然而串口接收寄存器后面收到的2、3两个字 节Cache并不知道,因为Cache把串口接收寄存器当作普通内存单元,并且相信内存单元中的数 据是不会自己变的,以后每次读串口接收寄存器时,Cache都会把缓存的第1个字节提供 给CPU执行单元。 通常,有Cache的平台都有办法对某一段地址范围禁用Cache,一般是在页表中设置的,可以设 定哪些页面允许Cache缓存,哪些页面不允许Cache缓存,MMU不仅要做地址转换和访问权限 检查,也要和Cache协同工作。 除了设备寄存器需要用volatile限定之外,当一个全局变量被同一进程中的多个控制流程访问 时也要用volatile限定,比如信号处理函数和多线程。 [28] 实际的串口设备通常有一些标志位指示是否有数据到达以及是否可以发送下一个字节的数 据,通常要先查询这些标志位再做读写操作,在这个例子中我们抓主要矛盾,忽略这些细节。 上一页 5. C内联汇编 上一级 起始页 下一页 第 20 章 链接详解 上一页 第 20 章 链接详解 目录 1. 多目标文件的链接 2. 定义和声明 2.1. extern和static关键字 2.2. 头文件 2.3. 定义和声明的详细规则 3. 静态库 4. 共享库 4.1. 编译、链接、运行 4.2. 动态链接的过程 4.3. 共享库的命名惯例 5. 虚拟内存管理 上一页 6. volatile限定符 第 20 章 链接详解 部分 II. C语言本质 上一级 起始页 下一页 下一页 1. 多目标文件的链接 上一页 1. 多目标文件的链接 第 20 章 链接详解 下一页 1. 多目标文件的链接 现在我们把例 12.1 “用堆栈实现倒序打印”拆成两个程序文件,stack.c实现堆栈,而main.c使用 堆栈: /* stack.c */ char stack[512]; int top = -1; void push(char c) { stack[++top] = c; } char pop(void) { return stack[top--]; } int is_empty(void) { return top == -1; } 这段程序和原来有点不同,在例 12.1 “用堆栈实现倒序打印”中top总是指向栈顶元素的下一个元 素,而在这段程序中top总是指向栈顶元素,所以要初始化成-1才表示空堆栈,这两种堆栈使用 习惯都很常见。另外,从现在开始本书的代码尽可能把++、--运算符作为表达式的一部分使 用,而不单独使用,这才符合C语言的简洁风格,读者要适应和学习这种写法。 /* main.c */ #include int a, b = 1; int main(void) { push('a'); push('b'); push('c'); while(!is_empty()) putchar(pop()); putchar('\n'); return 0; } a和b这两个变量没有用,只是为了顺便说明链接过程才加上的。编译的步骤和以前一样,可以 一步编译: $ gcc main.c stack.c -o main 也分可以多步编译: $ gcc -c main.c $ gcc -c stack.c $ gcc main.o stack.o -o main 如果按照第 2 节 “main函数和启动例程”的做法,用nm命令查看目标文件的符号表,会发 现main.o中有未定义的符号push、pop、is_empty、putchar,前三个符号在stack.o中实现了, 链接生成可执行文件main时可以做符号解析,而putchar是libc的库函数,在可执行文件main中 仍然是未定义的,要在程序运行时做动态链接。 我们通过readelf -a main命令可以看到,main的.bss段合并了main.o和stack.o的.bss段,其中 包含了变量a和stack ,main 的.data 段也合并了main.o和stack.o的.data 段,其中包含了变 量b和top,main的.text段合并了main.o和stack.o的.text段,包含了各函数的定义。如下图所 示。 图 20.1. 多目标文件的链接 为什么在可执行文件main的每个段中来自main.o的变量或函数都在前面,而来自stack.o的变量 或函数都在后面呢?我们可以试试把gcc命令中的两个目标文件反过来写: $ gcc stack.o main.o -o main 结果正如我们所预料的,可执行文件main的每个段中来自main.o的变量或函数都排到后面了。 实际上链接的过程是由一个链接脚本(Linker Script)控制的,链接脚本决定了给每个段分配什 么地址,如何对齐,哪个段在前,哪个段在后,哪些段合并到同一个Segment,另外链接脚本 还要插入一些符号到最终生成的文件中,例如__bss_start、_edata、_end等。如果用ld做链接 时没有用-T选项指定链接脚本,则使用ld的默认链接脚本,默认链接脚本可以用ld -verbose命令查看(由于比较长,只列出一些片断): $ ld --verbose ...... using internal linker script: ================================================== /* Script for -z combreloc: combine and sort reloc sections */ OUTPUT_FORMAT("elf32-i386", "elf32-i386", "elf32-i386") OUTPUT_ARCH(i386) ENTRY(_start) ...... SECTIONS { /* Read-only sections, merged into text segment: */ PROVIDE (__executable_start = 0x08048000); . = 0x08048000 + SIZEOF_HEADERS; .interp : { *(.interp) } .note.gnu.build-id : { *(.note.gnu.build-id) } .hash : { *(.hash) } .gnu.hash : { *(.gnu.hash) } .dynsym : { *(.dynsym) } .dynstr : { *(.dynstr) } .gnu.version : { *(.gnu.version) } .gnu.version_d : { *(.gnu.version_d) } .gnu.version_r : { *(.gnu.version_r) } .rel.dyn : ...... .rel.plt : { *(.rel.plt) } ...... .init : ...... .plt : { *(.plt) } .text : ...... .fini : ...... .rodata : { *(.rodata .rodata.* .gnu.linkonce.r.*) } ...... .eh_frame : ONLY_IF_RO { KEEP (*(.eh_frame)) } ...... /* Adjust the address for the data segment. We want to adjust up to the same address within the page on the next page up. */ . = ALIGN (CONSTANT (MAXPAGESIZE)) - ((CONSTANT (MAXPAGESIZE) - .) & (CONSTANT (MAXPAGESIZE) - 1)); . = DATA_SEGMENT_ALIGN (CONSTANT (MAXPAGESIZE), CONSTANT (COMMONPAGESIZE)); ...... .ctors : ...... .dtors : ...... .jcr : { KEEP (*(.jcr)) } ...... .dynamic : { *(.dynamic) } .got : { *(.got) } ...... .got.plt : { *(.got.plt) } .data : ...... _edata = .; PROVIDE (edata = .); __bss_start = .; .bss : ...... _end = .; PROVIDE (end = .); . = DATA_SEGMENT_END (.); /* Stabs debugging sections. */ ...... /* DWARF debug sections. Symbols in the DWARF debugging sections are relative to the beginning of the section so we begin them at 0. */ ...... } ================================================== ENTRY(_start)说明_start是整个程序的入口点,因此_start是入口点并不是规定死的,是可以 改用其它函数做入口点的。 PROVIDE (__executable_start = 0x08048000); . = 0x08048000 + SIZEOF_HEADERS;是Text Segment的起始地址,这个Segment包含后面列出的那些段,.plt、.text、.rodata等等。每 个段的描述格式都是“段名 : { 组成 }”,例如.plt : { *(.plt) },左边表示最终生成的文件 的.plt段,右边表示所有目标文件的.plt段,意思是最终生成的文件的.plt段由各目标文件 的.plt段组成。 . = ALIGN (CONSTANT (MAXPAGESIZE)) - ((CONSTANT (MAXPAGESIZE) - .) & (CONSTANT (MAXPAGESIZE) - 1)); . = DATA_SEGMENT_ALIGN (CONSTANT (MAXPAGESIZE), CONSTANT (COMMONPAGESIZE));是Data Segment的起始地址,要做一系列的对齐操作,这个Segment包含 后面列出的那些段,.got、.data、.bss等等。 Data Segment的后面还有其它一些Segment,主要是调试信息。关于链接脚本就介绍这么多, 本书不做深入讨论。 上一页 第 20 章 链接详解 上一级 起始页 下一页 2. 定义和声明 上一页 2. 定义和声明 第 20 章 链接详解 下一页 2. 定义和声明 2.1. extern和static关键字 在上一节我们把两个程序文件放在一起编译链接,main.c用到的函 数push、pop和is_empty由stack.c提供,其实有一点小问题,我们用-Wall选项编译main.c可以 看到: $ gcc -c main.c -Wall main.c: In function ‘main’: main.c:8: warning: implicit declaration of function ‘push’ main.c:12: warning: implicit declaration of function ‘is_empty’ main.c:13: warning: implicit declaration of function ‘pop’ 这个问题我们在第 2 节 “自定义函数”讨论过,由于编译器在处理函数调用代码时没有找到函数 原型,只好根据函数调用代码做隐式声明,把这三个函数声明为: int push(char); int pop(void); int is_empty(void); 现在你应该比学第 2 节 “自定义函数”的时候更容易理解这条规则了。为什么编译器在处理函数 调用代码时需要有函数原型?因为必须知道参数的类型和个数以及返回值的类型才知道生成什 么样的指令。为什么隐式声明靠不住呢?因为隐式声明是从函数调用代码推导而来的,而事实 上函数定义的形参类型可能跟函数调用代码传的实参类型并不一致,如果函数定义带有可变参 数(例如printf),那么从函数调用代码也看不出来这个函数带有可变参数,另外,从函数调 用代码也看不出来返回值应该是什么类型,所以隐式声明只能规定返回值都是int型的。既然隐 式声明靠不住,那编译器为什么不自己去找函数定义,而非要让我们在调用之前写函数原型 呢?因为编译器往往不知道去哪里找函数定义,像上面的例子,我让编译器编译main.c,而这 几个函数的定义却在stack.c里,编译器又怎么会知道呢?所以编译器只能通过隐式声明来猜测 函数原型,这种猜测往往会出错,但在比较简单的情况下还算可用,比如上一节的例子这么编 译过去了也能得到正确结果。 现在我们在main.c中声明这几个函数的原型: /* main.c */ #include extern void push(char); extern char pop(void); extern int is_empty(void); int main(void) { push('a'); push('b'); push('c'); while(!is_empty()) putchar(pop()); putchar('\n'); return 0; } 这样编译器就不会报警告了。在这里extern关键字表示这个标识符具有External Linkage。External Linkage的定义在上一章讲过,但现在应该更容易理解了,push这个标识符 具有External Linkage指的是:如果把main.c和stack.c链接在一起,如 果push在main.c和stack.c中都有声明(在stack.c中的声明同时也是定义),那么这些声明指的 是同一个函数,链接之后是同一个GLOBAL符号,代表同一个地址。 函数声明中的extern也可以省略不写,不写extern仍然表示这个函数名具有External Linkage。C语言不允许嵌套定义函数[29],但如果只是声明而不定义,这种声明是允许写在函数 体里面的,这样声明的标识符具有块作用域,例如上面的main.c也可以写成: /* main.c */ #include int main(void) { void push(char); char pop(void); int is_empty(void); push('a'); push('b'); push('c'); while(!is_empty()) putchar(pop()); putchar('\n'); return 0; } 如果用static关键字修饰一个函数声明,则表示该标识符具有Internal Linkage,例如有以下两 个程序文件: /* foo.c */ static void foo(void) {} /* main.c */ void foo(void); int main(void) { foo(); return 0; } 编译链接在一起会出错: $ gcc foo.c main.c /tmp/ccRC2Yjn.o: In function `main': main.c:(.text+0x12): undefined reference to `foo' collect2: ld returned 1 exit status 虽然在foo.c中定义了函数foo,但这个函数只具有Internal Linkage,只有在foo.c中多次声明才 表示同一个函数,而在main.c中声明就不表示它了。如果把foo.c编译成目标文件,函数 名foo在其中是一个LOCAL的符号,不参与链接过程,所以在链接时,main.c中用到一个External Linkage的foo函数,链接器却找不到它的定义在哪儿,无法确定它的地址,也就无法做符号解 析,只好报错。凡是被多次声明的变量或函数,必须有且只有一个声明是定义,如果有多个定 义,或者一个定义都没有,链接器就无法完成链接。 以上讲了用static和extern修饰函数声明的情况。现在来看用它们修饰变量声明的情况。仍然 用stack.c和main.c的例子,如果我想在main.c中直接访问stack.c中定义的变量top,则可以 用extern声明它: /* main.c */ #include int main(void) { void push(char); char pop(void); int is_empty(void); extern int top; push('a'); push('b'); push('c'); printf("%d\n", top); while(!is_empty()) putchar(pop()); putchar('\n'); printf("%d\n", top); return 0; } 变量top具有External Linkage,它的存储空间是在stack.c中分配的,所以main.c中的变量声 明extern int top;不是变量定义,因为它不分配存储空间。和函数声明类似,变量声明既可以 是块作用域的也可以是文件作用域的,上面的例子把声明写在main函数体里面,top这个标识符 具有块作用域。注意,变量声明和函数声明有一点不同,函数声明的extern可写可不写,而变 量声明如果不写extern意思就完全变了,如果上面的例子不写extern就表示在main函数中定义一 个局部变量top。另外要注意,stack.c中的定义是int top = -1;,而main.c中的声明就不能 加Initializer了,如果上面的例子写成extern int top = -1;则编译器会报错。 在main.c中可以通过变量声明来访问stack.c中的变量top,但是从实现stack.c这个模块的角度 来看,top这个变量是不希望被外界访问到的,变量top和stack都属于这个模块的内部状态,外 界应该只允许通过push和pop函数来改变模块的内部状态,这样才能保证堆栈的LIFO特性,如果 外界可以随机访问stack或者随便修改top,那么堆栈的状态就乱了。那怎么才能阻止外界访 问top和stack呢?答案就是用static关键字把它们声明为Internal Linkage的: /* stack.c */ static char stack[512]; static int top = -1; void push(char c) { stack[++top] = c; } char pop(void) { return stack[top--]; } int is_empty(void) { return top == -1; } 这样,即使在main.c中用extern声明也访问不到stack.c的变量top和stack。从而保护 了stack.c模块的内部状态,这也是一种封装(Encapsulation)的思想。 用static关键字声明具有Internal Linkage的函数也是出于这个目的。在一个模块中,有些函数 是提供给外界使用的,也称为导出(Export)给外界使用,这些函数声明为External Linkage的。有些函数只在模块内部使用而不希望被外界访问到,则声明为Internal Linkage的。 2.2. 头文件 我们继续前面关于stack.c和main.c的讨论。stack.c这个模块封装了top和stack两个变量,导出 了push、pop、is_empty三个函数接口,已经设计得比较完善了。但是使用这个模块的每个程序 文件都要写三个函数声明也是很麻烦的,假设又有一个foo.c也使用这个模 块,main.c和foo.c中各自要写三个函数声明。重复的代码总是应该尽量避免的,以前我们通过 各种办法把重复的代码提取出来,比如在第 2 节 “数组应用实例:统计随机数”讲过用宏定义避 免硬编码的问题,这次有什么办法呢?答案就是可以自己写一个头文件stack.h: /* stack.h */ #ifndef STACK_H #define STACK_H extern void push(char); extern char pop(void); extern int is_empty(void); #endif 这样在main.c中只需包含这个头文件就可以了,而不需要写三个函数声明: /* main.c */ #include #include "stack.h" int main(void) { push('a'); push('b'); push('c'); while(!is_empty()) putchar(pop()); putchar('\n'); return 0; } 首先说为什么#include 用角括号,而#include "stack.h"用引号。对于用角括号包含 的头文件,gcc首先查找-I选项指定的目录,然后查找系统的头文件目录(通常 是/usr/include,在我的系统上还包括/usr/lib/gcc/i486-linux -gnu/4.3.2/include);而对 于用引号包含的头文件,gcc首先查找包含头文件的.c文件所在的目录,然后查找-I选项指定的 目录,然后查找系统的头文件目录。 假如三个代码文件都放在当前目录下: $ tree . |-- main.c |-- stack.c `-- stack.h 0 directories, 3 files 则可以用gcc -c main.c编译,gcc会自动在main.c所在的目录中找到stack.h。假如把stack.h移 到一个子目录下: $ tree . |-- main.c `-- stack |-- stack.c `-- stack.h 1 directory, 3 files 则需要用gcc -c main.c -Istack编译。用-I选项告诉gcc头文件要到子目录stack里找。 在#include预处理指示中可以使用相对路径,例如把上面的代码改成#include "stack/stack.h",那么编译时就不需要加-Istack选项了,因为gcc会自动在main.c所在的目录 中查找,而头文件相对于main.c所在目录的相对路径正是stack/stack.h。 在stack.h中我们又看到两个新的预处理指示#ifndef STACK_H和#endif,意思是说,如 果STACK_H这个宏没有定义过,那么从#ifndef到#endif之间的代码就包含在预处理的输出结果 中,否则这一段代码就不出现在预处理的输出结果中。stack.h这个头文件的内容整个 被#ifndef和#endif括起来了,如果在包含这个头文件时STACK_H这个宏已经定义过了,则相当于 这个头文件里什么都没有,包含了一个空文件。这有什么用呢?假如main.c包含了两 次stack.h: ...... #include "stack.h" #include "stack.h" int main(void) { ...... 则第一次包含stack.h时并没有定义STACK_H这个宏,因此头文件的内容包含在预处理的输出结果 中: ...... #define STACK_H extern void push(char); extern char pop(void); extern int is_empty(void); #include "stack.h" int main(void) { ...... 其中已经定义了STACK_H这个宏,因此第二次再包含stack.h就相当于包含了一个空文件,这就避 免了头文件的内容被重复包含。注意这里的宏定义和我们以前使用的宏定义有点不同,例 如#define N 20将N定义为20,在预处理时把代码中所有的标识符N替换成20,而#define STACK_H把STACK_H定义为空,在预处理时把代码中所有的标识符STACK_H替换成空。当然 把STACK_H定义成别的也不是不可以,只是对于保护头文件这种用法来说定义成空就足够了。这 种保护头文件的用法称为Header Guard,以后我们每写一个头文件都要加上Header Guard,宏 定义名就用头文件名的大写形式,这是规范的做法。 那为什么需要防止重复包含呢?谁会把一个头文件包含两次呢?像上面那么明显的错误没人会 犯,但有时候重复包含的错误并不是那么明显的。比如: #include "stack.h" #include "foo.h" 然而foo.h里又包含了bar.h,bar.h里又包含了stack.h。在规模较大的项目中头文件包含头文 件的情况很常见,经常会包含四五层,这时候重复包含的问题就很难发现了。比如在我的系统 头文件目录/usr/include中,errno.h包含了bits/errno.h,后者又包含了linux/errno.h,后者 又包含了asm/errno.h,后者又包含了asm -generic/errno.h 。 另外一个问题是,就算我是重复包含了头文件,那有什么危害么?像上面的三个函数声明,在 程序中声明两次也没有问题,对于具有External Linkage的函数,声明任意多次也都代表同一个 函数。重复包含头文件有以下问题: 1. 一是使预处理的速度变慢了,要处理很多本来不需要处理的头文件。 2. 二是如果有foo.h包含bar.h,bar.h又包含foo.h的情况,预处理器就陷入死循环了(其实 编译器都会规定一个包含层数的上限)。 3. 三是头文件里有些代码不允许重复出现,虽然变量和函数允许多次声明(只要不是多次定 义就行),但头文件里有些代码是不允许多次出现的,比如typedef类型定义和结构 体Tag定义等,在一个程序文件中只允许出现一次。 还有一个问题,既然要#include头文件,那我不如直接在main.c中#include "stack.c"得了。这 样把stack.c和main.c合并为同一个程序文件,相当于又回到最初的例 12.1 “用堆栈实现倒序打 印”了。当然这样也能编译通过,但是在一个规模较大的项目中不能这么做,假如又有一 个foo.c也要使用stack.c这个模块怎么办呢?如果在foo.c里面也#include "stack.c",就相当 于push 、pop 、is_empty 这三个函数在main.c和foo.c 中都有定义,那么main.c和foo.c 就不能链 接在一起了。如果采用包含头文件的办法,那么这三个函数只在stack.c中定义了一次,最后可 以把main.c、stack.c、foo.c链接在一起。如下图所示: 图 20.2. 为什么要包含头文件而不是.c文件 同样道理,头文件中的变量和函数声明一定不能是定义。如果头文件中出现变量或函数定义, 这个头文件又被多个.c文件包含,那么这些.c文件就不能链接在一起了。 2.3. 定义和声明的详细规则 以上两节关于定义和声明只介绍了最基本的规则,在写代码时掌握这些基本规则就够用了,但 其实C语言关于定义和声明还有很多复杂的规则,在分析错误原因或者维护规模较大的项目时需 要了解这些规则。本节的两个表格出自[Standard C]。 首先看关于函数声明的规则。 表 20.1. Storage Class关键字对函数声明的作用 Storage Class File Scope Declaration Block Scope Declaration none previous linkage can define previous linkage cannot define extern static previous linkage can define internal linkage can define previous linkage cannot define N/A 以前我们说“extern关键字表示这个标识符具有External Linkage”其实是不准确的,准确地说应 该是Previous Linkage。Previous Linkage的定义是:这次声明的标识符具有什么样 的Linkage取决于前一次声明,这前一次声明具有相同的标识符名,而且必须是文件作用域的声 明,如果在程序文件中找不到前一次声明(这次声明是第一次声明),那么这个标识符具 有External Linkage。例如在一个程序文件中在文件作用域两次声明同一个函数: static int f(void); /* internal linkage */ extern int f(void); /* previous linkage */ 则这里的extern修饰的标识符具有Interanl Linkage而不是External Linkage。从上表可以看出我 们前面所说的“函数声明加不加extern关键字都一样”,也可以看出在文件作用域允许定义函数, 在块作用域不允许定义函数,或者说函数定义不能嵌套。另外,在块作用域中不允许 用static关键字声明函数。 关于变量声明的规则要复杂一些: 表 20.2. Storage Class关键字对变量声明的作用 Storage Class File Scope Declaration Block Scope Declaration none external linkage static duration static initializer tentative definition no linkage automatic duration dynamic initializer definition extern previous linkage static duration no initializer[*] not a definition previous linkage static duration no initializer not a definition static internal linkage static duration static initializer tentative definition no linkage static duration static initializer definition 上表的每个单元格里分成四行,分别描述变量的链接属性、生存期,以及这种变量如何初始 化,是否算变量定义。链接属性有External Linkage、Internal Linkage、No Linkage和Previous Linkage四种情况,生存期有Static Duration和Automatic Duration两种情况,请参考本章和上一 章的定义。初始化有Static Initializer和Dynamic Initializer两种情况,前者表示Initializer中只能 使用常量表达式,表达式的值必须在编译时就能确定,后者表示Initializer中可以使用任意的右 值表达式,表达式的值可以在运行时计算。是否算变量定义有三种情况,Definition(算变量定 义)、Not a Definition(不算变量定义)和Tentative Definition(暂定的变量定义)。什么叫“暂 定的变量定义”呢?一个变量声明具有文件作用域,没有Storage Class关键字修饰,或者 用static关键字修饰,那么如果它有Initializer则编译器认为它就是一个变量定义,如果它没 有Initializer则编译器暂定它是变量定义,如果程序文件中有这个变量的明确定义就用明确定 义,如果程序文件没有这个变量的明确定义,就用这个暂定的变量定义[30],这种情况下变量 以0初始化。在[C99]中有一个例子: int i1 = 1; // definition, external linkage static int i2 = 2; // definition, internal linkage extern int i3 = 3; // definition, external linkage int i4; // tentative definition, external linkage static int i5; // tentative definition, internal linkage int i1; // valid tentative definition, refers to previous int i2; // 6.2.2 renders undefined, linkage disagreement int i3; // valid tentative definition, refers to previous int i4; // valid tentative definition, refers to previous int i5; // 6.2.2 renders undefined, linkage disagreement extern int i1; // refers to previous, whose linkage is external extern int i2; // refers to previous, whose linkage is internal extern int i3; // refers to previous, whose linkage is external extern int i4; // refers to previous, whose linkage is external extern int i5; // refers to previous, whose linkage is internal 变量i2和i5第一次声明为Internal Linkage,第二次又声明为External Linkage,这是不允许的, 编译器会报错。注意上表中标有[*]的单元格,对于文件作用域的extern变量声明,C99是允许 带Initializer的,并且认为它是一个定义,但是gcc对于这种写法会报警告,为了兼容性应避免这 种写法。 [29] 但gcc的扩展特性允许嵌套定义函数,本书不讨论这种特性。 [30] 由于本书没有提及将不完全类型进行组合的问题,所以这条规则被我简化了,真正的规则还 要复杂一些。读者可以参考C99中有关Incomplete Type和Composite Type的条款。Tentative Definition的完整定义在C99的6.9.2节条款2。 上一页 1. 多目标文件的链接 上一级 起始页 下一页 3. 静态库 上一页 3. 静态库 第 20 章 链接详解 下一页 3. 静态库 有时候需要把一组代码编译成一个库,这个库在很多项目中都要用到,例如libc就是这样一个 库,我们在不同的程序中都会用到libc中的库函数(例如printf),也会用到libc中的变量 (例如以后要讲到的environ变量)。本节介绍怎么创建这样一个库。 我们继续用stack.c的例子。为了便于理解,我们把stack.c拆成四个程序文件(虽然实际上没太 大必要),把main.c改得简单一些,头文件stack.h不变,本节用到的代码如下所示: /* stack.c */ char stack[512]; int top = -1; /* push.c */ extern char stack[512]; extern int top; void push(char c) { stack[++top] = c; } /* pop.c */ extern char stack[512]; extern int top; char pop(void) { return stack[top--]; } /* is_empty.c */ extern int top; int is_empty(void) { return top == -1; } /* stack.h */ #ifndef STACK_H #define STACK_H extern void push(char); extern char pop(void); extern int is_empty(void); #endif /* main.c */ #include #include "stack.h" int main(void) { push('a'); return 0; } 这些文件的目录结构是: $ tree . |-- main.c `-- stack |-- is_empty.c |-- pop.c |-- push.c |-- stack.c `-- stack.h 1 directory, 6 files 我们把stack.c、push.c、pop.c 、is_empty.c 编译成目标文件: $ gcc -c stack/stack.c stack/push.c stack/pop.c stack/is_empty.c 然后打包成一个静态库libstack.a: $ ar rs libstack.a stack.o push.o pop.o is_empty.o ar: creating libstack.a 库文件名都是以lib开头的,静态库以.a作为后缀,表示Archive。ar命令类似于tar命令,起一 个打包的作用,但是把目标文件打包成静态库只能用ar命令而不能用tar命令。选项r表示将后 面的文件列表添加到文件包,如果文件包不存在就创建它,如果文件包中已有同名文件就替换 成新的。s是专用于生成静态库的,表示为静态库创建索引,这个索引被链接器使用。ranlib命 令也可以为静态库创建索引,以上命令等价于: $ ar r libstack.a stack.o push.o pop.o is_empty.o $ ranlib libstack.a 然后我们把libstack.a和main.c编译链接在一起: $ gcc main.c -L. -lstack -Istack -o main -L选项告诉编译器去哪里找需要的库文件,-L.表示在当前目录找。-lstack告诉编译器要链 接libstack库,-I选项告诉编译器去哪里找头文件。注意,即使库文件就在当前目录,编译器 默认也不会去找的,所以-L.选项不能少。编译器默认会找的目录可以用-print-search-dirs选 项查看: $ gcc -print-search-dirs install: /usr/lib/gcc/i486-linux-gnu/4.3.2/ programs: =/usr/lib/gcc/i486-linux-gnu/4.3.2/:/usr/lib/gcc/i486linux - gnu/4.3.2/:/usr/lib/gcc/i486 - linux - gnu/:/usr/lib/gcc/i486linux - gnu/4.3.2/:/usr/lib/gcc/i486 - linux gnu/:/usr/libexec/gcc/i486 - linux gnu/4.3.2/:/usr/libexec/gcc/i486- linux - gnu/:/usr/lib/gcc/i486linux - gnu/4.3.2/:/usr/lib/gcc/i486 - linux - gnu/:/usr/lib/gcc/i486linux - gnu/4.3.2/../../../../i486 - linux - gnu/bin/i486- linux gnu/4.3.2/:/usr/lib/gcc/i486 - linux - gnu/4.3.2/../../../../i486 linux - gnu/bin/ libraries: =/usr/lib/gcc/i486-linux-gnu/4.3.2/:/usr/lib/gcc/i486linux - gnu/4.3.2/:/usr/lib/gcc/i486 - linux gnu/4.3.2/../../../../i486 - linux - gnu/lib/i486- linux gnu/4.3.2/:/usr/lib/gcc/i486 - linux - gnu/4.3.2/../../../../i486 linux - gnu/lib/../lib/:/usr/lib/gcc/i486 - linux - gnu/4.3.2/../../../i486 - linux - gnu/4.3.2/:/usr/lib/gcc/i486 - linux gnu/4.3.2/../../../../lib/:/lib/i486 - linux gnu/4.3.2/:/lib/../lib/:/usr/lib/i486- linux gnu/4.3.2/:/usr/lib/../lib/:/usr/lib/gcc/i486 - linux gnu/4.3.2/../../../../i486 - linux - gnu/lib/:/usr/lib/gcc/i486 linux - gnu/4.3.2/../../../:/lib/:/usr/lib/ 其中的libraries就是库文件的搜索路径列表,各路径之间用:号隔开。编译器会在这些搜索路 径以及-L选项指定的路径中查找用-l选项指定的库,比如-lstack,编译器会首先找有没有共享 库libstack.so,如果有就链接它,如果没有就找有没有静态库libstack.a,如果有就链接它。 所以编译器是优先考虑共享库的,如果希望编译器只链接静态库,可以指定-static选项。 那么链接共享库和链接静态库有什么区别呢?在第 2 节 “main函数和启动例程”讲过,在链 接libc共享库时只是指定了动态链接器和该程序所需要的库文件,并没有真的做链接,可执行 文件main中调用的libc库函数仍然是未定义符号,要在运行时做动态链接。而在链接静态库 时,链接器会把静态库中的目标文件取出来和可执行文件真正链接在一起。我们通过反汇编看 上一步生成的可执行文件main: $ objdump -d main ...... 08048394
    : 8048394: 8d 4c 24 04 8048398: 83 e4 f0 804839b: ff 71 fc ...... 080483c0 : 80483c0: 55 80483c1: 89 e5 80483c3: 83 ec 04 lea 0x4(%esp),%ecx and $0xfffffff0,%esp pushl -0x4(%ecx) push mov sub %ebp %esp,%ebp $0x4,%esp 有意思的是,main.c只调用了push这一个函数,所以链接生成的可执行文件中也只有push而没 有pop和is_empty。这是使用静态库的一个好处,链接器可以从静态库中只取出需要的部分来做 链接。如果是直接把那些目标文件和main.c编译链接在一起: $ gcc main.c stack.o push.o pop.o is_empty.o -Istack -o main 则没有用到的函数也会链接进来。当然另一个好处就是使用静态库只需写一个库文件名,而不 需要写一长串目标文件名。 上一页 2. 定义和声明 上一级 起始页 下一页 4. 共享库 上一页 4. 共享库 第 20 章 链接详解 下一页 4. 共享库 4.1. 编译、链接、运行 组成共享库的目标文件和一般的目标文件有所不同,在编译时要加-fPIC选项,例如: $ gcc -c -fPIC stack/stack.c stack/push.c stack/pop.c stack/is_empty.c -f后面跟一些编译选项,PIC是其中一种,表示生成位置无关代码(Position Independent Code)。那么用-fPIC生成的目标文件和一般的目标文件有什么不同呢?下面分析这个问题。 我们知道一般的目标文件称为Relocatable,在链接时可以把目标文件中各段的地址做重定位, 重定位时需要修改指令。我们先不加-fPIC选项编译生成目标文件: $ gcc -c -g stack/stack.c stack/push.c stack/pop.c stack/is_empty.c 由于接下来要用objdump -dS把反汇编指令和源代码穿插起来分析,所以用-g选项加调试信息。 注意,加调试信息必须在编译每个目标文件时用-g选项,而不能只在最后编译生成可执行文件 时用-g选项。反汇编查看push.o: $ objdump -dS push.o push.o: file format elf32-i386 Disassembly of section .text: 00000000 : /* push.c */ extern char stack[512]; extern int top; void push(char c) { 0: 55 1: 89 e5 3: 83 ec 04 6: 8b 45 08 9: 88 45 fc stack[++top] = c; c: a1 00 00 00 00 11: 83 c0 01 14: a3 00 00 00 00 19: 8b 15 00 00 00 00 1f: 0f b6 45 fc 23: 88 82 00 00 00 00 } 29: c9 2a: c3 push mov sub mov mov %ebp %esp,%ebp $0x4,%esp 0x8(%ebp),%eax %al,-0x4(%ebp) mov 0x0,%eax add $0x1,%eax mov %eax,0x0 mov 0x0,%edx movzbl -0x4(%ebp),%eax mov %al,0x0(%edx) leave ret 指令中凡是用到stack和top的地址都用0x0表示,准备在重定位时修改。再看readelf输出 的.rel.text段的信息: Relocation section '.rel.text' at offset 0x848 contains 4 entries: Offset Info Type Sym.Value Sym. Name 0000000d 00001001 R_386_32 00000000 top 00000015 00001001 R_386_32 00000000 top 0000001b 00001001 R_386_32 00000000 top 00000025 00001101 R_386_32 00000000 stack 标出了指令中有四处需要在重定位时修改。下面编译链接成可执行文件之后再做反汇编分析: $ gcc -g main.c stack.o push.o pop.o is_empty.o -Istack -o main $ objdump -dS main ...... 080483c0 : /* push.c */ extern char stack[512]; extern int top; void push(char c) { 80483c0: 55 80483c1: 89 e5 80483c3: 83 ec 04 80483c6: 8b 45 08 80483c9: 88 45 fc stack[++top] = c; 80483cc: a1 10 a0 04 08 80483d1: 83 c0 01 80483d4: a3 10 a0 04 08 80483d9: 8b 15 10 a0 04 08 80483df: 0f b6 45 fc 80483e3: 88 82 40 a0 04 08 } 80483e9: c9 80483ea: c3 80483eb: 90 ...... push mov sub mov mov %ebp %esp,%ebp $0x4,%esp 0x8(%ebp),%eax %al,-0x4(%ebp) mov 0x804a010,%eax add $0x1,%eax mov %eax,0x804a010 mov 0x804a010,%edx movzbl -0x4(%ebp),%eax mov %al,0x804a040(%edx) leave ret nop 原来指令中的0x0被修改成了0x804a010和0x804a040,这样做了重定位之后,各段的加载地址 就定死了,因为在指令中使用了绝对地址。 现在看用-fPIC编译生成的目标文件有什么不同: $ gcc -c -g -fPIC stack/stack.c stack/push.c stack/pop.c stack/is_empty.c $ objdump -dS push.o push.o: file format elf32-i386 Disassembly of section .text: 00000000 : /* push.c */ extern char stack[512]; extern int top; void push(char c) { 0: 55 1: 89 e5 3: 53 4: 83 ec 04 7: e8 fc ff ff ff c: 81 c3 02 00 00 00 12: 8b 45 08 15: 88 45 f8 stack[++top] = c; push mov push sub call add mov mov %ebp %esp,%ebp %ebx $0x4,%esp 8 $0x2,%ebx 0x8(%ebp),%eax %al,-0x8(%ebp) 18: 1e: 20: 23: 29: 2b: 31: 33: 39: 3d: } 40: 43: 44: 45: 8b 83 00 00 00 00 8b 00 8d 50 01 8b 83 00 00 00 00 89 10 8b 83 00 00 00 00 8b 08 8b 93 00 00 00 00 0f b6 45 f8 88 04 0a 83 c4 04 5b 5d c3 mov 0x0(%ebx),%eax mov (%eax),%eax lea 0x1(%eax),%edx mov 0x0(%ebx),%eax mov %edx,(%eax) mov 0x0(%ebx),%eax mov (%eax),%ecx mov 0x0(%ebx),%edx movzbl -0x8(%ebp),%eax mov %al,(%edx,%ecx,1) add $0x4,%esp pop %ebx pop %ebp ret Disassembly of section .text.__i686.get_pc_thunk.bx: 00000000 <__i686.get_pc_thunk.bx>: 0: 8b 1c 24 mov 3: c3 ret (%esp),%ebx 指令中用到的stack和top的地址不再以0x0表示,而是以0x0(%ebx)表示,但其中还是留有0x0准 备做进一步修改。再看readelf输出的.rel.text段: Relocation section '.rel.text' at offset 0x94c contains 6 entries: Offset Info Type Sym.Value Sym. Name 00000008 00001202 R_386_PC32 00000000 __i686.get_pc_thunk.bx 0000000e 0000130a R_386_GOTPC 00000000 _GLOBAL_OFFSET_TABLE_ 0000001a 00001403 R_386_GOT32 00000000 top 00000025 00001403 R_386_GOT32 00000000 top 0000002d 00001403 R_386_GOT32 00000000 top 00000035 00001503 R_386_GOT32 00000000 stack top和stack对应的记录类型不再是R_386_32了,而是R_386_GOT32,有什么区别呢?我们先编译 生成共享库再做反汇编分析: $ gcc -shared -o libstack.so stack.o push.o pop.o is_empty.o $ objdump -dS libstack.so ...... 0000047c : /* push.c */ extern char stack[512]; extern int top; void push(char c) { 47c: 55 47d: 89 e5 47f: 53 480: 83 ec 04 483: e8 ef ff ff ff 488: 81 c3 6c 1b 00 00 48e: 8b 45 08 491: 88 45 f8 stack[++top] = c; 494: 8b 83 f4 ff ff ff 49a: 8b 00 49c: 8d 50 01 49f: 8b 83 f4 ff ff ff 4a5: 89 10 4a7: 8b 83 f4 ff ff ff 4ad: 8b 08 4af: 8b 93 f8 ff ff ff 4b5: 0f b6 45 f8 4b9: 88 04 0a } push mov push sub call add mov mov %ebp %esp,%ebp %ebx $0x4,%esp 477 <__i686.get_pc_thunk.bx> $0x1b6c,%ebx 0x8(%ebp),%eax %al,-0x8(%ebp) mov -0xc(%ebx),%eax mov (%eax),%eax lea 0x1(%eax),%edx mov -0xc(%ebx),%eax mov %edx,(%eax) mov -0xc(%ebx),%eax mov (%eax),%ecx mov -0x8(%ebx),%edx movzbl -0x8(%ebp),%eax mov %al,(%edx,%ecx,1) 4bc: 4bf: 4c0: 4c1: 4c2: 4c3: ...... 83 c4 04 5b 5d c3 90 90 add $0x4,%esp pop %ebx pop %ebp ret nop nop 和先前的结果不同,指令中的0x0(%ebx)被修改成-0xc(%ebx)和-0x8(%ebx),而不是修改成绝对 地址。所以共享库各段的加载地址并没有定死,可以加载到任意位置,因为指令中没有使用绝 对地址,因此称为位置无关代码。另外,注意这几条指令: 494: 49a: 49c: 8b 83 f4 ff ff ff 8b 00 8d 50 01 mov mov lea -0xc(%ebx),%eax (%eax),%eax 0x1(%eax),%edx 和先前的指令对比一下: 80483cc: 80483d1: a1 10 a0 04 08 83 c0 01 mov 0x804a010,%eax add $0x1,%eax 可以发现,-0xc(%ebx)这个地址并不是变量top的地址,这个地址的内存单元中又保存了另外一 个地址,这另外一个地址才是变量top的地址,所以mov -0xc(%ebx),%eax是把变量top的地址传 给eax,而mov (%eax),%eax才是从top的地址中取出top的值传给eax。lea 0x1(%eax),%edx是 把top的值加1存到edx中,如下图所示: 图 20.3. 间接寻址 top和stack的绝对地址保存在一个地址表中,而指令通过地址表做间接寻址,因此避免了将绝 对地址写死在指令中,这也是一种避免硬编码的策略。 现在把main.c和共享库编译链接在一起,然后运行: $ gcc main.c -g -L. -lstack -Istack -o main $ ./main ./main: error while loading shared libraries: libstack.so: cannot open shared object file: No such file or directory 结果出乎意料,编译的时候没问题,由于指定了-L.选项,编译器可以在当前目录下找 到libstack.so,而运行时却说找不到libstack.so。那么运行时在哪些路径下找共享库呢?我们 先用ldd命令查看可执行文件依赖于哪些共享库: $ ldd main linux-gate.so.1 => (0xb7f5c000) libstack.so => not found libc.so.6 => /lib/tls/i686/cmov/libc.so.6 (0xb7dcf000) /lib/ld-linux.so.2 (0xb7f42000) ldd模拟运行一遍main,在运行过程中做动态链接,从而得知这个可执行文件依赖于哪些共享 库,每个共享库都在什么路径下,加载到进程地址空间的什么地址。/lib/ld-linux.so.2是动态 链接器,它的路径是在编译链接时指定的,我们在第 2 节 “main函数和启动例程”讲过gcc在做链 接时用-dynamic-linker指定动态链接器的路径,它也像其它共享库一样加载到进程的地址空间 中。libc.so.6 的路径/lib/tls/i686/cmov/libc.so.6 是由动态链接器ld-linux.so.2 在做动态链 接时搜索到的,而libstack.so的路径没有找到。linux-gate.so.1这个共享库其实并不存在于文 件系统中,它是由内核虚拟出来的共享库,所以它没有对应的路径,它负责处理系统调用。总 之,共享库的搜索路径由动态链接器决定,从ld.so(8)Man Page可以查到共享库路径的搜索顺 序: 1. 首先在环境变量LD_LIBRARY_PATH所记录的路径中查找。 2. 然后从缓存文件/etc/ld.so.cache中查找。这个缓存文件由ldconfig命令读取配置文 件/etc/ld.so.conf之后生成,稍后详细解释。 3. 如果上述步骤都找不到,则到默认的系统路径中查找,先是/usr/lib然后是/lib。 Man Page的Section ld.so(8)后面的(8)表示这个页面位于Man Page的 第8个Section。可以用如下命令查看: $ man 8 ld.so 用man ld.so命令也可以查看它,因为其它Section没有和它重名 的Man Page。但有些Man Page是有重名的,比如man printf看 到的并不是C函数printf,而是系统命令printf(1),要查 看printf函数的Man Page应该用man 3 printf,因为它位于 第3个Section。FHS(Filesystem Hierarchy Standard)标准规定 了Man Page各Section的含义如下: 表 20.3. Man Page的Section Section 描述 1 用户命令,例如ls(1) 2 系统调用,例如_exit(2) 3 库函数,例如printf(3) 4 特殊文件,例如null(4)描述了设备文 件/dev/null、/dev/zero的作用 5 系统配置文件的格式,例如passwd(5)描述 了系统配置文件/etc/passwd的格式 6 游戏 7 其它杂项,例如bash-builtins(7)描述 了bash的各种内建命令 8 系统管理命令,例如ifconfig(8) 注意区分用户命令和系统管理命令,用户命令通常位 于/bin 和/usr/bin ,系统管理命令通常位于/sbin 和/usr/sbin ,任 何用户都可以执行用户命令,而执行系统管理命令经常需 要root权限。 还要注意区分系统调用和库函数,第2个Section的函数只是简单 包装了一下int $0x80指令,调用这些函数将进入内核执行。 第3个Section的函数有些完全在用户模式执行,例如以后要讲 的strcpy(3),有些则调用第2个Section的函数完成它的工作,例 如在第 2 节 “main函数和启动例程”讲过,exit(3)首先在用户模式 做一些清理工作,然后调用_exit(2)进内核终止当前进程。 先试试第一种方法,在运行main时通过环境变量LD_LIBRARY_PATH把当前目录添加到共享库的搜 索路径: $ LD_LIBRARY_PATH=. ./main 这种方法只适合在开发中临时用一下,通常LD_LIBRARY_PATH是不推荐使用的,尽量不要设置这 个环境变量,理由可以参考Why LD_LIBRARY_PATH is bad(http://www.visi.com/~barr/ldpath.html)。 再试试第二种方法,这是最常用的方法。把libstack.so所在目录的绝对路径(比 如/home/akaedu/somedir)添加到/etc/ld.so.conf中(每个路径占一行),然后运 行ldconfig: $ sudo ldconfig -v ...... /home/akaedu/somedir: libstack.so -> libstack.so /lib: libe2p.so.2 -> libe2p.so.2.3 libncursesw.so.5 -> libncursesw.so.5.6 ...... /usr/lib: libkdeinit_klauncher.so -> libkdeinit_klauncher.so libv4l2.so.0 -> libv4l2.so.0 ...... /usr/lib64: /lib/tls: (hwcap: 0x8000000000000000) /usr/lib/sse2: (hwcap: 0x0000000004000000) ...... /usr/lib/tls: (hwcap: 0x8000000000000000) ...... /usr/lib/i686: (hwcap: 0x0008000000000000) /usr/lib/i586: (hwcap: 0x0004000000000000) ...... /usr/lib/i486: (hwcap: 0x0002000000000000) ...... /lib/tls/i686: (hwcap: 0x8008000000000000) /usr/lib/i686/cmov: (hwcap: 0x0008000000008000) ...... /lib/tls/i686/cmov: (hwcap: 0x8008000000008000) ldconfig命令除了处理/etc/ld.so.conf中配置的目录之外,还处理一些默认目录, 如/lib、/usr/lib等,处理之后生成/etc/ld.so.cache缓存文件,动态链接器就从这个缓存中搜 索共享库。hwcap是x86平台的Linux特有的一种机制,系统检测到当前平台是i686而不 是i586或i486,所以在运行程序时使用i686的库,这样可以更好地发挥平台的性能,也可以利 用一些新的指令,所以上面ldd命令的输出结果显示动态链接器搜索到 的libc是/lib/tls/i686/cmov/libc.so.6,而不是/lib/libc.so.6。现在再用ldd命令查 看,libstack.so就能找到了: $ ldd main linux-gate.so.1 => (0xb809c000) libstack.so => /home/akaedu/somedir/libstack.so (0xb806a000) libc.so.6 => /lib/tls/i686/cmov/libc.so.6 (0xb7f0c000) /lib/ld-linux.so.2 (0xb8082000) 第三种方法就是把libstack.so拷到/usr/lib或/lib目录,这样可以确保动态链接器能找到这个 共享库。 其实还有第四种方法,在编译可执行文件main的时候就把libstack.so的路径写死在可执行文件 中: $ gcc main.c -g -L. -lstack -Istack -o main -Wl,rpath,/home/akaedu/somedir -Wl,-rpath,/home/akaedu/somedir表示-rpath /home/akaedu/somedir是由gcc传递给链接器的 选项。可以看到readelf的结果多了一条rpath记录: $ readelf -a main ...... Dynamic section at offset 0xf10 contains 23 entries: Tag Type Name/Value 0x00000001 (NEEDED) Shared library: [libstack.so] 0x00000001 (NEEDED) Shared library: [libc.so.6] 0x0000000f (RPATH) Library rpath: [/home/akaedu/somedir] ...... 还可以看出,可执行文件运行时需要哪些共享库也都记录在.dynamic段中。当然rpath这种办法 也是不推荐的,把共享库的路径定死了,失去了灵活性。 4.2. 动态链接的过程 本节研究一下在main.c中调用共享库的函数push是如何实现的。首先反汇编看一下main的指 令: $ objdump -dS main ...... Disassembly of section .plt: 080483a8 <__gmon_start__@plt-0x10>: 80483a8: ff 35 f8 9f 04 08 80483ae: ff 25 fc 9f 04 08 80483b4: 00 00 ...... 080483d8 : 80483d8: ff 25 08 a0 04 08 80483de: 68 10 00 00 00 80483e3: e9 c0 ff ff ff pushl 0x8049ff8 jmp *0x8049ffc add %al,(%eax) jmp push jmp *0x804a008 $0x10 80483a8 <_init+0x30> Disassembly of section .text: ...... 080484a4
    : /* main.c */ #include #include "stack.h" int main(void) { 80484a4: 8d 4c 24 04 80484a8: 83 e4 f0 80484ab: ff 71 fc 80484ae: 55 80484af: 89 e5 80484b1: 51 80484b2: 83 ec 04 push('a'); 80484b5: c7 04 24 61 00 00 00 80484bc: e8 17 ff ff ff ...... lea and pushl push mov push sub 0x4(%esp),%ecx $0xfffffff0,%esp -0x4(%ecx) %ebp %esp,%ebp %ecx $0x4,%esp movl $0x61,(%esp) call 80483d8 和第 3 节 “静态库”链接静态库不同,push函数没有链接到可执行文件中。而且call 80483d8 这条指令调用的也不是push函数的地址。共享库是位置无关代码,在运行时可以加 载到任意地址,其加载地址只有在动态链接时才能确定,所以在main函数中不可能直接通过绝 对地址调用push函数,也是通过间接寻址来找push函数的。对照着上面的指令,我们用gdb跟踪 一下: $ gdb main ...... (gdb) start Breakpoint 1 at 0x80484b5: file main.c, line 7. Starting program: /home/akaedu/somedir/main main () at main.c:7 7 push('a'); (gdb) si 0x080484bc 7 push('a'); (gdb) si 0x080483d8 in push@plt () Current language: auto; currently asm 跳转到.plt段中,现在将要执行一条jmp *0x804a008指令,我们看看0x804a008这个地址里存 的是什么: (gdb) x 0x804a008 0x804a008 <_GLOBAL_OFFSET_TABLE_+20>: 0x080483de 原来就是下一条指令push $0x10的地址。继续跟踪下去: (gdb) si 0x080483de in push@plt () (gdb) si 0x080483e3 in push@plt () (gdb) si 0x080483a8 in ?? () (gdb) si 0x080483ae in ?? () (gdb) si 0xb806a080 in ?? () from /lib/ld-linux.so.2 最终进入了动态链接器/lib/ld-linux.so.2,在其中完成动态链接的过程并调用push函数,我们 不深入这些细节了,直接用finish命令返回到main函数: (gdb) finish Run till exit from #0 0xb806a080 in ?? () from /lib/ld-linux.so.2 main () at main.c:8 8 return 0; Current language: auto; currently c 这时再看看0x804a008这个地址里存的是什么: (gdb) x 0x804a008 0x804a008 <_GLOBAL_OFFSET_TABLE_+20>: (gdb) x 0xb803f47c 0xb803f47c : 0x53e58955 0xb803f47c 动态链接器已经把push函数的地址存在这里了,所以下次再调用push函数就可以直接从jmp *0x804a008指令跳到它的地址,而不必再进入/lib/ld-linux.so.2做动态链接了。 4.3. 共享库的命名惯例 你可能已经注意到了,系统的共享库通常带有符号链接,例如: $ ls -l /lib ...... -rwxr-xr-x 1 root root 1315024 2009-01-09 22:10 libc-2.8.90.so lrwxrwxrwx 1 root root 14 2008-07-04 05:58 libcap.so.1 -> libcap.so.1.10 -rw-r--r-- 1 root root 10316 2007-08-01 03:20 libcap.so.1.10 lrwxrwxrwx 1 root root 14 2008-11-01 08:55 libcap.so.2 -> libcap.so.2.10 -rw-r--r-- 1 root root 13792 2008-06-12 21:39 libcap.so.2.10 ...... lrwxrwxrwx 1 root root 14 2009-01-13 09:28 libc.so.6 -> libc-2.8.90.so ...... $ ls -l /usr/lib/libc.so -rw-r--r-- 1 root root 238 2009-01-09 21:59 /usr/lib/libc.so 按照共享库的命名惯例,每个共享库有三个文件名:real name、soname和linker name。真正 的库文件(而不是符号链接)的名字是real name,包含完整的共享库版本号。例如上面 的libcap.so.1.10、libc-2.8.90.so等。 soname是一个符号链接的名字,只包含共享库的主版本号,主版本号一致即可保证库函数的接 口一致,因此应用程序的.dynamic段只记录共享库的soname,只要soname一致,这个共享库 就可以用。例如上面的libcap.so.1和libcap.so.2是两个主版本号不同的libcap,有些应用程序 依赖于libcap.so.1,有些应用程序依赖于libcap.so.2,但对于依赖libcap.so.1的应用程序来 说,真正的库文件不管是libcap.so.1.10还是libcap.so.1.11都可以用,所以使用共享库可以很 方便地升级库文件而不需要重新编译应用程序,这是静态库所没有的优点。注意libc的版本编 号有一点特殊,libc-2.8.90.so的主版本号是6而不是2或2.8。 linker name仅在编译链接时使用,gcc的-L选项应该指定linker name所在的目录。有的linker name是库文件的一个符号链接,有的linker name是一段链接脚本。例如上面的libc.so就是一 个linker name,它是一段链接脚本: $ cat /usr/lib/libc.so /* GNU ld script Use the shared library, but some functions are only in the static library, so try that secondarily. */ OUTPUT_FORMAT(elf32-i386) GROUP ( /lib/libc.so.6 /usr/lib/libc_nonshared.a AS_NEEDED ( /lib/ld-linux.so.2 ) ) 下面重新编译我们的libstack,指定它的soname: $ gcc -shared -Wl,-soname,libstack.so.1 -o libstack.so.1.0 stack.o push.o pop.o is_empty.o 这样编译生成的库文件是libstack.so.1.0,是real name,但这个库文件中记录了它 的soname是libstack.so.1: $ readelf -a libstack.so.1 ...... Dynamic section at offset 0xf10 contains 22 entries: Tag Type Name/Value 0x00000001 (NEEDED) Shared library: [libc.so.6] 0x0000000e (SONAME) Library soname: [libstack.so.1] ...... 如果把libstack.so.1.0 所在的目录加入/etc/ld.so.conf 中,然后运行ldconfig 命 令,ldconfig会自动创建一个soname的符号链接: $ sudo ldconfig $ ls -l libstack* lrwxrwxrwx 1 root root 15 2009-01-21 17:52 libstack.so.1 -> libstack.so.1.0 -rwxr-xr-x 1 djkings djkings 10142 2009-01-21 17:49 libstack.so.1.0 但这样编译链接main.c却会报错: $ gcc main.c -L. -lstack -Istack -o main /usr/bin/ld: cannot find -lstack collect2: ld returned 1 exit status 注意,要做这个实验,你得把先前编译的libstack共享库、静态库都删掉,如果先前拷 到/lib 或者/usr/lib 下了也删掉,只留下libstack.so.1.0 和libstack.so.1 ,这样你会发现编译 器不认这两个名字,因为编译器只认linker name。可以先创建一个linker name的符号链接,然 后再编译就没问题了: $ ln -s libstack.so.1.0 libstack.so $ gcc main.c -L. -lstack -Istack -o main 上一页 3. 静态库 上一级 起始页 下一页 5. 虚拟内存管理 上一页 5. 虚拟内存管理 第 20 章 链接详解 下一页 5. 虚拟内存管理 我们知道操作系统利用体系结构提供的VA到PA的转换机制实现虚拟内存管理。有了共享库的基 础知识之后,现在我们可以进一步理解虚拟内存管理了。首先分析一个例子: $ ps PID TTY TIME CMD 29977 pts/0 00:00:00 bash 30032 pts/0 00:00:00 ps $ cat /proc/29977/maps 08048000-080f4000 r-xp 00000000 08:15 688142 080f4000-080f9000 rw-p 000ac000 08:15 688142 080f9000-080fe000 rw-p 080f9000 00:00 0 09283000-09497000 rw-p 09283000 00:00 0 b7ca8000-b7cb2000 r-xp 00000000 08:15 581665 /lib/tls/i686/cmov/libnss_files-2.8.90.so b7cb2000-b7cb3000 r--p 00009000 08:15 581665 /lib/tls/i686/cmov/libnss_files-2.8.90.so b7cb3000-b7cb4000 rw-p 0000a000 08:15 581665 /lib/tls/i686/cmov/libnss_files-2.8.90.so ...... b7e15000-b7f6d000 r-xp 00000000 08:15 581656 /lib/tls/i686/cmov/libc-2.8.90.so b7f6d000-b7f6f000 r--p 00158000 08:15 581656 /lib/tls/i686/cmov/libc-2.8.90.so b7f6f000-b7f70000 rw-p 0015a000 08:15 581656 /lib/tls/i686/cmov/libc-2.8.90.so ...... b7fbd000-b7fd7000 r-xp 00000000 08:15 565466 2.8.90.so b7fd7000-b7fd8000 r-xp b7fd7000 00:00 0 b7fd8000-b7fd9000 r--p 0001a000 08:15 565466 2.8.90.so b7fd9000-b7fda000 rw-p 0001b000 08:15 565466 2.8.90.so bfac5000-bfada000 rw-p bffeb000 00:00 0 /bin/bash /bin/bash [heap] /lib/ld[vdso] /lib/ld/lib/ld[stack] 用ps命令查看当前终端下的进程,得知bash进程的id是29977,然后用cat /proc/29977/maps命 令查看它的虚拟地址空间。/proc目录中的文件并不是真正的磁盘文件,而是由内核虚拟出来的 文件系统,当前系统中运行的每个进程在/proc下都有一个子目录,目录名就是进程的id,查看 目录下的文件可以得到该进程的相关信息。此外,用pmap 29977命令也可以得到类似的输出结 果。 图 20.4. 进程地址空间 在第 4 节 “MMU”讲过,x86平台的虚拟地址空间是0x0000 0000~0xffff ffff,大致上 前3GB(0x0000 0000~0xbfff ffff)是用户空间,后1GB(0xc000 0000~0xffff ffff)是内核空 间,在这里得到了印证。0x0804 8000-0x080f 4000是从/bin/bash加载到内存的,访问权限 为r-x,表示Text Segment,包含.text段、.rodata段、.plt段等。0x080f 4000-0x080f 9000也是从/bin/bash加载到内存的,访问权限为rw-,表示Data Segment,包 含.data 段、.bss 段等。 0x0928 3000-0x0949 7000不是从磁盘文件加载到内存的,这段空间称为堆(Heap),以后会 讲到用malloc函数动态分配内存是在这里分配的。从0xb7ca 8000开始是共享库的映射空间,每 个共享库也分为几个Segment,每个Segment有不同的访问权限。可以看到,从堆空间的结束 地址(0x0949 7000)到共享库映射空间的起始地址(0xb7ca 8000)之间有很大的地址空洞, 在动态分配内存时堆空间是可以向高地址增长的。堆空间的地址上限(0x09497000)称 为Break,堆空间要向高地址增长就要抬高Break,映射新的虚拟内存页面到物理内存,这是通 过系统调用brk实现的,malloc函数也是调用brk向内核请求分配内存的。 /lib/ld-2.8.90.so 就是动态链接器/lib/ld-linux.so.2 ,后者是前者的符号链接。标 有[vdso]的地址范围是linux-gate.so.1的映射空间,我们讲过这个共享库是由内核虚拟出来 的。0xbfac 5000-0xbfad a000是栈空间,其中高地址的部分保存着进程的环境变量和命令行参 数,低地址的部分保存函数栈帧,栈空间是向低地址增长的,但显然没有堆空间那么大的可供 增长的余地,因为实际的应用程序动态分配大量内存的并不少见,但是有几十层深的函数调用 并且每层调用都有很多局部变量的非常少见。总之,栈空间是可能用尽的,并且比堆空间更容 易用尽,在第 3 节 “递归”讲过,无穷递归会用尽栈空间最终导致段错误。 虚拟内存管理起到了什么作用呢?可以从以下几个方面来理解。 第一,虚拟内存管理可以控制物理内存的访问权限。物理内存本身是不限制访问的,任何地址 都可以读写,而操作系统要求不同的页面具有不同的访问权限,这是利用CPU模式和MMU的内 存保护机制实现的。例如,Text Segment被只读保护起来,防止被错误的指令意外改写,内核 地址空间也被保护起来,防止在用户模式下执行错误的指令意外改写内核数据。这样,执行错 误指令或恶意代码的破坏能力受到了限制,顶多使当前进程因段错误终止,而不会影响整个系 统的稳定性。 第二,虚拟内存管理最主要的作用是让每个进程有独立的地址空间。所谓独立的地址空间是 指,不同进程中的同一个VA被MMU映射到不同的PA,并且在某一个进程中访问任何地址都不 可能访问到另外一个进程的数据,这样使得任何一个进程由于执行错误指令或恶意代码导致的 非法内存访问都不会意外改写其它进程的数据,不会影响其它进程的运行,从而保证整个系统 的稳定性。另一方面,每个进程都认为自己独占整个虚拟地址空间,这样链接器和加载器的实 现会比较容易,不必考虑各进程的地址范围是否冲突。 继续前面的实验,再打开一个终端窗口,看一下这个新的bash进程的地址空间,可以发现和先 前的bash进程地址空间的布局差不多: $ ps PID TTY TIME CMD 30697 pts/1 00:00:00 bash 30749 pts/1 00:00:00 ps $ cat /proc/30697/maps 08048000-080f4000 r-xp 00000000 08:15 688142 080f4000-080f9000 rw-p 000ac000 08:15 688142 080f9000-080fe000 rw-p 080f9000 00:00 0 082d7000-084f9000 rw-p 082d7000 00:00 0 b7cf1000-b7cfb000 r-xp 00000000 08:15 581665 /lib/tls/i686/cmov/libnss_files-2.8.90.so b7cfb000-b7cfc000 r--p 00009000 08:15 581665 /lib/tls/i686/cmov/libnss_files-2.8.90.so b7cfc000-b7cfd000 rw-p 0000a000 08:15 581665 /lib/tls/i686/cmov/libnss_files-2.8.90.so ...... b7e5e000-b7fb6000 r-xp 00000000 08:15 581656 /lib/tls/i686/cmov/libc-2.8.90.so b7fb6000-b7fb8000 r--p 00158000 08:15 581656 /lib/tls/i686/cmov/libc-2.8.90.so b7fb8000-b7fb9000 rw-p 0015a000 08:15 581656 /lib/tls/i686/cmov/libc-2.8.90.so ...... b8006000-b8020000 r-xp 00000000 08:15 565466 2.8.90.so b8020000-b8021000 r-xp b8020000 00:00 0 b8021000-b8022000 r--p 0001a000 08:15 565466 2.8.90.so b8022000-b8023000 rw-p 0001b000 08:15 565466 2.8.90.so bff0e000-bff23000 rw-p bffeb000 00:00 0 /bin/bash /bin/bash [heap] /lib/ld[vdso] /lib/ld/lib/ld[stack] 该进程也占用了0x0000 0000-0xbfff ffff的地址空间,Text Segment也是0x0804 8000-0x080f 4000,Data Segment也是0x080f 4000-0x080f 9000,和先前的进程一模一样,因为这些地址 是在编译链接时写进/bin/bash这个可执行文件的,两个进程都加载它。这两个进程在同一个系 统中同时运行着,它们的Data Segment占用相同的VA,但是两个进程各自干各自的事情,显 然Data Segment中的数据应该是不同的,相同的VA怎么会有不同的数据呢?因为它们被映射到 不同的PA。如下图所示。 图 20.5. 进程地址空间是独立的 从图中还可以看到,两个进程都是bash进程,Text Segment是一样的,并且Text Segment是只 读的,不会被改写,因此操作系统会安排两个进程的Text Segment共享相同的物理页面。由于 每个进程都有自己的一套VA到PA的映射表,整个地址空间中的任何VA都在每个进程自己的映 射表中查找相应的PA,因此不可能访问到其它进程的地址,也就没有可能意外改写其它进程的 数据。 另外,注意到两个进程的共享库加载地址并不相同,共享库的加载地址是在运行时决定的,而 不是写在/bin/bash这个可执行文件中。但即使如此,也不影响两个进程共享相同物理页面中的 共享库,当然,只有只读的部分是共享的,可读可写的部分不共享。 使用共享库可以大大节省内存。比如libc,系统中几乎所有的进程都映射libc到自己的进程地 址空间,而libc的只读部分在物理内存中只需要存在一份,就可以被所有进程共享,这就是“共 享库”这个名称的由来了。 现在我们也可以理解为什么共享库必须是位置无关代码了。比如libc,不同的进程虽然共 享libc所在的物理页面,但这些物理页面被映射到各进程的虚拟地址空间时却位于不同的地 址,所以要求libc的代码不管加载到什么地址都能正确执行。 第三,VA到PA的映射会给分配和释放内存带来方便,物理地址不连续的几块内存可以映射成虚 拟地址连续的一块内存。比如要用malloc分配一块很大的内存空间,虽然有足够多的空闲物理 内存,却没有足够大的连续空闲内存,这时就可以分配多个不连续的物理页面而映射到连续的 虚拟地址范围。如下图所示。 图 20.6. 不连续的PA可以映射为连续的VA 第四,一个系统如果同时运行着很多进程,为各进程分配的内存之和可能会大于实际可用的物 理内存,虚拟内存管理使得这种情况下各进程仍然能够正常运行。因为各进程分配的只不过是 虚拟内存的页面,这些页面的数据可以映射到物理页面,也可以临时保存到磁盘上而不占用物 理页面,在磁盘上临时保存虚拟内存页面的可能是一个磁盘分区,也可能是一个磁盘文件,称 为交换设备(Swap Device)。当物理内存不够用时,将一些不常用的物理页面中的数据临时保 存到交换设备,然后这个物理页面就认为是空闲的了,可以重新分配给进程使用,这个过程称 为换出(Page out)。如果进程要用到被换出的页面,就从交换设备再加载回物理内存,这称 为换入(Page in)。换出和换入操作统称为换页(Paging),因此: 系统中可分配的内存总量 = 物理内存的大小 + 交换设备的大小 如下图所示。第一张图是换出,将物理页面中的数据保存到磁盘,并解除地址映射,释放物理 页面。第二张图是换入,从空闲的物理页面中分配一个,将磁盘暂存的页面加载回内存,并建 立地址映射。 图 20.7. 换页 上一页 4. 共享库 上一级 起始页 下一页 第 21 章 预处理 上一页 第 21 章 预处理 部分 II. C语言本质 第 21 章 预处理 目录 1. 预处理的步骤 2. 宏定义 2.1. 函数式宏定义 2.2. 内联函数 2.3. #、##运算符和可变参数 2.4. 宏展开的步骤 3. 条件预处理指示 4. 其它预处理特性 上一页 5. 虚拟内存管理 上一级 起始页 下一页 下一页 1. 预处理的步骤 上一页 1. 预处理的步骤 第 21 章 预处理 下一页 1. 预处理的步骤 现在我们全面了解一下C编译器做语法解析之前的预处理步骤: 1、把第 2 节 “常量”提到过的三连符替换成相应的单字符。第 1 节 “继续Hello World”还提到 过,Windows平台的文本文件用\r\n做行分隔符,而Linux平台用\n做行分隔符,C编译器要能 够处理这种差别,不管是哪种行分隔符,以下统称为换行。 2、把用\字符续行的多行代码接成一行。例如: #define STR "hello, "\ "world" 经过这个预处理步骤之后接成一行#define STR "hello, " "world"。这种续行的写法要求\后面 紧跟换行,中间不能有其它空白字符。 3、把注释(不管是单行注释还是多行注释)都替换成一个空格。 4、经过以上两步之后去掉了一些换行,有的换行在续行过程中去掉了,有的换行在多行注释之 中,也随着注释一起去掉了,剩下的代码行称为逻辑代码行。然后预处理器把逻辑代码行划分 成Token和空白字符,这时的Token称为预处理Token,包括标识符、整数常量、浮点数常量、 字符常量、字符串、运算符和其它符号。继续上面的例子,两个源代码行被接成一个逻辑代码 行,然后这个逻辑代码行被划分成Token和空白字符:#,define,空格,STR,空格,"hello, ",Tab,Tab,"world"。 在划分Token时可能会遇到歧义,例如a+++++b这个表达式,既可以划分成a,++,++,+,b,也 可以划分成a,++,+,++,b。C语言规定按照从前到后的顺序划分Token,每个Token都要尽可 能长,所以这个表达式应该按第一种方式划分。其实按第一种方式划分Token是不合语法的,因 为++运算符的操作数必须是左值,如果a是左值则a++是合乎语法的,但a++这个表达式的值就不 再是左值了,所以a++再++就不合语法了,按第二种方式划分Token反倒是合乎语法的。即便如 此,C编译器对这个表达式做词法分析时还是会按第一种方式划分Token,然后在语法和语义分 析时再报错。 5、在Token中识别出预处理指示,做相应的预处理动作,如果遇到#include预处理指示,则把 相应的源文件包含进来,并对源文件做以上1-4步预处理。如果遇到宏定义则做宏展开。 我们早在第 2 节 “数组应用实例:统计随机数”就认识了预处理指示这个概念,现在给出它的严 格定义。一条预处理指示由一个逻辑代码行组成,以#开头,后面跟若干个预处理Token,在预 处理指示中允许使用的空白字符只有空格和Tab。 6、找出字符常量或字符串中的转义序列,用相应的字节来替换它,比如把\n替换成字节0x0a。 7、把相邻的字符串连接起来。继续上面的例子,如果代码中有: printf( STR); 经过第4步处理划分成以下Token:printf,(,换行,Tab,STR,),;,换行。经过第5步宏展 开后变成以下Token:printf,(,换行,Tab,"hello, ",Tab,Tab,"world",),;,换 行。然后把相邻的字符串连接起来,变成以下Token:printf,(,换行,Tab,"hello, world",),;,换行。 8、经过以上处理之后,把空白字符丢掉,把Token交给C编译器做语法解析,这时就不再是预 处理Token,而称为C Token了。这里丢掉的空白字符包括空格、换行、水平Tab、垂直Tab、 分页符。继续上面的例子,最后交给C编译器做语法解析的Token是:printf,(,"hello, world",),;。注意,把一个预处理指示写成多行要用\续行,因为根据定义,一条预处理指示 只能由一个逻辑代码行组成,而把C代码写成多行则不需要用\续行,因为换行在C代码中只不 过是一种空白字符,在做语法解析时所有空白字符都已经丢掉了。 上一页 第 21 章 预处理 上一级 起始页 下一页 2. 宏定义 上一页 2. 宏定义 第 21 章 预处理 下一页 2. 宏定义 较大的项目都会用大量的宏定义来组织代码,你可以看看/usr/include下面的头文件中用了多少 个宏定义。看起来宏展开就是做个替换而已,其实里面有比较复杂的规则,C语言有很多复杂但 不常用的语法规则本书并不涉及,但有关宏展开的语法规则本节却力图做全面讲解,因为它很 重要也很常用。 2.1. 函数式宏定义 以前我们用过的#define N 20或#define STR "hello, world"这种宏定义可以称为变量式宏定义 (Object-like Macro),宏定义名可以像变量一样在代码中使用。另外一种宏定义可以像函数 调用一样在代码中使用,称为函数式宏定义(Function-like Macro)。例如编辑一个文 件main.c: #define MAX(a, b) ((a)>(b)?(a):(b)) k = MAX(i&0x0f, j&0x0f) 我们想看第二行的表达式展开成什么样,可以用gcc的-E选项或cpp命令,尽管这个C程序不合语 法,但没关系,我们只做预处理而不编译,不会检查程序是否符合C语法。 $ cpp main.c # 1 "main.c" # 1 "" # 1 "" # 1 "main.c" k = ((i&0x0f)>(j&0x0f)?(i&0x0f):(j&0x0f)) 就像函数调用一样,把两个实参分别替换到宏定义中形参a和b的位置。注意这种函数式宏定义 和真正的函数调用有什么不同: 1、函数式宏定义的参数没有类型,预处理器只负责做形式上的替换,而不做参数类型检查,所 以传参时要格外小心。 2、调用真正函数的代码和调用函数式宏定义的代码编译生成的指令不同。如果MAX是个真正的 函数,那么它的函数体return a > b ? a : b;要编译生成指令,代码中出现的每次调用也要编 译生成传参指令和call指令。而如果MAX是个函数式宏定义,这个宏定义本身倒不必编译生成指 令,但是代码中出现的每次调用编译生成的指令都相当于一个函数体,而不是简单的几条传参 指令和call指令。所以,使用函数式宏定义编译生成的目标文件会比较大。 3、定义这种宏要格外小心,如果上面的定义写成#define MAX(a, b) (a>b?a:b),省去内层括 号,则宏展开就成了k = (i&0x0f>j&0x0f?i&0x0f:j&0x0f),运算符的优先级就错了。同样道 理,这个宏定义的外层括号也是不能省的,想一想为什么。 4、调用函数时先求实参表达式的值再传给形参,如果实参表达式有Side Effect,那么这些Side Effect只发生一次。例如MAX(++a, ++b),如果MAX是个真正的函数,a和b只增加一次。但如 果MAX是上面那样的宏定义,则要展开成k = ((++a)>(++b)?(++a):(++b)),a和b就不一定是增加 一次还是两次了。 5、即使实参没有Side Effect,使用函数式宏定义也往往会导致较低的代码执行效率。下面举一 个极端的例子,也是个很有意思的例子。 例 21.1. 函数式宏定义 #define MAX(a, b) ((a)>(b)?(a):(b)) int a[] = { 9, 3, 5, 2, 1, 0, 8, 7, 6, 4 }; int max(int n) { return n == 0 ? a[0] : MAX(a[n], max(n-1)); } int main(void) { max(9); return 0; } 这段代码从一个数组中找出最大的数,如果MAX是个真正的函数,这个算法就是从前到后遍历一 遍数组,时间复杂度是Θ(n),而现在MAX是这样一个函数式宏定义,思考一下这个算法的时间复 杂度是多少? 尽管函数式宏定义和真正的函数相比有很多缺点,但只要小心使用还是会显著提高代码的执行 效率,毕竟省去了分配和释放栈帧、传参、传返回值等一系列工作,因此那些简短并且被频繁 调用的函数经常用函数式宏定义来代替实现。例如C标准库的很多函数都提供两种实现,一种是 真正的函数实现,一种是宏定义实现,这一点以后还要详细解释。 函数式宏定义经常写成这样的形式(取自内核代码include/linux/pm.h): #define device_init_wakeup(dev,val) \ do { \ device_can_wakeup(dev) = !!(val); \ device_set_wakeup_enable(dev,val); \ } while(0) 为什么要用do { ... } while(0)括起来呢?不括起来会有什么问题呢? #define device_init_wakeup(dev,val) \ device_can_wakeup(dev) = !!(val); \ device_set_wakeup_enable(dev,val); if (n > 0) device_init_wakeup(d, v); 这样宏展开之后,函数体的第二条语句不在if条件中。那么简单地用{ ... }括起来组成一个语 句块不行吗? #define device_init_wakeup(dev,val) \ { device_can_wakeup(dev) = !!(val); \ device_set_wakeup_enable(dev,val); } if (n > 0) device_init_wakeup(d, v); else continue; 问题出在device_init_wakeup(d, v);末尾的;号,如果不允许写这个;号,看起来不像个函数调 用,可如果写了这个;号,宏展开之后就有语法错误,if语句被这个;号结束掉了,没法 跟else配对。因此,do { ... } while(0)是一种比较好的解决办法。 如果在一个程序文件中重复定义一个宏,C语言规定这些重复的宏定义必须一模一样。例如这样 的重复定义是允许的: #define OBJ_LIKE (1 - 1) #define OBJ_LIKE /* comment */ (1/* comment */-/* comment */ 1)/* comment */ 在定义的前后多些空白(空格、Tab、注释)没有关系,在定义之中多些空白或少些空白也没有 关系,但在定义之中有空白和没有空白被认为是不同的,所以这样的重复定义是不允许的: #define OBJ_LIKE (1 - 1) #define OBJ_LIKE (1-1) 如果需要重新定义一个宏,和原来的定义不同,可以先用#undef取消原来的定义,再重新定 义,例如: #define X 3 ...... /* X is 3 */ #undef X ...... /* X has no definition */ #define X 2 ...... /* X is 2 */ 2.2. 内联函数 C99引入一个新关键字inline,用于定义内联函数(inline function)。这种用法在内核代码中 很常见,例如include/linux/rwsem.h中: static inline void down_read(struct rw_semaphore *sem) { might_sleep(); rwsemtrace(sem,"Entering down_read"); __down_read(sem); rwsemtrace(sem,"Leaving down_read"); } inline关键字告诉编译器,这个函数的调用要尽可能快,可以当普通的函数调用实现,也可以 用宏展开的办法实现。我们做个实验,把上一节的例子改一下: 例 21.2. 内联函数 inline int MAX(int a, int b) { return a > b ? a : b; } int a[] = { 9, 3, 5, 2, 1, 0, 8, 7, 6, 4 }; int max(int n) { return n == 0 ? a[0] : MAX(a[n], max(n-1)); } int main(void) { max(9); return 0; } 按往常的步骤编译然后反汇编: $ gcc main.c -g $ objdump -dS a.out ...... int max(int n) { 8048369: 55 push %ebp 804836a: 89 e5 mov %esp,%ebp 804836c: 83 ec 0c sub $0xc,%esp return n == 0 ? a[0] : MAX(a[n], max(n-1)); 804836f: 83 7d 08 00 cmpl $0x0,0x8(%ebp) 8048373: 75 0a jne 804837f 8048375: a1 c0 95 04 08 mov 0x80495c0,%eax 804837a: 89 45 fc mov %eax,-0x4(%ebp) 804837d: eb 29 jmp 80483a8 804837f: 8b 45 08 mov 0x8(%ebp),%eax 8048382: 83 e8 01 sub $0x1,%eax 8048385: 89 04 24 mov %eax,(%esp) 8048388: e8 dc ff ff ff call 8048369 804838d: 89 c2 mov %eax,%edx 804838f: 8b 45 08 mov 0x8(%ebp),%eax 8048392: 8b 04 85 c0 95 04 08 mov 0x80495c0(,%eax,4),%eax 8048399: 89 54 24 04 mov %edx,0x4(%esp) 804839d: 89 04 24 mov %eax,(%esp) 80483a0: e8 9f ff ff ff call 8048344 80483a5: 89 45 fc mov %eax,-0x4(%ebp) 80483a8: 8b 45 fc mov -0x4(%ebp),%eax } ...... 可以看到MAX是作为普通函数调用的。如果指定优化选项编译,然后反汇编: $ gcc main.c -g -O $ objdump -dS a.out ...... int max(int n) { 8048355: 55 push %ebp 8048356: 89 e5 mov %esp,%ebp 8048358: 53 push %ebx 8048359: 83 ec 04 sub $0x4,%esp 804835c: 8b 5d 08 mov 0x8(%ebp),%ebx return n == 0 ? a[0] : MAX(a[n], max(n-1)); 804835f: 85 db test %ebx,%ebx 8048361: 75 07 jne 804836a 8048363: a1 a0 95 04 08 mov 0x80495a0,%eax 8048368: eb 18 jmp 8048382 804836a: 8d 43 ff lea -0x1(%ebx),%eax 804836d: 89 04 24 mov %eax,(%esp) 8048370: e8 e0 ff ff ff call 8048355 inline int MAX(int a, int b) { return a > b ? a : b; 8048375: 8b 14 9d a0 95 04 08 mov 0x80495a0(,%ebx,4),%edx 804837c: 39 d0 cmp %edx,%eax 804837e: 7d 02 jge 8048382 8048380: 89 d0 mov %edx,%eax int a[] = { 9, 3, 5, 2, 1, 0, 8, 7, 6, 4 }; int max(int n) { return n == 0 ? a[0] : MAX(a[n], max(n-1)); } 8048382: 83 c4 04 add $0x4,%esp 8048385: 5b pop %ebx 8048386: 5d pop %ebp 8048387: c3 ret ...... 可以看到,并没有call指令调用MAX函数,MAX函数的指令是内联在max函数中的,由于源代码和 指令的次序无法对应,max和MAX函数的源代码也交错在一起显示。 2.3. #、##运算符和可变参数 在函数式宏定义中,#运算符用于创建字符串,#运算符后面应该跟一个形参(中间可以有空格 或Tab),例如: #define STR(s) # s STR(hello world) 用cpp命令预处理之后是"hello world",自动用"号把实参括起来成为一个字符串,并且实参中 的连续多个空白字符被替换成一个空格。 再比如: #define STR(s) #s fputs(STR(strncmp("ab\"c\0d", "abc", '\4"') == 0) STR(: @\n), s); 预处理之后是fputs("strncmp(\"ab\\\"c\\0d\", \"abc\", '\\4\"') == 0" ": @\n", s);,注 意如果实参中包含字符常量或字符串,则宏展开之后字符串的界定符"要替换成\",字符常量或 字符串中的\和"字符要替换成\\和\"。 在宏定义中可以用##运算符把前后两个预处理Token连接成一个预处理Token,和#运算符不 同,##运算符不仅限于函数式宏定义,变量式宏定义也可以用。例如: #define CONCAT(a, b) a##b CONCAT(con, cat) 预处理之后是concat。再比如,要定义一个宏展开成两个#号,可以这样定义: #define HASH_HASH # ## # 中间的##是运算符,宏展开时前后两个#号被这个运算符连接在一起。注意中间的两个空格是不 可少的,如果写成####,会被划分成##和##两个Token,而根据定义##运算符用于连接前后两个 预处理Token,不能出现在宏定义的开头或末尾,所以会报错。 我们知道printf函数带有可变参数,函数式宏定义也可以带可变参数,同样是在参数列表中 用...表示可变参数。例如: #define showlist(...) printf(#__VA_ARGS__) #define report(test, ...) ((test)?printf(#test):\ printf(__VA_ARGS__)) showlist(The first, second, and third items.); report(x>y, "x is %d but y is %d", x, y); 预处理之后变成: printf("The first, second, and third items."); ((x>y)?printf("x>y"): printf("x is %d but y is %d", x, y)); 在宏定义中,可变参数的部分用__VA_ARGS__表示,实参中对应...的几个参数可以看成一个参数 替换到宏定义中__VA_ARGS__所在的地方。 调用函数式宏定义允许传空参数,这一点和函数调用不同,通过下面几个例子理解空参数的用 法。 #define FOO() foo FOO() 预处理之后变成foo。FOO在定义时不带参数,在调用时也不允许传参数给它。 #define FOO(a) foo##a FOO(bar) FOO() 预处理之后变成: foobar foo FOO在定义时带一个参数,在调用时必须传一个参数给它,如果不传参数则表示传了一个空参 数。 #define FOO(a, b, c) a##b##c FOO(1,2,3) FOO(1,2,) FOO(1,,3) FOO(,,3) 预处理之后变成: 123 12 13 3 FOO在定义时带三个参数,在调用时也必须传三个参数给它,空参数的位置可以空着,但必须给 够三个参数,FOO(1,2)这样的调用是错误的。 #define FOO(a, ...) a##__VA_ARGS__ FOO(1) FOO(1,2,3,) 预处理之后变成: 1 12,3, FOO(1)这个调用相当于可变参数部分传了一个空参数,FOO(1,2,3,)这个调用相当于可变参数部 分传了三个参数,第三个是空参数。 gcc有一种扩展语法,如果##运算符用在__VA_ARGS__前面,除了起连接作用之外还有特殊的含 义,例如内核代码net/netfilter/nf_conntrack_proto_sctp.c中的: #define DEBUGP(format, ...) printk(format, ## __VA_ARGS__) printk这个内核函数相当于printf,也带有格式化字符串和可变参数,由于内核不能调用libc的 函数,所以另外实现了一个打印函数。这个函数式宏定义可以这样调用:DEBUGP("info no. %d", 1)。也可以这样调用:DEBUGP("info")。后者相当于可变参数部分传了一个空参数,但展 开后并不是printk("info",) ,而是printk("info") ,当__VA_ARGS 是空参数时,##运算符把它前 面的,号“吃”掉了。 2.4. 宏展开的步骤 以上举的宏展开的例子都是最简单的,有些宏展开的过程要做多次替换,例如: #define sh(x) printf("n" #x "=%d, or %d\n",n##x,alt[x]) #define sub_z 26 sh(sub_z) sh(sub_z)要用sh(x)这个宏定义来展开,形参x对应的实参是sub_z,替换过程如下: 1. #x要替换成"sub_z"。 2. n##x要替换成nsub_z。 3. 除了带#和##运算符的参数之外,其它参数在替换之前要对实参本身做充分的展开,所以 应该先把sub_z展开成26再替换到alt[x]中x的位置。 4. 现在展开成了printf("n" "sub_z" "=%d, or %d\n",nsub_z,alt[26]),所有参数都替换完 了,这时编译器会再扫描一遍,再找出可以展开的宏定义来展开,假设nsub_z或alt是变 量式宏定义,这时会进一步展开。 再举一个例子: #define x 3 #define f(a) f(x * (a)) #undef x #define x 2 #define g f #define t(a) a t(t(g)(0) + t)(1); 展开的步骤是: 1. 先把g展开成f再替换到#define t(a) a中,得到t(f(0) + t)(1);。 2. 根据#define f(a) f(x * (a)),得到t(f(x * (0)) + t)(1);。 3. 把x替换成2,得到t(f(2 * (0)) + t)(1);。注意,一开始定义x为3,但是后来用#undef x取消了x的定义,又重新定义x为2。当处理到t(t(g)(0) + t)(1);这一行代码时x已经定 义成2了,所以用2来替换。还要注意一点,现在得到的t(f(2 * (0)) + t)(1);中仍然 有f,但不能再次根据#define f(a) f(x * (a))展开了,f(2 * (0))就是由展开f(0)得到 的,这里面再遇到f就不展开了,这样规定可以避免无穷展开(类似于无穷递归),因此 我们可以放心地使用递归定义,例如#define a a[0],#define a a.member等。 4. 根据#define t(a) a,最终展开成f(2 * (0)) + t(1);。这时不能再展开t(1)了,因为这 里的t就是由展开t(f(2 * (0)) + t)得到的,所以不能再展开了。 上一页 1. 预处理的步骤 上一级 起始页 下一页 3. 条件预处理指示 上一页 3. 条件预处理指示 第 21 章 预处理 下一页 3. 条件预处理指示 我们在第 2.2 节 “头文件”中见过Header Guard的用法: #ifndef HEADER_FILENAME #define HEADER_FILENAME /* body of header */ #endif 条件预处理指示也常用于源代码的配置管理,例如: #if MACHINE == 68000 int x; #elif MACHINE == 8086 long x; #else /* all others */ #error UNKNOWN TARGET MACHINE #endif 假设这段程序是为多种平台编写的,在68000平台上需要定义x为int型,在8086平台上需要定 义x为long型,对其它平台暂不提供支持,就可以用条件预处理指示来写。如果在预处理这段代 码之前,MACHINE被定义为68000,则包含intx;这段代码;否则如果MACHINE被定义为8086,则 包含long x;这段代码;否则(MACHINE没有定义,或者定义为其它值),包含#error UNKNOWN TARGET MACHINE这段代码,编译器遇到这个预处理指示就报错退出,错误信息就是UNKNOWN TARGET MACHINE。 如果要为8086平台编译这段代码,有几种可选的办法: 1、手动编辑代码,在前面添一行#define MACHINE 8086。这样做的缺点是难以管理,如果这个 项目中有很多源文件都需要定义MACHINE,每次要为8086平台编译就得把这些定义全部改 成8086,每次要为68000平台编译就得把这些定义全部改成68000。 2、在所有需要配置的源文件开头包含一个头文件,在头文件中定义#define MACHINE 8086,这 样只需要改一个头文件就可以影响所有包含它的源文件。通常这个头文件由配置工具生成,比 如在Linux内核源代码的目录下运行make menuconfig命令可以出来一个配置菜单,在其中配置的 选项会自动转换成头文件include/linux/autoconf.h中的宏定义。 举一个具体的例子,在内核配置菜单中用回车键和方向键进入Device Drivers ---> Network device support,然后用空格键选中Network device support(菜单项左边的[ ]括号内会出现 一个*号),然后保存退出,会生成一个名为.config的隐藏文件,其内容类似于: ...... # # Network device support # CONFIG_NETDEVICES=y # CONFIG_DUMMY is not set # CONFIG_BONDING is not set # CONFIG_EQUALIZER is not set # CONFIG_TUN is not set ...... 然后运行make命令编译内核,这时根据.config文件生成头文件include/linux/autoconf.h,其 内容类似于: ...... /* * Network device support */ #define CONFIG_NETDEVICES 1 #undef CONFIG_DUMMY #undef CONFIG_BONDING #undef CONFIG_EQUALIZER #undef CONFIG_TUN ...... 上面的代码用#undef确保取消一些宏的定义,如果先前没有定义过CONFIG_DUMMY,用#undef CONFIG_DUMMY取消它的定义没有任何作用,也不算错。 include/linux/autoconf.h被另一个头文件include/linux/config.h所包含,通常内核代码包含 后一个头文件,例如net/core/sock.c : ...... #include ...... int sock_setsockopt(struct socket *sock, int level, int optname, char __user *optval, int optlen) { ...... #ifdef CONFIG_NETDEVICES case SO_BINDTODEVICE: { ...... } #endif ...... 再比如drivers/isdn/i4l/isdn_common.c : ...... #include ...... static int isdn_ioctl(struct inode *inode, struct file *file, uint cmd, ulong arg) { ...... #ifdef CONFIG_NETDEVICES case IIOCNETGPN: /* Get peer phone number of a connected * isdn network interface */ if (arg) { if (copy_from_user(&phone, argp, sizeof(phone))) return -EFAULT; return isdn_net_getpeer(&phone, argp); } else return -EINVAL; #endif ...... #ifdef CONFIG_NETDEVICES case IIOCNETAIF: ...... #endif /* CONFIG_NETDEVICES */ ...... 这样,在配置菜单中所做的配置通过条件预处理最终决定了哪些代码被编译到内核 中。#ifdef或#if可以嵌套使用,但预处理指示通常都顶头写不缩进,为了区分嵌套的层次,可 以像上面的代码中最后一行那样,在#endif处用注释写清楚它结束的是哪个#if或#ifdef。 3、要定义一个宏不一定非得在代码中用#define定义,早在第 6 节 “折半查找”我们就见过 用gcc的-D选项定义一个宏NDEBUG。对于上面的例子,我们需要给MACHINE定义一个值,可以写成 类似这样的命令:gcc -c -DMACHINE=8086 main.c。这种办法需要给每个编译命令都加上适当的 选项,和第2种方法相比似乎也很麻烦,第2种方法在头文件中只写一次宏定义就可以在很多源 文件中生效,第3种方法能不能做到“只写一次到处生效”呢?等以后学习了Makefile就有办法 了。 最后通过下面的例子说一下#if后面的表达式: #define VERSION 2 #if defined x || y || VERSION < 3 1. 首先处理defined运算符,defined运算符一般用作表达式中的一部分,如果单独使用,#if defined x相当于#ifdef x,而#if !defined x相当于#ifndef x。在这个例子中,如果x这 个宏有定义,则把defined x替换为1,否则替换为0,因此变成#if 0 || y || VERSION < 3。 2. 然后把有定义的宏展开,变成#if 0 || y || 2 < 3。 3. 把没有定义的宏替换成0,变成#if 0 || 0 || 2 < 3,注意,即使前面定义了一个变量名 是y,在这一步也还是替换成0,因为#if的表达式必须在编译时求值,其中包含的名字只 能是宏定义。 4. 把得到的表达式0 || 0 || 2 < 3像C表达式一样求值,求值的结果是#if 1,因此条件成 立。 上一页 2. 宏定义 上一级 起始页 下一页 4. 其它预处理特性 上一页 4. 其它预处理特性 第 21 章 预处理 下一页 4. 其它预处理特性 #pragma预处理指示供编译器实现一些非标准的特性,C标准没有规定#pragma后面应该写什么以 及起什么作用,由编译器自己规定。有的编译器用#pragma定义一些特殊功能寄存器名,有的编 译器用#pragma定位链接地址,本书不做深入讨论。如果编译器在代码中碰到不认识的#pragma指 示则忽略它,例如gcc的#pragma指示都是#pragma GCC ...这种形式,用别的编译器编译则忽略 这些指示。 C标准规定了几个特殊的宏,在不同的地方使用可以自动展开成不同的值,常用的 有__FILE__和__LINE__,__FILE__展开为当前源文件的文件名,是一个字符串,__LINE__展开为 当前代码行的行号,是一个整数。这两个宏在源代码中不同的位置使用会自动取不同的值,显 然不是用#define能定义得出来的,它们是编译器内建的特殊的宏。在打印调试信息时打印这两 个宏可以给开发者非常有用的提示,例如在第 6 节 “折半查找”我们看到assert函数打印的错误 信息就有__FILE__和__LINE__的值。现在我们自己实现这个assert函数,以理解它的原理。这个 实现出自[Standard C Library]: 例 21.3. assert.h的一种实现 /* assert.h standard header */ #undef assert /* remove existing definition */ #ifdef NDEBUG #define assert(test) ((void)0) #else /* NDEBUG not defined */ void _Assert(char *); /* macros */ #define _STR(x) _VAL(x) #define _VAL(x) #x #define assert(test) ((test) ? (void)0 \ : _Assert(__FILE__ ":" _STR(__LINE__) " " #test)) #endif 通过这个例子可以全面复习本章所讲的知识。C标准规定assert应该实现为宏定义而不是一个真 正的函数,并且assert(test)这个表达式的值应该是void类型的。首先用#undef assert确保取 消前面对assert的定义,然后分两种情况:如果定义了NDEBUG,那么assert(test)直接定义成一 个void类型的值,什么也不做;如果没有定义NDEBUG,则要判断测试条件test是否成立,如果 条件成立就什么也不做,如果不成立则调用_Assert函数。假设在main.c文件的第33行调 用assert(is_sorted()) ,那么__FILE__ 是字符串"main.c" ,__LINE__ 是整数33,#test 是字符 串"is_sorted()"。注意_STR(__LINE__)的展开过程:首先展开成_VAL(33),然后进一步展开成 字符串"33"。这样,最后_Assert调用的形式是_Assert("main.c" ":" "33" " " "is_sorted()"),传给_Assert函数的字符串是"main.c:33 is_sorted()"。_Assert函数是我们 自己定义的,在另一个源文件中: /* xassert.c _Assert function */ #include #include void _Assert(char *mesg) { /* print assertion message and abort */ fputs(mesg, stderr); fputs(" -- assertion failed\n", stderr); abort(); } 注意,在头文件assert.h中自己定义的内部使用的标识符都以_线开头,例 如_STR,_VAL,_Assert,因为我们在模拟C标准库的实现,在第 3 节 “变量”讲过,以_线开头的 标识符通常由编译器和C语言库使用,在/usr/include下的头文件中你可以看到大量_线开头的 标识符。另外一个问题,为什么我们不直接在assert的宏定义中调用fputs和abort呢?因为调 用这两个函数需要包含stdio.h和stdlib.h,C标准库的头文件应该是相互独立的,一个程序只 要包含assert.h就应该能使用assert,而不应该再依赖于别的头文件。_Assert中的fputs向标准 错误输出打印错误信息,abort异常终止当前进程,这些函数以后再详细讨论。 现在测试一下我们的assert实现,把assert.h和xassert.c和测试代码main.c放在同一个目录 下。 /* main.c */ #include "assert.h" int main(void) { assert(2>3); return 0; } 注意#include "assert.h"要用"引号而不要用<>括号,以保证包含的是我们自己写的assert.h而 非C标准库的头文件。然后编译运行: $ gcc main.c xassert.c $ ./a.out main.c:6 2>3 -- assertion failed Aborted 在打印调试信息时除了文件名和行号之外还可以打印出当前函数名,C99引入一个特殊的标识 符__func__支持这一功能。这个标识符应该是一个变量名而不是宏定义,不属于预处理的范 畴,但它的作用和__FILE__、__LINE__类似,所以放在一起讲。例如: 例 21.4. 特殊标识符__func__ #include void myfunc(void) { printf("%s\n", __func__); } int main(void) { myfunc(); printf("%s\n", __func__); return 0; } $ gcc main.c $ ./a.out myfunc main 上一页 3. 条件预处理指示 上一级 起始页 下一页 第 22 章 Makefile基础 上一页 第 22 章 Makefile基础 部分 II. C语言本质 第 22 章 Makefile基础 目录 1. 基本规则 2. 隐含规则和模式规则 3. 变量 4. 自动处理头文件的依赖关系 5. 常用的make命令行选项 上一页 4. 其它预处理特性 上一级 起始页 下一页 下一页 1. 基本规则 上一页 1. 基本规则 第 22 章 Makefile基础 下一页 1. 基本规则 除了Hello World这种极简单的程序之外,一般的程序都是由多个源文件编译链接而成的,这些 源文件的处理步骤通常用Makefile来管理。Makefile起什么作用呢?我们先看一个例子,这个例 子由例 12.3 “用深度优先搜索解迷宫问题”改写而成: /* main.c */ #include #include "main.h" #include "stack.h" #include "maze.h" struct point predecessor[MAX_ROW][MAX_COL] = { {{-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}}, {{-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}}, {{-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}}, {{-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}}, {{-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}}, }; void visit(int row, int col, struct point pre) { struct point visit_point = { row, col }; maze[row][col] = 2; predecessor[row][col] = pre; push(visit_point); } int main(void) { struct point p = { 0, 0 }; maze[p.row][p.col] = 2; push(p); while (!is_empty()) { p = pop(); if (p.row == MAX_ROW - 1 /* goal */ && p.col == MAX_COL - 1) break; if (p.col+1 < MAX_COL /* right */ && maze[p.row][p.col+1] == 0) visit(p.row, p.col+1, p); if (p.row+1 < MAX_ROW /* down */ && maze[p.row+1][p.col] == 0) visit(p.row+1, p.col, p); if (p.col-1 >= 0 /* left */ && maze[p.row][p.col-1] == 0) visit(p.row, p.col-1, p); if (p.row-1 >= 0 /* up */ && maze[p.row-1][p.col] == 0) visit(p.row-1, p.col, p); print_maze(); } if (p.row == MAX_ROW - 1 && p.col == MAX_COL - 1) { printf("(%d, %d)\n", p.row, p.col); while (predecessor[p.row][p.col].row != -1) { p = predecessor[p.row][p.col]; printf("(%d, %d)\n", p.row, p.col); } } else printf("No path!\n"); return 0; } 我们把堆栈和迷宫的代码分别转移到模块stack.c和maze.c中,main.c包含它们提供的头文 件stack.h和maze.h。 /* main.h */ #ifndef MAIN_H #define MAIN_H typedef struct point { int row, col; } item_t; #define MAX_ROW 5 #define MAX_COL 5 #endif 在main.h中定义了一个类型和两个常量,main.c、stack.c和maze.c都要用到这些定义,都要包 含这个头文件。 /* stack.c */ #include "stack.h" static item_t stack[512]; static int top = 0; void push(item_t p) { stack[top++] = p; } item_t pop(void) { return stack[--top]; } int is_empty(void) { return top == 0; } /* stack.h */ #ifndef STACK_H #define STACK_H #include "main.h" /* provides definition for item_t */ extern void push(item_t); extern item_t pop(void); extern int is_empty(void); #endif 例 12.3 “用深度优先搜索解迷宫问题”中的堆栈规定死了只能放char型数据,现在我们做进一步 抽象,堆栈中放item_t类型的数据,item_t可以定义为任意类型,只要它能够通过函数的参数和 返回值传递并且支持赋值操作就行。这也是一种避免硬编码的策略,stack.c中多次使 用item_t类型,要改变它的定义只需改变main.h中的一行代码。 /* maze.c */ #include #include "maze.h" int maze[MAX_ROW][MAX_COL] = { 0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, }; void print_maze(void) { int i, j; for (i = 0; i < MAX_ROW; i++) { for (j = 0; j < MAX_COL; j++) printf("%d ", maze[i][j]); putchar('\n'); } printf("*********\n"); } /* maze.h */ #ifndef MAZE_H #define MAZE_H #include "main.h" /* provides defintion for MAX_ROW and MAX_COL */ extern int maze[MAX_ROW][MAX_COL]; void print_maze(void); #endif maze.c中定义了一个maze数组和一个print_maze函数,需要在头文件maze.h中声明,以便提供 给main.c使用,注意print_maze 的声明可以不加extern,而maze 的声明必须加extern。 这些源文件可以这样编译: $ gcc main.c stack.c maze.c -o main 但这不是个好办法,如果编译之后又对maze.c做了修改,又要把所有源文件编译一遍,即 使main.c、stack.c和那些头文件都没有修改也要跟着重新编译。一个大型的软件项目往往由上 千个源文件组成,全部编译一遍需要几个小时,只改一个源文件就要求全部重新编译肯定是不 合理的。 这样编译也许更好一些: $ gcc -c main.c $ gcc -c stack.c $ gcc -c maze.c $ gcc main.o stack.o maze.o -o main 如果编译之后又对maze.c做了修改,要重新编译只需要做两步: $ gcc -c maze.c $ gcc main.o stack.o maze.o -o main 这样又有一个问题,每次编译敲的命令都不一样,很容易出错,比如我修改了三个源文件,可 能有一个忘了重新编译,结果编译完了修改没生效,运行时出了Bug还满世界找原因呢。更复杂 的问题是,假如我改了main.h怎么办?所有包含main.h的源文件都需要重新编译,我得挨个找哪 些源文件包含了main.h,有的还很不明显,例如stack.c包含了stack.h,而后者包含了main.h。 可见手动处理这些问题非常容易出错,那有没有自动的解决办法呢?有,就是写一 个Makefile文件和源代码放在同一个目录下: main: main.o stack.o maze.o gcc main.o stack.o maze.o -o main main.o: main.c main.h stack.h maze.h gcc -c main.c stack.o: stack.c stack.h main.h gcc -c stack.c maze.o: maze.c maze.h main.h gcc -c maze.c 然后在这个目录下运行make编译: $ make gcc -c main.c gcc -c stack.c gcc -c maze.c gcc main.o stack.o maze.o -o main make命令会自动读取当前目录下的Makefile文件[31],完成相应的编译步骤。Makefile由一组规 则(Rule)组成,每条规则的格式是: target ... : prerequisites ... command1 command2 ... 例如: main: main.o stack.o maze.o gcc main.o stack.o maze.o -o main main是这条规则的目标(Target),main.o、stack.o和maze.o是这条规则的条件 (Prerequisite)。目标和条件之间的关系是:欲更新目标,必须首先更新它的所有条件;所有 条件中只要有一个条件被更新了,目标也必须随之被更新。所谓“更新”就是执行一遍规则中的命 令列表,命令列表中的每条命令必须以一个Tab开头,注意不能是空格,Makefile的格式不 像C语言的缩进那么随意,对于Makefile中的每个以Tab开头的命令,make会创建一个Shell进程 去执行它。 对于上面这个例子,make执行如下步骤: 1. 尝试更新Makefile中第一条规则的目标main,第一条规则的目标称为缺省目标,只要缺省 目标更新了就算完成任务了,其它工作都是为这个目的而做的。由于我们是第一次编 译,main文件还没生成,显然需要更新,但规则说必须先更新 了main.o、stack.o和maze.o这三个条件,然后才能更新main。 2. 所以make会进一步查找以这三个条件为目标的规则,这些目标文件也没有生成,也需要更 新,所以执行相应的命令(gcc -c main.c、gcc -c stack.c和gcc -c maze.c)更新它 们。 3. 最后执行gcc main.o stack.o maze.o -o main更新main。 如果没有做任何改动,再次运行make: $ make make: `main' is up to date. make会提示缺省目标已经是最新的了,不需要执行任何命令更新它。再做个实验,如果修改 了maze.h(比如加个无关痛痒的空格)再运行make: $ make gcc -c main.c gcc -c maze.c gcc main.o stack.o maze.o -o main make会自动选择那些受影响的源文件重新编译,不受影响的源文件则不重新编译,这是怎么做 到的呢? 1. make仍然尝试更新缺省目标,首先检查目标main是否需要更新,这就要检查三个条 件main.o、stack.o和maze.o是否需要更新。 2. make会进一步查找以这三个条件为目标的规则,然后发现main.o和maze.o需要更新,因为 它们都有一个条件是maze.h,而这个文件的修改时间比main.o和maze.o晚,所以执行相应 的命令更新main.o和maze.o。 3. 既然main的三个条件中有两个被更新过了,那么main也需要更新,所以执行命令gcc main.o stack.o maze.o -o main更新main。 现在总结一下Makefile的规则,请读者结合上面的例子理解。如果一条规则的目标属于以下情况 之一,就称为需要更新: 目标没有生成。 某个条件需要更新。 某个条件的修改时间比目标晚。 在一条规则被执行之前,规则的条件可能处于以下三种状态之一: 需要更新。能够找到以该条件为目标的规则,并且该规则中目标需要更新。 不需要更新。能够找到以该条件为目标的规则,但是该规则中目标不需要更新;或者不能 找到以该条件为目标的规则,并且该条件已经生成。 错误。不能找到以该条件为目标的规则,并且该条件没有生成。 执行一条规则A的步骤如下: 1. 检查它的每个条件P: 如果P需要更新,就执行以P为目标的规则B。之后,无论是否生成文件P,都认 为P已被更新。 如果找不到规则B,并且文件P已存在,表示P不需要更新。 如果找不到规则B,并且文件P不存在,则报错退出。 2. 在检查完规则A的所有条件后,检查它的目标T,如果属于以下情况之一,就执行它的命令 列表: 文件T不存在。 文件T存在,但是某个条件的修改时间比它晚。 某个条件P已被更新(并不一定生成文件P)。 通常Makefile都会有一个clean规则,用于清除编译过程中产生的二进制文件,保留源文件: clean: @echo "cleanning project" -rm main *.o @echo "clean completed" 把这条规则添加到我们的Makefile末尾,然后执行这条规则: $ make clean cleanning project rm main *.o clean completed 如果在make的命令行中指定一个目标(例如clean),则更新这个目标,如果不指定目标则更 新Makefile中第一条规则的目标(缺省目标)。 和前面介绍的规则不同,clean目标不依赖于任何条件,并且执行它的命令列表不会生 成clean这个文件,刚才说过,只要执行了命令列表就算更新了目标,即使目标并没有生成也 算。在这个例子还演示了命令前面加@和-字符的效果:如果make执行的命令前面加了@字符,则 不显示命令本身而只显示它的结果;通常make执行的命令如果出错(该命令的退出状态非0)就 立刻终止,不再执行后续命令,但如果命令前面加了-号,即使这条命令出错,make也会继续执 行后续命令。通常rm命令和mkdir命令前面要加-号,因为rm要删除的文件可能不存在,mkdir要 创建的目录可能已存在,这两个命令都有可能出错,但这种错误是应该忽略的。例如上面已经 执行过一遍make clean,再执行一遍就没有文件可删了,这时rm会报错,但make忽略这一错 误,继续执行后面的echo命令: $ make clean cleanning project rm main *.o rm: cannot remove `main': No such file or directory rm: cannot remove `*.o': No such file or directory make: [clean] Error 1 (ignored) clean completed 读者可以把命令前面的@和-去掉再试试,对比一下结果有何不同。这里还有一个问题,如果当 前目录下存在一个文件叫clean会怎么样呢? $ touch clean $ make clean make: `clean' is up to date. 如果存在clean这个文件,clean目标又不依赖于任何条件,make就认为它不需要更新了。而我 们希望把clean当作一个特殊的名字使用,不管它存在不存在都要更新,可以添一条特殊规则, 把clean声明为一个伪目标: .PHONY: clean 这条规则没有命令列表。类似.PHONY这种make内建的特殊目标还有很多,各有不同的用途,详 见[GNUmake]。在C语言中要求变量和函数先声明后使用,而Makefile不太一样,这条规则写 在clean:规则的后面也行,也能起到声明clean是伪目标的作用: clean: @echo "cleanning project" -rm main *.o @echo "clean completed" .PHONY: clean 当然写在前面也行。gcc处理一个C程序分为预处理和编译两个阶段,类似地,make处 理Makefile的过程也分为两个阶段: 1. 首先从前到后读取所有规则,建立起一个完整的依赖关系图,例如: 图 22.1. Makefile的依赖关系图 2. 然后从缺省目标或者命令行指定的目标开始,根据依赖关系图选择适当的规则执行,执 行Makefile中的规则和执行C代码不一样,并不是从前到后按顺序执行,也不是所有规则 都要执行一遍,例如make缺省目标时不会更新clean目标,因为从上图可以看出,它跟缺 省目标没有任何依赖关系。 clean目标是一个约定俗成的名字,在所有软件项目的Makefile中都表示清除编译生成的文件, 类似这样的约定俗成的目标名字有: all,执行主要的编译工作,通常用作缺省目标。 install,执行编译后的安装工作,把可执行文件、配置文件、文档等分别拷到不同的安 装目录。 clean,删除编译生成的二进制文件。 distclean,不仅删除编译生成的二进制文件,也删除其它生成的文件,例如配置文件和 格式转换后的文档,执行make distclean之后应该清除所有这些文件,只留下源文件。 [31] 只要符合本章所描述的语法的文件我们都叫它Makefile,而它的文件名则不一定 是Makefile。事实上,执行make命令时,是按照GNUmakefile、makefile、Makefile的顺序找到 第一个存在的文件并执行它,不过还是建议使用Makefile做文件名。除了GNU make,有 些UNIX系统的make命令不是GNU make,不会查找GNUmakefile这个文件名,如果你写 的Makefile包含GNU make的特殊语法,可以起名为GNUmakefile,否则不建议用这个文件名。 上一页 第 22 章 Makefile基础 上一级 起始页 下一页 2. 隐含规则和模式规则 上一页 2. 隐含规则和模式规则 第 22 章 Makefile基础 下一页 2. 隐含规则和模式规则 上一节的Makefile写得中规中矩,比较繁琐,是为了讲清楚基本概念,其实Makefile有很多灵活 的写法,可以写得更简洁,同时减少出错的可能。本节我们来看看这样一个例子还有哪些改进 的余地。 一个目标依赖的所有条件不一定非得写在一条规则中,也可以拆开写,例如: main.o: main.h stack.h maze.h main.o: main.c gcc -c main.c 就相当于: main.o: main.c main.h stack.h maze.h gcc -c main.c 如果一个目标拆开写多条规则,其中只有一条规则允许有命令列表,其它规则应该没有命令列 表,否则make会报警告并且采用最后一条规则的命令列表。 这样我们的例子可以改写成: main: main.o stack.o maze.o gcc main.o stack.o maze.o -o main main.o: main.h stack.h maze.h stack.o: stack.h main.h maze.o: maze.h main.h main.o: main.c gcc -c main.c stack.o: stack.c gcc -c stack.c maze.o: maze.c gcc -c maze.c clean: -rm main *.o .PHONY: clean 这不是比原来更繁琐了吗?现在可以把提出来的三条规则删去,写成: main: main.o stack.o maze.o gcc main.o stack.o maze.o -o main main.o: main.h stack.h maze.h stack.o: stack.h main.h maze.o: maze.h main.h clean: -rm main *.o .PHONY: clean 这就比原来简单多了。可是现在main.o、stack.o和maze.o这三个目标连编译命令都没有了,怎 么编译的呢?试试看: $ make cc -c -o main.o main.c cc -c -o stack.o stack.c cc -c -o maze.o maze.c gcc main.o stack.o maze.o -o main 现在解释一下前三条编译命令是怎么来。如果一个目标在Makefile中的所有规则都没有命令列 表,make会尝试在内建的隐含规则(Implicit Rule)数据库中查找适用的规则。make的隐含规则 数据库可以用make -p命令打印,打印出来的格式也是Makefile的格式,包括很多变量和规则, 其中和我们这个例子有关的隐含规则有: # default OUTPUT_OPTION = -o $@ # default CC = cc # default COMPILE.c = $(CC) $(CFLAGS) $(CPPFLAGS) $(TARGET_ARCH) -c %.o: %.c # commands to execute (built-in): $(COMPILE.c) $(OUTPUT_OPTION) $< #号在Makefile中表示单行注释,就像C语言的//注释一样。CC是一个Makefile变量,用CC = cc定义和赋值,用$(CC)取它的值,其值应该是cc。Makefile变量像C的宏定义一样,代表一串 字符,在取值的地方展开。cc是一个符号链接,通常指向gcc,在有些UNIX系统上可能指向另 外一种C编译器。 $ which cc /usr/bin/cc $ ls -l /usr/bin/cc lrwxrwxrwx 1 root root 20 2008-07-04 05:59 /usr/bin/cc -> /etc/alternatives/cc $ ls -l /etc/alternatives/cc lrwxrwxrwx 1 root root 12 2008-11-01 09:10 /etc/alternatives/cc > /usr/bin/gcc CFLAGS这个变量没有定义,$(CFLAGS)展开是空,CPPFLAGS和TARGET_ARCH也是如此。这 样$(COMPILE.c)展开应该是cc 空 空 空 -c,去掉“空”得到cc -c,注意中间留下4个空格,所 以%.o: %.c规则的命令$(COMPILE.c) $(OUTPUT_OPTION) $<展开之后是cc -c -o $@ $<,和上面 的编译命令已经很接近了。 $@和$<是两个特殊的变量,$@的取值为规则中的目标,$<的取值为规则中的第一个条件。%.o: %.c是一种特殊的规则,称为模式规则(Pattern Rule)。现在回顾一下整个过程,在我们 的Makefile中以main.o为目标的规则都没有命令列表,所以make会查找隐含规则,发现隐含规则 中有这样一条模式规则适用,main.o符合%.o的模式,现在%就代表main(称为main.o这个名字 的Stem),再替换到%.c中就是main.c。所以这条模式规则相当于: main.o: main.c cc -c -o main.o main.c 随后,在处理stack.o目标时又用到这条模式规则,这时又相当于: stack.o: stack.c cc -c -o stack.o stack.c maze.o也同样处理。这三条规则可以由make的隐含规则推导出来,所以不必写在Makefile中。 先前我们写Makefile都是以目标为中心,一个目标依赖于若干条件,现在换个角度,以条件为中 心,Makefile还可以这么写: main: main.o stack.o maze.o gcc main.o stack.o maze.o -o main main.o stack.o maze.o: main.h main.o maze.o: maze.h main.o stack.o: stack.h clean: -rm main *.o .PHONY: clean 我们知道,写规则的目的是让make建立依赖关系图,不管怎么写,只要把所有的依赖关系都描 述清楚了就行。对于多目标的规则,make会拆成几条单目标的规则来处理,例如 target1 target2: prerequisite1 prerequisite2 command $< -o $@ 这样一条规则相当于: target1: prerequisite1 prerequisite2 command prerequisite1 -o target1 target2: prerequisite1 prerequisite2 command prerequisite1 -o target2 注意两条规则的命令列表是一样的,但$@的取值不同。 上一页 1. 基本规则 上一级 起始页 下一页 3. 变量 上一页 3. 变量 第 22 章 Makefile基础 下一页 3. 变量 这一节我们详细看看Makefile中关于变量的语法规则。先看一个简单的例子: foo = $(bar) bar = Huh? all: @echo $(foo) 我们执行make将会打出Huh?。当make读到foo = $(bar)时,确定foo的值是$(bar),但并不立即 展开$(bar),然后读到bar = Huh?,确定bar的值是Huh?,然后在执行规则all:的命令列表时才 需要展开$(foo),得到$(bar),再展开$(bar),得到Huh?。因此,虽然bar的定义写在foo之 后,$(foo)展开还是能够取到$(bar)的值。 这种特性有好处也有坏处。好处是我们可以把变量的值推迟到后面定义,例如: main.o: main.c $(CC) $(CFLAGS) $(CPPFLAGS) -c $< CC = gcc CFLAGS = -O -g CPPFLAGS = -Iinclude 编译命令可以展开成gcc -O -g -Iinclude -c main.c。通常把CFLAGS定义成一些编译选项,例 如-O、-g等,而把CPPFLAGS定义成一些预处理选项,例如-D、-I等。用=号定义变量的延迟展开 特性也有坏处,就是有可能写出无穷递归的定义,例如CFLAGS = $(CFLAGS) -O,或者: A = $(B) B = $(A) 当然,make有能力检测出这样的错误而不会陷入死循环。有时候我们希望make在遇到变量定义 时立即展开,可以用:=运算符,例如: x := foo y := $(x) bar all: @echo "-$(y)-" 当make读到y := $(x) bar定义时,立即把$(x)展开,使变量y的取值是foo bar,如果把这两行 颠倒过来: y := $(x) bar x := foo 那么当make读到y := $(x) bar时,x还没有定义,展开为空值,所以y的取值是 bar,注 意bar前面有个空格。一个变量的定义从=后面的第一个非空白字符开始(从$(x)的$开始),包 括后面的所有字符,直到注释或换行之前结束。如果要定义一个变量的值是一个空格,可以这 样: nullstring := space := $(nullstring) # end of the line nullstring的值为空,space的值是一个空格,后面写个注释是为了增加可读性,如果不写注释 就换行,则很难看出$(nullstring)后面有个空格。 还有一个比较有用的赋值运算符是?=,例如foo ?= $(bar)的意思是:如果foo没有定义过,那 么?=相当于=,定义foo的值是$(bar),但不立即展开;如果先前已经定义了foo,则什么也不 做,不会给foo重新赋值。 +=运算符可以给变量追加值,例如: objects = main.o objects += $(foo) foo = foo.o bar.o object是用=定义的,+=仍然保持=的特性,objects的值是main.o $(foo)(注意$(foo)前面自动 添一个空格),但不立即展开,等到后面需要展开$(objects)时会展开成main.o foo.o bar.o。 再比如: objects := main.o objects += $(foo) foo = foo.o bar.o object是用:=定义的,+=保持:=的特性,objects的值是main.o $(foo),立即展开得到main.o (这时foo还没定义),注意main.o后面的空格仍保留。 如果变量还没有定义过就直接用+=赋值,那么+=相当于=。 上一节我们用到了特殊变量$@和$<,这两个变量的特点是不需要给它们赋值,在不同的上下文 中它们自动取不同的值。常用的特殊变量有: $@,表示规则中的目标。 $<,表示规则中的第一个条件。 $?,表示规则中所有比目标新的条件,组成一个列表,以空格分隔。 $^,表示规则中的所有条件,组成一个列表,以空格分隔。 例如前面写过的这条规则: main: main.o stack.o maze.o gcc main.o stack.o maze.o -o main 可以改写成: main: main.o stack.o maze.o gcc $^ -o $@ 这样即使以后又往条件里添加了新的目标文件,编译命令也不需要修改,减少了出错的可能。 $?变量也很有用,有时候希望只对更新过的条件进行操作,例如有一个库文件libsome.a依赖于 几个目标文件: libsome.a: foo.o bar.o lose.o win.o ar r libsome.a $? ranlib libsome.a 这样,只有更新过的目标文件才需要重新打包到libsome.a中,没更新过的目标文件原本已经 在libsome.a中了,不必重新打包。 在上一节我们看到make的隐含规则数据库中用到了很多变量,有些变量没有定义(例 如CFLAGS),有些变量定义了缺省值(例如CC),我们写Makefile时可以重新定义这些变量的 值,也可以在缺省值的基础上追加。以下列举一些常用的变量,请读者体会其中的规律。 AR 静态库打包命令的名字,缺省值是ar。 ARFLAGS 静态库打包命令的选项,缺省值是rv。 AS 汇编器的名字,缺省值是as。 ASFLAGS 汇编器的选项,没有定义。 CC C编译器的名字,缺省值是cc。 CFLAGS C编译器的选项,没有定义。 CXX C++编译器的名字,缺省值是g++。 CXXFLAGS C++编译器的选项,没有定义。 CPP C预处理器的名字,缺省值是$(CC) -E。 CPPFLAGS C预处理器的选项,没有定义。 LD 链接器的名字,缺省值是ld。 LDFLAGS 链接器的选项,没有定义。 TARGET_ARCH 和目标平台相关的命令行选项,没有定义。 OUTPUT_OPTION 输出的命令行选项,缺省值是-o $@。 LINK.o 把.o文件链接在一起的命令行,缺省值是$(CC) $(LDFLAGS) $(TARGET_ARCH)。 LINK.c 把.c文件链接在一起的命令行,缺省值是$(CC) $(CFLAGS) $(CPPFLAGS) $(LDFLAGS) $(TARGET_ARCH)。 LINK.cc 把.cc文件(C++源文件)链接在一起的命令行,缺省值是$(CXX) $(CXXFLAGS) $(CPPFLAGS) $(LDFLAGS) $(TARGET_ARCH)。 COMPILE.c 编译.c文件的命令行,缺省值是$(CC) $(CFLAGS) $(CPPFLAGS) $(TARGET_ARCH) -c。 COMPILE.cc 编译.cc文件的命令行,缺省值是$(CXX) $(CXXFLAGS) $(CPPFLAGS) $(TARGET_ARCH) -c。 RM 删除命令的名字,缺省值是rm -f。 上一页 2. 隐含规则和模式规则 上一级 起始页 下一页 4. 自动处理头文件的依赖关系 上一页 4. 自动处理头文件的依赖关系 第 22 章 Makefile基础 下一页 4. 自动处理头文件的依赖关系 现在我们的Makefile写成这样: all: main main: main.o stack.o maze.o gcc $^ -o $@ main.o: main.h stack.h maze.h stack.o: stack.h main.h maze.o: maze.h main.h clean: -rm main *.o .PHONY: clean 按照惯例,用all做缺省目标。现在还有一点比较麻烦,在写main.o、stack.o和maze.o这三个目 标的规则时要查看源代码,找出它们依赖于哪些头文件,这很容易出错,一是因为有的头文件 包含在另一个头文件中,在写规则时很容易遗漏,二是如果以后修改源代码改变了依赖关系, 很可能忘记修改Makefile的规则。为了解决这个问题,可以用gcc的-M选项自动生成目标文件和 源文件的依赖关系: $ gcc -M main.c main.o: main.c /usr/include/stdio.h /usr/include/features.h \ /usr/include/sys/cdefs.h /usr/include/bits/wordsize.h \ /usr/include/gnu/stubs.h /usr/include/gnu/stubs-32.h \ /usr/lib/gcc/i486-linux-gnu/4.3.2/include/stddef.h \ /usr/include/bits/types.h /usr/include/bits/typesizes.h \ /usr/include/libio.h /usr/include/_G_config.h /usr/include/wchar.h \ /usr/lib/gcc/i486-linux-gnu/4.3.2/include/stdarg.h \ /usr/include/bits/stdio_lim.h /usr/include/bits/sys_errlist.h main.h \ stack.h maze.h -M选项把stdio.h以及它所包含的系统头文件也找出来了,如果我们不需要输出系统头文件的依 赖关系,可以用-MM选项: $ gcc -MM *.c main.o: main.c main.h stack.h maze.h maze.o: maze.c maze.h main.h stack.o: stack.c stack.h main.h 接下来的问题是怎么把这些规则包含到Makefile中,GNU make的官方手册建议这样写: all: main main: main.o stack.o maze.o gcc $^ -o $@ clean: -rm main *.o .PHONY: clean sources = main.c stack.c maze.c include $(sources:.c=.d) %.d: %.c set -e; rm -f $@; \ $(CC) -MM $(CPPFLAGS) $< > $@.$$$$; \ sed 's,\($*\)\.o[ :]*,\1.o $@ : ,g' < $@.$$$$ > $@; \ rm -f $@.$$$$ sources变量包含我们要编译的所有.c文件,$(sources:.c=.d)是一个变量替换语法, 把sources变量中每一项的.c替换成.d,所以include这一句相当于: include main.d stack.d maze.d 类似于C语言的#include指示,这里的include表示包含三个文件main.d、stack.d和maze.d,这 三个文件也应该符合Makefile的语法。如果现在你的工作目录是干净的,只有.c文件、.h文件 和Makefile,运行make的结果是: $ make Makefile:13: main.d: No such file or directory Makefile:13: stack.d: No such file or directory Makefile:13: maze.d: No such file or directory set -e; rm -f maze.d; \ cc -MM maze.c > maze.d.$$; \ sed 's,\(maze\)\.o[ :]*,\1.o maze.d : ,g' < maze.d.$$ > maze.d; \ rm -f maze.d.$$ set -e; rm -f stack.d; \ cc -MM stack.c > stack.d.$$; \ sed 's,\(stack\)\.o[ :]*,\1.o stack.d : ,g' < stack.d.$$ > stack.d; \ rm -f stack.d.$$ set -e; rm -f main.d; \ cc -MM main.c > main.d.$$; \ sed 's,\(main\)\.o[ :]*,\1.o main.d : ,g' < main.d.$$ > main.d; \ rm -f main.d.$$ cc -c -o main.o main.c cc -c -o stack.o stack.c cc -c -o maze.o maze.c gcc main.o stack.o maze.o -o main 一开始找不到.d文件,所以make会报警告。但是make会把include的文件名也当作目标来尝试更 新,而这些目标适用模式规则%.d: %c,所以执行它的命令列表,比如生成maze.d的命令: set -e; rm -f maze.d; \ cc -MM maze.c > maze.d.$$; \ sed 's,\(maze\)\.o[ :]*,\1.o maze.d : ,g' < maze.d.$$ > maze.d; \ rm -f maze.d.$$ 注意,虽然在Makefile中这个命令写了四行,但其实是一条命令,make只创建一个Shell进程执 行这条命令,这条命令分为5个子命令,用;号隔开,并且为了美观,用续行符\拆成四行来写。 执行步骤为: 1. set -e命令设置当前Shell进程为这样的状态:如果它执行的任何一条命令的退出状态非零 则立刻终止,不再执行后续命令。 2. 把原来的maze.d删掉。 3. 重新生成maze.c的依赖关系,保存成文件maze.d.1234(假设当前Shell进程 的id是1234)。注意,在Makefile中$有特殊含义,如果要表示它的字面意思则需要写两 个$,所以Makefile中的四个$传给Shell变成两个$,两个$在Shell中表示当前进程的id,一 般用它给临时文件起名,以保证文件名唯一。 4. 这个sed命令比较复杂,就不细讲了,主要作用是查找替换。maze.d.1234的内容应该 是maze.o: maze.c maze.h main.h,经过sed处理之后存为maze.d,其内容是maze.o maze.d: maze.c maze.h main.h。 5. 最后把临时文件maze.d.1234删掉。 不管是Makefile本身还是被它包含的文件,只要有一个文件在make过程中被更新了,make就会重 新读取整个Makefile以及被它包含的所有文件,现在main.d、stack.d和maze.d都生成了,就可 以正常包含进来了(假如这时还没有生成,make就要报错而不是报警告了),相当于 在Makefile中添了三条规则: main.o main.d: main.c main.h stack.h maze.h maze.o maze.d: maze.c maze.h main.h stack.o stack.d: stack.c stack.h main.h 如果我在main.c中加了一行#include "foo.h",那么: 1、main.c的修改日期变了,根据规则main.o main.d: main.c main.h stack.h maze.h要重新生 成main.o和main.d。生成main.o的规则有两条: main.o: main.c main.h stack.h maze.h %.o: %.c # commands to execute (built-in): $(COMPILE.c) $(OUTPUT_OPTION) $< 第一条是把规则main.o main.d: main.c main.h stack.h maze.h拆开写得到的,第二条是隐含 规则,因此执行cc命令重新编译main.o。生成main.d的规则也有两条: main.d: main.c main.h stack.h maze.h %.d: %.c set -e; rm -f $@; \ $(CC) -MM $(CPPFLAGS) $< > $@.$$$$; \ sed 's,\($*\)\.o[ :]*,\1.o $@ : ,g' < $@.$$$$ > $@; \ rm -f $@.$$$$ 因此main.d的内容被更新为main.o main.d: main.c main.h stack.h maze.h foo.h。 2、由于main.d被Makefile包含,main.d被更新又导致make重新读取整个Makefile,把新 的main.d包含进来,于是新的依赖关系生效了。 上一页 3. 变量 上一级 起始页 下一页 5. 常用的make命令行选项 上一页 5. 常用的make命令行选项 第 22 章 Makefile基础 下一页 5. 常用的make命令行选项 -n选项只打印要执行的命令,而不会真的执行命令,这个选项有助于我们检查Makefile写得是否 正确,由于Makefile不是顺序执行的,用这个选项可以先看看命令的执行顺序,确认无误了再真 正执行命令。 -C选项可以切换到另一个目录执行那个目录下的Makefile,比如先退到上一级目录再执行我们 的Makefile(假设我们的源代码都放在testmake目录下): $ cd .. $ make -C testmake make: Entering directory `/home/djkings/testmake' cc -c -o main.o main.c cc -c -o stack.o stack.c cc -c -o maze.o maze.c gcc main.o stack.o maze.o -o main make: Leaving directory `/home/djkings/testmake' 一些规模较大的项目会把不同的模块或子系统的源代码放在不同的子目录中,然后在每个子目 录下都写一个该目录的Makefile,然后在一个总的Makefile中用make -C命令执行每个子目录下 的Makefile。例如Linux内核源代码根目录下有Makefile,子目录fs、net等也有各自 的Makefile,二级子目录fs/ramfs、net/ipv4等也有各自的Makefile。 在make命令行也可以用=或:=定义变量,如果这次编译我想加调试选项-g,但我不想每次编译都 加-g选项,可以在命令行定义CFLAGS变量,而不必修改Makefile编译完了再改回来: $ make CFLAGS=-g cc -g -c -o main.o main.c cc -g -c -o stack.o stack.c cc -g -c -o maze.o maze.c gcc main.o stack.o maze.o -o main 如果在Makefile中也定义了CFLAGS变量,则命令行的值覆盖Makefile中的值。 上一页 4. 自动处理头文件的依赖关系 上一级 起始页 下一页 第 23 章 指针 上一页 第 23 章 指针 目录 1. 指针的基本操作 2. 指针类型的参数和返回值 3. 指针与数组 4. 指针与const限定符 5. 指针与结构体 6. 指向指针的指针与指针数组 7. 指向数组的指针与多维数组 8. 函数类型和函数指针类型 9. 不完全类型和复杂声明 上一页 5. 常用的make命令行选项 第 23 章 指针 部分 II. C语言本质 上一级 起始页 下一页 下一页 1. 指针的基本操作 上一页 1. 指针的基本操作 第 23 章 指针 下一页 1. 指针的基本操作 在第 12 章 栈与队列讲过,堆栈有栈顶指针,队列有头指针和尾指针,这些概念中的“指针”本质 上是一个整数,是数组的索引,通过指针访问数组中的某个元素。在图 20.3 “间接寻址”我们又 看到另外一种指针的概念,把一个变量所在的内存单元的地址保存在另外一个内存单元中,保 存地址的这个内存单元称为指针,通过指针和间接寻址访问变量,这种指针在C语言中可以用一 个指针类型的变量表示,例如某程序中定义了以下全局变量: int i; int *pi = &i; char c; char *pc = &c; 这几个变量的内存布局如下图所示,在初学阶段经常要借助于这样的图来理解指针。 图 23.1. 指针的基本概念 这里的&是取地址运算符(Address Operator),&i表示取变量i的地址,int *pi = &i;表示定 义一个指向int型的指针变量pi,并用i的地址来初始化pi。我们讲过全局变量只能用常量表达 式初始化,如果定义int p = i;就错了,因为i不是常量表达式,然而用i的地址来初始化一个 指针却没有错,因为i的地址是在编译链接时能确定的,而不需要到运行时才知道,&i是常量表 达式。后面两行代码定义了一个字符型变量c和一个指向c的字符型指针pc,注意pi和pc虽然是 不同类型的指针变量,但它们的内存单元都占4个字节,因为要保存32位的虚拟地址,同理, 在64位平台上指针变量都占8个字节。 我们知道,在同一个语句中定义多个数组,每一个都要有[]号:int a[5], b[5];。同样道理, 在同一个语句中定义多个指针变量,每一个都要有*号,例如: int *p, *q; 如果写成int* p, q;就错了,这样是定义了一个整型指针p和一个整型变量q,定义数组的[]号 写在变量后面,而定义指针的*号写在变量前面,更容易看错。定义指针的*号前后空格都可以 省,写成int*p,*q;也算对,但*号通常和类型int之间留空格而和变量名写在一起,这样看int *p, q;就很明显是定义了一个指针和一个整型变量,就不容易看错了。 如果要让pi指向另一个整型变量j,可以重新对pi赋值: pi = &j; 如果要改变pi所指向的整型变量的值,比如把变量j的值增加10,可以写: *pi = *pi + 10; 这里的*号是指针间接寻址运算符(Indirection Operator),*pi表示取指针pi所指向的变量的 值,也称为Dereference操作,指针有时称为变量的引用(Reference),所以根据指针找到变 量称为Dereference。 &运算符的操作数必须是左值,因为只有左值才表示一个内存单元,才会有地址,运算结果是指 针类型。*运算符的操作数必须是指针类型,运算结果可以做左值。所以,如果表达式E可以做 左值,*&E和E等价,如果表达式E是指针类型,&*E和E等价。 指针之间可以相互赋值,也可以用一个指针初始化另一个指针,例如: int *ptri = pi; 或者: int *ptri; ptri = pi; 表示pi指向哪就让ptri也指向哪,本质上就是把变量pi所保存的地址值赋给变量ptri。 用一个指针给另一个指针赋值时要注意,两个指针必须是同一类型的。在我们的例子 中,pi是int *型的,pc是char *型的,pi = pc;这样赋值就是错误的。但是可以先强制类型转 换然后赋值: pi = (int *)pc; 图 23.2. 把char *指针的值赋给int *指针 现在pi指向的地址和pc一样,但是通过*pc只能访问到一个字节,而通过*pi可以访问到4个字 节,后3个字节已经不属于变量c了,除非你很确定变量c的一个字节和后面3个字节组合而成 的int值是有意义的,否则就不应该给pi这么赋值。因此使用指针要特别小心,很容易将指针指 向错误的地址,访问这样的地址可能导致段错误,可能读到无意义的值,也可能意外改写了某 些数据,使得程序在随后的运行中出错。有一种情况需要特别注意,定义一个指针类型的局部 变量而没有初始化: int main(void) { int *p; ...... *p = 0; ...... } 我们知道,在堆栈上分配的变量初始值是不确定的,也就是说指针p所指向的内存地址是不确定 的,后面用*p访问不确定的地址就会导致不确定的后果,如果导致段错误还比较容易改正,如 果意外改写了数据而导致随后的运行中出错,就很难找到错误原因了。像这种指向不确定地址 的指针称为“野指针”(Unbound Pointer),为避免出现野指针,在定义指针变量时就应该给它 明确的初值,或者把它初始化为NULL: int main(void) { int *p = NULL; ...... *p = 0; ...... } NULL在C标准库的头文件stddef.h中定义: #define NULL ((void *)0) 就是把地址0转换成指针类型,称为空指针,它的特殊之处在于,操作系统不会把任何数据保存 在地址0及其附近,也不会把地址0~0xfff的页面映射到物理内存,所以任何对地址0的访问都会 立刻导致段错误。*p = 0;会导致段错误,就像放在眼前的炸弹一样很容易找到,相比之下,野 指针的错误就像埋下地雷一样,更难发现和排除,这次走过去没事,下次走过去就有事。 讲到这里就该讲一下void *类型了。在编程时经常需要一种通用指针,可以转换为任意其它类 型的指针,任意其它类型的指针也可以转换为通用指针,最初C语言没有void *类型,就把char *当通用指针,需要转换时就用类型转换运算符(),ANSI在将C语言标准化时引入了void *类 型,void *指针与其它类型的指针之间可以隐式转换,而不必用类型转换运算符。注意,只能 定义void *指针,而不能定义void型的变量,因为void *指针和别的指针一样都占4个字节,而 如果定义void型变量(也就是类型暂时不确定的变量),编译器不知道该分配几个字节给变 量。同样道理,void *指针不能直接Dereference,而必须先转换成别的类型的指针再 做Dereference。void *指针常用于函数接口,比如: void func(void *pv) { /* *pv = 'A' is illegal */ char *pchar = pv; *pchar = 'A'; } int main(void) { char c; func(&c); ... } 下一章讲函数接口时再详细介绍void *指针的用处。 上一页 第 23 章 指针 上一级 起始页 下一页 2. 指针类型的参数和返回值 上一页 2. 指针类型的参数和返回值 第 23 章 指针 2. 指针类型的参数和返回值 首先看以下程序: 例 23.1. 指针参数和返回值 #include int *swap(int *px, int *py) { int temp; temp = *px; *px = *py; *py = temp; return px; } int main(void) { int i = 10, j = 20; int *p = swap(&i, &j); printf("now i=%d j=%d *p=%d\n", i, j, *p); return 0; } 下一页 我们知道,调用函数的传参过程相当于用实参定义并初始化形参,swap(&i, &j)这个调用相当 于: int *px = &i; int *py = &j; 所以px和py分别指向main函数的局部变量i和j,在swap函数中读写*px和*py其实是读写main函 数的i和j。尽管在swap函数的作用域中访问不到i和j这两个变量名,却可以通过地址访问它 们,最终swap函数将i和j的值做了交换。 上面的例子还演示了函数返回值是指针的情况,return px;语句相当于定义了一个临时变量并 用px初始化: int *tmp = px; 然后临时变量tmp的值成为表达式swap(&i, &j)的值,然后在main函数中又把这个值赋给了p, 相当于: int *p = tmp; 最后的结果是swap函数的px指向哪就让main函数的p指向哪。我们知道px指向i,所以p也指 向i。 习题 1、对照本节的描述,像图 23.1 “指针的基本概念”那样画图理解函数的调用和返回过程。在下一 章我们会看到更复杂的参数和返回值形式,在初学阶段对每个程序都要画图理解它的运行过 程,只要基本概念清晰,无论多复杂的形式都应该能正确分析。 2、现在回头看第 3 节 “形参和实参”的习题1,那个程序应该怎么改? 上一页 1. 指针的基本操作 上一级 起始页 下一页 3. 指针与数组 上一页 3. 指针与数组 第 23 章 指针 下一页 3. 指针与数组 先看个例子,有如下语句: int a[10]; int *pa = &a[0]; pa++; 首先指针pa指向a[0]的地址,注意后缀运算符的优先级高于单目运算符,所以是取a[0]的地 址,而不是取a的地址。然后pa++让pa指向下一个元素(也就是a[1]),由于pa是int *指针, 一个int型元素占4个字节,所以pa++使pa所指向的地址加4,注意不是加1。 下面画图理解。从前面的例子我们发现,地址的具体数值其实无关紧要,关键是要说明地址之 间的关系(a[1]位于a[0]之后4个字节处)以及指针与变量之间的关系(指针保存的是变量的地 址),现在我们换一种画法,省略地址的具体数值,用方框表示存储空间,用箭头表示指针和 变量之间的关系。 图 23.3. 指针与数组 既然指针可以用++运算符,当然也可以用+、-运算符,pa+2这个表达式也是有意义的,如上图 所示,pa指向a[1],那么pa+2指向a[3]。事实上,E1[E2]这种写法和(*((E1)+(E2)))是等价 的,*(pa+2)也可以写成pa[2],pa就像数组名一样,其实数组名也没有什么特殊的,a[2]之所 以能取数组的第2个元素,是因为它等价于*(a+2),在第 1 节 “数组的基本操作”讲过数组名做右 值时自动转换成指向首元素的指针,所以a[2]和pa[2]本质上是一样的,都是通过指针间接寻址 访问元素。由于(*((E1)+(E2)))显然可以写成(*((E2)+(E1))),所以E1[E2]也可以写成E2[E1], 这意味着2[a]、2[pa]这种写法也是对的,但一般不这么写。另外,由于a做右值使用时 和&a[0]是一个意思,所以int *pa = &a[0];通常不这么写,而是写成更简洁的形式int *pa = a;。 在第 1 节 “数组的基本操作”还讲过C语言允许数组下标是负数,现在你该明白为什么这样规定 了。在上面的例子中,表达式pa[-1]是合法的,它和a[0]表示同一个元素。 现在猜一下,两个指针变量做比较运算(>、>=、<、<=、==、!=)表示什么意义?两个指针变 量做减法运算又表示什么意义? 根据什么来猜?根据第 3 节 “形参和实参”讲过的Rule of Least Surprise原则。你理解了指针和 常数加减的概念,再根据以往使用比较运算的经验,就应该猜到pa + 2 > pa,pa - 1 == a, 所以指针之间的比较运算比的是地址,C语言正是这样规定的,不过C语言的规定更为严谨,只 有指向同一个数组中元素的指针之间相互比较才有意义,否则没有意义。那么两个指针相减表 示什么?pa - a等于几?因为pa - 1 == a,所以pa - a显然应该等于1,指针相减表示两个指 针之间相差的元素个数,同样只有指向同一个数组中元素的指针之间相减才有意义。两个指针 相加表示什么?想不出来它能有什么意义,因此C语言也规定两个指针不能相加。假如C语言为 指针相加也规定了一种意义,那就相当Surprise了,不符合一般的经验。无论是设计编程语言还 是设计函数接口或人机界面都是这个道理,应该尽可能让用户根据以往的经验知识就能推断出 该系统的基本用法。 在取数组元素时用数组名和用指针的语法一样,但如果把数组名做左值使用,和指针就有区别 了。例如pa++是合法的,但a++就不合法,pa = a + 1是合法的,但a = pa + 1就不合法。数组 名做右值时转换成指向首元素的指针,但做左值仍然表示整个数组的存储单元,而不是首元素 的存储单元,数组名做左值还有一点特殊之处,不支持++、赋值这些运算符,但支持取地址运 算符&,所以&a是合法的,我们将在第 7 节 “指向数组的指针与多维数组”介绍这种语法。 在函数原型中,如果参数是数组,则等价于参数是指针的形式,例如: void func(int a[10]) { ... } 等价于: void func(int *a) { ... } 第一种形式方括号中的数字可以不写,仍然是等价的: void func(int a[]) { ... } 参数写成指针形式还是数组形式对编译器来说没区别,都表示这个参数是指针,之所以规定两 种形式是为了给读代码的人提供有用的信息,如果这个参数指向一个元素,通常写成指针的形 式,如果这个参数指向一串元素中的首元素,则经常写成数组的形式。 上一页 2. 指针类型的参数和返回值 上一级 起始页 下一页 4. 指针与const限定符 上一页 4. 指针与const限定符 第 23 章 指针 下一页 4. 指针与const限定符 const限定符和指针结合起来常见的情况有以下几种。 const int *a; int const *a; 这两种写法是一样的,a是一个指向const int型的指针,a所指向的内存单元不可改写,所 以(*a)++是不允许的,但a可以改写,所以a++是允许的。 int * const a; a是一个指向int型的const指针,*a是可以改写的,但a不允许改写。 int const * const a; a是一个指向const int型的const指针,因此*a和a都不允许改写。 指向非const变量的指针或者非const变量的地址可以传给指向const变量的指针,编译器可以做 隐式类型转换,例如: char c = 'a'; const char *pc = &c; 但是,指向const变量的指针或者const变量的地址不可以传给指向非const变量的指针,以免透 过后者意外改写了前者所指向的内存单元,例如对下面的代码编译器会报警告: const char c = 'a'; char *pc = &c; 即使不用const限定符也能写出功能正确的程序,但良好的编程习惯应该尽可能多地使 用const,因为: 1. const给读代码的人传达非常有用的信息。比如一个函数的参数是const char *,你在调 用这个函数时就可以放心地传给它char *或const char *指针,而不必担心指针所指的内 存单元被改写。 2. 尽可能多地使用const限定符,把不该变的都声明成只读,这样可以依靠编译器检查程序 中的Bug,防止意外改写数据。 3. const对编译器优化是一个有用的提示,编译器也许会把const变量优化成常量。 在第 3 节 “变量的存储布局”我们看到,字符串字面值通常分配在.rodata段,而在第 4 节 “字符 串”提到,字符串字面值类似于数组名,做右值使用时自动转换成指向首元素的指针,这种指针 应该是const char *型。我们知道printf函数原型的第一个参数是const char *型,可以 把char *或const char *指针传给它,所以下面这些调用都是合法的: const char *p = "abcd"; const char str1[5] = "abcd"; char str2[5] = "abcd"; printf(p); printf(str1); printf(str2); printf("abcd"); 注意上面第一行,如果要定义一个指针指向字符串字面值,这个指针应该是const char *型, 如果写成char *p = "abcd";就不好了,有隐患,例如: int main(void) { char *p = "abcd"; ... *p = 'A'; ... } p指向.rodata段,不允许改写,但编译器不会报错,在运行时会出现段错误。 上一页 3. 指针与数组 上一级 起始页 下一页 5. 指针与结构体 上一页 5. 指针与结构体 第 23 章 指针 下一页 5. 指针与结构体 首先定义一个结构体类型,然后定义这种类型的变量和指针: struct unit { char c; int num; }; struct unit u; struct unit *p = &u; 要通过指针p访问结构体成员可以写成(*p).c和(*p).num,为了书写方便,C语言提供了->运算 符,也可以写成p->c和p->num。 上一页 4. 指针与const限定符 上一级 起始页 下一页 6. 指向指针的指针与指针数组 上一页 6. 指向指针的指针与指针数组 第 23 章 指针 下一页 6. 指向指针的指针与指针数组 指针可以指向基本类型,也可以指向复合类型,因此也可以指向另外一个指针变量,称为指向 指针的指针。 int i; int *pi = &i; int **ppi = π 这样定义之后,表达式*ppi取pi的值,表达式**ppi取i的值。请读者自己画图理 解i、pi、ppi这三个变量之间的关系。 很自然地,也可以定义指向“指向指针的指针”的指针,但是很少用到: int ***p; 数组中的每个元素可以是基本类型,也可以复合类型,因此也可以是指针类型。例如定义一个 数组a由10个元素组成,每个元素都是int *指针: int *a[10]; 这称为指针数组。int *a[10];和int **pa;之间的关系类似于int a[10];和int *pa;之间的关 系:a是由一种元素组成的数组,pa则是指向这种元素的指针。所以,如果pa指向a的首元素: int *a[10]; int **pa = &a[0]; 则pa[0]和a[0]取的是同一个元素,唯一比原来复杂的地方在于这个元素是一个int *指针,而 不是基本类型。 我们知道main函数的标准原型应该是int main(int argc, char *argv[]);。argc是命令行参数 的个数。而argv是一个指向指针的指针,为什么不是指针数组呢?因为前面讲过,函数原型中 的[]表示指针而不表示数组,等价于char **argv。那为什么要写成char *argv[]而不写成char **argv呢?这样写给读代码的人提供了有用信息,argv不是指向单个指针,而是指向一个指针 数组的首元素。数组中每个元素都是char *指针,指向一个命令行参数字符串。 例 23.2. 打印命令行参数 #include int main(int argc, char *argv[]) { int i; for(i = 0; i < argc; i++) printf("argv[%d]=%s\n", i, argv[i]); return 0; } 编译执行: $ gcc main.c $ ./a.out a b c argv[0]=./a.out argv[1]=a argv[2]=b argv[3]=c $ ln -s a.out printargv $ ./printargv d e argv[0]=./printargv argv[1]=d argv[2]=e 注意程序名也算一个命令行参数,所以执行./a.out a b c这个命令时,argc是4,argv如下图 所示: 图 23.4. argv指针数组 由于argv[4]是NULL,我们也可以这样循环遍历argv: for(i=0; argv[i] != NULL; i++) NULL标识着argv的结尾,这个循环碰到NULL就结束,因而不会访问越界,这种用法很形象地称 为Sentinel,NULL就像一个哨兵守卫着数组的边界。 在这个例子中我们还看到,如果给程序建立符号链接,然后通过符号链接运行这个程序,就可 以得到不同的argv[0]。通常,程序会根据不同的命令行参数做不同的事情,例如ls -l和ls R打印不同的文件列表,而有些程序会根据不同的argv[0]做不同的事情,例如专门针对嵌入式 系统的开源项目Busybox,将各种Linux命令裁剪后集于一身,编译成一个可执行文件busybox, 安装时将busybox程序拷到嵌入式系统的/bin目录下,同时 在/bin 、/sbin 、/usr/bin 、/usr/sbin 等目录下创建很多指向/bin/busybox的符号链接,命名 为cp、ls、mv、ifconfig等等,不管执行哪个命令其实最终都是在执行/bin/busybox,它会根 据argv[0]来区分不同的命令。 习题 1、想想以下定义中的const分别起什么作用?编写程序验证你的猜测。 const char **p; char *const *p; char **const p; 上一页 5. 指针与结构体 上一级 起始页 下一页 7. 指向数组的指针与多维数组 上一页 7. 指向数组的指针与多维数组 第 23 章 指针 下一页 7. 指向数组的指针与多维数组 指针可以指向复合类型,上一节讲了指向指针的指针,这一节学习指向数组的指针。以下定义 一个指向数组的指针,该数组有10个int元素: int (*a)[10]; 和上一节指针数组的定义int *a[10];相比,仅仅多了一个()括号。如何记住和区分这两种定义 呢?我们可以认为[]比*有更高的优先级,如果a先和*结合则表示a是一个指针,如果a先和[]结 合则表示a是一个数组。int *a[10];这个定义可以拆成两句: typedef int *t; t a[10]; t代表int *类型,a则是由这种类型的元素组成的数组。int (*a)[10];这个定义也可以拆成两 句: typedef int t[10]; t *a; t代表由10个int组成的数组类型,a则是指向这种类型的指针。 现在看指向数组的指针如何使用: int a[10]; int (*pa)[10] = &a; a是一个数组,在&a这个表达式中,数组名做左值,取整个数组的首地址赋给指针pa。注 意,&a[0]表示数组a的首元素的首地址,而&a表示数组a的首地址,显然这两个地址的数值相 同,但这两个表达式的类型是两种不同的指针类型,前者的类型是int *,而后者的类型是int (*)[10]。*pa就表示pa所指向的数组a,所以取数组的a[0]元素可以用表达式(*pa)[0]。注意 到*pa可以写成pa[0],所以(*pa)[0]这个表达式也可以改写成pa[0][0],pa就像一个二维数组的 名字,它表示什么含义呢?下面把pa和二维数组放在一起做个分析。 int a[5][10];和int (*pa)[10];之间的关系同样类似于int a[10];和int *pa;之间的关系:a是 由一种元素组成的数组,pa则是指向这种元素的指针。所以,如果pa指向a的首元素: int a[5][10]; int (*pa)[10] = &a[0]; 则pa[0]和a[0]取的是同一个元素,唯一比原来复杂的地方在于这个元素是由10个int组成的数 组,而不是基本类型。这样,我们可以把pa当成二维数组名来使用,pa[1][2]和a[1][2]取的也 是同一个元素,而且pa比a用起来更灵活,数组名不支持赋值、自增等运算,而指针可以支 持,pa++使pa跳过二维数组的一行(40个字节),指向a[1]的首地址。 习题 1、定义以下变量: char a[4][3][2] = {{{'a', 'b'}, {'c', 'd'}, {'e', 'f'}}, {{'g', 'h'}, {'i', 'j'}, {'k', 'l'}}, {{'m', 'n'}, {'o', 'p'}, {'q', 'r'}}, {{'s', 't'}, {'u', 'v'}, {'w', 'x'}}}; char (*pa)[2] = &a[1][0]; char (*ppa)[3][2] = &a[1]; 要想通过pa或ppa访问数组a中的'r'元素,分别应该怎么写? 上一页 6. 指向指针的指针与指针数组 上一级 起始页 下一页 8. 函数类型和函数指针类型 上一页 8. 函数类型和函数指针类型 第 23 章 指针 下一页 8. 函数类型和函数指针类型 在C语言中,函数也是一种类型,可以定义指向函数的指针。我们知道,指针变量的内存单元存 放一个地址值,而函数指针存放的就是函数的入口地址(位于.text段)。下面看一个简单的例 子: 例 23.3. 函数指针 #include void say_hello(const char *str) { printf("Hello %s\n", str); } int main(void) { void (*f)(const char *) = say_hello; f("Guys"); return 0; } 分析一下变量f的类型声明void (*f)(const char *),f首先跟*号结合在一起,因此是一个指 针。(*f)外面是一个函数原型的格式,参数是const char *,返回值是void,所以f是指向这种 函数的指针。而say_hello的参数是const char *,返回值是void,正好是这种函数,因此f可 以指向say_hello。注意,say_hello是一种函数类型,而函数类型和数组类型类似,做右值使用 时自动转换成函数指针类型,所以可以直接赋给f,当然也可以写成void (*f)(const char *) = &say_hello;,把函数say_hello先取地址再赋给f,就不需要自动类型转换了。 可以直接通过函数指针调用函数,如上面的f("Guys"),也可以先用*f取出它所指的函数类型, 再调用函数,即(*f)("Guys")。可以这么理解:函数调用运算符()要求操作数是函数指针,所 以f("Guys")是最直接的写法,而say_hello("Guys")或(*f)("Guys")则是把函数类型自动转换成 函数指针然后做函数调用。 下面再举几个例子区分函数类型和函数指针类型。首先定义函数类型F: typedef int F(void); 这种类型的函数不带参数,返回值是int。那么可以这样声明f和g: F f, g; 相当于声明: int f(void); int g(void); 下面这个函数声明是错误的: F h(void); 因为函数可以返回void类型、标量类型、结构体、联合体,但不能返回函数类型,也不能返回 数组类型。而下面这个函数声明是正确的: F *e(void); 函数e返回一个F *类型的函数指针。如果给e多套几层括号仍然表示同样的意思: F *((e))(void); 但如果把*号也套在括号里就不一样了: int (*fp)(void); 这样声明了一个函数指针,而不是声明一个函数。fp也可以这样声明: F *fp; 通过函数指针调用函数和直接调用函数相比有什么好处呢?我们研究一个例子。回顾第 3 节 “数 据类型标志”的习题1,由于结构体中多了一个类型字段,需要重新实 现real_part、img_part、magnitude、angle这些函数,你当时是怎么实现的?大概是这样吧: double real_part(struct complex_struct z) { if (z.t == RECTANGULAR) return z.a; else return z.a * cos(z.b); } 现在类型字段有两种取值,RECTANGULAR和POLAR,每个函数都要if ... else ...,如果类型字 段有三种取值呢?每个函数都要if ... else if ... else,或者switch ... case ...。这样 维护代码是不够理想的,现在我用函数指针给出一种实现: double rect_real_part(struct complex_struct z) { return z.a; } double rect_img_part(struct complex_struct z) { return z.b; } double rect_magnitude(struct complex_struct z) { return sqrt(z.a * z.a + z.b * z.b); } double rect_angle(struct complex_struct z) { double PI = acos(-1.0); if (z.a > 0) return atan(z.b / z.a); else return atan(z.b / z.a) + PI; } double pol_real_part(struct complex_struct z) { return z.a * cos(z.b); } double pol_img_part(struct complex_struct z) { return z.a * sin(z.b); } double pol_magnitude(struct complex_struct z) { return z.a; } double pol_angle(struct complex_struct z) { return z.b; } double (*real_part_tbl[])(struct complex_struct) = { rect_real_part, pol_real_part }; double (*img_part_tbl[])(struct complex_struct) = { rect_img_part, pol_img_part }; double (*magnitude_tbl[])(struct complex_struct) = { rect_magnitude, pol_magnitude }; double (*angle_tbl[])(struct complex_struct) = { rect_angle, pol_angle }; #define real_part(z) real_part_tbl[z.t](z) #define img_part(z) img_part_tbl[z.t](z) #define magnitude(z) magnitude_tbl[z.t](z) #define angle(z) angle_tbl[z.t](z) 当调用real_part(z)时,用类型字段z.t做索引,从指针数组real_part_tbl中取出相应的函数指 针来调用,也可以达到if ... else ...的效果,但相比之下这种实现更好,每个函数都只做一 件事情,而不必用if ... else ...兼顾好几件事情,比如rect_real_part和pol_real_part各做 各的,互相独立,而不必把它们的代码都耦合到一个函数中。“低耦合,高内聚”(Low Coupling, High Cohesion)是程序设计的一条基本原则,这样可以更好地复用现有代码,使代 码更容易维护。如果类型字段z.t又多了一种取值,只需要添加一组新的函数,修改函数指针数 组,原有的函数仍然可以不加改动地复用。 上一页 7. 指向数组的指针与多维数组 上一级 起始页 下一页 9. 不完全类型和复杂声明 上一页 9. 不完全类型和复杂声明 第 23 章 指针 下一页 9. 不完全类型和复杂声明 在第 1 节 “复合数据类型--结构体”讲过算术类型、标量类型的概念,现在又学习了几种类 型,我们完整地总结一下C语言的类型。下图出自[Standard C]。 图 23.5. C语言类型总结 C语言的类型分为函数类型、对象类型和不完全类型三大类。对象类型又分为标量类型和非标量 类型。指针类型属于标量类型,因此也可以做逻辑与、或、非运算的操作数 和if、for、while的控制表达式,NULL指针表示假,非NULL指针表示真。不完全类型是暂时没 有完全定义好的类型,编译器不知道这种类型该占几个字节的存储空间,例如: struct s; union u; char str[]; 具有不完全类型的变量可以通过多次声明组合成一个完全类型,比如数组str声明两次: char str[]; char str[10]; 当编译器碰到第一个声明时,认为str是一个不完全类型,碰到第二个声明时str就组合成完全 类型了,如果编译器处理到程序文件的末尾仍然无法把str组合成一个完全类型,就会报错。读 者可能会想,这个语法有什么用呢?为何不在第一次声明时就把str声明成完全类型?有些情况 下这么做有一定的理由,比如第一个声明是写在头文件里的,第二个声明写在.c文件里,这样 如果要改数组长度,只改.c文件就行了,头文件可以不用改。 不完全的结构体类型有重要作用: struct s { struct t *pt; }; struct t { struct s *ps; }; struct s和struct t各有一个指针成员指向另一种类型。编译器从前到后依次处理,当看 到struct s { struct t* pt; };时,认为struct t是一个不完全类型,pt是一个指向不完全类 型的指针,尽管如此,这个指针却是完全类型,因为不管什么指针都占4个字节存储空间,这一 点很明确。然后编译器又看到struct t { struct s *ps; };,这时struct t有了完整的定义, 就组合成一个完全类型了,pt的类型就组合成一个指向完全类型的指针。由于struct s在前面 有完整的定义,所以struct s *ps;也定义了一个指向完全类型的指针。 这样的类型定义是错误的: struct s { struct t ot; }; struct t { struct s os; }; 编译器看到struct s { struct t ot; };时,认为struct t是一个不完全类型,无法定义成 员ot,因为不知道它该占几个字节。所以结构体中可以递归地定义指针成员,但不能递归地定 义变量成员,你可以设想一下,假如允许递归地定义变量成员,struct s中有一个struct t,struct t中又有一个struct s,struct s又中有一个struct t,这就成了一个无穷递归的定 义。 以上是两个结构体构成的递归定义,一个结构体也可以递归定义: struct s { char data[6]; struct s* next; }; 当编译器处理到第一行struct s {时,认为struct s是一个不完全类型,当处理到第三行struct s *next;时,认为next是一个指向不完全类型的指针,当处理到第四行};时,struct s成了一个 完全类型,next也成了一个指向完全类型的指针。类似这样的结构体是很多种数据结构的基本 组成单元,如链表、二叉树等,我们将在后面详细介绍。下图示意了由几个struct s结构体组 成的链表,这些结构体称为链表的节点(Node)。 图 23.6. 链表 head指针是链表的头指针,指向第一个节点,每个节点的next指针域指向下一个节点,最后一 个节点的next指针域为NULL,在图中用0表示。 可以想像得到,如果把指针和数组、函数、结构体层层组合起来可以构成非常复杂的类型,下 面看几个复杂的声明。 typedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler); 这个声明来自signal(2)。sighandler_t是一个函数指针,它所指向的函数带一个参数,返回值 为void,signal是一个函数,它带两个参数,一个int参数,一个sighandler_t参数,返回值也 是sighandler_t参数。如果把这两行合成一行写,就是: void (*signal(int signum, void (*handler)(int)))(int); 在分析复杂声明时,要借助typedef把复杂声明分解成几种基本形式: T *p;,p是指向T类型的指针。 T a[];,a是由T类型的元素组成的数组,但有一个例外,如果a是函数的形参,则相当于T *a; T1 f(T2, T3...);,f是一个函数,参数类型是T2、T3等等,返回值类型是T1。 我们分解一下这个复杂声明: int (*(*fp)(void *))[10]; 1、fp和*号括在一起,说明fp是一个指针,指向T1类型: typedef int (*T1(void *))[10]; T1 *fp; 2、T1应该是一个函数类型,参数是void *,返回值是T2类型: typedef int (*T2)[10]; typedef T2 T1(void *); T1 *fp; 3、T2和*号括在一起,应该也是个指针,指向T3类型: typedef int T3[10]; typedef T3 *T2; typedef T2 T1(void *); T1 *fp; 显然,T3是一个int数组,由10个元素组成。分解完毕。 上一页 8. 函数类型和函数指针类型 上一级 起始页 下一页 第 24 章 函数接口 上一页 第 24 章 函数接口 部分 II. C语言本质 下一页 第 24 章 函数接口 目录 1. 本章的预备知识 1.1. strcpy与strncpy 1.2. malloc与free 2. 传入参数与传出参数 3. 两层指针的参数 4. 返回值是指针的情况 5. 回调函数 6. 可变参数 我们在第 6 节 “折半查找”讲过,函数的调用者和函数的实现者之间订立了一个契约,在调用函 数之前,调用者要为实现者提供某些条件,在函数返回时,实现者要对调用者尽到某些义务。 如何描述这个契约呢?首先靠函数接口来描述,即函数名,参数,返回值,只要函数和参数的 名字起得合理,参数和返回值的类型定得准确,至于这个函数怎么用,调用者单看函数接口就 能猜出八九分了。函数接口并不能表达函数的全部语义,这时文档就起了重要的补充作用,函 数的文档该写什么,怎么写,Man Page为我们做了很好的榜样。 函数接口一旦和指针结合起来就变得异常灵活,有五花八门的用法,但是万变不离其宗,只要 像图 23.1 “指针的基本概念”那样画图分析,指针的任何用法都能分析清楚,所以,如果上一章 你真正学明白了,本章不用学也能自己领悟出来,之所以写这一章是为了照顾悟性不高的读 者。本章把函数接口总结成几类常见的模式,对于每种模式,一方面讲函数接口怎么写,另一 方面讲函数的文档怎么写。 上一页 9. 不完全类型和复杂声明 上一级 起始页 下一页 1. 本章的预备知识 上一页 1. 本章的预备知识 第 24 章 函数接口 下一页 1. 本章的预备知识 这一节介绍本章的范例代码要用的几个C标准库函数。我们先体会一下这几个函数的接口是怎么 设计的,Man Page是怎么写的。其它常用的C标准库函数将在下一章介绍。 1.1. strcpy与strncpy 从现在开始我们要用到很多库函数,在学习每个库函数时一定要看Man Page。Man Page随时 都在我们手边,想查什么只要敲一个命令就行,然而很多初学者就是不喜欢看Man Page,宁可 满世界去查书、查资料,也不愿意看Man Page。据我分析原因有三: 1. 英文不好。那还是先学好了英文再学编程吧,否则即使你把这本书都学透了也一样无法胜 任开发工作,因为你没有进一步学习的能力。 2. Man Page的语言不够友好。Man Page不像本书这样由浅入深地讲解,而是平铺直叙,不 过看习惯了就好了,每个Man Page都不长,多看几遍自然可以抓住重点,理清头绪。本 节分析一个例子,帮助读者把握Man Page的语言特点。 3. Man Page通常没有例子。描述一个函数怎么用,一靠接口,二靠文档,而不是靠例子。 函数的用法无非是本章所总结的几种模式,只要把本章学透了,你就不需要每个函数都得 有个例子教你怎么用了。 总之,Man Page是一定要看的,一开始看不懂硬着头皮也要看,为了鼓励读者看Man Page, 本书不会像[K&R]那样把库函数总结成一个附录附在书后面。现在我们来分析strcpy(3)。 图 24.1. strcpy(3) 这个Man Page描述了两个函数,strcpy和strncpy,敲命令man strcpy或者man strncpy都可以 看到这个Man Page。这两个函数的作用是把一个字符串拷贝给另一个字符串。SYNOPSIS部分 给出了这两个函数的原型,以及要用这些函数需要包含哪些头文件。参数dest、src和n都加了 下划线,有时候并不想从头到尾阅读整个Man Page,而是想查一下某个参数的含义,通过下划 线和参数名就能很快找到你关心的部分。 dest表示Destination,src表示Source,看名字就能猜到是把src所指向的字符串拷贝到dest所 指向的内存空间。这一点从两个参数的类型也能看出来,dest是char *型的,而src是const char *型的,说明src所指向的内存空间在函数中只能读不能改写,而dest所指向的内存空间在 函数中是要改写的,显然改写的目的是当函数返回后调用者可以读取改写的结果。因此可以猜 到strcpy函数是这样用的: char buf[10]; strcpy(buf, "hello"); printf(buf); 至于strncpy的参数n是干什么用的,单从函数接口猜不出来,就需要看下面的文档。 图 24.2. strcpy(3) 在文档中强调了strcpy在拷贝字符串时会把结尾的'\0'也拷到dest中,因此保证了dest中是 以'\0'结尾的字符串。但另外一个要注意的问题是,strcpy只知道src字符串的首地址,不知道 长度,它会一直拷贝到'\0'为止,所以dest所指向的内存空间要足够大,否则有可能写越界, 例如: char buf[10]; strcpy(buf, "hello world"); 如果没有保证src所指向的内存空间以'\0'结尾,也有可能读越界,例如: char buf[10] = "abcdefghij", str[4] = "hell"; strcpy(buf, str); 因为strcpy函数的实现者通过函数接口无法得知src字符串的长度和dest内存空间的大小,所 以“确保不会写越界”应该是调用者的责任,调用者提供的dest参数应该指向足够大的内存空 间,“确保不会读越界”也是调用者的责任,调用者提供的src参数指向的内存应该确保以'\0'结 尾。 此外,文档中还强调了src和dest所指向的内存空间不能有重叠。凡是有指针参数的C标准库函 数基本上都有这条要求,每个指针参数所指向的内存空间互不重叠,例如这样调用是不允许 的: char buf[10] = "hello"; strcpy(buf, buf+1); strncpy的参数n指定最多从src中拷贝n个字节到dest中,换句话说,如果拷贝到'\0'就结束, 如果拷贝到n个字节还没有碰到'\0',那么也结束,调用者负责提供适当的n值,以确保读写不 会越界,比如让n的值等于dest所指向的内存空间的大小: char buf[10]; strncpy(buf, "hello world", sizeof(buf)); 然而这意味着什么呢?文档中特别用了Warning指出,这意味着dest有可能不是以'\0'结尾 的。例如上面的调用,虽然把"hello world"截断到10个字符拷贝至buf中,但buf不是以'\0'结 尾的,如果再printf(buf)就会读越界。如果你需要确保dest以'\0'结束,可以这么调用: char buf[10]; strncpy(buf, "hello world", sizeof(buf)); buf[sizeof(buf)-1] = '\0'; strncpy还有一个特性,如果src字符串全部拷完了不足n个字节,那么还差多少个字节就补多少 个'\0',但是正如上面所述,这并不保证dest一定以'\0'结束,当src字符串的长度大于n时, 不但不补多余的'\0',连字符串的结尾'\0'也不拷贝。strcpy(3)的文档已经相当友好了,为了 帮助理解,还给出一个strncpy的简单实现。 图 24.3. strcpy(3) 函数的Man Page都有一部分专门讲返回值的。这两个函数的返回值都是dest指针。可是为什么 要返回dest指针呢?dest指针本来就是调用者传过去的,再返回一遍dest指针并没有提供任何 有用的信息。之所以这么规定是为了把函数调用当作一个指针类型的表达式使用,比 如printf(strcpy(buf, "hello")),一举两得,如果strcpy的返回值是void就没有这么方便了。 CONFORMING TO部分描述了这个函数是遵照哪些标准实现的。strcpy和strncpy是C标准库函 数,当然遵照C99标准。以后我们还会看到libc中有些函数属于POSIX标准但并不属于C标准, 例如write(2)。 NOTES部分给出一些提示信息。这里指出如何确保strncpy的dest以'\0'结尾,和我们上面给 出的代码类似,但由于n是个变量,在执行buf[n - 1]= '\0';之前先检查一下n是否大于0,如 果n不大于0,buf[n - 1]就访问越界了,所以要避免。 图 24.4. strcpy(3) BUGS部分说明了使用这些函数可能引起的Bug,这部分一定要仔细看。用strcpy比 用strncpy更加不安全,如果在调用strcpy之前不仔细检查src字符串的长度就有可能写越界,这 是一个很常见的错误,例如: void foo(char *str) { char buf[10]; strcpy(buf, str); ...... } str所指向的字符串有可能超过10个字符而导致写越界,在第 4 节 “段错误”我们看到过,这种写 越界可能当时不出错,而在函数返回时出现段错误,原因是写越界覆盖了保存在栈帧上的返回 地址,函数返回时跳转到非法地址,因而出错。像buf这种由调用者分配并传给函数读或写的一 段内存通常称为缓冲区(Buffer),缓冲区写越界的错误称为缓冲区溢出(Buffer Overflow)。 如果只是出现段错误那还不算严重,更严重的是缓冲区溢出Bug经常被恶意用户利用,使函数返 回时跳转到一个事先设好的地址,执行事先设好的指令,如果设计得巧妙甚至可以启动一 个Shell,然后随心所欲执行任何命令,可想而知,如果一个用root权限执行的程序存在这样 的Bug,被攻陷了,后果将很严重。至于怎样巧妙设计和攻陷一个有缓冲区溢出Bug的程序,有 兴趣的读者可以参考[SmashStack]。 习题 1、自己实现一个strcpy函数,尽可能简洁,你能用三行代码写出函数体吗? 2、编一个函数,输入一个字符串,要求做一个新字符串,把其中所有的一个或多个连续的空白 字符都压缩为一个空格。这里所说的空白包括空格、'\t'、'\n'、'\r'。例如原来的字符串是: This Content hoho ok? file system uttered words ok ok end. is ok ? 压缩了空白之后就是: This Content hoho is ok ok? file system uttered words ok ok ? end. 实现该功能的函数接口要求符合下述规范: char *shrink_space(char *dest, const char *src, size_t n); 各项参数和返回值的含义和strncpy类似。完成之后,为自己实现的函数写一个Man Page。 1.2. malloc与free 程序中需要动态分配一块内存时怎么办呢?可以像上一节那样定义一个缓冲区数组。这种方法 不够灵活,C89要求定义的数组是固定长度的,而程序往往在运行时才知道要动态分配多大的内 存,例如: void foo(char *str, int n) { char buf[?]; strncpy(buf, str, n); ...... } n是由参数传进来的,事先不知道是多少,那么buf该定义多大呢?在第 1 节 “数组的基本操 作”讲过C99引入VLA特性,可以定义char buf[n+1] = {};,这样可确保buf是以'\0'结尾的。 但即使用VLA仍然不够灵活,VLA是在栈上动态分配的,函数返回时就要释放,如果我们希望动 态分配一块全局的内存空间,在各函数中都可以访问呢?由于全局数组无法定义成VLA,所以 仍然不能满足要求。 其实在第 5 节 “虚拟内存管理”提过,进程有一个堆空间,C标准库函数malloc可以在堆空间动态 分配内存,它的底层通过brk系统调用向操作系统申请内存。动态分配的内存用完之后可以 用free释放,更准确地说是归还给malloc,这样下次调用malloc时这块内存可以再次被分配。本 节学习这两个函数的用法和工作原理。 #include void *malloc(size_t size); 返回值:成功返回所分配内存空间的首地址,出错返回NULL void free(void *ptr); malloc的参数size表示要分配的字节数,如果分配失败(可能是由于系统内存耗尽)则返 回NULL。由于malloc函数不知道用户拿到这块内存要存放什么类型的数据,所以返回通用指 针void *,用户程序可以转换成其它类型的指针再访问这块内存。malloc函数保证它返回的指 针所指向的地址满足系统的对齐要求,例如在32位平台上返回的指针一定对齐到4字节边界,以 保证用户程序把它转换成任何类型的指针都能用。 动态分配的内存用完之后可以用free释放掉,传给free的参数正是先前malloc返回的内存块首 地址。举例如下: 例 24.1. malloc和free #include #include #include typedef struct { int number; char *msg; } unit_t; int main(void) { unit_t *p = malloc(sizeof(unit_t)); >msg); if (p == NULL) { printf("out of memory\n"); exit(1); } p->number = 3; p->msg = malloc(20); strcpy(p->msg, "Hello world!"); printf("number: %d\nmsg: %s\n", p->number, p- free(p->msg); free(p); p = NULL; return 0; } 关于这个程序要注意以下几点: unit_t *p = malloc(sizeof(unit_t));这一句,等号右边是void *类型,等号左边 是unit_t *类型,编译器会做隐式类型转换,我们讲过void *类型和任何指针类型之间可 以相互隐式转换。 虽然内存耗尽是很不常见的错误,但写程序要规范,malloc之后应该判断是否成功。以后 要学习的大部分系统函数都有成功的返回值和失败的返回值,每次调用系统函数都应该判 断是否成功。 free(p);之后,p所指的内存空间是归还了,但是p的值并没有变,因为从free的函数接口 来看根本就没法改变p的值,p现在指向的内存空间已经不属于用户,换句话说,p成了野 指针,为避免出现野指针,我们应该在free(p);之后手动置p = NULL;。 应该先free(p->msg) ,再free(p)。如果先free(p),p成了野指针,就不能再通过p->msg 访 问内存了。 上面的例子只有一个简单的顺序控制流程,分配内存,赋值,打印,释放内存,退出程序。这 种情况下即使不用free释放内存也可以,因为程序退出时整个进程地址空间都会释放,包括堆 空间,该进程占用的所有内存都会归还给操作系统。但如果一个程序长年累月运行(例如网络 服务器程序),并且在循环或递归中调用malloc分配内存,则必须有free与之配对,分配一次 就要释放一次,否则每次循环都分配内存,分配完了又不释放,就会慢慢耗尽系统内存,这种 错误称为内存泄漏(Memory Leak)。另外,malloc返回的指针一定要保存好,只有把它传 给free才能释放这块内存,如果这个指针丢失了,就没有办法free这块内存了,也会造成内存 泄漏。例如: void foo(void) { char *p = malloc(10); ...... } foo函数返回时要释放局部变量p的内存空间,它所指向的内存地址就丢失了,这10个字节也就 没法释放了。内存泄漏的Bug很难找到,因为它不会像访问越界一样导致程序运行错误,少量内 存泄漏并不影响程序的正确运行,大量的内存泄漏会使系统内存紧缺,导致频繁换页,不仅影 响当前进程,而且把整个系统都拖得很慢。 关于malloc和free还有一些特殊情况。malloc(0)这种调用也是合法的,也会返回一个非NULL的 指针,这个指针也可以传给free释放,但是不能通过这个指针访问内存。free(NULL)也是合法 的,不做任何事情,但是free一个野指针是不合法的,例如先调用malloc返回一个指针p,然后 连着调用两次free(p);,则后一次调用会产生运行时错误。 [K&R]的8.7节给出了malloc和free的简单实现,基于环形链表。目前读者还没有学习链表,看 那段代码会有点困难,我再做一些简化,图示如下,目的是让读者理解malloc和free的工作原 理。libc的实现比这要复杂得多,但基本工作原理也是如此。读者只要理解了基本工作原理, 就很容易分析在使用malloc和free时遇到的各种Bug了。 图 24.5. 简单的malloc和free实现 图中白色背景的框表示malloc管理的空闲内存块,深色背景的框不归malloc管,可能是已经分配 给用户的内存块,也可能不属于当前进程,Break之上的地址不属于当前进程,需要通过brk系 统调用向内核申请。每个内存块开头都有一个头节点,里面有一个指针字段和一个长度字段, 指针字段把所有空闲块的头节点串在一起,组成一个环形链表,长度字段记录着头节点和后面 的内存块加起来一共有多长,以8字节为单位(也就是以头节点的长度为单位)。 1. 一开始堆空间由一个空闲块组成,长度为7×8=56字节,除头节点之外的长度为48字节。 2. 调用malloc分配8个字节,要在这个空闲块的末尾截出16个字节,其中新的头节点占了8个 字节,另外8个字节返回给用户使用,注意返回的指针p1指向头节点后面的内存块。 3. 又调用malloc分配16个字节,又在空闲块的末尾截出24个字节,步骤和上一步类似。 4. 调用free释放p1所指向的内存块,内存块(包括头节点在内)归还给了malloc,现 在malloc管理着两块不连续的内存,用环形链表串起来。注意这时p1成了野指针,指向不 属于用户的内存,p1所指向的内存地址在Break之下,是属于当前进程的,所以访问p1时 不会出现段错误,但在访问p1时这段内存可能已经被malloc再次分配出去了,可能会读到 意外改写数据。另外注意,此时如果通过p2向右写越界,有可能覆盖右边的头节点,从而 破坏malloc管理的环形链表,malloc就无法从一个空闲块的指针字段找到下一个空闲块 了,找到哪去都不一定,全乱套了。 5. 调用malloc分配16个字节,现在虽然有两个空闲块,各有8个字节可分配,但是这两块不 连续,malloc只好通过brk系统调用抬高Break,获得新的内存空间。在[K&R]的实现中, 每次调用sbrk函数时申请1024×8=8192个字节,在Linux系统上sbrk函数也是通过brk实现 的,这里为了画图方便,我们假设每次调用sbrk申请32个字节,建立一个新的空闲块。 6. 新申请的空闲块和前一个空闲块连续,因此可以合并成一个。在能合并时要尽量合并,以 免空闲块越割越小,无法满足大的分配请求。 7. 在合并后的这个空闲块末尾截出24个字节,新的头节点占8个字节,另外16个字节返回给 用户。 8. 调用free(p3)释放这个内存块,由于它和前一个空闲块连续,又重新合并成一个空闲块。 注意,Break只能抬高而不能降低,从内核申请到的内存以后都归malloc管了,即使调 用free也不会还给内核。 习题 1、小练习:编写一个小程序让它耗尽系统内存。观察一下,分配了多少内存后才会出现分配失 败?内存耗尽之后会怎么样?会不会死机? 上一页 第 24 章 函数接口 上一级 起始页 下一页 2. 传入参数与传出参数 上一页 2. 传入参数与传出参数 第 24 章 函数接口 下一页 2. 传入参数与传出参数 如果函数接口有指针参数,既可以把指针所指向的数据传给函数使用(称为传入参数),也可 以由函数填充指针所指的内存空间,传回给调用者使用(称为传出参数),例如strcpy的src参 数是传入参数,dest参数是传出参数。有些函数的指针参数同时担当了这两种角色, 如select(2)的fd_set *参数,既是传入参数又是传出参数,这称为Value-result参数。 表 24.1. 传入参数示例:void func(const unit_t *p); 调用者 实现者 1. 分配p所指的内存空间 2. 在p所指的内存空间中保存数据 3. 调用函数 4. 由于有const限定符,调用者可以确信p所指的内 存空间不会被改变 1. 规定指针参数的类 型unit_t * 2. 读取p所指的内存空 间 想一想,如果有函数接口void func(const int p);这里的const有意义吗? 表 24.2. 传出参数示例:void func(unit_t *p); 调用者 实现者 1. 分配p所指的内存空间 2. 调用函数 3. 读取p所指的内存空间 1. 规定指针参数的类型unit_t * 2. 在p所指的内存空间中保存数据 表 24.3. Value-result参数示例:void func(unit_t *p); 调用者 实现者 1. 分配p所指的内存空间 2. 在p所指的内存空间保存数据 1. 规定指针参数的类型unit_t * 2. 读取p所指的内存空间 3. 调用函数 4. 读取p所指的内存空间 3. 改写p所指的内存空间 由于传出参数和Value-result参数的函数接口完全相同,应该在文档中说明是哪种参数。 以下是一个传出参数的完整例子: 例 24.2. 传出参数 /* populator.h */ #ifndef POPULATOR_H #define POPULATOR_H typedef struct { int number; char msg[20]; } unit_t; extern void set_unit(unit_t *); #endif /* populator.c */ #include #include "populator.h" void set_unit(unit_t *p) { if (p == NULL) return; /* ignore NULL parameter */ p->number = 3; strcpy(p->msg, "Hello World!"); } /* main.c */ #include #include "populator.h" int main(void) { unit_t u; set_unit(&u); printf("number: %d\nmsg: %s\n", u.number, u.msg); return 0; } 很多系统函数对于指针参数是NULL的情况有特殊规定:如果传入参数是NULL表示取缺省值,例 如pthread_create(3)的pthread_attr_t *参数,也可能表示不做特别处理,例如free的参数; 如果传出参数是NULL表示调用者不需要传出值,例如time(2)的参数。这些特殊规定应该在文档 中写清楚。 上一页 1. 本章的预备知识 上一级 起始页 下一页 3. 两层指针的参数 上一页 3. 两层指针的参数 第 24 章 函数接口 下一页 3. 两层指针的参数 两层指针也是指针,同样可以表示传入参数、传出参数或者Value-result参数,只不过该参数所 指的内存空间应该解释成一个指针变量。用两层指针做传出参数的系统函数也很常见,比 如pthread_join(3)的void **参数。下面看一个简单的例子。 例 24.3. 两层指针做传出参数 /* redirect_ptr.h */ #ifndef REDIRECT_PTR_H #define REDIRECT_PTR_H extern void get_a_day(const char **); #endif 想一想,这里的参数指针是const char **,有const限定符,却不是传入参数而 是传出参数,为什么?如果是传入参数应该怎么表示? /* redirect_ptr.c */ #include "redirect_ptr.h" static const char *msg[] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}; void get_a_day(const char **pp) { static int i = 0; *pp = msg[i%7]; i++; } /* main.c */ #include #include "redirect_ptr.h" int main(void) { const char *firstday = NULL; const char *secondday = NULL; get_a_day(&firstday); get_a_day(&secondday); printf("%s\t%s\n", firstday, secondday); return 0; } 两层指针作为传出参数还有一种特别的用法,可以在函数中分配内存,调用者通过传出参数取 得指向该内存的指针,比如getaddrinfo(3)的struct addrinfo **参数。一般来说,实现一个分 配内存的函数就要实现一个释放内存的函数,所以getaddrinfo(3)有一个对应 的freeaddrinfo(3) 函数。 表 24.4. 通过参数分配内存示例:void alloc_unit(unit_t **pp); void free_unit(unit_t *p); 调用者 实现者 1. 分配pp所指的指针变量的空间 2. 调用alloc_unit分配内存 3. 读取pp所指的指针变量,通过后 者使用alloc_unit分配的内存 4. 调用free_unit释放内存 1. 规定指针参数的类型unit_t ** 2. alloc_unit分配unit_t的内存并初 始化,为pp所指的指针变量赋值 3. free_unit释放在alloc_unit中分配 的内存 例 24.4. 通过两层指针参数分配内存 /* para_allocator.h */ #ifndef PARA_ALLOCATOR_H #define PARA_ALLOCATOR_H typedef struct { int number; char *msg; } unit_t; extern void alloc_unit(unit_t **); extern void free_unit(unit_t *); #endif /* para_allocator.c */ #include #include #include #include "para_allocator.h" void alloc_unit(unit_t **pp) { unit_t *p = malloc(sizeof(unit_t)); if(p == NULL) { printf("out of memory\n"); exit(1); } p->number = 3; p->msg = malloc(20); strcpy(p->msg, "Hello World!"); *pp = p; } void free_unit(unit_t *p) { free(p->msg); free(p); } /* main.c */ #include #include "para_allocator.h" int main(void) { unit_t *p = NULL; alloc_unit(&p); printf("number: %d\nmsg: %s\n", p->number, p>msg); free_unit(p); p = NULL; return 0; } 思考一下,为什么在main函数中不能直接调用free(p)释放内存,而要调用free_unit(p)?为什 么一层指针的函数接口void alloc_unit(unit_t *p);不能分配内存,而一定要用两层指针的函 数接口? 总结一下,两层指针参数如果是传出的,可以有两种情况:第一种情况,传出的指针指向静态 内存(比如上面的例子),或者指向已分配的动态内存(比如指向某个链表的节点);第二种 情况是在函数中动态分配内存,然后传出的指针指向这块内存空间,这种情况下调用者应该在 使用内存之后调用释放内存的函数,调用者的责任是请求分配和请求释放内存,实现者的责任 是完成分配内存和释放内存的操作。由于这两种情况的函数接口相同,应该在文档中说明是哪 一种情况。 上一页 2. 传入参数与传出参数 上一级 起始页 下一页 4. 返回值是指针的情况 上一页 4. 返回值是指针的情况 第 24 章 函数接口 下一页 4. 返回值是指针的情况 返回值显然是传出的而不是传入的,如果返回值传出的是指针,和上一节通过参数传出指针类 似,也分为两种情况:第一种是传出指向静态内存或已分配的动态内存的指针,例 如localtime(3)和inet_ntoa(3),第二种是在函数中动态分配内存并传出指向这块内存的指针, 例如malloc(3),这种情况通常还要实现一个释放内存的函数,所以有和malloc(3)对应 的free(3)。由于这两种情况的函数接口相同,应该在文档中说明是哪一种情况。 表 24.5. 返回指向已分配内存的指针示例:unit_t *func(void); 调用者 实现者 1. 调用函数 1. 规定返回值指针的类型unit_t * 2. 将返回值保存下来以备后用 2. 返回一个指针 以下是一个完整的例子。 例 24.5. 返回指向已分配内存的指针 /* ret_ptr.h */ #ifndef RET_PTR_H #define RET_PTR_H extern char *get_a_day(int idx); #endif /* ret_ptr.c */ #include #include "ret_ptr.h" static const char *msg[] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}; char *get_a_day(int idx) { static char buf[20]; strcpy(buf, msg[idx]); return buf; } /* main.c */ #include #include "ret_ptr.h" int main(void) { printf("%s %s\n", get_a_day(0), get_a_day(1)); return 0; } 这个程序的运行结果是Sunday Monday吗?请读者自己分析一下。 表 24.6. 动态分配内存并返回指针示例:unit_t *alloc_unit(void); void free_unit(unit_t *p); 调用者 实现者 1. 调用alloc_unit分配内存 2. 将返回值保存下来以备后 用 3. 调用free_unit释放内存 1. 规定返回值指针的类型unit_t * 2. alloc_unit分配内存并返回指向该内存的 指针 3. free_unit释放由alloc_unit分配的内存 以下是一个完整的例子。 例 24.6. 动态分配内存并返回指针 /* ret_allocator.h */ #ifndef RET_ALLOCATOR_H #define RET_ALLOCATOR_H typedef struct { int number; char *msg; } unit_t; extern unit_t *alloc_unit(void); extern void free_unit(unit_t *); #endif /* ret_allocator.c */ #include #include #include #include "ret_allocator.h" unit_t *alloc_unit(void) { unit_t *p = malloc(sizeof(unit_t)); if(p == NULL) { printf("out of memory\n"); exit(1); } p->number = 3; p->msg = malloc(20); strcpy(p->msg, "Hello world!"); return p; } void free_unit(unit_t *p) { free(p->msg); free(p); } /* main.c */ #include #include "ret_allocator.h" int main(void) { unit_t *p = alloc_unit(); printf("number: %d\nmsg: %s\n", p->number, p>msg); free_unit(p); p = NULL; return 0; } 思考一下,通过参数分配内存需要两层的指针,而通过返回值分配内存就只需要返回一层的指 针,为什么? 上一页 3. 两层指针的参数 上一级 起始页 下一页 5. 回调函数 上一页 5. 回调函数 第 24 章 函数接口 下一页 5. 回调函数 如果参数是一个函数指针,调用者可以传递一个函数的地址给实现者,让实现者去调用它,这 称为回调函数(Callback Function)。例如qsort(3)和bsearch(3)。 表 24.7. 回调函数示例:void func(void (*f)(void *), void *p); 调用者 实现者 1. 提供一个回调函数,再提 供一个准备传给回调函数 的参数。 2. 把回调函数传给参数f,把 准备传给回调函数的参数 按void *类型传给参数p 1. 在适当的时候根据调用者传来的函数指 针f调用回调函数,将调用者传来的参 数p转交给回调函数,即调用f(p); 以下是一个简单的例子。实现了一个repeat_three_times函数,可以把调用者传来的任何回调函 数连续执行三次。 例 24.7. 回调函数 /* para_callback.h */ #ifndef PARA_CALLBACK_H #define PARA_CALLBACK_H typedef void (*callback_t)(void *); extern void repeat_three_times(callback_t, void *); #endif /* para_callback.c */ #include "para_callback.h" void repeat_three_times(callback_t f, void *para) { f(para); f(para); f(para); } /* main.c */ #include #include "para_callback.h" void say_hello(void *str) { printf("Hello %s\n", (const char *)str); } void count_numbers(void *num) { int i; for(i=1; i<=(int)num; i++) printf("%d ", i); putchar('\n'); } int main(void) { repeat_three_times(say_hello, (void *)"Guys"); repeat_three_times(count_numbers, (void *)4); return 0; } 回顾一下前面几节的例子,参数类型都是由实现者规定的。而本例中回调函数的参数按什么类 型解释由调用者规定,对于实现者来说就是一个void *指针,实现者只负责将这个指针转交给 回调函数,而不关心它到底指向什么数据类型。调用者知道自己传的参数是char *型的,那么 在自己提供的回调函数中就应该知道参数要转换成char *型来解释。 回调函数的一个典型应用就是实现类似C++的泛型算法(Generics Algorithm)。下面实现 的max函数可以在任意一组对象中找出最大值,可以是一组int、一组char或者一组结构体,但 是实现者并不知道怎样去比较两个对象的大小,调用者需要提供一个做比较操作的回调函数。 例 24.8. 泛型算法 /* generics.h */ #ifndef GENERICS_H #define GENERICS_H typedef int (*cmp_t)(void *, void *); extern void *max(void *data[], int num, cmp_t cmp); #endif /* generics.c */ #include "generics.h" void *max(void *data[], int num, cmp_t cmp) { int i; void *temp = data[0]; for(i=1; i #include "generics.h" typedef struct { const char *name; int score; } student_t; int cmp_student(void *a, void *b) { if(((student_t *)a)->score > ((student_t *)b)- >score) return 1; else if(((student_t *)a)->score == ((student_t *)b)->score) return 0; else return -1; } int main(void) { student_t list[4] = {{"Tom", 68}, {"Jerry", 72}, {"Moby", 60}, {"Kirby", 89}}; student_t *plist[4] = {&list[0], &list[1], &list[2], &list[3]}; student_t *pmax = max((void **)plist, 4, cmp_student); printf("%s gets the highest score %d\n", pmax>name, pmax->score); return 0; } max函数之所以能对一组任意类型的对象进行操作,关键在于传给max的是指向对象的指针所构 成的数组,而不是对象本身所构成的数组,这样max不必关心对象到底是什么类型,只需转给比 较函数cmp,然后根据比较结果做相应操作即可,cmp是调用者提供的回调函数,调用者当然知 道对象是什么类型以及如何比较。 以上举例的回调函数是被同步调用的,调用者调用max函数,max函数则调用cmp函数,相当于调 用者间接调了自己提供的回调函数。在实际系统中,异步调用也是回调函数的一种典型用法, 调用者首先将回调函数传给实现者,实现者记住这个函数,这称为注册一个回调函数,然后当 某个事件发生时实现者再调用先前注册的函数,比如sigaction(2)注册一个信号处理函数,当信 号产生时由系统调用该函数进行处理,再比如pthread_create(3)注册一个线程函数,当发生调 度时系统切换到新注册的线程函数中运行,在GUI编程中异步回调函数更是有普遍的应用,例如 为某个按钮注册一个回调函数,当用户点击按钮时调用它。 以下是一个代码框架。 /* registry.h */ #ifndef REGISTRY_H #define REGISTRY_H typedef void (*registry_t)(void); extern void register_func(registry_t); #endif /* registry.c */ #include #include "registry.h" static registry_t func; void register_func(registry_t f) { func = f; } static void on_some_event(void) { ... func(); ... } 既然参数可以是函数指针,返回值同样也可以是函数指针,因此可以有func()();这样的调用。 返回函数的函数在C语言中很少见,在一些函数式编程语言例如LISP、Haskell中则很常见,基 本思想是把函数也当作一种数据来操作,输入、输出和参与运算,操作函数的函数称为高阶函 数(High-order Function)。 习题 1、[K&R]的5.6节有一个qsort函数的实现,可以对一组任意类型的对象做快速排序。请读者仿 照那个例子,写一个插入排序的函数和一个折半查找的函数。 上一页 4. 返回值是指针的情况 上一级 起始页 下一页 6. 可变参数 上一页 6. 可变参数 第 24 章 函数接口 6. 可变参数 到目前为止我们只见过一个带有可变参数的函数printf: int printf(const char *format, ...); 以后还会见到更多这样的函数。现在我们实现一个简单的myprintf函数: 例 24.9. 用可变参数实现简单的printf函数 #include #include void myprintf(const char *format, ...) { va_list ap; char c; va_start(ap, format); while (c = *format++) { switch(c) { case 'c': { /* char is promoted to int when passed through '...' */ char ch = va_arg(ap, int); putchar(ch); break; } case 's': { char *p = va_arg(ap, char *); fputs(p, stdout); break; } default: putchar(c); } } va_end(ap); } int main(void) { myprintf("c\ts\n", '1', "hello"); return 0; } 下一页 要处理可变参数,需要用C到标准库的va_list类型和va_start、va_arg、va_end宏,这些定义 在stdarg.h头文件中。这些宏是如何取出可变参数的呢?我们首先对照反汇编分析在调 用myprintf函数时这些参数的内存布局。 myprintf("c\ts\n", '1', "hello"); 80484c5: c7 44 24 08 b0 85 04 movl $0x80485b0,0x8(%esp) 80484cc: 08 80484cd: 80484d4: 80484d5: 80484dc: c7 44 24 04 31 00 00 00 c7 04 24 b6 85 04 08 e8 43 ff ff ff movl movl call $0x31,0x4(%esp) $0x80485b6,(%esp) 8048424 图 24.6. myprintf函数的参数布局 这些参数是从右向左依次压栈的,所以第一个参数靠近栈顶,第三个参数靠近栈底。这些参数 在内存中是连续存放的,每个参数都对齐到4字节边界。第一个和第三个参数都是指针类型,各 占4个字节,虽然第二个参数只占一个字节,但为了使第三个参数对齐到4字节边界,所以第二 个参数也占4个字节。现在给出一个stdarg.h的简单实现,这个实现出自[Standard C Library]: 例 24.10. stdarg.h的一种实现 /* stdarg.h standard header */ #ifndef _STDARG #define _STDARG /* type definitions */ typedef char *va_list; /* macros */ #define va_arg(ap, T) \ (* (T *)(((ap) += _Bnd(T, 3U)) - _Bnd(T, 3U))) #define va_end(ap) (void)0 #define va_start(ap, A) \ (void)((ap) = (char *)&(A) + _Bnd(A, 3U)) #define _Bnd(X, bnd) (sizeof (X) + (bnd) & ~(bnd)) #endif 这个头文件中的内部宏定义_Bnd(X, bnd)将类型或变量X的长度对齐到bnd+1字节的整数倍,例 如_Bnd(char, 3U)的值是4,_Bnd(int, 3U)也是4。 在myprintf中定义的va_list ap;其实是一个指针,va_start(ap, format)使ap指向format参数 的下一个参数,也就是指向上图中esp+4的位置。然后va_arg(ap, int)把第二个参数的值 按int型取出来,同时使ap指向第三个参数,也就是指向上图中esp+8的位置。然后va_arg(ap, char *)把第三个参数的值按char *型取出来,同时使ap指向更高的地址。va_end(ap)在我们的 简单实现中不起任何作用,在有些实现中可能会把ap改写成无效值,C标准要求在函数返回前调 用va_end。 如果把myprintf中的char ch = va_arg(ap, int);改成char ch = va_arg(ap, char);,用我们 这个stdarg.h的简单实现是没有问题的。但如果改用libc提供的stdarg.h,在编译时会报错: $ gcc main.c main.c: In function ‘myprintf’: main.c:33: warning: ‘char’ is promoted to ‘int’ when passed through ‘...’ main.c:33: note: (so you should pass ‘int’ not ‘char’ to ‘va_arg’) main.c:33: note: if this code is reached, the program will abort $ ./a.out Illegal instruction 因此要求char型的可变参数必须按int型来取,这是为了与C标准一致,我们在第 3.1 节 “Integer Promotion”讲过Default Argument Promotion规则,传递char型的可变参数时要提升 为int型。 从myprintf的例子可以理解printf的实现原理,printf函数根据第一个参数(格式化字符串)来 确定后面有几个参数,分别是什么类型。保证参数的类型、个数与格式化字符串的描述相匹配 是调用者的责任,实现者只管按格式化字符串的描述从栈上取数据,如果调用者传递的参数类 型或个数不正确,实现者是没有办法避免错误的。 还有一种方法可以确定可变参数的个数,就是在参数列表的末尾传一个Sentinel,例 如NULL。execl(3)就采用这种方法确定参数的个数。下面实现一个printlist函数,可以打印若 干个传入的字符串。 例 24.11. 根据Sentinel判断可变参数的个数 #include #include void printlist(int begin, ...) { va_list ap; char *p; va_start(ap, begin); p = va_arg(ap, char *); while (p != NULL) { fputs(p, stdout); putchar('\n'); p = va_arg(ap, char*); } va_end(ap); } int main(void) { printlist(0, "hello", "world", "foo", "bar", NULL); return 0; } printlist的第一个参数begin的值并没有用到,但是C语言规定至少要定义一个有名字的参数, 因为va_start宏要用到参数列表中最后一个有名字的参数,从它的地址开始找可变参数的位 置。实现者应该在文档中说明参数列表必须以NULL结尾,如果调用者不遵守这个约定,实现者 是没有办法避免错误的。 习题 1、实现一个功能更完整的printf,能够识别%,能够处理%d、%o、%x对应的整数参数。在实现 中不许调用printf(3)这个Man Page中描述的任何函数。 上一页 5. 回调函数 上一级 起始页 下一页 第 25 章 C标准库 上一页 第 25 章 C标准库 部分 II. C语言本质 下一页 第 25 章 C标准库 目录 1. 字符串操作函数 1.1. 初始化字符串 1.2. 取字符串的长度 1.3. 拷贝字符串 1.4. 连接字符串 1.5. 比较字符串 1.6. 搜索字符串 1.7. 分割字符串 2. 标准I/O库函数 2.1. 文件的基本概念 2.2. fopen/fclose 2.3. stdin/stdout/stderr 2.4. errno与perror函数 2.5. 以字节为单位的I/O函数 2.6. 操作读写位置的函数 2.7. 以字符串为单位的I/O函数 2.8. 以记录为单位的I/O函数 2.9. 格式化I/O函数 2.10. C标准库的I/O缓冲区 3. 数值字符串转换函数 4. 分配内存的函数 5. 本章综合练习 C标准主要由两部分组成,一部分描述C的语法,一部分描述C标准库。换句话说,要在一个平 台上支持C语言,不仅要实现C编译器,还要实现C标准库,这样的实现才算符合C标准。不符 合C标准的实现也是存在的,例如有些单片机的C语言开发工具中只有C编译器而没有完整 的C标准库。 在前面的各章中我们已经见过C标准库的一些用法,总结如下: 我们最常用的是包含stdio.h,使用其中声明的printf函数,这个函数在libc中实现,程序 在运行时要动态链接libc共享库。 在第 1 节 “数学函数”中用到了math.h中声明的sin和log函数,使用这些函数需要动态链 接libm共享库。 在第 2 节 “数组应用实例:统计随机数”中用到了stdlib.h中声明的rand函数,还提到了这 个头文件中定义的RAND_MAX常量,在例 8.5 “剪刀石头布”中用到了stdlib.h中声明 的srand函数和time.h中声明的time函数。使用这些函数需要动态链接libc共享库。 在第 2 节 “main函数和启动例程”中用到了stdlib.h中声明的exit函数,使用这个函数需要 动态链接libc共享库。 在第 6 节 “折半查找”中用到了assert.h中定义的assert宏,在第 4 节 “其它预处理特性”中 我们看到了这个宏的一种实现,它的实现需要调用stdio.h和stdlib.h中声明的函数,所以 使用这个宏也需要动态链接libc共享库。 在第 2.4 节 “sizeof运算符与typedef类型声明”中提到了size_t类型在stddef.h中定义, 在第 1 节 “指针的基本操作”中提到了NULL指针也在stddef.h中定义。 在第 1 节 “本章的预备知识”中介绍了stdlib.h中声明的malloc和free函数以及string.h中 声明的strcpy和strncpy函数,使用这些函数需要动态链接libc共享库。 在第 6 节 “可变参数”中介绍了stdarg.h中定义的va_list类型 和va_arg、va_start、va_end等宏定义,并给出了一种实现,这些宏定义的实现并没有调 用库函数,所以不依赖于某个共享库,这一点和assert不同。 总结一下,Linux平台提供的C标准库包括: 一组头文件,定义了很多类型和宏,声明了很多库函数。这些头文件放在哪些目录下取决 于不同的编译器,在我的系统上,stdarg.h 和stddef.h 位于/usr/lib/gcc/i486-linux gnu/4.3.2/include目录下,stdio.h、stdlib.h、time.h、math.h、assert.h位 于/usr/include目录下。C99标准定义的头文件有24个,本书只介绍其中最基本、最常用 的几个。 一组库文件,提供了库函数的实现。大多数库函数在libc共享库中,有些库函数在另外的 共享库中,例如数学函数在libm中。在第 4 节 “共享库”讲过,通常libc共享库 是/lib/libc.so.6,而我的系统启用了hwcap机制,libc共享库 是/lib/tls/i686/cmov/libc.so.6。 本章介绍另外一些最基本和最常用的库函数(包括一些不属于C标准但在UNIX平台上很常用的 函数),写这一章是为了介绍字符串操作和文件操作的基本概念,而不是为了写一本C标准库函 数的参考手册,Man Page已经是一本很好的手册了,读者学完这一章之后在开发时应该查 阅Man Page,而不是把我这一章当参考手册来翻,所以本章不会面面俱到介绍所有的库函数, 对于本章讲到的函数有些也不会讲得很细,因为我假定读者经过上一章的学习再结合我讲过的 基本概念已经能看懂相关的Man Page了。很多技术书的作者给自己的书太多定位,既想写成一 本入门教程,又想写成一本参考手册,我觉得这样不好,读者过于依赖技术书就失去了看真正 的手册的能力。 上一页 6. 可变参数 上一级 起始页 下一页 1. 字符串操作函数 上一页 1. 字符串操作函数 第 25 章 C标准库 下一页 1. 字符串操作函数 程序按功能划分可分为数值运算、符号处理和I/O操作三类,符号处理程序占相当大的比例,符 号处理程序无处不在,编译器、浏览器、Office套件等程序的主要功能都是符号处理。无论多复 杂的符号处理都是由各种基本的字符串操作组成的,本节介绍如何用C语言的库函数做字符串初 始化、取长度、拷贝、连接、比较、搜索等基本操作。 1.1. 初始化字符串 #include void *memset(void *s, int c, size_t n); 返回值:s指向哪,返回的指针就指向哪 memset函数把s所指的内存地址开始的n个字节都填充为c的值。通常c的值为0,把一块内存区清 零。例如定义char buf[10];,如果它是全局变量或静态变量,则自动初始化为0(位 于.bss段),如果它是函数的局部变量,则初值不确定,可以用memset(buf, 0, 10)清零, 由malloc分配的内存初值也是不确定的,也可以用memset清零。 1.2. 取字符串的长度 #include size_t strlen(const char *s); 返回值:字符串的长度 strlen函数返回s所指的字符串的长度。该函数从s所指的第一个字符开始找'\0'字符,一旦找 到就返回,返回的长度不包括'\0'字符在内。例如定义char buf[] = "hello";, 则strlen(buf)的值是5,但要注意,如果定义char buf[5] = "hello";,则调用strlen(buf)是 危险的,会造成数组访问越界。 1.3. 拷贝字符串 在第 1 节 “本章的预备知识”中介绍了strcpy和strncpy函数,拷贝以'\0'结尾的字符 串,strncpy还带一个参数指定最多拷贝多少个字节,此外,strncpy并不保证缓冲区以'\0'结 尾。现在介绍memcpy和memmove函数。 #include void *memcpy(void *dest, const void *src, size_t n); void *memmove(void *dest, const void *src, size_t n); 返回值:dest指向哪,返回的指针就指向哪 memcpy函数从src所指的内存地址拷贝n个字节到dest所指的内存地址,和strncpy不 同,memcpy并不是遇到'\0'就结束,而是一定会拷贝完n个字节。这里的命名规律是,以str开 头的函数处理以'\0'结尾的字符串,而以mem开头的函数则不关心'\0'字符,或者说这些函数并 不把参数当字符串看待,因此参数的指针类型是void *而非char *。 memmove也是从src所指的内存地址拷贝n个字节到dest所指的内存地址,虽然叫move但其实也 是拷贝而非移动。但是和memcpy有一点不同,memcpy的两个参数src和dest所指的内存区间如果 重叠则无法保证正确拷贝,而memmove却可以正确拷贝。假设定义了一个数组char buf[20] = "hello world\n";,如果想把其中的字符串往后移动一个字节(变成"hhello world\n"),调 用memcpy(buf + 1, buf, 13)是无法保证正确拷贝的: 例 25.1. 错误的memcpy调用 #include #include int main(void) { char buf[20] = "hello world\n"; memcpy(buf + 1, buf, 13); printf(buf); return 0; } 在我的机器上运行的结果是hhhllooworrd。如果把代码中的memcpy改成memmove则可以保证正确 拷贝。memmove可以这样实现: void *memmove(void *dest, const void *src, size_t n) { char temp[n]; int i; char *d = dest; const char *s = src; for (i = 0; i < n; i++) temp[i] = s[i]; for (i = 0; i < n; i++) d[i] = temp[i]; return dest; } 借助于一个临时缓冲区temp,即使src和dest所指的内存区间有重叠也能正确拷贝。思考一下, 如果不借助于临时缓冲区能不能正确处理重叠内存区间的拷贝? 用memcpy如果得到的结果是hhhhhhhhhhhhhh倒不奇怪,可为什么会得到hhhllooworrd这个奇怪的 结果呢?根据这个结果猜测的一种可能的实现是: void *memcpy(void *dest, const void *src, size_t n) { char *d = dest; const char *s = src; int *di; const int *si; int r = n % 4; while (r--) *d++ = *s++; di = (int *)d; si = (const int *)s; n /= 4; while (n--) *di++ = *si++; return dest; } 在32位的x86平台上,每次拷贝1个字节需要一条指令,每次拷贝4个字节也只需要一条指 令,memcpy函数的实现尽可能4个字节4个字节地拷贝,因而得到上述结果。 C99的restrict关键字 我们来看一个跟memcpy/memmove类似的问题。下面的函数将两个数 组中对应的元素相加,结果保存在第三个数组中。 void vector_add(float *x, float *y, float *result) { int i; for (i = 0; i < 64; ++i) result[i] = x[i] + y[i]; } 如果这个函数要在多处理器的计算机上执行,编译器可以做这样 的优化:把这一个循环拆成两个循环,一个处理器计算i值 从0到31的循环,另一个处理器计算i值从32到63的循环,这样两 个处理器可以同时工作,使计算时间缩短一半。但是这样的编译 优化能保证得出正确结果吗?假如result和x所指的内存区间是重 叠的,result[0]其实是x[1],result[i]其实是x[i+1],这两个处 理器就不能各干各的事情了,因为第二个处理器的工作依赖于第 一个处理器的最终计算结果,这种情况下编译优化的结果是错 的。这样看来编译器是不敢随便做优化了,那么多处理器提供的 并行性就无法利用,岂不可惜?为此,C99引入restrict关键字, 如果程序员把上面的函数声明为void vector_add(float *restrict x, float *restrict y, float *restrict result), 就是告诉编译器可以放心地对这个函数做优化,程序员自己会保 证这些指针所指的内存区间互不重叠。 由于restrict是C99引入的新关键字,目前Linux的Man Page还没 有更新,所以都没有restrict关键字,本书的函数原型都取 自Man Page,所以也都没有restrict关键字。但在C99标准中库 函数的原型都在必要的地方加了restrict关键字, 在C99中memcpy的原型是void *memcpy(void * restrict s1, const void * restrict s2, size_t n);,就是告诉调用者,这个 函数的实现可能会做些优化,编译器也可能会做些优化,传进来 的指针不允许指向重叠的内存区间,否则结果可能是错的, 而memmove的原型是void *memmove(void *s1, const void *s2, size_t n);,没有restrict关键字,说明传给这个函数的指针允许 指向重叠的内存区间。在restrict关键字出现之前都是用自然语 言描述哪些函数的参数不允许指向重叠的内存区间,例如在C89标 准的库函数一章开头提到,本章描述的所有函数,除非特别说 明,都不应该接收两个指针参数指向重叠的内存区间,例如调 用sprintf时传进来的格式化字符串和结果字符串的首地址相同, 诸如此类的调用都是非法的。本书也遵循这一惯例,除非 像memmove这样特别说明之外,都表示“不允许”。 关于restrict关键字更详细的解释可以参考[BeganFORTRAN]。 字符串的拷贝也可以用strdup(3)函数,这个函数不属于C标准库,是POSIX标准中定义 的,POSIX标准定义了UNIX系统的各种接口,包含C标准库的所有函数和很多其它的系统函 数,在第 2 节 “C标准I/O库函数与Unbuffered I/O函数”将详细介绍POSIX标准。 #include char *strdup(const char *s); 返回值:指向新分配的字符串 这个函数调用malloc动态分配内存,把字符串s拷贝到新分配的内存中然后返回。用这个函数省 去了事先为新字符串分配内存的麻烦,但是用完之后要记得调用free释放新字符串的内存。 1.4. 连接字符串 #include char *strcat(char *dest, const char *src); char *strncat(char *dest, const char *src, size_t n); 返回值:dest指向哪,返回的指针就指向哪 strcat把src所指的字符串连接到dest所指的字符串后面,例如: char d[10] = "foo"; char s[10] = "bar"; strcat(d, s); printf("%s %s\n", d, s); 调用strcat函数后,缓冲区s的内容没变,缓冲区d中保存着字符串"foobar",注意原来"foo"后 面的'\0'被连接上来的字符串"bar"覆盖掉了,"bar"后面的'\0'仍保留。 strcat和strcpy有同样的问题,调用者必须确保dest缓冲区足够大,否则会导致缓冲区溢出错 误。strncat函数通过参数n指定一个长度,就可以避免缓冲区溢出错误。注意这个参数n的含义 和strncpy的参数n不同,它并不是缓冲区dest的长度,而是表示最多从src缓冲区中取n个字符 (不包括结尾的'\0')连接到dest后面。如果src中前n个字符没有出现'\0',则取前n个字符再 加一个'\0'连接到dest后面,所以strncat总是保证dest缓冲区以'\0'结尾,这一点又 和strncpy不同,strncpy并不保证dest缓冲区以'\0'结尾。所以,提供给strncat函数的dest缓 冲区的大小至少应该是strlen(dest)+n+1个字节,才能保证不溢出。 1.5. 比较字符串 #include int memcmp(const void *s1, const void *s2, size_t n); int strcmp(const char *s1, const char *s2); int strncmp(const char *s1, const char *s2, size_t n); 返回值:负值表示s1小于s2,0表示s1等于s2,正值表示s1大于s2 memcmp从前到后逐个比较缓冲区s1和s2的前n个字节(不管里面有没有'\0'),如果s1和s2的 前n个字节全都一样就返回0,如果遇到不一样的字节,s1的字节比s2小就返回负值,s1的字节 比s2大就返回正值。 strcmp把s1和s2当字符串比较,在其中一个字符串中遇到'\0'时结束,按照上面的比较准 则,"ABC" 比"abc" 小,"ABCD"比"ABC" 大,"123A9"比"123B2"小。 strncmp的比较结束条件是:要么在其中一个字符串中遇到'\0'结束(类似于strcmp),要么比 较完n个字符结束(类似于memcmp)。例如,strncmp("ABCD", "ABC", 3)的返回值 是0,strncmp("ABCD", "ABC", 4)的返回值是正值。 #include int strcasecmp(const char *s1, const char *s2); int strncasecmp(const char *s1, const char *s2, size_t n); 返回值:负值表示s1小于s2,0表示s1等于s2,正值表示s1大于s2 这两个函数和strcmp/strncmp类似,但在比较过程中忽略大小写,大写字母A和小写字母a认为 是相等的。这两个函数不属于C标准库,是POSIX标准中定义的。 1.6. 搜索字符串 #include char *strchr(const char *s, int c); char *strrchr(const char *s, int c); 返回值:如果找到字符c,返回字符串s中指向字符c的指针,如果找不到就返回NULL strchr在字符串s中从前到后查找字符c,找到字符c第一次出现的位置时就返回,返回值指向这 个位置,如果找不到字符c就返回NULL。strrchr和strchr类似,但是从右向左找字符c,找到字 符c第一次出现的位置就返回,函数名中间多了一个字母r可以理解为Right-to-left。 #include char *strstr(const char *haystack, const char *needle); 返回值:如果找到子串,返回值指向子串的开头,如果找不到就返回NULL strstr在一个长字符串中从前到后找一个子串(Substring),找到子串第一次出现的位置就返 回,返回值指向子串的开头,如果找不到就返回NULL。这两个参数名很形象,在干草 堆haystack中找一根针needle,按中文的说法叫大海捞针,显然haystack是长字符串,needle是 要找的子串。 搜索子串有一个显而易见的算法,可以用两层的循环,外层循环把haystack中的每一个字符的 位置依次假定为子串的开头,内层循环从这个位置开始逐个比较haystack和needle的每个字符是 否相同。想想这个算法最多需要做多少次比较?其实有比这个算法高效得多的算法,有兴趣的 读者可以参考[算法导论]。 1.7. 分割字符串 很多文件格式或协议格式中会规定一些分隔符或者叫界定符(Delimiter),例如/etc/passwd文 件中保存着系统的帐号信息: $ cat /etc/passwd root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/bin/sh bin:x:2:2:bin:/bin:/bin/sh ...... 每条记录占一行,也就是说记录之间的分隔符是换行符,每条记录又由若干个字段组成,这些 字段包括用户名、密码、用户id、组id、个人信息、主目录、登录Shell,字段之间的分隔符 是:号。解析这样的字符串需要根据分隔符把字符串分割成几段,C标准库提供的strtok函数可 以很方便地完成分割字符串的操作。tok是Token的缩写,分割出来的每一段字符串称为一 个Token。 #include char *strtok(char *str, const char *delim); char *strtok_r(char *str, const char *delim, char **saveptr); 返回值:返回指向下一个Token 的指针,如果没有下一个Token 了就返回NULL 参数str是待分割的字符串,delim是分隔符,可以指定一个或多个分隔符,strtok遇到其中任 何一个分隔符就会分割字符串。看下面的例子。 例 25.2. strtok #include #include int main(void) { char str[] = "root:x::0:root:/root:/bin/bash:"; char *token; token = strtok(str, ":"); printf("%s\n", token); while ( (token = strtok(NULL, ":")) != NULL) printf("%s\n", token); return 0; } $ ./a.out root x 0 root /root /bin/bash 从"root:x::0:root:/root:/bin/bash:"这个例子可以看出,如果在字符串开头或结尾出现分隔 符会被忽略,如果字符串中连续出现两个分隔符就认为是一个分隔符,而不会认为两个分隔符 中间有一个空字符串的Token。第一次调用时把字符串传给strtok,以后每次调用时第一个参数 只要传NULL就可以了,strtok函数自己会记住上次处理到字符串的什么位置(显然这是通 过strtok函数中的一个静态指针变量记住的)。 用gdb跟踪这个程序,会发现str字符串被strtok不断修改,每次调用strtok把str中的一个分隔 符改成'\0',分割出一个小字符串,并返回这个小字符串的首地址。 (gdb) start Breakpoint 1 at 0x8048415: file main.c, line 5. Starting program: /home/djkings/a.out main () at main.c:5 5 { (gdb) n 6 char str[] = "root:x::0:root:/root:/bin/bash:"; (gdb) 9 token = strtok(str, ":"); (gdb) display str 1: str = "root:x::0:root:/root:/bin/bash:" (gdb) n 10 printf("%s\n", token); 1: str = "root\000x::0:root:/root:/bin/bash:" (gdb) root 11 while ( (token = strtok(NULL, ":")) != NULL) 1: str = "root\000x::0:root:/root:/bin/bash:" (gdb) 12 printf("%s\n", token); 1: str = "root\000x\000:0:root:/root:/bin/bash:" (gdb) x 11 while ( (token = strtok(NULL, ":")) != NULL) 1: str = "root\000x\000:0:root:/root:/bin/bash:" 刚才提到在strtok函数中应该有一个静态指针变量记住上次处理到字符串中的什么位置,所以 不需要每次调用时都把字符串中的当前处理位置传给strtok,但是在函数中使用静态变量是不 好的,以后会讲到这样的函数是不可重入的。strtok_r函数则不存在这个问题,它的内部没有 静态变量,调用者需要自己分配一个指针变量来维护字符串中的当前处理位置,每次调用时把 这个指针变量的地址传给strtok_r的第三个参数,告诉strtok_r从哪里开始处理,strtok_r返回 时再把新的处理位置写回到这个指针变量中(这是一个Value-result参数)。strtok_r末尾的r就 表示可重入(Reentrant),这个函数不属于C标准库,是在POSIX标准中定义的。关 于strtok_r的用法Man Page上有一个很好的例子: 例 25.3. strtok_r #include #include #include int main(int argc, char *argv[]) { char *str1, *str2, *token, *subtoken; char *saveptr1, *saveptr2; int j; if (argc != 4) { fprintf(stderr, "Usage: %s string delim subdelim\n", argv[0]); exit(EXIT_FAILURE); } for (j = 1, str1 = argv[1]; ; j++, str1 = NULL) { token = strtok_r(str1, argv[2], &saveptr1); if (token == NULL) break; printf("%d: %s\n", j, token); for (str2 = token; ; str2 = NULL) { subtoken = strtok_r(str2, argv[3], &saveptr2); if (subtoken == NULL) break; printf(" --> %s\n", subtoken); } } exit(EXIT_SUCCESS); } $ ./a.out 'a/bbb///cc;xxx:yyy:' ':;' '/' 1: a/bbb///cc --> a --> bbb --> cc 2: xxx --> xxx 3: yyy --> yyy a/bbb///cc;xxx:yyy:这个字符串有两级分隔符,一级分隔符是:号或;号,把这个字符串分割 成a/bbb///cc、xxx、yyy三个子串,二级分隔符是/,只有第一个子串中有二级分隔符,它被进 一步分割成a、bbb、cc三个子串。由于strtok_r不使用静态变量,而是要求调用者自己保存字 符串的当前处理位置,所以这个例子可以在按一级分隔符分割整个字符串的过程中穿插着用二 级分隔符分割其中的每个子串。建议读者用gdb的display命令跟 踪argv[1]、saveptr1和saveptr2,以理解strtok_r函数的工作方式。 Man Page的BUGS部分指出了用strtok和strtok_r函数需要注意的问题: 这两个函数要改写字符串以达到分割的效果 这两个函数不能用于常量字符串,因为试图改写.rodata段会产生段错误 在做了分割之后,字符串中的分隔符就被'\0'覆盖了 strtok函数使用了静态变量,它不是线程安全的,必要时应该用可重入的strtok_r函数, 以后再详细介绍“可重入”和“线程安全”这两个概念 习题 1、出于练习的目的,strtok和strtok_r函数非常值得自己动手实现一遍,在这个过程中不仅可 以更深刻地理解这两个函数的工作原理,也为以后理解“可重入”和“线程安全”这两个重要概念打 下基础。 上一页 第 25 章 C标准库 上一级 起始页 下一页 2. 标准I/O库函数 上一页 2. 标准I/O库函数 第 25 章 C标准库 下一页 2. 标准I/O库函数 2.1. 文件的基本概念 我们已经多次用到了文件,例如源文件、目标文件、可执行文件、库文件等,现在学习如何 用C标准库对文件进行读写操作,对文件的读写也属于I/O操作的一种,本节介绍的大部分函数 在头文件stdio.h中声明,称为标准I/O库函数。 文件可分为文本文件(Text File)和二进制文件(Binary File)两种,源文件是文本文件,而目 标文件、可执行文件和库文件是二进制文件。文本文件是用来保存字符的,文件中的字节都是 字符的某种编码(例如ASCII或UTF-8),用cat命令可以查看其中的字符,用vi可以编辑其中 的字符,而二进制文件不是用来保存字符的,文件中的字节表示其它含义,例如可执行文件中 有些字节表示指令,有些字节表示各Section和Segment在文件中的位置,有些字节表示 各Segment的加载地址。 在第 5.1 节 “目标文件”中我们用hexdump命令查看过一个二进制文件。我们再做一个小实验, 用vi编辑一个文件textfile,在其中输入5678然后保存退出,用ls -l命令可以看到它的长度 是5: $ ls -l textfile -rw-r--r-- 1 akaedu akaedu 5 2009-03-20 10:58 textfile 5678四个字符各占一个字节,vi会自动在文件末尾加一个换行符,所以文件长度是5。用od命令 查看该文件的内容: $ od -tx1 -tc -Ax textfile 000000 35 36 37 38 0a 5 6 7 8 \n 000005 -tx1选项表示将文件中的字节以十六进制的形式列出来,每组一个字节,-tc选项表示将文件中 的ASCII码以字符形式列出来。和hexdump类似,输出结果最左边的一列是文件中的地址,默认 以八进制显示,-Ax选项要求以十六进制显示文件中的地址。这样我们看到,这个文件中保存 了5个字符,以ASCII码保存。ASCII码的范围是0~127,所以ASCII码文本文件中每个字节只用 到低7位,最高位都是0。以后我们会经常用到od命令。 文本文件是一个模糊的概念。有些时候说文本文件是指用vi可以编辑出来的文件,例如/etc目 录下的各种配置文件,这些文件中只包含ASCII码中的可见字符,而不包含像'\0'这种不可见字 符,也不包含最高位是1的非ASCII码字节。从广义上来说,只要是专门保存字符的文件都算文 本文件,包含不可见字符的也算,采用其它字符编码(例如UTF-8编码)的也算。 2.2. fopen/fclose 在操作文件之前要用fopen打开文件,操作完毕要用fclose关闭文件。打开文件就是在操作系统 中分配一些资源用于保存该文件的状态信息,并得到该文件的标识,以后用户程序就可以用这 个标识对文件做各种操作,关闭文件则释放文件在操作系统中占用的资源,使文件的标识失 效,用户程序就无法再操作这个文件了。 #include FILE *fopen(const char *path, const char *mode); 返回值:成功返回文件指针,出错返回NULL并设置errno path是文件的路径名,mode表示打开方式。如果文件打开成功,就返回一个FILE *文件指针来 标识这个文件。以后调用其它函数对文件做读写操作都要提供这个指针,以指明对哪个文件进 行操作。FILE是C标准库中定义的结构体类型,其中包含该文件在内核中标识(在第 2 节 “C标 准I/O库函数与Unbuffered I/O函数”将会讲到这个标识叫做文件描述符)、I/O缓冲区和当前读写 位置等信息,但调用者不必知道FILE结构体都有哪些成员,我们很快就会看到,调用者只是把 文件指针在库函数接口之间传来传去,而文件指针所指的FILE结构体的成员在库函数内部维 护,调用者不应该直接访问这些成员,这种编程思想在面向对象方法论中称为封装 (Encapsulation)。像FILE *这样的指针称为不透明指针(Opaque Pointer)或者叫句柄 (Handle),FILE *指针就像一个把手(Handle),抓住这个把手就可以打开门或抽屉,但用 户只能抓这个把手,而不能直接抓门或抽屉。 下面说说参数path和mode,path可以是相对路径也可以是绝对路径,mode表示打开方式是读还 是写。比如fp = fopen("/tmp/file2", "w");表示打开绝对路径/tmp/file2,只做写操 作,path也可以是相对路径,比如fp = fopen("file.a", "r");表示在当前工作目录下打开文 件file.a,只做读操作,再比如fp = fopen("../a.out", "r");只读打开当前工作目录上一层目 录下的a.out,fp = fopen("Desktop/file3", "w");只写打开当前工作目录下子目录Desktop下 的file3。相对路径是相对于当前工作目录(Current Working Directory)的路径,每个进程都 有自己的当前工作目录,Shell进程的当前工作目录可以用pwd命令查看: $ pwd /home/akaedu 通常Linux发行版都把Shell配置成在提示符前面显示当前工作目录,例如~$表示当前工作目录是 主目录,/etc$表示当前工作目录是/etc。用cd命令可以改变Shell进程的当前工作目录。 在Shell下敲命令启动新的进程,则该进程的当前工作目录继承自Shell进程的当前工作目录,该 进程也可以调用chdir(2)函数改变自己的当前工作目录。 mode参数是一个字符串,由rwatb+六个字符组合而成,r表示读,w表示写,a表示追加 (Append),在文件末尾追加数据使文件的尺寸增大。t表示文本文件,b表示二进制文件,有 些操作系统的文本文件和二进制文件格式不同,而在UNIX系统中,无论文本文件还是二进制文 件都是由一串字节组成,t和b没有区分,用哪个都一样,也可以省略不写。如果省 略t和b,rwa+四个字符有以下6种合法的组合: "r" 只读,文件必须已存在 "w" 只写,如果文件不存在则创建,如果文件已存在则把文件长度截断(Truncate)为0字节 再重新写,也就是替换掉原来的文件内容 "a" 只能在文件末尾追加数据,如果文件不存在则创建 "r+" 允许读和写,文件必须已存在 "w+" 允许读和写,如果文件不存在则创建,如果文件已存在则把文件长度截断为0字节再重新 写 "a+" 允许读和追加数据,如果文件不存在则创建 在打开一个文件时如果出错,fopen将返回NULL并设置errno,errno稍后介绍。在程序中应该做 出错处理,通常这样写: if ( (fp = fopen("/tmp/file1", "r")) == NULL) { printf("error open file /tmp/file1!\n"); exit(1); } 比如/tmp/file1这个文件不存在,而r打开方式又不会创建这个文件,fopen就会出错返回。 再说说fclose函数。 #include int fclose(FILE *fp); 返回值:成功返回0,出错返回EOF并设置errno 把文件指针传给fclose可以关闭它所标识的文件,关闭之后该文件指针就无效了,不能再使用 了。如果fclose调用出错(比如传给它一个无效的文件指针)则返回EOF并设置errno,errno稍 后介绍,EOF在stdio.h中定义: /* End of file character. Some things throughout the library rely on this being -1. */ #ifndef EOF # define EOF (-1) #endif 它的值是-1。fopen调用应该和fclose调用配对,打开文件操作完之后一定要记得关闭。如果不 调用fclose,在进程退出时系统会自动关闭文件,但是不能因此就忽略fclose调用,如果写一个 长年累月运行的程序(比如网络服务器程序),打开的文件都不关闭,堆积得越来越多,就会 占用越来越多的系统资源。 2.3. stdin/stdout/stderr 我们经常用printf打印到屏幕,也用过scanf读键盘输入,这些也属于I/O操作,但不是对文件 做I/O操作而是对终端设备做I/O操作。所谓终端(Terminal)是指人机交互的设备,也就是可以 接受用户输入并输出信息给用户的设备。在计算机刚诞生的年代,终端是电传打字机和打印 机,现在的终端通常是键盘和显示器。终端设备和文件一样也需要先打开后操作,终端设备也 有对应的路径名,/dev/tty就表示和当前进程相关联的终端设备(在第 1.1 节 “终端的基本概 念”会讲到这叫进程的控制终端)。也就是说,/dev/tty不是一个普通的文件,它不表示磁盘上 的一组数据,而是表示一个设备。用ls命令查看这个文件: $ ls -l /dev/tty crw-rw-rw- 1 root dialout 5, 0 2009-03-20 19:31 /dev/tty 开头的c表示文件类型是字符设备。中间的5, 0是它的设备号,主设备号5,次设备号0,主设备 号标识内核中的一个设备驱动程序,次设备号标识该设备驱动程序管理的一个设备。内核通过 设备号找到相应的驱动程序,完成对该设备的操作。我们知道常规文件的这一列应该显示文件 尺寸,而设备文件的这一列显示设备号,这表明设备文件是没有文件尺寸这个属性的,因为设 备文件在磁盘上不保存数据,对设备文件做读写操作并不是读写磁盘上的数据,而是在读写设 备。UNIX的传统是Everything is a file,键盘、显示器、串口、磁盘等设备在/dev目录下都有一 个特殊的设备文件与之对应,这些设备文件也可以像普通文件一样打开、读、写和关闭,使用 的函数接口是相同的。本书中不严格区分“文件”和“设备”这两个概念,遇到“文件”这个词,读者 可以根据上下文理解它是指普通文件还是设备,如果需要强调是保存在磁盘上的普通文件,本 书会用“常规文件”(Regular File)这个词。 那为什么printf和scanf不用打开就能对终端设备进行操作呢?因为在程序启动时(在main函数 还没开始执行之前)会自动把终端设备打开三次,分别赋给三个FILE *指 针stdin、stdout和stderr,这三个文件指针是libc中定义的全局变量,在stdio.h中声 明,printf向stdout写,而scanf从stdin读,后面我们会看到,用户程序也可以直接使用这三个 文件指针。这三个文件指针的打开方式都是可读可写的,但通常stdin只用于读操作,称为标准 输入(Standard Input),stdout只用于写操作,称为标准输出(Standard Output),stderr也 只用于写操作,称为标准错误输出(Standard Error),通常程序的运行结果打印到标准输出, 而错误提示(例如gcc报的警告和错误)打印到标准错误输出,所以fopen的错误处理写成这样 更符合惯例: if ( (fp = fopen("/tmp/file1", "r")) == NULL) { fputs("Error open file /tmp/file1\n", stderr); exit(1); } fputs函数将在稍后详细介绍。不管是打印到标准输出还是打印到标准错误输出效果是一样的, 都是打印到终端设备(也就是屏幕)了,那为什么还要分成标准输出和标准错误输出呢?以后 我们会讲到重定向操作,可以把标准输出重定向到一个常规文件,而标准错误输出仍然对应终 端设备,这样就可以把正常的运行结果和错误提示分开,而不是混在一起打印到屏幕了。 2.4. errno与perror函数 很多系统函数在错误返回时将错误原因记录在libc定义的全局变量errno中,每种错误原因对应 一个错误码,请查阅errno(3)的Man Page了解各种错误码,errno在头文件errno.h中声明,是 一个整型变量,所有错误码都是正整数。 如果在程序中打印错误信息时直接打印errno变量,打印出来的只是一个整数值,仍然看不出是 什么错误。比较好的办法是用perror或strerror函数将errno解释成字符串再打印。 #include void perror(const char *s); perror函数将错误信息打印到标准错误输出,首先打印参数s所指的字符串,然后打印:号,然后 根据当前errno的值打印错误原因。例如: 例 25.4. perror #include #include int main(void) { FILE *fp = fopen("abcde", "r"); if (fp == NULL) { perror("Open file abcde"); exit(1); } return 0; } 如果文件abcde不存在,fopen返回-1并设置errno为ENOENT,紧接着perror函数读取errno的值, 将ENOENT解释成字符串No such file or directory并打印,最后打印的结果是Open file abcde: No such file or directory。虽然perror可以打印出错误原因,传给perror的字符串参 数仍然应该提供一些额外的信息,以便在看到错误信息时能够很快定位是程序中哪里出了错, 如果在程序中有很多个fopen调用,每个fopen打开不同的文件,那么在每个fopen的错误处理中 打印文件名就很有帮助。 如果把上面的程序改成这样: #include #include #include int main(void) { FILE *fp = fopen("abcde", "r"); if (fp == NULL) { perror("Open file abcde"); printf("errno: %d\n", errno); exit(1); } return 0; } 则printf打印的错误号并不是fopen产生的错误号,而是perror产生的错误号。errno是一个全局 变量,很多系统函数都会改变它,fopen函数Man Page中的ERRORS部分描述了它可能产生的 错误码,perror函数的Man Page中没有ERRORS部分,说明它本身不产生错误码,但它调用的其 它函数也有可能改变errno变量。大多数系统函数都有一个Side Effect,就是有可能改变errno变 量(当然也有少数例外,比如strcpy),所以一个系统函数错误返回后应该马上检查errno,在 检查errno之前不能再调用其它系统函数。 strerror函数可以根据错误号返回错误原因字符串。 #include char *strerror(int errnum); 返回值:错误码errnum所对应的字符串 这个函数返回指向静态内存的指针。以后学线程库时我们会看到,有些函数的错误码并不保存 在errno中,而是通过返回值返回,就不能调用perror打印错误原因了,这时strerror就派上了 用场: fputs(strerror(n), stderr); 习题 1、在系统头文件中找到各种错误码的宏定义。 2、做几个小练习,看看fopen出错有哪些常见的原因。 打开一个没有访问权限的文件。 fp = fopen("/etc/shadow", "r"); if (fp == NULL) { perror("Open /etc/shadow"); exit(1); } fopen也可以打开一个目录,传给fopen的第一个参数目录名末尾可以加/也可以不加/,但只允 许以只读方式打开。试试如果以可写的方式打开一个存在的目录会怎么样呢? fp = fopen("/home/akaedu/", "r+"); if (fp == NULL) { perror("Open /home/akaedu"); exit(1); } 请读者自己设计几个实验,看看你还能测试出哪些错误原因? 2.5. 以字节为单位的I/O函数 fgetc函数从指定的文件中读一个字节,getchar从标准输入读一个字节,调用getchar()相当于 调用fgetc(stdin)。 #include int fgetc(FILE *stream); int getchar(void); 返回值:成功返回读到的字节,出错或者读到文件末尾时返回EOF 注意在Man Page的函数原型中FILE *指针参数有时会起名叫stream,这是因为标准I/O库操作 的文件有时也叫做流(Stream),文件由一串字节组成,每次可以读或写其中任意数量的字 节,以后介绍TCP协议时会对流这个概念做更详细的解释。 对于fgetc函数的使用有以下几点说明: 要用fgetc函数读一个文件,该文件的打开方式必须是可读的。 系统对于每个打开的文件都记录着当前读写位置在文件中的地址(或者说距离文件开头的 字节数),也叫偏移量(Offset)。当文件打开时,读写位置是0,每调用一次fgetc,读 写位置向后移动一个字节,因此可以连续多次调用fgetc函数依次读取多个字节。 fgetc成功时返回读到一个字节,本来应该是unsigned char型的,但由于函数原型中返回 值是int型,所以这个字节要转换成int型再返回,那为什么要规定返回值是int型呢?因 为出错或读到文件末尾时fgetc将返回EOF,即-1,保存在int型的返回值中是0xffffffff,如 果读到字节0xff,由unsigned char型转换为int型是0x000000ff,只有规定返回值是int型 才能把这两种情况区分开,如果规定返回值是unsigned char型,那么当返回值是0xff时无 法区分到底是EOF还是字节0xff。如果需要保存fgetc的返回值,一定要保存在int型变量 中,如果写成unsigned char c = fgetc(fp);,那么根据c的值又无法区分EOF和0xff字节 了。注意,fgetc读到文件末尾时返回EOF,只是用这个返回值表示已读到文件末尾,并不 是说每个文件末尾都有一个字节是EOF(根据上面的分析,EOF并不是一个字节)。 fputc函数向指定的文件写一个字节,putchar向标准输出写一个字节,调用putchar(c)相当于调 用fputc(c, stdout)。 #include int fputc(int c, FILE *stream); int putchar(int c); 返回值:成功返回写入的字节,出错返回EOF 对于fputc函数的使用也要说明几点: 要用fputc函数写一个文件,该文件的打开方式必须是可写的(包括追加)。 每调用一次fputc,读写位置向后移动一个字节,因此可以连续多次调用fputc函数依次写 入多个字节。但如果文件是以追加方式打开的,每次调用fputc时总是将读写位置移到文 件末尾然后把要写入的字节追加到后面。 下面的例子演示了这四个函数的用法,从键盘读入一串字符写到一个文件中,再从这个文件中 读出这些字符打印到屏幕上。 例 25.5. 用fputc/fget读写文件和终端 #include #include int main(void) { FILE *fp; int ch; if ( (fp = fopen("file2", "w+")) == NULL) { perror("Open file file2\n"); exit(1); } while ( (ch = getchar()) != EOF) fputc(ch, fp); rewind(fp); while ( (ch = fgetc(fp)) != EOF) putchar(ch); fclose(fp); return 0; } 从终端设备读有点特殊。当调用getchar()或fgetc(stdin)时,如果用户没有输入字 符,getchar函数就阻塞等待,所谓阻塞是指这个函数调用不返回,也就不能执行后面的代码, 这个进程阻塞了,操作系统可以调度别的进程执行。从终端设备读还有一个特点,用户输入一 般字符并不会使getchar函数返回,仍然阻塞着,只有当用户输入回车或者到达文件末尾 时getchar才返回[32]。这个程序的执行过程分析如下: $ ./a.out hello (输入hello 并回车,这时第一次调用getchar返回,读取字符h 存到文件中,然 后连续调用getchar五次,读取ello和换行符存到文件中,第七次调用getchar又阻塞 了) hey(输入hey并回车,第七次调用getchar返回,读取字符h存到文件中,然后连续调 用getchar三次,读取ey和换行符存到文件中,第11次调用getchar又阻塞了) (这时输入Ctrl-D,第11次调用getchar返回EOF,跳出循环,进入下一个循环,回到 文件开头,把文件内容一个字节一个字节读出来打印,直到文件结束) hello hey 从终端设备输入时有两种方法表示文件结束,一种方法是在一行的开头输入Ctrl-D(如果不在一 行的开头则需要连续输入两次Ctrl-D),另一种方法是利用Shell的Heredoc语法: $ ./a.out < hello > hey > END hello hey < int fseek(FILE *stream, long offset, int whence); 返回值:成功返回0,出错返回-1并设置errno long ftell(FILE *stream); 返回值:成功返回当前读写位置,出错返回-1并设置errno void rewind(FILE *stream); fseek的whence和offset参数共同决定了读写位置移动到何处,whence参数的含义如下: SEEK_SET 从文件开头移动offset个字节 SEEK_CUR 从当前位置移动offset个字节 SEEK_END 从文件末尾移动offset个字节 offset可正可负,负值表示向前(向文件开头的方向)移动,正值表示向后(向文件末尾的方 向)移动,如果向前移动的字节数超过了文件开头则出错返回,如果向后移动的字节数超过了 文件末尾,再次写入时将增大文件尺寸,从原来的文件末尾到fseek移动之后的读写位置之间的 字节都是0。 先前我们创建过一个文件textfile,其中有五个字节,5678加一个换行符,现在我们拿这个文 件做实验。 例 25.6. fseek #include #include int main(void) { FILE* fp; if ( (fp = fopen("textfile","r+")) == NULL) { perror("Open file textfile"); exit(1); } if (fseek(fp, 10, SEEK_SET) != 0) { perror("Seek file textfile"); exit(1); } fputc('K', fp); fclose(fp); return 0; } 运行这个程序,然后查看文件textfile的内容: $ ./a.out $ od -tx1 -tc -Ax textfile 000000 35 36 37 38 0a 00 00 00 00 00 4b 5 6 7 8 \n \0 \0 \0 \0 \0 K 00000b fseek(fp, 10, SEEK_SET)将读写位置移到第10个字节处(其实是第11个字节,从0开始数), 然后在该位置写入一个字符K,这样textfile文件就变长了,从第5到第9个字节自动被填充 为0。 2.7. 以字符串为单位的I/O函数 fgets从指定的文件中读一行字符到调用者提供的缓冲区中,gets从标准输入读一行字符到调用 者提供的缓冲区中。 #include char *fgets(char *s, int size, FILE *stream); char *gets(char *s); 返回值:成功时s指向哪返回的指针就指向哪,出错或者读到文件末尾时返回NULL gets函数无需解释,Man Page的BUGS部分已经说得很清楚了:Never use gets()。gets函数 的存在只是为了兼容以前的程序,我们写的代码都不应该调用这个函数。gets函数的接口设计 得很有问题,就像strcpy一样,用户提供一个缓冲区,却不能指定缓冲区的大小,很可能导致 缓冲区溢出错误,这个函数比strcpy更加危险,strcpy的输入和输出都来自程序内部,只要程序 员小心一点就可以避免出问题,而gets读取的输入直接来自程序外部,用户可能通过标准输入 提供任意长的字符串,程序员无法避免gets函数导致的缓冲区溢出错误,所以唯一的办法就是 不要用它。 现在说说fgets函数,参数s是缓冲区的首地址,size是缓冲区的长度,该函数从stream所指的 文件中读取以'\n'结尾的一行(包括'\n'在内)存到缓冲区s中,并且在该行末尾添加一 个'\0'组成完整的字符串。 如果文件中的一行太长,fgets从文件中读了size-1个字符还没有读到'\n',就把已经读到 的size-1个字符和一个'\0'字符存入缓冲区,文件中剩下的半行可以在下次调用fgets时继续 读。 如果一次fgets调用在读入若干个字符后到达文件末尾,则将已读到的字符串加上'\0'存入缓冲 区并返回,如果再次调用fgets则返回NULL,可以据此判断是否读到文件末尾。 注意,对于fgets来说,'\n'是一个特别的字符,而'\0'并无任何特别之处,如果读到'\0'就当 作普通字符读入。如果文件中存在'\0'字符(或者说0x00字节),调用fgets之后就无法判断缓 冲区中的'\0'究竟是从文件读上来的字符还是由fgets自动添加的结束符,所以fgets只适合读 文本文件而不适合读二进制文件,并且文本文件中的所有字符都应该是可见字符,不能 有'\0'。 fputs向指定的文件写入一个字符串,puts向标准输出写入一个字符串。 #include int fputs(const char *s, FILE *stream); int puts(const char *s); 返回值:成功返回一个非负整数,出错返回EOF 缓冲区s中保存的是以'\0'结尾的字符串,fputs将该字符串写入文件stream,但并不写入结尾 的'\0'。与fgets不同的是,fputs并不关心的字符串中的'\n'字符,字符串中可以有'\n'也可 以没有'\n'。puts将字符串s写到标准输出(不包括结尾的'\0'),然后自动写一个'\n'到标准 输出。 习题 1、用fgets/fputs写一个拷贝文件的程序,根据本节对fgets函数的分析,应该只能拷贝文本文 件,试试用它拷贝二进制文件会出什么问题。 2.8. 以记录为单位的I/O函数 #include size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream); size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream); 返回值:读或写的记录数,成功时返回的记录数等于nmemb ,出错或读到文件末尾时返回 的记录数小于nmemb ,也可能返回0 fread和fwrite用于读写记录,这里的记录是指一串固定长度的字节,比如一个int、一个结构 体或者一个定长数组。参数size指出一条记录的长度,而nmemb指出要读或写多少条记录,这些 记录在ptr所指的内存空间中连续存放,共占size * nmemb个字节,fread从文件stream中读 出size * nmemb个字节保存到ptr中,而fwrite把ptr中的size * nmemb个字节写到文 件stream中。 nmemb是请求读或写的记录数,fread和fwrite返回的记录数有可能小于nmemb指定的记录数。例 如当前读写位置距文件末尾只有一条记录的长度,调用fread时指定nmemb为2,则返回值为1。 如果当前读写位置已经在文件末尾了,或者读文件时出错了,则fread返回0。如果写文件时出 错了,则fwrite的返回值小于nmemb指定的值。下面的例子由两个程序组成,一个程序把结构体 保存到文件中,另一个程序和从文件中读出结构体。 例 25.7. fread/fwrite /* writerec.c */ #include #include struct record { char name[10]; int age; }; int main(void) { struct record array[2] = {{"Ken", 24}, {"Knuth", 28}}; FILE *fp = fopen("recfile", "w"); if (fp == NULL) { perror("Open file recfile"); exit(1); } fwrite(array, sizeof(struct record), 2, fp); fclose(fp); return 0; } /* readrec.c */ #include #include struct record { char name[10]; int age; }; int main(void) { struct record array[2]; FILE *fp = fopen("recfile", "r"); if (fp == NULL) { perror("Open file recfile"); exit(1); } fread(array, sizeof(struct record), 2, fp); printf("Name1: %s\tAge1: %d\n", array[0].name, array[0].age); printf("Name2: %s\tAge2: %d\n", array[1].name, array[1].age); fclose(fp); return 0; } $ gcc writerec.c -o writerec $ gcc readrec.c -o readrec $ ./writerec $ od -tx1 -tc -Ax recfile 000000 4b 65 6e 00 00 00 00 00 00 00 00 00 18 00 00 00 K e n \0 \0 \0 \0 \0 \0 \0 \0 \0 030 \0 \0 \0 000010 4b 6e 75 74 68 00 00 00 00 00 00 00 1c 00 00 00 K n u t h \0 \0 \0 \0 \0 \0 \0 034 \0 \0 \0 000020 $ ./readrec Name1: Ken Age1: 24 Name2: Knuth Age2: 28 我们把一个struct record结构体看作一条记录,由于结构体中有填充字节,每条记录占16字 节,把两条记录写到文件中共占32字节。该程序生成的recfile文件是二进制文件而非文本文 件,因为其中不仅保存着字符型数据,还保存着整型数据24和28(在od命令的输出中以八进制 显示为030和034)。注意,直接在文件中读写结构体的程序是不可移植的,如果在一种平台上 编译运行writebin.c程序,把生成的recfile文件拷到另一种平台并在该平台上编译运 行readbin.c程序,则不能保证正确读出文件的内容,因为不同平台的大小端可能不同(因而对 整型数据的存储方式不同),结构体的填充方式也可能不同(因而同一个结构体所占的字节数 可能不同,age成员在name成员之后的什么位置也可能不同)。 2.9. 格式化I/O函数 现在该正式讲一下printf和scanf函数了,这两个函数都有很多种形式。 #include int printf(const char *format, ...); int fprintf(FILE *stream, const char *format, ...); int sprintf(char *str, const char *format, ...); int snprintf(char *str, size_t size, const char *format, ...); #include int vprintf(const char *format, va_list ap); int vfprintf(FILE *stream, const char *format, va_list ap); int vsprintf(char *str, const char *format, va_list ap); int vsnprintf(char *str, size_t size, const char *format, va_list ap); 返回值:成功返回格式化输出的字节数(不包括字符串的结尾'\0'),出错返回一个负值 printf格式化打印到标准输出,而fprintf打印到指定的文件stream中。sprintf并不打印到文 件,而是打印到用户提供的缓冲区str中并在末尾加'\0',由于格式化后的字符串长度很难预 计,所以很可能造成缓冲区溢出,用snprintf更好一些,参数size指定了缓冲区长度,如果格 式化后的字符串长度超过缓冲区长度,snprintf就把字符串截断到size-1字节,再加上一 个'\0'写入缓冲区,也就是说snprintf保证字符串以'\0'结尾。snprintf的返回值是格式化后的 字符串长度(不包括结尾的'\0'),如果字符串被截断,返回的是截断之前的长度,把它和实 际缓冲区中的字符串长度相比较就可以知道是否发生了截断。 上面列出的后四个函数在前四个函数名的前面多了个v,表示可变参数不是以...的形式传进 来,而是以va_list类型传进来。下面我们用vsnprintf包装出一个类似printf的带格式化字符串 和可变参数的函数。 例 25.8. 实现格式化打印错误的err_sys函数 #include #include #include #include #include #define MAXLINE 80 void err_sys(const char *fmt, ...) { int err = errno; char buf[MAXLINE+1]; va_list ap; va_start(ap, fmt); vsnprintf(buf, MAXLINE, fmt, ap); snprintf(buf+strlen(buf), MAXLINE-strlen(buf), ": %s", strerror(err)); strcat(buf, "\n"); fputs(buf, stderr); va_end(ap); exit(1); } int main(int argc, char *argv[]) { FILE *fp; if (argc != 2) { fputs("Usage: ./a.out pathname\n", stderr); exit(1); } fp = fopen(argv[1], "r"); if (fp == NULL) err_sys("Line %d - Open file %s", __LINE__, argv[1]); printf("Open %s OK\n", argv[1]); fclose(fp); return 0; } 有了err_sys函数,不仅简化了main函数的代码,而且可以把fopen的错误提示打印得非常清 楚,有源代码行号,有打开文件的路径名,一看就知道哪里出错了。 现在总结一下printf格式化字符串中的转换说明的有哪些写法。在这里只列举几种常用的格 式,其它格式请参考Man Page。每个转换说明以%号开头,以转换字符结尾,我们以前用过的 转换说明仅包含%号和转换字符,例如%d、%s,其实在这两个字符中间还可以插入一些可选项。 表 25.1. printf转换说明的可选项 选 项 描述 举例 # 八进制前面加0(转换字符为o),十六进制前面 加0x(转换字符为x)或0X(转换字符为X)。 printf("%#x", 0xff)打 印0xff,printf("%x", 0xff) 打印ff。 - 格式化后的内容居左,右边可以留空格。 见下面的例子 用一个整数指定格式化后的最小长度,如果格式化 printf("-%10s-", 宽 后的内容没有这么长,可以在左边留空格,如果前 度 面指定了-号就在右边留空格。宽度有一种特别的 形式,不指定整数值而是写成一个*号,表示取一 "hello")打印hello -,printf(" -%-*s", 10, "hello")打印- 个int型参数作为宽度。 hello -。 用于分隔上一条提到的最小长度和下一条要讲的精 . 度。 见下面的例子 printf("%.4s", 用一个整数表示精度,对于字符串来说指定了格式 "hello")打 精 度 化后保留的最大长度,对于浮点数来说指定了格式 化后小数点右边的位数,对于整数来说指定了格式 化后的最小位数。精度也可以不指定整数值而是写 印hell,printf("%6.4d-", 100)打印0100 -,printf(" -%*.*f - 成一个*号,表示取下一个int型参数作为精度。 ", 8, 4, 3.14)打印- 3.1400-。 对于整型参数可以指定字长,hh、h、l、ll分别表 字 长 示char 、short 、long 、long long的字长,至于是 printf("%hhd", 255)打 印-1。 有符号数还是无符号数则取决于后面的转换字符。 常用的转换字符有: 表 25.2. printf的转换字符 转 换 字 描述 符 举例 d i 取int型参数格式化成有符号十进制表示,如 printf("%.4d", 果格式化后的位数小于指定的精度,就在左边 补0。 印0100。 100)打 取unsigned int型参数格式化成无符号八进制 o u x X (o)、十进制(u)、十六进制(x或X)表 示,x表示十六进制数字用小写abcdef,X表 示十六进制数字用大写ABCDEF,如果格式 printf("%#X", 0xdeadbeef)打 印0XDEADBEEF ,printf("%hhu", -1)打印255。 化后的位数小于指定的精度,就在左边补0。 c 取int型参数转换成unsigned char型,格式化 成对应的ASCII码字符。 printf("%c", 256+'A')打印A。 s 取const char *型参数所指向的字符串格式化 printf("%.4s", 输出,遇到'\0'结束,或者达到指定的最大长 "hello")打 度(精度)结束。 印hell。 p 取void *型参数格式化成十六进制表示。相当 printf("%p", main)打 印main函数的首地 于%#x。 址0x80483c4。 f 取double型参数格式化成[-]ddd.ddd这样的格 式,小数点后的默认精度是6位。 printf("%f", 3.14)打 印3.140000,printf("%f", 0.00000314)打印0.000003。 取double型参数格式化成[-]d.ddde±dd (转换 e 字符是e)或[-]d.dddE±dd(转换字符是E) printf("%e", 3.14)打 E 这样的格式,小数点后的默认精度是6位,指 印3.140000e+00。 数至少是两位。 取double型参数格式化,精度是指有效数字而 非小数点后的数字,默认精度是6。如果指数 printf("%g", 3.00)打 g 小于-4或大于等于精度就按%e(转换字符 印3,printf("%g", G 是g)或%E(转换字符是G)格式化,否则 0.00001234567)打印1.23457e- 按%f格式化。小数部分的末尾0去掉,如果没 05。 有小数部分,小数点也去掉。 % 格式化成一个%。 printf("%%")打印一个%。 我们在第 6 节 “可变参数”讲过可变参数的原理,printf并不知道实际参数的类型,只能按转换 说明指出的参数类型从栈帧上取参数,所以如果实际参数和转换说明的类型不符,结果可能会 有些意外,上面也举过几个这样的例子。另外,如果str指向一个字符串,用printf(s)打印这 个字符串可能得到错误的结果,因为字符串中可能包含%号而被printf当成转换说明,printf并 不知道后面没有传其它参数,照样会从栈帧上取参数。所以比较保险的办法是printf("%s", s)。 下面看scanf函数的各种形式。 #include int scanf(const char *format, ...); int fscanf(FILE *stream, const char *format, ...); int sscanf(const char *str, const char *format, ...); #include int vscanf(const char *format, va_list ap); int vsscanf(const char *str, const char *format, va_list ap); int vfscanf(FILE *stream, const char *format, va_list ap); 返回值:返回成功匹配和赋值的参数个数,成功匹配的参数可能少于所提供的赋值参数, 返回0表示一个都不匹配,出错或者读到文件或字符串末尾时返回EOF并设置errno scanf从标准输入读字符,按格式化字符串format中的转换说明解释这些字符,转换后赋给后面 的参数,后面的参数都是传出参数,因此必须传地址而不能传值。fscanf从指定的文件stream中 读字符,而sscanf从指定的字符串str中读字符。后面三个以v开头的函数的可变参数不是 以...的形式传进来,而是以va_list类型传进来。 现在总结一下scanf的格式化字符串和转换说明,这里也只列举几种常用的格式,其它格式请参 考Man Page。scanf用输入的字符去匹配格式化字符串中的字符和转换说明,如果成功匹配一 个转换说明,就给一个参数赋值,如果读到文件或字符串末尾就停止,或者如果遇到和格式化 字符串不匹配的地方(比如转换说明是%d却读到字符A)就停止。如果遇到不匹配的地方而停 止,scanf的返回值可能小于赋值参数的个数,文件的读写位置指向输入中不匹配的地方,下次 调用库函数读文件时可以从这个位置继续。 格式化字符串中包括: 空格或Tab,在处理过程中被忽略。 普通字符(不包括%),和输入字符中的非空白字符相匹配。输入字符中的空白字符是指 空格、Tab、\r、\n、\v、\f。 转换说明,以%开头,以转换字符结尾,中间也有若干个可选项。 转换说明中的可选项有: *号,表示这个转换说明只是用来匹配一段输入字符,但匹配结果并不赋给后面的参数。 用一个整数指定的宽度N。表示这个转换说明最多匹配N个输入字符,或者匹配到输入字 符中的下一个空白字符结束。 对于整型参数可以指定字长,有hh、h、l、ll(也可以写成一个L),含义和printf相 同。但l和L还有一层含义,当转换字符是e、f、g时,表示赋值参数的类型是float *而 非double *,这一点跟printf不同,这时前面加上l或L表示double *或long double *型。 常用的转换字符有: 表 25.3. scanf的转换字符 转 换 字 描述 符 d 匹配十进制整数(开头可以有负号),赋值参数的类型是int *。 匹配整数(开头可以有负号),赋值参数的类型是int *,如果输入字符 i 以0x或0X开头则匹配十六进制整数,如果输入字符以0开头则匹配八进制整 数。 o u 匹配无符号八进制、十进制、十六进制整数,赋值参数的类型是unsigned int x *。 匹配一串字符,字符的个数由宽度指定,缺省宽度是1,赋值参数的类型 c 是char *,末尾不会添加'\0'。如果输入字符的开头有空白字符,这些空白字 符并不被忽略,而是保存到参数中,要想跳过开头的空白字符,可以在格式化 字符串中用一个空格去匹配。 匹配一串非空白字符,从输入字符中的第一个非空白字符开始匹配到下一个空 s 白字符之前,或者匹配到指定的宽度,赋值参数的类型是char *,末尾自动添 加'\0'。 e f g 匹配符点数(开头可以有负号),赋值参数的类型是float *,也可以指 定double *或long double *的字长。 % 转换说明%%匹配一个字符%,不做赋值。 下面几个例子出自[K&R]。第一个例子,读取用户输入的浮点数累加起来。 例 25.9. 用scanf实现简单的计算器 #include int main(void) /* rudimentary calculator */ { double sum, v; sum = 0; while (scanf("%lf", &v) == 1) printf("\t%.2f\n", sum += v); return 0; } 如果我们要读取25 Dec 1988这样的日期格式,可以这样写: char *str = "25 Dec 1988"; int day, year; char monthname[20]; sscanf(str, "%d %s %d", &day, monthname, &year); 如果str中的空白字符再多一些,比如" 25 Dec 1998",仍然可以正确读取。如果格式化字符串 中的空格和Tab再多一些,比如"%d %s %d ",也可以正确读取。scanf函数是很强大的,但是要 用对了不容易,需要多练习,通过练习体会空白字符的作用。 如果要读取12/25/1998这样的日期格式,就需要在格式化字符串中用/匹配输入字符中的/: int day, month, year; scanf("%d/%d/%d", &month, &day, &year); scanf把换行符也看作空白字符,仅仅当作字段之间的分隔符,如果输入中的字段个数不确定, 最好是先用fgets按行读取,然后再交给sscanf处理。如果我们的程序需要同时识别以上两种日 期格式,可以这样写: while (fgets(line, sizeof(line), stdin) > 0) { if (sscanf(line, "%d %s %d", &day, monthname, &year) == 3) printf("valid: %s\n", line); /* 25 Dec 1988 form */ else if (sscanf(line, "%d/%d/%d", &month, &day, &year) == 3) printf("valid: %s\n", line); /* mm/dd/yy form */ else printf("invalid: %s\n", line); /* invalid form */ } 2.10. C标准库的I/O缓冲区 用户程序调用C标准I/O库函数读写文件或设备,而这些库函数要通过系统调用把读写请求传给 内核(以后我们会看到与I/O相关的系统调用),最终由内核驱动磁盘或设备完成I/O操作。C标 准库为每个打开的文件分配一个I/O缓冲区以加速读写操作,通过文件的FILE结构体可以找到这 个缓冲区,用户调用读写函数大多数时候都在I/O缓冲区中读写,只有少数时候需要把读写请求 传给内核。以fgetc/fputc为例,当用户程序第一次调用fgetc读一个字节时,fgetc函数可能通 过系统调用进入内核读1K字节到I/O缓冲区中,然后返回I/O缓冲区中的第一个字节给用户,把 读写位置指向I/O缓冲区中的第二个字符,以后用户再调fgetc,就直接从I/O缓冲区中读取,而 不需要进内核了,当用户把这1K字节都读完之后,再次调用fgetc时,fgetc函数会再次进入内 核读1K字节到I/O缓冲区中。在这个场景中用户程序、C标准库和内核之间的关系就像在第 5 节 “Memory Hierarchy”中CPU、Cache和内存之间的关系一样,C标准库之所以会从内核预读一些 数据放在I/O缓冲区中,是希望用户程序随后要用到这些数据,C标准库的I/O缓冲区也在用户空 间,直接从用户空间读取数据比进内核读数据要快得多。另一方面,用户程序调用fputc通常只 是写到I/O缓冲区中,这样fputc函数可以很快地返回,如果I/O缓冲区写满了,fputc就通过系统 调用把I/O缓冲区中的数据传给内核,内核最终把数据写回磁盘。有时候用户程序希望把I/O缓冲 区中的数据立刻传给内核,让内核写回设备,这称为Flush操作,对应的库函数 是fflush,fclose函数在关闭文件之前也会做Flush操作。 下图以fgets/fputs示意了I/O缓冲区的作用,使用fgets/fputs函数时在用户程序中也需要分配 缓冲区(图中的buf1和buf2),注意区分用户程序的缓冲区和C标准库的I/O缓冲区。 图 25.1. C标准库的I/O缓冲区 C标准库的I/O缓冲区有三种类型:全缓冲、行缓冲和无缓冲。当用户程序调用库函数做写操作 时,不同类型的缓冲区具有不同的特性。 全缓冲 如果缓冲区写满了就写回内核。常规文件通常是全缓冲的。 行缓冲 如果用户程序写的数据中有换行符就把这一行写回内核,或者如果缓冲区写满了就写回内 核。标准输入和标准输出对应终端设备时通常是行缓冲的。 无缓冲 用户程序每次调库函数做写操作都要通过系统调用写回内核。标准错误输出通常是无缓冲 的,这样用户程序产生的错误信息可以尽快输出到设备。 下面通过一个简单的例子证明标准输出对应终端设备时是行缓冲的。 #include int main() { printf("hello world"); while(1); return 0; } 运行这个程序,会发现hello world并没有打印到屏幕上。用Ctrl-C终止它,去掉程序中 的while(1);语句再试一次: $ ./a.out hello world$ hello world被打印到屏幕上,后面直接跟Shell提示符,中间没有换行。 我们知道main函数被启动代码这样调用:exit(main(argc, argv));。main函数return时启动代 码会调用exit,exit函数首先关闭所有尚未关闭的FILE *指针(关闭之前要做Flush操作),然 后通过_exit系统调用进入内核退出当前进程[33]。 在上面的例子中,由于标准输出是行缓冲的,printf("hello world");打印的字符串中没有换行 符,所以只把字符串写到标准输出的I/O缓冲区中而没有写回内核(写到终端设备),如果 敲Ctrl-C,进程是异常终止的,并没有调用exit,也就没有机会Flush I/O缓冲区,因此字符串最 终没有打印到屏幕上。如果把打印语句改成printf("hello world\n");,有换行符,就会立刻写 到终端设备,或者如果把while(1);去掉也可以写到终端设备,因为程序退出时会调 用exitFlush所有I/O缓冲区。在本书的其它例子中,printf打印的字符串末尾都有换行符,以保 证字符串在printf调用结束时就写到终端设备。 我们再做个实验,在程序中直接调用_exit退出。 #include #include int main() { printf("hello world"); _exit(0); } 结果也不会把字符串打印到屏幕上,如果把_exit调用改成exit就可以打印到屏幕上。 除了写满缓冲区、写入换行符之外,行缓冲还有一种情况会自动做Flush操作。如果: 用户程序调用库函数从无缓冲的文件中读取 或者从行缓冲的文件中读取,并且这次读操作会引发系统调用从内核读取数据 那么在读取之前会自动Flush所有行缓冲。例如: #include #include int main() { char buf[20]; printf("Please input a line: "); fgets(buf, 20, stdin); return 0; } 虽然调用printf并不会把字符串写到设备,但紧接着调用fgets读一个行缓冲的文件(标准输 入),在读取之前会自动Flush所有行缓冲,包括标准输出。 如果用户程序不想完全依赖于自动的Flush操作,可以调fflush函数手动做Flush操作。 #include int fflush(FILE *stream); 返回值:成功返回0,出错返回EOF并设置errno 对前面的例子再稍加改动: #include int main() { printf("hello world"); fflush(stdout); while(1); } 虽然字符串中没有换行,但用户程序调用fflush强制写回内核,因此也能在屏幕上打印出字符 串。fflush函数用于确保数据写回了内核,以免进程异常终止时丢失数据。作为一个特例,调 用fflush(NULL)可以对所有打开文件的I/O缓冲区做Flush操作。 [32] 这些特性取决于终端的工作模式,终端可以配置成一次一行的模式,也可以配置成一次一个 字符的模式,默认是一次一行的模式(本书的实验都是在这种模式下做的),关于终端的配置 可参考[APUE2e]。 [33] 其实在调_exit进内核之前还要调用户程序中通过atexit(3)注册的退出处理函数,本书不做 详细介绍,读者可参考[APUE2e]。 上一页 1. 字符串操作函数 上一级 起始页 下一页 3. 数值字符串转换函数 上一页 3. 数值字符串转换函数 第 25 章 C标准库 3. 数值字符串转换函数 下一页 #include int atoi(const char *nptr); double atof(const char *nptr); 返回值:转换结果 atoi把一个字符串开头可以识别成十进制整数的部分转换成int型,相当于下面要讲 的strtol(nptr, (char **) NULL, 10);。例如atoi("123abc")的返回值是123,字符串开头可 以有若干空格,例如atoi(" -90.6-")的返回值是-90。如果字符串开头没有可识别的整数,例 如atoi("asdf"),则返回0,而atoi("0***")也返回0,根据返回值并不能区分这两种情况,所以 使用atoi函数不能检查出错的情况。下面要讲的strtol函数可以设置errno,因此可以检查出错 的情况,在严格的场合下应该用strtol,而atoi用起来更简便,所以也很常用。 atof把一个字符串开头可以识别成浮点数的部分转换成double型,相当于下面要讲 的strtod(nptr, (char **) NULL);。字符串开头可以识别的浮点数格式和C语言的浮点数常量 相同,例如atof("31.4 ")的返回值是31.4,atof("3.14e+1AB")的返回值也是31.4。atof也不能 检查出错的情况,而strtod可以。 #include long int strtol(const char *nptr, char **endptr, int base); double strtod(const char *nptr, char **endptr); 返回值:转换结果,出错时设置errno strtol是atoi的增强版,主要体现在这几方面: 不仅可以识别十进制整数,还可以识别其它进制的整数,取决于base参数,比 如strtol("0XDEADbeE~~", NULL, 16)返回0xdeadbee的值,strtol("0777~~", NULL, 8)返回0777的值。 endptr是一个传出参数,函数返回时指向后面未被识别的第一个字符。例如char *pos; strtol("123abc", &pos, 10);,strtol返回123,pos指向字符串中的字母a。如果字符串 开头没有可识别的整数,例如char *pos; strtol("ABCabc", &pos, 10);,则strtol返 回0,pos指向字符串开头,可以据此判断这种出错的情况,而这是atoi处理不了的。 如果字符串中的整数值超出long int的表示范围(上溢或下溢),则strtol返回它所能表 示的最大(或最小)整数,并设置errno为ERANGE,例如strtol("0XDEADbeef~~", NULL, 16)返回0x7fffffff并设置errno为ERANGE。 回想一下使用fopen的套路if ( (fp = fopen(...)) == NULL) { 读取errno },fopen在出错时 会返回NULL,因此我们知道需要读errno,但strtol在成功调用时也可能返回0x7fffffff,我们如 何知道需要读errno呢?最严谨的做法是首先把errno置0,再调用strtol,再查看errno是否变 成了错误码。Man Page上有一个很好的例子: 例 25.10. strtol的出错处理 #include #include #include #include int main(int argc, char *argv[]) { int base; char *endptr, *str; long val; if (argc < 2) { fprintf(stderr, "Usage: %s str [base]\n", argv[0]); exit(EXIT_FAILURE); } str = argv[1]; base = (argc > 2) ? atoi(argv[2]) : 10; errno = 0; /* To distinguish success/failure after call */ val = strtol(str, &endptr, base); /* Check for various possible errors */ if ((errno == ERANGE && (val == LONG_MAX || val == LONG_MIN)) || (errno != 0 && val == 0)) { perror("strtol"); exit(EXIT_FAILURE); } if (endptr == str) { fprintf(stderr, "No digits were found\n"); exit(EXIT_FAILURE); } /* If we got here, strtol() successfully parsed a number */ printf("strtol() returned %ld\n", val); if (*endptr != '\0') /* Not necessarily an error... */ printf("Further characters after number: %s\n", endptr); exit(EXIT_SUCCESS); } strtod是atof的增强版,增强的功能和strtol类似。 上一页 2. 标准I/O库函数 上一级 起始页 下一页 4. 分配内存的函数 上一页 4. 分配内存的函数 第 25 章 C标准库 下一页 4. 分配内存的函数 除了malloc之外,C标准库还提供了另外两个在堆空间分配内存的函数,它们分配的内存同样 由free释放。 #include void *calloc(size_t nmemb, size_t size); void *realloc(void *ptr, size_t size); 返回值:成功返回所分配内存空间的首地址,出错返回NULL calloc的参数很像fread/fwrite的参数,分配nmemb个元素的内存空间,每个元素占size字节, 并且calloc负责把这块内存空间用字节0填充,而malloc并不负责把分配的内存空间清零。 有时候用malloc或calloc分配的内存空间使用了一段时间之后需要改变它的大小,一种办法是调 用malloc分配一块新的内存空间,把原内存空间中的数据拷到新的内存空间,然后调用free释 放原内存空间。使用realloc函数简化了这些步骤,把原内存空间的指针ptr传给realloc,通过 参数size指定新的大小(字节数),realloc返回新内存空间的首地址,并释放原内存空间。新 内存空间中的数据尽量和原来保持一致,如果size比原来小,则前size个字节不变,后面的数 据被截断,如果size比原来大,则原来的数据全部保留,后面长出来的一块内存空间未初始化 (realloc不负责清零)。注意,参数ptr要么是NULL,要么必须是先前调 用malloc、calloc或realloc返回的指针,不能把任意指针传给realloc要求重新分配内存空间。 作为两个特例,如果调用realloc(NULL, size),则相当于调用malloc(size),如果调 用realloc(ptr, 0),ptr不是NULL,则相当于调用free(ptr)。 #include void *alloca(size_t size); 返回值:返回所分配内存空间的首地址,如果size太大导致栈空间耗尽,结果是未定义的 参数size是请求分配的字节数,alloca函数不是在堆上分配空间,而是在调用者函数的栈帧上 分配空间,类似于C99的变长数组,当调用者函数返回时自动释放栈帧,所以不需要free。这个 函数不属于C标准库,而是在POSIX标准中定义的。 上一页 3. 数值字符串转换函数 上一级 起始页 下一页 5. 本章综合练习 上一页 5. 本章综合练习 第 25 章 C标准库 下一页 5. 本章综合练习 1、编程读写一个文件test.txt,每隔1秒向文件中写入一行记录,类似于这样: 1, 2007-7-30 15:16:42 2, 2007-7-30 15:16:43 该程序应该无限循环,直到按Ctrl-C终止。下次再启动程序时在test.txt文件末尾追加记录,并 且序号能够接续上次的序号,比如: 1, 2007-7-30 15:16:42 2, 2007-7-30 15:16:43 3, 2007-7-30 15:19:02 4, 2007-7-30 15:19:03 5, 2007-7-30 15:19:04 这类似于很多系统服务维护的日志文件,例如在我的机器上系统服务进程acpid维护一个日志文 件/var/log/acpid,就像这样: $ cat /var/log/acpid [Sun Oct 26 08:44:46 2008] logfile reopened [Sun Oct 26 10:11:53 2008] exiting [Sun Oct 26 18:54:39 2008] starting up ... 每次系统启动时acpid进程就以追加方式打开这个文件,当有事件发生时就追加一条记录,包括 事件发生的时刻以及事件描述信息。 获取当前的系统时间需要调用time(2)函数,返回的结果是一个time_t类型,其实就是一个大整 数,其值表示从UTC(Coordinated Universal Time)时间1970年1月1日00:00:00(称 为UNIX系统的Epoch时间)到当前时刻的秒数。然后调用localtime(3)将time_t所表示 的UTC时间转换为本地时间(我们是+8区,比UTC多8个小时)并转成struct tm类型,该类型 的各数据成员分别表示年月日时分秒,具体用法请查阅Man Page。调用sleep(3)函数可以指定 程序睡眠多少秒。 2、INI文件是一种很常见的配置文件,很多Windows程序都采用这种格式的配置文件, 在Linux系统中Qt程序通常也采用这种格式的配置文件。比如: ;Configuration of http [http] domain=www.mysite.com port=8080 cgihome=/cgi-bin ;Configuration of db [database] server = mysql user = myname password = toopendatabase 一个配置文件由若干个Section组成,由[]括号括起来的是Section名。每个Section下面有若干 个key = value形式的键值对(Key-value Pair),等号两边可以有零个或多个空白字符(空格 或Tab),每个键值对占一行。以;号开头的行是注释。每个Section结束时有一个或多个空行, 空行是仅包含零个或多个空白字符(空格或Tab)的行。INI文件的最后一行后面可能有换行符 也可能没有。 现在XML兴起了,INI文件显得有点土。现在要求编程把INI文件转换成XML文件。上面的例子经 转换后应该变成这样: www.mysite.com 8080 /cgi-bin mysql myname toopendatabase 3、实现类似gcc的-M选项的功能,给定一个.c文件,列出它直接和间接包含的所有头文件,例 如有一个main.c文件: #include #include "stack.h" int main() { return 0; } 你的程序读取这个文件,打印出其中包含的所有头文件的绝对路径: $ ./a.out main.c /usr/include/errno.h /home/akaedu/stack.h: cannot find /usr/include/features.h /usr/include/bits/errno.h /usr/include/linux/errno.h ...... 如果有的头文件找不到,就像上面例子那样打印/home/akaedu/stack.h: cannot find。首先复 习一下第 2.2 节 “头文件”讲过的头文件查找顺序,本题目不必考虑-I选项指定的目录,只 在.c文件所在的目录以及系统目录/usr/include中查找。 上一页 4. 分配内存的函数 上一级 起始页 下一页 第 26 章 链表、二叉树和哈希表 上一页 第 26 章 链表、二叉树和哈希表 部分 II. C语言本质 第 26 章 链表、二叉树和哈希表 目录 1. 链表 1.1. 单链表 1.2. 双向链表 1.3. 静态链表 2. 二叉树 2.1. 二叉树的基本概念 2.2. 排序二叉树 3. 哈希表 上一页 5. 本章综合练习 上一级 起始页 下一页 下一页 1. 链表 上一页 1. 链表 第 26 章 链表、二叉树和哈希表 下一页 1. 链表 1.1. 单链表 图 23.6 “链表”所示的链表即单链表(Single Linked List),本节我们学习如何创建和操作这种 链表。每个链表有一个头指针,通过头指针可以找到第一个节点,每个节点都可以通过指针域 找到它的后继,最后一个节点的指针域为NULL,表示没有后继。数组在内存中的连续存放的, 而链表在内存中的布局是不规则的,我们知道访问某个数组元素b[n]时可以通过基地址+n×每个元 素的字节数得到它地址,或者说数组支持随机访问,而链表是不支持随机访问的,只能通过前一 个元素的指针域得知后一个元素的地址,因此只能从头指针开始顺序访问各节点。以下代码实 现了单链表的基本操作。 例 26.1. 单链表 /* linkedlist.h */ #ifndef LINKEDLIST_H #define LINKEDLIST_H typedef struct node *link; struct node { unsigned char item; link next; }; link make_node(unsigned char item); void free_node(link p); link search(unsigned char key); void insert(link p); link delete(link p); void traverse(void (*visit)(link)); void destroy(void); void push(link p); link pop(void); #endif /* linkedlist.c */ #include #include "linkedlist.h" static link head = NULL; link make_node(unsigned char item) { link p = malloc(sizeof *p); p->item = item; p->next = NULL; return p; } void free_node(link p) { free(p); } link search(unsigned char key) { link p; for (p = head; p; p = p->next) if (p->item == key) return p; return NULL; } void insert(link p) { p->next = head; head = p; } link delete(link p) { link prev; if (p == head) { head = p->next; return p; } for (prev = head; prev; prev = prev->next) if (prev->next == p) { prev->next = p->next; return p; } return NULL; } void traverse(void (*visit)(link)) { link p; for (p = head; p; p = p->next) visit(p); } void destroy(void) { link q, p = head; head = NULL; while (p) { q = p; p = p->next; free(q); } } void push(link p) { insert(p); } link pop(void) { if (head == NULL) return NULL; else return delete(head); } /* main.c */ #include #include "linkedlist.h" void print_item(link p) { printf("%d\n", p->item); } int main(void) { link p = make node(10); insert(p); p = make_node(5); insert(p); p = make_node(90); insert(p); p = search(5); delete(p); free_node(p); traverse(print_item); destroy(); p = make_node(100); push(p); p = make_node(200); push(p); p = make_node(250); push(p); while (p = pop()) { print_item(p); free_node(p); } return 0; } 在初始化时把头指针head初始化为NULL,表示空链表。然后main函数调用make_node创建几个节 点,分别调用insert插入到链表中。 void insert(link p) { p->next = head; head = p; } 图 26.1. 链表的插入操作 正如上图所示,insert函数虽然简单,其中也隐含了一种特殊情况(Special Case)的处理, 当head为NULL时,执行insert操作插入第一个节点之后,head指向第一个节点,而第一个节点 的next指针域成为NULL,这很合理,因为它也是最后一个节点。所以空链表虽然是一种特殊情 况,却不需要特殊的代码来处理,和一般情况用同样的代码处理即可,这样写出来的代码更简 洁,但是在读代码时要想到可能存在的特殊情况。当然,insert函数传进来的参数p也可能有特 殊情况,传进来的p可能是NULL,甚至是野指针,本章的函数代码都假定调用者的传进来的参数 是合法的,不对参数做特别检查。事实上,对指针参数做检查是不现实的,如果传进来的 是NULL还可以检查一下,如果传进来的是野指针,根本无法检查它指向的内存单元是不是合法 的,C标准库的函数通常也不做这种检查,例如strcpy(p, NULL)就会引起段错误。 接下来main函数调用search在链表中查找某个节点,如果找到就返回指向该节点的指针,找不 到就返回NULL。 link search(unsigned char key) { link p; for (p = head; p; p = p->next) if (p->item == key) return p; return NULL; } search函数其实也隐含了对于空链表这种特殊情况的处理,如果是空链表则循环体一次都不执 行,直接返回NULL。 然后main函数调用delete从链表中摘除用search找到的节点,最后调用free_node释放它的存储 空间。 link delete(link p) { link prev; if (p == head) { head = p->next; return p; } for (prev = head; prev; prev = prev->next) if (prev->next == p) { prev->next = p->next; return p; } return NULL; } 图 26.2. 链表的删除操作 从上图可以看出,要摘除一个节点需要首先找到它的前趋然后才能做摘除操作,而在单链表中 通过某个节点只能找到它的后继而不能找到它的前趋,所以删除操作要麻烦一些,需要从第一 个节点开始依次查找要摘除的节点的前趋。delete操作也要处理一种特殊情况,如果要摘除的 节点是链表的第一个节点,它是没有前趋的,这种情况要用特殊的代码处理,而不能和一般情 况用同样的代码处理。这样很不爽,能不能把这种特殊情况转化为一般情况呢?可以 把delete函数改成这样: link delete(link p) { link *pnext; for (pnext = &head; *pnext; pnext = &(*pnext)->next) if (*pnext == p) { *pnext = p->next; return p; } return NULL; } 图 26.3. 消除特殊情况的链表删除操作 定义一个指向指针的指针pnext,在for循环中pnext遍历的是指向链表中各节点的指针域,这样 就把head指针和各节点的next指针统一起来了,可以在一个循环中处理。 然后main函数调用traverse函数遍历整个链表,调用destroy函数销毁整个链表。请读者自己阅 读这两个函数的代码。 如果限定每次只在链表的头部插入和删除元素,就形成一个LIFO的访问序列,所以在链表头部 插入和删除元素的操作实现了堆栈的push和pop操作,main函数的最后几步把链表当成堆栈来操 作,从打印的结果可以看到出栈的顺序和入栈是相反的。想一想,用链表实现的堆栈和第 2 节 “堆栈”中用数组实现的堆栈相比有什么优点和缺点? 习题 1、修改insert函数实现插入排序的功能,链表中的数据按从小到大排列,每次插入数据都要在 链表中找到合适的位置再插入。在第 6 节 “折半查找”中我们看到,如果数组中的元素是有序排 列的,可以用折半查找算法更快地找到某个元素,想一想如果链表中的节点是有序排列的,是 否适用折半查找算法?为什么? 2、基于单链表实现队列的enqueue和dequeue操作。在链表的末尾再维护一个指针tail, 在tail处enqueue,在head处dequeue。想一想能不能反过来,在head处enqueue而 在tail处dequeue? 1.2. 双向链表 链表的delete操作需要首先找到要摘除的节点的前趋,而在单链表中找某个节点的前趋需要从 表头开始依次查找,对于n个节点的链表,删除操作的时间复杂度为O(n)。可以想像得到,如果 每个节点再维护一个指向前趋的指针,删除操作就像插入操作一样容易了,时间复杂度 O(1) Doubly Linked List 为 ,这称为双向链表( 改动两个地方。 )。要实现双向链表只需在上一节代码的基础上 在linkedlist.h中修改链表节点的结构体定义: struct node { unsigned char item; link prev, next; }; 在linkedlist.c中修改insert和delete函数: void insert(link p) { p->next = head; if (head) head->prev = p; head = p; p->prev = NULL; } link delete(link p) { if (p->prev) p->prev->next = p->next; else head = p->next; if (p->next) p->next->prev = p->prev; return p; } 图 26.4. 双向链表 由于引入了prev指针,insert和delete函数中都有一些特殊情况需要用特殊的代码处理,不能和 一般情况用同样的代码处理,这非常不爽,如果在表头和表尾各添加一个Sentinel节点(这两个 节点只用于界定表头和表尾,不保存数据),就可以把这些特殊情况都转化为一般情况了。 /* doublylinkedlist.h */ #ifndef DOUBLYLINKEDLIST_H #define DOUBLYLINKEDLIST_H typedef struct node *link; struct node { unsigned char item; link prev, next; }; link make_node(unsigned char item); void free_node(link p); link search(unsigned char key); void insert(link p); link delete(link p); void traverse(void (*visit)(link)); void destroy(void); void enqueue(link p); link dequeue(void); #endif /* doublylinkedlist.c */ #include #include "doublylinkedlist.h" struct node tailsentinel; struct node headsentinel = {0, NULL, &tailsentinel}; struct node tailsentinel = {0, &headsentinel, NULL}; static link head = &headsentinel; static link tail = &tailsentinel; link make_node(unsigned char item) { link p = malloc(sizeof *p); p->item = item; p->prev = p->next = NULL; return p; } void free_node(link p) { free(p); } link search(unsigned char key) { link p; for (p = head->next; p != tail; p = p->next) if (p->item == key) return p; return NULL; } void insert(link p) { p->next = head->next; head->next->prev = p; head->next = p; p->prev = head; } link delete(link p) { p->prev->next = p->next; p->next->prev = p->prev; return p; } void traverse(void (*visit)(link)) { link p; for (p = head->next; p != tail; p = p->next) visit(p); } void destroy(void) { link q, p = head->next; head->next = tail; tail->prev = head; while (p != tail) { q = p; p = p->next; free(q); } } void enqueue(link p) { insert(p); } link dequeue(void) { if (tail->prev == head) return NULL; else return delete(tail->prev); } /* main.c */ #include #include "doublylinkedlist.h" void print_item(link p) { printf("%d\n", p->item); } int main(void) { link p = make_node(10); insert(p); p = make_node(5); insert(p); p = make_node(90); insert(p); p = search(5); delete(p); free_node(p); traverse(print_item); destroy(); p = make_node(100); enqueue(p); p = make_node(200); enqueue(p); p = make_node(250); enqueue(p); while (p = dequeue()) { print_item(p); free_node(p); } return 0; } 图 26.5. 带Sentinel的双向链表 这个例子也实现了队列的enqueue和dequeue操作,现在每个节点有了prev指针,可以反过来 在head处enqueue而在tail处dequeue了。 现在结合第 5 节 “环形队列”想一想,其实用链表实现环形队列是最自然的,以前基于数组实现 环形队列,我们还需要“假想”它是首尾相接的,而如果基于链表实现环形队列,我们本来就可以 用指针串成首尾相接的。把上面的程序改成环形链表(Circular Linked List)也非常简单,只需 要把doublylinkedlist.c中的 struct node tailsentinel; struct node headsentinel = {0, NULL, &tailsentinel}; struct node tailsentinel = {0, &headsentinel, NULL}; static link head = &headsentinel; static link tail = &tailsentinel; 改成: struct node sentinel = {0, &sentinel, &sentinel}; static link head = &sentinel; 改这两行,再把doublylinkedlist.c中所有的tail替换成head即可,相当于把tail和head合二为 一了。 图 26.6. 环形链表 1.3. 静态链表 回想一下我们在例 12.4 “用广度优先搜索解迷宫问题”中使用的数据结构,为了便于阅读,我把 图重新画在下面。 图 26.7. 广度优先搜索的队列数据结构 这是一个静态分配的数组,每个数组元素都有row、col和predecessor三个成员,predecessor成 员保存一个数组下标,指向数组中的另一个元素,这其实也是链表的一种形式,称为静态链 表,例如上图中的第6、4、2、1、0个元素串成一个链表。 上一页 第 26 章 链表、二叉树和哈希表 上一级 起始页 下一页 2. 二叉树 上一页 2. 二叉树 第 26 章 链表、二叉树和哈希表 下一页 2. 二叉树 2.1. 二叉树的基本概念 链表的每个节点可以有一个后继,而二叉树(Binary Tree)的每个节点可以有两个后继。比如 这样定义二叉树的节点: typedef struct node *link; struct node { unsigned char item; link l, r; }; 这样的节点可以组织成下图所示的各种形态。 图 26.8. 二叉树的定义和举例 二叉树可以这样递归地定义: 1. 就像链表有头指针一样,每个二叉树都有一个根指针(上图中的root指针)指向它。根指 针可以是NULL,表示空二叉树,或者 2. 根指针可以指向一个节点,这个节点除了有数据成员之外还有两个指针域,这两个指针域 又分别是另外两个二叉树(左子树和右子树)的根指针。 上图举例示意了几种情况。 单节点的二叉树:左子树和右子树都是空二叉树。 只有左子树的二叉树:右子树是空二叉树。 只有右子树的二叉树:左子树是空二叉树。 一般的二叉树:左右子树都不为空。注意右侧由圈和线段组成的简化图示,以后我们都采 用这种简化图示法,在圈中标上该节点数据成员的值。 链表的遍历方法是显而易见的:从前到后遍历即可。二叉树是一种树状结构,如何做到把所有 节点都走一遍不重不漏呢?有以下几种方法: 图 26.9. 二叉树的遍历 前序(Pre-order Traversal)、中序(In-order Traversal)、后序遍历(Post-order Traversal)和深度优先搜索的顺序类似,层序遍历(Level-order Traversal)和广度优先搜索的 顺序类似。 前序和中序遍历的结果合在一起可以唯一确定二叉树的形态,也就是说根据遍历结果可以构造 出二叉树。过程如下图所示: 图 26.10. 根据前序和中序遍历结果构造二叉树 想一想,根据中序和后序遍历结果能否构造二叉树?根据前序和后序遍历结果能否构造二叉 树? /* binarytree.h */ #ifndef BINARYTREE_H #define BINARYTREE_H typedef struct node *link; struct node { unsigned char item; link l, r; }; link tree_init(unsigned char VLR[], unsigned char LVR[], int n); void pre_order(link t, void (*visit)(link)); void in_order(link t, void (*visit)(link)); void post_order(link t, void (*visit)(link)); int count(link t); int depth(link t); void destroy(link t); #endif /* binarytree.c */ #include #include "binarytree.h" static link make_node(unsigned char item) { link p = malloc(sizeof *p); p->item = item; p->l = p->r = NULL; return p; } static void free_node(link p) { free(p); } link tree_init(unsigned char VLR[], unsigned char LVR[], int n) { link t; int k; if (n <= 0) return NULL; for (k = 0; VLR[0] != LVR[k]; k++); t = make_node(VLR[0]); t->l = tree_init(VLR+1, LVR, k); t->r = tree_init(VLR+1+k, LVR+1+k, n-k-1); return t; } void pre_order(link t, void (*visit)(link)) { if (!t) return; visit(t); pre_order(t->l, visit); pre_order(t->r, visit); } void in_order(link t, void (*visit)(link)) { if (!t) return; in_order(t->l, visit); visit(t); in_order(t->r, visit); } void post_order(link t, void (*visit)(link)) { if (!t) return; post_order(t->l, visit); post_order(t->r, visit); visit(t); } int count(link t) { if (!t) return 0; return 1 + count(t->l) + count(t->r); } int depth(link t) { int dl, dr; if (!t) return 0; dl = depth(t->l); dr = depth(t->r); return 1 + (dl > dr ? dl : dr); } void destroy(link t) { post_order(t, free_node); } /* main.c */ #include #include "binarytree.h" void print_item(link p) { printf("%d", p->item); } int main() { unsigned char pre_seq[] = { 4, 2, 1, 3, 6, 5, 7 }; unsigned char in seq[] = { 1, 2, 3, 4, 5, 6, 7 }; link root = tree_init(pre_seq, in_seq, 7); pre_order(root, print_item); putchar('\n'); in_order(root, print_item); putchar('\n'); post_order(root, print_item); putchar('\n'); printf("count=%d depth=%d\n", count(root), depth(root)); destroy(root); return 0; } 2.2. 排序二叉树 排序二叉树(BST,Binary Search Tree)具有这样的性质:对于二叉树中的任意节点,如果它 有左孩子或右孩子节点,则该节点的数据成员大于左孩子的数据成员,且小于右孩子的数据成 员。排序二叉树的中序遍历结果是从小到大排列的,其实上一节的图 26.9 “二叉树的遍历”就是 排序二叉树。 /* bst.h */ #ifndef BST_H #define BST_H typedef struct node *link; struct node { unsigned char item; link l, r; }; link search(link t, int key); link insert(link t, int key); link delete(link t, int key); void print_tree(link t); #endif /* bst.c */ #include #include #include "bst.h" static link make_node(unsigned char item) { link p = malloc(sizeof *p); p->item = item; p->l = p->r = NULL; return p; } static void free_node(link p) { free(p); } link search(link t, int key) { if (!t) return NULL; if (t->item > key) return search(t->l, key); if (t->item < key) return search(t->r, key); /* if (t->item == key) */ return t; } link insert(link t, int key) { if (!t) return make_node(key); if (t->item > key) /* insert to left subtree */ t->l = insert(t->l, key); else /* if (t->item <= key), insert to right subtree */ t->r = insert(t->r, key); return t; } link delete(link t, int key) { link p; if (!t) return NULL; if (t->item > key) /* delete from left subtree */ t->l = delete(t->l, key); else if (t->item < key) /* delete from right subtree */ t->r = delete(t->r, key); else { /* if (t->item == key) */ if (t->l == NULL && t->r == NULL) { /* if t is leaf node */ free_node(t); t = NULL; } else if (t->l) { /* if t has left subtree */ /* replace t with the rightmost node in left subtree */ for (p=t->l; p->r; p=p->r); t->item = p->item; t->l = delete(t->l, t->item); } else if (t->r) { /* if t has right subtree */ /* replace t with the leftmost node in right subtree */ for (p=t->r; p->l; p=p->l); t->item = p->item; t->r = delete(t->r, t->item); } } return t; } void print_tree(link t) { if (t) { printf("("); printf("%d", t->item); print_tree(t->l); print_tree(t->r); printf(")"); } else printf("()"); } /* main.c */ #include #include #include #include "bst.h" #define RANGE 100 #define N 6 void print_item(link p) { printf("%d", p->item); } int main() { int i, key; link root = NULL; srand(time(NULL)); for (i = 0; i < N; i++) root = insert(root, rand() % RANGE); printf("\t\\tree"); print_tree(root); printf("\n\n"); while (root) { key = rand() % RANGE; if (search(root, key)) { printf("delete %d in tree\n", key); root = delete(root, key); printf("\t\\tree"); print_tree(root); printf("\n\n"); } } } $ ./a.out \tree(83(77(15()(35()()))())(86()(93()()))) delete 86 in tree \tree(83(77(15()(35()()))())(93()())) delete 35 in tree \tree(83(77(15()())())(93()())) delete 93 in tree \tree(83(77(15()())())()) delete 15 in tree \tree(83(77()())()) delete 83 in tree \tree(77()()) delete 77 in tree \tree() 程序的运行结果可以用Greg Lee编写的The Tree Preprocessor(http://www.essex.ac.uk/linguistics/clmt/latex4ling/trees/tree/)转换成树形: $ ./a.out | ./tree/tree 83 ___|___ | | 77 86 _|__ _|__ |||| 15 93 _|__ _|__ || || 35 _|__ || delete 86 in tree 83 ___|___ | | 77 93 _|__ _|__ |||| 15 _|__ || 35 _|__ || delete 35 in tree 83 ___|___ | | 77 93 _|__ _|__ |||| 15 _|__ || delete 93 in tree 83 _|__ || 77 _|__ || 15 _|__ || delete 15 in tree 83 _|__ || 77 _|__ || delete 83 in tree 77 _|__ || delete 77 in tree 上一页 1. 链表 上一级 起始页 下一页 3. 哈希表 上一页 3. 哈希表 第 26 章 链表、二叉树和哈希表 3. 哈希表 下图示意了哈希表(Hash Table)这种数据结构。 图 26.11. 哈希表 下一页 如上图所示,首先分配一个指针数组,数组的每个元素是一个链表的头指针,每个链表称为一 个槽(Slot)。哪个数据应该放入哪个槽中由哈希函数决定,在这个例子中我们简单地选取哈希 函数h(x) = x % 11,这样任意数据x都可以映射成0~10之间的一个数,就是槽的编号,将数据放 入某个槽的操作就是链表的插入操作。 如果每个槽里至多只有一个数据,可以想像这种情况下insert、delete和search操作的时间复杂 度都是O(1),但有时会有多个数据被哈希函数映射到同一个槽中,这称为碰撞(Collision),设 计一个好的哈希函数可以把数据比较均匀地分布到各个槽中,尽量避免碰撞。如果能把n个数据 比较均匀地分布到m个槽中,每个糟里约有n/m个数据,则insert、delete和search操作的时间 复杂度都是O(n/m),如果n和m的比是常数,则时间复杂度仍然是O(1)。一般来说,要处理的数 据越多,构造哈希表时分配的槽也应该越多,所以n和m成正比这个假设是成立的。 请读者自己编写程序构造这样一个哈希表,并实现insert、delete、search操作。 如果用我们学过的各种数据结构来表示n个数据的集合,下表是insert和search操作的平均时间 复杂度比较。 表 26.1. 各种数据结构insert和search操作的平均时间复杂度比较 数据结构 insert search 数组 O(n) O(n),有序数组是O(lgn)(折半查找) 链表 O(1) O(n) 排序二叉树 O(lgn) O(lgn) 哈希表(n与槽数m成正比) O(1) O(1) 我们没有比较delete操作,因为在本章的示例代码中,链表的delete操作是把一个事先找到的节 点传给它做删除操作,而排序二叉树的delete操作是一边找要删除的节点一边做删除操作,没 有可比性。请读者自己比较各种数据结构删除操作的时间复杂度。 上一页 2. 二叉树 上一级 起始页 下一页 第 27 章 本阶段总结 上一页 第 27 章 本阶段总结 编写中 上一页 3. 哈希表 第 27 章 本阶段总结 部分 II. C语言本质 上一级 起始页 下一页 下一页 部分 III. Linux系统编程 上一页 部分 III. Linux系统编程 部分 III. Linux系统编程 目录 28. 文件与I/O 1. 汇编程序的Hello world 2. C标准I/O库函数与Unbuffered I/O函数 3. open/close 4. read/write 5. lseek 6. fcntl 7. ioctl 8. mmap 29. 文件系统 1. 引言 2. ext2文件系统 2.1. 总体存储布局 2.2. 实例剖析 2.3. 数据块寻址 2.4. 文件和目录操作的系统函数 3. VFS 3.1. 内核数据结构 3.2. dup和dup2函数 30. 进程 1. 引言 2. 环境变量 3. 进程控制 3.1. fork函数 3.2. exec函数 3.3. wait和waitpid函数 4. 进程间通信 4.1. 管道 4.2. 其它IPC机制 5. 练习:实现简单的Shell 下一页 31. Shell脚本 1. Shell的历史 2. Shell如何执行命令 2.1. 执行交互式命令 2.2. 执行脚本 3. Shell的基本语法 3.1. 变量 3.2. 文件名代换(Globbing):* ? [] 3.3. 命令代换:`或 $() 3.4. 算术代换:$(()) 3.5. 转义字符\ 3.6. 单引号 3.7. 双引号 4. bash启动脚本 4.1. 作为交互登录Shell启动,或者使用--login参数启动 4.2. 以交互非登录Shell启动 4.3. 非交互启动 4.4. 以sh命令启动 5. Shell脚本语法 5.1. 条件测试:test [ 5.2. if/then/elif/else/fi 5.3. case/esac 5.4. for/do/done 5.5. while/do/done 5.6. 位置参数和特殊变量 5.7. 函数 6. Shell脚本的调试方法 32. 正则表达式 1. 引言 2. 基本语法 3. sed 4. awk 5. 练习:在C语言中使用正则表达式 33. 信号 1. 信号的基本概念 2. 产生信号 2.1. 通过终端按键产生信号 2.2. 调用系统函数向进程发信号 2.3. 由软件条件产生信号 3. 阻塞信号 3.1. 信号在内核中的表示 3.2. 信号集操作函数 3.3. sigprocmask 3.4. sigpending 4. 捕捉信号 4.1. 内核如何实现信号的捕捉 4.2. sigaction 4.3. pause 4.4. 可重入函数 4.5. sig_atomic_t类型与volatile限定符 4.6. 竞态条件与sigsuspend函数 4.7. 关于SIGCHLD信号 34. 终端、作业控制与守护进程 1. 终端 1.1. 终端的基本概念 1.2. 终端登录过程 1.3. 网络登录过程 2. 作业控制 2.1. Session与进程组 2.2. 与作业控制有关的信号 3. 守护进程 35. 线程 1. 线程的概念 2. 线程控制 2.1. 创建线程 2.2. 终止线程 3. 线程间同步 3.1. mutex 3.2. Condition Variable 3.3. Semaphore 3.4. 其它线程间同步机制 4. 编程练习 36. TCP/IP协议基础 1. TCP/IP协议栈与数据包封装 2. 以太网(RFC 894)帧格式 3. ARP数据报格式 4. IP数据报格式 5. IP地址与路由 6. UDP段格式 7. TCP协议 7.1. 段格式 7.2. 通讯时序 7.3. 流量控制 37. socket编程 1. 预备知识 1.1. 网络字节序 1.2. socket地址的数据类型及相关函数 2. 基于TCP协议的网络程序 2.1. 最简单的TCP网络程序 2.2. 错误处理与读写控制 2.3. 把client改为交互式输入 2.4. 使用fork并发处理多个client的请求 2.5. setsockopt 2.6. 使用select 3. 基于UDP协议的网络程序 4. UNIX Domain Socket IPC 5. 练习:实现简单的Web服务器 5.1. 基本HTTP协议 5.2. 执行CGI程序 上一页 第 27 章 本阶段总结 起始页 下一页 第 28 章 文件与I/O 上一页 第 28 章 文件与I/O 部分 III. Linux系统编程 下一页 第 28 章 文件与I/O 目录 1. 汇编程序的Hello world 2. C标准I/O库函数与Unbuffered I/O函数 3. open/close 4. read/write 5. lseek 6. fcntl 7. ioctl 8. mmap 从本章开始学习各种Linux系统函数,这些函数的用法必须结合Linux内核的工作原理来理解,因 为系统函数正是内核提供给应用程序的接口,而要理解内核的工作原理,必须熟练掌握C语言, 因为内核也是用C语言写的,我们在描述内核工作原理时必然要用“指针”、“结构体”、“链表”这 些名词来组织语言,就像只有掌握了英语才能看懂英文书一样,只有学好了C语言才能看懂我描 述的内核工作原理。读者看到这里应该已经熟练掌握了C语言了,所以应该有一个很好的起点 了。我们在介绍C标准库时并不试图把所有库函数讲一遍,而是通过介绍一部分常用函数让读者 把握库函数的基本用法,在掌握了方法之后,书上没讲的库函数读者应该自己查Man Page学会 使用。同样,本书的第三部分也并不试图把所有的系统函数讲一遍,而是通过介绍一部分系统 函数让读者理解操作系统各部分的工作原理,在有了这个基础之后就应该能够看懂Man Page学 习其它系统函数的用法。 读者可以结合[APUE2e]学习本书的第三部分,该书在讲解系统函数方面更加全面,但对于内核 工作原理涉及得不够深入,而且假定读者具有一定的操作系统基础知识,所以并不适合初学 者。该书还有一点非常不适合初学者,作者不辞劳苦,在N多种UNIX系统上做了实验,分析了 它们的内核代码,把每个系统函数在各种UNIX系统上的不兼容特性总结得非常详细,很多开发 者需要编写可移植的应用程序,一定爱死他了,但初学者看了大段大段的这种描述(某某函数 在4.2BSD上怎么样,到4.4BSD又改成怎么样了,在SVR4上怎么样,到Solaris又改成怎么样 了,现在POSIX标准是怎么统一的,还有哪些系统没有完全遵守POSIX标准)只会一头雾水, 不看倒还明白,越看越不明白了。也正因为该书要兼顾各种UNIX系统,所以没法深入讲解内核 的工作原理,因为每种UNIX系统的内核都不一样。而本书的侧重点则不同,只讲Linux平台的特 性,只讲Linux内核的工作原理,涉及体系结构时只讲x86平台,对于初学者来说,绑定到一个 明确的平台上学习就不会觉得太抽象了。当然本书的代码也会尽量兼顾可移植性,避免依赖 于Linux平台特有的一些特性。 上一页 部分 III. Linux系统编程 上一级 起始页 下一页 1. 汇编程序的Hello world 上一页 1. 汇编程序的Hello world 第 28 章 文件与I/O 下一页 1. 汇编程序的Hello world 之前我们学习了如何用C标准I/O库读写文件,本章详细讲解这些I/O操作是怎么实现的。所 有I/O操作最终都是在内核中做的,以前我们用的C标准I/O库函数最终也是通过系统调用把I/O操 作从用户空间传给内核,然后让内核去做I/O操作,本章和下一章会介绍内核中I/O子系统的工作 原理。首先看一个打印Hello world的汇编程序,了解I/O操作是怎样通过系统调用传给内核的。 例 28.1. 汇编程序的Hello world .data declaration # section msg: .ascii "Hello, world!\n" string len = . - msg dear string # our dear # length of our .text declaration # section # we must export the entry point to the ELF linker or .global _start # loader. They conventionally recognize _start as their # entry point. Use ld -e foo to override the default. _start: # write our string to stdout movl $len,%edx message length movl $msg,%ecx pointer to message to write movl $1,%ebx handle (stdout) movl $4,%eax (sys_write) int $0x80 # third argument: # second argument: # first argument: file # system call number # call kernel # and exit movl code movl (sys_exit) int $0,%ebx $1,%eax $0x80 # first argument: exit # system call number # call kernel 像以前一样,汇编、链接、运行: $ as -o hello.o hello.s $ ld -o hello hello.o $ ./hello Hello, world! 这段汇编相当于以下C代码: #include char msg[14] = "Hello, world!\n"; #define len 14 int main(void) { write(1, msg, len); _exit(0); } .data段有一个标号msg,代表字符串"Hello, world!\n"的首地址,相当于C程序的一个全局变 量。注意在C语言中字符串的末尾隐含有一个'\0',而汇编指示.ascii定义的字符串末尾没有隐 含的'\0'。汇编程序中的len代表一个常量,它的值由当前地址减去符号msg所代表的地址得 到,换句话说就是字符串"Hello, world!\n"的长度。现在解释一下这行代码中的.,汇编器总是 从前到后把汇编代码转换成目标文件,在这个过程中维护一个地址计数器,当处理到每个段的 开头时把地址计数器置成0,然后每处理一条汇编指示或指令就把地址计数器增加相应的字节 数,在汇编程序中用.可以取出当前地址计数器的值,是一个常量。 在_start中调了两个系统调用,第一个是write系统调用,第二个是以前讲过的_exit系统调 用。在调write系统调用时,eax寄存器保存着write的系统调用号4,ebx、ecx、edx寄存器分别 保存着write系统调用需要的三个参数。ebx保存着文件描述符,进程中每个打开的文件都用一 个编号来标识,称为文件描述符,文件描述符1表示标准输出,对应于C标准I/O库 的stdout。ecx保存着输出缓冲区的首地址。edx保存着输出的字节数。write系统调用把 从msg开始的len个字节写到标准输出。 C代码中的write函数是系统调用的包装函数,其内部实现就是把传进来的三个参数分别赋 给ebx、ecx、edx寄存器,然后执行movl $4,%eax和int $0x80两条指令。这个函数不可能完全 用C代码来写,因为任何C代码都不会编译生成int指令,所以这个函数有可能是完全用汇编写 的,也可能是用C内联汇编写的,甚至可能是一个宏定义(省了参数入栈出栈的步 骤)。_exit函数也是如此,我们讲过这些系统调用的包装函数位于Man Page的 第2个Section。 上一页 第 28 章 文件与I/O 上一级 起始页 下一页 2. C标准I/O库函数与Unbuffered I/O函数 上一页 2. C标准I/O库函数与Unbuffered I/O函数 第 28 章 文件与I/O 下一页 2. C标准I/O库函数与Unbuffered I/O函数 现在看看C标准I/O库函数是如何用系统调用实现的。 fopen(3) 调用open(2)打开指定的文件,返回一个文件描述符(就是一个int类型的编号),分配一 个FILE结构体,其中包含该文件的描述符、I/O缓冲区和当前读写位置等信息,返回这 个FILE结构体的地址。 fgetc(3) 通过传入的FILE *参数找到该文件的描述符、I/O缓冲区和当前读写位置,判断能否 从I/O缓冲区中读到下一个字符,如果能读到就直接返回该字符,否则调用read(2),把文 件描述符传进去,让内核读取该文件的数据到I/O缓冲区,然后返回下一个字符。注意,对 于C标准I/O库来说,打开的文件由FILE *指针标识,而对于内核来说,打开的文件由文件 描述符标识,文件描述符从open系统调用获得,在使用read、write、close系统调用时都 需要传文件描述符。 fputc(3) 判断该文件的I/O缓冲区是否有空间再存放一个字符,如果有空间则直接保存在I/O缓冲区 中并返回,如果I/O缓冲区已满就调用write(2),让内核把I/O缓冲区的内容写回文件。 fclose(3) 如果I/O缓冲区中还有数据没写回文件,就调用write(2)写回文件,然后调用close(2)关闭 文件,释放FILE结构体和I/O缓冲区。 以写文件为例,C标准I/O 库函数(printf(3) 、putchar(3) 、fputs(3) )与系统调用write(2) 的 关系如下图所示。 图 28.1. 库函数与系统调用的层次关系 open、read、write、close等系统函数称为无缓冲I/O(Unbuffered I/O)函数,因为它们位 于C标准库的I/O缓冲区的底层[34]。用户程序在读写文件时既可以调用C标准I/O库函数,也可以 直接调用底层的Unbuffered I/O函数,那么用哪一组函数好呢? 用Unbuffered I/O函数每次读写都要进内核,调一个系统调用比调一个用户空间的函数要 慢很多,所以在用户空间开辟I/O缓冲区还是必要的,用C标准I/O库函数就比较方便,省 去了自己管理I/O缓冲区的麻烦。 用C标准I/O库函数要时刻注意I/O缓冲区和实际文件有可能不一致,在必要时需调 用fflush(3)。 我们知道UNIX的传统是Everything is a file,I/O函数不仅用于读写常规文件,也用于读写 设备,比如终端或网络设备。在读写设备时通常是不希望有缓冲的,例如向代表网络设备 的文件写数据就是希望数据通过网络设备发送出去,而不希望只写到缓冲区里就算完事儿 了,当网络设备接收到数据时应用程序也希望第一时间被通知到,所以网络编程通常直接 调用Unbuffered I/O函数。 C标准库函数是C标准的一部分,而Unbuffered I/O函数是UNIX标准的一部分,在所有支持C语 言的平台上应该都可以用C标准库函数(除了有些平台的C编译器没有完全符合C标准之外), 而只有在UNIX平台上才能使用Unbuffered I/O函数,所以C标准I/O库函数在头文件stdio.h中声 明,而read、write等函数在头文件unistd.h中声明。在支持C语言的非UNIX操作系统上,标 准I/O库的底层可能由另外一组系统函数支持,例如Windows系统的底层是Win32 API,其中读 写文件的系统函数是ReadFile、WriteFile。 关于UNIX标准 POSIX(Portable Operating System Interface)是由IEEE制定的 标准,致力于统一各种UNIX系统的接口,促进各种UNIX系统向互 相兼容的发向发展。IEEE 1003.1(也称为POSIX.1)定义 了UNIX系统的函数接口,既包括C标准库函数,也包括系统调用 和其它UNIX库函数。POSIX.1只定义接口而不定义实现,所以并 不区分一个函数是库函数还是系统调用,至于哪些函数在用户空 间实现,哪些函数在内核中实现,由操作系统的开发者决定,各 种UNIX系统都不太一样。IEEE 1003.2定义了Shell的语法和各种 基本命令的选项等。本书的第三部分不仅讲解基本的系统函数接 口,也顺带讲解Shell、基本命令、帐号和权限以及系统管理的基 础知识,这些内容合在一起定义了UNIX系统的基本特性。 在UNIX的发展历史上主要分成BSD和SYSV两个派系,各自实现 了很多不同的接口,比如BSD的网络编程接口是socket, 而SYSV的网络编程接口是基于STREAMS的TLI。POSIX在统一 接口的过程中,有些接口借鉴BSD的,有些接口借鉴SYSV的,还 有些接口既不是来自BSD也不是来自SYSV,而是凭空发明出来的 (例如本书要讲的pthread库就属于这种情况),通过Man Page的COMFORMING TO部分可以看出来一个函数接口属于哪 种情况。Linux的源代码是完全从头编写的,并不继 承BSD或SYSV的源代码,没有历史的包袱,所以能比较好地遵 照POSIX标准实现,既有BSD的特性也有SYSV的特性,此外还有 一些Linux特有的特性,比如epoll(7),依赖于这些接口的应用程 序是不可移植的,但在Linux系统上运行效率很高。 POSIX定义的接口有些规定是必须实现的,而另外一些是可以选 择实现的。有些非UNIX系统也实现了POSIX中必须实现的部分, 那么也可以声称自己是POSIX兼容的,然而要想声称自己 是UNIX,还必须要实现一部分在POSIX中规定为可选实现的接 口,这由另外一个标准SUS(Single UNIX Specification)规 定。SUS是POSIX的超集,一部分在POSIX中规定为可选实现的 接口在SUS中规定为必须实现,完整实现了这些接口的系统称 为XSI(X/Open System Interface)兼容的。SUS标准由The Open Group维护,该组织拥有UNIX的注册商标 (http://www.unix.org/),XSI兼容的系统可以从该组织获得授权 使用UNIX这个商标。 现在该说说文件描述符了。每个进程在Linux内核中都有一个task_struct结构体来维护进程相关 的信息,称为进程描述符(Process Descriptor),而在操作系统理论中称为进程控制块 (PCB,Process Control Block)。task_struct中有一个指针指向files_struct结构体,称为 文件描述符表,其中每个表项包含一个指向已打开的文件的指针,如下图所示。 图 28.2. 文件描述符表 至于已打开的文件在内核中用什么结构体表示,我们将在下一章详细介绍,目前我们在画图时 用一个圈表示。用户程序不能直接访问内核中的文件描述符表,而只能使用文件描述符表的索 引(即0、1、2、3这些数字),这些索引就称为文件描述符(File Descriptor),用int型变量 保存。当调用open打开一个文件或创建一个新文件时,内核分配一个文件描述符并返回给用户 程序,该文件描述符表项中的指针指向新打开的文件。当读写文件时,用户程序把文件描述符 传给read或write,内核根据文件描述符找到相应的表项,再通过表项中的指针找到相应的文 件。 我们知道,程序启动时会自动打开三个文件:标准输入、标准输出和标准错误输出。在C标准库 中分别用FILE *指针stdin、stdout和stderr表示。这三个文件的描述符分别是0、1、2,保存 在相应的FILE结构体中。头文件unistd.h中有如下的宏定义来表示这三个文件描述符: #define STDIN_FILENO 0 #define STDOUT_FILENO 1 #define STDERR_FILENO 2 [34] 事实上Unbuffered I/O这个名词是有些误导的,虽然write系统调用位于C标准库I/O缓冲区 write write 的底层,但在 的底层也可以分配一个内核I/O缓冲区,所以 也不一定是直接写到文件 的,也可能写到内核I/O缓冲区中,至于究竟写到了文件中还是内核缓冲区中对于进程来说是没 有差别的,如果进程A和进程B打开同一文件,进程A写到内核I/O缓冲区中的数据从进程B也能 读到,而C标准库的I/O缓冲区则不具有这一特性(想一想为什么)。 上一页 1. 汇编程序的Hello world 上一级 起始页 下一页 3. open/close 上一页 3. open/close 第 28 章 文件与I/O 下一页 3. open/close open函数可以打开或创建一个文件。 #include #include #include int open(const char *pathname, int flags); int open(const char *pathname, int flags, mode_t mode); 返回值:成功返回新分配的文件描述符,出错返回-1并设置errno 在Man Page中open函数有两种形式,一种带两个参数,一种带三个参数,其实在C代码 中open函数是这样声明的: int open(const char *pathname, int flags, ...); 最后的可变参数可以是0个或1个,由flags参数中的标志位决定,见下面的详细说明。 pathname参数是要打开或创建的文件名,和fopen一样,pathname既可以是相对路径也可以是绝 对路径。flags参数有一系列常数值可供选择,可以同时选择多个常数用按位或运算符连接起 来,所以这些常数的宏定义都以O_开头,表示or。 必选项:以下三个常数中必须指定一个,且仅允许指定一个。 O_RDONLY 只读打开 O_WRONLY 只写打开 O_RDWR 可读可写打开 以下可选项可以同时指定0个或多个,和必选项按位或起来作为flags参数。可选项有很多,这 里只介绍一部分,其它选项可参考open(2)的Man Page: O_APPEND 表示追加。如果文件已有内容,这次打开文件所写的数据附加到文件的末尾而不 覆盖原来的内容。 O_CREAT 若此文件不存在则创建它。使用此选项时需要提供第三个参数mode,表示该文件 的访问权限。 O_EXCL 如果同时指定了O_CREAT,并且文件已存在,则出错返回。 O_TRUNC 如果文件已存在,并且以只写或可读可写方式打开,则将其长度截断 (Truncate)为0字节。 O_NONBLOCK 对于设备文件,以O_NONBLOCK方式打开可以做非阻塞I/O(Nonblock I/O),非 阻塞I/O在下一节详细讲解。 注意open函数与C标准I/O库的fopen函数有些细微的区别: 以可写的方式fopen一个文件时,如果文件不存在会自动创建,而open一个文件时必须明 确指定O_CREAT才会创建文件,否则文件不存在就出错返回。 以w或w+方式fopen一个文件时,如果文件已存在就截断为0字节,而open一个文件时必须 明确指定O_TRUNC才会截断文件,否则直接在原来的数据上改写。 第三个参数mode指定文件权限,可以用八进制数表示,比如0644表示-rw-r--r--,也可以 用S_IRUSR、S_IWUSR等宏定义按位或起来表示,详见open(2)的Man Page。要注意的是,文件 权限由open的mode参数和当前进程的umask掩码共同决定。 补充说明一下Shell的umask命令。Shell进程的umask掩码可以用umask命令查看: $ umask 0022 用touch命令创建一个文件时,创建权限是0666,而touch进程继承了Shell进程的umask掩码, 所以最终的文件权限是0666&~022=0644。 $ touch file123 $ ls -l file123 -rw-r--r-- 1 akaedu akaedu 0 2009-03-08 15:07 file123 同样道理,用gcc编译生成一个可执行文件时,创建权限是0777,而最终的文件权限 是0777&~022=0755。 $ gcc main.c $ ls -l a.out -rwxr-xr-x 1 akaedu akaedu 6483 2009-03-08 15:07 a.out 我们看到的都是被umask掩码修改之后的权限,那么如何证明touch或gcc创建文件的权限本来应 该是0666和0777呢?我们可以把Shell进程的umask改成0,再重复上述实验: $ umask 0 $ touch file123 $ rm file123 a.out $ touch file123 $ ls -l file123 -rw-rw-rw- 1 akaedu akaedu 0 2009-03-08 15:09 file123 $ gcc main.c $ ls -l a.out -rwxrwxrwx 1 akaedu akaedu 6483 2009-03-08 15:09 a.out 现在我们自己写一个程序,在其中调用open("somefile", O_WRONLY|O_CREAT, 0664);创建文 件,然后在Shell中运行并查看结果: $ umask 022 $ ./a.out $ ls -l somefile -rw-r--r-- 1 akaedu akaedu 6483 2009-03-08 15:11 somefile 不出所料,文件somefile的权限是0664&~022=0644。有几个问题现在我没有解释:为什么 被Shell启动的进程可以继承Shell进程的umask掩码?为什么umask命令可以读写Shell进程 的umask掩码?这些问题将在第 1 节 “引言”解释。 close函数关闭一个已打开的文件: #include int close(int fd); 返回值:成功返回0,出错返回-1并设置errno 参数fd是要关闭的文件描述符。需要说明的是,当一个进程终止时,内核对该进程所有尚未关 闭的文件描述符调用close关闭,所以即使用户程序不调用close,在终止时内核也会自动关闭 它打开的所有文件。但是对于一个长年累月运行的程序(比如网络服务器),打开的文件描述 符一定要记得关闭,否则随着打开的文件越来越多,会占用大量文件描述符和系统资源。 由open返回的文件描述符一定是该进程尚未使用的最小描述符。由于程序启动时自动打开文件 描述符0、1、2,因此第一次调用open打开文件通常会返回描述符3,再调用open就会返回4。可 以利用这一点在标准输入、标准输出或标准错误输出上打开一个新文件,实现重定向的功能。 例如,首先调用close关闭文件描述符1,然后调用open打开一个常规文件,则一定会返回文件 描述符1,这时候标准输出就不再是终端,而是一个常规文件了,再调用printf就不会打印到屏 幕上,而是写到这个文件中了。后面要讲的dup2函数提供了另外一种办法在指定的文件描述符 上打开文件。 习题 1、在系统头文件中查找flags和mode参数用到的这些宏定义的值是多少。把这些宏定义按位或 起来是什么效果?为什么必选项只能选一个而可选项可以选多个? 2、请按照下述要求分别写出相应的open调用。 打开文件/home/akae.txt用于写操作,以追加方式打开 打开文件/home/akae.txt用于写操作,如果该文件不存在则创建它 打开文件/home/akae.txt用于写操作,如果该文件已存在则截断为0字节,如果该文件不 存在则创建它 打开文件/home/akae.txt用于写操作,如果该文件已存在则报错退出,如果该文件不存在 则创建它 上一页 2. C标准I/O库函数与Unbuffered I/O函数 上一级 起始页 下一页 4. read/write 上一页 4. read/write 第 28 章 文件与I/O 下一页 4. read/write read函数从打开的设备或文件中读取数据。 #include ssize_t read(int fd, void *buf, size_t count); 返回值:成功返回读取的字节数,出错返回- 1 并设置errno ,如果在调read之前已到达文 件末尾,则这次read返回0 参数count是请求读取的字节数,读上来的数据保存在缓冲区buf中,同时文件的当前读写位置 向后移。注意这个读写位置和使用C标准I/O库时的读写位置有可能不同,这个读写位置是记在 内核中的,而使用C标准I/O库时的读写位置是用户空间I/O缓冲区中的位置。比如用fgetc读一 个字节,fgetc有可能从内核中预读1024个字节到I/O缓冲区中,再返回第一个字节,这时该文 件在内核中记录的读写位置是1024,而在FILE结构体中记录的读写位置是1。注意返回值类型 是ssize_t,表示有符号的size_t,这样既可以返回正的字节数、0(表示到达文件末尾)也可 以返回负值-1(表示出错)。read函数返回时,返回值说明了buf中前多少个字节是刚读上来 的。有些情况下,实际读到的字节数(返回值)会小于请求读的字节数count,例如: 读常规文件时,在读到count个字节之前已到达文件末尾。例如,距文件末尾还有30个字 节而请求读100个字节,则read返回30,下次read将返回0。 从终端设备读,通常以行为单位,读到换行符就返回了。 从网络读,根据不同的传输层协议和内核缓存机制,返回值可能小于请求的字节数,后 面socket编程部分会详细讲解。 write函数向打开的设备或文件中写数据。 #include ssize_t write(int fd, const void *buf, size_t count); 返回值:成功返回写入的字节数,出错返回-1并设置errno 写常规文件时,write的返回值通常等于请求写的字节数count,而向终端设备或网络写则不一 定。 读常规文件是不会阻塞的,不管读多少字节,read一定会在有限的时间内返回。从终端设备或 网络读则不一定,如果从终端输入的数据没有换行符,调用read读终端设备就会阻塞,如果网 络上没有接收到数据包,调用read从网络读就会阻塞,至于会阻塞多长时间也是不确定的,如 果一直没有数据到达就一直阻塞在那里。同样,写常规文件是不会阻塞的,而向终端设备或网 络写则不一定。 现在明确一下阻塞(Block)这个概念。当进程调用一个阻塞的系统函数时,该进程被置于睡眠 (Sleep)状态,这时内核调度其它进程运行,直到该进程等待的事件发生了(比如网络上接收 到数据包,或者调用sleep指定的睡眠时间到了)它才有可能继续运行。与睡眠状态相对的是运 行(Running)状态,在Linux内核中,处于运行状态的进程分为两种情况: 正在被调度执行。CPU处于该进程的上下文环境中,程序计数器(eip)里保存着该进程 的指令地址,通用寄存器里保存着该进程运算过程的中间结果,正在执行该进程的指令, 正在读写该进程的地址空间。 就绪状态。该进程不需要等待什么事件发生,随时都可以执行,但CPU暂时还在执行另一 个进程,所以该进程在一个就绪队列中等待被内核调度。系统中可能同时有多个就绪的进 程,那么该调度谁执行呢?内核的调度算法是基于优先级和时间片的,而且会根据每个进 程的运行情况动态调整它的优先级和时间片,让每个进程都能比较公平地得到机会执行, 同时要兼顾用户体验,不能让和用户交互的进程响应太慢。 下面这个小程序从终端读数据再写回终端。 例 28.2. 阻塞读终端 #include #include int main(void) { char buf[10]; int n; n = read(STDIN_FILENO, buf, 10); if (n < 0) { perror("read STDIN_FILENO"); exit(1); } write(STDOUT_FILENO, buf, n); return 0; } 执行结果如下: $ ./a.out hello (回车) hello $ ./a.out hello world(回车) hello worl$ d bash: d: command not found 第一次执行a.out的结果很正常,而第二次执行的过程有点特殊,现在分析一下: 1. Shell进程创建a.out进程,a.out进程开始执行,而Shell进程睡眠等待a.out进程退出。 2. a.out调用read时睡眠等待,直到终端设备输入了换行符才从read返回,read只读走10个 字符,剩下的字符仍然保存在内核的终端设备输入缓冲区中。 3. a.out进程打印并退出,这时Shell进程恢复运行,Shell继续从终端读取用户输入的命令, 于是读走了终端设备输入缓冲区中剩下的字符d和换行符,把它当成一条命令解释执行, 结果发现执行不了,没有d这个命令。 如果在open一个设备时指定了O_NONBLOCK标志,read/write就不会阻塞。以read为例,如果设备 暂时没有数据可读就返回-1,同时置errno为EWOULDBLOCK(或者EAGAIN,这两个宏定义的值相 同),表示本来应该阻塞在这里(would block,虚拟语气),事实上并没有阻塞而是直接返回 错误,调用者应该试着再读一次(again)。这种行为方式称为轮询(Poll),调用者只是查询 一下,而不是阻塞在这里死等,这样可以同时监视多个设备: while(1) { 非阻塞read( 设备1); if(设备1有数据到达) 处理数据; 非阻塞read( 设备2); if(设备2有数据到达) 处理数据; ... } 如果read(设备1)是阻塞的,那么只要设备1没有数据到达就会一直阻塞在设备1的read调用上, 即使设备2有数据到达也不能处理,使用非阻塞I/O就可以避免设备2得不到及时处理。 非阻塞I/O有一个缺点,如果所有设备都一直没有数据到达,调用者需要反复查询做无用功,如 果阻塞在那里,操作系统可以调度别的进程执行,就不会做无用功了。在使用非阻塞I/O时,通 常不会在一个while循环中一直不停地查询(这称为Tight Loop),而是每延迟等待一会儿来查 询一下,以免做太多无用功,在延迟等待的时候可以调度其它进程执行。 while(1) { 非阻塞read( 设备1); if(设备1有数据到达) 处理数据; 非阻塞read( 设备2); if(设备2有数据到达) 处理数据; ... sleep(n); } 这样做的问题是,设备1有数据到达时可能不能及时处理,最长需延迟n秒才能处理,而且反复 查询还是做了很多无用功。以后要学习的select(2)函数可以阻塞地同时监视多个设备,还可以 设定阻塞等待的超时时间,从而圆满地解决了这个问题。 以下是一个非阻塞I/O的例子。目前我们学过的可能引起阻塞的设备只有终端,所以我们用终端 来做这个实验。程序开始执行时在0、1、2文件描述符上自动打开的文件就是终端,但是没 有O_NONBLOCK标志。所以就像例 28.2 “阻塞读终端”一样,读标准输入是阻塞的。我们可以重新 打开一遍设备文件/dev/tty(表示当前终端),在打开时指定O_NONBLOCK标志。 例 28.3. 非阻塞读终端 #include #include #include #include #include #define MSG_TRY "try again\n" int main(void) { char buf[10]; int fd, n; fd = open("/dev/tty", O_RDONLY|O_NONBLOCK); if(fd<0) { perror("open /dev/tty"); exit(1); } tryagain: n = read(fd, buf, 10); if (n < 0) { if (errno == EAGAIN) { sleep(1); write(STDOUT_FILENO, MSG_TRY, strlen(MSG_TRY)); goto tryagain; } perror("read /dev/tty"); exit(1); } write(STDOUT_FILENO, buf, n); close(fd); return 0; } 以下是用非阻塞I/O实现等待超时的例子。既保证了超时退出的逻辑又保证了有数据到达时处理 延迟较小。 例 28.4. 非阻塞读终端和等待超时 #include #include #include #include #include #define MSG_TRY "try again\n" #define MSG_TIMEOUT "timeout\n" int main(void) { char buf[10]; int fd, n, i; fd = open("/dev/tty", O_RDONLY|O_NONBLOCK); if(fd<0) { perror("open /dev/tty"); exit(1); } for(i=0; i<5; i++) { n = read(fd, buf, 10); if(n>=0) break; if(errno!=EAGAIN) { perror("read /dev/tty"); exit(1); } sleep(1); write(STDOUT_FILENO, MSG_TRY, strlen(MSG_TRY)); } if(i==5) write(STDOUT_FILENO, MSG_TIMEOUT, strlen(MSG_TIMEOUT)); else write(STDOUT_FILENO, buf, n); close(fd); return 0; } 上一页 3. open/close 上一级 起始页 下一页 5. lseek 上一页 5. lseek 第 28 章 文件与I/O 下一页 5. lseek 每个打开的文件都记录着当前读写位置,打开文件时读写位置是0,表示文件开头,通常读写多 少个字节就会将读写位置往后移多少个字节。但是有一个例外,如果以O_APPEND方式打开,每 次写操作都会在文件末尾追加数据,然后将读写位置移到新的文件末尾。lseek和标准I/O库 的fseek函数类似,可以移动当前读写位置(或者叫偏移量)。 #include #include off_t lseek(int fd, off_t offset, int whence); 参数offset和whence的含义和fseek函数完全相同。只不过第一个参数换成了文件描述符。 和fseek一样,偏移量允许超过文件末尾,这种情况下对该文件的下一次写操作将延长文件,中 间空洞的部分读出来都是0。 若lseek成功执行,则返回新的偏移量,因此可用以下方法确定一个打开文件的当前偏移量: off_t currpos; currpos = lseek(fd, 0, SEEK_CUR); 这种方法也可用来确定文件或设备是否可以设置偏移量,常规文件都可以设置偏移量,而设备 一般是不可以设置偏移量的。如果设备不支持lseek,则lseek返回-1,并将errno设置 为ESPIPE。注意fseek和lseek在返回值上有细微的差别,fseek成功时返回0失败时返回-1,要 返回当前偏移量需调用ftell,而lseek成功时返回当前偏移量失败时返回-1。 上一页 4. read/write 上一级 起始页 下一页 6. fcntl 上一页 6. fcntl 第 28 章 文件与I/O 下一页 6. fcntl 先前我们以read终端设备为例介绍了非阻塞I/O,为什么我们不直接对STDIN_FILENO做非阻 塞read,而要重新open一遍/dev/tty呢?因为STDIN_FILENO在程序启动时已经被自动打开了,而 我们需要在调用open时指定O_NONBLOCK标志。这里介绍另外一种办法,可以用fcntl函数改变一 个已打开的文件的属性,可以重新设置读、写、追加、非阻塞等标志(这些标志称为File Status Flag),而不必重新open文件。 #include #include int fcntl(int fd, int cmd); int fcntl(int fd, int cmd, long arg); int fcntl(int fd, int cmd, struct flock *lock); 这个函数和open一样,也是用可变参数实现的,可变参数的类型和个数取决于前面的cmd参数。 下面的例子使用F_GETFL和F_SETFL这两种fcntl 命令改变STDIN_FILENO的属性,加 上O_NONBLOCK选项,实现和例 28.3 “非阻塞读终端”同样的功能。 例 28.5. 用fcntl改变File Status Flag #include #include #include #include #include #define MSG_TRY "try again\n" int main(void) { char buf[10]; int n; int flags; flags = fcntl(STDIN_FILENO, F_GETFL); flags |= O_NONBLOCK; if (fcntl(STDIN_FILENO, F_SETFL, flags) == -1) { perror("fcntl"); exit(1); } tryagain: n = read(STDIN_FILENO, buf, 10); if (n < 0) { if (errno == EAGAIN) { sleep(1); write(STDOUT_FILENO, MSG_TRY, strlen(MSG_TRY)); goto tryagain; } perror("read stdin"); exit(1); } write(STDOUT_FILENO, buf, n); return 0; } 以下程序通过命令行的第一个参数指定一个文件描述符,同时利用Shell的重定向功能在该描述 符上打开文件,然后用fcntl的F_GETFL命令取出File Status Flag并打印。 #include #include #include #include int main(int argc, char *argv[]) { int val; if (argc != 2) { fputs("usage: a.out \n", stderr); exit(1); } if ((val = fcntl(atoi(argv[1]), F_GETFL)) < 0) { printf("fcntl error for fd %d\n", atoi(argv[1])); exit(1); } switch(val & O_ACCMODE) { case O_RDONLY: printf("read only"); break; case O_WRONLY: printf("write only"); break; case O_RDWR: printf("read write"); break; default: fputs("invalid access mode\n", stderr); exit(1); } if (val & O_APPEND) printf(", append"); if (val & O_NONBLOCK) printf(", nonblocking"); putchar('\n'); return 0; } 运行该程序的几种情况解释如下。 $ ./a.out 0 < /dev/tty read only Shell在执行a.out时将它的标准输入重定向到/dev/tty,并且是只读的。argv[1]是0,因此取出 文件描述符0(也就是标准输入)的File Status Flag,用掩码O_ACCMODE取出它的读写位,结果 是O_RDONLY。注意,Shell的重定向语法不属于程序的命令行参数,这个命行只有两个参 数,argv[0]是"./a.out",argv[1]是"0",重定向由Shell解释,在启动程序时已经生效,程序在 运行时并不知道标准输入被重定向了。 $ ./a.out 1 > temp.foo $ cat temp.foo write only Shell在执行a.out时将它的标准输出重定向到文件temp.foo,并且是只写的。程序取出文件描述 符1的File Status Flag,发现是只写的,于是打印write only,但是打印不到屏幕上而是打印 到temp.foo这个文件中了。 $ ./a.out 2 2>>temp.foo write only, append Shell在执行a.out时将它的标准错误输出重定向到文件temp.foo,并且是只写和追加方式。程序 取出文件描述符2的File Status Flag,发现是只写和追加方式的。 $ ./a.out 5 5<>temp.foo read write Shell在执行a.out时在它的文件描述符5上打开文件temp.foo,并且是可读可写的。程序取出文 件描述符5的File Status Flag,发现是可读可写的。 我们看到一种新的Shell重定向语法,如果在<、>、>>、<>前面添一个数字,该数字就表示在哪 个文件描述符上打开文件,例如2>>temp.foo表示将标准错误输出重定向到文件temp.foo并且以 追加方式写入文件,注意2和>>之间不能有空格,否则2就被解释成命令行参数了。文件描述符 数字还可以出现在重定向符号右边,例如: $ command > /dev/null 2>&1 首先将某个命令command的标准输出重定向到/dev/null,然后将该命令可能产生的错误信息 (标准错误输出)也重定向到和标准输出(用&1标识)相同的文件,即/dev/null,如下图所 示。 图 28.3. 重定向之后的文件描述符表 /dev/null设备文件只有一个作用,往它里面写任何数据都被直接丢弃。因此保证了该命令执行 时屏幕上没有任何输出,既不打印正常信息也不打印错误信息,让命令安静地执行,这种写法 在Shell脚本中很常见。注意,文件描述符数字写在重定向符号右边需要加&号,否则就被解释 成文件名了,2>&1其中的>左右两边都不能有空格。 除了F_GETFL和F_SETFL命令之外,fcntl还有很多命令做其它操作,例如设置文件记录锁等。可 以通过fcntl设置的都是当前进程如何访问设备或文件的访问控制属性,例如读、写、追加、非 阻塞、加锁等,但并不设置文件或设备本身的属性,例如文件的读写权限、串口波特率等。下 一节要介绍的ioctl函数用于设置某些设备本身的属性,例如串口波特率、终端窗口大小,注意 区分这两个函数的作用。 上一页 5. lseek 上一级 起始页 下一页 7. ioctl 上一页 7. ioctl 第 28 章 文件与I/O 下一页 7. ioctl ioctl用于向设备发控制和配置命令,有些命令也需要读写一些数据,但这些数据是不能 用read/write读写的,称为Out-of-band数据。也就是说,read/write读写的数据是in-band数 据,是I/O操作的主体,而ioctl命令传送的是控制信息,其中的数据是辅助的数据。例如,在串 口线上收发数据通过read/write操作,而串口的波特率、校验位、停止位通过ioctl设 置,A/D转换的结果通过read读取,而A/D转换的精度和工作频率通过ioctl设置。 #include int ioctl(int d, int request, ...); d是某个设备的文件描述符。request是ioctl的命令,可变参数取决于request,通常是一个指向 变量或结构体的指针。若出错则返回-1,若成功则返回其他值,返回值也是取决于request。 以下程序使用TIOCGWINSZ命令获得终端设备的窗口大小。 #include #include #include #include int main(void) { struct winsize size; if (isatty(STDOUT_FILENO) == 0) exit(1); if(ioctl(STDOUT_FILENO, TIOCGWINSZ, &size)<0) { perror("ioctl TIOCGWINSZ error"); exit(1); } printf("%d rows, %d columns\n", size.ws_row, size.ws_col); return 0; } 在图形界面的终端里多次改变终端窗口的大小并运行该程序,观察结果。 上一页 6. fcntl 上一级 起始页 下一页 8. mmap 上一页 8. mmap 第 28 章 文件与I/O 下一页 8. mmap mmap可以把磁盘文件的一部分直接映射到内存,这样文件中的位置直接就有对应的内存地址, 对文件的读写可以直接用指针来做而不需要read/write函数。 #include void *mmap(void *addr, size_t len, int prot, int flag, int filedes, off_t off); int munmap(void *addr, size_t len); 该函数各参数的作用图示如下: 图 28.4. mmap函数 如果addr参数为NULL,内核会自己在进程地址空间中选择合适的地址建立映射。如果addr不 是NULL,则给内核一个提示,应该从什么地址开始映射,内核会选择addr之上的某个合适的地 址开始映射。建立映射后,真正的映射首地址通过返回值可以得到。len参数是需要映射的那一 部分文件的长度。off参数是从文件的什么位置开始映射,必须是页大小的整数倍(在32位体系 统结构上通常是4K)。filedes是代表该文件的描述符。 prot参数有四种取值: PROT_EXEC表示映射的这一段可执行,例如映射共享库 PROT_READ表示映射的这一段可读 PROT_WRITE表示映射的这一段可写 PROT_NONE表示映射的这一段不可访问 flag参数有很多种取值,这里只讲两种,其它取值可查看mmap(2) MAP_SHARED多个进程对同一个文件的映射是共享的,一个进程对映射的内存做了修 改,另一个进程也会看到这种变化。 MAP_PRIVATE多个进程对同一个文件的映射不是共享的,一个进程对映射的内存做了修 改,另一个进程并不会看到这种变化,也不会真的写到文件中去。 如果mmap成功则返回映射首地址,如果出错则返回常数MAP_FAILED。当进程终止时,该进程的 映射内存会自动解除,也可以调用munmap解除映射。munmap成功返回0,出错返回-1。 下面做一个简单的实验。 $ vi hello (编辑该文件的内容为“hello”) $ od -tx1 -tc hello 0000000 68 65 6c 6c 6f 0a h e l l o \n 0000006 现在用如下程序操作这个文件(注意,把fd关掉并不影响该文件已建立的映射,仍然可以对文 件进行读写)。 #include #include #include int main(void) { int *p; int fd = open("hello", O_RDWR); if (fd < 0) { perror("open hello"); exit(1); } p = mmap(NULL, 6, PROT_WRITE, MAP_SHARED, fd, 0); if (p == MAP_FAILED) { perror("mmap"); exit(1); } close(fd); p[0] = 0x30313233; munmap(p, 6); return 0; } 然后再查看这个文件的内容: $ od -tx1 -tc hello 0000000 33 32 31 30 6f 0a 3 2 1 0 o \n 0000006 请读者自己分析一下实验结果。 mmap函数的底层也是一个系统调用,在执行程序时经常要用到这个系统调用来映射共享库到该 进程的地址空间。例如一个很简单的hello world程序: #include int main(void) { printf("hello world\n"); return 0; } 用strace命令执行该程序,跟踪该程序执行过程中用到的所有系统调用的参数及返回值: $ strace ./a.out execve("./a.out", ["./a.out"], [/* 38 vars */]) = 0 brk(0) = 0x804a000 access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory) mmap2(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb7fca000 access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory) open("/etc/ld.so.cache", O_RDONLY) =3 fstat64(3, {st_mode=S_IFREG|0644, st_size=63628, ...}) = 0 mmap2(NULL, 63628, PROT_READ, MAP_PRIVATE, 3, 0) = 0xb7fba000 close(3) =0 access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory) open("/lib/tls/i686/cmov/libc.so.6", O_RDONLY) = 3 read(3, "\177ELF\1\1\1\0\0\0\0\0\0\0\0\0\3\0\3\0\1\0\0\0\260a\1"..., 512) = 512 fstat64(3, {st_mode=S_IFREG|0644, st_size=1339816, ...}) = 0 mmap2(NULL, 1349136, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0xb7e70000 mmap2(0xb7fb4000, 12288, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x143) = 0xb7fb4000 mmap2(0xb7fb7000, 9744, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0xb7fb7000 close(3) =0 mmap2(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb7e6f000 set_thread_area({entry_number:-1 -> 6, base_addr:0xb7e6f6b0, limit:1048575, seg_32bit:1, contents:0, read_exec_only:0, limit_in_pages:1, seg_not_present:0, useable:1}) = 0 mprotect(0xb7fb4000, 4096, PROT_READ) = 0 munmap(0xb7fba000, 63628) =0 fstat64(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 1), ...}) = 0 mmap2(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb7fc9000 write(1, "hello world\n", 12hello world ) = 12 exit_group(0) =? Process 8572 detached 可以看到,执行这个程序要映射共享库/lib/tls/i686/cmov/libc.so.6到进程地址空间。也可以 看到,printf函数的底层确实是调用write。 上一页 7. ioctl 上一级 起始页 下一页 第 29 章 文件系统 上一页 第 29 章 文件系统 部分 III. Linux系统编程 第 29 章 文件系统 目录 1. 引言 2. ext2文件系统 2.1. 总体存储布局 2.2. 实例剖析 2.3. 数据块寻址 2.4. 文件和目录操作的系统函数 3. VFS 3.1. 内核数据结构 3.2. dup和dup2函数 上一页 8. mmap 上一级 起始页 下一页 下一页 1. 引言 上一页 1. 引言 第 29 章 文件系统 下一页 1. 引言 本章主要解答以下问题: 1. 文件系统在内核中是如何实现的?如何呈现给用户一个树状的目录结构?如何处理用户的 文件和目录操作请求? 2. 磁盘是一种顺序的存储介质,一个树状的目录结构如何扯成一条线存到磁盘上?怎样设计 文件系统的存储格式使访问磁盘的效率最高?各种文件和目录操作在磁盘上的实际效果是 什么? 图 29.1. 文件系统的表示和存储 我们首先介绍一种文件系统的存储格式-早期Linux广泛使用的ext2文件系统。现在Linux最常用 的ext3文件系统也是与ext2兼容的,基本格式是一致的,只是多了一些扩展。然后再介绍文件系 统在内核中是如何实现的。 上一页 第 29 章 文件系统 上一级 起始页 下一页 2. ext2文件系统 上一页 2. ext2文件系统 第 29 章 文件系统 下一页 2. ext2文件系统 2.1. 总体存储布局 我们知道,一个磁盘可以划分成多个分区,每个分区必须先用格式化工具(例如某种mkfs命 令)格式化成某种格式的文件系统,然后才能存储文件,格式化的过程会在磁盘上写一些管理 存储布局的信息。下图是一个磁盘分区格式化成ext2文件系统后的存储布局。 图 29.2. ext2文件系统的总体存储布局 文件系统中存储的最小单位是块(Block),一个块究竟多大是在格式化时确定的,例 如mke2fs的-b选项可以设定块大小为1024、2048或4096字节。而上图中启动块(Boot Block)的大小是确定的,就是1KB,启动块是由PC标准规定的,用来存储磁盘分区信息和启动 信息,任何文件系统都不能使用启动块。启动块之后才是ext2文件系统的开始,ext2文件系统将 整个分区划成若干个同样大小的块组(Block Group),每个块组都由以下部分组成。 超级块(Super Block) 描述整个分区的文件系统信息,例如块大小、文件系统版本号、上次mount的时间等等。 超级块在每个块组的开头都有一份拷贝。 块组描述符表(GDT,Group Descriptor Table) 由很多块组描述符组成,整个分区分成多少个块组就对应有多少个块组描述符。每个块组 描述符(Group Descriptor)存储一个块组的描述信息,例如在这个块组中从哪里开始 是inode表,从哪里开始是数据块,空闲的inode和数据块还有多少个等等。和超级块类 似,块组描述符表在每个块组的开头也都有一份拷贝,这些信息是非常重要的,一旦超级 块意外损坏就会丢失整个分区的数据,一旦块组描述符意外损坏就会丢失整个块组的数 据,因此它们都有多份拷贝。通常内核只用到第0个块组中的拷贝,当执行e2fsck检查文 件系统一致性时,第0个块组中的超级块和块组描述符表就会拷贝到其它块组,这样当 第0个块组的开头意外损坏时就可以用其它拷贝来恢复,从而减少损失。 块位图(Block Bitmap) 一个块组中的块是这样利用的:数据块存储所有文件的数据,比如某个分区的块大小 是1024字节,某个文件是2049字节,那么就需要三个数据块来存,即使第三个块只存了 一个字节也需要占用一个整块;超级块、块组描述符表、块位图、inode位图、inode表这 几部分存储该块组的描述信息。那么如何知道哪些块已经用来存储文件数据或其它描述信 息,哪些块仍然空闲可用呢?块位图就是用来描述整个块组中哪些块已用哪些块空闲的, 它本身占一个块,其中的每个bit代表本块组中的一个块,这个bit为1表示该块已用,这 个bit为0表示该块空闲可用。 为什么用df命令统计整个磁盘的已用空间非常快呢?因为只需要查看每个块组的块位图即 可,而不需要搜遍整个分区。相反,用du命令查看一个较大目录的已用空间就非常慢,因 为不可避免地要搜遍整个目录的所有文件。 与此相联系的另一个问题是:在格式化一个分区时究竟会划出多少个块组呢?主要的限制 在于块位图本身必须只占一个块。用mke2fs格式化时默认块大小是1024字节,可以用-b参 数指定块大小,现在设块大小指定为b字节,那么一个块可以有8b个bit,这样大小的一个 块位图就可以表示8b个块的占用情况,因此一个块组最多可以有8b个块,如果整个分区 有s个块,那么就可以有s/(8b)个块组。格式化时可以用-g参数指定一个块组有多少个块, 但是通常不需要手动指定,mke2fs工具会计算出最优的数值。 inode位图(inode Bitmap) 和块位图类似,本身占一个块,其中每个bit表示一个inode是否空闲可用。 inode表(inode Table) 我们知道,一个文件除了数据需要存储之外,一些描述信息也需要存储,例如文件类型 (常规、目录、符号链接等),权限,文件大小,创建/修改/访问时间等,也就是ls -l命 令看到的那些信息,这些信息存在inode中而不是数据块中。每个文件都有一个inode,一 个块组中的所有inode组成了inode表。 inode表占多少个块在格式化时就要决定并写入块组描述符中,mke2fs格式化工具的默认 策略是一个块组有多少个8KB就分配多少个inode。由于数据块占了整个块组的绝大部 分,也可以近似认为数据块有多少个8KB就分配多少个inode,换句话说,如果平均每个 文件的大小是8KB,当分区存满的时候inode表会得到比较充分的利用,数据块也不浪 费。如果这个分区存的都是很大的文件(比如电影),则数据块用完的时候inode会有一 些浪费,如果这个分区存的都是很小的文件(比如源代码),则有可能数据块还没用 完inode就已经用完了,数据块可能有很大的浪费。如果用户在格式化时能够对这个分区 以后要存储的文件大小做一个预测,也可以用mke2fs的-i参数手动指定每多少个字节分配 一个inode。 数据块(Data Block) 根据不同的文件类型有以下几种情况 对于常规文件,文件的数据存储在数据块中。 对于目录,该目录下的所有文件名和目录名存储在数据块中,注意文件名保存在它 所在目录的数据块中,除文件名之外,ls -l命令看到的其它信息都保存在该文件 的inode中。注意这个概念:目录也是一种文件,是一种特殊类型的文件。 对于符号链接,如果目标路径名较短则直接保存在inode中以便更快地查找,如果目 标路径名较长则分配一个数据块来保存。 设备文件、FIFO和socket等特殊文件没有数据块,设备文件的主设备号和次设备号 保存在inode中。 现在做几个小实验来理解这些概念。例如在home目录下ls -l: $ ls -l total 32 drwxr-xr-x 114 akaedu akaedu 12288 2008-10-25 11:33 akaedu drwxr-xr-x 114 ftp ftp 4096 2008-10-25 10:30 ftp drwx------ 2 root root 16384 2008-07-04 05:58 lost+found 为什么各目录的大小都是4096的整数倍?因为这个分区的块大小是4096,目录的大小总是数据 块的整数倍。为什么有的目录大有的目录小?因为目录的数据块保存着它下边所有文件和目录 的名字,如果一个目录中的文件很多,一个块装不下这么多文件名,就可能分配更多的数据块 给这个目录。再比如: $ ls -l /dev ...... prw-r----- 1 syslog adm crw-rw-rw- 1 root root 0 2008-10-25 11:39 xconsole 1, 5 2008-10-24 16:44 zero xconsole文件的类型是p(表示pipe),是一个FIFO文件,后面会讲到它其实是一块内核缓冲区 的标识,不在磁盘上保存数据,因此没有数据块,文件大小是0。zero文件的类型是c,表示字 符设备文件,它代表内核中的一个设备驱动程序,也没有数据块,原本应该写文件大小的地方 写了1, 5这两个数字,表示主设备号和次设备号,访问该文件时,内核根据设备号找到相应的 驱动程序。再比如: $ touch hello $ ln -s ./hello halo $ ls -l total 0 lrwxrwxrwx 1 akaedu akaedu 7 2008-10-25 15:04 halo -> ./hello -rw-r--r-- 1 akaedu akaedu 0 2008-10-25 15:04 hello 文件hello是刚创建的,字节数为0,符号链接文件halo指向hello,字节数却是7,为什么呢? 其实7就是“./hello”这7个字符,符号链接文件就保存着这样一个路径名。再试试硬链接: $ ln ./hello hello2 $ ls -l total 0 lrwxrwxrwx 1 akaedu akaedu 7 2008-10-25 15:08 halo -> ./hello -rw-r--r-- 2 akaedu akaedu 0 2008-10-25 15:04 hello -rw-r--r-- 2 akaedu akaedu 0 2008-10-25 15:04 hello2 hello2和hello除了文件名不一样之外,别的属性都一模一样,并且hello的属性发生了变化, 第二栏的数字原本是1,现在变成2了。从根本上说,hello和hello2是同一个文件在文件系统中 的两个名字,ls -l第二栏的数字是硬链接数,表示一个文件在文件系统中有几个名字(这些名 字可以保存在不同目录的数据块中,或者说可以位于不同的路径下),硬链接数也保存 在inode中。既然是同一个文件,inode当然只有一个,所以用ls -l看它们的属性是一模一样 的,因为都是从这个inode里读出来的。再研究一下目录的硬链接数: $ mkdir a $ mkdir a/b $ ls -ld a drwxr-xr-x 3 akaedu akaedu 4096 2008-10-25 16:15 a $ ls -la a total 20 drwxr-xr-x 3 akaedu akaedu 4096 2008-10-25 16:15 . drwxr-xr-x 115 akaedu akaedu 12288 2008-10-25 16:14 .. drwxr-xr-x 2 akaedu akaedu 4096 2008-10-25 16:15 b $ ls -la a/b total 8 drwxr-xr-x 2 akaedu akaedu 4096 2008-10-25 16:15 . drwxr-xr-x 3 akaedu akaedu 4096 2008-10-25 16:15 .. 首先创建目录a,然后在它下面创建子目录a/b。目录a的硬链接数是3,这3个名字分别是当前目 录下的a,a目录下的.和b目录下的..。目录b的硬链接数是2,这两个名字分别是a目录下 的b和b目录下的.。注意,目录的硬链接只能这种方式创建,用ln命令可以创建目录的符号链 接,但不能创建目录的硬链接。 2.2. 实例剖析 如果要格式化一个分区来研究文件系统格式则必须有一个空闲的磁盘分区,为了方便实验,我 们把一个文件当作分区来格式化,然后分析这个文件中的数据来印证上面所讲的要点。首先创 建一个1MB的文件并清零: $ dd if=/dev/zero of=fs count=256 bs=4K 我们知道cp命令可以把一个文件拷贝成另一个文件,而dd命令可以把一个文件的一部分拷贝成 另一个文件。这个命令的作用是把/dev/zero文件开头的1M(256×4K)字节拷贝成文件名 为fs的文件。刚才我们看到/dev/zero是一个特殊的设备文件,它没有磁盘数据块,对它进行读 操作传给设备号为1, 5的驱动程序。/dev/zero这个文件可以看作是无穷大的,不管从哪里开始 读,读出来的都是字节0x00。因此这个命令拷贝了1M个0x00到fs文件。if和of参数表示输入文 件和输出文件,count和bs参数表示拷贝多少次,每次拷多少字节。 做好之后对文件fs进行格式化,也就是把这个文件的数据块合起来看成一个1MB的磁盘分区, 在这个分区上再划分出块组。 $ mke2fs fs mke2fs 1.40.2 (12-Jul-2007) fs is not a block special device. Proceed anyway? (y,n) (输入y回车) Filesystem label= OS type: Linux Block size=1024 (log=0) Fragment size=1024 (log=0) 128 inodes, 1024 blocks 51 blocks (4.98%) reserved for the super user First data block=1 Maximum filesystem blocks=1048576 1 block group 8192 blocks per group, 8192 fragments per group 128 inodes per group Writing inode tables: done Writing superblocks and filesystem accounting information: done This filesystem will be automatically checked every 27 mounts or 180 days, whichever comes first. Use tune2fs -c or -i to override. 格式化一个真正的分区应该指定块设备文件名,例如/dev/sda1,而这个fs是常规文件而不是块 设备文件,mke2fs认为用户有可能是误操作了,所以给出提示,要求确认是否真的要格式化, 输入y回车完成格式化。 现在fs的大小仍然是1MB,但不再是全0了,其中已经有了块组和描述信息。用dumpe2fs工具可 以查看这个分区的超级块和块组描述符表中的信息: $ dumpe2fs fs dumpe2fs 1.40.2 (12-Jul-2007) Filesystem volume name: Last mounted on: Filesystem UUID: 8e1f3b7a-4d1f-41dc-8928-526e43b2fd74 Filesystem magic number: 0xEF53 Filesystem revision #: 1 (dynamic) Filesystem features: resize_inode dir_index filetype sparse_super Filesystem flags: signed directory hash Default mount options: (none) Filesystem state: clean Errors behavior: Continue Filesystem OS type: Linux Inode count: 128 Block count: 1024 Reserved block count: 51 Free blocks: 986 Free inodes: 117 First block: 1 Block size: 1024 Fragment size: 1024 Reserved GDT blocks: 3 Blocks per group: 8192 Fragments per group: 8192 Inodes per group: 128 Inode blocks per group: 16 Filesystem created: Sun Dec 16 14:56:59 2007 Last mount time: n/a Last write time: Sun Dec 16 14:56:59 2007 Mount count: 0 Maximum mount count: 30 Last checked: Sun Dec 16 14:56:59 2007 Check interval: 15552000 (6 months) Next check after: Fri Jun 13 14:56:59 2008 Reserved blocks uid: 0 (user root) Reserved blocks gid: 0 (group root) First inode: 11 Inode size: 128 Default directory hash: tea Directory Hash Seed: 6d0e58bd-b9db-41ae-92b3-4563a02a5981 Group 0: (Blocks 1-1023) Primary superblock at 1, Group descriptors at 2-2 Reserved GDT blocks at 3-5 Block bitmap at 6 (+5), Inode bitmap at 7 (+6) Inode table at 8-23 (+7) 986 free blocks, 117 free inodes, 2 directories Free blocks: 38-1023 Free inodes: 12-128 128 inodes per group, 8 inodes per block, so: 16 blocks for inode table 根据上面讲过的知识简单计算一下,块大小是1024字节,1MB的分区共有1024个块,第0个块 是启动块,启动块之后才算ext2文件系统的开始,因此Group 0占据第1个到第1023个块, 共1023个块。块位图占一个块,共有1024×8=8192个bit,足够表示这1023个块了,因此只要一 个块组就够了。默认是每8KB分配一个inode,因此1MB的分区对应128个inode,这些数据都 和dumpe2fs的输出吻合。 用常规文件制作而成的文件系统也可以像磁盘分区一样mount到某个目录,例如: $ sudo mount -o loop fs /mnt $ cd /mnt/ $ ls -la total 17 drwxr-xr-x 3 akaedu akaedu 1024 2008-10-25 12:20 . drwxr-xr-x 21 root root 4096 2008-08-18 08:54 .. drwx------ 2 root root 12288 2008-10-25 12:20 lost+found -o loop mount mount 选项告诉 这是一个常规文件而不是一个块设备文件。 会把它的数据块中的数 据当作分区格式来解释。文件系统格式化之后在根目录下自动生成三个子目 录:.,..和lost+found。其它子目录下的.表示当前目录,..表示上一级目录,而根目录 的.和..都表示根目录本身。lost+found目录由e2fsck工具使用,如果在检查磁盘时发现错误, 就把有错误的块挂在这个目录下,因为这些块不知道是谁的,找不到主,就放在这里“失物招 领”了。 现在可以在/mnt目录下添加删除文件,这些操作会自动保存到文件fs中。然后把这个分 区umount下来,以确保所有的改动都保存到文件中了。 $ sudo umount /mnt 注意,下面的实验步骤是对新创建的文件系统做的,如果你在文件系统中添加删除过文件,跟 着做下面的步骤时结果可能和我写的不太一样,不过也不影响理解。 现在我们用二进制查看工具查看这个文件系统的所有字节,并且同dumpe2fs工具的输出信息相 比较,就可以很好地理解文件系统的存储布局了。 $ od -tx1 -Ax fs 000000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 * 000400 80 00 00 00 00 04 00 00 33 00 00 00 da 03 00 00 000410 75 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 ...... 其中以*开头的行表示这一段数据全是零因此省略了。下面详细分析od输出的信息。 从000000开始的1KB是启动块,由于这不是一个真正的磁盘分区,启动块的内容全部为零。 从000400到0007ff的1KB是超级块,对照着dumpe2fs的输出信息,详细分析如下: 图 29.3. 超级块 超级块中从0004d0到末尾的204个字节是填充字节,保留未用,上图未画出。注意,ext2文件 系统中各字段都是按小端存储的,如果把字节在文件中的位置看作地址,那么靠近文件开头的 是低地址,存低字节。各字段的位置、长度和含义详见[ULK]。 从000800开始是块组描述符表,这个文件系统较小,只有一个块组描述符,对照着dumpe2fs的 输出信息分析如下: ... Group 0: (Blocks 1-1023) Primary superblock at 1, Group descriptors at 2-2 Reserved GDT blocks at 3-5 Block bitmap at 6 (+5), Inode bitmap at 7 (+6) Inode table at 8-23 (+7) 986 free blocks, 117 free inodes, 2 directories Free blocks: 38-1023 Free inodes: 12-128 ... 图 29.4. 块组描述符 整个文件系统是1MB,每个块是1KB,应该有1024个块,除去启动块还有1023个块,分别编号 为1-1023,它们全都属于Group 0。其中,Block 1是超级块,接下来的块组描述符指出,块位 图是Block 6,因此中间的Block 2-5是块组描述符表,其中Block 3-5保留未用。块组描述符还指 出,inode位图是Block 7,inode表是从Block 8开始的,那么inode表到哪个块结束呢?由于超 级块中指出每个块组有128个inode,每个inode的大小是128字节,因此共占16个块,inode表 的范围是Block 8-23。 从Block 24开始就是数据块了。块组描述符中指出,空闲的数据块有986个,由于文件系统是新 创建的,空闲块是连续的Block 38-1023,用掉了前面的Block 24-37。从块位图中可以看出, 前37位(前4个字节加最后一个字节的低5位)都是1,就表示Block 1-37已用: 001800 ff ff ff ff 1f 00 00 00 00 00 00 00 00 00 00 00 001810 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 * 001870 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 80 001880 ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff * 在块位图中,Block 38-1023对应的位都是0(一直到001870那一行最后一个字节的低7位), 接下来的位已经超出了文件系统的空间,不管是0还是1都没有意义。可见,块位图每个字节中 的位应该按从低位到高位的顺序来看。以后随着文件系统的使用和添加删除文件,块位图中 的1就变得不连续了。 块组描述符指出,空闲的inode有117个,由于文件系统是新创建的,空闲的inode也是连续 的,inode编号从1到128,空闲的inode编号从12到128。从inode位图可以看出,前11位都 是1,表示前11个inode已用: 001c00 ff 07 00 00 00 00 00 00 00 00 00 00 00 00 00 00 001c10 ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff * 以后随着文件系统的使用和添加删除文件,inode位图中的1就变得不连续了。 001c00这一行的128位就表示了所有inode,因此下面的行不管是0还是1都没有意义。已用 的11个inode中,前10个inode是被ext2文件系统保留的,其中第2个inode是根目录, 第11个inode是lost+found目录,块组描述符也指出该组有两个目录,就是根目录 和lost+found 。 探索文件系统还有一个很有用的工具debugfs,它提供一个命令行界面,可以对文件系统做各种 操作,例如查看信息、恢复数据、修正文件系统中的错误。下面用debugfs打开fs文件,然后在 提示符下输入help看看它都能做哪些事情: $ debugfs fs debugfs 1.40.2 (12-Jul-2007) debugfs: help 在debugfs的提示符下输入stat /命令,这时在新的一屏中显示根目录的inode信息: Inode: 2 Type: directory Mode: 0755 Flags: 0x0 Generation: 0 User: 1000 Group: 1000 Size: 1024 File ACL: 0 Directory ACL: 0 Links: 3 Blockcount: 2 Fragment: Address: 0 Number: 0 Size: 0 ctime: 0x4764cc3b -- Sun Dec 16 14:56:59 2007 atime: 0x4764cc3b -- Sun Dec 16 14:56:59 2007 mtime: 0x4764cc3b -- Sun Dec 16 14:56:59 2007 BLOCKS: (0):24 TOTAL: 1 按q退出这一屏,然后用quit命令退出debugfs: debugfs: quit 把以上信息和od命令的输出对照起来分析: 图 29.5. 根目录的inode 上图中的st_mode以八进制表示,包含了文件类型和文件权限,最高位的4表示文件类型为目录 (各种文件类型的编码详见stat(2)),低位的755表示权限。Size是1024,说明根目录现在只有 一个数据块。Links为3表示根目录有三个硬链接,分别是根目录下的.和..,以及lost+found子 目录下的..。注意,虽然我们通常用/表示根目录,但是并没有名为/的硬链接,事实上,/是路 径分隔符,不能在文件名中出现。这里的Blockcount是以512字节为一个块来数的,并非格式化 文件系统时所指定的块大小,磁盘的最小读写单位称为扇区(Sector),通常是512字节,所 以Blockcount是磁盘的物理块数量,而非分区的逻辑块数量。根目录数据块的位置由上图中 的Blocks[0]指出,也就是第24个块,它在文件系统中的位置是24×0x400=0x6000,从od命令的 输出中找到006000地址,它的格式是这样: 图 29.6. 根目录的数据块 目录的数据块由许多不定长的记录组成,每条记录描述该目录下的一个文件,在上图中用框表 示。第一条记录描述inode号为2的文件,也就是根目录本身,该记录的总长度为12字节,其中 文件名的长度为1字节,文件类型为2(见下表,注意此处的文件类型编码和st_mode不一致), 文件名是.。 表 29.1. 目录中的文件类型编码 编码 文件类型 0 Unknown 1 Regular file 2 Directory 3 Character device 4 Block device 5 Named pipe 6 Socket 7 Symbolic link 第二条记录也是描述inode号为2的文件(根目录),该记录总长度为12字节,其中文件名的长 度为2字节,文件类型为2,文件名字符串是..。第三条记录一直延续到该数据块的末尾,描 述inode号为11的文件(lost+found目录),该记录的总长度为1000字节(和前面两条记录加起 来是1024字节),文件类型为2,文件名字符串是lost+found,后面全是0字节。如果要在根目 录下创建新的文件,可以把第三条记录截短,在原来的0字节处创建新的记录。如果该目录下的 文件名太多,一个数据块不够用,则会分配新的数据块,块编号会填充到inode的Blocks[1]字 段。 debugfs也提供了cd、ls等命令,不需要mount就可以查看这个文件系统中的目录,例如用ls查 看根目录: 2 (12) . 2 (12) .. 11 (1000) lost+found 列出了inode号、记录长度和文件名,这些信息都是从根目录的数据块中读出来的。 习题 1、请读者仿照对根目录的分析,自己分析lost+found目录的inode和数据块的格式。 2、mount这个文件系统,在里面添加删除文件,然后umount下来,再次分析它的格式,和原来 的结果比较一下看哪些字节发生了变化。 2.3. 数据块寻址 如果一个文件有多个数据块,这些数据块很可能不是连续存放的,应该如何寻址到每个块呢? 根据上面的分析,根目录的数据块是通过其inode中的索引项Blocks[0]找到的,事实上,这样 的索引项一共有15个,从Blocks[0]到Blocks[14],每个索引项占4字节。前12个索引项都表示 块编号,例如上面的例子中Blocks[0]字段保存着24,就表示第24个块是该文件的数据块,如果 块大小是1KB,这样可以表示从0字节到12KB的文件。如果剩下的三个索引 项Blocks[12]到Blocks[14]也是这么用的,就只能表示最大15KB的文件了,这是远远不够的, 事实上,剩下的三个索引项都是间接索引。 索引项Blocks[12]所指向的块并非数据块,而是称为间接寻址块(Indirect Block),其中存放 的都是类似Blocks[0]这种索引项,再由索引项指向数据块。设块大小是b,那么一个间接寻址 块中可以存放b/4个索引项,指向b/4个数据块。所以如果把Blocks[0]到Blocks[12]都用上,最 多可以表示b/4+12个数据块,对于块大小是1K的情况,最大可表示268K的文件。如下图所示, 注意文件的数据块编号是从0开始的,Blocks[0]指向第0个数据块,Blocks[11]指向第11个数据 块,Blocks[12]所指向的间接寻址块的第一个索引项指向第12个数据块,依此类推。 图 29.7. 数据块的寻址 从上图可以看出,索引项Blocks[13]指向两级的间接寻址块,最多可表示(b/4)2+b/4+12个数据 块,对于1K的块大小最大可表示64.26MB的文件。索引项Blocks[14]指向三级的间接寻址块, 最多可表示(b/4)3+(b/4)2+b/4+12个数据块,对于1K的块大小最大可表示16.06GB的文件。 可见,这种寻址方式对于访问不超过12个数据块的小文件是非常快的,访问文件中的任意数据 只需要两次读盘操作,一次读inode(也就是读索引项)一次读数据块。而访问大文件中的数据 则需要最多五次读盘操作:inode、一级间接寻址块、二级间接寻址块、三级间接寻址块、数据 块。实际上,磁盘中的inode和数据块往往已经被内核缓存了,读大文件的效率也不会太低。 2.4. 文件和目录操作的系统函数 本节简要介绍一下文件和目录操作常用的系统函数,常用的文件操作命令如ls、cp、mv等也是 基于这些函数实现的。本节的侧重点在于讲解这些函数的工作原理,而不是如何使用它们,理 解了实现原理之后再看这些函数的用法就很简单了,请读者自己查阅Man Page了解其用法。 stat(2)函数读取文件的inode,然后把inode中的各种文件属性填入一个struct stat结构体传出 给调用者。stat(1)命令是基于stat函数实现的。stat需要根据传入的文件路径找到inode,假 设一个路径是/opt/file,则查找的顺序是: 1. 读出inode表中第2项,也就是根目录的inode,从中找出根目录数据块的位置 2. 从根目录的数据块中找出文件名为opt的记录,从记录中读出它的inode号 3. 读出opt目录的inode,从中找出它的数据块的位置 4. 从opt目录的数据块中找出文件名为file的记录,从记录中读出它的inode号 5. 读出file文件的inode 还有另外两个类似stat的函数:fstat(2)函数传入一个已打开的文件描述符,传出inode信 息,lstat(2)函数也是传入路径传出inode信息,但是和stat函数有一点不同,当文件是一个符 号链接时,stat(2)函数传出的是它所指向的目标文件的inode,而lstat函数传出的就是符号链 接文件本身的inode。 access(2)函数检查执行当前进程的用户是否有权限访问某个文件,传入文件路径和要执行的访 问操作(读/写/执行),access函数取出文件inode中的st_mode字段,比较一下访问权限,然后 返回0表示允许访问,返回-1表示错误或不允许访问。 chmod(2)和fchmod(2)函数改变文件的访问权限,也就是修改inode中的st_mode字段。这两个函 数的区别类似于stat/fstat。chmod(1)命令是基于chmod函数实现的。 chown(2)/fchown(2)/lchown(2)改变文件的所有者和组,也就是修改inode中的User和Group字 段,只有超级用户才能正确调用这几个函数,这几个函数之间的区别类似 于stat / fstat / lstat 。chown(1) 命令是基于chown 函数实现的。 utime(2)函数改变文件的访问时间和修改时间,也就是修改inode中的atime和mtime字 段。touch(1)命令是基于utime函数实现的。 truncate(2)和ftruncate(2)函数把文件截断到某个长度,如果新的长度比原来的长度短,则后 面的数据被截掉了,如果新的长度比原来的长度长,则后面多出来的部分用0填充,这需要修 改inode中的Blocks索引项以及块位图中相应的bit。这两个函数的区别类似于stat/fstat。 link(2)函数创建硬链接,其原理是在目录的数据块中添加一条新记录,其中的inode号字段和 原文件相同。symlink(2)函数创建一个符号链接,这需要创建一个新的inode,其中st_mode字段 的文件类型是符号链接,原文件的路径保存在inode中或者分配一个数据块来保存。ln(1)命令 是基于link和symlink函数实现的。 unlink(2)函数删除一个链接。如果是符号链接则释放这个符号链接的inode和数据块,清 除inode位图和块位图中相应的位。如果是硬链接则从目录的数据块中清除一条文件名记录,如 果当前文件的硬链接数已经是1了还要删除它,就同时释放它的inode和数据块,清除inode位图 和块位图中相应的位,这样就真的删除文件了。unlink(1)命令和rm(1)命令是基于unlink函数实 现的。 rename(2)函数改变文件名,需要修改目录数据块中的文件名记录,如果原文件名和新文件名不 在一个目录下则需要从原目录数据块中清除一条记录然后添加到新目录的数据块中。mv(1)命令 是基于rename函数实现的,因此在同一分区的不同目录中移动文件并不需要复制和删除文件 的inode和数据块,只需要一个改名操作,即使要移动整个目录,这个目录下有很多子目录和文 件也要随着一起移动,移动操作也只是对顶级目录的改名操作,很快就能完成。但是,如果在 不同的分区之间移动文件就必须复制和删除inode和数据块,如果要移动整个目录,所有子目录 和文件都要复制删除,这就很慢了。 readlink(2)函数读取一个符号链接所指向的目标路径,其原理是从符号链接的inode或数据块中 读出保存的数据,这就是目标路径。 mkdir(2)函数创建新的目录,要做的操作是在它的父目录数据块中添加一条记录,然后分配新 的inode和数据块,inode的st_mode字段的文件类型是目录,在数据块中填两个记录,分别 是.和..,由于..表示父目录,因此父目录的硬链接数要加1。mkdir(1)命令是基于mkdir函数实 现的。 rmdir(2)函数删除一个目录,这个目录必须是空的(只包含.和..)才能删除,要做的操作是释 放它的inode和数据块,清除inode位图和块位图中相应的位,清除父目录数据块中的记录,父 目录的硬链接数要减1。rmdir(1)命令是基于rmdir函数实现的。 opendir(3) / readdir(3) / closedir(3)用于遍历目录数据块中的记录。opendir打开一个目录,返 回一个DIR *指针代表这个目录,它是一个类似FILE *指针的句柄,closedir用于关闭这个句 柄,把DIR *指针传给readdir读取目录数据块中的记录,每次返回一个指向struct dirent的指 针,反复读就可以遍历所有记录,所有记录遍历完之后readdir返回NULL。结构体struct dirent的定义如下: struct dirent { ino_t d_ino; /* inode number */ off_t d_off; /* offset to the next dirent */ unsigned short d_reclen; /* length of this record */ unsigned char d_type; /* type of file */ char d_name[256]; /* filename */ }; 这些字段和图 29.6 “根目录的数据块”基本一致。这里的文件名d_name被库函数处理过,已经在 结尾加了'\0',而图 29.6 “根目录的数据块”中的文件名字段不保证是以'\0'结尾的,需要根据前面 的文件名长度字段确定文件名到哪里结束。 下面这个例子出自[K&R],作用是递归地打印出一个目录下的所有子目录和文件,类似ls -R。 例 29.1. 递归列出目录中的文件列表 #include #include #include #include #include #include #define MAX_PATH 1024 /* dirwalk: apply fcn to all files in dir */ void dirwalk(char *dir, void (*fcn)(char *)) { char name[MAX_PATH]; struct dirent *dp; DIR *dfd; if ((dfd = opendir(dir)) == NULL) { fprintf(stderr, "dirwalk: can't open %s\n", dir); return; } while ((dp = readdir(dfd)) != NULL) { if (strcmp(dp->d_name, ".") == 0 || strcmp(dp->d_name, "..") == 0) continue; /* skip self and parent */ if (strlen(dir)+strlen(dp->d_name)+2 > sizeof(name)) fprintf(stderr, "dirwalk: name %s %s too long\n", dir, dp->d_name); else { sprintf(name, "%s/%s", dir, dp- >d_name); (*fcn)(name); } } closedir(dfd); } /* fsize: print the size and name of file "name" */ void fsize(char *name) { struct stat stbuf; if (stat(name, &stbuf) == -1) { fprintf(stderr, "fsize: can't access %s\n", name); return; } if ((stbuf.st_mode & S_IFMT) == S_IFDIR) dirwalk(name, fsize); printf("%8ld %s\n", stbuf.st_size, name); } int main(int argc, char **argv) { if (argc == 1) /* default: current directory */ fsize("."); else while (--argc > 0) fsize(*++argv); return 0; } 然而这个程序还是不如ls -R健壮,它有可能死循环,思考一下什么情况会导致死循环。 上一页 1. 引言 上一级 起始页 下一页 3. VFS 上一页 3. VFS 第 29 章 文件系统 下一页 3. VFS Linux支持各种各样的文件系统格式,如ext2、ext3、reiserfs、FAT、NTFS、iso9660等等,不 同的磁盘分区、光盘或其它存储设备都有不同的文件系统格式,然而这些文件系统都可 以mount到某个目录下,使我们看到一个统一的目录树,各种文件系统上的目录和文件我们 用ls命令看起来是一样的,读写操作用起来也都是一样的,这是怎么做到的呢?Linux内核在各 种不同的文件系统格式之上做了一个抽象层,使得文件、目录、读写访问等概念成为抽象层的 概念,因此各种文件系统看起来用起来都一样,这个抽象层称为虚拟文件系统(VFS,Virtual Filesystem)。上一节我们介绍了一种典型的文件系统在磁盘上的存储布局,这一节我们介绍运 行时文件系统在内核中的表示。 3.1. 内核数据结构 Linux内核的VFS子系统可以图示如下: 图 29.8. VFS 在第 28 章 文件与I/O中讲过,每个进程在PCB(Process Control Block)中都保存着一份文件 描述符表,文件描述符就是这个表的索引,每个表项都有一个指向已打开文件的指针,现在我 们明确一下:已打开的文件在内核中用file结构体表示,文件描述符表中的指针指向file结构 体。 在file结构体中维护File Status Flag(file结构体的成员f_flags)和当前读写位置(file结构 体的成员f_pos)。在上图中,进程1和进程2都打开同一文件,但是对应不同的file结构体,因 此可以有不同的File Status Flag和读写位置。file结构体中比较重要的成员还有f_count,表示 引用计数(Reference Count),后面我们会讲到,dup、fork等系统调用会导致多个文件描述 符指向同一个file结构体,例如有fd1和fd2都引用同一个file结构体,那么它的引用计数就 是2,当close(fd1)时并不会释放file结构体,而只是把引用计数减到1,如果再close(fd2), 引用计数就会减到0同时释放file结构体,这才真的关闭了文件。 每个file结构体都指向一个file_operations结构体,这个结构体的成员都是函数指针,指向实 现各种文件操作的内核函数。比如在用户程序中read一个文件描述符,read通过系统调用进入 内核,然后找到这个文件描述符所指向的file结构体,找到file结构体所指向 的file_operations结构体,调用它的read成员所指向的内核函数以完成用户请求。在用户程序 中调用lseek 、read 、write 、ioctl 、open 等函数,最终都由内核调用file_operations 的各成员 所指向的内核函数完成用户请求。file_operations结构体中的release成员用于完成用户程序 的close请求,之所以叫release而不叫close是因为它不一定真的关闭文件,而是减少引用计 数,只有引用计数减到0才关闭文件。对于同一个文件系统上打开的常规文件来 说,read、write等文件操作的步骤和方法应该是一样的,调用的函数应该是相同的,所以图中 的三个打开文件的file结构体指向同一个file_operations结构体。如果打开一个字符设备文 件,那么它的read、write操作肯定和常规文件不一样,不是读写磁盘的数据块而是读写硬件设 备,所以file结构体应该指向不同的file_operations结构体,其中的各种文件操作函数由该设 备的驱动程序实现。 每个file结构体都有一个指向dentry结构体的指针,“dentry”是directory entry(目录项)的缩 写。我们传给open、stat等函数的参数的是一个路径,例如/home/akaedu/a,需要根据路径找到 文件的inode。为了减少读盘次数,内核缓存了目录的树状结构,称为dentry cache,其中每个 节点是一个dentry结构体,只要沿着路径各部分的dentry搜索即可,从根目录/找到home目录, 然后找到akaedu目录,然后找到文件a。dentry cache只保存最近访问过的目录项,如果要找的 目录项在cache中没有,就要从磁盘读到内存中。 每个dentry结构体都有一个指针指向inode结构体。inode结构体保存着从磁盘inode读上来的信 息。在上图的例子中,有两个dentry,分别表示/home/akaedu/a和/home/akaedu/b,它们都指向 同一个inode,说明这两个文件互为硬链接。inode结构体中保存着从磁盘分区的inode读上来信 息,例如所有者、文件大小、文件类型和权限位等。每个inode结构体都有一个指 向inode_operations结构体的指针,后者也是一组函数指针指向一些完成文件目录操作的内核函 数。和file_operations不同,inode_operations所指向的不是针对某一个文件进行操作的函 数,而是影响文件和目录布局的函数,例如添加删除文件和目录、跟踪符号链接等等,属于同 一文件系统的各inode结构体可以指向同一个inode_operations结构体。 inode结构体有一个指向super_block结构体的指针。super_block结构体保存着从磁盘分区的超 级块读上来的信息,例如文件系统类型、块大小等。super_block结构体的s_root成员是一个指 向dentry的指针,表示这个文件系统的根目录被mount到哪里,在上图的例子中这个分区 被mount 到/home 目录下。 file、dentry、inode、super_block这几个结构体组成了VFS的核心概念。对于ext2文件系统来 说,在磁盘存储布局上也有inode和超级块的概念,所以很容易和VFS中的概念建立对应关系。 而另外一些文件系统格式来自非UNIX系统(例如Windows的FAT32、NTFS),可能没 有inode或超级块这样的概念,但为了能mount到Linux系统,也只好在驱动程序中硬凑一下, 在Linux下看FAT32和NTFS分区会发现权限位是错的,所有文件都是rwxrwxrwx,因为它们本来 就没有inode和权限位的概念,这是硬凑出来的。 3.2. dup和dup2函数 dup和dup2都可用来复制一个现存的文件描述符,使两个文件描述符指向同一个file结构体。如 果两个文件描述符指向同一个file结构体,File Status Flag和读写位置只保存一份在file结构 体中,并且file结构体的引用计数是2。如果两次open同一文件得到两个文件描述符,则每个描 述符对应一个不同的file结构体,可以有不同的File Status Flag和读写位置。请注意区分这两 种情况。 #include int dup(int oldfd); int dup2(int oldfd, int newfd); 如果调用成功,这两个函数都返回新分配或指定的文件描述符,如果出错则返回-1。dup返回的 新文件描述符一定该进程未使用的最小文件描述符,这一点和open类似。dup2可以用newfd参数 指定新描述符的数值。如果newfd当前已经打开,则先将其关闭再做dup2操作,如果oldfd等 于newfd,则dup2直接返回newfd而不用先关闭newfd再复制。 下面这个例子演示了dup和dup2函数的用法,请结合后面的连环画理解程序的执行过程。 例 29.2. dup和dup2示例程序 #include #include #include #include #include #include int main(void) { int fd, save_fd; char msg[] = "This is a test\n"; fd = open("somefile", O_RDWR|O_CREAT, S_IRUSR|S_IWUSR); if(fd<0) { perror("open"); exit(1); } save_fd = dup(STDOUT_FILENO); dup2(fd, STDOUT_FILENO); close(fd); write(STDOUT_FILENO, msg, strlen(msg)); dup2(save_fd, STDOUT_FILENO); write(STDOUT_FILENO, msg, strlen(msg)); close(save_fd); return 0; } 图 29.9. dup/dup2示例程序 重点解释两个地方: 第3幅图,要执行dup2(fd, 1);,文件描述符1原本指向tty,现在要指向新的文 件somefile,就把原来的关闭了,但是tty这个文件原本有两个引用计数,还有文件描述 符save_fd也指向它,所以只是将引用计数减1,并不真的关闭文件。 第5幅图,要执行dup2(save_fd, 1);,文件描述符1原本指向somefile,现在要指向新的 文件tty,就把原来的关闭了,somefile原本只有一个引用计数,所以这次减到0,是真的 关闭了。 上一页 2. ext2文件系统 上一级 起始页 下一页 第 30 章 进程 上一页 第 30 章 进程 目录 1. 引言 2. 环境变量 3. 进程控制 3.1. fork函数 3.2. exec函数 3.3. wait和waitpid函数 4. 进程间通信 4.1. 管道 4.2. 其它IPC机制 5. 练习:实现简单的Shell 上一页 3. VFS 第 30 章 进程 部分 III. Linux系统编程 上一级 起始页 下一页 下一页 1. 引言 上一页 1. 引言 第 30 章 进程 下一页 1. 引言 我们知道,每个进程在内核中都有一个进程控制块(PCB)来维护进程相关的信息,Linux内核 的进程控制块是task_struct结构体。现在我们全面了解一下其中都有哪些信息。 进程id。系统中每个进程有唯一的id,在C语言中用pid_t类型表示,其实就是一个非负整 数。 进程的状态,有运行、挂起、停止、僵尸等状态。 进程切换时需要保存和恢复的一些CPU寄存器。 描述虚拟地址空间的信息。 描述控制终端的信息。 当前工作目录(Current Working Directory)。 umask 掩码。 文件描述符表,包含很多指向file结构体的指针。 和信号相关的信息。 用户id和组id。 控制终端、Session和进程组。 进程可以使用的资源上限(Resource Limit)。 目前读者并不需要理解这些信息的细节,在随后几章中讲到某一项时会再次提醒读者它是保存 在PCB中的。 fork和exec是本章要介绍的两个重要的系统调用。fork的作用是根据一个现有的进程复制出一 个新进程,原来的进程称为父进程(Parent Process),新进程称为子进程(Child Process)。系统中同时运行着很多进程,这些进程都是从最初只有一个进程开始一个一个复制 出来的。在Shell下输入命令可以运行一个程序,是因为Shell进程在读取用户输入的命令之后会 调用fork复制出一个新的Shell进程,然后新的Shell进程调用exec执行新的程序。 我们知道一个程序可以多次加载到内存,成为同时运行的多个进程,例如可以同时开多个终端 窗口运行/bin/bash,另一方面,一个进程在调用exec前后也可以分别执行两个不同的程序,例 如在Shell提示符下输入命令ls,首先fork创建子进程,这时子进程仍在执行/bin/bash程序,然 后子进程调用exec执行新的程序/bin/ls,如下图所示。 图 30.1. fork/exec 在第 3 节 “open/close”中我们做过一个实验:用umask命令设置Shell进程的umask掩码,然后运 行程序a.out,结果a.out进程的umask掩码也和Shell进程一样。现在可以解释了,因为a.out进 程是Shell进程的子进程,子进程的PCB是根据父进程复制而来的,所以其中的umask掩码也和父 进程一样。同样道理,子进程的当前工作目录也和父进程一样,所以我们可以用cd命令改 变Shell进程的当前目录,然后用ls命令列出那个目录下的文件,ls进程其实是在列自己的当前 目录,而不是Shell进程的当前目录,只不过ls进程的当前目录正好和Shell进程相同。有一个例 外,子进程PCB中的进程id和父进程是不同的。 上一页 第 30 章 进程 上一级 起始页 下一页 2. 环境变量 上一页 2. 环境变量 第 30 章 进程 下一页 2. 环境变量 先前讲过,exec系统调用执行新程序时会把命令行参数和环境变量表传递给main函数,它们在 整个进程地址空间中的位置如下图所示。 图 30.2. 进程地址空间 和命令行参数argv类似,环境变量表也是一组字符串,如下图所示。 图 30.3. 环境变量 libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用 时要用extern声明。例如: 例 30.1. 打印环境变量 #include int main(void) { extern char **environ; int i; for(i=0; environ[i]!=NULL; i++) printf("%s\n", environ[i]); return 0; } 执行结果为 $ ./a.out SSH_AGENT_PID=5717 SHELL=/bin/bash DESKTOP_STARTUP_ID= TERM=xterm ... 由于父进程在调用fork创建子进程时会把自己的环境变量表也复制给子进程,所以a.out打印的 环境变量和Shell进程的环境变量是相同的。 按照惯例,环境变量字符串都是name=value这样的形式,大多数name由大写字母加下划线组 成,一般把name的部分叫做环境变量,value的部分则是环境变量的值。环境变量定义了进程的 运行环境,一些比较重要的环境变量的含义如下: PATH 可执行文件的搜索路径。ls命令也是一个程序,执行它不需要提供完整的路径 名/bin/ls,然而通常我们执行当前目录下的程序a.out却需要提供完整的路径名./a.out, 这是因为PATH环境变量的值里面包含了ls命令所在的目录/bin,却不包含a.out所在的目 录。PATH环境变量的值可以包含多个目录,用:号隔开。在Shell中用echo命令可以查看这 个环境变量的值: $ echo $PATH /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games SHELL 当前Shell,它的值通常是/bin/bash。 TERM 当前终端类型,在图形界面终端下它的值通常是xterm,终端类型决定了一些程序的输出 显示方式,比如图形界面终端可以显示汉字,而字符终端一般不行。 LANG 语言和locale,决定了字符编码以及时间、货币等信息的显示格式。 HOME 当前用户主目录的路径,很多程序需要在主目录下保存配置文件,使得每个用户在运行该 程序时都有自己的一套配置。 用environ指针可以查看所有环境变量字符串,但是不够方便,如果给出name要在环境变量表中 查找它对应的value,可以用getenv函数。 #include char *getenv(const char *name); getenv的返回值是指向value的指针,若未找到则为NULL。 修改环境变量可以用以下函数 #include int setenv(const char *name, const char *value, int rewrite); void unsetenv(const char *name); putenv和setenv函数若成功则返回为0,若出错则返回非0。 setenv将环境变量name的值设置为value。如果已存在环境变量name,那么 若rewrite非0,则覆盖原来的定义; 若rewrite为0,则不覆盖原来的定义,也不返回错误。 unsetenv删除name的定义。即使name没有定义也不返回错误。 例 30.2. 修改环境变量 #include #include int main(void) { printf("PATH=%s\n", getenv("PATH")); setenv("PATH", "hello", 1); printf("PATH=%s\n", getenv("PATH")); return 0; } $ ./a.out PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games PATH=hello $ echo $PATH /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games 可以看出,Shell进程的环境变量PATH传给了a.out,然后a.out修改了PATH的值,在a.out中能打 印出修改后的值,但在Shell进程中PATH的值没变。父进程在创建子进程时会复制一份环境变量 给子进程,但此后二者的环境变量互不影响。 上一页 1. 引言 上一级 起始页 下一页 3. 进程控制 上一页 3. 进程控制 3.1. fork函数 3. 进程控制 第 30 章 进程 下一页 #include #include pid_t fork(void); fork调用失败则返回-1,调用成功的返回值见下面的解释。我们通过一个例子来理解fork是怎 样创建新进程的。 例 30.3. fork #include #include #include #include int main(void) { pid_t pid; char *message; int n; pid = fork(); if (pid < 0) { perror("fork failed"); exit(1); } if (pid == 0) { message = "This is the child\n"; n = 6; } else { message = "This is the parent\n"; n = 3; } for(; n > 0; n--) { printf(message); sleep(1); } return 0; } $ ./a.out This is the child This is the parent This is the child This is the parent This is the child This is the parent This is the child $ This is the child This is the child 这个程序的运行过程如下图所示。 图 30.4. fork 1. 父进程初始化。 2. 父进程调用fork,这是一个系统调用,因此进入内核。 3. 内核根据父进程复制出一个子进程,父进程和子进程的PCB信息相同,用户态代码和数据 也相同。因此,子进程现在的状态看起来和父进程一样,做完了初始化,刚调用了fork进 入内核,还没有从内核返回。 4. 现在有两个一模一样的进程看起来都调用了fork进入内核等待从内核返回(实际上fork只 调用了一次),此外系统中还有很多别的进程也等待从内核返回。是父进程先返回还是子 进程先返回,还是这两个进程都等待,先去调度执行别的进程,这都不一定,取决于内核 的调度算法。 5. 如果某个时刻父进程被调度执行了,从内核返回后就从fork函数返回,保存在变量pid中 的返回值是子进程的id,是一个大于0的整数,因此执下面的else分支,然后执行for循 环,打印"This is the parent\n"三次之后终止。 6. 如果某个时刻子进程被调度执行了,从内核返回后就从fork函数返回,保存在变量pid中 的返回值是0,因此执行下面的if (pid == 0)分支,然后执行for循环,打印"This is the child\n"六次之后终止。fork调用把父进程的数据复制一份给子进程,但此后二者互 不影响,在这个例子中,fork调用之后父进程和子进程的变量message和n被赋予不同的 值,互不影响。 7. 父进程每打印一条消息就睡眠1秒,这时内核调度别的进程执行,在1秒这么长的间隙里 (对于计算机来说1秒很长了)子进程很有可能被调度到。同样地,子进程每打印一条消 息就睡眠1秒,在这1秒期间父进程也很有可能被调度到。所以程序运行的结果基本上是父 子进程交替打印,但这也不是一定的,取决于系统中其它进程的运行情况和内核的调度算 法,如果系统中其它进程非常繁忙则有可能观察到不同的结果。另外,读者也可以 把sleep(1);去掉看程序的运行结果如何。 8. 这个程序是在Shell下运行的,因此Shell进程是父进程的父进程。父进程运行时Shell进程 处于等待状态(第 3.3 节 “wait和waitpid函数”会讲到这种等待是怎么实现的),当父进程 终止时Shell进程认为命令执行结束了,于是打印Shell提示符,而事实上子进程这时还没 结束,所以子进程的消息打印到了Shell提示符后面。最后光标停在This is the child的 下一行,这时用户仍然可以敲命令,即使命令不是紧跟在提示符后面,Shell也能正确读 取。 fork函数的特点概括起来就是“调用一次,返回两次”,在父进程中调用一次,在父进程和子进程 中各返回一次。从上图可以看出,一开始是一个控制流程,调用fork之后发生了分叉,变成两 个控制流程,这也就是“fork”(分叉)这个名字的由来了。子进程中fork的返回值是0,而父进 程中fork的返回值则是子进程的id(从根本上说fork是从内核返回的,内核自有办法让父进程和 子进程返回不同的值),这样当fork函数返回后,程序员可以根据返回值的不同让父进程和子 进程执行不同的代码。 fork的返回值这样规定是有道理的。fork在子进程中返回0,子进程仍可以调用getpid函数得到 自己的进程id,也可以调用getppid函数得到父进程的id。在父进程中用getpid可以得到自己的 进程id,然而要想得到子进程的id,只有将fork的返回值记录下来,别无它法。 fork的另一个特性是所有由父进程打开的描述符都被复制到子进程中。父、子进程中相同编号 的文件描述符在内核中指向同一个file结构体,也就是说,file结构体的引用计数要增加。 用gdb调试多进程的程序会遇到困难,gdb只能跟踪一个进程(默认是跟踪父进程),而不能同 时跟踪多个进程,但可以设置gdb在fork之后跟踪父进程还是子进程。以上面的程序为例: $ gcc main.c -g djkings@djkings-desktop:~$ gdb a.out GNU gdb 6.8-debian Copyright (C) 2008 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. Type "show copying" and "show warranty" for details. This GDB was configured as "i486-linux-gnu"... (gdb) l 2 #include 3 #include 4 #include 5 6 int main(void) 7 { 8 pid_t pid; 9 char *message; 10 int n; 11 pid = fork(); (gdb) 12 if(pid<0) { 13 perror("fork failed"); 14 exit(1); 15 } 16 if(pid==0) { 17 message = "This is the child\n"; 18 n = 6; 19 } else { 20 message = "This is the parent\n"; 21 n = 3; (gdb) b 17 Breakpoint 1 at 0x8048481: file main.c, line 17. (gdb) set follow-fork-mode child (gdb) r Starting program: /home/djkings/a.out This is the parent [Switching to process 30725] Breakpoint 1, main () at main.c:17 17 message = "This is the child\n"; (gdb) This is the parent This is the parent set follow-fork-mode child命令设置gdb在fork之后跟踪子进程(set follow-fork-mode parent则是跟踪父进程),然后用run命令,看到的现象是父进程一直在运行,在(gdb)提示符 下打印消息,而子进程被先前设的断点打断了。 3.2. exec函数 用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程 往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间 代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所 以调用exec前后该进程的id并未改变。 其实有六种以exec开头的函数,统称exec函数: #include int execl(const char *path, const char *arg, ...); int execlp(const char *file, const char *arg, ...); int execle(const char *path, const char *arg, ..., char *const envp[]); int execv(const char *path, char *const argv[]); int execvp(const char *file, char *const argv[]); int execve(const char *path, char *const argv[], char *const envp[]); 这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回,如果调用出错则返回1,所以exec函数只有出错的返回值而没有成功的返回值。 这些函数原型看起来很容易混,但只要掌握了规律就很好记。不带字母p(表示path)的exec函 数第一个参数必须是程序的相对路径或绝对路径,例如"/bin/ls"或"./a.out",而不能 是"ls"或"a.out"。对于带字母p的函数: 如果参数中包含/,则将其视为路径名。 否则视为不带路径的程序名,在PATH环境变量的目录列表中搜索这个程序。 带有字母l(表示list)的exec函数要求将新程序的每个命令行参数都当作一个参数传给它,命令 行参数的个数是可变的,因此函数原型中有...,...中的最后一个可变参数应该是NULL, 起sentinel的作用。对于带有字母v(表示vector)的函数,则应该先构造一个指向各参数的指针 数组,然后将该数组的首地址当作参数传给它,数组中的最后一个指针也应该是NULL,就 像main函数的argv参数或者环境变量表一样。 对于以e(表示environment)结尾的exec函数,可以把一份新的环境变量表传给它,其 他exec函数仍使用当前的环境变量表执行新程序。 exec调用举例如下: char *const ps_argv[] ={"ps", "-o", "pid,ppid,pgrp,session,tpgid,comm", NULL}; char *const ps_envp[] ={"PATH=/bin:/usr/bin", "TERM=console", NULL}; execl("/bin/ps", "ps", "-o", "pid,ppid,pgrp,session,tpgid,comm", NULL); execv("/bin/ps", ps_argv); execle("/bin/ps", "ps", "-o", "pid,ppid,pgrp,session,tpgid,comm", NULL, ps_envp); execve("/bin/ps", ps_argv, ps_envp); execlp("ps", "ps", "-o", "pid,ppid,pgrp,session,tpgid,comm", NULL); execvp("ps", ps_argv); 事实上,只有execve是真正的系统调用,其它五个函数最终都调用execve,所以execve在man手 册第2节,其它函数在man手册第3节。这些函数之间的关系如下图所示。 图 30.5. exec函数族 一个完整的例子: #include #include int main(void) { execlp("ps", "ps", "-o", "pid,ppid,pgrp,session,tpgid,comm", NULL); perror("exec ps"); exit(1); } 执行此程序则得到: $ ./a.out PID PPID 6614 6608 7199 6614 PGRP 6614 7199 SESS TPGID COMMAND 6614 7199 bash 6614 7199 ps 由于exec函数只有错误返回值,只要返回了一定是出错了,所以不需要判断它的返回值,直接 在后面调用perror即可。注意在调用execlp时传了两个"ps"参数,第一个"ps"是程序 名,execlp函数要在PATH环境变量中找到这个程序并执行它,而第二个"ps"是第一个命令行参 数,execlp函数并不关心它的值,只是简单地把它传给ps程序,ps程序可以通过main函数 的argv[0]取到这个参数。 调用exec后,原来打开的文件描述符仍然是打开的[35]。利用这一点可以实现I/O重定向。先看一 个简单的例子,把标准输入转成大写然后打印到标准输出: 例 30.4. upper /* upper.c */ #include int main(void) { int ch; while((ch = getchar()) != EOF) { putchar(toupper(ch)); } return 0; } 运行结果如下: $ ./upper hello THERE HELLO THERE (按Ctrl-D表示EOF) $ 使用Shell重定向: $ cat file.txt this is the file, file.txt, it is all lower case. $ ./upper < file.txt THIS IS THE FILE, FILE.TXT, IT IS ALL LOWER CASE. 如果希望把待转换的文件名放在命令行参数中,而不是借助于输入重定向,我们可以利 用upper程序的现有功能,再写一个包装程序wrapper。 例 30.5. wrapper /* wrapper.c */ #include #include #include #include int main(int argc, char *argv[]) { int fd; if (argc != 2) { fputs("usage: wrapper file\n", stderr); exit(1); } fd = open(argv[1], O_RDONLY); if(fd<0) { perror("open"); exit(1); } dup2(fd, STDIN_FILENO); close(fd); execl("./upper", "upper", NULL); perror("exec ./upper"); exit(1); } wrapper程序将命令行参数当作文件名打开,将标准输入重定向到这个文件,然后调用exec执 行upper程序,这时原来打开的文件描述符仍然是打开的,upper程序只负责从标准输入读入字 符转成大写,并不关心标准输入对应的是文件还是终端。运行结果如下: $ ./wrapper file.txt THIS IS THE FILE, FILE.TXT, IT IS ALL LOWER CASE. 3.3. wait和waitpid函数 一个进程在终止时会关闭所有文件描述符,释放在用户空间分配的内存,但它的PCB还保留 着,内核在其中保存了一些信息:如果是正常终止则保存着退出状态,如果是异常终止则保存 着导致该进程终止的信号是哪个。这个进程的父进程可以调用wait或waitpid获取这些信息,然 后彻底清除掉这个进程。我们知道一个进程的退出状态可以在Shell中用特殊变量$?查看,因 为Shell是它的父进程,当它终止时Shell调用wait或waitpid得到它的退出状态同时彻底清除掉 这个进程。 如果一个进程已经终止,但是它的父进程尚未调用wait或waitpid对它进行清理,这时的进程状 态称为僵尸(Zombie)进程。任何进程在刚终止时都是僵尸进程,正常情况下,僵尸进程都立 刻被父进程清理了,为了观察到僵尸进程,我们自己写一个不正常的程序,父进程fork出子进 程,子进程终止,而父进程既不终止也不调用wait清理子进程: #include #include int main(void) { pid_t pid=fork(); if(pid<0) { perror("fork"); exit(1); } if(pid>0) { /* parent */ while(1); } /* child */ return 0; } 在后台运行这个程序,然后用ps命令查看: $ ./a.out & [1] 6130 $ ps u USER PID %CPU %MEM COMMAND akaedu 6016 0.0 0.3 bash akaedu 6130 97.2 0.0 ./a.out akaedu 6131 0.0 0.0 [a.out] akaedu 6163 0.0 0.0 ps u VSZ RSS TTY 5724 3140 pts/0 1536 284 pts/0 0 0 pts/0 2620 1000 pts/0 STAT START TIME Ss 08:41 0:00 R 08:44 14:33 Z 08:44 0:00 R+ 08:59 0:00 在./a.out命令后面加个&表示后台运行,Shell不等待这个进程终止就立刻打印提示符并等待用 户输命令。现在Shell是位于前台的,用户在终端的输入会被Shell读取,后台进程是读不到终端 输入的。第二条命令ps u是在前台运行的,在此期间Shell进程和./a.out进程都在后台运行,等 到ps u命令结束时Shell进程又重新回到前台。在第 33 章 信号和第 34 章 终端、作业控制与守 护进程将会进一步解释前台(Foreground)和后台(Backgroud)的概念。 父进程的pid是6130,子进程是僵尸进程,pid是6131,ps命令显示僵尸进程的状态为Z,在命令 行一栏还显示。 如果一个父进程终止,而它的子进程还存在(这些子进程或者仍在运行,或者已经是僵尸进程 了),则这些子进程的父进程改为init进程。init是系统中的一个特殊进程,通常程序文件 是/sbin/init,进程id是1,在系统启动时负责启动各种系统服务,之后就负责清理子进程,只 要有子进程终止,init就会调用wait函数清理它。 僵尸进程是不能用kill命令清除掉的,因为kill命令只是用来终止进程的,而僵尸进程已经终 止了。思考一下,用什么办法可以清除掉僵尸进程? wait和waitpid函数的原型是: #include #include pid_t wait(int *status); pid_t waitpid(pid_t pid, int *status, int options); 若调用成功则返回清理掉的子进程id,若调用出错则返回-1。父进程调用wait或waitpid时可能 会: 阻塞(如果它的所有子进程都还在运行)。 带子进程的终止信息立即返回(如果一个子进程已终止,正等待父进程读取其终止信 息)。 出错立即返回(如果它没有任何子进程)。 这两个函数的区别是: 如果父进程的所有子进程都还在运行,调用wait将使父进程阻塞,而调用waitpid时如果 在options参数中指定WNOHANG可以使父进程不阻塞而立即返回0。 wait等待第一个终止的子进程,而waitpid可以通过pid参数指定等待哪一个子进程。 可见,调用wait和waitpid不仅可以获得子进程的终止信息,还可以使父进程阻塞等待子进程终 止,起到进程间同步的作用。如果参数status不是空指针,则子进程的终止信息通过这个参数 传出,如果只是为了同步而不关心子进程的终止信息,可以将status参数指定为NULL。 例 30.6. waitpid #include #include #include #include #include int main(void) { pid_t pid; pid = fork(); if (pid < 0) { perror("fork failed"); exit(1); } if (pid == 0) { int i; for (i = 3; i > 0; i--) { printf("This is the child\n"); sleep(1); } exit(3); } else { int stat_val; waitpid(pid, &stat_val, 0); if (WIFEXITED(stat_val)) printf("Child exited with code %d\n", WEXITSTATUS(stat_val)); else if (WIFSIGNALED(stat_val)) printf("Child terminated abnormally, signal %d\n", WTERMSIG(stat_val)); } return 0; } 子进程的终止信息在一个int中包含了多个字段,用宏定义可以取出其中的每个字段:如果子进 程是正常终止的,WIFEXITED取出的字段值非零,WEXITSTATUS取出的字段值就是子进程的退出状 态;如果子进程是收到信号而异常终止的,WIFSIGNALED取出的字段值非零,WTERMSIG取出的字 段值就是信号的编号。作为练习,请读者从头文件里查一下这些宏做了什么运算,是如何取出 字段值的。 习题 1、请读者修改例 30.6 “waitpid”的代码和实验条件,使它产生“Child terminated abnormally”的 输出。 [35] 事实上,在每个文件描述符中有一个close-on-exec标志,如果该标志为1,则调用exec时关 闭这个文件描述符。该标志默认为0,可以用fcntl函数将它置1,本书不讨论该标志为1的情 况。 上一页 2. 环境变量 上一级 起始页 下一页 4. 进程间通信 上一页 4. 进程间通信 第 30 章 进程 下一页 4. 进程间通信 每个进程各自有不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所 以进程之间要交换数据必须通过内核,在内核中开辟一块缓冲区,进程1把数据从用户空间拷到 内核缓冲区,进程2再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信 (IPC,InterProcess Communication)。如下图所示。 图 30.6. 进程间通信 4.1. 管道 管道是一种最基本的IPC机制,由pipe函数创建: #include int pipe(int filedes[2]); 调用pipe函数时在内核中开辟一块缓冲区(称为管道)用于通信,它有一个读端一个写端,然 后通过filedes参数传出给用户程序两个文件描述符,filedes[0]指向管道的读 端,filedes[1]指向管道的写端(很好记,就像0是标准输入1是标准输出一样)。所以管道在 用户程序看起来就像一个打开的文件,通过read(filedes[0]);或者write(filedes[1]);向这个 文件读写数据其实是在读写内核缓冲区。pipe函数调用成功返回0,调用失败返回-1。 开辟了管道之后如何实现两个进程间的通信呢?比如可以按下面的步骤通信。 图 30.7. 管道 1. 父进程调用pipe开辟管道,得到两个文件描述符指向管道的两端。 2. 父进程调用fork创建子进程,那么子进程也有两个文件描述符指向同一管道。 3. 父进程关闭管道读端,子进程关闭管道写端。父进程可以往管道里写,子进程可以从管道 里读,管道是用环形队列实现的,数据从写端流入从读端流出,这样就实现了进程间通 信。 例 30.7. 管道 #include #include #define MAXLINE 80 int main(void) { int n; int fd[2]; pid_t pid; char line[MAXLINE]; if (pipe(fd) < 0) { perror("pipe"); exit(1); } if ((pid = fork()) < 0) { perror("fork"); exit(1); } if (pid > 0) { /* parent */ close(fd[0]); write(fd[1], "hello world\n", 12); wait(NULL); } else { /* child */ close(fd[1]); n = read(fd[0], line, MAXLINE); write(STDOUT_FILENO, line, n); } return 0; } 使用管道有一些限制: 两个进程通过一个管道只能实现单向通信,比如上面的例子,父进程写子进程读,如果有 时候也需要子进程写父进程读,就必须另开一个管道。请读者思考,如果只开一个管道, 但是父进程不关闭读端,子进程也不关闭写端,双方都有读端和写端,为什么不能实现双 向通信? 管道的读写端通过打开的文件描述符来传递,因此要通信的两个进程必须从它们的公共祖 先那里继承管道文件描述符。上面的例子是父进程把文件描述符传给子进程之后父子进程 之间通信,也可以父进程fork两次,把文件描述符传给两个子进程,然后两个子进程之间 通信,总之需要通过fork传递文件描述符使两个进程都能访问同一管道,它们才能通信。 使用管道需要注意以下4种特殊情况(假设都是阻塞I/O操作,没有设置O_NONBLOCK标志): 1. 如果所有指向管道写端的文件描述符都关闭了(管道写端的引用计数等于0),而仍然有 进程从管道的读端读数据,那么管道中剩余的数据都被读取后,再次read会返回0,就像 读到文件末尾一样。 2. 如果有指向管道写端的文件描述符没关闭(管道写端的引用计数大于0),而持有管道写 端的进程也没有向管道中写数据,这时有进程从管道读端读数据,那么管道中剩余的数据 都被读取后,再次read会阻塞,直到管道中有数据可读了才读取数据并返回。 3. 如果所有指向管道读端的文件描述符都关闭了(管道读端的引用计数等于0),这时有进 程向管道的写端write,那么该进程会收到信号SIGPIPE,通常会导致进程异常终止。 在第 33 章 信号会讲到怎样使SIGPIPE信号不终止进程。 4. 如果有指向管道读端的文件描述符没关闭(管道读端的引用计数大于0),而持有管道读 端的进程也没有从管道中读数据,这时有进程向管道写端写数据,那么在管道被写满时再 次write会阻塞,直到管道中有空位置了才写入数据并返回。 管道的这四种特殊情况具有普遍意义。在第 37 章 socket编程要讲的TCP socket也具有管道的 这些特性。 习题 1、在例 30.7 “管道”中,父进程只用到写端,因而把读端关闭,子进程只用到读端,因而把写端 关闭,然后互相通信,不使用的读端或写端必须关闭,请读者想一想如果不关闭会有什么问 题。 2、请读者修改例 30.7 “管道”的代码和实验条件,验证我上面所说的四种特殊情况。 4.2. 其它IPC机制 进程间通信必须通过内核提供的通道,而且必须有一种办法在进程中标识内核提供的某个通 道,上一节讲的管道是用打开的文件描述符来标识的。如果要互相通信的几个进程没有从公共 祖先那里继承文件描述符,它们怎么通信呢?内核提供一条通道不成问题,问题是如何标识这 条通道才能使各进程都可以访问它?文件系统中的路径名是全局的,各进程都可以访问,因此 可以用文件系统中的路径名来标识一个IPC通道。 FIFO和Unix Domain Socket这两种IPC机制都是利用文件系统中的特殊文件来标识的。可以 用mkfifo命令创建一个FIFO文件: $ mkfifo hello $ ls -l hello prw-r--r-- 1 djkings djkings 0 2008-10-30 10:44 hello FIFO文件在磁盘上没有数据块,仅用来标识内核中的一条通道,各进程可以打开这个文件进 行read/write,实际上是在读写内核通道(根本原因在于这个file结构体所指向 的read、write函数和常规文件不一样),这样就实现了进程间通信。Unix Domain Socket和FIFO的原理类似,也需要一个特殊的socket文件来标识内核中的通道,例 如/var/run目录下有很多系统服务的socket文件: $ ls -l /var/run/ total 52 srw-rw-rw- 1 root acpid.socket ...... srw-rw-rw- 1 root gdm_socket ...... srw-rw-rw- 1 root ...... srwxr-xr-x 1 root synaptic.socket root root root root 0 2008-10-30 00:24 0 2008-10-30 00:25 0 2008-10-30 00:24 sdp 0 2008-10-30 00:42 文件类型s表示socket,这些文件在磁盘上也没有数据块。UNIX Domain Socket是目前最广泛 使用的IPC机制,到后面讲socket编程时再详细介绍。 现在把进程之间传递信息的各种途径(包括各种IPC机制)总结如下: 父进程通过fork可以将打开文件的描述符传递给子进程 子进程结束时,父进程调用wait可以得到子进程的终止信息 几个进程可以在文件系统中读写某个共享文件,也可以通过给文件加锁来实现进程间同步 进程之间互发信号,一般使用SIGUSR1和SIGUSR2实现用户自定义功能 管道 FIFO mmap函数,几个进程可以映射同一内存区 SYS V IPC,以前的SYS V UNIX系统实现的IPC机制,包括消息队列、信号量和共享内 存,现在已经基本废弃 UNIX Domain Socket,目前最广泛使用的IPC机制 上一页 3. 进程控制 上一级 起始页 下一页 5. 练习:实现简单的Shell 上一页 5. 练习:实现简单的Shell 第 30 章 进程 下一页 5. 练习:实现简单的Shell 用讲过的各种C函数实现一个简单的交互式Shell,要求: 1、给出提示符,让用户输入一行命令,识别程序名和参数并调用适当的exec函数执行程序,待 执行完成后再次给出提示符。 2、识别和处理以下符号: 简单的标准输入输出重定向(<和>):仿照例 30.5 “wrapper”,先dup2然后exec。 管道(|):Shell进程先调用pipe创建一对管道描述符,然后fork出两个子进程,一个子 进程关闭读端,调用dup2把写端赋给标准输出,另一个子进程关闭写端,调用dup2把读端 赋给标准输入,两个子进程分别调用exec执行程序,而Shell进程把管道的两端都关闭,调 用wait等待两个子进程终止。 你的程序应该可以处理以下命令: ○ls△-l△-R○>○file1○ ○cat○<○file1○|○wc△-c○>○file1○ ○表示零个或多个空格,△表示一个或多个空格 上一页 4. 进程间通信 上一级 起始页 下一页 第 31 章 Shell脚本 上一页 第 31 章 Shell脚本 部分 III. Linux系统编程 第 31 章 Shell脚本 目录 1. Shell的历史 2. Shell如何执行命令 2.1. 执行交互式命令 2.2. 执行脚本 3. Shell的基本语法 3.1. 变量 3.2. 文件名代换(Globbing):* ? [] 3.3. 命令代换:`或 $() 3.4. 算术代换:$(()) 3.5. 转义字符\ 3.6. 单引号 3.7. 双引号 4. bash启动脚本 4.1. 作为交互登录Shell启动,或者使用--login参数启动 4.2. 以交互非登录Shell启动 4.3. 非交互启动 4.4. 以sh命令启动 5. Shell脚本语法 5.1. 条件测试:test [ 5.2. if/then/elif/else/fi 5.3. case/esac 5.4. for/do/done 5.5. while/do/done 5.6. 位置参数和特殊变量 5.7. 函数 6. Shell脚本的调试方法 上一页 5. 练习:实现简单的Shell 上一级 起始页 下一页 下一页 1. Shell的历史 上一页 1. Shell的历史 第 31 章 Shell脚本 下一页 1. Shell的历史 Shell的作用是解释执行用户的命令,用户输入一条命令,Shell就解释执行一条,这种方式称为 交互式(Interactive),Shell还有一种执行命令的方式称为批处理(Batch),用户事先写一 个Shell脚本(Script),其中有很多条命令,让Shell一次把这些命令执行完,而不必一条一条 地敲命令。Shell脚本和编程语言很相似,也有变量和流程控制语句,但Shell脚本是解释执行 的,不需要编译,Shell程序从脚本中一行一行读取并执行这些命令,相当于一个用户把脚本中 的命令一行一行敲到Shell提示符下执行。 由于历史原因,UNIX系统上有很多种Shell: 1. sh(Bourne Shell):由Steve Bourne开发,各种UNIX系统都配有sh。 2. csh(C Shell):由Bill Joy开发,随BSD UNIX发布,它的流程控制语句很像C语言,支 持很多Bourne Shell所不支持的功能:作业控制,命令历史,命令行编辑。 3. ksh(Korn Shell):由David Korn开发,向后兼容sh的功能,并且添加了csh引入的新功 能,是目前很多UNIX系统标准配置的Shell,在这些系统上/bin/sh往往是指向/bin/ksh的 符号链接。 4. tcsh(TENEX C Shell):是csh的增强版本,引入了命令补全等功能, 在FreeBSD、Mac OS X等系统上替代了csh。 5. bash(Bourne Again Shell):由GNU开发的Shell,主要目标是与POSIX标准保持一致, 同时兼顾对sh的兼容,bash从csh和ksh借鉴了很多功能,是各种Linux发行版标准配置 的Shell,在Linux系统上/bin/sh往往是指向/bin/bash的符号链接[36]。虽然如 此,bash和sh还是有很多不同的,一方面,bash扩展了一些命令和参数,另一方 面,bash并不完全和sh兼容,有些行为并不一致,所以bash需要模拟sh的行为:当我们通 过sh这个程序名启动bash时,bash可以假装自己是sh,不认扩展的命令,并且行为与sh保 持一致。 文件/etc/shells给出了系统中所有已知(不一定已安装)的Shell,除了上面提到的Shell之外还 有很多变种。 # /etc/shells: valid login shells /bin/csh /bin/sh /usr/bin/es /usr/bin/ksh /bin/ksh /usr/bin/rc /usr/bin/tcsh /bin/tcsh /usr/bin/esh /bin/dash /bin/bash /bin/rbash /usr/bin/screen 用户的默认Shell设置在/etc/passwd文件中,例如下面这行对用户mia的设置: mia:L2NOfqdlPrHwE:504:504:Mia Maya:/home/mia:/bin/bash 用户mia从字符终端登录或者打开图形终端窗口时就会自动执行/bin/bash。如果要切换到其 它Shell,可以在命令行输入程序名,例如: ~$ sh(在bash提示符下输入sh命令) $(出现sh的提示符) $(按Ctrl-d或者输入exit命令) ~$(回到bash提示符) ~$(再次按Ctrl-d或者输入exit命令会退出登录或者关闭图形终端窗口) 本章只介绍bash和sh的用法和相关语法,不介绍其它Shell。所以下文提到Shell都是 指bash或sh。 [36] 最新的发行版有一些变化,例如Ubuntu 7.10的/bin/sh是指向/bin/dash的符号链 接,dash也是一种类似bash的Shell。 $ ls /bin/sh /bin/dash -l -rwxr-xr-x 1 root root 79988 2008-03-12 19:22 /bin/dash lrwxrwxrwx 1 root root 4 2008-07-04 05:58 /bin/sh -> dash 上一页 第 31 章 Shell脚本 上一级 起始页 下一页 2. Shell如何执行命令 上一页 2. Shell如何执行命令 第 31 章 Shell脚本 下一页 2. Shell如何执行命令 2.1. 执行交互式命令 用户在命令行输入命令后,一般情况下Shell会fork并exec该命令,但是Shell的内建命令例外, 执行内建命令相当于调用Shell进程中的一个函数,并不创建新的进程。以前学过 的cd、alias、umask、exit等命令即是内建命令,凡是用which命令查不到程序文件所在位置的 命令都是内建命令,内建命令没有单独的man手册,要在man手册中查看内建命令,应该 $ man bash-builtins 本节会介绍很多内建命令,如export、shift、if、eval、[、for、while等等。内建命令虽然 不创建新的进程,但也会有Exit Status,通常也用0表示成功非零表示失败,虽然内建命令不创 建新的进程,但执行结束后也会有一个状态码,也可以用特殊变量$?读出。 习题 1、在完成第 5 节 “练习:实现简单的Shell”时也许有的读者已经试过了,在自己实现的Shell中 不能执行cd命令,因为cd是一个内建命令,没有程序文件,不能用exec执行。现在请完善该程 序,实现cd命令的功能,用chdir(2)函数可以改变进程的当前工作目录。 2、思考一下,为什么cd命令要实现成内建命令?可不可以实现一个独立的cd程序,例 如/bin/cd,就像/bin/ls一样? 2.2. 执行脚本 首先编写一个简单的脚本,保存为script.sh: 例 31.1. 简单的Shell脚本 #! /bin/sh cd .. ls Shell脚本中用#表示注释,相当于C语言的//注释。但如果#位于第一行开头,并且是#!(称 为Shebang)则例外,它表示该脚本使用后面指定的解释器/bin/sh解释执行。如果把这个脚本 文件加上可执行权限然后执行: $ chmod +x script.sh $ ./script.sh Shell会fork一个子进程并调用exec执行./script.sh这个程序,exec系统调用应该把子进程的代 码段替换成./script.sh程序的代码段,并从它的_start开始执行。然而script.sh是个文本文 件,根本没有代码段和_start函数,怎么办呢?其实exec还有另外一种机制,如果要执行的是 一个文本文件,并且第一行用Shebang指定了解释器,则用解释器程序的代码段替换当前进 程,并且从解释器的_start开始执行,而这个文本文件被当作命令行参数传给解释器。因此, 执行上述脚本相当于执行程序 $ /bin/sh ./script.sh 以这种方式执行不需要script.sh文件具有可执行权限。再举个例子,比如某个sed脚本的文件 名是script,它的开头是 #! /bin/sed -f 执行./script相当于执行程序 $ /bin/sed -f ./script.sh 以上介绍了两种执行Shell脚本的方法: $ ./script.sh $ sh ./script.sh 这两种方法本质上是一样的,执行上述脚本的步骤为: 图 31.1. Shell脚本的执行过程 1. 交互Shell(bash)fork/exec一个子Shell(sh)用于执行脚本,父进程bash等待子进 程sh终止。 2. sh读取脚本中的cd ..命令,调用相应的函数执行内建命令,改变当前工作目录为上一级 目录。 3. sh读取脚本中的ls命令,fork/exec这个程序,列出当前工作目录下的文件,sh等待ls终 止。 4. ls终止后,sh继续执行,读到脚本文件末尾,sh终止。 5. sh终止后,bash继续执行,打印提示符等待用户输入。 如果将命令行下输入的命令用()括号括起来,那么也会fork出一个子Shell执行小括号中的命 令,一行中可以输入由分号;隔开的多个命令,比如: $ (cd ..;ls -l) 和上面两种方法执行Shell脚本的效果是相同的,cd ..命令改变的是子Shell的PWD,而不会影响 到交互式Shell。然而命令 $ cd ..;ls -l 则有不同的效果,cd ..命令是直接在交互式Shell下执行的,改变交互式Shell的PWD,然而这种 方式相当于这样执行Shell脚本: $ source ./script.sh 或者 $ . ./script.sh source或者.命令是Shell的内建命令,这种方式也不会创建子Shell,而是直接在交互式Shell下 逐行执行脚本中的命令。 习题 1、解释如下命令的执行过程: $ (exit 2) $ echo $? 2 上一页 1. Shell的历史 上一级 起始页 下一页 3. Shell的基本语法 上一页 3. Shell的基本语法 第 31 章 Shell脚本 下一页 3. Shell的基本语法 3.1. 变量 按照惯例,Shell变量由全大写字母加下划线组成,有两种类型的Shell变量: 环境变量 在第 2 节 “环境变量”中讲过,环境变量可以从父进程传给子进程,因此Shell进程的环境变 量可以从当前Shell进程传给fork出来的子进程。用printenv命令可以显示当前Shell进程 的环境变量。 本地变量 只存在于当前Shell进程,用set命令可以显示当前Shell进程中定义的所有变量(包括本地 变量和环境变量)和函数。 环境变量是任何进程都有的概念,而本地变量是Shell特有的概念。在Shell中,环境变量和本地 变量的定义和用法相似。在Shell中定义或赋值一个变量: $ VARNAME=value 注意等号两边都不能有空格,否则会被Shell解释成命令和命令行参数。 一个变量定义后仅存在于当前Shell进程,它是本地变量,用export命令可以把本地变量导出为 环境变量,定义和导出环境变量通常可以一步完成: $ export VARNAME=value 也可以分两步完成: $ VARNAME=value $ export VARNAME 用unset命令可以删除已定义的环境变量或本地变量。 $ unset VARNAME 如果一个变量叫做VARNAME,用${VARNAME}可以表示它的值,在不引起歧义的情况下也可以 用$VARNAME表示它的值。通过以下例子比较这两种表示法的不同: $ echo $SHELL $ echo $SHELLabc $ echo $SHELL abc $ echo ${SHELL}abc $ $C Shell 注意,在定义变量时不用 ,取变量值时要用 。和 语言不同的是, 变量不需要明确定义 类型,事实上Shell变量的值都是字符串,比如我们定义VAR=45,其实VAR的值是字符串45而非整 数。Shell变量不需要先定义后使用,如果对一个没有定义的变量取值,则值为空字符串。 3.2. 文件名代换(Globbing):* ? [] 这些用于匹配的字符称为通配符(Wildcard),具体如下: 表 31.1. 通配符 * 匹配0个或多个任意字符 ? 匹配一个任意字符 [若干字符] 匹配方括号中任意一个字符的一次出现 $ ls /dev/ttyS* $ ls ch0?.doc $ ls ch0[0-2].doc $ ls ch[012][0-9].doc 注意,Globbing所匹配的文件名是由Shell展开的,也就是说在参数还没传给程序之前已经展开 了,比如上述ls ch0[012].doc命令,如果当前目录下有ch00.doc和ch02.doc,则传给ls命令的 参数实际上是这两个文件名,而不是一个匹配字符串。 3.3. 命令代换:`或 $() 由反引号括起来的也是一条命令,Shell先执行该命令,然后将输出结果立刻代换到当前命令行 中。例如定义一个变量存放date命令的输出: $ DATE=`date` $ echo $DATE 命令代换也可以用$()表示: $ DATE=$(date) 3.4. 算术代换:$(()) 用于算术计算,$(())中的Shell变量取值将转换成整数,例如: $ VAR=45 $ echo $(($VAR+3)) $(())中只能用+-*/和()运算符,并且只能做整数运算。 3.5. 转义字符\ 和C语言类似,\在Shell中被用作转义字符,用于去除紧跟其后的单个字符的特殊意义(回车除 外),换句话说,紧跟其后的字符取字面值。例如: $ echo $SHELL /bin/bash $ echo \$SHELL $SHELL $ echo \\ \ 比如创建一个文件名为“$ $”的文件可以这样: $ touch \$\ \$ 还有一个字符虽然不具有特殊含义,但是要用它做文件名也很麻烦,就是-号。如果要创建一个 文件名以-号开头的文件,这样是不行的: $ touch -hello touch: invalid option -- h Try `touch --help' for more information. 即使加上\转义也还是报错: $ touch \-hello touch: invalid option -- h Try `touch --help' for more information. 因为各种UNIX命令都把-号开头的命令行参数当作命令的选项,而不会当作文件名。如果非要处 理以-号开头的文件名,可以有两种办法: $ touch ./-hello 或者 $ touch -- -hello \还有一种用法,在\后敲回车表示续行,Shell并不会立刻执行命令,而是把光标移到下一行,给 出一个续行提示符>,等待用户继续输入,最后把所有的续行接到一起当作一个命令执行。例 如: $ ls \ > -l (ls -l命令的输出) 3.6. 单引号 和C语言不一样,Shell脚本中的单引号和双引号一样都是字符串的界定符(双引号下一节介 绍),而不是字符的界定符。单引号用于保持引号内所有字符的字面值,即使引号内的\和回车 也不例外,但是字符串中不能出现单引号。如果引号没有配对就输入回车,Shell会给出续行提 示符,要求用户把引号配上对。例如: $ echo '$SHELL' $SHELL $ echo 'ABC\(回车) > DE'(再按一次回车结束命令) ABC\ DE 3.7. 双引号 双引号用于保持引号内所有字符的字面值(回车也不例外),但以下情况除外: 反引号仍表示命令替换 \$表示$的字面值 \`表示`的字面值 \"表示"的字面值 \\表示\的字面值 除以上情况之外,在其它字符前面的\无特殊含义,只表示字面值 $ echo "$SHELL" /bin/bash $ echo "`date`" Sun Apr 20 11:22:06 CEST 2003 $ echo "I'd say: \"Go for it\"" I'd say: "Go for it" $ echo "\"(回车) >"(再按一次回车结束命令) " $ echo "\\" \ 上一页 2. Shell如何执行命令 上一级 起始页 下一页 4. bash启动脚本 上一页 4. bash启动脚本 第 31 章 Shell脚本 下一页 4. bash启动脚本 启动脚本是bash启动时自动执行的脚本。用户可以把一些环境变量的设置和alias、umask设置 放在启动脚本中,这样每次启动Shell时这些设置都自动生效。思考一下,bash在执行启动脚本 时是以fork子Shell方式执行的还是以source方式执行的? 启动bash的方法不同,执行启动脚本的步骤也不相同,具体可分为以下几种情况。 4.1. 作为交互登录Shell启动,或者使用--login参数启动 交互Shell是指用户在提示符下输命令的Shell而非执行脚本的Shell,登录Shell就是在输入用户 名和密码登录后得到的Shell,比如从字符终端登录或者用telnet/ssh从远程登录,但是从图形 界面的窗口管理器登录之后会显示桌面而不会产生登录Shell(也不会执行启动脚本),在图形 界面下打开终端窗口得到的Shell也不是登录Shell。 这样启动bash会自动执行以下脚本: 1. 首先执行/etc/profile,系统中每个用户登录时都要执行这个脚本,如果系统管理员希望 某个设置对所有用户都生效,可以写在这个脚本里 2. 然后依次查找当前用户主目录的~/.bash_profile、~/.bash_login和~/.profile三个文 件,找到第一个存在并且可读的文件来执行,如果希望某个设置只对当前用户生效,可以 写在这个脚本里,由于这个脚本在/etc/profile之后执行,/etc/profile设置的一些环境 变量的值在这个脚本中可以修改,也就是说,当前用户的设置可以覆盖(Override)系统 中全局的设置。~/.profile这个启动脚本是sh规定的,bash规定首先查找以~/.bash_开头 的启动脚本,如果没有则执行~/.profile,是为了和sh保持一致。 3. 顺便一提,在退出登录时会执行~/.bash_logout脚本(如果它存在的话)。 4.2. 以交互非登录Shell启动 比如在图形界面下开一个终端窗口,或者在登录Shell提示符下再输入bash命令,就得到一个交 互非登录的Shell,这种Shell在启动时自动执行~/.bashrc脚本。 为了使登录Shell也能自动执行~/.bashrc ,通常在~/.bash_profile 中调用~/.bashrc : if [ -f ~/.bashrc ]; then . ~/.bashrc fi 这几行的意思是如果~/.bashrc文件存在则source它。多数Linux发行版在创建帐户时会自动创 建~/.bash_profile 和~/.bashrc 脚本,~/.bash_profile 中通常都有上面这几行。所以,如果要 在启动脚本中做某些设置,使它在图形终端窗口和字符终端的Shell中都起作用,最好就是 在~/.bashrc中设置。 下面做一个实验,在~/.bashrc文件末尾添加一行(如果这个文件不存在就创建它): export PATH=$PATH:/home/akaedu 然后关掉终端窗口重新打开,或者从字符终端logout之后重新登录,现在主目录下的程序应该 可以直接输程序名运行而不必输入路径了,例如: ~$ a.out 就可以了,而不必 ~$ ./a.out 为什么登录Shell和非登录Shell的启动脚本要区分开呢?最初的设计是这样考虑的,如果从字符 终端或者远程登录,那么登录Shell是该用户的所有其它进程的父进程,也是其它子Shell的父进 程,所以环境变量在登录Shell的启动脚本里设置一次就可以自动带到其它非登录Shell里, 而Shell的本地变量、函数、alias等设置没有办法带到子Shell里,需要每次启动非登录Shell时 设置一遍,所以就需要有非登录Shell的启动脚本,所以一般来说在~/.bash_profile里设置环境 变量,在~/.bashrc里设置本地变量、函数、alias等。如果你的Linux带有图形系统则不能这样 设置,由于从图形界面的窗口管理器登录并不会产生登录Shell,所以环境变量也应该 在~/.bashrc里设置。 4.3. 非交互启动 为执行脚本而fork出来的子Shell是非交互Shell,启动时执行的脚本文件由环境变量BASH_ENV定 义,相当于自动执行以下命令: if [ -n "$BASH_ENV" ]; then . "$BASH_ENV"; fi 如果环境变量BASH_ENV的值不是空字符串,则把它的值当作启动脚本的文件名,source这个脚 本。 4.4. 以sh命令启动 如果以sh命令启动bash,bash将模拟sh的行为,以~/.bash_开头的那些启动脚本就不认了。所 以,如果作为交互登录Shell启动,或者使用--login参数启动,则依次执行以下脚本: 1. /etc/profile 2. ~/.profile 如果作为交互Shell启动,相当于自动执行以下命令: if [ -n "$ENV" ]; then . "$ENV"; fi 如果作为非交互Shell启动,则不执行任何启动脚本。通常我们写的Shell脚本都以#! /bin/sh开 头,都属于这种方式。 上一页 3. Shell的基本语法 上一级 起始页 下一页 5. Shell脚本语法 上一页 5. Shell脚本语法 第 31 章 Shell脚本 下一页 5. Shell脚本语法 5.1. 条件测试:test [ 命令test或[可以测试一个条件是否成立,如果测试结果为真,则该命令的Exit Status为0,如果 测试结果为假,则命令的Exit Status为1(注意与C语言的逻辑表示正好相反)。例如测试两个 数的大小关系: $ VAR=2 $ test $VAR -gt 1 $ echo $? 0 $ test $VAR -gt 3 $ echo $? 1 $ [ $VAR -gt 3 ] $ echo $? 1 虽然看起来很奇怪,但左方括号[确实是一个命令的名字,传给命令的各参数之间应该用空格隔 开,比如,$VAR、-gt、3、]是[命令的四个参数,它们之间必须用空格隔开。命令test或[的参 数形式是相同的,只不过test命令不需要]参数。以[命令为例,常见的测试命令如下表所示: 表 31.2. 测试命令 [ -d DIR ] 如果DIR存在并且是一个目录则为真 [ ] -f FILE 如果FILE存在且是一个普通文件则为真 [ -z STRING ] 如果STRING的长度为零则为真 [ -n STRING ] 如果STRING的长度非零则为真 [ STRING1 = STRING2 如果两个字符串相同则为真 ] [ STRING1 != 如果字符串不相同则为真 STRING2 ] ARG1和ARG2应该是整数或者取值为整数的变量,OP是-eq(等于)- [ ARG1 ARG2 ] OP ne(不等于)-lt(小于)-le(小于等于)-gt(大于)-ge(大于等 于)之中的一个 和C语言类似,测试条件之间还可以做与、或、非逻辑运算: 表 31.3. 带与、或、非的测试命令 [ ! EXPR ] EXPR可以是上表中的任意一种测试条件,!表示逻辑反 [ EXPR1 -a EXPR2 EXPR1和EXPR2可以是上表中的任意一种测试条件,-a表示逻 ] 辑与 [ EXPR1 -o EXPR2 EXPR1和EXPR2可以是上表中的任意一种测试条件,-o表示逻 ] 辑或 例如: $ VAR=abc $ [ -d Desktop -a $VAR = 'abc' ] $ echo $? 0 注意,如果上例中的$VAR变量事先没有定义,则被Shell展开为空字符串,会造成测试条件的语 法错误(展开为[ -d Desktop -a = 'abc' ]),作为一种好的Shell编程习惯,应该总是把变量 取值放在双引号之中(展开为[ -d Desktop -a "" = 'abc' ]): $ unset VAR $ [ -d Desktop -a $VAR = 'abc' ] bash: [: too many arguments $ [ -d Desktop -a "$VAR" = 'abc' ] $ echo $? 1 5.2. if/then/elif/else/fi 和C语言类似,在Shell中用if、then、elif、else、fi这几条命令实现分支控制。这种流程控 制语句本质上也是由若干条Shell命令组成的,例如先前讲过的 if [ -f ~/.bashrc ]; then . ~/.bashrc fi 其实是三条命令,if [ -f ~/.bashrc ]是第一条,then . ~/.bashrc是第二条,fi是第三条。 如果两条命令写在同一行则需要用;号隔开,一行只写一条命令就不需要写;号了,另外,then后 面有换行,但这条命令没写完,Shell会自动续行,把下一行接在then后面当作一条命令处理。 和[命令一样,要注意命令和各参数之间必须用空格隔开。if命令的参数组成一条子命令,如果 该子命令的Exit Status为0(表示真),则执行then后面的子命令,如果Exit Status非0(表示 假),则执行elif、else或者fi后面的子命令。if后面的子命令通常是测试命令,但也可以是 其它命令。Shell脚本没有{}括号,所以用fi表示if语句块的结束。见下例: #! /bin/sh if [ -f /bin/bash ] then echo "/bin/bash is a file" else echo "/bin/bash is NOT a file" fi if :; then echo "always true"; fi :是一个特殊的命令,称为空命令,该命令不做任何事,但Exit Status总是真。此外,也可以执 行/bin/true或/bin/false得到真或假的Exit Status。再看一个例子: #! /bin/sh echo "Is it morning? Please answer yes or no." read YES_OR_NO if [ "$YES_OR_NO" = "yes" ]; then echo "Good morning!" elif [ "$YES_OR_NO" = "no" ]; then echo "Good afternoon!" else echo "Sorry, $YES_OR_NO not recognized. Enter yes or no." exit 1 fi exit 0 上例中的read命令的作用是等待用户输入一行字符串,将该字符串存到一个Shell变量中。 此外,Shell还提供了&&和||语法,和C语言类似,具有Short-circuit特性,很多Shell脚本喜欢写 成这样: test "$(whoami)" != 'root' && (echo you are using a nonprivileged account; exit 1) &&相当于“if...then...”,而||相当于“if not...then...”。&&和||用于连接两个命令,而上面讲的-a和o仅用于在测试表达式中连接两个测试条件,要注意它们的区别,例如, test "$VAR" -gt 1 -a "$VAR" -lt 3 和以下写法是等价的 test "$VAR" -gt 1 && test "$VAR" -lt 3 5.3. case/esac case命令可类比C语言的switch/case语句,esac表示case语句块的结束。C语言的case只能匹配 整型或字符型常量表达式,而Shell脚本的case可以匹配字符串和Wildcard,每个匹配分支可以 有若干条命令,末尾必须以;;结束,执行时找到第一个匹配的分支并执行相应的命令,然后直接 跳到esac之后,不需要像C语言一样用break跳出。 #! /bin/sh echo "Is it morning? Please answer yes or no." read YES_OR_NO case "$YES_OR_NO" in yes|y|Yes|YES) echo "Good Morning!";; [nN]*) echo "Good Afternoon!";; *) echo "Sorry, $YES_OR_NO not recognized. Enter yes or no." exit 1;; esac exit 0 使用case语句的例子可以在系统服务的脚本目录/etc/init.d中找到。这个目录下的脚本大多具 有这种形式(以/etc/apache2为例): case $1 in start) ... ;; stop) ... ;; reload | force-reload) ... ;; restart) ... *) log_success_msg "Usage: /etc/init.d/apache2 {start|stop|restart|reload|force-reload|start-htcacheclean|stophtcacheclean}" exit 1 ;; esac 启动apache2服务的命令是 $ sudo /etc/init.d/apache2 start $1是一个特殊变量,在执行脚本时自动取值为第一个命令行参数,也就是start,所以进 入start)分支执行相关的命令。同理,命令行参数指定为stop、reload或restart可以进入其它 分支执行停止服务、重新加载配置文件或重新启动服务的相关命令。 5.4. for/do/done Shell脚本的for循环结构和C语言很不一样,它类似于某些编程语言的foreach循环。例如: #! /bin/sh for FRUIT in apple banana pear; do echo "I like $FRUIT" done FRUIT是一个循环变量,第一次循环$FRUIT的取值是apple,第二次取值是banana,第三次取值 是pear。再比如,要将当前目录下的chap0、chap1、chap2等文件名改 为chap0~、chap1~、chap2~等(按惯例,末尾有~字符的文件名表示临时文件),这个命令可以 这样写: $ for FILENAME in chap?; do mv $FILENAME $FILENAME~; done 也可以这样写: $ for FILENAME in `ls chap?`; do mv $FILENAME $FILENAME~; done 5.5. while/do/done while的用法和C语言类似。比如一个验证密码的脚本: #! /bin/sh echo "Enter password:" read TRY while [ "$TRY" != "secret" ]; do echo "Sorry, try again" read TRY done 下面的例子通过算术运算控制循环的次数: #! /bin/sh COUNTER=1 while [ "$COUNTER" -lt 10 ]; do echo "Here we go again" COUNTER=$(($COUNTER+1)) done Shell还有until循环,类似C语言的do...while循环。本章从略。 习题 1、把上面验证密码的程序修改一下,如果用户输错五次密码就报错退出。 5.6. 位置参数和特殊变量 有很多特殊变量是被Shell自动赋值的,我们已经遇到了$?和$1,现在总结一下: 表 31.4. 常用的位置参数和特殊变量 $0 相当于C语言main函数的argv[0] $1、$2... 这些称为位置参数(Positional 的argv[1]、argv[2]... Parameter),相当于C语言main函数 $# 相当于C语言main函数的argc - 1,注意这里的#后面不表示注释 $@ 表示参数列表"$1" "$2" ...,例如可以用在for循环中的in后面。 $? 上一条命令的Exit Status $$ 当前Shell的进程号 位置参数可以用shift命令左移。比如shift 3表示原来的$4现在变成$1,原来的$5现在变 成$2等等,原来的$1、$2、$3丢弃,$0不移动。不带参数的shift命令相当于shift 1。例如: #! /bin/sh echo "The program $0 is now running" echo "The first parameter is $1" echo "The second parameter is $2" echo "The parameter list is $@" shift echo "The first parameter is $1" echo "The second parameter is $2" echo "The parameter list is $@" 5.7. 函数 和C语言类似,Shell中也有函数的概念,但是函数定义中没有返回值也没有参数列表。例如: #! /bin/sh foo(){ echo "Function foo is called";} echo "-=start=-" foo echo "-=end=-" 注意函数体的左花括号{和后面的命令之间必须有空格或换行,如果将最后一条命令和右花括 号}写在同一行,命令末尾必须有;号。 在定义foo()函数时并不执行函数体中的命令,就像定义变量一样,只是给foo这个名字一个定 义,到后面调用foo函数的时候(注意Shell中的函数调用不写括号)才执行函数体中的命 令。Shell脚本中的函数必须先定义后调用,一般把函数定义都写在脚本的前面,把函数调用和 其它命令写在脚本的最后(类似C语言中的main函数,这才是整个脚本实际开始执行命令的地 方)。 Shell函数没有参数列表并不表示不能传参数,事实上,函数就像是迷你脚本,调用函数时可以 传任意个参数,在函数内同样是用$0、$1、$2等变量来提取参数,函数中的位置参数相当于函 数的局部变量,改变这些变量并不会影响函数外面的$0、$1、$2等变量。函数中可以 用return命令返回,如果return后面跟一个数字则表示函数的Exit Status。 下面这个脚本可以一次创建多个目录,各目录名通过命令行参数传入,脚本逐个测试各目录是 否存在,如果目录不存在,首先打印信息然后试着创建该目录。 #! /bin/sh is_directory() { DIR_NAME=$1 if [ ! -d $DIR_NAME ]; then return 1 else return 0 fi } for DIR in "$@"; do if is_directory "$DIR" then : else echo "$DIR doesn't exist. Creating it now..." mkdir $DIR > /dev/null 2>&1 if [ $? -ne 0 ]; then echo "Cannot create directory $DIR" exit 1 fi fi done 注意is_directory()返回0表示真返回1表示假。 上一页 4. bash启动脚本 上一级 起始页 下一页 6. Shell脚本的调试方法 上一页 6. Shell脚本的调试方法 第 31 章 Shell脚本 下一页 6. Shell脚本的调试方法 Shell提供了一些用于调试脚本的选项,如下所示: -n 读一遍脚本中的命令但不执行,用于检查脚本中的语法错误 -v 一边执行脚本,一边将执行过的脚本命令打印到标准错误输出 -x 提供跟踪执行信息,将执行的每一条命令和结果依次打印出来 使用这些选项有三种方法,一是在命令行提供参数 $ sh -x ./script.sh 二是在脚本开头提供参数 #! /bin/sh -x 第三种方法是在脚本中用set命令启用或禁用参数 #! /bin/sh if [ -z "$1" ]; then set -x echo "ERROR: Insufficient Args." exit 1 set +x fi set -x和set +x分别表示启用和禁用-x参数,这样可以只对脚本中的某一段进行跟踪调试。 上一页 5. Shell脚本语法 上一级 起始页 下一页 第 32 章 正则表达式 上一页 第 32 章 正则表达式 部分 III. Linux系统编程 第 32 章 正则表达式 目录 1. 引言 2. 基本语法 3. sed 4. awk 5. 练习:在C语言中使用正则表达式 上一页 6. Shell脚本的调试方法 上一级 起始页 下一页 下一页 1. 引言 上一页 1. 引言 第 32 章 正则表达式 下一页 1. 引言 以前我们用grep在一个文件中找出包含某些字符串的行,比如在头文件中找出一个宏定义。其 实grep还可以找出符合某个模式(Pattern)的一类字符串。例如找出所有符 合xxxxx@xxxx.xxx模式的字符串(也就是email地址),要求x字符可以是字母、数字、下划线、 小数点或减号,email地址的每一部分可以有一个或多个x字符,例如abc.d@ef.com、1_2@9876.54,当然符合这个模式的不全是合法的email地址,但至少可以做一次初步筛选,筛 掉a.b、c@d等肯定不是email地址的字符串。再比如,找出所有符合yyy.yyy.yyy.yyy模式的字符 串(也就是IP地址),要求y是0-9的数字,IP地址的每一部分可以有1-3个y字符。 如果要用grep查找一个模式,如何表示这个模式,这一类字符串,而不是一个特定的字符串 呢?从这两个简单的例子可以看出,要表示一个模式至少应该包含以下信息: 字符类(Character Class):如上例的x和y,它们在模式中表示一个字符,但是取值范围 是一类字符中的任意一个。 数量限定符(Quantifier): 邮件地址的每一部分可以有一个或多个x字符,IP地址的每一 部分可以有1-3个y字符 各种字符类以及普通字符之间的位置关系:例如邮件地址分三部分,用普通字符@和.隔 开,IP地址分四部分,用.隔开,每一部分都可以用字符类和数量限定符描述。为了表示 位置关系,还有位置限定符(Anchor)的概念,将在下面介绍。 规定一些特殊语法表示字符类、数量限定符和位置关系,然后用这些特殊语法和普通字符一起 表示一个模式,这就是正则表达式(Regular Expression)。例如email地址的正则表达式可以 写成[a-zA-Z0-9_. -]+@[a -zA-Z0-9_. -]+\.[a-zA-Z0-9_. -]+,IP 地址的正则表达式可以写成[09]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}。下一节介绍正则表达式的语法,我们先看看 正则表达式在grep中怎么用。例如有这样一个文本文件testfile: 192.168.1.1 1234.234.04.5678 123.4234.045.678 abcde 查找其中包含IP地址的行: $ egrep '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}' testfile 192.168.1.1 1234.234.04.5678 egrep相当于grep -E,表示采用Extended正则表达式语法。grep的正则表达式 有Basic和Extended两种规范,它们之间的区别下一节再解释。另外还有fgrep命令,相当 于grep -F,表示只搜索固定字符串而不搜索正则表达式模式,不会按正则表达式的语法解释后 面的参数。 注意正则表达式参数用单引号括起来了,因为正则表达式中用到的很多特殊字符在Shell中也有 特殊含义(例如\),只有用单引号括起来才能保证这些字符原封不动地传给grep命令,而不会 被Shell解释掉。 192.168.1.1符合上述模式,由三个.隔开的四段组成,每段都是1到3个数字,所以这一行被找 出来了,可为什么1234.234.04.5678也被找出来了呢?因为grep找的是包含某一模式的行,这一 行包含一个符合模式的字符串234.234.04.567。相反,123.4234.045.678这一行不包含符合模式 的字符串,所以不会被找出来。 grep是一种查找过滤工具,正则表达式在grep中用来查找符合模式的字符串。其实正则表达式 还有一个重要的应用是验证用户输入是否合法,例如用户通过网页表单提交自己的email地址, 就需要用程序验证一下是不是合法的email地址,这个工作可以在网页的Javascript中做,也可 以在网站后台的程序中做,例如PHP、Perl、Python、Ruby、Java或C,所有这些语言都支持 正则表达式,可以说,目前不支持正则表达式的编程语言实在很少见。除了编程语言之外,很 多UNIX命令和工具也都支持正则表达式,例如grep、vi、sed、awk、emacs等等。“正则表达 式”就像“变量”一样,它是一个广泛的概念,而不是某一种工具或编程语言的特性。 上一页 第 32 章 正则表达式 上一级 起始页 下一页 2. 基本语法 上一页 2. 基本语法 第 32 章 正则表达式 下一页 2. 基本语法 我们知道C的变量和Shell脚本变量的定义和使用方法很不相同,表达能力也不相同,C的变量有 各种类型,而Shell脚本变量都是字符串。同样道理,各种工具和编程语言所使用的正则表达式 规范的语法并不相同,表达能力也各不相同,有的正则表达式规范引入很多扩展,能表达更复 杂的模式,但各种正则表达式规范的基本概念都是相通的。本节介绍egrep(1)所使用的正则表 达式,它大致上符合POSIX正则表达式规范,详见regex(7)(看这个man page对你的英文绝对 是很好的锻炼)。希望读者仿照上一节的例子,一边学习语法,一边用egrep命令做实验。 表 32.1. 字符类 字符 . [] 含义 匹配任意一个字符 匹配括号中的任意一个字符 - 在[]括号内表示字符范围 位于[]括号内的开头,匹配除 ^ 括号中的字符之外的任意一个 字符 grep工具预定义的一些命名字 [[:xxx:]] 符类 举例 abc.可以匹配abcd、abc9等 [abc]d可以匹配ad、bd或cd [0-9a-fA-F]可以匹配一位十六进制数 字 [^xy]匹配除xy之外的任一字符,因 此[^xy]1可以匹配a1、b1但不匹 配x1、y1 [[:alpha:]]匹配一个字 母,[[:digit:]]匹配一个数字 表 32.2. 数量限定符 字符 含义 举例 [0-9]?\.[0-9]匹配0.0、2.3、.5等,由于.在正则表 ? 紧跟在它前面的单元 达式中是一个特殊字符,所以需要用\转义一下,取 应匹配零次或一次 字面值 紧跟在它前面的单元 [a-zA-Z0-9_.-]+@[a-zA-Z0-9_.-]+\.[a-zA-Z0-9_.- + 应匹配一次或多次 ]+匹配email地址 紧跟在它前面的单元 [0-9][0-9]*匹配至少一位数字,等价于[0-9]+,[a- * 应匹配零次或多次 zA-Z_]+[a-zA-Z_0-9]*匹配C语言的标识符 {N} 紧跟在它前面的单元 [1-9][0-9]{2}匹配从100到999的整数 应精确匹配N次 {N,} 紧跟在它前面的单元 应匹配至少N次 [1-9][0-9]{2,}匹配三位以上(含三位)的整数 紧跟在它前面的单元 {,M} 应匹配最多M次 [0-9]{,1}相当于[0-9]? 紧跟在它前面的单元 [0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0- {N,M} 应匹配至少N次,最 多M次 9]{1,3}匹配IP地址 再次注意grep找的是包含某一模式的行,而不是完全匹配某一模式的行。再举个例子,如果文 本文件的内容是 aaabc aad efg 查找a*这个模式的结果是三行都被找出来了 $ egrep 'a*' testfile aabc aad efg a*匹配0个或多个a,而第三行包含0个a,所以也包含了这一模式。单独用a*这样的正则表达式 做查找没什么意义,一般是把a*作为正则表达式的一部分来用。 表 32.3. 位置限定符 字 符 含义 举例 ^ 匹配行首的位置 ^Content匹配位于一行开头的Content $ 匹配行末的位置 ;$匹配位于一行结尾的;号,^$匹配空行 \< 匹配单词开头的位置 \ 匹配单词结尾的位置 p\>匹配leap ...,但不匹配parent、sleepy 匹配单词开头或结尾的位 \bat\b匹配... at ...,但不匹 \b 置 配cat、atexit、batch \B 匹配非单词开头和结尾的 \Bat\B匹配battery,但不匹配... attend、hat 位置 ... 位置限定符可以帮助grep更准确地查找,例如上一节我们用[0-9]{1,3}\.[0-9]{1,3}\.[09]{1,3}\.[0-9]{1,3}查找IP地址,找到这两行 192.168.1.1 1234.234.04.5678 如果用^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$查找,就可以 把1234.234.04.5678这一行过滤掉了。 表 32.4. 其它特殊字符 字 符 含义 举例 普通字符<写成\<表示单词开头的位置, 转义字符,普通字符转义为特殊字 \ 符,特殊字符转义为普通字符 特殊字符.写成\.以及\写成\\就当作普通 字符来匹配 将正则表达式的一部分括起来组成 () 一个单元,可以对整个单元使用数 ([0-9]{1,3}\.){3}[0-9]{1,3}匹配IP地址 量限定符 | 连接两个子表达式,表示或的关系 n(o|either)匹配no或neither 以上介绍的是grep正则表达式的Extended规范,Basic规范也有这些语法,只是字符?+{}|()应 解释为普通字符,要表示上述特殊含义则需要加\转义。如果用grep而不是egrep,并且不加E参数,则应该遵照Basic规范来写正则表达式。 上一页 1. 引言 上一级 起始页 下一页 3. sed 上一页 3. sed 第 32 章 正则表达式 下一页 3. sed sed意为流编辑器(Stream Editor),在Shell脚本和Makefile中作为过滤器使用非常普遍,也就 是把前一个程序的输出引入sed的输入,经过一系列编辑命令转换为另一种格式输 出。sed和vi都源于早期UNIX的ed工具,所以很多sed命令和vi的末行命令是相同的。 sed命令行的基本格式为 sed option 'script' file1 file2 ... sed option -f scriptfile file1 file2 ... sed处理的文件既可以由标准输入重定向得到,也可以当命令行参数传入,命令行参数可以一次 传入多个文件,sed会依次处理。sed的编辑命令可以直接当命令行参数传入,也可以写成一个 脚本文件然后用-f参数指定,编辑命令的格式为 /pattern/action 其中pattern是正则表达式,action是编辑操作。sed程序一行一行读出待处理文件,如果某一行 与pattern匹配,则执行相应的action,如果一条命令没有pattern而只有action,这个action将 作用于待处理文件的每一行。 表 32.5. 常用的sed命令 /pattern/p 打印匹配pattern的行 /pattern/d 删除匹配pattern的行 查找符合pattern的行,将该行第一个匹 /pattern/s/pattern1/pattern2/ 配pattern1的字符串替换为pattern2 查找符合pattern的行,将该行所有匹 /pattern/s/pattern1/pattern2/g 配pattern1的字符串替换为pattern2 使用p命令需要注意,sed是把待处理文件的内容连同处理结果一起输出到标准输出的,因此p命 令表示除了把文件内容打印出来之外还额外打印一遍匹配pattern的行。比如一个文 件testfile的内容是 123 abc 456 打印其中包含abc的行 $ sed '/abc/p' testfile 123 abc abc 456 要想只输出处理结果,应加上-n选项,这种用法相当于grep命令 $ sed -n '/abc/p' testfile abc 使用d命令就不需要-n参数了,比如删除含有abc的行 $ sed '/abc/d' testfile 123 456 注意,sed命令不会修改原文件,删除命令只表示某些行不打印输出,而不是从原文件中删去。 使用查找替换命令时,可以把匹配pattern1的字符串复制到pattern2中,比如: $ sed 's/bc/-&-/' testfile 123 a-bc456 pattern2中的&表示原文件的当前行中与pattern1相匹配的字符串,再比如: $ sed 's/\([0-9]\)\([0-9]\)/-\1-~\2~/' testfile -1-~2~3 abc -4-~5~6 pattern2中的\1表示与pattern1的第一个()括号相匹配的内容,\2表示与pattern1的第二个()括 号相匹配的内容。sed默认使用Basic正则表达式规范,如果指定了-r选项则使用Extended规 范,那么()括号就不必转义了。 如果testfile的内容是 Hello World Welcome to the world of regexp! 现在要去掉所有的HTML标签,使输出结果为 Hello World Welcome to the world of regexp! 怎么做呢?如果用下面的命令 $ sed 's/<.*>//g' testfile 结果是两个空行,把所有字符都过滤掉了。这是因为,正则表达式中的数量限定符会匹配尽可 能长的字符串,这称为贪心的(Greedy)[37]。比如sed在处理第一行时,<.*>匹配的并不 是或这样的标签,而是 Hello World 这样一整行,因为这一行开头是<,中间是若干个任意字符,末尾是>。那么这条命令怎么改才 对呢?留给读者思考。 [37] 有些正则表达式规范支持Non-greedy的数量限定符,匹配尽可能短的字符串,例如 在Python中*?和*一样表示0个或任意多个,但前者是Non-greedy的。 上一页 2. 基本语法 上一级 起始页 下一页 4. awk 上一页 4. awk 第 32 章 正则表达式 下一页 4. awk sed以行为单位处理文件,awk比sed强的地方在于不仅能以行为单位还能以列为单位处理文 件。awk缺省的行分隔符是换行,缺省的列分隔符是连续的空格和Tab,但是行分隔符和列分隔 符都可以自定义,比如/etc/passwd文件的每一行有若干个字段,字段之间以:分隔,就可以重新 定义awk的列分隔符为:并以列为单位处理这个文件。awk实际上是一门很复杂的脚本语言,还有 像C语言一样的分支和循环结构,但是基本用法和sed类似,awk命令行的基本形式为: awk option 'script' file1 file2 ... awk option -f scriptfile file1 file2 ... 和sed一样,awk处理的文件既可以由标准输入重定向得到,也可以当命令行参数传入,编辑命 令可以直接当命令行参数传入,也可以用-f参数指定一个脚本文件,编辑命令的格式为: /pattern/{actions} condition{actions} 和sed类似,pattern是正则表达式,actions是一系列操作。awk程序一行一行读出待处理文件, 如果某一行与pattern匹配,或者满足condition条件,则执行相应的actions,如果一条awk命令 只有actions部分,则actions作用于待处理文件的每一行。比如文件testfile的内容表示某商店 的库存量: ProductA 30 ProductB 76 ProductC 55 打印每一行的第二列: $ awk '{print $2;}' testfile 30 76 55 自动变量$1、$2分别表示第一列、第二列等,类似于Shell脚本的位置参数,而$0表示整个当前 行。再比如,如果某种产品的库存量低于75则在行末标注需要订货: $ awk '$2<75 {printf "%s\t%s\n", $0, "REORDER";} $2>=75 {print $0;}' testfile ProductA 30 REORDER ProductB 76 ProductC 55 REORDER 可见awk也有和C语言非常相似的printf函数。awk命令的condition部分还可以是两个特殊 的condition-BEGIN和END,对于每个待处理文件,BEGIN后面的actions在处理整个文件之前执 行一次,END后面的actions在整个文件处理完之后执行一次。 awk命令可以像C语言一样使用变量(但不需要定义变量),比如统计一个文件中的空行数 $ awk '/^ *$/ {x=x+1;} END {print x;}' testfile 就像Shell的环境变量一样,有些awk变量是预定义的有特殊含义的: 表 32.6. awk常用的内建变量 FILENAME 当前输入文件的文件名,该变量是只读的 NR 当前行的行号,该变量是只读的,R代表record NF 当前行所拥有的列数,该变量是只读的,F代表field OFS 输出格式的列分隔符,缺省是空格 FS 输入文件的列分融符,缺省是连续的空格和Tab ORS 输出格式的行分隔符,缺省是换行符 RS 输入文件的行分隔符,缺省是换行符 例如打印系统中的用户帐号列表 $ awk 'BEGIN {FS=":"} {print $1;}' /etc/passwd awk还可以像C语言一样使用if/else、while、for控制结构,此处从略。 上一页 3. sed 上一级 起始页 下一页 5. 练习:在C语言中使用正则表达式 上一页 5. 练习:在C语言中使用正则表达式 第 32 章 正则表达式 下一页 5. 练习:在C语言中使用正则表达式 POSIX规定了正则表达式的C语言库函数,详见regex(3)。我们已经学习了很多C语言库函数的 用法,读者应该具备自己看懂man手册的能力了。本章介绍了正则表达式在grep、sed、awk中 的用法,学习要能够举一反三,请读者根据regex(3)自己总结正则表达式在C语言中的用法,写 一些简单的程序,例如验证用户输入的IP地址或email地址格式是否正确。 上一页 4. awk 上一级 起始页 下一页 第 33 章 信号 上一页 第 33 章 信号 部分 III. Linux系统编程 第 33 章 信号 目录 1. 信号的基本概念 2. 产生信号 2.1. 通过终端按键产生信号 2.2. 调用系统函数向进程发信号 2.3. 由软件条件产生信号 3. 阻塞信号 3.1. 信号在内核中的表示 3.2. 信号集操作函数 3.3. sigprocmask 3.4. sigpending 4. 捕捉信号 4.1. 内核如何实现信号的捕捉 4.2. sigaction 4.3. pause 4.4. 可重入函数 4.5. sig_atomic_t类型与volatile限定符 4.6. 竞态条件与sigsuspend函数 4.7. 关于SIGCHLD信号 上一页 5. 练习:在C语言中使用正则表达式 上一级 起始页 下一页 下一页 1. 信号的基本概念 上一页 1. 信号的基本概念 第 33 章 信号 下一页 1. 信号的基本概念 为了理解信号,先从我们最熟悉的场景说起: 1. 用户输入命令,在Shell下启动一个前台进程。 2. 用户按下Ctrl-C,这个键盘输入产生一个硬件中断。 3. 如果CPU当前正在执行这个进程的代码,则该进程的用户空间代码暂停执行,CPU从用户 态切换到内核态处理硬件中断。 4. 终端驱动程序将Ctrl-C解释成一个SIGINT信号,记在该进程的PCB中(也可以说发送了一 个SIGINT信号给该进程)。 5. 当某个时刻要从内核返回到该进程的用户空间代码继续执行之前,首先处理PCB中记录的 信号,发现有一个SIGINT信号待处理,而这个信号的默认处理动作是终止进程,所以直接 终止进程而不再返回它的用户空间代码执行。 注意,Ctrl-C产生的信号只能发给前台进程。在第 3.3 节 “wait和waitpid函数”中我们看到一个命 令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进 程。Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像Ctrl-C这 种控制键产生的信号。前台进程在运行过程中用户随时可能按下Ctrl-C而产生一个信号,也就是 说该进程的用户空间代码执行到任何地方都有可能收到SIGINT信号而终止,所以信号相对于进 程的控制流程来说是异步(Asynchronous)的。 用kill -l命令可以察看系统定义的信号列表: $ kill -l 1) SIGHUP 5) SIGTRAP 9) SIGKILL 13) SIGPIPE 17) SIGCHLD 21) SIGTTIN 25) SIGXFSZ 29) SIGIO 35) SIGRTMIN+1 ... 2) SIGINT 6) SIGABRT 10) SIGUSR1 14) SIGALRM 18) SIGCONT 22) SIGTTOU 26) SIGVTALRM 30) SIGPWR 36) SIGRTMIN+2 3) SIGQUIT 4) SIGILL 7) SIGBUS 8) SIGFPE 11) SIGSEGV 12) SIGUSR2 15) SIGTERM 16) SIGSTKFLT 19) SIGSTOP 20) SIGTSTP 23) SIGURG 24) SIGXCPU 27) SIGPROF 28) SIGWINCH 31) SIGSYS 34) SIGRTMIN 37) SIGRTMIN+3 38) SIGRTMIN+4 每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定 义#define SIGINT 2。编号34以上的是实时信号,本章只讨论编号34以下的信号,不讨论实时 信号。这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说 明: Signal Value Action Comment ------------------------------------------------------------- ------------ SIGHUP 1 Term Hangup detected on controlling terminal or death of controlling process SIGINT 2 Term Interrupt from keyboard SIGQUIT 3 Core Quit from keyboard SIGILL ... 4 Core Illegal Instruction 上表中第一列是各信号的宏定义名称,第二列是各信号的编号,第三列是默认处理动 作,Term表示终止当前进程,Core表示终止当前进程并且Core Dump(下一节详细介绍什么 是Core Dump),Ign表示忽略该信号,Stop表示停止当前进程,Cont表示继续执行先前停止的 进程,表中最后一列是简要介绍,说明什么条件下产生该信号。 产生信号的条件主要有: 用户在终端按下某些键时,终端驱动程序会发送信号给前台进程,例如Ctrl-C产 生SIGINT信号,Ctrl-\产生SIGQUIT信号,Ctrl-Z产生SIGTSTP信号(可使前台进程停止,这 个信号将在第 34 章 终端、作业控制与守护进程详细解释)。 硬件异常产生信号,这些条件由硬件检测到并通知内核,然后内核向当前进程发送适当的 信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常 解释为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,,MMU会产生异 常,内核将这个异常解释为SIGSEGV信号发送给进程。 一个进程调用kill(2)函数可以发送信号给另一个进程。 可以用kill(1)命令发送信号给某个进程,kill(1)命令也是调用kill(2)函数实现的,如果 不明确指定信号则发送SIGTERM信号,该信号的默认处理动作是终止进程。 当内核检测到某种软件条件发生时也可以通过信号通知进程,例如闹钟超时产 生SIGALRM信号,向读端已关闭的管道写数据时产生SIGPIPE信号。 如果不想按默认动作处理信号,用户程序可以调用sigaction(2)函数告诉内核如何处理某种信号 (sigaction函数稍后详细介绍),可选的处理动作有以下三种: 1. 忽略此信号。 2. 执行该信号的默认处理动作。 3. 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种 方式称为捕捉(Catch)一个信号。 上一页 第 33 章 信号 上一级 起始页 下一页 2. 产生信号 上一页 2. 产生信号 第 33 章 信号 下一页 2. 产生信号 2.1. 通过终端按键产生信号 上一节讲过,SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump,现在我们来验证一下。 首先解释什么是Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据 全部保存到磁盘上,文件名通常是core,这叫做Core Dump。进程异常终止通常是因为 有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫 做Post-mortem Debug。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个 信息保存在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏 感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。 首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024K: $ ulimit -c 1024 然后写一个死循环程序: #include int main(void) { while(1); return 0; } 前台运行这个程序,然后在终端键入Ctrl-C或Ctrl-\: $ ./a.out (按Ctrl-C) $ ./a.out (按Ctrl-\)Quit (core dumped) $ ls -l core -rw------- 1 akaedu akaedu 147456 2008-11-05 23:40 core ulimit命令改变了Shell进程的Resource Limit,a.out进程的PCB由Shell进程复制而来,所以也 具有和Shell进程相同的Resource Limit值,这样就可以产生Core Dump了。 2.2. 调用系统函数向进程发信号 仍以上一节的死循环程序为例,首先在后台执行这个程序,然后用kill命令给它发SIGSEGV信 号。 $ ./a.out & [1] 7940 $ kill -SIGSEGV 7940 $(再次回车) [1]+ Segmentation fault (core dumped) ./a.out 7940是a.out进程的id。之所以要再次回车才显示Segmentation fault,是因为在7940进程终止 掉之前已经回到了Shell提示符等待用户输入下一条命令,Shell不希望Segmentation fault信息 和用户的输入交错在一起,所以等用户输入命令之后才显示。指定某种信号的kill命令可以有 多种写法,上面的命令还可以写成kill -SEGV 7940或kill -11 7940,11是信号SIGSEGV的编 号。以往遇到的段错误都是由非法内存访问产生的,而这个程序本身没错,给它发SIGSEGV也能 产生段错误。 kill命令是调用kill函数实现的。kill函数可以给一个指定的进程发送指定的信号。raise函数 可以给当前进程发送指定的信号(自己给自己发信号)。 #include int kill(pid_t pid, int signo); int raise(int signo); 这两个函数都是成功返回0,错误返回-1。 abort函数使当前进程接收到SIGABRT信号而异常终止。 #include void abort(void); 就像exit函数一样,abort函数总是会成功的,所以没有返回值。 2.3. 由软件条件产生信号 SIGPIPE是一种由软件条件产生的信号,在例 30.7 “管道”中已经介绍过了。本节主要介 绍alarm 函数和SIGALRM信号。 #include unsigned int alarm(unsigned int seconds); 调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信 号,该信号的默认处理动作是终止当前进程。这个函数的返回值是0或者是以前设定的闹钟时间 还余下的秒数。打个比方,某人要小睡一觉,设定闹钟为30分钟之后响,20分钟后被人吵醒 了,还想多睡一会儿,于是重新设定闹钟为15分钟之后响,“以前设定的闹钟时间还余下的时 间”就是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定 的闹钟时间还余下的秒数。 例 33.1. alarm #include #include int main(void) { int counter; alarm(1); for(counter=0; 1; counter++) printf("counter=%d ", counter); return 0; } 这个程序的作用是1秒钟之内不停地数数,1秒钟到了就被SIGALRM信号终止。 上一页 1. 信号的基本概念 上一级 起始页 下一页 3. 阻塞信号 上一页 3. 阻塞信号 第 33 章 信号 下一页 3. 阻塞信号 3.1. 信号在内核中的表示 以上我们讨论了信号产生(Generation)的各种原因,而实际执行信号的处理动作称为信号递 达(Delivery),信号从产生到递达之间的状态,称为信号未决(Pending)。进程可以选择阻 塞(Block)某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻 塞,才执行递达的动作。注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是 在递达之后可选的一种处理动作。信号在内核中的表示可以看作是这样的: 图 33.1. 信号在内核中的表示示意图 每个信号都有两个标志位分别表示阻塞和未决,还有一个函数指针表示处理动作。信号产生 时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子 中, 1. SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。 2. SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在 没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。 3. SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函 数sighandler 。 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递 送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信 号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。从上图来看,每个信 号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。 因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型 可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否 被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。下一节将详细介 绍信号集的各种操作。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏 蔽”应该理解为阻塞而不是忽略。 3.2. 信号集操作函数 sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这 些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操 作sigset_t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是 没有意义的。 #include int sigemptyset(sigset_t *set); int sigfillset(sigset_t *set); int sigaddset(sigset_t *set, int signo); int sigdelset(sigset_t *set, int signo); int sigismember(const sigset_t *set, int signo); 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不 包含任何有效信号。函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置 位,表示该信号集的有效信号包括系统支持的所有信号。注意,在使用sigset_t类型的变量之 前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始 化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信 号。这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号 集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。 3.3. sigprocmask 调用函数sigprocmask可以读取或更改进程的信号屏蔽字。 #include int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 返回值:若成功则为0,若出错则为-1 如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针, 则更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来 的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字 为mask,下表说明了how参数的可选值。 表 33.1. how参数的含义 set包含了我们希望添加到当前信号屏蔽字的信号,相当 SIG_BLOCK 于mask=mask|set set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当 SIG_UNBLOCK 于mask=mask&~set SIG_SETMASK 设置当前信号屏蔽字为set所指向的值,相当于mask=set 如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其 中一个信号递达。 3.4. sigpending #include int sigpending(sigset_t *set); sigpending读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回1。 下面用刚学的几个函数做个实验。程序如下: #include #include void printsigset(const sigset_t *set) { int i; for (i = 1; i < 32; i++) if (sigismember(set, i) == 1) putchar('1'); else putchar('0'); puts(""); } int main(void) { sigset_t s, p; sigemptyset(&s); sigaddset(&s, SIGINT); sigprocmask(SIG_BLOCK, &s, NULL); while (1) { sigpending(&p); printsigset(&p); sleep(1); } return 0; } 程序运行时,每秒钟把各信号的未决状态打印一遍,由于我们阻塞了SIGINT信号,按Ctrl-C将会 使SIGINT信号处于未决状态,按Ctrl-\仍然可以终止程序,因为SIGQUIT信号没有阻塞。 $ ./a.out 0000000000000000000000000000000 0000000000000000000000000000000(这时按Ctrl-C) 0100000000000000000000000000000 0100000000000000000000000000000(这时按Ctrl-\) Quit (core dumped) 上一页 2. 产生信号 上一级 起始页 下一页 4. 捕捉信号 上一页 4. 捕捉信号 第 33 章 信号 下一页 4. 捕捉信号 4.1. 内核如何实现信号的捕捉 如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由 于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 1. 用户程序注册了SIGQUIT信号的处理函数sighandler。 2. 当前正在执行main函数,这时发生中断或异常切换到内核态。 3. 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 4. 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系, 是两个独立的控制流程。 5. sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 6. 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。 图 33.2. 信号的捕捉 上图出自[ULK]。 4.2. sigaction #include int sigaction(int signo, const struct sigaction *act, struct sigaction *oact); sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回1。signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针 非空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体: struct sigaction { void (*sa_handler)(int); sigset_t sa_mask; */ int sa_flags; */ /* addr of signal handler, */ /* or SIG_IGN, or SIG_DFL */ /* additional signals to block /* signal options, Figure 10.16 /* alternate handler */ void (*sa_sigaction)(int, siginfo_t *, void *); }; 将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系 统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号 处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这 样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而 是被系统所调用。 当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函 数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产 生,那么它会被阻塞到当前处理结束为止。如果在调用信号处理函数时,除了当前信号被自动 屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当 信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags字段包含一些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理 函数,本章不详细解释这两个字段,有兴趣的读者参考[APUE2e]。 4.3. pause #include int pause(void); pause函数使调用进程挂起直到有信号递达。如果信号的处理动作是终止进程,则进程终 止,pause函数没有机会返回;如果信号的处理动作是忽略,则进程继续处于挂起状 态,pause不返回;如果信号的处理动作是捕捉,则调用了信号处理函数之后pause返回1,errno设置为EINTR,所以pause只有出错的返回值(想想以前还学过什么函数只有出错返回 值?)。错误码EINTR表示“被信号中断”。 下面我们用alarm 和pause 实现sleep(3) 函数,称为mysleep。 例 33.2. mysleep #include #include #include void sig_alrm(int signo) { /* nothing to do */ } unsigned int mysleep(unsigned int nsecs) { struct sigaction newact, oldact; unsigned int unslept; newact.sa_handler = sig_alrm; sigemptyset(&newact.sa_mask); newact.sa_flags = 0; sigaction(SIGALRM, &newact, &oldact); alarm(nsecs); pause(); unslept = alarm(0); sigaction(SIGALRM, &oldact, NULL); return unslept; } int main(void) { while(1){ mysleep(2); printf("Two seconds passed\n"); } return 0; } 1. main函数调用mysleep函数,后者调用sigaction注册了SIGALRM信号的处理函数sig_alrm。 2. 调用alarm(nsecs)设定闹钟。 3. 调用pause等待,内核切换到别的进程运行。 4. nsecs秒之后,闹钟超时,内核发SIGALRM给这个进程。 5. 从内核态返回这个进程的用户态之前处理未决信号,发现有SIGALRM信号,其处理函数 是sig_alrm。 6. 切换到用户态执行sig_alrm函数,进入sig_alrm函数时SIGALRM信号被自动屏蔽, 从sig_alrm函数返回时SIGALRM信号自动解除屏蔽。然后自动执行系统调用sigreturn再次 进入内核,再返回用户态继续执行进程的主控制流程(main函数调用的mysleep函数)。 7. pause函数返回-1,然后调用alarm(0)取消闹钟,调用sigaction恢复SIGALRM信号以前的处 理动作。 以下问题留给读者思考: 1、信号处理函数sig_alrm什么都没干,为什么还要注册它作为SIGALRM的处理函数?不注册信 号处理函数可以吗? 2、为什么在mysleep函数返回前要恢复SIGALRM信号原来的sigaction? 3、mysleep函数的返回值表示什么含义?什么情况下返回非0值?。 4.4. 可重入函数 当捕捉到信号时,不论进程的主控制流程当前执行到哪儿,都会先跳到