在Linux下实现comer的TCP/IP协议栈——缓冲池管理和信号灯控制

时间:2023-01-26 10:35:02

CHAPTER3:

一、简介:

人啊,不该偷懒的时候还真不能偷懒。最先开始移植协议栈的时候,我为了方便,把comer中所有向缓冲池申请内存的地方改成了用malloc分配,认为这样简单。但越到后来越觉得这种不规范的操作带来了很多不便——内存的分配是散乱的,没有一个统一的管理机制。另外就是信号灯控制。Comer中很多地方用了signalwait函数做信号灯控制,我总是在需要的时候创建一个linux信号灯,结果程序里到处都是semget函数,很不方便。为了让程序看上去更规范一些,我重写了xinu下的缓冲池管理函数和信号灯控制函数,把它们都封装到一个c文件里,这样,所有的操作都用同一组函数实现,简单明了,而且对comer协议栈的改动更小了,让同样再跟comerTCP/IP的朋友能看的更明白一些。以后,凡是用到了xinu系统函数的地方,我都会尽量把它重新在linux下实现一遍(当然,写不出来就只有找办法代替了),并单独写成一篇文章。这样,对comer中调用系统函数不感兴趣的朋友可以跳过这些章节,专心协议栈的实现。

 

二、源码:

1、 缓冲池管理:

首先,要搞清楚缓冲池和缓冲区的区别。缓冲池是一个管理机制(或数据结构),用于管理缓冲区。缓冲区是一个被缓冲池管理的内存区域链表。comer中缓冲池的数据结构、常量、以及函数声明都放在了bufpool.h文件中。其中struct bpool是缓冲池的数据结构,最为重要。结构如下:

struct      bpool      {                   /* Description of a single pool       */

       int    bpsize;                  /* size of buffers in this pool */

       int    bpmaxused;           /* max ever in use         */

       int    bptotal;           /* # buffers this pool            */

       char *bpnext;         /* pointer to next free buffer  */

       int    bpsem;                  /* semaphore that counts buffers*/

       };   

字段的注释都很清楚,要注意的是bpmaxused字段是表示曾经有多少个缓冲区被使用,而不是正在被使用的缓冲区个数。Bpnext是指向空闲缓冲区的指针。一个缓冲池被划分成了bptotal个缓冲区。每个缓冲区的头4个字节存放的是下一个缓冲区首地址的指针值。整个缓冲池操作其实就是个链表操作,为了直观的表明缓冲池的结构以及缓冲池是如何管理缓冲区的,下面用图进行描述:

在Linux下实现comer的TCP/IP协议栈——缓冲池管理和信号灯控制

从图中可以看出,缓冲池构成了一个单向链表,其中指向链表下一表项(下一个缓冲区)的指针值存在了缓冲区的头4个字节中。Bpool结构对缓冲池的管理可以用下图表示:

在Linux下实现comer的TCP/IP协议栈——缓冲池管理和信号灯控制

从上图可以看到,bufpool结构对缓冲池的管理实际上是通过移动bpnext指针来进行的。举例来说,当缓冲区1被程序用getbuf函数申请时,函数将缓冲区14个字节中存放的值取出来,赋给bpnext指针,然后把缓冲区1的首地址返回给调用者,供其使用。这时,bpnext便指向了缓冲区2的首地址。缓冲区2被申请时,bpnext又经过同样的操作指向了缓冲区3的首地址,以此类推,直到bpnext指向0,也就是空指针,则缓冲池被申请完了,后来的申请操作将被信号灯阻塞。

Getbuf在将缓冲区头4个字节中的地址值赋给bpnext指针的同时,也将当前缓冲池的标号(这个标号是当前缓冲池在缓冲池数组里的序号,bptable是缓冲池数组,共有5个元素)存入了这4个字节。这是为了freebuf函数在释放缓冲区时,能根据该标号将缓冲区返还给相应的缓冲池。释放的过程比较简单,仅仅是根据被释放缓冲区头4个字节中存放的缓冲池标号,找到对应的缓冲池(bufpool),接着将bpnext指针的值存入这4个字节,最后将bpnext指针指向被释放缓冲区的首地址。从整个过程可以看出,各缓冲区在缓冲池的中的位置不是固定的,但这无关紧要,毕竟我们并不要求对缓冲池进行检索操作。

好了,我们已经知道怎么向缓冲池申请缓冲区以及如何释放缓冲区,现在来看看缓冲池是如何被创建的。首先,mkpool函数根据传入的参数确定要分配多少个缓冲区(numbufs参数)以及每个缓冲区的大小(bufsiz参数)。然后根据一系列判断条件,确定要申请的缓冲池是否合理,然后使用了malloc函数分配出整块内存区域(malloc((bufsiz + sizeof(int) * numbufs)),接着将该内存区域划分成numbufs块,在每块的头4个字节中存入下一块的首地址(最后一块存的是0),最后将bufpoolbpnext指针指向第一块缓冲区的首地址,缓冲池就分配完成了。在使用getbuf函数时,bpnext指针会不断移动,我们就能使用缓冲池中的所有缓冲区。

缓冲池的管理大概就是这样,也许前面的叙述比较拗口,你已经看晕了。不要怕,结合后面的源码和注释,再回过头来理解一下,很快就能弄懂。

 

/* zbufpool.c - 缓冲池管理函数(2006.4.19)*/

 

//#include <conf.h>

#include <kernel.h>

#include <bufpool.h>

#include <pthread.h>

#include <zsem.h>

 

pthread_mutex_t bufpool_mutex;

Bool is_bufpool_inited = FALSE;

struct bpool bptab[NBPOOLS];

int nbpools;

 

//初始化缓冲池

poolinit()

{

         nbpools = 0;

         is_bufpool_inited = TRUE;

         return OK;

}

 

//申请一个缓冲池

//numbufs 参数决定要分配的缓冲区块数

//bufsiz表示每块缓冲区的大小

int mkpool(int bufsiz,int numbufs)

{

         int poolid;

         char *where;

 

         if (!is_bufpool_inited)

                   poolinit();

        

         pthread_mutex_lock(&bufpool_mutex);

         if (bufsiz < BPMINB || bufsiz > BPMAXB || numbufs < 1

             || numbufs > BPMAXN || nbpools >= NBPOOLS

                   //这里的malloc操作为每个缓冲区多分配了sizeof(int)字节的空间,目的就是用来

                   //存入下一个缓冲区的首地址值

             || (where = (char *) malloc((bufsiz + sizeof(int)) * numbufs)) == NULL)

                   {

                            pthread_mutex_unlock(&bufpool_mutex);

                            return SYSERR;

                   }

         poolid = nbpools++;

         bptab[poolid].bptotal = numbufs;

         bptab[poolid].bpmaxused = 0;

         bptab[poolid].bpnext = where;

         bptab[poolid].bpsize = bufsiz;

         bptab[poolid].bpsem = create_sem(numbufs);

         bufsiz += sizeof(int);

         //这里每个被分配的内存区域头4 个字节是用于存放

         //下一块空闲缓冲区的头指针的。

         //通过一个循环将缓冲池的所有缓冲区串成一个链表

         for (numbufs--;numbufs > 0;numbufs--,where += bufsiz)

                   *((int *)where) = (int)(where + bufsiz);

         pthread_mutex_unlock(&bufpool_mutex);

        

         return poolid;

}

 

//以阻塞方式在缓冲池中申请一块缓冲区

//参数是缓冲池的标号,表示在poolid号缓冲池中申请一块缓冲区

void *getbuf(unsigned poolid)

{

         int *buf,inuse;

         if (!is_bufpool_inited || poolid >=nbpools)

                   return ((void *)SYSERR);

 

         wait_sem(bptab[poolid].bpsem);

         //曾经被使用过的最大缓冲区数量

         inuse = bptab[poolid].bptotal - scount_sem(bptab[poolid].bpsem);

         if (inuse > bptab[poolid].bpmaxused)

                   bptab[poolid].bpmaxused = inuse;

        

         pthread_mutex_lock(&bufpool_mutex);

         buf = (int *)bptab[poolid].bpnext;

         //这里把缓冲池表项指向下一块空闲缓冲区的指针指向了

         //当前缓冲区头4 个字节里存放的指针值

         //注意,每块缓冲区的头4 个字节存放的是指向下一块

         //缓冲区的指针值

         bptab[poolid].bpnext = (char *)(*buf);

         pthread_mutex_unlock(&bufpool_mutex);

         //把当前缓冲区头4 个字节里存放的指向下一块缓冲区的

         //指针值替换成了当前缓冲区的标号(为了freebuf 释放的时候找到对应的缓冲池),然后将指针向后移动

         // 4 个字节(buf int 型,自加1 相当于移动4 个字节)

         //于是,buf 指针便指向了数据区

         *(buf++)=poolid;

         return ((void *)buf);                   

        

}

 

//释放缓冲区,参数是缓冲区的首地址

//注意,释放操作并没有删除缓冲区中的值

int freebuf(void *buf0)

{

         int *buf = (int *)buf0;

         int poolid;

 

         if (!is_bufpool_inited)

                   return SYSERR;

 

         //取出缓冲区头部存放的缓冲区标号

         poolid = *(--buf);

         if (poolid < 0|| poolid >=nbpools)

                   return SYSERR;

        

         pthread_mutex_lock(&bufpool_mutex);

         //重新把当前缓冲区的头4 个字节存为下一块

         //空闲缓冲区的头指针

         *buf = (int)bptab[poolid].bpnext;

         //把缓冲区重新接入缓冲池表项中

         bptab[poolid].bpnext = (char *)buf;

         pthread_mutex_lock(&bufpool_mutex);

         signal_sem(bptab[poolid].bpsem);

         return OK;

        

        

}

 

整个缓冲池结构的亮点在于bpsem字段(信号灯)。并不是所有的缓冲池结构都设置了信号灯(例如ucOS操作系统中的内存管理)。设置信号灯的好处在于当缓冲区被申请完后,新的申请操作会自动阻塞,直到有缓冲区被释放(这时,系统会自动为阻塞的申请操作返回缓冲区)。如果没有使用信号灯,那么在程序员不得不关注getbuf函数的返回值以及erron变量,以确定申请缓冲区失败的原因,并在一个延时后再次申请。这是非常繁琐的。当然,或许你会认为阻塞操作降低了程序的时实性,但可以想像的是,一旦getbuf操作发生阻塞,说明我们的内存已经耗尽了,在内存被释放之前,不应该进行其它操作。毕竟,没有人希望自己的程序不停的吞噬内存,直到整个系统挂起。

 

阅读bufpool.h文件,可以了解关于缓冲池的一些常量,例如总共的缓冲池个数(5),缓冲池中每块缓冲区的最大长度(2048)、最小长度(2)等等。这些对应了comer中的缓冲池分配方案——大缓冲区方案和小缓区池方案。说到这里,想必大家都想起comer中在申请缓冲池经常使用的操作getbuf(Net.netpool)getbuf(Net.lrgpool)。前者是申请小缓冲区,后者是申请大缓冲区。Net变量在net.h文件中声明,其结构是struct netinfo,用于管理系统中所有的网络接口。它的初始化在《网络接口》一文中已经给出,这里详细讲一下:

    Net.netpool = mkpool(MAXNETBUF, NETBUFS);

       Net.lrgpool = mkpool(MAXLRGBUF, LRGBUFS);

       Net.sema = create_sem(1);

       Net.nif = 2;    

从上面的代码可以看到,netpool字段为一个拥有64个缓冲区,每块缓冲区大小为1524(以太网数据长度 扩展以太网帧头长度)字节的缓冲池的标号,这是小缓冲区方案。Lrgpool字段为拥有16个缓冲区,每个缓冲区大小为2048字节的缓冲区标号,这是大缓冲区方案。当要发送的数据包长度大于1524时,就应该申请大缓冲区。当然,这里所谓的大也是个相对概念,如果要将缓冲区分配的足够容纳任何长度的数据包,那么根据协议规定一次得分配64K内存!很明显,这是不合理的。Sema字段是个信号灯,用于一些阻塞操作。Nif字段代表系统中网络接口的个数,这里我们把它设置成了2(一个本地伪接口,一个网卡接口)。好了,你已经明白comer中的缓冲池管理,在以后的代码中再也不会为getbuffreebuf这些系统调用困惑了。下面我们来看看信号灯控制。

 

PS:关于缓冲池管理有一个让我担忧的地方,就是里面的disable(关中断)操作。我在这里再次使用了互斥锁。很明显,这里的disable并不是为了控制对临界资源访问,而是保证我们下面代码的原子性(我对原子性的理解是:操作时cpu的时间片不会被让出去,也就是操作在完成前不会被打断)。但互斥锁却不能完成这个功能,它也会被中断。到现在我仍不明白如何在linux下保持操作的原子性,这也许会成为协议栈中的bug

 

2、信号灯控制

相对于缓冲池管理,信号灯控制就比较简单了。只是把linux的信号灯函数封装了一遍。在这里,我并不想讲解linux下的信号灯函数是如何使用的,毕竟这方面的文章太多了,有兴趣的朋友可以查阅相关资料。这里我把源码给出,源码的注释已经非常清楚了,大家稍微花点时间就能看懂。然后我会给出它们与comer中相对应的函数名,以后大家在comer中看到相关的函数就可以用这里封装的信号灯函数代替。

 

/* sem.c -信号灯控制函数(2006.4.18)*/

#include <linux/sem.h>

 

/* linux 信号灯设置部分*/

struct sembuf semwait,semsignal;

#define PERMS IPC_CREAT|IPC_NOWAIT

 

//信号灯操作方式初始化函数

//信号灯操作方式也就是semop 函数的第二个参数

void init_sem_struct(struct sembuf *sem,int semnum,int semop,int semflg)

{

      sem->sem_num = semnum;

      sem->sem_op = semop;

      sem->sem_flg = semflg;

}

//阻塞申请一个资源

void wait_sem(int mutex)

{

         semop(mutex,&semwait,1);

}

//释放一个资源

void signal_sem(int mutex)

{

         semop(mutex,&semsignal,1);

}

 

int create_sem(int num)

{

         int semid;

         union semun arg;

         /* semsignal是释放资源的操作(+1) ,SEM_UNDO在我们程序退出后由内核释放信号灯*/

       init_sem_struct(&semwait,0,-1,SEM_UNDO);

         /* semwait是要求资源的操作(-1) */

       init_sem_struct(&semsignal,0,1,SEM_UNDO);

 

         semid = semget(IPC_PRIVATE,1,PERMS);

         //创建后先分配num 个可用资源

         arg.val = num;

         semctl(semid,0,SETVAL,arg);

         return semid;

}

 

//删除信号灯

int del_sem(int semid)

{

     return semctl(semid,0,IPC_RMID);

}

 

//返回信号灯当前值

int scount_sem(int mutex)

{       

         union semun arg;

         return semctl(mutex,0,GETVAL,arg);

}

 

 

create_sem(int num)对应comer中的screate函数,用于创建一个信号灯。Num参数指定信号灯的初始值(表示有多少个可用资源)。

Wait_Sem(int mutex)对应wait函数,阻塞申请一个资源。Mutex参数表示信号灯的id

Signal_semint mutex)对应signal函数,释放一个资源。

Scount_sem(int mutex)对应scount函数,返回mutex指定的信号灯的值。

Del_sem(int mutex)对应sdelete函数,删除mutex指定的信号灯。

 

以上就是信号灯控制的全部,代码非常简单,就不多做解释了。

 

 

                                                                                           ——未完待续