csapp之第8章:异常控制流

<h1 >0 理解异常控制流</h1>

<p>作为程序员,理解异常控制流(Exceptional Control Flow)ECF很重要,原因:</p>

<ul>

<li><p>理解ECF将帮助你理解重要的系统概念。ECF是操作系统实现I/O、进程和虚拟内存的基本机制</p>

</li>

<li><p>理解ECF将帮助你理解应用和系统是如何交互的。程序通过<code>trap</code>或<code>syscall</code>的ECF形式,向系统请求服务</p>

</li>

<li><p>理解ECF将帮助编写有趣的新应用程序</p>

</li>

<li><p>理解ECF将有助于理解并发,ECF是系统中实现并发的基本机制。在运行中的并发的例子:</p>

<ul>

<li>中断程序执行的异常处理程序</li>

<li>时间上重叠执行的进程和线程</li>

<li>中断应用程序执行的异常处理程序</li>

</ul>

</li>

</ul>

<h1 >1 异常控制流</h1>

<h2 >1.1 控制流</h2>

<p>从开机到关机,处理器做的工作很简单,每个CPU内核只是简单地读和执行指令,每次一条。整个指令执行的序列就是CPU<strong>控制流</strong></p>

<p>前面已经学过两种改变控制流的方式:</p>

<ul>

<li>跳转和分支</li>

<li>调用和返回</li>

</ul>

<p>只适用于程序状态的改变,很难应对系统状态的改变,比如:</p>

<ul>

<li>数据从磁盘或网络适配器读取</li>

<li>指令除零</li>

<li>用户按ctrl-c</li>

<li>系统定时器超时</li>

</ul>

<p>因此系统需要叫做<strong>异常控制流</strong>的机制。它存在于系统每个层面:</p>

<ul>

<li><p><strong>底层机制</strong>:</p>

<ul>

<li><strong>异常(Exceptions)</strong>:用以响应系统事件,通常由硬件和操作系统实现</li>

</ul>

</li>

<li><p><strong>高层机制</strong>:</p>

<ul>

<li><strong>进程切换(Process Context Switch)</strong>:系统软件和硬件定时器实现</li>

<li><strong>信号(Signal)</strong>:系统软件实现</li>

<li><strong>非本地跳转(Nonlocal Jumps)</strong>:包括setjmp()和longjmp()。C运行时库实现</li>

</ul>

</li>

</ul>

<h1 >2 异常</h1>

<p>异常指的是<strong>把控制权交给系统内核以响应某些事件</strong>(如处理器状态的改变),系统内核是操作系统常驻内存的一部分,响应的事件包括:除零、运算溢出、页错误、IO请求完成或用户按ctrl-c等系统级别的事件。过程如图:</p>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119122413641.png" referrerpolicy="no-referrer" alt="image-20220119122413641"></p>

<p>注意:<strong>中断</strong>和<strong>陷阱</strong>(最重要用途是<strong>syscall</strong>)都是返回到下条指令,<strong>故障</strong>若被处理程序修正错误则返回当前指令重新执行,否则返回到内核中<strong>abort例程</strong>,它会<strong>终止</strong>应用程序,它从不将控制权返回给应用程序。</p>

<p>每种事件都对应唯一的异常编号,当异常发生时,系统通过查找异常表(Exception Table)中对应的异常编号确定异常处理代码,过程如图:</p>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119122532519.png" referrerpolicy="no-referrer" alt="image-20220119122532519"></p>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119122613855.png" referrerpolicy="no-referrer" alt="image-20220119122613855"></p>

<h2 >2.1 异步异常(中断)</h2>

<p>异步异常(Asynchronous Exception)也叫<strong>中断</strong>(Interrupt),是由处理器外部事件引起。对执行程序而言,“中断”的发生完全是<strong>异步</strong>的,因为不知道什么时候会发生。CPU对其响应也是被动的,但可以屏蔽。这种情况下:</p>

<ul>

<li>需设置CPU中断指针</li>

<li>处理完后返回之前控制流下条指令</li>

</ul>

<p>常见中断有:</p>

<ul>

<li>计时器中断:每隔几毫秒外部定时器芯片就会触发一次中断,被内核用来从用户程序拿回控制权</li>

<li>I/O中断:类型较多:如键盘输入ctrl-c、来自网络中包到达、来自磁盘的数据到达</li>

</ul>

<h2 >2.2 同步异常</h2>

<p>同步异常(Synchronous Exceptions)由执行指令的结果导致的事件,包括三类:</p>

<figure><table>

<thead>

<tr><th style='text-align:center;' >类型</th><th style='text-align:center;' >说明</th><th style='text-align:center;' >行为</th><th style='text-align:center;' >示例</th></tr></thead>

<tbody><tr><td style='text-align:center;' >Trap</td><td style='text-align:center;' >为某事有意设置</td><td style='text-align:center;' >返回到先前下条指令</td><td style='text-align:center;' >系统调用、调试断点</td></tr><tr><td style='text-align:center;' >Fault</td><td style='text-align:center;' >潜在可恢复错误</td><td style='text-align:center;' >返回当前指令或终止</td><td style='text-align:center;' >页故障(page faults)</td></tr><tr><td style='text-align:center;' >Abort</td><td style='text-align:center;' >不可恢复的错误</td><td style='text-align:center;' >终止当前执行的程序</td><td style='text-align:center;' >非法指令、硬件错误</td></tr></tbody>

</table></figure>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119122824882.png" referrerpolicy="no-referrer" alt="image-20220119122824882"></p>

<h2 >2.3 系统调用</h2>

<p>在X86-64系统中,每个系统调用都有唯一的ID号,如:</p>

<figure><table>

<thead>

<tr><th style='text-align:center;' >编号</th><th style='text-align:center;' >名称</th><th style='text-align:center;' >说明</th></tr></thead>

<tbody><tr><td style='text-align:center;' >0</td><td style='text-align:center;' ><code>read</code></td><td style='text-align:center;' >读取文件</td></tr><tr><td style='text-align:center;' >1</td><td style='text-align:center;' ><code>write</code></td><td style='text-align:center;' >写入文件</td></tr><tr><td style='text-align:center;' >2</td><td style='text-align:center;' ><code>open</code></td><td style='text-align:center;' >打开文件</td></tr><tr><td style='text-align:center;' >3</td><td style='text-align:center;' ><code>close</code></td><td style='text-align:center;' >关闭文件</td></tr><tr><td style='text-align:center;' >4</td><td style='text-align:center;' ><code>stat</code></td><td style='text-align:center;' >文件信息</td></tr><tr><td style='text-align:center;' >57</td><td style='text-align:center;' ><code>fork</code></td><td style='text-align:center;' >创建进程</td></tr><tr><td style='text-align:center;' >59</td><td style='text-align:center;' ><code>execve</code></td><td style='text-align:center;' >执行程序</td></tr><tr><td style='text-align:center;' >60</td><td style='text-align:center;' ><code>_exit</code></td><td style='text-align:center;' >关闭进程</td></tr><tr><td style='text-align:center;' >62</td><td style='text-align:center;' ><code>kill</code></td><td style='text-align:center;' >发送信号</td></tr></tbody>

</table></figure>

<h3 >2.3.1 系统调用示例:open</h3>

<p>用户调用open打开文件,系统实际通过__open函数执行编号为2的syscall,返回值为负则出错,汇编代码如图:</p>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119123200826.png" referrerpolicy="no-referrer" alt="image-20220119123200826"></p>

<h3 >2.3.2 故障示例</h3>

<p>以Page Fault为例说明,Page Fault发生的前提是:用户写入内存位置,但该位置暂时不在内存,系统荣过Page Fault异常把对应的页从磁盘拷贝到内存,代码和流程如图:</p>

<pre><code class='language-c' lang='c'>int a[1000];

int main()

{

    a[500] = 13;

}

</code></pre>

<p>但若代码改成非法地址,则整个流程会变成如图:</p>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119123418124.png" referrerpolicy="no-referrer" alt="image-20220119123418124"></p>

<p>系统向用户进程发送SIGSEGV信号,用户进程以segmentation fault的标记退出。</p>

<h1 >3 进程</h1>

<p>进程是程序的运行实例,它是计算机科学中最重要的思想之一,进程给每个程序提供两个关键的抽象,使得具体的进程不需操心处理器和内存等细节,也保证不同情况下运行同样的程序能得到相同的结果。两个关键抽象如下:</p>

<ul>

<li>逻辑控制流:通过上下文切换(context switching)的内核机制让每个程序都感觉在独占处理器</li>

<li>私有地址空间。通过虚拟内存(virtual memory)的机制让每个程序都感觉在独占内存</li>

</ul>

<p>尽管和每个私有地址空间相关联的内存的内容一般不同,但每个这样的空间都有相同的通用结构,如图:</p>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119123450788.png" referrerpolicy="no-referrer" alt="image-20220119123450788"></p>

<h2 >3.1 多进程</h2>

<p>多进程就是计算机同时运行多个进程,如web浏览器、email客户端、编辑器、检测网络和IO设备等。分为两大类:</p>

<ul>

<li>传统:单处理器交错执行多个进程(但可把并发进程看作并行运行),虚拟内存系统管理地址空间,未运行进程的寄存器值存储在内存中,以便下次执行时恢复(即上下文切换),切换时会载入已保存的将要执行的进程的寄存器值</li>

<li>现代:现代处理器都有多个核心,多核处理器即单个芯片有多个CPU,共享主要内存和某些缓存,具体调度由内核控制,每个CPU都可执行单独进程</li>

</ul>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119123527065.png" referrerpolicy="no-referrer" alt="image-20220119123527065"></p>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119123555006.png" referrerpolicy="no-referrer" alt="image-20220119123555006"></p>

<p>进程切换时,由内核负责调度,如图:</p>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119123627743.png" referrerpolicy="no-referrer" alt="image-20220119123627743"></p>

<h2 >3.2 用户态模式和内核模</h2>

<p>处理器提供限制应用可执行的指令及可访问的地址空间范围的机制,通常用某个控制寄存器中的一个<strong>模式位</strong>(mode biit)提供,设置模式位时,进程就运行在内核模式,可执行任何指令且访问系统任何内存位置,否则是用户模式,<strong>必须通过系统调用接口间接访问内核代码和数据</strong>。</p>

<p>进程初始时在用户模式,改变为内核模式的唯一方法是通过中断、陷入系统调用、故障这样的异常,异常发生时,控制传递到异常处理程序,处理器将用户模式变为内核模式,程序在内核模式中运行,当控制返回到进程时,处理器将模式从内核模式变为用户模式</p>

<h2 >3.3 上下文切换</h2>

<p>内核用一种被称为<strong>上下文切换</strong>(context switch)的较高层形式的异常控制流实现多任务,是建立在较低层异常机制上的。内核为每个进程维持一个上下文(context),即重新启动被抢占的进程所需的状态:寄存器、用户栈、内核栈及各种内核数据结构(如页表、进程表、已打开文件的信息的文件表)</p>

<p>内核可决定抢占当前进程,重新开始先前被抢占的进程,该决策叫调度(scheduling),由内核中的调度器(scheduler)处理。内核调度新的进程运行后,抢占当前进程,使用上下文切换机制将控制权转移到新进程:</p>

<ol>

<li>保存当前进程上下文</li>

<li>恢复先前被抢占进程保存的上下文</li>

<li>控制权传递给该新恢复的进程</li>

</ol>

<h1 >4 进程控制</h1>

<h2 >4.1 系统调用错误处理</h2>

<p>出错时,Linux系统函数通常返回-1且设置全局变量errno表示错误原因。使用系统函数时牢记两个原则:</p>

<ul>

<li>每个系统调用都应检查返回值</li>

<li>唯一例外是少数返回void的函数</li>

</ul>

<p>如对于fork()函数,应检查返回值:</p>

<pre><code class='language-c' lang='c'>if ((pid = fork()) &lt; 0) {

fprintf(stderr, &quot;fork error: %s\n&quot;, strerror(errno));

exit(-1);

}

</code></pre>

<h2 >4.2 错误处理包装</h2>

<p>若嫌麻烦可用下面错误处理包装函数,可进一步简化代码,Stevens首先提出该方法,定义相同参数的包装函数但首字母大写,包装函数内调用基本函数并检查错误:</p>

<pre><code class='language-c' lang='c'>void unix_error(char *msg) /* Unix-style error */

{

fprintf(stderr, &quot;%s: %s\n&quot;, msg, strerror(errno));

exit(-1);

}

//则fork()返回错误值的部分可改写为

if ((pid = fork()) &lt; 0)

unix_error(&quot;fork error&quot;);

</code></pre>

<p>更进一步,可把整个fork()包装起来,就可自带错误处理,如:</p>

<pre><code class='language-c' lang='c'>pid_t Fork(void)

{

pid_t pid;

if ((pid = fork()) &lt; 0)

unix_error(&quot;Fork error&quot;);

return pid;

}

pid = Fork();//调用时则直接调用包装的函数

</code></pre>

<p>&nbsp;</p>

<h2 >4.3 获取进程ID</h2>

<p>使用以下两个函数获取进程相关信息:</p>

<ul>

<li><code>pid_t getpid(void)</code>:返回当前进程PID</li>

<li><code>pid_t getppid(void)</code> - 返回当前进程的父进程的 PID</li>

</ul>

<h2 >4.4 进程生命周期</h2>

<p>从程序员的角度看,可将进程看作是处于三种状态之一:</p>

<figure><table>

<thead>

<tr><th style='text-align:left;' >状态</th><th style='text-align:left;' >说明</th></tr></thead>

<tbody><tr><td style='text-align:left;' >运行 Running</td><td style='text-align:left;' >进程要么正运行,要么等待被执行或最终将会被内核调度执行</td></tr><tr><td style='text-align:left;' >停止 Stopped</td><td style='text-align:left;' >进程执行被挂起(suspended),并且在进一步收到<code>SIGCONT</code>信号前不会被调度执行</td></tr><tr><td style='text-align:left;' >终止 Terminated</td><td style='text-align:left;' >进程被永久停止</td></tr></tbody>

</table></figure>

<p>当然还包括新建(new)和就绪(ready),待补充。</p>

<h3 >4.4.1 终止进程</h3>

<p>包括三种情况:</p>

<ul>

<li>接收到终止信号</li>

<li>从<code>main</code>函数返回</li>

<li>调用<code>exit</code>函数,注意该函数被调用一次但从不返回</li>

</ul>

<h3 >4.4.2 创建进程</h3>

<p>父进程调用<code>fork</code>创建新进程,注意该函数执行一次,但返回两次,子进程返回0,父进程返回子进程PID,函数原型:</p>

<pre><code class='language-c' lang='c'>// 子进程,返回 0,父进程,返回子进程的 PID

int fork(void)

</code></pre>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119123702412.png" referrerpolicy="no-referrer" alt="image-20220119123702412"></p>

<p>注意:子进程和父进程几乎完全相同,子进程获得父进程虚拟地址空间相同但独立的副本,获得相同的父进程打开的文件描述符拷贝,但子进程有和父进程不同的PID</p>

<p>完全拷贝执行状态:</p>

<ul>

<li>指定一个为父,一个为子</li>

<li>恢复父或子的执行</li>

</ul>

<h3 >4.4.3 重新审视<code>fork</code>函数</h3>

<ul>

<li>虚拟内存和内存映射解释<code>fork</code>如何为每个进程提供私有地址空间</li>

<li>创建当前进程的<code>mm_struct</code>、<code>vm_area_struct</code>和页表的精确拷贝,标记每个进程的每个页为只读,标记每个进程的每个<code>vm_area_struct</code>为私有COW,每个进程有精确的虚拟内存拷贝</li>

<li>后续写操作使用COW机制创建新页</li>

</ul>

<h3 >4.4.4 <code>fork</code>示例</h3>

<pre><code class='language-c' lang='c'>int main()

{

    pid_t pid;

    int x = 1;

    pid = Fork();

    if (pid == 0) {  // Child

        printf(&quot;I&#39;m the child!  x = %d\n&quot;, ++x);

        return 0;

    }


    // Parent

    printf(&quot;I&#39;m the parent! x = %d\n&quot;, --x);

    return 0;

}

</code></pre>

<p>注意:</p>

<ul>

<li>调用一次返回两次</li>

<li>并行执行,无法预计父进程和子进程执行顺序,因此执行结果有两种</li>

<li>双方有相同的但独立的地址空间(变量独立)</li>

<li>共享文件,继承所有打开文件,如都把它们的输出显示在屏幕,因为父调fork时,stdout是打开,子继承并输出到指向的屏幕</li>

</ul>

<p>补充:</p>

<ul>

<li>问题:Linux调度器不会产生太多run-to-run的变化,在不确定中隐藏潜在的竞争关系,如<code>fork</code>是先返回child还是parent?</li>

<li>解决:创建自定义的库例程,在不同的分支中插入随机延迟;使用运行时定位使程序使用特殊版本的库代码</li>

</ul>

<pre><code class='language-c' lang='c'>/* fork wrapper function */

pid_t fork(void) {

initialize();

int parent_delay = choose_delay();

int child_delay = choose_delay();

pid_t parent_pid = getpid();

pid_t child_pid_or_zero = real_fork();

if (child_pid_or_zero &gt; 0) {

/* Parent */

if (verbose) {

printf(&quot;Fork. Child pid=%d, delay = %dms. Parent pid=%d, delay = %dms\n&quot;,child_pid_or_zero, child_delay,parent_pid, parent_delay);

fflush(stdout);

}

ms_sleep(parent_delay);

} else {

/* Child */

ms_sleep(child_delay);

}

return child_pid_or_zero;

}

</code></pre>

<p>&nbsp;</p>

<h2 >4.5 进程图</h2>

<p>进程图是一个很有用的工具,可捕获并发程序中部分语句的顺序:For the process graph, as long as topological sorting is satisfied, it is a possible output</p>

<ul>

<li>每个节点表示一条执行的语句</li>

<li>a -&gt; b 表示 a 在 b 前面执行</li>

<li>边可以用当前变量的值来标记</li>

<li><code>printf</code> 节点可用输出来标记</li>

<li>每个图由一个入度为 0 的点起始</li>

</ul>

<p>对于进程图来说,只要满足拓扑排序,就是可能的输出。嵌套的<code>fork</code>示例:</p>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119123906902.png" referrerpolicy="no-referrer" alt="image-20220119123906902"></p>

<p>&nbsp;</p>

<h2 >4.6 回收子进程</h2>

<h3 >4.6.1 回收子进程</h3>

<ul>

<li>定义:即使进程已终止,但还未被回收的进程还在消耗系统资源,如<code>exit</code>状态、系统表,称之为僵尸进程,”半死不活“。</li>

<li>回收:可以采用回收(Reaping) 的方法。父进程用 <code>wait</code> 或 <code>waitpid</code> 回收已终止的子进程,然后父进程给系统提供退出状态信息,kernel 就会删除 zombie child process。</li>

<li>父进程不回收:若父进程不回收子进程的话,通常会被 <code>init</code> 进程(pid == 1)回收(所以一般不必显式回收),除非ppid==1,然后需要重启。所以仅长期运行的进程,需要显式回收(例如 shells 和 servers)。</li>

</ul>

<h3 >4.6.2 僵尸进程和孤儿进程示例</h3>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119124008845.png" referrerpolicy="no-referrer" alt="image-20220119124008845"></p>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119124040020.png" referrerpolicy="no-referrer" alt="image-20220119124040020"></p>

<p><strong>孤儿进程</strong>指子进程正运行,父进程突然退出,子进程就是孤儿进程。进程都需要一父进程,否则进程退出后无法回收进程描述符,消耗资源,该进程会找到一个父进程,若所在进程组没进程收养,就作为<code>init</code>进程的子进程</p>

<h3 >4.6.3 <code>wait</code>和<code>waitpid</code></h3>

<ul>

<li><code>wait</code>:父进程回收子进程调用该函数,函数声明为:<code>int wait(int *child_status)</code>,由<code>syscall</code>实现,等待当前进程直到它的一个子进程终止,返回终止进程的PID,如果<code>child_status</code>非空,那么它所指向的整数将被设置为表明子进程终止的原因和退出状态的值,可用<code>WIFEXITED</code>等宏检查,shlab中有用到。若有多个孩子进程则按任意顺序,同样可用宏检查退出状态。</li>

</ul>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119141030777.png" referrerpolicy="no-referrer" alt="image-20220119141030777"></p>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119141050821.png" referrerpolicy="no-referrer" alt="image-20220119141050821"></p>

<pre><code class='language-c' lang='c'>void fork10() {

pid_t pid[N];

int i, child_status;

for (i = 0; i &lt; N; i++)

if ((pid[i] = fork()) == 0) {

exit(100+i); /* Child */

}

for (i = 0; i &lt; N; i++) { /* Parent */

pid_t wpid = wait(&amp;child_status);

if (WIFEXITED(child_status))

printf(&quot;Child %d terminated with exit status %d\n&quot;,

wpid, WEXITSTATUS(child_status));

else

printf(&quot;Child %d terminate abnormally\n&quot;, wpid);

}

}

</code></pre>

<p>注意:若多个子进程完成,按任意顺序;可用宏<code>WIFEXITED</code>和<code>WEXITSTATUS</code>获取退出状态的信息</p>

<ul>

<li><code>waitpid</code>:暂停当前进程直到进程描述符为pid的进程终止,函数声明为:<code>pid_t waitpid(pid_t pid, int *status, int options)</code></li>

</ul>

<pre><code class='language-c' lang='c'>void fork11() {

pid_t pid[N];

int i;

int child_status;


for (i = 0; i &lt; N; i++)

if ((pid[i] = fork()) == 0)

exit(100+i); /* Child */

for (i = N-1; i &gt;= 0; i--) {

pid_t wpid = waitpid(pid[i], &amp;child_status, 0);

if (WIFEXITED(child_status))

printf(&quot;Child %d terminated with exit status %d\n&quot;,

wpid, WEXITSTATUS(child_status));

else

printf(&quot;Child %d terminate abnormally\n&quot;, wpid);

}

}

</code></pre>

<p>可修改函数中的options为:WNOHANG、WUNTRACED、WCONTINUED各种组合修改默认行为。同样也可像<code>wait</code>一样,若<code>status</code>参数非空,该函数就会在<code>status</code>指向的地方放上导致返回的子进程的状态信息,可用WIFEXITED、WEXITSTATUS、WIFSIGNALED、WITERMSIG、WIFSTOPPED、WSTOPSIG、WIFCONTINUED宏检查已回收子进程的退出状态。</p>

<h3 >4.6.4 execve</h3>

<p>想在进程载入其他的程序,就需要该函数。函数声明:<code>int execve(char *filename, char *argv[], char *envp[])</code>,执行<code>filename</code>的程序(可以是二进制文件或脚本),参数和环境变量分别为<code>argv</code>和<code>envp</code>,重写code、data和stack,保留PID、打开文件和信号上下文,调用一次且不返回除非出错。</p>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119140326232.png" referrerpolicy="no-referrer" alt="image-20220119140326232"></p>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119140351196.png" referrerpolicy="no-referrer" alt="image-20220119140351196"></p>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119140414419.png" referrerpolicy="no-referrer" alt="image-20220119140414419"></p>

<h1 >5 signal</h1>

<h2 >5.1 信号</h2>

<p>已经学习硬件和软件合作提供基本的底层异常机制,也看到利用异常来支持进程上下文切换的异常控制流形式,更高层的软件形式的异常被称为信号,允许进程和内核中断其他进程。</p>

<p><strong>信号</strong>提醒进程一个事件已经发生,类似异常和中断,异常和中断是从硬件发往内核,信号由内核(在其他进程的请求下)向进程发出,可能异步也可能同步发生。例如:硬件异常、硬件中断、另一个进程中的事件、另一个进程的显式请求。</p>

<p>每个信号都有name和ID,大多信号能被进程处理,就像中断处理程序,函数指针表,每个进程都有一个默认的动作,若信号没被处理,通常要么被忽略(ignore)要么中断进程(terminate process)</p>

<p>常用信号的编号及简介:</p>

<figure><table>

<thead>

<tr><th>事件</th><th>名称</th><th>ID</th><th>默认动作</th></tr></thead>

<tbody><tr><td>用户按ctrl-c</td><td>SIGINT</td><td>2</td><td>终止</td></tr><tr><td>强制中断(不能被处理)</td><td>SIGKILL</td><td>9</td><td>终止</td></tr><tr><td>段冲突</td><td>SIGSEGV</td><td>11</td><td>终止且dump</td></tr><tr><td>时钟信号</td><td>SIGALRM</td><td>14(可变)</td><td>终止</td></tr><tr><td>子进程停止或终止</td><td>SIGCHLD</td><td>17(可变)</td><td>忽略</td></tr></tbody>

</table></figure>

<h2 >5.2 信号概念</h2>

<h3 >5.2.1 发送和传递</h3>

<p>当事件发生时,内核<strong>发送</strong>信号,如硬件异常、硬件中断、另一个进程发生某些事(如exit)、另一个进程要求发送信号。</p>

<p>当内核使目标进程对信号作出响应时,内核<strong>传递</strong>(delivers)信号。如执行处理程序、执行默认操作。</p>

<p>发送和传递信号之间可能存在延迟,通常是因为进程不能立刻被调度,延迟期间,信号处于待处理(pending)状态</p>

<h3 >5.2.2 待处理和阻塞信号</h3>

<p>如果信号已被发送但是未被接收,那么处于<strong>待处理状态</strong>(pending),注意:信号不排队,任何时刻一个类型至多只有一个待处理信号,因此若进程有一个类型为K的待处理的信号,则后续的被发送给进程的类型为K的信号都被直接丢弃。进程也可以<strong>阻塞</strong>特定信号的接收,直到信号被解除阻塞。</p>

<h3 >5.2.3 接收信号</h3>

<p>目标进程以某种方式对信号的传递做出响应时,进程<strong>接收</strong>信号,可能响应的操作:</p>

<ul>

<li><strong>忽略</strong>(ignore):忽略该型号,不作任何响应</li>

<li><strong>终止</strong>(terminate):终止进程,可能core dump</li>

<li><strong>捕获</strong>(catch):执行用户层的函数:信号处理器(signal handler),类似响应异步中断而调用的硬件异常处理程序(exception handler)</li>

</ul>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119140511533.png" referrerpolicy="no-referrer" alt="image-20220119140511533"></p>

<h3 >5.2.4 待处理和阻塞位</h3>

<p>内核在每个进程的上下文中维护等待(pending)和阻塞(blocked)位集合</p>

<ul>

<li>等待信号集合:表示等待信号集合,当类型为k的信号被传递,内核将位k置为pending,当接收到类型为k的信号时,内核清除待处理的位k,任何时刻每种类型为k的信号只有一个</li>

<li>阻塞信号集合:表示阻塞信号集合,可用<code>sigprocmask</code>函数设置和清除,也称为信号掩码</li>

</ul>

<h2 >5.3 进程组</h2>

<p>每个进程都只属于一个进程组,如图:</p>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119141214993.png" referrerpolicy="no-referrer" alt="image-20220119141214993"></p>

<p>一般使用如下函数:</p>

<ul>

<li><code>getpgrp()</code> - 返回当前进程的进程组</li>

<li><code>setpgid()</code> - 设置一个进程的进程组</li>

</ul>

<p>可以通过 <code>kill</code> 来发送信号给进程组或进程(包括自己),如图:</p>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119141233949.png" referrerpolicy="no-referrer" alt="image-20220119141233949"></p>

<pre><code class='language-c' lang='c'>void fork12()

{

pid_t pid[N];

int i;

int child_status;

for (i = 0; i &lt; N; i++)

if ((pid[i] = fork()) == 0) {

/* Child: Infinite Loop */

while(1)

;

}

for (i = 0; i &lt; N; i++) {

printf(&quot;Killing process %d\n&quot;, pid[i]);

kill(pid[i], SIGINT);

}

for (i = 0; i &lt; N; i++) {

pid_t wpid = wait(&amp;child_status);

if (WIFEXITED(child_status))

printf(&quot;Child %d terminated with exit status %d\n&quot;,

wpid, WEXITSTATUS(child_status));

else

printf(&quot;Child %d terminated abnormally\n&quot;, wpid);

}

}

</code></pre>

<p>&nbsp;</p>

<h3 >5.3.1 从键盘发送信号</h3>

<p>可通过键盘让内核向每个前台进程发送 SIGINT(SIGTSTP) 信号</p>

<ul>

<li>SIGINT - <code>ctrl+c</code> 默认终止进程</li>

<li>SIGTSTP - <code>ctrl+z</code> 默认停止(挂起)进程</li>

</ul>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119141804157.png" referrerpolicy="no-referrer" alt="image-20220119141804157"></p>

<h3 >5.3.2 信号传递细节</h3>

<p>假定内核正从一个异常处理程序返回,且准备将控制权传给进程p,内核会计算进程 p 的 pnb 值:<code>pnb = pending &amp; ~blocked</code></p>

<ul>

<li><p>如果 <code>pnb == 0</code>,那么就把控制交给进程 p 的逻辑流中的下一条指令</p>

</li>

<li><p>否则</p>

<ul>

<li>选择 <code>pnb</code> 中最小的非零位 k,并强制进程 p 接收信号 k</li>

<li>接收到信号之后,进程 p 会执行对应的动作</li>

<li>对 <code>pnb</code> 中所有的非零位进行这个操作</li>

<li>最后把控制交给进程 p 的逻辑流中的下一条指令</li>

</ul>

</li>

</ul>

<p>每个信号类型都有一个默认动作,可能是以下的情况:</p>

<ul>

<li>忽略信号</li>

</ul>

<ul>

<li>终止进程并 dump core</li>

<li>停止进程,收到 <code>SIGCONT</code> 信号之后重启</li>

<li>若进程停止则进程重启</li>

</ul>

<h2 >5.4 信号控制</h2>

<h3 >5.4.1 注册信号处理程序</h3>

<p><code>sigaction</code>函数改变与接收信号相关的程序,原型是:<code>int sigaction(int signum,const struct sigaction *sa,struct sigaction *old_sa)</code>,sa结构提设置新的处理程序,选项包括<code>ignore</code>、默认处理器、调用该函数,也有控制信号传递细节的选项,若old_sa非空,则old action存储在这。</p>

<pre><code class='language-c' lang='c'>#include &lt;signal.h&gt;

#include &lt;stdio.h&gt;

void sigint_handler(int sig) {

// Doesn’t do anything but interrupt the call to pause() below.

}

int main(void) {

struct sigaction sa;

// Sensible defaults. Use these unless you have a reason not to.

sigemptyset(&amp;sa.sa_mask);

sa.sa_flags = SA_RESTART;

// The handler for SIGINT will be sigint_handler.

sa.sa_handler = sigint_handler;

    if (sigaction(SIGINT, &amp;sa, 0) != 0)

unix_error(&quot;signal error&quot;);

/* Wait for the receipt of a signal */

pause();

puts(&quot;Ctrl-C received, exiting.&quot;);

return 0;

}

</code></pre>

<p>&nbsp;</p>

<h3 >5.4.2 作为并发流的信号处理程序</h3>

<p>信号处理程序是独立的逻辑流(不是进程),与主程序并发运行,但该流只存在直到返回主程序</p>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119142040551.png" referrerpolicy="no-referrer" alt="image-20220119142040551"></p>

<p>此外,信号处理程序也可被其他信号处理程序中断,控制流如下:</p>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119142052313.png" referrerpolicy="no-referrer" alt="image-20220119142052313"></p>

<h3 >5.4.3 阻塞和非阻塞信号</h3>

<ul>

<li><p>隐式阻塞机制:内核会阻塞与当前在处理的信号同类型的其他正等待的信号,如一个 SIGINT 信号处理器是不能被另一个 SIGINT 信号中断的。</p>

</li>

<li><p>显式阻塞机制:使用 <code>sigprocmask</code> 函数,以及其他辅助函数:</p>

<ul>

<li><code>sigemptyset</code> :创建空集</li>

<li><code>sigfillset</code> :把所有的信号都添加到集合中(因为信号数目不多)</li>

<li><code>sigaddset</code> :添加指定信号到集合中</li>

<li><code>sigdelset</code> :删除集合中的指定信号</li>

</ul>

</li>

</ul>

<p>临时阻塞信号示例:</p>

<pre><code class='language-c' lang='c'>sigset_t mask, prev_mask;

Sigemptyset(&amp;mask); // 创建空集

Sigaddset(&amp;mask, SIGINT); // 把 SIGINT 信号加入屏蔽列表中

// 阻塞对应信号,并保存之前的集合

Sigprocmask(SIG_BLOCK, &amp;mask, &amp;prev_mask);

... // 这部分代码不会被 SIGINT 中断

// 取消阻塞信号,恢复原来的状态

Sigprocmask(SIG_SETMASK, &amp;prev_mask, NULL);

</code></pre>

<h3 >5.4.4 安全处理信号</h3>

<p>Handler是十分棘手,因为它们和主程序并发执行且共享相同的全局数据结构,所以不被保护的数据可能会被破坏,这里提供一些基本的指南帮助避免问题:</p>

<ul>

<li><p>规则 1:信号处理器越简单越好</p>

<ul>

<li>例如:设置一个全局的标记,并返回</li>

</ul>

</li>

<li><p>规则 2:信号处理器中只调用异步且信号安全(async-signal-safe)的函数</p>

<ul>

<li>诸如 <code>printf</code>, <code>sprintf</code>, <code>malloc</code> 和 <code>exit</code> 都是不安全的!</li>

</ul>

</li>

<li><p>规则 3:在进入和退出的时候保存和恢复 <code>errno</code></p>

<ul>

<li>这样信号处理器就不会覆盖原有的 <code>errno</code> 值</li>

</ul>

</li>

<li><p>规则 4:临时阻塞所有的信号以保证对于共享数据结构的访问</p>

<ul>

<li>防止可能出现的数据破坏</li>

</ul>

</li>

<li><p>规则 5:用 <code>volatile</code>关键字声明全局变量</p>

<ul>

<li>这样编译器就不会把它们保存在寄存器中,保证一致性</li>

</ul>

</li>

<li><p>规则 6:用 <code>volatile sig_atomic_t</code>来声明全局标识符(flag)</p>

<ul>

<li>flag是只能读或写的变量,这样定义后就不需像其他全局变量被保护</li>

</ul>

</li>

</ul>

<h3 >5.4.5 异步信号安全</h3>

<p>指两类函数:</p>

<ul>

<li>所有变量都保存在帧栈中的可重入函数</li>

<li>不会被信号中断的函数</li>

</ul>

<p>Posix 标准指定了 117 个异步信号安全(async-signal-safe)的函数(可通过 <code>man 7 signal-safety</code> 查看),常用的<code>printf、sprintf、malloc、exit</code>都不是。</p>

<h3 >5.4.6 信号安全代码示例</h3>

<ul>

<li>同步避免父子竞争:</li>

</ul>

<pre><code class='language-c' lang='c'>int main(int argc, char **argv)

{

int pid;

sigset_t mask_all, mask_one, prev_one;

int n = N; /* N = 5 */

Sigfillset(&amp;mask_all);

Sigemptyset(&amp;mask_one);

Sigaddset(&amp;mask_one, SIGCHLD);

Signal(SIGCHLD, handler);

initjobs(); /* Initialize the job list */


while (n--) {

Sigprocmask(SIG_BLOCK, &amp;mask_one, &amp;prev_one); /* Block SIGCHLD */

if ((pid = Fork()) == 0) { /* Child process */

Sigprocmask(SIG_SETMASK, &amp;prev_one, NULL); /* Unblock SIGCHLD */

Execve(&quot;/bin/date&quot;, argv, NULL);

}

Sigprocmask(SIG_BLOCK, &amp;mask_all, NULL); /* Parent process */

addjob(pid); /* Add the child to the job list */

Sigprocmask(SIG_SETMASK, &amp;prev_one, NULL); /* Unblock SIGCHLD */

}

exit(0);

}

</code></pre>

<p>&nbsp;</p>

<ul>

<li>显式等待信号:</li>

</ul>

<pre><code class='language-c' lang='c'>volatile sig_atomic_t pid;

void sigchld_handler(int s)

{

int olderrno = errno;

pid = Waitpid(-1, NULL, 0); /* Main is waiting for nonzero pid */

errno = olderrno;

}

void sigint_handler(int s)

{

}

</code></pre>

<pre><code class='language-c' lang='c'>int main(int argc, char **argv) {

sigset_t mask, prev;

int n = N; /* N = 10 */

Signal(SIGCHLD, sigchld_handler);

Signal(SIGINT, sigint_handler);

Sigemptyset(&amp;mask);

Sigaddset(&amp;mask, SIGCHLD);

//Similar to a shell waiting for a foreground job to terminate.

while (n--) {

Sigprocmask(SIG_BLOCK, &amp;mask, &amp;prev); /* Block SIGCHLD */

if (Fork() == 0) /* Child */

exit(0);

/* Parent */

pid = 0;

Sigprocmask(SIG_SETMASK, &amp;prev, NULL); /* Unblock SIGCHLD */

        /* Wait for SIGCHLD to be received (wasteful!) */

while (!pid)

;

/* Do some work after receiving SIGCHLD */

printf(&quot;.&quot;);

}

printf(&quot;\n&quot;);

exit(0);

}

</code></pre>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119142649442.png" referrerpolicy="no-referrer" alt="image-20220119142649442"></p>

<ul>

<li>使用<code>sigsuspend</code>等价于不可中断版本:</li>

</ul>

<pre><code class='language-c' lang='c'>sigprocmask(SIG_SETMASK, &amp;mask, &amp;prev);

pause();

sigprocmask(SIG_SETMASK, &amp;prev, NULL);

</code></pre>

<pre><code class='language-c' lang='c'>int main(int argc, char **argv) {

sigset_t mask, prev;

int n = N; /* N = 10 */

Signal(SIGCHLD, sigchld_handler);

Signal(SIGINT, sigint_handler);

Sigemptyset(&amp;mask);

Sigaddset(&amp;mask, SIGCHLD);

while (n--) {

Sigprocmask(SIG_BLOCK, &amp;mask, &amp;prev); /* Block SIGCHLD */

if (Fork() == 0) /* Child */

exit(0);

        /* Wait for SIGCHLD to be received */

pid = 0;

while (!pid)

Sigsuspend(&amp;prev);

/* Optionally unblock SIGCHLD */

Sigprocmask(SIG_SETMASK, &amp;prev, NULL);

/* Do some work after receiving SIGCHLD */

printf(&quot;.&quot;);

}

printf(&quot;\n&quot;);

exit(0);

}

</code></pre>

<p>&nbsp;</p>

<h2 >5.5 非本地跳转</h2>

<h3 >5.5.1 非本地跳转</h3>

<p>本地跳转的限制在于不能从一个函数跳转到另一个函数中。若突破限制,C语言提供用户级异常控制流形式,叫非本地跳转,就要使用 <code>setjmp</code> 或 <code>longjmp</code> 来进行<strong>非本地跳转</strong>(Nonlocal Jumps)。强大(但危险)用户级机制,用于将控制权转移到任意位置,打破<code>call/return</code>调用机制,对错误恢复和信号处理有帮助。</p>

<p><code>setjmp</code> 声明是:<code>int setjmp(jmp_buf j)</code>,必须在<code>longjmp</code>前调用,为后续<code>longjmp</code>标识返回地址,调用一次返回一次或多次,保存当前程序的寄存器上下文(register context)、栈指针、PC寄存器值在<code>jmp_buf</code>中,注意,保存的堆栈上下文环境仅在调用 <code>setjmp</code> 的函数内有效,如果调用 <code>setjmp</code> 的函数返回,保存的上下文环境就失效了。直接返回值为 0。</p>

<p><code>longjmp</code> 声明是:<code>void longjmp(jmp_buf j, int i)</code>,<code>setjmp</code>后被调用,调用一次但永远不返回,将会从缓存j中恢复由 <code>setjmp</code> 保存的程序堆栈上下文,跳转到j中保存的地址,设置<code>%eax</code>(返回值)为i,而不是<code>setjmp</code>的0</p>

<pre><code class='language-c' lang='c'>/* Deeply nested function foo */

void foo(void)

{

if (error1)

longjmp(buf, 1);

bar();

}

void bar(void)

{

if (error2)

longjmp(buf,2);

}

jmp_buf buf;

int error1 = 0;

int error2 = 1;

void foo(void), bar(void);

int main()

{

switch(setjmp(buf)) {

case 0:

foo();

break;

case 1:

printf(&quot;Detected an error1 condition in foo\n&quot;);

break;

case 2:

printf(&quot;Detected an error2 condition in foo\n&quot;);

break;

default:

printf(&quot;Unknown error condition in foo\n&quot;);

}

exit(0);

}

</code></pre>

<h3 >5.5.2 非本地跳转的限制</h3>

<p>只能跳转到已经调用但尚未完成(函数还在栈中)的函数环境</p>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119142859909.png" referrerpolicy="no-referrer" alt="image-20220119142859909"></p>

<p><img src="https://gitee.com/wwgswqg/picgopics/raw/master/img/image-20220119142911613.png" referrerpolicy="no-referrer" alt="image-20220119142911613"></p>

<p>P2在跳转的时候已返回,栈帧在内存中已被清理,所以P3中的 <code>longjmp</code> 并不能实现期望的操作</p>

<h1 >6 操作进程的工具</h1>

<ul>

<li>STRACE:打印正运行的程序和它子进程调用的每个系统调用的轨迹,静态编译程序可得到干净不带大量与共享库相关的输出的轨迹</li>

<li>PS:列出当前系统中所有进程(包括僵尸进程)</li>

<li>TOP:打印当前进程资源使用信息</li>

<li>PMAP:进程内存映射</li>

<li>/proc:虚拟文件系统,以ASCII码输出大量内核数据结构内容</li>

</ul>

<h1 >7 总结</h1>

<p>异常控制流(ECF)发生在系统各层次,是系统提供并发的基本机制:</p>

<ul>

<li>硬件层:四种类型异常</li>

<li>操作系统层:内核用ECF提供进程基本概念,进程提供两个重要抽象:逻辑控制流和私有地址空间</li>

<li>操作系统和程序之间的接口:程序可创建子进程,等进程停止或终止,运行新程序以及捕获其他进程的信号</li>

<li>应用层:C程序可用非本地跳转规避正常调用/返回栈规则,直接从一个函数分支道另一个函数</li>

</ul>

<p>&nbsp;</p>

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,142评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,298评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,068评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,081评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,099评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,071评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,990评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,832评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,274评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,488评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,649评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,378评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,979评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,625评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,796评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,643评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,545评论 2 352

推荐阅读更多精彩内容

  • 学习目标 1.了解异步异常与同步异常,以及异常控制流与平时的逻辑控制流的差异2.理解进程的工作机制,如何通过异常来...
    KEEEPer阅读 325评论 0 2
  • 异常 当处理器检测到有事件发生时,他就会通过一张叫做异常表的跳转表,进行一个间接的过程调用,转到专门用于处理这类事...
    userheng阅读 412评论 0 0
  • 学习目标 了解异步异常与同步异常,以及异常控制流与平时的逻辑控制流的差异 理解进程的工作机制,如何通过异常来进行进...
    西部小笼包阅读 306评论 0 1
  • 实验介绍 完成一个简单的shell程序,总体的框架和辅助代码都已经提供好了,我们需要完成的函数主要以下几个: ev...
    leon4ever阅读 8,387评论 1 4
  • 8.1 引言 在理解线程之前,首先需要了解UNIX/Linux进程。 进程是由操作系统创建的,需要相当数量的“开销...
    MachinePlay阅读 394评论 0 0