第四章 麒麟芯片中断与定时器驱动:从GIC到hrtimer

大家好,我是你们的老朋友。今天我们来聊聊麒麟芯片里一个绕不开的话题——中断与定时器。说实话,我刚入行那会儿,觉得中断就是“CPU被叫停,去干别的事”,但真正上手写驱动才发现,这里面的门道深着呢。尤其是麒麟芯片用的GIC(通用中断控制器),跟ARM标准GIC还有不少差异,踩坑是常有的事。

这一章,我会从GIC基础讲起,带你走一遍中断注册与处理的完整流程,再聊聊tasklet和workqueue这对“好兄弟”,最后落到内核定时器和hrtimer的驱动实现上。嗯,内容不少,但都是干货。

4.1 中断控制器(GIC)基础

GIC,全称Generic Interrupt Controller,是ARM架构下中断管理的核心。麒麟芯片用的GIC版本通常是GIC-400或GIC-500系列,支持SPI(共享外设中断)、PPI(私有外设中断)和SGI(软件生成中断)。

我个人习惯把GIC理解成一个“总机接线员”。外设要通知CPU,先打给GIC,GIC再根据优先级和亲和性,把电话转给合适的CPU核心。你想想看,如果没有这个接线员,所有外设直接喊CPU,那CPU得疯掉。

关键点:麒麟芯片的GIC初始化,必须在系统启动早期完成。我见过不少新手在probe函数里才去配置GIC,结果中断根本触发不了——因为GIC还没准备好。

GIC的寄存器空间分为两部分:Distributor(分发器)CPU Interface(CPU接口)。Distributor负责全局中断管理,比如使能、优先级、路由;CPU Interface则负责把中断信号送给具体哪个CPU核。

举个例子,你要使能一个SPI中断,需要操作GICD_ISENABLER寄存器。代码大概长这样:

// 使能SPI中断号32
#define GICD_BASE      0x2C001000
#define GICD_ISENABLER (GICD_BASE + 0x100)

void gic_enable_spi_irq(unsigned int irq_num) {
    unsigned int reg_offset = (irq_num / 32) * 4;
    unsigned int bit_mask = 1 << (irq_num % 32);
    writel(bit_mask, GICD_ISENABLER + reg_offset);
}

这里要注意,GIC的中断号是从0开始的,但Linux内核里IRQ号通常从16开始(前16个留给异常)。所以你在写驱动时,记得做偏移映射。我曾经因为没做这个映射,调试了整整一个下午,最后发现是中断号对不上。

4.2 中断注册与处理流程

在Linux内核里,注册中断的标准接口是request_irq()。但麒麟芯片的驱动开发中,我更推荐用devm_request_irq()——它能自动管理资源,省得你忘了释放。

中断处理流程,说白了就是三步:

  1. 中断发生:外设拉高中断线,GIC检测到后,查找路由表,决定发给哪个CPU。
  2. CPU响应:CPU保存现场,跳转到异常向量表,进入内核的中断处理入口。
  3. 执行handler:内核根据IRQ号,调用你注册的中断处理函数。

这里有个坑:中断处理函数是在中断上下文中执行的,不能睡眠,不能调用可能引起阻塞的函数。我刚开始写驱动时,在中断处理里调了printk,结果系统直接死锁——因为printk内部用了自旋锁。

警告:中断处理函数要快进快出。如果要做耗时操作,请用下半部机制(tasklet或workqueue)。

来看一个典型的中断注册示例:

static irqreturn_t my_irq_handler(int irq, void *dev_id) {
    // 快速处理:读取状态寄存器,清除中断标志
    unsigned int status = readl(MY_DEV_STATUS);
    if (status & MY_DEV_IRQ_FLAG) {
        writel(MY_DEV_IRQ_FLAG, MY_DEV_CLEAR);
        // 调度下半部
        tasklet_schedule(&my_tasklet);
        return IRQ_HANDLED;
    }
    return IRQ_NONE;
}

static int my_probe(struct platform_device *pdev) {
    int irq = platform_get_irq(pdev, 0);
    if (irq < 0) return irq;

    return devm_request_irq(&pdev->dev, irq, my_irq_handler,
                            IRQF_TRIGGER_RISING, "my_device", NULL);
}

注意IRQF_TRIGGER_RISING这个标志。麒麟芯片的GPIO中断,触发方式一定要跟硬件设计匹配。我曾经遇到一个案子,硬件工程师说用上升沿触发,结果实际是下降沿——板子一上电就疯狂进中断,差点烧了电机。

4.3 tasklet与workqueue:下半部的两种选择

中断处理分上半部和下半部。上半部处理紧急的、必须关中断做的事;下半部处理那些可以慢慢来的事。tasklet和workqueue就是下半部的两种实现。

tasklet:基于软中断实现,执行在中断上下文中,不能睡眠。它的特点是快,但限制多。我一般用它来处理简单的、确定性的工作,比如清除标志位、更新统计计数。

workqueue:基于内核线程实现,执行在进程上下文中,可以睡眠。适合做复杂操作,比如I2C读写、内存分配、文件操作。

怎么选?我有个简单的判断标准:

  • 如果下半部要做的事,能在几十微秒内完成,用tasklet。
  • 如果需要等待硬件响应、或者要调用可能阻塞的函数,用workqueue。
小技巧:麒麟芯片的某些外设(比如ISP、NPU)中断频率很高,用tasklet可能导致CPU占用过高。这时候可以考虑用 threaded IRQ(线程化中断),它本质上就是一个专用的workqueue。

tasklet的使用示例:

// 定义tasklet
static void my_tasklet_handler(unsigned long data) {
    // 这里做下半部处理,不能睡眠
    struct my_device *dev = (struct my_device *)data;
    dev->stats.irq_count++;
}

DECLARE_TASKLET(my_tasklet, my_tasklet_handler, (unsigned long)&my_dev);

// 在上半部中调度
tasklet_schedule(&my_tasklet);

workqueue的使用示例:

// 定义work
static void my_work_handler(struct work_struct *work) {
    struct my_device *dev = container_of(work, struct my_device, my_work);
    // 这里可以睡眠,比如读取传感器数据
    i2c_smbus_read_byte_data(dev->client, dev->reg);
}

// 初始化work
INIT_WORK(&my_dev.my_work, my_work_handler);

// 在上半部中调度
schedule_work(&my_dev.my_work);

嗯,这里要提醒一下:workqueue默认使用系统全局的system_wq,但如果你的驱动对实时性有要求,建议创建自己的workqueue。麒麟芯片的多核架构下,你可以把workqueue绑定到特定CPU上,避免缓存抖动。

4.4 内核定时器与高精度定时器(hrtimer)

定时器驱动,说白了就是让内核在未来的某个时间点执行一段代码。Linux内核提供了两种定时器:传统的timer_list和现代的hrtimer

传统内核定时器基于jiffies,精度受限于HZ值。比如HZ=100时,精度只有10ms。对于大多数外设(比如按键消抖、LED闪烁),这个精度够用了。

来看一个简单的timer_list示例:

static void my_timer_callback(struct timer_list *t) {
    struct my_device *dev = from_timer(dev, t, my_timer);
    // 定时到后执行的操作
    gpio_set_value(dev->led_pin, !gpio_get_value(dev->led_pin));
    // 重新启动定时器,实现周期性闪烁
    mod_timer(&dev->my_timer, jiffies + msecs_to_jiffies(500));
}

// 初始化定时器
timer_setup(&dev->my_timer, my_timer_callback, 0);
mod_timer(&dev->my_timer, jiffies + msecs_to_jiffies(500));

但如果你需要微秒甚至纳秒级的精度,传统定时器就不够用了。这时候要用hrtimer(高精度定时器)。hrtimer基于ktime,精度可以达到纳秒级,底层依赖硬件定时器(比如ARM的Generic Timer)。

麒麟芯片的hrtimer驱动实现,我建议直接使用hrtimer_start()接口。注意,hrtimer的回调函数运行在软中断上下文中,不能睡眠。

static enum hrtimer_restart my_hrtimer_callback(struct hrtimer *hrt) {
    struct my_device *dev = container_of(hrt, struct my_device, my_hrtimer);
    // 高精度定时任务
    dev->sample_counter++;

    // 如果需要周期性触发,返回HRTIMER_RESTART并设置下次时间
    hrtimer_forward_now(hrt, ktime_set(0, 1000000)); // 1ms周期
    return HRTIMER_RESTART;
}

// 初始化hrtimer
hrtimer_init(&dev->my_hrtimer, CLOCK_MONOTONIC, HRTIMER_MODE_REL);
dev->my_hrtimer.function = my_hrtimer_callback;
hrtimer_start(&dev->my_hrtimer, ktime_set(0, 1000000), HRTIMER_MODE_REL);
避坑指南:hrtimer的回调函数里,不要调用任何可能引起调度的函数(比如kmalloc、mutex_lock)。我曾经在hrtimer回调里调了printk,结果导致系统响应延迟飙升——因为printk内部会尝试获取控制台锁,而那个锁可能被其他进程持有。

另外,hrtimer的时钟源选择也很重要。麒麟芯片通常支持CLOCK_MONOTONICCLOCK_REALTIME。我个人习惯用CLOCK_MONOTONIC,因为它不受系统时间调整的影响,适合做周期性任务。

小结

这一章我们聊了GIC的基础配置、中断注册与处理流程、下半部的两种实现方式,以及定时器驱动的两种选择。说实话,中断和定时器是嵌入式驱动开发中最容易出问题的部分——不是代码写不对,而是对硬件行为理解不到位。

下一章,我们会深入麒麟芯片的DMA驱动,看看怎么用DMA实现零拷贝数据传输。嗯,那又是一个充满坑的世界,但我会把踩过的坑都告诉你。

公众号:蓝海资料掘金营,微信deep3321