Build your own Operating System #3

Gayan Malinda
13 min readAug 6, 2021

Integrating Outputs

Hello everyone!

This is the third article of the “Build your own Operating System “ article series. In previous articles, I discussed how to set up the development environment and booting a primitive operating system, and implementation with C programming language. First of all, I am linking the first two articles in case you haven’t followed them. So, I like to suggest, please refer to previous articles, before reading this.

In this article, I am going to explain how to display text on the console as well as writing 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

Drivers teach the operating system to interact with each bit of hardware. Graphics cards, sound cards, networking cards, USB peripherals, and everything else you connect to your computer relies on drivers. The operating system then uses these drivers to ensure correct operation of each device.

The way to interact with hardware has two, memory maps I / O and I/ O ports.

Memory-mapped I/O

Memory-mapped I/O uses the same address space to address both memory and I/O devices. The memory and registers of the I/O devices are mapped to (associated with) address values. So a memory address may refer to either a portion of physical RAM, or instead to memory of the I/O device. Thus, the CPU instructions used to access the memory can also be used for accessing devices.

If the hardware uses the I / O of the memory map, you can write a specific memory address, and the hardware will be updated using new data. The frame buffer is an example and will be discussed in detail later.
For example, if the value 0x410f is written to the address 0x000b8000, the white letter A will be seen on a black background

I/O ports

An I/O port is a socket on a computer that a cable is plugged into. The port connects the CPU to a peripheral device via a hardware interface or to the network via a network interface.

If the hardware uses the I / O port, you must communicate with the hardware using the assembly code instructions of OUT and IN. The OUT command has two parameters: the address of the I / O port, and the data to be sent. The in order uses a single parameter, that is, the address of the I / O port, and returns data from the hardware. The I / O port can be deemed to communicate with the hardware is the same as the usage socket communication with the server. The cursor (flicker rectangle) frame buffer is an example of hardware controlled through the I / O port on the PC.

Before all this to be done, We have to configure two devices. They are,

  1. Framebuffer
  2. Serial Ports

The Framebuffer

A framebuffer (frame buffer, or sometimes frame store) is a portion of random-access memory (RAM) containing a bitmap that drives a video display. It is a memory buffer containing data representing all the pixels in a complete video frame. The framebuffer has 80 columns and 25 rows, and the row and column indices start at 0 (so rows are labeled 0–24, and columns ate labeled 0–79).

Writing text to the console via framebuffer

The text write console is performed by the frame buffer is completed by the I / O map of memory. The starting address of the memory mapping I / O of the frame buffer is 0x000b8000.The memory is divided into 16-bit unit, 16 bits determine characters, foreground, and background colors. The highest eight is the character’s ASCII value, bit 7–4 is the background, bit 3–0 is the foreground, as can be seen in the following figure:

Bit:     | 15 14 13 12 11 10 9 8 | 7 6 5 4 | 3 2 1 0 |Content: |          ASCII        |    BG   |    FG   |

The available colors are shown in the following table:

On the console, The first cell corresponds to the first line, the zero column on the console. Using the ASCII table, you can see A correspond to 65 or 0x41. Therefore, it is necessary to write character A with green foreground (2) and dark gray background (8) at position (0, 0), use the following assembly code instructions:

mov [0x000B8000], 0x4128

Then, The second cell then corresponds to row zero, column one and its address is therefore:

0x000B8000 + 16 = 0x000B8010

By treating address 0x000B8000 as a CHAR pointer char * fb = (char *) 0x000b8000, the write to the frame buffer can be completed in the C language. Then, write A to the position (0, 0) having a green foreground and dark gray background to:

fb[0] = ‘A’;
fb[1] = 0x28;

The following shows how to apply it to a function:

The above function can be called as follows:

Now your console should look like this.

Moving the Cursor

Moving the cursor of the framebuffer is done via two different I/O ports. The cursor’s position is determined with a 16 bits integer: 0 means 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 8 bits then the next 8 bits. The framebuffer has two I/O ports, one for accepting the data, and one for describing the data being received. Port 0x3D4 is the port that describes the data 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 0x3D4, 14           ; 14 tells the framebuffer to expect the highest 8 bits of the positionout 0x3D5, 0x00         ; sending the highest 8 bits of 0x0050out 0x3D4, 15           ; 15 tells the framebuffer to expect the lowest 8 bits of the positionout 0x3D5, 0x50         ; sending the lowest 8 bits of 0x0050

The out assembly code instruction can’t be executed directly in C. Therefore it is a good idea to wrap 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:

C header file to access outb() function

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(400); // move the cursor to 400th position

The Driver

In computing, a device driver is a computer program that operates or controls a particular type of device that is attached to a computer or automaton. A driver provides a software interface to hardware devices, enabling operating systems and other computer programs to access hardware functions without needing to know precise details about the hardware being used.

A driver communicates with the device through the computer bus or communications subsystem to which the hardware connects. When a calling program invokes a routine in the driver, the driver issues commands to the device. Once the device sends data back to the driver, the driver may invoke routines in the original calling program.

Drivers are hardware-dependent and operating-system-specific. They usually provide the interrupt handling required for any necessary asynchronous time-dependent hardware interface.

The main purpose of device drivers is to provide abstraction by acting as a translator between a hardware device and the applications or operating systems that use it. Programmers can write higher-level application code independently of whatever specific hardware the end-user is using. For example, a high-level application for interacting with a serial port may simply have two functions for “send data” and “receive data”. At a lower level, a device driver implementing these functions would communicate to the particular serial port controller installed on a user’s computer. The commands needed to control a 16550 UART are much different from the commands needed to control an FTDI serial port converter, but each hardware-specific device driver abstracts these details into the same (or similar) software interface.

Device drivers, particularly on modern Microsoft Windows platforms, can run in kernel-mode (Ring 0 on x86 CPUs) or in user-mode (Ring 3 on x86 CPUs). The primary benefit of running a driver in user mode is improved stability, since a poorly written user-mode device driver cannot crash the system by overwriting kernel memory. On the other hand, user/kernel-mode transitions usually impose a considerable performance overhead, thus making kernel-mode drivers preferred for low-latency networking.

Kernel space can be accessed by user module only through the use of system calls. End-user programs like the UNIX shell or other GUI-based applications are part of user space. These applications interact with hardware through kernel-supported functions.

Implementing the driver for the framebuffer

Now 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.

Driver for writing strings in the console

Following is the kmain.c file configured to write string: “Hello World” to the console.

kmain.c file configured to write string: “Hello World” 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.

So, I modified my code. Now I make frameBuffer.h header file and modified my kamin.c looks like below. It will be more convenient now.

frameBuffer.h header file
Modified kmain.c file

The Serial Ports

The serial port is an interface for communicating between hardware devices and although it is available on almost all motherboards, it is seldom exposed to the user in the form of a DE-9 connector nowadays. 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, then it usually has support for multiple serial ports, but we will only make use of 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 data that need to be sent to the serial port is configuration data. In order for two hardware devices to be able to talk to each other they must agree upon a couple of things. These things include:

· The speed used for sending data (bit or baud rate)

· If any error checking should be used for the data (parity bit, stop bits)

· 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.

Function to set the data transmission speed

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 |

A description for each name can be found in the table below:

 Name                          Description  d          Enables (d = 1) or disables (d = 0) DLAB  b          If break control is enabled (b = 1) or disabled (b = 0) prty        The number of parity bits to use  s          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.

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 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 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:

 Name                          Description lvl         How many bytes should be stored in the FIFO buffers bs          If the buffers should be 16 or 64 bytes large  r          Reserved for future use dma         How the serial port data should be accessed clt         Clear the transmission FIFO buffer clr         Clear the receiver FIFO buffer  e          If the FIFO buffer should be enabled or not

This WikiBook on serial programming goes into greater detail about the values.

We use the value 0xC7 = 11000111 that:

· Enables FIFO

· Clear both receiver and transmission FIFO queues

· Use 14 bytes as size of queue

We can use the following code to configure the buffers with the above value.

The function for configuring buffers

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 we want RTS and DTR to be 1,

which means 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 |

A description for each name can be found in the table below:

 Name                        Description  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.

The function for configuring the modem

Writing Data to the Serial Port

Writing data to the serial port is done via the data I/O port. However, before writing, the transmit FIFO queue has to be empty (all previous writes must have 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:

Assembly function to wrap in
C header file to access inb() function

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

To save the output from the first 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

The output from serial port one will now be stored 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:

Finally, remember to import frameBuffer.h and serialPort.h header files to your kmain.c file.

Look at the image below to get an understanding of the files in our working directory. It will avoid more confusion.

The link below is the implementation that I did with this article. Go through the codes and then you will be more clear and up to date with the project.

Congratulations! now you know how to print something in the console. In the next article, I’ll talk about segmentation. Thank you so much for reading!

References:

The Little OS Book: https://littleosbook.github.io/book.pdf

--

--

Gayan Malinda

Software Engineering Undergraduate - University of Kelaniya, Sri Lanka