前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Android Native库的加载及动态链接的过程

Android Native库的加载及动态链接的过程

作者头像
砸漏
发布2020-11-01 14:44:54
2.9K0
发布2020-11-01 14:44:54
举报
文章被收录于专栏:恩蓝脚本恩蓝脚本

Native库的装载过程

我们从一个简单的NDK Demo开始分析。

代码语言:javascript
复制
public class MainActivity extends AppCompatActivity {
 @Override
 protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.activity_main);
  // Example of a call to a native method
  final TextView tv = (TextView) findViewById(R.id.sample_text);
  tv.setOnClickListener(new View.OnClickListener() {
   @Override
   public void onClick(View view) {
    tv.setText(stringFromJNI());
   }
  });
 }
 /**
  * A native method that is implemented by the 'native-lib' native library,
  * which is packaged with this application.
  */
 public native String stringFromJNI();
 // Used to load the 'native-lib' library on application startup.
 // 动态库的装载及链接
 static {
  System.loadLibrary("native-lib");
 }
}

Android 链接器Linker之前的工作

下面从 System.loadLibrary() 开始分析。

代码语言:javascript
复制
public static void loadLibrary(String libname) {
  这里VMStack.getCallingClassLoader()返回应用的类加载器
  Runtime.getRuntime().loadLibrary0(VMStack.getCallingClassLoader(), libname);
 }

下面看 loadLibrary0()

代码语言:javascript
复制
synchronized void loadLibrary0(ClassLoader loader, String libname) {
  if (libname.indexOf((int)File.separatorChar) != -1) {
   throw new UnsatisfiedLinkError(
 "Directory separator should not appear in library name: " + libname);
  }
  String libraryName = libname;
  if (loader != null) {
   // findLibrary()返回库的全路径名
   String filename = loader.findLibrary(libraryName);
   if (filename == null) {
    // It's not necessarily true that the ClassLoader used
    // System.mapLibraryName, but the default setup does, and it's
    // misleading to say we didn't find "libMyLibrary.so" when we
    // actually searched for "liblibMyLibrary.so.so".
    throw new UnsatisfiedLinkError(loader + " couldn't find \"" +
            System.mapLibraryName(libraryName) + "\"");
   }
   // 装载动态库
   String error = doLoad(filename, loader);
   if (error != null) {
    throw new UnsatisfiedLinkError(error);
   }
   return;
  }
  ......
 }

参数 loader 为Android的应用类加载器,它是 PathClassLoader 类型的对象,继承自 BaseDexClassLoader 对象,下面看 BaseDexClassLoader 的 findLibrary() 方法。

代码语言:javascript
复制
public String findLibrary(String name) {
  // 调用DexPathList的findLibrary方法
  return pathList.findLibrary(name);
 }

下面看 DexPathList 的 findLibrary() 方法

代码语言:javascript
复制
public String findLibrary(String libraryName) {
  // 产生平台相关的库名称这里返回libxxx.so
  String fileName = System.mapLibraryName(libraryName);
  for (Element element : nativeLibraryPathElements) {
   // 查找动态库返回库的全路径名
   String path = element.findNativeLibrary(fileName);

   if (path != null) {
    return path;
   }
  }
  return null;
 }

回到 loadLibrary0() ,有了动态库的全路径名就可以装载库了,下面看 doLoad() 。

代码语言:javascript
复制
private String doLoad(String name, ClassLoader loader) {
  ......
  // 获取应用类加载器的Native库搜索路径
  String librarySearchPath = null;
  if (loader != null && loader instanceof BaseDexClassLoader) {
   BaseDexClassLoader dexClassLoader = (BaseDexClassLoader) loader;
   librarySearchPath = dexClassLoader.getLdLibraryPath();
  }
  // nativeLoad should be synchronized so there's only one LD_LIBRARY_PATH in use regardless
  // of how many ClassLoaders are in the system, but dalvik doesn't support synchronized
  // internal natives.
  synchronized (this) {
   return nativeLoad(name, loader, librarySearchPath);
  }
 }

nativeLoad() 最终调用 LoadNativeLibrary() ,下面直接分析 LoadNativeLibrary() 。

代码语言:javascript
复制
bool JavaVMExt::LoadNativeLibrary(JNIEnv* env,
         const std::string& path,
         jobject class_loader,
         jstring library_path,
         std::string* error_msg) {
 ......
 SharedLibrary* library;
 Thread* self = Thread::Current();
 {
 // TODO: move the locking (and more of this logic) into Libraries.
 // 检查动态库是否已经装载过,如果已经装载过(类加载器也匹配)直接返回。
 MutexLock mu(self, *Locks::jni_libraries_lock_);
 library = libraries_- Get(path);
 }
 ......
 // 没有装载过,装载链接动态库
 // 参数patch_str传递的是动态库的全路径,之所以还要传递搜索路径,是因为可能包含它的依赖库
 void* handle = android::OpenNativeLibrary(env,
           runtime_- GetTargetSdkVersion(),
           path_str,
           class_loader,
           library_path);
 ......
 // 查找动态库中的"JNI_OnLoad"函数
 sym = library- FindSymbol("JNI_OnLoad", nullptr);
 if (sym == nullptr) {
 VLOG(jni) << "[No JNI_OnLoad found in \"" << path << "\"]";
 was_successful = true;
 } else {
 // Call JNI_OnLoad. We have to override the current class
 // loader, which will always be "null" since the stuff at the
 // top of the stack is around Runtime.loadLibrary(). (See
 // the comments in the JNI FindClass function.)
 ScopedLocalRef<jobject  old_class_loader(env, env- NewLocalRef(self- GetClassLoaderOverride()));
 self- SetClassLoaderOverride(class_loader);

 VLOG(jni) << "[Calling JNI_OnLoad in \"" << path << "\"]";
 typedef int (*JNI_OnLoadFn)(JavaVM*, void*);
 JNI_OnLoadFn jni_on_load = reinterpret_cast<JNI_OnLoadFn (sym);
 // 调用库的JNI_OnLoad函数注册JNI, 本文暂不讨论
 int version = (*jni_on_load)(this, nullptr);
 ......
 }
 ......
}

对于JNI注册,这里暂不讨论,下面看 OpenNativeLibrary() 的实现。

代码语言:javascript
复制
void* OpenNativeLibrary(JNIEnv* env,
      int32_t target_sdk_version,
      const char* path,
      jobject class_loader,
      jstring library_path) {
#if defined(__ANDROID__)
 UNUSED(target_sdk_version);
 if (class_loader == nullptr) {
 return dlopen(path, RTLD_NOW);
 }
 std::lock_guard<std::mutex  guard(g_namespaces_mutex);
 // 找到类加载器映射的命名空间(Android应用类加载器创建时创建)
 // 关于命名空间的动态链接请参考http://jackwish.net/namespace-based-dynamic-linking-chn.html
 android_namespace_t* ns = g_namespaces- FindNamespaceByClassLoader(env, class_loader);
 .......
 android_dlextinfo extinfo;
 // 在一个不同的命名空间中装载
 extinfo.flags = ANDROID_DLEXT_USE_NAMESPACE;
 extinfo.library_namespace = ns;
 // RILD_NOW表示重定位在dlopen返回前完成,不会延迟到第一次执行(RTLD_LAZY)
 return android_dlopen_ext(path, RTLD_NOW, &extinfo);
 ......
}

下面看 android_dlopen_ext() 的实现

代码语言:javascript
复制
void* android_dlopen_ext(const char* filename, int flags, const android_dlextinfo* extinfo) {
 // __builtin_return_address是编译器的内建函数,__builtin_return_address(0)表示当前函数的返回地址
 void* caller_addr = __builtin_return_address(0);
 return dlopen_ext(filename, flags, extinfo, caller_addr);
}

接下来就Android链接器linker的工作了。

Android 链接器Linker的装载工作

下面从 do_dlopen() 开始分析。

代码语言:javascript
复制
void* do_dlopen(const char* name, int flags, const android_dlextinfo* extinfo,
     void* caller_addr) {
 // caller_addr在libnativeloader.so中
 // 查找地址所在的动态库(采用遍历查找,可以优化查找)
 soinfo* const caller = find_containing_library(caller_addr);
 // ns为调用库所在命名空间
 android_namespace_t* ns = get_caller_namespace(caller);
 ......
 if (extinfo != nullptr) {
 ......
 // extinfo- flags为ANDROID_DLEXT_USE_NAMESPACE
 if ((extinfo- flags & ANDROID_DLEXT_USE_NAMESPACE) != 0) {
  if (extinfo- library_namespace == nullptr) {
  DL_ERR("ANDROID_DLEXT_USE_NAMESPACE is set but extinfo- library_namespace is null");
  return nullptr;
  }
  // 命名空间使用应用自身类加载器-命名空间
  ns = extinfo- library_namespace;
 }
 }
 ......
 // 在命名空间ns中装载库
 soinfo* si = find_library(ns, translated_name, flags, extinfo, caller);
 ......
}

find_library() 当参数translated_name不为空时,直接调用 find_libraries() ,这是装载链接的关键函数,下面看它的实现。

代码语言:javascript
复制
static bool find_libraries(android_namespace_t* ns,
soinfo* start_with,
const char* const library_names[],
size_t library_names_count, soinfo* soinfos[],
std::vector<soinfo* * ld_preloads,
size_t ld_preloads_count, int rtld_flags,
const android_dlextinfo* extinfo,
bool add_as_children) {
// ns为应用类加载器-命名空间
// 这里start_with为libnativeloader.so的soinfo
// library_names为需要装载的动态库,不包含依赖库
// library_names_count需要装载的动态库的数量,这里为1
// ld_preloads为nullptr
// add_as_children为false
......
// 为需要装载的动态库创建LoadTask添加到load_tasks
// LoadTask用于管理动态库的装载
for (size_t i = 0; i < library_names_count; ++i) {
const char* name = library_names[i];
load_tasks.push_back(LoadTask::create(name, start_with, &readers_map));
}
// Construct global_group.
// 收集命名空间ns中设置了DF_1_GLOBAL(RTLD_GLOBAL:共享库中的符号可被后续装载的库重定位)标志的动态库
soinfo_list_t global_group = make_global_group(ns);
......
// Step 1: expand the list of load_tasks to include
// all DT_NEEDED libraries (do not load them just yet)
// load_tasks以广度优先遍历的顺序存储动态库依赖树
// 例如依赖树: 1
//   / \
//   2 3
//   /  \
//  4  5
// load_tasks: 1- 2- 3- 4- 5
for (size_t i = 0; i<load_tasks.size(); ++i) {
LoadTask* task = load_tasks[i];
soinfo* needed_by = task- get_needed_by();
bool is_dt_needed = needed_by != nullptr && (needed_by != start_with || add_as_children);
task- set_extinfo(is_dt_needed ? nullptr : extinfo);
task- set_dt_needed(is_dt_needed);
// 收集动态库的信息以及它的依赖库
if(!find_library_internal(ns, task, &zip_archive_cache, &load_tasks, rtld_flags)) {
return false;
}
soinfo* si = task- get_soinfo();
if (is_dt_needed) {
// si添加到needed_by的依赖中
needed_by- add_child(si);
}
if (si- is_linked()) {
// 已经链接过的库增加引用计数
si- increment_ref_count();
}
......
if (soinfos_count < library_names_count) {
soinfos[soinfos_count++] = si;
}
}
// Step 2: Load libraries in random order (see b/24047022)
LoadTaskList load_list;
// 需要装载的库放到load_list中
for (auto&& task : load_tasks) {
soinfo* si = task- get_soinfo();
auto pred = [&](const LoadTask* t) {
return t- get_soinfo() == si;
};
if (!si- is_linked() &&
std::find_if(load_list.begin(), load_list.end(), pred) == load_list.end() ) {
load_list.push_back(task);
}
}
// 随机化load_list中库的顺序
shuffle(&load_list);
for (auto&& task : load_list) {
// 装载动态库
if (!task- load()) {
return false;
}
}
// Step 3: pre-link all DT_NEEDED libraries in breadth first order.
// 预链接load_tasks中没有链接过的库,见下文
for (auto&& task : load_tasks) {
soinfo* si = task- get_soinfo();
if (!si- is_linked() && !si- prelink_image()) {
return false;
}
}
// Step 4: Add LD_PRELOADed libraries to the global group for
// future runs. There is no need to explicitly add them to
// the global group for this run because they are going to
// appear in the local group in the correct order.
if (ld_preloads != nullptr) {
for (auto&& si : *ld_preloads) {
si- set_dt_flags_1(si- get_dt_flags_1() | DF_1_GLOBAL);
}
}
// Step 5: link libraries.
// 链接过程,见下文
soinfo_list_t local_group;
// 广度优先遍历添加动态库依赖图到local_group中
walk_dependencies_tree(
(start_with != nullptr && add_as_children) ? &start_with : soinfos,
(start_with != nullptr && add_as_children) ? 1 : soinfos_count,
[&] (soinfo* si) {
local_group.push_back(si);
return true;
});
// We need to increment ref_count in case
// the root of the local group was not linked.
bool was_local_group_root_linked = local_group.front()- is_linked();
bool linked = local_group.visit([&](soinfo* si) {
if (!si- is_linked()) {
if (!si- link_image(global_group, local_group, extinfo)) {
return false;
}
}
return true;
});
if (linked) {
// 设置链接标志
local_group.for_each([](soinfo* si) {
if (!si- is_linked()) {
si- set_linked();
}
});
failure_guard.disable();
}
......
}

find_libraries() 中动态库的装载可以分为两部分

  • 收集动态库的信息及其依赖库
  • 装载动态库及依赖库
  • 收集动态库的信息及依赖库

下面从 find_library_internal() 开始分析。

代码语言:javascript
复制
static bool find_library_internal(android_namespace_t* ns,
LoadTask* task,
ZipArchiveCache* zip_archive_cache,
LoadTaskList* load_tasks,
int rtld_flags) {
soinfo* candidate;
// 在应用类加载器-命名空间中查找动态库是否已经装载过
if (find_loaded_library_by_soname(ns, task- get_name(), &candidate)) {
task- set_soinfo(candidate);
return true;
}
// 在默认命名空间中查找动态库是否已经装载过
if (ns != &g_default_namespace) {
// check public namespace
candidate = g_public_namespace.find_if([&](soinfo* si) {
return strcmp(task- get_name(), si- get_soname()) == 0;
});
......
}
......
// 装载库
if (load_library(ns, task, zip_archive_cache, load_tasks, rtld_flags)) {
return true;
} else {
// In case we were unable to load the library but there
// is a candidate loaded under the same soname but different
// sdk level - return it anyways.
if (candidate != nullptr) {
task- set_soinfo(candidate);
return true;
}
}
return false;
}

下面分析 load_library()

代码语言:javascript
复制
static bool load_library(android_namespace_t* ns,
LoadTask* task,
ZipArchiveCache* zip_archive_cache,
LoadTaskList* load_tasks,
int rtld_flags) {
......
// 打开库文件返回文件描述符
int fd = open_library(ns, zip_archive_cache, name, needed_by, &file_offset, &realpath);
if (fd == -1) {
DL_ERR("library \"%s\" not found", name);
return false;
}
task- set_fd(fd, true);
task- set_file_offset(file_offset);
// 装载库
return load_library(ns, task, load_tasks, rtld_flags, realpath);
}

下面看另一个 load_library() 的实现

代码语言:javascript
复制
static bool load_library(android_namespace_t* ns,
LoadTask* task,
LoadTaskList* load_tasks,
int rtld_flags,
const std::string& realpath) {
off64_t file_offset = task- get_file_offset();
const char* name = task- get_name();
const android_dlextinfo* extinfo = task- get_extinfo();
......
// 为动态库创建soinfo,用于记录动态链接信息等
soinfo* si = soinfo_alloc(ns, realpath.c_str(), &file_stat, file_offset, rtld_flags);
if (si == nullptr) {
return false;
}
task- set_soinfo(si);
// Read the ELF header and some of the segments.
// 读取ELF文件头以及一些段信息
if (!task- read(realpath.c_str(), file_stat.st_size)) {
soinfo_free(si);
task- set_soinfo(nullptr);
return false;
}
......
// 查找依赖库,创建LoadTask添加到load_tasks
for_each_dt_needed(task- get_elf_reader(), [&](const char* name) {
load_tasks- push_back(LoadTask::create(name, si, task- get_readers_map()));
});
return true;
}

下面分析ELF文件头以及段信息的读取过程,也就是LoadTask的 read() ,它直接调用ElfReader的 Read() 方法。

代码语言:javascript
复制
bool ElfReader::Read(const char* name, int fd, off64_t file_offset, off64_t file_size) {
CHECK(!did_read_);
CHECK(!did_load_);
name_ = name;
fd_ = fd;
file_offset_ = file_offset;
file_size_ = file_size;
if (ReadElfHeader() &&
VerifyElfHeader() &&
ReadProgramHeaders() &&
ReadSectionHeaders() &&
ReadDynamicSection()) {
did_read_ = true;
}
return did_read_;
}
ReadElfHeader() : 读取ELF文件头信息
VerifyElfHeader() : 校验ELF(文件类型等)
ReadProgramHeaders() : 根据ELF文件头信息获取程序头表
ReadSectionHeaders() : 根据ELF文件头信息获取段头表
ReadDynamicSection() : 获取Dynamic Section的信息
装载动态库
动态库的装载在LoadTask的 load() 中实现。
bool load() {
ElfReader& elf_reader = get_elf_reader();
// 映射动态库的可加载Segment到进程的虚拟地址空间中
if (!elf_reader.Load(extinfo_)) {
return false;
}
// 保存装载信息
// 动态库装载的起始地址
si_- base = elf_reader.load_start();
// 可装载的Segment大小之和
si_- size = elf_reader.load_size();
si_- set_mapped_by_caller(elf_reader.is_mapped_by_caller());
// 动态库装载的期望起始地址,通常si_- load_bias = si_- base
si_- load_bias = elf_reader.load_bias();
// 动态库程序头表项数
si_- phnum = elf_reader.phdr_count();
// 动态库程序头表的地址
si_- phdr = elf_reader.loaded_phdr();
return true;
}

在实际的地址计算中,使用si_- load_bias,不使用si_- base。

下面看ElfReader的 Load() 方法

代码语言:javascript
复制
bool ElfReader::Load(const android_dlextinfo* extinfo) {
CHECK(did_read_);
CHECK(!did_load_);
if (ReserveAddressSpace(extinfo) &&
LoadSegments() &&
FindPhdr()) {
did_load_ = true;
}
return did_load_;
}

ReserveAddressSpace(): 保留虚拟地址空间为动态库(装载地址随机化) LoadSegments() : 装载ELF文件中可装载的Segments FindPhdr() : 确保程序头表包含在一个可加载的Segment中

动态库的装载已经完成,下面看链接过程。

Native库动态链接的过程

预链接

下面看 prelink_image()

代码语言:javascript
复制
bool soinfo::prelink_image() {
/* Extract dynamic section */
ElfW(Word) dynamic_flags = 0;
// 根据程序头表的地址计算dynamic section的地址
phdr_table_get_dynamic_section(phdr, phnum, load_bias, &dynamic, &dynamic_flags);
......
uint32_t needed_count = 0;
// 解析dynamic section获取动态链接信息
for (ElfW(Dyn)* d = dynamic; d- d_tag != DT_NULL; ++d) {
DEBUG("d = %p, d[0](tag) = %p d[1](val) = %p",
d, reinterpret_cast<void* (d- d_tag), reinterpret_cast<void* (d- d_un.d_val));
switch (d- d_tag) {
......
case DT_STRTAB:
// 动态字符串表的地址
strtab_ = reinterpret_cast<const char* (load_bias + d- d_un.d_ptr);
break;
case DT_STRSZ:
strtab_size_ = d- d_un.d_val;
break;
case DT_SYMTAB:
// 动态符号表的地址
symtab_ = reinterpret_cast<ElfW(Sym)* (load_bias + d- d_un.d_ptr);
break;
......
case DT_JMPREL:
// 需重定位的函数表(.rela.plt)的地址
#if defined(USE_RELA)
plt_rela_ = reinterpret_cast<ElfW(Rela)* (load_bias + d- d_un.d_ptr);
#else
plt_rel_ = reinterpret_cast<ElfW(Rel)* (load_bias + d- d_un.d_ptr);
#endif
break;
......
case DT_RELA:
// 需重定位的数据表(.rela.dyn)的地址
rela_ = reinterpret_cast<ElfW(Rela)* (load_bias + d- d_un.d_ptr);
break;
......
case DT_NEEDED:
// 依赖的动态库
++needed_count;
break;
}
}
......
// Sanity checks.
// 检查动态链接信息
if (relocating_linker && needed_count != 0) {
DL_ERR("linker cannot have DT_NEEDED dependencies on other libraries");
return false;
}
if (nbucket_ == 0 && gnu_nbucket_ == 0) {
DL_ERR("empty/missing DT_HASH/DT_GNU_HASH in \"%s\" "
"(new hash type from the future?)", get_realpath());
return false;
}
if (strtab_ == 0) {
DL_ERR("empty/missing DT_STRTAB in \"%s\"", get_realpath());
return false;
}
if (symtab_ == 0) {
DL_ERR("empty/missing DT_SYMTAB in \"%s\"", get_realpath());
return false;
}
......
}

链接

链接主要完成符号重定位工作,下面从 link_image() 开始分析

代码语言:javascript
复制
bool soinfo::link_image(const soinfo_list_t& global_group, const soinfo_list_t& local_group,
const android_dlextinfo* extinfo) {
......
#if defined(USE_RELA)
// rela_为重定位数据表的地址
if (rela_ != nullptr) {
DEBUG("[ relocating %s ]", get_realpath());
// 数据引用重定位
if (!relocate(version_tracker,
plain_reloc_iterator(rela_, rela_count_), global_group, local_group)) {
return false;
}
}
// plt_rela_为重定位函数表的地址
if (plt_rela_ != nullptr) {
DEBUG("[ relocating %s plt ]", get_realpath());
// 函数引用重定位
if (!relocate(version_tracker,
plain_reloc_iterator(plt_rela_, plt_rela_count_), global_group, local_group)) {
return false;
}
}
#else
......
}

下面以函数引用重定位为例分析 relocate() 方法

代码语言:javascript
复制
template<typename ElfRelIteratorT 
bool soinfo::relocate(const VersionTracker& version_tracker, ElfRelIteratorT&& rel_iterator,
const soinfo_list_t& global_group, const soinfo_list_t& local_group) {
for (size_t idx = 0; rel_iterator.has_next(); ++idx) {
const auto rel = rel_iterator.next();
if (rel == nullptr) {
return false;
}
// rel- r_info的低32位
ElfW(Word) type = ELFW(R_TYPE)(rel- r_info);
// rel- r_info的高32位
ElfW(Word) sym = ELFW(R_SYM)(rel- r_info);
// 重定位地址的存储位置
ElfW(Addr) reloc = static_cast<ElfW(Addr) (rel- r_offset + load_bias);
ElfW(Addr) sym_addr = 0;
const char* sym_name = nullptr;
ElfW(Addr) addend = get_addend(rel, reloc);
......
if (sym != 0) {
// sym为动态符号表项的索引
// symtab_[sym].st_name为符号在动态字符串表的索引
// sysm_name为需重定位的符号名
sym_name = get_string(symtab_[sym].st_name);
const version_info* vi = nullptr;
if (!lookup_version_info(version_tracker, sym, sym_name, &vi)) {
return false;
}
// 查找符号返回符号表项的地址
if (!soinfo_do_lookup(this, sym_name, vi, &lsi, global_group, local_group, &s)) {
return false;
}
if (s == nullptr) {
......
} else {
......
// 根据符号表项计算符号地址
sym_addr = lsi- resolve_symbol_address(s);
......
}
......
}
switch (type) {
// ELF64中R_GENERIC_JUMP_SLOT = R_AARCH64_JUMP_SLOT
case R_GENERIC_JUMP_SLOT:
count_relocation(kRelocAbsolute);
MARK(rel- r_offset);
TRACE_TYPE(RELO, "RELO JMP_SLOT %16p <- %16p %s\n",
reinterpret_cast<void* (reloc),
reinterpret_cast<void* (sym_addr + addend), sym_name);
// 符号地址更新到reloc(GOT表)中
*reinterpret_cast<ElfW(Addr)* (reloc) = (sym_addr + addend);
break;
......
}
}
return true;
}

参考

Tool Interface Standard (TIS) Executable and Linking Format (ELF) Specification

总结

以上所述是小编给大家介绍的Android Native库的加载及动态链接的过程,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对ZaLou.Cn网站的支持!

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2020-09-11 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档