Linux socket-编程入门(TCP server 端)
通常,socket编程总是Client/Server形式的,因为有了telnet,先不考虑client的程序,先写一个支持TCP协议的server端,然后用telnet作为client验证我们的程序。
TCP server端的基本流程
想象你自己是个小大佬,坐办公室(什么样的黑社会做办公室啊?可能是讨债公司吧^^)你很土,只有一个小弟帮你接电话(因为你自己的号码是不敢对外公开的)。一次通讯的流程大概应该是这样的:小弟那里的总机电话响了;小弟接起电话;对方说是你女朋友A妹;小弟转达说,“老大,你马子电话”;你说,接过来;小弟把电话接给你;你和你女朋友聊天半小时;挂电话。
分析一下整个过程中的元素。你小弟(listenSock),你需要他来监听(listen)电话;你自己(communicationSock),实际上打电话进行交流的是你自己;你的电话号码(servAddr),否则你女朋友怎么能找到你?你女朋友的电话号码(clntAddr),这个比喻有点牵强,因为事实上你接起电话,不需要知道对方的号码也可以通话(虽然事实上你应该是知道的,你不会取消了来电显示功能吧^^),但是,难道你是只接女朋友电话从来不打过去的牛人吗?这个过程中的行为(成员函数):你小弟接电话并转接给你(isAccept());你自己的通话(handleEcho())(这个行为确实比较土,只会乌鸦学舌的echo,呵呵)。
UNIX中的一切事物都是文件(everything in Unix is a file!)
这是UNIX的基本理念之一,也是一句很好的概括。比如,很多UNIX老鸟会举出个例子来,“你看,/dev/hdc是个文件,它实际上也是我的光盘……”UNIX中的文件可以是:网络连接(network connection),输入输出(FIFO),管道(a pipe),终端(terminal),硬盘上的实际文件,或者其它任何东东。
3个已经打开的fd,0:标准输入(STDIN_FILENO);1:标准输出(STDOUT_FILENO);2:标准错误(STDERR_FILENO)。(以上宏定义在<unistd.h>中)一个最简单的使用fd的例子,就是使用<unistd.h>中的函数:write(1, "Hello, World!\n", 20);,在标准输出上显示“Hello, World!”。
file和fd并非一定是一一对应的。当一个file被多个程序调用的时候,会生成相互独立的fd。这个概念可以类比于C++中的引用(eg: int& rTmp = tmp;)。
socket与file descriptor
文件是应用程序与系统(包括特定硬件设备)之间的桥梁,而文件描述符就是应用程序使用这个“桥梁”的接口。在需要的时候,应用程序会向系统申请一个文件,然后将文件的描述符返回供程序使用。返回socket的文件通常被创建在/tmp或者/usr/tmp中。我们实际上不用关心这些文件,仅仅能够利用返回的socket描述符就可以了。
收件人:全体女生。
地址:<一种地址描述方式>
事实上,在socket的通用address描述结构sockaddr中, 正是用这样的方式来进行地址描述的:
{
unsigned short sa_family;
char sa_data[14];
};
sa_family可以认为是socket address family的缩写,也可能被简写成AF(Address Family),他就好像我们例子中那个“收件人:全体女生”一样,虽然事实上有很多AF的种类,但是我们这个教程中只用得上大名鼎鼎的internet家族AF_INET。另外的14字节是用来描述地址的。这是一种通用结构,事实上,当我们指定sa_family=AF_INET之后,sa_data的形式也就被固定了下来:最前端的2字节用于记录16位的端口,紧接着的4字节用于记录32位的IP地址,最后的8字节清空为零。这就是我们实际在构造sockaddr时候用到的结构sockaddr_in(意指socket address internet):
{
unsigned short sin_family;
unsigned short sin_port;
struct in_addr sin_addr;
char sin_zero[8];
};
我想,sin_的意思,就是socket (address) internet吧,只不过把address省略掉了。sin_addr被定义成了一个结构,这个结构实际上就是:
struct in_addr
{
unsigned long s_addr;
};
in_addr显然是internet address了,s_addr是什么意思呢?说实话我没猜出值得肯定的答案,也许就是socket address的意思吧,尽管跟更广义的sockaddr结构意思有所重复了。哎,这些都是历史原因,也许我是没有精力去考究了。
看看源码实现:
![](/Images/OutliningIndicators/ContractedBlock.gif)
我们前面说到了网络分层:链路——网络——传输——应用。数据从应用程序里诞生,传送到互联网上每一层都会进行一次封装:
Data>>Application>>TCP/UDP>>IP>>OS(Driver, Kernel & Physical Address)
我们用socket重点描述的是协议,包括网络协议(IP)和传输协议(TCP/UDP)。
sockaddr重点描述的是地址,包括IP地址和TCP/UDP端口。
socket()函数
我们从TcpServer::TcpServer()函数可以看到,socket和sockaddr的产生是可以相互独立的。socket()的函数原型是:
在Linux中的实现为:
#include <sys/socket.h>
/* Create a new socket of type TYPE in domain DOMAIN, using
protocol PROTOCOL. If PROTOCOL is zero, one is chosen automatically.
Returns a file descriptor for the new socket, or -1 for errors. */
extern int socket (int __domain, int __type, int __protocol) __THROW;
第一个参数是协议簇(Linux里面叫作域,意思一样的),还是那句话,我们这篇教程用到的就仅仅是一个PF_INET(protocol family : internet),很多时候你会发现人们也经常在这里赋值为AF_INET,事实上,当前,AF_INET就是PF_INET的一个#define,但是,写成PF_INET从语义上会更加严谨。这也就是TCP/IP协议簇中的IP协议(Internet Protocol),网络层的协议。
后面两个参数定义传输层的协议。
第二个参数是传输层协议类型,我们教程里用到的宏,只有两个:SOCK_STREAM(数据流格式)和SOCK_DGRAM(数据报格式);(具体是什么我们以后讨论)
第三个参数是具体的传输层协议。当赋值为0的时候,系统会根据传输层协议类型自动匹配和选择。事实上,当前,匹配SOCK_STREAM的就是TCP协议;而匹配SOCK_DGRAM就是UDP协议。所以,我们指定了第二个参数,第三个就可以简单的设置为0。不过,为了严谨,我们最好还是把具体协议写出来,比如,我们的例子中的TCP协议的宏名称:IPPROTO_TCP。
数据的“地址”
从数据封装的模型,我们可以看到数据是怎么从应用程序传递到互联网的。我们说过,数据的传送是通过socket进行的。但是socket只描述了协议类型。要让数据正确的传送到某个地方,必须添加那个地方的sockaddr地址;同样,要能接受网络上的数据,必须有自己的sockaddr地址。
可见,在网络上传送的数据包,是socket和sockaddr共同“染指”的结果。他们共同封装和指定了一个数据包的网络协议(IP)和IP地址,传输协议(TCP/UDP)和端口号。
网络字节和本机字节的相互转换
sockaddr结构中的IP地址(sin_addr.s_addr)和端口号(sin_port)将被封装到网络上传送的数据包中,所以,它的结构形式需要保证是网络字节形式。我们这里用到的函数是htons()和htonl(),这些缩写的意思是:
n: network,网络
to: to转换
s: short,16位(2字节,常用于端口号)
l: long, 32位(4字节,常用于IP地址)
“反过来”的函数也是存在的ntohs()和ntohl()。
socket和sockaddr的创建是可以相互独立的
首先通过socket()系统调用创建了listenSock,然后通过为结构体赋值的方法具体定义了服务器端的sockaddr。这里需要补充的是说明宏定义INADDR_ANY。这里的意思是使用本机所有可用的IP地址。当然,如果你机器绑定了多个IP地址,你也可以指定使用哪一个。
socket与本机sockaddr的绑定
有时候绑定是系统的任务,特别是当你不需要知道自己的IP地址和所使用的端口号的时候。但是,我们现在是建立服务器,你必须告诉客户端你的连接信息:IP和Port。所以,我们需要指明IP和Port,然后进行绑定。
int bind(int socket, struct sockaddr* localAddress, unsigned int addressLength);
作为C++的程序员,也许你会觉得这个函数很不友好,它似乎更应该写成:
int bind_cpp_style(int socket, const sockaddr& localAddress);
我们需要通过函数原型指明两点:
1、我们仅仅使用sockaddr结构的数据,但并不会对原有的数据进行修改;
2、我们使用的是完整的结构体,而不仅仅是这个结构体的指针。(很显然光用指针是无法说明结构体大小的)
幸运的是,在Linux的实现中,这个函数已经被写为:
#include <sys/socket.h>
/* Give the socket FD the local address ADDR (which is LEN bytes long). */
extern int bind (int __fd, __CONST_SOCKADDR_ARG __addr, socklen_t __len)
__THROW;
看到亲切的const,我们就知道这个指针带入是没有“副作用”的。
监听:listen()
stream流模型形式上是一种“持续性”的连接,这就是要求信息的流动是“可来可去”的。也就是说,stream流的socket除了绑定本机的sockaddr,还应该拥有对方sockaddr的信息。在listen()中,这“对方的sockaddr”就可以不是某一个特定的sockaddr。实际上,listen socket的目的是准备被动的接受来自“所有”sockaddr的请求。所以,listen()反而就不能指定某个特定的sockaddr。
int listen(int socket, int queueLimit);
其中第二个参数是等待队列的限制,一般设置在5-20。Linux中实现为:
#include <sys/socket.h>
/* Prepare to accept connections on socket FD.
N connection requests will be queued before further requests are refused.
Returns 0 on success, -1 for errors. */
extern int listen (int __fd, int __n) __THROW;
完成了这一步,回到我们的例子,就像是让你小弟在电话机前做好了接电话的准备工作。需要再次强调的是,这些行为仅仅是改变了socket的状态,实际上我想强调的是,为什么这些函数不会造成block(阻塞)的原因。(block的概念以后再解释)
创建“通讯 ”嵌套字
这里的“通讯”加上了引号,是因为实际上所有的socket都有通讯的功能,只是在我们的例子中,之前那个socket只负责listen,而这个socket负责接受信息并echo回去。
我们现看看这个函数:
{
unsigned int clntAddrLen = sizeof(clntAddr);
if ( (communicationSock = accept(listenSock, (sockaddr*)&clntAddr, &clntAddrLen)) < 0 ) {
return false;
} else {
std::cout << "Client(IP: " << inet_ntoa(clntAddr.sin_addr) << ") connected.\n";
return true;
}
}
用accept()创建新的socket
在我们的例子中,communicationSock实际上是用函数accept()创建的。
int accept(int socket, struct sockaddr* clientAddress, unsigned int* addressLength);
在Linux中的实现为:
/* Await a connection on socket FD.
When a connection arrives, open a new socket to communicate with it,
set *ADDR (which is *ADDR_LEN bytes long) to the address of the connecting
peer and *ADDR_LEN to the address's actual length, and return the
new socket's descriptor, or -1 for errors.
This function is a cancellation point and therefore not marked with
__THROW. */
extern int accept (int __fd, __SOCKADDR_ARG __addr,
socklen_t *__restrict __addr_len);
这个函数实际上起着构造socket作用的仅仅只有第一个参数(另外还有一个不在这个函数内表现出来的因素,后面会讨论到),后面两个指针都有副作用,在socket创建后,会将客户端sockaddr的数据以及结构体的大小传回。
当程序调用accept()的时候,程序有可能就停下来等accept()的结果。这就是我们前一小节说到的block(阻塞)。这如同我们调用std::cin的时候系统会等待输入直到回车一样。accept()是一个有可能引起block的函数。请注意我说的是“有可能”,这是因为accept()的block与否实际上决定与第一个参数socket的属性。这个文件描述符如果是block的,accept()就block,否则就不block。默认情况下,socket的属性是“可读可写”,并且,是阻塞的。所以,我们不修改socket属性的时候,accept()是阻塞的。
accept()的另一面connect()
accept()只是在server端被动的等待,它所响应的,是client端connect()函数:
int connect(int socket, struct sockaddr* foreignAddress, unsigned int addressLength);
虽然我们这里不打算详细说明这个client端的函数,但是我们可以看出来,这个函数与之前我们介绍的bind()有几分相似,特别在Linux的实现中:
/* Open a connection on socket FD to peer at ADDR (which LEN bytes long).
For connectionless socket types, just set the default address to send to
and the only address from which to accept transmissions.
Return 0 on success, -1 for errors.
This function is a cancellation point and therefore not marked with
__THROW. */
extern int connect (int __fd, __CONST_SOCKADDR_ARG __addr, socklen_t __len);
connect() 也使用了const的sockaddr,只不过是远程电脑上的而非bind()的本机。
accept()在server端表面上是通过listen socket创建了新的socket,实际上,这种行为是在接受对方客户机程序中connect()函数的请求后发生的。综合起看,被创建的新socket实际上包含了listen socket的信息以及客户端connect()请求中所包含的信息——客户端的sockaddr地址。
新socket与sockaddr的关系
accept()创建的新socket(我们例子中的communicationSock,这里我们简单用newSock来带指)首先包含了listen socket的信息,所以,newSock具有本机sockaddr的信息;其次,因为它响应于client端connect()函数的请求,所以,它还包含了clinet端sockaddr的信息。
我们说过,stream流形式的TCP协议实际上是建立起一个“可来可去”的通道。用于listen的通道,远程机的目标地址是不确定的;但是newSock却是有指定的本机地址和远程机地址,所以,这个socket,才是我们真正用于TCP“通讯”的socket。
inet_ntoa()
#include <arpa/inet.h>
/* Convert Internet number in IN to ASCII representation. The return value
is a pointer to an internal array containing the string. */
extern char *inet_ntoa (struct in_addr __in) __THROW;
将IP地址,由in_addr结构转换为可读的ASCII形式的固定用法。
TCP通讯模型
TCP的Server/Client模型类似这样: ServApp——ServSock——Internet——ClntSock——ClntApp
当然,我们这里的socket指的就是用于“通讯”的socket。TCP的server端至少有两个socket,一个用于监听,一个用于通讯;TCP的server端可以只有一个socket,这个socket同时“插”在server的两个socket上。当然,插上listen socket的目的只是为了创建communication socket,创建完备后,listen是可以关闭的。但是,如果这样,其他的client就无法再连接上server了。
我们这个模型,是client的socket插在server的communication socket上的示意。这两个socket,都拥有完整的本地地址信息以及远程计算机地址信息,所以,这两个socket以及之间的网络实际上形成了一条形式上“封闭”的管道。数据包只要从一端进来,就能知道出去的目的地,反之亦然。这正是TCP协议,数据流形式抽象化以及实现。因为不再需要指明“出处”和“去向”,对这样的socket(实际上是S/C上的socket对)的操作,就如同对本地文件描述符的操作一样。但是,尽管我们可以使用read()和write(),但是,为了完美的控制,我们最好使用recv()和send()。
recv()和send()
int recv(int socket, void* rcvBuffer, unsigned int bufferLength, int flags);
在Linux中的实现为:
#include <sys/socket.h>
/* Send N bytes of BUF to socket FD. Returns the number sent or -1.
This function is a cancellation point and therefore not marked with
__THROW. */
extern ssize_t send (int __fd, __const void *__buf, size_t __n, int __flags);
/* Read N bytes into BUF from socket FD.
Returns the number read or -1 for errors.
This function is a cancellation point and therefore not marked with
__THROW. */
extern ssize_t recv (int __fd, void *__buf, size_t __n, int __flags);
这两个函数的第一个参数是用于“通讯”的socket,第二个参数是发送或者接收数据的起始点指针,第三个参数是数据长度,第四个参数是控制符号(默认属性设置为0就可以了)。失败时候传回-1,否则传回实际发送或者接收数据的大小,返回0往往意味着连接断开了。
处理echo行为
{
const int BUFFERSIZE = 32;
char buffer[BUFFERSIZE];
int recvMsgSize;
bool goon = true;
while ( goon == true ) {
if ( (recvMsgSize = recv(communicationSock, buffer, BUFFERSIZE, 0)) < 0 ) {
throw "recv() failed";
} else if ( recvMsgSize == 0 ) {
goon = false;
} else {
if ( send(communicationSock, buffer, recvMsgSize, 0) != recvMsgSize ) {
throw "send() failed";
}
}
}
close(communicationSock);
}
本小节最后要讲的函数是close(),它包含在<unistd.h>中
#include <unistd.h>
/* Close the file descriptor FD.
This function is a cancellation point and therefore not marked with
__THROW. */
extern int close (int __fd);
这个函数用于关闭一个文件描述符,自然,也就可以用于关闭socket。
默认的监听端口是5000。我们可以通过
$telnet 127.0.0.1 5000
验证在本机运行的echo server程序。