Содержание


Пример разработки сервера с поддержкой пользовательских сессий на языке C в ОС GNU/Linux

Часть 1. Знакомство с окружением разработки.

Серия контента:

Этот контент является частью # из серии # статей: Пример разработки сервера с поддержкой пользовательских сессий на языке C в ОС GNU/Linux

Следите за выходом новых статей этой серии.

Этот контент является частью серии:Пример разработки сервера с поддержкой пользовательских сессий на языке C в ОС GNU/Linux

Следите за выходом новых статей этой серии.

Эта статья открывает цикл, в котором мы рассмотрим пример разработки на языке C в системе GNU/Linux сетевого сервера со следующими возможностями:

  • работа в фоновом режиме ("демонизация");
  • параллельная обработка входящих запросов;
  • аутентификация и авторизация клиентов, сеансы;
  • использование модулей расширения;
  • TELNET-подобный командный интерфейс;
  • отложенное выполнение команд.

Изложение построено в виде пошагового руководства – реализация функционала сервера будет последовательно рассмотрена в материалах серии. Для разработки выбран язык программирования C. Прежде всего это связано с его простотой, высокой скоростью выполнения и компактностью исполняемых файлов, а также с повсеместной распространенностью средств разработки и run-time среды (в том числе и во встраиваемых системах). Пять небольших статей должны дать подготовленному человеку представление об устройстве подобных программ и некоторых деталях их работы. Предполагается, что читатель знаком с языком программирования C, ОС GNU/Linux, компиляторами и компиляцией, умеет находить и устанавливать в систему необходимое ПО. Цикл рассчитан на разработчиков, ищущих примеры реальных программ с упомянутым функционалом. Материал не является ни исчерпывающим руководством, ни справочником, его можно рассматривать как отправную точку для своих исследований либо как связующую нить для, возможно, разрозненных сведений.

Чтобы работать с приведёнными в статьях примерами, вам понадобится текстовый редактор, графический эмулятор терминала либо текстовая консоль в работающей linux-системе, GNU-компилятор программ на языке C и основная библиотека языка C проекта GNU (GNU C library, libc, glibc). В некоторых дистрибутивах такие вещи по умолчанию не устанавливаются и/или вынесены в так называемые dev-пакеты (пакеты для разработки ПО). Сверьтесь с документацией по вашему дистрибутиву, чтобы прояснить этот вопрос. Для установки пакетов в систему требуются права root, для работы с примерами из статьи права root не нужны.

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

"HelloWorld" с использованием разделяемой библиотеки

Библиотеки функций позволяют избежать "велосипедостроения" – многократной реализации одного и того же разными разработчиками. Библиотеки функций бывают статическими и динамическими (разделяемыми). Первые включаются в программу в процессе сборки исходного кода и становятся ее частью. При использовании вторых в исполняемый файл включаются только ссылки, связывание с реальным библиотечным кодом происходит уже при загрузке программы в ОЗУ для выполнения, и занимается этим программа-линковщик (линкер, от англ. "link" – связь, ссылка). В каждом подходе есть плюсы и минусы, однако из этих двух подходов только разделяемые библиотеки позволяют расширять функциональные возможности программы без её перекомпиляции, а именно это нам и нужно. Ещё одним способом расширения программы может быть вызов из неё других программ. Однако этот путь менее производителен (другая программа может потребовать для своей работы загрузки дополнительных библиотек и выполнения произвольного количества действий для своей инициализации) и сложнее в реализации, так как необходимо совершать дополнительные и немалые усилия по обмену данными между программами (тогда как функции из динамически подключаемой библиотеки вызываются и выполняются в контексте текущей программы, как "свои"). Обращение к другим программам по сети тоже можно рассматривать как расширение функционала программы, но тут также встают вопросы эффективности (поддержка сетевого протокола, упаковка/распаковка данных, сетевые задержки) и надёжности (сеть не настолько надёжна, как ОЗУ одной физической системы).

В GNU-библиотеке основных функций языка C (libc) инструментом для работы с разделяемыми библиотеками в процессе выполнения программы ("на лету") является группа вызовов dlopen()/dlsym()/dlclose() (больше информации о них можно получить в их руководствах-мануалах). Для демонстрации их использования построим простой каркас, открывающий библиотеку, вызывающий функцию из неё и закрывающий библиотеку. Сейчас нам важно познакомиться с самим механизмом, поэтому наша библиотека будет предельно простой:

float sum(float a, float b) { 
    return a + b; 
}

Здесь содержится функция, складывающая два числа, без каких-либо проверок. Сохраним этот код в файл под именем lib.c и теперь посмотрим на код, ее использующий:

#include <stdio.h> 
#include <stdlib.h> 
#include <dlfcn.h> 
 
int main(void) { 
    char *error; /* сюда будут помещаться сообщения об ошибках */ 
    void *libHandle; /* через этот указатель будем "общаться" с библиотекой */ 
    /* указатель на библиотечную функцию, через который мы сможем её вызвать */ 
    float (*sum) (float, float); 
 
    /* пытаемся открыть файл с библиотечным кодом и задействовать его */ 
    libHandle = dlopen("./lib.so", RTLD_NOW); 
    /* проверяем результат работы dlopen() */ 
    if (NULL == libHandle) { 
        /* если мы попали внутрь этого if, то у dlopen() ничего не получилось */ 
        fprintf(stderr, "%s\n", dlerror()); 
		/* пробуем узнать, что именно не получилось */ 
        exit(EXIT_FAILURE); /* больше сделать ничего не можем, поэтому завершаемся */ 
    } 
 
    /* связываем наш указатель с кодом конкретной функции по её имени */ 
    *(void **) (&sum) = dlsym(libHandle, "sum"); 
    if ((error = dlerror()) != NULL) { /* проверяем результат работы dlsym() */ 
        /* снова неудача, пытаемся выяснить, из-за чего */ 
        fprintf(stderr, "%s\n", error); 
        exit(EXIT_FAILURE); 
    } 
    /* вот тут запускаем код из библиотеки */ 
    fprintf(stdout, "2 + 3 = %f\n", (*sum)(2, 3)); 
	/* вот тут запускаем код из библиотеки */ 
    dlclose(libHandle); /* "отключаем" библиотеку от программы */ 
    exit(EXIT_SUCCESS); /* завершаем программу */ 
}

Флаг RTLD_NOW в вызове dlopen() заставляет run-time среду:

  • убедиться в том, что все ссылки из нашей программы на библиотечный код актуальны ("разрешаются");
  • выполнить такую проверку до возврата из dlopen(), иначе – вернуться из неё с ошибкой.

Также имеется флаг RTLD_LAZY ("ленивый"), благодаря которому такие проверки производятся по мере исполнения программы только при обнаружении ещё не связанного кода. Первый способ надёжнее (мы сразу узнаем о проблемах), но может застопорить работу программы на длительное время для проверки всех ссылок (если их много). Второй "размазывает" задержку по тем моментам, когда эти ссылки встретятся при выполнении кода, но создаёт эффект "русской рулетки".

Сохраняем код программы в файл с именем main.c и приступаем к компиляции библиотеки:

$ gcc -shared -fPIC -o lib.so lib.c

Параметры "-shared" и "-fPIC" как раз и сообщают компилятору о нашем желании построить разделяемую библиотеку, а не "обычный" исполняемый файл. В случае успеха в текущем каталоге у нас появится файл с именем lib.so. Теперь собираем саму программу:

$ gcc -rdynamic -o main main.c -ldl

Параметр "-rdynamic" сообщает компилятору о необходимости добавить в исполняемый код поддержку dlopen(), а "-ldl" подключает стандартную библиотеку, в которой находится код нужных нам функций (dlopen() и прочих). Эта библиотека – libdl – является частью GNU-библиотеки функций языка C в GNU/Linux и, кстати, сама является разделяемой библиотекой, а указание её через параметр командной строки решает проблему "курицы и яйца".

В случае успеха в текущем каталоге должен появиться исполняемый файл main. Попросим систему выполнить содержащийся в нём код:

$ ./main 
2 + 3 = 5.000000

Если система отвечает чем-то похожим на "bash: ./main: Отказано в доступе", то убедитесь в том, что у вас есть права на запуск этого файла. Команда

$ ls -l main

должна выдать что-то вроде "-rwx... main", здесь важно наличие "x" в первой группе прав ("eXecutable" – исполняемый). Если это не так, то можно набрать следующую команду:

$ chmod u+x main

для исправления ситуации. Итак, базовые навыки создания и использования разделяемых библиотек "на лету" у нас есть.

Внедрение в программу краткой справки по использованию

Наличие или отсутствие встроенной краткой справки не влияет на способность программы делать то, для чего её придумали, однако наличие такой помощи существенно облегчит жизнь пользователям. Внедрение толковой справки также является элементом usability и просто хорошим тоном разработки. Тем не менее, справка не является исчерпывающим руководством по программе и её использованию. С одной стороны, она должна сообщить о том, где и как можно получить подробную информацию, а с другой – просто напомнить о параметрах командной строки, которые распознаёт программа.

Запросить справку у программы можно через параметры командной строки (строки запуска), которые передаются в программу через аргументы argc и argv функции main(). Первая переменная содержит число, равное числу указанных параметров, вторая является указателем на массив параметров и их значений в виде строк. Параметрами командной строки обычно считаются пары "имя_параметра=значение_параметра" или "имя_параметра<пробел>значение_параметра", разделённые пробелами и перечисленные после имени исполняемого файла программы, например, так:

$ ./program param1=val1 param2=val2 param3=val3

Также параметрами могут быть и одиночные комбинации символов и слова вроде "-h" или "--help". Важный момент: разработчиками в своё время было принято соглашение о том, что первым параметром (нулевым элементом массива параметров, argv) будет являться путь до исполняемого файла программы, как он указан в строке запуска – этот параметр передаётся в программу автоматически и доступен всегда, когда программист использует параметры командной строки.

Пусть в нашем случае вывод на экран справки будет происходить при указании пары "help=true". Допишем/перепишем немного кода в нашей программе:

... 
#include <string.h> 
 
int main(int argc, void **argv) { 
    char *error, *paramStr; 
    void *libHandle; 
    float (*sum) (float, float); 
 
    /* убеждаемся в том, что в списке параметров не только путь до программы */ 
    if (1 < argc) { 
        paramStr = argv[1]; /* получаем первый после имени программы параметр */ 
        if (0 == strncmp("help=true", paramStr, strlen(paramStr))) { 
            fprintf(stdout, "Программа [%s] работает ", argv[0]); 
            fprintf(stdout, "с динамически подключаемыми библиотеками "); 
            fprintf(stdout, "и складывает два зашитых в код числа - 2 и 3 - "); 
            fprintf(stdout, "при помощи библиотечной функции.\n"); 
            exit(EXIT_SUCCESS); 
        } 
    } 
    ... 
}

В этом примере я не стал мудрствовать с разбором параметров и просто сравнил первый из них с известным значением при помощи библиотечной функции strncmp(). Конечно, простейшее решение работает только в простейшем случае, например, если запрос на подсказку окажется не первым параметром, то программа его проигнорирует и т.д. Позже мы воспользуемся готовой библиотекой для разбора параметров командной строки, которая сделает всю черновую работу правильно, и при этом у нас появится поддержка "длинных" и "красивых" имён параметров ("--help").

Повторяем приведённую выше команду сборки программы (самой программы, не библиотеки), запускаем файл на выполнение и любуемся на справку:

$ ./main help=true

Программа [./main] работает с динамически подключаемыми библиотеками и складывает два зашитых в код числа - 2 и 3 - при помощи библиотечной функции.

Работа в фоновом режиме ("демонизация")

Консоли и терминалы являются средствами взаимодействия пользователей с программами, однако программам-серверам, вроде нашей, "прямое" – через консоль – взаимодействие с пользователем не требуется. Поскольку в GNU/Linux все запускаемые из консоли программы по умолчанию связаны с консолью (в так называемом "интерактивном режиме"), нам потребуется предпринять некоторые усилия по "отключению" программы от консоли. Такие действия входят в мероприятия по "демонизации" программы, и про них говорят, что они уводят программу в фон или в фоновый режим.

Процесс "демонизации" довольно подробно описан в Linux Daemon HOWTO, поэтому снова не будем изобретать велосипед и воспользуемся изложенной в этом документе информацией. Ещё раз дополним код нашей программы и не забудем потом её пересобрать:

 ... 
#include <fcntl.h> 
#include <errno.h> 
#include <unistd.h> 
#include <syslog.h> 
#include <sys/types.h> 
#include <sys/stat.h> 
 
int main(int argc, void **argv) { 
    ... 
    pid_t pid; 
 
    /* Здесь идёт код проверки параметров командной строки. */ 
    ... 
 
    /* Здесь начинаем "демонизировать". Одного только закрытия 
     * дескрипторов стандартных потоков (ввода, вывода и 
     * ошибок) недостаточно для отключения от консоли – 
     * необходимо породить процесс-потомок, который к консоли 
     * привязан уже не будет. Это можно сделать при помощи функции fork(). 
     */ 
    pid = fork(); 
    if (pid < 0) { 
        fprintf(stderr, "РОДИТЕЛЬ: породить потомка не получилось: 
			%s\n", strerror(errno)); 
        exit(EXIT_FAILURE); 
    } 
 
    if (pid > 0) { 
        /* Порождение прошло успешно, а значение pid, большее 0, означает, 
         * что здесь мы в родительском процессе, который можно уже завершить. 
         */ 
        fprintf(stdout, "РОДИТЕЛЬ: потомок порождён, и родитель завершается.\n"); 
        exit(EXIT_SUCCESS); 
    } 
 
    /* здесь в будущем хорошо бы переходить в рабочий каталог программы, 
     * но пока что останемся в текущем. 
     */ 
    char dir[] = "."; 
    if ((chdir(dir)) < 0) { 
        /* Сменить каталог не получилось. */ 
        fprintf(stderr, "ПОТОМОК: ошибка смены каталога на [%s]: 
			%s\n", dir, strerror(errno)); 
        exit(EXIT_FAILURE); 
    } 
 
    /* Закрываем стандартные потоки – нашей программе они не нужны, 
     * а лазейку могут предоставить. 
     */ 
    close(STDIN_FILENO); 
    close(STDOUT_FILENO); 
    close(STDERR_FILENO); 
 
    /* Теперь пишем отладочные сообщения в системные журналы. 
     * Для этого нужно сначала подключить программу 
     * к демону, обслуживающему системные журналы. 
     */ 
    openlog("*** ДЕМОН ***", LOG_PID, LOG_LOCAL0); /* Подключили. */ 
    /* Вот и первое сообщение в журнале. */ 
    syslog(LOG_DEBUG, "ПОТОМОК: дескрипторы стандартных потоков закрыты."); 
 
    /* ТОТ САМЫЙ ПОЧТИ БЕСКОНЕЧНЫЙ ЦИКЛ */ 
    int flag = 1; 
    while (flag) { 
        /* С этого места начинается основная работа нашей программы. */ 
        libHandle = dlopen("./lib.so", RTLD_NOW); 
        if (NULL == libHandle) { 
            syslog(LOG_ERR, "ПОТОМОК: подключить библиотеку не получилось:
				%s.", dlerror()); 
            exit(EXIT_FAILURE); 
        } 
        *(void **) (&sum) = dlsym(libHandle, "sum"); 
        if ((error = dlerror()) != NULL) { 
            syslog(LOG_ERR, "ПОТОМОК: связать код функции не получилось: %s.", error); 
            exit(EXIT_FAILURE); 
        } 
        syslog(LOG_DEBUG, "ПОТОМОК: 2 + 3 = %f.", (*sum)(2, 3)); 
        dlclose(libHandle); 
        flag = 0; 
    } 
    syslog(LOG_DEBUG, "ПОТОМОК завершается."); 
    closelog(); /* Отключаемся от демона ведения журналов. */ 
    exit(EXIT_SUCCESS); 
}

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

Функция fork() создаёт новый процесс, который во многом (с точки зрения ядра) является копией того процесса, из которого эта функция была вызвана. Поскольку код у процессов один и тот же, нам необходимо знать, когда код выполняется в контексте родителя, а когда – в контексте потомка. Узнать это можно по значению, возвращённому fork(). 0 возвращается уже при работе в контексте потомка, а число, большее нуля – идентификатор процесса-потомка – возвращается в контексте родителя. Отрицательное число означает какую-то ошибку при порождении потомка, в этом случае работа продолжается в контексте родителя, а потомок, разумеется, не порождается.

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

Пока у нас из таких файлов только разделяемая библиотека, и программу мы запускаем из того же каталога, где находится её исполняемый файл, поэтому фактически смены каталога не происходит ("." означает текущий каталог, ".." – на уровень выше).

Затем происходит закрытие дескрипторов стандартных потоков. Тонкость здесь в том, что после закрытия потоков вывода ("обычного" и "ошибочного") все наши сообщения, которые мы будем отправлять на экран, там не появятся. Тем не менее, о происходящем с программой нам хочется знать, и для этого мы перенаправляем весь вывод в системные журналы. Подробности об использованных для этого функциях (openlog()/syslog()/closelog()) можно узнать в их мануалах. Сообщения с уровнем LOG_DEBUG скорее всего появятся в файле /var/log/debug, а с уровнем LOG_ERR – в файлах /var/log/messages или /var/log/syslog – это зависит от настроек отвечающего за журналы демона в вашей системе (обычно это syslogd). Для просмотра журналов скорее всего понадобятся права root. Можно воспользоваться утилитой tail, например, так:

# tail -f /var/log/messages

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

Поскольку демоны обычно работают непрерывно и столько же времени, сколько работает вся система, нам необходима периодичность выполнения программой заложенных в нее алгоритмов. Это как раз и происходит в "почти бесконечном цикле" while(). Возобновление цикла (выполнение полезных действий) обычно происходит либо после выжидания тайм-аута, либо при возникновении какого-то события. После прохода цикла программа возвращается в некоторое исходное состояние и либо засыпает (скажем, при помощи функции sleep()), либо блокируется ядром на ожидании какого-то события (появления данных из сети, готовности устройства на COM-порте и т.д.), после чего всё повторяется. Именно на такие "почти бесконечные циклы" и расходуют электроэнергию www-серверы по всему миру при обработке запросов браузеров пользователей.

В текущем примере цикл выполняется всего один раз, чего на данном этапе достаточно.

Автоматизация сборки программы при помощи make

У нас сейчас всего два файла с исходным кодом, но выписывать каждый раз команды их сборки уже надоедает. Дальше файлов будет больше, и зависимости между ними усложнятся. Конечно, можно перечислить все эти команды сборки в одном скрипте на языке командной оболочки системы, но это тоже недостаточно эффективно: как отслеживать зависимости, распараллеливать сборку и определять, что нужно пересобирать, а что – нет? Изобретать велосипед традиционно не стоит, можно воспользоваться готовым инструментом для этой задачи – утилитой make. Она считывает инструкции из указанного файла (по умолчанию: Makefile из текущего каталога) и выполняет их либо передаёт на выполнение внешним программам. Подробное описание make выходит за рамки нашего цикла, интересующимся могу предложить ознакомиться с документацией в Сети и книгу "Managing projects with GNU make" (её русский перевод и печатное издание мне пока не попадались).

Содержимое любого Makefile – это параметры (переменные) и сгруппированные по какому-либо признаку команды. Каждая такая группа создаётся для достижения конкретной частной цели. Разработка Makefile как раз и является разбиением процесса сборки конечной программы на отдельные шаги-цели и описанием этих шагов. Кроме того, make распознаёт в тексте Makefile свои собственные инструкции и особые символы. При этом утилита достаточно абстрактна и сама выполнением команд не занимается. Для этого порождаётся отдельный процесс с командным интерпретатором, что позволяет использовать make не только для сборки программ, а вообще для любой деятельности, если она хорошо разбивается на последовательность чётких шагов и для ее выполнения достаточно имеющихся в системе программ и распознаваемых интерпретатором команд.

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

all: main lib.so 
 
main: main.c 
<tab>gcc -rdynamic -o main main.c -ldl 
 
lib.so: lib.c 
<tab>gcc -shared -fPIC -o lib.so lib.c 
 
clean: 
<tab>rm -f main lib.so

Здесь "<tab>" означает, что отступы необходимо сделать при помощи табуляции, а не пробелов. Убедитесь также в том, что ваш текстовый редактор "честно" вставляет табуляцию, а не эмулирует её пробелами. Сохраняем приведенный код под именем Makefile в одном каталоге с нашими программой и библиотекой, а затем набираем команду make clean.

Скомпилированные файлы main и lib.so должны исчезнуть. После команды make они должны появиться вновь. Впредь нам больше не придётся беспокоиться об их сборке – make проверит дату изменения каждого файла и если окажется, что файлы с исходным кодом "моложе" исполняемых файлов, заново запустит их сборку (иначе – молча пропустит). Дальше у нас будет больше файлов, появятся дополнительные каталоги, и наш Makefile усложнится. Будем дорабатывать его по ходу разработки сервера.

Заключение

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


Ресурсы для скачивания

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Linux
ArticleID=458901
ArticleTitle=Пример разработки сервера с поддержкой пользовательских сессий на языке C в ОС GNU/Linux: Часть 1. Знакомство с окружением разработки.
publish-date=12242009