基本TCP socket编程
1. 介绍
一个典型的TCP客户端和服务端的时间轴图标:
2. socket函数
为了执行网络I/O, 首先就是要调用socket函数来,来声明我们连接协议类型(使用IPv4,IPv6等)。
#include <sys/socket.h>
int socket (int family, int type, int protocol);
Returns: non-negative descriptor if OK, -1 on error
family指定了协议类型
type
protocol
不是所有的family和type的组合都是有效的,下图显示了有效的组合
当socket函数调用成功之后,返回一个非负整数值,类似一个文件描述符,我们称之为套接字描述符,sickfd。我们只需要指定family和type参数来获得socket描述符。
3. connect函数
connect 函数用来从TCP客户端与TCP 服务器建立连接。
#include<sys/socket.h>
intconnect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen);
Returns:0 if OK, -1 on error
sockfd参数是之前调用socket函数返回的套接字描述符。第二个和第三个参数是socket地址结构体的指针和大小。这个结构体指针必须包含IP地址和服务器的端口号,下面先看一个例子,来自参考书第一章中的daytime。
intro/daytimetcpcli.c
1 #include "unp.h"
2 int
3 main(int argc,char **argv)
4 {
5 int sockfd, n;
6 char recvline[MAXLINE + 1];
7 struct sockaddr_in servaddr;
8 if (argc != 2)
9 err_quit("usage: a.out<IPaddress>");
10 if ( (sockfd= socket(AF_INET, SOCK_STREAM, 0)) < 0)
11 err_sys("socket error");
12 bzero(&servaddr, sizeof(servaddr));
13 servaddr.sin_family = AF_INET;
14 servaddr.sin_port = htons(13); /*daytime server */
15 if(inet_pton(AF_INET, argv[1], &servaddr.sin_addr) <= 0)
16 err_quit("inet_pton error for %s", argv[1]);
17 if(connect(sockfd, (SA *) &servaddr, sizeof(servaddr)) < 0)
18 err_sys("connect error");
19 while ( (n =read(sockfd, recvline, MAXLINE)) > 0) {
20 recvline[n] = 0; /* null terminate */
21 if(fputs(recvline, stdout) == EOF)
22 err_sys("fputs error");
23 }
24 if (n <0)
25 err_sys("read error");
26 exit(0);
27 }
connect函数初始化了TCP的3步握手,只有当连接被建立或者发生错误时,才被返回。有如下可能的错误发生:
a. 如果TCP客户端接受到没有回应,返回ETIMEDOUT。
b. 如果服务器给客户端的回应是一个RST,这表明了没有进程在服务端指定的端口号在等待建立连接。返回ECONNREFUSED。
c. 远端服务器没有收到返回EHOSTUNREACH 或者 ENETUNREACH。
我们可以使用上面的代码区测试一下一上的一些错误的发生。
4. bind函数
bind函数分配一个本地的协议地址给socket,根据互联网协议,地址是由一个32位的IPv4地址或者128位的IPv6地址组成,还有一个16位的TCP或者UDP端口号组成。
#include<sys/socket.h>
intbind (int sockfd, const struct sockaddr *myaddr, socklen_t addrlen);
Returns:0 if OK,-1 on error
第二个参数是制定协议地址的指针,第三个参数是这个地址结构体的大小。TCP协议中调用bind函数我们需要制定端口号,ip地址。
服务端要绑定一个已知的端口,如果tcp客户端或者服务端不选择一个端口,内核会提供一个短暂的端口给socket。
一个进程绑定一个制定的ip地址给套接口(socket),这个ip地址必须是属于一个主机的接口,对于一个tcp客户端来说,这个制定的ip地址源会被用来给ip数据包发送在套接口上。对于一个tcp服务端来说,这个ip地址会限制套接口来接受从客户端发来的连接。
以下列出了制定ip地址和端口号给bind之后返回的结果,
如果我们制定一个0端口号,内核会选择一个短暂的端口号来绑定。但是如果我们制定一个通配的ip地址,内核就不会选择本地的ip地址知道socket被建立起来。
这个通配的地址给bind函数,INADDR_ANY
struct sockaddr_in servaddr;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); /* wildcard */
下面是一个daytime客户端使用bind的例子
intro/daytimetcpsrv.c
1 #include "unp.h".
2 #include <time.h>
3 int
4 main(intargc, char **argv)
5 {
6 int listenfd, connfd;
7 struct sockaddr_in servaddr;
8 char buff[MAXLINE];
9 time_t ticks;
10 listenfd =Socket(AF_INET, SOCK_STREAM, 0);
11 bzeros(&servaddr, sizeof(servaddr));
12 servaddr.sin_family = AF_INET;
13 servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
14 servaddr.sin_port = htons(13); /* daytime server */
15 Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));
16 Listen(listenfd, LISTENQ);
17 for ( ; ; ){
18 connfd =Accept(listenfd, (SA *) NULL, NULL);
19 ticks =time(NULL);
20 snprintf(buff, sizeof(buff), "%.24s\r\n", ctime(&ticks));
21 Write(connfd, buff, strlen(buff));
22 Close(connfd);
23 }
24 }
5.listen 函数
listen只被TCP server调用,listen执行2个动作:
1)当一个socket函数被调用来建立一个socket,代表一个socket被激活,也就是说客户端的socket会调用connect函数。函数listen将未连接的套接口转化成被动套接口,指示内核应接受指向此套接口的连接请求。如下图,调用listen函数使得套接口从closed状态转化到listen状态。
2)第二个参数是指定了内核为这个套接口最大的排队数,也就是最多的连接数。
#include<sys/socket.h>
#intlisten (int sockfd, int backlog);
Returns:0 if OK, -1 on error
这个函数一般在调用了socket和bind之后被调用,必须在accept函数被调用之前执行。
为了理解第二个参数backlog,我们必须意识到,作为一个正在监听的套接口,内核维护了2个队列:
1)未完成连接的队列(anincomplete connection queue)为每个这样的SYN分节开设一个条目:已由客户发出并到达服务器,服务器正在等待完成相应的TCP三路握手过程。这些套接口都处于SYN_RCVD状态。
2)已完成连接队列(acompleted connection queue)为每个已完成TCP三路握手过程的客户开设一个条目。这些套接口都处于ESTABLISHED状图,可参考上面的图。
监听套接口的这两个队列
当一个已完成连接队列被建立起来,从监听的套接口传过来的参数被copy到新建立的连接。建立连接的机制是自动完成的,服务端的进程没有被调用到。
2个队列建立连接时所交换的分组
当客户端发送一个SYN到服务端,TCP建立一个新的未完成连接队列,然后回应三路握手的第二个分节,也就是响应SYN,并且附带对客户SYN的ACK。这个连接将一直保持到未连接队列的第三个三路握手步骤到达,或者知道timeouterror。如果三路握手正常完成了,这个未完成连接队列会转向为已完成连接队列。当client进程执行accept函数,已完成队列中的对头条目返回给进程,当对列为空时,进程将睡眠,知道有条目放入已完成连接队列才唤醒它。
一个listen的封装函数
lib/wrapsock.c
137 void
138 Listen (int fd, int backlog)
139 {
140 char *ptr;
141 /*can override 2nd argument with environment variable */
142 if ( (ptr =getenv("LISTENQ")) != NULL)
143 backlog= atoi (ptr);
144 if (listen(fd, backlog) < 0)
145 err_sys("listen error");
146 }
6.accept函数
当TCP服务端返回下一个从头部已完成连接队列的已完成连接是调用,如果已完成队列是空,那么进程将睡眠。
#include<sys/socket.h>
intaccept (int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);
Returns:non-negative descriptor if OK, -1 on error
cliaddr和addrlen这2个参数被用来返回客户端进程的协议地址。
如果accept调用成功,就会返回一个由内核自动生成的描述符。新的描述符是根据客户端的TCP连接而定的。我们把accept的返回值称为已连接套接口,存在于服务端的生命周期中。
这个函数返回3个可能的值:一个整型值,要么是新的套接口描述符,要么是一个错误值。如果我们对于客户端的协议地址没兴趣的话,可以把cliaddr和addrlen的指针都设置为NULL。
下面是一个实例,其中把第二个和第三个参数都不是NULL,然后我们借助于传进去的第二个和第三个参数来打印出客户端的ip地址和端口号。
intro/daytimetcpsrv1.c
1 #include "unp.h" 2
2 #include <time.h>
3 int
4 main(int argc,char **argv)
5 {
6 int listenfd, connfd;
7 socklen_t len;
8 struct sockaddr_in servaddr, cliaddr;
9 char buff[MAXLINE];
10 time_t ticks;
11 listenfd =Socket(AF_INET, SOCK_STREAM, 0);
12 bzero(&servaddr, sizeof(servaddr));
13 servaddr.sin_family = AF_INET;
14 servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
15 servaddr.sin_port = htons(13); /*daytime server */
16 Bind(listenfd,(SA *) &servaddr, sizeof(servaddr));
17 Listen(listenfd, LISTENQ);
18 for ( ; ; ){
19 len =sizeof(cliaddr);
20 connfd =Accept(listenfd, (SA *) &cliaddr, &len);
21 printf("connection from %s, port %d\n",
22 Inet_ntop(AF_INET, &cliaddr.sin_addr, buff, sizeof(buff)),
23 ntohs(cliaddr.sin_port));
24 ticks =time(NULL);
25 snprintf(buff, sizeof(buff), "%.24s\r\n", ctime(&ticks));
26 Write(connfd, buff, strlen(buff));
27 Close(connfd);
28 }
29 }
可以结合之前的server和client执行,会发现打印出client的ip地址和端口号,而且要注意的是,我们的server端程序,必须使用超级用户运行,因为只超级用户才能绑定预留的端口13,不然会发生Permission denied错误。
7. fork 和exec函数
在我们要讨论下一节中要讲到的写一个并发的server端程序之前,我们必须先要了解下UNIX的fork函数。在UNIX中这个只有这个方法来创建一个进程。
note:fork知识恶补:http://blog.csdn.net/zhangjie201412/article/details/7695159
#include<unistd.h>
pid_tfork(void);
Returns:0 in child, process ID of child in parent, -1 on error
如果你之前从来都没有见过这个函数,理解fork函数的难点在于fork被调用的时候返回2个返回值。一个返回是当前进程(父进程)的进程ID,还在紫禁城中返回,返回值是0.所以,根据返回值判断进程是子进程还是父进程。
为什么在子进程中fork返回0呢,而不是父进程的进程ID呢?因为一个孩子只有一个父亲,他总是可以调用getppid来得到父进程的ID号。一个父亲,理论上来说可以拥有任意多个孩子,没有什么方法可以获得孩子的进程ID。如果一个父进程想要跟踪他的子进程,他必须记录下fork的返回值。
所有的文件描述符先要在父进程中被打开,然后再调用fork,在fork函数返回之后来共享这个描述符。这个特征被用到网络服务器总:父进程调用accept函数然后调用fork函数。已连接套接口在父进程和子进程中被共享。当然,子进程读写已连接套接口,父进程关闭这个套接口。
有2个fork的典型用法:
1)一个进程拷贝他自身,一个拷贝用来执行自己的操作,另外一个拷贝用来执行另外的任务。这个典型的用法用在网络服务器中,后面会讲到。
2)一个进程执行另外的一个程序。既然只有fork可以创建一个新的进程,那么进程首先调用fork来拷贝自身,一个拷贝(子进程)调用exec来待机自己执行新的程序。这个典型用法被用在shell中。
The execfunction
#include <unistd.h> |
int execl (const char *pathname, const char *arg0, ... /* (char *) 0 */ ); |
int execv (const char *pathname, char *const argv[]); |
int execle (const char *pathname, const char *arg0, ... |
/* (char *) 0, char *const envp[] */ ); |
int execve (const char *pathname, char *const argv[], char *const envp[]); |
int execlp (const char *filename, const char *arg0, ... /* (char *) 0 */ ); |
int execvp (const char *filename, char *const argv[]); |
All six return: -1 on error, no return on success |
这6个exec函数的不同之处:
a) 可执行文件被制定为一个文件名或者路径名
b) 新程序的参数是一一列出还是由一个指针数组来索引
c) 调用进程的环境传递给新程序还是指定新环境
下图为这6个函数之间的关系,一般来说,只有execve是一个内核的系统调用,其他5个都是调用execve的库函数。
8. 并发的server
在介绍accept的时候我们的例子是一个迭代的server。对于像例子这么简单的时间,这足够了。但是当一个客户端的要求占用很长的时间的话,就会有问题了。我们就不想要一个server对应一个client,我们需要的是多个client同时来访问。最简单的就是写一个并发的server没使用UNIX的fork函数来给每一个client创建一个子进程。
pid_t pid;
int listenfd, connfd;
listenfd = Socket( ... );
/* fill insockaddr_in{} with server's well-known port */
Bind(listenfd, ... );
Listen(listenfd, LISTENQ);
for ( ; ; ) {
connfd = Accept(listenfd, ... ); /* probably blocks*/
if( (pid =Fork()) == 0) {
Close(listenfd); /* childcloses listening socket */
doit(connfd); /* process therequest */
Close(connfd); /* done withthis client */
exit(0); /* childterminates */
}
Close(connfd); /* parentcloses connected socket */
}
当建立一个连接,accept返回,server调用fork,子进程处理客户端,父进程等待另一个连接。到子进程得到一个新的client之后父进程关闭已连接的socket。
下面我们来形象的描述上面那段代码
上图显示了client端的状态和server调用accept阻塞之后client去调用connect来请求连接。
Server调用accept之后返回connfd已连接套接口,现在可以进行数据的read/write了。
上图就是并发的server调用fork来创建进程。
这里要注意的是listenfd 和connfd 在子进程和父进程中被共享。
然后这里就是父进程关闭已连接套接口,紫禁城关闭监听套接口。
9. close 函数
#include <unistd.h> |
int close (int sockfd); |
Returns: 0 if OK, -1 on error |
10. getsocketname 和 getpeername 函数
第一个函数返回的是本地协议地址,第二个返回的是远端的协议地址。
#include <sys/socket.h> |
int getsockname(int sockfd, struct sockaddr *localaddr, socklen_t *addrlen); |
int getpeername(int sockfd, struct sockaddr *peeraddr, socklen_t *addrlen); |
Both return: 0 if OK, -1 on error |
这两个函数被调用的地方:
a) 当client端connect函数被成功调用之后,getsocketname会返回本地的ip地址和本地的端口号。
b) 当使用0端口号调用bind函数之后,getsocketname返回本地端口号。
c) getsocketname被调用可以得到套接口的地址簇。(如下面的实例代码)
d) 在捆绑了一个通配IP地址的TCP服务器上,一旦与Client端建立了连接,就可以调用getsocketname函数来获得分配给这个连接的本地IP地址。在这样的调用中套接口描述子参数必须是已连接套接口描述子,而不是监听套接口描述子。
e) 当一个服务器调用accept的进程调用exec启动执行时,他获得clent身份的唯一途径就是调用getpeername。守护进程inetd fork和exec 一个TCP服务器时就是这么做的。
得到一个套接口地址簇实例
lib/sockfd_to_family.c
1 #include "unp.h"
2 int
3sockfd_to_family(int sockfd)
4 {
5 struct sockaddr_storage ss;
6 socklen_t len;
7 len = sizeof(ss);
8 if (getsockname(sockfd, (SA *) &ss,&len) < 0)
9 return (-1);
10 return(ss.ss_family);
11 }
11. 总结
所有的客户端和服务端都是从调用socket开始的,返回一个套接口描述符,然后client调用connect,server调用bind、listen和accept。套接口一般由标准的close函数关闭,当然可以调用shutdown。
多数TCP服务器是与调用fork来处理每个客户连接的服务器并发执行的。