1. Вы находитесь в архивной версии форума xaker.name. Здесь собраны темы с 2007 по 2012 год, большинство инструкций и мануалов уже неактуальны.

C++. Представление различных типов числовых данных в памяти

  1. 1. Деление целых чисел на отрицательные и положительные.
    Целые числа делятся на отрицательные и положительные условно, в памяти никакого деления на положительные и отрицательные нет. Т.е. если под int отведено 4 байта, то в эти 4 байта должны поместиться и положительные значения, и отрицательные числп. Отсюда в ЯВУ как правило есть специальные типы: знаковые и беззнаковые.
    Допустим int, в 32х разрядной системе, имеет размер 4 байта в памяти и фактически в памяти диапазон значений таков: от 0 до 0xFFFFFFFF, но если в программе тип int объявлен как signed(знаковый, кстати по умолчанию он является таким), то этот диапазон допускает значения от -2147483648(0x80000000) до 2147483647(0x7FFFFFFF). Т.е. в памяти ничего прицнипиально не меняется, но программа считает числа, чьё беззнаковое значение больше 0x7FFFFFFF, отрицательными.
    Отсюда следует, что например -1 запишется в памяти как 0xFFFFFFFF, -2 как 0xFFFFFFFE.
    Давай убедимся в этом на практике и продизассемблируем такую программу:
    int b;
    int main()
    Код:
    {
    b = -1;
    b = -2;
    b = -2147483647;
    printf("%d", b);
    getch();
    return 0;
    }
    Прежде всего нас интересуют первые три операции присвоения, компилятор их скомпилирует в машинный код, а переведя этот машинный код на язык ассемблера(продизассемблировав его) мы увидим такие эквивалентые, с точки зрения процессора, команды:
    Код:
    OR DWORD PTR DS:[402008],FFFFFFFF // b = -1;
    MOV DWORD PTR DS:[402008],FFFFFFFE // b = -2;
    MOV DWORD PTR DS:[402008],80000001 // b = -2147483647;
    Операция MOV DWORD PTR DS:[402008],FFFFFFFE на языке ассемблера дословно значит: присвоить ячейке памяти по смещению 0x402008 значение размером 4 байта(DWORD) равное 0xFFFFFFFE.
    Каждую строку я закоментировал. В первой операции присвоения используется OR только потому, что она занимает меньше места, чем MOV, но она эквивалентна ей в данном случае.
    Отсюда можно вывести простую закономерность, фактическое представление любого отрицательного целого числа в памяти можно получить по формуле:
    Код:
    RealValue = 0xFFFFFFFF - b + 1
    Где b - это абсолютное значение отрицательного числа.
    Для удобства в процессоры была включена специальная команда для этого - NEG.
    Выдержка из книги Юрова:
    Команда эта так же используется компилятором при инвертировании положительного знака целых чисел:
    Код:
    int b;
    int main()
    {
    b = 2147483647;
    b = -b;
    return 0;
    }
    Такой вот код компилятор преобразует в следующее машинное выражение:
    Код:
    MOV DWORD PTR DS:[402008],7FFFFFFF // b = 2147483647;
    NEG DWORD PTR DS:[402008] // b = -b;
    Как видишь, сначала происходит присвоение положительного числа, а после изменение знака специальной командой процессора.

    2. Как выглядят числа с плавающей запятой в памяти.
    Тут всё сложнее. Дело в том, что работой с flat point числами занимается арифметический сопроцессор - FPU. Он имеет свои команды, свои регистры(при этом основными являются регистры R0-R7, состовляющие основу модели сопроцессора - стек сопроцессора. Размерность каждого из этих регистров 80 бит, обрати внимание на это).
    Набор функций и форматы данных с которыми работает сопроцессор описывает стандарт IEEE 754.
    При этом формат данных с плавающей запятой делится на 3 группы:
    Короткий
    Длинный
    Расширенный

    Отличаются они между собой прежде всего разрядностью, первый занимает в памяти 4 байта, второй 8 байт, а третий 10.
    В общем случае вещественное число или число с плавающей точкой имеет следующую структуру в памяти:
    Код:
    [знак][экспонента][мантисса]
    
    От размера экспоненты и мантиссы как раз зависит формат данных.
    В С/С++(да и других языках) мы привыкли использовать два формата: float и double. Первый является коротким форматом представления вещественного числа(под экспоненту отводится 8 бит, под мантиссу 23), второй длинным(под экспоненту отводится 11 бит, под мантиссу 52). Под знак во всех форматах отводится 1 бит.
    Теперь переходим к практике, я буду разбирать всё на примере float, с остальными форматами всё аналогично, только с учётом размера составных частей.
    Скомпилируем такой пример и посмотрим как с ним работает процессор:
    Код:
    float a;
    int main()
    {
    a = -7.770000;
    return 0;
    }
    Смотрим во что превращается команда присвоения:
    Код:
    FLD DWORD PTR DS:[403094]
    FSTP DWORD PTR DS:[402000] // a = -7.770000;
    Ага, ну вот, то о чём я и говорил. Сопроцессор использует свой набор команд, поэтому непосвящённым они кажутся совсем непривычными. При этом в них нет ничего необычного. Первая команда загружет нв верхушку стека сопроцессора значение из памяти, а вторая кладёт значение с вершины по адресу 0x402000(очевидно, что это смещение в памяти нашей переменной a).
    При этом если мы посмотрим по адресу 0x403094 мы не увидим ничего даже отдалённо напоминающего -7.770000, вместо этого мы видим следующее значение по этому адресу: 0xC0F8A3D7.
    Вот это и есть представление числа -7.770000 в памяти, таким его видит процессор. Основываясь на вышесказанном, конвертируем это число в последовательность битов и получаем:
    Код:
    11000000111110001010001111010111
    Раскладывая его на составляющие получаем:
    1 - знак. (0 - число положительное; 1 - отрицательное)
    10000001 - Экспонента.
    11110001010001111010111 - Мантисса.

    Начнём постепенно строить модель нашего числа. Нам известно, что число отрицательное, теперь нужно перевести экспоненту в нормальный вид. Экспонента показывает разрядность числа. При этом экспонента может иметь как положительные так и отрицательные значения(нужно ведь как-то и числа от 0 до 1 представлять), т.е. фактически экспонента это знаковое число, у формата float экспонента занимает 8 бит в памяти, а значит её значения могут колебаться в пределаз от -128(0x80) до 127(0x7F).
    Берём значение экспоненты, оно равно 129(10000001b) и чтобы получить кол-во битов, которое характеризует разрядность, нужно отнять от этого числа 127, получаем 2(на самом деле устройство экспоненты это довольно обширная тема, я в подробности вдаваться не стал). Берём у мантиссы 2 бита в счёт целой части:
    Код:
    11.110001010001111010111
    Но 11b это не 7, скажите вы, и будете правы. На самом деле перед этим я должен был сказать, что при преобразовании в число с плавающей точкой в мантиссу уходит всё до последнего бита, равного 1. Этот бит всегда есть и поэтому его нет смысла хранить в памяти, он добавляется в "уме".
    Вы должны были обратить на это внимание, когда я описывал формат и сказал, что размер мантиссы у float - 24 бита, а когда мы разделили на составляющие наше число получили 23 бита, перед этими 23 битами стоит ещё один. Учитывая это получаем:
    Код:
    111.110001010001111010111
    Итак, целая часть 111b, а это как раз 7 в десятичной системе.
    Осталось привести к нормальному виду дробную часть: 110001010001111010111, но и тут наши приключения не заканчиваются Дробная часть получается по следующей формуле(в случае, если Drob представить как массив бит и нумерация элементов массива начинается с 1):
    DrobValue = Drob(1) * 2^(-1) + Drob(2) * 2^(-2) + Drob(3) * 2^(-3) + Drob(4) * 2^(-4) ... Drob(n) * 2^(-n)

    Где n кол-во бит в оставшейся мантиссе.
    Или, если применить к нашему случаю, получаем:
    DrobValue = (1 * 2^(-1)) + (1 * 2^(-2)) + (0 * 2^(-3)) + (0 * 2^(-4)) ... 1 * 2^(-21)

    Если проссумировать всё, как написано выше, получим: 0,769999980926514

    И вот тут открывается ещё одна тайна Хочу сразу сказать, что точность вычислений формата float ограничена 7 знаками после запятой, поэтому всё, что после не учитывается, хотя оно и не влияет на результат округления. Так всё-таки, почему так? Всё очень просто, при переводе десятичных дробей в двоичные мы получаем гораздо больше варинатов периодических дробей(В десятичных дробях знаменатели - это всегда степени десятки, в двоичных дробях знаменатели - это всегда степени двойки), отсюда и появляется некоторая погрешность. Она присутствует всегда и от неё никуда не деться. Как правило ЯВУ дают возможность абстрагироваться от неё округляя такие числа, поэтому конечному пользователю она не видна, однако на аппаратном уровне вычисления происходят с дробами, где эта погрешность всё-таки имеется.
    Связывая в одно всё вышесказанное, значение 0xC0F8A3D7 превращается в отрицательное число с плавающей точкой, где целая часть равна 7, а дробная 769999980926514:

    -7.769999980926514

    На полноту описание конечно же не претендует, я и половины особенностей форматов FPU не рассмотрел, однако постарался изложить всё просто и доступно, подкрепив примерами.
    Теперь ссылки по теме, если она Вас заинтересовала:
    http://babbage.cs.qc.edu/IEEE-754/References.xhtml
    http://babbage.cs.qc.edu/IEEE-754/Decimal.html
    http://basicproduction.nm.ru/articles/bpdblvb.htm

    (C) W[4Fh]LF [hunger.ru]