第二章:Linux内核基础与驱动模型

好,咱们开始聊内核。说实话,很多初学者一听到「Linux内核」四个字就发怵。我当年也一样,觉得那是大神才碰的东西。但后来我发现,其实内核没那么神秘——它就是个管理硬件资源的操作系统核心。你写的驱动,说白了就是内核和硬件之间的翻译官。

2.1 Linux内核源码结构

先看看内核源码长什么样。你从 kernel.org 下载一份源码,解压后能看到这些目录:

目录 作用
arch/ 架构相关代码,比如arm64、x86。麒麟芯片属于arm64
drivers/ 驱动代码的大本营。我们主要在这里干活
fs/ 文件系统实现,ext4、procfs都在这里
include/ 头文件,驱动开发常用的API声明都在这里
kernel/ 核心调度、进程管理
mm/ 内存管理
net/ 网络协议栈
Documentation/ 文档,我建议你多翻翻

我个人习惯,拿到一个新内核版本,先看 arch/arm64/drivers/ 这两个目录。为什么?因为麒麟芯片是ARM架构,驱动代码基本都塞在drivers里。你想想看,如果连硬件平台相关的代码在哪都不知道,后面怎么调试?

小技巧: 我刚开始看内核源码时,总想一口气读完。后来发现根本不可能。我的建议是——带着问题看。比如你想写一个I2C驱动,那就直接去 drivers/i2c/ 目录下找类似芯片的代码,边看边学。

2.2 内核模块编程——Hello World

驱动开发的第一课,永远是写一个内核模块。说白了,内核模块就是一段可以在运行时加载到内核的代码,不用重新编译整个内核。这在调试驱动时太方便了。

来看一个最简单的例子:

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>

static int __init hello_init(void)
{
    printk(KERN_INFO "Hello, Kirin 芯片驱动世界!\n");
    return 0;
}

static void __exit hello_exit(void)
{
    printk(KERN_INFO "模块卸载,再见!\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("你的名字");
MODULE_DESCRIPTION("一个简单的Hello模块");

嗯,这里要注意几个点:

  • __init__exit 是内核的宏,告诉内核这些函数只在初始化或卸载时使用,用完后可以释放内存
  • printk 是内核版的printf,输出到内核日志。我在项目中经常用它来调试,但正式代码里要慎用
  • MODULE_LICENSE("GPL") 必须写,否则内核会报「tainted」警告

编译这个模块需要Makefile:

obj-m := hello.o
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)

all:
    $(MAKE) -C $(KDIR) M=$(PWD) modules

clean:
    $(MAKE) -C $(KDIR) M=$(PWD) clean

然后执行 make,生成 hello.ko。用 insmod hello.ko 加载,rmmod hello 卸载。用 dmesg 看输出。

我曾经踩过的坑: 有一次我忘了写 MODULE_LICENSE("GPL"),结果模块加载时内核直接报错,说模块污染了内核。查了半天才发现是许可证问题。所以,记得加上。

2.3 字符设备驱动框架

字符设备是驱动开发中最基础的一类。什么是字符设备?就是像串口、LED、按键这种,按字节流读写数据的设备。我当年在麒麟芯片上调试第一个GPIO驱动,用的就是字符设备框架。

核心结构体是 struct file_operations,它定义了设备支持的操作:

static struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
    .write   = my_write,
};

注册字符设备的流程大概是:

  1. 调用 alloc_chrdev_region() 分配设备号
  2. 调用 cdev_init() 初始化cdev结构体
  3. 调用 cdev_add() 添加到内核
  4. 创建设备类并在 /dev/ 下生成节点

说白了,就是告诉内核:「嘿,这个设备号归我管,用户程序读写它时,调用我写的这些函数。」

核心要点: 字符设备驱动的本质,就是实现 file_operations 里的回调函数。你写的 read() 函数,就是用户调用 read(fd, buf, size) 时内核真正执行的代码。

2.4 platform驱动模型

platform驱动模型,是Linux内核为了解决「设备怎么和驱动配对」这个问题而设计的。你想想看,在PC上,设备是PCI插槽插上去的,系统能自动枚举。但在嵌入式系统里,很多设备是直接焊在板子上的,没有自发现能力。怎么办?

答案就是:用platform_device来描述设备,用platform_driver来描述驱动。内核通过名字匹配,把两者绑在一起。

一个典型的platform驱动长这样:

static struct platform_driver my_driver = {
    .probe  = my_probe,
    .remove = my_remove,
    .driver = {
        .name = "my_device",
        .of_match_table = my_of_match,
    },
};

module_platform_driver(my_driver);

当内核发现一个名字叫 my_device 的platform设备时,就会调用 my_probe 函数。我在麒麟芯片上调试I2C控制器驱动时,用的就是这套机制。probe函数里做硬件初始化、注册中断、创建字符设备等操作。

我的经验: 写platform驱动时,一定要把 proberemove 函数写干净。probe里申请的资源,remove里要全部释放。否则模块卸载时系统可能崩溃。

2.5 设备树(Device Tree)基础

设备树,简称DT。它是用来描述硬件信息的。为什么需要它?因为ARM架构不像x86那样有ACPI来自动发现硬件。以前的做法是把硬件信息硬编码在板级文件里,结果每个板子都要改内核代码,太痛苦了。

设备树用 .dts 文件描述硬件,编译成 .dtb 文件,内核启动时解析它。举个例子:

/ {
    model = "Kirin 开发板";
    compatible = "hisilicon,kirin";

    uart0: serial@ff000000 {
        compatible = "hisilicon,kirin-uart";
        reg = <0x0 0xff000000 0x0 0x1000>;
        interrupts = <0 38 4>;
        clock-frequency = <19200000>;
    };
};

这里描述了一个UART控制器:

  • compatible:驱动匹配的关键字,驱动里也要写一样的字符串
  • reg:寄存器基地址和大小
  • interrupts:中断号、触发类型
  • clock-frequency:时钟频率,自定义属性

在驱动里,通过 of_match_table 来匹配设备树节点:

static const struct of_device_id my_of_match[] = {
    { .compatible = "hisilicon,kirin-uart" },
    { /* 结束标记 */ },
};
注意: 设备树里的 reg 地址,一定要和芯片手册里的一致。我曾经因为看错地址,把UART的基地址写成了0xff000001,结果驱动死活不工作。查了两天才发现是地址对齐问题。

好了,这一章的内容就这些。内核源码结构让你知道去哪找代码,内核模块让你能动态加载驱动,字符设备框架是驱动的基础模板,platform模型解决了设备驱动配对问题,设备树让硬件描述和驱动代码分离。这些知识点,你在麒麟芯片驱动开发中会反复用到。

下一章,我们聊内存管理和中断处理——这两个是驱动开发里最容易出bug的地方。到时候我会分享一些我在项目中遇到的「血泪史」。