Skip to content

Latest commit

 

History

History
306 lines (262 loc) · 12 KB

21-vga.org

File metadata and controls

306 lines (262 loc) · 12 KB

VGA driver and terminals

The time has come to improve the text output, so that we can do more complicated things with text, and have multiple processes writing text output without interfering with each other like rtl8139 and gopher in the last section.

The terminal consists of both input and output: when a user is interacting with a program the keyboard & mouse input should go to that program, and anything the program outputs should be shown on screen. We therefore need some kind of multiplexer which will take one console (combination of input & output) and split it into multiple virtual consoles.

A sketch of how this might work is shown below:

    <vga>   <keyboard/mouse>
      |            |
       < vconsole >
            |
  ------------------------
  |       |       |
debug   login1  login2 ...
          |
        shell

The vga program should provide an interface for writing text to screen. The vconsole multiplexer will catch keys, perhaps Ctrl + F1, Ctrl + F2 etc., and switch the input & output between multiple sets of input/output pairs: One could be a debug/status screen, and others connected to instances of a login program. That program will be responsible for restricting user capabilities, mainly by customising the Virtual File System (VFS) that the user sees. When a user logs in a shell would be connected, which might have further multiplexing for Ctrl + Tab switching between processes, or for arranging multiple programs on screen at once.

To enable all this to happen we need a way to specify where on the screen text should be drawn, what color it should be, whether it blinks etc. Fortunately there is an ANSI standard for doing this.

VGA device driver

We probably need a very basic VGA driver in the kernel, at least for now, as a fallback and for debugging any startup issues. The choice then is between making that kernel driver more complete, or creating a new VGA driver which runs in user space. In the long run something like UEFI GOP is probably the way to go for high-resolution graphics, so extended VGA support will become optional rather than baked into the kernel. I’d like to keep the kernel as minimal as possible, so this will be a user-space driver.

There is a Rust vga crate which is hosted on github and includes both text (up to 80x25) and graphics modes (up to 640x480x16). It depends on some other crates, including font8x8 which provides 8x8 pixel unicode characters.

VGA (and all other graphics drivers that I know of) depend heavily on Direct Memory Access (DMA): the CPU writes to memory addresses, which are mapped into registers on the graphics card.

Accessing video memory

To access VGA memory from user programs we need to map the physical 0xA0000 .. 0xBFFFF (inclusive). One way to do this would be to identity map this range into user programs’ address space. The kernel would need to know which programs should have this access, or all programs would be able to write directly to VGA memory. Perhaps this could be a capability like I/O privileges, that processes could choose whether to share with their process children:

  • Quite simple to implement: If a video memory flag is set in process Params then identity map the VGA memory range.
  • Multiple processes will have access to the VGA memory, unless there is a mechanism to unmap the memory range. This might be useful if a video driver crashes and another driver is needed
  • Not clear if it generalises to different framebuffers e.g. UEFI GOP or other hardware.

Instead in EuraliOS we’ll reuse the memory chunk mechanism which allows regions of memory to be allocated, passed between processes and free’d. This:

  • Enables this memory access to be passed between processed, but not shared.
  • Needs some special handling in the allocation and freeing.

The code needed is quite short, however: We create a “special” memory chunk mapping a specified physical address:

pub fn special_memory_chunk(
    thread: &Thread,
    num_pages: u64,
    start_physaddr: u64
) -> Result<(VirtAddr, PhysAddr), usize> {
    // Virtual address of the available page chunk
    let start_virtaddr = match memory::find_available_page_chunk(
        thread.page_table_physaddr) {
        Some(value) => value,
        None => return Err(syscalls::SYSCALL_ERROR_MEMORY)
    };

    match memory::create_physical_range_pages(
        thread.page_table_physaddr,
        start_virtaddr,
        num_pages,
        PhysAddr::new(start_physaddr)) {
        Ok(physaddr) => Ok((start_virtaddr, physaddr)),
        Err(_) => Err(syscalls::SYSCALL_ERROR_MEMORY)
    }
}

which we first allocate in a thread’s address space, remove it, and send it in a message:

let thread = process::new_user_thread(...).unwrap();

// Allocate a memory chunk mapping video memory
let (virtaddr, _) = process::special_memory_chunk(
    &init_thread,
    32,  // Pages, 128k. 0xC0000 - 0xA0000
    0xA0000).unwrap();

// Remove chunk from table so it can be sent
let (physaddr, _) = init_thread.take_memory_chunk(virtaddr).unwrap();

// Send a message to process containing the chunk.
// When received the chunk will be mapped into address space
thread_rendezvous.write().send(None, Message::Long(
    message::VIDEO_MEMORY,
    (0xC0000 - 0xA0000).into(),
    physaddr.into()
));

The process can receive this message when it starts. Processes are usually given two Rendezvous handles, STDIN and STDOUT. Receiving the video memory message could be through either, but here it’s through STDOUT to avoid collisions with input from other sources e.g. keyboard:

// Expect a video memory buffer from the kernel
// Note: Sent to STDOUT channel to avoid conflict with keyboard
let (vmem_length, vmem_handle) = match syscalls::receive(&STDOUT) {
    Ok(Message::Long(
        message::VIDEO_MEMORY,
        MessageData::Value(length),
        MessageData::MemoryHandle(handle))) => {
        (length, handle)
    },
    m => {
        panic!("Expected video memory message. Received {:?}", m);
    }
};

Writing to video memory

Having mapped video memory into a user program’s address space, the next task is to figure out how to write to it. The vga library expects to find the VGA buffer at the virtual address identity mapped to the physical address i.e. the range 0xA0000 .. 0xBFFFF (inclusive), but our video memory chunk might be mapped to different virtual memory addresses. We therefore need to modify the vga library to use arbitrary video addresses.

I’ve added a video_memory_start field to the Vga type, defaulting to the 0xa0000 physical memory address, and a set_memory_start method to change the default to an arbitrary virtual address. The FrameBuffer enum type is extended to hold an arbitrary usize address, rather than constant physical addresses.

Running the VGA program from Init

We can now move the VGA driver execution out of the kernel and into the init program, passing on the I/O privilege:

// Start the VGA driver
syscalls::exec(
    include_bytes!("../../user/vga_driver"),
    syscalls::EXEC_PERM_IO, // I/O permissions
    vga_com2.clone(),
    vga_com2);

The kernel now just starts the init user program, and that user program then starts everything else. Unfortunately loading vga_driver results in a kernel panic, shown in figure fig-kernel-panic:

./img/21-01-kernel-panic.png

This occurs when the kernel tries to allocate memory for the vga_driver ELF binary the allocation fails on this line:

// Assemble a slice then copy to a Vec in the kernel heap
let bin_vec = unsafe{slice::from_raw_parts(bin, bin_length as usize)}.to_vec();

This is because the kernel heap is only 100kb, so part of the solution is to just increase the kernel heap size. It would be good to avoid a kernel panic by first checking if memory can be allocated:

// Assemble a slice pointing to user data
let bin_slice = unsafe{slice::from_raw_parts(bin, bin_length as usize)};

let mut bin_vec : Vec<u8> = Vec::new();
// Reserve space
if bin_vec.try_reserve_exact(bin_slice.len()).is_err() {
    // Could not allocate memory
    println!("[kernel] Couldn't allocate {} bytes for Exec from thread {}", bin_slice.len(), thread.tid());
    thread.return_error(SYSCALL_ERROR_MEMORY);
    process::set_current_thread(thread);
    return;
}
// Copy data into vector, which is now large enough
bin_vec.extend(bin_slice.iter());

Now we get the slightly more useful result in figure fig-user-panic: The kernel can’t allocate enough memory, so returns with an error message; the user program (init) panics but other programs (pci) continue.

./img/21-02-user-panic.png

./img/21-03-writer-sys.png

Switching between screens

When running multiple programs simultaneously it would be good to be able to switch between them. While a program is not writing to the physical screen the changes should be made to a memory buffer, and then when the screen is switched the buffer should be copied into VGA memory. If we want to completely separate the VGA driver from the virtual console code then the rendering code (interpreting ANSI etc.) needs to be duplicated: Reading from VGA memory is (apparently) very slow so even when a process is writing to the physical screen we would still need to render to a separate buffer.

In a concession to efficiency the VGA driver in EuraliOS will provide multiple screen “writers”, and a mechanism to switch between them. A separate process can then decide the policy of when to switch, based on e.g keyboard inputs.

We can now switch between screens with this function, which sends a message to the VGA driver with the screen ID to switch to:

fn activate_writer(vga_com: &CommHandle, writer_id: u64) {
    syscalls::send(
        &vga_com,
        Message::Short(message::WRITE, writer_id, 0));
}

The special keys like `F1` to `F12` are not currently forwarded by the keyboard handler, as only Unicode characters are sent. For now we can just intercept TAB and ESC characters to switch between two screens, one for system programs (`pci`, `rtl8139` and `tcp`) and one for the user program (`gopher`):

loop {
    // Wait for keyboard input
    match syscalls::receive(&STDIN) {
        Ok(syscalls::Message::Short(
            message::CHAR, ch, _)) => {
            // Received a character

            if ch == 9 { // TAB
                activate_writer(&vga_com, writer_user_id);
            } else if ch == 27 { // ESC
                activate_writer(&vga_com, writer_sys_id);
            } else {
                syscalls::send(&input_user,
                               syscalls::Message::Short(
                                   message::CHAR, ch, 0));
            }
        }
        _ => {
            // Ignore
        }
    }
}

Now the TAB and ESC keys switch between the system screen with messages from `tcp` and `rtl8139` networking processes on one screen (fig fig-writer-sys), and the output of the `gopher` program on the other (fig fig-gopher).

./img/21-04-gopher.png

ANSI escape codes

ANSI escape codes are used to change the position and color of the cursor on text-based terminals.

https://github.com/rust-osdev/ansi_rgb

In the next section we’ll start making the operating system more useful by adding a simple file system stored in memory (a ramdisk).