Skip to main content

字符设备驱动框架

应用编程与裸机编程、驱动编程有什么区别?

我们说的这三种编程分别是指:

  • 应用编程:基于 Linux 操作系统的应用编程,在应用程序中通过调用系统调用 API 完成应用程序的功能和逻辑,应用程序运行于操作系统之上。
  • 裸机编程:没有操作系统支持的编程环境称为裸机编程环境
  • 驱动编程:基于内核驱动框架开发驱动程序,驱动开发工程师通过调用 Linux 内核提供的接口完成设备驱动的注册

裸机程序当中,硬件操作代码和用户逻辑代码没有分离,也没有操作系统支持,编译之后就直接跑在硬件上。这就是“裸跑”。

而如果添加驱动,我们可以添加驱动函数,比如说open、write、release方法,当应用程序调用open系统调用,就会执行open函数。

应用程序和驱动程序应当是分离的,它们的编译也应当是独立的。应用程序运行在操作系统之上,有操作系统支持,应用程序处于用户态,而驱动程序处于内核态。

1. 库函数

我们移植项目经常会遇见.so文件,这个.so文件就是库函数,库函数就是应用层使用的函数库,以动态.so库文件的形式提供,存放在lib之下。

  • 库函数是属于应用层,而系统调用是内核提供给应用层的编程接口,属于系统内核的一部分
  • 库函数运行在用户空间,调用系统调用会由用户空间陷入到内核空间。
  • 库函数通常是有缓存的,而系统调用是无缓存的,所以在性能、效率上,库函数通常要优于系统调用。
  • 可移植性:库函数相比于系统调用具有更好的可移植性。虽然在不同的操作系统,内核向应用层提供的系统调用往往不同,但C语言库函数在不同操作系统中往往都是一样的,所以说可移植性是相对来说更好的。

glibc源码获取

git clone https://sourceware.org/git/glibc.git
cd glibc
git checkout master

或者:

git clone https://sourceware.org/git/glibc.git
cd glibc
git checkout release/2.33/master

或者通过ftn下载。

Linux的glibc版本可以通过libc文件内容来确定。lib目录下方可能会存放一个libc.so.6文件,但是也可能还存在于其下方一个linux-gnu的目录中,需要find去找。

libc文件中,其stable release version x.xx就指的是glibc的版本号。

main函数

许多开发中,main函数都是作为入口函数存在的。Linux开发中也是如此。

main函数的形参一般会有两种写法,如果执行应用程序无法传参,则可以写成如下形式:

int main(void){
/* code here,不传参 */
}

传参写法:

int main(int argc,char **argv){
/* code here */
}

或者这样传递参数

./exec 11

此时传入了两个参数。

第一个参数就是"./exec",第二个参数就是"11"。

而且是argv[0]和argv[1]。因为argc 形参表示传入参数的个数,所以argc是2。

一个实际项目下需要管理成百上千的.c源文件。要保证他们之间正常的互相工作不是一件容易的事,所幸我们可以使用cmake来进行管理和编译,而不需要耗费大量的时间编写Makefile。

2. 文件IO

简单的示例

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main(void){
char buf[1024];
int fd1 = open("test.txt",O_RDONLY); /*只读方式*/
int fd2 = open("test.txt",O_WRONLY);
if (-1 == fd1)
return fd1;
if (-1 == fd2){
ret = fd2;
goto out1;
}
/* 读取源文件1KB数据到buf中去 */
ret = read(fd1,buf,sizeof(buf));
if (-1 == ret){
goto out2;
}

/* 将buf中的数据写入到目标文件中去 */
ret = write(fd2,buf,sizeof(buf));
if (-1 == ret){
goto out2;
}

ret =0;

out2:
/*关闭文件*/
close(fd2);

out1:
/*关闭源文件*/
close(fd1);
return ret;
}

文件描述符

调用 open 函数会有一个非负整数的返回值,它就是文件描述符。

如果超过进程可打开的最大文件数限制,内核将会发送警告信号给对应的进程,然后结束进程;

通过 ulimit命令来查看进程可打开的最大文件数,用法如下所示:

ulimit -n

该最大值默认情况下是 1024,也就是一个进程最多可以打开1024个文件。它可以通过ulimit -n xxx来修改这个值。

文件描述符是一种有限资源,从0开始分配。所以由此可知,文件描述符数字最大值为 1023(0~1023)。

每个被打开的文件在同一进程中都有一个喂一次的文件描述符,不可重复。

关闭文件后,文件描述符将被释放。

实际上,我们调用open函数打开文件后,文件描述符都是从3开始的,这是因为0、1、2是标准输入、输出、错误。这是已经默认分配好的。

标准输入一般是对应键盘,标准输出一般指的是LCD显示器。打开LCD设备时,所得到的文件描述符就是1,标准错误一般也是LCD显示器。

open打开文件

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
info

types.h是指定了各种基本数据类型:

  • off_t:表示文件偏移量的类型。
  • pid_t:表示进程ID的类型。
  • uid_t 和 gid_t:表示用户ID和组ID的类型。
  • size_t:表示对象的大小,通常用于 sizeof 运算符和内存管理函数。

stat.h提供文件状态相关的结构和宏定义,主要定义了 struct stat 结构,该结构包含了文件的各种属性,如文件类型、权限、所有者、大小、时间戳等。

flags参数:

  • O_RDONLY:只读方式打开文件。
  • O_WRONLY:只写方式打开文件。
  • O_RDWR:读写方式打开文件。
  • O_CREAT:如果文件不存在,则创建该文件。
  • O_DIRECTORY:如果 pathname 参数指向的不是一个目录,则调用 open 失败。
  • O_EXCL:和 O_CREAT 配合使用,如果文件已经存在,则返回错误。
  • O_TRUNC:如果文件存在且为只读文件,则返回错误。如果文件存在且为只
  • O_NOFOLLOW::如果 pathname 参数指向的是一个符号链接,将不对其进行解引用,直接返回错误。
open("./src_file",O_RDONLY) //单个标志
open("./src_file",O_RDONLY|O_NOFOLLOW) //多个标志组合

此参数用于指定新建文件的访问权限,只有当 flags 参数中包含 O_CREAT 或 O_TMPFILE 标志时才有效(O_TMPFILE 标志用于创建一个临时文件)。

最高权限表示方法:111111111(二进制表示)、777(八进制表示)、511(十进制表示)。

不同的宏定义表示不同的权限:

  • S_IRUSR:表示当前用户具有读取权限。
  • S_IWUSR:表示当前用户具有写入权限。
  • S_IXUSR:表示当前用户具有执行权限。
  • S_IRGRP:表示当前用户组具有读取权限。
  • S_IWGRP:表示当前用户组具有写入权限。
  • S_IXGRP:表示当前用户组具有执行权限。
  • S_ISUID:set-user-ID,特殊权限
  • S_ISGID:set-group-ID,特殊权限
  • S_ISVTX:sticky,特殊权限

这些宏既可以单独使用,也可以通过位或运算将多个宏组合在一起,譬如:

S_IRUSR | S_IWUSR | S_IROTH

返回值:成功将返回文件描述符,文件描述符是一个非负整数;失败将返回-1。

(1) 使用 open 函数打开一个已经存在的文件(例如当前目录下的 app.c 文件),使用只读方式打开:

int fd = open("app.c", O_RDONLY);
if (fd == -1) {
return fd;
}

(2) 使用 open 函数打开一个已经存在的文件(例如当前目录下的 app.c 文件),使用可读可写方式打开:

int fd = open("app.c", O_RDWR);
if (fd == -1) {
return fd;
}

(3) 使用 open 函数打开一个指定的文件(譬如/home/dengtao/hello),使用可读可写方式,如果该文件是一个符号链接文件,则不对其进行解引用,直接返回错误:

int fd = open("/home/gufei/hello", O_RDWR | O_NOFOLLOW);
if (fd == -1) {
return fd;
}

(4)使用 open 函数打开一个指定的文件(譬如/home/dengtao/hello),如果该文件不存在则创建该文件,创建该文件时,将文件权限设置如下:

  • 文件所属者拥有读、写、执行权限;
  • 同组用户与其他用户只有读权限。
  • 使用可读可写方式打开:
int fd = open("/home/gufei/hello", O_RDWR | O_CREAT, S_IRWXU | S_IRGRP | S_IROTH);
if (-1 == fd)
return fd;

write 写文件

调用 write 函数可向打开的文件写入数据,其函数原型如下所示:

#include <unistd.h> 
ssize_t write(int fd, const void *buf, size_t count);

函数参数和返回值含义如下:

  • fd: 文件描述符
  • buf: 写入的数据缓冲区
  • count: 写入的数据长度
  • 返回值:如果成功将返回写入的字节数(0 表示未写入任何字节),如果此数字小于 count 参数,这不是错误,譬如磁盘空间已满,可能会发生这种情况;如果写入出错,则返回-1。

从文件的哪个位置开始进行读写操作?IO操作所对应的位置偏移量,读写操作都是从文件的当前位置偏移量处开始,当前位置偏移量可以通过lseek函数设置。

默认情况下,当前位置偏移量是0,即文件起始位置。当调用read、write函数完成读写操作,当前位置偏移量也会向后移动对应字节数。

read读文件

调用read函数可从打开的文件中读取数据,函数原型:

#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);

如果读取成功将返回读取到的字节数。实际读取到的字节数可能会小于 count 参数指定的字节数,也有可能会为 0。实际读取到的字节数少于要求读取的字节数,如在到达文件末尾之前有30个字节的数据,而要求读取100个字节,则read读取成功只能返回30,而下一次再调用read读,将返回0。

close关闭文件

close可以关闭一个已经打开的文件。

#include <unistd.h>
int close(int fd)

close可以显式关闭文件,但当一个进程终止时,内核会自动关闭它打开的所有文件,这是隐式的关闭文件。

但我们应当显式关闭不再需要的文件描述符。这是一个好习惯。

lseek

对于每个打开的文件,系统都会记录它的读写位置偏移量,我们也把这个读写位置偏移量称为读写偏移量,记录了文件当前的读写位置。

读写偏移量用于指示 read()write()函数操作时文件的起始位置,会以相对于文件头部的位置偏移量来表示,文件第一个字节数据的位置偏移量为 0。

#include <unistd.h>
#include <sys/types.h>
/* 调用 lseek 函数需要包含<sys/types.h>和<unistd.h>两个头文件 */
off_t lseek(int fd, off_t offset, int whence);

lseek()函数的参数 fd 是文件描述符, offset 是偏移量, whence 是偏移量类型, whence 的取值可以是 SEEK_SETSEEK_CURSEEK_END

#define SEEK_SET 0
#define SEEK_CUR 1
#define SEEK_END 2

offset可以为正,也可以为负。正数表示向文件尾部偏移,负数表示向文件头部偏移。

  • SEEK_SET: 读写偏移量将指向 offset 字节位置处(从文件头部开始算)
  • SEEK_CUR: 读写偏移量将指向当前位置加上 offset 字节位置处
  • SEEK_END: 读写偏移量将指向文件尾部加上 offset 字节位置处

执行成功将返回从文件头部开始到读写偏移量的距离,执行失败返回-1。

示例:

  1. 将读写位置移动到文件开头处:
off_t off = lseek(fd, 0, SEEK_SET);
if(off == -1)
return -1;
  1. 将读写位置移动到文件尾部处:
off_t off = lseek(fd, 0, SEEK_END);
if(off == -1)
return -1;
  1. 将读写位置移动到文件当前位置的指定偏移量处:
off_t off = lseek(fd, 10, SEEK_SET); /* 偏移量为10 */
if(off == -1)
return -1;
  1. 获取当前读写位置偏移量:
off_t off = lseek(fd, 0, SEEK_CUR);
if(off == -1)
return -1;

3. 文件IO进阶

3.1 Linux 系统如何管理文件

  • 静态文件:文件存放在磁盘文件系统中,并且以一种固定的形式进行存放。

文件在没有被打开的情况下一般都是存放在磁盘中的,硬盘的最小存储单位为“扇区”(Sector)。每个扇区储存 512 字节(0.5KB左右)。

操作系统读取硬盘的时候,不会一个个扇区地读取,而是一次性连续读取多个扇区,即一次性读取一个“块”。

由多个扇区组成的“块”,是文件存取的最小单位。块的大小常见是4KB。即连着8个扇区为一个Block。

磁盘分区时会将其分为两个区域。一个是数据区,一个是inode区,用于存放inode表。

inode实际上是一个结构体,这个结构体中有很多的元素,不同的元素记录了文件的不同信息,譬如文件字节大小、文件所有者、文件对应的读/写/执行权限、文件时间戳、文件类型、数据存储块地址等。

每个文件都有一个唯一的inode,inode又对应着某个数字编号,相当于索引,跟着这个inode就可以找到inode table对应的inode。

ls -i # 查看文件inode

返回值的第一个值就是inode编号。你也可以用stat xxx 命令查看文件inode。

stat xxx

所以,打开一个文件,系统内部发生的事情可以概括为:

  1. 通过文件名找到inode编号;
  2. 通过Inode编号找到inode结构体。
  3. 通过inode结构体确定文件所在的Block,从而读出数据。

为什么快速格式化磁盘可以找回数据?

快速格式化磁盘只是删掉了U盘的inode表,真正的数据区是没有动的,所以快速格式化的U盘是可以找回来数据的。

文件打开时的状态

调用Open函数时,内核会申请一段内存(缓冲区),并且将静态文件的数据内容从磁盘这些存储设备中读取到内存中进行管理、缓存。

这之后,对这个文件的读写操作,都是对这个缓冲区中的动态文件的操作,并不是对静态文件执行的。

对动态文件进行读写操作后,此时内存中的动态文件和磁盘设备中的静态文件不会同步,同步工作是由内核完成的。在之后,内存会把动态文件的内容同步到磁盘设备中。

磁盘等设备基本都是Flash块设备,块设备硬件有读写限制,想要改动它需要从属于的block先读出来,然后再修改,然后再写进块设备中去。但是如果用内存就没有这种限制,因为内存里不存在block这个概念,操作就比较灵活。

Linux中,内核为每个进程设置一个专门的数据结构用于管理该进程,用于记录进程的状态信息、运行特征等,称为进程控制块(Process Control Block,PCB)。

这个结构体中有一个指针指向看文件描述符表,这个表的每个元素索引到对应的文件表,而这个文件表也是一个数据结构体,它记录的是文件的相关信息。比如说文件状态标志、引用计数、文件的读写偏移量以及i-node指针等。

进程打开的所有文件对应的文件描述符都记录在文件描述符表中,每一个文件描述符都会指向一个对应的文件表。

Reference

[1]. 正点原子官方.正点原子【第四期】手把手教你学 Linux之驱动开发篇 [M/OL](2019-12-04)[2025-01-10]. https://www.bilibili.com/video/BV1fJ411i7PB/?p=3&share_source=copy_web&vd_source=8b2bc57e71349607b55c9fde6b078ebd