Смещённый код против дополнительного: два способа представлять знаковые числа
Представлять знаковые числа в двоичном виде можно несколькими способами, и каждый идёт на свои компромиссы. Самый привычный — дополнительный код (two’s complement), который сегодня использует практически каждый целочисленный тип в каждом языке. Но есть ещё два способа, о которых стоит знать: прямой код (sign-magnitude — интуитивный подход «просто добавим бит знака», от которого отказались десятилетия назад, потому что он ломает арифметику) и смещённый код (offset binary, он же biased exponent или offset-k, где k обозначает смещение), который IEEE-754 применяет к порядку каждого числа с плавающей точкой. В этой статье мы разберём все три и выясним, почему прямого кода недостаточно, как дополнительный код решает его проблемы и почему IEEE-754 всё же выбрал для порядков смещённый код, а не дополнительный.
Наивная первая попытка: прямой код
Самый интуитивный способ расширить двоичную запись на знаковые числа — отвести крайний левый бит под флаг знака (0 для положительных, 1 для отрицательных), а остальными битами закодировать величину как беззнаковое число. Такая схема называется прямым кодом (sign-magnitude).
Для примера на 4 битах +5 и -5 выглядят так:
Та же величина 101, отличается лишь старший бит. Читается приятно и понятно. Но у этой схемы есть два серьёзных недостатка, из-за которых она не годится для реального железа.
Два нуля
Поскольку бит знака не зависит от величины, у нуля оказывается два представления:
Ноль должен быть чем-то одним. Два битовых шаблона для него заставляют процессор особым образом обрабатывать проверки на равенство, где 0000 == 1000 всё равно должно давать истину.
Это ещё и расходует один из 16 возможных шаблонов в 4 битах: в схеме прямого кода у нас лишь 15 различных значений в диапазоне [-7; 7], а не все 16.
Арифметика не работает сама собой
В беззнаковом двоичном виде a + b устроено просто: складываете бит за битом, переносите разряды, готово. Прямой код это ломает. Рассмотрим 3 + (-3) в 4-битном прямом коде:
Прочитав 1110 как прямой код, получаем -6 — совершенно неверно.
Обычное двоичное сложение не знает, что старший бит должен быть знаком; оно складывает его вместе со всем остальным.
Чтобы арифметика прямого кода заработала, процессор должен проверить биты знака у обоих операндов,
решить, складывать величины или вычитать, возможно, сравнить величины, чтобы определить знак результата,
и отдельно обработать случай с двумя нулями. Всё это превращается в дополнительные схемы и более медленные операции.
Именно эти проблемы и подтолкнули к переходу на схему, где знак — не отдельный флаг, а арифметическое следствие самого кодирования: к дополнительному коду.
Краткое введение в дополнительный код
Дополнительный код — стандартное кодирование знаковых целых в современных процессорах, и он проще, чем кажется.
В дополнительном коде крайний левый бит (старший значащий) играет двойную роль: он участвует в вычислениях, как любой другой бит, и — когда значение трактуется как знаковое целое — сообщает знак (0 для неотрицательных, 1 для отрицательных).
Это не отдельный флаг, который отщепляют; знак сам вытекает из арифметики, потому что позиционный вес старшего бита отрицателен.
Возьмём двоичное число 1011 и предположим, что это знаковое целое в дополнительном коде. Чтобы его декодировать, мы используем такую же позиционную запись, как и в беззнаковом двоичном, где каждому биту в качестве веса даётся степень двойки, — но степень старшего бита берётся с минусом. Для 4 битов это выглядит так:
Заметьте, что изменилось по сравнению с обычным беззнаковым двоичным: три младших бита сохраняют свои обычные положительные веса (, , ), а старший бит умножается на вместо . Эта единственная смена знака на старшей позиции и есть вся схема. Обобщая: у n-битного числа в дополнительном коде старший бит имеет вес вместо обычного , а все остальные биты сохраняют свои нормальные положительные веса.
Подставив 1011 в формулу, получаем — не 11 (что вы получили бы, прочитав 1011 как обычное беззнаковое двоичное: ) и не -3 (что дал бы прямой код: старший бит 1 означает «отрицательное», младшие биты 011 кодируют величину 3).
Как закодировать отрицательное число
В предыдущем разделе мы декодировали битовый шаблон, который уже лежал в памяти. Но как пройти путь в обратную сторону — взять десятичное число вроде -5 и получить 4-битный шаблон, в котором оно хранится? Позиционная формула говорит, что означает шаблон, но не подсказывает напрямую, как построить шаблон для заданного отрицательного значения.
К счастью, есть простой рецепт, который всякий раз даёт нужный шаблон: взять положительную версию числа, инвертировать все биты (0 → 1, 1 → 0) и прибавить 1. Результат и есть представление отрицательного значения в дополнительном коде. (Почему этот рецепт работает, мы увидим позже, в разделе о модульной арифметике; пока просто считайте это механической процедурой.)
Применим его, чтобы найти представление -5 в 4 битах:
- Начинаем с двоичной записи +5:
- Инвертируем все биты:
- Прибавляем 1:
Итак, -5 хранится как 1011 в 4-битном дополнительном коде. Проверим формулой взвешенных битов: .
Почему используется именно эта схема
Дополнительный код позволяет процессору использовать одну и ту же схему сложения (физический блок из логических вентилей, выполняющий побитовое сложение с распространением переноса) и для знаковой, и для беззнаковой арифметики.
Выполним сложение 5 + (-5):
Перенос из старшего бита отбрасывается, остаётся 0000 — в точности ноль. Железу не нужно проверять, знаковые ли операнды, и не нужен отдельный путь для вычитания. Обычное двоичное сложение просто работает.
Из этого кодирования вытекают ещё два свойства. Во-первых, диапазон для n битов равен — для 4 битов это [-8; 7], для 8 битов [-128; 127].
Обратите внимание, что он асимметричен: отрицательных значений на одно больше, чем положительных, потому что слот, который в прямом коде отвечал бы за -0, здесь переиспользован под самое отрицательное число.
Во-вторых, шаблон нуля ровно один — 0000, а не два, 0000 и 1000.
Объединяющий принцип: модульная арифметика
Теперь посмотрим, почему дополнительный код работает именно так. Единственная идея, объясняющая почти всё об этом формате, такова: n-битная арифметика — это модульная арифметика на окружности из позиций. Почти любое другое свойство формата — рецепт «инвертируй и прибавь 1», аргумент про одну схему, перенос через край при переполнении — следует прямо из этой одной формулировки.
Что означает «модульная арифметика»
Когда мы говорим, что «n-битная арифметика модульна», мы имеем в виду, что все значения и все операции происходят на замкнутом круге из позиций, а не на бесконечной числовой прямой. Представьте 4-битный циферблат с 16 позициями, у каждой из которых две подписи: беззнаковое значение (снаружи) и знаковое значение в дополнительном коде (внутри):
Обратите внимание, как знаковые подписи делят окружность на две половины: справа лежат неотрицательные значения 0–+7,
слева — отрицательные −1…−8 (причём сама позиция 8 — битовый шаблон 1000 — содержит самое отрицательное значение −8).
Про работу этого циферблата стоит отметить несколько вещей:
- Каждая позиция содержит один битовый шаблон — беззнаковая и знаковая подписи это просто две разные трактовки одних и тех же 4 битов.
- Прибавление
1смещает вас по часовой стрелке на одну позицию. - После
15вы возвращаетесь к0. На этом циферблате15 + 1 = 0,15 + 2 = 1,15 + 3 = 2и так далее — любое значение после15продолжает идти по кругу, попадая на позицию0, затем1, затем2.
У математиков есть компактная запись для такого поведения с переносом через край: a ≡ b (mod n) читается как «a попадает на ту же позицию циферблата, что и b, на циферблате из n позиций». Так что наши примеры с переносом из пункта выше можно записать как 16 ≡ 0 (mod 16), 17 ≡ 1 (mod 16), 18 ≡ 2 (mod 16) — каждая запись просто говорит, что число слева попадает на ту же позицию, что и число справа, если идти по окружности из 16 позиций.
Так что 18 ≡ 2 (mod 16) всего лишь означает, что 18 попадает на ту же позицию, что и 2, на 16-позиционном циферблате: полный круг (16 шагов) плюс 2 лишних.
Заметьте, что a ≡ b (mod n) — это отношение (утверждение «истина или ложь», сравнивающее два числа), а не операция, поэтому оно не отображается напрямую на %. Его эквивалент на уровне кода — проверка a % n == b % n: оба выражения истинны в точности тогда, когда a и b попадают на одну позицию циферблата. Сам же оператор % соответствует другой записи, a mod n (без ≡), которая является операцией: например, 18 mod 16 = 2 даёт остаток от деления, то есть ту самую позицию циферблата, на которую вы попадаете. Для любого n n-битная беззнаковая арифметика работает в точности как циферблат из позиций.
Почему работает рецепт «инвертируй и прибавь 1»
Теперь у нас достаточно инструментов, чтобы понять, почему приведённый ранее рецепт действительно даёт правильное кодирование отрицательных чисел.
Центральное понятие — аддитивное обратное (additive inverse). На модульном циферблате «минус x» — это вовсе не знак минус, а та позиция циферблата, которая, будучи прибавленной к x, возвращает вас на позицию 0. Эта позиция и называется аддитивным обратным к x, и именно её дополнительный код записывает в биты. А рецепт, который мы видели раньше — взять положительную версию, инвертировать все биты и прибавить 1, — делает ровно это: он представляет собой побитовую процедуру вычисления аддитивного обратного.
Найдём аддитивное обратное к 5 на нашем 16-позиционном циферблате. Нам нужна позиция y такая, что 5 + y попадает на 0. Начав с 5 и пройдя 11 шагов по часовой стрелке, мы попадём на 16, что заворачивается в 0. Значит, y = 11:
Итак, аддитивное обратное к 5 на 16-позиционном циферблате равно 11. А 11 в двоичном виде — это 1011, в точности тот битовый шаблон, который ранее выдал рецепт «инвертируй и прибавь 1» для −5.
Теперь мы можем обобщить то, что сделали с 5 и 11, на любое число битов. На циферблате из позиций аддитивное обратное к любому значению x равно , потому что
Значит, дополнительный код хранит −x как битовый шаблон числа , прочитанный как беззнаковый. Это мы и наблюдали на циферблате, где знаковые отрицательные −1, −2, …, −8 стоят на тех же позициях, что и беззнаковые 15, 14, …, 8: каждое из них — аддитивное обратное к своему положительному напарнику.
Наконец, «инвертировать биты, затем прибавить 1» — это просто быстрый побитовый способ вычислить , не выполняя настоящего вычитания:
- Инвертирование каждого бита даёт (это промежуточное значение называется обратным кодом, one’s complement). Для 4 битов при
0101: инвертирование даёт1010, что читается как беззнаковое10, и действительно . - Прибавление 1 даёт . Продолжая пример: — то самое аддитивное обратное, что мы посчитали выше.
Так что рецепт — не хитрый трюк, придуманный разработчиками, а побитовое сокращение для вычисления модульного аддитивного обратного.
Весь формат в одном предложении
Дополнительный код — это беззнаковая модульная арифметика с другой разметкой верхней половины циферблата. Вот и вся схема. Любое свойство — рецепт, единственная схема сложения, перенос через край, единственный ноль — есть следствие этой одной идеи.
Это же объясняет, почему одна схема сложения обслуживает и знаковую, и беззнаковую арифметику. Железо не знает и не интересуется, помечаете ли вы позиции циферблата как «знаковые» или «беззнаковые». Оно просто складывает по модулю . Трактуете ли вы шаблон 1101 как беззнаковое 13 или знаковое -3, процессор воспринимает оба операнда как позиции циферблата, проходит вперёд на столько шагов, сколько задаёт второй операнд, и попадает на какую-то позицию. Две трактовки возникают из того, как вы читаете результат, а не из того, как процессор его вычисляет.
Смещённый код: схема IEEE-754
Дополнительный код хорош в том, для чего он создан: он делает арифметику знаковых целых той же схемой, что и беззнаковую. Но когда вы отходите от «мне надо складывать и вычитать знаковые целые» и задаёте знаковому представлению другие вопросы, он начинает казаться неудобным. Вопросы вроде:
- Как сравнить два знаковых значения побитово — так же, как процессор сравнивает беззнаковые?
- Где в пространстве битовых шаблонов естественным образом окажутся особые шаблоны (наименьший, наибольший, ноль)?
- Отделяет ли кодирование «маленькое» от «большого» без акробатики с флагом знака?
Соглашение «старший бит — знак» в дополнительном коде означает, что 1000...0000 (наименьшее отрицательное) и 0111...1111 (наибольшее положительное) находятся на противоположных концах пространства битовых шаблонов, разбросанные, а не выстроенные по границам. Для целых общего назначения это нормально, но это мешает форматам, которым нужны предсказуемые, монотонные знаковые значения — например, полю порядка числа с плавающей точкой, где сравнение двух чисел по их порядкам находится на горячем пути, и железу надо сделать его дешёвым.
Поэтому компьютерная арифметика использует другое знаковое представление, когда эти свойства важны: смещённый код (offset binary, он же biased binary или excess-K). Именно эту схему IEEE-754 применяет к порядку каждого числа с плавающей точкой, и почему именно — мы увидим позже. А пока посмотрим, как она работает.
Процедура кодирования в смещённом коде проста: вычисляем смещение (bias), прибавляем его к числу, которое хотим сохранить, и получившееся значение и есть то, что реально записывается (при необходимости переведённое в двоичный вид). Чтобы продемонстрировать шаги, посмотрим, как число 3 можно сохранить в 4 битах.
Сначала находим смещение по формуле, упомянутой в стандарте IEEE-754:
где n — количество битов. Значит, смещение для 4 битов равно 7. Затем прибавляем смещение к исходному числу: 3 + 7 = 10.
Получившееся число 10 и есть то, как число 3 хранится в схеме смещённого кода. Поскольку для получения результата 10 мы пользовались десятичной системой, его нужно перевести в двоичную:
Если вы не знаете алгоритма перевода или хотите понять, почему он работает, посмотрите мою статью об алгоритмах перевода между десятичной и двоичной системами.
Определяем смещение
Допустим, у нас есть всего 4 бита для хранения чисел. Формула размещений с повторениями даёт различных битовых шаблонов. Вопрос в том, что эти 16 чисел означают. Предположим, нас интересует хранение только неотрицательных целых. Тогда диапазон таков:
Но если включить отрицательные целые, диапазон может быть разным:
Интересно здесь то, что хотя в десятичном виде диапазон меняется, в двоичном он остаётся тем же — только теперь наименьшее число 0000 представляет отрицательное значение. Как будто это наименьшее число смещено вниз от нуля на 1 в первом случае, на 7 во втором и на 8 в третьем.
Иначе говоря, смещение K — это просто выбор того, где разделить фиксированный набор битовых шаблонов между отрицательными и неотрицательными: оно решает, сколько слотов достанется каждой стороне от нуля. Единого математического стандарта, как его выбирать, нет — есть лишь соглашения. Распространены два варианта:
Для 4 битов K = 8 даёт равно поделённый диапазон [-8; 7], тогда как K = 7 (выбор IEEE-754) даёт диапазон [-7; 8] с одним дополнительным положительным значением.
Допустим, нам нужно сохранить число 3 в 4 битах. Мы воспользуемся формулой смещения из IEEE-754 , которая при даёт . Это смещение делит 16 битовых шаблонов на диапазон [-7; 8], так что шаблон 0000 представляет -7, а 1111 — 8. Теперь, если 0000 — это -7, какое число нужно прибавить, чтобы получить 3? Это 10.
Посмотрим, что это даёт:
Это показывает, что число 3 хранится как 1010 в двоичном виде при смещении 7. Кроме того, должно быть легко заметить, откуда берётся операция прибавления смещения для получения представления числа в смещённом коде:
И как следствие: если мы прибавляли смещение, чтобы получить представление числа в смещённом коде, то для обратного перевода его надо вычесть.
Преимущества перед дополнительным кодом
Главное преимущество смещённого кода над дополнительным в том, что он позволяет сравнивать числа как есть, в лексикографическом порядке, без дополнительных операций. Например, сравним два числа — 3 и -3, представленные 4 битами. В смещённом коде они выглядят так:
Сравнивая бит за битом, компьютеру уже по первому биту сразу ясно, что первое число больше. К числам, хранящимся в дополнительном коде, лексикографический порядок применить нельзя:
Чтобы их сравнить, компьютеру придётся выполнить дополнительные операции.
Монотонное упорядочение — главное преимущество, и из него вытекает несколько важных следствий; вместе они объясняют, почему IEEE-754 выбрал для порядка числа с плавающей точкой именно смещённый код.
Самая большая выгода в том, что целое положительное число с плавающей точкой — знак, порядок и мантисса, прочитанные как одно большое беззнаковое целое — становится монотонным по представляемому значению. Это значит, что аппаратные схемы сравнения чисел с плавающей точкой могут буквально переиспользовать обычный целочисленный компаратор. В дополнительном коде это свойство нарушилось бы, и для чисел с плавающей точкой понадобилась бы отдельная логика сравнения.
Схема также аккуратно размещает зарезервированные битовые шаблоны по границам. Поля порядка из всех нулей и из всех единиц оказываются на двух краях диапазона, и IEEE-754 использует их как слоты для особых значений: ±0 и субнормальные числа внизу, ±∞ и NaN наверху. Смещение (например, 1023 для fp64) подобрано так, чтобы используемые порядки попадали между этими зарезервированными краями, а субнормальные числа и постепенное исчезновение точности работают гладко, потому что живут прямо рядом с точным нулём на нижнем краю. С дополнительным кодом наименьший и наибольший битовые шаблоны оказались бы в середине пространства шаблонов, что сделало бы обнаружение особых значений неуклюжим.
Так что выбор не произволен: смещённый код — это то представление, при котором все эти свойства складываются вместе разом.
Диапазон всегда асимметричен
Одно свойство есть у всех трёх схем: у любого двоичного представления, включающего ноль, диапазон асимметричен, причём ровно на одно значение. Это ограничение подсчёта: с n битами у вас кодов (чётное число), а симметричный диапазон вокруг нуля требовал бы кодов (нечётное число). Так что одна сторона всегда несёт одно лишнее значение.
Три схемы справляются с этим по-разному. Дополнительный код отдаёт лишнее значение отрицательной стороне: для 8 битов диапазон от -128 до +127, где -128 (10000000) — единственное отрицательное без положительного напарника. В смещённом коде смещение само решает, какой стороне достанется лишний слот: K = 2^{n-1} - 1 из IEEE-754 даёт одно лишнее положительное, а K = 2^{n-1} повторяет дополнительный код. Прямой код избегает асимметричного диапазона только за счёт двух нулей (+0 и -0), что ломает арифметику, — так что на практике любая схема, у которой арифметика в порядке, обязана принять эту асимметрию.
Это видно прямо в константах IEEE-754: для fp64 используемый диапазон порядков (после резервирований) — от -1022 до +1023, всё так же со сдвигом на единицу. Именно поэтому наибольшее конечное число двойной точности составляет около , а наименьшее нормальное положительное — около : величины близки, но не равны.
Разбор того, как на целых числах в дополнительном коде на самом деле работают четыре базовые операции (сложение, вычитание, умножение, деление) — и что происходит на уровне битов при переполнении или иных сбоях, — смотрите в статье Как работает двоичная арифметика: целые в дополнительном коде и числа IEEE-754.