Настоящий многопоточный веб-сервер на ассемблере под Linux

от автора

Добрый день, хабр!
Сегодня я вам расскажу как написать свой настоящий веб-сервер на асме.

Сразу скажу что мы не будем использовать дополнительные библиотеки типа 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 выглядит таким образом:

127.0.0.1/00003F480000FFFF

То смещение в файле равно 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *