本文主要讲解两种野指针检测的原理及实现
本文的主要目的是理解野指针的形成过程以及如何去检测野指针
在介绍野指针之前,首先说下目前的异常处理类型,附上苹果官网链接)
异常大致可以分为两类:
软件异常
:主要是来自kill()、pthread_kill()、iOS中的NSException未捕获、absort等硬件异常
:硬件的信号始于处理器trap,是和平台相关的,野指针崩溃大部分是硬件异常而在处理异常时,需要关注两个概念
Mach异常
:Mach层
捕获UNIX信号
:BSD层
获取iOS中的POSIX API就是通过Mach之上的BSD层实现的,如下图所示
image
Mach
是一个受 Accent 启发而搞出的Unix兼容系统。BSD
层是建立在Mach之上,是XNU中一个不可分割的一部分。BSD负责提供可靠的、现代的APIPOSIX
表示可移植操作系统接口(Portable Operating System Interface)所以,综上所述,Mach异常和UNIX信号存在对应的关系
image
Mach异常与UNIX信号的转换
下面是Mach异常
与 UNIX信号
的转换关系代码,来自 xnu
中的 bsd/uxkern/ux_exception.c
switch(exception) {
case EXC_BAD_ACCESS:
if (code == KERN_INVALID_ADDRESS)
*ux_signal = SIGSEGV;
else
*ux_signal = SIGBUS;
break;
case EXC_BAD_INSTRUCTION:
*ux_signal = SIGILL;
break;
case EXC_ARITHMETIC:
*ux_signal = SIGFPE;
break;
case EXC_EMULATION:
*ux_signal = SIGEMT;
break;
case EXC_SOFTWARE:
switch (code) {
case EXC_UNIX_BAD_SYSCALL:
*ux_signal = SIGSYS;
break;
case EXC_UNIX_BAD_PIPE:
*ux_signal = SIGPIPE;
break;
case EXC_UNIX_ABORT:
*ux_signal = SIGABRT;
break;
case EXC_SOFT_SIGNAL:
*ux_signal = SIGKILL;
break;
}
break;
case EXC_BREAKPOINT:
*ux_signal = SIGTRAP;
break;
}
image
Mach异常 | 说明 |
---|---|
EXC_BAD_ACCESS | 不能访问的内存 |
EXC_BAD_INSTRUCTION | 非法或未定义的指令或操作数 |
EXC_ARITHMETIC | 算术异常(例如除以0)。iOS 默认是不启用的,所以我们一般不会遇到 |
EXC_EMULATION | 执行打算用于支持仿真的指令 |
EXC_SOFTWARE | 软件生成的异常,我们在 Crash 日志中一般不会看到这个类型,苹果的日志里会是 EXC_CRASH |
EXC_BREAKPOINT | 跟踪或断点 |
EXC_SYSCALL | UNIX 系统调用 |
EXC_MACH_SYSCALL | Mach 系统调用 |
UNIX信号 | 说明 |
---|---|
SIGSEGV | 段错误。访问未分配内存、写入没有写权限的内存等。 |
SIGBUS | 总线错误。比如内存地址对齐、错误的内存类型访问等。 |
SIGILL | 执行了非法指令,一般是可执行文件出现了错误 |
SIGFPE | 致命的算术运算。比如数值溢出、NaN数值等。 |
SIGABRT | 调用 abort() 产生,通过 pthread_kill() 发送。 |
SIGPIPE | 管道破裂。通常在进程间通信产生。比如采用FIFO(管道)通信的两个进程,读管道没打开或者意外终止就往管道写,写进程会收到SIGPIPE信号。根据苹果相关文档,可以忽略这个信号。 |
SIGSYS | 系统调用异常。 |
SIGKILL | 此信号表示系统中止进程。崩溃报告会包含代表中止原因的编码。exit(), kill(9) 等函数调用。iOS 系统杀进程,如 watchDog 杀进程。 |
SIGTRAP | 断点指令或者其他trap指令产生。 |
所指向的对象被释放或者收回
,但是该指针没有作任何的修改
,以至于该指针仍旧指向已经回收的内存地址
。这个指针就是野指针
野指针分类
这个参考腾讯Bugly团队的总结,大致分为两类
如下图所示
image
为什么OC野指针的crash这么多?
我们一般在app发版前,都会经过多轮的自测、内侧、灰度测试
等,按照常理来说,大部分的crash应该都被覆盖了,但是由于野指针的随机性
,使得经常在测试时不会出现crash,而是在线上出现crash
,这对app体验来说是非常致命的
而野指针的随机性问题大致可以分为两类:
提高测试场景覆盖率
来解决野指针
其本质是一个指向已经删除的对象
或受限内存区域
的指针
。这里说的OC野指针
,是指OC对象释放后指针未置空而导致的野指针
。这里不必现的原因是因为dealloc
执行后只是告诉系统,这片内存我不用了,而系统并没有让这片内存不能访问
这里主要是借鉴Xcode中的两种处理方案:
image
alloc
时在内存上填0xAA
,释放内存 dealloc
在内存上填 0x55
。
image
Zombie Objects
(僵尸对象)。这种方案的重点就是将释放的对象,全都转为僵尸对象
image
两种方案对比
僵尸对象
相比 Malloc Scribble
,不需要考虑会不会崩溃的问题
,只要野指针指向僵尸对象,那么再次访问野指针就一定会崩溃不如Malloc Scribble覆盖面广
,可以通过hook free方法将c函数也包含在其中思路:当访问到对象内存中填充的是0xAA、0x55
时,程序就会出现异常
alloc
时在内存上填0xAA
,dealloc
在内存上填 0x55
。以上的申请和释放的填充分别对应一下两种情况
所以综上所述,针对野指针,我们的解决办法是:在对象释放时做数据填充0x55
即可。关于对象的释放流程可以参考这篇文章iOS-底层原理 33:内存管理(一)TaggedPointer/retain/release/dealloc/retainCount 底层分析
这个实现主要依据腾讯Bugly工程师:陈其锋的分享,在其代码中的主要思路是
fishhook
替换C函数
的free
方法为自定义的safe_free
,类似于Method Swizzlingsafe_free
方法中对已经释放变量的内存
,填充0x55
,使已经释放变量不能访问
,从而使某些野指针的crash从不必现安变成必现
。
防止填充0x55的内存被新的数据内容填充
,使野指针crash变成不必现,在这里采用的策略是,safe_free不释放这片内存,而是自己保留着
,即safe_free方法中不会真的调用free。防止系统内存过快消耗
(因为要保留内存),需要在保留的内存大于一定值时释放一部分
,防止被系统杀死,同时,在收到系统内存警告
时,也需要释放一部分内存
NSProxy
的子类),重写消息转发的三个方法(参考这篇文章iOS-底层原理 14:消息流程分析之 动态方法决议 & 消息转发),以及NSObject的实例方法,来获取异常信息。但是这的话,还有一个问题,就是NSProxy只能做OC对象的代理,所以需要在safe_free中增加对象类型的判断以下是完整的野指针探测实现代码
image
<!--1、MIZombieProxy.h-->
@interface MIZombieProxy : NSProxy
@property (nonatomic, assign) Class originClass;
@end
<!--2、MIZombieProxy.m-->
#import "MIZombieProxy.h"
@implementation MIZombieProxy
- (BOOL)respondsToSelector:(SEL)aSelector{
return [self.originClass instancesRespondToSelector:aSelector];
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel{
return [self.originClass instanceMethodSignatureForSelector:sel];
}
- (void)forwardInvocation: (NSInvocation *)invocation
{
[self _throwMessageSentExceptionWithSelector: invocation.selector];
}
#define MIZombieThrowMesssageSentException() [self _throwMessageSentExceptionWithSelector: _cmd]
- (Class)class{
MIZombieThrowMesssageSentException();
return nil;
}
- (BOOL)isEqual:(id)object{
MIZombieThrowMesssageSentException();
return NO;
}
- (NSUInteger)hash{
MIZombieThrowMesssageSentException();
return 0;
}
- (id)self{
MIZombieThrowMesssageSentException();
return nil;
}
- (BOOL)isKindOfClass:(Class)aClass{
MIZombieThrowMesssageSentException();
return NO;
}
- (BOOL)isMemberOfClass:(Class)aClass{
MIZombieThrowMesssageSentException();
return NO;
}
- (BOOL)conformsToProtocol:(Protocol *)aProtocol{
MIZombieThrowMesssageSentException();
return NO;
}
- (BOOL)isProxy{
MIZombieThrowMesssageSentException();
return NO;
}
- (NSString *)description{
MIZombieThrowMesssageSentException();
return nil;
}
#pragma mark - MRC
- (instancetype)retain{
MIZombieThrowMesssageSentException();
return nil;
}
- (oneway void)release{
MIZombieThrowMesssageSentException();
}
- (void)dealloc
{
MIZombieThrowMesssageSentException();
[super dealloc];
}
- (NSUInteger)retainCount{
MIZombieThrowMesssageSentException();
return 0;
}
- (struct _NSZone *)zone{
MIZombieThrowMesssageSentException();
return nil;
}
#pragma mark - private
- (void)_throwMessageSentExceptionWithSelector:(SEL)selector{
@throw [NSException exceptionWithName:NSInternalInconsistencyException reason:[NSString stringWithFormat:@"(-[%@ %@]) was sent to a zombie object at address: %p", NSStringFromClass(self.originClass),NSStringFromSelector(selector), self] userInfo:nil];
}
@end
<!--1、MISafeFree.h-->
@interface MISafeFree : NSObject
//系统警告时,用函数释放一些内存
void free_safe_mem(size_t freeNum);
@end
<!--2、MISafeFree.m-->
#import "MISafeFree.h"
#import "queue.h"
#import "fishhook.h"
#import "MIZombieProxy.h"
#import <dlfcn.h>
#import <objc/runtime.h>
#import <malloc/malloc.h>
//用于保存zombie类
static Class kMIZombieIsa;
//用于保存zombie类的实例变量大小
static size_t kMIZombieSize;
//用于表示调用free函数
static void(* orig_free)(void *p);
//用于保存已注册的类的集合
static CFMutableSetRef registeredClasses = nil;
/*
用来保存自己保留的内存
- 1、队列要线程安全或者自己加锁
- 2、这个队列内部应该尽量少申请和释放堆内存
*/
struct DSQueue *_unfreeQueue = NULL;
//用来记录自己保存的内存的大小
int unfreeSize = 0;
//最多存储的内存,大于这个值就释放一部分
#define MAX_STEAL_MEM_SIZE 1024*1024*100
//最多保留的指针个数,超过就释放一部分
#define MAX_STEAL_MEM_NUM 1024*1024*10
//每次释放时释放的指针数量
#define BATCH_FREE_NUM 100
@implementation MISafeFree
#pragma mark - Public Method
//系统警告时,用函数释放一些内存
void free_safe_mem(size_t freeNum){
#ifdef DEBUG
//获取队列的长度
size_t count = ds_queue_length(_unfreeQueue);
//需要释放的内存大小
freeNum = freeNum > count ? count : freeNum;
//遍历并释放
for (int i = 0; i < freeNum; i++) {
//获取未释放的内存块
void *unfreePoint = ds_queue_get(_unfreeQueue);
//创建内存块申请的大小
size_t memSize = malloc_size(unfreePoint);
//原子减操作,多线程对全局变量进行自减
__sync_fetch_and_sub(&unfreeSize, (int)memSize);
//释放
orig_free(unfreePoint);
}
#endif
}
#pragma mark - Life Circle
+ (void)load{
#ifdef DEBUG
loadZombieProxyClass();
init_safe_free();
#endif
}
#pragma mark - Private Method
void safe_free(void* p){
//获取自己保留的内存的大小
int unFreeCount = ds_queue_length(_unfreeQueue);
//保留的内存大于一定值时就释放一部分
if (unFreeCount > MAX_STEAL_MEM_NUM*0.9 || unfreeSize>MAX_STEAL_MEM_SIZE) {
free_safe_mem(BATCH_FREE_NUM);
}else{
//创建p申请的内存大小
size_t memSize = malloc_size(p);
//有足够的空间才覆盖
if (memSize > kMIZombieSize) {
//指针强转为id对象
id obj = (id)p;
//获取指针原本的类
Class origClass = object_getClass(obj);
//判断是不是objc对象
char *type = @encode(typeof(obj));
/*
- strcmp 字符串比较
- CFSetContainsValue 查看已注册类中是否有origClass这个类
如果都满足,则将这块内存填充0x55
*/
if (strcmp("@", type) == 0 && CFSetContainsValue(registeredClasses, origClass)) {
//内存上填充0x55
memset(obj, 0x55, memSize);
//将自己类的isa复制过去
memcpy(obj, &kMIZombieIsa, sizeof(void*));
//为obj设置指定的类
object_setClass(obj, [MIZombieProxy class]);
//保留obj原本的类
((MIZombieProxy*)obj).originClass = origClass;
//多线程下int的原子加操作,多线程对全局变量进行自加,不用理会线程锁了
__sync_fetch_and_add(&unfreeSize, (int)memSize);
//入队
ds_queue_put(_unfreeQueue, p);
}else{
orig_free(p);
}
}else{
orig_free(p);
}
}
}
//加载野指针自定义类
void loadZombieProxyClass(){
registeredClasses = CFSetCreateMutable(NULL, 0, NULL);
//用于保存已注册类的个数
unsigned int count = 0;
//获取所有已注册的类
Class *classes = objc_copyClassList(&count);
//遍历,并保存到registeredClasses中
for (int i = 0; i < count; i++) {
CFSetAddValue(registeredClasses, (__bridge const void *)(classes[i]));
}
//释放临时变量内存
free(classes);
classes = NULL;
kMIZombieIsa = objc_getClass("MIZombieProxy");
kMIZombieSize = class_getInstanceSize(kMIZombieIsa);
}
//初始化以及free符号重绑定
bool init_safe_free(){
//初始化用于保存内存的队列
_unfreeQueue = ds_queue_create(MAX_STEAL_MEM_NUM);
//dlsym 在打开的库中查找符号的值,即动态调用free函数
orig_free = (void(*)(void*))dlsym(RTLD_DEFAULT, "free");
/*
rebind_symbols:符号重绑定
- 参数1:rebindings 是一个rebinding数组,其定义如下
struct rebinding {
const char *name; // 目标符号名
void *replacement; // 要替换的符号值(地址值)
void **replaced; // 用来存放原来的符号值(地址值)
};
- 参数2:rebindings_nel 描述数组的长度
*/
//重绑定free符号,让它指向自定义的safe_free函数
rebind_symbols((struct rebinding[]){{"free", (void*)safe_free}}, 1);
return true;
}
@end
- (void)viewDidLoad {
[super viewDidLoad];
id obj = [[NSObject alloc] init];
self.assignObj = obj;
// [MIZombieSniffer installSniffer];
}
- (IBAction)mallocScribbleAction:(id)sender {
UIView* testObj = [[UIView alloc] init];
[testObj release];
for (int i = 0; i < 10; i++) {
UIView* testView = [[UIView alloc] initWithFrame:CGRectMake(0,200,CGRectGetWidth(self.view.bounds), 60)];
[self.view addSubview:testView];
}
[testObj setNeedsLayout];
}
打印结果如下
image
僵尸对象
EXC_BAD_ACCESS
),它可以捕获任何阐释访问坏内存的调用苹果的僵尸对象检测原理 首先我们来看下Xcode中僵尸对象是如何实现的,具体操作步骤可以参考这篇文章iOS Zombie Objects(僵尸对象)原理探索
dealloc
的源码中,我们可以看到“Replaced by NSZombie”
,即对象释放
时, NSZombie 将在 dealloc 里做替换
,如下所示
image 所以僵尸对象的生成过程伪代码如下
//1、获取到即将deallocted对象所属类(Class)
Class cls = object_getClass(self);
//2、获取类名
const char *clsName = class_getName(cls)
//3、生成僵尸对象类名
const char *zombieClsName = "_NSZombie_" + clsName;
//4、查看是否存在相同的僵尸对象类名,不存在则创建
Class zombieCls = objc_lookUpClass(zombieClsName);
if (!zombieCls) {
//5、获取僵尸对象类 _NSZombie_
Class baseZombieCls = objc_lookUpClass(“_NSZombie_");
//6、创建 zombieClsName 类
zombieCls = objc_duplicateClass(baseZombieCls, zombieClsName, 0);
}
//7、在对象内存未被释放的情况下销毁对象的成员变量及关联引用。
objc_destructInstance(self);
//8、修改对象的 isa 指针,令其指向特殊的僵尸类
objc_setClass(self, zombieCls);
image 所以僵尸对象触发流程伪代码如下
//1、获取对象class
Class cls = object_getClass(self);
//2、获取对象类名
const char *clsName = class_getName(cls);
//3、检测是否带有前缀_NSZombie_
if (string_has_prefix(clsName, "_NSZombie_")) {
//4、获取被野指针对象类名
const char *originalClsName = substring_from(clsName, 10);
//5、获取当前调用方法名
const char *selectorName = sel_getName(_cmd);
//6、输出日志
Log(''*** - [%s %s]: message sent to deallocated instance %p", originalClsName, selectorName, self);
//7、结束进程
abort();
所以综上所述,这中野指针探测方式的思路是:dealloc
方法的替换,其关键是调用objc_destructInstance
来解除对象的关联引用
这种方式的思路主要是来源sindrilin的源码,其主要思路是:
通过僵尸对象检测的实现思路
Mehod Swizzling
,交换根类NSObject和NSProxy
的dealloc
方法为自定义的dealloc
方法避免内存空间释放后被重写造成野指针
的问题,通过字典存储被释放的对象
,同时设置在30s后调用dealloc方法将字典中存储的对象释放,避免内存增大
具体实现
MIZombieProxy
是一模一样的<!--1、MIZombieSniffer.h-->
@interface MIZombieSniffer : NSObject
/*!
* @method installSniffer
* 启动zombie检测
*/
+ (void)installSniffer;
/*!
* @method uninstallSnifier
* 停止zombie检测
*/
+ (void)uninstallSnifier;
/*!
* @method appendIgnoreClass
* 添加白名单类
*/
+ (void)appendIgnoreClass: (Class)cls;
@end
<!--2、MIZombieSniffer.m-->
#import "MIZombieSniffer.h"
#import "MIZombieProxy.h"
#import <objc/runtime.h>
//
typedef void (*MIDeallocPointer) (id objc);
//野指针探测器是否开启
static BOOL _enabled = NO;
//根类
static NSArray *_rootClasses = nil;
//用于存储被释放的对象
static NSDictionary<id, NSValue*> *_rootClassDeallocImps = nil;
//白名单
static inline NSMutableSet *__mi_sniffer_white_lists(){
//创建白名单集合
static NSMutableSet *mi_sniffer_white_lists;
//单例初始化白名单集合
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
mi_sniffer_white_lists = [[NSMutableSet alloc] init];
});
return mi_sniffer_white_lists;
}
static inline void __mi_dealloc(__unsafe_unretained id obj){
//获取对象的类
Class currentCls = [obj class];
Class rootCls = currentCls;
//获取非NSObject和NSProxy的类
while (rootCls != [NSObject class] && rootCls != [NSProxy class]) {
//获取rootCls的父类,并赋值
rootCls = class_getSuperclass(rootCls);
}
//获取类名
NSString *clsName = NSStringFromClass(rootCls);
//根据类名获取dealloc的imp指针
MIDeallocPointer deallocImp = NULL;
[[_rootClassDeallocImps objectForKey:clsName] getValue:&deallocImp];
if (deallocImp != NULL) {
deallocImp(obj);
}
}
//hook交换dealloc
static inline IMP __mi_swizzleMethodWithBlock(Method method, void *block){
/*
imp_implementationWithBlock :接收一个block参数,将其拷贝到堆中,返回一个trampoline
可以让block当做任何一个类的方法的实现,即当做类的方法的IMP来使用
*/
IMP blockImp = imp_implementationWithBlock((__bridge id _Nonnull)(block));
//method_setImplementation 替换掉method的IMP
return method_setImplementation(method, blockImp);
}
@implementation MIZombieSniffer
//初始化根类
+ (void)initialize
{
_rootClasses = [@[[NSObject class], [NSProxy class]] retain];
}
#pragma mark - public
+ (void)installSniffer{
@synchronized (self) {
if (!_enabled) {
//hook根类的dealloc方法
[self _swizzleDealloc];
_enabled = YES;
}
}
}
+ (void)uninstallSnifier{
@synchronized (self) {
if (_enabled) {
//还原dealloc方法
[self _unswizzleDealloc];
_enabled = NO;
}
}
}
//添加百名单
+ (void)appendIgnoreClass:(Class)cls{
@synchronized (self) {
NSMutableSet *whiteList = __mi_sniffer_white_lists();
NSString *clsName = NSStringFromClass(cls);
[clsName retain];
[whiteList addObject:clsName];
}
}
#pragma mark - private
+ (void)_swizzleDealloc{
static void *swizzledDeallocBlock = NULL;
//定义block,作为方法的IMP
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
swizzledDeallocBlock = (__bridge void *)[^void(id obj) {
//获取对象的类
Class currentClass = [obj class];
//获取类名
NSString *clsName = NSStringFromClass(currentClass);
//判断该类是否在白名单类
if ([__mi_sniffer_white_lists() containsObject: clsName]) {
//如果在白名单内,则直接释放对象
__mi_dealloc(obj);
} else {
//修改对象的isa指针,指向MIZombieProxy
/*
valueWithBytes:objCType 创建并返回一个包含给定值的NSValue对象,该值会被解释为一个给定的NSObject类型
- 参数1:NSValue对象的值
- 参数2:给定值的对应的OC类型,需要使用编译器指令@encode来创建
*/
NSValue *objVal = [NSValue valueWithBytes: &obj objCType: @encode(typeof(obj))];
//为obj设置指定的类
object_setClass(obj, [MIZombieProxy class]);
//保留对象原本的类
((MIZombieProxy *)obj).originClass = currentClass;
//设置在30s后调用dealloc将存储的对象释放,避免内存空间的增大
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(30 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
__unsafe_unretained id deallocObj = nil;
//获取需要dealloc的对象
[objVal getValue: &deallocObj];
//设置对象的类为原本的类
object_setClass(deallocObj, currentClass);
//释放
__mi_dealloc(deallocObj);
});
}
} copy];
});
//交换了根类NSObject和NSProxy的dealloc方法为originalDeallocImp
NSMutableDictionary *deallocImps = [NSMutableDictionary dictionary];
//遍历根类
for (Class rootClass in _rootClasses) {
//获取指定类中dealloc方法
Method oriMethod = class_getInstanceMethod([rootClass class], NSSelectorFromString(@"dealloc"));
//hook - 交换dealloc方法的IMP实现
IMP originalDeallocImp = __mi_swizzleMethodWithBlock(oriMethod, swizzledDeallocBlock);
//设置IMP的具体实现
[deallocImps setObject: [NSValue valueWithBytes: &originalDeallocImp objCType: @encode(typeof(IMP))] forKey: NSStringFromClass(rootClass)];
}
//_rootClassDeallocImps字典存储交换后的IMP实现
_rootClassDeallocImps = [deallocImps copy];
}
+ (void)_unswizzleDealloc{
//还原dealloc交换的IMP
[_rootClasses enumerateObjectsUsingBlock:^(Class rootClass, NSUInteger idx, BOOL * _Nonnull stop) {
IMP originDeallocImp = NULL;
//获取根类类名
NSString *clsName = NSStringFromClass(rootClass);
//获取hook后的dealloc实现
[[_rootClassDeallocImps objectForKey:clsName] getValue:&originDeallocImp];
NSParameterAssert(originDeallocImp);
//获取原本的dealloc实现
Method oriMethod = class_getInstanceMethod([rootClass class], NSSelectorFromString(@"dealloc"));
//还原dealloc的实现
method_setImplementation(oriMethod, originDeallocImp);
}];
//释放
[_rootClassDeallocImps release];
_rootClassDeallocImps = nil;
}
@end
@interface ViewController ()
@property (nonatomic, assign) id assignObj;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
id obj = [[NSObject alloc] init];
self.assignObj = obj;
[MIZombieSniffer installSniffer];
}
- (IBAction)zombieObjectAction:(id)sender {
NSLog(@"%@", self.assignObj);
}
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。