网络通信本质上是一种进程间通信,是位于网络中不同主机上的进程之间的通信。网络通信大致分为以下三层。
在硬件层,两台主机都提供了网卡设备,满足了进行网络通信最基本的要求,网卡设备是实现网络数据收发的硬件基础。
在内核层提供了网卡驱动程序,其可以驱动底层网卡硬件设备,同时向应用层提供socket接口。
在应用层基于内核提供的socket接口进行应用编程,以实现特定的网络应用程序。socket接口是内核向应用层提供的一套网络编程接口,学习网络编程其实就是学习socket编程,即如何基于socket接口编写应用程序。
除了socket接口,在应用层通常还会使用一些更为高级的编程接口,譬如http、网络控件等,这些接口实际上是对socket接口的一种更高级别的封装。
OSI(Open System Interconnection)七层参考模型是国际标准化组织(ISO)制定的一个用于计算机或通信系统间网络互联的标准体系,一般称为OSI参考模型或七层模型。
物理层是OSI参考模型的最低层,物理层的主要功能是利用传输介质为数据链路层提供物理连接,实现比特流的透明传输,物理层的作用是实现相邻计算机节点之间比特流的透明传送,尽可能屏蔽掉具体传输介质和物理设备的差异。使数据链路层不必考虑网络的具体传输介质是什么。透明传送比特流表示经实际电路传送后的比特流没有发生变化,对传送的比特流来说,这个电路好像是看不见的。网络数据信号的传输是通过物理层实现的,通过物理介质传输比特流,物理层规定了物理设备标准、电平、传输速率等,集线器、中继器、调制解调器、网线、双绞线、同轴电缆等都是物理层的传输介质。
数据链路层是OSI参考模型中的第二层,负责建立和管理节点间逻辑连接、进行硬件地址寻址、差错检测等功能。数据链路层又分为两个子层,即逻辑链路控制子层和媒体访问控制子层。MAC子层的主要任务是解决共享型网络中多用户对信道竞争的问题,完成网络介质的访问控制,LLC子层的主要任务是建立和维护网络连接,执行差错校验、流量控制和链路控制。数据链路层的具体工作是接收来自物理层的比特流,并封装成帧,传送到上一层,同样也可将来自上层的数据帧拆为比特流形式的数据转发到物理层,此外还负责处理接收端发回的确认帧的信息,以便提供可靠的数据传输。
网络层进行逻辑地址寻址,实现不同网络之间的路径选择,该层通过IP寻址来建立两个节点之间的连接,为源端发送的数据包选择合适的路由和交换节点,正确无误地按照地址传送给目的端。该层的主要协议有IP、ICMP等。
传输层定义传输数据的协议端口号,以及端到端的流控和差错校验,该层建立了主机端到端的连接,传输层的作用是为上层协议提供端到端的可靠和透明的数据传输服务,包括差错校验处理和流控等问题。该层的主要协议有TCP、UDP等。
会话层对应主机进程,指本地主机与远程主机正在进行的会话,会话层负责建立、管理和终止表示层实体之间的通信会话。该层的通信由不同设备中的应用程序之间的服务请求和响应组成,将不同实体之间表示层的连接称为会话,因此会话层的任务就是组织和协调两个会话进程之间的通信,并对数据交换进行管理。
表示层提供各种用于应用层数据的编码和转换功能,确保一个系统的应用层发送的数据能被另一个系统的应用层识别。如果必要,该层可提供一种标准表示形式,用于将计算机内部的多种数据格式转换成通信中采用的标准表示形式。
应用层是OSI参考模型中的最高层,为上层用户提供应用接口或直接提供各种网络服务。
TCP/IP五层模型是OSI七层模型的简化,TCP/IP五层模型中下面四层不变,并将OSI七层模型的最上面三层合并为应用层。
TCP/IP四层模型在TCP/IP五层模型的基础上,将底层的数据链路层和物理层合并为网络接口层。
网络通信中,数据从上层到下层交付时,要进行封装,同样地,当目标主机接收到数据时,数据由下层传递给上层时需要进行拆封。数据的封装过程如下图所示。
可以看到,用户发送数据时会从上层到下层一次进行封装,也就是添加各层的头部信息,在链路层封装完成后将数据交付给网卡,然后网卡硬件设备将数据转换成物理链路上的电平信号,数据就被发送到了网络中了。
当数据被目标主机接收到之后,会进行相反的拆封过程,将每一层的首部进行拆解最终得到用户数据。
关于IP地址的描述可以参看文章:计算机网络——网络层数据交换方式、IP数据报、IPv4地址、重要协议、IPv6。
关于TCP和UDP的内容可以参看文章:计算机网络——传输层 UDP 和 TCP。
根据IP地址可以找到对应的主机,但是主机上运行着很多的进程,比如QQ、微信、浏览器等,这些进程都需要进行网络连接,它们都可通过网络发送/接收数据,主机接收到网络数据之后,如何确定该数据发往哪个进程呢?其实就是通过端口号来确定的。端口号本质上就是一个数字编号,用来在一台主机中唯一标识一个能上网的进程,端口号的取值范围为0~65535,一台主机通常只有一个IP地址,但是可能有多个端口号。一台拥有IP地址的主机可以提供许多服务,比如Web服务、FTP服务、SMTP服务等,这些服务都是能够进行网络通信的进程,IP地址只能区分网络中不同的主机,并不能区分主机中的这些进程,有了端口号,通过IP地址+端口号来区分主机不同的进程。
很多常见的服务器都有特定的端口号,HTTP服务端口号是80,FTP服务的端口号是21,SMTP(简单邮件传输协议)服务的端口号是25,TFTP(简单文件传输协议)服务的端口号是69,SSH(安全外壳协议)服务的端口号是22,Telnet(终端远程登录协议)服务的端口号是23等。
Linux下的网络编程一般称为socket编程,socket是内核向应用层提供的一套网络编程接口,用户基于socket接口可开发自己的网络相关应用程序。
套接字(socket)是Linux下的一种进程间通信机制,通过这种机制可以在不同主机上的应用程序之间进行通信,也可以在同一台主机的不同应用程序之间通信。
socket()函数用于创建一个网络通信端点,成功就返回一个网络文件描述符。socket()函数和open()函数比较相似,open()函数会返回一个文件描述符,socket()函数会返回一个socket描述符,之后的操作中都会使用到这个socket描述符。
使用 man 2 socket 命令即可查看socket()函数原型如下。
#include
#include
int socket(int domain, int type, int protocol);
参数domain用于指定一个通信域,这将选择将用于通信的协议族,常用的协议族有AF_INET(IPv4 Internet protocols)、AF_INET6(IPv6 Internet protocols)、AF_UNIX/AF_LOCAL(Local communication)等,对于TCP/IP协议来说,一般选择AF_INET。
参数type指定套接字的类型,主要有:SOCK_STREAM(用于TCP协议)、SOCK_DGRAM(用于UDP协议)等。
参数protocol通常设置为0,表示为给定的通信域和套接字类型选择默认协议。
同open()函数一样,套接字使用完成后使用close()函数来关闭socket描述符。
bind()函数原型如下。
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
bind()函数用于将一个IP地址或端口号与一个套接字进行绑定,即将套接字与地址进行关联。一般会将一个服务器的套接字绑定到某个固定的IP地址和端口号上(大家都知道的),这样客户端就会知道自己要访问的服务器IP地址和端口号,从而进行通信。
addr参数是一个struct sockaddr类型的指针,其结构体如下。
struct sockaddr
{
sa_family_t sa_family;
char sa_data[14];
}
sa_data结构体成员是一个字符数组,包含了IP地址和端口号等信息,用户对这个数组无法进行赋值。所以使用struct sockaddr_in结构体替代struct sockaddr,然后在函数中进行类型转换即可。struct sockaddr_in结构体的内容如下。
struct sockaddr_in
{
sa_family_t sin_family; // 协议族
in_port_t sin_port; // 端口号
struct in_addr sin_addr; // IP地址
unsigned char sin_zero[8];
};
实际使用的时候按照下面这样使用即可。
struct sockaddr_in socket_addr;
server_addr.sin_family = AF_INET; //协议族
server_addr.sin_port = htons(SERVER_PORT); //端口号
server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // IP地址
代码中的htons和htonl并不是函数,只是一个宏定义,主要的作用在于为了避免大小端的问题,使用这些宏需要在代码中包含头文件
listen()函数只能在服务器进程中使用,让服务器进程进入监听状态,等待客户端的连接请求,listen()函数在一般在bind()函数之后调用,在accept()函数之前调用,它的函数原型如下。
int listen(int sockfd, int backlog);
参数backlog用来描述sockfd的等待连接队列能够达到的最大值,当有多个客户端请求建立连接的时候,等待连接队列上限在这里设置。当一个客户端的连接请求到达并且该队列为满时,客户端可能会收到一个表示连接失败的错误,本次请求会被丢弃不作处理。
无法在一个已经连接的套接字上执行listen(),即已经成功执行connect()的套接字或由accept()调用返回的套接字。
服务器调用listen()函数之后,就会进入到监听状态,等待客户端的连接请求,使用accept()函数获取客户端的连接请求并建立连接,函数原型如下。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数addr是一个传出参数,用来返回已连接的客户端的IP地址与端口号等信息,如果对客户端的信息不感兴趣,两个都可以设置为NULL。
accept()函数通常只用于服务器应用程序中,如果调用accept()函数时,并没有客户端请求连接,此时accept()会进入阻塞状态,直到有客户端连接请求到达为止。当有客户端连接请求到达时,accept()函数与远程客户端之间建立连接,accept()函数会返回一个新的套接字,这个套接字与socket()函数返回的套接字并不同,socket()函数返回的是服务器的套接字,而accept()函数返回的套接字连接到调用connect()的客户端,服务器通过该套接字与客户端进行数据交互。这个新的套接字就是服务器端accept()与执行connect()的客户端之间建立连接产生的新套接字,这个套接字代表了服务器与客户端的一个连接。
connect()函数用于客户端应用程序中,客户端调用connect()函数将套接字sockfd与远程服务器进行连接,其原型如下。
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数addr指定了待连接的服务器的IP地址以及端口号等信息,参数addrlen指定了addr指向的struct sockaddr对象的字节大小。
客户端通过connect()函数请求与服务器建立连接,对于TCP连接来说,调用该函数将发生TCP连接的握手过程,并最终建立一个TCP连接,而对于UDP协议来说,调用这个函数只是在sockfd中记录服务器IP地址与端口号,而不发送任何数据。
recv()函数用来接收数据,不论是客户端还是服务器都可以通过revc()函数读取网络数据,它与read()函数的功能是相似的,其函数原型如下。
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
sockfd是套接字描述符;参数buf指向了一个数据接收缓冲区;参数len指定了读取数据的字节大小;参数flags可以指定一些标志用于控制如何接收数据,一般设置为0。
send()函数用来发送数据,它与write()函数的功能是相似的,其函数原型如下。
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
参数flags一般设置为0。
在程序中,revc()函数可以由read()函数替代,send()函数也可以由write()函数替代。
我们经常看到的IP地址是点分十进制表示的,这其实是一种字符串的形式,但是计算机能理解的是二进制形式的IP地址,所以需要在点分十进制字符串和二进制地址之间进行转换。点分十进制字符串和二进制地址之间的转换函数主要有:inet_aton、inet_addr、inet_ntoa、inet_ntop、inet_pton。
下面这些函数已经不怎么使用了。
#include
#include
#include
int inet_aton(const char *cp, struct in_addr *inp);
in_addr_t inet_addr(const char *cp);
char *inet_ntoa(struct in_addr in);
现在经常使用的是下面这两个函数进行转换。
#include
int inet_pton(int af, const char *src, void *dst);
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
inet_pton()函数将点分十进制表示的字符串形式转换成二进制Ipv4或Ipv6地址。inet_ntop()函数将二进制IP地址转换为点分十进制形式的字符串。
参数af必须是AF_INET或AF_INET6,AF_INET表示待转换的Ipv4地址,AF_INET6表示待转换的是Ipv6地址;src是点分十进制字符串;参数af被指定为AF_INET,参数dst所指的是一个struct in_addr结构体对象,参数af被指定为AF_INET6,则参数dst所指的是一个struct in6_addr结构体对象。
服务器端代码如下。(以下代码来自正点原子,本人只做了一点改动!)
#include
#include
#include
#include
#include
#include
#include
#include
#define SERVER_PORT 8888 //设置服务器端口号
int main(int argc, char *argv[])
{
struct sockaddr_in server_addr; //定义服务器结构体
struct sockaddr_in client_addr;
char ip_str[20]; //存放IP地址
int sockfd, connfd; //一个socket描述符,一个建立连接后的描述符
char recvbuf[512]; //接收客户端发来的数据
int addrlen = sizeof(client_addr);
int ret;
sockfd = socket(AF_INET,SOCK_STREAM,0); //打开套接字,得到套接字描述符
if(sockfd < 0)
{
perror("socket error");
return sockfd;
}
/*给结构体成员设置值*/
server_addr.sin_family = AF_INET; //协议族
server_addr.sin_port = htons(SERVER_PORT); //端口号
server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // IP地址
ret = bind(sockfd,(struct sockaddr *)&server_addr,sizeof(server_addr)); //将套接字与指定端口号进行绑定
if(ret < 0)
{
perror("bind error");
close(sockfd);
return ret;
}
ret = listen(sockfd, 50); //服务器进入监听状态,等待连接队列最大值为50
if(ret < 0)
{
perror("listen error");
close(sockfd);
return ret;
}
printf("等待客户端接入...\n");
connfd = accept(sockfd,(struct sockaddr *)&client_addr,&addrlen); //阻塞等待客户端连接
if(connfd < 0)
{
perror("accept error");
close(sockfd);
return connfd;
}
printf("一个客户端已接入!\n");
inet_ntop(AF_INET,&client_addr.sin_addr.s_addr,ip_str,sizeof(ip_str)); //得到点分十进制IP地址
printf("客户端主机的IP地址: %s\n",ip_str);
printf("客户端进程的端口号: %d\n",client_addr.sin_port);
/* 接收客户端发送过来的数据 */
while(1)
{
memset(recvbuf,0x0,sizeof(recvbuf)); // 接收缓冲区清零
ret = recv(connfd,recvbuf,sizeof(recvbuf),0); //接收客户端发来的数据
//ret = read(connfd,recvbuf,sizeof(recvbuf));
if(ret < 0)
{
perror("recv error");
close(connfd);
break;
}
printf("message from client: %s\n", recvbuf);
ret = send(connfd,"data received.",15,0); //给客户端发送反馈信息
//ret = write(connfd,"data received.",15);
if(ret < 0)
{
perror("send error");
break;
}
if (strncmp("exit",recvbuf,4) == 0) //客户端发来"exit"就关闭套接字退出程序
{
printf("server exit!\n");
close(connfd);
break;
}
}
close(sockfd); //关闭套接字描述符
return 0;
}
客户端代码如下。(以下代码来自正点原子,本人只做了一点改动!)
#include
#include
#include
#include
#include
#include
#include
#include
#define SERVER_PORT 8888 //服务器端口号
#define SERVER_IP "192.168.0.232" //服务器的IP地址
int main(int argc, char *argv[])
{
struct sockaddr_in server_addr;
int sockfd;
char buf[512]; //发送数据缓冲区
char rcvbuf[512]; //接收数据缓冲区
int ret;
sockfd = socket(AF_INET,SOCK_STREAM,0); //打开套接字,得到套接字描述符
if(sockfd < 0)
{
perror("socket error");
return sockfd;
}
/*给结构体成员设置值*/
server_addr.sin_family = AF_INET; //协议族
server_addr.sin_port = htons(SERVER_PORT); //端口号
inet_pton(AF_INET,SERVER_IP,&server_addr.sin_addr); //得到二进制IP地址
ret = connect(sockfd,(struct sockaddr *)&server_addr,sizeof(server_addr)); //连接服务器
if(ret < 0)
{
perror("connect error");
close(sockfd);
return ret;
}
printf("服务器连接成功...\n\n");
/* 向服务器发送数据 */
while(1)
{
memset(buf,0x0,sizeof(buf)); //发送数据缓冲区清零
printf("Please enter a string: ");
fgets(buf,sizeof(buf),stdin); //接收用户输入
ret = send(sockfd,buf,strlen(buf),0); //给服务器发送数据
//ret = write(sockfd,buf,strlen(buf));
if(ret < 0)
{
perror("send error");
break;
}
memset(rcvbuf, 0x0, sizeof(rcvbuf)); //接收数据缓冲区清零
ret = recv(sockfd,rcvbuf,sizeof(rcvbuf),0); //接收服务器反馈信息
//ret = read(sockfd,rcvbuf,sizeof(rcvbuf));
printf("message from server: %s\n", rcvbuf);
if (strncmp("exit",buf,4) == 0) //发送"exit"就退出
{
printf("client exit!\n");
break;
}
}
close(sockfd); //关闭套接字描述符
return 0;
}
上面的代码不管是开发板做服务器还是电脑做服务器都是可以的,确认好之后在客户端代码中设定要做服务器一方的主机IP,然后将一个代码用GCC编译器编译,编译后的可执行程序运行在电脑端,另一个代码用交叉编译器编译,编译后的可执行程序运行在开发板上。
运行的时候,先运行服务器端程序,再运行客户端程序,没有问题的话连接就建立了,之后就可以传输数据了。
①开发板做服务器,PC做客户端
开发板做服务器,PC做客户端时,先在开发板上执行服务器程序,然后在PC上执行客户端程序。
服务器端执行结果如下图所示。
客户端执行结果如下图所示。
②PC做服务器,开发板做客户端
PC做服务器,开发板做客户端时,先在PC上执行服务器程序,然后在开发板上执行客户端程序。
服务器端执行结果如下图所示。
客户端执行结果如下图所示。
通过上面的程序执行结果可以看到,通过socket实现了服务器和客户端的通信。
参考资料:
I.MX6U嵌入式Linux C应用编程指南V1.4——正点原子