1. 基本概念

块设备是Linux三大设备之一,其驱动模型主要针对磁盘,Flash等存储类设备,块设备(blockdevice)是一种具有一定结构的随机存取设备,对这种设备的读写是按(所以叫块设备)进行的,他使用缓冲区来存放暂时的数据,待条件成熟后,从缓存一次性写入设备或者从设备一次性读到缓冲区。

1.1 块设备结构

块设备由Page->Segment->Block->Sector的层次结构组成。
    Page就是内存映射的最小单位;Segment就是一个Page中我们要操作的一部分,由若干个相邻的块组成;Block是逻辑上的进行数据存取的最小单位,是文件系统的抽象,逻辑块的大小是在格式化的时候确定的, 一个Block最多仅能容纳一个文件(即不存在多个文件同一个block的情况)。如果一个文件比block小,他也会占用一个block,因而block中空余的空间会浪费掉。而一个大文件,可以占多个甚至数十个成百上千万的blockLinux内核要求 Block_Size = Sector_Size * (2的n次方),并且Block_Size <= 内存的Page_Size(页大小), 如ext2 fsblock缺省是4k。若block太大,则存取小文件时,有空间浪费的问题;若block太小,则硬盘的 Block 数目会大增,而造成inode在指向block的时候的一些搜寻时间的增加,又会造成大文件读写方面的效率较差,blockVFS和文件系统传送数据的基本单位。block对应磁盘上的一个或多个相邻的扇区,而VFS将其看成是一个单一的数据单元,块设备的block的大小不是唯一的,创建一个磁盘文件系统时,管理员可以选择合适的扇区的大小,同一个磁盘的几个分区可以使用不同的块大小。此外,对块设备文件的每次读或写操作是一种"原始"访问,因为它绕过了磁盘文件系统,内核通过使用最大的块(4096)执行该操作。Linux对内存中的block会被进一步划分为SectorSector是硬件设备传送数据的基本单位,这个Sector就是512byte,和物理设备上的概念不一样,如果实际的设备的sector不是512byte,而是4096byte(eg SSD),那么只需要将多个内核sector对应一个设备sector即可。

1.2 块设备与字符设备的区别

作为一种存储设备,和字符设备相比,块设备有以下几种不同:

字符设备 块设备
1byte 块,硬件块各有不同,但是内核都使用512byte描述
顺序访问 随机访问
没有缓存,实时操作 有缓存,不是实时操作
一般提供接口给应用层 块设备一般提供接口给文件系统
是被用户程序调用 由文件系统程序调用

2. 块设备模型

下图是Linux中的块设备模型示意图,应用层程序有两种方式访问一个块设备:/dev和文件系统挂载点,前者和字符设备一样,通常用于配置,后者就是我们mount之后通过文件系统直接访问一个块设备了。

1. read()系统调用最终会调用一个适当的VFS函数(read()-->sys_read()-->vfs_read()),将文件描述符fd和文件内的偏移量offset传递给它。
    2. VFS会判断这个SCI的处理方式,如果访问的内容已经被缓存在RAM中(磁盘高速缓存机制),就直接访问,否则从磁盘中读取。
    3. 为了从物理磁盘中读取,内核依赖映射层mapping layer,即上图中的磁盘文件系统。
        3.1 确定该文件所在文件系统的块的大小,并根据文件块的大小计算所请求数据的长度。本质上,文件被拆成很多块,因此内核需要确定请求数据所在的块。
        3.2 映射层调用一个具体的文件系统的函数,这个层的函数会访问文件的磁盘节点,然后根据逻辑块号确定所请求数据在磁盘上的位置。
    4. 内核利用通用块层(generic block layer)启动IO操作来传达所请求的数据,通常,一个IO操作只针对磁盘上一组连续的块。
    5. IO调度程序根据预先定义的内核策略将待处理的IO进行重排和合并。
    6. 块设备驱动程序向磁盘控制器硬件接口发送适当的指令,进行实际的数据操作。

3. 块设备驱动调用过程

当我们要写一个很小的数据到txt文件某个位置时,由于块设备写的数据是按扇区为单位,但又不能破坏txt文件里其它位置,那么就引入了一个“缓存区”,将所有数据读到缓存区里,然后修改缓存数据,再将整个数据放入txt文件对应的某个扇区中,当我们对txt文件多次写入很小的数据的话,那么就会重复不断地对扇区读出,写入,这样会浪费很多时间在读/写硬盘上,所以内核提供了一个队列的机制,再没有关闭txt文件之前,会将读写请求进行优化,排序,合并等操作,从而提高访问硬盘的效率(IO调度)
    IO调度其实就是电梯算法。我们知道,磁盘是的读写是通过机械性的移动磁头来实现读写的,理论上磁盘设备满足块设备的随机读写的要求,但是出于节约磁盘,提高效率的考虑,我们希望当磁头处于某一个位置的时候,一起将最近需要写在附近的数据写入,而不是这写一下,那写一下然后再回来,IO调度就是将上层发下来的IO请求的顺序进行重新排序以及对多个请求进行合并,这样就可以实现上述的提高效率、节约磁盘的目的。这种解决问题的思路使用电梯算法,一个运行中的电梯,一个人20楼->1楼,另外一个人15->5楼,电梯不会先将第一个人送到1楼再去15楼接第二个人将其送到5楼,而是从20楼下来,到15楼的时候停下接人,到5楼将第二个放下,最后到达1楼,一句话,电梯算法最终服务的优先顺序并不按照按按钮的先后顺序。
    当我们对一个*.txt写入数据时,文件系统会转换为对块设备上扇区的访问,也就是调用ll_rw_block()函数,从这个函数开始就进入了设备层。

3.1 ll_rw_block()函数

ll_rw_block()函数的部分代码如下(位于/fs/buffer.c):

/* * rw:读写标志位* nr:bhs[]长度* bhs[]:要读写的数据数组 */
void ll_rw_block(int rw, int nr, struct buffer_head *bhs[])
{int i; for (i = 0; i < nr; i++) {struct buffer_head *bh = bhs[i];    //获取nr个buffer_head... ...if (rw == WRITE || rw == SWRITE) {if (test_clear_buffer_dirty(bh)) {... ...submit_bh(WRITE, bh);    //提交WRITE写标志的buffer_head   continue;}}else {if (!buffer_uptodate(bh)) {... ...submit_bh(rw, bh);    //提交其它标志的buffer_headcontinue;}}unlock_buffer(bh); }
}

其中buffer_head结构体定义如下:

struct buffer_head {unsigned long b_state;          //缓冲区状态标志 struct buffer_head *b_this_page;    //页面中的缓冲区 struct page *b_page;           //存储缓冲区位于哪个页面sector_t b_blocknr;           //逻辑块号size_t b_size;              //块的大小char *b_data;               //页面中的缓冲区struct block_device *b_bdev;     //块设备,来表示一个独立的磁盘设备bh_end_io_t *b_end_io;         //I/O完成方法void *b_private;             //完成方法数据struct list_head b_assoc_buffers;   //相关映射链表/* mapping this buffer is associated with */struct address_space *b_assoc_map;   atomic_t b_count;             //缓冲区使用计数
};

submit_bh()函数就是通过bh来构造bio,然后调用submit_bio()提交bio,submit_bio()函数如下:

void submit_bio(int rw, struct bio *bio)
{... ...generic_make_request(bio);
}

最终调用generic_make_request(),把bio数据提交到相应块设备的请求队列中,generic_make_request()函数主要是实现对bio的提交处理。

3.2 generic_make_request()函数

    generic_make_request()函数如下:

void generic_make_request(struct bio *bio)
{if (current->bio_tail) {                   // current->bio_tail不为空,表示有bio正在提交*(current->bio_tail) = bio;     //将当前的bio放到之前的bio->bi_next里面bio->bi_next = NULL;    //更新bio->bi_next=0;current->bio_tail = &bio->bi_next; //然后将当前的bio->bi_next放到current->bio_tail里,使下次的bio就会放到当前bio->bi_next里面了return;    }BUG_ON(bio->bi_next);do {current->bio_list = bio->bi_next;if (bio->bi_next == NULL)current->bio_tail = &current->bio_list;elsebio->bi_next = NULL;__generic_make_request(bio);           //调用__generic_make_request()提交biobio = current->bio_list;} while (bio);current->bio_tail = NULL; /* deactivate */
}

__generic_make_request()首先由bio对应的block_device获取申请队列q,然后要检查对应的设备是不是分区,如果是分区的话要将扇区地址进行重新计算,最后调用q的成员函数make_request_fn完成bio的递交。__generic_make_request()函数如下:

static inline void __generic_make_request(struct bio *bio)
{request_queue_t *q;    int ret;  ... ...do{q = bdev_get_queue(bio->bi_bdev);    //通过bio->bi_bdev获取申请队列q... ...ret = q->make_request_fn(q, bio);    //提交申请队列q和bio}while (ret);
}

在内核中搜索make_request_fn,调用如下:
    blk_init_queue_node()
        blk_queue_make_request(q,
__make_request)
            make_request_fn

    最终q->make_request_fn()执行的是__make_request()函数。

3.3 __make_request()函数

static int __make_request(request_queue_t *q, struct bio *bio)
{struct request *req;          //块设备本身的队列... ...//将之前的申请队列q和传入的bio,通过排序,合并在本身的req队列中el_ret = elv_merge(q, &req, bio);... ...init_request_from_bio(req, bio);        //合并失败,单独将bio放入req队列add_request(q, req);                  //单独将之前的申请队列q放入req队列... ...__generic_unplug_device(q);      //执行申请队列的处理函数     }

上面的elv_merge()函数,就是之前所说的电梯算法函数。

3.4 总结

读写块设备在内核中的调用过程如下:
    ll_rw_block()    //进入内核中设备层,提交buff_head缓存区结构体
        submit_bio()    //用buff_head构造bio,提交bio
            submit_bio()    //把提交上来的bio提交的到相应块设备的请求队列中
                generic_make_request()    //对bio进行提交处理
                    __generic_make_request()    //获取等待队列q,提交bio
                        __make_request()    //合并q和bio,执行队列
                            elv_merge()    //使用电梯算法合并q和bio
                            __generic_unplug_device()  //执行队列
                                q->request_fn    //调用队列处理函数
   
这个队列处理函数显然就是要我们驱动去实现的,参考内核自带的块设备驱动程序drivers/block/xd.c,使用队列如下:

static struct request_queue *xd_queue;             //定义一个申请队列xd_queuexd_queue = blk_init_queue(do_xd_request, &xd_lock);       //分配一个申请队列

队列处理函数如下:

static void do_xd_request (request_queue_t * q)
{struct request *req;            if (xdc_busy)return;while ((req = elv_next_request(q)) != NULL)    //(1)while获取申请队列中的需要处理的申请{int res = 0;... ...for (retry = 0; (retry < XD_RETRIES) && !res; retry++)         res = xd_readwrite(rw, disk, req->buffer, block, count);//将获取申请req的buffer成员 读写到disk扇区中,当读写失败返回0,成功返回1end_request(req, res);         //申请队列中的的申请已处理结束,当res=0,表示读写失败}
}

最终申请队列q调用驱动来对扇区读写。

4. 块设备驱动程序实现流程

    分析内核块设备驱动drivers/block/xd.c如下:

static DEFINE_SPINLOCK(xd_lock);     //定义一个自旋锁,用到申请队列中
static struct request_queue *xd_queue; //定义一个申请队列xd_queuestatic int __init xd_init(void)          //入口函数
{if (register_blkdev(XT_DISK_MAJOR, "xd"))  //1.创建一个块设备,保存在/proc/devices中goto out1;xd_queue = blk_init_queue(do_xd_request, &xd_lock);  //2.分配一个申请队列,后面会赋给gendisk结构体的queue成员... ...for (i = 0; i < xd_drives; i++) {                   ... ...struct gendisk *disk = alloc_disk(64);  //3.分配一个gendisk结构体, 64:次设备号个数,也称为分区个数/*    4.接下来设置gendisk结构体        */disk->major = XT_DISK_MAJOR;             //设置主设备号disk->first_minor = i<<6;                //设置次设备号disk->fops = &xd_fops;                   //设置块设备驱动的操作函数disk->queue = xd_queue;                  //设置queue申请队列,用于管理该设备IO申请队列... ...xd_gendisk[i] = disk;}... ...for (i = 0; i < xd_drives; i++)add_disk(xd_gendisk[i]);                                //5.注册gendisk结构体
}

4.1 重点数据结构gendisk

Linux内核使用gendisk对象描述一个系统的中的块设备,类似于Windows系统中的磁盘分区和物理磁盘的关系,OS眼中的磁盘都是逻辑磁盘,也就是一个磁盘分区,一个物理磁盘可以对应多个磁盘分区,在Linux中,这个gendisk就是用来描述一个逻辑磁盘,也就是一个磁盘分区。

struct gendisk {int major;                        /*设备主设备号*/int first_minor;                  /*起始次设备号*/int minors;                       /*次设备号的数量,也称为分区数量,如果改值为1,表示无法分区*/char disk_name[32];              /*设备名称*/struct hd_struct **part;          /*分区表的信息*/int part_uevent_suppress;struct block_device_operations *fops;  /*块设备操作集合 */struct request_queue *queue;           /*申请队列,用于管理该设备IO申请队列的指针*/void *private_data;                    /*私有数据*/sector_t capacity;                     /*扇区数,512字节为1个扇区,描述设备容量*/....
};

4.2 块设备驱动程序流程

    1. 创建一个块设备
    2. 分配一个申请队列
    3. 分配一个gendisk结构体
    4. 设置gendisk结构体的成员
    5. 注册gendisk结构体

5. 编写代码

接下来仿造内核块设备驱动drivers/block/xd.c编写自己的驱动程序。

5.1 代码框架

5.1.1 入口函数中

    1. 使用register_blkdev()创建一个块设备
    2. blk_init_queue()使用分配一个申请队列,并赋申请队列处理函数
    3. 使用alloc_disk()分配一个gendisk结构体 4
    4. 设置gendisk结构体的成员
        4.1 设置成员参数(major、first_minor、disk_name、fops)
        4.2 设置queue成员,等于之前分配的申请队列
        4.3 通过set_capacity()设置capacity成员,等于扇区数
        4.4 使用kzalloc()来获取缓存地址,用做扇区
        4.5 使用add_disk()注册gendisk结构体

5.1.2 申请队列的处理函数中

    1. while循环使用elv_next_request()获取申请队列中每个未处理的申请
    2. 使用rq_data_dir()来获取每个申请的读写命令标志,为 0(READ)表示读, 为1(WRITE)表示写
    3. 使用memcp()来读或者写扇区(缓存)
    4. 使用end_request()来结束获取的每个申请

5.1.3 出口函数中

    1. 使用put_disk()和del_gendisk()来注销,释放gendisk结构体
    2. 使用kfree()释放磁盘扇区缓存
    3. 使用blk_cleanup_queue()清除内存中的申请队列
    4. 使用unregister_blkdev()卸载块设备

5.2 编写代码

驱动程序ramblock.c代码如下:

/*参考xd.c头文件*/
#include <linux/module.h>
#include <linux/errno.h>
#include <linux/interrupt.h>
#include <linux/mm.h>
#include <linux/fs.h>
#include <linux/kernel.h>
#include <linux/timer.h>
#include <linux/genhd.h>
#include <linux/hdreg.h>
#include <linux/ioport.h>
#include <linux/init.h>
#include <linux/wait.h>
#include <linux/blkdev.h>
#include <linux/blkpg.h>
#include <linux/delay.h>
#include <linux/io.h>
#include <asm/system.h>
#include <asm/uaccess.h>
#include <asm/dma.h>static struct gendisk *ramblock_disk;
static request_queue_t *ramblock_queue;
static DEFINE_SPINLOCK(ramblock_lock);
static int major;
#define RAMBLOCK_SIZE (1024*1024)
static unsigned char *ramblock_buf;static int fd_getgeo(struct block_device *bdev, struct hd_geometry *geo)
{int drive = MINOR(bdev->bd_dev) & 3;/*容量=heads*cylinders*sectors*512字节*/geo->heads = 2; //磁头,有多少面,假设2geo->cylinders = 32; //柱面,有多少环,假设32geo->sectors = RAMBLOCK_SIZE/2/32/512;    //按公式算出来扇区数return 0;
}static struct block_device_operations ramblock_fops = {.owner = THIS_MODULE,.getgeo      = fd_getgeo,
};
static void do_ramblock_request (request_queue_t * q)
{struct request *req;while ((req = elv_next_request(q)) != NULL) {    //以电梯调度算法执行下一请求/*数据传输3要素,源,目的,长度*//*源*/unsigned long offset = req->sector *512;    //偏移值/*目的*///req->buffer/*长度*/unsigned long len  = req->current_nr_sectors *512; //长度if(rq_data_dir(req)==READ)    //如果是读数据,就把ramblock_buf+offset里的数据拷贝到req->buffer{memcpy(req->buffer,ramblock_buf+offset,len);}else{memcpy(ramblock_buf+offset,req->buffer,len);}end_request(req, 1); //结束获取的申请}
}static int ramblock_init(void)
{/* 1. 创建一个块设备 */major=register_blkdev(0, "ramblock");/* 2. 分配一个gendisk结构体 */ramblock_disk=alloc_disk(16);  //不分区/* 3. 分配设置队列(用于填充gendisk) */ramblock_queue=blk_init_queue(do_ramblock_request, &ramblock_lock);/* 4. 设置gendisk结构体的成员 */ramblock_disk->major      =  major;ramblock_disk->first_minor =  0;sprintf(ramblock_disk->disk_name, "ramblock");ramblock_disk->fops         =  &ramblock_fops; ramblock_disk->queue     =  ramblock_queue;set_capacity(ramblock_disk, RAMBLOCK_SIZE/512);/* 5. 分配缓存地址,用做扇区 */ramblock_buf=kzalloc(RAMBLOCK_SIZE,GFP_KERNEL);/* 6. 注册gendisk结构体 */add_disk(ramblock_disk);return 0;
}static void ramblock_exit(void)
{del_gendisk(ramblock_disk);put_disk(ramblock_disk);kfree(ramblock_buf);blk_cleanup_queue(ramblock_queue);unregister_blkdev(major,"ramblock");
}
module_init(ramblock_init);
module_exit(ramblock_exit);
MODULE_LICENSE("GPL");

Makefile代码如下:

KERN_DIR = /work/system/linux-2.6.22.6    //内核目录all:make -C $(KERN_DIR) M=`pwd` modules clean:make -C $(KERN_DIR) M=`pwd` modules cleanrm -rf modules.orderobj-m  += ramblock.o

6. 测试

内核:linux-2.6.22.6
编译器:arm-linux-gcc-3.4.5
环境:ubuntu9.10

    1. 首先编译驱动程序。在驱动文件目录下执行:
      make
    2. 安装驱动,在开发板上执行:
      insmod ramblock.ko
    3.
memblock块设备格式化为dos磁盘类型,在开发板上执行:
      mkdosfs /dev/ramblock    (使用韦东山老师的文件系统fs_mini_mdev/sbin中将mkdosfs拷贝到/first_fs/usr/sbin目录下就可以用mkdosfs工具了)
    4. 挂载块设备到/tmp目录下,在开发板上执行:
      mount /dev/ramblock /tmp/
   
5. 接下来在/tmp目录下创建编辑一个文件,最终都会保存在/dev/ memblock块设备里面。
      cd /tmp
      vi lzh.txt (随便输入内容保存)
      cd /
      umount /tmp/ (不能在/tmp目录里进行卸载。此时重新挂接,刚才新建的lzh.txt依然存在)

      cat /dev/ramblock > /ramblock.bin(将/dev/ramblock磁盘内容存到bin文件里,也就是说刚才新建的lzh.txt会存入bin文件内)
    6.
使用” -o loop”将ramblock.bin文件模拟成磁盘挂接到/mntramblock.bin必须放在文件系统根目录里并在该目录执行该命令。注意:如果该磁盘没有格式化分区,或模拟磁盘的文件生成前没有格式化,会挂接不成功,提示“mount: you must specify the filesystem type”)如下:
      sudo mount -o loop ramblock.bin /mnt
   
mnt目录下就有ramblock.bin文件里的内容了,如下:

    7. 磁盘分区   
        7.1 重装驱动
        7.2 fdisk /dev/ramblock    (磁盘分区命令)
        7.3 依次执行以下命令:
            m (查看帮助)
            n (添加分区)
            p (主分区)
            1 (第一个主分区)
            1 (最外面的几个柱面作为第一个主分区开始,这里设为1)
            5 (最外面的几个柱面作为第一个主分区结束,这里设为5)

     重复7.2步骤再添加第二个主分区,完成后输入p可以查看分区,输入w将配置写入分区表(该磁盘里的第一个扇区))。
    8. 查看效果,执行如下:
      ls /dev/ramblock* -l

还可以分别进行磁盘格式化:
      mkdosfs /dev/ramblock1
      mkdosfs /dev/rambloc2

    分别挂接磁盘:
      mount /dev/ramblock1 /tmp/
      mount /dev/ramblock2 /tmp/

十六、Linux驱动之块设备驱动相关推荐

  1. linux中流设备_[快速上手Linux设备驱动]之块设备驱动流程详解一

    [快速上手Linux设备驱动]之块设备驱动流程详解一 walfred已经在[快速上手Linux设备驱动]之我看字符设备驱动一 文中详细讲解了linux下字符设备驱动,并紧接着用四篇文章描述了Linux ...

  2. Linux驱动开发|块设备驱动

    块设备驱动 块设备驱动是 Linux 三大驱动类型之一,块设备驱动比字符设备驱动复杂得多,不同类型的存储设备又对应不同的驱动子系统,下面介绍块设备驱动框架及使用 一.块设备介绍 块设备是针对存储设备的 ...

  3. linux内核的块设备驱动框架详解

    1.块设备和字符设备的差异 (1)块设备只能以块为单位接受输入和返回输出,而字符设备则以字节为单位.大多数设备是字符设备,因为它们不需要缓冲而且不以固定块大小进行操作; (2)块设备对于 I/O 请求 ...

  4. Linux驱动开发---块设备驱动

    块设备驱动(Linux kernel 4.9.x) 主要结构 gendisk结构体:表示一个独立的磁盘设备(或分区) 1.1 定义如下: struct gendisk {/* major, first ...

  5. Linux块设备驱动总结

    <Linux设备驱动程序>第十六章 块设备驱动程序读书笔记 简介 一个块设备驱动程序主要通过传输固定大小的随机数据来访问设备 Linux内核视块设备为与字符设备相异的基本设备类型 Linu ...

  6. Linux块设备驱动-MTD子系统

    Linux块设备驱动 块设备驱动 块设备驱动的引入 1. 简单字符驱动程序思想 2. 块设备驱动程序思想 块设备驱动框架 1. 层次框架 2. 分析ll_rw_block 块设备驱动程序编写 1.分配 ...

  7. STM32MP157驱动开发——Linux块设备驱动

    STM32MP157驱动开发--Linux块设备驱动 一.简介 二.驱动开发 1.使用请求队列的方式 2.测试① 3.不使用请求队列的方式 4.测试② 参考文章:[正点原子]I.MX6U嵌入式Linu ...

  8. 【linux驱动之字符设备驱动基础】

    linux驱动之字符设备驱动基础 文章目录 linux驱动之字符设备驱动基础 前言 一.开启驱动学习之路 二.驱动预备知识 三.什么是驱动? 3.1 驱动概念 3.2 linux 体系架构 3.3 模 ...

  9. 块设备驱动、bio理解

    别人写过的内容,我就不写了.贴一下大佬的博客,写的非常好: 块设备驱动实战基础篇一 (170行代码构建一个逻辑块设备驱动) 块设备驱动实战基础篇二 (继续完善170行过滤驱动代码至200行) 块设备驱 ...

最新文章

  1. 机器学习(Part I)机器学习的种类
  2. WPF DispatcherTimer(定时器应用) 无人触摸60s自动关闭窗口
  3. Linux内存管理 (2)页表的映射过程
  4. css样式图片、渐变、相关小知识
  5. Java这些多线程基础知识你会吗?
  6. MediaCodec的使用和若干问题处理
  7. 算法 c语言_C语言中10个经典的算法,学会它,利用它
  8. Android菜鸟的成长笔记(16)——Service简介
  9. linux informix数据库下载,informix数据库基础下载_informix数据库基础官方下载-太平洋下载中心...
  10. html5弹性盒子模型,推荐10款弹性盒子源码(收藏)
  11. [置顶] iOs LightBlue与cc2540 BLE开发板之间的通信实验 [原创, 多图]
  12. 电脑录屏软件哪个最好用?都是高效高清!
  13. 2023年北京师范大学应用统计考研上岸前辈备考经验指导
  14. C语言程序设计作业04
  15. 内网DNS报错:** server can‘t find ns1.aaa.com: SERVFAIL
  16. 大疆哪吒飞控naza-m等无法解锁的问题遥控无法启动电机不转解决疑难杂症。
  17. SAP CDS 开发和Fiori App生成学习笔记
  18. Prometheus 之 Alertmanager告警抑制与静默
  19. 使用ALT+数字小键盘在文本文件中输入特殊字符
  20. python性能分析工具

热门文章

  1. jquery管理数据
  2. 可定制代理软件——privoxy
  3. NIO学习笔记——缓冲区(Buffer)详解
  4. 蓝桥杯嵌入式国赛 ---- TRDO TRAO 光敏电阻
  5. 一个运维工程师必须要知道的(工作职责与应用场景)干货整理
  6. jQuery-入口函数
  7. 我的2020推免之路:清叉、TBSI、贵系、中山、国防科大、自动化所
  8. Redis 根据IPv6地址查询全球国家、省、市位置信息方案
  9. OpenAI的创立和发展历程
  10. CSM(Certified Scrum Master) 敏捷认证是什么?