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

из песочницы
Hocok_B_KapMaHe 28 июля 2013 в 18:23 62k
Добрый день, хабр!
Сегодня я вам расскажу как написать свой настоящий веб-сервер на асме.

Сразу скажу, что мы не будем использовать дополнительные библиотеки типа 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) и т.д. Также возможно убрать считывание из файла, и написать работу с файловой системой а также добавить виртуальные хосты. А вообще все ограничено только вашей фантазией.
Проголосовать:
+171
Сохранить: