Linux内核设计与实现——定时器和时间管理

Linux系统时间管理

1. 内核中的时间概念

  1. 硬件为内核提供了一个系统定时器用以计算时间,该时钟在内核中可看成是一个电子时间资源,比如数字时钟或处理器频率等。系统定时器以某种频率自行触发(常被称为击中(hitting)或射中(popping))时钟中断,该频率可以通过编程预定,称作节拍率。当时钟中断发生时,内核就通过一种特殊的中断处理程序对其进行处理。

  2. 连续两次时钟中断的间隔时间称为节拍,它等于节拍率分之一秒。内核靠这种已知的时钟中断间隔来计算墙上时间(实际时间)和系统运行时间。内核为用户空间提供了一组系统调用以获取实际日期和实际时间。

  3. 下面是一些利用时间中断周期执行的工作:

    a. 更新系统运行时间。

    b. 更新实际时间。

    c. 在smp系统上,均衡调度程序中各处理器上的运行队列。

    d. 检查当前进程是否用尽了自己的时间片。如果用尽,就重新进行调度。

    e. 运行超时的动态定时器。

    f. 更新资源消耗和处理器时间的统计值。

2. 节拍率:HZ

(1) 节拍率

  1. 系统定时器频率(节拍率)是通过静态预处理定义的,也就是HZ(赫兹),在系统启动时按照HZ值对硬件进行设置。体系结构不同,HZ的值也不同,对于某些体系结构来说,甚至是机器不同,它的值都会不一样。

  2. 内核在<asm/param.h>文件中定义了这个值。节拍率有一个HZ频率,一个周期为1/HZ秒。例如,x86体系结构中,系统定时器频率默认值为100。因此,时钟中断的频率就为100HZ,也就是说在i386处理上的每秒钟时钟中断100次(百分之一秒,即每10ms产生一次)。其他体系结构的节拍率为250和1000,分别对应4ms和1ms。

(2) 无节拍

大多数体系结构的节拍率都是可调的。Linux内核支持“无节拍操作”这样的选项。当编译内核时设置了CONFIG_HZ配置选项,系统就根据这个选项动态调度时钟中断。不是每隔固定的时间间隔触发时钟中断,而是按需动态调度和重新设置。如果下一个时钟频率设置为3ms,就每3ms触发一次时钟中断。之后,如果50ms内都无事可做,内核以50ms重新调度时钟中断。对于无节拍的系统而言,空闲档期不会被不必要的时钟中断打断,减少了系统的能耗。

3. 节拍总数jiffies

  1. 全局变量jiffies用来记录自系统启动以来产生的节拍的总数。启动时,内核将该变量初始化为0,此后,每次时钟中断处理程序就会增加该变量的值。因为一秒内时钟中断的次数等于HZ,所以jiffies一秒内增加的值也就为HZ。系统运行时间以秒为单位计算,就等于jiffies/HZ。实际出现的情况可能稍微复杂些:内核给jiffies赋一个特殊的初值,引起这个变量不断地溢出,由此捕捉bug。当找到实际的jiffies值后,就首先把这个“偏差”减去。jiffies定义于<linux/jiffies.h>中:
extern unsigned long volatile jiffies;
  1. 内核中将秒转换为jiffies用得多一些,比如代码经常需要设置一些将来的时间:
unsigned long time_stamp = jiffies;				/*现在*/
unsigned long next_tick = jiffies + 1;			/*从现在开始1个节拍*/
unsigned long later = jiffies + 5 * HZ;			/*从现在开始5秒*/
unsigned long fraction = jiffies + HZ / 10;		/*从现在开始1/10秒*/

(1) jiffies的内部表示

  1. jiffies变量总是unsigned long,在32位体系结构上是32位,在64位体系结构上是64位。jiffies_64变量定义在<linux/jiffies.h>中:
extern u64 jiffies_64;

​ ld(1)脚本用于连接主内核映像(在x86上位于arch/x86/kernel/vmlinux.lds.S),然后用jiffies_64变量的初值覆盖jiffies变量:

jiffies = jiffies_64;

jiffiesjiffies_64变量的低32位,大多数代码只关心低32位。时间管理代码使用整个64位,以此避免整个64位的溢出。

  1. a. 访问jiffies的代码仅会读取jiffies_64的低32位。通过get_jiffies_64()函数就可以读取整个64位数值。因为32位体系结 构不能原子地一次访间64位变量中的两个32位数值。在读取jiffies时,该函数利用xtime_lock锁对jiffies变量进行锁定。

    b. 在64位体系结构上,jiffies_64jiffies指的是同一个变量,代码既可以直接读取jiffies也可以调用get_jiffies_64() 函数,它们的作用相同。

(2) jiffies的回绕

  1. 当jiffies变量的值超过它的最大存放范围后就会发生溢出,它的值会回绕到0。下面代码希望设置一个准确的超时时间:
unsigned long timeout = jiffies + HZ/2;/* 0.5秒后超时 */
/* 执行一些任务 ... */

/* 然后查看是否花的时间过长 */
if(timeout>jiffies){
		/* 没有超时,很好 ... */
}else {
		/* 超时了,发生错误...*/
}

​ 如果在设置完timeout变量后,jiffies重新回绕为0,此时,if判断语句的结果刚好相反。

  1. 内核提供了四个宏正确地处理节拍计数回绕情况,定义在<linux/jiffies.h>中,这里列出的宏是简化版:
#define time_after(unknowm, knorm) ((long)(known) - (long)(unknown) <0)
#define time_before(unknown, kmom) ((long)(unknown) - (long)(known)<0)
#define time_after_eq(unknowm, knowm) ((long)(unknown) - (long)(known)>=0)
#define time_before_eq(unknown, knowm) ((long)(known) - (long)(unknown)>=0)

​ 其中unknown参数通常是jiffiesknown参数是需要对比的值。前面的例子可以改造成时钟-回绕-安全的版本,形式如下:

unsigned long timeout = jiffies + HZ/2;/* 0.5秒后超时 */
/* 执行一些任务 ... */

/* 然后查看是否花的时间过长 */
if(time_before(jiffies, timeout)){
		/* 没有超时,很好 ... */
}else {
		/* 超时了,发生错误...*/
}

(3) 用户空间和HZ

  1. 内核是以节拍数/秒的形式给用户空间导出HZ值的。如果在内核中更改了HZ的定义值,就打破了用户空间的常量关系——用户空间不知道新的HZ值。用户空间认为的系统运行时间跟实际的系统运行时间不一样。

  2. 内核定义了USER_HZ来代表用户空间看到的HZ值。在x86体系结构上,USER_HZ值定义为100。内核可以使用函数jiffies_to_clock_t()(定义于kernel/time.c中)将一个由HZ表示的节拍计数转换成一个由USER_HZ表示的节拍计数。所采用的表达式取决于USER_HZ和HZ是否互为整数倍,且USER_HZ是否小于等于HZ。如果这两个条件都满足,则表达式相当简单:

return x / (HZ / USBR_HZ);

​ 内核使用函数jiffies_64_to_clock_t()将64位的jiffies值的单位从HZ转换为USER_HZ。

  1. 在需要把以节拍数/秒为单位的值导出到用户空间时,需要使用上面的函数。比如:
unsigned long start;
unsigned long total_time;

start = jiffies;
/*执行一些任务...*/
total_time = jiffies - start;
printk("That took %lu ticks\n", jiffies_to_clock_t(total_time));

​ 如果以秒为单位而不是以节拍为单位,输出信息会执行得好一些。 比如:

printk("That took %lu seconds\n", total_time/HZ);

4. 硬时钟和定时器

体系结构提供了两种设备进行计时——一种是系统定时器;另一种是实时时钟。

(1) 实时时钟

  1. 实时时钟(RTC)是用来持久存放系统时间的设备,即便系统关闭,它也可以靠主板上的微型电池提供的电力保持系统的计时。在PC体系结构中,RTC和CMOS集成在一起,且RTC的运行和BIOS的保存设置通过同一个电池供电。

  2. 当系统启动时,内核通过读取RTC来初始化墙上时间,该时间存放在xtime变量中。有些体系结构(如x86)会周期性地将当前时间值存回RTC中。

(2) 系统定时器

  1. 系统定时器的根本思想是提供一种周期性触发中断机制。有些体系结构通过对电子晶振进行分频来实现系统定时器;有些体系结构则提供一个衰减测量器——衰减测量器设置一个初始值,该值以固定频率递减,当减到零时,触发一个中断。两种情况的效果都一样。

  2. 在x86体系结构中,主要采用可编程中断时钟(PIT)。PIT在PC机器中普遍存在,从DOS时代就开始以它作为时钟中断源了。内核在启动时对PIT进行编程初始化,使其能够以HZ/秒的频率产生时钟中断(中断O)。x86体系结构中的其他的时钟资源还包括本地APIC时钟和时间戳计数(TSC)等。

5. 时钟中断处理程序

  1. 时钟中断处理程序可以划分体系结构相关部分和体系结构无关部分。与体系结构相关的例程作为系统定时器的中断处理程序而注册到内核中。绝大多数处理程序都要执行如下工作:

    a. 获得xtime_lock锁,以便对访问jiffies_64和墙上时间xtime进行保护。

    b. 需要时应答或重新设置系统时钟。

    c. 周期性地使用墙上时间更新实时时钟。

    d. 调用体系结构无关的时钟例程:tick_periodic()

  2. 中断服务程序主要通过调用与体系结构无关的例程,tick_periodic()执行下面的工作:

    a. 给jiffies_64变量增加1。

    b. 更新资源消耗的统计值,比如当前进程所消耗的系统时间和用户时间。

    c. 执行已经到期的动态定时器。

    d. 执行sheduler_tick()函数。

    e. 更新墙上时间,该时间存放在xtime变量中。

    f. 计算平均负载值。

    (1) tick_periodic()例程的代码看起来非常简单:

static void tick_periodic(int cpu)
{
        if (tick_do_timer_cpu == cpu) {
            	write_seqlock(&xtime_lock);

                /* 记录下一个节拍事件 */
                tick_next_period = ktime_add(tick_next_period, tick_period);

                do_timer(1);
                write_sequnlock(&xtime_lock);
        }
        update_process_times(user_mode(get_irg_regs()));
        profile_tick(CPU_PROFILING);
}

​ 很多重要的操作都在do_fimer()update_process_times()函数中进行。前者承担着对jiffies_64的实际增加操作:

void do_timer(unsigned long ticks)
{
        jiffies_64 += ticks;
        update_wall_time();
        calc_global_load();
}

​ 函数update_wall_time()根据所流逝的时间更新墙上的时钟,而calc_global_load()更新系统的平均负载统计值。

​ (2) 当do_timer()最终返回时,调用update_process_times()更新所耗费的各种节拍数。通过user_tick区别是花费在用户空间还 是内核空间。

void update_process_times(int user_tick)
{
        struct task_struct *p = current;
        int cpu = smp_processor_id();
        /* 注意:也必须对这个时钟irg的上下文说明一下原因 */
        account_process_tick(p, user_tick);
        run_local_timers();
        rcu_check_callbacks(cpu, user_tick);
        printk_tick();
        scheduler_tick();
        run_posix_cpu_timers(p);
}

user_tick的值是通过查看系统寄存器来设置的:

update_process_times(user_mode(get_irq_regs()));

account_process_tick()函数对进程的时间进行实质性更新:

void account_process_tick(struct task_struct *p, int user_tick)
{
        cputime_t one_jiffy_scaled = cputime_to_scaled(cputime_one_jiffy);
    	struct rg *rq = this_rq();
    
        if (user_tick)
        		account_user_time(p, cputime_one_jiffy, one_jiffy_scaled);
        else if ((p != rq->idle) || (irq_count() != HARDIRQ_OFFSET))
        		account_system_time(p, HARDIRQ_OFFSET, cputime_one_jiffy,
        							one_jiffy_scaled);
                                    
        else
        		account_idle_time(cputime_one_jiffy);
}

​ 这样做意味着内核对进程进行时间计数时是根据中断发生时处理器所处的模式进行分类统计的,它把上一个节拍全部算给了进程。进 程在上一个节拍期间可能多次进入和退出内核模式,而且在上一个节拍期间,该进程也不一定是唯一一个运行进程。

​ (3) 接下来,run_lock_timers()函数标记了一个软中断去处理所有到期的定时器。

​ (4) 最后,scheduler_tick()函数负责减少当前运行进程的时间片计数值并且在需要时设置need_resched标志。在SMP机器中,该 函数还要负责平衡每个处理器上的运行队列。

tick_periodic()函数执行完毕后返回与体系结构相关的中断处理程序,继续执行后面的工作,释放xtime_lock锁,然后退出。以 上全部工作每1/HZ秒都要发生一次。

6. 实际时间

  1. 当前实际时间(墙上时间)定义在kernel/time/timekeeping.c中:
struct timespec xtime;

timespec数据结构定义在<linux/time.h>中,形式如下:

struct timespec{
        _kernel_time_t tv_sec; 		/* 秒 */
        long tv_nsec; 				/* ns */
};

xtime.tv_sec以秒为单位,存放着自1970年1月1日(UTC)以来经过的时间。xtime.tv_nsec记录自上一秒开始经过的ns数。

  1. 读写xtime变量需要使用xtime_lock锁,是一个seqlock锁。

    a. 更新xtime首先要申请一个seqlock锁:

write_seqlock(&xtime_lock);
/* 更新xtime... */
write_sequnlock(&xtime_lock);

​ b. 读取xtime时也要使用read_seqbegin()read_seqretry()函数:

unsigned long seq;

do {
        unsigned long lost;
        seq = read_seqbegin(&xtime_lock);
    
        usec = timer->get_offset();
        lost = jiffies - wall_jiffies;
        if (lost)
        		usec += lost * (1000000 / HZ);
        sec = xtime.tv_sec;
        usec += (xtime.tv_nsec / 1000);
} while (read_seqretry(&xtime_lock, seq));

​ 该循环不断重复,直到读者确认读取数据时没有写操作介入。如果循环期间有时钟中断处理程序更新xtimeread_seqretry() 就返回无效序列号,继续循环等待。

  1. 从用户空间取得墙上时间的主要接口是gettimeofday(),在内核中对应系统调用为sys_gettimeofday(),定义于kernel/time.c:
asmlinkage long sys_gettimeofday(struct timeval *tv, struct timezone *tz)
{
        if (likely(tv)) {
                struct timeval ktv;
                do_gettimeofday(&ktv);
            	if (copy_to_user(tv, &ktv, sizeof(ktv)))
        				return -EFAULT;
        }
        if (unlikely(tz)) {
                if (copy_to_user(tz, &sys_tz, sizeof(sys_tz)))
                		return -EFAULT;
        }
        return 0;
}

​ 如果tv参数非空,do_gettimeofday()执行上面的循环读取xtime的操作。如果tz为空,该函数将把系统时区(存放在sys_tz中) 返回用户。

  1. a. 虽然内核也实现了time()系统调用,但是gettimeofday()几乎取代了它。另外,C库也提供了一些墙上时间相关的库调用,比如ftime()ctime()

    b. 系统调用settimeofday()来设置当前时间,它需要具有CAP_SYS_TIME权能。

    c. 除了更新xtime时间外,内核不会像用户空间程序那样频繁使用xtime。但在文件系统的实现代码中存放访向时间戳(创建、存 取、修改等)时需要使用xtime

7. 定时器

定时器,有时也称为动态定时器或内核定时器。只需要执行一些初始化工作,设置一个超时时间,指定超时发生后执行的函数,然后激活定时器,指定的函数将在定时器到期时自动执行。定时器不周期运行,它在超时后就自行撒销。

(1) 使用定时器

  1. 定时器由结构timer_list表示,定义在<linux/timer.h>中。
struct timer_list {
        struct list_head entry;			  /* 定时器链表的入口 */
        unsigned long expires; 			  /* 以jiffies为单位的定时值 */
        void (* function)(unsigned long); /* 定时器处理函数 */
        unsigned long data; 			  /* 传给处理函数的长整型参数 */
        struct tvec_t_base_s *base; 	  /* 定时器内部值,用户不要使用 */
};
  1. 内核提供了一组与定时器相关的接口声明在<linux/timer.h> 中,大多数接口在kernel/timer.c中获得实现。

    a. 创建定时器时需要先定义它:

struct timer_list my_timer;

​ b. 接着需要初始化定时器数据结构的内部值,初始化必须在使用其他定时器管理函数对定时器进行操作前完成。

init_timer(&my_timer);

​ c. 现在可以填充结构中需要的值了:

my_timer.expires = jiffies + delay;		  /* 定时器超时时的节拍数 */
my_timer.data = 0;						  /* 给定时器处理函数传入0值 */
my_timer.function = my_function; 		  /* 定时器超时时调用的函数 */

​ 处理函数必须符合下面的函数原型:

void my_timer_function(unsigned long data);

​ 可以利用同一个处理函数注册多个定时器,只需通过data就能区别对待它们。如果不需要这个参数,可以简单地传递0(或任何其 他值)给处理函数。

​ d. 最后,必须激活定时器:

add_timer(&my_timer);

​ e. 这样定时器就可以工作了。内核不会在超时时间到期前运行定时器处理函數,但是有可能延误定时器的执行。一般定时器都在超时 后马上就会执行,但也有可能推迟到下一次时钟节拍时才能运行,所以不能用定时器实现硬实时任务。

  1. 函数mod_timer()可以改变指定的定时器超时时间:
mod_timer(&my_timer,jiffies+new_delay);		/* 新的定时值 */

mod_timer()函数也可操作那些已经初始化,但还没有被激活的定时器,如果定时器未被激活,mod_timer()会激活它。如果调用 时定时器未被激活,该函数返回0;否则返回1。

  1. a. 如果需要在定时器超时前停止定时器,可以使用del_timer()函数:
del_timer(&my_timer);

​ 如果定时器还未被激活,该函数返回0;否则返回1。

​ b. del_timer()只可以保证定时器不会再被激活,但是在多处理器机器上定时器中断可能已经在其他处理器上运行了,所以删除定 时器时需要等待可能在其他处理器上运行的定时器处理程序都退出,这时就要使用del_timer_sync()函数执行删除工作:

del_timer_sync(&my_timer);

​ 和del_timer()函数不同,del_timer_sync()函数不能在中断上下文中使用。

  1. 定时器与当前执行代码是异步的,因此有可能存在潜在的竞争条件。所以,不能用如下所示的代码替代mod_timer()函数来改变定时器的超时时间,这样的代码在多处理器机器上是不安全的:
del_timer(my_timer)
my_timer->expires = jiffies + new_delay;
add_timer(my_timer);

(2) 实现定时器

  1. 内核在时钟中断发生后执行定时器,定时器作为软中断在下半部上下文中执行:时钟中断处理程序会执行update_process_times()函数,该函数随即调用run_local_timers()函数:
void run_local_timers(void)
{
        hrtimer_run_queues();
        raise_softirq(TIMER_SOFTIRQ); /* 执行定时器软中断 */
        softlockup_tick();
}
  1. run_timer_softirq()函数处理软中断TIMER_SOFTIRQ,从而在当前处理器上运行所有的超时定时器。

  2. 内核将定时器按它们的超时时间划分为五组。当定时器超时时间接近时,定时器将随组一起下移。采用分组定时器的方法可以在执行软中断的多数情况下,确保内核尽可能减少搜索超时定时器所带来的负担。

8. 延迟执行

内核提供了许多延迟方法处理各种延迟要求。有些是在延迟任务时挂起处理器,防止处理器执行任何实际工作;另一些不会挂起处理器,不能确保被延迟的代码能够在指定的延迟时间运行。

(1) 忙等待

  1. 最简单的延迟方法(通常也最不理想)是忙等待(忙循环)。该方法仅在想要延迟的时间是节拍的整数倍,或者精确率要求不高时才使用。

  2. 更好的方法应该是在代码等待时,允许内核重新调度执行其他任务:

unsigned long delay = jiffies +5*HZ;
while(time_before(jiffies, delay))
		cond_resched();

cond_resched()函数将调度一个新程序投入运行, 但它只有在设置完need_resched标志后才能生效。因为该方法需要调用调度程 序,所以不能在中断上下文中使用一一只能在进程上下文中使用。另外,延迟执行不应该在持有锁时或禁止中断时发生。

(2) 短延迟

  1. 有时内核代码(通常也是驱动程序)不但需要很短暂的延迟(比时钟节拍短),而且还要求延迟的时间很精确,多发生在和硬件同步时。内核提供了可以处理ms、ns和ms级别的延迟函数,定义在<linux/delay.h>和<asm/delay.h> 中,它们并不使用jiffies:
void udelay(unsigned long usecs)
void ndelay(unsigned long nsecs)
void mdelay(unsigned long msecs)

udelay()依靠执行数次循环达到延迟效果,而mdelay()通过udelay()实现。

  1. BogoMIPS

    BogoMIPS值记录处理器在给定时间内忙循环执行的次数(在空闲时速度有多快),主要被udelay()函数和mdelay()函数使用。它的名字取自bogus(伪的)和MIPS(每秒处理百万条指令)。该值存放在变量loops_per_jiffy中,可以从/proc/cpuinfo中读到它。延迟循环函数使用loops_per_jiffy值来计算为提供精确延迟而需要进行多少次循环。内核在启动时利用calibrate_delay()计算loops_per_jiffy值,该函数在init/main.c中。

(3) schedule_timeout()

更理想的延迟执行方法是使用schedule_timeout()函数,该方法会让需要延迟执行的任务睡眠到指定的延迟时间耗尽后再重新运行。该方法不能保证睡眠时间正好等于指定的延迟时间,只能尽量使睡眠时间接近指定的延迟时间。当指定的时间到期后,内核唤醒被延迟的任务并将其重新放回运行队列:

/* 将任务设置为可中断睡眠状态 */
set_current_state(TASK_INTERRUPTIBLE);

/* "s"秒后唤醒 */
schedule_timeout(s*HZ);

唯一的参数是延迟的相对时间,单位为jiffies。如果睡眠任务不想接收信号,可以将任务状态设置为TASK_UNINTERRUPTIBLE,然后睡眠。在调用sechedule_timeout()函数前必须首先将任务设置成上面两种状态之一,否则任务不会睡眠。由于schedule_timeout()函数需要调用调度程序,所以调用它的代码必须保证能够睡眠。调用代码必须处于进程上下文中,并且不能持有锁。

  1. a. schedule_timeout()的实现
signed long schedule_timeout(signed long timeout)
{
        timer_t timer;
        unsigned long expire;
    
        switch (timeout)
        {
        case MAX_SCHEDULE_TIMEOUT:
                schedule();
                goto out;
        default:
                if (timeout < 0)
                {
                        printk(KERN_ERR, "schedule_timeout: wrong timeout "
                        	"value %lx from %p\n", timeout,
                        	__builtin_return_address(0));
                        current->state = TASK_RUNING;
                        goto out;
        }
                
        expire = timeout + jiffies;
              
        init_timer(&timer);
        timer.expires = expire;
        timer.data = (unsigned long) current;
        timer.function = process_timeout;
                
        add_timer(&timer);
        schedule();
        del_timer_sync(&timer);
           
                
        timeout = expire - jiffies;

                
out:
        return timeout < 0 ? 0 : timeout;
}

​ 该函数用原始的名字timer创建了一个定时器timer;然后设置它的超时时间timeout;设置超时执行函数process_timeout(); 接着激活定时器而且调用schedule()

​ b. 当定时器超时时,process_timeout()函数会被调用:

void process_timeout(unsigned long data)
{
		wake_up_process((task_t *)data);
}

​ 该函数将任务设置为TASK_RUNNING状态,然后将其放入运行队列。

​ c. 当任务重新被调度时,将返回代码进入睡眠前的位置继续执行(正好在调用schedule()后)。如果任务提前被唤醒,那么定时器被 撤销,process_timeout()函数返回剩余的时间。

​ d. 在switch()括号中的代码是为处理特殊情况而写的,正常情况不会用到。MAX_SCHEDULE_TIMEOUT用来检查任务是否无限期地睡 眠,如果是,函数不会为它设置定时器,调度程序会立刻被调用。

  1. 设置超时时间,在等待队列上睡眠

    a. 进程上下文中的代码为了等待特定事件发生,可以将自己放入等待队列,然后调用调度程序去执行新任务。一旦事件发生后,内核 调用wake_up()函数唤醒在睡眠队列上的任务,使其重新投入运行。

    b. 有时,等待队列上的某个任务可能既在等待一个特定事件到来,又在等待一个特定时间到期,这种情况下可以使用schedule_timeout()函数代替schedule()函数。这样,当指定时间到期时,任务就被唤醒。代码检查被唤醒的原因(可能是被事件唤醒,可能是延迟的时间到期,可能是接收到了信号),然后执行相应的操作。

注:本文摘自《Linux内核设计与实现(第三版)》
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值