imx6ull-驱动开发篇3——字符设备驱动开发实验

发布于:2025-07-30 ⋅ 阅读:(25) ⋅ 点赞:(0)

目录

前言

实验程序编写

创建 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函数有两个参数:

参数​

​作用​

​典型值​

pathname

设备/文件路径(如 /dev/chrdevbase

字符串路径

flags

打开模式(必选+可选组合)

见下方模式说明

flags: 文件打开模式,下表中的值必须三选一

​模式​

​说明​

​示例场景​

O_RDONLY

只读

读取传感器数据

O_WRONLY

只写

控制LED灯

O_RDWR

读写(最常用)

双向通信设备

flags:常用可选模式(按位或 |组合)​

​模式​

​作用​

​示例​

O_APPEND

每次写入追加到文件末尾

日志文件

O_CREAT

文件不存在时创建(需指定权限)

`O_RDWR

O_TRUNC

打开时清空文件内容(慎用)

临时配置文件

O_NONBLOCK

非阻塞模式(立即返回,不等待设备就绪)

串口设备

O_SYNC

写入后等待物理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函数有三个参数:

​参数​

​作用​

​注意事项​

fd

文件描述符(由 open返回)

必须有效且已打开

buf

数据存储缓冲区(用户空间内存)

需确保内存足够且可写

count

请求读取的最大字节数

实际读取可能小于此值

read函数的返回值有下面三种情况:

​返回值​

​含义​

​典型场景​

​正整数​

实际读取的字节数

成功读取部分或全部数据

0

文件末尾(EOF)

无更多数据可读(如管道关闭)

​负值​

错误(通过 errno获取具体原因)

设备故障/信号中断/权限不足

举例:

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函数的参数如下:

参数​

​作用​

​注意事项​

fd

文件描述符(由 open返回)

必须有效且已打开(有写权限)

buf

待写入的数据缓冲区(用户空间)

数据需合法且缓冲区可读

count

请求写入的字节数

实际写入可能小于此值

返回值和read一样,也有3种情况:

​返回值​

​含义​

​典型场景​

​正整数​

实际写入的字节数

成功写入部分或全部数据

0

未写入数据(特殊场景)

如写入空缓冲区或非阻塞设备满

​负值​

错误(通过 errno获取具体原因)

设备故障/空间不足/权限问题

举例:

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);

​参数​

​作用​

​注意事项​

fd

待关闭的文件描述符

必须是由 open或类似函数返回的有效描述符

返回值: 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 设备为例,完成了第一个字符设备驱动的开发,掌握了字符设备驱动的开发框架以及测试方法。


    网站公告

    今日签到

    点亮在社区的每一天
    去签到