§ Введение

Пожалуй, это одна из обширных тем для процессора, поскольку именно этим процессор и должен заниматься - это вычислением. Существуют 8 базовых арифметически-логических операций для 8 ил 16, 32, 64 разрядностей. Для 8086 доступны только 8 и 16.
  • ADD - Сложение двух чисел
  • ADC - Сложение чисел с учетом переноса (флаг C)
  • SUB - Вычитание
  • SBB - Вычитание с переносом
  • AND - Логическое И
  • XOR - Логическое Исключающее ИЛИ
  • OR - Логическое ИЛИ
  • CMP - Сравнение, которое на самом деле тоже самое что и SUB
Для расчетов я напишу одну функцию, которая будет этим заниматься. Ее прототип:
uint16_t arithlogic(char id, char i_w, uint16_t op1, uint16_t op2);
Принимает на вход ID - номер функции (0-ADD, 1-OR и т.д.); параметр i_w - это битность, либо 0 (8 бит), либо 1 (16 бит); два операнда op1, op2.
Также надо создать вспомогательную функцию, которая будет подсчитывать четность в результате.
uint8_t parity(uint8_t b) {

    b = (b >> 4) ^ (b & 15);
    b = (b >> 2) ^ (b & 3);
    b = (b >> 1) ^ (b & 1);
    return !b;
}
Четность будет рассчитана только для 8 битного результата, то есть, только для младшего байта любого результата. Что такое четность? Если PF=1, это значит, что количество единичных битов четно, если PF=0, то нет. К примеру 01100011 чётно, потому что тут 4 бита, а 01011011 нечетно, т.к. тут 5 битов.
Как работает эта инструкция? Очень просто: используются свойства исключающего ИЛИ для пересчета четности. Сначала складывается бит 0 с битом 4, 1 с 5, 2 с 6 и 3 с 7, получается 4 битное число. Потом складываются биты 0 с 2, 1 с 3, получается 2 битное число, и наконец 0 складывается 1. Если получилось 0, это значит, что все парные биты друг друга погасили, если 1 - то нет. То есть при 0 число имеет четное количество бит.

§ Общий вид функции вычисления

Вычисление АЛУ двух чисел 8 (i_w=0) или 16 бит (i_w=1)
  • 0 ADD = op1 + op2
  • 1 OR = op1 | op2
  • 2 ADC = op1 + op2 + CF
  • 3 SBB = op1 - op2 - CF
  • 4 AND = op1 & op2
  • 5 SUB = op1 - op2
  • 6 XOR = op1 ^ op2
  • 7 CMP = op1 - op2
uint16_t arithlogic(char id, char i_w, uint16_t op1, uint16_t op2) {

    uint32_t res = 0;

    // Расчет битности
    int bits = i_w ? 0x08000 : 0x080;
    int bitw = i_w ? 0x0FFFF : 0x0FF;
    int bitc = i_w ? 0x10000 : 0x100;

    op1 &= bitw;
    op2 &= bitw;

    // Выбор режима работы
    switch (id) {

        case 0: break; // ADD
        // ...
    }

    // Эти флаги проставляются для всех
    flags.p = parity(res);
    flags.z = !(res & bitw);
    flags.s = !!(res & bits);

    return res & bitw;
}
В этом коде res представляется 32-х битным числом потому, что иногда результат может превышать пределы 16 битного значения, а такие случаи необходимо отмечать в флаге CF. Также здесь рассчитываются некоторые вспомогательные маски: bits - это маска старшего бита в результате, bitw - маска значащих битов (их либо 16, либо 8), bitc - бит, где будет обнаруживаться перенос.
После исполнения арифметической или логической операции в конце ставятся 3 бита S,Z,P. Причем они ставятся при любом выбранном id. Бит P высчитывает четность результата, Z ставится в 1, если результат ноль, и S - если старший бит результата единица (или знак минус в дополненном коде).
И наконец, в конце возвращается число с учетом нужной битности, которая ограничена через bitw.

§ Переполнение

Это один из самых странных и трудных вопросов, на который не так и легко дать ответ. Начнем с переполнения при сложении. Что такое переполнение? Это случай, когда число определенного знака не помещается в разрядность. Например для байта 127 + 1 должно дать по идее, 128, и это так, но дело в том, что 0x80 это уже знаковое число, которое равно -128. То есть, произошло переполнение знака, и вместо 128 появилось -128. Или если сложить 127 + 2, то получим не 129, а -127, опять знак с + поменялся на -, переполнение.
Переполнения при сложении не может быть, если складываются числа с разным знаком. Переполнения не может быть, так как, чтобы это случилось, нужно чтобы число преодолело барьер знака, но разные знаки будут только отдалять от этой границы. Если интересно, можно попробовать самому не найти решения такой задачи.
Итак, при сложении переполнение может быть только с одинаковыми знаками. То есть когда положительное число плюс положительное дает отрицательное, и наоборот, когда отрицательное + отрицательное дает положительное. Получаем таблицу истинности:
A B R
0 0 1 Плюс + Плюс = Минус
1 1 0 Минус + Минус = Плюс
С вычитанием наоборот, одинаковые знаки операнда никак не могут дать переполнения, здесь переполнение дают только разные знаки. Если вычитание отрицательного из положительного дает отрицательное, то это переполнение. И наоборот.
A B R
0 1 1 Плюс - Минус = Минус
1 0 0 Минус - Плюс = Плюс

§ Код вычислений

Ниже приведен код, который, как оказалось, не такой уж и большой, как я предполагал.
switch (id) {

    case 0: // ADD
    case 2: // ADC

        res = op1 + op2;

        // Если это ADC, добавляется флаг CF
        if (id == 2) res += !!flags.c;

        flags.c = !!(res & bitc);
        flags.a = !!((op1 ^ op2 ^ res) & 0x10);
        flags.o = !!((op1 ^ op2 ^ bits) & (op1 ^ res) & bits);
        break;

    case 3: // SBB
    case 5: // SUB
    case 7: // CMP

        res = op1 - op2;

        // Если это SBB, вычитается флаг CF
        if (id == 3) res -= !!flags.c;

        flags.c = !!(res & bitc);
        flags.a = !!((op1 ^ op2 ^ res) & 0x10);
        flags.o = !!((op1 ^ op2) & (op1 ^ res) & bits);
        break;

    case 1: // OR
    case 4: // AND
    case 6: // XOR

        if (id == 1) res = op1 | op2;
        if (id == 4) res = op1 & op2;
        if (id == 6) res = op1 ^ op2;

        flags.c = 0;
        flags.a = !!(res & 0x10); // Unknown
        flags.o = 0;
        break;
}
Выбирается режим работы, от 0 до 7. Здесь порядок расположения кодов инструкции не просто так - ровно такой же порядок под теми же номерами в процессоре. Рассмотрим инструкции сложения, они очень простые: складывается op1 + op2, и если это инструкция ADC (номер 2), то еще добавляется флаг CF к результату. Потом считаются флаги:
  • C - устанавливается, когда результат достиг переноса (установлен 8-й или 16-й бит)
  • A - ставится 1, если при сложении нижних 4-х битов получился перенос в 5-й бит
  • O - флаг переполнения ставится при знаковом переполнении старшего бита
Теперь насчет флага A, этот флаг отвечает за перенос бита с 3-го на 4-й разряд. Как оно работает, op1^op2 - это обычная схема полусумматора, который складывает 4-й бит, и если в результате res 4-й бит равен op1^op2, то получается 0, т.е. (op1^op2^res) и проверяется 4-й бит & 0x10. Если это так, то никакого переноса точно не было. Если же там 1, это значит, что предполагаемый результат и реальный результат различаются - это значит только то, что перенос есть. Это работает также и с операцией SUB, SBB.
Флаг переполнения ставится тогда, если старшие биты op1, op2 одинаковые, а res отличается. Если op1, op2 биты одинаковые, то op1^op2^res дает неотрицательный результат, иначе - ноль. Если же биты op1 и res отличаются, то op1^res в старшем бите даст 1, что даст overflow=1
Давайте теперь рассмотрим SUB, SBB, CMP. Сразу можно сказать, что SUB это тоже самое что и CMP. SBB отличается от SUB тем, что вычитается еще и флаг CF из результата. Флаги AF и CF выставляются аналогично флагам для ADD, ADC. Для флага OF меняется только разве что то, что бит op1^op2 не инвертируется, т.е. если старшие биты не равны, то op1^op2 дает 1, и если бит именно op1 не равен биту res, то в таком случае засчитывается переполнение.
С AND, XOR и OR все элементарно и видно из кода, что OF = 0, CF = 0 всегда, а флаг AF не определен. Для пущей аутентичности я сделал так, что он копирует бит 4 из AF. Все равно неясно как на реальной машине это работает. Надо проверить бы.

§ Базовые инструкции АЛУ

А теперь приступаем к самому главному - к инструкциям. Я буду рассматривать инструкции в диапазоне опкодов $00-$3F.
Для начала надо дописать вспомогательные функции
// Получение значения регистра из ModRM
uint16_t get_reg(int i_w) { return i_w ? regs16[i_reg] : regs[REG8(i_reg)]; }

// Сохранение в регистр
void put_reg(int i_w, uint16_t data) {

    if (i_w) regs16[i_reg] = data;
    else     regs[REG8(i_reg)] = data;
}
Эти функции позволяют записывать обратно в регистр, полученный через сканирование modrm. Здесь читается или записывается в регистры в зависимости от выбранного размера - 8 или 16 бит.
// Базовые инструкции АЛУ
case 0x00: case 0x01: case 0x02: case 0x03: // ADD modrm
case 0x08: case 0x09: case 0x0A: case 0x0B: // OR  modrm
case 0x10: case 0x11: case 0x12: case 0x13: // ADC modrm
case 0x18: case 0x19: case 0x1A: case 0x1B: // SBB modrm
case 0x20: case 0x21: case 0x22: case 0x23: // AND modrm
case 0x28: case 0x29: case 0x2A: case 0x2B: // SUB modrm
case 0x30: case 0x31: case 0x32: case 0x33: // XOR modrm
case 0x38: case 0x39: case 0x3A: case 0x3B: // CMP modrm

    i_sel  = (opcode_id & 0x38) >> 3; // Режим работы АЛУ
    i_dir  = !!(opcode_id & 2); // Направление
    i_size = opcode_id & 1; // Размер byte | word

    // rm, r или r, rm
    i_op1  = i_dir ? get_reg(i_size) : get_rm(i_size);
    i_op2  = i_dir ? get_rm(i_size)  : get_reg(i_size);

    // Вычисление операндов
    i_res  = arithlogic(i_sel, i_size, i_op1, i_op2);

    // Запись результата обратно в регистр или в память
    if (i_sel != ALU_CMP) {

        if (i_dir) put_reg(i_size, i_res);
            else   put_rm(i_size, i_res);
    }

    break;
Здесь описаны 32 инструкции для АЛУ, которые работают с байтом modrm.
  • i_sel - из опкода выбираются биты 3..5, которые отвечают за номер режима АЛУ
  • i_dir - если =0, то первым операндом выступает r/m, вторым reg-часть, если =1 то наоборот, reg, r/m
  • i_size - если 0, то 8 бит, если 1, то 16 бит (или 32)
  • i_op1 и i_op2 - читаются операнды из памяти или регистров в соответствии с i_dir, i_size
  • i_res - вычисляется итоговый результат
После всех вычислений записывается результат. Если i_dir=1, то результат пишется исключительно в 8/16-регистр, если i_dir=0, то либо в память, либо регистр. Причем если это CMP-инструкция, то результат записан не будет.
// Базовые АЛУ с AL/AX
case 0x04: case 0x05: case 0x0C: case 0x0D: // ADD | OR
case 0x14: case 0x15: case 0x1C: case 0x1D: // ADC | SBB
case 0x24: case 0x25: case 0x2C: case 0x2D: // AND | SUB
case 0x34: case 0x35: case 0x3C: case 0x3D: // XOR | CMP

    // Режим работы АЛУ
    i_sel  = (opcode_id & 0x38) >> 3;
    i_size = opcode_id & 1;

    // Операнды
    i_op1  = i_size ? regs16[REG_AX] : regs[REG_AL]; // AL, AX
    i_op2  = fetch(i_size + 1); // 1 или 2 байта

    // Вычисление
    i_res  = arithlogic(i_sel, i_size, i_op1, i_op2);

    if (i_sel != ALU_CMP) {

        if (i_size) regs16[REG_AX] = i_res;
               else regs[REG_AL] = i_res;
    }

    break;
Это те же базовые инструкции, где выбор режима работы i_sel точно такой же, как и i_size, но отличается тем, что в качестве первого операнда выступает AL или AX, а в качества второго операнда непосредственное значение, идущее за опкодом (1 или 2 байта). CMP не сохраняет в регистр AL или AX новое значение.

§ INC, DEC

Инструкции INC и DEC это тоже самое, что и ADD, SUB с вторым операндом, равным 1, и с сохранением старого значения флага.
// INC r16
case 0x40: case 0x41: case 0x42: case 0x43:
case 0x44: case 0x45: case 0x46: case 0x47:
// DEC r16
case 0x48: case 0x49: case 0x4A: case 0x4B:
case 0x4C: case 0x4D: case 0x4E: case 0x4F:

    old_cf = flags.c;
    i_op1 = regs16[opcode_id & 7];
    regs16[opcode_id & 7] = arithlogic(opcode_id & 8 ? ALU_SUB : ALU_ADD, 1, i_op1, 1);
    flags.c = old_cf;
    break;
В зависимости от бита 3 в опкоде меняется операция ADD на SUB. Бит CF сохранен. Номер регистра содержится в нижних 3 битах 0..2.
Помимо базового набора, INC и DEC располагается также в групповых инструкциях
case 0xFE: // Групповая инструкция #4

    switch (i_reg) {

        case 0: // INC rm8
        case 1: // DEC rm8

            i_op1   = get_rm(0);
            old_cf  = flags.c;
            put_rm(0, arithlogic(i_reg ? ALU_SUB : ALU_ADD, 0, i_op1, 1));
            flags.c = old_cf;
            break;
    }

    break;
Этот код описывает работу с 8 битной групповой инструкцией. Получается 8 битный операнд, выполняется сложение или вычитание с сохранением старого флага, и обратно сохраняется в память или регистр результат.
case 0xFF:

    switch (i_reg) {

        case 0: // INC rm8
        case 1: // DEC rm8

            i_op1   = get_rm(1);
            old_cf  = flags.c;
            put_rm(1, arithlogic(i_reg ? ALU_SUB : ALU_ADD, 1, i_op1, 1));
            flags.c = old_cf;
            break;
        // ...
   }
Для 16-битных операции используется групповая инструкция 0xFFh, где код аналогичен тому, что и 8 бит, просто поменялась разрядность.

§ Групповые инструкции АЛУ

Также в наборе x86 существует 4 опкода, которые выполняют инструкции в зависимости от заданного значения reg-части в байте modrm. Сначала декодируется modrm, получается операнд rm, который и будет использоваться в качестве первого операнда, потом получается второй операнд путем считывания непосредственного значения.
case 0x80: case 0x82: // alu rm, i8
case 0x81: // alu rm, i16
case 0x83: // alu rm16, i8

    i_size = opcode_id & 1;
    i_op2  = opcode_id == 0x81 ? fetch(2) : fetch(1);

    // Знаковое расширение для 0x83 инструкции
    if (opcode_id == 0x83 && (i_op2 & 0x80)) i_op2 |= 0xFF00;

    // Вычисление
    i_res = arithlogic(i_reg, i_size, get_rm(i_size), i_op2);

    // Сохранение результата
    if (i_reg != ALU_CMP) put_rm(i_size, i_res);
    break;
Выбор размера операнда i_size как и обычно, через младший бит опкода, а вот выбор размера операнда для непосредственного значения немного другой. 16-битное число будет прочитано только тогда, когда опкод будет равным 0x81. Для опкода 0x83 происходит знаковое расширение. То есть будет прочитан 1 байт, но если в его старшем бите 1, то вся старшая часть второго операнда выставится в FFh. Далее все происходит как обычно до этого, выбирается режим работы АЛУ i_reg, указывается i_size, читается первый операнд и указывается второй. Если CMP, то результат обратно не пишется.

§ TEST

В процессорном наборе инструкции существует такая инструкция как TEST - полный аналог инструкции AND за исключением того, что сохраняются только флаги, а сам результат нет.
case 0x84: case 0x85: // TEST rm, r

    i_size = opcode_id & 1;
    arithlogic(ALU_AND, i_size, get_rm(i_size), get_reg(i_size));
    break;
Также есть вариант с непосредственным операндом совместно с аккумулятором:
case 0xA8: case 0xA9: // TEST A, i8

    i_size = opcode_id & 1;
    i_op1  = i_size ? regs16[REG_AX] : regs[REG8(REG_AL)];
    i_op2  = fetch(1 + i_size);
    arithlogic(ALU_AND, i_size, i_op1, i_op2);
    break;
Еще TEST присутствует в групповых инструкциях F6, F7, о которых речь далее.

§ Групповые инструкции F6h, F7h

В групповых однооперандных также находится TEST, который получает в качестве второго операнда непосредственный операнд 8 или 16 бит.
case 0xF6:
case 0xF7:

    switch (i_reg) {

        case 0:
        case 1: // TEST rm, i8

            i_size = opcode_id & 1;
            i_op1  = get_rm(i_size);
            i_op2  = fetch(1 + i_size);
            arithlogic(ALU_AND, i_size, i_op1, i_op2);
            break;
    }

    break;
Интересная особенность заключается в том, что по какой-то причине TEST занимает 0 и 1 код инструкции i_reg. Это особенность архитектуры x86, вероятно, какой-то баг.

§ NOT

Операция NOT работает элементарно:
case 2: // NOT (i_reg=2)

    i_size = opcode_id & 1;
    put_rm(i_size, ~get_rm(i_size));
    break;
Берется операнд rm, инвертируются биты и записывается в память или регистр обратно с нужной разряднстью.

§ NEG

Операция NEG равнозначна вычитанию операнда из нуля.
case 3: // NEG (i_reg=3)

    i_size = opcode_id & 1;
    put_rm(i_size, arithlogic(ALU_SUB, i_size, 0, get_rm(i_size)));
    break;
Как и в NOT, данные для операнда берутся из rm-части modrm, потом обратно записываются после вычитания из 0.
На этом всё, я разобрал все базовые простые арифметические инструкции. Дополнительно к этим инструкциям, есть еще MUL, DIV, IMUL и IDIV, они будут рассмотрены позже.
Код, как обычно, прикреплен здесь.
Следующий материал
30 сен, 2020
© 2007-2022 Ситуация крадет отлично