How To Build An Operating System: Play with `Outputs`-Part 03

Pubudu Wickramathunge
10 min readAug 6, 2021

This is the third article of this building an own operating system article series. In this article, I will be discussing how to display text on the console and how to write data to the serial port after that I will teach you how to create the first driver for our OS.

Interacting with the Hardware

Memory-mapped I/O and I/O ports are the usual ways to interact with the hardware. If the hardware uses memory-mapped I/O, you can write to a specific memory address and the hardware will be updated with the new data. One example of this is the framebuffer, which will be discussed in more detail later. For example, if you write the value 0x410F to address 0x000B8000, you will see the letter A in white color on a black background

If the hardware uses I/O ports then the assembly code instructions out and in must be used to communicate with the hardware. The instruction out takes two parameters: the address of the I/O port and the data to send. The instruction in takes a single parameter, the address of the I/O port, and returns data from the hardware. One can think of I/O ports as communicating with hardware the same way as you communicate with a server using sockets. The cursor of the framebuffer is one example of hardware controlled via I/O ports on a PC.

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.

In computing, a screen buffer is a part of computer memory used by a computer application for the representation of the content to be shown on the computer display. The screen buffer may also be called the video buffer, the regeneration buffer, or regen buffer for short. Screen buffers should be distinguished from video memory. To this end, the term off-screen buffer is also used.

The information in the buffer typically consists of color values for every pixel to be shown on the display. Color values are commonly stored in 1-bit binary, 4-bit palettized, 8-bit palettized, 16-bit high color, and 24-bit true-color formats. An additional alpha channel is sometimes used to retain information about pixel transparency. The total amount of memory required for the frame buffer depends on the resolution of the output signal and the color depth or palette size.

Writing text to the console

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 . The memory is divided into 16-bit cells, where the 16 bits determine both the character, the foreground color, and the background color. The highest eight bits is the ASCII value of the character, bit 7 - 4 the background, and bit 3 - 0 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 | FG | BG |

These are the available colors.

color : value

Black : 0
Blue : 1
Green : 2
Cyan : 3
Red : 4
Magenta : 5
Brown : 6
Light grey : 7
Dark grey : 8
Light blue : 9
Light green : 10
Light cyan : 11
Light red : 12
Light magenta : 13
Light brown : 14
White : 15

The first cell corresponds to row zero, column zero on the console. Using an ASCII table, one can see that A corresponds to 65 or 0x41. Therefore, to write the character A with a green foreground (2) and dark grey background (8) at place (0,0), the following assembly code instruction is 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 a char pointer, char *fb = (char *) 0x000B8000. Then, writing A at place (0,0) with green foreground and dark grey background becomes:

fb[0] = 'A';
fb[1] = 0x28;

The following code shows how this can be wrapped into a function:

/** 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);

Moving the Cursor

Two different I/O ports use to move the cursor of the framebuffer. 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 [29] is the port that describes the data and port 0x3D5 [29] 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 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

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 that can be accessed from C via the cdecl calling standard.

Now we should store the following function in a file called io.s.

After that creating a header io.h, the out assembly code instruction can be conveniently accessed from C.

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 the function writes the contents of the buffer buf of length len to the screen. The write the function should automatically advance the cursor after a character has been written and scroll the screen if necessary.

The Serial Ports

A serial port is an interface that uses to communicate between hardware devices and although it is available on almost all motherboards. The serial port is easy to use, and, more importantly, it can be used as a logging utility in Bochs too. In this section, 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

First, we need to send configuration data to the serial port. 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

Here, we need to configure, how data is being sent over the line. The serial port has an I/O port, which is a configuration line command port. First, the data transmission speed will be configured. The serial port features an internal clock with a 115200 Hz frequency. Setting speed to a serial port implies sending divisions, such as transmission 2, at a rate of 115200/2 = 57600 Hz.

Even if the divisor is a 16-bit number, we can only send 8 bits at a time. So, we need to 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"    #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)
#define SERIAL_LINE_ENABLE_DLAB 0x80void 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 |

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

NameDescriptiondEnables (d = 1) or disables (d = 0) DLAB
bIf break control is enabled (b = 1) or disabled (b = 0)
prtyThe 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

We will use the most standard value 0x03 , 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

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

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.

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 handle any received data. Therefore we use the configuration value 0x03 = 00000011 (RTS = 1 and DTS = 1).

Writing Data to the Serial Port

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:

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
/* in file io.h */ /** inb:
* Read a byte from an I/O port.
*
* @param port The address of the I/O port
* @return The read byte
*/
unsigned char inb(unsigned short port);

Checking if the transmit FIFO is empty can then be done from C:

#include "io.h"    /** serial_is_transmit_fifo_empty:
* Checks whether the transmit FIFO queue is empty or not for the given COM
* port.
*
* @param com The COM port
* @return 0 if the transmit FIFO queue is not empty
* 1 if the transmit FIFO queue is empty
*/
int serial_is_transmit_fifo_empty(unsigned int com)
{
/* 0x20 = 0010 0000 */
return inb(SERIAL_LINE_STATUS_PORT(com)) & 0x20;
}

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

After all the final serial_port.h header file will look like the following:

The output from serial port one will now be stored in the file com1.out. Now you can run your OS using the make run command.

This is all for this article. Hope you enjoyed it!

You can prefer The little book about OS development/Erik Helin, Adam Renberg and my git repository for more help.

Thank you for reading. See you in the next article!

--

--

Pubudu Wickramathunge

Software Engineering Undergraduate at University of Kelaniya