本系列文章是学习被誉为UNIX编程圣经的《UNIX环境高级编程》的读书笔记。《UNIX环境高级编程》的英文全称为《Advanced Programming in the UNIX Environment》,简称《APUE》,其作者是UNIX和网络技术领域的知名专家W.Richard Stevens。
本书描述了UNIX系统的程序设计接口:系统调用接口和标准C库提供的很多函数。与大多数操作系统一样,UNIX为程序运行提供了大量的服务--打开文件,读文件,启动一个新程序,分配存储区,获取当前时间等等。这些服务被称为系统调用接口(system call interface)。另外标准C库提供了大量广泛应用于C程序中的函数。
本书共分为6个部分:
(1)对UNIX程序设计基本概念和术语的简要描述,以及对各种UNIX标准化工作和不同UNIX实现的讨论;
(2)I/O:不带缓冲的I/O,文件和目录,标准I/O库和标准系统数据文件;
(3)进程:UNIX进程的环境,进程控制,进程之间的关系和信号;
(4)更多的I/0:终端I/O,高级I/O和守护进程;
(5)IPC:进程间通信;
(6)实例;
接下来就正式进入《UNIX环境高级编程》的学习了。本章将从程序设计人员的角度快速浏览UNIX,对书中引用的一些术语和概念进行简要的说明,后续文章再对这些概念作更详细的说明。
UNIX体系结构:
在严格意义上,可将操作系统定义为一种软件,它控制计算机硬件资源,提供程序运行环境。一般而言,我们称这种软件为内核(kernel)。 内核的接口被称为系统调用(system call)。公用函数库构建在系统调用接口之上。应用软件既可以使用公用函数库,也可以使用系统调用。
Shell是一种特殊的应用程序,它为运行其他的应用程序提供了一个接口。
在广义上,操作系统包括内核和一些其它软件,这些软件使得计算机能够发挥作用,这些软件包括系统实用程序(system utilities),应用软件,shell以及公用函数库等。
例如,Linux是GNU操作系统使用的内核,仅仅是GNU操作系统的关键组件之一,GNU操作系统还包括很多其它的自由软件,例如bash,Tex,GNU C库等等。所以严格意义上这些操作系统的发行版应该称为GNU/Linux,但是很多人将其简称为Linux。虽然这种表达方法不正确,但是“操作系统”本省就具有双重含义,所以这也是可以理解的。关于GNU和Linux的关系,可以参考:http://www.gnu.org/gnu/linux-and-gnu.en.html。
登录:
登录名:用户在登录UNIX系统时,需要输入登录名及相应的口令。系统在其口令文件(通常是/etc/passwd文件)中查看登录名。
shell:
shell:shell是一个命令行解释器,它读取用户输入,然后执行命令。用户通常通过终端(交互式shell),有时则通过文件(shell script)向shell进行输入。用户在登录后,系统从口令文件中相应用户登录项的最后一个字段中了解到应该为该登录用户执行哪一个shell。
文件和目录:
文件系统:UNIX文件系统是目录和文件组成的一种层次结构,目录的起点称为根(root),写为"/"。目录是一个包含许多目录项的文件,逻辑上,可以认为每个目录项都包含一个文件名,同时还包含说明该文件属性的信息。
文件名:目录中的各个名字称为文件名,不能出现在文件名中的字符只有斜线("/")和空字符(null)。斜线用来分隔构成路径名的各文件名,空字符则用来终止一个路径名。
在创建新目录时,会自动创建两个文件名:.(称为点)和..(称为点-点)。点指当前目录,点点指父目录。在根目录中,点和点点相同。
现如今,几乎所有商品化的UNIX系统都支持至少255个字符的文件名。
路径名:一个或多个斜线分隔的文件名序列(也可以斜线开头)构成路径名。以斜线开头的路径名称为绝对路径,否则称为相对路径。相对路径引用相当于当前目录的文件。
下列程序是ls(1)命令的简要实现,用于列出某个目录下的所有文件名。
/* * Copyright (C) [email protected] */ #include <stdio.h> #include <stdlib.h> #include <dirent.h> int main(int argc, char *argv[]) { if (argc != 2) { printf("usage: ls directory_name\n"); exit(1); } DIR *dp; struct dirent *dirp; if ( (dp = opendir(argv[1])) == NULL) { printf("can't open %s\n", argv[1]); exit(2); } while ( (dirp = readdir(dp)) != NULL) { printf("%s\n", dirp -> d_name); } closedir(dp); exit(0); }
ls(1)这种表示方法是UNIX系统的惯用方法,表示引用UNIX手册集中的某个特定项。ls(1)表示引用第一部分中的ls项。各部分通常用数字1-8表示,每个部分中的各项则按字母顺序排列。在我的CentOS上,这八个部分分别为:
目前,大多数手册都以电子文档的形式提供。所以如果使用的是联机手册,可以使用如下命令查看ls命令手册页:man 1 ls 或 man -s1 ls。
由于不同的UNIX系统的目录项的格式是不同的,因此上个程序采用opendir,readdir,closedir函数来对该目录进行处理。关于该程序的更多细节将在后续文章进一步介绍。
工作目录:每个进程都有一个工作目录,有时将其称为当前工作目录。所有相对路径名都从工作目录开始解释。
起始目录:登录时,工作目录设置为起始目录,起始目录从口令文件中的相应用户的登录项中取得。
输入与输出:
文件描述符:文件描述符通常是一个小的非负整数,内核用它表示一个特定进程正在访问的文件。当内核打一个已有文件或创建一个新文件时,它返回一个文件描述符。之后读写文件时,就可以使用该文件描述符。
标准输入、标准输出、标准出错:按照惯例,每当运行一个新程序时,shell就为其打开三个文件描述符:标准输入、标准输出、标准出错。如果程序中没有做什么特殊处理,则这三个文件描述符都链向终端。大多数shell也提供相应的方法,让这三个文件描述符重定向到某个文件。
不带缓冲的I/O:函数open,read,write,lseek以及close都提供了不用缓冲的I/O,这些函数都使用文件描述符
下列程序将标准输入复制到标准输出:
/* * Copyright (C) [email protected] */ #include <unistd.h> #include <stdlib.h> #include <stdio.h> #define BUF_SIZE 4096 int main(int argc, char *argv[]) { int n; char buffer[BUF_SIZE]; while ( (n = read(STDIN_FILENO, buffer, BUF_SIZE)) > 0) { if (write(STDOUT_FILENO, buffer, n) != n) { printf("write error\n"); exit(1); } } if (n < 0) { printf("read error\n"); exit(2); } exit(0); }
头文件<unistd.h>以及两个常量STDIN_FILENO以及STDOUT_FILENO都是POSIX标准的一部分。该头文件包含了许多UNIX系统服务的函数原型。STDIN_FILENO以及STDOUT_FILENO分别指定了标准输入和标准输出的文件描述符。它们的典型值是0和1,但是考虑到可移植性,最好还是使用标识符。
在shell中运行该程序时,通过重定向,可以用于复制任何一个UNIX普通文件。
标准I/O:标准I/O函数提供一种对不带缓冲I/O函数的带缓冲的接口。使用标准I/O函数可以无需担心如何选取最佳的缓冲区大小,而且简化了对输入行的处理。
下列程序用标准I/O函数实现了将标准输入复制到标准输出:
/* * Copyright (C) [email protected] */ #include <stdio.h> #include <stdlib.h> int main(int argc, char *argv[]) { int c; while ( (c = getc(stdin)) != EOF) { if (putc(c, stdout) == EOF) { printf("put char error\n"); exit(1); } } if(ferror(stdin)) { printf("get char error\n"); exit(2); } exit(0); }
getc一次读取一个字符,putc将该字符写到标准输出。程序中的标准输入常量 stdin,标准输出常量stdout,以及文件结束符EOF,都定义在<stdio.h>中。
程序和进程
程序:程序是存在磁盘上、处于某个目录中的可执行文件。使用6个exec函数中的一个由内核将该程序读入存储器,并使其执行。
进程和进程ID:程序的执行实例被称为进程。UNIX系统确保每个进程都有一个唯一的数字标识符,称为进程ID。进程ID总是一个非负整数。
下列程序通过调用getpid函数来获取自己的进程ID:
/* * Copyright (C) [email protected] */ #include <unistd.h> #include <stdio.h> #include <stdlib.h> int main(void) { printf("hello world from process ID : %d\n", getpid()); exit(0); }
进程控制:有三个用于进程控制的主要函数:fork,exec,waitpid。(exec函数有6种变体,但把它们统称为exec函数)。
下列程序展示UNIX系统的进程控制功能,该程序从标准输入中读取命令,然后执行这些命令,是一个类似于shell的简单实现:
/* * Copyright (C) [email protected] */ #include <unistd.h> #include <stdlib.h> #include <stdio.h> #include <string.h> #define MAX_LINE 32 int main(void) { char buf[MAX_LINE]; pid_t pid; int status; printf("%% "); while (fgets(buf, MAX_LINE, stdin) != NULL) { if (buf[strlen(buf) - 1] == '\n') { buf[strlen(buf) - 1] = '\0'; } if ( (pid = fork()) < 0) { printf("fork error"); exit(1); } else if (pid == 0) { /* child process */ execlp(buf, buf, (char*)0); printf("can't execute %s\n", buf); exit(2); } if ( (pid = waitpid(pid, &status, 0)) < 0) { printf("waitpid error\n"); exit(3); } printf("%% "); } exit(0); }
该程序最主要的部分就是调用fork函数创建一个新进程。新进程是调用进程的复制品,因此调用进程称为父进程,新创建的进程为子进程。fork函数向父进程返回子进程的进程ID(非负),向子进程返回0。所以fork函数是调用一次,返回两次(分别在父进程和子进程中)。
在子进程中,调用execlp函数执行从标准输入读入的命令。这就用新的程序文件代替了子进程原先执行的程序文件。而父进程调用waitpid函数等待子进程终止。
关于该程序的更多细节将在后续文章进一步学习。
线程和线程ID:
通常,一个进程只有一个控制线程。但是对于某些问题,如果不同部分各使用一个控制线程,那么解决问题会更加容易,而且多个控制线程也能充分地利用多处理器系统的并行性。
在一个进程内所有线程共享同一地址空间,文件描述符,以及相关的进程属性。因为所有线程都能访问同一存储区,所以各线程在访问共享数据时需要采取同步措施以避免不一致性。
线程也用ID标识,但是线程ID只在它所属的进程内起作用。一个进程中的线程ID在另一个进程中并无意义。
在进程模型建立很久之后,线程模型才被引入UNIX系统中,这两个模型间存在复杂的相互作用。