找回密码
 立即注册
Qt开源社区 门户 查看内容

嵌入式软件设计之防御性编程

2019-9-17 09:29| 发布者: admin| 查看: 474| 评论: 0

摘要: 最近阅读了《编程匠艺:编写卓越的代码》一书,其中讲述了防御性编程的概念和技巧。个人觉得防御性编程在嵌入式软件设计中很有必要,故在本文中总结一下书中所述知识,并结合自身的经历谈谈嵌入式软件编程时需要注意 ...
最近阅读了《编程匠艺:编写卓越的代码》一书,其中讲述了防御性编程的概念和技巧。个人觉得防御性编程在嵌入式软件设计中很有必要,故在本文中总结一下书中所述知识,并结合自身的经历谈谈嵌入式软件编程时需要注意的几个地方。

引入

书中以积木城堡为引子,城堡可以堆的很高,但很脆弱,轻轻一推就会坍塌。进而说出软件设计中的分层思想,如果只追求层的高度,而不考虑其稳定性,那么设计的软件就像积木城堡一样,只要基础受到轻微的干扰,整个建筑就会倒塌。在嵌入式软件设计中,经常会分为驱动软件、硬件抽象层、中间件、操作系统、各种各样的库和应用软件几个层次。层与层之间看似相互独立,实则相互依赖,所以在异常情况发生,意外的输入产生时,如何保证我们软件每一层稳定运行,保证整个软件继续正常运行,才是编写优秀代码的设计人员将要面对的一大难题,而防御性编程的方法和技巧就是这些难题的“预防针”,是一种预防代码漏洞百出的有效手段。

设想

书中提到编写软件时设想的坏处,会导致我们写出带有缺陷的软件,比如:

这个函数“绝不会”被那样调用。传递给我的参数总是有效的;

这段代码肯定会“一直”正常运行,它绝不会产生错误;

如果我把这个变量标记为“仅限内部使用”,就没有人尝试访问这个变量。

在进行防御性编程的时候,我们不应该做任何设想,我们不应该设想“那不会发生”。我们也不应该设想地球怎么转和我们所想的一样。

经验告诉我们,唯一肯定的是:代码在某天会因为某种原因而出错。有人会做出愚蠢的举动。墨菲定律(Murphy’s Law)这样说道:“凡是可能出错的事,准会出错。”他说的没错—这个结论是经验之谈。防御性编程通过预见到(或至少是预先推测到)问题所在,断定代码中每个阶段可能出现的错误,并做出相应的防范措施,来防止这类意外的发生。

随着代码的发展,你会忘记最初所做的设想(真实的代码确实在发展)。其他程序员并不知道你脑海中的设想,对于你的代码的作用,他们只能自己做出一些无效的设想。软件的发展使弱点曝光,而代码的增长则隐藏了最初的简单设想。从一开始就保持适度的偏执,可以使代码在很长的时间内更加健壮。

 “设想”这个问题我想大多数编程人员都经历过,自己编写的软件只能自己使用,自己知道第一步做什么,第二步做什么,一旦换了其他人操作可能就会出现不正常的结果。自己知道正确的输入,永远产生同样的正确输出,换了其他的莫名其妙的输入,软件有时会突然崩溃掉,再重启一步步的操作。

编写软件时不应该带有“主观性”,因为这种“主观性”很可能随着时间的推移,自己都会怀疑自己当时的做法。更严重的是换做别人接手你的软件,严重怀疑你的做法的同时还不敢改动,因为害怕牵一发而动全身,引发雪崩的后果。

程序运行在机器上,机器是客观存在的,我们设计软件时也应该客观的去考虑程序可能存在的风险,考虑存在的异常输入,存在的显式BUG,并在设计之初就规避,从而把调试的时间用于那些真正挠头棘手的问题上。

概念

经过前面的介绍,已经给出防御性编程的解释,下面给出书中对于防御性编程的定义:

顾名思义,防御性编程是一种细致、谨慎的编程方法。为了开发可靠的软件,我们要设计系统中的每个组件,以使其尽可能地“保护”自己。我们通过明确地在代码中对设想进行检查,击碎了未记录下来的设想。这是一种努力,防止(或至少是观察)我们的代码以将会展现错误行为的方式被调用。

防御性编程并不能排除所有的程序错误,但是问题所带来的麻烦将会减小,并易于修改。防御性程序员只是抓住飘落的雪花,而不是被埋葬在错误的雪崩中。

防御性编程技巧:

(1)使用好的编码风格和合理的设计

大多数的公司都会有自己的编码规范,显而易见的是当软件代码量庞大以后,肯定不是一个人员来完成,多数人员都是完成某个模块,然后进行整合和系统测试,这样就必须要求所有参与编写的人员严格按照统一的编码标准编写,大到整个公司,小到一个项目,都会有这样的机制来束缚代码的风格。良好的命名方式,文件组织或者清晰的函数结构都能够帮助在设计之初避免编码错误的出现。

很多软件外包公司,在承接软件项目时,都会在前期进行详细的需求分析,然后由专人进行架构设计,模块划分,形成文档,文档的内容甚至详细到函数的名称,内部实现的细节的文字描述,甚至于函数内部每一行代码的写法都有要求。到达编程人员的手中时,要做的就是将文字的内容用代码表示。如果将一个软件项目比作一个大厦的建筑,那么软件前期的设计和文档编写就相当于大厦的设计者在规划建筑的结构外形,内部的主要结构,形成初期的设计图纸。一个设计图对于大厦稳定的重要性每个人都清楚,同样的,在软件设计上,良好的架构规划,清晰的逻辑层次,详细的设计文档也具有重要的意义。

(2)不要仓促地编写代码

“欲速则不达”的道理都清楚,在程序编写上也是一样的。如果着急编写一段代码或者一个函数,不对内部的逻辑仔细思考,不对内部可能出现的异常进行处理,就匆匆结束,那么后期在整个软件的运行中,很可能会由于此函数的设计而崩溃。书中也提到,现在就去做正确的事,不要等将来,在编写一段代码时就仔细认真一点,不要丢下某个环节,因为很可能将来出现错误的时候才意识到自己当时忘了这里没做了。

(3)不要相信任何人

书中提到任何人(包括自己)都可能把缺陷引入你的程序逻辑当中。用怀疑的眼光审视所有的输入和所有的结果,直到你能证明他们是正确的时为止。

常常听设计人员说,只要写代码就会产生BUG,自己写的代码将来要面对的不只是清醒的自己,还有用户的误操作,恶意的攻击者,运行环境的变化,外部程序库的错误等等,甚至有时候还要面对已经遗忘了这段代码的自己。所以不要指望代码会被正确安全的对待,武装好代码本身,做好面对一切打击的准备。

(4)编码的目标是清晰,而不是简洁

书中提到如果你从简洁(但是有可能让人困惑)的代码和清晰(但是有可能比较冗长)的代码中选择,一定要选那些看上去和预期相符合的代码,即使它不太优雅。

前段时间听说一位工程师编写嵌入式的应用软件被审核者返回来好多次,命名、注释、结构等等。最后提交成功后能够达到的标准就是叫来一个不懂程序的人读你的代码能够读懂。想想代码的设计是给人读的,当然越清晰简单越赏心悦目,如果代码像小说一样能够吸引读者那说明你厉害了。

代码之美,美在清晰简单,而不是复杂多变。

(5)不要让任何人做他们不该做的修补工作

书中提到内部的事情就该留在内部。私人的东西就应该用锁和钥匙保管起来。不要把你的代码初稿公示于众。不管你多么礼貌地恳求,只要你稍不注意,别人就会篡改你的数据,然后自以为是地试着调用“仅用于执行”的例行程序。

在面向对象语言中,通过将属性设为专用(private)来防止对内部类数据的访问。在过程语言中,你仍然可以使用面向对象(oo)的打包概念,将private数据打包在不透明的类型背后,并提供可以操作它们的定义良好的公共函数。

将所有变量保持在尽可能小的范围内。不到万不得已,不要声明全局变量。如果变量可以声明为函数内的局部变量,就不要在文件的范围上声明。如果变量可以声明为循环体内的局部变量,就不要在函数范围上声明。

(6)编译时打开所有警告开关

大多数的语言编译器都会在“伤了它们感情的时候”给出一大堆错误信息。当这些编译器碰到潜在的有缺陷代码时,它们也会给出各种各样的警告。通常情况下,这些警告可以有选择地启用或禁用。

很多时候大家都会处理编译错误,因为不处理编译不过去,但遇到警告就会容忍度大一些,因为大多数情况下不影响最后的执行结果。这就给了软件出现缺陷的可能性。警告往往预示着程序编写的不合理,或潜在的风险。所以出现警告一定要仔细查看,部分警告(如不同变量类型赋值)很有用处,编译出的警告很可能运行时就会出错。所以即使你认为某个警告无关紧要,也不要置之不理。否则,总有一天这个警告会隐藏一个确实重要的警告。

(7)使用静态分析工具

书中提到,编译器警告是对代码的一次有限的静态分析(即在程序运行之前执行的代码检查)的结果。

还有许多独立的静态分析工具可供使用。如用于C语言的lint(以及更多新出的衍生工具)和用于.NET汇编程序的FxCop。你的日常编程工作,应该包括使用这些工具来检查你的代码。他们会比你的编译器挑出更多的错误。

现在有很多软件可以协助完成代码的扫描和问题排查,很多公司也专门提供软件的静态和动态分析和测评。

(8)使用安全的数据结构

书中此处提到一个最常见的安全隐患,即缓冲区溢出。一般是由于不正确的使用固定大小的数据结构而造成的。如果你的代码在没有检查一个缓冲的大小之前就写入这个缓冲,那么写入的内容总是有可能会超出缓冲的末尾的。

书中给出的C语言代码:

    char *unsafe_copy(constchar *source){    char *buffer= new char[10];strcpy(buffer,source);return buffer;}

    上述代码中,如果source中数据的长度超过10个字符,他的副本就会超出buffer所保留内存的末尾。随后,任何事都可能会发生。数据出错是最好情况下的结果—一些其他数据结构的内容就会被覆盖。而在最坏的情况下,恶意用户会利用这个简单的错误,把可执行代码加入到程序堆栈中,并使用它来任意运行他自己的程序,从而劫持了计算机。这类缺陷常常被系统黑客所利用,后果极其严重。

    避免这些隐患,使用更安全的、不允许破坏程序的数据结构—使用类似C++的string类的托管缓冲。或者对不安全的数据类型系统地使用安全的操作。通过把strcpy更换为有大小限制的字符串复制操作strncpy,就可以使上面的C代码段得到保护。

      char *safer_copy(constchar*source){char *buffer = newchar[10];strncpy(buffer, source ,10);return buffer;}

      (9)检查所有的返回值

      如果在编写代码时调用一个API,没有返回值,心里会不会慌?它究竟执行成功与否?内部的状态是什么样的?

      在编写一个函数时没有返回值,调用者怎么知道运行真正的成功了呢?就依靠执行完毕,没有异常吗?

      大多数函数在设计时都会具有返回值,用于表达该函数的执行状态,哪些表示成功,哪些表示异常,并给出错误代码的示意。调用者同样要注意其返回的信息,及时获取并处理各种异常的错误代码。不要让错误悄无声息地侵入你的程序,忍受错误会导致不可预知的行为。

      很多时候不检查调用函数的返回值,会带来难以察觉的错误。不论何时,都要在适当的级别上捕获和处理相应的异常。

      (10)谨慎地处理内存(和其他宝贵的资源)

       这个大多数语言书籍都会提到,函数内部申请的内存资源用完要及时释放,删除一个线程时,申请的内存要释放,打开的文件要关闭。重视所有稀有的资源,审慎地管理它们的获取和释放。

      记得自己曾经也在此处犯过错误,编写的函数在错误检查捕获到异常时return出去,但return之前没有释放用于保护资源的信号量,导致一旦该函数内部执行错误(比如硬件异常),该函数内部关键代码无法再次进入运行。

      Java和.NET使用垃圾回收器来执行这些繁重的清洁工作,所以你可以“忘记”释放资源。让他们进入工作状态,这样在运行时将会不时地进行清扫。这真是一种享受,不过,不要因此而对安全性抱有错误的想法。你仍然需要思考。你必须显式地终止对那些不再需要,或不会被自动清除的对象的引用。不要意外地保留对对象的引用。不太先进的垃圾回收器也很容易会被循环引用蒙蔽(例如,A引用B,B又引用A,除此之外没有对A和B的引用)。这会导致对象永远不会被清除,这是一种难以发现的内存泄露形式。

      永远不要只依赖编译器或者运行环境的帮忙,自己时刻注意代码规范,养成良好的编写习惯,有环境帮忙更好,没环境帮忙也挺好。

      (11)在声明位置初始化所有变量

      如果你初始化了每个变量,他们的用途就会是明确的。依靠像“如果我不初始化它,我就不关心初始值”的经验主义是不安全的。代码会发展,未初始化的值以后可能随时变成问题。

      C和C++使这个问题更加复杂化。如果你意外地使用了一个没有初始化的变量,那么你的程序在每次运行的时候都将得到不同的结果,这取决于当时内存中的垃圾信息是什么。在一个地方声明一个变量,随后再对它进行赋值,在这之后再使用它,这样会为错误打开一个窗口。如果赋值的语句被跳过,你就会花费大量的时间来寻找程序随机出现各种行为的原因。在声明每个变量的时候就对它进行初始化,就可以把这个窗口关上,因为即使初始化时赋的值是错误的,至少出现的错误行为也是可以预知的。

      比较安全的语言(比如Java和C#)通过为所有变量定义初始值,回避了这个易犯的错误。在声明变量的时候对它进行初始化仍然是一种好的做法,这样可以提高代码的明确性。

      (12)尽可能推迟一些声明变量

      在使用变量的附近声明这个变量,防止它干扰代码的其他部分。不要在多个地方重用同一个临时变量,即使每次使用都是在逻辑上相互分离的区域中进行的。变量重用会使以后对代码重新完善的工作变得异常复杂。每次都创建一个新的变量—编译器会解决任何有关效率的问题。

      (13)使用标准语言工具

      在这方面,C和C++都是一场噩梦。它们的规范有许多不同的版本,使得许多情况成为了其他实现的未定义行为。现如今有很多编译器,每个编译器都有一些与其他编译器稍有不同的行为。这些编译器大部分是相互兼容的,但是仍然存在大量的绳索会套住你的脖子。

      明确地定义你正在使用的是哪个语言版本。除非你的项目要求你(最好是有一个好的理由),否则不要将命运交给编译器,或者对该语言的任何非标准的扩展。如果该语言的某个领域还没有定义,就不要依赖你所使用的特定编译器的行为(例如,不要依赖你的C编译器将char作为有符号的值对待,因为其他的编译器并不是这样的)。这样做会产生非常脆弱的代码。当你更新了编译器之后,会发生什么?一位新的程序员加入到开发团队中,如果他不理解那些扩展,会发生什么?依赖于特定编译器的个别行为,将导致以后难以发现的错误。

      (14)使用好的诊断信息日志工具

      编写代码常常会加入很多诊断信息,以确定程序的运行情况。调试结束后保留这些信息对以后代码的再次访问带来很多方便,特别是如果在此期间可以有选择地禁用这些信息。

      有很多诊断信息日志系统可以帮助实现这种功能。这些系统中很多都可以使诊断信息在不需要的时候不带来任何开销,可以有选择地使它们不参加编译。

      (15)谨慎地进行强制转换

      大多数语言都允许你将数据从一种类型强制转换(或转换)为另一种类型。这种操作有时比其他操作更成功。如果试着将一个64位的整数转换为较小的8位数据类型,那么其他的56位会怎么样呢?你的执行环境可能会突然抛出异常,或者悄悄地使你数据的完整性降级。很多程序员并不考虑这类事情,所以他们的程序会表现出不正常的行为。
             如果你真的想使用强制转换,就必须对之深思熟虑。你告诉编译器的是:“忘记类型检查吧,我知道这个变量是什么,而你并不知道。”你在类型系统中撕开了一个大洞,并直接穿越过去。这样做很不可靠。如果你犯了任何一种错误,编译器将只会静静地坐在那里小声嘀咕道:“我告诉过你的。”如果你很幸运(例如使用Java或C#),运行时可能会抛出异常以让你了解发生了错误,但这完全依赖于你要进行的是什么转换。

      C和C++对于数据类型的精度并不明确,所以对于数据类型的可互换性不要做任何假设。不要假设int和long的大小相同并且可以相互赋值,即使在你的平台上侥幸可以这样做,代码可以在平台之间移植,但是糟糕的代码可移植性很差。

      (16)细则

      提供默认的行为

            大多数语言提供了一条switch语句,这些语言都将碰到defaultcase的执行情况。如果default case是错误的,在代码中将错误情况明示出来,如果一切都正常,也要在代码中明示顺利执行的情况,只有这样维护代码的程序员才会理解程序的执行情况。

      同样地,如果你要编写一条不带else子句的if语句,停下来想一想,你是否应该处理这个逻辑上的默认情况。

      遵从语言习惯

      这条简单的建议将确保你的读者可以明白你所编写的所有代码。他们做出的错误设想会更少。

      检查数值的上下限

      即使是最基本的计算,也会使数值型变量上溢或下溢。对此要非常注意。语言规范或核心库提供了一些机制。用来确定各个标准类型的大小—别忘了使用这些机制。确保你了解所有可用的数值类型,以及每种类型最适合的情况。

      检查并确保每一次运算都是可靠稳定的。例如,确保自己一定不要使用可能会造成除0错误的值。

      正确设置常量

      C或C++语言的程序员真的应该对常量保持高度警惕,这会让日子好过很多。尽可能将所有可以设置成常量的都设置为常量。这样做有两个好处。首先,常量的限制条件可以充当代码记录。其次,常量使编译器可以找到你所犯下的愚蠢错误。这样,你就可以避免修改超出上下限的数据了。

      上面所述多数为书中原文,部分加入自己的理解,下面结合自己的实际经历总结一下嵌入式软件设计时代码上需要注意的几个地方,部分与书中所列知识点重合。

      关于嵌入式编程的几点建议总结:

      (1)编码规范和设计架构

            书中也描述了这个方面,编写软件,特别是代码量稍庞大一点的软件,一定要注意编码规范和架构的设计。在项目的前期做好需求分析,模块的划分,组成关系,通信的接口等等。这些工作做得越详细,越到位,后期的编码工作就会越顺利,出错的概率会很大程度的降低。

      多年前曾参与一个嵌入式软件的设计,前期的文档编写和讨论就用了一个月的时间,其中明确了整个软件的功能,模块组成,结构关系和通信接口等,甚至讨论到了接口函数的形式,代码的实现细节。当时觉得很浪费时间,规划了太久,可是到了编代码的阶段才发现,进展的很顺利,短时间内就完成了。测试上也没有出现严重的问题。从那以后,也意识到了软件前期规划的重要性,规划的越仔细,后期的调试改进工作越顺利。

      (2)变量命名和初始化

      变量命名这个问题相信很多公司都有自己的一套规范,比如匈牙利、大小驼峰等,这些都是经常听说的命名方式,其实不论哪种命名,其根本目的是让程序能够被人读懂和理解,统一代码风格,方便继承和管理。

      变量的初始化,经常看到定义时没有初值,这个如果是全局变量(当然不建议使用全局变量)可能由编译器和运行的OS等帮助清零(BSS),但如果是局部变量,就很可能是一个垃圾的值,一旦忘记赋值(或者赋值的语句没有执行),引用起来就可能引起意外的发生(最好的是挂在当前的函数或者任务或者线程,如果是挂在了别的线程中,就会有种莫名其妙,偶发崩溃,非常棘手)。再设想C语言中的指针没有赋初值呢?野指针乱飞的后果也会很严重。

      记得有些公司在招聘时都会出一些基础的面试或者笔试题目,其中就有对于定义变量(尤其是指针变量)赋初值的考察。工作时间越久的,这方面的考察越仔细。可能大家都不能忍受庞大的软件运行时,出故障的原因竟是一个变量没有赋初值吧···

      (3)函数参数有效性验证

      不同的项目有不同的设计要求,比如要求特别严格的电力、轨道交通、航空航天等领域,有些软件设计时函数要求只有一个出口,保证安全可控。但多数的代码设计时还是会在函数入口处放置一个return语句(如果入口参数检测不通过)。

      个人认为很有必要在函数的入口处加入参数检验的代码,如果输入异常,立即返回错误信息,提醒调用者输入参数有误,执行效率高些(当然也可以在函数内部标记错误信息,等待函数体执行完毕,统一出口,返回错误信息,效率也不会差很多)。

      (4)函数的返回值

      函数的返回值的设计和调用检测都很重要,设计者可以用于标记函数体内重要执行过程的状态信息,调用者可以清楚当前函数的执行结果,从而决策程序的下一步行为。

      曾经碰到过程序设计者没有检测API的返回值,结果运行了很久,都不知道出错在哪里,因为调试的代码中没有错误检测的信息。只关注if(success== API)而没有else的情况很糟糕,连返回值是什么都看不到,调试很不方便。

      (5)分支结构考虑完全

      这一点上面的书中也提到了,程序设计时尽量保证结构的完整性,任何时候都不要设想没有default或者else的情况发生,一切皆有可能的。

      程序设计时建议在else或者defalut中加入调试信息,这样在Debug时,单步调试或者加入断点等都很有帮助。

      (6)内存泄露 

      记得有一位老师说过,偶发的软件死机很可能是两种原因:一个是内存泄露,另外一个是线程锁。内存泄露会导致莫名其妙的死机问题,当事发现场就死机还好,如果事发现场没有死机,而是将错误蔓延到其他线程(多线程编程模型下),出了故障会很难定位和调试。

      多线程编程下很容易出现线程内申请一个定长数组,然后就不去动它的长度,以后不断的利用此数组作为缓冲区,处理传递数据,每次进入此线程都会刷新此缓冲区内的数据,最可怕的就是后面处理数据时拷贝数据长度超出此数组的最大容量值,造成栈溢出。很多情况下这种错误并不会立即造成死机,程序运行只是结果不对而已。故关于数组的copy或赋值操作务必注意其最大长度,务必对copy的长度做限定。

      多线程编程下还有一种任务栈溢出的问题,常常在创建任务时会指定固定长度的任务栈,相信在创建任务时,都会申请足够的栈空间,自己也清楚任务需要处理的数据量,可在调试一段时间后,很可能会有新的需求加入,比如新建一个结构或者临时数组,一定要注意任务栈的使用情况,运行时监测任务栈的余量。任务栈溢出,如果操作系统监测到,会给出警告,进而停止软件的运行,但若操作系统不检测,就很危险,结果也是不可预测的。

      (7)函数的可重入性,线程安全

      说到线程安全,可重入都会考虑到嵌入式操作系统下的编程,其实在MCU或者单片机、微控制器的编程下也有多线程的影子,主函数main中的while(1)循环和各种中断处理函数之间也算作多线程的操作,当多个中断和while(1)下处理同一资源时应该加入保护机制。同样在多线程的操作系统,甚至多核多线程编程模型下更要注意对于竞争资源的保护机制,同时注意函数的可重入性,设计时是否用到了全局的元素,局部静态变量等等。特别是习惯裸机编程的设计移植到多任务系统,或者单核多线程编程移植到多核SMP编程环境下,更需要注意这些。

      (8)加入必要的调试手段

      软件的可测试性一直是作为考量软件设计的很重要的因素,很多嵌入式软件都运行在微处理器之中,有的工作在飞机、铁路、卫星、甚至是飞船之上,如何在软件运行出故障时监测和定位故障点,是非常重要的,想想当年火星探测的设备,VxWorks系统在出现故障时,从地面的定位和升级手段,是多么的重要。所以很有必要在软件的设计之初,就将测试和协助解决软件故障的功能加入进去,在后期的测试和实际运行环境中一定有其大展拳脚的时候。

      (9)注释的必要性

      软件的设计标准一定都离不开对于注释的要求,可见注释对于软件编写的重要性。所谓好记性不如烂笔头,当觉得自己编写的这段代码很漂亮,写的很出色时,别忘了记录下自己骄傲的时刻,写上时间作者和你实现的重要功能,注释不是直译代码的表面的意思,而是对于现实事物的表达,比如:age++并不是age+1 而是又长了一岁,或者时光飞逝啊。

      如果你的代码里充满了j=i+3;j=2*i+3+1 ···如果不进行充分的注释,一年后当你再次看到自己的设计时,你会是什么样的感受呢?当别人看到这段代码绞尽脑汁的思考时又是怎样的体验?

      总结:

      本文从《编程匠艺:编写卓越的代码》一书出发,列出书中对于编写代码的一种思想:防御性编程,进而给出书中对于防御性编程的建议和技术点,最后结合自身的经历说明了嵌入式编程时需要注意的地方,读书的观后感加自己的经验总结,经验和技术有限,难免有错误和不妥的地方,希望每一个读到本文的读者多多见谅,一起学习,共同进步。


      ----------------------------------------------------------------------------------------------------------------------
      我们尊重原创,也注重分享,文章来源于微信公众号:瓶凡的世界,建议关注公众号查看原文。如若侵权请联系qter@qter.org。
      ----------------------------------------------------------------------------------------------------------------------

      鲜花

      握手

      雷人

      路过

      鸡蛋
      
      公告
      可以关注我们的微信公众号yafeilinux_friends获取最新动态,或者加入QQ会员群进行交流:190741849、186601429(已满) 我知道了