Utilisation des fonctions système

L'objectif de ce TP est de se passer de la bibliothèque client_serveur qui avait été utilisée jusque là, et d'apprendre à utiliser directement les fonctions système. Quelques exercices sont proposés après un résumé des fonctions à  utiliser.

Résumé des fonctions

Socket

Sous Unix, les communications se font à l'aide d'un objet que l'on nomme « socket ».

Celles-ci sont créées par la fonction socket(2) :

    int socket(int domaine, int type, int protocole);

où les paramètres domaine et type définissent le type de socket souhaité. Par exemple, pour des communications en TCP/IP, il faut utiliser le domaine AF_INET (pour IPv4) et le type SOCK_STREAM (pour TCP). Le paramètre protocole prend la valeur 0.

Cette fonction retourne un descripteur de fichier pour la socket nouvellement créée, ou -1 en cas d'erreur.

Le client

Une fois que la socket est créée, il est possible de la connecter grâce à la fonction :

    int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

où les paramètres sont :

  • sockfd est la socket à connecter
  • addr est l'adresse à laquelle se connecter
  • addrlen est la taille de la structure pointée par addr

Les adresses IP sont codées dans une structure de type sockaddr_in :

    struct sockaddr_in {
        sa_family_t    sin_family; /* address family: AF_INET */
        in_port_t      sin_port;   /* port in network byte order */
        struct in_addr sin_addr;   /* internet address */
    };

    /* Internet address. */
    struct in_addr {
        uint32_t       s_addr;     /* address in network byte order */
    };

  • sin_family prend la valeur AF_INET
  • sin_port est le numéro de port où se connecter
  • sin_addr est l'adresse IP où se connecter

sin_port est codé avec les octets dans l'ordre réseau. Cf. la fonction htons(3) pour effectuer le codage.

sin_addr est l'adresse IP codée avec les octets dans l'ordre réseau. Cf. la fonction inet_aton(3) pour effectuer le codage.

Pour passer une telle adresse à connect, il faudra faire un transtypage (cast). Par exemple, pour se connecter au port 4242 de l'adresse 1.2.3.4, on pourra utiliser :

    int sock;
    struct sockaddr_in addr;

    /* création de la socket */
    sock = socket(AF_INET, SOCK_STREAM, 0);

    /* remplissage de la structure addr */
    addr.sin_family = AF_INET;
    addr.sin_port = htons(4242);
    inet_aton("1.2.3.4", &addr.sin_addr);

    /* connexion à 1.2.3.4:4242 */
    connect(sock, (struct sockaddr *)&addr, sizeof addr);

Une fois la connexion effectuée il est possible de faire des lectures/écritures sur la socket avec les primitives read(2) et write(2).

Une socket est fermée et la connexion terminée par close(2).

Le serveur

Pour réaliser un serveur en écoute sur un numéro de port donné, il faut créer une socket pour l'écoute, et l'associer (lier) au numéro de port avec la fonction bind(2) :

    int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

où les paramètres sont :

  • sockfd est la socket à associer
  • addr est l'adresse à laquelle l'associer
  • addrlen est la taille de la structure pointée par addr

En TCP/IP, on utilisera, comme pour connect (cf. client), une structure sockaddr_in pour l'adresse, passée avec le transtypage approprié. Cette adresse sera remplie avec les valeurs :

  • sin_family : AF_INET
  • sin_port : le numéro de port souhaité
  • sin_addr : l'adresse souhaitée, où INADDR_ANY pour associer la socket à toutes les interfaces locales.
    On utilisera par exemple: addr.sin_addr.s_addr = htonl(INADDR_ANY);

Une fois la socket associée à une adresse, on peut mettre la socket en écoute de connexions entrantes avec la fonction listen(2) :

    int listen(int sockfd, int backlog);

  • sockfd est la socket
  • backlog est le nombre maximal de connexions entrantes simultanées

Le serveur peut ensuite attendre une connexion entrante à l'aide de la fonction accept(2) :

    int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

où sockfd est la socket d'écoute.

Le paramètre addr, si différent de NULL, permet de passer un pointeur sur une structure sockaddr qui sera remplie par les caractéristiques de la connexion entrante. La variable pointée par addrlen sert à passer la taille de la structure.

Une fois encore, il faudra en pratique utiliser une structure sockaddr_in.

La valeur de retour de la fonction accept est une nouvelle socket, liée à la connexion nouvellement acceptée. Il est possible d'utiliser les primitives read(2) et write(2) sur cette socket. La connexion est terminée par close(2).

Fonctions utiles

Pour faire la correspondance entre nom de domaine et adresse IP, il est possible d'utiliser les fonction getaddrinfo(3) et getnameinfo(3).

Remarque : traditionnellement les fonctions gethostbyname(3) et gethostbyaddr(3) étaient utilisées, mais leur usage est aujourd'hui déconseillé.

Plus de documentation

Vous trouverez plus de détails dans les pages de manuel socket(7), ip(7) et tcp(7).

Exemples

Voici un exemple simple de client et de serveur :

Exercices

Dans les exercices, vous vous appliquerez à utiliser correctement les primitives systèmes, et notamment à vérifier, à l'aide des codes de retour que celles-ci n'ont pas échoué. Dans le cas contraire, vous afficherez un message d'erreur significatif avant de terminer proprement le programme.

Le client

Dans ce premier exercice, il est demandé d'écrire un programme client qui prendra sur sa ligne de commande un nom de machine et un numéro de port.

Ce client devra se connecter en TCP à la machine et au port donnés. Il devra ensuite lire son entrée standard, ligne par ligne, et recopier les données lues sur la socket.

Pour tester votre client, vous pourrez utiliser (en shell) la commande nc(1) (netcat) pour créer un serveur de test. Par exemple, pour démarrer un serveur sur le port 3333 :

$ nc -v -n -l -p 3333

Le serveur

Dans ce second exercice, il est demandé d'écrire un programme serveur qui prendra sur sa ligne de commande un numéro de port.

Ce serveur devra attendre des connexions entrantes sur le numéro de port donné. Lorsqu'une connexion est acceptée, le serveur devra afficher des informations sur le client (adresse et numéro de port utilisé), puis lire les données sur la socket et les recopier sur sa sortie standard.

Première amélioration

Le serveur actuel n'accepte qu'une seule connexion à la fois. On désire le modifier pour qu'il puisse recevoir des données provenant de plusieurs clients en même temps.

À l'aide de la commande fork(), créez des fils au sein du serveur pour gérer de telles connexions multiples.

On rappelle que chaque connexion commence au retour de la fonction accept(). Les processus chargés de la connexion devront évidemment se terminer à la fin de celle-ci.

Vous expérimenterez deux variantes :

  1. Dans un premier temps, chaque connexion sera gérée par le processus père.
  2. Dans un second temps, chaque connexion sera gérée par le processus fils.

Pour lequel de ces deux derniers serveurs et dans quelle situation observe-t-on des zombies ? Pourquoi ?

Deuxième amélioration

Comme dans la question précédente, écrivez un serveur capable de gérer plusieurs connexions simultanées. Dans cette troisième version, c'est à un petit-fils de gérer la connexion. Le père crée un fils, qui crée le petit-fils chargé de la connexion. Le fils se termine immédiatement après avoir créé le petit-fils. Comment et pourquoi cette dernière version permet d'éviter les processus zombies précédemment observés ?

Avec des threads

Seriez-vous capable de réécrire le serveur, mais en utilisant des threads à la place de processus fils pour gérer les connexions simultanées ?

Pour les plus rapides

On souhaite modifier le client et le serveur pour que les communications soient bidirectionnelles. Lorsque le serveur reçoit des données de l'un de ses clients, il doit en envoyer une copie à chacun des autres clients connectés. Les clients devront bien sûr afficher les données qu'ils reçoivent de la part du serveur.