Linux 内核:设备树(2)dtb转换成device_node

背景

前面我们了解到dtb的内存分布以后(dtb格式),接下来就来看看内核是如何把设备树解析成所需的device_node

原文(有删改):https://www.cnblogs.com/downey-blog/p/10485596.html

基于arm平台,Linux 4.14

设备树的执行入口setup_arch

linux最底层的初始化部分在HEAD.s中,这是汇编代码,我们暂且不作过多讨论。

在head.s完成部分初始化之后,就开始调用C语言函数,而被调用的第一个C语言函数就是start_kernel

asmlinkage __visible void __init start_kernel(void)
{
//...
setup_arch(&command_line);
//...
}

而对于设备树的处理,基本上就在setup_arch()这个函数中。

可以看到,在start_kernel()中调用了setup_arch(&command_line);

void __init setup_arch(char **cmdline_p)
{
const struct machine_desc *mdesc; // 根据传入的设备树dtb的首地址完成一些初始化操作
mdesc = setup_machine_fdt(__atags_pointer); // ... // 保证设备树dtb本身存在于内存中而不被覆盖
arm_memblock_init(mdesc); // ...
// 对设备树具体的解析
unflatten_device_tree();
// ...
}

这三个被调用的函数就是主要的设备树处理函数:

  • setup_machine_fdt():根据传入的设备树dtb的首地址完成一些初始化操作。
  • arm_memblock_init():主要是内存相关函数,为设备树保留相应的内存空间,保证设备树dtb本身存在于内存中而不被覆盖。用户可以在设备树中设置保留内存,这一部分同时作了保留指定内存的工作。
  • unflatten_device_tree():对设备树具体的解析,事实上在这个函数中所做的工作就是将设备树各节点转换成相应的struct device_node结构体。

下面我们再来通过代码跟踪仔细分析。

setup_machine_fdt

    const struct machine_desc *mdesc;

    // 根据传入的设备树dtb的首地址完成一些初始化操作
mdesc = setup_machine_fdt(__atags_pointer);

__atags_pointer这个全局变量存储的就是r2的寄存器值,是设备树在内存中的起始地址,将设备树起始地址传递给setup_machine_fdt,对设备树进行解析。

const struct machine_desc * __init setup_machine_fdt(unsigned int dt_phys)
{
const struct machine_desc *mdesc, *mdesc_best = NULL;
// 内存地址检查
if (!dt_phys || !early_init_dt_verify(phys_to_virt(dt_phys)))
return NULL; // 读取 compatible 属性
mdesc = of_flat_dt_match_machine(mdesc_best, arch_get_next_mach); // 扫描各个子节点
early_init_dt_scan_nodes();
// ...
}

setup_machine_fdt主要是获取了一些设备树提供的总览信息。

内存地址检查

先将设备树在内存中的物理地址转换为虚拟地址

然后再检查该地址上是否有设备树的魔数(magic),魔数就是一串用于识别的字节码:

  • 如果没有或者魔数不匹配,表明该地址没有设备树文件,函数返回失败
  • 否则验证成功,将设备树地址赋值给全局变量initial_boot_params

读取compatible属性

逐一读取设备树根目录下的compatible属性。

/**
* of_flat_dt_match_machine - Iterate match tables to find matching machine.
*
* @default_match: A machine specific ptr to return in case of no match.
* @get_next_compat: callback function to return next compatible match table.
*
* Iterate through machine match tables to find the best match for the machine
* compatible string in the FDT.
*/
const void * __init of_flat_dt_match_machine(const void *default_match,
const void * (*get_next_compat)(const char * const**))
{
const void *data = NULL;
const void *best_data = default_match;
const char *const *compat;
unsigned long dt_root;
unsigned int best_score = ~1, score = 0; // 获取首地址
dt_root = of_get_flat_dt_root();
// 遍历
while ((data = get_next_compat(&compat))) {
// 将compatible中的属性一一与内核中支持的硬件单板相对比,
// 匹配成功后返回相应的machine_desc结构体指针。
score = of_flat_dt_match(dt_root, compat);
if (score > 0 && score < best_score) {
best_data = data;
best_score = score;
}
} // ... pr_info("Machine model: %s\n", of_flat_dt_get_machine_name()); return best_data;
}

machine_desc结构体中描述了单板相关的一些硬件信息,这里不过多描述。

主要的的行为就是根据这个compatible属性选取相应的硬件单板描述信息;一般compatible属性名就是"厂商,芯片型号"。

扫描各子节点

第三部分就是扫描设备树中的各节点,主要分析这部分代码。

void __init early_init_dt_scan_nodes(void)
{
of_scan_flat_dt(early_init_dt_scan_chosen, boot_command_line);
of_scan_flat_dt(early_init_dt_scan_root, NULL);
of_scan_flat_dt(early_init_dt_scan_memory, NULL);
}

出人意料的是,这个函数中只有一个函数的三个调用,每次调用时,参数不一样。

of_scan_flat_dt

首先of_scan_flat_dt()这个函数接收两个参数,一个是函数指针it,一个为相当于函数it执行时的参数。

/**
* of_scan_flat_dt - scan flattened tree blob and call callback on each.
* @it: callback function
* @data: context data pointer
*
* This function is used to scan the flattened device-tree, it is
* used to extract the memory information at boot before we can
* unflatten the tree
*/
int __init of_scan_flat_dt(int (*it)(unsigned long node,
const char *uname, int depth,
void *data),
void *data)
{
unsigned long p = ((unsigned long)initial_boot_params) +
be32_to_cpu(initial_boot_params->off_dt_struct);
int rc = 0;
int depth = -1; do {
u32 tag = be32_to_cpup((__be32 *)p);
const char *pathp; p += 4;
if (tag == OF_DT_END_NODE) {
depth--;
continue;
}
if (tag == OF_DT_NOP)
continue;
if (tag == OF_DT_END)
break;
if (tag == OF_DT_PROP) {
u32 sz = be32_to_cpup((__be32 *)p);
p += 8;
if (be32_to_cpu(initial_boot_params->version) < 0x10)
p = ALIGN(p, sz >= 8 ? 8 : 4);
p += sz;
p = ALIGN(p, 4);
continue;
}
if (tag != OF_DT_BEGIN_NODE) {
pr_err("Invalid tag %x in flat device tree!\n", tag);
return -EINVAL;
}
depth++;
pathp = (char *)p;
p = ALIGN(p + strlen(pathp) + 1, 4);
if (*pathp == '/')
pathp = kbasename(pathp);
rc = it(p, pathp, depth, data);
if (rc != 0)
break;
} while (1); return rc;
}

结论:of_scan_flat_dt()函数的作用就是扫描设备树中的节点,然后对各节点分别调用传入的回调函数。

那么重点关注函数指针,在上述代码中,传入的参数分别为

  • early_init_dt_scan_chosen
  • ``early_init_dt_scan_root`
  • early_init_dt_scan_memory

从名称可以猜测,这三个函数分别是处理chosen节点、root节点中除子节点外的属性信息、memory节点。

early_init_dt_scan_chosen

of_scan_flat_dt(early_init_dt_scan_chosen, boot_command_line);

boot_command_lineboot_command_line是一个静态数组,存放着启动参数,

int __init early_init_dt_scan_chosen(unsigned long node, const char *uname,int depth, void *data){
// ...
p = of_get_flat_dt_prop(node, "bootargs", &l);
if (p != NULL && l > 0)
strlcpy(data, p, min((int)l, COMMAND_LINE_SIZE));
// ...
} int __init early_init_dt_scan_chosen(unsigned long node, const char *uname,
int depth, void *data)
{
unsigned long l;
char *p; pr_debug("search \"chosen\", depth: %d, uname: %s\n", depth, uname); if (depth != 1 || !data ||
(strcmp(uname, "chosen") != 0 && strcmp(uname, "chosen@0") != 0))
return 0; early_init_dt_check_for_initrd(node); /* Retrieve command line */
// 找到设备树中的的chosen节点中的bootargs,并作为cmd_line
p = of_get_flat_dt_prop(node, "bootargs", &l);
if (p != NULL && l > 0)
strlcpy(data, p, min((int)l, COMMAND_LINE_SIZE)); // ... pr_debug("Command line is: %s\n", (char*)data); /* break now */
return 1;
}

经过代码分析,early_init_dt_scan_chosen的作用是获取从chosen节点中获取bootargs,然后将bootargs放入boot_command_line中,作为启动参数。

而非字面意思的处理整个chosen

以我之前调过的zynq平台为例:

/ {
model = "ZynqMP ZCU104 RevA";
compatible = "xlnx,zynqmp-zcu104-revA", "xlnx,zynqmp-zcu104", "xlnx,zynqmp"; aliases {
ethernet0 = &gem3;
gpio0 = &gpio;
i2c0 = &i2c1;
mmc0 = &sdhci1;
rtc0 = &rtc;
serial0 = &uart0;
serial1 = &uart1;
serial2 = &dcc;
spi0 = &qspi;
usb0 = &usb0;
}; chosen {
bootargs = "earlycon";
stdout-path = "serial0:115200n8";
}; memory@0 {
device_type = "memory";
reg = <0x0 0x0 0x0 0x80000000>;
};
};

在支持设备树的嵌入式系统中,实际上:

  • uboot基本上可以不通过显式的bootargs=xxx来传递给内核,而是在env拿出,并存放进设备树中的chosen节点中
  • Linux也开始在设备树中的chosen节点中获取出来,

这样子就可以做到针对uboot与Linux在bootargs传递上的统一。

early_init_dt_scan_root

int __init early_init_dt_scan_root(unsigned long node, const char *uname,int depth, void *data)
{
dt_root_size_cells = OF_ROOT_NODE_SIZE_CELLS_DEFAULT;
dt_root_addr_cells = OF_ROOT_NODE_ADDR_CELLS_DEFAULT; prop = of_get_flat_dt_prop(node, "#size-cells", NULL);
if (prop)
dt_root_size_cells = be32_to_cpup(prop);
prop = of_get_flat_dt_prop(node, "#address-cells", NULL);
if (prop)
dt_root_addr_cells = be32_to_cpup(prop);
// ...
}

通过进一步代码分析,early_init_dt_scan_root为了将root节点中的#size-cells#address-cells属性提取出来,并非获取root节点中所有的属性,放到全局变量dt_root_size_cellsdt_root_addr_cells中。

size-cells和address-cells表示对一个属性(通常是reg属性)的地址需要多少个四字节描述,而地址的长度需要多少个四字节描述,数据长度基本单位为4。

// 表示数据大小为一个4字节描述,32位
#size-cells = 1 // 表示地址由一个四字节描述
#address-cells = 1 // 而reg属性由四个四字节组成,所以存在两组地址描述,
// 第一组是起始地址为0x12345678,长度为0x100,
// 第二组起始地址为0x22,长度为0x4,
// 因为在<>中,所有数据都是默认为32位。
reg = <0x12345678 0x100 0x22 0x4>

early_init_dt_scan_memory

int __init early_init_dt_scan_memory(unsigned long node, const char *uname,int depth, void *data){
// ...
if (!IS_ENABLED(CONFIG_PPC32) || depth != 1 || strcmp(uname, "memory@0") != 0)
return 0;
reg = of_get_flat_dt_prop(node, "reg", &l);
endp = reg + (l / sizeof(__be32)); while ((endp - reg) >= (dt_root_addr_cells + dt_root_size_cells)) {
base = dt_mem_next_cell(dt_root_addr_cells, &reg);
size = dt_mem_next_cell(dt_root_size_cells, &reg);
early_init_dt_add_memory_arch(base, size);
}
}

函数先判断节点的unit name是memory@0,如果不是,则返回。然后将所有memory相关的reg属性取出来,根据address-cell和size-cell的值进行解析,然后调用early_init_dt_add_memory_arch()来申请相应的内存空间。

    memory@0 {
device_type = "memory";
reg = <0x0 0x0 0x0 0x80000000>, <0x8 0x00000000 0x0 0x80000000>;
};

到这里,setup_machine_fdt()函数对于设备树的第一次扫描解析就完成了,主要是获取了一些设备树提供的总览信息。

arm_memblock_init

// arch/arm/mm/init.c
void __init arm_memblock_init(const struct machine_desc *mdesc)
{
// ...
early_init_fdt_reserve_self();
early_init_fdt_scan_reserved_mem();
// ...
}

对于设备树的初始化而言,主要做了两件事:

  • 调用early_init_fdt_reserve_self,根据设备树的大小为设备树分配空间,设备树的totalsize在dtb头部中有指明,因此当系统启动之后,设备树就一直存在在系统中。
  • 扫描设备树节点中的"reserved-memory"节点,为其分配保留空间。

memblock_init对于设备树的部分解析就完成了,主要是为设备树指定保留内存空间。

unflatten_device_tree

这一部分就进入了设备树的解析部分:

注意of_root这个对象,我们后续文章中会提到它。实际上,解析以后的数据都是放在了这个对象里面。

void __init unflatten_device_tree(void)
{
// 展开设备树
__unflatten_device_tree(initial_boot_params, NULL, &of_root,early_init_dt_alloc_memory_arch, false); // 扫描设备树
of_alias_scan(early_init_dt_alloc_memory_arch);
// ...
}

展开设备树

property原型

struct property {
char *name;
int length;
void *value;
struct property *next;
// ...
};

在设备树中,对于属性的描述是key = value,这个结构体中的name和value分别对应key和value,而length表示value的长度;

next指针指向下一个struct property结构体(用于构成单链表)。

__unflatten_device_tree

__unflatten_device_tree(initial_boot_params, NULL, &of_root,early_init_dt_alloc_memory_arch, false);

我们再来看最主要的设备树解析函数:

void *__unflatten_device_tree(const void *blob,struct device_node *dad,
struct device_node **mynodes,void *(*dt_alloc)(u64 size, u64 align),bool detached)
{
int size;
// ...
size = unflatten_dt_nodes(blob, NULL, dad, NULL);
// ...
mem = dt_alloc(size + 4, __alignof__(struct device_node));
// ...
unflatten_dt_nodes(blob, mem, dad, mynodes);
}

主要的解析函数为unflatten_dt_nodes(),在__unflatten_device_tree()函数中,unflatten_dt_nodes()被调用两次:

  • 第一次是扫描得出设备树转换成device node需要的空间,然后系统申请内存空间;
  • 第二次就进行真正的解析工作,我们继续看unflatten_dt_nodes()函数:

值得注意的是,在第二次调用unflatten_dt_nodes()时传入的参数为unflatten_dt_nodes(blob, mem, dad, mynodes);

unflatten_dt_nodes

第一个参数是设备树存放首地址,第二个参数是申请的内存空间,第三个参数为父节点,初始值为NULL,第四个参数为mynodes,初始值为of_node.

static int unflatten_dt_nodes(const void *blob,void *mem,struct device_node *dad,struct device_node **nodepp)
{
// ...
for (offset = 0;offset >= 0 && depth >= initial_depth;offset = fdt_next_node(blob, offset, &depth)) {
populate_node(blob, offset, &mem,nps[depth],fpsizes[depth],&nps[depth+1], dryrun);
// ...
}
}

这个函数中主要的作用就是从根节点开始,对子节点依次调用populate_node(),从函数命名上来看,这个函数就是填充节点,为节点分配内存。

device_node原型
// include/linux/of.h
struct device_node {
const char *name;
const char *type;
phandle phandle;
const char *full_name;
// ...
struct property *properties;
struct property *deadprops; /* removed properties */
struct device_node *parent;
struct device_node *child;
struct device_node *sibling;
struct kobject kobj;
unsigned long _flags;
void *data;
// ...
};
  • name:设备节点中的name属性转换而来。
  • type:由设备节点中的device_type转换而来。
  • phandle:有设备节点中的"phandle"和"linux,phandle"属性转换而来,特殊的还可能由"ibm,phandle"属性转换而来。
  • full_name:这个指针指向整个结构体的结尾位置,在结尾位置存储着这个结构体对应设备树节点的unit_name,意味着一个struct device_node结构体占内存空间为sizeof(struct device_node)+strlen(unit_name)+字节对齐
  • properties:这是一个设备树节点的属性链表,属性可能有很多种,比如:"interrupts","timer","hwmods"等等。
  • parent,child,sibling:与当前属性链表节点相关节点,所以相关链表节点构成整个device_node的属性节点。
  • kobj:用于在/sys目录下生成相应用户文件。
populate_node
static unsigned int populate_node(const void *blob,int offset,void **mem,
struct device_node *dad,unsigned int fpsize,struct device_node **pnp,bool dryrun){
struct device_node *np;
// 申请内存
// 注,allocl是节点的unit_name长度(类似于chosen、memory这类子节点描述开头时的名字,并非.name成员)
np = unflatten_dt_alloc(mem, sizeof(struct device_node) + allocl,__alignof__(struct device_node)); // 初始化node(设置kobj,接着设置node的fwnode.ops。)
of_node_init(np); // 将device_node的full_name指向结构体结尾处,
// 即,将一个节点的unit name放置在一个struct device_node的结尾处。
np->full_name = fn = ((char *)np) + sizeof(*np); // 设置其 父节点 和 兄弟节点(如果有父节点)
if (dad != NULL) {
np->parent = dad;
np->sibling = dad->child;
dad->child = np;
} // 为节点的各个属性分配空间
populate_properties(blob, offset, mem, np, pathp, dryrun); // 获取,并设置device_node节点的name和type属性
np->name = of_get_property(np, "name", NULL);
np->type = of_get_property(np, "device_type", NULL);
if (!np->name)
np->name = "<NULL>";
if (!np->type)
np->type = "<NULL>";
// ...
}

一个设备树中节点转换成一个struct device_node结构的过程渐渐就清晰起来,现在我们接着看看populate_properties()这个函数,看看属性是怎么解析的,

populate_properties
static void populate_properties(const void *blob,int offset,void **mem,struct device_node *np,const char *nodename,bool dryrun){
// ...
for (cur = fdt_first_property_offset(blob, offset);
cur >= 0;
cur = fdt_next_property_offset(blob, cur))
{
fdt_getprop_by_offset(blob, cur, &pname, &sz);
unflatten_dt_alloc(mem, sizeof(struct property),__alignof__(struct property));
if (!strcmp(pname, "phandle") || !strcmp(pname, "linux,phandle")) {
if (!np->phandle)
np->phandle = be32_to_cpup(val); pp->name = (char *)pname;
pp->length = sz;
pp->value = (__be32 *)val;
*pprev = pp;
pprev = &pp->next;
// ...
}
}
}

从属性转换部分的程序可以看出,对于大部分的属性,都是直接填充一个struct property属性;

而对于"phandle"属性和"linux,phandle"属性,直接填充struct device_node phandle字段,不放在属性链表中。

扫描节点:of_alias_scan

从名字来看,这个函数的作用是解析根目录下的alias

struct device_node *of_chosen;
struct device_node *of_aliases; void of_alias_scan(void * (*dt_alloc)(u64 size, u64 align)){
of_aliases = of_find_node_by_path("/aliases");
of_chosen = of_find_node_by_path("/chosen");
if (of_chosen) {
if (of_property_read_string(of_chosen, "stdout-path", &name))
of_property_read_string(of_chosen, "linux,stdout-path",
&name);
if (IS_ENABLED(CONFIG_PPC) && !name)
of_property_read_string(of_aliases, "stdout", &name);
if (name)
of_stdout = of_find_node_opts_by_path(name, &of_stdout_options);
}
for_each_property_of_node(of_aliases, pp) {
// ...
ap = dt_alloc(sizeof(*ap) + len + 1, __alignof__(*ap));
if (!ap)
continue;
memset(ap, 0, sizeof(*ap) + len + 1);
ap->alias = start;
of_alias_add(ap, np, id, start, len);
// ...
}
}

of_alias_scan()函数先是处理设备树chosen节点中的"stdout-path"或者"stdout"属性(两者最多存在其一),然后将stdout指定的path赋值给全局变量of_stdout_options,并将返回的全局struct device_node类型数据赋值给of_stdout,指定系统启动时的log输出。

接下来为aliases节点申请内存空间,如果一个节点中同时没有name/phandle/linux,phandle,则被定义为特殊节点,对于这些特殊节点将不会申请内存空间。

然后,使用of_alias_add()函数将所有的aliases内容放置在aliases_lookup链表中。

转换过程总结

此后,内核就可以根据device_node来创建设备。

Linux 内核:设备树(2)dtb转换成device_node的更多相关文章

  1. linux设备驱动程序-设备树(1)-dtb转换成device_node

    linux设备驱动程序-设备树(1)-dtb转换成device_node 本设备树解析基于arm平台 从start_kernel开始 linux最底层的初始化部分在HEAD.s中,这是汇编代码,我们暂 ...

  2. Linux内核 设备树操作常用API【转】

    转自:https://www.linuxidc.com/Linux/2017-02/140818.htm 一文中介绍了设备树的语法,这里主要介绍内核中提供的操作设备树的API,这些API通常都在&qu ...

  3. Linux内核 设备树操作常用API

    Linux设备树语法详解一文中介绍了设备树的语法,这里主要介绍内核中提供的操作设备树的API,这些API通常都在"include/of.h"中声明. device_node 内核中 ...

  4. linux设备驱动程序-设备树(0)-dtb格式

    linux设备树dtb格式 设备树的一般操作方式是:开发人员根据开发需求编写dts文件,然后使用dtc将dts编译成dtb文件. dts文件是文本格式的文件,而dtb是二进制文件,在linux启动时被 ...

  5. Linux dts 设备树详解(二) 动手编写设备树dts

    Linux dts 设备树详解(一) 基础知识 Linux dts 设备树详解(二) 动手编写设备树dts 文章目录 前言 硬件结构 设备树dts文件 前言 在简单了解概念之后,我们可以开始尝试写一个 ...

  6. Linux dts 设备树详解(一) 基础知识

    Linux dts 设备树详解(一) 基础知识 Linux dts 设备树详解(二) 动手编写设备树dts 文章目录 1 前言 2 概念 2.1 什么是设备树 dts(device tree)? 2. ...

  7. (转)Linux内核基数树应用分析

    Linux内核基数树应用分析 ——lvyilong316 基数树(Radix tree)可看做是以二进制位串为关键字的trie树,是一种多叉树结构,同时又类似多层索引表,每个中间节点包含指向多个节点的 ...

  8. Linux 获取设备树源文件(DTS)里描述的资源

    Linux 获取设备树源文件(DTS)里的资源 韩大卫@吉林师范大学 在linux使用platform_driver_register() 注册 platform_driver 时, 需要在 plat ...

  9. Linux下将UTF8编码批量转换成GB2312编码的方法

    Linux下将UTF8编码批量转换成GB2312编码的方法 在sqlplus中导入UTF8编码的sql脚本就会出现乱码错误,这时就需要将UTF8编码转换成GB2312编码,下面为大家介绍下在Linux ...

  10. Linux 获取设备树源文件(DTS)里的资源【转】

    本文转载自:http://blog.csdn.net/keleming1/article/details/51036000 http://www.cnblogs.com/dyllove98/archi ...

随机推荐

  1. 数仓OLAP技术

    数据应用,是真正体现数仓价值的部分,包括且又不局限于 数据可视化.BI.OLAP.即席查询,实时大屏,用户画像,推荐系统,数据分析,数据挖掘,人脸识别,风控反欺诈,ABtest等等 OLAP(On-L ...

  2. 《最新出炉》系列入门篇-Python+Playwright自动化测试-43-分页测试

    1.简介 分页测试,这种一般都是公共的方法系统中都写好了,这种一般出现是数据展示比较多的时候,会采取分页的方法,而且比较固定,一般是没有问题的,因此它非常适合自动化测试,但是如何使用playwrigh ...

  3. rails 之下载

    控制器 def index #传给前端展示层当前的id @id = 6 end # http://127.0.0.1:3000/admin/category_statistics/export_tab ...

  4. SpringBoot 二维码生成

    一.基于Google开发工具包ZXing生成二维码 1.引入需要的依赖 <!-- zxing生成二维码 --> <dependency> <groupId>com. ...

  5. ABP-VNext 用户权限管理系统实战06---实体的创建标准及迁移

    在apb-vnext的实体的创建中可以确实字段的长度.说明.对应的表.表中给字段加的索引 以项目中的订单表为例,如下: [Comment("订单主表")] [Table(" ...

  6. spring boot整合maybatis plus 的 文件生成代码

    /** * 代码生成 */public class AutoGenerator_ { public static void main(String[] args) { AutoGenerator ge ...

  7. navicat premium 15 下载和激活

    Navicat Premium 15 下载地址 链接:https://pan.baidu.com/s/1bL-M3-hkEa4M-547giVjYQ?pwd=1107 推荐安装参考地址:https:/ ...

  8. Android 12(S) MultiMedia(十二)MediaCodecList & IOmxStore

    这节来了解下MediaCodecList相关代码路径: frameworks/av/media/libstagefright/MediaCodecList.cpp frameworks/av/medi ...

  9. ABP邮件发送

    ABP  Vnext发邮件要使用AbpMailKitModule的实现IEmailSender,要检查添加了Volo.Abp.MailKit,其dependon 要添加typeof() 它使用Sett ...

  10. Part1--软件规范总纲

    开发人员规范 软件代码编写规范 套话 目的:统一公司编码风格:提高代码易读性.可靠性和稳定性:减少软件维护成本提高生产力 基本原则:维持代码易读.可维护:保持代码清晰:尽可能复用代码 实用规则 缩进 ...