[MIT 6.s081] Xv6 Lab 8: Networking Record
The content below was generated entirely by machine translation. Please verify its accuracy. If anything is unclear, consult the Chinese source version.
Update on 2022/9/14: I recently put the lab code on GitHub. If you need a reference, you can find it here:
https://github.com/ttzytt/xv6-riscv
The different branches contain the different labs.
Lab 8: Networking
The lab description is extremely long, although much of it introduces the E1000 network card. The final task is actually simple: implement transmit() and recv() in the E1000 driver.
The code is not complicated, but writing it requires a solid understanding of the hints and consultation of the E1000 documentation.
I will first explain how the processor interacts with the E1000 and then describe the implementation of the two functions.
Interacting with the E1000
The E1000 uses DMA, or direct memory access, and can write received packets directly into computer memory. This is particularly useful for large volumes of data because memory serves as a buffer.
For transmission, software similarly writes descriptors, discussed below, into specific memory locations. The E1000 finds the data awaiting transmission and sends it itself.
For both receiving and transmission, packets are described by arrays of descriptors. The receive and transmit descriptor layouts are introduced in their respective sections.
Receiving
When the network card receives data, it generates an interrupt and invokes the corresponding interrupt handler to process the newly arrived packet.
Descriptors
The receive descriptor format is:

In xv6, it is defined as:
1 | // [E1000 3.2.3] |
An array of descriptors is placed in memory and interpreted as a ring queue.
When the card receives a packet, it examines the descriptor at head and writes the data to that descriptor’s buffer, whose address is stored in addr.
The status and length fields are also important, and the card sets both when writing.
length is the size of the packet written at addr. status represents the following states:
The main flag we need is DD, Descriptor Done, which means the card has completely received the packet.
The driver must inspect this flag. If reception is incomplete, it should wait for some additional time.
Ring queue
As described above, the card writes a newly received packet into the buffer of the descriptor at head. We now consider how the card and driver manage this buffer.
The following diagram shows the receive-descriptor ring:

During initialization, head is zero and tail is one less than the queue-buffer size.
The light-colored region from head through tail is free. The diagram appears slightly inaccurate because the position at tail is also free. Software has finished processing every packet in this region. When another packet arrives, the card writes at the beginning of this area, head, overwriting old data, and then increments head.
Software processes the dark region in order. When reading the ring, it reads the buffer at tail + 1, which is the longest-waiting unprocessed packet. After processing the buffer, software increments tail.
Transmission
Descriptors
The transmit descriptor format is:
In xv6, it is defined as:
1 | // [E1000 3.3.3] |
addr and length have the same purposes as in a receive descriptor, so I will not repeat them.
The other fields we mainly use are cmd and status.
As on receive descriptors, the DD status flag means that transmission of the referenced data has completed.
cmd describes settings for transmitting the packet—in other words, commands for the network card.
The available commands are:
We need the following commands:
- RPS, Report Packet Sent: after this is set, the card reports transmission status. For example, after sending the data referenced by a descriptor, it sets that descriptor’s DD flag.
- EOP, End of Packet: indicates that this descriptor is the end of a packet. A very large packet may occupy buffers from several descriptors. EOP is set on its final descriptor. Only then can certain other features, such as IC checksum insertion, be applied.
Ring queue
The transmit-descriptor ring differs slightly from the receive ring. The region from head through tail, shown in a light color, contains data that software wants to send but the card has not yet transmitted.

head points to the longest-waiting pending descriptor, from which the card begins transmission. After finishing, it increments head. New descriptors are inserted at the tail side, and software increments tail as well.
xv6’s representation of network data
To simplify packet handling, xv6 defines struct mbuf:
1 | struct mbuf { |
e1000_transmit() receives network data in an mbuf, writes it to the memory used by DMA, and thereby lets the card transmit it.
The approximate layout of an mbuf is:
1 | // The above functions manipulate the size and position of the buffer: |
Data can be pushed into headroom to store network-protocol headers. After receiving network data, a portion of the central buffer can also be pulled and interpreted as a header structure such as:
1 | // an Ethernet packet header (start of the packet). |
The conversion appears in net_rx():
1 | struct eth *ethhdr; |
The buffer contains the packet body. Tailroom is whatever remains of char buf[MBUF_SIZE] after headroom and the occupied buffer.
Within struct mbuf, len is the length of the body and head marks the end of headroom, or the current beginning of the buffer.
net.c contains many mbuf-related functions. The most important are mbufalloc() and mbuffree(), which allocate and release an mbuf.
Register operations
Specific memory mappings provide access to the E1000 control registers. More precisely, code adds offsets to the global regs variable in e1000.c; those offsets are defined in e1000_dev.h.
Implementation and explanation
Transmission
The overall idea follows the lab hints.
First, read the current ring tail—the first descriptor not currently being transmitted—from the memory-mapped control register, and obtain its descriptor:
1 | acquire(&e1000_lock); // Multiple threads may transmit simultaneously, so acquire the lock |
Then inspect the descriptor status. If E1000_TXD_STAT_DD is clear, the ring has no free position: tail has reached the light-colored region because the entire queue contains pending transmit descriptors. Return immediately in this case.
1 | if(!(desc->status & E1000_TXD_STAT_DD)){ // If transmission is incomplete, the ring has no free buffer |
Next, check the mbuf associated with this descriptor. Its addr points to the mbuf. If the descriptor’s old data has finished transmitting, release that mbuf.
1 | if(tx_mbufs[idx] != NULL){ // This buffer points to the packet being transmitted |
After freeing the old buffer, point addr at the data currently being sent and update the length:
1 | desc->addr = m->head; |
It took me a long time to understand why the assignment is desc->addr = m->head rather than desc->addr = m->buf.
I initially thought an mbuf’s headroom stored the packet header. In fact, the header is stored at the beginning of the central buffer, while headroom is only reserve space. If the current header must be replaced by a larger one, for example, code can call mbufpullhdr() and then mbufpushhdr().
An example caller of e1000_transmit() shows the purpose of headroom. The only caller in net.c is net_tx_eth():
1 | // sends an ethernet packet |
This function primarily adds an Ethernet header. ethhdr = mbufpushhdr(m, *ethhdr); shrinks headroom and enlarges the buffer, returning the newly added space as ethhdr.
The following calls to memmove(ethhdr->shost, local_mac, ETHADDR_LEN) and memmove(ethhdr->dhost, broadcast_mac, ETHADDR_LEN) copy the header fields into that space carved out of headroom. The mbuf’s buffer now includes the packet header.
If a larger header is later required, headroom can again be reduced to enlarge the buffer.
Returning to e1000_transmit(), after setting addr and length, set the descriptor commands:
1 | desc->cmd = E1000_TXD_CMD_RS | E1000_TXD_CMD_EOP; |
The two commands were explained in the transmit-descriptor section above.
The final code in e1000_transmit() is:
1 | tx_mbufs[idx] = m; // Record it for later cleanup |
The main line to explain is tx_mbufs[idx] = m. Earlier, the function checked E1000_TXD_STAT_DD to learn whether transmission of this descriptor had finished. If not, it returned. If so, it released the old packet buffer.
Assigning m to tx_mbufs[idx] records the buffer so the next use of this descriptor can inspect and clean up its transmission state.
The complete e1000_transmit() is:
1 | int |
Receiving
One important point is that e1000_recv() must read every currently pending packet in one invocation. It therefore needs a loop that repeatedly reads the descriptor at tail until it finds one whose reception is incomplete.
The E1000 supports several interrupt strategies for received packets. A common one is RDTR, the Receive Interrupt Delay Timer. Roughly, after a packet is received and DMA writes it into host memory, a timer begins; the interrupt occurs only after the configured delay.
This strategy reduces interrupt volume when many packets arrive in a short interval. xv6 does not use it, however, and instead generates an interrupt after every write into host memory:
1 | regs[E1000_RDTR] = 0; // interrupt after every received packet (no timer) |
If every packet produces an interrupt, why not read only one descriptor per interrupt instead of looping at tail?
My understanding is that interrupt delivery is disabled while handling an external-device interrupt.
Suppose many packets arrive quickly. The first interrupt begins handling, but several more interrupts may be generated before that handler finishes. Those later interrupts cannot be received while interrupts are disabled, especially if processing a descriptor is slower than packet arrival.
The handler therefore checks for additional arrived packets every time it runs and continues reading while they exist.
Returning to the implementation, first read tail and obtain its descriptor:
1 | uint idx = (regs[E1000_RDT] + 1) % RX_RING_SIZE; // The region from head through tail is free |
Tail itself is a free buffer whose data was processed earlier, so increment tail before selecting the next descriptor.
Use the DD flag to determine whether all pending descriptors have been read:
1 | if(!(desc->status & E1000_RXD_STAT_DD)){ |
Set the mbuf length from the received descriptor:
1 | rx_mbufs[idx]->len = desc->length; |
Unlike transmission, each receive descriptor has a permanently associated mbuf. Its addr was initialized beforehand. The initialization code allocates the first set of mbufs as follows:
1 | // [E1000 14.4] Receive initialization |
Then pass the mbuf to net_rx() for processing by the appropriate network-protocol stack:
1 | net_rx(rx_mbufs[idx]); |
The upper protocol layers still need the old mbuf, so it cannot be overwritten. Allocate a fresh mbuf for the current descriptor:
1 | rx_mbufs[idx] = mbufalloc(0); |
Finally, update tail. Remember that tail itself points to a descriptor already processed by software:
1 | regs[E1000_RDT] = idx; |
The complete e1000_recv() is:
1 | static void |
After completing these functions, the lab passes:





![[Stanford CS144] Lab 4 Record](/img/CS144/tcp%E7%8A%B6%E6%80%81%E6%B5%81%E8%BD%AC%E5%9B%BE.jpg)