|
发表于 2006-6-29 14:28:05
|
显示全部楼层
通过分析Unix基本命令的源码,开始Unix下的编程(By wsw)
******************************************************
如果你是一个编程爱好者,希望学习Unix系统下的编程,
又苦于迟迟不能入门,那么这篇文章就是写给你看的。
如果你已经是一个Unix系统编程高手了,
那这篇文章对你来说就是小儿科了。那也希望你能看一下,
因为能得到高手的指点也是很快乐的事情。
******************************************************
不少编程爱好者都已经学习了C语言,但是发现学会C语言的语法和能用C语言编程是不同的概念。学了C语言以后可能会觉得自己只能编写一些教科书后的作业程序,了解一些简单的算法,如用起泡法排序之类的。这时最渴望的就是自己可以编写一些确实有用的程序,或者去分析一下人家写的C程序的源代码。你可能会听说Linux就是用C语言写成的,所以你会激动万分的去www.kernel.org上下载了最新 ... 完全崩溃了。
以上的情况就是我刚开始学习Unix系统编程时的情景,我相信不少的初学者都会经历这样的阶段。那该怎么办呢,是放弃学习Unix系统编程吗?当然不是,你现在需要的正是阅读人家写的C源代码来积累写程序的经验,最好就是Unix下的命令程序,但不是coreutils软件包中的,因为这些软件就是你所使用的命令的源代码,Unix下的命令会有很多选项,有些是常用的,有些则是很少用的,有些甚至是“一辈子”也用不着的。(你可以man 一下常用的命令,看看有多少选项是你经常用的。)这些选项都在coreutils软件包里实现了,所以十分简单的程序都会有那么复杂的源码,但是这些小程序完成的任务是很简单的:cat就是显示一个文件的内容,echo就是重复命令后面的内容等等。所以我们只需要这些最基本的功能,那该怎么办呢?去修改coreutils吗?不是的,我们可以阅读早期的Unix版本的命令源代码,我选择了Unix V7版本。原因是这个版本曾经广泛使用过,它的源代码也被高度优化过,以后的Unix变体多少都受到Unix V7的影响。但是这些C源码是用K.R.风格写的,和现在使用的C89(ANSI C)标准有点不同,还由于现代的系统与早期的Unix的系统调用或者函数库有点不兼容的问题,可能会导致Gcc编译不通过。但是我会把这些源码改成C99标准,(所以用Gcc编译修改过的程序时请使用 -std=c99选项)但是尽量保持原来代码的面貌。为了足够严谨,我会附上没有被修改的Unix V7源码以供审查。在分析源码过程中我会提及Unix编程的一些思想,介绍有关函数的使用,还会更多的介绍Unix本生这个系统给程序员提供的环境,比如Unix下的文件,进程,管道之类的。
下面就开始我们的旅程吧,第一个程序是echo。这是个十分简单的程序,就是把命令行参数传递给echo程序,然后echo再把接收到的参数打印到标准输出。下面给出echo.c的源码:
//////////////////////////////////////////////////////////////////////////////////////////
/*************************************
UnixV7中的echo程序。
注解:wsw 2006年5月25日
*************************************/
1 #include <stdio.h>
2 #include <stdlib.h>
3 int main (int argc, char *argv[])
4 {
5 register int nflg=0;
6 //以下的if语句探测是否有用-n选项
7 if (argc > 1 && argv[1][0] == '-' && argv[1][1] == 'n')
8 {
9 nflg++;
10 argc--; //减去argc中-n选项
11 argv++; //现在的argc指向-n后的第一个参数
12 }
13 for (int i = 1; i < argc; i++)
14 {
15 fputs (argv, stdout);
16 //这个if语句是为了恢复echo后面的参数之间的空格
17 if (i < argc - 1) //(argc-1)是防止在最后一个参数后加空格
18 putchar (' ');
19 }
20 if (nflg == 0)
21 putchar ('\n');
22 exit(0);
23 }
////////////////////////////////////////////////////////////////////////////////////////////
在我们开始继续讨论下去前,先看一下main函数的两个参数。这两个参数是系统传递给main函数的,argc是命令行的参数个数,这些参数是以空格分开的。(注意:这个参数个数包括命令本身。比如:echo A B C 这一行命令使得argc的值是4)argv是指向命令行参数的指针,与argc相同argv也是包括本来的命令。(比如说:echo A B C,argv[0]是指向echo的,argv[1]是指向A的)了解了main程序的参数的作用然后就可以看一下整个程序的流程,从7-12行是用一个if语句来探测命令行是否给出-n选项,若探测到则给nflg加1再从argc和argv两个参数中去除-n。如果命令行给出-n选项,是让rcho程序不要在最后添加换行符'\n',这可以从20-22行看出来。如果nflg不是0的话,就跳过了putchar('\n');语句。我们再来看13-19行这个for语句,其中调用了fputs()函数,其原型是:
int fputs(const char *s, FILE *stream);
作用:把s指向的字符串写到stream指向的流,除了s指向的字符串最后的'\0'字符。
在echo程序调用这个函数是把argv指向的字符串写到stdout流中。stdout是标准输出流,在一个Unix程序运行时系统会默认打开三个文件,stdin.stdout,stderr这三个文件代表标准输入,标准输出,标准出错。一般情况下stdout是计算机的屏幕,当然也可以重新定向到其他设备。17和18行是比较有趣的,这两行保证了把echo后面的所有参数打印出来的同时给每个参数后面加一个空格。(除了最好一个参数)从这两行代码来看我们可以得出一个结论:echo并不是把它后面的内容原封不动的输出,而是用空格把每个字符串分开。比如:
echo A B C
输出的是:
A B C
可以看到在B和C之间的6个空格变成了一个空格输出。这个小小的细节我在使用echo的时候也没有发现,还是在阅读它的源码的时候才发现的。(我这么粗心大意真是不应该啊!)
这23行程序就可以实现echo最基本的功能了,而我们现在在多数Unix版本(当然包括Linux)上使用的echo比这个要复杂,选项命令也比这个版本多,但是实现的原理是一样的。你如果有兴趣就可以去看看GNU版的echo了,其实是没有必要再去阅读GNU版的了,我们继续下一个命令的分析吧。
第二个看看cat命令的源码cat.c
///////////////////////////////////////////////////////////////////////////////////////////
/**************************************
* UnixV7中的cat程序。 *
* 注解:wsw 2006年5月27日 *
* *
**************************************/
1 #include <stdio.h>
2 #include <sys/types.h>
3 #include <sys/stat.h>
4 #include <unistd.h>
5 char stdbuf[BUFSIZ]; //BUFSIZ是stdio.h中定义的常量。
6 int
7 main (int argc, char *argv[])
8 {
9 setbuf (stdout, stdbuf);
10 for (; argc > 1 && argv[1][0] == '-'; argc--, argv++)
11 {
12 switch (argv[1][1])
13 {
14 case 0:
15 break;
16 case 'u':
17 setbuf (stdout, (char *) NULL); //取消缓存
18 continue; //继续检查下一个选项。
19 }
20 break; //跳出参数检查循环
21 }
22 struct stat statb; //文件的属性结构
23 fstat (fileno (stdout), &statb);
24 int dev, ino = -1; //dev文件的属性,ino是文件的inode
25 if (!S_ISCHR (statb.st_mode) && !S_ISBLK (statb.st_mode))
26 {
27 dev = statb.st_dev; //当文件不是快设备也不是字符设备特殊文件时,
28 ino = statb.st_ino; //对dev和ino赋值。
29 }
30 int fflg = 0;
31 if (argc < 2) //cat后面没有参数
32 {
33 argc = 2; //这里对argc的赋值是为了下面语句中的--argc的操作
34 fflg++;
35 }
36 while (--argc > 0)
37 {
38 register FILE *fi; //输入文件指针
39 if (fflg || (*++argv)[0] == '-' && (*argv)[1] == '\0')
40 fi = stdin;
41 else
42 {
43 if ((fi = fopen (*argv, "r")) == NULL)
44 {
45 fprintf (stderr, "cat: can't open %s\n", *argv);
46 continue; //这条continue语句是为了当打开多个文件其中某个出错时,还能继续尝试打开下一个。
47 }
48 }
49 fstat (fileno (fi), &statb);
50 if (statb.st_dev == dev && statb.st_ino == ino) //防止输入输出文件是同一个文件
51 {
52 fprintf (stderr, "cat: input %s is output\n", fflg ? "-" : *argv);
53 fclose (fi);
54 continue; //这个语句的作用与46行的一样。
55 }
56 register char c;
57 while ((c = getc (fi)) != EOF)
58 putchar (c);
59 if (fi != stdin) //stdin文件在本进程结束时,系统会自动关闭,并不需要人为关闭。
60 fclose (fi); //关闭输入文件。
61 }
62 return (0);
63 }
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////
这个源代码稍微有点长,不要心急,我们慢慢分析。其实分析比较复杂的程序的最好方法并不是从代码的第一行一直读到最后一行,我们可以模拟系统运行,来分析程序。比如,在命令行打入
cat
则程序开始运行,argc被设为1,argv[0]指向了cat。第9行是setbuf()函数在stdio.h中的原型是:
void setbuf(FILE *stream, char *buf);
这个函数是把stream指向的文件设置成全缓存的,如果buf指针为NULL则stream被设置为不用缓存。程序首先就用setbuf函数把标准输出设置为全缓存的,然后进行参数检查。注意第十行的for语句,与平时我们碰到的for有些不同,它没有初始化语句,中间的条件语句检查了第二个参数的第一个字符是否为'-',后面是一个逗号语句。逗号语句的执行是安顺序执行返回最后的语句的值。12-19的switch语句检查了两个参数,当命令行只是cat时,这段程序不起作用,目前先跳过去。23行有两个陌生的函数:
int fstat(int filedes, struct stat *buf); 得到文件的属性,filedes是文件描述符。
int fileno(FILE *stream); 返回stream指向的文件的文件描述符。
程序执行到23行statb储存了标准输出的文件属性,stat结构是:
struct stat
{
dev_t st_dev;
ino_t st_ino;
unsigned short st_mode; //现代系统中的st_mode是mode_t的类型。
short st_nlink;
short st_uid;
short st_gid;
dev_t st_rdev;
off_t st_size;
time_t st_atime;
time_t st_mtime;
time_t st_ctime;
};
这个stat结构是UnixV7上定义的结构,现代系统上定义的stat结构要复杂一点,但是现代系统都会兼容这个stat结构定义。25-29行测试了一下标准输出的属性,当它不是字符并且也不是块设备文件时,用dev和ino记录下标准输出的设备属性(dev)和inode值(ino)。现在程序中的argc值为1,在经过31-35行时,argc被设为2,fflg被设为1。这样就进入36行的while语句,argc自减成为1,在39行因为fflg为1,(这里要注意&& || 两个逻辑算符的优先级。)直接把fi设为了标准输入。49-55行的检查是防止输入输出的文件相同,现在的输入文件是标准输入,输出是标准输出,这样就顺利通过检查。57-58是该程序的核心,把从fi读到的字符输出到标准输出上。我们直接在命令行上打入cat,标准输入就是输入文件,所以你可以在命令行输入字符,但是按回车的时候cat并不会立即把你敲入的字符显示出来,以为程序中用了全部缓存,当用Ctrl+D结束输入文件时,cat才会把它接收到的字符一下子打印到标准输出上。如果用cat -u命令试试,在程序的10-21行会检测到这个-u参数,然后把标准输入设成无缓冲的。如果在cat后面加了一个文件名,那么在程序的43行会把这个文件以只读的形式打开,fi就指向这个文件了。如果加多个文件的名字,那么由while循环就会处理这多个文件,把它们的内容都打印到标准输出。
下面是cp的源代码,因为这个程序稍稍有点长,我们将采用在程序源码中做详细注释的方式来分析cp程序。
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/**************************************
UnixV7中的cp程序。
注解:wsw 2006年5月27日
使用方法:
cp oldfile newfile
**************************************/
#define BSIZE 512
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
struct stat stbuf1, stbuf2;
char iobuf[BSIZE];
//定义一个copy函数,from是指向要复制的文件名的指针,to是指向复制的文件名的指针
int
copy (char *from, char *to)
{
int fold, fnew, mode;
if ((fold = open (from,O_RDONLY )) < 0) //用from指向的文件名以只读方式打开
{
fprintf (stderr, "cp: cannot open %s\n", from);
return (1);
}
fstat (fold, &stbuf1); //取出fold文件的属性
mode = stbuf1.st_mode;
if (stat (to, &stbuf2) >= 0 && (S_ISDIR (stbuf2.st_mode))) //测试to指向的文件是不是目录
{
register char *p1, *p2, *bp;
p1 = from;
p2 = to;
bp = iobuf;
while (*bp++ = *p2++)
;
//这里操作的就是给iobuf指向的一片512字节的字符数组赋值。P2是指向目标文件名的指针,
//bp指向了512字节的字符数组的开头,while的空循环就是把目标文件名放入iobuf数组中。
bp[-1] = '/';
p2 = bp;
//注意这里的bp[-1]的操作,bp[-1]并不是一个数组脚标引用,这样的引用只是在脚本语言中合法,在C中的意思是*(bp-1)
//上面的while语句结束是因为p2自增到目标文件名的最后返回了一个Null值,导致(*bp++ = *p2++)的值为0了,所以
//while循环结束。这样的话,在iobuf的最后就有一个0值,所以要用*(bp-1)来指向这个0值,把它替换成'/'
//看一下iobuf的情况:
//--------------------------------------------------------
// 目标文件名 | / | |
//------------------|-------------------------------------
// ^
// bp 现在指向这个位置
while (*bp = *p1++)
if (*bp++ == '/') //如果bp遇到'/'字符,把bp还原,重新赋值。
bp = p2;
to = iobuf;
//这个while语句把源文件的文件名也赋到iobuf中,紧接在目标文件名的后面,两个文件名之间有个'/'字符。
//最后再把iobuf这个指向字符数组头的指针赋给to指针。现在的to就是指向了这个512字节的字符数组了。
//这里的操作的用意是让to指向真正的目标文件名。别忘了,我们现在还在if语句中,if语句测试了to指向的
//是不是目录,如果是目录,这段操作是把目标文件名加上这个目录名。
//-------------------------------------------------------
// 目标文件名 | / |源文件名 |
//--------|----------------------------------------------
// ^
// 这是个目录名
}
if (stat (to, &stbuf2) >= 0)
{
if (stbuf1.st_dev == stbuf2.st_dev && stbuf1.st_ino == stbuf2.st_ino) //测试一下两个参数是不是同一个文件
{
fprintf (stderr, "cp: cannot copy file to itself.\n");
return (1);
}
}
//创建新的文件,mode是前面程序获得的源文件的属性。
if ((fnew = creat (to, mode)) < 0)
{
fprintf (stderr, "cp: cannot create %s\n", to);
close (fold);
return (1);
}
int n; //从文件中读到的字节数
while (n = read (fold, iobuf, BSIZE)) //现在的iobuf指向的字符数组已经无用了,所以用来做一个IO缓冲。
{
if (n < 0) //探测IO是否出错
{
fprintf (stderr, "cp: read error\n");
close (fold);
close (fnew);
return (1);
}
else if (write (fnew, iobuf, n) != n) //把刚才读到的文件内容写入新文件。
{ //如果读到的字节数与n不相等,则说明出现IO错误。
fprintf (stderr, "cp: write error.\n");
close (fold);
close (fnew);
return (1);
}
}
close (fold);
close (fnew);
return (0);
}
//copy()函数到此结束了。
int
main (int argc, char *argv[])
{
register int r = 0;
//保证命令行参数要大于3个,也就是说传递给cp的参数至少要有两个。
if (argc < 3)
{
fprintf (stderr, "Usage: cp: f1 f2; or cp f1 ... fn d2\n");
exit (1);
}
if (argc > 3)
{
//这是当传递给cp的命令行参数多于两个时,检查一下最后一个参数是不是目录的文件名。
if ((stat (argv[argc - 1], &stbuf2) < 0) || !S_ISDIR (stbuf2.st_mode))
{
fprintf (stderr, "Usage: cp: f1 f2; or cp f1 ... fn d2\n");
exit (1);
}
}
for (int i = 1; i < argc - 1; i++)
//调用copy()函数来进行文件复制。r是一个返回给系统的值。
r |= copy (argv, argv[argc - 1]);
exit (r);
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
这个cp程序涉及到的几个函数:
ssize_t read(int fd, void *buf, size_t count);
read()函数从fd表示的打开文件中读出count字节个数据,把这些数据存入buf指向的内存空间中。
ssize_t write(int fd, const void *buf, size_t count);
write()函数把buf指向的数据写count字节到fd表示的打开文件中。
这些系统调用在Unix man手册中都用详细的介绍,如果有不清楚的地方可以去查询man手册。 |
|