发信人: gracewind (和风), 信区: FreeBSD
标 题: FreeBSD内核编程初步(转载)
发信站: BBS 水木清华站 (Mon Aug 26 10:56:34 2002), 站内信件
FreeBSD内核编程初步
王波 Wb@email.online.ha.cn
随着Unix/Linux在国内的流行,基于他们的开发也变得越来越重要,因而更多的人对更
深层次的开发也越来越有兴趣。这些兴趣就包括对核心的研究、学习、开发、定制等等
。
虽然Linux的流行程度最高,但FreeBSD作为另外一种别具特色的操作系统,在很多地方
有其特殊的优势,特别是在网络功能方面,其强大的功能使其足以满足很多网络服务的
要求。事实上, 国内很多网络设备,比如防火墙等等,也是基于这种系统开发的。这样
的例子在国外也有类似的例子,甚至更有说服力。因为当前第二大的骨干路由器厂商-j
uniper,其核心代码就是基于FreeBSD的,当然他们使用了特殊的处理芯片,以加速数据
处理速度。事实上,如果有足够的硬件设计能力,加上FreeBSD的核心开发能力,设计和
实现一个超级路由器、防火墙等等网络设备,并不是一个遥不可及的事情,当然这样做
的基础就必然需要拥有核心开发能力。
要学习FreeBSD内核编程,第一步就是要学习FreeBSD操作系统本身。在这些方面,有不
少书籍和资料能提供帮助,其中最为著名的是那本《Unix设计和实现》,这本书是BSD
Unix的设计者撰写的,由于FreeBSD是标准的BSD实现,因此使用FreeBSD来学习Unix其实
是顺理成章的事情,事实上,甚至Berkeley的操作系统课程也是使用FreeBSD作为教学系
统的。
虽然,学习了这些书之后,一个程序员应该能够完成一些系统级的编程工作,但是,毕
竟这些书只是讲述基本的原理,并没有一步一步的、手把手教给你如何定制系统内核,
因此,如何完成内核编程第一步也许还需要一些尝试和研究。FreeBSD毕竟是一个实际的
操作系统,而针对内核编程也不是一个简单的任务。这样,很多人都希望能得到一些进
一步的提示,以节约时间和经历。然而,这方面的文档就并不是十分丰富了。
我记得,某一个著名的Unix程序员说过,“Unix有很好的文档,但这些文档是用C写的”
,显然,从源代码中学习,是必然的,也是应该的一个过程。作为一个完整的操作系统
,其代码的复杂程度可想而知,因此,这个学习任务并不轻松,尤其在起步的时候。因
此在这里,我打算介绍一些最初步的内容,以一种特殊的方式来和对FreeBSD系统内核有
兴趣,但还没有开始系统内核编程实践的朋友进行交流。对于已经开始内核编程实践的
朋友,也许就可以忽略这篇文章,而真正从源代码出发,进行更深入的研究了。
学习内核编程有很多种起步方式,一种比较正规的方式是首先对源代码本身进行整体的
学习,例如购买一本源代码分析的书,并仔细阅读,另一种方式在一开始并不下很大的
工夫去研究所有的代码,而是基于兴趣基础,首先写一点点内核方面的东西,然后根据
兴趣可以逐步加深研究,直到掌握系统内核的某一个方面。当然,这两个方向并不是截
然对立的,可能在学习整体代码的时候,就打算进行实践,也可能在编写实际代码的时
候,由于需要,而在整体上对代码进行多次学习。
1.第一个内核模块
如果要打算实际编写核心代码,有一点是非常重要的,因为改写内核之后,系统需要重
新启动才能生效,每次改动都需要一次重新启动才能看到改动的结果,这样就导致核心
编程的交互性很差。然而,内核模块的方式能够解决这个问题,能够在不改动整体内核
的基础上,将代码载入内核,因此,编写核心代码的最容易的方法应该是从内核模块开
始,这样一来,可以马上看到定制内核的结果。因此,第一步,我们学习如何编写一个
内核模块,并如何载入内核和从内核中卸载下来。
编写内核倒也没有必要完全从头来过,当前很多程序员的“copy paste”大法对于初步
学习来讲,还是有一定的意义的。由于FreeBSD本身已经提供了全部代码,因此,可以从
一个现成内核模块的例子出发,进行学习。当然,实际的内核模块毕竟要完成很多具体
的工作,因此对于学习目的反而并不是十分合适,最好是使用最简单的演示目的的模块
。然而,甚至这个工作也可以省略,因为FreeBSD系统已经给出了一些最简单的例子,用
来对学习内核编程的人提供帮助。这里就是最简单的一个:/usr/share/examples/kld/
syscall。
因此,第一步就是先进行实践,内核模块的目录位于一个子目录module中。
cd module
make
然后可以将其载入内核,
make load
我们可以看看这些需要执行什么操作,Makefile的内容包括:
# Makefile for building the sample syscall moduleSRCS = syscall.cKMOD = sysc
allKO = ${KMOD}.koKLDMOD = tKLDLOAD = /sbin/kldloadKLDUNLOAD = /sbin/kldunlo
adload: ${KO} ${KLDLOAD} -v ./${KO}unload: ${KO} ${KLDUNLOAD} -v -n ${KO}
.include
可以看出,make load 和make unload 使用系统命令kldload, 和 kldunload 执行载入
和卸载任务。而Makefile包括了系统的一个宏定义文件bsd.kmod.mk,这里定义了编译内
核模块的最基本内容,例如使用SRCS、KMOD的定义去编译内核。
现在让我们看看这个模块本身的代码,这里忽略了许可文字,包含文件和原始文件的注
释。
static int
hello (struct proc *p, void *arg)
{
printf ("hello kernel\n");
return 0;
}
static struct sysent hello_sysent = {
0, /* sy_narg */
hello /* sy_call */
};
static int offset = NO_SYSCALL;
static int
load (struct module *module, int cmd, void *arg)
{
int error = 0;
switch (cmd) {
case MOD_LOAD :
printf ("syscall loaded at %d\n", offset);
break;
case MOD_UNLOAD :
printf ("syscall unloaded from %d\n", offset);
break;
default :
error = EINVAL;
break;
}
return error;
}
SYSCALL_MODULE(syscall, &offset, &hello_sysent, load, NULL);
在这个内核模块程序中,函数hello是真实的处理函数,它有两个参数,一个为调用它的
用户进程,另一个为调用它使用的函数参数。而结构hello_sysent则为用于表示系统调
用的数据结构,两个成员第一个为该系统调用的参数数量,另一个为系统调用的处理函
数的指针,这里直接使用了前面定义的函数hello。第三部分定义了一个整数,这用于保
存系统调用在系统调用表中的位置,当系统调用没有位于系统调用表中的时候,用户进
程就无法调用该系统调用。函数一个部分为模块装载和卸载处理函数,当使用kldload或
kldunload载入或卸载模块的时候,就执行该函数的相应部分。最后一部分SYSCALL_MOD
ULE为系统的一个宏定义,用来声明一个系统调用,这用来简化模块编写的任务。
我们必须注意到,虽然这个内核程序也使用了printf等标准函数,但是这些函数是特别
的内核版本,事实上内核编程的时候,可使用函数是非常有限的,只有标准的C库函数,
通常被称为libc中的函数,才能使用,如果一个函数需要连接额外的库,在内核编程中
必然无法使用。即使标准的函数,用法上也略微有些差别,例如printf就只能在控制台
上输出,因此如果用户是使用远程连接的方式尝试这些例子的话,就看不到结果,因为
结果仍然输出到控制台上。此时,需要uprintf来进行输出,它能输出数据到用户直接连
接的终端上。
我们还必须注意到,几乎所有的变量和函数都被声明为static,这是因为内核包含相当
多的代码,难免会出现名字冲突的现象,而声明static则可以限制名字的作用域为单个
文件。
可以看看用户的程序如何和这个刚刚载入内核的模块通信,用户程序在另一个子目录中
。
cd test
Make
./call
这个调用程序的执行过程是非常简单的,而代码本身也几乎同样简单。
intmain(int argc, char **argv){ char *endptr; int syscall_num;
struct module_stat stat;
stat.version = sizeof(stat);
modstat(modfind("syscall"), &stat);
syscall_num = stat.data.intval;
return syscall (syscall_num);
}
该程序使用modfind去查找相关的模块,其中syscall是模块中使用SYSCALL_MODULE声明
的名字。并使用modstat获得相关数据,然后就可以使用syscall去调用它们了。
从这个例子可以看出,内核编程并不是高不可攀的,其实任何一个熟悉C语言的使用者,
只要按照这些规范去做,都可以做到。但同时,编写一个能用于处理真实事件,并非常
稳定的内核模块,却不会这么简单了。
2. 访问内核
第一个内核模块只是出于演示目的,因为它除了打印一些字符串之外,实际上什么也没
有作。要想让一个内核模块完成一定的任务,那么它应该具备更复杂的处理能力,例如
根据用户提供的参数,作出不同的反应等等。这样一来就涉及到了参数传递的问题。
然而,用户程序调用内核模块中的函数的传递参数却不那么简单,因为一个函数的参数
的数量是不同的,而系统调用中的参数的数量却固定为两个参数,即上面提到的两个指
针,一个为指向调用这个系统调用的用户程序的进程,另一个才与用户程序的参数有关
。因此,事实上第二个参数是指向一个与参数和系统调用返回值相关的一个数据结构。
因此,基于上面的第一个例子,可以添加另一个系统调用,得到一个支持一个参数的系
统内核模块。将下面代码添加到syscall.c中的合适位置就可以了。
struct hello_args { char * name; int returnvalue;
};
static char hello2_buf[100] = "";
static int
hello2 (struct proc *p, struct hello_args *arg)
{
if ( ! arg->name ) {
arg->returnvalue = -1;
return 1;
}
printf ("hello kernel, %s : %s\n", arg->name, hello2_buf);
strcpy( hello2_buf , arg->name );
arg->returnvalue = 0;
return 0;
}
static struct sysent hello2_sysent = {
1, /* sy_narg */
hello2 /* sy_call */
};
static int hello2_offset = NO_SYSCALL;
SYSCALL_MODULE(syscall2, &hello2_offset, &hello2_sysent, load, NULL);
这个新添加的系统调用需要从用户的程序中接收参数并返回值,因此就首先声明了一个
数据结构hello_args,第一个参数为一个字符串指针,第二个参数为一个整数,是系统
调用的返回值,这些成员都应该是32位长的变量,因此类型都是指针、整数等等。同时
,表示系统调用的结构变量hello2_sysent也必须指明它支持一个参数。
相应的用户端用于调用这些系统调用的程序为:
intmain(int argc, char **argv){
char *endptr;
int syscall_num, returnvalue;
struct module_stat stat;
stat.version = sizeof(stat);
modstat(modfind("syscall"), &stat);
syscall_num = stat.data.intval;
syscall (syscall_num);
modstat(modfind("syscall2"), &stat);
syscall_num = stat.data.intval;
returnvalue = syscall (syscall_num, "test");
printf("return value is %d\n", returnvalue);
returnvalue = syscall (syscall_num, (void *)0);
printf("return value is %d\n", returnvalue);
}
上面的例子中,以一个字符串指针作为参数,并观察不同的参数给出的不同返回值。
需要指出的是,这个模块本身有着很大的buffer overflow问题,因为这里使用了strcp
y。同样,这里的strcpy和标准的strcpy也不相同,也是相应的内核版本,但是,strcp
y是一个一个字符的复制,显然效率不高,一般高性能的复制函数为bcopy、memcpy等,
但是由于内核模块中是在内核和用户程序之间复制数据,这些函数无法使用,通常内核
程序通常使用copyin、copyout的内核调用在内核和用户程序之间复制数据。
然而,即使能够和系统调用之间传递参数,但是对于具体应用来讲还是没有意义的,因
为如果只是完成普通任务,采用普通的库函数就可以了,还不必进行系统的上下文切换
,没有必要一定要把它放在内核中,放在内核中的意义是能够直接访问内核中的数据,
能够直接访问系统硬件,等等,这些才是系统内核编程的意义。
这里就是一个使用内核模块访问计算机的CMOS数据的例子,这里使用了前面例子中的he
llo2处理函数,而其他部分几乎不需要任何改动。
static inthello2 (struct proc *p, struct hello_args *arg)
{
int i;
if ( ! arg->name ) {
arg->returnvalue = -1;
return 1;
}
for(i=0;i<64;i++) {
outb(0x70, i);
arg->name[i]=inb(0x71);
}
arg->returnvalue = 0;
return 0;
}
同样,需要改动调用程序,以调用这段代码。
intmain(int argc, char **argv)
{
char *endptr;
unsigned char cmos[256];
int i;
int syscall_num, returnvalue;
struct module_stat stat;
stat.version = sizeof(stat);
modstat(modfind("syscall2"), &stat);
syscall_num = stat.data.intval;
syscall (syscall_num, cmos);
for ( i=0; i<64; i++ ) {
printf("%x ", (int)cmos[i]);
}
}
显然,这只是一个非常简单的例子,因为访问CMOS数据本身就是非常简单,又不需要特
别的性能,复杂的硬件访问就可能涉及一些汇编代码,并需要设计为设备文件的方式,
以便于用户进程访问。
事实上,对于这些比较简单的访问硬件的情况,不需要涉及内核编程,用户进程可以在
一定条件下直接访问硬件端口,一般的情况下就是需要打开设备文件/dev/io。下面的例
子就是一个直接访问CMOS数据的程序,这比起使用内核的方式要简单一些,但是存在性
能方面的问题,因为此时每进行一次端口操作,就有可能进行一次内核和用户的上下文
切换,而系统调用方式只需要进行一次上下文切换。此外,对于复杂的硬件,还应使用
统一的访问界面,通常情况下是使用设备文件,以简化应用程序的处理,因此下面程序
的这种方式只适合处理比如访问CMOS数据这样的简单情况。
#include #include #include
main(){ if ( open("/dev/io", O_RDONLY ) ) { int i, v[64];
for(i=0;i<64;i++) {
outb(0x70, i);
v[i]=inb(0x71);
printf("%x ", v[i] );
}
}
}
3. 标准系统调用
通过内核模块,就可以完成添加内核调用的目的,这些内核调用就可以用于和硬件通信
、访问内核数据的特殊目的。事实上,标准的系统调用,如open、read等等,也是使用
同样的方式,来完成用户进程与内核交互的目的,只不过这些代码是缺省已经位于系统
内部了。
查看文件/sys/kern/init_sysent.c,从变量sysent这个表中可以了解到当前系统中存在
的系统调用,而sysent这个数组的类别就是struct sysent,和前面程序中每个系统调用
使用的系统调用数据结构是相同的。
上面我们例子的结果,通常模块的系统调用数字为210,可以看出,这个数字,在标准系
统调用的表中是空白项lkmnosys。可以推想整个过程是,模块载入程序从系统调用的表
中找到的lkmnosys的空白项,然后将模块中的相应数据填入该空白项,此后用户进程就
可以使用对应的序号来调用这些系统调用了。
因此,完全可以使用对应的数字序号来调用标准系统调用,下面就是一个简单的例子。
#include
#include
#include
int
main(int argc, char **argv)
{
char buf[256];
syscall(SYS_getlogin, buf, 256 );
printf(“login name is %s \n”, buf );
}
上面这个例子就是使用系统调用getlogin的另一种方式,看起来麻烦一些,但的确有效
。
既然,系统调用其实主要是根据序号进行的,那么如果替换了标准的系统调用,是否是
可行的呢?如果读者对DOS下的TSR程序比较熟悉的话,应该对这个概念没有什么疑问的
。毫无疑问,这种方法可以用来扩展系统调用的功能,当然,用的不好,也会造成问题
,比如影响标准系统调用的功能。
因此,可以简单的编写一个模块,可以用来拦截系统调用。
extern int getlogin();
static intngetlogin (struct proc *p, void *arg)
{
printf ("at first enter new getlogin\n");
return getlogin(p, arg );
}
static struct sysent ngetlogin_sysent = {
2,
ngetlogin
};
static struct sysent saved_sysent;
static int offset = NO_SYSCALL;
static int
load (struct module *module, int cmd, void *arg)
{
int error = 0;
switch (cmd) {
case MOD_LOAD :
printf ("syscall loaded at %d\n", offset);
saved_sysent = sysent[SYS_getlogin] ;
sysent[SYS_getlogin] = ngetlogin_sysent ;
break;
case MOD_UNLOAD :
printf ("syscall unloaded from %d\n", offset);
sysent[SYS_getlogin] = saved_sysent;
break;
default :
error = EINVAL;
break;
}
return error;
}
SYSCALL_MODULE(ngetlogin, &offset, &ngetlogin_sysent, load, NULL);
这样,当系统调用getlogin来获得登陆用户的名字的时候,必然首先进入新的函数nget
login,程序员就可以添加新的限制或新的功能。这种方式在很多情况下很有用,例如我
们可以利用这些功能提供额外的访问控制,实现安全BSD操作系统等等
当然,这也对制造特洛伊木马形成可能。因此,除非完全信任,千万不可以随意载入不
信任的内核模块。
拦截系统调用的用法虽然很有用,但如果打算实现一些与这些系统调用无关的其他内核
功能,可能就达不到目的了,例如访问系统内核的防火墙功能。FreeBSD系统提供了ipf
w和ipfilter两种防火墙系统,尤其ipfilter由于内建nat功能,因此得到了很多人的青
睐。事实上,当前国内的很多商业防火墙系统就是基于ipfilter来实现的。然而,这里
不得不提的一件事情是,就在几个月之前,ipfilter的作者和OpenBSD的开发者关于ipf
ilter的版权问题产生了冲突,以至于OpenBSD将ipfilter从其标准系统中删除。这个问
题虽然没有影响到FreeBSD,但是了解如何编写一个防火墙系统,也是一件有意思的事情
。
extern int (*fr_checkp) __P((struct ip *, int, struct ifnet *, int, struct m
buf **)) ;
static int (*saved_fr_checkp) __P((struct ip *, int, struct ifnet *, int, st
ruct mbuf **)) ;
static int filter_count = 0;
int sfilter __P((struct ip *ip, int len, struct ifnet * ifn, int i, struct m
buf ** buf))
{
uprintf("ip packet from %s ", inet_ntoa(ip->ip_src));
uprintf(" to %s ", inet_ntoa(ip->ip_dst));
if ( filter_count++ % 2 ) {
uprintf(" Droped\n");
return 1;
}
else {
uprintf(" Passed\n");
return 0;
}
}
static int
sfilter_unload (struct proc *p, void * arg)
{
fr_checkp = saved_fr_checkp;
return 0;
}
static struct sysent sfilter_sysent = {
0, /* sy_narg */
sfilter_unload /* sy_call */
};
static int offset = NO_SYSCALL;
static int
load (struct module *module, int cmd, void *arg)
{
int error = 0;
switch (cmd) {
case MOD_LOAD :
saved_fr_checkp = fr_checkp ;
printf ("sfilter loaded at %d, filter %p\n", offset, fr_checkp);
fr_checkp = sfilter;
break;
case MOD_UNLOAD :
printf ("sfilter unloaded from %d\n", offset);
fr_checkp = saved_fr_checkp;
break;
default :
error = EINVAL;
break;
}
return error;
}
SYSCALL_MODULE(sfilter_unload, &offset, &sfilter_sysent, load, NULL);
上面的这个模块只是用来演示一下编写防火墙的一般性的东西,从过滤函数sfilter中可
以看出,它只是简单的丢弃一半的数据包,当然真正的防火墙可以根据地址、状态丢弃
,甚至改变数据包的内容,这就不是这个演示性的模块能够完成的任务了。
而这个模块一载入,就将sfilter赋给fr_checkp,fr_checkp实际是系统内核中的一个函
数指针,系统使用它来判断一个IP数据包是应该丢弃,还是应该通过。实际上,fr_che
ckp是ipfilter的一个入口,因此载入这个模块,ipfilter就无法发挥作用了。因此,要
同时使用ipfilter和自己定制的防火墙功能,就需要在系统代码中添加自己的入口。简
单的提示一下,这个入口应该位于/sys/netinet/ip_input.c中。4. 设备文件和sysctl
虽然采用系统调用的方法,用户的程序和内核可以自由的交换数据,但是这种方法还是
有着种种问题,例如系统调用的数量有限制,受系统内核调用表的大小限制,此外,系
统调用的方法在处理大量数据的时候也比较麻烦,而且只能用于提供这种接口的C程序,
对于不够强大的其他编程语言,就无法访问非标准的系统调用。
事实上,对于有着数据需要在内核和用户进程之间传递的时候,Unix提供了一种标准的
方法,就是抽象为文件系统的读写操作。这样,不仅编程方式比较标准,此外,通过设
备文件,可以提供一种标准的访问系统内核的方式,并不局限于C程序。
此外,FreeBSD也为内核和用户通信提供了一种更容易的方式,称为sysctl,用户可以通
过命令sysctl去查看内核中的变量,进而可以更改相应的值。这种方式适合少量的数据
通信,例如一些内核的设置变量等等,而设备文件适合大量的数据通信。
FreeBSD也在/usr/share/examples/kld/dynsysctl和/usr/share/examples/kld/cdev中
提供了最简单的sysctl和设备文件的实例,有兴趣的朋友可以依据这些例子,边改动,
边学习,详细解释内容已经不是这篇短短的文章所能容纳的了。
--
^..^ ^..^ ^..^ ^..^ ^..^ ^□^ ^..^
(oo) ( oo ) (OO) (oo ) (@@) (oo) (00)
猪 肥猪 澎恰恰型猪 牙疼猪 台湾土猪 睡猪 黑暗中的猪 仰泳猪
^..^ ^qp^ ^cc^ ^@@^
(qp) (oo) (oo) (oo)
感冒猪 哭泣猪 斜视猪 近视猪
※ 来源:·BBS 水木清华站 smth.edu.cn·[FROM: 210.28.216.9]
阅读(5002) | 评论(0) | 转发(0) |