Чуть более продвинутая техника
7.2 select() — мультиплексирование синхронного I/O
7.3 Обработка частичного send()
7.4 Сериализация — как упаковывать данные
7.5 Сын инкапсуляции данных
7.6 Бродкаст пакеты — Hello, World!
На самом деле тут нет ничего действительно продвинутого, но это уже следующий уровень по сравнению с базовыми знаниями. Фактически, если вы дошли до этого места, вы уже сказать, что освоили основы сетевого программирования! Поздравляю!
Отсюда мы вступаем в мир более эзотерических вещей, которые вы можете захотеть изучить.
7.1 Блокировка
Блокировка. Вы уже слышали о ней, но что же это такое всё-таки? В двух словах, «блок» — это практически то же самое, что «сон». Вы, наверно, заметили, что при запуске lilstener, пока не приходит пакет — он просто сидит и слушает сеть, ничего не делая. То, что он делает, называется rcvfrom(); никаких данных не поступает, и поэтому rcvfrom() говорит «блок» (вот оно, отсюда программа спит), пока на сокет не поступят новые данные.
Многие функции являются блокирующими. accept() — блокирующая, и recv() тоже блокирующая. Причина, по которой они блокируют программу — то, что им позволили это делать. Когда вы впервые создавали дескриптор вызовом socket(), ядро сделало его блокирующим. Если вам не нужен блокирующий сокет, вы должны вызвать fcntl():
#include <fcntl.h>
.
.
.
sockfd = socket(PF_INET, SOCK_STREAM, 0);
fcntl(sockfd, F_SETFL, O_NONBLOCK);
.
.
.
Обьявив сокет неблокирующим, вы можете эффективно «опрашивать» сокет о наличии информации: если попытаться прочитать что-либо из неблокирующего сокета при отсутствии в нём данных, он вернёт -1 и значение ошибки будет установлено в EWOULDBLOCK.
Однако, вобще-то, такой тип опроса — не очень хорошая идея. Если вы оставите свою программу в режиме такого активного ожидания данных на сокете, вы будете бесконечно «сосать» процессорное время, а такое поведение программ постепенно выходит из моды. Более элегантное решение проверки, есть ли данные на сокете, будет показано в следующем разделе о select().
7.2 select() — мультиплексирование синхронного I/O
Эта функция может показаться немного странной, но она полезна. Рассмотрим следующую ситуацию: вы — сервер, и хотите слушать входящие соединения, а так же читать данные из уже установленного.
Вы скажете — нет проблем, просто делаем accept() и пару recv(). Не так быстро! Что делать, если программа заблокирована вызовом accept()? Как вы собираетесь в это же время читать данные с помощью recv()? «Используем неблокирующие сокеты!» Ни в коем случае! Вы же не хотите положить процессор, верно? Что же тогда?
Функция select() даёт вам возможность следить за несколькими сокетами одновременно. Она скажет вам, какие из них готовы для чтения, какие для записи, а на каких возникли ошибки, если вам действительно нужно это знать.
Как уже говорилось, в наше время select(), хоть и является очень портируемым — всё же один из самых медленных способов мониторинга сокетов. Одна из возможных альтернатив — libevent, или что-то аналогичное, что инкапсулирует платформо-зависимые вещи, связанные с получением уведомлений сокетов.
Ладно, рассмотрим синтаксис select():
#include <sys/types.h>
#include <unistd.h>int select(int numfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
Функция мониторит «сет» файловых дескрипторов: чтения, записи, исключений. Если вы хотите узнать, можете ли вы произвести чтение из стандартного ввода и какого-нибуть сокета «sockfd», просто добавьте 0 и sockfd в сет readfs. Параметр numfds должен быть установлен в значение «самый высокий дескриптор плюс один». В этом примере он должен быть sockfd+1, поскольку сокет в любом случае больше, чем 0 (стандартный ввод).
Когда select() отработает, readfds будут модифицированы так, чтобы отразить, какие из них доступны для чтения. Вы можете проверить их макросом FD_ISSET(), подробности ниже.
Прежде, чем продвигаться гораздо дальше, я хочу поговорить о том, как манипулировать этими сетами. Каждый сет имеет тип fd_set. Следующий макрос оперирует этим типом:
FD_SET(int fd, fd_set *set); | добавляет дескриптор fd в сет |
FD_CLR(int fd, fd_set *set); | удаляет дескриптор fd из сета |
FD_ISSET(int fd, fd_set *set); | возвращает значение, есть ли дескриптор fd в сете set |
FD_ZERO(fd_set *set); | очищает все элементы сета |
Наконец, что это за странная структура timeval? Ну, не всегда нужно бесконечно ждать, что кто-то пошлёт вам данные. Возможно, вы хотите каждые 96 секунд печатать в терминал «Still Going…», даже если ничего не произошло. Эта структура позволяет указать таймаут. Если время истекло и select() так и не нашел готовых файловых дескрипторов, он вернёт контроль, чтобы вы могли продолжить работу.
Структура timeval содержит следующие поля:
int tv_sec; // seconds
int tv_usec; // microseconds
};
Просто установите tv_sec во время ожидания в секундах, а tv_usec — в микросекундах. Именно микро, а не миллисекунды. В миллисекунде 1000 микросекунд, и 1000 миллисекунд в секунде. То есть 1000000 микросекунд в одной секунде. Почему «usec»? «u» — похожа да греческую букву μ (Mu), которая используется для обозначения «микро».
Вау! Таймер с разрешением в микросекунды! Ну, особо на это не рассчитывайте. В любом случае придётся ждать не меньше стандартного кванта Unix, вне зависимости от того, какой интервал вы установите.
Другие интересные вещи: если вы установите поля в структуре timeval в 0, select() будет таймаутить немедленно, эффективно проверяя все дескрипторы в ваших сетах. Если вы установите интервалы в NULL, таймаута не произойдёт никогда, и придётся ждать, пока не будет готов один из дескрипторов. Наконец, если вас не напрягает ожидание определённого сета, вы можете просто передать select()’у NULL вместо структуры timeval.
Следующий пример кода ждёт 2.5 сек., пока что-то не придёт на стандартный ввод:
** select.c — a select() demo
*/#include <stdio.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
#define STDIN 0 // файловый дескриптор стандартного ввода
int main(void)
{
struct timeval tv;
fd_set readfds;
tv.tv_sec = 2;
tv.tv_usec = 500000;
FD_ZERO(&readfds);
FD_SET(STDIN, &readfds);
// writefds и exceptfds нам не важны:
select(STDIN+1, &readfds, NULL, NULL, &tv);
if (FD_ISSET(STDIN, &readfds))
printf(«A key was pressed!n»);
else
printf(«Timed out.n»);
return 0;
}
Если вы находитесь в буферизующем терминале, вам нужно нажать ВВОД, иначе произойдёт таймаут.
Сейчас многие из вас могут подуматть, что это отличный способ ожидания данных от дейтаграммных сокетов, и вы правы: бывает и так. Некоторые unix-системы могут использовать select() таким образом, а некоторые нет. Касательно именно вашей системы, можно узнать из ваших локальных man-страниц.
Некоторые операционные системы обновляют структуру timeval, чтобы отразить, сколько времени осталось до таймаута. А некоторые — нет. Не полагайтесь на эту особенность, если хотите сохранить кроссплатформенность (используйте gettimeofday(), если вам нужно отслеживать прошедшее время. Лениво, я знаю, но так делать правильнее.)
Что случится, если сокет в сете закрывает соединение? В этом случае select() возвращает дескриптор как «готовый к чтению». Когда вы пытаетесь произвести над ним recv(), функция вернёт 0. таким образом вы узнаете, что клиент закрыл соединение.
Ещё одно интересное замечание о select(): если у вас слушающий сокет, вы можете проверить, есть ли у него новые соединения, поместив его дескриптор в сет readfds.
И это, друзья мои, был краткий обзор всемогущей функции select().
Но, по многочисленным просьбам, вот более глубокий пример. К сожалению, разница между совсем-простым-примером, выше, и этом — значительна. Взглянув на пример, прочитайте описание после него.
Эта программа работает как простой сервер многопользовательского чата. Запустите его в одном терминале, затем соединитесь к нему из нескольких других окон («telnet localhost 9034»). Когда вы введёте что-нибуть в одной телнет-сессии, это что-то появится во всех других.
** selectserver.c — сервер многопользовательского чата
*/#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#define PORT «9034» // порт, который мы слушаем
// получаем sockaddr, IPv4 или IPv6:
void *get_in_addr(struct sockaddr *sa)
{
if (sa->sa_family == AF_INET) {
return &(((struct sockaddr_in*)sa)->sin_addr);
}
return &(((struct sockaddr_in6*)sa)->sin6_addr);
}
int main(void)
{
fd_set master; // главный сет дескрипторов
fd_set read_fds; // временный сет дескрипторов для select()
int fdmax; // макс. число дескрипторов
int listener; // дескриптор слушающего сокета
int newfd; // дескриптор для новых соединений после accept()
struct sockaddr_storage remoteaddr; // адрес клиента
socklen_t addrlen;
char buf[256]; // буфер для данных клиента
int nbytes;
char remoteIP[INET6_ADDRSTRLEN];
int yes=1; // для setsockopt() SO_REUSEADDR, ниже
int i, j, rv;
struct addrinfo hints, *ai, *p;
FD_ZERO(&master); // очищаем оба сета
FD_ZERO(&read_fds);
// получаем сокет и биндим его
memset(&hints, 0, sizeof hints);
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE;
if ((rv = getaddrinfo(NULL, PORT, &hints, &ai)) != 0) {
fprintf(stderr, «selectserver: %sn», gai_strerror(rv));
exit(1);
}
for(p = ai; p != NULL; p = p->ai_next) {
listener = socket(p->ai_family, p->ai_socktype, p->ai_protocol);
if (listener < 0) {
continue;
}
// избегаем ошибки «address already in use»
setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(int));
if (bind(listener, p->ai_addr, p->ai_addrlen) < 0) {
close(listener);
continue;
}
break;
}
// если мы попали сюда, значит мы не смогли забиндить сокет
if (p == NULL) {
fprintf(stderr, «selectserver: failed to bindn»);
exit(2);
}
freeaddrinfo(ai); // с этим мы всё сделали
// слушаем
if (listen(listener, 10) == —1) {
perror(«listen»);
exit(3);
}
// добавляем слушающий сокет в мастер-сет
FD_SET(listener, &master);
// следим за самым большим номером дескриптора
fdmax = listener; // на данный момент это этот
// главный цикл
for(;;) {
read_fds = master; // копируем его
if (select(fdmax+1, &read_fds, NULL, NULL, NULL) == —1) {
perror(«select»);
exit(4);
}
// проходим через существующие соединения, ищем данные для чтения
for(i = 0; i <= fdmax; i++) {
if (FD_ISSET(i, &read_fds)) { // есть!
if (i == listener) {
// обрабатываем новые соединения
addrlen = sizeof remoteaddr;
newfd = accept(listener,
(struct sockaddr *)&remoteaddr,
&addrlen);
if (newfd == —1) {
perror(«accept»);
} else {
FD_SET(newfd, &master); // добавляем в мастер-сет
if (newfd > fdmax) { // продолжаем отслеживать самый большой номер дескиптора
fdmax = newfd;
}
printf(«selectserver: new connection from %s on «
«socket %dn»,
inet_ntop(remoteaddr.ss_family,
get_in_addr((struct sockaddr*)&remoteaddr),
remoteIP, INET6_ADDRSTRLEN),
newfd);
}
} else {
// обрабатываем данные клиента
if ((nbytes = recv(i, buf, sizeof buf, 0)) <= 0) {
// получена ошибка или соединение закрыто клиентом
if (nbytes == 0) {
// соединение закрыто
printf(«selectserver: socket %d hung upn», i);
} else {
perror(«recv»);
}
close(i); // bye!
FD_CLR(i, &master); // удаляем из мастер-сета
} else {
// у нас есть какие-то данные от клиента
for(j = 0; j <= fdmax; j++) {
// отсылаем данные всем!
if (FD_ISSET(j, &master)) {
// кроме слушающего сокета и клиента, от которого данные пришли
if (j != listener && j != i) {
if (send(j, buf, nbytes, 0) == —1) {
perror(«send»);
}
}
}
}
}
} // Закончили обрабатывать данные от клиента
} // Закончили обрабатывать новое входящее соединение
} // Закончили цикл по дескрипторам
} // Закончили for(;;) — А вы-то думали, что это никогда не кончится!
return 0;
}
Заметьте, что у меня в коде два сета дескрипторов: мастер и read_fds. Во-первых, мастер содержит все дескрипторы сокетов, которые подключены в настоящее время, а также дескриптор слушающего сокета.
Причина для создания мастер-сета — то, что select() на самом деле изменяет сет, который вы ему передаёте для учёта сокетов, которые готовы к чтению. Поскольку я должен следить за соединениями от одного вызова select() до следующего, я должен их куда-то безопасно сохранять. В последний момент я копирую мастер в read_fds, а затем вызываю select().
Но разве это не означает, что каждый раз, когда у меня возникает новое соединение, я должен добавить его в мастер-сет? Да! И каждый раз, когда соединение закрывается, оно должно быть удалено из мастер-сета? Да, должно.
Заметьте, я проверяю также, готов ли listener к чтению. Если готов — это значит, что есть новое ожидающее соединение; я принимаю его и добавляю в сет. Аналогичным образом, когда клиентское соединение готово к чтению и recv() возвращает 0, я должен удалить его из мастер-сета.
Если клиентский recv() возвращает не-ноль, я знаю, что получены какие-то данные. Тогда я бери их, прохожу циклом по мастер-сету и отправляю всем остальным подключенным клиентам.
И это, друзья мои, более-чем-простой обзор всемогущей функции select().
Кроме того, вот вам бонусная запоздалая мысль: есть ещё одна функция под названием poll(), которая ведёт себя так же, как select(), но использует другую систему управления сетом дескрипторов. Читайте дальше!
7.3 Обработка частичного send()
Помните, в разделе о функции send() я говорил, что send() может не отправить все данные, которые вы попросилии её отправить? То есть например, вы хотите отправить 512 байт, а отправляются только 412. Что случилось с оставшимися 100 байтами?
Они всё ещё находятся в вашем маленьком буфере, ожидая отправки. По каким-то причинам, вам неподконтрольным, ядро решило не отправлять данные одним куском, и теперь, друг мой, ваше дело — всё же отправить данные целиком.
Вы могли бы написать функцию вроде этой:
#include <sys/socket.h>int sendall(int s, char *buf, int *len)
{
int total = 0; // как много байт мы отправляем
int bytesleft = *len; // как много байт осталось отправить
int n;
while(total < *len) {
n = send(s, buf+total, bytesleft, 0);
if (n == —1) { break; }
total += n;
bytesleft -= n;
}
*len = total; // число фактически отосланных байт
return n==-1?-1:0; // вернём -1 при ошибке и 0 при успехе
}
В этом примере «s» — этосокет, в который вы хотите отправить данные; «buf» — буфер, содержащий данные; и len — указатель на int, содержащий размер данных в байтах.
Функция возвращает -1 при ошибке (а описание её засунуто в errno вызовом send()). Кроме того, число фактически отправленных байтов возвращается в «len». Если произошла ошибка, это будет ровно то число байтов, которое вы просили передать. sendall(), потея и отдуваясь, отправит-таки все данные, если не произойдёт ошибки. Если произойдёт — тут же вернёт вам управление.
Для полноты картины вот вам пример её использования:
int len;len = strlen(buf);
if (sendall(s, buf, &len) == —1) {
perror(«sendall»);
printf(«We only sent %d bytes because of the error!n», len);
}
Что происходит на принимающей стороне, когда приходит только часть пакетов? Если пакеты обычно бывают переменной длинны, как «приёмник» узнает, когда заканчивается один пакет и начинается другой? Да, в сценариях реального мира всё намного сложнее! Вы, наверное, пока что помните что-то об инкапсуляции, которую мы рассматривали чуть раньше? Детально о ней — дальше!
7.4. Сериализация — как упаковывать данные
Как видите, в сущности передавать по сети текстовые данные — достаточно просто. Но что делать, если нам нужно переслать какие-нибуть бинарные данные, например int или float? Оказывается, у вас есть несколько вариантов.
1. Преобразовать числа в текст функцией sprintf(), а затем отправить его. Принимающая сторона будет разбирать текст обратно в числа с помощью функций вроде strtol().
2. Просто отправить сырые данные, передав указатель на них в функцию send().
3. Кодировать числа в бинарную форму. А получатель будет их разкодировать.
Предварительный просмотр! Только сегодня!
[Занавес поднимается]
Beej говорит: «Я предпочитаю Метод №3, см. выше!»
[КОНЕЦ]
(Прежде, чем начать этот раздел всерьёз, а не в шутку, я должен сказать вам, что для этих целей существуют отдельные библиотеки. Добавляя свои собственные методы, сложно сохранить кроссплатформенность. Оглядитесь вокруг, почитайте материалы в интернете и подумайте, действительно ли вам нужно самостоятельно всё это реализовывать. Я включил последующую информацию для тех, кому интересно, как это всё работает.)
На самом деле все методы, описанные выше, имеют свои преимущества и недостатки, но, как я уже сказал, в целом я предпочитаю третий метод. Хотя, прежде всего давайте поговорим о недостатках двух других.
Первый метод — перекодирование чисел в текст перед отправкой — имеет то преимущество, что вы можете легко вывести на экран данные, идущие по проводам, и понять их. Иногда человеко-читабельный протокол незаменим для приложений, не требующих большой полосы пропускания — например, IRC. Однако он имеет тот недостаток, что этот способ преобразования очень медленный, к тому же результат в виде текста занимает гораздо больше места, чем в бинарном виде!
Метод два: передача исходных данных. Это довольно легко (но опасно!): просто взять указатель на данные для передачи и передать в функцию.
double d = 3490.15926535;
send(s, &d, sizeof d, 0); /* DANGER—не переносимо! */
Принимающая сторона получит их так:
double d;
recv(s, &d, sizeof d, 0); /* DANGER—не переносимо! */
Быстро, просто — что не нравится-то? Оказывается, не все архитектуры представляют double (или тот же int)) одинаково, и не всегда в одном и том же порядке следования байт! Код решительно не переносим. (Хотя, может быть, вам не нужна мобильность — в этом случае всё приятно и быстро).
При упаковке целых типов данных мы уже видели, как htons()-класс функций может помочь сохранить всё переносимым путём преобразования числа в сетевой порядок байт, и насколько это правильное действие. К сожалению, подобной функции нет для чисел с плавающей точкой. Надежды больше нет?
Не бойся! (Вы боялись хоть пару секунд? Нет? Ни капельки??) Существует выход: мы можем «упаковать» (или «расположить», или «сериализировать», или любое другое из тысяч названий) данные в известный нам двоичный формат так, что принимающая сторона сможет их распаковать на удалённой машине.
Что такое «известный нам бинарный формат»? Например, вы уже видели ф-ю htons(), верно? Она изменяет (или «кодирует», если вам так больше нравится) число в зависимости от того, какой порядок байт родной для машины, где программа запущена. Чтобы восстановить («раскодировать») число, получатель вызывает ntohs().
Но не я ли только что сказал, что такой функции нет для других типов даных? Да, сказал. И посколько стандартного способо для этого в C не преддусмотрено, мы в довольно неприятном положении.
Требуемое действие — упаковка данных в известный формат и передача их через сеть для последующего декодироваия. Например, вот вам быстрое и кривоватое средство упаковки float’ов с большим простором для оптимизации:
#include <stdint.h>
uint32_t htonf(float f)
{
uint32_t p;
uint32_t sign;
if (f < 0) { sign = 1; f = —f; }
else { sign = 0; }
p = ((((uint32_t)f)&0x7fff)<<16) | (sign<<31); // целая часть и знак
p |= (uint32_t)(((f — (int)f) * 65536.0f))&0xffff; // дробная часть
return p;
}
float ntohf(uint32_t p)
{
float f = ((p>>16)&0x7fff); // whole part
f += (p&0xffff) / 65536.0f; // fraction
if (((p>>31)&0x1) == 0x1) { f = —f; } // бит знака
return f;
}
Приведенный выше код является своего рода наивной реализацией, которая сохраняет float d 32-битное число. Высший бит (31) используется для сохранеия знака числа («1» означает отрицательное), следующие семь (30-16) — для хранения целой части числа, и оставшиеся (15-0) — дробной.
Использование очень просто:
#include <stdio.h>
int main(void)
{
float f = 3.1415926, f2;
uint32_t netf;
netf = htonf(f); // конвертируем в «сетевую» форму
f2 = ntohf(netf); // конвертируем обратно для теста
printf(«Original: %fn», f); // 3.141593
printf(» Network: 0x%08Xn», netf); // 0x0003243F
printf(«Unpacked: %fn», f2); // 3.141586
return 0;
}
Плюс — код маленький, простой и быстрый. Минусы — неэффективное использование пространства (памяти), и диапазон строго ограничен — попробуйте закодировать число более 32767, результат вам врядли понравится! Также из примера видно, что последние два знака после запятой сохранены неверно.
Что можно использовать вместо этого? Ну, есть стандарт для хранения чисел с плавающей точкой: IEEE-754. Большинство компьютеров используют этот формат для внутренних операций с числами с плавающей запятой, поэтоу в этих случаях, строго говоря, преобразование делать не нужно. Но если вы хотите, чтобы ваш исходный код был переносим, этот вариант работать будет не всегда. (С другой стороны, если вам важна скорость — вы можете выкинуть преобразование на тех платформах, где оно не нужно — так же, как это делает htons() и ей подобные функции.)
Вот код, который кодирует float и double в формат IEEE-754. (В основном… Он не кодирует неопределённость и бесконечность, но может быть соответственно модифицирован.)
#define pack754_64(f) (pack754((f), 64, 11))
#define unpack754_32(i) (unpack754((i), 32, 8))
#define unpack754_64(i) (unpack754((i), 64, 11))uint64_t pack754(long double f, unsigned bits, unsigned expbits)
{
long double fnorm;
int shift;
long long sign, exp, significand;
unsigned significandbits = bits — expbits — 1; // -1 для бита знака
if (f == 0.0) return 0; // проигнорируем этот особый случай
// проверим знак и начнём нормализацию
if (f < 0) { sign = 1; fnorm = —f; }
else { sign = 0; fnorm = f; }
// возьмём нормализованную форму f и отследим экспоненту
shift = 0;
while(fnorm >= 2.0) { fnorm /= 2.0; shift++; }
while(fnorm < 1.0) { fnorm *= 2.0; shift—; }
fnorm = fnorm — 1.0;
// рассчитаем бинарную форму (не-float) значимых данных
significand = fnorm * ((1LL<<significandbits) + 0.5f);
// получаем экспоненту
exp = shift + ((1<<(expbits—1)) — 1); // shift + bias
// возвращаем итоговое значение
return (sign<<(bits—1)) | (exp<<(bits—expbits—1)) | significand;
}
long double unpack754(uint64_t i, unsigned bits, unsigned expbits)
{
long double result;
long long shift;
unsigned bias;
unsigned significandbits = bits — expbits — 1; // -1 для бита знака
if (i == 0) return 0.0;
// pull the significand
result = (i&((1LL<<significandbits)—1)); // маска
result /= (1LL<<significandbits); // конвертируем обратно во float
result += 1.0f; // прибавляем обратно
// разбираемся с экспонентой
bias = (1<<(expbits—1)) — 1;
shift = ((i>>significandbits)&((1LL<<expbits)—1)) — bias;
while(shift > 0) { result *= 2.0; shift—; }
while(shift < 0) { result /= 2.0; shift++; }
// получаем результат
result *= (i>>(bits—1))&1? —1.0: 1.0;
return result;
}
В начале у меня несколько полезных макросов для упаковки и распаковки 32-битных (вероятно, float) и 64-битных (вероятно, double) чисел, но функция pack754 может быть вызвана и прямо для кодирования данных фиксированной битности (битность которых зарезервирована для экспоненты нормализованного числа).
Вот пример использования:
#include <stdint.h> // defines uintN_t types
#include <inttypes.h> // defines PRIx macrosint main(void)
{
float f = 3.1415926, f2;
double d = 3.14159265358979323, d2;
uint32_t fi;
uint64_t di;
fi = pack754_32(f);
f2 = unpack754_32(fi);
di = pack754_64(d);
d2 = unpack754_64(di);
printf(«float before : %.7fn», f);
printf(«float encoded: 0x%08» PRIx32 «n», fi);
printf(«float after : %.7fnn», f2);
printf(«double before : %.20lfn», d);
printf(«double encoded: 0x%016» PRIx64 «n», di);
printf(«double after : %.20lfn», d2);
return 0;
}
Код выше даст следующий вывод:
float encoded: 0x40490FDA
float after : 3.1415925double before : 3.14159265358979311600
double encoded: 0x400921FB54442D18
double after : 3.14159265358979311600
Другой вопрос, который у вас может возникнуть — как упаковывать структуры? К сожалению для вас, компилятор волен поместить вообще всю начинку в структуру, и это значит, что вы не можете кроссплатформенно отправить структуру по проводам одним куском. (Вам ещё не надоело постоянно слышать «не можете сделать то, не можете сделать это»? Извините! По словам моего друга «Каждый раз, когда что-то идёт не так, я обвиняю Microsoft.». В данном случае это, конечно, не вина майкрософта, но тем не менее заявление моего друга совершенно верно.)
Вернёмся к делу: лучший способ отправить структуру в сеть — упаковка каждого поля независимо, а затем распаковка их в структуру на стороне получателя.
Вы думаете, что это большая лишняя работа. Да, это так. Единственное, что вы можете сделать для облегчения работы — написать вспомогательную функцию, упаковывающую и распаковывающую данные. Это будет здорово! Серьёзно!
В книге «Практика программирования» Кернигана и Пайка они реализуют printf()-подобные функции pack() и unpack(), которые делают именно это. Я бы дал ссылки на них, но этих функций, как и самой книги, в сети, к сожалению, нет.
(«Практика программирования» — отличное чтение. Зевс спасает котёнка каждый раз, когда я её рекомендую.)
Сейчас я хочу указать вам на выпускающийся под лицензией BSD Typed Parameter Language C API, который я никогда не использовал, но выглядит он внушительно. программисты на питоне и перле могут проверить, есть ли в их языках pack() и unpack(), делающие то же самое.
Но если вы хотите написать собственную утилиту упаковки на C, К. и П. используют трюк с printf()-подобным списком аргументов для построения пакетов. Вот версия, которую я приготовил сам для того, чтобы дать вам представление о том, как это можнт работать.
(Этот код обращается к функции pack754(), которая приведена выше. packi*()-функции работают как знакомое вам htons()-семейство, кроме того, что упаковывают данные в массив char вместо другого числа).
#include <stdarg.h>
#include <string.h>
#include <stdint.h>
#include <inttypes.h>// различные биты для чисел с плавающей точкой —
// меняются в зависимости от архитектуры
typedef float float32_t;
typedef double float64_t;
/*
** packi16() — сохраняет 16-битное число в буфер char[] (похоже на htons())
*/
void packi16(unsigned char *buf, unsigned int i)
{
*buf++ = i>>8; *buf++ = i;
}
/*
** packi32() — сохраняет 32-битное число в массив char[] (похоже на htonl())
*/
void packi32(unsigned char *buf, unsigned long i)
{
*buf++ = i>>24; *buf++ = i>>16;
*buf++ = i>>8; *buf++ = i;
}
/*
** unpacki16() — распаковывает 16-битное число из массива char (как ntohs())
*/
unsigned int unpacki16(unsigned char *buf)
{
return (buf[0]<<8) | buf[1];
}
/*
** unpacki32() — распаковывает 32-битное число из массива char (как ntohl())
*/
unsigned long unpacki32(unsigned char *buf)
{
return (buf[0]<<24) | (buf[1]<<16) | (buf[2]<<8) | buf[3];
}
/*
** pack() — сохраняет в буфер данные, переданные в строке «format»
**
** h — 16-bit l — 32-bit
** c — 8-bit char f — float, 32-bit
** s — string (автоматически добавляется длинна в 16 бит)
*/
int32_t pack(unsigned char *buf, char *format, …)
{
va_list ap;
int16_t h;
int32_t l;
int8_t c;
float32_t f;
char *s;
int32_t size = 0, len;
va_start(ap, format);
for(; *format != ‘; format++) {
switch(*format) {
case ‘h’: // 16-bit
size += 2;
h = (int16_t)va_arg(ap, int);
packi16(buf, h);
buf += 2;
break;
case ‘l’: // 32-bit
size += 4;
l = va_arg(ap, int32_t);
packi32(buf, l);
buf += 4;
break;
case ‘c’: // 8-bit
size += 1;
c = (int8_t)va_arg(ap, int);
*buf++ = (c>>0)&0xff;
break;
case ‘f’: // float
size += 4;
f = (float32_t)va_arg(ap, double);
l = pack754_32(f); // convert to IEEE 754
packi32(buf, l);
buf += 4;
break;
case ‘s’: // string
s = va_arg(ap, char*);
len = strlen(s);
size += len + 2;
packi16(buf, len);
buf += 2;
memcpy(buf, s, len);
buf += len;
break;
}
}
va_end(ap);
return size;
}
/*
** unpack() — распаковывает в буфер данные, переданные в строке «format»
*/
void unpack(unsigned char *buf, char *format, …)
{
va_list ap;
int16_t *h;
int32_t *l;
int32_t pf;
int8_t *c;
float32_t *f;
char *s;
int32_t len, count, maxstrlen=0;
va_start(ap, format);
for(; *format != ‘; format++) {
switch(*format) {
case ‘h’: // 16-bit
h = va_arg(ap, int16_t*);
*h = unpacki16(buf);
buf += 2;
break;
case ‘l’: // 32-bit
l = va_arg(ap, int32_t*);
*l = unpacki32(buf);
buf += 4;
break;
case ‘c’: // 8-bit
c = va_arg(ap, int8_t*);
*c = *buf++;
break;
case ‘f’: // float
f = va_arg(ap, float32_t*);
pf = unpacki32(buf);
buf += 4;
*f = unpack754_32(pf);
break;
case ‘s’: // string
s = va_arg(ap, char*);
len = unpacki16(buf);
buf += 2;
if (maxstrlen > 0 && len > maxstrlen) count = maxstrlen — 1;
else count = len;
memcpy(s, buf, count);
s[count] = ‘;
buf += len;
break;
default:
if (isdigit(*format)) { // отслеживаем максимальную длинну строки
maxstrlen = maxstrlen * 10 + (*format—‘0’);
}
}
if (!isdigit(*format)) maxstrlen = 0;
}
va_end(ap);
}
А вот демонстрационная программа, использующая приведённый код, чтобы упаковать данные в buf, а затем распаковать в переменные. Заметьте, вызывая unpack() со строковым аргументом (аргумент формата «s»), будет целесообразно поставить перед ним максимальное число, чтобы избежать переполнения буфера, например «96s. Будьте осторожны при распаковке данных, которые вы получили по сети: злоумышленник может отправить намеренно искаженные пакеты с целью атаки вашей системы!
#include <stdio.h>
// различные биты для чисел с плавающей точкой —
// меняются в зависимости от архитектуры
typedef float float32_t;
typedef double float64_t;
int main(void)
{
unsigned char buf[1024];
int8_t magic;
int16_t monkeycount;
int32_t altitude;
float32_t absurdityfactor;
char *s = «Great unmitigated Zot! You’ve found the Runestaff!»;
char s2[96];
int16_t packetsize, ps2;
packetsize = pack(buf, «chhlsf», (int8_t)‘B’, (int16_t)0, (int16_t)37,
(int32_t)—5, s, (float32_t)—3490.6677);
packi16(buf+1, packetsize); // сохраняем в пакет размер пакета
printf(«packet is %» PRId32 » bytesn», packetsize);
unpack(buf, «chhl96sf», &magic, &ps2, &monkeycount, &altitude, s2,
&absurdityfactor);
printf(«‘%c’ %» PRId32» %» PRId16 » %» PRId32
» «%s» %fn», magic, ps2, monkeycount,
altitude, s2, absurdityfactor);
return 0;
}
Написали ли вы собственный код или использовали чужой, будет хорошей идеей иметь общий набор функций для упаковки/распаковки вместо упаковки каждого бита в ручную каждый раз. Это намного упростит дебаг.
При упаковке данных, какой формат лучше использовать? Отличный вопрос. К счастью, RFC 4506, стандарт представления внешних данных, уже определил бинарные форматы нескольких различных типов — числа с плавающей точкой, целые числа, массивы, raw-данные и т.д. Я предлагаю, чтобы вы формировали свои данные самостоятельно. Но вы не обязаны. Пакетная Полиция не стоит за вашей дверью. По крайней мере, лично я не думаю, что она вообще существует.
В любом случае, кодирование данных перед отправкой тем или иным способом — правильный путь.
7.5 Сын инкапсуляции данных
Что на самом деле означает понятие «инкапсуляция данных»? В простейшем случае оно означает, что вы будете прикреплять к данным некий заголовок с некоторой идентифицирующей информацей или длинной пакета, или и то и другое.
Как должен выглядеть такой заголовок? Обычно это просто некие двоичные данные, которые содержат всё, что вы сочтёте нужным в своём проекте.
Мда.. Как-то расплывчато.
Хорошо. Предположим, что у вас есть многопользовательский чат, который использует SOCK_STREAM’ы. Когда пользователь печатает («говорит») что-то, должны быть переданы два фрагмента информации: кто сказал, и что сказал.
Пока все понятно? «В чем проблема?» — спросите вы.
Проблема в том, что сообщения пользователей могут быть различной длины. Один человек, назовём его Tom, может сказать: «Hi», а еще один, Benjamin, может сказать: «Hey guys what is up?»
Когда это происходит, вы должны send() эти данные остальным клиентам. Ваш исходящий поток будет выглядеть примерно так:
И так далее. Как клиентская программа узнает, когда закончилось одно сообщение и началось следующее? Где само сообщение, а где имя собеседника? Вы можете, если захотите, ограничить все сообщения одинаковой длинной и просто использовать sendall(), как мы делали выше. Но это утилизирует пропускную способность! Нам нафиг не нужно, чтобы send() отправлял 1024 байта только для того, чтобы передать «том сказал Hi».
Поэтому нам нужно инкапсулировать данные в маленький заголовок, описывающий структуру пакета. Оба клиента и сервер должны знать, как упаковать и распаковать (или «кодировать» и «раскодировать») эти данные. Для всего этого нам теперь нужно задать протокол, который и будет описывать, как общаются клиент и сервер.
В данном случае давайте предположим, что имя пользователя является строкой с фиксированной длиной в 8 символов, заканчивающейся ‘ 0’. А текст сообщения тогда представляет собой данные переменной длины, максимальной длинной в 128 символов. Давайте посмотрим структуру образца пакета, который мы могли бы использовать в данной ситуации:
1. len (1 байт, без знака) — общая длина пакета, включая 8-байтовое имя пользователя и передаваемый текст.
2. имя (8 байт) — имя пользователя, NUL-терминированное в случае необходимости.
3. chatdata (N-байт) — сами данные, не более, чем 128 байт. Длина пакета должна быть рассчитана как длина этих данных плюс 8 (длина имени).
Почему я выбрал именно 8-байт и 128-байт? Я взял их из воздуха, предполагая, что их будет достаточно. Может быть, однако, что 8 байт слишком мало для ваших нужд, в этом случае вы можете сделать 30-байтовое имя поля, или вообще какое угодно. Выбор за вами.
Используя подобное определение пакета, первый пакет будет содержать следующую информацию (в шестнадцатеричном виде и ASCII):
(length) T o m (padding) H i
И второй — такой же:
(length) B e n j a m i n H e y g u y s w …
(Длина хранятся в сетевом порядке байт, конечно. В нашем случае это только один байт, таким образом порядок следования не имеет значения, но в на будущее — учтите, что все ваши двоичные числа будут храниться в сетевом порядке байт, передаваясь по сети.)
Передавая эти данные, вы должны обеспечивать безопасность и использовать функцию подобную sendall(), описанную выше, тогда вы будете уверены, что передали все данные, даже если это заняло несколько вызовов send().
Аналогичным образом, получая эти данные, нужно проделать несколько дополнительных действий. В целях безопасности вы должны предполагать, что можете получить лишь частичный пакет (например, «18 42 65 6E 6A» от Бенджамина, выше) за один вызов recv(). Поэтому нужно вызывать recv() снова и снова, пока пакет не будет получен полностью.
Но как это реализовать? Мы знаем число байт, которое мы должны получить в общей сложности, чтобы считать пакет полученным, так как это число передаётся перед всем остальным пакетом. Мы также знаем, что максимальный размер пакета равен 1+8+128, или 137 байт (потому что именно так мы определили пакет.)
На самом деле есть несколько действий, которые вы можете предпринять. Поскольку вы знаете, что каждый пакет начинается с определения собственной длины, вы можете вызвать recv() только раз, чтобы получить длину пакета. Затем, узнав длину пакета, вы можете вызвать recv() снова, указав точно оставшуюся длину (возможно, для получения всех данных вызывать придётся несколько несколько раз), пока не будет сформирован полный пакет. Преимуществом этого метода является то, что вам нужно только иметь буфер, достаточно большой для одного пакета, а недостаток — то, что вам нужно вызвать recv() по меньшей мере два раза, чтобы получить все данные.
Другим вариантом является просто вызвать recv() и указать ему в качестве длинны, которую готовы получить, максимальное количество байтов в пакете. Тогда все, что вы получите, попадёт в буфер, и только тогда вы проверите, является ли пакет полным. Конечно, вы можете при этом получить некоторые данные из уже последующего пакета, поэтому вы должны предусмотреть и эту возможность.
Всё, что вы можете сделать — это объявить массив char’ов достаточно большим для двух пакетов. Он будет вашим рабочим массивом, в котором вы будете реконструировать пакеты по мере их поступления.
Каждый раз, когда вы recv() данные, вы добавляете их в рабочий буфер и проверяете, является ли пакет завершенным. То есть, является ли количество байт в буфере больше или равно длине, указанной в заголовке (+1, так как длины в заголовке не содержит байта собственной длинны.) Если число байт в буфере меньше 1, то пакет, очевидно, не является полным. Вы должны предусмотреть этот особый случай.
Как только пакет будет завершен, вы можете делать с ним что хотите. Используйте его и удалите из рабочего буфера.
Вот так! У вас ещё не кружится голова из-за этого жонглирования? Ну, вот вам второй из этого двойного удара: вы могли прочитать конец прошлого один пакета и начало следующего в одним вызовом recv(). То есть, у вас есть рабочий буфер с одним законченным пакетом и какой-то частью следующего пакета! Неудобная особенность. (Но это именно то, зачем вы сделали ваш рабочий буфер достаточно большим, чтобы поместить два пакета в подобном случае!)
Поскольку вы знаете длину первого пакета из его заголовка и вы отслеживали число байт в рабочем буфере, вы можете вычесть одно из другого и вычислить, сколько байт в буфере относятся ко второму (неполному) пакету. Когда вы обрабатываете первый пакет, вы можете вычистить его из рабочего буфера и переместить частичный второй пакет в начало, чтобы буфер был готов к приёму данных от следующего recv().
(Некоторые из вас, читателей, заметят, что процесс перемещения части второго пакета для начала работы буфера занимает много времени, и программы могут быть написаны так, чтобы не прибегать к этому, с помощью кольцевого буфера. К сожалению, для всех остальных из вас, обсуждение кольцевых буферов выходит за рамки данной книги. Если вам всё же любопытно, возьмите книгу о структурах данных и начните с неё.)
Я никогда не говорил, что это будет легко. Ну ладно, я сказал, что будет легко. Нужна всего лишь практика, и очень скоро она вас настигнет. Клянусь Экскалибуром!
7.6 Бродкаст пакеты — Hello, World!
До сих пор это руководство рассказывало о передаче данных с одного хоста на один другой хост. Но вы можете, я авторитетно об этом заявляю, и пересылать данные нескольким хостам одновременно!
В UDP (только в UDP, не в TCP) и стандартном IPv4 это сделано с помощью механизма, называемого широковещанием (бродкаст, broadcast). В IPv6 бродкаст не поддерживается и вы вынуждены прибегать к часто избыточной технике многоадресной рассылки (малтикаст, multicast), которую, к сожалению, я не буду сейчас обсуждать. Но довольно о прекрасном будущем — в настоящее время мы увязли в 32-битном мире.
Но постойте! Вы не можете просто побежать и начать вещание. Вы должны передать сокету опцию SO_BROADCAST прежде чем отправлять широковещательные пакеты в сеть. Это как одна из тех небольших пластиковых предохранительных крышечек, которыми накрывают переключатель запуска ракеты! Именно столько мощности вы держите в руках!
А если серьезно, есть некоторая опасность в использовании широковещательных пакетов, а именно: каждая система, которая получает широковещательный пакет, должна отложить все операции с инкапсулированными данными, пока не узнает, какому порту предназначены пришедшие широковещательные запросы. И только затем принимает данные или отбрасывает их. В любом случае это означает много работы для каждой машины, которая получает широковещательный пакет, а так как все из них находятся в одной локальной сети, в результате множество машин могут производить абсолютно ненужную работу. Когда впервые вышла игра Doom, именно бродкаст вызвал много жалоб на её сетевой код.
А ещё, есть больее одного способо покувыркаться… подождите минуту. Разве есть более одного способа кувыркаться? Ух, и точно так же есть более чем один способ отправки широковещательных пакетов. В общем, чтобы добраться до сути вещеё: как вам указать адрес назначения для широковещательного сообщения? Есть два распространенных способа:
1. Отправить данные на широковещательный адрес конкретной подсети. Это адрес подсети со максимальным значением битов в хостовом адресе. Например, в моей домашней сети 192.168.1.0 маска — 255.255.255.0, поэтому последний байт — хостовый адрес (потому что первые три байта, в соответствии с маской, являются номером сети). А мой широковещательный адрес — 192.168.1.255. На самом деле В Unix команда Ifconfig даст вам все эти данные. Вы можете отправить широковещательные пакеты такого типа в удаленные сети, также, как в ваше локальной сети, но скорее всего пакеты будут отброшены промежуточными роутерами. (Если бы не отбрасывали, то из-за некоторых случайных событий их сеть затопило бы бродкастовым трафиком.)
2. Отправить данные на «глобальный» широковещательный адрес. Это 255.255.255.255, ака INADDR_BROADCAST. Многие машины автоматически вычисляют из этого и вашего сетевого адреса широковещательный адрес сети, но какие-то и не делают этого. Маршрутизаторы не пересылают такого рода широковещательные пакеты из вашей локальной сети.
Так что же происходит, если вы пытаетесь отправить данные на широковещательный адрес без предварительной установки опции сокета SO_BROADCAST? Ну, давайте запустим старые добрые talker и listener и посмотрим, что произойдёт.
sent 3 bytes to 192.168.1.2
$ talker 192.168.1.255 foo
sendto: Permission denied
$ talker 255.255.255.255 foo
sendto: Permission denied
Да, такой результат не радует… Такое происходит именно потому, что мы не установили опцию сокета SO_BROADCAST. Сделайв это, вы сможете sendto() данные куда захотите!
Фактически это единственное различие между UDP-программами, которые могут вещать бродкаст и которые не могут. Давайте возьмём нашу старую программу listener и добавим раздел, устанавливающий опцию сокета SO_BROADCAST. Назовём новую программу broadcaster.c:
** broadcaster.c — a datagram «client» like talker.c, except
** this one can broadcast
*/#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#define SERVERPORT 4950 // порт для соединения клиентом
int main(int argc, char *argv[])
{
int sockfd;
struct sockaddr_in their_addr; // информация об адресе клиента
struct hostent *he;
int numbytes;
int broadcast = 1;
//char broadcast = ‘1’; // если не работает, попробуйте так
if (argc != 3) {
fprintf(stderr,«usage: broadcaster hostname messagen»);
exit(1);
}
if ((he=gethostbyname(argv[1])) == NULL) { // получаем инфо о хосте
perror(«gethostbyname»);
exit(1);
}
if ((sockfd = socket(AF_INET, SOCK_DGRAM, 0)) == —1) {
perror(«socket»);
exit(1);
}
// это и есть то, что позволяет нам отправлять бродкастовые пакеты
if (setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &broadcast,
sizeof broadcast) == —1) {
perror(«setsockopt (SO_BROADCAST)»);
exit(1);
}
their_addr.sin_family = AF_INET; // хостовый порядок байт
their_addr.sin_port = htons(SERVERPORT); // short, сетевой порядок байт
their_addr.sin_addr = *((struct in_addr *)he->h_addr);
memset(their_addr.sin_zero, ‘, sizeof their_addr.sin_zero);
if ((numbytes=sendto(sockfd, argv[2], strlen(argv[2]), 0,
(struct sockaddr *)&their_addr, sizeof their_addr)) == —1) {
perror(«sendto»);
exit(1);
}
printf(«sent %d bytes to %sn», numbytes,
inet_ntoa(their_addr.sin_addr));
close(sockfd);
return 0;
}
В чем разница между этим и «нормальным» взаимодействием UDP клиент / сервер? Ни в чём! (За исключением клиента, который может отправлять широковещательные пакеты в данном случае.) Давайте, запустите старую UDP-программу listener в одном окне, и broadcaster в другом. Сейчас у вас должно получится то, что не получилось выше.
sent 3 bytes to 192.168.1.2
$ broadcaster 192.168.1.255 foo
sent 3 bytes to 192.168.1.255
$ broadcaster 255.255.255.255 foo
sent 3 bytes to 255.255.255.255
Вы должны увидеть ответ listener, говорящий, что он получил пакеты. (Если listener не отвечает, это может означать, что он забиндился на адрес ipv6. Попробуйте заменить AF_UNSPEC на AF_INET в listener.c).
Ну, вроде работает. Но теперь запустите listener на другой машине рядом с вами в той же сети, чтобы увидеть две копии происходящего, по одному на каждой машине, и запустите broadcaster снова… Ух ты! Оба listenerа получить пакет, даже если вы вызваете sendto() только один раз! Круто! .
Если listener получает информацию, передаваемую непосредственно ему, но не получает данные из широковещательного запроса, может быть, у вас на компьютере запущен брандмауэр, который блокирует пакеты.
Ещё раз — будьте осторожны с широковещательными пакетами. Поскольку каждая машина в локальной сети будет вынуждена обрабатывать пакет независимо от того, есть ли у неё слушающий recvfrom(), бродкаст может вызвать сильную нагрузку на всю сеть.