一、驱动工程搭建
1、用户应用编程 VS 内核驱动编程差异
应用程序:运行在用户空间;
驱动:属于内核级别编程,代码属于操作系统内核一部分。
- 核心问题:如何在已经运行的大型内核代码中插入、执行我们自己的代码;
- 驱动本质最终还是操作寄存器,原理和裸机一致;区别是内核下要做物理地址→虚拟地址映射。
2、驱动两种加载方式
方式 1:静态编译进内核
- 编写驱动
.c源码; - 修改
Kconfig、Makefile; make menuconfig勾选驱动,<*代表编译进 zImage;- 驱动代码被打包进 zImage 镜像,zImage 体积变大;
- 必须重新烧写内核、重启开发板,驱动才能生效。
方式 2:动态模块 ko(推荐调试使用)
- 编写驱动
.c源码; - 修改 Makefile,
obj‑m += xxx.o,执行make modules生成.ko模块文件; - ko 独立于 zImage 镜像,不修改内核镜像;
- 开发板 Linux 运行后手动加载卸载:
insmod xxx.ko:加载内核模块
lsmod:查看已经加载的内核模块
rmmod xxx:卸载内核模块
优点:不需要重启板子,调试效率高。
3、内核模块基础宏
| 宏 | 功能说明 |
|---|---|
module_init(func) |
模块加载入口函数,insmod 的时候执行 |
module_exit(func) |
模块卸载入口函数,rmmod 的时候执行 |
MODULE_LICENSE("GPL") |
许可证声明,必须写,否则内核报警告,部分接口不可用 |
printk() |
内核打印函数,代替应用层 printf,不能打印浮点数 |
pr_info(fmt,...) |
带日志等级的打印,普通提示信息 |
pr_err(fmt,...) |
带日志等级的打印,打印错误信息 |
#include <linux/init.h>
#include <linux/module.h>
#include <linux/printk.h>
static int __init mymodule_init(void)
{
pr_info("mymodule_init ok\n");
return 0;
}
static void __exit mymodule_exit(void)
{
pr_info("mymodule_exit byebye\n");
}
module_init(mymodule_init);
module_exit(mymodule_exit);
MODULE_LICENSE("GPL");
4、两种工程组织方式
① 放在内核源码树内部开发(drivers 目录下新增驱动)
步骤:
-
在
drivers/char新建mydevtest.c驱动源码; -
修改同目录
Kconfig,添加配置项config MYMODULE
tristate "mymodule inner kernel test"
default y
help
driver test kconfig makefile -
修改同目录
Makefile:obj‑$(CONFIG_MYMODULE) += myhello.o -
make menuconfig选中对应模块; -
make modules生成 ko; -
将 ko 拷贝到 nfs 的 rootfs 目录,开发板 insmod 加载测试。
Kconfig:控制 menuconfig 图形配置界面;Makefile 根据 CONFIG_xxx 变量决定是否编译文件。
② 源码树外部独立工程(实际项目推荐)
不改动内核源码,驱动工程和内核源码分开放。 目录结构示例
├── app # 用户应用程序
│ ├── app.c
│ └── Makefile
├── drv # 内核驱动代码
│ ├── .clangd
│ ├── compile_commands.json #软链接指向内核源码生成的该文件,用于vscode代码补全
│ ├── drv.c
│ └── Makefile
└── Makefile #顶层总Makefile
drv/Makefile示例
MODULE_NAME:= drv
KER_DIR := /home/linux/imx6ull/linux‑imx‑rel_imx_4.1.15_2.1.0_ga_alientek
CUR_DIR := $(shell pwd)
obj‑m += $(MODULE_NAME).o
all:
make -C $(KER_DIR) M=$(CUR_DIR) modules
cp $(MODULE_NAME).ko ~/nfs/rootfs
clean:
make -C $(KER_DIR) M=$(CUR_DIR) clean
distclean:
rm $(MODULE_NAME).ko
make -C $(KER_DIR) M=$(CUR_DIR) distclean
app/Makefile示例
SRC:=app.c
OBJ:=app
CC:=arm‑linux‑gnueabihf‑gcc
all:
$(CC) $(SRC) -o $(OBJ)
clean:
rm $(OBJ)
顶层 Makefile
all:
make -C ./app all
make -C ./drv all
clean:
make -C ./app clean
make -C ./drv clean
前置条件:内核源码必须完整编译过一次,外部模块编译依赖内核编译产物。
5、字符设备驱动基础框架
Linux 驱动三大分类
(1)字符设备:按字节流顺序读写;GPIO、I2C、SPI、UART、LED、按键、LCD、摄像头;占驱动 95% 以上;
(2)块设备:按块读写;SD、EMMC、Flash 存储设备;随机访问,吞吐量大;
(3)网络设备:wifi、蓝牙、CAN。
字符设备开发步骤
1)alloc_chrdev_region():动态分配设备号(推荐);register_chrdev_region()静态注册已知设备号;
2)cdev_alloc() / cdev_init():初始化字符设备结构体,绑定file_operations;
3)cdev_add():将字符设备注册进内核;
4)file_operations结构体:实现open/read/write/release,用户态系统调用最终会回调这些内核函数;
5)点两种方创建设备节式
- 手动:
mknod /dev/led0 c 主设备号 次设备号 - 自动:
class_create()+device_create(),sysfs 自动生成 /dev 设备文件;
6)卸载的时候依次执行cdev_del()、unregister_chrdev_region()、device_destroy()、class_destroy()。
| 字符设备 API | 功能 |
|---|---|
cdev_init |
初始化 cdev 结构体,绑定 file_operations |
cdev_alloc |
动态申请 cdev 结构体内存 |
cdev_add |
把 cdev 注册到内核,驱动正式生效 |
cdev_del |
从内核删除 cdev 设备 |
alloc_chrdev_region |
动态分配主次设备号 |
unregister_chrdev_region |
释放已经注册的设备号 |
/proc/devices可以查看系统所有已经注册的字符设备主设备号。
file_operations 说明
应用层调用 open/read/write/close,内核就会调用驱动中file_operations对应的函数指针。
6、sysfs 虚拟文件系统
sysfs 是内存虚拟文件系统,不占用磁盘存储 ,挂载在/sys。
sysfs 重要目录
| 目录 | 作用 |
|---|---|
/sys/devices |
真实物理设备 |
/sys/bus |
总线,devices 子目录存放设备,drivers 存放对应驱动 |
/sys/class |
设备分类,按设备类型分组(tty、led、input 等) |
/sys/module |
已经加载的内核模块 |
驱动中使用 sysfs:
class_create():创建设备类;device_create():创建设备,自动生成/dev/xxx节点;__ATTR宏定义设备属性;device_create_file()创建 sys 属性文件;- 用户空间直接
cat /sys/xxx/xxx读,echo value > /sys/xxx/xxx写,用于调试硬件,不用写专门应用程序。
注意:内核属性文件权限不能设置 0666,只能使用 0664。
show函数返回读取字符串长度;store函数必须返回传入数据长度,否则应用层会阻塞。
相关 API
| 函数 | 功能 |
|---|---|
class_create |
创建设备类目录 |
class_destroy |
销毁设备类 |
device_create |
创建设备,自动生成 /dev 节点 |
device_destroy |
销毁设备 |
__ATTR |
初始化设备属性结构体 |
device_create_file |
生成 sys 属性文件 |
device_remove_file |
删除 sys 属性文件 |
7、misc 杂项设备(主设备号固定 10)
属于字符设备的简化封装,主设备号固定为 10,自动分配次设备号,自动创建设备节点。
可以省去
alloc_chrdev_region、cdev_init、cdev_add、手动 mknod、class/device 的部分代码。
| misc API | 功能 |
|---|---|
misc_register() |
注册杂项设备 |
misc_deregister() |
注销杂项设备 |
二、LED 灯驱动知识点
1、硬件原理图要点
LED:低电平点亮,高电平熄灭;串联 510Ω 限流电阻。 引脚:LED0 接到 GPIO1_IO03。
2、裸机操作需要的寄存器
- IOMUXC_SW_MUX_CTL_PAD_GPIO1_IO03:引脚复用功能寄存器;
- IOMUXC_SW_PAD_CTL_PAD_GPIO1_IO03:引脚电气特性配置;
- GPIO1_DR:GPIO 数据寄存器;写 0 输出低电平,写 1 输出高电平;
- GPIO1_GDIR:GPIO 方向寄存器;bit 置 1 配置输出模式。
⚠️内核中不能直接操作物理寄存器地址!必须做物理地址→虚拟地址映射。
3、内存映射相关 API(内核访问硬件寄存器)
头文件:#include <linux/io.h>
| 函数 | 功能说明 |
|---|---|
ioremap(phys, size) |
把物理地址映射成内核虚拟地址,返回虚拟地址指针 |
iounmap(virt_addr) |
解除映射 |
devm_ioremap(phys,size) |
资源托管映射;设备卸载驱动自动 iounmap 释放,推荐优先使用,防止内存泄漏 |
devm_ioremap_resource() |
检查资源冲突,设备树驱动优先使用 |
寄存器读写函数(32 位 SOC,imx6ull 寄存器都是 32bit)
readl(addr):读取 32 位寄存器;writel(value, addr):写 32 位寄存器。
4、用户空间 ↔ 内核空间数据拷贝
驱动 read/write 函数中,不能直接解引用用户空间指针,必须使用专用拷贝函数。
copy_from_user(kbuf, ubuf, size)用户空间数据拷贝到内核空间
copy_to_user(ubuf, kbuf, size)内核空间数据拷贝到用户空间
5、两套 LED 驱动实现思路
思路 1:直接硬编码寄存器地址(不使用设备树)
- ioremap 映射 4 个寄存器物理地址得到虚拟地址;
- writel 配置引脚复用、PAD 电气属性;
- writel 设置 GPIO 方向为输出;
- writel 写 DR 寄存器控制输出高低电平,实现 LED 亮灭;
- 在 file_operations 的 write 函数接收用户层传进来的亮灭值,操作寄存器;
- 模块 exit 中 iounmap 释放映射。
缺点:硬件换板子,寄存器地址变了,驱动代码必须修改,可移植性差。
思路 2:设备树方式 LED 驱动(工程实际推荐,设备与驱动分离)
Linux3.x 之后引入设备树思想:硬件资源(寄存器、引脚、中断)写在 dts 设备树,驱动写通用逻辑;换硬件只改 dts,不用修改驱动 c 代码。
设备树相关文件后缀
| 后缀 | 含义 |
|---|---|
.dts |
板级设备树,对应开发板硬件配置 |
.dtsi |
公共头文件,多个 dts 可以 include 引用 |
.dtb |
DTC 工具编译 dts/dtsi 得到的二进制,uboot 加载传递给内核 |
6、编译与测试完整流程
- 修改 dts,配置 led 节点(pinctrl+gpio);
- 编译 dtb,放到 tftpboot;
- 编写驱动 c 代码,使用外部 Makefile 编译生成 ko;
- ko 拷贝到 nfs 共享 rootfs;
- 开发板网络启动,
insmod led_drv.ko加载驱动 - 测试方式二选一
- 应用程序 open /dev/xxx,write 写 1/0 控制亮灭;
- sysfs 节点:
echo 1 > /sys/devices/virtual/LED/led0/led_state;cat /sys/devices/virtual/LED/led0/led_state;
rmmod led_drv卸载驱动。