百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术分析 > 正文

Perfetto工具集之traced_perf

liebian365 2025-01-02 17:42 36 浏览 0 评论

1.Perf工具概览

linux中包含了众多性能分析工具,perf(特指linux-tools perf)工具是2009年在linux内核2.6.31中引入的一个工具。它的主要功能是可以跟踪hardware performance counter(PMU)、tracepoints、software performance counter(hrtimer)、dynamic probes等信息。linux内核将这些信息进行封装,通过syscall(perf_event_open等)的形式提供,使之抽象为events的概念,可以供userspace的进程使用。perf作为一个linux下的命令行工具,可以读取这些events,并结合性能分析的场景,提供了诸如stats、top、record、report等子工具命令,适配更细化的分析需求。


Android中一般使用的并不是老牌的linux-tools perf工具,而是使用经过Android客制化的perf工具,用于支持Android中拓展的一些feature。


  • simpleperf:

Android最早于Android 6.0(2015年)中引入,距今(2022年)已经有7年的历史。其主要作用就是实现Linux中perf工具的基本功能。

  • traced_perf:

Google于2019[在2019年开始开发][修改了一下]年开始开发,其作为perfetto的一个consumer而不是单独的一个项目去开发的。其开发目的是能够:

a.利用perfetto的成熟平台,提供profiling、unwinding、UI等各方面的能力

b.伴随着Android权限管控的愈发严格和MAC的要求,原Simpleperf的独立selinux domain完成所有功能的方式已经无法满足sandbox的需求,需要进行严格的domain隔离

本文着重讲一下traced_perf。


2.traced_perf的结构

2.1.代码结构

traced_perf的代码位于AOSP的external/perfetto/src/profiling/perf/ 目录下,可以看出,traced_perf的代码实际上是perfetto项目的一个子目录。


此目录下的代码如下图:

可以看出代码分为三类:

  • 编译脚本相关: BUILD.gn
  • 单元测试相关:X_unittest.cc
  • 主要代码逻辑

除了上述的代码目录外,在perfetto的主目录下还存在文件:external/perfetto/traced_perf.rc

此文件是traced_perf可执行文件的启动脚本。


2.2运行时结构


根据external/perfetto/Android.bp的编译脚本可以看出,traced_perf最终会被编译为一个可执行文件,并且被install到/system/bin/traced_perf。此可执行文件以daemon的形式存在,其启动和结束受它对应的rc启动脚本的控制。



其运行时的生命周期可以通过traced_perf.rc文件去分析:



  • traced_perf的权限配置

?traced_perf的用户设置为了nobody,可以确保权限不会影响到其他用户,避免被恶意破解后获得提权

?traced_perf的组包含nobody、readproc、readtracefs三个,readproc是为了使之被赋予可以读取/proc/PID目录的权限,readtracefs是为了赋予其读取tracefs mount的目录的权限,这两个权限是traced_perf能够正常运行所必须的权限。

?traced_perf赋予了相应的capability,分别为KILL,DAC_READ_SEARCH。KILL是为了使traced_perf能够给其他进程发送信号。DAC_READ_SEARCH是为了使之能够至少能够获取一些文件的权限,而不至于甚至不能够探测某些文件的存在。这两个权限都是traced_perf正常运行所必需的。

?task_profiles是为了给traced_perf设置为高capacity(unwinding)的一类cgroup,从而使得调度器可以给予其更合理的资源分配。


  • traced_perf的资源

traced_perf申请了一个名为traced_perf的unix_socket,此unix_socket是traced_perf与待profiling的进程间通信的通道,后续章节有涉及。


  • traced_perf的生命周期管控

traced_perf的生命周期管控通过property trigger来完成。当设置persist.traced_perf.enable 为true的时候,会自动启动traced_perf。同时,它还会受到sys.init.perf_lsm_hooks和traced.lazy.traced_perf的管控。


3.traced_perf的架构

3.1perfetto的框架

traced_perf作为perfetto工具集的一个组成部分,其遵循perfetto的service model的。perfetto的service model如下图所示:


3.1.1producer

traced_perf作为Tracing service的producer,其和tracing service的交互由两条通道,分别是IPC channel和shared_memory,其中IPC channel为unix socket,后面有详细描述。


shared_memory是指与tracing service之间建立的共享内存通道,此共享内存通道有两个作用:

1.进行高效的进程间数据传递,这里传递的主要是结构化的采样点数据。


2.与控制流进行隔离,避免被恶意破解后造成安全隐私风险。


traced_perf本身作为producer端,提供了Data Source,每个producer可以提供多种Data Source。traced_perf本身对外提供的Data Source包含linux.perf 和一个metadata的Data Source。后续我们对此Data Souce展开详细的描述。


3.1.2Tracing Service

Tracing Service作为perfetto在手机端的核心服务,其承担了主控的作用。Tracing Service在手机端主要表现为traced进程,它一方面接收consumer的配置文件的控制,另外一方面将配置文件转化为对Producer的控制;同时还承担了producer端与consumer端的桥梁。producer端与consumer的数据通道采用了trace buffer内存,这部分内存是没有进行进程间共享的,从而可以保持数据的隔离。


3.1.3Consumer


consumer端是指对perfetto trace类数据的消耗端,比如perfetto ui、shell command、traceur等,consumer端还可以进行自定义,在Android中添加客制化的consumer,从而对Data Source进行客制化的处理。

consumer端和Tracing Service的IPC通道主要也是通过unix socket进行连接的。


3.2traced_perf与perfetto的交互

3.2.1整体流程

整体流程示意图如下,读者可以根据此流程理解。交互流程涉及到Perfetto内部的众多类的实现,建议读者优先理解涉及到的C++类的声明与函数实现,而不要刚开始就陷入到调用流程的跟踪中,避免陷入到多层嵌套的复杂逻辑中。当把几个关键类的功能和对外关系理清楚之后,再通过调用关系依次跟踪调用流程。


  • PerfProducer:

调用ConnectService建立连接流程

实现OnConnect流程

实现OnTracingSetup、StartDataSource等函数

  • ProducerEndPoint

创建建立进程间通信的必要对象

实现OnConnect

  • ClientImpl

建立Socket连接

实现onConnect、onDataAvaliable等


通过上述流程拆分可以看出,每个类的职责都是非常清晰的。


3.2.2IPC通道建立

对于traced_perf来讲,建立IPC通道由下面几个重要流程:

1.实例化task_runner和AndroidRemoteDescriptorGetter。task_runner是traced_perf中使用的一个Looper工具类实例,AndroidRemoteDescriptorGetter是traced_perf为了获取想要trace的应用的私有进程数据而建立的一个类。后续章节有相关描述。

2.与Tracing Service建立连接

3.启动消息循环



3.2.3IPC通道框架

IPC通道的框架相对来说比较复杂,本小节进行一个原理剖析。


  • TaskRunner: 是一个Looper interface,PerfProducer使用的实例是基于unix domain socket实例化的TaskRunner。此task_runner_在各个结构间传递,承担了各类消息的派发和处理。
  • ProducerEndPoint: 是Tracing service的producer端的接口类,通过ProducerIPCClientImpl得以实例化。

为了能够将PerfProducer类注册为Tracing Service的producer,需要执行如下操作:


其中,ProducerIPCClient::Connect是一个静态方法,其实例化了ProducerIPCClientImpl并将其以unique_ptr的形式返回。



上述流程走完之后,实际上就建立了PerfProducer的事件处理流程。


关注到ConnectService中,ProducerIPCClient::Connect中的第二个参数是this指针,实际上是把PerfProducer的对象指针传递给了ProducerEndpoint对象,它是通过ProducerIPCClientImpl构造函数中的第三个参数producer传递过去的。


3.2.3.1相关概念

  • DataSource

顾名思义,这个是数据源的意思。根据Perfetto的框架图,consumer端需要指明从哪个“数据源”收集数据,而Producer可以提供数据源。数据源在perfetto中的定义以proto的形式进行了规定,在PerfProducer中,它对数据源的定义进行了抽象,通过DataSourceState进行描述。


与DataSource相对应的一个数据结构是traced_perf里面的DataSourceState的结构,可以看到DataSourceState中维护了一个TraceWriter指针,此TraceWriter提供了写入Trace数据的相关方法。

3.2.3.2TraceWriter

TraceWriter类是为了让使用者可以在perfetto的共享内存中,以零拷贝的形式写入Trace数据,方便使用者高效写入Trace数据。

  • NewTracePacket

创建一个TracePacket并返回一个handle

  • FinishTracePacket

完成之前创建的TracePacket

  • Flush

将TracePacket刷入到service端


3.2.3.3IPC消息的接收

ProducerEndPoint对象会通过PerfProducer对象提供的service_sock_name与PerfProducer进行通信,当连接建立之后,就进入了IPC流程,服务端会将消息按照perfetto定义的协议格式发送对应的指令。消息协议如下:


上述消息会被ProducerEndPoint解析,并最终转化为Producer接口类的虚函数调用(注意到ProducerEndPoint维护了一个Producer(PerfProducer)实例的指针)。


Producer的实例需要实现如下接口:

  • OnConnect

当与Tracing Service建立Socket连接后会被调用

  • OnDisconnect

当与Tracing Service断开Socket连接后会被调用。此时可以销毁PerfProducer对象了。

  • OnStartupTracingSetup

当第一个DataSource被创建之前被调用,可以做一些初始化的工作。

  • SetupDataSource

设置DataSource时被调用,其传递的参数包含DataSourceInstanceId以及DataSourceConfig

  • StartDataSource

启动DataSource

  • StopDataSource

停止DataSource

  • Flush

Tracing Service要求Producer将数据写入到共享内存中。

  • ClearIncrementalState

Producer端应该在此调用后,停止引用之前写入到共享内存的数据。



3.2.3.4IPC消息的发送

ProducerEndPoint提供如下接口:

  • Disconnect:

用于与ProducerEndPoint断开连接,此时不再能收到来自于Service端的回调消息。

  • RegisterDataSource

注册DataSource

  • UpdateDataSource

更新DataSource

  • RegisterTraceWriter

注册TraceWriter

  • CommitData

通知Tracing Service shared memory中的数据已经更新。

  • CreateTraceWriter

创建TraceWriter

  • 其他同步方法


4.traced_perf的事件处理

上一章节中,我们讨论了traced_perf与perfetto的框架的关系,本章节中着重阐述traced_perf在perfetto producer框架下,如何实现其作为perfetto的一个producer,达到profiling进线程counter信息、获取调用栈、分析性能问题的目的的。


上一章节中,描述了trace_perf 通过IPC通道从tracing service进行事件接收,这些事件最终转化为了Producer的重写函数,那么traced_perf作为tracing service的producer,需要实现这函数从而完成整个流程。



图中方框里面的是PerfProducer的事件处理状态,连接线上的字是traced_perf中发生的事件或者通过IPC接收到的命令。


4.1onConnect的实现

onConnect的实现非常简单,首先设置连接状态的状态机为“kConnected”状态,其次实例化了两个名字分别为“linux.perf”与“perfetto.metatrace”的DataSourceDescriptor,然后通过endpoint_指针的RegisterDataSource方法进行DataSource注册,其中endpoint_即为上一章节中提到的ProducerEndPoint对象的指针。


4.2StartDataSource的实现

StartDataSource的参数有两个,分别是DataSourceInstanceID和DataSourceConfig,其中DataSourceInstanceID是一个唯一的无符号64位的id,用来标识DataSource的实例;DataSourceConfig是data_source_config.proto生成的protobuf类,其原型可以参考:https://cs.android.com/android/platform/superproject/+/master:external/perfetto/protos/perfetto/config/data_source_config.proto;l=1;bpv=1;bpt=0


4.3启动MetaTraceSource


通过endpoint_智能指针,调用CreateTraceWriter方法,创建一个TraceWriter对象。同时将此metatrace进行使能,并保存到metatrace_writers_维护的一个map结构中。


4.4tracepoint与id的mapping的lookup操作

tracepoint一般是以名字的方式提供给配置文件的,但是linux kernel中一般使用其对应的id进行API访问控制,因此这里需要一个映射的提取。一般来说,此id可以通过位于tracefs的events/GROUP/NAME/id文件中可以提取出来。


4.5打开perf event对应的eventfd

首先将pb格式封装的配置文件转化为perf_event_attr数据结构,之后调用linux kernel提供的系统调用向操作系统注册。


打开perf event所必须的linux syscall为perf_event_open,此API的参数比较复杂,详细介绍可以参考官方文档:https://man7.org/linux/man-pages/man2/perf_event_open.2.html

这里着重讲解一下关键的配置信息:


?perf_event_attr


perf_event_attr是一个比较大的结构体,包含了对perf_event配置的各种属性信息,以比较简单的tracepoint事件为例,一般来说需要设置以下必须的字段:


type: 设置为PERF_TYPE_TRACEPOINT类型

size: 设置为sizeof(perf_event_attr)

config:设置为上一步中获取到的mapping的id信息

sample_type: 设置sample中包含的数据类型

read_format:设置read返回的数值中包含的数据类型

开关bitmask配置:包含是否包含mmap的数据,是否包含comm等近30个配置项


pid

获取哪个pid的perf event事件

cpu

获取哪个cpu的perf event事件

groud_fd

可以将多个events通过同一个event fd进行返回,可以将其中一个事件传入-1作为group leader,后续事件可以将返回的fd传入此参数中。


4.6创建TraceWriter并使能perf event

4.7通知Unwinder启动了DataSource



4.8启动周期性读取任务


周期性的读取任务,主要是从内核的共享内存中,获取perf event的数据。在后续的章节中我们会着重讲述获取的数据。


在TickDataSourceRead函数中的ReadAndParsePerCpuBuffer中,会将从内核的共享内存中读取的sample数据,推送到unwinding_worker的queue中。当调用PostProcessQueue时,会唤醒unwinding_worker对应的线程,并执行unwind操作,直到所有sample都unwind完毕。


若DataSource的状态未停止,则需要继续抓取更多的samples,因此在这个task中,又继续调用了延迟任务,继续让task_runner调度本任务。


5.Sample的获取

Sample事件的获取是从Linux内核中提供的ring buffer共享内存中获取的,这部分操作位于PerfProducer::ReadAndParsePerCpuBuffer中进行的,这部分操作相对来说比较繁琐,下图中截取了一部分。其基本的流程是:


循环通过EventReader的ReadUntilSample获取解析好的Sample,如果DataSource的config中有配置一些filter项,则筛选掉不感兴趣的Sample,直到没有Sample产生了或者已经获取到足够的Sample了。


5.1PerfRingBuffer之环形缓冲区数据的获取

回顾一下perf_event_open函数的原型:

int syscall(SYS_perf_event_open, struct perf_event_attr *attr, pid_t pid, int cpu, int group_fd, unsigned long flags);

其中perf_event_attr结构中包含了众多的配置参数。跟通过ring buffer获取sample相关的参数有以下几个配置:

  • sample_period/sample_freq: 指明多久获取一次sample。
  • sample_type: 指明什么类型的数据会包含在sample中,比如Instruction pointer、TID、Sample的时间、地址信息等

通过perf_event_open返回的文件描述符,可以进而通过mmap系统调用,返回一个Kernel与Userspace共享的内存地址空间,此内存地址中的数据一般由Kernel写入,Userspace的程序负责对其进行解析。mmap的共享内存地址的分布如下:


metadata页对应的数据结构如下:


  • data_head: 指向数据区的首地址,这个地址是持续自增加的,在使用它的时候,需要将其地址与mmap buffer的大小进行一个wrap操作。
  • data_tail: 此数据是需要由userspace写入,指明userspace最后读取的数据的位置,从而使得内核不会降未读取的数据覆盖。
  • data_offset: perf_sample的起始位置由此述职来确定。
  • data_size: 包含了perf_sample区域大小信息


由Linux内核提供的perf sample也包含固定的格式,每个perf sample的数据原型如下:

注意到上述结构右边的if注释,假如对应的选项没有在sample_type中配置,则不包含对应的字段,在解析sample的时候,值的注意。

perf_event_header是每个sample的头信息,它的定义如下:

  • size: 本perf sample的大小
  • misc:包含本sample的一些额外的信息
  • type: 不同的sample类型,只有类型为PERF_RECORD_SAMPLE时,才有上面的perf sample的数据结构。比如当其类型为PERF_RECORD_LOST时,对应的perf event的数据结构为

5.2EventReader之Sample的解析

5.2.1perf sample的读取


perf sample的获取实际上是在对环形缓冲区的读取,环形缓冲区的包含一个读取偏移量以及一个写入偏移量。其中写入偏移量是由内核负责写入的,只要读取偏移量小于写入偏移量,则说明环形缓冲区中仍有数据未读取。


这里注意到环形缓冲区实际上是可以出现回卷操作的,假如出现了回卷操作,需要将数据进行重组。


5.2.2Perf sample的解析

perf sample本身的解析工作是通过EventReader::ParseSampleRecord进行的。解析后的数据结构为ParsedSample,其定义为:



可以看出,traced_perf关注的信息包含:

  • CommonSampleData: cpu_mode, cpu, pid, tid, timestamp, timebase_count等信息
  • regs: 用作unwinding的userspace寄存器信息
  • stack: userspace栈信息
  • kernel_ips: 内核instruction pointer信息


下面是Sample解析的具体流程


上述函数会返回解析好的Perf sample,即ParsedSample。进行一系列的筛选逻辑后,此sample会被发送到unwindwing_worker提供的queue中,以便于进行后续的unwinding操作。



至此,所有从内核中所需要的perf event信息已经收集并解析完毕,下一步是将之转化为可读的callstack信息的流程,这离不开unwinding 操作。


6.Unwinding操作

unwinding操作发生在解析完perf event sample之后,其发起动作的调用为:


其主要处理逻辑位于Unwinder::ConsumeAndUnwindReadySamples函数中。


当unwind成功后,调用到PerfProducer中的PostEmitSample中,将unwinding之后的数据写入TraceWriter。


6.1内核栈的解析


内核栈的解析相对简单,其主要操作函数再Unwinder::SynbolizeKernelCallchain中。其主要原理是解析"/proc/kallsyms" 中的内核地址与符号之间的对应关系。根据对应关系,将sample中的kernel态的instruction pointer翻译成地址信息。kernel 态的地址信息介绍见之前章节。



6.2用户栈的解析

用户栈的解析相对复杂,用户栈的解析首先要获取几个必要的信息:

  1. Userspace寄存器信息
  2. Userspace栈信息
  3. /proc//mem信息
  4. /proc//maps信息

其中前两个信息已经通过之前的Sample解析操作成功获取了,那么第3、4个信息怎么获取呢?

在之前的Android版本中,特权进程是可以直接访问到对应pid的这两个信息的,随着Android对安全隐私的越来越重视,对不同进程的敏感信息进行了比较强的隔离。因此traced_perf为了获取此信息,必须按照符合Android安全设计的机制,以相对复杂的方式进行实现。


6.2.1traced_perf如何请求目标进程的maps和mem信息

在AndroidRemoteDescriptorGetter类中,实现了获取/proc//mem以及/proc//maps操作的动作,获取操作是通过发起signal操作来完成的,signal的目标是目标进程:

而信息的接收是通过socket来完成的,即traced_perf进程刚启动时创建的socket:


在此socket的数据收取操作中,获取到上述两个文件的文件描述符(文件描述符已经经过内核态转换,可以在traced_perf进程中正常使用)。

上述代码中的delegate实际上指向的是PerfProducer对象,因此delegate_->onProcDescriptors会将两个文件描述符发送给PerfProducer对象。而PerfProducer进而将此文件描述符发送给了UnwinderHandle对象。


6.2.6目标进程如何将maps和mem信息发送给traced_perf

如前面所述,traced perf通过signal通知的目标进程,让目标进程将文件描述符进行了发送,那么目标进程为什么都会响应这类信息呢?(目标进程可能非常多样,包括daemon、系统apk、三方apk等),答案在C库中。


当目标进程接收到信号后,通过unix socket与traced_perf进程建立连接,然后打开两个文件maps和mem,通过unix socket的sendmsg进行发送。关于通过unix socket发送文件描述符,可以参考文档:https://man7.org/linux/man-pages/man7/unix.7.html,这里不做详细描述。


上述代码在android_profiling_dynamic.cpp中,会编译成C库的一部分,并被大多数进程所加载。


到此为止,用户栈的所有所需信息均已准备完毕。


6.2.3用户栈的解析

将所有信息准备好后,真正解析用户栈可以直接通过libunwindstack提供的方法Unwinder::Unwind即可了,反而流程显得很直接。



7.Sample的写入


Sample数据写入到trace中的操作也是比较直接的,将前面流程中获取到的信息,通过TraceWriter返回的TrackPacket protobuf结构进行写入即可。

至此,整个traced_perf的获取sample的执行流程大概完成了。


8.总结

traced_perf的工作流程主要部分包括:

  • perfetto流程的嵌入:traced_perf是perfetto的producer
  • Sample的获取
  • Unwinding操作

这三项主要内容看似复杂,实际上整体结构也是比较清晰的。Perfetto已经将tracing、profiling的框架打通,Tracing producer要接入perfetto,也是一件按部就班的事情而已。


9.参考链接

TracedPerf源码: https://cs.android.com/

traced_perf相关文档: https://perfetto.dev/docs

perf历史: https://en.wikipedia.org/wiki/Perf_(Linux)

simpleperf相关文档: https://android.googlesource.com/platform/prebuilts/simpleperf/+/782cdf2ea6e33f2414b53884742d59fe11f01ebe/README.md

perf_event_open: https://man7.org/linux/man-pages/man2

相关推荐

python如何对字符串进行操作(python如何对字符串进行操作输出)

1.字符串的创建可通过直接赋值、构造或转义字符来创建字符串。#普通字符串s="Hello,World!"#多行字符串(使用三引号)multi_line_str='''Thisi...

Excel表格中11个常用的字符串函数

今天和大家聊聊常用的字符串函数,在不同的条件下,如何选择字符串函数很关键。下面我为大家列举了11个关于字符串的函数公式。一、EXACT(两个字符串进行结果比较)比较两个字符串是否完全相同(这里是要区分...

详细介绍一下Python中如何对字符串进行操作?

在Python中,字符串做为一种常见的数据处理类型,几乎在每个应用程序中都会被用到。而作为Python中使用最广泛的数据类型Python也是提供了很多强大的方法来支持对于字符串的处理操作。下面我们就来...

Java中你知道几种从字符串中找指定的字符的数量

遇到这样的问题,常规的思路估计就是遍历String,然后逐个对比。下面先看循环遍历循环遍历privatestaticintgetNum(StringoriginStr,Stringtarg...

C语言strcspn函数详解:字符串的“扫雷探测器”

strcspn是C语言标准库中的一个函数,定义在头文件中。它用于计算从字符串的开始到首次出现任何属于指定字符集合的字符之间的字符数量。换句话说,strcspn计算的是主字符串中不包含指定字符集...

如何使用 Python 的 f-string 进行字符串格式化

Python中的字符串格式化曾经有点麻烦。必须在...

java判断字符串中是否包含某个字符

1使用String类的contains()方法contains()方法用于判断字符串中是否包含指定的字符或字符串。语法如下:publicbooleancontains(CharSequence...

Python基础:f-string不同数据类型的格式化选项,终极指南!

上一篇文章我们介绍了4种字符串格式化方法,其中最现代、最直观的方式是f-string,从Python3.6开始引入,而且时不时就增加一些超级优雅的小改进。今天,钢铁老豆想要继续给大家展开介绍不同数据...

Excel查找指定字符串,返回相应的结果

通过下面的函数,可以实现查找指定字符串,若找到返回“有”,若找不到返回“无”。=IF(ISNUMBER(SEARCH("失业",G3)),"有","无")...

一个list中,有b.a.b.c.b.b.写个方法去掉所有b

importjava.util.ArrayList;importjava.util.List;publicclassRemoveBFromStringList{/**...

掌握Python f-string(掌握催眠能力之后的日常生活)

f-string,通常称为格式化字符串文本,是Python3.6中添加的一项强大功能,它提供了一种将表达式包含在字符串文本中的清晰实用的方法。,...

深入了解字符串:定义、转义字符和字符串下标

字符串是编程中常见的数据类型之一,用于表示文本信息。在绝大多数编程语言中,字符串都是由一系列字符组成的序列,可以包含字母、数字、符号以及空格等。字符串的定义:...

100个Java工具类之70:字符串处理工具类StringUtils

StringUtils是常用的工具类,提供大量处理字符串的静态方法。StringUtils主要特点...

Shell中针对字符串的切片,截取,替换,删除,大小写操作

切片返回字符串变量var的长度...

Sqlite - 常规函数 - RTRIM(sqlite命令行工具)

在SQLite中,RTRIM函数是一个用于处理字符串的函数,其主要作用是移除字符串右侧(尾部)的指定字符。如果不指定要移除的字符,默认会移除字符串右侧的空格字符。以下是对RTRIM函数的详细...

取消回复欢迎 发表评论: