[Android]Android P(9) WIFI学习笔记 - 扫描 (3)

前文回顾

前言

  1. 基于Android P源码学习;
  2. 代码片为了方便阅读段经过删、裁减,请以实际源码为准;

请求WLAN芯片开始扫描,然后获取扫描结果,整个过程是分两个阶段:

  1. 请求扫描
  2. 获取结果

前面两篇已经将Framework层的逻辑梳理了一遍,而本篇,会尝试从wificond开始,先后梳理一下发起扫描请求,与收到扫描结果两个阶段,在Native层的逻辑(最多只到nl80211消息发送,不涉及驱动);

请求扫描

WIFI学习笔记 - 扫描 (1)最后,我们已经找到了,发起扫描请求的调用,在Framework层最终会调用到IWifiScannerImpl.scan()这一接口上,而后者的实现类在wificond进程中:

wificond

scanner_impl
//system/connectivity/wificond/scanning/scanner_impl.cpp
Status ScannerImpl::scan(const SingleScanSettings& scan_settings,
                         bool* out_success) {
  ...
  // Only request MAC address randomization when station is not associated.
  bool request_random_mac =
      wiphy_features_.supports_random_mac_oneshot_scan &&
      !client_interface_->IsAssociated();
  int scan_type = scan_settings.scan_type_;
  //wiphy_features_是硬件层直接返回的当前WLAN芯片支持的扫描类型
  //如果请求的类型不被硬件支持,则回落为SCAN_TYPE_DEFAULT
  if (!IsScanTypeSupported(scan_settings.scan_type_, wiphy_features_)) {
    LOG(DEBUG) << "Ignoring scan type because device does not support it";
    scan_type = SCAN_TYPE_DEFAULT;
  }

  // Initialize it with an empty ssid for a wild card scan.
  vector<vector<uint8_t>> ssids = {{}};

  //将需要扫描的隐藏网络添加到ssids;
  //如果ssids内元素数量超过max_num_scan_ssids,则将其添加到skipped_scan_ssids中,后者仅用于日志打印
  vector<vector<uint8_t>> skipped_scan_ssids;
  for (auto& network : scan_settings.hidden_networks_) {
    if (ssids.size() + 1 > scan_capabilities_.max_num_scan_ssids) {
      skipped_scan_ssids.emplace_back(network.ssid_);
      continue;
    }
    ssids.push_back(network.ssid_);
  }

  LogSsidList(skipped_scan_ssids, "Skip scan ssid for single scan");

  //将扫描需要覆盖的信道频率添加到freqs向量中
  vector<uint32_t> freqs;
  for (auto& channel : scan_settings.channel_settings_) {
    freqs.push_back(channel.frequency_);
  }

  int error_code = 0;
  //关键调用
  if (!scan_utils_->Scan(interface_index_, request_random_mac, scan_type,
                         ssids, freqs, &error_code)) {
    CHECK(error_code != ENODEV) << "Driver is in a bad state, restarting wificond";
    *out_success = false;
    return Status::ok();
  }
  ...
  *out_success = true;
  return Status::ok();
}

上面的函数实现,由如下几个关键点:

  1. 扫描请求中设定的扫描类型,如果与硬件上报支持的类型不匹配,则后续扫描类型会回落为SCAN_TYPE_DEFAULT
  2. 隐藏网络扫描数量有限制,最大为max_num_scan_ssids(通过NL80211_ATTR_MAX_NUM_SCAN_SSIDS属性获取)
  3. 信道频率直接转存到freqs中,没有过滤;
  4. 处理完数据后,将参数传入scan_utils_->Scan的函数调用中,继续后面的逻辑;
scan_utils
//system/connectivity/wificond/scanning/scan_utils.cpp
bool ScanUtils::Scan(uint32_t interface_index,
                     bool request_random_mac,
                     int scan_type,
                     const vector<vector<uint8_t>>& ssids,
                     const vector<uint32_t>& freqs,
                     int* error_code) {
  //构造一个NL80211Packet,命令为NL80211_CMD_TRIGGER_SCAN
  NL80211Packet trigger_scan(
      netlink_manager_->GetFamilyId(),
      NL80211_CMD_TRIGGER_SCAN,
      netlink_manager_->GetSequenceNumber(),
      getpid());
  // If we do not use NLM_F_ACK, we only receive a unicast repsonse
  // when there is an error. If everything is good, scan results notification
  // will only be sent through multicast.
  // If NLM_F_ACK is set, there will always be an unicast repsonse, either an
  // ERROR or an ACK message. The handler will always be called and removed by
  // NetlinkManager.
  // 这里英文解释我保留,然后根据自己的理解简单描述一下:
  // 通常,只有在内核处理消息时出现错误时,会调用netlink_ack通知到调用方;
  // 而添加了NLM_F_ACK这个标志位后,无论成功还是失败,netlink_ack均会调用;
  // 示例代码:
  // if ((err) || (nlh->nlmsg_flags & NLM_F_ACK)) 
  // 	netlink_ack(skb, nlh, err);
  trigger_scan.AddFlag(NLM_F_ACK);
  //构造NL80211Attr,key为NL80211_ATTR_IFINDEX,value为interface_index
  NL80211Attr<uint32_t> if_index_attr(NL80211_ATTR_IFINDEX, interface_index);
  //构造NL80211NestedAttr,key为NL80211_ATTR_SCAN_SSIDS,key为后面遍历添加的所有隐藏网络SSID
  NL80211NestedAttr ssids_attr(NL80211_ATTR_SCAN_SSIDS);
  for (size_t i = 0; i < ssids.size(); i++) {
    ssids_attr.AddAttribute(NL80211Attr<vector<uint8_t>>(i, ssids[i]));
  }
  //频率添加方式与上面ssids_attr类似
  NL80211NestedAttr freqs_attr(NL80211_ATTR_SCAN_FREQUENCIES);
  for (size_t i = 0; i < freqs.size(); i++) {
    freqs_attr.AddAttribute(NL80211Attr<uint32_t>(i, freqs[i]));
  }

  //将上面构造的NL80211Attr/NL80211NestedAttr添加进trigger_scan这个NL80211Packet
  trigger_scan.AddAttribute(if_index_attr);
  trigger_scan.AddAttribute(ssids_attr);
  // An absence of NL80211_ATTR_SCAN_FREQUENCIES attribue informs kernel to
  // scan all supported frequencies.
  if (!freqs.empty()) {
    trigger_scan.AddAttribute(freqs_attr);
  }

  uint32_t scan_flags = 0;
  //ScannerImpl::scan中处理过的一些参数,在这里以scan_flags传递
  if (request_random_mac) {
    scan_flags |= NL80211_SCAN_FLAG_RANDOM_ADDR;
  }
  switch (scan_type) {
	...
    case IWifiScannerImpl::SCAN_TYPE_HIGH_ACCURACY:
      scan_flags |= NL80211_SCAN_FLAG_HIGH_ACCURACY;
      break;
    case IWifiScannerImpl::SCAN_TYPE_DEFAULT:
      break;
    default:
      CHECK(0) << "Invalid scan type received: " << scan_type;
  }
  //最后,将scan_flags封装进trigger_scan这个NL80211Packet
  if (scan_flags) {
    trigger_scan.AddAttribute(
        NL80211Attr<uint32_t>(NL80211_ATTR_SCAN_FLAGS,
                              scan_flags));
  }
  // We are receiving an ERROR/ACK message instead of the actual
  // scan results here, so it is OK to expect a timely response because
  // kernel is supposed to send the ERROR/ACK back before the scan starts.
  vector<unique_ptr<const NL80211Packet>> response;
  if (!netlink_manager_->SendMessageAndGetAckOrError(trigger_scan,
                                                     error_code)) {
    // Logging is done inside |SendMessageAndGetAckOrError|.
    return false;
  }
  if (*error_code != 0) {
    LOG(ERROR) << "NL80211_CMD_TRIGGER_SCAN failed: " << strerror(*error_code);
    return false;
  }
  return true;
}

可见,ScanUtils::Scan大部分逻辑还是参数的处理、封装,最后整合到名为trigger_scan的一个NL80211Packet结构体中,并通过netlink_manager_->SendMessageAndGetAckOrError发送出去;
而通过注释我们可以知道,通常结果是在内核实际开始扫描之前就返回了,所以我们在请求扫描的调用栈中,是不可能拿到扫描结果的,这也是为什么请求扫描与获取扫描结果两个步骤无法做到同步的主要原因;

netlink_manager
//system/connectivity/wificond/net/netlink_manager.cpp
bool NetlinkManager::SendMessageAndGetAckOrError(const NL80211Packet& packet,
                                                 int* error_code) {
  unique_ptr<const NL80211Packet> response;
  if (!SendMessageAndGetSingleResponseOrError(packet, &response)) {
    return false;
  }
  uint16_t type = response->GetMessageType();
  if (type != NLMSG_ERROR) {
    LOG(ERROR) << "Receive unexpected message type :" << type;
    return false;
  }

  *error_code = response->GetErrorCode();
  return true;
}
...
bool NetlinkManager::SendMessageAndGetSingleResponseOrError(
    const NL80211Packet& packet,
    unique_ptr<const NL80211Packet>* response) {
  vector<unique_ptr<const NL80211Packet>> response_vec;
  if (!SendMessageAndGetResponses(packet, &response_vec)) {
    return false;
  }
  if (response_vec.size() != 1) {
    LOG(ERROR) << "Unexpected response size: " << response_vec.size();
    return false;
  }

  *response = std::move(response_vec[0]);
  return true;
}
...
bool NetlinkManager::SendMessageAndGetResponses(
    const NL80211Packet& packet,
    vector<unique_ptr<const NL80211Packet>>* response) {
  if (!SendMessageInternal(packet, sync_netlink_fd_.get())) {
    return false;
  }
  // Polling netlink socket, waiting for GetFamily reply.
  struct pollfd netlink_output;
  memset(&netlink_output, 0, sizeof(netlink_output));
  netlink_output.fd = sync_netlink_fd_.get();
  netlink_output.events = POLLIN;

  uint32_t sequence = packet.GetMessageSequence();

  int time_remaining = kMaximumNetlinkMessageWaitMilliSeconds;
  // Multipart messages may come with seperated datagrams, ending with a
  // NLMSG_DONE message.
  // ReceivePacketAndRunHandler() will remove the handler after receiving a
  // NLMSG_DONE message.
  message_handlers_[sequence] = std::bind(AppendPacket, response, _1);

  while (time_remaining > 0 &&
      message_handlers_.find(sequence) != message_handlers_.end()) {
    nsecs_t interval = systemTime(SYSTEM_TIME_MONOTONIC);
    int poll_return = poll(&netlink_output,
                           1,
                           time_remaining);

    if (poll_return == 0) {
      LOG(ERROR) << "Failed to poll netlink fd: time out ";
      message_handlers_.erase(sequence);
      return false;
    } else if (poll_return == -1) {
      LOG(ERROR) << "Failed to poll netlink fd: " << strerror(errno);
      message_handlers_.erase(sequence);
      return false;
    }
    ReceivePacketAndRunHandler(sync_netlink_fd_.get());
    interval = systemTime(SYSTEM_TIME_MONOTONIC) - interval;
    time_remaining -= static_cast<int>(ns2ms(interval));
  }
  if (time_remaining <= 0) {
    LOG(ERROR) << "Timeout waiting for netlink reply messages";
    message_handlers_.erase(sequence);
    return false;
  }
  return true;
}

上面代码是比较多,但是调用路线很明显:SendMessageAndGetAckOrError -> SendMessageAndGetSingleResponseOrError -> SendMessageAndGetResponses -> SendMessageInternal & ReceivePacketAndRunHandler

那么接下来,来看一下SendMessageInternalReceivePacketAndRunHandler这两个函数实现:

bool NetlinkManager::SendMessageInternal(const NL80211Packet& packet, int fd) {
  const vector<uint8_t>& data = packet.GetConstData();
  ssize_t bytes_sent =
      TEMP_FAILURE_RETRY(send(fd, data.data(), data.size(), 0));
  if (bytes_sent == -1) {
    LOG(ERROR) << "Failed to send netlink message: " << strerror(errno);
    return false;
  }
  return true;
}
...
void NetlinkManager::ReceivePacketAndRunHandler(int fd) {
  ssize_t len = read(fd, ReceiveBuffer, kReceiveBufferSize);
  if (len == -1) {
    LOG(ERROR) << "Failed to read packet from buffer";
    return;
  }
  if (len == 0) {
    return;
  }
  // There might be multiple message in one datagram payload.
  uint8_t* ptr = ReceiveBuffer;
  while (ptr < ReceiveBuffer + len) {
    // peek at the header.
    if (ptr + sizeof(nlmsghdr) > ReceiveBuffer + len) {
      LOG(ERROR) << "payload is broken.";
      return;
    }
    const nlmsghdr* nl_header = reinterpret_cast<const nlmsghdr*>(ptr);
    unique_ptr<NL80211Packet> packet(
        new NL80211Packet(vector<uint8_t>(ptr, ptr + nl_header->nlmsg_len)));
    ptr += nl_header->nlmsg_len;
    if (!packet->IsValid()) {
      LOG(ERROR) << "Receive invalid packet";
      return;
    }
    // Some document says message from kernel should have port id equal 0.
    // However in practice this is not always true so we don't check that.

    uint32_t sequence_number = packet->GetMessageSequence();

    // Handle multicasts.
    if (sequence_number == kBroadcastSequenceNumber) {
      BroadcastHandler(std::move(packet));
      continue;
    }

    auto itr = message_handlers_.find(sequence_number);
    // There is no handler for this sequence number.
    if (itr == message_handlers_.end()) {
      LOG(WARNING) << "No handler for message: " << sequence_number;
      return;
    }
    // A multipart message is terminated by NLMSG_DONE.
    // In this case we don't need to run the handler.
    // NLMSG_NOOP means no operation, message must be discarded.
    uint32_t message_type =  packet->GetMessageType();
    if (message_type == NLMSG_DONE || message_type == NLMSG_NOOP) {
      message_handlers_.erase(itr);
      return;
    }
    if (message_type == NLMSG_OVERRUN) {
      LOG(ERROR) << "Get message overrun notification";
      message_handlers_.erase(itr);
      return;
    }

    // In case we receive a NLMSG_ERROR message:
    // NLMSG_ERROR could be either an error or an ACK.
    // It is an ACK message only when error code field is set to 0.
    // An ACK could be return when we explicitly request that with NLM_F_ACK.
    // An ERROR could be received on NLM_F_ACK or other failure cases.
    // We should still run handler in this case, leaving it for the caller
    // to decide what to do with the packet.

    bool is_multi = packet->IsMulti();
    // Run the handler.
    itr->second(std::move(packet));
    // Remove handler after processing.
    if (!is_multi) {
      message_handlers_.erase(itr);
    }
  }
}

  1. SendMessageInternal很简单,就是一个send系统调用;
  2. ReceivePacketAndRunHandler也不复杂,主要也就是一个read系统调用,后面都是对消息的解析、分发、处理;

到这里,扫描请求的wificond部分就差不多了,后面就涉及到nl80211通信的部分了,暂不展开;

接下来我们来梳理获取扫描结果的流程:

扫描结果

WIFI学习笔记 - 扫描 (2)最后,我们已经找到了,发起扫描请求的调用,在Framework层最终会调用到IWifiScannerImpl.getScanResults()这一接口上,而后者的实现类在wificond进程中:

wificond

scanner_impl
//system/connectivity/wificond/scanning/scanner_impl.cpp
Status ScannerImpl::getScanResults(vector<NativeScanResult>* out_scan_results) {
  ...
  if (!scan_utils_->GetScanResult(interface_index_, out_scan_results)) {
    LOG(ERROR) << "Failed to get scan results via NL80211";
  }
  return Status::ok();
}
scan_utils
//system/connectivity/wificond/scanning/scan_utils.cpp
bool ScanUtils::GetScanResult(uint32_t interface_index,
                              vector<NativeScanResult>* out_scan_results) {
  //构造一个NL80211Packet,命名为get_scan,封装nl80211消息为NL80211_CMD_GET_SCAN
  NL80211Packet get_scan(
      netlink_manager_->GetFamilyId(),
      NL80211_CMD_GET_SCAN,
      netlink_manager_->GetSequenceNumber(),
      getpid());
  //添加NLM_F_DUMP标志位,即返回所有满足条件的结果(NLM_F_DUMP = NLM_F_ROOT|NLM_F_MATCH)
  get_scan.AddFlag(NLM_F_DUMP);
  //构建名为ifindex的NL80211Attr类型对象,key为NL80211_ATTR_IFINDEX,value为interface_index
  NL80211Attr<uint32_t> ifindex(NL80211_ATTR_IFINDEX, interface_index);
  //将ifindex封装进get_scan
  get_scan.AddAttribute(ifindex);

  vector<unique_ptr<const NL80211Packet>> response;
  //发送消息
  if (!netlink_manager_->SendMessageAndGetResponses(get_scan, &response))  {
    LOG(ERROR) << "NL80211_CMD_GET_SCAN dump failed";
    return false;
  }
  ...
  //读取返回结果
  for (auto& packet : response) {
	...
    if (packet->GetMessageType() != netlink_manager_->GetFamilyId()) {
      LOG(ERROR) << "Wrong message type: "
                 << packet->GetMessageType();
      continue;
    }
    uint32_t if_index;
    //获取返回数据中key为NL80211_ATTR_IFINDEX的值,如果不存在,则跳过
    if (!packet->GetAttributeValue(NL80211_ATTR_IFINDEX, &if_index)) {
      LOG(ERROR) << "No interface index in scan result.";
      continue;
    }
    //如果与请求时的值不同,则忽略掉
    if (if_index != interface_index) {
      LOG(WARNING) << "Uninteresting scan result for interface: " << if_index;
      continue;
    }

    NativeScanResult scan_result;
    //封装函数ParseScanResult用于解析packet中的数据,并将其封装为scan_result
    if (!ParseScanResult(std::move(packet), &scan_result)) {
      LOG(DEBUG) << "Ignore invalid scan result";
      continue;
    }
    //将封装好的scan_result存入out_scan_results中,用于最终返回
    out_scan_results->push_back(std::move(scan_result));
  }
  return true;
}

发送消息部分,与上面请求开始扫描的调用差不多,都会走到NetlinkManager::SendMessageAndGetResponses()中去,后者负责通过继续调用,最终发送nl80211消息到内核,这里就不赘述了;

扫描结果就绪事件回调

最后,简单梳理一下WIFI学习笔记 - 扫描 (2)提到的两个AIDL接口:

  1. wificond注册事件监听的IWifiScannerImpl.subscribeScanEvents
    scanner_impl
    //system/connectivity/wificond/scanning/scanner_impl.cpp
    Status ScannerImpl::subscribeScanEvents(const sp<IScanEvent>& handler) {
      ...
      if (scan_event_handler_ != nullptr) {
        LOG(ERROR) << "Found existing scan events subscriber."
                   << " This subscription request will unsubscribe it";
      }
      scan_event_handler_ = handler;
      return Status::ok();
    }
    
    有且仅有一个监听器可被注册,说明这就是系统独占的一个内部接口,若需注册新的监听器,需要先将之前的反注册掉(ScannerImpl::unsubscribeScanEvents接口)
    Status ScannerImpl::unsubscribeScanEvents() {
      scan_event_handler_ = nullptr;
      return Status::ok();
    }
    
  2. wificond在扫描结果就绪后通知上层的ScanEventHandler.OnScanResultReady
    scanner_impl
    //system/connectivity/wificond/scanning/scanner_impl.cpp
    void ScannerImpl::OnScanResultsReady(uint32_t interface_index, bool aborted,
                                         vector<vector<uint8_t>>& ssids,
                                         vector<uint32_t>& frequencies) {
      ...
      scan_started_ = false;
      if (scan_event_handler_ != nullptr) {
        // TODO: Pass other parameters back once we find framework needs them.
        if (aborted) {
          LOG(WARNING) << "Scan aborted";
          scan_event_handler_->OnScanFailed();
        } else {
          scan_event_handler_->OnScanResultReady();
        }
      } else {
        LOG(WARNING) << "No scan event handler found.";
      }
    }
    
    继续反向查找调用栈,可找到:
    netlink_manager
    //system/connectivity/wificond/net/netlink_manager.cpp
    void NetlinkManager::OnScanResultsReady(unique_ptr<const NL80211Packet> packet) {
      uint32_t if_index;
      if (!packet->GetAttributeValue(NL80211_ATTR_IFINDEX, &if_index)) {
        LOG(ERROR) << "Failed to get interface index from scan result notification";
        return;
      }
      bool aborted = false;
      if (packet->GetCommand() == NL80211_CMD_SCAN_ABORTED) {
        aborted = true;
      }
    
      //scanner_impl.cpp中,ScannerImpl的构造函数里会调用scan_utils_->SubscribeScanResultNotification
      //后者会调用到netlink_manager_->SubscribeScanResultNotification
      //将自己注册到on_scan_result_ready_handler_
      const auto handler = on_scan_result_ready_handler_.find(if_index);
      if (handler == on_scan_result_ready_handler_.end()) {
        LOG(WARNING) << "No handler for scan result notification from interface"
                     << " with index: " << if_index;
        return;
      }
    
      vector<vector<uint8_t>> ssids;
      NL80211NestedAttr ssids_attr(0);
      if (!packet->GetAttribute(NL80211_ATTR_SCAN_SSIDS, &ssids_attr)) {
        if (!aborted) {
          LOG(WARNING) << "Failed to get scan ssids from scan result notification";
        }
      } else {
        if (!ssids_attr.GetListOfAttributeValues(&ssids)) {
          return;
        }
      }
      vector<uint32_t> freqs;
      NL80211NestedAttr freqs_attr(0);
      if (!packet->GetAttribute(NL80211_ATTR_SCAN_FREQUENCIES, &freqs_attr)) {
        if (!aborted) {
          LOG(WARNING) << "Failed to get scan freqs from scan result notification";
        }
      } else {
        if (!freqs_attr.GetListOfAttributeValues(&freqs)) {
          return;
        }
      }
      //触发回调时,根据绑定规则,会调用到ScannerImpl::OnScanResultsReady
      // Run scan result notification handler.
      handler->second(if_index, aborted, ssids, freqs);
    }
    
    继续,调用NetlinkManager::OnScanResultsReady的是本类的函数NetlinkManager::BroadcastHandler
    void NetlinkManager::BroadcastHandler(unique_ptr<const NL80211Packet> packet) {
      ...	
      if (command == NL80211_CMD_NEW_SCAN_RESULTS ||
          // Scan was aborted, for unspecified reasons.partial scan results may be
          // available.
          command == NL80211_CMD_SCAN_ABORTED) {
        OnScanResultsReady(std::move(packet));
        return;
      }	
      ...
    }
    
    NetlinkManager::BroadcastHandler的调用,同样来此本类的另一个函数NetlinkManager::ReceivePacketAndRunHandler()
    void NetlinkManager::ReceivePacketAndRunHandler(int fd) {
      ssize_t len = read(fd, ReceiveBuffer, kReceiveBufferSize);
      ...
      // There might be multiple message in one datagram payload.
      uint8_t* ptr = ReceiveBuffer;
      while (ptr < ReceiveBuffer + len) {
        // peek at the header.
        if (ptr + sizeof(nlmsghdr) > ReceiveBuffer + len) {
          LOG(ERROR) << "payload is broken.";
          return;
        }
        const nlmsghdr* nl_header = reinterpret_cast<const nlmsghdr*>(ptr);
        unique_ptr<NL80211Packet> packet(
            new NL80211Packet(vector<uint8_t>(ptr, ptr + nl_header->nlmsg_len)));
        ptr += nl_header->nlmsg_len;
        if (!packet->IsValid()) {
          LOG(ERROR) << "Receive invalid packet";
          return;
        }
        // Some document says message from kernel should have port id equal 0.
        // However in practice this is not always true so we don't check that.
    
        uint32_t sequence_number = packet->GetMessageSequence();
    
        // Handle multicasts.
        if (sequence_number == kBroadcastSequenceNumber) {
          BroadcastHandler(std::move(packet));
          continue;
        }
        ...
      }
    }
    
    调用NetlinkManager::ReceivePacketAndRunHandler()的地方有两处:
    1. NetlinkManager::SendMessageAndGetResponses()
    2. NetlinkManager::WatchSocket注册后,由底层主动上报;
      根据之前的分析,扫描结果就绪这一事件,一定是异步上报的,与上层的请求不存在同步关系,因此一定不是某次发送消息后的返回结果,故而排除第一处;
      而第二处是在NetlinkManager构造时执行的,属于全生命周期的监听,符合这一场景设计;

至此,应该是把除内核、驱动以外部分,整个扫描请求,扫描结果获取的流程梳理完毕了;

最后,流程图奉上:
在这里插入图片描述

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值