chromium通信系统-ipcz系统(十)-消息通知(trap)

通过前面一系列ipcz的文章分析,我们了解到ipcz是在unix domain socket上层建立起了多个逻辑通道,所以对于上层的消息事件,不能单纯依赖io event 来触发,所以在ipcz系统中加入了trap的机制。 trap 的含义是陷阱,这个名字应该是来自cpu架构的概念,其实就是一种消息机制。 在cpu中, 执行分为中断和陷阱,陷阱是安插在程序指令流里面,当执行到了陷阱指令,就相当于掉入了陷阱中,这时候要调用陷阱处理程序来从陷阱中恢复出来。 ipcz的trap 机制则不同,它既有陷阱的含义,也有中断的含义。

我们都知道ipcz将来会取代mojo,不过现在还是混合使用的状态。 所以目前有两层Trap。 第一层是ipcz 层的trap, 用于接收端口事件, 该层主要的api是/third_party/ipcz/src/api.cc的Trap函数

IpczResult Trap(IpczHandle portal_handle,
                const IpczTrapConditions* conditions,
                IpczTrapEventHandler handler,
                uintptr_t context,
                uint32_t flags,
                const void* options,
                IpczTrapConditionFlags* satisfied_condition_flags,
                IpczPortalStatus* status)
             

Trap函数如果trap 关注的事件(conditions)没有满足,则会注册成功,否则返回IPCZ_RESULT_FAILED_PRECONDITION 表示前置条件不满足,满足的条件通过satisfied_condition_flags参数返回。 这使得关注portal的事件有两个路径,一个是注册成功,后续事件触发导致回调,一个是注册失败,满足的条件通过satisfied_condition_flags 来返回。 这给编程造成了一定困难,是不好的设计。

mojo层的Trap管理多个ipcz层的trap, 每个Trap叫做一个Trigger。 它主要的api是:


MojoResult MojoTrap::AddTrigger(MojoHandle handle,
                                MojoHandleSignals signals,
                                MojoTriggerCondition condition,
                                uintptr_t trigger_context)

由于MojoTrap管理多个Ipcz Trap, MojoTrap使用Trigger代表一个ipcz的trap。 AddTrigger为添加一个Trigger。 由于添加时Trigger 不一定能添加进去(因为IPCZ_RESULT_FAILED_PRECONDITION), 所以又增加了一个Arm函数,用于返回当前满足的状态,并注册为满足状态的trigger 到ipcz trap。

MojoResult MojoTrap::Arm(MojoTrapEvent* blocking_events,
                         uint32_t* num_blocking_events)

blocking_events 用于返回满足条件的Trigger对应的事件。

废话不多说我们直接通过代码分析。

我们先来看Trap对象的创建。
mojo/core/core_ipcz.cc

MojoResult MojoAddTriggerIpcz(MojoTrapEventHandler handler,
                              const MojoCreateTrapOptions* options,
                              MojoHandle* trap_handle) {
  if (!handler || !trap_handle) {
    return MOJO_RESULT_INVALID_ARGUMENT;
  }

  *trap_handle = ipcz_driver::MojoTrap::MakeBoxed(handler);
  return MOJO_RESULT_OK;
}

MojoCreateTrapIpcz的作用是创建一个Trap对象,有了这个Trap对象后,就可以想这个trap对象注册一些事件观察,当事件发生的时候就会向这些观察者发出通知。
参数handler 代表触发trap时的处理函数, 这是一个回调函数。 trap_handle 是一个传输参数,是安装trap的句柄,用于向这个trap注册一下事件监听。
函数直接通过ipcz_driver::MojoTrap::MakeBoxed(handler) 创建MojoTrap, MakeBoxed我们前面已经分析过了,会调用MojoTrap的构造方法, 我们看一下MojoTrap的构造方法。

MojoTrap::MojoTrap(MojoTrapEventHandler handler) : handler_(handler) {}

也很简单,只是设置了handler_成员变量。

我们再看下一个Trap相关api

MojoResult MojoAddTriggerIpcz(MojoHandle trap_handle,
                              MojoHandle handle,
                              MojoHandleSignals signals,
                              MojoTriggerCondition condition,
                              uintptr_t context,
                              const MojoAddTriggerOptions* options) {
  auto* trap = ipcz_driver::MojoTrap::FromBox(trap_handle);
  if (!trap) {
    return MOJO_RESULT_INVALID_ARGUMENT;
  }
  return trap->AddTrigger(handle, signals, condition, context);
}

MojoAddTriggerIpcz 函数顾名思义就是向trap添加一个触发器,用于注册trap 事件观察者,参数trap_handle 是用于指向MojoTrap的句柄,handle 是 portal的句柄,也就是添加的触发器针对与portal 上面的事件。signals则代表观察的具体事件,比如portal可读、可写等。 condition 就是表示触发器具体触发的条件,比如既可以在管道可读时触发,也可以在管道变为不可读时触发。 context参数用于回调函数的参数,是注册触发器时候给的上下文信息,这是回调场景中常用的手段。

MojoAddTriggerIpcz 函数代码逻辑也很简单,就是调用MojoTrap->AddTrigger() 方法添加触发器。在分析addTrigger之前我们先给出MojoHandleSignals 和MojoTriggerCondition的枚举值。

MojoHandleSignals:

  • MOJO_HANDLE_SIGNAL_READABLE: portal可读
  • MOJO_HANDLE_SIGNAL_WRITABLE: portal可写
  • MOJO_HANDLE_SIGNAL_PEER_CLOSED:portal 对端关闭
  • MOJO_HANDLE_SIGNAL_PEER_REMOTE:portal对等点在另一个进程中
  • MOJO_HANDLE_SIGNAL_QUOTA_EXCEEDED: 超过某个限额
    限额包括如下
    • receive_queue_length_limit_;
    • receive_queue_memory_size_limit_;
    • unread_message_count_limit_;

MojoTriggerCondition:

  • MOJO_TRIGGER_CONDITION_SIGNALS_UNSATISFIED 不满足条件触发
  • MOJO_TRIGGER_CONDITION_SIGNALS_SATISFIED 满足条件触发

对于信号和触发条件我们有了了解,继续分析MojoTrap->AddTrigger()函数。

195 MojoResult MojoTrap::AddTrigger(MojoHandle handle,
196                                 MojoHandleSignals signals,
197                                 MojoTriggerCondition condition,
198                                 uintptr_t trigger_context) {
      .....
205   auto* data_pipe = DataPipe::FromBox(handle);
206   scoped_refptr<DataPipe::PortalWrapper> control_portal;
      // 如果handle 是data_pipem 获取control_portal
207   if (data_pipe) {
208     control_portal = data_pipe->GetPortal();
209     if (!control_portal) {
210       return MOJO_RESULT_INVALID_ARGUMENT;
211     }
212     handle = control_portal->handle();
213   } else if (ObjectBase::FromBox(handle)) {
214     // Any other type of driver object cannot have traps installed.
215     return MOJO_RESULT_INVALID_ARGUMENT;
216   }
217 
      // 创建Trigger对象
218   auto trigger = base::MakeRefCounted<Trigger>(this, handle, data_pipe, signals,
219                                                trigger_context);
220 
221   if (condition == MOJO_TRIGGER_CONDITION_SIGNALS_UNSATISFIED) {
         // MOJO_TRIGGER_CONDITION_SIGNALS_UNSATISFIED 这个功能废弃了
227   } else if (data_pipe) { 
        // data_pipe 类型处理
228     GetConditionsForDataPipeSignals(signals, &trigger->conditions);
229   } else {
        // portal 处理
230     GetConditionsForMessagePipeSignals(signals, &trigger->conditions);
231   }
232 
233   base::AutoLock lock(lock_);
      // 维护triggers_ 和 trigger的关系
234   auto [it, ok] = triggers_.try_emplace(trigger_context, trigger);
235   if (!ok) {
236     return MOJO_RESULT_ALREADY_EXISTS;
237   }
238 
239   next_trigger_ = triggers_.begin();
240 
241   // Install an ipcz trap to effectively monitor the lifetime of the watched
242   // object referenced by `handle`. Installation of the trap should always
243   // succeed, and its resulting trap event will always mark the end of this
244   // trigger's lifetime. This trap effectively owns a ref to the Trigger, as
245   // added here.
246   trigger->AddRef();
247   IpczTrapConditions removal_conditions = {
248       .size = sizeof(removal_conditions),
249       .flags = IPCZ_TRAP_REMOVED,
250   };
      // 向ipcz 注册Trap, TrapRemovalEventHandler是trap被移除的回调函数
251   IpczResult result = GetIpczAPI().Trap(
252       handle, &removal_conditions, &TrapRemovalEventHandler,
253       trigger->ipcz_context(), IPCZ_NO_FLAGS, nullptr, nullptr, nullptr);
254   CHECK_EQ(result, IPCZ_RESULT_OK);
255 
256   if (!armed_) {// 有其他trigger 没有安装上, 则返回MOJO_RESULT_OK,暂不安装trigger。
257     return MOJO_RESULT_OK;
258   }
259 
260   // The Mojo trap is already armed, so attempt to install an ipcz trap for
261   // the new trigger immediately.
262   MojoTrapEvent event;
      // 安装trigger
263   result = ArmTrigger(*trigger, event);
264   if (result == IPCZ_RESULT_OK) { // 没获取到,说明事件不属于该触发器,直接返回
265     return MOJO_RESULT_OK;
266   }
267 
268   // The new trigger already needs to fire an event. OK.
      // 该触发器有事件要处理,派发时间
269   armed_ = false;
270   DispatchOrQueueEvent(*trigger, event);
271   return MOJO_RESULT_OK;
272 }

Trap api分为两层。 上层是MojoTrap, 它可以管理多个ipcz trap, ipcz trap 在MojoTrap中叫做Trigger。
触发器支持DataPipe和Portal的事件触发。218行创建Trigger对象,251-253行向Ipcz层注册Trigger对象, 注意这里的IpczTrapConditions->flags 为IPCZ_TRAP_REMOVED, 这表示不关注portal其他事件,只关注ipcz trap被移除的事件 。 256-258行如果MojoTrap上有其他trigger 没有安装到ipcz trap, 则直接返回,等待一起安装。 262-266 其他Trigger 都成功安装则这里可以安装当前Trigger到ipcz trap,这里使用ArmTrigger 函数安装ipcz trap, 如果安装成功直接返回,安装失败则表示当前trigger 已经被触发,调用DispatchOrQueueEvent派发事件。

我们先来看一下事件的派发。

528 void MojoTrap::DispatchOrQueueEvent(Trigger& trigger,
529                                     const MojoTrapEvent& event) {
530   lock_.AssertAcquired();
531   if (dispatching_thread_ == base::PlatformThread::CurrentRef()) {
532     // This thread is already dispatching an event, so queue this one. It will
533     // be dispatched before the thread fully unwinds from its current dispatch.
        // 当前线程正在派发事件,添加到pending_mojo_events_中
534     pending_mojo_events_->emplace_back(base::WrapRefCounted(&trigger), event);
535     return;
536   }
537 
538   // Block as long as any other thread is dispatching.
      // 同时只能有一个线程派发事件,其他线程在派发,等待
539   while (dispatching_thread_.has_value()) {
540     base::ScopedAllowBaseSyncPrimitivesOutsideBlockingScope allow_wait;
541     waiters_++;
542     dispatching_condition_.Wait();
543     waiters_--;
544   }
545 
546   dispatching_thread_ = base::PlatformThread::CurrentRef();
      // 派发事件
547   DispatchEvent(event);
548 
549   // NOTE: This vector is only shrunk by the clear() below, but it may
550   // accumulate more events during each iteration. Hence we iterate by index.
551   for (size_t i = 0; i < pending_mojo_events_->size(); ++i) {
552     if (!pending_mojo_events_[i].trigger->removed ||
553         pending_mojo_events_[i].event.result == MOJO_RESULT_CANCELLED) {
          // 派发pending_mojo_events_
554       DispatchEvent(pending_mojo_events_[i].event);
555     }
556   }
557   pending_mojo_events_->clear();
558 
559   // We're done. Give other threads a chance.
560   dispatching_thread_.reset();
561   if (waiters_ > 0) {
        // 通知等待者
562     dispatching_condition_.Signal();
563   }
564 }

DispatchOrQueueEvent函数代码很简单,保证同时只有一个线程派发事件,通过调用DispatchEvent函数进行事件派发。

566 void MojoTrap::DispatchEvent(const MojoTrapEvent& event) {
567   lock_.AssertAcquired();
568   DCHECK(dispatching_thread_ == base::PlatformThread::CurrentRef());
569 
570   // Note that other threads may enter DispatchOrQueueEvent while this is
571   // unlocked; but they will be blocked from dispatching since we've set
572   // `dispatching_thread_` to our thread.
573   base::AutoUnlock unlock(lock_);
574   handler_(&event);
575 }

DispatchEvent 调用创建MojoTrap时候传入的回调函数。 参数为MojoTrapEvent。

再来看ipcz trap的注册。
third_party/ipcz/src/api.cc
我们继续分析向ipcz层注册Trap。

257 IpczResult Trap(IpczHandle portal_handle,
258                 const IpczTrapConditions* conditions,
259                 IpczTrapEventHandler handler,
260                 uintptr_t context,
261                 uint32_t flags,
262                 const void* options,
263                 IpczTrapConditionFlags* satisfied_condition_flags,
264                 IpczPortalStatus* status) {
265   ipcz::Portal* portal = ipcz::Portal::FromHandle(portal_handle);
266   if (!portal || !handler || !conditions ||
267       conditions->size < sizeof(*conditions)) {
268     return IPCZ_RESULT_INVALID_ARGUMENT;
269   }
270 
271   if (status && status->size < sizeof(*status)) {
272     return IPCZ_RESULT_INVALID_ARGUMENT;
273   }
274 
275   return portal->router()->Trap(*conditions, handler, context,
276                                 satisfied_condition_flags, status);
277 }

参数portal_handle 指向portal,conditions 表示关注portal的信号条件,比如trap被移除的回调。 trap 本身关心的事件在context的成员变量中。satisfied_condition_flags 是传出参数,表示给定的conditions下当前满足的条件。status用于查询端口的状态。
函数也比较简单,调用router->Trap 方法注册ipcz层的trap。

third_party/ipcz/src/ipcz/router.cc

IpczResult Router::Trap(const IpczTrapConditions& conditions,
                        IpczTrapEventHandler handler,
                        uint64_t context,
                        IpczTrapConditionFlags* satisfied_condition_flags,
                        IpczPortalStatus* status) {
  absl::MutexLock lock(&mutex_);
  return traps_.Add(conditions, handler, context, status_,
                    satisfied_condition_flags, status);
}

Router对象使用traps_维护多个Trap, 我们来看一下它的代码.

third_party/ipcz/src/ipcz/trap_set.cc

 63 IpczResult TrapSet::Add(const IpczTrapConditions& conditions,
 64                         IpczTrapEventHandler handler,
 65                         uintptr_t context,
 66                         const IpczPortalStatus& current_status,
 67                         IpczTrapConditionFlags* satisfied_condition_flags,
 68                         IpczPortalStatus* status) {
 69   last_known_status_ = current_status;
 70   IpczTrapConditionFlags flags =
 71       GetSatisfiedConditions(conditions, current_status);
 72   if (flags != 0) {
 73     if (satisfied_condition_flags) {
 74       *satisfied_condition_flags = flags;
 75     }
 76     if (status) {
 77       // Note that we copy the minimum number of bytes between the size of our
 78       // IpczPortalStatus and the size of the caller's, which may differ if
 79       // coming from another version of ipcz. The `size` field is updated to
 80       // reflect how many bytes are actually meaningful here.
 81       const uint32_t size = std::min(status->size, sizeof(current_status));
 82       memcpy(status, &current_status, size);
 83       status->size = size;
 84     }
 85     return IPCZ_RESULT_FAILED_PRECONDITION;
 86   }
 87 
 88   traps_.emplace_back(conditions, handler, context);
 89   return IPCZ_RESULT_OK;
 90 }

参数current_status 是出入参数,为Router对象的status_成员变量。 status为传出参数。conditions则表示关注的条件。

70-71行通过router的状态和关注的条件,返回满足的条件。
73-74行给传出参数satisfied_condition_flags 赋值,设置为conditions下当前满足的条件。
76-84行给传出参数status赋值。
注意72-85行,整体上如果当前已经有满足的条件,则不注册trap,返回IPCZ_RESULT_FAILED_PRECONDITION,也就是前置条件不满足。
最后如果没有满足的条件则trap注册成功。向traps_成员变量添加一个Trap对象。

在 MojoTrap::AddTrigger中给定的条件为IPCZ_TRAP_REMOVED, 这个条件在trap被移除时触发,所以这里一定会添加trap成功。 也就是MojoTrap::AddTrigger 只关注Ipcz Trap被移除事件,也就是它的生命周期。 对应的MojoTrap::TrapRemovalEventHandler(const IpczTrapEvent* event) 函数我们就不分析了,它的作用主要是用于清理资源。

我们再看ArmTrigger函数,真正注册Trigger 到ipcz trap。

453 IpczResult MojoTrap::ArmTrigger(Trigger& trigger, MojoTrapEvent& event) {
454   lock_.AssertAcquired();
      ......
469   DataPipe* const data_pipe = trigger.data_pipe.get();
      ......
474   if (!data_pipe && (trigger.signals & MOJO_HANDLE_SIGNAL_WRITABLE)) {
        // data_pipe 永远可写,直接返回IPCZ_RESULT_FAILED_PRECONDITION
475     // Message pipes are always writable, so a trap watching for writability can
476     // never be armed.
477     IpczPortalStatus status = {.size = sizeof(status)};
478     const IpczResult result = GetIpczAPI().QueryPortalStatus(
479         trigger.handle, IPCZ_NO_FLAGS, nullptr, &status);
480     if (result == IPCZ_RESULT_OK) {
481       PopulateEventForMessagePipe(trigger.signals, status, event);
482     }
483     return IPCZ_RESULT_FAILED_PRECONDITION;
484   }
485 
486   // Bump the ref count on the Trigger. This ref is effectively owned by the
487   // trap if it's installed successfully.
488   trigger.AddRef();
489   IpczTrapConditionFlags satisfied_flags;
490   IpczPortalStatus status = {.size = sizeof(status)};
      // 依然调用ipcz 层的Trap函数,不过这里使用的conditions为真正的trigger conditions
491   IpczResult result =
492       GetIpczAPI().Trap(trigger.handle, &trigger.conditions, &TrapEventHandler,
493                         trigger.ipcz_context(), IPCZ_NO_FLAGS, nullptr,
494                         &satisfied_flags, &status);
495   if (result == IPCZ_RESULT_OK) {
496     trigger.armed = true;
497     return MOJO_RESULT_OK;
498   }
499 
500   // Balances the AddRef above since no trap was installed.
501   trigger.Release();
502 
503   if (data_pipe) {
504     PopulateEventForDataPipe(*data_pipe, trigger.signals, event);
505   } else {
506     PopulateEventForMessagePipe(trigger.signals, status, event);
507   }
508   return result;
509 }

函数依然调用ipcz层的Trap函数去注册trigger, 不过这里的conditions已经换成了trigger的真正conditions。同样conditions如果满足则会返回IPCZ_RESULT_FAILED_PRECONDITION。函数503-507行就是处理这种情况, 我们只看PopulateEventForMessagePipe函数

void PopulateEventForMessagePipe(MojoHandleSignals trigger_signals,
                                 const IpczPortalStatus& current_status,
                                 MojoTrapEvent& event) {
  const MojoHandleSignals kRead = MOJO_HANDLE_SIGNAL_READABLE;
  const MojoHandleSignals kWrite = MOJO_HANDLE_SIGNAL_WRITABLE;
  const MojoHandleSignals kPeerClosed = MOJO_HANDLE_SIGNAL_PEER_CLOSED;

  MojoHandleSignals& satisfied = event.signals_state.satisfied_signals;
  MojoHandleSignals& satisfiable = event.signals_state.satisfiable_signals;

  satisfied = 0;
  satisfiable = kPeerClosed | MOJO_HANDLE_SIGNAL_QUOTA_EXCEEDED;
  if (!(current_status.flags & IPCZ_PORTAL_STATUS_DEAD)) {
    satisfiable |= kRead;
  }

  if (current_status.flags & IPCZ_PORTAL_STATUS_PEER_CLOSED) {
    satisfied |= kPeerClosed;
  } else {
    satisfiable |= MOJO_HANDLE_SIGNAL_PEER_REMOTE | kWrite;
    satisfied |= kWrite;
  }

  if (current_status.num_local_parcels > 0) {
    satisfied |= kRead;
  }

  DCHECK((satisfied & satisfiable) == satisfied);
  GetEventResultForSignalsState(event.signals_state, trigger_signals,
                                event.result);
}

函数主要根据当前状态装填MojoTrapEvent这个传输参数。 所以在MojoTrap::AddTrigger() 函数中可以直接派发event。

假如MojoTrap.armed_为false什么时候去注册Trap呢,一般这种情况需要主动调用MojoArmTrapIpcz函数。 MojoArmTrapIpcz函数的作用是注册MojoTrap的trigger 到ipcz trap。 如果有trigger 已经满足条件,则返回满足条件的MojoTrapEvent。 把能注册上的Trigger 进行注册。

MojoResult MojoArmTrapIpcz(MojoHandle trap_handle,
                           const MojoArmTrapOptions* options,
                           uint32_t* num_blocking_events,
                           MojoTrapEvent* blocking_events) {
  auto* trap = ipcz_driver::MojoTrap::FromBox(trap_handle);
  if (!trap) {
    return MOJO_RESULT_INVALID_ARGUMENT;
  }
  return trap->Arm(blocking_events, num_blocking_events);
}

函数参数trap_handle 为创建的MojoTrap的句柄, blocking_events 用户返回已经满足条件的Trigger 所触发的时间, num_blocking_events为blocking_events 的容量。

这个设计看起来很不统一。

mojo/core/ipcz_driver/mojo_trap.cc

289 MojoResult MojoTrap::Arm(MojoTrapEvent* blocking_events,
290                          uint32_t* num_blocking_events) {
     ......

302   base::AutoLock lock(lock_);
303   if (armed_) { // 已经有事件被触发,并且没被处理,则没有阻塞事件,返回MOJO_RESULT_OK
304     return MOJO_RESULT_OK;
305   }
306 
      // 没有注册的触发器,返回MOJO_RESULT_NOT_FOUND
307   if (triggers_.empty()) {
308     return MOJO_RESULT_NOT_FOUND;
309   }
310 
311   uint32_t num_events_returned = 0;
312   auto increment_wrapped = [this](TriggerMap::iterator it) {
313     lock_.AssertAcquired();
314     if (++it != triggers_.end()) {
315       return it;
316     }
317     return triggers_.begin();
318   };
319 
320   TriggerMap::iterator next_trigger = next_trigger_;
321   DCHECK(next_trigger != triggers_.end());
322 
323   // We iterate over all triggers, starting just beyond wherever we started last
324   // time we were armed. This guards against any single trigger being starved.
325   const TriggerMap::iterator end_trigger = next_trigger;
326   do {
327     auto& [trigger_context, trigger] = *next_trigger;
328     next_trigger = increment_wrapped(next_trigger);
329 
330     MojoTrapEvent event;
        // 安装trigger
331     const IpczResult result = ArmTrigger(*trigger, event);
332     if (result == IPCZ_RESULT_OK) { // 已经触发,下一个
333       // Trap successfully installed, nothing else to do for this trigger.
334       continue;
335     }
336 
337     if (result != IPCZ_RESULT_FAILED_PRECONDITION) {
338       NOTREACHED();
339       return result;
340     }
341 
342     // The ipcz trap failed to install, so this trigger's conditions are already
343     // met. Accumulate would-be event details if there's output space.
344     if (event_capacity == 0) {
345       return MOJO_RESULT_FAILED_PRECONDITION;
346     }
347 
348     blocking_events[num_events_returned++] = event;
349   } while (next_trigger != end_trigger &&
350            (num_events_returned == 0 || num_events_returned < event_capacity));
351 
352   if (next_trigger != end_trigger) {
353     next_trigger_ = next_trigger;
354   } else {
355     next_trigger_ = increment_wrapped(next_trigger);
356   }
357 
358   if (num_events_returned > 0) {
359     *num_blocking_events = num_events_returned;
360     return MOJO_RESULT_FAILED_PRECONDITION;
361   }
362 
363   // The whole Mojo trap is collectively armed if and only if all of the
364   // triggers managed to install an ipcz trap.
      // 全部都安装上了
365   armed_ = true;
366   return MOJO_RESULT_OK;
367 }

函数主要调用ArmTrigger去安装ipcz trap, 如果安装失败则代表该Trigger 已经触发,或者其他前置条件不满足,原因会通过MojoTrapEvent传出。 如果全部Trigger 都注册成功则设置armed_=true。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值