lab 6是整个6.828课程的最后一个lab。据这个lab介绍,这个是最难的一个,但整体做下来来看,我个人感觉不是很难,且难度主要在看和理解文档上面。本次lab的内容是补全代码完成网卡的驱动程序。操作系统的TCP/IP协议栈是借用了一个轻量的开源实现,不必我们再动手写。qemu模拟器的虚拟网卡是Intel的E1000网卡。主要的参考文档是Intel的![]
整体结构如下图所示,我们需要做的工作是完成绿色框对应的部分。
Part A: Initialization and transmitting packets Exercise 1 exercise 1要求在每个时钟中断到来时,在相应的中断处理逻辑处加上一个time_tick()
函数调用。另外,完成一个系统调用sys_time_msec()
来允许用户进程获取时间。
在kern/syscall.c的trap_dispatch()
的相应地方添加一个time_tick()
函数调用:
1 2 3 4 5 6 if (tf->tf_trapno == IRQ_OFFSET + IRQ_TIMER){ time_tick(); lapic_eoi(); sched_yield(); }
然后实现sys_time_msec()
并在syscall()
中添加相应的分支:
1 2 3 4 5 6 7 static int sys_time_msec(void ) { return time_msec(); }
1 2 case SYS_time_msec: return sys_time_msec();
E1000网卡是PCI设备。系统首先发现PCI总线上的设备,获得设备的信息,然后查pci_attach_vendor
,如果获得的设备信息匹配上了一个条目,便调用相应的函数进行设备的初始化。
Exercise 3 在exercise 3中,要求向pci_attach_vendor
数组添加适合E1000网卡的相应条目,然后实现attach function来进行设备的初始化。
首先向pci_attach_vendor
数组添加相应的条目:
1 2 3 4 5 6 struct pci_driver pci_attach_vendor [] = { {(uint32_t )0x8086 , (uint32_t )0x100e , &attachfn}, { 0 , 0 , 0 }, };
而attachfn()
是在e1000.c实现的。在现在,attachfn()
中只需要添加对pci_func_enable()
的调用即可。另外,还需要中在pci.c中添加e1000.h头文件。
1 2 3 4 5 6 7 8 int attachfn (struct pci_func *pcif) { pci_func_enable(pcif); return 0 ; }
系统通过MMIO来与网卡进行通信。在之前的attachfn()
中,pci_func_enable()
已经与网卡协商好了一个MMIO区域并储存在BAR 0(即reg_base[0]和reg_size[0]),也就是说,对这个区域的物理地址的访问将被映射到网卡中。那么,需要使用mmio_map_region()
创建一个映射从虚拟地址空间MMIO中一块相应区域到这片物理地址区域的映射。
Exercise 4 在e1000.h中声明一个变量volatile uint32_t * location;
并在attachfn()
中添加相应功能:
1 location = mmio_map_region(pcif->reg_base[0 ], pcif->reg_size[0 ]);
此后,对网卡的各寄存器的访问,便可以转化成对(location + 偏移量)地址处的访问了。例如,查文档13.4.36节得知,TDBAL寄存器的偏移量是0x3800,那么也就是说,(location + 0x3800)地址处的4字节内容正是TDBAL寄存器的内容。在代码中,就是*(location + 0x3800/4)
,因为location的类型是uint32_t *
。
由于直接写地址不易读且不易调试,更好的方法是定义一些列宏。lab页面给出了一个示例页面,我们可以使用其中的一些宏定义来使代码更加清晰:
1 2 3 #define E1000_TDBAL 0x03800 *(location + E1000_TDBAL/4 ) = XXXXX;
在E1000中,数据包的传输是基于DMA的。驱动程序负责初始化两个队列:发送队列和接收队列,每个队列是一系列的发送/接收描述符。
传送描述符的定义是:
1 2 3 4 5 6 7 8 9 10 struct tx_desc { uint64_t addr; uint16_t length; uint8_t cso; uint8_t cmd; uint8_t status; uint8_t css; uint16_t special; };
其中,addr是要发送的数据缓冲区的地址,它是一个物理地址,网卡通过DMA的方式读取到该地址处待发送的数据。如果要发送数据,则选择队列中一个空闲描述符,填入一系列信息并告知网卡,网卡会从发送队列中取得数据并发送;接收数据也是类似的原理。
Exercise 5 exercise 5要求在驱动程序中完成transmit初始化的过程,参考上面的manual的14.5节。
首先要做的是分配一块区域作为发送队列。队列是一系列的描述符,每个描述符的addr字段是相应的数据缓冲区,而这些数据缓冲区也是需要分配的。申请一个页面作为发送队列,根据提示,整个队列的描述符数量不应该超过64。对于每个描述符,也分配给它一个数据缓冲区,注意,缓冲区的地址是物理地址 。此外,队列初始为空,那么初始化的时候,也要添加标志位E1000_TXD_STAT_DD
,表示该描述符是空闲的。
然后,根据文档,向网卡的一些寄存器填入适当的值:
在e1000.h中声明传送描述符指针以及一些需要用到的宏定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 struct e1000_tx_desc * trans_des ;#define E1000_TDBAL 0x03800 #define E1000_TDBAH 0x03804 #define E1000_TDLEN 0x03808 #define E1000_TDH 0x03810 #define E1000_TDT 0x03818 #define E1000_TCTL 0x00400 #define E1000_TIPG 0x00410 #define E1000_TCTL_EN 0x00000002 #define E1000_TCTL_PSP 0x00000008 #define E1000_TXD_CMD_RS 0x08000000 #define E1000_TXD_CMD_EOP 0x01000000 #define E1000_TXD_STAT_DD 0x00000001 struct e1000_tx_desc { uint64_t buffer_addr; union { uint32_t data; struct { uint16_t length; uint8_t cso; uint8_t cmd; } flags; } lower; union { uint32_t data; struct { uint8_t status; uint8_t css; uint16_t special; } fields; } upper; };
然后在attachfn()
中添加:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 struct PageInfo * p = page_alloc (1);if (!p) panic("trans des array page alloc failed in attachfn\n" ); uint32_t trans_des_paddr = page2pa(p);trans_des = page2kva(p); for (int i = 0 ; i < 64 ; i ++){ p = page_alloc(1 ); if (!p) panic("trans buffer page alloc failed in attachfn\n" ); trans_des[i].buffer_addr = page2pa(p); trans_des[i].upper.data |= E1000_TXD_STAT_DD; } *(location + E1000_TDBAL/4 ) = trans_des_paddr; *(location + E1000_TDBAH/4 ) = 0 ; *(location + E1000_TDLEN/4 ) = 64 * sizeof (struct e1000_tx_desc); *(location + E1000_TDT/4 ) = 0 ; *(location + E1000_TDH/4 ) = 0 ; *(location + E1000_TCTL/4 ) = E1000_TCTL_EN | E1000_TCTL_PSP | 0x40100 ; *(location + E1000_TIPG/4 ) = 10 | 10 << 10 | 10 << 20 ;
接下来着手完成发送数据的功能。发送队列是一个循环队列,我们需要知道队列是否已满,如果满了,我们就只能把要发送的数据丢弃了。不过不用担心,网络协议栈在设计时就已经考虑了这个情况并确保了即使因为队列满导致数据包被丢弃,也不影响上层应用的正确运行。然而,文档中提到了,直接访问队头指针TDH可能得到不可信的数据,那么就需要一种不访问TDH也能知道队列是否已满的方法了。
E1000提供了这样一个功能:在发送数据时,如果置了描述符的cmd字段的E1000_TXD_CMD_RS(Report Status)位,那么,网卡在成功将这个数据包发送出去后,会将这个描述符的status字段置E1000_TXD_STAT_DD(Descriptor Done)位,表示这个描述符现在是空闲的。那么,每次发送数据前,检查队尾的TDT指针,若它指向了一个被置E1000_TXD_STAT_DD字段的描述符,则可以使用这个描述符,否则,代表当前发送队列已满,丢弃数据并返回。
Exercise 6 exercise 6正是要求写一个发送数据的函数。代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 int send_packet (void * content, uint32_t size) { if (trans_des[*(location + E1000_TDT/4 )].upper.data & E1000_TXD_STAT_DD) { trans_des[*(location + E1000_TDT/4 )].upper.data ^= E1000_TXD_STAT_DD; memcpy (page2kva(pa2page(trans_des[*(location + E1000_TDT/4 )].buffer_addr)), content, size); trans_des[*(location + E1000_TDT/4 )].lower.data |= E1000_TXD_CMD_RS; trans_des[*(location + E1000_TDT/4 )].lower.data |= E1000_TXD_CMD_EOP; trans_des[*(location + E1000_TDT/4 )].lower.flags.length = size; *(location + E1000_TDT/4 ) = (*(location + E1000_TDT/4 ) + 1 ) % 64 ; return 0 ; } else { return -1 ; } }
上面代码的逻辑还是比较清晰的,描述符的缓冲区地址字段是一个物理地址,而使用memcpy()
拷贝要发送的数据使用的是虚拟地址,所以需要来回转化一下。另外,在这个lab中,一个描述符只用来传送一个数据包,不存在传输超大数据包以至于需要只用多个描述符的缓冲区才能装得下的情况,所以每个描述符都要置E1000_TXD_CMD_EOP(End Of Packet)位,否则,网卡在扫描到这个描述符时,会以为后面还有数据,导致虽然描述符被扫描到了,但是却没有数据被发送出去。
Exercise 7 exercise 7要求实现一个系统调用接口以允许用户进程进行系统调用来发送一个数据包。
首先,需要在lib/syscall.h中添加添加相应的枚举类型,然后在lib/syscall.c中添加相应实现来让用户进程可以调用:
1 2 3 4 5 int sys_packet_send(void * content, uint32_t len) { return syscall(SYS_packet_send, 0 , (uint32_t )content, len, 0 , 0 , 0 ); }
不要忘了在lib/inc.h中添加它的声明:int sys_packet_send(void* content, uint32_t len);
。
然后,在系统的内核部分添加处理这个请求的代码,在kern/syscall.c添加系统调用处理函数:
1 2 3 4 5 6 7 static int sys_packet_send(void * content, uint32_t len) { user_mem_assert(curenv, content, len, PTE_U); return send_packet(content, len); }
在kern/syscall.c的syscall()
函数中添加相应分支:
1 2 case SYS_packet_send: return sys_packet_send((void *)a1, a2);
Exercise 8 exercise 8要求完成output.c中的内容。output.c对应了上面的整体结构图片中的output helper environment。它的功能是不断地从网络栈中获取要发送的数据(通过IPC接收),然后通过上面实现的系统调用将数据发送出去。代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 while (1 ){ int r = ipc_recv(0 , &nsipcbuf, 0 ); if (r < 0 ) panic("output env ipc recv error:%e\n" , r); do { r = sys_packet_send((void *)nsipcbuf.pkt.jp_data, nsipcbuf.pkt.jp_len); sys_yield(); } while (r < 0 ); }
Question
How did you structure your transmit implementation? In particular, what do you do if the transmit ring is full?
上面已经提到了,如果发送队列已经满了的话,直接丢弃数据包并返回-1.网络栈会保证即使丢弃部分数据包也不影响上层通信的。
Part B: Receiving packets and the web server part b是接收数据包以及web服务器部分。
Exercise 10 exercisse 10要求完成接收队列的初始化工作。这部分与上面的发送队列初始化有些类似,但又不一样。基本上也是参考Manual写,就不多说了,代码如下:
在e1000.h中声明接收描述符指针以及一些需要用到的宏定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 struct e1000_rx_desc * recv_des ;#define E1000_RDBAL 0x02800 #define E1000_RDBAH 0x02804 #define E1000_RDLEN 0x02808 #define E1000_RDH 0x02810 #define E1000_RDT 0x02818 #define E1000_RA 0x05400 #define E1000_RAH_AV 0x80000000 #define E1000_MTA 0x05200 #define E1000_IMS 0x000D0 #define E1000_RCTL 0x00100 #define E1000_RCTL_EN 0x00000002 #define E1000_RCTL_LPE 0x00000020 #define E1000_RCTL_LBM_NO 0x00000000 #define E1000_RCTL_RDMTS_HALF 0x00000000 #define E1000_RCTL_BAM 0x00008000 #define E1000_RCTL_BSEX 0x02000000 #define E1000_RCTL_SZ_4096 0x00030000 #define E1000_RCTL_SECRC 0x04000000 #define E1000_RXD_STAT_DD 0x01 struct e1000_rx_desc { uint64_t buffer_addr; uint16_t length; uint16_t csum; uint8_t status; uint8_t errors; uint16_t special; };
在attachfn()
中添加:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 *(location + E1000_RA/4 ) = 0x12005452 ; *(location + E1000_RA/4 + 1 ) = 0x00005634 ; *(location + E1000_RA/4 + 1 ) |= E1000_RAH_AV; *(location + E1000_MTA/4 ) = 0 ; *(location + E1000_IMS/4 ) = 0 ; p = page_alloc(1 ); if (!p) panic("recv des array page alloc failed in attachfn\n" ); uint32_t recv_des_paddr = page2pa(p);recv_des = page2kva(p); *(location + E1000_RDBAL/4 ) = recv_des_paddr; *(location + E1000_RDBAH/4 ) = 0 ; *(location + E1000_RDLEN/4 ) = 128 * sizeof (struct e1000_rx_desc); for (int i = 0 ; i < 128 ; i++){ p = page_alloc(1 ); if (!p) panic("recv buffer page alloc failed in attachfn\n" ); recv_des[i].buffer_addr = page2pa(p); recv_des[i].length = 4096 ; } *(location + E1000_RDH/4 ) = 0 ; *(location + E1000_RDT/4 ) = 127 ; *(location + E1000_RCTL/4 ) |= E1000_RCTL_EN; *(location + E1000_RCTL/4 ) ^= E1000_RCTL_LPE; *(location + E1000_RCTL/4 ) |= E1000_RCTL_LBM_NO; *(location + E1000_RCTL/4 ) |= E1000_RCTL_RDMTS_HALF; *(location + E1000_RCTL/4 ) |= E1000_RCTL_BAM; *(location + E1000_RCTL/4 ) |= E1000_RCTL_BSEX; *(location + E1000_RCTL/4 ) |= E1000_RCTL_SZ_4096; *(location + E1000_RCTL/4 ) |= E1000_RCTL_SECRC;
Exercise 11 exercise 11也是一样的,实现一个接收数据的函数,并实现相应的系统调用提供给用户进程使用。也是跟上面的非常类似,所以也就略过一些头文件声明或者枚举类型定义之类的东西了,完整的代码可以在我的GitHub上找到。
在e1000.c中添加:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 int recv_packet (void * buf, uint32_t * size) { if (recv_des[(*(location + E1000_RDT/4 ) + 1 ) % 128 ].status & E1000_RXD_STAT_DD) { *(location + E1000_RDT/4 ) = (*(location + E1000_RDT/4 ) + 1 ) % 128 ; recv_des[*(location + E1000_RDT/4 )].status ^= E1000_RXD_STAT_DD; if (!size) panic("nullptr cannot be used in fn-recv_packet\n" ); *size = recv_des[*(location + E1000_RDT/4 )].length; memcpy (buf, page2kva(pa2page(recv_des[*(location + E1000_RDT/4 )].buffer_addr)), *size); return 0 ; } else return -1 ; }
在lib/syscall.c中添加下面的函数使得用户进程可以调用:
1 2 3 4 5 int sys_packet_recv(void * buf, uint32_t * size) { return syscall(SYS_packet_recv, 0 , (uint32_t )buf, (uint32_t )size, 0 , 0 , 0 ); }
在kern/syscall.c中添加系统调用处理函数:
1 2 3 4 5 6 static int sys_packet_recv(void * buf, uint32_t * size) { user_mem_assert(curenv, buf, PGSIZE - sizeof (int ), PTE_U); return recv_packet(buf, size); }
最后在kern/syscall.c的syscall()
中添加相应的分支:
1 2 case SYS_packet_recv: return sys_packet_recv((void *)a1, (uint32_t *)a2);
Exercise 12 exercise 12也是同样类似,完成input helper environment中的内容。唯一的区别是,这里是通过IPC的方法将从网卡接收到的数据发送给网络栈,那么网络栈的进程不一定能够立即取出IPC传递过去的页的数据,这需要一段时间,所以,在IPC消息发送出去后,立即将相同的页用于放下一个数据包的IPC信息可能导致错误。所以,这里采用两个页交替使用的方法,第一个包使用P1,第二个使用P2,第三个使用P1…等等。
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 int r = sys_page_alloc(0 , (void *)PKTMAP0, PTE_U|PTE_W|PTE_P);if (r < 0 ) panic("fn-input: could not allocate page of memory" ); r = sys_page_alloc(0 , (void *)PKTMAP1, PTE_U|PTE_W|PTE_P); if (r < 0 ) panic("fn-input: could not allocate page of memory" ); struct jif_pkt *pkt0 = (struct jif_pkt *)PKTMAP0 ;struct jif_pkt *pkt1 = (struct jif_pkt *)PKTMAP1 ;uint32_t size0, size1, t = 0 ; while (1 ){ if (t++ % 2 ) { r = sys_packet_recv(pkt0->jp_data, &size0); if (!r) { pkt0->jp_len = size0; ipc_send(ns_envid, NSREQ_INPUT, (void *)pkt0, PTE_P|PTE_W|PTE_U); } } else { r = sys_packet_recv(pkt1->jp_data, &size1); if (!r) { pkt1->jp_len = size1; ipc_send(ns_envid, NSREQ_INPUT, (void *)pkt1, PTE_P|PTE_W|PTE_U); } } }
Question
How did you structure your receive implementation? In particular, what do you do if the receive queue is empty and a user environment requests the next incoming packet?
如果没有新的数据包的话,系统调用会返回-1,并不会传递数据。
Exercise 13 最后一个exercise要求完成web服务器的两个函数:send_file()
和send_data()
。
send_file()
要做的是打开http请求的url字段请求的资源,并且取得该文件的大小,并把文件发送出去,比较简单,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 fd = open(req->url, O_RDONLY); if (fd < 0 ){ send_error(req, 404 ); goto end; } struct Stat stat ;fstat(fd, &stat); if (stat.st_isdir){ send_error(req, 404 ); goto end; } file_size = stat.st_size;
send_data()
要做的就是将相应文件描述符对应的文件的内容发送给http请求方,同样也很简单:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 char * buf = (char *)malloc (32 );int len; do { len = read(fd, buf, 32 ); if (len < 0 ) die("Failed to read data from fd" ); if (write(req->sock, buf, len) != len) die("Failed to send bytes to client" ); } while (len > 0 ); return 0 ;
至此,整个lab就完成了。
lab的完整代码在我的GitHub 中可以找到
(完)