IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 系统运维 -> linux驱动程序开发指南-字符驱动介绍 -> 正文阅读

[系统运维]linux驱动程序开发指南-字符驱动介绍

概述:

在linux系统中设备驱动程序通常是作为应用层和设备层的中间层软件,驱动程序的主要功能是实现应用层访问硬件设备的具体操作接口,通过调用驱动程序,上层应用程序可以采用统一的接口访问各种硬件设备。由于驱动程序一般位于操作系统内核层,因此驱动也可以作为用户空间与内核空间交互的接口层,用户空间、驱动、内核空间框架如下图所示:

? ? ? ? 图1

根据设备数据访问方式一般将linux驱动分为字符设备驱动和块设备驱动两大类,字符设备一般按照字节流的方式访问设备,如串口,块设备一般按照固定长度数据块的方式访问设备如磁盘。如果根据驱动所在的子系统进行分类的话,驱动可以划分为终端(tty)驱动,网络设备驱动,USB驱动等。

由于硬件设备种类繁多,且各硬件访问方式各不相同,导致针对不同的硬件需要编写不同的驱动程序,在实际项目中需要结合具体的硬件资料进行相关调试。为了便于理解,本文不包含具体的硬件设备驱动编写方法,尽量不涉及其他子系统的内容,只包含通用字符设备驱动框架介绍。

1 驱动模块基本结构

linux系统驱动程序可以直接编译到内核中,也可以编译成模块的方式,驱动模块可以进行单独安装。为了便于调试,项目中我们一般用模块的形式进行驱动开发,本章介绍驱动模块代码的基本结构。

1.1 驱动模块安装接口

通过宏module_init(function)申明函数function为模块安装时开始调用的接口函数,相当于应用程序main函数。在终端通过insmod命令安装驱动模块时会调用该函数,该函数实现的主要功能实现模块相关初始化工作,比如资源申请。

1.2 驱动模块卸载接口

通过宏module_exit(function)申明函数function为模块卸载时调用的接口函数。当卸载驱动模块是会以该函数为调用入口,在终端通过rmmod命令卸载驱动模块时会调用该函数,该函数实现的主要功能是实现模块相关的清理工作,比如资源释放。

1.3 驱动模块头文件

要编写一个linux驱动模块必须要包含以下头文件:

#include <linux/init.h>

该头文件包含内核初始化相关接口的申明和定义。

#include <linux/module.h>

该头文件包含内核模块相关接口、数据结构的申明和定义,宏module_init、module_exit也是通过这个头文件定义的。

1.4 模块常用宏定义

一般我们的模块程序会使用宏MODULE_LICENSE是用来告知内核, 该模块带有一个自由的许可证; 如果没有这样的说明, 在模块加载时内核打印如下警告信息:

“module verification failed: signature and/or required key missing - tainting kernel”

模块中增加以下语句,可以避免警告:

MODULE_LICENSE("Dual BSD/GPL");

另外以下几个宏可以根据需要使用

MODULE_AUTHOR:申明模块编写人

MODULE_DESCRIPION:模块描述

MODULE_VERSION:模块版本

1.5 驱动模块程序示例

编写一个简单的驱动模块程序test_driver.c如下所示:

#include <linux/init.h>

#include <linux/module.h>

MODULE_LICENSE("Dual BSD/GPL");

static int test_init(void)

{

??????? printk("test module init\n");

??????? return 0;

}

static void test_exit(void)

{

??????? printk("test module exit\n");

}

module_init(test_init);

module_exit(test_exit);

以上例子中printk用于在内核空间打印信息,功能和用户空间的printf接口类似。

2 驱动模块编译

2.1 驱动makefile模板格式

驱动模块编译和应用程序编译不同,由于驱动模块一般要调用内核其他接口,所以驱动模块要使用内核编译系统来进行编译。内核驱动模块makefile格式比较固定,模板如下:

ifneq ($(KERNELRELEASE),)

obj-m := module_name.o

module_name -objs := file1.o file2.o …

else

KERNELDIR ?= /lib/modules/$(shell uname -r)/build

PWD := $(shell pwd)?

default:

$(MAKE) -C $(KERNELDIR) M=$(PWD) modules

endif?

clean:

?? rm -rf *.o .*.cmd *.ko *.mod.c modules.order? Module.symvers

运行make命令,makefile会执行两次,第一次执行else分支,设置变量KERNELDIR和PWD,然后调用KERNELDIR所指定目录的内核Makefile。内核Makefile再次执行第一个分支编译当前目录模块。这里makefile中的”clean”目标和应用程序makefile意义相同,都是清除生成文件。驱动模块makefile模板虽然不好理解但实际使用并不需要修改太多,一般只要修改以下两行:

obj-m := module_name.o?

该行指定要生成的模块名称

module_name -objs := file1.o file2.o …

该行指定生成模块所依赖的目标文件,file1.o file2.o分别是模块.c文件对应的目标文件。

2.2 驱动模块Makefile实例

编译上节驱动模块只要对Makefile模板文件进行简单修改:将块名改成修改成test,将目标文件指定为test_driver.o。

ifneq ($(KERNELRELEASE),)

obj-m := test.o

test-objs := test_driver.o

else

KERNELDIR ?= /lib/modules/$(shell uname -r)/build

PWD := $(shell pwd)?

default:

??????? $(MAKE) -C $(KERNELDIR) M=$(PWD) modules

endif

clean:

??????? rm -rf *.o .*.cmd *.ko *.mod.c modules.order? Module.symvers

修改完后在终端输入make命令进行编译,当前目录下生成test.ko文件。通过insmod test.ko安装驱动,dmesg 查看内核打印信息如下:

“test module init”

rmmod test.ko卸载驱动,dmesg 查看内核打印信息如下:

“test module exit”

3 字符驱动与应用层交互

前面两章对内核驱动模块基本结构和编译方式进行了介绍,本章在此基础上通过编写一个完整的字符设备驱动,介绍驱动和应用层交互的相关接口。之所以选字符设备作为例子来讲是由于字符设备驱动比块设备驱动、网络设备等其他驱动要简单,另外字符驱动也能用于很多简单硬件设备驱动,比如:项目中长见的spi、I2C、pcie等设备驱动都是字符设备驱动。

3.1 应用程序访问设备接口

在linux系统中用户层操作一切都是以文件的形式,对于操作设备是通过读写设备文件的方式来实现。在用户层读写设备和读写普通文件没有任何差别,也是通过调用open、lseek、read、write、close等系统API访问设备。

设备文件通常位于/dev目录下。终端输入ls /dev –al可以查看各设备文件属性,下图截取了一部分串口终端设备文件属性:

?

图2

红框中的两列数字分别表示主设备号和次设备号,主设备号表示某一类设备,次设备号用于区分该类设备中的具体设备。设备号和设备文件如何创建的?如何与驱动程序关联的?下面我们逐步分析。

3.2 设备号分配、释放

3.2.1 设备号申请

主设备号一般在驱动程序中由内核相应接口函数动态分配,也可以手动指定当前系统未使用的主设备号,由于手动分配要指定空闲设备号,为了避免冲突,我一般不用这种方式。下面介绍动态分配的方式。

设备号动态分配接口函数:

int alloc_chrdev_region(dev_t *dev,

unsigned int firstminor,

unsigned int count,

char *name);

接口说明:

dev :申请的第一个设备号

firstminor:起始次设备号

count:连续请求设备号数

name:设备名称

返回值: 生成成功返回0,失败返回错误码

调用该接口成功申请设备号之后,可以通过以下宏定义进行设备号相关操作:

#define MINORBITS?? 20

#define MINORMASK?? ((1U << MINORBITS) - 1)

#define MAJOR(dev)? ((unsigned int) ((dev) >> MINORBITS))

#define MINOR(dev)? ((unsigned int) ((dev) & MINORMASK))

#define MKDEV(ma,mi)? (((ma) << MINORBITS) | (mi))

MAJOR(dev):获取主设备号

MINOR(dev):获取次设备号

MKDEV(ma,mi):将主设备号和次设备号组合成一个设备号

从宏定义知道32位的设备号由高12位的主设备号和低20位的次设备号组成。

3.2.2 设备号释放

设备号释放接口函数:

void unregister_chrdev_region(dev_t first, unsigned int count);

参数说明:
first:释放的第一个设备号

count:释放的设备号数

该接口将申请的设备号释放掉,一般用于驱动卸载时。

3.2.3 设备号申请示例

将第一章的例子增加设备号申请接口:

#include <linux/init.h>

#include <linux/module.h>

#include <linux/aio.h> //设备号申请接口相关头文件

MODULE_LICENSE("Dual BSD/GPL");

int test_dev_major = 0;

static int test_init(void)

{

??? int result = 0;

??? dev_t dev = 0;?

???

??? printk("test module init\n");

??? result = alloc_chrdev_region(&dev, 0, 1, "test_dev");

??? test_dev_major = MAJOR(dev);

??? if (result < 0)

??? {

??????? printk("test_dev: can't get major %d\n", test_dev_major);

??????? return result;

??? }

?? ?printk("test_dev: alloc devid %d major %d minor %d\n", dev, test_dev_major, MINOR(dev));

??? return 0;

}

static void test_exit(void)

{

??? printk("test module exit\n");

??? unregister_chrdev_region(MKDEV(test_dev_major, 0), 1);

}

module_init(test_init);

module_exit(test_exit);

编译后安装,dmesg 查看内核增加了以下打印信息:

?

说明申请了主设备号243。通过cat /proc/devices命令可以查看当前申请了哪些主设备号:

?

可以使用rmmod命令卸载驱动,再次查看的话设备号已经释放掉:

?

3.3 设备文件创建

之前提到应用层访问设备是通过读写/dev目录下的设备文件的方式,本节对设备文件的创建方式进行介绍。

这里为了便于理解不涉及通过内核接口的方式创建设备文件,这里只介绍通过终端输入命令来创建字符设备文件这种简单的方式:

mknod [OPTION]... NAME TYPE [MAJOR MINOR]

为了便于理解,[OPTION]可选参数这里不讨论。其他几个参数内容为:

NAME:创建的设备文件名,如/dev/test_dev

TYPE:创建的设备文件类型,字符设备为‘c’

MAJOR:主设备号

MINOR:次设备号

已上一节的驱动程序为例,创建一个字符设备文件如下所示:

在根用户下输入命令:mknod /dev/test_dev c 243 0

输入命令ls /dev –al | grep test 查看/dev目录确实生成了一个“test_dev”文件。

?

3.4 设备文件操作接口说明

现在设备文件已经创建了,创建的设备文件和我们的驱动怎么关联起来?当应用层读写设备时如何和设备操作关联起来?本节对相关问题进行讨论。

3.4.1 设备数据结构定义

为了便于理解,创建一个虚拟设备,设备数据结构定义如下:

#define BUF_SIZE 128

struct test_dev {

??char buf[BUF_SIZE];

? struct cdev cdev;

};

buf数组模拟设备数据空间。

cdev 字符设备结构体,表面虚拟设备是一个字符设备。

3.4.2 字符设备初始化

void cdev_init(struct cdev *cdev, struct file_operations *fops);

参数说明:

cdev:字符设备指针,表示一个字符设备。结构在include/linux/cdev.h文件中定义:

struct cdev {

??????? struct kobject kobj;

??????? struct module *owner;

??????? const struct file_operations *ops;

??????? struct list_head list;

??????? dev_t dev;

??????? unsigned int count;

};

fops:文件操作接口,包含文件操作的多个接口,结构在文件include/linux/fs.h中定义如下:

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 (*read_iter) (struct kiocb *, struct iov_iter *);

??????? ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);

??????? int (*iterate) (struct file *, struct dir_context *);

??????? unsigned int (*poll) (struct file *, struct poll_table_struct *);

??????? long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);

??????? long (*compat_ioctl) (struct file *, unsigned int, unsigned long);

??????? int (*mmap) (struct file *, struct vm_area_struct *);

??????? 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);

??????? int (*aio_fsync) (struct kiocb *, int datasync);

??????? int (*fasync) (int, struct file *, int);

??????? int (*lock) (struct file *, int, struct file_lock *);

??????? ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);

??????? unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);

??????? int (*check_flags)(int);

??????? int (*flock) (struct file *, int, struct file_lock *);

??????? ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);

? ??????ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);

??????? int (*setlease)(struct file *, long, struct file_lock **, void **);

??????? long (*fallocate)(struct file *file, int mode, loff_t offset,

????? ????????????????????loff_t len);

??????? void (*show_fdinfo)(struct seq_file *m, struct file *f);

#ifndef CONFIG_MMU

??????? unsigned (*mmap_capabilities)(struct file *);

#endif

};

该结构比较复杂,为了便于理解,下面只介绍几个经常要用到的成员。

struct module *owner?

是一个指向拥有这个结构的模块的指针。这个成员用来在它的操作还在被使用时阻止模块被卸载一般初始化为THIS_MODULE, 该宏在include/linux/module.h文件中定义。

3.4.3 文件打开

int (*open)(struct inode *inode, struct file *filp);

参数说明:

inode :该参数包含 struct cdev 成员地址,通过cdev成员地址可以进一步获取设备数据结构地址。

filp:文件结构指针,表示当前打开的一个文件,该结构比较复杂,在include/linux/fs.h文件中定义,这里只说明一个下面要使用到的成员void *private_data,该指针指向驱动定义的私有数据,这样就可以通过文件指针和驱动定义的数据联系起来。

3.4.4 文件读取

ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);

参数说明:

file:同open接口

__user:用户空间数据缓存地址

size_t:读取文件数据长度

loff_t:文件偏移位置

返回值:返回负值表示错误,非负值表示正确读出的数据长度

3.4.5 文件写入

ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);

参数说明:

file:同open接口

__user:用户空间数据缓存地址

size_t:待写入文件的数据长度

loff_t:文件偏移位置

返回值:返回负值表示错误,非负值表示正确写入的数据长度

3.5 设备文件操作接口实现

上一节对字符设备操作接口进行了说明,本节通过在之前的例子中实现相关接口,实现一个完整的字符驱动。

3.5.1 open接口实现

在介绍该接口实现前,先说明一下宏定义container_of,在内核头文件/include/linux/kernel.h中定义:

#define container_of(ptr, type, member) ({????????????????????? \

??????? const typeof( ((type *)0)->member ) *__mptr = (ptr);??? \

??????? (type *)( (char *)__mptr - offsetof(type,member) );})

该宏定义的功能是根据结构体成员地址获取结构体地址。

接口实现如下:

int test_open (struct inode *inode, struct file *filp)

{

??? struct test_dev *pdev;

??? /*这里根据cdev地址获取test_dev 地址*/

??? pdev = container_of(inode->i_cdev, struct test_dev, cdev);

??? /* 文件私有数据指针指向字符设备地址*/

??? filp->private_data = pdev;

??? return 0;????????? /* success */

}

3.5.2 read接口实现

用户空间程序数据拷贝可以直接使用C库函数memcpy,但是驱动程序将内核空间数据拷贝到用户空间不能直接调用memcpy接口,需要用到以下接口:
unsigned long copy_to_user(void __user *to,const void *from,unsigned long count);?

参数说明:

to:用户空间目的地址

from:内核空间源地址

count:拷贝数据长度

返回值:0 成功, 非0 未拷贝的数据

read接口实现如下:

ssize_t test_read (struct file *filp, char __user *buf, size_t count,loff_t *f_pos)

{

?? //通过文件指针私有数据获取字符设备地址

struct test_dev *dev = filp->private_data;

?? ssize_t retval = 0;

?? loff_t pos = *f_pos;

?????

?? if((count > BUF_SIZE) || (pos >= BUF_SIZE))

?? {

????? return -EFAULT;

?? }

?? /*拷贝内核数据到用户空间*/

?? if (copy_to_user (buf, dev->buf + pos, count)) {

????? retval = -EFAULT;

????? goto out;

?? }

??

?? return count;

?? out:

?? return retval;

}

3.5.3 write接口实现

类似于read接口,用户空间程序数据拷贝可以直接使用C库函数memcpy,但是驱动程序将用户空间数据拷贝到内核空间不能直接调用memcpy接口,需要用到以下接口:
unsigned long copy_from_user(void *to,const void __user *from,unsigned long count);?

参数说明:

to:内核空间目的地址

from:用户空间源地址

count:拷贝数据长度

返回值:0 成功, 非0 未拷贝的数据

write接口实现:

ssize_t test_write (struct file *filp, const char __user *buf, size_t count,loff_t *f_pos)

{

?? struct test_dev *dev = filp->private_data;

?? ssize_t retval = 0;

?? loff_t pos = *f_pos;

??

?? if((count > BUF_SIZE) || (pos > BUF_SIZE))

?? {

????? return -EFAULT;

?? }

?? ?if (copy_from_user (dev->buf + pos, buf, count))

{

????? retval = -EFAULT;

????? goto out;

?? }

?? return count;

out:

?? return retval;

}

3.5.4 文件操作数据结构初始化

基本的文件操作接口实现后就可以对文件操作结构体变量进行初始化了:

struct file_operations test_fops = {

?? .owner =???? THIS_MODULE,

?? .read = ???? test_read,

?? .write =???? test_write,

?? .open = ???? lpc_test_open,

};

3.6 字符设备添加

设备初始化后需要将字符设备添加到内核:

int cdev_add(struct cdev *dev, dev_t num, unsigned int count);

参数说明:

dev:已初始化的字符设备

num:设备响应的第一个设备号

count:关联到设备的设备号的数目, 一般是 1

3.7 字符设备删除

当驱动卸载时需从内核中删除字符设备:

void cdev_del(struct cdev *dev)

参数说明:

dev:已添加的字符设备

4 字符设备驱动完整实现

通过前几章的了解,本章通过将上章实现的接口添加都前面的示例代码中实现一个完整的简单驱动程序,添加相关接口的驱动代码如下:

#include <linux/init.h>

#include <linux/module.h>

#include <linux/kernel.h>???? /* container_of */

#include <linux/fs.h>???????? /* file opt */

#include <linux/errno.h>????? /* error codes */

#include <linux/types.h>????? /* size_t */

#include <linux/aio.h>??????? /* alloc_chrdev_region */

#include <linux/uaccess.h>??? /* copy_to_user */

#include <linux/cdev.h>?????? /* struct cdev */

MODULE_LICENSE("Dual BSD/GPL");

int test_dev_major = 0;

#define BUF_SIZE 128

struct test_dev {

? char buf[BUF_SIZE];

? struct cdev cdev;

}test_dev;

int test_open (struct inode *inode, struct file *filp)

{

??????? struct test_dev *pdev;

? ??????/*这里根据cdev地址获取test_dev 地址*/

??????? pdev = container_of(inode->i_cdev, struct test_dev, cdev);

??????? /* 文件私有数据指针指向字符设备地址*/

??????? filp->private_data = pdev;

??????? printk("test_dev open success\n");

??????? return 0;

}

ssize_t test_read (struct file *filp, char __user *buf, size_t count,loff_t *f_pos)

{

??????? //通过文件指针私有数据获取字符设备地址

??????? struct test_dev *dev = filp->private_data;

??????? ssize_t retval = 0;

??????? loff_t pos = *f_pos;

??????? if((count > BUF_SIZE) || (pos >= BUF_SIZE))

???? ???{

??????????????? return -EFAULT;

??????? }

??????? /*拷贝内核数据到用户空间*/

??????? if(copy_to_user(buf, dev->buf + pos, count))

??????? {

??????????????? retval = -EFAULT;

??????????????? goto out;

??????? }

??????? printk("test_dev read success\n");

?????? ?return count;

?? out:

??????? return retval;

}

ssize_t test_write (struct file *filp, const char __user *buf, size_t count,loff_t *f_pos)

{

??????? struct test_dev *dev = filp->private_data;

??????? ssize_t retval = 0;

??????? loff_t pos = *f_pos;

??????? if((count > BUF_SIZE) || (pos > BUF_SIZE))

??????? {

??????????????? return -EFAULT;

??????? }

??????? if(copy_from_user(dev->buf + pos, buf, count))

??????? {

??????????????? retval = -EFAULT;

??????????????? goto out;

??????? }

??????? printk("test_dev write success\n");

??????? return count;

out:

??????? return retval;

}

struct file_operations test_fops = {

??????? .owner =???? THIS_MODULE,

??????? .read =????? test_read,

??????? .write =???? test_write,

??????? .open =????? test_open,

};

static int test_init(void)

{

??? int result = 0;

??? dev_t dev = 0;?

???

??? printk("test module init\n");

??? result = alloc_chrdev_region(&dev, 0, 1, "test_dev");

??? test_dev_major = MAJOR(dev);

??? if (result < 0)

??? {

??????? printk("test_dev: can't get major %d\n", test_dev_major);

??????? return result;

??? }

???

??? printk("test_dev: alloc devid %d major %d minor %d\n", dev, test_dev_major, MINOR(dev));

??? cdev_init(&test_dev.cdev, &test_fops);

??? result = cdev_add(&test_dev.cdev, dev, 1);

??? if (result)

??? {

????? printk("Error %d adding test_dev\n", result);

??? }

??? return 0;

}

static void test_exit(void)

{

??? printk("test module exit\n");

??? cdev_del(&test_dev.cdev);

??? unregister_chrdev_region(MKDEV(test_dev_major, 0), 1);

}

module_init(test_init);

module_exit(test_exit);

5 字符驱动程序测试

本章通过一个简单的应用程序对上章编写的虚拟字符设备进行操作,测试程序如下:

#include <stdio.h>

#include <string.h>

#include <fcntl.h>

#include <unistd.h>

#define DEV_NAME "/dev/test_dev"

int main(int argc, char **argv)

{

??? int fd = 0;

??? char *testStr = "test dev opt";

??? char buf[64] = {0};

??? if((fd = open(DEV_NAME, O_RDWR|O_NONBLOCK)) < 0)

??? {??

????? return -1;

??? }

??? write(fd,testStr,strlen(testStr));

??? read(fd,buf,strlen(testStr));

??? printf("read:%s\n",buf);

??? close(fd);??

}

终端输入:gcc -o test.exe main.c编译乘车test.exe执行程序。

输入sudo ./test.exe运行结果如下:

read:test dev opt

dmesg命令查看内核驱动打印信息如下:

[2522609.557786] test_dev open success

[2522609.557792] test_dev write success

[2522609.557795] test_dev read success

(注:设备文件默认只有根权限才能读写)

总结:本文通过一个简单的字符驱动介绍了字符驱动框架、用户空间和内核空间的数据交互接口。为了便于理解,除了字符驱动必须接口外文中的例子尽量没有涉及内核的其他功能接口,如果想进一步了解驱动编写建议在此基础上对一个真实设备驱动进行学习。

?

  系统运维 最新文章
配置小型公司网络WLAN基本业务(AC通过三层
如何在交付运维过程中建立风险底线意识,提
快速传输大文件,怎么通过网络传大文件给对
从游戏服务端角度分析移动同步(状态同步)
MySQL使用MyCat实现分库分表
如何用DWDM射频光纤技术实现200公里外的站点
国内顺畅下载k8s.gcr.io的镜像
自动化测试appium
ctfshow ssrf
Linux操作系统学习之实用指令(Centos7/8均
上一篇文章      下一篇文章      查看所有文章
加:2022-04-23 11:09:45  更:2022-04-23 11:11:55 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/15 19:50:36-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码