henry_flower: A melancholy wolf (Default)
henry_flower ([personal profile] henry_flower) wrote2024-12-28 11:47 am

Експерименти з nolibc TCP-сервером

Such is human stupidity that whatever is difficult
to obtain is always thought to be better.
-- Peter Martyr d'Anghiera, 1524.

Колись давно, років 8 тому, ув часи сивочолого гетьмана і кволих реформ мусоріату, на HN з'явився лінка на http-сервера розміром 1KB. Не src того сервера був 1KB, а його статично зкомпільований байнарник. Все що той сервер вмів робити це відповідати на будь-який ріквест 1 файлом. Я проявив 0 інтересу, тому що написано воно було на asm/C ув пропорції 60/40.

Ідея такого розміру програм сподобалася французькому автору haproxy і він вмовив нарід ув LKML погодитися закомітити окрему бібліотеку для написання "nolibc" аплікацій. Прикладом такої аплікації є rcutorture--тестування кернела--де генерується невеличкий initrd, написаний на C без використання будь-якої libc.

Іронія ув необхідності спеціяльної бібліотеки щоб не використовувати стандартну бібліотеку нагадує спробу обійти коло, рухаючись по його контуру.

На тему < 1KB байнарників є класичний текста Really Teensy ELF Executables, але у ньому йде мова про асемблера, на який я не бажав звертати уваги ув минулому і на який не бажаю звертати уваги зараз.

Чи можна створювати крихітні (байнарі-вайз) аплікації без асемблеру на C без стандартної бібліотеки? Майже пусту main() лінкер--лінкує, але:

$ cat 42.v1.c
int main() { return 42; }

$ clang -Os 42.v1.c -o 42.v1.o -c
$ ld.lld -e main -o 42.v1 42.v1.o

$ ./42.v1
Segmentation fault (core dumped)

Виявляється, осемблер потрібен щоб кликати сисколи лайнакса:

$ cat 42.v2.c
static inline long syscall(long NR, long arg1) {
  long r;
  __asm__ volatile("syscall" : "=a" (r) : "a" (NR),
                   "D" (arg1) : "rcx", "r11", "memory");
  return r;
}
int main() { return 42; }
void _start() { syscall(60, main()); }

$ clang -Os 42.v2.c -o 42.v2.o -c
$ ld.lld -e _start -o 42.v2 42.v2.o
$ strip -s 42.v2

$ strace ./42.v2
execve("./42.v2", ["./42.v2"], 0x7ffc7190b9a0 /* 105 vars */) = 0
exit(42)                                = ?
+++ exited with 42 +++

Ага! Такий статичний байнарник має розмір 800 байт і залежить від 0 бібліотек. Номер сисколу (60) для exit можна подивитися ув файлі arch/x86/entry/syscalls/syscall_64.tbl кернельного src. На відміну від віндюка, де ABI змінюється з щорічними "feature updates", такий 800-байтовий ікзек'ютабл буде працювати доки є жива орхітектура x86_64.

Чи складно тоді, маючи обгортки для лайнаксних сисколів, написати елементарного TCP сервера для x86_64? Я вирішив спробувати:

$ _out/uptime-tcp-nolibc &
[1] 53161
$ nc --recv-only 127.0.0.1 1377
 07:16:29 up 1 day(s) 22:14,  load average: 0.19, 0.31, 0.30

Воно вертає шось схоже на команду uptime на ремоутній мошині, але не використовує ніяких екстернальних команд чи бібліотек. Якого розміру uptime-tcp-nolibc та скільки він їсть пам'яті написано ув кінці посту.

Якщо без libc немає жодного способу робити I/O чи відкривати сокета, тоді С стає фанкшонал мовою, про яку мріяли вчоні з 80х років, але, на їх невдачу, кернела набув IP стек та аналоги open(2) з write(2).

42.v2.c не вміє читати argv чи environ, а варіятивні хфункції у ньому роблять кордамп. Останне лікується

__attribute__((force_align_arg_pointer))
void _start() { ... }

З argv все є набагато гірше: якщо мета є саме отримувати поінтера на argv, потрібен знову осемблер (~14 рядків), який я тут постити не буду, щоб не лякати людей. Технічно, прочитати argv можна просто відкривши /proc/self/cmdline, ув якому \0 є роздільником аргументів:

$ tr \\0 \\n < /proc/self/cmdline
tr
\0
\n

але вас засміють ув інтервебах.

Перша каменюка на шляху до сокетів лежить ув man-сторінках відповідних libc враперів. Наприклад, bind(2) чекає на аргумента struct sockaddr *, опис якого ув man-сторінці ip(7) був скопійований з документації якоїсь 4.3BSD-Tahoe 1987 року і відповідає реальності нутрощів лайнуксного сьогодення з висоти дзвіниці Києво-Печерської лаври. (Потрібний додатковий паддінґ' unsigned char __pad[8].)

Якщо libc-врапери вертають -1 на помилку та виставляють errno, то сисколи вертають errno × -1. Ув справжніх libc errno є локальна для поточного треду. Для мікімаусного nolibc серверу писати свою версію позікс тредів це як збирати гелікоптер AK1-3 на подвір'ї поміж 2ма хрущовками, тому тут errno--глобальна змінна. Як зберігати інтерхфейса, до якого звикли всі, хто коли-небудь писав мережеві майкросервіси на C, то найпростіший враппер виглядає як

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen) {
  int r = my_syscall(43, sockfd, (long)addr, (long)addrlen, 0, 0, 0);
  if (r < 0) {
    errno = -r;
    r = -1;
  }
  return r;
}

Сигнали

Ув класичних Unix-серверах, якщо вони писалися не для inetd, конкурентна модель є форкова модель, яка вимагає прислуховуватися до SIGCHLD і чекати доки child завершить роботу, інакше після кожного ріквесту буде залишатися зомбі.

Форкова модель була популярна, тому що дозволяла серверу не лякатися сіґфолта, якщо сервер мав бага на який натрапив клайент: зупинявся лише child-невдаха, всі інші продовжували працювати.

Для встановлення колбеку на окремий сигнал лайнакс має свій rt_sigaction сискола. Ув аргументах той має поінтера на struct sigaction з дуже підступним філдом

void (*sa_restorer)(void);

Ув man-сторінках про нього написано ось таке:

'This flag is used by C libraries [тобто, нами] to indicate that the sa_restorer field contains the address of a "signal trampoline".

'… the C library's sigaction(2) wrapper function informs the kernel of the location of the trampoline code by placing its address in the sa_restorer field of the sigaction structure.'

Шматок з мікімаусного врапера:

int sigaction(int sig, const struct sigaction *act, struct sigaction *oact) {
  // ...

  struct kernel_sigaction kact, koact;
  kact.k_sa_handler = act->sa_handler;
  memcpy(&kact.sa_mask, &act->sa_mask, sizeof(sigset_t));
  // ...
  kact.sa_restorer = &restore_rt; // THE MOST IMPORTANT STEP

  int r = my_syscall(13, sig,
                     (long)(act?&kact:NULL), (long)(oact?&koact:NULL),
                     _NSIG/8, 0, 0);
  // ...
}

Звідкіля береться адреса хфункції для kact.sa_restorer? Це є та сама "signal trampoline", про необхідність якої попереджає лайнаксна документація. Звичайно, можна її не виставляти, але тоді жоден з сигналів не буде пійманий і жоден з колбеків не буде виконаний.

Спочатку я наївно написав

void restore_rt() {
  my_syscall(15, 0, 0, 0, 0, 0, 0); // 15 це rt_sigreturn
  __builtin_trap();
}

сигнали перехоплювалися, колбеки виконувалися,

СЄЧЄНОВ

Цей гіпноз корисний дуже,
У хазяйстві вєрно служить,
Злакі мощно прорастають
І надої возрастають!

але після завершення останніх сервер гепався через сіґфолта. На жаль, тут був потрібен осемблер знову:

__asm__(
  ".globl restore_rt\n"
  "restore_rt:\n"
  "    movq $15, %rax\n"
  "    syscall\n"
  "    hlt\n"
);

Чому не працює С-версія я не знаю. Клод ЕйАй вважає вона "may modify registers or the stack layout."

snprintf та gettimeofday

Життя без snprintf--життя варварів.
-- Невідомий філософ.

Ув src кернелу є приклад мінімалістичної vfprintf на 101 рядок, але вона не підтримує floats, які має друкувати uptime. Замість додавання їх підтримки, ліпше пошукати справжню snprintf, яка не використовує malloc. Така є ув pkg Федори (із усіх можливих місць саме тут) і називається stb_sprintf-devel. Це самотній .h файла, який зкомпільовується ув 18168 байт. Жахливе марнування ресурсів, але нічого не вдіяти. Тій хфункції потрібні va_start з друзями, але gcc та clang мають __builtin_va_* ув комплекті:

#define va_start(v,l)   __builtin_va_start(v,l)

Після інкорпорації stb_sprintf життя заграло новими барвами.

Дізнатися час ув секундах з початку буту можна зі сисколом sysinfo, поточний час--зі gettimeofday. Останній провайдить навіть кількість хвилин на захід від Ґрінвічу! Така розкіш.

Фіналє

Розмір нестріпованого статичного байнарника: 21736 байт. (Я до сих пір не можу повірити як це можливо.)

$ make info
wc -lc *.[ch] | while read -r lines bytes file; do \
 elf=-;\
 echo $file | grep -q '\.c$' && elf=`stat -c%s _out/${file%.*}.o`;\
 echo $file $lines $bytes $elf;\
done | column -t -N\ ,LINES,BYTES,ELF
            LINES  BYTES  ELF
main.c      134    3657   5896
signal.c    76     1699   1704
signal.h    113    2649   -
snprintf.c  6      120    18168
snprintf.h  12     274    -
sockets.c   37     896    1728
sockets.h   45     1119   -
u.c         98     2152   4496
u.h         46     1353   -
uptime.c    67     2148   2128
uptime.h    6      79     -
total       640    16146  -

Якби не stb_sprintf, розмір був би < 10KB.

Але це є не найбільша цікавинка. Ось кількість пам'яті, яку їсть такий конкурентний tcp-сервер після відповіді на 100 одночасних ріквестів:

$ sudo ps_mem -p `pgrep -f uptime-tcp-nolibc`
 Private  +   Shared  =  RAM used       Program

 36.0 KiB +   4.5 KiB =  40.5 KiB       uptime-tcp-nolibc
---------------------------------
                         40.5 KiB

Дуже сміюся. Ув сучасному світі, якщо споживання серверу, який робить 0 складного, < 80 _мегабайт_, то це є видатний вчинок і безстрашний прояв волі, за який треба сертифікат хоноровий, медаль та грошовий бонус для походу ув підпільне казино з повіями.

kondybas: (Default)

[personal profile] kondybas 2024-12-28 10:11 am (UTC)(link)
О! А я якраз журився, що досліди накрилися. Усе-таки, ноосфера бурлить в правильну сторону.
kondybas: (Default)

[personal profile] kondybas 2024-12-28 10:18 am (UTC)(link)
Нумо мені скальпеля ldd - подивимося, що там за статика.
juan_gandhi: (Default)

[personal profile] juan_gandhi 2024-12-28 11:58 am (UTC)(link)
I love it!!!
straktor: benders (Default)

[personal profile] straktor 2024-12-28 12:37 pm (UTC)(link)
спеціяльної бібліотеки щоб не використовувати стандартну бібліотеку

Потому что в стандартной за 50 лет нарос пресловутый блекджек с женщинами ста профессий. Позикс адаптеры, апи утилиты, варианты вызовов, эвристики, порты, багфиксы...
straktor: benders (Default)

[personal profile] straktor 2024-12-29 08:24 am (UTC)(link)
Кстати фактоид
Голанг генерит бинарники не юзающие либц, а сразу сисколлы. На Винде тоже!

На Винде вообще с++ аналог либц превратилось в десяток msvcrt версий от разных версий вижал студий и версий с++. Как-то длл хелл разруливают.
vak: (Default)

[personal profile] vak 2024-12-28 07:39 pm (UTC)(link)
Спрощення Сі-шного рантайму це цікавий напрямок.
vak: (Default)

[personal profile] vak 2024-12-29 05:54 am (UTC)(link)
Навпаки, корисний. Такі проекти вже існують, наприклад MUSL.

https://en.wikipedia.org/wiki/Musl
vak: (Default)

[personal profile] vak 2024-12-29 07:14 am (UTC)(link)
MUSL стартує в три рази швидше ніж Glibc. Я порівнював.

[personal profile] ymz5 2025-02-21 10:11 pm (UTC)(link)
Дуже колоритна у Вас мова. І про експерименти цікаво читати :)

А ось Вам челлендж: розділити "сіамських близнюків", IPv4 і IPv6 у ядрі Linux. Одна швейцарська фірма за таку роботу платить $100 :D