第二章: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里。你想想看,如果连硬件平台相关的代码在哪都不知道,后面怎么调试?
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,
};
注册字符设备的流程大概是:
- 调用
alloc_chrdev_region()分配设备号 - 调用
cdev_init()初始化cdev结构体 - 调用
cdev_add()添加到内核 - 创建设备类并在
/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函数里做硬件初始化、注册中断、创建字符设备等操作。
probe 和 remove 函数写干净。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的地方。到时候我会分享一些我在项目中遇到的「血泪史」。