This is an old revision of the document!
Table of Contents
Wstęp – przypomnienia
Trochę o C/C++
Komendy do kompilacji
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 wszystkie(("Wszystkie" oznacza wybrany zbiór ostrzeżeń o nazwie wszystkie, poza ''-Wall warto też dodać -Wextra i rozważyć dodanie -pedantic. Szczegóły w dokumentacji kompilatora gcc/clang)) 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] |
Przekazywanie struktur do funkcji
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?
Przekazywanie danych nieznanego z góry rozmiaru
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:
- funkcja zawsze czyta tyle samo bajtów, wywołujący musi zagwarantować że przynajmniej tyle jest,
- funkcja czyta do trafienia na określoną wartość, np. ciągi znaków kończą się zerem,
- w innym argumencie wywołujący podaje ile bajtów funkcja może przeczytać.
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?
Kolejność bajtów wielobajtowych liczb całkowitych
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).
Wybrane elementy z API systemu operacyjnego
Deskryptory plików
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.
Odczytywanie i zapisywanie danych, zamykanie plików
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:
- jeżeli przeczyta dane i umieści je w buforze, to zwraca wartość dodatnią - ilość przeczytanych znaków
- wartość 0 jeżeli nie ma więcej danych w pliku1)
- wartość -1 jeżeli wystąpił błąd
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:
- jeżeli zapisze dane do pliku, to zwraca wartość dodatnią - ilość zapisanych znaków
- wartość -1 jeżeli wystąpił błąd
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>).
Określanie przyczyn niepowodzenia – perror i errno
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).
Sockets
BSD socket API
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.
Zapis adresu gniazda
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.
"Hello world"
Zadanie 4 Połącz się pod port 13 swojego komputera używając protokołu TCP.
Zadanie 5 Napisz program, który kolejno:
- stworzy bufor na dane (np. tablicę znaków).
- stworzy zmienną typu
sockaddr_in(zdefiniowaną w pliku nagłówkowymnetinet/in.h) i wypełni:- rodzinę adresów na IPv4 – stała
AF_INET - port – wartość 13, ale zapisane w sieciowym porządku bajtów
- adres IP – wpisz tam adres 127.0.0.1, używając do konwersji na liczbę funkcji
inet_addrlubinet_aton
Uwaga:.sin_addrto struktura typuin_addrz jedną składową.s_addrtypuuint32_t
inet_atonprzyjmuje wskaźnik na strukturę, natomiastinet_addrzwraca 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).
- tworzył funkcją
socket(…)(zdefiniowaną w pliku nagłówkowymsys/socket.h) nowe gniazdo, podając jako argumenty:- jako domenę komunikacyjną poda protokół IPv4 – stała
PF_INET(zamiennieAF_INET) - określi typ jako strumieniowy – stała
SOCK_STREAM(czyli typ komunikacji używany przez TCP) - ustawi protokół na TCP – stała
IPPROTO_TCP(można też podać0, oznaczające domyślny protokół; TCP jest domyślnym protokołem typu strumieniowego)
- funkcją
connect(…)nakaże systemowi operacyjnemu wykonać na wcześniej utworzonym gnieździe połączenie do wcześniej przygotowanego adresu- pierwszym argumentem jest informacja które gniazdo ma się połączyć
- drugim jest informacja na jaki adres – pamiętaj że funkcja
connectoczekujesockaddr*zamiastsockaddr_in*(wymagane rzutownaie) - trzeci argument funkcji connect to rozmiar struktury opisującej adres –
sizeof()zmiennej lub typu
- funkcją
read(…)(zdefiniowaną w pliku nagłówkowymunistd.h) odczyta dane z gniazda, przy czym:- pierwszy argument wskazuje skąd czytać dane
- drugi wskazuje gdzie system operacyjny ma umieścić przeczytane dane – tutaj podaj wcześniej przygotowany bufor
- trzeci argument podaje ile co najwyżej bajtów system operacyjny może odczytać (tzn. ile jest miejsca w buforze)
- funkcja zwróci informację ile danych faktycznie zostało przeczytane
- wypisze na standardowe wyjście odebrane dane (funkcją
write(…)lub inną) - zamknie gniazdo funkcją
shutdown(…), która:- jako pierwszy argument oczekuje informacji na którym gnieździe ma zamknąć połączenie
- jako drugi chce wiedzieć czy zakończyć zarówno nadawanie jak i odbiór (
SHUT_RDWR), czy tylko jedno z nich (co jest rzadko używane)
- zwolni zasoby systemowe związane z gniazdem funkcją
close()
Szczegółowy opis każdej z funkcji znajdziesz w podręczniku systemowym (
man 3 … / man 3p …)Potrzebne pliki nagłówkowe to:
#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.
Funkcje send/recv/…
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(…)