“AI 技术+人才”如何成为企业增长新引擎?戳此了解>>> 了解详情
写点什么

编写 Linux 内核模块——第二部分:字符设备

  • 2015-11-01
  • 本文字数:18828 字

    阅读完需:约 62 分钟

【编者的话】字符设备作为 Linux 设备中的一大类,它提供对按字节访问设备的抽象。用户空间应用程序可以通过标准文件操作来访问设备。本文来自 Derek Molloy 的博客,介绍了如何字符设备驱动的概念,以及如何编写和测试一个字符设备驱动。

前言

本系列文章中,主要描述如何为嵌入式 Linux 设备编写可加载内核模块(LKM)。这是该系列的第二篇文章,在阅读本文之前,请先阅读《编写Linux 内核模块——第一部分:前言》,它讲解了如何构建、加载和卸载可加载内核模块。这些描述不在本文中不再赘述。

字符设备驱动

字符设备通常和用户应用程序双向传输数据,它们的行为类似管道和串行接口,即时从字符流中读写字节数据。它们为许多典型的驱动提供了框架,比如那些需要和串行设备、视频捕捉设备和音频设备交互的驱动。字符设备的一种替代是块设备。块设备的行为类似普通文件,它可以允许程序查看缓存数据中的缓冲队列,或是通过读、写、查找等函数进行操作。两种设备类型都可以通过关联到文件系统树上的设备文件进行访问。例如,本文中的代码构建后,可以通过以下方式在Linux 系统中创建 /dev/ebbchar设备文件:

复制代码
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ lsmod
Module Size Used by
ebbchar 2754 0
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ ls -l /dev/ebb*
crw-rw-rwT 1 root root 240, 0 Apr 11 15:34 /dev/ebbchar

本文介绍了一个简单的字符设备,可用于用户空间应用程序和运行在内核空间的内核模块之间相互传递消息。在示例中,用 C 编写的用户空间应用程序发送字符串到内核模块。内核模块响应这条消息,并发回这条消息包含的字母数。然后,本文还将介绍为什么需解决示例中这种实现方式引发的同步问题,并且提供一个使用互斥锁的版本,解决这个同步问题。

在描述本文驱动的源代码前,需要先讨论一些概念,比如设备驱动的主设备号和次设备号,还有文件操作数据结构。

主设备号和次设备号

设备驱动有关联的主设备号和次设备号。例如,/dev/ram0 和 /dev/null 关联了主设备号为 1 的驱动,而 /dev/tty0 和 /dev/ttyS0 关联了主设备号为 4 的驱动。主设备号用于内核在设备访问时能够识别正确的设备驱动。次设备号的角色和设备相关,它主要使用在驱动中。如果在 /dev 目录中执行列出文件操作,可以看见每个设备的主 / 次设备号。比如:

复制代码
molloyd@beaglebone:/dev$ ls -l
crw-rw---T 1 root i2c 89, 0 Jan 1 2000 i2c-0
brw-rw---T 1 root disk 1, 0 Mar 1 20:46 ram0
brw-rw---T 1 root floppy 179, 0 Mar 1 20:46 mmcblk0
crw-rw-rw- 1 root root 1, 3 Mar 1 20:46 null
crw------- 1 root root 4, 0 Mar 1 20:46 tty0
crw-rw---T 1 root dialout 4, 64 Mar 1 20:46 ttyS0

输出中的第一列为“c”,表示这是一个字符设备,而为“b”表示这是一个块设备。每个设备都有授权访问的用户和组。BeagleBone 上的普通用户帐号是这些组中的成员,因此有权限访问 i2c-0 和 ttyS0 等设备。

复制代码
molloyd@beaglebone:/dev$ groups
molloyd dialout cdrom floppy audio video plugdev users i2c spi

本文开发的设备将在 **/dev目录中以设备文件的形式出现(/dev/ebbchar**)。

也可以手工创建一个块设备或者字符设备文件项,然后将它关联到指定设备上(即 sudo mknod /dev/test c 92 1),但是这个方式容易出现问题。其中一个问题就是,必须确保使用的设备号(即示例中的 92)没有被使用。在 BeagleBone 可以通过 /usr/src/linux-headers-3.8.13-bone70/include/uapi/linux/major.h 文件检查所有系统设备的主设备号。然后,使用此方法找到“唯一”的主设备号是不可移植的,因为在其他设备或者其他 Linux 单板机(发行版)中,主设备号可能冲突。本文的代码自动确认并使用一个合适的主设备号。

文件操作数据结构

file_operations数据结构定义在 /linux/fs.h 头文件中,它保存驱动中的函数指针,允许开发者定义文件操作行为。例如,列表 1 是从 /linux/fs.h 头文件中摘录的数据结构的片段。本文中的驱动提供了文件操作中的读、写、打开、释放这几个系统调用的实现。如果数据结构中的某些字段不需要实现,只需要简单的将它指向 NULL,这样这些字段将不可访问。列表 1 展示的操作函数的数量看上去是比较吓人的。然而,构建 ebbchar 内核模块,只需要提供其中四个字段的实现即可。因此,列表 1 提供了在驱动框架中可以扩展使用的额外函数接口。

复制代码
// 注意:__userNote 指向用户空间地址。
struct file_operations {
struct module *owner; // 指向拥有该结构的内核模块
loff_t (*llseek) (struct file *, loff_t, int); // 修改中间中当前读写的位置
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *); // 用于从设备读取数据
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *); // 用于向设备发送数据
ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long, loff_t); // 异步读
ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long, loff_t); // 异步写
ssize_t (*read_iter) (struct kiocb *, struct iov_iter *); // 可能异步读
ssize_t (*write_iter) (struct kiocb *, struct iov_iter *); // 可能异步写
int (*iterate) (struct file *, struct dir_context *); // 当虚拟文件系统(VFS)需要读取文件夹内容的时候调用
unsigned int (*poll) (struct file *, struct poll_table_struct *); // 读或写会阻塞?
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long); // 由 ioctl 系统调用使用
long (*compat_ioctl) (struct file *, unsigned int, unsigned long); // 由 ioctl 系统调用使用
int (*mmap) (struct file *, struct vm_area_struct *); // 由 mmap 系统调用使用
int (*mremap)(struct file *, struct vm_area_struct *); // 由 remap 系统调用使用
int (*open) (struct inode *, struct file *); // 设备文件初次使用时调用
int (*flush) (struct file *, fl_owner_t id); // 进程关闭或者复制文件描述符时调用
int (*release) (struct inode *, struct file *); // 当文件结构释放是调用
int (*fsync) (struct file *, loff_t, loff_t, int datasync); // 通知设备修改 FASYNC 标志
int (*aio_fsync) (struct kiocb *, int datasync); // 同步通知设备修改 FASYNC 标志
int (*fasync) (int, struct file *, int); // 异步通知设备修改 FASYNC 标志
int (*lock) (struct file *, int, struct file_lock *); // 用于文件锁的实现
};

列表 1:/linux/fs.h 中的文件操作数据结构(片段)

要了解更多信息, Kernel.org 虚拟文件系统为文件操作数据结构提供了优秀的文档。

本次讨论的源码

本次讨论的所有代码都在为《Exploring BeagleBone》准备的 GitHub 仓库上。代码可以在 ExploringBB GitHub 仓库内核工程目录中公开查看,或者也可以将代码复制到 BeagleBone(或者其他 Linux 设备):

复制代码
molloyd@beaglebone:~$ sudo apt-get install git
molloyd@beaglebone:~$ git clone https://github.com/derekmolloy/exploringBB.git

代码中 /extras/kernel/ebbchar 目录是本文最重要的资源。为这些示例代码自动生成的 Doxygen 文档有 HTML 格式 PDF 格式

设备驱动源码

ebbchar 设备驱动源码展示在列表 2 中。和本系列第一篇文章类似,里面有一个 init() 函数和 exit() 函数。除此之外,字符设备还需要一些额外的文件操作函数:

  • dev_open():用空空间每次打开设备的时候调用。
  • dev_read():从设备向用户空间发送数据的时候调动。
  • dev_write():从用户空间向设备发送数据的时候调用。
  • dev_release():用户空间关闭设备的时候调用。

设备驱动有一个类名和设备名。在列表 2 中,ebb(探索 BeagleBone,Exploring BeagleBone)作为类名,ebbchar作为设备名。这使得设备最终显示在文件系统的/sys/class/ebb/ebbchar中。

复制代码
/**
* @file ebbchar.c
* @author Derek Molloy
* @date 2015 年 4 月 7 日
* @version 0.1
* @brief 一个介绍性的字符设备驱动,作为 Linux 可加载内核驱动系列文章第二篇的示例。
* 该模块映射到 /dev/ebbchar 文件中,并且提供一个运行于 Linux 用户空间的 C 程序,
* 来和此内核模块进行交互。
* @see http://www.derekmolloy.ie/ 查看完整描述和补充描述。
*/
#include <linux/init.h> // 用于标记函数的宏,如 _init、__exit
#include <linux/module.h> // 将内核模块加载到内核中的核心头文件
#include <linux/device.h> // 支持内核驱动模型的头文件
#include <linux/kernel.h> // 包含内核中的类型、宏和函数
#include <linux/fs.h> // 支持 Linux 文件系统的头文件
#include <asm/uaccess.h> // 复制到用户用户空间函数需要的头文件
#define DEVICE_NAME "ebbchar" ///< 使用此值,设备将会展示在 /dev/ebbchar
#define CLASS_NAME "ebb" ///< 设备类名,这是一个字符设备驱动
MODULE_LICENSE("GPL"); ///< 许可类型,这回影响到可用功能
MODULE_AUTHOR("Derek Molloy"); ///< 作者,当使用 modinfo 命令时可见
MODULE_DESCRIPTION("A simple Linux char driver for the BBB"); ///< 描述,参见 modinfo 命令
MODULE_VERSION("0.1"); ///< 告知用户的版本号
static int majorNumber; ///< 保存主设备号,这里自动确定
static char message[256] = {0}; ///< 用于保存从用户空间传输过来字符串的内存
static short size_of_message; ///< 用于记录保存的字符串长度
static int numberOpens = 0; ///< 用于保存设备打开次数的计数器
static struct class* ebbcharClass = NULL; ///< 设备驱动类结构体指针
static struct device* ebbcharDevice = NULL; ///< 设备驱动设备结构体指针
// 字符设备操作的函数原型,必须在结构体定义前定义
static int dev_open(struct inode *, struct file *);
static int dev_release(struct inode *, struct file *);
static ssize_t dev_read(struct file *, char *, size_t, loff_t *);
static ssize_t dev_write(struct file *, const char *, size_t, loff_t *);
/** @brief 设备在内核中被表示为文件结构。 /linux/fs.h 中定义的 file_operations 结构体,
* 它使用 C99 语法的结构体,列举了文件操作关联的回调函数。
* 字符设备通常需要实现 open、read、write 和 release 函数。
*/
static struct file_operations fops =
{
.open = dev_open,
.read = dev_read,
.write = dev_write,
.release = dev_release,
};
/** @brief 可加载内核模块初始化函数
* static 关键字限制该函数的可见性在该 C 文件之内。 The __init
* __init 宏对于内置驱动(非可加载内核模块)来说,只在初始化时调用,在此之后,该函数将被废弃,内存将被回收。
* @return 如果成功返回 0
*/
static int __init ebbchar_init(void){
printk(KERN_INFO "EBBChar: Initializing the EBBChar LKM\n");
// 尝试为这个设备动态生成一个主设备号,虽然麻烦一点,但这是值得的
majorNumber = register_chrdev(0, DEVICE_NAME, &fops);
if (majorNumber<0){
printk(KERN_ALERT "EBBChar failed to register a major number\n");
return majorNumber;
}
printk(KERN_INFO "EBBChar: registered correctly with major number %d\n", majorNumber);
// 注册设备类
ebbcharClass = class_create(THIS_MODULE, CLASS_NAME);
if (IS_ERR(ebbcharClass)){ // 如果有错误,清理环境
unregister_chrdev(majorNumber, DEVICE_NAME);
printk(KERN_ALERT "Failed to register device class\n");
return PTR_ERR(ebbcharClass); // 对于指针类型返回错误消息正确的方式
}
printk(KERN_INFO "EBBChar: device class registered correctly\n");
// 注册设备驱动
ebbcharDevice = device_create(ebbcharClass, NULL, MKDEV(majorNumber, 0), NULL, DEVICE_NAME);
if (IS_ERR(ebbcharDevice)){ // 如果有错误,清理环境
class_destroy(ebbcharClass); // 重复的代码,可选方式是使用 goto 语句
unregister_chrdev(majorNumber, DEVICE_NAME);
printk(KERN_ALERT "Failed to create the device\n");
return PTR_ERR(ebbcharDevice);
}
printk(KERN_INFO "EBBChar: device class created correctly\n"); // 搞定,设备已经初始化
return 0;
}
/** @brief 可加载内核模块清理函数
* 和初始化函数类似,该函数是静态的。__exit 宏标识如果这个代码是使用在内置驱动(非可加载内核模块)中,该函数不需要。
*/
static void __exit ebbchar_exit(void){
device_destroy(ebbcharClass, MKDEV(majorNumber, 0)); // 移除设备
class_unregister(ebbcharClass); // 注销设备类
class_destroy(ebbcharClass); // 移除设备类
unregister_chrdev(majorNumber, DEVICE_NAME); // 注销主设备号
printk(KERN_INFO "EBBChar: Goodbye from the LKM!\n");
}
/** @brief 每次设备被代开的时候调用的设备打开函数
* 在本例中,该函数只是简单的累加 numberOpens 计数器。
* @param inodep 指向 inode 对象的指针(定义在 linux/fs.h 头文件中)
* @param filep 指向文件对象指针(定义在 linux/fs.h 头文件中)
*/
static int dev_open(struct inode *inodep, struct file *filep){
numberOpens++;
printk(KERN_INFO "EBBChar: Device has been opened %d time(s)\n", numberOpens);
return 0;
}
/** @brief 该函数在设备从用户空间读取的时候被调用,即数据从设备向用户空间传输。
* 在本例中,通过 copy_to_user() 函数将缓冲区中的字符串发送给用户,并且捕获任何异常。
* @param filep 指向文件对象的指针(定义在 linux/fs.h 头文件中)
* @param buffer 指向本函数写入数据的缓冲区指针
* @param len 缓冲区长度
* @param offset 此次读取在内核缓冲区中的偏移量
*/
static ssize_t dev_read(struct file *filep, char *buffer, size_t len, loff_t *offset){
int error_count = 0;
// copy_to_user 函数参数格式为 ( * to, *from, size),如果成功返回 0
error_count = copy_to_user(buffer, message, size_of_message);
if (error_count==0){ // 如果为 true,表示调用成功
printk(KERN_INFO "EBBChar: Sent %d characters to the user\n", size_of_message);
return (size_of_message=0); // 清楚当前位置标记,并且返回 0
}
else {
printk(KERN_INFO "EBBChar: Failed to send %d characters to the user\n", error_count);
return -EFAULT; // 失败,返回无效地址消息(即 -14)
}
}
/** @brief 该函数在设备想用户空间写入的时候调用,即从数据从用户发往设备。
* 在此内核模块中,数据通过 sprintf() 函数复制到 message[] 数组中,同时字符串长度被保存到 size_of_message 变量中。
* @param filep 指向文件对象的指针
* @param buffer 包含待写入设备字符串的缓冲区
* @param len 传递到 const char 类型缓冲区的数据长度
* @param offset 文件设备当前偏移量
*/
static ssize_t dev_write(struct file *filep, const char *buffer, size_t len, loff_t *offset){
sprintf(message, "%s(%d letters)", buffer, len); // 通过长度追加当前接受到的字符串
size_of_message = strlen(message); // 保存当前保存的信息的长度
printk(KERN_INFO "EBBChar: Received %d characters from the user\n", len);
return len;
}
/** @brief 当设备被用户空间程序关闭 / 释放时调用的函数。
* @param inodep 指向 inode 对象的指针(定义在 linux/fs.h 头文件中)
* @param filep 指向文件对象的指针(定义在 linux/fs.h 头文件中)
*/
static int dev_release(struct inode *inodep, struct file *filep){
printk(KERN_INFO "EBBChar: Device successfully closed\n");
return 0;
}
/** @brief 内核模块必须使用 linux/init.h 头文件中提供的 module_init()、module_exit() 宏,
* 在插入和清理的时候标识对应的函数(如上所列)
*/
module_init(ebbchar_init);
module_exit(ebbchar_exit);

列表 2:ebbchar 内核模块( /extras/kernel/ebbchar/ebbchar.c

除了列表 2 中的注释,这里还有一些补充:

  • 代码中固定了消息长度为 256 个字符,在下文中会改造成动态申请内存。
  • 代码非多进程安全,下文中也会提到。
  • ebbchar_init() 函数比上一篇文章中的要长很多。因为它现在自动申请设备的主设备号,注册设备类并且注册设备驱动。重要的是,如果任何地方出错,代码会小心的终止已经成功的操作。为了达到这点,中间有重复的代码(这是我非常不喜欢的),但是替代方案是使用 goto 语句,这更是不可接受的(虽然稍微整洁一些)。
  • PTR_ERR() 是一个定义在 linux/err.h 头文件中的函数,用于从指针中抽取出错误码。
  • sprintf() 和 strlen() 函数可以通过包含 linux/kernel.h 头文件或者直接通过 linux/string.h 使用。string.h 中的函数是和平台相关的。

下一步是将这些代码构建成内核模块。

构建和测试可加载内核模块

构建可加载内核模块需要 Makefile 文件,在列表 3 中提供。这个 Makefile 文件和本系列的第一篇文章中的 Makefile 文件类似,例外是它同时构建了一个可以和可加载内核模块进行交互的用户空间 C 程序。

复制代码
obj-m+=ebbchar.o
all:
make -C /lib/modules/$(shell uname -r)/build/ M=$(PWD) modules
$(CC) testebbchar.c -o test
clean:
make -C /lib/modules/$(shell uname -r)/build/ M=$(PWD) clean

列表 3:构建可加载内核模块和用户空间程序的 Makefile 文件( /extras/kernel/ebbchar/Makefile

列表 4 是一个简单的程序,它向用户请求一个字符串,写入到/dev/ebbchar设备。在随后的按键(回车)之后,它从设备读取响应,并且显示在终端窗口中。

复制代码
/**
* @file testebbchar.c
* @author Derek Molloy
* @date 2015 年 4 月 7 日
* @version 0.1
* @brief Linux 用户空间程序,用于和 ebbchar.c 内核模块进行交互。
* 它传递一个字符串到内核模块,并且从内核模块读取响应。在此示例中,必须使用 /dev/ebbchar 设备。
* @see http://www.derekmolloy.ie/ 查看详细描述和后续描述。
*/
#include<stdio.h>
#include<stdlib.h>
#include<errno.h>
#include<fcntl.h>
#include<string.h>
#define BUFFER_LENGTH 256 ///< 缓冲区长度(简陋但是可以工作)
static char receive[BUFFER_LENGTH]; ///< 可加载内核模块接收缓存
int main(){
int ret, fd;
char stringToSend[BUFFER_LENGTH];
printf("Starting device test code example...\n");
fd = open("/dev/ebbchar", O_RDWR); // 使用读写权限打开设备
if (fd < 0){
perror("Failed to open the device...");
return errno;
}
printf("Type in a short string to send to the kernel module:\n");
scanf("%[^\n]%*c", stringToSend); // 读取字符串(包含空格)
printf("Writing message to the device [%s].\n", stringToSend);
ret = write(fd, stringToSend, strlen(stringToSend)); // 将字符串发送给内核模块
if (ret < 0){
perror("Failed to write the message to the device.");
return errno;
}
printf("Press ENTER to read back from the device...\n");
getchar();
printf("Reading from the device...\n");
ret = read(fd, receive, BUFFER_LENGTH); // 从内核模块读取响应
if (ret < 0){
perror("Failed to read the message from the device.");
return errno;
}
printf("The received message is: [%s]\n", receive);
printf("End of the program\n");
return 0;
}

列表 4:测试可加载内核模块使用的用户空间程序( /extras/kernel/ebbchar/testebbchar.c

列表 4 中,除了注释中提到的,还有一些补充的点:

  • %[^\n]%*c 使用了 scanf 函数的说明符,其中 %[] 中的 ^ 字符表示读取到第一个\n 字符时停止。此外,%*c 忽略尾随字符,确保后续的 getchar() 函数可以正常工作。从本质上说,这里的 scanf() 代码是要读取一个句子。如果 scanf() 使用普通的 %s 说明符,那么字符串将会在第一个空格字符处停止。
  • getchar() 函数允许程序在当时中断,直到回车键按下。这对于检查当前代码结构是否有问题是有必要的。
  • 程序然后读取可加载内核模块的响应,并显示在终端窗口中。

如果所有的进展都很顺利,构建内核模块的流程应该比较简单,但前提是已经按照第一篇文章安装了 Linux 头文件。构建步骤如下:

复制代码
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ make
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ ls -l *.ko
-rw-r--r-- 1 molloyd molloyd 7075 Apr 8 19:04 ebbchar.ko
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ ls -l test
-rwxr-xr-x 1 molloyd molloyd 6342 Apr 8 19:23 test
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ sudo insmod ebbchar.ko
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ lsmod
Module Size Used by
ebbchar 2521 0

现在,这个设备会展示在/dev目录中,包含以下属性:

复制代码
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ cd /dev
molloyd@beaglebone:/dev$ ls -l ebb*
crw------- 1 root root 240, 0 Apr 8 19:28 ebbchar

从输出可以看到,这个设备文件的主设备号是 240,它通过列表 2 中自动分配的代码生成的。

然后,就可以使用testebbchar程序(列表 4)来测试可加载内核模块是否正常工作。目前,这个测试程序必须使用 root 权限执行,这个问题会马上解决。

复制代码
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ sudo insmod ebbchar.ko
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ sudo ./test
Starting device test code example...
Type in a short string to send to the kernel module:
This is a test of the ebbchar LKM
Writing message to the device [This is a test of the ebbchar LKM].
Press ENTER to read back from the device...
Reading from the device...
The received message is: [This is a test of the ebbchar LKM(33 letters)]
End of the program
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ sudo rmmod ebbchar

printk() 函数的输出可以用如下步骤通过内核日志查看:

复制代码
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ sudo tail -f /var/log/kern.log
Apr 11 22:24:50 beaglebone kernel: [358664.365942] EBBChar: Initializing the EBBChar LKM
Apr 11 22:24:50 beaglebone kernel: [358664.365980] EBBChar: registered correctly with major number 240
Apr 11 22:24:50 beaglebone kernel: [358664.366061] EBBChar: device class registered correctly
Apr 11 22:24:50 beaglebone kernel: [358664.368383] EBBChar: device class created correctly
Apr 11 22:25:15 beaglebone kernel: [358689.812483] EBBChar: Device has been opened 1 time(s)
Apr 11 22:25:31 beaglebone kernel: [358705.451551] EBBChar: Received 33 characters from the user
Apr 11 22:25:32 beaglebone kernel: [358706.403818] EBBChar: Sent 45 characters to the user
Apr 11 22:25:32 beaglebone kernel: [358706.404207] EBBChar: Device successfully closed
Apr 11 22:25:44 beaglebone kernel: [358718.497000] EBBChar: Goodbye from the LKM!

这里会发现,向可加载内核模块发送了 33 个字符,但是返回了 45 个字符。这是因为多了 12 个字符“(33 letters)”来标记原始发送的字符串长度。这个用于测试的附加的字符,可以看出代码发送和接收的独特数据。

当前可加载内核模块中有两个重大的问题。第一个是可加载内核模块设备文件只能通过超级用户权限访问,另一个是当前的模块是非多进程安全的。

通过 Udev 规则修改设备的用户访问权限

在前面的示例中,应用程序和可加载内核模块设备交互的时候是通过 sudo 执行的。设置内核模块设备能够让特定的用户或者组访问,且仍然能够保护文件系统,是非常有用的。为了解决这个问题,可以通过使用 Linux 的高级特性:udev 规则,它能够让用户定制 udevd 服务的行为。该服务为 Linux 系统上的设备提供了用户空间的控制方式。

例如,要给ebbchar设备用户访问权限,第一步是找到该设备在 sysfs 上的文件项。可以通过简单的 find 命令实现:

复制代码
root@beaglebone:/sys# find . -name "ebbchar"
./devices/virtual/ebb/ebbchar
./class/ebb/ebbchar
./module/ebbchar

然后需要找到编写规则需要的 KERNEL 和 SUBSYSTEM 的值。这可以通过udevadm命令实现:

复制代码
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ udevadm info -a -p /sys/class/ebb/ebbchar
Udevadm info starts with the device specified by the devpath and then walks up the chain of parent
devices. It prints for every device found, all possible attributes in the udev rules key format. A
rule to match, can be composed by the attributes of the device and the attributes from one single
parent device.
looking at device '/devices/virtual/ebb/ebbchar':
KERNEL=="ebbchar"
SUBSYSTEM=="ebb"
DRIVER==""

规则保存在 **/etc/udev/rules.d目录中。新的规则可以通过使用这些值创建一个文件,这个文件名字以优先级编号开头。使用类似99-ebbchar.rules的名字创建一个有最低优先级的规则,以防止它影响其他设备规则。这个规则内容如列表 5 所示,存放在/etc/udev/rules.d** 目录中:

复制代码
molloyd@beaglebone:/etc/udev/rules.d$ ls
50-hidraw.rules 50-spi.rules 60-omap-tty.rules 70-persistent-net.rules 99-ebbchar.rules
molloyd@beaglebone:/etc/udev/rules.d$ more 99-ebbchar.rules
#Rules file for the ebbchar device driver
KERNEL=="ebbchar", SUBSYSTEM=="ebb", MODE="0666"
复制代码
#Rules file for the ebbchar device driver
KERNEL=="ebbchar", SUBSYSTEM=="ebb", MODE="0666"

列表 5:ebbchar 设备驱动的 Udev 规则( /extras/kernel/ebbchar/99-ebbchar.rules

一旦该规则文件添加到系统中以后,可以通过以下方式测试:

复制代码
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ sudo insmod ebbchar.ko
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ ls -l /dev/ebbchar
crw-rw-rwT 1 root root 240, 0 Apr 9 00:44 /dev/ebbchar

从以上输出可以看到,用户和组现在已经有权限读写设备了。有趣的是,文件权限也设置了粘滞位(sticky bit)。粘滞位通常表示有写权限,但是没有删除文件的权限。因此,/tmp文件夹任何用户都可以创建文件,但是用户不可以删除其他用户的文件。粘滞位通过在权限列中最后一个字符是大写的 T 标识。通常来说,如果其他用户的执行权限(x)位设置了,这里将显示一个小写的 t;而如果 x 位没有设置,这里显示的是一个大写的 T。然而,还没有完全搞清楚为什么 udev 设置了粘滞位,这似乎是 Debian 发行版中不寻常的 udev 规则。此时,测试应用程序可以在不需要超级用户权限的情况下执行了。

strace 命令

strace 命令是一个非常有用的调试工具,它可以执行一个程序,拦截和记录该程序执行的系统调用。系统调用名字、传递的参数和返回的结果都是可见的,因此它是解决运行时问题非常有价值的工具。重要的是,strace 在查看这些输出的时候,不需要提供可执行程序的源代码。例如,可以在应用空间应用程序中使用 strace 工具来查看用户空间程序和内核模块之间的交互。以下是test应用程序 strace 的结果:

复制代码
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ sudo apt-get install strace
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ strace -v
usage: strace [-dffhiqrtttTvVxx] [-a column] [-e expr] … [-o file]
[-p pid] … [-s strsize] [-u username] [-E var=val] …
[command [arg …]]
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ sudo strace ./test
execve("./test", ["./test"], [/* 15 vars */]) = 0
write(1, "Starting device test code exampl"..., 37Starting de…) = 37
open("/dev/ebbchar", O_RDWR) = 3
write(1, "Writing message to the device [T"..., 60Writing message …) = 60
write(3, "Testing the EBBChar device", 26) = 26
write(1, "Reading from the device...\n", 27Reading from the device…) = 27
read(3, "", 100) = 0
write(1, "The received message is: [Testin"..., 66The received …) = 66
write(1, "End of the program\n", 19End of the program) = 19
exit_group(0) = ?

系统调用的输出给了我们直观的视角来观察用户空间程序test/dev/ebbchar设备驱动交互的过程。

可加载内核模块的同步问题

列表 2 中描述的可加载内核模块有严重的问题。在本系列第一篇文章中,已经指出可加载内核模块不是顺序执行的,它会被打断。这些现象对本文所写的代码都有重要的影响,以下步骤来示范:

步骤 1:在第一个终端窗口中,执行test应用程序,但是不要让应用程序结束(在提示符出现后不要按下回车键):

复制代码
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ ./test
Starting device test code example...
Type in a short string to send to the kernel module:
This is the message from the first terminal window
Writing message to the device [This is the message from the first terminal window].
Press ENTER to read back from the device...

步骤 2:打开第二个终端窗口,执行同样的test应用程序,模拟 Linux 设备上的第二个进程。例如:

复制代码
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ ./test
Starting device test code example...
Type in a short string to send to the kernel module:
This is the message from the second terminal window
Writing message to the device [This is the message from the second terminal window].
Press ENTER to read back from the device...

步骤 3:现在返回第一个终端窗口,并且按下回车键,让应用程序运行完,此时有如下输出:

复制代码
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ ./test
Starting device test code example...
Type in a short string to send to the kernel module:
This is the message from the first terminal window
Writing message to the device [This is the message from the first terminal window].
Press ENTER to read back from the device...
Reading from the device...
The received message is: [This is the message from the second terminal window(51 letters)]
End of the program
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$

从上面输出可以看到,接受到的消息,实际上是步骤 2 中的test应用程序发出的,它运行在第二个终端窗口中(不是期望的第一个)。这是因为步骤 2 发出的消息覆盖了可加载内核模块中保存的步骤 1 的字符串消息。

步骤 4:回到第二个终端,按下回车让应用程序运行结束,结果输出为:

复制代码
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$ ./test
Starting device test code example...
Type in a short string to send to the kernel module:
This is the message from the second terminal window
Writing message to the device [This is the message from the second terminal window].
Press ENTER to read back from the device...
Reading from the device...
The received message is: []
End of the program
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbchar$

此时没有接收到字符串。这是因为此时可加载内核模块没有保存任何消息。它已经将保存的消息传递给第一个终端窗口的 test 应用程序,并且将缓冲区索引重置为 0.

增加互斥锁

Linux 内核模块提供了信号量的完整实现:一个包含用于控制多进程访问共享资源的数据类型(semaphore 结构体)。在内核中使用信号量最简单的方式是使用互斥量,因为它提供了全套的帮助函数和宏。

防止上述问题最简单的方式是阻止两个进程同时访问/dev/ebbchar设备。互斥量是一个可以在进程使用共享资源之前设置的锁。这个锁在进程使用完共享资源之后释放。当锁被设置后,其他进程无法访问被锁住的代码区域。一旦互斥锁被加锁的进程释放,共享区域的代码又可以重新被其他进程访问,这样又会对资源加锁。

为了实现互斥锁,对于代码只需要做很小的改动。这些改动的概要内容在列表 6 中展示:

复制代码
#include <linux/mutex.h> /// 互斥锁功能需要的头文件
static DEFINE_MUTEX(ebbchar_mutex); /// 定义互斥变量的宏,可见范围为本文件
/// 结果是创建一个值为 1(未上锁)的信号量变量 ebbchar_mutex
/// DEFINE_MUTEX_LOCKED() 宏的结果是创建一个值为 0(上锁)的变量
static int __init ebbchar_init(void){
mutex_init(&ebbchar_mutex); /// 在运行时动态初始化互斥锁
}
static int dev_open(struct inode *inodep, struct file *filep){
if(!mutex_trylock(&ebbchar_mutex)){ /// 尝试获取互斥量(即加锁)
/// 如果成功返回 1,如果有竞争返回 0
printk(KERN_ALERT "EBBChar: Device in use by another process");
return -EBUSY;
}
}
static int dev_release(struct inode *inodep, struct file *filep){
mutex_unlock(&ebbchar_mutex); /// 释放互斥变量(即解锁)
}
static void __exit ebbchar_exit(void){
mutex_destroy(&ebbchar_mutex); /// 销毁动态分配的互斥量
}

列表 6:ebbchar.c 程序代码引入互斥锁之后的概要改动

完整示例代码在 /extras/kernel/ebbcharmutex/ 目录中,最终的可加载内核模块源码文件是 ebbcharmutex.c 。如果在包含互斥锁代码版本中按照前面的步骤做同样的测试,将会观察到不同的行为。例如:

步骤 1:使用第一个终端窗口,可以加载模块和执行test应用程序,这会产出如下的输出:

复制代码
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbcharmutex$ sudo insmod ebbcharmutex.ko
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbcharmutex$ lsmod
Module Size Used by
ebbcharmutex 2754 0
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbcharmutex$ ls -l /dev/ebb*
crw-rw-rwT 1 root root 240, 0 Apr 12 15:26 /dev/ebbchar
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbcharmutex$ ./test
Starting device test code example...
Type in a short string to send to the kernel module:
Testing the ebbchar LKM that has mutex code
Writing message to the device [Testing the ebbchar LKM that has mutex code].
Press ENTER to read back from the device...

步骤 2:使用第二个终端窗口,尝试执行test应用程序,创建第二个进程:

复制代码
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbcharmutex$ ./test
Starting device test code example...
Failed to open the device...: Device or resource busy
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbcharmutex$

正如预期和所需要的那样,第二个进程访问设备失败。

步骤 3:返回到第一个终端窗口,程序可以通过按回车键继续运行直到结束:

复制代码
molloyd@beaglebone:~/exploringBB/extras/kernel/ebbcharmutex$ ./test
Starting device test code example...
Type in a short string to send to the kernel module:
Testing the ebbchar LKM that has mutex code
Writing message to the device [Testing the ebbchar LKM that has mutex code].
Press ENTER to read back from the device...
Reading from the device...
The received message is: [Testing the ebbchar LKM that has mutex code(43 letters)]
End of the program

在这个时候,第二个终端窗口可以执行test程序,于是这个程序可以获取互斥锁,并正确运行。

总结

本文中提到了几个不同的问题。本文最主要的成果有:

  • 创建了我们自己的设备(/dev/ebbchar),可以向设备发送和读取信息。这是非常重要的,它提供了 Linux 用户空间和内核空间的桥梁。使得我们能够开发高级驱动,例如通信驱动,这些驱动能够让用户空间 C 程序能够控制设备。
  • 明白了为什么在内核模块编程中,同步问题需要需要特别重视的。
  • 能够使用 udev 规则修改设备加载时修改设备属性。

本系列中的下一篇文章会介绍如何和通用输入输出接口(GPIO)设备(如物理按钮和 LED 电路)交互,让开发者能够为嵌入式 Linux 应用程序开发定制的驱动,来和定制的硬件进行交互。敬请阅读《编写 Linux 内核模块——第三部分:和通用输入输出接口设备交互》。最后,列表 7 和列表 8 提供了一份参照表,它们提供了本文使用的标准错误状态和它们关联的错误码和描述。

复制代码
#define EPERM 1 /* 操作不被允许 */
#define ENOENT 2 /* 文件或者目录不存在 */
#define ESRCH 3 /* 没有该进程 */
#define EINTR 4 /* 中断的系统调用 */
#define EIO 5 /* 输入输出错误 */
#define ENXIO 6 /* 没有该设备或地址 */
#define E2BIG 7 /* 参数列表太长 */
#define ENOEXEC 8 /* 执行文件格式错误 */
#define EBADF 9 /* 文件描述符错误 */
#define ECHILD 10 /* 子进程不存在 */
#define EAGAIN 11 /* 资源暂时不可用 */
#define ENOMEM 12 /* 内存不足 */
#define EACCES 13 /* 没有权限 */
#define EFAULT 14 /* 地址错误 */
#define ENOTBLK 15 /* 不是块设备 */
#define EBUSY 16 /* 设备或资源忙 */
#define EEXIST 17 /* 文件已存在 */
#define EXDEV 18 /* 夸设备链接 */
#define ENODEV 19 /* 设备不存在 */
#define ENOTDIR 20 /* 不是目录文件 */
#define EISDIR 21 /* 是目录文件 */
#define EINVAL 22 /* 参数无效 */
#define ENFILE 23 /* 文件表溢出 */
#define EMFILE 24 /* 打开文件太多 */
#define ENOTTY 25 /* 没有 tty 终端 */
#define ETXTBSY 26 /* 文本文件忙 */
#define EFBIG 27 /* 文件过大 */
#define ENOSPC 28 /* 设备没有剩余空间 */
#define ESPIPE 29 /* 非法文件指针重定位 */
#define EROFS 30 /* 只读文件系统 */
#define EMLINK 31 /* 链接过多 */
#define EPIPE 32 /* 断开的管道 */
#define EDOM 33 /* 数学函数参数超出范围 */
#define ERANGE 34 /* 函数结果超出范围 */

列表 7:可加载内核模块开发使用的错误状态(/usr/include/asm-generic/errno-base.h)

复制代码
#ifndef _ASM_GENERIC_ERRNO_H
#define _ASM_GENERIC_ERRNO_H
#include <asm-generic/errno-base.h>
#define EDEADLK 35 /* 资源可能死锁 */
#define ENAMETOOLONG 36 /* 文件名太长 */
#define ENOLCK 37 /* 没有锁可用 */
#define ENOSYS 38 /* 函数未实现 */
#define ENOTEMPTY 39 /* 目录非空 */
#define ELOOP 40 /* 遇到太多符号链接 */
#define EWOULDBLOCK EAGAIN /* 操作可能被阻塞 */
#define ENOMSG 42 /* 没有符合需求类型的消息 */
#define EIDRM 43 /* 标识符已删除 */
#define ECHRNG 44 /* 通道编号超出范围 */
#define EL2NSYNC 45 /* 2 级不同步 */
#define EL3HLT 46 /* 3 级停止 */
#define EL3RST 47 /* 3 级重置 */
#define ELNRNG 48 /* 链接编号超出范围 */
#define EUNATCH 49 /* 协议驱动程序没有连接 */
#define ENOCSI 50 /* 没有可用的 CSI 结构 */
#define EL2HLT 51 /* 2 级停止 */
#define EBADE 52 /* 无效交换 */
#define EBADR 53 /* 无效请求描述 */
#define EXFULL 54 /* 交换完全 */
#define ENOANO 55 /* 无 anode */
#define EBADRQC 56 /* 无效请求码 */
#define EBADSLT 57 /* 无效插槽 */
#define EDEADLOCK EDEADLK
#define EBFONT 59 /* 错误的字体文件格式 */
#define ENOSTR 60 /* 设备不是流 */
#define ENODATA 61 /* 无数据 */
#define ETIME 62 /* 计时器到期 */
#define ENOSR 63 /* 流资源不足 */
#define ENONET 64 /* 机器不在网络上 */
#define ENOPKG 65 /* 包未安装 */
#define EREMOTE 66 /* 对象是远程的 */
#define ENOLINK 67 /* 链接正在服务中 */
#define EADV 68 /* 广告错误 */
#define ESRMNT 69 /* 服务器远程挂载错误 */
#define ECOMM 70 /* 发送过程中通讯错误 */
#define EPROTO 71 /* 协议错误 */
#define EMULTIHOP 72 /* 试图多次反射 */
#define EDOTDOT 73 /* 远程文件共享(RFS)特定错误 */
#define EBADMSG 74 /* 不是数据消息 */
#define EOVERFLOW 75 /* 定义数据类型值太大 */
#define ENOTUNIQ 76 /* 网络名称不唯一 */
#define EBADFD 77 /* 文件描述符状态不良 */
#define EREMCHG 78 /* 改变远程地址 */
#define ELIBACC 79 /* 无法访问所需共享库 */
#define ELIBBAD 80 /* 访问损坏共享库 */
#define ELIBSCN 81 /* .out 中的.lib 部分损坏 */
#define ELIBMAX 82 /* 试图链接共享库太多 */
#define ELIBEXEC 83 /* 无法直接执行共享库 */
#define EILSEQ 84 /* 非法字节序列 */
#define ERESTART 85 /* 中断系统调用会重启 */
#define ESTRPIPE 86 /* 流管道错误 */
#define EUSERS 87 /* 用户太多 */
#define ENOTSOCK 88 /* 在非套接字上执行套接字操作 */
#define EDESTADDRREQ 89 /* 需要目的地址 */
#define EMSGSIZE 90 /* 消息太长 */
#define EPROTOTYPE 91 /* 套接字协议类型错误 */
#define ENOPROTOOPT 92 /* 协议不可用 */
#define EPROTONOSUPPORT 93 /* 协议不支持 */
#define ESOCKTNOSUPPORT 94 /* 套接字类型不支持 */
#define EOPNOTSUPP 95 /* 不支持操作传输端点 */
#define EPFNOSUPPORT 96 /* 协议系列不支持 */
#define EAFNOSUPPORT 97 /* 协议不支持地址系列 */
#define EADDRINUSE 98 /* 地址已在使用 */
#define EADDRNOTAVAIL 99 /* 无法分配请求地址 */
#define ENETDOWN 100 /* 网络已关闭 */
#define ENETUNREACH 101 /* 网络无法企及 */
#define ENETRESET 102 /* 网络因为重置而断开连接 */
#define ECONNABORTED 103 /* 软件导致连接中止 */
#define ECONNRESET 104 /* 连接被对方重置 */
#define ENOBUFS 105 /* 没有可用缓冲空间 */
#define EISCONN 106 /* 已连接传输端点 */
#define ENOTCONN 107 /* 未连接传输端点 */
#define ESHUTDOWN 108 /* 传输端点关闭后无法发送 */
#define ETOOMANYREFS 109 /* 引用太多:无法粘接 */
#define ETIMEDOUT 110 /* 连接超时 */
#define ECONNREFUSED 111 /* 连接被拒绝 */
#define EHOSTDOWN 112 /* 主机已关闭 */
#define EHOSTUNREACH 113 /* 没有到主机路由 */
#define EALREADY 114 /* 操作已在进行中 */
#define EINPROGRESS 115 /* 操作已在进行中 */
#define ESTALE 116 /* 过时网络文件系统(NFS)文件句柄 */
#define EUCLEAN 117 /* 结构需要清理 */
#define ENOTNAM 118 /* 非 XENIX 平台命名类型文件 */
#define ENAVAIL 119 /* 没有可用 XENIX 平台信号量 */
#define EISNAM 120 /* 是命名类型文件 */
#define EREMOTEIO 121 /* 远程输入输出错误 */
#define EDQUOT 122 /* 超过配额 */
#define ENOMEDIUM 123 /* 未找到媒体 */
#define EMEDIUMTYPE 124 /* 错误的媒体类型 */
#define ECANCELED 125 /* 操作被取消 */
#define ENOKEY 126 /* 需要的 key 不可用 */
#define EKEYEXPIRED 127 /* key 已过期 */
#define EKEYREVOKED 128 /* key 已被撤销 */
#define EKEYREJECTED 129 /* key 被服务拒绝 */
/* 为稳定的互斥变量 */
#define EOWNERDEAD 130 /* 拥有者已经退出 */
#define ENOTRECOVERABLE 131 /* 状态不可恢复 */
#define ERFKILL 132 /* 因为无线关闭,操作不可实现 */
#define EHWPOISON 133 /* 内存页遇到硬件错误 */
#endif

列表 8:可加载内核模块开发使用的错误状态 /usr/include/asm-generic/errno.h

查看英文原文: Writing a Linux Kernel Module — Part 2: A Character Device

编后语

《他山之石》是 InfoQ 中文站新推出的一个专栏,精选来自国内外技术社区和个人博客上的技术文章,让更多的读者朋友受益,本栏目转载的内容都经过原作者授权。文章推荐可以发送邮件到 editors@cn.infoq.com。


感谢魏星对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ @丁晓昀),微信(微信号: InfoQChina )关注我们,并与我们的编辑和其他读者朋友交流(欢迎加入 InfoQ 读者交流群)。

2015-11-01 18:216940

评论

发布
暂无评论
发现更多内容

Vue 和 React 前端框架的比较

高端章鱼哥

Vue React

用AI提高代码质量,同事对我的代码赞不绝口~

SoFlu软件机器人

多场景PAI-Diffusion中文模型家族大升级,12个模型、2个工具全部开源

阿里云大数据AI技术

人工智能 阿里云

Footprint Analytics 团队参与 Token 2049,为多场活动以及演讲提供数据支持

Footprint Analytics

区块链 公链

英特尔产品组合针对多规模AI模型性价比优势明显

E科讯

腾讯云 CODING 入选“数智影响力”数字化转型创新典型案例

CODING DevOps

在对接自有账户体系时,FinClip 是怎么做的?

Onegun

用户 账户管理 账户体系

Footprint Analytics 为 Layer2 公链提供数据支持,助力新兴项目发展

Footprint Analytics

区块链 公链 layer2

企业高管IT战略指南——为何要落地平台工程

York

容器 DevOps 云原生 敏捷 平台工程

议题征集|Flink Forward Asia 2023 正式启动

Apache Flink

flink

什么是网络营销?做网络营销怎么用代理IP?

巨量HTTP

IP

一文读懂私有云、公有云和本地化部署

青椒云云电脑

公有云 私有云

软件测试/测试开发丨使用ChatGPT自动进行需求分析

测试人

人工智能 程序员 软件测试 需求分析 ChatGPT

不知道该选公有云还是私有云?这些客户请选私有云

青椒云云电脑

桌面云 云桌面

INFINI Easysearch 与兆芯完成产品兼容互认证

极限实验室

easysearch 兆芯 国产适配

代码混淆和加固,保障应用程序的安全性

雪奈椰子

产教融合 | 力软联合重庆科技学院开展低代码应用开发培训

力软低代码开发平台

区块链数字货币交易所系统软件开发详情(源码)

西安链酷科技

数字货币交易所开发 交易所开发软件开发

大模型的东风中,看雄安的数字飞翔

脑极体

数字化

基于Vue3前后端分离的低代码开发框架

互联网工科生

Vue 软件开发 低代码 JNPF

Footprint Analytics 为 ABGA Web3 Gaming Summit 提供支持,助力 Web3 游戏行业发展

Footprint Analytics

HiAI Foundation助力端侧音视频AI能力,高性能低功耗释放云侧成本

HMS Core

huawei HarmonyOS

lrc下载安装 图像处理软件Lightroom Classic 2023 mac中文激活版

mac

图像处理软件 苹果mac Windows软件 lrc2023 Lightroom Classic

为什么越来越多的学校使用云桌面?

青椒云云电脑

桌面云 云桌面

Ask Milvus Anything!聊聊被社区反复@的那些事儿 Ⅰ

Zilliz

非结构化数据 Milvus 向量数据库 deepdive

中国智能卡车“遥遥领先”:卡车NOA落地5000万公里0事故,全球首个

Openlab_cosmoplat

人工智能 自动驾驶

对齐管理后台中账户体系的四种方法

FN0

单点登录 账户体系

云桌面怎么选,好产品的标准是什么

青椒云云电脑

桌面云 云桌面

从构建者到设计者的低代码之路

树上有只程序猿

软件开发 低代码

高校云桌面的“正确打开方式”是什么?

青椒云云电脑

云桌面 云桌面方案

百度智能云千帆社区上线有礼,助力开发者开启大模型之路!

科技热闻

编写Linux内核模块——第二部分:字符设备_Linux_金灵杰_InfoQ精选文章