现在的很多库基本是基于异步网络IO, 以及 epoll等模型的封装, 但是 epoll模型的基础也还是基本的 socket api .
最开始学习网络编程的时候, 这个地方肯定是逃不过去的.
相关代码可以参考: https://github.com/WizardMerlin/network_life
引子
网络编程中第一个部分是深入学习TCP/IP模型, 其次就是借助socket api进行实践.
大概涉及如下api:
- 创建 socket bind listen accept
- 收发 read/recv/recvfrom write/send/sendto
- 关闭 close shutdown
- 参数 getsockopt/setsockopt
- 地址 gethostbyaddr getaddrbyhost
socket是posix系统或者unix系统中一种特殊的文件, 也可以按照文件读写的方式进行读写 (打开, read/write, 关闭)
一个完整的连接需要一对套接字
基本的网络IO模型说起来也就3句话:
- 客户端和服务端建立连接
- 开始进行网络IO传输数据
- 完毕后断开连接
正文
连接函数
服务端: socket, bind, listen, accept;
客户端: socket, connect
socket
1 | int socket(int domain, int type, int protocol); |
你就把这个函数理解成打开文件(它返回一个可操作的文件fd或者称为socket descriptor). 正如可以给fopen的传入不同参数值, 以打开不同的文件. 创建socket的时候,也可以指定不同的参数创建不同的socket描述符.
参数解释:
domain
: 是指协议族, 具体可以查看/usr/include/bits/socket.h的定义, 常见的有:
- AF_INET
- AF_INET6
- AF_LOCAL(或称AF_UNIX,Unix域socket)
- AF_ROUTE
协议族决定了通信对应的地址类型:
- AF_INET用ipv4地址(32位的)与端口号(16位的)的组合
- AF_INET6, 即ipv6的地址
- AF_UNIX用一个绝对路径名作为地址(本地套接字)
type
: 根据使用协议的不同采用不同的socket类型(流还是数据报)–socket type和 protocol是配合的.
主要有SOCK_STREAM, SOCK_DGRAM, SOCK_RAW, SOCK_PACKET, SOCK_SEQPACKET等等
- SOCK_STREAM 表明我们用的是 TCP 协议,这样会提供按顺序的,可靠,双向,面向连接的比特流(可以按字节读取)
- SOCK_DGRAM 表明我们用的是 UDP 协议,这样只会提供定长的,不可靠,无连接的通信(只能按消息读取)
protocol
: 指定协议, 常用的协议有: IPPROTO_TCP , IPPTOTO_UDP, IPPROTO_SCTP, IPPROTO_TIPC 等IP层的协议
这里其实可以填0, 因为会自动根据type对应的协议(前面一个参数type填写好, 这里直接填写0就可以了)
bind
1 | int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); |
通过socket返回的 “socket fd” 只有协议相关的信息, 但是还没有存储具体的地址和端口, 需要借助bind()函数把具体的地址和端口号赋值给socket fd.
sockfd
: 即socket描述字, 它是通过socket()函数创建了, 唯一标识一个socket. bind()函数就是将给这个描述字绑定一个具体地址.
addr
: 该结构体常量指针通常指向需要填充的地址协议信息, struct sockaddr根据协议族的不同有不同的机构.
可能是历史原因: 现在没有使用 struct sockaddr(而是使用的sockaddr_in), 所以到时候要强转(struct sockaddr*), 例如
1 | struct sockaddr_in addr; |
如ipv4和ipv6:
1 | //ipv4 |
Unix域对应的是:
1 |
|
除了本地套接字(Unix)套接字外, 都要制定网络ip address和 network port.
(也就是使用的时候, 要从本机序转换到网络序)
比较常见的填充代码:
1 | struct sockaddr_in servaddr; |
addrlen
: 对应地址的长度, 一般就是sockaddr_in的长度
完整的案例:
1 | if ( (bind(socketfd, (struct sockaddr*)&servaddr, sizeof(servaddr))) == -1){ |
通常服务器在启动的时候都会绑定一个众所周知的地址(如ip地址+端口号), 用于提供服务, 客户就可以通过它来接连服务器;
而客户端就不用指定, 由系统自动分配一个端口号和自身的ip地址组合.这就是为什么通常服务器端在listen之前会调用bind(), 而客户端就不会调用, 而是在connect()时由系统随机生成一个.
(主机序: 现在的x86架构一般是小端, 但是不保证, 也不要假定; 传输之前还是做一下转换; 网络序: 大端)
(0x0102, 如果a[0]存的是02就表示是小端; 否则是大端)
(网络字节序:4个字节的32 bit值以下面的次序传输. 首先是0~7bit, 其次8~15bit, 然后16~23bit, 最后是24~31bit)
在将一个地址绑定到socket 的时候, 请先将主机字节序转换成为网络字节序, 而不要假定主机字节序跟网络字节序一样使用的是Big-Endian.
例如:
1 | servaddr.sin_addr.s_addr = htonl(INADDR_ANY); |
再次强调务必转成网络序.
listen和connect
1 | int listen(int sockfd, int backlog); ---服务端(第二个参数为相应socket可以排队的最大连接个数) |
如果连接数目达此上限则 client 端将收到ECONNREFUSED 的错误;
listen()只适用 SOCK_STREAM 或 SOCK_SEQPACKET 的socket 类型, 并且如果 socket 为 AF_INET 则参数 backlog 最大值可设至 128 .
客户端通过该函数发送建立连接请求给服务端
例如:
(connect并不会返回新的socket fd, 连接成功之后就开始进行读写操作了)
( 记得最后buffer[nbytes]=’\0’; 读写操作自己控制socket fd的关闭)
1 | /* 客户程序发起连接请求 */ |
但是注意在阻塞IO下 read
如果读不满1024会阻塞等待知道满足或者超时.
accept
1 | int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); //第一个参数为服务器端socket fd |
一旦服务端接受该请求(tcp请求队列中拿出一个), 就返回一个新的用于读写的fd(由内核生成); 此时连接也建立完毕了, 可以进行IO了(网络IO和本地IO并没有太多的区别)
参数:
sockfd
: 监听套接字, 就是socket函数产生的(服务器的一次生命周期中仅创建一个;而 accept 函数返回的是已连接的 socket 描述字, 完成客户的请求该套接字就被关闭).addr
: 这是一个传出参数, struct sockaddr *的指针, 用于返回客户端的协议地址 (不区分客户端直接传入NULL, 记得强转)addrlen
: 客户端struct sockaddr 协议地址的长度; (放的是指针, 说明是传出参数)
accept之后, 就可以进行网络IO了(关于网络IO, 也还有好几种专门的网络IO模型)
IO函数
基本IO函数
read()/write()
简单的使用Linux的文件api进行网络IO:
1 |
|
介绍一下read, write:
read函数是负责从 fd中读取内容.
当读成功时, read返回实际所读的字节数(>0), 如果返回的值是0表示已经读到文件的结束, 小于0表示出现了错误.
如果错误为 EINTR 说明读是由中断引起的, 如果是ECONNREST表示网络连接出了问题.
write函数将buf中的nbytes字节内容写入文件描述符fd.成功时返回写的字节数(>0).失败时返回-1, 并设置 errno变量.
在网络程序中, 当我们向套接字文件描述符写时有俩种可能:
1. 返回值大于0; 表示写了部分或者是全部的数据
2. 返回值小于0; 此时出现了错误.
错误为EINTR 表示在写的时候出现了中断错误
如果为EPIPE表示网络连接出现了问题(对方已经关闭了连接)
专于socket
recv()/send()----和read, write大致一样, 不过flags提供了更加强大的选项
recvfrom()/sendto() ---- 一般用在udp中(server bind之后之后不需要listen, 直接和客户端进行通信)---容易阻塞
recvmsg()/sendmsg() ---- 最通用的, 最强大的,可以实现前面所有函数的功能.(涉及到的struct msghdr, iovec比较复杂)
1 |
|
关闭IO
1 | int close(int fd); |
close 操作只是使相应socket描述字的引用计数-1, 只有当引用计数为0的时候, 才会触发TCP客户端向服务器发送终止连接请求.
(被关闭的套接字不能在用于read或者write了; 注意你关闭了是accept出来用于响应客户端读写请求的socket fd还是listen fd)
1 | int shutdown(int sockfd,int howto) |
TCP 连接是双向的(是可读写的),当我们使用 close 时,会把读写通道都关闭,有时侯我们希望只关闭一个方向,这个时候我们可以使用 shutdown.
针对不同的 howto,系统回采取不同的关闭方式.
- howto=0 这个时候系统会关闭读通道. 但是可以继续往接字描述符写.
- howto=1 关闭写通道, 和上面相反,着时候就只可以读了.
- howto=2 关闭读写通道, 和 close 一样
close() Vs shutdown()
在多进程程序里面, 如果有几个子进程共享一个套接字时, 如果我们使用 shutdown, 那么所有的子进程都不能够操作了.
这个时候我们只能够使用 close 来关闭子进程的套接字描述符.
套接字选项
说明
控制套接字的行为, 如缓冲区大小, 端口重用等
1 |
|
参数说明: (当做key-value, map去理解)
相当于每个type里面有多个多个key, 每个key有多种value.
level
: (type)
- SOL_SOCKET: 通用套接字 选项
- IPPROTO_IP: IP 选项
- IPPROTO_TCP: TCP 选项
optname
: (key)
指定控制的方式(选项的名称), 下面详细解释
optval
: (value)
获得或者是设置套接字选项.根据选项名称的数据类型进行转换
详细说明如下:
SOL_SOCKET:(optname-ptval)
- SO_BROADCAST 允许发送广播数据 int
- SO_DEBUG 允许调试 int
- SO_DONTROUTE 不查找路由 int
- SO_ERROR 获得套接字错误 int
- SO_KEEPALIVE 保持连接 int
- SO_LINGER 延迟关闭连接 struct linger
- SO_OOBINLINE 带外数据放入正常数据流 int
- SO_RCVBUF 接收缓冲区大小 int
- SO_SNDBUF 发送缓冲区大小 int
- SO_RCVLOWAT 接收缓冲区下限 int
- SO_SNDLOWAT 发送缓冲区下限 int
- SO_RCVTIMEO 接收超时 struct timeval
- SO_SNDTIMEO 发送超时 struct timeval
- SO_REUSERADDR 允许重用本地地址和端口 int
- SO_TYPE 获得套接字类型 int
- SO_BSDCOMPAT 与 BSD 系统兼容 int
IPPROTO_IP
- IP_HDRINCL 在数据包中包含 IP 首部 int
- IP_OPTINOS IP 首部选项 int
- IP_TOS 服务类型
- IP_TTL 生存时间 int
IPPRO_TCP
- TCP_MAXSEG TCP 最大数据段的大小 int
- TCP_NODELAY 不使用 Nagle 算法 int
使用较多的案例:
案例(端口复用)
在 server 的 TCP 连接没有完全断开之前不允许重新监听是不合理的.
因为, TCP 连接没有完全断开指的是 connfd (clinetIP:6666)没有完全断开, 而我们重新监听的是 lis-tenfd(0.0.0.0:6666),
虽然是占用同一个端口, 但 IP 地址不同, connfd 对应的是与某个客户端通讯的一个具体的 IP 地址, 而 listenfd 对应的是 wildcard address.
解决这个问题的方法是使用 setsockopt()设置 socket 描述符的选项 SO_REUSEADDR 为 1,表示允许创建端口号相同但 IP 地址不同的多个 socket 描述符.
在 server 代码的 socket()和 bind()调用之间插入如下代码:
1 | int opt = 1; |
案例(getsockopt)
1 |
|
原始套接字
以TCP协议为例, 把原来需要系统内核做的事情, 现在我们自己来做封包 (IP数据包 && TCP数据包, 具体发送还是交给内核)
原来的话, 我们只是处理好要发送的Buffer.
调用socket函数的时候, type传入的(SOCK_STREAM,SOCK_DRAGM) 或者 SOCK_RAW; 协议传入:IPPROTO_ICMP, IPPROTO_TCP, IPPROTO_UDP 等
肉鸡
使用案例: (肉鸡攻击, 你一定听过)
下面是一个demo, 自定义tcp的源地址(模拟实现dos):
dos.c 生成可执行文件 DOS, 恶意客户端代码如下:
1 |
|
编译一下, 拿 localhost 做一下实验,看看有什么结果(千万不要试别人的啊).
为了让普通用户可以运行这个程序, 我们应该将这个程序的所有者变为root, 且设置 setuid 位, 之后再运行.
1 | chown root DOS |
尾巴
这里只是把 基本socket模型
大概的轮廓给描述了一下, 还有许多问题和相关函数没有涉及到.
其他问题
比如具体的IO函数总结, 读写函数的封装, 错误处理, 函数头文件等.
具体可以参考一下我的github库:
https://github.com/WizardMerlin/network_life