linux网络编程基础API

148 阅读15分钟

Socket地址API

大小端字节序问题

现代CPU的累加器一次都能装载至少4字节(这里考虑32位机,下同),即一个整数。那么这4字节在内存中排列的顺序将影响它被累加器装载成的整数的值。这就是字节序问题。字节序分为大端字节序(big endian)和小端字节序(little endian)。大端字节序是指一个整数的高位字节(23~31 bit)存储在内存的低地址处,低位字节(0~7 bit)存储在内存的高地址处。小端字节序则是指整数的高位字节存储在内存的高地址处,而低位字节则存储在内存的低地址处。

现代PC大多采用小端字节序,因此小端字节序又被称为主机字节序。

当格式化的数据(比如32 bit整型数和16 bit短整型数)在两台使用不同字节序的主机之间直接传递时,接收端必然错误地解释之。解决问题的方法是:发送端总是把要发送的数据转化成大端字节序数据后再发送,而接收端知道对方传送过来的数据总是采用大端字节序,所以接收端可以根据自身采用的字节序决定是否对接收到的数据进行转换(小端机转换,大端机不转换)。因此大端字节序也称为网络字节序,它给所有接收数据的主机提供了一个正确解释收到的格式化数据的保证。

即使是同一台机器上的两个进程(比如一个由C语言编写,另一个由JAVA编写)通信,也要考虑字节序的问题(JAVA虚拟机采用大端字节序)。

Linux提供了如下4个函数来完成主机字节序和网络字节序之间的转换:

#include<netinet/in.h>
unsigned long int htonl(unsigned long int hostlong);//主机字节->网络字节 长整型转换
unsigned short int htons(unsigned short int hostshort);//主机字节->网络字节 短整型转换
unsigned long int ntohl(unsigned long int netlong); //网络字节->主机字节
unsigned short int ntohs(unsigned short int netshort);

通用socket地址

socket网络编程接口中表示socket地址的是结构体sockaddr

#include<bits/socket.h>
struct sockaddr
{
    sa_family_t sa_family;
    char sa_data[14];
}

sa_family成员是地址族类型(sa_family_t)的变量。地址族类型通常与协议族类型对应。

image.png

sa_data成员用于存放socket地址值。但是,不同的协议族的地址值具有不同的含义和长度。

image.png

14字节的sa_data根本无法完全容纳多数协议族的地址值。因此,Linux定义了新的通用socket地址结构体:

#include<bits/socket.h>
struct sockaddr_storage
{
    sa_family_t sa_family;
    unsigned long int__ss_align;
    char__ss_padding[128-sizeof(__ss_align)];
}

通用socket地址结构体显然很不好用,比如设置与获取IP地址和端口号就需要执行烦琐的位操作。所以Linux为各个协议族提供了专门的socket地址结构体。

专用socket地址

UNIX本地域协议族使用如下专用socket地址结构体

#include<sys/un.h>
struct sockaddr_un
{
    sa_family_t sin_family;/*地址族:AF_UNIX*/
    char sun_path[108];/*文件路径名*/
};

TCP/IP协议族有sockaddr_in和sockaddr_in6两个专用socket地址结构体,它们分别用于IPv4和IPv6

struct sockaddr_in
{
    sa_family_t sin_family;/*地址族:AF_INET*/
    u_int16_t sin_port;/*端口号,要用网络字节序表示*/
    struct in_addr sin_addr;/*IPv4地址结构体,见下面*/
};
struct in_addr
{
    u_int32_t s_addr;/*IPv4地址,要用网络字节序表示*/
};
struct sockaddr_in6
{
    sa_family_t sin6_family;/*地址族:AF_INET6*/
    u_int16_t sin6_port;/*端口号,要用网络字节序表示*/
    u_int32_t sin6_flowinfo;/*流信息,应设置为0*/
    struct in6_addr sin6_addr;/*IPv6地址结构体,见下面*/
    u_int32_t sin6_scope_id;/*scope ID,尚处于实验阶段*/
};
struct in6_addr
{
    unsigned char sa_addr[16];/*IPv6地址,要用网络字节序表示*/
};

所有专用socket地址(以及sockaddr_storage)类型的变量在实际使用时都需要转化为通用socket地址类型sockaddr(强制转换即可),因为所有socket编程接口使用的地址参数的类型都是sockaddr。

IP地址转换函数

下面3个函数可用于用点分十进制字符串表示的IPv4地址和用网络字节序整数表示的IPv4地址之间的转换

#include<arpa/inet.h>
in_addr_t inet_addr(const char*strptr);
int inet_aton(const char*cp,struct in_addr*inp);
char*inet_ntoa(struct in_addr in);
  • inet_addr函数将用点分十进制字符串表示的IPv4地址转化为用网络字节序整数表示的IPv4地址。它失败时返回INADDR_NONE。
  • inet_aton函数完成和inet_addr同样的功能,但是将转化结果存储于参数inp指向的地址结构中。它成功时返回1,失败则返回0。
  • inet_ntoa函数将用网络字节序整数表示的IPv4地址转化为用点分十进制字符串表示的IPv4地址。但需要注意的是,该函数内部用一个静态变量存储转化结果,函数的返回值指向该静态内存,因此inet_ntoa是不可重入的。

下面这对更新的函数也能完成和前面3个函数同样的功能,并且它们同时适用于IPv4地址和IPv6地址

#include<arpa/inet.h>
int inet_pton(int af,const char*src,void*dst);
const char*inet_ntop(int af,const void*src,char*dst,socklen_t cnt);

Socket编程

最基本的socket通信流程大概如下

创建socket-socket()

UNIX/Linux的一个哲学是:所有东西都是文件。socket也不例外,它就是可读、可写、可控制、可关闭的文件描述符。下面的socket系统调用可创建一个socket

#include<sys/types.h>
#include<sys/socket.h>
int socket(int domain,int type,int protocol);
  • domain参数告诉系统使用哪个底层协议族。对TCP/IP协议族而言,该参数应该设置为PF_INET(Protocol Family of Internet,用于IPv4)或PF_INET6(用于IPv6);对于UNIX本地域协议族而言,该参数应该设置为PF_UNIX。
  • type参数指定服务类型。服务类型主要有SOCK_STREAM服务(流服务)和SOCK_UGRAM(数据报)服务。对TCP/IP协议族而言,其值取SOCK_STREAM表示传输层使用TCP协议,取SOCK_DGRAM表示传输层使用UDP协议。
  • protocol参数是在前两个参数构成的协议集合下,再选择一个具体的协议。不过这个值通常都是唯一的(前两个参数已经完全决定了它的值)。几乎在所有情况下,我们都应该把它设置为0,表示使用默认协议。

socket系统调用成功时返回一个socket文件描述符,失败则返回-1并设置errno。

绑定socket-bind()

创建socket时,我们给它指定了地址族,但是并未指定使用该地址族中的哪个具体socket地址。将一个socket与socket地址绑定称为给socket命名**。在服务器程序中,我们通常要命名socket,因为只有命名后客户端才能知道该如何连接它。客户端则通常不需要命名socket,而是采用匿名方式,即使用操作系统自动分配的socket地址**。命名socket的系统调用是bind。

#include<sys/types.h>
#include<sys/socket.h>
int bind(int sockfd,const struct sockaddr*my_addr,socklen_t addrlen);

bind将my_addr所指的socket地址分配给未命名的sockfd文件描述符,addrlen参数指出该socket地址的长度。

bind成功时返回0,失败则返回-1并设置errno。其中两种常见的errno是EACCES和EADDRINUSE,它们的含义分别是:

  • EACCES,被绑定的地址是受保护的地址,仅超级用户能够访问。比如普通用户将socket绑定到知名服务端口(端口号为0~1023)上时,bind将返回EACCES错误。
  • EADDRINUSE,被绑定的地址正在使用中。比如将socket绑定到一个处于TIME_WAIT状态的socket地址。

监听socket-listen()

socket被命名之后,还不能马上接受客户连接,我们需要使用如下系统调用来创建一个监听队列以存放待处理的客户连接:

#include<sys/socket.h>
int listen(int sockfd,int backlog);

sockfd参数指定被监听的socket。backlog参数提示内核监听队列的最大长度。监听队列的长度如果超过backlog,服务器将不受理新的客户连接,客户端也将收到ECONNREFUSED错误信息。

listen成功时返回0,失败则返回-1并设置errno。

接受连接-accept()

#include<sys/types.h>
#include<sys/socket.h>
int accept(int sockfd,struct sockaddr*addr,socklen_t*addrlen);
  • sockfd参数是执行过listen系统调用的监听socket
  • addr参数用来获取被接受连接的远端socket地址,该socket地址的长度由addrlen参数指出。accept成功时返回一个新的连接socket,该socket唯一地标识了被接受的这个连接,服务器可通过读写该socket来与被接受连接对应的客户端通信。accept失败时返回-1并设置errno。

accept只是从监听队列中取出连接,而不论连接处于何种状态

客户端发起连接-connect()

服务器通过listen调用来被动接受连接,那么客户端需要通过如下系统调用来主动与服务器建立连接:

#include<sys/types.h>
#include<sys/socket.h>
int connect(int sockfd,const struct sockaddr*serv_addr,socklen_t addrlen);

sockfd参数由socket系统调用返回一个socket。serv_addr参数是服务器监听的socket地址,addrlen参数则指定这个地址的长度。

connect成功时返回0。一旦成功建立连接,sockfd就唯一地标识了这个连接,客户端就可以通过读写sockfd来与服务器通信。connect失败则返回-1并设置errno。其中两种常见的errno是ECONNREFUSED和ETIMEDOUT,它们的含义如下:

  • ECONNREFUSED,目标端口不存在,连接被拒绝。
  • ETIMEDOUT,连接超时

所以发起连接时首先要知道目标服务器的ip与端口

关闭连接-close()

关闭一个连接实际上就是关闭该连接对应的socket,这可以通过如下关闭普通文件描述符的系统调用来完成:

#include<unistd.h>
int close(int fd);

fd参数是待关闭的socket。不过,close系统调用并非总是立即关闭一个连接,而是将fd的引用计数减1。只有当fd的引用计数为0时,才真正关闭连接。多进程程序中,一次fork系统调用默认将使父进程中打开的socket的引用计数加1,因此我们必须在父进程和子进程中都对该socket执行close调用才能将连接关闭。

如果无论如何都要立即终止连接(而不是将socket的引用计数减1),可以使用如下的shutdown系统调用(相对于close来说,它是专门为网络编程设计的):

#include<sys/socket.h>
int shutdown(int sockfd,int howto);

howto参数决定了shutdown的行为

image.png

shutdown能够分别关闭socket上的读或写,或者都关闭。而close在关闭连接时只能将socket上的读和写同时关闭。 shutdown成功时返回0,失败则返回-1并设置errno。

读写数据

tcp

对文件的读写操作read和write同样适用于socket。但是socket编程接口提供了几个专门用于socket数据读写的系统调用,它们增加了对数据读写的控制。其中用于TCP流数据读写的系统调用是:

#include<sys/types.h>
#include<sys/socket.h>
ssize_t recv(int sockfd,void*buf,size_t len,int flags);
ssize_t send(int sockfd,const void*buf,size_t len,int flags);

recv读取sockfd上的数据,buf和len参数分别指定读缓冲区的位置和大小,flags参数通常设置为0即可。recv成功时返回实际读取到的数据的长度,它可能小于我们期望的长度len。因此我们可能要多次调用recv,才能读取到完整的数据。recv可能返回0,这意味着通信对方已经关闭连接了。

send往sockfd上写入数据,buf和len参数分别指定写缓冲区的位置和大小。send成功时返回实际写入的数据的长度,失败则返回-1并设置errno。

udp

socket编程接口中用于UDP数据报读写的系统调用是:

#include<sys/types.h>
#include<sys/socket.h>
ssize_t recvfrom(int sockfd,void*buf,size_t len,int flags,struct sockaddr*src_addr,socklen_t*addrlen);
ssize_t sendto(int sockfd,const void*buf,size_t len,int flags,const struct sockaddr*dest_addr,socklen_t addrlen);

recvfrom读取sockfd上的数据,buf和len参数分别指定读缓冲区的位置和大小。因为UDP通信没有连接的概念,所以我们每次读取数据都需要获取发送端的socket地址,即参数src_addr所指的内容,addrlen参数则指定该地址的长度。

sendto往sockfd上写入数据,buf和len参数分别指定写缓冲区的位置和大小。dest_addr参数指定接收端的socket地址,addrlen参数则指定该地址的长度。

这两个系统调用的flags参数以及返回值的含义均与send/recv系统调用的flags参数及返回值相同。

值得一提的是,recvfrom/sendto系统调用也可以用于面向连接(STREAM)的socket的数据读写,只需要把最后两个参数都设置为NULL以忽略发送端/接收端的socket地址(因为我们已经和对方建立了连接,所以已经知道其socket地址了)。

地址信息函数

在某些情况下,我们想知道一个连接socket的本端socket地址,以及远端的socket地址。

#include<sys/socket.h>
int getsockname(int sockfd,struct sockaddr*address,socklen_t*address_len);
int getpeername(int sockfd,struct sockaddr*address,socklen_t*address_len);

getsockname获取sockfd对应的本端socket地址,并将其存储于address参数指定的内存中,该socket地址的长度则存储于address_len参数指向的变量中。如果实际socket地址的长度大于address所指内存区的大小,那么该socket地址将被截断。getsockname成功时返回0,失败返回-1并设置errno。

getpeername获取sockfd对应的远端socket地址,其参数及返回值的含义与getsockname的参数及返回值相同。

网络信息API

socket地址的两个要素,即IP地址和端口号,都是用数值表示的。这不便于记忆,也不便于扩展(比如从IPv4转移到IPv6)。因此我们用主机名来访问一台机器,而避免直接使用其IP地址。同样,我们用服务名称来代替端口号。比如,下面两条telnet命令具有完全相同的作用:

telnet 127.0.0.1 80 telnet localhost www

上面的例子中,telnet客户端程序是通过调用某些网络信息API来实现主机名到IP地址的转换,以及服务名称到端口号的转换的。

域名与ip转换

gethostbyname函数根据主机名称获取主机的完整信息,gethostbyaddr函数根据IP地址获取主机的完整信息。gethostbyname函数通常先在本地的/etc/hosts配置文件中查找主机,如果没有找到,再去访问DNS服务器。

#include<netdb.h>
struct hostent*gethostbyname(const char*name);
struct hostent*gethostbyaddr(const void*addr,size_t len,int type);

name参数指定目标主机的主机名,addr参数指定目标主机的IP地址,len参数指定addr所指IP地址的长度,type参数指定addr所指IP地址的类型,其合法取值包括AF_INET(用于IPv4地址)和AF_INET6(用于IPv6地址)。

这两个函数返回的都是hostent结构体类型的指针,hostent结构体的定义如下:

#include<netdb.h>
struct hostent
{
char*h_name;/*主机名*/
char**h_aliases;/*主机别名列表,可能有多个*/
int h_addrtype;/*地址类型(地址族)*/
int h_length;/*地址长度*/
char**h_addr_list/*按网络字节序列出的主机IP地址列表*/
};

服务名与端口转换

getservbyname函数根据名称获取某个服务的完整信息,getservbyport函数根据端口号获取某个服务的完整信息。它们实际上都是通过读取/etc/services文件来获取服务的信息的。

#include<netdb.h>
struct servent*getservbyname(const char*name,const char*proto);
struct servent*getservbyport(int port,const char*proto);

name参数指定目标服务的名字,port参数指定目标服务对应的端口号。proto参数指定服务类型,给它传递“tcp”表示获取流服务,给它传递“udp”表示获取数据报服务,给它传递NULL则表示获取所有类型的服务。

这两个函数返回的都是servent结构体类型的指针,结构体servent的定义如下:

#include<netdb.h>
struct servent
{
char*s_name;/*服务名称*/
char**s_aliases;/*服务的别名列表,可能有多个*/
int s_port;/*端口号*/
char*s_proto;/*服务类型,通常是tcp或者udp*/
};

实例

simple_server

//
// Created by lsl on 2021/12/7.
//
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<csignal>
#include<unistd.h>
#include<stdlib.h>
#include<assert.h>
#include "string.h"
#include "iostream"

using std::cout;

static bool stop = false;

static void handle_term(int sig) {
    stop = true;
}

const int BUF_SIZE = 1024;

int main(int argc, char *argv[]) {
    std::cout << "lsl";
    signal(SIGTERM, handle_term);

    const char *ip = "192.168.187.128";//ip
    int port = atoi("1234");//端口
    int backlog = 5;//监听队列长度
    //创建socket,ipv4,字符流(tcp)
    int sock = socket(PF_INET, SOCK_STREAM, 0);
    assert(sock > 0);
    /*创建一个IPv4 socket地址,主要设置ip和端口信息*/
    struct sockaddr_in address;
    bzero(&address, sizeof(address));//清空地址
    address.sin_family = AF_INET;//ipv4
    inet_pton(AF_INET, ip, &address.sin_addr);//ip转换
    address.sin_port = htons(port);//端口
    //命名Socket / 绑定Socket
    int ret = bind(sock, (struct sockaddr *) &address, sizeof(address));
    assert(ret != -1);
    //监听Socket
    ret = listen(sock, backlog);
    assert(ret != -1);
    //客户端tcp连接描述,等待符合格式的连接
    struct sockaddr_in client;
    socklen_t client_addrlength = sizeof(client);
    //接受客户端连接
    while (true) {
        int connfd = accept(sock, (struct sockaddr *) &client, &client_addrlength);
        if (connfd < 0) {
            std::cout << "客户端连接错误,错误码:" << connfd;
        } else {
            //接收客户端信息
            char remote[INET_ADDRSTRLEN];
            std::cout << "客户端连接成功:" << inet_ntop(AF_INET, &client.sin_addr, remote, INET_ADDRSTRLEN) << ",端口:"
                      << ntohs(client.sin_port) << std::endl;
//            struct sockaddr clientInfo;
//            int clientInfoFd = getpeername(connfd, &clientInfo, &client_addrlength);
            char buffer[BUF_SIZE];
            memset(buffer, '\0', BUF_SIZE);
            int res = recv(connfd, buffer, BUF_SIZE - 1, 0);
            std::cout << "接受信息:" << res << "," << buffer << std::endl;
            close(connfd);
            if (res == -1)break;
        }
    }

    //关闭Socket
    close(sock);

    return 0;

}

simple_client

//
// Created by lsl on 2021/12/13.
//

#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<csignal>
#include<unistd.h>
#include<netdb.h>
#include<stdlib.h>
#include<assert.h>
#include "string.h"
#include "iostream"

int main() {
    const char *ip = "192.168.187.128";
    int port = 1234;

    //服务器tcp连接描述
    struct sockaddr_in server_address;
    bzero(&server_address, sizeof(server_address));
    server_address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &server_address.sin_addr);
    server_address.sin_port = htons(port);
    //建立socket
    int sockfd = socket(PF_INET, SOCK_STREAM, 0);
    assert(sockfd > 0);
    //连接服务器
    if (connect(sockfd, (struct sockaddr *) &server_address, sizeof(server_address)) < 0) {
        std::cout << "连接服务器失败" << std::endl;
    } else {
        std::cout << "连接服务器成功" << std::endl;
        //发送数据
        send(sockfd, "helloworld!", 12, 0);
        std::cout << "发送数据成功" << std::endl;
    }
    //关闭socket
    close(sockfd);

    //获取目标服务器上的时间服务
    /*获取目标主机地址信息*/
    struct hostent *hostInfo = gethostbyname("localhost");
    assert(hostInfo);
    /*获取daytime服务信息*/
    struct servent *servInfo = getservbyname("daytime", "tcp");
    assert(servInfo);
    std::cout << "daytime服务端口:" << servInfo->s_port << std::endl;
    //切换服务ip与端口,注意地址列表是一个二维指针,取第一个地址

    server_address.sin_addr = (*(struct in_addr *) *hostInfo->h_addr_list);
    std::cout << inet_ntoa(server_address.sin_addr) << std::endl;
    server_address.sin_port = servInfo->s_port;
    //建立socket
    sockfd = socket(PF_INET, SOCK_STREAM, 0);
    assert(sockfd > 0);
    //连接daytime服务,需要在ubuntu中启动
    if (connect(sockfd, (struct sockaddr *) &server_address, sizeof(server_address)) < 0) {
        std::cout << "连接服务失败" << std::endl;
    } else {
        std::cout << "连接服务成功" << std::endl;
        //接受数据
        char buffer[128];
        int result = read(sockfd, buffer, sizeof(buffer));
        assert(result > 0);
        buffer[result] = '\0';
        std::cout << "接收数据:" << buffer << std::endl;
    }
    //关闭socket
    close(sockfd);

    return 0;
}