03. 编写第一个驱动

在学习编程的旅程中,无论是初涉C语言还是探索其他编程语言,我们往往以一句经典的 “ Hello, World! ” 作为起点,以此叩开代码世界的大门。这一简单而富有仪式感的传统,不仅象征着程序员与计算机之间的第一次对话,更承载了无数开发者对技术的热爱与追求。同样地,在驱动程序编程的领域,我们也可以延续这一传统,以 “ Hello, World! ” 作为我们的首个驱动程序,开启通往底层系统开发的奇妙之旅。

接下来,我们将着手编写第一个驱动程序——一个名为 “ Hello, World! ” 的简单驱动模块。这不仅是我们迈向驱动开发的第一步,更是理解Linux内核机制的重要起点。通过这段代码,我们将初步领略驱动程序的基本结构、加载与卸载流程,以及与内核交互的方式。准备好了吗?让我们一起开始这段充满挑战与乐趣的驱动开发之旅吧!

一、内核的基本框架

Linux 内核模块(驱动)开发,最基本的框架由三部分组成:

  • 加载函数(Module Entry Point)

  • 这是模块/驱动的初始化入口函数。

  • 作用是在驱动被加载(insmod)时完成资源的分配、硬件的初始化或数据结构的建立等准备工作。
  • 通常用 static int __init xxx_init(void) 定义,并通过 module_init() 宏注册。
  • 加载失败时可通过返回非零值让模块加载中断。
  • 卸载函数(Module Exit Point)

  • 当驱动被卸载(rmmod)时自动调用。

  • 作用是做资源释放、注销设备和数据结构清理,确保不会造成内存泄漏或系统不稳定。
  • 用 static void __exit xxx_exit(void) 定义,并通过 module_exit() 宏注册。
  • 许可证声明(License Declaration)

  • 使用 MODULE_LICENSE("GPL v2") 或类似宏声明许可证。

  • 作用是表明你的模块遵循哪种开源协议,告知内核你的代码符合开源要求。
  • 如果不声明,内核会做安全处理,并标记模块为“不受信任”(tainted),也影响某些功能调用。

任何一个最简单、最基础的 Linux 内核驱动模块,都必须有:

  • 明确的加载与卸载流程(开闭原则,保障资源安全)
  • 清晰的法律许可声明(保障合规与内核兼容)

其他补充(根据实际需求可添加):

  • 模块参数:允许模块加载时传递参数,提高灵活性
  • 导出符号:实现不同模块/驱动间的功能共享
  • 作者和描述信息:便于维护和模块识别

二、驱动文件夹准备

我们在自己的工作目录中创建一个 01_helloworld/ 文件夹,用来存放我们的驱动源码和 Makefile 文件:

mkdir 01_helloworld

三、编写驱动源码

1、源码

我们在 01_helloworld/ 文件夹下创建一个 hello_world.c 文件,并在其中编写以下代码:

#include <linux/module.h>     /* 模块相关宏和函数 */
#include <linux/kernel.h>     /* printk日志函数 */

/* 加载函数(驱动入口),当驱动被 insmod 加载时自动执行 */
static int __init helloworld_init(void)
{
    printk("helloworld_init\r\n"); // 内核日志打印
    return 0; // 返回0代表加载成功
}

/* 卸载函数(驱动出口),当驱动被 rmmod 卸载时自动执行 */
static void __exit helloworld_exit(void)
{
    printk("helloworld_exit\r\n");
}

/* 下面这两行,告诉内核入口和出口分别是哪两个函数 */
module_init(helloworld_init);
module_exit(helloworld_exit);

/* 这3个是模块信息声明 */
MODULE_LICENSE("GPL v2");               /* 模块许可证 */
MODULE_VERSION("1.0");                  /* 模块版本,可选 */
MODULE_DESCRIPTION("helloworld Driver");/* 模块描述,可选,一般用于 lsmod 时显示 */

2、函数讲解

  1. 头文件:
#include <linux/module.h>     /* 模块相关宏和函数 */
#include <linux/kernel.h>     /* printk日志函数 */
  • linux/module.h:提供模块的初始化、退出和信息声明相关的宏和 API,编写内核模块必须包含。
  • linux/kernel.h:包含内核常用功能,比如 printk 日志输出函数。
  1. 加载函数(驱动入口):
static int __init helloworld_init(void)
{
    printk("helloworld_init\r\n"); // 内核日志打印
    return 0; // 返回0代表加载成功
}
  • static int:声明为静态、只在本文件可见,返回整型。
  • __init:GCC 标识,告诉内核这是初始化代码。模块加载后其内存可被回收,优化内存占用。
  • helloworld_init:模块入口函数。
  • printk:内核中用来打印信息到系统日志(比用户空间的 printf 适用)。
  • return 0:成功返回 0,否则内核拒绝加载该模块。
  1. 卸载函数(驱动出口):
static void __exit helloworld_exit(void)
{
    printk("helloworld_exit\r\n");
}
  • static void:静态、无返回值。
  • __exit:告知内核这是卸载相关代码。卸载模块时自动调用。
  • helloworld_exit:模块出口(释放资源、清理善后)。
  • printk:打印卸载信息到内核日志。
  1. 告诉内核这两个关键函数的位置:
module_init(helloworld_init);
module_exit(helloworld_exit);
  • module_init:注册入口函数。加载模块时自动执行 helloworld_init。
  • module_exit:注册出口函数。卸载模块时自动执行 helloworld_exit。
  1. 模块信息声明:
MODULE_LICENSE("GPL v2");               /* 模块许可证 */
MODULE_VERSION("1.0");                  /* 模块版本,可选 */
MODULE_DESCRIPTION("helloworld Driver");/* 模块描述,可选,一般用于 lsmod 时显示 */
  • MODULE_LICENSE:告知内核模块遵循的协议(必须填写,否则内核会报警告“tainted”)。
  • MODULE_VERSION:为你的驱动指定版本号,方便维护和升级(可选)。
  • MODULE_DESCRIPTION:描述模块用途,便于识别(可选,lsmod 显示时体现)。

3、printk详解

printk 是 Linux 内核中的“打印日志”函数,相当于我们在普通 C 程序里的 printf,但它专门用在内核空间,用于给开发者输出调试信息、状态信息或错误信息。

在 Linux 驱动开发、内核模块开发中,printk 是主要的调试工具。

为什么要用 printk 而不是 printf?

  • printf 只能在用户空间(比如你写的应用程序)用,不能直接在内核里用,因为内核和用户空间的输入输出机制不一样。
  • 内核中没有标准输出窗口,也没有终端,所以只能通过日志系统(dmesg)来查看信息。
  • printk 会把你的信息记录到内核日志缓冲区,然后可以用 dmesg 命令或查看 /var/log/messages(不同系统不同路径)来读取。

printk 还支持日志等级,可以指定消息的重要程度:

等级表示意义
KERN_EMERG严重到系统可能瘫痪
KERN_ALERT需要立即处理的警报
KERN_CRIT危急错误
KERN_ERR普通错误
KERN_WARNING警告(不是致命错误)
KERN_NOTICE需要注意的信息
KERN_INFO一般信息(成功/流程)
KERN_DEBUG调试信息(代码跟踪)

如果不指定日志等级,那么就是默认的 KERN_WARNING 级别。

用法示例:

printk(KERN_INFO "Driver loaded successfully.\n"); // 流程性通知
printk(KERN_ERR "Can not open device!\n"); // 错误通知
printk(KERN_DEBUG "x value is %d\n", x); // 普通调试时用

内核会把日志分级保存,你可以通过设置过滤只看高“优先级”的日志(比如只看错误/警告,不看调试信息),常见 dmesg 或日志管理工具支持等级过滤,让你专注于当前关心的问题。

四、编写Makefile

我们在 01_helloworld/ 文件夹下创建一个 Makefile 文件,并在其中编写以下代码:

export ARCH=arm64

# 交叉编译器绝对路径前缀
export CROSS_COMPILE=/home/lckfb/TaishanPi-3-Linux/prebuilts/gcc/linux-x86/aarch64/gcc-arm-10.3-2021.07-x86_64-aarch64-none-linux-gnu/bin/aarch64-none-linux-gnu-

# 和源文件名一致
obj-m += hello_world.o

# 内核源码目录
KDIR := /home/lckfb/TaishanPi-3-Linux/kernel-6.1
PWD ?= $(shell pwd)

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

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

1、CROSS_COMPILE 讲解

这个参数指定的是我们SDK中自带的交叉编译器,编译 hello_world.c 源码的时候调用这个编译器进行编译,这个 CROSS_COMPILE 的值填的是编译器前缀,结尾带 短横线(-)。

一般情况下SDK版本一致编译器路径都会在:

  • TaishanPi-3-Linux/prebuilts/gcc/linux-x86/aarch64/gcc-arm-10.3-2021.07-x86_64-aarch64-none-linux-gnu/bin/

2、.o 说明

obj-m += hello_world.o

作用:指定需要编译的对象文件名

  • obj-m 表示当前模块需要编译的对象文件(object:.o),后面跟的是你的源代码文件名(去掉扩展名加 .o)。
  • 例如有 hello_world.c,此处就写 hello_world.o。

INFO

编译过程实际是先把 hello_world.c 变为 hello_world.o,再根据内核框架生成最终的模块文件 hello_world.ko(Kernel Object)。

3、KDIR 说明

KDIR := /home/lckfb/TaishanPi-3-Linux/kernel-6.1

作用:告诉 Makefile 用哪个内核版本的源码和头文件进行编译链接。

  • 很多设备驱动模块需要与目标内核环境配套,否则无法正常加载和运行。
  • KDIR := /home/.../kernel-6.1 指明当前使用的 Linux 内核源码主目录。

五、编译

INFO

特别注意:我们编译内核驱动模块之前,一定要确保kernel已经被编译过了,因为编译内核模块的依赖文件有些需要在编译内核后生成。

如何编译内核:参考 Debian12编译内核

我们进入 01_helloworld/ 目录,使用下面的命令进行编译:

make

最终使用的就是生成的 .ko 文件,我们只需要将此文件复制到开发板中,进行模块加载即可:

六、驱动测试

我们将 hello_world.ko 复制到开发板中:

运行下面的命令加载驱动模块:

sudo insmod hello_world.ko

有些用户可能就发现了,加载之后没有任何的反应,这是因为日志自动被系统归类隐藏了,运行下面的命令即可查看 hello_world 相关的日志:

dmesg | grep -E 'hello'

到此我们第一个 hello world 驱动就完成了。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐