有中断下半部tasklet处理的按键驱动

Linux设备驱动之中断下半部tasklet

上半部与下半部的区别

① 处理及时性差异:上半部指的是中断处理程序,下半部则指的是一些虽然与中断有相关性但是可以延后执行的任务。比如:在网络传输中,网卡接收到数据包这个事件不一定需要马上被处理,适合用下半部去实现;但是用户敲击键盘这样的事件就必须马上被响应,应该用中断实现。

② 可嵌套性差异:上半部(中断)不能被相同类型的中断打断,而下半部依然可以被中断打断;上半部(中断)对于时间非常敏感,而下半部基本上都是一些可以延迟的工作。

基于上半部与下半部的区别,一个任务是放在上半部还是放在下半部去执行,可以参考下面4条:

如果一个任务对时间非常敏感,将其放在上半部(中断处理程序)中执行。

如果一个任务和硬件相关,将其放在上半部(中断处理程序)中执行。

如果一个任务要保证不被其他中断(特别是相同的中断)打断,将其放在上半部(中断处理程序)中执行。

其他任务,考虑放在下半部去执行。

有写内核任务需要延后执行,因此才有的下半部,进而实现了三种实现下半部的方法。即:软中断、tasklet和工作队列。在Linux2.6内核中存在三者之间差别与联系如下:

软中断    tasklet    工作队列
1. 软中断是在编译期间静态分配的。
2. 软中断不会抢占另外一个软中断,唯一可以抢占软中断的是中断处理程序。
3. 可以并发运行在多个CPU上(即使同一类型的也可以)。所以软中断必须设计为可重入的函数(允许多个CPU同时操作),因此也需要使用自旋锁来保护其数据结构。
4. 执行时间有:从硬件中断代码返回时、在ksoftirqd内核线程中和某些显示检查并执行软中断的代码中。    1. tasklet是使用两类软中断实现的:HI_SOFTIRQ和TASKLET_SOFTIRQ。
2. 可以动态增加减少,没有数量限制。
3. 同一类tasklet不能并发执行。
4. 不同类型可以并发执行。    1. 由内核线程去执行,换句话说总在进程上下文执行。
2. 可以睡眠,阻塞。
① 软中断

​ 软中断作为下半部机制的代表,是随着SMP(share memory processor)的出现应运而生的,它也是tasklet实现的基础(tasklet实际上只是在软中断的基础上添加了一定的机制)。软中断一般是“可延迟函数”的总称,有时候也包括了tasklet(请读者在遇到的时候根据上下文推断是否包含tasklet)。它的出现就是因为要满足上面所提出的上半部和下半部的区别,使得对时间不敏感的任务延后执行,而且可以在多个CPU上并行执行,使得总的系统效率可以更高。它的特性包括:

产生后并不是马上可以执行,必须要等待内核的调度才能执行。软中断不能被自己打断(即单个cpu上软中断不能嵌套执行),只能被硬件中断打断(上半部)。
可以并发运行在多个CPU上(即使同一类型的也可以)。所以软中断必须设计为可重入的函数(允许多个CPU同时操作),因此也需要使用自旋锁来保其数据结构。
② tasklet

​ 由于软中断必须使用可重入函数,这就导致设计上的复杂度变高,作为设备驱动程序的开发者来说,增加了负担。而如果某种应用并不需要在多个CPU上并行执行,那么软中断其实是没有必要的。因此诞生了弥补以上两个要求的tasklet。它具有以下特性:

一种特定类型的tasklet只能运行在一个CPU上,不能并行,只能串行执行。
多个不同类型的tasklet可以并行在多个CPU上。
软中断是静态分配的,在内核编译好之后,就不能改变。但tasklet就灵活许多,可以在运行时改变(比如添加模块时)。
​ tasklet是在两种软中断类型的基础上实现的,因此如果不需要软中断的并行特性,tasklet就是最好的选择。也就是说tasklet是软中断的一种特殊用法,即延迟情况下的串行执行。

③ 工作队列

​ 从上面的介绍看以看出,软中断运行在中断上下文中,因此不能阻塞和睡眠,而tasklet使用软中断实现,当然也不能阻塞和睡眠。但如果某延迟处理函数需要睡眠或者阻塞呢?没关系工作队列就可以如您所愿了。
​ 把推后执行的任务叫做工作(work),描述它的数据结构为work_struct ,这些工作以队列结构组织成工作队列(workqueue),其数据结构为workqueue_struct ,而工作线程就是负责执行工作队列中的工作。系统默认的工作者线程为events。
​ 工作队列(work queue)是另外一种将工作推后执行的形式。工作队列可以把工作推后,交由一个内核线程去执行—这个下半部分总是会在进程上下文执行,但由于是内核线程,其不能访问用户空间。最重要特点的就是工作队列允许重新调度甚至是睡眠。
​ 通常,在工作队列和软中断/tasklet中作出选择非常容易。可使用以下规则:

如果推后执行的任务需要睡眠,那么只能选择工作队列。
如果推后执行的任务需要延时指定的时间再触发,那么使用工作队列,因为其可以利用timer延时(内核定时器实现)。
如果推后执行的任务需要在一个tick之内处理,则使用软中断或tasklet,因为其可以抢占普通进程和内核线程,同时不可睡眠。
如果推后执行的任务对延迟的时间没有任何要求,则使用工作队列,此时通常为无关紧要的任务。
实际上,工作队列的本质就是将工作交给内核线程处理,因此其可以用内核线程替换。但是内核线程的创建和销毁对编程者的要求较高,而工作队列实现了内核线程的封装,不易出错,所以我们也推荐使用工作队列。
软中断、tasklet和工作队列相关数据结构

**① 软中断 **

软中断描述符
struct softirq_action { void (*action)(struct softirq_action *);};
描述每一种类型的软中断,其中void(*action)是软中断触发时的执行函数。

软中断全局数据和类型

static struct softirq_action softirq_vec[NR_SOFTIRQS] __cacheline_aligned_in_smp;  
enum  
{  
    HI_SOFTIRQ=0,         /*用于高优先级的tasklet*/  
    TIMER_SOFTIRQ,         /*用于定时器的下半部*/  
    NET_TX_SOFTIRQ,     /*用于网络层发包*/  
    NET_RX_SOFTIRQ,     /*用于网络层收报*/  
    BLOCK_SOFTIRQ,  
    BLOCK_IOPOLL_SOFTIRQ,  
    TASKLET_SOFTIRQ,     /*用于低优先级的tasklet*/  
    SCHED_SOFTIRQ,  
    HRTIMER_SOFTIRQ,  
    RCU_SOFTIRQ,         /* Preferable RCU should always be the last softirq */  
    NR_SOFTIRQS  
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
注册软中断

void open_softirq(int nr, void (*action)(struct softirq_action *))

​ 即注册对应类型的处理函数到全局数组softirq_vec中。例如网络发包对应类型为NET_TX_SOFTIRQ的处理函数net_tx_action.

触发软中断

void raise_softirq(unsigned int nr)

​ 实际上即以软中断类型nr作为偏移量置位irq_stat[cpu_id]的成员变量__softirq_pending,这也是同一类型软中断可以在多个cpu上并行运行的根本原因。

#define NR_IPI    8

typedef struct {
    unsigned int __softirq_pending;
#ifdef CONFIG_SMP
    unsigned int ipi_irqs[NR_IPI];
#endif
} ____cacheline_aligned irq_cpustat_t;

#ifndef __ARCH_IRQ_STAT
irq_cpustat_t irq_stat[NR_CPUS] ____cacheline_aligned;
EXPORT_SYMBOL(irq_stat);
#endif

#ifndef __ARCH_IRQ_STAT
extern irq_cpustat_t irq_stat[];        /* defined in asm/hardirq.h */
#define __IRQ_STAT(cpu, member)    (irq_stat[cpu].member)
#endif

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
软中断执行函数

do_softirq-->__do_softirq

执行软中断处理函数__do_softirq前首先要满足两个条件:
(1) 不在中断中(硬中断、软中断和NMI) 。
(2) 有软中断处于pending状态。
系统这么设计是为了避免软件中断在中断嵌套中被调用,并且达到在单个CPU上软件中断不能被重入的目的。对于ARM架构的CPU不存在中断嵌套中调用软件中断的问题,因为ARM架构的CPU在处理硬件中断的过程中是关闭掉中断的。只有在进入了软中断处理过程中之后才会开启硬件中断,如果在软件中断处理过程中有硬件中断嵌套,也不会再次调用软中断,because硬件中断是软件中断处理过程中再次进入的,此时preempt_count已经记录了软件中断!对于其它架构的CPU,有可能在触发调用软件中断前,也就是还在处理硬件中断的时候,就已经开启了硬件中断,可能会发生中断嵌套,在中断嵌套中是不允许调用软件中断处理的。Why?我的理解是,在发生中断嵌套的时候,表明这个时候是系统突发繁忙的时候,内核第一要务就是赶紧把中断中的事情处理完成,退出中断嵌套。避免多次嵌套,哪里有时间处理软件中断,所以把软件中断推迟到了所有中断处理完成的时候才能触发软件中断。

软中断的调度时机

(1) do_irq完成I/O中断时调用irq_exit。
(2) 系统使用I/O APIC,在处理完本地时钟中断时。
(3) local_bh_enable,即开启本地软中断时。
(4) SMP系统中,cpu处理完被CALL_FUNCTION_VECTOR处理器间中断所触发的函数时。
(5) ksoftirqd/n线程被唤醒时。
下面以从中断处理返回函数irq_exit中调用软中断为例详细说明。触发和初始化的的流程如图所示:


软中断处理流程

asmlinkage void __do_softirq(void)
{
    struct softirq_action *h;
    __u32 pending;
    int max_restart = MAX_SOFTIRQ_RESTART;
    int cpu;

    pending = local_softirq_pending();
    account_system_vtime(current);

    __local_bh_disable((unsigned long)__builtin_return_address(0));
    lockdep_softirq_enter();

    cpu = smp_processor_id();
restart:
    /* Reset the pending bitmask before enabling irqs */
    set_softirq_pending(0);

    local_irq_enable();

    h = softirq_vec;

    do {
        if (pending & 1) {
            int prev_count = preempt_count();
            kstat_incr_softirqs_this_cpu(h - softirq_vec);

            trace_softirq_entry(h, softirq_vec);
            h->action(h);
            trace_softirq_exit(h, softirq_vec);
            if (unlikely(prev_count != preempt_count())) {
                printk(KERN_ERR "huh, entered softirq %td %s %p"
                       "with preempt_count %08x,"
                       " exited with %08x?\n", h - softirq_vec,
                       softirq_to_name[h - softirq_vec],
                       h->action, prev_count, preempt_count());
                preempt_count() = prev_count;
            }

            rcu_bh_qs(cpu);
        }
        h++;
        pending >>= 1;
    } while (pending);

    local_irq_disable();

    pending = local_softirq_pending();
    if (pending && --max_restart)
        goto restart;

    if (pending)
        wakeup_softirqd();

    lockdep_softirq_exit();

    account_system_vtime(current);
    _local_bh_enable();
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
(1) 首先调用local_softirq_pending函数取得目前有哪些位存在软件中断。
(2) 调用__local_bh_disable关闭软中断,其实就是设置正在处理软件中断标记,在同一个CPU上使得不能重入__do_softirq函数。
(3) 重新设置软中断标记为0,set_softirq_pending重新设置软中断标记为0,这样在之后重新开启中断之后硬件中断中又可以设置软件中断位。
(4) 调用local_irq_enable,开启硬件中断。
(5) 之后在一个循环中,遍历pending标志的每一位,如果这一位设置就会调用软件中断的处理函数。在这个过程中硬件中断是开启的,随时可以打断软件中断。这样保证硬件中断不会丢失。
(6) 之后关闭硬件中断(local_irq_disable),查看是否又有软件中断处于pending状态,如果是,并且在本次调用__do_softirq函数过程中没有累计重复进入软件中断处理的次数超过max_restart=10次,就可以重新调用软件中断处理。如果超过了10次,就调用wakeup_softirqd()唤醒内核的一个进程来处理软件中断。设立10次的限制,也是为了避免影响系统响应时间。
(7) 调用_local_bh_enable开启软中断。

软中断内核线程

​ 之前我们分析的触发软件中断的位置其实是中断上下文中,而在软中断的内核线程中实际已经是进程的上下文。
​ 这里说的软中断上下文指的就是系统为每个CPU建立的ksoftirqd进程。
​ 软中断的内核进程中主要有两个大循环,外层的循环处理有软件中断就处理,没有软件中断就休眠。内层的循环处理软件中断,每循环一次都试探一次是否过长时间占据了CPU,需要调度就释放CPU给其它进程。具体的操作在注释中做了解释。

    set_current_state(TASK_INTERRUPTIBLE);
    //外层大循环。
    while (!kthread_should_stop()) {
        preempt_disable();//禁止内核抢占,自己掌握cpu
        if (!local_softirq_pending()) {
            preempt_enable_no_resched();
            //如果没有软中断在pending中就让出cpu
            schedule();
            //调度之后重新掌握cpu
            preempt_disable();
        }

        __set_current_state(TASK_RUNNING);

        while (local_softirq_pending()) {
            /* Preempt disable stops cpu going offline.
               If already offline, we'll be on wrong CPU:
               don't process */
            if (cpu_is_offline((long)__bind_cpu))
                goto wait_to_die;
            //有软中断则开始软中断调度
            do_softirq();
            //查看是否需要调度,避免一直占用cpu
            preempt_enable_no_resched();
            cond_resched();
            preempt_disable();
            rcu_sched_qs((long)__bind_cpu);
        }
        preempt_enable();
        set_current_state(TASK_INTERRUPTIBLE);
    }
    __set_current_state(TASK_RUNNING);
    return 0;

wait_to_die:
    preempt_enable();
    /* Wait for kthread_stop */
    set_current_state(TASK_INTERRUPTIBLE);
    while (!kthread_should_stop()) {
        schedule();
        set_current_state(TASK_INTERRUPTIBLE);
    }
    __set_current_state(TASK_RUNNING);
    return 0;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
② tasklet

​ 由于软中断必须使用可重入函数,这就导致设计上的复杂度变高,作为设备驱动程序的开发者来说,增加了负担。而如果某种应用并不需要在多个CPU上并行执行,那么软中断其实是没有必要的。因此诞生了弥补以上两个要求的tasklet。它具有以下特性:
a)一种特定类型的tasklet只能运行在一个CPU上,不能并行,只能串行执行。
b)多个不同类型的tasklet可以并行在多个CPU上。
c)软中断是静态分配的,在内核编译好之后,就不能改变。但tasklet就灵活许多,可以在运行时改变(比如添加模块时)。
​ tasklet是在两种软中断类型的基础上实现的,因此如果不需要软中断的并行特性,tasklet就是最好的选择。也就是说tasklet是软中断的一种特殊用法,即延迟情况下的串行执行。

tasklet描述符

struct tasklet_struct
{
      struct tasklet_struct *next;    // 将多个tasklet链接成单向循环链表
      unsigned long state;            // TASKLET_STATE_SCHED(Tasklet is scheduled for execution)  TASKLET_STATE_RUN(Tasklet is running (SMP only))
      atomic_t count;                // 0:激活tasklet 非0:禁用tasklet
      void (*func)(unsigned long);     // 用户自定义函数
      unsigned long data;              // 函数入参
};
1
2
3
4
5
6
7
8
其中的state有2位:

① bit0表示TASKLET_STATE_SCHED

等于1时表示已经执行了tasklet_schedule把该tasklet放入队列了;tasklet_schedule会判断该位,如果已经等于1那么它就不会再次把tasklet放入队列。

② bit1表示TASKLET_STATE_RUN

等于1时,表示正在运行tasklet中的func函数;函数执行完后内核会把该位清0。

其中的count表示该tasklet是否使能:等于0表示使能了,非0表示被禁止了。对于count非0的tasklet,里面的func函数不会被执行。

tasklet链表

static DEFINE_PER_CPU(struct tasklet_head, tasklet_vec);    // 低优先级
static DEFINE_PER_CPU(struct tasklet_head, tasklet_hi_vec);    // 高优先级
1
2
定义tasklet

​ 使用中断下半部之前,要先实现一个tasklet_struct结构体,这可以用这2个宏来定义结构体

// 定义名字为name的非激活tasklet
#define DECLARE_TASKLET(name, func, data) \
struct tasklet_struct name = { NULL, 0, ATOMIC_INIT(0), func, data }

// 定义名字为name的激活tasklet
#define DECLARE_TASKLET_DISABLED(name, func, data) \
struct tasklet_struct name = { NULL, 0, ATOMIC_INIT(1), func, data } 

// 动态初始化tasklet
void tasklet_init(struct tasklet_struct *t, void (*func)(unsigned long), unsigned long data)
1
2
3
4
5
6
7
8
9
10
也可以使用tasklet_init函数来初始化tasklet结构体

tasklet操作

tasklet_enable把count增加1;tasklet_disable把count减1。

// 函数暂时禁止给定的tasklet被tasklet_schedule调度,直到这个tasklet被再次被enable;若这个tasklet当前在运行, 这个函数忙等待直到这个tasklet退出
static inline void tasklet_disable(struct tasklet_struct *t)

// 使能一个之前被disable的tasklet;若这个tasklet已经被调度, 它会很快运行。tasklet_enable和tasklet_disable必须匹配调用, 因为内核跟踪每个tasklet的"禁止次数"
static inline void tasklet_enable(struct tasklet_struct *t)

// 调度 tasklet 执行,如果tasklet在运行中被调度, 它在完成后会再次运行; 这保证了在其他事件被处理当中发生的事件受到应有的注意. 这个做法也允许一个 tasklet 重新调度它自己   
static inline void tasklet_schedule(struct tasklet_struct *t)

//和tasklet_schedule类似,只是在更高优先级执行。当软中断处理运行时, 它处理高优先级 tasklet 在其他软中断之前,只有具有低响应周期要求的驱动才应使用这个函数, 可避免其他软件中断处理引入的附加周期.
tasklet_hi_schedule(struct tasklet_struct *t)

//确保了 tasklet 不会被再次调度来运行,通常当一个设备正被关闭或者模块卸载时被调用。如果 tasklet 正在运行, 这个函数等待直到它执行完毕。若 tasklet 重新调度它自己,则必须阻止在调用 tasklet_kill 前它重新调度它自己,如同使用 del_timer_sync    
tasklet_kill(struct tasklet_struct *t)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
调度原理

(1) tasklet_schedule调度tasklet时,其中的函数并不会立刻执行,而只是把tasklet放入队列;

(2) 调用一次tasklet_schedule,只会导致tasklnet的函数被执行一次;

(3) 如果tasklet的函数尚未执行,多次调用tasklet_schedule也是无效的,只会放入队列一次

static inline void tasklet_schedule(struct tasklet_struct *t)
{
    /* 1. 如果未设置为SCHED, 则设置为SCHED状态并放入队列 */
    if (!test_and_set_bit(TASKLET_STATE_SCHED, &t->state))
        __tasklet_schedule(t);
}
void __tasklet_schedule(struct tasklet_struct *t)
{
    unsigned long flags;

    local_irq_save(flags);
    t->next = NULL;    /* 2. 放入队列 加入低优先级列表 */
    *__this_cpu_read(tasklet_vec.tail) = t;
    __this_cpu_write(tasklet_vec.tail, &(t->next));
    raise_softirq_irqoff(TASKLET_SOFTIRQ);            /* 3. 触发软中断 */
    local_irq_restore(flags);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
tasklet使用方法

​ 先定义tasklet,需要使用时调用tasklet_schedule,驱动卸载前调用tasklet_kill。tasklet_schedule只是把tasklet放入内核队列,它的func函数会在软件中断的执行过程中被调用。

tasklet执行过程
TASKLET_SOFTIRQ对应执行函数为tasklet_action,HI_SOFTIRQ为tasklet_hi_action,以tasklet_action为例说明,tasklet_hi_action大同小异。

​ 从队列中找到tasklet,进行状态判断后执行func函数,从队列中删除tasklet。

static void tasklet_action(struct softirq_action *a)
{
    struct tasklet_struct *list;

    local_irq_disable();
    list = __this_cpu_read(tasklet_vec.head); /* 取得tasklet链表 */
    __this_cpu_write(tasklet_vec.head, NULL);
    __this_cpu_write(tasklet_vec.tail, this_cpu_ptr(&tasklet_vec.head));
    local_irq_enable();

    while (list) {
        struct tasklet_struct *t = list;

        list = list->next;    /* 1. 从链表中取出每一个节点 */
        
        /* 如果t->count的值不等于0,说明这个tasklet在调度之后,被disable掉了,所以
         * 会将tasklet结构体重新放回到tasklet_vec链表,并重新调度TASKLET_SOFTIRQ软
         * 中断,在之后enable这个tasklet之后重新再执行它 
         */
        if (tasklet_trylock(t)) {
            if (!atomic_read(&t->count)) {
                if (!test_and_clear_bit(TASKLET_STATE_SCHED,
                            &t->state))
                    BUG(); /* 2. 判断 若为非SCHED状态, 即有BUG */
                t->func(t->data);  /* 3. 执行tasklet */
                tasklet_unlock(t);
                continue;
            }
            tasklet_unlock(t);
        }
        
        local_irq_disable();
        t->next = NULL;    /* 4. 从队列中取出 */
        *__this_cpu_read(tasklet_vec.tail) = t;
        __this_cpu_write(tasklet_vec.tail, &(t->next));
        __raise_softirq_irqoff(TASKLET_SOFTIRQ);
        local_irq_enable();
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
tasklet处理流程

③ 工作队列

​ 从上面的介绍看以看出,软中断运行在中断上下文中,因此不能阻塞和睡眠,而tasklet使用软中断实现,当然也不能阻塞和睡眠。但如果某延迟处理函数需要睡眠或者阻塞呢?没关系工作队列就可以如您所愿了。
​ 把推后执行的任务叫做工作(work),描述它的数据结构为work_struct ,这些工作以队列结构组织成工作队列(workqueue),其数据结构为workqueue_struct ,而工作线程就是负责执行工作队列中的工作。系统默认的工作者线程为events。
​ 工作队列(work queue)是另外一种将工作推后执行的形式。工作队列可以把工作推后,交由一个内核线程去执行—这个下半部分总是会在进程上下文执行,但由于是内核线程,其不能访问用户空间。最重要特点的就是工作队列允许重新调度甚至是睡眠。
​ 通常,在工作队列和软中断/tasklet中作出选择非常容易。可使用以下规则:

a) 如果推后执行的任务需要睡眠,那么只能选择工作队列。

b) 如果推后执行的任务需要延时指定的时间再触发,那么使用工作队列,因为其可以利用timer延时(内核定时器实现)。

c) 如果推后执行的任务需要在一个tick之内处理,则使用软中断或tasklet,因为其可以抢占普通进程和内核线程,同时不可睡眠。

d) 如果推后执行的任务对延迟的时间没有任何要求,则使用工作队列,此时通常为无关紧要的任务。

​ 实际上,工作队列的本质就是将工作交给内核线程处理,因此其可以用内核线程替换。但是内核线程的创建和销毁对编程者的要求较高,而工作队列实现了内核线程的封装,不易出错,所以我们也推荐使用工作队列。

工作结构体

struct work_struct {
    atomic_long_t data;         /* 传递给工作函数的参数 */
#define WORK_STRUCT_PENDING 0   /* T if work item pending execution */
#define WORK_STRUCT_FLAG_MASK (3UL)
#define WORK_STRUCT_WQ_DATA_MASK (~WORK_STRUCT_FLAG_MASK)
    struct list_head entry;     /* 链表结构,链接同一工作队列上的工作。 */
    work_func_t func;             /* 工作函数,用户自定义实现 */
#ifdef CONFIG_LOCKDEP
    struct lockdep_map lockdep_map;
#endif
};

/* 工作队列执行函数的原型: 
 * 该函数会由一个工作者线程执行,因此其在进程上下文中,可以睡眠也可以中断。但只能在内核中运行,无法访问用户空间
 */
void (*work_func_t) (struct work_struct *work);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
延迟工作结构体(延迟的实现是在调度时延迟插入相应的工作队列)

struct delayed_work {
    struct work_struct work;
    struct timer_list timer; /* 定时器,用于实现延迟处理 */
};
1
2
3
4
工作队列结构体

struct workqueue_struct {
    struct cpu_workqueue_struct *cpu_wq; /* 指针数组,其每个元素为per-cpu的工作队列 */
    struct list_head list;
    const char *name;
    int singlethread;     /* 标记是否只创建一个工作者线程 */
    int freezeable;     /* Freeze threads during suspend */
    int rt;
#ifdef CONFIG_LOCKDEP
    struct lockdep_map lockdep_map;
#endif
};
1
2
3
4
5
6
7
8
9
10
11
cpu核工作队列(每个cpu核都对应一个工作者线程worker_thread)

 struct cpu_workqueue_struct {
    spinlock_t lock;
    struct list_head worklist;
    wait_queue_head_t more_work;
    struct work_struct *current_work;
    struct workqueue_struct *wq;
    struct task_struct *thread;
} cacheline_aligned;
1
2
3
4
5
6
7
8
缺省工作队列API

// 静态创建 
DECLARE_WORK(name,function);         /* 定义正常执行的工作项 */
DECLARE_DELAYED_WORK(name,function);/* 定义延后执行的工作项 */

// 动态创建
INIT_WORK(_work, _func)             /* 创建正常执行的工作项 */
INIT_DELAYED_WORK(_work, _func)        /* 创建延后执行的工作项 */

// 调度默认工作队列
int schedule_work(struct work_struct *work)

/* 对正常执行的工作进行调度,即把给定工作的处理函数提交给缺省的工作队列和工作者线程。工作者线程本质上
* 是一个普通的内核线程,在默认情况下,每个CPU核均有一个类型为“events”的工作者线程,当调用schedule_work
* 时,这个工作者线程会被唤醒去执行工作链表上的所有工作。
* 系统默认的工作队列名称是:keventd_wq, 默认的工作者线程叫:events/n,这里的n是处理器的编号,每个处
* 理器对应一个线程。比如,单处理器的系统只有events/0这样一个线程。而双处理器的系统就会多一个events/1
* 线程。
* 默认的工作队列和工作者线程由内核初始化时创建:
*/
start_kernel()-->rest_init-->do_basic_setup-->init_workqueues

// 调度延迟工作
int schedule_delayed_work(struct delayed_work *dwork,unsigned long delay)

// 刷新缺省工作队列
void flush_scheduled_work(void)     /* 此函数会一直等待,直到队列中的所有工作都被执行 */

取消延迟工作
static inline int cancel_delayed_work(struct delayed_work *work)
//flush_scheduled_work并不取消任何延迟执行的工作,因此,如果要取消延迟工作,应该调用cancel_delayed_work。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
​ 以上均是采用缺省工作者线程来实现工作队列,其优点是简单易用,缺点是如果缺省工作队列负载太重,执行效率会很低,这就需要我们创建自己的工作者线程和工作队列。

自定义工作队列

// 宏定义 返回值为工作队列,name为工作线程名称。创建新的工作队列和相应的工作者线程,name用于该内核线程的命名。
create_workqueue(name) 

// 类似于schedule_work,区别在于queue_work把给定工作提交给创建的工作队列wq而不是缺省队列。
int queue_work(struct workqueue_struct *wq, struct work_struct *work)

// 调度延迟工作。
int queue_delayed_work(struct workqueue_struct *wq,struct delayed_work *dwork, unsigned long delay)

// 刷新指定工作队列。
void flush_workqueue(struct workqueue_struct *wq)

// 释放创建的工作队列。
void destroy_workqueue(struct workqueue_struct *wq)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
工作队列的组织结构
即workqueue_struct、cpu_workqueue_struct与work_struct的关系。
一个工作队列对应一个work_queue_struct,工作队列中每cpu的工作队列由cpu_workqueue_struct表示,而work_struct为其上的具体工作。
关系如下图所示:


工作队列的工作过程

应用

a) linux各个接口的状态(up/down)的消息需要通知netdev_chain上感兴趣的模块同时上报用户空间消息。这里使用的就是工作队列。具体流程图如下所示:

b) 是否处于中断中在Linux中是通过preempt_count来判断的,具体如下: 在linux系统的进程数据结构里,有这么一个数据结构:

#define preempt_count() (current_thread_info()->preempt_count)
/* 利用preempt_count可以表示是否处于中断处理或者软件中断处理过程中,如下所示: */
# define hardirq_count() (preempt_count() & HARDIRQ_MASK)
#define softirq_count() (preempt_count() & SOFTIRQ_MASK)
#define irq_count() (preempt_count() & (HARDIRQ_MASK | SOFTIRQ_MASK | NMI_MASK))
#define in_irq() (hardirq_count())
#define in_softirq() (softirq_count())
#define in_interrupt() (irq_count())
1
2
3
4
5
6
7
8


preempt_count的8~23位记录中断处理和软件中断处理过程的计数。如果有计数,表示系统在硬件中断或者软件中断处理过程中。

以按键进行编程实践

5.1 按键设备驱动文件
在button_drv.c文件中,

加载模块时,btn_hw_drv_probe函数中调用tasklet_init将按键中断下半部处理函数tasklet_fun赋给.func成员;
gpio_btn_isr按键中断服务程序(中断上半部)调用tasklet_schedule函数,将按键.tasklet放入链表;
中断上半部处理完毕,有时机通过软中断softirq_vec[TASKLET_SOFTIRQ]链表执行按键中断下半部处理函数tasklet_fun,其中可以放一些处理时间较长的事情,比如:打印信息等;
当卸载模块时,btn_hw_drv_remove函数调用tasklet_kill禁能下半部调度。
/**
 * 文件    : button_drv.c
 * 作者    : glen  
 * 描述    : button driver文件
 */
#include <linux/types.h>
#include <linux/kernel.h>
#include <linux/delay.h>
#include <linux/ide.h>
#include <linux/init.h>
#include <linux/poll.h>
#include <linux/module.h>
#include <linux/errno.h>
#include <linux/gpio.h>
#include <linux/cdev.h>
#include <linux/of.h>
#include <linux/of_gpio.h>
#include <linux/platform_device.h>
#include <linux/gpio/consumer.h>
#include <asm/mach/map.h>
#include <asm/uaccess.h>
#include <asm/io.h>
#include <linux/fs.h>
#include <linux/time.h>
#include <linux/timer.h>
#include <linux/proc_fs.h>
#include <linux/stat.h>
#include <linux/device.h>
#include <linux/module.h>
#include <linux/poll.h>
#include <linux/fs.h>
#include <linux/errno.h>
#include <linux/miscdevice.h>
#include <linux/major.h>
#include <linux/mutex.h>
#include <linux/proc_fs.h>
#include <linux/seq_file.h>
#include <linux/stat.h>
#include <linux/device.h>
#include <linux/tty.h>
#include <linux/kmod.h>
#include <linux/gfp.h>
#include <linux/of_irq.h>
#include <linux/interrupt.h>
#include <linux/irq.h>
#include <linux/slab.h>
#include <linux/fcntl.h>

#define KEY_BUF_LEN 16
#define NEXT_POS(x) ((x+1) % KEY_BUF_LEN)

struct gbtn_irq {
    int gpio;
    struct gpio_desc *gpiod;
    int flag;
    int irq;
    int idx;
    char kval[KEY_BUF_LEN];
    int r, w;
    struct fasync_struct *fp;
    struct timer_list key_timer;
    struct tasklet_struct tasklet;
};

struct button_drv {
    struct class *class;
    struct gbtn_irq *gbtn_irq;
    
    char  *name;
    int count;
    int major;
};

static struct button_drv *btn_drv;

static int is_key_buf_empty(void *arg)
{
    struct gbtn_irq *p = (struct gbtn_irq *)arg;

    return (p->r == p->w);
}

static int is_key_buf_full(void *arg)
{
    struct gbtn_irq *p = (struct gbtn_irq *)arg;

    return (p->r == NEXT_POS(p->w));
}

static void put_key(char key, void *arg)
{
    struct gbtn_irq *p = (struct gbtn_irq *)arg;

    if (!is_key_buf_full(arg)) {
        p->kval[p->w] = key;
        p->w = NEXT_POS(p->w);
    }
}

static char get_key(void *arg)
{
    char key = 'N';
    struct gbtn_irq *p = (struct gbtn_irq *)arg;

    if (!is_key_buf_full(arg)) {
        key = p->kval[p->r];
        p->r = NEXT_POS(p->r);
    }

    return key;
}

/* 等待队列头的静态初始化 */
static DECLARE_WAIT_QUEUE_HEAD(gpio_button_wait);

static void key_timer_expire(unsigned long data)
{
    int val;
    char key;
    struct gbtn_irq *ops = (struct gbtn_irq *)data;

    /* 读取按键的值 */
    val = gpiod_get_value(ops->gpiod);

    printk("button%d %d %d\n", ops->idx, ops->gpio, val);
    key = (ops->gpio << 4) | val;

    put_key(key, ops);

    /* 唤醒等待队列 */
    wake_up_interruptible(&gpio_button_wait);

    kill_fasync(&ops->fp, SIGIO, POLL_IN);

    /* enable btn*/
    enable_irq(ops->irq);
}

/* 实现file_operations结构体成员 read 函数 */
ssize_t button_drv_read (struct file *filp, char __user *buf, size_t size, loff_t *pos)
{
    int minor = iminor(filp->f_inode);
    struct gbtn_irq *ops = (struct gbtn_irq *)filp->private_data;
    char kval;

    size = (size >= 1) ? 1 : 0;
    if (ops == NULL) {
        printk("Please register button instance %s %s, %d\n", __FILE__, __FUNCTION__, __LINE__);
        return -EIO;
    }

    if (is_key_buf_empty(ops) && (filp->f_flags & O_NONBLOCK))
        return -EAGAIN;

    wait_event_interruptible(gpio_button_wait, !is_key_buf_empty(ops));
    kval = get_key(ops);

    if (copy_to_user(buf, &kval, size))
        return -EFAULT;

    printk("Read button%d value successfully:", minor);
    return size;
}

/* 实现file_operations结构体成员 open 函数 */
int button_drv_open(struct inode *nd, struct file *filp)
{
    int ret;
    int minor = iminor(nd);
    struct gbtn_irq *ops; 
    
    if (btn_drv == NULL) {
        printk("Please register button instance %s %s, %d\n", __FILE__, __FUNCTION__, __LINE__);
        return -EIO;
    }

    ops = &btn_drv->gbtn_irq[minor];

    ret = gpiod_direction_input(ops->gpiod);
    if (ret) 
        printk("Set the button pin as input error %s %s, %d\n", __FILE__, __FUNCTION__, __LINE__);
    else 
        printk("Set the button%d pin as input successfully!\n", minor);

    filp->private_data = ops;

    return 0;
}

/* 实现file_operations结构体成员 release 函数 */
int button_drv_release (struct inode *nd, struct file *filp)
{
    struct gbtn_irq *ops = (struct gbtn_irq *)filp->private_data;

    if (ops == NULL) {
        printk("Please register button instance %s %s, %d\n", __FILE__, __FUNCTION__, __LINE__);
        return -EIO;
    }

    filp->private_data = NULL;

    return 0;
}

/* 实现file_operations结构体成员 poll 函数 */
unsigned int button_drv_poll (struct file *filp, struct poll_table_struct * wait)
{
    int minor = iminor(filp->f_inode);

    printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
    poll_wait(filp, &gpio_button_wait, wait);
    return (is_key_buf_empty(&btn_drv->gbtn_irq[minor]) ? 0 : POLLIN | POLLRDNORM);
}

static int button_drv_fasync(int fd, struct file *filp, int on)
{
    struct gbtn_irq *ops = (struct gbtn_irq *)filp->private_data;

    if (fasync_helper(fd, filp, on, &ops->fp) >= 0)
        return 0;
    else
        return -EIO;
}

/**
 * 1. 构造file_operations结构体 
 */
static struct file_operations button_drv_ops = {
    .owner   = THIS_MODULE,
    .read    = button_drv_read,
    .open    = button_drv_open,
    .release = button_drv_release,
    .poll    = button_drv_poll,
    .fasync  = button_drv_fasync,
};

/* 中断服务函数 */
static irqreturn_t gpio_btn_isr (int irq, void *dev_id)
{
    struct gbtn_irq *ops = dev_id;

    // printk("gpio_btn_isr key %d irq happened\n", ops->gpio);
    tasklet_schedule(&ops->tasklet);
    mod_timer(&ops->key_timer, jiffies + HZ / 50);
    disable_irq_nosync(irq);
    
    return IRQ_HANDLED;
}

/* tasklet action function */
static void tasklet_fun (unsigned long data)
{
    int val;
    struct gbtn_irq *ops = (struct gbtn_irq *)data;

    /* 读取按键的值 */
    val = gpiod_get_value(ops->gpiod);

    printk("tasklet_fun key%d %d %d\n", ops->idx, ops->gpio, val);

}

/* platform_driver结构体的 probe成员函数实现 */
int btn_hw_drv_probe (struct platform_device *pdev)
{
    int i;
    int ret;
    int count;
    // enum of_gpio_flags flag;
    struct device_node *node = pdev->dev.of_node;

    /* 从设备节点获取gpio数量 */
    count = of_gpio_count(node);
    if (!count) {
        printk("%s %s line %d, there isn't any gpio available!\n", __FILE__, __FUNCTION__, __LINE__);
        return -EIO;
    }
    
    btn_drv = kzalloc(sizeof(struct button_drv), GFP_KERNEL);
    if (btn_drv == NULL) 
        return -ENOMEM;

    btn_drv->gbtn_irq = kzalloc(sizeof(struct gbtn_irq) * count, GFP_KERNEL);
    if (btn_drv->gbtn_irq == NULL)
        return -ENOMEM;

    for (i = 0; i < count; i++) {
        btn_drv->gbtn_irq[i].gpiod = gpiod_get_index_optional(&pdev->dev, NULL, i, GPIOD_ASIS);
        if (btn_drv->gbtn_irq[i].gpiod == NULL) {
            printk("%s %s line %d, gpiod_get_index_optional failed!\n", __FILE__, __FUNCTION__, __LINE__);
            return -EIO;
        }

        btn_drv->gbtn_irq[i].irq = gpiod_to_irq(btn_drv->gbtn_irq[i].gpiod);
        btn_drv->gbtn_irq[i].gpio = desc_to_gpio(btn_drv->gbtn_irq[i].gpiod);

        setup_timer(&btn_drv->gbtn_irq[i].key_timer, key_timer_expire, &btn_drv->gbtn_irq[i]);
        btn_drv->gbtn_irq[i].key_timer.expires = ~0;
        add_timer(&btn_drv->gbtn_irq[i].key_timer);

        tasklet_init(&btn_drv->gbtn_irq[i].tasklet, tasklet_fun, &btn_drv->gbtn_irq[i]);

        btn_drv->gbtn_irq[i].idx = i;
    }

    for (i = 0; i < count; i++) 
        /* 申请irq中断, 将中断服务程序注册到上半部 */
        ret = request_irq(btn_drv->gbtn_irq[i].irq, gpio_btn_isr, IRQF_TRIGGER_FALLING | IRQF_TRIGGER_RISING, 
                          "gpio_btn", &btn_drv->gbtn_irq[i]);

    /* 注册file_operationss结构体对象 -- button_drv_ops  */
    btn_drv->major = register_chrdev(btn_drv->major, "gbtn", &button_drv_ops);
    btn_drv->class = class_create(THIS_MODULE, "gbtn");
    if (IS_ERR(btn_drv->class)) {
        printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
        unregister_chrdev(btn_drv->major, "gbtn");
        return PTR_ERR(btn_drv->class);
    }

    for (i = 0; i < count; i++)
        device_create(btn_drv->class, NULL, MKDEV(btn_drv->major, i), NULL, "gbtn%d", i);

    btn_drv->count = count;
        
    return 0;
}

/* platform_driver结构体的 remove成员函数实现 */
int btn_hw_drv_remove(struct platform_device *pdev)
{
    int i;
    struct device_node *node = pdev->dev.of_node;
    int count = of_gpio_count(node);

    for (i = 0; i < count; i++) {
        device_destroy(btn_drv->class, MKDEV(btn_drv->major, i));
        free_irq(btn_drv->gbtn_irq[i].irq, &btn_drv->gbtn_irq[i]);
        del_timer(&btn_drv->gbtn_irq[i].key_timer);
        tasklet_kill(&btn_drv->gbtn_irq[i].tasklet);
    }
    class_destroy(btn_drv->class);
    unregister_chrdev(btn_drv->major, "gbtn");

    kfree(btn_drv);
    return 0;
}

/* 构造用于配置的设备属性 */
static const struct of_device_id gbtns_id[] = {
    {.compatible = "glen,gbtn"},
    { },
};

/* 构造(初始化)file_operations结构体 */
static struct platform_driver btn_hw_drv = {
    .driver = {
        .name = "gbtn",
        .of_match_table = gbtns_id,
    },
    .probe = btn_hw_drv_probe,
    .remove = btn_hw_drv_remove,
};

/* 初始化 */
static int __init button_drv_init(void)
{
    int ret;
    ret = platform_driver_register(&btn_hw_drv);
    if (ret)
        pr_err("Unable to initialize button driver\n");
    else
        pr_info("The button driver is registered.\n");

    
    return 0;
}
module_init(button_drv_init);

static void __exit button_drv_exit(void)
{
    platform_driver_unregister(&btn_hw_drv);
    printk(" %s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
}
module_exit(button_drv_exit);

/* insert author information for module */
MODULE_AUTHOR("glen");
/* insert license for module */
MODULE_LICENSE("GPL");
 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
probe函数采用了先获取按键节点数量,然后分别读取gpio描述符并通过其获取为gpio和irq号,并申请注册中断服务程序。

5.2 设备树文件(不作更改)

         pinctrl_btn0:btn0 {
             fsl,pins = <
                 MX6UL_PAD_UART1_CTS_B__GPIO1_IO18    0xF080    /* KEY0 */ 
             >;
         };
 
         pinctrl_btn1:btn1 {
             fsl,pins = <
                 MX6UL_PAD_GPIO1_IO03__GPIO1_IO03    0xF080    /* KEY1  此按键不存在 */
             >;
         };
     /* 在根节点下添加基于pinctrl的gbtns设备节点 */
     gbtns {
         compatible = "glen,gbtn";
         #address-cells = <1>;
 
         pinctrl-names = "default";
         pinctrl-0 = <&pinctrl_btn0 
                      &pinctrl_btn1>;
 
         gpio-controller;
         #gpio-cells = <2>;
         gpios = <&gpio1 18 GPIO_ACTIVE_LOW /* button0 */
                  &gpio1 3 GPIO_ACTIVE_LOW>;   /* button1 */
 
     };
 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
取消了gpios前缀“xxx-",相应地,在驱动程序用gpiod_get_index_optional函数获取gpio描述符时,第2个形参 ”const char *con_id“ 传递NULL即可;

将pinctrl-0、gpios属性值由 “<>,<>;” 改为 “<>;",效果是一样的

嵌入式 Linux 驱动模块实验
docx

0星
超过10%的资源
1.21MB

下载
5.3 应用程序

应用程序文件button_drv_test.c提供:

定义sig_fun信号处理函数并注册信号,以后APP收到SIGIO信号时,这个函数会被自动调用;
fcntl(fd, F_SETOWN, getpid()); 把APP的PID(进程ID)告诉驱动程序,这个调用不涉及驱动程序,在内核的文件系统层次记录PID;
oflags = fcntl(fd, F_GETFL); 读取驱动程序文件oflags
fcntl(fd, F_SETFL, oflags | FASYNC); 设置oflags里面的FASYNC位为1:当FASYNC位发生变化时,会导致驱动程序的fasync被调用
 /*
  * 文件名   :  button_drv_test.c
  * 作者     :  glen
  * 描述     :  button_drv应用程序
  */
 
 #include "stdio.h"
 #include "sys/types.h"
 #include "sys/stat.h"
 #include "stdlib.h"
 #include "string.h"
 #include "poll.h"
 #include <signal.h>
 #include <unistd.h>
 #include <fcntl.h>
 
 int fd;
 
 static void sig_fun(int sig)
 {
     char kval;
     read(fd, &kval, 1);
     printf("The glen button value is: %d!\n", kval);
 }
 
 /**
  * @brief   : main函数
  * @par     : argc  argv数组元素的个数
  *            argv  参数数组
  * @retval  : 0 成功    其它 失败
  */
 int main(int argc, char *argv[])
 {
     int ret;
     int oflags;
     char *filename;
 
     if (argc != 2) {
         printf("Error Usage!\r\n");
         return -1;
     }
 
     signal(SIGIO, sig_fun);
 
     filename = argv[1];
 
     /* 打开驱动文件 */
     fd = open(filename, O_RDWR);
     if (fd < 0) {
         printf("Can't open file %s\r\n", filename);
         return -1;
     }
 
     fcntl(fd, F_SETOWN, getpid());
     oflags = fcntl(fd, F_GETFL);
     fcntl(fd, F_SETFL, oflags | FASYNC);
 
     while (1) {
         sleep(2);
         printf("Read the glen button in sleepping!\n");
     }
 
     /* 关闭文件 */
     ret = close(fd);
     if (ret < 0) {
         printf("file %s close failed!\r\n", argv[1]);
         return -1;
     }
     return 0;
 }
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
5.4 在alientek_linux_alpha开发板实测验证如下

/drv_module # ./btn_drv_test /dev/gbtn0
tasklet_fun key0 18 1
button0 18 1
tasklet_fun key0 18 0
button0 18 0
tasklet_fun key0 18 1
button0 18 1
tasklet_fun key0 18 0
button0 18 0
tasklet_fun key0 18 0
button0 18 0
tasklet_fun key0 18 1
button0 18 1
tasklet_fun key0 18 0
button0 18 0
tasklet_fun key0 18 1
button0 18 1


————————————————
版权声明:本文为CSDN博主「glen_cao」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/glen_cao/article/details/123266598

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值