print_in_*()
Разобьем задачу на более простые части.
Начнем с print_in_hex()
. Байт — это 8 бит, то есть две цифры в шестнадцатеричной системе. Чтобы напечатать байт, нужно напечатать цифру, соответствующую его старшей и младшей половине (они называются nibble). Любой блок данных (по адресу в нетипизированном указателе void*
) — это массив байт; нужно только указать компилятору рассмотреть void*
как uint8_t*
. Очевидно, чтобы напечатать массив байт, нужно напечатать каждый байт в цикле.
Перевод байта в двоичную запись можно делать целиком, дробить байт нет смысла. Печать массива байт в двоичном виде по сути не отличается от печати их в шестнадцатеричной системе счисления.
Итак, элементарные задачи:
void*
в uint8_t*
.size
элементов массива по адресу в uint8_t*
(hex).size
элементов массива по адресу в uint8_t*
(binary).Напишем вспомогательную функцию, которая будет представлять значение от 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);
Задача сводится к тому, чтобы из восьми бит четыре младших оставить такими, как есть, а четыре старших обнулить. Типовое решение — наложить битовую маску. Битовая маска следующая: 0b00001111
, или 0x0f
, — в ней единицы стоят в тех позициях, биты в которых нужно извлечь. Логическое «И» (&
) бита x
с нулем дает 0, а с единицей - x
, то есть byte & mask
даст искомый младший nibble. Решение для математиков — взять остаток от деления байта на 32 (0b00010000
).
Можно разбить эту задачу еще на две: выделение старших разрядов байта и их перемещение (сдвиг) на позиции младших разрядов. Какая маска подойдет для выделения? Очевидно, 0b11110000
(0xf0
). Сдвиг вправо на 4 разряда делается оператором >>
: byte >> 4
. По стандарту C++, старшие биты результата будут равны 0, поэтому на самом деле выделять старшие биты не нужно. Сдвиг вправо на 4 позиции математически равносилен делению на 2⁴, но при работе с битами сдвиг лучше выражает суть дела.
Запишем в коде все предыдущие рассуждения:
void
print_in_hex(uint8_t byte) {
cout << nibble_to_hex(byte >> 4)
<< nibble_to_hex(byte & 0xf);
}
Для самопроверки следует попробовать напечатать байты 0x0
, 0xab
, 0xff
.
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';
Известен способ перевода в двоичную систему путем взятия остатков от деления на два, однако, порядок остатком получается обратным порядку бит. В программе проще проверять биты, начиная со старшего, и печатать 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, shift);
}
}
Самопроверка: перевести в двоичное представление и напечатать числа из лекционного слайда про двоичные операции (исходные два числа и результаты всех действий).
Очевидно, что приведение типов не отличается от случая для шестнадцатеричной системы. Напишем и проверим по аналогии с 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
. Этот байт должен стоять первым при печати целого числа любой длины, а за ним — байты с нулями.
В отчет нужно занести код и результаты в отчет в виде текста.
Вопрос: почему 1025 (0b00000100'00000001
, 0x0401
) представлено байтами 01 04
, а не наоборот?
Ответ: потому что на x86 (Intel) порядок байт от младшего к старшему (little-endian), то есть младший байт в памяти расположен первым.
Комментарий к отчету по данному пункту ЛР.
Необходим вывод программы — все, что нужно напечатать по заданию.
В распечатанном массиве байт, которые занимает массив структур, нужно отметить, какие байты чему соответствуют (элементам массива, полям структуры).
Необходима готовность отвечать (пользуясь отчетом), что представляет собой тот или иной участок распечатанного блока памяти, и почему его содержимое именно таково (кроме действительных чисел).
Вместо пошагового выполнения ЛР рассмотрим решение двух типовых задач: ввода и обработки строки C функциями стандартной библиотеки и загрузки текста из файла в строку C. Задание на ЛР представляет собой их комбинацию.
Решим задачу: считать строку C и напечатать по отдельности слов в ней (слова разделены пробелами и знаками препинания).
Для определенности предположим, что длина строки не превышает некоторой заранее заданной, например, 255 символов. С учетом завершающего '\0'
под строку нужно 256 символов:
const size_t MAX_SIZE = 256;
char text[MAX_SIZE];
Ввести с строку C можно функцией fgets()
. Ознакомимся с документацией по ссылке. В документации обычно есть и примеры использования описываемых функций.
Прототип функции:
char* fgets(char* str, int count, std::FILE* stream);
Над прототипом написано: Defined in header <cstdio>
— это значит, что для использования функции нужно включить заголовочный файл <cstdio>
.
Под прототипом написано, что делает данная функция: считывает не более count - 1
символов и записывает их в массив, на который указывает str
; чтение ведется из файлового потока stream
.
Нам необходимо считывать строку со стандартного ввода, где взять файловый поток для него? В справочнике std::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>
. Из обширного списка наиболее подходящими для задачи выглядят описания:
strspn()
— определяет, сколько первых символов строки подряд относятся к множеству, заданному другой строкой;
strсspn()
— определяет, сколько первых символов строки подряд не относятся к множеству, заданному другой строкой (например, сколько символов с начала строки — не разделители слов);
Алгоритм решения:
Определить, сколько разделителей находятся в начале строки — strspn()
.
Пропустить их (сместить указатель на начало строки).
Если достигнут конец строки (начальный символ — '\0'
), закончить работу.
Найти первый разделитель от нового начала строки (или слова), то есть длину слова — strcspn()
.
Напечатать часть строки от начала слова до разделителя (это можно сделать методом cout.write()
или функцией fwrite()
). Также напечатать символ перевода строки.
Сдвинуть начало строки вперед на длину слова.
Перейти к пункту 1.
Почти каждый шаг алгоритма — всего одна строка или конструкция. Начало строки (то есть еще не разобранной части) будем хранить в переменной:
const char* start = text;
Алгоритм представляет собой цикл:
while (true) {
Определить, сколько разделителей находятся в начале строки:
const size_t separator_count = strspn(start, separators);
Пропустить их:
start += separator_count;
Если достигнут конец строки, закончить работу.
if (start[0] == '\0') {
break;
}
Найти первый разделитель от нового начала строки:
const size_t word_length = strcspn(start, separators);
Напечатать часть строки от начала слова до разделителя:
cout.write(start, word_length);
Также напечатать символ перевода строки:
cout << '\n';
Сдвинуть начало строки вперед на длину слова:
start += word_length;
}
Соединив участки кода в полноценную программу, можно убедиться, что она работает правильно:
echo "News,from beyond the Narrow Sea. Haven't you heard?" | lab04.exe
Вывод:
News
from
beyond
the
Narrow
Sea
Haven't
you
heard
Козлюк Д. А. для кафедры Управления и информатики НИУ «МЭИ», 2017 г.