Список предыдущих эпизодов:
Размер данных и типы
Когда мы пишем что-то вроде 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, однако, имеет механизм для решения таких проблем. Это объединения.
Синтаксис union позволяет задать параллельные взаимозамещаемые типы для одной и той же переменной. В примере выше поле int16 имеет размер 2 байта, а поле bytes занимает те же самые 2 байта, но уже в виде массива.
Выше я писал, что выделяя несколько байт под одну переменную, мы фактически создаём массив. Так вот он тут и есть. И присваивать значения мы можем либо через поле int16, либо через отдельные байты-элементы массива.
Однако никто не ограничивает нас рамками union. Давайте проэкспериментируем и заодно вспомним то, что было в предыдущих материалах.
Итак, foo это адрес, который указывает на область памяти размером 2 байта. Так как это адрес, мы можем сделать из него указатель:
&foo
А так как это указатель, мы можем произвести с ним математические действия:
&foo + 1
И получив новый адрес, мы можем туда что-то записать:
*(&foo + 1) = 5
А это как раз адрес старшего байта foo. То есть нам не понадобился даже union. Давайте проверим. Поместим в foo значение 65535 и обнулим старший байт. После этого значение foo должно стать равным 255.
Распечатав значение до и после операции, мы видим, что ничего не изменилось. Почему?
Если вы внимательно читали предыдущие части, то должны помнить, что язык C автоматически умножает смещения, которые прибавляются к адресам, на размер того указателя, который владеет адресом. Если размер foo 2 байта, то выражение
&foo + 1
на деле превращается в:
&foo + 1 * sizeof(foo)
И к адресу добавляется 2. И это ещё не всё. Когда мы по такому указателю записываем число 0, оно также пишется неразделимой порцией в 2 байта, потому что таков размер указателя.
Так что мы записали не ноль в старший байт foo, а сразу два ноля начиная с адреса, следующего за foo. И так как доступ туда никто не санкционировал, это может привести к падению программы.
Чтобы оперировать адресами на уровне байтов, нужен указатель соответствующего типа. И можно привести указатель &foo к такому типу:
(char *)&foo + 1
Теперь всё получится:
Для достижения того же самого результата мы можем использовать и синтаксис массива:
((char *)&foo)[1] = 0;
Язык C удивительно гибкий и полностью нам доверяет. Как не любить его?
Читайте дальше: