C 语言实现 ping 程序
来源:互联网 发布:五笔打字 知乎 编辑:程序博客网 时间:2024/05/29 11:14
C 语言实现 ping 程序
实验介绍
本项目学习用 C 语言实现 ping 命令。通过本项目中更深入地理解 TCP/IP 协议,掌握 C 语言进行网络编程的技巧方法。
一、ping 命令使用的相关 TCP/IP 协议介绍
ping 命令是用来查看网络上另一个主机系统的网络连接是否正常的一个工具。例如,执行 ping 127.0.0.1 会得到如下结果:
由上面的执行结果可以看到,ping 命令执行后显示出被测试系统主机名和相应 IP 地址、返回给当前主机的 ICMP 报文顺序号、ttl 生存时间和往返时间 rtt(单位是毫秒,即千分之一秒)。
要真正了解 ping 命令实现原理,就要了解 ping 命令所使用到的 TCP/IP 协议:ICMP 协议。
ICMP 是(Internet Control Message Protocol)Internet 控制报文协议。它是 TCP/IP 协议族的一个子协议,用于在 IP 主机、路由器之间传递控制消息。
控制消息有:目的不可达消息,超时信息,重定向消息,时间戳请求和时间戳响应消息,回显请求和回显应答消息。
ping 命令使用回显请求和回显应答消息。具体表现是向网络上的另一个主机系统发送 ICMP 报文,如果指定系统得到了报文,它将把报文一模一样地传回给发送者。
下面来看下回显请求和回显应答消息 ICMP 报文格式:
回显请求报文其中类型为 0,代码为 0。
回显应答报文其中类型为 8,代码为 0。
校验和字段:包括数据在内的整个 ICMP 协议数据包的校验和,具体实现方法会在下面详细介绍。
标识符字段:用于唯一标识 ICMP 报文,本项目使用程序的进程 id。因为如果同时在两个命令行终端执行 ping 命令的话,每个 ping 命令都会接收到所有的回显应答,所以需要根据标识符来判断回显应答是否应该接收。
序号字段:ICMP 报文的序号。
数据字段:也就是报文,本项目中我们将发送报文的时间戳放入数据字段,这样当接收到该报文应答的时候可以取出发送时间戳,将接收应答的时间戳减去发送时间戳就是报文往返时间(rtt)。提前预告一下,这里使用gettimeofday()
API函数获取时间戳,详细介绍会在函数介绍部分说明。
ICMP 报文 C 语言实现可以用下面的数据结构表示:
struct icmp{ unsigned char type; // 类型 unsigned char code; // 代码 unsigned short checksum; // 校验和 unsigned short id; // 标识符 unsigned short sequence; // 序号 struct timeval timestamp; // 时间戳 };
unsigned char 正好一字节,也就是 8bit,unsigned short 二字节,也就是 16bit,unsigned int4 字节(32bit),不过上面没用到。其中 struct timeval 类型可能有人不清楚,不过没关系,函数部分说明。
系统发送ICMP报文时会将ICMP报文作为IP的数据,也就是放入IP报文格式的数据字段,IP报文格式如下图所示:
注意上面4位首部长度和16位总长度,4位首部长度不包括数据字段,并且单位是4字节。16位总长度包括数据字段,单位是1字节。
IP报文首部C语言实现可以用下面的数据结构表示:
struct ip{ unsigned char version:4; // 版本 unsigned char hlen:4; // 首部长度 unsigned char tos; // 服务类型 unsigned short len; // 总长度 unsigned short id; // 标识符 unsigned short offset; // 标志和片偏移 unsigned char ttl; // 生存时间 unsigned char protocol; // 协议 unsigned short checksum; // 校验和 struct in_addr ipsrc; // 32位源ip地址 struct in_addr ipdst; // 32位目的ip地址};
struct in_addr
类型会函数介绍部分说明。
注意ICMP协议是IP层的一个协议,这里有人可能不清楚为什么ICMP协议是IP层的。
这是因为ICMP报文在发送给报文接收方时可能要经过若干子网,会牵涉到路由选择等问题,所以ICMP报文需通过IP协议来发送,是IP层的。
二、地址信息表示
当我们编写网络应用程序时,必然要使用地址信息指定数据传输给网络上哪个主机,那么地址信息应该包含哪些呢?
1.地址族,基于IPv4的地址族还是IPv6的地址族。
2.IP地址。
3.端口号。
为了便于记录地址信息,系统定义了如下结构体:
struct sockaddr_in{ sa_family_t sin_family; // 地址族 uint16_t sin_port; // 端口号 struct in_addr sin_addr; // 32位IP地址 char sin_zero[8]; // 不使用};
其中struct in_addr
结构体定义如下:
struct in_addr{ in_addr_t s_addr; // 32位IP地址};
in_addr_t
使用如下宏指令定义,也就是无符号整型32位。
#define in_addr_t uint32_t
但实际上,还有一种结构体也可以表示地址信息,如下所示:
struct sockaddr{ sa_family_t sin_family; // 地址族 char sa_data[14]; // IP地址和端口};
成员sa_data
保存的信息包含IP地址和端口号,剩余部分填充0。
在网络编程中,常用的是struct sockaddr_in结构体,因为相对于struct sockaddr结构体,前者填充数据比较方便。
不过网络编程接口函数定义使用的是struct sockaddr结构体类型,这是由于最先使用的是struct sockaddr结构体,struct sockaddr_in结构体是后来为了方便填充地址信息数据定义。这就出现矛盾了,不过也不用担心上面两个结构体之间是可以相互转换的。定义地址信息时使用struct sockaddr_in结构体,然后将该结构体类型转为struct sockaddr结构体类型传递给网络编程接口函数即可。
三、主机字节序与网络字节序
在不同CPU中,4字节整型数值1在内存空间的保存方式是不同的。
4字节整型数值1可用二进制表示如下:
00000000 00000000 00000000 00000001
而有些CPU则以倒序保存。
00000001 00000000 00000000 00000000
所以,如果发送主机与接收主机CPU字节序不一样则就会出现问题。
引申上面的问题,这就涉及到CPU解析数据的方式,其方式有2种:
大端序(Big Endian):高位字节存放到低位地址。
小端序(Little Endian):高位字节存放到高位地址。由于不同CPU字节序不一样,因此,在通过网络传输数据时约定统一方式,这种约定称为网络字节序(Network Byte Order),非常简单——统一为大端序。
所以,进行网络传输数据前,需要先把数据数组转化为大端序格式再进行网络传输。接收到网络数据后,需要转换本机字节序格式然后进行后续处理。不过,幸运地是这些工作不需要我们自己完成,系统会自动转换的。
我们唯一需要转换的是向struct sockaddr_in结构体变量填充IP地址和端口号数据的时候。当然,系统已经提供了一些函数,只需调用相应函数即可。
转换字节序的函数有:
unsigned short htons(unsigned short);unsigned short ntohs(unsigned short);unsigned long htonl(unsigned long);unsigned long ntohl(unsigned long);
上面的函数非常简单,通过函数名就能知道它们的功能,htonl/htons中的h代表主机(host)字节序,n代表网络(network)字节序,s指的是short,l指的是long(需要注意一下,linux中long类型只占4个字节,跟int类型一样)。
下面通过简单的示例熟悉一下上面的函数,代码如下所示(赶快动手敲一下吧^_^):
#include <stdio.h>#include <arpa/inet.h>int main(void){ unsigned short hosts = 0x1234; unsigned short nets; unsigned long hostl = 0x12345678; unsigned long netl; nets = htons(hosts); netl = htonl(hostl); printf("Host ordered short: %#x \n", hosts); printf("Network ordered short: %#x \n", nets); printf("Host ordered long: %#lx \n", hostl); printf("Network ordered long: %#lx \n", netl); return 0;}
将上面代码写入order.c文件中,然后编译执行,结果如下。
从上面执行结果可以看出,主机字节序与网络字节序不一样,是小端序的。大家通过上面的程序也可以判断自己主机是大端序的还是小端序的。多说一句,Intel系列CPU采用的都是小端序标准。
四、函数介绍
首先介绍第一个函数gettimeofday()
。
#include <sys/time.h>int gettimeofday(struct timeval *tv, struct timezone *tz);
该函数的作用是把当前的时间放入struct timeval结构体中返回。
注意:
1.精确级别,微妙级别
2.受系统时间修改影响
3.返回的秒数是从1970年1月1日0时0分0秒开始
其参数tv是保存获取时间结果的结构体,参数tz用于保存时区结果。
结构体timeval的定义为:
struct timeval{ long int tv_sec; // 秒数 long int tv_usec; // 微秒数}
结构体timezone的定义为:
struct timezone{ int tz_minuteswest;/*格林威治时间往西方的时差*/ int tz_dsttime; /*DST 时间的修正方式*/}
timezone 参数若不使用则传入0即可,本项目传入0。
下面通过计算程序运行时间的一个简单示例感受一下gettimeofday函数的使用,大家可以先动手自己实现一下,然后再看看下面的实现(赶快动手敲一下吧^_^)。
/** * 本程序实现计算程序运行时间 */#include <stdio.h>#include <string.h>#include <unistd.h>#include <sys/time.h>// 计算时间差,单位:msfloat timediff(struct timeval *begin, struct timeval *end){ int n; // 先计算两个时间点相差多少微秒 n = ( end->tv_sec - begin->tv_sec ) * 1000000 + ( end->tv_usec - begin->tv_usec ); // 转化为毫秒返回 return (float) (n / 1000);}int main(void){ struct timeval begin, end; gettimeofday(&begin, 0); // 这里让程序挂起一秒 printf("do something here..."); sleep(1); gettimeofday(&end, 0); printf("running time : %fms\n", timediff(&begin, &end)); return 0;}
从上面执行结果可以看到,大约都运行了1秒钟时间。但有时候多1毫秒,有时候又恰好1秒。这是因为系统中运行的程序不只本程序一个,有时候恰好遇到内核进行进程切换需要时间。
下面介绍inet_addr函数。
#include <arpa/inet.h>in_addr_t inet_addr(const char *string);
该函数的作用是将用点分十进制字符串格式表示的IP地址转换成32位大端序整型。
成功时返回32位大端序整型数值,失败时返回INADDR_NONE。
下面通过一个简单的示例熟悉一下上面介绍的函数。
#include <stdio.h>#include <arpa/inet.h>int main(void){ char *addr1 = "1.2.3.4"; char *addr2 = "192.168.1.1"; in_addr_t data; data = inet_addr(addr1); printf(" %s -> %#lx \n", addr1, (long)data ); data = inet_addr(addr2); printf(" %s -> %#lx \n", addr2, (long)data ); return 0;}
将上面代码写入inetaddr.c文件中,然后编译执行,结果如下。
接下来介绍inet_ntoa函数。
char * inet_ntoa(struct in_addr addr);
该函数的作用与inet_addr正好相反。将32位大端序整型格式IP地址转换为点分十进制格式。
成功时返回转换的字符串地址值,失败时返回-1。
同样下面也通过一个简单的示例熟悉一下inet_ntoa函数。
#include <stdio.h>#include <arpa/inet.h>int main(void){ struct in_addr addr1, addr2; char * str1, * str2; addr1.s_addr = htonl(0x1020304); addr2.s_addr = htonl(0xc0a80101); str1 = inet_ntoa(addr1); str2 = inet_ntoa(addr2); printf("%#lx -> %s \n", (long)addr1.s_addr, str1); printf("%#lx -> %s \n", (long)addr2.s_addr, str2); return 0;}
下面介绍另一个函数gethostbyname。
#include <netdb.h>struct hostent * gethostbyname(const char * hostname);
该函数的作用是根据域名获取IP地址。
成功时返回hostent结构体地址,失败时返回NULL指针。
struct hosten结构体定义如下:
struct hostent{ char * h_name; char ** h_aliases; char h_addrtype; char h_length; char ** h_addr_list;};
我们最关心的是h_addr_list成员,它保存的就是域名对应IP地址。由于一个域名对应的IP地址不止一个,所以h_addr_list成员是char **类型,相当于二维字符数组。
下面通过一张图来表示h_addr_list成员的参数,希望大家可以加深对hostent结构体的理解。
同样下面通过一个简单的示例熟悉一下gethostbyname函数的用法。
#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <arpa/inet.h>#include <netdb.h>int main(int argc, char *argv[]){ int i; struct hostent *host; if(argc < 2){ printf("Use : %s <hostname> \n", argv[0]); exit(1); } host = gethostbyname(argv[1]); for(i = 0; host->h_addr_list[i]; i++){ printf("IP addr %d : %s \n", i+1, inet_ntoa(*(struct in_addr *)host->h_addr_list[i])); } return 0;}
上面代码中,打印IP地址是出现了令人困惑的类型转换。
host->h_addr_list[i]
得到的是字符串指针,但该字符串指针实际指向的是struct in_addr
结构体变量地址值。
(struct in_addr *)host->h_addr_list[i]
将字符串指针转换为struct in_addr
结构体指针。由于inet_ntoa
参数类型是struct in_addr
而不是struct in_addr *
,所以用*
运算符取出struct in_addr
结构体的值。
下面介绍比较重要的函数:socket。
#include <sys/socket.h>int socket(int family, int type, int protocol);
成功时返回文件描述符,失败时返回-1。
该函数的作用是创建一个套接字。一般为了执行网络I/O,一个进程必须做的第一件事就是调用socket函数。
第一个参数family:指明套接字中使用的协议族信息。
第二个参数type:指明套接口类型,也即套接字的数据传输方式。
在常见的使用socket进行网络编程中,经常使用SOCK_STREAM和SOCK_DGRAM,也就是TCP和UDP编程。在本项目中,我们将使用SOCK_RAW(原始套接字)。
原始套接字的主要作用在三个方面:
1.通过原始套接字发送/接收ICMP协议包。
2.接收发向本级的,但TCP/IP协议栈不能处理的IP包。
3.用来发送一些自己制定源地址特殊作用的IP包(自己写IP头)。
ping命令使用的就是ICMP协议,因此我们不能直接通过建立一个SOCK_STREAM或SOCK_DGRAM来发送协议包,只能自己构建ICMP包通过SOCK_RAW来发送。
第三个参数protocol:指明协议类型。
参数protocol指明了所要接收的协议包。
如果指定了IPPROTO_ICMP,则内核碰到ip头中protocol域和创建socket所使用参数protocol相同的IP包,就会交给我们创建的原始套接字来处理。
因此,一般来说,要想接收什么样的数据包,就应该在参数protocol里来指定相应的协议。当内核向我们创建的原始套接字交付数据包的时候,是包括整个IP头的,并且是已经重组好的IP包。如下所示:
这里的数据也就是前面所说的时间戳。
但是,当我们发送IP包的时候,却不用自己处理IP首部,IP首部由内核自己维护,首部中的协议字段被设置成调用socket函数时传递给它的第三个参数。
我们发送IP包时,发送数据时从IP首部的第一个字节开始的,所以只需要构造一个如下所示的数据缓冲区就可以了。
如果想自己处理IP首部,则需要设置IP_HDRINCL的socket选项,如下所示:
int flag = 1;setsocketopt(sockfd, IPPROTO_TO, IP_HDRINCL, &flag, sizeof(int));
此时,我们需要构造如下所示的数据缓冲区。
注意,我们自己填充IP首部时,也不是填充IP首部的所有字段,而是应该将IP首部的id字段设置为0,表示让内核来处理这个字段。同时,内核还会自动完成IP首部的校验和的计算并填充。
最后介绍发送和接收IP包的两个函数:recvfrom和sendto。
#include <sys/socket.h>ssize_t recvfrom(int sockfd, void * buff, size_t nbytes, int flags, struct sockaddr * from, socklen_t * addrlen);ssize_t sendto(int sockfd, const void * buff, size_t nbytes, int flags,const struct sockaddr * to, socklen_t addrlen);
成功时返回读写的字节数,失败时返回-1。
sockfd参数:套接字描述符。
buff参数:指向读入或写出缓冲区的指针。
nbytes参数:读写字节数。
flags参数:本项目中设置为0。
recvfrom的from参数指向一个将由该函数在返回时填写数据发送者的地址信息的结构体,而该结构体中填写的字节数则放在addrlen参数所指的整数中。
sendto的to参数指向一个含有数据报接收者的地址信息的结构体,其大小由addrlen参数指定。
五、校验和算法
检验和算法在TCP/IP协议族中是比较常见的算法。IP、ICMP、UDP和TCP报文头部都有校验和字段,不过IP、TCP、UDP只针对首部计算校验和,而ICMP对首部和报文数据一起计算校验和。
检验和算法可以分成两步来实现。
首先在发送端,有以下三步: 1.把校验和字段置为0。
2.对需要校验的数据看成以16bit为单位的数字组成,依次进行二进制求和。
3.将上一步的求和结果取反,存入校验和字段。
其次在接收端,也有相应的三步:
1.对需要校验的数据看成以16bit为单位的数字组成,依次进行二进制求和,包括校验和字段。
2.将上一步的求和结果取反。
3.判断最终结果是否为0。如果为0,说明校验和正确。如果不为0,则协议栈会丢掉接收到的数据。
从上可以看出,归根到底,校验和算法就是二进制反码求和。由于先取反后相加与先相加后取反,得到的结果是一样的,所以上面的步骤都是先求和后取反。
下面用C语言来实现校验和算法,代码如下:
/** * addr 指向需校验数据缓冲区的指针 * len 需校验数据的总长度(字节单位) */unsigned short checkSum(unsigned short *addr, int len){ unsigned int sum = 0; while(len > 1){ sum += *addr++; len -= 2; } // 处理剩下的一个字节 if(len == 1){ sum += *(unsigned char *)addr; } // 将32位的高16位与低16位相加 sum = (sum >> 16) + (sum & 0xffff); sum += (sum >> 16); return (unsigned short) ~sum;}
上面的代码首先定义了一个32位无符号整型的变量sum,用来保存16bit二进制数字相加的结果,由于16bit相加可能会产生进位,所以这里使用32位变量来保存结果,其中高16bit保存的是相加产生的进位。
然后下面的while循环,对数据按16bit累加求和。
接下来的if语句判断是否还剩下8bit(一字节)。如果校验的数据为奇数个字节,会剩下最后一字节。把最后一个字节视为一个2字节数据的高字节,这个2字节数据的低字节为0,继续累加。
之后的两行代码作用是将sum高16bit的值加到低16bit上,即把累加中最高位的进位加到最低位上。(sum >> 16)将高16bit右移到低16bit,(sum & 0xffff)将高16bit全部置为0。注意,这两步都不会改变sum原来的值。
进行了两次相加可以保证sum高16bit都为0,没有进位了。
最后取反,并返回。
扩展:
为什么使用二进制反码求和,而不是原码或补码呢?
这是因为,使用反码计算校验和比较简单和快速。对于网络通信来说,最重要的就是效率和速度。
六、编码实现
第一步,首先创建原始套接字。
第二步,封装ICMP报文,向目的IP地址发送ICMP报文,1秒后接收ICMP响应报文,并打印TTL,RTT。
第三步:循环第二步N次,本项目设置为5。
第四步输出统计信息。
#include <stdio.h>#include <stdlib.h>#include <string.h>#include <unistd.h>#include <netdb.h>#include <sys/socket.h>#include <netinet/in.h>#include <arpa/inet.h>#include <sys/time.h>#include <sys/types.h>#define ICMP_ECHO 0#define ICMP_ECHOREPLY 0#define BUF_SIZE 1024#define ICMP_SIZE (sizeof(struct icmp))#define NUM 5#define UCHAR unsigned char#define USHORT unsigned short#define UINT unsigned int//icmp shujujiegoustruct icmp{UCHAR type; //lei xingUCHAR code; //dai maUSHORT checksum; //jian yan heUSHORT id; //biao shi fuUSHORT sequence; // xu lie haostruct timeval timestamp; };struct ip{#if __BYTE_ORDER == __LITTLE_ENDIANUCHAR hlen:4;UCHAR version:4;#endif#if __BYTE_ORDER == __BIG_ENDIANUCHAR version:4;UCHAR hlen:4;#endifUCHAR tos;USHORT len;USHORT id;USHORT offset;UCHAR ttl;UCHAR protocol;USHORT checksum;struct in_addr ipsrc;struct in_addr ipdst;};char buf[BUF_SIZE] = {0};USHORT checksum(USHORT *, int);float timediff(struct timeval *, struct timeval *);void pack(struct icmp *, int);int unpack(char *, int , char *);int main(int argc, char *argv[]){struct hostent * host;struct icmp *icmpsend;struct sockaddr_in from;struct sockaddr_in to;int fromlen = 0;int sockfd;int nsend = 0;int nreceived = 0;int i, n;in_addr_t inaddr;memset(&from, 0, sizeof(from));memset(&to, 0, sizeof(to));if (argc < 2){printf("usage: %s hostname/IP address\n", argv[0]);exit(1);}if ((sockfd = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)) == -1){printf("socket() error!\n");exit(1);}to.sin_family = AF_INET;if (inaddr = inet_addr(argv[1]) == INADDR_NONE){if ((host = gethostbyname(argv[1])) == NULL){printf("gethostbyname() error!\n");exit(1);}to.sin_addr = *(struct in_addr *)host->h_addr_list[0];}else{to.sin_addr.s_addr = inaddr;}printf("PING %s(%s):%d bytes of data.\n", argv[1], inet_ntoa(to.sin_addr), (int)ICMP_SIZE);for (i = 0; i < NUM; i++){nsend++;memset(&icmpsend, 0, ICMP_SIZE);pack(&icmpsend, nsend);if (sendto(sockfd, &icmpsend, ICMP_SIZE, 0, (struct sockaddr *)&to, sizeof(to)) == -1){printf("sendto error()!\n");continue;}if ((n = recvfrom(sockfd, buf, BUF_SIZE, 0, (struct sockaddr *)&from, &fromlen)) < 0){printf("recvfrom error()!\n");continue;}nreceived++;if (unpack(buf, n, inet_ntoa(from.sin_addr)) == -1){printf("unpack() error!\n");}sleep(1);}printf("--- %s ping statistics ---\n", argv[1]);printf("%d packets transmitted, %d received, %%%d packet loss\n", nsend, nreceived, (nsend-nreceived)/nsend*100);return 0;}USHORT checksum(USHORT *addr, int len){UINT sum = 0;while(len > 1){sum += *addr++;len -= 2;}if (len == 1){sum += *(UCHAR*)addr;}sum = (sum >> 16) + (sum & 0xffff);sum += (sum >> 16);return (USHORT)~sum;}float timediff(struct timeval *begin, struct timeval *end){int n;n = (end->tv_sec - begin->tv_sec) * 1000000 + (end->tv_usec - begin->tv_usec);return (float)(n/1000);}void pack(struct icmp *icmp, int sequence){icmp->type = ICMP_ECHO; icmp->code = 0; icmp->checksum = 0; icmp->id = getpid(); icmp->sequence = sequence; gettimeofday(&icmp->timestamp, 0);icmp->checksum = checksum((USHORT*)icmp, ICMP_SIZE);}int unpack(char *buf, int len, char *addr){int ipheadlen;struct ip *ip;struct icmp *icmp;float rtt;struct timeval end;ip = (struct ip*)buf;ipheadlen = ip->hlen << 2;icmp = (struct icmp *)(buf + ipheadlen);len -= ipheadlen;if (len < 8){printf("ICMP packet's length is less than 8!\n");return -1;}if (icmp->type != ICMP_ECHOREPLY || icmp->id != getpid()){printf("icmp packet are not send by us!\n");return -1;}gettimeofday(&end, 0);rtt = timediff(&icmp->timestamp, &end);printf("%d bytes from %s: icmp_seq=%d ttl=%d rtt=%f ms\n", len, addr, icmp->sequence, ip->ttl, rtt);return 0;}
- C语言实现PING程序
- ping程序-c语言实现
- C 语言实现 ping 程序
- 用C语言实现Ping程序功能
- 用C语言实现Ping程序功能
- 用C语言实现ping程序
- 用C语言实现Ping程序功能
- 用C语言实现Ping程序功能
- 用C语言实现Ping程序功能
- 基于C语言实现的Ping程序
- 基于C语言实现的Ping程序
- 用C语言实现Ping程序功能
- 用C语言实现Ping程序功能
- 用C语言实现Ping程序功能
- 用C语言实现Ping程序功能
- 用C语言实现Ping程序功能
- 用C语言实现Ping程序功能
- 用C语言实现Ping程序功能
- idea常用快捷键
- EventBus主页面发送消息其他页面接收消息
- OkHttp 的基本数据请求 步骤 模板 (同时包裹异步请求和同步请求)
- 谈话的艺术-参与性技术
- 加密解密技术基础、PKI及私有CA
- C 语言实现 ping 程序
- -strong em标签 span q
- MongoDB数据库免费代理获取
- Erlang笔试题
- 水仙花数/四叶玫瑰数
- Oracle排序nulls last,nulls first ,nvl,case
- 八大排序算法
- 在Windows下编译多种VS版本的Skia
- CorelDraw衣装这样设计才是最简便的方法了