[Linux]从零开始的STM32MP157第一个驱动编写教程

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

一、前言     

        在前面的教程中,教了大家如何移植TF-A以及如何启动Uboot,再到移植一个Linux内核,最后移植根文件系统。我们做这些的目的就是为了进行Linux驱动开发,Linux驱动开发也是建立在内核和根文件系统之上的。那么本次教程,就来教大家如何进行Linux驱动开发,当然,我们本次教程只讲框架,带大家熟悉Linux驱动开发的步骤与常用的函数。本次我们编写的驱动程序也不是一个具体设备的驱动程序,而是一个虚拟的演示程序,如果你已经准备好了,那就让我们开始吧!

二、谁适合本次教程

        前面已经提到了Linux驱动开发必须建立在内核以及根文件系统的基础上,所以,在开始之前,请确保你已经移植好了一个根文件系统。这里根文件系统不管是Buildroot与Busybox都可以进行Linux驱动开发,大家随意选择一个即可,本次教程我使用的是Busybox根文件系统。如果你还不会移植根文件系统请看下面的教程:

Buildroot根文件系统移植:[Linux]从零开始的STM32MP157 Buildroot根文件系统构建-CSDN博客

Busybox根文件系统移植:[Linux]从零开始的STM32MP157 Busybox根文件系统构建_stm32mp构建根文件系统-CSDN博客

根文件系统移植完成以后如图所示:

 这里的根文件系统我们仍然采用NFS挂载的形式,这样我们计算机中的文件也可以直接映射到开发板中。这也有利于我们Linux驱动开发。

三、资料的准备

        本次Linux驱动开发教程仍然参考正点原子的官方资料,下载方式在环境搭建时就已经讲过了,如果你还不会下载正点原子的官方资料,可以看下方的教程:

STM32MP157环境搭建:[Linux]从零开始的STM32MP157交叉编译环境配置_stm32mp157 linux 开发 单片机开发-CSDN博客

资料下载完成以后如图所示:

本次教程参考的是正点原子官方资料下的“09、文档教程(非常重要)/【正点原子】STM32MP1嵌入式Linux驱动开发指南V2.1.pdf”:

四、Linux的第一个驱动程序

        关于Linux驱动程序的介绍在正点原子的文档中已经写得很详细了,我这里就不多说了,本次我们编写的是Linux字符设备驱动,这类驱动在Linux驱动中占了非常大一部分。字符设备简单来说就是一个字节一个字节的设备,并且按照字节对设备进行读取与写入操作,通常我们会在应用程序中使用“open()、close()、read()、write()”等函数对字符设备进行操作。当我们在应用程序中调用这些函数时,驱动程序也会调用对应的函数对设备进行操作。

1.file_operations结构体

        学习Linux内核开发,我们必须要了解file_operations结构体,它是Linux内核驱动操作函数的集合,后续我们使用到的函数大多都来自这个结构体。它被定义到了Linux内核文件夹下的“include/linux/fs.h”文件中,如图所示:

这里的函数介绍在正点原子的文档中写得比较详细,这里就不多说了。

2.驱动模块的加载与卸载

        了解了“file_operation”结构体以后,我们就可以来编写第一个Linux驱动程序了。这里我们单独在linux目录下新建一个名为“Linux_Drivers”的目录来存放我们编写的Linux驱动程序:

然后再进入“Linux_Drivers”目录新建一个名为“chrdevbase”的文件夹用于存放我们编写的第一个Linux设备驱动:

新建好文件夹以后,我们使用vscode打开这个文件夹:

然后在这个名为“chrdevbase”的文件夹下新建一个名为“chrdevbase.c”的文件用来编写我们的第一个字符设备驱动:

现在我们准备编写程序。在Linux驱动开发中,非常精髓的操作就是去借鉴别人已经写好的,下面来教大家如何去借鉴别人的程序从而完善我们的驱动。我们再开一个vscode窗口,然后打开Linux内核的文件夹,如图所示:

然后我们直接在Linux内核文件夹中搜索“file_operations”。只要用了这个结构体的文件,里面肯定就有我们要用的函数与头文件:

我们随意打开一个文件,就可以看到它使用了“file_operations”结构体:

我们直接来到这个文件的最上面,我们可以看到这个驱动用到的头文件,我们全部复制过来,头文件多引用几个也不会有什么问题,所以索性都复制到我们的文件中,完成以后如图所示:

头文件复制过来以后,我们就可以来写驱动模块的加载与卸载函数。我们的驱动加载与卸载主要依赖“module_init();”与“module_exit();”函数,我们同样的,直接在Linux内核代码中直接搜“module_init”:

我们还是随便进入一个,我们就可以看到这个函数的使用方式:

我们先将这两个函数直接复制到我们的驱动中:

我们可以看到,这两个函数中,需要传入我们自己定义的驱动加载与卸载的函数,我们同样的直接把别人的驱动加载与卸载函数复制过来:

我们这里直接将这两个函数的名字修改为我们自己驱动对应的加载与卸载的名字,下面的名字也要对应着修改:

 这样就非常简单明了了,当我们的驱动被加载时就调用“chrdevbase_init”函数,当我们的驱动被卸载时就调用“chrdevbase_exit”函数。这里为了让我们知道驱动正常被加载以及卸载了,我们可以在驱动加载与卸载函数中打印一行信息。这里需要注意,在内核中打印信息我们需要使用“printk”函数,这里打印完一段语句以后,记得使用\r\n换行,如果没有换行的话,缓存不会刷新,我们不能立即看到我们模块加载与卸载以后打印出的内容:

大家现在可能已经发现了,我们上面引用的头文件下面有波浪线,表示vscode没有找到这个头文件的路径,并且我们的代码也不会自动补全。那么现在我们就来添加我们的头文件路径,让vscode能够找到这些头文件。

这里我们首先按下“Crtl+Shift+P”打开vscode的控制台,然后在控制台中搜索“C/C++: Edit configurations(JSON)”:

然后选择第一个“编辑配置”:

点击了以后,我们就可以看到我们的驱动文件夹中生成了一个名为“.vscode”的文件夹,并且为我们生成了一个预配置的配置文件:

下面我们就来修改这个配置文件,把我们需要用到的头文件目录都添加进来,这样vscode就能找到这些头文件了。

这里我们需要将下面三个路径添加到配置文件的“includePath”中:

"/home/chulingxiao/linux/LINUX/my-linux/linux-5.4.31/arch/arm/include",
"/home/chulingxiao/linux/LINUX/my-linux/linux-5.4.31/include",
"/home/chulingxiao/linux/LINUX/my-linux/linux-5.4.31/arch/arm/include/generated"

添加完成以后,如图所示:

添加以后,我们的.c文件中,就可以发现我们的头文件已经不报错了,当然,还有个别头文件报错是因为它依赖了别的头文件,而它依赖的这个头文件的路径我们并没有添加进来,这里大家不用在意,这些头文件在本次教程中不会用到,后续如果要解决头文件的依赖关系也会单独出教程给大家说,完成以后头文件如图:

完成上面的步骤以后,我们的驱动模块就已经可以完成基本的加载以及卸载了,我们可以编译到开发板上试试,因为我们的驱动代码用到了许多内核中的头文件以及函数,所以我们需要将驱动代码单独放到内核中编译,但是为了保证内核的完整性,我们没有直接放到内核中,而是单独新建了一个文件夹,这样一来,要把驱动模块放到内核中编译就需要借助Makefile文件,这里Makefile的语法就不多说了,大家也不需要全会,知道怎么修改即可,我们使用下方的Makefile代码来编译我们的驱动模块:

KERNELDIR := /home/chulingxiao/linux/LINUX/my-linux/linux-5.4.31 
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

我们需要在“chrdevbase.c”的同级目录下新建一个“Makefile”文件,并且上面的内容写入,完成以后如图所示:

这里还需要注意的是,大家需要将“KERNELDIR”的路径修改为自己内核目录的路径:

这里大家可能会好奇,为什么我们的Makefile文件中没有指定编译器,这是因为我们已经将我们的驱动模块放到内核目录中去编译了,这里的Makefile会直接引用内核目录中的顶层Makefile,在这个Makefile中我们已经指定过交叉编译器了。这里我们只需要在"chrdevbase"目录下执行执行下面的命令就可以开始编译了:

make

如果在编译时被提示“Makefile:8: *** 缺失分隔符。 停止。”很有可能是缩进出了问题,在Makefile中所有的缩进都是TAB而不是空格。如果在编译时提示某个头文件找不到,不需要犹豫,直接把这个头文件的引用语句删掉即可:

删掉以后,我们可以发现已经不报错了,但是有一个警告,因为我们没有给这个函数返回值:

加上返回值以后如图所示:

再次编译,我们可以发现已经没有警告了:

编译完成以后,可以发现,在我们的“chrdevbase”目录下已经多了许多文件,而驱动模块就是这个以.ko为后缀的文件:

在加载这个模块之前,我们需要先在开发板上进行一些操作,这里我们同样使用串口远程开发板:

我们使用下面的命令新建一个文件夹,这个文件夹用于存放我们所有的驱动模块:

mkdir -p /lib/modules/5.4.31

这里的5.4.31是我们内核的版本,这里新建的文件夹的名字对应的就是我们内核的版本。

新建好文件夹以后,我们使用下面的命令将我们编译好的.ko文件复制到这个被我们新建好的文件夹中:

 sudo cp chrdevbase.ko /home/chulingxiao/linux/nfs/rootfs/lib/modules/5.4.31/

完成以后,我们来到开发板一侧,首先使用下面的命令生成“modules.dep”文件:

depmod

命令执行以后,一般没有提示,我们继续操作即可,我们使用“modprobe”命令来加载我们已经编译好的.ko文件:

modprobe chrdevbase

加载模块时就不需要写.ko了,直接写模块名即可,modprobe命令会去“/lib/modules/5.4.31”下找模块。输入命令回车以后,就可以看到我们的模块正常被加载了:

但是前面有几行信息,因为我们没有指定开源协议与作者信息。后面再加上即可。

模块加载后,打印出了对应的日志说明我们模块加载的函数被正常执行了,我们可以使用下面的命令来查看已经被加载的模块:

lsmod

下面我们来演示如何卸载模块,这里我们使用“rmmod”命令来卸载模块:

rmmod chrdevbase

输入模块卸载命令以后,可以发现,内核打印出了模块卸载的信息,说明我们卸载模块的函数被正常调用。至此,我们模块的加载与卸载就完成了。

3.字符设备的注册与注销

        当我们能够正常加载与卸载驱动模块后,我们就可以来编写字符设备的注册与注销函数了,

字符设备的注册与注销主要会使用到下面两个函数:

static inline int register_chrdev(unsigned int major,const char *name, const struct file_operations *fops) 
static inline void unregister_chrdev(unsigned int major,const char *name) 

下面我们将这连个函数单独拿出来,分析一下每个参数的含义:

static inline int register_chrdev(unsigned int major,const char *name, const struct file_operations *fops) 

这里字符设备注册函数中的参数及解释如下:

unsigned int major:主设备号,Linux下每个设备都有一个设备号,设备号分为主设备号和次设备号两部分。后续如果使用到会讲解。

const char *name:表示注册的设备的名字,指向一个字符串。

const struct file_operations *fops:file_operations结构体类型指针,指向一个file_operations类型的结构体,在这个结构体中,我们会定义对于这个模块的操作函数如write(),read()等。

相比注册函数,注销函数就要简单许多了:

static inline void unregister_chrdev(unsigned int major,const char *name) 

unsigned int major:同样的,是主设备号。和注册函数中使用到的是一样的。

const char *name:同样表示注册设备的名字,这里就不多解释了。

了解了这两个函数的参数以后,我们现在就来写代码,首先就是设备号,我们应该怎样确定我们设备的设备号?我们可以使用下面的命令打印一下已经被使用的设备号:

cat /proc/devices

这里我们可以看到许多已经被注册了设备的设备号:

这里我们只需要找一个没有被使用的设备号作为我们设备的设备号即可。观察发现,200这个设备号没有被任何设备使用,那么,我们就直接使用200作为我们字符设备的设备号。

这里我们直接在代码中写一个宏定义来定义我们的设备号,后面遇到需要传入设备号的地方直接传入这个宏定义即可:

#define  CHRDEVBASE_MAJOR  200

然后我们再写一个宏定义来定义我们设备的名字:

#define  CHRDEVBASE_NAME "chrdevbase"

写好这些以后,我们就可以把我们的字符设备注册与注销函数复制过来了,如图所示:

这里有一点需要注意的是,字符设备的注册函数一定要放在驱动模块的加载函数中。字符设备的注销函数一定要放在驱动模块的卸载函数中。

因为注册函数要求传入一个“file_operations”的结构体指针,我们下面创建一个“file_operations”结构体类型的变量:

static struct file_operations chrdevbase_fops = { };

准备好上面的内容以后,我们就可以向字符设备的注册与注销函数中传参数了,完成以后,如图所示:

完整代码如下:

#include <linux/kernel.h>
#include <linux/gfp.h>
#include <linux/module.h>
#include <linux/init.h>
#include <linux/proc_fs.h>
#include <linux/seq_file.h>
#include <linux/uaccess.h>

#define  CHRDEVBASE_MAJOR  200
#define  CHRDEVBASE_NAME "chrdevbase"

static struct file_operations chrdevbase_fops = { };

static int __init  chrdevbase_init(void)
{
	
	register_chrdev(CHRDEVBASE_MAJOR,CHRDEVBASE_NAME, &chrdevbase_fops) ;
	printk("chrdevbase_init\r\n");
	return 0;
}
static void __exit chrdevbase_exit(void)
{
	unregister_chrdev(CHRDEVBASE_MAJOR,CHRDEVBASE_NAME) ;
	printk("chrdevbase_exit\r\n");
}

module_init(chrdevbase_init);
module_exit(chrdevbase_exit);

字符设备的注册函数有一个Int类型的返回值,我们可以通过这个返回值来判断设备是否注册成功,设备注册成功后,注册函数会返回0,我们下面写一些简单的判断语句,完成以后如图所示:

当我们注册成功以后,就会打印“chrdevbase_register”。下面我们同样将我们的驱动模块编译来测试一下我们的注册与注销是否生效。为了防止每次我们编译了都需要将模块复制到对应的目录,我们这里在Makefile中一些内容,使编译器每次编译完了自动帮我们拷贝到目录:

KERNELDIR := /home/chulingxiao/linux/LINUX/my-linux/linux-5.4.31 
CURRENT_PATH := $(shell pwd)
TARGET_PATH := /home/chulingxiao/linux/nfs/rootfs/lib/modules/5.4.31

obj-m := chrdevbase.o    
build: kernel_modules 

kernel_modules:     
	$(MAKE) -C $(KERNELDIR) M=$(CURRENT_PATH) modules 
    @cp $(CURRENT_PATH)/chrdevbase.ko $(TARGET_PATH)
clean:    
	$(MAKE) -C $(KERNELDIR) M=$(CURRENT_PATH) clean

这里大家需要将“TARGET_PATH”替换为自己驱动模块存放的路径。

我们这里直接make即可:

如果在文件复制时提示权限不够,可以将上级文件夹的权限修改为777。

编译完成以后,我们来到板端,我们再次加载模块:

我们可以看到,加载模块时,模块也正常注册了并且打印出了相关信息。说明我们的注册函数是生效的。然后我们再卸载模块:

我们可以看到,模块的卸载也同样正常。

4.字符设备的open(),release(),read(),write()函数

        当我们能够注册与注销设备以后,就可以来实现我们驱动设备的打开、关闭、读、写函数了

这里我们不知道这些函数的模板是怎样的,我们同样可以去借鉴别人的,这些函数的原型不就是被定义在了“file_operations”结构体了吗?我们直接去到“file_operations”结构体看不就行了嘛:

根据这几个函数原型,我们就可以得到我们设备对应的open(),release(),read(),write()函数,完成以后,如下图所示,这里需要一定的C语言功底,如果你并不清楚为什么要这样写,只需要跟着我写即可,不用纠结:

static int chrdevbase_open(struct inode *inode, struct file *filp)
{
	return 0;
}
static int chrdevbase_release(struct inode *inode, struct file *filp)
{

	return 0;
}
static ssize_t chrdevbase_read(struct file *filp, char __user *buf, size_t cnt, loff_t *offt)
{
	return 0;
}
static ssize_t chrdevbase_write(struct file *filp, const char __user *buf, size_t cnt, loff_t *offt)
{
	return 0;
}

这里我们主要分析读取与写入函数,我们还是单独拿出来分析:

static ssize_t chrdevbase_read(struct file *filp, char __user *buf, size_t cnt, loff_t *offt)

首先就是读取函数,当用户空间调用读取函数时,内核驱动中的读取函数会被调用,内核驱动中的函数会读取设备数据并且返回给用户空间。因为用户空间不能直接读取内核空间中的内容,我们需要使用下面的函数来将内核空间的内容复制到用户空间中:

copy_to_user();

而用户空间能够访问的内存就是我们读取函数中的“char __user *buf”参数,我们后续会使用读取函数对这个参数进行操作。后面的“size_t cnt”参数表示要读取的内存长度,由用户程序指定。

static ssize_t chrdevbase_write(struct file *filp, const char __user *buf, size_t cnt, loff_t *offt)

然后就是写入函数,当用户空间调用写入函数时,内核驱动中的写入函数会被调用,用户空间会向内核空间写入数据,内核空间会对这些数据进行操作从而写入到对应设备。因为用户空间不能直接操作内核空间的内存,我们需要借助下面的函数来将用户空间的数据拷贝到内核空间:

copy_from_user()

函数中的“char __user *buf”参数就是用户空间给内核空间的数据,“ize_t cnt”就是用户空间给内核空间写的数据的长度,由用户空间指定。

了解了上面的信息以后,我们就可以来给这些函数填写内容了。我们这里先把简单的open()与release(),写了,这里的打开与关闭设备函数,只需要在设备打开和关闭时打印一下提示即可,编写好如图:

然后就是读写函数,这里我们先定义两个区域,分别用于读缓存与写缓存:

下面我们先来编写读函数的逻辑。前面也提到了,读函数是用户空间向我们请求数据,我们需要返回一段数据到用户空间,我们这里需要拷贝数据到“buf”中。具体实现如下:

static ssize_t chrdevbase_read(struct file *filp, char __user *buf, size_t cnt, loff_t *offt)
{
	memcpy(read_buf,"Hello User\r\n",100);
	copy_to_user(buf,read_buf,cnt);
	return 0;
}

这里的代码非常简单就不多说了。因为我们现在是测试阶段,所以就没对代码做过多的错误处理。

下面我们来实现写函数,当用户空间对我们写数据时,我们需要将用户空间对我们写的数据使用相关函数拷贝到内核空间,实现逻辑如下:

static ssize_t chrdevbase_write(struct file *filp, const char __user *buf, size_t cnt, loff_t *offt)
{
	copy_from_user(write_buf, buf, cnt);
	printk("Receive UserData:%s",write_buf);
	return 0;
}

这里当内核空间收到用户空间发送的数据时,将其拷贝大内核空间中并且打印出来。

这样,我们的写函数就写好了。

当我们的打开,关闭,读,写,函数都写好以后,我们需要将这些函数传入我们之前定义的“file_operations”类型的结构体中,这样驱动程序才知道该调用哪个函数,我们直接在驱动加载部分编写以下代码:

	chrdevbase_fops.owner=THIS_MODULE;
	chrdevbase_fops.open=chrdevbase_open;
	chrdevbase_fops.release=chrdevbase_release;
	chrdevbase_fops.read=chrdevbase_read;
	chrdevbase_fops.write=chrdevbase_write;

完成以后,如图所示:

是不是有STM32那味儿了,虽然我们现在也是在写STM32的代码,哈哈!

写完上面的内容以后,我们的字符设备的几个重要函数就实现好了,下面我们来编译,同样执行make:

这里有两个警告,一个是在说我们定义变量在函数语句以后,另一个是说我们复制的内存可能越界了,我们简单修改以后,如图:

再次编译,我们发现已经没有警告了:

至此,我们这个测试用的字符设备的驱动就编写完成了。

5.APP代码编写

        Linux驱动被写出来就是为了让上层APP调用,我们现在就来编写APP相关的代码,我们还是在同级目录下新建一个名为“chrdevbase_APP.c”的文件:

这里我们先将对应的头文件添加进去:

#include "stdio.h" 
#include "unistd.h" 
#include "sys/types.h" 
#include "sys/stat.h" 
#include "fcntl.h" 
#include "stdlib.h" 
#include "string.h" 

完成以后,如图所示:

然后我们来编写用户程序,这里的用户程序比较简单,我就一次性写好,然后贴过来了:

#include "stdio.h" 
#include "unistd.h" 
#include "sys/types.h" 
#include "sys/stat.h" 
#include "fcntl.h" 
#include "stdlib.h" 
#include "string.h" 

 int main(int argc, char *argv[]) 
 {
    char read_buf[100], write_buf[100]; 
    int fd;
    char* devname = argv[1];
    fd = open(devname, O_RDWR); 
    read(fd, read_buf, 100);
    printf("Read KernelData:%s\r\n",read_buf);
    sleep(1); 
    memcpy(write_buf, "Hello Kernel\r\n", 14); 
     write(fd, write_buf, 50);
     close(fd); 
 }

 

在我们的用户程序中,我们首先传入我们创建的字符设备的设备文件路径,然后使用对应函数打开设备,再通过读函数,读我们内核中的内容,然后等待一秒钟通过写函数向内核写内容,最后关闭设备。这里需要注意的是,在用户空间我们打印直接使用printf即可。

我们这里使用下面的命令编译我们编写好的用户程序:

arm-none-linux-gnueabihf-gcc chrdevbase_APP.c -o chrdevbase_APP

然后在根文件系统中新建一个名为“Linux_Drivers”的文件夹,然后把我们编译好的用户可执行程序放进去:

然后我们现在先不着急启动,我们首先加载我们的驱动:

然后使用下面的命令为我们的驱动创建设备节点文件:

mknod /dev/chrdevbase c 200 0 

这里的200就是我们的主设备号,0就是次设备号,“/dev/chrdevbase”就是我们设备挂载的文件节点。命令执行完成以后,我们就可以看到我们的驱动程序已经挂载到了/dev目录下:

然后我们直接执行我们的用户程序,这里记得要传入设备文件名:

./chrdevbase_APP /dev/chrdevbase

执行以后,我们就可以看到我们向内核读文件,然后过一秒,内核会打印出用户空间向它写文件,内核的打印与用户空间的打印是不一样的,很容易就能看出来:

至此,我们的第一个Linux驱动程序就编写完成了。

五、结语

        经过我们的不懈努力,终于编写好了我们的第一个Linux驱动程序并且使其可以正常与用户程序进行交互,当然,这只是一个开始,我们并没有真正的驱动某个硬件,后面还有许多东西需要学习,那么最后,感谢大家的观看!


网站公告

今日签到

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