Android.mk语法详解

前言

目前 Android 工程可以通过 .mk、或者 .cmake 的形式构建 NDK 工程,较新的 Android 工程一般采用的是 .cmake 构建 NDK 源码,而相对创建时间久的工程则大多数采用的是 .mk 的形式构建。下文主要是通过解析 Android 源码里的 docs 文档来深入了解 .mk 语法,其中会对原文里面一些描述通过实际工程加以描述。(原文以及演示的工程的链接将会在文章末尾给出)

MK 语法概述

一个 Android.mk 文件是用来描述 Android 的工程源码如何被构建系统所构建。进一步来说:

  • Android.mk 文件是一种会被构建系统解析一次或多次以上的 GUN Makefile 片段。.mk 语法允许构建工程的 module 源文件,每个 module 可以在 Android.mk 中被声明为下面的其中一种库:

    • 静态库(a static library)
    • 动态库(a shared library)

    只有动态库会被拷贝进项目工程,而静态库则是产生动态库的中间产物。在项目工程中,可以定义一个或多个 moduleAndroid.mk 文件中,或者可以使用同一份源码(.c / .cpp)在多个 module 内。

  • 构建系统会自动地为 .mk 工程处理细节问题。例如,我们不需要在 Android.mk 文件中列出源码的头文件,或者定义生成库的需要使用到的中间文件,NDK 构建工程会自动地为我们完成这些细节任务。同时新版本的 NDK toolchain / platform 支持向下兼容 Android.mk 语法。

NDK工程image-20211219210440455

对上图的解析说明:

  • src 文件中包含着 Java 源文件、jni 目录。jni 目录下包含着 .cpp / .mk ;

  • jni/Android.mk 描述如何把 hello_mk.cpp 等文件构建成一个动态库,其内容如下:

    LOCAL_PATH := $(call my-dir)
    include $(CLEAR_VARS)
    
    LOCAL_MODULE := hello_mk
    LOCAL_SRC_FILES := hello_mk.cpp
    
    include $(BUILD_SHARED_LIBRARY)
    

    在工程 app 下的 build.gradle 文件下对 Android.mk / Appplication.mk 文件位置的声明一般有两种方式:
    方式一:

    android {
        ...
        task ndkBuild(type:Exec,description:'NDK Project'){
            commandLine "C:\\Users\\iroot\\AppData\\Local\\Android\\Sdk\\ndk\\16.1.4479499\\ndk-build.cmd",//配置ndk的路径
                'NDK_PROJECT_PATH=build/intermediates/ndk',       // ndk默认的生成so的文件路径
                'NDK_LIBS_OUT=src/main/jniLibs',                  // 配置的我们想要生成的so文件所在的位置
                'APP_BUILD_SCRIPT=src/main/jni/Android.mk',       // 指定项目的 Android.mk 所在位置
                'NDK_APPLOCATION_MK=src/main/jni/Application.mk'  // 指定项目的 Applicaiton.mk 所在位置
        }
    
        tasks.withType(JavaCompile){   //使用ndkBuild
            compileTask ->compileTask.dependsOn ndkBuild
        }
    }
    

    方式二:(方式二需要工程的 setting.gradle 文件配合声明 NDK 的所在位置)

    android {
        ...
        defaultConfig {
            ...
            externalNativeBuild {
                ndkBuild {
                    arguments 'NDK_APPLICATION_MK:=src/main/jni/Application.mk'
                    cFlags ''
                    cppFlags ''
                    abiFilters 'arm64-v8a, armeabi-v7a, x86, x86_64'
                }
            }
        }
    
        externalNativeBuild {
            ndkBuild {
                path 'src/main/jni/Android.mk'
            }
        }
    }
    

MK 语法详解(一)

LOCAL_PATH := $(call my-dir)

解析:每个 Android.mk 文件必须在文件头部最开始处定义 LOCAL_PATH 变量,该变量用来获取工程中的文件节点。在上述工程图中,通过构建系统提供的函数 my-dir 获取 Android.mk 当前的所在的目录节点。

include $(CLEAR_VARS)

解析:CLEAR_VARS 变量是构建系统提供,同时指向一个特殊的 GNU Makefile,主要是用来清除如 LOCAL_XXX 所定义的变量(e.g. LOCAL_MODULE, LOCAL_SRC_FILES, LOCAL_STATIC_LIBRARIES, etc...)、以及 LOCAL_PATH 环境中的异常(CLEAR_VARS 可简单理解为初始化环境)。include $(CLEAR_VARS) 声明是必须的,因为 Android.mk 在第一次被解析的时候,变量被初始化为为知的值(这里个人理解为类似 C 中的数据定义未初始化,被其值被系统赋值为垃圾值)。

LOCAL_MODULE := hello_mk

解析:LOCAL_MODULE 变量是用来声明需要被生成的 module 名称。该定义的名称在整个工程中必须是唯一的,同时在构建的时候,系统会自动为该 module 名称补全前缀、以及后缀。也就是说,上文定义的动态库名称 hello_mk 最后被补全为 libhello_mk.so。当然,如果我们使用 LOCAL_MODULE := libhello_mk 声明的时候,系统则不会为其添加前缀。

LOCAL_SRC_FILES := hello-jni.c

解析:变量 LOCAL_SRC_FILES 是用来定义将要生成的目标动态库所需要的源码文件列表,如 c 或者 c++ 文件。但我们不要把头文件或者被 include 的文件也定义到该变量列表中,因为这些构建系统已经自动地帮我们完成这些任务。

include $(BUILD_SHARED_LIBRARY)

解析:变量 BUILD_SHARED_LIBRARY 被定义后,GNU Makefile 脚本就会负责把 include $(BUILD_SHARED_LIBRARY) 往上定义的如 LOCAL_XXX 变量都收集起来,直到离 include $(BUILD_SHARED_LIBRARY) 最近的定义的 include $(CLEAR_VARS) 为止。可简单理解为 [include $(CLEAR_VARS) ... include $(BUILD_SHARED_LIBRARY)] 之间的 LOCAL_XXX 的变量将决定如何生成一个动态库。当然,BUILD_STATIC_LIBRARY 变量是用来声明定义生成静态库。

自定义变量

构建系统会提供一系列的 .mk 变量供我们使用,当然我们也可以在我们工程需要的时候自定义某些变量。但需要注意不能与构建系统保留的变量名发生冲突:

  • LOCAL_ 开头的变量名;(如 LOCAL_MODULE
  • PRIVATE_NDK_APP_ 开头的变量名;(这些开头的变量名称被用以系统内部)
  • lower-case 名称;(用以系统内部,如 my-dir

如果我们需要自定义变量,官方推荐我们使用 MY_ 前缀开头的变量,避免与系统变量发生冲突。例子如下:

MY_SOURCES := foo.c
ifneq ($(MY_CONFIG_BAR),)
MY_SOURCES += bar.c
endif
LOCAL_SRC_FILES += $(MY_SOURCES)

MK 语法详解(二)

在上文我们了解了 CLEAR_VARSBUILD_SHARED_LIBRARYBUILD_STATIC_LIBRARY 这几个变量。构建系统还提供了其他种类的变量供我们在 mk 文件中使用,下面我们来一一认识了解它们吧。

NDK提供的变量

PREBUILT_SHARED_LIBRARY

该变量用来指定一个需要被依赖进工程的动态库。与 BUILD_SHARED_LIBRARYBUILD_STATIC_LIBRARY 不同的是,该变量对应的 LOCAL_SRC_FILES 必须被初始化为一个需要被纳入到工程的动态库路径,而不是源码文件。NDK Prebuilt library support 参考资料

使用方式 (这里不做解析,感兴趣可以查看提供的 NDK Prebuilt library support 参考资料):

include $(CLEAR_VARS)
LOCAL_MODULE := foo-prebuilt
LOCAL_SRC_FILES := libfoo.so
LOCAL_EXPORT_C_INCLUDES := $(LOCAL_PATH)/include  #导出libfoo.so的头文件
include $(PREBUILT_SHARED_LIBRARY)

include $(CLEAR_VARS)
LOCAL_MODULE := foo-user
LOCAL_SRC_FILES := foo-user.c
LOCAL_SHARED_LIBRARY := foo-prebuilt
include $(BUILD_SHARED_LIBRARY)

PREBUILT_STATIC_LIBRARY

作用和 PREBUILT_SHARED_LIBRARY 类似,只是该初始化的为静态库路径。

TARGET_ARCH

指定程序运行的目标 CPU 架构指令集的名称;

TARGET_PLATFORM

指定 Android.mk 文件将被哪一个 Android 版本解析。例如,'android-3' 对应于 Android 1.5 系统镜像。

TARGET_ARCH_ABI

目标 CPU+ABI 被 Android.mk 解析。

部分举例:

armeabi => when targetting ARMv5TE or higher CPUs
armeabi-v7a => when targetting ARMv7 or higher CPUs
x86 => when targetting x86 CPUs

同时 armeabi-v7a 系统可以兼容 armeabi 二进制文件。

代码例子:

include $(CLEAR_VARS)
LOCAL_MODULE := foo-prebuilt
LOCAL_SRC_FILES := $(TARGET_ARCH_ABI)/libfoo.so
LOCAL_EXPORT_C_INCLUDES := $(LOCAL_PATH)/include
include $(PREBUILT_SHARED_LIBRARY)

同时假设上述代码目录结构如下:

Android.mk --> the file above

armeabi/libfoo.so --> the armeabi prebuilt shared library

armeabi-v7a/libfoo.so --> the armeabi-v7a prebuilt shared library

include/foo.h --> the exported header file

TARGET_ABI

TARGET_PLATFORMTARGET_ARCH_ABI 变量类似,变量 TARGET_ABI 在我们需要测试某一块真机的系统镜像的时候会非常有用。默认值为 android-3-armeabi。(升级 Android NDK 1.6_r1,将会默认使用 android-3-arm

函数宏

接下来将要描述的是 GUN Make 函数宏(function macros),函数宏返回的是文本信息(可以理解为字符串),使用的格式如:$(call <function>)

my-dir

在上文我们提到构建系统提供的函数 my-dir :返回最近导入的 Makefile 的路径,在 NDK 中一般为 Android.mk 当前的目录节点路径。这里我们再展开解析该函数在使用中需要注意的点。

$(call my-dir) 变量获取的是最新导入的 Makefile 路径,也就意味着,当 include 新的文件路径进来以后,$(call my-dir) 返回的是该新的文件路径。如下源码一:

源码一

LOCAL_PATH := $(call my-dir)
... declare one module
include $(LOCAL_PATH)/foo/Android.mk
LOCAL_PATH := $(call my-dir)
... declare another module

例子一存在二次调用 $(call my-dir) ,第二次调用返回的将是 $(LOCAL_PATH)/foo 而不是 $PATH。因此,比较好的写法做法是把将要新增的 Android.mk 文件放在文件末尾,如下源码二:

源码二

LOCAL_PATH := $(call my-dir)
... declare one module
LOCAL_PATH := $(call my-dir)
... declare another module
# extra includes at the end of the Android.mk
include $(LOCAL_PATH)/foo/Android.mk

但源码二给出形式还不是最好的,如当最后需要 include 多个 Android.mk 呢?最好的写法是把第一次获取到的 $(call my-dir) 获取到的值预先保存在另一个变量:

源码三

MY_LOCAL_PATH := $(call my-dir)
LOCAL_PATH := $(MY_LOCAL_PATH)
... declare one module
include $(LOCAL_PATH)/foo/Android.mk
LOCAL_PATH := $(MY_LOCAL_PATH)
... declare another module

all-subdir-makefiles

该变量返回当前包含 Android.mkmy-dir 路径,以及子目录中含有 Android.mk 的路径。例如:

sources/foo/Android.mk
sources/foo/lib1/Android.mk
sources/foo/lib2/Android.mk

如果在 sources/foo/Android.mk 文件中含有 include $(call all-subdir-makefiles) ,则将会自动地 include 文件 sources/foo/lib1/Android.mksources/foo/lib2/Android.mk 。默认地,NDK 只会遍历查找 sources/*/Android.mk 格式地目录下的 Android.mk

this-makefile

返回当前的 Makefile 文件所在文件夹的路径。

parent-makefile

返回当前 Makefile 文件位于相对文件树节点的父 Makefile 路径。

grand-parent-makefile

import-module

该函数允许我们通过 module 的名字导入另一个 module,同时自动地导入该 moduleAndroid.mk 文件。但该 module 需要在 NDK_MODULE_PATH 该变量中声明。代码如下例子:

$(call import-module,<name>)

Module描述变量

接下来解析的变量是用来描述我们的 module 将要如何被构建系统所构建。

LOCAL_PATH

该变量被赋值为当前文件的路径。我们必须把它定义在 Android.mk 文件中开头的地方。同时该变量是不会被 $(CLEAR_VARS) 函数所清除,所以我们需要为每个需要的 Android.mk 定义路径(如在单一个 Android.mk 文件中导入多个 module)。写法如下:

LOCAL_PATH := $(call my-dir)

LOCAL_MODULE

用来声明我们 module 的名称。声明的 module 的名称必须在该工程内是唯一的,该名称不可以包含任何的空格,同时需要声明在任何的 $(BUILD_XXXX) 变量前。该 module 的名称默认是生成文件的名称,如 module 的名称为 foo,则生成的静态库的文件名为 libfoo.a,或者生成静态库的文件名为 linfoo.so

LOCAL_MODULE_FILENAME

这是个可选的变量,用来重载 LOCAL_MODULE 定义的名称。写法如下:

LOCAL_MODULE := foo-version-1
LOCAL_MODULE_FILENAME := libfoo

需要注意的是,我们不可以把路径名或者文件的后缀名称定义在 LOCAL_MODULE_FILENAME

LOCAL_SRC_FILES

该变量用来声明构建生成目标文件(静态 / 动态 / 可执行文件)所需要的源文件(C / C++),只有定义在该变量的源文件才会被编译进目标文件,同时构建系统会自动地为源码文件处理头文件导入这些细节操作。

因为源码文件的路径已经在声明 LOCAL_PATH 的时候已经导入进环境,因此我们只需要补充源码文件具体的目录位置即可。写法如下:

LOCAL_SRC_FILES := foo.c \
                   toto/bar.c

LOCAL_CPP_EXTENSION

该变量用来声明 C++ 的文件扩展后缀名称。我们可以更改默认的后缀(.cpp)名称声明。例子如下:

LOCAL_CPP_EXTENSION := .cxx

LOCAL_C_INCLUDES

相对于 NDK 的根目录路径,该变量定义编译源码时会被追加到导入搜索路径。如下例子:

LOCAL_C_INCLUDES := sources/foo

或者:

LOCAL_C_INCLUDES := $(LOCAL_PATH)/../foo

LOCAL_C_INCLUDES 生效的时机是在 LOCAL_CFLAGS / LOCAL_CPPFLAGS 变量之前。同时,LOCAL_C_INCLUDES 路径在进行 native 层的调试的时候会被使用到。

LOCAL_CFLAGS

该变量声明的值会在构建 CC++ 源码文件的时候给编译器设置编译参数,是一个可选的变量。这对于指定宏定义或者编译选项是非常有帮助的。

同时官方推荐不要尝试去调整优化选项、或者调试等级在我们的 Android.mk 文件,这些会通过 Application.mk 指定相关的信息为我们自动地处理,同时让 NDK 在调试的时候为我们生成有用的调试信息。

android-ndk-1.5_r1 的 NDK 版本,相对应的 LOCAL_CFLAGS 定义只针对 C 文件起效,而 C++ 则需要通过设置 LOCAL_CPPFLAGS 变量指定。

通过 LOCAL_CFLAGS 也可以像 LOCAL_C_INCLUDES 指定导入的源码文件路径,但推荐使用 LOCAL_C_INCLUDES 变量,因为后者会在 native 层调试的时候也需要使用到。

LOCAL_CXXFLAGS

LOCAL_CXXFLAGS 变量是 LOCAL_CPPFLAGS 的别名。但该变量可能在新的 NDK 版本中不再被支持。

LOCAL_CPPFLAGS

该变量会被拼接在 LOCAL_CFLAGS 变量之后,同时只对 C++ 源文件生效。

LOCAL_STATIC_LIBRARIES

LOCAL_STATIC_LIBRARIES 变量将被被链接进 module 里面的静态库列表,而这些静态库是通过 BUILD_STATIC_LIBRARY 定义构建的。

LOCAL_SHARED_LIBRARIES

作用与 LOCAL_STATIC_LIBRARIES 相同,只是定义的是动态库列表。

LOCAL_LDLIBS

LOCAL_LDLIBS 用于指定系统库通过 -l 前缀,指定的系统库将在编译的时候加载进我们目标 module。例如:

LOCAL_LDLIBS := -lz

通过指定 -lz 参数,编译器会加载 /system/lib/libz.so 进我们目标生成的 module。(参考 docs/STABLE-APIS.html

LOCAL_ALLOW_UNDEFINED_SYMBOLS

在编译生成动态库的时候,构建系统回去检测源码中是否存在 undefined symbol 的错误,这一行为有利于帮助我们提前发现代码中的 Bug。但实际中,我们总会遇到某些原因让我们不得不关闭该检测行为,当 LOCAL_ALLOW_UNDEFINED_SYMBOLS := true 会关闭对 undefined symbol 的检查。但带来的风险是在加载动态库时会使得程序发生崩溃。

LOCAL_ARM_MODE

默认条件下,ARM 目标二进制文件将会在 thumb 模式下生成,同时在该模式下生成的每条指令都是 16 位的。我们可以通过该变量指定在 arm 模式下生成我们的目标文件,也就是说此时的目标文件的指令宽是 32 位的。写法如下:

LOCAL_ARM_MODE := arm

在生成指定的目标文件的过程中,我们还可以通过指定某个源码文件按照我们需要的方式构建,如:

LOCAL_SRC_FILES := foo.c bar.c.arm

通过在源码文件名称后面后添加 .arm 可以指定在编译的时候,该文件以 arm 模式构建。

同时,在 Application.mk 文件通过 APP_OPTIM := debug 定义的方式同样可以生成 ARM 目标二进制文件。在官方文档中指出这是因为由于工具链调试器在处理 thumb 指令有 bug

LOCAL_ARM_NEON

当该变量被设为 true 的时允许我们使用 ARM Advanced SIMD (又名 NEON)、以及 GCC 内敛函数在我们的 CC++ 代码中、同时允许 NEON 汇编出现在汇编文件中。

我们只允许指定 ARMv7 汇编指令集在对应架构 armeabi-v7a ABI 中。但并非所有的基于 ARMv7 架构的 CPU 都支持 NEON 扩展指令集,这需要我们执行运行检测才可以发现是否 NEON 指令可以安全运行。如 LOCAL_ARM_MODE 可以在运行时指定特定文件可以支持 arm 模式进行编译,LOCAL_ARM_NEON 指令同样也可以通过指定某特定的文件追加 .neon 后缀,来支持 NEON 扩展指令集。写法如下:

LOCAL_SRC_FILES = foo.c.neon \
				  bar.c \
				  zoo.c.arm.neon

在上面的例子中,foo.c 文件将以 thumb+neon 模式编译、bar.c 文件将以 thumb 模式编译、zoo.c 将以 arm + neon 模式编译。需要注意的是,如果某个文件同时需要以 armneon 模式编译,那么 .neon 后缀必须跟在 .arm 后面。

LOCAL_DISABLE_NO_EXECUTE

Android NDK r4 版本新增对 NX bit 的安全特性的支持。该特性被默认支持,我们可以通过该变量设置 LOCAL_DISABLE_NO_EXECUTE := true 来关闭该特性。

关于 NX 特性可以参考:

  • http://en.wikipedia.org/wiki/NX_bit
  • http://www.gentoo.org/proj/en/hardened/gnu-stack.xml

LOCAL_EXPORT_CFLAGS

LOCAL_EXPORT_CFLAGS 定义了一组 C / C++ 编译器参数,当其他模块以LOCAL_STATIC_LIBRARIES / LOCAL_SHARED_LIBRARIES 方式引用该模块时,就会将该组值加入到 LOCAL_CFLAGS,从而传递给编译器。如下例子:

代码片段一:

include $(CLEAR_VARS)
LOCAL_MODULE := foo
LOCAL_SRC_FILES := foo/foo.c
LOCAL_EXPORT_CFLAGS := -DFOO=1
include $(BUILD_STATIC_LIBRARY)

代码片段二:

include $(CLEAR_VARS)
LOCAL_MODULE := bar
LOCAL_SRC_FILES := bar.c
LOCAL_CFLAGS := -DBAR=2
LOCAL_STATIC_LIBRARIES := foo
include $(BUILD_SHARED_LIBRARY)

在代码片段一种,我们通过 foo/foo.c 构建目标静态库 foo,同时 LOCAL_EXPORT_CFLAGS := -DFOO=1。在代码片段二中,我们通过 bar.c 构建动态库 bar,这时候 -DFOO=1 -DBAR=2 会传递给编译器以用来构建动态库 bar

LOCAL_EXPORT_CFLAGS 定义的 flags 是可以被继承的。假设 zoo 依赖 bar,而 bar 依赖 foo,那么 zoo 也会继承来自 foo 中导出的 flags。在上述代码片段中,定义的 LOCAL_EXPORT_CFLAGS 对于构建本模块是不生效的,如上述例子中,构建 foo 时声明的 LOCAL_EXPORT_CFLAGS := -DFOO=1 不会传给编译器。

LOCAL_EXPORT_CPPFLAGS

作用和 LOCAL_EXPORT_CFLAGS 相同,但作用于 C++ 文件。

LOCAL_EXPORT_C_INCLUDES

作用和 LOCAL_EXPORT_CFLAGS 相同,但只是针对 C 导入的路径。当 bar.c 需要 foo 模块提供的头文件的时候,该定义会很有帮助。

LOCAL_EXPORT_LDLIBS

LOCAL_EXPORT_CFLAGS 相同,但针对的是链接器 flags。由于 Unix 的链接器工作的方式,导入的链接器 flags 将会被追加到我们 moduleLOCAL_LDLIBS 变量处。

include $(CLEAR_VARS)
LOCAL_MODULE := foo
LOCAL_SRC_FILES := foo/foo.c
LOCAL_EXPORT_LDLIBS := -llog
include $(BUILD_STATIC_LIBRARY)

include $(CLEAR_VARS)
LOCAL_MODULE := bar
LOCAL_SRC_FILES := bar.c
LOCAL_STATIC_LIBRARIES := foo
include $(BUILD_SHARED_LIBRARY)

在上述例子中,foo 是静态库,并且依赖系统库。同时 LOCAL_EXPORT_LDLIBS 定义用于导出依赖。当在编译器构建 bar 的时候,将会把 -llog (表示依赖系统的 日志库)构建进动态库中。

参考资料

后言

Android 原生开发随着 Flutter 的到来,前景开始变得不那么明朗。Android 原生可以实现一般功能,在 Flutter 同样也可以实现,同时 Flutter 一份代码可以实现跨平台运行。可能 Flutter 并不是最终跨平台的最终实现方案,但在一定程度上也给 Android 原生开发带来不小的冲击。

但随着目前 5G 时代的来临、以及人们普遍开始越来越重视应用安全,如应用隐私安全等。音视频领域、移动安全领域方兴未艾,尤其移动安全领域比较难找到相关的参考学习资料、学习路线,而笔者正深耘 Android 移动安全领域,希望在这学习 Android 移动安全的路上有你一起长风破浪。

  • 8
    点赞
  • 44
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
android.mk 是一种用于构建 Android 应用程序的 Makefile 文件格式,而 android.bp 是将要取代 android.mk 的一种新的构建系统文件格式,它是用于生成 Android 系统的 Blueprint 构建系统的配置文件。 要将 android.mk 转成 android.bp,首先需要了解 android.mk 的结构和语法规则,然后根据这些规则来编写对应的 android.bp 文件。由于 android.bp 是基于 Blueprint 构建系统的配置文件,所以需要通过编写规范的 Blueprint 描述文件来完成对 android.mk 的转换。 在转换过程中,需要注意以下几点: 1. 将 android.mk 文件中的每个模块(例如库,可执行文件,模块等)的描述转换为对应的 android.bp 格式。 2. 考虑和处理 android.bp 特有的新特性和语法规则,例如 module {} 块的使用。 3. 更新外部依赖项的描述,如库的引入方式、依赖关系的定义等。 4. 调整路径和命名规则,使之符合 android.bp 的文件组织结构和命名规范。 在转换完成后,需要进行测试和验证,确保转换后的 android.bp 文件可以正确地生成相应的 Android 应用程序。同时,也需要注意在转换过程中可能会出现的兼容性问题,确保转换后的 android.bp 文件能够正常地被构建系统接受并使用。 总的来说,将 android.mk 转成 android.bp 需要对 android.mk 的结构和语法进行理解,并根据 Blueprint 构建系统的规则和特性来进行相应的转换和调整。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值