13. Двоичная программа на Си#

Введение#

Под простой программой будем понимать программу, полученную из одного файла исходного кода.

В UNIX можно выделить два типа программ:

  • двоичную программу с инструкциями для ЦП;

  • текстовую программу с инструкциями для интерпретатора.

Текстовые программы называют сценариями или скриптами. Их пошагово выполняет интерпретатор, который можно рассматривать как “виртуальный” процессор. Им может быть не только командная оболочка bash, но и интерпретаторы языков программирования python, perl.

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

Из описаний выше двоичных и текстовых программ вытекают следующие различия в:

  • переносимости;

  • скорости исполнения;

  • сложности с модификацией.

Под переносимостью программы подразумевают возможность ее запуска на другом компьютере. Двоичную программу можно запустить на компьютере с той же платформой (комбинацией типа ОС и архитектуры ЦП), под которую она была подготовлена. Запуск на отличающейся платформе потребует повторной компиляции программ под нее. Текстовая программа же запустится на любом компьютере с установленным интерпретатором. В обоих случаях необходима подготовка: в первом подготавливается исполняемый файл и этим занимается разработчик, а во втором настраивается системное окружение (системный администратор устанавливает интерпретатор).

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

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

Создание программы#

Файлы с двоичными программами можно поделить на две части – это запускаемые файлы и разделяемые библиотеки. Разделяемые библиотеки (динамические библиотеки в Windows) представляют повторно используемые части программ. Они пристыковываются системным загрузчиком к программе при запуске. У запускаемых файлов расширение как правило отсутствует, а у разделяемых библиотек – это .so (сокращение от “shared object”).

Исполняемые файлы являются результатом преобразования текстовых файлов. Такое преобразование часто называют компиляцией. Если быть точным, то компиляция – это только один из этапов преобразования наравне с предобработкой, компоновкой и оптимизацией. Поэтому преобразование текстовых файлов в исполняемый лучше называть сборкой программы.

Текстовые файлы оформлены по правилам языка программирования. Содержимое файлов еще называют исходным кодом. В зависимости от того, на каком языке написан исходный код, файл имеет одно из расширений. Для языка программирования Си используют расширение .c, а для C++ – .cpp (также распространены .cxx, .cc).

Сборку программ в Linux осуществляют компиляторы, поставляемые проектом “GNU GCC”. Для Windows эти же компиляторы доступны через проекты Cygwin и MinGW. Набор утилит для программирования поставляет пакет build-essential. Чтобы проверить доступность компиляторов на машине, запустите команду gcc --version:

bob@pc:~$ gcc --version
gcc (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0
Copyright (C) 2021 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

В проект “GNU GCC” входят компиляторы языков программирования Си, C++, Objective-C, Fortran, Go, Ada и другие. Список доступных компиляторов можно узнать из вывода команды gcc -v в записи --enable-languages.

Проект GNU GCC предлагает множество утилит командной строки. Среди них всех представляет интерес управляющая утилита gcc. Чтобы собрать простую программу достаточно gcc передать имя файла: gcc main.c На выходе появится исполняемый файл a.out. Имя выходного файла можно переопределить после опции -o: gcc main.c -o program.

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

Вывод переменных окружения#

Главная функция main программы имеет несколько форм, из которых нам знакомы две:

  • int main()

  • int main(int argc, char** argv)

Также доступна и другая форма функции, в которой третьим аргументом вводится массив с переменными окружения:

int main(int argc, char** argv, char** envp)

Массив envp состоит из указателей на строки. Последний элемент в массиве отмечен нулевым символом. Иллюстрация к массиву char** envp:

             ┌─────────────> SHELL=/bin/bash
             │    ┌────────> LOGNAME=skt
             │    │    ┌───> PWD=/home/skt/projects/sppo
          ┌──┼─┬──┼─┬──┼─┬──⋅⋅⋅┬ ───┐
envp ────>│  x │  x │  x │     │NULL│
          └────┴────┴────┴──⋅⋅⋅┴────┘

Тот же самый массив доступен из любой точки программы из глобальной переменной environ, которую предварительно следует объявить как extern char **environ.

Напишем альтернативу команде printenv. Она обходит массив envp и выводит на экран его элементы, т.е. переменные окружения.

#include <stdio.h>
int main(int argc, char** argv, char** envp)
{
  char** ptr = envp;
  while(*ptr != NULL)
  {
    printf("%s\n", *ptr);
    ++ptr;
  }
  return 0;
}

Чтение файла построчно#

Разберем небольшую программу на Си, выводящую на экран текстовый файл по строкам. Программа оформлена вокруг функции getline(3) из заголовочного файла stdio.h.

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char** argv)
{
    FILE* file = fopen("data.txt", "r");
    if(!file)
	      return 1;

    char* line = NULL;
    size_t n = 0;
    while(getline(&line, &n, file) != -1)
        printf("%s", line);

    free(line);
    fclose(file);
    return 0;
}

Функция помещает строку из файлового потока в буфер и имеет следующую форму:

ssize_t getline(char **lineptr, size_t *n, FILE *stream)

Файловый поток представлен параметром stream, а буфер двумя параметрами: указателем на строку lineptr и размером строки n. Перед вызовом функции буфер может быть пуст (*lineptr == NULL и n = 0) или может содержать указатель на память из кучи и размер этой памяти. Если память под буфер не выделена или она недостаточна для сохранения результата, то функция getline скорректирует его размер. Функция возвращает количество прочитанных символов или -1 при достижении конца файла. По завершению программы память под буфер должна быть возвращена системе вызовом функции free.

Для работы с файлом в Си используем структуру FILE и функции для открытия fopen(3) и закрытия fclose(3). Функция FILE *fopen(const char *pathname, const char *mode) открывает файл и возвращает ассоциированный с ним указатель на файловый поток. Первый параметр содержит строку с именем файла, а второй параметр – настройки для потока. Строка "r" указывает, что открывается текстовый файл для чтения. После завершения работы с файлом, он должен быть закрыт функцией fclose.

Использованные источники#

  1. GCC and Make – A Tutorial on how to compile, link and build C/C++ application

  2. getline(3)

Вопросы для самоконтроля#

  1. Какие типы программ вам известны?

  2. Какие расширения дают исполняемым файлам?

  3. Чем отличаются двоичные программы от текстовых?

  4. Как превратить созданный в редакторе текстовый файл в исполняемый?

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

  6. Как указывают интерпретатор для сценария?

Упражнения#

  1. Оформите сценарий bash, который формирует следующие вложенные каталоги и файлы:

project
  ├─> src
  │    ├─> app ─> ∅
  │    ├─> core ─> ∅
  │    └─> utility ─> ∅
  ├─> test
  │    ├─> gtest ─> ∅
  │    └─> unittests ─> ∅
  ├─> build
  │    ├─> build-windows.bat
  │    └─> build-unix.sh
  ├─> cmake ─> ∅
  └─> CMakeLists.txt
  1. Модифицируйте программу вывода переменных окружения на экран так, чтобы она брала строки из глобальной переменной char** environ.

  2. Напишите собственную упрощенную версию утилиты echo, которая отправит в стандартный вывод переданные ей аргументы командной строки. Для отправки строки в стандартный вывод воспользуйтесь функцией fputs(3) и файловым потоком из глобальной переменной stdout.

  3. Напишите собственную утилиту pwd. Вам понадобится функция стандартной библиотеки strcmp(3).

  4. Напишите собственную утилиту cat.

  5. Напишите программу, которая ищет в списке аргументов командной строки опцию --help и выводит сообщение с краткой справкой.

  6. Напишите свой “виртуальный процессор”, который будет выполнять сценарии, просто отправляя на экран его строки. В getline(3) показан пример исходного кода считывающий последовательно строки из файла.

  7. Найдите на GitHub исходные коды утилит true и false. Как разработчики смогли реализовать одно, используя программный код другого?

  8. Напишите программу, выполняющую длительную операцию. Прервите программу разными сигналами и посмотрите, какой код ошибки будет возвращен.