Введение
Дисциплина «Разработка кроссплатформенных программных систем» преподаётся студентам 4-го курса Института информационных технологий, обучающимся по направлению подготовки 09.03.04 «Программная инженерия».
Эта дисциплина предназначена для студентов, знакомых с основными алгоритмами и структурами данных, владеющих языками программирования Python, C/C++ и Java, способных разрабатывать программное обеспечение (ПО), пригодное для практического использования.
В пособии рассматриваются вопросы кроссплатформенной адаптации ПО на языке C, который широко используется в области системного программирования и встраиваемых систем, а также практические задачи, программное решение которых демонстрировалось на лекционных и семинарских занятиях в РТУ МИРЭА в 2025-2026 учебном году. Изложение имеет практическую направленность и отличается фундаментальным подходом к выбору материала.
В частности, приводится разбор подходов к написанию переносимого кода для платформ с разным порядком байт, различающимся размером и выравниванием данных одного и того же типа. Кроме того, рассматриваются вопросы кроссплатформенной адаптации приложений на языке C, работающих с графикой, для ОС Windows, ОС Linux и для веб-браузеров, а также особенности построения архитектуры программных систем с графическим пользовательским интерфейсом (Graphical User Interface, GUI) на основе переносимого и платформозависимого модулей, взаимодействующих посредством программного интерфейса (Application Programming Interface, API), представляющего собой промежуточный слой абстракции от платформы. В конце каждого раздела пособия приведен ряд задач для самостоятельного выполнения студентами.
В книге также приводится разбор двух типов задач для итоговой проверки знаний студентов, приведены дополнительные варианты задач каждого типа для самостоятельного выполнения студентами. В РТУ МИРЭА варианты этих задач генерируются автоматически для каждого студента.
1.1. Реализация функции для просмотра значений байт
1.1. Реализация функции для просмотра значений байт
1.1.1. Переносимые типы данных
Платформа – окружение, в котором выполняется ПО. Примеры платформ включают настольные и мобильные платформы, веб-платформы, платформы встраиваемых систем. Различают программные и аппаратные платформы [1].
Переносимое ПО (portable software) – ПО, перенос которого на новую платформу стоит значительно дешевле, чем его повторная разработка для новой платформы.
Кроссплатформенное ПО (cross-platform software) – ПО, предназначенное для работы на нескольких, заранее заданных платформах.
Перенос или портирование ПО (porting software) – адаптация ПО для новой платформы.
Язык C широко используется в области системного программирования и встраиваемых систем. На C написано, в частности, следующее переносимое и кроссплатформенное ПО:
- UNIX-подобные операционные системы [2].
- Система контроля версий (СКВ) git [3].
- Интерпретаторы языков Python и Lua.
- Веб-сервер Nginx.
- СУБД SQLite, Redis и Tarantool.
- Классические игры Doom и Quake.
Среди основных источников аппаратной непереносимости можно выделить следующие:
- Несовместимые наборы команд.
- Различный объем памяти.
- Различный порядок байт при хранении чисел.
- Различные варианты выравнивания данных.
- Различные возможности периферийных устройств.
Переносимость ПО на языке C достигается за счёт следования стандартам при использовании языка программирования, библиотек и инструментального ПО, а также за счёт использования переносимых библиотек. Кроссплатформенность ПО на языке C достигается за счёт использования компиляторов, способных транслировать программу на языке высокого уровня в низкоуровневые представления, совместимые с различными процессорными архитектурами и операционными системами.
В процессе отладки кроссплатформенных программных систем на языке C может возникнуть необходимость просмотра содержимого участков оперативной памяти, в которых размещены значения переменных. При этом на разных платформах может различаться как количество байт, необходимых для хранения значений переменных, так и порядок байт.
Рассмотрим следующую программу на языке C в качестве примера:
#include <stdio.h>
int main() {
long n = 258;
printf("%ld\n", n);
printf("%ld\n", sizeof(n));
return 0;
}
В приведённой программе сначала инициализируется переменная n со значением 258, имеющая тип long, после чего на экран последовательно выводятся 2 строки – значение переменной n и число байт, используемых для хранения значения инициализированной переменной. Число байт при этом вычисляется на этапе компиляции при помощи оператора sizeof. Вывод на экран осуществляется при помощи POSIX-функции printf [4], для использования которой необходимо подключить стандартный заголовочный файл stdio.h.
При компиляции программы на языке C в целевое низкоуровневое представление, как правило, не включается информация о типах – эта информация предназначена, главным образом, для компилятора, генерирующего эффективный код для целевой платформы. Вследствие этого при вызове функции printf необходимо явно указать формат вывода значения переменной. Для вывода значений переменных типа int может использоваться формат %d, а для вывода значений переменных типа long используется формат %ld [4]. В отличие от функции print в языке Python, функция printf в языке C по умолчанию не добавляет в конец выводимой строки символ переноса строки на новую, и вследствие этого символ \n необходимо указывать явно.
Результат работы приведённой программы, скомпилированной для устройств под управлением ОС Windows архитектуры x86-64, будет отличаться от результата работы той же программы, скомпилированной для ОС Linux x86-64.
Для проверки работы программы на ОС Windows воспользуемся компилятором gcc, включённым в набор инструментов для разработки Minimalist GNU for Windows (MinGW) [5]. Перед началом работы необходимо установить MinGW и добавить путь к папке с компилятором gcc в переменную окружения PATH.
Установив MinGW [5], поместим программу в файл program.c, скомпилируем программу и запустим полученный исполняемый файл program.exe. Для этого воспользуемся следующими командами в PowerShell-терминале [6]:
PS C:\> gcc --version
gcc.exe (x86-64-posix-seh-rev0, Built by MinGW-Builds project) 14.1.0
PS C:\> gcc -std=c99 program.c -o program.exe
PS C:\> ./program.exe
258
4
Опция компилятора -std=c99 указывает на необходимость использования стандарта C99 при компиляции программы и отключает нестандартные расширения GNU – эта опция полезна для обеспечения переносимости кода. Опция -o позволяет задать имя файла, в который необходимо сохранить скомпилированную программу. Здесь и далее предполагается, что содержимое приведённых программ на языке C сохраняется в файл program.c перед запуском компилятора.
Для проверки работы программы на ОС Ubuntu Linux установим компилятор clang при помощи менеджера пакетов APT [7], скомпилируем программу и запустим исполняемый файл:
~$ clang --version
Ubuntu clang version 14.0.0-1ubuntu1.1
Target: x86-64-pc-linux-gnu
Thread model: posix
~$ clang -std=c99 program.c -o program.bin
~$ ./program.bin
258
8
Как видно из примера выше, число байт, занимаемых стандартными типами данных, такими как, например, long, может отличаться на разных платформах. Вследствие этого для обеспечения переносимости программ на языке C используют типы фиксированного размера из стандартного заголовочного файла stdint.h, такие как int32_t, uint32_t, int64_t и другие.
Заменим тип long в программе на тип int32_t:
#include <stdio.h>
#include <stdint.h>
int main() {
int32_t n = 258;
printf("%ld\n", n);
printf("%ld\n", sizeof(n));
return 0;
}
Исправленная версия программы отличается от её исходной версии одинаковым выводом на устройствах под управлением ОС Windows и ОС Ubuntu Linux архитектуры x86-64, поскольку переменная типа int32_t занимает 4 байта.
1.1.2. Представление целых чисел в памяти
Для просмотра содержимого байт, выделенных для хранения значения переменной n, получим адрес переменной n при помощи оператора & и присвоим значение адреса указателю np типа int32_t*. После этого при помощи оператора приведения типов (unsigned char*) преобразуем указатель на переменную типа int32_t к указателю на её первый байт bp. Выведем в консоль адреса указателей np и bp, а также значения по адресам указателей *np и *bp:
#include <stdio.h>
#include <stdint.h>
int main() {
int32_t n = 258;
int32_t* np = &n;
unsigned char *bp = (unsigned char*) np;
printf("%p %d\n", np, *np);
printf("%p %d\n", bp, *bp);
return 0;
}
Символ * в левой части присваивания при объявлении переменной обозначает, что переменная является указателем на значение того типа, имя которого размещено слева от символа *. В языке C по умолчанию отсутствует тип byte, однако по стандарту C99 [8] (см. пункт 6.5.3.4) размер переменной типа unsigned char составляет 1 байт. Вследствие этого в приведённой программе указатель bp на первый байт переменной n имеет тип unsigned char*.
Для получения значения переменной по её адресу используется оператор * – выражение *np позволяет получить значение типа int32_t, занимающее 4 байта, по указателю np, так как тип указателя np – int32_t*. Выражение *bp позволяет получить значение, занимающее 1 байт, по указателю bp, так как тип указателя bp – unsigned char*. При печати адресов указателей используется формат %p [4].
Проверим работу программы на ОС Ubuntu Linux x86-64:
~$ clang -std=c99 program.c -o program.bin
~$ ./program.bin
0x7fffd42438b4 258
0x7fffd42438b4 2
Из вывода программы следует, что указатели np и bp содержат один и тот же адрес, однако значения, полученные по адресам этих указателей при помощи оператора *, различаются из-за различий в типах указателей.
Поскольку адреса указателей – это числовые значения, для получения и вывода на экран значений всех байт числа n можно воспользоваться оператором + вместе с оператором доступа к значению, хранящемуся по адресу указателя *. Тот же результат можно получить, воспользовавшись оператором индексирования:
#include <stdio.h>
#include <stdint.h>
int main() {
int32_t n = 258;
int32_t *np = &n;
unsigned char *p = (unsigned char*) np;
printf("%d %d %d %d\n", *p, *(p + 1), *(p + 2), *(p + 3));
printf("%d %d %d %d\n", p[0], p[1], p[2], p[3]);
return 0;
}
Проверим работу программы:
~$ clang -std=c99 program.c -o program.bin
~$ ./program.bin
2 1 0 0
2 1 0 0
Полученный вывод обусловлен тем, что для хранения чисел, больших чем число 255, необходимо использовать несколько байт – в этой связи для представления числа используется позиционная система счисления по основанию 256. Из результата работы программы можно сделать вывод, что на платформе x86-64, для которой приведённая выше программа была скомпилирована, используется обратный порядок байт – порядок байт от младшего байта к старшему (little endian). На платформах с таким порядком байт младший байт, то есть тот байт, который содержит 8 младших бит, расположен первым – 4 байта переменной типа int32_t располагаются в оперативной памяти в следующем порядке: \(b_1, b_2, b_3, b_4\), где \(b_1\) содержит 8 младших бит, а \(b_4\) содержит 8 старших бит 32-битного целого числа [9].
Функция для преобразования последовательности байт в число для платформ с порядком байт от младшего к старшему может быть определена как:
| $$ f(b_1, b_2, ..., b_{n}) = \sum_{i=1}^{n} 256^{i-1} \cdot b_i = b_1 + 256 \cdot b_2 + ... + 256^{n-1} \cdot b_{n}, $$ | (1) |
где \(i\) – порядковый номер байта, \(b_i\) – значение байта с номером \(i\), причём \(b_i \in [0, 255]\), \(n\) – число байт, определяемое используемым для хранения числа типом данных и особенностями целевой платформы.
Исходное число 258 легко получить, подставив в функцию f (1) значения байт, выведенные ранее на экран:
| f(2, 1, 0, 0) = 2 + 256 ⋅ 1 = 258. | (2) |
На платформах, где используется прямой порядок байт – порядок байт от старшего к младшему (big endian), 4 байта целого числа располагались бы в порядке \(b_4, b_3, b_2, b_1\), , где \(b_1\) содержит 8 младших бит, а \(b_4\) содержит 8 старших бит 32-битного целого числа, причём в результате запуска программы на экран была бы выведена последовательность 0 0 1 2.
Сведения о порядке байт на некоторых из распространённых аппаратных платформ приведены в табл. 1.
|
Аппаратная платформа |
Порядок байт |
|---|---|
|
x86 и x86-64 (Intel/AMD) |
Обратный |
|
Apple Silicon |
Обратный |
|
IBM PowerPC |
Прямой |
|
Sun SPARC |
Прямой |
|
Эльбрус 2000 (E2K) |
Обратный |
|
TCP/IP |
Прямой |
1.1.3. Функция для вывода на экран байт по указанному адресу
Во избежание дублирования кода при печати байт со значениями переменных в консоль реализуем функцию print_data, принимающую на вход указатель на область памяти со значениями байт переменной, а также число байт, необходимых для хранения значения переменной, вычисленное на этапе компиляции по её типу при помощи оператора sizeof.
Функция print_data принимает на вход указатель, имеющий тип void*, который затем преобразуется к указателю на первый байт значения переменной unsigned char*, после чего значения байт выводятся на экран в цикле. Проверим результат работы функции на переменных со значениями 255, 256, 257:
#include <stdio.h>
#include <stdint.h>
void print_data(void *pointer, int count) {
unsigned char *bytes = (unsigned char*) pointer;
for (int i = 0; i < count; i += 1) {
printf("0x%02x ", bytes[i]);
}
printf("\n");
}
int main() {
int16_t a = 255;
int32_t b = 256;
int64_t c = 257;
print_data(&a, sizeof(a));
print_data(&b, sizeof(b));
print_data(&c, sizeof(c));
return 0;
}
Для вывода значений байт на экран в функции print_data используется формат %02x – вследствие этого вывод осуществляется в шестнадцатеричной системе счисления, причём в случае, если для печати числа достаточно одного символа, перед этим символом будет выведен символ 0. Использование типа void* позволяет передавать на вход функции print_data указатель на данные любого типа без необходимости явного использования оператора приведения типов.
Проверим работу программы:
~$ clang -std=c99 program.c -o program.bin
~$ ./program.bin
0xff 0x00
0x00 0x01 0x00 0x00
0x01 0x01 0x00 0x00 0x00 0x00 0x00 0x00
1.1.4. Особенности выравнивания данных
Выравнивание для типа данных размером n означает, что данные должны располагаться по адресу, кратному числу n. Нарушение этого ограничения может привести к потере быстродействия и, в некоторых архитектурах, к ошибке выполнения программы. Так, например, для выполнения SIMD-инструкций (Single Instruction-Multiple Data) из набора SSE (Streaming SIMD Extensions), доступных на процессорах с архитектурой x86 и x86-64, требуется выравнивание на границу 16 байт.
При выводе на экран при помощи функции print_data значений байт структуры, содержащей поля с данными, выравнивание которых отличается друг от друга, между значениями байт полей будут содержаться значения байт, не относящиеся к полям структуры. Определим структуру, содержащую 3 поля с данными типов с отличающимся выравниванием – такими типами на платформе ОС Ubuntu Linux x86-64 являются, например, типы char, short и double:
struct data {
char c;
short s;
double d;
};
После этого воспользуемся реализованной ранее функцией print_data для вывода значений байт структуры на экран. Дополнительно выведем на экран размер структуры и размеры данных типа char, short и double:
#include <stdio.h>
int main() {
struct data s = {.c=1, .s=257, .d=0.5};
print_data(&s, sizeof(s));
printf("%lld ", sizeof(struct data));
printf("%lld ", sizeof(char));
printf("%lld ", sizeof(short));
printf("%lld ", sizeof(double));
return 0;
}
В функции main сначала объявляется и инициализируется структура data, после чего адрес структуры &s и её размер, вычисленный при помощи оператора sizeof, передаётся на вход функции print_data, печатающей значения байт структуры на экран. Оператор sizeof при компиляции для ОС Ubuntu Linux x86-64 возвращает значение типа unsigned long long, для вывода на экран значений этого типа используется формат %lld [4].
Запустим полученную программу:
~$ clang -std=c99 program.c -o program.bin
~$ ./program.bin
0x01 0x00 0x01 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0xe0 0x3f
16 1 2 8
Значения типа char, short и double занимают в памяти 1, 2 и 8 байт соответственно, однако для хранения структуры data было выделено 16 байт вместо 11 байт. Это связано с тем, что данные типа short выравниваются компилятором на границу 2 байта, а данные типа double выравниваются компилятором на границу 8 байт для повышения быстродействия программы на устройствах с архитектурой x86-64.
Выравнивание данных может быть отключено пользователем при помощи атрибута packed, которым помечается структура:
struct __attribute__((packed)) data {
char c;
short s;
double d;
};
Однако, на некоторых платформах отключение выравнивания полей структуры может привести к непредсказуемому поведению программы и ошибкам во время её выполнения.
Проверим работу программы со структурой, помеченной атрибутом packed, на ОС Ubuntu Linux x86-64:
~$ clang -std=c99 program.c -o program.bin
~$ ./program.bin
0x01 0x01 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0xe0 0x3f
11 1 2 8
1.1.5. Упражнения
Задача 1. Реализуйте функцию is_little_endian для определения порядка байт в системе.
Задача 2. Следующая программа ведёт себя по-разному в Linux и Windows:
#include <stdio.h>
int arr[] = { 0xa, 1, 2, 3, 4, 5, 6, 7 };
#define ARR_SIZE (sizeof(arr) / sizeof(int))
void save_to_file(char *filename, int *arr, int size) {
FILE *fp = fopen(filename, "w");
fwrite(arr, sizeof(int), size, fp);
fclose(fp);
}
void load_from_file(char *filename, int *arr, int size) {
FILE *fp = fopen(filename, "rb");
fread(arr, sizeof(int), size, fp);
fclose(fp);
}
void print_array(int *arr, int size) {
for (int i = 0; i < size; i += 1) {
printf("%08x\n", arr[i]);
}
}
int main(int argc, char** argv) {
int new_arr[ARR_SIZE];
save_to_file("data.bin", arr, ARR_SIZE);
load_from_file("data.bin", new_arr, ARR_SIZE);
print_array(new_arr, ARR_SIZE);
return 0;
}
Вывод в Windows:
00000a0d
00000100
00000200
00000300
00000400
00000500
00000600
00000700
Вывод в Linux:
0000000a
00000001
00000002
00000003
00000004
00000005
00000006
00000007
Найдите и исправьте ошибку. Перепишите функции load_from_file и save_to_file таким образом, чтобы файлом с данными можно было пользоваться на платформах с разным порядком байт.
Задача 3. Напишите программу, которая выведет на экран для конкретной структуры последовательность смещений ее полей. Анализируемая структура является частью программы, но способ вывода смещений полей должен легко адаптироваться для других структур. Например, для следующей структуры:
struct data {
char c;
short s;
double d;
};
На экран должны быть выведены следующие значения смещений полей:
c: 0
s: 2
d: 8
Получите без изменения исходного текста программы другие значения смещений полей при выводе.
Задача 4. Реализуйте простейший аналог команды ls с использованием POSIX-функций [4]. Добавьте к реализации ls обработку ключа -l. Используйте POSIX-функцию getopt.
1.2. Кроссплатформенная графика на языке C
1.2. Кроссплатформенная графика на языке C
1.2.1. Работа с графикой на SDL3 в ОС Windows
SDL3 (Simple DirectMedia Layer) [10] – кроссплатформенная библиотека на языке C для разработки переносимых игр и других мультимедийных приложений.
В SDL3 поддерживаются следующие периферийные устройства: звуковая карта; клавиатура; мышь; джойстик; видеокарта с OpenGL и Direct3D.
Поддерживаемые библиотекой SDL3 платформы включают: Windows, macOS, Linux, iOS, Android.
Рассмотрим реализацию кроссплатформенного приложения с поддержкой вывода графики на SDL3. Изобразим с помощью программы на SDL3 картину «Чёрный квадрат» с белыми полями.
В этом разделе предполагается, что отладка и тестирование программы на языке C осуществляется на устройстве с архитектурой x86-64 под управлением ОС Windows. Для использования библиотеки SDL3 в ОС Windows x86-64 с компилятором gcc, включённым в набор инструментов для разработки MinGW [5], необходимо сохранить архив SDL3-devel-3.2.24-mingw.zip [10] на устройство пользователя и распаковать его, например, в корень диска C. После распаковки архива будет создана папка SDL3-devel-3.2.24-mingw.
Создадим файл program.c со следующим содержимым:
#define SDL_MAIN_USE_CALLBACKS 1
#include <stdio.h>
#include "SDL3/SDL.h"
#include "SDL3/SDL_main.h"
SDL_AppResult SDL_AppInit(void **appstate, int argc, char **argv) {
SDL_Init(SDL_INIT_VIDEO);
SDL_CreateWindow("App", 500, 500, SDL_WINDOW_OPENGL);
return SDL_APP_CONTINUE;
}
SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event) {
if (event->type == SDL_EVENT_QUIT)
return SDL_APP_SUCCESS;
return SDL_APP_CONTINUE;
}
SDL_AppResult SDL_AppIterate(void *appstate) { return SDL_APP_CONTINUE; }
void SDL_AppQuit(void *appstate, SDL_AppResult result) {}
Определённая в начале файла program.c константа SDL_MAIN_USE_CALLBACKS указывает SDL3 на необходимость использования функций SDL_AppInit, SDL_AppEvent, SDL_AppIterate и SDL_AppQuit, которые являются частью нового API для упрощения создания программ на SDL3.
Этот API избавляет разработчика от необходимости вручную создавать основной цикл SDL3 и функцию main, причём:
SDL_AppInitвызывается однократно при запуске приложения.SDL_AppEventвызывается каждый раз при возникновении нового события.SDL_AppIterateиспользуется для обновления графики.SDL_AppQuitвызывается однократно перед завершением работы SDL3.
Воспользуемся следующими командами в терминале PowerShell для копирования dll-файла библиотеки SDL3 в текущую директорию, сборки файла program.c с библиотекой SDL3, запуска скомпилированной программы:
PS C:\> cp C:\SDL3-3.2.22\x86_64-w64-mingw32\bin\SDL3.dll .
PS C:\> gcc -std=c99 program.c -o program.exe -IC:\SDL3-3.2.22\x86_64-w64-mingw32\include -LC:\SDL3-3.2.22\x86_64-w64-mingw32\lib -lmingw32 -lSDL3
PS C:\> .\program.exe
Опция -I позволяет указать путь к заголовочным файлам SDL3, -L позволяет указать путь к библиотечным файлам SDL3 с расширением .a, опции -lmingw32 и -lSDL3 связывают компилируемую программу с MinGW [5] и с библиотекой SDL3 [10].
После запуска скомпилированной программы program.exe откроется пустое окно с чёрным фоном (см. рис. 1), созданное функцией SDL_CreateWindow, вызванной из SDL_AppInit.
Поскольку в функции SDL_AppEvent обработано событие SDL_EVENT_QUIT, при этом в обработчике события SDL_EVENT_QUIT из функции возвращается код успешного завершения работы программы SDL_APP_SUCCESS, открывшееся окно удастся закрыть нажатием на соответствующую кнопку.
Для вывода на экран чёрного квадрата с белыми полями требуется создать отрисовщик SDL_Renderer. В SDL3 реализована система двойной буферизации: все графические операции выполняются со скрытым буфером, а на экран выводятся только полностью готовые кадры при вызове функции SDL_RenderPresent. Этот подход устраняет мерцание, так как во время подготовки нового кадра пользователь продолжает видеть предыдущий кадр.
Обновим реализацию функции SDL_AppInit – при помощи вызова функции SDL_CreateRenderer создадим новый отрисовщик для окна при запуске программы, сохраним отрисовщик в глобальную переменную renderer в файле program.c:
SDL_Renderer* renderer;
SDL_AppResult SDL_AppInit(void **appstate, int argc, char **argv) {
SDL_Init(SDL_INIT_VIDEO);
SDL_Window* window = SDL_CreateWindow("SDL", 500, 500, SDL_WINDOW_OPENGL);
renderer = SDL_CreateRenderer(window, NULL);
return SDL_APP_CONTINUE;
}
Для визуализации чёрного квадрата с белыми полями нарисуем белый прямоугольник, ширина и высота которого совпадают с шириной и высотой окна, после чего нарисуем квадрат в середине окна. Точка с координатами (0, 0) расположена в левом верхнем углу окна, поэтому для отрисовки чёрного квадрата со стороной 200 точек внутри квадратного окна со стороной 500 точек установим значения координат левого верхнего угла чёрного квадрата x и y равными 150.
В функции SDL_AppIterate в файле program.c зададим цвет отрисовки фона при помощи вызова функции SDL_SetRenderDrawColor, передав в SDL_SetRenderDrawColor 4 компоненты белого цвета в формате RGBA (Red, Green, Blue, Alpha). Затем инициализируем структуру SDL_FRect с описанием прямоугольника размером во всё окно и выполним его заливку посредством вызова функции SDL_RenderFillRect, для заливки прямоугольника будет использован белый цвет, заданный ранее при помощи функции SDL_SetRenderDrawColor.
Аналогичным образом выполним отрисовку чёрного квадрата в середине окна. По завершении выполнения графических операций вызовем функцию SDL_RenderPresent, которая выполнит переключение буферов – подготовленный кадр станет виден на экране:
SDL_AppResult SDL_AppIterate(void *appstate) {
SDL_SetRenderDrawColor(renderer, 255, 255, 255, 255);
SDL_FRect back = {.x = 0, .y = 0, .w = 500, .h = 500};
SDL_RenderFillRect(renderer, &back);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0);
SDL_FRect rect = {.x = 150, .y = 150, .w = 200, .h = 200};
SDL_RenderFillRect(renderer, &rect);
SDL_RenderPresent(renderer);
return SDL_APP_CONTINUE;
}
Скомпилируем и запустим обновлённую программу на SDL3:
PS C:\> gcc -std=c99 program.c -o program.exe -IC:\SDL3-3.2.22\x86_64-w64-mingw32\include -LC:\SDL3-3.2.22\x86_64-w64-mingw32\lib -lmingw32 -lSDL3
PS C:\> .\program.exe
В результате выполнения указанных команд откроется окно, в середине которого изображён чёрный квадрат с белыми полями, показанное на рис. 2.
1.2.2. Адаптация SDL3-графики для веб-браузера
Кроссплатформенное графическое приложение, написанное на языке C с использованием библиотеки SDL3 [10], несложно портировать и в веб-браузер. Для этого воспользуемся компилятором Emscripten [11].
Первая версия компилятора Emscripten поддерживала трансляцию промежуточного представления LLVM (Low Level Virtual Machine) в код на языке JavaScript для веб-браузеров [12]. Промежуточное представление LLVM может быть получено в результате компиляции программы на языке C с использованием, например, компилятора clang. Однако, в дальнем компилятор Emscripten был доработан для поддержки трансляции кода на языке C в WebAssembly (Wasm) [11]. Известные применения Emscripten включают, например, использование emcc для запуска многопользовательских SDL-игр в веб-браузерах.
Для портирования программы на SDL3 в веб-браузер установим Emscripten версии 4.0.8:
PS C:\> git clone https://github.com/emscripten-core/emsdk.git
PS C:\> cd emsdk
PS C:\emsdk> git checkout 419021fa040428bc69ef1559b325addb8e10211f
HEAD is now at 419021f Release 4.0.8 (#1556)
PS C:\emsdk> .\emsdk.ps1 install latest
PS C:\emsdk> .\emsdk.ps1 activate latest
Resolving SDK alias 'latest' to '4.0.8'
Resolving SDK version '4.0.8' to 'sdk-releases-56f86607aeb458086e72f23188789be2ee0e971a-64bit'
Setting the following tools as active:
node-20.18.0-64bit
python-3.9.2-nuget-64bit
releases-56f86607aeb458086e72f23188789be2ee0e971a-64bit
PS C:\emsdk> emcc --version
emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 4.0.8
Copyright (C) 2025 the Emscripten authors (see AUTHORS.txt)
This is free and open source software under the MIT license.
После установки соберём при помощи компилятора emcc реализованное ранее графическое приложение на SDL3. В результате сборки в рабочей директории будет создан файл index.html и ряд вспомогательных файлов:
PS C:\> emcc gui.c -o index.html -s USE_SDL=3
ports:INFO: retrieving port: sdl3 from https://github.com/libsdl-org/SDL/archive/release-3.2.4.zip
ports:INFO: unpacking port: sdl3
cache:INFO: generating port: sysroot\lib\wasm32-emscripten\libSDL3.a...
emcc: warning: sdl3 port is still experimental [-Wexperimental]
system_libs:INFO: compiled 174 inputs in 31.26s
cache:INFO: - ok
Опция -o компилятора emcc позволяет задать имя HTML-файла с результатом сборки, а значение USE_SDL=3 опции -s указывает Emscripten на необходимость подключения к компилируемому приложению библиотеки SDL3 [10].
Для корректного функционирования веб-приложения в браузере необходимо обеспечить раздачу файлов веб-приложения через HTTP-сервер, поскольку корректно открыть полученный на предыдущем шаге HTML-файл по протоколу file:// не удастся из-за ограничений безопасности браузеров. В этой связи запустим стандартный веб-сервер на языке Python, используя порт 8000:
PS C:\> python -m http.server 8000
Serving HTTP on :: port 8000 (http://[::]:8000/) ...
Открыв в веб-браузере страницу по адресу http://127.0.0.1:8000 увидим чёрный квадрат и логотип проекта Emscripten (см. рис. 3).
1.2.3. Упражнения
Задача 1. Изобразите на SDL3 вариации на работу «Композиция А» художника П. Мондриана (см. рис. 4). Каждый запуск программы должен приводить к выводу на экран новой вариации, отличающейся размерами, количеством и цветом раскрашенных прямоугольников.
Задача 2. Реализуйте средствами SDL3 клеточный автомат, имитирующий снегопад. Пример одного из кадров анимации снегопада показан на рис. 5.
Задача 3. Реализуйте элементы графического интерфейса с помощью SDL3. Реализуйте миниатюрную библиотеку графического интерфейса для SDL3 по методу IMGUI [13].
1.3. Сборка игры Doom
1.3. Сборка игры Doom
1.3.1. Сборка Doom для ОС Linux с GUI
Игра Doom, выпущенная в 1993 году, написана на языке C. Разработка игры Doom велась одновременно на двух различных платформах [14], таких как:
- Рабочая станция NeXT Computer под управлением варианта ОС NeXTSTEP с компилятором
gcc. - Персональный компьютер с ОС MS-DOS и компилятором
Watcom.
Эти платформы имели ряд существенных различий, включая разный порядок байт. В дальнейшем Doom был портирован на множество самых разных платформ, причём для обеспечения переносимости программного кода в Doom было выделено переносимое ядро и платформозависимые компоненты, как показано на рис. 6.
Платформозависимые компоненты, выделенные серым цветом на рис. 6, подключались к проекту в виде файлов с расширением .c. Компоненты переносимого ядра, выделенные белым цветом на рис. 6, использовались на всех поддерживаемых Doom платформах [14].
Рассмотрим процесс сборки переносимой версии игры Doom doomgeneric [15], отличающейся от исходной версии улучшенной переносимостью.
В этом разделе предполагается, что сборка проекта осуществляется на устройстве с архитектурой x86-64 под управлением ОС Ubuntu Linux.
При помощи СКВ git сохраним репозиторий с кодом doomgeneric на устройство с ОС Ubuntu Linux x86-64:
~$ git clone https://github.com/ozkl/doomgeneric.git
~$ cd doomgeneric/doomgeneric
~/doomgeneric/doomgeneric$ ls
am_map.c
am_map.h
config.h
...
Сборка проекта doomgeneric из его исходного кода осуществляется при помощи системы сборки GNU Make [7], сценарии сборки для поддерживаемых платформ описаны в файлах, имена которых начинаются со строки Makefile.
При помощи утилиты grep [2] отфильтруем выведенные в стандартный вывод утилитой ls имена файлов, оставим в выводе только файлы со сценариями сборки, имена которых включают подстроку Makefile. Стандартный вывод утилиты ls попадает в стандартный ввод утилиты grep за счёт использования оператора конвейера |, доступного на Unix-подобных ОС [7]:
~/doomgeneric/doomgeneric$ ls | grep Makefile
Makefile
Makefile.djgpp
Makefile.emscripten
Makefile.freebsd
Makefile.linuxvt
Makefile.sdl
Makefile.soso
Makefile.sosox
При помощи GNU Make [7] скомпилируем проект, воспользовавшись файлом Makefile со сценарием сборки по умолчанию, выполняющим компиляцию Doom с реализациями платформозависимых функций для Linux, приведёнными в файле doomgeneric_xlib.c:
~/doomgeneric/doomgeneric$ make -f Makefile
[Compiling dummy.c]
[Compiling am_map.c]
[Compiling doomdef.c]
...
По завершении компиляции в рабочей директории будет создан исполняемый файл doomgeneric. Для проверки работы Doom воспользуемся файлом с игровыми данными doom1.wad [16], запустим исполняемый файл doomgeneric, передав путь к файлу с данными игры в качестве CLI-параметра:
~/doomgeneric/doomgeneric$ ./doomgeneric doom1.wad
После запуска игры, скомпилированной с настройками по умолчанию, откроется окно с экраном заставки, показанное на рис. 7, экран заставки затем сменится приветственной анимацией.
1.3.2. Сборка Doom для ОС Windows с GUI
В этом разделе предполагается, что сборка проекта doomgeneric выполняется на устройстве с архитектурой x86-64 под управлением ОС Windows.
Для сборки переносимой версии игры Doom doomgeneric с GUI на ОС Windows воспользуемся компилятором gcc, включённым в набор инструментов для разработки MinGW [5]. Перед началом работы необходимо установить MinGW и добавить путь к папке с компилятором gcc в переменную окружения PATH, а также установить СКВ git.
В терминале PowerShell [6] при помощи СКВ git сохраним репозиторий с кодом проекта doomgeneric на устройство под управлением ОС Windows x86-64:
PS C:\> git clone https://github.com/ozkl/doomgeneric.git
PS C:\> cd doomgeneric\doomgeneric
PS C:\doomgeneric\doomgeneric> ls
Length Name
------ ----
27807 am_map.c
1243 am_map.h
2801 config.h
...
Сценарии сборки в файлах, имена которых начинаются с подстроки Makefile, адаптированы для ОС Linux. В связи с этим сборку Doom на ОС Windows выполним при помощи команды компилятора gcc, сформированной вручную на основе содержимого Makefile.
Перечень файлов с кодом на языке C для компиляции кроссплатформенного ядра Doom получим из Makefile. Дополнительно подключим файл doomgeneric_win.c, содержащий реализации платформозависимых функций для ОС Windows – сборку проекта с doomgeneric_win.c необходимо осуществлять с опцией -lgdi32, которая указывает компоновщику на необходимость подключения библиотеки GDI32 (Graphics Device Interface).
Сформируем сценарий сборки build.ps1, содержащий команду для компиляции переносимой версии Doom, на основе содержимого Makefile при помощи набора PowerShell-команд, объединённых оператором конвейера:
PS C:\doomgeneric\doomgeneric> Get-Content Makefile | Where-Object { $_ -match 'SRC_DOOM = *' } | ForEach-Object { $_ -replace 'SRC_DOOM = ', '' -replace '\.o', '.c' -replace 'doomgeneric_xlib.c', 'doomgeneric_win.c' } | ForEach-Object { "gcc -o doomgeneric.exe $_ -lgdi32" } | Out-File -FilePath build.ps1
PowerShell-команда Get-Content построчно читает содержимое файла с именем Makefile и передаёт прочитанные строки как объекты платформы .NET на вход команде Where-Object, для этого используется оператор конвейера |. В отличие от конвейера на Unix-подобных ОС [7], PowerShell-конвейер на ОС Windows передаёт между командами объекты платформы .NET со свойствами и методами вместо обычного текста [6].
Команда Where-Object оставляет в коллекции .NET-объектов на выходе только те строки, которые соответствуют выражению SRC_DOOM = *, где символ * соответствует произвольной последовательности символов. Выражение $_ позволяет получать доступ к очередному объекту на каждой итерации цикла.
После этого команда ForEach-Object заменяет в полученной строке $_ префикс SRC_DOOM = на пустую строку, заменяет расширения файлов .o на .c, заменяет имя файла с платформозависимыми функциями doomgeneric_xlib.c на doomgeneric_win.c. Сформированная в результате замен строка, содержащая перечень файлов для компиляции на ОС Windows, подставляется в строку с вызовом компилятора gcc с опцией -lgdi32, результат подстановки сохраняется в файл build.ps1 при помощи команды Out-File [6]. В последней команде ForEach-Object используются двойные кавычки для раскрытия значения переменной $_.
Выведем содержимое файла build.ps1 на экран:
PS C:\doomgeneric\doomgeneric> Get-Content build.ps1
gcc -o doomgeneric.exe dummy.c am_map.c doomdef.c doomstat.c dstrings.c d_event.c d_items.c d_iwad.c d_loop.c d_main.c d_mode.c d_net.c f_finale.c f_wipe.c g_game.c hu_lib.c hu_stuff.c info.c i_cdmus.c i_endoom.c i_joystick.c i_scale.c i_sound.c i_system.c i_timer.c memio.c m_argv.c m_bbox.c m_cheat.c m_config.c m_controls.c m_fixed.c m_menu.c m_misc.c m_random.c p_ceilng.c p_doors.c p_enemy.c p_floor.c p_inter.c p_lights.c p_map.c p_maputl.c p_mobj.c p_plats.c p_pspr.c p_saveg.c p_setup.c p_sight.c p_spec.c p_switch.c p_telept.c p_tick.c p_user.c r_bsp.c r_data.c r_draw.c r_main.c r_plane.c r_segs.c r_sky.c r_things.c sha1.c sounds.c statdump.c st_lib.c st_stuff.c s_sound.c tables.c v_video.c wi_stuff.c w_checksum.c w_file.c w_main.c w_wad.c z_zone.c w_file_stdc.c i_input.c i_video.c doomgeneric.c doomgeneric_win.c -lgdi32
Скомпилируем и запустим Doom на ОС Windows, а для проверки работы Doom воспользуемся файлом с игровыми данными doom1.wad [16]:
PS C:\doomgeneric\doomgeneric> .\build.ps1
PS C:\doomgeneric\doomgeneric> .\doomgeneric.exe doom1.wad
После выполнения команд на ОС Windows откроется окно с GUI игры Doom, показанное на рис. 8.
1.3.3. Сборка Doom для веб-браузера с GUI
В этом разделе предполагается, что сборка проекта doomgeneric выполняется на устройстве с архитектурой x86-64 под управлением ОС Windows. Переместимся на ОС Windows x86-64 в загруженный ранее репозиторий с кодом переносимой версии игры Doom doomgeneric [15], в папку doomgeneric.
Репозиторий переносимой версии Doom doomgeneric, помимо версии Doom для ОС Linux doomgeneric_xlib.c и версии Doom для ОС Windows doomgeneric_win.c, содержит кроссплатформенную реализацию вывода графики и звука на основе библиотеки SDL2 [17] – 2-й версии библиотеки SDL. Код реализации SDL-компонентов Doom приведён в файле doomgeneric_sdl.c.
Кроссплатформенное приложение с GUI, написанное на SDL2, легко адаптировать для работы в веб-браузере, скомпилировав код на языке C при помощи компилятора Emscripten [11] с рядом незначительных исправлений. Код SDL-версии Doom, адаптированной для совместимости с компилятором Emscripten, приведён в файле doomgeneric_emscripten.c, содержащемся в репозитории.
Сравним стандартно оформленное содержимое файлов doomgeneric_sdl.c и doomgeneric_emscripten.c при помощи утилиты diff [18], использующейся в СКВ git. Опция --no-index команды git diff позволяет найти различия между двумя указанными файлами:
PS C:\doomgeneric\doomgeneric> git diff --no-index doomgeneric_sdl.c doomgeneric_emscripten.c
diff --git a/doomgeneric_sdl.c b/doomgeneric_emscripten.c
index ee556a2..4f93459 100644
--- a/doomgeneric_sdl.c
+++ b/doomgeneric_emscripten.c
@@ -1,4 +1,4 @@
-//doomgeneric for cross-platform development library 'Simple DirectMedia Layer'
+//doomgeneric emscripten port
#include "doomkeys.h"
#include "m_argv.h"
@@ -9,6 +9,7 @@
#include <stdbool.h>
#include <SDL.h>
+#include <emscripten.h>
SDL_Window* window = NULL;
SDL_Renderer* renderer = NULL;
@@ -196,8 +197,6 @@ void DG_SetWindowTitle(const char * title)
int main(int argc, char **argv) {
doomgeneric_Create(argc, argv);
- for (int i = 0; ; i++) {
- doomgeneric_Tick();
- }
+ emscripten_set_main_loop(doomgeneric_Tick, 0, 1);
return 0;
}
В выводе команды git diff добавленные строки обозначены символом «+», а удалённые строки обозначены символом «-».
Из вывода команды git diff следует, что для преобразования SDL-версии Doom doomgeneric_sdl.c в SDL-версию Doom для Emscripten doomgeneric_emscripten.c главный цикл программы в функции main был реализован при помощи функции emscripten_set_main_loop вместо обычного цикла, а для использования этой функции был подключён заголовочный файл emscripten.h.
Сформируем сценарий сборки emcc-build.ps1 для PowerShell [6], содержащий команду для сборки SDL-версии Doom для веб-браузеров при помощи компилятора emcc. Перечень файлов с кодом на языке C для компиляции кроссплатформенного ядра Doom получим из Makefile, как в предыдущем разделе. Дополнительно подключим файл doomgeneric_emscripten.c, содержащий реализации платформозависимых функций для SDL и Emscripten:
PS C:\doomgeneric\doomgeneric> Get-Content Makefile | Where-Object { $_ -match 'SRC_DOOM = *' } | ForEach-Object { $_ -replace 'SRC_DOOM = ', '' -replace '\.o', '.c' -replace 'doomgeneric_xlib.c', 'doomgeneric_emscripten.c' } | ForEach-Object { "emcc $_ -o index.html --preload-file doom1.wad -s USE_SDL=2" } | Out-File -FilePath emcc-build.ps1
PowerShell-команды Get-Content, Where-Object, ForEach-Object и Out-File рассматривались в разделе 1.3.2. Выведем полученное в результате выполнения PowerShell-команд содержимое сценария сборки emcc-build.ps1 на экран при помощи команды Get-Content:
PS C:\doomgeneric\doomgeneric> Get-Content emcc-build.ps1
emcc dummy.c am_map.c doomdef.c doomstat.c dstrings.c d_event.c d_items.c d_iwad.c d_loop.c d_main.c d_mode.c d_net.c f_finale.c f_wipe.c g_game.c hu_lib.c hu_stuff.c info.c i_cdmus.c i_endoom.c i_joystick.c i_scale.c i_sound.c i_system.c i_timer.c memio.c m_argv.c m_bbox.c m_cheat.c m_config.c m_controls.c m_fixed.c m_menu.c m_misc.c m_random.c p_ceilng.c p_doors.c p_enemy.c p_floor.c p_inter.c p_lights.c p_map.c p_maputl.c p_mobj.c p_plats.c p_pspr.c p_saveg.c p_setup.c p_sight.c p_spec.c p_switch.c p_telept.c p_tick.c p_user.c r_bsp.c r_data.c r_draw.c r_main.c r_plane.c r_segs.c r_sky.c r_things.c sha1.c sounds.c statdump.c st_lib.c st_stuff.c s_sound.c tables.c v_video.c wi_stuff.c w_checksum.c w_file.c w_main.c w_wad.c z_zone.c w_file_stdc.c i_input.c i_video.c doomgeneric.c doomgeneric_emscripten.c -o index.html --preload-file doom1.wad -s USE_SDL=2
Опция -o компилятора emcc позволяет задать имя HTML-файла с результатом сборки, опция --preload-file позволяет упаковать файл с заданным именем в виртуальную файловую систему для использования веб-приложением, а значение USE_SDL=2 опции -s указывает Emscripten на необходимость подключения к компилируемому приложению библиотеки SDL2 [17].
Переместимся в папку с установленным компилятором Emscripten [11] версии 4.0.8, процесс установки которого из git-репозитория рассматривался в разделе 1.2.2, после чего воспользуемся PowerShell-сценарием emsdk.ps1 для активации окружения с компилятором emcc:
PS C:\doomgeneric\doomgeneric> cd ..\..\emsdk\
PS C:\emsdk> .\emsdk.ps1 activate latest
Resolving SDK alias 'latest' to '4.0.8'
Resolving SDK version '4.0.8' to 'sdk-releases-56f86607aeb458086e72f23188789be2ee0e971a-64bit'
Setting the following tools as active:
node-20.18.0-64bit
python-3.9.2-nuget-64bit
releases-56f86607aeb458086e72f23188789be2ee0e971a-64bit
PS C:\emsdk> emcc --version
emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 4.0.8
Copyright (C) 2025 the Emscripten authors (see AUTHORS.txt)
This is free and open source software under the MIT license.
Вернёмся в репозиторий doomgeneric и выполним сборку проекта при помощи созданного ранее PowerShell-сценария сборки с именем emcc-build.ps1:
PS C:\emsdk> cd ..\doomgeneric\doomgeneric\
PS C:\doomgeneric\doomgeneric> .\emcc-build.ps1
ports:INFO: retrieving port: sdl2 from https://github.com/libsdl-org/SDL/archive/release-2.32.0.zip
ports:INFO: unpacking port: sdl2
cache:INFO: generating port: sysroot\lib\wasm32-emscripten\libSDL2.a...
system_libs:INFO: compiled 118 inputs in 19.77s
cache:INFO: - ok
В результате сборки в рабочей директории будет создан файл index.html и ряд вспомогательных файлов. Запустим стандартный веб-сервер на языке Python, используя порт 8000:
PS C:\> python -m http.server 8000
Serving HTTP on :: port 8000 (http://[::]:8000/) ...
Открыв в веб-браузере страницу по адресу http://127.0.0.1:8000 увидим экран заставки игры Doom и логотип проекта Emscripten (см. рис. 9).
1.4. Перенос игры Doom на новую платформу
1.4. Перенос игры Doom на новую платформу
1.4.1. Адаптация Doom для ОС Linux с TUI
В этом разделе предполагается, что отладка и тестирование кода ведётся на устройстве под управлением ОС Ubuntu Linux x86-64.
Написанную в переносимом стиле программу с GUI несложно адаптировать для вывода графики в консоль. Приложение командной строки, или CLI-приложение (Command Line Interface, CLI), использующее терминал для вывода графики, позволяет обеспечить поддержку не только распространённых ОС, таких как Windows, Linux или MacOS, но и серверных ОС без поддержки GUI, а также устройств, обладающих ограниченными вычислительными ресурсами, сессий протокола SSH (Secure Shell Protocol).
Более того, передача GUI с использованием протокола удаленного доступа к рабочему столу (Remote Desktop Protocol, RDP), например, протокола RBF (Remote FrameBuffer), требует высокой пропускной способности канала связи, в то время как передача простого текста по протоколу SSH таких ограничений на канал связи не накладывает.
Рассмотрим процесс портирования программы с GUI на новую платформу с TUI (Text-based User Interface) на примере версии игры Doom doomgeneric [15] с улучшенной переносимостью.
Для добавления поддержки новой платформы для doomgeneric необходимо реализовать всего 6 платформозависимых функций, названия которых приведены в файле с документацией по проекту README.md и в табл. 2.
|
Функция |
Описание |
|---|---|
|
DG_Init |
Инициализация платформозависимых функций. |
|
DG_DrawFrame |
Вывод кадрового буфера |
|
DG_SleepMs |
Задержка в миллисекундах. |
|
DG_GetTicksMs |
Время в миллисекундах. |
|
DG_GetKey |
Обработка нажатий клавиатуры. |
|
DG_SetWindowTitle |
Обновление заголовка окна. |
Адаптируем игру для поддержки вывода ASCII-графики в терминал, реализовав TUI для Doom вместо GUI.
Для портирования Doom с GUI на новую платформу – терминал с выводом графики в виде ASCII-символов – переместимся на ОС Ubuntu Linux в загруженный ранее репозиторий с кодом переносимой версии игры Doom doomgeneric [15], в папку doomgeneric.
Создадим новый файл doomgeneric_tty.c при помощи команды touch. Также создадим новый сценарий сборки на основе напечатанного командой cat в стандартный вывод содержимого файла Makefile. Заменим в стандартном выводе cat при помощи утилиты sed подстроку с именем объектного файла doomgeneric_xlib.o на подстроку с именем нового объектного файла doomgeneric_tty.o, этот файл будет создан в процессе компиляции файла с исходным кодом doomgeneric_tty.c. Для перенаправления стандартного вывода утилиты cat в стандартный ввод утилиты sed воспользуемся оператором конвейера Unix | [7], а для перенаправления вывода утилиты sed в файл с именем Makefile.tty воспользуемся оператором >:
~/doomgeneric/doomgeneric$ touch doomgeneric_tty.c
~/doomgeneric/doomgeneric$ cat Makefile | sed s/doomgeneric_xlib.o/doomgeneric_tty.o/ | sed s/-lX11// > Makefile.tty
Запустим сборку проекта, используя созданный сценарий Makefile.tty:
~/doomgeneric/doomgeneric$ make -f Makefile.tty
mkdir -p build
[Compiling dummy.c]
[Compiling am_map.c]
[Compiling doomdef.c]
...
(.text+0x24): undefined reference to `main'
collect2: error: ld returned 1 exit status
Сборка проекта завершилась неудачей, поскольку созданный файл doomgeneric_tty.c пока не содержит функции main и платформозависимых функций.
При реализации указанных в табл. 2 функций сначала необходимо для каждой функции определить её сигнатуру – тип возвращаемого значения, количество и типы принимаемых на вход аргументов.
Для поиска сигнатур функций в заголовочных файлах с расширением .h воспользуемся утилитой grep:
~/doomgeneric/doomgeneric$ grep DG_Init *.h
doomgeneric.h:void DG_Init();
~/doomgeneric/doomgeneric$ grep DG_DrawFrame *.h
doomgeneric.h:void DG_DrawFrame();
~/doomgeneric/doomgeneric$ grep DG_SleepMs *.h
doomgeneric.h:void DG_SleepMs(uint32_t ms);
~/doomgeneric/doomgeneric$ grep DG_GetTicksMs *.h
doomgeneric.h:uint32_t DG_GetTicksMs();
~/doomgeneric/doomgeneric$ grep DG_GetKey *.h
doomgeneric.h:int DG_GetKey(int* pressed, unsigned char* key);
~/doomgeneric/doomgeneric$ grep DG_SetWindowTitle *.h
doomgeneric.h:void DG_SetWindowTitle(const char * title);
Поместим найденные сигнатуры и тривиальные реализации всех функций из табл. 2 в файл doomgeneric_tty.c. Добавим, как указано в README.md, главный цикл программы в функцию с именем main в том же файле, подключим заголовочный файл с сигнатурами функций doomgeneric.h в doomgeneric_tty.c:
#include <stdio.h>
#include <stdint.h>
#include "doomgeneric.h"
void DG_Init() { printf("Doom launched!\n"); }
void DG_SleepMs(uint32_t ms) {}
void DG_SetWindowTitle(const char *title) {}
void DG_DrawFrame() {}
uint32_t DG_GetTicksMs() { return 0; }
int DG_GetKey(int *pressed, unsigned char *key) { return 0; }
int main(int argc, char **argv) {
doomgeneric_Create(argc, argv);
while (1) doomgeneric_Tick();
return 0;
}
Вновь запустим сборку проекта:
~/doomgeneric/doomgeneric$ make -f Makefile.tty
[Compiling doomgeneric_tty.c]
[Linking doomgeneric]
[Size]
size doomgeneric
text data bss dec hex filename
322570 82200 271240 676010 a50aa doomgeneric
Сборка проекта с платформозависимыми функциями-заглушками была выполнена успешно, в результате компилятором был создан новый исполняемый файл с именем doomgeneric.
Проверим работу скомпилированной программы:
~/doomgeneric/doomgeneric$ ./doomgeneric doom1.wad
Doom launched!
Doom Generic 0.1
Z_Init: Init zone memory allocation daemon.
zone memory: 0x7f2fba975010, 600000 allocated for zone
Using . for configuration and saves
...
player 1 of 1 (1 nodes)
Emulating the behavior of the 'Doom 1.9' executable.
HU_Init: Setting up heads up display.
ST_Init: Init status bar.
Версия Doom с функциями-заглушками, размещёнными в файле doomgeneric_tty.c вместо платформозависимых реализаций функций, перечисленных в табл. 2, запустилась – на экран было выведено приветственное сообщение Doom launched, размещённое в функции-заглушке DG_Init, а также отладочные сообщения. Однако, затем выполнение программы остановилось. Это ожидаемое проведение программы, поскольку не все из платформозависимых функций (см. табл. 2) в файле doomgeneric_tty.c реализованы корректно.
Исправим реализацию функции DS_GetTicksMs – как указано в табл. 2, эта функция должна возвращать время, прошедшее с начала игры, в миллисекундах.
Для реализации этой функции создадим глобальную переменную counter, значение которой будет увеличиваться и возвращаться при каждом вызове функции DG_GetTicksMs. При помощи стандартной функции usleep, для использования которой необходимо подключить стандартный заголовочный файл unistd.h, добавим задержку, снижающую частоту смены кадров для упрощения отладки:
#include <stdio.h>
#include <stdint.h>
#include <unistd.h>
#include "doomgeneric.h"
void DG_Init() { printf("Doom launched!\n"); }
void DG_SleepMs(uint32_t ms) {}
void DG_SetWindowTitle(const char *title) {}
void DG_DrawFrame() {}
uint32_t DG_GetTicksMs() {
static uint32_t counter = 0;
usleep(1000);
printf("Tick: %d\n", counter);
return counter++;
}
int DG_GetKey(int *pressed, unsigned char *key) { return 0; }
int main(int argc, char **argv) {
doomgeneric_Create(argc, argv);
while (1) doomgeneric_Tick();
return 0;
}
Выполним сборку и проверим работу программы:
~/doomgeneric/doomgeneric$ make -f Makefile.tty
~/doomgeneric/doomgeneric$ ./doomgeneric doom1.wad
Doom launched!
Doom Generic 0.1
Z_Init: Init zone memory allocation daemon.
zone memory: 0x7fd914c28010, 600000 allocated for zone
Using . for configuration and saves
...
player 1 of 1 (1 nodes)
Emulating the behavior of the 'Doom 1.9' executable.
HU_Init: Setting up heads up display.
ST_Init: Init status bar.
Tick: 0
Tick: 1
Tick: 2
Tick: 3
Tick: 4
Tick: 5
...
Теперь после инициализации Doom не прекращает работу, а продолжает выводить в терминал отладочные сообщения с текущим значением переменной counter.
Для реализации функции DS_DrawFrame, задача которой – вывести кадровый буфер DG_ScreenBuffer на экран (см. табл. 2), необходимо определить тип глобальной переменной DG_ScreenBuffer:
~/doomgeneric/doomgeneric$ grep DG_ScreenBuffer *.h
doomgeneric.h:extern pixel_t* DG_ScreenBuffer;
~/doomgeneric/doomgeneric$ grep pixel_t *.h
doomgeneric.h:typedef uint32_t pixel_t;
doomgeneric.h:extern pixel_t* DG_ScreenBuffer;
~/doomgeneric/doomgeneric$ grep RES doomgeneric.h
#define DOOMGENERIC_RESX 640
#define DOOMGENERIC_RESY 400
Из вывода утилиты grep можно сделать вывод о том, что глобальная переменная DG_ScreenBuffer – это массив чисел типа uint32_t, поскольку pixel_t – это псевдоним типа uint32_t. Число пикселей в массиве пикселей оценивается как произведение ширины экрана DOOMGENERIC_RESX на его высоту DOOMGENERIC_RESY. Таким образом, для размера экрана 640 на 400 точек число пикселей в массиве DG_ScreenBuffer равно 256 000, пиксели в массиве сохраняются ядром Doom (см. рис. 6) построчно.
Каждое число в массиве DG_ScreenBuffer кодирует цвет пикселя и занимает 4 байта, причём 3 младших байта содержат цвет в формате RGB (Red, Green, Blue). Обновим реализацию функции DG_DrawFrame, при помощи побитовых операций в цикле извлечём компоненты цвета каждого 2-го пикселя по горизонтали и каждого 4-го пикселя по вертикали и выведем их на экран:
void DG_DrawFrame() {
for (int y = 0; y < DOOMGENERIC_RESY; y += 5) {
for (int x = 0; x < DOOMGENERIC_RESX; x += 3) {
int i = y * DOOMGENERIC_RESX + x;
uint32_t pixel = DG_ScreenBuffer[i];
unsigned char r = (pixel >> 16) & 255;
unsigned char g = (pixel >> 8) & 255;
unsigned char b = (pixel >> 0) & 255;
printf("%d %d %d; ", r, g, b);
}
printf("\n");
}
}
Номер пикселя i в массиве DG_ScreenBuffer вычисляется как номер строки y умноженный на ширину экрана DOOMGENERIC_RESX, к которому прибавляется номер пикселя в строке x. Для извлечения байта красной компоненты цвета r, зелёной компоненты цвета g и синей компоненты цвета b используется побитовый сдвиг вправо >> и побитовое «и» &. Для извлечения красной компоненты значение pixel сдвигается вправо на 16 бит (на 2 байта), для извлечения зелёной компоненты – на 8 бит (на 1 байт). Побитовое «и» используется для наложение маски из восьми единиц для исключения из числа-результата старших разрядов.
Удалим инструкцию, выводящую отладочные сообщения, из функции DG_GetTicksMs. Скорость вывода цветов пикселей в консоль можно регулировать, меняя число миллисекунд, подаваемое на вход функции usleep, вызываемой из функции DG_GetTicksMs. Проверим работу обновлённой версии программы:
~/doomgeneric/doomgeneric$ make -f Makefile.tty
~/doomgeneric/doomgeneric$ ./doomgeneric doom1.wad
...
0 0 0; 0 0 0; 0 0 0; 0 0 0; 0 0 0; 0 0 0; 0 0 0; 0 0 0; 0 0 0; 0 0 0; ...
...
128 1 1; 116 1 1; 116 1 1; 104 1 1; 128 1 1; 155 1 1; 155 1 1; 167 1 1; 255 255 72; 155 92 20; ...
...
Через некоторое время после вывода чёрных пикселей на экран начинают выводиться и другие цвета, например, 255 255 72 (ярко-жёлтый цвет).
Для поддержки вывода графики Doom в виде ASCII-символов в терминале на следующем шаге необходимо преобразовать цвет каждого пикселя в формате RGB в ASCII-символ. Выбор ASCII-символа, соответствующего пикселю, может осуществляться, например, на основе относительной яркости (relative luminance) цвета пикселя, определённой в [19].
Воспользуемся следующей упрощённой формулой на основе [19] для вычисления относительной яркости цвета:
| l(r, g, b) = 255−1(0.2126r + 0.7152g + 0.0722b) | (3) |
где r, g и b – численные значения красной, зелёной и синей компонент цвета в формате RGB, значения функции l принадлежат вещественному промежутку от 0 до 1 включительно.
Реализуем формулу (3) на языке C и используем вычисленное по ней округлённое вниз вещественное значение в функции DG_DrawFrame для выбора ASCII-символа пикселя из строковой глобальной переменной chars. Номер символа в строке chars соответствует его яркости – пробел будем считать самым тусклым, а # – самым ярким символом. Округление вниз для вещественных значений произведём при помощи оператора приведения типа данных (int).
Перед отрисовкой каждого кадра в терминале воспользуемся управляющей последовательностью ANSI (ANSI escape code) \033[1;1H – эта последовательность позволяет переместить курсор на позицию 1;1, в левый верхний угол терминала, и продолжить консольный вывод с этой позиции:
void DG_DrawFrame() {
printf("\033[1;1H");
for (int y = 0; y < DOOMGENERIC_RESY; y += 5) {
for (int x = 0; x < DOOMGENERIC_RESX; x += 3) {
int i = y * DOOMGENERIC_RESX + x;
uint32_t pixel = DG_ScreenBuffer[i];
unsigned char r = (pixel >> 16) & 255;
unsigned char g = (pixel >> 8) & 255;
unsigned char b = (pixel >> 0) & 255;
float lum = (0.2126f * r + 0.7152f * g + 0.0722f * b) / 255;
char *chars = " .:-+=*0#";
int index = (int) (lum * sizeof(chars));
printf("%c", chars[index]);
}
printf("\n");
}
}
При помощи команды make -f Makefile.tty выполним сборку новой версии программы и проверим её работу, запустив программу при помощи команды ./doomgeneric doom1.wad. Экран заставки игры Doom, выведенный в терминал ОС Linux в виде ASCII-символов, показан на рис. 10.
1.4.2. Адаптация Doom для ОС Windows с TUI
В этом разделе предполагается, что отладка и тестирование кода ведётся на устройстве под управлением ОС Windows x86-64.
Попробуем запустить реализованную в предыдущем разделе версию игры Doom с поддержкой вывода графики в терминал в виде ASCII-символов на ОС Windows.
Для этого в терминале PowerShell [6] переместимся в загруженный ранее репозиторий с кодом переносимой версии игры Doom doomgeneric [15], в папку doomgeneric. После этого создадим новый файл doomgeneric_tty.c и на основе созданного ранее сценария сборки Doom с GUI для ОС Windows build.ps1 создадим новый сценарий build-term.ps1 для сборки Doom с TUI для ОС Windows:
PS C:\doomgeneric\doomgeneric> New-Item doomgeneric_tty.c
PS C:\doomgeneric\doomgeneric> Get-Content build.ps1 | ForEach-Object { $_ -replace 'doomgeneric_win.c', 'doomgeneric_tty.c' } | Out-File -FilePath build-term.ps1
Содержимое файла build-term.ps1 отличается от содержимого файла build.ps1 тем, что подстрока с именем файла платформы с GUI doomgeneric_win.c в нём заменена подстрокой с именем файла платформы с TUI doomgeneric_tty.c.
Поместим в созданный файл doomgeneric_tty.c функции, перечисленные в табл. 2 и реализованные в предыдущем разделе для ОС Ubuntu Linux x86-64:
#include <stdio.h>
#include <stdint.h>
#include <unistd.h>
#include "doomgeneric.h"
void DG_Init() { printf("Doom launched!\n"); }
void DG_SleepMs(uint32_t ms) {}
void DG_SetWindowTitle(const char *title) {}
int DG_GetKey(int *pressed, unsigned char *key) { return 0; }
void DG_DrawFrame() {
printf("\033[1;1H");
for (int y = 0; y < DOOMGENERIC_RESY; y += 5) {
for (int x = 0; x < DOOMGENERIC_RESX; x += 3) {
int i = y * DOOMGENERIC_RESX + x;
uint32_t pixel = DG_ScreenBuffer[i];
unsigned char r = (pixel >> 16) & 255;
unsigned char g = (pixel >> 8) & 255;
unsigned char b = (pixel >> 0) & 255;
float lum = (0.2126f * r + 0.7152f * g + 0.0722f * b) / 255;
char *chars = " .:-+=*0#";
int index = (int) (lum * sizeof(chars));
printf("%c", chars[index]);
}
printf("\n");
}
}
uint32_t DG_GetTicksMs() {
static uint32_t counter = 0;
usleep(1000);
return counter++;
}
int main(int argc, char **argv) {
doomgeneric_Create(argc, argv);
while (1) doomgeneric_Tick();
return 0;
}
Выполним сборку проекта и запустим Doom в PowerShell-терминале:
PS C:\doomgeneric\doomgeneric> .\build-term.ps1
PS C:\doomgeneric\doomgeneric> .\doomgeneric.exe doom1.wad
После запуска программы в терминал будут выведены кадры, сформированные функцией DG_DrawFrame, показанные на рис. 11.
По умолчанию в некоторых версиях Windows отключены управляющие последовательности ANSI, из-за чего выполнение инструкции printf("\033[1;1H"), перемещающей курсор в левый верхний угол экрана, не приведёт к ожидаемому результату. Вместо перемещения курсора управляющая последовательность будет выведена на экран как обычный текст. В результате в терминал кадры будут выводиться последовательно, как показано на рис. 11.
Для включения поддержки управляющих последовательностей ANSI на ОС Windows воспользуемся Windows-специфичными функциями GetStdHandle, GetConsoleMode и SetConsoleMode.
В функции инициализации DG_Init (см. табл. 2) в файле doomgeneric_tty.c получим дескриптор HANDLE для стандартного вывода при помощи функции GetStdHandle, после чего получим текущие настройки консоли – для этого применим функцию GetConsoleMode. Добавим к настройкам консоли dwMode флаг ENABLE_VIRTUAL_TERMINAL_PROCESSING и сохраним настройки функцией SetConsoleMode:
#ifdef _WIN32
#include <windows.h>
#endif
void DG_Init() {
#ifdef _WIN32
HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);
DWORD dwMode;
GetConsoleMode(handle, &dwMode);
dwMode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING;
SetConsoleMode(handle, dwMode);
#endif
}
Для использования функций GetStdHandle, GetConsoleMode, SetConsoleMode необходимо подключить заголовочный файл windows.h. Этот заголовочный файл доступен только на ОС Windows и содержит специфичные для ОС Windows сигнатуры функций и типы данных.
В связи с этим для обеспечения переносимости реализации ASCII-графики Doom в файле doomgeneric_tty.c воспользуемся директивой компилятора #ifdef для проверки, компилируется ли программа для ОС Windows – в случае, если константа _WIN32 не определена, компилятор удалит добавленный нами Windows-специфичный код внутри директив #ifdef, и код в файле doomgeneric_tty.c продолжит работать на ОС Linux без изменений.
Скомпилируем и запустим обновлённую версию Doom:
PS C:\doomgeneric\doomgeneric> .\build-term.ps1
PS C:\doomgeneric\doomgeneric> .\doomgeneric.exe doom1.wad
После включения управляющих последовательностей ANSI поведение программы на ОС Windows (см. рис. 12) совпадает с поведением на ОС Linux (см. рис. 10) в части формата вывода графики.
Однако, частота смены кадров терминальной версии Doom на ОС Windows существенно ниже частоты смены кадров на ОС Linux – из-за различий в деталях реализации консольного вывода посимвольный вывод, интенсивно используемый для отрисовки графики в виде ASCII-символов в терминале, работает намного медленнее на ОС Windows по сравнению с ОС Linux.
Для устранения этого различия исправим функцию DG_DrawFrame – вместо вызова функции printf для каждого выводимого символа в обновлённой функции DG_DrawFrame будем сохранять в массив line все символы очередной строки, которую необходимо вывести на экран, после чего выведем сформированную строку line при помощи вызова стандартной функции puts. Кроме того, переместим задержку из функции DG_GetTicksMs в функцию DG_DrawFrame:
void DG_DrawFrame() {
char line[DOOMGENERIC_RESX];
printf("\033[1;1H");
for (int y = 0; y < DOOMGENERIC_RESY; y += 5) {
int pos = 0;
for (int x = 0; x < DOOMGENERIC_RESX; x += 3) {
int i = y * DOOMGENERIC_RESX + x;
uint32_t pixel = DG_ScreenBuffer[i];
unsigned char r = (pixel >> 16) & 255;
unsigned char g = (pixel >> 8) & 255;
unsigned char b = (pixel >> 0) & 255;
float lum = (0.2126f * r + 0.7152f * g + 0.0722f * b) / 255;
char *chars = " .:-+=*0#";
int index = (int) (lum * sizeof(chars));
line[pos++] = chars[index];
}
line[pos] = 0;
puts(line);
}
usleep(1000);
}
uint32_t DG_GetTicksMs() {
static uint32_t counter = 0;
return counter++;
}
Скомпилируем и запустим Doom с исправленными функциями:
PS C:\doomgeneric\doomgeneric> .\build-term.ps1
PS C:\doomgeneric\doomgeneric> .\doomgeneric.exe doom1.wad
Обновлённая версия Doom теперь характеризуется приемлемой частотой смены кадров – приветственная анимация игрового процесса, один из кадров которой показан на рис. 13, теперь выводится на экран плавно.
1.4.3. Кроссплатформенный сценарий сборки для Doom с TUI
Полученная в предыдущем разделе реализация ASCII-графики Doom для платформ с терминалом doomgeneric_tty.c отличается кроссплатформенностью – рассмотренные реализации платформозависимых функций (см. табл. 2) в файле doomgeneric_tty.c позволяют обеспечить корректную работу игры Doom как в терминалах на ОС Linux, так и в терминалах на ОС Windows.
Кроссплатформенность doomgeneric_tty.c достигается за счёт использования директивы компилятора #ifdef для включения специфичного для Windows кода только на ОС Windows, а также за счёт ускоренного построчного вывода на экран изображения, состоящего из символов, функцией puts вместо посимвольного вывода изображения функцией printf, медленного на ОС Windows.
Однако, сборка проекта Doom для платформ с терминалом из файлов с исходным кодом выполнялась зависящим от целевой ОС способом – так, на ОС Linux использовалась система сборки GNU Make и адаптированный для ОС Linux файл Makefile.tty, созданный при помощи команд в терминале с языком оболочки Bash, а на ОС Windows использовался сценарий сборки build.ps1, созданный при помощи команд PowerShell [6].
Для реализации сценария сборки, совместимого и с ОС Windows, и с ОС Linux, воспользуемся кроссплатформенной системой сборки GNU Make [7]. В папке с исходным кодом doomgeneric и с файлом doomgeneric_tty.c создадим файл Makefile.tty со следующим содержимым:
CC = gcc -std=gnu99
OBJS = dummy.o am_map.o doomdef.o doomstat.o dstrings.o d_event.o d_items.o d_iwad.o d_loop.o d_main.o d_mode.o d_net.o f_finale.o f_wipe.o g_game.o hu_lib.o hu_stuff.o info.o i_cdmus.o i_endoom.o i_joystick.o i_scale.o i_sound.o i_system.o i_timer.o memio.o m_argv.o m_bbox.o m_cheat.o m_config.o m_controls.o m_fixed.o m_menu.o m_misc.o m_random.o p_ceilng.o p_doors.o p_enemy.o p_floor.o p_inter.o p_lights.o p_map.o p_maputl.o p_mobj.o p_plats.o p_pspr.o p_saveg.o p_setup.o p_sight.o p_spec.o p_switch.o p_telept.o p_tick.o p_user.o r_bsp.o r_data.o r_draw.o r_main.o r_plane.o r_segs.o r_sky.o r_things.o sha1.o sounds.o statdump.o st_lib.o st_stuff.o s_sound.o tables.o v_video.o wi_stuff.o w_checksum.o w_file.o w_main.o w_wad.o z_zone.o w_file_stdc.o i_input.o i_video.o doomgeneric.o doomgeneric_tty.o
%.o: %.c
$(CC) -c $< -o $@
doom.exe: $(OBJS)
$(CC) $(OBJS) -o doom.exe
clean:
ifeq ($(OS), Windows_NT)
if exist *.o del /q *.o
else
rm -f *.o
endif
В переменную CC поместим имя компилятора gcc с опцией -std=gnu99, указывающей на необходимость использования стандарта C99 и нестандартных GNU-расширений при компиляции Doom. В переменную OBJS поместим перечень файлов с расширением .o, полученный из файла с именем Makefile, уже присутствовавшего в репозитории doomgeneric.
За переменными в Makefile.tty следуют 2 цели сборки.
Первая цель сборки %.o: %.c описывает правило преобразования файлов с кодом на языке C в объектные файлы с расширением .o. При этом для сборки любого .o-файла требуется наличие на диске .c-файла с тем же именем. Например, для успешного выполнения цели doomgeneric_tty.o, которая может быть запущена командой make -f Makefile.tty doomgeneric_tty.o, требуется наличие на диске файла doomgeneric_tty.c.
Выражение $(CC) позволяет подставить в сборочную команду значение переменной CC, а выражение $< позволяет получить имя первой зависимости %.c цели %.o – то есть, имя конкретного файла с расширением .c. Выражение $@, в свою очередь, позволяет получить имя цели %.o – то есть, имя конкретного файла с расширением .o.
Таким образом, в результате выполнения команды make -f Makefile.tty doomgeneric_tty.o выражение $(CC) -c $< -o $@ преобразуется в команду gcc -std=gnu99 -c doomgeneric_tty.c -o doomgeneric_tty.o, после чего команда выполнится.
Вторая цель сборки doom.exe: $(OBJS) зависит от всех файлов с расширением .o, перечисленных в переменной OBJS. Перед выполнением цели doom.exe GNU Make выполнит цель %.o: %.c для каждого файла из OBJS, и в результате на диске будут созданы все объектные файлы, перечисленные в OBJS, из соответствующих им файлов с кодом на языке C. В результате выполнения цели doom.exe на диске будет создан исполняемый файл с именем doom.exe путём компоновки всех объектных файлов из OBJS.
Для удаления с диска артефактов сборки с расширениями .o в конец файла с именем Makefile.tty добавлена цель clean. На ОС Linux для удаления объектных файлов используется команда rm, а на ОС Windows – команда del:
Проверим работу Makefile.tty на ОС Windows x86-64:
PS C:\doomgeneric\doomgeneric> make -f Makefile.tty
gcc -std=gnu99 -c dummy.c -o dummy.o
gcc -std=gnu99 -c am_map.c -o am_map.o
gcc -std=gnu99 -c doomdef.c -o doomdef.o
...
PS C:\doomgeneric\doomgeneric> .\doom.exe doom1.wad
Проверим работу Makefile.tty на ОС Ubuntu Linux x86-64:
~/doomgeneric/doomgeneric$ make -f Makefile.tty
gcc -std=gnu99 -c dummy.c -o dummy.o
gcc -std=gnu99 -c am_map.c -o am_map.o
gcc -std=gnu99 -c doomdef.c -o doomdef.o
...
~/doomgeneric/doomgeneric$ ./doom.exe doom1.wad
При помощи кроссплатформенной реализации ASCII-графики для Doom doomgeneric_tty.c и кроссплатформенного сценария сборки Makefile.tty становится возможным собрать и запустить игру в рамках одной SSH-сессии, как показано на рис. 14.
Для лучшего визуального эффекта необходимо уменьшить размер выводимых в терминал символов до минимально возможного при помощи сочетания клавиш Ctrl и - после запуска программы в SSH-сессии, открытой в терминале на ОС Windows.
1.4.4. Упражнения
Задача 1. Добавьте поддержку пользовательского ввода в консольную версию Doom. Реализуйте в файле doomgeneric_tty.c платформозависимую функцию DG_GetKey, которая проверяет, была ли нажата клавиша, без блокировки выполнения программы. Проверьте корректность работы функции DG_GetKey на ОС Linux и на ОС Windows.
Задача 2. Добавьте в консольную версию Doom doomgeneric_tty.c опциональную поддержку ASCII-графики с использованием других цветов кроме чёрного и белого. Воспользуйтесь управляющими последовательностями ANSI для задания цвета выводимого в консоль текста.
Задача 3. Добавьте в консольную версию Doom doomgeneric_tty.c опциональную поддержку вывода в терминал символов геометрических фигур Unicode с различающейся прозрачностью вместо ASCII-символов.
Задача 4. Портируйте Doom с ASCII-графикой doomgeneric_tty.c в терминал веб-браузера при помощи Emscripten. Для очистки экрана вместо ANSI-кодов воспользуйтесь вызовом console.clear().
Задача 5. Добавьте в консольную версию Doom doomgeneric_tty.c поддержку вывода звука на ОС Linux и ОС Windows.
2.1. Преобразование структуры данных в массив байт
2.1. Преобразование структуры данных в массив байт
2.1.1. Разбор задачи
В индивидуальных вариантах задач на тему преобразования структуры данных в массив байт предлагается написать программу на языке C или C++ для работы со структурой данных c учетом архитектурных особенностей некоторой платформы. Рассмотрим платформу, информация о которой представлена в табл. 3, с порядком байт от старшего к младшему.
|
Тип |
Размер (байт) |
Знак |
Выравнивание (байт) |
|---|---|---|---|
|
short |
4 |
Да |
1 |
|
char |
1 |
Нет |
1 |
|
float |
4 |
Да |
8 |
|
unsigned int |
8 |
Нет |
1 |
Структура данных на целевой платформе имеет следующий вид, типы в приведенной структуре данных следует адаптировать к используемому в решении задачи компилятору:
struct data {
short field1;
char field2;
short field3;
char field4[9];
float field5;
float field6;
unsigned int field7;
};
Необходимо написать функцию для преобразования структуры data в массив байт. В реализации необходимо учесть особенности платформы и вывести на экран значения массива байт, как показано в примерах ниже.
Пример 1
Функция для заполнения полей структуры data:
void test_code1(struct data *d) {
d->field1 = 577412691;
d->field2 = 50;
d->field3 = -1430727895;
d->field4[0] = 223;
d->field4[1] = 172;
d->field4[2] = 195;
d->field4[3] = 169;
d->field4[4] = 31;
d->field4[5] = 107;
d->field4[6] = 61;
d->field4[7] = 233;
d->field4[8] = 167;
d->field5 = -0.36180755496025085;
d->field6 = -0.06448640674352646;
d->field7 = 1362959830220383687;
}
Результат вывода массива байт на экран:
22 6A 9E 53 32 AA B8 D3
29 DF AC C3 A9 1F 6B 3D
E9 A7 00 00 00 00 00 00
BE B9 3E D7 00 00 00 00
BD 84 11 73 12 EA 34 BE
8B 69 A5 C7
Пример 2
Функция для заполнения полей структуры data:
void test_code2(struct data *d) {
d->field1 = 1539482488;
d->field2 = 71;
d->field3 = 499575056;
d->field4[0] = 225;
d->field4[1] = 148;
d->field4[2] = 133;
d->field4[3] = 246;
d->field4[4] = 83;
d->field4[5] = 176;
d->field4[6] = 211;
d->field4[7] = 200;
d->field4[8] = 226;
d->field5 = 0.35434696078300476;
d->field6 = -0.5025861859321594;
d->field7 = 13314929168479759564;
}
Результат вывода массива байт на экран:
5B C2 A3 78 47 1D C6 E9
10 E1 94 85 F6 53 B0 D3
C8 E2 00 00 00 00 00 00
3E B5 6C F7 00 00 00 00
BF 00 A9 7D B8 C8 21 88
80 B9 2C CC
Решение задачи
Как указано в постановке задачи, при преобразовании структуры данных в массив байт необходимо учесть архитектурные особенности целевой платформы, при этом целевая платформа может отличаться от той платформы, на которой ведётся разработка программы – далее в этом разделе будем считать, что отладка и тестирование программы производится на устройстве с архитектурой x86-64 под управлением ОС Windows.
Начать решение задачи следует с адаптации типов полей структуры, перечисленных в табл. 3, к компилятору, используемому в процессе разработки.
Из табл. 3 следует, что на целевой платформе тип short является знаковым и занимает 4 байта, однако, на устройстве с ОС Windows x86-64 тип short занимает 2 байта. Для учёта этой особенности целевой платформы в нашем коде заменим поля структуры типа short на поля типа int32_t, занимающие 4 байта памяти на любой платформе.
Как указано в табл. 3, тип char на целевой платформе является беззнаковым – для учёта этой особенности при разработке на ОС Windows x86-64 необходимо заменить поля структуры типа char на поля типа uint8_t.
Кроме того, в табл. 3 указано, что тип unsigned int на целевой платформе занимает 8 байт, однако, на устройстве с ОС Windows x86-64 переменные типа unsigned int занимают 4 байта – в этой связи необходимо заменить в определении структуры тип unsigned int на тип uint64_t, занимающий 8 байт на любой платформе.
Определения типов int32_t, uint8_t, uint64_t приведены в стандартном заголовочном файле stdint.h, который необходимо подключить перед определением структуры. Характеристики типа float, приведённые в табл. 3, совпадают с характеристиками этого типа данных на платформе ОС Windows x86-64, поэтому поля типа float в структуре data оставим без изменений.
Применив указанные преобразования к структуре из постановки задачи, получим следующее определение структуры data:
#include <stdint.h>
#include <stdio.h>
struct data {
int32_t field1;
uint8_t field2;
int32_t field3;
uint8_t field4[9];
float field5;
float field6;
uint64_t field7;
};
Как показано в примерах результатов вычислений, массив байт, соответствующий структуре data, следует выводить на экран построчно. Каждая выведенная строка содержит до 8 байт, разделённых пробелами, при этом байты необходимо вывести в виде шестнадцатеричной строки в верхнем регистре.
Для учёта указанных особенностей выходного формата реализуем вспомогательную функцию move и функцию print_uint8_t. Проверим работу реализованных функций – для этого в функции main объявим структуру data и заполним её тестовыми значениями при помощи приведённой в постановке задачи функции test_code1, после чего выведем на экран байты, соответствующие полю field2 и первому элементу массива в поле field4:
int move(int offs) {
offs += 1;
if (offs % 8 == 0)
printf("\n");
return offs;
}
int print_uint8_t(uint8_t byte, int offs) {
printf("%02X ", byte);
return move(offs);
}
int main() {
struct data d;
test_code1(&d);
int offs = 0;
offs = print_uint8_t(d.field2, offs);
offs = print_uint8_t(d.field4[0], offs);
return 0;
}
Функция move принимает на вход номер текущего байта, вычисляет номер следующего байта и печатает на экран символ переноса строки \n в том случае, если номер следующего байта кратен 8. Функция print_uint8_t выводит на экран значение поданного на вход байта в виде шестнадцатеричной строки в верхнем регистре за счёт использования формата X. Последовательность 02, указанная между символом % и форматом X, позволяет установить минимальную длину выводимой строки равной 2, при этом символ 0 будет добавлен в начало выводимой строки в случае, если её длина меньше минимальной.
После печати значения байта в виде шестнадцатеричной строки функцией print_uint8_t за счёт использования функции move на экран также будет выведен символ переноса строки на новую \n в том случае, если на экран уже было выведено 8 байт.
Поместим в файл exam.c исправленное определение структуры data, приведённую в постановке задачи функцию test_code1, а также функции move, print_uint8_t и main, скомпилируем программу при помощи компилятора gcc из набора инструментов MinGW [5] и проверим её работу:
PS C:\> gcc -std=c99 -Wall -Wextra -Wpedantic exam.c -o exam.exe
PS C:\> ./exam.exe
32 DF
Опции компилятора, начинающиеся с префикса -W, позволяют включить дополнительные предупреждения для обнаружения ошибок и несоответствий стандарту при компиляции кода на языке C.
Первое выведенное на экран значение – это байт, соответствующий полю field2, его значение совпадает со значением 5-го байта из 1-го примера (см. результат вывода массива байт на экран для функции test_code1), поскольку полю field2 предшествуют 4 байта поля field1. Второе выведенное на экран значение – это байт 1-го элемента массива field4, его значение совпадает со значением 10-го байта из 1-го примера, так как ему предшествуют поля field1 и field3, занимающие по 4 байта, а также поле field2, занимающее 1 байт.
На следующем шаге реализуем функции print_int32_t и print_uint64_t для вывода на экран байт целочисленных полей структуры data размером 4 и 8 байт соответственно.
Как указано в постановке задачи, порядок байт на целевой платформе – от старшего к младшему. Порядок байт целевой платформы отличается от порядка байт на устройстве с архитектурой x86-64. На x86-64 используется порядок байт от младшего к старшему, и в связи с этим при выводе байт на экран необходимо менять их порядок – для этого в функциях print_int32_t и print_uint64_t будем выводить байты на экран в цикле for, перебирающем значения по адресу bytes в обратном порядке.
Проверим работу реализованных функций – для этой цели в функции main выведем на экран значения первых 4-х полей структуры data:
int print_int32_t(int32_t field, int offs) {
uint8_t *bytes = (uint8_t*) &field;
for (int i = 4; i > 0; i -= 1)
offs = print_uint8_t(bytes[i-1], offs);
return offs;
}
int print_uint64_t(uint64_t field, int offs) {
uint8_t *bytes = (uint8_t*) &field;
for (int i = 8; i > 0; i -= 1)
offs = print_uint8_t(bytes[i-1], offs);
return offs;
}
int main() {
struct data d;
test_code1(&d);
int offs = 0;
offs = print_int32_t(d.field1, offs);
offs = print_uint8_t(d.field2, offs);
offs = print_int32_t(d.field3, offs);
for (int i = 0; i < 9; i++)
offs = print_uint8_t(d.field4[i], offs);
return 0;
}
Каждая из функций print_int32_t и print_uint64_t принимает на вход содержимое поля field, при помощи оператора & извлекает адрес поля field, после чего при помощи оператора приведения типов (uint8_t*) меняет тип указателя на uint8_t* – на указатель на первый байт. Этот указатель затем используется для вывода на экран значения каждого i-го байта при помощи функции print_uint8_t, выражение bytes[i-1] эквивалентно выражению *(bytes+i-1) и позволяет получить значение байта с номером i-1 начиная с адреса указателя bytes.
Проверим работу обновлённой программы:
PS C:\> gcc -std=c99 -Wall -Wextra -Wpedantic exam.c -o exam.exe
PS C:\> ./exam.exe
22 6A 9E 53 32 AA B8 D3
29 DF AC C3 A9 1F 6B 3D
E9 A7
Значения байт, выведенные в консоль, совпадают со значениями байт, приведёнными в постановке задачи (см. результат вывода массива байт на экран для функции test_code1). Первая строка, содержащая 8 байт, была автоматически отделена от следующей строки за счёт использования функции move, печатающей символ \n после каждого 8-го байта.
Как указано в табл. 3, поля типа float на целевой платформе должны быть выровнены по 8 байт – на практике это означает, что адреса первых байт полей типа float должны быть кратны 8. Реализуем функцию print_align_8 для печати данных, выровненных по 8 байт. Реализуем также функцию print_float и обновим функцию main для вывода на экран байт всех полей структуры data:
int print_align_8(int offs) {
while (offs % 8 != 0)
offs = print_uint8_t(0, offs);
return offs;
}
int print_float(float field, int offs) {
uint8_t *bytes = (uint8_t*) &field;
for (int i = 4; i > 0; i -= 1)
offs = print_uint8_t(bytes[i - 1], offs);
return offs;
}
int main() {
struct data d;
test_code1(&d);
int offs = 0;
offs = print_int32_t(d.field1, offs);
offs = print_uint8_t(d.field2, offs);
offs = print_int32_t(d.field3, offs);
for (int i = 0; i < 9; i++)
offs = print_uint8_t(d.field4[i], offs);
offs = print_align_8(offs);
offs = print_float(d.field5, offs);
offs = print_align_8(offs);
offs = print_float(d.field6, offs);
offs = print_uint64_t(d.field7, offs);
return 0;
}
Функция print_align_8 увеличивает номер выводимого на экран байта и печатает 00 до тех пор, пока не встретится номер байта, который делится на 8 без остатка, после чего завершает работу. В этой связи вызывать функцию print_align_8 необходимо перед вызовом функции print_float для выравнивания полей типа float.
Проверим работу программы:
PS C:\> gcc -std=c99 -Wall -Wextra -Wpedantic exam.c -o exam.exe
PS C:\> ./exam.exe
22 6A 9E 53 32 AA B8 D3
29 DF AC C3 A9 1F 6B 3D
E9 A7 00 00 00 00 00 00
BE B9 3E D7 00 00 00 00
BD 84 11 73 12 EA 34 BE
8B 69 A5 C7
Для проверки корректности работы программы на 2 примерах входных данных, представленных функциями test_code1 и test_code2, переместим код для вывода на экран структуры в виде массива байт в функцию print_struct:
void print_struct(struct data *d) {
int offs = 0;
offs = print_int32_t(d->field1, offs);
offs = print_uint8_t(d->field2, offs);
offs = print_int32_t(d->field3, offs);
for (int i = 0; i < 9; i++)
offs = print_uint8_t(d->field4[i], offs);
offs = print_align_8(offs);
offs = print_float(d->field5, offs);
offs = print_align_8(offs);
offs = print_float(d->field6, offs);
offs = print_uint64_t(d->field7, offs);
}
int main() {
struct data d;
test_code1(&d);
print_struct(&d);
printf("\n\n");
test_code2(&d);
print_struct(&d);
return 0;
}
Для доступа к значениям полей структуры data в функции print_struct используется оператор -> вместо оператора точки, поскольку на вход функции print_struct передаётся указатель на структуру struct data *d – синтаксис d->field эквивалентен синтаксису (*d).field, где выражение *d получает объект по его адресу, сохранённому в переменной d.
Проверим работу программы на тестовых данных:
PS C:\> gcc -std=c99 -Wall -Wextra -Wpedantic exam.c -o exam.exe
PS C:\> ./exam.exe
22 6A 9E 53 32 AA B8 D3
29 DF AC C3 A9 1F 6B 3D
E9 A7 00 00 00 00 00 00
BE B9 3E D7 00 00 00 00
BD 84 11 73 12 EA 34 BE
8B 69 A5 C7
5B C2 A3 78 47 1D C6 E9
10 E1 94 85 F6 53 B0 D3
C8 E2 00 00 00 00 00 00
3E B5 6C F7 00 00 00 00
BF 00 A9 7D B8 C8 21 88
80 B9 2C CC
2.1.2. Упражнения
Задача 1
Написать программу на C/C++ для работы со структурой данных c учетом архитектурных особенностей некоторой платформы. Информация о платформе представлена в табл. 4, порядок байт – от старшего к младшему.
|
Тип |
Размер (байт) |
Знак |
Выравнивание (байт) |
|---|---|---|---|
|
unsigned short |
2 |
Нет |
8 |
|
char |
1 |
Нет |
1 |
|
unsigned int |
4 |
Нет |
1 |
|
long long |
8 |
Да |
4 |
Структура данных на целевой платформе имеет следующий вид, типы в приведенной структуре данных, возможно, придется адаптировать к используемому в решении задачи компилятору:
struct data {
unsigned short field1;
char field2[3];
unsigned int field3;
long long field4;
};
Написать функцию для преобразования структуры data в массив байт. В реализации необходимо учесть особенности платформы. Вывести на экран значения массива байт, как показано в примерах ниже.
Пример 1
Функция для заполнения полей структуры data:
void test_code1(struct data *d) {
d->field1 = 6122;
d->field2[0] = 110;
d->field2[1] = 178;
d->field2[2] = 32;
d->field3 = 1897468891;
d->field4 = -7251212081377159638;
}
Результат вывода массива байт на экран:
17 EA 6E B2 20 71 19 13
DB 00 00 00 9B 5E 85 07
D2 7E 56 2A
Пример 2
Функция для заполнения полей структуры data:
void test_code2(struct data *d) {
d->field1 = 40929;
d->field2[0] = 98;
d->field2[1] = 78;
d->field2[2] = 104;
d->field3 = 322651602;
d->field4 = 6308753995488277642;
}
Результат вывода массива байт на экран:
9F E1 62 4E 68 13 3B 45
D2 00 00 00 57 8D 32 55
6C 67 B4 8A
Задача 2
Написать программу на C/C++ для работы со структурой данных c учетом архитектурных особенностей некоторой платформы. Информация о платформе представлена в табл. 5, порядок байт – от младшего к старшему.
|
Тип |
Размер (байт) |
Знак |
Выравнивание (байт) |
|---|---|---|---|
|
char |
1 |
Да |
1 |
|
unsigned short |
2 |
Нет |
1 |
|
long long |
8 |
Да |
8 |
Структура данных на целевой платформе имеет следующий вид, типы в приведенной структуре данных, возможно, придется адаптировать к используемому в решении задачи компилятору:
struct data {
char field1[5];
unsigned short field2;
long long field3;
char field4;
unsigned short field5;
unsigned short field6;
};
Написать функцию для преобразования структуры data в массив байт. В реализации необходимо учесть особенности платформы. Вывести на экран значения массива байт, как показано в примерах ниже.
Пример 1
Функция для заполнения полей структуры data:
void test_code1(struct data *d) {
d->field1[0] = 89;
d->field1[1] = 122;
d->field1[2] = 3;
d->field1[3] = -123;
d->field1[4] = 109;
d->field2 = 65240;
d->field3 = 7002812016868415575;
d->field4 = -122;
d->field5 = 14233;
d->field6 = 33981;
}
Результат вывода массива байт на экран:
59 7A 03 85 6D D8 FE 00
57 58 47 46 6D FC 2E 61
86 99 37 BD 84
Пример 2
Функция для заполнения полей структуры data:
void test_code2(struct data *d) {
d->field1[0] = 20;
d->field1[1] = 21;
d->field1[2] = 119;
d->field1[3] = 17;
d->field1[4] = -109;
d->field2 = 6137;
d->field3 = -1989174332666178083;
d->field4 = -125;
d->field5 = 13528;
d->field6 = 29366;
}
Результат вывода массива байт на экран:
14 15 77 11 93 F9 17 00
DD 95 1B C9 7B 08 65 E4
83 D8 34 B6 72
Задача 3
Написать программу на C/C++ для работы со структурой данных c учетом архитектурных особенностей некоторой платформы. Информация о платформе представлена в табл. 6, порядок байт – от старшего к младшему.
|
Тип |
Размер (байт) |
Знак |
Выравнивание (байт) |
|---|---|---|---|
|
float |
4 |
Да |
2 |
|
char |
1 |
Да |
1 |
|
double |
8 |
Да |
2 |
|
unsigned long |
4 |
Нет |
8 |
Структура данных на целевой платформе имеет следующий вид, типы в приведенной структуре данных, возможно, придется адаптировать к используемому в решении задачи компилятору:
struct data {
float field1;
char field2[7];
double field3;
unsigned long field4[5];
float field5;
};
Написать функцию для преобразования структуры data в массив байт. В реализации необходимо учесть особенности платформы. Вывести на экран значения массива байт, как показано в примерах ниже.
Пример 1
Функция для заполнения полей структуры data:
void test_code1(struct data *d) {
d->field1 = -0.31428834795951843;
d->field2[0] = 48;
d->field2[1] = 123;
d->field2[2] = -46;
d->field2[3] = 18;
d->field2[4] = -108;
d->field2[5] = -74;
d->field2[6] = -94;
d->field3 = -0.11718039696756088;
d->field4[0] = 3439945603;
d->field4[1] = 3356052132;
d->field4[2] = 1245866636;
d->field4[3] = 688797180;
d->field4[4] = 246177702;
d->field5 = 0.5174322724342346;
}
Результат вывода массива байт на экран:
BE A0 EA 67 30 7B D2 12
94 B6 A2 00 BF BD FF 88
D4 B5 3D A0 00 00 00 00
CD 09 67 83 00 00 00 00
C8 09 4A A4 00 00 00 00
4A 42 6A 8C 00 00 00 00
29 0E 35 FC 00 00 00 00
0E AC 5F A6 3F 04 76 71
Пример 2
Функция для заполнения полей структуры data:
void test_code2(struct data *d) {
d->field1 = -0.8661420941352844;
d->field2[0] = -7;
d->field2[1] = 50;
d->field2[2] = -82;
d->field2[3] = -60;
d->field2[4] = 123;
d->field2[5] = -59;
d->field2[6] = 24;
d->field3 = 0.4712565849740169;
d->field4[0] = 103666961;
d->field4[1] = 1135678811;
d->field4[2] = 784585022;
d->field4[3] = 867071605;
d->field4[4] = 2384339639;
d->field5 = -0.17117364704608917;
}
Результат вывода массива байт на экран:
BF 5D BB 7D F9 32 AE C4
7B C5 18 00 3F DE 29 11
61 1F 3C 2C 00 00 00 00
06 2D D5 11 00 00 00 00
43 B1 15 5B 00 00 00 00
2E C3 D1 3E 00 00 00 00
33 AE 76 75 00 00 00 00
8E 1E 22 B7 BE 2F 48 25
Задача 4
Написать программу на C/C++ для работы со структурой данных c учетом архитектурных особенностей некоторой платформы. Информация о платформе представлена в табл. 7, порядок байт – от младшего к старшему.
|
Тип |
Размер (байт) |
Знак |
Выравнивание (байт) |
|---|---|---|---|
|
int |
4 |
Да |
8 |
|
unsigned int |
4 |
Нет |
8 |
|
char |
1 |
Нет |
1 |
|
unsigned short |
4 |
Нет |
1 |
Структура данных на целевой платформе имеет следующий вид, типы в приведенной структуре данных, возможно, придется адаптировать к используемому в решении задачи компилятору:
struct data {
int field1;
unsigned int field2;
char field3[4];
unsigned int field4;
unsigned short field5[8];
int field6;
};
Написать функцию для преобразования структуры data в массив байт. В реализации необходимо учесть особенности платформы. Вывести на экран значения массива байт, как показано в примерах ниже.
Пример 1
Функция для заполнения полей структуры data:
void test_code1(struct data *d) {
d->field1 = -1350465593;
d->field2 = 1070862709;
d->field3[0] = 133;
d->field3[1] = 250;
d->field3[2] = 100;
d->field3[3] = 119;
d->field4 = 853061266;
d->field5[0] = 4045919985;
d->field5[1] = 621142676;
d->field5[2] = 2522688801;
d->field5[3] = 4266396485;
d->field5[4] = 4011826561;
d->field5[5] = 2781046377;
d->field5[6] = 4181524247;
d->field5[7] = 2634240086;
d->field6 = 1526700227;
}
Результат вывода массива байт на экран:
C7 87 81 AF 00 00 00 00
75 11 D4 3F 85 FA 64 77
92 AE D8 32 F1 D6 27 F1
94 E2 05 25 21 2D 5D 96
45 0B 4C FE 81 9D 1F EF
69 66 C3 A5 17 FF 3C F9
56 50 03 9D 00 00 00 00
C3 98 FF 5A
Пример 2
Функция для заполнения полей структуры data:
void test_code2(struct data *d) {
d->field1 = 1629770468;
d->field2 = 2964430478;
d->field3[0] = 80;
d->field3[1] = 4;
d->field3[2] = 142;
d->field3[3] = 93;
d->field4 = 4275451258;
d->field5[0] = 3089026750;
d->field5[1] = 3619447220;
d->field5[2] = 409496463;
d->field5[3] = 3070974784;
d->field5[4] = 3770746185;
d->field5[5] = 1824038642;
d->field5[6] = 2365466585;
d->field5[7] = 2202808331;
d->field6 = 330349589;
}
Результат вывода массива байт на экран:
E4 52 24 61 00 00 00 00
8E 9E B1 B0 50 04 8E 5D
7A 35 D6 FE BE CE 1E B8
B4 61 BC D7 8F 6B 68 18
40 5B 0B B7 49 05 C1 E0
F2 9E B8 6C D9 27 FE 8C
0B 30 4C 83 00 00 00 00
15 BC B0 13
2.2. Разбор массива байт, содержащего структуру данных
2.2. Разбор массива байт, содержащего структуру данных
2.2.1. Разбор задачи
В индивидуальных вариантах задач на тему разбора массива байт, содержащего структуру данных, предлагается написать программу на языке C или C++ для работы со структурой данных c учетом архитектурных особенностей некоторой платформы. Рассмотрим платформу, информация о которой представлена в табл. 8, с порядком байт от младшего к старшему.
|
Тип |
Размер (байт) |
Знак |
Выравнивание (байт) |
|---|---|---|---|
|
char |
1 |
Да |
1 |
|
unsigned int |
4 |
Нет |
8 |
Структура данных на целевой платформе имеет следующий вид, типы в приведенной структуре данных, возможно, придется адаптировать к используемому в решении задачи компилятору:
struct data {
char field1[9];
unsigned int field2;
char field3[6];
unsigned int field4;
char field5;
char field6;
char field7;
};
Необходимо написать функцию для разбора массива байт, содержащего структуру data. В реализации необходимо учесть особенности платформы и вывести на экран значения полей, как показано в примерах ниже.
Пример 1
Массив байт, содержащий значения структуры data:
unsigned char test_data1[] = {
0xa0, 0x15, 0x01, 0x06, 0x42, 0x89, 0x38, 0x10,
0xbb, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xd7, 0x2b, 0x09, 0x8d, 0xd8, 0xbc, 0xf1,
0x86, 0xd4, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xae, 0x9f, 0xab, 0xcd, 0x7b, 0x6c, 0x67,
};
Результат вывода значений полей data на экран:
-96 21 1 6 66 -119 56 16 -69
153868117
-115 -40 -68 -15 -122 -44
3450576814
123
108
103
Пример 2
Массив байт, содержащий значения структуры data:
unsigned char test_data2[] = {
0x67, 0xe7, 0xaf, 0x7e, 0x23, 0x7e, 0x5a, 0xb7,
0x82, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xe4, 0x5b, 0x6e, 0x21, 0x4e, 0x9f, 0x94, 0xcb,
0x4d, 0x5d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x27, 0x3e, 0x61, 0x4c, 0xfc, 0x0b, 0x03,
};
Результат вывода значений полей data на экран:
103 -25 -81 126 35 126 90 -73 -126
560880612
78 -97 -108 -53 77 93
1281441319
-4
11
3
Решение задачи
В этом разделе предположим, что отладка и тестирование программы ведётся на устройстве с архитектурой x86-64 под управлением ОС Windows.
Характеристики типов данных char и unsigned int на целевой платформе, приведённые в таблице табл. 8, совпадают с характеристиками этих типов данных на ОС Windows x86-64, за исключением выравнивания данных для типа unsigned int. В этой связи при решении задачи воспользуемся исходным определением структуры data.
Для копирования значения поля типа char начиная с номера байта offs реализуем функцию copy_char, принимающую на вход указатель to на поле, в которое необходимо поместить значение из массива байт from с номером offs. Функция copy_char записывает в байт по адресу to значение по адресу from + offs, после чего возвращает увеличенный на 1 номер байта.
В функции main объявим структуру data, заполним массив в поле field1 тестовыми значениями из массива байт test_data1 в цикле, после чего в цикле выведем элементы массива в поле field1 на экран:
#include <stdio.h>
int copy_char(char *to, unsigned char *from, int offs) {
to[0] = from[offs];
return offs + 1;
}
int main() {
struct data s;
int offs = 0;
for (int i = 0; i < 9; i++)
offs = copy_char(&s.field1[i], test_data1, offs);
for (int i = 0; i < 9; i++)
printf("%d ", s.field1[i]);
return 0;
}
Для использования функции printf необходимо подключить стандартный заголовочный файл stdio.h. Функция copy_char выполняется в цикле, копируя 9 элементов массива test_data1 в поле field1 структуры data, при копировании значений функция copy_char также увеличивает номер копируемого байта offs. После заполнения массива field1 значения типа char в цикле выводятся на экран, для печати чисел со знаком функцией printf используется формат %d.
Сохраним определение структуры из постановки задачи, массив test_data1 и функции copy_char, main в файл exam.c, скомпилируем полученную программу при помощи компилятора gcc из набора инструментов MinGW [5] и проверим её работу:
PS C:\> gcc -std=c99 -Wall -Wextra -Wpedantic exam.c -o exam.exe
PS C:\> ./exam.exe
-96 21 1 6 66 -119 56 16 -69
Выведенная на экран строка совпадает с ожидаемым результатом вывода поля field1 на экран (см. результат вывода значений полей data на экран для массива test_data1).
На следующем шаге реализуем функцию copy_unsigned_int для копирования из массива байт from начиная с номера байта offs значения поля типа unsigned int, занимающего 4 байта как на целевой платформе (см. табл. 8), так и на ОС Windows x86-64:
int copy_unsigned_int(unsigned int *to, unsigned char *from, int offs) {
unsigned char *to_bytes = (unsigned char*) to;
for (int i = 0; i < 4; i++)
to_bytes[i] = from[offs + i];
return offs + 4;
}
Функция copy_unsigned_int при помощи оператора приведения типов (unsigned char*) преобразует указатель на беззнаковое целое число unsigned int, занимающее 4 байта, в указатель на 1 байт to_bytes, после чего в цикле копирует 4 байта начиная с адреса from + offs в память по адресу to_bytes.
При использовании функции copy_unsigned_int следует учитывать, что данные типа unsigned int на целевой платформе выровнены по 8 байт – это означает, что адрес первого байта значения типа unsigned int должен быть кратным 8. Выравнивание данных типа unsigned int отличается от выравнивания данных этого типа на ОС Windows x86-64.
Для учёта этой особенности целевой платформы реализуем также функцию align_8, которая будет увеличивать номер байта offs до тех пор, пока он не станет кратным 8:
int align_8(int offs) {
while (offs % 8 != 0)
offs++;
return offs;
}
Воспользуемся реализованными функциями align_8 и copy_unsigned_int для копирования значения поля field2 из массива test_data1, а для вывода беззнакового целочисленного значения на экран укажем формат %u в функции printf:
int main() {
struct data s;
int offs = 0;
for (int i = 0; i < 9; i++)
offs = copy_char(&s.field1[i], test_data1, offs);
offs = align_8(offs);
offs = copy_unsigned_int(&s.field2, test_data1, offs);
for (int i = 0; i < 9; i++)
printf("%i ", s.field1[i]);
printf("\n%u\n", s.field2);
return 0;
}
Проверим работу обновлённой программы exam.c:
PS C:\> gcc -std=c99 -Wall -Wextra -Wpedantic exam.c -o exam.exe
PS C:\> ./exam.exe
-96 21 1 6 66 -119 56 16 -69
153868117
Выведенные на экран строки, содержащие значения полей field1 и field2, совпадают с первыми 2 строками из ожидаемого вывода (см. результат вывода значений полей data на экран для массива test_data1).
Воспользуемся реализованными функциями copy_char, align_8 и copy_unsigned_int для разбора всего массива байт, содержащего структуру data. Для проверки корректности работы программы сразу на 2 примерах входных данных, представленных массивами test_data1 и test_data2, переместим код для разбора массива байт в функцию copy_struct:
void copy_struct(struct data *s, unsigned char *data) {
int offs = 0;
for (int i = 0; i < 9; i++)
offs = copy_char(&s->field1[i], data, offs);
offs = align_8(offs);
offs = copy_unsigned_int(&s->field2, data, offs);
for (int i = 0; i < 6; i++)
offs = copy_char(&s->field3[i], data, offs);
offs = align_8(offs);
offs = copy_unsigned_int(&s->field4, data, offs);
offs = copy_char(&s->field5, data, offs);
offs = copy_char(&s->field6, data, offs);
offs = copy_char(&s->field7, data, offs);
}
Для доступа к значениям полей структуры data в функции copy_struct используется оператор -> вместо оператора точки, который использовался ранее – это связано с тем, что на вход функции передаётся указатель на структуру struct data *s, а синтаксис s->field эквивалентен синтаксису (*s).field, где выражение *s получает объект по его адресу, сохранённому в переменной s. Перед каждым вызовом функции copy_unsigned_int используется функция align_8 для учёта особенности целевой платформы в части выравнивания данных типа unsigned int на 8 байт (см. табл. 8).
Код для вывода содержимого полей структуры data на экран отделим от кода для разбора массива байт и поместим в функцию print_struct.
В функции main заполним объявленную структуру data данными из массива test_data1 при помощи функции copy_struct и выведем значения полей на экран функцией print_struct, после чего выполним те же действия с массивом test_data2:
void print_struct(struct data *s) {
for (int i = 0; i < 9; i++)
printf("%d ", s->field1[i]);
printf("\n%u\n", s->field2);
for (int i = 0; i < 6; i++)
printf("%d ", s->field3[i]);
printf("\n%u\n", s->field4);
printf("%d\n", s->field5);
printf("%d\n", s->field6);
printf("%d\n", s->field7);
}
int main() {
struct data s;
copy_struct(&s, test_data1);
print_struct(&s);
copy_struct(&s, test_data2);
print_struct(&s);
return 0;
}
Проверим работу программы на тестовых данных:
PS C:\> gcc -std=c99 -Wall -Wextra -Wpedantic exam.c -o exam.exe
PS C:\> ./exam.exe
-96 21 1 6 66 -119 56 16 -69
153868117
-115 -40 -68 -15 -122 -44
3450576814
123
108
103
103 -25 -81 126 35 126 90 -73 -126
560880612
78 -97 -108 -53 77 93
1281441319
-4
11
3
2.2.2. Упражнения
Задача 1
Написать программу на C/C++ для работы со структурой данных c учетом архитектурных особенностей некоторой платформы. Информация о платформе представлена в таблице табл. 9, порядок байт – от старшего к младшему.
|
Тип |
Размер (байт) |
Знак |
Выравнивание (байт) |
|---|---|---|---|
|
int |
8 |
Да |
4 |
|
char |
1 |
Да |
1 |
|
unsigned long |
8 |
Нет |
1 |
Структура данных на целевой платформе имеет следующий вид, типы в приведенной структуре данных, возможно, придется адаптировать к используемому в решении задачи компилятору:
struct data {
int field1;
char field2[8];
char field3[5];
int field4;
unsigned long field5;
unsigned long field6;
};
Написать функцию для разбора массива байт, содержащего структуру data. В реализации необходимо учесть особенности платформы. Вывести на экран значения полей, как показано в примерах ниже.
Пример 1
Массив байт, содержащий значения структуры data:
unsigned char test_data1[] = {
0x9a, 0xf1, 0x75, 0x3e, 0xf2, 0xc9, 0x4a, 0xe3,
0xb3, 0x60, 0xfa, 0xed, 0xd7, 0x90, 0x76, 0x3d,
0xfc, 0xc3, 0x8a, 0x53, 0xd6, 0x00, 0x00, 0x00,
0x82, 0x5e, 0xc1, 0x7e, 0xeb, 0xff, 0xdd, 0xfb,
0x07, 0xd7, 0x18, 0xdb, 0x9c, 0xe9, 0x8f, 0x49,
0xc1, 0x67, 0x9f, 0xf5, 0x35, 0x80, 0x06, 0x3c,
};
Результат вывода значений полей data на экран:
-7281910209259681053
-77 96 -6 -19 -41 -112 118 61
-4 -61 -118 83 -42
-9052585450098663941
564947609767743305
13936283447434675772
Пример 2
Массив байт, содержащий значения структуры data:
unsigned char test_data2[] = {
0x55, 0x2d, 0x9b, 0x10, 0x70, 0x71, 0x40, 0xa2,
0x5a, 0x31, 0xa5, 0x75, 0x08, 0x6d, 0x47, 0x70,
0x65, 0xb9, 0x9d, 0x9a, 0x0b, 0x00, 0x00, 0x00,
0xe8, 0x24, 0x21, 0x22, 0x64, 0xaf, 0x35, 0xf6,
0xe3, 0x01, 0xc6, 0x30, 0x78, 0x80, 0xdd, 0x62,
0x92, 0x0b, 0xd3, 0x6a, 0x21, 0x9c, 0x1d, 0xc0,
};
Результат вывода значений полей data на экран:
6137732362084106402
90 49 -91 117 8 109 71 112
101 -71 -99 -102 11
-1719212726146877962
16357573233068793186
10523737407065169344
Задача 2
Написать программу на C/C++ для работы со структурой данных c учетом архитектурных особенностей некоторой платформы. Информация о платформе представлена в таблице табл. 10, порядок байт – от младшего к старшему.
|
Тип |
Размер (байт) |
Знак |
Выравнивание (байт) |
|---|---|---|---|
|
short |
2 |
Да |
2 |
|
char |
1 |
Да |
1 |
|
unsigned long long |
8 |
Нет |
1 |
|
unsigned short |
2 |
Нет |
2 |
Структура данных на целевой платформе имеет следующий вид, типы в приведенной структуре данных, возможно, придется адаптировать к используемому в решении задачи компилятору:
struct data {
short field1;
char field2;
unsigned long long field3;
unsigned short field4[3];
};
Написать функцию для разбора массива байт, содержащего структуру data. В реализации необходимо учесть особенности платформы. Вывести на экран значения полей, как показано в примерах ниже.
Пример 1
Массив байт, содержащий значения структуры data:
unsigned char test_data1[] = {
0xb8, 0xb6, 0x73, 0xf3, 0xcb, 0x8f, 0x9c, 0x54,
0x98, 0x64, 0x2d, 0x00, 0x94, 0xc8, 0xd7, 0x15,
0x06, 0x76,
};
Результат вывода значений полей data на экран:
-18760
115
3270906718549167091
51348 5591 30214
Пример 2
Массив байт, содержащий значения структуры data:
unsigned char test_data2[] = {
0x5c, 0x1b, 0x68, 0x14, 0xdc, 0xe0, 0x7e, 0x34,
0x60, 0x81, 0xb5, 0x00, 0x74, 0xa4, 0x25, 0x0e,
0x3b, 0xd7,
};
Результат вывода значений полей data на экран:
7004
104
13078840571443862548
42100 3621 55099
Задача 3
Написать программу на C/C++ для работы со структурой данных c учетом архитектурных особенностей некоторой платформы. Информация о платформе представлена в таблице табл. 11, порядок байт – от старшего к младшему.
|
Тип |
Размер (байт) |
Знак |
Выравнивание (байт) |
|---|---|---|---|
|
float |
4 |
Да |
4 |
|
long |
8 |
Да |
1 |
|
char |
1 |
Да |
1 |
Структура данных на целевой платформе имеет следующий вид, типы в приведенной структуре данных, возможно, придется адаптировать к используемому в решении задачи компилятору:
struct data {
float field1;
float field2[6];
long field3;
char field4[3];
float field5;
float field6;
};
Написать функцию для разбора массива байт, содержащего структуру data. В реализации необходимо учесть особенности платформы. Вывести на экран значения полей, как показано в примерах ниже.
Пример 1
Массив байт, содержащий значения структуры data:
unsigned char test_data1[] = {
0x3e, 0x92, 0x2b, 0x7a, 0xbc, 0x1f, 0x0e, 0x35,
0x3f, 0x63, 0xbd, 0x20, 0x3e, 0x82, 0x92, 0x08,
0xbf, 0x33, 0x3a, 0x49, 0xbf, 0x01, 0x2c, 0x5c,
0x3e, 0x1b, 0x1c, 0x34, 0xb2, 0x38, 0x01, 0x9f,
0xdd, 0x60, 0xab, 0xe3, 0x23, 0xe2, 0x19, 0x00,
0xbd, 0xdc, 0x73, 0xd3, 0x3f, 0x79, 0xcf, 0x2c,
};
Результат вывода значений полей data на экран:
0.28548794984817505
-0.009707977063953876 0.8896045684814453 0.2550203800201416 -0.7001081109046936 -
0.5045831203460693 0.15147477388381958
-5604727950137054237
35 -30 25
-0.10764279216527939
0.9758174419403076
Пример 2
Массив байт, содержащий значения структуры data:
unsigned char test_data2[] = {
0xbf, 0x24, 0xa9, 0xb0, 0x3e, 0x72, 0x7a, 0x8e,
0x3e, 0x7d, 0x2b, 0x40, 0xbe, 0x63, 0x67, 0xe0,
0xbf, 0x0f, 0x9a, 0x1c, 0x3e, 0xee, 0x2c, 0x48,
0xbf, 0x26, 0x22, 0xa7, 0x5b, 0x2f, 0x71, 0xb6,
0x04, 0x2b, 0x0c, 0x94, 0x8a, 0x87, 0xe7, 0x00,
0x3d, 0xce, 0x69, 0xc1, 0x3f, 0x37, 0x89, 0x0c,
};
Результат вывода значений полей data на экран:
-0.643214225769043
0.23679563403129578 0.24723529815673828 -0.2220759391784668 -0.5609452724456787
0.4651815891265869 -0.6489662528038025
6570595407924759700
-118 -121 -25
0.10078764706850052
0.7169349193572998
Задача 4
Написать программу на C/C++ для работы со структурой данных c учетом архитектурных особенностей некоторой платформы. Информация о платформе представлена в таблице табл. 12, порядок байт – от младшего к старшему.
|
Тип |
Размер (байт) |
Знак |
Выравнивание (байт) |
|---|---|---|---|
|
unsigned short |
4 |
Нет |
8 |
|
char |
1 |
Да |
1 |
|
long |
8 |
Да |
2 |
|
unsigned long long |
8 |
Нет |
4 |
Структура данных на целевой платформе имеет следующий вид, типы в приведенной структуре данных, возможно, придется адаптировать к используемому в решении задачи компилятору:
struct data {
unsigned short field1[4];
char field2[4];
char field3[9];
char field4;
char field5;
long field6;
unsigned long long field7;
};
Написать функцию для разбора массива байт, содержащего структуру data. В реализации необходимо учесть особенности платформы. Вывести на экран значения полей, как показано в примерах ниже.
Пример 1
Массив байт, содержащий значения структуры data:
unsigned char test_data1[] = {
0x49, 0xd0, 0x03, 0x13, 0x00, 0x00, 0x00, 0x00,
0x28, 0x36, 0x62, 0x48, 0x00, 0x00, 0x00, 0x00,
0xde, 0xaa, 0xd5, 0x63, 0x00, 0x00, 0x00, 0x00,
0x96, 0x2f, 0xc8, 0x07, 0x82, 0xc3, 0xe2, 0xa0,
0xa8, 0x4d, 0xb1, 0x8f, 0xad, 0xfc, 0xc2, 0xc4,
0xb9, 0xa0, 0x28, 0x00, 0x0f, 0x86, 0x54, 0xaa,
0xe0, 0x61, 0x2a, 0x4a, 0x33, 0x79, 0x5d, 0xbf,
0x91, 0xe7, 0x3a, 0x15,
};
Результат вывода значений полей data на экран:
319017033 1214395944 1674947294 130559894
-126 -61 -30 -96
-88 77 -79 -113 -83 -4 -62 -60 -71
-96
40
5344191525386749455
1529789636612553011
Пример 2
Массив байт, содержащий значения структуры data:
unsigned char test_data2[] = {
0x67, 0x35, 0x68, 0x15, 0x00, 0x00, 0x00, 0x00,
0xe3, 0xe7, 0xc1, 0x5b, 0x00, 0x00, 0x00, 0x00,
0x80, 0x52, 0xc8, 0x38, 0x00, 0x00, 0x00, 0x00,
0x74, 0xed, 0x4c, 0x4c, 0x64, 0x18, 0xa2, 0x0c,
0x2b, 0x34, 0x3b, 0xdb, 0x5f, 0x89, 0x22, 0x78,
0x9d, 0x0c, 0x4a, 0x00, 0xab, 0x91, 0xd4, 0xd0,
0xb8, 0x44, 0x56, 0x26, 0x5d, 0xe0, 0xaa, 0x43,
0x71, 0x16, 0x74, 0xfd,
};
Результат вывода значений полей data на экран:
359150951 1539434467 952652416 1280109940
100 24 -94 12
43 52 59 -37 95 -119 34 120 -99
12
74
2762470982006641067
18263247064616591453
Список литературы
1. Bryant R. E., O’Hallaron D. R. Computer Systems. — Paperback, 2016.
2. Робачевский А. М. Операционная система UNIX, 2 изд. — БХВ-Петербург, 2010.
3. Chacon S., Straub B. Pro git. — Springer Nature, 2014.
4. IEEE Standard for Information Technology - Portable Operating System Interface (POSIX(TM)) Base Specifications, Issue 7 [Онлайн]. — C/MSC - Microprocessor Standards Committee, 2018. — URL: https://pubs.opengroup.org/onlinepubs/9699919799/.
5. MinGW-W64 Compiler Binaries [Онлайн]. — URL: https://github.com/niXman/mingw-builds-binaries/releases.
6. Holmes L. Windows PowerShell Cookbook: The Complete Guide to Scripting Microsoft’s Command Shell. — O’Reilly Media, Inc., 2012.
7. Советов П. Н. Конфигурационное управление. — М.: МИРЭА – Российский технологический университет, 2021.
8. ISO/IEC 9899:1999 Programming languages — C. — International Organization for Standardization, 1999.
9. Stevens W. R., Rago S. A. Advanced programming in the UNIX environment. — Addison-Wesley, 2013.
10. Simple DirectMedia Layer 3.0.
11. Emscripten is a complete compiler toolchain to WebAssembly, using LLVM.
12. Zakai A. Emscripten: an LLVM-to-JavaScript compiler / Proceedings of the ACM international conference companion on Object oriented programming systems languages and applications companion. — 2011. — С. 301–312.
13. Gustafsson R., Algelind J. Immediate Mode Graphical User Interfaces. — 2011.
14. Sanglard F. Game engine black book: doom. — Software Wizards, 2018.
15. doomgeneric - Easily Portable Doom [Онлайн]. — URL: https://github.com/ozkl/doomgeneric.
16. iwad - Doom Wads [Онлайн]. — URL: https://github.com/Gaytes/iwad.
17. Simple DirectMedia Layer 2.0.
18. Советов П. Н., Горчаков А. В. Конфигурирование программного обеспечения вычислительных систем: командная строка, менеджеры пакетов, системы управления версиями. — М.: Издательство «Перо», 2025. — С. 142.
19. Caldwell B., Cooper M., Reid L. G., Vanderheiden G., Chisholm W., Slatin J., White J. Web content accessibility guidelines (WCAG) 2.0 // WWW Consortium (W3C). — 2008. — Т. 290, вып. 1-34. — С. 5–12.