文章目录
一、信号保存
信号其他相关常见概念
1、实际执⾏信号的处理动作称为信号递达(Delivery),
2、信号从产⽣到递达之间的状态,称为信号未决(Pending),也就是进程的信号位图被修改了,信号已被进程保存,但是没有执行信号处理动作。
3、进程可以选择阻塞或屏蔽 (Block )某个信号,也就是阻止某个信号从未决转化为递达。
4、被阻塞的信号产⽣时将保持在未决状态,直到进程解除对此信号的阻塞,才执⾏递达的动作.
5、注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,⽽忽略是在递达之后可选的⼀种处理动作。
6、理解阻塞: task struct里存在两张信号位图,一张叫做pending,一张叫做block,它们的比特位位置都表示信号编号,pending的比特位内容1/0表示是否收到信号,block的比特位内容1/0表示信号是否被阻塞,也就是说只有进程收到一个信号,pending对应位为1,block对应位为0,进程才有机会执行该信号的信号递达。
并且信号递达后pending的对应位会由1变为0。
7、进程接受到信号后先将pending表中对应标志位由1置0,再执行信号处理方法,该顺序是 Linux 内核为避免信号重复递达、保证处理逻辑原子性的核心设计。
信号在内核中的表示

我们可以看到task_struct中有三张位图结构,三张位图的某一个特定比特位对于一个信号,所以我们未来对信号做操作本质就是对这三张表做操作,我们之前介绍的signal系统调用本质就是对handler做设置。
下面是linux内核中位图代码:
cpp
// 内核结构 2.6.18
struct task_struct {
...
/* signal handlers */
struct sighand_struct* sighand;
sigset_t blocked;
struct sigpending pending;
...
}
struct sighand_struct {
atomic_t count;
struct k_sigaction action[_NSIG]; // #define _NSIG 64
spinlock_t siglock;
};
struct __new_sigaction {
__sighandler_t sa_handler;
unsigned long sa_flags;
void (*sa_restorer)(void); /* Not used by Linux/SPARC */
__new_sigset_t sa_mask;
};
struct k_sigaction {
struct __new_sigaction sa;
void __user* ka_restorer;
};
/* Type of a signal handler. */
typedef void (*__sighandler_t)(int);
struct sigpending {
struct list_head list;
sigset_t signal;
};
sigset_t
从上图来看,每个信号只有一个 bit 的未决标志,非 0 即 1,
不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型 sigset_t 来存储,sigset_t 称为信号集 ,这个类型可以表示每个信号的 "有效" 或 "无效" 状态,在阻塞信号集中 "有效" 和 "无效"
的含义是该信号是否被阻塞,而在未决信号集中 "有效" 和 "无效" 的含义是该信号是否处于未决状态。下一节将详细介绍信号集的各种操作。阻塞信号集也叫做当前进程的信号屏蔽字 (Signal Mask), 这里的 "屏蔽" 应该理解为阻塞而不是忽略。
sigset_t本质是一种位图结构,是结构体套数组的形式:
cpp
//内核
typedef struct {
unsigned long sig[_NSIG_WORDS];
} sigset_t;
之前我们提到的信号位图都是sigset_t类型,所以现在我们就可以对特定的信号位图做修改以修改特定信号。
信号集操作函数
sigset_t类型对于每种信号⽤⼀个bit表⽰"有效"或"⽆效"状态, ⾄于这个类型内部如何存储这些bit则依赖于系统实现,从使⽤者的⻆度是不必关⼼的, 使⽤者只能调⽤以下函数来操作sigset_t变量,⽽不应该对它的内部数据做任何解释,⽐如⽤printf直接打印sigset_t变量是没有意义的。
cpp
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
1、这批接口可以方便用户直接对信号集做操作,而不用自己进行位操作,注意它只是函数,并不是系统调用。
2、函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,往往用来对所有信号做初始化。
3、函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表⽰该信号集的有效信号包括系统⽀持的所有信号,往往用来对信号做屏蔽。
4、注意,在使⽤sigset_ t类型的变量之前,⼀定要调 ⽤sigemptyset或sigfillset做初始化,使信号集处于确定的
状态。初始化sigset_t变量之后就可以在调⽤sigaddset和sigdelset在该信号集中添加或删除某种有效信号,其实就对特定比特位由0置1或者由1置0。
5、sigismember是判定某个信号是否在信号集里,返回1说明信号在信号集里,返回0说明不在,返回-1说明出现错误了。
6、这四个函数都是成功返回0,出错返回-1。sigismember是⼀个布尔函数,⽤于判断⼀个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
这里还有一个注意事项,首先我们要把sigset_t数据类型和以前在语言阶段学习的数据类型做区分,sigset_t类型是系统提供的数据类型,而以前的数据类型是语言提供的。但是我们直接在代码中用sigset_t定义一个对象,并对它进行sigemptyset,这时该对象只是存在用户空间栈区中,并没有被直接设置到内核里,我们要把对象设置到内核里必须调用系统调用。
cpp
int main()
{
sigset_t sig; //用户栈上开辟的变量
sigemptyset(&sig); //并没有把sug设置进内核里
return 0;
}
sigprocmask
该系统调用可以修改信号的BLOCK位图,并设置进内核,该系统调用有三种设置方式,具体调用哪种有传入的第一个参数决定,当传入SIG_BLOCK时是新增屏蔽某一个或几个信号,具体有第二个参数set决定,当传入0010时说明新增屏蔽2号信号,当传入SIG_UNBLOCK时是解除某一个或几个信号的屏蔽,当传入SIG_SETMASK时是覆盖式的重置信号位图。
cpp
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);

返回值:若成功则为0,若出错则为-1
sigpending
它是获取当前进程pending表的系统调用接口,它会把当前进程的pending表通过输出型参数带出。

返回值:若成功则为0,若出错则为-1
这里有的读者可能会问,为什么没有修改pending表的系统调用接口,其实我们上一篇博客介绍的5种产生信号方式的任意一种本质都是在修改进程的pending表。
信号保存代码示例
下面我们实现一份代码,先屏蔽进程的2号信号,然后不断获取信号的pending信号集并打印,当用户向该进程发送2号信号后我们会看到pending信号集的2号信号对应位置由0变1了,解除屏蔽后会看到pending信号集对应位置由1变0了。但是需要修改2号信号的默认递达动作(使进程退出),否则在打印下一个pending信号集之前当前进程就退出了。
(写代码前,小编补充一点,9号信号不仅不可被捕捉,也不可被屏蔽,因为9号信号是管理员信号)
cpp
#include <iostream>
#include <signal.h>
#include <sys/types.h>
#include <unistd.h>
void handler(int signo)
{
//解除屏蔽后的递达行为
printf("执行%d号信号的递达\n", signo);
//进程不退出
}
void PrintPending(sigset_t& pending)
{
std::cout << "pid: " << getpid() << ", pending:";
for (int signo = 31; signo > 0; signo--)
{
// 从右往左比特位由底边高,从高位开始打印
if(sigismember(&pending, signo))
{
std::cout << 1;
}
else{
std::cout << 0;
}
}
std::cout << std::endl;
}
int main()
{
// 0 更改2号信号默认行为,避免执行信号递达时进程退出
signal(2, handler);
// 1 屏蔽2号信号
sigset_t block, oblock;
// 1.1 用户层面,设置block位图
sigemptyset(&block);
sigemptyset(&oblock);
sigaddset(&block, SIGINT);
// 1.2 将block位图设置进内核
sigprocmask(SIG_SETMASK, &block, &oblock);
// 2 获取pending并打印,10秒后解除屏蔽
int cnt = 10;
while (true)
{
// 2.1获取内核pending
sigset_t pending;
sigpending(&pending);
// 2.2打印获取的pending
PrintPending(pending);
sleep(1);
cnt--;
if(cnt == 0)
{
//10秒后将原来的oclock设置回来
sigprocmask(SIG_SETMASK, &oblock, nullptr);
}
}
return 0;
}
二、信号捕捉
下面我们先厘清一组概念:

下面是信号捕捉的大致流程,看着很复杂,后面小编会让大小一秒钟记住的。

小编先解释一下上图的内核态和用户态,我们特指CPU的运行状态,当CPU处于内核态时主要执行内核的代码,当CPU处于用户态时主要执行用户自己的代码。
第一步和第二步很好理解,主要解释一下第三步,当进程要从内核态切换回用户态时,OS会检查当前进程的三张表:block pending handler,除了当前进程收到了信号且该信号未被屏蔽以外,进程都会切换回用户态返回main函数继续执行后续逻辑,其它情况就是信号的处理方法需要被执行了,当信号处理方法是忽略时依旧返回用户态main函数,当信号处理方法是默认是若默认处理方法是杀死该进程或者让该进程退出那么不用将进程从内核态切换回用户态,以外杀死和暂停进程都要修改内核,当前进程处于内核态就可以顺带修改了。
如果信号处理方法是自定义handler就比较复杂了,首先OS不能在内核态执行自定义handler,因为OS被禁止执行用户写的代码(比如用户写的删除根目录),OS直接执行用户级代码就跳过了系统的权限认证,有可能破坏系统。
所以自定义handler方法只能由用户自己执行,所以需要从内核态跳转到用户态执行完自定义handler后再返回内核,再通过内核返回main函数。
下面让大家一秒钟记住信号捕捉的整个流程,我们把它想成一个无穷的标志,然后拉一根线在交点的上方:

穿插话题:操作系统是怎么运行的
硬件中断

硬件层面:
1、根据冯诺依玛体系我们知道,外设和内存和CPU主要是做各种IO操作,把数据从外设拷贝到内存,或者从内存拷贝到外设,但是这里会有一个问题,CPU怎么知道外设把数据准备好了随时可以拷贝,拷贝完后CPU怎么知道拷贝完毕了。
(补充:寄存器本质是一种特殊的硬件电路,如32位寄存器,它每一个比特位在软件层面的1\0本质就是高低电平,所以向写寄存器本质就是对寄存器充电,比如要让寄存器32全为1就需要将寄存器的32位全部置为高电平)
2、所以到就需要外设适时向CPU的一些针脚发起硬件中断,但是CPU的针脚是很宝贵的,不会让每一个外设都占据CPU的一个针脚,所以外设发起的中断都需要先交给中断控制器,再由中断控制器和CPU的一个针脚做交互。
3、CPU收到中断控制器的硬件中断后,它会从中断控制器中获取该中断的中断号,因为中断和CPU的执行是异步的,此时CPU还会把收到中断时只在执行的任务进行现场保护。
软件层面:
1、操作系统中有一个最重要的模块:中断向量表,中断向量表内部包含了各种外设的中断服务,也就是CPU执行对应外设中断的方法。
总结:
1、中断向量表是OS提供的,所以我们也可以说中断向量表本质就是OS的一部分,并且在启动时就加载到内存中了。
2、CPU在收到硬件中断时执行中断向量表的方法,本质就是在执行OS的代码。
3、硬件中断和信号,中断向量表和信号的handler表,中断号和信号编号,我们可以看到中断和信号的结构有一定的相似性,但是它们本质是两套互不相干技术体系,如果硬要说的话,硬件中断是信号的的 "爸爸",信号其实就是通过软件模拟实现硬件中断。
时钟中断
我们知道进程可以被OS管理、调度,但是OS本身也是软件,那它又由谁驱动呢?
外部设备可以触发硬件中断,但是这个是需要用户或者设备自己触发,有没有自己可以定期触发的设备?
接下来介绍的时钟中断就和这两个问题有关。

除了键盘、显示器等等常规外设外,计算机中还会存在一种特殊的外设------时钟源,它是专门用来触发硬件中断的外设,它的中断服务就是进程调度,它从上电开始就会一直触发中断,所以我们再一次强调我们的观点,OS核心就是中断向量表。
所以我们这里输出结论,OS的运行是在时钟源的驱动下执行的,时钟源一直触发中断,从而使OS执行自己的代码。
所以操作系统本质是一种什么都不用做的软件,只需要一个死循环即可,OS的运行全靠时钟源驱动,也可以说OS是一个躺平在中断上的软件集合。
时钟源触发的中断叫做时钟中断,它也有对应的中断号、中断处理方法,时钟中断的中断处理方法是 do_timer(),do_timer()中就有进程调度逻辑schdule方法,随着进程被调度起来,通过进程控制的文件管理系统、内存管理系统也就随之被调度起来了。
细节补充
1、当代计算机已经没有外部时钟源了,而是将时钟源集成到了CPU内部,这样效率更高,CPU内有两部分核心组件,时钟发生器和计数器,时钟发生器发送一次时钟信号,会使计数器--,当计数器减到0后会往CPU发送一次时钟中断。
2、一般CPU的主频是固定的,也就是说CPU收到时钟中断的频率是固定的。OS中有一个全局变量jiffies,它是用来统计时钟中断次数的,CPU每触发一次时钟中断jiffies就会++,
3、OS内还有一个全局计数器counter,每触发一次时钟中断counter就要减减,所以时钟中断并不是OS的全部,在两个时钟中断的间隔时间中CPU可以执行当前进程的代码,当counter未减到0时CPU执行时钟中断方法时并不会进行进程调度,当counter减到0后CPU执行时钟中断方法时会进行进程调度,细节时OS就会把当前进程从CPU中剥离下来,选取下一个进程运行。
软中断
1、上述外部硬件中断,需要硬件设备触发。
2、有没有可能,CPU自身因为软件原因,也触发上⾯的中断逻辑?有!
3、为了让操作系统⽀持进⾏系统调⽤,CPU也设计了对应的汇编指令(int 或者 syscall),当CPU执行这两条汇编指令时就会在CPU内部触发特定中断号的中断逻辑。
4、这种触发中断的方式叫做软中断,特点是不需要外部硬件触发,而是CPU通过软件的方式触发的,这样就可以通过软件编程的方式,让CPU进入中断的处理例程中。
5、软中断的实际应用场景是用来辅助实现系统调用。

系统调用
1、OS内部实现了许多系统调用方法,如下所示:
cpp
// sys.h
extern int sys_setup (); // 系统启动初始化设置函数。 (kernel/blk_drv/hd.c,71)
extern int sys_exit (); // 程序退出。 (kernel/exit.c, 137)
extern int sys_fork (); // 创建进程。 (kernel/system_call.s, 208)
extern int sys_read (); // 读⽂件。 (fs/read_write.c, 55)
extern int sys_write (); // 写⽂件。 (fs/read_write.c, 83)
...
2、所有系统调用都被维护在内存的一张系统调用表里,它本质是一个全局的函数指针数组。
3、我们要调用特定的系统调用首先要找到它,所以内核给每一个系统调用都定义了一个系统调用号,它本质就是系统调用表数组的下标,系统调用号在unistd.h头文件里:
cpp
//unistd.h
#define __NR_exit 1 /* 进程退出 */
#define __NR_fork 2 /* 创建子进程 */
#define __NR_read 3 /* 读取文件/设备 */
#define __NR_write 4 /* 写入文件/设备 */
#define __NR_open 5 /* 打开文件/设备 */
#define __NR_close 6 /* 关闭文件/设备 */
#define __NR_waitpid 7 /* 等待子进程结束 */
#define __NR_creat 8 /* 创建新文件 */
#define __NR_link 9 /* 创建文件硬链接 */
#define __NR_unlink 10 /* 删除文件/链接 */
#define __NR_execve 11 /* 执行新程序 */
#define __NR_chdir 12 /* 切换当前工作目录 */
内核态和用户态
1、进程地址空间的0-3GB为用户区,3-4GB为内核区。
2、页表分为用户级页表和内核级页表(这两个页表本质是一个页表,只是不同的呈现形式),用户级页表负责将进程地址空间的0-3GB空间映射到物理内存,内核级页表负责将进程地址空间的3-4GB空间映射到物理内存。
3、对于进程来讲,每一个进程都要有自己的用户级页表,但是所有进程共用同一个内核级页表,也就是说每一个进程都要各自映射自己的代码和数据,但所有进程映射同一份OS内核的代码和数据。
结论:1、也就是无论进程如何切换,如何调度,每一个进程都可以通过内核级页表找到同一个操作系统内核,包括CPU也可以找到内核,因为CPU内随时都有进程在运行。
2、用户要访问OS内核只能通过系统调用,那么用户是如何看到系统调用的?通过进程地址空间!程序正常调用我们自己写的函数是在代码区内进行切换,程序调用动态库是在代码区和共享区进行切换,程序调用系统调用是在代码区和内核区进行切换,也就是说未来所有函数调用,都是在进程自己的地址空间中完成。
3、补充:OS提供的系统调用并不是C风格的系统调用,而是用系统调用号、约定寄存器、int 0x80等技术提供的系统调用,如下图所示:

而我们之所以能用C语言调用系统调用,是因为linux内核的设计者将系统调用封装到C语言标准库中了,是C语言替我们完成了将系统调用名装换为系统调用号、将系统调用号拷贝到CPU的eax寄存器、主动触发一次软中断,让CPU执行内核的中断处理方法,替我们查表并执行系统调用。所以这也是C生万物的核心原因之一,绝大多数语言只要需要访问内核调用系统调用,底层都需要调用C语言封装的系统调用。
内核态和用户态的深层次理解
我们前面知道了进程地址空间中包含用户区和内核区,未来所有函数调用都是在进程自己的地址空间中完成,那这不就意味着用户可以随时访问OS内核了吗?所以OS为了保护自己的安全,引入了一组概念:内核态用户态,和配套的方法。
内核态用户态的实现方法如下: CPU内部有一个cs(code segment)寄存器,其中有两个比特位表示CPU的执行级别,0b00(0)表示内核,
0b11(3)表示用户,它也被称为CPL(Current Privilege Level)当前特权级标志位,在页表的每一条虚拟到物理的映射条目也存在这样类似的标志位,它表示该条映射是映射的内核区空间还是用户区空间,当用户访问内存、CPU进行内存映射时只有当CPL标志位和页表映射条目的CPL标志位匹配时才会映射成功,否则就会发生MMU报错。
CPU的CPL默认为3,所以如果我们要访问内核空间,就必须修改CPU的CPL标志位,在软件方面在(用户主观层面),更改CPL唯一的途径是通过int 0x80、syscall这样的软中断汇编指令,也就是说软中断指令和更改CPL是绑定在一次的,所以未来我们想调用系统调用就必须更改CPL,更改CPL后只能进行系统调用,非常巧妙的设计思路。
理解写时拷贝、缺页中断
缺⻚中断?内存碎⽚处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断,然后⾛中断处理例程,完成所有处理。有的是进⾏申请内存,填充⻚表,进⾏映射的。有的是⽤来处理内存碎⽚的,有的是⽤来给⽬标进⾏发送信号,杀掉进程等等。
例如写时拷贝,父进程fork子进程后初期父子共享代码和数据,但OS会将映射数据的页表权限由原来的可读可写修改为只读,当父子其中一方对共享数据做修改时就会触发MMU报错,接着转换成为CPU内部的软中断,⾛中断处理例程,对于这里来说,中断处理例程就是新创建一份物理内存空间并拷贝原来的数据到新空间,这样父子进程的数据就独立了。
三组概念
硬件中断:外设硬件引起的中断。
陷阱:CPU内部的软中断,⽐如int 0x80或者syscall,它的特点是没有出错,用户主动让自己陷入内核。
异常:CPU内部的软中断,⽐如除零/野指针等,它的特点是出错了(违反了CPU的架构规则),但是不是外设引起的,是用户操作导致 cpu 内部硬件出问题。
sigaction
我们再介绍一个进行信号捕捉的系统调用,本质就是更改handler表。
sigaction类似我们之前介绍的signal,但是signal功能较单一,虽然未来还是signal用的更多,还是有必要介绍一下sigaction,着重介绍一下和sigaction系统调用重名的结构体sigaction中的sa_mask字段。

sa_mask也是一个位图结构,其中31个比特位对应31个信号。
当某个信号的处理函数被调⽤时,内核⾃动将当前信号加⼊进程的信号屏蔽字,当信号处理函数返回时⾃动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产⽣,那么它会被阻塞到当前处理结束为⽌,这是为了防止同一信号进行递归处理,但不同信号可以打断当前的信号处理。
如果在调⽤信号处理函数时,除了当前信号被⾃动屏蔽之外,还希望⾃动屏蔽另外⼀些信号,则⽤sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时⾃动恢复原来的信号屏蔽字。
cpp
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
void handler(int signo)
{
std::cout << "捕捉了一个信号:" << signo << std::endl;
}
int main()
{
struct sigaction act, oact;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
act.sa_handler = handler;
sigaddset(&(act.sa_mask), 3); //当2号信号在进行处理时额外屏蔽3号信号
sigaddset(&(act.sa_mask), 4); //当2号信号在进行处理时额外屏蔽4号信号
sigaction(2, &act, &oact);
while (true)
{
std::cout << "我是一个进程:" << getpid() << std::endl;
sleep(1);
}
return 0;
}
三、可重入函数
下面我们先看一个函数重入的示例:

1、当一个函数被重入后会出现问题,那么该函数就是不可重入函数。
2、函数是否可重入是函数的特点,并非函数的优缺点。
3、大部分函数都是不可重入的。
4、main函数执行流和信号处理handler执行流是两套不同的执行流,虽然执行者都是同一个进程,当main函数执行流和信号处理handler执行流执行同一个函数时,就会发生重入问题。
如果⼀个函数符合以下条件之⼀则是不可重⼊的::
- 调⽤了malloc或free,因为malloc也是⽤全局链表来管理堆的。
- 调⽤了标准I/O库函数。标准I/O库的很多实现都以不可重⼊的⽅式使⽤全局数据结构。
- 函数使用了全局变量或全局数据,例如上面的全局链表,main函数和handler方法都能看到。
四、volatile
我们先看下面的代码示例:
cpp
#include <stdio.h>
#include <signal.h>
int flag = 0;
void handler(int sig)
{
printf("chage flag 0 to 1\n");
flag = 1;
}
int main()
{
signal(2, handler);
while (!flag);
printf("process quit normal\n");
return 0;
}
在标准情况下,输入 CTRL+C ,2号信号被捕捉,执⾏⾃定义动作,修改
flag=1 , while 条件不满⾜, 退出循环,进程退出。
但是如果我们开编译器优化后,情况就不一样了,首先介绍一下编译器主要有以下几种优化级别:

O0表示没开优化,后面三个优化级别依次升高,开启编译器优化的指令示例:
bash
gcc testsig.c -O1
当我们没开优化时while (!flag);这句代码转化为汇编指令时会循环将内存中的flag变量转到CPU寄存器中,然后做判断,这叫做访存,所以当handler方法修改内存中的flag变量后CPU会第一时间检测到变化,判断不满足循环条件后随即跳出循环。
但当我们开了优化后编译器会自作聪明的对代码进行优化,屏蔽内存数据,说人话就是CPU在while循环判断中不在进行访存了,转化为汇编指令时就少了将内存中的flag变量值mov到CPU寄存器的这句指令,所以while循环就会一直用原本CPU中保存flag值的寄存器进行判断,所以即使内存中flag值已经被修改了也影响不到CPU内的对应寄存器。
为了解决这一问题就需要用到volatile关键字修饰变量flag,volatile的作用是保持内存的可⻅性,汇编指令会强制每次从内存读取变量,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进⾏操作。
五、SIGCHLD信号
⼦进程在终⽌时会给⽗进程发SIGCHLD信号,该信号的默认处理动作是忽略,⽗进程可以⾃定义SIGCHLD信号的处理函数,当⽗进程调⽤sigactionSIGCHLD的处理动作置为SIG_IGN,这样fork出来的⼦进程在终⽌时会⾃动清理掉,不会产⽣僵⼫进程,也不会通知⽗进程。系统默认的忽略动作和⽤⼾⽤sigaction函数⾃定义的忽略通常是没有区别的,但这里是⼀个特例。
以上就是小编分享的全部内容了,如果觉得不错还请留下免费的关注和收藏
如果有建议欢迎通过评论区或私信留言,感谢您的大力支持。
一键三连好运连连哦~~
