IMPLEMENTING A LITTLE OS #3
A beginners guide to Operating System — Part 3 (Integrating Outputs)
Welcome to the third episode of my operating system development series. You can refer to the first two articles in this series before going through this week’s episode. Here is the link for the second episode.
This article will cover how to display text on the console as well as write data to the serial port. Furthermore, we will create our first driver, that is, code that acts as a layer between the kernel and the hardware, providing a higher abstraction than communicating directly with the hardware.
Interacting with the Hardware
There are generally two different ways to interact with hardware, memory-mapped I/O, and I/O ports.
1 -> If using hardware memory-mapped I/O, you can write to a specific memory address and the hardware will be updated with the new data.
2 -> If using hardware I/O ports, assembly code must use out
andin
instructions to communicate with it. The instruction out accepts two parameters: the I/O port address and the data to send. The instruction in accepts a single parameter, the I/O port address, and returns data from the hardware.
Now let’s discuss how to implement output functions with Framebuffer and Serial Port in practice.
The Framebuffer
The framebuffer is a hardware device that is capable of displaying a buffer of memory on the screen. The framebuffer has 80 columns and 25 rows, and the row and column indices start at 0 (so rows are labeled 0 — 24)
Writing text via the framebuffer
Writing text to the console via the framebuffer is done with memory-mapped I/O.
The starting address of the memory-mapped I/O for the framebuffer is 0x000B8000. Memory is divided into 16-bit cells, 23 bits of which determine both the 16-bit character (foreground color and background color). the highest eight bits represent the ASCII value of the character, bit 7–4 represent the background and bit 3–0 represent the foreground.
Bit: | 15 14 13 12 11 10 9 8 | 7 6 5 4 | 3 2 1 0 |
Content: | ASCII | FG | BG |
The first cell corresponds to row zero, column zero in the console. Using an ASCII table, one can see that A corresponds to 65 or 0x41. So, to write the letter A at position (0,0) with a green foreground (2) and a dark gray background (8), the following assembly code instructions are used:
mov [0x000B8000], 0x4128
The second cell then corresponds to row zero, column one and its address is therefore:
0x000B8000 + 16 = 0x000B8010
Writing to the framebuffer can also be done in C by treating the address 0x000B8000 as char *fb = (char *) 0x000B8000. Then, write A at (0,0) with green foreground and dark gray background:
fb[0] = ’A’;
fb[1] = 0x28;
The code below demonstrates how this can be wrapped into a function:
The photo below shows the letter ‘A’ printed on the console.
Move the cursor
Cursor movement in the framebuffer is done through two different I/O ports.
The cursor position is determined by a 16-bit integer: 0 is row zero, column zero; 1 means row zero, column one; 80 means row one, column zero, and so on. Since the position is 16 bits large, and the out assembly code instruction argument is 8 bits, the position must be sent in two turns, first the 8 bits and then the next 8 bits.
The frame buffer has two I/O ports, one to accept data and one to describe incoming data. Port 0x3D4 is the data describing port and port 0x3D5 is for the data itself.
To set the cursor at row one, column zero (position 80 = 0x0050), one would use the following assembly code instructions:
Out assembly code instructions cannot be executed directly from C. So it’s a good idea to wrap a function in assembly code accessible from C via the cdecl calling standard.
Store the above function in a file named io.s
Also make sure you add io.o to the OBJECTS variable in your Makefile. After adding it your Makefile should look like below.
Create a C header file named io.h
to make it easier to access assembly code instructions from outside of C. The code to include in that file is as follows:
Now we can wrap the cursor movement functionality in a C function as follows:
You can now move the cursor by applying the following command to your main C function.
fb_move_cursor(600); // move the cursor to 800th position
The driver for the framebuffer
Now let’s create a driver to write a string to the console with proper cursor movements. For this we can reuse the C functions we created to write characters and move the cursor.
Following is the kmain.c
file configured to write string: “Welcome to LittleOS” to the console.
It is a good practice to move the driver to a separate header file as it will be easier to use in the future.
The Serial Ports
A serial port is an interface for communication between hardware devices and is present on almost every motherboard, but nowadays it is rarely exposed to the user in the form of a DE-9 connector. The serial port is easy to use and, more importantly, it can be used as a logging utility in Bochs. If a computer has support for a serial port, it usually supports multiple serial ports, but we will only use one port. This is because we only use serial ports for logging. Also, we use serial ports for output and not for input. Serial ports are fully controlled via I/O ports.
Configuring the Serial Port
The first data to be sent to the serial port is the configuration data. In order for two hardware devices to be able to talk to each other, they must agree on several things. These things include:
• Speed (bit or baud rate) used to send data
• If any error checking should be used for the data (parity bit, stop bits)
• Number of bits representing a unit of data (data bits)
Configuring the Line
Configuring the line means configuring how data is sent over the line. The serial port has an I/O port, the line command port, which is used for configuration.
First the data transmission speed will be set. The serial port has an internal clock running at 115200 Hz. Setting the speed means sending a divider to the serial port, for example sending 2 results gives a speed of 115200 / 2 = 57600 Hz.
Divisor is a 16 bit number but we can send only 8 bits at a time. So we must instruct the serial port to expect the 8 highest bits first, then the 8 lowest bits. This is done by sending the 0x80 line to the command port.
The layout of the 8 bits looks like the following:
Bit: | 7 | 6 | 5 4 3 | 2 | 1 0 |
Content: | d | b | prty | s | dl |
A description for each name can be found in the table below
We mostly use the standard value 0x03, which means 8 bits long, no parity bit, one stop bit and break control disabled. This is sent to the line command port as shown in the example below.
Configuring the Buffers
When data is transmitted over the serial port, it is buffered during receive and send data. In this way, if data is sent to the serial port faster than the speed that can be sent through the wire, it will be buffered. However, if you send data too fast, the buffer will fill up and the data will be lost. In other words, buffers are FIFO queues. The FIFO queue configuration byte looks like the following image:
Bit: | 7 6 | 5 | 4 | 3 | 2 | 1 | 0 |
Content: | lvl | bs | r | dma | clt | clr | e |
Following are the meanings of the names mentioned above:
We’ll use the value 0xC7 = 11000111, which means:
- FIFO is enabled,
- FIFO queues for both the receiver and the transmission are cleared and,
- The queue size is set to 14 bytes.
Configure the Modem
The modem control register is used for very simple hardware flow control via the Ready To Transmit (RTS) and Data Terminal Ready (DTR) pins. When configuring the serial port we want RTS and DTR to be 1 which means we are ready to send data.
The modem configuration byte is shown in the figure below:
Bit: | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
Content: | r | r | af | lb | ao2 | ao1 | rts | dtr |
Following are the descriptions for the above-mentioned short terms.
We don’t need to enable interrupts because we won’t be dealing with any data that comes in. Therefore we use 0x03 = 00000011 (RTS = 1 and DTS = 1) as the configuration value.
Writing Data to the Serial Port
Writing data to the serial port is done through the data I/O port. However, before writing, the transmit FIFO queue must be empty (all previous writes must have been completed). If bit 5 of the line status I/O port is equal to one, the transmit FIFO queue is empty.
The in
assembly code instruction is used to read the contents of an I/O port. Because there is no way to use the in
assembly code instruction from C, it must be wrapped (in the same way as the out
assembly code instruction). The following assembly code will do that for you:
Configuring Bochs
The Bochs configuration file bochsrc.txt should be updated to save the output from the first serial port. The com1 configuration tells Bochs how to handle the first serial port:
com1: enabled=1, mode=file, dev=com1.out
The output from serial port one will now be saved in the file com1.out
.
Driver for Serial Port
Since we have implemented many functions in configuring the serial port, it is easier to write the serial port driver in a C header file. An example of a header file for configuring and writing to the serial port is given below:
Let’s come up with the next part of this os developing series.
References ….
The Little OS Book: https://littleosbook.github.io/book.pdf