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

说实话,很多刚接触嵌入式Linux驱动开发的朋友,一上来就急着写代码。我当年也是这样,结果折腾半天连编译都过不了。后来我才明白,内核基础不牢,后面全是坑。这一章,咱们就把地基打扎实。

2.1 Linux内核源码结构

先聊聊内核源码的目录结构。你下载一份内核源码,解压后看到一堆文件夹,别慌。我习惯先看顶层目录,心里有个谱。

几个关键目录:

  • arch/:架构相关代码。比如arm、x86、mips等。咱们做嵌入式,重点关注arch/arm或arch/arm64。
  • drivers/:驱动代码的大本营。里面按设备类型分,比如char、net、i2c、spi等。我们的字符设备驱动,通常放在drivers/char/下。
  • fs/:文件系统实现。VFS(虚拟文件系统)的核心在这里。
  • include/:头文件。驱动开发常用的都在这里。
  • kernel/:内核核心功能,比如调度、进程管理。
  • mm/:内存管理。
  • Documentation/:文档。嗯,虽然有时候文档更新不及时,但总比没有强。

我个人有个习惯:拿到一个新内核,先看Documentation/目录下的文件列表,能快速了解这个版本有哪些新特性。

2.2 内核编译与模块编译

编译内核,说白了就是配置+编译两步。但这里面的门道不少。

2.2.1 内核编译

标准流程:

make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- defconfig
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- menuconfig
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- -j4

第一步生成默认配置,第二步按需调整,第三步开编。注意-j4表示4个线程并行编译,根据你电脑CPU核数来设。

我曾经踩过的坑:交叉编译工具链版本必须与内核版本匹配。有一次我用gcc 9.x编译一个老内核,结果链接阶段各种报错。后来换成gcc 7.x才搞定。所以,先确认工具链版本,别盲目用最新的。

2.2.2 模块编译

驱动开发中,我们更多是编译模块,而不是整个内核。模块编译的Makefile写法有固定套路:

obj-m := my_driver.o
KERNELDIR := /path/to/kernel/source
PWD := $(shell pwd)

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

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

然后执行:

make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf-

生成.ko文件,这就是你的驱动模块。用insmod加载,rmmod卸载。

小技巧:调试阶段,我习惯在Makefile里加一行CFLAGS_my_driver.o += -DDEBUG,这样代码里的printk(KERN_DEBUG ...)就能输出调试信息。上线前再删掉。

2.3 字符设备驱动基础框架

字符设备驱动,是Linux驱动里最基础、最典型的一类。说白了,就是按字节流读写数据的设备,比如串口、LED、按键等。

一个最简单的字符设备驱动框架,包含这几部分:

  1. 模块入口/出口函数:module_init和module_exit。
  2. 设备号申请/释放:register_chrdev_region或alloc_chrdev_region。
  3. cdev结构体初始化与添加:cdev_init和cdev_add。
  4. file_operations结构体实现:这是核心,后面细讲。

来看一个骨架代码:

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>

static int major = 0;
static struct cdev my_cdev;

static int __init my_driver_init(void)
{
    dev_t devno;
    int ret;

    // 动态分配设备号
    ret = alloc_chrdev_region(&devno, 0, 1, "my_device");
    if (ret < 0) {
        printk(KERN_ERR "alloc_chrdev_region failed\n");
        return ret;
    }
    major = MAJOR(devno);

    // 初始化cdev
    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;

    // 添加cdev到内核
    ret = cdev_add(&my_cdev, devno, 1);
    if (ret) {
        printk(KERN_ERR "cdev_add failed\n");
        unregister_chrdev_region(devno, 1);
        return ret;
    }

    printk(KERN_INFO "my_driver loaded, major=%d\n", major);
    return 0;
}

static void __exit my_driver_exit(void)
{
    dev_t devno = MKDEV(major, 0);
    cdev_del(&my_cdev);
    unregister_chrdev_region(devno, 1);
    printk(KERN_INFO "my_driver unloaded\n");
}

module_init(my_driver_init);
module_exit(my_driver_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");

你可能会问:为什么用alloc_chrdev_region而不是register_chrdev_region?我个人建议新手用动态分配,省得跟别人冲突。等产品定型了,再考虑固定设备号。

2.4 file_operations结构体

这个结构体,是字符设备驱动的灵魂。它把用户空间的系统调用(open、read、write等)和内核空间的驱动函数关联起来。

定义在中,核心成员如下:

成员 对应系统调用 说明
.open open() 打开设备,通常做初始化
.release close() 关闭设备,做清理工作
.read read() 从设备读取数据
.write write() 向设备写入数据
.unlocked_ioctl ioctl() 设备控制命令
.llseek lseek() 修改文件读写位置

来看一个实际例子:

static int my_open(struct inode *inode, struct file *filp)
{
    printk(KERN_INFO "device opened\n");
    return 0;
}

static int my_release(struct inode *inode, struct file *filp)
{
    printk(KERN_INFO "device closed\n");
    return 0;
}

static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t count, loff_t *f_pos)
{
    // 注意:这里要用copy_to_user,不能直接memcpy
    // 因为用户空间和内核空间地址不能直接互访
    printk(KERN_INFO "read called, count=%zu\n", count);
    return 0;
}

static ssize_t my_write(struct file *filp, const char __user *buf,
                        size_t count, loff_t *f_pos)
{
    // 同理,用copy_from_user
    printk(KERN_INFO "write called, count=%zu\n", count);
    return count;
}

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

重点提醒:read和write函数里,数据拷贝必须用copy_to_user/copy_from_user。我见过有人直接memcpy,结果内核崩溃。为什么?因为用户空间的指针可能是虚拟地址,内核不能直接访问。这是新手最容易犯的错误之一。

另外,.owner = THIS_MODULE这行不能省。它告诉内核,这个结构体属于哪个模块。当模块被卸载时,如果还有进程在使用这个设备,内核会阻止卸载。

嗯,说到这我想起一个案例。有次我写一个LED驱动,open函数里做了GPIO申请,release里忘了释放。结果反复打开关闭设备几次后,GPIO资源就耗尽了。所以,open和release一定要成对考虑,申请的资源必须释放。

小结

这一章的内容,说白了就是驱动开发的"Hello World"。内核源码结构让你知道文件放哪,编译流程让你能跑起来,字符设备框架和file_operations让你知道怎么写。下一章,我们会在这个基础上,加入实际硬件操作,让设备真正动起来。

记住:驱动开发,七分在框架,三分在逻辑。框架搭对了,后面就顺了。