Linux jest systemem typowo sieciowym. Nawet niektóre usługi systemowe działają jako serwery sieciowe, umożliwiając dostęp maszynom z zewnątrz. A ja bez niepotrzebnego zagłębiania się w porty, protokoły i inne szczegóły dotyczące sieci, pokażę teraz, jak napisać prosty, własny serwerek i klienta do niego.
Komunikacja w sieci odbywa się z wykorzystaniem wielu różnych elementów. Podstawowym pojęciem jest gniazdo (ang. socket). Jest to logiczne (czyli nie istniejące fizyczne) urządzenie będące podstawową bramką, przez którą przepływają informacje. Gniazdko tworzy się funkcją socket (z biblioteki języka C, tak jak wszystkie późniejsze). Przyjmuje ona 3 argumenty (patrz: man 2 socket):
Jeśli utworzenie gniazda nie udało się, funkcja socket zwróci wartość -1. Jeśli się udało, zwróci liczbę całkowitą - deskryptor otwartego gniazda (podobnie, jak w plikach). Po zakończeniu pracy gniazdo można zamknąć funkcją close.
Od chwili utworzenia gniazda dalszy kod w serwera i klienta różnią się, więc omówię je po kolei.
Jak wiemy, zadaniem serwera jest nasłuchiwanie połączeń od klientów. Aby to osiągnąć, należy wykonać następujące kroki.
W chwili utworzenia, gniazdo nie jest jeszcze przypisane do adresu, a przecież trzeba jakoś określić, na jakim adresie i porcie nasłuchuje nasz serwer. Służy do tego funkcja bind. Przyjmuje ona następujące argumenty (patrz: man 2 bind):
Choć definicja funkcji bind mówi o strukturze sockaddr, to funkcji tej podaje się odpowiednio rzutowany wskaźnik do struktury sockaddr_in. Ta struktura wygląda tak:
struc sockaddr_in .sin_family resw 1 ; rodzina adresów .sin_port resw 1 ; numer portu .sin_addr resd 1 ; adres resb 8 ; dopełnienie do 16 bajtów endstruc
Do pola sin_family wpisujemy AF_INET=2, oznaczające rodzinę adresów internetowych.
Do pola sin_port wpisujemy numer portu, na którym będzie nasłuchiwał nasz serwer. Ale uwaga - nie bezpośrednio! Najpierw numer portu musi zostać przetłumaczony na sieciowy porządek bajtów funkcją htons (patrz: man htons). Dopiero wynik funkcji, której podajemy numer portu, wpisujemy w to pole. Programy bez uprawnień administratora mogą korzystać tylko z portów o numerach powyżej 1023.
Do pola sin_addr wpisujemy wartość INADDR_ANY=0, co oznacza, że chcemy nasłuchiwać na dowolnym adresie.
W przypadku błędu, bind zwraca -1.
Aby włączyć nasłuchiwanie na danym gnieździe, należy użyć funkcji listen. Przyjmuje ona dwa argumenty (patrz: man 2 listen):
W przypadku błędu, listen zwraca -1.
Jeśli funkcja listen się powiedzie, to można z serwerem przejść w tryb demona (o tym w kursie o pisaniu programów rezydentnych).
Po włączeniu nasłuchiwania na gnieździe możemy zacząć przyjmować połączenia od klientów. Przyjęcie połączenia odbywa się funkcją accept. Przyjmuje ona trzy argumenty (patrz: man 2 accept):
Gdy klient już się połączył, accept zwraca deskryptor nowego gniazda, które będzie służyć do komunikacji z klientem.
W porównaniu z serwerem, w kliencie jest mniej pracy. Po utworzeniu gniazda do połączenia się z serwerem wystarczy jedna funkcja - connect. Przyjmuje ona trzy argumenty (patrz: man 2 connect):
Tutaj także zamiast struktury sockaddr przekazujemy adres struktury sockaddr_in. Jednak trzeba ją trochę inaczej wypełnić.
Pola sin_family i sin_port wypełniamy tak samo, jak dla bind. W końcu chcemy się połączyć do tego samego portu, na którym nasłuchuje serwer.
Pole sin_addr wypełniamy adresem IP serwera. Oczywiście nie wprost jako łańcuch znaków, ale odpowiednio przerobionym. Do przerobienia łańcucha znaków 127.0.0.1 (oznaczającego zawsze bieżący komputer dla niego samego) na właściwą postać posłuży nam funkcja inet_aton. Przyjmuje ona 2 argumenty (patrz: man inet_aton):
Struktura in_addr jest jedyną składową pola sin_addr w naszej strukturze sockaddr_in i to adres tego właśnie pola podajemy funkcji inet_aton.
Po poprawnym wykonaniu połączenia funkcją connect, można przystąpić do wymiany danych.
Po dokonaniu połączenia obie strony - klient i serwer - mają gotowe gniazda, którymi mogą się komunikować. Do wymiany danych służą dwie podstawowe funkcje: send i recv. Obie przyjmują dokładnie te same cztery parametry (patrz: man 2 send, man 2 recv):
Po przebrnięciu przez tł trudną teorię możemy wreszcie przystąpić do pisania programów. Wiem, że sucha teoria nie umożliwi natychmiastowego napisania programów serwera i klienta (jest wiele pułapek, na które trzeba zwrócić uwagę), dlatego prezentuję tutaj przykładowe programy serwera i klienta (składnia NASMa).
Serwer:
; Program serwera ; ; autor: Bogdan D., bogdandr (at) op.pl ; ; kompilacja: ; nasm -O999 -f elf -o serwer.o serwer.asm ; gcc -o serwer serwer.o section .text global main ; będziemy korzystać z biblioteki C, więc ; funkcja główna musi się nazywać "main" ; definicje kilku przydatnych stałych %define PF_INET 2 %define AF_INET PF_INET %define SOCK_STREAM 1 %define INADDR_ANY 0 %define NPORTU 4242 %define MAXKLIENT 5 ; maksymalna liczba klientów ; zewnętrzne funkcje z biblioteki C, z których będziemy korzystać extern daemon extern socket extern listen extern accept extern bind extern htons extern recv extern send extern close main: push dword 0 push dword SOCK_STREAM push dword AF_INET call socket ; tworzymy gniazdo: ;socket(AF_INET,SOCK_STREAM,0); add esp, 12 ; usuwamy argumenty ze stosu cmp eax, 0 ; EAX < 0 oznacza błąd jl .sock_blad mov [gniazdo], eax ; zachowujemy deskryptor gniazda push word NPORTU call htons ; przerabiamy numer portu na ; właściwy format ; htons(NPORTU); add esp, 2 ; wpisujemy przerobiony numer portu: mov [adres+sockaddr_in.sin_port], ax ; rodzina adresów internetowych: mov word [adres+sockaddr_in.sin_family], AF_INET ; akceptujemy każdy adres mov dword [adres+sockaddr_in.sin_addr], INADDR_ANY push dword sockaddr_in_size push dword adres push dword [gniazdo] call bind ; przypisujemy gniazdo do adresu: ; bind(gniazdo,&adres,sizeof(adres)); add esp, 12 cmp eax, 0 jl .bind_blad push dword MAXKLIENT push dword [gniazdo] call listen ; włączamy nasłuchiwanie: ; listen(gniazdo,MAXKLIENT); add esp, 8 cmp eax, 0 jl .list_blad push dword 1 push dword 1 call daemon ; przechodzimy w tryb demona add esp, 8 ; usuniecie argumentów ze stosu mov dword [rozmiar], sockaddr_in_size .czekaj: push dword rozmiar ; [rozmiar] zawiera rozmiar ; struktury sockaddr_in push dword adres push dword [gniazdo] call accept ; czekamy na połączenie ; accept(gniazdo,&adres,&rozmiar) add esp, 12 cmp eax, 0 jl .czekaj mov [gniazdo_kli], eax ; gdy accept się udało, ; zwraca nowe gniazdo klienta .rozmowa: push dword 0 push dword buf_d push dword bufor push dword [gniazdo_kli] call recv ; odbieramy dane; ; recv(gniazdo_kli,&bufor,sizeof(bufor),0); add esp, 16 cmp eax, 0 ; jeśli błąd, to czekamy ponownie jl .rozmowa cmp byte [bufor], "Q" ; ustalamy, że Q kończy transmisję je .koniec mov ecx, buf_d mov edi, bufor xor eax, eax cld rep stosb ; czyścimy bufor push dword 0 push dword 2 push dword ok push dword [gniazdo_kli] call send ; wysyłamy dane ; (na cokolwiek odpowiadamy "OK") ; send(gniazdo_kli,&ok,2,0); add esp, 16 jmp .rozmowa ; i czekamy od nowa .koniec: push dword 0 push dword buf_d push dword bufor push dword [gniazdo_kli] call send ; wysyłamy Q, które jest w buforze add esp, 16 push dword [gniazdo_kli] call close ; zamykamy gniazdo klienta add esp, 4 ; jeśli chcemy, aby serwer nasłuchiwał kolejnych połączeń, piszemy tu: ;;; jmp .czekaj ; serwera nie da się wtedy inaczej zamknąć niż przez zabicie procesu push dword [gniazdo] call close ; zamykamy gniazdo główne serwera add esp, 4 mov eax, 1 xor ebx, ebx int 80h ; wychodzimy z programu ; obsługa błędów: .sock_blad: mov eax, 4 mov ebx, 1 mov ecx, blad_socket mov edx, blad_socket_d int 80h ; wyświetlenie napisu mov eax, 1 mov ebx, 1 int 80h ; wyjście z programu z ; odpowiednim kodem błędu .bind_blad: mov eax, 4 mov ebx, 1 mov ecx, blad_bind mov edx, blad_bind_d int 80h push dword [gniazdo] call close ; zamykamy gniazdo mov eax, 1 mov ebx, 2 int 80h .list_blad: mov eax, 4 mov ebx, 1 mov ecx, blad_listen mov edx, blad_listen_d int 80h push dword [gniazdo] call close ; zamykamy gniazdo mov eax, 1 mov ebx, 3 int 80h section .data ; deskryptory gniazd: gniazdo dd 0 gniazdo_kli dd 0 bufor times 20 db 0 ; bufor odbiorczo-nadawczy buf_d equ $ - bufor ; długość bufora ; komunikaty błędów: blad_socket db "Problem z socket!", 10 blad_socket_d equ $ - blad_socket blad_bind db "Problem z bind!", 10 blad_bind_d equ $ - blad_bind blad_listen db "Problem z listen!", 10 blad_listen_d equ $ - blad_listen ok db "OK" ; to, co wysyłamy struc sockaddr_in .sin_family resw 1 ; rodzina adresów .sin_port resw 1 ; numer portu .sin_addr resd 1 ; adres resb 8 ; dopełnienie do 16 bajtów endstruc adres istruc sockaddr_in ; adres jako zmienna, która ; jest strukturą rozmiar dd sockaddr_in_size ; rozmiar struktury
; Program klienta ; ; autor: Bogdan D., bogdandr (at) op.pl ; ; kompilacja: ; nasm -O999 -f elf -o klient.o klient.asm ; gcc -o klient klient.o section .text global main ; będziemy korzystać z biblioteki C, więc ; funkcja główna musi się nazywać "main" ; definicje kilku przydatnych stałych %define PF_INET 2 %define AF_INET PF_INET %define SOCK_STREAM 1 %define INADDR_ANY 0 %define NPORTU 4242 ; zewnętrzne funkcje z biblioteki C, z których będziemy korzystać extern socket extern connect extern htons extern recv extern send extern close extern inet_aton main: push dword 0 push dword SOCK_STREAM push dword AF_INET call socket ; tworzymy gniazdo: ; socket(AF_INET,SOCK_STREAM,0); add esp, 12 ; usuwamy argumenty ze stosu cmp eax, 0 ; EAX < 0 oznacza błąd jle .sock_blad mov [gniazdo], eax ; zachowujemy deskryptor gniazda ; rodzina adresów internetowych: mov word [adres+sockaddr_in.sin_family], AF_INET push dword (adres + sockaddr_in.sin_addr) push dword localhost call inet_aton ; przerabiamy adres 127.0.0.1 na ; właściwy format add esp, 8 test eax, eax ; EAX = 0 oznacza, że adres ; był nieprawidłowy jz .inet_blad push word NPORTU call htons ; przerabiamy numer portu ; na właściwy format add esp, 2 ; wpisujemy przerobiony numer portu: mov word [adres+sockaddr_in.sin_port], ax push dword sockaddr_in_size push dword adres push dword [gniazdo] call connect ; łączymy się z serwerem: ; connect(gniazdo,&adres,sizeof(adres)); add esp, 12 cmp eax, 0 jne .conn_blad .rozmowa: mov eax, 3 mov ebx, 0 mov ecx, bufor mov edx, buf_d int 80h ; wczytujemy dane ze ; standardowego wejścia push dword 0 push dword buf_d push dword bufor push dword [gniazdo] call send ; wysyłamy to, co wczytaliśmy: ; send(gniazdo,&bufor,sizeof(bufor),0); add esp, 16 cmp eax, 0 jl .send_blad mov ecx, buf_d mov edi, bufor xor eax, eax cld rep stosb ; czyścimy bufor .odbieraj: push dword 0 push dword buf_d push dword bufor push dword [gniazdo] call recv ; odbieramy dane od serwera: ; recv(gniazdo,&bufor,sizeof(bufor),0); add esp, 16 cmp eax, 0 jl .odbieraj mov eax, 4 mov ebx, 1 mov ecx, odebrano mov edx, odebrano_dl int 80h ; wypisujemy, co odebraliśmy cmp byte [bufor], "Q" ; "Q" kończy transmisję jne .rozmowa push dword [gniazdo] call close ; zamykamy gniazdo add esp, 4 mov eax, 1 xor ebx, ebx int 80h ; wychodzimy z programu ; sekcja obsługi błędów .sock_blad: mov eax, 4 mov ebx, 1 mov ecx, blad_socket mov edx, blad_socket_d int 80h ; wyświetlenie napisu mov eax, 1 mov ebx, 1 int 80h ; wyjście z programu z ; odpowiednim kodem błędu .conn_blad: mov eax, 4 mov ebx, 1 mov ecx, blad_connect mov edx, blad_connect_d int 80h push dword [gniazdo] call close ; zamykamy gniazdo mov eax, 1 mov ebx, 2 int 80h .inet_blad: mov eax, 4 mov ebx, 1 mov ecx, blad_inet mov edx, blad_inet_d int 80h push dword [gniazdo] call close ; zamykamy gniazdo mov eax, 1 mov ebx, 3 int 80h .send_blad: mov eax, 4 mov ebx, 1 mov ecx, blad_send mov edx, blad_send_d int 80h push dword [gniazdo] call close ; zamykamy gniazdo mov eax, 1 mov ebx, 4 int 80h .recv_blad: mov eax, 4 mov ebx, 1 mov ecx, blad_recv mov edx, blad_recv_d int 80h push dword [gniazdo] call close ; zamykamy gniazdo mov eax, 1 mov ebx, 5 int 80h section .data gniazdo dd 0 ; deskryptor gniazda odebrano db "Serwer: " bufor times 20 db 0 ; bufor nadawczo-odbiorczy buf_d equ $ - bufor ; długość bufora db 10 ; przejście do nowej linii odebrano_dl equ $ - odebrano ; komunikaty błędów blad_socket db "Problem z socket!", 10 blad_socket_d equ $ - blad_socket blad_connect db "Problem z connect!", 10 blad_connect_d equ $ - blad_connect blad_inet db "Problem z inet_aton!", 10 blad_inet_d equ $ - blad_inet blad_send db "Problem z send!", 10 blad_send_d equ $ - blad_send blad_recv db "Problem z recv!", 10 blad_recv_d equ $ - blad_recv localhost db "127.0.0.1", 0 ; adres, z którym ; będziemy się łączyć struc sockaddr_in .sin_family resw 1 ; rodzina adresów .sin_port resw 1 ; numer portu .sin_addr resd 1 ; adres resb 8 ; dopełnienie do 16 bajtów endstruc adres istruc sockaddr_in ; adres jako zmienna, ; która jest strukturą
Jako że programy te korzystają z biblioteki języka C, ich kompilacja musi wyglądać trochę inaczej niż zwykle:
nasm -f elf -o plik.o plik.asm gcc -o plik plik.o
Po kompilacji najpierw oczywiście uruchamiamy serwer poleceniem
./serwer
(program serwera sam przejdzie w tło). Możecie sprawdzić,
co się stanie, jeśli dwa razy spróbujecie uruchomić serwer lub uruchomicie klienta
bez uruchomionego serwera.
Oczywiście, serwer może też być klientem innego serwera (na przykład po odebraniu danych przerabiać je i przekazywać dalej).
Korzystanie z sieci jest oczywiście możliwe także bez pośrednictwa biblioteki języka C. W końcu każda tak istotna funkcja przecież musi być zaprogramowana jako część jądra.
Interfejs sieciowy jądra to jedna funkcja - sys_socketcall (numer 102). Przyjmuje ona dwa argumenty. Pierwszy (w EBX) to funkcja, którą chcemy uruchomić. Każda wspomniana wcześniej funkcja z biblioteki C ma swój numer. Są to: dla socket - 1, dla bind - 2, connect - 3, listen - 4, accept - 5, send - 9, recv - 10. Funkcja close jest tą samą, której używa się do zamykania plików (a więc EBX=[gniazdo], EAX=6, int 80h).
Drugim argumentem (w ECX) jest adres reszty argumentów, które podalibyśmy funkcji z biblioteki C.
Można je bez przeszkód w tej samej kolejności, co wcześniej, umieścić na stosie, po czym wykonać
instrukcję mov ecx, esp
. Z resztą, tak to właśnie robi biblioteka C
(plik sysdeps/unix/sysv/linux/i386/socket.S w źródłach glibc, tam jednak jest "ecx+4", gdyż
należy przeskoczyć jeszcze adres powrotny z funkcji). Można te dane
umieścić oczywiście w swojej sekcji danych i podać ich adres, ale dane te muszą być jedna po
drugiej dokładnie w takiej kolejności, w jakiej znajdowałyby na stosie (czyli
od lewej do prawej na wzrastających adresach). Po prostu po kolei,
według deklaracji C, od lewej do prawej.
Do omówienia zostają jeszcze funkcje pomocnicze - htons i inet_aton.
Funkcja htons jest dość prosta w budowie (plik sysdeps/i386/htons.S w źródłach glibc), jej treść mieści się w takim oto makrze (zakładając, że argument jest w EAX):
%macro htons 0 and eax, 0FFFFh ror ax, 8 %endm
Czyli po prostu zeruje górną połowę EAX i zamienia zawartość rejestrów AH i AL między sobą.
Funkcja inet_aton (plik resolv/inet_addr.c w źródłach glibc) jest trochę trudniejsza. Wolę znacznie wszystko skrócić i powiedzieć, że adres należy załadować do rejestru EAX binarnie, czyli na przykład z 127.0.0.1 dostajemy EAX=7F000001h, a z 192.168.0.2 - EAX=C0A80002h. Potem trzeba odwrócić kolejność bajtów. Najlepiej od początku skorzystać z następującego makra:
%macro adr2bin 4 mov al, %4 shl eax, 8 mov al, %3 shl eax, 8 mov al, %2 shl eax, 8 mov al, %1 %endm ; użycie: adr2bin 127, 0, 0, 1 ; dla adresu 127.0.0.1 adr2bin 192, 168, 45, 243 ; dla adresu 192.168.45.243
którego wynik (EAX) zapisujemy do pierwszych czterech bajtów pola sin_addr struktury sockaddr_in (co normalnie funkcja inet_aton robiła automatycznie).
To całe odwracanie bierze się z tego, że porządek bajtów w protokole TCP jest typu big-endian, a procesory zgodne z Intelem są typu little-endian.
O tym, jak pisać demony korzystając wyłącznie z przerwania int 80h, napisałem w kursie o pisaniu programów rezydentnych.
Obsługa sieci różni się nieco na systemach 64-bitowych w porównaniu z systemami 32-bitowymi. Nie tylko zmienia się numer funkcji, ale teraz poszczególne operacje sieciowe mają swoje własne funkcje systemowe. Są to: socket - 41, connect - 42, accept - 43, sendto - 44, recvfrom - 45, bind - 49, listen - 50. Reszta parametrów jest przekazywana nie na stosie, a w kolejnych rejestrach, zgodnie z interfejsem systemu 64-bitowego (kolejno w rejestrach: RDI, RSI, RDX, R10, R8, R9). Samo wywołanie systemu następuje instrukcją syscall, a nie poprzez przerwanie 80h.
Przykładowe wywołania funkcji wyglądają więc następująco:
mov rax, 41 ; socket mov rdi, AF_INET mov rsi, SOCK_STREAM mov rdx, IPPROTO_TCP syscall mov rax, 42 ; connect mov rdi, [socket] mov rsi, sock_struc mov rdx, sockaddr_in_size syscall mov rax, 44 ; sendto mov rdi, [socket] mov rsi, buf mov rdx, buf_ile mov r10, 0 syscall mov rax, 49 ; bind mov rdi, [socket] mov rsi, sock_struc mov rdx, sockaddr_in_size syscall mov rax, 50 ; listen mov rdi, [socket] mov rsi, MAXKLIENT syscall mov rax, 43 ; accept mov rdi, [socket] mov rsi, sock_struc mov rdx, sockaddr_in_size syscall mov rax, 45 ; recvfrom mov rdi, [socket_client] mov rsi, buf mov rdx, buf_ile mov r10, 0 syscall ... struc sockaddr_in .sin_family: resw 1 .sin_port: resw 1 .sin_addr: resd 1 resb 8 endstruc sock_struc istruc sockaddr_in
Funkcje htons i inet_aton są takie same, jak dla systemów 32-bitowych (bo przecież kolejność bajtów przesyłanych w sieci się nie zmienia).
Warto jeszcze wspomnieć o dwóch sprawach.
Pierwsza to programy strace i ltrace. Pozwalają one na śledzenie, których funkcji
systemowych i kiedy dany program używa. Jeśli coś Wam nie działa, wyłączcie tryb
demona w serwerze, po czym uruchomcie
strace ./serwer
i patrzcie, na których wywołaniach funkcji są jakieś problemy.
Podobnie możecie oczywiście zrobić
z klientem, na przykład na drugim terminalu. Po szczegóły odsyłam do stron manuala.
Drugą sprawa jest dla tych z Was, którzy poważnie myślą o pisaniu aplikacji sieciowych. Jest to zbiór norm RFC (Request For Comment). Opisują one wszystkie publicznie używane protokoły, na przykład HTTP, SMTP czy POP3: rfc-editor.org.