Using a Serial Device
In this chapter, we will build a root task that interacts with a serial device. Start by navigating to and running this chapter's example, which, so far, doesn't do anything interesting.
cd workspaces/root-task/serial-device
make simulate
The module at device.rs implements a higher-level interface over the serial device's MMIO registers, whose physical base address is:
const SERIAL_DEVICE_MMIO_PADDR: usize = 0x0900_0000;
Our first goal will be to map the serial device's MMIO registers into the root task's address space.
After that, we will set up the root task's access to the serial device's interrupt, whose value is:
const SERIAL_DEVICE_IRQ: usize = 33;
Finally, we will implement a simple loop that echoes serial input to serial output.
Step 5.A
First, add some familiar snippets that we will use for allocating CSlots and kernel objects:
fn find_largest_kernel_untyped(bootinfo: &sel4::BootInfo) -> sel4::cap::Untyped {
let (ut_ix, _desc) = bootinfo
.untyped_list()
.iter()
.enumerate()
.filter(|(_i, desc)| !desc.is_device())
.max_by_key(|(_i, desc)| desc.size_bits())
.unwrap();
bootinfo.untyped().index(ut_ix).cap()
}
let mut empty_slots = bootinfo
.empty()
.range()
.map(sel4::init_thread::Slot::<sel4::cap_type::Unspecified>::from_index);
let largest_kernel_ut = find_largest_kernel_untyped(bootinfo);
Step 5.B (exercise)
largest_kernel_ut
will be useful for allocating kernel objects whose backing physical addresses don't matter to us, but we must allocate the frame which contains the serial device's MMIO registers at a particular physicall address (SERIAL_DEVICE_MMIO_PADDR
).
Furthermore, the seL4 API distinguishes between general purpose untyped and device untyped. General purpose untyped is backed by normal memory, and can be used to create any type of object. Device untyped is not backed by normal memory, and can only be used to create frames.
See the last two paragraphs of seL4 Reference Manual § 2.4 (Kernel Memory Allocation) for more information.
So, we must allocate the serial device MMIO frame from the particular initial device untyped that contains SERIAL_DEVICE_MMIO_PADDR
.
Exercice: Identify this initial untyped in the bootinfo
. We will need a corresponding sel4::cap::Untyped
along with the untyped's physical address (or sel4::UntypedDesc
, which contains the physical address) for the next step.
Step 5.C
The untyped we've identified contains the frame we are targeting, but that frame may be somewhere in the middle of the region of device memory the untyped covers.
To allocate the frame at SERIAL_DEVICE_MMIO_PADDR
, we must allocate dummy objects from this untyped until its watermark is at SERIAL_DEVICE_MMIO_PADDR
.
This trim_untyped
function takes the untyped capability, its physical address, the desired physical address, and two empty slots for temporarily holding dummy objects.
We need two slots because the kernel resets an untyped's watermark if it has no live children.
So, we must always keep one child around so that our progress on advancing the watermark is never lost.
trim_untyped(
device_ut_cap,
device_ut_desc.paddr(),
SERIAL_DEVICE_MMIO_PADDR,
empty_slots.next().unwrap(),
empty_slots.next().unwrap(),
);
fn trim_untyped(
ut: sel4::cap::Untyped,
ut_paddr: usize,
target_paddr: usize,
free_slot_a: sel4::init_thread::Slot,
free_slot_b: sel4::init_thread::Slot,
) {
let rel_a = sel4::init_thread::slot::CNODE
.cap()
.absolute_cptr(free_slot_a.cptr());
let rel_b = sel4::init_thread::slot::CNODE
.cap()
.absolute_cptr(free_slot_b.cptr());
let mut cur_paddr = ut_paddr;
while cur_paddr != target_paddr {
let size_bits = (target_paddr - cur_paddr).ilog2().try_into().unwrap();
ut.untyped_retype(
&sel4::ObjectBlueprint::Untyped { size_bits },
&sel4::init_thread::slot::CNODE
.cap()
.absolute_cptr_for_self(),
free_slot_b.index(),
1,
)
.unwrap();
rel_a.delete().unwrap();
rel_a.move_(&rel_b).unwrap();
cur_paddr += 1 << size_bits;
Step 5.D
device_ut_cap
is now primed; the physical address of the next allocation will be SERIAL_DEVICE_MMIO_PADDR
.
Exercise: Allocate a small frame object (sel4::cap_type::Granule
) from device_ut_cap
.
If your sel4::cap::Granule
is called serial_device_frame_cap
, then the following assertion should succeed:
assert_eq!(
serial_device_frame_cap.frame_get_address().unwrap(),
SERIAL_DEVICE_MMIO_PADDR
);
Step 5.E (exercise)
Exercise: Using code from Step 4.B, Step 4.C, and Step 4.D, map serial_device_frame_cap
into the root task's virtual address space.
You should now be able interact with the serial device's MMIO registers. Try printing "Hello, World!" to the serial console with something like:
let serial_device = unsafe { Device::new(serial_device_mmio_page_addr.cast()) };
serial_device.init();
for c in b"Hello, World!\n" {
serial_device.put_char(*c);
}
where serial_device_mmio_page_addr: *mut _
is a pointer to where the MMIO registers are mapped in the root task's virtual address space.
Step 5.F (exercise)
Interrupts are delivered to userspace via notifications.
A IRQHandler
capability represents the authority to manage a particular interrupt.
Specifically, an IRQHandler
capability (sel4::cap::IrqHandler
) has the following methods:
seL4_IRQHandler_SetNotification()
: Associate the interrupt with the given notification. Userspace can callseL4_Wait()
orseL4_Poll()
on this notification to receive the interrupt.seL4_IRQHandler_Clear()
: Disassociate the notification associated with this interrupts.seL4_IRQHandler_Ack()
: Tell the kernel to pass on acknowledgement of this interrupt to the interrupt controller.
The Rust bindings for these methods are:
sel4::cap::IrqHandler::irq_handler_set_notification()
sel4::cap::IrqHandler::irq_handler_clear()
sel4::cap::IrqHandler::irq_handler_ack()
The root task spawns with a special IRQControl
capability (sel4::cap::IrqControl
) which can be used to create IRQHandler
capabilities with seL4_IRQControl_Get()
(sel4::cap::IrqControl::irq_control_get()
).
The intent behind this API is that a highly-privileged component will hold an IRQControl
capability, which it will use to distribute more finely-grained IRQHandler
capabilities to less privileged components for the interrupts they will manage.
The root task can access its IRQControl
capability with sel4::init_thread::slot::IRQ_CONTROL.cap()
Exercise: Use sel4::init_thread::slot::IRQ_CONTROL.cap()
to create a sel4::cap::IrqHandler
for SERIAL_DEVICE_IRQ
.
Step 5.G (exercise)
Exercise: Create a notification object from largest_kernel_ut
and associate it with the IRQHandler
you just created using sel4::cap::IrqHandler::irq_handler_set_notification()
.
Step 5.H (exercise)
Exercise: Use serial_device: Device
, your IRQHandler
, and the notification you associated with the interrupt to write a loop that echoes serial input to serial output.
Use serial_device.clear_all_interrupts()
and irq_handler_cap.irq_handler_ack()
in sequence to clear the interrupt at the device and interrupt controller levels respectively.
Note that you should do this at the beginning of the loop in case your loop enters with an interrupt already pending.
Use irq_notification_cap.wait()
to wait for hte interrupt.
Use serial_device.get_char()
and serial_device.put_char()
to read and write data.