На основе курса Nand2Tetris и книги The Elements of Computing Systems

Запись чисел в компьютере

Самые базовые операции, которые компьютер может выполнять - арифметические. Собственно, эти операции и выполняет ALU - arithmetical logical unit. В этом посте будет разобрано, как его реализовать, основываясь на гейтах из прошлого поста. Здесь пока не будет умножения и деления, только сложение и вычитание.

Начать стоит с напоминания - компьютеры (по крайней мере распространенные их реализации) не работают с “обычными” числами, они работают с двоичными числами. Двоичные числа - это числа, записанные в двоичной системе счисления. Двоичная система счисления - позиционная система счисления с основанием 2. Основание системы счисления - натуральное число, показывающее, сколько различных символов используется для записи чисел в данной системе счисления. Примеры:

  • Двоичная: {0, 1}
  • Десятичная: {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
  • Шестнадцатеричная: {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E, F}

В компьютерах обычно числа представляются “словами” - последовательностью цифр заданной длины. Например, если длина слова 4, то двоичное число 11 (3 в десятичной системе) будет записано не как 11, а как 0011. Соответственно, при такой записи числа не могут быть больше \(2^{word\_len}-1\).

Сложение

Сложение двоичных чисел происходит так же, как и десятичных. Складываем соответствующие разряды числа и перенос (carry, в школе это называли “в уме”). Если результат сложения не влезает в заданное слово, то старшие разряды просто отбрасываются (это называется переполнением). Таким образом на каждом разряде нам нужно сложить три числа: первое слагаемое (x), второе слагаемое (у) и перенос. Пример без переполнения:

Addition

Пример c переполнением:

Addition_overflow

Запись отрицательных чисел

Прямой код

Старший разряд используется для индикации отрицательного числа. То есть если 0011 = 3, то 1011 = -3. При таком подходе получается два ноля: +0 (0000) и -0 (1000)

Обратный код

Опять, старший разряд используется для индикации отрицательного числа, но все биты “перевернуты” относительно положительного числа. То есть если 0011 = 3, то при обратном коде -3 = 1100. И при этой записи тоже есть +0 и -0.

Дополнительный код

Его мы и будем использовать. Если мы хотим представить отрицательное число \(-x\), то мы его будем записывать в форме \(2^n-x\), где n - длина слова. Таким образом, если мы хотим записать -3, то вместо этого мы запишем \(2^4 - 3 = 16 - 3 = 13 = 1101\).

И опять, получается, что старший разряд бита мы отдаем под индикацию отрицательного числа. И тогда доступный нам диапазон чисел - это \([-2^{n-1} : 2^{n-1} - 1]\)

Сравнительная таблица

Слово (n = 3) Unsigned Прямой код Обратный код Дополнительный код
000 0 0 0 0
001 1 1 1 1
010 2 2 2 2
011 3 3 3 3
100 4 -0 -3 -4
101 5 -1 -2 -3
110 6 -2 -1 -2
111 7 -3 -0 -1

Вычитание отрицательных чисел

При записи дополнительным кодом отрицательных чисел мы автоматом получаем их вычитание. Посмотрим пару примеров, для облегчения возьмем длину слов n = 3, поскольку для них можно использовать таблицу выше.

Проверим на примере 3 - 1. Мы можем переписать его как 3 + (-1). Запишем в битовом виде: 011 + 111 = 1010. Отбросим лишний бит: 010. Смотрим по таблице: 010 -> 2. Что является корректным.

Проверим на примере 3 - 4: 011 + 100 = 111 -> -1. Верно.

Проверим на примере -1 - 2: 111 + 110 = 1101 -> 101 -> -3. Тоже верно

Как сделать число отрицательным

Вспомним, как именно мы представляем отрицательное число: если у нас есть положительное число \(x\), то отрицательное число \(-x\) будет иметь запись \(2^n-x\). Мы можем добавить и вычесть к этой записи единицу: \(1 + (2^n -1) - x\). \(2^n - 1\) при представлении в битовом слове будет полностью забито единицами. То есть если длина слова 4, то \(2^4 - 1 = 16 - 1 = 15 = 1111_{base\ two}\). Из такого слова легко вычесть любое другое: если у соответствующего разряда в вычитаемом стоит единица, то результирующий разряд просто зануляем, иначе ничего не делаем. После этого к получившемуся числу нужно просто добавить единицу.

Чипы

Теперь все описанное выше нужно реализовать в рамках логических гейтов и двоичной арифметики. Для этого нам понадобится 4 новых чипа:

  • Half adder - на вход получает два бита для суммы, на выходе тоже два - результат в этом же разряде и индикация переполнения разряда
  • Full adder - то же самое, что half adder, только на вход три бита
  • Adder - складывает числа для слов заданной длины
  • ALU - собственно, первый шаг к компьютеру. На вход получает два числа: x, y. Кроме этого несколько управляющих флагов, которые будут задавать функцию, применяемую к этим словам.

Я буду описывать чипы для длины слова N = 4, но они без проблем расширяются до 8 / 16 / 32 / …

Half adder

Все просто: на вход поступает 2 бита, на выход тоже. Один из битов это сумма битов на входе, другой сигнализирует, нужен ли перенос при сложении.

Half adder

CHIP HalfAdder {
    IN a, b;    // 1-bit inputs
    OUT sum,    // Right bit of a + b 
        carry;  // Left bit of a + b

    PARTS:
    Xor(a = a, b = b, out = sum);
    And(a = a, b = b, out = carry);
}

Full adder

То же, что и Half Adder, только складывает 3 бита Full adder

CHIP FullAdder {
    IN a, b, c;  // 1-bit inputs
    OUT sum,     // Right bit of a + b + c
        carry;   // Left bit of a + b + c

    PARTS:
    HalfAdder(a = a, b = b, sum = smallSum, carry = smallCarry);
    HalfAdder(a = smallSum, b = c, sum = sum, carry = bigCarry);

    Or(a = smallCarry, b = bigCarry, out = carry);
}

Adder

Уже умеет складывать числа. На вход подается 2 числа в двоичном представлении, на выходе их сумма.

Full adder

CHIP Add4 {
    IN a[4], b[4];
    OUT out[4];

    PARTS:
    HalfAdder(a = a[0], b = b[0], sum = out[0], carry = carry0);
    FullAdder(a = a[1], b = b[1], c = carry0, sum = out[1], carry = carry1);
    FullAdder(a = a[2], b = b[2], c = carry1, sum = out[2], carry = carry2);
    FullAdder(a = a[3], b = b[3], c = carry2, sum = out[3], carry = discardCarry);
}

ALU

Арифметико-логическое устройство принимает в себя два числа: Х и Y, и несколько управляющих флагов:

  • ZX - нужно ли занулить Х ввод
  • ZY - нужно ли занулить Y ввод
  • NX - нужно ли инвертировать биты X ввода
  • NY - нужно ли инвертировать биты Y ввода
  • F - если 1, то в OUT будет лежать результат X + Y, иначе там будет X & Y
  • NO - нужно ли инвертировать биты OUT вывода.

Вывод имеет следующий вид:

  • OUT - результат, посчитанный в соответствии с флагом F и прочими флагами, влияющими на ввод
  • ZR - 1 если OUT == 0, иначе 0
  • NG - 1 если OUT < 0, иначе 0

На схеме выделил несколько областей, чтобы легче было разобрать, что именно происходит (изображение кликабельно):

ALU

Сначала мы зануляем ввод, если это нужно, в соответствии с флагами ZX и ZY (1). Затем мы, опять же, в соответствии с флагами NX и NY при необходимости инвертируем ввод(2). Поскольку у нас нет чипов, которые позволили бы ветвить сигнал, мы посчитаем сразу обе функции, которые от нас могут попросить: и SUM и AND (3). Ненужный нам результат мы занулим и из двух оставим только один (4). Осталось немного: инвертировать вывод, если нужно (5) и вывести результат(6). При выводе результата мы в out выводим, собственно, результат вычислений, в NG мы выводим старший бит результата, поскольку если число негативное, то там будет 1 (согласно описанию записи через дополнительный код). Также в чипе Or4Way мы применяем Or ко всем битам OUT, чтобы понять, есть ли там хоть одна 1. Если мы на выходе получим 0, то, значит, и сам OUT - это 0. Поскольку флаг ZR стоит в 1, если результат 0, то, прежде чем вывести это значение в ZR, мы его тоже инвертируем.

// Implementation: the ALU logic manipulates the x and y inputs
// and operates on the resulting values, as follows:
// if (zx == 1) set x = 0        // 4-bit constant
// if (nx == 1) set x = !x       // bitwise not
// if (zy == 1) set y = 0        // 4-bit constant
// if (ny == 1) set y = !y       // bitwise not
// if (f == 1)  set out = x + y  // integer 2's complement addition
// if (f == 0)  set out = x & y  // bitwise and
// if (no == 1) set out = !out   // bitwise not
// if (out == 0) set zr = 1
// if (out < 0) set ng = 1

CHIP ALU {
    IN  
        x[4], y[4],     // 4-bit inputs        
        zx,             // zero the x input?
        nx,             // negate the x input?
        zy,             // zero the y input?
        ny,             // negate the y input?
        f,              // compute out = x + y (if 1) or x & y (if 0)
        no;             // negate the out output?

    OUT 
        out[4], // 4-bit output
        zr,     // 1 if (out == 0), 0 otherwise
        ng;     // 1 if (out < 0),  0 otherwise

    PARTS:
    Not4(
        in[0] = zx,
        in[1] = zx,
        in[2] = zx,
        in[3] = zx,
        out = notZx);

    Not4(
        in[0] = zy,
        in[1] = zy,
        in[2] = zy,
        in[3] = zy,
        out = notZy);

    And4(a = notZx, b = x, out = xZeroPreset);
    And4(a = notZy, b = y, out = yZeroPreset);    

    Xor4(
        a = xZeroPreset,
        b[0] = nx,
        b[1] = nx,
        b[2] = nx,
        b[3] = nx,
        out = xNegatePreset);

    Xor4(
        a = yZeroPreset,
        b[0] = ny,
        b[1] = ny,
        b[2] = ny,
        b[3] = ny,
        out = yNegatePreset);

    Add4(a = xNegatePreset, b = yNegatePreset, out = sumResult);
    And4(a = xNegatePreset, b = yNegatePreset, out = andResult);

    Not4(
        in[0] = f,
        in[1] = f,
        in[2] = f,
        in[3] = f,
        out = notF);

    And4(
        a[0] = f,
        a[1] = f,
        a[2] = f,
        a[3] = f,
        b = sumResult,
        out = sumZeroPostprocess);

    And4(a = notF, b = andResult, out = andZeroPostProcess);
    Or4(a = sumZeroPostprocess, b = andZeroPostProcess, out = funcResult);

    Xor4(a = funcResult,
            b[0] = no,
            b[1] = no,
            b[2] = no,
            b[3] = no,
            out = out,
            out = outSecond,
            out[3] = ng);

    Or4Way(in = outSecond, out = isOutNotZero);
    Not(in = isOutNotZero, out = zr);
}