Смещённый код против дополнительного: два способа представлять знаковые числа

Представлять знаковые числа в двоичном виде можно несколькими способами, и каждый идёт на свои компромиссы. Самый привычный — дополнительный код (two’s complement), который сегодня использует практически каждый целочисленный тип в каждом языке. Но есть ещё два способа, о которых стоит знать: прямой код (sign-magnitude — интуитивный подход «просто добавим бит знака», от которого отказались десятилетия назад, потому что он ломает арифметику) и смещённый код (offset binary, он же biased exponent или offset-k, где k обозначает смещение), который IEEE-754 применяет к порядку каждого числа с плавающей точкой. В этой статье мы разберём все три и выясним, почему прямого кода недостаточно, как дополнительный код решает его проблемы и почему IEEE-754 всё же выбрал для порядков смещённый код, а не дополнительный.

Наивная первая попытка: прямой код

Самый интуитивный способ расширить двоичную запись на знаковые числа — отвести крайний левый бит под флаг знака (0 для положительных, 1 для отрицательных), а остальными битами закодировать величину как беззнаковое число. Такая схема называется прямым кодом (sign-magnitude).

Для примера на 4 битах +5 и -5 выглядят так:

+510=01012510=11012\begin{aligned} +5_{10} &= 0101_2 \\ -5_{10} &= 1101_2 \end{aligned}

Та же величина 101, отличается лишь старший бит. Читается приятно и понятно. Но у этой схемы есть два серьёзных недостатка, из-за которых она не годится для реального железа.

Два нуля

Поскольку бит знака не зависит от величины, у нуля оказывается два представления:

+010=00002010=10002\begin{aligned} +0_{10} &= 0000_2 \\ -0_{10} &= 1000_2 \end{aligned}

Ноль должен быть чем-то одним. Два битовых шаблона для него заставляют процессор особым образом обрабатывать проверки на равенство, где 0000 == 1000 всё равно должно давать истину. Это ещё и расходует один из 16 возможных шаблонов в 4 битах: в схеме прямого кода у нас лишь 15 различных значений в диапазоне [-7; 7], а не все 16.

Арифметика не работает сама собой

В беззнаковом двоичном виде a + b устроено просто: складываете бит за битом, переносите разряды, готово. Прямой код это ломает. Рассмотрим 3 + (-3) в 4-битном прямом коде:

100112(+3)+  110112(3)111102(?)\begin{aligned} & \phantom{1}0011_2 \quad (+3) \\ +\; & \phantom{1}1011_2 \quad (-3) \\ \hline & \phantom{1}1110_2 \quad (?) \end{aligned}

Прочитав 1110 как прямой код, получаем -6 — совершенно неверно. Обычное двоичное сложение не знает, что старший бит должен быть знаком; оно складывает его вместе со всем остальным. Чтобы арифметика прямого кода заработала, процессор должен проверить биты знака у обоих операндов, решить, складывать величины или вычитать, возможно, сравнить величины, чтобы определить знак результата, и отдельно обработать случай с двумя нулями. Всё это превращается в дополнительные схемы и более медленные операции.

Именно эти проблемы и подтолкнули к переходу на схему, где знак — не отдельный флаг, а арифметическое следствие самого кодирования: к дополнительному коду.

Краткое введение в дополнительный код

Дополнительный код — стандартное кодирование знаковых целых в современных процессорах, и он проще, чем кажется.

В дополнительном коде крайний левый бит (старший значащий) играет двойную роль: он участвует в вычислениях, как любой другой бит, и — когда значение трактуется как знаковое целое — сообщает знак (0 для неотрицательных, 1 для отрицательных). Это не отдельный флаг, который отщепляют; знак сам вытекает из арифметики, потому что позиционный вес старшего бита отрицателен.

Возьмём двоичное число 1011 и предположим, что это знаковое целое в дополнительном коде. Чтобы его декодировать, мы используем такую же позиционную запись, как и в беззнаковом двоичном, где каждому биту в качестве веса даётся степень двойки, — но степень старшего бита берётся с минусом. Для 4 битов это выглядит так:

b3(23)+b222+b121+b020b_3 \cdot (-2^3) + b_2 \cdot 2^2 + b_1 \cdot 2^1 + b_0 \cdot 2^0

Заметьте, что изменилось по сравнению с обычным беззнаковым двоичным: три младших бита b2,b1,b0b_2, b_1, b_0 сохраняют свои обычные положительные веса (+4+4, +2+2, +1+1), а старший бит b3b_3 умножается на 23=8-2^3 = -8 вместо +23=+8+2^3 = +8. Эта единственная смена знака на старшей позиции и есть вся схема. Обобщая: у n-битного числа в дополнительном коде старший бит имеет вес 2n1-2^{n-1} вместо обычного +2n1+2^{n-1}, а все остальные биты сохраняют свои нормальные положительные веса.

Подставив 1011 в формулу, получаем 8+0+2+1=5-8 + 0 + 2 + 1 = -5 — не 11 (что вы получили бы, прочитав 1011 как обычное беззнаковое двоичное: 8+0+2+18 + 0 + 2 + 1) и не -3 (что дал бы прямой код: старший бит 1 означает «отрицательное», младшие биты 011 кодируют величину 3).

Как закодировать отрицательное число

В предыдущем разделе мы декодировали битовый шаблон, который уже лежал в памяти. Но как пройти путь в обратную сторону — взять десятичное число вроде -5 и получить 4-битный шаблон, в котором оно хранится? Позиционная формула говорит, что означает шаблон, но не подсказывает напрямую, как построить шаблон для заданного отрицательного значения.

К счастью, есть простой рецепт, который всякий раз даёт нужный шаблон: взять положительную версию числа, инвертировать все биты (0 → 1, 1 → 0) и прибавить 1. Результат и есть представление отрицательного значения в дополнительном коде. (Почему этот рецепт работает, мы увидим позже, в разделе о модульной арифметике; пока просто считайте это механической процедурой.)

Применим его, чтобы найти представление -5 в 4 битах:

  1. Начинаем с двоичной записи +5:
+510=01012+5_{10} = 0101_2
  1. Инвертируем все биты:
01012101020101_2 \rightarrow 1010_2
  1. Прибавляем 1:
10102+00012=101121010_2 + 0001_2 = 1011_2

Итак, -5 хранится как 1011 в 4-битном дополнительном коде. Проверим формулой взвешенных битов: 8+0+2+1=5-8 + 0 + 2 + 1 = -5.

Почему используется именно эта схема

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

Выполним сложение 5 + (-5):

101012(+5)+  110112(5)100002\begin{aligned} & \phantom{1}0101_2 \quad (+5) \\ +\; & \phantom{1}1011_2 \quad (-5) \\ \hline & 10000_2 \end{aligned}

Перенос из старшего бита отбрасывается, остаётся 0000 — в точности ноль. Железу не нужно проверять, знаковые ли операнды, и не нужен отдельный путь для вычитания. Обычное двоичное сложение просто работает.

Из этого кодирования вытекают ещё два свойства. Во-первых, диапазон для n битов равен [2n1; 2n11][-2^{n-1};\ 2^{n-1}-1] — для 4 битов это [-8; 7], для 8 битов [-128; 127]. Обратите внимание, что он асимметричен: отрицательных значений на одно больше, чем положительных, потому что слот, который в прямом коде отвечал бы за -0, здесь переиспользован под самое отрицательное число. Во-вторых, шаблон нуля ровно один — 0000, а не два, 0000 и 1000.

Объединяющий принцип: модульная арифметика

Теперь посмотрим, почему дополнительный код работает именно так. Единственная идея, объясняющая почти всё об этом формате, такова: n-битная арифметика — это модульная арифметика на окружности из 2n2^n позиций. Почти любое другое свойство формата — рецепт «инвертируй и прибавь 1», аргумент про одну схему, перенос через край при переполнении — следует прямо из этой одной формулировки.

Что означает «модульная арифметика»

Когда мы говорим, что «n-битная арифметика модульна», мы имеем в виду, что все значения и все операции происходят на замкнутом круге из 2n2^n позиций, а не на бесконечной числовой прямой. Представьте 4-битный циферблат с 16 позициями, у каждой из которых две подписи: беззнаковое значение (снаружи) и знаковое значение в дополнительном коде (внутри):

снаружи: без знака (0–15)внутри: со знаком (−8…+7)те же биты, два прочтения001+12+23+34+45+56+67+78−89−710−611−512−413−314−215−1

Обратите внимание, как знаковые подписи делят окружность на две половины: справа лежат неотрицательные значения 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-битная беззнаковая арифметика работает в точности как циферблат из 2n2^n позиций.

Почему работает рецепт «инвертируй и прибавь 1»

Теперь у нас достаточно инструментов, чтобы понять, почему приведённый ранее рецепт действительно даёт правильное кодирование отрицательных чисел.

Центральное понятие — аддитивное обратное (additive inverse). На модульном циферблате «минус x» — это вовсе не знак минус, а та позиция циферблата, которая, будучи прибавленной к x, возвращает вас на позицию 0. Эта позиция и называется аддитивным обратным к x, и именно её дополнительный код записывает в биты. А рецепт, который мы видели раньше — взять положительную версию, инвертировать все биты и прибавить 1, — делает ровно это: он представляет собой побитовую процедуру вычисления аддитивного обратного.

Найдём аддитивное обратное к 5 на нашем 16-позиционном циферблате. Нам нужна позиция y такая, что 5 + y попадает на 0. Начав с 5 и пройдя 11 шагов по часовой стрелке, мы попадём на 16, что заворачивается в 0. Значит, y = 11:

5+11=160(mod16)5 + 11 = 16 \equiv 0 \pmod{16}

Итак, аддитивное обратное к 5 на 16-позиционном циферблате равно 11. А 11 в двоичном виде — это 1011, в точности тот битовый шаблон, который ранее выдал рецепт «инвертируй и прибавь 1» для −5.

Теперь мы можем обобщить то, что сделали с 5 и 11, на любое число битов. На циферблате из 2n2^n позиций аддитивное обратное к любому значению x равно 2nx2^n - x, потому что

x+(2nx)=2n0(mod2n)x + (2^n - x) = 2^n \equiv 0 \pmod{2^n}

Значит, дополнительный код хранит −x как битовый шаблон числа 2nx2^n - x, прочитанный как беззнаковый. Это мы и наблюдали на циферблате, где знаковые отрицательные −1, −2, …, −8 стоят на тех же позициях, что и беззнаковые 15, 14, …, 8: каждое из них — аддитивное обратное к своему положительному напарнику.

Наконец, «инвертировать биты, затем прибавить 1» — это просто быстрый побитовый способ вычислить 2nx2^n - x, не выполняя настоящего вычитания:

  • Инвертирование каждого бита xx даёт 2n1x2^n - 1 - x (это промежуточное значение называется обратным кодом, one’s complement). Для 4 битов при x=5=x = 5 = 0101: инвертирование даёт 1010, что читается как беззнаковое 10, и действительно 1615=1016 - 1 - 5 = 10.
  • Прибавление 1 даёт 2nx2^n - x. Продолжая пример: 10+1=1110 + 1 = 11 — то самое аддитивное обратное, что мы посчитали выше.

Так что рецепт — не хитрый трюк, придуманный разработчиками, а побитовое сокращение для вычисления модульного аддитивного обратного.

Весь формат в одном предложении

Дополнительный код — это беззнаковая модульная арифметика с другой разметкой верхней половины циферблата. Вот и вся схема. Любое свойство — рецепт, единственная схема сложения, перенос через край, единственный ноль — есть следствие этой одной идеи.

Это же объясняет, почему одна схема сложения обслуживает и знаковую, и беззнаковую арифметику. Железо не знает и не интересуется, помечаете ли вы позиции циферблата как «знаковые» или «беззнаковые». Оно просто складывает по модулю 2n2^n. Трактуете ли вы шаблон 1101 как беззнаковое 13 или знаковое -3, процессор воспринимает оба операнда как позиции циферблата, проходит вперёд на столько шагов, сколько задаёт второй операнд, и попадает на какую-то позицию. Две трактовки возникают из того, как вы читаете результат, а не из того, как процессор его вычисляет.

Смещённый код: схема IEEE-754

Дополнительный код хорош в том, для чего он создан: он делает арифметику знаковых целых той же схемой, что и беззнаковую. Но когда вы отходите от «мне надо складывать и вычитать знаковые целые» и задаёте знаковому представлению другие вопросы, он начинает казаться неудобным. Вопросы вроде:

  • Как сравнить два знаковых значения побитово — так же, как процессор сравнивает беззнаковые?
  • Где в пространстве битовых шаблонов естественным образом окажутся особые шаблоны (наименьший, наибольший, ноль)?
  • Отделяет ли кодирование «маленькое» от «большого» без акробатики с флагом знака?

Соглашение «старший бит — знак» в дополнительном коде означает, что 1000...0000 (наименьшее отрицательное) и 0111...1111 (наибольшее положительное) находятся на противоположных концах пространства битовых шаблонов, разбросанные, а не выстроенные по границам. Для целых общего назначения это нормально, но это мешает форматам, которым нужны предсказуемые, монотонные знаковые значения — например, полю порядка числа с плавающей точкой, где сравнение двух чисел по их порядкам находится на горячем пути, и железу надо сделать его дешёвым.

Поэтому компьютерная арифметика использует другое знаковое представление, когда эти свойства важны: смещённый код (offset binary, он же biased binary или excess-K). Именно эту схему IEEE-754 применяет к порядку каждого числа с плавающей точкой, и почему именно — мы увидим позже. А пока посмотрим, как она работает.

Процедура кодирования в смещённом коде проста: вычисляем смещение (bias), прибавляем его к числу, которое хотим сохранить, и получившееся значение и есть то, что реально записывается (при необходимости переведённое в двоичный вид). Чтобы продемонстрировать шаги, посмотрим, как число 3 можно сохранить в 4 битах.

Сначала находим смещение по формуле, упомянутой в стандарте IEEE-754:

K=2n11K = 2^{n-1} - 1

где n — количество битов. Значит, смещение для 4 битов равно 7. Затем прибавляем смещение к исходному числу: 3 + 7 = 10.

Получившееся число 10 и есть то, как число 3 хранится в схеме смещённого кода. Поскольку для получения результата 10 мы пользовались десятичной системой, его нужно перевести в двоичную:

10:2=5+05:2=2+12:2=1+01:2=0+11010=10102\begin{aligned} 10 : 2 &= 5 + 0 \\ 5 : 2 &= 2 + 1 \\ 2 : 2 &= 1 + 0 \\ 1 : 2 &= 0 + 1 \\ \\ 10_{10} &= 1010_2 \end{aligned}

Если вы не знаете алгоритма перевода или хотите понять, почему он работает, посмотрите мою статью об алгоритмах перевода между десятичной и двоичной системами.

Определяем смещение

Допустим, у нас есть всего 4 бита для хранения чисел. Формула размещений с повторениями даёт 24=162^4 = 16 различных битовых шаблонов. Вопрос в том, что эти 16 чисел означают. Предположим, нас интересует хранение только неотрицательных целых. Тогда диапазон таков:

[0;15]10[0000;1111]2[0; 15]_{10} \qquad [0000; 1111]_2

Но если включить отрицательные целые, диапазон может быть разным:

[1;14]10[0000;1111]2[7;8]10[0000;1111]2[8;7]10[0000;1111]2\begin{aligned} [-1; 14]_{10} &\qquad [0000; 1111]_2 \\ [-7; 8]_{10} &\qquad [0000; 1111]_2 \\ [-8; 7]_{10} &\qquad [0000; 1111]_2 \end{aligned}

Интересно здесь то, что хотя в десятичном виде диапазон меняется, в двоичном он остаётся тем же — только теперь наименьшее число 0000 представляет отрицательное значение. Как будто это наименьшее число смещено вниз от нуля на 1 в первом случае, на 7 во втором и на 8 в третьем.

Иначе говоря, смещение K — это просто выбор того, где разделить фиксированный набор битовых шаблонов между отрицательными и неотрицательными: оно решает, сколько слотов достанется каждой стороне от нуля. Единого математического стандарта, как его выбирать, нет — есть лишь соглашения. Распространены два варианта:

K=2n1(равное деление, диапазон [2n1; 2n11])K=2n11(IEEE-754, диапазон [(2n11); 2n1])\begin{aligned} K &= 2^{n-1} \quad &\text{(равное деление, диапазон } [-2^{n-1};\ 2^{n-1}-1]) \\ K &= 2^{n-1} - 1 \quad &\text{(IEEE-754, диапазон } [-(2^{n-1}-1);\ 2^{n-1}]) \end{aligned}

Для 4 битов K = 8 даёт равно поделённый диапазон [-8; 7], тогда как K = 7 (выбор IEEE-754) даёт диапазон [-7; 8] с одним дополнительным положительным значением.

Допустим, нам нужно сохранить число 3 в 4 битах. Мы воспользуемся формулой смещения из IEEE-754 K=2n11K = 2^{n-1} - 1, которая при n=4n = 4 даёт K=7K = 7. Это смещение делит 16 битовых шаблонов на диапазон [-7; 8], так что шаблон 0000 представляет -7, а 11118. Теперь, если 0000 — это -7, какое число нужно прибавить, чтобы получить 3? Это 10.

Посмотрим, что это даёт:

00002+10102=10102710+1010=310\begin{aligned} 0000_2 + 1010_2 &= 1010_2 \\ -7_{10} + 10_{10} &= 3_{10} \end{aligned}

Это показывает, что число 3 хранится как 1010 в двоичном виде при смещении 7. Кроме того, должно быть легко заметить, откуда берётся операция прибавления смещения для получения представления числа в смещённом коде:

7+10=33+7=10-7 + 10 = 3 \rightarrow 3 + 7 = 10

И как следствие: если мы прибавляли смещение, чтобы получить представление числа в смещённом коде, то для обратного перевода его надо вычесть.

Преимущества перед дополнительным кодом

Главное преимущество смещённого кода над дополнительным в том, что он позволяет сравнивать числа как есть, в лексикографическом порядке, без дополнительных операций. Например, сравним два числа — 3 и -3, представленные 4 битами. В смещённом коде они выглядят так:

310=10102310=01002\begin{aligned} 3_{10} &= 1010_2 \\ -3_{10} &= 0100_2 \end{aligned}

Сравнивая бит за битом, компьютеру уже по первому биту сразу ясно, что первое число больше. К числам, хранящимся в дополнительном коде, лексикографический порядок применить нельзя:

310=00112310=11012\begin{aligned} 3_{10} &= 0011_2 \\ -3_{10} &= 1101_2 \end{aligned}

Чтобы их сравнить, компьютеру придётся выполнить дополнительные операции.

Монотонное упорядочение — главное преимущество, и из него вытекает несколько важных следствий; вместе они объясняют, почему IEEE-754 выбрал для порядка числа с плавающей точкой именно смещённый код.

Самая большая выгода в том, что целое положительное число с плавающей точкой — знак, порядок и мантисса, прочитанные как одно большое беззнаковое целое — становится монотонным по представляемому значению. Это значит, что аппаратные схемы сравнения чисел с плавающей точкой могут буквально переиспользовать обычный целочисленный компаратор. В дополнительном коде это свойство нарушилось бы, и для чисел с плавающей точкой понадобилась бы отдельная логика сравнения.

Схема также аккуратно размещает зарезервированные битовые шаблоны по границам. Поля порядка из всех нулей и из всех единиц оказываются на двух краях диапазона, и IEEE-754 использует их как слоты для особых значений: ±0 и субнормальные числа внизу, ±∞ и NaN наверху. Смещение (например, 1023 для fp64) подобрано так, чтобы используемые порядки попадали между этими зарезервированными краями, а субнормальные числа и постепенное исчезновение точности работают гладко, потому что живут прямо рядом с точным нулём на нижнем краю. С дополнительным кодом наименьший и наибольший битовые шаблоны оказались бы в середине пространства шаблонов, что сделало бы обнаружение особых значений неуклюжим.

Так что выбор не произволен: смещённый код — это то представление, при котором все эти свойства складываются вместе разом.

Диапазон всегда асимметричен

Одно свойство есть у всех трёх схем: у любого двоичного представления, включающего ноль, диапазон асимметричен, причём ровно на одно значение. Это ограничение подсчёта: с n битами у вас 2n2^n кодов (чётное число), а симметричный диапазон вокруг нуля требовал бы 2k+12k + 1 кодов (нечётное число). Так что одна сторона всегда несёт одно лишнее значение.

Три схемы справляются с этим по-разному. Дополнительный код отдаёт лишнее значение отрицательной стороне: для 8 битов диапазон от -128 до +127, где -128 (10000000) — единственное отрицательное без положительного напарника. В смещённом коде смещение само решает, какой стороне достанется лишний слот: K = 2^{n-1} - 1 из IEEE-754 даёт одно лишнее положительное, а K = 2^{n-1} повторяет дополнительный код. Прямой код избегает асимметричного диапазона только за счёт двух нулей (+0 и -0), что ломает арифметику, — так что на практике любая схема, у которой арифметика в порядке, обязана принять эту асимметрию.

Это видно прямо в константах IEEE-754: для fp64 используемый диапазон порядков (после резервирований) — от -1022 до +1023, всё так же со сдвигом на единицу. Именно поэтому наибольшее конечное число двойной точности составляет около 1.8×103081.8 \times 10^{308}, а наименьшее нормальное положительное — около 2.2×103082.2 \times 10^{-308}: величины близки, но не равны.

Разбор того, как на целых числах в дополнительном коде на самом деле работают четыре базовые операции (сложение, вычитание, умножение, деление) — и что происходит на уровне битов при переполнении или иных сбоях, — смотрите в статье Как работает двоичная арифметика: целые в дополнительном коде и числа IEEE-754.