78. Сокеты и веб-сервер на C
Сокет — программный интерфейс для обмена данными по сети. Основа у него
одна в Linux, в Windows и — через стек TCP/IP под ядром RTOS — на F407
со встроенным Ethernet (раздел 37.1), но детали различаются. В Windows
перед первым вызовом нужен WSAStartup, сокет имеет тип SOCKET, а не
int, закрывают его closesocket, код ошибки берут из WSAGetLastError,
а MSG_NOSIGNAL и SIGPIPE там нет вовсе. В стеке lwIP на камне настоящие
имена — lwip_socket, lwip_close; привычные socket и close дают
макросы, а errno — настройка стека. Примеры раздела написаны для Linux.
У F103 сетевого контроллера нет: там сокеты живут во внешнем модуле.
Поэтому раздел проверен на компьютере: программы собраны gcc под Linux
в Docker, сервер и клиент обмениваются данными по локальному адресу.
А всё, что от операционной системы не зависит, — разбор HTTP-запроса,
страница, кадры с байт-стаффингом — собрано ещё и armclang для
Cortex-M3 и M0: этот код переносится на STM32 без изменений.
Как это называется в документации
Оригинал Перевод socket сокет — конечная точка соединения file descriptor дескриптор — номер открытого файла или сокета address family AF_INETсемейство адресов IPv4 stream socket SOCK_STREAM, TCPпотоковый сокет, TCP host / network byte order порядок байтов машины / сети bind, listen, accept, connect привязать, слушать, принять, подключиться backlog очередь соединений, ждущих acceptblocking call блокирующий вызов errnoкод последней ошибки TIME_WAITсостояние порта после закрытия соединения SIGPIPEсигнал «запись в закрытый канал» request line, status line, header строка запроса, строка статуса, заголовок framing, byte stuffing деление потока на кадры, вставка байтов MMU / MPU блок управления памятью / блок защиты памяти segmentation fault нарушение защиты памяти
78.1 Программа на компьютере не видит железа
На STM32 адрес 0x40010800 — это регистры порта GPIOA у F103
(раздел 8), и писать туда может любой код. На компьютере так нельзя.
Под Linux у каждого процесса своё виртуальное адресное пространство.
Блок управления памятью (MMU) процессора переводит каждый виртуальный
адрес в физический по таблице, которую ведёт ядро, — страницами по
4096 байт. Адреса, которых процессу не выдали, не существуют: обращение
к ним ядро пресекает сигналом SIGSEGV, «segmentation fault». В
проверке программа на компьютере записала число по адресу 0x40010800
и была убита сигналом 11.
Поэтому программа на компьютере работает с устройствами через ядро: она получает дескриптор — небольшое целое число — и вызывает функции с ним. Файл, сокет, последовательный порт — всё это дескрипторы.
| Компьютер | F103 | F407 | |
|---|---|---|---|
| перевод адресов | MMU, страницы по 4 КБ | нет | нет |
| защита памяти | у каждого процесса своё пространство | нет | MPU — регионы с правами |
| обращение к чужому адресу | SIGSEGV, процесс завершён |
выполняется или BusFault | MemManage, если MPU настроен |
MPU — не перевод адресов, а только права доступа к нескольким областям
памяти: RTOS с его помощью закрывает, например, стек одной задачи от
другой. В stm32f407xx.h он объявлен (__MPU_PRESENT 1U), в
stm32f10x.h — только для XL-серии F1: у F103C8 его нет.
78.2 Сокет, адрес, порт
socket(AF_INET, SOCK_STREAM, 0) создаёт TCP-сокет и возвращает
дескриптор — первое свободное число. В проверке это 3: номера 0, 1 и 2
заняты стандартными вводом, выводом и выводом ошибок. При ошибке функция
возвращает −1, а причина лежит в errno; так устроены все вызовы
этого раздела.
Адрес IPv4 — четыре байта (записывается четырьмя числами через точку),
порт — 16 бит, от 0 до 65 535. Оба лежат в struct sockaddr_in —
в проверке это 16 байт: семейство (2), порт (2), адрес (4) и 8 байт
заполнения, которые обнуляют.
Порядок байтов. В сети принят «старший байт первым». У x86 и Cortex-M
наоборот — младший байт первым (разделы 3а и 67). Поэтому порт и адрес
переводят в сетевой порядок: htons — для 16 бит (порт), htonl — для
32 бит (адрес). htons(1500) лежит в памяти как 05 DC; если прочитать
эти байты как число на компьютере, выйдет 56 325.
static struct sockaddr_in addr_of(uint32_t ip, uint16_t port)
{
struct sockaddr_in a;
memset(&a, 0, sizeof a);
a.sin_family = AF_INET;
a.sin_addr.s_addr = htonl(ip);
a.sin_port = htons(port);
return a;
}
⚠️
htonsдля адреса. Строкаaddr.sin_addr.s_addr = htons(INADDR_ANY);работает — потому чтоINADDR_ANYравен нулю, а нулю порядок байтов безразличен. Замените его наINADDR_LOOPBACK(127.0.0.1) — иhtonsобрежет адрес до 16 бит: получится0.1.0.0, иbindоткажет сEADDRNOTAVAIL. Адрес — всегдаhtonl.
На Cortex-M перестановка байтов стоит одну команду. armclang
превращает __builtin_bswap16 и __builtin_bswap32 — и даже то же
самое, записанное сдвигами, — в rev16 и rev, причём и на M3, и на
M0. Сетевому стеку на STM32 это ничего не стоит.
78.3 Сервер и клиент
| Шаг | Сервер | Клиент |
|---|---|---|
| 1 | socket — создать |
socket — создать |
| 2 | bind — привязать к адресу и порту |
— (порт выберет система) |
| 3 | listen — принимать подключения |
— |
| 4 | accept — принять: новый дескриптор |
connect — подключиться к серверу |
| 5 | recv / send через новый дескриптор |
send / recv |
| 6 | close |
close |
/* слушающий сокет на 127.0.0.1:port; reuse — SO_REUSEADDR до bind */
static int listener(uint16_t port, int reuse, int *err)
{
int s = socket(AF_INET, SOCK_STREAM, 0);
if (reuse)
setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof reuse);
struct sockaddr_in a = addr_of(INADDR_LOOPBACK, port);
if (bind(s, (struct sockaddr *)&a, sizeof a) < 0) {
if (err)
*err = errno;
close(s);
return -1;
}
listen(s, 1);
return s;
}
static int dial(uint16_t port, int *err)
{
int c = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in a = addr_of(INADDR_LOOPBACK, port);
if (connect(c, (struct sockaddr *)&a, sizeof a) < 0) {
if (err)
*err = errno;
close(c);
return -1;
}
return c;
}
Что здесь важно:
acceptвозвращает новый дескриптор для каждого клиента, а слушающий остаётся ждать следующих. Адрес клиента, если он нужен, — в отдельную структуру, а не в ту, где лежит адрес сервера.- Второй аргумент
listen— не число клиентов, а длина очереди соединений, которые уже подключились, но ещё не принятыaccept. В проверке приlisten(s, 1)три клиента были открыты одновременно. - Все вызовы блокирующие:
acceptждёт клиента,recv— данных. Один поток обслуживает одного клиента за раз; для нескольких нужны потоки, процессы или вызов, ожидающий сразу много сокетов (poll). - Ошибки печатают по
errno, а не своим текстом. Сообщение вроде «ошибка привязки: сокет уже установлен» уводит в сторону: настоящая причина почти всегда — занятый порт (78.5).
| Ситуация | errno |
|---|---|
connect, а сервер не запущен |
ECONNREFUSED |
bind на занятый порт |
EADDRINUSE |
bind на адрес, которого нет у машины |
EADDRNOTAVAIL |
повторный connect подключённого сокета |
EISCONN |
| запись в соединение, закрытое собеседником | EPIPE |
78.4 TCP — поток байтов, а не сообщений
Главное, что нужно понять про TCP: он гарантирует порядок и доставку байтов, но не хранит границ сообщений. В проверке:
- два
sendпо 5 байт —helloиworld— одинrecvполучил 10 байт:helloworld; - 10 байт, читаемые буфером в 4 байта, пришли частями 4, 4 и 2.
Правило «один send — один recv» неверно. В локальной сети на столе
оно часто выполняется — и ломается в настоящей. Границы задаёт протокол:
разделитель (HTTP заканчивает заголовки пустой строкой), длина перед
данными или кадры с флагами (78.7).
recv вернул |
Значит |
|---|---|
| > 0 | столько байт пришло — может быть меньше, чем просили |
| 0 | собеседник закрыл соединение |
| −1 | ошибка, причина в errno |
/* читать, пока не встретится end (NULL — до закрытия соединения) */
static size_t recv_until(int fd, char *buf, size_t cap, const char *end)
{
size_t n = 0;
buf[0] = '\0';
while (n + 1 < cap) {
ssize_t r = recv(fd, buf + n, cap - 1 - n, 0);
if (r <= 0) /* 0 — собеседник закрыл, −1 — ошибка */
break;
n += (size_t)r;
buf[n] = '\0';
if (end && strstr(buf, end))
break;
}
return n;
}
Функция годится для одного запроса на соединение. Если по соединению идут сообщения подряд, всё прочитанное после разделителя — начало следующего сообщения, и его нужно сохранить, а не выбросить.
⚠️ Закрытое соединение даёт 0 — каждый раз. В проверке
recvпосле закрытия вернул 0 три раза подряд. Цикл «читать, пока сообщение не кончится», который не смотрит на результат, крутится вечно на старом содержимом буфера.⚠️ Отправляют длину данных, а не буфера.
send(c, buf, sizeof buf, 0)послеstrcpy(buf, "OK")отправил 1024 байта — вместе со всем, что лежало в буфере раньше. В проверке так ушёл в сеть пароль, записанный туда до этого. Отправляютstrlen(buf)— плюс единицу, если получатель ждёт завершающий ноль.⚠️ Ввод в буфер — с пределом.
scanf("%s", buf)не знает размера буфера: длинная строка пишет за его конец. Нужны%1023sилиfgets(раздел 68).
78.5 Закрытое соединение, SIGPIPE и перезапуск
SIGPIPE. Собеседник закрыл соединение, а программа пишет в него.
Первая запись проходит — данные уходят в буфер, собеседник отвечает
сбросом соединения. На второй ядро посылает процессу сигнал SIGPIPE,
а он по умолчанию завершает процесс. Сервер умирает оттого, что
клиент закрыл вкладку браузера. В проверке процесс был убит сигналом 13.
Лечится одним из двух способов, оба проверены:
send(c, buf, n, MSG_NOSIGNAL); /* Linux: вместо сигнала −1 и EPIPE */
signal(SIGPIPE, SIG_IGN); /* один раз в начале программы */
TIME_WAIT. Кто закрыл соединение первым, тот ещё некоторое время
держит порт в состоянии TIME_WAIT — чтобы запоздавшие пакеты старого
соединения не попали в новое. Веб-сервер закрывает соединение первым
после каждого ответа. Перезапустите его — и bind откажет с
EADDRINUSE. В проверке без SO_REUSEADDR так и вышло, а с ним порт
оказался свободен. Параметр ставят через setsockopt до bind,
при каждом запуске, — как в listener выше.
78.6 Веб-сервер
HTTP — текстовый протокол поверх TCP. Браузер подключается и присылает
строку запроса, заголовки и пустую строку; каждая строка кончается
парой \r\n:
GET /on1 HTTP/1.1
Host: stm32
← пустая строка: заголовки кончились
Сервер отвечает строкой статуса, заголовками, пустой строкой и телом:
HTTP/1.1 303 See Other
Location: /
Content-Type: text/html; charset=utf-8
Connection: close
← пустая строка, тела у этого ответа нет
Разбор запроса и ответ не зависят от того, откуда пришли байты. Этот
код собран и для компьютера, и armclang для Cortex-M:
int led[2]; /* на плате здесь были бы выводы GPIO */
/* По строке запроса «GET /on1 HTTP/1.1» выполняет команду; вернёт код ответа */
int http_route(const char *req)
{
char method[8], path[32];
if (sscanf(req, "%7s %31s", method, path) != 2)
return 400;
if (strcmp(method, "GET") != 0)
return 405;
if (strcmp(path, "/") == 0)
return 200;
int on = strncmp(path, "/on", 3) == 0;
int off = strncmp(path, "/off", 4) == 0;
const char *num = path + (on ? 3 : 4);
if ((on || off) && (num[0] == '1' || num[0] == '2') && num[1] == '\0') {
led[num[0] - '1'] = on;
return 303; /* выполнили — и назад на страницу */
}
return 404;
}
/* Ответ целиком в out; 0 — не поместился. Конец тела браузер узнаёт
* по закрытию соединения: заголовок Connection: close. */
size_t http_reply(int code, char *out, size_t cap)
{
const char *status = code == 200 ? "200 OK"
: code == 303 ? "303 See Other"
: code == 404 ? "404 Not Found"
: code == 405 ? "405 Method Not Allowed"
: "400 Bad Request";
int n = snprintf(out, cap, "HTTP/1.1 %s\r\n%s"
"Content-Type: text/html; charset=utf-8\r\n"
"Connection: close\r\n"
"\r\n",
status, code == 303 ? "Location: /\r\n" : "");
if (code == 200 && n > 0 && (size_t)n < cap)
n += snprintf(out + n, cap - (size_t)n,
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
"<title>STM32</title></head><body><h1>Светодиоды</h1>"
"<p>LED 1: %s <a href=\"/on1\"><button>ON</button></a>"
" <a href=\"/off1\"><button>OFF</button></a></p>"
"<p>LED 2: %s <a href=\"/on2\"><button>ON</button></a>"
" <a href=\"/off2\"><button>OFF</button></a></p>"
"</body></html>",
led[0] ? "ON" : "OFF", led[1] ? "ON" : "OFF");
return n > 0 && (size_t)n < cap ? (size_t)n : 0;
}
А это — сервер на компьютере: одно соединение, один запрос.
static void serve_one(int ls)
{
static char req[512], resp[1024];
int c = accept(ls, NULL, NULL);
recv_until(c, req, sizeof req, "\r\n\r\n");
size_t n = http_reply(http_route(req), resp, sizeof resp);
send(c, resp, n, MSG_NOSIGNAL);
close(c);
}
В проверке этому серверу отправлены настоящие запросы:
| Запрос | Ответ | Что произошло |
|---|---|---|
GET /on1 |
303, Location: / |
LED 1 включён, браузер вернётся на страницу |
GET /favicon.ico |
404 | ничего не переключилось |
GET / |
200 | страница: LED 1: ON, LED 2: OFF — весь ответ 464 байта |
POST /on2 |
405 | команда не выполнена |
| пустой запрос | 400 | — |
| путь из 100 символов | 404 | %31s обрезал путь, переполнения нет |
Три решения в этом коде стоят пояснения.
- Ответ 303 после команды. Кнопка — это ссылка, то есть запрос GET.
Если в ответ на
/on1сразу отдать страницу, адрес/on1останется в строке браузера, и обновление страницы повторит команду. Ответ 303 отправляет браузер обратно на/. Сама команда по GET — упрощение примера: браузер вправе запросить ссылку заранее, страницу может обойти поисковый робот, а чужая страница — вставить<img src="http://плата/on1">, и лампа переключится без нажатия. В настоящем приборе изменения шлют формой с методом POST, а 303 после неё — тот же приём. /favicon.ico→ 404. Браузер сам запрашивает значок страницы. Сервер, который считает командой любой запрос, получит лишнюю.- Где кончается тело. Либо заголовок
Content-Lengthс точной длиной тела, либоConnection: closeи закрытие соединения — у нас второе, тогда длину не нужно считать заранее. Без того и другого браузер ждёт продолжения и крутит индикатор загрузки.
⚠️ Лишняя точка с запятой в странице. Длинную страницу склеивают из строк: соседние литералы
"…" "…"компилятор объединяет в один. Одна лишняя;посреди — и остаток превращается в отдельные выражения, которые ничего не делают:
c static const char page[] = "<p>LED 2</p>" "</p>"; "</html>";Это собирается, а
</html>в страницу не попадает.armclangпредупреждает даже без ключей —expression result unused; читайте предупреждения (раздел 77).
Буфер ответа на STM32 — не на стеке
На компьютере привычно написать в обработчике
char response[1024] = "HTTP/1.1 200 OK…";. На STM32 это катастрофа:
- по
armclang -fstack-usageкадр такой функции — 1032 байта, а стартовые файлы F103 и F407 дают весь стек в 1024 байта (раздел 72); - при каждом вызове 1024 байта копируются из Flash в стек
(
__aeabi_memcpy4).
Постоянные части — static const: они остаются во Flash, кадр стека
функции, которая их отправляет, — ноль байт. Буфер под изменяемую часть —
статический, как resp в serve_one. Ключ -fstack-usage вообще
полезен: рядом с объектным файлом появляется .su с кадром каждой
функции. У http_route он 48 байт, у приёма кадра из 78.7 — ноль.
Где это работает на STM32
Код http_route и http_reply одинаков при любом способе подключения
(раздел 37.1); меняется только то, откуда приходят и куда уходят байты:
| Подключение | Сокеты | Камень |
|---|---|---|
| встроенный Ethernet + стек TCP/IP под RTOS | те же socket, bind, listen, accept, recv, send |
F407 |
| микросхема со стеком в кремнии по SPI | в микросхеме, управление регистрами | любой |
| Wi-Fi модуль по UART | в модуле, управление AT-командами | любой |
78.7 Кадры: границы сообщений в потоке байтов
И TCP, и UART (раздел 16) дают поток байтов. Чтобы передавать сообщения,
поток делят на кадры. Надёжная схема — байт-стаффинг: байт 7E —
флаг на краях кадра, а внутри данных 7E и 7D заменяются парой
«7D, байт XOR 20»:
| В данных | В кадре |
|---|---|
7E |
7D 5E |
7D |
7D 5D |
| остальные | как есть |
Данные 01 7E 02 7D 03 превращаются в кадр
7E 01 7D 5E 02 7D 5D 03 7E. Флага внутри кадра не бывает, поэтому
флаг — всегда граница: после мусора или потерянного байта приёмник
находит начало следующего кадра сам.
#define FLAG 0x7E
#define ESC 0x7D
/* FLAG, данные с заменами, FLAG; out — не меньше 2 * n + 2 байт */
size_t frame_encode(const uint8_t *in, size_t n, uint8_t *out)
{
size_t k = 0;
out[k++] = FLAG;
for (size_t i = 0; i < n; i++) {
if (in[i] == FLAG || in[i] == ESC) {
out[k++] = ESC;
out[k++] = in[i] ^ 0x20;
} else {
out[k++] = in[i];
}
}
out[k++] = FLAG;
return k;
}
typedef struct {
uint8_t buf[64];
size_t len;
uint8_t inside, esc, overflow;
} deframer_t;
/* Приём по одному байту — как из прерывания UART. Вернёт длину кадра,
* когда он закончился (данные в d->buf до следующего вызова), иначе 0. */
size_t frame_feed(deframer_t *d, uint8_t b)
{
if (b == FLAG) {
size_t n = (d->inside && !d->esc && !d->overflow) ? d->len : 0;
d->inside = 1; /* флаг закрывает кадр и открывает следующий */
d->len = 0;
d->esc = d->overflow = 0;
return n;
}
if (!d->inside)
return 0; /* мусор до первого флага */
if (b == ESC) {
d->esc = 1;
return 0;
}
if (d->esc) {
b ^= 0x20;
d->esc = 0;
}
if (d->len < sizeof d->buf)
d->buf[d->len++] = b;
else
d->overflow = 1;
return 0;
}
Что проверено:
- 1000 случайных кадров с частыми
7Eи7Dприняты байт в байт; - худший случай — все байты особые: кадр вдвое длиннее данных плюс два флага, 10 байт превращаются в 22. Буфер под кадр считают по нему;
- мусор до первого флага отброшен; кадр, у которого потерялся конец, выдан как короткий кадр из 2 байт, а следующий кадр принят верно.
Последнее — важный вывод: стаффинг находит границы, но не проверяет содержимое. В кадр добавляют контрольную сумму — CRC (раздел 43.1, аппаратный блок — раздел 12.5), и кадр с неверной суммой выбрасывают.
78.8 Грабли
Сервер работает на нулевом адресе и ломается на 127.0.0.1. htons
вместо htonl для адреса.
Сообщения слипаются или приходят половинами. TCP — поток; границы задаёт протокол.
Сервер крутится в цикле после ухода клиента. recv вернул 0, а цикл
этого не проверяет.
В сеть ушло лишнее, вплоть до паролей. send с размером буфера
вместо длины данных.
Сервер молча завершается, когда клиент закрыл вкладку. SIGPIPE:
MSG_NOSIGNAL или signal(SIGPIPE, SIG_IGN).
После перезапуска — «Address already in use». TIME_WAIT;
SO_REUSEADDR до bind.
Лампочка переключается «сама». Обновили страницу с адресом команды,
или сервер принял за команду запрос /favicon.ico. После команды —
ответ 303 на страницу.
Браузер бесконечно грузит страницу. Нет ни Content-Length, ни
закрытия соединения.
У страницы пропал хвост. Лишняя точка с запятой среди склеиваемых строк.
Веб-сервер на STM32 ведёт себя непредсказуемо с первого запроса. Буфер ответа на стеке больше всего стека.
Повторное подключение даёт ошибку. Второй connect того же сокета —
EISCONN; для нового соединения нужен новый сокет.
Программа на компьютере падает при обращении к регистру. Физические адреса процессу недоступны: виртуальная память.
78.9 Итоги
- Сокет — дескриптор соединения. Сервер:
socket→bind→listen→accept, клиент:socket→connect.acceptдаёт новый дескриптор на каждого клиента;listen(s, 1)не ограничивает их число. - Ошибка — −1 и
errno:ECONNREFUSED,EADDRINUSE,EADDRNOTAVAIL,EISCONN,EPIPE. - Порт —
htons, адрес —htonl; на Cortex-M перестановка байтов — одна командаrev16илиrev. - TCP — поток байтов: границы сообщений задаёт протокол,
recvможет вернуть часть, а при закрытии возвращает 0. SIGPIPEзавершает сервер при записи в закрытое соединение;SO_REUSEADDRснимаетEADDRINUSEпри перезапуске.- HTTP — текст: строка запроса, заголовки, пустая строка. Ответы 200,
303, 404, 405, 400; конец тела —
Content-Lengthили закрытие соединения. - Разбор запроса и страница не зависят от транспорта и переносятся на STM32 как есть; буфер ответа там — не на стеке.
- Кадр с байт-стаффингом:
7Eпо краям,7D 5Eи7D 5Dвнутри, худший случай 2n + 2; целостность проверяет CRC. - Процесс на компьютере живёт в виртуальной памяти, чужой адрес —
SIGSEGV. У STM32 MMU нет; у F407 есть MPU, у F103C8 — нет.
Дальше: раздел 79 «Как рождается устройство: этапы разработки» — начало части X.