Арифметика с плавающей запятой - популярная эзотерическая тема в информатике. Можно с уверенностью заявить, что каждый инженер-программист по крайне мере слышал о числах с плавающей запятой. Многие даже использовали их в какой-то момент. Но мало кто будет утверждать, что действительно понимает их достаточно хорошо, и значительно меньшее число скажет, что знает все пограничные случаи. Эта последняя категория инженеров, вероятно, мифическая или, в лучшем случае, очень оптимистичная. В прошлом мне приходилось иметь дело с проблемами связанными с плавающей запятой в библиотеке C GNU, но я бы не стал называть себя экспертом по этой теме. И я определенно не ожидал узнать о существовании нового типа чисел, о которых я услышал пару месяцев назад.  В этой статье описаны новые типы чисел с плавающей запятой, которые ничему не соответствуют в физическом мире. Числа, которые я называю псевдо-нормальными числами, могут создать проблемы для программистов, которые трудно отследить, и даже попали в печально известный список Common Vulnerabilities and Exposures (CVE). Практически каждый язык программирования реализует 64-битные double (числа с плавающей запятой двойной точности) с использованием формата IEEE-754. Формат определяет 64-битное хранилище, которое имеет один знаковый бит, 11 битов порядка/экспоненты (exponent bits) и 52 бита мантиссы (significand bits). Каждый битовый паттерн принадлежит одному единственному из этих типов чисел с плавающей запятой: Нормальное число: в экспоненте установлен (в единицу) хотя бы один бит (но не все биты). Биты мантиссы и знака могут иметь любое значение. Денормализованное/субнормальное (denormal) число: у экспоненты все биты сброшены (в ноль). Биты мантиссы и знака могут иметь любое значение. Бесконечность (Infinity): у экспоненты установлены все биты. В мантиссе все биты сброшены, а знаковый бит может иметь любое значение. Неопределенность/Not a Number - (NaN): у экспоненты установлены все биты. Мантисса имеет по крайней мере один установленный бит, а знаковый бит может иметь любое значение. Ноль: у экспоненты и мантиссы все биты сброшены. Знаковый бит может иметь любое значение, откуда берет начало излюбленная многими концепция нулей со знаком. Биты мантиссы описывают только дробную часть; для денормализованных чисел и нулей первый бит мантиссы (в оригинале “integer”) по соглашению равен нулю, а для всех остальных чисел он равен единице. Языки программирования идеально сопоставляют представления с этими категориями чисел. Не существует числа с плавающей запятой двойной точности, для которого, по крайней мере, не определена его классификация. Из-за широкого распространения можно в значительной степени полагаться на единообразное поведение на разных аппаратных платформах и средах выполнения. Если бы мы только могли сказать то же самое о старшем брате типа double: типе long double. Формат расширенной точности (extended precision) IEEE-754 существует, но он не определяет кодировки и не является стандартом для всех архитектур. Однако мы здесь не для того, чтобы сетовать на такое положение вещей; мы исследуем новый вид чисел. Мы можем найти их в формате чисел с плавающей запятой двойной расширенной точности (double extended-precision) от Intel. Раздел 4.2 в руководстве разработчика программного обеспечения для архитектур Intel 64 и IA-32 определяется формат числа с плавающей запятой двойной расширенной точности как 80-битное значение со схемой, показанной на рисунке 1.   Учитывая это определение, наши надежные классификации чисел, на которые мы полагаемся, сопоставляются с форматом long-double следующим образом: Нормальное число: у экспоненты установлен хотя бы один бит (но не все биты). Биты мантиссы и знака могут иметь любое значение. Первый бит мантиссы (integer bit) установлен в единицу. Денормализованное число: у экспоненты все биты сброшены. Биты мантиссы и знака могут иметь любое значение. Первый бит мантиссы сброшен в ноль. Бесконечность: у экспоненты установлены все биты. В мантиссе все биты сброшены, а знаковый бит может иметь любое значение. Первый бит мантиссы установлен в единицу. Неопределенность (NaN): у экспоненты установлены все биты. Мантисса имеет по крайней мере один установленный бит, а знаковый бит может иметь любое значение. Первый бит мантиссы установлен в единицу. Ноль: у экспоненты и мантиссы все биты сброшены. Знаковый бит может иметь любое значение. Первый бит мантиссы сброшен в ноль. Внимательный наблюдатель может задать два очень разумных вопроса: Что, если у нормального числа, бесконечности или NaN первый бит мантиссы сброшен в ноль? Что, если у денормализованного числа первый бит мантиссы установлен в единицу?   С этими вопросами вы откроете для себя новый набор чисел. Поздравляю! В разделе 8.2.2 «Неподдерживаемые кодировки чисел с плавающей запятой двойной расширенной точности и псевдо-денормализованные числа» руководства для разработчиков архитектур Intel 64 и IA-32 эти числа описаны, поэтому они известны. Таким образом, математический сопроцессор Intel для обработки операций с плавающей запятой (floating point unit - FPU) будет генерировать исключение недопустимой операции, если он встречает псевдо-NaN (то есть NaN с нулевым первым битом мантиссы), псевдо-бесконечность (бесконечность с нулевым первым битом мантиссы) или ненормальное/unnormal (нормальное число с нулевым первым битом мантиссы). FPU продолжает поддерживать псевдо-денормализованные значения (денормализованные числа с единицей в первом бите мантиссы) так же, как и обычные денормализованные числа, путем генерации исключения денормализованного операнда. Так было со времен 387 FPU.  Псевдо-денормализованные числа менее интересны, потому что они обрабатываются так же, как денормализованные. Остальные же не поддерживаются; в руководстве указано, что FPU никогда не будет генерировать эти числа и не утруждает себя присвоением им общего названия. Однако в этой статье нам нужно как-то к ним обращаться, поэтому я назову их псевдо-нормальными числами. К настоящему времени очевидно, что эти числа раскрывают пробел в нашем мировоззрении чисел с плавающей запятой в нашей среде программирования. Псевдо-NaN - это тоже NaN? Псевдо-бесконечность тоже бесконечность? А что насчет ненормальных (unnormal) чисел? Должны ли они быть вынесены в свой собственный класс чисел? Должен ли каждый из этих типов быть представлен своим собственным классом чисел? Почему я не плюнул на это все еще до третьего вопроса? Изменение среды программирования для введения нового класса чисел не является стоящим решениям ни для какой архитектуры, так что об этом не может быть и речи. Отнесение этих чисел к существующим классам может зависеть от того, к какому классу они относятся. В качестве альтернативы, мы могли бы коллективно рассматривать их как NaN (в частности, сигнальный NaN), потому что, как и сигнальный NaN, работа с ними генерирует исключение недопустимой операции. Здесь возникает актуальный вопрос, должны ли мы вообще об этом думать. Стандарт C, например, в разделе 6.2.6 Представления типов, утверждает, что «Некоторые определенные представления объектов не обязательно должны представлять значение типа объекта», что соответствует нашей ситуации. FPU никогда не будет генерировать эти представления для типа long double, поэтому можно утверждать, что передача этих представлений в long double не определена. Это один из способов ответить на вопрос о классификации, но он, по сути, мешает пользователю понять спецификацию оборудования. Это подразумевает, что каждый раз, когда пользователь читает long double из двоичного файла или сети, ему необходимо проверить, действительно ли представление соответствует базовой архитектуре. Это то, что функция fpclassify и ей подобные должны делать, но, к сожалению, они этого не делают. Чтобы определить, является ли ввод NaN, коллекция компиляторов GNU (GCC) передает ответ, который ему дал ЦП. То есть он реализует __builtin_isnanl, выполняя сравнение с плавающей запятой со вводом. Когда генерируется исключение (как в случае с NaN), устанавливается флаг четности (parity flag), который указывает на неупорядоченный результат, указывая, таким образом, на то, что входное значение - NaN. Когда ввод - любое из псевдо-нормальных чисел, он генерирует исключение недопустимой операции, поэтому все эти числа классифицируются как NaN.  Библиотека C GNU (glibc), с другой стороны, смотрит на битовый паттерн числа, чтобы определить его классификацию. Библиотека оценивает все биты числа, чтобы решить, является ли число NaN в __isnanl или __fpclassify. Во время этой оценки реализация предполагает, что FPU никогда не будет генерировать псевдо-нормальные числа, и игнорирует первый бит мантиссы. В результате, когда вводом является любое из псевдо-нормальных чисел (кроме, конечно, псевдо-NaN), реализация «фиксирует» числа в их не-псевдо аналогах и делает их валидными! Реализация isnanl в glibc предполагает, что он всегда получает правильно отформатированный long double. Это не является необоснованным предположением, но оно возлагает на каждого программиста ответственность по проверке двоичных данных long double, считываемых из файлов или сети, перед их передачей в isnanl, который, по иронии судьбы, является функцией проверки. Эти предположения привели к CVE-2020-10029 и CVE-2020-29573. В обоих этих CVE функции (тригонометрические функции в первом и семейство функций printf во втором) полагаются на допустимые входные данные и в конечном итоге приводят к потенциальным переполнениям стека. Мы исправили CVE-2020-10029, рассматривая псевдо-нормальные числа как NaN. Функции проверяют первый бит мантиссы и дают сбой, если он равен нулю.  История исправлений CVE-2020-29573 немного интереснее. Несколько лет назад в качестве очистки glibc заменила использование isnanf, isnan и isnanl на стандартный макрос C99 isnan, который расширяется до соответствующей функции на основе ввода. Впоследствии был выпущен другой патч для оптимизации определения макроса isnan C99, чтобы он использовал __builtin_isnan, когда это безопасно. Это непреднамеренно исправило CVE-2020-29573, потому что проверка на достоверность теперь перестала работать для псевдо-нормальных чисел. CVE побудили нас (сообщество инструментальных средств GNU) более серьезно поговорить о классификации этих чисел по отношению к интерфейсу библиотеки C. Мы обсудили это в сообществах glibc и GCC и согласились, что эти числа следует рассматривать как сигнальные NaN в контексте интерфейсов библиотеки C. Однако это не означает, что libm будет стремиться последовательно обрабатывать эти числа как NaN внутренне или обеспечивать исчерпывающий охват. Цель не в том, чтобы определять поведение этих чисел; это только для того, чтобы сделать классификацию последовательной во всей цепочке инструментов. Что еще более важно, мы согласовали рекомендации в случаях, когда неправильная классификация этих чисел приводит к сбоям или проблемам с безопасностью.  Это, друзья, история ненормальных чисел, псевдо-NaN и псевдо-бесконечности. Я надеюсь, что вы никогда не столкнетесь с ними, но если столкнетесь, надеюсь, мы упростили для вас борьбу с ними. Хотите узнать чем же все-таки язык С++ лучше других ЯП? Тогда приглашаю всех желающих зарегистрироваться на бесплатный интенсив, в рамках которого  мы настроим свой http-сервер и разберем его что называется "от и до"; А во второй день произведем все необходимые замеры и сделаем наш сервер супер быстрым. Интенсив пройдет в рамка курса "C++ Developer. Basic" Записаться на интенсив. День 1 Записаться на интенсив. День 2