在30天自制操作系统上编写网卡驱动[6]:用缓冲环和中断来接收数据


控制网卡接收数据

我们已经成功的把一个DHCP discover数组发送出去了,并且通过抓包软件wireshark也抓取到了我们发送出去的DHCP discover数组。

那么运行在路由器上的DHCP服务就会给我们返回一个数组回来,这个数组是回应我们发出去的DHCP discover数组的,这个数组是DHCP offer类型的。

那么问题来了,如何操作网卡,网卡才会接收到这个DHCP offer类型的数组呢?

前面几篇是控制网卡发送数据的内容:

如何在自制操作系统写网卡驱动程序(1)
在自制操作系统上写网卡驱动(2): 网卡的I/O配置
在30天自制操作系统上编写网卡驱动「3」:读取网卡的MAC地址
在30天自制操作系统上编写网卡驱动[4]:寄存器控制网卡收发数据
在30天自制操作系统上编写网卡驱动[5]:寄存器控制网卡收发数据

今天开始,就可以接收数据了。

其实网卡芯片会自动地接收网线上的一切网络数据,然后存储在网卡的内存里。随后就乖乖等着我们去读取这些数据了。

所以,我们直接从网卡的内存空间里读取数据,就相当于从网线上读取数据了。

FIFO 与接收缓冲环

那么问题来了:网线上只要有数据,就会进入网卡的内存,如果时间拉长为无限长,那么我们将得到一个无限长的有序数组,但是网卡的内存却是有限的,是无法存储这个无限长的有序数组的,对于此种情况,该怎么办呢?

这种情况就类似于咱们的鼠标的中断信号,键盘的中断信号。

我们采用了FIFO的思路,即First in First Out先入先出思路,建立了类似如下的一个FIFO32结构体。

struct FIFO32 {
	int *buf;
	int curr, bnry,size;
};
void fifo32_init(struct FIFO32 *fifo, int size, int *buf, struct TASK *task);
int fifo32_put(struct FIFO32 *fifo, int data);
int fifo32_get(struct FIFO32 *fifo);
int fifo32_status(struct FIFO32 *fifo);

这个结构体中有一个数组buf,它是有限长的。每次有新数据要存入buf的时候,就存入buf[cu rr],

然后把curr++. 当curr到达buf的末端时,就令curr=0,这样,要有新数据存入buf时,就存入buf[0]。

我们用fifo32_put函数实现往buf里放入新数据:

int fifo32_put(struct FIFO32 *fifo, int data)
{
  ...
	fifo->buf[fifo->curr] = data;
	fifo->curr++;
	if (fifo->curr == fifo->size) {
		fifo->curr = 0;
	}
  ...
	return 0;
}

因为每次把buf存满的时候,就会从buf[0]位置重新存储,这样的循环存储,就不怕要存入的数据有无限长了。

那有同学问:按重新存储,就会把原来buf[0]的位置覆盖掉了呢?

对,会被覆盖掉。所以,在buf[0]被覆盖掉之前,我们得把这个数据读出去。如果还没来得及读取,要设置一个标志bnry,让fifo32_put函数知道,当前这个位置的数据还没有被读出去,你还不能覆盖。

如果这个bnry=0,也就是说,如果fifo32_put函数,发现bnry=0,就不能把数据放在0位置了,因为bnry=0,就意味着,0位置的数据还没有被读出来。

bnry就是一个存放数据的边界了。

当我们把buf[0]读出来后,把bnry+1.这样,bnry=1就是数据存放的边界了,buf[0]的位置就可以存放新的数据了。

按照新的思路,一旦curr的值,与bury的值相等,就不能再存储数据了,所以要把fifo32_put函数改为如下形式:

int fifo32_put(struct FIFO32 *fifo, int data)
{
  ...
  if(fifo->curr != fifo->bnry){
	    fifo->buf[fifo->curr] = data;
	    fifo->curr++;
  }
	if (fifo->curr == fifo->size) {
		fifo->curr = 0;
	}
  ...
	return 0;
}

同理,一旦bnry的值与curr相等,就不能再读出数据了,所以读出数据的函数可以这样写:

int fifo32_get(struct FIFO32 *fifo)
{
  ...
  int data;
  if(fifo->bnry != fifo->curr){
	    data=fifo->buf[fifo->bnry];
	    fifo->bnry++;
  }
	if (fifo->bnry == fifo->size) {
		fifo->bnry = 0;
	}
  ...
	return data;
}


不过,推演一下,发现这里有个bug:

视频加载中...


拿一个具体的例子:比如现在bnry=3:

此时按照fifo32_put函数的逻辑,当curr=3时,就无法再往buff[3]放入数据,如果能从buff[3]读出一个数据,才能继续放入。如果不把buff[3]读出,就无法往buff[3]写入.

而如果此时没有新的数据要写入,所以我们就一直执行读出函数fifo32_get,按照fifo32_get函数的逻辑,读出函数也会一直执行到当bnry=curr=3时停止,此时,就无法再从buff[3]读出数据。如果能从buff[3]写入一个数据,才能够继续读出。如果不写入buff[3],就无法读出buff[3].

而如果此时,有一个新的数据要往buff中写入,就会发现写不进去。因为fifo32_put函数中,需要从buff[3]把数据读出。而fifo32_get函数不能满足其需求,返回fifo32_get函数需要往buff[3]写入一个数据,才能往外读出数据。

也就是说,当curr=bnry=3时,即不能读出buff[3],也不能写入buff[3].

这是一个隐藏的bug,这种情况发生的时候也不会报错,我们从表面上看:好像没有数据从网线上写入网卡内存,也没有数据从网卡内存读出。但,真实情况是:fifo32在逻辑上锁死了,虽然我们再用fifo32_put函数往里写,但写不进去。虽然我们在用fifoe32_get函数往外读,也读不出来。


该怎么办呢?其中一个办法就是: 新数据要存入时,我们不再往buf[curr]存入数据,而是往buf[curr-1]存入数据,如下:

int fifo32_put(struct FIFO32 *fifo, int data)
{
  ...
  if(fifo->curr != fifo->bnry){
	    fifo->buf[fifo->curr-1] = data;
	    fifo->curr++;
  }
	if (fifo->curr == fifo->size) {
		fifo->curr = 0;
	}
  ...
	return 0;
}

这样改变之后,当curr=bury=3时,就不能再往curr-1=2存放数据了,如果能从buf中读出一个数据,使bnry+1=4,才能往curr-1=2放入数据。此时,希望从bnry=curr=3读出一个数据。

既然curr-1=2处此时没有存入数据,那么从buf中往外读出数据时,就要判断bnry 与2是否相等了,如果相等,就不能读出数据了,将fifo32_get改为:

int fifo32_get(struct FIFO32 *fifo)
{
  ...
  int data;
  if(fifo->bnry != (fifo->curr-1)){
	    data=fifo->buf[fifo->bnry];
	    fifo->bnry++;
  }
	if (fifo->bnry == fifo->size) {
		fifo->bnry = 0;
	}
  ...
	return data;
}

这样改过之后,就只能读取到buff[1], 此时,如果curr-1=2位置写入一个数据,才能读取bnry=curr-1=2处的数据.此时,希望往curr-1=2处写入一个数。

更改后,当无法再写入时,需要从bnry=curr=3读出一个数据就可以继续写入了

当无法再读出时,需要往curr-1=2处写入一个数据,就可以继续读出了。

这就没有矛盾了

这就不会再出现需要往curr=3处写入,又需要从curr=3处读出的情况发生了。


这样边在curr-1位置处存储,边在bnry位置处读出数据的过程,在解决了把无限长的数据按顺序的存入有限的数组内,同时保证了按照顺序的读出。从数据的角度来看,先进入数组的数据,会先被读出,我们称这样的数组为FIFO数组。

我们也形象的把这种用循环的方式来存储数据的结构,叫做环结构,ring struct

如果每次写入/读出的数据不是一个int,而是256个bytes,即一Page数据,那么它就是我们8029网卡地接收缓冲环了

这个基本结构,就是网卡驱动的资料上常说的接收缓冲区环发送缓冲区环

其实,在操作系统的鼠标数据,键盘数据的接收上,我们已经使用了这种环结构,不过那是,我们称其为FIFO结构体,我们每次存入一个int.


那么,网卡就在硬件内部实现了这个ring 结构,然后给了我们两个寄存器:BNRY,CURR,分别来代表fifo结构中的curr和bnry。



也就是说,网卡内部内存对应buff, 网卡内部已经实现了fifo32_put函数,即网卡可以自动的把网线上的数据存入网卡的内存中。

那么我们编写网卡驱动的人,想要读取网卡内存中的数据,只用自己实现fifo32_get函数就行了。

所以,我们只用读取网卡的BNRY寄存器的值,与CURR寄存器的值做比较,只要BNRY的值与CURR-1不想等,就可以从内存的BNRY位置往外读数据。一旦BURY=CURR-1,就意味着已经没有可以读出的数据了。

由此,从网卡内存中读取数据的程序可以这样写:

网卡会自动把数据从网线中读入网卡内存中,我们只用在合适的时候,从网卡内存中读出数据即可

    uchar recv[0xffff];
    page_select(0);
    bnry=io_in8(IOADDR+3);// 读取BNRY寄存器的值,放入bnry,即要读取的数据的地址
    page_select(1);
    curr=io_in8(IOADDR+7);// 读取CURR寄存器的值,即要写入的地址+1
    page_select(0);
    if(bnry+1!=curr){
        //此时表示收到了数据包了
        io_out8(IOADDR+9,bnry);// 把要读取的地址的高位放入位置寄存器
		    io_out8(IOADDR+8,0x00);
		    io_out8(IOADDR+0xb,0x00);// 把读取数据个数的高8位放入数量寄存器
		    io_out8(IOADDR+0xa,18);// 读取数据个数的低8位
        io_in8(IOADDR+0x0a);//read,启动remote dma 读
        for(i=0;i<18;i++)
            recv[i]=io_in8(IOADDR+0x10)
    }

在以上代码的第3行,我们读取了bnry寄存器的值,这个值是网卡接收完成的数据在网卡内存中的地址。

在以上代码的第5行,我读取了curr寄存器的值,网卡会把从网络上接收到的数据存放在curr-1处。

可以看到,只要bnry+1不等于curr,我们就可以读取数据。

8-15行就是读取数据的,这里用到了几个新的寄存器,我们还是结合8029的datasheet来看:

io_out8(IOADDR+9,bnry);// 把要读取的地址的高位放入位置寄存器
io_out8(IOADDR+8,0x00);

操作的是Page0的RSAR1,RSAR0两个寄存器。


这两个寄存器是Remote Start Address Registers, 从远端DMS的这个内存地址处开始,把数据从网卡读入到CPU的内存中。这里DMA是指从网卡内存到CPU内存的数据传输使用了DMA技术,Remote是指网卡到GPU的距离相对于网卡到网线的距离比较远。其实网卡从网线接收数据也是用了DMA技术,只不过从网线获取技术的操作我们不用管。

 io_out8(IOADDR+0xb,0x00);// 把读取数据个数的高8位放入数量寄存器
io_out8(IOADDR+0xa,18);// 读取数据个数的低8位

这两个寄存器是RBCR1,0.即Remote Byte Count Register ,设置用DMA技术接收的字节数。

这4个寄存器,2个构成开始地址,两个构成字节数,定义了要从网卡内存的bnry*256位置,读入18个字节到CPU的内存recv数组中。

13,14,15行启动DMA传送,然后使用for循环从DMA的端口上读取了18个 bytes的数据。

这里问什么只读取18个数据呢?因为ethernet协议头是14个字节,网卡在ethernet协议头之前,又加了4个字节的数据,所以一共18个字节。

ehternet协议头的14个字节为:

即,12个字节的MAC地址,2个字节的协议号

网卡在ethernet协议头之前加的4个字节为:

这4个字节分别为:
Receive Status:网卡RSR寄存器的值,即这条数据再接收过程中是否有错误?
Nest Packet Pointer: 下一个数据包的地址
Receive Byte Count0,Receive Byte Count1,数据的长度。

图中的21,62,4d,1就是前4个字节的值:

21,即RSR=21=0010 0001B,即PRX=1,表示接收数据没有错误发生。PHY=1,表示我们收到的数据里含有MAC地址。

62表示如果下一个接收得到的数据会存储在网卡内存的0x6200处。

4d,1表示称0x014d,表示这条数据的总长度。

第5-第17个字节分别是:6位目的地网卡的mac地址:0,1c,42,4f,d2,1f,6位发送这条数据的网卡的mac地址:0,1c,42,0,0,18,以及ether type : 8,0,即0x0800,表示后续跟的数据是按照IP协议整理出来的IP 头。

综合来收,当我们接收一条数据,它的前18位基本上告诉我们:这条数据是不是正常接收? 这条数据是否完整,如果不完整,那么它的下一页数据的地址在哪里? 以及这条数据所含有的字节数是多少? 这条数据的目的地网卡的MAC地址,发送这条数据的网卡的MAC地址,最后,可以通过ether type知道以太网头后面的数据是按照那种协议编写的?如果这个ether type=0x0800的话,以太网头后面的数据是按照IP协议整理的,说明从第19位开始的数据是IP头,如果ether type=0x0806,说明以太网头后面的数据是按照ARP协议整理的,从第19位开始的数据就是ARP头。

至此,我们接收到了数据,并且通过读取数据的前18个字节,来获取到非常多的信息。

通过对比目的网卡地址与我们自己的网卡地址,就知道这条数据是不是发送给我们的。

通过查看ether type,就知道从第19个字节开始的数据,所遵循的协议是哪个协议。

比如如果是DHCP数组,那以太网头后面应该是IP头,IP头后应该是UDP头,UDP头后就是DHCP消息。

所以,如果我们想接到DHCP offer数组,这里就可以只分析ether type=0x0800时的数据,因为enter type为其他值时,肯定不是DHCP offer数组。

我们现在收到了一条数据,并且也明白了这条数据的基本意义,是不是可以去接收18个字节之后的数据,然后进行更多地解析了呢?

当我们发出DHCP discover信息之后,在路由器上的DHCP服务器有没有给我们回复一个DHCP offer 信息呢?

可以的。

只要我们写一个for循环,在操作系统的无限循环里,去无限循环的调用我们的接收数据的代码即可。

虽然这样也可以,不过这显然是要把一大堆任务塞入for循环里。因为接收到数据后,通常还要对数据解析,解析之后要继续构造合适的数据包再发送出去。所以,把这个复杂的任务放在for循环里,显然是不合适的。

所以,我们使用中断来获取接收到了新数据的信号,而不再用for循环去无限查询。

使用中断机制来完成数据的接收

要想使用网卡的中断机制,还得去看PCI配置中,关于中断的配置信息:


上图的右上角就是PCI配置信息了,其中第15行,即0x0f行的0x0107就配置了中断号。



这里这个07就是interrupt line:当网卡接收到数据后,可以给CPU发送一个中断,这个中断号为0x20+interrupt line=0x20+0x07=0x27。

我们可以像接收鼠标,键盘的中断一样,去接收完卡的这个中断号为0x27的中断。

这个interrpt line 值是BIOS帮我们配置好的,我们直接使用就行。

不过,在8029寄存器中,是可以设置IMR寄存器,来屏蔽掉这个中断的。当然,我们这里使用中断,就要不能屏蔽。

那么8029中,与中断有关的寄存器有2个:IMR,ISR。ISR,即interrupt status register ,中断状态寄存器,表明因为什么原因发生的中断,比如发送完成可以产生中断,接收完成可以产生中断,接收缓冲环满,然生中断等。


通过查询8029的datasheet,我们发现,当CPU接收到一个0x27号中断时,可能是因为如上图所示的8个原因之一。具体那种原因造成的中断,只用查看ISR寄存器的值就行了。


IMR寄存器即interrupt mask register ,如果不希望发生中断,可以将其屏蔽掉。

IMR寄存器专门用来屏蔽8种中断中的每一种。比如设置IMR=0x0F,即允许PTR,RTX,PXE,TXE4种原因造成的中断,不允许OVW,CNT,RDC,RST造成的中断。IMR的第几位设置为1,就表示允许ISR的第几位所对应的中断发生。

所以,在代码上,我们要首先设置8029寄存器中的IMR寄存器,然后模仿鼠标中断,给操作系统添加一个新的中断函数。

首先在8029的初始化函数中:

void netcard_init()
{
  ...
	page_select(0);
	io_out8(IOADDR+0xf,0x3);// IMR:enable PTX,PRX两个中断
  ...
	return;
}

然后就是给操作系统新添加一个专门处理向量号为0x27的中断函数。

首先IDT的初始化函数里,把0x27号向量对应的中断函数asm_inthandler27,写入IDT中,以下代码第7行。

IDT即Interrupt descriptor table,中断函数描述表,这个表里记录了CPU收到中断后,应该去执行的函数,比如收到0x0d号函数,去执行asm_inthandler0d函数。收到0x27号中断,就是执行asm_inthandler27函数。

void init_gdtidt(void)
{
	...
	set_gatedesc(idt + 0x0d, (int) asm_inthandler0d, 2 * 8, AR_INTGATE32);
	set_gatedesc(idt + 0x20, (int) asm_inthandler20, 2 * 8, AR_INTGATE32);
	set_gatedesc(idt + 0x21, (int) asm_inthandler21, 2 * 8, AR_INTGATE32);
	set_gatedesc(idt + 0x27, (int) asm_inthandler27, 2 * 8, AR_INTGATE32);
	set_gatedesc(idt + 0x2b, (int) asm_inthandler2b, 2 * 8, AR_INTGATE32);
	set_gatedesc(idt + 0x2c, (int) asm_inthandler2c, 2 * 8, AR_INTGATE32);
	set_gatedesc(idt + 0x40, (int) asm_hrb_api,      2 * 8, AR_INTGATE32 + 0x60);

	return;
}

接着去完成asm_inthandler27函数:中断函数都要用汇编写,如果有复杂的功能需要实现,可以把复杂的部分写到一个函数里,然后再用汇编中去调用:

_asm_inthandler27:
		PUSH	ES
		PUSH	DS
		PUSHAD
		MOV		EAX,ESP
		PUSH	EAX
		MOV		AX,SS
		MOV		DS,AX
		MOV		ES,AX
		CALL	_inthandler27
		POP		EAX
		POPAD
		POP		DS
		POP		ES
		IRETD

这里,我们CALL了一个函数,inthandler27, 在这个函数里,我们写了一些不方便再汇编里的功能:

struct FIFO32 *netfifo;
void inthandler27(int *esp)
{
	io_out8(PIC0_OCW2, 0x67); 
  fifo32_put(netfifo, io_in8(IOADDR+0x07));
    
  //一定要选择页,不然不能有效清除中断状态,芯片会一直报中断,造成非常多的中断涌入
  page_select(0);
	io_out8(IOADDR+0x07,0xFF);
	
	return;
}

可以看到,在这个函数里,我们首先将中断已响应的消息通过端口PIC0_OCW2发送给中断控制器,然后就ISR寄存器的值放入了netfifo.

这里的netfifo是一个我们自己实现的先入先出数组,专门用来存储每次中断发生时,ISR寄存器的值。然后在操作系统的for循环里,我们就可以将从netfifo里读出ISR寄存器的值,确定是否时接收中断,如果是接收中断,就调用我们上述的接收代码。

这样,我们就完成了利用中断来知道已经接收到数据了,不用再去不停的查询了:

//操作系统的for循环
for(;;){  
      ...
      int fifo_length=fifo32_status(netfifo);
	    if(fifo_length>0){//判断netfifo里是否存储了ISR的值,如果有
           io_out8(IOADDR+0x07,0xFF);
	    	   int ISR = fifo32_get(&fifo_net);//获取ISR的值 
            //bit-0   PRX:recive success
            if((ISR&0x01)==0x01){//是否时接收成功的中断
            	   unsigned char bnry,curr;
            	   short length=0;
	               page_select(0);
	               bnry=io_in8(IOADDR+3);// 读取BNRY寄存器
	               page_select(1);
	               curr=io_in8(IOADDR+7);// 读取CURR寄存器
	               // 如果此时bnry+1!=curr/即还有数据没有从环中读取出来
                 if(bnry+1!=curr){
                    // 先读18个字节,看数据是否是我们要的0x0800类型的数据
        	          int j=ReadFromDma(bnry<<8,18,recv);
                    // 利用结构体的protocal字段去取出enther type ,
        	          if(recv_frame->header.protocal==hxl(0x0800)){
                    	  sprintf(s2,"ether type:%s",number2etherprototal(recv_frame->header.protocal));
                        strshow(s2);
                    }
                 }
            }
        ...
      }
}

第20行判断了ether type 的类型是不是0x0800,如果是,就表示是IP协议,可能是我们要接收的DHCP offer消息。

hxl函数将short类型数据的高8位和低8位进行了交换,因为数据传输时,顺序颠倒了,需要调换回来。

这里的ReadFromDma函数是从网卡内存的start_addr位置读取count个字节到CPU内存中的数组str里

// 使用remote dma技术从网卡的存储区读入若干个字符
int ReadFromDma(int start_addr,int count, unsigned char *str){
    int i;
    page_select(0);
	  WriteToNet(0x09,(start_addr&0x0000ff00)>>8);
    WriteToNet(0x08,start_addr&0x000000ff);
    WriteToNet(0x0b,(count&0x0000ff00)>>8);   //DMA 写高字节
    WriteToNet(0x0a,count&0x000000ff);       //DMA写低字节
    WriteToNet(0,0x0a);   //开始读
    for(i=0;i<count;i++)
        str[i]=(ReadFromNet(0x10));    // 从dma读取数据出来
    return i;
}

这样,我们就完成了对接收到的数据的初步解析。

至此,我们完成了使用中断机制去接收数据,并且对数据进行了初步的解析,过滤出了IP协议封装的数据。
我们可以再下一篇文章继续解析,看看路由器上的DHCP 服务器 有没有给我们发送一个DHCP offer数据。

开发后记

使用网卡接收数据还是比较繁琐的。

比使用网卡发送数据繁琐。

碰到的第一个难点就是:对接收缓冲环的理解

因为首先就是要理解CURR寄存器和BNRY寄存器的功能,理解接收缓冲环的机制,然后才能把CURR寄存器和BNRY寄存器正确的使用起来

接着之前的FIFO的基础,花了些时间,最终理解了CURR寄存器和BNRY寄存器的作用,完成了接收程序。

第二个难点就是:把中断机制用起来。

中断机制涉及到多个设备:PCI的配置信息,对网卡的ISR,IMR寄存器的设置,以及CPU的中断函数编写,还有把中断函数注册到IDT中。

如果有一个设备理解的不对,就无法产生中断。

在一个难点就是:虚拟网卡本身对中断的不支持。我花费了一周时间,才慢慢明白过来,可能qemu虚拟机中的ne2k网卡,对中断不支持。后来再paralles虚拟机中找到了对中断支持的8029AS 网卡,才顺利完成了实验。

举报
评论 0