很多人在写完 Linux 字符设备驱动后,应用层都是直接调用:open(),read(),write(),lseek(),ioctl(),close()。这样当然能跑,但有两个问题很快会出现:
第一,业务代码到处散落着 /dev/xxx、ioctl 命令号和各种系统调用。
第二,一旦驱动接口改了,所有应用程序都要跟着改。
更好的做法是:
在用户态再封装一层 API,把驱动访问统一成几个清晰的函数。
这篇文章基于一份已经写好的字符设备驱动:
- 支持 read/write
- 支持 llseek
- 支持 unlocked_ioctl
- 设备节点是 /dev/cdev_test_device
- ioctl 支持三个命令:
- CMD_TIMER_OPEN
- CMD_TIMER_CLOSE
- CMD_TIMER_SET
另外,这个驱动内部还维护了一个内核定时器,CMD_TIMER_SET 用于设置定时周期 CMD_TIMER_OPEN/CLOSE 用于开启和关闭定时器。
1.为什么要封装用户态API?
如果不封装,应用层一般会写成这样:
cpp
int fd = open("/dev/cdev_test_device", O_RDWR);
ioctl(fd, CMD_TIMER_OPEN);
ioctl(fd, CMD_TIMER_SET, 500);
write(fd, "hello", 5);
lseek(fd, 0, SEEK_SET);
read(fd, buf, 5);
close(fd);
功能没问题,但有几个缺点:
- 设备节点写死在业务里
- ioctl命令直接暴露给上层
- 错误处理不统一
- 后续多个程序复用不方便
所以更推荐再包一层:
cpp
cdev_open();
cdev_timer_open();
cdev_timer_set();
cdev_write_data();
cdev_seek();
cdev_read_data();
cdev_close();
这样代码更清晰,也方便以后把这层封装做成静态库或动态库。
2.封装实战
封装时,我们推荐创建两个头文件:
第一个:共享协议头 cdev_user.h
这个头文件给驱动和用户态共同使用,里面只放:
- 设备节点名
- ioctl命令号
第二个:API 头文件 cdev_api.h
这个头文件只给应用程序使用,里面放:
- cdev_open
- cdev_write_data
- cdev_timer_set
- cdev_close
cdev_user.h内容:
cpp
#ifndef CDEV_USER_H
#define CDEV_USER_H
#include <sys/ioctl.h>
#define CDEV_DEV_PATH "/dev/cdev_test_device"
#define CMD_TIMER_OPEN _IO('L', 0)
#define CMD_TIMER_CLOSE _IO('L', 1)
#define CMD_TIMER_SET _IOW('L', 2, int)
#endif
用户态 API 头文件:cdev_api.h :
cpp
#ifndef CDEV_API_H
#define CDEV_API_H
#include <sys/types.h>
int cdev_open(void);
int cdev_close(int fd);
ssize_t cdev_write_data(int fd, const void *buf, size_t count);
ssize_t cdev_read_data(int fd, void *buf, size_t count);
off_t cdev_seek(int fd, off_t offset, int whence);
int cdev_timer_open(int fd);
int cdev_timer_close(int fd);
int cdev_timer_set(int fd, int ms);
#endif
用户态API实现:
cpp
#include "cdev_api.h"
#include "cdev_user.h"
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <errno.h>
int cdev_open(void)
{
return open(CDEV_DEV_PATH, O_RDWR);
}
int cdev_close(int fd)
{
return close(fd);
}
ssize_t cdev_write_data(int fd, const void *buf, size_t count)
{
return write(fd, buf, count);
}
ssize_t cdev_read_data(int fd, void *buf, size_t count)
{
return read(fd, buf, count);
}
off_t cdev_seek(int fd, off_t offset, int whence)
{
return lseek(fd, offset, whence);
}
int cdev_timer_open(int fd)
{
return ioctl(fd, CMD_TIMER_OPEN);
}
int cdev_timer_close(int fd)
{
return ioctl(fd, CMD_TIMER_CLOSE);
}
int cdev_timer_set(int fd, int ms)
{
return ioctl(fd, CMD_TIMER_SET, ms);
}
测试程序:
cpp
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include "cdev_api.h"
int main(void)
{
int fd;
char wbuf[] = "hello_driver";
char rbuf[32] = {0};
fd = cdev_open();
if (fd < 0) {
perror("cdev_open");
return -1;
}
if (cdev_timer_open(fd) < 0) {
perror("cdev_timer_open");
cdev_close(fd);
return -1;
}
if (cdev_timer_set(fd, 1000) < 0) {
perror("cdev_timer_set");
cdev_close(fd);
return -1;
}
if (cdev_write_data(fd, wbuf, strlen(wbuf)) < 0) {
perror("cdev_write_data");
cdev_close(fd);
return -1;
}
if (cdev_seek(fd, 0, SEEK_SET) < 0) {
perror("cdev_seek");
cdev_close(fd);
return -1;
}
if (cdev_read_data(fd, rbuf, strlen(wbuf)) < 0) {
perror("cdev_read_data");
cdev_close(fd);
return -1;
}
printf("read back: %s\n", rbuf);
sleep(3);
if (cdev_timer_close(fd) < 0) {
perror("cdev_timer_close");
}
cdev_close(fd);
return 0;
}
3.编译与使用
我们分为Ubuntu本机编译(本机使用)和交叉编译到rk3568两种方式。
3.1Ubuntu本机编译
编译分两部分:
- 编译驱动模块
- 编译用户态 API 和测试程序
编译驱动模块很常规,写makefile然后make生成xxx.ko即可。
然后我们将 cdev_api.c 先打包成静态库,再进行链接:
cpp
gcc -Wall -O2 -c cdev_api.c -o cdev_api.o
ar rcs libcdevapi.a cdev_api.o
gcc -Wall -O2 test_app.c -L. -lcdevapi -o test_app
3.2交叉编译到rk3568
编译驱动模块的交叉编译我们在前文中讲过,在此就不再赘述了。
通过交叉编译将 cdev_api.c 先打包成静态库,再进行链接的过程如下:
bash
aarch64-linux-gnu-gcc -Wall -O2 -c cdev_api.c -o cdev_api.o
aarch64-linux-gnu-ar rcs libcdevapi.a cdev_api.o
aarch64-linux-gnu-gcc test_app.c -L. -lcdevapi -o test_app_arm64