Найти в Дзене
ZDG

Этот программист гранулирует память как хочет, жена устала оттаскивать

Оглавление

Список предыдущих эпизодов:

Размер данных и типы

Когда мы пишем что-то вроде foo = 5, то кладём значение 5 в память по адресу foo. По умолчанию мы считали, что число 5 занимает какую-то одну ячейку памяти, но что именно она из себя представляет, не уточняли.

В реальности одна ячейка памяти это байт, то есть число размером 8 бит. В один байт можно записать максимальное значение 255, значит с 5 не будет никаких проблем.

Что делать с числами побольше? Для них можно выделить два байта. Тогда максимальное число вырастет до 65535. Но и запись в память усложняется. Если мы выделим под переменную foo два байта, фактически это значит, что мы сделали массив длиной 2 элемента. И одну часть нашего большого числа (младший байт) нужно записать в один элемент массива, а другую часть (старший байт) в другой.

Условно это будет происходить так. Число 65535 состоит из двух байт, каждый из которых в свою очередь равен 255.

foo[0] = 255;
foo[1] = 255
;

Такая же составная процедура потребуется для чтения.

Если понадобилось число ещё больше, можно выделить под переменную 4 байта или даже 8 байт. То есть опять же создать массивы с соответствующей длиной. И аналогично с ними работать, только это будет ещё неудобнее, чем с двумя байтами.

К счастью, процессор на аппаратном уровне поддерживает чтение и запись данных порциями больше чем 1 байт. Так, если процессор 16-битный, он умеет читать и писать сразу по 2 байта. Если 32-битный, то по 4 байта. И т.д.

Стало быть, если мы напишем foo = 65535, процессор запишет сразу 2 байта в память так, как будто это одна неделимая ячейка памяти.

Но если мы напишем foo = 5, что делать процессору? Записывать один байт или всё-таки два?

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

short int foo

И тогда компилятор знает, что писать и читать надо по два байта. И да, даже если это число 5, оно будет расширено до двух байт. Условно,

foo[0] = 5;
foo[1] = 0;

Старший байт в данном случае равен 0.

Объединения

Договорившись, что foo будет занимать 2 байта, мы считаем этот объём памяти неделимым. Теперь всегда будут записываться и читаться два байта сразу как единое целое.

Но можем ли получить доступ к индивидуальным байтам внутри foo?

Во-первых, это можно сделать чисто математически. Чтобы получить младший байт foo, достаточно обнулить старший:

foo & 255

Или просто сделать приведение типа foo к char:

(unsigned char) foo

А чтобы получить старший, надо сдвинуть его вправо на место младшего:

high_byte = foo >> 8

Аналогично, чтобы записать в foo два разных байта, их предварительно надо объединить в одно целое:

foo = (high_byte << 8) + low_byte

Язык C, однако, имеет механизм для решения таких проблем. Это объединения.

-2

Синтаксис union позволяет задать параллельные взаимозамещаемые типы для одной и той же переменной. В примере выше поле int16 имеет размер 2 байта, а поле bytes занимает те же самые 2 байта, но уже в виде массива.

Выше я писал, что выделяя несколько байт под одну переменную, мы фактически создаём массив. Так вот он тут и есть. И присваивать значения мы можем либо через поле int16, либо через отдельные байты-элементы массива.

Однако никто не ограничивает нас рамками union. Давайте проэкспериментируем и заодно вспомним то, что было в предыдущих материалах.

-3

Итак, foo это адрес, который указывает на область памяти размером 2 байта. Так как это адрес, мы можем сделать из него указатель:

&foo

А так как это указатель, мы можем произвести с ним математические действия:

&foo + 1

И получив новый адрес, мы можем туда что-то записать:

*(&foo + 1) = 5

А это как раз адрес старшего байта foo. То есть нам не понадобился даже union. Давайте проверим. Поместим в foo значение 65535 и обнулим старший байт. После этого значение foo должно стать равным 255.

-4

Распечатав значение до и после операции, мы видим, что ничего не изменилось. Почему?

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

&foo + 1

на деле превращается в:

&foo + 1 * sizeof(foo)

И к адресу добавляется 2. И это ещё не всё. Когда мы по такому указателю записываем число 0, оно также пишется неразделимой порцией в 2 байта, потому что таков размер указателя.

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

Чтобы оперировать адресами на уровне байтов, нужен указатель соответствующего типа. И можно привести указатель &foo к такому типу:

(char *)&foo + 1

Теперь всё получится:

-5

Для достижения того же самого результата мы можем использовать и синтаксис массива:

((char *)&foo)[1] = 0;

Язык C удивительно гибкий и полностью нам доверяет. Как не любить его?

-6

Читайте дальше: