本文目标

  1. 什么是文件描述符?
  2. 进程打开文件相关信息管理
  3. Linux设备文件三大结构:inode,file,file_operations
  4. mknod 做了什么事?
  5. 进程打开设备文件
  6. 驱动如何支持同类型设备?
  7. 如何获得注册的设备结构体私有地址?

什么是文件描述符?

Linux 中一切都可以看作文件,包括普通文件、链接文件、Socket 以及设备驱动等,对其进行相关操作时,都可能会创建对应的文件描述符。文件描述符(file descriptor)是内核为了高效管理已被打开的文件所创建的索引,用于指代被打开的文件,对文件所有 I/O 操作相关的系统调用都需要通过文件描述符。

Linux启动后,会默认打开3个文件描述符,分别是:

0:标准输入 standard input

1:正确输出 standard output

2:错误输出 error output

这就是为什么我们在程序运行时可以直接打印信息和从命令终端获取信息的原因。

并且以后打开文件后。新增文件绑定描述符 可以依次增加(从3开始累加)。每一条shell命令执行,都会继承父进程的文件描述符。因此,所有运行的shell命令,都会有默认3个文件描述符。

  • 进程级别的文件描述符表files_struct:内核为每个进程维护一个文件描述符表,该表记录了文件描述符的相关信息,包括文件描述符、指向打开文件表中记录的指针。
  • 系统级别的打开文件表file:内核对所有打开文件维护的一个进程共享的打开文件描述表,表中存储了处于打开状态文件的相关信息,包括文件类型、访问权限、文件操作函数(file_operations)等。
  • 系统级别的 i-node 表:i-node 结构体记录了文件相关的信息,包括文件长度,文件所在设备,文件物理位置,创建、修改和更新时间等,"ls -i" 命令可以查看文件 i-node 节点。

进程在Linux内核中是由结构体task_struct维护,进程打开的所有文件描述符都在进程维护的结构体task_struct的files变量中维护:

//include\linux\sched.h

struct task_struct {
……
/* open file information */
struct files_struct *files;
……
}

该结构体定义如下:

/*
* Open file table structure
*/
struct files_struct {
/*
* read mostly part
*/
atomic_t count;
struct fdtable __rcu *fdt;
struct fdtable fdtab;
/*
* written part on a separate cache line in SMP
*/
spinlock_t file_lock ____cacheline_aligned_in_smp;
int next_fd;
unsigned long close_on_exec_init[1];
unsigned long open_fds_init[1];
struct file __rcu * fd_array[NR_OPEN_DEFAULT];
};

该进程所有打开的文件对应的file指针均由fd_array维护,文件描述符和数组下标一一对应。

进程打开文件相关信息管理

文件描述符是一种系统资源,可以通过以下命令来查看文件描述符的上限。

查看所有进程允许打开的最大 fd 数量

查看所有进程已经打开的 fd 数量以及允许的最大数量

查看单个进程允许打开的最大 fd 数量.

查看某个文件被哪些进程打开?

可以借助lsof命令

编写调试代码如下:

该代码功能是打开文件test,然后休眠100秒,我们需要在这100秒内执行lsof操作。

&是程序放在后台运行,为了释放终端,方便输入下一个命令;

7284:程序进程ID;

lsof功能:查看某个文件被进程打开的详细信息。

查看某个进程打开了哪些文件?

接着上述的例子,ls -l /proc/{PID}/fd 可以查看某个进程打开了哪些文件。

可以看到该进程打开了除了test之外,还打开了前面所述的3个默认文件,结构体对应关系如下:

实际开发中,可能会遇到 fd 资源超过上限导致的 "Too many open files" 之类的问题,一般都是因为没有及时释放掉 fd,若循环执行超过单个进程允许打开的最大 fd 数量,程序就会出现异常。

Linux设备文件三大结构:inode,file,file_operations

驱动程序就是向下控制硬件,向上提供接口,驱动向上提供的接口最终对应到应用层有三种方式:设备文件,/proc,/sys,其中最常用的就是使用设备文件,而Linux设备中用的最多的就是字符设备,本文就以字符设备为例来分析创建并打开一个字符设备的文件内部机制。

struct inode

Linux中一切皆文件,当我们在Linux中创建一个文件时,就会在相应的文件系统创建一个inode与之对应。

对于不同的文件类型,inode被填充的成员内容也会有所不同,以创建字符设备为例,我们知道,add_chrdev_region其实是把一个驱动对象和一个(一组)设备号联系到一起。而创建设备文件,其实是把设备文件和设备号联系到一起。至此,这三者就被绑定在一起了。这样,内核就有能力创建一个struct inode实例了,下面是Linux 3.14内核中的inode。这个inode是VFS的inode,是最具体文件系统的inode的进一步封装,也是驱动开发中关心的inode,针对具体的文件系统,还有struct ext2_inode_info 等结构。

//include/linux/fs.h 596
/*
* Keep mostly read-only and often accessed (especially for
* the RCU path lookup and 'stat' data) fields at the beginning
* of the 'struct inode'
*/
struct inode {
umode_t i_mode; //表示访问权限控制
unsigned short i_opflags;
kuid_t i_uid; //用户ID
kgid_t i_gid; //用户组ID
unsigned int i_flags; //文件系统标志 #ifdef CONFIG_FS_POSIX_ACL
struct posix_acl *i_acl;
struct posix_acl *i_default_acl;
#endif const struct inode_operations *i_op;
struct super_block *i_sb;
struct address_space *i_mapping; #ifdef CONFIG_SECURITY
void *i_security;
#endif /* Stat data, not accessed from path walking */
unsigned long i_ino;
/*
* Filesystems may only read i_nlink directly. They shall use the
* following functions for modification:
*
* (set|clear|inc|drop)_nlink
* inode_(inc|dec)_link_count
*/
union { //硬链接数计数
const unsigned int i_nlink;
unsigned int __i_nlink;
};
dev_t i_rdev; //设备号
loff_t i_size; //以字节为单位的文件大小
struct timespec i_atime; //最后access时间
struct timespec i_mtime; //最后modify时间
struct timespec i_ctime; //最后change时间
spinlock_t i_lock; /* i_blocks, i_bytes, maybe i_size */
unsigned short i_bytes;
unsigned int i_blkbits;
blkcnt_t i_blocks; #ifdef __NEED_I_SIZE_ORDERED
seqcount_t i_size_seqcount;
#endif /* Misc */
unsigned long i_state;
struct mutex i_mutex; unsigned long dirtied_when; /* jiffies of first dirtying */ struct hlist_node i_hash;
struct list_head i_wb_list; /* backing dev IO list */
struct list_head i_lru; /* inode LRU list */
struct list_head i_sb_list;
union {
struct hlist_head i_dentry;//目录项链表
struct rcu_head i_rcu;
};
u64 i_version;
atomic_t i_count;//引用计数,当引用计数变为0时,会释放inode实例
atomic_t i_dio_count;
atomic_t i_writecount;//写者计数
const struct file_operations *i_fop; /* former ->i_op->default_file_ops */
struct file_lock *i_flock;
struct address_space i_data;
#ifdef CONFIG_QUOTA
//创建设备文件的时候i_fops填充的是def_chr_fops,
//blk_blk_fops,def_fifo_fops,bad_sock_fops之一,
//参见创建过程中调用的init_special_inode()
struct dquot *i_dquot[MAXQUOTAS];
#endif
struct list_head i_devices;
union {
//特殊文件类型的union,pipe,cdev,blk.link etc,
//i_cdev表示这个inode属于一个字符设备文件,
//本文中创建设备文件的时候会把与之相关的设备号的驱动对象cdev拿来填充
struct pipe_inode_info *i_pipe;
struct block_device *i_bdev;
struct cdev *i_cdev;
}; __u32 i_generation; #ifdef CONFIG_FSNOTIFY
__u32 i_fsnotify_mask; /* all events this inode cares about */
struct hlist_head i_fsnotify_marks;
#endif #ifdef CONFIG_IMA
atomic_t i_readcount; /* struct files open RO */
#endif
//inode的私有数据
void *i_private; /* fs or device private pointer */
};

重要的成员已经添加注释。

struct file

Linux内核会为每一个进程维护一个文件描述符表,这个表其实就是struct file[]的索引。open()的过程其实就是根据传入的路径填充好一个file结构并将其赋值到数组中并返回其索引。下面是file的主要内容

struct file {
union {
struct llist_node fu_llist;
struct rcu_head fu_rcuhead;
} f_u;
struct path f_path;
#define f_dentry f_path.dentry
struct inode *f_inode; /* cached value */
const struct file_operations *f_op;
/*
* Protects f_ep_links, f_flags.
* Must not be taken from IRQ context.
*/
spinlock_t f_lock;
atomic_long_t f_count;
unsigned int f_flags;
fmode_t f_mode;
struct mutex f_pos_lock;
loff_t f_pos;
struct fown_struct f_owner;
const struct cred *f_cred;
struct file_ra_state f_ra;
u64 f_version;
#ifdef CONFIG_SECURITY
void *f_security;
#endif
/* needed for tty driver, and maybe others */
void *private_data; #ifdef CONFIG_EPOLL
/* Used by fs/eventpoll.c to link all the hooks to this file */
struct list_head f_ep_links;
struct list_head f_tfile_llink;
#endif /* #ifdef CONFIG_EPOLL */
struct address_space *f_mapping;
#ifdef CONFIG_DEBUG_WRITECOUNT
unsigned long f_mnt_write_state;
#endif
} __attribute__((aligned(4)));
/* lest something weird decides that 2 is OK */

关键成员定义如下:

-->f_path里存储的是open传入的路径,VFS就是根据这个路径逐层找到相应的inode
-->f_inode里存储的是找到的inode
-->f_op里存储的就是驱动提供的file_operations对象,这个对象应该在第一次open()的时候被填充,具体地,应用层的open通过层层搜索会调用inode.i_fops->open(),即我们注册的open接口函数chrdev_open()
-->f_count的作用是记录对文件对象的引用计数,也即当前有多少个使用CLONE_FILES标志克隆的进程在使用该文件。典型的应用是在POSIX线程中。就像在内核中普通的引用计数模块一样,最后一个进程调用put_files_struct()来释放文件描述符。
-->f_flags当打开文件时指定的标志,对应系统调用open的int flags,比如驱动程序为了支持非阻塞型操作需要检查这个标志是否有O_NONBLOCK。
-->f_mode;对文件的读写模式,对应系统调用open的mod_t mode参数,比如O_RDWR。如果驱动程序需要这个值,可以直接读取这个字段。
-->private_data表示file结构的私有数据

mknod 做了什么事?

本例假定我们创建两个串口com0、com1,他们公用同一个主设备号250,次设备号分别为0、1,他们公用同一个字符设备驱动,那么我们的驱动要能够根据应用进程打开的是设备com0还是com1来操作不同的串口。

首先创建两个设备节点:

mknod /dev/com0 c 250 0
mknod /dev/com1 c 250 1

执行结果如下:

内核为了维护这两个文件节点,内核需要创建结构体维护这两个文件,具体如下图所示:

当我们通过命令mknod创建一个字符设备文件,那么内核就会创建好一个inode会存在存储器中,创建和该文件实体一一对应的inode。这个inode和其他的inode一样,通常用来存储关于这个文件的静态信息(不变的信息),包括这个设备文件对应的设备号,文件的路径以及对应的驱动对象等。

inode作为VFS四大对象之一,在驱动开发中很少需要自己进行填充,更多的是在open()方法中进行查看并根据需要填充我们的file结构。

创建字符设备 /dev/com0、 /dev/com1,只是增加了对应的inode节点,此时VFS层并没有并没有创建file结构体,而且inode和驱动也并没有产生联系。

进程打开设备文件发生了什么?

当进程试图打开设备文件的时候,系统做了什么事?

如果应用程序执行以下代码:

fd0 = open("/dev/com0",O_RDWR);
fd1 = open("/dev/com1",O_RDWR);

各个结构体之间关系入下图所示:

当应用程序执行open函数,该函数会调用到内核的sys_open(),该函数会根据该设备节点inode保存的信息,i_flags:文件类型, i_rdev:设备号,初始化结构体inode其他信息,比如inode->i_cdev,此时已经指向我们注册的cdev结构体。

通过设备号,可以很容易找到该设备在设备号全局管理数组chedevs[]的下标,进而找到我们注册的驱动cdev以及file_operations。

同时内核会在VFS层为创建结构体file,该函数调用成功之后,应用层会返回整型值用来和该file对应,就是上图的文件描述符fd0、fd1。

其中:

file->f_dentry->d_inode->i_rdev  保存对应的设备节点的设备号,
file-> f_op保存我们注册的file_operations 字符设备接口函数集合。

由此可得在read和write等其他接口函数中,我们可以通过file来得到次设备号。

【注意】同一个文件如果打开了两次,那么第二次linux内核仍然会重新分配1个新的file结构体和文件描述符。

驱动如何支持多种同类型设备

对于同种类型设备,比如多个串口、网口等,这些驱动比较类似,仅仅是一些寄存器基地址不一样,所以我们没有必须要为每一个设备单独写一个驱动,这些设备的驱动完全可以共用同一个驱动,我们只需要在驱动中区分出设备的次设备号,然后根据次设备号的访问不同的内存地址空间即可。

根据上一届内容,驱动的read、write可以通过以下方式获得设备号:

file->f_dentry->d_inode->i_rdev

这样我们就可以通过宏MINOR来提取此设备号。

实现代码如下:

ssize_t dev_fifo_read (struct file *file, char __user *buf, size_t size,
loff_t *pos)
{
int minor = MINOR(file->f_dentry->d_inode->i_rdev);
struct mydev *cd; printk("read() MINOR(file->f_dentry->d_inode->i_rdev)=%d\n",minor); cd = (struct mydev *)file->private_data;
printk("read() file->private_data cd->test=%d\n",cd->test); if(copy_to_user(buf, &minor, size)){
return -EFAULT;
}
return size;
}

当驱动可以提取次设备号之后,我们就可以实现一份驱动支持多个同种类型的设备。

如何获得注册的设备结构体私有地址?

在大多情况下,我们会创建一个自定义的设备信息维护结构体,同时创建一个指针数组用来管理不同的设备。

#define MAX_COM_NUM 2

struct mydev{
struct cdev cdev;
char *reg;
int test;
};
struct mydev *pmydev[MAX_COM_NUM];

然后通过成员cdev注册字符设备,

 for(i=0;i<MAX_COM_NUM;i++)
{
pmydev[i]->test = i;
cdev_init(&pmydev[i]->cdev,&dev_fifo_ops);
devno = MKDEV(major,i); error = cdev_add(&pmydev[i]->cdev,devno,1);
if(error < 0)
{
printk("cdev_add fail \n");
goto ERR2;
}
}

想一个问题:如果我们为每一个同类型设备分配独立的设备结构体,分别注册对应的cdev,假如我打开/dev/com0 进行操作的时候,我怎么知道com0对应我们自己定义的设备管理结构体变量的地址呢?

有问题是好的,我们带着问题出发,看看大牛们是怎么做的。

//打开设备
static int dev_fifo_open (struct inode *inode, struct file *file)
{
struct mydev *cd; cd = container_of(inode->i_cdev, struct mydev, cdev);
file->private_data = cd;
return 0;
}

该函数功能:

字符设备架构调用我们注册的接口函数open会传递参数inode和file,inode->i_cdev指向了我们注册的pmydev[i]->cdev,在open中通过inode->cdev来识别具体的设备,通过container_of来找到对应的pmycdev结构体变量,并将其私有数据隐藏到file结构的private_data中,进而识别同一个驱动操作一类设备。

而read,write接口函数可以直接通过file的 private_data获取对应的pmycdev结构体变量。

cd = (struct mydev *)file->private_data;

【补充1】

再来看下contianer_of 接口功能参数如下:

该宏是如何实现的,留给读者自己思考。

【补充2】

我们也可以在回调cdev.fops->open()阶段重新填充file结构的fop,进而实现同一个驱动操作不同的设备,这种思想就是内核驱动中常用的分层!

执行结果如下:

由结果可知,应用程序正确读取了minor的值。

从内核log来看,MINOR(file->f_dentry->d_inode->i_rdev)可以成功读取此设备号。而read接口函数也成功通过file->private_data得到了设备结构体变量(初始化的时候为不同设备的test成员附了不同的值)。

驱动程序:

#include <linux/init.h>
#include <linux/module.h>
#include <linux/kdev_t.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/slab.h>
#include <linux/uaccess.h>
static int major = 250;
static int minor = 0;
static dev_t devno; #define MAX_COM_NUM 2 struct mydev{
struct cdev cdev;
char *reg;
int test;
};
struct mydev *pmydev[MAX_COM_NUM]; ssize_t dev_fifo_read (struct file *file, char __user *buf, size_t size, loff_t *pos)
{
int minor = MINOR(file->f_dentry->d_inode->i_rdev);
struct mydev *cd; printk("read() MINOR(file->f_dentry->d_inode->i_rdev)=%d\n",minor); cd = (struct mydev *)file->private_data;
printk("read() file->private_data cd->test=%d\n",cd->test); if(copy_to_user(buf, &minor, size)){
return -EFAULT;
} return size;
}
int dev_fifo_close (struct inode *inode, struct file *file)
{
printk("dev_fifo_close()\n");
return 0;
}
//打开设备
static int dev_fifo_open (struct inode *inode, struct file *file)
{
struct mydev *cd; cd = container_of(inode->i_cdev, struct mydev, cdev);
file->private_data = cd;
return 0;
}
static struct file_operations dev_fifo_ops =
{
.open = dev_fifo_open,
.read = dev_fifo_read,
.release = dev_fifo_close,
};
static int dev_fifo_init(void)
{
int result;
int error;
int i = 0; printk("dev_fifo_init \n");
devno = MKDEV(major,minor);
result = register_chrdev_region(devno, MAX_COM_NUM, "test");
if(result<0)
{
printk("register_chrdev_region fail \n");
goto ERR1;
} for(i=0;i<MAX_COM_NUM;i++)
{
pmydev[i] =kmalloc(sizeof(struct mydev), GFP_KERNEL);
} for(i=0;i<MAX_COM_NUM;i++)
{
pmydev[i]->test = i;
cdev_init(&pmydev[i]->cdev,&dev_fifo_ops);
devno = MKDEV(major,i);
error = cdev_add(&pmydev[i]->cdev,devno,1);
if(error < 0)
{
printk("cdev_add fail \n");
goto ERR2;
}
}
return 0;
ERR2:
devno = MKDEV(major,0);
unregister_chrdev_region(devno,MAX_COM_NUM);
for(i=0;i<MAX_COM_NUM;i++)
{
kfree(pmydev[i]);
}
return error;
ERR1:
return result;
}
static void dev_fifo_exit(void)
{
int i; printk("dev_fifo_exit \n"); for(i=0;i<MAX_COM_NUM;i++)
{
cdev_del(&pmydev[i]->cdev);
}
for(i=0;i<MAX_COM_NUM;i++)
{
kfree(pmydev[i]);
}
devno = MKDEV(major,0);
unregister_chrdev_region(devno,MAX_COM_NUM);
return;
}
MODULE_LICENSE("GPL");
MODULE_AUTHOR("daniel.peng");
module_init(dev_fifo_init);
module_exit(dev_fifo_exit);

测试程序

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
main()
{
int fd0,fd1;
int minor; fd0 = open("/dev/com0",O_RDWR);
if(fd0<0)
{
perror("open fail \n");
return;
}
printf("open /dev/com0 OK\n"); read(fd0,&minor,sizeof(minor));
printf("minor of /dev/com0 =%d\n",minor);
close(fd0); fd1 = open("/dev/com1",O_RDWR);
if(fd1<0)
{
perror("open fail \n");
return;
}
printf("open /dev/com1 OK\n"); read(fd1,&minor,sizeof(minor));
printf("minor of /dev/com1 =%d\n",minor);
close(fd1);
}

最后 附送一幅我总结的字符设备的框架图:

获取更多关于Linux的资料,请关注公众号「一口Linux

手把手教Linux驱动4-进程、文件描述符、file、inode关系详解的更多相关文章

  1. 手把手教Linux驱动2-之模块参数和符号导出

    通过<手把手教Linux驱动1-模块化编程,玩转module>的学习,我们已经掌握了如何向内核加载一个模块,现在我们学习模块之间如何传递参数. 一.给模块传递参数 当我们加载一个模块到Li ...

  2. [转] linux系统文件流、文件描述符与进程间关系详解

    http://blog.sina.com.cn/s/blog_67b74aea01018ycx.html linux(unix)进程与文件的关系错综复杂,本教程试图详细的阐述这个问题. 包括:     ...

  3. [svc]linux中的文件描述符(file descriptor)和文件

    linux中的文件描述符(file descriptor)和文件 linux为了实现一切皆文件的设计哲学,不仅将数据抽象成了文件,也将一切操作和资源抽象成了文件,比如说硬件设备,socket,磁盘,进 ...

  4. linux内核中的文件描述符(二)--socket和文件描述符

    http://blog.csdn.net/ce123_zhouwei/article/details/8459730 Linux内核中的文件描述符(二)--socket和文件描述符 Kernel ve ...

  5. linux 文件描述符和inode 的理解和区别

    inode 或i节点是指对文件的索引.如一个系统,所有文件是放在磁盘或flash上,就要编个目录来说明每个文件在什么地方,有什么属性,及大小等.就像书本的目录一样,便于查找和管理.这目录是操作系统需要 ...

  6. 文件描述符file descriptor与inode的相关知识

    每个进程在Linux内核中都有一个task_struct结构体来维护进程相关的 信息,称为进程描述符(Process Descriptor),而在操作系统理论中称为进程控制块 (PCB,Process ...

  7. 手把手教Linux驱动3-之字符设备架构详解,有这篇就够了

    一.Linux设备分类 Linux系统为了管理方便,将设备分成三种基本类型: 字符设备 块设备 网络设备 字符设备: 字符(char)设备是个能够像字节流(类似文件)一样被访问的设备,由字符设备驱动程 ...

  8. Linux进程描述符task_struct结构体详解--Linux进程的管理与调度(一)【转】

    Linux内核通过一个被称为进程描述符的task_struct结构体来管理进程,这个结构体包含了一个进程所需的所有信息.它定义在include/linux/sched.h文件中. 谈到task_str ...

  9. linux专题一之文件描述符、重定向、管道符、tee命令

    本节讨论一下几个问题: 1. 文件描述符. 2. 重定向. 3. 管道符 4. tee的用法. 1. 文件描述符. 在linux系统中一切皆文件.文件夹和设备都是文件.如何用来区别不同的文件呢?这里的 ...

  10. Linux中通过Socket文件描述符寻找连接状态介绍

    针对下文的总结:socket是一种文件描述符 进程的打开文件描述符表 Linux的三个系统调用:open,socket,pipe 返回的都是一个描述符.不同的进程中,他们返回的描述符可以相同.那么,在 ...

随机推荐

  1. Apache Kylin(三)Kylin上手

    Kylin 上手 根据Kylin 官方给出的测试数据,我们实际操作一下 Kylin. 1. 导入 Hive 数据 首先创建一个project,在界面左上角有个"Add Project&quo ...

  2. 分享两个内置Google广告位的Typecho主题

    前言 很多项目的开始都是因为情怀和热爱,"为爱发电"是一件很值得尊敬的事情,然而大量"为爱发电"的项目最后却不得不因"难以为继"而被迫停服. ...

  3. SpringBoot 校验post请求参数

    导读 前后端分离项目中,前端往后端传值时,后端都要做参数格式校验,比如校验数字最大值.最小值.是否允许为空.日期格式等等. 添加依赖 <!-- 参数校验 --> <dependenc ...

  4. yolov5+deepsort+slowfast复现

    1.运行环境 ubuntu 18.04.1 Cuda 11.5 Python 3.8.15 torch 1.10.1+cu113 torchvision 0.11.2+cu113 2.安装PyTorc ...

  5. 在 DjangoStarter 中集成 TailwindCSS

    前言 好久没有更新技术文章了 这个月开箱和随笔倒是写了不少,又忙又懒的 基础的文章不太想写,稍微深入一点的又需要花很多时间来写 虽然但是,最终还是想水一篇 最近做了一个基于 wagtail 的项目,有 ...

  6. Vscode 一次选中多行 光标一次定位多行

    1 . 鼠标点击开始位置(定位到行首时,鼠标就点击第一行的行首:定位到行尾时,鼠标就点击第一行的行尾:) 2.  按住shift+alt 点击结束的位置(定位到行首时,鼠标就点击最后一行的行首:定位到 ...

  7. SCSS与CSS的区别

    SCSS(Sassy CSS)是一种CSS预处理器,它扩展了CSS的功能,并为样式表的编写提供了额外的便利性.以下是两者之间的主要区别: 1. 语法扩展:           CSS标准层叠样式表语言 ...

  8. 移动web开发适配秘籍Rem

    目录 移动web开发的特点 Rem 布局适配原理 Media Query(媒体查询) scss 工程使用函数计算 JS动态获取屏幕的宽度 直接将html 的 font-size 设置成 100px 移 ...

  9. [oeasy]python0111_字型码_字符字型编码_点阵字库_ascii演化

    编码进化 回忆上次内容 上次回顾了 早期的英文字符点阵 最小的 3*5 通用的 5*7   点阵字库逐渐规范化 ​     添加图片注释,不超过 140 字(可选)     这些点阵字符的字型 究竟是 ...

  10. UE-自带的HotUpdate【转】

    原文链接:https://baijiahao.baidu.com/s?id=1745200406976270792&wfr=spider&for=pc 这是百度可以直接搜索到的 UE4 ...