Linux驱动开发:中断处理与并发控制,从上半部到锁机制一次讲透
中断是外设与CPU沟通的桥梁,并发控制是多核场景下数据安全的底线。这两块搞不清楚,写出来的驱动迟早翻车。
一、中断是什么?
CPU跑得快,外设跑得慢,如果CPU一直轮询外设状态,大部分时间都在空转——这跟等快递时每分钟打一次电话问到了没一个道理。
中断就是让外设"到了叫我",CPU该干嘛干嘛,外设就绪后主动发信号通知CPU。CPU收到信号后暂停当前任务,去处理外设请求,处理完再回来接着干。
中断 vs 轮询
| 维度 | 中断方式 | 轮询方式 |
|---|---|---|
| CPU利用率 | 高,空闲时做别的 | 低,反复查询浪费周期 |
| 响应延迟 | 低,信号到达立即响应 | 取决于轮询间隔 |
| 实现复杂度 | 较高,需注册handler、处理并发 | 简单,循环读状态 |
| 适用场景 | 事件驱动、低频外设 | 高频数据流、无中断线设备 |
| 功耗 | 低,CPU可进入低功耗模式 | 高,CPU持续运行 |
中断类型
Linux把中断分成两大类。
- 硬件中断(IRQ):外设通过中断控制器发来的电信号,CPU必须立即响应,比如按键按下、网卡收包、定时器到期。- 软件中断(softirq):内核模拟的中断机制,用于推迟执行耗时操作。softirq、tasklet、workqueue都属于这一类。
硬件中断优先级最高,会打断一切;软件中断在硬中断处理完毕后、进程调度之前执行。
二、中断处理流。
中断来了,CPU不是随便跳转就完事,有一套严格的流程。
上半部 vs 下半部
这是Linux中断处理的核心设计哲学:
| 维度 | 上半部(硬中断) | 下半部(软中断) |
|---|---|---|
| 执行上下文 | 中断上下文 | 中断上下文或进程上下文 |
| 中断状态 | 关中断或部分分支 | 开中断 |
| 执行时间 | 必须极短 | 可以较长 |
| 能否睡眠 | 绝对不行 | workqueue可以 |
| 典型操作 | 读取硬件状态、ACK | 数据处理、协议解析 |
| 机制 | irq_handler | softirq/tasklet/workqueue |
一句话上半部只做最紧急的事,剩下的推给下半部 你在中断handler里磨蹭,整个系统的实时性都得跟着遭殃。
三、中断注册与处理
request_irq / free_irq
Linux 6.8中注册中断的标准接口。
#include <linux/interrupt.h>
// 注册中断
int request_irq(unsigned int irq,
irq_handler_t handler,
unsigned long flags,
const char *name,
void *dev);
// 释放中断
void free_irq(unsigned int irq, void *dev);
参数说明。
- irq:IRQ编号,从设备树或平台资源获取
- handler:中断处理函数- flags:中断标志位
- name。proc/interrupts中显示的名称
- dev:传递给handler的私有数据,共享中断时用于区。
irq_handler_t 签名与返回调
typedef irqreturn_t (*irq_handler_t)(int irq, void *dev_id);
返回值:
- IRQ_HANDLED:中断已处理
- IRQ_NONE:不是我的中断(共享中断时用。
共享中断:IRQF_SHARED
多个设备共用一条中断线时,必须设置IRQF_SHARED标志,且dev_id不能为NULL(内核靠它区分是谁的中断):
// 共享中断注册
ret = request_irq(irq, my_handler, IRQF_SHARED, "my_device", &my_dev);
所有共享同一个IRQ的handler都会被调用,每个handler检查dev_id判断是不是自己的设备产生的中断,不是就返回IRQ_NONE。
线程化中断:request_threaded_irq
Linux 6.8推荐使用线程化中断,把耗时的下半部工作放到内核线程中执行:
int request_threaded_irq(unsigned int irq,
irq_handler_t handler, // 上半部,可为NULL
irq_handler_t thread_fn, // 线程化下半部
unsigned long flags,
const char *name,
void *dev);
handler返回IRQ_WAKE_THREAD时,内核会唤醒thread_fn对应的内核线程来执行,线程化中断的好处:下半部运行在进程上下文,可以睡眠,RT-Linux友好。
完整代码示例:按键中断驱动
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/interrupt.h>
#include <linux/gpio.h>
#include <linux/of.h>
#include <linux/platform_device.h>
#define DRIVER_NAME "btn_irq_demo"
struct btn_priv {
int gpio;
int irq;
struct device *dev;
};
// 上半部:读取GPIO状态,决定是否唤醒线程
static irqreturn_t btn_hard_handler(int irq, void *dev_id)
{
struct btn_priv *priv = dev_id;
int val = gpio_get_value(priv->gpio);
dev_info(priv->dev, "hard IRQ: GPIO=%d val=%d\n", priv->gpio, val);
// 唤醒线程化下半部
return IRQ_WAKE_THREAD;
}
// 线程化下半部:可以睡眠,做耗时操作
static irqreturn_t btn_thread_fn(int irq, void *dev_id)
{
struct btn_priv *priv = dev_id;
int val = gpio_get_value(priv->gpio);
dev_info(priv->dev, "thread: GPIO=%d val=%d\n", priv->gpio, val);
// 这里可以做msleep、kmalloc(GFP_KERNEL)等操。 return IRQ_HANDLED;
}
static int btn_probe(struct platform_device *pdev)
{
struct device *dev = &pdev->dev;
struct btn_priv *priv;
struct device_node *np = dev->of_node;
int ret;
priv = devm_kzalloc(dev, sizeof(*priv), GFP_KERNEL);
if (!priv)
return -ENOMEM;
priv->dev = dev;
// 从设备树获取GPIO
priv->gpio = of_get_named_gpio(np, "btn-gpio", 0);
if (!gpio_is_valid(priv->gpio))
return dev_err_probe(dev, -EINVAL, "invalid btn-gpio\n");
ret = devm_gpio_request(dev, priv->gpio, "btn_gpio");
if (ret)
return dev_err_probe(dev, ret, "gpio request failed\n");
gpio_direction_input(priv->gpio);
// 获取IRQ。 priv->irq = gpio_to_irq(priv->gpio);
if (priv->irq < 0)
return dev_err_probe(dev, priv->irq, "gpio_to_irq failed\n");
// 注册线程化中断,下降沿触。 ret = devm_request_threaded_irq(dev, priv->irq,
btn_hard_handler,
btn_thread_fn,
IRQF_TRIGGER_FALLING,
DRIVER_NAME, priv);
if (ret)
return dev_err_probe(dev, ret, "request threaded irq failed\n");
platform_set_drvdata(pdev, priv);
dev_info(dev, "button irq driver probed, gpio=%d irq=%d\n",
priv->gpio, priv->irq);
return 0;
}
static const struct of_device_id btn_of_match[] = {
{ .compatible = "linuxros,btn-irq" },
{ /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, btn_of_match);
static struct platform_driver btn_driver = {
.probe = btn_probe,
.driver = {
.name = DRIVER_NAME,
.of_match_table = btn_of_match,
},
};
module_platform_driver(btn_driver);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("linuxros.cn");
MODULE_DESCRIPTION("Button IRQ driver demo (Linux 6.8)");
对应的设备树节点。
btn_irq {
compatible = "linuxros,btn-irq";
btn-gpio = <&gpio0 5 GPIO_ACTIVE_LOW>;
};
四、下半部机制
上半部必须快,那耗时操作往哪推?三种机制各有适用场景:
三种机制对比
| 维度 | softirq | tasklet | workqueue |
|---|---|---|---|
| 执行上下文 | 中断上下文 | 中断上下文 | 进程上下文 |
| 能否睡眠 | 不能 | 不能 | 可以 |
| 并发度 | 同类型可多CPU并行 | 同类型严格串行 | 每个worker独立 |
| 使用途 | 内核核心(网络、块设备等) | 驱动开发 | 驱动开发 |
| 灵活度 | 低,需编译时注册 | 中 | 高 |
| 开销 | 最低 | 无 | 较大(线程调度) |
softirq
最底层的软中断机制。6.8内核中只有网络收发(NET_RX/NET_TX)、块设备、定时器等核心子系统在用。驱动开发者一般不直接碰softirq,内核也不建议导出相关API给模块使用。
softirq在__do_softirq()中执行,同一个softirq可以在多个CPU上同时运行,所以softirq的handler必须自己处理多核并发。
tasklet
基于softirq实现的轻量级延迟机制,是驱动开发中最常用的下半部方案。
// 定义并初始化
DECLARE_TASKLET(my_tasklet, my_tasklet_func, (unsigned long)&my_data);
// 或者动态初始化
struct tasklet_struct my_tasklet;
tasklet_init(&my_tasklet, my_tasklet_func, (unsigned long)&my_data);
// 在中断handler中调试tasklet_schedule(&my_tasklet);
// 不用了要销毁
tasklet_kill(&my_tasklet);
tasklet的关键特性:同一个tasklet不会在多个CPU上同时执行,但不同tasklet可以并行。这比softirq安全得多,代价是同一tasklet的执行是串行的。
workqueue
运行在进程上下文的延迟执行机制,可以睡眠、可以调度、可以执行耗时操作。集
// 静态定义
DECLARE_WORK(my_work, my_work_func);
DECLARE_DELAYED_WORK(my_dwork, my_dwork_func);
// 动态初始化
INIT_WORK(&my_work, my_work_func);
INIT_DELAYED_WORK(&my_dwork, my_dwork_func);
// 调度执行
schedule_work(&my_work);
schedule_delayed_work(&my_dwork, msecs_to_jiffies(100));
// 等待完成
flush_work(&my_work);
cancel_delayed_work_sync(&my_dwork);
workqueue的handler函数签名。
void my_work_func(struct work_struct *work);
6.8内核默认使用system_wq(per-CPU的worker pool),也可以用alloc_workqueue()创建自定义workqueue。
选择指南
| 场景 | 推荐机制 | 理由 |
|---|---|---|
| 简单延迟操作,不需睡眠 | tasklet | 轻量、安全、够用 |
| 需要睡眠(kmalloc、msleep等) | workqueue | 进程上下文,允许睡眠 |
| 需要定时延迟执行 | delayed_work | workqueue的定时版本 |
| 高频网络包处理 | softirq(NAPI) | 内核网络栈已实现 |
| 需要指定CPU执行 | 自定义workqueue | 设置WQ_CPU_INTENSIVE |
经验法则:拿不准就用workqueue,它最安全。tasklet虽然轻量,但在中断上下文执行的约束太多。
五、并发控制原。
中断随时可能打断当前执行流,多核CPU上多个handler可能同时跑——共享数据不加保护,数据竞争只是时间问题。
自旋锁(spinlock。
自旋锁是最基本的中断上下文同步原语。拿不到锁就原地自旋(忙等),不会让出CPU。
#include <linux/spinlock.h>
// 定义
spinlock_t my_lock;
// 初始化spin_lock_init(&my_lock);
// 基本用法
spin_lock(&my_lock);
// 临界区
spin_unlock(&my_lock);
关键问题:如果进程拿着spinlock,中断handler也要拿同一个锁——死锁。因为中断会打断进程,handler发现锁被自己打断的进程拿着,永远等不到释放。
解决方案——中断安全版本:
// 关本地中断版本
spin_lock_irq(&my_lock);
// 临界区
spin_unlock_irq(&my_lock);
// 保存/恢复中断标志版本(推荐)
unsigned long flags;
spin_lock_irqsave(&my_lock, flags);
// 临界区
spin_lock_restore(&my_lock, flags);
spin_lock_irqsave把当前CPU的中断状态保存到flags,然后关中断再拿锁。spin_unlock_irqrestore恢复之前的中断状态。这比spin_lock_irq安全,因为你不知道调用时中断是开还是关。
互斥锁(mutex。
mutex用于进程上下文的同步,拿不到锁就睡眠让出CPU。
#include <linux/mutex.h>
// 定义和初始化
DEFINE_MUTEX(my_mutex);
// 或动态初始化
struct mutex my_mutex;
mutex_init(&my_mutex);
// 基本用法
mutex_lock(&my_mutex);
// 临界区(可以睡眠)
mutex_unlock(&my_mutex);
// 可中断版本(推荐用于用户空间可触发的路径)
if (mutex_lock_interruptible(&my_mutex))
return -ERESTARTSYS; // 被信号打断
// 临界区
mutex_unlock(&my_mutex);
*铁律:中断上下文绝对不能用mutex mutex拿不到会睡眠,中断上下文不允许睡眠。
原子操作
不需要锁的轻量级并发控制,适合简单的计数器操作:
#include <linux/atomic.h>
// 定义和初始化
atomic_t counter = ATOMIC_INIT(0);
// 操作
atomic_inc(&counter); // +1
atomic_dec(&counter); // -1
atomic_add(5, &counter); // +5
atomic_sub(3, &counter); // -3
int val = atomic_read(&counter); // 。
// 带返回调int new_val = atomic_inc_return(&counter); // +1并返回新。
// 条件操作
atomic_dec_and_test(&counter); // -1,结果为0返回true
atomic_cmpxchg(&counter, old, new); // CAS操作
6.8内核还提供了atomic64_t和atomic_long_t,用法相同。
读写锁(rwlock)与 RCU
读多写少场景的优化方案:
rwlock——读写自旋锁。
#include <linux/rwlock.h>
rwlock_t my_rwlock;
rwlock_init(&my_rwlock);
// 读端(可多读者并行)
read_lock(&my_rwlock);
// 只读临界区
read_unlock(&my_rwlock);
// 写端(独占)
write_lock_irqsave(&my_rwlock, flags);
// 写临界区
write_unlock_irqrestore(&my_rwlock, flags);
RCU——读端几乎零开销。
#include <linux/rcupdate.h>
// 读端
rcu_read_lock();
ptr = rcu_dereference(global_ptr);
// 使用ptr,不能睡眠
rcu_read_unlock();
// 写端
new_ptr = kmalloc(sizeof(*new_ptr), GFP_KERNEL);
*new_ptr = *old_ptr; // 拷贝
new_ptr->field = new_val; // 修改
rcu_assign_pointer(global_ptr, new_ptr); // 原子更新指针
synchronize_rcu(); // 等待所有读者退出
kfree(old_ptr); // 安全释放旧数```
RCU的读端不加锁不写内存屏障,开销极低。代价是写端要等所有读者退出才能释放旧数据,适合读远多于写的场景:
### 完成量(completion。
用于"一个执行流通知另一个执行流完成"的场景:
```c
#include <linux/completion.h>
// 定义和初始化
DECLARE_COMPLETION(my_comp);
// 静态定义
struct completion my_comp;
init_completion(&my_comp);
// 等待
wait_for_completion(&my_comp); // 不可中断等待
wait_for_completion_interruptible(&my_comp); // 可中断
wait_for_completion_timeout(&my_comp, timeout); // 超时
// 完成
complete(&my_comp); // 唤醒一个等待者
complete_all(&my_comp); // 唤醒所有等待者
典型场景:中断handler中complete(),进程上下文wait_for_completion()。
各原语对。
| 原语 | 适用上下文 | 可否睡眠 | 开销 | 典型场景 |
|---|---|---|---|---|
| spinlock | 中断+进程 | 不能 | 中断handler与进程共享数据 | |
| spin_lock_irqsave | 中断+进程 | 不能 | 防止中断打断导致的死锁 | |
| mutex | 仅进程 | 可以 | 中 | 进程间互斥 |
| atomic | 任意 | 不能 | 最低 | 简单计数器 |
| rwlock | 中断+进程 | 不能 | 读多写少 | |
| RCU | 读:任意;写:进程 | 读不能,写可以 | 读极低 | 读远多于写 |
| completion | 进程等待 | 等待时可以 | 低 | 中断通知进程 |
六、完整示例:中断+并发控制
把前面的知识点串起来:按键中断上半部读取GPIO状态,tasklet做延迟处理,spinlock保护共享数据。
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/interrupt.h>
#include <linux/gpio.h>
#include <linux/spinlock.h>
#include <linux/of.h>
#include <linux/platform_device.h>
#include <linux/uaccess.h>
#include <linux/cdev.h>
#include <linux/fs.h>
#define DRIVER_NAME "btn_concurrent"
#define BUF_SIZE 64
struct btn_priv {
int gpio;
int irq;
struct device *dev;
dev_t devt;
struct cdev cdev;
struct class *class;
// 共享数据:自旋锁保护
spinlock_t lock;
int event_count;
int last_gpio_val;
char buf[BUF_SIZE];
int buf_len;
// 下半。 struct tasklet_struct tlet;
};
// tasklet处理函数:在中断上下文执行,不能睡眠
static void btn_tasklet_func(unsigned long data)
{
struct btn_priv *priv = (struct btn_priv *)data;
unsigned long flags;
int count, val;
char local_buf[BUF_SIZE];
int local_len;
// 拷贝共享数据到本地,减少临界区时。 spin_lock_irqsave(&priv->lock, flags);
count = priv->event_count;
val = priv->last_gpio_val;
local_len = priv->buf_len;
if (local_len > 0)
memcpy(local_buf, priv->buf, local_len);
spin_unlock_irqrestore(&priv->lock, flags);
// 在本地处理数据,不持。 dev_info(priv->dev, "tasklet: count=%d val=%d\n", count, val);
}
// 中断上半部:只做最紧急的操作
static irqreturn_t btn_handler(int irq, void *dev_id)
{
struct btn_priv *priv = dev_id;
unsigned long flags;
int val;
val = gpio_get_value(priv->gpio);
// 快速更新共享数。 spin_lock(&priv->lock); // handler中中断已关,用spin_lock即可
priv->event_count++;
priv->last_gpio_val = val;
priv->buf_len = snprintf(priv->buf, BUF_SIZE, "evt:%d val:%d\n",
priv->event_count, val);
spin_unlock(&priv->lock);
// 调度下半。 tasklet_schedule(&priv->tlet);
return IRQ_HANDLED;
}
// 用户空间读取中断事件计数
static ssize_t btn_read(struct file *filp, char __user *ubuf,
size_t count, loff_t *ppos)
{
struct btn_priv *priv = filp->private_data;
unsigned long flags;
char kbuf[BUF_SIZE];
int len;
spin_lock_irqsave(&priv->lock, flags);
len = snprintf(kbuf, BUF_SIZE, "events=%d last_val=%d\n",
priv->event_count, priv->last_gpio_val);
spin_unlock_irqrestore(&priv->lock, flags);
if (*ppos >= len)
return 0;
if (count > len - *ppos)
count = len - *ppos;
if (copy_to_user(ubuf, kbuf + *ppos, count))
return -EFAULT;
*ppos += count;
return count;
}
static int btn_open(struct inode *inode, struct file *filp)
{
struct btn_priv *priv = container_of(inode->i_cdev,
struct btn_priv, cdev);
filp->private_data = priv;
return 0;
}
static const struct file_operations btn_fops = {
.owner = THIS_MODULE,
.open = btn_open,
.read = btn_read,
};
static int btn_probe(struct platform_device *pdev)
{
struct device *dev = &pdev->dev;
struct btn_priv *priv;
struct device_node *np = dev->of_node;
int ret;
priv = devm_kzalloc(dev, sizeof(*priv), GFP_KERNEL);
if (!priv)
return -ENOMEM;
priv->dev = dev;
spin_lock_init(&priv->lock);
// GPIO和IRQ初始化 priv->gpio = of_get_named_gpio(np, "btn-gpio", 0);
if (!gpio_is_valid(priv->gpio))
return dev_err_probe(dev, -EINVAL, "invalid btn-gpio\n");
ret = devm_gpio_request(dev, priv->gpio, "btn_gpio");
if (ret)
return dev_err_probe(dev, ret, "gpio request failed\n");
gpio_direction_input(priv->gpio);
priv->irq = gpio_to_irq(priv->gpio);
if (priv->irq < 0)
return dev_err_probe(dev, priv->irq, "gpio_to_irq failed\n");
// 初始化tasklet
tasklet_init(&priv->tlet, btn_tasklet_func, (unsigned long)priv);
// 注册中断
ret = devm_request_irq(dev, priv->irq, btn_handler,
IRQF_TRIGGER_FALLING,
DRIVER_NAME, priv);
if (ret)
return dev_err_probe(dev, ret, "request irq failed\n");
// 字符设备注册
ret = alloc_chrdev_region(&priv->devt, 0, 1, DRIVER_NAME);
if (ret)
return dev_err_probe(dev, ret, "alloc chrdev failed\n");
cdev_init(&priv->cdev, &btn_fops);
priv->cdev.owner = THIS_MODULE;
ret = cdev_add(&priv->cdev, priv->devt, 1);
if (ret)
goto err_region;
priv->class = class_create(DRIVER_NAME);
if (IS_ERR(priv->class)) {
ret = PTR_ERR(priv->class);
goto err_cdev;
}
if (IS_ERR(device_create(priv->class, NULL, priv->devt,
NULL, DRIVER_NAME))) {
ret = -ENOMEM;
goto err_class;
}
platform_set_drvdata(pdev, priv);
dev_info(dev, "probed: gpio=%d irq=%d dev=%d:%d\n",
priv->gpio, priv->irq, MAJOR(priv->devt), MINOR(priv->devt));
return 0;
err_class:
class_destroy(priv->class);
err_cdev:
cdev_del(&priv->cdev);
err_region:
unregister_chrdev_region(priv->devt, 1);
return ret;
}
static void btn_remove(struct platform_device *pdev)
{
struct btn_priv *priv = platform_get_drvdata(pdev);
tasklet_kill(&priv->tlet);
device_destroy(priv->class, priv->devt);
class_destroy(priv->class);
cdev_del(&priv->cdev);
unregister_chrdev_region(priv->devt, 1);
dev_info(&pdev->dev, "removed\n");
}
static const struct of_device_id btn_of_match[] = {
{ .compatible = "linuxros,btn-concurrent" },
{ /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, btn_of_match);
static struct platform_driver btn_driver = {
.probe = btn_probe,
.remove = btn_remove,
.driver = {
.name = DRIVER_NAME,
.of_match_table = btn_of_match,
},
};
module_platform_driver(btn_driver);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("linuxros.cn");
MODULE_DESCRIPTION("Button IRQ + concurrency control demo (Linux 6.8)");
这个例子覆盖了几个关键点。
1. spinlock保护共享数据:event_count、last_gpio_val、buf被中断handler和read函数共享,必须加锁。2. handler中用spin_lock:硬中断handler执行时本地CPU中断已关,用spin_lock就够。3. 进程上下文用spin_lock_irqsave:read函数可能被中断打断,必须保存中断标志
4. tasklet做延迟处理:handler只更新数据、调度tasklet,tasklet做后续处理。5. 临界区尽量短**:tasklet中先持锁拷贝数据到本地,释放锁后再处理。
七、常见问题
*Q1:中断handler里调用kmalloc(GFP_KERNEL)会怎样
会死。GFP_KERNEL意味着允许睡眠(内核可能需要换页、刷磁盘),中断上下文不允许睡眠。必须用GFP_ATOMIC,它不会睡眠,代价是分配成功率略低。同理,中断handler里也不能调用msleep()、mutex_lock()、copy_from_user()等可能阻塞的函数。
Q2:spin_lock在中断handler里导致死锁怎么破?
进程拿着spinlock时被中断打断,handler也要拿同一个锁——死锁。解法:进程上下文用spin_lock_irqsave(),拿锁前先关本地CPU中断。handler里用spin_lock()即可(因为中断进来时本地CPU中断已关)。记住一条:谁怕被打断谁关中断。
Q3:tasklet和workqueue怎么选?
需要睡眠(调kmalloc(GFP_KERNEL)、msleep、等IO)就用workqueue。不需要睡眠且对延迟敏感就用tasklet,它在中断上下文执行,调度开销更小。拿不准就workqueue,它最安全,性能差距在大多数场景下可以忽略。
八、总结
| 知识 | 关键结论 |
|---|---|
| 中断本质 | 外设主动通知CPU,避免轮询空转 |
| 上半部/下半部 | 上半部快进快出,耗时操作推迟到下半部 |
| request_threaded_irq | 6.8推荐,下半部运行在内核线程可睡眠 |
| tasklet | 中断上下文,不能睡眠,同类型串行 |
| workqueue | 进程上下文,可以睡眠,最安全 |
| spin_lock_irqsave | 进程上下文防死锁的标准写法 |
| mutex | 仅进程上下文,可睡眠,中断中禁用 |
| atomic | 最轻量,适合简单计数器 |
| GFP_ATOMIC | 中断上下文唯一的内存分配标志 |
本文首发于linuxros.cn,转载请注明出处。