目录
前言
在上一讲内容里,字符设备驱动开发步骤,我们详细说明了:模块加载/卸载机制、设备号管理、操作函数实现等。
本讲实验里,以 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_ga
CURRENT_PATH := $(shell pwd)
obj-m := chrdevbase.o
build: kernel_modules
kernel_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 设备为例,完成了第一个字符设备驱动的开发,掌握了字符设备驱动的开发框架以及测试方法。