GERBELOTBARILLON.COM

Parce qu'il faut toujours un commencement...

Les sockets en C

Que sont-elles ?

L'entité de base de la communication entre programmes informatiques est la socket. C'est l'extrémité d'un canal de communication auquel on donne un nom et qui autorise l'envoi et la réception de flux d'octets, constituant la base du système de communication inter processus (IPC = Inter Process Communication). Les IPC existent selon plusieurs domaines, mais seulement les deux premiers vous seront d'une quelconque utilité :

Les sockets appartiennent à un domaine de communication, sont typées et supportent plusieurs protocoles de transport. Une socket dispose également d'un type déterminant la méthode d'acheminement des données sur le canal de communication : Enfin, une socket repose sur un protocole de transport pour organiser le tranfert des informations d'un côté à l'autre du canal de communication :

Comment les utiliser ?

Création de sockets

#include <sys/types.h>
#include <sys/socket.h>

int socket(int domain, int type, int protocol);

La fonction socket() crée une extrémité d'un canal de communication et retourne un descripteur de socket car, rappelons-le, une socket se comporte comme un fichier dans lequel nous pouvons lire ou écrire des données.

domain indique le domaine de communication qui peut prendre de nombreuses valeurs mais seulement deux sont intéressantes :

type défini la méthode d'acheminement du flux d'information sur le canal de communication. Il peut être :

protocol n'a pas véritablement besoin d'être spécifié puisque chaque méthode d'acheminement repose sur un protocole unique. Il suffit alors de mettre la valeur 0 comme paramètre pour que la configuration choisisse le protocole adapté à la communication.

Par exemple, la création d'une socket pourrait être de la forme

s = socket(AF_INET, SOCK_STREAM, 0);

L'appel de la fonction socket() renvoie le descripteur du fichier ou -1 en cas d'erreur. Le code d'erreur est contenu dans la variable errno. Ces codes sont disponibles dans le fichier include socket.h ou dans la page de man de socket().

Remarque

Comme il n'y a que deux protocoles - TCP et UDP - associés automatiquement en fonction du type d'information (STREAM ou DGRAM), normalement il n'y a rien d'autre à faire. Il est cependant tout à fait possible de spécifier un protocole particulier pour un type de socket qui ne serait pas nativement associée.


        struct protoent *pp; /* déclaration d'une variable de type protocole */
        pp = getprotobyname("tcp"); /* affectation de la structure tcp à la variable de protocole */
        s = socket(AF_INET, SOCK_STREAM, pp->p_proto);
Les codes d'erreurs sont les suivants :

Nommage des sockets

Le nommage d'une socket, seul moyen qu'ont les processus externes à une famille de la référencer, s'effectue par l'appel à la commande

int bind(int socket, struct sockaddr *addr, int longueur);
La commande bind() retourne la valeur 0 si tout s'est bien passé et -1 en cas d'erreur. Regarder la valeur de la variable errno pour déterminer l'erreur exacte.

la fonction bind() peut se révéler compliquée à utiliser pour la raison qu'elle accepte un paramètre du type sockaddr_un si le domaine de la socket est UNIX, ou sockaddr_in si le domaine de la socket est INTERNET.

Dans le domaine UNIX, la structure sockaddr_un est la suivante :

struct sockaddr_un {
  sun_family; /* domaine de la socket = AF_UNIX */
  sun_path; /* chaîne de caractères composant le nom de la socket.
}
Si l'on souhaite associer la socket de type UNIX avec le fichier /tmp/foo, voici la démarche à suivre :
struct sockaddr_un sun;
int s = socket(AF_UNIX, SOCK_STREAM, IPPROTO_TCP);
sun.sun_family = AF_UNIX;
strcpy(sun.sun_path, "/tmp/foo");
bind(s, &sun, strlen(sun.sun_path)+6); /* 6 bytes pour le padding du nom de la famille */
ATTENTION: le nom de la socket ne peut pas dépasser 14 caractères. Il faut donc bien choisir le nom des sockets que l'on souhaite mettre en place.

Dans le domaine Internet, le nommage s'effectue avec l'usage du type sockaddr_in qui est une structure de données de ce type :

struct sockaddr_in {
  uint8_t sin_len; /* longueur totale */
  sa_family_t sin_family; /* domaine de la socket = AF_INET */
  struct in_addr sin_addr; /* adresse de la machine avec laquelle on veut communiquer */
  in_port_t sin_port; /* numéro du port utilisé */
  unsigned char sin_zero[8]; /* padding de 8 zéros */
}
La structure sockaddr_in n'est pas directement utilisée dans les différentes fonctions relatives aux sockets. C'est la structure sockaddr que l'on manipule. Dans ce cas il est juste requis de caster sockaddr_in sur sockaddr puisque les champs sont semblables.

Les champs sin_len et sin_zero ne sont pas utilisés directement dans la conception du programme utilisateur mais par le mécanisme d'affectation des sockets du système mais servent de padding pour le cast entre structures.

struct sockaddr {
  unsigned char sa_len; /* longueur totale */
  sa_family_t sa_family; /* domaine de la socket = AF_INET */
  char sa_data[14]; /* valeur de l'adresse */
}

Une structure plus générique encapsule le format d'une adresse IP

struct in_addr {
  in_addr_t s_addr;
}

Il existe une structure également en charge de la récupération d'information sur un hôte

struct hostent {
  char *h_name; /* nom de l'hôte */
  char **h_aliases; /* liste d'alias pour cet hôte */
  int h_addrtype; /* type de l'adresse de l'hôte */
  int h_length; /* longueur de l'adresse */
  char **h_addr_list; /* liste des adresses de l'hôte */
}

#define h_addr h_addr_list[0] /* pour la compatibilité */

L'appel de la fonction bind() s'effectue comme suit :

struct sockaddr_in sin;
int s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
sin.sin_family = AF_INET;
sin.sin_addr = <adresse de la machine>
sin.sin_port = htons(port);
bind(s, &sin, sizeof(sin));

Etablissement de la communication

La communication s'établit entre deux processus par la méthode du handshake. Elle met généralement en relation un serveur, qui crée, nomme et attend les connexions sur sa socket, et un ou plusieurs clients, qui crée(nt) egalement une socket et se connecte(nt) au serveur.

Le serveur

Après avoir créé sa socket (via socket()) et l'avoir nommée (via bind()), le serveur va attendre les connexions avec les deux étapes suivantes :

listen(int socket, int n);
avec socket représentant la socket précédemment créée, et n représentant la longueur de la file d'attente des connexions, actuellement limitée à 5 connexions.

int accept(int socket, struct sockaddr_xx *nom, int *longueur);
avec La fonction accept() retourne la valeur -1 en cas d'erreur et une valeur représentant le descripteur de socket si la connexion a été acceptée. En cas d'erreur, la variable errno recense le code de l'erreur.

Le client

Tout comme le serveur, le client va créer un socket par la fonction socket() qui va renvoyer un descripteur de socket avec lequel le dialogue va se dérouler. Le principe de connexion du client est plus simple sachant qu'il n'y aura qu'une seule fonction à savoir

connect(int socket, struct sockaddr *srvaddr, socklen_t addrlen);
avec socket représentant la socket précédemment créée, la structure sockaddr qui va pointer sur les informations du socket serveur ainsi que la longueur de cette structure.

Le client n'aura donc qu'un schéma de connexion socket() -> connect() là où le serveur réalise plutôt socket() -> bind() -> listen() -> accept()

Les sockets sous Windows

L'implémentation des sockets sous Windows respecte globalement la référence POSIX à ceci près que :

  1. pour utiliser les sockets il faut initaliser une structure spécifique avec la fonction WSAStartup()
  2. pour compiler le programme C il est nécessaire de rajouter une bibliothèque de liens -lws2_32

// Fonction pour appeler la DLL qui va permettre d'accéder aux sockets
int WSAStartup(__in WORD wVersionRequested, __out LPWSADATA LPWSAData)

// Fonction pour libérer les ressources de la DLL
int WSACleanup(void);
Pour utiliser ces éléments nous allons faire simplement :

WSADATA wsa;
WSAStartup(&wsa);

Windows dispose également de types spécifiques qui redéfinissent les types d'origine Unix (souvent des entiers).

C'est un peu de la cosmétique mais cela permet de visualiser rapidement ce que nous sommes en train de manipuler. D'ailleurs certains font la même chose même si directement sous Linux, ce qui permet de rendre les programmes davantages portables entre les systèmes. D'ailleurs...

Les sockets sous Windows et Linux avec un même code

Les directives conditionnelles disponibles avec les compilateurs C permettent de disposer d'un code source qui pourra être compilé sous Windows ou Linux, juste en rajoutant des directives dans le programme.


#ifdef WIN32 /* déclaration pour Windows */

#include 

#elif defined(linux) /* Si c'est du Linuw */

#include 
#include 
#include 
#include 
#include 
#include 

#define INVALID_SOCKET -1
#define SOCKET_ERROR -1
#define closesocket(s) close(s)

/* redéfinition des types et fonctions comme sous Windows, pour que le code soit portable
entre les deux plateformes */
typedef int SOCKET;
typedef struct sockaddr_in SOCKADDR_IN;
typedef struct sockaddr SOCKADDR;
typedef struct in_addr IN_ADDR;

#endif

/* Redéfinition des fonctions d'initialisation et de terminaison des sockets, spécifiquement pour Windows.
Avec Linux elles ne feront rien mais permettront de ne pas avoir à modifier votre code pour plus de portabilité. */

static void init(void) {
#ifdef WIN32
  WSADATA wsa;
  int err = WSAStartup(MAKEWORD(2, 2), &wsa);
  if (err < 0) {
    puts("Error calling WSAStartup().");
    exit(EXIT_FAILURE);
  }
#endif
}

static void end(void) {
#ifdef WIN32
  WSACleanup();
#endif
}