Минутка истории: сегментная модель памяти

IBM PC мог адресовать 1 MB памяти (20-битная шина адреса), а регистры были 16-битные.

Сегментные регистры: cs, ds, es, ss (fs, gs в 32-битном режиме).

Каждое обращение к памяти происходит по адресу из двух частей: база и смещение. В 16-битном режиме база хранилась в сегментном регистре, и физический адрес вычислялся как 16 * seg + offset.

    mov $42, (%ax)      // обращение к памяти по адресу ds:ax = 16 * ds + ax
                        // (сегмент данных ds подразумевается)

    mov $42, %es:(%ax)  // обращение к памяти по адресу es:ax = 16 * es + ax
                        // (используем дополнительный сегмент es)

    push $42            // верхушка стека находится по адресу ss:sp —
                        // в сегменте стека

    jmp somelabel       // значение somelabel попадает в регистр ip
                        // следующая инструкция лежит в памяти по адресу cs:ip

    ljmp base, offs     // меняем одновременно cs и ip

В 32-битном защищённом режиме в регистрах лежат уже не смещения, а селекторы (≈номера) сегментов в глобальной таблице дескрипторов (GDT, Global Descriptor Table).

Прерывания

Хотим, чтобы устройства ввода могли активно сигнализировать о своей готовности к обмену данными.

    // спокойно что-то считаем на процессоре, не опрашивая устройства ввода
    sub ...
    mov ...     <---- eip
    add ...

    ...

    // когда пользователь нажимает на клавишу, хотим выполнить этот код
key_pressed:
	in $KEYBOARD_PORT, %al // выясняем, какую клавишу нажали
    ...                    // сохраняем куда-то сканкод
	iret                   // возвращаемся обратно

Чтобы прервать последовательное исполнение инструкций по сигналу извне, существует механизм прерываний. Он обслуживает следующие три вещи:

  1. Exceptions (исключительные ситуации — например, деление на 0).
  2. Hardware interrupts (аппаратные прерывания — например, событие от клавиатуры).
  3. Software interrupts (программные прерывания).

Механизм аппаратных (асинхронных) прерываний

                IRQ lines
                         ┌──────────┐
AT keyboard     ─────────┤          │             ┌──────────────┐
                         │          │             │              │
network         ─────────┤          │  Interrupt  │              │
adapter                  │Interrupt ├─────────────┤     CPU      │
                         │controller│             │              │
USB controller  ─────────┤          │             │              │
                         │          │             │              │
...             ─────────┤          │             └──────────────┘
                         └──────────┘

Процессор хранит в регистре idtr адрес таблицы дескрипторов прерываний (IDT — interrupt descriptor table), в которой в том числе хранятся адреса обработчиков (interrupt handlers, interrupt service routines — ISR).

Для обработки прерывания процессор сохраняет в стеке регистры cs, eip и eflags, а затем загружает в cs и eip адрес обработчика, соответствующего номеру прерывания.

После обработки прерывания мы возвращаемся в наш код с помощью iret - инструкции, которая извлекает из стека три верхних значения и помещает их в регистры IP, CS и флагов.

IF — interrupt flag

Если IF взведён (то есть равен 1), то после каждой инструкции у процессора происходит проверка на наличие прерываний - если они есть, то запускается вышеописанный механизм обработки прерывания.

Если же IF не взведён (=0), то процессор просто не реагирует на маскируемые аппаратные прерывания.

Про остальное

Программные прерывания и исключения обрабатываются тем же самым механизмом ловушек.

Вызвать программное прерывание можно с помощью ассемблерной инструкции int.

Interrupt controller (контроллер прерываний)

Нам не хотелось бы обрабатывать приоритеты и очереди прерываний в самом процессоре, тем самым загружая его данной работой. Все эти обработки выносят в отдельную микросхему, которая называется interrupt controller. Мы будем пользоваться PC-совместимым стандартом PIC (Programmable Interrupt Controller).

В современном компьютере все устроено немного сложнее - там у каждого ядра процессора есть свой LAPIC (Local Advanded PIC), а глобально во всей системе есть IO APIC (Input Output APIC), который распределяет прерывания между ядрами.

Назревает сразу же вопрос - “Как же PIC узнает про то, что центральный процессор обработал прерывание и можно сбрасывать сигнал об этом прерывании и браться за следующее?”

Так вот, CPU взаимодействует с PIC с помощью Port Mapped IO — так и узнает.

Как мы обрабатываем прерывания в Yabloko

Таблица обработчиков прерываний

Для каждого приходящего нам прерывания у нас есть структура idt_gate_t. Для каждого номера прерывания в таблице обработчиков (про природу этих номеров чуть дальше) хранится структура, которая соответствующим образом выстраивает работу с памятью. Посмотрим как выглядит таблица:

enum {
    IDT_HANDLERS = 256,
};

idt_gate_t idt[IDT_HANDLERS];

То есть сами прерывания приходят в виде номера int X → handler, где handler - это структура idt_gate_t idt[X] в нашей таблице. Теперь посмотрим на структуру idt_gate_t:

typedef struct {
    uint16_t low_offset;
    uint16_t selector; // это поле содержит в себе номер сегмента, который 
// будет загружен в cs. Менять значение cs может потребоваться, когда мы 
// в пользовательском коде (режим с пониженными привилегиями) ловим прерывания,
// но обрабатываем их уже в ядре (режим с повышенными привилегиями).
    uint8_t always0;
    uint8_t type: 4; // поле type занимает только 4 бита
    uint8_t s: 1;
    uint8_t dpl: 2;
    uint8_t p: 1;
    uint16_t high_offset;
} __attribute__((packed)) idt_gate_t;

Мы работаем в 32-битном режиме и соответственно адрес у нас также 32-битный, поэтому мы режем наш адрес на две части: low_offset (раньше было так, что она была 16-битная и была только эта часть) и high_offset (то, что добавили потом).

Векторы (номера) прерываний

Первые 32 вектора используются для исключительных ситуаций. Сейчас их определено 19:

const char * const exception_messages[] = {
    [0] = "Division By Zero",
    [1] = "Debug",
    [2] = "Non Maskable Interrupt",
    [3] = "Breakpoint",
    [4] = "Into Detected Overflow",
    [5] = "Out of Bounds",
    [6] = "Invalid Opcode",
    [7] = "No Coprocessor",

    [8] = "Double Fault",
    [9] = "Coprocessor Segment Overrun",
    [10] = "Bad TSS",
    [11] = "Segment Not Present",
    [12] = "Stack Fault",
    [13] = "General Protection Fault",
    [14] = "Page Fault",
    [15] = "Unknown Interrupt",

    [16] = "Coprocessor Fault",
    [17] = "Alignment Check",
    [18] = "Machine Check",
};

Например, если процессор ловит исключительную ситуацию - “деление на 0”, он всегда возбуждает вектор прерывания с номером 0.

Дальше нам хотелось бы иметь векторы, которые работают с прерываниями у разных устройств компьютера. Схема этого процесса выглядит таким образом:

Device ->> PIC ->> CPU

Если что-то случилось с устройством компьютера, оно сигнализирует PIC об этом, PIC хранит информацию о том, что на устройстве было конкретное прерывание с конкретным номером и пока процессор не обработает его, PIC будет напоминать об этом CPU. Например, клавиатура, присоединенная на первой ноге к PIC, присылает IRQ1 (interrupt request - запрос на прерывание) на PIC, дальше PIC делает смещение относительно 0x20 для IRQ и уже на процессор приходит то, что вызван 33-й по счету вектор.

Как обрабатывает прерывания сам процессор? Посмотрим на код:

/*
* mov ... // IF = 1
* ------ // interrupt handling started
* push eflags
* push error_code
* push eip
* push cs
* cli
* ljmp handler_segment:handler_offset // cs, eip = handler_segment, handler_offset
*/

Обработчики.

Рассмотрим ассемблерный файл cpu/vector.S, здесь мы с помощью макро языка создаем 256 обработчиков исключений, которые мы будем называть vector\i (можете дизассамблировать файл с помощью objdump и увидите 256 функций). Эти вектора кладутся в default_handlers.

Рассмотрим сами вектора. На 31-ой строчке мы можем увидеть if — он кладет 0 в стек для тех векторов, в которых не был положен процессором код ошибки (это нужно для того, чтобы у всех обработчиков стек выглядел однородно). Потом уже мы делаем push номера обработчика.

Untitled

Далее мы прыгаем в функцию alltraps, которая является общим обработчиком прерываний и лежит в том же файле.

Untitled

С 5-ой по 8-ую строчку мы сохраняем все сегментные регистры (регистр cs за нас уже сохранил процессор), а инструкцией pushal мы сохраняем все регистры общего назначения (чтобы потом их всех восстановить). Далее мы вызываем уже Си-шную функцию trap, определённую в cpu/idt.c.

trap — это функция, которая принимает на вход указатель на структуру registers_t, которая уже в свою очередь определена в cpu/isr.h и выглядит следующим образом.

Untitled

Нетрудно догадаться, что она просто хранит в себе всю информацию о сохраненных в alltraps регистров.

И чтобы передать указатель на registers_t мы кладём в 15-ой строчке функции alltraps регистр esp, который как раз указывает на начало нашей структуры

.

Untitled

Как видим — функция trap не слишком гостеприимна и сразу же нас встречает двумя if-ами с 89-ой по 94-ую строки. Объяснения такие: чтобы контролер прерываний передал следующее прерывание процессору — нужно ему сообщить, что предыдущее мы уже завершили. Поэтому мы должны сказать, что произошла ситуация EOI - end of interrupt, но поскольку контроллеров прерываний у нас два, то с 32-ого вектора по 40-ой мы сообщаем одному контроллеру, а с 40-ого уже двум.

Далее мы уже в 97-ой строчке проверяем, что существует обработчик прерываний для нашего прерывания, и если он есть, то просто запускаем его.

Если прерывание имеет номер до 32 (зарезервированные интелом номера), то мы просто вызываем функцию panic (заставляем ядро паниковать), предварительно напечатав сообщение об ошибке, которое мы взяли с соответствующей таблицы.

Пора возвращаться домой.

После обработки исключения мы вернёмся обратно в функцию alltraps.

Untitled

с 17-ой по 25-ую строчку мы восстановим все регистры, которые мы положили в стек (popal - противоположность pushal) а в 26-ой строчке убираем код ошибки и номер вектора со стека. Далее вызываем iret, который восстанавливает cs, eip и eflags, тем самым мы возвращаемся в ту точку программы, на которой мы были прерваны для обработки прерывания.

Разбор обработчика прерываний для клавиатуры.

Находится данный обработчик в файле driver/keyboard.c, загружаем мы его в таблицу обработчиков с помощью функции init_keyboard(), в которой вызывается register_interrupt_handler.

Untitled

В 18-ой строчке из порта клавиатуры мы читаем scancode — номер клавиши и в верхних старших битах сообщение о состоянии нажатия клавиши.

Таблица сканкодов имеется в том же файлике:

Untitled

А дальше просто очевидно обрабатываем scancode с таблицы и печатаем его на экран (25-ая строчка).

Как хендлеры попадают в таблицу обработчиков процессора?

Это происходит благодаря функции load_idt (из cpu/idt), которая вызывается в _start (kernel.c).

Untitled

Функция init_idt просто заполняет нашу таблицу — она берёт адрес вектора из default_handlers и кладёт в таблицу idt_gate_t idt[IDT_HANDLERS]. Далее в специальную структуру мы кладём адрес нашей таблицы и с помощью ассемблерной инструкции lidt мы загружаем нашу таблицу обработчиков в процессор.