Większość przykładów w materiałach i programów pisanych na laboratoriach jest w C++.
Stąd przypominam jak obsługiwać kompilator GCC / clang w Linuksie.
Przykładowe polecenie do kompilacji przykładów ze strony:
g++ --std=c++20 -Wall -O0 -g -pthread -o example example.cpp
c++ | domyślny kompilator C++. Zwykle link do g++ lub clang++ |
c++ zrodlo.cpp -o prog | kompiluje plik zrodlo.cpp do programu prog |
c++ -Wall z.cpp -o p | włącza wszystkie1) ostrzeżenia kompilatora (-W = warn, all = wszystkie) |
c++ -O0 -g z.cpp -o p | wyłącza optymalizacje i dodaje do programu dane umożliwiające debugowanie |
c++ --std=c++20 z.cpp -o p | włącza używanie standardu ISO C++ z 2020 roku |
c++ -pthread z.cpp -o p | włącza obsługę wątków standardu POSIX wymagane do wersji glibc ≤ 2.33 [1] [2] |
W C++ do funkcji można przekazać argumenty przez wartość lub referencję.
W C do funkcji można przekazać argumenty tylko przez wartość.
Często do funkcji przekazuje się (jako wartość) adres w pamięci pod którym
znajduje się zmienna którą ma używać funkcja. Takie przekazanie zmiennej do
funkcji bywa nazywane przekazaniem przez wskaźnik/adres.
Argumenty przekazywane do funkcji przez wartość – w tym struktury/klasy – są
kopiowane i funkcja pracuje na własnej kopii.
Aby uniknąć kopiowania struktur/klas (które kosztuje czas procesora i zajmuje
pamięć na stosie) tam gdzie to możliwe wykorzystuje się przekazywanie adresu
do zmiennej (w C) lub przekazywanie referencji (w C++).
Jednocześnie taki sposób przekazania argumentu powoduje że funkcja może zmienić
wartość przekazanej zmiennej. Dlatego wskaźnik bądź referencja będąca argumentem
funkcji jest często kwalifikowana jako const, by kompilator gwarantował że
zmienna nie będzie modyfikowana przez funkcję.
Zadanie 1 Porównaj programy:
Czym się od siebie różnią? Dlaczego jeden z nich w trakcie uruchomienia ulega awarii?
W C/C++ kompilator nie potrafi określić tego ile bajtów ma coś do czego wskaźnik został przekazany jako argument funkcji. (Czasami mógłby, ale są sytuacje gdzie się nie da.)
Dlatego przekazując jako argument adres trzeba określić ile bajtów można spod niego odczytać. Możliwe opcje to m. inn:
Zadanie 2 Dlaczego funkcja
strchr
nie przyjmuje jako argument długości ciągu znaków, a
memchr
przyjmuje?
Zadanie 3 Patrząc na programy:
Czym się od siebie różnią? Dlaczego w lewym kompilator pozwala na umieszczenie na liście argumentów
unsigned tab[16]? Dlaczego mimo to pozwala skompilować
kod w którym do tego argumentu przekazywana jest tablica czteroelementowa?
Twórcy procesorów mieli różne pomysły na to jak zapisywać liczby (szczególnie
niecałkowite). Jednym z pytań było: skoro w pamięci adresowane są pojedyncze
bajty i chcę zapisać liczbę całkowitą 1000 (0x03e8) na dwóch bajtach, to czy
wartość 232 (0xe8) ma być zapisana w bajcie o niższym czy wyższym adresie?
Taki spór o kolejność bajtów
początkowo nie został rozstrzygnięty, część procesorów używała kolejności
big endian, część little endian.
Dzisiaj przeważa użycie little endian (najmniej znaczący bajt ma najmniejszy
adres, co jednocześnie oznacza że odczytywane w porządku rosnących adresów
wartości kolejnych bajtów są w odwrotnym porządku niż naturalny zapis liczby).
Wszystkie systemowe funkcje programistyczne do obsługi sieci oczekują że programista przekaże wielobajtowe liczy w sieciowej kolejności bajtów (networking byte order), którą ustalono na to big endian – odwrotnie niż na większości współczesnych procesorów.
Do zmiany kolejności bajtów z lokalnej na sieciową / z sieciowej na lokalną
można użyć funkcji
htons(uint16_t num),
htonl(uint32_t num),
ntohs(uint16_t num),
ntohl(uint32_t num)
(więcej w man byteorder).
W API bibliotek standardowych języka C pliki reprezentowane są przez typ
FILE*, a każdy program
przy starcie ma otwarte pliki stdin, stdout i stderr.
Zauważ że np. funkcja printf(…) pisze właśnie do pliku stdin, a
nie "na ekran").
W API bibliotek standardowych języka C++ pliki reprezentowane są przez typy
dziedziczące po std::basic_istream/std::basic_ostream,
a każdy program przy starcie ma otwarte pliki std::cin, std::cout i std::cerr.
W API uniksopodobnych systemów operacyjnych pliki są reprezentowane przez
liczby (zmienną typu int), nazywane zwyczajowo deskryptorami plików,
a każdy program przy starcie ma otwarte pliki 0, 1 i 2
(odpowiadające standardowemu wejściu, standardowemu wyjściu i standardowemu
[strumieniu] błędów).
Każdy nowo stworzony/otwarty plik dostaje najniższy wolny numer.
Programiści wywołując systemowe operacje na plikach (tzn. prosząc system
operacyjny o wykonanie jakiejś akcji, takiej jako odczyt kolejnej porcji
danych) musi podać numer pliku na którym ta operacja ma być wykonana.
Dla uniksopodobnego systemu operacyjnego wiele rzeczy jest plikiem; poza zwykłymi plikami (które są tratowane jako rodzaj pliku) plikami są też katalogi, urządzenia i połączenia sieciowe.
Podstawową systemową funkcją do odczytywania danych jest funkcja read:
ssize_t read(int fd, void * buf, size_t len);
(typ ssize_t to liczba całkowita ze znakiem reprezentująca rozmiar,
np. int, a size_t to liczba całkowita bez znaku reprezentująca rozmiar,
np. unsigned int.)
Funkcja read umieszcza odczytane dane, maksymalnie len znaków, w miejscu
wskazanym przez buf. Read zwraca:
Uwaga,
read może przeczytać mniej znaków niż len – np. jeśli plik się skończył.
Podstawową systemową funkcją do zapisu danych jest funkcja write:
ssize_t write(int fd, const void * buf, size_t len);
Funkcja write zapisuje dp pliku len znaków zaczynających się w miejscu
wskazanym przez buf i zwraca:
Każdy plik trzeba zamknąć żeby zwolnic zasoby które system operacyjny w swojej
pamięci trzyma dla tego pliku. Do tego służy funkcja close().
Opisane tutaj funkcje są w pliku nagłówkowym unistd.h (#include <unistd.h>).
Jeżeli funkcja systemowa nie powiedzie się, to nadpisze wartość ostatniego kodu
błędu trzymanego w zmiennej errno.
Zmienna errno jest dostępna dla programisty (#include <errno.h>). Funkcje
które mogą ustawić taki kod błędu mają w dokumentacji podane jakie kody mogą ustawić
i co te kody błędu oznaczają.
Programista może wyświetlić funkcją perror(const char *msg)
(#include <stdio.h>) komunikat powiązany z kodem błędu ze zmiennej
errno. Jeżeli w msg będzie "tekst", to funkcja
perror na standardowe wyjście wypisze tekst: komunikat, a jeżeli
msg będzie pustym wskaźnikiem, to wypisze komunikat (gdzie
komunikat objaśnia co poszło nie tak).
Gniazdo (socket) – interfejs między systemem operacyjnym a programem użytkownika używany do dwukierunkowej komunikacji poza program.
API stworzone dla systemu BSD zostało przyjęte przez praktycznie wszystkie systemy operacyjne (POSIX socket API, WinSock).
Po więcej informacji o BSD Socket API zajrzyj na
wikipedię oraz man 7
socket
tcp
udp
Co ważne, BSD socket API zostało zaprojektowane tak żeby korzystać z różnych protokołów sieciowych (i niesieciowych), np. bluetooth, CAN, https://en.wikipedia.org/wiki/Unix_domain_socket, komunikacja międzyprocesowa . Stąd korzystając z IPv4/IPv6 trzeba przekazać tą informację do wielu funkcji.
Protokół trzeba podać na etapie tworzenia gniazda.
Adres IP oraz numer portu potrzebny np. przy łączeniu do podanego celu
trzeba podać w odpowiedniej strukturze.
Do przekazywania adresu gniazda funkcje używają wskaźnika na strukturę
sockaddr (w C – struct sockaddr). API celowo przekazuje wskaźnik
na adres nieokreślonego typu, tak żeby móc działać dla wielu rodzajów adresów.
C nie pozwala na dziedziczenie, więc zamiast tego (https://en.wikipedia.org/wiki/Type_punning#Sockets_example) struktura sockaddr ma kilka "specjalizacji" dla konkretnej rodziny adresów:
sockaddr_in (INET, czyli IPv4)sockaddr_in6 (INET6, czyli IPv6)sockaddr_un (dla unix socket).
Do przekazania adresu gniazda IPv4 trzeba stworzyć zmienną typu sockaddr_in
(w C – struct sockaddr_in), wpisać do niej adres IP i numer portu (pola
sin_port i sin_addr) oraz wpisać informację że to jest adres gniazda
typu IPv4 uzupełniając wartość pola sin_family na AF_INET (INET to
skrót od Internet Protocol version 4).
Funkcje które przyjmują jako argument sockaddr* zawsze sprawdzają co
wpisano w pole sin_family żeby sprawdzić czy dostały właściwego typu adres.
Kompilator nie pozwala na przekazanie adresu zmiennej typu sockaddr_in jako
adresu zmiennej typu sockaddr, wskazując że typ sockaddr_in* nie może
być automatycznie zmieniony w sockaddr*.
Przekazując wskaźnik na adres konkretnego typu (np. sockaddr_in*) jako
wskaźnik na adres nieokreślonego typu sockaddr* konieczne jest rzutowanie.
Dodatkowo funkcje przyjmujące adres jako kolejny argument oczekują rozmiaru
przekazanej struktury, np:
sockaddr_in ipv4addr = {AF_INET, ………}; connect(sockFd, (sockaddr*)&ipv4addr, sizeof(ipv4addr));
Struktura sockaddr_in jest w pliku nagłówkowym #include <netinet/in.h>
Struktura sockaddr jest w pliku nagłówkowym #include <sys/socket.h>
Pełny opis struktury i jej pól – patrz
man sockaddr,
man 7 ip i
man netinet_in.h.
Zadanie 4 Połącz się pod port 13 swojego komputera używając protokołu TCP.
Zadanie 5 Napisz program, który kolejno:
sockaddr_in (zdefiniowaną w pliku nagłówkowym netinet/in.h) i wypełni:AF_INETinet_addr lub inet_aton .sin_addr to struktura typu in_addr z jedną składową .s_addr typu uint32_t inet_aton przyjmuje wskaźnik na strukturę, natomiast inet_addr zwraca liczbę którą trzeba przypisać składowej .s_addr: sockaddr_in addr;
wersja 1: addr.sin_addr.s_addr = inet_addr("8.8.8.8");
wersja 2: inet_aton("8.8.8.8", &addr.sin_addr);
można też ustawić adres na stałą htonl(INADDR_LOOPBACK). socket(…) (zdefiniowaną w pliku nagłówkowym sys/socket.h) nowe gniazdo, podając jako argumenty:PF_INET (zamiennie AF_INET) SOCK_STREAM (czyli typ komunikacji używany przez TCP) IPPROTO_TCP (można też podać 0, oznaczające domyślny protokół; TCP jest domyślnym protokołem typu strumieniowego)socket(…) zwraca deskryptor nowo utworzonego gniazda – ten numer trzeba potem podawać do wszystkich funkcji które mają na tym gnieździe operować connect(…) nakaże systemowi operacyjnemu wykonać na wcześniej utworzonym gnieździe połączenie do wcześniej przygotowanego adresuconnect oczekuje sockaddr* zamiast sockaddr_in* (wymagane rzutownaie)sizeof() zmiennej lub typuread(…) (zdefiniowaną w pliku nagłówkowym unistd.h) odczyta dane z gniazda, przy czym:write(…) lub inną)shutdown(…), która:SHUT_RDWR), czy tylko jedno z nich (co jest rzadko używane)close()Szczegółowy opis każdej z funkcji znajdziesz w podręczniku systemowym (
man 3 … / man 3p …)#include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h>
Zadanie 6 Zmień poprzedni program tak, by czytał dane aż druga strona nie zamknie połączenia.
Zadanie 7 Dodaj do programu obsługę błędów zwracanych przez funkcje connect i read.
Zadanie 8 Przekształć poprzedni program tak, by czytał dane z adresu IP i portu podanego w argumentach programu.
Zadanie 9 Zmień IP na losowe (tak, by nie odpowiadało na próbę połączenia). Programem netstat -tnp / ss -tnp wyświetl utworzone połączenie.
Poza funkcją read(…) do odbierania danych można używać funkcji recv,
recvfrom i recvmsg, przy czym read(fd, buf, len) jest
równoważne recv(fd, buf, len, 0) i recvfrom(sockfd, buf, len, 0, NULL, NULL).
Podobnie poza funkcją write do wysyłania można używać też funkcji send,
sendto i sendmsg, analogicznych do powyższych.
Dodatkowy argument recv/send (pole flag) pozwala na zmianę zachowania
tych funkcji i będzie omawiany później.
recvfrom i sendto mają dodatkowe pole na adres nadawcy/odbiorcy i są
przeznaczone dla protokołów warstwy transportowej pozwalających na komunikację
po jednym gnieździe z wieloma partnerami. Będą omawiane przy obsłudze UDP.
Zadanie 10 Zmień program tak, by zamiast read(…) używał funkcji recv(…)