Сайт кафедры → Дисциплины ИТ → РПОСУ

РПОСУ. ЛР № 4. Представление данных в памяти

Цель работы

  1. Знать представление различных типов и структур данных в памяти.
  2. Уметь использовать средства языка и стандартной библиотеки C++ для манипуляций с битами данных, адресами памяти и строками C.

Задание

Настоятельно рекомендуется после выполнения каждого пункта данной работы делать коммиты.

1. Подготовить инструменты для исследований и отладки

Написать функции для печати отдельных байт и блока данных data размером size байтов в шестнадцатеричном и в двоичном представлении:

void print_in_hex(uint8_t byte);
void print_in_hex(const void* data, size_t size);
void print_in_binary(uint8_t byte);
void print_in_binary(const void* data, size_t size);

Для удобства чтения рекомендуется между байтами добавлять пробелы и делать перевод строки, например, после каждых 16-и байт (в print_in_hex()) или после каждых четырех байт (в print_in_binary()).

2. Написать программу-калькулятор для побитовых операций

Пользователь вводит первый операнд, затем оператор (символ &, | или ^), затем второй операнд. Программа выполняет указанное действие над операндами и печатает результат в шестнадцатеричном и двоичном виде. Операнды — двухбайтовые беззнаковые целые числа (uint16_t).

Пример работы:

Ввод: 1025 & 127

Вывод на экран: 01 04 & 7F 00 = 01 00

00000001 00000100 & 01111111 00000000 = 00000001 00000000

3. Изучить представление и размещение данных в памяти

  1. Определить структуру Student, описывающую студента атрибутами:

    1. Имя (массив из 17 символов, включая завершающий '\0').
    2. Год поступления (беззнаковое целое, 2 байта).
    3. Средний балл (с плавающей запятой).
    4. Пол, представленный одним битом (0 — женский, 1 — мужской).
    5. Количество пройденных курсов.
    6. Указатель на структуру Student, описывающую старосту группы (для старосты — нулевой указатель).

    Указание. Поле пола из одного бита можно определить так:

  2. Объявить и заполнить массив из трех структур Student, описывающий двух студентов одной группы и их старосту.

  3. Напечатать, занести в отчет и письменно пояснить:

    Указание. Смещение поля field структуры типа type от начала любого её экземпляра можно определить макросом offsetof(type, field).

4. Написать программу для обработки текстового файла

Данная часть лабораторной работы выполняется отдельно от предыдущей части, в новом проекте.

В учебных целях текст нужно представлять только строками C, размещаемыми в динамической памяти или на стеке.

  1. Запросить у пользователя имя файла, сохранив его в массиве символов, размещенном на стеке (не в динамической памяти).

  2. Проверить, используя функции стандартной библиотеки C++ для работы со строками C, что введенное имя файла корректно (в Windows):

    Указание. Задачи решаются стандартными функциями isalpha(), strchr(), strrchr(), strncmp(), tolower().

  3. Если введенное имя файла не имеет расширения, добавить расширение .txt.

  4. Загрузить содержимое текстового файла в память целиком:

    1. Использовать ifstream или fopen() для доступа к файлу.
    2. Использовать методы seekg() и tellg() либо функции fseek() и ftell() для определения размера файла, переместившись в его конец и получив текущее положение в файле;
    3. Выделить в динамической памяти массив достаточного размера;
    4. Загрузить всё содержимое файла в выделенную область памяти методом read() или функцией fread().
  5. Запросить у пользователя строку, поместив её в массив на стеке.

  6. Подсчитать и вывести число вхождений введенной строки в текст файла.

  7. Освободить все выделенные в процессе решения блоки памяти.

Указания к выполнению

1. Функции print_in_*()

Разобьем задачу на более простые части.

Начнем с print_in_hex(). Байт — это 8 бит, то есть две цифры в шестнадцатеричной системе. Чтобы напечатать байт, нужно напечатать цифру, соответствующую его старшей и младшей половине (они называются nibble). Любой блок данных (по адресу в нетипизированном указателе void*) — это массив байт; нужно только указать компилятору рассмотреть void* как uint8_t*. Очевидно, чтобы напечатать массив байт, нужно напечатать каждый байт в цикле.

Перевод байта в двоичную запись можно делать целиком, дробить байт нет смысла. Печать массива байт в двоичном виде по сути не отличается от печати их в шестнадцатеричной системе счисления.

Итак, элементарные задачи:

  1. Напечатать шестнадцатеричную цифру для значения от 0 до 15.
  2. Извлечь из байта младший nibble как число от 0 до 15.
  3. Извлечь из байта старший nibble как число от 0 до 15.
  4. Напечатать байт в шестнадцатеричном виде как два nibble.
  5. Преобразовать void* в uint8_t*.
  6. Напечатать size элементов массива по адресу в uint8_t* (hex).
  7. Напечатать байт в двоичном виде.
  8. Напечатать size элементов массива по адресу в uint8_t* (binary).

1.1. Напечатать шестнадцатеричную цифру для значения от 0 до 15

Напишем вспомогательную функцию, которая будет представлять значение от 0 до 15 в шестнадцатеричном виде. Что она принимает? Целое число от 0 до 15, для этого достаточно uint8_t. Что она возвращает? Можно сразу печатать результат, тогда не нужно возвращать ничего (void). Но вспомним, что функции желательно делать максимально пригодными для повторного использования, и вовсе не всегда нужно печатать nibble не экране. Поэтому лучше возвращать символ для nibble, то есть char. Итого: char nibble_to_hex(uint8_t i);.

Как реализовать nibble_to_hex()? Очевидный вариант — через switch на 16 вариантов. Есть и более лаконичный вариант: заведем массив цифр char digits[] = "0123456789abcdef"; и будем для значения i возвращать digits[i]. На практике популярен еще один вариант - через коды символов. Вспомним, что символы в памяти хранятся как их коды, например, коды цифр от '0' до '9' — от 48 до 57, а коды букв от 'a' до 'f' — от 97 до 102. Таким образом, если i меньше 10, можно прибавить i к '0' и получить соответствующую цифру; если i больше, нужно прибавить к 'a' столько, на сколько i больше 10 (то есть для 10 — 0, для 11 — 1 и т. д.).

Важный момент — самопроверка. Чтобы проверить работу nibble_to_hex(), добавим в программу функцию-тест, вызываемую в начале main(), из 16 строк:

assert(nibble_to_hex(0x0) == '0');
assert(nibble_to_hex(0x1) == '1');
// ...
assert(nibble_to_hex(0xf) == 'f');

Еще один вопрос — реакция nibble_to_hex() на некорректные значения аргумента. Можно решить его путем защитного программирования: добавить в начало функции

assert(0x0 <= i && i <= 0xf);

1.2. Извлечь из байта младший nibble как число от 0 до 15

Задача сводится к тому, чтобы из восьми бит четыре младших оставить такими, как есть, а четыре старших обнулить. Типовое решение — наложить битовую маску. Битовая маска следующая: 0b00001111, или 0x0f, — в ней единицы стоят в тех позициях, биты в которых нужно извлечь. Логическое «И» (&) бита x с нулем дает 0, а с единицей - x, то есть byte & mask даст искомый младший nibble. Решение для математиков — взять остаток от деления байта на 32 (0b00010000).

1.3. Извлечь из байта старший nibble как число от 0 до 15

Можно разбить эту задачу еще на две: выделение старших разрядов байта и их перемещение (сдвиг) на позиции младших разрядов. Какая маска подойдет для выделения? Очевидно, 0b11110000 (0xf0). Сдвиг вправо на 4 разряда делается оператором >>: byte >> 4. По стандарту C++, старшие биты результата будут равны 0, поэтому на самом деле выделять старшие биты не нужно. Сдвиг вправо на 4 позиции математически равносилен делению на 2⁴, но при работе с битами сдвиг лучше выражает суть дела.

1.4. Напечатать байт как два nibble

Запишем в коде все предыдущие рассуждения:

void
print_in_hex(uint8_t byte) {
    cout << nibble_to_hex(byte >> 4)
         << nibble_to_hex(byte & 0xf);
}

Для самопроверки следует попробовать напечатать байты 0x0, 0xab, 0xff.

1.5, 1.6. Преобразовать void* в uint8_t* и напечатать массив этих байт

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

const uint8_t* as_bytes(const void* data);

Здесь важны ключевые слова const. Они означают, что данные по адресу, хранимому в указателе, не могут быть изменены через этот указатель.

Считая, что она реализована, можно записать печать массива сразу:

void
print_in_hex(const void* data, size_t size) {
    const uint8_t* bytes = as_bytes(data);
    for (size_t i = 0; i < size; i++) {
        print_in_hex(bytes[i]);

        // Для удобства чтения: пробелы между байтам, по 16 байт на строку.
        if ((i + 1) % 16 == 0) {
            cout << '\n';
        }
        else {
            cout << ' ';
        }
    }
}

Указание компилятору, что значение одного типа нужно трактовать как значение другого, называется приведением типов. Из лекций известно, что в данном случае корректна такая реализация:

const uint8_t*
as_bytes(const void* data) {
    return reinterpret_cast<const uint8_t*>(data);
}

Самопроверка: завести переменные типа uint8_t, uint16_t, uint32_t и дать им одно и то же значение, 0x42. Напечатать их через новую функцию и убедиться визуально, что единственным ненулевым байтом будет 0x42 в каждом случае, а всего байт будет столько, сколько ожидается. Вторым параметром следует передавать sizeof, например:

uint32_t u32 = 0x42;
cout << "u32 bytes: ";
print_in_hex(&u32, sizeof(u32));
cout << '\n';

1.7. Напечатать байт в двоичном виде

Известен способ перевода в двоичную систему путем взятия остатков от деления на два, однако, порядок остатком получается обратным порядку бит. В программе проще проверять биты, начиная со старшего, и печатать 0, если бит равен 0, и 1, если бит равен 1. Для выделения бита можно воспользоваться маской: старший бит выделяется как 0b10000000, или (0x1 << 7), младший — маской (0x1 << 0). После наложения маски с одним установленным битом в результате останется либо 0 (если соответствующий бит не установлен), либо не-ноль, если установлен. Выделим эту логику в функцию по аналогии с nibble_to_hex():

char
bit_digit(uint8_t byte, uint8_t bit) {
    if (byte & (0x1 << bit)) {
        return '1';
    }
    return '0';
}

Сдвиги на 7, 6, …, 0 бит логично делать циклом. Итого:

void
print_in_binary(uint8_t byte) {
    for (uint8_t bit = 7; bit > 0; bit--) {
        cout << bit_digit(byte, bit);
    }
}

Внимание: в данном цикле имеется ошибка - не выводится бит младшего разряда. Предлается устранить ошибку самостоятельно. Самопроверка: напечатать число 15 - ожидаемый вывод: 00001111. Перевести в двоичное представление и напечатать числа из лекционного слайда про двоичные операции (исходные два числа и результаты всех действий).

1.8. Напечатать блок данных в двоичном виде

Очевидно, что приведение типов не отличается от случая для шестнадцатеричной системы. Напишем и проверим по аналогии с print_in_hex():

void
print_in_binary(const void* data, size_t size) {
    const uint8_t* bytes = as_bytes(data);
    for (size_t i = 0; i < size; i++) {
        print_in_binary(bytes[i]);

        // Для удобства чтения: пробелы между байтами, по 4 байта на строку.
        if ((i + 1) % 4 == 0) {
            cout << '\n';
        }
        else {
            cout << ' ';
        }
    }
}

В двоичной системе 42 будет 0b00101010. Этот байт должен стоять первым при печати целого числа любой длины, а за ним — байты с нулями.

2. Битовый калькулятор

В отчет нужно занести код и результаты в отчет в виде текста.

Вопрос: почему 1025 (0b00000100'00000001, 0x0401) представлено байтами 01 04, а не наоборот?

Ответ: потому что на x86 (Intel) порядок байт от младшего к старшему (little-endian), то есть младший байт в памяти расположен первым.

3. Исследование представления и размещения данных в памяти

Комментарий к отчету по данному пункту ЛР.

4. Работа со строками C

Вместо пошагового выполнения ЛР рассмотрим решение двух типовых задач: ввода и обработки строки C функциями стандартной библиотеки и загрузки текста из файла в строку C. Задание на ЛР представляет собой их комбинацию.

4.1. Ввод строки C и её обработка функциями стандартной библиотеки

Решим задачу: считать строку C и напечатать по отдельности слов в ней (слова разделены пробелами и знаками препинания).

Ввод строки C

Для определенности предположим, что длина строки не превышает некоторой заранее заданной, например, 255 символов. С учетом завершающего '\0' под строку нужно 256 символов:

const size_t MAX_SIZE = 256;
char text[MAX_SIZE];

Ввести с строку C можно функцией fgets(). Ознакомимся с документацией по ссылке. В документации обычно есть и примеры использования описываемых функций.

Прототип функции:

char* fgets(char* str, int count, FILE* stream);

Над прототипом написано: Defined in header <cstdio> — это значит, что для использования функции нужно включить заголовочный файл <cstdio>.

Под прототипом написано, что делает данная функция: считывает не более count - 1 символов и записывает их в массив, на который указывает str; чтение ведется из файлового потока stream.

Нам необходимо считывать строку со стандартного ввода, где взять файловый поток для него? В справочнике FILE является ссылкой на статью «C-style file input/output» («Файловый ввод-вывод средствами C»). В конце её в разделе Macros можно найти запись:

    stdin       expression of type FILE* associated with the input stream

То есть глобальная переменная stdin из <cstdio> и есть нужный поток (это упоминалось в лекциях).

Итак, вызов для чтения строки:

fgets(text, MAX_SIZE, stdin);

Заметим, что на практике, а не в учебных целях, удобнее считывать строки C++:

string text;
getline(cin, text);

Если затем нужен указатель на массив считанных символов, его можно получить как text.c_str() (менять символы в этом массиве нельзя; при необходимости есть метод text.data()).

Разбиение строки на слова

Чтобы напечатать слова строки по отдельности, нужно искать границы слов и печатать часть строки от начала до конца слова. Чтобы найти конец слова, нужно найти первый (от любой позиции внутри слова, в том числе от его начала) символ-разделитель. Разделители могут идти подряд. Вот пример текста:

    News,from beyond the Narrow Sea.  Haven't you heard?!
        ↑                           ↑↑
    нет пробела               два пробела

Кроме знаков препинания, разделители включают также пробел и символы перевода строк:

const char* separators = " \r\n,.!?:;()-";

Функции стандартной библиотеки для работы со строками C — в заголовочном файле <cstring>. Из обширного списка наиболее подходящими для задачи выглядят описания:

Алгоритм решения:

  1. Определить, сколько разделителей находятся в начале строки — strspn().

  2. Пропустить их (сместить указатель на начало строки).

  3. Если достигнут конец строки (начальный символ — '\0'), закончить работу.

  4. Найти первый разделитель от нового начала строки (или слова), то есть длину слова — strcspn().

  5. Напечатать часть строки от начала слова до разделителя (это можно сделать методом cout.write() или функцией fwrite()). Также напечатать символ перевода строки.

  6. Сдвинуть начало строки вперед на длину слова.

  7. Перейти к пункту 1.

Почти каждый шаг алгоритма — всего одна строка или конструкция. Начало строки (то есть еще не разобранной части) будем хранить в переменной:

const char* start = text;

Алгоритм представляет собой цикл:

while (true) {
  1. Определить, сколько разделителей находятся в начале строки:

  2. Пропустить их:

  3. Если достигнут конец строки, закончить работу.

  4. Найти первый разделитель от нового начала строки:

  5. Напечатать часть строки от начала слова до разделителя:

    Также напечатать символ перевода строки:

  6. Сдвинуть начало строки вперед на длину слова:

}

Соединив участки кода в полноценную программу, можно убедиться, что она работает правильно, выполнив в командной строке:

echo "News,from beyond the Narrow Sea.  Haven't you heard?" | lab04.exe

Вывод:

News
from
beyond
the
Narrow
Sea
Haven't
you
heard

Контрольные вопросы

  1. Опишите назначение и использование операторов new и delete. Чем отличается синтаксис удаления массива от синтаксиса удаления одиночного значения?

  2. Как при помощи динамических массивов организовать работу с квадратной матрицей, размер которой становится известен во время выполнения?

  3. Что такое «рваный» массив (jagged array)? Как выделять и освобождать память под его элементы, как к ним обращаться?

  4. Каковы особенности арифметики типов с плавающей запятой по сравнению с математически точной и сравнения их значений?

  5. Как и почему корректно сравнивать значения с плавающей запятой и бороться с ошибками округления при операциях над такими значениями?

  6. В чем заключается арифметика с фиксированной запятой, какие у нее преимущества и недостатки по сравнению с арифметикой типов с плавающей запятой?

  7. Опишите действие оператора reinterpret_cast. В каких случаях его удобно применять, и какие проблемы при этом могут возникнуть?

  8. Что такое выравнивание данных (alignment)? Почему оно существует, зачем и как контролировать его наличие?

  9. Как определить размер переменной в C++, и в каких случаях он отличается от размера полезных данных, связанных с переменной?

  10. Какие побитовые операторы имеются в C++? Приведите примеры их работы.

  11. Что такое битовые флаги и битовые маски? Как в C++ записываются числа в системах счисления, отличных от десятичной?

  12. Каким образом можно: а) объявить целочисленную переменную размером 8, 16, 32 бита (гарантированно); б) объявить битовый массив произвольно большого размера; в) поле структуры размера, не кратного байту (например, 9 бит)?

  13. Как в C++ объявляются простые массивы (одномерные и многомерные), каков синтаксис их инициализации, доступа к отдельным элементам, определения размера?

  14. Опишите шаблон класса std::array<T, N>, его назначение и преимущества использования по сравнению с простыми массивами.

  15. Что такое строки в стиле C (C-style string) и какие средства работы с ними имеются в стандартной библиотеке C++?


Козлюк Д. А., Мохов А. С., Василькова П. Д. для кафедры Управления и информатики НИУ «МЭИ», 2019 г. Система Orphus