Android Framework 存储子系统(01)vold守护进程

112 篇文章 88 订阅

该系列文章总纲链接:专题分纲目录 Android Framework 存储子系统


本章关键点总结 & 说明:

导图是不断迭代的,这里主要关注➕ vold 守护进程 部分即可,主要从init.rc开始 开始解读vold,通过源码分析,研究vold主要作的一些事情,实际上就是 通过NetLinkhandler监听底层发来的uevent数据,发送给 VolumeManager来处理,通过CommandListener与 上层 MountService交互, 同时 MountService也会发送命令给VolumeManager,最后交给系统底层处理,同时 最后简单解读了下fstab文件。

Android的存储系统主要由SystemServer中的MountService和守护进程vold来组成(管理着系统的存储设备以及一些相关操作、挂载、卸载、格式化。。。),整体架构如下所示:

这里解读几个关键概念以及它们之间的关系:

  1. vold:负责与底层交互,是用于管理Android平台存储设备的守护进程,包括SD卡插拔事件、挂载和卸载、格式化等。
  2. MountService:为应用提供Binder类服务,运行在SystemServer中。
  3. StorageManager:是MountService的代理,在用户进程中使用。
  4. NetlinkManager(简称NM): 接收自Linux内核的uevent消息。比如:SD卡的插拔就会引起Kernel向NM发送uevent消息。
  5. NM将这些消息转发给VolumeManager模块(简称VM)。VM操作后会把相关信息通过CommandListener(简称CL)发送给MountService,MountService根据消息发送相关处理命令给VM做进一步处理。比如:SD卡插入后,VM会将来自NM的 Disk Insert消息发送给MountService,而后MountService则发送“Mount”指令给Vold,然后挂载这个SD卡。
  6. CommandListener(CL):封装Socket用于跨进程通信。负责和MountService中的NativeDaemonConnector进行通信。
  7. NetlinkHandler:负责监听来自驱动的Netlink Socket消息。
  8. Netlink:Linux系统中一种用户空间进程和Kernel进行通信的机制,即通过Netlink机制,应用层可接收来自Kernel的消息。

1 vold分析

vold守护进程启动时通过init.rc 启动的,关键信息如下:

service vold /system/bin/vold
    class core
    socket vold stream 0660 root mount
    ioprio be 2

vold服务被分配到core组,这说明它开机就被启动,同时这里也定义了socket,用于和MountService通信,入口main函数如下:

int main() {
    VolumeManager *vm;
    CommandListener *cl;
    NetlinkManager *nm;

    mkdir("/dev/block/vold", 0755);//创建vold目录
    klog_set_level(6);
	
    if (!(vm = VolumeManager::Instance())) {//关键点1:创建VolumeManager对象
        exit(1);
    };
    if (!(nm = NetlinkManager::Instance())) {//关键点2:创建NetlinkManager对象
        exit(1);
    };

    cl = new CommandListener();//关键点3:创建CommandListener对象
    vm->setBroadcaster((SocketListener *) cl);//vm和c1建立关系,赋值操作
    nm->setBroadcaster((SocketListener *) cl);//nm和c1建立关系,赋值操作

    if (vm->start()) {//关键点4:启动VolumeManager,这里直接返回0,不分析
        exit(1);
    }

    if (process_config(vm)) {//关键点5:创建文件/fstab.xxx中定义的Volume对象
        //...
    }

    if (nm->start()) {//关键点6:启动NetlinkManager
        exit(1);
    }

    coldboot("/sys/block");
    if (cl->startListener()) {//关键点7:CommandListener,监听framework下的socket
        exit(1);
    }

    while(1) {//死循环
        sleep(1000);
    }
    exit(0);
}

总结下:这里主要是创建几个对象VolumeManager、NetlinkManager、CommandListener,同时建立联系并启动。

2 NetlinkManager对象

NetlinkManager对象的目的是监听驱动发出的uevent消息。main函数中调用Instance来创建对象,代码如下:

NetlinkManager *NetlinkManager::Instance() {
    if (!sInstance)
        sInstance = new NetlinkManager();
    return sInstance;
}

继续分析其构造器,代码如下:

 NetlinkManager::NetlinkManager() {
    mBroadcaster = NULL;
}

这里在vold初始化时会通过setBroadcaster来设置mBroadcaster成员变量。之后会调用start函数,代码如下:

int NetlinkManager::start() {
    struct sockaddr_nl nladdr;
    int sz = 64 * 1024;
    int on = 1;

    memset(&nladdr, 0, sizeof(nladdr));
    nladdr.nl_family = AF_NETLINK;
    nladdr.nl_pid = getpid();
    nladdr.nl_groups = 0xffffffff;

    if ((mSock = socket(PF_NETLINK,SOCK_DGRAM,NETLINK_KOBJECT_UEVENT)) < 0) {
        return -1;
    }
    if (setsockopt(mSock, SOL_SOCKET, SO_RCVBUFFORCE, &sz, sizeof(sz)) < 0) {
        goto out;
    }
    if (setsockopt(mSock, SOL_SOCKET, SO_PASSCRED, &on, sizeof(on)) < 0) {
        goto out;
    }
    //绑定socket地址
    if (bind(mSock, (struct sockaddr *) &nladdr, sizeof(nladdr)) < 0) {
        goto out;
    }

    mHandler = new NetlinkHandler(mSock);
    if (mHandler->start()) {
        goto out;
    }
    return 0;

out:
    close(mSock);
    return -1;
}

start函数调用后,会创建一个netlink的socket,设置相关参数后,创建一个NetlinkHandler对象,用于监听和接收socket数据。

3 处理block类型的uevent

CommandListener对象由main函数中创建,在start函数中又创建了NetlinkHandler函数,这里将两者的类关系图绘制出来,发现它们都是从SocketListener派生的,如下所示:

底层的SocketListener类是监听socket的数据,接收到数据后分别移交给FrameworkListener类和NetlinkListener类的函数,对数据分析后分别调用CommandListener和NetlinkHandler中的函数。NetlinkHandler的构造函数如下:

NetlinkHandler::NetlinkHandler(int listenerSocket) :
                NetlinkListener(listenerSocket) {
}

并没有做什么,继续分析其start函数,代码如下:

int NetlinkHandler::start() {
    return this->startListener();
}

这里继续分析其startListener函数,代码如下:

int SocketListener::startListener(int backlog) {
    if (!mSocketName && mSock == -1) {
        errno = EINVAL;
        return -1;
    } else if (mSocketName) {
        //只有CommandListener中会设置mSocketName
        //init.rc中定义的字符串
        //从环境变量中取出socket
        if ((mSock = android_get_control_socket(mSocketName)) < 0) {
            return -1;
        }
    }
    //这里mListen决定是否调用listen函数
    //对于CommandListener,mListen = true,因为需要和systemServer通信
    //对于NetlinkListener,mListen = false,不需要监听socket
    if (mListen && listen(mSock, backlog) < 0) {
        return -1;
    } else if (!mListen){
        mClients->push_back(new SocketClient(mSock, false, mUseCmdNum));
    }
    
    if (pipe(mCtrlPipe)) {//创建管道,用于退出监听线程
        return -1;
    }
    //创建一个监听线程
    if (pthread_create(&mThread, NULL, SocketListener::threadStart, this)) {
        return -1;
    }
    return 0;
}

startListener函数监听socket,该函数在NetlinkHandler中被调用,在CommandListener中也被调用,这里创建的管道用于监听线程,监听线程的函数为SocketListener::threadStart。threadStart函数代码如下:

void *SocketListener::threadStart(void *obj) {
    SocketListener *me = reinterpret_cast<SocketListener *>(obj);
    me->runListener();
    pthread_exit(NULL);
    return NULL;
}

继续分析runListener,代码实现如下:

void SocketListener::runListener() {
    SocketClientCollection pendingList;
    while(1) {
        SocketClientCollection::iterator it;
        fd_set read_fds;
        int rc = 0;
        int max = -1;
        FD_ZERO(&read_fds);
        if (mListen) {
            max = mSock;
            FD_SET(mSock, &read_fds);
        }

        FD_SET(mCtrlPipe[0], &read_fds);
        if (mCtrlPipe[0] > max)
            max = mCtrlPipe[0];

        pthread_mutex_lock(&mClientsLock);
        for (it = mClients->begin(); it != mClients->end(); ++it) {
            // NB: calling out to an other object with mClientsLock held (safe)
            int fd = (*it)->getSocket();
            FD_SET(fd, &read_fds);
            if (fd > max) {
                max = fd;
            }
        }
        pthread_mutex_unlock(&mClientsLock);
        //执行select调用,等待socket上数据的到来
        if ((rc = select(max + 1, &read_fds, NULL, NULL, NULL)) < 0) {
            if (errno == EINTR)
                continue;
            sleep(1);
            continue;
        } else if (!rc)//fd上没数据达到,继续
            continue;

        if (FD_ISSET(mCtrlPipe[0], &read_fds)) {
            char c = CtrlPipe_Shutdown;
            TEMP_FAILURE_RETRY(read(mCtrlPipe[0], &c, 1));
            if (c == CtrlPipe_Shutdown) {
                break;
            }
            continue;
        }
        //如果CommandListener上有连接请求
        if (mListen && FD_ISSET(mSock, &read_fds)) {
            struct sockaddr addr;
            socklen_t alen;
            int c;

            do {
                alen = sizeof(addr);
                c = accept(mSock, &addr, &alen);//接入连接请求
            } while (c < 0 && errno == EINTR);
            if (c < 0) {
                sleep(1);
                continue;
            }
            pthread_mutex_lock(&mClientsLock);
            //把socket接入到mClients,这样循环时就会监听它数据的到达。
            mClients->push_back(new SocketClient(c, true, mUseCmdNum));
            pthread_mutex_unlock(&mClientsLock);
        }

        pendingList.clear();
        pthread_mutex_lock(&mClientsLock);
        for (it = mClients->begin(); it != mClients->end(); ++it) {
            SocketClient* c = *it;
            int fd = c->getSocket();
            if (FD_ISSET(fd, &read_fds)) {
                //如果某个socket上有数据,就会把它放入到pendingList中
                pendingList.push_back(c);
                c->incRef();
            }
        }
        pthread_mutex_unlock(&mClientsLock);

        while (!pendingList.empty()) {//处理pendingList列表
            it = pendingList.begin();
            SocketClient* c = *it;
            pendingList.erase(it);//处理了的socket移除
            if (!onDataAvailable(c)) {//处理数据
                release(c, false);
            }
            c->decRef();
        }
    }
}

这里也继续分析onDataAvailable来处理数据,这里NetlinkListener和FrameworkListener都会重载这个函数,这里着重分析NetlinkListener,它的实现如下所示:

bool NetlinkListener::onDataAvailable(SocketClient *cli)
{
    int socket = cli->getSocket();
    ssize_t count;
    uid_t uid = -1;

    //接收uevent事件消息。
    count = TEMP_FAILURE_RETRY(uevent_kernel_multicast_uid_recv(
                                       socket, mBuffer, sizeof(mBuffer), &uid));
    //...
    NetlinkEvent *evt = new NetlinkEvent();
    //解码消息
    if (evt->decode(mBuffer, count, mFormat)) {
        onEvent(evt);
    } else if (mFormat != NETLINK_FORMAT_BINARY) {
        //...
    }

    delete evt;
    return true;
}

在处理消息时,最后调用了onEvent函数,代码实现如下:

void NetlinkHandler::onEvent(NetlinkEvent *evt) {
    VolumeManager *vm = VolumeManager::Instance();
    const char *subsys = evt->getSubsystem();
    //...错误处理
    if (!strcmp(subsys, "block")) {//如果类型是 block则处理
        vm->handleBlockEvent(evt);
    }
}

最后 handleBlockEvent的函数实现如下所示:

void VolumeManager::handleBlockEvent(NetlinkEvent *evt) {
    const char *devpath = evt->findParam("DEVPATH");

    bool hit = false;
    //对mVolumes中的每个Volume对象,调用它的handleBlockEvent来处理event
    for (it = mVolumes->begin(); it != mVolumes->end(); ++it) {
        //如果能够处理event,则返回0,然后退出循环
        if (!(*it)->handleBlockEvent(evt)) {
            hit = true;
            break;
        }
    }
    //...调试相关
}

从这里开始继续分析mVolumes(DirectVolume类型,因此同上相比,这并不是同一个handleBlockEvent)的handleBlockEvent,对应代码如下:

int DirectVolume::handleBlockEvent(NetlinkEvent *evt) {
    const char *dp = evt->findParam("DEVPATH");
    PathCollection::iterator  it;
    for (it = mPaths->begin(); it != mPaths->end(); ++it) {
        if ((*it)->match(dp)) {
            /* We can handle this disk */
            int action = evt->getAction();
            const char *devtype = evt->findParam("DEVTYPE");

            if (action == NetlinkEvent::NlActionAdd) {
                int major = atoi(evt->findParam("MAJOR"));
                int minor = atoi(evt->findParam("MINOR"));
                char nodepath[255];

                snprintf(nodepath,
                         sizeof(nodepath), "/dev/block/vold/%d:%d",
                         major, minor);
				//创建设备节点
                if (createDeviceNode(nodepath, major, minor)) {
                    SLOGE("Error making device node '%s' (%s)", nodepath,
                                                               strerror(errno));
                }
                if (!strcmp(devtype, "disk")) {
                    handleDiskAdded(dp, evt);
                } else {
                    handlePartitionAdded(dp, evt);
                }
                
                //通知java层有sd卡插入
                if (getState() == Volume::State_Idle) {
                    char msg[255];
                    snprintf(msg, sizeof(msg),
                             "Volume %s %s disk inserted (%d:%d)", getLabel(),
                             getFuseMountpoint(), mDiskMajor, mDiskMinor);
                    mVm->getBroadcaster()->sendBroadcast(ResponseCode::VolumeDiskInserted,
                                                         msg, false);
                }
            } else if (action == NetlinkEvent::NlActionRemove) {//remove消息
                if (!strcmp(devtype, "disk")) {
                    handleDiskRemoved(dp, evt);
                } else {
                    handlePartitionRemoved(dp, evt);
                }
            } else if (action == NetlinkEvent::NlActionChange) {//change消息
                if (!strcmp(devtype, "disk")) {
                    handleDiskChanged(dp, evt);
                } else {
                    handlePartitionChanged(dp, evt);
                }
            } else {
                    SLOGW("Ignoring non add/remove/change event");
            }
            return 0;
        }
    }
    errno = ENODEV;
    return -1;
}

从驱动发送的消息 有三大类:NlActionAdd,NlActionRemove,NlActionChange,底层是在 移除/插入sd卡的时候会发送对应消息,进而通知vold模块,对于每一种消息根据设备类型是Disk还是Parition,共有6种处理函数,提取如下:

handleDiskAdded(dp, evt);
handlePartitionAdded(dp, evt);
handleDiskChanged(dp, evt);
handlePartitionChanged(dp, evt);
handleDiskRemoved(dp, evt);
handlePartitionRemoved(dp, evt);

这里以handleDiskAdded(dp, evt);为例,看DirectVolume如何处理event,代码如下:

void DirectVolume::handleDiskAdded(const char * /*devpath*/,NetlinkEvent *evt) {
    mDiskMajor = atoi(evt->findParam("MAJOR"));//主设备号
    mDiskMinor = atoi(evt->findParam("MINOR"));//次设备号

    const char *tmp = evt->findParam("NPARTS");//分区数量
    if (tmp) {
        mDiskNumParts = atoi(tmp);
    } else {
        mDiskNumParts = 1;
    }

    mPendingPartCount = mDiskNumParts;
    for (int i = 0; i < MAX_PARTITIONS; i++)
        mPartMinors[i] = -1;

    if (mDiskNumParts == 0) {
        setState(Volume::State_Idle);//分区数量为0,设备状态设置为Idle
    } else {
        setState(Volume::State_Pending);//设置为pending状态,等准备好了,再设置Idle状态
    }
}

这里handleDiskAdded函数从消息中得到了磁盘的分区数量,为0则状态设置变成Idle,这样上层就可以使用这个设备了;分区数量不为0时,则还需要等到增加分区的消息到来后再将设备的状态改为Idle,因此设置状态为pending。

4 处理MountService命令

CommandListener对象处理从MountService发来的数据是通过FrameworkListener的onDataAvailable函数来实现,代码如下:

bool FrameworkListener::onDataAvailable(SocketClient *c) {
    char buffer[CMD_BUF_SIZE];
    int len;

    //从socket中读数据
    len = TEMP_FAILURE_RETRY(read(c->getSocket(), buffer, sizeof(buffer)));
    //...错误处理
    int offset = 0;
    int i;

    for (i = 0; i < len; i++) {
        if (buffer[i] == '\0') {
            //分发命令
            dispatchCommand(c, buffer + offset);
            offset = i + 1;
        }
    }
    return true;
}

这里继续分析dispatchCommand,代码实现如下:

void FrameworkListener::dispatchCommand(SocketClient *cli, char *data) {
    //...
    for (i = mCommands->begin(); i != mCommands->end(); ++i) {
        FrameworkCommand *c = *i;

        if (!strcmp(argv[0], c->getCommand())) {//匹配命令
            if (c->runCommand(cli, argc, argv)) {//命令处理函数
                SLOGW("Handler '%s' error (%s)", c->getCommand(), strerror(errno));
            }
            goto out;
        }
    }
	//...
}

如果这里mCommands命令匹配FrameworkCommand对象,则调用它的runCommand函数,接下来分析FrameworkCommand,代码如下:

CommandListener::CommandListener() :FrameworkListener("vold", true) {
    registerCmd(new DumpCmd());
    registerCmd(new VolumeCmd());
    registerCmd(new AsecCmd());
    registerCmd(new ObbCmd());
    registerCmd(new StorageCmd());
    registerCmd(new CryptfsCmd());
    registerCmd(new FstrimCmd());
}

这里注册了很多命令,实际上就是将命令放入到mCommands列表中,registerCmd的实现如下:

void FrameworkListener::registerCmd(FrameworkCommand *cmd) {
    mCommands->push_back(cmd);
}

这里专注分析下VolumeCmd的命令处理函数runCommand,代码实现如下:

int CommandListener::VolumeCmd::runCommand(SocketClient *cli,
                                           int argc, char **argv) {
    //...
    VolumeManager *vm = VolumeManager::Instance();
    int rc = 0;

    if (!strcmp(argv[1], "list")) {//处理list命令
        bool broadcast = argc >= 3 && !strcmp(argv[2], "broadcast");
        return vm->listVolumes(cli, broadcast);
    } else if (!strcmp(argv[1], "debug")) {
        if (argc != 3 || (argc == 3 && (strcmp(argv[2], "off") && strcmp(argv[2], "on")))) {
            cli->sendMsg(ResponseCode::CommandSyntaxError, "Usage: volume debug <off/on>", false);
            return 0;
        }
        vm->setDebug(!strcmp(argv[2], "on") ? true : false);
    } else if (!strcmp(argv[1], "mount")) {//处理mount命令
        //...
        //调用VolumeManager的mountVolume函数
        rc = vm->mountVolume(argv[2]);
    } else if (!strcmp(argv[1], "unmount")) {//处理unmount命令
        //...
        //调用VolumeManager的unmountVolume函数
        rc = vm->unmountVolume(argv[2], force, revert);
    } else if (!strcmp(argv[1], "format")) {
        if (argc < 3 || argc > 4 ||
            (argc == 4 && strcmp(argv[3], "wipe"))) {
            cli->sendMsg(ResponseCode::CommandSyntaxError, "Usage: volume format <path> [wipe]", false);
            return 0;
        }
        bool wipe = false;
        if (argc >= 4 && !strcmp(argv[3], "wipe")) {
            wipe = true;
        }
        rc = vm->formatVolume(argv[2], wipe);
    } else if (!strcmp(argv[1],"share")) {
        //处理各种命令...
    } else {
        cli->sendMsg(ResponseCode::CommandSyntaxError, "Unknown volume cmd", false);
    }

    if (!rc) {
        cli->sendMsg(ResponseCode::CommandOkay, "volume operation succeeded", false);
    } else {
        int erno = errno;
        rc = ResponseCode::convertFromErrno();
        cli->sendMsg(rc, "volume operation failed", true);
    }
    return 0;
}

VolumeManager可以处理命令list、mount、unmount、format等等,这里的命令处理由VolumeManager中的函数完成,这里以format为例分析下命令的处理,处理函数为formatVolume,代码如下:

int VolumeManager::formatVolume(const char *label, bool wipe) {
    Volume *v = lookupVolume(label);
    //...
    return v->formatVol(wipe);
}

继续分析formatVol,代码如下:

int Volume::formatVol(bool wipe) {
    //错误处理
    if (isMountpointMounted(getMountpoint())) {//分区未挂载,返回
        SLOGW("Volume is idle but appears to be mounted - fixing");
        setState(Volume::State_Mounted);
        // mCurrentlyMountedKdev = XXX
        errno = EBUSY;
        return -1;
    }

    bool formatEntireDevice = (mPartIdx == -1);
    char devicePath[255];
    dev_t diskNode = getDiskDevice();
    dev_t partNode = MKDEV(MAJOR(diskNode),
              MINOR(diskNode) + (formatEntireDevice ? 1 : mPartIdx));

    setState(Volume::State_Formatting);

    int ret = -1;
    // Only initialize the MBR if we are formatting the entire device
    //格式化整个设备,先初始化设备的mbr
    if (formatEntireDevice) {
        sprintf(devicePath, "/dev/block/vold/%d:%d",
                major(diskNode), minor(diskNode));

        if (initializeMbr(devicePath)) {
            goto err;
        }
    }
    sprintf(devicePath, "/dev/block/vold/%d:%d",
            major(partNode), minor(partNode));
    if (Fat::format(devicePath, 0, wipe)) {//调用底层格式化函数
        goto err;
    }
    ret = 0;
err:
    setState(Volume::State_Idle);
    return ret;
}

这里首先是检查状态,通过后,调用initializeMbr来创建磁盘的MBR,最后调用底层的format函数格式化整个设备。

5 VolumeManager对象

VolumeManager通过Instance来创建对象,代码如下:

VolumeManager *VolumeManager::Instance() {
    if (!sInstance)
        sInstance = new VolumeManager();
    return sInstance;
}

构造器函数如下

VolumeManager::VolumeManager() {
    mDebug = false;
    mVolumes = new VolumeCollection();
    mActiveContainers = new AsecIdCollection();
    mBroadcaster = NULL;
    mUmsSharingCount = 0;
    mSavedDirtyRatio = -1;
    // set dirty ratio to 0 when UMS is active
    mUmsDirtyRatio = 0;
    mVolManagerDisabled = 0;
}

在创建了该对象后,调用process_config来处理该对象,代码如下:

static int process_config(VolumeManager *vm)
{
    char fstab_filename[PROPERTY_VALUE_MAX + sizeof(FSTAB_PREFIX)];
    char propbuf[PROPERTY_VALUE_MAX];
    int i;
    int ret = -1;
    int flags;
    
    //拼接为/fstab.xxx
    property_get("ro.hardware", propbuf, "");
    snprintf(fstab_filename, sizeof(fstab_filename), FSTAB_PREFIX"%s", propbuf);
    fstab = fs_mgr_read_fstab(fstab_filename);//文件内容写入内存
    //...

    //循环处理文件所有行
    for (i = 0; i < fstab->num_entries; i++) {
        if (fs_mgr_is_voldmanaged(&fstab->recs[i])) {
            DirectVolume *dv = NULL;
            flags = 0;
            //不可移动存储器,加上标记
            if (fs_mgr_is_nonremovable(&fstab->recs[i])) {
                flags |= VOL_NONREMOVABLE;
            }
            //加密存储器,加上标记
            if (fs_mgr_is_encryptable(&fstab->recs[i])) {
                flags |= VOL_ENCRYPTABLE;
            }
            //外置存储卡,加上标记
            if (fs_mgr_is_noemulatedsd(&fstab->recs[i]) &&
                !strcmp(fstab->recs[i].fs_type, "vfat")) {
                flags |= VOL_PROVIDES_ASEC;
            }
            
            dv = new DirectVolume(vm, &(fstab->recs[i]), flags);
            if (dv->addPath(fstab->recs[i].blk_device)) {
                goto out_fail;
            }

            vm->addVolume(dv);
        }
    }
    ret = 0;
out_fail:
    return ret;
}

fstab文件中主要是 设备分区的五种属性:

  1. 分区的原始位置,比如 /dev/block/platform/.../by-name/system
  2. 挂载点:/system
  3. 分区类型: ext4、vfat、emmc
  4. mount分区标志属性:ro,barrier=1
  5. 分区属性:是否加密,能否移动等。。多数情况为default

 

 

 

 

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

图王大胜

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值