Build your own OS (Part 3)
Integrating outputs
Hello and welcome everyone again to the third part of my OS implementation article series. First of all, I am linking the first two articles in case you haven’t followed them.
In the previous article, I discussed how to write code in C for our operating system. In this article, I’ll show you how to use those features, along with assembly language, to integrate outputs into our operating system. We’ll go over how to display text on the console and write data to a file using the serial port. Furthermore, we will write two drivers, which are the codes that act as layers between the kernel and the hardware, providing a higher level of abstraction than directly communicating with the hardware.
For all this to be done, We have to configure two devices. They are,
- Framebuffer
- Serial Ports
Before talking about the above two devices, I’d like to give you a little introduction to hardware interaction.
How to Interact with the Hardware
Memory-mapped I/O and I/O ports are the two most common ways to interact with hardware.
If the hardware supports memory-mapped I/O, you can write to a specific memory address and the hardware will be updated.
If the hardware uses I/O ports, the assembly code instructions
out
andin
must be used to communicate with it. The instructionout
accepts two parameters: theI/O port address
and thedata to send
. The instructionin
accepts a single parameter, theI/O port address
, and returns data from the hardware.
Now let’s discuss how we can practically implement the output functions with the Framebuffer and Serial Port.
The Framebuffer
The framebuffer is a hardware device that can display a memory buffer on the screen. It has 80 columns and 25 rows, with row and column indices starting with 0. (so rows are labelled 0–24, and columns are labelled 0–79).
Writing text to the console via framebuffer
This is possible with memory-mapped I/O (You can write to a specific memory address with memory-mapped I/O, and the hardware will be updated with new data.). The framebuffer’s memory-mapped I/O begins at address 0x000B8000. The memory is divided into 16-bit cells, with 16 bits determining both the character and the colours (Foreground and background). As shown in the figure below, 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 following table shows you available colours and their values.
On the console, the first cell corresponds to row zero, column zero. Using an ASCII table, we can see that ‘A’ corresponds to 65, or 0x41. As a result, the following assembly code instruction is used to write the character A with a green foreground (2) and a dark grey background (8) at position (0,0):
mov [0x000B8000], 0x4128
consequently, the second cell corresponds to row zero, column one, and its address is as follows:
0x000B8000 + 16 = 0x000B8010
Writing to the framebuffer can also be done in C by treating the address 0x000B8000 as a char pointer: char *fb = (char *) 0x000B8000. Then, writing A at (0,0) with a green foreground and a dark grey background becomes:
fb[0] = 'A';
fb[1] = 0x28;
The code below demonstrates how this can be wrapped into a function:
The above function can be called as follows:
Following snapshot shows the character ‘A’ printed on the console.
Before writing functions to move the cursor, try print some characters to the console by changing the parameters to the fb_write_cell()
function.
Moving the cursor
Not let’s discuss how we can implement the function to move the framebuffer’s cursor. For this, we need two different I/O ports. The position of the cursor is determined by a 16-bit integer: where 0 denotes row zero and column zero; 1 denotes row zero and column one; 80 denotes row one and column zero, and so on. Because the position is 16 bits long and the out assembly code instruction argument is 8 bits long, the position must be sent in two turns, the first 8 bits followed by the next 8 bits. The framebuffer has two I/O ports, one for accepting data and the other for describing the data received. The port that describes the data is 0x3D4, and the port that contains the data is 0x3D5.
The following assembly code instructions can be used to set the cursor to row one, column zero (position 80 = 0x0050):
Since the out assembly code instruction can’t be executed directly in C, it is a good idea to wrap it out in a function in assembly code which can be accessed from C via the cdecl calling standard.
Store the above function in a file named io.s
. And make sure that you add io.o in your Makefile’s OBJECTS variable. After adding that your makefile should look like the following.
Make a C header file called io.h
to make it convenient to access the out assembly code instruction from C. The code to include in that file is as follows:
Now we can wrap the cursor moving functionality in a C function as follows:
You can now move the cursor by putting the following command in your main C function.
fb_move_cursor(600); // move the cursor to 600th position
Implementing the driver for the framebuffer
Now let’s create a driver to write a string to the console with proper cursor movements. We can reuse the C functions that we created for writing characters and moving the cursor for this.
Following is the kmain.c
file configured to write string: “Welcome to CarbonOS” to the console.
It is a good practice to move the driver to a separate header file as it will be more convenient to use in the future.
The Serial Ports
The serial port is an interface that allows hardware devices to communicate with one another. It is simple to use and can be used as a logging utility in Bochs. If a computer supports a serial port, it usually supports multiple serial ports as well. We will, however, only use one of the ports. This is because we will only use the serial ports for logging. Furthermore, we will only use the serial ports for output, not input. The serial ports are completely controlled via I/O ports.
Configuring the Serial Port
The first piece of data that needs to be sent to the serial port is configuration data. A couple of things must be agreed upon before two hardware devices can communicate with each other. Among these are:
- The speed at which data is sent (bit or baud rate),
- If any error checking for the data should be used (parity bit, stop bits), and
- The number of bits that represent a unit of data (data bits).
Configuring the Line
Configuring the line means configuring how data is transmitted across the line. The serial port has an I/O port for configuration, known as the line command port.
The speed at which data is sent will be determined first. The internal clock speed of the serial port is 115200 Hz. Setting the speed means sending a divisor to the serial port, for example sending 2 results in a speed of 115200 / 2 = 57600 Hz.
The divisor is a 16 bit number but we can only send 8 bits at a time. As a result, we must send an instruction to the serial port, instructing it to expect the highest 8 bits first, followed by the lowest 8 bits. This can be done by sending 0x80 to the line command port. The following function can be used to set the speed.
Furthermore, The method by which data should be sent must be configured. This is also done by sending a byte to the line command port. The layout of the 8 bits is as follows:
Bit: | 7 | 6 | 5 4 3 | 2 | 1 0 |
Content: | d | b | prty | s | dl |
Following are the meanings of the above-mentioned names:
d Enables (d = 1) or disables (d = 0) DLABb If break control is enabled (b = 1) or disabled (b = 0)prty The number of parity bits to uses The number of stop bits to use (s = 0 equals 1, s = 1 equals 1.5 or 2)dl Describes the length of the data
You can refer to this article on OSDev to get a better understanding of these values. However, We will use the most standard value 0x03
, which means a length of 8 bits, no parity bit, one stop bit and break control disabled. This can be sent to the line command port, as seen in the following code:
Configuring the Buffers
When data is transmitted via the serial port it is placed in buffers, both when receiving and sending data. This way, if you send data to the serial port faster than it can send it over the wire, it will be buffered. However, if you send too much data too fast, the buffer will be full and data will be lost. In other words, the buffers function as FIFO queues. The FIFO queue configuration byte is depicted in the figure below:
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:
lvl How many bytes should be stored in the FIFO buffersbs If the buffers should be 16 or 64 bytes larger Reserved for future usedma How the serial port data should be accessedclt Clear the transmission FIFO bufferclr Clear the receiver FIFO buffere If the FIFO buffer should be enabled or not
This WikiBook on serial programming goes into greater detail about the values.
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.
We can use the following code to configure the buffers with the above value.
Configuring 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, RTS and DTR should be set to 1, indicating that we are ready to send data.
The modem configuration byte is shown in the following figure:
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.
r Reserved
af Autoflow control enabled
lb Loopback mode (used for debugging serial ports)
ao2 Auxiliary output 2, used for receiving interrupts
ao1 Auxiliary output 1
rts Ready To Transmit
dtr Data Terminal Ready
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. We can use the following C function to achieve that.
Writing Data to the Serial Port
The data I/O port is used to write data to the serial port. However, the transmit FIFO queue must be empty before writing (all previous writes must have been finished). The transmit FIFO queue is empty if bit 5 of the line status I/O port is equal to one.
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:
Checking if the transmit FIFO is empty can then be done from C:
Writing to a serial port means spinning as long as the transmit FIFO queue isn’t empty, and then writing the data to the data I/O port.
Configuring Bochs
The Bochs configuration file bochsrc.txt must be updated to save the output from the first serial port. The com1 configuration instructs Bochs on 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
.
The driver for serial port
Since we implemented a lot of functions when configuring the serial port, It is more convenient 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 provided below:
Because we talked about a lot of theories and codes, I’d like to give you a clear explanation of the project’s current state to avoid any confusion. First, take a look at the image below to get an understanding of the files in our working directory. (Note that I’ve moved the driver header files to a separate folder).
Linked below is the implementation that I did with this article. Go through the codes and try to understand them with the information given in the article. Then you will be more clear and up to date with the project.
I know this has been a very long article but I had to put all of the above in one place. Please feel free to leave a comment if you have any questions about any of the things we discussed above. For now, since you’ve made it this far, I’d like to suggest a hot beverage to help you unwind☕😜. And I’ll see you in the next article, where I’ll talk about segmentation. Until then, Goodbye and stay safe!
Reference: The little book about OS development (Erik Helin, Adam Renberg)
-Pasan Devin Jayawardene-