zoukankan      html  css  js  c++  java
  • Xposed原理分析

    安卓系统启动

    什么zygote?

    init是内核启动的第一个用户级进程,zygote是由init进程通过解析init.zygote.rc文件而创建的,zygote所对应的具体可执行程序是app_process,所对应的源文件是App_main.cpp,进程名称为zygote。

    init.zygote.rc:

    service zygote /system/bin/app_process -Xzygote /system/bin --zygote --start-system-server
        class main
        socket zygote stream 660 root system
        onrestart write /sys/android_power/request_state wake
        onrestart write /sys/power/state on
        onrestart restart media
        onrestart restart netd
        writepid /dev/cpuset/foreground/tasks
    

    安卓应用运行?

    在ART模式下,zygote被init进程创建出来,用来孵化和启动其他App。zygote进程具有App所需要的所有核心库。

    zygote进程复制

    新的App进程在生成后,就会加载本App的程序代码(apk中的dex文件)

    Xposed介绍

    Xposed是安卓系统上能够修改系统或三方应用信息的框架。

    xposed_installer

    Xposed构成

    名称 介绍
    Xposed Xposed框架Native部分
    XposedBridge Xposed向开发者提供的API与相应工具类库
    XposedInstaller Xposed框架Android端本地管理,环境框架,以及第三方module资源下载的工具

    Xposed初始化大体工作流程

    (1)xposed的主要接口在XposedBrigde.jar中,核心功能在替换的虚拟机中实现。

    (2)app_process是Android App的启动程序(具体形式是zygote fork() 调用app_process作为Android app的载体)。

    xposed初始化流程

    源码分析

    初始化

    app_process有两个对应源文件,Android.mk会在编译时根据sdk版本选择对应源文件作为入口(app_main.cpp或app_main2.cpp)

    ...
    
    ifeq (1,$(strip $(shell expr $(PLATFORM_SDK_VERSION) >= 21)))
      LOCAL_SRC_FILES := app_main2.cpp
      LOCAL_MULTILIB := both
      LOCAL_MODULE_STEM_32 := app_process32_xposed
      LOCAL_MODULE_STEM_64 := app_process64_xposed
    else
      LOCAL_SRC_FILES := app_main.cpp
      LOCAL_MODULE_STEM := app_process_xposed
    endif
    ...
    ifeq (1,$(strip $(shell expr $(PLATFORM_SDK_VERSION) >= 21)))
      include frameworks/base/cmds/xposed/ART.mk
    else
      include frameworks/base/cmds/xposed/Dalvik.mk
    endif
    

    app_main#main

    在系统开机时,会通过app_process去创建zygote虚拟机,就会调用到app_main2.cpp中的main函数。

    main函数中主要做两件事:(1)初始化xposed;(2)创建虚拟机

    int main(int argc, char* const argv[])
    {
        if (xposed::handleOptions(argc, argv))
            return 0;
        
        //代码省略...
    
        runtime.mParentDir = parentDir;
        // 初始化xposed,主要是将jar包添加至Classpath中
        isXposedLoaded = xposed::initialize(zygote, startSystemServer, className, argc, argv);
        if (zygote) {
            // 如果xposed初始化成功,将zygoteInit 替换为 de.robv.android.xposed.XposedBridge,然后创建虚拟机
            runtime.start(isXposedLoaded ? XPOSED_CLASS_DOTS_ZYGOTE : "com.android.internal.os.ZygoteInit",
                    startSystemServer ? "start-system-server" : "");
        } 
        ...
    }
    

    app_main#initialize

    初始化xposed

    (1)初始化xposed内相关变量

    (2)调用addJarToClasspath将XposedBridge.jar添加至系统目录。

    bool initialize(bool zygote, bool startSystemServer, const char* className, int argc, char* const argv[]) {
        ...
        // 初始化xposed的相关变量
        xposed->zygote = zygote;
        xposed->startSystemServer = startSystemServer;
        xposed->startClassName = className;
        xposed->xposedVersionInt = xposedVersionInt;
        ...
        // 打印 release、sdk、manufacturer、model、rom、fingerprint、platform相关数据
        printRomInfo();
    
        // 主要在于将jar包加入Classpath
        return addJarToClasspath();
    }
    

    frameworks.base.core.jni.AndroidRuntime#start

    创建对应虚拟机

    start做了4件事:

    (1)创建虚拟机

    (2)初始化虚拟机

    (3)传入调用类de.robv.android.xposed.XposedBridge

    (4)初始化XposedBridge

    /*
     * Start the Android runtime.  This involves starting the virtual machine
     * and calling the "static void main(String[] args)" method in the class
     * named by "className".
     *
     * Passes the main function two arguments, the class name and the specified
     * options string.
     */
    void AndroidRuntime::start(const char* className, const Vector<String8>& options)
    {
        /* start the virtual machine */
        JniInvocation jni_invocation;
        jni_invocation.Init(NULL);
        JNIEnv* env;
        //创建虚拟机
        if (startVm(&mJavaVM, &env) != 0) {
            return;
        }
        
        // 初始化虚拟机,xposed对虚拟机进行修改
        onVmCreated(env);
       
        // 虚拟机初始化完成后,会调用传入的de.robv.android.xposed.XposedBridge类,初始化java层XposedBridge.jar
        char* slashClassName = toSlashClassName(className);
        jclass startClass = env->FindClass(slashClassName);
        if (startClass == NULL) {
            ...
        } else {
            
            jmethodID startMeth = env->GetStaticMethodID(startClass, "main",
            ...
        }
    }
    

    Xposed.cpp#onVmCreated

    xposed重写了onVmCreated。

    onVmCreated做了什么:

    1、xposedInitLib->onVmCreatedCommon->initXposedBridge,初始化XposedBridge

    (1)将register_natives_XposedBridge中的函数注册为Native方法

    2、xposedInitLib->onVmCreatedCommon->onVmCreated,为xposed_callback_class与xposed_callback_method赋值;

    (1)xposed_callback_class和xposed_callback_method变量赋值

    3、 de.robv.android.xposed.XposedBridge#main,初始化java层XposedBridge.jar

    (1)hook 住系统资源相关的方法;

    (2)hook 住zygote 的相关方法;

    (3)加载系统中已经安装的xposed 模块。

    void onVmCreated(JNIEnv* env) {
        // Determine the currently active runtime
        ...
    
        // Load the suitable libxposed_*.so for it 通过dlopen加载libxposed_art.so
        void* xposedLibHandle = dlopen(xposedLibPath, RTLD_NOW);
        ...
    
        // Initialize the library  初始化xposed相关库
        bool (*xposedInitLib)(XposedShared* shared) = NULL;
        // 根据动态链接库操作句柄与符号,返回符号对应的地址
        *(void **) (&xposedInitLib) = dlsym(xposedLibHandle, "xposedInitLib");
        if (!xposedInitLib)  {
            ALOGE("Could not find function xposedInitLib");
            return;
        }
        ...
        // xposedInitLib -> onVmCreatedCommon -> initXposedBridge -> 注册Xposed相关Native方法
        if (xposedInitLib(xposed)) {
            xposed->onVmCreated(env);
        }
    }
    

    libxposed_art.cpp#xposedInitLib

    /** Called by Xposed's app_process replacement. */
    bool xposedInitLib(XposedShared* shared) {
        xposed = shared;
        xposed->onVmCreated = &onVmCreatedCommon;
        return true;
    }
    

    libxposed_common.cpp#onVmCreatedCommon

    void onVmCreatedCommon(JNIEnv* env) {
        if (!initXposedBridge(env) || !initZygoteService(env)) {
            return;
        }
    
        if (!onVmCreated(env)) {
            return;
        }
    
        xposedLoadedSuccessfully = true;
        return;
    }
    

    libxposed_common.cpp#initXposedBridge

    bool initXposedBridge(JNIEnv* env) {
        classXposedBridge = env->FindClass(CLASS_XPOSED_BRIDGE);
        ...
        classXposedBridge = reinterpret_cast<jclass>(env->NewGlobalRef(classXposedBridge));
        ALOGI("Found Xposed class '%s', now initializing", CLASS_XPOSED_BRIDGE);
        // 将register_natives_XposedBridge中的函数注册为Native方法
        if (register_natives_XposedBridge(env, classXposedBridge) != JNI_OK) {
            ALOGE("Could not register natives for '%s'", CLASS_XPOSED_BRIDGE);
            logExceptionStackTrace();
            env->ExceptionClear();
            return false;
        }
    
        // 获取XposedBridge.jar中的handleHookedMethod方法,并将该方法赋值给methodXposedBridgeHandleHookedMethod,后续会赋值至全局变量中
        methodXposedBridgeHandleHookedMethod = env->GetStaticMethodID(classXposedBridge, "handleHookedMethod",
            "(Ljava/lang/reflect/Member;ILjava/lang/Object;Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;");
        ...
        return true;
    }
    

    libxposed_art.cpp#onVmCreated

    /** Called very early during VM startup. */
    bool onVmCreated(JNIEnv*) {
        // TODO: Handle CLASS_MIUI_RESOURCES?
        ArtMethod::xposed_callback_class = classXposedBridge;
        ArtMethod::xposed_callback_method = methodXposedBridgeHandleHookedMethod;
        return true;
    }
    

    de.robv.android.xposed.XposedBridge#main

    虚拟机初始化完成后,会调用传入的de.robv.android.xposed.XposedBridge类,初始化java层XposedBridge.jar,调用main函数

    (1)hook 系统资源相关的方法;

    (2)hook zygote 的相关方法;

    (3)加载系统中已经安装的xposed 模块。

    protected static void main(String[] args) {
            // Initialize the Xposed framework and modules
            try {
                if (!hadInitErrors()) {
                    initXResources();
    
                    SELinuxHelper.initOnce();
                    SELinuxHelper.initForProcess(null);
    
                    runtime = getRuntime();
                    XPOSED_BRIDGE_VERSION = getXposedVersion();
    
                    if (isZygote) {
                        XposedInit.hookResources();
                        XposedInit.initForZygote();
                    }
    
                    XposedInit.loadModules();
                } else {
                    Log.e(TAG, "Not initializing Xposed because of previous errors");
                }
            } 
    
            // Call the original startup code
            if (isZygote) {
                ZygoteInit.main(args);
            } else {
                RuntimeInit.main(args);
            }
        }
    

    初始化结束。

    例子

    static final String TAG = "XposedTest001";
        //final XC_MethodReplacement replacementTrue = XC_MethodReplacement.returnConstant(true);
    
        public CheckSNHook(ClassLoader cl) {
            super();
    
            XposedBridge.log("hooking checkSN.");
            try {
                Class clz = (Class<?>) XposedHelpers.findClass("com.droider.crackme0201.MainActivity", cl);
                //XposedBridge.hookAllMethods(clz, "checkSN", replacementTrue);
                Log.d(TAG, "hooking clz");
                XposedHelpers.findAndHookMethod(clz,
                        "checkSN",
                        String.class, String.class,
                        new XC_MethodHook() {
    
                            @Override
                            protected void afterHookedMethod(MethodHookParam param)
                                    throws Throwable {
                                XposedBridge.log("1CheckSN afterHookedMethod called.");
                                String s1 = (String) param.args[0];
                                String s2 = (String) param.args[1];
    
                                Log.d(TAG, "s1:" + s1);
                                Log.d(TAG, "s2:" + s2);
                                param.setResult(true);
    
                                super.afterHookedMethod(param);
                            }
                        });
    
            } catch (Exception e) {
                e.printStackTrace();
            }
            XposedBridge.log("1hook checkSN done.");
        }
    

    Hook原理分析

    XposedBridge#findAndHookMethod

    1、根据函数名获取对应Method对象

    2、调用XposedBridge.hookMethod函数

    public static XC_MethodHook.Unhook findAndHookMethod(Class<?> clazz, String methodName, Object... parameterTypesAndCallback) {
            if (parameterTypesAndCallback.length == 0 || !(parameterTypesAndCallback[parameterTypesAndCallback.length-1] instanceof XC_MethodHook))
                throw new IllegalArgumentException("no callback defined");
            // 封装回调函数
            XC_MethodHook callback = (XC_MethodHook) parameterTypesAndCallback[parameterTypesAndCallback.length-1];
            // 主要函数Method method = clazz.getDeclaredMethod(methodName, parameterTypes);
            Method m = findMethodExact(clazz, methodName, getParameterClasses(clazz.getClassLoader(), parameterTypesAndCallback));
            // 核心函数
            return XposedBridge.hookMethod(m, callback);
        }
    

    XposedBridge#hookMethod

    1、将回调函数、参数类型、返回类型记录到AdditionalHookInfo中

    2、拦截指定函数调用,并使用其他函数替代(native函数)

    public static XC_MethodHook.Unhook hookMethod(Member hookMethod, XC_MethodHook callback) {
            ...
                // 将回调函数、参数类型、返回类型记录到AdditionalHookInfo中
                AdditionalHookInfo additionalInfo = new AdditionalHookInfo(callbacks, parameterTypes, returnType);
                // 拦截指定函数调用,并使用其他函数替代
                hookMethodNative(hookMethod, declaringClass, slot, additionalInfo);
            }
    
            return callback.new Unhook(hookMethod);
        }
    
    private native synchronized static void hookMethodNative(Member method, Class<?> declaringClass, int slot, Object additionalInfo);
    

    libxposed.cpp#hookMethodNative

    1、查找我们需要hook的java Method对应的ArtMethod (每一个java层函数在ART下都有一个对应的ArtMethod)

    void XposedBridge_hookMethodNative(JNIEnv* env, jclass, jobject javaReflectedMethod,
                jobject, jint, jobject javaAdditionalInfo) {
        ...
            
        // 获取Java层Method对应native层的ArtMethod指针,将java函数描述为ArtMethod,查找我们需要hook的java Method对应的ArtMethod 
        ArtMethod* artMethod = ArtMethod::FromReflectedMethod(soa, javaReflectedMethod);
    
        // Hook the method
        artMethod->EnableXposedHook(soa, javaAdditionalInfo);
    }
    

    EnableXposedHook

    1、创建原函数备份

    2、创建 XposedHookInfo 保存原函数、before函数、after函数

    3、设置机器指令入口地址,此时跳入到GetQuickProxyInvokeHandler()地址

    void ArtMethod::EnableXposedHook(ScopedObjectAccess& soa, jobject additional_info) {
      ...
          
      // 创建原函数备份
      auto* cl = Runtime::Current()->GetClassLinker();
      auto* linear_alloc = cl->GetAllocatorForClassLoader(GetClassLoader());
      ArtMethod* backup_method = cl->CreateRuntimeMethod(linear_alloc);
      backup_method->CopyFrom(this, cl->GetImagePointerSize());
      // 设置标识符kAccXposedOriginalMethod
      backup_method->SetAccessFlags(backup_method->GetAccessFlags() | kAccXposedOriginalMethod);
    
      // Create a Method/Constructor object for the backup ArtMethod object
      mirror::AbstractMethod* reflected_method;
      if (IsConstructor()) {
        reflected_method = mirror::Constructor::CreateFromArtMethod(soa.Self(), backup_method);
      } else {
        reflected_method = mirror::Method::CreateFromArtMethod(soa.Self(), backup_method);
      }
      reflected_method->SetAccessible<false>(true);
    
      // 创建 XposedHookInfo 保存原函数、before函数、after函数(reflected_method:被hook的函数,XposedHookInfo包含回调函数)
      XposedHookInfo* hook_info = reinterpret_cast<XposedHookInfo*>(linear_alloc->Alloc(soa.Self(), sizeof(XposedHookInfo)));
      hook_info->reflected_method = soa.Vm()->AddGlobalRef(soa.Self(), reflected_method);
      hook_info->additional_info = soa.Env()->NewGlobalRef(additional_info);
      hook_info->original_method = backup_method;
      ...
      //将entry_point_from_jni_指针指向hook信息(目的是存储),hook信息包括原函数、before函数、after函数
      SetEntryPointFromJniPtrSize(reinterpret_cast<uint8_t*>(hook_info), sizeof(void*));
      
      // 设置机器指令入口地址,此时跳入到GetQuickProxyInvokeHandler()地址
      SetEntryPointFromQuickCompiledCode(GetQuickProxyInvokeHandler());
      SetCodeItemOffset(0);
    
      // Adjust access flags.
      // 进行标志位清除,此时这个ArtMethod对象对应是Hook后的方法,这个方法的实现不是native的
      const uint32_t kRemoveFlags = kAccNative | kAccSynchronized | kAccAbstract | kAccDefault | kAccDefaultConflict;
      SetAccessFlags((GetAccessFlags() & ~kRemoveFlags) | kAccXposedHookedMethod);
    
      MutexLock mu(soa.Self(), *Locks::thread_list_lock_);
      Runtime::Current()->GetThreadList()->ForEach(StackReplaceMethodAndInstallInstrumentation, this);
    }
    

    artQuickProxyInvokeHandler

    extern "C" uint64_t artQuickProxyInvokeHandler(
                ArtMethod* proxy_method, mirror::Object* receiver, Thread* self, ArtMethod** sp)
                const bool is_xposed = proxy_method->IsXposedHookedMethod();//判断 GetAccessFlags() 的kAccXposedHookedMethod 字段
                ......
                if (is_xposed) {
                    jmethodID proxy_methodid = soa.EncodeMethod(proxy_method);
                    self->EndAssertNoThreadSuspension(old_cause);
                    JValue result = InvokeXposedHandleHookedMethod(soa, shorty, rcvr_jobj, proxy_methodid, args);
                    local_ref_visitor.FixupReferences();
                    return result.GetJ();
                }
                ......
            }
    

    InvokeXposedHandleHookedMethod

    JValue InvokeXposedHandleHookedMethod(ScopedObjectAccessAlreadyRunnable& soa, const char* shorty, jobject rcvr_jobj, jmethodID method, std::vector<jvalue>& args) {
                //获取ArtMethod 的 hookinfo 信息,该信息是EntryPointFromJniPtrSize所指向的信息
                const XposedHookInfo* hookInfo = soa.DecodeMethod(method)->GetXposedHookInfo();
                //将hookinfo 转为一个数组,以便和java 层进行通信调用
                jvalue invocation_args[5];
                invocation_args[0].l = hookInfo->reflectedMethod;
                invocation_args[1].i = 1;
                invocation_args[2].l = hookInfo->additionalInfo;
                invocation_args[3].l = rcvr_jobj;
                invocation_args[4].l = args_jobj;
                //通过CallStaticObjectMethodA 调用 xposed_callback_class 类里面 xposed_callback_method 的方法
                //xposed_callback_class: XposedBridge.java
                //xposed_callback_method: handleHookedMethod 方法
                //ArtMethod 的这两个值,在系统开机时 在 onVmCreated 进行赋值的
                jobject result = soa.Env()->CallStaticObjectMethodA(ArtMethod::xposed_callback_class,
                                           ArtMethod::xposed_callback_method,
                                           invocation_args);
            }
    

    InvokeXposedHandleHookedMethod

    (1)获取ArtMethod 的 hookinfo 信息,该信息是EntryPointFromJniPtrSize所指向的信息

    (2)通过CallStaticObjectMethodA 调用 xposed_callback_class 类里面 xposed_callback_method 的方法

    (3)此处xposed_callback_class,xposed_callback_method 是libxposed_art****.cpp#onVmCreated重写时做的事

    const XposedHookInfo* GetXposedHookInfo() {
      DCHECK(IsXposedHookedMethod());
      // 前面存储EntryPointFromJniPtrSize指向的信息
      return reinterpret_cast<const XposedHookInfo*>(GetEntryPointFromJniPtrSize(sizeof(void*)));
    }
    

    GetXposedHookInfo:获取EntryPointFromJniPtrSize存储的信息

    Xposed.java#handleHookedMethod

    private static Object handleHookedMethod(Member method, int originalMethodId, Object additionalInfoObj,
                Object thisObject, Object[] args) throws Throwable {
            AdditionalHookInfo additionalInfo = (AdditionalHookInfo) additionalInfoObj;
            ...
            // call "before method" callbacks
            int beforeIdx = 0;
            do {
                try {
                    ((XC_MethodHook) callbacksSnapshot[beforeIdx]).beforeHookedMethod(param);
                } catch (Throwable t) {
                    XposedBridge.log(t);
    
                    // reset result (ignoring what the unexpectedly exiting callback did)
                    param.setResult(null);
                    param.returnEarly = false;
                    continue;
                }
    
                if (param.returnEarly) {
                    // skip remaining "before" callbacks and corresponding "after" callbacks
                    beforeIdx++;
                    break;
                }
            } while (++beforeIdx < callbacksLength);
    
            // call original method if not requested otherwise
            if (!param.returnEarly) {
                try {
                    param.setResult(invokeOriginalMethodNative(method, originalMethodId,
                            additionalInfo.parameterTypes, additionalInfo.returnType, param.thisObject, param.args));
                } catch (InvocationTargetException e) {
                    param.setThrowable(e.getCause());
                }
            }
    
            // call "after method" callbacks
            int afterIdx = beforeIdx - 1;
            do {
                Object lastResult =  param.getResult();
                Throwable lastThrowable = param.getThrowable();
    
                try {
                    ((XC_MethodHook) callbacksSnapshot[afterIdx]).afterHookedMethod(param);
                } catch (Throwable t) {
                    XposedBridge.log(t);
    
                    // reset to last result (ignoring what the unexpectedly exiting callback did)
                    if (lastThrowable == null)
                        param.setResult(lastResult);
                    else
                        param.setThrowable(lastThrowable);
                }
            } while (--afterIdx >= 0);
    
            // return
            if (param.hasThrowable())
                throw param.getThrowable();
            else
                return param.getResult();
        }
    

    XposedBridge.java 类的handleHookedMethod 方法,真正去处理 before、Original、after 这三个方法的调用关系。

    ART函数调用原理

    每一个Java函数在ART(虚拟机)内部都由一个ArtMethod对象表示,ArtMethod对象中包含了函数名、参数类型、方法体代码入口地址等。

    class ArtMethod {
     ...
     protect:
      HeapReference<Class> declaring_class_;
      HeapReference<ObjectArray<ArtMethod>> dex_cache_resolved_methods_;
      HeapReference<ObjectArray<Class>> dex_cache_resolved_types_;
      uint32_t access_flags_;
      uint32_t dex_code_item_offset_;
      uint32_t dex_method_index_;
      uint32_t method_index_;
      struct PACKED(4) PtrSizedFields {
        void* entry_point_from_interpreter_;
        // 用于存储jni函数信息,非jni函数的无用,所以经常被hook框架将原方法保存在entry_point_from_jni_
        void* entry_point_from_jni_;
        // ART HOOK常见的方法是替换入口点,执行hook的函数。(此处指向的是汇编代码,运行的是已经预处理过的机器码)
        void* entry_point_from_quick_compiled_code_;
     
    #if defined(ART_USE_PORTABLE_COMPILER)
        void* entry_point_from_portable_compiled_code_;
    #endif
      } ptr_sized_fields_;
      static GcRoot<Class> java_lang_reflect_ArtMethod_;
    }
    
    Art接口机器码入口

    替换entrypoint。将原函数对应的ArtMethod对象中entrypoint指向的机器码替换为目标函数的机器码,即可达到hook的目的。

    总结

    (1)准备包名、函数、参数类型、回调函数调用Hook接口

    (2)Xposed在找到art虚拟机中找到方法对应的ArtMethod对象

    (3)对ArtMethod对象进行备份

    (4)修改备份对象的机器指令入口

    (5)回调handleHookedMethod函数

    参考

    Xposed 源码剖析1(初始话相关):https://blog.csdn.net/xiaolli/article/details/107506138

    Xposed 源码剖析2:https://blog.csdn.net/a314131070/article/details/81092526

    Xposed 源码剖析3:https://blog.csdn.net/a314131070/article/details/81092548

    Xposed 源码剖析4:https://blog.csdn.net/xiaolli/article/details/107517039

    Xposed 源码剖析5:https://egguncle.github.io/2018/02/04/xposed-art-hook-%E6%B5%85%E6%9E%90/

    Xposed dalvik 源码剖析6:https://bbs.pediy.com/thread-247030.htm

    ART入口点替换分析:https://www.jianshu.com/p/820eceabf219

    ArtMethod结构:https://zhuanlan.zhihu.com/p/92267192

    ArtMethod结构:https://bbs.pediy.com/thread-248898.htm

    Dalvik与ART:https://www.jianshu.com/p/59d98244fb52

    定制xposed:https://blog.csdn.net/qq_35834055/article/details/103256122

  • 相关阅读:
    宝塔面板定时/同步备份网站及数据库至FTP存储空间完整教程
    Heroku是部署又是网站空间? github是仓库
    python批量添加hexo文章封面
    hexo史上最全搭建教程
    小皮面板一款好像还不错的 Linux 管理面板
    [Python] Hexo博文图片上传图床并自动替换链接的Python脚本
    5分钟搞定个人博客-hexo
    python的嵌入式开发
    Windows Embedded CE 6.0开发环境的搭建(2)
    EPLAN中的edz文件的用法
  • 原文地址:https://www.cnblogs.com/boycelee/p/13418371.html
Copyright © 2011-2022 走看看