В данной статье я расскажу, как совместил U-Boot и TCP/IP стек lwIP, и c использованием lwIP написал веб-консоль на WebSocket, очень простой DHCP-сервер и HTTP-сервер. Код лежит на репозиториях U-Boot и lwIP.

UPD: В U-Boot уже появился встроенный lwIP, поэтому я переписал свою веб-консоль под него. Репозиторий про lwIP уже не актуален, все изменения в U-Boot. Всю статью не стал обновлять, актуализировал только раздел Запуск на эмуляторе QEMU.
Всё началось, когда мне подарили для экспериментов роутер Xiaomi Mi Wi-Fi Router 3C.

Я начал с компиляции из исходников OpenWRT для роутера, но это быстро наскучило. Так как с прошлых экспериментов у меня оставался программатор и USB-UART-конвертер CH341A, то я решил попробовать поменять загрузчик роутера, залив его напрямую в SPI Flash память роутера.

Загрузчик Breed
На 4PDA была готовая сборка OpenWRT c загрузчиком Breed. Это оказался кастомный загрузчик c Web-GUI и возможностью через браузер загружать прошивки. Но чтобы зайти в Web-GUI, необходимо нажать Enter в UART консоли. Как подключить UART, написано тут. А пользоваться можно через PuTTY. Находим в диспетчере устройств, какой виртуальный COM-порт создал конвертер.

А дальше вписываем этот номер в PuTTY:

И видим лог загрузчика Breed:

Остановив автозагрузку Linux, мы можем попасть в Web-GUI по адресу 192.168.1.1. Главное, чтобы этот адрес не совпадал с вашим основным роутером.

А также попасть в меню загрузки прошивки:

Мне очень понравилась идея с Web-GUI и мне захотелось повторить её на основе open source кода. Для этого я использовал популярный open source загрузчик U-Boot.
U-Boot
Роутер построен на базе MediaTek MT7628AN, а для этого чипа в U-Boot есть поддержка. Но даже сборка U-Boot из исходников оказалась нетривиальной задачей для человека, который сталкивается с этим в первый раз. Я буду показывать на примере виртуальной машины с Ubuntu 24.04.3 LTS.
Необходимо поставить пакеты:
sudo apt update wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb sudo apt -y install build-essential qemu-system-mips \ gcc-mips-linux-gnu bison flex libncurses-dev \ libssl-dev isc-dhcp-client ./google-chrome-stable_current_amd64.deb
Выкачать U-Boot:
git clone https://github.com/u-boot/u-boot.git
Выставить параметры кросс-компиляции:
export ARCH=mips export CROSS_COMPILE=mips-linux-gnu-
Применить конфигурацию устройства:
make mt7628_rfb_defconfig
Запустить сборку:
make
Для упрощённого заливания прошивки на роутер, я оставил на нём загрузчик Breed, а загрузчик U-Boot запаковывал в образ ядра Linux. Получается, загрузчик Breed запускал загрузчик U-Boot. А первоначально я залил загрузчик Breed через программатор.
mkimage -A mips -T kernel -C none -O linux -a 0x80200000 -e 0x80200000 \ -n "U-Boot" -d u-boot.bin u-boot.img
Загрузим образ загрузчика U-Boot:

И увидим в PuTTY лог загрузчика U-Boot:

Так как Breed имеет Web-GUI, то для U-Boot захотелось сделать хотя бы веб-консоль.
Для веб-консоли необходим WebSocket, а он в свою очередь основан на TCP. По умолчанию U-Boot умеет передавать данные только через UDP. А значит для использования TCP необходимо воспользоваться внешним TCP/IP стеком. Выбор пал на популярный lwIP.
lwIP
Первым делом я выкачал lwIPв папку /lib проекта U-Boot:
git clone https://github.com/lwip-tcpip/lwip.git
Сборка U-Boot основа на Kconfig, поэтому пришлось добавить сборку через Kconfig для lwIP. Подробнее про Kconfig можно почитать тут.
Пример части одного из makefile:
ccflags-y += -I$(obj)/../include obj-y += \ init.o \ def.o \ dns.o \ inet_chksum.o \ ip.o \ mem.o \ memp.o \
Для настройки lwIP необходимо создать файл lwipopts.h, в котором выставляются настройки TCP, поддерживаемые функции, размеры памяти и т. д. Подробнее можно почитать тут.
Пример части lwipopts.h:
#ifndef __LWIPOPTS_H__ #define __LWIPOPTS_H__ #define NO_SYS 1 #define SYS_LIGHTWEIGHT_PROT 0 #define LWIP_NETCONN 0 #define LWIP_SOCKET 0 #define LWIP_DHCP 1 #define MEM_ALIGNMENT 4 #define MEM_SIZE (8 * 1024 * 1024)
А также необходимо создать файл cc.h, который хранит настройки для компилятора.
Пример части cc.h:
#ifndef __ARCH_CC_H__ #define __ARCH_CC_H__ #include <stdio.h> #include <stdlib.h> #include <string.h> int atoi(const char *str); #ifdef CONFIG_SYS_BIG_ENDIAN #define BYTE_ORDER BIG_ENDIAN #else #define BYTE_ORDER LITTLE_ENDIAN #endif #define LWIP_NO_LIMITS_H 1 #define LWIP_NO_CTYPE_H 1 typedef uint8_t u8_t; typedef int8_t s8_t;
Методом проб и ошибок эти файлы были созданы.
Теперь необходимо передать пакет от драйвера Ethernet к lwIP.
У U-Boot есть механизм добавления callback-функции обработки входящих пакетов. Для этого необходимо включить API в конфигурации сборки.
make menuconfig

Код передачи пакета от драйвера Ethernet к lwIP
void eth_save_packet_lwip(void* packet, int length) { if (length > 0) { struct pbuf* p = pbuf_alloc(PBUF_RAW, length, PBUF_POOL); if (p != NULL) { pbuf_take(p, packet, length); if (netif.input(p, &netif) != ERR_OK) { pbuf_free(p); } } } }
push_packet = eth_save_packet_lwip;
Отправка пакета настраивается через функцию U-Boot eth_send.
Код передачи пакета от lwIP к драйверу Ethernet
err_t netif_output(struct netif* netif, struct pbuf* p) { unsigned char mac_send_buffer[p->tot_len]; pbuf_copy_partial(p, (void*)mac_send_buffer, p->tot_len, 0); eth_send(mac_send_buffer, p->tot_len); return ERR_OK; }
Инициализация lwIP, настройка IP-адреса, маски подсети и шлюза по умолчанию.
Код инициализации lwIP, настройка IP-адреса, маски подсети и шлюза по умолчанию.
eth_halt(); eth_init(); ip4_addr_t addr; ip4_addr_t netmask; ip4_addr_t gw; IP4_ADDR(&addr, 192, 168, 10, 1); IP4_ADDR(&netmask, 255, 255, 255, 0); IP4_ADDR(&gw, 192, 168, 10, 2); lwip_init(); netif_add(&netif, &addr, &netmask, &gw, NULL, netif_set_opts, netif_input); netif.name[0] = 'e'; netif.name[1] = '0'; netif_set_default(&netif);
Настройка MTU и MAC адреса.
Код настройки MTU и MAC адреса.
err_t netif_set_opts(struct netif* netif) { netif->linkoutput = netif_output; netif->output = etharp_output; netif->mtu = 1500; netif->flags = NETIF_FLAG_BROADCAST | NETIF_FLAG_ETHARP \ | NETIF_FLAG_ETHERNET | NETIF_FLAG_LINK_UP | NETIF_FLAG_UP; netif->hwaddr_len = 6; if (env_get("ethaddr")) string_to_enetaddr(env_get("ethaddr"), netif->hwaddr); else memset(netif->hwaddr, 0, 6); return ERR_OK; }
DHCP-сервер
Для удобной работы с веб-консолью необходимо, чтобы загрузчик выдавал пользователю IP-адрес, для этого пришлось написать очень упрощённый DHC-сервер, который на любой случай отдаёт один и тот же пакет, пользователю выдается IP-адрес 192.168.10.2, а загрузчик имеет адрес 192.168.10.1. Подробнее почитать можно тут. Код приведён в lwip_u_boot_port.c.
Код DHCP-сервера
struct udp_pcb *dhcp = udp_new(); udp_bind(dhcp, IP_ADDR_ANY, 67); udp_recv(dhcp , dhcp_recv, NULL);
void dhcp_recv(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port) { if(p == NULL) return; dhcps_msg dhcp_rec; int data_len = p->tot_len; pbuf_copy_partial(p, (void*)&dhcp_rec, data_len, 0); pbuf_free(p); int i = 4; while(dhcp_rec.options[i] != 255 && dhcp_rec.options[i] != 53) { i += dhcp_rec.options[i+1] + 2; }
HTTP-сервер
Чтобы веб-консоль работала, необходимо отдать http-страницу. Так как у нас статичная http-страница, то нам хватит самого простого http-сервера, который на любой случай отдаёт один и тот же запрос. Подробнее можно почитать тут. Но так как страница содержала в себе зависимости, пришлось их вставить напрямую в страницу для работы без интернета. Для этого очень пригодилась возможность команды xxd переводить файл в массив для C. Подробнее код приведён в lwip_u_boot_port.c.
xxd -include index.html
Пример вывода xxd
unsigned char http_ans[] = { 0x3c, 0x68, 0x74, 0x6d, 0x6c, 0x3e, 0x0a, 0x3c, 0x68, 0x65, 0x61, 0x64, 0x3e, 0x0a, 0x09, 0x3c, 0x73, 0x74, 0x79, 0x6c, 0x65, 0x3e, 0x0a, 0x09, 0x2e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x7b, 0x66, 0x6f, 0x6e, 0x74, 0x2d, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x2d, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x3a, 0x22, 0x6c, 0x69, 0x67, 0x61, 0x22, 0x20, 0x30, 0x3b, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x3a, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x76, 0x65, 0x3b, 0x75, 0x73, 0x65, 0x72, 0x2d, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x3a, 0x6e, 0x6f, 0x6e, 0x65, 0x3b, 0x2d, 0x6d, 0x73, 0x2d, 0x75, 0x73, 0x65, 0x72, 0x2d, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x3a, 0x6e, 0x6f, 0x6e, 0x65, 0x3b, 0x2d, 0x77, 0x65, 0x62, 0x6b, 0x69, 0x74, 0x2d, 0x75, 0x73, 0x65, 0x72, 0x2d, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x3a, 0x6e, 0x6f, 0x6e, 0x65, 0x7d, 0x2e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2e, 0x66, 0x6f, 0x63, 0x75, 0x73, 0x2c, 0x2e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x3a, 0x66, 0x6f, 0x63, 0x75, 0x73, 0x7b, 0x6f, 0x75, 0x74, 0x6c, 0x69, 0x6e, 0x65, 0x3a, 0x30, 0x7d, 0x2e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x20, 0x2e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x68, 0x65, 0x6c, 0x70, 0x65, 0x72, 0x73, 0x7b, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x3a, 0x61, 0x62, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x65, 0x3b, 0x74, 0x6f, 0x70, 0x3a, 0x30, 0x3b, 0x7a, 0x2d, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x3a, 0x31, 0x30, 0x7d, 0x2e, 0x78, 0x74, 0x65,
unsigned int http_ans_len = 227384;
Страница без вставки зависимостей
<html> <head> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/xterm/3.14.5/xterm.min.css" /> <script src="https://cdnjs.cloudflare.com/ajax/libs/xterm/3.14.5/xterm.min.js"></script> </head> <body> <div id="terminal"></div> <script> var opts = { cols: 80, rows: 54, convertEol : 1 } var term = new Terminal(opts); term.open(document.getElementById('terminal')); var ws = new WebSocket("ws://192.168.10.1:3000"); ws.binaryType = "arraybuffer"; ws.addEventListener('message', function (event) { term.write(event.data); }); term.on("key", function(key, ev) { if (ev.keyCode === 13) { ws.send("\n"); } else if (ev.keyCode === 8) { ws.send("\b"); } else { ws.send(key); } }); </script> </body> </html>
Код HTTP сервера
struct tcp_pcb* http = tcp_new(); tcp_bind(http, IP_ADDR_ANY, 80); http = tcp_listen_with_backlog(http, TCP_DEFAULT_LISTEN_BACKLOG); tcp_accept(http, http_accept);
err_t http_recv(void* arg, struct tcp_pcb* tpcb, struct pbuf* p, err_t err) { int data_len = p->tot_len; char tcp_rec[data_len]; pbuf_copy_partial(p, (void*)tcp_rec, data_len, 0); tcp_recved(tpcb, data_len); pbuf_free(p); char answer_html[1000]; sprintf(answer_html, HTTP_RSP, http_ans_len); tcp_write(tpcb, answer_html, strlen(answer_html), 0x01); http_ans_sended = 0; return ERR_OK; }
const char HTTP_RSP[] = \ "HTTP/1.1 200 OK\r\n" \ "Content-Length: %d\r\n" \ "Content-Type: text/html\r\n\r\n";
WebSocket-сервер
С WebSocket я работал в первый раз, пришлось вникать, как он работает. Протокол используем SHA1 и Base64, эти библиотеки необходимо было добавить в исходники. Подробнее, как работает WebSocket-протокол, можно почитать тут. Подробнее код приведён в lwip_u_boot_port.c.
Код WebSocket-сервера
const char WS_RSP[] = \ "HTTP/1.1 101 Switching Protocols\r\n" \ "Upgrade: websocket\r\n" \ "Connection: Upgrade\r\n" \ "Sec-WebSocket-Accept: %s\r\n\r\n"; const char WS_GUID[] = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; const char WS_KEY[] = "Sec-WebSocket-Key: ";
err_t websocket_recv(void* arg, struct tcp_pcb* tpcb, struct pbuf* p, err_t err) { if (p == NULL) { web_socket_open = 0; globa_tcp = NULL; tcp_get = 0; return ERR_OK; } int data_len = p->tot_len; char tcp_rec[data_len]; pbuf_copy_partial(p, (void*)tcp_rec, data_len, 0); tcp_recved(tpcb, data_len); if(web_socket_open == 0){ char * sec_websocket_position_start = strstr(tcp_rec, WS_KEY); if(sec_websocket_position_start){
Веб-консоль
Теперь, когда у нас есть все составляющие, мы можем найти место ввода/вывода обычной консоли U-Boot и заменить на символы пришедшие/ушедшие из WebSocket. Инициализацию веб-консоли мы вставляем перед бесконечным циклом, который ждет команд, файл main.c. А сама консоль находится в файле console.c. Отправляемый символ или символы необходимо преобразовать, используя правила WebSocket.
Код веб-консоли
lwip_u_boot_port(); cli_loop();
#ifndef CONFIG_SPL_BUILD if(push_packet) { eth_rx(); char tmp = tcp_get; tcp_get = 0; if(tmp) return tmp; } #endif
#ifndef CONFIG_SPL_BUILD if(globa_tcp) { unsigned char buf[3]; buf[0] = 0x80 | 0x01; buf[1] = 1; buf[2] = c; tcp_write(globa_tcp, buf, 3, 1); tcp_output(globa_tcp); } else { #endif
#ifndef CONFIG_SPL_BUILD if(globa_tcp) { int len = strlen(s); unsigned char buf[150]; while (len) { int send_len = min(len, 125); buf[0] = 0x80 | 0x01; buf[1] = send_len; memcpy(&buf[2], s, send_len); tcp_write(globa_tcp, buf, send_len + 2, 1); len -= send_len; s += send_len; } tcp_output(globa_tcp); } else { #endif
Запуск на эмуляторе QEMU
Каждый раз заливать загрузчик на роутер - не самая быстрая затея, и для удобной проверки работоспособности я решил настроить U-Boot под QEMU.
Я буду показывать на примере виртуальной машины с Ubuntu 24.04.3 LTS.
Необходимо поставить пакеты:
sudo apt update wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb sudo apt -y install build-essential qemu-system-mips \ gcc-mips-linux-gnu bison flex libncurses-dev \ libssl-dev isc-dhcp-client ./google-chrome-stable_current_amd64.deb
Выкачать U-Boot:
git clone https://github.com/karen07/u-boot.git git checkout WebConsole
Выставить параметры кросс-компиляции:
export ARCH=mips export CROSS_COMPILE=mips-linux-gnu-
Запускаем конфигурацию под другое устройство Malta:
make malta_defconfig
Зайти в настройки сборки:
make menuconfig
Включаем lwIP, в меню Networking -> Networking stack -> Use lwIP for networking stack включить.

Теперь в меню Networking появится опция Enable web console, её надо включить.

Запускаем сборку:
make
Далее необходимо создать виртуальный Ethernet:
sudo ip tuntap add dev tap0 mode tap && sudo ip link set dev tap0 up
Запускаем U-Boot в Qemu:
sudo qemu-system-mips -M malta -m 256 --nographic -net nic \ -net tap,ifname=tap0,script=no,downscript=no -bios u-boot.bin
Получаем IP-адрес:
sudo dhclient tap0
Запускаем браузер и заходим на 192.168.10.1:

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