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