9、Linux驱动开发:驱动-控制接口的实现(ioctl)

目录

🍅点击这里查看所有博文

  随着自己工作的进行,接触到的技术栈也越来越多。给我一个很直观的感受就是,某一项技术/经验在刚开始接触的时候都记得很清楚。往往过了几个月都会忘记的差不多了,只有经常会用到的东西才有可能真正记下来。存在很多在特殊情况下有一点用处的技巧,用的不多的技巧可能一个星期就忘了。

  想了很久想通过一些手段把这些事情记录下来。也尝试过在书上记笔记,这也只是一时的,书不在手边的时候那些笔记就和没记一样,不是很方便。

  很多时候我们遇到了问题,一般情况下都是选择在搜索引擎检索相关内容,这样来的也更快一点,除非真的找不到才会去选择翻书。后来就想到了写博客,博客作为自己的一个笔记平台倒是挺合适的。随时可以查阅,不用随身携带。

  同时由于写博客是对外的,既然是对外的就不能随便写,任何人都可以看到。经验对于我来说那就只是经验而已,公布出来说不一定我的一些经验可以帮助到其他的人。遇到和我相同问题时可以少走一些弯路。

  既然决定了要写博客,那就只能认真去写。不管写的好不好,尽力就行。千里之行始于足下,一步一个脚印,慢慢来 ,写的多了慢慢也会变好的。权当是记录自己的成长的一个过程,等到以后再往回看时,就会发现自己以前原来这么菜😂。

  本系列博客所述资料均来自互联网,并不是本人原创(只有博客是自己写的)。出于热心,本人将自己的所学笔记整理并推出相对应的使用教程,方面其他人学习。为国内的物联网事业发展尽自己的一份绵薄之力,没有为自己谋取私利的想法。若出现侵权现象,请告知本人,本人会立即停止更新,并删除相应的文章和代码。

前言

  以字符设备为例。一般情况下,一个字符设备的驱动,除了读取和写入设备之外,大部分的驱动程序都需要通过设备驱动程序来执行各种类型的硬件控制。

  例如,针对串口设备,驱动层除了需要提供对串口的读写,还需要提供对串口波特率、校验位、以及流控等配置信息的控制。

  这些配置信息需要从应用层传递一些基本数据,相比普通的读写数据,控制数据仅仅也只是数据类型不同。同时传输的控制信息,数据量一般情况下也不会太大。

IOTCTL

  在Linux应用空间中,针对字符设备应用程序可通过ioctl是设备驱动程序中对设备的I/O通道进行管理的函数。一般用来用来控制设备的状态,修改设备的配置。

函数

/*
参数:
	@fd:打开设备文件的时候获得文件描述符
	@ cmd:第二个参数:给驱动层传递的命令,需要注意的是,驱动层的命令和应用层的命令一定要统一
	@第三个参数: "..."在C语言中,很多时候都被理解成可变参数。
返回值
	成功:0
	失败:-1,同时设置errno
*/
int iotctl(int fd, unsigned long request, ...);

  该系统调用进入内核空间后,设备驱动中被调用的file_operations结构体对应的函数指针如下:

/*
参数:
	@file: vfs层为打开字符设备文件的进程创建的结构体,用于存放文件的动态信息
	@ cmd: 用户空间传递的命令,可以根据不同的命令做不同的事情
	@第三个参数: 用户空间的数据,主要这个数据可能是一个地址值(用户空间传递的是一个地址),也可能是一个数值,也可能没值
返回值
	成功:0
	失败:带错误码的负值
*/
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);

  两者之间的参数对应关系如下图所示:

输入图片说明

命令

  ioctl主要用来来实现控制的功能。用户程序所作的只是通过命令码cmd告诉驱动程序它想做什么,至于怎么解释这些命令和怎么实现这些命令,这都是驱动程序要做的事情,而ioctl就是负责接收cmd命令码来实现这些命令,它保证了程序的有序和整洁。

  在这个系统调用之间,有一个非常关键的参数,就是cmd。其由用户空间直接不经修改的传递给驱动程序。大小为4个字节,在其定义中该参数被分为四个字段。

字段取值释意
设备类型(8bit)字符
0~255
类型或叫幻数,代表一类设备,一般用一个字母或者1个8bit的数字
序列号(8bit)0~255代表这个设备的第几个命令
方向(2bit)只读
只写
读写
表示是由内核空间到用户空间,或是用户空间到内核空间
数据大小(16bit)0~65535表示需要读写的参数大小

命令构建

  <linux/ioctl.h>中包含的<asm/ioctl.h>头文件定义了一些构造命令编号的宏。在驱动中可通过以下宏定义快速组合一个命令。_IO用于构造无数据传输的命令编号。_IOR用于构造从驱动程序中读取数据的命令编号。_IOW用于构造向设备写入数据的命令编号。_IOWR用于构造双向传输命令编号。

_IO(type, nr)
_IOR(type, nr, datatype)
_IOW(type, nr, datatype)
_IOWR(type, nr, datatype)
//其中,type和number位段从以上宏的参数中传入,size位段通过对datatype参数取sizeof获得。

  下面的示例则是我们即将要编写的驱动代码中的命令定义:

#define DEV_FIFO_TYPE 'k'
#define DEV_FIFO_CLEAN _IO(DEV_FIFO_TYPE,0)
#define DEV_FIFO_GETVALUE _IOR(DEV_FIFO_TYPE,1,int)
#define DEV_FIFO_SETVALUE _IOW(DEV_FIFO_TYPE,2,int)

  在使用ioctl命令编号时,一定要避免与预定义命令重复,否则,命令冲突,设备不会响应。下列ioctl命令对任何文件(包括设备特定文件)都是预定义的:

FIOCTLX  //设置执行时关闭标志
FIONCLEX  //清除执行时关闭标志
FIOASYNC  //设置或复位文件异步通知
FIOQSIZE  //返回文件或目录大小
FIONBIO  //文件非阻塞型IO,file ioctl non-blocking i/o

命令检查

  在设备的驱动中,虽然定义了一系列命令。但是用户空间中的应用代码在执行的过程中,有可能并不会按照预定义的命令进行调用。那么在驱动模块中,该如何检查传入的命令是否合法?

  在<asm/ioctl.h>头文件中还定义了一些用于解析命令编号的宏。_IOC_TYPE获取cmd的幻数,来判断应用程序传下来的命令type是否正确。_IOC_NR获取cmd的序号。_IOC_DIR获取cmd的数据传输方向,来判断命令是读还是写。_IOC_SIZE获取cmd的用户数据大小。

_IOC_TYPE(cmd)
_IOC_NR(cmd)
_IOC_DIR(cmd)
_IOC_SIZE(cmd)

  我们在驱动程序的iotctl的处理函数中,一般会先判断命令的类型是否正确。若类型不正确则直接报错,并返回错误代码。

if(_IOC_TYPE(cmd) != DEV_FIFO_TYPE){
	pr_err("cmd %u,bad magic 0x%x/0x%x.\n",cmd,_IOC_TYPE(cmd),DEV_FIFO_TYPE);
	return-ENOTTY;
}

  紧接着会获取命令的传输方向,然后再通过宏access_ok来判断用户层传递的内存地址是否合法。

/*
参数:
	type: VERIFY_READ 或是 VERIFY_WRITE,取决于是读取还是写入用户空间内存区。
	如果在该地址处既要读取,又要写入,则应该用:VERIFY_WRITE,因为它是VERIFY_READ的超集。
	addr: 一个用户空间的地址
	size: 如果要读取或写入一个int型数据,则为sizeof(int)

返回值:
	为1(成功)或0(失败)
	如果返回失败,驱动程序通常返回-EFAULT给调用者来验证地址的合法性。
Note:
	access_ok不检查空指针,如果传入空指针,是可以通过判断的。空指针需要另行检查
	access_ok不做校验内存存取的完整工作; 它只检查内存引用是否在这个进程有合理权限的内存范围中,且确保这个地址不指向内核空间内存
	大部分驱动代码不需要真正调用 access_ok,而直接使用put_user(datum, ptr)和get_user(local, ptr),它们带有校验的功能,确保进程能够写入给定的内存地址
*/
#define __range_ok(addr, size)						\
	(test_thread_flag(TIF_USERSPACE)				\
	 && (((unsigned long)(addr) >= 0x80000000)			\
	     || ((unsigned long)(size) > 0x80000000)			\
	     || (((unsigned long)(addr) + (unsigned long)(size)) > 0x80000000)))

 int  access_ok(int type,const void *addr,unsigend long size);

	if(_IOC_DIR(cmd)&_IOC_READ)
	{
		if(p == NULL){
			pr_err("arg is NULL!\n");
			return-EFAULT;
		}
		ret = !access_ok(VERIFY_WRITE,(void __user*)arg,_IOC_SIZE(cmd));
	}
	else if( _IOC_DIR(cmd)&_IOC_WRITE)
	{
		if(p == NULL){
			pr_err("arg is NULL!\n");
			return-EFAULT;
		}	
		ret = !access_ok(VERIFY_READ,(void   __user*)arg,_IOC_SIZE(cmd));
	}
	if(ret){
		pr_err("arg bad access \n");
		return-EFAULT;
	}

  最后一步就是检查命令的序号了,不过一般情况下我们也不会单独提取出序号进行检查。大多数情况都是直接根据cmd的值进行散转。根据不同的命令进入到不同的分支进行相应的处理。

	void __user *argp = (void __user *)arg;
	int __user *p = argp;
	switch(cmd)
	{
		case DEV_FIFO_CLEAN:
			printk("DEV_FIFO_CLEAN\n");
			break;
		case DEV_FIFO_GETVALUE:
			err = put_user(knum, p);
			printk("DEV_FIFO_GETVALUE %d\n",knum);
			break;
		case DEV_FIFO_SETVALUE:
			err = get_user(knum, p);
			printk("DEV_FIFO_SETVALUE %d\n",knum);
			break;
		default:
			pr_err("bad cmd %ld.\n",cmd);
			return -EINVAL;
	}

驱动编写

  节省篇幅,这里的代码是在上一篇文章设计内容的基础上修改的。驱动代码中首先补充file_operations的**unlocked_ioctl **函数。

static struct file_operations hello_ops = 
{
	.open = hello_open,
	.release = hello_release,
	.read = hello_read,
	.write = hello_write,
	.unlocked_ioctl = hello_ioctl,
};

  iotctl函数这里就不多做解释了,就是上面命令检查的内容。

/*ioctl(fd,DEV_FIFO_GETVALUE, &num);*/
static int knum = 99;
long hello_ioctl (struct file *filep, unsigned int cmd, unsigned long arg)
{
	long err, ret = 0;
	void __user *argp = (void __user *)arg;
	int __user *p = argp;
	if(_IOC_TYPE(cmd) != DEV_FIFO_TYPE){
		pr_err("cmd   %u,bad magic 0x%x/0x%x.\n",cmd,_IOC_TYPE(cmd),DEV_FIFO_TYPE);
		return-ENOTTY;
	}
	if(_IOC_DIR(cmd)&_IOC_READ)
	{
		if(p == NULL){
			pr_err("arg is NULL!\n");
			return-EFAULT;
		}
		ret = !access_ok(VERIFY_WRITE,(void __user*)arg,_IOC_SIZE(cmd));
	}
	else if( _IOC_DIR(cmd)&_IOC_WRITE)
	{
		if(p == NULL){
			pr_err("arg is NULL!\n");
			return-EFAULT;
		}	
		ret = !access_ok(VERIFY_READ,(void   __user*)arg,_IOC_SIZE(cmd));
	}
	if(ret){
		pr_err("arg bad access \n");
		return-EFAULT;
	}
	switch(cmd)
	{
		case DEV_FIFO_CLEAN:
			printk("DEV_FIFO_CLEAN\n");
			break;
		case DEV_FIFO_GETVALUE:
			err = put_user(knum, p);
			printk("DEV_FIFO_GETVALUE %d\n",knum);
			break;
		case DEV_FIFO_SETVALUE:
			err = get_user(knum, p);
			printk("DEV_FIFO_SETVALUE %d\n",knum);
			break;
		default:
			pr_err("bad cmd\n");
			return -EINVAL;
	}
	return err;
}

  示例中使用到了put_userget_user宏定义。这里有一个比较迷惑的地方,一般情况下Linux内核代码中宏定义都是全大写字母,这里却用的小写字母,很容易让人以为是一个函数。这两个同样可用于内核空间和用户空间之间的数据拷贝。

  put_user可以向用户空间传递单个数据。单个数据并不是指一个字节数据,对ARM而言,put_user一次性可传递一个charshort或者int型的数据,即1、2或者4字节。用put_user比用copy_to_user要快:

参数
	x 为内核空间的数据,
	p 为用户空间的指针。
返回值
	传递成功,返回 0,否则返回-EFAULT。
int put_user(x,p)

  get_user可以从用户空间获取单个数据,单个数据并不是指一个字节数据,对ARM而言,get_user一次性可获取一个charshort或者 int型的数据,即1、2或者4字节。用get_user比用get_from_user要快:

参数
	x为内核空间的变量
	p为用户空间的指针。
返回值
	获取成功,返回0,否则返回-EFAULT.
int get_user(x,p)

  这里很多新手朋友会犯错误。习惯性认为,单个变量传入函数中获取值需要取地址。前面页说到了get_user是一个宏,使用get_user时并不需要对内核空间的变量取地址。将宏定义展开,可清晰的看到所谓传入的x就是一个直接赋值而已(x) = (__typeof__(*(ptr))) __gu_val

#define __get_user(x,ptr) \
	__get_user_nocheck((x),(ptr),sizeof(*(ptr)))

#define __get_user_nocheck(x,ptr,size)				\
({								\
	long __gu_err = 0;					\
	unsigned long __gu_val;					\
	__chk_user_ptr(ptr);					\
	switch (size) {						\
	  case 1: __get_user_8(ptr); break;			\
	  case 2: __get_user_16(ptr); break;			\
	  case 4: __get_user_32(ptr); break;			\
	  case 8: __get_user_64(ptr); break;			\
	  default: __get_user_unknown(); break;			\
	}							\
	(x) = (__typeof__(*(ptr))) __gu_val;			\
	__gu_err;						\
})

实验结果

  实验代码如下,在打开驱动程序后。首先,分别写了三个错误行为,依次是错误的命令类型(幻术)、错误的命令序号(不存在该命令)、错误的地址(写入数据时传入了0x9999999999999)、错误的地址(读取数据时传入了NULL)。

	#if 1
	#define DEV_FIFO_TEST_TYPE _IO('L', 0)
	if(ioctl(fd, DEV_FIFO_TEST_TYPE) < 0) {
		perror("DEV_FIFO_TEST_TYPE");
	}
	#define DEV_FIFO_TEST_NR _IO(DEV_FIFO_TYPE, 3)
	if(ioctl(fd, DEV_FIFO_TEST_NR) < 0) {
		perror("DEV_FIFO_TEST_NR");
	}
	if(ioctl(fd, DEV_FIFO_ETVALUE, 0x9999999999999) < 0) {
		perror("DEV_FIFO_TEST_DIR");
	}
	if(ioctl(fd, DEV_FIFO_GETVALUE, NULL) < 0) {
		perror("DEV_FIFO_TEST_DIR");
	}
	#endif

  紧接着依次调用头文件中定义的三个命令,最后又读取了DEV_FIFO_SETVALUE写入的数据。

  通过代码分析,驱动模块中默认的num(knum)是99。第一次读取,num值应该是99,紧接着num加一,并将num通过iotctl写入到驱动中。那么第二次读取的值,理应为100。

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <unistd.h> //close
#include "beep.h"

void main(void)
{
	int num = 0;	
	int fd = open("/dev/hellodev",O_RDWR);
	if(fd < 0) {
		perror("open fail \n");
		return;
	}
	#if 1
	#define DEV_FIFO_TEST_TYPE _IO('L', 0)
	if(ioctl(fd, DEV_FIFO_TEST_TYPE) < 0) {
		perror("DEV_FIFO_TEST_TYPE");
	}
	#define DEV_FIFO_TEST_NR _IO(DEV_FIFO_TYPE, 3)
	if(ioctl(fd, DEV_FIFO_TEST_NR) < 0) {
		perror("DEV_FIFO_TEST_NR");
	}
	if(ioctl(fd, DEV_FIFO_GETVALUE, 0x9999999999999) < 0) {
		perror("DEV_FIFO_TEST_DIR");
	}
	if(ioctl(fd, DEV_FIFO_GETVALUE, NULL) < 0) {
		perror("DEV_FIFO_TEST_DIR");
	}
	#endif
	if(ioctl(fd, DEV_FIFO_CLEAN) < 0) {
		perror("DEV_FIFO_CLEAN");
		return;
	}
	if(ioctl(fd, DEV_FIFO_GETVALUE, &num) < 0) {
		perror("DEV_FIFO_GETVALUE");
		return;
	}
	printf("get num = %d \n",num);
	num++;
	ioctl(fd,DEV_FIFO_SETVALUE, &num);
	printf("set num = %d \n",num);
	if(ioctl(fd,DEV_FIFO_GETVALUE, &num) < 0) {
		perror("DEV_FIFO_GETVALUE");
		return;
	}
	printf("get num = %d \n",num);
	close(fd);
	return;
}

  代码运行的结果如下,可以看到模拟的几个错误行为,也都正常识别到。读写的数据也是符合预期的。

root@ubuntu:# insmod ./hello.ko 
root@ubuntu:# gcc ./test.c 
root@ubuntu:# ./a.out 
DEV_FIFO_TEST_TYPE: Inappropriate ioctl for device
DEV_FIFO_TEST_NR: Invalid argument
DEV_FIFO_TEST_DIR: Bad address
DEV_FIFO_TEST_DIR: Bad address
get num = 99 
set num = 100 
get num = 100 
root@ubuntu:# dmesg
[261820.060585] hello_open()
[261820.060596] cmd   19456,bad magic 0x4c/0x6b.
[261820.060670] bad cmd
[261820.060684] arg bad access 
[261820.060696] arg is NULL!
[261820.060708] DEV_FIFO_CLEAN
[261820.060715] DEV_FIFO_GETVALUE 99
[261820.060725] DEV_FIFO_SETVALUE 100
[261820.060734] DEV_FIFO_GETVALUE 100
[261820.060807] hello_release()

  那么本篇博客就到此结束了,这里只是记录了一些我个人的学习笔记,其中存在大量我自己的理解。文中所述不一定是完全正确的,可能有的地方我自己也理解错了。如果有些错的地方,欢迎大家批评指正。如有问题直接在对应的博客评论区指出即可,不需要私聊我。我们交流的内容留下来也有助于其他人查看,说不一定也有其他人遇到了同样的问题呢😂。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/435270.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

DailyNotes个人笔记管理工具

DailyNotes 是记录笔记和跟踪任务的应用程序&#xff0c;使用markdown进行编辑 部署 下载镜像 docker pull m0ngr31/dailynotes创建目录并授权 mkdir -p /data/dailynotes/config_dir chmod -R 777 /data/dailynotes启动容器 docker run -d --restart always --name mynot…

ruoyi-vue框架密码加密传输

先看一下改造后的样子&#xff0c;输入的密码不会再以明文展示。 下面我主要把前后端改造的代码贴出来。 1.后端代码 RsaUtils类 在com.ruoyi.common.utils包下新建RsaUtils类&#xff0c;RsaUtils添加了Component注解 generateKeyPair()构建密钥对添加了Bean注解 在项目启动…

【MATLAB】语音信号识别与处理:卷积滑动平均滤波算法去噪及谱相减算法呈现频谱

1 基本定义 卷积滑动平均滤波算法是一种基于卷积操作的滤波方法&#xff0c;它通过对信号进行卷积运算来计算移动平均值&#xff0c;以消除噪声。该算法的主要思想是将滤波窗口的加权系数定义为一个卷积核&#xff0c;对信号进行卷积运算来得到平滑后的信号。这样可以有效地去…

【论文精读】【Yolov1】You Only Look Once Unified, Real-Time Object Detection

0.论文摘要 我们提出了YOLO&#xff0c;一种新的目标检测方法。先前关于目标检测的工作重新利用分类器来执行检测。相反&#xff0c;我们将目标检测框架确定为空间分离的边界框和相关类别概率的回归问题。单个神经网络在一次评估中直接从完整图像预测边界框和类别概率。由于整…

mybatis单表curd笔记(尚硅谷

Mybatis 11111ibatis和mybatis不同 查询文档mybatis的日志输出id赋值输入&#xff08;向sql语句传入数据单个简单类型单个实体对象多个简单类型map类型 输出数据的指定单个简单类型单个实体类型输出map类型输出list输出类型主键回显&#xff08;自增长类型主键回显&#xff08;…

R语言中定义函数、调用函数及常用编程技巧

R语言中定义函数、调用函数及常用编程技巧 介绍定义函数调用函数常用编程循环结构apply 函数族apply()案例&#xff1a; lapply()案例&#xff1a; sapply()案例&#xff1a; vapply()案例&#xff1a; mapply()案例&#xff1a; 介绍 R语言是一种功能强大的统计分析编程语言&a…

构建阶段的软件供应链威胁

随着软件供应链生命周期从源代码发展到可执行组件&#xff0c;构建阶段是一个关键时刻。然而&#xff0c;这一变革阶段也容易受到一系列威胁的影响&#xff0c;这些威胁可能会危及软件的完整性和构建安全性。 这些威胁可以通过各种方法渗透构建过程&#xff0c;包括规避已建立…

LVS----DR模式

一、LVS-DR工作原理 1、LVS-DR数据包流向分析 客户端发送请求到Director Server (负载均衡器)&#xff0c;请求的数据报文&#xff08;源IP是CIP&#xff0c;目标IP是VIP&#xff09;到达内核空间。Director Server 和Real Server 在同一个网络中&#xff0c;数据通过二层数据…

比较 2 名无人机驾驶员:借助分析飞得更高

近年来&#xff0c;越来越多的政府和执法机构使用无人机从空中鸟瞰。为了高效执行任务&#xff0c;无人机必须能够快速机动到预定目标。快速机动使它们能够在复杂的环境中航行&#xff0c;并高效地完成任务。成为认证的无人机驾驶员的要求因国家/地区而异&#xff0c;但都要求您…

node_vue个人博客系统开发

Day01 一、导入express 1、创建node_serve服务文件夹 2、初始化项目 npm init -y3、导入express框架 npm i express4、创建一个app.js文件,为服务端的入口文件 // 导入express模块 const express = require(express); // 创建express服务 const app = express(); // 调用…

UVa11595 Crossing Streets EXTREME

题目链接 UVa11595 - Crossing Streets EXTREME 题意 平面上有 n&#xff08;n≤35&#xff09;条直线&#xff0c;各代表一条街道。街道相互交叉&#xff0c;形成一些路段&#xff08;对应于几何上的线段&#xff09;。你的任务是设计一条从A到B的路线&#xff0c;使得穿过路…

土地利用数据分类过程教学/土地利用分类/遥感解译/土地利用获取来源介绍/地理数据获取

本篇主要介绍如何对影像数据进行分类解译&#xff0c;及过程教学&#xff0c;示例数据下载链接&#xff1a;数据下载链接 一、背景介绍 土地是人类赖以生存与发展的重要资源和物质保障&#xff0c;在“人口&#xff0d;资源&#xff0d;环境&#xff0d;发展&#x…

excel中去除公式,仅保留值

1.单个单元格去除公式 双击单元格&#xff0c;按F9. 2.批量去除公式 选中列然后复制&#xff0c;选择性粘贴&#xff0c;选值粘贴

C++之类型转换

C语言中的类型转换 在C语言中, 如果赋值运算符左右两侧类型不同, 或者形参与实参类型不匹配, 或者返回值类型与 接收返回值类型不一致时, 就需要发生类型转化, C语言中总共有两种形式的类型转换: 隐式类型转换和显式类型转换 1. 隐式类型转化是关联度很强, 意义相近的类型之间…

事务 失效的八种情况

在某些业务场景下&#xff0c;如果一个请求中&#xff0c;需要同时写入多张表的数据。为了保证操作的原子性&#xff08;要么同时成功&#xff0c;要么同时失败&#xff09;&#xff0c;避免数据不一致的情况&#xff0c;我们一般都会用到 spring 事务。 确实&#xff0c;sprin…

css使用伪元素绘制带三角箭头的提示框

效果图 代码实现 使用伪元素进行绘制&#xff1a; <div class"my-tip"></div> .my-tip{width: 128px;height: 100px;background: #FFFFFF;box-shadow: 0px 1px 10px 0px rgba(0,0,0,0.05), 0px 4px 5px 0px rgba(0,0,0,0.08), 0px 2px 4px -1px rgba(0…

【开源】SpringBoot框架开发网上药店系统

目录 一、摘要1.1 项目介绍1.2 项目录屏 二、功能模块2.1 数据中心模块2.2 药品类型模块2.3 药品档案模块2.4 药品订单模块2.5 药品收藏模块2.6 药品资讯模块 三、系统设计3.1 用例设计3.2 数据库设计3.2.1 角色表3.2.2 药品表3.2.3 药品订单表3.2.4 药品收藏表3.2.5 药品留言表…

Python 快速获取PDF文件的页数

有时在处理或打印一个PDF文档之前&#xff0c;你可能需要先知道该文档包含多少页。虽然我们可以使用Adobe Acrobat这样的工具来查看页数&#xff0c;但对于程序员来说&#xff0c;编写脚本来完成这项工作会更加高效。本文就介绍一个使用Python快速获取PDF文件页数的办法。 安装…

使用css结合js实现html文件中的双行混排

此前写过一个使用flex布局实现html文件中的双行混排&#xff0c;但是感觉效果不佳。经过几天思考&#xff0c;我认为双行混排的要点其实是两个&#xff1a; 1、正文和批注的文字大小不同&#xff1b; 2、正文和批注的行距相互配合进行设定。 正文和批注的文字大小及行距都可…

vue在线查看pdf文件

1.引入组件 npm install --save vue-pdf2、pdf组件页面模板 <template><div class"scrollBox" ><el-dialog :visible.sync"open" :top"1" width"50%" append-to-body><div slot"title"><el…
最新文章