共有回帖数 0 个
-
[0] sentinel
首先谴责下咱家万能的vim,竟然不支持中文。嘛,反正写程序又不用中文。
这篇tutorial仅供快速入手。如果希望达到〔训练有素〕之程度,请一定要学习网络原理和经典的网络编程教材。
推荐Tanenbaum的《计算机网络》,W.Stevens的《TCP/IP详解》(三卷)和《UNIX网络编程》(两卷)。
在开始tutorial之前,请确保必要的开发环境已经部署好了。win的库不熟悉,略过。
*nix下,请先高呼whereis gcc, whereis make, whereis perl
如果都存在,也请尽量部署一种你熟悉/喜欢的数据库系统。一部分服务器的构建需要数据库(这比解析和维护CSV要实用很多)。
我使用mysql, 如果你部署了其他软件,请修改代码中的DBI部分。(出于方便而不是性能考虑,大部分DBI是用perl实现的)
另外,你可能需要一些诸如telnet, tcpdump和wireshark(旧一些的版本称作ethereal)之类的软件辅助分析/调试。
在我所见过的Linux发布版本上,telnet都包括在了基本安装中。tcpdump, libpcap和wireshark很容易从安装源或者其他站点获得。
强烈建议在了解协议的时候,使用tcpdump抓取报文进行细致的案例分析。这一强调来自W.Stevens.
tutorial中的示例程序均使用BSD套接字。
[1] 前导常识
在涉及到〔编程〕之前,稍微提几个概念和模型。
这篇tutorial的基本模型是TCP/IP协议族,但是只涉及应用层和传输层(也即协议栈最顶端两层)。
你可以认为通讯模型像是下面这个样子:
+---------+ +---------+
| 应用层 | | 应用层 |
+---------+ +---------+
| |
| |
+---------+ +---------+
| 传输层 |-sock------sock-| 传输层 |
+---------+ +---------+
这里的应用层协议一般是开发者(好吧,就是你)自行规定,传输层协议可能用到TCP和UDP.
[1.1] IP地址和端口
这是两个经常提到,但是也经常不明就里的概念。这里的IP地址(下面简称IP,请不要与同名协议混淆)均指IPv4.
很多时候我们并不会问:IP是什么?或者,一个简单的理解是〔IP标识主机〕。不过并非尽然。
实际上,IP意味着〔通路〕,亦即到达一台主机的道路。主机可以同时拥有多个IP(以后也许会在STUN相关的部分里涉及到使用多IP的服务器)。
〔端口〕则是一个对新人更为模糊的概念。实际上,报文到达主机之后自然地要发送给某个进程,具体给〔哪个〕进程的标识即是端口。
举一个例子。一般的web服务器运行在80端口上(总有人喜欢用8080……无所谓)。发送给此服务器的报文会标明〔送往80端口〕,传输层负责完成实际的分发工作。
如果还不是很清晰,请参考一本网络原理教材。
[1.2] 传输层和套接字
传输层不表。请自行查考。
所谓〔套接字〕,写作socket, 读做〔插座〕,在上面的模型中位于传输层之间。数据通过套接字发送,经过链路到达另一方的套接字,递交给传输层...等等。
有一点*nix背景的读者可以认为socket等同于一个文件描述符(如果不想理解得多么深刻),实际上*nix也是这么使用套接字的。
套接字之间的通信使用IP:端口对标识。
[1.3] 传输层协议:TCP和UDP
具体内容不会涉及太多,请参考一本教材。推荐W.Stevens的TCP/IP详解。
这里只是提及几个特点。
TCP是面向连接、可靠的传输。〔面向连接〕意味着基于TCP的通信维持一对套接字之间的〔连接〕,通信主机的另一方总是固定的。
可靠意味着数据是有序、无损的。但是这在传输大文件时并不能很好地保证。
UDP相反,是无连接、不可靠的传输。基于UDP的通信中,套接字并不在乎通信另一方是谁,只要指定了信宿就可以发送报文。接受也是〔相似〕的道理。
UDP只对数据作简单校验,理论上我们需要自行检查数据是否损坏。
[2] 初步
[2.0] 简单的调试提示
假设读者在遇到简单Bug的时候懂得如何调试,在这里就不普及DEBUG常识了。
有一点需要提醒读者:网络应用程序的初步测试可以在本机完成。IP地址127.0.0.1对应本机的环回接口(loopback, 在你的系统里可能称作接口lo0)。
使用环回接口,可以在没有任何其他网络接入的情况下使用套接字进行通信。当然,环回接口的MTU比较大也请醒目。(对于快速入手来说,这不太重要)
简单的程序使用gcc编译,但多文件的工程会写makefile(但不是automake, 仅仅手写一个直供make)。没有特定情况,我不会给出makefile.
[2.1] 从功能划分的通讯模型
很遗憾还是要在理论上继续绕。从功能(或者说,角色?)看来,通讯模型可以划分成下面两种形式:
* 客户端-服务器模型。服务器在熟知的IP:端口上长期运行,客户端相对随意地启动/关闭,并向服务器发起通信。
流行的浏览器-服务器模型也属于这一范畴。
* peer-to-peer, 再或者大家耳熟能详的P2P. 用客户端-服务器模型比喻的话,每台主机既是客户端又是服务器,通信的发起方并不固定。
方便起见,现阶段的讨论以客户端-服务器模型为主。
[2.2] 使用BSD套接字
如果下降到代码层次,一个BSD套接字是通过标准的UNIX文件描述符与其他程序通信的方法。表现出来的样子就是一个文件描述符int listener_fd这样。
一个完整的套接字用下面的5个要素描述:(协议,本地地址,本地端口,远程地址,远程端口)。不过这个描述只是通用的说法,在下面会看到差别。
用于网络通信的BSD套接字常用三种类型:
* 流式套接字。提供可靠的、面向连接的数据流。这意味着一般通过TCP.
流式套接字的客户端-服务器通讯形式可能像是这样:
服务器: socket() - bind() - listen() - accept() - 阻塞,等待客户端连接 - 处理业务 - close()
客户端: socker() - connect() - 收发报文 - close()
收发报文的例程,一般使用send()和recv().
* 数据报套接字。提供不可靠、无连接的数据报传输。这意味着一般通过UDP.
数据报套接字的客户端-服务器通讯形式可能像是这样:
服务器:socket - bind - recvfrom() - 阻塞,等待客户端报文 - 处理业务 - close()
客户端:socker - 收发报文 - close()
收发报文的例程,一般使用sendto()和recvfrom().
* 原始套接字。允许对较低层协议如IP/ICMP进行访问,这里不做涉及(我说了只到传输层,大家keep out)。
[2.2.1] 一个例子
在具体介绍相关数据结构和API之前,先拿个例子混脸熟。注意示例去掉了绝大部分错误处理,但这些处理在真实的应用程序当中是高度关键的。
(服务器程序需要〔长期运行〕——所谓的24 * 7——而不是两下子就宕机了)
/* main.c */
#include "common.h"
#include "defines.h"
int main(int argc, char *argv[]){
int listen_fd, conn_fd;
struct sockaddr_in server, client;
int clilen = sizeof(struct sockaddr_in), addrlen = sizeof(struct sockaddr_in);
pid_t childpid;
char msg_recv[MAX_MSGLEN] = "";
int recvlen = -1;
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
server.sin_family = AF_INET;
server.sin_port = htons(9000);
server.sin_addr.s_addr = inet_addr("127.0.0.1");
bzero(&(server.sin_zero), 8);
bind(listen_fd, (struct sockaddr *)&server, addrlen);
listen(listen_fd, BACKLOG);
while(1){
conn_fd = accept(listen_fd, (struct sockaddr *)&client, &clilen);
switch(childpid = fork()){
case -1:
printf("fork() failed.n");
break;
case 0:
close(listen_fd);
recvlen = recv(conn_fd, msg_recv, MAX_MSGLEN, 0);
send(conn_fd, msg_recv, strlen(msg_recv), 0);
exit(0);
default:
close(conn_fd);
}
}
exit(0);
}
/* common.h */
#ifndef HAVE_COMMON_H
#define HAVE_COMMON_H
#include stdio.h
#include stdlib.h
#include string.h
#include unistd.h
#include sys/types.h
#include sys/stat.h
#include sys/socket.h
#include netinet/in.h
#include netdb.h
#include signal.h
#include fcntl.h
#endif /* HAVE_COMMON_H */
/* defines.h */
#ifndef HAVE_DEFINES_H
#define HAVE_DEFINES_H
#define MAX_MSGLEN 1024
#define BACKLOG 5
#endif /* HAVE_DEFINES_H */
这是一个简单的echo server或者练习缓冲区溢出的某个BUG程序。它在环回接口:9000端口上工作,从客户端接受一个连接请求,派生一个子进程读取客户端发来的字符串之后回送给客户端。请尽量对照代码和前面流式套接字的服务器通信形式。
下面是一个实际的交互过程(假设服务器正在运行):
varna@serv:~/workspace/documents/forumAround$ telnet 127.0.0.1 9000
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
Hello world!
Hello world!
Connection closed by foreign host.
varna@serv:~/workspace/documents/forumAround$
作为一个练习,请试图批评这个程序的缺陷。在后面的部分中,会要求读者改进这个程序,并添加一个客户端程序。
同时,读者也可以使用tcpdump观察这一通信过程中的分组交换。
[2.2.2] 套接字地址的表示
套接字地址相关的数据结构定义于socket.h中:
struct sockaddr{
unsigned short sa_family;
char sa_data[14];
};
其中sa_family代表地址族,网络通信时使用AF_INET(相对地,本地通信使用AF_UNIX,但这里不做介绍);sa_data包含了IP, 端口等等数据。
实际上我们并没有用到这个结构,而是使用一个尺寸、意义相同,但重新划分了成员从而更易于理解的结构:
struct sockaddr_in{
short int sin_family;
unsigned short int sin_port;
struct in_addr sin_addr;
unsigned char sin_zero[8];
};
sin_family对应sa_family;sin_port存储端口号(注意变换成网络字节顺序);sin_addr存储IP地址;sin_zero清零(这里用来和struct sockaddr对齐)。
特别提一下struct in_addr(究竟是联合还是结构,我也没深究过):
第一种可能的表示:
typedef struct in_addr {
union {
struct { UCHAR s_b1,s_b2,s_b3,s_b4; } S_un_b;
struct { USHORT s_w1,s_w2; } S_un_w;
ULONG S_addr;
} S_un;
#define s_addr S_un.S_addr /* can be used for most tcp & ip code */
#define s_host S_un.S_un_b.s_b2 /* host on imp */
#define s_net S_un.S_un_b.s_b1 /* network */
#define s_imp S_un.S_un_w.s_w2 /* imp */
#define s_impno S_un.S_un_b.s_b4 /* imp # */
#define s_lh S_un.S_un_b.s_b3 /* logical host */
} IN_ADDR, *PIN_ADDR, FAR *LPIN_ADDR;
第二种可能的表示:
struct in_addr{
unsigned long s_addr;
};
总之,这个联合/结构存储了4字节长的IP地址。
下面的转换函数实现了主机字节顺序和网络字节顺序的互换。请一定使用这些函数,这样才会保证程序的可移植性。
函数名字中的h代表主机(host),n代表网络(network),s代表短整型(short),l代表长整型(long)。
比如,htons()表示〔将主机顺序转换成网络顺序,参数为短整型〕。其余不多解释,如有问题请高呼man.
可能的组合是这些:htons(), htonl(), ntohs(), ntohl().
另外的两个函数inet_addr()和inet_ntoa()完成IP地址的ASCII表示和内部表示之间的转换。ASCII表示都假设是点分十进制。
inet_addr()将一个字符串转化成内部表示,比如server.sin_addr.s_addr = inet_addr("127.0.0.1");
inet_ntoa()则将一个struct in_addr转化成点分十进制表示,比如printf("%sn", inet_ntoa(server.sin_addr));
这里请小心鳄鱼。inet_addr()在出错时返回-1,但对于4字节的IP地址这意味着广播地址255.255.255.255, 请妥善处理返回值。
[2.2.3] API提要
下面列出了常用的API,和最简单的用法。这些API并不包括DNS解析方面的内容。
socket() - 取得一个套接字描述符
#include sys/types.h
#include sys/socket.h
int socket(int domain, int type, int protocol);
一般而言,使用TCP/UDP的套接字设domain为AF_INET,type为SOCK_STREAM或SOCK_DGRAM(对应流式套接字和数据报套接字),protocol为0。
调用成功时返回一个套接字描述符,发生错误时返回-1并设置errno.
套接字的关闭使用close()(全关闭)或shutdown()(不介绍了,但是可以很恶毒)。
bind() - 将套接字绑定到机器上一个IP:端口对,以便远程主机发起通信
#include sys/types.h
#include sys/socket.h
int bind(int sockfd, struct sockaddr *my_addr, int addrlen);
sockfd是待绑定的套接字描述符,my_addr指向一个本地套接字地址结构(一般会是struct sockaddr_in,它给出了协议族、IP和端口),addrlen请设为本地struct sockaddr的长度。
调用错误时返回-1并设置errno.
connect() - 利用已有的(流式)套接字建立从本地客户端到服务器的连接
#include sys/types.h
#include sys/socket.h
int connect(int sockfd, struct sockaddr *serv_addr, int addrlen);
sockfd指明套接字,serv_addr给出远程服务器的套接字地址,addrlen应该是sizeof(struct sockaddr), 和之前的意义相仿。
调用错误时返回-1并设置errno.
listen() - 进行系统侦听请求,等待连接
#include sys/socket.h
int listen(int sockfd, int backlog);
sockfd指定进行侦听的套接字。backlog给出未经处理的连接请求队列可以容纳的最大数量,推荐的范围是5~10。
调用错误时返回-1并设置errno.
listen()之前,bind()是必要的。
accept() - 从等待队列里取出连接,创建并返回用于此连接的套接字描述符
#include sys/types.h
#include sys/socket.h
int accept(int sockfd, struct sockaddr *addr, int *addrlen);
sockfd应当是侦听中的流式套接字;addr指向一个本地结构,用于保存远程计算机的套接字地址;addrlen指向一个本地整型,其中的数据应该是sizeof(struct sockaddr): 如果远程主机的套接字地址结构长度不同,则会改变此值以反映变化。
调用错误时返回-1并设置errno,否则返回一个用于新连接的套接字。
这里多说几句。listen() - accept()是TCP区分于UDP的一个表现:即TCP面向连接。这里可以看到,建立的新〔连接〕用accept()返回的套接字标明。
如果有困惑,请参考TCP连接的建立过程。逻辑上也可以认为如果客户端占用了服务器唯一的侦听用套接字,这显然是很不合理的设置(天生的DoS本质么.DoS = Denial of Service)。
一般的TCP服务器采用并发结构,和TCP面向连接的特性直接相关。无论如何,每个工作进程应该处理一个连接,而父进程总是用于侦听,不会被长期占用以致DoS。
顺便请思考如何实现简单的DoS.后面会作为练习提出。
send() / recv() - 使用流式套接字发送/接收数据流
#include sys/types.h
#include sys/socket.h
int send(int sockfd, const void *msg, int len, unsigned int flags);
sockfd应当是一个accept()返回的连接用套接字;msg为需要发送的消息,len为其长度。flags在简单的情形下可以设为0,具体内容请参考man.
调用错误时返回-1并设置errno. 成功的调用返回已发送消息的长度:当数据太多时,send()只发送它所能处理的最大数据长度,并且认为调用者会再次调用它发送剩下的数据。保证数据全部完整地发送出去的责任在于编程者,请慎重处理(除非套接字被设置为非阻塞模式)。
int recv(int sockfd, void *buf, int len, unsigned int flags);
与send()基本对称,差别在于buf用来接收数据,而len是buf这一缓存的(最大)长度。
调用错误时返回-1并设置errno,成功的调用返回已接受消息的长度。同样存在数据太多时只接受一部分的情况,请反复调用以获得全部数据。
sendto() / recvfrom() - 使用套接字发送/接收数据报
#include sys/types.h
#include sys/socket.h
int sendto(int sockfd, const void *msg, int len, unsigned flags, const struct sockaddr *to, int tolen);
int recvfrom(int sockfd, void *buf, int len, unsigned flags, struct sockaddr *from ,int *fromlen);
前四个参数和send()/recv()含义基本一致,差别在于sockfd可以是(一般是)数据报套接字。
to/tolen, from/fromlen分别指明了远程主机的套接字地址和地址结构长度。sendto()时需要完整指定远程地址,recvfrom()则使用一个本地结构存储远程地址,fromlen预先设为sizeof(struct sockaddr)。请类比connect()和accept()。
返回值的含义和send()/recv()相同。
上面是常用的API. 利用这些结构和API,很容易读懂和写出2.2.1中的服务器程序。请自行尝试编写和调试。
如果感觉API略为繁多,可以试着记下主要的功能,实际写程序的时候再查阅手册/文档,逐步加深印象。
[2.3] 练习
这里列出了一部分练习和需要思考的问题。
A.请结合对API的了解,尽可能为2.2.1中的服务器程序添加错误处理代码,并编写一个对应的客户端程序。
B.抛开错误处理和缓冲区溢出的BUG不谈,我们认定2.2.1中的程序存在架构上的问题。请指出这一问题,并重写一个程序作出改进。
C*.拒绝服务GONGJI(Denial of Service, DoS)希望通过某种手段使得服务器无法对客户端的请求作出响应。针对在B题中所写的服务器程序,尝试设计一个DoS Attack方案。
D.了解并部署一种web服务器,尽可能分析浏览器访问本地网页过程中的分组交换。
E.如果要实现一个实时通讯(Instant Message)软件(比如QQ, MSN, Hi),你认为客户端/服务器各需要选择什么样的传输层协议?
楼主 2015-12-05 13:53 回复
Copyright © 2010~2015 直线网 版权所有,All Rights Reserved.沪ICP备10039589号
意见反馈 |
关于直线 |
版权声明 |
会员须知