进程信号(1)

目录

一、信号

1.1、生活中的信号

1.2、Linux中的信号

二、信号处理常见方式

三、信号的产生

3.1、简单理解信号的保存和发送

3.2、键盘产生信号

3.3、核心转储

3.4、系统调用接口产生信号

3.4.1、kill

3.4.2、raise

3.4.3、abort 

3.5、软件条件产生信号

3.6、硬件异常产生信号

四、信号的保存

4.1、相关概念

4.2、信号保存——三个数据结构

4.3、信号集——sigset_t

4.3、信号操作函数


一、信号

1.1、生活中的信号

        在生活中,我们很容易能够想到常见的一些信号。比如,红绿灯,手机闹钟,上下课铃声,转向灯等等。我们人不仅能够识别它们,还能够知道不同的信号对应的下一步动作应该怎么做。比如,红灯停绿灯行;上课铃响就上课,下课铃响就下课;转向灯告诉别人我要转的方向。

        那么,我们是怎么识别并知道这些信号,并且知道信号发出后,接下来的动作应该是怎么样的呢?首先,这当然是规定过的,交通部门规定了红灯停绿灯行,而如果交通部门规定红灯行,绿灯停,那么我们也就只能照做。其次,我们从出生开始,大人们就不断告诉我们,要红灯停,绿灯行,久而久之,我们就记住了特定场景下的信号,以及后续我们需要做到动作,并且终身不忘。

        而且,即使我们没有在过马路,而是在吃饭,我们也能够知道应该如何处理红绿灯信号。

        再比如,如果,我的9点的闹钟响了,但是我没有立即起床,而是30分钟后再起床。这就说明,当信号产生的时候,我们不一定会立即执行后续动作,但是我记住了闹钟响过了,我该起床了,后面我再执行起床的动作。

        上面就是一些生活中的信号,以及我们对待信号的方式。下面我们就来看看Linux中的信号。

1.2、Linux中的信号

什么是Linux信号?

        Linux信号本质是一种通知机制,是用户或者操作系统,通过发送一定的信号,来通知进程某件事已经发生,你可以后续对其进行处理。

Linux信号的特点

       结合上面生活中的信号的特点,Linux信号有如下特点:

        a. 进程能够识别信号,即能够看到信号发送给了自己,并且知道后续的处理动作。

        b. 进程能够识别信号,已经由Linux设计者提前设计好了,并且规定了各种信号的后续处理动作。

        c. 信号的产生是随机的,信号产生时,进程可能正在做自己的事,所以,进程不一定会立即对信号进行处理。

        d. 因为进程不一定立即处理信号,所以进程一定能够将信号记住,后续再进行处理。

        e. 进程会在合适的时候处理信号(什么时候合适?后面会讲)。

        g. 一般而言,信号的产生相对于进程是异步的。

信号查看:我们可以通过 kill -l 命令查看Linux中有哪些信号

        其中,1~31号信号,是普通信号,34~64是实时信号。我们在平时使用中使用的最多的是普通信号。

二、信号处理常见方式

为了方便后面的讲解,我们首先了解一下信号处理的常见方式:

1、执行该信号的默认处理动作(进程自带的,Linux设计者写好的逻辑)。

2、用户自己提供一个信号处理函数,要求在进行信号处理时,使用用户自己定义的方式处理信号,这种方式称为捕捉(Catch)一个信号。

3、忽略该信号。

我们可以通过 man 7 signal 查看信号的默认处理动作:

value:信号编号  action:默认处理动作。 

三、信号的产生

3.1、简单理解信号的保存和发送

        为了下面我们讲解信号的产生,这里我们先简单地理解一下信号的保存。

        前面讲到过,信号产生后,进程不一定会立即处理信号,而是在之后的某个合适的时间对信号进行处理。所以在这中间的一段时间里,我们必须对信号进行保存。

        对于保存,进程只需要知道是否有这个信号,就可以对信号进行处理,所以我们可以使用位图来对信号进行保存。0就代表该比特位对应的信号没有产生,1就代表产生了该信号。这样,在之后进程只需要遍历一遍位图,就可以知道产生了哪些信号,然后进行处理。

        该位图在进程的PCB中,属于内核数据,只有操作系统能够修改,所以信号的发送就是os把信号对应的比特位的数字由0改成1。

        当然,关于信号的保存和发送我们会在下面的内容中,进行详细的讲解,这里只是有一个概念。

3.2、键盘产生信号

        在之前讲进程等待时,我们知道使用 Ctrl + c 的组合键能够终止一个进程,而且我们也讲了,其本质就是通过向进程发送2号信号,来终止进程的。下面我们就来证明一下:

        我们使用自定义函数,将信号进行捕捉:signal

#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
 
RETURN VALUE
signal() returns the previous value of the signal handler, or SIG_ERR on error.  In the event of an error, errno is set to indicate the cause.
#include <iostream>
#include <signal.h>
#include <unistd.h>
 
using namespace std;
 
void catchsig(int signum)
{
    cout << "进程捕捉到了一个信号:" << signum << " "
         << "pid"
         << " " << getpid() << endl;
}
 
int main()
{
    signal(SIGINT, catchsig);
 
    while (true)
    {
        cout << "我是一个进程,我正在运行"
             << " "
             << "pid"
             << " " << getpid() << endl;
        sleep(1);
    }
 
    return 0;
}

        通过对比上面两张图,我们发现Ctrl + c 和发送2号命令,都调用了我们自定义的处理动作。所以 Ctrl + c的本质就是发送2号命令。

3.3、核心转储

        上面的一张图,在信号的默认动作action中,term表示只终止进程,而还有的信号的动作是core,这个动作不仅会终止进程,还可以发生核心转储。这个与我们前面的进程等待的内容又有些关联了。

        上图是进程等待中,父进程获取子进程信息的status位图结构。低7位保存信号,之前有一个core dump标志,该比特位表示是否发生了核心转储。

        核心转储:当进程出现某种异常时,是否由os将当前进程在内存中的相关核心数据,转存到磁盘中。

        一般来说,云服务器上的核心转储功能是被关闭了的。而我们可以使用ulimit -a 命令查看core文件,ulimit -c 大小 命令打开云服务器的核心转储功能。

那么核心转储有什么作用呢?我们使用下面的代码来看看:

#include <iostream>
#include <signal.h>
#include <unistd.h>
 
using namespace std;
 
void catchsig(int signum)
{
    cout << "进程捕捉到了一个信号:" << signum << " "
         << "pid"
         << " " << getpid() << endl;
}
 
int main()
{
    signal(SIGQUIT, catchsig);
 
    while (true)
    {
        cout << "我是一个进程,我正在运行"
             << " "
             << "pid"
             << " " << getpid() << endl;
        sleep(1);
 
        int a = 100;
        a /= 0;
    }
 
    return 0;
}

运行代码后生成了core文件,且以进程pid为后缀。

        我们知道程序出错了,而有了core文件后,我们不用去一行一行找出错位置,使用core文件在gdb下可以直接定位出错位置,如下:

3.4、系统调用接口产生信号

3.4.1、kill

NAME
       kill - send signal to a process
 
SYNOPSIS
       #include <sys/types.h>
       #include <signal.h>
 
       int kill(pid_t pid, int sig);
 
   Feature Test Macro Requirements for glibc (see feature_test_macros(7)):
       kill(): _POSIX_C_SOURCE >= 1 || _XOPEN_SOURCE || _POSIX_SOURCE

        其实,我们常常使用的kill命令的底层所调用的就是该函数,下面我们可以模拟实现一下 kill命令的实现。

#include <iostream>
#include <cassert>
#include <sys/types.h>
#include <signal.h>
 
using namespace std;
 
static void Usage(const string &proc)
{
    cout << "\nUsage:" << proc << " pid signo\n"
         << endl;
}
 
//  ./mykill 2 pid
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }
    int signo = atoi(argv[1]);
    int sigpid = atoi(argv[2]);
    int n = kill(sigpid, signo);
    assert(n == 0);
 
    return 0;
}

3.4.2、raise

作用:进程让os给自己发送某一个信号。

NAME
       raise - send a signal to the caller
 
SYNOPSIS
       #include <signal.h>
 
       int raise(int sig);
 
DESCRIPTION
       The raise() function sends a signal to the calling process or thread.  In a single-threaded program it is equivalent to
       kill(getpid(), sig);
#include <iostream>
#include <cassert>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
 
int main()
{
    cout << "我开始运行了" << endl;
    sleep(2);
    raise(2);
 
    return 0;
}

3.4.3、abort 

作用:让os给自己发一个6号信号。其实abort的底层也是去调用 raise(6)去实现的。

NAME
       abort - cause abnormal process termination
 
SYNOPSIS
       #include <stdlib.h>
 
       void abort(void);
#include <iostream>
#include <cassert>
#include <unistd.h>
#include<stdlib.h>
#include <sys/types.h>
#include <signal.h>
 
using namespace std;
 
int main()
{
    cout << "我开始运行了" << endl;
    sleep(2);
    abort();
 
    return 0;
}

        所以,总的来说,系统调用接口产生信号的具体过程就是: 用户调用系统接口——os执行对应的代码——os向目标进程写入信号——修改信号对应的比特位——进程后续对信号进行处理。

3.5、软件条件产生信号

~ 管道

        在前面的进程间通信的管道中,我们讨论了一个问题:对于正在通信的两个进程,当管道的读端不读了,而且读端关闭了,但是写端一直在写。这时,写就没有任何意义了。我们验证了,在这个时候,os会通过发送13号信号的方式终止进程。因为管道是一个通过文件在内存级的实现,所以管道是一个软件,所以这种情况就是软件条件不满足而产生信号的一种情况。

~ 设置闹钟 alarm

#include <unistd.h>
unsigned int alarm(unsigned int seconds);

        调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号,该信号的默认处理动作是终止当前进程。这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。闹钟一旦触发了,将会自动移除。

我们可以使用该函数写一个能够测试自己的电脑CPU的计算能力的代码:

#include <iostream>
#include <cassert>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
 
using namespace std;
 
int count = 0;
 
void sigcath(int sig)
{
    cout << "final count: "
         << " " << count << endl;
}
 
int main()
{
    alarm(1);
    signal(SIGALRM, sigcath);
 
    while (true)
        count++;
 
    return 0;
}

我们也可以写一个代码来让os帮助我们每隔1秒就可以显示cout最新的计算结果

#include <iostream>
#include <cassert>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
 
using namespace std;
 
uint64_t count = 0;
 
void sigcath(int sig)
{
    cout << "final count: "
         << " " << count << endl;
 
        alarm(1);
}
 
int main()
{
    alarm(1);
    signal(SIGALRM, sigcath);
 
    while (true)
        count++;
 
    return 0;
}

3.6、硬件异常产生信号

~ 除0错误

我们来看一看下面的代码:

#include <iostream>
#include <cassert>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
 
using namespace std;
 
void hander(int sig)
{
    cout << "我捕捉了一个信号:"
         << " " << sig << endl;
    sleep(1);
}
 
int main()
{
    signal(SIGFPE, hander);
    int a = 100;
    a /= 0;
 
    return 0;
}

运行结果如下:

        我们知道了如果代码中出现了除0错误,os会给进程发送8号信号,那么是怎么产生并发送的呢?

        首先,计算以及各种信息的处理是由CPU这个硬件进行的。CPU中有一个寄存器,叫做状态寄存器,它含有一个位图,该位图上有溢出标记位。 CPU在进行计算时,发现代码中出现了除0错误,因此将溢出标记位由0改为1,进程异常,CPU将该进程切出。os会自动进行计算完成后,检测状态寄存器,当检查到溢出标记位为1时,os就会提取当前正在运行的进程的PID,给其发送8号信号。

        那么为什么会是死循环打印呢?

        上面讲到,溢出标记位由0改为1后,CPU就会将该进程切出,因为寄存器里面的数据是该进程的上下文,所以位图也会跟随进程一起切出。但是,我们虽然将信号进行了捕捉,但是并没有让进程退出,所以这个进程只是被切出去了,当CPU正常进行调度时,再次调度该进程,上下文恢复上去,os立马识别到了溢出标记位还是1,再次打印,如此反复。

所以,为了解决这个问题,我们要在捕捉函数最后加上 exit,让进程退出。

      ~ 野指针和越界访问

        我们知道,指针变量必须通过地址才能找到目标位置。而我们语言上的地址是虚拟地址,所以我们前面讲了通过页表将物理地址和虚拟地址建立联系。但是事实上,我们是通过页表+MMU(memory manger unit,一个硬件)的方式将物理地址和虚拟地址建立联系的,所以当代码中出现了野指针或者越界访问时,因为这是一个非法地址,那么MMU一定会报错,它会将自己内部的寄存器进行标识,os就能够检测到,且知道是哪个进程的地址转化出错了。

四、信号的保存

4.1、相关概念

a. 信号递达:进程对信号的处理动作称为信号递达。

b. 信号未决:信号从产生到递达之间的这个状态称为信号未决。

c. 信号阻塞:被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。

阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

4.2、信号保存——三个数据结构

        前面我们讲到,在进程的PCB中,存在一种位图是用来保存信号的,但是事实上有3种数据结构与信号是相关的。他们分别是pending位图,block位图,typedef void(*handler_t)(int signo),handler_t handler[32]={0}结构。

        pending位图:该位图就是我们常说的用来保存信号的位图。

        block位图:该位图比特位的位置与信号标号一一对应,比特位的内容代表该信号是否阻塞。

        typedef void(*handler_t)(int signo),handler_t handler[32]={0}:这个是一个函数指针数组,这个数组在内核中有指针指向它,这个数组称为当前进程所匹配的信号递达的所有方法,数组下标代表信号的编号,数组的每一个元素都是一个函数指针(存函数地址),指向信号对应的处理方法。

4.3、信号集——sigset_t

        上面讲到的三个结构都是属于进程PCB,是内核数据结构。所以os必定不会让用户直接访问这三个结构,更不能够让用户直接进行位移操作。那么如果用户想要得到pending和block位图该怎么办呢?于是,Linux就提供了一种数据类型信号集——sigset_t,用户可以直接使用。

4.3、信号操作函数

        既然Linux提供了信号集,那么必定也通过了与之相关的各种方法,让用户能够去操作,这样用户根本就不需要关系在内核中这些结构到底是怎么样的。下面的5个函数就是对信号集进行操作的函数。

#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);

sigpending:获取当前进程的 pending 信号集。信号发送的本质就是对pending位图进行修改。

NAME
       sigpending - examine pending signals
 
SYNOPSIS
       #include <signal.h>
       int sigpending(sigset_t *set);
 
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。

sigprocmask :读取或更改进程的信号屏蔽字(阻塞信号集) (block)

NAME
       sigprocmask - examine and change blocked signals
 
SYNOPSIS
       #include <signal.h>
 
       int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

下表说明了how参数的可选值及其作用:

选项作用
SIG_BLOCKset包含了我们希望添加到当前信号屏蔽字的信号
SIG_UNBLOCKset包含了我们希望从当前信号屏蔽字中解除阻塞的信号
SIG_SETMASK设置当前信号屏蔽字为set所指向的信号

注:9号信号是不能被捕捉或阻塞的。 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/645755.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

网络编程-TCP

一、TCP的相关IP 1.1 SeverSocket 这是Socket类,对应到网卡,但是这个类只能给服务器使用. 1.2 Socket 对应到网卡,既可以给服务器使用,又可以给客户端使用. TCP是面向字节流的,传输的基本单位是字节. TCP是有连接的,和打电话一样,需要客户端拨号,服务器来听. 服务器的内核…

【Linux】为 VMware 的 Linux 系统(CentOS 7)设置静态IP地址

文章目录 准备工作查看 子网掩码 和 网关IP确认准备设置的虚拟机端口没有被占用 调整设置编辑配置文件配置文件说明 完成配置&#xff0c;准备测试使用命令终端连接服务器 我是一名立志把细节说清楚的博主&#xff0c;欢迎【关注】&#x1f389; ~ 原创不易&#xff0c; 如果有…

redis--消息队列

分类 生产者消费模式 发布者订阅模式 生产者消费模式 在生产者消费者(Producer/Consumer)模式下&#xff0c;上层应用接收到的外部请求后开始处理其当前步骤的操作&#xff0c;在执行完成后将已经完成的操作发送至指定的频道(channel)当中&#xff0c;并由其下层的应用监听…

开发公众号自定义菜单之创建菜单

文章目录 申请测试账号换取Token接口测试提交自定义菜单查看效果校验菜单配置清空菜单配置结束语 申请测试账号 https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?actionshowinfo&tsandbox/index 或 得到appid和secret 换取Token 使用appid和secret换取token令牌…

嵌入式实时操作系统笔记1:RTOS入门_理解简单的OS系统

今日开始学习嵌入式实时操作系统RTOS&#xff1a;UCOS-III实时操作系统 本次目标是入门RTOS&#xff0c;理解多任务系统...... 本文只是个人学习笔记&#xff0c;基本都是对网上资料的整合...... 目录 STM32裸机与RTOS区别&#xff1a; 裸机中断示例&#xff1a; RTOS对优先级…

飞鸡:从小训练飞行的鸡能飞行吗?为什么野鸡能飞吗?是同一品种吗?今天自由思考

鸡的飞行能力在很大程度上受到其生理结构的限制。尽管鸡有翅膀&#xff0c;但与能够长时间飞行的鸟类相比&#xff0c;鸡的翅膀相对较小&#xff0c;且胸部肌肉较弱。再加上鸡的身体较重&#xff0c;这些因素共同限制了鸡的飞行能力。通常&#xff0c;鸡只能进行短暂的、低空的…

Redis简介与安装到python的调用

前言 本文只不对redis的具体用法做详细描述&#xff0c;做简单的介绍&#xff0c;安装&#xff0c;和python代码调用详细使用教程可查看一下网站 https://www.runoob.com/redis/redis-tutorial.html https://pypi.org/project/redis/ 官方原版: https://redis.io/ 中文官网:…

【698协议】帧校验算法

698协议&#xff0c;帧校验算法 帧格式 帧校验范围 校验算法 #include "fcs16.h" /* * u16 represents an unsigned 16-bit number. Adjust the typedef for * your hardware. * Drew D. Perkins at Carnegie Mellon University. * Code liberally borrowed from M…

GB报文中的Cseq值的注意点

一、 问题现象 【问题现象】NVR使用GB接三方平台发现倍速回放时&#xff0c; 【现场拓扑】现场拓扑如下 &#xff08;1&#xff09; NVR侧使用家用宽带的方式&#xff0c;通过国标跨公网接入三方平台。 图1.1&#xff1a;网络拓扑 二、 抓包分析 INVITE sip:420000004013200…

异步获取线程执行结果,JDK中的Future、Netty中的Future和Promise对比

JDK中的Future和Netty中的Future、Promise的关系 三者源头追溯 Netty中的Future与JDK中的Future同名&#xff0c;但是是两个不同的接口。Netty中的Future继承自JDK的Future&#xff0c;而Promise又对Netty中的Future进行了扩展。 JDK中的Future源自JUC并发包&#xff1a; Net…

【LeetCode刷题】滑动窗口思想解决问题:长度最小的子数组、无重复字符的最长子串

【LeetCode刷题】Day 7 题目1&#xff1a;209.长度最小的子数组思路分析&#xff1a;思路1&#xff1a;暴力枚举 O(N^2^)思路2&#xff1a;滑动窗口 O(N) 题目2&#xff1a;3. 无重复字符的最长子串题目分析&#xff1a;思想1&#xff1a;暴力枚举哈希表O(N^2^)思想2&#xff1…

鹏特资本进入中国市场具有以下一些优势

1. 带来资金&#xff1a;补充国内资金缺口&#xff0c;为企业发展和项目建设提供重要的资金支持。 2. 先进技术和管理经验&#xff1a;有助于推动技术创新和管理水平提升&#xff0c;促进产业升级和优化。 3. 促进竞争&#xff1a;激发国内市场活力&#xff0c;促使本土企业不…

Spring Cloud 项目中使用 Swagger

Spring Cloud 项目中使用 Swagger 关于方案的选择 在 Spring Cloud 项目中使用 Swagger 有以下 4 种方式&#xff1a; 方式一 &#xff1a;在网关处引入 Swagger &#xff0c;去聚合各个微服务的 Swagger。未来是访问网关的 Swagger 原生界面。 方式二 &#xff1a;在网关处引…

软件设计师笔记2

文章目录 软考知识点总结1. 计算机组成原理网络与信息安全数据结构与算法AOE网 编译原理操作系统软件设计软件测试数据库计算机软件产权其它 软考知识点总结 1. 计算机组成原理 cpu控制器&#xff0c;专门产生指令操作&#xff0c;送到计算机各个部位执行处理 DMA&#xff08…

ISCC2024个人挑战赛WP-WEB

&#xff08;非官方解&#xff0c;以下内容均互联网收集的信息和个人思路&#xff0c;仅供学习参考&#xff09; 还没想好名字的塔防游戏 GET /world.js HTTP/1.1 Host: 101.200.138.180:17345 Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,i…

LeetCode题练习与总结:二叉树的层序遍历Ⅱ--107

一、题目描述 给你二叉树的根节点 root &#xff0c;返回其节点值 自底向上的层序遍历 。 &#xff08;即按从叶子节点所在层到根节点所在的层&#xff0c;逐层从左向右遍历&#xff09; 示例 1&#xff1a; 输入&#xff1a;root [3,9,20,null,null,15,7] 输出&#xff1a;[…

嵌入式进阶——LED呼吸灯(PWM)

&#x1f3ac; 秋野酱&#xff1a;《个人主页》 &#x1f525; 个人专栏:《Java专栏》《Python专栏》 ⛺️心若有所向往,何惧道阻且长 文章目录 PWM基础概念STC8H芯片PWMA应用PWM配置详解占空比 PWM基础概念 PWM全称是脉宽调制&#xff08;Pulse Width Modulation&#xff09…

C++笔试强训day33

目录 1.跳台阶扩展问题 2.包含不超过两种字符的最长子串 3.字符串的排列 1.跳台阶扩展问题 链接https://www.nowcoder.com/practice/953b74ca5c4d44bb91f39ac4ddea0fee?tpId230&tqId39750&ru/exam/oj 我是用动态规划解决的&#xff1a; #include <iostream>…

一文读懂:http免费升级https

背景&#xff1a; 随着现在全民网络安全意识的日益提升&#xff0c;各个网站需要实现的https数量也随之提升&#xff0c;那么如何将原本网站的http访问方式升级为https呢&#xff1f; 该内容为如何免费将网站的http访问升级为https访问 论https的加密逻辑&#xff1a; 步骤 …

【数据结构(邓俊辉)学习笔记】图01——图的表示与实现

文章目录 1. 概述1.1 邻接 关联1.2 无向 有向1.3 路径 环路 2. 邻接矩阵2.1 接口2.2 邻接矩阵 关联矩阵2.3 实例2.4 顶点和边2.5 邻接矩阵2.6 顶点静态操作2.7 边操作2.7 顶点动态操作2.8 综合评价 1. 概述 1.1 邻接 关联 相对于此前的线性以及半线性结构&#xff0c;图…