Embedded HandbookПриложения
Все разделы › IX. Язык C: от основ до продвинутого
STM32F103, STM32F407 · уровень базовый · 18 мин чтения
Открыть в интерактивном справочнике

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 очередь соединений, ждущих accept
blocking 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 ждёт подключения и возвращает новый дескриптор 4, recv отдаёт 5 байт hello, а после close клиента возвращает 0

Ситуация errno
connect, а сервер не запущен ECONNREFUSED
bind на занятый порт EADDRINUSE
bind на адрес, которого нет у машины EADDRNOTAVAIL
повторный connect подключённого сокета EISCONN
запись в соединение, закрытое собеседником EPIPE

78.4 TCP — поток байтов, а не сообщений

Главное, что нужно понять про TCP: он гарантирует порядок и доставку байтов, но не хранит границ сообщений. В проверке:

Правило «один send — один recv» неверно. В локальной сети на столе оно часто выполняется — и ломается в настоящей. Границы задаёт протокол: разделитель (HTTP заканчивает заголовки пустой строкой), длина перед данными или кадры с флагами (78.7).

Два send слились в один recv, а десять байт пришли частями 4, 4 и 2

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 обрезал путь, переполнения нет

Три решения в этом коде стоят пояснения.

⚠️ Лишняя точка с запятой в странице. Длинную страницу склеивают из строк: соседние литералы "…" "…" компилятор объединяет в один. Одна лишняя ; посреди — и остаток превращается в отдельные выражения, которые ничего не делают:

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 это катастрофа:

Постоянные части — 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;
}

Что проверено:

Последнее — важный вывод: стаффинг находит границы, но не проверяет содержимое. В кадр добавляют контрольную сумму — 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 Итоги

Дальше: раздел 79 «Как рождается устройство: этапы разработки» — начало части X.

Открыть в интерактивном справочнике