网络编程套接字

本文介绍了网络通信的基础知识,包括源IP地址和目的IP地址的概念,端口号的作用,以及TCP和UDP协议的特点。重点讲述了TCP的面向连接、可靠传输特性,以及UDP的无连接、尽最大努力交付的特性。此外,还详细讲解了Socket编程接口,如socket()、bind()、listen()、accept()、connect()、send()、recv()等函数的使用,以及sockaddr结构体和网络字节序的重要性。最后,给出了一个简单的UDP服务器和客户端的C++实现示例。
摘要由CSDN通过智能技术生成

1.预备知识

1.1 理解源IP地址和目的IP地址

在IP数据包头部中,有两个IP地址,分别叫做源IP地址,和目的IP地址。

源IP地址:发送数据包的节点的IP地址。这标识了本次通信的发送方。
目的IP地址:接收数据包的节点的IP地址。标识了本次通信的接收方。

也就是说,源IP地址是数据包的出发节点的IP地址,而目的IP地址,是目的节点IP地址。是一条通信线路的首位两端,但是光有IP地址不能完成通信。
比方说发QQ,有了IP地址能够把消息发送到对方的机器上,但是还需要有一个其他的标识来区分出,这个消息究竟要发给哪个程序去解析。

1.2 认识端口号

端口号是传输层协议的内容。

端口号是一个2字节16位的整数;
端口号用来标识一个进程,告诉操作系统,当前的这个数据要交给哪一个进程来处理;
IP地址+端口号能够标识网络上的某一台主机的某一个进程;
一个端口号只能被一个进程占用;

1.2.1 理解端口号和进程ID

之前学习系统编程的时候,知道pid是进程ID,表示系统中的唯一一个进程,此处的端口号同样也是表示一个进程,看起来两者非常相似,那么这两者之间是怎样的关系?

端口号和进程ID都是用于标识应用程序进程的表示符号,但两者还是有些不同:

1.作用范围不同:
a.端口号作用于网络传输,用来标识主机中服务端口。
b.进程ID仅作用于本地操作系统,标识主机内部的进程。

2.标识粒度不同:
a.端口号仅标识服务类型。例如TCP端口80代表HTTP服务。
b.进程ID唯一标识一个具体运行的进程。

3.绑定关系不同:
a.一个端口号可以被一个或多个进程绑定。多个进程可以监听同一端口。
b.一个进程ID只可以对应一个具体进程。

4.作用协议不同
a.端口号作用域传输层TCP和UDP/
b.进程ID属于操作系统内核的概念。

5.号段不同:
a.端口号为16位整数,号段为65535。
b.进程ID一般为32为整数,取值范围更大。

1.3 认识TCP协议

TCP(传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。其主要特征和功能包括:
1.面向连接:TCP通信之前需要在两端建立连接,形成连接后进行数据传输。
2.可靠传输:TCP提供可靠交付服务,通过超时重传、确认应答机制、流量控制、拥塞控制来确保数据传输的可靠性。
3.字节流服务:TCP以字节流的形式进行数据传输,无消息边界,应用层需要处理消息的划分。
4.全双工通信:TCP支持通信双方同时传送数据,实现全双工通信。
5.点对点通信:TCP是一对一的通信,每对通信节点之间有一个独立的TCP连接。
6.面向报文:TCP对传输的数据包进行编号和排序,保证按序到达。也支持乱序接收和重组。
7.拥塞控制:TCP采用滑动窗口、满开始、拥塞避免算法来进行拥塞控制。

TCP作为互联网协议栈中的核心协议,提供了可靠的网络数据传输机制。

1.4认识UDP协议:

UDP(用户数据报协议)是一种面向无连接的传输层协议。UDP的主要特征包括:
1.无连接:UDP没有TCP连接建立和终止过程,发送数据前不需要建立连接。
2.尽最大努力交付:UDP不保证可靠交付,只是尽力而为,可能会出现丢包情况。
3.面向报文:UDP以整包的方式发送数据报,无顺序要求,对端收到也是整包。
4封装简单:UDP头部只有8字节,没有复杂的控制机制。
5传输速度快:无需拥塞控制和流量控制,处理简单、快速。
6.支持一对一、一对多、多对一和多对多的交互通信。
7.应用层处理复杂流程。

UDP适合于对可靠性要求不高、需要快速传输的应用环境。如视频直播、DNS查询等。

1.5 网络字节序

我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分,磁盘文件中的多字节数据相对于文件中的便宜地址也有大端小端之分,网络数据流同样有大端小端之分,那么如何定义网络数据流的地址呢?

发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;
接收主机把从网络上接收到的字节一次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
因此,网络数据流的地址应这样规定:先发出的数据是低地址、后发出的数据是高地址。
TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节。
不管这台主机是大端机还是小端机,都会按照这个TCP/IP规定的网络字节序来发送/接收数据;
如果当前发送主机是小端,就需要先将数据转成大端,否则就忽略,直接发送即可;

为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换。

#include <arpa/inet.h>

uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);

这些函数名很好记,h表示host,n表示network,l表示32位长整数,s表示16位短整数。
例如htonl表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。
如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;
如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。

2.socket编程接口

Socket编程是网络编程中使用Socket API进行网络通信编程的过程。主要包括以下方面:

  1. 创建socket:调用socket()函数创建一个socket描述符,指定协议簇(AF_INET或AF_INET6)、类型(SOCK_STREAM或SOCK_DGRAM)、协议(IPPROTO_TCP或IPPROTO_UDP)。
  2. 绑定socket:调用bind()函数将socket绑定到一个IP地址和端口上。
  3. 监听连接:对于TCP Server端,调用listen()进行监听。
  4. 建立连接:对于TCP,客户端调用connect()建立连接。
  5. 数据传输:调用send()和recv()完成进程间的数据传输。
  6. 关闭连接:调用close()关闭socket描述符。

Socket编程提供了一个相对便捷的网络编程接口,可以直接调用操作系统的网络功能实现进程间通信。既支持TCP也支持UDP,灵活性强.需要注意错误处理。

2.1 socket常见API

1.创建一个新的socket(套接字)的系统调用函数,sockt()函数

#include <sys/socket.h>

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

它包含以下参数:

domain:指定协议族,常用的有AF_INET、AF_INET6、AF_UNIX等。

AF_INET:用于IPv4网络协议的地址族,地址类型为32位IPv4地址+16位端口号。对应的socket地址结构体是struct sockaddr_in。
AF_INET6:用于IPv6网络协议的地址族,地址类型为128位IPv6地址+16位端口号。对应的socket地址结构体是struct sockaddr_in6。
AF_UNIX:用于同一主机进程间通信的地址族,地址类型为一个绝对路径的文件名。对应的socket地址结构体是struct sockaddr_un。

type:指定socket类型,如SOCK_STREAM(TCP)、SOCK_DGRAM(UDP)等。

protocol:具体的协议,通常为0,会自动选择type对应的默认协议

调用socket()会返回一个整数表示的socket描述符,以后对这个socket的操作都基于这个描述符。

2.将socket绑定到指定IP地址和端口上,bind(()函数

#include <sys/socket.h>

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

其中的参数含义如下:
sockfd:需要绑定的socket描述符,即上文中socket函数返回值。

addr:指向要绑定的协议地址结构体的指针,通常是struct sockaddr_in(IPv4)或struct sockaddr_in6(IPv6)。

addrlen:地址结构体的长度。

bind函数如果成功绑定就返回0,失败则返回-1。

以下是一个简单的绑定IPv4的例子

struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); 
servaddr.sin_port = htons(3000);

bind(listenfd, (struct sockaddr*)&servaddr, sizeof(servaddr));

这会把服务器的socket绑定到本机所有IPv4地址上的3000端口。

3.用于监听客户端发来的连接请求,listen()

#include <sys/socket.h>

int listen(int sockfd, int backlog);

listen函数包含以下参数:
sockfd - 要进行监听的socket描述符。
backlog - 已完成三次握手队列的最大长度。

该函数的主要作用是:将socket转换为可接受连接状态,启动该socket上的连接请求队列。
监听是否有新连接请求到来。
参数backlog指定了未完成连接队列的大小。

listen函数如果成功调用会返回0, 失败则返回-1,并且将错误的结果存在errno中。

4.接收客户端发来的网络连接请求,accept函数

#include <sys/socket.h>

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

accept()函数包含以下参数:

sockfd - 监听socket描述符
addr - 指向客户端socket地址的指针
addrlen - 指向记录客户端地址结构大小的指针

accept()会提取第一个未处理的连接请求,创建一个新的连接socket,即返回一个新的socket描述符,并使用这个新的socket与客户端通信。在这个过程中,等待队列中剩余的连接请求,accept函数会阻塞。直到处理完第一个连接请求。

accept函数成功调用会返回连接描述符,失败返回-1,错误原因存于errno中。

5.客户端主动发起连接请求,connect函数

#include <sys/socket.h>

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

以下是该函数的参数定义:

sockfd - 客户端socket描述符。
addr - 指向服务端socket地址的指针。
addrlen - 服务端socket地址的长度。

connect函数如果成功调用就返回0,负责返回-1,并将错误原因存在errno中。

6.向socket发送数据,send函数

#include <sys/socket.h>

ssize_t send(int sockfd, const void *buf, size_t len, int flags);

sockfd:socket描述符
buf:指向发送缓冲区的指针
len:发送的数据擦汗高难度
flags:发送的标志,一般为0。

常用的发送标志有:

MSG_OOB:发送紧急数据
MSG_DONTROUTE:不查找路由表
MSG_NOSIGNAL:不发送SIGPIPE信号

send函数返回实际发送出的字节数。返回0表示连接断开,-1表示失败。

在socket编程中也可以使用write函数向socket中写入数据,这个在之前Linux进程间通信时用到的函数。因为write函数其实是向文件中写入,而Linux系统中一切皆文件,所以也可以使用write函数向socket写入数据。

#include <unistd.h>

ssize_t write(int sockfd, const void *buf, size_t len);

sockfd: socket描述符
buf: 指向待写入数据的缓冲区指针
len: 写入的数据长度

write函数会将buf指向的缓冲区中的len个字节数据写入socket中发送出去。与send函数不同的是,write函数一个更高层次的系统调用函数,经过了库函数的封装。

write函数只用于文件描述符,send更底层,只面向socket。
write不提供标志参数,无法发送紧急数据或无路由数据。
写入的是缓冲区数据,不考虑底层协议的数据包边界。
写入失败时write会自动重试,send需要重启调用。

write返回实际写入的字节数,可能小于len。返回-1表示失败。

7.从socket中接收数据,recv函数

#include <sys/socket.h>

ssize_t recv(int sockfd, void *buf, size_t len, int flags);

该函数的参数定义如下:

sockfd:要接收数据的 socket 描述符。
buf:指向接收缓冲区的指针。
len:希望读取的数据最大长度。
flags:接收方式,一般设为 0。

recv函数从sockfd所绑定的socket中读取数据,并存入buf指向缓冲区,最多读取len个字节。

recv函数返回实际接收到的字节数,可能小于请求的len。返回0表示对端关闭,返回-1表示失败。

既然可以使用write函数往socket中写入数据,自然也可以使用read函数从socket中读取数据。

#include <unistd.h>

ssize_t read(int sockfd, void *buf, size_t count);

read函数的主要参数含义:

sockfd: socket描述符
buf: 接收缓冲区的指针
count: 希望读取的最大字节数

read函数会从sockfd对应的socket连接中读取数据,存入buf缓冲区,最大读取count个字节。

read函数和recv函数的不同之处:

read是更高层的系统调用,可用于文件和socket。
recv只用于socket,提供更多参数控制。
read只返回实际读取的字节数。
recv会返回具体的错误代码。

8.关闭socket,close函数

#include <sys/socket.h>

int close(int sockfd);

调用close()需要传入要关闭的socket描述符sockfd。

close()函数的主要作用包括:

关闭socket描述符,该描述符不再被应用程序使用。
释放与该socket描述符相关的所有资源,如缓冲区、数据结构等。
关闭TCP连接,断开两端的网络连接。UDPsocket无连接可言。
唤醒其他因该socket阻塞而睡眠的线程。

close()函数调用成功会返回0,失败返回-1,错误代码存于errno。

2.2 sockaddr结构

socket API是一层抽象网络编程接口,适用于各种底层网络协议,如IPv4、IPv6,以及UNIX Domin Socket。然而,各种网络协议的地址格式并不相同。

IPv4和IPv6的地址格式定义再netinet/in.h中,IPv4地址用sockaddr_in结构体表示,包括16位地址类型,16位端口号和32位IP地址。
IPv4、IPv6地址类型分别定义位常数AF_INET、AF_INET6。这样,只要取得某种sockaddr结构体的首地址,不需要直到具体是那种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容。
socket API可以都用struct sockaddr*类型表示,在使用的使用需要强制转化成sockaddr_in;这样的好处是程序的通用性,可以接收IPv4、IPv6,以及UNIX Domin Socket各种类型的sockaddr结构体指针作为参数。

sockaddr结构
struct sockaddr {
sa_family_t sa_family; // address family
char sa_data[14]; // 14字节协议地址
};
该结构体主要包含两个字段
sa_family:地址族,比如AF_INET、AF_INET6等。
sa_data:socket地址值,长度为14字节。
sockaddr是一个通用结构体,不同地址家族的具体socket地址结构都包含一个sockaddr成员,从而可以转换为通用的sockaddr。

虽然socket api的接口是sockaddr,但是我们真正再基于IPv4编程时,使用的数据结构是sockaddr_in;这个结构里主要有三部分信息:地址类型、端口号、IP地址。
在socket编程中,struct sockaddr_in是代表IPv4 socket地址的数据结构。它的定义如下:

struct sockaddr_in {
  sa_family_t    sin_family; // address family, AF_INET
  in_port_t      sin_port;   // port number
  struct in_addr sin_addr;   // internet address
};

// Internet address (a structure for historical reasons)
struct in_addr {
  uint32_t       s_addr;     // address in network byte order
};

in_addr用来表示一个IPv4的IP地址,其实就是一个32位的整数。

sin_family:地址族,对于IPv4通常是AF_INET。
sin_port:端口号,使用网络字节序。
sin_addr:IPv4地址,通常可以直接赋值为一个uint32_t类型的IP地址。

2.3 简单的UDP网络程序

udp_server.hpp

#ifndef _UDP_SERVER_HPP
#define _UDP_SERVER_HPP

#include "log.hpp"
#include <iostream>
#include <cstdio>
#include <string>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

#define SIZE 1024

class UdpServer
{
public:
    UdpServer(uint16_t port, std::string ip = "") : _port(port), _ip(ip), _sock(-1)
    {
    }
    bool initServer()
    {
        //  1. 创建套接字
        _sock = socket(AF_INET, SOCK_DGRAM, 0);
        if (_sock < 0)
        {
            logMessage(FATAL, "%d:%s", errno, strerror(errno));
            exit(2);
        }
        // 2. bind: 将用户设置的ip和port在内核中和我们当前的进程强关联
        struct sockaddr_in local;
        bzero(&local, sizeof(local));
        local.sin_family = AF_INET;
        // 服务器的IP和端口未来也是要发送给对方主机的 -> 先要将数据发送到网络!
        local.sin_port = htons(_port);
        // 1. 同上,先要将点分十进制字符串风格的IP地址 ->  4字节
        // 2.  4字节主机序列 -> 网络序列
        // 有一套接口,可以一次帮我们做完这两件事情, 让服务器在工作过程中,可以从任意IP中获取数据
        local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());
        if (bind(_sock, (struct sockaddr *)&local, sizeof(local)) < 0)
        {
            logMessage(FATAL, "%d:%s", errno, strerror(errno));
            exit(2);
        }
        logMessage(NORMAL, "init udp server done ... %s", strerror(errno));
        // done
        return true;
    }
    void Start()
    {
        // echo server: client给我们发送消息,我们原封不动返回
        char buffer[SIZE];
        for (;;)
        {
            // 注意:
            //  peer,纯输出型参数
            struct sockaddr_in peer;
            bzero(&peer, sizeof(peer));
            // 输入: peer 缓冲区大小
            // 输出: 实际读到的peer
            socklen_t len = sizeof(peer);
            // start. 读取数据
            ssize_t s = recvfrom(_sock, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
            if (s > 0)
            {
                buffer[s] = 0;

                uint16_t cli_port = ntohs(peer.sin_port);      // 从网络中来的!
                std::string cli_ip = inet_ntoa(peer.sin_addr); // 4字节的网络序列的IP->本主机的字符串风格的IP,方便显示
                printf("[%s:%d]# %s\n", cli_ip.c_str(), cli_port, buffer);
            }
            // 分析和处理数据,TODO
            // end. 写回数据
            sendto(_sock, buffer, strlen(buffer), 0, (struct sockaddr *)&peer, len);
        }
    }
    ~UdpServer()
    {
        if (_sock >= 0)
            close(_sock);
    }

private:
    // 一个服务器,一般必须需要ip地址和port(16位的整数)
    uint16_t _port;
    std::string _ip;
    int _sock;
};

#endif

udp_server.cc

#include "udp_server.hpp"
#include <memory>
#include <cstdlib>

static void usage(std::string proc)
{
    std::cout << "\nUsage: " << proc << "ip port\n"
              << std::endl;
}

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        usage(argv[0]);
        exit(1);
    }
    // std::string ip = argv[1];
    uint16_t port = atoi(argv[1]);
    std::unique_ptr<UdpServer> svr(new UdpServer(port));
    svr->initServer();
    svr->Start();
    return 0;
}


udp_client.cc

#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <memory>

uint16_t serverport = 0;
std::string serverip;

static void usage(std::string proc)
{
    std::cout << "\nUsage: " << proc << " serverIp serverPort\n"
              << std::endl;
}

// ./udp_client 127.0.0.1 8080
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        usage(argv[0]);
        exit(1);
    }
    int sock = socket(AF_INET, SOCK_DGRAM, 0);
    if (sock < 0)
    {
        std::cerr << "socket error" << std::endl;
        exit(2);
    }
    serverport = atoi(argv[2]);
    serverip = argv[1];

    std::string message;
    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(atoi(argv[2]));
    server.sin_addr.s_addr = inet_addr(argv[1]);

    char buffer[1024];
    while (true)
    {
        std::cout << "请输入你的信息# ";
        std::getline(std::cin, message);
        // if (message == "quit")
        //     break;
        // 当client首次发送消息给服务器的时候,OS会自动给client bind他的IP和PORT
        sendto(sock, message.c_str(), message.size(), 0, (struct sockaddr *)&server, sizeof server);

        struct sockaddr_in temp;
        socklen_t len = sizeof(temp);
        ssize_t s = recvfrom(sock, buffer, sizeof buffer, 0, (struct sockaddr *)&temp, &len);
        if (s > 0)
        {
            buffer[s] = 0;
            std::cout << "server echo# " << buffer << std::endl;
        }
    }
    return 0;
}

Makefile

.PHONY:all
all:udp_client udp_server

udp_client:udp_client.cc
	g++ -o $@ $^ -std=c++11 
udp_server:udp_server.cc
	g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
	rm -f udp_client udp_server

log.hpp

#pragma once

#include <iostream>
#include <cstdio>
#include <cstdarg>
#include <ctime>
#include <string>

// 日志是有日志级别的
#define DEBUG 0
#define NORMAL 1
#define WARNING 2
#define ERROR 3
#define FATAL 4

const char *gLevelMap[] = {
    "DEBUG",
    "NORMAL",
    "WARNING",
    "ERROR",
    "FATAL"};

#define LOGFILE "./threadpool.log"

// 完整的日志功能,至少: 日志等级 时间 支持用户自定义(日志内容, 文件行,文件名)
void logMessage(int level, const char *format, ...)
{
#ifndef DEBUG_SHOW
    if (level == DEBUG)
        return;
#endif
    char stdBuffer[1024]; // 标准部分
    time_t timestamp = time(nullptr);
    // struct tm *localtime = localtime(&timestamp);
    snprintf(stdBuffer, sizeof stdBuffer, "[%s] [%ld] ", gLevelMap[level], timestamp);

    char logBuffer[1024]; // 自定义部分
    va_list args;
    va_start(args, format);
    vsnprintf(logBuffer, sizeof logBuffer, format, args);
    va_end(args);

    // FILE *fp = fopen(LOGFILE, "a");
    printf("%s%s\n", stdBuffer, logBuffer);
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值