APUE学习之路(网络IPC)

我们经常接触各种网络应用程序,比如ftp、svn、QQ、迅雷等,通讯双方在不同的机器上,属于跨主机的进程间通讯,网络通讯也是一种进程间通讯。
跨主机的程序在传输数据过程中要遵守严谨的协议,确保对方能够准确解析你发送的数据,防止数据传送失败,造成安全类bug,所以跨主机的通讯比同一台主机上的进程间通讯复杂。

主动端和被动端

制定协议要考虑的问题至少包括以下几点:
1)告诉对方自己的 IP 和端口;
先来看看 IP 和端口的概念。
我们的程序在进行网络通讯之前,需要先与自己的机器进行约定,告诉操作系统我需要使用哪个端口,这样操作系统的某个端口收到数据的时候就会发送给我们的进程。如果另一个程序也来通知操作系统它要使用这个端口,操作系统要保证这个端口只有我们使用,不能让别人使用,否则当它收到数据时,不知道应该发送给谁。
当我们需要发送数据的时候,也会使用这个端口进行发送,只有特殊情况才会使用别的端口或使用多个端口。
2)还要考虑的问题是通信的双方应该采用什么数据类型呢?
假如通讯双方要传送一个 int 类型的数据,那么对方机器上 int 类型的位数与我们机器上的位数是否相同呢?
也就是说 int 类型在我的机器上是 32bit,但是在对方的机器上也是 32bit 吗?假设在对方机器上是 16bit,那么我发送给它的 int 值,它能正确解析吗?
所以通信双方的数据类型要采用完全一致的约定,这个我们在下面会讨论如何让数据类型一致。
3)还要考虑字节序问题,这个说的是大小端的问题。
在这里插入图片描述大端模式存储
在这里插入图片描述
小端模式存储
在这里插入图片描述
使用union测试大小端模式

#include <stdio.h>

union mynuion{
    int a;
    char b;
};
// 大端模式,数据的低位保存在内存的高地址中
// 小端模式,数据的低位保存在内存的低地址中
int is_little_endian(void)
{
    union mynuion u;
    u.a = 1;
    return u.b;
}
int main()
{
    int temp;
    temp = is_little_endian();
    if (temp == 1){
        printf("is little endian\r\n");
    }else{
        printf("is big endian\r\n");
    }
    return 0;
}

系统为我们准备了一组函数可以帮我们实现字节序转换,我们可以像使用公式一样使用它们。

htonl,  htons,  ntohl,  ntohs - convert values between host and network byte order
#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 表示 long,s 表示 short。
它们的作用从名字中就可以看出来,把数据从主机序转换为网络序,或者把数据从网络序转换为主机序。
网路字节序一般都是大端的,而主机字节序则根据硬件平台的不同而不同(在 x86 平台和绝大多数的 ARM 平台都是小端)。所以为了简化我们编程的复杂度,这些函数的内部会根据当前机器的结构自动为我们选择是否要转换数据的字节序。我们不用管到底我们自己的主机采用的是什么字节序,只要是从主机发送数据到网络就需要调用 hton 函数,从网络接收数据到主机就需要调用 ntoh 函数。
4)最后一项约定是结构体成员不对齐,由于数据对齐也是与硬件平台相关的,所以不同的主机如果使用不同的对齐方式,就会导致数据无法解析。
如何使数据不对齐?只需要在定义结构体的时候在结尾添加 attribute((packed)) ,如下所示:

struct msg_st
{
    uint8_t name[NAMESIZE];
    uint32_t math;
    uint32_t chinese;
}__attribute__((packed));

网络传输的结构体中的成员都是紧凑的,所以不能地址对齐,需要在结构体外面增加 attribute((packed))。

结构体的地址对齐是通过 起始地址 % sizeof(type) == 0 这个公式计算的,即存放数据的起始地址位于数据类型本身长度的整倍数。
如果当前成员的起始地址能被 sizeof 整除,就可以把数据存放在这;否则就得继续看下一个地址能否被 sizeof 整除,直到找到合适的地址为止,不适合做起始地址的空间将会空置。

Socket 中主动端和被动端都要做什么?
主动端(先发包的一方)
1.取得 Socket
2.给 Socket 取得地址(可省略,不必与操作系统约定端口,由操作系统指定随机端口)
3.发/收消息
4.关闭 Socket

被动端(先收包的一方,先运行)
1.取得 Socket
2.给 Socket 取得地址
3.收/发消息
4.关闭 Socket

proto.h 里面主要是通讯双方约定的协议,包含端口号、传送数据的结构体等。

/* proto.h */
#ifndef PROTO_H__
#define PROTO_H__
#include <stdint.h>
#define RCVPORT            "1989"
#define NAMESIZE        13

struct msg_st
{
    uint8_t name[NAMESIZE];
    uint32_t math;
    uint32_t chinese;
}__attribute__((packed));

#endif

rcver.c 是被动端的代码,也是通讯双方先启动的一端。

/* rcver.c */
#include <stdio.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <sys/socket.h>
#include "proto.h"
#define IPSTRSIZE        64

int main()
{
    int sd;
    struct sockaddr_in laddr,raddr;
    socklen_t raddr_len;
    struct msg_st rbuf;
    char ipstr[IPSTRSIZE];

    sd = socket(AF_INET,SOCK_DGRAM, 0/*IPPROTO_UDP*/);
    if (sd < 0) {
        perror("socket()");
        exit(1);
    }
    laddr.sin_family = AF_INET;
    laddr.sin_port = htons(atoi(RCVPORT));
    inet_pton(AF_INET,"0.0.0.0",&laddr.sin_addr.s_addr);

    if (bind(sd,(void *)&laddr,sizeof(laddr)) < 0) {
        perror("bind()");
        exit(1);
    }
    raddr_len = sizeof(raddr);
    while(1) {
        if (recvfrom(sd,&rbuf,sizeof(rbuf),0,(void *)&raddr,&raddr_len) < 0) {
            perror("recvfrom()");
            exit(1);
        }
        inet_ntop(AF_INET,&raddr.sin_addr,ipstr,IPSTRSIZE);
        printf("---MESSAGE FROM:%s:%d---\n",ipstr,ntohs(raddr.sin_port));
        printf("Name = %s\n",rbuf.name);
        printf("Math = %d\n",ntohl(rbuf.math));
        printf("Chinese = %d\n",ntohl(rbuf.chinese));
    }
    close(sd);
    exit(0);
}

snder.c 是主动端,主动向另一端发送消息。这端可以不用向操作系统绑定端口,发送数据的时候由操作系统为我们分配可用的端口即可,当然如果想要自己绑定特定的端口也是可以的。

/* snder.c */
#include <stdio.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <string.h>
#include "proto.h"

int main(int argc,char **argv)
{
    int sd;
    struct msg_st sbuf;
    struct sockaddr_in raddr;

    if (argc < 2) {
        fprintf(stderr,"Usage...\n");
        exit(1);
    }
    sd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sd < 0) {
        perror("socket()");
        exit(1);
    }
//    bind();    // 主动端可省略绑定端口的步骤
    memset(&sbuf,'\0',sizeof(sbuf));
    strcpy(sbuf.name,"Alan");
    sbuf.math = htonl(rand()%100);
    sbuf.chinese = htonl(rand()%100);

    raddr.sin_family = AF_INET;
    raddr.sin_port = htons(atoi(RCVPORT));
    inet_pton(AF_INET, argv[1], &raddr.sin_addr);

    if (sendto(sd, &sbuf, sizeof(sbuf), 0, (void *)&raddr,sizeof(raddr)) < 0) {
        perror("sendto()");
        exit(1);
    }
    puts("ok!");
    close(sd);
    exit(0);
}

由这三个文件组成的程序就可以进行网络通讯了,注意,无论是发送端还是接收端,执行的步骤都是固定的,将来大家在开发更复杂的网络应用时也是基于这几个步骤进行扩展。
根据上面代码中协议(proto.h)的定义,我们知道其中 msg_st 结构体中 name 成员的长度是固定的,不好用,把它修改为变长结构体。
只需把变长的部分放到结构体的最后面,然后通过 malloc(3) 动态内存管理来为它分配内存,如下所示:

struct msg_st
{
     uint32_t math;
     uint32_t chinese;
     uint8_t name[1];
}__attribute__((packed));

UDP 包常规的最大尺寸是 512 字节,去掉包头的 8 个字节,再去掉结构体中除了最后一个成员以外其它成员大小的总和,剩下的就是我们最后一个成员最大能分配的大小。

大家还记得如何操作一个文件吗?

1.首先通过 open(2) 函数打开文件,并获得文件描述符;
2.通过 read(2)、write(2) 函数读写文件;
3.调用 close(2) 函数关闭文件,释放相关资源。

没错,在 Linux 的一切皆文件的设计理念中,网络也是文件,网络之间的通讯也可以像操作文件一样,对它进行读写,通常步骤如下:

1.首先通过 socket(2) 函数获得 socket 文件描述符;
2.通过 send(2)、sendto(2)、recv(2)、recvfrom(2) 等函数读写数据,这一步就相当于在网络上收发数据。
3.调用 close(2) 函数关闭网络,释放相关资源。你没看错,这个函数就是我们关闭文件描述符的时候使用的函数。

下面我们依次介绍上面遇到的各种函数。
socket(2)

socket - create an endpoint for communication
#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>
int socket(int domain, int type, int protocol);

socket(2) 函数是用来获取对网络操作的文件描述符的,就像 open(2) 函数一样。
参数列表:

domain:协议族;
type:链接方式;
protocol:具体使用哪个协议。在 domain 的协议族中每一个对应的 type 都有一个或多个协议,使用协议族中默认的协议可以填写 0。
返回值:如果成功,返回的是一个代表当前网络链接的文件描述符,保存好它,因为后续的网络操作都需要它。如果失败,返回 -1,并设置 errno。

下面就是 Linux 支持的协议族,也是 domain 参数可选择的宏,都定义在 sys/socket.h 头文件中,想使用下面的宏需要包含这个头文件。

AF_UNIX、AF_LOCAL:本地协议;通过 man 7 unix 可以得到有关这个协议族更详细的描述。
AF_INET:IPV4 协议;这是我们最常见的协议族,通过 man 7 ip 可以得到有关这个协议族更详细的描述。
AF_INET6:IPV6 协议;通过 man 7 ipv6 可以得到有关这个协议族更详细的描述。
AF_IPX:Novell 当年是网络的代名词,是非常古老的操作系统,出现在 TCP/IP 之前;
AF_NETLINK:是用户态与内核态通信的协议;
AF_X25:这是很早的协议,感兴趣的话可以自己去 Google 一下;
AF_AX25:应用于业余无线电,也称为短波通信,都是一些无线电爱好者使用的协议。据说汶川地震时灾区所有通讯都瘫痪了,第一个求救信号就是短波发送出来的,因为这些无线电爱好者家里一般都有大大小小的发电机。
AF_ATMPVC:当年如日中天,后来死于封闭。协议设计得非常好,后来几家公司都为了拿大头就僵持起来,谁都没有推广它,就在这时候以太网发展起来了,就把它打败了。以太网发展起来就是因为很简陋,所以更容易推广。
AF_APPLETALK:苹果使用的一个局域网协议;
AF_PACKET:底层 socket 所用到的协议,比如抓包器所遵循的协议一定要在网卡驱动层,而不能在应用层,否则无法见到包封装的过程。再比如 ping(1) 命令大家都熟悉吧,想要实现 ping(1) 命令就需要了解这个协议族,感兴趣的话大家可以自行 Google 一下。

如果想要对网络编程进行更深入的学习,那么《APUE》作者写的《UNIX 网络编程》有必要读一遍;《TCP/IP详解》三卷也要读一下。
下面我们看一下 type 参数有哪些可选项:

SOCK_STREAM:流式套接字,特点是有序、可靠。有序、双工、基于链接的、以字节流为单位的。
可靠不是指不丢包,而是流式套接字保证只要你能接收到这个包,那么包中的数据的完整性一定是正确的。
双工是指双方都能收发。
基于链接的是指:比如大街上张三、李四进行对话,一定不会说每句话之前都叫着对方的名字。也就是说通信双方是知道对方是谁的。
字节流是指数据没有明显的界限,一端数据可以分为任意多个包发送。

SOCK_DGRAM:报式套接字,无链接的,固定的最大长度,不可靠的消息。
就像写信,无法保证你发出的信对方一定能收到,而且无法保证内容不会被篡改。如果今天发了一封信,明天又发了一封信,不能保证哪封信先到。
大家都能收到这个包,但是发现不是自己的之后就会丢弃,发现是自己的包再处理,有严格的数据分界线。更详细的解释可以参阅 man 手册。

SOCK_SEQPACKET:提供有序、可靠、双向基于连接的数据报通信。
SOCK_RAW:原始的套接字,提供的是网络协议层的访问。
SOCK_RDM:数据层的访问,不保证传输顺序。
SOCK_PACKET:不好用,具体的 bug 要查 man 7 packet。

bind(2)

bind - bind a name to a socket
#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr,
         socklen_t addrlen);

bind(2) 函数用于绑定本机端口,就是提前跟操作系统约定好,来自 xx 端口的数据都要转交给我(当前进程)处理,并且我占用了这个端口号别人(其它进程)就不能再使用了。
参数列表:
  sockfd:刚刚使用 socket(2) 函数得到的文件描述符,表示要对该网络链接绑定端口。
  addr:要绑定到套接字上的地址。根据不同的协议要在 man 手册第 7 章查阅具体的章节,然后在 Address Types 一栏里面找到对应的结构体。比如你在调用 socket(2) 函数的时候,domain 参数选择的是 AF_INET,那么这个结构体就可以在 man 手册 ip(7) 章节中找到。
  addrlen:addr 传递的地址结构体的长度。

以 AF_INET 为例,下面这两个结构体就是在 ip(7) 中找到的。

struct sockaddr_in {
sa_family_t sin_family; /* 指定协议族,一定是 AF_INET,因为既然是 man ip(7),那么一定是 AF_INET 协议族的 */
in_port_t sin_port; /* 端口,需要使用 htons(3) 转换为网络序 */
struct in_addr sin_addr; /* internet address */
};

/* Internet address. */
struct in_addr {
uint32_t s_addr; /* 无符号32位大整数,可以使用 inet_pton(3) 将便于记忆的点分式 IP 地址表示法转换为便于计算机使用的大整数,inet_ntop(3) 的作用则正好相反。本机地址转换的时候可以使用万能IP:0.0.0.0(称为any address),函数会自动将 0.0.0.0 解析为真实的本机 IP 地址。 */
};

大家可以看到,这个结构体的类型是 struct sockaddr_in,而 bind(2) 函数的第二个参数类型是 struct sockaddr,它们二者有什么关系呢?别瞎想,不是继承关系啦,C 语言中没有继承这种东东。在传参的时候直接把实参强转为 void* 类型即可,就像上面栗子中 rcver.c 写得那样。

recv(2) 和 recvfrom(2) 函数

recv, recvfrom - receive a message from a socket
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);

这两个函数的作用是从网络上接收内容并写入 len 个字节长度的数据到 buf 中,且将发送端的地址信息填写到 src_addr 中。
返回值是真正能接收到的字节数,返回 -1 表示失败。
recv(2) 函数一般用在流式(SOCK_STREAM)套接字中,而 recvfrom(2) 则一般用在报式(SOCK_DGRAM)套接字中。
为什么这么说呢,还记得上面我们提到过吗,流式套接字是基于链接的,而报式套接字是无链接的。那么我们再来观察下这两个函数的参数列表,很明显 recv(2) 函数并没有地址相关的参数,而 recvfrom(2) 函数则会将对方的地址端口等信息回填给调用者。

网络中的数据只有单字节数据不用考虑字节序,从网络上接收过来的数据只要涉及到字节序就需要使用 ntoh 系列函数进行字节序转换。

小提示:通过 netstat(1) 命令 ant 参数可以查看 TCP 链接情况,或通过 netstat(1) 命令 anu 参数可以查看 UDP 链接情况。
t 参数表示 TCP;
u 参数表示 UDP;

send(2) 和 sendto(2) 函数

send, sendto, sendmsg - send a message on a socket
#include <sys/types.h>
#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);

这两个函数与 recv(2) 和 recvfrom(2) 函数正好是对应的,它们的作用是向网络上发送数据。

参数列表:

sockfd:通过哪个 Socket 往外发数据,这个参数的值就是在调用 socket(2) 函数的时候取得的;
buf:要发送的数据;
len:要发送的数据的长度;
flags:特殊要求,没有填 0;
src_addr:目标地址;类似于 bind(2) 函数,具体使用哪个结构体和你调用 socket(2) 函数时,使用的具体协议族有关系,然后到对应的 man 手册第 7 章去查找。
addrlen:目标地址的长度;
返回值是真正发送出去的数据的长度;出现错误返回 -1 并设置 errno。

最后剩下 close(2) 函数就不介绍了。

上面讨论的是单点通讯,多点通讯只能用报式套接字来实现。
一般多点通讯分为:广播 和 多播(组播)两种方式。
广播又分为 全网广播(255.255.255.255) 和 子网广播 两种形式。
多播:都是 D 类地址,以 224. 开头。224.0.0.1 是一个组播中的特殊地址,发到这个地址的消息会强制所有组播地址中的主机接收,类似于全网广播。
注意:广播和组播仅在局域网内有效。

getsockopt(2) 和 setsockopt(2) 函数

getsockopt, setsockopt - get and set options on sockets
#include <sys/types.h>
#include <sys/socket.h>
int getsockopt(int sockfd, int level, int optname,
               void *optval, socklen_t *optlen);
int setsockopt(int sockfd, int level, int optname,
               const void *optval, socklen_t optlen);

这两个函数用于读取和设置套接字的特殊要求。
对 sockfd 这个套接字的 level 层的 optname 选项进行设置,值放在 optval 里,大小是 optlen。
参数 sockfd、level 和 optname 的对应关系就是:一个 sock 有多个 level,每个 level 有多个选项。
所有的选项需要在不同协议的 man 手册(第7章) Socket options 一栏查找。
常用 optname 参数:
SO_BROADCAST:设置或获取广播标识,当这个标识被打开时才允许接收和发送报式套接字广播,所以大家使用广播的时候不要忘记设置这个 opt,但在流式套接字中无效。
IP_MULTICAST_IF:创建多播组,optval 参数应该使用 ip_mreqn 还是 ip_mreq 结构体,取决于 IP_ADD_MEMBERSHIP 选项。

struct ip_mreqn {
struct in_addr imr_multiaddr; /* 多播组 IP 地址,大整数,可以用 inet_pton(3) 将点分式转换为大整数 */
struct in_addr imr_address; /* 本机 IP 地址,可以用 0.0.0.0 代替,大整数,可以用 inet_pton(3) 将点分式转换为大整数 */
int imr_ifindex; /* 当前使用的网络设备的索引号,ip ad sh 命令可以查看编号,用 if_nametoindex(3) 函数也可以通过网络设备名字获取编号,名字就是 ifconfig(1) 看到的名字,如 eth0、wlan0 等 */
};

IP_ADD_MEMBERSHIP:加入多播组

丢包和校验

下面来谈谈丢包和校验的问题。
UDP 会丢包,为什么会丢包呢?因为不同的请求会选择不同的路径经过不同的路由器,这些包到达路由器的时候会进入路由器的等待队列,当路由比较繁忙的时候队列就会满,当队列满了的时候各个路由会根据不同的算法丢弃多余的包(一般是丢弃新来的包或随机丢弃包),所以丢包的根本原因是拥塞。

ping 命令的 TTL 是一个数据包能够经过的路由器数量的上限,这个上限在 Linux 环境里默认是 64,在 Windows 里默认是 128。假设从中国某个点发送一个包到美国的某个点,从发出开始到中国的总路由器需要大约十几跳,从中国总路由到美国总路由大约两三跳就到了,再从美国总路由到达目标点也经过大约十几跳,因此无论 TTL 是 64 还是 128 都足以从全球任何一个点发送数据到另一个点了,所以丢包绝不是因为 TTL 值太小导致的。
解决丢包的方法是使用流量控制,之前我们写过令牌桶还记得吧?流控分为开环式和闭环式。
这里介绍一种停等式流控:它是一种闭环式流控。实现方式很简单,一问一答即可。就是发送方每次发送一个数据包之后要等待接收方的响应,确认接收方收到了自己的数据包后再发送下一个数据包。这种方式的特点是每次等待的时间是不确定的,因为每次发包走的路径是不同的,所以包到达目的地的时间也是不同的,而且还要受网络等环境因素影响。

停等式流控的缺点也很明显:
1.浪费时间,多数时间都花费在等待响应上面了。
2.双方发送包的数量增加了,这也意味着丢包率升高了。
3.为了降低错误率,实现的复杂度会变高。如果 s 端 data 包发过去了,但是 c 端响应的 ack 包丢了,s 端过了一会儿没收到 ack 认为 data 丢了再次发送 data,当 c 端再次收到一模一样的 data 包时不知道到底是有两段数据一模一样还是 s 端把包发重复了,所以需要给data包加编号,这样 c 端就知道当前这个 data 包是合法的数据还是多余的数据了。
停等式流控虽然上升了丢包率,但是能保证对方一定能收到数据包。
web 传输通常采用两种校验方案:
1.不做硬性校验:交给用户来做。比如你在浏览网页,网页周边的广告都加载出来了,但是正文没有加载出来,你肯定会刷新页面吧?但是如果正文加载出来了,周边的广告没有加载出来,你会刷新网页一定要让整个网页全部都加载完整再看内容码?
2.延迟应答:下次通讯的时候把上次的 ack 带过来,表示上次的通讯是完整的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值