Spis treści

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 progkompiluje plik zrodlo.cpp do programu prog
c++ -Wall z.cpp -o pwłącza wszystkie1) ostrzeżenia kompilatora (-W = warn, all = wszystkie)
c++ -O0 -g z.cpp -o pwyłącza optymalizacje i dodaje do programu dane umożliwiające debugowanie
c++ --std=c++20 z.cpp -o pwłącza używanie standardu ISO C++ z 2020 roku
c++ -pthread z.cpp -o pwłą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:

ver1.c
#include <stdio.h>
struct bigStrct {
  int version;
  char data1[1024*1024];
};
void prVer(struct bigStrct var){
  printf("%d\n", var.version);
}
int main(){
  struct bigStrct myData = {5, "hello"};
  prVer(myData);
  return 0;
}
ver2.c
#include <stdio.h>
struct bigStrct {
  int version;
  char data1[1024*1024];
};
void prVer(const struct bigStrct * var){
  printf("%d\n", var->version);
}
int main(){
  struct bigStrct myData = {5, "hello"};
  prVer(&myData);
  return 0;
}
ver3.c
#include <stdio.h>
struct bigStrct {
  int version;
  char data1[4*1024*1024];
};
void prVer(struct bigStrct var){
  printf("%d\n", var.version);
}
int main(){
  struct bigStrct myData = {5, "hello"};
  prVer(myData);
  return 0;
}
ver4.c
#include <stdio.h>
struct bigStrct {
  int version;
  char data1[4*1024*1024];
};
void prVer(const struct bigStrct * var){
  printf("%d\n", var->version);
}
int main(){
  struct bigStrct myData = {5, "hello"};
  prVer(&myData);
  return 0;
}

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:

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:

ver1.c
#include <stdio.h>
unsigned max(unsigned tab[16]){
  unsigned res = 0;
  for(size_t i = 0 ; i < 16; ++i)
    if(res < tab[i]) res = tab[i];
  return res;
}
int main(){
  unsigned data[4] = {5,6,8,7};
  printf("%u\n", max(data));
  return 0;
}
ver2.c
#include <stdio.h>
unsigned max(unsigned tab[], size_t len){
  unsigned res = 0;
  for(size_t i = 0 ; i < len; ++i)
    if(res < tab[i]) res = tab[i];
  return res;
}
int main(){
  unsigned data[4] = {5,6,8,7};
  printf("%u\n", max(data, 4));
  return 0;
}

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:

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>).

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:

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(…)

1) "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
2) uwaga – w UDP wartość 0 ma inne znaczenie