📌 个人主页: 孙同学_
🔧 文章专栏: Liunx
💡 关注我,分享经验,助你少走弯路!
文章目录
-
- [一. 认识信号](#一. 认识信号)
- [二. 产生信号的方式](#二. 产生信号的方式)
-
- [2.1 键盘产生信号](#2.1 键盘产生信号)
- [2.2 系统调用](#2.2 系统调用)
- [2.3 硬件异常产生信号](#2.3 硬件异常产生信号)
- [2.4 软件条件产生信号](#2.4 软件条件产生信号)
- [三. 信号的保存](#三. 信号的保存)
-
- [3.1 信号其他的相关常见概念](#3.1 信号其他的相关常见概念)
- [3.2 这些概念在内核中的表现](#3.2 这些概念在内核中的表现)
- [3.3 sigset_t](#3.3 sigset_t)
- [3.4 信号集操作函数](#3.4 信号集操作函数)
-
- [3.4.1 sigprocmask](#3.4.1 sigprocmask)
- [3.4.2 sigpending](#3.4.2 sigpending)
一. 认识信号
首先我们应该知道信号和信号量之间没有任何关系。
生活中的信号比如说:闹钟,红绿灯,上课铃声,狼烟,电话铃声,肚子叫,脸色等。
闹钟响了我们就得起床,上课铃声响了我们就得进教室进行上课,狼烟起来了士兵们就知道要打仗了,电话铃声响了我们就知道来电话了,肚子叫我们就知道我们的肚子饿了,一个人的脸色不好,我们就能直到他可能生气了。
什么是信号?中断我们人正在做的事情,是一种事件的异步通知机制
其实进程就相当于我们的人,当进程收到信号时,进程就要中断进程正在做的事情,这种方式就叫做信号,所以信号是给进程发送的,用来进行事件异步通知的机制
- 信号的产生相对于进程的运行是异步的
- 信号是发给进程的
基本结论:
- 信号处理在信号没有产生时就知道该如何处理了
- 信号的处理不是立即处理,可以等一会再处理,在合适的时候进行处理
- 人能识别信号前提是被"教育"过的(红灯亮了要等一等),进程也是如此,OS程序员设计的进程,进程早已经内置的对于信号的识别和处理方式!
- 信号源是非常多的,给进程产生信号的信号源也非常多
二. 产生信号的方式
2.1 键盘产生信号
我们先编写一段代码testSig.cc
cpp
#include <iostream>
#include <unistd.h>
int main()
{
while(true)
{
std::cout << "Hello world" << std::endl;
sleep(1);
}
return 0;
}

当这段代码运行起来后我么想终止,我们可以使用ctrl + c,但为什么ctrl + c可以终止呢?
因为ctrl + c是给目标进程发送信号的。相当一部分信号的处理动作就是让进程自己终止。
查看linux中的信号列表
指令:kill -l

在我们计算机中,信号就是一个整数的数字,我们未来想要用进程的话,可以用该数字向目标进程发送信号,但是数字的可读性不是很好,未来我们在使用的时候,会把数字定义成为大写的字母宏 ,该宏对应的值就是前面匹配的数字。
在Linux系统里一共有62和信号,从34到64这部分信号称为实时信号,这种信号往往一旦产生就需要立即处理。
而我们上面的ctrl + c就是通过键盘给目标进程发送2号信号。
进程收到信号的处理方式:
1.默认动作处理
2.自定义动作处理
3.忽略处理!
更改一个信号的默认处理动作:signal

cpp
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
- 参数
signum:信号对应的数字handler:返回值为void,参数为int的函数
信号不再执行默认的方法,反而执行自定义的函数中指针指向的方法。
案例:将2号信号的默认处理改成打印:"获得了一个信号:" << sig
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
void headlerSig(int sig)
{
std::cout << "获得了一个信号:" << sig << std::endl;
}
int main()
{
signal(SIGINT,headlerSig);
int cnt = 0;
while(true)
{
std::cout << "Hello world: " << cnt++ << std::endl;
sleep(1);
}
return 0;
}
当我们ctrl + c的时候就会发现,此时的ctrl + c不再是终止,而是打印出这段话。

此时我们想终止的话可以用ctrl + \3号信号来终止。
查看所有信号的额默认处理:man 7 signal

我们上面说过,信号是发送给目标进程的,可是目标进程是谁呢?
前台进程和后台进程
当我们在命令行直接./XXX运行时,这种进程叫做前台进程 ;当我们直接在命令行./XXX &,这叫做后台进程。
当我们没有启动任何程序的时候,我们的系统里有一个进程一直在给我们提供的服务,这个进程叫做命令行shell进程,命令行shell进程就是在前台的。
当我们的进程是后台进程时,我们ctrl + c无法处理我们的信号,所以键盘产生的信号只能发送给前台进程。
我们再来谈谈前后台的问题:
当我们登陆Linux系统,不管我们是拿桌面登录还是拿Xshell登陆,Linux系统首先会帮我们创建一个进程,这个进程叫做shell,在ubuntu下这个进程叫做bash进程,这个bash进程首先会输出命令行,然后等待用户输入。当我们./xxx它就会创建一个子进程,这个进程是前台进程,前台进程能从标准输入(键盘)中获取内容,后台进程是相反的。但是不管是前台进程还是后台进程,它们两个都能向标准输出上打印。
前台进程只能有一个,后台进程可以有多个。
前台进程的本质是从键盘上获取数据的。
我们以前的父进程退出,子进程ctrl + c就杀不掉了,原因是父进程退出了子进程就变成了孤儿进程,被自动提到后台了,所以只能用kill杀掉。
jobs查看所有的后台任务
fg + 任务号:表示把特定的进程提到前台
ctrl + z:暂停进程
前台进程不能被暂停,因为前台进程永远要接收用户输入
所以一个进程一旦被暂停,就会被自动提到后台
让暂停的进程运行起来:bg + 任务号,但是此时运行起来的进程是后台进程
什么叫做给进程发送信号?
信号产生之后并不是立即处理的,所以进程要把接收到的信号记录下来。记录的目的是为了在合适的时候处理。
记录在哪里?
进程的struct task_struct
如何记录?
在进程的pcb里维护一个整数,位图结构,用比特位的位置表示信号编号,用内容表示是否收到信号。
发送信号的本质是向目标进程写信号,本质上是修改位图 (需要两个东西,一个是进程的pid,一个是信号的编号)
task_struct舒徐操作系统内的数据结构对象,所以修改位图的本质是修改内核的数据。
不管信号怎么产生,发送信号在底层必须让给OS发送。所以操作系统必须提供发送信号的系统调用。kill命令是用c语言写的,它调的就是操作系统的系统调用接口,来完成对目标进程发信号。

信号 VS 通信IPC
狭义上讲,通信IPC和信号不一样
2.2 系统调用
给目标进程发送信号的系统调用:kill

原型
cpp
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
代码示例:
Makefile
cpp
.PHONY:all
all:testsig mykill
testsig:testSig.cc
g++ -o $@ $^ -std=c++11
mykill:mykill.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f testsig mykill
mykill.cc
cpp
#include <iostream>
#include <sys/types.h>
#include <signal.h>
//./mykill signumber pid
int main(int argc,char *argv[])//命令行参数
{
if(argc != 3)
{
std::cout << "./mykilee signumber pid" << std::endl;
return 1;//退出码设置为1
}
int signum = std::stoi(argv[1]); //给进程发送几号信号
pid_t target = std::stoi(argv[2]); //给哪个进程发送信号
int n = kill(target,signum);//给哪个进程发,发什么
if(n == 0)//发送成功
{
std::cout << "send" << signum << "to" << target << "success";
return 0;
}
return 0;
}
testSig.cc
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
void headlerSig(int sig)
{
std::cout << "获得了一个信号:" << sig << std::endl;
}
int main()
{
signal(SIGINT,headlerSig);
int cnt = 0;
while(true)
{
std::cout << "Hello world: " << cnt++ <<",pid"<< getpid() << std::endl;
sleep(1);
}
return 0;
}
我们的信号大部分都是可以自定义捕捉的,但是有一个信号不能自定义捕捉,这个信号是9号信号SIGKILL,所以9号信号不能被自定义捕捉。
产生信号的第二个系统调用:raise

cpp
#include <signal.h>
int raise(int sig);
raise表示自己给自己发送信号,raise可以指明自己给自己发送哪一个信号
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
void headlerSig(int sig)
{
std::cout << "获得了一个信号:" << sig << std::endl;
}
int main()
{
for (int i = 1; i < 32; i++)
signal(i, headlerSig); // 捕捉所有信号
for (int i = 1; i < 32; i++)
{
//每隔1秒自己给自己发一个信号
sleep(1);
raise(i);
}
int cnt = 0;
while (true)
{
std::cout << "Hello world: " << cnt++ << ",pid" << getpid() << std::endl;
sleep(1);
}
return 0;
}

到9号信号停了下来原因是9号信号不能被捕捉。
第三个系统调用:abort(其实不属于系统调用)
abort也是自己给自己发送信号,这个信号要求进程必须处理,它是用来终止进程的
2.3 硬件异常产生信号
先说问题:我们在执行C/C++代码时,我们的程序有时会崩掉,一种叫做除0,一种叫做野指针。
我们的代码里要是有除0错误代码就会崩掉是因为进程收到了8号信号(SIGFPE)。
我们的代码中要是有野指针,就会收到11号信号(SIGSEGV)。段错误
信号全部都是由操作系统发送的,我们的程序出错了,操作系统会识别我们的进程犯错了并且分析犯错类型,然后向我们的目标进程发送信号,整个过程其实和我们的用户是没有关系的。
现在的核心问题是操作系统怎么知道我们的进程犯错了???
- 操作系统怎么知道我们的程序除0了?
除0运算本质是在CPU上运行的,CPU上有各种的寄存器,比如标志寄存器(EFLAGS)这个寄存器是若干个比特位,比如000100,有一个比特位表示我们CPU当前计算时是否出现溢出。换句话说我们的操作系统怎么知道CPU计算出问题了呢?
答案是我们的CPU属于硬件,操作系统是软硬件资源的管理者,当我们的程序一旦出错,操作系统是能识别到是硬件上出错了的,然后它这个计算是溢出了的,而我们的CPU寄存器保存的是当前进程的上下文,当然也包括当前进程的task_struct,它的硬件上出错了,所以操作系统就能识别到哪一个进程出错了,操作系统转而给我们的目标进程发送对应的信号。 - 操作系统怎么知道野指针(段错误)的问题?
我们在拿野指针访问0号地址,则进程的虚拟地址就是0,而0号地址在我们的页表中并不存在映射关系,所以无法映射到我们的内存当中,CPU中存在的地址都是虚拟地址,在我们的CPU内部存在着一个寄存器叫做CR3寄存器,这个寄存器会记录下当前页表的起始地址。在CPU内部继承了一种硬件单元,这个硬件单元叫做MMU,将来CPU寻址是将虚拟地址交给MMU,把CR3寄存器里的内容也交给MMU,此时虚拟地址有了,页表有了,MMU在硬件层面上拿着对应的页表完成虚拟地址到物理地址的转化。
MMU在转化时也有可能转化失败,此时再往0号地址去写就会发生硬件报错。操作系统作为软硬件资源的管理者,硬件报错了,当前运行的依旧是当前进程的上下文,并且知道当前错误是虚拟到物理转化失败了,并且还要写入,所以就会给当前进程发送11号信号,让进程直接终止。
2.4 软件条件产生信号
存在一个系统调用,为当前进程设置闹钟:alarm
当闹钟时间到了的时候,它就会为当前进程推送一个信号。
比如alarm(5)定一个5秒的闹钟,5秒之后它就会为当前进程发送一个信号。alarm(0)表示取消闹钟。

cpp
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用 alarm函数可以设定⼀个闹钟,也就是告诉内核在 seconds 秒之后给当前进程发SIGALRM 信号,该信号的默认处理动作是终止当前进程。
- 返回值
这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。
我们来设定一种闹钟,这个闹钟1秒钟发送一个闹钟信号,此时进程就执行某种任务。以闹钟为驱动力,让进程每隔1秒做一次,每隔1秒做一次。
cpp
#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
void handlerSig(int sig)
{
std::cout<<"获得了一个信号" << sig << "pid:" << getpid() << std::endl;
alarm(1);
}
int main()
{
signal(SIGALRM,handlerSig);
alarm(1);
while(true)
{
std::cout << ".," << "pid:" << getpid() << std::endl;
sleep(1);
}
return 0;
}
等待一个信号的接口:pause

cpp
#include <unistd.h>
int pause(void);
pause的作用是等待一个信号,也就是没有信号时pause就暂停了,只有当我们的信号被捕捉,并且从信号捕捉函数返回的时候它才会直接返回。出错的时候会返回-1,否则就会暂停起来。
我们让我们的进程每隔一秒完成一些任务
让我们的进程一直处于暂停状态,受外部信号的驱动每隔一秒发送一个信号,信号到来时它会执行对应的任务,这就是操作系统的本质

cpp
#include <iostream>
#include <vector>
#include <functional>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
///////func/////////
void Sche()
{
std::cout << "我是进程调度" << std::endl;
}
void MemManger()
{
std::cout << "我是周期性的内存管理,正在检查有没有内存问题" << std::endl;
}
void Fflush()
{
std::cout << "我是刷新程序,我在定期刷新内存数据" << std::endl;
}
////////////////////
using func_t = std::function<void()>; // 用来存储没有返回值且没有参数的函数
std::vector<func_t> funcs; // 这个容器中包含了一堆方法
// 每隔一秒,完成一些任务
void handlerSig(int sig)
{
std::cout << "#########################" << std::endl;
for(auto f : funcs)
{
f();
}
std::cout << "#########################" << std::endl;
alarm(1);
}
int main()
{
funcs.push_back(Sche);
funcs.push_back(MemManger);
funcs.push_back(Fflush);
signal(SIGALRM, handlerSig);
alarm(1);
while (true) //操作系统的本质!
{
pause();
}
return 0;
}
快速理解闹钟:
OS内可能同时存在很多闹钟,所以操作系统就要对闹钟进行管理。所以所谓的闹钟就是一种数据结构,创建闹钟就是创建一种闹钟的结构体对象。
下面这就是一个闹钟的描述结构:
cpp
struct timer_list
{
struct list_head entry;
unsigned long expires; //闹钟自己的过期时间
void (*function)(unsigned long);//闹钟要执行的方法
unsigned long data;
struct tvec_t_base_s *base;
};
我们之前学过数据结构中的堆,我们设置的闹钟是以最小堆的方式,即堆顶元素为最短超时的闹钟,通过和时间戳做比较,如果堆顶元素的时间小于了我们的时间戳,则堆顶的闹钟出堆,向目标进程发送信号,执行对应的SIGALRM。
所以闹钟超时之后,向目标进程发送信号的这种方式,叫做软件条件产生信号。
总结:上面产生信号的五中方式当中,不论哪一种方式,都是由OS进行信号的发送的。所谓的信号发送其实是向目标进程修改比特位。
三. 信号的保存
我们上面就已经说过了,进程接受到信号不一定立即处理,所以前提就先得保存信号。
3.1 信号其他的相关常见概念
- 实际执行信号的处理动作称为信号抵达(Delivery)。
- 信号从产生到信号抵达中间的的状态称为信号未决(Pending)。
- 进程可以选择阻塞(Block)某个信号,我们把阻塞信号也叫做屏蔽信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行抵达的动作。
- 注意,忽略和信号阻塞是不同的,忽略是信号抵达状态时一种可选择的执行信号的方式。
3.2 这些概念在内核中的表现

在进程的PCB中不仅仅有位图,其实进程当中与普通信号相关的有三张表,block表pending表handler表。
pending:(unsigned int pending)表其实就是保存我们信号收到的位图,也叫做未决图。
- 比特位的位置:表示的是第几个信号
- 比特位的内容:是否收到信号,0表示未收到该信号,1表示收到了该信号
block:(unsigned int block)也是位图。
- 比特位的位置:表示的是第几个信号
- 比特位的内容:是否阻塞,0表示可以直接抵达,1表示不能直接抵达

pending&(~block):表示可以直接抵达的信号
handler:我们之前用的系统调用signal,它里面的一个参数就是handler

这个表是一个函数指针数组。这个表的下标表示的就是信号编号。
所以我们的signal函数的本质就是在系统调用的内部,拿着我们传进去的信号编号作为该数组的索引,找到特定的位置,把我们自己设置的函数的地址传进去。
所以当前有没有信号,有没有被阻塞,怎么处理我们就知道了。这三张表合起来,共同承担了进程怎么识别信号。
回到最开始我们说的,信号再没抵达时我们就知道该信号如何处理了,即便是没有block,pending,我们也知道它的handler是SIG_DFL默认处理动作,进程未来处理信号的方式本质上就是修改handler表
所以我们应该如何看待信号的处理呢?

我们应该横着来看这张表,从上往下对应了31组描述信号的关系。这3这表支撑了进程对信号的识别。
3.3 sigset_t
从上图来看,每个信号只有一个比特位的未决标志,即非0即1,不记录信号产生了多少次,阻塞信号也是这样的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示信号的"有效"或者"无效"状态。在阻塞信号集中"有效"和"无效"的含义是该信号是否被阻塞,在未决信号集中,"有效"和"无效"表示该信号是否处于未决状态。
阻塞信号集也叫当前进程的信号屏蔽字(Signal Mask),这里的"屏蔽"应该理解为阻塞而不是忽略。
3.4 信号集操作函数
sigset_t类型对于每种信号用一个比特位表示"有效"或者"无效"状态,至于内部如何存储这些比特位依赖于系统的实现,从使用者的角度是不关心的,使用者只需要调用下面的函数来操作sigset_t变量,而不需要对它的内部做任何解释。
cpp
#include <signal.h>
int sigemptyset(sigset_t *set); //表示把其中一个信号集清空
int sigfillset(sigset_t *set); //把位图全部置1
int sigaddset(sigset_t *set, int signo); //把一个信号添加到集合里(这个信号是几,就把其比特位置1)
int sigdelset(sigset_t *set, int signo); //把一个信号删掉(比特位置0)
int sigismember(const sigset_t *set, int signo);//判断一个信号是否在集合里(其实就是判断第几个比特位是否为1)
sigemptyset:函数sigemptyset用来初始化set所指向的信号集,使其中所有信号的对应比特位清零,表示该信号集不包含任何有效信号。sigfillset:函数sigfillset用来初始化set所指向的信号集,使其中所有信号的对应比特位全部置1,表示该信号集包含所有信号。- 注意:在使用
sigset_t变量之前,一定要调用sigemptyset或者sigfillset初始化,使信号集处于确定状态。初始化sigset_t后,就可以调用sigaddset和sigdelset在信号集中添加或者删除某种有效信号了。
3.4.1 sigprocmask
调用函数sigprocmask可以读取或者更改进程的信号屏蔽字(阻塞信号集)
哪一个进程调用这个函数就是在设置或者更新自己的block表


- 参数
how:表示你想做什么操作

sig_block:表示把传进来的设置为1的信号新增到block表中,如果已经屏蔽了就继续屏蔽,没有屏蔽的信号设置成屏蔽
sig_unblock:表示把已经屏蔽的信号设置为未屏蔽的
sig_setmask:表示未来自己定义的信号集,把整个block表全部更新一遍- 第二个参数是未来我们用户自己传入的
sigset_t类型(输入型) - 第三个参数是一个输出型参数,在我们改
block之前把旧的block带出去,这样如果我们就可以恢复到之前的block
- 返回值
设置成功0被返回,否则-1被返回
3.4.2 sigpending

cpp
#include <signal.h>
int sigpending(sigset_t *set);
这个函数是用来获取当前进程的pending信号集的,这个参数是一个输出型参数
demo代码: 先将2号信号屏蔽,然后不断获取pending表,不断打印,再向目标进程发送2号信号,因为2号信号不会被立即抵达(执行),只是pending表由0变1,不执行对应的操作,所以我们才能看到信号由0变为1的过程。
cpp
#include <iostream>
#include <cstdio>
#include <vector>
#include <functional>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
void PrintPending(sigset_t &pending)
{
printf("我是一个进程(%d),pending:", getpid());
// 遍历所有信号
for (int signo = 31; signo >= 1; signo--) // 先打印出来的第一个比特位是最高位
{
// 判断指定信号是否在当前集合里
if (sigismember(&pending, signo))
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout << std::endl;
}
void handler(int sig)
{
std::cout << "抵达" << sig << "信号!" << std::endl;
}
int main()
{
signal(SIGINT, handler);
// 1.屏蔽2号信号
sigset_t block, oblock;
sigemptyset(&block); // 将信号集清空
sigemptyset(&oblock);
// 将2号信号添加到信号集中
sigaddset(&block, SIGINT);
// 将32个信号全部添加到信号集当中
// for(int i = 1; i < 32; i++)
// sigaddset(&block,i);
int n = sigprocmask(SIG_SETMASK, &block, &oblock);
(void)n; // 防止出现n只有定义但并没有被使用
// 4. 重复获取打印过程
int cnt = 10;
while (true)
{
// 2.获取pending信号集
sigset_t pending;
int m = sigpending(&pending);
// 3.打印
PrintPending(pending);
if (cnt == 0)
{
// 5.恢复对2号信号的block情况
std::cout << "解除对2号信号的屏蔽" << std::endl;
sigprocmask(SIG_SETMASK, &oblock, nullptr);
}
sleep(1);
cnt--;
}
return 0;
}
在这个过程中我们会发现一个现象,我们要是把所有信号都屏蔽的话,那么这个进程就有了"金刚不坏之身了",这个进程要是病毒的话那岂不是完蛋了!但是由实验现象可以知道,9号信号是不能被屏蔽的。所以9号信号不可被捕捉,不可被阻塞!!!
pending表中的由1变0,是在handler执行之前变的还是handler执行之后变的。
结论是:当我们准备抵达的时候,首先要清空pending信号集中的对应的位图 1 -> 0。(在抵达之前由1变0)
🌵补充细节问题: 我们保存信号用的是pending位图,pending位图用一个比特位来表示是否收到,那如果将来有一种信号产生很多次该怎么办呢?
比如说今天向目标进程发了一百个2号信号,因为进程不一定是对信号做立即处理的,此时的普通信号该怎么处理呢?Linux中是这样设计的,常规信号在抵达之前产生多次只记录一次,而实时信号在抵达之前产生多次,可以一次放在一个队列里。
🍒信号终止进程的方式有两种,core,term
core 🆚 term
core是核心的意思,如果一个进程以core退出,往往会在当前路径下形成一个文件,进程异常退出的时候,进程在内存中的核心数据会从内存拷贝在磁盘,形成一个文件,称为核心转储 ,只要是为了支持debug调试的。
term是进程因为异常而退出,但是term不做任何转储功能,直接退出
注意:在我们的云服务器上 core dump功能是被禁止掉的。
为什么要禁止掉?
未来如果我们的程序发生错误,core dump是要在当前路径下形成一个文件的,如果我们的程序挂一次,它形成一个文件,挂一次,形成一个文件就很容易把我们的磁盘打满。
如何查看?
ulimit -a ,其中有一个core file size为0表示关闭
如何打开?
ulimit -c (临时方案)
为什么要进行核心转储?
答案是:为了支持debug
如果我们未来在找bug找不到,我们可以开启core dump,直接让程序运行崩溃,gdb,core-file core,直接帮助我们来定位到出错行。(事后调试)

我们之前的进程等待,次低8位代表的是进程退出时的退出码,低7位表示的是该进程退出时的退出信号,一个进程在退出的时候如果信号为0表示正常退出的(因为信号是从1-31的,没有0号信号)。除了次低8位和低7位,还有一个标志位就是core dump标志位,表示是否core dump


👍 如果对你有帮助,欢迎:
- 点赞 ⭐️
- 收藏 📌
- 关注 🔔
