尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
六个Linux系统调用Demo:从实验报告到可运行代码
简介这份资源是西安电子科技大学操作系统课程的实验报告文档面向正在学习操作系统原理、需要完成Linux环境下进程与线程编程实验的高校学生。内容围绕进程建立、线程共享进程数据、信号通信、匿名管道与命名管道通信、信号量实现进程同步等六个实验展开每个实验均包含实验目的、软硬件环境、完整C语言程序及逐段分析、运行截图与心得体会可帮助读者对照fork()、waitpid()、pthread_create()、sem_init()等核心函数的用法理解父子进程协同、全局变量共享、异步信号处理与临界区互斥的实现思路。压缩包内共1个doc文件约377KB结构完整、代码可直接参考复现。目前已有1350人学习下载适合作为课程实验的对照范本与期末复习材料也便于初学者在动手实践中建立对进程、线程及进程间通信机制的系统认识。1. 从一份实验报告到六个可跑的 Linux 系统调用 Demo翻到一份操作系统上机实验报告很多人第一反应是“交完就忘”。但把里面的代码抽出来单独跑一遍会发现它其实是一套很紧凑的 Linux 系统编程入门骨架fork建进程、pthread_create建线程、kill发信号、pipe和mkfifo做管道、semget/semop配共享内存做生产者消费者同步。六个实验覆盖了进程管理、线程共享、进程间通信、进程同步四条主线环境只要 Linux 加 gcc没有第三方库没有构建系统每段代码都能单独编译成可执行文件。适合刚学操作系统课、想把手写代码跑出真实输出的人也适合已经工作、想回头把waitpid、semop这些调用重新捋一遍的开发者。下面按“能干什么、怎么跑、怎么改”的顺序拆开讲。2. 实验环境与目录组织六个独立 C 文件怎么摆2.1 环境准备与编译命令这套实验的软硬件环境在原文里写得很明确Linux 加 gcc。没有 Makefile没有 CMake每个实验就是一个.c文件。我一般会按实验编号建目录把每个实验的源码和它依赖的数据文件放在一起避免fopen(test1file.txt,r)这种相对路径找不到文件。# 建工作目录按实验分开放 mkdir -p os-lab/{exp1_process,exp2_thread,exp3_signal,exp4_pipe,exp5_fifo,exp6_sem} cd os-lab # 编译单个实验-pthread 是线程和信号量实验必须的 gcc exp1_process/main.c -o exp1_process/run gcc exp2_thread/main.c -o exp2_thread/run -pthread gcc exp3_signal/main.c -o exp3_signal/run gcc exp4_pipe/main.c -o exp4_pipe/run gcc exp5_fifo/main.c -o exp5_fifo/run gcc exp6_sem/main.c -o exp6_sem/run -pthread编译参数里唯一容易漏的是-pthread。实验二用pthread_create实验六虽然主体是 System V 信号量但链接时带上-pthread不会有副作用反而能避免某些环境下semop相关符号的隐式依赖问题。-o后面跟输出名我习惯统一叫run这样每个目录里./run就能直接执行不用记一长串名字。提示实验一依赖一个叫test1file.txt的文本文件必须在运行目录下存在。如果从别的目录执行./exp1_process/runfopen会失败子进程打印“未找到文件”。这是新手第一个翻车点。2.2 六个实验的模块划分与依赖关系把六个实验当成一个项目看它们的依赖关系其实很清晰实验一是所有后续实验的地基因为fork创建父子进程的模式在实验三、四、五、六里反复出现实验二单独讲线程和进程间通信是两条平行线实验三到实验五是一条递进线从信号异步、无数据载体到匿名管道半双工、有血缘关系到命名管道半双工、可无血缘关系实验六把前面所有东西合起来用共享内存加信号量做同步。实验核心系统调用关键头文件是否依赖父子进程实验一 进程建立forkwaitpidunistd.hsys/wait.h是实验二 线程共享pthread_createpthread.h否实验三 信号通信killsignalsignal.h是实验四 匿名管道pipereadwriteunistd.h是实验五 命名管道mkfifoopenreadwritesys/stat.hfcntl.h是实验六 信号量同步semgetsemopsemctlmmapsys/sem.hsys/mman.h是多进程这张表是我自己整理时用的原文没有列但对着它看代码会快很多。比如实验四和实验五都叫“管道”区别就在pipe创建的是内核里一块无名的缓冲区只能靠fork继承文件描述符来共享mkfifo在文件系统里创建一个真实存在的 FIFO 文件任何进程只要open它就能通信。理解这一点实验五里父进程open(./fifo,O_RDONLY)、子进程open(./fifo,O_WRONLY)的写法就顺了。3. 进程与线程基础fork 和 pthread_create 两条主线3.1 实验一fork 之后父子进程各走各的实验一的核心是fork()的返回值语义父进程里返回子进程 pid子进程里返回 0出错返回负数。原文代码用if(fpid0) / else if(fpid0) / else三分支这是标准写法。真正值得讲的是父进程用waitpid(0,NULL,0)等待子进程结束保证“子进程读完文件父进程才继续”。#include sys/unistd.h #include sys/wait.h #include unistd.h #include stdio.h #include stdlib.h int main() { pid_t fpid fork(); // 创建子进程返回值决定当前在哪个进程 int count 0; if (fpid 0) { printf(error); } else if (fpid 0) { // 子进程分支fpid 为 0 printf(子进程我是子进程,id%d开始读取文件\n, getpid()); FILE *fp fopen(test1file.txt, r); if (fp NULL) { printf(未找到文件\n); } else { char text[2000]; while (fgets(text, 2000, fp) ! NULL) { puts(text); // 逐行输出文件内容 } fclose(fp); // 原文漏了关闭补上避免句柄泄漏 } printf(子进程读取文件结束\n); exit(1); // 子进程用 exit 退出不回到父进程栈 } else { // 父进程分支fpid 是子进程 pid printf(父进程我是父进程id%d ,我要等待子进程\n, getpid()); int ret waitpid(0, NULL, 0); // 0 表示等待任意子进程 printf(父进程子进程 已经结束ret%d\n, ret); } return 0; }waitpid的三个参数第一个pid传 0 表示等待同一进程组里的任意子进程第二个status传NULL表示不关心子进程退出状态第三个options传 0 表示阻塞等待。原文代码里ret打印出来就是子进程的 pid可以拿它和子进程里getpid()的输出对照验证父子关系。我在原文基础上补了fclose(fp)因为原文子进程读完文件没关流虽然exit会回收但养成习惯更好。注意子进程分支用exit(1)而不是return 0。exit会刷新 stdio 缓冲区并终止整个进程return在main里效果类似但在fork场景下用exit语义更明确避免子进程意外继续执行父进程后续代码。3.2 实验二线程共享全局变量sleep 是同步手段实验二用pthread_create创建一个线程线程函数里读全局变量shdata、自增、再打印。主线程sleep(3)后打印shdata看到值变了说明线程和进程共享同一块数据段。#include stdio.h #include stdlib.h #include pthread.h #include unistd.h int shdata 0; // 全局变量位于进程数据段线程共享 void *creat(void *arg) { printf(new pthread ….\n); printf(shared data %d \n, shdata); shdata; // 直接修改全局变量无需加锁单线程写 printf(after operating :shared data %d\n, shdata); return (void *)0; } int main() { pthread_t tid; int pth pthread_create(tid, NULL, creat, NULL); if (pth ! 0) { printf(creat thread error\n); return -1; } else { sleep(3); // 给新线程足够时间跑完 printf(creat thread success\n); printf(shared data %d \n, shdata); return 0; } }pthread_create四个参数tid接收线程 idNULL表示用默认线程属性creat是线程入口函数签名必须是void *(*)(void *)最后一个NULL是传给线程函数的参数。这里sleep(3)是一种“土办法”同步靠时间差保证线程先跑完。真实项目里不会这么写应该用pthread_join(tid, NULL)等待线程结束。原文用sleep是为了演示简单但这也是一个值得在二次开发时改掉的点。4. 进程间通信三件套信号、匿名管道、命名管道4.1 实验三kill 发信号signal 注册处理函数实验三的模型是子进程向父进程发SIGALRM父进程用signal(SIGALRM, ouch)注册处理函数把全局标志alarm_fired置 1主循环检测到标志就退出。#include unistd.h #include sys/types.h #include stdlib.h #include stdio.h #include signal.h static int alarm_fired 0; void ouch(int sig) // 信号处理函数参数是信号编号 { alarm_fired 1; } int main() { pid_t pid fork(); if (pid 0) { perror(fork failed\n); exit(1); } else if (pid 0) { kill(getppid(), SIGALRM); // 子进程向父进程发 SIGALRM exit(0); } else { signal(SIGALRM, ouch); // 父进程注册处理函数 while (!alarm_fired) { printf(no signal\n); // 忙等直到信号到达 } if (alarm_fired) printf(\nI got a signal %d\n, SIGALRM); } return 0; }kill(getppid(), SIGALRM)里getppid()拿父进程 pidSIGALRM是闹钟信号。signal的第二个参数是函数指针信号到达时内核调用它。这里有个时序问题如果子进程在父进程执行signal之前就发了信号信号会丢失父进程可能永远卡在while里。原文代码能跑通是因为fork之后父进程通常先执行到signal但这不是强保证。稳妥做法是在fork之前就注册处理函数或者用sigaction配合阻塞信号集。这是实验三最值得在二次开发时加固的地方。4.2 实验四匿名管道父写子读匿名管道pipe(fd)创建两个文件描述符fd[0]读端、fd[1]写端。父子进程通过fork继承这两个描述符各自关掉不用的那一端就形成单向数据流。#include stdio.h #include unistd.h #include stdlib.h #include string.h #include sys/types.h #define MAX_LINE 80 int main() { int fd[2]; pid_t child_pid; if (pipe(fd) 0) { printf(管道创建成功\n); child_pid fork(); if (child_pid -1) { printf(子进程创建失败\n); } else if (child_pid 0) { close(fd[1]); // 子进程关写端只读 sleep(3); // 等父进程写完 char readbuf[MAX_LINE]; read(fd[0], readbuf, sizeof(readbuf)); printf(子进程读取数据成功\n); printf(data%s\n, readbuf); close(fd[0]); exit(0); } else { close(fd[0]); // 父进程关读端只写 char str[] hello the world; write(fd[1], str, strlen(str)); printf(父进程写入数据成功\n); close(fd[1]); return 0; } } else { printf(管道创建失败\n); } }关键在“各关一端”父进程关fd[0]只留写端子进程关fd[1]只留读端。如果两边都不关管道两端都开着读端read会一直阻塞等数据因为内核认为还有写端可能写入。原文子进程里sleep(3)是为了让父进程先写其实管道本身有缓冲通常 64KB父进程写完再读也没问题sleep可以去掉。read的第三个参数是最大读取字节数这里传sizeof(readbuf)是 80够放hello the world。4.3 实验五命名管道mkfifo 建一个文件系统里的 FIFO命名管道和匿名管道的本质区别是它有一个路径名mkfifo(./fifo, 0666)在当前目录创建一个 FIFO 文件权限 0666。之后任何进程open这个路径就能读写。#include stdio.h #include stdlib.h #include sys/types.h #include sys/stat.h #include fcntl.h #include unistd.h #include string.h int main() { int ret mkfifo(./fifo, 0666); // 创建命名管道文件 if (ret 0) { printf(管道创建失败\n); exit(0); } else { printf(命名管道创建成功\n); } pid_t fpid fork(); if (fpid 0) { printf(创建子进程失败\n); } else if (fpid 0) { // 父进程读 int fd open(./fifo, O_RDONLY); if (fd 0) { printf(读数据出错\n); exit(0); } char buf[BUFSIZ] {0}; int n read(fd, buf, sizeof(buf)); if (n 0) { printf(父进程read buffer后 %s\r\n, buf); } close(fd); } else { // 子进程写 int fd open(./fifo, O_WRONLY); if (fd 0) { printf(写数据出错\n); exit(0); } char buf[100] HELLO,WORLD; sleep(1); printf(子进程写入管道\n); write(fd, buf, strlen(buf)); close(fd); } return 0; }mkfifo的第二个参数是权限位0666表示所有用户可读写实际权限还会受umask影响。open一个 FIFO 时O_RDONLY会阻塞到有写端打开O_WRONLY会阻塞到有读端打开所以父进程open读端后会等子进程open写端两边都打开后才继续。原文子进程里sleep(1)其实没必要因为open本身已经做了同步。跑完实验后当前目录会留下一个fifo文件下次再跑mkfifo会返回 -1文件已存在需要先rm fifo或者用unlink清理。提示实验五跑完记得rm ./fifo否则第二次运行会打印“管道创建失败”然后直接退出。这是命名管道实验最常见的“跑一次就坏”问题。5. 信号量同步生产者消费者用共享内存加 PV 操作5.1 共享内存映射与信号量初始化实验六是六个实验里最复杂的一个它把mmap匿名共享映射、System V 信号量、fork多进程三样东西合在一起。先看共享内存部分array是 5 个槽的缓冲区sum是累加和set是生产位置get是消费位置四个都用mmap映射成父子进程共享。#include sys/mman.h #include sys/types.h #include sys/sem.h #include fcntl.h #include unistd.h #include stdio.h #include errno.h #include time.h #include stdlib.h #define MAXSEM 5 union semun { int val; struct semid_ds *buf; unsigned short *array; }; int fullid, emptyid, mutxid; // full已有产品数, empty空槽数, mutex互斥 int main() { struct sembuf P, V; union semun arg; // 四个共享区域缓冲区、累加和、生产下标、消费下标 int *array mmap(NULL, sizeof(int) * MAXSEM, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); int *sum mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); int *set mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); int *get mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); // 创建三个信号量IPC_PRIVATE 表示只在本进程组内使用 fullid semget(IPC_PRIVATE, 1, IPC_CREAT | 0666); emptyid semget(IPC_PRIVATE, 1, IPC_CREAT | 0666); mutxid semget(IPC_PRIVATE, 1, IPC_CREAT | 0666); // 初始化full0empty5mutex1 arg.val 0; semctl(fullid, 0, SETVAL, arg); arg.val MAXSEM; semctl(emptyid, 0, SETVAL, arg); arg.val 1; semctl(mutxid, 0, SETVAL, arg); // P 操作sem_op -1V 操作sem_op 1 V.sem_num 0; V.sem_op 1; V.sem_flg SEM_UNDO; P.sem_num 0; P.sem_op -1; P.sem_flg SEM_UNDO; // ... 后续 fork 生产者与消费者 }mmap的MAP_SHARED | MAP_ANONYMOUS组合是关键MAP_ANONYMOUS表示不关联文件MAP_SHARED表示fork后父子进程共享同一块物理内存。如果写成MAP_PRIVATE子进程写的是自己的副本父进程看不到整个实验就失效了。semget的IPC_PRIVATE让内核自动分配 key0666是权限。semctl的SETVAL用来给信号量赋初值union semun是 System V 信号量 API 的历史遗留必须自己定义。5.2 生产者与两个消费者的 PV 操作顺序生产者循环 100 次每次先P(empty)等空槽、P(mutex)拿锁写缓冲区、set然后V(mutex)、V(full)。两个消费者各自循环先P(full)等产品、P(mutex)拿锁读缓冲区、累加、get再V(mutex)、V(empty)。pid_t fpid1 fork(); if (fpid1 0) { // 生产者 int i 0; while (i 100) { semop(emptyid, P, 1); // 等空槽 semop(mutxid, P, 1); // 进临界区 array[(*set) % MAXSEM] i 1; (*set); printf(produce %d\n, i 1); semop(mutxid, V, 1); // 出临界区 semop(fullid, V, 1); // 产品数 1 i; } sleep(1); printf(producer is over\n); exit(0); } else { pid_t fpid2 fork(); if (fpid2 0) { // 消费者 A while (1) { semop(fullid, P, 1); // 等产品 semop(mutxid, P, 1); // 进临界区 if (*get 100) break; *sum array[(*get) % MAXSEM]; printf(the comsumer A get number %d\n, array[(*get) % MAXSEM]); (*get); if (*get 100) { printf(the sum is %d\n, *sum); break; } semop(mutxid, V, 1); semop(emptyid, V, 1); // 空槽 1 sleep(0.5); } printf(consumer A is over\n); exit(0); } else { // 消费者 B逻辑与 A 相同输出标识不同 // ... } }PV 顺序不能乱生产者必须先P(empty)再P(mutex)如果反过来先拿锁再等空槽缓冲区满时生产者持锁阻塞消费者又拿不到锁释放空槽直接死锁。消费者同理先P(full)再P(mutex)。原文实验心得里提到“消费者取出生产者还未生产的产品”就是某个 PV 顺序写反导致的。SEM_UNDO标志让进程异常退出时内核自动撤销它做的信号量操作避免死锁这个细节原文没展开但实际调试时很有用。6. 避坑与排查六个实验里最容易翻车的五处6.1 现象实验一子进程打印“未找到文件”原因fopen(test1file.txt,r)用的是相对路径相对于进程的当前工作目录不是源码所在目录。从os-lab根目录执行./exp1_process/run时当前目录是os-lab而文件在exp1_process/里。解决要么cd exp1_process ./run要么把fopen的路径改成绝对路径或相对于执行目录的路径。我一般会在实验目录里放一个test1file.txt然后cd进去跑。6.2 现象实验二编译报undefined reference to pthread_create原因pthread_create在libpthread里gcc 默认不链接。解决编译命令加-pthread注意是-pthread不是-lpthread前者还会设置一些编译期宏。命令写成gcc main.c -o run -pthread参数顺序放在源文件后面。6.3 现象实验三父进程卡在while(!alarm_fired)死循环原因子进程在父进程执行signal(SIGALRM, ouch)之前就调用了kill信号在注册处理函数前到达被默认动作处理SIGALRM默认终止进程或者信号丢失。解决把signal注册移到fork之前保证处理函数在任何信号到达前就位。更稳的写法是用sigprocmask先阻塞SIGALRMfork后再解除阻塞。6.4 现象实验五第二次运行打印“管道创建失败”原因mkfifo(./fifo, 0666)在文件已存在时返回 -1errno为EEXIST。解决运行前rm -f ./fifo或者在代码里判断errno EEXIST时直接复用已有 FIFO。我习惯在实验目录的清理脚本里加一行rm -f fifo跑之前先清。6.5 现象实验六消费者打印的产品编号乱序或重复原因PV 操作顺序错误最常见的是生产者先P(mutex)再P(empty)或者消费者在break之前没有释放mutex。原文消费者在if (*get 100) break;处直接跳出没有执行后面的V(mutex)和V(empty)如果这是最后一个产品mutex会一直处于 P 状态另一个消费者可能卡住。解决把break改成先V再break或者用goto统一跳到释放段。调试时可以在每次semop后打印信号量值semctl(id,0,GETVAL)来定位。7. 二次开发把这六个 Demo 改成一个可复用的系统编程练习框架这套实验代码单独看都是教学 Demo但把它们收拢成一个练习框架价值会大很多。我的做法是建一个common/目录放公共头文件和工具函数每个实验一个子目录顶层写一个Makefile统一编译。公共部分至少抽三样东西一个check_err宏包装系统调用返回值一个sem_p/sem_v封装semop的 P/V 操作一个cleanup_fifo函数在atexit里清理命名管道文件。# 顶层 Makefile统一编译六个实验 CC gcc CFLAGS -Wall -Wextra -g LDFLAGS -pthread EXPS exp1_process exp2_thread exp3_signal exp4_pipe exp5_fifo exp6_sem all: $(EXPS) $(EXPS): $(CC) $(CFLAGS) $/main.c -o $/run $(LDFLAGS) clean: rm -f */run fifo-Wall -Wextra打开警告能提前发现read返回值未检查、write参数类型不匹配这类问题。-g保留调试符号配合gdb可以单步看fork之后父子进程的执行流。clean里顺手删掉fifo省得每次手动rm。改造方向上我建议优先做两件事。第一把实验二的sleep(3)换成pthread_join把实验四子进程的sleep(3)去掉让同步靠机制而不是靠时间这样代码在慢机器和快机器上行为一致。第二给实验六加一个semctl(..., IPC_RMID, ...)在程序结束时删除信号量集否则ipcs -s会看到一堆残留的 semid反复跑实验会耗尽系统信号量资源。验证方法很简单改完后连续跑十次实验六ipcs -s | grep 0x应该看不到新增条目实验四去掉sleep后父子进程输出顺序仍然正确。血泪经验是这类系统编程实验最容易被“能跑就行”糊弄过去但fork之后的文件描述符继承、信号量的SEM_UNDO、mmap的MAP_SHARED这些点恰恰是面试和真实项目里会追问的地方。从那以后我每次改这类多进程代码都强制在fork前后各打一行getpid()和关键资源 id跑一遍ipcs确认没有泄漏再收工。希望帮到你。本文还有配套的精品资源点击获取
RELATED

相关推荐

Java抢票助手源码拆解:自动登录、查询与订单提交实战

Java抢票助手源码拆解:自动登录、查询与订单提交实战

简介:这份资源面向熟悉 Java、希望在春运等购票高峰自动抢购 12306 火车票的程序员,提供一套可运行的自动购票程序及配套说明。程序支持自动登录与重试、自动查询刷新、指定车次与动车/卧铺优先、代理配置及验证码识别,并兼容 FirefoxScripti…

📅 2026/10/30 2:54:50
深度学习车牌识别:OpenCV定位与CNN字符识别

深度学习车牌识别:OpenCV定位与CNN字符识别

简介:《车牌识别系统详细讲解》PPT面向做毕业设计、课程项目或准备答辩的学生,围绕Python与OpenCV构建车牌识别系统,梳理从图像预处理、车牌定位过滤到字符分割识别的完整技术路线,并说明双CNN结构在后台图片识别中的应用思路。压…

📅 2026/10/30 2:54:50
深入了解Python的继承

深入了解Python的继承

前言 继承(inheritance)是 Python 里最常用的代码复用手段之一:把一组类共有的属性和方法抽到一个基类(base class,也叫父类)里,其余类通过 class Child(Base) 声明自己是它的子类(s…

📅 2026/10/30 2:54:50
MORE NEWS

更多资讯

📰

EMC整改实战:辐射发射、传导发射与静电抗扰度排查思路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

嵌入式开发从裸机到Linux的四阶段工程路线图

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

一周就业信息速递:从采集到发布的完整实操指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

20+款ESP32在线开发工具盘点:浏览器仿真、烧录与免安装实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

STM32实战进阶:用嵌入式C++补齐外设、总线与数据上云的关键技术解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

ESP-IDF驱动DHT11实战:从时序原理到稳定采集的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬