Internet Windows Android

How to write an operating system kernel. Assembler School: Operating System Development

Reading Habr over the past two years, I saw only a few attempts to develop the OS (specifically: from users and (postponed indefinitely) and (not abandoned, but for now looks more like a description of the protected mode of x86-compatible processors, which is undoubtedly also you need to know to write an OS for x86); and a description of the finished system from (though not from scratch, although there is nothing wrong with that, maybe even the other way around)). For some reason, I think that almost all system (and part of the application) programmers at least once, but thought about writing their own operating system. In this connection, 3 OS from the large community of this resource seems like a ridiculous number. Apparently, most of those who think about their own OS do not go anywhere further than the idea, a small part stops after writing the bootloader, few write pieces of the kernel, and only the hopelessly stubborn create something remotely resembling an OS (when compared with something like Windows / Linux) ... There are many reasons for this, but the main one, in my opinion, is that people quit development (some of them did not even have time to start) because of the small number of descriptions of the process of writing and debugging the OS itself, which is quite different from what happens during development applied software.

With this small note I would like to show that, if you start correctly, then there is nothing particularly difficult in developing your own OS. Below the cut is a short and fairly general guide to the action of writing an OS from scratch.

How no need start off
Please do not take the following text as an explicit criticism of someone else's articles or OS writing guides. It's just that too often in such articles under loud headlines, the emphasis is on the implementation of some kind of minimal preparation, and it is presented as a prototype of the kernel. In fact, you should think about the structure of the kernel and the interaction of parts of the OS as a whole, and that prototype should be considered as a standard "Hello, World!" - an application in the world of applied software. As a small excuse for these remarks, it should be said that below there is a subsection "Hello, World!"

No need to write a bootloader. Smart people came up with the Multiboot Specification, implemented and described in detail what it is and how to use it. I don’t want to repeat myself, I’ll just say that it works, it makes life easier, and it should be applied. The specification, by the way, is better to read in full, it is short and even contains examples.

It is not necessary to write the OS completely in assembly language. This is not so bad, rather the opposite - fast and small programs will always be held in high esteem. Just since this language requires much more development effort, then using assembler will only lead to a decrease in enthusiasm and, as a result, to throwing the OS sources on the back burner.

There is no need to load a custom font into video memory and display anything in Russian. There is no sense in this. It is much easier and more versatile to use English, and leave changing the font for later, loading it from the hard drive through the file system driver (at the same time, there will be an additional incentive to do more than just start).

Preparation
To begin with, as always, you should familiarize yourself with the general theory in order to have some idea of ​​the upcoming volume of work. Good sources on the issue under consideration are the books by E. Tanenbaum, which have already been mentioned in other articles on writing OS in Habré. There are also articles describing existing systems, and there are various guides / mailing lists / articles / examples / sites with a bias in OS development, links to some of which are given at the end of the article.

After the initial educational program, you need to decide on the main questions:

  • target architecture - x86 (real / protected / long mode), PowerPC, ARM, ...
  • kernel / OS architecture - monolith, modular monolith, microkernel, exokernel, various hybrids
  • language and its compiler - C, C ++, ...
  • kernel file format - elf, a.out, coff, binary, ...
  • development environment (yes, this also plays an important role) - IDE, vim, emacs, ...
Next, you should deepen your knowledge according to the chosen one and in the following areas:
  • video memory and work with it - conclusion as proof of work is necessary from the very beginning
  • HAL (Hardware Abstraction layer) - even if support for several hardware architectures and it is not planned to competently separate the lowest-level parts of the kernel from the implementation of such abstract things as processes, semaphores, and so on will not be superfluous
  • memory management - physical and virtual
  • execution control - processes and threads, their scheduling
  • device management - drivers
  • virtual file systems - to provide a single interface to the contents of various file systems
  • API (Application Programming Interface) - how exactly applications will access the kernel
  • IPC (Interprocess Communication) - sooner or later processes will have to communicate
Instruments
Taking into account the chosen language and development tools, you should select such a set of utilities and their settings, which in the future will make it possible, by writing scripts, to maximally facilitate and speed up the assembly, image preparation and launch of a virtual machine with a project. Let's dwell in a little more detail on each of these points:
  • any standard tools are suitable for building, such as make, cmake, ... Here scripts for the linker and (specially written) utilities for adding a Multiboot header, checksums or for any other purpose can be used.
  • preparing an image means mounting it and copying files. Accordingly, the format of the image file must be selected so that both the mount / copy utility and the virtual machine support it. Naturally, no one forbids performing the actions from this point either as the final part of the assembly, or as preparation for launching the emulator. It all depends on the specific tools and the chosen options for their use.
  • starting a virtual machine of labor does not represent, but you must not forget to first unmount the image (unmounting at this point, since there is no real sense in this operation before starting the virtual machine). Also, it will not be superfluous to have a script to start the emulator in debug mode (if there is one).
If you have completed all the previous steps, you should write a minimal program that loads as a kernel and displays something on the screen. If inconveniences or shortcomings of the selected means are found, it is necessary to eliminate them (shortcomings), or, in the worst case, take them for granted.

At this step, you need to test as many features of the development tools that you plan to use in the future. For example, loading modules into GRUB or using a physical disk / partition / flash drive in a virtual machine instead of an image.

After this stage has passed successfully, the real development begins.

Providing run-time support
Since it is proposed to write in high-level languages, care should be taken to provide support for some of the language features that are usually implemented by the authors of the compiler package. For example, for C ++, this includes:
  • function for dynamically allocating a block of data on the stack
  • work with heap
  • data block copy function (memcpy)
  • program entry point function
  • calls to constructors and destructors of global objects
  • a number of functions for working with exceptions
  • stub for unimplemented pure-virtual functions
When writing "Hello, World!" the absence of these functions may not make itself felt in any way, but as the code is added, the linker will start complaining about unsatisfied dependencies.

Naturally, the standard library should be mentioned right away. A complete implementation is not necessary, but a major subset of the functionality is worth implementing. Then writing code will be much more familiar and faster.

Debugging
Don't see what it says about debugging towards the end of this article. In fact, this is a very serious and difficult issue in OS development, since the usual tools are not applicable here (with some exceptions).

You can advise the following:

  • for granted, debug output
  • assert with immediate exit to the "debugger" (see next paragraph)
  • some semblance of a console debugger
  • check if the emulator allows you to connect a debugger, symbol tables or something else
Without a debugger built into the kernel, finding bugs has a very real chance of becoming a nightmare. So there is simply no escape from writing it at some stage of development. And since this is inevitable, it is better to start writing it in advance and thus greatly facilitate your development and save a lot of time. It is important to be able to implement the debugger in a kernel-independent way so that debugging has minimal impact on the normal operation of the system. There are several types of commands that can be useful:
  • some of the standard debugging operations: breakpoints, call stack, outputting values, printing a dump, ...
  • commands to display various useful information, such as the execution queue of the scheduler or various statistics (it is not as useless as it might seem at first)
  • commands for checking the consistency of the state of various structures: lists of free / used memory, heap or message queue
Development
Next, you need to write and debug the main elements of the OS, which at the moment should ensure its stable operation, and in the future - easy extensibility and flexibility. Besides memory managers / processes / (anything else), the interface of drivers and filesystems is very important. Their design should be approached with special care, taking into account all the variety of device / FS types. Of course, you can change them over time, but this is a very painful and error-prone process (and debugging the kernel is not an easy task), so just remember - think about these interfaces at least ten times before you start implementing them.
Similarity to SDK
As the project develops, new drivers and programs should be added to it. Most likely, already on the second driver (possibly of a certain type) / program, some common features will be noticeable (directory structure, build control files, specification of dependencies between modules, repeated code in main or in system request handlers (for example, if the drivers themselves check their compatibility with the device) )). If so, then this is a sign of the need to develop templates for various types of programs for your OS.

There is no need for documentation describing the process of writing this or that type of program. But it's worth making a blank from standard elements. This will not only make it easier to add programs (which can be done by copying existing programs and then changing them, but this will take more time), but also make it easier to update them when there are changes in interfaces, formats, or something else. It is clear that such changes, ideally, should not be, but since OS development is an atypical thing, there are a lot of places for potentially wrong decisions. But the understanding of the erroneousness of the decisions made, as always, will come some time after their implementation.

Further actions
In short, read about operating systems (and primarily about their device), develop your system (the pace is actually not important, the main thing is not to stop at all and return to the project from time to time with new forces and ideas) and it is natural to correct errors in it (to find which it is sometimes necessary to start the system and "play" with it). Over time, the development process will become easier and easier, errors will be less common, and you will be included in the list of "hopelessly stubborn", those few who, despite some absurdity of the idea of ​​developing their own OS, still did it.

This series of articles is devoted to low-level programming, that is, computer architecture, operating systems, assembly language programming and related areas. So far, two habrausers are engaged in writing - iley and pehat. For many high school students, students, and professional programmers, these topics turn out to be very difficult to learn. There is a lot of literature and courses on low-level programming, but it is difficult to get a complete and comprehensive picture from them. It is difficult, after reading one or two books on assembler and operating systems, to at least in general terms imagine how this complex system of iron, silicon and many programs - a computer - actually works.

Everyone solves the learning problem in their own way. Someone reads a lot of literature, someone is trying to quickly switch to practice and understand along the way, someone is trying to explain to friends everything that he is studying. And we decided to combine these approaches. So, in this course of articles, we will demonstrate step by step how to write a simple operating system. The articles will be of an overview nature, that is, they will not contain exhaustive theoretical information, however, we will always try to provide links to good theoretical materials and answer all questions that arise. We do not have a clear plan, so many important decisions will be made along the way, taking into account your feedback.

Perhaps we will deliberately lead the development process to a standstill in order to allow you and ourselves to fully understand all the consequences of a wrong decision, as well as hone some technical skills on it. So you shouldn't take our decisions as the only correct ones and blindly believe us. We emphasize once again that we expect readers to be active in discussing articles, which should strongly influence the overall development and writing of subsequent articles. Ideally, we would like to see some of the readers join in the development of the system over time.

We will assume that the reader is already familiar with the basics of assembly and C languages, as well as basic concepts of computer architecture. That is, we will not explain what a register or, say, random access memory is. If you do not have enough knowledge, you can always refer to additional literature. A short list of references and links to sites with good articles are at the end of the article. It is also advisable to be able to use Linux, since all compilation instructions will be given specifically for this system.

And now - more to the point. In the rest of the article, we will write a classic "Hello World" program. Our Hello World will turn out to be a little specific. It will not run from any operating system, but directly, so to speak "on bare metal." Before proceeding directly to writing the code, let's figure out how exactly we are trying to do this. And for this you need to consider the process of booting your computer.

So, take your favorite computer and press the largest button on the system unit. We see a cheerful splash screen, the system unit beeps happily with a speaker, and after a while the operating system is loaded. As you understand, the operating system is stored on the hard disk, and here the question arises: how did the operating system magically boot into RAM and start executing?

You should know: the system that is on any computer is responsible for this, and its name - no, not Windows, pips your tongue - it is called BIOS. Its name stands for Basic Input-Output System, that is, the basic input-output system. The BIOS is located on a small microcircuit on the motherboard and starts immediately after pressing the large ON button. BIOS has three main tasks:

  1. Detect all connected devices (processor, keyboard, monitor, RAM, video card, head, arms, wings, legs and tails ...) and check them for operability. The POST program (Power On Self Test) is responsible for this. If vital hardware is not found, then no software will be able to help, and at this point the system speaker will squeak something ominous and the OS will not reach the OS at all. Let's not talk about the sad, suppose we have a fully working computer, rejoice and move on to considering the second BIOS function:
  2. Providing the operating system with a basic set of functions for working with hardware. For example, through BIOS functions, you can display text on the screen or read data from the keyboard. That is why it is called the basic I / O system. Typically, the operating system accesses these functions through interrupts.
  3. Launching the operating system loader. In this case, as a rule, the boot sector is read - the first sector of the information carrier (floppy disk, hard disk, CD, flash drive). The order of polling media can be set in BIOS SETUP. The boot sector contains a program sometimes called the primary boot loader. Roughly speaking, the bootloader's job is to start the operating system. The process of loading an operating system can be very specific and highly dependent on its features. Therefore, the primary bootloader is written directly by the OS developers and is written to the boot sector during installation. At the moment the bootloader starts, the processor is in real mode.
Sad news: the size of the bootloader should be only 512 bytes. Why so little? To do this, we need to familiarize ourselves with the device of the floppy disk. Here is an informative picture:

The picture shows the surface of a disk drive. The floppy disk has 2 surfaces. Each surface has ring-shaped tracks (tracks). Each track is divided into small, arched pieces called sectors. So, historically, a floppy sector has a size of 512 bytes. The very first sector on the disk, the boot sector, is read by the BIOS into the zero memory segment at offset 0x7C00, and then control is transferred to this address. The boot loader usually loads into memory not the OS itself, but another loader program stored on the disk, but for some reason (most likely, this reason is the size) that does not fit into one sector.And since so far the role of our OS is played by a banal Hello World, our main goal is to make the computer believe in the existence of our OS, even if only on one sector, and run it.

How does the boot sector work? On a PC, the only requirement for the boot sector is that its last two bytes contain the values ​​0x55 and 0xAA - the boot sector signature. So, it is already more or less clear what we need to do. Let's write the code! The above code is written for the yasm assembler.

Section .text use16 org 0x7C00; our program is loaded at 0x7C00 start: mov ax, cs mov ds, ax; select the data segment mov si, message cld; direction for string commands mov ah, 0x0E; BIOS function number mov bh, 0x00; video memory page puts_loop: lodsb; load the next symbol into al test al, al; null character means end of string jz puts_loop_exit int 0x10; call the BIOS function jmp puts_loop puts_loop_exit: jmp $; eternal loop message: db "Hello World!", 0 finish: times 0x1FE-finish + start db 0 db 0x55, 0xAA; boot sector signature

This short program requires a number of important explanations. The org 0x7C00 line is needed so that the assembler (I mean the program, not the language) correctly calculates the addresses for labels and variables (puts_loop, puts_loop_exit, message). So we inform him that the program will be loaded into memory at the address 0x7C00.
In lines
mov ax, cs mov ds, ax
the data segment (ds) is set equal to the code segment (cs), since in our program both data and code are stored in one segment.

Then the message “Hello World!” Is displayed character by character in the loop. The function 0x0E of interrupt 0x10 is used for this. It has the following parameters:
AH = 0x0E (function number)
BH = video page number (don't bother yet, specify 0)
AL = ASCII character code

At the line "jmp $" the program hangs. And rightly so, there is no need for her to execute extra code. However, in order for the computer to work again, you will have to reboot.

In the line "times 0x1FE-finish + start db 0", the rest of the program code (except for the last two bytes) is filled with zeros. This is done so that after compilation the boot sector signature appears in the last two bytes of the program.

We sort of figured out the program code, let's now try to compile this happiness. For compilation, we need, in fact, an assembler - the above-mentioned yasm. It is available in most Linux repositories. The program can be compiled as follows:

$ yasm -f bin -o hello.bin hello.asm

The resulting file hello.bin must be written to the boot sector of the floppy disk. This is done something like this (of course, instead of fd, you need to substitute the name of your drive).

$ dd if = hello.bin of = / dev / fd

Since not everyone has floppy drives and floppy disks left, you can use a virtual machine, for example, qemu or VirtualBox. To do this, you have to make an image of a floppy disk with our bootloader and insert it into the "virtual floppy drive".
Create a disk image and fill it with zeros:

$ dd if = / dev / zero of = disk.img bs = 1024 count = 1440

We write our program at the very beginning of the image:
$ dd if = hello.bin of = disk.img conv = notrunc

Run the resulting image in qemu:
$ qemu -fda disk.img -boot a

After launching, you should see a qemu window with a joyful line "Hello World!" This concludes the first article. We will be glad to see your feedback and suggestions.

Developing a kernel is rightfully considered a daunting task, but anyone can write the simplest kernel. To touch the magic of kernel hacking, you just need to observe some conventions and master the assembler. In this article, we'll show you how to do it on our fingers.


Hello World!

Let's write a kernel that will boot via GRUB on x86 compatible systems. Our first kernel will display a message on the screen and stop there.

How x86 machines boot

Before thinking about how to write a kernel, let's see how the computer boots up and transfers control to the kernel. Most of the x86 processor registers have specific values ​​after loading. The Instruction Pointer Register (EIP) contains the address of the instruction that will be executed by the processor. Its hardcoded value is 0xFFFFFFF0. That is, the x86 processor will always start execution from the physical address 0xFFFFFFF0. This is the last 16 bytes of the 32-bit address space. This address is called the "reset vector".

The memory card contained in the chipset says that the address 0xFFFFFFF0 refers to a specific part of the BIOS, and not to RAM. However, the BIOS copies itself to the RAM for faster access - this process is called shadowing, creating a shadow copy. So the address 0xFFFFFFF0 will only contain the instruction to go to the memory location where the BIOS copied itself.

So the BIOS starts executing. First, it looks for devices from which you can boot in the order specified in the settings. It checks the media for the presence of a "magic number" that distinguishes bootable disks from normal ones: if bytes 511 and 512 in the first sector are 0xAA55, then the disk is bootable.

As soon as the BIOS finds the boot device, it will copy the contents of the first sector into RAM, starting at 0x7C00, and then transfer execution to this address and start executing the code that it just loaded. This code is called the bootloader.

The loader loads the kernel at the physical address 0x100000. It is he who is used by most of the popular kernels for x86.

All x86-compatible processors start in a primitive 16-bit mode called "real mode". The GRUB boot loader switches the processor to 32-bit protected mode by setting the lower bit of the CR0 register to one. Therefore, the kernel starts loading in 32-bit protected mode.

Note that GRUB, in the case of Linux kernels, chooses the appropriate boot protocol and loads the kernel in real mode. Linux kernels themselves switch to protected mode.

What do we need

  • X86 compatible computer (obviously)
  • Linux,
  • NASM assembler,
  • ld (GNU Linker),
  • GRUB.

Assembly language entry point

Of course, we would like to write everything in C, but we cannot completely avoid using assembler. We will write a small file in x86 assembler that will be the starting point for our kernel. All the assembly code will do is call an external function, which we will write in C, and then stop the execution of the program.

How do we make the assembly code the starting point for our kernel? We use a linker script that links the object files and creates the final kernel executable (explained in more detail below). In this script, we will directly indicate that we want our binary to load at 0x100000. This is the address, as I wrote earlier, at which the bootloader expects to see the entry point into the kernel.

Here is the assembler code.

kernel.asm
bits 32 section .text global start extern kmain start: cli mov esp, stack_space call kmain hlt section .bss resb 8192 stack_space:

The first bits 32 instruction is not an x86 assembler, but a NASM directive to generate code for a processor that will run in 32-bit mode. This is not necessary for our example, but it is good practice to state it explicitly.

The second line starts a text section, also known as a code section. All our code will go here.

global is another NASM directive, it declares symbols from our code to be global. This will allow the linker to find the start symbol, which is our entry point.

kmain is a function that will be defined in our kernel.c file. extern declares that the function is declared somewhere else.

Next comes the start function, which calls kmain and stops the processor with the hlt instruction. Interrupts can wake up the processor after hlt, so first we disable interrupts with the cli (clear interrupts) instruction.

Ideally, we should allocate some amount of memory for the stack and point the stack pointer (esp) to it. GRUB seems to do this for us, and at this point the stack pointer is already set. However, just in case, let's allocate some memory in the BSS section and point the stack pointer to its beginning. We use the resb instruction - it reserves the memory specified in bytes. Then a label is left pointing to the edge of the reserved chunk of memory. Just before the call to kmain, the stack pointer (esp) is directed to this area with the mov instruction.

C kernel

In the kernel.asm file, we called the kmain () function. So in C code, execution will start there.

kernel.c
void kmain (void) (const char * str = "my first kernel"; char * vidptr = (char *) 0xb8000; unsigned int i = 0; unsigned int j = 0; while (j< 80 * 25 * 2) { vidptr[j] = " "; vidptr = 0x07; j = j + 2; } j = 0; while(str[j] != "\0") { vidptr[i] = str[j]; vidptr = 0x07; ++j; i = i + 2; } return; }

All our kernel will do is clear the screen and output the line my first kernel.

The first thing we do is create a vidptr pointer that points to address 0xb8000. In protected mode, this is the start of video memory. Textual screen memory is just part of the address space. A section of memory is allocated for screen I / O, which begins at address 0xb8000, - 25 lines of 80 ASCII characters are placed in it.

Each character in text memory is represented by 16 bits (2 bytes), not the 8 bits (1 byte) we are used to. The first byte is the ASCII character code and the second byte is the attribute-byte. This is the definition of the format of the symbol, including its color.

To print s green on black, we need to put s in the first byte of video memory, and the value 0x02 in the second byte. 0 here means black background and 2 means green. We will use a light gray color, its code is 0x07.

In the first while loop, the program fills all 25 lines of 80 characters with empty characters with the 0x07 attribute. This will clear the screen.

In the second while loop, characters from the null-terminated string my first kernel are written to video memory and each character is assigned an attribute-byte equal to 0x07. This should output the line.

Layout

We now need to compile kernel.asm into an object file using NASM, and then use GCC to compile kernel.c into another object file. Our task is to link these objects into a bootable executable kernel. To do this, you need to write a script for the linker (ld), which we will pass as an argument.

link.ld
OUTPUT_FORMAT (elf32-i386) ENTRY (start) SECTIONS (. = 0x100000; .text: (* (. Text)) .data: (* (. Data)) .bss: (* (. Bss)))

Here we first set the format (OUTPUT_FORMAT) of our executable file as 32-bit ELF (Executable and Linkable Format), the standard binary format for Unix systems for the x86 architecture.

ENTRY takes one argument. It specifies the name of the symbol that will serve as the entry point for the executable file.

SECTIONS is the most important part for us. This is where we define the layout of our executable. We can define how different sections will be combined and where each one will be placed.

In the curly braces that follow the SECTIONS expression, the period indicates the location counter. It is automatically initialized to 0x0 at the beginning of the SECTIONS block, but it can be changed by assigning a new value.

Earlier I wrote that the kernel code should start at 0x100000. This is why we set the position counter to 0x100000.

Take a look at the line .text: (* (. Text)). An asterisk sets a mask here that matches any file name. Accordingly, the expression * (. Text) means all input .text sections in all input files.

As a result, the linker will merge all text sections of all object files into the text section of the executable file and place them at the address specified in the position counter. The code section of our executable will start at 0x100000.

After the linker renders the text section, the position counter is 0x100000 plus the size of the text section. Likewise, the data and bss sections will be merged and placed at the address given by the position counter.

GRUB and multiboot

All of our files are now ready to build the kernel. But since we will be loading the kernel using GRUB, there is one more step left.

There is a standard for booting different x86 kernels using a bootloader. This is called the "multiboot specification". GRUB will only load kernels that match it.

According to this specification, the kernel can contain a header (Multiboot header) in the first 8 kilobytes. This heading should contain three fields:

  • magic- contains the "magic" number 0x1BADB002, by which the header is identified;
  • flags- this field is not important for us, you can leave zero;
  • checksum- checksum, should give zero if added to the magic and flags fields.

Our kernel.asm file will now look like this.

kernel.asm
bits 32 section .text; multiboot spec align 4 dd 0x1BADB002; magic dd 0x00; flags dd - (0x1BADB002 + 0x00); checksum global start extern kmain start: cli mov esp, stack_space call kmain hlt section .bss resb 8192 stack_space:

The dd instruction specifies a 4-byte double word.

Putting the core together

So, everything is ready to create an object file from kernel.asm and kernel.c and link them using our script. We write in the console:

$ nasm -f elf32 kernel.asm -o kasm.o

With this command, the assembler will create a kasm.o file in ELF-32 bit format. Now it's GCC's turn:

$ gcc -m32 -c kernel.c -o kc.o

The -c option indicates that the file does not need to be linked after compilation. We will do it ourselves:

$ ld -m elf_i386 -T link.ld -o kernel kasm.o kc.o

This command will launch the linker with our script and generate an executable called kernel.

WARNING

Kernel hacking is best done in a virtual machine. To run the kernel in QEMU instead of GRUB, use the qemu-system-i386 -kernel kernel command.

Configuring GRUB and launching the kernel

GRUB requires the kernel filename to follow the kernel-<версия>... So let's rename the file - I'll name mine kernel-701.

Now we put the kernel in the / boot directory. This will require superuser privileges.

You will need to add something like this to the GRUB configuration file grub.cfg:

Title myKernel root (hd0,0) kernel / boot / kernel-701 ro

Do not forget to remove the hiddenmenu directive, if it is specified.

GRUB 2

To run the kernel we created in GRUB 2, which is shipped by default in new distributions, your config should look like this:

Menuentry "kernel 701" (set root = "hd0, msdos1" multiboot / boot / kernel-701 ro)

Thanks to Ruben Laguana for this addition.

Restart your computer and you should see your kernel listed! And by selecting it, you will see that very line.



This is your core!

Writing a kernel with keyboard and screen support

We've finished working on a minimal kernel that boots through GRUB, runs in protected mode, and prints a single line to the screen. Now is the time to extend it and add a keyboard driver that will read characters from the keyboard and display them on the screen.

We will communicate with I / O devices through the I / O ports. In essence, they are just addresses on the I / O bus. There are special processor instructions for read and write operations.

Working with ports: reading and output

read_port: mov edx, in al, dx ret write_port: mov edx, mov al, out dx, al ret

I / O ports are accessed using the in and out instructions included in the x86 suite.

In read_port, the port number is passed as an argument. When the compiler calls a function, it pushes all the arguments onto the stack. The argument is copied into the edx register using a stack pointer. The dx register is the lower 16 bits of the edx register. The in instruction here reads the port number given in dx and puts the result in al. The al register is the lower 8 bits of the eax register. You may remember from college course that the values ​​returned by functions are passed through the eax register. Thus, read_port allows us to read from I / O ports.

The write_port function works in a similar way. We take two arguments: the port number and the data to be written. The out instruction writes data to the port.

Interrupts

Now, before we return to writing the driver, we need to understand how the processor knows that some device has performed an operation.

The simplest solution is to poll devices - continuously check their status in a loop. This is ineffective and impractical for obvious reasons. So this is where interrupts come into play. An interrupt is a signal sent to the processor by a device or program that indicates that an event has occurred. Using interrupts, we can avoid the need to poll devices and will only respond to events of interest to us.

A chip called the Programmable Interrupt Controller (PIC) is responsible for interrupts in the x86 architecture. It handles hardware interrupts and routes and turns them into the appropriate system interrupts.

When the user does something to the device, a pulse called an Interrupt Request (IRQ) is sent to the PIC. The PIC translates the received interrupt into a system interrupt and sends a message to the processor that it is time to stop what it is doing. Further interrupt handling is the responsibility of the kernel.

Without the PIC, we would have to poll all the devices present in the system to see if an event has occurred involving any of them.

Let's take a look at how this works in the case of a keyboard. The keyboard hangs on ports 0x60 and 0x64. Port 0x60 sends data (when a button is pressed), and port 0x64 sends status. However, we need to know when exactly to read these ports.

Interruptions come in handy here. When the button is pressed, the keyboard sends a PIC signal on the IRQ1 interrupt line. The PIС stores the offset value stored during its initialization. It adds the input line number to this indentation to form the interrupt vector. The processor then looks for a data structure called an Interrupt Descriptor Table (IDT) to give the interrupt handler function an address corresponding to its number.

Then the code at this address is executed and handles the interrupt.

Setting IDT

struct IDT_entry (unsigned short int offset_lowerbits; unsigned short int selector; unsigned char zero; unsigned char type_attr; unsigned short int offset_higherbits;); struct IDT_entry IDT; void idt_init (void) (unsigned long keyboard_address; unsigned long idt_address; unsigned long idt_ptr; keyboard_address = (unsigned long) keyboard_handler; IDT.offset_lowerbits = keyboard_address & 0xffff; IDT.selector = 0x08; / ​​* KERNELFSET_COMMENT ; IDT.type_attr = 0x8e; / * INTERRUPT_GATE * / IDT.offset_higherbits = (keyboard_address & 0xffff0000) >> 16; write_port (0x20, 0x11); write_port (0xA0, 0x11); write_port (0x21, 0x20); write_port 0x28); write_port (0x21, 0x00); write_port (0xA1, 0x00); write_port (0x21, 0x01); write_port (0xA1, 0x01); write_port (0x21, 0xff); write_port (0xA1, 0xff); idt_address = (unsign longed ) IDT; idt_ptr = (sizeof (struct IDT_entry) * IDT_SIZE) + ((idt_address & 0xffff)<< 16); idt_ptr = idt_address >> 16; load_idt (idt_ptr); )

IDT is an array of IDT_entry structures. We'll also discuss binding a keyboard interrupt to a handler, but for now let's see how the PIC works.

Modern x86 systems have two PIC chips, each with eight input lines. We will call them PIC1 and PIC2. PIC1 receives from IRQ0 to IRQ7 and PIC2 receives from IRQ8 to IRQ15. PIC1 uses port 0x20 for commands and 0x21 for data, while PIC2 uses port 0xA0 for commands and 0xA1 for data.

Both PICs are initialized with eight-bit words called Initialization command words (ICW).

In protected mode, both PICs first need to issue the ICW1 initialization command (0x11). It tells the PIC to wait for three more initialization words to come to the data port.

These commands will send the PIC:

  • padding vector (ICW2),
  • what is the master / slave relationship between the PICs (ICW3),
  • additional information about the environment (ICW4).

The second initialization command (ICW2) is also sent to the input of each PIC. It assigns offset, which is the value to which we add the line number to get the interrupt number.

PICs allow their pins to be cascaded to each other's inputs. This is done using ICW3, and each bit represents the cascading status for the corresponding IRQ. For now, we will not use cascading redirection and set it to zeros.

ICW4 sets additional environment parameters. We only need to define the bottom bit so that the PICs know that we are operating in 80x86 mode.

Ta-dam! The PICs are now initialized.

Each PIC has an internal eight-bit register called the Interrupt Mask Register (IMR). It stores a bitmap of the IRQ lines that go to the PIC. If the bit is set, the PIC ignores the request. This means that we can turn on or off a specific IRQ line by setting the corresponding value to 0 or 1.

Reading from the data port returns the value in the IMR register, and writing changes the register. In our code, after initializing the PIC, we set all bits to one, thereby deactivating all IRQ lines. Later, we activate the lines that correspond to the keyboard interrupts. But first, let's turn it off!

If the IRQ lines are working, our PICs can receive signals on the IRQ and convert them to an interrupt number by adding an offset. We need to fill in the IDT so that the number of the interrupt that came from the keyboard corresponds to the address of the handler function that we will write.

What interrupt number do we need to tie the keyboard handler to in IDT?

The keyboard uses IRQ1. This is input line 1 and is processed by PIC1. We have initialized PIC1 with 0x20 offset (see ICW2). To get the interrupt number, you need to add 1 and 0x20, you get 0x21. This means that the address of the keyboard handler will be tied in the IDT to interrupt 0x21.

The task comes down to filling the IDT for interrupt 0x21. We will map this interrupt to the keyboard_handler function, which we will write in the assembler file.

Each IDT entry is 64 bits long. In the record corresponding to the interrupt, we do not store the entire address of the handler function. Instead, we split it into two 16-bit chunks. The lower bits are stored in the first 16 bits of the IDT record, and the upper 16 bits are stored in the last 16 bits of the record. All this is done for compatibility with 286 processors. As you can see, Intel issues such numbers on a regular basis and in many, many places!

In the IDT record, it remains for us to register the type, indicating in such a way that all this is done to catch the interrupt. We also need to set the offset of the kernel code segment. GRUB sets up GDT for us. Each GDT entry is 8 bytes long, where the kernel code descriptor is the second segment, so its offset is 0x08 (details will not go into this article). The interrupt gate is represented as 0x8e. Fill the remaining 8 bits in the middle with zeros. This will populate the IDT entry that corresponds to the keyboard interrupt.

When the IDT mapping is done, we will need to tell the processor where the IDT is located. For this there is an assembler instruction lidt, it takes one operand. It is a pointer to a handle to the structure that describes the IDT.

There are no difficulties with the descriptor. It contains the size of the IDT in bytes and its address. I used an array to make it more compact. Similarly, you can fill in a descriptor using a structure.

In the idr_ptr variable, we have a pointer that we pass to the lidt instruction in the load_idt () function.

Load_idt: mov edx, lidt sti ret

Additionally, the load_idt () function returns an interrupt when using the sti instruction.

Having filled in and loaded the IDT, we can access the keyboard IRQ using the interrupt mask we talked about earlier.

Void kb_init (void) (write_port (0x21, 0xFD);)

0xFD is 11111101 - enable only IRQ1 (keyboard).

Function - keyboard interrupt handler

So, we have successfully bound keyboard interrupts to the keyboard_handler function by creating an IDT entry for interrupt 0x21. This function will be called every time you press any button.

Keyboard_handler: call keyboard_handler_main iretd

This function calls another function written in C and returns control using instructions from the iret class. We could write our entire handler here, but it's much easier to code in C, so let's roll over there. The iret / iretd instructions should be used instead of ret when control returns from the interrupt-handling function to the program it interrupted. This instruction class raises a flag register, which is pushed onto the stack when an interrupt is called.

Void keyboard_handler_main (void) (unsigned char status; char keycode; / * We write EOI * / write_port (0x20, 0x20); status = read_port (KEYBOARD_STATUS_PORT); / * The lower status bit will be set if the buffer is not empty * / if (status & 0x01) (keycode = read_port (KEYBOARD_DATA_PORT); if (keycode< 0) return; vidptr = keyboard_map; vidptr = 0x07; } }

Here we first give the EOI (End Of Interrupt) signal by writing it to the PIC command port. Only then will the PIC allow further interrupt requests. We need to read two ports: data port 0x60 and command port (aka status port) 0x64.

First of all, we read port 0x64 to get the status. If the lower status bit is zero, then the buffer is empty and there is no data to read. In other cases, we can read data port 0x60. It will give us the code of the key pressed. Each code corresponds to one button. We use the simple character array defined in the keyboard_map.h file to map the codes to the corresponding characters. The symbol is then displayed on the screen using the same technique that we used in the first version of the kernel.

In order not to complicate the code, here I only process lowercase letters from a to z and numbers from 0 to 9. You can easily add special characters, Alt, Shift and Caps Lock. You can find out that a key was pressed or released from the output of the command port and take the appropriate action. Likewise, you can bind any keyboard shortcuts to special functions like shutdown.

Now you can build the kernel, run it on a real machine or on an emulator (QEMU) just like in the first part.

Operating System 0 to 1 is published on GitHub and has over 2,000 stars and 100 forks. As the name implies, after reading it, you can create your own operating system - and, perhaps, few things in the world of programmers can be cooler.

Through this book, you will learn the following:

  • Learn how to create an operating system based on hardware technical documentation. This is how it works in the real world, you can't use Google for quick replies.
  • Understand how computer components interact with each other, from software to hardware.
  • Learn to write code yourself. Blind copying of code is not a learning curve, you really learn how to solve problems. By the way, blind copying is also dangerous.
  • Master the familiar tools for low-level development.
  • Get familiar with assembly language.
  • Find out what programs are made of and how the operating system runs them. We gave a small overview of this topic for the curious in.
  • Figure out how to debug a program directly on hardware with GDB and QEMU.
  • Programming language C. You can quickly master it by following.
  • Basic knowledge of Linux. It is enough to study on our website.
  • Basic knowledge in physics: atoms, electrons, protons, neutrons, voltage.

What you need to know to write an operating system

Creating an operating system is one of the most difficult tasks in programming, as it requires extensive and complex knowledge of the operation of a computer. Which ones? Let's look at it below.

What is OS

An operating system (OS) is software that works with computer hardware and its resources and is the bridge between the hardware and software of a computer.

First generation computers did not have operating systems. The programs on the first computers included the code for the direct operation of the system, communication with peripheral devices and calculations, for the execution of which this program was written. Because of this alignment, even programs that were simple in terms of logic were difficult to implement in software.

As computers became more diverse and complex, writing programs that worked both as an OS and as an application became simply inconvenient. Therefore, to make programs easier to write, computer owners began to develop software. This is how operating systems came to be.

The OS provides everything you need to run custom programs. Their appearance meant that now programs did not need to control the entire volume of the computer's work (this is a great example of encapsulation). Now the programs needed to work with the operating system, and the system itself took care of the resources and work with the peripherals (keyboard, printer).

Briefly about the history of operating systems

C language

As mentioned above, there are several high-level programming languages ​​for writing an OS. However, the most popular of these is C.

You can start learning this language from here. This resource will introduce you to basic concepts and prepare you for more complex tasks.

Learn C the Hard Way is the title of yet another book. In addition to the usual theory, it contains many practical solutions. This tutorial will cover all aspects of the language.

Or you can choose one of these books:

  • Kernighan and Ritchie's The C Programming Language;
  • "C Programming Absolute Beginner's Guide" by Perry and Miller.

OS development

After you have mastered everything you need to know about computer science, assembly language, and C, you should read at least one or two books about direct OS development. Here are some resources for doing this:

Linux From Scratch. The process of building the Linux operating system is considered here (the tutorial has been translated into many languages, including Russian). Here, as in other textbooks, you will be provided with all the necessary basic knowledge. Relying on them, you can try yourself in creating an operating system. To make the software part of the OS more professional, there are additions to the textbook: “