imx6ull-驱动开发篇3——字符设备驱动开发实验
目录
前言
实验程序编写
创建 VSCode 工程
添加头文件路径
编写实验程序
printk函数
设备操作函数
chrdevbase_open
chrdevbase_read
chrdevbase_write
chrdevbase_release
设备注册与注销
chrdevbase_init
chrdevbase_exit
关键数据结构
编写测试 APP
C 库文件操作基本函数
open函数
read函数
write函数
close函数
编写测试 APP 程序
编译驱动程序和测试 APP
编译驱动程序
编译测试 APP
运行测试
加载驱动模块
创建设备节点文件
chrdevbase 设备操作测试
卸载驱动模块
前言
在上一讲内容里,字符设备驱动开发步骤,我们详细说明了:模块加载/卸载机制、设备号管理、操作函数实现等。
本讲实验里,以 chrdevbase 这个虚拟设备为例,完整地编写一个字符设备驱动模块。
实验程序编写
chrdevbase 这个虚拟设备,假设有两个缓冲区,一个为读缓冲区,一个为写缓冲区,这两个缓冲区的大小都为 100 字节。在应用程序中可以向 chrdevbase 设备的写缓冲区中写入数据,从读缓冲区中读取数据。
通过实现chrdevbase虚拟设备的功能,我们就能学会字符设备驱动开发的最基本功能。
创建 VSCode 工程
在 Ubuntu 中创建一个目录用来存放 Linux 驱动程序,
在drivers 目录下新建一个名为 1_chrdevbase 的子目录来存放本实验所有文件,
在 1_chrdevbase 目录中新建 VSCode 工程,并且新建 chrdevbase.c 文件。
添加头文件路径
因为是编写 Linux 驱动,因此会用到 Linux 源码中的函数。我们需要在 VSCode 中添加 Linux源码中的头文件路径。
打开 VSCode,按下“Crtl+Shift+P”打开 VSCode 的控制台,然后输入“C/C++: Edit configurations(JSON) ”,打开 C/C++编辑配置文件,如图:
打开以后会自动在.vscode 目录下生成一个名为 c_cpp_properties.json 的文件。
此文件默认内容如下所示:
其中,includePath 表示头文件路径,需要将 Linux 源码里面的头文件路径添加进来:
添加头文件路径以后的 c_cpp_properties.json的文件内容如下所示:
{"configurations": [{"name": "Linux","includePath": ["${workspaceFolder}/**","/home/huax/linux/linux_test/linux-imx-rel_imx_4.1.15_2.1.0_ga/include","/home/huax/linux/linux_test/linux-imx-rel_imx_4.1.15_2.1.0_ga/arch/arm/include","/home/huax/linux/linux_test/linux-imx-rel_imx_4.1.15_2.1.0_ga/arch/arm/include/generated/"],"defines": [],"compilerPath": "/usr/bin/gcc","cStandard": "c11","cppStandard": "c++17","intelliSenseMode": "clang-x64"}],"version": 4
}
分别添加了开发板所使用的 Linux 源码下的:
- include、
- arch/arm/include
- arch/arm/include/generated
这三个目录的路径,注意,这里使用了绝对路径。
编写实验程序
我们之前新建了文件 chrdevbase.c,打开,输入如下内容:
#include <linux/types.h>
#include <linux/kernel.h>
#include <linux/delay.h>
#include <linux/ide.h>
#include <linux/init.h>
#include <linux/module.h>#define CHRDEVBASE_MAJOR 200 /* 主设备号 */
#define CHRDEVBASE_NAME "chrdevbase" /* 设备名 *//* 缓冲区定义 */
static char readbuf[100]; /* 读缓冲区 */
static char writebuf[100]; /* 写缓冲区 */
static char kerneldata[] = {"kernel data!"};/*** @brief 打开设备* @param inode 传递给驱动的inode* @param filp 设备文件指针(可通过private_data传递设备结构体)* @return 0 成功,其他失败*/
static int chrdevbase_open(struct inode *inode, struct file *filp)
{return 0;
}/*** @brief 从设备读取数据* @param filp 设备文件指针* @param buf 用户空间缓冲区* @param cnt 请求读取的字节数* @param offt 文件偏移指针* @return 实际读取的字节数(负值表示错误)*/
static ssize_t chrdevbase_read(struct file *filp, char __user *buf,size_t cnt, loff_t *offt)
{int retvalue = 0;/* 内核数据准备 */memcpy(readbuf, kerneldata, sizeof(kerneldata));/* 拷贝数据到用户空间 */retvalue = copy_to_user(buf, readbuf, cnt);if (retvalue == 0) {printk("kernel senddata ok!\n");} else {printk("kernel senddata failed!\n");}return 0;
}/*** @brief 向设备写入数据* @param filp 设备文件指针* @param buf 用户空间数据缓冲区* @param cnt 请求写入的字节数* @param offt 文件偏移指针* @return 实际写入的字节数(负值表示错误)*/
static ssize_t chrdevbase_write(struct file *filp, const char __user *buf,size_t cnt, loff_t *offt)
{int retvalue = 0;/* 从用户空间拷贝数据 */retvalue = copy_from_user(writebuf, buf, cnt);if (retvalue == 0) {printk("kernel recevdata:%s\n", writebuf);} else {printk("kernel recevdata failed!\n");}return 0;
}/*** @brief 关闭设备* @param inode inode结构体指针* @param filp 设备文件指针* @return 0 成功,其他失败*/
static int chrdevbase_release(struct inode *inode, struct file *filp)
{return 0;
}/* 设备操作函数结构体 */
static struct file_operations chrdevbase_fops = {.owner = THIS_MODULE,.open = chrdevbase_open,.read = chrdevbase_read,.write = chrdevbase_write,.release = chrdevbase_release,
};/*** @brief 驱动初始化入口* @return 0 成功,其他失败*/
static int __init chrdevbase_init(void)
{int retvalue = 0;/* 注册字符设备驱动 */retvalue = register_chrdev(CHRDEVBASE_MAJOR, CHRDEVBASE_NAME, &chrdevbase_fops);if (retvalue < 0) {printk("chrdevbase driver register failed\n");}printk("chrdevbase init\n");return 0;
}/*** @brief 驱动退出函数*/
static void __exit chrdevbase_exit(void)
{/* 注销字符设备驱动 */unregister_chrdev(CHRDEVBASE_MAJOR, CHRDEVBASE_NAME);printk("chrdevbase exit\n");
}/* 指定驱动入口/出口函数 */
module_init(chrdevbase_init);
module_exit(chrdevbase_exit);/* 模块信息 */
MODULE_LICENSE("GPL");
MODULE_AUTHOR("huax");
printk函数
在这段代码里,用printk 来输出信息,而不是 printf。
因为在 Linux 内核中没有 printf 这个函数。printf运行在用户态, printk 运行在内核态。
printk 可以根据日志级别对消息进行分类,一共有 8 个消息级别,这 8 个消息级别定义在文件 include/linux/kern_levels.h 里面,定义如下:
#define KERN_SOH "\001"
#define KERN_EMERG KERN_SOH "0" /* 紧急事件,一般是内核崩溃 */
#define KERN_ALERT KERN_SOH "1" /* 必须立即采取行动 */
#define KERN_CRIT KERN_SOH "2" /* 临界条件,比如严重的软件或硬件错误*/
#define KERN_ERR KERN_SOH "3" /* 错误状态,一般设备驱动程序中使用
KERN_ERR 报告硬件错误 */
#define KERN_WARNING KERN_SOH "4" /* 警告信息,不会对系统造成严重影响 */
#define KERN_NOTICE KERN_SOH "5" /* 有必要进行提示的一些信息 */
#define KERN_INFO KERN_SOH "6" /* 提示性的信息 */
#define KERN_DEBUG KERN_SOH "7" /* 调试信息 */
一共定义了 8 个级别,其中 0 的优先级最高, 7 的优先级最低。
举例:
printk(KERN_EMERG "gsmi: Log Shutdown Reason\n");
printk还可以通过消息级别来决定哪些消息可以显示在控制台上。在 include/linux/printk.h 中有个宏 CONSOLE_LOGLEVEL_DEFAULT,定义如下:
#define CONSOLE_LOGLEVEL_DEFAULT 7
CONSOLE_LOGLEVEL_DEFAULT 控制着哪些级别的消息可以显示在控制台上,此宏默认为 7,意味着只有优先级高于 7 的消息才能显示在控制台上。
设备操作函数
chrdevbase_open
- 作用:设备打开时调用
- 关键参数:filp->private_data(可指向设备结构体,存储设备属性)
- 示例用途:初始化硬件或分配资源
chrdevbase_read
- 作用:从设备读取数据到用户空间
- 关键操作:内核数据准备(memcpy),通过 copy_to_user安全拷贝到用户空间
- 返回值:成功返回0,失败返回负数
chrdevbase_write
- 作用:将用户空间数据写入设备
- 关键操作:通过 copy_from_user安全拷贝到内核缓冲区
- 调试输出:打印接收到的数据内容
chrdevbase_release
- 作用:关闭设备时释放资源
- 典型操作:若 private_data指向动态内存,需在此释放
设备注册与注销
chrdevbase_init
- 功能:驱动入口,注册字符设备
- 关键调用:register_chrdev(CHRDEVBASE_MAJOR, ...)
- 错误处理:检查返回值并打印日志
chrdevbase_exit
-
功能:驱动出口,注销字符设备
-
关键调用:unregister_chrdev(CHRDEVBASE_MAJOR, ...)
关键数据结构
file_operations结构体
- 成员:.open、.read、.write、.release
- 作用:绑定用户操作与驱动函数
编写测试 APP
C 库文件操作基本函数
编写测试 APP 就是编写 Linux 应用,需要用到 C 库里面和文件操作有关的一些函数,比如open、 read、 write 和 close 这四个函数。
open函数
函数原型如下:
int open(const char *pathname, int flags);
open函数有两个参数:
参数 | 作用 | 典型值 |
---|---|---|
| 设备/文件路径(如 | 字符串路径 |
| 打开模式(必选+可选组合) | 见下方模式说明 |
flags: 文件打开模式,下表中的值必须三选一
模式 | 说明 | 示例场景 |
---|---|---|
| 只读 | 读取传感器数据 |
| 只写 | 控制LED灯 |
| 读写(最常用) | 双向通信设备 |
flags:常用可选模式(按位或 |
组合)
模式 | 作用 | 示例 |
---|---|---|
| 每次写入追加到文件末尾 | 日志文件 |
| 文件不存在时创建(需指定权限) | `O_RDWR |
| 打开时清空文件内容(慎用) | 临时配置文件 |
| 非阻塞模式(立即返回,不等待设备就绪) | 串口设备 |
| 写入后等待物理I/O完成(数据安全性高) | 关键数据存储 |
open函数返回值
-
成功:返回 文件描述符(正整数,后续操作凭据)
-
失败:返回
-1
,并通过errno
标识错误原因(如ENOENT
文件不存在)
举例:
int fd = open("/dev/chrdevbase", O_RDWR | O_NONBLOCK);
if (fd < 0) {perror("Open failed");exit(1);
}
read函数
函数原型如下:
ssize_t read(int fd, void *buf, size_t count)
read函数有三个参数:
参数 | 作用 | 注意事项 |
---|---|---|
| 文件描述符(由 | 必须有效且已打开 |
| 数据存储缓冲区(用户空间内存) | 需确保内存足够且可写 |
| 请求读取的最大字节数 | 实际读取可能小于此值 |
read函数的返回值有下面三种情况:
返回值 | 含义 | 典型场景 |
---|---|---|
正整数 | 实际读取的字节数 | 成功读取部分或全部数据 |
| 文件末尾(EOF) | 无更多数据可读(如管道关闭) |
负值 | 错误(通过 | 设备故障/信号中断/权限不足 |
举例:
char buffer[100];
int ret = read(fd, buffer, sizeof(buffer));
if (ret < 0) {perror("Read failed");
} else {printf("Read %d bytes: %.*s\n", ret, ret, buffer);
}
write函数
函数原型如下:
ssize_t write(int fd, const void *buf, size_t count);
write函数的参数如下:
参数 | 作用 | 注意事项 |
---|---|---|
| 文件描述符(由 | 必须有效且已打开(有写权限) |
| 待写入的数据缓冲区(用户空间) | 数据需合法且缓冲区可读 |
| 请求写入的字节数 | 实际写入可能小于此值 |
返回值和read一样,也有3种情况:
返回值 | 含义 | 典型场景 |
---|---|---|
正整数 | 实际写入的字节数 | 成功写入部分或全部数据 |
| 未写入数据(特殊场景) | 如写入空缓冲区或非阻塞设备满 |
负值 | 错误(通过 | 设备故障/空间不足/权限问题 |
举例:
char data[] = "Hello, Driver!";
int ret = write(fd, data, sizeof(data));
if (ret < 0) {perror("Write failed");
} else {printf("Wrote %d bytes\n", ret);
}
close函数
函数原型如下:
int close(int fd);
参数 | 作用 | 注意事项 |
---|---|---|
| 待关闭的文件描述符 | 必须是由 |
返回值: 0 表示关闭成功,负值表示关闭失败。
举例:
int fd = open("/dev/chrdevbase", O_RDWR);
// ... 读写操作 ...
if (close(fd) == -1) {perror("Close failed");
}
编写测试 APP 程序
接下来编写一个简单的测试 APP,测试 APP 很简单通过输入相应的指令来对 chrdevbase 设备执行读或者写操作。
在1_chrdevbase 目录中新建 chrdevbaseApp.c 文件,在此文件中输入如下内容:
#include "stdio.h"
#include "unistd.h"
#include "sys/types.h"
#include "sys/stat.h"
#include "fcntl.h"
#include "stdlib.h"
#include "string.h"static char usrdata[] = {"usr data!"};/** @description : main 主程序* @param - argc : argv 数组元素个数* @param - argv : 具体参数* @return : 0 成功;其他 失败*/
int main(int argc, char *argv[])
{int fd, retvalue;char *filename;char readbuf[100], writebuf[100];if(argc != 3){printf("Error Usage!\r\n");return -1;}filename = argv[1];/* 打开驱动文件 */fd = open(filename, O_RDWR);if(fd < 0){printf("Can't open file %s\r\n", filename);return -1;}if(atoi(argv[2]) == 1){ /* 从驱动文件读取数据 */retvalue = read(fd, readbuf, 50);if(retvalue < 0){printf("read file %s failed!\r\n", filename);}else{/* 读取成功,打印出读取成功的数据 */printf("read data:%s\r\n",readbuf);}}if(atoi(argv[2]) == 2){/* 向设备驱动写数据 */memcpy(writebuf, usrdata, sizeof(usrdata));retvalue = write(fd, writebuf, 50);if(retvalue < 0){printf("write file %s failed!\r\n", filename);}}/* 关闭设备 */retvalue = close(fd);if(retvalue < 0){printf("Can't close file %s\r\n", filename);return -1;}return 0;
}
数组 usrdata 是测试 APP 要向 chrdevbase 设备写入的数据。
argv[]是 main函数的参数数组,用于接收从命令行传入的参数。
- argv[0]:程序自身的名称(可执行文件的路径或名称)。
- argv[1]:要打开的驱动文件或设备文件的路径。
- argv[2]:操作类型标志,决定是读取(1)还是写入(2)驱动文件。
比如,现在要从 chrdevbase 设备中读取数据,需要输入如下命令:
./chrdevbaseApp /dev/chrdevbase 1
当 argv[2]为 1 的时候,表示要从 chrdevbase 设备中读取数据,一共读取 50 字节的数据,读取到的数据保存在 readbuf 中,读取成功以后就在终端上打印出读取到的数据。
当 argv[2]为 2 的时候,表示要向 chrdevbase 设备写数据。
编译驱动程序和测试 APP
编译驱动程序
首先编译驱动程序,也就是 chrdevbase.c 这个文件,我们需要将其编译为.ko 模块。
创建Makefile 文件,然后在其中输入如下内容:
KERNELDIR := /home/huax/linux/linux_test/linux-imx-rel_imx_4.1.15_2.1.0_gaCURRENT_PATH := $(shell pwd)
obj-m := chrdevbase.obuild: kernel_moduleskernel_modules:$(MAKE) -C $(KERNELDIR) M=$(CURRENT_PATH) modules
clean:$(MAKE) -C $(KERNELDIR) M=$(CURRENT_PATH) clean
- KERNELDIR 表示自己开发板所使用的 Linux 内核源码目录,使用绝对路径。
- CURRENT_PATH 表示当前路径,直接通过运行“pwd”命令来获取当前所处路径。
- obj-m 表示将 chrdevbase.c 这个文件编译为 chrdevbase.ko 模块。
其中具体的编译命令:
$(MAKE) -C $(KERNELDIR) M=$(CURRENT_PATH) modules
- -C $(KERNELDIR),-C选项告诉 make切换工作目录到 $(KERNELDIR)(即内核源码目录)
- M=$(CURRENT_PATH),M=是内核构建系统的特殊参数,指定外部模块的源代码目录(即当前模块所在的路径)。
- modules,这是内核构建系统的目标(target),表示编译外部模块。最终会生成 .ko(内核模块)文件。
Makefile 编写好以后输入“make”命令编译驱动模块,编译成功以后就会生成一个叫做 chrdevbaes.ko 的文件,此文件就是 chrdevbase 设备的驱动模块。
编译测试 APP
只有一个文件,直接用gcc编译:
arm-linux-gnueabihf-gcc chrdevbaseApp.c -o chrdevbaseApp
编译完成以后会生成一个叫做 chrdevbaseApp 的可执行程序。
查看chrdevbaseAPP 这个程序的文件信息,可以输入:
file chrdevbaseApp
运行测试
加载驱动模块
Linux 系统选择通过 TFTP 从网络启动,并且使用 NFS 挂载网络根文件系统。
启动 Linux 系统,检查开发板根文件系统中有没有“/lib/modules/4.1.15”这个目录,如果没有的话自行创建。4.1.15是因为ALPHA 开发板现在用的是 4.1.15 版本的 Linux 内核。
将 chrdevbase.ko 和 chrdevbaseAPP 复制到 根文件系统rootfs/lib/modules/4.1.15 目录中,命令如下:
sudo cp chrdevbase.ko chrdevbaseApp /home/huax/linux/nfs/rootfs/lib/modules/4.1.15/ -f
拷贝完成以后就会在开发板的 /lib/modules/4.1.15 目录下存在 chrdevbase.ko 和chrdevbaseAPP 这两个文件,如图:
加载 chrdevbase.ko 驱动文件,可以用以下两个命令:
insmod chrdevbase.ko
modprobe chrdevbase.ko
如果使用 modprobe 加载驱动,提示无法打开“modules.dep”这个文件:
直接输入 depmod 命令,会自动生成 modules.alias、modules.symbols 和 modules.dep 这三个文件,如图:
然后再使用modprobe 加载 chrdevbase.ko,结果如图::
输入“lsmod”命令即可查看当前系统中存在的模块,结果如图:
存在chrdevbase”这一个模块,再查看当前系统中有没有 chrdevbase 这个设备:
cat /proc/devices
创建设备节点文件
驱动加载成功需要在/dev 目录下创建一个与之对应的设备节点文件,应用程序就是通过操作这个设备节点文件来完成对具体设备的操作。
输入如下命令创建/dev/chrdevbase 这个设备节点文件:
mknod /dev/chrdevbase c 200 0
- /dev/chrdevbase”是要创建的节点文件,
- “c”表示这是个字符设备,
- “ 200”是设备的主设备号,
- “ 0”是设备的次设备号。
创建完成以后就会存在/dev/chrdevbase 这个文件。
可以使用“ls /dev/chrdevbase -l”命令查看,结果如图:
如果 chrdevbaseAPP 想要读写 chrdevbase 设备,直接对/dev/chrdevbase 进行读写操作即可。相当于/dev/chrdevbase 这个文件是 chrdevbase 设备在用户空间中的实现。
chrdevbase 设备操作测试
使用 chrdevbaseApp 软件操作 chrdevbase 这个设备,看看读写是否正常。
首先进行读操作,输入如下命令:
./chrdevbaseApp /dev/chrdevbase 1
结果如图:
然后测试对 chrdevbase 设备的写操作,输入如下命令:
./chrdevbaseApp /dev/chrdevbase 2
结果如图:
对 chrdevbase 的读写操作正常,说明我们编写的 chrdevbase 驱动是没有问题的。
卸载驱动模块
输入如下命令卸载掉 chrdevbase 这个设备:
rmmod chrdevbase.ko
卸载以后,可以使用 lsmod 命令查看 chrdevbase 这个模块还存不存在。
本讲实验就结束了,以一个虚拟的 chrdevbase 设备为例,完成了第一个字符设备驱动的开发,掌握了字符设备驱动的开发框架以及测试方法。