• 20145339《信息安全系统设计基础》第十一周学习总结


    第八章 异常控制流

    • 平滑:指在存储器中指令都是相邻的。
    • 突变:出现不相邻,通常由诸如跳转、调用、和返回等指令造成。
    • 异常控制流ECF:即这些突变。
    • 关于ECF:
      • ECF是操作系统用来实现I/O、进程和虚拟存器的基本机制
      • 应用程序通过使用一个叫做陷阱或者系统调用的ECF形式,向操作系统请求服务
      • ECF是计算机系统中实现并发的基本机制
      • 软件异常机制——C++和Java有try,catch,和throw,C中非本地跳转是setjmp和longjmp

    第一节 异常

    异常是异常控制流的一种形式,由硬件和操作系统实现。简单来说,就是控制流中的突变。

    • 事件:即状态变化,与当前指令的执行可能直接相关,也可能没有关系。
    • 出现异常的处理方式:
      • 处理器检测到有异常发生
      • 通过异常表,进行间接过程调用,到达异常处理程序
      • 完成处理后:①返回给当前指令②返回给下一条指令③终止

    1-异常处理

    • 需要知道几个概念:异常号,异常表,异常表基址寄存器。
      • 异常号:系统为每种类型的异常分配的唯一的非负整数。
      • 异常表:系统启动时操作系统就会初始化一张条转变,使得条目k包含异常k的处理程序的地址。
        关系:
    • 异常号是到异常表中的索引,异常表的起始地址放在异常表基址寄存器。

    • 异常类似于过程调用,区别在:

      1.处理器压入栈的返回地址,是当前指令地址或者下一条指令地址。
      2.处理器也把一些额外的处理器状态压到栈里
      3.如果控制一个用户程序到内核,所有项目都压到内核栈里。
      4.异常处理程序运行在内核模式下,对所有的系统资源都有完全的访问权限。

    2-异常的类别

    • 故障指令:执行当前指令导致异常
    • 中断处理程序:硬件中断的异常处理程序。

    (1)中断

    • 异步发生
    • 来自处理器外部的I/O设备的信号的结果
    • 返回下一条指令

    (2)陷阱

    • 陷阱是有意的异常
    • 是执行一条指令的结果
    • 最重要的用途——系统调用

    (3)故障

    • 由错误状况引起,可能能够被故障处理程序修正
    • 结果要么重新执行指令(就是返回当前指令地址),要么终止
    • 典型示例:缺页异常

    (4)终止

    • 是不可恢复的致命错误造成的结果
    • 通常是一些硬件错误

    3-Linux/IA32系统中的异常

    一共有256种不同的异常类型。

    (1)Linux/IA32故障和终止

    • 除法错误/浮点异常 异常0 终止程序
    • 一般保护故障/段故障 异常13 终止程序
    • 缺页 异常14 返回当前地址
    • 机器检查 异常18 终止程序

    (2)Linux/IA32系统调用

    每一个系统调用都有一个唯一的整数号,对应于一个到内核中跳转表的偏移量。

    系统调用的实现方法:

    • 在IA32中,系统调用通过一条陷阱指令提供:
      int n;//n为异常号

    所有的到Linux系统调用的参数都是通过寄存器传递的。惯例如下:

    • %eax:包含系统调用号
    • %ebx,%ecx,%edx,%esi,%edi,%ebp:包含最多六个任意参数
    • %esp:栈指针,不能使用

      第二节 进程

    • 进程的经典定义:一个执行中的程序的实例。

    • 系统中的每个程序都是运行在某个进程的上下文中的。

    ※上下文:由程序正确运行所需的状态组成的。

    • 进程提供给应用程序的关键抽象:

      • 一个独立的逻辑控制流:独占的使用处理器
      • 一个私有的地址空间:独占的使用存储器系统

    1-逻辑控制流

    (1)含义

    • 一系列的程序计数器PC的值,分别唯一的对应于包含子啊程序的可执行目标文件中的指令,或者是包含在运行时动态链接到程序的共享对象中的指令,这个PC值的序列就叫做逻辑控制流。

    (2)逻辑流示例

    • 异常处理程序、进程、信号处理程序、线程、Java进程

    2-并发流

    (1)含义

    • 一个逻辑流的执行在时间上与另一个流重叠。【与是否在同一处理器无关】

    • 这两个流并发的运行。

    (2)几个概念

    • 并发:多个流并发的执行
    • 多任务:一个进程和其他进程轮流运行(也叫时间分片)
    • 时间片:一个进程执行它的控制流的一部分的每一时间段

    (3)并行

    • 两个流并发的运行在不同的处理机核或者计算机上。

    • 并行流并行的运行,并行的执行。

    3-私有地址空间

    进程为程序提供的假象,好像它独占的使用系统地址空间。一般而言,和这个空间中某个地址相关联的那个存储器字节是不能被其他进程读写的。

    4-用户模式和内核模式

    • 简单的说,用户模式和内核模式的区别就在于用户的权限上,权限指的是对系统资源使用的权限。

    • 具体的区别是有无模式位,有的话就是内核模式,可以执行指令集中的所有指令,访问系统中任何存储器位置;没有就是用户模式。

    • 进程从用户模式变为内核模式的唯一方法是通过异常——中断,故障,或者陷入系统调用。

    • Linux的聪明机制——/proc文件系统,将许多内核数据结构的内容输出为一个用户程序可以读的文本文件的层次结构。

    • 但我在虚拟机中输入告诉我权限不够?

    • 这个通过最后一节得知,是我输入方法不对,应该输入的是cat打印指令,再接后面的目录,如下图:

    5-上下文切换

    • 操作系统内核使用上下文切换这种较高层形式的异常控制流来实现多任务。上下文切换机制建立在较底层异常机制之上。

    (1)上下文:内核重新启动一个被抢占的进程所需的状态。

    • 由一些对象的值组成:

      • 通用目的寄存器
      • 浮点寄存器
      • 程序计数器
      • 用户栈
      • 状态寄存器
      • 内核栈
      • 内核数据结构:页表、进程表、文件表

    (2)调度和调度器

    • 内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程。有内核中称为调度器的代码处理的。

    (3)上下文切换机制

    • 保存当前进程的上下文

    • 恢复某个先前被抢占的进程被保存的上下文

    • 将控制传递给这个新恢复的进程。

    (4)可能发生上下文切换的原因:

    • 内核代表用户执行系统调用时
    • 中断

    第三节 系统调用错误处理

    • 这一节主要是附录A的内容的重复解释,在上周已经学习过。

    • 简单总结就是,系统会使用错误处理包装函数,系统级函数是小写,他们的包装函数名大写,包装函数调用基本函数,有任何问题就终止,如果没有问题和基本函数是一样的。

    需要注意的就是,检查错误的思想!!!

    第四节 进程控制

    一、获取进程ID

    • 每个进程都有一个唯一的正数进程ID(PID)。

      #include <sys/types.h>
      #include <unistd.h>
      pid_t getpid(void); 返回调用进程的PID
      pid_t getppid(void);    返回父进程的PID(创建调用进程的进程)

    二、创建和终止进程

    1.进程总是处于下面三种状态之一
    • 运行
    • 停止:被挂起且不会被调度
    • 终止:永远停止。原因:
      • 1.收到信号,默认行为为终止进程
      • 2.从主程序返回
      • 3.调用exit函数
    2.创建进程
    • 父进程通过调用fork函数来创建一个新的运行子进程。fork函数定义如下:

      #include <sys/types.h>
      #include <unistd.h>
      pid_t fork(void);
    • fork函数只被调用一次,但是会返回两次:父进程返回子进程的PID,子进程返回0.如果失败返回-1.

    在图8-15的代码示例为:

    /* $begin fork */
    #include "csapp.h"
    int main() 
    {
        pid_t pid;
        int x = 1;
        pid = Fork(); //line:ecf:forkreturn
        if (pid == 0) {  /* Child */
        printf("child : x=%d
    ", ++x); //line:ecf:childprint
        exit(0);
        }
        /* Parent */
        printf("parent: x=%d
    ", --x); //line:ecf:parentprint
        exit(0);
    }
    /* $end fork */
    • 调用一次,返回两次
    • 并发执行,内核能够以任何方式交替执行它们的逻辑控制流中的指令
    • 相同和不同:
      • 相同:用户栈、本地变量值、堆、全局变量值、代码
      • 不同:私有地址空间
    • 共享文件:子进程继承了父进程所有的打开文件。参考10.6节笔记。

    • 调用fork函数n次,产生2的n次方个进程。

    3.终止进程
    • 用exit函数。

      #include <stdlib.h>
      void exit(int status);
    • exit函数以status退出状态来终止进程。

    三、回收子进程

    • 进程终止后还要被父进程回收,否则处于僵死状态。

    • 如果父进程没有来得及回收,内核会安排init进程来回收他们。init进程的PID为1.

    • 一个进程可以通过调用waitpid函数来等待它的子进程终止或停止。waitpid函数的定义如下:

      #include <sys/types.h>
      #include <sys/wait.h>
      pid_t waitpid(pid_t pid, int *status, int options);
    • 成功返回子进程PID,如果WNOHANG,返回0,其他错误返回-1.

    1.判断等待集合的成员——pid
    • pid>0:等待集合是一个单独子进程,进程ID等于pid
    • pid=-1:等待集合是由父进程所有的子进程组成
    • 其他。
    2.修改默认行为——options

    设置为常量WNOHANG和WUNTRACED的各种组合:

    3.检查已回收子进程的退出状态——status
    • 在wait.h头文件中定义了解释status参数的几个宏:
      • WIFEXITED:如果子进程通过调用exit或一个返回正常终止,就返回真
      • WEXITSTATUS:返回一个正常终止的子进程的退出状态。只有在WIFEXITED返回为真时,才会定义这个状态
      • WIFSIGNALED:如果子进程是因为一个未被捕获的信号终止的,那么返回真
      • WTERMSIG:返回导致子进程终止的信号的编号。只有在WIFSIGNALED返回为真时才定义这个状态
      • WIFSTOPPED:如果引起返回的子进程当前是被停止的,那么返回真
      • WSTOPSIG:返回引起子进程停止的信号的数量。只有在WIFSTOPPED返回为真时才定义这个状态
    4.错误条件
    • 如果调用进程没有子进程,那么waitpid返回-1,并且设置errno为ECHILD。

    • 如果waitpid被一个信号中断,那么他返回-1,并且设置errno为EINTR。

    5.wait函数
    • wait函数是waitpid函数的简单版本,wait(&status)等价于waitpid(-1,&status,0).

      #include <sys/types.h>
      #include <sys/wait.h>
      pid_t wait(int *status);
    • 成功返回子进程pid,出错返回-1

    四、让进程休眠

    1.sleep函数
    • sleep函数使一个进程挂起一段指定的时间。定义如下:

      #include <unistd.h>
      unsigned int sleep(unsigned int secs);
    • 返回值是剩下还要休眠的秒数,如果到了返回0.

    2.pause函数
    #include <unistd.h>
    int pause(void);
    • 让调用函数休眠,直到该进程收到一个信号。

    五、加载并运行程序——execve函数

    #include <unistd.h>
    int execve(const char *filename, const char *argv[], const char *envp[]);
    • 成功不返回,失败返回-1.
    • execve函数调用一次,从不返回。

    • filename:可执行目标文件
    • argv:参数列表
    • envp:环境列表

    新程序开始时:

    getnev函数

    #include <stdlib.h>
    char *getenv(const char *name);
    若存在则为指向name的指针,无匹配是null
    • 在环境数组中搜寻字符串"name=value",如果找到了就返回一个指向value的指针,否则返回null。

    setenv和unsetenv函数

    #include <stdlib.h>
    int setenv(const char *name, const char *newvalue, int overwrite);
    若成功返回0,错误返回-1
    void unsetenv(const char *name);
    无返回值
    • 如果环境数组包含"name=oldvalue"的字符串,unsetenv会删除它,setenv会用newvalue代替oldvalue,只有在overwrite非零时成立。

    • 如果name不存在,setenv会将"name=newvalue"写进数组。

    ※fork函数和execve函数的区别

    • fork函数是创建新的子进程,是父进程的复制体,在新的子进程中运行相同的程序,父进程和子进程有相同的文件表,但是不同的PID

    • execve函数在当前进程的上下文中加载并运行一个新的程序,会覆盖当前进程的地址空间,但是没有创建一个新进程,有相同的PID,继承文件描述符。

    第五节 信号

    • Unix信号:更高层的软件形式的异常允许进程中断其他进程。

    一、信号术语

    • 传递一个信号到目的进程的两个步骤:发送信号和接收信号。

    • 发送信号的原因:
      • 内核检测到一个系统事件
      • 一个进程调用了kill函数,显式的要求内核发送一个信号给目的进程。
    • 一个进程可以发送信号给它自己。

    • 接收信号:
      • 1.忽略
      • 2.终止
      • 3.执行信号处理程序,捕获信号
    • 待处理信号:
      • 只发出没有被接收的信号
      • 任何时刻,一种类型至多只会有一个待处理信号,多的会被直接丢弃
      • 一个进程可以选择性的阻塞接受某种信号,被阻塞仍可以被发送,但是不会被接收
      • 一个待处理信号最多只能被接收一次。
      • pending:待处理信号集合
      • blocked:被阻塞信号集合。

    二、发送信号——基于进程组

    1.进程组
    • 每个进程都只属于一个进程组。
    • 进程组ID:正整数
    • 一个子进程和他的父进程属于同一进程组。
    • 查看进程组id:getpgrp
    • 修改进程组:setpgid
    2.用/bin/kill程序发送信号
    • /bin/kill程序可以向另外的进程发送任意的信号,格式是:

      /bin/kill -n m
      n是信号,m是进程或进程组
    • 当n>0时,发送信号n到进程m

    • 当n<0时,使信号|n|发送到进程组m中的所有进程。

    3.从键盘发送信号
    • 作业:表示对一个命令行求值而创建的进程。外壳为每个作业创建一个独立的进程组。
    4、用kill函数发送信号
    • 进程通过调用kill函数发送信号给其他的进程。父进程用kill函数发送SIGKILL信号给它的子进程。
    5.用alarm函数发送信号
    • 进程可以通过调用alarm函数向它自己发送SIGALRM信号。

    三、信号简介

    1.基本概念
    • 信号是一种进程间通信的方法,应用于异步事件的处理,实质是软中断。

    • 在软件层面。

    2.信号生命周期
    • 信号产生、信号注册、信号注销、信号处理

    (1)信号产生-四种类型

    • 用户产生-Ctrl+C。
    • stty-a,查看哪些按键可以产生信号
      • 硬件产生-除零错误
      • 进程产生-kill指令
      • 内核产生-闹钟超时

    (2)信号处理-三种方法

    • 执行默认操作
    • 忽略信号
    • 捕捉信号:执行信号处理函数,切换到用户态。
    • 捕捉:signal函数

    • 忽略信号:SIG_IGN
    • 默认操作:SIG_DFL

    (3) 多信号处理

    • 处理方法:

      • 1.递归,调用同一个处理函数

      • 2.忽略第二个信号

      • 3.阻塞第二个信号直至第一个处理完毕

    第六节 非本地跳转

    • c语言中,用户级的异常控制流形式,通过setjmp和longjmp函数提供。

    • setjump函数在env缓冲区中保存当前调用环境,以供后面longjmp使用,并返回0.

    • 调用环境:程序计数器,栈指针,通用目的寄存器

    • longjmp函数从env缓冲区中恢复调用环境,然后触发一个从最近一次初始化env的setjmp调用的返回。然后setjmp返回,并带有非零的返回值retval。

    注:

    • setjmp函数只被调用一次,但返回多次;

    • longjmp函数被调用一次,但从不返回。

    第七节 操作进程的工具

    • STRACE:打印一个正在运行的程序和他的子程序调用的每个系统调用的痕迹
    • PS:列出当前系统中的进程,包括僵死进程
    • TOP:打印出关于当前进程资源使用的信息
    • PMAP:显示进程的存储器映射

    代码分析

    关于exec.1

    • exec1.c代码运行如下

    • exec1.c中execvp()会从PATH 环境变量所指的目录中查找符合参数file 的文件名,找到后便执行该文件,然后将第二个参数argv传给该欲执行的文件
    • 如果执行成功则函数不会返回,执行失败则直接返回-1,失败原因存于errno中
    • exevp函数调用成功没有返回,所以没有打印出“* * * ls is done. bye”这句话

    关于exec2.c

    • exec2与exec1的区别就在于exevp函数的第一个参数,exec1传的是ls,exec2直接用的arglist[0],不过由定义可得这两个等价,所以运行结果是相同的

    关于exec3.c

    • 函数中execlp()会从PATH 环境变量所指的目录中查找符合参数file的文件名,找到后便执行该文件,然后将第二个以后的参数当做该文件的argv[0]、argv[1]……最后一个参数必须用空指针(NULL)作结束

    关于env

    • environ.c代码运行如下

    • 代码中涉及到getenv函数和setenv函数
    • getenv函数是获得环境变量值的函数,参数是环境变量名name,例如”HOME”或者”PATH”。如果环境变量存在,那么getenv函数会返回环境变量值,即value的首地址;如果环境变量不存在,那么getenv函数返回NULL
    • setenv函数是修改或添加环境变量的函数
      • 1.如果name在环境中不存在,那么很好办,在环境中添加这个新的变量就OK。
        setenv函数必须在environment list中增加一个新的entry,然后动态申请存储空间来存储name=value,并且使entry指向该空间。
      • 2.如果在环境中name已经存在,那么
        • (a)若overwrite非0,那么更新name的value(实质是更新环境表,指向新的value)
        • (b)若overwrite为0,则环境变量name不变,并且也不出错
    • setenv函数不必在environment list中增加一个新的entry。当overwrite为0, 则不必改动entry的指向;当overwrite非0, 则直接使该entry指向name=value,当然该name=value也是存储在动态申请的内存里。
      environvar.c代码简单打印环境变量表,运行结果如下

    • 每个程序都有一个环境表,它是一个字符指针数组,其中每个指针包含一个以NULL结尾的C字符串的地址。全局变量environ则包含了该指针数组的地址

    关于argv

    • 头文件argv.h,下面的函数的功能是把命令行字符串转化为以NULL结尾的参数数组

      int makeargv(const char *s, const char *delimiters, char ***argvp);
    • 其中s为命令行字符串,delimiters为分割符,argvp为指向参数数组的指针,如果转化成功则返回标记的个数,如果错误则返回-1,并设置errno
    • 由于argtest.c中有如下代码:

       if (argc != 2) 
      {
      fprintf(stderr, "Usage: %s string
      ", argv[0]);
       return 1;
      }
    • 只有当输入命令的个数等于2时,才能显示命令正确的结果。

    • 结合上图,可以发现计算输入命令的个数时,将bin/argtest这个执行可执行文件的命令也计算在内了

    关于fifo

    • FIFO不同于管道之处在于它提供一个路径名与之关联,以FIFO的文件形式存在于文件系统中
    • FIFO严格遵循先进先出(first in first out),对管道及FIFO的读总是从开始处返回数据,对它们的写则把数据添加到末尾。它们不支持诸如lseek()等文件定位操作
    • FIFO往往都是多个写进程,一个读进程

    consumer.c代码中:

    memset(void *s,int ch,size_t n);将s中前n个字节用ch替换并返回s
    open(const char *pathname,int flags);第一个参数是欲打开的文件路径字符串,第二个参数是打开方式
    • fifo是一种文件类型,可以通过查看文件stat结构中的stmode成员的值来判断文件是否是FIFO文件。fifo是用来在进程中使用文件来传输数据的,也具有管道特性,可以在数据读出的时候清除数据

    consumer.c代码运行如下

    producer.c代码运行如下

    • testmf.c代码中调用了mkfifo函数
      mkfifo(FIFO_NAME, 0777);//依据FIFO_NAME创建fifo文件,0777依次是相应权限
    • mkfifo()建立的FIFO文件其他进程都可以用读写一般文件的方式存取

    关于forkdemo1.c

    • forkdemo1.c代码先是打印进程pid,然后调用fork函数生成子进程,休眠一秒后再次打印进程id,这时父进程打印子进程pid,子进程返回0
    • 运行结果如下

    关于forkdemo2.c

    • 这个代码调用两次fork,一共产生四个子进程,所以会打印四个aftre输出
    • 代码运行如下

    关于forkdemo3.c

    • fork产生子进程,父进程返回子进程pid,不为0,所以输出父进程的那句话,子进程返回0,所以会输出子进程那句话
    • 运行如下

    关于forkdemo4.c

    • 先打印进程pid,然后fork创建子进程,父进程返回子进程pid,所以输出parent一句,休眠十秒;子进程返回0,所以输出child与之后一句
    • 运行如下

    关于forkgdb.c

    • 父进程打印是先打印两句,然后休眠一秒,然后打印一句,子进程先打印一句,然后休眠一秒,然后打印两句。并且这两个线程是并发的,所以可以看到在一个线程休眠的那一秒,另一个线程在执行,并且线程之间相互独立互不干扰
    • 运行如下

    关于waitdemo1.c

    • waitdemo1.c的功能是如果有子进程,则终止子进程,成功返回子进程pid。运行如下

    关于waitdemo2.c

    • waitdemo2.c比起1来就是多了一个子进程的状态区分,把状态拆分成三块,exit,sig和core
    • 运行如下

    关于testbuf

    testbuf1.c代码运行如下

    testbuf2.c代码运行如下

    • testbuf1.c和testbuf2.c代码运行结果一致,因为fflush(stdout)的效果和换行符 是一样的

    testbuf3.c

    • 将内容格式化输出到标准错误、输出流中

    testpid.c代码

    • 输出当前进程pid和当前进程的父进程的pid

    关于pipe

    • pipe用来创建管道并将其两端连接到两个文件描述符,array[0]为读数据端的文件描述符,而array[1]则为写数据端的文件描述符,内部则隐藏在内核中,进程只能看到两个文件描述符

    listargs.c

    • 代码运行结果如下,证明了shell并不将重定向标记和文件名传递给程序

    pipe.c

    • 引入oops,当linux系统执行代码遇到问题时,就会报告oops,运行如下

    pipedemo.c

    • 展示了如何创建管道并使用管道来向自己发送数据

    pipedemo2.c

    • 说明了如何将pipe和fork结合起来,创建一对通过管道来通信的进程。在程序中显示了从键盘到进程,从进程到管道,再从管道到进程以及从进程回到终端的数据传输流

    stdinredir1.c

    • 将stdin定向到文件,程序中先关闭标准输入流,后打开文件,进行重定向

    stdinredir2.c运行如下

    关于psh1.c

    psh1.c代码

    • 效果是输入要执行的指令,回车表示输入结束,然后输入的每个参数对应到函数中,再调用对应的指令
    • 运行结果如下

    关于psh2.c

    • psh2.c比起1来,多了循环判断,不退出的话就会一直要你输入指令,并且对于子程序存在的状态条件
    • 运行如下

    关于signal

    sigdemo1.c

    • 程序连续输出五个hello,每两个之间的间隔时间为2秒,且在此期间输入的Ctrl+C都被处理成打印OUCH

    sigdemo2.c一直输出haha,按Ctrl+C不能停止

    • sigdemo2.c中:
      • SIG_DFL,SIG_IGN 分别表示无返回值的函数指针,指针值分别是0和1,这两个指针值逻辑上讲是实际程序中不可能出现的函数地址值。
      • SIG_DFL:默认信号处理程序
      • SIG_IGN:忽略信号的处理程序

    sigdemo3.c

    • 根据代码,在read函数不发生错误的情况下输入什么,就输出什么,输入的Ctrl+C也无法终止程序,只有输入quit的时候才会退出

    sigactdemo.c

    • sigaction()会依参数signum指定的信号编号来设置该信号的处理函数。参数signum可以指定SIGKILL和SIGSTOP以外的所有信号

      int sigaction(int signum,const struct sigaction *act ,struct sigaction *oldact);
    • sigactdemo.c执行如下

    • SA_RESETHAND:当调用信号处理函数时,将信号的处理函数重置为缺省值SIG_DFL
    • SA_RESTART:如果信号中断了进程的某个系统调用,则系统自动启动该系统调用
    • SA_NODEFER :一般情况下, 当信号处理函数运行时,内核将阻塞该给定信号。但是如果设置SA_NODEFER标记, 那么在该信号处理函数运行时,内核将不会阻塞该信号

    sigactdemo2.c

    • 休息seconds秒后返回;或者被信号中断且信号处理函数返回后sleep()返回0。所以如果不计较返回值的话,pause()的功能相当于无限期的sleep()
  • 相关阅读:
    代理模式
    windows服务
    Log4Net配置日志
    PLSql的使用
    母版页与部分视图
    Core Mvc传值ViewData、ViewBag和return view(model)
    IActionResult的返回类型
    Core Mvc传值Query、Form、Cookies、Session、TempData、Cache
    .Net Core 配置文件appsettings
    享元模式
  • 原文地址:https://www.cnblogs.com/dj20145339/p/6107004.html
Copyright © 2020-2023  润新知