TCP网络编程是目前比较通用的方式,例如HTTP协议、FTP协议等很多广泛应用的协议均基于TCP协议。TCP编程主要为C/S模式,即服务器(S)、客户端(C)模式。 TCP网络编程的流程包含服务器和客户端两种模式,这两种模式之间的程序设计流程存在很大的差别。
TCP网络编程有两种模式, 一种是服务器模式, 另一种是客户端模式。服务器模式创建一个服务程序, 等待客户端用户的连接, 接收到用户的连接请求后, 根据用户的请求进行处理;客户端模式则根据目的服务器的地址和端口进行连接, 向服务器发送请求并对服务器的响应进行数据处理。
TCP协议是TCP/IP协议中很重要的一个协议, 由于它传输的稳定性, 在很多程序中都在使用,例如HTTP, FTP等协议都是在TCP的基础上进行构建的。
服务器模式的程序设计流程
TCP连接的服务器模式的程序设计流程。流程主要分为套接字初始化socket() , 套接字与端口的绑定bind()设置服务器的侦听连接listen() , 接受客户端连接accept() , 接收和发送数据(read()、write())并进行数据处理及处理完毕的套接宇关闭(close()) 。
1.套接字初始化过程中, 根据用户对套接字的需求来确定套接字的选项。这个过程中的函数为socket(), 它按照用户定义的网络类型、协议类型和具体的协议标号等参数来定义。系统根据用户的需求生成一个套接字文件描述符供用户使用。
2.套接字与端口的绑定过程中, 将套接字与一个地址结构进行绑定。绑定之后, 在进行网络程序设计的时候, 套接字所代表的IP地址和端口地址及协议类型等参数按照绑定值进行操作。
3.由于一个服务器需要满足多个客户端的连接请求, 而服务器在某个时间仅能处理有限个数的客户端连接请求, 所以服务器需要设置服务端排队队列的长度。服务器侦听连接会设置这个参数, 限制客户端中等待服务器处理连接请求的队列长度。
4.在客户端发送连接请求之后, 服务器需要接收客户端的连接, 然后才能进行其他的处理。
5.在服务器接收客户端请求之后, 可以从套接字文件描述符中读取数据或者向文件描述符发送数据。接收数据后服务器按照定义的规则对数据进行处理, 并将结果
发送给客户端。
6.当服务器处理完数据, 要结束与客户端的通信过程的时候, 需要关闭套接字连接。
客户端模式的程序设计流程
客户端模式主要分为套接字初始化socket() , 连接服务器connect() ,读写网络数据read() 、write() 并进行数据处理和最后的套接字关闭close() 过程。
客户端程序设计模式流程与服务器端的处理模式流程类似, 二者的不同之处是客户端在套接字初始化之后可以不进行地址绑定, 而是直接连接服务器端。
客户端连接服务器的处理过程中, 客户端根据用户设置的服务器地址、端口等参数与特定的服务器程序进行通信。
客户端与服务器在连接、读写数据、关闭过程中有交互过程。
1.客户端的连接过程, 对服务器端是接收过程, 在这个过程中客户端与服务器进行三次握手, 建立TCP连接。建立TCP连接之后, 客户端与服务器之间可以进行数据的交互。
2.客户端与服务器之间的数据交互是相对的过程, 客户端的读数据过程对应了服务器端的写数据过程, 客户端的写数据过程对应服务器的读数据过程。
3.在服务器和客户端之间的数据交互完毕之后, 关闭套接字连接。
在使用套接字地址结构中,struct sockaddr不方便进行设置, 在以太网中, 一般均采用结构 struct sockaddr _ in进行设置, 这个结构的定义如下:
struct sockaddr_in{ /*以太网套接字地址结构*/
u8 sin_len; /*结构struct sockaddr_in的长度, 16*/
u8 sin_family; /*通常为AF_INET*/
u16 sin_port; /*16 位的端口号, 网络字节序*/
struct in_addr sin_addr; /*IP 地址32 位*/
char sin_zero[8]; /*未用*/
};
结构structsockaddr _in的成员变量in_addr用于表示IP地址, 这个结构的定义如下:
struct in_addr{ /*IP 地址结构*/
u32 s_addr; /*32 位IP 地址, 网络字节序*/
};
由于结构struct sockaddr和结构struct sockaddr_in的大小是完全一致的,所以进行地址结构设置时,通常的方法是利用结构struct sockaddr_in进行设置,然后强制转换为结构struct sockaddr类型。 因为这两个结构大小是完全—致的, 所以进行这样的转换不会有什么副作用。
TCP常用函数
1.socket()
网络程序设计中的套接字系统调用socket()函数用来获得文件描述符。socket()函数的原型如下,这个函数建立一个协议族为domain、协议类型为type 、协议编号为protocol的套接字文件描述符。如果函数调用成功, 会返回一个表示这个套接字的文件描述符, 失败的时候返回-1。
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
函数socket()的参数domain用于设置网络通信的域,函数socket()根据这个参数选择通信协议的族。通信协议族在文件sys/socket.h中定义, 包含表7.1所示的值, 以太网中应该PF_INET这个域。在程序设计的时候会发现有的代码使用了AF_lNET这个值, 在头文件中AF_INET和PF_INET的值是一致的。
使用socketO函数的时候需要设置上述3个参数, 例如将socket()函数的第1个参数domain设置为AF_INET, 第2 个参数设置为SOCK_STREAM, 第3 个参数设置为o, 建立一个流式套接字。
int sock = socket(AF_INET, SOCK_STREAM, 0);
- bind()
在建立套接字文件描述符成功后, 需要对套接字进行地址和端口的绑定, 才能进行数据的接收和发送操作。
bind()函数将长度为addlen的struct sockaddr类型的参数my_addr与sockfd绑定在 起,将sockfd绑定到某个端口上, 如果使用 connect()函数则没有绑定的必要。绑定的函数原型如下:
#include <sys/types.h>
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *my_addr, socklen_t addrlen);
bind()函数有3个参数。第1个参数sockfd是用socket()函数创建的文件描述符。第2个参数my_addr是指向一个结构为sockaddr参数的指针,sockaddr中包含了地址、端口和IP地址的信息。在进行地址绑定的时候, 需要先将地址结构中的IP地址、端口、类型等结构struct sockaddr中的域进行设置后才能进行绑定, 这样进行绑定后才能将套接字文件描述符与地址等结合在一起。第3个参数addrlen是my_addr结构的长度, 可以设置成siezof(struc stockaddr)。 使用sizeof(struc st ockaddr)来设置addrlen是一个良好的习惯, 虽然一般情况下使用AF_lNET来设置套接字的类型和其对应的结构, 但是不同类型的套接字有不同的地址描述结构, 如果对地址长度进行了强制的指定, 可能会造成不可预料的结果。bind()函数的返回值为0时表示绑定成功, -1表示绑定失败。
- listen()
服务器模式中有listen()和accept()两个函数, 而客户端则不需要这两个函数。 函数listen()用来初始化服务器可连接队列, 服务器处理客户端连接请求的时候是顺序处理的, 同 时间仅能处理一个客户端连接。 当多个客户端的连接请求同时到来的时候,服务器并不是同时处理, 而是将不能处理的客户端连接请求放到等待队列中,这个队列的长度由listen()函数来定义。isten()函数的原型如下,其中的backlog表示等待队列的长度。
#include <sys/socket.h>
int listen(int sockfd, int backlog);
当listen()函数成功运行时,返回值为0;当运行失败时,它的返回值为-1,并且设置 ermo值。
3.accept()
当—个客户端的连接请求到达服务器主机侦听的端口时, 此时客户端的连接会在队列中等待, 直到使用服务器处理接收请求。函数accept()成功执行后,会返回一个新的套接口文件描述符来表示客户端的连接, 客户端连接的信息可以通过这个新描述符来获得。因此当服务器成功处理客户端的请求连接后, 会有两个文件描述符, 老的文件描述符表示正在监听的socket, 新产生的文件描述符表示客户端的连接, 函数send()和recv。通过新的文件描述符进行数据收发。
accept()函数的原型如下:
#include <sys/types.h>
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
通过accept()函数可以得到成功连接客户端的IP 地址、端口和协议族等信息, 这个信息是通过参数addr 获得的。当accept()返回的时候,会将客户端的信息存储在参数addr 中。参数addrlen 表示第2 个参数(addr) 所指内容的长度, 可以使用sizeof(struct sockaddr _in)来获得。需要注意的是在accept 中addrlen 参数是一个指针而不是结构, accept()将这个指针传给TCP/IP 协议栈。
accpet()函数的返回值是新连接的客户端套接字文件描述符,与客户端之间的通信是通过accept()返回的新套接字文件描述符来进行的,而不是通过建立套接字时的文件描述符, 这是在程序设计的时候需要注意的地方。如果accept()函数发生错误,accept会返回-1。 通过ermo可以得到错误值。
- connect()
客户端在建立套接字之后,不需要进行地址绑定,就可以直接连接服务器。连接服务器的函数为connect(),此函数连接指定参数的服务器,例如IP地址、端口等。
connect()函数的原型如下, 其中的参数sockfd是建立套接字时返回的套接字文件描述符, 它是由系统调用socket()返回的。参数serv_addr, 是一个指向数据结构sockaddr的指针, 其中包括客户端需要连接的服务器的目的端口和IP 地址以及协议类型。参数addrlen表示第二个参数内容的大小, 可以使用sizeof(structsockaddr)而获得, 与bind不同, 这个参数是一个整型的变量而不是指针。connect()函数的返回值在成功时为0, 当发生错误的时候返回-1, 可以查看errno获得错误的原因。
#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, struct sockaddr *, int addrlen);
- write()
当服务器端在接收到一个客户端的连接后, 可以通过套接字描述符进行数据的写入操作。对套接字进行写入的形式和过程与普通文件的操作方式一致, 内核会
根据文件描述符的值来查找所对应的属性,当为套接字的时候,会调用相对应的内核函数。
下面是一个向套接字文件描述符中写入数据的例子,将缓冲区data的数据全部写入套接字文件描述符s中, 返回值为成功写入的数据长度。
int size;
char data[1024];
size = write(s, data, 1024);
- read()
与写入数据类似, 使用read()函数可以从套接字描述符中读取数据。当然在读取数据之前, 必须建立套接字并连接。读取数据的方式如下所示, 从套接字描述符s 中读取1024个字节, 放入缓冲区data 中, size 变量的值为成功读取的数据大小。
int size;
char data[1024];
size = read(s, data, 1024);
- close()/shutdown()
关闭socket 连接可以使用close()函数实现,函数的作用是关闭已经打开的socket 连接,内核会释放相关的资源, 关闭套接字之后就不能再使用这个套接字文件描述符进行读写操作了。函数shutdow()可以使用更多方式来关闭连接, 允许单方向切断通信或者切断双方的通信。函数原型如下, 第一个参数s是切断通信的套接口文件描述符, 第二个参数how表示切断的方式。
#include <sys/socket.h>
int shutdown(int s, int how);
函数shutdown()用于关闭双向连接的一部分, 具体的关闭行为方式通过参数的how设置来实现。可以为如下值:
(1) SHUT_RD: 值为0, 表示切断读, 之后不能使用此文件描述符进行读操作。
(2) SHUT_WR: 值为1, 表示切断写, 之后不能使用此文件描述符进行写操作。
(3)SHUT_RDWR: 值为2, 表示切断读写,之后不能使用此文件描述符进行读写操作,与close()函数功能相同。
函数shutdown()如果调用成功则返回0, 如果失败则返回-1, 通过ermo可以获得错误的具体信息。
服务器/客户端的简单例子
一个简单的基于TCP协议的服务器/客户端的例子, 通过本例中代码和程序构建过程的了解, 读者能够对基于TCP协议的服务器、客户端程序设计方法和过程有基本的了解, 能够进一步编写自己的程序。
例子程序分为服务器端和客户端, 客户端连接服务器后从标准输入读取输入的字符串, 发送给服务器;服务器接收到字符串后, 发送接收到的总字符串个数给客户端;客户端将接收到的服务器的信息打印到标准输出。
服务器端程序
程序的代码如下, 程序按照网络流程建立套接字、初始化绑定网络地址、将套接字与网络地址绑定、设置侦听队列长度、接收客户端连接、收发数据、关闭套接字。
#include <stdio.h>
#include <stdlib.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <netinet/in.h>
#define PORT 8888 /*侦听端口地址*/
#define BACKLOG 2 /*侦听队列长度*/
void process_conn_server(int s);
int main(int argc, char *argv[])
{
int ss,sc; /*ss为服务器的socke七描述符, SC为客户端的socket描述符*/
struct sockaddr_in server_addr; /*服务器地址结构*/
struct sockaddr_in client_addr; /*客户端地址结构*/
int err; /*返回值*/
pid_t pid; /*分叉的进行ID*/
ss = socket(AF_INET, SOCK_STREAM, 0);
if(ss < 0){
printf("socket error\n");
return -1;
}
bzero(&server_addr, sizeof(server_addr)); /*清零*/
server_addr.sin_family = AF_INET; /*协议族*/
server_addr.sin_addr.s_addr = htonl(INADDR_ANY); /*本地地址*/
server_addr.sin_port = htons(PORT); /*服务器端口*/
err = bind(ss, (struct sockaddr*)&server_addr, sizeof(server_addr));
if(err < 0){
printf("bind error\n");
return -1;
}
err = listen(ss, BACKLOG);
if(err < 0){
printf("listen error\n");
return -1;
}
/*主循环过程*/
for(;;){
int addrlen = sizeof(struct sockaddr);
sc = accept(ss, (struct sockaddr*)&client_addr, &addrlen); /*接收客户端连接*/
if(sc < 0){ /*出错*/
continue; /*结束本次循环*/
}
/*建立一个新的进程处理到来的连接*/
pid = fork(); /*分叉进程*/
if(pid == 0){ /*子进程中*/
close(ss); /*在子进程中关闭服务器的侦听*/
process_conn_server(sc); /*处理连接*/
}else{
close(sc); /*在父进程中关闭客户端的连接*/
}
}
}
/*服务器对客户端的处理*/
void process_conn_server(int s)
{
ssize_t size = 0;
char buffer[1024]; /*数据的缓冲区*/
for(;;){ /*循环处理过程*/
size = read(s, buffer, 1024); /*从套接字中读取数据放到缓冲区buffer中*/
if(size == 0){ /*没有数据*/
return;
}
/*构建响应字符,为接收到客户端字节的数量*/
sprintf(buffer, "%d bytes altogether\n", size);
write(s, buffer, strlen(buffer)+1); /*发给客户端*/
}
}
在主循环过程中为了方便处理, 每个客户端的连接请求服务器会分叉一个进程进行处理。函数fork()出来的进程继承了父进程的属性, 例如套接字描述符, 在子进程和父进程中都有一套。为了防止误操作, 在父进程中关闭了客户端的套接字描述符, 在子进程中关闭了父进程中的侦听套接字描述符。一个进程中的套接字文件描述符的关闭,不会造成套接字的真正关闭, 因为仍然有一个进程在使用这些套接字描述符, 只有所有的进程都关闭了这些描
述符,Linux 内核才释放它们。在子进程中, 处理过程通过调用函数process_conn_ server()来完成。
客户端程序
客户端的程序十分简单, 建立一个流式套接字后, 将服务器的地址和端口绑定到套接字描述符上。然后连接服务器, 进程处理。最后关闭连接。
#include <stdio.h>
#include <stdlib.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <netinet/in.h>
#define PORT 8888 /*侦听端口地址*/
void process_conn_client(int s);
int main(int argc, char *argv[])
{
int s; /*s为socket描述符*/
struct sockaddr_in server_addr; /*服务器地址结构*/
int err; /*返回值*/
s = socket(AF_INET, SOCK_STREAM, 0); /*建立一个流式套接字*/
if(s < 0){ /*出错*/
printf("socket error\n");
return -1;
}
/*设置服务器地址*/
bzero(&server_addr, sizeof(server_addr)); /*清零*/
server_addr.sin_family = AF_INET; /*协议旂*/
server_addr.sin_addr.s_addr = htonl(INADDR_ANY); /*本地地址*/
server_addr.sin_port = htons(PORT); /*服务器端口*/
/*将用户输入的字符串类型的IP地址转为整型*/
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
/*连接服务器*/
connect(s, (struct sockaddr*)&server_addr, sizeof(struct sockaddr));
process_conn_client(s); /*客户端处理过程*/
close(s); /*关闭连接*/
}
/*客户端的处理过程*/
void process_conn_client(int s)
{
ssize_t size = 0;
char buffer[1024]; /*数据的缓冲区*/
for(;;){ /*循环处理过程*/
size = read(0, buffer, 1024); /*从标准输入中读取数据放到缓冲区buffer中*/
if(size > 0){ /*读到数据*/
write(s, buffer, size); /*发送给服务器*/
size = read(s, buffer, 1024); /*从服务器读取数据*/
write(1, buffer, size); /*写到标准输出*/
}
}
}
测试UDP程序
将服务器端的代码存到server.c 文件中, 将客户端的代码存放到client.c文件中。按照如下方式进行编译:
Debian#gcc -o server server.c
Debian#gcc -o client client.c
先运行服务器程序。
Debian#./server
再运行客户端的程序,并输入hello 和nihao 字符串。服务器端将客户端发送的数据进行计算并返回给客户端, 结果如下:
Debian#./client 127.0.0.1
hello
6 bytes altogether
nihao
6 bytes altogether
使用netstat 命令查询网络连接情况, 8888 是服务器的端口, 59664 是客户端的端口,服务器和客户端通过这两个端口建立了连接。
Debain#netstat
tcp 0 0 localhost:8888 localhost:59664 ESTABLISHED
tcp 0 0 localhost:59664 localhost:8888 ESTABLISHED