Сегодня я вам расскажу как написать свой настоящий веб-сервер на асме.
Сразу скажу что мы не будем использовать дополнительные библиотеки типа libc. А будем пользоваться тем что предоставляет нам ядро.
Уже только ленивый не писал подобных статей, — сервер на perl, node.js, по-моему даже были попытки на php.
Вот только на ассемблере еще не было, — значит нужно заполнить пробелы.
Немного истории
Как-то раз мне нужно было хранить мелкие файлы (меньше 1Kb) их было ооочень много, я боялся за ext3, и решил я хранить все эти файлы в одном большом, а отдавать посредством веб-сервера, задавая в get параметре смещение и длину самого файла в hex виде.
Времени было прилично, решил я немного извратиться и написать это на асме.
Итак, приступим
Писать будем на FASM, т.к. нравится он мне, да и к Intel-синтаксису я привык.
Итак, стандартная процедура создания elf:
format elf executable 3 entry _start segment readable writeable executable
Далее некоторые данные для заголовков:
HTTP200 db "HTTP/1.1 200 OK", 0xD,0xA ; CTYPE db "Content-Type: application/octet-stream", 0xD,0xA ; CNAME db 'Content-Disposition: attachment; filename="BIGTABLE"',0xD,0xA,0xD,0xA ; SERVER db 'Server: Kylie',0xD,0xA ; KeepClose db 'Connection: close',0xD,0xA,0xD,0xA ; и переменные для sendfile off_set dd 0x00 n_bytes dd 0x00
А также путь к тому самому большому файлу в котором хранятся все картинки:
FILE1 db "/home/andrew/FILE.FBF",0
Определим несколько констант для удобства:
IPPROTO_TCP equ 0x06 SOCK_STREAM equ 0x01 PF_INET equ 0x02 AF_INET equ 0x02
Подключим самописную функцию перевода из str в hex
include 'str2hex.asm'
Принцип работы данной функции прост:
Забиваем в google.com.ua «Таблица ASCI», — распечатываем, и смотрим на нее…
Замечаем, что значения в ASCII от 0 — 9 соответствуют значениям от 30h до 39h
А значения от A до F в диапазоне от 41h до 46h
Входной параметр для макроса — адрес буфера в esi (по этому адресу — строка, которую надо перевести из str в hex)
Макрос просто проверяет код ASCII символа и если он больше 39h, — то работаем с A — F, если меньше или равно ему то с 0 — 9
Вот его полный код:
; esi,- адрес на строковый id Возвращаемые значения: ; eax - результат работы Macro STR2HEX4 { local str2hex,bin2hex, out_buff, func, result, nohex ; // Локальный макрос для определения (строка больше 9 (т.е. A..F) или меньше) cld ;// Флаг направления (в сторону увеличения) mov edi,out_buff ; jmp func ;// Та самая проверка str2hex: cmp al,39h jle nohex sub al,07h nohex: sub al,30h ret out_buff dd 0x00 func: ; // Будем считать 4 раза (32 бит) mov ecx,4 bin2hex: lodsb ;// Загрузим первое значение call str2hex ;// Конвертируем его ASCII код в значение shl al,4 ; // Сдвинем на 4 (это будут старшие 4 бита) mov bl,al ; // Сохраним его в bl lodsb ; // Загрузим следующий call str2hex ; // Конвертируем (Это будут младшие 4 бита) xor al,bl ; // Объединим старшие и младшие биты ; // Все готово, теперь в AL у нас результат от первой пары символов stosb ; // Сохраним его в edi на всякий пожарный sub ecx,1 ; // Уменьшим счетчик на 1 jecxz result ; Продолжаем пока ecx != 0 jmp bin2hex ; result: ;// В результате все аккуратно сложим в регистр eax xor eax,eax cld mov esi,out_buff lodsb shl eax,8 lodsb shl eax,8 lodsb shl eax,8 lodsb ; На выходе - значение в eax }
P.S. Функция лишена обработчиков ошибок, поэтому надеюсь вы будете правильно задавать размер-смещение (обратите внимание, параметры регистрозависимы. Т.е. A != a, B =! b и т.д.)
Также максимальный размер и максимальное смещение = 32 бит.
Разобрались, поехали дальше:
Теперь наконец пришло время создать сокет
; // Заполняем структуру для сокета push IPPROTO_TCP ; IPPROTO_TCP (=6) push SOCK_STREAM ; SOCK_STREAM (=1) push PF_INET ; PF_INET (=2) ;socketcall mov eax, 102 ; // Функция 102 (работа с сокетами) mov ebx, 1 ; // 1 говорить что нужно создать сокет mov ecx, esp ; // Указатель на нашу структуру в стеке int 0x80 mov edi,eax ; // Сохраним значение в edi, т.к. он нам еще пригодится cmp eax, -1 je near errn ; // Проверим на ошибки
Сокет создан, биндим его на адрес 0.0.0.0 (в простонароде — INADDR_ANY) и порт 8080 (т.к. на 80м у меня работает lighttpd, и если поменять на 80й то в eax вернется 0 и произойдет ошибка -EADDRINUSE говорящая о том что порт уже занят)
; binding push 16 ; socklen_t addrlen push ecx ; const struct sockaddr *my_addr push edi ; int sockfd mov eax, 102 ; socketcall() syscall mov ebx, 2 ; bind() = int call 2 mov ecx, esp ; // Указатель int 0x80 cmp eax, 0 jne near errn ;// Проверим на ошибки (если порт занят например...)
Кстати про использование INADDR_ANY. Если вы хотите использовать localhost, или любой другой адрес вы должны написать его «наоборот». Т.е.
localhost = 127.0.0.1 = 0x0100007F
habrahabr.ru = 212.24.43.44 = 2C2B18D4
Тоже самое каcается и номеров порта:
8080 = 901Fh
25 = 1900h
Конечно вам ничего не мешает указать ip как-то так:
localhost db 127,0,0,1
habrahabr.ru db 212,24,43,44
и т.д.
Ну и наконец начинаем прослушивать сам сокет на принятие новых соединений:
push 1 ;// int backlog push edi ;// int sockfd pop esi push edi mov eax, 102 ; // syscall mov ebx, 4 ;// указывает что необходимо прослушивать сокет (listen) mov ecx, esp ; // указатель на нашу структуру int 0x80
Теперь важный момент. Т.к. мы будем работать с процессами, то родительский процесс будет ожидать код возврата от дочернего после fork, и при завершении дочернего процесса родитель так и будет «думать» что он еще есть. Таким образом из дочерних процессов появляются зомби. Если мы скажем родителю что будем игнорировать эти сигналы то никого никто ждать не будет, и зомби появляться также не будут:
mov eax,48 mov ebx,17 mov ecx,1 ; SIG_IGN int 0x80
Создаем структуру для accept и начинаем принимать соединения:
push 0x00 push 0x00 ; struct sockaddr *addr push edi ; int sockfd sock_accept: mov eax, 102 ; socketcall() syscall mov ebx, 5 ; accept() = int call 5 mov ecx, esp int 0x80 ; // Проверка на ошибки: cmp eax, -1 je near errn mov edi, eax ; Теперь в edi будет хранится mov [c_accept],eax
Если ошибок никаких не возникло и мы оказались в этой части кода, значит подключился новый клиент
Создадим процесс для обработки:
mov eax,2 ; // Системный вызов sys_fork() int 0x80 cmp eax,0 jl exit ; if error
Теперь выясним кем мы тут являемся, форком или родительским процессом:
test eax,eax jnz fork ; Переходим на отработку запроса от клиента (дочерний процесс) ; edi - accept descriptor ; // Закрываем коннекшн в родителе и возвращаемся к принятию других клиентов mov eax, 6 ; close() syscall mov ebx, edi ; The socket descriptor int 0x80 ; Call the kernel jmp sock_accept fork: ;// Дальше - код обработки запроса
Все! «Голова» нашего сервера готова.
Дальше идет код исключительно для дочернего процесса
Отправим клиенту статус 200 OK
mov eax, 4 ; write() syscall mov ebx, edi ; sockfd mov ecx, HTTP200 ; Send 200 Ok mov edx, 17 ; 17 characters in length int 0x80 ;
Также тип контента. «application/octet-stream» — самый универсальный в данном случае
mov eax, 4 ; write() syscall mov ebx, edi ; sockfd mov ecx, CTYPE ; Content-type - 'application/octet-stream' mov edx, 40 ; 40 characters in length int 0x80 ; Call the kernel
Название сервера:
mov eax, 4 ; write() syscall mov ebx, edi ; sockfd mov ecx, SERVER ; our string to send mov edx, 15 ; 15 characters in length int 0x80 ; Call the kernel
Так как наш сервер пока не поддерживает Keep-Alive то признаемся в этом:
mov eax, 4 ; write() syscall mov ebx, edi ; sockfd mov ecx, KeepClose ; Connection: Close mov edx, 21 ; 21 characters in length int 0x80 ; Call the kernel
Обратите внимание, необходимо отправить в конце два раза 0xD 0xA (мы это сделали вместе с отправкой Connection: Close) и можно считать что с заголовками покончено
Ну а теперь собственно узнаем какой файл хочет скачать клиент. Для этого поместим в буфер запрос GET со сдвигом в 5 байтов влево, тем самым обрезая ненужную информацию(‘GET /’), оставляя только чистый ID размером в 16 байт.
Ах да, я все об id, id … А что он из себя представляет? Я решил все сделать просто, указав в GET 32-битное значение для смещения в файле, и сразу за ним 32 битное значение равное размеру файла.
Т.е. если запрос URL выглядит таким образом:
То смещение в файле равно 00003F48 а размер запрошенных данных — 0000FFFF
mov esi,buffer ; // Поместим адрес откуда читать наш id (для STR2HEX) push edi ; Сохраним edi т.к. макрос его очищает STR2HEX4 ; Макрос принимает буфер по адресу esi pop edi ; возвратим edi mov [off_set],eax ; // функция возвратила значение в eax, сохраним ее в переменной
Теперь нам нужно открыть большой файл, где начало файла будет с заданным смещением:
Сейчас просто откроем его (дескриптор будет сохранен в eax):
; Open BIG file mov eax,5 mov ebx,FILE1 mov ecx, 2 int 0x80
Теперь для полного удовлетворения пришло время использовать функцию sendfile.
Как пишут в мануалах:
Because this copying is done within the kernel, sendfile() is more efficient than the combination of read(2) and write(2), which would require transferring data to and from user space.
; Send [n_bytes] from BIGTABLE starting at [off_set] send_file: mov ecx,eax ; file descriptor from previous function mov eax,187 mov ebx,edi ; socket mov edx,off_set ; pointer mov esi,[n_bytes] ; int 0x80
Как вы поняли дескриптор из eax мы скопировали в ecx для функции sendfile, не сохраняя его в промежуточных регистрах\памяти.
success
Вот здесь в свое время я долго не спал по ночам, потому что не мог понять почему же после отправки всех байт файл не скачивается полностью, а за секунду до полного скачивания браузер пишет «Сетевая ошибка» и его не сохраняет. В sendfile ошибок не возникало, пришлось научится пользоваться chrome developer tools.
Оказывается что после отправки самого файла, браузер шлет заголовок, который сервер должен принять. Не важно какие там данные, — его все равно можно отослать в /dev/null но очень важно что бы сервер его прочел. Иначе браузер посчитает что с файлом что-то не то. Зачем именно так сделано — на 100% мне неизвестно. Мне кажется что это связано с возможным отсутствием Content-Length в заголовках, когда файл принять надо, а сколько данных браузер заведомо не знает. Буду признателен если кто-то откроет тайну )))
Итак, принимаем браузерный хедер:
Читаем из адреса в edi, в адрес buffer
; Read the header mov eax,3 mov ebx,edi mov ecx,buffer mov edx,1024 int 0x80
Если заголовки не слишком большие то 1024 байта вполне хватит
(Если на этом домене не используете длинных кук и т.д.)
Закрытие файла и завершение:
mov eax, 6 ; close() syscall mov ebx, edi ; The socket descriptor int 0x80 ; Call the kernel ; end to pcntl_fork () mov eax,1 xor ebx,ebx int 0x80
Вообще файл можно держать открытым какое-то время в родителе, и использовать его остальными форками, для экономии времени. Но это не совсем правильный вариант.
И самое главное!
Никаких внешних библиотек!
root@server:/home/andrew# ldd server
not a dynamic executable
Ссылка для скачивания (можно проверить работает\нет, протестить бенчмарком ab например)))
http://ubuntuone.com/3yNexPG0yewlGnjNd6219W
P.S. В коде упущено множество проверок на ошибки, также в некоторых кусках кода не подчищается стек, наличие некоторых переменных подобрано вручную (за отсутствием нормальной документации), и в общем код не претендует на звание самого «чистого».
Сервер хорошо работает на многоядерных системах (проверено на Core I7 2600). Он обгоняет lighttpd у меня на сервере по статике почти в 4 раза, хотя я думаю что мой lighttpd просто не настроен на многоядерность.
Что быстро можно добавить:
Ну например cgi для любого языка (php, perl, python) и т.д. Также возможно убрать считывание из файла, и написать работу с файловой системой а также добавить виртуальные хосты. А вообще все ограничено только вашей фантазией.
ссылка на оригинал статьи http://habrahabr.ru/post/188114/
Добавить комментарий