Initiation à la programmation réseau sous Windows

Jessee Edouard

Ce tutoriel a pour but de vous apprendre les bases de la programmation réseau sous Windows en utilisant le langage C.

Commentez cet article : Commentez

- Titre : Initiation à la programmation réseau sous Windows

- Auteur : Jessee Edouard

- Parution : 15 juillet 2008

- Mise à jour : 15 avril 2020

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2008 Melem. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- II. Généralités (rappels)

- II-A. Introduction

- II-B. Les différents modes de communication

- II-C. Les protocoles de l'Internet

- II-C-1. TCP

- II-C-2. IP

- II-C-3. Les protocoles de la couche application

- II-D. Communication entre les applications

- II-D-1. En mode connecté

- II-D-2. En mode non connecté

- II-E. Les sockets

- II-E-1. Généralités

- II-E-2. Les sockets sous Windows

- III. Le mode connecté

- III-A. Réalisation d'un client

- III-B. Réalisation d'un serveur

- IV. Le mode non connecté

- V. Traitement des erreurs

- VI. F.A.Q.

- Où trouver la documentation des protocoles utilisés sur Internet ?

- Comment récupérer le nom de l'ordinateur ?

- Comment connaître l'adresse d'un hôte dont le nom est donné ?

- Comment connaître l'adresse d'un hôte identifié par un socket ?

- VII. Au-delà des sockets bloquants

- VII-A. Introduction

- VII-B. Les sockets non bloquants

- VII-C. Spécifier un temps d'attente

- VII-D. Les notifications

- VIII. Remerciements

I - Introduction

Ce tutoriel va vous montrer comment créer des applications communiquant à travers un réseau en utilisant le langage C. Dans ce tutoriel, nous avons choisi Windows comme plateforme cible, mais sachez que pour les applications, le réseau apparaît un peu comme une boîte noire, ce qui signifie qu'une application n'a pas besoin de connaître le système sous lequel tourne l'application distante, et peu importe le langage dans lequel celle-ci a été écrite pour communiquer avec. Tout ce qui importe, c'est qu'elles doivent « s'entendre » entre elles, c'est-à-dire être bien d'accord sur la manière d'échanger les données. L'ensemble des règles qui stipulent cette manière d'échanger les informations entre deux nœuds d'un réseau s'appelle un protocole. Il existe cependant plusieurs niveaux de protocoles. Les applications utilisent un protocole dit « de haut niveau » car ce protocole peut être défini par n'importe qui (c'est le programmeur qui fixe les règles), mais les données échangées entre les applications ne sont pas transportées sur le réseau telles quelles. Elles sont précédées d'un en-tête qui contient des informations indispensables comme l'adresse de l'expéditeur, celle du destinataire, etc. sans quoi il serait impossible de les identifier. Cela est réalisé par des protocoles de niveau inférieur dont nous reparlerons plus tard.

Le paragraphe suivant traite de tout ce que vous devriez au moins savoir concernant les réseaux et la programmation réseau pour pouvoir bien suivre ce tutoriel. Bonne lecture.

II - Généralités (rappels)

II-A - Introduction

Comme nous l'avons déjà dit dans le paragraphe précédent, tous les protocoles n'opèrent pas forcément au même niveau. C'est la raison pour laquelle on utilise toujours un modèle en couches lorsque l'on étudie les réseaux. Le modèle OSI, un standard développé par l'ISO afin de normaliser les communications entre les machines, en comporte 7 allant de la couche physique, qui correspond au niveau le plus bas, à la couche « application » qui correspond au niveau le plus élevé. Sur Internet, de nombreux protocoles sont utilisés dont TCP (Transmission Control Protocol) et IP (Internet Protocol). Cette suite de protocole est connue sous le nom de TCP/IP, du nom de deux des protocoles les plus importants de la série : TCP et IP.

II-B - Les différents modes de communication

On distingue généralement deux types de communications entre les éléments d'un réseau (et non nécessairement un réseau informatique) : les communications en mode connecté et les communications en mode non connecté. La communication en mode connecté est comparable à la communication au téléphone : un programme demande à un autre d'établir une connexion et si ce dernier accepte, la connexion est effectivement établie et la communication entre les deux programmes peut se faire tant que la connexion reste active. En mode non connecté, un programme envoie des données à destination d'un autre programme qui se trouve généralement sur une autre machine sans établir une connexion (l'adresse de destination doit donc toujours être spécifiée à chaque expédition). Ce type de communication est comparable à la communication par courrier. TCP est un exemple de protocole orienté connexion et UDP un exemple de protocole non orienté connexion. Tous deux font partie de la saga TCP/IP.

II-C - Les protocoles de l'Internet

II-C-1 - TCP

TCP (Transmission Control Protocol) est un protocole permettant aux applications d'échanger des données à travers un réseau. Lors d'une transmission, les données (provenant d'une application) traversent une ou plusieurs couches avant d'atteindre ce protocole, qui et un protocole de niveau quatre d'après le modèle OSI. Ce dernier ajoute ensuite un en-tête au message, permettant d'identifier les applications communicantes (rappelons qu'une machine peut faire tourner plusieurs applications en même temps) avant de passer le message à la couche immédiatement inférieure (domaine du protocole IP), qui gère à son tour l'expédition du message. UDP (User Datagram Protocol) est un protocole de même niveau que TCP, mais il est non orienté connexion tandis que ce dernier est orienté connexion.

II-C-2 - IP

IP (Internet Protocol), LE protocole de l'Internet, est un protocole qui permet d'envoyer des données d'une machine à une autre en encapsulant les données (provenant de la couche supérieure, TCP par exemple) dans un paquet appelé paquet IP. Un paquet IP est constitué d'un en-tête contenant entre autres l'adresse de l'expéditeur et celle du destinataire immédiatement suivi des données. Ce paquet traversera ensuite une ou plusieurs couches avant d'atteindre la couche physique qui est la couche la plus basse. Le transport des données à travers le réseau est ensuite assuré par les supports physiques de transmission (domaine des télécommunications). À l'arrivée (c'est-à-dire du côté de la machine réceptrice), les données entrent par la couche physique puis traversent les différents protocoles pour atteindre finalement la couche « application » où le message retrouve enfin sa forme originale.

Le système d'adressage utilisé sur Internet (plus précisément sur les réseaux utilisant le protocole IP) est appelé adressage IP (bien sûr …). Chaque machine sur le réseau est identifiée par un numéro logique appelé adresse IP. Dans le format actuel (appelé IPv4), une adresse est codée dur 32 bits soit 4 octets, mais afin de faciliter la lecture des adresses IP, on les représente également sous la forme de 4 nombres décimaux représentant chacun un octet (donc ces nombres sont tous compris entre 0 et 255). Par exemple, 192.168.0.10 correspond vraisemblablement à une adresse IP valide. Écrite en binaire, elle correspond à l'adresse 11000000 10101000 00000000 00001010.

II-C-3 - Les protocoles de la couche application

Un protocole applicatif est un protocole qui définit la manière d'échanger des informations entre les applications d'un réseau donné. Supposons par exemple que l'on doive réaliser un système d'informations dans lequel un « client » désire pouvoir faire des calculs mathématiques sur des nombres flottants. Pour cela, il contacte un « serveur » auquel il transmet les informations sur le calcul qu'il veut faire, attend la réponse du serveur puis affiche le résultat. Dans ce cas, nous devons tout d'abord définir un langage adapté à ce type d'application. Par exemple :

1. Pour calculer ln(2), le client doit envoyer au serveur le message « ln 2 ». Si le serveur reçoit « ln 2 », il sait donc qu'il doit calculer ln(2) et envoyer la réponse au client ;

2. La réponse du serveur doit être quelque chose comme « ok 0.69 ». Si le client reçoit une réponse commençant par « ok », il pourra donc conclure que l'opération a réussi et n'aura plus qu'à lire le résultat (« 0.69 »). Sinon, il devra conclure qu'une erreur s'est produite.

Et voilà, on vient d'inventer un protocole de communication ! On peut bien sûr le rendre plus complexe, mais j'espère au moins que vous avez saisi le principe.

Les applications Internet utilisent un protocole standardisé pour communiquer entre elles. En voici quelques-uns :

- HTTP (HyperText Transfert Protocol) : utilisé dans la communication entre un navigateur et un serveur web. Permet entre autres de télécharger une page web (hébergée sur le serveur) ;

- FTP (File Transfert Protocol) : utilisé dans la communication entre un client et un serveur FTP. Permet de transférer (télécharger ou envoyer) des fichiers via Internet ;

- SMTP (Simple Mail Transfert Protocol) : utilisé dans la communication entre un client (qui veut envoyer un e-mail) et un serveur SMTP (qui va effectivement envoyer le mail). Permet d'envoyer un e-mail ;

- POP3 (Post Office Protocol, version 3) : utilisé dans la communication entre un client et un serveur POP (serveur de courriers). Permet de télécharger un e-mail. Le protocole IMAP est également un autre moyen de récupérer son mail.

Implémenter tout ou partie de ces protocoles n'est (en général) pas difficile, il suffit juste de lire leurs spécifications, mais ceci n'est pas encore notre but. Dans ce tutoriel, nous allons simplement développer des applications qui envoient et reçoivent du texte, mais vous comprendrez vite que développer des applications qui communiquent avec un protocole plus élaboré n'est en rien plus compliqué.

Dans notre cas, on pourrait se dire que pour transmettre du texte, on devrait également transmettre le caractère de fin de chaîne '\0' du C pour que le récepteur puisse détecter la fin du message. En fait, c'est non seulement inutile, mais en plus déconseillé pour les raisons suivantes :

- les différents en-têtes ajoutés par les protocoles des couches inférieures contiennent entre autres des informations permettant de connaître la taille des données transportées. Il est donc toujours possible, à la réception, de connaître cette taille sans que l'émetteur n’ait eu à la fournir lui-même (comme en ajoutant un '\0' pour indiquer la fin du message par exemple) ;

- le '\0' est le marqueur de fin de chaîne du langage C. Les autres langages ont leurs propres manières de représenter les chaînes, c'est pourquoi il ne faut jamais transmettre des données dans un format spécifique à un langage.

Nous verrons comment mettre cela en œuvre lorsque nous entamerons la partie programmation proprement dite.

II-D - Communication entre les applications

II-D-1 - En mode connecté

Pour établir une connexion avec une machine sur le réseau, il faut spécifier son adresse puis le numéro du port sur lequel on veut se connecter. Un port est tout simplement un canal à travers lequel les applications peuvent se communiquer entre elles. Pour être plus clair, supposons qu'une machine M (le serveur) fait tourner simultanément deux programmes A et B, le premier écoutant sur le port 2024 et le deuxième sur le port 3312. Si un programme vient se connecter sur M sur le port 2024, alors celui-ci rencontrera le programme A alors qu'un autre qui viendrait se connecter sur le port 3312 rencontrerait le programme B. Les numéros de port sont représentés sur 16 bits ce qui fait que chaque machine possède donc 65 536 ports. Les ports de 0 à 1023 sont réservés. Par exemple le port 80 est utilisé par le protocole HTTP, le port 21 par FTP… Rien n'empêche cependant un serveur web d'écouter par exemple sur le port 8080 et dans ce cas le navigateur doit se connecter sur ce port de la machine et non plus le port 80 pour contacter le serveur …

II-D-2 - En mode non connecté

Le mode non connecté diffère principalement du mode connecté par le fait que les programmes communiquent entre eux sans établir de connexion. À chaque envoi de message, il faut spécifier l'adresse du destinataire et le numéro du port sur lequel on veut que le message arrive. À la réception, il faut vérifier d'où vient le message.

II-E - Les sockets

II-E-1 - Généralités

Un socket est une entité logicielle pouvant être manipulée par un programme pour communiquer avec un autre programme (représenté par le socket). En fait, à l'intérieur du programme, ce n'est pas le socket lui-même qui sera retourné par la fonction socket (celle qui permet de créer un socket), mais un handle vers ce socket. Mais comme nous le savons très bien, avoir un handle sur une chose c'est avoir cette chose. Sous UNIX (si vous savez des choses sur UNIX), ce handle n'est qu'un simple descripteur de fichier et vous pouvez donc utiliser les fonctions read et write sur des sockets, mais il est quand même préférable d'utiliser les fonctions spécialisées que nous verrons un peu plus loin (ne serait-ce que pour la portabilité …). Sous Windows, ce sont ces fonctions qu'il faut utiliser.

En parlant de fonction, il n'est pas sans intérêt que vous sachiez que, fondamentalement, la programmation des sockets sous Windows n'est pas très différente de leur programmation sous UNIX (cela signifie que vous utiliserez les mêmes fonctions, les mêmes paramètres, les mêmes techniques, etc.). En effet, les sockets furent en fait utilisés pour la première fois sous UNIX (BSD) avant d'être ensuite implémentés dans les autres systèmes. Un jour lorsque vous allez programmer sous UNIX (qui sait ?), tout ce que vous aurez à faire sera de changer le nom des headers et se débarrasser des initialisations spécifiques à Windows (discutées dans le paragraphe suivant). Il est donc très facile de créer des programmes très portables en utilisant les sockets.

II-E-2 - Les sockets sous Windows

L'API Windows comprend un certain nombre de fonctions permettant de manipuler les sockets en C ou en C++ et qui forment à elles seules une bibliothèque connue sous le nom de Winsock (Windows Sockets API), qui en est actuellement à la version 2. Cette version a la supporte une plus grande variété de protocoles par rapport à la version 1.1, qui peut être alors considérée comme un sous-ensemble de Winsock 2. Dans ce tutoriel, c'est cette version que nous allons utiliser.

Pour utiliser Winsock, il faut lier ws2_32.lib, inclure winsock2.h, mais également, à l'intérieur du programme, initialiser la DLL Winsock (ws2_32.dll) en spécifiant la version que l'on veut utiliser (dans notre cas 2.0) à l'aide de la fonction WSAStartup. Puis, avant de quitter le programme, il faut appeler WSACleanup (qui ne nécessite aucun argument). Voici donc le squelette d'un programme utilisant l'API WinSock :

#include <winsock2.h>

int main()
{
WSADATA wsaData;

if (WSAStartup(MAKEWORD(2, 0), &wsaData) != 0)
/* La fonction WSAStartup a échoué */ ;
else
{
/* ... On peut continuer ... */

/*, Mais il faut appeler WSACleanup a la fin */
WSACleanup();
}

return 0;
}

III - Le mode connecté

III-A - Réalisation d'un client

Nous allons créer un programme qui va se connecter sur la machine locale (127.0.0.1), sur le port 5050 et qui, une fois connecté, va attendre un message de bienvenue venant du serveur, afficher ce message puis envoyer une réponse au serveur.

La fonction qui permet de créer un socket est socket :

SOCKET socket(int pf, int type, int protocol);

Le premier argument, pf pour protocol family, spécifie la famille de protocoles que le socket doit supporter, dans notre cas : PF_INET (TCP/IP). Cependant, sachez qu'à chaque famille de protocoles correspond une famille d'adresses (chaque architecture est caractérisée par son système d'adressage, sur Internet on utilise l'adressage IP), c'est pourquoi on dit aussi que le premier argument de la fonction socket spécifie la famille d'adresses (en abrégé af pour address family) à utiliser avec le socket. Dans notre cas, on peut donc remplacer PF_INET par AF_INET. D'ailleurs, en fait, PF_INET = AF_INET.

Le deuxième argument, type, permet de spécifier le type du socket. Dans le cas d'un socket PF_INET, on a au moins le choix entre SOCK_STREAM qui permet de créer un socket orienté connexion et SOCK_DGRAM qui permet de créer un socket non orienté connexion. Pour cet exemple, nous allons créer un socket orienté connexion c'est-à-dire de type SOCK_STREAM.

Et enfin le dernier argument, protocol, spécifie le protocole à utiliser avec le socket. Nous pourrons par exemple choisir TCP (IPPROTO_TCP) pour un socket Internet (PF_INET) orienté connexion (SOCK_STREAM) et UDP (IPPROTO_UDP) pour un socket non orienté connexion. La valeur 0 est également acceptée et dans ce cas la fonction va choisir elle-même le protocole qui va le mieux avec notre socket vu les valeurs que nous avons spécifiées dans les deux premiers arguments.

SOCKET s;

s = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
if (s == INVALID_SOCKET)
/* La fonction socket a échoué */ ;
else
{
/* ... On continue ... */

/*, Mais il faut liberer le socket a la fin */
closesocket(s);
}

Maintenant, nous allons connecter ce socket au port 5050 de 127.0.0.1. Nous allons donc dans un premier temps décrire la machine avec laquelle on veut établir une connexion à l'aide d'une structure de type SOCKADDR_IN (struct sockaddr_in), ensuite appeler la fonction connect qui va effectivement faire le lien entre notre socket et le serveur.

SOCKADDR_IN server;

server.sin_family       = AF_INET;
server.sin_addr.s_addr  = inet_addr("127.0.0.1");
server.sin_port         = htons(5050);
memset(&server.sin_zero, '\0', sizeof(server.sin_zero));

if (connect(s, (SOCKADDR *)&server, sizeof(server)) == SOCKET_ERROR)
/* La fonction connect a échoué */ ;
else
{
/* Connexion réussie ! */
}

Le champ sin_addr de la structure SOCKADDR_IN qui est censé contenir l'adresse proprement dite n'est pas de type entier. C’est une structure de type IN_ADDR (struct in_addr) qui est composée seulement d'un champ, S_un, qui est une union entre trois objets de types différents, permettant chacun de stocker une adresse IP. Parmi eux, on notera S_addr qui est de type entier long. s_addr est en fait un raccourci (un #define) vers S_un.S_addr. La fonction inet_addr permet de convertir une chaîne contenant une adresse IP en un entier représentant cette adresse. Et enfin, le champ sin_zero est inutilisé, mais il doit être mis à zéro. Il ne sert qu'à faire en sorte que les structures SOCKADDR (présentée plus bas) et SOCKADDR_IN aient la même taille.

La fonction htons (host to netwok short) permet de réarranger l'ordre des octets de l'entier (unsigned short) passé en argument de façon à ce qu'il corresponde au format requis sur le réseau. En effet, sur les machines x86 par exemple, les octets sont rangés en mémoire en commençant par l'octet de poids le plus faible. On appelle ce format little-endian. Or sur le réseau c'est complètement l'inverse : les octets sont rangés en commençant par l'octet de poids le plus fort. C'est le format big-endian. htons fera en sorte, quel que soit le format utilisé sur votre machine, que les octets soient rangés dans le bon ordre. Il y a aussi la fonction ntohs (netwok to host short) qui réalise l'inverse et htonl et ntohl qui s'utilisent de la même façon que les deux premiers, mais avec des arguments de type unsigned long. Sachez également que l'entier retourné par inet_addr est déjà au format réseau (big-endian), c'est pourquoi il ne faut plus appeler htonl (sinon l'ordre des octets sera à nouveau inversé …).

SOCKADDR (struct sockaddr) est une structure générique permettant de décrire n'importe quelle adresse (c'est-à-dire indépendamment du protocole utilisé). Le cast de &server en SOCKADDR * est nécessaire car la conversion de SOCKADDR_IN * à SOCKADDR * ne peut se faire implicitement.

Maintenant, on va attendre le message du serveur puis lui envoyer une réponse après avoir affiché ce message. Il est donc temps de parler des fonctions permettant d'envoyer ou de recevoir des données à travers un socket, à savoir send et recv en mode connecté et sendto et recvfrom en mode non connecté. Dans notre cas, c'est recv et send qu'il faut utiliser.

int send(SOCKET s, const char * buf, int len, int flags);
int recv(SOCKET s, char * buf, int len, int flags);

On constate à quel point ces fonctions sont voisines des fonctions read et write (ou si vous voulez, fread et fwrite). Le seul argument qui mérite d'être commenté est donc l'argument flags. Cet argument est rarement utilisé (la plupart du temps on met tout simplement 0), mais son rôle est de spécifier la manière de réaliser l'opération actuelle. Par exemple dans un recv, le flag MSG_PEEK permet de ne pas retirer les données du tampon de lecture après leur lecture. Dans un send, MSG_DONTROUTE permet de spécifier que les données ne doivent pas être routées. Et enfin, tout comme read et write, ces fonctions retournent le nombre d'octets effectivement lus / écrits ou SOCKET_ERROR en cas d'erreur.

Avant de fermer (c'est-à-dire libérer) le socket, il faut tout d'abord se déconnecter du serveur à l'aide de la fonction shutdown.

int shutdown(SOCKET s, int how);

Si how = SD_SEND, il ne sera plus possible d'écrire sur le socket. En particulier, si le socket utilise le protocole TCP et que le tampon d'écriture associé au socket n'est pas vide (ce qui signifie qu'il reste encore des données à transmettre), la fonction patientera jusqu'à ce que ce tampon soit complètement vidé (et les données arrivées à destination) avant d’émettre la demande de déconnexion. Une autre manière permettant d'initier une demande de déconnexion est d'utiliser la fonction WSASendDisconnect, mais cette fonction est spécifique de l'API Windows …

Lorsque nous avons bien été déconnectés, c'est à ce moment et à ce moment seulement que l'on peut fermer (libérer) le socket. Cela est réalisé par la fonction closesocket (close sous UNIX puisqu'un socket n'est ni plus ni moins qu'un descripteur de fichier).

Un exemple valant mille mots, voici le code complet de notre programme « client » :

client.c

#include <stdio.h>
#include <winsock2.h>

int main()
{
WSADATA wsaData;

if (WSAStartup(MAKEWORD(2, 0), &wsaData) != 0)
fprintf(stderr, "La fonction WSAStartup a échoué.\n");
else
{
SOCKET s;

s = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
if (s == INVALID_SOCKET)
fprintf(stderr, "La fonction socket a échoué.\n");
else
{
SOCKADDR_IN server;

server.sin_family       = AF_INET;
server.sin_addr.s_addr  = inet_addr("127.0.0.1");
server.sin_port         = htons(5050);
memset(&server.sin_zero, '\0', sizeof(server.sin_zero));

if (connect(s, (SOCKADDR *)&server, sizeof(server)) == SOCKET_ERROR)
fprintf(stderr, "La fonction connect a échoué.\n");
else
{
char buffer[100];
int n;

n = recv(s, buffer, sizeof(buffer) - 1, 0); /* Lire tout au plus sizeof(buffer) - 1 octets */
/* Il faut réserver le dernier octet pour le caractère de fin de chaîne, '\0'.             */

if (n != SOCKET_ERROR)
{
buffer[n] = '\0';
printf("%s", buffer);

send(s, "Au revoir\n", (int)strlen("Au revoir\n"), 0);
}

shutdown(s, SD_SEND);
}

closesocket(s);
}

WSACleanup();
}

return 0;
}

Le cast du strlen dans le send sert tout simplement à supprimer le warning insupportable de certains compilateurs aboyant que la conversion d'une donnée de type size_t en int peut entraîner des pertes de données. Donc si ce message ne vous gêne pas ou tout simplement si vous ne l'avez pas, vous pouvez très bien le supprimer.

III-B - Réalisation d'un serveur

Nous allons à présent créer un serveur qui va écouter sur le port 5050 en attente d'une demande de connexion de la part d'un client. Le cas échéant, la connexion sera acceptée, un message de bienvenue sera envoyé puis lorsque le client aura répondu, le programme se terminera. Bien entendu, un vrai serveur ne se termine pas de cette façon, mais on verra ce détail plus tard.

Nous avons déjà presque tout dit des sockets (enfin je veux dire des bases) dans le paragraphe précédent, mais il y a encore quelques notions sur les serveurs qui n'ont pas encore été présentées. Comme nous le savons bien, une machine peut posséder plusieurs interfaces réseau (ou cartes réseau). À chaque interface peut être associée une adresse IP. Lorsqu'un serveur se met en marche, il doit choisir sur quelle interface (et quel port) il veut écouter, c'est-à-dire attendre les demandes de connexion. Donc si une machine possède deux interfaces, A et B, et qu'un client vient se connecter sur A alors que le serveur écoute sur B, leurs chemins ne se croiseront pas. Or, généralement, on veut que le serveur soit capable d'accepter toutes les demandes de connexion arrivant sur n'importe quelle interface de la machine. Alors que faire ? Eh bien, on met tout simplement INADDR_ANY (0) dans le champ adresse (sin_adrr.s_addr) de la structure SOCKADDR_IN. Ensuite, un serveur n'appelle pas connect mais plutôt accept, qui est une fonction qui permet de rester en attente d'une demande de connexion. Mais avant de faire un accept sur un socket, il faut que celui-ci ait déjà été correctement attaché à un port (comme avec connect) et que ce port soit déjà sur écoute, ce que l'on peut faire respectivement à l'aide des fonctions bind et listen. Donc vous voyez bien qu'un serveur n'est pas plus compliqué qu'un client.

serveur.c

#include <stdio.h>
#include <winsock2.h>

int main()
{
WSADATA wsaData;

if (WSAStartup(MAKEWORD(2, 0), &wsaData) != 0)
fprintf(stderr, "La fonction WSAStartup a échoué.\n");
else
{
SOCKET s_server;

s_server = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
if (s_server == INVALID_SOCKET)
fprintf(stderr, "La fonction socket a échoué.\n");
else
{
SOCKADDR_IN server;

server.sin_family       = AF_INET;
server.sin_addr.s_addr  = htonl(INADDR_ANY);
server.sin_port         = htons(5050);
memset(&server.sin_zero, '\0', sizeof(server.sin_zero));

if (bind(s_server, (SOCKADDR *)&server, sizeof(server)) == SOCKET_ERROR)
fprintf(stderr, "La fonction bind a échoué.\n");
else
{
if (listen(s_server, 0) == SOCKET_ERROR) /* listen : commencer l'écoute */
fprintf(stderr, "La fonction listen a échoué.\n");
else
{
SOCKET s_client;
SOCKADDR_IN client;
int csize = sizeof(client);

s_client = accept(s_server, (SOCKADDR *)&client, &csize);

if (s_client == INVALID_SOCKET)
fprintf(stderr, "La fonction accept a échoué.\n");
else
{
char buffer[100];
int n;

printf("Le client %s s'est connecté !\n", inet_ntoa(client.sin_addr));

strcpy(buffer, "Bonjour\n");
send(s_client, buffer, (int)strlen(buffer), 0);

n = recv(s_client, buffer, sizeof(buffer) - 1, 0);

if (n != SOCKET_ERROR)
{
buffer[n] = '\0';
printf("%s", buffer);
}

closesocket(s_client);
}
}
}

closesocket(s_server);
}

WSACleanup();
}

return 0;
}

Le second paramètre de la fonction listen s'appelle le backlog. Il indique le nombre maximal de demandes de connexion qu'il peut y avoir dans la file d'attente. En effet, la fonction accept ne traite qu'une seule demande à la fois. Or, pendant que le serveur est en train de traiter cette demande, il y en a d'autres qui peuvent arriver. Ces dernières seront alors placées dans une file d'attente. Ainsi, si le backlog vaut quatre et que quatre demandes de connexion sont déjà en file d'attente, une cinquième demande sera immédiatement refusée. Si le backlog vaut zéro, aucune limite théorique n'est imposée.

Les deux derniers arguments de accept ne sont pas obligatoires (si vous ne les utilisez pas, mettez donc NULL), mais ils permettent de récupérer des informations utiles comme on peut d'ailleurs le constater dans l'exemple. D'ailleurs, on pourra toujours récupérer ces mêmes informations à n'importe quel moment grâce à la fonction getpeername.

La fonction inet_ntoa permet de convertir une adresse représentée par une structure IN_ADDR en chaîne de caractères, dans le format usuel (xxx.xxx.xxx.xxx).

Enfin, sachez qu'un serveur peut également initier la déconnexion d'un client en appelant toujours shutdown sur le socket identifiant le client avec SD_SEND. Ici, il n'était pas vraiment nécessaire d'appeler shutdown car le dernier send du serveur est suivi d'un recv bloquant (c'est le client qui aura le dernier mot !).

IV - Le mode non connecté

La programmation des sockets non orientés connexion (SOCK_DGRAM) n'est pas très différente de leur programmation en mode connecté. La principale différence est que l'on n'a plus besoin d'appeler connect puisque l'adresse du destinataire est spécifiée à chaque send, ni listen et accept du côté du récepteur puisque chaque message entrant peut venir de n'importe où. Par contre, il est toujours nécessaire d'attacher le socket récepteur à un port de la machine, c'est-à-dire faire un bind. En outre, on utilisera sendto et recvfrom à la place de send et recv.

int sendto(SOCKET s, const char * buf, int len, int flags, const struct sockaddr * to, int tolen);
int recvfrom(SOCKET s, char * buf, int len, int flags, struct sockaddr * from, int * fromlen);

En fait, la fonction sendto peut être utilisée aussi bien en mode connecté qu'en mode non connecté. Si s est un socket connecté, alors les arguments to et tolen seront tout simplement ignorés et la fonction se comportera comme un simple send. Dans recvfrom, s doit être un socket attaché à un port. De plus, comme dans accept, les arguments from et fromlen ne sont pas obligatoires, mais sont quand même utiles pour avoir des informations sur l'expéditeur du message.

À présent nous allons voir un petit exemple de communication entre deux programmes en mode non connecté : un expéditeur et un récepteur.

Code source de l'expéditeur :

expediteur.c

#include <stdio.h>
#include <winsock2.h>

int main()
{
WSADATA wsaData;

if (WSAStartup(MAKEWORD(2, 0), &wsaData) != 0)
fprintf(stderr, "La fonction WSAStartup a échoué.\n");
else
{
SOCKET s;

s = socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (s == INVALID_SOCKET)
fprintf(stderr, "La fonction socket a échoué.\n");
else
{
SOCKADDR_IN dest;
char buffer[100] = "Bonjour\n";

dest.sin_family       = AF_INET;
dest.sin_addr.s_addr  = inet_addr("127.0.0.1");
dest.sin_port         = htons(5050);
memset(&dest.sin_zero, '\0', sizeof(dest.sin_zero));

sendto(s, buffer, (int)strlen(buffer), 0, (SOCKADDR *)&dest, sizeof(dest));

closesocket(s);
}

WSACleanup();
}

return 0;
}

Et maintenant le code du récepteur :

recepteur.c

#include <stdio.h>
#include <winsock2.h>

int main()
{
WSADATA wsaData;

if (WSAStartup(MAKEWORD(2, 0), &wsaData) != 0)
fprintf(stderr, "La fonction WSAStartup a échoué.\n");
else
{
SOCKET s;

s = socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (s == INVALID_SOCKET)
fprintf(stderr, "La fonction socket a échoué.\n");
else
{
SOCKADDR_IN r; /* Le récepteur (nous) */

r.sin_family       = AF_INET;
r.sin_addr.s_addr  = htonl(INADDR_ANY);
r.sin_port         = htons(5050);
memset(&r.sin_zero, '\0', sizeof(r.sin_zero));

if (bind(s, (SOCKADDR *)&r, sizeof(r)) == SOCKET_ERROR)
fprintf(stderr, "La fonction bind a échoué.\n");
else
{
SOCKADDR_IN e; /* L’expéditeur (d’où vient le message ...) */
int n, esize;
char buffer[100];

esize = sizeof(e);
n = recvfrom(s, buffer, sizeof(buffer) - 1, 0, (SOCKADDR *)&e, &esize);

if (n != SOCKET_ERROR)
{
buffer[n] = '\0';
printf("Un message de %s : %s", inet_ntoa(e.sin_addr), buffer);
}
}

closesocket(s);
}

WSACleanup();
}

return 0;
}

V - Traitement des erreurs

Le traitement des erreurs sous Windows diffère un peu de la manière de le faire sous UNIX. Alors qu'un programmeur UNIX testerait le retour de chaque fonction (qui peut valoir ou NULL ou -1 en cas d'erreur selon que la fonction retourne un pointeur ou un entier) puis testerait la valeur de errno pour obtenir de plus amples informations quant à l'origine de l'erreur, un programmeur Windows testera également le retour des fonctions (qui, en cas d'erreur, peut être INVALID_SOCKET pour celles qui retournent un SOCKET, NULL pour celles qui retournent un pointeur et SOCKET_ERROR pour celles qui retournent un entier), mais appellera ensuite la fonction WSAGetLastError pour connaître la cause de l'erreur. La fonction WSASetLastError permet de modifier la valeur mémorisée par WSAGetLastError.

VI - F.A.Q.

Où trouver la documentation des protocoles utilisés sur Internet ?

Les protocoles utilisés sur Internet sont spécifiés par des documents internationaux appelés RFC (Request For Comments). Ils sont très nombreux et disponibles en téléchargement sur de nombreux sites, dont celui de l'IETF : http://www.ietf.org/.

Comment récupérer le nom de l'ordinateur ?

À l'aide de la fonction gethostname.

int gethostname(char * name, int namelen);

Comment connaître l'adresse d'un hôte dont le nom est donné ?

À l'aide de la fonction gethostbyname.

struct hostent * gethostbyname(const char * name);

Cette fonction retourne un pointeur vers une structure de type HOSTENT (struct hostent). Cette structure contient des informations concernant un hôte donné, dont celle qui nous intéresse ici, son adresse. Le programme suivant demande à l'utilisateur d'entrer une chaîne de caractères (sans espace) et affiche l'adresse IP de la machine dont le nom correspond à cette chaîne ou un message d'erreur.

#include <stdio.h>
#include <winsock2.h>

int main()
{
WSADATA wsaData;

if (WSAStartup(MAKEWORD(2, 0), &wsaData) != 0)
fprintf(stderr, "La fonction WSAStartup a échoué.\n");
else
{
char s[100];
HOSTENT * h;

printf("Entrez le nom de la machine : "); /* exemples : localhost, developpez.com, www.developpez.com, etc. */
scanf("%99s", s);
h = gethostbyname(s);

if (h == NULL)
fprintf(stderr, "L’hôte '%s' n'a pas pu être trouvé.\n", s);
else
{
/* Supposons que l'adresse de la machine soit 127.0.0.1 */
/* Alors : h->h_addr_list[0][0] = 127 (1 octet) */
/*         h->h_addr_list[0][1] = 0   (1 octet) */
/*         h->h_addr_list[0][2] = 0   (1 octet) */
/*         h->h_addr_list[0][3] = 1   (1 octet) */

IN_ADDR addr; /* rappel : 'ULONG addr.s_addr;' (4 octets) */
memcpy(&addr.s_addr, h->h_addr_list[0], sizeof(addr.s_addr));

printf("%s\n", inet_ntoa(addr));
}

WSACleanup();
}

return 0;
}

Le champ h_addr_list est un tableau contenant une liste d'adresses permettant chacune d'identifier l'hôte (en effet un hôte peut posséder plusieurs adresses logiques). Le dernier élément de ce tableau est un pointeur nul (NULL). La macro h_addr est également définie et correspond à h_addr_list[0]. Sous Windows uniquement, il existe une fonction plus à la mode, getaddrinfo, permettant d'avoir plus d'informations (cette fonction n'existe pas sous UNIX).

Comment connaître l'adresse d'un hôte identifié par un socket ?

Cette question se pose souvent quand on a affaire à un serveur. La fonction accept permet d'associer un socket à un client accepté et de récupérer en même temps l'adresse du client (deuxième et troisième arguments). Si jamais on a oublié de récupérer cette adresse au moment de l'accept, on pourra toujours les obtenir plus tard à l'aide de la fonction getpeername.

int getpeername(SOCKET s, struct sockaddr * name, int * namelen);

Remarquez bien que les deux derniers arguments sont exactement les mêmes que ceux d'accept …

VII - Au-delà des sockets bloquants

VII-A - Introduction

Nous avons vu d'après les exemples précédents que certaines fonctions comme recv ou accept ne répondent que lorsque des données arrivent sur le socket. Les applications client/serveur sont donc généralement multithreadées (une application multithreadée est une application qui réalise plusieurs tâches (thread) en même temps) afin de pouvoir traiter en même temps les événements qui se produisent sur le réseau d'une part et les entrées de l'utilisateur d'autre part. Mais il existe également d'autres méthodes, plus ou moins basées sur l'utilisation des sockets non bloquants. Ce sont ces méthodes que nous allons étudier dans cette partie.

VII-B - Les sockets non bloquants

Lorsqu'un socket est créé, celui-ci est dans le mode par défaut qui n'est autre que le mode bloquant. Pour mettre un socket en mode non bloquant, il suffit d'appeler la fonction ioctlsocket (et là encore, ioctl sous UNIX étant donné qu'un socket n'est ni plus ni moins qu'un descripteur de fichier …) sur ce socket en fournissant les bons paramètres (nous y reviendrons tout à l'heure). Lorsqu'un socket est en mode non bloquant, les fonctions comme recv, accept, etc. renvoient immédiatement après l'appel si les données ont pu être lues ou non. Si aucune donnée n'a pu être lue, la fonction retournera une valeur indiquant une erreur et WSAGetLastError retournera WSAEWOULDBLOCK. L'extrait de code suivant montre comment rendre un socket (s) non bloquant.

ULONG fionbio = TRUE;
ioctlsocket(s, FIONBIO, &fionbio);

VII-C - Spécifier un temps d'attente

La fonction setsockopt permet d'ajuster certaines options d'un socket comme le délai d'attente des fonctions de lecture par exemple. Lorsqu'un tel délai a été fixé, si aucune donnée n'arrive durant la période spécifiée, la fonction échoue et WSAGetLastError retourne WSATIMEDOUT. On peut également fixer un tel délai aux fonctions d'écriture. Évidemment, toutes ces manœuvres n'ont d'intérêt que sur des sockets bloquants puisqu'un socket non bloquant est déjà … non bloquant.

int setsockopt(SOCKET s, int level, int optname, const char * optval, int optlen);

L'argument level indique à quel niveau (par exemple SOL_SOCKET) se situe l'option (optname) que l'on veut modifier. L'extrait de code suivant montre comment accorder 2 secondes aux fonctions de lecture pour compléter leur opération.

DWORD timeo = 2000; /* 2000 ms */
setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (char *)&timeo, sizeof(timeo));

VII-D - Les notifications

Windows permet d'associer un message à chaque événement se produisant sur un socket (arrivée de données, arrivée d'une demande de connexion, etc.). Cela permet de développer des programmes très structurés et facilement maintenables. Il existe également d'autres techniques similaires, mais plus difficiles à mettre en œuvre, c'est pourquoi nous ne parlerons ici que de la première.

Cette technique se résume en fait à l'utilisation d'une et une seule fonction en plus : WSAAsyncSelect.

int WSAAsyncSelect(SOCKET s, HWND hWnd, unsigned int wMsg, long lEvent);

wMsg est le message qu'on veut associer avec le socket et lEvent une combinaison de valeurs indiquant les différents événements autorisés à provoquer l'envoi du message. Les valeurs les plus utilisées sont :

- FD_ACCEPT : permet d'être notifié de la présence d'une demande de connexion ;

- FD_READ : permet d'être notifié de la présence de données dans le tampon de lecture ; associé au socket

- FD_WRITE : permet d'être notifié lorsque le socket peut être écrit ;

- FD_CLOSE : permet d'être notifié de la fermeture d'une connexion.

Par exemple :

#define WM_NETEVENT (WM_APP + 1)

...

WSAAsyncSelect(s, hWnd, WM_NETEVENT, FD_READ | FD_WRITE);

Permet d'ordonner à Windows de nous envoyer le message WM_NETEVENT à travers la fenêtre hWnd chaque fois qu'un événement FD_READ ou FD_WRITE se produit sur le socket s.

Après un appel réussi à WSAAsyncSelect, le socket que l'on a utilisé devient non bloquant (tant mieux !). Lorsqu'un message est envoyé à la fenêtre, on a :

- Dans wParam : le socket sur lequel l'événement s'est produit ;

- LOWORD(lParam) : l'événement qui s'est produit (FD_XXX) ;

- HIWORD(lParam) : un code d'erreur.

Les macros WSAGETSELECTEVENT et WSAGETSELECTERROR (nécessitent tous lParam en paramètre) permettent de récupérer respectivement la description de l'événement et le code d'erreur avec un niveau d'abstraction plus élevé.

Pour terminer, sachez également qu'un socket retourné par accept a les mêmes propriétés que le socket d'écoute, c'est-à-dire comme si le même WSAAsyncSelect avait été aussi appliqué au nouveau socket. Rien ne nous empêche cependant de modifier les propriétés au socket ainsi créé.

VIII - Remerciements

Je tiens à adresser mes plus vifs remerciements à Emmanuel Delahaye pour sa relecture technique.