C语言学习笔记——网络编程(1)
网络编程基本原理
网络模型与协议
- 为减少协议设计的复杂性,大多数网络模型都是按层(layer)的方式来组织的。在分层网络模型中,每一层都要为上一层提供一定的服务,而把如何实现本层服务的细节对上一层加以屏蔽。
- TCP/IP各层功能如下:
- 网络接口层:它是TCP/IP模型最下一层,包括了多种逻辑链路控制和媒体访问协议
- Internet层(网络层):网络层负责在发送端和接收端之间建立一条虚拟路径。
- 传输层:传输层通过位于该层的TCP协议(传输控制协议)或UDP协议(用户数据报协议)在两台主机间传输数据。
- 应用层:应用层面向用户提供一系列访问网络的协议,如用于传输文件的FTP协议、用于远程登录的Telnet协议,用于发送电子邮件的SMTP协议(简单邮件传输协议),以及最常用的用于浏览网页的HTTP协议(超文本传输协议)。还有近几年来非常流行的点对点共享文件协议,即BitTorrent协议,该协议基于HTTP协议。使用该协议构建的BT下载工具有比特精灵、BitTorrent等。
地址
- 物理地址:对于以太网来说,物理地址就是一个48位的位串,此地址在网卡的生产过程中就已经固定了,是不可更改的,并且是全球唯一的。在Shell下输入命令
ifconfig
可以查看到本机的物理地址。 - IP地址127.0.0.1是一个特殊的地址,他织带本机,用于测试本机上的TCP/IP协议是否正常工作。输入命令
ping 127.0.0.1
,如果有回应说明本机上的TCP/IP协议工作正常。 - TCP/IP上的每台主机都还有一个32位的子网掩码,它用来区分IP地址的网络号和主机号。将IP地址与子网掩码进行按位“与”运算就可以得到IP地址的网络号,网络号是一台主机所处的网络的编号。例如,有一台主机的IP地址为222.197.168.244,对应的子网掩码为255.255.255.0两者与运算得到的结果是222.197.168.0,即这台主机所处的网络编号。
端口
- 有了网络地址,就可以唯一的标示网络上的每台计算机。通常一台计算机上汇通试运行多个程序,而他们可能要同时访问网络。对于一台计算机上的多个应用程序,TCP和UDP协议采用16位端口号来识别他们。一台主机上的不同进程可以绑定到不同的端口上,这些进程都可以访问网络而互不干扰。
- TCP/IP将端口号(端口号是一个16位的无符号整数,因此端口号的范围是02^16,即065535)分为两部分:一部分是保留端口即知名端口,范围是0~1023,这些端口由权威机构规定其用途,如编号为21的TCP端口由FTP协议专用,80号TCP端口由HTTP协议专用,其余的为自由端口,用户进程可以自由申请和使用。
IP协议
-
IP协议是网络层最重要的协议。无论传输层使用何种协议,都要依靠IP协议来确定到达目的计算机的路由。IP协议主要负责确定路由,当到达统一目的地有多条路由时,IP协议会选择一条最短路由来将数据分组传送到目的计算机。同时,IP协议还定义了一组规则,例如,有时目的地不可达或不存在,IP协议规定了在这种情况下合适丢弃传送中的数据分组。IP协议定义了数据单元格式,称为IP数据报。他由IP首部和数据两部分组成,如下图所示。
-
主要字段的含义如下:
- 版本:表示该数据报采用的是哪个版本的IP协议,该字段长为4位(IPv4)
- 总长度:IP首部和数据的总长度
- 生存期:该字段表示数据报在网络上的最大生存时间(Time to Live,TTL)。数据报每经过一个路由器,路由器将TTL值减一,当TTL的值减为0时,数据报将被丢弃,并且该路由器会向发送者返回一个ICMP超时报文,告知数据报被丢弃。TTL的默认值是64,最大值是225。
- 协议:该字段用于说明发送数据报所使用的协议,0x06表示TCP协议,0x11表示UDP协议。
- 报头校验和:用于检查IP首部的完整性,该字段只校验IP首部,不校验数据。
- 源IP地址:该字段为发送数据报的源计算机IP地址
- 目的IP地址:该字段为接受数据报的目的计算机IP地址
- IP选项:该字段是一个可选字段,主要用于网络调试
用户数据报协议UDP
在TCP/IP模型中,UDP协议位于传输层,在网络层之上而在应用层之下。UDP协议向应用程序提供一种面向无连接的服务,通常UDP协议被用于不需要可靠数据传输的网络环境中。UDP不需要建立连接,应用程序采用UDP协议无需建立和维持链接。UDP协议不保证数据报按顺序、正确的到达目的地,这项任务由应用程序来完成。
- UDP数据报首部格式如图所示
- UDP首部的各部分含义如下:
- 源端口:发送UDP数据的源端口号。
- 目标端口:接收UDP数据的目的端口号
- 长度:该字段表示包括UDP首部和数据在内的整个数据报的长度,以字节为单位。
- UDP校验和:该字段是根据IP首部、UDP首部和数据计算出来的值,当该字段被设置为0x0000时,表示发送端计算机没有计算校验和。
传输控制协议TCP
TCP数据报的格式
- TCP提供了一种面向连接的、可靠的数据传输服务。
- TCP数据报的首部格式如图所示
- 主要字段含义如下:
- 源端口:发送TCP数据的源端口号
- 目标端口:接受TCP数据的目的端口号
使用TCP进行通信的过程
- 建立连接
- 连接的发起端(通常称为客户端)向目标计算机(通常称为服务器)发送一个请求建立连接的数据报
- 服务器收到请求之后,对客户端的同步信号作出响应,并发送自己的同步信号给客户端
- 客户端对服务器端发来的同步信号进行响应。连接建立完成,就可以进行数据传输了。这是哪个步骤顺利完成后,建立连接,这个过程也被称为三次握手。
- 关闭
- 请求主机发送一个关闭连接的请求给另一方。
- 另一方收到关闭连接的请求后,发送一个接受请求的确认数据包,并关闭它的socket连接。
- 请求主机收到确认数据报后,发售一个确认数据包,告知另一方其发送的确认已收到,请求主机关闭它的socker连接。
客户机/服务器模型
- 网络中的实际应用大多都可以归纳为客户机/服务器模型(Client/Server模型、C/S模型),其中客户机是指请求服务的一方,服务器是指提供某种服务的乙方。有些应用程序,请求服务器的同时也提供一定的服务,如果拆开来看,这种程序也是基于C/S模型。
- C/S模型即可以使用TCP协议又可以使用UDP协议,或者两者混合使用,可根据具体需要而定。在C/S模型中,通常服务器端的IP地址和端口号是固定的,客户端程序连接到服务器IP和端口。通常客户端的程序设计相对简单一些。而服务器段由于要考虑多个客户端同时请求服务的问题,设计上相对复杂一些。
套接字编程
随着UNIX操作系统的广泛应用,socket套接字成为最流行最通用的网络通信应用程序的开发接口。现在不论是Windows还是Linux都使用socket来开发网络应用程序。通常Linux下的网络编程就是指套接字编程,本节将对套接字的使用做一个较为全面的介绍。
套接字地址结构
- 结构
struct sockaddr
定义了一种通用的套接字地址,他在linux/socket.h中的定义代码如下:
struct sockaddr {
unsigned short sa_family; //地址类型,AF_xxx
char sa_data[14]; //14字节的协议地址
};
- 其中,成员
sa_family
表示套接字的协议族类型,对应于TCP/IP协议该值为AF_INET,成员sa_data
存储具体的协议地址。sa_data
之所以被定义成14个字节,因为有的协议族使用较长的地址格式。一般在编程中并不对该结构体进行操作,而是使用另一个与他等价的数据结构:sockaddr_in。 - 每种协议族都有自己的协议地址格式,TCP/IP协议族的地址格式为
struct sockaddr_in
,他在netinet/in.h头文件中定义,格式如下:
struct sockaddr_in {
unsigned short sin_family; //地址类型
unsigned short int sin_port; //端口号
struct in_addr sin_addr; //IP地址
unsigned char sin_zero[8]; //填充字节,一般赋值为0
};
- 其中,成员
sin_family
表示地址类型,对于使用TCP/IP协议进行的网络编程,该值只能是AF_INET。sin_port
是端口号,sin_addr
用来存储32位IP地址,数组sin_zero
为填充字段,一般赋值为0。 struct in_addr
的定义如下:
struct in_addr {
unsigned long s_addr;
}
- 结构体
sockaddr
的长度为16字节,结构体sockaddr_in
的长度也为16字节。通常在编写基于TCP/IP协议的网络程序时,使用结构体sockaddr_in
来设置地址,然后通过强制类型转换成sockaddr
类型 - 以下是设置地址信息的示例代码:
struct sockaddr_in sock;
sock.sin_family = AF_INET;
sock.sin_port = htons(80); //设置端口号为80
sock.sin_addr.s_addr = inet_addr("202.205.3.195") //设置地址
memset(sock.sin_zero, 0, sizeof(sock.sin_zero)); //将数组sin_zero清0;
- 函数htons和inet_addr将在后面介绍。
创建套接字
- socket函数用来创建一个套接字,在Shell下输入
man socket
可获得该函数的原型:
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
- 参数domain用于指定创建套接字所使用的协议族,他们在头文件linux/socket.h中定义。常用的协议族如下:
- AF_UNIX:创建只在本机内进行通信的套接字
- AF_INET:使用IPv4 TCP/IP协议
- AF_INET6:使用IPv6 TCP/IP协议
- 参数type指定套接字的类型,可以取如下值:
- SOCK_STREAM:创建TCP流套接字
- SOCK_DGRAM:创建UDP数据报套接字
- SOCK_RAW:创建原始套接字
- 参数protocol通常设置为0,表示通过参数domain指定的协议族和参数type指定的套接字类型来确定使用的协议,当创建原始套接字时,系统无法唯一的确定协议,此时就需要该参数指定所使用的协议。
- 执行成功返回一个新创建的套接字;若有错误发生则返回-1,错误代码存入errno中。详细的错误代码请参考man手册。
- 下面的代码创建了一个TCP套接字:
int sock_fd;
socf_fd = socket(AF_INET, SOCK_STREAM, 0);
if (sock_fd < 0 ) {
perror("socket");
exit(1);
}
- 创建UDP协议的套接字为:
sock_fd = socket(AF_INET, SOCK_DGRAM, 0);
建立连接
- 函数connect用来在一个指定的套接字上创建一个连接,使用man可获得函数原型:
#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *serv_addr, socklen_t addrlen);
参数sockfd是一个由函数socket创建的套接字。如果该套接字的类型是SOCK_STREAM,则connect函数用于向服务器发出连接请求,服务器的IP地址和端口号由参数serv_addr指定。如果套接字的类型是SOCK_DGRAM,则connect函数并不建立真正的连接,他只是告诉内核与该套接字进行通信的目的地址(由第二个参数决定),只有该目的地址发来的数据才会被该socket接收。对于SOCK_DGRAM类型的套接字,调用connect函数的好处是不必在每次发送和接收数据时都制定目的地址。
- 通常一个面向连接的套接字(如TCP套接字)只能调用一次connect函数。而对于无连接的套接字(如UDP套接字)则可以多次调用connect函数以改变与目的地址的绑定。将参数serv_addr中的sa_family设置为AF_UNSPEC可以取消绑定。
- 参数serv_addr是一个地址结构,详见前文
- addrlen为参数serv_addr的长度
- 执行成功返回0,有错误发生则返回-1,错误代码存入errno中。
- 该函数的常见用法如下:
sturct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(struct sockaddr_in)); //将serv_addr的各个字段清零
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(80); //htons是字节顺序转换函数,在后面会讲
// inet_aton函数将一个字符串转换成一个网络地址,并把该网络地址赋给第二个参数
// inet族函数将在后面介绍
if (inet_aton("172.17.242.131", &serv_addr.sin_addr) < 0) {
perror("inet_aton");
exit(1);
}
// 使用sock_fd套接字链接到由serv_addr指定的目的地址上,假定sock_fd已定义
if (connect(sock_fd, (struct sockaddr* ) &serv_addr, sizoef (struct sockaddr_in)) < 0 ) {
perror("connect");
exit(1);
}
注意:serv_addr强制类型转换为struct sockaddr类型
绑定套接字
- 函数bind用来将一个套接字和某个端口绑定在一起,在Shell下输入
man 2 bind
可获得该函数原型:
#include <sys/types.h>
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *my_addr,socklen_t addrlen);
- socket函数只是创建了一个套接字,这个套接字将工作在哪个端口上程序并没有指定。前面提到,在客户机/服务器模型中,服务器端的IP地址和端口号一般是固定的,因此在服务器端的程序中,使用bind函数讲一个套接字和某个端口绑定在一起。该函数一般只有服务器端的程序调用。
- 参数my_addr制定了sockfd将绑定到的本地地址,可以将参数my_addr的sin_addr设置为INADDR_ANY而不是某个确定的IP地址就可以绑定到任何网络接口。对于只有一个IP地址的计算机,INADDR_ANY对应的就是他的IP地址;对于多宿主主机(拥有多块网卡),INADDR_ANY表示本服务器程序将处理来自所有网络接口上相应端口的连接请求。
- 函数执行成功返回0,当有错误发生时则返回-1.
- 该函数常见用法如下:
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(struct sockaddr_in));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(80);
serv_addr.sin_addr.s_addr = htonl(IADDR_ANY);
if (bind(sock_fd, (struct sockaddr*) &serv_addr, sizeof(struct sockaddr_in)) < 0) {
perror("bind");
exit(1);
}
在套接字上监听
- 函数listen把套接字转化为被动监听,在Shell下输入
man listen
可获得该函数原型:
#include <sys/types.h>
#include <sys/socket.h>
int listen(int sockfd, int backlog);
- 由函数socket创建的套接字是主动套接字,这种套接字可以用来主动请求连接到某个服务器(通过函数connect)。但是作为服务器端的程序,通常在某个端口上监听等待来自客户端的连接请求。
- 在服务器端,一般是先调用函数socket创建一个主动套接字,然后调用函数bind将该套接字绑定到某个端口上,接着在调用函数listen将该套接字转化为监听套接字,等待来自于客户端的连接请求。
- 一般多个客户端连接到一个服务器,服务器向这些客户端提供某种服务。服务器端设置一个连接队列,记录已经建立的连接,参数backlog指定了该连接队列的最大长度。如果连接队列已经达到最大,之后的连接请求将被服务器拒绝。
- 执行成功返回0,当有错误发生时则返回-1,错误代码存入errno中。
注意:函数listen只是将套接字设置为倾听模式以等待连接请求,它并不能接收连接请求,真正接收客户端连接请求的是后面即将介绍的函数accept()
- 该函数常见用法如下:
#define LISTEN_NUM 12 //定义连接请求队列长度
...
if (listen (sock_fd, LISTEN_NUM) < 0) {
perror("listen");
exit(1);
}
接受连接
- 函数accept用来接受一个连接请求,在Shell下输入
man 2 accept
可获得该函数原型:
#include <sys/types.h>
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- 参数sockfd是由函数socket创建,经函数bind绑定到本地某一端口上,然后通过函数listen转化而来的监听套接字。
- 参数addr用来保存发起连接请求的主机的地址和端口。
- 参数addrlen是addr所指向的结构体的大小。
- 执行成功返回一个新的代表客户端的套接字,出错则返回-1,错误代码存入errno中
- 只能对面向连接的套接字使用accept函数。accept执行成功时,将创建一个新的套接字,并且为这个新的套接字分配一个套接字描述符,并返回这个新的套接字描述符。这个新的套接字描述符与打开文件时返回的文件描述符类似,进程可以利用这个新的套接字描述符与客户端交换数据,参数sockfd所指定的套接字继续等待客户端的连接请求。
- 如果参数sockfd所指定的套接字被设置为阻塞方式(Linux下的默认方式),且连接请求队列为空,则accept()将被阻塞直到有连接请求到达为止;如果参数sockfd所指定的套接字被设置为非阻塞方式,则如果队列为空,accept将立即返回-1,errno被设置为EAGAIN。
- 套接字为阻塞方式下该函数的常见用法:
int client_fd;
int client_len;
struct sockaddr_in client_addr;
...
client_len = sizeof(struct sockaddr_in);
client_fd = accept(sock_fd, (struct sockaddr*)&client_addr, &client_len);
if (conn_fd < 0) {
perror("accept");
exit(1);
}
TCP套接字的数据传输
发送数据
- 函数send用来在TCP套接字上发送数据,在Shell下输入
man 2 send
可获取函数原型:
#include <sys/types.h>
#include <sys/socket.h>
ssize_t send(int sockfd, const void *msg, size_t len, int flags);
- 函数send只能对处于连接状态的套接字使用。参数sockfd为已建立好连接的套接字描述符,即accept函数的返回值。参数msg指向存放待发送数据的缓冲区,参数len为待发送数据的长度。
- 参数flags为控制选项,一般设置为0或取以下值:
- MSG_OOB:在指定的套接字上发送带外数据(out-of-band data),该类型的套接字必须支持带外数据(如SOCK_STREAM)。
- MSG_DONTROUTE:通过最直接的路径发送数据,而忽略下层协议的路由设置。
- 如果要发送的数据太长而不能发送时,将出现错误,errno设置为EMSGSIZE;如果要发送的数据长度大于该套接字的缓冲区剩余大小时,send()一般会被阻塞,如果该套接字被设置为非阻塞方式,则此时立即返回-1并将errno设为EAGAIN。
- 执行成功返回实际发送数据的字节数,出错则返回-1,错误代码存入errno中。
注意:执行成功只是说明数据写入套接字的缓冲区中,并不代表数据已经成功地通过网络发送到目的地。
- 套接字为阻塞方式下,该函数的常见写法:
#define BUFFERSIZE 1500
char send_buf[BUFFERSIZE];
...
if (send(conn_fd, send_buf, len, 0) < 0) { //len为待发送数据的长度
perror("send");
exit(1);
}
接收数据
- 函数recv用来在TCP套接字上接收数据,在Shell下输入
man recv
可获得该函数的原型:
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
- 函数recv从参数s所指定的套接字描述符(必须是面向连接的套接字)上接收数据并保存到参数buf所指定的缓冲区,参数len这位缓冲区长度。
- 参数flags为控制选项,一般设置为0或取以下数值。
- MSG_OOB:请求接收带外数据
- MSG_PEEK:只查看数据而不读出
- MSG_WAITALL:只在接收缓冲区满时才返回。
- 如果一个数据包太长以至于缓冲不能完全放下时,剩余部分的数据将可能被丢弃(根据接收数据的套接字类型而定)。如果在指定的套接字上无数据到达时,recv()将被阻塞,如果该套接字被设置为非阻塞方式,则立即返回01并将errno设为EAGAIN。函数recv接收到数据就返回,并不会等待接受到参数len指定长度的数据才返回。
- 执行成功返回接收到的数据字节数,出错则返回-1,错误代码存入errno中。
- 套接字为阻塞方式下该函数的常见用法:
char recv_buf[BUFFERSIZE];
...
if ( recv(conn_fd, recv_buf, sizeof (recv_buf), 0) < 0) {
perror("recv");
exit(1);
}
UDP套接字的数据传输
发送数据
- 函数sendto用来在UDP套接字上发送数据,在Shell下输入
man sendto
可获取其函数原型:
#include <sys/types.h>
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *msg, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
- 函数sendto的功能和函数send相似,但函数sendto不需要套接字处于连接状态,所以该函数通常用来发送UDP数据。同事因为是无连接的套接字,在使用sendto时需要指定数据的目的地址。
- 参数msg指向待发送数据的缓冲区,参数len指定了待发送数据的长度,参数flags是控制选项,含义与send()一致,参数dest_addr用于指定目的地址,目的地址的长度由addelen指定。
- 执行成功返回实际发送数据的字节数,出错则返回-1,错误代码存入errno中。
- 以下为该函数的常见写法:
char send_buf[BUFFERSIZE];
struct sockaddr_in dest_addr;
// 设置目的地址
memset(&dest_addr, 0, sizeof (struct sockaddr_in) );
dest_addr.sin_family = AF_INET;
dest_addr.sin_port = htons(DEST_PORT);
// inet族函数在下面会介绍
if (inet_aton("172.17.242.131", &dest_addr.sin_addr) < ) {
perror("inet_aton");
exit(1);
}
接收数据
- 函数recvfrom用来在UDP套接字上接收数据,在Shell下输入
man recvfrom
可获得该函数原型:
#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);
- 函数recvfrom与函数recv功能类似,只是函数recv只能用于面向连接的套接字,而函数recvfrom没有此限制,可以用于从无连接的套接字(如UDP套接字)上接收数据。
- 参数buf指向接收缓冲区,参数len指定了缓冲区的大小,参数flags是控制选项,含义与recv一致。如果参数src_addr非空,且该套接字不是面向连接的,则函数recvfrom返回时,参数src_addr中将保存数据的源地址,参数addrlen在调用recvfrom前为参数src_addr的长度,调用recvfrom后将保存src_addr的实际大小。
- 执行成功返回实际接收到数据的字节数,出错则返回-1,错误代码存入errno中。
- 套接字为阻塞方式下该函数的常见用法:
char recv_buf[BUFFERSIZE];
struct sockaddr_in src_addr;
int src_len;
src_len = sizeof (struct sockaddr_in);
if (recvfrom(sock_fd, recv_buf, sizeof(recv_buf), 0, (struct sockaddr*) &src_addr, &src_len) < 0) {
perror("again_recvfrom");
exit(1);
}
关闭套接字
函数close
- 函数close用来关闭一个套接字描述符,他与关闭文件描述符是类似的,在Shell下输入
man close
可获得该函数的原型:
#include <unistd.h>
int close(int fd);
- 参数fd为一个套接字描述符。该函数关闭一个套接字。执行成功返回0,出错则返回-1,错误代码存入errno中。
函数shutdown
- 函数shutdown也用于关闭一个套接字描述符,在Shell下输入
man 2 shutdown
可获得该函数的原型:
#include <sys/socket.h>
int shutdown(int sockfd, int how);
- 函数shutdown的功能与close类似,但是shutdown()功能更加强大,可以对套接字的关闭进行一些更细致的控制,它允许对套接字进行单项关闭或全部禁止。参数sockfd为待关闭的套接字描述符,参数how指定了关闭的方式,具体取值如下:
- SHUT_RD:将连接上的读通道关闭,此后进程将不能在接受任何数据,接收缓冲区中还未被读取的数据也将被丢弃,但仍然可以在该套接字上发送数据。
- SHUT_WR:将连接上的写通道关闭,此后进程将不能再发送任何数据,发送缓冲区中还未被发送的数据也将被丢弃,但仍然可以在该套接字上接收数据。
- SHUT_RDWR:读、写通道都将被关闭
- 执行成功返回0,出错则返回-1,错误代码写入errno中