• Android Linker详解

    • 本文目的
    • Linker入口
    • So的装载
    • 总结

本文目的

Unidbg在对So进行模拟执行的时候,需要先将So文件加载到内存,配置So的进程映像,然后使用CPU模拟器(Unicorn、Dynamic等)对So进行模拟执行。本文的目的是为了彻底搞懂So文件是如何加载到内存的,以及加载进内存之后做了什么,史无巨细,握住方向盘

Linker入口

我们在Android程序中,往往会使用到JNI编程来加快某些算法的运行或增加APP的逆向难度。当然这不是Android的新特性,它是Java自带的本地编程接口,可以使我们的Java程序能够调用本地语言。

当我们在Android程序想使用本地编译的So库,第一步就是要将So加载进来对吧,Android Studio创建C/C++ Native模板的时候,它会在我们的MainActivity类中加这么一段代码

static{System.loadLibrary("native-lib");
}

这句代码的作用就是将So加载进来供Android程序来使用,所以以此为入口,开始分析

http://androidxref.com/4.4.4_r1/xref/libcore/luni/src/main/java/java/lang/System.java#525

public static void loadLibrary(String libName) {Runtime.getRuntime().loadLibrary(libName, VMStack.getCallingClassLoader());
}

又调用了Runtime类的loadLibrary,第二个参数为调用类的ClassLoader

http://androidxref.com/4.4.4_r1/xref/libcore/luni/src/main/java/java/lang/Runtime.java#354

void loadLibrary(String libraryName, ClassLoader loader) {if (loader != null) {//调用findLibrary通过名称("native-lib")寻找真实库文件名String filename = loader.findLibrary(libraryName);if (filename == null) {throw new UnsatisfiedLinkError("...");}//如果找到了,进行加载String error = doLoad(filename, loader);if (error != null) {throw new UnsatisfiedLinkError(error);}return;}String filename = System.mapLibraryName(libraryName);List<String> candidates = new ArrayList<String>();String lastError = null;for (String directory : mLibPaths) {String candidate = directory + filename;candidates.add(candidate);if (IoUtils.canOpenReadOnly(candidate)) {//这里是还有其他的路径来搜索我们的库文件名,都是调用doLoad方法String error = doLoad(candidate, loader);if (error == null) {return; // We successfully loaded the library. Job done.}lastError = error;}}if (lastError != null) {throw new UnsatisfiedLinkError(lastError);}throw new UnsatisfiedLinkError("Library " + libraryName + " not found; tried " + candidates);
}

接着看doLoad方法

http://androidxref.com/4.4.4_r1/xref/libcore/luni/src/main/java/java/lang/Runtime.java#393

private String doLoad(String name, ClassLoader loader) {String ldLibraryPath = null;if (loader != null && loader instanceof BaseDexClassLoader) {//如果是BaseDexClassLoader,获取系统so的路径ldLibraryPath = ((BaseDexClassLoader) loader).getLdLibraryPath();}synchronized (this) {//继续调用nativeLoad,还加了同步锁return nativeLoad(name, loader, ldLibraryPath);}
}

http://androidxref.com/4.4.4_r1/xref/libcore/luni/src/main/java/java/lang/Runtime.java#426

private static native String nativeLoad(String filename, ClassLoader loader, String ldLibraryPath);

继续往下分析,找到nativeLoad对应的C层函数

http://androidxref.com/4.4.4_r1/xref/art/runtime/native/java_lang_Runtime.cc#43

static jstring Runtime_nativeLoad(JNIEnv* env, jclass, jstring javaFilename, jobject javaLoader, jstring javaLdLibraryPath) {//...各种检查mirror::ClassLoader* classLoader = soa.Decode<mirror::ClassLoader*>(javaLoader);std::string detail;JavaVMExt* vm = Runtime::Current()->GetJavaVM();bool success = vm->LoadNativeLibrary(filename.c_str(), classLoader, detail);if (success) {return NULL;}// Don't let a pending exception from JNI_OnLoad cause a CheckJNI issue with NewStringUTF.env->ExceptionClear();return env->NewStringUTF(detail.c_str());
}

最关键的函数是vm->LoadNativeLibrary,继续往下跟

http://androidxref.com/4.4.4_r1/xref/art/runtime/jni_internal.cc#3120

bool JavaVMExt::LoadNativeLibrary(const std::string& path, ClassLoader* class_loader,std::string& detail) {//...self->TransitionFromRunnableToSuspended(kWaitingForJniOnLoad);//调用dlopen加载So,并返回一个handle句柄void* handle = dlopen(path.empty() ? NULL : path.c_str(), RTLD_LAZY);self->TransitionFromSuspendedToRunnable();VLOG(jni) << "[Call to dlopen(\"" << path << "\", RTLD_LAZY) returned " << handle << "]";//...//此时如果So加载正常,会调用dlsym查找JNI_OnLoad符号,并执行void* sym = dlsym(handle, "JNI_OnLoad");if (sym == NULL) {VLOG(jni) << "[No JNI_OnLoad found in \"" << path << "\"]";was_successful = true;} else {}typedef int (*JNI_OnLoadFn)(JavaVM*, void*);JNI_OnLoadFn jni_on_load = reinterpret_cast<JNI_OnLoadFn>(sym);ClassLoader* old_class_loader = self->GetClassLoaderOverride();self->SetClassLoaderOverride(class_loader);int version = 0;{ScopedThreadStateChange tsc(self, kNative);VLOG(jni) << "[Calling JNI_OnLoad in \"" << path << "\"]";//在这里调用JNIOnloadversion = (*jni_on_load)(this, NULL);}//...library->SetResult(was_successful);return was_successful;
}

我们分析上面的函数知道,此函数主要做了两件事

  • 调用dlopen加载So
  • 查找So中的JNI_OnLoad函数,并执行
    继续往下分析dlopen

http://androidxref.com/4.4.4_r1/xref/bionic/linker/dlfcn.cpp#63

void* dlopen(const char* filename, int flags) {ScopedPthreadMutexLocker locker(&gDlMutex);soinfo* result = do_dlopen(filename, flags);if (result == NULL) {__bionic_format_dlerror("dlopen failed", linker_get_error_buffer());return NULL;}return result;
}

调用了do_dlopen

So的装载

http://androidxref.com/4.4.4_r1/xref/bionic/linker/linker.cpp#823

soinfo* do_dlopen(const char* name, int flags) {if ((flags & ~(RTLD_NOW|RTLD_LAZY|RTLD_LOCAL|RTLD_GLOBAL)) != 0) {DL_ERR("invalid flags to dlopen: %x", flags);return NULL;}set_soinfo_pool_protection(PROT_READ | PROT_WRITE);soinfo* si = find_library(name);if (si != NULL) {si->CallConstructors();}set_soinfo_pool_protection(PROT_READ);return si;
}

分析到这里,终于进入Linker部分了,上面的篇幅我们由System.loadLibrary()方法,找到了Linker的do_dlopen函数,这个函数就可以说是Linker开始加载的地方了。这个函数主要做了两件事

  • 调用函数find_library,返回soinfo。soinfo就是so被加载到内存的一个代表,存放了内存中so的信息
  • 调用soinfo的CallConstructors函数,做了一些初始化操作(Iint、init.array)

继续分析find_library

http://androidxref.com/4.4.4_r1/xref/bionic/linker/linker.cpp#785

static soinfo* find_library(const char* name) {soinfo* si = find_library_internal(name);if (si != NULL) {si->ref_count++;}return si;
}

这个函数的作用很简单

  • 调用find_library_internal
  • so的引用计数+1

继续分析find_library_internal函数

http://androidxref.com/4.4.4_r1/xref/bionic/linker/linker.cpp#751

static soinfo* find_library_internal(const char* name) {if (name == NULL) {return somain;}//寻找已经加载过的so,当我们的so被加载完成后,会放到已加载列表,再次调用System.loadLibrary的时候不需要进行二次加载soinfo* si = find_loaded_library(name);if (si != NULL) {if (si->flags & FLAG_LINKED) {return si;}DL_ERR("OOPS: recursive link to \"%s\"", si->name);return NULL;}TRACE("[ '%s' has not been loaded yet.  Locating...]", name);//如果没有被加载过,就调用load_library进行加载si = load_library(name);if (si == NULL) {return NULL;}// At this point we know that whatever is loaded @ base is a valid ELF// shared library whose segments are properly mapped in.TRACE("[ init_library base=0x%08x sz=0x%08x name='%s' ]",si->base, si->size, si->name);// so被加载后,进行链接if (!soinfo_link_image(si)) {munmap(reinterpret_cast<void*>(si->base), si->size);soinfo_free(si);return NULL;}return si;
}

这个函数主要做了3个事情:

  • 判断想要加载的so是否已经被加载过
  • 如果没有被加载过,调用load_library进行加载
  • 加载完成后,调用soinfo_link_image函数进行链接
    也就体现了我们So装载的主要两个步骤
  • So的装载
  • So的链接
    在上面我们还有一个调用soinfo的CallConstructors函数,这个也可以作为第三个
  • So的初始化

那么我们假设我们的So是第一次进行加载,继续分析load_library函数,看看linker如何装载我们的So

http://androidxref.com/4.4.4_r1/xref/bionic/linker/linker.cpp#702

static soinfo* load_library(const char* name) {// 打开So文件,拿到文件描述符fdint fd = open_library(name);if (fd == -1) {DL_ERR("library \"%s\" not found", name);return NULL;}//创建ElfReader对象,并调用Load方法ElfReader elf_reader(name, fd);if (!elf_reader.Load()) {return NULL;}//生成soinfo,并根据elf_reader的结果进行赋值const char* bname = strrchr(name, '/');soinfo* si = soinfo_alloc(bname ? bname + 1 : name);if (si == NULL) {return NULL;}si->base = elf_reader.load_start();si->size = elf_reader.load_size();si->load_bias = elf_reader.load_bias();si->flags = 0;si->entry = 0;si->dynamic = NULL;si->phnum = elf_reader.phdr_count();si->phdr = elf_reader.loaded_phdr();return si;
}

那么我们主要来分析elf_reader.Load()函数

http://androidxref.com/4.4.4_r1/xref/bionic/linker/linker_phdr.cpp#134

bool ElfReader::Load() {return ReadElfHeader() &&VerifyElfHeader() &&ReadProgramHeader() &&ReserveAddressSpace() &&LoadSegments() &&FindPhdr();
}

Load函数分别调用了6个函数

  • ReadElfHeader 读取ElfHeader
  • VerifyElfHeader 验证ElfHeader
  • ReadProgramHeader 读取程序头表
  • ReserveAddressSpace 准备地址空间
  • LoadSegments 加载段
  • FindPhdr 寻找Phdr段

从函数名直译,我们也能知道一个大概。下面我们来分析这6个函数

bool ElfReader::ReadElfHeader() {//从我们打开的So文件中,读取header长度的内容赋值到header_ssize_t rc = TEMP_FAILURE_RETRY(read(fd_, &header_, sizeof(header_)));if (rc < 0) {DL_ERR("can't read file \"%s\": %s", name_, strerror(errno));return false;}if (rc != sizeof(header_)) {DL_ERR("\"%s\" is too small to be an ELF executable", name_);return false;}return true;
}
bool ElfReader::VerifyElfHeader() {//校验魔数if (header_.e_ident[EI_MAG0] != ELFMAG0 ||header_.e_ident[EI_MAG1] != ELFMAG1 ||header_.e_ident[EI_MAG2] != ELFMAG2 ||header_.e_ident[EI_MAG3] != ELFMAG3) {DL_ERR("\"%s\" has bad ELF magic", name_);return false;}//因为分析的是Android4.4源码,所以它必须是一个32位的Soif (header_.e_ident[EI_CLASS] != ELFCLASS32) {DL_ERR("\"%s\" not 32-bit: %d", name_, header_.e_ident[EI_CLASS]);return false;}//必须为小端字节序if (header_.e_ident[EI_DATA] != ELFDATA2LSB) {DL_ERR("\"%s\" not little-endian: %d", name_, header_.e_ident[EI_DATA]);return false;}//必须为ET_DYN,也就是我们的Shared Object So文件if (header_.e_type != ET_DYN) {DL_ERR("\"%s\" has unexpected e_type: %d", name_, header_.e_type);return false;}// version当前版本,这个一般都是EV_CURRENT(1)if (header_.e_version != EV_CURRENT) {DL_ERR("\"%s\" has unexpected e_version: %d", name_, header_.e_version);return false;}// 校验e_machineif (header_.e_machine !=
#ifdef ANDROID_ARM_LINKEREM_ARM
#elif defined(ANDROID_MIPS_LINKER)EM_MIPS
#elif defined(ANDROID_X86_LINKER)EM_386
#endif) {DL_ERR("\"%s\" has unexpected e_machine: %d", name_, header_.e_machine);return false;}return true;
}
bool ElfReader::ReadProgramHeader() {//e_phnum 为我们So的程序头表(段)数,后面我们都叫段,是一个意思phdr_num_ = header_.e_phnum;// Like the kernel, we only accept program header tables that// are smaller than 64KiB.if (phdr_num_ < 1 || phdr_num_ > 65536/sizeof(Elf32_Phdr)) {DL_ERR("\"%s\" has invalid e_phnum: %d", name_, phdr_num_);return false;}//e_phoff 为我们段表在文件中的偏移, 然后进行内存页对其Elf32_Addr page_min = PAGE_START(header_.e_phoff);Elf32_Addr page_max = PAGE_END(header_.e_phoff + (phdr_num_ * sizeof(Elf32_Phdr)));Elf32_Addr page_offset = PAGE_OFFSET(header_.e_phoff);// 在Linux中内存读写都是以页为单位,所以上面按照存放段的位置,算出了需要的页大小// page_offset就是段在该页的一个偏移phdr_size_ = page_max - page_min;//将该包含段表的页映射到内存void* mmap_result = mmap(NULL, phdr_size_, PROT_READ, MAP_PRIVATE, fd_, page_min);if (mmap_result == MAP_FAILED) {DL_ERR("\"%s\" phdr mmap failed: %s", name_, strerror(errno));return false;}phdr_mmap_ = mmap_result;// phdr_table_ 就指向了段表在内存中的起始位置phdr_table_ = reinterpret_cast<Elf32_Phdr*>(reinterpret_cast<char*>(mmap_result) + page_offset);return true;
}
bool ElfReader::ReserveAddressSpace() {//此时段表已经被加载到内存Elf32_Addr min_vaddr;//先获取该So的load_size,也就是需要加载的大小,先看下面对该函数的解释load_size_ = phdr_table_get_load_size(phdr_table_, phdr_num_, &min_vaddr);if (load_size_ == 0) {DL_ERR("\"%s\" has no loadable segments", name_);return false;}uint8_t* addr = reinterpret_cast<uint8_t*>(min_vaddr);int mmap_flags = MAP_PRIVATE | MAP_ANONYMOUS;//根据PT_LOAD段的指示,匿名映射一块足够装下我们So的内存void* start = mmap(addr, load_size_, PROT_NONE, mmap_flags, -1, 0);if (start == MAP_FAILED) {DL_ERR("couldn't reserve %d bytes of address space for \"%s\"", load_size_, name_);return false;}load_start_ = start;//这里需要注意一下,start为我们映射出来的那块内存的起始地址,按理说它就是我们So加载的一个起始地址//那这里又计算了一个load_bias_是什么意思呢?//So文件并没有对p_vaddr有特殊要求,所以它可以是任意地址,如果它指定了一个最小的虚拟地址不为0//那么文件中的关于地址的引用就是根据它指定的虚拟地址来的//所以我们在后面进行对地址修正的时候,就要计算 start - min_addr来得到正确的值//所以这里计算了load_bias_, 后面关于地址引用的地方,我们都用这个load_bias_就可以了//举个例子:假设一个So中的PT_LOAD段指定的最小虚拟地址min_vaddr = 0x100//那么如果这个So中的一个函数中引用了一个地址为0x300地方的字符串//那这个字符串在实际文件中的偏移就是0x200 = 0x300 - 0x100//当So加载到内存中,需要对这个函数中的引用做重定位的时候,就应该这样计算//start + 0x300 - 0x100 <==> start - 0x100  + 0x300 //每次在计算的时候都要-0x100,所以这里就计算了一个load_bias_ = start - 0x100//后面直接用这个load_bias_ + 0x300(地址引用偏移) 就可以了load_bias_ = reinterpret_cast<uint8_t*>(start) - addr;return true;
}
size_t phdr_table_get_load_size(const Elf32_Phdr* phdr_table,size_t phdr_count,Elf32_Addr* out_min_vaddr,Elf32_Addr* out_max_vaddr)
{Elf32_Addr min_vaddr = 0xFFFFFFFFU;Elf32_Addr max_vaddr = 0x00000000U;bool found_pt_load = false;//遍历我们的段表for (size_t i = 0; i < phdr_count; ++i) {const Elf32_Phdr* phdr = &phdr_table[i];//只处理PT_LOAD段,因为PT_LOAD段说明了我们的So应该怎么加载if (phdr->p_type != PT_LOAD) {continue;}found_pt_load = true;//遍历所有的PT_LOAD段,寻找So指定的最小的一个虚拟地址if (phdr->p_vaddr < min_vaddr) {min_vaddr = phdr->p_vaddr;}//遍历所有的PT_LOAD段,寻找So指定的要加载到内存的最大的一个虚拟地址if (phdr->p_vaddr + phdr->p_memsz > max_vaddr) {max_vaddr = phdr->p_vaddr + phdr->p_memsz;}}if (!found_pt_load) {min_vaddr = 0x00000000U;}//So文件并没有对p_vaddr有特殊要求,所以这里需要页对齐min_vaddr = PAGE_START(min_vaddr);max_vaddr = PAGE_END(max_vaddr);if (out_min_vaddr != NULL) {*out_min_vaddr = min_vaddr;}if (out_max_vaddr != NULL) {*out_max_vaddr = max_vaddr;}//最大-最小拿到该So加载到内存的一个sizereturn max_vaddr - min_vaddr;
}
//上面ReserveAddressSpace函数,只是开辟了一块足够的内存,并没有内容
//这个函数就在填充内容
bool ElfReader::LoadSegments() {for (size_t i = 0; i < phdr_num_; ++i) {const Elf32_Phdr* phdr = &phdr_table_[i];//还是在遍历每一个PT_LOAD段if (phdr->p_type != PT_LOAD) {continue;}// 计算该PT_LOAD段在内存中的开始地址和结束地址Elf32_Addr seg_start = phdr->p_vaddr + load_bias_;Elf32_Addr seg_end   = seg_start + phdr->p_memsz;Elf32_Addr seg_page_start = PAGE_START(seg_start);Elf32_Addr seg_page_end   = PAGE_END(seg_end);//计算该PT_LOAD段在内存中对应文件的结束位置Elf32_Addr seg_file_end   = seg_start + phdr->p_filesz;//文件中的偏移Elf32_Addr file_start = phdr->p_offset;Elf32_Addr file_end   = file_start + phdr->p_filesz;Elf32_Addr file_page_start = PAGE_START(file_start);Elf32_Addr file_length = file_end - file_page_start;if (file_length != 0) {//将该PT_LOAD段的实际内容页对齐后映射到内存中void* seg_addr = mmap((void*)seg_page_start,file_length,PFLAGS_TO_PROT(phdr->p_flags),MAP_FIXED|MAP_PRIVATE,fd_,file_page_start);if (seg_addr == MAP_FAILED) {DL_ERR("couldn't map \"%s\" segment %d: %s", name_, i, strerror(errno));return false;}}//如果该段的权限可写且该段指定的文件大小并不是页边界对齐的,就要对页内没有文件与之对应的区域置0if ((phdr->p_flags & PF_W) != 0 && PAGE_OFFSET(seg_file_end) > 0) {memset((void*)seg_file_end, 0, PAGE_SIZE - PAGE_OFFSET(seg_file_end));}seg_file_end = PAGE_END(seg_file_end);// 如果该段指定的内存大小超出了文件映射的页面,就要对多出的页进行匿名映射// 防止出现Bus error的情况if (seg_page_end > seg_file_end) {void* zeromap = mmap((void*)seg_file_end,seg_page_end - seg_file_end,PFLAGS_TO_PROT(phdr->p_flags),MAP_FIXED|MAP_ANONYMOUS|MAP_PRIVATE,-1,0);if (zeromap == MAP_FAILED) {DL_ERR("couldn't zero fill \"%s\" gap: %s", name_, strerror(errno));return false;}}}return true;
}
//这个函数看看就可以,就是在找PT_PHDR段并检查,此段指定了段表本身的位置和大小
bool ElfReader::FindPhdr() {const Elf32_Phdr* phdr_limit = phdr_table_ + phdr_num_;// If there is a PT_PHDR, use it directly.for (const Elf32_Phdr* phdr = phdr_table_; phdr < phdr_limit; ++phdr) {if (phdr->p_type == PT_PHDR) {return CheckPhdr(load_bias_ + phdr->p_vaddr);}}for (const Elf32_Phdr* phdr = phdr_table_; phdr < phdr_limit; ++phdr) {if (phdr->p_type == PT_LOAD) {if (phdr->p_offset == 0) {Elf32_Addr  elf_addr = load_bias_ + phdr->p_vaddr;const Elf32_Ehdr* ehdr = (const Elf32_Ehdr*)(void*)elf_addr;Elf32_Addr  offset = ehdr->e_phoff;return CheckPhdr((Elf32_Addr)ehdr + offset);}break;}}DL_ERR("can't find loaded phdr for \"%s\"", name_);return false;
}

至此 So的装载部分就分析完了

总结

总结一下So的装载就是根据So的文件信息,先读入So的头部信息,并进行验证。然后找到段表的位置,遍历段表的每一个段,根据PT_LOAD段指定的信息将So进行装载,如果我们要模拟这个过程,只需要注意一下细节就可以了。相对于So的装载,更难的部分是So的动态链接,我们另起一篇文章来讲解So的动态链接。如果觉得本篇文章对您有用,可以扫码加入我们的星球,不定期分享各种最新的技术

Android Linker详解相关推荐

  1. Android Linker详解(二)

    Android Linker详解(二) Android Linker详解(二) 本文目的 So的链接 So重定位 总结 本文目的 接上篇Linker源码详解(一),本文继续来分析Linker的链接过程 ...

  2. 【转】Android菜单详解——理解android中的Menu--不错

    原文网址:http://www.cnblogs.com/qingblog/archive/2012/06/08/2541709.html 前言 今天看了pro android 3中menu这一章,对A ...

  3. Android菜单详解——理解android中的Menu

    前言 今天看了pro android 3中menu这一章,对Android的整个menu体系有了进一步的了解,故整理下笔记与大家分享. PS:强烈推荐<Pro Android 3>,是我至 ...

  4. Android LayoutInflater详解

    Android LayoutInflater详解 在实际开发中LayoutInflater这个类还是非常有用的,它的作用类 似于findViewById().不同点是LayoutInflater是用来 ...

  5. android Fragments详解

    android Fragments详解一:概述 android Fragments详解二:创建Fragment 转载于:https://my.oschina.net/liangzhenghui/blo ...

  6. android WebView详解,常见漏洞详解和安全源码(下)

    上篇博客主要分析了 WebView 的详细使用,这篇来分析 WebView 的常见漏洞和使用的坑.  上篇:android WebView详解,常见漏洞详解和安全源码(上)  转载请注明出处:http ...

  7. android WebView详解,常见漏洞详解和安全源码(上)

    这篇博客主要来介绍 WebView 的相关使用方法,常见的几个漏洞,开发中可能遇到的坑和最后解决相应漏洞的源码,以及针对该源码的解析.  由于博客内容长度,这次将分为上下两篇,上篇详解 WebView ...

  8. android子视图无菜单,Android 菜单详解

    Android中菜单分为三种,选项菜单(OptionMenu),上下文菜单(ContextMenu),子菜单(SubMenu) 选项菜单 可以通过两种办法增加选项菜单,一是在menu.xml中添加,该 ...

  9. Android StateFlow详解

    转载请标明出处:http://blog.csdn.net/zhaoyanjun6/article/details/121913352 本文出自[赵彦军的博客] 文章目录 系列文章 一.冷流还是热流 S ...

最新文章

  1. 原来音色是波峰到波谷时间序列
  2. (004) java后台开发之Eclipse(Neon) 版本安装Java EE插件
  3. 测验3: 基本数据类型 (第3周)
  4. java对Oracle数据库查询_java 连接Oracle数据库 查询
  5. 如何设置python程序定时执行?
  6. python map lambda 分割字符串_Python特殊语法:filter、map、reduce、lambda [转]
  7. Java IO(File类)
  8. mysql 字段操作_Mysql:数据库操作、数据表操作、字段操作整理
  9. 重磅消息!三星、联想和微软的设备将会搭载Android 12L
  10. 程序员的职业素养---转载
  11. 8个优质自学网站收藏
  12. Oracle锁表解决方法
  13. printf() 输出数据格式汇总
  14. 使用JS快速读取TXT文件
  15. 仿微信图片编辑软件,涂鸦、裁剪、添加文本等常规操作
  16. 陕西师范大学第七届程序设计竞赛网络同步赛 D ZQ的睡前故事(java)
  17. 苹果8参数_iPhone11 iPhone11Pro哪里买最便宜靠谱划算 2020双十一苹果手机购机攻略...
  18. 多智能体强化学习-DGN
  19. thymeleaf遇到的问题01
  20. UWB室内定位:TDOA定位方法的时间同步问题

热门文章

  1. Unity 单侧拉伸物体
  2. 2019年武汉Web前端开发薪酬数据,可以了解一下!
  3. 四年开放共创,微众银行助力区块链产业蓬勃发展
  4. 什么是 CAPTCHA
  5. PHP学习案例三 判断学生成绩等级
  6. nyoj21 三个水杯
  7. 程序卡死在中断向量表B .处
  8. 《流浪地球2》Deepfake小试牛刀,45+吴京「被」年轻,变身21岁小鲜肉
  9. 第一片真正用于微型计算机的cpu名称是,LCSE初级2015-计算机硬件基础试题.xls
  10. 手机qpython3使用教程爬书_5.Python3爬虫入门实践——爬取名著