This page looks best with JavaScript enabled

MercuryOS Hardware I/O

 ·  🎃 kr0m

Historically, the CPU communicated directly with device controllers, but this meant that the CPU had to lower its speed to that of the slowest device in the system. To avoid this problem, the CPU now communicates with the bus controller, which in turn communicates with the bus controller of that technology, forming a hierarchy of buses. This hierarchy is necessary to prevent the higher-level bus controller from being forced to work at the speed of the slowest device. In this article, we will learn how to communicate with devices using I/O ports.

Before we begin, it is recommended that you read these previous articles:


Unlike video memory, the registers of the controllers are mapped to a different memory than the main memory. This special memory is called I/O address space. Each of these mappings is called port I/O, and read/write instructions are performed using special instructions.

The typical example is the 82077AA floppy disk drive. To start the motor, you need to follow these steps:

  • Store the address where the port that activates the motor is mapped in the dx register.
  • Set the register to 00001000.
  • Set the memory address pointed to by dx to the value al.

In ASM, this would be:

mov dx , 0x3f2 ; Must use DX to store port address
or al , 00001000b ; Switch on the motor bit
out dx , al ; Update DOR of the device.

In older systems that used ISA , the memory addresses of the ports were statically assigned to each device. But with plug-and-play buses and PCI , the BIOS can dynamically assign addresses to the devices before booting the OS. To perform this task, devices must communicate their configuration through a bus that describes the number of I/O ports needed, how much memory, and a hardware ID.

One drawback of using port I/O is that they cannot be configured from C. We must inject parts of ASM into our code. Additionally, the syntax followed in C is GAS, the order of the operands is reversed, and the % characters must be escaped with %, resulting in code that is really unpleasant to look at.

An example of reading would be:

unsigned char port_byte_in(unsigned short port) {
    unsigned char result ;
    __asm__("in %% dx , %% al" : "=a" (result) : "d" (port));
    return result ;
}

As input parameters, we need to indicate the port (memory address), and the function will return the value of the port in the result variable.

Let’s program some functions for reading and writing ports that will be useful in the near future:

vi drivers/ports.c

/**
 * Read a byte from the specified port
 */
unsigned char port_byte_in(unsigned short port) {
    unsigned char result;
    __asm__("in %%dx, %%al" : "=a" (result) : "d" (port));
    return result;
}

/**
 * Write a byte to the specified port
 */
void port_byte_out(unsigned short port, unsigned char data) {
    __asm__("out %%al, %%dx" : : "a" (data), "d" (port));
}

/**
 * Read a word from the specified port
 */
unsigned short port_word_in(unsigned short port) {
    unsigned short result;
    __asm__("in %%dx, %%ax" : "=a" (result) : "d" (port));
    return result;
}

/**
 * Write a word to the specified port
 */
void port_word_out(unsigned short port, unsigned short data) {
    __asm__("out %%ax, %%dx" : : "a" (data), "d" (port));
}

In the header file, we will define the interface of the functions.

vi drivers/ports.h

unsigned char port_byte_in(unsigned short port);
void port_byte_out(unsigned short port, unsigned char data);
unsigned short port_word_in(unsigned short port);
void port_word_out(unsigned short port, unsigned short data);

To test the access to the I/O ports, we are going to move the cursor on the screen, but first we need to have some clear ideas:

  • In text mode, the monitor has 80x25: 2000 characters in total.
  • Register 3D5 is the data register, with which we will control the cursor position. We must indicate the character position where we want to place it (0-1999). Additionally, the position must be assigned in two phases:
    • First, the most significant byte: We move the binary number 8 positions to the right, obtaining the first 8 bits of the number.
    • Then, the least significant byte: We perform a bitwise AND operation with the value 0xff: 11111111, which allows us to obtain the last 8 bits of the number.
  • Register 3D4 is the control register, with which we will indicate whether we are going to write the first byte in register 3D5 or the second byte. If we send command 14, we will write the first byte; if we send command 15, we will write the second byte.

Our kernel would look like this:

vi kernel/kernel.c

#include "../drivers/ports.h"

void main() {
    int cursor_x = 2;
    int cursor_y = 5;
    int char_pos = cursor_y * 80 + cursor_x;
    port_byte_out(0x3D4,14); // We indicate that we are going to write first byte in 0x3D5
    port_byte_out(0x3D5,char_pos>>8); // Write first byte
    port_byte_out(0x3D4,15); // We indicate that we are going to write second byte in 0x3D5
    port_byte_out(0x3D5,(char_pos & 0xff)); // Write second byte
}

We recompile the kernel using make as explained in previous articles :

gmake run

As we can see, the cursor has moved to position (2,5).

If you liked the article, you can treat me to a RedBull here