android启动过程的底层实现(三)

目录​​​​​​​

1、触发Action

1.1、action_for_each_trigger

1.2、queue_builtin_action

2、执行Action

3、启动service

4、init对属性服务的处理

4.1、属性服务处理的流程

4.2、属性服务客户端


        触发并启动Action和Service:init解析int.rc后,生成了存放Service和Action的链表。那么init又是如何控制这些Action和Service的呢?

1、触发Action

        init解析完init.rc后接着执行了action_for_each_trigger和queue_builtin_action。这两个函数都做了些什么?

1.1、首先定位action_for_each_trigger,代码位于init_parser.c中。

void action_for_each_trigger(const char *trigger,void (*func)(struct action *act))
{
    struct listnode *node;
    struct action *act;
    list_for_each(node, &action_list) {//使用node遍历整个action_list
    act = node_to_item(node, struct action, alist);//node所在的action地址
    if (!strcmp(act->name, trigger)) {
        func(act);
    }
}

        list_for_eachnode_to_item到底做了些什么?node_to_item第二个参数struct action又是什么?这两部分定义在lish.h中带,代码如下:

#define list_for_each(node, list) \
for (node = (list)->next; node != (list); node = node->next)
//原来list_for_each是个宏,代表一个for循环,使用node节点遍历list列表。


#define node_to_item(node, container, member) \
(container *) (((char*) (node)) - offsetof(container, member))
//offsetof函数得到的是member在container中的偏移量。接下来进行替换:
node_to_item(node, struct action, alist)替换为 (struct action *) (((char*) (node)) - offsetof(struct action, alist))
offsetof求得的是alist在action中的偏移量,(char*) (node)减去offsetof求得的是node对应的Action地址(node减去自己在Action中的偏移量,即为node所在的Action首地址)

1.2、queue_builtin_action函数内容如下

void queue_builtin_action(int (*func)(int nargs, char **args), char *name)
{
    struct action *act;
    struct command *cmd;
    
    act = calloc(1, sizeof(*act));
    act->name = name;
    list_init(&act->commands);

    cmd = calloc(1, sizeof(*cmd));
    cmd->func = func;
    cmd->args[0] = name;
    list_add_tail(&act->commands, &cmd->clist);

    list_add_tail(&action_list, &act->alist);
    action_add_queue_tail(act);//该函数是吧Action中的qlist放入action_queue中,action_queue与service_list和action_list一样都是由list_declare声明的宏。
}

可知action_for_each_trigger和queue_builtin_action都没有实际执行Service和Action

2、执行Action

        上节分析了Action的触发,那Action又是怎么执行的呢?定位到execute_one_command函数,位于init.c中,代码如下:

void execute_one_command(void)
{
    int ret;
    //从Action中取出Command
    if (!cur_action || !cur_command || is_last_command(cur_action, cur_command)) {
    //首次调用函数的话,可以走进if
    cur_action = action_remove_queue_head();
    cur_command = NULL;
    if (!cur_action)
    return;
    INFO("processing action %p (%s)\n", cur_action, cur_action->name);
    cur_command = get_first_command(cur_action);
    } else {
    cur_command = get_next_command(cur_action, cur_command);
    }

    if (!cur_command)
    return;
    //调用Command中定义的func函数,执行Command
    ret = cur_command->func(cur_command->nargs, cur_command->args);
    INFO("command '%s' r=%d\n", cur_command->args[0], ret);
}

//在这个函数中首先获得第一个action结构体,然后将这个结构体对应的节点从action_queue里面删除,最后返回这个action结构体。

struct action *action_remove_queue_head(void)
{
    if (list_empty(&action_queue)) {
    return 0;
    } else {
    struct listnode *node = list_head(&action_queue);
    struct action *act = node_to_item(node, struct action, qlist);
    list_remove(node);
    return act;
    }
}

//这个函数首先获得head节点,然后利用node_to_item寻找到该节点所在的Command结构体。


static struct command *get_first_command(struct action *act)
{
    struct listnode *node;
    node = list_head(&act->commands);
    if (!node || list_empty(&act->commands))
    return NULL;

    return node_to_item(node, struct command, clist);
}

        execute_one_command函数的作用:首先从action_queue链表中获得头部的action结构体,并将这个头部的action结构体从action_queue移除。如果我们是第一次使用这个action结构体的话,我们会调用get_first_command函数去获得链接在它上面的第一个command结构体,并执行这个结构体对应的函数;如果我们不是第一次使用这个action结构体的话,我们会调用get_next_command函数去获得链接在这个action结构体的下一个command结构体,并执行对应的函数。
        在main函数中,我们现在是处于一个for循环中。所以,每当我们在for循环中调用execute_one_command的时候,便会获得一个command结构体,并执行其对应的函数。当我们获得action结构体的最后一个command结构体后,再次调用execute_one_command函数的时候,我们就会从action_queue中去获得新的action结构体,并重复获得command结构体,执行函数的步骤,直到最终我们把action_queue链表中的所有的action结构体上的所有的command都调用一遍。

        execute_one_command函数做了两部分工作:取出命令和执行命令的func函数。此处的func便是command结构体中的成员函数func,是在parse_line_action解析Action的时候赋值的,代码如下:

cmd->func = kw_func(kw);//获取Command对应的指令函数

-------------------------------------------------

接下来定位到kw_func,分析一下到这里都赋值了那些函数。kw_func位于init_parser.c中,代码如下:

-------------------------------------------------

#define kw_func(kw) (keyword_info[kw].func)

-------------------------------------------------

这是个宏定义,需要找到keyword_info的定义。位于init_parser.c中,代码如下:

-------------------------------------------------

struct {
const char *name;
int (*func)(int nargs, char **args);
unsigned char nargs;
unsigned char flags;
} keyword_info[KEYWORD_COUNT] = {
[ K_UNKNOWN ] = { "unknown", 0, 0, 0 },
#include "keywords.h"//包含了头文件keywords.h
};
#undef KEYWORD

keywords.h头文件内容如下:

#ifndef KEYWORD
int do_chroot(int nargs, char **args);
int do_chdir(int nargs, char **args);
int do_class_start(int nargs, char **args);
int do_class_stop(int nargs, char **args);
int do_class_reset(int nargs, char **args);
int do_domainname(int nargs, char **args);
int do_exec(int nargs, char **args);
int do_export(int nargs, char **args);
int do_hostname(int nargs, char **args);
int do_ifup(int nargs, char **args);
int do_insmod(int nargs, char **args);
int do_mkdir(int nargs, char **args);
int do_mount_all(int nargs, char **args);
int do_mount(int nargs, char **args);
int do_restart(int nargs, char **args);
int do_restorecon(int nargs, char **args);
int do_rm(int nargs, char **args);
int do_rmdir(int nargs, char **args);
int do_setcon(int nargs, char **args);
int do_setenforce(int nargs, char **args);
int do_setkey(int nargs, char **args);
int do_setprop(int nargs, char **args);
int do_setrlimit(int nargs, char **args);
int do_setsebool(int nargs, char **args);
int do_start(int nargs, char **args);
int do_stop(int nargs, char **args);
int do_trigger(int nargs, char **args);
int do_symlink(int nargs, char **args);
int do_sysclktz(int nargs, char **args);
int do_write(int nargs, char **args);
int do_copy(int nargs, char **args);
int do_chown(int nargs, char **args);
int do_chmod(int nargs, char **args);
int do_loglevel(int nargs, char **args);
int do_load_persist_props(int nargs, char **args);
int do_wait(int nargs, char **args);
#define __MAKE_KEYWORD_ENUM__
#define KEYWORD(symbol, flags, nargs, func) K_##symbol,
enum {
K_UNKNOWN,
#endif
KEYWORD(capability, OPTION, 0, 0)
KEYWORD(chdir, COMMAND, 1, do_chdir)
KEYWORD(chroot, COMMAND, 1, do_chroot)
KEYWORD(class, OPTION, 0, 0)
KEYWORD(class_start, COMMAND, 1, do_class_start)
KEYWORD(class_stop, COMMAND, 1, do_class_stop)
KEYWORD(class_reset, COMMAND, 1, do_class_reset)
KEYWORD(console, OPTION, 0, 0)
KEYWORD(critical, OPTION, 0, 0)
KEYWORD(disabled, OPTION, 0, 0)
KEYWORD(domainname, COMMAND, 1, do_domainname)
KEYWORD(exec, COMMAND, 1, do_exec)
KEYWORD(export, COMMAND, 2, do_export)
KEYWORD(group, OPTION, 0, 0)
KEYWORD(hostname, COMMAND, 1, do_hostname)
KEYWORD(ifup, COMMAND, 1, do_ifup)
KEYWORD(insmod, COMMAND, 1, do_insmod)
KEYWORD(import, SECTION, 1, 0)
KEYWORD(keycodes, OPTION, 0, 0)
KEYWORD(mkdir, COMMAND, 1, do_mkdir)
KEYWORD(mount_all, COMMAND, 1, do_mount_all)
KEYWORD(mount, COMMAND, 3, do_mount)
KEYWORD(on, SECTION, 0, 0)
KEYWORD(oneshot, OPTION, 0, 0)
KEYWORD(onrestart, OPTION, 0, 0)
KEYWORD(restart, COMMAND, 1, do_restart)
KEYWORD(restorecon, COMMAND, 1, do_restorecon)
KEYWORD(rm, COMMAND, 1, do_rm)
KEYWORD(rmdir, COMMAND, 1, do_rmdir)
KEYWORD(seclabel, OPTION, 0, 0)
KEYWORD(service, SECTION, 0, 0)
KEYWORD(setcon, COMMAND, 1, do_setcon)
KEYWORD(setenforce, COMMAND, 1, do_setenforce)
KEYWORD(setenv, OPTION, 2, 0)
KEYWORD(setkey, COMMAND, 0, do_setkey)
KEYWORD(setprop, COMMAND, 2, do_setprop)
KEYWORD(setrlimit, COMMAND, 3, do_setrlimit)
KEYWORD(setsebool, COMMAND, 1, do_setsebool)
KEYWORD(socket, OPTION, 0, 0)
KEYWORD(start, COMMAND, 1, do_start)
KEYWORD(stop, COMMAND, 1, do_stop)
KEYWORD(trigger, COMMAND, 1, do_trigger)
KEYWORD(symlink, COMMAND, 1, do_symlink)
KEYWORD(sysclktz, COMMAND, 1, do_sysclktz)
KEYWORD(user, OPTION, 0, 0)
KEYWORD(wait, COMMAND, 1, do_wait)
KEYWORD(write, COMMAND, 2, do_write)
KEYWORD(copy, COMMAND, 2, do_copy)
KEYWORD(chown, COMMAND, 2, do_chown)
KEYWORD(chmod, COMMAND, 2, do_chmod)
KEYWORD(loglevel, COMMAND, 1, do_loglevel)
KEYWORD(load_persist_props, COMMAND, 0, do_load_persist_props)
KEYWORD(ioprio, OPTION, 0, 0)
#ifdef __MAKE_KEYWORD_ENUM__
KEYWORD_COUNT,
};
#undef __MAKE_KEYWORD_ENUM__
#undef KEYWORD
#endif

        这里定义了所用Command对应的执行函数,执行Command就是执行这些函数。接下来分别讲解Action和Service是如何执行的。以init.rc中定义的eraly-init Action为例讲解Action的执行过程。其定义如下:

on early-init
    write /proc/1/oom_adj -16
    start ueventd

        write和start都是Command,在KEYWORD映射表中分别对应可执行函数do_write和do_start。do_write定义在builtins.c文件中,代码如下:

int do_write(int nargs, char **args)
{
    const char *path = args[1];
    const char *value = args[2];
    char prop_val[PROP_VALUE_MAX];
    int ret;

    ret = expand_props(prop_val, value, sizeof(prop_val));
    if (ret) {
    ERROR("cannot expand '%s' while writing to '%s'\n", value, path);
    return -EINVAL;
    }
    //最终调用 open、write、close等库函数往path里写入value
    return write_file(path, prop_val);
}

do_start函数体内容如下:

int do_start(int nargs, char **args)
{
    struct service *svc;
    //do_start是用来启动Service的
    svc = service_find_by_name(args[1]);
    if (svc) {
    //启动Service,这个Service就是ueventd
    service_start(svc, NULL);
    }
    return 0;
}

找到do_start距离真相就不远了。下面继续分析service_start函数,其位于init.c中,代码如下:

void service_start(struct service *svc, const char *dynamic_args)
{
struct stat s;
pid_t pid;
int needs_console;
int n;
#ifdef HAVE_SELINUX
char *scon = NULL;
int rc;
#endif

//Service启动前需要清除异常状态
svc->flags &= (~(SVC_DISABLED|SVC_RESTARTING|SVC_RESET));
svc->time_started = 0;

... ...//省略部分内容
//调用fork创建子进程,fork函数调用一次但会返回两次,分别返回子进程和父进程。其中返回0表示在子进程中,返回大于0表示在父进程中,该数字便是子进程的进程ID。
pid = fork();

if (pid == 0) {//表示在子进程中
struct socketinfo *si;
struct svcenvinfo *ei;
char tmp[32];
int fd, sz;

umask(077);
//将属性信息添加到环境变量中
if (properties_inited()) {
get_property_workspace(&fd, &sz);
sprintf(tmp, "%d,%d", dup(fd), sz);
add_environment("ANDROID_PROPERTY_WORKSPACE", tmp);
}

for (ei = svc->envvars; ei; ei = ei->next)
add_environment(ei->name, ei->value);

#ifdef HAVE_SELINUX
setsockcreatecon(scon);
#endif
//创建Socket,并在环境变量中设置Socket信息
for (si = svc->sockets; si; si = si->next) {
int socket_type = (
!strcmp(si->type, "stream") ? SOCK_STREAM :
(!strcmp(si->type, "dgram") ? SOCK_DGRAM : SOCK_SEQPACKET));
int s = create_socket(si->name, socket_type,
si->perm, si->uid, si->gid);
if (s >= 0) {
publish_socket(si->name, s);
}
}

... ...//省略部分内容

//根据参数值,调用Linux系统函数execve执行service对应的命令,在此处是ueventd
if (!dynamic_args) {
if (execve(svc->args[0], (char**) svc->args, (char**) ENV) < 0) {
ERROR("cannot execve('%s'): %s\n", svc->args[0], strerror(errno));
}
} else {
char *arg_ptrs[INIT_PARSER_MAXARGS+1];
int arg_idx = svc->nargs;
char *tmp = strdup(dynamic_args);
char *next = tmp;
char *bword;

/* Copy the static arguments */
memcpy(arg_ptrs, svc->args, (svc->nargs * sizeof(char *)));

while((bword = strsep(&next, " "))) {
arg_ptrs[arg_idx++] = bword;
if (arg_idx == INIT_PARSER_MAXARGS)
break;
}
arg_ptrs[arg_idx] = '\0';
execve(svc->args[0], (char**) arg_ptrs, (char**) ENV);
}
_exit(127);
}
... //省略


//以下是父进程,设置了service的启动信息,并更新service的属性状态。
svc->time_started = gettime();
svc->pid = pid;
svc->flags |= SVC_RUNNING;

if (properties_inited())
notify_service_state(svc->name, "running");
}

        到这里early_init这个Action就分析完了。从这个Action 可以看出一部分的Command是以Service的方式执行的,这部分Service并不是以service关键字显式声明的。那么显式生命的Service又是怎么启动的呢?

3、启动service

        从init.c的main函数中只看到了Action的触发和执行(action_for_each_trigger),似乎并没有找到Service启动的痕迹。在上一节分析early_init的时候出现了service_start函数,他是专门用来启动Service的,只需要找出谁调用了它就能找到Service是在哪里启动的。定位到service_start_if_not_disabled函数,位于builtins.c中,代码如下:

static void service_start_if_not_disabled(struct service *svc)
{
if (!(svc->flags & SVC_DISABLED)) {
service_start(svc, NULL);
}
}

        service_start_if_not_disabled也是根据传入的service结构体调用service_start的,这不是最终目标。继续定位service_start_if_not_disabled的调用者,找到do_class_start函数,依然位于builtins.c文件:

int do_class_start(int nargs, char **args)
{

service_for_each_class(args[1], service_start_if_not_disabled);
return 0;
}

do_class_start函数就是keywords.h重定义的class_start这个Command所对应的函数!定义如下:

KEYWORD(class_start, COMMAND, 1, do_class_start)

        原来只要运行了class_start这个Command,就会启动相应的Service。所以接下来只需要在init.rc中找到哪里执行了class_start,就知道Service在哪里启动了。事实是init.rc中很多Action都执行了class_start这个Command,选择在启动阶段触发并且在init.rc中配置过的Action。其中Trigger为boot的Action满足条件,代码如下:

on boot
... ... //省略其他Command
class_start core
class_start main

        原来在init的main方法中,执行到action_for_each_trigger("boot",action_add_queue_tail),在触发boot Action的过程中,将要启动的Service与Command关联起来。库建,init是把Service作为一个进程,用Command启动的,这样所有的Service便是init的子进程了。这些由init启动的Service主要有:ueventd、servicemanager、vold、zygote、installd、ril-deamon、debuggerd、bootanim(显示开机动画)等,通常称这些为Service为守护进程(Daemon Service)。到这里Action和Service的启动就分析完了,下分析init对属性服务的处理。

4、init对属性服务的处理

        init中除了解析init.rc中配置的Action和Service外,还处理了一些内置Action,这些工作由queue_builtin_action函数完成。其中最重要的便是属性服务(Property Service)相关部分。

4.1、属性服务处理的流程

        Android为了存储全局系统设置信息,提供了一个系统属性共享内存区,它的内容是一些键值对的列表,对外提供get和set方法读写属性。系统启动时由init初始化并开启属性服务。现在回到init.c的main函数,分析init中是如何处理属性服务的。定位到属性服务相关的代码:

//共享内存区分配
property_init();

... ...

if (!is_charger)
//加载默认属性
property_load_boot_defaults();

//触发属性相关的Action
queue_builtin_action(property_service_init_action, "property_service_init");
queue_builtin_action(queue_property_triggers_action, "queue_property_triggers");

        init中与属性服务相关的工作由四部分:
        1)通过property_init函数调用init_property_area()函数初始化属性区,打开ashmem设备申请共享内存,以便所有用户进程都可以共享这块内存。
        2)通过property_load_boot_defaults()函数加载/default.prop文件中定义的默认属性。
        3)通过queue_builtin_action函数触发property_service_init。
        4)通过queue_builtin_action函数触发queue_property_triggers。

        其中第一部分涉及到Android Shared Memory,我们只需要知道分配了一块内存共享区便可;第二部分是简单的文件加载。下面分析第三部分和第四部分:

(1)property_service_init_action

        property_service_init_action是init执行的第一个属性触发函数,位于init.c中,在其内部直接调用start_property_service函数,该函数定义于property_service.c中,代码如下:

void start_property_service(void)
{
int fd;
//加载其他默认的属性文件
load_properties_from_file(PROP_PATH_SYSTEM_BUILD);// /system/build.prop
load_properties_from_file(PROP_PATH_SYSTEM_DEFAULT);///system/default.prop
#ifdef ALLOW_LOCAL_PROP_OVERRIDE
load_properties_from_file(PROP_PATH_LOCAL_OVERRIDE);// /data/local.prop
#endif /* ALLOW_LOCAL_PROP_OVERRIDE */
/*默认属性加载完后会加载一些持久化属性,路径位于/data/property目录下,由PERSISTENT_PROPERTT_DIR宏定义,持久化属性是以persist.开头的属性*/
load_persistent_properties();

//创建一个Socket,用于接收用户请求
fd = create_socket(PROP_SERVICE_NAME, SOCK_STREAM, 0666, 0, 0);
if(fd < 0) return;
fcntl(fd, F_SETFD, FD_CLOEXEC);
fcntl(fd, F_SETFL, O_NONBLOCK);

//监听fd上的连接请求,并建立一个请求队列,最大请求数是8,这些请求在队列中等待被accept()方法接收
listen(fd, 8);
//设置property_set_fd以便在init中处理,记得init中poll函数在等待这个fd上的事件发生吗
property_set_fd = fd;
}

        start_property_service方法中主要做了三部分工作:1)加载属性文件;2)创建Socket接受客户端请求;3)监听Socket。

(2)queue_property_triggers_action

        queue_property_triggers_action是init执行的第二个属性触发函数,位于init.c中,代码:

static int queue_property_triggers_action(int nargs, char **args)
{
queue_all_property_triggers();
/* enable property triggers */
property_triggers_enabled = 1;
return 0;
}

------------------------------------------------------------
这里调用了queue_all_property_triggers,位于init_parser.c中:
-----------------------------------------------------------
void queue_all_property_triggers()
{
struct listnode *node;
struct action *act;
//遍历action_list
list_for_each(node, &action_list) {
//act指向node所在的Action
act = node_to_item(node, struct action, alist);
//判断Action名字中是否包含property:,解析proeprrty:<name>=<value>
if (!strncmp(act->name, "property:", strlen("property:"))) {
/* parse property name and value
syntax is property:<name>=<value> */
const char* name = act->name + strlen("property:");
const char* equals = strchr(name, '=');
if (equals) {
char prop_name[PROP_NAME_MAX + 1];
const char* value;
int length = equals - name;
if (length > PROP_NAME_MAX) {
ERROR("property name too long in trigger %s", act->name);
} else {
memcpy(prop_name, name, length);
prop_name[length] = 0;

/* does the property exist, and match the trigger value? */
value = property_get(prop_name);
if (value && (!strcmp(equals + 1, value) ||
!strcmp(equals + 1, "*"))) {
//将这些Action加入可执行队列
action_add_queue_tail(act);
}
}
}
}
}
}

        queue_all_property_triggers触发了所有名字以“property:”开头的 Action。Android的属性系统是一种特殊的Action ,这种Action以“on property为前缀”,代码如下

on property:ro.debuggable=1 //只有这个条件为真时才执行Action中指定的Command
          start console

4.2、属性服务客户端

        前面说到start_property_service中开启了一个Socket接收客户端请求,这个请求又是从哪里发出的?即属性服务的客户端是什么? 在属性设置过程中,属性服务器调用了property_set函数设置属性,其实在客户端也有一个对应名为property_set的函数,这个函数供客户端与属性服务通信,位于 /system/core/libcutils/proeprtys.c,代码如下:

int property_set(const char *key, const char *value)
{
    return __system_property_set(key, value);
}

调用到了 __system_property_set函数,位于bionic/libc/bionic/system_properties.c中:

int __system_property_set(const char *key, const char *value)
{
    int err;
    int tries = 0;
    int update_seen = 0;
    prop_msg msg;

    if(key == 0) return -1;
    if(value == 0) value = "";
    if(strlen(key) >= PROP_NAME_MAX) return -1;
    if(strlen(value) >= PROP_VALUE_MAX) return -1;

    memset(&msg, 0, sizeof msg);
    msg.cmd = PROP_MSG_SETPROP;
    strlcpy(msg.name, key, sizeof msg.name);
    strlcpy(msg.value, value, sizeof msg.value);
    
    //在send_prop_msg中建立了s = socket(AF_LOCAL, SOCK_STREAM, 0)
    err = send_prop_msg(&msg);
    if(err < 0) {
        return err;
    }

    return 0;
}

接着分析send_prop_msg,在这里真正创建了Socket通信连接。代码:

static int send_prop_msg(prop_msg *msg)
{
    struct pollfd pollfds[1];
    struct sockaddr_un addr;
    socklen_t alen;
    size_t namelen;
    int s;
    int r;
    int result = -1;

    //创建Socket 
    s = socket(AF_LOCAL, SOCK_STREAM, 0);
    if(s < 0) {
        return result;
    }

    memset(&addr, 0, sizeof(addr));
    namelen = strlen(property_service_socket);
    
    //设置Socket服务
    strlcpy(addr.sun_path, property_service_socket, sizeof addr.sun_path);
    addr.sun_family = AF_LOCAL;
    alen = namelen + offsetof(struct sockaddr_un, sun_path) + 1;

    //连接服务Socket
    if(TEMP_FAILURE_RETRY(connect(s, (struct sockaddr *) &addr, alen) < 0)) {
        close(s);
        return result;
    }

    //发送消息
    r = TEMP_FAILURE_RETRY(send(s, msg, sizeof(prop_msg), 0));

    if(r == sizeof(prop_msg)) {
        ... ...
        }
    }

    close(s);
    return result;
}

        可见Android的属性系统是通过Socket实现客户端和服务端通信的,通信的接口是property_set和property_get这两个函数。到此为止,属性系统的三大部分分析完了,下一节分析init最后一个阶段:循环监听处理事件。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值