软件调试是鸡肋?你的认知决定你的层次!
1955年,一家名为Computer Usage Corporation(CUC)的公司诞生了,它是世界上第一家专门从事软件开发和服务的公司。CUC公司的创始人是Elmer Kubie和John W. Sheldon,他们都在IBM工作过。他们从当时计算机硬件的迅速发展中看到了软件方面所潜在的机遇。CUC的诞生标志着一个新兴的产业正式起步了。
与其他产业相比,软件产业的发展速度是惊人的。短短60余年后,我们已经难以统计世界上共有多少家软件公司,只知道这一定是一个很庞大的数字,而且这个数字还在不断增大。与此同时,软件产品的数量也达到了难以统计的程度,各种各样的软件已经渗透到人类生产和生活的各个领域,越来越多的人开始依赖软件工作和生活。
与传统的产品相比,软件产品具有根本的不同,其生产过程也有着根本的差异。在开发软件的整个过程中,存在非常多的不确定性因素。在一个软件真正完成之前,它的完成日期是很难预计的。很多软件项目都经历了多次的延期,还有很多中途夭折了。
直到今天,人们还没有找到一种有效的方法来控制软件的生产过程。导致软件生产难以控制的根本原因是源自软件本身的复杂性。一个软件的规模越大,它的复杂度也越高。
简单来说,软件是程序和文档的集合,程序的核心内容便是按一定顺序排列的一系列指令。如果把每个指令看作一块积木,那么软件开发就是使用这些积木修建一个让CPU(中央处理器)在其中运行的交通系统。这个系统中有很多条不同特征的道路(函数)。
如果说软件的执行过程就像是CPU在无数条道路(指令流)间飞奔,那么开发软件的过程就是设计和构建这个交通网络的过程。
其基本目标是要让CPU在这个网络中奔跑时可以完成需求中所定义的功能。对这个网络的其他要求通常还有可靠、灵活、健壮和易于维护,开发者通过简单的改造就能让其他类型的车辆(CPU)在上面行驶……
开发一个满足以上要求的软件系统不是一件简单的事,通常需要经历分析、设计、编码和测试等多个环节。通过测试并发布后,还需要维护和支持工作。在以上环节中,每一步都可能遇到这样那样的技术难题。
在软件世界中,螺丝刀、万用表等传统的探测工具和修理工具都不再适用了,取而代之的是以调试器为核心的各种软件调试工具。软件调试的基本手段有断点、单步执行、栈回溯等,其初衷就是跟踪和记录CPU执行软件的过程,把动态的瞬间“凝固”下来,以供检查和分析。
软件调试的基本目标是定位软件中存在的设计错误(bug)。但除此之外,软件调试技术和工具还有很多其他用途,比如分析软件的工作原理、分析系统崩溃、辅助解决系统和硬件问题等。
综上所述,软件是通过指令的组合来指挥硬件,既简单又复杂,是个充满神秘与挑战的世界。而软件调试是帮助人们探索和征服这个神秘世界的有力工具。
简介
本文首先给出软件调试的解释性定义,然后介绍软件调试的基本过程。
1.1.1 定义
什么是软件调试?我们不妨从英文的原词software debug说起。debug是在bug一词前面加上词头de,意思是分离和去除bug。
bug的本意就是“昆虫”,但早在19世纪时,人们就开始用这个词来描述电子设备中的设计缺欠。著名发明家托马斯·阿尔瓦·爱迪生(1847—1931)就用这个词来描述电路方面的设计错误。
1947年9月9日,当人们测试Mark II计算机时,它突然发生了故障。经过几个小时的检查后,工作人员发现一只飞蛾被打死在面板F的第70号继电器中。取出这只飞蛾后,计算机便恢复了正常。当时为Mark II计算机工作的著名女科学家Grace Hopper将这只飞蛾粘贴到了当天的工作手册中(见图1-1),并在上面加了一行注释——“First actual case of bug being found”,当时的时间是15:45。
随着这个故事的广为流传,越来越多的人开始用bug一词来指代计算机中的设计错误,并把Grace Hopper登记的那只飞蛾看作计算机历史上第一个记录于文档(documented)中的bug。
图1-1 计算机历史上第一个记录于文档中的bug
在bug一词广泛使用后,人们自然地开始用debug这个词来泛指排除错误的过程。关于谁最先创造和使用了这个词,目前还没有公认的说法,但可以肯定的是,Grace Hopper在20世纪50年代发表的很多论文中就已频繁使用这个词了。
因此可以肯定地说,在20世纪50年代,人们已经开始用这个词来表达软件调试这一含义,而且一直延续到了今天。
尽管从字面上看,debug的直接意思就是去除bug,但它实际上包含了寻找和定位bug。因为去除bug的前提是要找到bug,如何找到bug大都比发现后去除它要难得多。而且,随着计算机系统的发展,软件调试已经变得越来越不像在继电器间“捉虫”那样轻而易举了。
因此,在我国台湾地区,人们把software debug翻译为“软件侦错”。这个翻译没有按照英文原词直译,超越了单指“去除”的原意,融入了“侦查”的含义,是个很不错的意译。
在我国,我们通常将software debug翻译为“软件调试”,泛指重现软件故障、定位故障根源并最终解决软件问题的过程。
对软件调试另一种更宽泛的解释是指使用调试工具求解各种软件问题的过程,例如跟踪软件的执行过程,探索软件本身或与其配套的其他软件,或者硬件系统的工作原理等,这些过程有可能是为了去除软件缺欠,也可能不是。
1.1.2 基本过程
尽管取出那只飞蛾非常轻松,但为了找到它还是耗费了几个小时的时间。因此,软件调试从一开始实际上就包含了定位错误和去除错误这两个基本步骤。进一步讲,一个完整的软件调试过程是图1-2所示的循环过程,它由以下几个步骤组成。
图1-2 软件调试过程
第一,重现故障,通常是在用于调试的系统上重复导致故障的步骤,使要解决的问题出现在被调试的系统中。
第二,定位根源,即综合利用各种调试工具,使用各种调试手段寻找导致软件故障的根源。通常测试人员报告和描述的是软件故障所表现出的外在症状,比如界面或执行结果中所表现出的异常;或者是与软件需求和功能规约不符的地方,即所谓的软件缺欠。
而这些表面的缺欠总是由一个或多个内在因素导致的,这些内因要么是代码的行为错误,要么是“不行为”(该做而未做)错误。定位根源就是要找到导致外在缺欠的内因。
第三,探索和实现解决方案,即根据找到的故障根源、资源情况、紧迫程度等设计和实现解决方案。
第四,验证方案,在目标环境中测试方案的有效性,又称为回归(regress)测试。如果问题已经解决,那么就可以关闭问题;如果没有解决,则回到第三步调整和修改解决方案。
在以上各步骤中,定位根源常常是最困难也是最关键的步骤,它是软件调试过程的核心。如果没有找到故障根源,那么解决方案便很可能是隔靴搔痒或者头痛医脚,有时似乎缓解了问题,但事实上没有彻底解决问题,甚至是白白浪费时间。
分类
根据被调试软件的特征、所使用的调试工具以及软件的运行环境等要素,可以把软件调试分成很多个子类。本节将介绍几种常用的分类方法,并介绍每一种分类方法中的典型调试任务。
1.2.1 按调试目标的系统环境分类
软件调试所使用的工具和方法与操作系统有着密切的关系。例如,很多调试器是针对操作系统所设计的,只能在某一种或几种操作系统上运行。对软件调试的一种基本分类标准就是被调试程序(调试目标)所运行的系统环境(操作系统)。
按照这个标准,我们可以把调试分为Windows下的软件调试、Linux下的软件调试、DOS下的软件调试,等等。
这种分类方法主要是针对编译为机器码的本地(native)程序而言的。对于使用Java和.NET等动态语言所编写的运行在虚拟机中的程序,它们具有较好的跨平台特性,与操作系统的关联度较低,因此不适用于这种分类方法(见下文)。
1.2.2 按目标代码的执行方式分类
脚本语言具有简单易学、不需要编译等优点,比如网页开发中广泛使用的JavaScript和VBScript。脚本程序是由专门的解释程序解释执行的,不需要产生目标代码,与编译执行的程序有很多不同。调试使用脚本语言编写的脚本程序的过程称为脚本调试。所使用的调试器称为脚本调试器。
编译执行的程序又主要分成两类:一类是先编译为中间代码,在运行时再动态编译为当前CPU能够执行的目标代码,典型的代表便是使用C#开发的.NET程序。另一类是直接编译和链接成目标代码的程序,比如传统的C/C++程序。
为了便于区分,针对前一类代码的调试一般称为托管调试,针对后一类程序的调试称为本地调试。如果希望在同一个调试会话中既调试托管代码又调试本地代码,那么这种调试方式称为混合调试。
图1-3归纳出了按照执行和编译方式来对软件调试进行分类的判断方法和步骤。
图1-3 按照执行和编译方式对软件调试进行分类的判断方法和步骤
1.2.3 按目标代码的执行模式分类
在Windows这样的多任务操作系统中,作为保证安全和秩序的一个根本措施,系统定义了两种执行模式,即低特权级的用户模式和高特权级的内核模式。
应用程序代码是运行在用户模式下的,操作系统的内核、执行体和大多数设备驱动程序则是运行在内核模式的。因此,根据被调试程序的执行模式,我们可以把软件调试分为用户态调试和内核态调试。
因为运行在内核态的代码主要是本地代码以及很少量的脚本,例如ASL语言编写的ACPI脚本,所以内核态调试主要是调试本地代码。而用户态调试包括调试本地应用程序和调试托管应用程序等。
1.2.4 按调试器与调试目标的相对位置分类
如果被调试程序(调试目标)和调试器在同一个计算机系统中,那么这种调试称为本机调试。这里的同一个计算机系统是指在同一台计算机上的同一个操作系统中,不包括运行在同一个物理计算机上的多个虚拟机。
如果调试器和被调试程序分别位于不同的计算机系统中,它们通过以太网络或其他网络进行通信,那么这种调试方式称为远程调试。远程调试通常需要在被调试程序所在的系统中运行一个调试服务器程序。这个服务器程序和远程的调试器相互联系,向调试器报告调试事件,并执行调试器下达的命令。
利用Windows内核调试引擎所做的活动内核调试需要使用两台机器,两者之间通过串行接口、1394接口或USB 2.0进行连接。尽管这种调试的调试器和调试目标也在两台机器中,但是通常不将其归入远程调试的范畴。
1.2.5 按调试工具分类
软件调试也可以根据所使用的工具进行分类。最简单的就是按照调试时是否使用调试器分为使用调试器的软件调试和不使用调试器的软件调试。
使用调试器的调试可以使用断点、单步执行、跟踪执行等强大的调试功能。不使用调试器的调试主要依靠调试信息输出、日志文件、观察内存和文件等。后者具有简单的优点,适用于调试简单的问题或无法使用调试器的情况。
以上介绍了软件调试的几种常见分类方法,目的是让读者对典型的软件调试任务有概括性的了解。有些分类方法是有交叉性的,比如调试浏览器中的JavaScript属于脚本调试,也属于用户态调试。
调试技术概览
深入介绍各种软件调试技术是本书的主题,本着循序渐进的原则,在本文中,我们先概述各种常用的软件调试技术,帮助大家建立起一个总体印象。
1.3.1 断点
断点是使用调试器进行调试时最常用的调试技术之一。其基本思想是在某一个位置设置一个“陷阱”,当CPU执行到这个位置时便“跌入陷阱”,即停止执行被调试的程序,中断到调试器中,让调试者进行分析和调试。调试者分析结束后,可以让被调试程序恢复执行。
根据断点的设置空间可以把断点分为如下几种。
(1)代码断点:设置在内存空间中的断点,其地址通常为某一段代码的起始处。当CPU执行指定内存地址的代码(指令)时断点命中(hit),中断到调试器。使用调试器的图形界面或快捷键在某一行源代码或汇编代码处设置的断点便是代码断点。
(2)数据断点:设置在内存空间中的断点,其地址一般为所要监视变量(数据)的起始地址。当被调试程序访问指定内存地址的数据时断点命中。根据需要,测试人员可以定义触发断点的访问方式(读/写)和宽度(字节、字、双字等)。
(3)I/O断点:设置在I/O空间中的断点,其地址为某一I/O地址。当程序访问指定I/O地址的端口时中断到调试器。与数据断点类似,测试人员也可以根据需要设置断点被触发的访问宽度。
根据断点的设置方法,我们可以把断点分为软件断点和硬件断点。软件断点通常是通过向指定的代码位置插入专用的断点指令来实现的,比如IA32 CPU的INT 3指令(机器码为0xCC)就是断点指令。
硬件断点通常是通过设置CPU的调试寄存器来设置的。IA32 CPU定义了8个调试寄存器:DR0~DR7,可以同时设置最多4个硬件断点(对于一个调试会话)。通过调试寄存器可以设置以上3种断点中的任意一种,但是通过断点指令只可以设置代码断点。
当中断到调试器时,系统或调试器会将被调试程序的状态保存到一个数据结构中——通常称为执行上下文(CONTEXT)。中断到调试器后,被调试程序是处于静止状态的,直到用户输入恢复执行命令。
追踪点是断点的一种衍生形式。其基本思路是:当设置一个追踪点时,调试器内部会当作特殊的断点来处理。当执行到追踪点时,系统会向调试器报告断点事件,在调试器收到后,会检查内部维护的断点列表,发现目前发生的是追踪点后,便执行这个追踪点所定义的行为,通常是打印提示信息和变量值,然后便直接恢复被调试程序执行。
因为调试器是在执行追踪动作后立刻恢复被调试程序执行的,所以调试者没有感觉到被调试程序中断到调试器的过程,尽管实际上是发生的。
条件断点的工作方式也与此类似。当用户设置一个条件断点时,调试器实际插入的还是一个无条件断点,在断点命中、调试器收到调试事件后,它会检查这个断点的附加条件。如果条件满足,便中断给用户,让用户开始交互式调试;如果不满足,那么便立刻恢复被调试程序执行。
1.3.2 单步执行
单步执行是最早的调试方式之一。简单来说,就是让应用程序按照某一步骤单位一步一步执行。
根据每次要执行的步骤单位,又分为如下几种。
(1)每次执行一条汇编指令,称为汇编语言一级的单步跟踪。其实现方法一般是设置CPU的单步执行标志,以IA32 CPU为例,设置CPU标志寄存器的陷阱标志(Trap Flag,TF)位,可以让CPU每执行完一条指令便产生一个调试异常(INT 1),中断到调试器。
(2)每次执行源代码(比汇编语言更高级的程序语言,如C/C++)的一条语句,又称为源代码级的单步跟踪。高级语言的单步执行一般也是通过多次汇编一级的单步执行实现的。
当调试器每次收到调试事件时,它会判断程序指针(IP)是否还属于当前的高级语言语句,如果是,便再次设置单步执行标志并立刻恢复执行,让CPU再执行一条汇编指令,直到程序指针指向的汇编指令已经属于其他语句。调试器通常是通过符号文件中的源代码行信息来判断程序指针所属于的源代码行的。
(3)每次执行一个程序分支,又称为分支到分支单步跟踪。设置IA32 CPU的DbgCtl MSR寄存器的BTF(Branch Trap Flag)标志后,再设置TF标志,便可以让CPU执行到下一个分支指令时触发调试异常。WinDBG的tb命令用来执行到下一个分支。
(4)每次执行一个任务(线程),即当指定任务被调度执行时中断到调试器。当IA32 CPU切换到一个新的任务时,它会检查任务状态段(TSS)的T标志。如果该标志为1,那么便产生调试异常。但目前的调试器大多还没有提供对应的功能。
单步执行可以跟踪程序执行的每一个步骤,观察代码的执行路线和数据的变化过程,是深入诊断软件动态特征的一种有效方法。但是随着软件向大型化方向的发展,从头到尾跟踪执行一个软件乃至一个模块,一般都不再可行了。一般的做法是先使用断点功能将程序中断到感兴趣的位置,然后再单步执行关键的代码。我们将在第4章详细介绍CPU的单步执行调试。
1.3.3 输出调试信息
打印和输出调试信息是一种简单而“古老”的软件调试方式。其基本思想就是在程序中编写专门用于输出调试信息的语句,将程序运行的位置、状态和变量取值等信息以文本的形式输出到某一个可以观察到的地方,可以是控制台、窗口、文件或者调试器。
比如,在Windows平台上,驱动程序可以使用DbgPrint/DbgPrintEx来输出调试信息,应用程序可以调用OutputDebugString,控制台程序可以直接使用printf系列函数打印信息。在Linux平台上,驱动程序可以使用printk来输出调试信息,应用程序可以使用printf系列函数。
以上方法的优点是简单方便、不依赖于调试器和复杂的工具,因此至今仍在很多场合广泛使用。
不过这种简单方式也有一些明显的缺点,比如需要在被调试程序中加入代码,如果被调试程序的某个位置没有打印语句,那么便无法观察到那里的信息,如果要增加打印语句,那么需要重新编译和更新程序。
另外,这种方法容易影响程序的执行效率,打印出的文字所包含的信息有限,容易泄漏程序的技术细节,通常不可以动态开启、信息不是结构化的、难以分析和整理等。我们将在16.5.2节介绍使用这种方法应该注意的一些细节。
1.3.4 日志
与输出调试信息类似,写日志(log)是另一种被调试程序自发的辅助调试手段。其基本思想是在编写程序时加入特定的代码将程序运行的状态信息写到日志文件或数据库中。
日志文件通常自动按时间取文件名,每一条记录也有详细的时间信息,因此适合长期保存以及事后检查与分析。因此很多需要连续长时间在后台运行的服务器程序都有日志机制。
Windows操作系统提供了基本的日志记录、观察和管理(删除和备份)功能。Windows Vista新引入了名为Common Log File System(CLFS.SYS)的内核模块,用于进一步加强日志功能。Syslog是Linux系统下常用的日志设施。
1.3.5 事件追踪
打印调试信息和日志都是以文本形式来输出和记录信息的,因此不适合处理数据量庞大且速度要求高的情况。事件追踪机制(Event Trace)正是针对这一需求设计的,它使用结构化的二进制形式来记录数据,观察时再根据格式文件将信息格式转化为文本形式,因此适用于监视频繁且复杂的软件过程,比如监视文件访问和网络通信等。
ETW(Event Trace for Windows)是Windows操作系统内建的一种事件追踪机制,Windows内核本身和很多Windows下的软件工具(如Bootvis、TCP/IP View)都使用了该机制。我们将在第16章详细介绍事件追踪机制及其应用。
1.3.6 栈回溯
目前的主流CPU架构都是用栈来进行函数调用的,栈上记录了函数的返回地址,因此通过递归式寻找放在栈上的函数返回地址,便可以追溯出当前线程的函数调用序列,这便是栈回溯的基本原理。通过栈回溯产生的函数调用信息称为call stack(函数调用栈)。
栈回溯是记录和探索程序执行踪迹的极佳方法,使用这种方法,可以快速了解程序的运行轨迹,看其“从哪里来,向哪里去”。
因为从栈上得到的只是函数返回地址(数值),不是函数名称,所以为了便于理解,可以利用调试符号(debug symbol)文件将返回地址翻译成函数名。大多数编译器都支持在编译时生成调试符号。微软的调试符号服务器包含了多个Windows版本的系统文件的调试符号。我们将在本书后续分卷深入讨论调试符号。
大多数调试器都提供了栈回溯的功能,比如WinDBG的k命令和GDB的bt命令,它们都是用来观察栈回溯信息的,某些非调试器工具也可以记录和呈现栈回溯信息。
重要性
从软件工程的角度来讲,软件调试是软件工程的一个重要部分,软件调试出现在软件工程的各个阶段。从最初的可行性分析、原型验证到开发和测试阶段,再到发布后的维护与支持,都需要软件调试技术。
定位和修正bug是几乎所有软件项目的重要问题,越临近发布,这个问题的重要性越高!很多软件项目的延期是由于无法在原来的期限内修正bug所造成的。
为了解决这个问题,整个软件团队都应该重视软件的可调试性,重视对软件调试风险的评估和预测,并预留时间。
1.4.1 调试与编码的关系
调试与编码(coding)是软件开发中不同但联系密切的两个过程。在软件的开发阶段,对于一个模块(一段代码)来说,它的编写者通常也是它的调试者。或者说,一个程序员要负责调试他所编写的代码。这样做有两个非常大的好处。
(1)调试过程可以让程序员了解程序的实际执行过程,检验执行效果与自己设计时的预想是否一致,如果不一致,那么很可能预示着代码存在问题,应该引起重视。
(2)调试过程可以让程序员更好地认识到提高代码可调试性和代码质量的重要性,进而让他们自觉改进编码方式,合理添加可用来支持调试的代码。
编码和调试是程序员日常工作中两项最主要的任务,这两项任务是相辅相成的,编写具有可调试性的高质量代码可以明显提高调试效率,节约调试时间。此外,调试可以让程序员真切感受程序的实际执行过程,反思编码和设计中的问题,加深对软件和系统的理解,提高对代码的感知力和控制力。
在软件发布后,有些调试任务是由技术支持人员来完成的,但是当他们将错误定位到某个模块并且无法解决时,有时还要找到它的本来设计者。
很多经验丰富的程序员都把调试放在头等重要的位置,他们会利用各种调试手段观察、跟踪和理解代码的执行过程。通过调试,他们可以发现编码和设计中的问题,并把这些问题在发布给测试人员之前便纠正了。
于是,人们认为他们编写代码的水平非常高,没有或者很少有bug,在团队中有非常好的口碑。对于测试人员发现的问题,他们也仿佛先知先觉,看了测试人员的描述后,一般很快就能意识到问题所在,因为他们已经通过调试把代码的静态和动态特征都放在大脑中了,对其了然于胸。
但也有些程序员很少跟踪和调试他们编写的代码,也不知道这些代码何时被执行以及执行多少次。对于测试人员报告的一大堆问题,他们也经常是一头雾水,不知所措。
毋庸置疑,忽视调试对于提高程序员的编程水平和综合能力都是很不利的。因此,《Debugging by Thinking》一书的作者Robert Charles Metzger说道:“导致今天的软件有如此多缺欠的原因有很多,其中之一就是很多程序员不擅长调试,一些程序员对待软件调试就像对待个人所得税申报表那样消极。”
1.4.2 调试与测试的关系
简单地说,测试的目的是在不知道有问题存在的情况下去寻找和发现问题,而调试是在已经知道问题存在的情况下定位问题根源。从因果关系的角度来看,测试是旨在发现软件“表面”的不当行为和属性,而调试是寻找这个表象下面的内因。因此二者是有明显区别的,尽管有些人时常将它们混淆在一起。
测试与调试的宗旨是一致的,那就是软件的按期交付。为了实现这一共同目标,测试人员与调试人员应该相互尊重,密切配合。例如,测试人员应该尽可能准确详细地描述缺欠,说明错误的症状、实际的结果和期待的结果、发现问题的软硬件环境、重现问题的方法以及需要注意的细节。
测试人员应该在软件中加入检查错误和辅助调试的手段,以便更快地定位问题。
软件的调试版本应包含更多的错误检查环节,以便更容易测试出错误,因此除了测试软件的发布版本外,测试调试版本是提高测试效率、加快整个项目进度的有效措施。
1.4.3 调试与逆向工程的关系
典型的软件开发过程是设计、编码,再编译为可执行文件(目标程序)的过程。因此,所谓逆向工程就是根据可执行文件反向推导出编码方式和设计方法的过程。
调试器是逆向工程中的一种主要工具。符号文件、跟踪执行、变量监视和观察以及断点这些软件调试技术都是实施逆向工程时经常使用的技术手段。
逆向工程的合法性依赖于很多因素,需要视软件的授权协议、所在国家的法律、逆向工程的目的等具体情况而定,其细节超出了本书的讨论范围。
张银奎 著
本书堪称是软件调试的“百科全书”。作者围绕软件调试的“生态”系统、异常和调试器 3 条主线,介绍软件调试的相关原理和机制,探讨可调试性(debuggability)的内涵、意义以及实现软件可调试性的原则和方法,总结软件调试的方法和技巧。
第1卷主要围绕硬件技术展开介绍。全书分为4篇,共16章。第一篇“绪论”(第1章),介绍了软件调试的概念、基本过程、分类和简要历史,并综述了本书后面将详细介绍的主要调试技术。第二篇“CPU及其调试设施”(第2~7章),以英特尔和ARM架构的CPU为例系统描述了CPU的调试支持。第三篇“GPU及其调试设施”(第8~14章),深入探讨了Nvidia、AMD、英特尔、ARM和Imagination 这五大厂商的GPU。第四篇“可调试性”(第15~16章),介绍了提高软件可调试性的意义、基本原则、实例和需要注意的问题,并讨论了如何在软件开发实践中实现可调试性。
本文转载自异步社区。
原文链接:https://www.epubit.com/articleDetails?id=N78eab654-4a91-438c-8454-a47532a066ca
- 点赞
- 收藏
- 关注作者
评论(0)