zoukankan      html  css  js  c++  java
  • 粘包、丢包及TCP信息收发

    初涉socket编程的朋友经常有下面一些疑惑: 1. 为什么我发了3次,另一端只收到2次? 2. 我每次发送都成功了,为什么对方收到的信息不完整?
    这些疑惑往往是对send和recv这两个函数理解不准确所致。send和recv都提供了一个长度参数。对于send而言,这是你希望发送的字节数,而对于recv而言,则是希望收到的最大字节数。
    1。 send
    send函数的原型是:int send(SOCKET sd, const char * buffer, int len, int flag). 其中len指出buffer中包含的实际字节数,也是程序员希望发出的最大字节数。而这个函数的返回值是实际发送出去的字节数。在网络程序中,正常情况是这个返回值小于len,也就是说buffer中的内容没有完全被发送出去。
    为了确保一个缓冲区内的内容被完全被发送出去,我们需要如下代码:
    int res; int pos = 0; //下一次发送需要开始的位置: while(pos < len) { res = send(sd, buffer + pos , len - pos, 0); if(res <=0) goto err_handler; //去错误处理 pos += res; }
    这样经过多次send,可以确保buffer内的内容都别发送出去。
    为了避免发送线程被阻塞,应该考虑把上述代码放到一个子线程中,并通过队列来缓冲所有收发。
    2. recv
    recv的原型是:int recv(SOCKET sd, char * buffer, int len, int flag),其各个参数的含义同前面send。需要注意的是,系统并不会等待bufer被填满了再返回,而是一旦有数据被收到,就立刻返回。因此不要期望实际收到的数据长度就等于len。
    你可以使用前面send的循环算法确保收到len个字节,也可以使用内容驱动的方法实现分段数据分析。不过这个就超出本文内容,也就不再赘述。
    3. 粘包 所谓粘包,是指发送端发送的两个报文,在接收端被拼在一起。由于TCP是面向流的协议,报文与报文之间是没有分界符号的。在接收端,所有的数据都逻辑上拼在一起给你。举例来说,你分10次发送10个长度为10的报文,在接收端,你可能只收到一个长度为100的报文,而不会收到10个消息。
    为了解决这个问题,你必须在接收端有能力把这些报文分隔开来。如果消息长度总是固定的,这就比较容易,只要按长度取出即可。如果长度不固定,一般有两种方法解决:
    a)使用特征字节。例如:如果是聊天程序,发送的是普通文本,一些字符是绝对不可能出现在正文中的,你可以使用这些字符做分隔符隔离不同消息。我们可以使用'\0'做分隔,一般对于全文本传输是比较安全的。
    b) 在发送方发送正文前,先发送一个长度。例如你要发送2345字节的内容,你可以先发送一个2字节的长度给对方,然后再发正文。接收放只要收到这个长度信息,就可以正确的分包。需要注意的,到底用多少字节来发送长度是应该预先约定的,一般2字节就足够,不过你约定4字节也是可以的。还要注意的是,如果接收一次报文后,解包完毕还剩下一部分内容,这些内容应该留给下次报文分包使用,而不能扔掉。
    4. 丢包 丢包一般都是由于对上面说的理解不足引起的,因为TCP本身是确保不丢包的。
    发表于 2010-01-03 13:33 馨荣家园 阅读(3369) | 评论
    (39)
    | 编辑 收藏

    2009年12月30日 #

    1. 原理分析 经常有些新C++程序员问:C++的类的成员个数是不是有限制,为什么我加一个变量后程序就死了?或者说:是不是成员变量的顺序很重要,为什么我两个成员变量顺序换一换程序就不行了?凡此种种之怪现象,往往都是内存访问越界所致。
    何谓内存访问越界,简单的说,你向系统申请了一块内存,在使用这块内存的时候,超出了你申请的范围。例如,你明明申请的是100字节的空间,但是你由于某种原因写入了120字节,这就是内存访问越界。内存访问越界的后果是:你的写入破坏了本不属于你的空间。
    下面是一个简单的例子: int a; char b[16]="abcd"; int c;
    a = 1; c = 2; printf("a=%d,c=%d\n", a,c); memset(b, 0,32); //注意这里访问越界了,你只有16字节空间,却修改了32字节 printf("a=%d,c=%d\n", a,c);
    你可以看出,在memset前后,两个printf语句打印出来的值并不一样,因为memset越界后修改了a或者c的值(由于不同编译器对变量在空间中顺序的安排可能有不同策略,因此我用两个变量,希望能抓到越界信息。对于VC,debug模式下系统添加了很多填充字节,你可能需要增加越界的数量才能看到效果)
    2. 为什么增加一个变量后程序就崩溃了? 增加一个变量后,内存中变量的布局也发生了变化。如果一个内存越界破坏了一个不含指针的结构,程序虽然逻辑不对,但是不至于崩溃。但是如果增加变量后,内存访问越界破坏了一个指针,则会导致程序崩溃。
    例如:
    int a; char b[128]; //bool c; char* d=new char[128]; int e;
    b[136] = '\0'; b[137] = '\0'; b[138] = '\0'; b[139] = '\0'; strcpy(d, "haha"); 注意, b访问越界了8个字节位置处的4个字节。如果没有c,那么越界破坏了e变量,不会导致程序崩溃。但是加上c之后,破坏的变量可能就是d了,由于指针被破坏后,一旦访问就是内存访问违例,导致程序崩溃。
    这也解释了为什么交换顺序会导致程序崩溃。如果上面情况没有变量c,你交换e和d,结构也是类似的,程序也一样要崩溃。
    3. 为什么有些情况越界了程序也没错? 这主要是说这个话的人对什么是“错”没有正确的认识。程序不是只有崩溃了才是错!你破坏了别的变量,那个变量总有被使用的时候,尽管那个变量不会导致诸如程序崩溃、报警之类的严重错误,但是其计算结果必然是错误的。你说“程序没错”,是因为你根本没有发现错误而已。这种情况甚至比程序直接崩溃还要恶劣,因为程序一旦崩溃你肯定会去查,可以在导致真正严重的问题之前就把问题解决了。而如果计算错误隐藏到很晚,你的损失就可能很大了。(例如,一颗卫星上天了,你才发现一台仪器由于软件故障无法测量真正的数据,那得多少损失?)
    4. 如何解决内存访问越界问题? 老实说没有好的方法。遇到这种问题,首先你得找到哪里有内存访问越界,而一个比较麻烦得问题在于,出现错误得地方往往不是真正内存越界得地方。对于内存访问越界,往往需要进行仔细得代码走查、单步跟踪并观察变量以及在调试环境得帮助下对变量进行写入跟踪(如VC6就有一旦变量被修改就break得机制)。
    更重要得是,程序员要养成良好的编程习惯,在修改每个数组时一定要对这个数组有多少空间有清醒的认识,否则一旦出错,找到原因是很痛苦的事情。
    发表于 2009-12-30 10:58 馨荣家园 阅读(11919) | 评论 (56) | 编辑 收藏

    2007年8月28日 #

    考虑这样一个问题:我们把2个红色球和2个白色球放进一个黑盒子里,问取出两个球颜色相同得概率是多少。
    很显然,取出两个球的组合有以下四种:RR, RW,WR,WW,因此同颜色的概率是2/4=0.5
    按照乘法原理,我们可以分两次取出。 第一次,我们取出白色的可能性为1/2,剩下的3个球再取出白色的可能性为1/3,因此,两次都取出白色的可能性为1/2 * 1/3 = 1/6 同理,都取出红色的可能性为1/6 那么,取出同种颜色的可能性应该为1/6 + 1/6=1/3
    为什么会不一样?
    发表于 2007-08-28 10:25 馨荣家园 阅读(3566) | 评论 (19) | 编辑 收藏

    2007年6月25日 #

    大家经常听到一个名词叫内存泄漏。到底怎样才会遇到内存泄漏,内存泄漏到底该怎么定位,大家却都很糊涂。实际上我对这个问题也很头疼,下面就是我关于这个问题的一些小看法:
    1. 什么叫内存泄漏? 内存泄漏是指你分配了内存,使用完毕后没有正确释放它。这样这个内存就不能再被使用。
    例如: void test() {     char * p = new char[MAX_PATH];     GetModuleFileName(NULL,p, MAX_PATH);     strcat(p+strlen(p) - 3, "txt");     CStdioFile file(p);     file.WriteString("text");     file.Close(); }
    注意:上面的指针p函数退出后就无法再释放,new char[MAX_PATH]分配的内存就泄漏了。
    2. 何时我的程序泄漏了? 从外部看,我们很难100%准确的方法去判断是否有内存泄漏。实际上,一块内存是否被泄漏,从外部看只有当进程结束才能知道。因为你完全可以从开始分配一块内存,直到结束才去释放它。
    基本上,下面行为不能说明内存泄漏: a) 任务管理器上显示内存使用增加了1MB b) 内存使用很多(我见过最大内存使用超过1.5GB的程序)
    下面行为可能意味着有内存泄漏: a) 一个进程的内存使用量按照一个固定的速度稳定的增长(例如每小时增加20MB)
    如果可以在VC IDE中运行程序,当程序退出时,系统会报一堆泄漏错误。
    3. 如何定位 从编译器角度讲,在分配内存后,任何时候释放都是合适的,因此系统不会知道你何时适合去释放内存。在进程运行过程中,你只能估计是否有内存泄漏,而不能确定一定有内存泄漏。一般来说,我们可以用以下一些办法定位:
    a) 使用性能监视器跟踪进程的内存使用情况,如果它在不断增长,且增长速度趋于稳定,一般说明在某个循环性的操作中有内存泄漏 b) 在编译器中按照Debug模式运行程序,运行一定时间后(例如2天),使用正常的方式停止进程 正常情况下,你不会收到任何和内存有关的异常。如果你程序有内存泄漏,你会收到如下错误信息:
    Detected memory leaks! Dumping objects -> D:\projects\EnumWnd\EnumWndDlg.cpp(185) : {78} normal block at 0x00421330, 100 bytes long. Data: <                > CD CD CD CD CD CD CD CD CD CD CD CD CD CD CD CD Object dump complete.
    注意,这里EnumWndDlg.cpp(185)应该是分配这块内存的地方。这是因为没有所谓的内存泄漏点,只有在某点分配的内存被泄漏了。
    4. 如何分析内存泄漏 内存泄漏分析一般是通过分析内存生命周期来进行的。你必须对你的内存的可能使用情况有所了解。在此基础上,你应该了解在那个地方内存应该被释放。如果有内存泄漏,说明程序没有走到那个地方,或者释放代码没有正确执行。因此你可能需要跟踪你代码的执行情况,通过分析为什么代码没有按照预期走到内存释放处,了解为什么内存泄漏了。
    常见的原因:
    a) 忘记释放 b) 释放的方法错误 例如:把指针加入诸如CArray之类的集合对象中,释放时应该首先通过delete操作删除指针指向的对象,然后才能去调用诸如RemoveAll函数去释放集合中的指针。单纯析构诸如CArray之类的对象,不会自动释放它所包含的指针所指向的对象。
    c) 析构函数错误:如果你对象中包含指针,那么你析构函数需要正确的释放这些指针。如果你析构函数不做这些,就会有内存泄漏 d) 线程问题:如果线程被你通过TerminateThread中止,那么它所分配的对象一般不会有机会被释放。这是那些喜欢在线程外中止线程的人经常遇到的问题。
    发表于 2007-06-25 17:19 馨荣家园 阅读(13591) | 评论 (271) | 编辑 收藏

    2007年4月29日 #

    一般而言,如果基类定义了operator new,那么派生类也必须对应定义。 考虑下面的两个类 char * pAddress; class CBase { public: static void* operator new(size_t size){return pAddress;}; static void operator delete(void * p){}; };

    class CDerive:public CBase { char buffer[1024]; public: CDerive() {   for(int i=0; i < 1024; i ++)   {    buffer[i] = i %26 + 'a';   } } }; 当调用CDerive * p = new CDerive时,编译器首先尝试匹配该类自己的new,由于没有,编译器就尝试匹配在其祖先链上的new,于是调用CBase::operator new 但是基类其实不知道派生类任何信息,它仅仅根据CBase处理,因此构造了一个错误的类对象。

    下面是我的测试代码,你可以发现在delete时程序报告p2指针被破坏,这就是因为CDerive得不到自己的1024字节内容,因此覆盖了后面的内容

    void test() { char *p1, * p2; p1 = new char[10]; memset(p1,0,10); pAddress = new char[sizeof(CBase)]; p2 = new char[10]; memset(p2,0,10); CDerive *pDerive = new CDerive; TRACE(_T("%p\n"),pDerive); delete p1; delete p2; delete pAddress; }

    发表于 2007-04-29 22:33 馨荣家园 阅读(4842) | 评论 (29) | 编辑 收藏

    2007年4月11日 #

    前一阵子,我申请部门内部调动,被其他部门的人面试了一次,面试官让我写一段代码来对一个整形数组排序,我写了下面一段代码 #define SWAP(a,b) do {\     a = a +b;\     b = a - b;\     a = a - b;\ }while(0) void sort(int number, int vData[]) {        int ii, jj;       for(ii=0; ii < number ; ii++)      {              for(jj=ii+1; jj < number ; jj ++)              {                  if(vData[ii] > vData[jj])                  {                   SWAP(vData[ii], vData[jj]);                  }              }      } }
    面试官让我优化一下代码,我又写出下面代码: #define SWAP(a,b) do {\     a = a +b;\     b = a - b;\     a = a - b;\ }while(0) void sort(int number, int vData[]) {        int ii, jj;        int min;       for(ii=0; ii < number ; ii++)      {            min = ii;              for(jj=ii+1; jj < number ; jj ++)              {                  if(vData[min] > vData[jj])                  {                    min = jj;                  }              }                           if(min != ii)              {              SWAP(vData[min],vData[ii]);              }      } }

    面试官说能不能再优化一下.其实那时候我已经一脑糨糊,啥快速排序算法啊都不会,只好对面试官说不行了. 面试官指着我那段宏说:你如果用一个中间变量进行交换,会减少很多计算,对于大的排序可以优化很多.
    感言:看起来神奇而水平高的算法不一定实用,要考虑算法的实用性.

    发表于 2007-04-11 19:12 馨荣家园 阅读(4774) | 评论 (15) | 编辑 收藏

    2006年9月23日 #

    今天下午五点无聊的拿遥控器换台,突然发现甘肃电视台正在有名家坐诊用“五联疗法”治疗大三阳。我听了几分钟,大意如下:
    黄主任:以前治好大三阳需要2-3个月就已经很快了,没想到技术发展的这么快,现在15天就可以治愈了。......
    曾主任:我们医院是卫生部....的医院,医生......,护士.....,所以患者放心前来就诊。
    在我记忆中,大三阳是很难治的,怎么这位黄医生2-3个月治好还嫌慢?现在半个月就好了?我感冒还得一星期才好,怎么肝病原来这么容易治啊?
    到网上一查大三阳,第一个帖子就是说“中国中医研究院专家提醒患者:可以使大三阳和小三阳都转阴就是一个骗局”,看来这个黄主任看来也是属于这个范畴得。老实说,我不怎么懂医学,但是大体我比较相信说黄某是骗子是肯定错不了。这篇文章似乎有介绍这是骗局得:http://www.zhongke.com/liver/006.htm
    查查五联疗法: 下面网站提到这种疗法,同一页面上说乙肝3、5年内才可能提治疗:http://www.renai.cn/zhongyi/ganbing/ 下面网站介绍是仁爱医院提出这种方法,乙肝转阴不是梦,但是目前没有有效治疗方法,只能使病毒DNA转阴(和肝病转阴有关否?)http://www.ccgs120.com/yfbj/news.jsp?info_id=10068
    看来:15天治好,只能如那位黄主任的话解释“一般15天能治好,多的也要2、3个月。当然有些人可能需要更长的时间,这就是所谓个体差异,有的人按时吃药,体质比较好,当然就快一些。有些人你问他,‘哦,昨天我去蹦的了,忘吃药了’,这样今天吃明天忘后天再吃,当然就慢一些(注意,不是不好,只是慢一点)”。我恐怕这种个体差异是100个中有0.00000001个治好,其他都差异了。真奇怪甘肃怎么还允许这种片子上电视台反复的造!
    PS:留的三个联系电话,2个北京,一个上海。本地不敢做广告,跑甘肃去,反正你被骗了也不能花很大代价到北京上海找我。
    发表于 2006-09-23 23:50 馨荣家园 阅读(3608) | 评论 (13) | 编辑 收藏

    2006年9月17日 #

    日前,我从我招行帐户向外地汇出一笔钱。两三天以后,对方仍然没有受到钱。由于招行声称最多5天到帐,倒也没有当回事情。但是一周过去后,才发现对方根本没有收到钱。连到招行专业版,发现我的存款记录中多了一条“退转帐汇款本金”,时间是汇款后的第四天。
    从这个记录中,我注意到招商银行专业版一来没有注明我的钱为什么没汇成功,二来没有把我汇款的手续费也退回来。我于是拨95555去问。95555在经过复杂的自动应答后,终于把我电话转到人工台。可是那天我等了3分钟,也没有人接电话。我以为周末他们不值班,就给他们发了封邮件,希望他们解释这两个问题。后来问了一下,第一:他们24小时有人值班,但是不知道为什么那天我的电话没人接。第二,目前他们还没有回我邮件,看来也没把我的问题当回事情。
    我以为我帐户写错了,特意打电话到对方,让他们把帐户用短信再发一遍。可是我对着我的汇款记录检查了好几遍,依然是帐户没错啊?
    我选工作时间再拨95555,询问两个问题:
    1。汇款为什么没汇出 招商银行的回答是:对方银行退款原因是无效帐户。
    2。为什么手续费没有退 我本来以为这只是系统一个失误,问之后退给我就好了。对方的回答让我很惊讶:因为我们提供了服务,所以要收费啊。
    我就问:失败的服务也收费?(后面具体话是不记得了,但是大意不会错,后面问代表是我的问话,答是银行服务员答复) 答:这是你账号填错了,又不是我们银行的错误啊? 问:但是服务没有成功,服务费当然应该退啊? 答:在跨行汇款时,我们中有人工参与,做了事情当然应该收钱 问:不是电子系统处理么? 答:在汇款之前,首先要有人工验核,然后再发到人行,人行转到x行,这手续费是三家分的,不是招行一家收钱 问:我不管谁分,只知道是你收的,不管怎么说,你的服务不成功,你就应该给我退钱。 答:但是这是你填错帐户,不是银行的过错 问:要是我查询后发现帐户没错,你是不是就退钱? 答:如果你查出没错,你可以找我,我工号是xxxx(我想,我本来就是查过后才问你的,我用什么方法证明我帐户没错啊?难道让对方把他存折复印给我?)
    后面还有一堆对话,不过似乎我和她都很激动,我就让他们经理给我打回电,她说没法保证时间,我说ok,反正有电话打过来就行。顺便问一句为什么我邮件没人回:她说电话和邮件答复系统不是一家,她不知道。
    一天以后,招行打我手机,再和我讨论: 招行:我是招行的 我:我对这个收费不退不理解,希望你们退回手续费 招行:不理解没有办法,我这只能给你解释 我:服务不成功为什么收费 招行:因为你帐户错了 我:但是我查过了,肯定对的 招行:你是怎么填的? 我:我就填四川省成都市工商银行xxxx帐户及户名啊 招行:成都市工商银行写的太泛,这样没法转帐 后面我们就各自找例子证明自己的话有理,最终由于她只解释不处理,实在谈不下去,而且我的手机费+电话费估计也超过能获得的退款了,只好挂机了事。
    事后想想,觉得银行实在是座大山,咱们实在撼她不动。其实她有明显的错误,罗列如下: 1。她说我帐户错误是因为我没有详细到具体开户行(估计要到某某营业所了) 这种说辞是站不住脚的。其实帐户号码+用户名已经足够定位到具体帐户。就如同我们打招行电话,她也只要我报帐户和密码,从来没有说在哪个营业点挂的号。
    2。按照一般人的理解,我的填法应该就对了。如果你银行有特殊要求,你必须事先说明,但是我查遍了专业版所有页面,也没看到谁提示我必须具体到哪个开户营业网点。
    3。招行没商量 我们手机收费很快就不能没商量了,但是招商银行客服第一句就是只能给你解释,不能退钱。不知道什么时候“不能没商量”
    4。遇到银行收错钱,如果不是错的离谱,还是别维权了。一来你是弱势群体,维权也是白搭,二来,你维权的费用估计也足以抵消你能收回的钱了
    5。汇钱前要注意问清具体要求,别象我一样这么白送手续费了。
    唉,真是
    服务收费本应当 失败退钱更合情 只想收钱不退钱 储户心里很受伤
    发表于 2006-09-17 00:34 馨荣家园 阅读(4890) | 评论 (30) | 编辑 收藏

    2006年5月24日 #

    http://spaces.msn.com/ronaldyan/blog/cns!FA58BC446FBB14B9!107.entry
    发表于 2006-05-24 22:00 馨荣家园 阅读(4647) | 评论 (10) | 编辑 收藏

    2005年12月7日 #

    对于一个程序员而言,学习一种语言和一种算法是非常容易的(不包括那些上学花很多时间玩,上班说学习没时间的人)。但是,任何程序都可能是有瑕疵的,尤其有过团队协作编程经验的人,对这个感触尤为深刻。

    在我前面的述及调试的文章里,我侧重于VC集成环境中的一些设置信息和调试所需要的一些基本技巧。但是,仅仅知道这些是不够的。一个成功的调试的开端是编程中的准备。

    分离错误

    很多程序员喜欢写下面这样的式子:

       CLeftView* pView =
         ((CFrameWnd*)AfxGetApp()->m_pMainWnd)->m_wndSplitterWnd.GetPane(0,0);
    

    如果一切顺利,这样的式子当然是没什么问题。但是作为一个程序员,你应该时刻记得任何一个调用在某些特殊的情况下都可能失败,一旦上面某个式子失败,那么整个级联式就会出问题,而你很难弄清楚到底哪儿出错了。这样的式子的结果往往是:省了2分钟编码的时间,多了几星期的调试时间。

    对于上面的式子,应该尽可能的把式子分解成独立的函数调用,这样我们可以随时确定是哪个函数调用出问题,进口缩小需要检查的范围。

    检查返回值

    检查返回值对于许多编程者来说似乎是一个很麻烦的事情。但是如果你能在每个可能出错的函数调用处都检查返回值,就可以立刻知道出错的函数。

    有些人已经意识到检查返回值的重要性,但是要记住,只检查函数是否失败是不够的,我们需要知道函数失败的确切原因。例如下面的代码:

    if(connect(sock, (const sockaddr*)&addr,sizeof(addr)) == SOCKET_ERROR)
    {
             AfxMessageBox("connect failed");
    }
    

    尽管这里已经检查了返回值,实际上没有多少帮助。正如很多在vckbase上提问的人一样,大概这时候只能喊“为什么连接失败啊?”。这种情况下,其实只能猜测失败的原因,即使高手,也无法准确说出失败的原因。

    增加诊断信息

    在知道错误的情况下,应该尽可能的告诉测试、使用者更多的信息,这样才能了解导致失败的原因。如果程序员能提供如下错误信息,对于诊断错误是非常有帮助的:

    1. 出错的文件:我们可以借助宏THIS_FILE和__FILE__。注意THIS_FILE是在cpp文件手工定义的,而__FILE__是编译器定义的。当记录错误的函数定义在.h中时,有时候用THIS_FILE更好,因为他能说明在哪个cpp中调用并导致失败的。
    2. 出错的行:我们可以借助宏__LINE__
    3. 出错的函数:如果设计的好,有以上两项已经足够。当然我们可以直接打印出出错的函数或者表达式,这样在大堆代码中搜索(尤其是不支持go to line的编辑器中)还是很有用的。大家可以参见我的文章http://blog.vckbase.com/arong/archive/2005/11/10/14704.html中的方式进行处理,也许是一个基本的开端。
    4. 出错的原因:出错的原因很多只能由程序自己给出。如果出错只会问别人,那么你永远不可能成为一个合格的程序设计人员。很多函数失败时都会设置errno。我们可以用GetLastError获得错误码,并通过FormatMessage打印出具体错误的文字描述。

    终了

    给初学者一个忠告:编程时麻烦10分钟,调试时省却数小时,要想省时间,还是要从代码的可重用性和可维护性上下功夫,而不是两个代码上节省。

  • 相关阅读:
    WCF 第十章 异常处理 创建并使用强类型错误
    WCF 第十章 总结
    WCF 第十章 异常处理
    WCF 第十章 异常处理 使用FaultException管理服务异常
    WCF 第十章 异常处理 通信异常细节
    哪本书是对程序员最有影响、每个程序员都该阅读的书?(转自外刊IT评论)
    WCF 第十一章 工作流服务 从WF调用一个WCF服务
    比尔盖茨给大学毕业生的11条人生忠告
    王爽 汇编语言 实验七
    王爽 汇编语言 实验五第5题和第6题
  • 原文地址:https://www.cnblogs.com/lzhitian/p/2329310.html
Copyright © 2011-2022 走看看