F2FS的删除文件操作流程
F2FS的删除文件操作流程
一般文件组织结构
F2FS中的block被分成data block 和 node block两种,一般情况下,data block 记录了文件的具体数据,node block记录文件的索引方式。
node block包括f2fs_inode
,direct_node
和indirect_node
三种。在源码中三者以联合体的方式保存在struct f2fs_node
中:
struct f2fs_node {/* can be one of three types: inode, direct, and indirect types */union {struct f2fs_inode i;struct direct_node dn;struct indirect_node in;};struct node_footer footer;
} __packed;
direct_node
用一个数组记录最多1018个data block的物理地址。indirect_node
用一个数据记录最多1018个node的nid。
struct direct_node {__le32 addr[DEF_ADDRS_PER_BLOCK]; /* array of data block address */
} __packed;struct indirect_node {__le32 nid[NIDS_PER_BLOCK]; /* array of data block address */
} __packed;
每个文件都有有且只有一个f2fs_inode
,记录了文件的基本索引方式。f2fs_inode
的核心成员是两个数组__le32 i_addr[DEF_ADDRS_PER_INODE]
和 __le32 i_nid[DEF_NIDS_PER_INODE]
,一般情况下分别记录最多923个data block的物理地址和5个node block 的nid(nid是每个node的唯一标识),前者用于直接寻址,后置用于间接寻址。
f2fs_inode
中的数组i_nid[5]
中前两个nid记录的是direct_node的nid,中间两个nid记录的是indirect_node的nid,最后一个记录的也是indirect_node的nid,但是用于这个indirect_node记录的也是indirect_node的nid,也就是用于三级索引。
struct f2fs_inode {...union {...__le32 i_addr[DEF_ADDRS_PER_INODE]; /* Pointers to data blocks */};__le32 i_nid[DEF_NIDS_PER_INODE]; /* direct(2), indirect(2), double_indirect(1) node id */
} __packed;
F2FS中一个block默认是4KB,所以文件最大为:
923 * 4KB + 2 * 1018 * 4KB + 2 * 1018 * 1018 * 4KB +1018 * 1018 * 1018 * 4KB ≈ 3.938TB
内联文件组织
当文件很小时,例如只有1Byte数据,按照上面的组织方式需要用一个data block存储数据和一个node block存储inode,即使用了2*4KB的空间存储1Byte数据。
为节省空间,F2FS采用内联的方式存储小文件,即当文件很小时,直接将数据存储在f2fs_inode的__le32 i_addr[DEF_ADDRS_PER_INODE]
部分。保留一个数据成员留作它用,所以这里最大可以存储4B*922=3688B。
寻址方式
当访问文件某个位置的数据时,需要从f2fs_inode开始层层索引,直到获取这个位置对应block的物理地址,这个过程在get_dnode_of_data()
中完成。
get_dnode_of_data()
接收一个block 偏移形参 index,也就是待当问位置位于文件中第几个block,然后调用get_node_path()
计算block偏移对应的索引路径,然后从f2fs_inode
出发,直接寻址或者最多三次间接寻址找到对应的data block地址、记录这个block地址的node。
get_node_path()
计算得到的索引路径由两个数组构成,分别是int offset[4]
和unsigned int noffset[4]
。offset记录路径上每个节点在上一个节点中的偏移量,noffset记录路径上一共使用了多少个direct_node
或 indirect_node
。
具体情况如下图所示:
truncate流程
文件系统中的删除操作由truncate实现,F2FS中的truncate流程基本如下,可以从truncate_blocks()
开始分析。
各个函数的功能如下:
// 将inode对应的文件中from字节之后的数据全部删除, 是f2fs正式开始truncate的位置
int truncate_blocks(struct inode *inode, u64 from, bool lock)// 如果文件是内联的, 那么调用这个函数删除执行内联的truncate
void truncate_inline_inode(struct inode *inode, struct page *ipage, u64 from)// 截断direct node中,后count个addr对应的block
int truncate_data_blocks_range(struct dnode_of_data *dn, int count);// 调用上面的truncate_data_blocks_range() 将整个direct node 包含的block截断
void truncate_data_blocks(struct dnode_of_data *dn);// 先截断direct node,再截断indirect node 和 dindirect node
int truncate_inode_blocks(struct inode *inode, pgoff_t from);// 截断direct node
static int truncate_partial_nodes(struct dnode_of_data *dn, struct f2fs_inode *ri, int *offset, int depth);// 截断indirect node 和 dindirect node
static int truncate_nodes(struct dnode_of_data *dn, unsigned int nofs, int ofs, int depth);// 截断这个node本身
static void truncate_node(struct dnode_of_data *dn);// 先调用truncate_data_blocks() 截断这个node 里所有的block, 再调用truncate_node() 截断这个node自己
static int truncate_dnode(struct dnode_of_data *dn);// 截断inode本身
int remove_inode_page(struct inode *inode);
具体实现:
/** 将文件中from字节之后的数据都删除*/
int truncate_blocks(struct inode *inode, u64 from, bool lock)
{struct f2fs_sb_info *sbi = F2FS_I_SB(inode);unsigned int blocksize = inode->i_sb->s_blocksize;struct dnode_of_data dn;pgoff_t free_from;int count = 0, err = 0;struct page *ipage;bool truncate_page = false;trace_f2fs_truncate_blocks_enter(inode, from);// from表示的是截断位置在文件中的字节偏移// 这里计算截断位置的下一个block,也就是第一个要删除的block, free_from表示block偏移free_from = (pgoff_t)F2FS_BYTES_TO_BLK(from + blocksize - 1);// 判断free_from 这个block有没有超出范围if (free_from >= sbi->max_file_blocks)goto free_partial;if (lock)f2fs_lock_op(sbi);// 获得inode对应的pageipage = get_node_page(sbi, inode->i_ino);if (IS_ERR(ipage)) {err = PTR_ERR(ipage);goto out;}// 如果有内敛数据,就直接调用truncate_inline_inode()来截断内联数据if (f2fs_has_inline_data(inode)) {truncate_inline_inode(inode, ipage, from);f2fs_put_page(ipage, 1);truncate_page = true;goto out;}// 获取 free_from 的block在文件中的dnode_of_data信息// 包括这个block的对应的 node 的 nid、对应的node page、数据偏移、物理地址等set_new_dnode(&dn, inode, ipage, NULL, 0);err = get_dnode_of_data(&dn, free_from, LOOKUP_NODE_RA);if (err) {if (err == -ENOENT)goto free_next;goto out;}// 计算free_from对应的node中大于free_from的block数量count = ADDRS_PER_PAGE(dn.node_page, inode);count -= dn.ofs_in_node;f2fs_bug_on(sbi, count < 0);// 如果这是个inode或者数据偏移不是0// 调用 truncate_data_blocks_range 把 node 中大于 free_from 的 block 全部截断// 如果是inode, 那么count = 923 - dn.ofs_in_node, 否则 count = 1018 - dn.ofs_in_node// 也就是说truncate_data_blocks_range() 只截断dn中记录的node的addr 部分,而不会涉及nid 部分// nid部分由下面的truncate_inode_blocks()负责if (dn.ofs_in_node || IS_INODE(dn.node_page)) {truncate_data_blocks_range(&dn, count);free_from += count;}/** 以上的操作是截断 free_from 对应的 node 中大于 free_from 的 block* 以block为单位,也就是 node 中的零头* 下面以 node 为单位做截断*/f2fs_put_dnode(&dn);
free_next:// 截断 free_from 之后的所有 node 的 blockerr = truncate_inode_blocks(inode, free_from);
out:if (lock)f2fs_unlock_op(sbi);
free_partial:/* lastly zero out the first data page */// 对字节偏移 from 所在 block 进行块内截断if (!err)err = truncate_partial_data_page(inode, from, truncate_page);trace_f2fs_truncate_blocks_exit(inode, err);return err;
}/** 将ipage中要删除的部分置0*/
void truncate_inline_inode(struct inode *inode, struct page *ipage, u64 from)
{void *addr;// 内联文件有个最大文件大小,先检查字节偏移有没有超出这个范围if (from >= MAX_INLINE_DATA(inode))return;// 计算page中内联数据起始部分在内存中的地址,所以addr是指page里面的某一地址addr = inline_data_addr(inode, ipage);f2fs_wait_on_page_writeback(ipage, NODE, true);// 将字节偏移from之后的部分全部置零, 将page置脏,并且置PageUptodatememset(addr + from, 0, MAX_INLINE_DATA(inode) - from);set_page_dirty(ipage);if (from == 0)clear_inode_flag(inode, FI_DATA_EXIST);
}/** All the block addresses of data and nodes should be nullified.*/
int truncate_inode_blocks(struct inode *inode, pgoff_t from)
{struct f2fs_sb_info *sbi = F2FS_I_SB(inode);int err = 0, cont = 1;int level, offset[4], noffset[4];unsigned int nofs = 0;struct f2fs_inode *ri;struct dnode_of_data dn;struct page *page;trace_f2fs_truncate_inode_blocks_enter(inode, from);// 先获取block 偏移 from 对应的node的路径,即offset[] 和 nooffset[]level = get_node_path(inode, from, offset, noffset);if (level < 0)return level;page = get_node_page(sbi, inode->i_ino);if (IS_ERR(page)) {trace_f2fs_truncate_inode_blocks_exit(inode, PTR_ERR(page));return PTR_ERR(page);}set_new_dnode(&dn, inode, page, NULL, 0);unlock_page(page);ri = F2FS_INODE(page);// truncate_blocks() 中已经对level = 0时做了处理,所以这里不会命中level = 0switch (level) {case 0:case 1:nofs = noffset[1];break;case 2:nofs = noffset[1];if (!offset[level - 1])goto skip_partial;// 截断direct nodeerr = truncate_partial_nodes(&dn, ri, offset, level);if (err < 0 && err != -ENOENT)goto fail;nofs += 1 + NIDS_PER_BLOCK;break;case 3:nofs = 5 + 2 * NIDS_PER_BLOCK;if (!offset[level - 1])goto skip_partial;// 截断direct nodeerr = truncate_partial_nodes(&dn, ri, offset, level);if (err < 0 && err != -ENOENT)goto fail;break;default:BUG();}
skip_partial:while (cont) {dn.nid = le32_to_cpu(ri->i_nid[offset[0] - NODE_DIR1_BLOCK]);switch (offset[0]) {case NODE_DIR1_BLOCK:case NODE_DIR2_BLOCK:err = truncate_dnode(&dn);break;case NODE_IND1_BLOCK:case NODE_IND2_BLOCK:// 截断 indirect node 和 dindirect nodeerr = truncate_nodes(&dn, nofs, offset[1], 2);break;case NODE_DIND_BLOCK:// 截断 indirect node 和 dindirect nodeerr = truncate_nodes(&dn, nofs, offset[1], 3);cont = 0;break;default:BUG();}if (err < 0 && err != -ENOENT)goto fail;if (offset[1] == 0 &&ri->i_nid[offset[0] - NODE_DIR1_BLOCK]) {lock_page(page);BUG_ON(page->mapping != NODE_MAPPING(sbi));f2fs_wait_on_page_writeback(page, NODE, true);ri->i_nid[offset[0] - NODE_DIR1_BLOCK] = 0;set_page_dirty(page);unlock_page(page);}offset[1] = 0;offset[0]++;nofs += err;}
fail:f2fs_put_page(page, 0);trace_f2fs_truncate_inode_blocks_exit(inode, err);return err > 0 ? 0 : err;
}/** 截断 dn 中记录的 node 的后 count 个block*/
int truncate_data_blocks_range(struct dnode_of_data *dn, int count)
{struct f2fs_sb_info *sbi = F2FS_I_SB(dn->inode);struct f2fs_node *raw_node;int nr_free = 0, ofs = dn->ofs_in_node, len = count;__le32 *addr;int base = 0;if (IS_INODE(dn->node_page) && f2fs_has_extra_attr(dn->inode))base = get_extra_isize(dn->inode);raw_node = F2FS_NODE(dn->node_page);addr = blkaddr_in_node(raw_node) + base + ofs;for (; count > 0; count--, addr++, dn->ofs_in_node++) {block_t blkaddr = le32_to_cpu(*addr);if (blkaddr == NULL_ADDR)continue;// 将修改的blkaddr 更新到 这个 dn 对应的 node 中去dn->data_blkaddr = NULL_ADDR;set_data_blkaddr(dn);invalidate_blocks(sbi, blkaddr);if (dn->ofs_in_node == 0 && IS_INODE(dn->node_page))clear_inode_flag(dn->inode, FI_FIRST_BLOCK_WRITTEN);nr_free++;}if (nr_free) {pgoff_t fofs;/** once we invalidate valid blkaddr in range [ofs, ofs + count],* we will invalidate all blkaddr in the whole range.*/fofs = start_bidx_of_node(ofs_of_node(dn->node_page),dn->inode) + ofs;f2fs_update_extent_cache_range(dn, fofs, 0, len);dec_valid_block_count(sbi, dn->inode, nr_free);}dn->ofs_in_node = ofs;f2fs_update_time(sbi, REQ_TIME);trace_f2fs_truncate_data_blocks_range(dn->inode, dn->nid,dn->ofs_in_node, nr_free);return nr_free;
}/** 截断 inode 中 from 字节之后的block*/
static int truncate_partial_data_page(struct inode *inode, u64 from,bool cache_only)
{unsigned offset = from & (PAGE_SIZE - 1); // from 字节位置对应在block中的偏移pgoff_t index = from >> PAGE_SHIFT; // from 字节位置对应的block地址struct address_space *mapping = inode->i_mapping;struct page *page;if (!offset && !cache_only)return 0;// 如果缓存了from所在的block,那么找到这个块对应的pageif (cache_only) {page = find_lock_page(mapping, index);if (page && PageUptodate(page))goto truncate_out;f2fs_put_page(page, 1);return 0;}page = get_lock_data_page(inode, index, true);if (IS_ERR(page))return PTR_ERR(page) == -ENOENT ? 0 : PTR_ERR(page);
truncate_out:// 把from所在block中,位于from后面的内容全部置零f2fs_wait_on_page_writeback(page, DATA, true);zero_user(page, offset, PAGE_SIZE - offset);/* An encrypted inode should have a key and truncate the last page. */f2fs_bug_on(F2FS_I_SB(inode), cache_only && f2fs_encrypted_inode(inode));if (!cache_only)set_page_dirty(page);f2fs_put_page(page, 1);return 0;
}/** 截断indirect node 和 dindirect node * depth = 2 或 3*/
static int truncate_nodes(struct dnode_of_data *dn, unsigned int nofs,int ofs, int depth)
{struct dnode_of_data rdn = *dn;struct page *page;struct f2fs_node *rn;nid_t child_nid;unsigned int child_nofs;int freed = 0;int i, ret;if (dn->nid == 0)return NIDS_PER_BLOCK + 1;trace_f2fs_truncate_nodes_enter(dn->inode, dn->nid, dn->data_blkaddr);page = get_node_page(F2FS_I_SB(dn->inode), dn->nid);if (IS_ERR(page)) {trace_f2fs_truncate_nodes_exit(dn->inode, PTR_ERR(page));return PTR_ERR(page);}ra_node_pages(page, ofs, NIDS_PER_BLOCK);rn = F2FS_NODE(page);if (depth < 3) {for (i = ofs; i < NIDS_PER_BLOCK; i++, freed++) {child_nid = le32_to_cpu(rn->in.nid[i]);if (child_nid == 0)continue;rdn.nid = child_nid;ret = truncate_dnode(&rdn);if (ret < 0)goto out_err;if (set_nid(page, i, 0, false))dn->node_changed = true;}} else {child_nofs = nofs + ofs * (NIDS_PER_BLOCK + 1) + 1;for (i = ofs; i < NIDS_PER_BLOCK; i++) {child_nid = le32_to_cpu(rn->in.nid[i]);if (child_nid == 0) {child_nofs += NIDS_PER_BLOCK + 1;continue;}rdn.nid = child_nid;ret = truncate_nodes(&rdn, child_nofs, 0, depth - 1);if (ret == (NIDS_PER_BLOCK + 1)) {if (set_nid(page, i, 0, false))dn->node_changed = true;child_nofs += ret;} else if (ret < 0 && ret != -ENOENT) {goto out_err;}}freed = child_nofs;}if (!ofs) {/* remove current indirect node */dn->node_page = page;truncate_node(dn);freed++;} else {f2fs_put_page(page, 1);}trace_f2fs_truncate_nodes_exit(dn->inode, freed);return freed;
out_err:f2fs_put_page(page, 1);trace_f2fs_truncate_nodes_exit(dn->inode, ret);return ret;
}/** 这个函数用来完成截断与indirect node的对齐* 也就是这个函数调用之后,后面的删除可以以indirect node为单位进行删除了* depth=2或3 截断direct node,从而剩下 indirect node*/
static int truncate_partial_nodes(struct dnode_of_data *dn, struct f2fs_inode *ri, int *offset, int depth)
{struct page *pages[2]; // pages数组存储的是指向page的指针nid_t nid[3];nid_t child_nid;int err = 0;int i;int idx = depth - 2;nid[0] = le32_to_cpu(ri->i_nid[offset[0] - NODE_DIR1_BLOCK]);if (!nid[0])return 0;/* get indirect nodes in the path */// 通过这个循环将node路径找出来,也就是路径上每个node的page 和 nidfor (i = 0; i < idx + 1; i++) {/* reference count'll be increased */pages[i] = get_node_page(F2FS_I_SB(dn->inode), nid[i]);if (IS_ERR(pages[i])) {err = PTR_ERR(pages[i]);idx = i - 1;goto fail;}nid[i + 1] = get_nid(pages[i], offset[i + 1], false);}ra_node_pages(pages[idx], offset[idx + 1], NIDS_PER_BLOCK);/* free direct nodes linked to a partial indirect node */// offset[idx + 1] = offset[depth - 1] 也就是最后一个indirect node的nid// depth = 2 或 depth =3 // 所以 idx = 0 或 1for (i = offset[idx + 1]; i < NIDS_PER_BLOCK; i++) {child_nid = get_nid(pages[idx], i, false);if (!child_nid)continue;dn->nid = child_nid;err = truncate_dnode(dn);if (err < 0)goto fail;if (set_nid(pages[idx], i, 0, false))dn->node_changed = true;}if (offset[idx + 1] == 0) {dn->node_page = pages[idx];dn->nid = nid[idx];truncate_node(dn);} else {f2fs_put_page(pages[idx], 1);}offset[idx]++;offset[idx + 1] = 0;idx--;
fail:for (i = idx; i >= 0; i--)f2fs_put_page(pages[i], 1);trace_f2fs_truncate_partial_nodes(dn->inode, nid, depth, err);return err;
}/** 截断dn记录的nid对应的node里存储的所有block 和 这个node本身*/
static int truncate_dnode(struct dnode_of_data *dn)
{struct page *page;if (dn->nid == 0)return 1;/* get direct node */page = get_node_page(F2FS_I_SB(dn->inode), dn->nid);if (IS_ERR(page) && PTR_ERR(page) == -ENOENT)return 1;else if (IS_ERR(page))return PTR_ERR(page);/* Make dnode_of_data for parameter */dn->node_page = page;dn->ofs_in_node = 0;truncate_data_blocks(dn);truncate_node(dn);return 1;
}static void truncate_node(struct dnode_of_data *dn)
{struct f2fs_sb_info *sbi = F2FS_I_SB(dn->inode);struct node_info ni;// 先获取node_infoget_node_info(sbi, dn->nid, &ni);f2fs_bug_on(sbi, ni.blk_addr == NULL_ADDR);/* Deallocate node address */// 修改文件系统元数据sit,将对应的node置为无效invalidate_blocks(sbi, ni.blk_addr);dec_valid_node_count(sbi, dn->inode, dn->nid == dn->inode->i_ino);set_node_addr(sbi, &ni, NULL_ADDR, false);// 由于文件inode的删除首先会将inode加入到orphan inode中/ 所以这里如果是inode,那就从orphan inode中删除这个inodeif (dn->nid == dn->inode->i_ino) {remove_orphan_inode(sbi, dn->nid);dec_valid_inode_count(sbi);f2fs_inode_synced(dn->inode); // 解除这个inode在内存中的一些链表关系}clear_node_page_dirty(dn->node_page);set_sbi_flag(sbi, SBI_IS_DIRTY);f2fs_put_page(dn->node_page, 1);// 删除页缓存invalidate_mapping_pages(NODE_MAPPING(sbi),dn->node_page->index, dn->node_page->index);dn->node_page = NULL;trace_f2fs_truncate_node(dn->inode, dn->nid, ni.blk_addr);
}
F2FS的删除文件操作流程相关推荐
- linux下修复win8引导文件,微软为推广win8系统linux删除文件的修复技巧
想必大家都遇到过win8系统linux删除文件的问题吧,大多数朋友还不知道怎么处理虽然解决方法很简单,但是大部分用户不清楚win8系统linux删除文件到底要如何搞定.最近有不少用户到本站咨询win8 ...
- F2FS源码分析-2.2 [F2FS 读写部分] F2FS的一般文件写流程分析
F2FS源码分析系列文章 主目录 一.文件系统布局以及元数据结构 二.文件数据的存储以及读写 F2FS文件数据组织方式 一般文件写流程 一般文件读流程 目录文件读流程(未完成) 目录文件写流程(未完成 ...
- python删除文件某行_python 文件操作删除某行的实例
python 文件操作删除某行的实例 使用continue跳过本次写循环就可以了 #文本内容 Yesterday when I was young 昨日当我年少轻狂 The tasting of li ...
- 用Python在Windows或Linux下批量删除文件夹中指定的文件
情况说明:当在一个文件夹下面有好几十个或几百个文件需要删除,此时一一去挑选费时费力,特别是在Linux下面.因此,需要批量删除文件. 对训练样本(图像)和测试样本(图像)进行评估时候,需要查看是数据本 ...
- java file 操作之创建、删除文件及文件夹
本文章向大家讲解java文件的基本操作,包括java创建文件和文件夹.java删除文件.java获取指定目录的全部文件.java判断指定路径是否为目录以及java搜索指定目录的全部内容等.请看下面实例 ...
- 一个java删除文件夹的小方法
java删除文件夹都是从里向外删除,使用递归的方法. public class IO_FILEdemo09 {public static void main(String[] args) {// TO ...
- 编程乐趣:C#彻底删除文件
经常用360的文件粉碎,删除隐私文件貌似还不错的.不过C#也可以实现彻底删除文件.试了下用360文件恢复恢复不了源文件了. 代码如下: public class AbsoluteFile{public ...
- 10 款可以找回删除文件的好软件
电脑突然死机或者断电,硬盘数据丢失?U盘重要文件不小心删掉了? 电脑中毒,文件丢失或无法读取? 系统突然崩溃,重要文件丢失?使用计算机最怕的就是象以上这些突如其来的灾难性故障导致重要数据的丢失,误操作 ...
- [C#]使用CMD命令删除文件函数
#region 使用CMD命令删除文件函数/// <summary>/// 使用CMD命令删除文件函数/// </summary> /// <param name=&qu ...
最新文章
- oracle参数文件initorcl位置,ORACLE参数文件
- sqlserver 无法远程连接到服务器,SQLServer2019无法连接远程服务器
- 下载python的步骤ios_下载及安装Python详细步骤
- Web网页布局的主要方式
- 方立勋_30天掌握JavaWeb_MySQL和表约束
- IOS开发基础之UI的喜马拉雅的项目-10
- 理解 Delphi 的类(十) - 深入方法[17] - 提前声明
- zip直链生成网站_安装网站程序
- python读取多个文件夹下所有txt_Python实现合并同一个文件夹下所有txt文件的方法示例...
- “天才少年”刚毕业就拿到华为200万年薪:确认过眼神,是我羡慕不来的人
- 【java】RMI教程:入门与编译方法 远程
- 阿里云CDN直播架构与双11晚会直播实战
- java----数据结构与算法----JavaAPI:java.util.Collection接口
- 数字抽奖小程序_两款火爆的抽奖小程序,最高抽2000元现金红包 亲测提现8.59元秒到...
- 模型笔记1---3d max 导入obj模型设置
- App登录方式和测试重点总结
- 八千里路云和月,蚂蚁金服面出血,offer已拿,仰天长啸,壮怀激烈!
- 读书笔记-情感化设计
- 红孩儿编辑器的模块设计5
- 目前主流服务器厂商有哪些?都有什么型号
热门文章
- 2021/11/16 定时器Timer和cron表达式
- 【安防百科】视频监控中常用的分辨率
- 关于keystore 证书转*.x509.pem 和*.pk8
- 教育网站通用登录页面html前端源码
- Shell脚本实现 ping功能
- 调用QQ/微信/新浪微博 实现登录
- latex数学符号(持续更新)
- 没装oracle plsql,64位WIN7系统,未装ORACLE,我用PLSQLDEV 远程连接数据库时报错ORA-12560:TNS:protocol adapter error...
- tomcat原理简要分析,java
- 能画数据库E-R图的软件有哪些