Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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, так как тип указателя npint32_t*. Выражение *bp позволяет получить значение, занимающее 1 байт, по указателю bp, так как тип указателя bpunsigned 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.

Таблица 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.