从Ctrl+\到/proc:Linux进程信号与内核实验避坑
简介这份资源是西南交通大学软件工程专业操作系统课程的实验报告由任课教师胡晓鹏指导完成获评95分适合正在修读操作系统实验课、需要参考规范实验报告写法或复现Linux实验流程的高校学生使用。报告围绕Linux环境与C语言程序调试展开涵盖目录结构与Shell常用命令、重定向与管道机制、gcc编译与gdb调试并深入/proc虚拟文件系统观察cpuinfo、version、stat、uptime等内核状态信息编程实现meminfo、loadavg、interrupts、filesystem的读取后续还包含软中断通信、进程调度FCFS与RR策略验证以及线程同步实验实验目的、内容、步骤与总结结构完整可直接对照搭建环境、补齐实验环节与撰写心得。资源包共1个文件为docx格式的实验报告文档压缩包约1.48MB含封面、个人声明与目录便于检索各实验模块。目前已有404人学习可作为课程实验的参考范本。1. 一次 Ctrl\ 为什么能连杀三代进程从实验日志里看见的内核契约一份 Linux 操作系统实验报告里最不像技术点的技术点是按下 Ctrl\ 之后终端上那三行退出打印孙进程先死、子进程再死、父进程最后死。多数人把它当截图素材其实这三行的顺序暴露了内核里三套机制的接力——fork 复制出的进程树、kill 只投递不执行的信号排队、以及进程从内核态返回用户态那一刻才真正跑起来的处理函数。把这条链路捋顺后面进程调度、线程同步、系统调用、缺页统计里的那些“为什么”都能挂到同一根骨架上。一份评分 95 的某高校软件工程专业 Linux 操作系统实验报告恰好把七组实验排成了从敲命令一路摸到内核变量的路径正好当作别人已经踩过坑的路线图来读。2. /proc 虚拟文件系统为什么 cat 一个文件就能读到内核变量2.1 procfs 的三层结构inode、file_operations 和 show 回调第一次 cat /proc/meminfo 的人通常会有个错觉内核在往一个文件里写数据。真相是磁盘上根本没有这个文件procfs 只注册了一个 inode 桩子真正的“内容”是读取时现场调用出来的。用户态执行 cat触发 open、read 两次系统调用read 进内核后VFS 顺着文件描述符找到struct file再取file-f_op-read落到 procfs 关心的那个回调上回调把内核变量格式化成字符串塞进内核缓冲区最后copy_to_user拷回用户地址空间。全过程发生在读进程自己的上下文里用的是读进程的时间片。所以 /proc 里的文件有三个反直觉特性。第一它不占磁盘inode 是内存里现造的ls -l看到的“大小 0”不是空文件而是“长度是算出来的”。第二读操作可能有副作用某些 /proc 节点读一次就跑一遍统计逻辑频繁轮询会真的消耗 CPU。第三读到的永远是采样值两次 cat 之间内核可能已经跑过几万个时钟节拍。现代内核里绝大多数 /proc 节点已经不用裸read回调而是走 seq_file 框架。原因很实际一个 /proc/meminfo 输出几 KB如果一次性格式化到内核缓冲区内存压力不可控seq_file 把输出做成迭代器每次只填一页缓冲区满了就拷给用户态下次 read 从上次的游标继续。#include linux/proc_fs.h #include linux/seq_file.h #include linux/ktime.h /* 每 read 一次就会在调用进程上下文里跑一遍这个函数 */ static int sample_show(struct seq_file *m, void *v) { struct timespec64 ts; /* 单调时钟不含系统挂起的时间换成 ktime_get_boottime_ts64 则含 */ ktime_get_ts64(ts); seq_printf(m, %lld.%02ld\n, (long long)ts.tv_sec, ts.tv_nsec / 10000000); return 0; /* 返回 0 表示数据没写完seq_file 会继续调 */ } static int sample_open(struct inode *inode, struct file *file) { return single_open(file, sample_show, NULL); /* 绑定 show 回调 */ } static const struct file_operations sample_fops { .owner THIS_MODULE, .open sample_open, .read seq_read, /* 由 seq_file 统一实现不自己写 */ .llseek seq_lseek, .release single_release, }; static int __init sample_init(void) { /* 0444所有人可读NULL 表示挂在 /proc 根下 */ proc_create(sample_uptime, 0444, NULL, sample_fops); return 0; }这段代码里最值得看的是read seq_read这一行。写 procfs 的人几乎不用碰 read 的具体实现seq_read负责判断缓冲区空不空、游标走到哪、要不要重新调用 show。single_open是特例版本适合“一次输出完、没有多条记录”的节点seq_printf的返回值甚至被忽略——这是框架给的便利也是坑如果输出量超过一页x86 上通常是 4KBsingle_open 会强行扩容而普通 seq_file 用seq_open 自定义start/next/stop/show四件套才能分页流式输出。2.2 jiffies 与 uptime内核的时间账本是怎么记的实验里有个问题被反复问Linux 用什么计时答案是两套并行的时间源。一套是jiffies内核里一个无符号长整型全局变量每次时钟中断或者 NOHZ 模式下的补偿加一加一的频率由编译期常量HZ决定x86 上常见的是 250 或 1000。另一套是 ktime基于 TSC 或 HPET 的高精度时钟纳秒精度/proc/uptime现在就是从它算出来的。jiffies有两个必须知道的细节。第一是会回绕。32 位时代它大约 49 天绕一圈所以内核提供了time_after(a, b)这类宏用无符号差值比较来代替直接的只要两次比较的时间差小于半个回绕周期结果就是对的。第二是内核故意让它“早点翻车”——INITIAL_JIFFIES被定义成-300 * HZ也就是开机五分钟后就撞上回绕点。这不是 bug是设计如果代码里存在错误的直接比较开机五分钟就会暴露而不是等到设备在客户现场跑了一个多月才出问题。血泪经验往往就是这么攒出来的。另一个容易翻车的地方是单位换算。/proc/stat的 cpu 行有 user、nice、system、idle、iowait 等字段很多人拿它们除以 HZ 想算 CPU 占用率结果怎么都对不上。原因是这些字段的单位不是HZ而是USER_HZx86 上固定为 100跟内核编译时选的 HZ 无关。内核用jiffies_to_clock_t()做换算就是为了让用户态看到的数值不受 HZ 取值影响。HZ1000 的内核和 HZ250 的内核/proc/stat的同一行数字含义完全一致但如果自己拿 jiffies 除以 1000得到的就只是“有多少个 tick”不是秒。至于jiffies本身它的精度上限就是 1/HZ 秒HZ250 时是 4ms用它测短间隔基本是玄学。真要测时延得用clock_gettime(CLOCK_MONOTONIC)或内核侧的 ktime。这也是/proc/uptime后来改用 ktime 的原因NOHZ 模式下空闲 CPU 不产生 tickjiffies 会落后于真实墙钟用它算 uptime 会越跑越偏。3. 软中断通信kill 到底给进程送去了什么3.1 fork 之后signal() 改的是 PCB 里的一张函数指针表实验代码里那句signal(SIGQUIT, stop)做的事比看上去重得多。内核为每个进程维护一个struct sighand_struct里面是一个长度为_NSIG的k_sigaction数组每个元素记录三样东西handler 地址可能是用户函数、SIG_DFL或SIG_IGN、信号掩码、标志位。signal()最终走rt_sigaction系统调用改的就是这张表里对应编号的那一格。fork 的语义在这张表上体现得很清楚。子进程会复制父进程的 sighand写时复制所以父进程设过的 handler 子进程一开始也有但子进程的 pending 位图是空的父进程收到的信号不会传染给子进程。exec 又是另一套规则所有被捕获的信号在 exec 后重置为SIG_DFL只有被显式忽略的SIG_IGN保留忽略状态。这条规则的存在理由很朴素——旧程序的函数地址在新程序里根本不存在留着就是野指针。signal()和sigaction()的区别也在这里。glibc 的signal()实现的是 BSD 语义处理函数执行期间自动屏蔽当前信号不重置 handler被中断的系统调用自动重启SA_RESTART。而历史上 SysV 的signal()会在触发一次后把 handler 重置回默认动作导致“第二次信号直接杀进程”这种诡异现象。想精确控制就得用sigaction()显式指定sa_flags别指望signal()的默认行为跨平台一致。3.2 信号递达的三个时机pending、blocked 和用户态返回点kill(pid, 16)这个名字极具误导性它不杀任何东西。内核里kill走group_send_sig_info检查目标进程的 handler 表如果是SIG_IGN直接丢弃如果是默认动作且该信号默认可忽略也丢弃否则把信号编号写进目标进程pending位图同时给它置上TIF_SIGPENDING标志然后返回。发送方该干嘛干嘛目标进程可能正在内核里跑一个耗时几十毫秒的 read完全不知道这事。真正的递达发生在“从内核态返回用户态”这个统一出口上。进程结束系统调用或中断处理、准备恢复用户态之前内核检查TIF_SIGPENDING有就进do_signal()取出一个未被阻塞的信号若 handler 是用户函数就在用户栈上构造一个 sigframe把原寄存器上下文存进去把返回地址改成 handler 入口然后返回用户态执行 handlerhandler 执行完ret落到内核预先放好的 trampoline 上触发rt_sigreturn把之前保存的上下文恢复回来进程从“被打断的那条指令”继续执行。整个过程对用户代码是透明的代价是两次额外的用户态/内核态往返。SIGKILL和SIGSTOP的特殊性也在这里。内核在get_signal()里对这两个信号不做 handler 查表直接执行默认动作所以它们既不能被捕获也不能被阻塞。实验里用 16、17 号信号自定义协议就是因为它们不属于内核保留的那两个。顺便说个坑x86_64 上 17 号是SIGCHLD。子进程退出时内核会自动给父进程发一个SIGCHLD如果父进程恰好用signal(17, ...)装了个 handler那么任何一个子进程的正常退出都会触发它——看起来就像“信号发早了”。实验里能跑通是因为那个进程本身没有子进程属于运气好。3.3 wait_mark 忙等与 lockf一个实验里藏着两种同步实验里父子进程靠一个全局int wait_mark互通父进程 kill 之后子进程的 handler 把它清 0主循环退出。这段代码有两个隐患都是教学代码里传承了很多年的。第一个隐患是while (wait_mark);。变量既不是volatile也不是sig_atomic_t编译器在 -O2 下完全有理由把它读进寄存器循环体里再也不去内存取值于是信号来了、内存改了、循环照转。修法是把变量声明成volatile sig_atomic_tvolatile强制每次从内存读sig_atomic_t保证这个宽度的读写是单条指令、不会被信号打断成半截。第二个隐患是忙等本身。while空转会把一个 CPU 核吃满更好的写法是pause()让进程睡到信号到来。但pause()有经典的“检查与睡眠窗口”竞态如果信号恰好在while判断之后、pause()之前到达handler 已经把wait_mark清 0 了pause()却还会一直睡下去。正确姿势是sigprocmask先屏蔽信号检查条件再sigsuspend原子地“解除屏蔽并睡眠”。#include signal.h #include unistd.h #include stdio.h static volatile sig_atomic_t wait_mark 1; static void stop(int signo) { (void)signo; wait_mark 0; } int main(void) { sigset_t mask, oldmask; sigemptyset(mask); sigaddset(mask, 16); sigprocmask(SIG_BLOCK, mask, oldmask); /* 先屏蔽关掉竞态窗口 */ pid_t p1 fork(); if (p1 0) { signal(SIGQUIT, SIG_IGN); /* 子进程不响应终端的 Ctrl\ */ signal(16, stop); while (wait_mark) sigsuspend(oldmask); /* 原子地解屏蔽并睡眠醒来必是信号已处理 */ printf(child %d exit\n, getpid()); return 0; } signal(SIGQUIT, stop); /* 父进程等终端发来的 SIGQUIT */ sigsuspend(oldmask); kill(p1, 16); /* 只是投递不等待 */ wait(NULL); /* 回收子进程防止僵尸 */ printf(parent %d exit\n, getpid()); return 0; }输出串行化用的是lockf(1, 1, 0)。这里容易被误解成“给标准输出加锁”实际加的是 POSIX 记录锁锁挂在文件 inode 的锁链表上作用范围是“进程 inode”。fork 之后父子共享同一个struct file指向同一个 tty inode所以锁真的能跨进程互斥。但有个前提printf是带缓冲的lockf保护的是“写进 stdio 缓冲区”这个动作不是“刷到终端”这个动作。不加fflush(stdout)或者setbuf(stdout, NULL)输出照样可能交错——锁保护了错误的边界。4. 用户态到内核态调度、同步、系统调用、缺页的四次跳转4.1 FCFS 与 RR调度指标背后的账本调度实验看起来是算法题其实是口径题。同一组进程、同样的到达时间和服务时间两个人算出的平均周转时间能差一截原因往往不是算法写错而是对“同一时刻发生两件事”的处理顺序理解不同时间片刚好用完的同时新进程到达谁先入队先入队的那个决定后续整条时间线。指标定义本身很干净。周转时间 完成时刻 − 到达时刻衡量的是用户主观等待带权周转时间 周转时间 / 服务时间衡量的是“这件事相对它自己的体量等了多久”一个跑 100ms 的作业等 100ms 和跑 1ms 的作业等 100ms用户感受完全不同带权指标才拉得开差距。响应时间则是首次被调度时刻 − 到达时刻交互式系统真正在意的是这个。FCFS 的问题叫护航效应一个长作业排在队首后面所有短作业都得陪跑平均带权周转时间会难看到离谱。RR 用时间片把长作业切碎改善的是响应时间代价是上下文切换。时间片 q 的取值是个典型取舍q 趋于无穷RR 退化成 FCFSq 趋于 0切换开销吃掉几乎全部 CPU真正干活的时间占比趋近于零。工程上一般取“略大于一次上下文切换开销的几十到几百倍”。写模拟器时状态机比算法更值得较真。每个 PCB 至少要有 arrive、burst、remain、start、finish 五个字段事件队列按时间戳排序同一时间戳要定一个稳定的优先级比如“到达优先于时间片耗尽”否则结果不可复现两个人对拍永远对不上。import heapq def rr(procs, q): procs: [(pid, arrive, burst)]返回每个进程的完成时刻 procs sorted(procs, keylambda p: (p[1], p[0])) ready, finished, now, i [], {}, 0, 0 while i len(procs) or ready: # 先把所有已到达进程塞进就绪队列按到达时刻、pid 稳定排序 while i len(procs) and procs[i][1] now: pid, arrive, burst procs[i] heapq.heappush(ready, (arrive, pid, burst)) i 1 if not ready: # CPU 空闲时间跳到下一个到达点 now procs[i][1] continue arrive, pid, remain heapq.heappop(ready) run min(remain, q) # 关键时间片与剩余时间取小 now run remain - run # 新到达的进程此刻已在队列里当前进程若没跑完排到队尾 if remain 0: heapq.heappush(ready, (now, pid, remain)) else: finished[pid] now return finished这段代码里run min(remain, q)和“没跑完就重入队尾”两行是 RR 的全部灵魂其余都是事件排序。真正需要推敲的是while i len(procs) and procs[i][1] now用的是到达时刻恰好等于当前时刻的进程在这一轮就进队列还是下一轮不同教材写法不同答案不同但不是对错问题是约定问题。把约定写进注释比多写一百行代码有用。4.2 线程同步临界区凭什么互斥互斥量的实现有个反直觉的事实绝大多数pthread_mutex_lock调用根本没有进内核。无竞争路径上加锁是一条用户态的原子比较交换x86 的lock cmpxchg把锁字从 0 改成 1成功就直接返回全程几十个时钟周期。只有发现锁字已经是 1有人占着才调用futex(FUTEX_WAIT, uaddr, val)进内核把自己挂到一张以锁地址为键的哈希表等待队列上睡过去解锁时发现等待队列非空再FUTEX_WAKE唤醒。这套设计的动机很直接临界区可能只有几条指令而一次系统调用的固定开销是几百纳秒到微秒级如果每次加锁都进内核锁本身就成了瓶颈。futex 把“快路径”留在用户态只让“慢路径”付费——这就是为什么它叫 fast userspace mutex。代价是复杂度转移到了用户态。锁字的三个状态0 空闲、1 已锁无等待者、2 已锁有等待者必须由库代码精确维护漏掉一次状态更新就会出现“唤醒丢失”等待者睡了解锁者看到状态 1 以为没人等直接走人等待者永远不醒。这类 bug 极难复现往往在压测时才炸。信号量是同一套机制上的另一层封装。sem_wait把计数减一减到负值就睡sem_post加一并唤醒。生产者消费者里两个信号量分别守“空位”和“满位”互斥量守缓冲区本身三者的加锁顺序一旦颠倒就是死锁。有条血泪经验值得记住handler 里不能碰任何互斥量。信号处理函数是异步插入的可能在主流程持锁期间执行此时再去pthread_mutex_lock同一把锁必然自死——POSIX 里只有极少数函数是异步信号安全的。4.3 系统调用一次 syscall 指令的完整旅程用户态调用read()glibc 并不做别的事就是把系统调用号放进rax参数按顺序放进rdi、rsi、rdx、r10、r8、r9然后执行syscall指令。CPU 切到 ring 0跳进内核入口entry_SYSCALL_64保存用户栈指针、swapgs换上内核 GS 基址、切到当前进程的内核栈接着进do_syscall_64用rax里的调用号去查全局数组sys_call_table取出函数指针调用。返回值同样放回rax负数表示错误码-errnoglibc 检查到负值就写errno并返回 −1。所以“添加一个系统调用”在原理上就是“在表里多一格”。老版本内核里sys_call_table是导出的符号写个模块直接改表项就行新内核不再导出这个符号模块拿不到地址改表这条路上就多了几道坎。更规范的做法是在内核源码树里用SYSCALL_DEFINEn宏定义让它自动生成符合调用约定的入口再更新syscall_64.tbl分配编号。#include linux/kernel.h #include linux/syscalls.h #include linux/sched.h /* SYSCALL_DEFINE2 展开后生成 __x64_sys_demo_add * 参数类型和名字成对书写int, a 表示第一个参数是 int 类型、名为 a */ SYSCALL_DEFINE2(demo_add, int, a, int, b) { pr_info(demo_add called by %s[%d]\n, current-comm, current-pid); /* 返回值直接放回 rax返回负值会被用户态解释为 -errno */ return (long)a (long)b; }这段代码看着简单但有几个点只有实际编译过才知道。SYSCALL_DEFINEn的宏参数必须成对写错一个类型就是一堆看不懂的展开错误参数不能是浮点或结构体因为传递只靠六个通用寄存器current宏能在任意进程上下文里拿到当前task_struct这是打印调用者身份最方便的一手信息比事后翻日志靠谱。要在用户态触发它还得在unistd_64.h里拿到编号或者直接用syscall(__NR_demo_add, 1, 2)绕过 glibc 封装。4.4 缺页统计minor 和 major 的分界线在哪缺页统计实验的核心不是“数个数”而是搞清缺页为什么会发生。进程访问一个虚拟地址MMU 查页表发现对应项无效触发 page fault进do_page_fault再进handle_mm_fault。接下来分岔如果这一页数据其实已经在物理内存里只是页表项没建好——比如 fork 之后父子共享的只读页被写、比如 mmap 的文件页已经在 page cache 里、比如访问栈上刚扩出来的匿名页——内核只需要填一个页表项不需要任何磁盘 IO。这叫 minor fault次缺页。如果页表项无效且数据得从块设备读上来——比如 mmap 的文件页不在 page cache、比如匿名页被换出到 swap 后又被访问——内核得发 IO、等 IO、再填页表。这叫 major fault主缺页。两者的耗时差着好几个数量级所以监控里永远分开统计。统计入口有两处。全局口径在/proc/vmstatpgfault是所有缺页次数pgmajfault是其中的主缺页次数全系统累加。进程口径在/proc/PID/stat的第 10 到 13 字段minflt、cminflt、majflt、cmajflt分别表示“自己触发的次缺页”“被回收子进程的次缺页”“自己的主缺页”“子进程的主缺页”。还有一条更省事的路getrusage(RUSAGE_SELF)直接返回ru_minflt和ru_majflt。def read_faults(pid): 解析 /proc/pid/stat 的缺页字段 with open(f/proc/{pid}/stat) as f: raw f.read() # comm 字段被括号包着且里面可能有空格甚至括号 # 所以只能按“最后一个右括号”切不能直接 split() tail raw[raw.rfind()) 2:].split() # tail[0] 是 state(字段3)往后推minflt 在字段10即 tail[7] return { minflt: int(tail[7]), # 次缺页无磁盘 IO cminflt: int(tail[8]), # 已回收子进程贡献的次缺页 majflt: int(tail[9]), # 主缺页需要读盘或换入 cmajflt: int(tail[10]), # 子进程贡献的主缺页 }按空格切/proc/PID/stat是这一节最经典的翻车点。第二个字段 comm 是进程名允许包含空格甚至允许包含右括号直接split()会让后面所有字段整体错位读出来的 minflt 其实是 ppid。正确做法是定位最后一个)从它后面开始切。这类问题不看内核文档根本想不到但一旦踩过就再也不会忘。5. 避坑与排查这些实验里最容易翻车的五处5.1 信号与进程控制里的坑现象一子进程收到 Ctrl\ 直接死了handler 根本没跑。原因是从终端按 Ctrl\ 时内核把 SIGQUIT 发给整个前台进程组父子进程全都收到子进程没来得及忽略就被默认动作终止。解决办法是在 fork 之后的第一时间、做任何其他事之前让子进程执行signal(SIGQUIT, SIG_IGN)把时间窗口压到最小更稳的是在 fork 前就用sigprocmask屏蔽fork 后在子进程里显式忽略再解除屏蔽。现象二程序 -O0 跑得好好的加上 -O2 就卡死不退出。原因是wait_mark没加volatile编译器把循环条件提到了寄存器里内存改了也看不见。解决办法是声明成volatile sig_atomic_t并且用sigsuspend取代忙等。这条不是玄学是编译器完全合法的优化。现象三明明只 kill 了一个子进程另一个也退了。十有八九是信号编号选重了。x86_64 上 17 号是 SIGCHLD任何子进程退出都会自动产生如果父进程恰好用 17 号做私有协议就会收到莫名其妙的“提前唤醒”。解决办法是避开内核已用的编号或者干脆改用管道、eventfd 这类有明确语义的 IPC。5.2 编译内核与统计口径里的坑现象四模块里改sys_call_table编译通过insmod 却报 unknown symbol。原因是较新的内核不再导出这个符号模块链接阶段找不到地址但编译期不报错。解决办法是走源码树内SYSCALL_DEFINEn的正规路线或者用 kprobe 在目标系统调用入口挂钩子别硬改表。现象五手工算的 CPU 占用率和 top 差了十倍。原因是/proc/stat的单位是 USER_HZ固定 100不是内核编译时的 HZ可能是 250 或 1000直接除 HZ 会得到错误结果。解决办法是用sysconf(_SC_CLK_TCK)拿到运行时值或者干脆相信jiffies_to_clock_t的换算结果。同理缺页统计别把 minflt 和 majflt 相加当总量报主缺页的代价是次缺页的成千上万倍混在一起这个指标就失去了意义。6. 设计取舍教科书为什么还坚持用 signal而不是直接上 eventfd信号这套机制放到今天看缺陷一抓一把不带数据载荷只能表达“发生了某件事”语义在 SysV 和 BSD 之间摇摆过signal()的行为得靠 glibc 打补丁统一处理函数里几乎什么都不能干连printf都不保证安全多个同种信号还会合并丢掉次数信息。真要传数据、要可靠排队管道和 socketpair 都比它合适。但教科书还是用它因为它把“异步事件”的最小模型暴露得最干净产生、排队、阻塞、递达、处理五个状态一个不少而且每一步都能用strace或者打印语句看见。换成 eventfd这些状态被封装进文件描述符的读写语义里学生看到的是“read 返回了一个 8 字节整数”中间的排队和唤醒全被藏起来了。教学上要的是可见性不是工业强度。从 signal 到 signalfd 再到 eventfd、epoll这条演进线其实只有一个主题把异步事件统一成文件描述符让它们能被同一个事件循环管理。信号处理函数之所以难写本质是因为它在一个“不是正常控制流”的位置被插入一旦把信号转成 fd 上的可读事件处理逻辑就回到了主循环里加锁、分配内存、打日志全都合法了。再往后看futex 把锁的快路径搬回用户态、io_uring 把一批系统调用合并成一次提交方向是一致的——减少用户态和内核态之间的往返次数把能批处理的都批处理掉。我自己的教训是凡是看到“handler 里改了个全局变量主循环等它变”的代码先别管逻辑对不对先去检查那个变量有没有volatile、循环有没有竞态窗口。这个习惯是从一次压测事故里换来的——那段代码在开发机上跑了半年没出问题上线后在高负载下偶发卡死排查了三天才发现是编译器把循环条件优化进了寄存器。从那以后我每次碰信号相关的代码都强制走一遍“声明检查、屏蔽窗口检查、handler 可重入检查”这三步宁可多写十行也不赌编译器的心情。希望帮到你。本文还有配套的精品资源点击获取