中断与时钟

本章主要讲解 Linux 设备驱动编程中的中断与定时器处理。由于中断服务程序的执行并不存在于进程上下文,因此,要求中断服务程序的时间尽可能地短。因此,Linux在中断处理中引入了顶半部和底半部分离的机制。另外,内核中对时钟的处理也采用中断方式,而内核软件定时器最终依赖于时钟中断。

问题:什么是进程上下文?

中断与定时器

所谓中断是指 CPU 在执行程序的过程中,出现了某些突发事件时 CPU 必须暂停执行当前的程序,转去处理突发事件,处理完毕后 CPU 又返回原程序被中断的位置并继续执行。

根据中断的来源,中断可分为内部中断和外部中断,内部中断的中断源来自 CPU内部(软件中断指令、溢出、除法错误等,例如,操作系统从用户态切换到内核态需借助 CPU 内部的软件中断),外部中断的中断源来自 CPU 外部,由外设提出请求。

根据是否可以屏蔽中断分为可屏蔽中断与不屏蔽中断(NMI) 可屏蔽中断可以通过屏蔽字被屏蔽,屏蔽后,该中断不再得到响应,而不屏蔽中断不能被屏蔽。

根据中断入口跳转方法的不同,中断分为向量中断和非向量中断

采用向量中断的 CPU 通常为不同的中断分配不同的中断号,当检测到某中断号的中断到来后,就自动跳转到与该中断号对应的地址执行。不同中断号的中断有不同的入口地址。

非向量中断的多个中断共享一个入口地址,进入该入口地址后再通过软件判断中断标志来识别具体是哪个中断。也就是说,向量中断由硬件提供中断服务程序入口地址,非向量中断由软件提供中断服务程序入口地址。

一个典型的非向量中断服务程序如下所示,它先判断中断源,然后调用不同中断源的中断服务程序。

irq_handler()
{
  ...
  int int_src = read_int_status(); /*读硬件的中断相关寄存器*/
  switch (int_src) /*判断中断源*/
  {
    case DEV_A:
       dev_a_handler();
       break;
    case DEV_B:
       dev_b_handler();
       break;
     ...
   default:
   break;
  }
  ...
}
由于向量中断使用方便,目前有许多工程师创造了一些在非向量中断的处理器上模拟向量中断的方法。
嵌入式系统以及 X86 PC 中大多包含可编程中断控制器(PIC),许多 MCU 内部就集成了 PIC。如在 80386 中,PIC 是两片 i8259A 芯片的级联。通过读写 PIC 的寄存器,程序员可以屏蔽/使能某中断及获得中断状态,前者一般通过中断 MASK 寄存器完成,后者一般通过中断 PEND 寄存器完成。

定时器在硬件上也依赖中断来实现, 下图给出了典型的嵌入式微处理内可编程间隔定时器(PIT)的工作原理,它接收一个时钟输入,当时钟脉冲到来时,将目前计数值增 1 并与预先设置的计数值(计数目标)比较,若相等,证明计数周期满,产生定时器中断并复位目前计数值。


Linux 中断处理程序架构

设备的中断会打断内核中进程的正常调度和运行,系统对更高吞吐率的追求势必要求中断服务程序尽可能地短小精悍。但是,这个良好的愿望往往与现实并不吻合。在大多数真实的系统中,当中断到来时,要完成的工作往往并不会是短小的,它可能要进行较大量的耗时处理。

下图描述了 Linux 内核的中断处理机制。为了在中断执行时间尽可能短和中断处理需完成大量工作之间找到一个平衡点,Linux 将中断处理程序分解为两个半部:顶半部(top half)和底半部(bottom half)


顶半部完成尽可能少的比较紧急的功能,它往往只是简单地读取寄存器中的中断状态并清除中断标志后就进行“登记中断”的工作。“登记中断”意味着将底半部处理程序挂到该设备的底半部执行队列中去。这样,顶半部执行的速度就会很快,可以服务更多的中断请求。

现在,中断处理工作的重心就落在了底半部的头上,它来完成中断事件的绝大多数任务。底半部几乎做了中断处理程序所有的事情,而且可以被新的中断打断,这也是底半部和顶半部的最大不同,因为顶半部往往被设计成不可中断。底半部则相对来说并不是非常紧急的,而且相对比较耗时,不在硬件中断服务程序中执行。

注意:尽管顶半部、底半部的结合能够改善系统的响应能力,但是,僵化地认为 Linux设备驱动中的中断处理一定要分两个半部则是不对的。如果中断要处理的工作本身很少,则完全可以直接在顶半部全部完成。

其他操作系统中对中断的处理也采用了类似于 Linux 系统的方法,真正的硬件中断服务程序都应该尽可能短。因此,许多操作系统都提供了中断上下文和非中断上下文相结合的机制,将中断的耗时工作保留到非中断上下文去执行。例如,在 VxWorks 系统中,网络设备包接收中断到来后,中断服务程序会通过 netJobAdd()函数将耗时的包接收和上传工作交给 tNetTask 任务去执行。

在 Linux 系统中,查看/proc/interrupts 文件可以获得系统中断的统计信息,如下所示。在单处理器的系统中,第一列是中断号,第二列是向 CPU0 产生该中断的次数,之后的是对于中断的描述

# cat /proc/interrupts
           CPU0
  6:     101847        sprd  timer1
 10:          0        sprd  qwerty
 11:     106993        sprd  sc8810-i2c, sc8810-i2c
 14:     200108        sprd  sc8810-i2c, sc8810-i2c
 21:       4025        sprd  sprd-dma
 23:          0        sprd  VSP
Linux 中断编程

申请和释放中断
在 Linux 设备驱动中,使用中断的设备需要申请和释放对应的中断,分别使用内核提供的 request_irq()和 free_irq()函数。
1.申请 IRQ

int request_irq(unsigned int irq, irq_handler_t handler,
		unsigned long irqflags, const char *devname, void *dev_id)
irq 是要申请的硬件中断号。
handler 是向系统登记的中断处理函数,是一个回调函数,中断发生时,系统调用这个函数,dev_id 参数将被传递给它。
irqflags 是中断处理的属性,若设置了 SA_INTERRUPT,则表示中断处理程序是快速处理程序,快速处理程序被调用时屏蔽所有中断,慢速处理程序不屏蔽;若设置了 SA_SHIRQ,则表示多个设备共享中断

dev_id 在中断共享时会用到,一般设置为这个设备的设备结构体或者 NULL。
request_irq()返回 0 表示成功,返回-INVAL 表示中断号无效或处理函数指针为NULL,返回-EBUSY 表示中断已经被占用且不能共享。

释放 IRQ
与 request_irq()向对应的函数为 free_irq(),free_irq()的原型如下:

void free_irq(unsigned int irq,void *dev_id);
free_irq()中参数的定义与 request_irq()相同。

使能和屏蔽中断
下列 3 个函数用于屏蔽一个中断源。

void disable_irq(int irq);
void disable_irq_nosync(int irq);
void enable_irq(int irq);
disable_irq_nosync()与 disable_irq()的区别在于前者立即返回,而后者等待目前的中断处理完成。注意,这 3 个函数作用于可编程中断控制器,因此,对系统内的所有CPU 都生效。
下列两个函数将屏蔽本 CPU 内的所有中断。

void local_irq_save(unsigned long flags);
void local_irq_disable(void);
前者会将目前的中断状态保留在 flags 中,注意 flags 被直接传递,而不是通过指针传递。后者直接禁止中断。
与上述两个禁止中断对应的恢复中断的方法如下:

void local_irq_restore(unsigned long flags);
void local_irq_enable(void);
底半部机制
Linux 系统实现底半部的机制主要有 tasklet、工作队列和软中断
1.tasklet
tasklet 的使用较简单,我们只需要定义 tasklet 及其处理函数并将两者关联,
例如:

void my_tasklet_func(unsigned long); /*定义一个处理函数*/
DECLARE_TASKLET(my_tasklet, my_tasklet_func, data);
/*定义一个 tasklet 结构 my_tasklet,与 my_tasklet_func(data)函数相关联*/
代码 DECLARE_TASKLET(my_tasklet, my_tasklet_func,data)实现了定义名称为my_tasklet 的 tasklet 并将其与 my_tasklet_func()这个函数绑定,而传入这个函数的参数为 data。
在需要调度 tasklet 的时候引用一个 tasklet_schedule()函数就能使系统在适当的时候进行调度运行,如下所示:

tasklet_schedule(&my_tasklet);
使用 tasklet 作为底半部处理中断的设备驱动程序模板如代码所示(仅包含与中断相关的部分)。
/*定义 tasklet 和底半部函数并关联*/
void xxx_do_tasklet(unsigned long);
DECLARE_TASKLET(xxx_tasklet, xxx_do_tasklet, 0);
/*中断处理底半部*/
void xxx_do_tasklet(unsigned long)
{
   ...
}
/*中断处理顶半部*/
irqreturn_t xxx_interrupt(int irq, void *dev_id, struct pt_regs *regs)
{
   ...
   tasklet_schedule(&xxx_tasklet);//调度中断低半部
   ...
}
/*设备驱动模块加载函数*/
int __init xxx_init(void)
{
   ...
   /*申请中断*/
   result = request_irq(xxx_irq, xxx_interrupt,SA_INTERRUPT, "xxx", NULL);
   ...
}
/*设备驱动模块卸载函数*/
void __exit xxx_exit(void)
{
   ...
   /*释放中断*/
   free_irq(xxx_irq, xxx_interrupt);
   ...
}
上述程序在模块加载函数中申请中断,并在模块卸载函数中释放它。对应于 xxx_irq 中断号的中断处理程序被设置为 xxx_interrupt()函数,在这个函数中, tasklet_schedule(&xxx_tasklet)调度的 tasklet 函数xxx_do_tasklet()在适当的时候得到执行。

显示中断处理程序顶半部的返回类型为 irqreturn_t,它定义为 int,中断处理程序顶半部一般返回 IRQ_HANDLED。
2.工作队列
工作队列的使用方法和 tasklet 非常相似,下面的代码用于定义一个工作队列和一个底半部执行函数。

struct work_struct my_wq; /*定义一个工作队列*/
void my_wq_func(unsigned long); /*定义一个处理函数*/
通过 INIT_WORK()可以初始化这个工作队列并将工作队列与处理函数绑定,如下所示:
INIT_WORK(&my_wq, (void (*)(void *)) my_wq_func, NULL);
/*初始化工作队列并将其与处理函数绑定*/
与 tasklet_schedule()对应的用于调度工作队列执行的函数为 schedule_work(),如:
schedule_work(&my_wq);/*调度工作队列执行*/
使用工作队列处理中断底半部的设备驱动程序模板如代码清单所示(仅包含与中断相关的部分)。
/*定义工作队列和关联函数*/
struct work_struct xxx_wq;
void xxx_do_work(unsigned long);
/*中断处理底半部*/
void xxx_do_work(unsigned long)
{
   ...
}
/*中断处理顶半部*/
irqreturn_t xxx_interrupt(int irq, void *dev_id, struct pt_regs *regs)
{
  ...
  schedule_work(&xxx_wq);//调度work_struct
  ...
}
/*设备驱动模块加载函数*/
int xxx_init(void)
{
  ...
  /*申请中断*/
  result = request_irq(xxx_irq, xxx_interrupt,SA_INTERRUPT, "xxx", NULL);
  ...
  /*初始化工作队列*/
  INIT_WORK(&xxx_wq, (void (*)(void *)) xxx_do_work, NULL);
  ...
}
/*设备驱动模块卸载函数*/
void xxx_exit(void)
{
  ...
  /*释放中断*/
  free_irq(xxx_irq, xxx_interrupt);
  ...
}
尽管 Linux 专家们多建议在设备第一次打开时才申请设备的中断并在最后一次关闭时释放中断以尽量减少中断被这个设备占用的时间,但是,大多数情况下,为求省事,大多数驱动工程师还是将中断申请和释放的工作放在了设备驱动的模块加载和卸载函数中。
3.软中断

实例:S3C2410 实时钟中断
S3C2410 处理器内部集成了实时钟(RTC)模块,该模块能够在系统断电的情况下由后备电池供电继续工作,其主要功能相对于一个时钟,记录年、月、日、时、分、秒等。S3C2410 的 RTC 可产生两种中断:周期节拍(tick)中断和报警(alarm)中断,前者相当于一个周期性的定时器,后者相当于一个“闹钟”,它在预先设定的时间到来时产生中断。

S3C2410 实时钟设备驱动的 open()函数中,会申请它将要使用的中断,如代码

/*S3C2410 实时钟驱动的 open()函数 */
static int s3c2410_rtc_open(void)
{
   int ret;
   /*申请 alarm 中断*/
   ret = request_irq(s3c2410_rtc_alarmno, s3c2410_rtc_alarmirq,SA_INTERRUPT, "s3c2410-rtc alarm", NULL);
   /*中断号被占用*/
   if (ret)
     printk(KERN_ERR "IRQ%d already in use\n", s3c2410_rtc_alarmno);
   /*申请 tick 中断*/
   ret = request_irq(s3c2410_rtc_tickno, s3c2410_rtc_tickirq,SA_INTERRUPT,"s3c2410-rtc tick", NULL);
   /*中断号被占用*/
   if (ret)
   {
     printk(KERN_ERR "IRQ%d already in use\n", s3c2410_rtc_tickno);
     goto tick_err;
   }
   return ret;

   tick_err: free_irq(s3c2410_rtc_alarmno, NULL); /*释放 alarm 中断*/
   return ret;
}
S3C2410 实时钟设备驱动的 release()函数中,会释放它将要使用的中断,代码如下:

static void s3c2410_rtc_release(void)
{
  s3c2410_rtc_setpie(0);
  /* 释放中断 */
  free_irq(s3c2410_rtc_alarmno, NULL);
  free_irq(s3c2410_rtc_tickno, NULL);
}
S3C2410 实时钟驱动的中断处理比较简单,不需要分为上下两个半部,而只存在顶半部,如代码如下 所示。
/*中断处理*/
static irqreturn_t s3c2410_rtc_alarmirq(int irq, void *id, struct _regs *r)
{
  rtc_update(1, RTC_AF | RTC_IRQF);
  return IRQ_HANDLED;
}

static irqreturn_t s3c2410_rtc_tickirq(int irq, void *id, struct pt_regs *r)
{
   rtc_update(1, RTC_PF | RTC_IRQF);
   return IRQ_HANDLED;
}
上 述 代 码 中 调 用 的rtc_update () 函 数 定 义 于\arch\arm\common\Rtctime.c 文件中,被各种实时钟共享,如代码:

void rtc_update(unsigned long num, unsigned long events)
{
	spin_lock(&rtc_lock);
	rtc_irq_data = (rtc_irq_data + (num << 8)) | events;
	spin_unlock(&rtc_lock);

	wake_up_interruptible(&rtc_wait);/*唤醒等待队列*/
	kill_fasync(&rtc_async_queue, SIGIO, POLL_IN);/*释放信号*/
}
上述中断处理程序并没有底半部(或者说没有严格意义上的 tasklet、工作队列或软中断底半部),实际上,它只是唤醒一个等待队列 rtc_wait,而这个等待队列的唤醒也将导致一个阻塞的进程被执行(这个阻塞的进程可看做底半部)。现在我们看到,等待队列可以作为中断处理程序顶半部和进程同步的一种良好机制。但是,任何情况下,都不能在顶半部等待一个等待队列,而只能唤醒。

内核定时器

内核定时器编程
软件意义上的定时器最终依赖硬件定时器来实现,内核在时钟中断发生后检测各定时器是否到期,到期后的定时器处理函数将作为软中断在底半部执行。实质上,时钟中断处理程序执行 update_process_timers()函数,该函数调用 run_local_timers()函数,这个函数处理 TIMER_SOFTIRQ 软中断,运行当前处理器上到期的所有定时器。

在 Linux 设备驱动编程中,可以利用 Linux 内核中提供的一组函数和数据结构来完成定时触发工作或者完成某周期性的事务。这组函数和数据结构使得驱动工程师多数情况下不用关心具体的软件定时器究竟对应着怎样的内核和硬件行为。
Linux 内核所提供的用于操作定时器的数据结构和函数如下。
1.timer_list
在 Linux 内核中,timer_list 结构体的一个实例对应一个定时器,如下面代码所示。

struct timer_list {
	struct list_head entry;//定时器列表
	unsigned long expires;//定时器到期时间

	void (*function)(unsigned long);//定时器处理函数
	unsigned long data;//作为参数被传入定时器处理函数

	struct tvec_t_base_s *base;
#ifdef CONFIG_TIMER_STATS
	void *start_site;
	char start_comm[16];
	int start_pid;
#endif
};
当定时器期满后, function()成员将被执行, data 成员则是传入其中的参数,expires 则是定时器到期的时间(jiffies)。
如下代码定义一个名为 my_timer 的定时器:

struct timer_list my_timer;
2.初始化定时器
void init_timer(struct timer_list * timer);
上述 init_timer()函数初始化 timer_list 的 entry 的 next 为 NULL,并给 base 指针赋值。
TIMER_INITIALIZER(_function, _expires, _data)宏用于赋值定时器结构体的function、expires、data 和 base 成员,这个宏的定义如下所示:
#define TIMER_INITIALIZER(_function, _expires, _data) {
.function = (_function),
.expires = (_expires),
.data = (_data),
.base = &__init_timer_base,
}
DEFINE_TIMER(_na me , _ functi on, _e x pires, _ da ta )宏是定义并初始化定时器成员的“快捷方式”,这个宏定义如下所示:
#define DEFINE_TIMER(_name, _function, _expires, _data)
struct timer_list _name =
TIMER_INITIALIZER(_function, _expires, _data)
此外,setup_timer()也可用于初始化定时器并赋值其成员,其源代码如下:
static inline void setup_timer(struct timer_list * timer,void (*function)(unsigned long),unsigned long data)
{
   timer->function = function;
   timer->data = data;
   init_timer(timer);
}
3.增加定时器
void add_timer(struct timer_list * timer);
上述函数用于注册内核定时器,将定时器加入到内核动态定时器链表中。
4.删除定时器
int del_timer(struct timer_list * timer);
上述函数用于删除定时器。
del_timer_sync()是 del_timer()的同步版,主要在多处理器系统中使用,如果编译内核时不支持 SMP,del_timer_sync()和 del_timer()等价。
5.修改定时器的 expire

int mod_timer(struct timer_list *timer, unsigned long expires);
上述函数用于修改定时器的到期时间,在新的被传入的 expires 到来后才会执行定时器函数。
下面代码 给出了一个完整的内核定时器使用模板,大多数情况下,设备驱动都如这个模板那样使用定时器。
/*xxx 设备结构体*/
struct xxx_dev
{
  struct cdev cdev;
  ...
  timer_list xxx_timer;/*设备要使用的定时器*/
};

/*xxx 驱动中的某函数*/
xxx_func1(...)
{
   struct xxx_dev *dev = filp->private_data;
   ...
   /*初始化定时器*/
   init_timer(&dev->xxx_timer);
   dev->xxx_timer.function = &xxx_do_timer;//定时器处理函数
   dev->xxx_timer.data = (unsigned long)dev;//定时器处理函数传进的参数

   /*设备结构体指针作为定时器处理函数参数*/
   dev->xxx_timer.expires = jiffies + delay;
   /*添加(注册)定时器*/
   add_timer(&dev->xxx_timer);
   ...
}

/*xxx 驱动中的某函数*/
xxx_func2(...)
{
   ...
   /*删除定时器*/
   del_timer (&dev->xxx_timer);
   ...
}

/*定时器处理函数*/
static void xxx_do_timer(unsigned long arg)
{
   struct xxx_device *dev = (struct xxx_device *)(arg);
   ...
   /*调度定时器再执行*/
   dev->xxx_timer.expires = jiffies + delay;
   add_timer(&dev->xxx_timer);
   ...
}
从上面代码可以看出,定时器的到期时间往往是在目前 jiffies 的基础上添加一个时延,若为 Hz,则表示延迟 1s。在定时器处理函数中,
在做完相应的工作后,往往会延后 expires 并将定时器再次、添加到内核定时器链表,以便定时器能再次被触发。

实例:秒字符设备
下面我们编写一个字符设备“second”(即“秒”)的驱动,它在被打开的时候初始化一个定时器并将其添加到内核定时器链表,每秒输出一次当前的 jiffies(为此,定时器处理函数中每次都要修改新的 expires),整个程序如代码如下。

#include ...

#define SECOND_MAJOR 252
/*预设的 second 的主设备号*/
static int second_major = SECOND_MAJOR;

/*second 设备结构体*/
struct second_dev
{
  struct cdev cdev; /*cdev 结构体*/
  atomic_t counter;/* 一共经历了多少秒?*/
  struct timer_list s_timer; /*设备要使用的定时器*/
};

struct second_dev *second_devp; /*设备结构体指针*/

/*定时器处理函数*/
static void second_timer_handle(unsigned long arg)
{
  mod_timer(&second_devp->s_timer,jiffies + HZ);
  atomic_inc(&second_devp->counter);
  printk(KERN_NOTICE "current jiffies is %ld\n", jiffies);
}

/*文件打开函数*/
int second_open(struct inode *inode, struct file *filp)
{
  /*初始化定时器*/
  init_timer(&second_devp->s_timer);
  second_devp->s_timer.function = &second_timer_handle;
  second_devp->s_timer.expires = jiffies + HZ;
  add_timer(&second_devp->s_timer); /*添加(注册)定时器*/

  atomic_set(&second_devp->counter,0); //计数清零
  return 0;
}
/*文件释放函数*/
int second_release(struct inode *inode, struct file *filp)
{
  del_timer(&second_devp->s_timer);
  return 0;
}

/*globalfifo 读函数*/
static ssize_t second_read(struct file *filp, char _ _user *buf,size_t count,loff_t *ppos)
{
  int counter;
  counter = atomic_read(&second_devp->counter);
  if(put_user(counter, (int*)buf))
     return - EFAULT;
  else
     return sizeof(unsigned int);
}

/*文件操作结构体*/
static const struct file_operations second_fops =
{
   .owner = THIS_MODULE,
   .open = second_open,
   .release = second_release,
   .read = second_read,
};

/*初始化并注册 cdev*/
static void second_setup_cdev(struct second_dev *dev, int index)
{
  int err, devno = MKDEV(second_major, index);
  cdev_init(&dev->cdev, &second_fops);
  dev->cdev.owner = THIS_MODULE;
  dev->cdev.ops = &second_fops;
  err = cdev_add(&dev->cdev, devno, 1);
  if (err)
    printk(KERN_NOTICE "Error %d adding LED%d", err, index);
}

/*设备驱动模块加载函数*/
int second_init(void)
{
  int ret;
  dev_t devno = MKDEV(second_major, 0);

  /* 申请设备号*/
  if (second_major)
    ret = register_chrdev_region(devno, 1, "second");
  else /* 动态申请设备号 */
  {
    ret = alloc_chrdev_region(&devno, 0, 1, "second");
    second_major = MAJOR(devno);
  }
  if (ret < 0)
    return ret;
  /* 动态申请设备结构体的内存*/
  second_devp = kmalloc(sizeof(struct second_dev), GFP_KERNEL);
  if (!second_devp)
  /*申请失败*/
  {
    ret = - ENOMEM;
    goto fail_malloc;
  }
  memset(second_devp, 0, sizeof(struct second_dev));
  second_setup_cdev(second_devp, 0);
  return 0;
  fail_malloc: unregister_chrdev_region(devno, 1);
}

/*模块卸载函数*/
void second_exit(void)
{
   cdev_del(&second_devp->cdev);/*注销 cdev*/
   kfree(second_devp);/*释放设备结构体内存*/
   unregister_chrdev_region(MKDEV(second_major, 0), 1); /*释放设备号*/
}

MODULE_AUTHOR("Song Baohua");
MODULE_LICENSE("Dual BSD/GPL");
module_param(second_major, int, S_IRUGO);
module_init(second_init);
module_exit(second_exit);
在 second 的 open()函数中,将启动定时器,此后每 1s 会再次运行定时器处理函数,在 second 的 release()函数中,定时器被删除。
second_dev 结构体中的原子变量 counter 用于秒计数,每次在定时器处理函数中将被 atomic_inc()调用原子的增 1,second 的 read()函数会将这个值返回给用户空间。编译驱动,加载该内核模块并创建“/dev/second”设备文件结点后,使用下面代码应用程序打开“/dev/second”。
下面的应用程序 second_test 会不断地读取自打开“/dev/second”设备文件以来经历的秒数。
#include ...
main()
{
  int fd;
  int counter = 0;
  int old_counter = 0;
  /*打开/dev/second 设备文件*/
  fd = open("/dev/second", O_RDONLY);
  if (fd != - 1)
  {
   while (1)
   {
    read(fd,&counter, sizeof(unsigned int));//读目前经历的秒数
    if(counter!=old_counter)
    {
     printf("seconds after open /dev/second :%d\n",counter);
     old_counter = counter;
    }
   }
  }
  else
  {
    printf("Device open failure\n");
  }
}


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值