The traditional next step might be to implement a mass storage driver to allow the OS to read and write to disk. Instead we’re going to work on bringing the network device up in the next section.
In the next section we’re going to work on a driver for a network
card, the RTL8139. We’ll start this here by writing the code to
request information on PCI devices from the pci
process.
Create a new crate:
cargo new rtl8139
then modify Cargo.toml
(in the root and rtl8139
directories), the
root makefile
, add build.rs
and a basic rtl8139/src/main.rs
file
as done for pci
in the last section. Compiling everything (make run
) should
produce a user/rtl8139
executable.
We want this rtl8139
driver to be able to send messages to the pci
program. One way to do this would be for the kernel to add a
rendezvous handle, but this would be a custom solution and is a good
excuse to start developing something more general: A Virtual File
System (VFS).
A virtual file system organises resources into a hierarchical structure, with nested directories containing ‘files’. These may correspond to real directories and files on disk, or they may represent devices or other information, for example the Proc filesystem provides access to information on running processes, with a ‘directory’ for each process.
Operating systems vary in how virtual file systems are presented to user programs. In Unix-like operating systems such as Linux almost all programs see the same file system, but not always: the chroot command and FreeBSD jails provide ways to run programs which see a different view of the file system. Plan 9 took this further and allows every program to have a different view of the filesystem.
In EuraliOS the kernel “Merrywig” is going to be quite lazy, and let
user programs do most of the work of creating the VFS. The kernel is
just going to keep a list of “mount points”, and use that to direct
program requests to list directories or open files. We’ll keep the
option of either sharing VFS between processes or creating a separate
one for each process, by giving each Process
a Arc<RwLock<>>
to
contain a potentially shared Vec
of pairs of String
(mount path)
and Arc<RwLock<Rendezvous>>
objects. In kernel/src/process.rs
:
struct Process {
page_table_physaddr: u64,
handles: Vec<Option<Arc<RwLock<Rendezvous>>>>,
mounts: Arc<RwLock<Vec<(String, Arc<RwLock<Rendezvous>>)>>> // New
}
Kernel threads (probably?) don’t need mounts, so in new_kernel_thread()
we just need to add:
mounts: Arc::new(RwLock::new(Vec::new()))
User programs need a new parameter:
pub struct Params {
pub handles: Vec<Arc<RwLock<Rendezvous>>>,
pub io_privileges: bool,
pub mounts: Arc<RwLock<Vec<(String, Arc<RwLock<Rendezvous>>)>>> // New
}
which is moved into the Process
:
process: Arc::new(RwLock::new(Process {
...
mounts: params.mounts, // New
})),
In kernel/src/main.rs
the kernel_thread_main()
function can
now be modified to launch both pci
and rtl8139
processes. We
need to set up the inputs so that (for now) both the keyboard
and the mount point are connected to the same Rendezvous (pci_input
):
let pci_input = interrupts::keyboard_rendezvous();
let vga_rz = vga_buffer::start_listener();
process::new_user_thread(
include_bytes!("../../user/pci"),
process::Params{
handles: Vec::from([
pci_input.clone(),
vga_rz.clone()
]),
io_privileges: true,
mounts: Arc::new(RwLock::new(Vec::new()))
});
process::new_user_thread(
include_bytes!("../../user/rtl8139"),
process::Params{
handles: Vec::from([
// New input (not shared with anything else)
Arc::new(RwLock::new(Rendezvous::Empty)),
// VGA output
vga_rz
]),
io_privileges: true,
mounts: Arc::new(RwLock::new(Vec::from([
// A VFS path to the PCI input
(String::from("/pci"), pci_input)
])))
});
The sys_open
syscall will (for now) just send a string, as
write_str()
in euralios_std/src/debug.rs
does. In euralios_std/src/syscalls.rs
we can add an open
function which is very similar to write_str()
but puts syscall number 6 in rax
, and gets an error code
and handle as return arguments:
/// Returns a handle on success, or an error code
pub fn open(path: &str) -> Result<u32, u64> {
let error: u64;
let handle: u32;
unsafe {
asm!("mov rax, 6", // syscall function
"syscall",
in("rdi") path.as_ptr(), // First argument
in("rsi") path.len(), // Second argument
out("rax") error,
lateout("rdi") handle,
out("rcx") _,
out("r11") _);
}
if error == 0 {
Ok(handle)
} else {
Err(error)
}
}
This new syscall will be handled in kernel/src/syscalls.rs
.
The dispatch_syscall()
function needs to match syscall 6:
6 => sys_open(context_ptr, arg1 as *const u8, arg2 as usize),
The sys_open()
function starts almost the same as sys_write()
except we’ve added some error code returns in rax
:
const SYSCALL_ERROR_PARAM: usize = 5; // Invalid parameter
const SYSCALL_ERROR_UTF8: usize = 6; // UTF8 conversion error
fn sys_open(
context_ptr: *mut Context,
ptr: *const u8,
len: usize) {
let context = unsafe {&mut (*context_ptr)};
// Check input length
if len == 0 {
context.rax = SYSCALL_ERROR_PARAM;
return;
}
// Convert raw pointer to a slice
let u8_slice = unsafe {slice::from_raw_parts(ptr, len)};
if let Ok(path_string) = str::from_utf8(u8_slice) {
// Open path
} else {
// Bad utf8 conversion
context.rax = SYSCALL_ERROR_UTF8;
}
}
To open the path and get the handle we’re going to call a function
in the process
module, open_path()
. That function will either
return a handle, or an error code:
match process::open_path(context, &path_string) {
Ok(handle) => {
context.rax = 0; // No error
context.rdi = handle; // Return handle
}
Err(error_code) => {
context.rax = error_code;
}
}
In kernel/src/process.rs
the open_path()
function will do the work
of resolving paths to resources. We need to get references to the thread and process structs:
pub fn open_path(
current_context: &mut Context,
path: &str) -> Result<usize, usize> {
if let Some(current_thread) = CURRENT_THREAD.read().as_ref() {
println!("Thread {} opening {}", current_thread.tid, path);
let mut process = current_thread.process.write();
// Lookup path, add to handles
}
Err(0)
}
Then lookup the Rendezvous corresponding to the path. In future this will be more involved but for now it will just do a simple text match, and we’ll make it more useful in future sections.
let option_rv = if let Some((_mount, rv)) =
process.mounts.read().iter().find(
|&(mount, _rv)| mount == path) {
Some(rv.clone())
} else {
None
};
And if a Rendezvous was found, add it to the Vec of handles:
if let Some(rv) = option_rv {
let handle = process.add_handle(rv.clone());
return Ok(handle);
} else {
return Err(syscalls::SYSCALL_ERROR_NOTFOUND);
}
Where we’ve added a method Process::add_handle
:
impl Process {
fn add_handle(&mut self, rv: Arc<RwLock<Rendezvous>>) -> usize {
// Find if there is an empty handles slot
if let Some(index) = self.handles.iter().position(
|handle| handle.is_none()) {
self.handles[index] = Some(rv);
return index;
}
// No free slot -> Add one
self.handles.push(Some(rv));
self.handles.len() - 1
}
}
To try this out, in rtl8139/src/main.rs
we can try
opening the “/pci” path to get a handle, then sending
a character message:
#[no_mangle]
fn main() {
debug_println!("rtl8139");
let handle = syscalls::open("/pci").expect("Couldn't open");
debug_println!("{}", handle);
syscalls::send(handle,
syscalls::Message::Short(
0, 'X' as u64, 0));
}
This produces the result in figure fig-open, showing character ‘88’ is received (ASCII ‘X’).
If several threads (A, B and C) are sharing a Rendezvous, such as the
/pci
mount point, we could have the following situation:
- Thread A receives, waits for a message;
- Thread B sends, passes to thread A as intended
- Thread C is scheduled and sends a message intended for thread A
- Thread B now runs, calls receive and gets the message sent by thread C rather than the reply from thread A.
To prevent this we need to block the Rendezvous, so that only the recipient
can send the reply. First we’ll modify Rendezvous
in kernel/src/rendezvous.rs
and make three changes:
- Modify the
Receiving
state so that it can be restricted to only receiving from one thread. - Add a new state,
SendReceiving
which represents a thread sending a message and expecting a reply back from the same thread that receives the message. - Add a
send_receive
method which puts aRendezvous
into theSendReceive
state.
The Rendezvous
enum type becomes:
pub enum Rendezvous {
Empty,
Sending(Option<Box<Thread>>, Message),
Receiving(Box<Thread>, Option<u64>), // Added optional thread ID
SendReceiving(Box<Thread>, Message), // New
}
so now the Receiving
state can optionally block all messages except
those from a specified thread. The SendReceiving
state differs
from Sending
because it needs to have a thread to return a message
to, so Box<Thread>
is not optional.
The functions send
, receive
, and send_receive
(which we’re going to add soon)
handle transitions between states. In send()
the Receiving
case needs to now
include a check of the thread ID:
Rendezvous::Receiving(_, some_tid) => {
if let Some(tid) = some_tid {
// Restricted to a single thread
if let Some(t) = &thread {
if t.tid() != *tid {
// Wrong thread ID
t.return_error(syscalls::SYSCALL_ERROR_RECV_BLOCKING);
return (thread, None);
}
} else {
// No sender thread => error
return (thread, None);
}
}
...
We also need to handle the case that send()
is called on a
Rendezvous
in the SendReceiving
state. This just signals
an error because there can’t be two threads sending:
Rendezvous::SendReceiving(_, _) => {
if let Some(t) = &thread {
t.return_error(syscalls::SYSCALL_ERROR_SEND_BLOCKING);
}
(thread, None)
}
In the receive()
method the Empty
case is slightly
modified, just adding a None
argument to indicate that
messages from any thread can be received:
Rendezvous::Empty => {
*self = Rendezvous::Receiving(thread, None); // Added 'None'
(None, None)
}
The SendReceiving
case is more interesting: We are receiving a
message from a thread which expects a reply from the receiving
thread. We therefore move the sending Thread
object from the
SendReceiving
state into a Receiving
state, along with the ID of
the receiving thread:
Rendezvous::SendReceiving(_, _) => {
// Sending, expecting a reply from the same thread
if let Rendezvous::SendReceiving(snd_thread, message) = mem::replace(self, Rendezvous::Empty) {
thread.return_message(message);
// Wait for a reply from the receiving thread
*self = Rendezvous::Receiving(snd_thread, Some(thread.tid()));
return (Some(thread), None);
}
(None, None)
}
The send_receive()
function can now be defined as:
pub fn send_receive(&mut self, thread: Box<Thread>, message: Message)
-> (Option<Box<Thread>>, Option<Box<Thread>>) {
match &*self {
Rendezvous::Empty => {
*self = Rendezvous::SendReceiving(thread, message);
(None, None)
}
Rendezvous::Sending(_, _) => {
// Signal error to thread: Can't have two sending threads
thread.return_error(syscalls::SYSCALL_ERROR_SEND_BLOCKING);
(Some(thread), None)
}
Rendezvous::Receiving(_, some_tid) => {
if let Some(tid) = some_tid {
// Restricted to a single thread
if thread.tid() != *tid {
// Wrong thread ID
thread.return_error(syscalls::SYSCALL_ERROR_RECV_BLOCKING);
return (Some(thread), None);
}
}
// Complete the message transfer
if let Rendezvous::Receiving(rec_thread, _) = mem::replace(self, Rendezvous::Empty) {
rec_thread.return_message(message);
// Calling thread waits for a reply
*self = Rendezvous::Receiving(thread, Some(rec_thread.tid()));
return (Some(rec_thread), None);
}
(None, None) // This should never be reached
}
Rendezvous::SendReceiving(_, _) => {
// Signal error to thread: Can't have two sending threads
thread.return_error(syscalls::SYSCALL_ERROR_SEND_BLOCKING);
(Some(thread), None)
}
}
}
In kernel/src/syscalls.rs
the Send-Receive system call can be implemented
by much of the same code as sys_send()
. We can
handle both by passing both in dispatch_syscall()
:
4 => sys_send(context_ptr, syscall_id, arg1, arg2, arg3), // sys_send
5 => sys_send(context_ptr, syscall_id, arg1, arg2, arg3), // sys_sendreceive
then in sys_send
we can again check the value of syscall_id
:
let (thread1, thread2) = match syscall_id & 0xFF {
5 => rdv.write().send(
Some(thread),
message),
6 => rdv.write().send_receive(
thread,
message),
_ => panic!("Internal error")
};
In the standard library euralios_std/src/syscalls.rs
we can simplify
the send
, receive
and send_receive
functions by defining methods
to convert Message
objects to and from register values. For now
these still only handle Short
messages. In euralios_std/src/message.rs
:
impl Message {
pub fn to_values(&self)
-> Result<(u64, u64, u64, u64), u64> {
match self {
Message::Short(data1, data2, data3) => {
Ok((0, *data1, *data2, *data3))
},
_ => Err(0)
}
}
pub fn from_values(_ctrl: u64,
data1: u64, data2: u64, data3: u64)
-> Message {
Message::Short(data1, data2, data3)
}
}
The new send_receive()
function can then be implemented as:
pub fn send_receive(
handle: u32,
message: Message
) -> Result<Message, u64> {
// Convert the message to register values
let (ctrl, data1, data2, data3) = message.to_values()?;
// Values to be received
let err: u64;
let (ret_ctrl, ret_data1, ret_data2, ret_data3): (u64, u64, u64, u64);
unsafe {
asm!("syscall",
in("rax") 5 | ctrl | ((handle as u64) << 32),
in("rdi") data1,
in("rsi") data2,
in("rdx") data3,
lateout("rax") ret_ctrl,
lateout("rdi") ret_data1,
lateout("rsi") ret_data2,
lateout("rdx") ret_data3,
out("rcx") _,
out("r11") _);
}
let err = ret_ctrl & 0xFF;
if err == 0 {
return Ok(Message::from_values(ret_ctrl,
ret_data1, ret_data2, ret_data3));
}
Err(err)
}
In the rtl8139
program we can now try using send_receive()
to find
a device using the pci
program. First we open the “/pci” path, then
send a message requesting a device with vendor 0x10EC
and device ID
0x8139
which is the RTL8139 network chip.
let handle = syscalls::open("/pci").expect("Couldn't open pci");
let reply = syscalls::send_receive(
handle,
syscalls::Message::Short(
pci::FIND_DEVICE, 0x10EC, 0x8139)).unwrap();
The reply could be an address (ADDRESS
, 257) if it was
found, a not-found message (NOTFOUND
, 258), or something
else:
let address = match reply {
syscalls::Message::Short(pci::ADDRESS,
address, _) => {
debug_println!("rtl8139 found at address: {:08X}", address);
address
}
syscalls::Message::Short(pci::NOTFOUND,
_, _) => {
debug_println!("rtl8139 not found");
return;
}
_ => {
debug_println!("rtl8139 unexpected reply: {:?}", reply);
return;
}
};
Note that we should also handle the case that the Rendezvous
is
blocked, in which case send_receive()
will return an Err
and we
should probably wait and try again.
The default network card on QEMU is not an RTL8139
so the rtl8139
driver won’t get a PCI address. To configure QEMU’s networking to
emulate an RTL8139
network card we need to add args in kernel/Cargo.toml
:
[package.metadata.bootimage]
run-args = ["-nic", "user,model=rtl8139"] # New
Running this should produce something like the output in fig-address below:
Now that we have the address of the RTL8139 device, we can start to develop the driver for it in the next section.