Разработка сетевых приложений на основе протокола
TCP/IP в среде Unix-подобных операционных систем

Жиганов Е.Д.

(Методические указания к лабораторному практикуму)

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

Предварительные замечания о сетевом порядке байтов

Как правило, в современных компьютерах минимальный элемент оперативной памяти, имеющий уникальный адрес, имеет длину 8 бит (1 байт). И, кроме того, процессоры умеют манипулировать как целым несколькими байтами: двумя, четырьмя, восемью, в зависимости от разрядности процессора. Хранение в памяти двух-, четырех- и восьмибайтовых слов, рассматриваемых как знаковые или беззнаковые целые числа, можно организовать по-разному. Именно, можно хранить самый младший (наименее значимый) байт числа по меньшему адресу, а можно наоборот, по меньшему адресу хранить самый старший (наиболее значимый) байт. Например, в процессорах семейства Intel используется первый способ, а в процессорах Motorola - второй. Поэтому, для того, чтобы компьютеры с разными в этом смысле процессорами могли обмениваться данными по сети, нужно договориться о том, в каком порядке байты будут передаваться по сети. Например, в семействе протоколов TCP/IP принят порядок, обратный по сравнению с тем, какой используется в процессорах Intel, то есть 2-х и 4-х байтовые числа должны передаваться, начиная с самого старшего байта. В этих протоколах в сетевом порядке байтов хранятся, в частности, IP-адрес и номер TCP-порта. Забота о преобразовании данных от локального порядка байтов к сетевому при передаче в сеть и от сетевого к локальному при приеме из сети лежит на программном обеспечении TCP/IP и на прикладном программисте. Как правило, среди функций, входящих в состав интерфейса прикладных программ, имеются функции для преобразования чисел из локального порядка байтов к сетевому и наоборот. К таким функциям относятся (прототипы описаны в заголовочном файле netinet/in.h):

unsigned long int htonl(unsigned long int hostlong);

unsigned short int htons(unsigned short int hostshort);

unsigned long int ntohl(unsigned long int netlong);

unsigned short int ntohs(unsigned short int netshort);

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

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

Разработка сервера

Основные действия, которые должна выполнить программа-сервер:

Ниже последовательно и подробно описаны все эти шаги.

Создание сокета

Эта операция производится посредством вызова функции socket(), имеющей следующий прототип:

int socket(int domain, int type, int protocol);

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

Сокет имеет тип, указанный вторым параметром. Тип сокета определяет семантику взаимодействия и может принимать следующие значения:

Следует заметить, что может оказаться так, что в рамках данного семейства протоколов реализованы не все типы сокетов.

Третий параметр указывает номер конкретного протокола в рамках указанного семейства для указанного типа сокета. Как правило, существует единственный протокол для каждого типа сокета внутри каждого семейства, однако, их может быть и больше. В таких случаях для получения информации о протоколах можно воспользоваться функциями getprotobyname(), getprotobynumber() или группой setprotoent(), getprotoent(), endprotoend().

Чтобы создать, например, TCP- сокет, используем следующий код:

#include <sys/types.h>

#include <sys/socket.h>

#include <netinet/in.h>

...

int tcp_socket;

tcp_socket = socket(PF_INET, SOCK_STREAM, 0);

...

Именование сокета

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

int bind(int sockfd, struct sockaddr *my_addr, int addrlen);

При успешном завершении функция возвращает 0, при ошибке - -1. Первый параметр представляет собой дескриптор сокета, возвращенный функцией socket(). Второй - это то имя (локальный адрес), который мы хотим дать сокету. Третий - длина второго параметра в байтах. Форматы адресов различаются для различных семейств протоколов и различных семейств адресов. Структура sockaddr - это 'родовая' (generic) структура, которая выглядит вот так

struct sockaddr {

unsigned short int sa_family;

char sa_data[14];

}

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

struct sockaddr_in {

sa_family_t sin_family;

u_int16_t sin_port;

struct in_addr sin_addr;

unsigned char sin_zero[8];

}

Тип sa_family_t эквивалентен типу unsigned short int. Структура in_addr состоит всего из одного элемента и имеет следующий формат

struct in_addr {

u_int32_t s_addr;

}

Поле sin_family может принимать только одно значение, а именно AF_INET. Следующее поле в структуре sockaddr_in - это TCP-порт. Поле s_addr, входящее в состав структуры in_addr - IP-адрес. Наконец, массив sin_zero дополняет структуру sockaddr_in до размера структуры sock_addr. Отметим, что sin_port и s_addr хранятся в сетевом порядке байтов.

Таким образом, для именования созданного TCP-сокета программа должна заполнить структуру sockaddr_in и вызвать функцию bind(), передав ей указатель на эту структуру вторым параметром:

#include <sys/types.h>

#include <sys/socket.h>

#include <netinet/in.h>

#include <strings.h> /* bzero() */

...

#define SOMEPORT 2001

...

int tcp_socket;

struct sockaddr_in socket_addr;

...

tcp_socket = socket(PF_INET, SOCK_STREAM, 0);

socket_addr.sin_family = AF_INET;

socket_addr.sin_port = htons(SOMEPORT);

/* в сетевом порядке байтов*/

socket_addr.sin_addr.s_addr = INADDR_ANY;

/* локальная машина */

bzero(&socket_addr.sin_zero, sizeof(socket_addr.sin_zero));

/* оставшуюся часть структуры обнуляем */

bind(tcp_socket, (struct sockaddr*)&socket_addr, sizeof(struct sockaddr));

...

Переключение сокета в режим прослушивания

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

int listen(int sockfd, int backlog);

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

Таким образом, после вот такого кода

#include <sys/types.h>

#include <sys/socket.h>

#include <netinet/in.h>

#include <strings.h> /* bzero() */

...

#define SOMEPORT 2001

#define PENDCONR 10

...

int tcp_socket;struct sockaddr_in socket_addr;

...

tcp_socket = socket(PF_INET, SOCK_STREAM, 0);

socket_addr.sin_family = AF_INET;

socket_addr.sin_port = htons(SOMEPORT);

socket_addr.sin_addr.s_addr = INADDR_ANY;

bzero(&socket_addr.sin_zero, sizeof(socket_addr.sin_zero));

bind(tcp_socket, (struct sockaddr*)&socket_addr, sizeof(struct sockaddr));

listen(tcp_socket, PENDCONR);

наш сокет готов принимать соединения от клиентов.

Принятие запросов на соединение от клиентов

В ответ на попытку установления соединения со стороны клиента сервер должен принять это соединение. Это делается с помощью функции accept(), прототип которой выглядит так:

int accept(int sockfd, struct sockaddr *remote_addr, int *addrlen);

Первый параметр - это дескриптор сокета, который должен быть связан с именем посредством функции bind() и переведен в прослушивающий режим вызовом функции listen() до вызова accept(). Функция accept() выполняет следующие действия: извлекает из очереди соединений, ожидающих обработки, первый запрос и создает новый сокет с такими же свойствами, как и sockfd. Если в момент вызова accept() в очереди не было запросов на соединение, то поведение функции зависит от того, в каком режиме находится сокет, блокирующем или неблокирующем. В первом случае программа блокируется до прихода запроса на соединение, во втором функция accept() возвращается с ошибкой (errno будет EWOULDBLOCK или EAGAIN). Функция возвращает дескриптор вновь созданного сокета, который нужно использовать для обмена данными, но нельзя для приема соединений. Первоначальный сокет sockfd остается открытым и служит для принятия последующих соединений на этом порту. Второй параметр заполняется самой функцией и по завершении вызова будет содержать информацию об адресе того, кто присоединился (имя удаленного сокета). Третий параметр одновременно является как входным, так и выходным. При вызове он должен содержать размер объекта, на который показывает указатель remote_addr, а по завершении он будет содержать фактическую длину адреса.

#include <sys/types.h>

#include <sys/socket.h>

#include <netinet/in.h>

#include <strings.h>

...

#define SOMEPORT 2001

#define PENDCONR 10

...

int listen_socket, data_socket;

int addr_size;

struct sockaddr_in local_addr, remote_addr;

...

listen_socket = socket(PF_INET, SOCK_STREAM, 0);

local_addr.sin_family = AF_INET;

local_addr.sin_port = htons(SOMEPORT);

local_addr.sin_addr.s_addr = INADDR_ANY;

bzero(&local_addr.sin_zero, sizeof(local_addr.sin_zero));

bind(listen_socket, (struct sockaddr*)&local_addr, sizeof(struct sockaddr));

listen(listen_socket, PENDCONR);

...

addr_size = sizeof(struct sockaddr_in);

data_socket = accept(listen_socket, (struct sockaddr*)&remote_addr, &addr_size);

...

После установления соединения программа может принимать данные из сокета и посылать их в сокет.

Чтение из сокета

Прием данных из сети можно осуществлять посредством функций recvfrom(), recvmsg(), recv() и read(). Первые две функции можно использовать для чтения данных вне зависимости от того, является ли сокет ориентированным на соединение или нет. Вторые две используются для приема данных из сокета, ориентированного на соединение. Функция read() - это обычная функция чтения, с помощью которой мы читаем из файлов и т.п. По сравнению с ней функция recv() ориентирована на работу исключительно с сокетами и обладает более богатыми возможностями. Рассмотрим подробнее функцию recv(). Она имеет следующий прототип

int recv(int sockfd, void *buf, int len, unsigned int flags);

и возвращает при успешном завершении число прочитанных байт, а при ошибке - -1, при этом, как обычно, переменная errno принимает соответствующее значение. Первый параметр функции - сокет, из которого нужно прочитать данные, второй - указатель на область памяти, в которую нужно записать принятые данные, третий - сколько байт читать. С помощью четвертого параметра можно управлять поведением функции. Например, указав в качестве флага MSG_PEEK, мы прочитаем данные из начала очереди, но после чтения они останутся в очереди. Разные флаги можно комбинировать, объединяя соответствующие константы посредством операции побитного ИЛИ. Отметим, что по умолчанию только что созданный сокет является блокирующим. В отношении функции recv() это означает, что если в момент ее вызова данных нет, она блокируется до тех пор, пока они не придут из сети.

Запись в сокет

Посылку данных в сеть можно осуществлять посредством функций sendto(), sendmsg(), send() и write(). Первые две функции можно использовать для записи данных вне зависимости от того, является ли сокет ориентированным на соединение или нет. Вторые две используются для записи данных в сокет, ориентированный на соединение. Функция write() - это обычная функция записи, с помощью которой мы пишем в файлы и т.п. По сравнению с ней функция send() ориентирована на работу исключительно с сокетами и обладает более богатыми возможностями. Рассмотрим подробнее функцию send(). Она имеет следующий прототип

int send(int sockfd, void *buf, int len, unsigned int flags);

и возвращает при успешном завершении число записанных байт, а при ошибке - -1, при этом, как обычно, переменная errno принимает соответствующее значение. Первый параметр функции - сокет, в который нужно записать данные, второй - указатель на область памяти, из которой нужно взять данные, третий - сколько байт записать. С помощью четвертого параметра можно управлять поведением функции. Например, указав в качестве флага MSG_DONTROUTE, мы заставим TCP/IP посылать данные в обход обычных средств маршрутизации непосредственно на сетевой интерфейс получателя, что используется, например, различными диагностическими программами и маршрутизаторами. Разные флаги можно комбинировать, объединяя соответствующие константы посредством операции побитного ИЛИ.

Закрытие сокета

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

int close(int fd);

Функции нужно передать дескриптор сокета, который нужно закрыть. При успешном завершении функция возвращает 0, при ошибке - -1.

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

#include <sys/types.h>

#include <sys/socket.h>

#include <netinet/in.h>

#include <strings.h>

...

#define SOMEPORT 2001

#define PENDCONR 10

#define BUFSIZE 10

...

int listen_socket, data_socket;

int addr_size;

struct sockaddr_in local_addr, remote_addr;

char out_buf[BUFSIZE] = (1,2,3,4,5,6,7,8,9,10);

char in_buf[BUFSIZE];

...

listen_socket = socket(PF_INET, SOCK_STREAM, 0);

local_addr.sin_family = AF_INET;

local_addr.sin_port = htons(SOMEPORT);

local_addr.sin_addr.s_addr = INADDR_ANY;

bzero(&local_addr.sin_zero, sizeof(local_addr.sin_zero));

bind(listen_socket, (struct sockaddr*)&local_addr, sizeof(struct sockaddr));

listen(listen_socket, PENDCONR);

...

addr_size = sizeof(struct sockaddr_in);

data_socket = accept(listen_socket, (struct sockaddr*)&remote_addr, &addr_size);

...

send(data_socket, (void*)&out_buf, sizeof(out_buf), 0);

...

recv(data_socket, (void*)&in_buf, sizeof(in_buf), 0);

...

close(data_socket);

...

close(listen_socket);

...

Разработка клиента

Минимальный набор действий для клиента:

Создание сокета описано выше. Чтобы обмениваться данными с сервером, клиент должен установить с ним соединение.

Установление соединения

Данная операция осуществляется посредством вызова функции connect(), имеющей следующий прототип

int connect(int sockfd, struct sockaddr *remote_addr, int addrlen);

При успешном завершении функция возвращает 0, при ошибке - -1. Первый параметр функции - дескриптор сокета, возвращенный вызовом socket(), второй - указатель на структуру, содержащую адрес удаленного сокета, которую нужно заполнить перед вызовом connect() и третий - длина структуры, на которую указывает remote_addr в байтах. Отметим, что вызов bind() не является необходимым для клиента, так как назначение порта сокету функция connect() сделает сама, а клиенту, вообще говоря, все равно, какой у него порт.

Ниже приведены фрагменты кода, содержащие основные шаги, которые должна выполнить программа-клиент для того, чтобы обменяться данными с программой-сервером.

#include <sys/types.h>

#include <sys/socket.h>

#include <netinet/in.h>

#include <arpa/inet.h>

#include <strings.h>

...

#define SOMEPORT 2001

#define BUFSIZE 10

#define SERVER_IP "192.168.1.1"

...

int data_socket;

struct server_addr;

struct in_addr server_ip;

char out_buf[BUFSIZE] = (1,2,3,4,5,6,7,8,9,10);

char in_buf[BUFSIZE];

...

data_socket = socket(PF_INET, SOCK_STREAM, 0);

server_addr.sin_family = AF_INET;

server_addr.sin_port = htons(SOMEPORT);

inet_aton(SERVER_IP, &server_ip);

server_addr.sin_addr.s_addr = server_ip.s_addr;

bzero(&server_addr.sin_zero, sizeof(server_addr.sin_zero));

...

connect(data_socket, (struct sockaddr*)&remote_addr, sizeof(struct sockaddr) );

...

send(data_socket, (void*)&out_buf, sizeof(out_buf), 0);

...

recv(data_socket, (void*)&in_buf, sizeof(in_buf), 0);

...

close(data_socket);

В этом отрывке используется не описанная ранее функция inet_aton(). Эта функция наряду с другими применяется для манипуляций с IP-адресами. Прототипы всех таких функций определены в заголовочном файле arpa/inet.h следующим образом:

int inet_aton(const char *cp, struct in_addr *inp);

unsigned long int inet_addr(const char *cp);

char *inet_ntoa(struct in_addr in);

unsigned long int inet_network(const char *cp);

struct in_addr inet_makeaddr(int net, int host);

unsigned long int inet_lnaof(struct in_addr in);

unsigned long int inet_netof(struct in_addr in);

Функция inet_aton() преобразует IP-адрес, задаваемый первым аргументом из стандартной формы в виде десятичных чисел, разделенными точками в бинарную в сетевом порядке байтов. При успешном завершении возвращается ненулевое значение, а если адрес неправильный, то 0. Результат преобразования помещается в структуру, на которую указывает второй параметр.

Функция inet_ntoa() выполняет обратное преобразование, то есть преобразует IP-адрес, заданный в двоичном виде в сетевом порядке байтов в стандартную форму числа-точки. Результат хранится в статическом буфере (возвращается указатель), поэтому при последующих вызовах он будет переписан.

Функция inet_addr() преобразует IP-адрес, задаваемый первым аргументом из стандартной формы в виде десятичных чисел, разделенными точками в бинарную в сетевом порядке байтов. При успешном завершении возвращается результат преобразования, в противном случае INADDR_NONE (-1). Эта функция устаревшая, поскольку -1=255.255.255.255 представляет собой корректный IP-адрес. Поэтому пользуйтесь inet_aton().

Функция inet_network() извлекает из IP-адреса в стандартной текстовой записи числа-точки номер сети в двоичном виде в локальном порядке байтов. При некорректном адресе возвращается -1.

Функция inet_makeaddr() составляет из номера сети net и номера узла host, заданных в локальном порядке байтов, IP-адрес в сетевом порядке байтов.

Функция inet_lnaof() извлекает из IP-адреса, заданного ее аргументом, часть, соответствующую узлу (в локальном порядке байтов).

Функция inet_netof() извлекает из IP-адреса, заданного ее аргументом, часть, соответствующую сети (в локальном порядке байтов).

Работа со службой доменных имен

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

struct hostent *gethostbyname(const char *name);

struct hostent *gethostbyaddr(const char *addr, int len, int type);

void sethostent(int stayopen);

void endhostent(void);

void herror(const char *s);

const char *hstrerror(int err);

Структура hostent, определенная также в заголовочном файле netdb.h, имеет следующий формат:

struct hostent {

char *h_name;

char **aliases;

int h_addrtype;

int h_length;

char **h_addr_list;

}

#define h_addr h_addr_list[0];

Первое поле структуры - официальное имя узла;

Второе - массив альтернативных имен (псевдонимов) узла;

Третье - тип адреса узла (в настоящее время всегда AF_INET);

Поле h_length - длина адреса в байтах;

Поле h_addr_list - массив адресов узла в сетевом порядке байтов, заканчивающийся нулем.

Функция gethostbyname() возвращает указатель на структуру hostent для узла, указанного ее единственным параметром, который может быть доменным именем, IPv4-адресом в стандартной записи или IPv6-адресом. Память под структуру выделять не надо, достаточно объявить указатель. Сама структура хранится в памяти ядра операционной системы. Отметим, что если параметр name представляет собой IPv4 или IPv6 адрес, то процедура разрешения адреса (обращение к DNS и т.п.) не выполняется, а name просто копируется в поле h_name структуры hostent. Если нужно получить доменное имя по IP-адресу, используйте функцию gethostbyaddr().

Функция gethostbyaddr() возвращает указатель на структуру hostent для узла, адрес которого (в двоичной форме в сетевом порядке байтов) указан первым параметром функции. Второй параметр задает длину адреса в байтах, а третий - тип адреса. Единственно допустимый тип адреса в настоящее время - AF_INET.

Для получения информации об узлах сети функции gethostbyname() и gethostbyaddr() используют комбинации следующих методов: обращение к службе доменных имен (DNS), поиск по файлу /etc/hosts и обращение к службе сетевой информации (NIS) в порядке, определяемом содержимым строки order в файле /etc/host.conf.

Функция sethostent() (если stayopen равно 1) указывает, что для обращений к DNS нужно использовать соединение и что это соединение не должно закрываться между последовательными запросами. Если же stayopen равно 0 (FALSE), то запросы к DNS будут выполняться с использованием UDP датаграмм.

Функция endhostent() заканчивает использование TCP соединения для выполнения запросов к DNS.

Функция herror() выводит сообщение об ошибке, соответствующее текущему значению переменной h_errno на стандартное устройство вывода для сообщений об ошибках. Тут надо пояснить, что описываемые функции вместо переменной errno для хранения текущего значения номера ошибки используют переменную h_errno.

Функция hstrerror() принимает в качестве параметра номер ошибки (обычно h_errno) и возвращает строку, содержащую соответствующее сообщение.

Получение информации о стандартных сетевых службах и протоколах

В комплект программной системы, составляющей стек TCP/IP, входят базы данных по стандартным сетевым службам и протоколам. Базы представляют собой текстовые файлы, содержащие информацию об именах, псевдонимах и портах различных сетевых служб и номерах и именах протоколов, соответственно. В операционных системах Unix эти файлы, как правило, называются /etc/services и /etc/protocols. Для получения информации из них имеется ряд функций, прототипы которых определены в заголовочном файле netdb.h следующим образом:

(функции для работы со службами)

struct servent *getservbyname(const char *name, const char *proto);

struct servent *getservbyport(int port, const char *proto);

struct servent *getservent(void);

void setservent(int stayopen);

void endservent(void);

(функции для работы с протоколами)

struct protoent *getprotobyname(const char *name);

struct protoent *getprotobynumber(int proto);

void setprotoent(int stayopen);

struct protoent *getprotoent(void);

void endprotoend(end);

Структуры servent и protoent определены в том же файле следующим образом:

struct servent {

char *s_name; /* официальное имя службы */

char **aliases; /* список псевдонимов */

int s_port; /* номер порта в сетевом порядке байтов */

char *s_proto; /* протокол */

}

struct protoent {

char *p_name; /* официальное имя протокола */

char **aliases; /* список псевдонимов протокола */

int p_proto; /* номер протокола */

}

Функция getservbyname() возвращает указатель на структуру servent, содержащую информацию из файла /etc/services о службе, имя которой совпадает с именем, указанным первым параметром функции, а протокол - с именем протокола, указанным ее вторым параметром. Если заданная служба не существует, возвращается константа NULL.

Функция getservbyport() делает то же самое, но в первым параметром ей нужно передать номер порта в сетевом порядке байтов.

Функция setservent() открывает файл /etc/services и устанавливает указатель файла на начало. Если при этом параметр stayopen имеет значение 1, файл не будет закрываться вызовами getservbyname() и getservbyport().

Функция getservent() читает очередную строку из файла /etc/services, заполняет структуру servent соответствующей информацией и возвращает указатель на эту структуру (NULL в случае, если произошла ошибка или достигнут конец файла).

Функция endservent() закрывает файл /etc/services.

Функции для работы с протоколами работают вполне аналогичным образом.