供应商系统工具剖析


一、文件结构

├── 3rdParty
│   └── wpa_supplicant
│       ├── CONTRIBUTIONS
│       ├── COPYING
│       ├── README
│       └── src
│           └── drivers
│               └── nl80211_copy.h
├── build
│   ├── beamforming_on_connected.sh
│   ├── build.sh
│   └── Makefile
├── common
│   └── include
│       ├── dot11.h
│       ├── netlink
│ .............................(netlink相关头文件)
├── doc


└── src
    ├── lib
    │   ├── libnl-3.so
    │   ├── libnl-3.so.200
    │   ├── libnl-3.so.200.20.0
    │   ├── libnl-genl-3.so
    │   ├── libnl-genl-3.so.200
    │   ├── libnl-genl-3.so.200.20.0
    │   ├── managed
    │   │   ├── PrsManagedIface.cpp
    │   │   └── PrsManagedIface.h
    │   ├── PrsDbg.cpp
    │   ├── PrsDbg.h
    │   ├── PrsDeviceAccessIface.h
    │   ├── PrsVendorCommand.cpp
    │   ├── PrsVendorCommand.h
    │   ├── PrsVendorEndian.cpp
    │   ├── PrsVendorEndian.h
    │   ├── PrsVendorEventFile.cpp
    │   ├── PrsVendorEventFile.h
    │   ├── PrsVendorInterface.h
    │   ├── PrsVendorInterfaceLinux.cpp
    │   ├── PrsVendorInterfaceLinux.h
    │   ├── PrsVendorLib.cpp

    │   └── PrsVendorLib.h
    └── sample
        └── linux
            ├── Sample.cpp
            ├── SampleEventListener.cpp
            ├── SampleEventListener.h
            ├── SampleFileWriterEventListener.cpp
            ├── SampleFileWriterEventListener.h
            ├── Sample.h
            ├── SampleJsonEventListener.cpp
            ├── SampleJsonEventListener.h
            ├── SampleSink.cpp
            └── SampleSink.h

二、makefile 结构

PREFIX ?= /usr
SBINDIR ?= $(PREFIX)/sbin
MANDIR ?= $(PREFIX)/share/man
MKDIR ?= mkdir -p
INSTALL ?= install
CXXFLAGS += -std=c++11 -Wall -Wundef -Wno-trigraphs -fno-strict-aliasing -fno-common -Werror-implicit-function-declaration
CC = /opt/toolchain-aarch64_cortex-a53_gcc-5.2.0_musl-1.1.16/bin/aarch64-openwrt-linux-gcc
CXX = /opt/toolchain-aarch64_cortex-a53_gcc-5.2.0_musl-1.1.16/bin/aarch64-openwrt-linux-g++
AR = /opt/toolchain-aarch64_cortex-a53_gcc-5.2.0_musl-1.1.16/bin/aarch64-openwrt-linux-ar
LD = /opt/toolchain-aarch64_cortex-a53_gcc-5.2.0_musl-1.1.16/bin/aarch64-openwrt-linux-ld
AROPTS = cr

ifeq ($(V),1)
Q=
NQ=true
else
Q=@
NQ=echo
endif
CP = cp

INCLUDES=-I$(PRS_COMMON_DIR)/include -I$(SUPPLICANT_DIR)/src/drivers -I$(SRC_DIR)/lib -I$(SRC_DIR)/sample/linux 
VPATH=$(SRC_DIR)/lib:$(SRC_DIR)/lib/managed:$(SRC_DIR)/sample/linux
LIBS += -lprsvendor -lpthread
LIBS += $(shell pkg-config --libs libnl-3.0 libnl-genl-3.0)

PRS_TYPES_FLAGS=-DLINUX_FWLOGS_POSTPROCESS
ifeq ($(DEBUG),1)

CXXFLAGS += -Og -g -D __DEBUG__=1 $(PRS_TYPES_FLAGS)
CFLAGS += -Og -g
OUTPUT_DIR=debug
DEBUG_FLAGS="DEBUG=1"

else

CXXFLAGS += -O3  $(PRS_TYPES_FLAGS)
CFLAGS += -O3
OUTPUT_DIR=release
DEBUG_FLAGS=

endif

REL_DIR = .
_OBJS_VENDOR_LIB =     PrsDbg.o                        \
                    PrsVendorEndian.o                \
                    PrsVendorCommand.o                \
                    PrsVendorLib.o                    \
                    PrsManagedIface.o                \
                    PrsVendorInterfaceLinux.o        \
                    PrsVendorEventFile.o            \

OBJS_VENDOR_LIB = $(patsubst %, $(OUTPUT_DIR)/%,$(_OBJS_VENDOR_LIB))
_OBJS_SAMPLE =         Sample.o                              \
                         SampleEventListener.o           \
                    SampleJsonEventListener.o        \
                    SampleFileWriterEventListener.o \
                    SampleSink.o

OBJS_SAMPLE = $(patsubst %, $(OUTPUT_DIR)/%,$(_OBJS_SAMPLE))

VENDOR_LIB = libprsvendor
SAMPLE_APP = prs_vendor_app

all: dirs $(VENDOR_LIB).a $(SAMPLE_APP)

$(OUTPUT_DIR)/%.o: $(REL_DIR)/%.c
    @$(NQ) ' CC     ' $@
    $(Q)$(CC) $(CFLAGS) $(INCLUDES) -c $< -o $@

$(OUTPUT_DIR)/%.o: $(REL_DIR)/%.cpp
    @$(NQ) ' CXX     ' $@
    $(Q)$(CXX) $(CXXFLAGS) $(INCLUDES) -c $< -o $@

$(VENDOR_LIB).a: dirs $(OBJS_VENDOR_LIB) 
    echo ' AR ' $(VENDOR_LIB).a
    $(AR) $(AROPTS) $(OUTPUT_DIR)/$(VENDOR_LIB).a $(OBJS_VENDOR_LIB) $(SRC_DIR)/lib/libnl-3.so $(SRC_DIR)/lib/libnl-genl-3.so
    $(CP) $(SRC_DIR)/lib/PrsVendorLib.h $(OUTPUT_DIR)/include
    $(CP) $(SRC_DIR)/lib/libnl-3.so $(OUTPUT_DIR)/libnl-3.so
    $(CP) $(SRC_DIR)/lib/libnl-genl-3.so $(OUTPUT_DIR)/libnl-genl-3.so
    $(CP) -rf $(PRS_COMMON_DIR)/include $(OUTPUT_DIR)

$(SAMPLE_APP): dirs $(OBJS_SAMPLE) $(VENDOR_LIB).a
    @$(NQ) ' LINK     ' $(SAMPLE_APP)
    $(CXX) $(LDFLAGS) $(OBJS_SAMPLE) -L$(OUTPUT_DIR) $(LIBS) -o $(OUTPUT_DIR)/$(SAMPLE_APP)

dirs:
    $(MKDIR) $(OUTPUT_DIR)
    $(MKDIR) $(OUTPUT_DIR)/include

clean: 
    $(Q)rm -rf debug release

其实我们关心的并不是这个工具的结构,而是这个工具的实现方法,是否有通用的软件工具开发方法?

主线流程

在这里插入图片描述
可以看出这个工具主要是两个用途:

  1. 向底层发送命令;
  2. 监听底层的事件。

我们主要分析 GetOpts() 和 g_run。
GetOpts() 用于解析用户命令,打包参数通过表驱动法查表封装任务到任务队列。

解析参数

bool GetOpts(int32_t a_argc, char* a_argv[], SPrsSampleAppOpts&  a_opts)
{
    int32_t     argIndex = 1;
    bool        ok = true;
    bool        specificCommands = false;
    void*       parms = NULL;
    bool        help = false;
    uint32_t    eventId = 0;

    a_opts.inputSelection       = InputSelection_Interface;
    a_opts.interfaceName        = NULL;
    a_opts.peerDevice           = false;
    a_opts.inputFileName        = "";

    a_opts.outputSelection      = OutputSelection_Stdout;
    a_opts.outputFileName       = "";
    a_opts.remoteJsonHost       = "";
    a_opts.remoteJsonPort       = "";
    a_opts.eventsWaitSeconds    = 0;
    a_opts.eventFilters         = NULL;

    while (argIndex < a_argc)
    {
        if (0 == strncmp(a_argv[argIndex], "-h", 2))
        {
            help = true;    //帮助模式开启
            break;
        }
        else if (0 == strncmp(a_argv[argIndex], "-c", 2) && (argIndex + 1) < a_argc)
        {
            cerr << "Note: Ignoring -c option (no longer needed)" << endl;
            argIndex++;
        }
        else if (0 == strncmp(a_argv[argIndex], "-i", 2) && (argIndex + 1) < a_argc)
        {
            argIndex++;
            a_opts.inputSelection = InputSelection_Interface;
            a_opts.interfaceName = a_argv[argIndex];
        }
        else if (0 == strncmp(a_argv[argIndex], "-e", 2) && (argIndex + 1) < a_argc)
        {
            argIndex++;
            ......
        }
        else if (0 == strncmp(a_argv[argIndex], "-o", 2) && (argIndex + 1) < a_argc)
        {
            argIndex++;
            ......
        }
        else if (0 == strncmp(a_argv[argIndex], "-s", 2) && (argIndex + 1) < a_argc)
        {
            specificCommands = true;    //命令模式
            argIndex++;

            if (!ProcessCmdParms(a_argc, a_argv, &argIndex, parms))    //查表执行命令
            {
                ok = false;
                break;
            }
        }
        else if (0 == strncmp(a_argv[argIndex], "-f", 2) && (argIndex + 1) < a_argc)
        {
            ......
        }
        else
        {
            cerr << "Error: Unknown option "
                 << a_argv[argIndex]
                 << endl;
            ok = false;
        }
        argIndex++;
    }

    if (help)
    {
        Usage(a_argv[0]);        //打印用法
        exit(EXIT_SUCCESS);
    }
    
    ......

    return ok;
}

省略了大部分不重要的代码,这个函数主要是分析用户执行时所带的参数,例如:
-s ,那么参数后面就要带命令。
-h ,直接就打印工具的使用方法了。
-i,参数后面要带指定的网络接口。
等等,我们继续分析命令模式。

表驱动法获取任务

ProcessCmdParms(a_argc, a_argv, &argIndex, parms)

a_argc 是 main 函数的参数个数,a_argv 是 main 函数的所有参数组成的字符串。
&argIndex 和 parms 有其他的用法,但是不是用在命令模式中,无视即可。
先来看一个用于封装任务的数据结构:

任务数据结构

typedef struct SPrsCmdDef
{
    /// @brief Test command name
    const char*     cmdName;

    /// @brief A test description
    const char*     cmdDesc;

    /// @brief Indicates whether connection is required or not
    bool            cnxn;    //这个无视即可

    /// @brief 任务的回调函数
    CmdHandler      func;

    /// @brief 解析一些特殊的参数
    CmdHandlerParms getFuncParms;

    /// @brief 命令的具体用法
    CmdUsage        usage;

    /// @brief 运行函数所需的参数指针
    void*           parms;

} SPrsCmdDef;

typedef bool (*CmdHandler)(PrsCommandIface* a_comIface, SPrsCmdDef* a_cmdDef);

/// @brief 为任务函数解析一些特殊的参数
typedef bool (*CmdHandlerParms)(int a_argc, char* a_argv[], int* a_argIndex, void** a_cmdParms);

/// @brief 打印用法的函数指针
typedef void (*CmdUsage)(void);

ProcessCmdParms 源码:

bool ProcessCmdParms(
 int32_t a_argc,
 char* a_argv[],
 int32_t* a_argIndex,
 void* a_parms )
{
    bool          ok = true;
    SPrsCmdDef*   cmd = NULL;

    if (NULL != (cmd = GetCommandByName(a_argv[*a_argIndex])))//查表获取命令的完整结构体
    {
        if (cmd->getFuncParms)
        {
            if (cmd->getFuncParms(a_argc, a_argv, a_argIndex, &a_parms))//解析命令的参数(-s之后的内容)
            {
                cmd->parms = a_parms;
            }
            else
            {
                if (cmd->usage)
                {
                    cout << "Error: "
                         << a_argv[*a_argIndex]
                         << " expects parameters ";
                    cmd->usage();
                    cout << endl
 << endl;
                }
                else

                    cout << "Error: Failed to collect command parameters"
 << endl;


                delete cmd;
                cmd = NULL;
                ok = false;
            }
        }

        if (ok && cmd)
        {
            cout << "Added \""
                 << cmd->cmdDesc
                 << "\" command"
                 << endl;
            g_run.push(cmd);

        }
    }
    else
    {
        cout << "Error: "
             << a_argv[*a_argIndex]
             << " command is not recognized."
             << endl
             << endl;
        ok = false;
    }

    return ok;
}

GetCommandByName 原型

SPrsCmdDef* GetCommandByName(const char* a_cmdName)
{
    int32_t       cmdNo = 0;
    SPrsCmdDef*   cmd = NULL;

    cmd = new SPrsCmdDef();
    if (cmd)
    {
        for (cmdNo = 0; cmdNo < ePrsSampleCommands_Max; cmdNo++)
        {
            if (0 == strcmp(g_cmds[cmdNo].cmdName, a_cmdName))//通过命令的枚举编号获取命令在表中的位置
            {
               // 如果找得到命令,就把这个命令在表中的结构体复制给要执行的 cmd 变量
                memcpy(cmd, &g_cmds[cmdNo], sizeof(SPrsCmdDef));
                break;
            }
        }

        if (cmdNo == ePrsSampleCommands_Max)
        {
            delete cmd;
            cmd = NULL;
        }
    }

    return cmd;
}

/* 命令的枚举,查表时使用 */
enum EPrsSampleCommands
{
    /// @brief Add Block ACK command
    ePrsSampleCommands_AddBlockAck = 0,

    /// @brief Query Block ACK status command
    ePrsSampleCommands_QueryBlockAckStatus,

    /// @brief Delete Block ACK command
    ePrsSampleCommands_DelBlockAck,

    /// @brief Link measurement request command
    ePrsSampleCommands_LinkMeasure,

    /// @brief Query MIB(s) command
    ePrsSampleCommands_QueryMib,

    /// @brief Set MIB(s) command
    ePrsSampleCommands_SetMib,

    /// @brief Set aiming mode with no options
    ePrsSampleCommands_SimpleAimMode,

    /// @brief Set aiming mode with channel number
    ePrsSampleCommands_ChannelAimMode,

    /// @brief Set aiming mode given BSSID of AP and channel number
    ePrsSampleCommands_BssidAimMode,

    /// @brief Enable firmware logs
    ePrsSampleCommands_EnableFwLogs,

    /// @brief Set the power mode
    ePrsSampleCommands_PowerMode,

    /// @brief Issue firmare reset
    ePrsSampleCommands_Reset,

    /// @brief Initiate get crash logs from firmware
    ePrsSampleCommands_GetCrashLogs,

    /// @brief Enable DBSC feature
    ePrsSampleCommands_DbscEnable,

    /// @brief Disable DBSC feature
    ePrsSampleCommands_DbscDisable,

    /// @brief Notification filter commands
    ePrsSampleCommands_NotificationFilter,

    /// @brief 命令的数量
    ePrsSampleCommands_Max
};

核心来了,表驱动法!!!

static const SPrsCmdDef g_cmds[ePrsSampleCommands_Max] =
{
   // Connection based commands
   { "aba", "Add Block ACK", 1, DoAddBlockAck, GetPeerAddress, UsagePeerAddr, 0 },
   { "qba", "Query Block ACK status", 1, DoQueryBlockAckStatus, GetPeerAddress, UsagePeerAddr, 0 },
   { "dba", "Send del Block ACK", 1, DoDelBlockAck, GetPeerAddress, UsagePeerAddr, 0 },
   { "lm", "Link measurement request", 1, DoLinkMeasure, GetPeerAddress, UsagePeerAddr, 0 },

   // Non-connection based commands
   { "qmib", "Query a range of MIBs", 0, DoQueryMib, GetQueryMibs, UsageQueryMibs, 0 },
   { "smib", "Set a range of MIBs", 0, DoSetMib, GetSetMibsAndVals, UsageSetMib, 0 },
   { "aim1", "Enable/Disable Aiming mode", 0, DoSimpleAimMode, GetSetting, UsageEnableAimingMode, 0 },
   { "aim2", "Set aiming mode on with channel", 0, DoChannelAimMode, GetSetting, UsageChannelNum, 0},
   { "aim3", "Set aiming mode on with BSSID/channel", 0, DoBssidAimMode, GetChannelAndBssid, UsageChannelAndBssid, 0 },
   { "fel", "Enable/Disable firmware logs", 0, DoEnableFwLogs, GetSetting, UsageEnable, 0 },
   { "pm", "Set power mode", 0, DoPowerMode, GetSetting, UsagePowerMode, 0 },
   { "rs", "Issue firmware reset", 0, DoReset, GetOptionalResetTime, UsageReset, 0 },
   { "fgl", "Trigger getting firmware crash logs", 0, DoGetCrashLogs, GetSetting, UsageCrashLogs, 0 },
   { "dbsc_en", "Enable DBSC", 0, DoDbscEnable, GetChannelAndBssid, UsageChannelAndBssid, 0 },
   { "dbsc_dis", "Disable DBSC", 0, DoDbscDisable, GetChannelAndBssid, UsageChannelAndBssid, 0 },
   { "notif", "Notification filter commands", 0, DoNotifFilter, GetNotifFilter, UsageNotifFilter, 0 }
};

每个命令的名称、说明、回调函数、获取参数的函数、普通参数做成了一个表。通过查找一个表的命令名称就可以找到对应的回调函数去执行。

任务队列

任务进队

ProcessCmdParms函数的下半段:

if (ok && cmd)
    {
        cout << "Added \""
             << cmd->cmdDesc
             << "\" command"
             << endl;
        g_run.push(cmd);
...

g_run.push(cmd) 就是将一个任务结构体写进了队列,这个队列在全局中实现:

static queue< SPrsCmdDef* > g_run;

可以看到是通过 std 标准库来实现的,而且为了减少内存和提高翻问速度,队列里存放的是任务指针。

任务出队

现在回到主函数的一部分:

    if (g_run.size())
    {
        // Command mode exclusive to event mode otherwise command
        // output gets garbled and cannot be parsed.

        while (rc && g_run.size())
        {
            cout << endl;

            cmd = g_run.front();    //获取任务队列中最先入队的任务
            g_run.pop();            //成员出队

            if (!(rc = cmd->func(com, cmd)))//然后执行任务
                ......
            else
                ......
        }

软件框架上就是这样,简单的框架,复杂的任务。
在这里插入图片描述

业务处理

下面简单讲一下这个工具的业务处理,对比两个命令的执行流程来了解一下。
在这里插入图片描述

从这里也就可以看出这个软件最终都是向 cfg80211 也就无线网卡的驱动发送 netlink 消息。
驱动会将消息传递给固件,从而修改无线网卡的工作。

真正起作用的底层代码:

1、将命令包装成驱动可以识别的数据包

int Private_IssueCommand(
    PrsNl80211VendorCommandHandle *a_handle,
    unsigned int a_vendorId, unsigned int a_subCommand,
    const void *a_pData, unsigned int a_dataLength,
    SPrsVendorReplyBuffer *a_pReplyBuffer
)
{
    struct nl_msg *msg = NULL;
    int returnCode = -1;

    do
    {
        const int hdrlen = 0;
        const int flags = 0;
        const uint8_t version = 0;

        msg = nlmsg_alloc();
        if (!msg) {
            PRS_ERROR("nlmsg_alloc failed\n");
            returnCode = -NLE_NOMEM;
            break;
        }
        if (!genlmsg_put(msg, NL_AUTO_PORT, NL_AUTO_SEQ, a_handle->nl80211_id, hdrlen, flags, NL80211_CMD_VENDOR, version)) {
            PRS_ERROR("genlmsg_put failed\n");
            returnCode = -NLE_NOMEM;
            break;
        }
        if ((returnCode = nla_put_u32(msg, NL80211_ATTR_IFINDEX, a_handle->ifindex)) != 0) {
            PRS_ERROR("nla_put_u32 NL80211_ATTR_IFINDEX failed\n");
            break;
        }
        if ((returnCode = nla_put_u32(msg, NL80211_ATTR_VENDOR_ID, a_vendorId)) != 0) {
            PRS_ERROR("nla_put_u32 NL80211_ATTR_VENDOR_ID failed\n");
            break;
        }
        if ((returnCode = nla_put_u32(msg, NL80211_ATTR_VENDOR_SUBCMD, a_subCommand)) != 0) {
            PRS_ERROR("nla_put_u32 NL80211_ATTR_VENDOR_SUBCMD failed\n");
            break;
        }
        if ((returnCode = nla_put(msg, NL80211_ATTR_VENDOR_DATA, a_dataLength, a_pData)) != 0) {
            PRS_ERROR("nla_put_u32 NL80211_ATTR_VENDOR_DATA failed\n");
            break;
        }
        returnCode = Private_SendReceive(a_handle->cb, a_handle->sk, msg, 
            Private_VendorReplyHandler, a_pReplyBuffer
        );
    } while(0);

    if (msg) 
    {
        nlmsg_free(msg);
        msg = NULL;
    }

    return returnCode;
}

2、netlink 收发函数

static int Private_SendReceive(
    struct nl_cb *a_pOriginalCallback,
    struct nl_sock *a_sk,
    struct nl_msg *msg,
    nl_recvmsg_msg_cb_t a_replyCallback,
    void *a_pReplyCallbackArg
)
{
    struct nl_cb *cb = NULL;
    int returnCode = -1;

    do 
    {
        cb = nl_cb_clone(a_pOriginalCallback);
        if (!cb) {
            PRS_ERROR("nl_cb_clone failed\n");
            returnCode = -NLE_NOMEM;
            break;
        }
        returnCode = nl_send_auto_complete(a_sk, msg);
        if (returnCode < 0) {
            PRS_ERROR("nl_send_auto_complete failed with %d\n", returnCode);
            break;
        }
        nl_cb_err(cb, NL_CB_CUSTOM, Private_ErrorHandler, &returnCode);

        /* Last message in a series of multi part messages received */
        nl_cb_set(cb, NL_CB_FINISH, NL_CB_CUSTOM, Private_FinishHandler, &returnCode);

        /* Message is an acknowledge */
        nl_cb_set(cb, NL_CB_ACK,    NL_CB_CUSTOM, Private_AckHandler, &returnCode);

        /* Message is valid */
        nl_cb_set(cb, NL_CB_VALID,  NL_CB_CUSTOM, a_replyCallback, a_pReplyCallbackArg);

        returnCode = 1;     /* This will be set by the handlers */
        while (returnCode > 0)
        {
            int res = nl_recvmsgs(a_sk, cb);
            if (res < 0) {
                PRS_ERROR("nl_recvmsgs failed with %d\n", res);
                returnCode = res;
                break;
            }
        }
    } while(0);

    if (cb) 
    {
        nl_cb_put(cb);
        cb = NULL;
    }

    return returnCode;
}

3、收到消息时使用供应商专属的解析函数

int Private_VendorReplyHandler(struct nl_msg *msg, void *arg)
{
    struct nlattr *tb[NL80211_ATTR_MAX + 1];
    struct nlattr *nl_vendor_reply, *nl;
    struct genlmsghdr *gnlh = (struct genlmsghdr *)nlmsg_data(nlmsg_hdr(msg));
    SPrsVendorReplyBuffer *buf = (SPrsVendorReplyBuffer *)arg;
    static const uint8_t sc_SuccessCodeBuffer[4] = {0x00, 0x00, 0x00, 0x00};
    int rem;

    if (!buf)
        return NL_SKIP; /* Skip this message. */

    // Create attribute index based on a stream of attributes. This will populate
    // `tb` by parsing all attributes from `gnlh`.
    nla_parse(tb, NL80211_ATTR_MAX, genlmsg_attrdata(gnlh, 0), genlmsg_attrlen(gnlh, 0), NULL);
    nl_vendor_reply = tb[NL80211_ATTR_VENDOR_DATA];

    if (!nl_vendor_reply){
        PRS_ERROR("No vendor data found in reply");
        return NL_SKIP;
    }

    nla_for_each_nested_typecast(nl, nl_vendor_reply, rem) {
        if (!Private_Buffer_Append(buf, nla_data(nl), nla_len(nl))) {
            if (nla_len(nl) == 4 && buf->capacity == 0 && memcmp(nla_data(nl), sc_SuccessCodeBuffer, 4) == 0)
            {
                // All commands seem to return return a 4 byte response!
            }
            else
            {
                PRS_ERROR("buffer_append error: data=0x%p len=%d buffer capacity=%zu size=%zu",
                    nla_data(nl), nla_len(nl), buf->capacity, buf->size);
            }
            return NL_SKIP;
        }
    }

    return NL_SKIP;
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值