索引

最后更新于:2022-04-01 22:02:32

# 索引 ### 符号 !号,Exclamation Mark, [布尔代数](ch04s03.html) "引号,Double Quote, [继续Hello World](ch02s01.html) \#!,Shebang, [执行脚本](ch31s02.html#id2872211) \#号,Pound Sign,Number Sign or Hash Sign, [数学函数](ch03s01.html) %号,Percent Sign, [常量](ch02s02.html) &号,Ampersand, [布尔代数](ch04s03.html) '引号,Single Quote or Apostrophe, [继续Hello World](ch02s01.html) ()括号,Parenthesis, [表达式](expr.expression.html) \*号,Asterisk, [继续Hello World](ch02s01.html) .号,Period, [复合类型与结构体](ch07s01.html) /斜线,Slash, [继续Hello World](ch02s01.html) 1's Complement, [1's Complement表示法](ch14s03.html#id2753761) 1-bit Full Adder, [为什么计算机用二进制计数](ch14s01.html) 1GL,1st Generation Programming Language, [程序和编程语言](intro.program.html) 2's Complement, [2's Complement表示法](ch14s03.html#id2753996) 2GL,2nd Generation Programming Language, [程序和编程语言](intro.program.html) 3GL,3rd Generation Programming Language, [程序和编程语言](intro.program.html) 4GL,4th Generation Programming Language, [程序和编程语言](intro.program.html) 5GL,5th Generation Programming Language, [程序和编程语言](intro.program.html) 9's Complement, [1's Complement表示法](ch14s03.html#id2753761) :号,Colon, [goto语句和标号](ch06s06.html) ;号,Semicolon, [第一个程序](intro.helloworld.html) <>括号,Angel Bracket, [数学函数](ch03s01.html) ?号,Question Mark, [继续Hello World](ch02s01.html) \[\]括号,Bracket, [数组的基本概念](ch08s01.html) \斜线,Backslash, [继续Hello World](ch02s01.html) \_下划线,Underscore, [变量](expr.variable.html) {}括号,Brace or Curly Brace, [第一个程序](intro.helloworld.html) |线,Pipe Sign, [布尔代数](ch04s03.html) Θ-notation, [算法的时间复杂度分析](ch11s03.html) 分页符,Form Feed, [继续Hello World](ch02s01.html) 响铃,Alert or Bell, [继续Hello World](ch02s01.html) 回车,Carriage Return, [继续Hello World](ch02s01.html) 垂直制表符,Vertical Tab, [继续Hello World](ch02s01.html) 换行符,Line Feed, [继续Hello World](ch02s01.html) 水平制表符,Horizontal Tab, [继续Hello World](ch02s01.html) 空格,Blank, [第一个程序](intro.helloworld.html) 退格,Backspace, [继续Hello World](ch02s01.html) ### A ABI,Application Binary Interface, [函数调用](ch19s01.html) Abstraction Layer, [数据抽象](ch07s02.html) Accumulator, [while语句](ch06s01.html) Adapt, [数据类型标志](ch07s03.html) Address, [内存与地址](ch17s01.html) Address Operator, [指针的基本概念](ch23s01.html) Address Space, [CPU](ch17s02.html) Addressing Mode, [寻址方式](ch18s04.html) Algorithm, [算法的概念](ch11s01.html) Alignment, [结构体和联合体](ch19s04.html) Allocated Storage Duration, [变量的存储布局](ch19s03.html) Ambiguity,歧义, [自然语言和形式语言](intro.naturalformal.html) Amortize, [Memory Hierarchy](ch17s05.html) Anchor, [引言](ch32s01.html) ANSI,American National Standards Institute, [继续Hello World](ch02s01.html) Append, [fopen/fclose](ch25s02.html#id2829869) Architecture,体系结构, [程序和编程语言](intro.program.html) Argument,实参, [形参和实参](ch03s03.html) Arithmetic Type, [复合类型与结构体](ch07s01.html) Array, [数组的基本概念](ch08s01.html) ASCII,American Standard Code for Information Interchange,美国信息交换标准码, [字符类型与字符编码](ch02s06.html) Assembler, [最简单的汇编程序](ch18s01.html) Assembler Directive, [最简单的汇编程序](ch18s01.html) (参见 Pseudo-operation) Assembler,汇编器, [程序和编程语言](intro.program.html) Assembly Language,汇编语言, [程序和编程语言](intro.program.html) Assertion, [折半查找](ch11s06.html) Assignment,赋值, [赋值](ch02s04.html) Associativity,结合性, [表达式](expr.expression.html) Asynchronous, [信号的基本概念](ch33s01.html) Automatic Storage Duration, [变量的存储布局](ch19s03.html) Average Case, [算法的时间复杂度分析](ch11s03.html) ### B Backgroud, [wait和waitpid函数](ch30s03.html#id2867242) Backward Compatibility, [继续Hello World](ch02s01.html) Base Case, [递归](ch05s03.html) Base Pointer Addressing Mode, [寻址方式](ch18s04.html) Basic Multilingual Plane, [Unicode和UTF-8](apas02.html) Batch, [Shell的历史](ch31s01.html) Best Practice, [变量](expr.variable.html) BFS,Breadth First Search, [队列与广度优先搜索](ch12s04.html) Biased Exponent, [浮点数](ch14s04.html) Big Endian, [CPU](ch17s02.html) Big-O notation, [算法的时间复杂度分析](ch11s03.html) Binary, [为什么计算机用二进制计数](ch14s01.html) Binary File, [文件的基本概念](ch25s02.html#id2829671) Binary Operator,双目运算符, [布尔代数](ch04s03.html) Binary Search, [折半查找](ch11s06.html) Binary Tree, [二叉树的基本概念](ch26s02.html#id2845875) Bit-field, [结构体和联合体](ch19s04.html) Bitwise AND, [按位与、或、异或、取反运算](ch16s01.html#id2761062) Bitwise NOT, [按位与、或、异或、取反运算](ch16s01.html#id2761062) Bitwise OR, [按位与、或、异或、取反运算](ch16s01.html#id2761062) Bitwise Shift, [移位运算](ch16s01.html#id2761805) Bitwise XOR, [按位与、或、异或、取反运算](ch16s01.html#id2761062) Bit,位, [为什么计算机用二进制计数](ch14s01.html) Block, [read/write](ch28s04.html), [总体存储布局](ch29s02.html#id2857323) Block Bitmap, [总体存储布局](ch29s02.html#id2857323) Block Group, [总体存储布局](ch29s02.html#id2857323) Block Scope, [变量的存储布局](ch19s03.html) Boilerplate, [第一个程序](intro.helloworld.html) Boolean Algebra,布尔代数, [布尔代数](ch04s03.html) Boot Block, [总体存储布局](ch29s02.html#id2857323) Bootloader, [设备](ch17s03.html) Branch, [if语句](ch04s01.html) Break, [虚拟内存管理](ch20s05.html) Breakpoint, [断点](ch10s02.html) BST,Binary Search Tree, [排序二叉树](ch26s02.html#id2846120) Buffer, [strcpy与strncpy](ch24s01.html#id2819066) Buffer Overflow, [strcpy与strncpy](ch24s01.html#id2819066) Bug, [程序的调试](ch01s03.html) Bus, [CPU](ch17s02.html) Byte, [赋值](ch02s04.html) Byte Order, [CPU](ch17s02.html) ### C C89, [继续Hello World](ch02s01.html) (参见 C90) (参见 ISO/IEC 9899:1990) C90, [继续Hello World](ch02s01.html) (参见 C89) (参见 ISO/IEC 9899:1990) C99, [继续Hello World](ch02s01.html) (参见 ISO/IEC 9899:1999) Cache, [Memory Hierarchy](ch17s05.html) Cache Line, [Memory Hierarchy](ch17s05.html) Call by Value, [形参和实参](ch03s03.html) Callback Function, [回调函数](ch24s05.html) Callee, [折半查找](ch11s06.html) Caller, [折半查找](ch11s06.html) Calling Convention, [函数调用](ch19s01.html) CamelCase, [标识符命名](ch09s03.html) Carry, [为什么计算机用二进制计数](ch14s01.html) Cast Operator, [强制类型转换](ch15s03.html#id2758655) Catch, [信号的基本概念](ch33s01.html) Ceiling, [表达式](expr.expression.html) Character, [常量](ch02s02.html) Character Class, [引言](ch32s01.html) Character Encoding,字符编码, [字符类型与字符编码](ch02s06.html) Child Process, [引言](ch30s01.html) Circular Linked List, [双向链表](ch26s01.html#id2845376) Circular Queue, [环形队列](ch12s05.html) Class Invariant, [堆栈](ch12s02.html) Clause, [if/else语句](ch04s02.html) Code Path, [return语句](ch05s01.html) Coding Style,代码风格, [继续Hello World](ch02s01.html) Coercion, [强制类型转换](ch15s03.html#id2758655) (参见 Implicit Conversion) Collision, [哈希表](ch26s03.html) Column-major, [多维数组](ch08s05.html) Comma Operator, [逗号运算符](ch16s02.html#id2762598) Comment,注释, [第一个程序](intro.helloworld.html) Compiler,编译器, [程序和编程语言](intro.program.html) Compile,编译, [程序和编程语言](intro.program.html) Composition, [表达式](expr.expression.html) Compound Assignment Operator, [复合赋值运算符](ch16s02.html#id2762352) Compound Literal, [复合类型与结构体](ch07s01.html) Compound Type, [复合类型与结构体](ch07s01.html) Condition Variable, [Condition Variable](ch35s03.html#id2895424) Conditional Operator, [条件运算符](ch16s02.html#id2762537) Constant Expression, [全局变量、局部变量和作用域](ch03s04.html) Constant,常量, [常量](ch02s02.html) Context,上下文, [自然语言和形式语言](intro.naturalformal.html) Contract, [折半查找](ch11s06.html) Control Flow, [if语句](ch04s01.html) Controlling Expression, [if语句](ch04s01.html) Controlling Terminal, [终端的基本概念](ch34s01.html#id2890359) Conversion Specification, [常量](ch02s02.html) Core Dump, [通过终端按键产生信号](ch33s02.html#id2884244) CPU,Central Processing Unit,中央处理器, [计算机体系结构基础](ch17.html) (参见 Processor,处理器) Current Working Directory, [fopen/fclose](ch25s02.html#id2829869), [引言](ch30s01.html) ### D Daemon, [守护进程](ch34s03.html) Dangling-else, [if/else语句](ch04s02.html) Data Abstraction, [复合类型与结构体](ch07s01.html) Data Block, [总体存储布局](ch29s02.html#id2857323) Data Structure, [数据结构的概念](ch12s01.html) Data-driven Programming, [多维数组](ch08s05.html) DbC,Design by Contract, [折半查找](ch11s06.html) Dead Code, [return语句](ch05s01.html) Debug,调试, [程序的调试](ch01s03.html) Decimal,十进制, [为什么计算机用二进制计数](ch14s01.html) Declaration, [变量](expr.variable.html) Declarative, [程序和编程语言](intro.program.html) Decrement Operator, [for语句](ch06s03.html) Default Argument Promotion, [Integer Promotion](ch15s03.html#id2757955) Definition, [变量](expr.variable.html) Delimiter, [继续Hello World](ch02s01.html), [分割字符串](ch25s01.html#id2829046) dentry cache, [内核数据结构](ch29s03.html#id2860264) Dequeue, [队列与广度优先搜索](ch12s04.html) Dereference, [指针的基本概念](ch23s01.html) Designated Initializer, [复合类型与结构体](ch07s01.html) Device, [设备](ch17s03.html) Device Driver, [设备](ch17s03.html) DFS,Depth First Search, [深度优先搜索](ch12s03.html) Direct Addressing Mode, [寻址方式](ch18s04.html) Disassemble, [目标文件](ch18s05.html#id2770854) Divide-and-Conquer, [归并排序](ch11s04.html) DRAM,Dynamic RAM, [Memory Hierarchy](ch17s05.html) Duff's Device, [goto语句和标号](ch06s06.html) ### E Echo, [终端登录过程](ch34s01.html#id2891132) Element, [数组的基本概念](ch08s01.html) Encapsulate,封装, [if/else语句](ch04s02.html) Encapsulation, [extern和static关键字](ch20s02.html#id2787367), [fopen/fclose](ch25s02.html#id2829869) Enqueue, [队列与广度优先搜索](ch12s04.html) Enumeration, [数据类型标志](ch07s03.html) Epoch, [数组应用实例:直方图](ch08s03.html), [本节综合练习](ch25s02.html#id2834904) Equality Operator, [if语句](ch04s01.html) Escape Sequence,转义序列, [继续Hello World](ch02s01.html) Exception, [goto语句和标号](ch06s06.html), [MMU](ch17s04.html) Executable, [ELF文件](ch18s05.html) Exit Status, [自定义函数](ch03s02.html) Explicit Conversion, [强制类型转换](ch15s03.html#id2758655) Exponent, [浮点数](ch14s04.html) Export, [extern和static关键字](ch20s02.html#id2787367) Expression,表达式, [表达式](expr.expression.html) Extended ASCII, [ASCII码](apas01.html) External Linkage, [变量的存储布局](ch19s03.html) ### F Factorial, [递归](ch05s03.html) False, [if语句](ch04s01.html) Fetch, [计算机体系结构基础](ch17.html) FIFO,First In First Out, [队列与广度优先搜索](ch12s04.html) File Descriptor, [C标准I/O库函数与Unbuffered I/O函数](ch28s02.html) File Scope, [变量的存储布局](ch19s03.html) File Status Flag, [fcntl](ch28s06.html) Filesystem Hierarchy Standard, [形参和实参](ch03s03.html) Flat, [嵌套结构体](ch07s04.html) Flip-flop, [Memory Hierarchy](ch17s05.html) Floating Point, [常量](ch02s02.html) Floor, [表达式](expr.expression.html) Flush, [C标准库的I/O缓冲区](ch25s02.html#id2834346) Foreground, [wait和waitpid函数](ch30s03.html#id2867242) Formal Language,形式语言, [自然语言和形式语言](intro.naturalformal.html) Format String,格式化字符串, [常量](ch02s02.html) FPU,Floating Point Unit, [浮点型](ch15s02.html) Function Call,函数调用, [数学函数](ch03s01.html) Function Designator, [数学函数](ch03s01.html) Function Prototype Scope, [变量的存储布局](ch19s03.html) Function Scope, [变量的存储布局](ch19s03.html) Function Type, [数学函数](ch03s01.html) Function-like Macro, [函数式宏定义](ch21s02.html#id2797214) Functional Programming, [while语句](ch06s01.html) Function,函数, [数学函数](ch03s01.html) ### G Gate, [为什么计算机用二进制计数](ch14s01.html) GCD,Greatest Common Divisor,最大公约数, [习题](ch05s03.html#id2723842) GDT,Group Descriptor Table, [总体存储布局](ch29s02.html#id2857323) General-purpose Register, [CPU](ch17s02.html) Generalize,泛化, [数学函数](ch03s01.html) Generics Algorithm, [回调函数](ch24s05.html) Global Variable, [全局变量、局部变量和作用域](ch03s04.html) Globbing, [文件名代换(Globbing):* ? []](ch31s03.html#id2872839) Grammar,语法, [自然语言和形式语言](intro.naturalformal.html) Greedy, [sed](ch32s03.html) Group Descriptor, [总体存储布局](ch29s02.html#id2857323) ### H Half Word, [CPU](ch17s02.html) Handle, [fopen/fclose](ch25s02.html#id2829869) (参见 Opaque Pointer) Hard coding, [数组应用实例:统计随机数](ch08s02.html) Hard-float, [浮点型](ch15s02.html) Header File,头文件, [数学函数](ch03s01.html) Header Guard, [头文件](ch20s02.html#id2788051) Heap, [虚拟内存管理](ch20s05.html) Helper Function, [函数](ch09s04.html) Heredoc,Here Document, [以字节为单位的I/O函数](ch25s02.html#id2831236) Hexadecimal, [不同进制之间的换算](ch14s02.html) High-level Language,高级语言, [程序和编程语言](intro.program.html) High-order Function, [回调函数](ch24s05.html) Highlight,高亮显示, [变量](expr.variable.html) Histogram, [数组应用实例:直方图](ch08s03.html) Hungarian notation, [标识符命名](ch09s03.html) ### I Identifier, [变量](expr.variable.html) IDE,Integrated Development Environment, [为什么要在Linux平台上学C语言?用Windows学C语言不好吗?](pr02.html#id2702965) IEEE 1003.1, [C标准I/O库函数与Unbuffered I/O函数](ch28s02.html) (参见 POSIX.1) IEEE 754, [浮点数](ch14s04.html) ILP32, [整型](ch15s01.html) Immediate, [最简单的汇编程序](ch18s01.html) Immediate Mode, [寻址方式](ch18s04.html) Imperative, [程序和编程语言](intro.program.html) Imperative Programming, [while语句](ch06s01.html) Implementation-defined, [整型](ch15s01.html) Implicit Conversion, [强制类型转换](ch15s03.html#id2758655) (参见 Coercion) Implicit Declaration, [自定义函数](ch03s02.html) Implicit Rule, [隐含规则和模式规则](ch22s02.html) Implied, [浮点数](ch14s04.html) Incremental, [增量式开发](ch05s02.html), [归并排序](ch11s04.html) Indent, [第一个程序](intro.helloworld.html) Index, [数组的基本概念](ch08s01.html) Indexed Addressing Mode, [寻址方式](ch18s04.html) Indirect Addressing Mode, [寻址方式](ch18s04.html) Indirect Block, [数据块寻址](ch29s02.html#id2859212) Indirection Operator, [指针的基本概念](ch23s01.html) Infinite Loop, [while语句](ch06s01.html) Infinite recursion, [递归](ch05s03.html) Initialization,初始化, [赋值](ch02s04.html) Initializer, [赋值](ch02s04.html) Inline Assembly, [C内联汇编](ch19s05.html) inline function, [内联函数](ch21s02.html#id2797661) inode, [总体存储布局](ch29s02.html#id2857323) inode Bitmap, [总体存储布局](ch29s02.html#id2857323) inode Table, [总体存储布局](ch29s02.html#id2857323) Input,输入, [程序和编程语言](intro.program.html) Institute of Electrical and Electronics Engineers, [浮点数](ch14s04.html) Instruction Decoder, [CPU](ch17s02.html) Instruction Set,指令集, [程序和编程语言](intro.program.html) Instruction,指令, [程序和编程语言](intro.program.html) Integer, [常量](ch02s02.html) Integer Conversion Rank, [Usual Arithmetic Conversion](ch15s03.html#id2758200) Integer Promotion, [Integer Promotion](ch15s03.html#id2757955) Integer Type, [字符类型与字符编码](ch02s06.html) Interactive, [Shell的历史](ch31s01.html) Interface, [形参和实参](ch03s03.html) Internal Linkage, [变量的存储布局](ch19s03.html) Internet Super-Server, [网络登录过程](ch34s01.html#id2891618) Interpreter,解释器, [程序和编程语言](intro.program.html) Interpret,解释, [程序和编程语言](intro.program.html) Interrupt, [设备](ch17s03.html) Inverter, [为什么计算机用二进制计数](ch14s01.html) IPC,InterProcess Communication, [进程间通信](ch30s04.html) ISO 10646, [Unicode和UTF-8](apas02.html) ISO/IEC 9899:1990, [继续Hello World](ch02s01.html) (参见 C89) (参见 C90) ISO/IEC 9899:1999, [继续Hello World](ch02s01.html) (参见 C99) ISR,Interrupt Service Routine, [设备](ch17s03.html) Iteration, [while语句](ch06s01.html) ### J Job, [Session与进程组](ch34s02.html#id2892071) Job Control, [Session与进程组](ch34s02.html#id2892071) ### K k-th Order Statistic, [习题](ch11s05.html#id2746619) Kernel, [设备](ch17s03.html) Key-value Pair, [习题](ch25s01.html#id2829560), [本节综合练习](ch25s02.html#id2834904) Keyword,关键字, [变量](expr.variable.html) (参见 Reserved Word,保留字) ### L Label, [goto语句和标号](ch06s06.html) Leap of Faith, [递归](ch05s03.html) Lexical,词法, [自然语言和形式语言](intro.naturalformal.html) LIFO,Last In First Out, [堆栈](ch12s02.html) Line Discipline, [终端登录过程](ch34s01.html#id2891132) Linear Function, [算法的时间复杂度分析](ch11s03.html) Linkage, [变量的存储布局](ch19s03.html) Linker Script, [多目标文件的链接](ch20s01.html) Linker,或Link Editor, [最简单的汇编程序](ch18s01.html) Literal,字面, [自然语言和形式语言](intro.naturalformal.html) Little Endian, [CPU](ch17s02.html) Load, [设备](ch17s03.html) Loader, [ELF文件](ch18s05.html) Local Variable,局部变量, [全局变量、局部变量和作用域](ch03s04.html) Locality, [Memory Hierarchy](ch17s05.html) Logical AND, [布尔代数](ch04s03.html) Logical NOT, [布尔代数](ch04s03.html) Logical OR, [布尔代数](ch04s03.html) Loop Invariant, [插入排序](ch11s02.html) Loop Variable, [while语句](ch06s01.html) Loop,循环, [while语句](ch06s01.html) Low Coupling, High Cohesion, [函数类型和函数指针类型](ch23s08.html) Low-level Language,低级语言, [程序和编程语言](intro.program.html) LP64, [整型](ch15s01.html) LSB,Least Significant Bit, [不同进制之间的换算](ch14s02.html) lvalue,左值, [表达式](expr.expression.html) ### M Machine Language,机器语言, [程序和编程语言](intro.program.html) Macro, [数组应用实例:统计随机数](ch08s02.html) Maintenance, [折半查找](ch11s06.html) Mantissa, [浮点数](ch14s04.html) (参见 Significand) Mask, [掩码](ch16s01.html#id2761995) Mathematical Induction, [递归](ch05s03.html) Member, [复合类型与结构体](ch07s01.html) Memberwise Initialization, [复合类型与结构体](ch07s01.html) Memory, [计算机体系结构基础](ch17.html) Memory Hierarchy, [Memory Hierarchy](ch17s05.html) Memory Leak, [malloc与free](ch24s01.html#id2820062) Metaphor,隐喻, [自然语言和形式语言](intro.naturalformal.html) MMU,Memory Management Unit,内存管理单元, [MMU](ch17s04.html) Mnemonic,助记符, [程序和编程语言](intro.program.html) Modulo, [if/else语句](ch04s02.html) MSB,Most Significant Bit, [不同进制之间的换算](ch14s02.html) Multi-dimensional Array, [多维数组](ch08s05.html) Multibyte Character, [在Linux C编程中使用Unicode和UTF-8](apas03.html) Multiplex, [网络登录过程](ch34s01.html#id2891618) Mutex,Mutual Exclusive Lock, [mutex](ch35s03.html#id2896462) ### N Name Space, [变量的存储布局](ch19s03.html) Natural Language,自然语言, [自然语言和形式语言](intro.naturalformal.html) Necessary Condition, [全局变量、局部变量和作用域](ch03s04.html) Nest,嵌套, [继续Hello World](ch02s01.html) No Linkage, [变量的存储布局](ch19s03.html) Node, [不完全类型和复杂声明](ch23s09.html) Non-printable Character, [字符类型与字符编码](ch02s06.html) Non-volatile Memory, [Memory Hierarchy](ch17s05.html) Nonblock I/O, [open/close](ch28s03.html) Normalize, [浮点数](ch14s04.html) Null Character, [字符类型与字符编码](ch02s06.html) Null Statement, [if语句](ch04s01.html) Null-terminated String, [字符串](ch08s04.html) ### O Object File, [ELF文件](ch18s05.html) (参见 Relocatable) Object-like Macro, [函数式宏定义](ch21s02.html#id2797214) Octal, [不同进制之间的换算](ch14s02.html) Offset, [以字节为单位的I/O函数](ch25s02.html#id2831236) Old Style C, [继续Hello World](ch02s01.html) Opaque Pointer, [fopen/fclose](ch25s02.html#id2829869) (参见 Handle) Operand,操作数, [表达式](expr.expression.html) Operating System, [程序和编程语言](intro.program.html), [设备](ch17s03.html) Operator,运算符, [表达式](expr.expression.html) Out-of-band, [ioctl](ch28s07.html) Output,输出, [程序和编程语言](intro.program.html) Overflow, [Sign and Magnitude表示法](ch14s03.html#id2753623) Override, [作为交互登录Shell启动,或者使用--login参数启动](ch31s04.html#id2873231) ### P Padding, [结构体和联合体](ch19s04.html) Page Frame,页帧, [MMU](ch17s04.html) Page in, [虚拟内存管理](ch20s05.html) Page out, [虚拟内存管理](ch20s05.html) Page Table, [MMU](ch17s04.html) Page,页, [MMU](ch17s04.html) Paging,换页, [虚拟内存管理](ch20s05.html) Parameter,形参, [形参和实参](ch03s03.html) Parent Process, [引言](ch30s01.html) Parity, [if/else语句](ch04s02.html) Parity Check, [异或运算的一些特性](ch16s01.html#id2762114) Parse,解析, [自然语言和形式语言](intro.naturalformal.html) Pattern, [引言](ch32s01.html) Pattern Rule, [隐含规则和模式规则](ch22s02.html) PA,Physical Address,物理地址, [MMU](ch17s04.html) PCB,Process Control Block, [C标准I/O库函数与Unbuffered I/O函数](ch28s02.html) PC,Program Counter, [CPU](ch17s02.html) Placeholder, [常量](ch02s02.html) Plane, [Unicode和UTF-8](apas02.html) Platform Independent,平台无关的, [程序和编程语言](intro.program.html) Pointer, [堆栈](ch12s02.html) Poll, [read/write](ch28s04.html) Pop, [堆栈](ch12s02.html) Portable,可移植, [程序和编程语言](intro.program.html) Position Independent Code, [编译、链接、运行](ch20s04.html#id2789691) Positional Parameter, [位置参数和特殊变量](ch31s05.html#id2874685) POSIX.1, [C标准I/O库函数与Unbuffered I/O函数](ch28s02.html) (参见 IEEE 1003.1) POSIX,Portable Operating System Interface, [C标准I/O库函数与Unbuffered I/O函数](ch28s02.html) Post-mortem Debug, [通过终端按键产生信号](ch33s02.html#id2884244) Postcondition, [折半查找](ch11s06.html) Postfix Decrement Operator, [for语句](ch06s03.html) Postfix Increment Operator, [for语句](ch06s03.html) Precondition , [折半查找](ch11s06.html) Predecessor, [深度优先搜索](ch12s03.html) Prededence,优先级, [表达式](expr.expression.html) Predicate, [return语句](ch05s01.html) Prefix Increment Operator, [for语句](ch06s03.html) Preprocess, [数组应用实例:统计随机数](ch08s02.html) Preprocessing Directive, [数组应用实例:统计随机数](ch08s02.html) Prerequisite, [基本规则](ch22s01.html) Primitive Type, [复合类型与结构体](ch07s01.html) Privileged Mode, [MMU](ch17s04.html) Procedure Abstraction, [复合类型与结构体](ch07s01.html) Process, [设备](ch17s03.html) Process Descriptor, [C标准I/O库函数与Unbuffered I/O函数](ch28s02.html) Process Group, [Session与进程组](ch34s02.html#id2892071) Process Group Leader, [Session与进程组](ch34s02.html#id2892071) Processor,处理器, [计算机体系结构基础](ch17.html) (参见 CPU,Central Processing Unit,中央处理器) Programming Language,编程语言, [程序和编程语言](intro.program.html) Program,程序, [程序和编程语言](intro.program.html) Prototype, [自定义函数](ch03s02.html) Pseudo TTY, [网络登录过程](ch34s01.html#id2891618) Pseudo-operation, [最简单的汇编程序](ch18s01.html) (参见 Assembler Directive) Pseudocode, [深度优先搜索](ch12s03.html) Pseudorandom, [数组应用实例:统计随机数](ch08s02.html) PTY Master, [网络登录过程](ch34s01.html#id2891618) PTY Slave, [网络登录过程](ch34s01.html#id2891618) Push, [堆栈](ch12s02.html) ### Q Quadratic Function, [算法的时间复杂度分析](ch11s03.html) Quantifier, [引言](ch32s01.html) ### R Race Condition, [竞态条件与sigsuspend函数](ch33s04.html#id2886686) Radix, [浮点数](ch14s04.html) Random Access Memory, [Memory Hierarchy](ch17s05.html) Rationale, [形参和实参](ch03s03.html) Recurrence, [归并排序](ch11s04.html) Recursive, [递归](ch05s03.html) Redundancy,冗余, [自然语言和形式语言](intro.naturalformal.html) Redundant Array of Independent Disks,独立磁盘冗余阵列, [习题](ch16s01.html#id2762311) Reference, [指针的基本概念](ch23s01.html) Reference Count, [内核数据结构](ch29s03.html#id2860264) Register, [CPU](ch17s02.html) Register Addressing Mode, [寻址方式](ch18s04.html) Regular Expression, [引言](ch32s01.html) Regular File, [stdin/stdout/stderr](ch25s02.html#id2830485) Relational Operator, [if语句](ch04s01.html) Release, [折半查找](ch11s06.html) Relocatable, [ELF文件](ch18s05.html) (参见 Object File) Remainder, [if/else语句](ch04s02.html) Reserved Word,保留字, [变量](expr.variable.html) (参见 Keyword,关键字) Resource Limit, [引言](ch30s01.html) Return Value,返回值, [数学函数](ch03s01.html) Reuse, [增量式开发](ch05s02.html) Ripple Carry Adder, [为什么计算机用二进制计数](ch14s01.html) Row-major, [多维数组](ch08s05.html) Rule, [基本规则](ch22s01.html) Rule of Least Surprise, [形参和实参](ch03s03.html) Run-time,运行时, [程序的调试](ch01s03.html) Running, [read/write](ch28s04.html) rvalue,右值, [表达式](expr.expression.html) (参见 Value,值) ### S Scaffold, [增量式开发](ch05s02.html) Scalar Type, [复合类型与结构体](ch07s01.html) Scientific Notation, [浮点数](ch14s04.html) Scope, [变量的存储布局](ch19s03.html) Script, [Shell的历史](ch31s01.html) Section, [最简单的汇编程序](ch18s01.html) Sector, [实例剖析](ch29s02.html#id2858019) sed,Stream Editor, [sed](ch32s03.html) Seed, [数组应用实例:直方图](ch08s03.html) Segment, [ELF文件](ch18s05.html) Selection Statement, [if语句](ch04s01.html) Semantic,语义, [自然语言和形式语言](intro.naturalformal.html) Semaphore, [Semaphore](ch35s03.html#id2897332) Sentinel, [指向指针的指针与指针数组](ch23s06.html) Sequence Point, [Side Effect与Sequence Point](ch16s03.html) Session, [Session与进程组](ch34s02.html#id2892071) Session Leader, [Session与进程组](ch34s02.html#id2892071) Shared Object,or Shared Library, [ELF文件](ch18s05.html) Short-circuit, [Side Effect与Sequence Point](ch16s03.html) Side Effect, [数学函数](ch03s01.html) Sign and Magnitude, [Sign and Magnitude表示法](ch14s03.html#id2753623) Sign Bit, [Sign and Magnitude表示法](ch14s03.html#id2753623) Sign Extension, [编译器如何处理类型转换](ch15s03.html#id2758764) Signal Mask, [信号在内核中的表示](ch33s03.html#id2884694) Signed Number, [有符号数和无符号数](ch14s03.html#id2754091) Significance Loss, [浮点数](ch14s04.html) Significand, [浮点数](ch14s04.html) (参见 Mantissa) Single Linked List, [单链表](ch26s01.html#id2844144) Single Pass, [数组应用实例:直方图](ch08s03.html) Sleep, [read/write](ch28s04.html) Slot, [哈希表](ch26s03.html) Soft-float, [浮点型](ch15s02.html) Source Code,源代码, [程序和编程语言](intro.program.html) Sparse, [复合类型与结构体](ch07s01.html) Special Case, [单链表](ch26s01.html#id2844144) Special-purpose Register, [CPU](ch17s02.html) SQL,Structured Query Language,结构化查询语言, [程序和编程语言](intro.program.html) SRAM,Static RAM, [Memory Hierarchy](ch17s05.html) Stack, [递归](ch05s03.html) Stack Frame, [递归](ch05s03.html) Standalone, [网络登录过程](ch34s01.html#id2891618) Standard Error, [stdin/stdout/stderr](ch25s02.html#id2830485) Standard Input, [stdin/stdout/stderr](ch25s02.html#id2830485) Standard Output, [stdin/stdout/stderr](ch25s02.html#id2830485) Startup Routine, [main函数和启动例程](ch19s02.html) Statement, [程序和编程语言](intro.program.html) Statement Block, [if语句](ch04s01.html) Static Storage Duration, [变量的存储布局](ch19s03.html) Stem, [隐含规则和模式规则](ch22s02.html) Storage Class Specifier, [变量的存储布局](ch19s03.html) Storage Duration,or Lifetime, [变量的存储布局](ch19s03.html) Stratify, [增量式开发](ch05s02.html) Stream, [以字节为单位的I/O函数](ch25s02.html#id2831236) String Literal, [继续Hello World](ch02s01.html) Structure, [自然语言和形式语言](intro.naturalformal.html) Substring, [搜索字符串](ch25s01.html#id2828881) Successor, [深度优先搜索](ch12s03.html) Sufficient Condition, [全局变量、局部变量和作用域](ch03s04.html) Super Block, [总体存储布局](ch29s02.html#id2857323) Surrogate Pair, [Unicode和UTF-8](apas02.html) SUS,Single UNIX Specification, [C标准I/O库函数与Unbuffered I/O函数](ch28s02.html) Swap Device,交换设备, [虚拟内存管理](ch20s05.html) Symbol, [最简单的汇编程序](ch18s01.html) Syntax,语法, [自然语言和形式语言](intro.naturalformal.html) System Call, [最简单的汇编程序](ch18s01.html) ### T Tag, [复合类型与结构体](ch07s01.html) Target, [基本规则](ch22s01.html) Terminal, [stdin/stdout/stderr](ch25s02.html#id2830485) Ternary Operator, [条件运算符](ch16s02.html#id2762537) Text File, [文件的基本概念](ch25s02.html#id2829671) The Open Group, [C标准I/O库函数与Unbuffered I/O函数](ch28s02.html) Tight Loop, [read/write](ch28s04.html) Timing, [竞态条件与sigsuspend函数](ch33s04.html#id2886686) Token, [自然语言和形式语言](intro.naturalformal.html) Translation Unit, [变量的存储布局](ch19s03.html) Traversal, [数组的基本概念](ch08s01.html) Trigraph, [常量](ch02s02.html) True, [if语句](ch04s01.html) Truncate, [fopen/fclose](ch25s02.html#id2829869), [open/close](ch28s03.html) Truncate toward Zero, [表达式](expr.expression.html) Truth Table,真值表, [布尔代数](ch04s03.html) Type, [常量](ch02s02.html) Type Cast, [强制类型转换](ch15s03.html#id2758655) Type Qualifier, [变量的存储布局](ch19s03.html) ### U UCS-2, [Unicode和UTF-8](apas02.html) UCS-4, [Unicode和UTF-8](apas02.html) UCS,Universal Character Set, [Unicode和UTF-8](apas02.html) Unary Operator,单目运算符, [布尔代数](ch04s03.html) Unbound Pointer, [指针的基本概念](ch23s01.html) Unbuffered I/O, [C标准I/O库函数与Unbuffered I/O函数](ch28s02.html) Undefined, [整型](ch15s01.html) Underflow, [Sign and Magnitude表示法](ch14s03.html#id2753623) Unicode, [Unicode和UTF-8](apas02.html) Unicode Transformation Format, [Unicode和UTF-8](apas02.html) Uniform Distribution, [数组应用实例:统计随机数](ch08s02.html) Unsigned Number, [有符号数和无符号数](ch14s03.html#id2754091) Unspecified, [整型](ch15s01.html) Upper Bound, [算法的时间复杂度分析](ch11s03.html) User Mode, [MMU](ch17s04.html) Usual Arithmetic Conversion, [Usual Arithmetic Conversion](ch15s03.html#id2758200) UTC,Coordinated Universal Time, [本节综合练习](ch25s02.html#id2834904) UTF-16, [Unicode和UTF-8](apas02.html) UTF-32, [Unicode和UTF-8](apas02.html) UTF-8, [Unicode和UTF-8](apas02.html) ### V Value-result, [传入参数与传出参数](ch24s02.html) Value,值, [变量](expr.variable.html) (参见 rvalue,右值) Variable Argument, [形参和实参](ch03s03.html) Variable,变量, [变量](expr.variable.html) VA,Virtual Address,虚拟地址, [MMU](ch17s04.html) VFS,Virtual Filesystem, [VFS](ch29s03.html) Virtual Memory Management,虚拟内存管理, [MMU](ch17s04.html) Virtual Terminal, [终端登录过程](ch34s01.html#id2891132) VLA,Variable Length Array, [数组的基本概念](ch08s01.html) Volatile Memory, [Memory Hierarchy](ch17s05.html) Von Neumann Architecture, [计算机体系结构基础](ch17.html) ### W Watchpoint, [观察点](ch10s03.html) Whitespace, [字符类型与字符编码](ch02s06.html) Wide Character, [在Linux C编程中使用Unicode和UTF-8](apas03.html) Wildcard, [文件名代换(Globbing):* ? []](ch31s03.html#id2872839) Wire, [为什么计算机用二进制计数](ch14s01.html) Word, [CPU](ch17s02.html) Worst Case, [算法的时间复杂度分析](ch11s03.html) ### X XOR,eXclusive OR, [为什么计算机用二进制计数](ch14s01.html) XSI,X/Open System Interface, [C标准I/O库函数与Unbuffered I/O函数](ch28s02.html) ### Z Zeroth, [数组的基本概念](ch08s01.html) Zombie, [wait和waitpid函数](ch30s03.html#id2867242)
';

参考书目

最后更新于:2022-04-01 22:02:30

# 参考书目 [ThinkCpp] _How To Think Like A Computer Scientist: Learning with C++_. Allen B. Downey. [GroudUp] _Programming from the Ground Up: An Introduction to Programming using Linux Assembly Language_. Jonathan Bartlett. [K&R] _The C Programming Language_. Brian W. Kernighan和Dennis M. Ritchie. 2\. [Standard C] _Standard C: A Reference_. P. J. Plauger和Jim Brodie. [Standard C Library] _The Standard C Library_. P. J. Plauger. [C99 Rationale] _Rationale for International Standard - Programming Languages - C_. 5.10\. [UNIX编程艺术] _The Art of UNIX Programming_. Eric Raymond. [C99] _ISO/IEC 9899: Programming Languages - C_. 2\. [数字逻辑基础] _Fundamentals of Digital Logic with VHDL Design_. Stephen Brown和Zvonko Vranesic. 2\. [IATLC] _Introduction to Automata Theory, Languages, and Computation_. John E. Hopcroft、Rajeev Motwani和Jeffrey D. Ullman. 2\. [Dragon Book] _Compilers: Principles, Techniques, & Tools_. Alfred V. Aho、Monica S. Lam、Ravi Sethi和Jeffrey D. Ullman. 2\. [SICP] _Structure and Interpretation of Computer Programs_. Harold Abelson、Gerald Jay Sussman和Julie Sussman. 2\. [人月神话] _The Mythical Man-Month: Essays on Software Engineering_. Frederick P. Brooks, Jr.. Anniversary Edition. [CodingStyle] _Linux内核源代码目录下的Documentation/CodingStyle文件_. [GDB] _Debugging with GDB: The GNU Source-Level Debugger_. 9\. Richard Stallman、Roland Pesch和Stan Shebs. [算法导论] _Introduction to Algorithms_. 2\. Thomas H. Cormen、Charles E. Leiserson、Ronald L. Rivest和Clifford Stein. [TAOCP] _The Art of Computer Programming_. Donald E. Knuth. [编程珠玑] _Programming Pearls_. 2\. Jon Bentley. [OOSC] _Object-Oriented Software Construction_. Bertrand Meyer. [算法+数据结构=程序] _Algorithms + Data Structures = Programs_. Niklaus Wirth. [AssemblyHOWTO] _Linux Assembly HOWTO([http://tldp.org/HOWTO/Assembly-HOWTO/](http://tldp.org/HOWTO/Assembly-HOWTO/))很不幸,目前tldp.org被我们伟大的防火墙屏蔽了,请自己找代理访问_. Konstantin Boldyshev和Francois-Rene Rideau. [x86Assembly] _Introduction to 80x86 Assembly Language and Computer Architecture_. Richard C. Detmer. [GNUmake] 3\. _Managing Projects with GNU make_. Robert Mecklenburg. [SmashStack] _Smashing The Stack For Fun And Profit,网上到处都可以搜到这篇文章_. Aleph One. [BeganFORTRAN] _The New C: It All Began with FORTRAN([http://www.ddj.com/cpp/184401313](http://www.ddj.com/cpp/184401313))_. Randy Meyers. [具体数学] _Concrete Mathematics_. 2\. Ronald L. Graham、Donald E. Knuth和Oren Patashnik. [APUE2e] _Advanced Programming in the UNIX Environment_. 2\. W. Richard Stevens和Stephen A. Rago. [ULK] _Understanding the Linux Kernel_. 3\. Daniel P. Bovet和Marco Cesati. [TCPIP] _TCP/IP Illustrated, Volume 1: The Protocols_. W. Richard Stevens. [UNPv13e] _UNIX Network Programming, Volume 1: The Sockets Networking API_. 3\. W. Richard Stevens、Bill Fenner和Andrew M. Rudoff. [Unicode FAQ] _UTF-8 and Unicode FAQ, [http://www.cl.cam.ac.uk/~mgk25/unicode.html](http://www.cl.cam.ac.uk/~mgk25/unicode.html)_. Markus Kuhn.
';

附录 B. GNU Free Documentation License Version 1.3, 3 November 2008

最后更新于:2022-04-01 22:02:28

# 附录 B. GNU Free Documentation License Version 1.3, 3 November 2008 Copyright © 2000, 2001, 2002, 2007, 2008 Free Software Foundation, Inc. <http://fsf.org/> 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 machine-readable 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 Back-Cover 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 public-standard 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.
';

附录 A. 字符编码

最后更新于:2022-04-01 22:02:25

# 附录 A. 字符编码 **目录** + [1\. ASCII码](apas01.html) + [2\. Unicode和UTF-8](apas02.html) + [3\. 在Linux C编程中使用Unicode和UTF-8](apas03.html) ## 1. ASCII码 ASCII码的取值范围是0~127,可以用7个bit表示。C语言中`char`型变量的大小规定为一字节,如果存放ASCII码则只用到低7位,高位为0。以下是ASCII码表: **图 A.1. ASCII码表** ![ASCII码表](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80dc07a4f.png) 绝大多数计算机的一个字节是8位,取值范围是0~255,而ASCII码并没有规定编号为128~255的字符,为了能表示更多字符,各厂商制定了很多种ASCII码的扩展规范。注意,虽然通常把这些规范称为扩展ASCII码(Extended ASCII),但其实它们并不属于ASCII码标准。例如以下这种扩展ASCII码由IBM制定,在字符终端下被广泛采用,其中包含了很多表格边线字符用来画界面。 **图 A.2. IBM的扩展ASCII码表** ![IBM的扩展ASCII码表](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80dc19d28.png) 在图形界面中最广泛使用的扩展ASCII码是ISO-8859-1,也称为Latin-1,其中包含欧洲各国语言中最常用的非英文字母,但毕竟只有128个字符,某些语言中的某些字母没有包含。如下表所示。 **图 A.3. ISO-8859-1** ![ISO-8859-1](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80dc2900e.png) 编号为128~159的是一些控制字符,在上表中没有列出。 ## 2. Unicode和UTF-8 为了统一全世界各国语言文字和专业领域符号(例如数学符号、乐谱符号)的编码,ISO制定了ISO 10646标准,也称为UCS(Universal Character Set)。UCS编码的长度是31位,可以表示231个字符。如果两个字符编码的高位相同,只有低16位不同,则它们属于一个平面(Plane),所以一个平面由216个字符组成。目前常用的大部分字符都位于第一个平面(编码范围是U-00000000~U-0000FFFD),称为BMP(Basic Multilingual Plane)或Plane 0,为了向后兼容,其中编号为0~256的字符和Latin-1相同。UCS编码通常用U-xxxxxxxx这种形式表示,而BMP的编码通常用U+xxxx这种形式表示,其中x是十六进制数字。在ISO制定UCS的同时,另一个由厂商联合组织也在着手制定这样的编码,称为Unicode,后来两家联手制定统一的编码,但各自发布各自的标准文档,所以UCS编码和Unicode码是相同的。 有了字符编码,另一个问题就是这样的编码在计算机中怎么表示。现在已经不可能用一个字节表示一个字符了,最直接的想法就是用四个字节表示一个字符,这种表示方法称为UCS-4或UTF-32,UTF是Unicode Transformation Format的缩写。一方面这样比较浪费存储空间,由于常用字符都集中在BMP,高位的两个字节通常是0,如果只用ASCII码或Latin-1,高位的三个字节都是0。另一种比较节省存储空间的办法是用两个字节表示一个字符,称为UCS-2或UTF-16,这样只能表示BMP中的字符,但BMP中有一些扩展字符,可以用两个这样的扩展字符表示其它平面的字符,称为Surrogate Pair。无论是UTF-32还是UTF-16都有一个更严重的问题是和C语言不兼容,在C语言中0字节表示字符串结尾,库函数`strlen`、`strcpy`等等都依赖于这一点,如果字符串用UTF-32存储,其中有很多0字节并不表示字符串结尾,这就乱套了。 UNIX之父Ken Thompson提出的UTF-8编码很好地解决了这些问题,现在得到广泛应用。UTF-8具有以下性质: * 编码为U+0000~U+007F的字符只占一个字节,就是0x00~0x7F,和ASCII码兼容。 * 编码大于U+007F的字符用2~6个字节表示,每个字节的最高位都是1,而ASCII码的最高位都是0,因此非ASCII码字符的表示中不会出现ASCII码字节(也就不会出现0字节)。 * 用于表示非ASCII码字符的多字节序列中,第一个字节的取值范围是0xC0~0xFD,根据它可以判断后面有多少个字节也属于当前字符的编码。后面每个字节的取值范围都是0x80~0xBF,见下面的详细说明。 * UCS定义的所有231个字符都可以用UTF-8编码表示出来。 * UTF-8编码最长6个字节,BMP字符的UTF-8编码最长三个字节。 * 0xFE和0xFF这两个字节在UTF-8编码中不会出现。 具体来说,UTF-8编码有以下几种格式: U-00000000 – U-0000007F:  0xxxxxxx U-00000080 – U-000007FF:  110xxxxx 10xxxxxx U-00000800 – U-0000FFFF:  1110xxxx 10xxxxxx 10xxxxxx U-00010000 – U-001FFFFF:  11110xxx 10xxxxxx 10xxxxxx 10xxxxxx U-00200000 – U-03FFFFFF:  111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx U-04000000 – U-7FFFFFFF:  1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 第一个字节要么最高位是0(ASCII字节),要么最高两位都是1,最高位之后1的个数决定后面有多少个字节也属于当前字符编码,例如111110xx,最高位之后还有四个1,表示后面有四个字节也属于当前字符的编码。后面每个字节的最高两位都是10,可以和第一个字节区分开。这样的设计有利于误码同步,例如在网络传输过程中丢失了几个字节,很容易判断当前字符是不完整的,也很容易找到下一个字符从哪里开始,结果顶多丢掉一两个字符,而不会导致后面的编码解释全部混乱了。上面的格式中标为x的位就是UCS编码,最后一种6字节的格式中x位有31个,可以表示31位的UCS编码,UTF-8就像一列火车,第一个字节是车头,后面每个字节是车厢,其中承载的货物是UCS编码。UTF-8规定承载的UCS编码以大端表示,也就是说第一个字节中的x是UCS编码的高位,后面字节中的x是UCS编码的低位。 例如U+00A9(©字符)的二进制是10101001,编码成UTF-8是11000010 10101001(0xC2 0xA9),但不能编码成11100000 10000010 10101001,UTF-8规定每个字符只能用尽可能少的字节来编码。 ## 3. 在Linux C编程中使用Unicode和UTF-8 目前各种Linux发行版都支持UTF-8编码,当前系统的语言和字符编码设置保存在一些环境变量中,可以通过`locale`命令查看: ``` $ locale LANG=en_US.UTF-8 LC_CTYPE="en_US.UTF-8" LC_NUMERIC="en_US.UTF-8" LC_TIME="en_US.UTF-8" LC_COLLATE="en_US.UTF-8" LC_MONETARY="en_US.UTF-8" LC_MESSAGES="en_US.UTF-8" LC_PAPER="en_US.UTF-8" LC_NAME="en_US.UTF-8" LC_ADDRESS="en_US.UTF-8" LC_TELEPHONE="en_US.UTF-8" LC_MEASUREMENT="en_US.UTF-8" LC_IDENTIFICATION="en_US.UTF-8" LC_ALL= ``` 常用汉字也都位于BMP中,所以一个汉字的存储通常占3个字节。例如编辑一个C程序: ``` #include int main(void) { printf("你好\n"); return 0; } ``` 源文件是以UTF-8编码存储的: ``` $ od -tc nihao.c 0000000 # i n c l u d e < s t d i o . 0000020 h > \n \n i n t m a i n ( v o i 0000040 d ) \n { \n \t p r i n t f ( " 344 275 0000060 240 345 245 275 \ n " ) ; \n \t r e t u r 0000100 n 0 ; \n } \n 0000107 ``` 其中八进制的`344 375 240`(十六进制`e4 bd a0`)就是“你”的UTF-8编码,八进制的`345 245 275`(十六进制`e5 a5 bd`)就是“好”。把它编译成目标文件,`"你好\n"`这个字符串就成了这样一串字节:`e4 bd a0 e5 a5 bd 0a 00`,汉字在其中仍然是UTF-8编码的,一个汉字占3个字节,这种字符在C语言中称为多字节字符(Multibyte Character)。运行这个程序相当于把这一串字节`write`到当前终端的设备文件。如果当前终端的驱动程序能够识别UTF-8编码就能打印出汉字,如果当前终端的驱动程序不能识别UTF-8编码(比如一般的字符终端)就打印不出汉字。也就是说,像这种程序,识别汉字的工作既不是由C编译器做的也不是由`libc`做的,C编译器原封不动地把源文件中的UTF-8编码复制到目标文件中,`libc`只是当作以0结尾的字符串原封不动地`write`给内核,识别汉字的工作是由终端的驱动程序做的。 但是仅有这种程度的汉字支持是不够的,有时候我们需要在C程序中操作字符串里的字符,比如求字符串`"你好\n"`中有几个汉字或字符,用`strlen`就不灵了,因为`strlen`只看结尾的0字节而不管字符串里存的是什么,求出来的是字节数7。为了在程序中操作Unicode字符,C语言定义了宽字符(Wide Character)类型`wchar_t`和一些库函数。在字符常量或字符串字面值前面加一个L就表示宽字符常量或宽字符串,例如定义`wchar_t c = L'你';`,变量`c`的值就是汉字“你”的31位UCS编码,而`L"你好\n"`就相当于`{L'你', L'好', L'\n', 0}`,`wcslen`函数就可以取宽字符串中的字符个数。看下面的程序: ``` #include #include int main(void) { if (!setlocale(LC_CTYPE, "")) { fprintf(stderr, "Can't set the specified locale! " "Check LANG, LC_CTYPE, LC_ALL.\n"); return 1; } printf("%ls", L"你好\n"); return 0; } ``` 宽字符串`L"你好\n"`在源代码中当然还是存成UTF-8编码的,但编译器会把它变成4个UCS编码`0x00004f60 0x0000597d 0x0000000a 0x00000000`保存在目标文件中,按小端存储就是`60 4f 00 00 7d 59 00 00 0a 00 00 00 00 00 00 00`,用`od`命令查看目标文件应该能找到这些字节。 ``` $ gcc hihao.c $ od -tx1 a.out ``` `printf`的`%ls`转换说明表示把后面的参数按宽字符串解释,不是见到0字节就结束,而是见到UCS编码为0的字符才结束,但是要`write`到终端仍然需要以多字节编码输出,这样终端驱动程序才能识别,所以`printf`在内部把宽字符串转换成多字节字符串再`write`出去。事实上,C标准并没有规定多字节字符必须以UTF-8编码,也可以使用其它的多字节编码,在运行时根据环境变量确定当前系统的编码,所以在程序开头需要调用`setlocale`获取当前系统的编码设置,如果当前系统是UTF-8的,`printf`就把UCS编码转换成UTF-8编码的多字节字符串再`write`出去。一般来说,程序在做内部计算时通常以宽字符编码,如果要存盘或者输出给别的程序,或者通过网络发给别的程序,则采用多字节编码。 关于Unicode和UTF-8本节只介绍了最基本的概念,部分内容出自[[Unicode FAQ]](bi01.html#bibli.unicodefaq "UTF-8 and Unicode FAQ, http://www.cl.cam.ac.uk/~mgk25/unicode.html"),读者可进一步参考这篇文章。
';

第 37 章 socket编程

最后更新于:2022-04-01 22:02:23

# 第 37 章 socket编程 **目录** + [1\. 预备知识](ch37s01.html) + [1.1\. 网络字节序](ch37s01.html#id2902826) + [1.2\. socket地址的数据类型及相关函数](ch37s01.html#id2902915) + [2\. 基于TCP协议的网络程序](ch37s02.html) + [2.1\. 最简单的TCP网络程序](ch37s02.html#id2902690) + [2.2\. 错误处理与读写控制](ch37s02.html#id2903656) + [2.3\. 把client改为交互式输入](ch37s02.html#id2903862) + [2.4\. 使用fork并发处理多个client的请求](ch37s02.html#id2903959) + [2.5\. setsockopt](ch37s02.html#id2904007) + [2.6\. 使用select](ch37s02.html#id2904122) + [3\. 基于UDP协议的网络程序](ch37s03.html) + [4\. UNIX Domain Socket IPC](ch37s04.html) + [5\. 练习:实现简单的Web服务器](ch37s05.html) + [5.1\. 基本HTTP协议](ch37s05.html#id2904532) + [5.2\. 执行CGI程序](ch37s05.html#id2904687) socket这个词可以表示很多概念: * 在TCP/IP协议中,“IP地址+TCP或UDP端口号”唯一标识网络通讯中的一个进程,“IP地址+端口号”就称为socket。 * 在TCP协议中,建立连接的两个进程各自有一个socket来标识,那么这两个socket组成的socket pair就唯一标识一个连接。socket本身有“插座”的意思,因此用来描述网络连接的一对一关系。 * TCP/IP协议最早在BSD UNIX上实现,为TCP/IP协议设计的应用层编程接口称为socket API。 本节的主要内容是socket API,主要介绍TCP协议的函数接口,最后简要介绍UDP协议和UNIX Domain Socket的函数接口。 ## 1. 预备知识 ### 1.1. 网络字节序 我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分,磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分。网络数据流同样有大端小端之分,那么如何定义网络数据流的地址呢?发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出,接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存,因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址。 TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节。例如上一节的UDP段格式,地址0-1是16位的源端口号,如果这个端口号是1000(0x3e8),则地址0是0x03,地址1是0xe8,也就是先发0x03,再发0xe8,这16位在发送主机的缓冲区中也应该是低地址存0x03,高地址存0xe8。但是,如果发送主机是小端字节序的,这16位被解释成0xe803,而不是1000。因此,发送主机把1000填到发送缓冲区之前需要做字节序的转换。同样地,接收主机如果是小端字节序的,接到16位的源端口号也要做字节序的转换。如果主机是大端字节序的,发送和接收都不需要做转换。同理,32位的IP地址也要考虑网络字节序和主机字节序的问题。 为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换。 ``` #include uint32_t htonl(uint32_t hostlong); uint16_t htons(uint16_t hostshort); uint32_t ntohl(uint32_t netlong); uint16_t ntohs(uint16_t netshort); ``` 这些函数名很好记,h表示host,n表示network,l表示32位长整数,s表示16位短整数。例如htonl表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回,如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。 ### 1.2. socket地址的数据类型及相关函数 socket API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、IPv6,以及后面要讲的UNIX Domain Socket。然而,各种网络协议的地址格式并不相同,如下图所示: **图 37.1. sockaddr数据结构** ![sockaddr数据结构](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80dbba70f.png) IPv4和IPv6的地址格式定义在`netinet/in.h`中,IPv4地址用sockaddr_in结构体表示,包括16位端口号和32位IP地址,IPv6地址用sockaddr_in6结构体表示,包括16位端口号、128位IP地址和一些控制字段。UNIX Domain Socket的地址格式定义在`sys/un.h`中,用sockaddr_un结构体表示。各种socket地址结构体的开头都是相同的,前16位表示整个结构体的长度(并不是所有UNIX的实现都有长度字段,如Linux就没有),后16位表示地址类型。IPv4、IPv6和UNIX Domain Socket的地址类型分别定义为常数AF_INET、AF_INET6、AF_UNIX。这样,只要取得某种sockaddr结构体的首地址,不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容。因此,socket API可以接受各种类型的sockaddr结构体指针做参数,例如bind、accept、connect等函数,这些函数的参数应该设计成void *类型以便接受各种类型的指针,但是sock API的实现早于ANSI C标准化,那时还没有void *类型,因此这些函数的参数都用struct sockaddr *类型表示,在传递参数之前要强制类型转换一下,例如: ``` struct sockaddr_in servaddr; /* initialize servaddr */ bind(listen_fd, (struct sockaddr *)&servaddr, sizeof(servaddr)); ``` 本节只介绍基于IPv4的socket网络编程,sockaddr_in中的成员struct in_addr sin_addr表示32位的IP地址。但是我们通常用点分十进制的字符串表示IP地址,以下函数可以在字符串表示和in_addr表示之间转换。 字符串转in_addr的函数: ``` #include int inet_aton(const char *strptr, struct in_addr *addrptr); in_addr_t inet_addr(const char *strptr); int inet_pton(int family, const char *strptr, void *addrptr); ``` in_addr转字符串的函数: ``` char *inet_ntoa(struct in_addr inaddr); const char *inet_ntop(int family, const void *addrptr, char *strptr, size_t len); ``` 其中inet_pton和inet_ntop不仅可以转换IPv4的in_addr,还可以转换IPv6的in6_addr,因此函数接口是void *addrptr。 ## 2. 基于TCP协议的网络程序 下图是基于TCP协议的客户端/服务器程序的一般流程: **图 37.2. TCP协议通讯流程** ![TCP协议通讯流程](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80dbcb915.png) 服务器调用socket()、bind()、listen()完成初始化后,调用accept()阻塞等待,处于监听端口的状态,客户端调用socket()初始化后,调用connect()发出SYN段并阻塞等待服务器应答,服务器应答一个SYN-ACK段,客户端收到后从connect()返回,同时应答一个ACK段,服务器收到后从accept()返回。 数据传输的过程: 建立连接后,TCP协议提供全双工的通信服务,但是一般的客户端/服务器程序的流程是由客户端主动发起请求,服务器被动处理请求,一问一答的方式。因此,服务器从accept()返回后立刻调用read(),读socket就像读管道一样,如果没有数据到达就阻塞等待,这时客户端调用write()发送请求给服务器,服务器收到后从read()返回,对客户端的请求进行处理,在此期间客户端调用read()阻塞等待服务器的应答,服务器调用write()将处理结果发回给客户端,再次调用read()阻塞等待下一条请求,客户端收到后从read()返回,发送下一条请求,如此循环下去。 如果客户端没有更多的请求了,就调用close()关闭连接,就像写端关闭的管道一样,服务器的read()返回0,这样服务器就知道客户端关闭了连接,也调用close()关闭连接。注意,任何一方调用close()后,连接的两个传输方向都关闭,不能再发送数据了。如果一方调用shutdown()则连接处于半关闭状态,仍可接收对方发来的数据。 在学习socket API时要注意应用程序和TCP协议层是如何交互的: *应用程序调用某个socket函数时TCP协议层完成什么动作,比如调用connect()会发出SYN段 *应用程序如何知道TCP协议层的状态变化,比如从某个阻塞的socket函数返回就表明TCP协议收到了某些段,再比如read()返回0就表明收到了FIN段 ### 2.1. 最简单的TCP网络程序 下面通过最简单的客户端/服务器程序的实例来学习socket API。 server.c的作用是从客户端读字符,然后将每个字符转换为大写并回送给客户端。 ``` /* server.c */ #include #include #include #include #include #include #define MAXLINE 80 #define SERV_PORT 8000 int main(void) { struct sockaddr_in servaddr, cliaddr; socklen_t cliaddr_len; int listenfd, connfd; char buf[MAXLINE]; char str[INET_ADDRSTRLEN]; int i, n; listenfd = socket(AF_INET, SOCK_STREAM, 0); bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(SERV_PORT); bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)); listen(listenfd, 20); printf("Accepting connections ...\n"); while (1) { cliaddr_len = sizeof(cliaddr); connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len); n = read(connfd, buf, MAXLINE); printf("received from %s at PORT %d\n", inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)), ntohs(cliaddr.sin_port)); for (i = 0; i < n; i++) buf[i] = toupper(buf[i]); write(connfd, buf, n); close(connfd); } } ``` 下面介绍程序中用到的socket API,这些函数都在`sys/socket.h`中。 ``` int socket(int family, int type, int protocol); ``` socket()打开一个网络通讯端口,如果成功的话,就像open()一样返回一个文件描述符,应用程序可以像读写文件一样用read/write在网络上收发数据,如果socket()调用出错则返回-1。对于IPv4,family参数指定为AF_INET。对于TCP协议,type参数指定为SOCK_STREAM,表示面向流的传输协议。如果是UDP协议,则type参数指定为SOCK_DGRAM,表示面向数据报的传输协议。protocol参数的介绍从略,指定为0即可。 ``` int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen); ``` 服务器程序所监听的网络地址和端口号通常是固定不变的,客户端程序得知服务器程序的地址和端口号后就可以向服务器发起连接,因此服务器需要调用bind绑定一个固定的网络地址和端口号。bind()成功返回0,失败返回-1。 bind()的作用是将参数sockfd和myaddr绑定在一起,使sockfd这个用于网络通讯的文件描述符监听myaddr所描述的地址和端口号。前面讲过,struct sockaddr *是一个通用指针类型,myaddr参数实际上可以接受多种协议的sockaddr结构体,而它们的长度各不相同,所以需要第三个参数addrlen指定结构体的长度。我们的程序中对myaddr参数是这样初始化的: ``` bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(SERV_PORT); ``` 首先将整个结构体清零,然后设置地址类型为AF_INET,网络地址为INADDR_ANY,这个宏表示本地的任意IP地址,因为服务器可能有多个网卡,每个网卡也可能绑定多个IP地址,这样设置可以在所有的IP地址上监听,直到与某个客户端建立了连接时才确定下来到底用哪个IP地址,端口号为SERV_PORT,我们定义为8000。 ``` int listen(int sockfd, int backlog); ``` 典型的服务器程序可以同时服务于多个客户端,当有客户端发起连接时,服务器调用的accept()返回并接受这个连接,如果有大量的客户端发起连接而服务器来不及处理,尚未accept的客户端就处于连接等待状态,listen()声明sockfd处于监听状态,并且最多允许有backlog个客户端处于连接待状态,如果接收到更多的连接请求就忽略。listen()成功返回0,失败返回-1。 ``` int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen); ``` 三方握手完成后,服务器调用accept()接受连接,如果服务器调用accept()时还没有客户端的连接请求,就阻塞等待直到有客户端连接上来。cliaddr是一个传出参数,accept()返回时传出客户端的地址和端口号。addrlen参数是一个传入传出参数(value-result argument),传入的是调用者提供的缓冲区cliaddr的长度以避免缓冲区溢出问题,传出的是客户端地址结构体的实际长度(有可能没有占满调用者提供的缓冲区)。如果给cliaddr参数传NULL,表示不关心客户端的地址。 我们的服务器程序结构是这样的: ``` while (1) { cliaddr_len = sizeof(cliaddr); connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len); n = read(connfd, buf, MAXLINE); ... close(connfd); } ``` 整个是一个while死循环,每次循环处理一个客户端连接。由于cliaddr_len是传入传出参数,每次调用accept()之前应该重新赋初值。accept()的参数listenfd是先前的监听文件描述符,而accept()的返回值是另外一个文件描述符connfd,之后与客户端之间就通过这个connfd通讯,最后关闭connfd断开连接,而不关闭listenfd,再次回到循环开头listenfd仍然用作accept的参数。accept()成功返回一个文件描述符,出错返回-1。 client.c的作用是从命令行参数中获得一个字符串发给服务器,然后接收服务器返回的字符串并打印。 ``` /* client.c */ #include #include #include #include #include #include #define MAXLINE 80 #define SERV_PORT 8000 int main(int argc, char *argv[]) { struct sockaddr_in servaddr; char buf[MAXLINE]; int sockfd, n; char *str; if (argc != 2) { fputs("usage: ./client message\n", stderr); exit(1); } str = argv[1]; sockfd = socket(AF_INET, SOCK_STREAM, 0); bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr); servaddr.sin_port = htons(SERV_PORT); connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)); write(sockfd, str, strlen(str)); n = read(sockfd, buf, MAXLINE); printf("Response from server:\n"); write(STDOUT_FILENO, buf, n); close(sockfd); return 0; } ``` 由于客户端不需要固定的端口号,因此不必调用bind(),客户端的端口号由内核自动分配。注意,客户端不是不允许调用bind(),只是没有必要调用bind()固定一个端口号,服务器也不是必须调用bind(),但如果服务器不调用bind(),内核会自动给服务器分配监听端口,每次启动服务器时端口号都不一样,客户端要连接服务器就会遇到麻烦。 ``` int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen); ``` 客户端需要调用connect()连接服务器,connect和bind的参数形式一致,区别在于bind的参数是自己的地址,而connect的参数是对方的地址。connect()成功返回0,出错返回-1。 先编译运行服务器: ``` $ ./server Accepting connections ... ``` 然后在另一个终端里用netstat命令查看: ``` $ netstat -apn|grep 8000 tcp 0 0 0.0.0.0:8000 0.0.0.0:* LISTEN 8148/server ``` 可以看到server程序监听8000端口,IP地址还没确定下来。现在编译运行客户端: ``` $ ./client abcd Response from server: ABCD ``` 回到server所在的终端,看看server的输出: ``` $ ./server Accepting connections ... received from 127.0.0.1 at PORT 59757 ``` 可见客户端的端口号是自动分配的。现在把客户端所连接的服务器IP改为其它主机的IP,试试两台主机的通讯。 再做一个小实验,在客户端的connect()代码之后插一个while(1);死循环,使客户端和服务器都处于连接中的状态,用netstat命令查看: ``` $ ./server & [1] 8343 $ Accepting connections ... ./client abcd & [2] 8344 $ netstat -apn|grep 8000 tcp 0 0 0.0.0.0:8000 0.0.0.0:* LISTEN 8343/server tcp 0 0 127.0.0.1:44406 127.0.0.1:8000 ESTABLISHED8344/client tcp 0 0 127.0.0.1:8000 127.0.0.1:44406 ESTABLISHED8343/server ``` 应用程序中的一个socket文件描述符对应一个socket pair,也就是源地址:源端口号和目的地址:目的端口号,也对应一个TCP连接。 **表 37.1. client和server的socket状态** | socket文件描述符 | 源地址:源端口号 | 目的地址:目的端口号 | 状态 | | --- | --- | --- | --- | | server.c中的listenfd | 0.0.0.0:8000 | 0.0.0.0:* | LISTEN | | server.c中的connfd | 127.0.0.1:8000 | 127.0.0.1:44406 | ESTABLISHED | | client.c中的sockfd | 127.0.0.1:44406 | 127.0.0.1:8000 | ESTABLISHED | ### 2.2. 错误处理与读写控制 上面的例子不仅功能简单,而且简单到几乎没有什么错误处理,我们知道,系统调用不能保证每次都成功,必须进行出错处理,这样一方面可以保证程序逻辑正常,另一方面可以迅速得到故障信息。 为使错误处理的代码不影响主程序的可读性,我们把与socket相关的一些系统函数加上错误处理代码包装成新的函数,做成一个模块wrap.c: ``` #include #include #include void perr_exit(const char *s) { perror(s); exit(1); } int Accept(int fd, struct sockaddr *sa, socklen_t *salenptr) { int n; again: if ( (n = accept(fd, sa, salenptr)) < 0) { if ((errno == ECONNABORTED) || (errno == EINTR)) goto again; else perr_exit("accept error"); } return n; } void Bind(int fd, const struct sockaddr *sa, socklen_t salen) { if (bind(fd, sa, salen) < 0) perr_exit("bind error"); } void Connect(int fd, const struct sockaddr *sa, socklen_t salen) { if (connect(fd, sa, salen) < 0) perr_exit("connect error"); } void Listen(int fd, int backlog) { if (listen(fd, backlog) < 0) perr_exit("listen error"); } int Socket(int family, int type, int protocol) { int n; if ( (n = socket(family, type, protocol)) < 0) perr_exit("socket error"); return n; } ssize_t Read(int fd, void *ptr, size_t nbytes) { ssize_t n; again: if ( (n = read(fd, ptr, nbytes)) == -1) { if (errno == EINTR) goto again; else return -1; } return n; } ssize_t Write(int fd, const void *ptr, size_t nbytes) { ssize_t n; again: if ( (n = write(fd, ptr, nbytes)) == -1) { if (errno == EINTR) goto again; else return -1; } return n; } void Close(int fd) { if (close(fd) == -1) perr_exit("close error"); } ``` 慢系统调用accept、read和write被信号中断时应该重试。connect虽然也会阻塞,但是被信号中断时不能立刻重试。对于accept,如果errno是ECONNABORTED,也应该重试。详细解释见参考资料。 TCP协议是面向流的,read和write调用的返回值往往小于参数指定的字节数。对于read调用,如果接收缓冲区中有20字节,请求读100个字节,就会返回20。对于write调用,如果请求写100个字节,而发送缓冲区中只有20个字节的空闲位置,那么write会阻塞,直到把100个字节全部交给发送缓冲区才返回,但如果socket文件描述符有O_NONBLOCK标志,则write不阻塞,直接返回20。为避免这些情况干扰主程序的逻辑,确保读写我们所请求的字节数,我们实现了两个包装函数readn和writen,也放在wrap.c中: ``` ssize_t Readn(int fd, void *vptr, size_t n) { size_t nleft; ssize_t nread; char *ptr; ptr = vptr; nleft = n; while (nleft > 0) { if ( (nread = read(fd, ptr, nleft)) < 0) { if (errno == EINTR) nread = 0; else return -1; } else if (nread == 0) break; nleft -= nread; ptr += nread; } return n - nleft; } ssize_t Writen(int fd, const void *vptr, size_t n) { size_t nleft; ssize_t nwritten; const char *ptr; ptr = vptr; nleft = n; while (nleft > 0) { if ( (nwritten = write(fd, ptr, nleft)) <= 0) { if (nwritten < 0 && errno == EINTR) nwritten = 0; else return -1; } nleft -= nwritten; ptr += nwritten; } return n; } ``` 如果应用层协议的各字段长度固定,用readn来读是非常方便的。例如设计一种客户端上传文件的协议,规定前12字节表示文件名,超过12字节的文件名截断,不足12字节的文件名用'\0'补齐,从第13字节开始是文件内容,上传完所有文件内容后关闭连接,服务器可以先调用readn读12个字节,根据文件名创建文件,然后在一个循环中调用read读文件内容并存盘,循环结束的条件是read返回0。 字段长度固定的协议往往不够灵活,难以适应新的变化。比如,以前DOS的文件名是8字节主文件名加“.”加3字节扩展名,不超过12字节,但是现代操作系统的文件名可以长得多,12字节就不够用了。那么制定一个新版本的协议规定文件名字段为256字节怎么样?这样又造成很大的浪费,因为大多数文件名都很短,需要用大量的'\0'补齐256字节,而且新版本的协议和老版本的程序无法兼容,如果已经有很多人在用老版本的程序了,会造成遵循新协议的程序与老版本程序的互操作性(Interoperability)问题。如果新版本的协议要添加新的字段,比如规定前12字节是文件名,从13到16字节是文件类型说明,从第17字节开始才是文件内容,同样会造成和老版本的程序无法兼容的问题。 现在重新看看上一节的TFTP协议是如何避免上述问题的:TFTP协议的各字段是可变长的,以'\0'为分隔符,文件名可以任意长,再看blksize等几个选项字段,TFTP协议并没有规定从第m字节到第n字节是blksize的值,而是把选项的描述信息“blksize”与它的值“512”一起做成一个可变长的字段,这样,以后添加新的选项仍然可以和老版本的程序兼容(老版本的程序只要忽略不认识的选项就行了)。 因此,常见的应用层协议都是带有可变长字段的,字段之间的分隔符用换行的比用'\0'的更常见,例如本节后面要介绍的HTTP协议。可变长字段的协议用readn来读就很不方便了,为此我们实现一个类似于fgets的readline函数,也放在wrap.c中: ``` static ssize_t my_read(int fd, char *ptr) { static int read_cnt; static char *read_ptr; static char read_buf[100]; if (read_cnt <= 0) { again: if ( (read_cnt = read(fd, read_buf, sizeof(read_buf))) < 0) { if (errno == EINTR) goto again; return -1; } else if (read_cnt == 0) return 0; read_ptr = read_buf; } read_cnt--; *ptr = *read_ptr++; return 1; } ssize_t Readline(int fd, void *vptr, size_t maxlen) { ssize_t n, rc; char c, *ptr; ptr = vptr; for (n = 1; n < maxlen; n++) { if ( (rc = my_read(fd, &c)) == 1) { *ptr++ = c; if (c == '\n') break; } else if (rc == 0) { *ptr = 0; return n - 1; } else return -1; } *ptr = 0; return n; } ``` #### 习题 1、请读者自己写出wrap.c的头文件wrap.h,后面的网络程序代码都要用到这个头文件。 2、修改server.c和client.c,添加错误处理。 ### 2.3. 把client改为交互式输入 目前实现的client每次运行只能从命令行读取一个字符串发给服务器,再从服务器收回来,现在我们把它改成交互式的,不断从终端接受用户输入并和server交互。 ``` /* client.c */ #include #include #include #include #include "wrap.h" #define MAXLINE 80 #define SERV_PORT 8000 int main(int argc, char *argv[]) { struct sockaddr_in servaddr; char buf[MAXLINE]; int sockfd, n; sockfd = Socket(AF_INET, SOCK_STREAM, 0); bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr); servaddr.sin_port = htons(SERV_PORT); Connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)); while (fgets(buf, MAXLINE, stdin) != NULL) { Write(sockfd, buf, strlen(buf)); n = Read(sockfd, buf, MAXLINE); if (n == 0) printf("the other side has been closed.\n"); else Write(STDOUT_FILENO, buf, n); } Close(sockfd); return 0; } ``` 编译并运行server和client,看看是否达到了你预想的结果。 ``` $ ./client haha1 HAHA1 haha2 the other side has been closed. haha3 $ ``` 这时server仍在运行,但是client的运行结果并不正确。原因是什么呢?仔细查看server.c可以发现,server对每个请求只处理一次,应答后就关闭连接,client不能继续使用这个连接发送数据。但是client下次循环时又调用write发数据给server,write调用只负责把数据交给TCP发送缓冲区就可以成功返回了,所以不会出错,而server收到数据后应答一个RST段,client收到RST段后无法立刻通知应用层,只把这个状态保存在TCP协议层。client下次循环又调用write发数据给server,由于TCP协议层已经处于RST状态了,因此不会将数据发出,而是发一个SIGPIPE信号给应用层,SIGPIPE信号的缺省处理动作是终止程序,所以看到上面的现象。 为了避免client异常退出,上面的代码应该在判断对方关闭了连接后break出循环,而不是继续write。另外,有时候代码中需要连续多次调用write,可能还来不及调用read得知对方已关闭了连接就被SIGPIPE信号终止掉了,这就需要在初始化时调用sigaction处理SIGPIPE信号,如果SIGPIPE信号没有导致进程异常退出,write返回-1并且errno为EPIPE。 另外,我们需要修改server,使它可以多次处理同一客户端的请求。 ``` /* server.c */ #include #include #include #include "wrap.h" #define MAXLINE 80 #define SERV_PORT 8000 int main(void) { struct sockaddr_in servaddr, cliaddr; socklen_t cliaddr_len; int listenfd, connfd; char buf[MAXLINE]; char str[INET_ADDRSTRLEN]; int i, n; listenfd = Socket(AF_INET, SOCK_STREAM, 0); bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(SERV_PORT); Bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)); Listen(listenfd, 20); printf("Accepting connections ...\n"); while (1) { cliaddr_len = sizeof(cliaddr); connfd = Accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len); while (1) { n = Read(connfd, buf, MAXLINE); if (n == 0) { printf("the other side has been closed.\n"); break; } printf("received from %s at PORT %d\n", inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)), ntohs(cliaddr.sin_port)); for (i = 0; i < n; i++) buf[i] = toupper(buf[i]); Write(connfd, buf, n); } Close(connfd); } } ``` 经过上面的修改后,客户端和服务器可以进行多次交互了。我们知道,服务器通常是要同时服务多个客户端的,运行上面的server和client之后,再开一个终端运行client试试,新的client能得到服务吗?想想为什么。 ### 2.4. 使用fork并发处理多个client的请求 怎么解决这个问题?网络服务器通常用fork来同时服务多个客户端,父进程专门负责监听端口,每次accept一个新的客户端连接就fork出一个子进程专门服务这个客户端。但是子进程退出时会产生僵尸进程,父进程要注意处理SIGCHLD信号和调用wait清理僵尸进程。 以下给出代码框架,完整的代码请读者自己完成。 ``` listenfd = socket(...); bind(listenfd, ...); listen(listenfd, ...); while (1) { connfd = accept(listenfd, ...); n = fork(); if (n == -1) { perror("call to fork"); exit(1); } else if (n == 0) { close(listenfd); while (1) { read(connfd, ...); ... write(connfd, ...); } close(connfd); exit(0); } else close(connfd); } ``` ### 2.5. setsockopt 现在做一个测试,首先启动server,然后启动client,然后用Ctrl-C使server终止,这时马上再运行server,结果是: ``` $ ./server bind error: Address already in use ``` 这是因为,虽然server的应用程序终止了,但TCP协议层的连接并没有完全断开,因此不能再次监听同样的server端口。我们用netstat命令查看一下: ``` $ netstat -apn |grep 8000 tcp 1 0 127.0.0.1:33498 127.0.0.1:8000 CLOSE_WAIT 10830/client tcp 0 0 127.0.0.1:8000 127.0.0.1:33498 FIN_WAIT2 - ``` server终止时,socket描述符会自动关闭并发FIN段给client,client收到FIN后处于CLOSE_WAIT状态,但是client并没有终止,也没有关闭socket描述符,因此不会发FIN给server,因此server的TCP连接处于FIN_WAIT2状态。 现在用Ctrl-C把client也终止掉,再观察现象: ``` $ netstat -apn |grep 8000 tcp 0 0 127.0.0.1:8000 127.0.0.1:44685 TIME_WAIT - $ ./server bind error: Address already in use ``` client终止时自动关闭socket描述符,server的TCP连接收到client发的FIN段后处于TIME_WAIT状态。TCP协议规定,主动关闭连接的一方要处于TIME_WAIT状态,等待两个MSL(maximum segment lifetime)的时间后才能回到CLOSED状态,因为我们先Ctrl-C终止了server,所以server是主动关闭连接的一方,在TIME_WAIT期间仍然不能再次监听同样的server端口。MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同,在Linux上一般经过半分钟后就可以再次启动server了。至于为什么要规定TIME_WAIT的时间请读者参考UNP 2.7节。 在server的TCP连接没有完全断开之前不允许重新监听是不合理的,因为,TCP连接没有完全断开指的是connfd(127.0.0.1:8000)没有完全断开,而我们重新监听的是listenfd(0.0.0.0:8000),虽然是占用同一个端口,但IP地址不同,connfd对应的是与某个客户端通讯的一个具体的IP地址,而listenfd对应的是wildcard address。解决这个问题的方法是使用setsockopt()设置socket描述符的选项SO_REUSEADDR为1,表示允许创建端口号相同但IP地址不同的多个socket描述符。在server代码的socket()和bind()调用之间插入如下代码: ``` int opt = 1; setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); ``` 有关setsockopt可以设置的其它选项请参考UNP第7章。 ### 2.6. 使用select select是网络程序中很常用的一个系统调用,它可以同时监听多个阻塞的文件描述符(例如多个网络连接),哪个有数据到达就处理哪个,这样,不需要fork和多进程就可以实现并发服务的server。 ``` /* server.c */ #include #include #include #include #include "wrap.h" #define MAXLINE 80 #define SERV_PORT 8000 int main(int argc, char **argv) { int i, maxi, maxfd, listenfd, connfd, sockfd; int nready, client[FD_SETSIZE]; ssize_t n; fd_set rset, allset; char buf[MAXLINE]; char str[INET_ADDRSTRLEN]; socklen_t cliaddr_len; struct sockaddr_in cliaddr, servaddr; listenfd = Socket(AF_INET, SOCK_STREAM, 0); bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(SERV_PORT); Bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)); Listen(listenfd, 20); maxfd = listenfd; /* initialize */ maxi = -1; /* index into client[] array */ for (i = 0; i < FD_SETSIZE; i++) client[i] = -1; /* -1 indicates available entry */ FD_ZERO(&allset); FD_SET(listenfd, &allset); for ( ; ; ) { rset = allset; /* structure assignment */ nready = select(maxfd+1, &rset, NULL, NULL, NULL); if (nready < 0) perr_exit("select error"); if (FD_ISSET(listenfd, &rset)) { /* new client connection */ cliaddr_len = sizeof(cliaddr); connfd = Accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len); printf("received from %s at PORT %d\n", inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)), ntohs(cliaddr.sin_port)); for (i = 0; i < FD_SETSIZE; i++) if (client[i] < 0) { client[i] = connfd; /* save descriptor */ break; } if (i == FD_SETSIZE) { fputs("too many clients\n", stderr); exit(1); } FD_SET(connfd, &allset); /* add new descriptor to set */ if (connfd > maxfd) maxfd = connfd; /* for select */ if (i > maxi) maxi = i; /* max index in client[] array */ if (--nready == 0) continue; /* no more readable descriptors */ } for (i = 0; i <= maxi; i++) { /* check all clients for data */ if ( (sockfd = client[i]) < 0) continue; if (FD_ISSET(sockfd, &rset)) { if ( (n = Read(sockfd, buf, MAXLINE)) == 0) { /* connection closed by client */ Close(sockfd); FD_CLR(sockfd, &allset); client[i] = -1; } else { int j; for (j = 0; j < n; j++) buf[j] = toupper(buf[j]); Write(sockfd, buf, n); } if (--nready == 0) break; /* no more readable descriptors */ } } } } ``` ## 3. 基于UDP协议的网络程序 下图是典型的UDP客户端/服务器通讯过程(该图出自[[UNPv13e]](bi01.html#bibli.unp "UNIX Network Programming, Volume 1: The Sockets Networking API"))。 **图 37.3. UDP通讯流程** ![UDP通讯流程](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80dbe66d9.png) 以下是简单的UDP服务器和客户端程序。 ``` /* server.c */ #include #include #include #include "wrap.h" #define MAXLINE 80 #define SERV_PORT 8000 int main(void) { struct sockaddr_in servaddr, cliaddr; socklen_t cliaddr_len; int sockfd; char buf[MAXLINE]; char str[INET_ADDRSTRLEN]; int i, n; sockfd = Socket(AF_INET, SOCK_DGRAM, 0); bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(SERV_PORT); Bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)); printf("Accepting connections ...\n"); while (1) { cliaddr_len = sizeof(cliaddr); n = recvfrom(sockfd, buf, MAXLINE, 0, (struct sockaddr *)&cliaddr, &cliaddr_len); if (n == -1) perr_exit("recvfrom error"); printf("received from %s at PORT %d\n", inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)), ntohs(cliaddr.sin_port)); for (i = 0; i < n; i++) buf[i] = toupper(buf[i]); n = sendto(sockfd, buf, n, 0, (struct sockaddr *)&cliaddr, sizeof(cliaddr)); if (n == -1) perr_exit("sendto error"); } } ``` ``` /* client.c */ #include #include #include #include #include "wrap.h" #define MAXLINE 80 #define SERV_PORT 8000 int main(int argc, char *argv[]) { struct sockaddr_in servaddr; int sockfd, n; char buf[MAXLINE]; char str[INET_ADDRSTRLEN]; socklen_t servaddr_len; sockfd = Socket(AF_INET, SOCK_DGRAM, 0); bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr); servaddr.sin_port = htons(SERV_PORT); while (fgets(buf, MAXLINE, stdin) != NULL) { n = sendto(sockfd, buf, strlen(buf), 0, (struct sockaddr *)&servaddr, sizeof(servaddr)); if (n == -1) perr_exit("sendto error"); n = recvfrom(sockfd, buf, MAXLINE, 0, NULL, 0); if (n == -1) perr_exit("recvfrom error"); Write(STDOUT_FILENO, buf, n); } Close(sockfd); return 0; } ``` 由于UDP不需要维护连接,程序逻辑简单了很多,但是UDP协议是不可靠的,实际上有很多保证通讯可靠性的机制需要在应用层实现。 编译运行server,在两个终端里各开一个client与server交互,看看server是否具有并发服务的能力。用Ctrl+C关闭server,然后再运行server,看此时client还能否和server联系上。和前面TCP程序的运行结果相比较,体会无连接的含义。 ## 4. UNIX Domain Socket IPC socket API原本是为网络通讯设计的,但后来在socket的框架上发展出一种IPC机制,就是UNIX Domain Socket。虽然网络socket也可用于同一台主机的进程间通讯(通过loopback地址127.0.0.1),但是UNIX Domain Socket用于IPC更有效率:不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等,只是将应用层数据从一个进程拷贝到另一个进程。这是因为,IPC机制本质上是可靠的通讯,而网络协议是为不可靠的通讯设计的。UNIX Domain Socket也提供面向流和面向数据包两种API接口,类似于TCP和UDP,但是面向消息的UNIX Domain Socket也是可靠的,消息既不会丢失也不会顺序错乱。 UNIX Domain Socket是全双工的,API接口语义丰富,相比其它IPC机制有明显的优越性,目前已成为使用最广泛的IPC机制,比如X Window服务器和GUI程序之间就是通过UNIX Domain Socket通讯的。 使用UNIX Domain Socket的过程和网络socket十分相似,也要先调用socket()创建一个socket文件描述符,address family指定为AF_UNIX,type可以选择SOCK_DGRAM或SOCK_STREAM,protocol参数仍然指定为0即可。 UNIX Domain Socket与网络socket编程最明显的不同在于地址格式不同,用结构体sockaddr_un表示,网络编程的socket地址是IP地址加端口号,而UNIX Domain Socket的地址是一个socket类型的文件在文件系统中的路径,这个socket文件由bind()调用创建,如果调用bind()时该文件已存在,则bind()错误返回。 以下程序将UNIX Domain socket绑定到一个地址。 ``` #include #include #include #include #include int main(void) { int fd, size; struct sockaddr_un un; memset(&un, 0, sizeof(un)); un.sun_family = AF_UNIX; strcpy(un.sun_path, "foo.socket"); if ((fd = socket(AF_UNIX, SOCK_STREAM, 0)) < 0) { perror("socket error"); exit(1); } size = offsetof(struct sockaddr_un, sun_path) + strlen(un.sun_path); if (bind(fd, (struct sockaddr *)&un, size) < 0) { perror("bind error"); exit(1); } printf("UNIX domain socket bound\n"); exit(0); } ``` 注意程序中的offsetof宏,它在stddef.h头文件中定义: ``` #define offsetof(TYPE, MEMBER) ((int)&((TYPE *)0)->MEMBER) ``` offsetof(struct sockaddr_un, sun_path)就是取sockaddr_un结构体的sun_path成员在结构体中的偏移,也就是从结构体的第几个字节开始是sun_path成员。想一想,这个宏是如何实现这一功能的? 该程序的运行结果如下。 ``` $ ./a.out UNIX domain socket bound $ ls -l foo.socket srwxrwxr-x 1 user 0 Aug 22 12:43 foo.socket $ ./a.out bind error: Address already in use $ rm foo.socket $ ./a.out UNIX domain socket bound ``` 以下是服务器的listen模块,与网络socket编程类似,在bind之后要listen,表示通过bind的地址(也就是socket文件)提供服务。 ``` #include #include #include #include #define QLEN 10 /* * Create a server endpoint of a connection. * Returns fd if all OK, <0 on error. */ int serv_listen(const char *name) { int fd, len, err, rval; struct sockaddr_un un; /* create a UNIX domain stream socket */ if ((fd = socket(AF_UNIX, SOCK_STREAM, 0)) < 0) return(-1); unlink(name); /* in case it already exists */ /* fill in socket address structure */ memset(&un, 0, sizeof(un)); un.sun_family = AF_UNIX; strcpy(un.sun_path, name); len = offsetof(struct sockaddr_un, sun_path) + strlen(name); /* bind the name to the descriptor */ if (bind(fd, (struct sockaddr *)&un, len) < 0) { rval = -2; goto errout; } if (listen(fd, QLEN) < 0) { /* tell kernel we're a server */ rval = -3; goto errout; } return(fd); errout: err = errno; close(fd); errno = err; return(rval); } ``` 以下是服务器的accept模块,通过accept得到客户端地址也应该是一个socket文件,如果不是socket文件就返回错误码,如果是socket文件,在建立连接后这个文件就没有用了,调用unlink把它删掉,通过传出参数uidptr返回客户端程序的user id。 ``` #include #include #include #include #include int serv_accept(int listenfd, uid_t *uidptr) { int clifd, len, err, rval; time_t staletime; struct sockaddr_un un; struct stat statbuf; len = sizeof(un); if ((clifd = accept(listenfd, (struct sockaddr *)&un, &len)) < 0) return(-1); /* often errno=EINTR, if signal caught */ /* obtain the client's uid from its calling address */ len -= offsetof(struct sockaddr_un, sun_path); /* len of pathname */ un.sun_path[len] = 0; /* null terminate */ if (stat(un.sun_path, &statbuf) < 0) { rval = -2; goto errout; } if (S_ISSOCK(statbuf.st_mode) == 0) { rval = -3; /* not a socket */ goto errout; } if (uidptr != NULL) *uidptr = statbuf.st_uid; /* return uid of caller */ unlink(un.sun_path); /* we're done with pathname now */ return(clifd); errout: err = errno; close(clifd); errno = err; return(rval); } ``` 以下是客户端的connect模块,与网络socket编程不同的是,UNIX Domain Socket客户端一般要显式调用bind函数,而不依赖系统自动分配的地址。客户端bind一个自己指定的socket文件名的好处是,该文件名可以包含客户端的pid以便服务器区分不同的客户端。 ``` #include #include #include #include #include #include #define CLI_PATH "/var/tmp/" /* +5 for pid = 14 chars */ /* * Create a client endpoint and connect to a server. * Returns fd if all OK, <0 on error. */ int cli_conn(const char *name) { int fd, len, err, rval; struct sockaddr_un un; /* create a UNIX domain stream socket */ if ((fd = socket(AF_UNIX, SOCK_STREAM, 0)) < 0) return(-1); /* fill socket address structure with our address */ memset(&un, 0, sizeof(un)); un.sun_family = AF_UNIX; sprintf(un.sun_path, "%s%05d", CLI_PATH, getpid()); len = offsetof(struct sockaddr_un, sun_path) + strlen(un.sun_path); unlink(un.sun_path); /* in case it already exists */ if (bind(fd, (struct sockaddr *)&un, len) < 0) { rval = -2; goto errout; } /* fill socket address structure with server's address */ memset(&un, 0, sizeof(un)); un.sun_family = AF_UNIX; strcpy(un.sun_path, name); len = offsetof(struct sockaddr_un, sun_path) + strlen(name); if (connect(fd, (struct sockaddr *)&un, len) < 0) { rval = -4; goto errout; } return(fd); errout: err = errno; close(fd); errno = err; return(rval); } ``` 下面是自己动手时间,请利用以上模块编写完整的客户端/服务器通讯的程序。 ## 5. 练习:实现简单的Web服务器 实现一个简单的Web服务器myhttpd。服务器程序启动时要读取配置文件/etc/myhttpd.conf,其中需要指定服务器监听的端口号和服务目录,例如: ``` Port=80 Directory=/var/www ``` 注意,1024以下的端口号需要超级用户才能开启服务。如果你的系统中已经安装了某种Web服务器(例如Apache),应该为myhttpd选择一个不同的端口号。当浏览器向服务器请求文件时,服务器就从服务目录(例如/var/www)中找出这个文件,加上HTTP协议头一起发给浏览器。但是,如果浏览器请求的文件是可执行的则称为CGI程序,服务器并不是将这个文件发给浏览器,而是在服务器端执行这个程序,将它的标准输出发给浏览器,服务器不发送完整的HTTP协议头,CGI程序自己负责输出一部分HTTP协议头。 ### 5.1. 基本HTTP协议 打开浏览器,输入服务器IP,例如 http://192.168.0.3 ,如果端口号不是80,例如是8000,则输入 http://192.168.0.3:8000 。这时浏览器向服务器发送的HTTP协议头如下: ``` GET / HTTP/1.1 Host: 192.168.0.3:8000 User-Agent: Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.8.1.6) Gecko/20061201 Firefox/2.0.0.6 (Ubuntu-feisty) Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5 Accept-Language: en-us,en;q=0.5 Accept-Encoding: gzip,deflate Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7 Keep-Alive: 300 Connection: keep-alive ``` 注意,其中每一行的末尾都是回车加换行(C语言的"\r\n"),第一行是GET请求和协议版本,其余几行选项字段我们不讨论,_HTTP协议头的最后有一个空行,也是回车加换行_。 我们实现的Web服务器只要能正确解析第一行就行了,这是一个GET请求,请求的是服务目录的根目录/(在本例中实际上是/var/www),Web服务器应该把该目录下的索引页(默认是index.html)发给浏览器,也就是把/var/www/index.html发给浏览器。假如该文件的内容如下(HTML文件没必要以"\r\n"换行,以"\n"换行就可以了): ``` Test Page

Test OK

``` 显示一行字和一幅图片,图片的相对路径(相对当前的index.html文件的路径)是mypic.jpg,也就是/var/www/mypic.jpg,如果用绝对路径表示应该是: ``` ``` 服务器应按如下格式应答浏览器: ``` HTTP/1.1 200 OK Content-Type: text/html Test Page

Test OK

``` 服务器应答的HTTP头也是每行末尾以回车加换行结束,最后跟一个空行的回车加换行。 HTTP头的第一行是协议版本和应答码,200表示成功,后面的消息OK其实可以随意写,浏览器是不关心的,主要是为了调试时给开发人员看的。虽然网络协议最终是程序与程序之间的对话,但是在开发过程中却是人与程序之间的对话,一个设计透明的网络协议可以提供很多直观的信息给开发人员,因此,很多应用层网络协议,如HTTP、FTP、SMTP、POP3等都是基于文本的协议,为的是透明性(transparency)。 HTTP头的第二行表示即将发送的文件的类型(称为MIME类型),这里是text/html,纯文本文件是text/plain,图片则是image/jpg、image/png等。 然后就发送文件的内容,发送完毕之后主动关闭连接,这样浏览器就知道文件发送完了。这一点比较特殊:通常网络通信都是客户端主动发起连接,主动发起请求,主动关闭连接,服务器只是被动地处理各种情况,而HTTP协议规定服务器主动关闭连接(有些Web服务器可以配置成Keep-Alive的,我们不讨论这种情况)。 浏览器收到index.html之后,发现其中有一个图片文件,就会再发一个GET请求(HTTP协议头其余部分略): ``` GET /mypic.jpg HTTP/1.1 ``` 一个较大的网页中可能有很多图片,浏览器可能在下载网页的同时就开很多线程下载图片,因此,'''服务器即使对同一个客户端也需要提供并行服务的能力'''。服务器收到这个请求应该把图片发过去然后关闭连接: ``` HTTP/1.1 200 OK Content-Type: image/jpg (这里是mypic.jpg的二进制数据) ``` 这时浏览器就应该显示出完整的网页了。 如果浏览器请求的文件在服务器上找不到,要应答一个404错误页面,例如: ``` HTTP/1.1 404 Not Found Content-Type: text/html request file not found ``` ### 5.2. 执行CGI程序 如果浏览器请求的是一个可执行文件(不管是什么样的可执行文件,即使是shell脚本也一样),那么服务器并不把这个文件本身发给浏览器,而是把它的执行结果标准输出发给浏览器。例如一个shell脚本/var/www/myscript.sh(注意一定要加可执行权限): ``` #!/bin/sh echo "Content-Type: text/html" echo echo "Hello world!" ``` 这样浏览器收到的是: ``` HTTP/1.1 200 OK Content-Type: text/html Hello world! ``` 总结一下服务器的处理步骤: 1. 解析浏览器的请求,在服务目录中查找相应的文件,如果找不到该文件就返回404错误页面 2. 如果找到了浏览器请求的文件,用stat(2)检查它是否可执行 3. 如果该文件可执行: 1. 发送HTTP/1.1 200 OK给客户端 2. fork(2),然后用dup2(2)重定向子进程的标准输出到客户端socket 3. 在子进程中exec(3)该CGI程序 4. 关闭连接 4. 如果该文件不可执行: 1. 发送HTTP/1.1 200 OK给客户端 2. 如果是一个图片文件,根据图片的扩展名发送相应的Content-Type给客户端 3. 如果不是图片文件,这里我们简化处理,都当作Content-Type: text/html 4. 简单的HTTP协议头有这两行就足够了,再发一个空行表示结束 5. 读取文件的内容发送到客户端 6. 关闭连接
';

第 36 章 TCP/IP协议基础

最后更新于:2022-04-01 22:02:21

# 第 36 章 TCP/IP协议基础 **目录** + [1\. TCP/IP协议栈与数据包封装](ch36s01.html) + [2\. 以太网(RFC 894)帧格式](ch36s02.html) + [3\. ARP数据报格式](ch36s03.html) + [4\. IP数据报格式](ch36s04.html) + [5\. IP地址与路由](ch36s05.html) + [6\. UDP段格式](ch36s06.html) + [7\. TCP协议](ch36s07.html) + [7.1\. 段格式](ch36s07.html#id2900865) + [7.2\. 通讯时序](ch36s07.html#id2900917) + [7.3\. 流量控制](ch36s07.html#id2901189) ## 1. TCP/IP协议栈与数据包封装 TCP/IP网络协议栈分为应用层(Application)、传输层(Transport)、网络层(Network)和链路层(Link)四层。如下图所示(该图出自[[TCPIP]](bi01.html#bibli.tcpip "TCP/IP Illustrated, Volume 1: The Protocols"))。 **图 36.1. TCP/IP协议栈** ![TCP/IP协议栈](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80da759d9.png) 两台计算机通过TCP/IP协议通讯的过程如下所示(该图出自[[TCPIP]](bi01.html#bibli.tcpip "TCP/IP Illustrated, Volume 1: The Protocols"))。 **图 36.2. TCP/IP通讯过程** ![TCP/IP通讯过程](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80da85f1f.png) 传输层及其以下的机制由内核提供,应用层由用户进程提供(后面将介绍如何使用socket API编写应用程序),应用程序对通讯数据的含义进行解释,而传输层及其以下处理通讯的细节,将数据从一台计算机通过一定的路径发送到另一台计算机。应用层数据通过协议栈发到网络上时,每层协议都要加上一个数据首部(header),称为封装(Encapsulation),如下图所示(该图出自[[TCPIP]](bi01.html#bibli.tcpip "TCP/IP Illustrated, Volume 1: The Protocols"))。 **图 36.3. TCP/IP数据包的封装** ![TCP/IP数据包的封装](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80da9f05a.png) 不同的协议层对数据包有不同的称谓,在传输层叫做段(segment),在网络层叫做数据报(datagram),在链路层叫做帧(frame)。数据封装成帧后发到传输介质上,到达目的主机后每层协议再剥掉相应的首部,最后将应用层数据交给应用程序处理。 上图对应两台计算机在同一网段中的情况,如果两台计算机在不同的网段中,那么数据从一台计算机到另一台计算机传输过程中要经过一个或多个路由器,如下图所示(该图出自[[TCPIP]](bi01.html#bibli.tcpip "TCP/IP Illustrated, Volume 1: The Protocols"))。 **图 36.4. 跨路由器通讯过程** ![跨路由器通讯过程](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80dab82d4.png) 其实在链路层之下还有物理层,指的是电信号的传递方式,比如现在以太网通用的网线(双绞线)、早期以太网采用的的同轴电缆(现在主要用于有线电视)、光纤等都属于物理层的概念。物理层的能力决定了最大传输速率、传输距离、抗干扰性等。集线器(Hub)是工作在物理层的网络设备,用于双绞线的连接和信号中继(将已衰减的信号再次放大使之传得更远)。 链路层有以太网、令牌环网等标准,链路层负责网卡设备的驱动、帧同步(就是说从网线上检测到什么信号算作新帧的开始)、冲突检测(如果检测到冲突就自动重发)、数据差错校验等工作。交换机是工作在链路层的网络设备,可以在不同的链路层网络之间转发数据帧(比如十兆以太网和百兆以太网之间、以太网和令牌环网之间),由于不同链路层的帧格式不同,交换机要将进来的数据包拆掉链路层首部重新封装之后再转发。 网络层的IP协议是构成Internet的基础。Internet上的主机通过IP地址来标识,Internet上有大量路由器负责根据IP地址选择合适的路径转发数据包,数据包从Internet上的源主机到目的主机往往要经过十多个路由器。路由器是工作在第三层的网络设备,同时兼有交换机的功能,可以在不同的链路层接口之间转发数据包,因此路由器需要将进来的数据包拆掉网络层和链路层两层首部并重新封装。IP协议不保证传输的可靠性,数据包在传输过程中可能丢失,可靠性可以在上层协议或应用程序中提供支持。 网络层负责点到点(point-to-point)的传输(这里的“点”指主机或路由器),而传输层负责端到端(end-to-end)的传输(这里的“端”指源主机和目的主机)。传输层可选择TCP或UDP协议。TCP是一种面向连接的、可靠的协议,有点像打电话,双方拿起电话互通身份之后就建立了连接,然后说话就行了,这边说的话那边保证听得到,并且是按说话的顺序听到的,说完话挂机断开连接。也就是说TCP传输的双方需要首先建立连接,之后由TCP协议保证数据收发的可靠性,丢失的数据包自动重发,上层应用程序收到的总是可靠的数据流,通讯之后关闭连接。UDP协议不面向连接,也不保证可靠性,有点像寄信,写好信放到邮筒里,既不能保证信件在邮递过程中不会丢失,也不能保证信件是按顺序寄到目的地的。使用UDP协议的应用程序需要自己完成丢包重发、消息排序等工作。 目的主机收到数据包后,如何经过各层协议栈最后到达应用程序呢?整个过程如下图所示(该图出自[[TCPIP]](bi01.html#bibli.tcpip "TCP/IP Illustrated, Volume 1: The Protocols"))。 **图 36.5. Multiplexing过程** ![Multiplexing过程](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80dacaabe.png) 以太网驱动程序首先根据以太网首部中的“上层协议”字段确定该数据帧的有效载荷(payload,指除去协议首部之外实际传输的数据)是IP、ARP还是RARP协议的数据报,然后交给相应的协议处理。假如是IP数据报,IP协议再根据IP首部中的“上层协议”字段确定该数据报的有效载荷是TCP、UDP、ICMP还是IGMP,然后交给相应的协议处理。假如是TCP段或UDP段,TCP或UDP协议再根据TCP首部或UDP首部的“端口号”字段确定应该将应用层数据交给哪个用户进程。IP地址是标识网络中不同主机的地址,而端口号就是同一台主机上标识不同进程的地址,IP地址和端口号合起来标识网络中唯一的进程。 注意,虽然IP、ARP和RARP数据报都需要以太网驱动程序来封装成帧,但是从功能上划分,ARP和RARP属于链路层,IP属于网络层。虽然ICMP、IGMP、TCP、UDP的数据都需要IP协议来封装成数据报,但是从功能上划分,ICMP、IGMP与IP同属于网络层,TCP和UDP属于传输层。本文对RARP、ICMP、IGMP协议不做进一步介绍,有兴趣的读者可以看参考资料。 ## 2. 以太网(RFC 894)帧格式 以太网的帧格式如下所示(该图出自[[TCPIP]](bi01.html#bibli.tcpip "TCP/IP Illustrated, Volume 1: The Protocols")): **图 36.6. 以太网帧格式** ![以太网帧格式](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80dae2d42.png) 其中的源地址和目的地址是指网卡的硬件地址(也叫MAC地址),长度是48位,是在网卡出厂时固化的。用ifconfig命令看一下,“HWaddr 00:15:F2:14:9E:3F”部分就是硬件地址。协议字段有三种值,分别对应IP、ARP、RARP。帧末尾是CRC校验码。 以太网帧中的数据长度规定最小46字节,最大1500字节,ARP和RARP数据包的长度不够46字节,要在后面补填充位。最大值1500称为以太网的最大传输单元(MTU),不同的网络类型有不同的MTU,如果一个数据包从以太网路由到拨号链路上,数据包长度大于拨号链路的MTU了,则需要对数据包进行分片(fragmentation)。ifconfig命令的输出中也有“MTU:1500”。注意,MTU这个概念指数据帧中有效载荷的最大长度,不包括帧首部的长度。 ## 3. ARP数据报格式 在网络通讯时,源主机的应用程序知道目的主机的IP地址和端口号,却不知道目的主机的硬件地址,而数据包首先是被网卡接收到再去处理上层协议的,如果接收到的数据包的硬件地址与本机不符,则直接丢弃。因此在通讯前必须获得目的主机的硬件地址。ARP协议就起到这个作用。源主机发出ARP请求,询问“IP地址是192.168.0.1的主机的硬件地址是多少”,并将这个请求广播到本地网段(以太网帧首部的硬件地址填FF:FF:FF:FF:FF:FF表示广播),目的主机接收到广播的ARP请求,发现其中的IP地址与本机相符,则发送一个ARP应答数据包给源主机,将自己的硬件地址填写在应答包中。 每台主机都维护一个ARP缓存表,可以用arp -a命令查看。缓存表中的表项有过期时间(一般为20分钟),如果20分钟内没有再次使用某个表项,则该表项失效,下次还要发ARP请求来获得目的主机的硬件地址。想一想,为什么表项要有过期时间而不是一直有效? ARP数据报的格式如下所示(该图出自[[TCPIP]](bi01.html#bibli.tcpip "TCP/IP Illustrated, Volume 1: The Protocols")): **图 36.7. ARP数据报格式** ![ARP数据报格式](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80db052cd.png) 注意到源MAC地址、目的MAC地址在以太网首部和ARP请求中各出现一次,对于链路层为以太网的情况是多余的,但如果链路层是其它类型的网络则有可能是必要的。硬件类型指链路层网络类型,1为以太网,协议类型指要转换的地址类型,0x0800为IP地址,后面两个地址长度对于以太网地址和IP地址分别为6和4(字节),op字段为1表示ARP请求,op字段为2表示ARP应答。 下面举一个具体的例子。 请求帧如下(为了清晰在每行的前面加了字节计数,每行16个字节): 以太网首部(14字节) 0000: ff ff ff ff ff ff 00 05 5d 61 58 a8 08 06 ARP帧(28字节) 0000:                                           00 01 0010: 08 00 06 04 00 01 00 05 5d 61 58 a8 c0 a8 00 37 0020: 00 00 00 00 00 00 c0 a8 00 02 填充位(18字节) 0020:                               00 77 31 d2 50 10 0030: fd 78 41 d3 00 00 00 00 00 00 00 00 以太网首部:目的主机采用广播地址,源主机的MAC地址是00:05:5d:61:58:a8,上层协议类型0x0806表示ARP。 ARP帧:硬件类型0x0001表示以太网,协议类型0x0800表示IP协议,硬件地址(MAC地址)长度为6,协议地址(IP地址)长度为4,op为0x0001表示请求目的主机的MAC地址,源主机MAC地址为00:05:5d:61:58:a8,源主机IP地址为c0 a8 00 37(192.168.0.55),目的主机MAC地址全0待填写,目的主机IP地址为c0 a8 00 02(192.168.0.2)。 由于以太网规定最小数据长度为46字节,ARP帧长度只有28字节,因此有18字节填充位,填充位的内容没有定义,与具体实现相关。 应答帧如下: 以太网首部 0000: 00 05 5d 61 58 a8 00 05 5d a1 b8 40 08 06 ARP帧 0000:                                           00 01 0010: 08 00 06 04 00 02 00 05 5d a1 b8 40 c0 a8 00 02 0020: 00 05 5d 61 58 a8 c0 a8 00 37 填充位 0020:                               00 77 31 d2 50 10 0030: fd 78 41 d3 00 00 00 00 00 00 00 00 以太网首部:目的主机的MAC地址是00:05:5d:61:58:a8,源主机的MAC地址是00:05:5d:a1:b8:40,上层协议类型0x0806表示ARP。 ARP帧:硬件类型0x0001表示以太网,协议类型0x0800表示IP协议,硬件地址(MAC地址)长度为6,协议地址(IP地址)长度为4,op为0x0002表示应答,源主机MAC地址为00:05:5d:a1:b8:40,源主机IP地址为c0 a8 00 02(192.168.0.2),目的主机MAC地址为00:05:5d:61:58:a8,目的主机IP地址为c0 a8 00 37(192.168.0.55)。 思考题:如果源主机和目的主机不在同一网段,ARP请求的广播帧无法穿过路由器,源主机如何与目的主机通信? ## 4. IP数据报格式 IP数据报的格式如下(这里只讨论IPv4)(该图出自[[TCPIP]](bi01.html#bibli.tcpip "TCP/IP Illustrated, Volume 1: The Protocols")): **图 36.8. IP数据报格式** ![IP数据报格式](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80db17b07.png) IP数据报的首部长度和数据长度都是可变长的,但总是4字节的整数倍。对于IPv4,4位版本字段是4。4位首部长度的数值是以4字节为单位的,最小值为5,也就是说首部长度最小是4x5=20字节,也就是不带任何选项的IP首部,4位能表示的最大值是15,也就是说首部长度最大是60字节。8位TOS字段有3个位用来指定IP数据报的优先级(目前已经废弃不用),还有4个位表示可选的服务类型(最小延迟、最大呑吐量、最大可靠性、最小成本),还有一个位总是0。总长度是整个数据报(包括IP首部和IP层payload)的字节数。每传一个IP数据报,16位的标识加1,可用于分片和重新组装数据报。3位标志和13位片偏移用于分片。TTL(Time to live)是这样用的:源主机为数据包设定一个生存时间,比如64,每过一个路由器就把该值减1,如果减到0就表示路由已经太长了仍然找不到目的主机的网络,就丢弃该包,因此这个生存时间的单位不是秒,而是跳(hop)。协议字段指示上层协议是TCP、UDP、ICMP还是IGMP。然后是校验和,只校验IP首部,数据的校验由更高层协议负责。IPv4的IP地址长度为32位。选项字段的解释从略。 想一想,前面讲了以太网帧中的最小数据长度为46字节,不足46字节的要用填充字节补上,那么如何界定这46字节里前多少个字节是IP、ARP或RARP数据报而后面是填充字节? ## 5. IP地址与路由 IPv4的IP地址长度为4字节,通常采用点分十进制表示法(dotted decimal representation)例如0xc0a80002表示为192.168.0.2。Internet被各种路由器和网关设备分隔成很多网段,为了标识不同的网段,需要把32位的IP地址划分成网络号和主机号两部分,网络号相同的各主机位于同一网段,相互间可以直接通信,网络号不同的主机之间通信则需要通过路由器转发。 过去曾经提出一种划分网络号和主机号的方案,把所有IP地址分为五类,如下图所示(该图出自[[TCPIP]](bi01.html#bibli.tcpip "TCP/IP Illustrated, Volume 1: The Protocols"))。 **图 36.9. IP地址类** ![IP地址类](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80db32e9f.png) A类 0.0.0.0到127.255.255.255 B类 128.0.0.0到191.255.255.255 C类 192.0.0.0到223.255.255.255 D类 224.0.0.0到239.255.255.255 E类 240.0.0.0到247.255.255.255 一个A类网络可容纳的地址数量最大,一个B类网络的地址数量是65536,一个C类网络的地址数量是256。D类地址用作多播地址,E类地址保留未用。 随着Internet的飞速发展,这种划分方案的局限性很快显现出来,大多数组织都申请B类网络地址,导致B类地址很快就分配完了,而A类却浪费了大量地址。这种方式对网络的划分是flat的而不是层级结构(hierarchical)的,Internet上的每个路由器都必须掌握所有网络的信息,随着大量C类网络的出现,路由器需要检索的路由表越来越庞大,负担越来越重。 针对这种情况提出了新的划分方案,称为CIDR(Classless Interdomain Routing)。网络号和主机号的划分需要用一个额外的子网掩码(subnet mask)来表示,而不能由IP地址本身的数值决定,也就是说,网络号和主机号的划分与这个IP地址是A类、B类还是C类无关,因此称为Classless的。这样,多个子网就可以汇总(summarize)成一个Internet上的网络,例如,有8个站点都申请了C类网络,本来网络号是24位的,但是这8个站点通过同一个ISP(Internet service provider)连到Internet上,它们网络号的高21位是相同的,只有低三位不同,这8个站点就可以汇总,在Internet上只需要一个路由表项,数据包通过Internet上的路由器到达ISP,然后在ISP这边再通过次级的路由器选路到某个站点。 下面举两个例子: **表 36.1. 划分子网的例子1** | | | | --- | --- | | IP地址 | 140.252.20.68 | 8C FC 14 44 | | 子网掩码 | 255.255.255.0 | FF FF FF 00 | | 网络号 | 140.252.20.0 | 8C FC 14 00 | | 子网地址范围 | 140.252.20.0~140.252.20.255 | **表 36.2. 划分子网的例子2** | | | | --- | --- | | IP地址 | 140.252.20.68 | 8C FC 14 44 | | 子网掩码 | 255.255.255.240 | FF FF FF F0 | | 网络号 | 140.252.20.64 | 8C FC 14 40 | | 子网地址范围 | 140.252.20.64~140.252.20.79 | 可见,IP地址与子网掩码做与运算可以得到网络号,主机号从全0到全1就是子网的地址范围。IP地址和子网掩码还有一种更简洁的表示方法,例如140.252.20.68/24,表示IP地址为140.252.20.68,子网掩码的高24位是1,也就是255.255.255.0。 如果一个组织内部组建局域网,IP地址只用于局域网内的通信,而不直接连到Internet上,理论上使用任意的IP地址都可以,但是RFC 1918规定了用于组建局域网的私有IP地址,这些地址不会出现在Internet上,如下表所示。 * 10.*,前8位是网络号,共16,777,216个地址 * 172.16.*到172.31.*,前12位是网络号,共1,048,576个地址 * 192.168.*,前16位是网络号,共65,536个地址 使用私有IP地址的局域网主机虽然没有Internet的IP地址,但也可以通过代理服务器或NAT(网络地址转换)等技术连到Internet上。 除了私有IP地址之外,还有几种特殊的IP地址。127.*的IP地址用于本机环回(loop back)测试,通常是127.0.0.1。loopback是系统中一种特殊的网络设备,如果发送数据包的目的地址是环回地址,或者与本机其它网络设备的IP地址相同,则数据包不会发送到网络介质上,而是通过环回设备再发回给上层协议和应用程序,主要用于测试。如下图所示(该图出自[[TCPIP]](bi01.html#bibli.tcpip "TCP/IP Illustrated, Volume 1: The Protocols"))。 **图 36.10. loopback设备** ![loopback设备](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80db46924.png) 还有一些不能用作主机IP地址的特殊地址: * 目的地址为255.255.255.255,表示本网络内部广播,路由器不转发这样的广播数据包。 * 主机号全为0的地址只表示网络而不能表示某个主机,如192.168.10.0(假设子网掩码为255.255.255.0)。 * 目的地址的主机号为全1,表示广播至某个网络的所有主机,例如目的地址192.168.10.255表示广播至192.168.10.0网络(假设子网掩码为255.255.255.0)。 下面介绍路由的过程,首先正式定义几个名词: 路由(名词) 数据包从源地址到目的地址所经过的路径,由一系列路由节点组成。 路由(动词) 某个路由节点为数据报选择投递方向的选路过程。 路由节点 一个具有路由能力的主机或路由器,它维护一张路由表,通过查询路由表来决定向哪个接口发送数据包。 接口 路由节点与某个网络相连的网卡接口。 路由表 由很多路由条目组成,每个条目都指明去往某个网络的数据包应该经由哪个接口发送,其中最后一条是缺省路由条目。 路由条目 路由表中的一行,每个条目主要由目的网络地址、子网掩码、下一跳地址、发送接口四部分组成,如果要发送的数据包的目的网络地址匹配路由表中的某一行,就按规定的接口发送到下一跳地址。 缺省路由条目 路由表中的最后一行,主要由下一跳地址和发送接口两部分组成,当目的地址与路由表中其它行都不匹配时,就按缺省路由条目规定的接口发送到下一跳地址。 假设某主机上的网络接口配置和路由表如下: ``` $ ifconfig eth0 Link encap:Ethernet HWaddr 00:0C:29:C2:8D:7E inet addr:192.168.10.223 Bcast:192.168.10.255 Mask:255.255.255.0 UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1 RX packets:0 errors:0 dropped:0 overruns:0 frame:0 TX packets:10 errors:0 dropped:0 overruns:0 carrier:0 collisions:0 txqueuelen:100 RX bytes:0 (0.0 b) TX bytes:420 (420.0 b) Interrupt:10 Base address:0x10a0 eth1 Link encap:Ethernet HWaddr 00:0C:29:C2:8D:88 inet addr:192.168.56.136 Bcast:192.168.56.255 Mask:255.255.255.0 UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1 RX packets:603 errors:0 dropped:0 overruns:0 frame:0 TX packets:110 errors:0 dropped:0 overruns:0 carrier:0 collisions:0 txqueuelen:100 RX bytes:55551 (54.2 Kb) TX bytes:7601 (7.4 Kb) Interrupt:9 Base address:0x10c0 lo Link encap:Local Loopback inet addr:127.0.0.1 Mask:255.0.0.0 UP LOOPBACK RUNNING MTU:16436 Metric:1 RX packets:37 errors:0 dropped:0 overruns:0 frame:0 TX packets:37 errors:0 dropped:0 overruns:0 carrier:0 collisions:0 txqueuelen:0 RX bytes:3020 (2.9 Kb) TX bytes:3020 (2.9 Kb) $ route Kernel IP routing table Destination Gateway Genmask Flags Metric Ref Use Iface 192.168.10.0 * 255.255.255.0 U 0 0 0 eth0 192.168.56.0 * 255.255.255.0 U 0 0 0 eth1 127.0.0.0 * 255.0.0.0 U 0 0 0 lo default 192.168.10.1 0.0.0.0 UG 0 0 0 eth0 ``` 这台主机有两个网络接口,一个网络接口连到192.168.10.0/24网络,另一个网络接口连到192.168.56.0/24网络。路由表的Destination是目的网络地址,Genmask是子网掩码,Gateway是下一跳地址,Iface是发送接口,Flags中的U标志表示此条目有效(可以禁用某些条目),G标志表示此条目的下一跳地址是某个路由器的地址,没有G标志的条目表示目的网络地址是与本机接口直接相连的网络,不必经路由器转发,因此下一跳地址处记为*号。 如果要发送的数据包的目的地址是192.168.56.3,跟第一行的子网掩码做与运算得到192.168.56.0,与第一行的目的网络地址不符,再跟第二行的子网掩码做与运算得到192.168.56.0,正是第二行的目的网络地址,因此从eth1接口发送出去,由于192.168.56.0/24正是与eth1接口直接相连的网络,因此可以直接发到目的主机,不需要经路由器转发。 如果要发送的数据包的目的地址是202.10.1.2,跟前三行路由表条目都不匹配,那么就要按缺省路由条目,从eth0接口发出去,首先发往192.168.10.1路由器,再让路由器根据它的路由表决定下一跳地址。 ## 6. UDP段格式 下图是UDP的段格式(该图出自[[TCPIP]](bi01.html#bibli.tcpip "TCP/IP Illustrated, Volume 1: The Protocols"))。 **图 36.11. UDP段格式** ![UDP段格式](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80db5f54d.png) 下面分析一帧基于UDP的TFTP协议帧。 以太网首部 0000: 00 05 5d 67 d0 b1 00 05 5d 61 58 a8 08 00  IP首部 0000:                                           45 00 0010: 00 53 93 25 00 00 80 11 25 ec c0 a8 00 37 c0 a8 0020: 00 01 UDP首部 0020:      05 d4 00 45 00 3f ac 40 TFTP协议 0020:                               00 01 'c'':''\''q' 0030: 'w''e''r''q''.''q''w''e'00 'n''e''t''a''s''c''i' 0040: 'i'00 'b''l''k''s''i''z''e'00 '5''1''2'00 't''i' 0050: 'm''e''o''u''t'00 '1''0'00 't''s''i''z''e'00 '0' 0060: 00 以太网首部:源MAC地址是00:05:5d:61:58:a8,目的MAC地址是00:05:5d:67:d0:b1,上层协议类型0x0800表示IP。 IP首部:每一个字节0x45包含4位版本号和4位首部长度,版本号为4,即IPv4,首部长度为5,说明IP首部不带有选项字段。服务类型为0,没有使用服务。16位总长度字段(包括IP首部和IP层payload的长度)为0x0053,即83字节,加上以太网首部14字节可知整个帧长度是97字节。IP报标识是0x9325,标志字段和片偏移字段设置为0x0000,就是DF=0允许分片,MF=0此数据报没有更多分片,没有分片偏移。TTL是0x80,也就是128。上层协议0x11表示UDP协议。IP首部校验和为0x25ec,源主机IP是c0 a8 00 37(192.168.0.55),目的主机IP是c0 a8 00 01(192.168.0.1)。 UDP首部:源端口号0x05d4(1492)是客户端的端口号,目的端口号0x0045(69)是TFTP服务的well-known端口号。UDP报长度为0x003f,即63字节,包括UDP首部和UDP层payload的长度。UDP首部和UDP层payload的校验和为0xac40。 TFTP是基于文本的协议,各字段之间用字节0分隔,开头的00 01表示请求读取一个文件,接下来的各字段是: c:\qwerq.qwe netascii blksize 512 timeout 10 tsize 0 一般的网络通信都是像TFTP协议这样,通信的双方分别是客户端和服务器,客户端主动发起请求(上面的例子就是客户端发起的请求帧),而服务器被动地等待、接收和应答请求。客户端的IP地址和端口号唯一标识了该主机上的TFTP客户端进程,服务器的IP地址和端口号唯一标识了该主机上的TFTP服务进程,由于客户端是主动发起请求的一方,它必须知道服务器的IP地址和TFTP服务进程的端口号,所以,一些常见的网络协议有默认的服务器端口,例如HTTP服务默认TCP协议的80端口,FTP服务默认TCP协议的21端口,TFTP服务默认UDP协议的69端口(如上例所示)。在使用客户端程序时,必须指定服务器的主机名或IP地址,如果不明确指定端口号则采用默认端口,请读者查阅ftp、tftp等程序的man page了解如何指定端口号。/etc/services中列出了所有well-known的服务端口和对应的传输层协议,这是由IANA(Internet Assigned Numbers Authority)规定的,其中有些服务既可以用TCP也可以用UDP,为了清晰,IANA规定这样的服务采用相同的TCP或UDP默认端口号,而另外一些TCP和UDP的相同端口号却对应不同的服务。 很多服务有well-known的端口号,然而客户端程序的端口号却不必是well-known的,往往是每次运行客户端程序时由系统自动分配一个空闲的端口号,用完就释放掉,称为ephemeral的端口号,想想这是为什么。 前面提过,UDP协议不面向连接,也不保证传输的可靠性,例如: * 发送端的UDP协议层只管把应用层传来的数据封装成段交给IP协议层就算完成任务了,如果因为网络故障该段无法发到对方,UDP协议层也不会给应用层返回任何错误信息。 * 接收端的UDP协议层只管把收到的数据根据端口号交给相应的应用程序就算完成任务了,如果发送端发来多个数据包并且在网络上经过不同的路由,到达接收端时顺序已经错乱了,UDP协议层也不保证按发送时的顺序交给应用层。 * 通常接收端的UDP协议层将收到的数据放在一个固定大小的缓冲区中等待应用程序来提取和处理,如果应用程序提取和处理的速度很慢,而发送端发送的速度很快,就会丢失数据包,UDP协议层并不报告这种错误。 因此,使用UDP协议的应用程序必须考虑到这些可能的问题并实现适当的解决方案,例如等待应答、超时重发、为数据包编号、流量控制等。一般使用UDP协议的应用程序实现都比较简单,只是发送一些对可靠性要求不高的消息,而不发送大量的数据。例如,基于UDP的TFTP协议一般只用于传送小文件(所以才叫trivial的ftp),而基于TCP的FTP协议适用于各种文件的传输。下面看TCP协议如何用面向连接的服务来代替应用程序解决传输的可靠性问题。 ## 7. TCP协议 ### 7.1. 段格式 TCP的段格式如下图所示(该图出自[[TCPIP]](bi01.html#bibli.tcpip "TCP/IP Illustrated, Volume 1: The Protocols"))。 **图 36.12. TCP段格式** ![TCP段格式](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80db76738.png) 和UDP协议一样也有源端口号和目的端口号,通讯的双方由IP地址和端口号标识。32位序号、32位确认序号、窗口大小稍后详细解释。4位首部长度和IP协议头类似,表示TCP协议头的长度,以4字节为单位,因此TCP协议头最长可以是4x15=60字节,如果没有选项字段,TCP协议头最短20字节。URG、ACK、PSH、RST、SYN、FIN是六个控制位,本节稍后将解释SYN、ACK、FIN、RST四个位,其它位的解释从略。16位检验和将TCP协议头和数据都计算在内。紧急指针和各种选项的解释从略。 ### 7.2. 通讯时序 下图是一次TCP通讯的时序图。 **图 36.13. TCP连接建立断开** ![TCP连接建立断开](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80db91c55.png) 在这个例子中,首先客户端主动发起连接、发送请求,然后服务器端响应请求,然后客户端主动关闭连接。两条竖线表示通讯的两端,从上到下表示时间的先后顺序,注意,数据从一端传到网络的另一端也需要时间,所以图中的箭头都是斜的。双方发送的段按时间顺序编号为1-10,各段中的主要信息在箭头上标出,例如段2的箭头上标着SYN, 8000(0), ACK 1001, <mss 1024>,表示该段中的SYN位置1,32位序号是8000,该段不携带有效载荷(数据字节数为0),ACK位置1,32位确认序号是1001,带有一个mss选项值为1024。 建立连接的过程: 1. 客户端发出段1,SYN位表示连接请求。序号是1000,这个序号在网络通讯中用作临时的地址,每发一个数据字节,这个序号要加1,这样在接收端可以根据序号排出数据包的正确顺序,也可以发现丢包的情况,另外,规定SYN位和FIN位也要占一个序号,这次虽然没发数据,但是由于发了SYN位,因此下次再发送应该用序号1001。mss表示最大段尺寸,如果一个段太大,封装成帧后超过了链路层的最大帧长度,就必须在IP层分片,为了避免这种情况,客户端声明自己的最大段尺寸,建议服务器端发来的段不要超过这个长度。 2. 服务器发出段2,也带有SYN位,同时置ACK位表示确认,确认序号是1001,表示“我接收到序号1000及其以前所有的段,请你下次发送序号为1001的段”,也就是应答了客户端的连接请求,同时也给客户端发出一个连接请求,同时声明最大尺寸为1024。 3. 客户端发出段3,对服务器的连接请求进行应答,确认序号是8001。 在这个过程中,客户端和服务器分别给对方发了连接请求,也应答了对方的连接请求,其中服务器的请求和应答在一个段中发出,因此一共有三个段用于建立连接,称为'''三方握手(three-way-handshake)'''。在建立连接的同时,双方协商了一些信息,例如双方发送序号的初始值、最大段尺寸等。 在TCP通讯中,如果一方收到另一方发来的段,读出其中的目的端口号,发现本机并没有任何进程使用这个端口,就会应答一个包含RST位的段给另一方。例如,服务器并没有任何进程使用8080端口,我们却用telnet客户端去连接它,服务器收到客户端发来的SYN段就会应答一个RST段,客户端的telnet程序收到RST段后报告错误Connection refused: ``` $ telnet 192.168.0.200 8080 Trying 192.168.0.200... telnet: Unable to connect to remote host: Connection refused ``` 数据传输的过程: 1. 客户端发出段4,包含从序号1001开始的20个字节数据。 2. 服务器发出段5,确认序号为1021,对序号为1001-1020的数据表示确认收到,同时请求发送序号1021开始的数据,服务器在应答的同时也向客户端发送从序号8001开始的10个字节数据,这称为piggyback。 3. 客户端发出段6,对服务器发来的序号为8001-8010的数据表示确认收到,请求发送序号8011开始的数据。 在数据传输过程中,ACK和确认序号是非常重要的,应用程序交给TCP协议发送的数据会暂存在TCP层的发送缓冲区中,发出数据包给对方之后,只有收到对方应答的ACK段才知道该数据包确实发到了对方,可以从发送缓冲区中释放掉了,如果因为网络故障丢失了数据包或者丢失了对方发回的ACK段,经过等待超时后TCP协议自动将发送缓冲区中的数据包重发。 这个例子只描述了最简单的一问一答的情景,实际的TCP数据传输过程可以收发很多数据段,虽然典型的情景是客户端主动请求服务器被动应答,但也不是必须如此,事实上TCP协议为应用层提供了全双工(full-duplex)的服务,双方都可以_主动甚至同时_给对方发送数据。 如果通讯过程只能采用一问一答的方式,收和发两个方向不能同时传输,在同一时间只允许一个方向的数据传输,则称为'''半双工(half-duplex)''',假设某种面向连接的协议是半双工的,则只需要一套序号就够了,不需要通讯双方各自维护一套序号,想一想为什么。 关闭连接的过程: 1. 客户端发出段7,FIN位表示关闭连接的请求。 2. 服务器发出段8,应答客户端的关闭连接请求。 3. 服务器发出段9,其中也包含FIN位,向客户端发送关闭连接请求。 4. 客户端发出段10,应答服务器的关闭连接请求。 建立连接的过程是三方握手,而关闭连接通常需要4个段,服务器的应答和关闭连接请求通常不合并在一个段中,因为有连接半关闭的情况,这种情况下客户端关闭连接之后就不能再发送数据给服务器了,但是服务器还可以发送数据给客户端,直到服务器也关闭连接为止,稍后会看到这样的例子。 ### 7.3. 流量控制 介绍UDP时我们描述了这样的问题:如果发送端发送的速度较快,接收端接收到数据后处理的速度较慢,而接收缓冲区的大小是固定的,就会丢失数据。TCP协议通过'''滑动窗口(Sliding Window)'''机制解决这一问题。看下图的通讯过程。 **图 36.14. 滑动窗口** ![滑动窗口](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80dba3141.png) 1. 发送端发起连接,声明最大段尺寸是1460,初始序号是0,窗口大小是4K,表示“我的接收缓冲区还有4K字节空闲,你发的数据不要超过4K”。接收端应答连接请求,声明最大段尺寸是1024,初始序号是8000,窗口大小是6K。发送端应答,三方握手结束。 2. 发送端发出段4-9,每个段带1K的数据,发送端根据窗口大小知道接收端的缓冲区满了,因此停止发送数据。 3. 接收端的应用程序提走2K数据,接收缓冲区又有了2K空闲,接收端发出段10,在应答已收到6K数据的同时声明窗口大小为2K。 4. 接收端的应用程序又提走2K数据,接收缓冲区有4K空闲,接收端发出段11,重新声明窗口大小为4K。 5. 发送端发出段12-13,每个段带2K数据,段13同时还包含FIN位。 6. 接收端应答接收到的2K数据(6145-8192),再加上FIN位占一个序号8193,因此应答序号是8194,连接处于半关闭状态,接收端同时声明窗口大小为2K。 7. 接收端的应用程序提走2K数据,接收端重新声明窗口大小为4K。 8. 接收端的应用程序提走剩下的2K数据,接收缓冲区全空,接收端重新声明窗口大小为6K。 9. 接收端的应用程序在提走全部数据后,决定关闭连接,发出段17包含FIN位,发送端应答,连接完全关闭。 上图在接收端用小方块表示1K数据,实心的小方块表示已接收到的数据,虚线框表示接收缓冲区,因此套在虚线框中的空心小方块表示窗口大小,从图中可以看出,随着应用程序提走数据,虚线框是向右滑动的,因此称为滑动窗口。 从这个例子还可以看出,发送端是一K一K地发送数据,而接收端的应用程序可以两K两K地提走数据,当然也有可能一次提走3K或6K数据,或者一次只提走几个字节的数据,也就是说,应用程序所看到的数据是一个整体,或说是一个流(stream),在底层通讯中这些数据可能被拆成很多数据包来发送,但是一个数据包有多少字节对应用程序是不可见的,因此TCP协议是面向流的协议。而UDP是面向消息的协议,每个UDP段都是一条消息,应用程序必须以消息为单位提取数据,不能一次提取任意字节的数据,这一点和TCP是很不同的。
';

第 35 章 线程

最后更新于:2022-04-01 22:02:19

# 第 35 章 线程 **目录** + [1\. 线程的概念](ch35s01.html) + [2\. 线程控制](ch35s02.html) + [2.1\. 创建线程](ch35s02.html#id2895632) + [2.2\. 终止线程](ch35s02.html#id2896029) + [3\. 线程间同步](ch35s03.html) + [3.1\. mutex](ch35s03.html#id2896462) + [3.2\. Condition Variable](ch35s03.html#id2895424) + [3.3\. Semaphore](ch35s03.html#id2897332) + [3.4\. 其它线程间同步机制](ch35s03.html#id2897423) + [4\. 编程练习](ch35s04.html) ## 1. 线程的概念 我们知道,进程在各自独立的地址空间中运行,进程之间共享数据需要用`mmap`或者进程间通信机制,本节我们学习如何在一个进程的地址空间中执行多个线程。有些情况需要在一个进程中同时执行多个控制流程,这时候线程就派上了用场,比如实现一个图形界面的下载软件,一方面需要和用户交互,等待和处理用户的鼠标键盘事件,另一方面又需要同时下载多个文件,等待和处理从多个网络主机发来的数据,这些任务都需要一个“等待-处理”的循环,可以用多线程实现,一个线程专门负责与用户交互,另外几个线程每个线程负责和一个网络主机通信。 以前我们讲过,`main`函数和信号处理函数是同一个进程地址空间中的多个控制流程,多线程也是如此,但是比信号处理函数更加灵活,信号处理函数的控制流程只是在信号递达时产生,在处理完信号之后就结束,而多线程的控制流程可以长期并存,操作系统会在各线程之间调度和切换,就像在多个进程之间调度和切换一样。由于同一进程的多个线程共享同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境: * 文件描述符表 * 每种信号的处理方式(`SIG_IGN`、`SIG_DFL`或者自定义的信号处理函数) * 当前工作目录 * 用户id和组id 但有些资源是每个线程各有一份的: * 线程id * 上下文,包括各种寄存器的值、程序计数器和栈指针 * 栈空间 * `errno`变量 * 信号屏蔽字 * 调度优先级 我们将要学习的线程库函数是由POSIX标准定义的,称为POSIX thread或者pthread。在Linux上线程函数位于`libpthread`共享库中,因此在编译时要加上`-lpthread`选项。 ## 2. 线程控制 ### 2.1. 创建线程 ``` #include int pthread_create(pthread_t *restrict thread, const pthread_attr_t *restrict attr, void *(*start_routine)(void*), void *restrict arg); ``` 返回值:成功返回0,失败返回错误号。以前学过的系统函数都是成功返回0,失败返回-1,而错误号保存在全局变量`errno`中,而pthread库的函数都是通过返回值返回错误号,虽然每个线程也都有一个`errno`,但这是为了兼容其它函数接口而提供的,pthread库本身并不使用它,通过返回值返回错误码更加清晰。 在一个线程中调用pthread_create()创建新的线程后,当前线程从pthread_create()返回继续往下执行,而新的线程所执行的代码由我们传给`pthread_create`的函数指针`start_routine`决定。`start_routine`函数接收一个参数,是通过`pthread_create`的`arg`参数传递给它的,该参数的类型为`void *`,这个指针按什么类型解释由调用者自己定义。`start_routine`的返回值类型也是`void *`,这个指针的含义同样由调用者自己定义。`start_routine`返回时,这个线程就退出了,其它线程可以调用`pthread_join`得到`start_routine`的返回值,类似于父进程调用`wait(2)`得到子进程的退出状态,稍后详细介绍`pthread_join`。 `pthread_create`成功返回后,新创建的线程的id被填写到`thread`参数所指向的内存单元。我们知道进程id的类型是`pid_t`,每个进程的id在整个系统中是唯一的,调用`getpid(2)`可以获得当前进程的id,是一个正整数值。线程id的类型是`thread_t`,它只在当前进程中保证是唯一的,在不同的系统中`thread_t`这个类型有不同的实现,它可能是一个整数值,也可能是一个结构体,也可能是一个地址,所以不能简单地当成整数用`printf`打印,调用`pthread_self(3)`可以获得当前线程的id。 `attr`参数表示线程属性,本章不深入讨论线程属性,所有代码例子都传`NULL`给`attr`参数,表示线程属性取缺省值,感兴趣的读者可以参考[[APUE2e]](bi01.html#bibli.apue "Advanced Programming in the UNIX Environment")。首先看一个简单的例子: ``` #include #include #include #include #include pthread_t ntid; void printids(const char *s) { pid_t pid; pthread_t tid; pid = getpid(); tid = pthread_self(); printf("%s pid %u tid %u (0x%x)\n", s, (unsigned int)pid, (unsigned int)tid, (unsigned int)tid); } void *thr_fn(void *arg) { printids(arg); return NULL; } int main(void) { int err; err = pthread_create(&ntid, NULL, thr_fn, "new thread: "); if (err != 0) { fprintf(stderr, "can't create thread: %s\n", strerror(err)); exit(1); } printids("main thread:"); sleep(1); return 0; } ``` 编译运行结果如下: ``` $ gcc main.c -lpthread $ ./a.out main thread: pid 7398 tid 3084450496 (0xb7d8fac0) new thread: pid 7398 tid 3084446608 (0xb7d8eb90) ``` 可知在Linux上,`thread_t`类型是一个地址值,属于同一进程的多个线程调用`getpid(2)`可以得到相同的进程号,而调用`pthread_self(3)`得到的线程号各不相同。 由于`pthread_create`的错误码不保存在`errno`中,因此不能直接用`perror(3)`打印错误信息,可以先用`strerror(3)`把错误码转换成错误信息再打印。 如果任意一个线程调用了`exit`或`_exit`,则整个进程的所有线程都终止,由于从`main`函数`return`也相当于调用`exit`,为了防止新创建的线程还没有得到执行就终止,我们在`main`函数`return`之前延时1秒,这只是一种权宜之计,即使主线程等待1秒,内核也不一定会调度新创建的线程执行,下一节我们会看到更好的办法。 思考题:主线程在一个全局变量`ntid`中保存了新创建的线程的id,如果新创建的线程不调用`pthread_self`而是直接打印这个`ntid`,能不能达到同样的效果? ### 2.2. 终止线程 如果需要只终止某个线程而不终止整个进程,可以有三种方法: * 从线程函数`return`。这种方法对主线程不适用,从`main`函数`return`相当于调用`exit`。 * 一个线程可以调用`pthread_cancel`终止同一进程中的另一个线程。 * 线程可以调用`pthread_exit`终止自己。 用`pthread_cancel`终止一个线程分同步和异步两种情况,比较复杂,本章不打算详细介绍,读者可以参考[[APUE2e]](bi01.html#bibli.apue "Advanced Programming in the UNIX Environment")。下面介绍`pthread_exit`的和`pthread_join`的用法。 ``` #include void pthread_exit(void *value_ptr); ``` `value_ptr`是`void *`类型,和线程函数返回值的用法一样,其它线程可以调用`pthread_join`获得这个指针。 需要注意,`pthread_exit`或者`return`返回的指针所指向的内存单元必须是全局的或者是用`malloc`分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。 ``` #include int pthread_join(pthread_t thread, void **value_ptr); ``` 返回值:成功返回0,失败返回错误号 调用该函数的线程将挂起等待,直到id为`thread`的线程终止。`thread`线程以不同的方法终止,通过`pthread_join`得到的终止状态是不同的,总结如下: * 如果`thread`线程通过`return`返回,`value_ptr`所指向的单元里存放的是`thread`线程函数的返回值。 * 如果`thread`线程被别的线程调用`pthread_cancel`异常终止掉,`value_ptr`所指向的单元里存放的是常数`PTHREAD_CANCELED`。 * 如果`thread`线程是自己调用`pthread_exit`终止的,`value_ptr`所指向的单元存放的是传给`pthread_exit`的参数。 如果对`thread`线程的终止状态不感兴趣,可以传`NULL`给`value_ptr`参数。 看下面的例子(省略了出错处理): ``` #include #include #include #include void *thr_fn1(void *arg) { printf("thread 1 returning\n"); return (void *)1; } void *thr_fn2(void *arg) { printf("thread 2 exiting\n"); pthread_exit((void *)2); } void *thr_fn3(void *arg) { while(1) { printf("thread 3 writing\n"); sleep(1); } } int main(void) { pthread_t tid; void *tret; pthread_create(&tid, NULL, thr_fn1, NULL); pthread_join(tid, &tret); printf("thread 1 exit code %d\n", (int)tret); pthread_create(&tid, NULL, thr_fn2, NULL); pthread_join(tid, &tret); printf("thread 2 exit code %d\n", (int)tret); pthread_create(&tid, NULL, thr_fn3, NULL); sleep(3); pthread_cancel(tid); pthread_join(tid, &tret); printf("thread 3 exit code %d\n", (int)tret); return 0; } ``` 运行结果是: ``` $ ./a.out thread 1 returning thread 1 exit code 1 thread 2 exiting thread 2 exit code 2 thread 3 writing thread 3 writing thread 3 writing thread 3 exit code -1 ``` 可见在Linux的pthread库中常数`PTHREAD_CANCELED`的值是-1。可以在头文件`pthread.h`中找到它的定义: ``` #define PTHREAD_CANCELED ((void *) -1) ``` 一般情况下,线程终止后,其终止状态一直保留到其它线程调用`pthread_join`获取它的状态为止。但是线程也可以被置为detach状态,这样的线程一旦终止就立刻回收它占用的所有资源,而不保留终止状态。不能对一个已经处于detach状态的线程调用`pthread_join`,这样的调用将返回`EINVAL`。对一个尚未detach的线程调用`pthread_join`或`pthread_detach`都可以把该线程置为detach状态,也就是说,不能对同一线程调用两次`pthread_join`,或者如果已经对一个线程调用了`pthread_detach`就不能再调用`pthread_join`了。 ``` #include int pthread_detach(pthread_t tid); ``` 返回值:成功返回0,失败返回错误号。 ## 3. 线程间同步 ### 3.1. mutex 多个线程同时访问共享数据时可能会冲突,这跟前面讲信号时所说的可重入性是同样的问题。比如两个线程都要把某个全局变量增加1,这个操作在某平台需要三条指令完成: 1. 从内存读变量值到寄存器 2. 寄存器的值加1 3. 将寄存器的值写回内存 假设两个线程在多处理器平台上同时执行这三条指令,则可能导致下图所示的结果,最后变量只加了一次而非两次。 **图 35.1. 并行访问冲突** ![并行访问冲突](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80da4c5be.png) 思考一下,如果这两个线程在单处理器平台上执行,能够避免这样的问题吗? 我们通过一个简单的程序观察这一现象。上图所描述的现象从理论上是存在这种可能的,但实际运行程序时很难观察到,为了使现象更容易观察到,我们把上述三条指令做的事情用更多条指令来做: ``` val = counter; printf("%x: %d\n", (unsigned int)pthread_self(), val + 1); counter = val + 1; ``` 我们在“读取变量的值”和“把变量的新值保存回去”这两步操作之间插入一个`printf`调用,它会执行`write`系统调用进内核,为内核调度别的线程执行提供了一个很好的时机。我们在一个循环中重复上述操作几千次,就会观察到访问冲突的现象。 ``` #include #include #include #define NLOOP 5000 int counter; /* incremented by threads */ void *doit(void *); int main(int argc, char **argv) { pthread_t tidA, tidB; pthread_create(&tidA, NULL, &doit, NULL); pthread_create(&tidB, NULL, &doit, NULL); /* wait for both threads to terminate */ pthread_join(tidA, NULL); pthread_join(tidB, NULL); return 0; } void *doit(void *vptr) { int i, val; /* * Each thread fetches, prints, and increments the counter NLOOP times. * The value of the counter should increase monotonically. */ for (i = 0; i < NLOOP; i++) { val = counter; printf("%x: %d\n", (unsigned int)pthread_self(), val + 1); counter = val + 1; } return NULL; } ``` 我们创建两个线程,各自把`counter`增加5000次,正常情况下最后`counter`应该等于10000,但事实上每次运行该程序的结果都不一样,有时候数到5000多,有时候数到6000多。 ``` $ ./a.out b76acb90: 1 b76acb90: 2 b76acb90: 3 b76acb90: 4 b76acb90: 5 b7eadb90: 1 b7eadb90: 2 b7eadb90: 3 b7eadb90: 4 b7eadb90: 5 b76acb90: 6 b76acb90: 7 b7eadb90: 6 b76acb90: 8 ... ``` 对于多线程的程序,访问冲突的问题是很普遍的,解决的办法是引入互斥锁(Mutex,Mutual Exclusive Lock),获得锁的线程可以完成“读-修改-写”的操作,然后释放锁给其它线程,没有获得锁的线程只能等待而不能访问共享数据,这样“读-修改-写”三步操作组成一个原子操作,要么都执行,要么都不执行,不会执行到中间被打断,也不会在其它处理器上并行做这个操作。 Mutex用`pthread_mutex_t`类型的变量表示,可以这样初始化和销毁: ``` #include int pthread_mutex_destroy(pthread_mutex_t *mutex); int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; ``` 返回值:成功返回0,失败返回错误号。 `pthread_mutex_init`函数对Mutex做初始化,参数`attr`设定Mutex的属性,如果`attr`为`NULL`则表示缺省属性,本章不详细介绍Mutex属性,感兴趣的读者可以参考[[APUE2e]](bi01.html#bibli.apue "Advanced Programming in the UNIX Environment")。用`pthread_mutex_init`函数初始化的Mutex可以用`pthread_mutex_destroy`销毁。如果Mutex变量是静态分配的(全局变量或`static`变量),也可以用宏定义`PTHREAD_MUTEX_INITIALIZER`来初始化,相当于用`pthread_mutex_init`初始化并且`attr`参数为`NULL`。Mutex的加锁和解锁操作可以用下列函数: ``` #include int pthread_mutex_lock(pthread_mutex_t *mutex); int pthread_mutex_trylock(pthread_mutex_t *mutex); int pthread_mutex_unlock(pthread_mutex_t *mutex); ``` 返回值:成功返回0,失败返回错误号。 一个线程可以调用pthread_mutex_lock获得Mutex,如果这时另一个线程已经调用pthread_mutex_lock获得了该Mutex,则当前线程需要挂起等待,直到另一个线程调用pthread_mutex_unlock释放Mutex,当前线程被唤醒,才能获得该Mutex并继续执行。 如果一个线程既想获得锁,又不想挂起等待,可以调用pthread_mutex_trylock,如果Mutex已经被另一个线程获得,这个函数会失败返回EBUSY,而不会使线程挂起等待。 现在我们用Mutex解决先前的问题: ``` #include #include #include #define NLOOP 5000 int counter; /* incremented by threads */ pthread_mutex_t counter_mutex = PTHREAD_MUTEX_INITIALIZER; void *doit(void *); int main(int argc, char **argv) { pthread_t tidA, tidB; pthread_create(&tidA, NULL, doit, NULL); pthread_create(&tidB, NULL, doit, NULL); /* wait for both threads to terminate */ pthread_join(tidA, NULL); pthread_join(tidB, NULL); return 0; } void *doit(void *vptr) { int i, val; /* * Each thread fetches, prints, and increments the counter NLOOP times. * The value of the counter should increase monotonically. */ for (i = 0; i < NLOOP; i++) { pthread_mutex_lock(&counter_mutex); val = counter; printf("%x: %d\n", (unsigned int)pthread_self(), val + 1); counter = val + 1; pthread_mutex_unlock(&counter_mutex); } return NULL; } ``` 这样运行结果就正常了,每次运行都能数到10000。 看到这里,读者一定会好奇:Mutex的两个基本操作lock和unlock是如何实现的呢?假设Mutex变量的值为1表示互斥锁空闲,这时某个进程调用lock可以获得锁,而Mutex的值为0表示互斥锁已经被某个线程获得,其它线程再调用lock只能挂起等待。那么lock和unlock的伪代码如下: ``` lock: if(mutex > 0){ mutex = 0; return 0; } else 挂起等待; goto lock; unlock: mutex = 1; 唤醒等待Mutex的线程; return 0; ``` unlock操作中唤醒等待线程的步骤可以有不同的实现,可以只唤醒一个等待线程,也可以唤醒所有等待该Mutex的线程,然后让被唤醒的这些线程去竞争获得这个Mutex,竞争失败的线程继续挂起等待。 细心的读者应该已经看出问题了:对Mutex变量的读取、判断和修改不是原子操作。如果两个线程同时调用lock,这时Mutex是1,两个线程都判断mutex>0成立,然后其中一个线程置mutex=0,而另一个线程并不知道这一情况,也置mutex=0,于是两个线程都以为自己获得了锁。 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。现在我们把lock和unlock的伪代码改一下(以x86的xchg指令为例): ``` lock: movb $0, %al xchgb %al, mutex if(al寄存器的内容 > 0){ return 0; } else 挂起等待; goto lock; unlock: movb $1, mutex 唤醒等待Mutex的线程; return 0; ``` unlock中的释放锁操作同样只用一条指令实现,以保证它的原子性。 也许还有读者好奇,“挂起等待”和“唤醒等待线程”的操作如何实现?每个Mutex有一个等待队列,一个线程要在Mutex上挂起等待,首先在把自己加入等待队列中,然后置线程状态为睡眠,然后调用调度器函数切换到别的线程。一个线程要唤醒等待队列中的其它线程,只需从等待队列中取出一项,把它的状态从睡眠改为就绪,加入就绪队列,那么下次调度器函数执行时就有可能切换到被唤醒的线程。 一般情况下,如果同一个线程先后两次调用lock,在第二次调用时,由于锁已经被占用,该线程会挂起等待别的线程释放锁,然而锁正是被自己占用着的,该线程又被挂起而没有机会释放锁,因此就永远处于挂起等待状态了,这叫做死锁(Deadlock)。另一种典型的死锁情形是这样:线程A获得了锁1,线程B获得了锁2,这时线程A调用lock试图获得锁2,结果是需要挂起等待线程B释放锁2,而这时线程B也调用lock试图获得锁1,结果是需要挂起等待线程A释放锁1,于是线程A和B都永远处于挂起状态了。不难想象,如果涉及到更多的线程和更多的锁,有没有可能死锁的问题将会变得复杂和难以判断。 写程序时应该尽量避免同时获得多个锁,如果一定有必要这么做,则有一个原则:如果所有线程在需要多个锁时都按相同的先后顺序(常见的是按Mutex变量的地址顺序)获得锁,则不会出现死锁。比如一个程序中用到锁1、锁2、锁3,它们所对应的Mutex变量的地址是锁1<锁2<锁3,那么所有线程在需要同时获得2个或3个锁时都应该按锁1、锁2、锁3的顺序获得。如果要为所有的锁确定一个先后顺序比较困难,则应该尽量使用pthread_mutex_trylock调用代替pthread_mutex_lock调用,以免死锁。 ### 3.2. Condition Variable 线程间的同步还有这样一种情况:线程A需要等某个条件成立才能继续往下执行,现在这个条件不成立,线程A就阻塞等待,而线程B在执行过程中使这个条件成立了,就唤醒线程A继续执行。在pthread库中通过条件变量(Condition Variable)来阻塞等待一个条件,或者唤醒等待这个条件的线程。Condition Variable用`pthread_cond_t`类型的变量表示,可以这样初始化和销毁: ``` #include int pthread_cond_destroy(pthread_cond_t *cond); int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr); pthread_cond_t cond = PTHREAD_COND_INITIALIZER; ``` 返回值:成功返回0,失败返回错误号。 和Mutex的初始化和销毁类似,`pthread_cond_init`函数初始化一个Condition Variable,`attr`参数为`NULL`则表示缺省属性,`pthread_cond_destroy`函数销毁一个Condition Variable。如果Condition Variable是静态分配的,也可以用宏定义`PTHEAD_COND_INITIALIZER`初始化,相当于用`pthread_cond_init`函数初始化并且`attr`参数为`NULL`。Condition Variable的操作可以用下列函数: ``` #include int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict abstime); int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex); int pthread_cond_broadcast(pthread_cond_t *cond); int pthread_cond_signal(pthread_cond_t *cond); ``` 返回值:成功返回0,失败返回错误号。 可见,一个Condition Variable总是和一个Mutex搭配使用的。一个线程可以调用`pthread_cond_wait`在一个Condition Variable上阻塞等待,这个函数做以下三步操作: 1. 释放Mutex 2. 阻塞等待 3. 当被唤醒时,重新获得Mutex并返回 `pthread_cond_timedwait`函数还有一个额外的参数可以设定等待超时,如果到达了`abstime`所指定的时刻仍然没有别的线程来唤醒当前线程,就返回`ETIMEDOUT`。一个线程可以调用`pthread_cond_signal`唤醒在某个Condition Variable上等待的另一个线程,也可以调用`pthread_cond_broadcast`唤醒在这个Condition Variable上等待的所有线程。 下面的程序演示了一个生产者-消费者的例子,生产者生产一个结构体串在链表的表头上,消费者从表头取走结构体。 ``` #include #include #include struct msg { struct msg *next; int num; }; struct msg *head; pthread_cond_t has_product = PTHREAD_COND_INITIALIZER; pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; void *consumer(void *p) { struct msg *mp; for (;;) { pthread_mutex_lock(&lock); while (head == NULL) pthread_cond_wait(&has_product, &lock); mp = head; head = mp->next; pthread_mutex_unlock(&lock); printf("Consume %d\n", mp->num); free(mp); sleep(rand() % 5); } } void *producer(void *p) { struct msg *mp; for (;;) { mp = malloc(sizeof(struct msg)); mp->num = rand() % 1000 + 1; printf("Produce %d\n", mp->num); pthread_mutex_lock(&lock); mp->next = head; head = mp; pthread_mutex_unlock(&lock); pthread_cond_signal(&has_product); sleep(rand() % 5); } } int main(int argc, char *argv[]) { pthread_t pid, cid; srand(time(NULL)); pthread_create(&pid, NULL, producer, NULL); pthread_create(&cid, NULL, consumer, NULL); pthread_join(pid, NULL); pthread_join(cid, NULL); return 0; } ``` 执行结果如下: ``` $ ./a.out Produce 744 Consume 744 Produce 567 Produce 881 Consume 881 Produce 911 Consume 911 Consume 567 Produce 698 Consume 698 ``` #### 习题 1、在本节的例子中,生产者和消费者访问链表的顺序是LIFO的,请修改程序,把访问顺序改成FIFO。 ### 3.3. Semaphore Mutex变量是非0即1的,可看作一种资源的可用数量,初始化时Mutex是1,表示有一个可用资源,加锁时获得该资源,将Mutex减到0,表示不再有可用资源,解锁时释放该资源,将Mutex重新加到1,表示又有了一个可用资源。 信号量(Semaphore)和Mutex类似,表示可用资源的数量,和Mutex不同的是这个数量可以大于1。 本节介绍的是POSIX semaphore库函数,详见sem_overview(7),这种信号量不仅可用于同一进程的线程间同步,也可用于不同进程间的同步。 ``` #include int sem_init(sem_t *sem, int pshared, unsigned int value); int sem_wait(sem_t *sem); int sem_trywait(sem_t *sem); int sem_post(sem_t * sem); int sem_destroy(sem_t * sem); ``` semaphore变量的类型为sem_t,sem_init()初始化一个semaphore变量,value参数表示可用资源的数量,pshared参数为0表示信号量用于同一进程的线程间同步,本节只介绍这种情况。在用完semaphore变量之后应该调用sem_destroy()释放与semaphore相关的资源。 调用sem_wait()可以获得资源,使semaphore的值减1,如果调用sem_wait()时semaphore的值已经是0,则挂起等待。如果不希望挂起等待,可以调用sem_trywait()。调用sem_post()可以释放资源,使semaphore的值加1,同时唤醒挂起等待的线程。 上一节生产者-消费者的例子是基于链表的,其空间可以动态分配,现在基于固定大小的环形队列重写这个程序: ``` #include #include #include #include #define NUM 5 int queue[NUM]; sem_t blank_number, product_number; void *producer(void *arg) { int p = 0; while (1) { sem_wait(&blank_number); queue[p] = rand() % 1000 + 1; printf("Produce %d\n", queue[p]); sem_post(&product_number); p = (p+1)%NUM; sleep(rand()%5); } } void *consumer(void *arg) { int c = 0; while (1) { sem_wait(&product_number); printf("Consume %d\n", queue[c]); queue[c] = 0; sem_post(&blank_number); c = (c+1)%NUM; sleep(rand()%5); } } int main(int argc, char *argv[]) { pthread_t pid, cid; sem_init(&blank_number, 0, NUM); sem_init(&product_number, 0, 0); pthread_create(&pid, NULL, producer, NULL); pthread_create(&cid, NULL, consumer, NULL); pthread_join(pid, NULL); pthread_join(cid, NULL); sem_destroy(&blank_number); sem_destroy(&product_number); return 0; } ``` #### 习题 1、本节和上一节的例子给出一个重要的提示:用Condition Variable可以实现Semaphore。请用Condition Variable实现Semaphore,然后用自己实现的Semaphore重写本节的程序。 ### 3.4. 其它线程间同步机制 如果共享数据是只读的,那么各线程读到的数据应该总是一致的,不会出现访问冲突。只要有一个线程可以改写数据,就必须考虑线程间同步的问题。由此引出了读者写者锁(Reader-Writer Lock)的概念,Reader之间并不互斥,可以同时读共享数据,而Writer是独占的(exclusive),在Writer修改数据时其它Reader或Writer不能访问数据,可见Reader-Writer Lock比Mutex具有更好的并发性。 用挂起等待的方式解决访问冲突不见得是最好的办法,因为这样毕竟会影响系统的并发性,在某些情况下解决访问冲突的问题可以尽量避免挂起某个线程,例如Linux内核的Seqlock、RCU(read-copy-update)等机制。 关于这些同步机制的细节,有兴趣的读者可以参考[[APUE2e]](bi01.html#bibli.apue "Advanced Programming in the UNIX Environment")和[[ULK]](bi01.html#bibli.ulk "Understanding the Linux Kernel")。 ## 4. 编程练习 哲学家就餐问题。这是由计算机科学家Dijkstra提出的经典死锁场景。 原版的故事里有五个哲学家(不过我们写的程序可以有N个哲学家),这些哲学家们只做两件事--思考和吃饭,他们思考的时候不需要任何共享资源,但是吃饭的时候就必须使用餐具,而餐桌上的餐具是有限的,原版的故事里,餐具是叉子,吃饭的时候要用两把叉子把面条从碗里捞出来。很显然把叉子换成筷子会更合理,所以:一个哲学家需要两根筷子才能吃饭。 现在引入问题的关键:这些哲学家很穷,只买得起五根筷子。他们坐成一圈,两个人的中间放一根筷子。哲学家吃饭的时候必须同时得到左手边和右手边的筷子。如果他身边的任何一位正在使用筷子,那他只有等着。 假设哲学家的编号是A、B、C、D、E,筷子编号是1、2、3、4、5,哲学家和筷子围成一圈如下图所示: **图 35.2. 哲学家问题** ![哲学家问题](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80da5feca.png) 每个哲学家都是一个单独的线程,每个线程循环做以下动作:思考rand()%10秒,然后先拿左手边的筷子再拿右手边的筷子(筷子这种资源可以用mutex表示),有任何一边拿不到就一直等着,全拿到就吃饭rand()%10秒,然后放下筷子。 编写程序仿真哲学家就餐的场景: ``` Philosopher A fetches chopstick 5 Philosopher B fetches chopstick 1 Philosopher B fetches chopstick 2 Philosopher D fetches chopstick 3 Philosopher B releases chopsticks 1 2 Philosopher A fetches chopstick 1 Philosopher C fetches chopstick 2 Philosopher A releases chopsticks 5 1 ... ``` 分析一下,这个过程有没有可能产生死锁?调用usleep(3)函数可以实现微秒级的延时,试着用usleep(3)加快仿真的速度,看能不能观察到死锁现象。然后修改上述算法避免产生死锁。
';

第 34 章 终端、作业控制与守护进程

最后更新于:2022-04-01 22:02:16

# 第 34 章 终端、作业控制与守护进程 **目录** + [1\. 终端](ch34s01.html) + [1.1\. 终端的基本概念](ch34s01.html#id2890359) + [1.2\. 终端登录过程](ch34s01.html#id2891132) + [1.3\. 网络登录过程](ch34s01.html#id2891618) + [2\. 作业控制](ch34s02.html) + [2.1\. Session与进程组](ch34s02.html#id2892071) + [2.2\. 与作业控制有关的信号](ch34s02.html#id2892541) + [3\. 守护进程](ch34s03.html) ## 1. 终端 ### 1.1. 终端的基本概念 在UNIX系统中,用户通过终端登录系统后得到一个Shell进程,这个终端成为Shell进程的控制终端(Controlling Terminal),在[第 1 节 “引言”](ch30s01.html#process.intro)讲过,控制终端是保存在PCB中的信息,而我们知道`fork`会复制PCB中的信息,因此由Shell进程启动的其它进程的控制终端也是这个终端。默认情况下(没有重定向),每个进程的标准输入、标准输出和标准错误输出都指向控制终端,进程从标准输入读也就是读用户的键盘输入,进程往标准输出或标准错误输出写也就是输出到显示器上。此外在[第 33 章 _信号_](ch33.html#signal)还讲过,在控制终端输入一些特殊的控制键可以给前台进程发信号,例如Ctrl-C表示`SIGINT`,Ctrl-\表示`SIGQUIT`。 在[第 28 章 _文件与I/O_](ch28.html#io)中讲过,每个进程都可以通过一个特殊的设备文件`/dev/tty`访问它的控制终端。事实上每个终端设备都对应一个不同的设备文件,`/dev/tty`提供了一个通用的接口,一个进程要访问它的控制终端既可以通过`/dev/tty`也可以通过该终端设备所对应的设备文件来访问。`ttyname`函数可以由文件描述符查出对应的文件名,该文件描述符必须指向一个终端设备而不能是任意文件。下面我们通过实验看一下各种不同的终端所对应的设备文件名。 **例 34.1. 查看终端对应的设备文件名** ``` #include #include int main() { printf("fd 0: %s\n", ttyname(0)); printf("fd 1: %s\n", ttyname(1)); printf("fd 2: %s\n", ttyname(2)); return 0; } ``` 在图形终端窗口下运行这个程序,可能会得到 ``` $ ./a.out fd 0: /dev/pts/0 fd 1: /dev/pts/0 fd 2: /dev/pts/0 ``` 再开一个终端窗口运行这个程序,可能又会得到 ``` $ ./a.out fd 0: /dev/pts/1 fd 1: /dev/pts/1 fd 2: /dev/pts/1 ``` 用Ctrl-Alt-F1切换到字符终端运行这个程序,结果是 ``` $ ./a.out fd 0: /dev/tty1 fd 1: /dev/tty1 fd 2: /dev/tty1 ``` 读者可以再试试在Ctrl-Alt-F2的字符终端下或者在`telnet`或`ssh`登陆的网络终端下运行这个程序,看看结果是什么。 ### 1.2. 终端登录过程 一台PC通常只有一套键盘和显示器,也就是只有一套终端设备,但是可以通过Ctrl-Alt-F1~Ctrl-Alt-F6切换到6个字符终端,相当于有6套虚拟的终端设备,它们共用同一套物理终端设备,对应的设备文件分别是`/dev/tty1`~`/dev/tty6`,所以称为虚拟终端(Virtual Terminal)。设备文件`/dev/tty0`表示当前虚拟终端,比如切换到Ctrl-Alt-F1的字符终端时`/dev/tty0`就表示`/dev/tty1`,切换到Ctrl-Alt-F2的字符终端时`/dev/tty0`就表示`/dev/tty2`,就像`/dev/tty`一样也是一个通用的接口,但它不能表示图形终端窗口所对应的终端。 再举个例子,做嵌入式开发时经常会用到串口终端,目标板的每个串口对应一个终端设备,比如`/dev/ttyS0`、`/dev/ttyS1`等,将主机和目标板用串口线连起来,就可以在主机上通过Linux的`minicom`或Windows的超级终端工具登录到目标板的系统。 内核中处理终端设备的模块包括硬件驱动程序和线路规程(Line Discipline)。 **图 34.1. 终端设备模块** ![终端设备模块](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80da04b7d.png) 硬件驱动程序负责读写实际的硬件设备,比如从键盘读入字符和把字符输出到显示器,线路规程像一个过滤器,对于某些特殊字符并不是让它直接通过,而是做特殊处理,比如在键盘上按下Ctrl-Z,对应的字符并不会被用户程序的`read`读到,而是被线路规程截获,解释成`SIGTSTP`信号发给前台进程,通常会使该进程停止。线路规程应该过滤哪些字符和做哪些特殊处理是可以配置的。 终端设备有输入和输出队列缓冲区,如下图所示。 **图 34.2. 终端缓冲** ![终端缓冲](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80da149a3.png) 以输入队列为例,从键盘输入的字符经线路规程过滤后进入输入队列,用户程序以先进先出的顺序从队列中读取字符,一般情况下,当输入队列满的时候再输入字符会丢失,同时系统会响铃警报。终端可以配置成回显(Echo)模式,在这种模式下,输入队列中的每个字符既送给用户程序也送给输出队列,因此我们在命令行键入字符时,该字符不仅可以被程序读取,我们也可以同时在屏幕上看到该字符的回显。 现在我们来看终端登录的过程: 1、系统启动时,`init`进程根据配置文件`/etc/inittab`确定需要打开哪些终端。例如配置文件中有这样一行: ``` 1:2345:respawn:/sbin/getty 9600 tty1 ``` 和`/etc/passwd`类似,每个字段用`:`号隔开。开头的1是这一行配置的id,通常要和`tty`的后缀一致,配置`tty2`的那一行id就应该是2。第二个字段2345表示运行级别2~5都执行这个配置。最后一个字段`/sbin/getty 9600 tty1`是`init`进程要`fork`/`exec`的命令,打开终端`/dev/tty1`,波特率是9600(波特率只对串口和Modem终端有意义),然后提示用户输入帐号。中间的`respawn`字段表示`init`进程会监视`getty`进程的运行状态,一旦该进程终止,`init`会再次`fork`/`exec`这个命令,所以我们从终端退出登录后会再次提示输入帐号。 有些新的Linux发行版已经不用`/etc/inittab`这个配置文件了,例如Ubuntu用`/etc/event.d`目录下的配置文件来配置`init`。 2、`getty`根据命令行参数打开终端设备作为它的控制终端,把文件描述符0、1、2都指向控制终端,然后提示用户输入帐号。用户输入帐号之后,`getty`的任务就完成了,它再执行`login`程序: ``` execle("/bin/login", "login", "-p", username, NULL, envp); ``` 3、`login`程序提示用户输入密码(输入密码期间关闭终端的回显),然后验证帐号密码的正确性。如果密码不正确,`login`进程终止,`init`会重新`fork`/`exec`一个`getty`进程。如果密码正确,`login`程序设置一些环境变量,设置当前工作目录为该用户的主目录,然后执行Shell: ``` execl("/bin/bash", "-bash", NULL); ``` 注意`argv[0]`参数的程序名前面加了一个`-`,这样`bash`就知道自己是作为登录Shell启动的,执行登录Shell的启动脚本。从`getty`开始`exec`到`login`,再`exec`到`bash`,其实都是同一个进程,因此控制终端没变,文件描述符0、1、2也仍然指向控制终端。由于`fork`会复制PCB信息,所以由Shell启动的其它进程也都是如此。 ### 1.3. 网络登录过程 虚拟终端或串口终端的数目是有限的,虚拟终端一般就是`/dev/tty1`~`/dev/tty6`六个,串口终端的数目也不超过串口的数目。然而网络终端或图形终端窗口的数目却是不受限制的,这是通过伪终端(Pseudo TTY)实现的。一套伪终端由一个主设备(PTY Master)和一个从设备(PTY Slave)组成。主设备在概念上相当于键盘和显示器,只不过它不是真正的硬件而是一个内核模块,操作它的也不是用户而是另外一个进程。从设备和上面介绍的`/dev/tty1`这样的终端设备模块类似,只不过它的底层驱动程序不是访问硬件而是访问主设备。通过[例 34.1 “查看终端对应的设备文件名”](ch34s01.html#jobs.ttyname)的实验结果可以看到,网络终端或图形终端窗口的Shell进程以及它启动的其它进程都会认为自己的控制终端是伪终端从设备,例如`/dev/pts/0`、`/dev/pts/1`等。下面以`telnet`为例说明网络登录和使用伪终端的过程。 **图 34.3. 伪终端** ![伪终端](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80da25fc1.png) 1. 用户通过`telnet`客户端连接服务器。如果服务器配置为独立(Standalone)模式,则在服务器监听连接请求是一个`telnetd`进程,它`fork`出一个`telnetd`子进程来服务客户端,父进程仍监听其它连接请求。 另外一种可能是服务器端由系统服务程序`inetd`或`xinetd`监听连接请求,`inetd`称为Internet Super-Server,它监听系统中的多个网络服务端口,如果连接请求的端口号和`telnet`服务端口号一致,则`fork`/`exec`一个`telnetd`子进程来服务客户端。`xinetd`是`inetd`的升级版本,配置更为灵活。 2. `telnetd`子进程打开一个伪终端设备,然后再经过`fork`一分为二:父进程操作伪终端主设备,子进程将伪终端从设备作为它的控制终端,并且将文件描述符0、1、2指向控制终端,二者通过伪终端通信,父进程还负责和`telnet`客户端通信,而子进程负责用户的登录过程,提示输入帐号,然后调用`exec`变成`login`进程,提示输入密码,然后调用`exec`变成Shell进程。这个Shell进程认为自己的控制终端是伪终端从设备,伪终端主设备可以看作键盘显示器等硬件,而操作这个伪终端的“用户”就是父进程`telnetd`。 3. 当用户输入命令时,`telnet`客户端将用户输入的字符通过网络发给`telnetd`服务器,由`telnetd`服务器代表用户将这些字符输入伪终端。Shell进程并不知道自己连接的是伪终端而不是真正的键盘显示器,也不知道操作终端的“用户”其实是`telnetd`服务器而不是真正的用户。Shell仍然解释执行命令,将标准输出和标准错误输出写到终端设备,这些数据最终由`telnetd`服务器发回给`telnet`客户端,然后显示给用户看。 如果`telnet`客户端和服务器之间的网络延迟较大,我们会观察到按下一个键之后要过几秒钟才能回显到屏幕上。这说明我们每按一个键`telnet`客户端都会立刻把该字符发送给服务器,然后这个字符经过伪终端主设备和从设备之后被Shell进程读取,同时回显到伪终端从设备,回显的字符再经过伪终端主设备、`telnetd`服务器和网络发回给`telnet`客户端,显示给用户看。也许你会觉得吃惊,但真的是这样:每按一个键都要在网络上走个来回! BSD系列的UNIX在`/dev`目录下创建很多`ptyXX`和`ttyXX`设备文件,`XX`由字母和数字组成,`ptyXX`是主设备,相对应的`ttyXX`是从设备,伪终端的数目取决于内核配置。而在SYS V系列的UNIX上,伪终端主设备是`/dev/ptmx`,“mx”表示Multiplex,意思是多个主设备复用同一个设备文件,每打开一次`/dev/ptmx`,内核就分配一个主设备,同时在`/dev/pts`目录下创建一个从设备文件,当终端关闭时就从`/dev/pts`目录下删除相应的从设备文件。Linux同时支持上述两种伪终端,目前的标准倾向于SYS V的伪终端。 ## 2. 作业控制 ### 2.1. Session与进程组 在[第 1 节 “信号的基本概念”](ch33s01.html#signal.intro)中我说过“Shell可以同时运行一个前台进程和任意多个后台进程”其实是不全面的,现在我们来研究更复杂的情况。事实上,Shell分前后台来控制的不是进程而是作业(Job)或者进程组(Process Group)。一个前台作业可以由多个进程组成,一个后台作业也可以由多个进程组成,Shell可以同时运行一个前台作业和任意多个后台作业,这称为作业控制(Job Control)。例如用以下命令启动5个进程(这个例子出自[[APUE2e]](bi01.html#bibli.apue "Advanced Programming in the UNIX Environment")): ``` $ proc1 | proc2 & $ proc3 | proc4 | proc5 ``` 其中`proc1`和`proc2`属于同一个后台进程组,`proc3`、`proc4`、`proc5`属于同一个前台进程组,Shell进程本身属于一个单独的进程组。这些进程组的控制终端相同,它们属于同一个Session。当用户在控制终端输入特殊的控制键(例如Ctrl-C)时,内核会发送相应的信号(例如`SIGINT`)给前台进程组的所有进程。各进程、进程组、Session的关系如下图所示。 **图 34.4. Session与进程组** ![Session与进程组](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80da38e77.png) 现在我们从Session和进程组的角度重新来看登录和执行命令的过程。 1. `getty`或`telnetd`进程在打开终端设备之前调用`setsid`函数创建一个新的Session,该进程称为Session Leader,该进程的id也可以看作Session的id,然后该进程打开终端设备作为这个Session中所有进程的控制终端。在创建新Session的同时也创建了一个新的进程组,该进程是这个进程组的Process Group Leader,该进程的id也是进程组的id。 2. 在登录过程中,`getty`或`telnetd`进程变成`login`,然后变成Shell,但仍然是同一个进程,仍然是Session Leader。 3. 由Shell进程`fork`出的子进程本来具有和Shell相同的Session、进程组和控制终端,但是Shell调用`setpgid`函数将作业中的某个子进程指定为一个新进程组的Leader,然后调用`setpgid`将该作业中的其它子进程也转移到这个进程组中。如果这个进程组需要在前台运行,就调用`tcsetpgrp`函数将它设置为前台进程组,由于一个Session只能有一个前台进程组,所以Shell所在的进程组就自动变成后台进程组。 在上面的例子中,`proc3`、`proc4`、`proc5`被Shell放到同一个前台进程组,其中有一个进程是该进程组的Leader,Shell调用`wait`等待它们运行结束。一旦它们全部运行结束,Shell就调用`tcsetpgrp`函数将自己提到前台继续接受命令。但是注意,如果`proc3`、`proc4`、`proc5`中的某个进程又`fork`出子进程,子进程也属于同一进程组,但是Shell并不知道子进程的存在,也不会调用`wait`等待它结束。换句话说,`proc3 | proc4 | proc5`是Shell的作业,而这个子进程不是,这是作业和进程组在概念上的区别。一旦作业运行结束,Shell就把自己提到前台,如果原来的前台进程组还存在(如果这个子进程还没终止),则它自动变成后台进程组(回顾一下[例 30.3 “fork”](ch30s03.html#process.simplefork))。 下面看两个例子。 ``` $ ps -o pid,ppid,pgrp,session,tpgid,comm | cat PID PPID PGRP SESS TPGID COMMAND 6994 6989 6994 6994 8762 bash 8762 6994 8762 6994 8762 ps 8763 6994 8762 6994 8762 cat ``` 这个作业由`ps`和`cat`两个进程组成,在前台运行。从`PPID`列可以看出这两个进程的父进程是`bash`。从`PGRP`列可以看出,`bash`在id为6994的进程组中,这个id等于`bash`的进程id,所以它是进程组的Leader,而两个子进程在id为8762的进程组中,`ps`是这个进程组的Leader。从`SESS`可以看出三个进程都在同一Session中,`bash`是Session Leader。从`TPGID`可以看出,前台进程组的id是8762,也就是两个子进程所在的进程组。 ``` $ ps -o pid,ppid,pgrp,session,tpgid,comm | cat & [1] 8835 $ PID PPID PGRP SESS TPGID COMMAND 6994 6989 6994 6994 6994 bash 8834 6994 8834 6994 6994 ps 8835 6994 8834 6994 6994 cat ``` 这个作业由`ps`和`cat`两个进程组成,在后台运行,`bash`不等作业结束就打印提示信息`[1] 8835`然后给出提示符接受新的命令,`[1]`是作业的编号,如果同时运行多个作业可以用这个编号区分,8835是该作业中某个进程的id。请读者自己分析`ps`命令的输出结果。 ### 2.2. 与作业控制有关的信号 我们通过实验来理解与作业控制有关的信号。 ``` $ cat & [1] 9386 $ (再次回车) [1]+ Stopped cat ``` 将`cat`放到后台运行,由于`cat`需要读标准输入(也就是终端输入),而后台进程是不能读终端输入的,因此内核发`SIGTTIN`信号给进程,该信号的默认处理动作是使进程停止。 ``` $ jobs [1]+ Stopped cat $ fg %1 cat hello(回车) hello ^Z [1]+ Stopped cat ``` `jobs`命令可以查看当前有哪些作业。`fg`命令可以将某个作业提至前台运行,如果该作业的进程组正在后台运行则提至前台运行,如果该作业处于停止状态,则给进程组的每个进程发`SIGCONT`信号使它继续运行。参数`%1`表示将第1个作业提至前台运行。`cat`提到前台运行后,挂起等待终端输入,当输入`hello`并回车后,`cat`打印出同样的一行,然后继续挂起等待输入。如果输入Ctrl-Z则向所有前台进程发`SIGTSTP`信号,该信号的默认动作是使进程停止。 ``` $ bg %1 [1]+ cat & [1]+ Stopped cat ``` `bg`命令可以让某个停止的作业在后台继续运行,也需要给该作业的进程组的每个进程发`SIGCONT`信号。`cat`进程继续运行,又要读终端输入,然而它在后台不能读终端输入,所以又收到`SIGTTIN`信号而停止。 ``` $ ps PID TTY TIME CMD 6994 pts/0 00:00:05 bash 11022 pts/0 00:00:00 cat 11023 pts/0 00:00:00 ps $ kill 11022 $ ps PID TTY TIME CMD 6994 pts/0 00:00:05 bash 11022 pts/0 00:00:00 cat 11024 pts/0 00:00:00 ps $ fg %1 cat Terminated ``` 用`kill`命令给一个停止的进程发`SIGTERM`信号,这个信号并不会立刻处理,而要等进程准备继续运行之前处理,默认动作是终止进程。但如果给一个停止的进程发`SIGKILL`信号就不同了。 ``` $ cat & [1] 11121 $ ps PID TTY TIME CMD 6994 pts/0 00:00:05 bash 11121 pts/0 00:00:00 cat 11122 pts/0 00:00:00 ps [1]+ Stopped cat $ kill -KILL 11121 [1]+ Killed cat ``` `SIGKILL`信号既不能被阻塞也不能被忽略,也不能用自定义函数捕捉,只能按系统的默认动作立刻处理。与此类似的还有`SIGSTOP`信号,给一个进程发`SIGSTOP`信号会使进程停止,这个默认的处理动作不能改变。这样保证了不管什么样的进程都能用`SIGKILL`终止或者用`SIGSTOP`停止,当系统出现异常时管理员总是有办法杀掉有问题的进程或者暂时停掉怀疑有问题的进程。 上面讲了如果后台进程试图从控制终端读,会收到`SIGTTIN`信号而停止,如果试图向控制终端写呢?通常是允许写的。如果觉得后台进程向控制终端输出信息干扰了用户使用终端,可以设置一个终端选项禁止后台进程写。 ``` $ cat testfile & [1] 11426 $ hello [1]+ Done cat testfile $ stty tostop $ cat testfile & [1] 11428 [1]+ Stopped cat testfile $ fg %1 cat testfile hello ``` 首先用`stty`命令设置终端选项,禁止后台进程写,然后启动一个后台进程准备往终端写,这时进程收到一个`SIGTTOU`信号,默认处理动作也是停止进程。 ## 3. 守护进程 Linux系统启动时会启动很多系统服务进程,例如[第 1.3 节 “网络登录过程”](ch34s01.html#jobs.netlogin)讲的`inetd`,这些系统服务进程没有控制终端,不能直接和用户交互。其它进程都是在用户登录或运行程序时创建,在运行结束或用户注销时终止,但系统服务进程不受用户登录注销的影响,它们一直在运行着。这种进程有一个名称叫守护进程(Daemon)。 下面我们用`ps axj`命令查看系统中的进程。参数`a`表示不仅列当前用户的进程,也列出所有其他用户的进程,参数`x`表示不仅列有控制终端的进程,也列出所有无控制终端的进程,参数`j`表示列出与作业控制相关的信息。 ``` $ ps axj PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND 0 1 1 1 ? -1 Ss 0 0:01 /sbin/init 0 2 0 0 ? -1 S< 0 0:00 [kthreadd] 2 3 0 0 ? -1 S< 0 0:00 [migration/0] 2 4 0 0 ? -1 S< 0 0:00 [ksoftirqd/0] ... 1 2373 2373 2373 ? -1 S pid_t setsid(void); ``` 该函数调用成功时返回新创建的Session的id(其实也就是当前进程的id),出错返回-1。注意,调用这个函数之前,当前进程不允许是进程组的Leader,否则该函数返回-1。要保证当前进程不是进程组的Leader也很容易,只要先`fork`再调用`setsid`就行了。`fork`创建的子进程和父进程在同一个进程组中,进程组的Leader必然是该组的第一个进程,所以子进程不可能是该组的第一个进程,在子进程中调用`setsid`就不会有问题了。 成功调用该函数的结果是: * 创建一个新的Session,当前进程成为Session Leader,当前进程的id就是Session的id。 * 创建一个新的进程组,当前进程成为进程组的Leader,当前进程的id就是进程组的id。 * 如果当前进程原本有一个控制终端,则它失去这个控制终端,成为一个没有控制终端的进程。所谓失去控制终端是指,原来的控制终端仍然是打开的,仍然可以读写,但只是一个普通的打开文件而不是控制终端了。 **例 34.2. 创建守护进程** ``` #include #include #include void daemonize(void) { pid_t pid; /* * Become a session leader to lose controlling TTY. */ if ((pid = fork()) < 0) { perror("fork"); exit(1); } else if (pid != 0) /* parent */ exit(0); setsid(); /* * Change the current working directory to the root. */ if (chdir("/") < 0) { perror("chdir"); exit(1); } /* * Attach file descriptors 0, 1, and 2 to /dev/null. */ close(0); open("/dev/null", O_RDWR); dup2(0, 1); dup2(0, 2); } int main(void) { daemonize(); while(1); } ``` 为了确保调用`setsid`的进程不是进程组的Leader,首先`fork`出一个子进程,父进程退出,然后子进程调用`setsid`创建新的Session,成为守护进程。按照守护进程的惯例,通常将当前工作目录切换到根目录,将文件描述符0、1、2重定向到`/dev/null`。Linux也提供了一个库函数`daemon(3)`实现我们的`daemonize`函数的功能,它带两个参数指示要不要切换工作目录到根目录,以及要不要把文件描述符0、1、2重定向到`/dev/null`。 ``` $ ./a.out $ ps PID TTY TIME CMD 11494 pts/0 00:00:00 bash 13271 pts/0 00:00:00 ps $ ps xj | grep a.out 1 13270 13270 13270 ? -1 Rs 1000 0:05 ./a.out 11494 13273 13272 11494 pts/0 13272 S+ 1000 0:00 grep a.out (关闭终端窗口重新打开,或者注销重新登录) $ ps xj | grep a.out 1 13270 13270 13270 ? -1 Rs 1000 0:21 ./a.out 13282 13338 13337 13282 pts/1 13337 S+ 1000 0:00 grep a.out $ kill 13270 ``` 运行这个程序,它变成一个守护进程,不再和当前终端关联。用`ps`命令看不到,必须运行带`x`参数的`ps`命令才能看到。另外还可以看到,用户关闭终端窗口或注销也不会影响守护进程的运行。
';

第 33 章 信号

最后更新于:2022-04-01 22:02:14

# 第 33 章 信号 **目录** + [1\. 信号的基本概念](ch33s01.html) + [2\. 产生信号](ch33s02.html) + [2.1\. 通过终端按键产生信号](ch33s02.html#id2884244) + [2.2\. 调用系统函数向进程发信号](ch33s02.html#id2884400) + [2.3\. 由软件条件产生信号](ch33s02.html#id2884567) + [3\. 阻塞信号](ch33s03.html) + [3.1\. 信号在内核中的表示](ch33s03.html#id2884694) + [3.2\. 信号集操作函数](ch33s03.html#id2884876) + [3.3\. sigprocmask](ch33s03.html#id2885022) + [3.4\. sigpending](ch33s03.html#id2885205) + [4\. 捕捉信号](ch33s04.html) + [4.1\. 内核如何实现信号的捕捉](ch33s04.html#id2885289) + [4.2\. sigaction](ch33s04.html#id2885439) + [4.3\. pause](ch33s04.html#id2885627) + [4.4\. 可重入函数](ch33s04.html#id2885983) + [4.5\. sig_atomic_t类型与volatile限定符](ch33s04.html#id2886197) + [4.6\. 竞态条件与sigsuspend函数](ch33s04.html#id2886686) + [4.7\. 关于SIGCHLD信号](ch33s04.html#id2887260) ## 1. 信号的基本概念 为了理解信号,先从我们最熟悉的场景说起: 1. 用户输入命令,在Shell下启动一个前台进程。 2. 用户按下Ctrl-C,这个键盘输入产生一个硬件中断。 3. 如果CPU当前正在执行这个进程的代码,则该进程的用户空间代码暂停执行,CPU从用户态切换到内核态处理硬件中断。 4. 终端驱动程序将Ctrl-C解释成一个`SIGINT`信号,记在该进程的PCB中(也可以说发送了一个`SIGINT`信号给该进程)。 5. 当某个时刻要从内核返回到该进程的用户空间代码继续执行之前,首先处理PCB中记录的信号,发现有一个`SIGINT`信号待处理,而这个信号的默认处理动作是终止进程,所以直接终止进程而不再返回它的用户空间代码执行。 注意,Ctrl-C产生的信号只能发给前台进程。在[第 3.3 节 “wait和waitpid函数”](ch30s03.html#process.wait)中我们看到一个命令后面加个`&`可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像Ctrl-C这种控制键产生的信号。前台进程在运行过程中用户随时可能按下Ctrl-C而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到`SIGINT`信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。 用`kill -l`命令可以察看系统定义的信号列表: ``` $ kill -l 1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP 6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1 11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM 16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP 21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ 26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR 31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 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 章 _终端、作业控制与守护进程_](ch34.html#jobs)详细解释)。 * 硬件异常产生信号,这些条件由硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为`SIGFPE`信号发送给进程。再比如当前进程访问了非法内存地址,,MMU会产生异常,内核将这个异常解释为`SIGSEGV`信号发送给进程。 * 一个进程调用`kill(2)`函数可以发送信号给另一个进程。 * 可以用`kill(1)`命令发送信号给某个进程,`kill(1)`命令也是调用`kill(2)`函数实现的,如果不明确指定信号则发送`SIGTERM`信号,该信号的默认处理动作是终止进程。 * 当内核检测到某种软件条件发生时也可以通过信号通知进程,例如闹钟超时产生`SIGALRM`信号,向读端已关闭的管道写数据时产生`SIGPIPE`信号。 如果不想按默认动作处理信号,用户程序可以调用`sigaction(2)`函数告诉内核如何处理某种信号(`sigaction`函数稍后详细介绍),可选的处理动作有以下三种: 1. 忽略此信号。 2. 执行该信号的默认处理动作。 3. 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。 ## 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 “管道”](ch30s04.html#process.pipe)中已经介绍过了。本节主要介绍`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`信号终止。 ## 3. 阻塞信号 ### 3.1. 信号在内核中的表示 以上我们讨论了信号_产生_(Generation)的各种原因,而实际执行信号的处理动作称为信号_递达_(Delivery),信号从产生到递达之间的状态,称为信号_未决_(Pending)。进程可以选择阻塞(Block)某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。信号在内核中的表示可以看作是这样的: **图 33.1. 信号在内核中的表示示意图** ![信号在内核中的表示示意图](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d9ba527.png) 每个信号都有两个标志位分别表示阻塞和未决,还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中, 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参数的含义** | | | | --- | --- | | `SIG_BLOCK` | `set`包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask|set | | `SIG_UNBLOCK` | `set`包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于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 #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) ``` ## 4. 捕捉信号 ### 4.1. 内核如何实现信号的捕捉 如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 1. 用户程序注册了`SIGQUIT`信号的处理函数`sighandler`。 2. 当前正在执行`main`函数,这时发生中断或异常切换到内核态。 3. 在中断处理完毕后要返回用户态的`main`函数之前检查到有信号`SIGQUIT`递达。 4. 内核决定返回用户态后不是恢复`main`函数的上下文继续执行,而是执行`sighandler`函数,`sighandler`和`main`函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。 5. `sighandler`函数返回后自动执行特殊的系统调用`sigreturn`再次进入内核态。 6. 如果没有新的信号要递达,这次再返回用户态就是恢复`main`函数的上下文继续执行了。 **图 33.2. 信号的捕捉** ![信号的捕捉](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d9c9f23.png) 上图出自[[ULK]](bi01.html#bibli.ulk "Understanding the Linux Kernel")。 ### 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); /* addr of signal handler, */ /* or SIG_IGN, or SIG_DFL */ sigset_t sa_mask; /* additional signals to block */ int sa_flags; /* 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]](bi01.html#bibli.apue "Advanced Programming in the UNIX Environment")。 ### 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. 可重入函数 当捕捉到信号时,不论进程的主控制流程当前执行到哪儿,都会先跳到信号处理函数中执行,从信号处理函数返回后再继续执行主控制流程。信号处理函数是一个单独的控制流程,因为它和主控制流程是异步的,二者不存在调用和被调用的关系,并且使用不同的堆栈空间。引入了信号处理函数使得一个进程具有多个控制流程,如果这些控制流程访问相同的全局资源(全局变量、硬件资源等),就有可能出现冲突,如下面的例子所示。 **图 33.3. 不可重入函数** ![不可重入函数](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d9df8d7.png) `main`函数调用`insert`函数向一个链表`head`中插入节点`node1`,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到`sighandler`函数,`sighandler`也调用`insert`函数向同一个链表`head`中插入节点`node2`,插入操作的两步都做完之后从`sighandler`返回内核态,再次回到用户态就从`main`函数调用的`insert`函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,`main`函数和`sighandler`先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。 像上例这样,`insert`函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,`insert`函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(`Reentrant`)函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱? 如果一个函数符合以下条件之一则是不可重入的: * 调用了`malloc`或`free`,因为`malloc`也是用全局链表来管理堆的。 * 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。 SUS规定有些系统函数必须以线程安全的方式实现,这里就不列了,请参考[[APUE2e]](bi01.html#bibli.apue "Advanced Programming in the UNIX Environment")。 ### 4.5. sig_atomic_t类型与volatile限定符 在上面的例子中,`main`和`sighandler`都调用`insert`函数则有可能出现链表的错乱,其根本原因在于,对全局链表的插入操作要分两步完成,不是一个原子操作,假如这两步操作必定会一起做完,中间不可能被打断,就不会出现错乱了。下一节线程会讲到如何保证一个代码段以原子操作完成。 现在想一下,如果对全局数据的访问只有一行代码,是不是原子操作呢?比如,`main`和`sighandler`都对一个全局变量赋值,会不会出现错乱呢?比如下面的程序: ``` long long a; int main(void) { a=5; return 0; } ``` 带调试信息编译,然后带源代码反汇编: ``` $ gcc main.c -g $ objdump -dS a.out ``` 其中main函数的指令中有: ``` a=5; 8048352: c7 05 50 95 04 08 05 movl $0x5,0x8049550 8048359: 00 00 00 804835c: c7 05 54 95 04 08 00 movl $0x0,0x8049554 8048363: 00 00 00 ``` 虽然C代码只有一行,但是在32位机上对一个64位的`long long`变量赋值需要两条指令完成,因此不是原子操作。同样地,读取这个变量到寄存器需要两个32位寄存器才放得下,也需要两条指令,不是原子操作。请读者设想一种时序,`main`和`sighandler`都对这个变量`a`赋值,最后变量`a`的值发生错乱。 如果上述程序在64位机上编译执行,则有可能用一条指令完成赋值,因而是原子操作。如果`a`是32位的`int`变量,在32位机上赋值是原子操作,在16位机上就不是。如果在程序中需要使用一个变量,要保证对它的读写都是原子操作,应该采用什么类型呢?为了解决这些平台相关的问题,C标准定义了一个类型`sig_atomic_t`,在不同平台的C语言库中取不同的类型,例如在32位机上定义`sig_atomic_t`为`int`类型。 在使用`sig_atomic_t`类型的变量时,还需要注意另一个问题。看如下的例子: ``` #include sig_atomic_t a=0; int main(void) { /* register a sighandler */ while(!a); /* wait until a changes in sighandler */ /* do something after signal arrives */ return 0; } ``` 为了简洁,这里只写了一个代码框架来说明问题。在`main`函数中首先要注册某个信号的处理函数`sighandler`,然后在一个`while`死循环中等待信号发生,如果有信号递达则执行`sighandler`,在`sighandler`中将`a`改为1,这样再次回到`main`函数时就可以退出`while`循环,执行后续处理。用上面的方法编译和反汇编这个程序,在`main`函数的指令中有: ``` /* register a sighandler */ while(!a); /* wait until a changes in sighandler */ 8048352: a1 3c 95 04 08 mov 0x804953c,%eax 8048357: 85 c0 test %eax,%eax 8048359: 74 f7 je 8048352 ``` 将全局变量`a`从内存读到`eax`寄存器,对`eax`和`eax`做AND运算,若结果为0则跳回循环开头,再次从内存读变量`a`的值,可见这三条指令等价于C代码的`while(!a);`循环。如果在编译时加了优化选项,例如: ``` $ gcc main.c -O1 -g $ objdump -dS a.out ``` 则`main`函数的指令中有: ``` 8048352: 83 3d 3c 95 04 08 00 cmpl $0x0,0x804953c /* register a sighandler */ while(!a); /* wait until a changes in sighandler */ 8048359: 74 fe je 8048359 ``` 第一条指令将全局变量`a`的内存单元直接和0比较,如果相等,则第二条指令成了一个死循环,注意,这是一个真正的死循环:即使`sighandler`将`a`改为1,只要没有影响Zero标志位,回到`main`函数后仍然死在第二条指令上,因为不会再次从内存读取变量`a`的值。 是编译器优化得有错误吗?不是的。设想一下,如果程序只有单一的执行流程,只要当前执行流程没有改变`a`的值,`a`的值就没有理由会变,不需要反复从内存读取,因此上面的两条指令和`while(!a);`循环是等价的,并且优化之后省去了每次循环读内存的操作,效率非常高。所以不能说编译器做错了,只能说_编译器无法识别程序中存在多个执行流程_。之所以程序中存在多个执行流程,是因为调用了特定平台上的特定库函数,比如`sigaction`、`pthread_create`,这些不是C语言本身的规范,不归编译器管,程序员应该自己处理这些问题。C语言提供了`volatile`限定符,如果将上述变量定义为`volatile sig_atomic_t a=0;`那么即使指定了优化选项,编译器也不会优化掉对变量a内存单元的读写。 对于程序中存在多个执行流程访问同一全局变量的情况,`volatile`限定符是必要的,此外,虽然程序只有单一的执行流程,但是变量属于以下情况之一的,也需要`volatile`限定: * 变量的内存单元中的数据不需要写操作就可以自己发生变化,每次读上来的值都可能不一样 * 即使多次向变量的内存单元中写数据,只写不读,也并不是在做无用功,而是有特殊意义的 什么样的内存单元会具有这样的特性呢?肯定不是普通的内存,而是映射到内存地址空间的硬件寄存器,例如串口的接收寄存器属于上述第一种情况,而发送寄存器属于上述第二种情况。 _`sig_atomic_t`类型的变量应该总是加上`volatile`限定符_,因为要使用`sig_atomic_t`类型的理由也正是要加`volatile`限定符的理由。 ### 4.6. 竞态条件与sigsuspend函数 现在重新审视[例 33.2 “mysleep”](ch33s04.html#signal.mysleep),设想这样的时序: 1. 注册`SIGALRM`信号的处理函数。 2. 调用`alarm(nsecs)`设定闹钟。 3. 内核调度优先级更高的进程取代当前进程执行,并且优先级更高的进程有很多个,每个都要执行很长时间 4. `nsecs`秒钟之后闹钟超时了,内核发送`SIGALRM`信号给这个进程,处于未决状态。 5. 优先级更高的进程执行完了,内核要调度回这个进程执行。`SIGALRM`信号递达,执行处理函数`sig_alrm`之后再次进入内核。 6. 返回这个进程的主控制流程,`alarm(nsecs)`返回,调用`pause()`挂起等待。 7. 可是`SIGALRM`信号已经处理完了,还等待什么呢? 出现这个问题的根本原因是系统运行的时序(Timing)并不像我们写程序时所设想的那样。虽然`alarm(nsecs)`紧接着的下一行就是`pause()`,但是无法保证`pause()`一定会在调用`alarm(nsecs)`之后的`nsecs`秒之内被调用。由于异步事件在任何时候都有可能发生(这里的异步事件指出现更高优先级的进程),如果我们写程序时考虑不周密,就可能由于时序问题而导致错误,这叫做竞态条件(Race Condition)。 如何解决上述问题呢?读者可能会想到,在调用`pause`之前屏蔽`SIGALRM`信号使它不能提前递达就可以了。看看以下方法可行吗? 1. 屏蔽`SIGALRM`信号; 2. `alarm(nsecs);` 3. 解除对`SIGALRM`信号的屏蔽; 4. `pause();` 从解除信号屏蔽到调用`pause`之间存在间隙,`SIGALRM`仍有可能在这个间隙递达。要消除这个间隙,我们把解除屏蔽移到`pause`后面可以吗? 1. 屏蔽`SIGALRM`信号; 2. `alarm(nsecs);` 3. `pause();` 4. 解除对`SIGALRM`信号的屏蔽; 这样更不行了,还没有解除屏蔽就调用`pause`,`pause`根本不可能等到`SIGALRM`信号。要是“解除信号屏蔽”和“挂起等待信号”这两步能合并成一个原子操作就好了,这正是`sigsuspend`函数的功能。`sigsuspend`包含了`pause`的挂起等待功能,同时解决了竞态条件的问题,在对时序要求严格的场合下都应该调用`sigsuspend`而不是`pause`。 ``` #include int sigsuspend(const sigset_t *sigmask); ``` 和`pause`一样,`sigsuspend`没有成功返回值,只有执行了一个信号处理函数之后`sigsuspend`才返回,返回值为-1,`errno`设置为`EINTR`。 调用`sigsuspend`时,进程的信号屏蔽字由`sigmask`参数指定,可以通过指定`sigmask`来临时解除对某个信号的屏蔽,然后挂起等待,当`sigsuspend`返回时,进程的信号屏蔽字恢复为原来的值,如果原来对该信号是屏蔽的,从`sigsuspend`返回后仍然是屏蔽的。 以下用`sigsuspend`重新实现`mysleep`函数: ``` unsigned int mysleep(unsigned int nsecs) { struct sigaction newact, oldact; sigset_t newmask, oldmask, suspmask; unsigned int unslept; /* set our handler, save previous information */ newact.sa_handler = sig_alrm; sigemptyset(&newact.sa_mask); newact.sa_flags = 0; sigaction(SIGALRM, &newact, &oldact); /* block SIGALRM and save current signal mask */ sigemptyset(&newmask); sigaddset(&newmask, SIGALRM); sigprocmask(SIG_BLOCK, &newmask, &oldmask); alarm(nsecs); suspmask = oldmask; sigdelset(&suspmask, SIGALRM); /* make sure SIGALRM isn't blocked */ sigsuspend(&suspmask); /* wait for any signal to be caught */ /* some signal has been caught, SIGALRM is now blocked */ unslept = alarm(0); sigaction(SIGALRM, &oldact, NULL); /* reset previous action */ /* reset signal mask, which unblocks SIGALRM */ sigprocmask(SIG_SETMASK, &oldmask, NULL); return(unslept); } ``` 如果在调用`mysleep`函数时`SIGALRM`信号没有屏蔽: 1. 调用`sigprocmask(SIG_BLOCK, &newmask, &oldmask);`时屏蔽`SIGALRM`。 2. 调用`sigsuspend(&suspmask);`时解除对`SIGALRM`的屏蔽,然后挂起等待待。 3. `SIGALRM`递达后`suspend`返回,自动恢复原来的屏蔽字,也就是再次屏蔽`SIGALRM`。 4. 调用`sigprocmask(SIG_SETMASK, &oldmask, NULL);`时再次解除对`SIGALRM`的屏蔽。 ### 4.7. 关于SIGCHLD信号 进程一章讲过用`wait`和`waitpid`函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。 其实,子进程在终止时会给父进程发`SIGCHLD`信号,该信号的默认处理动作是忽略,父进程可以自定义`SIGCHLD`信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用`wait`清理子进程即可。 请编写一个程序完成以下功能:父进程`fork`出子进程,子进程调用`exit(2)`终止,父进程自定义`SIGCHLD`信号的处理函数,在其中调用`wait`获得子进程的退出状态并打印。 事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用`sigaction`将`SIGCHLD`的处理动作置为`SIG_IGN`,这样`fork`出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用`sigaction`函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。请编写程序验证这样做不会产生僵尸进程。
';

第 32 章 正则表达式

最后更新于:2022-04-01 22:02:12

# 第 32 章 正则表达式 **目录** + [1\. 引言](ch32s01.html) + [2\. 基本语法](ch32s02.html) + [3\. sed](ch32s03.html) + [4\. awk](ch32s04.html) + [5\. 练习:在C语言中使用正则表达式](ch32s05.html) ## 1. 引言 以前我们用`grep`在一个文件中找出包含某些字符串的行,比如在头文件中找出一个宏定义。其实`grep`还可以找出_符合某个模式(Pattern)的一类字符串_。例如找出所有符合`xxxxx@xxxx.xxx`模式的字符串(也就是email地址),要求x字符可以是字母、数字、下划线、小数点或减号,email地址的每一部分可以有一个或多个x字符,例如`abc.d@ef.com`、`1_2@987-6.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地址的正则表达式可以写成`[0-9]{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等等。“正则表达式”就像“变量”一样,它是一个广泛的概念,而不是某一种工具或编程语言的特性。 ## 2. 基本语法 我们知道C的变量和Shell脚本变量的定义和使用方法很不相同,表达能力也不相同,C的变量有各种类型,而Shell脚本变量都是字符串。同样道理,各种工具和编程语言所使用的正则表达式规范的语法并不相同,表达能力也各不相同,有的正则表达式规范引入很多扩展,能表达更复杂的模式,但各种正则表达式规范的基本概念都是相通的。本节介绍`egrep(1)`所使用的正则表达式,它大致上符合POSIX正则表达式规范,详见`regex(7)`(看这个man page对你的英文绝对是很好的锻炼)。希望读者仿照上一节的例子,一边学习语法,一边用`egrep`命令做实验。 **表 32.1. 字符类** | 字符 | 含义 | 举例 | | --- | --- | --- | | `.` | 匹配任意一个字符 | `abc.`可以匹配`abcd`、`abc9`等 | | `[]` | 匹配括号中的任意一个字符 | `[abc]d`可以匹配`ad`、`bd`或`cd` | | `-` | 在`[]`括号内表示字符范围 | `[0-9a-fA-F]`可以匹配一位十六进制数字 | | `^` | 位于`[]`括号内的开头,匹配除括号中的字符之外的任意一个字符 | `[^xy]`匹配除`xy`之外的任一字符,因此`[^xy]1`可以匹配`a1`、`b1`但不匹配`x1`、`y1` | | `[[:xxx:]]` | `grep`工具预定义的一些命名字符类 | `[[: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}` | 紧跟在它前面的单元应精确匹配`N次` | `[1-9][0-9]{2}`匹配从`100`到`999`的整数 | | `{N,}` | 紧跟在它前面的单元应匹配至少`N`次 | `[1-9][0-9]{2,}`匹配三位以上(含三位)的整数 | | `{,M}` | 紧跟在它前面的单元应匹配最多`M`次 | `[0-9]{,1}`相当于`[0-9]?` | | `{N,M}` | 紧跟在它前面的单元应匹配至少`N`次,最多`M`次 | `[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-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` | | `$` | 匹配行末的位置 | `;$`匹配位于一行结尾的`;`号,`^$`匹配空行 | | `\<` | 匹配单词开头的位置 | `\<th`匹配`... this`,但不匹配`ethernet`、`tenth` | | `\>` | 匹配单词结尾的位置 | `p\>`匹配`leap ...`,但不匹配`parent`、`sleepy` | | `\b` | 匹配单词开头或结尾的位置 | `\bat\b`匹配`... at ...`,但不匹配`cat`、`atexit`、`batch` | | `\B` | 匹配非单词开头和结尾的位置 | `\Bat\B`匹配`battery`,但不匹配`... attend`、`hat ...` | 位置限定符可以帮助`grep`更准确地查找,例如上一节我们用`[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{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规范来写正则表达式。 ## 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/s/pattern1/pattern2/` | 查找符合`pattern`的行,将该行第一个匹配`pattern1`的字符串替换为`pattern2` | | `/pattern/s/pattern1/pattern2/g` | 查找符合`pattern`的行,将该行所有匹配`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-bc- 456 ``` `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)[[39](#ftn.id2880730)]。比如`sed`在处理第一行时,`<.*>`匹配的并不是`<html>`或`<head>`这样的标签,而是 ``` Hello World ``` 这样一整行,因为这一行开头是`<`,中间是若干个任意字符,末尾是`>`。那么这条命令怎么改才对呢?留给读者思考。 * * * [[39](#id2880730)] 有些正则表达式规范支持Non-greedy的数量限定符,匹配尽可能短的字符串,例如在Python中`*?`和`*`一样表示0个或任意多个,但前者是Non-greedy的。 ## 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`控制结构,此处从略。 ## 5. 练习:在C语言中使用正则表达式 POSIX规定了正则表达式的C语言库函数,详见`regex(3)`。我们已经学习了很多C语言库函数的用法,读者应该具备自己看懂man手册的能力了。本章介绍了正则表达式在`grep`、`sed`、`awk`中的用法,学习要能够举一反三,请读者根据`regex(3)`自己总结正则表达式在C语言中的用法,写一些简单的程序,例如验证用户输入的IP地址或email地址格式是否正确。
';

第 31 章 Shell脚本

最后更新于:2022-04-01 22:02:09

# 第 31 章 Shell脚本 **目录** + [1\. Shell的历史](ch31s01.html) + [2\. Shell如何执行命令](ch31s02.html) + [2.1\. 执行交互式命令](ch31s02.html#id2872017) + [2.2\. 执行脚本](ch31s02.html#id2872211) + [3\. Shell的基本语法](ch31s03.html) + [3.1\. 变量](ch31s03.html#id2872666) + [3.2\. 文件名代换(Globbing):* ? []](ch31s03.html#id2872839) + [3.3\. 命令代换:`或 $()](ch31s03.html#id2872936) + [3.4\. 算术代换:$(())](ch31s03.html#id2872971) + [3.5\. 转义字符\](ch31s03.html#id2873001) + [3.6\. 单引号](ch31s03.html#id2873083) + [3.7\. 双引号](ch31s03.html#id2873112) + [4\. bash启动脚本](ch31s04.html) + [4.1\. 作为交互登录Shell启动,或者使用--login参数启动](ch31s04.html#id2873231) + [4.2\. 以交互非登录Shell启动](ch31s04.html#id2873387) + [4.3\. 非交互启动](ch31s04.html#id2873571) + [4.4\. 以sh命令启动](ch31s04.html#id2873616) + [5\. Shell脚本语法](ch31s05.html) + [5.1\. 条件测试:test [](ch31s05.html#id2873722) + [5.2\. if/then/elif/else/fi](ch31s05.html#id2874121) + [5.3\. case/esac](ch31s05.html#id2874366) + [5.4\. for/do/done](ch31s05.html#id2874526) + [5.5\. while/do/done](ch31s05.html#id2874637) + [5.6\. 位置参数和特殊变量](ch31s05.html#id2874685) + [5.7\. 函数](ch31s05.html#id2874943) + [6\. Shell脚本的调试方法](ch31s06.html) ## 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`的符号链接[[38](#ftn.id2871814)]。虽然如此,`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`。 * * * [[38](#id2871814)] 最新的发行版有一些变化,例如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 ``` ## 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”](ch30s05.html#process.implementshell)时也许有的读者已经试过了,在自己实现的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脚本的执行过程** ![Shell脚本的执行过程](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d9a31d6.png) 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 ``` ## 3. Shell的基本语法 ### 3.1. 变量 按照惯例,Shell变量由全大写字母加下划线组成,有两种类型的Shell变量: 环境变量 在[第 2 节 “环境变量”](ch30s02.html#process.environ)中讲过,环境变量可以从父进程传给子进程,因此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 "\\" \ ``` ## 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`开头,都属于这种方式。 ## 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 OP ARG2 ]` | `ARG1`和`ARG2`应该是整数或者取值为整数的变量,`OP`是`-eq`(等于)`-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 non-privileged 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|stop-htcacheclean}" 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 Parameter),相当于C语言`main`函数的`argv[1]`、`argv[2]`... | | `$#` | 相当于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表示假。 ## 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`参数,这样可以只对脚本中的某一段进行跟踪调试。
';

第 30 章 进程

最后更新于:2022-04-01 22:02:07

# 第 30 章 进程 **目录** + [1\. 引言](ch30s01.html) + [2\. 环境变量](ch30s02.html) + [3\. 进程控制](ch30s03.html) + [3.1\. fork函数](ch30s03.html#id2866212) + [3.2\. exec函数](ch30s03.html#id2866732) + [3.3\. wait和waitpid函数](ch30s03.html#id2867242) + [4\. 进程间通信](ch30s04.html) + [4.1\. 管道](ch30s04.html#id2867812) + [4.2\. 其它IPC机制](ch30s04.html#id2868153) + [5\. 练习:实现简单的Shell](ch30s05.html) ## 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** ![fork/exec](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d91a74f.png) 在[第 3 节 “open/close”](ch28s03.html#io.open)中我们做过一个实验:用`umask`命令设置Shell进程的`umask`掩码,然后运行程序`a.out`,结果`a.out`进程的`umask`掩码也和Shell进程一样。现在可以解释了,因为`a.out`进程是Shell进程的子进程,子进程的PCB是根据父进程复制而来的,所以其中的`umask`掩码也和父进程一样。同样道理,子进程的当前工作目录也和父进程一样,所以我们可以用`cd`命令改变Shell进程的当前目录,然后用`ls`命令列出那个目录下的文件,`ls`进程其实是在列自己的当前目录,而不是Shell进程的当前目录,只不过`ls`进程的当前目录正好和Shell进程相同。有一个例外,子进程PCB中的进程id和父进程是不同的。 ## 2. 环境变量 先前讲过,`exec`系统调用执行新程序时会把命令行参数和环境变量表传递给`main`函数,它们在整个进程地址空间中的位置如下图所示。 **图 30.2. 进程地址空间** ![进程地址空间](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d9298f1.png) 和命令行参数`argv`类似,环境变量表也是一组字符串,如下图所示。 **图 30.3. 环境变量** ![环境变量](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d942ee1.png) `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`的值没变。父进程在创建子进程时会复制一份环境变量给子进程,但此后二者的环境变量互不影响。 ## 3. 进程控制 ### 3.1. fork函数 ``` #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** ![fork](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d9558e5.png) 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函数”](ch30s03.html#process.wait)会讲到这种等待是怎么实现的),当父进程终止时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 $ 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/akaedu/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函数族** ![exec函数族](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d968036.png) 一个完整的例子: ``` #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 PGRP SESS TPGID COMMAND 6614 6608 6614 6614 7199 bash 7199 6614 7199 6614 7199 ps ``` 由于`exec`函数只有错误返回值,只要返回了一定是出错了,所以不需要判断它的返回值,直接在后面调用`perror`即可。注意在调用`execlp`时传了两个`"ps"`参数,第一个`"ps"`是程序名,`execlp`函数要在`PATH`环境变量中找到这个程序并执行它,而第二个`"ps"`是第一个命令行参数,`execlp`函数并不关心它的值,只是简单地把它传给`ps`程序,`ps`程序可以通过`main`函数的`argv[0]`取到这个参数。 调用`exec`后,原来打开的文件描述符仍然是打开的[[37](#ftn.id2867112)]。利用这一点可以实现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 VSZ RSS TTY STAT START TIME COMMAND akaedu 6016 0.0 0.3 5724 3140 pts/0 Ss 08:41 0:00 bash akaedu 6130 97.2 0.0 1536 284 pts/0 R 08:44 14:33 ./a.out akaedu 6131 0.0 0.0 0 0 pts/0 Z 08:44 0:00 [a.out] akaedu 6163 0.0 0.0 2620 1000 pts/0 R+ 08:59 0:00 ps u ``` 在`./a.out`命令后面加个`&`表示后台运行,Shell不等待这个进程终止就立刻打印提示符并等待用户输命令。现在Shell是位于前台的,用户在终端的输入会被Shell读取,后台进程是读不到终端输入的。第二条命令`ps u`是在前台运行的,在此期间Shell进程和`./a.out`进程都在后台运行,等到`ps u`命令结束时Shell进程又重新回到前台。在[第 33 章 _信号_](ch33.html#signal)和[第 34 章 _终端、作业控制与守护进程_](ch34.html#jobs)将会进一步解释前台(Foreground)和后台(Backgroud)的概念。 父进程的pid是6130,子进程是僵尸进程,pid是6131,`ps`命令显示僵尸进程的状态为`Z`,在命令行一栏还显示`<defunct>`。 如果一个父进程终止,而它的子进程还存在(这些子进程或者仍在运行,或者已经是僵尸进程了),则这些子进程的父进程改为`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”](ch30s03.html#process.waitpid "例 30.6. waitpid")的代码和实验条件,使它产生“Child terminated abnormally”的输出。 * * * [[37](#id2867112)] 事实上,在每个文件描述符中有一个close-on-exec标志,如果该标志为1,则调用`exec`时关闭这个文件描述符。该标志默认为0,可以用`fcntl`函数将它置1,本书不讨论该标志为1的情况。 ## 4. 进程间通信 每个进程各自有不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进程之间要交换数据必须通过内核,在内核中开辟一块缓冲区,进程1把数据从用户空间拷到内核缓冲区,进程2再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信(IPC,InterProcess Communication)。如下图所示。 **图 30.6. 进程间通信** ![进程间通信](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d9773ab.png) ### 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. 管道** ![管道](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d9853b3.png) 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 章 _信号_](ch33.html#signal)会讲到怎样使`SIGPIPE`信号不终止进程。 4. 如果有指向管道读端的文件描述符没关闭(管道读端的引用计数大于0),而持有管道读端的进程也没有从管道中读数据,这时有进程向管道写端写数据,那么在管道被写满时再次`write`会阻塞,直到管道中有空位置了才写入数据并返回。 管道的这四种特殊情况具有普遍意义。在[第 37 章 _socket编程_](ch37.html#socket)要讲的TCP socket也具有管道的这些特性。 #### 习题 1、在[例 30.7 “管道”](ch30s04.html#process.pipe)中,父进程只用到写端,因而把读端关闭,子进程只用到读端,因而把写端关闭,然后互相通信,不使用的读端或写端必须关闭,请读者想一想如果不关闭会有什么问题。 2、请读者修改[例 30.7 “管道”](ch30s04.html#process.pipe)的代码和实验条件,验证我上面所说的四种特殊情况。 ### 4.2. 其它IPC机制 进程间通信必须通过内核提供的通道,而且必须有一种办法在进程中标识内核提供的某个通道,上一节讲的管道是用打开的文件描述符来标识的。如果要互相通信的几个进程没有从公共祖先那里继承文件描述符,它们怎么通信呢?内核提供一条通道不成问题,问题是如何标识这条通道才能使各进程都可以访问它?文件系统中的路径名是全局的,各进程都可以访问,因此可以用文件系统中的路径名来标识一个IPC通道。 FIFO和UNIX Domain Socket这两种IPC机制都是利用文件系统中的特殊文件来标识的。可以用`mkfifo`命令创建一个FIFO文件: ``` $ mkfifo hello $ ls -l hello prw-r--r-- 1 akaedu akaedu 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 root 0 2008-10-30 00:24 acpid.socket ... srw-rw-rw- 1 root root 0 2008-10-30 00:25 gdm_socket ... srw-rw-rw- 1 root root 0 2008-10-30 00:24 sdp ... srwxr-xr-x 1 root root 0 2008-10-30 00:42 synaptic.socket ``` 文件类型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机制 ## 5. 练习:实现简单的Shell 用讲过的各种C函数实现一个简单的交互式Shell,要求: 1、给出提示符,让用户输入一行命令,识别程序名和参数并调用适当的`exec`函数执行程序,待执行完成后再次给出提示符。 2、识别和处理以下符号: * 简单的标准输入输出重定向(<和>):仿照[例 30.5 “wrapper”](ch30s03.html#process.wrapper),先`dup2`然后`exec`。 * 管道(|):Shell进程先调用`pipe`创建一对管道描述符,然后`fork`出两个子进程,一个子进程关闭读端,调用`dup2`把写端赋给标准输出,另一个子进程关闭写端,调用`dup2`把读端赋给标准输入,两个子进程分别调用`exec`执行程序,而Shell进程把管道的两端都关闭,调用`wait`等待两个子进程终止。 你的程序应该可以处理以下命令: ○ls△-l△-R○>○file1○ ○cat○<○file1○|○wc△-c○>○file1○ ○表示零个或多个空格,△表示一个或多个空格
';

第 29 章 文件系统

最后更新于:2022-04-01 22:02:05

# 第 29 章 文件系统 **目录** + [1\. 引言](ch29s01.html) + [2\. ext2文件系统](ch29s02.html) + [2.1\. 总体存储布局](ch29s02.html#id2857323) + [2.2\. 实例剖析](ch29s02.html#id2858019) + [2.3\. 数据块寻址](ch29s02.html#id2859212) + [2.4\. 文件和目录操作的系统函数](ch29s02.html#id2859394) + [3\. VFS](ch29s03.html) + [3.1\. 内核数据结构](ch29s03.html#id2860264) + [3.2\. dup和dup2函数](ch29s03.html#id2860911) ## 1. 引言 本章主要解答以下问题: 1. 文件系统在内核中是如何实现的?如何呈现给用户一个树状的目录结构?如何处理用户的文件和目录操作请求? 2. 磁盘是一种顺序的存储介质,一个树状的目录结构如何扯成一条线存到磁盘上?怎样设计文件系统的存储格式使访问磁盘的效率最高?各种文件和目录操作在磁盘上的实际效果是什么? **图 29.1. 文件系统的表示和存储** ![文件系统的表示和存储](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d808045.png) 我们首先介绍一种文件系统的存储格式-早期Linux广泛使用的ext2文件系统。现在Linux最常用的ext3文件系统也是与ext2兼容的,基本格式是一致的,只是多了一些扩展。然后再介绍文件系统在内核中是如何实现的。 ## 2. ext2文件系统 ### 2.1. 总体存储布局 我们知道,一个磁盘可以划分成多个分区,每个分区必须先用格式化工具(例如某种`mkfs`命令)格式化成某种格式的文件系统,然后才能存储文件,格式化的过程会在磁盘上写一些管理存储布局的信息。下图是一个磁盘分区格式化成ext2文件系统后的存储布局。 **图 29.2. ext2文件系统的总体存储布局** ![ext2文件系统的总体存储布局](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d81781d.png) 文件系统中存储的最小单位是块(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 0 2008-10-25 11:39 xconsole crw-rw-rw- 1 root root 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. 超级块** ![超级块](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d829bbe.png) 超级块中从0004d0到末尾的204个字节是填充字节,保留未用,上图未画出。注意,ext2文件系统中各字段都是按小端存储的,如果把字节在文件中的位置看作地址,那么靠近文件开头的是低地址,存低字节。各字段的位置、长度和含义详见[[ULK]](bi01.html#bibli.ulk "Understanding the Linux Kernel")。 从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. 块组描述符** ![块组描述符](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d86a793.png) 整个文件系统是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** ![根目录的inode](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d87eb5e.png) 上图中的`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. 根目录的数据块** ![根目录的数据块](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d89b676.png) 目录的数据块由许多不定长的记录组成,每条记录描述该目录下的一个文件,在上图中用框表示。第一条记录描述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. 数据块的寻址** ![数据块的寻址](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d8b8a00.png) 从上图可以看出,索引项`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 “根目录的数据块”](ch29s02.html#fs.datablock)基本一致。这里的文件名`d_name`被库函数处理过,已经在结尾加了'\0',而[图 29.6 “根目录的数据块”](ch29s02.html#fs.datablock)中的文件名字段不保证是以'\0'结尾的,需要根据前面的文件名长度字段确定文件名到哪里结束。 下面这个例子出自[[K&R]](bi01.html#bibli.kr "The C Programming Language"),作用是递归地打印出一个目录下的所有子目录和文件,类似`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`健壮,它有可能死循环,思考一下什么情况会导致死循环。 ## 3. VFS Linux支持各种各样的文件系统格式,如ext2、ext3、reiserfs、FAT、NTFS、iso9660等等,不同的磁盘分区、光盘或其它存储设备都有不同的文件系统格式,然而这些文件系统都可以`mount`到某个目录下,使我们看到一个统一的目录树,各种文件系统上的目录和文件我们用`ls`命令看起来是一样的,读写操作用起来也都是一样的,这是怎么做到的呢?Linux内核在各种不同的文件系统格式之上做了一个抽象层,使得文件、目录、读写访问等概念成为抽象层的概念,因此各种文件系统看起来用起来都一样,这个抽象层称为虚拟文件系统(VFS,Virtual Filesystem)。上一节我们介绍了一种典型的文件系统在磁盘上的存储布局,这一节我们介绍运行时文件系统在内核中的表示。 ### 3.1. 内核数据结构 Linux内核的VFS子系统可以图示如下: **图 29.8. VFS** ![VFS](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d8d4669.png) 在[第 28 章 _文件与I/O_](ch28.html#io)中讲过,每个进程在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示例程序** ![dup/dup2示例程序](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d8f3dc2.png) 重点解释两个地方: * 第3幅图,要执行`dup2(fd, 1);`,文件描述符1原本指向`tty`,现在要指向新的文件`somefile`,就把原来的关闭了,但是`tty`这个文件原本有两个引用计数,还有文件描述符`save_fd`也指向它,所以只是将引用计数减1,并不真的关闭文件。 * 第5幅图,要执行`dup2(save_fd, 1);`,文件描述符1原本指向`somefile`,现在要指向新的文件`tty`,就把原来的关闭了,`somefile`原本只有一个引用计数,所以这次减到0,是真的关闭了。
';

第 28 章 文件与I/O

最后更新于:2022-04-01 22:02:02

# 第 28 章 文件与I/O **目录** + [1\. 汇编程序的Hello world](ch28s01.html) + [2\. C标准I/O库函数与Unbuffered I/O函数](ch28s02.html) + [3\. open/close](ch28s03.html) + [4\. read/write](ch28s04.html) + [5\. lseek](ch28s05.html) + [6\. fcntl](ch28s06.html) + [7\. ioctl](ch28s07.html) + [8\. mmap](ch28s08.html) 从本章开始学习各种Linux系统函数,这些函数的用法必须结合Linux内核的工作原理来理解,因为系统函数正是内核提供给应用程序的接口,而要理解内核的工作原理,必须熟练掌握C语言,因为内核也是用C语言写的,我们在描述内核工作原理时必然要用“指针”、“结构体”、“链表”这些名词来组织语言,就像只有掌握了英语才能看懂英文书一样,只有学好了C语言才能看懂我描述的内核工作原理。读者看到这里应该已经熟练掌握了C语言了,所以应该有一个很好的起点了。我们在介绍C标准库时并不试图把所有库函数讲一遍,而是通过介绍一部分常用函数让读者把握库函数的基本用法,在掌握了方法之后,书上没讲的库函数读者应该自己查Man Page学会使用。同样,本书的第三部分也并不试图把所有的系统函数讲一遍,而是通过介绍一部分系统函数让读者理解操作系统各部分的工作原理,在有了这个基础之后就应该能够看懂Man Page学习其它系统函数的用法。 读者可以结合[[APUE2e]](bi01.html#bibli.apue "Advanced Programming in the UNIX Environment")学习本书的第三部分,该书在讲解系统函数方面更加全面,但对于内核工作原理涉及得不够深入,而且假定读者具有一定的操作系统基础知识,所以并不适合初学者。该书还有一点非常不适合初学者,作者不辞劳苦,在N多种UNIX系统上做了实验,分析了它们的内核代码,把每个系统函数在各种UNIX系统上的不兼容特性总结得非常详细,很多开发者需要编写可移植的应用程序,一定爱死他了,但初学者看了大段大段的这种描述(某某函数在4.2BSD上怎么样,到4.4BSD又改成怎么样了,在SVR4上怎么样,到Solaris又改成怎么样了,现在POSIX标准是怎么统一的,还有哪些系统没有完全遵守POSIX标准)只会一头雾水,不看倒还明白,越看越不明白了。也正因为该书要兼顾各种UNIX系统,所以没法深入讲解内核的工作原理,因为每种UNIX系统的内核都不一样。而本书的侧重点则不同,只讲Linux平台的特性,只讲Linux内核的工作原理,涉及体系结构时只讲x86平台,对于初学者来说,绑定到一个明确的平台上学习就不会觉得太抽象了。当然本书的代码也会尽量兼顾可移植性,避免依赖于Linux平台特有的一些特性。 ## 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 # section declaration msg: .ascii "Hello, world!\n" # our dear string len = . - msg # length of our dear string .text # section declaration # 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 # third argument: message length movl $msg,%ecx # second argument: pointer to message to write movl $1,%ebx # first argument: file handle (stdout) movl $4,%eax # system call number (sys_write) int $0x80 # call kernel # and exit movl $0,%ebx # first argument: exit code movl $1,%eax # system call number (sys_exit) int $0x80 # 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。 ## 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. 库函数与系统调用的层次关系** ![库函数与系统调用的层次关系](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d7b1f48.png) `open`、`read`、`write`、`close`等系统函数称为无缓冲I/O(Unbuffered I/O)函数,因为它们位于C标准库的I/O缓冲区的底层[[36](#ftn.id2850829)]。用户程序在读写文件时既可以调用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/](http://www.unix.org/)),XSI兼容的系统可以从该组织获得授权使用UNIX这个商标。 现在该说说文件描述符了。每个进程在Linux内核中都有一个`task_struct`结构体来维护进程相关的信息,称为进程描述符(Process Descriptor),而在操作系统理论中称为进程控制块(PCB,Process Control Block)。`task_struct`中有一个指针指向`files_struct`结构体,称为文件描述符表,其中每个表项包含一个指向已打开的文件的指针,如下图所示。 **图 28.2. 文件描述符表** ![文件描述符表](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d7c41eb.png) 至于已打开的文件在内核中用什么结构体表示,我们将在下一章详细介绍,目前我们在画图时用一个圈表示。用户程序不能直接访问内核中的文件描述符表,而只能使用文件描述符表的索引(即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 ``` * * * [[36](#id2850829)] 事实上Unbuffered I/O这个名词是有些误导的,虽然`write`系统调用位于C标准库I/O缓冲区的底层,但在`write`的底层也可以分配一个内核I/O缓冲区,所以`write`也不一定是直接写到文件的,也可能写到内核I/O缓冲区中,至于究竟写到了文件中还是内核缓冲区中对于进程来说是没有差别的,如果进程A和进程B打开同一文件,进程A写到内核I/O缓冲区中的数据从进程B也能读到,而C标准库的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 节 “引言”](ch30s01.html#process.intro)解释。 `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`用于写操作,如果该文件已存在则报错退出,如果该文件不存在则创建它 ## 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 “阻塞读终端”](ch28s04.html#io.blockread)一样,读标准输入是阻塞的。我们可以重新打开一遍设备文件`/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; } ``` ## 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。 ## 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 “非阻塞读终端”](ch28s04.html#io.nonblockread)同样的功能。 **例 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. 重定向之后的文件描述符表** ![重定向之后的文件描述符表](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d7d4c2f.png) `/dev/null`设备文件只有一个作用,往它里面写任何数据都被直接丢弃。因此保证了该命令执行时屏幕上没有任何输出,既不打印正常信息也不打印错误信息,让命令安静地执行,这种写法在Shell脚本中很常见。注意,文件描述符数字写在重定向符号右边需要加&号,否则就被解释成文件名了,2>&1其中的>左右两边都不能有空格。 除了`F_GETFL`和`F_SETFL`命令之外,`fcntl`还有很多命令做其它操作,例如设置文件记录锁等。可以通过`fcntl`设置的都是当前进程如何访问设备或文件的访问控制属性,例如读、写、追加、非阻塞、加锁等,但并不设置文件或设备本身的属性,例如文件的读写权限、串口波特率等。下一节要介绍的`ioctl`函数用于设置某些设备本身的属性,例如串口波特率、终端窗口大小,注意区分这两个函数的作用。 ## 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; } ``` 在图形界面的终端里多次改变终端窗口的大小并运行该程序,观察结果。 ## 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函数** ![mmap函数](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d7e6a99.png) 如果`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`。
';

部分 III. Linux系统编程

最后更新于:2022-04-01 22:02:00

# 部分 III. Linux系统编程 **目录** + [28\. 文件与I/O](ch28.html) + [1\. 汇编程序的Hello world](ch28s01.html) + [2\. C标准I/O库函数与Unbuffered I/O函数](ch28s02.html) + [3\. open/close](ch28s03.html) + [4\. read/write](ch28s04.html) + [5\. lseek](ch28s05.html) + [6\. fcntl](ch28s06.html) + [7\. ioctl](ch28s07.html) + [8\. mmap](ch28s08.html) + [29\. 文件系统](ch29.html) + [1\. 引言](ch29s01.html) + [2\. ext2文件系统](ch29s02.html) + [2.1\. 总体存储布局](ch29s02.html#id2857323) + [2.2\. 实例剖析](ch29s02.html#id2858019) + [2.3\. 数据块寻址](ch29s02.html#id2859212) + [2.4\. 文件和目录操作的系统函数](ch29s02.html#id2859394) + [3\. VFS](ch29s03.html) + [3.1\. 内核数据结构](ch29s03.html#id2860264) + [3.2\. dup和dup2函数](ch29s03.html#id2860911) + [30\. 进程](ch30.html) + [1\. 引言](ch30s01.html) + [2\. 环境变量](ch30s02.html) + [3\. 进程控制](ch30s03.html) + [3.1\. fork函数](ch30s03.html#id2866212) + [3.2\. exec函数](ch30s03.html#id2866732) + [3.3\. wait和waitpid函数](ch30s03.html#id2867242) + [4\. 进程间通信](ch30s04.html) + [4.1\. 管道](ch30s04.html#id2867812) + [4.2\. 其它IPC机制](ch30s04.html#id2868153) + [5\. 练习:实现简单的Shell](ch30s05.html) + [31\. Shell脚本](ch31.html) + [1\. Shell的历史](ch31s01.html) + [2\. Shell如何执行命令](ch31s02.html) + [2.1\. 执行交互式命令](ch31s02.html#id2872017) + [2.2\. 执行脚本](ch31s02.html#id2872211) + [3\. Shell的基本语法](ch31s03.html) + [3.1\. 变量](ch31s03.html#id2872666) + [3.2\. 文件名代换(Globbing):* ? []](ch31s03.html#id2872839) + [3.3\. 命令代换:`或 $()](ch31s03.html#id2872936) + [3.4\. 算术代换:$(())](ch31s03.html#id2872971) + [3.5\. 转义字符\](ch31s03.html#id2873001) + [3.6\. 单引号](ch31s03.html#id2873083) + [3.7\. 双引号](ch31s03.html#id2873112) + [4\. bash启动脚本](ch31s04.html) + [4.1\. 作为交互登录Shell启动,或者使用--login参数启动](ch31s04.html#id2873231) + [4.2\. 以交互非登录Shell启动](ch31s04.html#id2873387) + [4.3\. 非交互启动](ch31s04.html#id2873571) + [4.4\. 以sh命令启动](ch31s04.html#id2873616) + [5\. Shell脚本语法](ch31s05.html) + [5.1\. 条件测试:test [](ch31s05.html#id2873722) + [5.2\. if/then/elif/else/fi](ch31s05.html#id2874121) + [5.3\. case/esac](ch31s05.html#id2874366) + [5.4\. for/do/done](ch31s05.html#id2874526) + [5.5\. while/do/done](ch31s05.html#id2874637) + [5.6\. 位置参数和特殊变量](ch31s05.html#id2874685) + [5.7\. 函数](ch31s05.html#id2874943) + [6\. Shell脚本的调试方法](ch31s06.html) + [32\. 正则表达式](ch32.html) + [1\. 引言](ch32s01.html) + [2\. 基本语法](ch32s02.html) + [3\. sed](ch32s03.html) + [4\. awk](ch32s04.html) + [5\. 练习:在C语言中使用正则表达式](ch32s05.html) + [33\. 信号](ch33.html) + [1\. 信号的基本概念](ch33s01.html) + [2\. 产生信号](ch33s02.html) + [2.1\. 通过终端按键产生信号](ch33s02.html#id2884244) + [2.2\. 调用系统函数向进程发信号](ch33s02.html#id2884400) + [2.3\. 由软件条件产生信号](ch33s02.html#id2884567) + [3\. 阻塞信号](ch33s03.html) + [3.1\. 信号在内核中的表示](ch33s03.html#id2884694) + [3.2\. 信号集操作函数](ch33s03.html#id2884876) + [3.3\. sigprocmask](ch33s03.html#id2885022) + [3.4\. sigpending](ch33s03.html#id2885205) + [4\. 捕捉信号](ch33s04.html) + [4.1\. 内核如何实现信号的捕捉](ch33s04.html#id2885289) + [4.2\. sigaction](ch33s04.html#id2885439) + [4.3\. pause](ch33s04.html#id2885627) + [4.4\. 可重入函数](ch33s04.html#id2885983) + [4.5\. sig_atomic_t类型与volatile限定符](ch33s04.html#id2886197) + [4.6\. 竞态条件与sigsuspend函数](ch33s04.html#id2886686) + [4.7\. 关于SIGCHLD信号](ch33s04.html#id2887260) + [34\. 终端、作业控制与守护进程](ch34.html) + [1\. 终端](ch34s01.html) + [1.1\. 终端的基本概念](ch34s01.html#id2890359) + [1.2\. 终端登录过程](ch34s01.html#id2891132) + [1.3\. 网络登录过程](ch34s01.html#id2891618) + [2\. 作业控制](ch34s02.html) + [2.1\. Session与进程组](ch34s02.html#id2892071) + [2.2\. 与作业控制有关的信号](ch34s02.html#id2892541) + [3\. 守护进程](ch34s03.html) + [35\. 线程](ch35.html) + [1\. 线程的概念](ch35s01.html) + [2\. 线程控制](ch35s02.html) + [2.1\. 创建线程](ch35s02.html#id2895632) + [2.2\. 终止线程](ch35s02.html#id2896029) + [3\. 线程间同步](ch35s03.html) + [3.1\. mutex](ch35s03.html#id2896462) + [3.2\. Condition Variable](ch35s03.html#id2895424) + [3.3\. Semaphore](ch35s03.html#id2897332) + [3.4\. 其它线程间同步机制](ch35s03.html#id2897423) + [4\. 编程练习](ch35s04.html) + [36\. TCP/IP协议基础](ch36.html) + [1\. TCP/IP协议栈与数据包封装](ch36s01.html) + [2\. 以太网(RFC 894)帧格式](ch36s02.html) + [3\. ARP数据报格式](ch36s03.html) + [4\. IP数据报格式](ch36s04.html) + [5\. IP地址与路由](ch36s05.html) + [6\. UDP段格式](ch36s06.html) + [7\. TCP协议](ch36s07.html) + [7.1\. 段格式](ch36s07.html#id2900865) + [7.2\. 通讯时序](ch36s07.html#id2900917) + [7.3\. 流量控制](ch36s07.html#id2901189) + [37\. socket编程](ch37.html) + [1\. 预备知识](ch37s01.html) + [1.1\. 网络字节序](ch37s01.html#id2902826) + [1.2\. socket地址的数据类型及相关函数](ch37s01.html#id2902915) + [2\. 基于TCP协议的网络程序](ch37s02.html) + [2.1\. 最简单的TCP网络程序](ch37s02.html#id2902690) + [2.2\. 错误处理与读写控制](ch37s02.html#id2903656) + [2.3\. 把client改为交互式输入](ch37s02.html#id2903862) + [2.4\. 使用fork并发处理多个client的请求](ch37s02.html#id2903959) + [2.5\. setsockopt](ch37s02.html#id2904007) + [2.6\. 使用select](ch37s02.html#id2904122) + [3\. 基于UDP协议的网络程序](ch37s03.html) + [4\. UNIX Domain Socket IPC](ch37s04.html) + [5\. 练习:实现简单的Web服务器](ch37s05.html) + [5.1\. 基本HTTP协议](ch37s05.html#id2904532) + [5.2\. 执行CGI程序](ch37s05.html#id2904687)
';

第 27 章 本阶段总结

最后更新于:2022-04-01 22:01:58

# 第 27 章 本阶段总结 在这一阶段我们又学习了很多新的语法规则,首先读者应该回到[第 13 章 _本阶段总结_](ch13.html#summary1)把那些知识点重新总结一遍。然后我们总结一下各种开发调试工具的用法。 1、gcc `-c` 编译生成目标文件(Relocatable),详见[第 2 节 “`main`函数和启动例程”](ch19s02.html#asmc.main)。 `-Dmacro[=defn]` 定义一个宏,详见[第 3 节 “条件预处理指示”](ch21s03.html#prep.cond)。 `-E` 只做预处理而不编译,`cpp`命令也可以达到同样的效果,详见[第 2.1 节 “函数式宏定义”](ch21s02.html#prep.funcmacro)。 `-g` 在生成的目标文件中添加调试信息,所谓调试信息就是源代码和指令之间的对应关系,在`gdb`调试和`objdump`反汇编时要用到这些信息,详见[第 1 节 “单步执行和跟踪函数调用”](ch10s01.html#gdb.step)。 `-Idir` `dir`是头文件所在的目录,详见[第 2.2 节 “头文件”](ch20s02.html#link.header)。 `-Ldir` `dir`是库文件所在的目录,详见[第 3 节 “静态库”](ch20s03.html#link.staticlib)。 `-M`和`-MM` 输出“`.o`文件: `.c`文件 `.h`文件”这种形式的Makefile规则,`-MM`的输出不包括系统头文件,详见[第 4 节 “自动处理头文件的依赖关系”](ch22s04.html#make.header)。 `-o outfile` `outfile`输出文件的文件名,详见[第 2 节 “`main`函数和启动例程”](ch19s02.html#asmc.main)。 `-O?` 各种编译优化选项,详见[第 6 节 “volatile限定符”](ch19s06.html#asmc.volatile)。 `-print-search-dirs` 打印库文件的默认搜索路径,详见[第 3 节 “静态库”](ch20s03.html#link.staticlib)。 `-S` 编译生成汇编代码,详见[第 2 节 “`main`函数和启动例程”](ch19s02.html#asmc.main)。 `-v` 打印详细的编译链接过程,详见[第 2 节 “`main`函数和启动例程”](ch19s02.html#asmc.main)。 `-Wall` 打印所有的警告信息,详见[第 4 节 “第一个程序”](intro.helloworld.html "4. 第一个程序")。 `-Wl,options` `options`是传递给链接器的选项,详见[第 4 节 “共享库”](ch20s04.html#link.shared)。 2、gdb 1. 在[第 10 章 _gdb_](ch10.html#gdb)集中介绍了`gdb`的基本命令和调试方法。 2. 在[第 1 节 “函数调用”](ch19s01.html#asmc.funccall)提到了`gdb`的指令级调试和反汇编命令。 3. 如果一个程序由多个`.c`文件编译链接而成,用`gdb`调试时如何定位某个源文件中的某一行代码呢?在[第 1 节 “多目标文件的链接”](ch20s01.html#link.basic)有介绍。 4. 在[第 6 节 “指向指针的指针与指针数组”](ch23s06.html#pointer.parray)提到了用`gdb`调试时如何给程序提供命令行参数。 3、其它开发调试工具 1. `as`,汇编器,用法详见[例 18.1 “最简单的汇编程序”](ch18s01.html#asm.simpleasm)。 2. `ld`,链接器,用法详见[例 18.1 “最简单的汇编程序”](ch18s01.html#asm.simpleasm),用`--verbose`选项可以显示默认链接脚本,详见[第 1 节 “多目标文件的链接”](ch20s01.html#link.basic)。 3. `readelf`,读ELF文件信息,用法详见[第 5.1 节 “目标文件”](ch18s05.html#asm.relocatable)。 4. `objdump`,显示目标文件中的信息,本书主要用它做反汇编,用法详见[第 5.1 节 “目标文件”](ch18s05.html#asm.relocatable)。 5. `hexdump`,以十六进制或ASCII码显示一个文件,用法详见[第 5.1 节 “目标文件”](ch18s05.html#asm.relocatable)。 6. `ar`,把目标文件打包成静态库,用法详见[第 3 节 “静态库”](ch20s03.html#link.staticlib)。 7. `ranlib`,给`ar`打包的静态库建索引,用法详见[第 3 节 “静态库”](ch20s03.html#link.staticlib)。 8. `nm`,查看符号表,用法详见[第 2 节 “`main`函数和启动例程”](ch19s02.html#asmc.main)。
';

第 26 章 链表、二叉树和哈希表

最后更新于:2022-04-01 22:01:56

# 第 26 章 链表、二叉树和哈希表 **目录** + [1\. 链表](ch26s01.html) + [1.1\. 单链表](ch26s01.html#id2844144) + [1.2\. 双向链表](ch26s01.html#id2845376) + [1.3\. 静态链表](ch26s01.html#id2845707) + [1.4\. 本节综合练习](ch26s01.html#id2845773) + [2\. 二叉树](ch26s02.html) + [2.1\. 二叉树的基本概念](ch26s02.html#id2845875) + [2.2\. 排序二叉树](ch26s02.html#id2846120) + [3\. 哈希表](ch26s03.html) ## 1. 链表 ### 1.1. 单链表 [图 23.6 “链表”](ch23s09.html#pointer.linkedlist)所示的链表即单链表(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); void 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; } void delete(link p) { link pre; if (p == head) { head = p->next; return; } for (pre = head; pre; pre = pre->next) if (pre->next == p) { pre->next = p->next; return; } } 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_node(q); } } void push(link p) { insert(p); } link pop(void) { if (head == NULL) return NULL; else { link p = head; head = head->next; return p; } } ``` ``` /* 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. 链表的插入操作** ![链表的插入操作](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d6bf684.png) 正如上图所示,`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`释放它的存储空间。 ``` void delete(link p) { link pre; if (p == head) { head = p->next; return; } for (pre = head; pre; pre = pre->next) if (pre->next == p) { pre->next = p->next; return; } } ``` **图 26.2. 链表的删除操作** ![链表的删除操作](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d6d02f9.png) 从上图可以看出,要摘除一个节点需要首先找到它的前趋然后才能做摘除操作,而在单链表中通过某个节点只能找到它的后继而不能找到它的前趋,所以删除操作要麻烦一些,需要从第一个节点开始依次查找要摘除的节点的前趋。`delete`操作也要处理一种特殊情况,如果要摘除的节点是链表的第一个节点,它是没有前趋的,这种情况要用特殊的代码处理,而不能和一般情况用同样的代码处理。这样很不爽,能不能把这种特殊情况转化为一般情况呢?可以把`delete`函数改成这样: ``` void delete(link p) { link *pnext; for (pnext = &head; *pnext; pnext = &(*pnext)->next) if (*pnext == p) { *pnext = p->next; return; } } ``` **图 26.3. 消除特殊情况的链表删除操作** ![消除特殊情况的链表删除操作](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d6e618f.png) 定义一个指向指针的指针`pnext`,在`for`循环中`pnext`遍历的是指向链表中各节点的指针域,这样就把`head`指针和各节点的`next`指针统一起来了,可以在一个循环中处理。 然后`main`函数调用`traverse`函数遍历整个链表,调用`destroy`函数销毁整个链表。请读者自己阅读这两个函数的代码。 如果限定每次只在链表的头部插入和删除元素,就形成一个LIFO的访问序列,所以在链表头部插入和删除元素的操作实现了堆栈的`push`和`pop`操作,`main`函数的最后几步把链表当成堆栈来操作,从打印的结果可以看到出栈的顺序和入栈是相反的。想一想,用链表实现的堆栈和[第 2 节 “堆栈”](ch12s02.html#stackqueue.stack)中用数组实现的堆栈相比有什么优点和缺点? #### 习题 1、修改`insert`函数实现插入排序的功能,链表中的数据按从小到大排列,每次插入数据都要在链表中找到合适的位置再插入。在[第 6 节 “折半查找”](ch11s06.html#sortsearch.binary)中我们看到,如果数组中的元素是有序排列的,可以用折半查找算法更快地找到某个元素,想一想如果链表中的节点是有序排列的,是否适用折半查找算法?为什么? 2、基于单链表实现队列的`enqueue`和`dequeue`操作。在链表的末尾再维护一个指针`tail`,在`tail`处`enqueue`,在`head`处`dequeue`。想一想能不能反过来,在`head`处`enqueue`而在`tail`处`dequeue`? 3、实现函数`void reverse(void);`将单链表反转。如下图所示。 **图 26.4. 单链表的反转** ![单链表的反转](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d703b28.png) ### 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; } void delete(link p) { if (p->prev) p->prev->next = p->next; else head = p->next; if (p->next) p->next->prev = p->prev; } ``` **图 26.5. 双向链表** ![双向链表](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d713808.png) 由于引入了`prev`指针,`insert`和`delete`函数中都有一些特殊情况需要用特殊的代码处理,不能和一般情况用同样的代码处理,这非常不爽,如果在表头和表尾各添加一个Sentinel节点(这两个节点只用于界定表头和表尾,不保存数据),就可以把这些特殊情况都转化为一般情况了。 **例 26.2. 带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); void 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; } void delete(link p) { p->prev->next = p->next; p->next->prev = p->prev; } 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_node(q); } } void enqueue(link p) { insert(p); } link dequeue(void) { if (tail->prev == head) return NULL; else { link p = tail->prev; delete(p); return p; } } ``` ``` /* 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.6. 带Sentinel的双向链表** ![带Sentinel的双向链表](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d7210a3.png) 这个例子也实现了队列的`enqueue`和`dequeue`操作,现在每个节点有了`prev`指针,可以反过来在`head`处`enqueue`而在`tail`处`dequeue`了。 现在结合[第 5 节 “环形队列”](ch12s05.html#stackqueue.circular)想一想,其实用链表实现环形队列是最自然的,以前基于数组实现环形队列,我们还需要“假想”它是首尾相接的,而如果基于链表实现环形队列,我们本来就可以用指针串成首尾相接的。把上面的程序改成环形链表(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`即可,相当于把`head`和`tail`合二为一了。 **图 26.7. 环形链表** ![环形链表](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d735e29.png) ### 1.3. 静态链表 回想一下我们在[例 12.4 “用广度优先搜索解迷宫问题”](ch12s04.html#stackqueue.bfs)中使用的数据结构,我把图重新画在下面。 **图 26.8. 广度优先搜索的队列数据结构** ![广度优先搜索的队列数据结构](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d2448f8.png) 这是一个静态分配的数组,每个数组元素都有`row`、`col`和`predecessor`三个成员,`predecessor`成员保存一个数组下标,指向数组中的另一个元素,这其实也是链表的一种形式,称为静态链表,例如上图中的第6、4、2、1、0个元素串成一条链表。 ### 1.4. 本节综合练习 1、Josephus是公元1世纪的著名历史学家,相传在一次战役中他和另外几个人被围困在山洞里,他们宁死不屈,决定站成一圈,每次数到三个人就杀一个,直到全部死光为止。Josephus和他的一个朋友不想死,于是串通好了站在适当的位置上,最后只剩下他们俩的时候这个游戏就停止了。如果一开始的人数为`N`,每次数到`M`个人就杀一个,那么要想不死应该站在什么位置呢?这个问题比较复杂,[[具体数学]](bi01.html#bibli.concrete "Concrete Mathematics")的1.3节研究了Josephus问题的解,有兴趣的读者可以参考。现在我们做个比较简单的练习,用链表模拟Josephus他们玩的这个游戏,`N`和`M`作为命令行参数传入,每个人的编号依次是1~N,打印每次被杀者的编号,打印最后一个幸存者的编号。 2、在[第 2.11 节 “本节综合练习”](ch25s02.html#stdlib.ioproblem)的习题1中规定了一种日志文件的格式,每行是一条记录,由行号、日期、时间三个字段组成,由于记录是按时间先后顺序写入的,可以看作所有记录是按日期排序的,对于日期相同的记录再按时间排序。现在要求从这样的一个日志文件中读出所有记录组成一个链表,在链表中首先按时间排序,对于时间相同的记录再按日期排序,最后写回文件中。比如原文件的内容是: ``` 1 2009-7-30 15:16:42 2 2009-7-30 15:16:43 3 2009-7-31 15:16:41 4 2009-7-31 15:16:42 5 2009-7-31 15:16:43 6 2009-7-31 15:16:44 ``` 重新排序输出的文件内容是: ``` 1 2009-7-31 15:16:41 2 2009-7-30 15:16:42 3 2009-7-31 15:16:42 4 2009-7-30 15:16:43 5 2009-7-31 15:16:43 6 2009-7-31 15:16:44 ``` ## 2. 二叉树 ### 2.1. 二叉树的基本概念 链表的每个节点可以有一个后继,而二叉树(Binary Tree)的每个节点可以有两个后继。比如这样定义二叉树的节点: ``` typedef struct node *link; struct node { unsigned char item; link l, r; }; ``` 这样的节点可以组织成下图所示的各种形态。 **图 26.9. 二叉树的定义和举例** ![二叉树的定义和举例](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d74fb67.png) 二叉树可以这样递归地定义: 1. 就像链表有头指针一样,每个二叉树都有一个根指针(上图中的`root`指针)指向它。根指针可以是`NULL`,表示空二叉树,或者 2. 根指针可以指向一个节点,这个节点除了有数据成员之外还有两个指针域,这两个指针域又分别是另外两个二叉树(左子树和右子树)的根指针。 上图举例示意了几种情况。 * 单节点的二叉树:左子树和右子树都是空二叉树。 * 只有左子树的二叉树:右子树是空二叉树。 * 只有右子树的二叉树:左子树是空二叉树。 * 一般的二叉树:左右子树都不为空。注意右侧由圈和线段组成的简化图示,以后我们都采用这种简化图示法,在圈中标上该节点数据成员的值。 链表的遍历方法是显而易见的:从前到后遍历即可。二叉树是一种树状结构,如何做到把所有节点都走一遍不重不漏呢?有以下几种方法: **图 26.10. 二叉树的遍历** ![二叉树的遍历](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d7690b0.png) 前序(Pre-order Traversal)、中序(In-order Traversal)、后序遍历(Post-order Traversal)和深度优先搜索的顺序类似,层序遍历(Level-order Traversal)和广度优先搜索的顺序类似。 前序和中序遍历的结果合在一起可以唯一确定二叉树的形态,也就是说根据遍历结果可以构造出二叉树。过程如下图所示: **图 26.11. 根据前序和中序遍历结果构造二叉树** ![根据前序和中序遍历结果构造二叉树](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d77c331.png) 想一想,根据中序和后序遍历结果能否构造二叉树?根据前序和后序遍历结果能否构造二叉树? **例 26.3. 二叉树** ``` /* binarytree.h */ #ifndef BINARYTREE_H #define BINARYTREE_H typedef struct node *link; struct node { unsigned char item; link l, r; }; link 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 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 = init(VLR+1, LVR, k); t->r = 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 = 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; } ``` #### 习题 1、本节描述了二叉树的递归定义,想一想单链表的递归定义应该怎么表述?请仿照本节的例子用递归实现单链表的各种操作函数: ``` link init(unsigned char elements[], int n); void pre_order(link t, void (*visit)(link)); void post_order(link t, void (*visit)(link)); int count(link t); void destroy(link t); ``` ### 2.2. 排序二叉树 排序二叉树(BST,Binary Search Tree)具有这样的性质:对于二叉树中的任意节点,如果它有左子树或右子树,则该节点的数据成员大于左子树所有节点的数据成员,且小于右子树所有节点的数据成员。排序二叉树的中序遍历结果是从小到大排列的,其实上一节的[图 26.10 “二叉树的遍历”](ch26s02.html#linkedlist.binarytraverse)就是排序二叉树。 **例 26.4. 排序二叉树** ``` /* bst.h */ #ifndef BST_H #define BST_H typedef struct node *link; struct node { unsigned char item; link l, r; }; link search(link t, unsigned char key); link insert(link t, unsigned char key); link delete(link t, unsigned char 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, unsigned char 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, unsigned char 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, unsigned char 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 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/](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 ``` ## 3. 哈希表 下图示意了哈希表(Hash Table)这种数据结构。 **图 26.12. 哈希表** ![哈希表](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d78f7df.png) 如上图所示,首先分配一个指针数组,数组的每个元素是一个链表的头指针,每个链表称为一个槽(Slot)。哪个数据应该放入哪个槽中由哈希函数决定,在这个例子中我们简单地选取哈希函数h(x) = x % 11,这样任意数据x都可以映射成0~10之间的一个数,就是槽的编号,将数据放入某个槽的操作就是链表的插入操作。 如果每个槽里至多只有一个数据,可以想像这种情况下`search`、`insert`和`delete`操作的时间复杂度都是O(1),但有时会有多个数据被哈希函数映射到同一个槽中,这称为碰撞(Collision),设计一个好的哈希函数可以把数据比较均匀地分布到各个槽中,尽量避免碰撞。如果能把n个数据比较均匀地分布到m个槽中,每个糟里约有n/m个数据,则`search`、`insert`和`delete`和操作的时间复杂度都是O(n/m),如果n和m的比是常数,则时间复杂度仍然是O(1)。一般来说,要处理的数据越多,构造哈希表时分配的槽也应该越多,所以n和m成正比这个假设是成立的。 请读者自己编写程序构造这样一个哈希表,并实现`search`、`insert`和`delete`操作。 如果用我们学过的各种数据结构来表示n个数据的集合,下表是`search`、`insert`和`delete`操作在平均情况下的时间复杂度比较。 **表 26.1. 各种数据结构的search、insert和delete操作在平均情况下的时间复杂度比较** | 数据结构 | search | insert | delete | | --- | --- | --- | --- | | 数组 | O(n),有序数组折半查找是O(lgn) | O(n) | O(n) | | 双向链表 | O(n) | O(1) | O(1) | | 排序二叉树 | O(lgn) | O(lgn) | O(lgn) | | 哈希表(n与槽数m成正比) | O(1) | O(1) | O(1) | ### 习题 1、统计一个文本文件中每个单词的出现次数,然后按出现次数排序并打印输出。单词由连续的英文字母组成,不区分大小写。 2、实现一个函数求两个数组的交集:`size_t intersect(const int a[], size_t nmema, const int b[], size_t nmemb, int c[], size_t nmemc);`。数组元素是32位`int`型的。数组`a`有`nmema`个元素且各不相同,数组`b`有`nmemb`个元素且各不相同。要求找出数组`a`和数组`b`的交集保存到数组`c`中,`nmemc`是数组`c`的最大长度,返回值表示交集中实际有多少个元素,如果交集中实际的元素数量超过了`nmemc`则返回`nmemc`个元素。数组`a`和数组`b`的元素数量可能会很大(比如上百万个),需要设计尽可能快的算法。
';

第 25 章 C标准库

最后更新于:2022-04-01 22:01:53

# 第 25 章 C标准库 **目录** + [1\. 字符串操作函数](ch25s01.html) + [1.1\. 初始化字符串](ch25s01.html#id2827594) + [1.2\. 取字符串的长度](ch25s01.html#id2827671) + [1.3\. 拷贝字符串](ch25s01.html#id2827741) + [1.4\. 连接字符串](ch25s01.html#id2828376) + [1.5\. 比较字符串](ch25s01.html#id2828656) + [1.6\. 搜索字符串](ch25s01.html#id2828881) + [1.7\. 分割字符串](ch25s01.html#id2829046) + [2\. 标准I/O库函数](ch25s02.html) + [2.1\. 文件的基本概念](ch25s02.html#id2829671) + [2.2\. fopen/fclose](ch25s02.html#id2829869) + [2.3\. stdin/stdout/stderr](ch25s02.html#id2830485) + [2.4\. errno与perror函数](ch25s02.html#id2830807) + [2.5\. 以字节为单位的I/O函数](ch25s02.html#id2831236) + [2.6\. 操作读写位置的函数](ch25s02.html#id2831814) + [2.7\. 以字符串为单位的I/O函数](ch25s02.html#id2832034) + [2.8\. 以记录为单位的I/O函数](ch25s02.html#id2832480) + [2.9\. 格式化I/O函数](ch25s02.html#id2832755) + [2.10\. C标准库的I/O缓冲区](ch25s02.html#id2834346) + [2.11\. 本节综合练习](ch25s02.html#id2834904) + [3\. 数值字符串转换函数](ch25s03.html) + [4\. 分配内存的函数](ch25s04.html) 在前面的各章中我们已经见过C标准库的一些用法,总结如下: * 我们最常用的是包含`stdio.h`,使用其中声明的`printf`函数,这个函数在`libc`中实现,程序在运行时要动态链接`libc`共享库。 * 在[第 1 节 “数学函数”](ch03s01.html#func.mathfunc)中用到了`math.h`中声明的`sin`和`log`函数,使用这些函数需要动态链接`libm`共享库。 * 在[第 2 节 “数组应用实例:统计随机数”](ch08s02.html#array.statistic)中用到了`stdlib.h`中声明的`rand`函数,还提到了这个头文件中定义的`RAND_MAX`常量,在[例 8.5 “剪刀石头布”](ch08s05.html#array.scissor)中用到了`stdlib.h`中声明的`srand`函数和`time.h`中声明的`time`函数。使用这些函数需要动态链接`libc`共享库。 * 在[第 2 节 “`main`函数和启动例程”](ch19s02.html#asmc.main)中用到了`stdlib.h`中声明的`exit`函数,使用这个函数需要动态链接`libc`共享库。 * 在[第 6 节 “折半查找”](ch11s06.html#sortsearch.binary)中用到了`assert.h`中定义的`assert`宏,在[第 4 节 “其它预处理特性”](ch21s04.html#prep.other)中我们看到了这个宏的一种实现,它的实现需要调用`stdio.h`和`stdlib.h`中声明的函数,所以使用这个宏也需要动态链接`libc`共享库。 * 在[第 2.4 节 “sizeof运算符与typedef类型声明”](ch16s02.html#op.sizeoftypedef)中提到了`size_t`类型在`stddef.h`中定义,在[第 1 节 “指针的基本概念”](ch23s01.html#pointer.intro)中提到了`NULL`指针也在`stddef.h`中定义。 * 在[第 1 节 “本章的预备知识”](ch24s01.html#interface.prereq)中介绍了`stdlib.h`中声明的`malloc`和`free`函数以及`string.h`中声明的`strcpy`和`strncpy`函数,使用这些函数需要动态链接`libc`共享库。 * 在[第 6 节 “可变参数”](ch24s06.html#interface.va)中介绍了`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 节 “共享库”](ch20s04.html#link.shared)讲过,通常`libc`共享库是`/lib/libc.so.6`,而我的系统启用了hwcap机制,`libc`共享库是`/lib/tls/i686/cmov/libc.so.6`。 本章介绍另外一些最基本和最常用的库函数(包括一些不属于C标准但在UNIX平台上很常用的函数),写这一章是为了介绍字符串操作和文件操作的基本概念,而不是为了写一本C标准库函数的参考手册,Man Page已经是一本很好的手册了,读者学完这一章之后在开发时应该查阅Man Page,而不是把我这一章当参考手册来翻,所以本章不会面面俱到介绍所有的库函数,对于本章讲到的函数有些也不会讲得很细,因为我假定读者经过上一章的学习再结合我讲过的基本概念已经能看懂相关的Man Page了。很多技术书的作者给自己的书太多定位,既想写成一本入门教程,又想写成一本参考手册,我觉得这样不好,读者过于依赖技术书就失去了看真正的手册的能力。 ## 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 节 “本章的预备知识”](ch24s01.html#interface.prereq)中介绍了`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(const double *x, const double *y, double *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(const double *restrict x, const double *restrict y, double *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]](bi01.html#bibli.restrict "The New C: It All Began with FORTRAN(http://www.ddj.com/cpp/184401313)")。 字符串的拷贝也可以用`strdup(3)`函数,这个函数不属于C标准库,是POSIX标准中定义的,POSIX标准定义了UNIX系统的各种接口,包含C标准库的所有函数和很多其它的系统函数,在[第 2 节 “C标准I/O库函数与Unbuffered I/O函数”](ch28s02.html#io.twoioflavors)将详细介绍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`的每个字符是否相同。想想这个算法最多需要做多少次比较?其实有比这个算法高效得多的算法,有兴趣的读者可以参考[[算法导论]](bi01.html#bibli.algorithm "Introduction to Algorithms")。 ### 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 ``` 结合这个例子,`strtok`的行为可以这样理解:冒号是分隔符,把`"root:x::0:root:/root:/bin/bash:"`这个字符串分隔成`"root"`、`"x"`、`""`、`"0"`、`"root"`、`"/root"`、`"/bin/bash"`、`""`等几个Token,但空字符串的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/akaedu/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`函数非常值得自己动手实现一遍,在这个过程中不仅可以更深刻地理解这两个函数的工作原理,也为以后理解“可重入”和“线程安全”这两个重要概念打下基础。 2、解析URL中的路径和查询字符串。动态网页的URL末尾通常带有查询,例如: http://www.google.cn/search?complete=1&hl=zh-CN&ie=GB2312&q=linux&meta= http://www.baidu.com/s?wd=linux&cl=3 比如上面第一个例子,`http://www.google.cn/search`是路径部分,?号后面的`complete=1&hl=zh-CN&ie=GB2312&q=linux&meta=`是查询字符串,由五个“key=value”形式的键值对(Key-value Pair)组成,以&隔开,有些键对应的值可能是空字符串,比如这个例子中的键`meta`。 现在要求实现一个函数,传入一个带查询字符串的URL,首先检查输入格式的合法性,然后对URL进行切分,将路径部分和各键值对分别传出,请仔细设计函数接口以便传出这些字符串。如果函数中有动态分配内存的操作,还要另外实现一个释放内存的函数。完成之后,为自己设计的函数写一个Man Page。 ## 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 节 “目标文件”](ch18s05.html#asm.relocatable)中我们用`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函数”](ch28s02.html#io.twoioflavors)将会讲到这个标识叫做文件描述符)、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 节 “终端的基本概念”](ch34s01.html#jobs.intro)会讲到这叫进程的控制终端)。也就是说,`/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`才返回[[34](#ftn.id2831641)]。这个程序的执行过程分析如下: ``` $ ./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 ``` `<<END`表示从下一行开始是标准输入,直到某一行开头出现`END`时结束。`<<`后面的结束符可以任意指定,不一定得是`END`,只要和输入的内容能区分开就行。 在上面的程序中,第一个`while`循环结束时`fp`所指文件的读写位置在文件末尾,然后调用`rewind`函数把读写位置移到文件开头,再进入第二个`while`循环从头读取文件内容。 #### 习题 1、编写一个简单的文件复制程序。 ``` $ ./mycp dir1/fileA dir2/fileB ``` 运行这个程序可以把`dir1/fileA`文件拷贝到`dir2/fileB`文件。注意各种出错处理。 2、虽然我说`getchar`要读到换行符才返回,但上面的程序并没有提供证据支持我的说法,如果看成每敲一个键`getchar`就返回一次,也能解释程序的运行结果。请写一个小程序证明`getchar`确实是读到换行符才返回的。 ### 2.6. 操作读写位置的函数 我们在上一节的例子中看到`rewind`函数把读写位置移到文件开头,本节介绍另外两个操作读写位置的函数,`fseek`可以任意移动读写位置,`ftell`可以返回当前的读写位置。 ``` #include 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`。 | | - | 格式化后的内容居左,右边可以留空格。 | 见下面的例子 | | 宽度 | 用一个整数指定格式化后的最小长度,如果格式化后的内容没有这么长,可以在左边留空格,如果前面指定了`-`号就在右边留空格。宽度有一种特别的形式,不指定整数值而是写成一个`*`号,表示取一个`int`型参数作为宽度。 | `printf("-%10s-", "hello")`打印`-␣␣␣␣␣hello-`,`printf("-%-*s-", 10, "hello")`打印`-hello␣␣␣␣␣-`。 | | . | 用于分隔上一条提到的最小长度和下一条要讲的精度。 | 见下面的例子 | | 精度 | 用一个整数表示精度,对于字符串来说指定了格式化后保留的最大长度,对于浮点数来说指定了格式化后小数点右边的位数,对于整数来说指定了格式化后的最小位数。精度也可以不指定整数值而是写成一个`*`号,表示取下一个`int`型参数作为精度。 | `printf("%.4s", "hello")`打印`hell`,`printf("-%6.4d-", 100)`打印`-␣␣0100-`,`printf("-%*.*f-", 8, 4, 3.14)`打印`-␣␣3.1400-`。 | | 字长 | 对于整型参数,`hh`、`h`、`l`、`ll`分别表示是`char`、`short`、`long`、`long long`型的字长,至于是有符号数还是无符号数则取决于转换字符;对于浮点型参数,`L`表示`long double`型的字长。 | `printf("%hhd", 255)`打印`-1`。 | 常用的转换字符有: **表 25.2. printf的转换字符** | 转换字符 | 描述 | 举例 | | --- | --- | --- | | d i | 取`int`型参数格式化成有符号十进制表示,如果格式化后的位数小于指定的精度,就在左边补0。 | `printf("%.4d", 100)`打印`0100`。 | | o u x X | 取`unsigned int`型参数格式化成无符号八进制(o)、十进制(u)、十六进制(x或X)表示,x表示十六进制数字用小写abcdef,X表示十六进制数字用大写ABCDEF,如果格式化后的位数小于指定的精度,就在左边补0。 | `printf("%#X", 0xdeadbeef)`打印`0XDEADBEEF`,`printf("%hhu", -1)`打印`255`。 | | c | 取`int`型参数转换成`unsigned char`型,格式化成对应的ASCII码字符。 | `printf("%c", 256+'A')`打印`A`。 | | s | 取`const char *`型参数所指向的字符串格式化输出,遇到`'\0'`结束,或者达到指定的最大长度(精度)结束。 | `printf("%.4s", "hello")`打印`hell`。 | | p | 取`void *`型参数格式化成十六进制表示。相当于`%#x`。 | `printf("%p", main)`打印`main`函数的首地址`0x80483c4`。 | | f | 取`double`型参数格式化成`[-]ddd.ddd`这样的格式,小数点后的默认精度是6位。 | `printf("%f", 3.14)`打印`3.140000`,`printf("%f", 0.00000314)`打印`0.000003`。 | | e E | 取`double`型参数格式化成`[-]d.ddde±dd`(转换字符是e)或`[-]d.dddE±dd`(转换字符是E)这样的格式,小数点后的默认精度是6位,指数至少是两位。 | `printf("%e", 3.14)`打印`3.140000e+00`。 | | g G | 取`double`型参数格式化,精度是指有效数字而非小数点后的数字,默认精度是6。如果指数小于-4或大于等于精度就按`%e`(转换字符是g)或`%E`(转换字符是G)格式化,否则按`%f`格式化。小数部分的末尾0去掉,如果没有小数部分,小数点也去掉。 | `printf("%g", 3.00)`打印`3`,`printf("%g", 0.00001234567)`打印`1.23457e-05`。 | | % | 格式化成一个`%`。 | `printf("%%")`打印一个`%`。 | 我们在[第 6 节 “可变参数”](ch24s06.html#interface.va)讲过可变参数的原理,`printf`并不知道实际参数的类型,只能按转换说明指出的参数类型从栈帧上取参数,所以如果实际参数和转换说明的类型不符,结果可能会有些意外,上面也举过几个这样的例子。另外,如果`s`指向一个字符串,用`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 *`。 | | i | 匹配整数(开头可以有负号),赋值参数的类型是`int *`,如果输入字符以0x或0X开头则匹配十六进制整数,如果输入字符以0开头则匹配八进制整数。 | | o u x | 匹配八进制、十进制、十六进制整数(开头可以有负号),赋值参数的类型是`unsigned int *`。 | | c | 匹配一串字符,字符的个数由宽度指定,缺省宽度是1,赋值参数的类型是`char *`,末尾不会添加`'\0'`。如果输入字符的开头有空白字符,这些空白字符并不被忽略,而是保存到参数中,要想跳过开头的空白字符,可以在格式化字符串中用一个空格去匹配。 | | s | 匹配一串非空白字符,从输入字符中的第一个非空白字符开始匹配到下一个空白字符之前,或者匹配到指定的宽度,赋值参数的类型是`char *`,末尾自动添加`'\0'`。 | | e f g | 匹配符点数(开头可以有负号),赋值参数的类型是`float *`,也可以指定`double *`或`long double *`的字长。 | | % | 转换说明`%%`匹配一个字符`%`,不做赋值。 | 下面几个例子出自[[K&R]](bi01.html#bibli.kr "The C Programming Language")。第一个例子,读取用户输入的浮点数累加起来。 **例 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”](ch17s05.html#arch.memh)中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缓冲区](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d6aab3e.png) 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`系统调用进入内核退出当前进程[[35](#ftn.id2834688)]。 在上面的例子中,由于标准输出是行缓冲的,`printf("hello world");`打印的字符串中没有换行符,所以只把字符串写到标准输出的I/O缓冲区中而没有写回内核(写到终端设备),如果敲Ctrl-C,进程是异常终止的,并没有调用`exit`,也就没有机会Flush I/O缓冲区,因此字符串最终没有打印到屏幕上。如果把打印语句改成`printf("hello world\n");`,有换行符,就会立刻写到终端设备,或者如果把`while(1);`去掉也可以写到终端设备,因为程序退出时会调用`exit`Flush所有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操作。 ### 2.11. 本节综合练习 1、编程读写一个文件`test.txt`,每隔1秒向文件中写入一行记录,类似于这样: ``` 1 2009-7-30 15:16:42 2 2009-7-30 15:16:43 ``` 该程序应该无限循环,直到按Ctrl-C终止。下次再启动程序时在`test.txt`文件末尾追加记录,并且序号能够接续上次的序号,比如: ``` 1 2009-7-30 15:16:42 2 2009-7-30 15:16:43 3 2009-7-30 15:19:02 4 2009-7-30 15:19:03 5 2009-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 /usr/include/features.h /usr/include/bits/errno.h /usr/include/linux/errno.h ... /home/akaedu/stack.h: cannot find ``` 如果有的头文件找不到,就像上面例子那样打印`/home/akaedu/stack.h: cannot find`。首先复习一下[第 2.2 节 “头文件”](ch20s02.html#link.header)讲过的头文件查找顺序,本题目不必考虑`-I`选项指定的目录,只在`.c`文件所在的目录以及系统目录`/usr/include`中查找。 * * * [[34](#id2831641)] 这些特性取决于终端的工作模式,终端可以配置成一次一行的模式,也可以配置成一次一个字符的模式,默认是一次一行的模式(本书的实验都是在这种模式下做的),关于终端的配置可参考[[APUE2e]](bi01.html#bibli.apue "Advanced Programming in the UNIX Environment")。 [[35](#id2834688)] 其实在调`_exit`进内核之前还要调用户程序中通过`atexit(3)`注册的退出处理函数,本书不做详细介绍,读者可参考[[APUE2e]](bi01.html#bibli.apue "Advanced Programming in the UNIX Environment")。 ## 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`类似。 ## 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标准中定义的。
';

第 24 章 函数接口

最后更新于:2022-04-01 22:01:51

# 第 24 章 函数接口 **目录** + [1\. 本章的预备知识](ch24s01.html) + [1.1\. `strcpy`与`strncpy`](ch24s01.html#id2819066) + [1.2\. `malloc`与`free`](ch24s01.html#id2820062) + [2\. 传入参数与传出参数](ch24s02.html) + [3\. 两层指针的参数](ch24s03.html) + [4\. 返回值是指针的情况](ch24s04.html) + [5\. 回调函数](ch24s05.html) + [6\. 可变参数](ch24s06.html) 我们在[第 6 节 “折半查找”](ch11s06.html#sortsearch.binary)讲过,函数的调用者和函数的实现者之间订立了一个契约,在调用函数之前,调用者要为实现者提供某些条件,在函数返回时,实现者要对调用者尽到某些义务。如何描述这个契约呢?首先靠函数接口来描述,即函数名,参数,返回值,只要函数和参数的名字起得合理,参数和返回值的类型定得准确,至于这个函数怎么用,调用者单看函数接口就能猜出八九分了。函数接口并不能表达函数的全部语义,这时文档就起了重要的补充作用,函数的文档该写什么,怎么写,Man Page为我们做了很好的榜样。 函数接口一旦和指针结合起来就变得异常灵活,有五花八门的用法,但是万变不离其宗,只要像[图 23.1 “指针的基本概念”](ch23s01.html#pointer.pointer0)那样画图分析,指针的任何用法都能分析清楚,所以,如果上一章你真正学明白了,本章不用学也能自己领悟出来,之所以写这一章是为了照顾悟性不高的读者。本章把函数接口总结成几类常见的模式,对于每种模式,一方面讲函数接口怎么写,另一方面讲函数的文档怎么写。 ## 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]](bi01.html#bibli.kr "The C Programming Language")那样把库函数总结成一个附录附在书后面。现在我们来分析`strcpy(3)`。 **图 24.1. `strcpy(3)`** ![strcpy(3)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d610b1c.png) 这个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(3)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d625eb0.png) 在文档中强调了`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)`** ![strcpy(3)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d6477d7.png) 函数的Man Page都有一部分专门讲返回值的。这两个函数的返回值都是`dest`指针。可是为什么要返回`dest`指针呢?`dest`指针本来就是调用者传过去的,再返回一遍`dest`指针并没有提供任何有用的信息。之所以这么规定是为了把函数调用当作一个指针类型的表达式使用,比如`printf("%s\n", 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)`** ![strcpy(3)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d663a5a.png) _BUGS_部分说明了使用这些函数可能引起的Bug,这部分一定要仔细看。用`strcpy`比用`strncpy`更加不安全,如果在调用`strcpy`之前不仔细检查`src`字符串的长度就有可能写越界,这是一个很常见的错误,例如: ``` void foo(char *str) { char buf[10]; strcpy(buf, str); ... } ``` `str`所指向的字符串有可能超过10个字符而导致写越界,在[第 4 节 “段错误”](ch10s04.html#gdb.segfault)我们看到过,这种写越界可能当时不出错,而在函数返回时出现段错误,原因是写越界覆盖了保存在栈帧上的返回地址,函数返回时跳转到非法地址,因而出错。像`buf`这种由调用者分配并传给函数读或写的一段内存通常称为缓冲区(Buffer),缓冲区写越界的错误称为缓冲区溢出(Buffer Overflow)。如果只是出现段错误那还不算严重,更严重的是缓冲区溢出Bug经常被恶意用户利用,使函数返回时跳转到一个事先设好的地址,执行事先设好的指令,如果设计得巧妙甚至可以启动一个Shell,然后随心所欲执行任何命令,可想而知,如果一个用`root`权限执行的程序存在这样的Bug,被攻陷了,后果将很严重。至于怎样巧妙设计和攻陷一个有缓冲区溢出Bug的程序,有兴趣的读者可以参考[[SmashStack]](bi01.html#bibli.smashstack "Smashing The Stack For Fun And Profit,网上到处都可以搜到这篇文章")。 #### 习题 1、自己实现一个`strcpy`函数,尽可能简洁,按照本书的编码风格你能用三行代码写出函数体吗? 2、编一个函数,输入一个字符串,要求做一个新字符串,把其中所有的一个或多个连续的空白字符都压缩为一个空格。这里所说的空白包括空格、'\t'、'\n'、'\r'。例如原来的字符串是: ``` This Content hoho is ok ok? file system uttered words ok ok ? end. ``` 压缩了空白之后就是: ``` 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 节 “数组的基本概念”](ch08s01.html#array.intro)讲过C99引入VLA特性,可以定义`char buf[n+1] = {};`,这样可确保`buf`是以`'\0'`结尾的。但即使用VLA仍然不够灵活,VLA是在栈上动态分配的,函数返回时就要释放,如果我们希望动态分配一块全局的内存空间,在各函数中都可以访问呢?由于全局数组无法定义成VLA,所以仍然不能满足要求。 其实在[第 5 节 “虚拟内存管理”](ch20s05.html#link.vm)提过,进程有一个堆空间,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)); 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->msg); 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]](bi01.html#bibli.kr "The C Programming Language")的8.7节给出了`malloc`和`free`的简单实现,基于环形链表。目前读者还没有学习链表,看那段代码会有点困难,我再做一些简化,图示如下,目的是让读者理解`malloc`和`free`的工作原理。`libc`的实现比这要复杂得多,但基本工作原理也是如此。读者只要理解了基本工作原理,就很容易分析在使用`malloc`和`free`时遇到的各种Bug了。 **图 24.5. 简单的`malloc`和`free`实现** ![简单的malloc和free实现](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d67e18d.png) 图中白色背景的框表示`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]](bi01.html#bibli.kr "The C Programming Language")的实现中,每次调用`sbrk`函数时申请1024×8=8192个字节,在Linux系统上`sbrk`函数也是通过`brk`实现的,这里为了画图方便,我们假设每次调用`sbrk`申请32个字节,建立一个新的空闲块。 6. 新申请的空闲块和前一个空闲块连续,因此可以合并成一个。在能合并时要尽量合并,以免空闲块越割越小,无法满足大的分配请求。 7. 在合并后的这个空闲块末尾截出24个字节,新的头节点占8个字节,另外16个字节返回给用户。 8. 调用`free(p3)`释放这个内存块,由于它和前一个空闲块连续,又重新合并成一个空闲块。注意,Break只能抬高而不能降低,从内核申请到的内存以后都归`malloc`管了,即使调用`free`也不会还给内核。 #### 习题 1、小练习:编写一个小程序让它耗尽系统内存。观察一下,分配了多少内存后才会出现分配失败?内存耗尽之后会怎么样?会不会死机? ## 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`所指的内存空间保存数据 3. 调用函数 4. 读取`p`所指的内存空间 | 1. 规定指针参数的类型`unit_t *` 2. 读取`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)`的参数。这些特殊规定应该在文档中写清楚。 ## 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);`不能分配内存,而一定要用两层指针的函数接口? 总结一下,两层指针参数如果是传出的,可以有两种情况:第一种情况,传出的指针指向静态内存(比如上面的例子),或者指向已分配的动态内存(比如指向某个链表的节点);第二种情况是在函数中动态分配内存,然后传出的指针指向这块内存空间,这种情况下调用者应该在使用内存之后调用释放内存的函数,调用者的责任是请求分配和请求释放内存,实现者的责任是完成分配内存和释放内存的操作。由于这两种情况的函数接口相同,应该在文档中说明是哪一种情况。 ## 4. 返回值是指针的情况 返回值显然是传出的而不是传入的,如果返回值传出的是指针,和上一节通过参数传出指针类似,也分为两种情况:第一种是传出指向静态内存或已分配的动态内存的指针,例如`localtime(3)`和`inet_ntoa(3)`,第二种是在函数中动态分配内存并传出指向这块内存的指针,例如`malloc(3)`,这种情况通常还要实现一个释放内存的函数,所以有和`malloc(3)`对应的`free(3)`。由于这两种情况的函数接口相同,应该在文档中说明是哪一种情况。 **表 24.5. 返回指向已分配内存的指针示例`:unit_t *func(void);`** | 调用者 | 实现者 | | --- | --- | | 1. 调用函数 2. 将返回值保存下来以备后用 | 1. 规定返回值指针的类型`unit_t *` 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; } ``` 思考一下,通过参数分配内存需要两层的指针,而通过返回值分配内存就只需要返回一层的指针,为什么? ## 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, "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)中则很常见,基本思想是把函数也当作一种数据来操作,输入、输出和参与运算,操作函数的函数称为高阶函数(High-order Function)。 ### 习题 1、[[K&R]](bi01.html#bibli.kr "The C Programming Language")的5.6节有一个`qsort`函数的实现,可以对一组任意类型的对象做快速排序。请读者仿照那个例子,写一个插入排序的函数和一个折半查找的函数。 ## 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: c7 44 24 04 31 00 00 movl $0x31,0x4(%esp) 80484d4: 00 80484d5: c7 04 24 b6 85 04 08 movl $0x80485b6,(%esp) 80484dc: e8 43 ff ff ff call 8048424 ``` **图 24.6. `myprintf`函数的参数布局** ![myprintf函数的参数布局](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d697c6b.png) 这些参数是从右向左依次压栈的,所以第一个参数靠近栈顶,第三个参数靠近栈底。这些参数在内存中是连续存放的,每个参数都对齐到4字节边界。第一个和第三个参数都是指针类型,各占4个字节,虽然第二个参数只占一个字节,但为了使第三个参数对齐到4字节边界,所以第二个参数也占4个字节。现在给出一个`stdarg.h`的简单实现,这个实现出自[[Standard C Library]](bi01.html#bibli.standardclib "The 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”](ch15s03.html#type.intpromo)讲过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`、`%f`对应的整数参数。在实现中不许调用`printf(3)`这个Man Page中描述的任何函数。
';

第 23 章 指针

最后更新于:2022-04-01 22:01:48

# 第 23 章 指针 **目录** + [1\. 指针的基本概念](ch23s01.html) + [2\. 指针类型的参数和返回值](ch23s02.html) + [3\. 指针与数组](ch23s03.html) + [4\. 指针与`const`限定符](ch23s04.html) + [5\. 指针与结构体](ch23s05.html) + [6\. 指向指针的指针与指针数组](ch23s06.html) + [7\. 指向数组的指针与多维数组](ch23s07.html) + [8\. 函数类型和函数指针类型](ch23s08.html) + [9\. 不完全类型和复杂声明](ch23s09.html) ## 1. 指针的基本概念 在[第 12 章 _栈与队列_](ch12.html#stackqueue)讲过,堆栈有栈顶指针,队列有头指针和尾指针,这些概念中的“指针”本质上是一个整数,是数组的索引,通过指针访问数组中的某个元素。在[图 20.3 “间接寻址”](ch20s04.html#link.indirect)我们又看到另外一种指针的概念,把一个变量所在的内存单元的地址保存在另外一个内存单元中,保存地址的这个内存单元称为指针,通过指针和间接寻址访问变量,这种指针在C语言中可以用一个指针类型的变量表示,例如某程序中定义了以下全局变量: ``` int i; int *pi = &i; char c; char *pc = &c; ``` 这几个变量的内存布局如下图所示,在初学阶段经常要借助于这样的图来理解指针。 **图 23.1. 指针的基本概念** ![指针的基本概念](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d58c4a7.png) 这里的`&`是取地址运算符(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 *`指针** ![把char *指针的值赋给int *指针](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d5b32ee.png) 现在`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); printf("%c\n", c); ... } ``` 下一章讲函数接口时再详细介绍`void *`指针的用处。 ## 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 “指针的基本概念”](ch23s01.html#pointer.pointer0)那样画图理解函数的调用和返回过程。在下一章我们会看到更复杂的参数和返回值形式,在初学阶段对每个程序都要画图理解它的运行过程,只要基本概念清晰,无论多复杂的形式都应该能正确分析。 2、现在回头看[第 3 节 “形参和实参”](ch03s03.html#func.paraarg)的习题1,那个程序应该怎么改? ## 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. 指针与数组** ![指针与数组](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d5c37a5.png) 既然指针可以用`++`运算符,当然也可以用`+`、`-`运算符,`pa+2`这个表达式也是有意义的,如上图所示,`pa`指向`a[1]`,那么`pa+2`指向a[3]。事实上,`E1[E2]`这种写法和`(*((E1)+(E2)))`是等价的,`*(pa+2)`也可以写成`pa[2]`,`pa`就像数组名一样,其实数组名也没有什么特殊的,`a[2]`之所以能取数组的第2个元素,是因为它等价于`*(a+2)`,在[第 1 节 “数组的基本概念”](ch08s01.html#array.intro)讲过数组名做右值时自动转换成指向首元素的指针,所以`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 节 “数组的基本概念”](ch08s01.html#array.intro)还讲过C语言允许数组下标是负数,现在你该明白为什么这样规定了。在上面的例子中,表达式`pa[-1]`是合法的,它和`a[0]`表示同一个元素。 现在猜一下,两个指针变量做比较运算(`>`、`>=`、`<`、`<=`、`==`、`!=`)表示什么意义?两个指针变量做减法运算又表示什么意义? 根据什么来猜?根据[第 3 节 “形参和实参”](ch03s03.html#func.paraarg)讲过的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 节 “指向数组的指针与多维数组”](ch23s07.html#pointer.array3)介绍这种语法。 在函数原型中,如果参数是数组,则等价于参数是指针的形式,例如: ``` void func(int a[10]) { ... } ``` 等价于: ``` void func(int *a) { ... } ``` 第一种形式方括号中的数字可以不写,仍然是等价的: ``` void func(int a[]) { ... } ``` 参数写成指针形式还是数组形式对编译器来说没区别,都表示这个参数是指针,之所以规定两种形式是为了给读代码的人提供有用的信息,如果这个参数指向一个元素,通常写成指针的形式,如果这个参数指向一串元素中的首元素,则经常写成数组的形式。 ## 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 节 “变量的存储布局”](ch19s03.html#asmc.layout)我们看到,字符串字面值通常分配在`.rodata`段,而在[第 4 节 “字符串”](ch08s04.html#array.string)提到,字符串字面值类似于数组名,做右值使用时自动转换成指向首元素的指针,这种指针应该是`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`段,不允许改写,但编译器不会报错,在运行时会出现段错误。 ## 5. 指针与结构体 首先定义一个结构体类型,然后定义这种类型的变量和指针: ``` struct unit { char c; int num; }; struct unit u; struct unit *p = &u; ``` 要通过指针`p`访问结构体成员可以写成`(*p).c`和`(*p).num`,为了书写方便,C语言提供了`->`运算符,也可以写成`p->c`和`p->num`。 ## 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指针数组](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d5d2d9c.png) 由于`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; ``` ## 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'`元素,分别应该怎么写? ## 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 节 “数据类型标志”](ch07s03.html#struct.datatag)的习题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`又多了一种取值,只需要添加一组新的函数,修改函数指针数组,原有的函数仍然可以不加改动地复用。 ## 9. 不完全类型和复杂声明 在[第 1 节 “复合类型与结构体”](ch07s01.html#struct.intro)讲过算术类型、标量类型的概念,现在又学习了几种类型,我们完整地总结一下C语言的类型。下图出自[[Standard C]](bi01.html#bibli.standardc "Standard C: A Reference")。 **图 23.5. C语言类型总结** ![C语言类型总结](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d5e1387.gif) 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. 链表** ![链表](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-04-02_56ff80d5f3b01.png) `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个元素组成。分解完毕。
';