Cours programmation réseau en C++

Premiers pas avec TCP

Bousk

Dans cette première partie, nous allons apprendre à créer un socket et nous connecter à un serveur via son adresse IP.

Commentez

- Titre : Cours programmation réseau en C++

- Auteur : Bousk

- Parution : 26 mai 2016

- Mise à jour : 8 mars 2019

- Public visé :

- 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 ® 2016 Cyrille (Bousk) Bousquet. 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

- I-A. Le socket TCP

- II. Spécificité Windows : initialisation

- II-A. WSAStartup - int WSAStartup(WORD version, WSADATA* data);

- II-B. WSACleanup - int WSACleanup();

- III. Gestion d’erreurs

- III-A. Windows - int WSAGetLastError();

- III-B. Unix - errno

- IV. Présentation des fonctions utiles

- IV-A. hton*

- IV-B. ntoh*

- V. Manipuler un socket

- V-A. socket - int socket(int family, int type, int protocol);

- V-B. Windows – int closesocket(SOCKET socket);

- V-C. UNIX – int close(int socket);

- VI. Se connecter à une machine distante

- VI-A. Windows - int connect(SOCKET _socket, const sockaddr* server, int serverlen);

- VI-B. UNIX – int connect(int _socket, const struct sockaddr* server, socklen_t serverlen);

- VII. Proposition de corrigé

I - Introduction

Un socket, qu’il soit TCP ou UDP, sera défini par un simple entier qui devra être passé aux fonctions qui l’utilisent. Sur plateformes UNIX il s’agira d’un descripteur de fichier, mais sur Windows c’est un descripteur de socket. La différence majeure est sur le type utilisé : un descripteur de socket (sous Windows donc) est un entier non signé (unsigned int), tandis que sous UNIX il s’agira d’un entier signé (int). Cela peut entraîner des avertissements supplémentaires à la compilation lors du portage d’un code d’une plateforme à l’autre.

I-A - Le socket TCP

Un socket TCP représente un lien direct avec une machine distante et est une route d’échange de données bilatérale avec celle-ci.

Un socket TCP sera utilisé pour envoyer des données à la machine distante qu’il représente, mais également pour en recevoir de cette dernière.

Dans cette première partie, nous allons voir comment créer un socket TCP et se connecter à un serveur dont on connait l’adresse IP et le port.

La plupart des fonctions présentées ici seront également utilisées pour un socket UDP.

II - Spécificité Windows : initialisation

Quelques spécificités existent sur plateforme Windows pour utiliser les sockets.

Il s’agit de deux méthodes particulières à appeler pour démarrer et arrêter la bibliothèque de sockets.

II-A - WSAStartup - int WSAStartup(WORD version, WSADATA* data);

Permet d’initialiser la DLL pour utiliser les sockets. La version actuelle est la 2.2. Derrière ce prototype, il s’agit d’effectuer un simple appel de fonction comme suit :

WSADATA data;
WSAStartup(MAKEWORD(2, 2), &data);

Retourne un code d’erreur en cas d’échec, 0 sinon.

II-B - WSACleanup - int WSACleanup();

Appeler cette fonction à la fin du programme pour libérer la DLL.

WSACleanup();

Retourne SOCKET_ERROR en cas d’erreur, 0 sinon.

III - Gestion d’erreurs

Quand une fonction génère une erreur, dans la majorité des cas, elle retourne -1 sous UNIX ou SOCKET_ERROR sous Windows, et met à jour l'indicateur d’erreur de son thread (souvenez-vous qu’il s’agit d’une bibliothèque en C). Pour récupérer la valeur d’erreur correcte, il faut récupérer la dernière erreur ainsi mise à jour.

III-A - Windows - int WSAGetLastError();

Comme son nom l’indique, retourne la dernière erreur survenue dans la bibliothèque de sockets pour le thread appelant la fonction.

Attention, sur Windows, la plupart des codes d'erreurs sont également « spécifiques ». EWOULDBLOCK sera par exemple remplacé par WSAEWOULDBLOCK, etc. La liste complète est disponible sur le site de la MSDN.

int error = WSAGetLastError();

III-B - Unix - errno

Sur Unix, il suffira de lire la valeur de la variable globale errno, disponible dans errno.h

#include <errno.h>
int error = errno;

IV - Présentation des fonctions utiles

IV-A - hton*

Les fonctions de cette forme sont les fonctions Host/Home to Network. Elles servent à convertir les données numériques de la machine en données « réseau ».

Par convention, les communications réseau sont en big-endian, c’est-à-dire l’octet de poids fort en premier. On parle aussi de network byte order.

Il existe une méthode pour chaque type numérique existant :

Conversion local vers réseau

short htons(short value);
long htonl(long value);

IV-B - ntoh*

Il s’agit des fonctions inverses des hton*. Elles convertissent les données réseau en données Host/Home.

Conversion réseau vers local

short ntohs(short value);
long ntohl(long value);

V - Manipuler un socket

Avant toute chose, il faut créer le socket à manipuler.

V-A - socket - int socket(int family, int type, int protocol);

Crée un socket avec les paramètres passés.

- family définit la famille du socket. Les valeurs principales sont AF_INET pour un socket IPv4, AF_INET6 pour un support IPv6.

- type spécifie le type de socket. Les valeurs principales utilisées sont SOCK_STREAM pour TCP, SOCK_DGRAM pour UDP.

- protocol définit le protocole à utiliser. Il sera dépendant du type de socket et de sa famille. Les valeurs principales sont IPPROTO_TCP pour un socket TCP, IPPROTO_UDP pour un socket UDP.

Retourne INVALID_SOCKET sous Windows, -1 sous UNIX, en cas d’erreur, le socket sinon.

Créer un socket

SOCKET socket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (socket == INVALID_SOCKET)
// erreur

Une fois qu’on en a fini avec notre socket, il faut le fermer pour indiquer au système qu’il peut disposer de celui-ci. Que le port qu’il utilisait est à nouveau disponible, que les ressources nécessaires à son utilisation peuvent être libérées.

V-B - Windows – int closesocket(SOCKET socket);

Ferme le socket précédemment ouvert.

- socket est le socket à fermer.

Retourne SOCKET_ERROR en cas d’erreur, 0 sinon.

V-C - UNIX – int close(int socket);

Ferme le socket.

- socket est le socket à fermer.

Retourne -1 en cas d’erreur, 0 sinon.

Effectivement, il s’agit de la simple fonction close() utilisée habituellement pour fermer un fichier. Mais comme indiqué en début de partie, sous UNIX les sockets sont de simples descripteurs de fichiers, de simples fichiers. Ce n’est donc pas si surprenant que ça.

VI - Se connecter à une machine distante

VI-A - Windows - int connect(SOCKET _socket, const sockaddr* server, int serverlen);

Connecte un socket précédemment créé au serveur passé en paramètre.

- _socket est le socket à connecter.

- server la structure représentant le serveur auquel se connecter.

- serverlen est la taille de la structure server. Généralement un sizeof(server) suffit.

Retourne 0 si la connexion réussit, SOCKET_ERROR sinon.

VI-B - UNIX – int connect(int _socket, const struct sockaddr* server, socklen_t serverlen);

- _socket est le socket à connecter.

- server la structure représentant le serveur auquel se connecter.

- serverlen est la taille de la structure server. socklen_t est un type spécifique aux plateformes UNIX et peut être un int ou unsigned int. Généralement un sizeof(server) suffit, nous ne nous attarderons donc pas sur lui pour l’instant.

L’appel à cette fonction, quelle que soit la plateforme, est bloquant tant que la connexion n’a pas été effectuée. Autrement dit : si cette fonction retourne, c’est que votre connexion a été effectuée et acceptée par l’ordinateur distant. Sauf si elle retourne une erreur bien sûr.

Une fois notre socket connecté, il agira comme un identifiant vers la machine distante. Quand nous passerons ce socket en paramètre des fonctions, ce sera pour indiquer que l’on appelle cette fonction à destination de la machine à laquelle il est connecté, pour envoyer des données à cette machine spécifiquement ou recevoir des données qu’elle nous aurait envoyées par exemple. Par abus de langage on parlera de la machine ou du socket qui sert de passerelle vers cette machine indistinctement.

Pour créer le paramètre server, on utilise une structure sockaddr_in à initialiser ainsi :

Structure de connexion au serveur

sockaddr_in server;
server.sin_addr.s_addr = inet_addr(const char* ipaddress);
server.sin_family = AF_INET;
server.sin_port = htons(int port);

Que l’on peut ensuite utiliser comme paramètre à connect :

If (connect(socket, &server, sizeof(server) != 0)
// Erreur

Attention, l’adresse à utiliser avec inet_addr est une adresse IP (v4 ou v6) et non un nom de domaine tel que google.com. Pour se connecter à un nom de domaine, d’autres manipulations sont à réaliser, que nous verrons plus tard.

Notez également l’utilisation de htons pour indiquer le port de destination auquel se connecter.

Training Time ! Avant d’aller plus loin, pourquoi ne pas déjà s’entraîner ? Lancez ou compilez le TD 01. Un serveur se lancera sur le port de votre choix et créez un client capable de se connecter à celui-ci.

Vous devriez avoir une fenêtre comme ça en lançant le serveur :

Puis quand un client se connecte, une ligne d’information relative à celui-ci s’affichera :

Puisque le serveur est sur la même machine que le client, votre pc, l’IP du serveur sera 127.0.0.1 aussi appelée adresse locale ou de loopback.

VII - Proposition de corrigé

Si vous êtes parvenus à vous connecter au serveur (et voir apparaître la ligne correspondante sur sa console), alors c’est que votre code est bon. Toutefois je vous propose comment personnellement j’aurais réalisé ceci.

Tout d’abord, le plus simple (pour moi), ce que j’aurais écrit sous Windows, en travaillant sous Visual Studio :

Main.cpp - premier jet, sous Windows

1. #include <iostream>

2. #include <WinSock2.h>

3. #pragma comment(lib, "Ws2_32.lib")

5. int main()

6. {

7. WSADATA data;

8. WSAStartup(MAKEWORD(2, 2), &data);

10. SOCKET socket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

11. if (socket == INVALID_SOCKET)

12. {

13. std::cout << "Erreur creation socket : " << WSAGetLastError() << std::endl;

14. return 0;

15. }

16. sockaddr_in server;

17. server.sin_addr.s_addr = inet_addr("127.0.0.1");

18. server.sin_family = AF_INET;

19. server.sin_port = htons(6666);

20. if (connect(socket, &server, sizeof(server)) == SOCKET_ERROR)

21. {

22. std::cout << "Erreur connection : " << WSAGetLastError() << std::endl;

23. return 0;

24. }

25. std::cout << "Socket connecte !" << std::endl;

26. closesocket(socket);

27. WSACleanup();

28. }

Quelques avertissements peuvent éventuellement survenir, notamment sur l’utilisation de inet_addr qui est dépréciée sur les versions récentes de Visual Studio.

Globalement, le code serait identique à peu de choses près pour les autres plateformes. Les seules spécificités à ce niveau sont l’appel à WSAStartup pour initialiser la DLL, WSACleanup pour la désinitialiser, WSAGetLastError pour récupérer l’erreur survenue et closesocket pour fermer le socket. Remarquez également que Windows déclare le type SOCKET, un define sur unsigned int, alors que sous UNIX on manipulerait un simple int. Servons-nous de la proposition de Windows pour utiliser SOCKET dans notre code, qui s’adaptera à la plateforme à la compilation. Faisons de même pour INVALID_SOCKET qui, en lisant la documentation de socket(), devra valoir -1 et sera un int :

#ifndef _WIN32
#define SOCKET int
#define INVALID_SOCKET ((int)-1)
#endif

Ainsi SOCKET sera un unsigned int sous Windows grâce à sa déclaration dans Winsock2.h que l’on inclut, et sera un int sous les autres plateformes via notre define. Et on utilisera désormais cette déclaration pour nos sockets.

Puisque ce cours se veut un minimum accessible sur différentes plateformes, faisons en sorte que ce soit le cas maintenant. Comme bien souvent, « l’astuce » consiste simplement à ajouter une indirection.

Ajoutons un fichier Sockets.h contenant ceci :

Sockets.h

1. #ifndef _WIN32

2. #define SOCKET int

3. #define INVALID_SOCKET ((int)-1)

4. #endif

6. namespace Sockets

7. {

8. bool Start();

9. void Release();

10. int GetError();

11. bool CloseSocket(SOCKET socket);

12. }

L’implémentation de chacune des fonctions dépendra de la plateforme, quitte à être vides, mais elles seront utilisables sur toutes. Ainsi le code initial deviendra :

Main.cpp

1. #include "Socket.h"

3. #include <iostream>

4. #include <WinSock2.h>

6. int main()

7. {

8. if (!Sockets::Start())

9. {

10. std::cout << "Erreur initialisation : " << Sockets::GetError() << std::endl;

11. return 0;

12. }

14. SOCKET socket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

15. if (socket == INVALID_SOCKET)

16. {

17. std::cout << "Erreur creation socket : " << Socket::GetError() << std::endl;

18. return 0;

19. }

20. sockaddr_in server;

21. server.sin_addr.s_addr = inet_addr("127.0.0.1");

22. server.sin_family = AF_INET;

23. server.sin_port = htons(6666);

24. if (connect(socket, &server, sizeof(server)) == SOCKET_ERROR)

25. {

26. std::cout << "Erreur connection : " << Socket::GetError() << std::endl;

27. return 0;

28. }

29. std::cout << "Socket connecte !" << std::endl;

30. Sockets::CloseSocket(socket);

31. Sockets::Release();

32. }

Concernant l’implémentation de nos fonctions, ajoutons un fichier Sockets.cpp pour celles-ci :

Sockets.cpp

1. #include "Sockets.h"

2. namespace Sockets

3. {

4. bool Start()

5. {

6. #ifdef _WIN32

7. WSAData wsaData;

8. return WSAStartup(MAKEWORD(2, 2), &wsaData) == 0;

9. #else

10. return true;

11. #endif

12. }

13. void Release()

14. {

15. #ifdef _WIN32

16. WSACleanup();

17. #endif

18. }

19. int GetError()

20. {

21. #ifdef _WIN32

22. return WSAGetLastError();

23. #else

24. return errno;

25. #endif

26. }

27. void CloseSocket(SOCKET s)

28. {

29. #ifdef _WIN32

30. closesocket(s);

31. #else

32. close(s);

33. #endif

34. }

35. }

Si la vue d’instructions ifdef au milieu du code vous dérange, vous pouvez opter pour avoir un fichier d’implémentation différent selon la plateforme. Ici par simplicité, et préférence personnelle, j’ai choisi de n’utiliser qu’un seul fichier et ces instructions préprocesseurs.

Il ne reste plus que l’include de Winsock2.h qui traîne, puisque nous avons regroupé nos fonctions dans Sockets.h, et qu’il nous servira de porte d’entrée pour utiliser nos sockets, utilisons ce fichier pour inclure le header correct selon la plateforme cible :

Sockets.hpp

1. #ifdef _WIN32

2. #if _MSC_VER >= 1800

3. #include <WS2tcpip.h>

4. #else

5. #define inet_pton(FAMILY, IP, PTR_STRUCT_SOCKADDR) (*(PTR_STRUCT_SOCKADDR)) = inet_addr((IP))

6. typedef int socklen_t;

7. #endif

8. #include <WinSock2.h>

9. #ifdef _MSC_VER

10. #if _WIN32_WINNT >= _WIN32_WINNT_WINBLUE

11. //!< Win8.1 & higher

12. #pragma comment(lib, "Ws2_32.lib")

13. #else

14. #pragma comment(lib, "wsock32.lib")

15. #endif

16. #endif

17. #else

18. #include <sys/socket.h>

19. #include <netinet/in.h> // sockaddr_in, IPPROTO_TCP

20. #include <arpa/inet.h> // hton*, ntoh*, inet_addr

21. #include <unistd.h>  // close

22. #include <cerrno> // errno

23. #define SOCKET int

24. #define INVALID_SOCKET ((int)-1)

25. #endif

27. namespace Sockets

28. {

29. bool Start();

30. void Release();

31. int GetError();

32. bool CloseSocket(SOCKET socket);

33. }

N’oubliez pas que vous devrez également lier Ws32_2.lib sous Windows.

Et puisque nous sommes en C++, et qu’un socket s’y prête bien, pourquoi ne pas avoir une classe Socket pour le manipuler plus aisément ?

TCPSocket.hpp

1. #ifndef TCPSOCKET_HPP

2. #define TCPSOCKET_HPP

3. #pragma once

5. #include "Sockets.h"

7. #include <string>

9. class TCPSocket

10. {

11. public:

12. TCPSocket();

13. ~TCPSocket();

15. bool Connect(const std::string& ipaddress, unsigned short port);

17. private:

18. SOCKET mSocket;

19. };

21. #endif // TCPSOCKET_HPP

Avec une telle interface, notre code source initial deviendra alors :

Main.cpp

1. #include "TCPSocket.h"

3. #include <iostream>

5. int main()

6. {

7. if (!Sockets::Start())

8. {

9. std::cout << "Erreur initialisation : " << Sockets::GetError() << std::endl;

10. return 0;

11. }

13. {

14. TCPSocket socket;

15. if (!socket.Connect("127.0.0.1", 6666))

16. {

17. std::cout << "Erreur connection : " << Sockets::GetError() << std::endl;

18. return 0;

19. }

20. std::cout << "Socket connecte !" << std::endl;

21. }

22. Sockets::Release();

23. }

Beaucoup plus clair n’est-ce pas ?

L’implémentation de notre TCPSocket sera très simple à réaliser :

TCPSocket.cpp

1. #include "TCPSocket.hpp"

3. TCPSocket::TCPSocket()

4. {

5. mSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

6. if (mSocket == INVALID_SOCKET)

7. {

8. std::ostringstream error;

9. error << "Erreur initialisation socket [" << Sockets::GetError() << "]";

10. throw std::runtime_error(error.str());

11. }

12. }

13. TCPSocket::~TCPSocket()

14. {

15. Sockets::CloseSocket(mSocket);

16. }

17. bool TCPSocket::Connect(const std::string& ipaddress, unsigned short port)

18. {

19. sockaddr_in server;

20. server.sin_addr.s_addr = inet_addr(ipaddress.c_str());

21. server.sin_family = AF_INET;

22. server.sin_port = htons(port);

23. return connect(mSocket, &server, sizeof(server)) == 0;

24. }

Enfin, pour peaufiner le tout, remplaçons cet inet_addr par inet_pton comme préconisé par l’avertissement de compilation. inet_pton a été introduit avec l’arrivée d’IPv6 et est donc préféré puisqu’il peut traduire une adresse IPv4 ou IPv6, alors que inet_addr ne pouvait gérer qu’une adresse IPv4.

Un appel à inet_addr sera remplacé par inet_pton de la sorte, dans le cas de l’utilisation pour connect :

sockaddr_in server;
server.sin_addr.s_addr = inet_addr("127.0.0.1");
// équivalent à
inet_pton(AF_INET, "127.0.0.1", &server.sin_addr.s_addr);

Remarquez la symétrie d’écriture, nous permettant d’écrire la macro suivante pour n’utiliser que inet_pton dans notre code et que celui-ci effectue un appel à inet_addr s’il n’est pas disponible :

#define inet_pton(FAMILY, IP, PTR_STRUCT_SOCKADDR) (*(PTR_STRUCT_SOCKADDR)) = inet_addr((IP))

On peut éventuellement ajouter un assert pour que FAMILY soit toujours égale à AF_INET puisque la valeur AF_INET6 et les adresses IPv6 ne sont pas permises avec inet_addr. Ce n’est pas trop dérangeant puisque si l’adresse est incorrecte, une valeur d’erreur sera de toute façon retournée.

Ainsi notre TCPSocket::Connect finale sera :

bool TCPSocket::Connect(const std::string& ipaddress, unsigned short port)
{
sockaddr_in server;
inet_pton(AF_INET, ipaddress.c_str(), &server.sin_addr.s_addr);
server.sin_family = AF_INET;
server.sin_port = htons(port);
return connect(mSocket, (const sockaddr*)&server, sizeof(server)) == 0;
}

Nous possédons désormais les fondations pour écrire un client TCP pouvant se connecter à une IP. Ce code évoluera au fil du cours pour ajouter des possibilités à notre application.

Télécharger les codes sources du cours

Article précédent

Article suivant

<< Introduction

TCP – Envoi et réception >>