一、基础概念
在 Linux 操作系统中,内核空间(Kernel Space)和用户空间(User Space)是完全隔离的两个内存区域。
- 用户空间:存放用户进程运行的代码和数据。
- 内核空间:操作系统内核及其模块(如驱动程序)运行的空间,有最高权限。
普通应用程序不能直接访问内核空间的数据,反之亦然。数据交换必须通过特定的接口或方法来完成。
二、典型的数据交换方式
常见的数据交换方式如下:
- 设备文件节点(如 /dev/mydevice)+ ioctl、read、write操作
- sysfs 文件系统(如 /sys/class/xxx/yyy)
- proc 文件系统(如 /proc/xxx)
- mmap 内存映射机制
- netlink 通信机制
- 字符设备、块设备驱动的特定接口
最常用的是通过字符设备文件结合 read、 write 、 ioctl 进行数据交换。
三、API讲解
最主要的两个函数copy_to_user() 与 copy_from_user()
这俩个函数定义在了
kernel-6.1/include/linux/uaccess.h文件下
这两个函数用于实现在内核空间与用户空间之间的安全数据传递。
copy_to_user(to, from, count):将数据从内核空间复制到用户空间。copy_from_user(to, from, count):将数据从用户空间复制到内核空间。 请务必不要直接用指针赋值,否则系统会崩溃!
1、copy_to_user
函数原型:
static __always_inline unsigned long __must_check
copy_to_user(void __user *to, const void *from, unsigned long n)
{
if (check_copy_size(from, n, true))
n = _copy_to_user(to, from, n);
return n;
}2
3
4
5
6
7
- 函数定义:用于将内核中的数据复制到用户程序的内存区域。
- 参数说明:
to:目标地址,是应用程序内存中的一个位置。from:源地址,是内核内存中需要拷贝的数据位置。n:要拷贝的数据量,单位是字节。
简单总结:这个函数就像一个搬运工,把内核里的数据(比如文件内容或计算结果)搬去用户程序能用的地方,并指定搬多少数据。
2、copy_from_user
函数原型:
static __always_inline unsigned long __must_check
copy_from_user(void *to, const void __user *from, unsigned long n)
{
if (check_copy_size(to, n, false))
n = _copy_from_user(to, from, n);
return n;
}2
3
4
5
6
7
- 函数定义:将用户程序中的数据复制到操作系统内核的内存区域。
- 参数说明:
to: 内核内存的地址,数据会被复制到这里。from: 用户程序内存的地址,数据来源。n: 需要复制的数据大小(单位为字节)。
简单总结:这个函数的作用就像一个搬运工,把用户程序(比如你运行的软件)里的数据,安全地搬移到操作系统内核(计算机的核心程序)的内存里。
三、字符设备驱动的数据交换
1. 编写基本驱动
首先,我们实现一个最简单的字符设备驱动,使用 mkdir 06_user_kernel_data/ 创建一个文件夹,然后进入目录,创建一个 data_exchange_driver.c 驱动文件,并编写以下内容:
#include <linux/init.h>
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/kdev_t.h>
#include <linux/cdev.h>
#include <linux/device.h>
// 设备名和类名的宏定义
#define DEV_NAME "mychardev" // 设备节点名称
#define CLASS_NAME "class_mychardev" // 设备类名称
static dev_t dev_num; // 保存设备号
static struct cdev mychardev; // 字符设备结构体
static struct class *class_mychardev; // 设备类指针
// 打开设备时调用的函数
// inode: 指向文件的 inode 结构体指针
// file: 文件结构体指针
static int chrdev_open(struct inode *inode, struct file *file)
{
printk(KERN_INFO "mychardev: chrdev_open called\n"); // 打印打开信息到内核日志
return 0; // 返回0表示成功
}
// 读设备时调用的函数
// file: 文件结构体指针
// buf: 用户空间的缓冲区指针
// size: 期望读取的字节数
// off: 偏移量指针
static ssize_t chrdev_read(struct file *file, char __user *buf, size_t size, loff_t *off)
{
char data[] = "This is data from kernel space!"; // 设定一段内核数据
size_t data_len = sizeof(data) -1; // 数据长度
if (*off >= data_len) {
return 0; // 已读完,返回0表示EOF
}
// 只读剩余的未读部分,调整读取大小,防止越界
if (size > data_len - *off) {
size = data_len - *off;
}
// 复制数据到用户空间
if (copy_to_user(buf, data + *off, data_len)) {
printk(KERN_ERR "mychardev: copy_to_user failed\n");
return -EFAULT; // 复制失败,返回错误码
}
*off += data_len; // 更新偏移量
printk(KERN_INFO "mychardev: chrdev_read called\n"); // 打印读操作信息
// 返回实际读取成功的字节数
return size;
}
// 写设备时调用的函数
// file: 文件结构体指针
// buf: 用户空间的缓冲区指针
// size: 要写入的字节数
// off: 偏移量指针
static ssize_t chrdev_write(struct file *file, const char __user *buf, size_t size, loff_t *off)
{
char kernel_buf[256]; // 内核缓冲区
size_t write_size = size;
if (write_size > sizeof(kernel_buf) - 1) {
write_size = sizeof(kernel_buf) - 1; // 限制写入大小,防止溢出
}
// 复制数据从用户空间到内核缓冲区
if (copy_from_user(kernel_buf, buf, write_size)) {
printk(KERN_ERR "mychardev: copy_from_user failed\n");
return -EFAULT; // 复制失败,返回错误码
}
kernel_buf[write_size] = '\0'; // 添加字符串结束符
printk(KERN_INFO "mychardev: Received from user: %s\n", kernel_buf); // 打印接收到的数据
return write_size;
}
// 关闭设备时调用的函数
// inode: 指向文件的 inode 结构体指针
// file: 文件结构体指针
static int chrdev_release(struct inode *inode, struct file *file)
{
printk(KERN_INFO "mychardev: chrdev_release called\n"); // 打印关闭信息
return 0; // 返回0表示成功
}
// file_operations 结构体,指明本设备支持的操作
static struct file_operations cdev_mychardev = {
.owner = THIS_MODULE, // 拥有者,一般为 THIS_MODULE
.open = chrdev_open, // open 操作
.read = chrdev_read, // read 操作
.write = chrdev_write, // write 操作
.release = chrdev_release, // release 操作
};
// 模块加载时自动调用的初始化函数
static int __init mychardev_init(void)
{
int ret;
int major, minor;
// 1. 自动申请设备号,主设备号和次设备号由内核分配
ret = alloc_chrdev_region(&dev_num, 0, 1, DEV_NAME);
if (ret < 0) {
printk(KERN_ERR "mychardev: alloc_chrdev_region failed\n"); // 申请失败
return ret;
}
major = MAJOR(dev_num); // 获取主设备号
minor = MINOR(dev_num); // 获取次设备号
printk(KERN_INFO "mychardev: alloc_chrdev_region ok! [major=%d] [minor=%d]\n", major, minor);
// 2. 初始化 cdev 结构体,并添加到内核
cdev_init(&mychardev, &cdev_mychardev); // 初始化 cdev
ret = cdev_add(&mychardev, dev_num, 1); // 注册 cdev 到内核
if (ret < 0) {
printk(KERN_ERR "mychardev: cdev_add failed\n");
unregister_chrdev_region(dev_num,1); // 失败时释放设备号
return ret;
}
// 3. 创建设备类,便于自动创建设备节点
class_mychardev = class_create(THIS_MODULE, CLASS_NAME);
if (IS_ERR(class_mychardev)) {
printk(KERN_ERR "mychardev: class_create failed\n");
cdev_del(&mychardev);
unregister_chrdev_region(dev_num,1);
return PTR_ERR(class_mychardev);
}
// 4. 创建设备节点 /dev/device_test
if (device_create(class_mychardev, NULL, dev_num, NULL, DEV_NAME) == NULL) {
printk(KERN_ERR "mychardev: device_create failed\n");
class_destroy(class_mychardev);
cdev_del(&mychardev);
unregister_chrdev_region(dev_num,1);
return -ENOMEM;
}
printk(KERN_INFO "mychardev: chrdev driver loaded successfully\n"); // 驱动加载成功
return 0;
}
// 模块卸载时自动调用的清理函数
static void __exit mychardev_exit(void)
{
device_destroy(class_mychardev, dev_num); // 删除设备节点
class_destroy(class_mychardev); // 删除设备类
cdev_del(&mychardev); // 注销 cdev
unregister_chrdev_region(dev_num, 1); // 释放设备号
printk(KERN_INFO "mychardev: chrdev driver unloaded\n"); // 卸载信息
}
// 指定模块的初始化和退出函数
module_init(mychardev_init); // 加载模块时调用
module_exit(mychardev_exit); // 卸载模块时调用
MODULE_LICENSE("GPL"); // 模块许可证声明2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
- 设备号与cdev注册
alloc_chrdev_region()申请主设备号和次设备号。cdev_init()、cdev_add()注册驱动到内核。class_create()、device_create()自动生成/dev/mychardev,方便用户直接访问。
- open/release(打开/关闭)
chrdev_open():设备被打开时调用。这里只打印日志。chrdev_release():设备被关闭时调用。这里只做日志处理。
- read(读操作)
- 实现为:每次用户读设备时返回一句固定的内核消息。
- 使用了
off偏移量防止内容重复输出,用户多次读取内容只返回一次,之后再读返回0,表示“文件读取完毕”。 - 用
copy_to_user()函数安全地把内核数据送到用户空间。
- write(写操作)
- 用户写数据到设备时,驱动读取用户的数据到内核,并打印收到的内容。
- 用
copy_from_user()函数安全地从用户空间复制数据。
- 模块加载/卸载
mychardev_init():模块加载时自动运行,完成设备注册与节点创建。mychardev_exit():模块卸载时自动运行,清理设备和节点。
TIP
- 必须用
copy_to_user()和copy_from_user()操作用户数据,不能直接操作用户指针。 read返回0表示“文件结尾”,必须配合处理*off偏移,否则会造成死循环或无输出。write返回实际写入字节数,否则用户空间可能判定写失败。
Linux 怎么用 read() 读文件?
Linux用户程序用read()多次去读文件或者设备。驱动的
.read每次实际被调用:- 如果返回的数据大于0,程序就以为还有数据没读完,会继续读。
- 如果返回值是0,程序才会停下来,认为“文件到结尾了”。
2. 编译与加载模块
(1)Makefile文件编写
创建一个文件并编写:
export ARCH=arm64
# 交叉编译器绝对路径前缀
export CROSS_COMPILE=/home/lckfb/TaishanPi-3-Linux/prebuilts/gcc/linux-x86/aarch64/gcc-arm-10.3-2021.07-x86_64-aarch64-none-linux-gnu/bin/aarch64-none-linux-gnu-
# 和源文件名一致
obj-m += data_exchange_driver.o
# 内核源码目录
KDIR := /home/lckfb/TaishanPi-3-Linux/kernel-6.1
PWD ?= $(shell pwd)
all:
make -C $(KDIR) M=$(PWD) modules
clean:
make -C $(KDIR) M=$(PWD) clean2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
和之前编写的 Makefile 几乎一摸一样!
这不过这里变为了 data_exchange_driver.o
CROSS_COMPILE:依旧是SDK中的编译器路径前缀。KDIR:依旧是内核源码目录。
(2)编译
make当前目录下会出现 data_exchange_driver.ko 文件,这就是我们要的。
(3)加载模块
将 data_exchange_driver.ko 复制到开发板中(U盘、TF卡或者是SSH都可以),并运行下面的命令加载模块:
sudo insmod data_exchange_driver.ko系统会打印设备主设备号。
3. 设定读写权限
sudo chmod 666 /dev/mychardev4. 用户空间测试
(1)写入测试
echo "hello kernel" > /dev/mychardev(2)读取测试
cat /dev/mychardev5. 卸载模块
sudo rmmod data_exchange_driver四、APP调用测试
1、编写APP程序
在 06_user_kernel_data/ 目录下创建一个 data_exchange_app.c 文件,并编写如下的代码:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#define DEV_PATH "/dev/mychardev"
int main() {
int fd;
char wbuf[] = "Hello kernel from userspace!";
char rbuf[128] = {0};
ssize_t ret;
// 1. 打开设备
fd = open(DEV_PATH, O_RDWR);
if (fd < 0) {
perror("open");
return 1;
}
printf("设备打开成功: %s\n", DEV_PATH);
// 2. 写入一段内容
ret = write(fd, wbuf, strlen(wbuf));
if (ret < 0) {
perror("write");
close(fd);
return 2;
}
printf("写入内容: %s (写了%zd字节)\n", wbuf, ret);
// 3. 读回设备内容
// 根据你的驱动实现,read会返回"This is data from kernel space!"
lseek(fd, 0, SEEK_SET); // 通常不需要,但为保险起见,移到开始
ret = read(fd, rbuf, sizeof(rbuf) - 1);
if (ret < 0) {
perror("read");
close(fd);
return 3;
}
rbuf[ret] = '\0';
printf("读回内容: %s (读了%zd字节)\n", rbuf, ret);
close(fd);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
2、编译APP
使用下面的命令进行编译:
/home/lckfb/TaishanPi-3-Linux/prebuilts/gcc/linux-x86/aarch64/gcc-arm-10.3-2021.07-x86_64-aarch64-none-linux-gnu/bin/aarch64-none-linux-gnu-gcc data_exchange_app.c -o data_exchange_app命令格式是:
<SDK的gcc交叉编译器> <源码.c文件> -o <最终生成的可执行文件名字>
-o重名的意思,后面紧跟着最终想要生成的名字。
- SDK的gcc交叉编译器:这个就和之前我们在Makefile中编写的路径一致只不过变为了
aarch64-none-linux-gnu-gcc,不单单是只有前缀了。
最终就是这样的:
3、APP运行测试
运行之前要确保
data_exchange_driver.ko模块已经挂载!!不然会报错。
将这个 data_exchange_app 复制到开发板中(U盘、TF卡、SSH都可以),并运行:
sudo ./data_exchange_app最终的效果是这样的:
[ 1132.722204] mychardev: chrdev_open called
- APP调用
open("/dev/mychardev", O_RDWR)时,内核驱动的chrdev_open被执行,打印了这行log。 - 说明设备节点能被正常打开,驱动正常响应。
[ 1132.722437] mychardev: Received from user: Hello kernel from userspace!
- APP调用
write(fd, wbuf, strlen(wbuf)),内核驱动的chrdev_write被执行,并成功把内容从用户空间复制到内核,且打印了用户数据。 - 说明数据交换写通路正常!
设备打开成功: /dev/mychardev
- APP里的
printf,标志open顺利,用户空间能访问设备节点。
[ 1132.722469] mychardev: chrdev_read called
- APP调用
read(fd, rbuf, ...),内核的chrdev_read被正常调用。 - 说明数据交换读通路正常,驱动能把数据回传给用户进程。
写入内容: Hello kernel from userspace! (写了28字节)
- APP的
printf,表明驱动返回写入了28个字节。 - 这个数字实际是
write_size,test字符串长度。 - write路径没有错。
[ 1132.722512] mychardev: chrdev_release called
- APP关闭文件句柄(
close(fd)),驱动的chrdev_release被调用,log随即打印。
读回内容: This is data from kernel space! (读了31字节)
- APP的
printf,表明驱动返回了31个字节内容,这就是驱动里data数组传出来的那句话。 - 说明read通路也没问题,正常实现了用户空间和内核空间的数据交换。
五、注意事项
- 不能直接用指针传递用户空间和内核空间数据。用
copy_to_user/copy_from_user等安全方法。 - 所有向用户空间返回的数据都需要显式
copy_to_user。 - 驱动必须有权限,注意权限控制。
- 内核模块调试可查看
dmesg输出。