Build your own operating system
Part 03
Hello and welcome everyone again to the third part of my OS implementation article series.
I covered how to develop C code for our operating system in the last article.
I’ll teach you how to use those features, as well as assembly language, to incorporate outputs into our operating system in this article.
We’ll go over how to use the serial port to show text on the console and write data to a file.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.
Hardware interaction
As you may already know, there are two different ways to interact with hardware.
- Memory-mapped I/O
- I/O ports
Memory-mapped I/O
If we are using Memory-mapped I/O, we can write data to a specific memory address and the hardware will be updated with the new data. One example for Memory-mapped I/O is, Framebuffer.
I/O ports
On the other hand, if we are using I/O ports, we can read
or write
data through a specific port. We will be using Assembly instructions in
and out
to communicate with the hardware using I/O ports. The cursor (the blinking rectangle) of the framebuffer is one example of hardware controlled via I/O ports on a PC.
Framebuffer
The framebuffer(Frame store) is a hardware device that is capable of displaying a buffer of memory on the screen. It is a memory buffer containing data representing all the pixels in a complete video frame. Modern video cards contain framebuffer circuitry in their cores. The framebuffer has 80 columns and 25 rows, and the row and column indices start at 0 (so rows are labelled 0–24).
1.Writing text to the screen
Now we are going to write text to the screen using framebuilder. The starting address of the memory-mapped I/O for the framebuffer is 0x000B8000
. The memory is divided in to 16 bits and it determine the charactor, foreground colour and the background colour as bellow.
Bit: | 15 14 13 12 11 10 9 8 | 7 6 5 4 | 3 2 1 0 |
Content: | ASCII | FG | BG |
The available colors are shown in the following table:
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 place (0,0) with green foreground and dark grey background:
fb[0] = 'A';
fb[1] = 0x28;
The following code shows how it can be done. (You have to call kmain
in the loader.s
file.)
/** fb_write_cell:
* Writes a character with the given foreground and background to position i
* in the framebuffer.
*
* @param i The location in the framebuffer
* @param c The character
* @param fg The foreground color
* @param bg The background color
*/
void fb_write_cell(unsigned int i, char c, unsigned char fg, unsigned char bg)
{
fb[i] = c;
fb[i + 1] = ((fg & 0x0F) << 4) | (bg & 0x0F)
}
The function can then be used as follows:
#define FB_GREEN 2
#define FB_DARK_GREY 8
fb_write_cell(0, 'A', FB_GREEN, FB_DARK_GREY);
Now you have successfully displayed ‘A’ in the screen using framebuilder as shown in the following screenshot.
2.Moving the Cursor
he mobile frame buffer’s cursor is controlled by two separate I/O ports. The cursor’s location is defined by 16-bit integers: 0 represents line 0, column 0; 1 represents row 0, column 1; 80 represents a row, zero columns, and so on. Because the location is 16 digits long, the assembly code instructions are 8 bits long, thus it must be delivered in two parts: first 8 bits, then 8 bits more. The frame buffer contains two I/O ports: one for receiving data and the other for summarizing the data that has been received.
The port 0x3d4 is used to describe data, whereas the port 0x3d5 is used to store the data.
The cursor should be placed in the first row column (position 80 = 0x0050). following assembly code instructions will be used:
out 0x3D4, 14 ; 14 tells the framebuffer to expect the highest 8 bits of the position
out 0x3D5, 0x00 ; sending the highest 8 bits of 0x0050
out 0x3D4, 15 ; 15 tells the framebuffer to expect the lowest 8 bits of the position
out 0x3D5, 0x50 ; sending the lowest 8 bits of 0x0050
In C, the OUT assembly code instruction cannot be directly executed. As a result, it’s better to put assembly code in a function that can be called from C via CDECL calls.
global outb ; make the label outb visible outside this file
; outb - send a byte to an I/O port
; stack: [esp + 8] the data byte
; [esp + 4] the I/O port
; [esp ] return address
outb:
mov al, [esp + 8] ; move the data to be sent into the al register
mov dx, [esp + 4] ; move the address of the I/O port into the dx register
out dx, al ; send the data to the I/O port
ret ; return to the calling function
After creating a c file named io.h , you may simply access the OUT assembly code instruction from C by putting this function in a file named io.s.
#ifndef INCLUDE_IO_H
#define INCLUDE_IO_H
/** outb:
* Sends the given data to the given I/O port. Defined in io.s
*
* @param port The I/O port to send the data to
* @param data The data to send to the I/O port
*/
void outb(unsigned short port, unsigned char data);
#endif /* INCLUDE_IO_H */
application:(kmain.c file)
#include "io.h"
/* The I/O ports */
#define FB_COMMAND_PORT 0x3D4
#define FB_DATA_PORT 0x3D5
/* The I/O port commands */
#define FB_HIGH_BYTE_COMMAND 14
#define FB_LOW_BYTE_COMMAND 15
/** fb_move_cursor:
* Moves the cursor of the framebuffer to the given position
*
* @param pos The new position of the cursor
*/
void fb_move_cursor(unsigned short pos)
{
outb(FB_COMMAND_PORT, FB_HIGH_BYTE_COMMAND);
outb(FB_DATA_PORT, ((pos >> 8) & 0x00FF));
outb(FB_COMMAND_PORT, FB_LOW_BYTE_COMMAND);
outb(FB_DATA_PORT, pos & 0x00FF);
}
Now, run make run command.
You will get following output in the emulator.
3.The Driver
The driver should provide an interface that the rest of the code in the OS will use for interacting with the framebuffer. There is no right or wrong in what functionality the interface should provide, but a suggestion is to have a write
function with the following declaration:
int write(char *buf, unsigned int len);
The write
function writes the contents of the buffer buf
of length len
to the screen. The write
function should automatically advance the cursor after a character has been written and scroll the screen if necessary.
4.The Serial Ports
Despite the fact that the serial port is found on almost all motherboards, it is rarely used by the user in the form of a DE-9 connection.
The serial port is simple to use and may be used to log into Bochs as well.
If your computer has a serial port, it probably has several, but we’ll only need one of them.
This is because the serial port will only be used for logging. We also solely utilize the serial port for output rather than input.
The I/O port has complete control over the serial port
Setting up the serial port
Configuration data is the first thing that has to be transmitted to the serial port. Two hardware devices must agree on several things before they may interact with one another. These are some of them: data transmission speed (bit or baud rate) (bit or baud rate) If any error check (parity bit, stop bit) should be used to indicate the number of bits (data bits)
Configuring the Line
Configuring the line means to configure how data is being sent over the line. The serial port has an I/O port, the line command port, that is used for configuration.
The data transmission speed will be configured first.The internal clock in the serial port is set to 115200 Hz.Setting the speed involves sending a divisor to the serial port, such as 2 for a speed of 115200 / 2 = 57600 Hz.
The divisor is a 16 bit number but we can only send 8 bits at a time. We must therefore send an instruction telling the serial port to first expect the highest 8 bits, then the lowest 8 bits. This is done by sending 0x80
to the line command port. An example is shown below:
#include "io.h" /* io.h is implement in the section "Moving the cursor" */
/* The I/O ports */
/* All the I/O ports are calculated relative to the data port. This is because
* all serial ports (COM1, COM2, COM3, COM4) have their ports in the same
* order, but they start at different values.
*/
#define SERIAL_COM1_BASE 0x3F8 /* COM1 base port */
#define SERIAL_DATA_PORT(base) (base)
#define SERIAL_FIFO_COMMAND_PORT(base) (base + 2)
#define SERIAL_LINE_COMMAND_PORT(base) (base + 3)
#define SERIAL_MODEM_COMMAND_PORT(base) (base + 4)
#define SERIAL_LINE_STATUS_PORT(base) (base + 5)
/* The I/O port commands */
/* SERIAL_LINE_ENABLE_DLAB:
* Tells the serial port to expect first the highest 8 bits on the data port,
* then the lowest 8 bits will follow
*/
#define SERIAL_LINE_ENABLE_DLAB 0x80
/** serial_configure_baud_rate:
* Sets the speed of the data being sent. The default speed of a serial
* port is 115200 bits/s. The argument is a divisor of that number, hence
* the resulting speed becomes (115200 / divisor) bits/s.
*
* @param com The COM port to configure
* @param divisor The divisor
*/
void serial_configure_baud_rate(unsigned short com, unsigned short divisor)
{
outb(SERIAL_LINE_COMMAND_PORT(com),
SERIAL_LINE_ENABLE_DLAB);
outb(SERIAL_DATA_PORT(com),
(divisor >> 8) & 0x00FF);
outb(SERIAL_DATA_PORT(com),
divisor & 0x00FF);
}
The way that data should be sent must be configured. This is also done via the line command port by sending a byte. 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 |
Descriptions for each of the above names.
We will use the mostly standard value 0x03
[31], meaning a length of 8 bits, no parity bit, one stop bit and break control disabled. This is sent to the line command port, as seen in the following example:
/** serial_configure_line:
* Configures the line of the given serial port. The port is set to have a
* data length of 8 bits, no parity bits, one stop bit and break control
* disabled.
*
* @param com The serial port to configure
*/
void serial_configure_line(unsigned short com)
{
/* Bit: | 7 | 6 | 5 4 3 | 2 | 1 0 |
* Content: | d | b | prty | s | dl |
* Value: | 0 | 0 | 0 0 0 | 0 | 1 1 | = 0x03
*/
outb(SERIAL_LINE_COMMAND_PORT(com), 0x03);
}
Configuring the Buffers
Data is placed in buffers when communicated via the serial port, both when receiving and sending data.If you transmit data to the serial port quicker than the serial port can transfer it across the wire, the data will be buffered.If you send too much data too quickly, though, the buffer will fill up and data will be lost.The buffers, in other terms, are FIFO queues. The FIFO queue configuration byte looks like the following figure:
Bit: | 7 6 | 5 | 4 | 3 | 2 | 1 | 0 |
Content: | lvl | bs | r | dma | clt | clr | e |
A description for each name can be found in the table below:
We use the value 0xC7 = 11000111
that will:
- Enables FIFO
- Clear both receiver and transmission FIFO queues
- Use 14 bytes as size of queue
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.
Bit: | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
Content: | r | r | af | lb | ao2 | ao1 | rts | dtr |
In here, we don’t need to enable interrupts, because we won’t handle any received data. Therefore we use the configuration value 0x03 = 00000011
(RTS = 1 and DTS = 1) for now.
Writing Data to the Serial Port
Before writing to a serial port, the transmit FIFO queue has to be empty. The transmit FIFO queue is empty if bit 5 of the line status I/O port is equal to one.
Reading the contents of an I/O port is done via the in
assembly code instruction. There is no way to use the in
assembly code instruction from C, therefore it has to be wrapped (the same way as the out
assembly code instruction):
global inb
; inb - returns a byte from the given I/O port
; stack: [esp + 4] The address of the I/O port
; [esp ] The return address
inb:
mov dx, [esp + 4] ; move the address of the I/O port to the dx register
in al, dx ; read a byte from the I/O port and store it in the al register
ret ; return the read byte
So, append the above code to the end of the io.s
file that you have already created. And then add the following statement to the end of the io.h
file.
unsigned char inb(unsigned short port);
I thought it’s better to define all the functions for the serial writing part, in a header file called serial_write.h
and include it into the kmain.c
file. And i wrote a custom function to write a serial buffer. So, after all the configurations, the serial_write.h
file will look like this:
#ifndef INCLUDE_IO_H
#define INCLUDE_IO_H#include "io.h" /* io.h is implement in the section "Moving the cursor" */
/* The I/O ports */
/* All the I/O ports are calculated relative to the data port. This is because
* all serial ports (COM1, COM2, COM3, COM4) have their ports in the same
* order, but they start at different values.
*/
#define SERIAL_COM1_BASE 0x3F8 /* COM1 base port */
#define SERIAL_DATA_PORT(base) (base)
#define SERIAL_FIFO_COMMAND_PORT(base) (base + 2)
#define SERIAL_LINE_COMMAND_PORT(base) (base + 3)
#define SERIAL_MODEM_COMMAND_PORT(base) (base + 4)
#define SERIAL_LINE_STATUS_PORT(base) (base + 5)
/* The I/O port commands */
/* SERIAL_LINE_ENABLE_DLAB:
* Tells the serial port to expect first the highest 8 bits on the data port,
* then the lowest 8 bits will follow
*/
#define SERIAL_LINE_ENABLE_DLAB 0x80
/** serial_configure_baud_rate:
* Sets the speed of the data being sent. The default speed of a serial
* port is 115200 bits/s. The argument is a divisor of that number, hence
* the resulting speed becomes (115200 / divisor) bits/s.
*
* @param com The COM port to configure
* @param divisor The divisor
*/
void serial_configure_baud_rate(unsigned short com, unsigned short divisor)
{
outb(SERIAL_LINE_COMMAND_PORT(com),
SERIAL_LINE_ENABLE_DLAB);
outb(SERIAL_DATA_PORT(com),
(divisor >> 8) & 0x00FF);
outb(SERIAL_DATA_PORT(com),
divisor & 0x00FF);
}void serial_configure_line(unsigned short com) { /* Bit: | 7 | 6 | 5 4 3 | 2 | 1 0 |
* Content: | d | b | prty | s | dl |
* Value: | 0 | 0 | 0 0 0 | 0 | 1 1 | = 0x03 * data length of 8 bits, one stop bit, no parity bit, break control * disabled and DLAB disabled
*/
outb(SERIAL_LINE_COMMAND_PORT(com), 0x03);
}void serial_configure_fifo_buffer(unsigned short com) { /* Bit: | 7 6 | 5 | 4 | 3 | 2 | 1 | 0 | * Content: | lvl | bs | r | dma | clt | clr | e | * Value: | 1 1 | 0 | 0 | 0 | 1 | 1 | 1 | = 0xC7 */
outb(SERIAL_FIFO_COMMAND_PORT(com), 0xC7);
}
void serial_configure_modem(unsigned short com)
{
/* Bit: | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | * Content: | r | r | af | lb | ao2 | ao1 | rts | dtr | * Value: | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | = 0x03 */
outb(SERIAL_MODEM_COMMAND_PORT(com), 0x03);
}
int serial_is_transmit_fifo_empty(unsigned short com)
{
/* 0x20 = 0010 0000 */
return inb(SERIAL_LINE_STATUS_PORT(com)) & 0x20; }
void serial_configure(unsigned short port, unsigned short baudRate) {
serial_configure_baud_rate(port, baudRate); serial_configure_line(port); serial_configure_fifo_buffer(port); serial_configure_modem(port); } void serial_write_byte(unsigned short port, char byteData)
{ outb(port, byteData); }
int serial_write(unsigned short com, char *buf, unsigned int len)
{
unsigned int indexToBuffer = 0;
while (indexToBuffer < len) {
if (serial_is_transmit_fifo_empty(com)) { serial_write_byte(com, buf[indexToBuffer]); indexToBuffer++;
} } return 0;
}
#endif
Configuring Bochs
To save the output from the first serial serial port the Bochs configuration file bochsrc.txt
must be updated. The com1
configuration instructs Bochs how to handle first serial port:
com1: enabled=1, mode=file, dev=com1.out
Now you should be able to write to a serial port. After running the Bochs
Emulator, execute the command cat com1.out
and you will see your message.
Hope you have successfully integrated outputs to your OS and hope to catch you in the next article.
Thank you…