Недавно, общаясь по видеосвязи с другом, я рассказывал ему о своих идеях по реализации нового продукта. В разговоре я упомянул добавление больших знаковых чисел в ассемблере с использованием дополнительного кода, на что получил от собеседника вопрос: «Что такое дополнительный код?» Меня немного удивила его неосведомлённость. Он уже больше 30 лет программирует на Java. Java и Python программисты (а также другие, работающие с языками *с придыханием* вроде Commodore / MicroSoft BASIC) не сталкиваются с нативным типом беззнакового целого числа. В этих языках подобные тонкости реализуются за них.
Всё это круто, но компьютер, за которым вы сидите, внутренне обрабатывает такие числа довольно простым способом, и хорошо бы знать, как именно это происходит.
Плюс это всё же наука. Так что давайте разбираться.
Что такое дополнительный код?
Дополнительный код – это система представления знаковых целых, упрощающая выполнение двоичной арифметики. Чтобы найти дополнительный код двоичного числа, вы инвертируете все его биты (изменяя 0 на 1 и наоборот), после чего прибавляете к результату единицу. Это представление упрощает построение вычислительных устройств, позволяя выполнять сложение и вычитание при помощи одной и тоже схемы, поскольку вычитание числа равнозначно прибавлению его дополнительного кода. К тому же этот приём позволяет эффективно обрабатывать изменение знаков и нулевые значения, в связи с чем он так широко используется в вычислительных системах.
Немного поупражняемся с 8-битным целым числом
Примечание: предполагаю, что вы уже знакомы с принципом работы двоичной системы исчисления. Если же нет, то вот хороший базовый материал. Кроме того, для лучшего понимания этой статьи вам потребуется знание побитовых операций. Для тех, кто недостаточно с ними знаком, тоже есть хороший ознакомительный материал.
Начнём с простого примера. Мы будем использовать 8-битные целые, потому что с ними проще работать.
5 в двоичном виде – это
00000101
.А как представить в двоичном виде -5? В дополнительном коде старший бит значения (крайний слева) используется как бит знака. Если он нулевой, то число положительное, а если равен единице – отрицательное.
Чтобы найти дополнительный код для 5 мы инвертируем все биты этого числа и затем добавляем к результату единицу.
- Инвертирование
00000101
даст нам11111010
. - Добавление единицы к
11111010
даст11111011
. - То есть для
00000101
дополнительным кодом является11111011
. - В десятичном виде
11111011
– это -5.
Проделаем ещё одну операцию, чтобы вы лучше усвоили её принцип.
- 100 в двоичном виде – это
01100100
. - Инвертируя все биты
01100100
, получаем10011011
. - Добавление единицы к
10011011
даст10011100
. - То есть дополнительным кодом для
01100100
является10011100
. - В десятичном виде
10011100
– это -100.
Напомню, что старший бит равен нулю у положительных чисел и единице у отрицательных. Это бит знака.
Проще простого, не так ли?
Используя старший бит для обозначения знака, мы можем представить только 8-битные числа от -128 до 127. Причина в том, что устанавливая старший бит для получения числа 128, мы меняем знак этого числа на минус.
Обновление от 2023-11-23: пользователь dperrin с Hacker News высказал по этому поводу следующие мысли, которые могут показаться вам интересными:
«Когда я впервые изучал дополнительный код, то воспринял эту информацию как нужную только для сдачи теста и не стал особо разбираться, почему этот принцип работает.
Реально же он меня заинтересовал, когда я взглянул на него с позиции модульной арифметики. Если мы рассматриваем 8-битные целые числа, то они охватывают диапазон от 0 до 255, и по факту вы выполняете действия по модулю 256. Значит, числа от 0 до 127 можно рассматривать как неотрицательные числа, а числа от 128 до 255 выступают как отрицательные по модулю 256 (например, -1 mod 256 = 255)».
Реализация на схеме
Один из стандартных способов реализации вычисления дополнительного кода – это использование схемы, получающей 8-битный ввод и выдающей его дополнительный код. Эта схема состоит из двух частей: инвертора и сумматора. Инвертор получает ввод и инвертирует все его биты, изменяя 0 на 1 и 1 на 0. Сумматор, в свою очередь, прибавляет к полученному результату единицу, что равнозначно добавлению дополнительного кода входного значения.
Для реализации этого можно использовать готовые логические вентили. Если мы хотим инвертировать бит, проще всего это будет сделать с помощью вентиля XOR. В микросхемах 74LS86 есть по 4 вентиля XOR. Можно использовать два из них для инвертирования битов.
Кстати, если вы просто будете считывать биты с вентилей 74LS86s, то получите обратный код входного значения. Подробнее об этом ниже.
В добавок к этому мы используем два 4-битных сумматора микросхемы 74LS283. Нам нужно подключить исходящий перенос первого сумматора на вход второго.
Чтобы выделить старший бит как знаковый, я использовал красный светодиод.
Вот видео схемы в действии. Здесь показано получение дополнительного кода для 5 и 100 вместе с проверкой результатов на калькуляторе:
Дополнительный квест: что за обратный код?
Обратный код – это система представления знаковых целых чисел, аналогичная дополнительному коду, но имеющая ключевое отличие. Чтобы найти обратный код двоичного числа, нужно просто инвертировать все его биты.
Обратный код стал известен тем, что использовался в вычислителе системы управления космическим кораблём Аполлон. Этот код активно применялся в первых компьютерах, потому что требует для своей реализации меньше аппаратных ресурсов, чем дополнительный код. Если взять нашу схему, то можно убрать сумматор и связанные с ним подключения, использовав для инвертирования битов вентили XOR. Такая схема будет выдавать для входного числа обратный код.
Однако у неё есть серьёзный недостаток. В этой системе существует два варианта представления нуля, +0 и -0. Это может вести к ошибкам в арифметических операциях. Например, если вы сложите +0 и -0, то получите -0. Мозг ещё не потёк?
В случае же дополнительного кода, когда нуль может быть представлен только в одной форме, такого уже не произойдёт.
Преимущество обратного кода (помимо мучений программиста) в том, что его легко реализовать в железе. Вы просто инвертируете биты.
Оперируя нашей скромной макетной платой, вы можете убрать две микросхемы справа и все провода, подключающие микросхемы XOR к сумматорам. В итоге мы будем считывать биты с микросхем XOR и получать обратный код для входного числа.
Как это использовать?
Основную часть работы за вас проделывает сам используемый язык. В некоторых языках, например, C/C++, Rust или Swift, есть знаковые и беззнаковые типы. При этом в других вроде Java, Ruby и Python такого различия нет.
Низкоуровневые языки
При использовании низкоуровневых языков, имеющих беззнаковые и знаковые типы данных, вы можете не понимать, что реализуете дополнительный код. Этот принцип неизменен, даже если вы не знаете, что конкретно происходит внутри процессора.
▍ Cи
#include <stdio.h>
#include <stdint.h>
int main() {
// 8-битное беззнаковое целое число
uint8_t unsignedEightBit = 255; // Максимальное значение для 8-битного беззнакового целого
printf("Unsigned 8-bit value: %u\n", unsignedEightBit);
// 8-битное знаковое целое число
int8_t signedEightBit = 127; // Максимальное значение для 8-битного знакового целого
printf("Signed 8-bit value: %d\n", signedEightBit);
// Демонстрация переполнения
unsignedEightBit = 256; // Это значение вызовет переполнение
printf("Overflowed Unsigned 8-bit value: %u\n", unsignedEightBit);
signedEightBit = 128; // Это значение вызовет переполнение
printf("Overflowed Signed 8-bit value: %d\n", signedEightBit);
return 0;
}
- Для 8-битных беззнаковых целых чисел используется тип
uint8_t
. Он может хранить значения от 0 до 255. - Для знаковых 8-битных целых чисел используется тип
int8_t
. Он может хранить значения от -128 до 127.
Здесь будет кстати продемонстрировать условия для возникновения переполнения. Оно происходит, когда вы присваиваете типу слишком большое значение. В случае беззнаковых типов переполнение вызывает возврат к началу численного диапазона, а в случае знаковых технически поведение программы не определено, но в Си зачастую происходит аналогичный возврат.
▍ Rust
В Rust для 8-битных беззнаковых целых используется тип
u8
, а для 8-битных знаковых – тип i8
. Этот язык предоставляет богатую систему типов и возможности безопасности, включая проверку на целочисленные переполнения в отладочных сборках. Вот пример, демонстрирующий использование обоих типов:fn main() {
// 8-битное беззнаковое целое число
let unsigned_eight_bit: u8 = 255; // Максимальное значение для 8-битного беззнакового целого
println!("Unsigned 8-bit value: {}", unsigned_eight_bit);
// 8-битное знаковое целое число
let signed_eight_bit: i8 = 127; // Максимальное значение для 8-битного знакового целого
println!("Signed 8-bit value: {}", signed_eight_bit);
// Демонстрация переполнения
// Примечание: в случае переполнения в режиме отладки в Rust активируется механизм паники
// Для обработки переполнения можно использовать wrapping_add, saturating_add и тому подобное
let overflowed_unsigned = unsigned_eight_bit.wrapping_add(1);
println!("Overflowed Unsigned 8-bit value: {}", overflowed_unsigned);
let overflowed_signed = signed_eight_bit.wrapping_add(1);
println!("Overflowed Signed 8-bit value: {}", overflowed_signed);
}
В этом примере:
- Для 8-битных беззнаковых целых чисел используется тип
u8
, способный хранить значения от 0 до 255. - Для 8-битных знаковых целых используется тип
i8
, способный хранить значения от -128 до 127.
В Rust переполнение обрабатывается не так, как в Си. По умолчанию проверка на переполнение реализована только в отладочных сборках языка, и в этом случае при его обнаружении срабатывает механизм паники. В релизные же сборки проверка на переполнение не включена в целях повышения быстродействия, и в этом случае Rust работает аналогично Си (производит возврат к началу диапазона). Однако в этом языке есть методы вроде
wrapping_add
и saturating_add
, позволяющие обрабатывать переполнения явно и безопасно.Как это реализовать на ассемблере?
Я всё ещё предпочитаю обучать людей ассемблеру начиная с ассемблера 6502. Он прост и прекрасно помогает понять основы работы компьютеров. Это также позволяет познакомиться с дополнительным кодом, поскольку внутри процессора мы ограничены работой с 8-битными значениями.
▍ Ассемблер 6502
В ассемблере 6502 понятие знаковых и беззнаковых целых чисел не определяется явно через типы данных, как это происходит в высокоуровневых языках. Вместо этого способ их различения определяется тем, как вы управляете данными в коде. Отмечу, что процессор 6502 работает с 8-битными данными и 16-битными адресами.
Ниже показан пример обработки знаковых и беззнаковых 8-битных значений на ассемблере 6502:
LDA #$FF ; Загрузка в аккумулятор 8-битного значения (255 в десятичном виде или -1, если интерпретировать его как знаковое)
STA $0200 ; Сохранение значения в области памяти $0200
LDA #$7F ; Загрузка в аккумулятор 127 (максимальное положительное значение для 8-битного знакового целого)
STA $0201 ; Сохранение значения в области памяти $0201
- Инструкция
LDA #$FF
загружает в регистр-аккумулятор значение0xFF
(255 в десятичной системе). Если интерпретировать его, как беззнаковый байт, то получится 255. Если же трактовать его как знаковый байт (используя дополнительный код), то значением будет -1. - Инструкция
LDA #$7F
загружает в аккумулятор значение0x7F
(127 в десятичной системе), являющегося максимумом для знакового 8-битного целого в представлении дополнительного кода.
Процессор 6502 не умеет различать знаковые и беззнаковые целые числа, поэтому программисту для правильной интерпретации передаваемых значений нужно использовать соответствующие инструкции.
Например, инструкции
BPL
(ветвление, если плюс) и BMI
(ветвление, если минус) можно использовать для реализации ветвления на основе того, к какому знаковому числу привела последняя операция (положительному или отрицательному). При этом для сравнения беззнаковых целых чисел используются инструкции вроде BCS
(ветвление, если перенос установлен) и BCC
(ветвление, если переноса нет).Это подобно вождению авто с механической коробкой передач. Автомобилю не важно, вперёд вы едете или назад – выбор нужной передачи за вами. Здесь комфортная и экономичная езда будет зависеть от своевременного включения правильной передачи. И те, кто ездит на механике, знает, что оно того стоит. Иногда.
Если у вас нет доступа к компьютеру с процессором 6502, то вы можете использовать эмулятор или онлайн-ассемблер. Мне нравится этот.
▍ Построение таблицы в Commodore BASIC
Commodore BASIC обрабатывает знаковые и беззнаковые числа внутренне. Но не может же статья на этом сайте (речь об imapenguin.com, — прим. пер.) обойтись без винтажного ПК?
Посмотрим, как это реализуется на Commodore 128 (должно работать на многих диалектах BASIC из начала 80-х):
2 PRINTCHR$(147)
3 PRINT"TWOS COMPLEMENT CHART FOR 8 BIT VALUES"
4 PRINT"----------------------------------------"
5 PRINT"POSITIVE NEGATIVE UNSIGNED BINARY"
10 FOR N=0TO127
30 T=0
40 FOR I=0 TO 7
50 B=2^I
60 IF (N AND B) = B THEN GOTO80
70 T=T+B
80 NEXT I
90 T=(T+1) AND 255
100 PRINT N,-N,T,
120 FOR I=7 TO 0 STEP -1
130 B=2^I
140 IF (T AND B) = B THEN PRINT "1";:GOTO 150
145 PRINT "0";
150 NEXT I
160 PRINT
170 NEXT
Интересно. В документации Commodore операция
AND
называется логическое AND
. Но эту операцию можно использовать для проверки установки битов, значит, в правильных руках она будет побитовым AND
.Версия на ППВМ
Нет макетной платы – нет проблем. Вполне будет достаточно вашей карманной ППВМ.
У вас же припасена в кармане ППВМ, не так ли?
Дополнительный код на ППВМ реализуется легко. Вот пример модуля, получающего 8-битный ввод и выдающего его дополнительный код:
module TwosComplement(
input [7:0] in, // 8-битный ввод
output [7:0] out // 8-битный вывод, представляющий дополнительный код
);
// Инвертирование всех битов входного значения; равнозначно работе наших вентилей XOR на микросхеме 74LS86
wire [7:0] inverted;
assign inverted = ~in;
// Прибавление 1 к инвертированному вводу; равнозначно работе сумматоров на нашей 74LS283
assign out = inverted + 1;
endmodule
Поскольку ширина регистров 8 бит, эти операции будут циклически обнуляться и работать для всех 8-битных целых чисел. Как и ассемблер, ППВМ не интересует, используете вы знаковое или беззнаковое число. Вы сами должны подобрать для него правильную реализацию.
Также, если возникнет потребность поработать с обратным кодом, можете просто использовать этот инвертированный сигнал.
HP-16C
Программируемый калькулятор HP-16C поддерживает как обратный код, так и дополнительный. Это странная и диковинная штуковина, но мне нравится.
В руководстве к HP-16C есть прекрасная таблица, перечисляющая обратный и дополнительный код для всех 4-битных целых. Мне она очень пригодилась, когда я изучал эту тему около года назад.
Получилось мягко?
Дополнительный код – это система для представления знаковых чисел, упрощающая двоичную арифметику. Для нахождения дополнительного кода двоичного числа мы инвертируем все его биты (изменяя 0 на 1 и 1 на 0), а затем прибавляем к результату 1. Это представление упрощает проектирование компьютеров, позволяя выполнять сложение и вычитание на одной схеме, поскольку вычитание числа равнозначно прибавлению его дополнительного кода. Кроме того, эта техника позволяет эффективно обрабатывать изменения знаков и нулевые значения, в связи с чем используется в компьютерах по сей день.
Скидки, итоги розыгрышей и новости о спутнике RUVDS — в нашем Telegram-канале ????
Комментарии (17)
SIISII
03.12.2023 10:23+10Понятно, что это перевод, но несколько замечаний всё ж выскажу.
1) Можно было бы показать формирование отрицательных значений на примере счётчика, считающего задом наперёд. Например, для 4-битового числа 0 = 0000, -1 = 1111, -2 = 1110 и т.д.
2) Обратный код "в древности" использовался довольно часто, но не потому что он проще для железа -- он, напротив, сложнее, почему от него, в конечном итоге, и отказались (при операциях с целыми числами, в вещественной арифметике используется именно он, но там -- уже совсем другая песня). Вероятно, автор статьи не задумывался о том, что в реальном железе для формирования дополнительного кода никакого отдельного сумматора не требуется: прибавление единицы осуществляется путём подачи нужного значения входного переноса на основной сумматор (чтобы выполнить операцию A - B, на сумматор подаётся инверсное значение операнда B и инверсное же значение входного переноса).
3) Можно было бы упомянуть про обнаружение переполнения (флажок V в большинстве процессорных архитектур, хотя в IA-32 aka x86 это флажок OF): технически этот признак формируется путём выполнения операции "исключающее или" между значениями выходного переноса из старшего (знакового) разряда и входного переноса в этот же разряд -- т.е. схема тоже очень проста. Правда, в классическом АЛУ SN74181 (у нас -- К155ИП3) переполнение почему-то не обнаруживается, поэтому там приходится городить внешнюю схему.
4) А почему код называется дополнительным? ;)
Hlad
03.12.2023 10:23Потому что дополняет до 2^(разрядность)
-5 выглядит точно так же, как 231. 231 + 5 = 256.
Кстати, не знаю, учат ли нюансам дополнительного кода в университетах сейчас, а когда я преподавал, была отдельная лабораторка на первом курсе, которая показывала, как "опрокидываются" значения при выходе за допустимые пределы.
funca
03.12.2023 10:23+14) А почему код называется дополнительным? ;)
Переводчику можно поставить памятник за корректное использование в статье классической русскоязычной терминологии. Но кто ее такой выдумал нужно казнить.
На мой взгляд, здесь тот случай когда русскоязычное научное сообщество с переводами перемудрило, усложнив понимание.
В английском достаточно знать слово compliment и иметь немного воображения.
One's complement (обратный код)
Two's complement (дополнительный код).
Complement ("дополнение", оно же в частных случаях "отрицание" или "инвертирование"). Грубо говоря если у вас есть часть предмета, то compliment это остальная часть, которой нужно дополнить вашу, чтобы получить целое. Например, есть множество допустимых значений {0, 1}. У вас есть {0}, соответственно не хватает {1} - это и есть дополнение.
В случае обратного кода (one's complement), отрицательное значение дополняет положительное до всех единиц. Технически мы просто инвертируем (sic это тоже дополнение) биты:
(0) 000 -> 111 (-0)
(1) 001 -> 110 (-1)
(2) 010 -> 101 (-2)
(3) 011 -> 100 (-3)
То есть: 000 + 111 = 001 + 110 = 010 + 101 = 011 + 100 == 111
Представление симметрично. Но +0 и -0 имеют разные значения, а прибавляя (-0) мы получаем переполнение.
Дополнительный код (two's complement) - термин похож на кальку с английского, где потерялось упоминание двойки. Здесь two (2) это основание системы счисления. Если у нас двоичная система счисления и есть N разрядов, то представление отрицательного значения должно дополнять представление положительного до 2^N. Например, для трёх бит 2^3 это (8) 1000. Следовательно:
(0) 000
(1) 001 -> 111 (-1)
(2) 010 -> 110 (-2)
(3) 011 -> 101 (-3)
--------------> 100 (-4)
Для проверки: 001 + 111 = 010 + 110 = 011 + 101 == 1000.
Как видно в таком представлении нет проблемы с нулем. Правда, отрицательных на одно больше, чем положительных, а верхний предел не укладывается в заданное число бит.
mayorovp
03.12.2023 10:23У них там терминология тоже запутана, но в другую сторону. Вы, к примеру, тоже запутались.
Правильно вот так:
Ones' complement - дополнение до единиц (множественное число!)
Two's complement - дополнение до двойки (единственное число!)
Представление симметрично. Но +0 и -0 имеют разные значения, а прибавляя (-0) мы получаем переполнение.
Перенос из старшего разряда мы получаем, а не переполнение. Переполнение - это когда результат не влезает в допустимый диапазон, при сложении с нулём это невозможно независимо от знака.
rukhi7
03.12.2023 10:23+2В ассемблере 6502 понятие знаковых и беззнаковых целых чисел не определяется явно через типы данных, как это происходит в высокоуровневых языках
Зачем надо было выделять какой-то 6502 ассемблер? В любом ассемблере у переменных тип явно не определяется. Дело в том, что понятие "переменная" требует дополнительных пояснений для ассемблера. Переменная это просто элемент памяти заданного размера в ассемблере. Когда:
вы применяете к этому элементу операции для работы со знаковым значением, вы как бы локально на время этой операции задаете тип переменной как знаковый, если
вы применяете к этому элементу операции для работы с БЕЗзнаковым значением вы как бы локально на время этой операции задаете тип переменной как БЕЗзнаковый.
Соответственно никто не мешает вам сначала работать со значением как со знаковым, а потом как с беззнаковым, и чередовать их до бесконечности.
Но тоже самое можно делать и в С/С++
вы можете объявить переменную как INT
int A = -15;
и работать с ней как со знаковой, но в любой момент вы можете обратиться к этому значению как к беззнаковому значению:
unsigned int B = 3 + (unsigned int) A;
В этом смысле С/С++ не имеют ограничений по интерпретации данных в памяти (и это относится не только к знаковым-беззнаковым), в таком смысле С/С++ являются низкоуровневыми языками. Они остаются родственниками ассемблера, так сказать, как бы некоторые не пытались об этом забыть.
Странно, что автор статьи совсем не уделил внимания разнице между знаковыми и беззнаковыми по операциям сравнения в ИФ-ах, например. Это то, где на практике проявляется дополнительный это код или основной или обратный, и особенности работы с ними на практике. Я подозреваю что автор хотел подвести только к разнице в аппаратной реализации операций для этих кодов, она действительно впечатляет, но ее вряд ли кто-то поймет из тех, кто о ней уже не знает.
perfect_genius
03.12.2023 10:23Также поведение кода при переполнении (начинается с обратной стороны) приписывает Си, хотя это зависит от процессора, скорее всего.
Wesha
03.12.2023 10:23-1Надо же, миллениалы открыли для себя дополнительный код!
Делаем ставки на то, что же они откроют для себя в следующий раз:
формат хранения чисел с плавающей точкой в компьютере?
Тьюринг-полноту логического элемента 2И-НЕ?
odissey_nemo
03.12.2023 10:23Если начинаешь писать на фортране, то про дополнительный код, естественно, не знаешь, он и не нужен. Но если писал на практически любом ассемблере, что подразумевает знакомство с системой команд соответствующего процессора. то дополнительный код естественным образом, как дыхание, возникает в голове и хранится там вместе с фортраном, без всяких антагонизмов и конфликтов.
Даже странно, что статья написана именно как откровение по этому поводу. Систему команд процессоров не обязательно знать 99.9% разработчиков, особенно разрабатывающим интерфейсы между браузером и БД. И ничего, живут они вполне спокойно и комплексами по поводу незнания про дополнительный числовой код не страдают.
dlinyj
03.12.2023 10:23+2По настоящему благодарен за перевод, благодаря которому узнал о таком крутом калькуляторе HP-16C.
Может кому интересно будет, вот мануал по нему. А вот тут можно поиграться в него онлайн. И есть даже приложуха для ведра.
ptr128
03.12.2023 10:23+1Жаль что обошли молчание еще порядок байтов little-endian и big-endian.
В современном мире с этим приходится часто сталкиваться, если так или иначе занимаешься обработкой данных в бинарном виде, передаваемых по сети или через файлы. Даже просто добавляя собственные типы данных в Protobuf/gRPC без таких знаний далеко не уедешь.
RichardMerlock
Я вот тоже сразу не понял, что тут будут про представление отрицательных чисел затирать. Все работают с дополнительным кодом даже не зная, что он дополнительный!
SIISII
Ну, можно быть вполне успешным программистом почти во всех областях и при этом не знать, что машина работает в двоичной, а не десятичной системе... Но, как по мне, действительно хороший специалист всё ж разбирается и в смежных со своей прямой специальностью вещах, да и свою знает глубже, чем необходимо чисто для работы.
aloginovpro
А можно наоборот, знать про двоичную систему и про мемори барьеры, но не к месту применять эти знания в контексте языков высокого уровня
sim31r
Иногда проскакивает и на высоком уровне, маска подсети какая-нибудь. Представление цвета в формате RGB где на цвет выделяется произвольное количество бит.
rukhi7
вот и автор исходной статьи, не надеется на высокий уровень знаний или способностей развивать эти знания у своих читателей. Симптоматично.