<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Katyusha's blog</title>
  <subtitle>记录技术与成长</subtitle>
  <link href="https://katyusha-blog.com/" rel="alternate" type="text/html"/>
  <link href="https://katyusha-blog.com/atom.xml" rel="self" type="application/atom+xml"/>
  <id>https://katyusha-blog.com/</id>
  <updated>2026-07-05T13:11:05.881Z</updated>
  <language>zh_CN</language>
  <entry>
    <title>从 OS 基础理解 eBPF：基于 ebpf-rca 的学习笔记</title>
    <link href="https://katyusha-blog.com/posts/others/os2026/learning/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/others/os2026/learning/</id>
    <published>2026-06-30T00:00:00.000Z</published>
    <updated>2026-06-30T00:00:00.000Z</updated>
    <summary>从调度、系统调用、内存回收、块 I/O 和 off-CPU 阻塞出发，理解 eBPF 的执行模型与 ebpf-rca 的诊断链路。</summary>
    <content type="html"><![CDATA[<h1>从 OS 基础理解 eBPF：基于 ebpf-rca 的学习笔记</h1>
<blockquote>
<p>基于 <code>ebpf-rca</code> 项目代码，从 OS 基础知识出发，逐层理解 eBPF。</p>
</blockquote>
<hr />
<h2>1. eBPF 是什么</h2>
<p>eBPF = <strong>内核内虚拟机</strong>，完整构成四件套：</p>
<table>
<thead>
<tr>
<th>组件</th>
<th>职责</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>挂载点</strong>（tracepoint/kprobe）</td>
<td>接入内核事件流</td>
</tr>
<tr>
<td><strong>沙箱虚拟机</strong>（eBPF 程序）</td>
<td>在内核态执行逻辑</td>
</tr>
<tr>
<td><strong>map</strong>（共享 KV 存储）</td>
<td>内核态 ↔ 用户态通信信道</td>
</tr>
<tr>
<td><strong>Verifier</strong>（验证器）</td>
<td>加载时静态检查，保证安全</td>
</tr>
</tbody>
</table>
<p><strong>不是"轻量 hook"，而是"一段能跑在内核里的受限 C 代码"。</strong></p>
<p>核心模型：用户态编译 eBPF 字节码 → <code>bpf()</code> syscall 注入 → verifier 检查通过 → 挂到内核预定义锚点（tracepoint 等） → 事件触发时执行 → 通过 map 向用户态回传聚合数据。</p>
<hr />
<h2>2. eBPF 是事件驱动的回调（不是持续运行的后台进程）</h2>
<p>类比：<strong>信号 handler 注册模型</strong>。</p>
<pre><code>sigaction(SIGALRM, handler)    ↔  link.Tracepoint("sched", "sched_switch", handler)
  时钟中断触发 → handler 执行     ↔  sched_switch 发生 → eBPF handler 执行
  handler return → 回到被中断代码  ↔  eBPF return → 回到调度器
</code></pre>
<p>区别：</p>
<ul>
<li>信号 handler 跑<strong>用户态</strong>，eBPF 跑<strong>内核态</strong></li>
<li>信号 handler 只能调 async-signal-safe 函数；eBPF 只能调白名单 helper + 受 verifier 约束（本质都是"中断上下文约束"）</li>
<li>信号 handler 仅影响本进程；eBPF 一挂就是<strong>系统全局</strong></li>
</ul>
<p><strong>无事件 = 零 CPU 占用。没有"后台轮询"。</strong></p>
<hr />
<h2>3. eBPF 如何避免 kernel panic — Verifier</h2>
<p>Verifier 在<strong>加载时</strong>（不是编译时）逐指令静态模拟执行，检查：</p>
<ol>
<li><strong>无死循环</strong> — 循环必须 <code>#pragma unroll</code>（编译期展开），跳转只能向前</li>
<li><strong>无越界访问</strong> — 每次内存访问，必须证明指针合法（判空检查、map value 范围内）</li>
<li><strong>无未初始化变量</strong> — 所有寄存器在使用前有确定值</li>
<li><strong>无除零</strong> — 值域追踪，除数可能为 0 的路径直接拒绝</li>
</ol>
<p>检查不通过 → 程序拒绝加载，<strong>根本不会执行</strong>。</p>
<p><strong>Verifier 保证的是"内存安全"，不是"逻辑正确"。</strong>
逻辑错误（算错指标、指错进程）不会导致 kernel panic，只会输出错误的诊断结果。</p>
<p>eBPF 能做的：读写 map、算术运算、调白名单 helper。
eBPF 不能做：<code>malloc</code>/<code>free</code>、调任意内核函数、无界循环、直接访问任意内核内存（必须走 <code>bpf_probe_read_kernel</code>）。</p>
<hr />
<h2>4. eBPF 执行模型：跑在触发者的上下文里</h2>
<p><strong>不是独立内核线程，是串行插入在触发事件的执行路径中。</strong></p>
<pre><code>CPU0 A 进程时间片到了                   CPU1 正在处理 write() syscall
  │                                      │
  ├── sched_switch tracepoint 触发        ├── block_rq_issue tracepoint 触发
  │     → handle_switch() 同步执行         │     → handle_issue() 同步执行
  │     → return                          │     → return
  │     → 调度器继续                       │     → write() 继续
</code></pre>
<ul>
<li><strong>同步调用</strong>，不是异步中断或信号投递。tracepoint 是内核源码中硬编码的函数调用点</li>
<li><strong>run-to-completion</strong>：不允许休眠、无 <code>mutex_lock</code></li>
<li>多核之间才真正并发，靠 <code>__sync_fetch_and_add</code> 做原子 map 更新</li>
</ul>
<hr />
<h2>5. tracepoint：内核自带的"监控探头"</h2>
<p>内核开发者在关键位置埋的标记点，平时零开销（不存在），激活后才执行回调。</p>
<pre><code>// 内核源码（kernel/sched/core.c），不是 eBPF
void __schedule(void) {
    // ...
    trace_sched_switch(prev, next);   // ← tracepoint 埋点
}
</code></pre>
<p>不是 eBPF 特有的。内核本来就埋了几百个这样的点。eBPF 只是提供了一种向这些点注册回调函数的机制。</p>
<hr />
<h2>6. BPF map：内核与用户态的通信信道</h2>
<p><strong>不是 mmap 共享内存。</strong> 内核单向拥有内存，两边访问方式不对称：</p>
<table>
<thead>
<tr>
<th></th>
<th>内核态 eBPF</th>
<th>用户态 Go</th>
</tr>
</thead>
<tbody>
<tr>
<td>访问方式</td>
<td><strong>直接指针解引用</strong> <code>st-&gt;run_ns += delta</code></td>
<td><strong>bpf() syscall</strong>（cilium/ebpf 库封装）</td>
</tr>
<tr>
<td>速度</td>
<td>零拷贝，几十个周期</td>
<td>走 syscall，有 copy_from/to_user 开销</td>
</tr>
<tr>
<td>为什么安全</td>
<td>verifier 已证明指针在 map value 范围内</td>
<td>N/A（本来就跑用户态）</td>
</tr>
</tbody>
</table>
<p><strong>核心设计</strong>：快的那边（eBPF，每秒触发几万次）用直接指针；慢的那边（用户态，每秒只读一次）走 syscall。各取所需。</p>
<p>ebpf-rca CPU 场景的三个 map（<code>cpu.bpf.c:27-48</code>）：</p>
<pre><code>oncpu_start   // tid → 上 CPU 的时间戳         (HASH, 16384 entries)
wakeup_ts     // tid → 被唤醒入队的时间戳      (HASH, 16384 entries)
stats         // tid → {run_ns, runq_ns, ctx}  (HASH, 16384 entries)
</code></pre>
<hr />
<h2>7. CPU 开销的真实情况</h2>
<p>eBPF 的 CPU 开销 = <strong>单次代价 × 触发频率</strong>。</p>
<ul>
<li>正常：无事件 = 无指令执行 = 零 CPU</li>
<li>触发：每次 <code>handle_switch</code> 约几十到一百周期（几次哈希查找 + 整数加法），上下文切换本身几千周期</li>
<li>开销可控：因为没有 per-event 上送到用户态（没有 <code>copy_to_user</code>）</li>
<li>syscall 场景（<code>raw_syscalls</code>）是开销最高的：每次 syscall 触发两次 eBPF，一秒几万次</li>
</ul>
<hr />
<h2>8. eBPF vs Timer 采样：拍照 vs 录像</h2>
<table>
<thead>
<tr>
<th></th>
<th>Timer 采样</th>
<th>eBPF</th>
</tr>
</thead>
<tbody>
<tr>
<td>原理</td>
<td>定时快照（如每 10ms 读 <code>/proc</code>）</td>
<td>每事件实时记录</td>
</tr>
<tr>
<td>短脉冲（1ms 延迟尖刺）</td>
<td>大概率漏掉</td>
<td>一个不漏</td>
</tr>
<tr>
<td>分布信息（P99）</td>
<td>不可见</td>
<td>直方图完整</td>
</tr>
<tr>
<td>开销</td>
<td>低</td>
<td>高频场景需聚合降开销</td>
</tr>
</tbody>
</table>
<p>ebpf-rca 的内存场景刻意混合使用：</p>
<ul>
<li>eBPF 抓直接回收事件（偶发但致命，timer 拍不到）</li>
<li><code>/proc/meminfo</code> 读可用内存（低频变化，不值得写 eBPF）</li>
</ul>
<p>选择原则：</p>
<ul>
<li>事件稀疏 + 需要完整性 → eBPF 挂载</li>
<li>事件高频 → eBPF 内核态聚合，不上送每条事件</li>
<li>本身就是常量 → 直接读 <code>/proc</code>，不用 eBPF</li>
</ul>
<hr />
<h2>9. ebpf-rca 完整调用链（CPU 场景）</h2>
<h3>阶段一：加载（一次性）</h3>
<pre><code>Go: NewCPUCollector()
  └─ bpf() syscall → 内核 verifier 静态检查字节码
  └─ 创建 3 个 map（oncpu_start, wakeup_ts, stats）
  └─ link.Tracepoint("sched", "sched_switch", handleSwitch)
  └─ link.Tracepoint("sched", "sched_wakeup", handleWakeup)
</code></pre>
<h3>阶段二：内核事件触发（持续，每切换一次触发一次）</h3>
<pre><code>sched_switch 发生
  └─ handle_switch:
       prev 切出: run_ns 累加, ctx++（记录进程名）
       next 上 CPU: runq_ns 累加（结算排队时间）
       → 只做 O(1) 聚合，不判断，不发消息
</code></pre>
<h3>阶段三：用户态采样（每秒）</h3>
<pre><code>Go: time.Ticker 触发
  └─ Poll(1s): 遍历 stats map → 差分 → 算 cpu_util / ctx_per_min / runq_wait
  └─ Detect: 阈值(0.9) + 连续窗口(3)判定异常
  └─ BuildCPUReport: 规则分类根因 + 组装证据链
  └─ output: JSON/YAML/Markdown
</code></pre>
<h3>阶段四：卸载</h3>
<pre><code>Ctrl-C → defer col.Close()
  └─ link.Close() → 从 tracepoint 摘除
  └─ objs.Close() → 释放 map 和程序 fd
</code></pre>
<hr />
<h2>10. Map 共享、原子性与统计偏差边界</h2>
<p><strong>Map 共享不是缺陷，是内核态聚合的自然选择。</strong> eBPF 程序在事件路径里执行，必须用一个所有 CPU 都能访问的 map 把同一对象的统计累加起来，否则用户态每个窗口要枚举每 CPU 副本再手动归并。</p>
<p>当前 <code>ebpf-rca</code> 使用的是普通 <code>BPF_MAP_TYPE_HASH</code>，不是 per-CPU map：</p>
<table>
<thead>
<tr>
<th>场景</th>
<th>共享 key</th>
<th>共享 value</th>
</tr>
</thead>
<tbody>
<tr>
<td>CPU</td>
<td><code>tid</code></td>
<td><code>run_ns / runq_ns / ctx / comm</code></td>
</tr>
<tr>
<td>I/O</td>
<td><code>dev</code></td>
<td><code>count / total_lat_ns / max_lat_ns / inflight / slots</code></td>
</tr>
<tr>
<td>内存</td>
<td><code>pid/tgid</code></td>
<td><code>direct_reclaim_count / direct_reclaim_ns / comm</code></td>
</tr>
<tr>
<td>锁</td>
<td><code>tid</code></td>
<td><code>offcpu_ns / offcpu_count / max_offcpu_ns / last_waker / stackid</code></td>
</tr>
<tr>
<td>syscall</td>
<td><code>(pid, syscall_nr)</code></td>
<td><code>count / total_ns / max_ns / comm</code></td>
</tr>
</tbody>
</table>
<p>但要精确地区分两件事：</p>
<ol>
<li><strong>共享 map 负责聚合位置</strong>：所有 CPU 都把同一对象写到同一个 value。</li>
<li><strong>原子操作负责并发写正确性</strong>：只有使用 <code>__sync_fetch_and_add</code> 的字段才有原子加保障。</li>
</ol>
<p>项目里的原子性现状：</p>
<table>
<thead>
<tr>
<th>文件</th>
<th>使用原子加的字段</th>
<th>普通写/普通加的字段</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>block.bpf.c</code></td>
<td><code>inflight/count/total_lat_ns/bytes/slots</code></td>
<td><code>max_lat_ns</code> 是 best-effort 最大值</td>
</tr>
<tr>
<td><code>syscall.bpf.c</code></td>
<td><code>count/total_ns</code></td>
<td><code>max_ns</code> 是 best-effort 最大值</td>
</tr>
<tr>
<td><code>mem.bpf.c</code></td>
<td><code>kswapd_wakes</code></td>
<td><code>direct_reclaim_count/direct_reclaim_ns</code> 普通加</td>
</tr>
<tr>
<td><code>cpu.bpf.c</code></td>
<td>无</td>
<td><code>run_ns/runq_ns/ctx</code> 普通加</td>
</tr>
<tr>
<td><code>lock.bpf.c</code></td>
<td>无</td>
<td><code>offcpu_ns/offcpu_count/max_offcpu_ns</code> 普通加</td>
</tr>
</tbody>
</table>
<p>所以更准确的判断是：当前设计接受小概率统计偏差，尤其是 <code>max_*</code> 这类 gauge 可能被并发覆盖；I/O/syscall 的主要累计字段用原子加兜底，CPU/lock/mem 更多依赖事件语义降低冲突概率。</p>
<p>真正的 per-CPU map 是 <code>BPF_MAP_TYPE_PERCPU_HASH</code>：每个 CPU 有独立 value，写入时通常不需要跨 CPU 原子竞争。但它不会让用户态“自动得到合并后的一个 value”。用户态读取时通常拿到每 CPU value 数组，再自己求和、取最大值或做直方图合并。代价是读路径和合并逻辑更复杂，收益是高频写路径更低竞争。</p>
<p>本项目选择普通共享 hash map 的工程含义：</p>
<ul>
<li>代码简单，用户态 <code>Poll()</code> 直接读一个 value。</li>
<li>适合比赛原型和低到中等负载诊断。</li>
<li>对 <code>count/total</code> 类累计值，热点场景最好使用原子加或 per-CPU map。</li>
<li>对 <code>max</code> 类字段，即使用原子加也不够，需要 CAS 循环或接受 best-effort。</li>
</ul>
<hr />
<h2>11. 五个异常场景解析（从 CSAPP 级 OS 知识出发）</h2>
<p>前提：你已经理解上下文切换、缺页、<code>read()</code>/<code>write()</code> 系统调用、<code>mutex_lock</code> 阻塞等待。下面每个场景只补一个关键内核事实。</p>
<h3>11.1 CPU 异常占用 / 调度延迟</h3>
<table>
<thead>
<tr>
<th>已知 OS 概念</th>
<th>补充事实</th>
<th>eBPF 记录什么</th>
<th>判定条件</th>
</tr>
</thead>
<tbody>
<tr>
<td>线程在 CPU 上运行，时间片到或阻塞时被切走</td>
<td><code>sched_switch</code> 暴露 prev/next，<code>sched_wakeup</code> 暴露被唤醒入队</td>
<td><code>run_ns</code> 运行时间、<code>runq_ns</code> 排队等待、<code>ctx</code> 切换次数</td>
<td><code>CPUUtil &gt;= threshold</code> 连续 <code>sustain</code> 窗口</td>
</tr>
</tbody>
</table>
<p>内核侧模型：</p>
<pre><code>sched_wakeup:
  wakeup_ts[tid] = now

sched_switch:
  prev 被切出:
    run_ns += now - oncpu_start[prev]
    ctx++
  next 上 CPU:
    oncpu_start[next] = now
    runq_ns += now - wakeup_ts[next]
</code></pre>
<p>用户态每个窗口做差分：</p>
<pre><code>CPUUtil    = delta(run_ns) / interval_ns
CtxPerMin  = delta(ctx) / interval_minutes
RunqWaitUs = delta(runq_ns) / delta(ctx) / 1000
</code></pre>
<p>对应代码：<code>cpu.bpf.c</code> 的 <code>handle_switch</code> / <code>handle_wakeup</code>，Go 侧是 <code>collector.go</code>。</p>
<h3>11.2 I/O 延迟抖动 / 阻塞等待</h3>
<p>唯一需要补充的事实：块层请求有两个稳定事件。</p>
<table>
<thead>
<tr>
<th>tracepoint</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>block:block_rq_issue</code></td>
<td>请求下发到块设备队列/驱动</td>
</tr>
<tr>
<td><code>block:block_rq_complete</code></td>
<td>设备报告请求完成</td>
</tr>
</tbody>
</table>
<p>二者时间差就是一次块 I/O 的完成延迟。eBPF 在 issue 时按 <code>(dev, sector)</code> 存时间戳，complete 时配对结算。</p>
<table>
<thead>
<tr>
<th>记录的数据</th>
<th>推导指标</th>
<th>判定条件</th>
</tr>
</thead>
<tbody>
<tr>
<td>完成请求数 <code>count</code></td>
<td><code>IOPS = delta(count) / interval</code></td>
<td><code>P99LatMs &gt;= threshold</code> 连续 <code>sustain</code> 窗口</td>
</tr>
<tr>
<td>累计延迟 <code>total_lat_ns</code></td>
<td>平均延迟</td>
<td></td>
</tr>
<tr>
<td>log2 延迟直方图 <code>slots</code></td>
<td>P99 延迟估计</td>
<td></td>
</tr>
<tr>
<td><code>inflight</code></td>
<td>队列深度</td>
<td></td>
</tr>
<tr>
<td><code>bytes</code></td>
<td>吞吐</td>
<td></td>
</tr>
</tbody>
</table>
<p><code>inflight = issue - complete</code>，就是当前在途请求数，近似反映设备队列压力。P99 用 log2 桶估计，不是保存每次请求的原始延迟。</p>
<p>对应代码：<code>block.bpf.c</code> 的 <code>handle_issue</code> / <code>handle_complete</code>，Go 侧是 <code>io.go</code>。</p>
<h3>11.3 内存抖动 / OOM 风险</h3>
<p>你已知：缺页是访问虚拟页时发现没有可用物理页映射，内核现场处理。</p>
<p>需要补充的事实：物理内存紧张时，内核有两类回收路径。</p>
<table>
<thead>
<tr>
<th>回收方式</th>
<th>谁执行</th>
<th>进程感知</th>
</tr>
</thead>
<tbody>
<tr>
<td>后台回收</td>
<td><code>kswapd</code> 内核线程</td>
<td>业务进程通常无直接阻塞</td>
</tr>
<tr>
<td>直接回收</td>
<td>申请内存的业务进程自己执行</td>
<td>业务进程被卡住，尾延迟升高</td>
</tr>
</tbody>
</table>
<p>eBPF 盯直接回收：</p>
<pre><code>mm_vmscan_direct_reclaim_begin:
  start[pid] = now

mm_vmscan_direct_reclaim_end:
  direct_reclaim_count++
  direct_reclaim_ns += now - start[pid]

mm_vmscan_kswapd_wake:
  kswapd_wakes++
</code></pre>
<p>为什么 direct reclaim 是强信号：它说明“想要内存的线程拿不到足够空闲页，只能自己同步回收”。这不是单纯低水位，而是已经进入业务路径的阻塞成本。</p>
<p>当前 detector 的触发条件是系统级的：</p>
<pre><code>MemAvailablePct &lt; threshold
连续 sustain 窗口
</code></pre>
<p>触发后 <code>pickCulprit()</code> 才从 <code>snap.Procs</code> 里选主要贡献者：优先 direct reclaim 次数，其次 major fault。major/minor fault 不是 eBPF 采集，而是 Go 侧低频读取 <code>/proc/&lt;pid&gt;/stat</code> 差分；<code>MemAvailable</code> 来自 <code>/proc/meminfo</code>。</p>
<p>对应代码：<code>mem.bpf.c</code> 的 <code>handle_direct_begin</code> / <code>handle_direct_end</code> / <code>handle_kswapd_wake</code>，Go 侧是 <code>mem.go</code>。</p>
<h3>11.4 锁竞争 / off-CPU 阻塞</h3>
<p>你已知：<code>mutex_lock</code> 拿不到锁，线程会睡眠，等持锁者释放后被唤醒。</p>
<p>需要补充的事实：<code>sched_switch</code> 的 <code>prev_state</code> 区分“被抢占”和“主动阻塞”。</p>
<table>
<thead>
<tr>
<th><code>prev_state</code></th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>0</code> / <code>TASK_RUNNING</code></td>
<td>仍可运行，通常是被抢占或让出 CPU</td>
</tr>
<tr>
<td>非 0</td>
<td>进入睡眠/阻塞状态，可能在等锁、I/O、条件变量等</td>
</tr>
</tbody>
</table>
<p>eBPF 逻辑：</p>
<pre><code>sched_switch, prev_state != 0:
  offcpu_start[prev] = { now, stackid }

sched_switch, next 上 CPU:
  dur = now - offcpu_start[next]
  lock_stats[next].offcpu_ns += dur
  lock_stats[next].offcpu_count++

sched_wakeup:
  lock_stats[wakee].last_waker = current_tid
</code></pre>
<p>Go 侧拿到三类证据：</p>
<table>
<thead>
<tr>
<th>数据</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>offcpu_ns</code> 窗口增量</td>
<td>阻塞睡眠了多久</td>
</tr>
<tr>
<td><code>stackid</code> + <code>/proc/kallsyms</code></td>
<td>阻塞点栈，RCA 用 <code>futex/mutex/rwsem</code> 等符号判断是否像锁</td>
</tr>
<tr>
<td><code>last_waker</code></td>
<td>最近唤醒该线程的人，疑似释放锁或满足条件的一方</td>
</tr>
</tbody>
</table>
<p>注意：当前 detector 只看 <code>OffcpuRatio &gt;= threshold</code> 连续窗口；是否归类为“锁竞争”，是在 <code>rca.BuildLockReport()</code> 里通过阻塞栈符号二次判断。没有命中锁符号时，报告会降级为“长时间阻塞等待”。</p>
<p>对应代码：<code>lock.bpf.c</code> 的 <code>handle_switch</code> / <code>handle_wakeup</code>，Go 侧是 <code>lock.go</code>。</p>
<h3>11.5 系统调用热点</h3>
<p>你已知：<code>read()</code>/<code>write()</code>/<code>fsync()</code> 等 syscall 是用户态陷入内核的边界。</p>
<p>eBPF 使用通用 raw syscall tracepoint：</p>
<pre><code>raw_syscalls:sys_enter:
  start[tid] = { now, syscall_nr }

raw_syscalls:sys_exit:
  dur = now - start[tid].ts
  syscall_stats[(tgid, nr)].count++
  syscall_stats[(tgid, nr)].total_ns += dur
  syscall_stats[(tgid, nr)].max_ns = max(max_ns, dur)  // best-effort
</code></pre>
<p>用户态窗口指标：</p>
<pre><code>CallsPerSec    = delta(count) / interval
AvgLatUs       = delta(total_ns) / delta(count) / 1000
TotalMsPerSec  = delta(total_ns) / interval / 1e6
</code></pre>
<p>当前 detector 的触发条件是二选一：</p>
<pre><code>CallsPerSec &gt;= threshold       // 默认 10000 次/秒
或 TotalMsPerSec &gt;= 300         // 单个 syscall 每秒累计占用超过 300ms
</code></pre>
<p><code>AvgLatUs &gt;= 1000</code> 不是触发条件，而是 RCA 分类条件：触发后如果平均单次耗时超过 1ms，就报告为“高耗时系统调用热点”；否则报告为“高频系统调用热点”。</p>
<p>对应代码：<code>syscall.bpf.c</code> 的 <code>handle_enter</code> / <code>handle_exit</code>，Go 侧是 <code>syscall.go</code>。</p>
<h3>11.6 五场景对比总表</h3>
<table>
<thead>
<tr>
<th>场景</th>
<th>OS 核心机制</th>
<th>挂载点</th>
<th>eBPF 记录什么</th>
<th>判定手段</th>
</tr>
</thead>
<tbody>
<tr>
<td>CPU</td>
<td>调度切换</td>
<td><code>sched_switch</code> + <code>sched_wakeup</code></td>
<td>运行时间、排队时间、切换次数</td>
<td>单核占用率持续超阈值</td>
</tr>
<tr>
<td>I/O</td>
<td>块层请求生命周期</td>
<td><code>block_rq_issue</code> + <code>block_rq_complete</code></td>
<td>请求延迟、队列深度、直方图</td>
<td>P99 延迟持续超阈值</td>
</tr>
<tr>
<td>内存</td>
<td>直接回收 / 后台回收</td>
<td><code>mm_vmscan_direct_reclaim_begin/end</code> + <code>mm_vmscan_kswapd_wake</code></td>
<td>direct reclaim 次数/耗时、kswapd 唤醒</td>
<td>可用内存占比持续低于阈值</td>
</tr>
<tr>
<td>锁</td>
<td>阻塞型 off-CPU</td>
<td><code>sched_switch</code> + <code>sched_wakeup</code></td>
<td>off-CPU 时长、阻塞栈、唤醒者</td>
<td>off-CPU 比例持续超阈值，RCA 再看锁栈</td>
</tr>
<tr>
<td>syscall</td>
<td>syscall 入口/出口</td>
<td><code>raw_syscalls:sys_enter/exit</code></td>
<td>次数、总耗时、最大耗时</td>
<td>高频或累计耗时持续超阈值</td>
</tr>
</tbody>
</table>
<p>共性：内核态只做聚合计数，用户态按窗口差分，再由 detector 判定持续异常。锁场景额外调用 <code>bpf_get_stackid</code> 抓阻塞栈，这是相对更贵的操作，但只在阻塞切出路径执行。</p>
<h3>11.7 一次 <code>write()</code> 可能触发多个探针</h3>
<pre><code>用户态 write(fd, buf, len)
         |
         v
raw_syscalls:sys_enter         -&gt; syscall 热点开始计时
         |
         | 可能缺页 / 分配页
         |   -&gt; direct_reclaim_begin/end
         |
         | 可能提交块 I/O
         |   -&gt; block_rq_issue
         |   -&gt; block_rq_complete
         |
         | 可能等锁 / 等 I/O 睡眠
         |   -&gt; sched_switch(prev_state != 0)
         |   -&gt; sched_wakeup
         |
         | 调度切换穿插发生
         |   -&gt; sched_switch 统计 CPU run_ns / ctx
         |
raw_syscalls:sys_exit          -&gt; syscall 热点结算耗时
         |
         v
返回用户态
</code></pre>
<p>这些探针彼此独立，不共享逻辑，只是从不同内核层面观察同一次业务行为。最终 RCA 依靠 evidence chain 把 CPU、I/O、内存、锁、syscall 的局部证据组织成根因判断。</p>
<h2>12. main.go：用户态总控逻辑</h2>
<p><code>cmd/ebpf-rca/main.go</code> 本身不直接分析内核事件，它是 <strong>orchestrator</strong>：</p>
<pre><code>命令行参数
  → 选择场景 runXXX
  → 初始化 collector / detector
  → 预热 Poll 建立差分基线
  → runLoop 周期采样
  → collector.Poll → detector.Detect → rca.BuildXXXReport → handler
  → Ctrl-C / SIGTERM / duration 到期退出
</code></pre>
<h3>参数解析</h3>
<p><code>main()</code> 先解析：</p>
<table>
<thead>
<tr>
<th>参数</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>--scenario</code></td>
<td>选择 cpu/io/mem/lock/syscall/all</td>
</tr>
<tr>
<td><code>--interval</code></td>
<td>采样窗口大小</td>
</tr>
<tr>
<td><code>--threshold</code></td>
<td>异常阈值；为 0 时使用场景默认值</td>
</tr>
<tr>
<td><code>--sustain</code></td>
<td>连续多少个窗口异常才触发</td>
</tr>
<tr>
<td><code>--duration</code></td>
<td>总运行时间；0 表示直到 Ctrl-C</td>
</tr>
<tr>
<td><code>--format</code> / <code>--output</code> / <code>--report</code></td>
<td>控制流式输出或汇总报告</td>
</tr>
</tbody>
</table>
<p><code>thresholdFor()</code> 给每类场景默认阈值。syscall 场景默认是 <code>10000</code> 次/秒。</p>
<h3>handler：每条诊断报告如何处理</h3>
<p><code>handler</code> 的类型是：</p>
<pre><code>type handler func(schema.AnomalyReport)
</code></pre>
<p>它处理的是一条完整 <code>AnomalyReport</code>，不是单条 evidence：</p>
<ul>
<li>未设置 <code>--report</code>：立刻用 <code>output.Write</code> 按 JSON/YAML/Markdown 输出</li>
<li>设置 <code>--report</code>：先 <code>agg.Add(r)</code> 聚合，程序退出后统一 <code>agg.Render</code></li>
</ul>
<h3>context：把退出信号变成取消事件</h3>
<pre><code>ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
</code></pre>
<p>含义：收到 Ctrl-C(<code>SIGINT</code>) 或 <code>SIGTERM</code> 时，<code>ctx.Done()</code> 会被关闭。</p>
<p><code>runLoop</code> 中：</p>
<pre><code>select {
case &lt;-ctx.Done():
    return
case &lt;-deadline:
    return
case now := &lt;-ticker.C:
    tick(now)
}
</code></pre>
<p>所以退出路径是：信号到达 → context 取消 → 采样循环返回 → <code>defer col.Close()</code> 卸载 eBPF 资源。</p>
<h3>runSyscall 执行路径</h3>
<p>系统调用性能分析对应：</p>
<pre><code>case "syscall":
    err = runSyscall(ctx, cfg, h)
</code></pre>
<p><code>runSyscall</code> 做四件事：</p>
<ol>
<li><code>collector.NewSyscallCollector()</code>：加载并挂载 syscall eBPF 程序</li>
<li><code>detector.NewSyscallDetector(cfg.threshold, cfg.sustain)</code>：创建连续窗口检测器</li>
<li><code>col.Poll(cfg.interval)</code>：预热一次，建立窗口差分基线</li>
<li><code>runLoop(...)</code>：每个窗口执行采样、检测、RCA、输出</li>
</ol>
<p>核心循环：</p>
<pre><code>samples, err := col.Poll(cfg.interval)
for _, sig := range det.Detect(samples, now) {
    h(rca.BuildSyscallReport(sig))
}
</code></pre>
<h3>窗口差分在 collector 中完成</h3>
<p>BPF map 保存的是累计值，例如 syscall 场景的：</p>
<pre><code>Count   = 累计调用次数
TotalNs = 累计耗时
MaxNs   = 历史最大单次耗时
</code></pre>
<p>collector 保存上一轮 <code>prev</code>。每次 <code>Poll</code> 读取当前 <code>cur</code> 后计算：</p>
<pre><code>dCount := cur.Count - prev.Count
dTotal := cur.TotalNs - prev.TotalNs
</code></pre>
<p>再换算成窗口指标：</p>
<pre><code>calls_per_sec   = dCount / interval
avg_lat_us      = dTotal / dCount
total_ms_per_sec = dTotal / interval
</code></pre>
<p>因此分工是：</p>
<table>
<thead>
<tr>
<th>模块</th>
<th>职责</th>
</tr>
</thead>
<tbody>
<tr>
<td>collector</td>
<td>读 BPF map，做窗口差分，生成窗口样本</td>
</tr>
<tr>
<td>detector</td>
<td>阈值 + 连续窗口判定异常是否成立</td>
</tr>
<tr>
<td>rca</td>
<td>根据异常信号和指标做规则化根因分类</td>
</tr>
<tr>
<td>output/report</td>
<td>输出单条报告或汇总报告</td>
</tr>
</tbody>
</table>
<h3>为什么 runLoop 前要先 Poll 一次</h3>
<p>第一次 <code>Poll</code> 的结果被丢弃：</p>
<pre><code>_, _ = col.Poll(cfg.interval)
</code></pre>
<p>它的作用不是诊断，而是记录当前累计值到 <code>prev</code>。</p>
<p>如果不预热，第一次正式采样时 <code>prev</code> 为空，collector 会把 eBPF 程序加载以来的全部累计值当作第一个窗口增量，导致第一次窗口不干净。</p>
<p>预热后的时间线：</p>
<pre><code>t0: eBPF 已挂载，map 开始累计
t0: Poll() 只建立 prev，丢弃样本
t1: ticker 触发，Poll() 得到 t0~t1 的差分
t2: ticker 触发，Poll() 得到 t1~t2 的差分
</code></pre>
<p>注意：当前速率计算使用传入的 <code>cfg.interval</code>，不是实测的 <code>now - lastPoll</code>。如果 Go 调度或系统负载导致 tick 延迟，速率会有轻微误差；更严谨的实现应记录真实采样时间差。</p>
<hr />
<h2>13. 关键设计原则</h2>
<p><strong>两态分离</strong>：内核态只做机械计数（零判断、零分支），全部"智力"（算指标、判阈值、定根因）在每秒 1 次的用户态循环。</p>
<p><strong>注入是一次性的，不是每次 syscall 都注入</strong>：Go 启动时一次性把字节码加载、挂载好，之后就驻留在内核事件链上。后续每次 tracepoint 触发，直接函数指针跳转到 eBPF 程序，不经过 syscall。</p>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="OS2026"></category>
  </entry>
  <entry>
    <title>assignment1</title>
    <link href="https://katyusha-blog.com/posts/cs149/assignment1/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/cs149/assignment1/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-02-28T00:00:00.000Z</updated>
    <summary>assignment1</summary>
    <content type="html"><![CDATA[<p>环境</p>
<p>OS:Windows11 wsl2 6.6.87.2-microsoft-standard-WSL2 Ubuntu 24.04.3 LTS</p>
<p>CPU: Intel Core i7 13620H 8 cores, 10 logic processors, AVX2</p>
<p>GPU:NVIDIA GeForce RTX 4060 Laptop</p>
<h2>assignment1</h2>
<p>https://github.com/stanford-cs149/asst1</p>
<h3>prog1_mandelbrot_threads</h3>
<p>给定了计算并生成以下称为<code>mandelbrot_set</code>图像的代码，其中计算一个像素的时间与这个像素的亮度成正比</p>
<p>可以<code>sudo apt install feh</code>后，使用<code>feh &lt;image-path&gt; </code>查看生成的图像</p>
<p><img src="https://katyusha-blog.com/_astro/cs149-assignment1-image-001.PfFnQR_1_ZIy3y4.webp" alt="image" /></p>
<p>给出的<code>mandelbrotSerial.cpp</code>实现了单线程的计算，我们需要在<code>mandelbrotThread.cpp</code>中修改<code>workerThreadStart</code>函数，使用<code>std::thread</code>加速这个过程</p>
<p>我们先采用朴素的分配方式，将空间从上到下分为线程数份，分别分配给每个线程执行</p>
<pre><code>void workerThreadStart(WorkerArgs * const args) {

    // TODO FOR CS149 STUDENTS: Implement the body of the worker
    // thread here. Each thread should make a call to mandelbrotSerial()
    // to compute a part of the output image.  For example, in a
    // program that uses two threads, thread 0 could compute the top
    // half of the image and thread 1 could compute the bottom half.

    printf("Hello world from thread %d\n", args-&gt;threadId);

    unsigned int num = args-&gt;height / args-&gt;numThreads;
    unsigned int st = args-&gt;threadId * num, ed = st + num;
    if (args-&gt;threadId == args-&gt;numThreads - 1) ed = args-&gt;height;

    mandelbrotSerial(args-&gt;x0, args-&gt;y0, args-&gt;x1, args-&gt;y1, args-&gt;width, args-&gt;height, st, ed - st, args-&gt;maxIterations, args-&gt;output);
}
</code></pre>
<p><code>make</code>后使用<code>./mandelbort -t &lt;线程数&gt;</code>运行得到线程数与加速比的关系如下 第一次用matplotlib画图显示中文字符调了我半天:(</p>
<p><img src="https://katyusha-blog.com/_astro/cs149-assignment1-image-002.DTxy5MoC_ZILXmi.webp" alt="image" /></p>
<p>有意思的是，在<code>view1</code>中，使用3个线程加速比反而降低了，观察<code>view1</code>图像，中间部分计算量明显大于两边的计算量，因而负载不均匀导致加速比降低</p>
<p>一种有效的方式是讲整个图像划分为若干块，每个块内使用我们第一次的方式，因为相邻部分计算量差距较小，这就保证了负载尽量均匀，这被称为分块+线程内连续行分配的策略</p>
<pre><code>void workerThreadStart(WorkerArgs * const args) {

    // TODO FOR CS149 STUDENTS: Implement the body of the worker
    // thread here. Each thread should make a call to mandelbrotSerial()
    // to compute a part of the output image.  For example, in a
    // program that uses two threads, thread 0 could compute the top
    // half of the image and thread 1 could compute the bottom half.

    printf("Hello world from thread %d\n", args-&gt;threadId);

    static constexpr unsigned int BLOCKSIZE = 128;
    unsigned int blocks = args-&gt;height / BLOCKSIZE;
    for (unsigned int i = 0; i &lt; blocks; i++) {
        unsigned int block_st = i * BLOCKSIZE, block_ed = block_st + BLOCKSIZE;
        if (i + 1 == blocks) block_ed = args-&gt;height;
        unsigned int block_len = block_ed - block_st, thread_len = block_len / args-&gt;numThreads;
        unsigned thread_st = block_st + args-&gt;threadId * thread_len, thread_ed = thread_st + thread_len;
        if (args-&gt;threadId + 1 == args-&gt;numThreads) thread_ed = block_ed;
        mandelbrotSerial(args-&gt;x0, args-&gt;y0, args-&gt;x1, args-&gt;y1, args-&gt;width, args-&gt;height, thread_st, thread_ed - thread_st, args-&gt;maxIterations, args-&gt;output);
    }
}
</code></pre>
<p>得到的结果如下：</p>
<p><img src="https://katyusha-blog.com/_astro/cs149-assignment1-image-003.VdZYfU6m_Z20xVRe.webp" alt="image" /></p>
<p>对比发现生成两张图片的加速比都高于简单的空间划分，加速比的波动是由于块的大小这个参数造成的</p>
<h3>prog2_vecintrin</h3>
<p>要求我们使用<code>intrinsics</code>向量化重写代码，但是为了简化，使用的是cs149提供的库函数(事实上库函数也是标量模拟向量化)</p>
<p>阅读<code>CS149intrin.cpp</code>源码，其中每个函数功能如下</p>
<p><code>__cs149_mask _cs149_init_ones(int first=VECTOR_WIDTH)</code>：返回一个前<code>first</code>位为1，其余位为0的<code>mask</code>向量</p>
<p><code>__cs149_mask _cs149_mask_not(__cs149_mask &amp;maska)</code>：将<code>maska</code>向量取反</p>
<p><code>__cs149_mask _cs149_mask_or(__cs149_mask &amp;maska, __cs149_mask &amp;maskb)</code>：返回两个向量<code>or</code>运算后的向量</p>
<p><code>__cs149_mask _cs149_mask_and(__cs149_mask &amp;maska, __cs149_mask &amp;maskb)</code>： 返回两个向量<code>and</code>运算后的向量</p>
<p><code>int _cs149_cntbits(__cs149_mask &amp;maska)</code> ： 返回<code>maska</code>中1的个数</p>
<p>对于接下来传入的mask参数，当某一位为1时表示对该位进行写入，为0时表示不写入</p>
<p><code>void _cs149_vset(__cs149_vec&lt;T&gt; &amp;vecResult, T value, __cs149_mask &amp;mask)</code>：将value尝试写入向量每一位</p>
<p><code>void _cs149_vmove(__cs149_vec&lt;T&gt; &amp;dest, __cs149_vec&lt;T&gt; &amp;src, __cs149_mask &amp;mask)</code>：将<code>src</code>每一位尝试按位写入<code>dest</code>中</p>
<p><code>void _cs149_vload(__cs149_vec&lt;T&gt; &amp;dest, T* src, __cs149_mask &amp;mask)</code>：将<code>src</code>数组每个位置按位尝试写入<code>dest</code>中</p>
<p><code>void _cs149_vstore(T* dest, __cs149_vec&lt;T&gt; &amp;src, __cs149_mask &amp;mask) </code>：将<code>src</code>每一位尝试写入数组<code>dest</code>中</p>
<p><code>void _cs149_vadd(__cs149_vec&lt;T&gt; &amp;vecResult, __cs149_vec&lt;T&gt; &amp;veca, __cs149_vec&lt;T&gt; &amp;vecb, __cs149_mask &amp;mask)</code>：将<code>veca</code>和<code>vecb</code>每一位做加法之后尝试写入<code>vecResult</code>中，<code>vsub</code>，<code>vmult</code>，<code>vdiv</code>同理</p>
<p><code>void _cs149_vabs(__cs149_vec&lt;T&gt; &amp;vecResult, __cs149_vec&lt;T&gt; &amp;veca, __cs149_mask &amp;mask)</code>：将<code>veca</code>每一位取绝对值后，尝试写入<code>vecResult</code>中</p>
<p><code>void _cs149_vgt(__cs149_mask &amp;maskResult, __cs149_vec&lt;T&gt; &amp;veca, __cs149_vec&lt;T&gt; &amp;vecb, __cs149_mask &amp;mask)</code>：将<code>veca</code>大于<code>vecb</code>按位比较得到的<code>bool</code>值尝试写入<code>maskResult</code>中，<code>vlt</code>，<code>veq</code>同理</p>
<p><code>void _cs149_hadd(__cs149_vec&lt;T&gt; &amp;vecResult, __cs149_vec&lt;T&gt; &amp;vec)</code>：从0开始，计算相邻奇偶两项的和，直接写入<code>vecResult</code>的原位置中</p>
<p><code>void _cs149_interleave(__cs149_vec&lt;T&gt; &amp;vecResult, __cs149_vec&lt;T&gt; &amp;vec)</code>：将<code>vec</code>中偶数索引放入<code>vecResult</code>前半部分，奇数索引放入后半部分</p>
<h4>补全absVector</h4>
<p>提供的代码已经实现了<code>absVector</code>主体部分，但是没有处理<code>N % VECTOR_WIDTH != 0</code>的情况，需要我们补全对余数的处理</p>
<p>因为每次计算答案不会发生变化，一种比较简单的方法是对于最后<code>VECTOR_WIDTH</code>个元素再向量化处理一次</p>
<pre><code>// implementation of absSerial() above, but it is vectorized using CS149 intrinsics
void absVector(float* values, float* output, int N) {
  __cs149_vec_float x;
  __cs149_vec_float result;
  __cs149_vec_float zero = _cs149_vset_float(0.f);
  __cs149_mask maskAll, maskIsNegative, maskIsNotNegative;

//  Note: Take a careful look at this loop indexing.  This example
//  code is not guaranteed to work when (N % VECTOR_WIDTH) != 0.
//  Why is that the case?
  int i;
  for (i=0; i + VECTOR_WIDTH - 1 &lt;N; i+=VECTOR_WIDTH) {

    // All ones
    maskAll = _cs149_init_ones();

    // All zeros
    maskIsNegative = _cs149_init_ones(0);

    // Load vector of values from contiguous memory addresses
    _cs149_vload_float(x, values+i, maskAll);               // x = values[i];

    // Set mask according to predicate
    _cs149_vlt_float(maskIsNegative, x, zero, maskAll);     // if (x &lt; 0) {

    // Execute instruction using mask ("if" clause)
    _cs149_vsub_float(result, zero, x, maskIsNegative);      //   output[i] = -x;

    // Inverse maskIsNegative to generate "else" mask
    maskIsNotNegative = _cs149_mask_not(maskIsNegative);     // } else {

    // Execute instruction ("else" clause)
    _cs149_vload_float(result, values+i, maskIsNotNegative); //   output[i] = x; }

    // Write results back to memory
    _cs149_vstore_float(output+i, result, maskAll);
  }
  if (N % VECTOR_WIDTH == 0) return;
  i = N - VECTOR_WIDTH;
  maskAll = _cs149_init_ones();
  maskIsNegative = _cs149_init_ones(0);
  _cs149_vload_float(x, values + i, maskAll);
  _cs149_vlt_float(maskIsNegative, x, zero, maskAll);
  _cs149_vsub_float(result, zero, x, maskIsNegative);
  _cs149_mask_not(maskIsNegative);
  _cs149_vmove_float(result, x, maskIsNegative);
  _cs149_vstore_float(output, result, maskAll);
}
</code></pre>
<h4>实现clampedExpVector</h4>
<p>要求你使用<code>intrinsics</code>实现朴素的$values[i]$的$exponents[i]$次方计算，当结果与<code>9.999999f</code>取$min$</p>
<p>直接实现即可</p>
<pre><code>void clampedExpVector(float* values, int* exponents, float* output, int N) {

  //
//   CS149 STUDENTS TODO: Implement your vectorized version of
  // clampedExpSerial() here.
  //
  // Your solution should work for any value of
  // N and VECTOR_WIDTH, not just when VECTOR_WIDTH divides N
  //
    __cs149_mask maskAll = _cs149_init_ones();
    __cs149_vec_int zero = _cs149_vset_int(0);
    __cs149_vec_int one = _cs149_vset_int(1);
    
    auto calc = [&amp;] (int i) {
        __cs149_vec_int count;
        _cs149_vload_int(count, exponents + i, maskAll);
        __cs149_vec_float base;
        _cs149_vload_float(base, values + i, maskAll);
        __cs149_vec_float result = _cs149_vset_float(1.f);
        __cs149_mask Ispositive;
        _cs149_vgt_int(Ispositive, count, zero, maskAll);
        while (_cs149_cntbits(Ispositive) &gt; 0) {    //while(count &gt; 0)
            _cs149_vmult_float(result, result, base, Ispositive);   //if (count[i] &gt; 0) result[i] *= values[i] 
            _cs149_vsub_int(count, count, one, maskAll);    //count--
            _cs149_vgt_int(Ispositive, count, zero, maskAll);
        }

        __cs149_vec_float bound = _cs149_vset_float(9.999999f);
        _cs149_vgt_float(Ispositive, result, bound, maskAll);   //if (result[i] &gt; bound)
        _cs149_vmove_float(result, bound, Ispositive);  //result[i] = bound
        _cs149_vstore_float(output + i, result, maskAll); //output[i] = result[i] 
    };

    for (int i = 0; i + VECTOR_WIDTH - 1 &lt; N; i += VECTOR_WIDTH) {
        calc(i);
    }
    if (N % VECTOR_WIDTH == 0) return;
    calc(N - VECTOR_WIDTH);
}
</code></pre>
<p>在工作集大小为10000时，测试结果如下</p>
<pre><code>CLAMPED EXPONENT (required) 
Results matched with answer!
****************** Printing Vector Unit Statistics *******************
Vector Width:              4
Total Vector Instructions: 97070
Vector Utilization:        90.4%
Utilized Vector Lanes:     350981
Total Vector Lanes:        388280
************************ Result Verification *************************
Passed!!!
</code></pre>
<p>按照要求改动<code>VECTOR_WIDTH</code>参数，发现从2到16，向量利用率依次下降。当向量大小增大的时候，一个向量中出现分歧<code>divergence</code>的概率增加，导致部分向量空转，从而降低了平均向量利用率</p>
<h4>arraySumVector(bonus)</h4>
<p>要求实现数组求和的向量化版本，保证了数组长度一定是<code>VECTOR_WIDTH</code>的倍数，并且<code>VECTOR_WIDTH</code>为2的幂</p>
<p>$O(\frac{n}{VECTOR_WIDTH}+VECTOR_WIDTH)$的做法是<code>trivial</code>的，考虑$O(\frac{n}{VECTOR_WIDTH}+ \log_2{VECTOR_WIDTH})$的做法</p>
<p>在使用向量累加数组中的元素后，考虑怎么优化向量内部求和的过程，由于已经提供了<code>hadd</code>和<code>interleave</code>两个内置函数，考虑每次<code>hadd</code>之后再<code>interleave</code>，此后向量前半部分的和就是原向量的总和，这样我们只需要$O(log_2{VECTOR_WIDTh})$次迭代即可</p>
<pre><code>// returns the sum of all elements in values
// You can assume N is a multiple of VECTOR_WIDTH
// You can assume VECTOR_WIDTH is a power of 2
float arraySumVector(float* values, int N) {
  
  //
  // CS149 STUDENTS TODO: Implement your vectorized version of arraySumSerial here
  //
    __cs149_vec_float acc = _cs149_vset_float(0.f);  
    __cs149_mask maskAll = _cs149_init_ones();

    for (int i=0; i&lt;N; i+=VECTOR_WIDTH) {
        __cs149_vec_float vec_values;
        _cs149_vload_float(vec_values, values + i, maskAll);
        _cs149_vadd_float(acc, acc, vec_values, maskAll);
    }
    for (int i = 1; i &lt; VECTOR_WIDTH; i &lt;&lt;=1) {
        _cs149_hadd_float(acc, acc);
        _cs149_interleave_float(acc, acc);
    }
    float *ptr = new float[VECTOR_WIDTH];
    _cs149_vstore_float(ptr, acc, maskAll);
    float result = ptr[0];
    delete ptr;
    return result;
}
</code></pre>
<h3>prog3_mandelbrot_ispc</h3>
<p>首先需要安装<code>ispc</code>, <code>sudo snap install ispc</code>即可</p>
<p><code>mandelbrot</code>的<code>ispc</code>代码已经给出，我们只需要<code>make</code>后运行即可，结果如下</p>
<pre><code>[mandelbrot serial]:            [140.509] ms
Wrote image file mandelbrot-serial.ppm
[mandelbrot ispc]:              [39.913] ms
Wrote image file mandelbrot-ispc.ppm
                                (3.52x speedup from ISPC)
</code></pre>
<p>发现实际只加速了3.5倍左右，与理论的<code>AVX2</code>8倍加速相差较大，究其原因是<code>mandelbrot</code>计算中每个点的迭代次数不同，导致了<code>divergence</code>，拉低了有效利用率，同时对于内存的访问可能同样是性能的瓶颈</p>
<p>注意到此处，我们只使用了数据级并行，还可以使用多线程进行任务级并行，<code>ispc</code>提供了<code>launch</code>机制，是用户级线程池，相较<code>std::thread</code>开销更低，采取工作窃取方式进行调度，更为高效</p>
<p>虽然理论上当使用<code>launch</code>创建的任务数与逻辑处理器数相等的时候加速比最高，但是实际上更高的任务数尽管增加了线程通信的成本，却可能使得任务负载更加均衡，进而提高效率。本地测试在任务数为50左右最为高效</p>
<pre><code>[mandelbrot serial]:            [140.531] ms
Wrote image file mandelbrot-serial.ppm
[mandelbrot ispc]:              [40.018] ms
Wrote image file mandelbrot-ispc.ppm
[mandelbrot multicore ispc]:    [4.202] ms
Wrote image file mandelbrot-task-ispc.ppm
                                (3.51x speedup from ISPC)
                                (33.45x speedup from task ISPC)
</code></pre>
<h3>prog4_sqrt</h3>
<p>已经完成了牛顿迭代法求解平方根倒数的<code>ispc</code>代码，其中需要求解的数据在$(0,3)$中，并且迭代次数与求解数据存在以下关系</p>
<p><img src="https://katyusha-blog.com/_astro/cs149-assignment1-image-004.zupi6GY__Z2tWXPP.webp" alt="image" /></p>
<h4>最好/最坏加速比的构造</h4>
<p>你需要构造两组输入的数据，使得<code>ispc</code>加速比最大/最小</p>
<p>这是随机生成状态下的加速比</p>
<pre><code>[sqrt serial]:          [633.462] ms
[sqrt ispc]:            [168.335] ms
[sqrt task ispc]:       [13.948] ms
                                (3.76x speedup from ISPC)
                                (45.42x speedup from task ISPC)
</code></pre>
<p>为了使得加速比最大化，我们既需要让指令级并行加速，既尽可能让<code>divergence</code>发生少，同时也需要加速任务级并行，即让每个任务负载尽量平均</p>
<p>一个很好的想法是输入值全部相同，这样不会发生<code>divergence</code>，同时任务的负载也是相当均匀的</p>
<p>注意到为了尽量提高加速比，在其他部分耗时不变的情况下，我们需要提高计算在总时间中的占比，取所有值都接近于3.0即可，此时运算最多，加速比也最大</p>
<pre><code>[sqrt serial]:          [2752.093] ms
[sqrt ispc]:            [573.343] ms
[sqrt task ispc]:       [50.224] ms
                                (4.80x speedup from ISPC)
                                (54.80x speedup from task ISPC)
</code></pre>
<p>要构造最坏加速比，我们不难联想到课程中的提问，只需要让8位向量中出现<code>divergence</code>，其他7位很快计算完毕后。空转，1位执行非常多的计算即可，使得CPU达到最坏利用率</p>
<p>所以考虑每隔8个数，构造一个为计算次数最多的接近于3.0，其它都为无需计算的1.0</p>
<pre><code>[sqrt serial]:          [420.101] ms
[sqrt ispc]:            [547.059] ms
[sqrt task ispc]:       [49.483] ms
                                (0.77x speedup from ISPC)
                                (8.49x speedup from task ISPC
</code></pre>
<p>有意思的是，ISPC向量化之后反而比标量执行更慢了，这可能是因为向量化引入的额外指令开销（如掩码操作、数据打包等）而变得更慢</p>
<h4>手写intrinsics实现牛顿迭代法求平方根倒数(bonus)</h4>
<p>完成以下ISPC代码的AVX2实现</p>
<pre><code>export void sqrt_ispc(uniform int N,
                      uniform float initialGuess,
                      uniform float values[],
                      uniform float output[])
{
    foreach (i = 0 ... N) {

        float x = values[i];
        float guess = initialGuess;

        float pred = abs(guess * guess * x - 1.f);

        while (pred &gt; kThreshold) {
            guess = (3.f * guess - x * guess * guess * guess) * 0.5f;
            pred = abs(guess * guess * x - 1.f);
        }

        output[i] = x * guess;
        
    }
}
</code></pre>
<p>请输入文本</p>
<pre><code>static void AVX2_invsqrt(int n, float initialGuess, float values[], float outputs[]) {
    __m256 vec_one = _mm256_set1_ps(1.0f);
    __m256 vec_three = _mm256_set1_ps(3.0f);
    __m256 vec_half = _mm256_set1_ps(0.5f);
    __m256 vec_neg = _mm256_set1_ps(-0.0f);
    __m256 vec_threshold = _mm256_set1_ps(kThreshold);

    auto calc = [&amp;](int i) {
        __m256 vec_x = _mm256_loadu_ps(values + i);
        __m256 vec_guess = _mm256_set1_ps(initialGuess);

        __m256 vec_pred = _mm256_mul_ps(vec_guess, vec_guess);
        vec_pred = _mm256_mul_ps(vec_pred, vec_x);
        vec_pred = _mm256_sub_ps(vec_pred, vec_one);
        vec_pred = _mm256_andnot_ps(vec_neg, vec_pred);
        
        __m256 mask = _mm256_cmp_ps(vec_pred, vec_threshold, _CMP_GT_OQ);
        while (_mm_popcnt_u32(_mm256_movemask_ps(mask))) {
            __m256 vec_sub, new_guess;
            new_guess = _mm256_mul_ps(vec_guess, vec_three);
            vec_sub = _mm256_mul_ps(vec_guess, vec_guess);
            vec_sub = _mm256_mul_ps(vec_sub, vec_guess);
            vec_sub = _mm256_mul_ps(vec_sub, vec_x);
            new_guess = _mm256_sub_ps(new_guess, vec_sub);
            new_guess = _mm256_mul_ps(new_guess, vec_half);
            vec_guess = _mm256_blendv_ps(vec_guess, new_guess, mask);

            __m256 new_pred;
            new_pred = _mm256_mul_ps(vec_guess, vec_guess);
            new_pred = _mm256_mul_ps(new_pred, vec_x);
            new_pred = _mm256_sub_ps(new_pred, vec_one);
            new_pred = _mm256_andnot_ps(vec_neg, new_pred);
            vec_pred = _mm256_blendv_ps(vec_pred, new_pred, mask);

            mask = _mm256_cmp_ps(vec_pred, vec_threshold, _CMP_GT_OQ);
        }
        
        _mm256_storeu_ps(outputs + i, _mm256_mul_ps(vec_guess, vec_x));
    };
    for (int i = 0; i + 7 &lt; n; i += 8) calc(i);
    if (n % 8 == 0) return;
    calc(n - 8);
}
</code></pre>
<p>实际性能如下：</p>
<pre><code>[sqrt serial]:          [425.807] ms
[sqrt ispc]:            [541.254] ms
[sqrt AVX2]:            [520.926] ms
[sqrt task ispc]:       [53.460] ms
                                (0.79x speedup from ISPC)
                                (0.82x speedup from AVX2)
                                (7.96x speedup from task ISPC)
</code></pre>
<p>发现相较<code>ISPC</code>没有显著提升，<s>所以没事别来写这一坨</s></p>
<h3>prog5_saxpy</h3>
<p>给了我们使用<code>ISPC</code>加速<code>saxpy</code>计算<code>result=scale*X+Y</code>，其中<code>result,X,Y</code>为$2\times10^7$大小的向量，<code>scale</code>为标量</p>
<p>运行后结果如下：</p>
<pre><code>[saxpy serial]:         [10.404] ms     [28.644] GB/s   [3.845] GFLOPS
[saxpy ispc]:           [8.441] ms      [35.306] GB/s   [4.739] GFLOPS
[saxpy task ispc]:      [4.405] ms      [67.656] GB/s   [9.081] GFLOPS
                                (1.92x speedup from use of tasks)
                                (1.23x speedup from ISPC)
                                (2.36x speedup from task ISPC)
</code></pre>
<p>我们发现加速效果相当差，远低于理论加速。不难发现，每两次运算(一次乘法和加法)需要访问内存四次(读取X,读取Y,对result读取所有权，写入result)共32字节，这与每访问字节进行10至20次运算的理想访存计算比相差极大，所以性能瓶颈在内存访问上，而不是计算上，无法通过并行计算达到近线性的加速</p>
<p>附加题：尝试有效地加速该程序 不会:(</p>
<h3>prog6_kmeans</h3>
<p><code>K-Means</code>算法实现了将一堆散乱的数据点分为<code>k</code>簇，所有数据点到其所属簇中心的距离平方和是一个局部最小值</p>
<p>给出你一段<code>K_Means</code>的正确但未优化的代码，要求自行找到性能热点并加速，要求至少达到2.1倍加速比</p>
<p>在<code>main.cpp</code>中去掉部分注释，本地生成数据，原始代码效率如下(运行时可能稍有波动)</p>
<pre><code>Reading data.dat...
Running K-means with: M=1000000, N=100, K=3, epsilon=0.100000
[Total Time]: 6587.359 ms
</code></pre>
<p>在重复迭代直至收敛的过程中，执行了三个函数<code>computeAssignments</code>，<code>computeCentroids</code>，<code>computeCost</code>，使用课程提供的<code>CycleTimer::currentSeconds</code>发现耗时分别如下</p>
<pre><code>Reading data.dat...
Running K-means with: M=1000000, N=100, K=3, epsilon=0.100000

computeAssignments: 4187.6ms
computeCentroids: 874.883ms
computeCost: 1277.58ms

assign_init: 28.4267ms assign_assign: 4158.83ms
centr_zero: 0.011599ms centr_sum: 874.803ms centr_compute: 0.003952ms
cost_zero: 0.008803ms cost_sum: 1277.49ms cost_update: 0.014024ms
[Total Time]: 6340.125 ms
</code></pre>
<p>发现性能瓶颈在<code>computeAssignments</code>中的分配部分，源代码如下</p>
<pre><code>  for (int k = args-&gt;start; k &lt; args-&gt;end; k++) {
    for (int m = 0; m &lt; args-&gt;M; m++) {
      double d = dist(&amp;args-&gt;data[m * args-&gt;N],
                      &amp;args-&gt;clusterCentroids[k * args-&gt;N], args-&gt;N);
      if (d &lt; minDist[m]) {
        minDist[m] = d;
        args-&gt;clusterAssignments[m] = k;
      }
    }
  }
</code></pre>
<p>考虑对这部分进行优化，阅读发现是对于每一个点，求离k个簇中哪个簇距离最近并更新</p>
<p>我们发现这<code>k</code>次更新具有并行性，可以考虑使用<code>k</code>个线程</p>
<pre><code>std::mutex mtx;

void assign(double *minDist, WorkerArgs *const args, int k) {
    for (int m = 0; m &lt; args-&gt;M; m++) {
      mtx.lock();
      double d = dist(&amp;args-&gt;data[m * args-&gt;N],
                      &amp;args-&gt;clusterCentroids[k * args-&gt;N], args-&gt;N);
      if (d &lt; minDist[m]) {
        minDist[m] = d;
        args-&gt;clusterAssignments[m] = k;
      }
      mtx.unlock();
    }
}

double assign_init = 0, assign_assign = 0;
void computeAssignments(WorkerArgs *const args) {
  double *minDist = new double[args-&gt;M];
  
  // Initialize arrays
  double timer; Set_timer(timer);
  for (int m =0; m &lt; args-&gt;M; m++) {
    minDist[m] = 1e30;
    args-&gt;clusterAssignments[m] = -1;
  }
  assign_init += Get_timer(timer);

  // Assign datapoints to closest centroids
  Set_timer(timer);
  static constexpr int SIZE = 3;
  std::thread workers[SIZE];
  for (int k = args-&gt;start + 1; k &lt; args-&gt;end; k++) {
    workers[k] = std::thread(assign, minDist, args, k);
  }
  assign(minDist, args, args-&gt;start);
  for (int k = args-&gt;start + 1; k &lt; args-&gt;end; k++) {
    workers[k].join();
  }
  assign_assign += Get_timer(timer);

  delete[] minDist;
}
</code></pre>
<p>注意会产生多个线程对于<code>minDist</code>和<code>args-&gt;clusterAssignments</code>的更新，需要上锁</p>
<p>效率如下：(我自己都要蚌埠住了)</p>
<pre><code>Reading data.dat...
Running K-means with: M=1000000, N=100, K=3, epsilon=0.100000

computeAssignments: 10419.6ms
computeCentroids: 843.017ms
computeCost: 1184.35ms

assign_init: 25.4263ms assign_assign: 10393.9ms
centr_zero: 0.009089ms centr_sum: 842.949ms centr_compute: 0.004045ms
cost_zero: 0.007699ms cost_sum: 1184.28ms cost_update: 0.009066ms
[Total Time]: 12447.063 ms
</code></pre>
<p>效率严重下降，因为加锁的代价大于线程的收益</p>
<p>于是考虑将<code>m</code>个任务分配给多个线程进行并行计算，这样保证了同一个资源只会被一个线程访问</p>
<pre><code>void assign(double *minDist, WorkerArgs *const args, int l, int r) {
    for (int k = args-&gt;start; k &lt; args-&gt;end; k++) {
        for (int m = l; m &lt; r; m++) {
        double d = dist(&amp;args-&gt;data[m * args-&gt;N],
                        &amp;args-&gt;clusterCentroids[k * args-&gt;N], args-&gt;N);
        if (d &lt; minDist[m]) {
            minDist[m] = d;
            args-&gt;clusterAssignments[m] = k;
        }
        }         
    }
}

double assign_init = 0, assign_assign = 0;
void computeAssignments(WorkerArgs *const args) {
  double *minDist = new double[args-&gt;M];
  
  // Initialize arrays
  double timer; Set_timer(timer);
  for (int m =0; m &lt; args-&gt;M; m++) {
    minDist[m] = 1e30;
    args-&gt;clusterAssignments[m] = -1;
  }
  assign_init += Get_timer(timer);

  // Assign datapoints to closest centroids
  Set_timer(timer);
  static constexpr int THREADS = 32;
  std::thread workers[THREADS];
  int len = args-&gt;M / THREADS;
  for (int i = 1; i &lt; THREADS; i++) {
      int l = i * len, r = l + len;
      if (i == THREADS - 1) r = args-&gt;M;
      workers[i] = std::thread(assign, minDist, args, l, r);
  }
  int l = 0, r = len; 
  assign(minDist, args, l, r);
  for (int i = 1; i &lt; THREADS; i++) workers[i].join();
  assign_assign += Get_timer(timer);

  delete[] minDist;
}
</code></pre>
<p>结果如下，刚好达到了要求的加速比，代码的其他部分也可以类似地并行化来提高效率，但是题目只要求了并行化一处</p>
<pre><code>Reading data.dat...
Running K-means with: M=1000000, N=100, K=3, epsilon=0.100000

computeAssignments: 1067.84ms
computeCentroids: 872.724ms
computeCost: 1160.83ms

assign_init: 25.0641ms assign_assign: 1042.34ms
centr_zero: 0.009931ms centr_sum: 872.655ms centr_compute: 0.004261ms
cost_zero: 0.008192ms cost_sum: 1160.75ms cost_update: 0.012866ms
[Total Time]: 3101.462 ms
</code></pre>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="CS149"></category>
  </entry>
  <entry>
    <title>assignment2</title>
    <link href="https://katyusha-blog.com/posts/cs149/assignment2/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/cs149/assignment2/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-16T00:00:00.000Z</updated>
    <summary>assignment2</summary>
    <content type="html"><![CDATA[<p>环境</p>
<p>OS:Windows11 wsl2 6.6.87.2-microsoft-standard-WSL2 Ubuntu 24.04.3 LTS</p>
<p>CPU: Intel Core i7 13620H 8 cores, 10 logic processors, AVX2</p>
<p>GPU:NVIDIA GeForce RTX 4060 Laptop</p>
<h2>assignment2</h2>
<p>这一部分要求你补全一个类似于<code>ISPC</code>的任务系统，其余部分已经完成，你需要完成负载分配部分</p>
<p>你需要完成无依赖和有依赖的任务系统，并且要用每次创建线程，使用线程池且线程自旋，使用线程池且线程休眠三种方式实现</p>
<h3>无依赖的任务系统</h3>
<h4>每次创建线程</h4>
<p>这是<code>trivial</code>的，每次创建线程执行任务即可，为了平均负载，每当有空闲进程时就取出下一个任务来给该线程执行</p>
<p>注意当前执行到了哪一个进程这个变量会被多个线程使用，需要声明为原子变量</p>
<pre><code>//tasksys.h
class TaskSystemParallelSpawn: public ITaskSystem {
    public:
        TaskSystemParallelSpawn(int num_threads);
        ~TaskSystemParallelSpawn() override;
        const char* name();
        void run(IRunnable* runnable, int num_total_tasks);
        TaskID runAsyncWithDeps(IRunnable* runnable, int num_total_tasks,
                                const std::vector&lt;TaskID&gt;&amp; deps);
        void sync();
    private:
        int Num_Threads;
        std::atomic &lt;int&gt; task_ptr;
        std::thread *thread_ptr;
};

//tasksys.cpp
const char* TaskSystemParallelSpawn::name() {
    return "Parallel + Always Spawn";
}

TaskSystemParallelSpawn::TaskSystemParallelSpawn(int num_threads) 
    : ITaskSystem(num_threads), 
      Num_Threads(num_threads), 
      thread_ptr(new std::thread[num_threads]),
      task_ptr(0) {

    //
    // TODO: CS149 student implementations may decide to perform setup
    // operations (such as thread pool construction) here.
    // Implementations are free to add new class member variables
    // (requiring changes to tasksys.h).
    //
}

TaskSystemParallelSpawn::~TaskSystemParallelSpawn() {
    delete[] thread_ptr;
}

void TaskSystemParallelSpawn::run(IRunnable* runnable, int num_total_tasks){

    //
    // TODO: CS149 students will modify the implementation of this
    // method in Part A.  The implementation provided below runs all
    // tasks sequentially on the calling thread.
    //
    task_ptr = 0;
    auto work = [&amp;]() {
        while(1) {
            int task_id = task_ptr.fetch_add(1);
            if (task_id &gt;= num_total_tasks) break;
            runnable-&gt;runTask(task_id, num_total_tasks);
        }
    };

    for (int i = 0; i &lt; Num_Threads; i++) {
        thread_ptr[i] = std::thread(work);
    }
    for (int i = 0; i &lt; Num_Threads; i++) {
        thread_ptr[i].join();
    }
}

TaskID TaskSystemParallelSpawn::runAsyncWithDeps(IRunnable* runnable, int num_total_tasks,
                                                 const std::vector&lt;TaskID&gt;&amp; deps) {
    // You do not need to implement this method.
    return 0;
}

void TaskSystemParallelSpawn::sync() {
    // You do not need to implement this method.
    return;
}
</code></pre>
<h4>自旋线程池</h4>
<p>每次都创建线程会产生额外的开销，为了减少开销，可以在任务系统被创建的时候就创建线程，在需要的时候执行任务，否则自旋，当任务系统调用析构函数的时候，结束所有线程</p>
<pre><code>//tasksys.h
class TaskSystemParallelThreadPoolSpinning: public ITaskSystem {
    public:
        TaskSystemParallelThreadPoolSpinning(int num_threads);
        ~TaskSystemParallelThreadPoolSpinning();
        const char* name();
        void run(IRunnable* runnable, int num_total_tasks);
        TaskID runAsyncWithDeps(IRunnable* runnable, int num_total_tasks,
                                const std::vector&lt;TaskID&gt;&amp; deps);
        void sync();
    private:
        int Num_Threads;
        std::thread *thread_ptr;
        IRunnable *task;
        int task_num;
        std::atomic &lt;bool&gt; end;
        std::atomic&lt;int&gt; task_ptr, task_done;
        std::mutex mtx;
};

//tasksys.cpp
const char* TaskSystemParallelThreadPoolSpinning::name() {
    return "Parallel + Thread Pool + Spin";
}

TaskSystemParallelThreadPoolSpinning::TaskSystemParallelThreadPoolSpinning(int num_threads)
    : ITaskSystem(num_threads),
      Num_Threads(num_threads),
      thread_ptr(new std::thread[num_threads]),
      task(nullptr), 
      task_num(0), 
      task_ptr(0),
      end(false) {

    //
    // TODO: CS149 student implementations may decide to perform setup
    // operations (such as thread pool construction) here.
    // Implementations are free to add new class member variables
    // (requiring changes to tasksys.h).
    //

    auto work = [&amp;]() {
        while (1) {
            if (end) break;
            int task_id = -1; {
                std::unique_lock &lt;std::mutex&gt; lock(mtx);
                if (task_ptr &lt; task_num) {
                    task_id = task_ptr;
                    task_ptr++;
                }
            }

            if (task_id != -1) {
                task-&gt;runTask(task_id, task_num);
                task_done.fetch_add(1);
            }
        }
    };

    for (int i = 0; i &lt; num_threads; i++) {
        thread_ptr[i] = std::thread(work);
    }
}

TaskSystemParallelThreadPoolSpinning::~TaskSystemParallelThreadPoolSpinning() {
    end = true;
    for (int i = 0; i &lt; Num_Threads; i++) {
        thread_ptr[i].join();
    }
    delete[] thread_ptr;
}

void TaskSystemParallelThreadPoolSpinning::run(IRunnable* runnable, int num_total_tasks) {


    //
    // TODO: CS149 students will modify the implementation of this
    // method in Part A.  The implementation provided below runs all
    // tasks sequentially on the calling thread.
    //
    mtx.lock();
    task = runnable;
    task_num = num_total_tasks;
    task_ptr = 0;
    task_done = 0;
    mtx.unlock();
    while (task_done &lt; task_num) {}
}

TaskID TaskSystemParallelThreadPoolSpinning::runAsyncWithDeps(IRunnable* runnable, int num_total_tasks,
                                                              const std::vector&lt;TaskID&gt;&amp; deps) {
    // You do not need to implement this method.
    return 0;
}

void TaskSystemParallelThreadPoolSpinning::sync() {
    // You do not need to implement this method.
    return;
}
</code></pre>
<h4>休眠线程池</h4>
<p>当一个线程在没有任务自旋的时候，仍然会占用CPU，考虑在没有剩余任务的时候让线程自旋</p>
<p><code>C++</code>提供了条件变量<code>condition_variable</code>来进行控制</p>
<p>对于一个条件变量，可以通过其成员函数<code>wait(std::unique_lock, pred)</code> 使得拥有指定的互斥锁的线程休眠，直到接到通知并且<code>pred</code>为<code>true</code>(如果不设置<code>pred</code>，可能出现丢失唤醒和虚假唤醒)；通过成员函数<code>notify_all/one</code> 来通知该条件变量的所有/随机一个线程</p>
<pre><code>//tasksys.h
class TaskSystemParallelThreadPoolSleeping: public ITaskSystem {
    public:
        TaskSystemParallelThreadPoolSleeping(int num_threads);
        ~TaskSystemParallelThreadPoolSleeping();
        const char* name();
        void run(IRunnable* runnable, int num_total_tasks);
        TaskID runAsyncWithDeps(IRunnable* runnable, int num_total_tasks,
                                const std::vector&lt;TaskID&gt;&amp; deps);
        void sync();
    private:
        int Num_Threads;
        std::thread *thread_ptr;
        IRunnable *task;
        int task_num;
        std::atomic &lt;bool&gt; end;
        std::atomic&lt;int&gt; task_ptr, task_done;
        std::mutex mtx;
        std::condition_variable cond;
};

//tasksys.cpp
const char* TaskSystemParallelThreadPoolSleeping::name() {
    return "Parallel + Thread Pool + Sleep";
}

TaskSystemParallelThreadPoolSleeping::TaskSystemParallelThreadPoolSleeping(int num_threads)
    : ITaskSystem(num_threads),
      Num_Threads(num_threads),
      thread_ptr(new std::thread[num_threads]),
      task(nullptr),
      end(false), 
      task_num(0),
      task_done(0),
      task_ptr(0) {
    //
    // TODO: CS149 student implementations may decide to perform setup
    // operations (such as thread pool construction) here.
    // Implementations are free to add new class member variables
    // (requiring changes to tasksys.h).
    //

    auto work = [&amp;]() {
        while (1) {
            int task_id = -1; {
                std::unique_lock &lt;std::mutex&gt; lock(mtx);
                cond.wait(lock, [&amp;]() {
                    return (task_ptr &lt; task_num) || end;
                });
                if (end) break;
                task_id = task_ptr;
                task_ptr++;             
            }

            if (task_id != -1) {
                task-&gt;runTask(task_id, task_num);
                task_done.fetch_add(1);
            }
        }
    };
    for (int i = 0; i &lt; Num_Threads; i++) {
        thread_ptr[i] = std::thread(work);
    }
}

TaskSystemParallelThreadPoolSleeping::~TaskSystemParallelThreadPoolSleeping() {
    //
    // TODO: CS149 student implementations may decide to perform cleanup
    // operations (such as thread pool shutdown construction) here.
    // Implementations are free to add new class member variables
    // (requiring changes to tasksys.h).
    //
    end = 1;
    cond.notify_all();
    for (int i = 0; i &lt; Num_Threads; i++) {
        thread_ptr[i].join();
    }
}

void TaskSystemParallelThreadPoolSleeping::run(IRunnable* runnable, int num_total_tasks) {


    //
    // TODO: CS149 students will modify the implementation of this
    // method in Parts A and B.  The implementation provided below runs all
    // tasks sequentially on the calling thread.
    //
    mtx.lock();
    task = runnable;
    task_num = num_total_tasks;
    task_done = 0;
    task_ptr = 0;
    mtx.unlock();
    cond.notify_all();
    while (task_done &lt; task_num) {}

}

TaskID TaskSystemParallelThreadPoolSleeping::runAsyncWithDeps(IRunnable* runnable, int num_total_tasks,
                                                    const std::vector&lt;TaskID&gt;&amp; deps) {


    //
    // TODO: CS149 students will implement this method in Part B.
    //

    return 0;
}

void TaskSystemParallelThreadPoolSleeping::sync() {

    //
    // TODO: CS149 students will modify the implementation of this method in Part B.
    //

    return;
}
</code></pre>
<p>测试结果如下</p>
<pre><code>katyusha@Katyusha-PC:~/lesson/CS149/asst2/part_a$ python3 ../tests/run_test_harness.py
runtasks_ref
Linux x86_64
================================================================================
Running task system grading harness... (11 total tests)
  - Detected CPU with 16 execution contexts
  - Task system configured to use at most 16 threads
================================================================================
================================================================================
Executing test: super_super_light...
Reference binary: ./runtasks_ref_linux
Results for: super_super_light
                                        STUDENT   REFERENCE   PERF?
[Serial]                                3.047     3.205       0.95  (OK)
[Parallel + Always Spawn]               187.19    185.967     1.01  (OK)
[Parallel + Thread Pool + Spin]         17.331    24.842      0.70  (OK)
[Parallel + Thread Pool + Sleep]        49.395    50.039      0.99  (OK)
================================================================================
Executing test: super_light...
Reference binary: ./runtasks_ref_linux
Results for: super_light
                                        STUDENT   REFERENCE   PERF?
[Serial]                                40.591    39.736      1.02  (OK)
[Parallel + Always Spawn]               192.036   192.494     1.00  (OK)
[Parallel + Thread Pool + Spin]         16.801    28.911      0.58  (OK)
[Parallel + Thread Pool + Sleep]        50.339    49.974      1.01  (OK)
================================================================================
Executing test: ping_pong_equal...
Reference binary: ./runtasks_ref_linux
Results for: ping_pong_equal
                                        STUDENT   REFERENCE   PERF?
[Serial]                                647.781   650.243     1.00  (OK)
[Parallel + Always Spawn]               226.69    232.931     0.97  (OK)
[Parallel + Thread Pool + Spin]         127.354   151.776     0.84  (OK)
[Parallel + Thread Pool + Sleep]        141.546   151.333     0.94  (OK)
================================================================================
Executing test: ping_pong_unequal...
Reference binary: ./runtasks_ref_linux
Results for: ping_pong_unequal
                                        STUDENT   REFERENCE   PERF?
[Serial]                                1190.3    1201.024    0.99  (OK)
[Parallel + Always Spawn]               296.642   301.453     0.98  (OK)
[Parallel + Thread Pool + Spin]         191.766   201.452     0.95  (OK)
[Parallel + Thread Pool + Sleep]        205.871   201.991     1.02  (OK)
================================================================================
Executing test: recursive_fibonacci...
Reference binary: ./runtasks_ref_linux
Results for: recursive_fibonacci
                                        STUDENT   REFERENCE   PERF?
[Serial]                                563.32    1044.844    0.54  (OK)
[Parallel + Always Spawn]               96.153    147.336     0.65  (OK)
[Parallel + Thread Pool + Spin]         95.011    161.128     0.59  (OK)
[Parallel + Thread Pool + Sleep]        95.246    136.351     0.70  (OK)
================================================================================
Executing test: math_operations_in_tight_for_loop...
Reference binary: ./runtasks_ref_linux
Results for: math_operations_in_tight_for_loop
                                        STUDENT   REFERENCE   PERF?
[Serial]                                321.704   364.809     0.88  (OK)
[Parallel + Always Spawn]               902.166   959.155     0.94  (OK)
[Parallel + Thread Pool + Spin]         109.294   148.513     0.74  (OK)
[Parallel + Thread Pool + Sleep]        233.708   260.56      0.90  (OK)
================================================================================
Executing test: math_operations_in_tight_for_loop_fewer_tasks...
Reference binary: ./runtasks_ref_linux
Results for: math_operations_in_tight_for_loop_fewer_tasks
                                        STUDENT   REFERENCE   PERF?
[Serial]                                355.836   368.551     0.97  (OK)
[Parallel + Always Spawn]               956.475   954.91      1.00  (OK)
[Parallel + Thread Pool + Spin]         152.932   179.352     0.85  (OK)
[Parallel + Thread Pool + Sleep]        258.914   261.421     0.99  (OK)
================================================================================
Executing test: math_operations_in_tight_for_loop_fan_in...
Reference binary: ./runtasks_ref_linux
Results for: math_operations_in_tight_for_loop_fan_in
                                        STUDENT   REFERENCE   PERF?
[Serial]                                184.691   187.97      0.98  (OK)
[Parallel + Always Spawn]               129.168   125.984     1.03  (OK)
[Parallel + Thread Pool + Spin]         39.776    45.638      0.87  (OK)
[Parallel + Thread Pool + Sleep]        51.235    53.255      0.96  (OK)
================================================================================
Executing test: math_operations_in_tight_for_loop_reduction_tree...
Reference binary: ./runtasks_ref_linux
Results for: math_operations_in_tight_for_loop_reduction_tree
                                        STUDENT   REFERENCE   PERF?
[Serial]                                185.884   190.249     0.98  (OK)
[Parallel + Always Spawn]               57.856    58.765      0.98  (OK)
[Parallel + Thread Pool + Spin]         38.416    38.12       1.01  (OK)
[Parallel + Thread Pool + Sleep]        41.4      38.552      1.07  (OK)
================================================================================
Executing test: spin_between_run_calls...
Reference binary: ./runtasks_ref_linux
Results for: spin_between_run_calls
                                        STUDENT   REFERENCE   PERF?
[Serial]                                219.686   373.554     0.59  (OK)
[Parallel + Always Spawn]               112.957   191.493     0.59  (OK)
[Parallel + Thread Pool + Spin]         192.487   238.134     0.81  (OK)
[Parallel + Thread Pool + Sleep]        122.142   191.913     0.64  (OK)
================================================================================
Executing test: mandelbrot_chunked...
Reference binary: ./runtasks_ref_linux
Results for: mandelbrot_chunked
                                        STUDENT   REFERENCE   PERF?
[Serial]                                273.413   274.303     1.00  (OK)
[Parallel + Always Spawn]               30.291    28.085      1.08  (OK)
[Parallel + Thread Pool + Spin]         25.06     26.769      0.94  (OK)
[Parallel + Thread Pool + Sleep]        26.543    25.636      1.04  (OK)
================================================================================
Overall performance results
[Serial]                                : All passed Perf
[Parallel + Always Spawn]               : All passed Perf
[Parallel + Thread Pool + Spin]         : All passed Perf
[Parallel + Thread Pool + Sleep]        : All passed Perf
</code></pre>
<h3>有依赖的任务系统</h3>
<p>要求你在休眠线程池功能基础上，实现<code>runAsyncWithDeps</code>函数，该函数相较于<code>run</code>还传入了一个<code>std::vector&lt;TaskID&gt;</code>参数，用来表示前置需要完成的任务集合，返回值为该任务的<code>TaskID</code>。该任务与<code>run</code>中的任务异步执行，直到调用<code>sync()</code>函数，阻塞主线程直到所有的依赖任务都已经完成</p>
<p>可以将<code>run</code>看做一个特殊的无依赖的任务，在对其调用<code>runAsyncWithDeps</code>后立即<code>sync()</code>同步</p>
<p>我们使用了队列来维护可执行的任务(即所有前置任务都已经完成的任务)，每次一个线程都尝试在队列中取出任务进行执行。</p>
<p>为了维护任务间的依赖关系，我们对每个任务都维护了一个<code>suf</code>集合，用来表示依赖于该任务的后继。当一个任务子任务全部完成时，就更新其所有后继的状态，若一个后继的前置任务全部完成，就将该后继加入队列；当申请一个新的任务时，对于其未完成的前置任务，尝试更新其<code>suf</code>集合</p>
<p>在实现上存在诸多细节，比如使用<code>vector</code>的话，原本有效的迭代器可能因为其他线程操作扩容而导致失效造成悬空引用，可以使用<code>deque</code>，<code>unordered_map</code>数据结构进行替代，或者采用下标索引；在效率方面尽量少使用互斥锁，多使用原子变量并且在必须使用互斥锁的时候只进行简单的标记，记录等操作</p>
<pre><code>//tasksys.h
class TaskSystemParallelThreadPoolSleeping: public ITaskSystem {
    public:
        TaskSystemParallelThreadPoolSleeping(int num_threads);
        ~TaskSystemParallelThreadPoolSleeping();
        const char* name();
        void run(IRunnable* runnable, int num_total_tasks);
        TaskID runAsyncWithDeps(IRunnable* runnable, int num_total_tasks,
                                const std::vector&lt;TaskID&gt;&amp; deps);
        void sync();
    private:
        std::thread *thread_ptr;
        IRunnable *task;
        int Num_threads;
        int task_done = 0;
        std::queue&lt;TaskID&gt; task_que;
        struct Task {
            std::vector &lt;int&gt; suf;
            IRunnable *task;
            std::atomic&lt;int&gt; task_ptr{0}, task_done{0}, pre_done{0};
            int task_num, pre_cnt;
            Task() {}
            Task(IRunnable *T, int Task_num, int Pre_cnt) 
                : task(T), 
                  task_num(Task_num),
                  pre_cnt(Pre_cnt) {

            }
        };
        std::vector &lt;Task*&gt; task_vec;
        std::mutex mtx;
        std::condition_variable work_cv, sync_cv;
        bool end = false;
};

//tasksys.cpp

/*
 * ================================================================
 * Parallel Thread Pool Sleeping Task System Implementation
 * ================================================================
 */

const char* TaskSystemParallelThreadPoolSleeping::name() {
    return "Parallel + Thread Pool + Sleep";
}

TaskSystemParallelThreadPoolSleeping::TaskSystemParallelThreadPoolSleeping(int num_threads)
    : ITaskSystem(num_threads),
      Num_threads(num_threads),
      thread_ptr(new std::thread[num_threads]),
      end(false),
      task_done(0) {
    //
    // TODO: CS149 student implementations may decide to perform setup
    // operations (such as thread pool construction) here.
    // Implementations are free to add new class member variables
    // (requiring changes to tasksys.h).
    //
    auto work = [&amp;]() {
        while (1) {
            int task_id = -1, subtask_id = -1;
            Task *ptr = nullptr; {
                std::unique_lock &lt;std::mutex&gt; lock(mtx);
                work_cv.wait(lock, [&amp;]() {
                    return (!task_que.empty()) || end;
                });
                if (end &amp;&amp; task_que.empty()) break;
                task_id = task_que.front();
                ptr = task_vec[task_id];
                if (ptr-&gt;task_ptr &gt;= ptr-&gt;task_num) {
                    task_que.pop();
                    continue;
                }
            }

            if (ptr != nullptr) {
                while (1) {
                    subtask_id = ptr-&gt;task_ptr.fetch_add(1);
                    if (subtask_id &gt;= ptr-&gt;task_num) break; 
                    ptr-&gt;task-&gt;runTask(subtask_id, ptr-&gt;task_num); 
                    int done = ptr-&gt;task_done.fetch_add(1) + 1; 
                    if (done == ptr-&gt;task_num){
                        std::unique_lock &lt;std::mutex&gt; lock(mtx);
                        ++task_done;
                        for (const auto &amp;idx : ptr-&gt;suf) {
                            Task *_ptr = task_vec[idx];
                            if (++_ptr-&gt;pre_done == _ptr-&gt;pre_cnt) {
                                task_que.push(idx);
                                work_cv.notify_all();
                            }    
                        }
                        sync_cv.notify_all();
                    }                    
                }
            }
        }
    };
    for (int i = 0; i &lt; Num_threads; i++) {
        thread_ptr[i] = std::thread(work);
    }
}

TaskSystemParallelThreadPoolSleeping::~TaskSystemParallelThreadPoolSleeping() {
    //
    // TODO: CS149 student implementations may decide to perform cleanup
    // operations (such as thread pool shutdown construction) here.
    // Implementations are free to add new class member variables
    // (requiring changes to tasksys.h).
    //
    std::unique_lock &lt;std::mutex&gt; lock(mtx);
    end = 1;
    lock.unlock();
    work_cv.notify_all();
    for (int i = 0; i &lt; Num_threads; i++) {
        thread_ptr[i].join();
    }
    delete[] thread_ptr;
    for (auto ptr : task_vec) {
        delete ptr;
    }
}

void TaskSystemParallelThreadPoolSleeping::run(IRunnable* runnable, int num_total_tasks) {


    //
    // TODO: CS149 students will modify the implementation of this
    // method in Parts A and B.  The implementation provided below runs all
    // tasks sequentially on the calling thread.
    //
    runAsyncWithDeps(runnable, num_total_tasks, std::vector&lt;TaskID&gt;{}); 
    sync();
}

TaskID TaskSystemParallelThreadPoolSleeping::runAsyncWithDeps(IRunnable* runnable, int num_total_tasks,
                                                    const std::vector&lt;TaskID&gt;&amp; deps) {


    //
    // TODO: CS149 students will implement this method in Part B.
    //
    std::unique_lock &lt;std::mutex&gt; lock(mtx);
    int id = static_cast &lt;int&gt; (task_vec.size());
    int pre = 0;
    for (const auto &amp;idx : deps) {
        Task *ptr = task_vec[idx];
        if (ptr-&gt;task_done &lt; ptr-&gt;task_num) {
            pre++;
            ptr-&gt;suf.push_back(id);
        }
    }
    Task *ptr = new Task(runnable, num_total_tasks, pre);
    task_vec.push_back(ptr);

    if (pre == 0) {
        task_que.push(id);
        work_cv.notify_all();
    }

    return id;
}

void TaskSystemParallelThreadPoolSleeping::sync() {

    //
    // TODO: CS149 students will modify the implementation of this method in Part B.
    //
    std::unique_lock &lt;std::mutex&gt; lock(mtx);
    sync_cv.wait(lock, [&amp;]() {
        return task_done == static_cast&lt;int&gt;(task_vec.size());
    });
}

</code></pre>
<p>评测结果如下</p>
<pre><code>katyusha@Katyusha-PC:~/lesson/CS149/asst2/part_b$ python3 ../tests/run_test_harness.py -a
runtasks_ref
Linux x86_64
================================================================================
Running task system grading harness... (22 total tests)
  - Detected CPU with 16 execution contexts
  - Task system configured to use at most 16 threads
================================================================================
================================================================================
Executing test: super_super_light...
Reference binary: ./runtasks_ref_linux
Results for: super_super_light
                                        STUDENT   REFERENCE   PERF?
[Serial]                                3.374     3.232       1.04  (OK)
[Parallel + Always Spawn]               3.294     143.047     0.02  (OK)
[Parallel + Thread Pool + Spin]         3.297     32.456      0.10  (OK)
[Parallel + Thread Pool + Sleep]        34.289    49.513      0.69  (OK)
================================================================================
Executing test: super_super_light_async...
Reference binary: ./runtasks_ref_linux
Results for: super_super_light_async
                                        STUDENT   REFERENCE   PERF?
[Serial]                                3.304     3.167       1.04  (OK)
[Parallel + Always Spawn]               3.28      141.914     0.02  (OK)
[Parallel + Thread Pool + Spin]         3.294     25.471      0.13  (OK)
[Parallel + Thread Pool + Sleep]        9.923     49.523      0.20  (OK)
================================================================================
Executing test: super_light...
Reference binary: ./runtasks_ref_linux
Results for: super_light
                                        STUDENT   REFERENCE   PERF?
[Serial]                                43.447    40.952      1.06  (OK)
[Parallel + Always Spawn]               43.668    151.303     0.29  (OK)
[Parallel + Thread Pool + Spin]         43.431    35.089      1.24  (OK)
[Parallel + Thread Pool + Sleep]        60.807    49.644      1.22  (OK)
================================================================================
Executing test: super_light_async...
Reference binary: ./runtasks_ref_linux
Results for: super_light_async
                                        STUDENT   REFERENCE   PERF?
[Serial]                                43.896    40.416      1.09  (OK)
[Parallel + Always Spawn]               43.025    149.93      0.29  (OK)
[Parallel + Thread Pool + Spin]         43.679    29.589      1.48  (OK)
[Parallel + Thread Pool + Sleep]        28.933    24.183      1.20  (OK)
================================================================================
Executing test: ping_pong_equal...
Reference binary: ./runtasks_ref_linux
Results for: ping_pong_equal
                                        STUDENT   REFERENCE   PERF?
[Serial]                                704.422   648.376     1.09  (OK)
[Parallel + Always Spawn]               702.252   204.452     3.43  (NOT OK)
[Parallel + Thread Pool + Spin]         699.26    156.193     4.48  (NOT OK)
[Parallel + Thread Pool + Sleep]        191.514   151.617     1.26  (OK)
================================================================================
Executing test: ping_pong_equal_async...
Reference binary: ./runtasks_ref_linux
Results for: ping_pong_equal_async
                                        STUDENT   REFERENCE   PERF?
[Serial]                                700.791   647.809     1.08  (OK)
[Parallel + Always Spawn]               703.253   208.985     3.37  (NOT OK)
[Parallel + Thread Pool + Spin]         702.64    156.071     4.50  (NOT OK)
[Parallel + Thread Pool + Sleep]        183.948   139.78      1.32  (OK)
================================================================================
Executing test: ping_pong_unequal...
Reference binary: ./runtasks_ref_linux
Results for: ping_pong_unequal
                                        STUDENT   REFERENCE   PERF?
[Serial]                                1291.155  1214.399    1.06  (OK)
[Parallel + Always Spawn]               1297.076  265.625     4.88  (NOT OK)
[Parallel + Thread Pool + Spin]         1284.404  207.297     6.20  (NOT OK)
[Parallel + Thread Pool + Sleep]        262.049   201.473     1.30  (OK)
================================================================================
Executing test: ping_pong_unequal_async...
Reference binary: ./runtasks_ref_linux
Results for: ping_pong_unequal_async
                                        STUDENT   REFERENCE   PERF?
[Serial]                                1292.723  1214.691    1.06  (OK)
[Parallel + Always Spawn]               1293.555  263.932     4.90  (NOT OK)
[Parallel + Thread Pool + Spin]         1295.538  206.042     6.29  (NOT OK)
[Parallel + Thread Pool + Sleep]        258.012   197.771     1.30  (OK)
================================================================================
Executing test: recursive_fibonacci...
Reference binary: ./runtasks_ref_linux
Results for: recursive_fibonacci
                                        STUDENT   REFERENCE   PERF?
[Serial]                                672.702   1059.673    0.63  (OK)
[Parallel + Always Spawn]               676.334   144.753     4.67  (NOT OK)
[Parallel + Thread Pool + Spin]         671.824   160.962     4.17  (NOT OK)
[Parallel + Thread Pool + Sleep]        108.368   139.518     0.78  (OK)
================================================================================
Executing test: recursive_fibonacci_async...
Reference binary: ./runtasks_ref_linux
Results for: recursive_fibonacci_async
                                        STUDENT   REFERENCE   PERF?
[Serial]                                677.219   1065.529    0.64  (OK)
[Parallel + Always Spawn]               671.694   141.044     4.76  (NOT OK)
[Parallel + Thread Pool + Spin]         672.491   137.377     4.90  (NOT OK)
[Parallel + Thread Pool + Sleep]        101.067   134.396     0.75  (OK)
================================================================================
Executing test: math_operations_in_tight_for_loop...
Reference binary: ./runtasks_ref_linux
Results for: math_operations_in_tight_for_loop
                                        STUDENT   REFERENCE   PERF?
[Serial]                                386.117   367.139     1.05  (OK)
[Parallel + Always Spawn]               390.633   763.636     0.51  (OK)
[Parallel + Thread Pool + Spin]         387.205   155.567     2.49  (NOT OK)
[Parallel + Thread Pool + Sleep]        335.568   260.16      1.29  (OK)
================================================================================
Executing test: math_operations_in_tight_for_loop_async...
Reference binary: ./runtasks_ref_linux
Results for: math_operations_in_tight_for_loop_async
                                        STUDENT   REFERENCE   PERF?
[Serial]                                379.027   357.445     1.06  (OK)
[Parallel + Always Spawn]               379.425   748.564     0.51  (OK)
[Parallel + Thread Pool + Spin]         381.661   129.059     2.96  (NOT OK)
[Parallel + Thread Pool + Sleep]        251.303   167.617     1.50  (OK)
================================================================================
Executing test: math_operations_in_tight_for_loop_fewer_tasks...
Reference binary: ./runtasks_ref_linux
Results for: math_operations_in_tight_for_loop_fewer_tasks
                                        STUDENT   REFERENCE   PERF?
[Serial]                                386.693   359.973     1.07  (OK)
[Parallel + Always Spawn]               385.614   767.266     0.50  (OK)
[Parallel + Thread Pool + Spin]         382.28    195.048     1.96  (NOT OK)
[Parallel + Thread Pool + Sleep]        361.556   261.403     1.38  (OK)
================================================================================
Executing test: math_operations_in_tight_for_loop_fewer_tasks_async...
Reference binary: ./runtasks_ref_linux
Results for: math_operations_in_tight_for_loop_fewer_tasks_async
                                        STUDENT   REFERENCE   PERF?
[Serial]                                383.238   362.11      1.06  (OK)
[Parallel + Always Spawn]               383.221   748.586     0.51  (OK)
[Parallel + Thread Pool + Spin]         383.377   60.354      6.35  (NOT OK)
[Parallel + Thread Pool + Sleep]        64.755    95.945      0.67  (OK)
================================================================================
Executing test: math_operations_in_tight_for_loop_fan_in...
Reference binary: ./runtasks_ref_linux
Results for: math_operations_in_tight_for_loop_fan_in
                                        STUDENT   REFERENCE   PERF?
[Serial]                                199.654   185.18      1.08  (OK)
[Parallel + Always Spawn]               199.644   111.129     1.80  (NOT OK)
[Parallel + Thread Pool + Spin]         200.94    46.147      4.35  (NOT OK)
[Parallel + Thread Pool + Sleep]        77.969    56.712      1.37  (OK)
================================================================================
Executing test: math_operations_in_tight_for_loop_fan_in_async...
Reference binary: ./runtasks_ref_linux
Results for: math_operations_in_tight_for_loop_fan_in_async
                                        STUDENT   REFERENCE   PERF?
[Serial]                                200.266   186.405     1.07  (OK)
[Parallel + Always Spawn]               196.858   107.224     1.84  (NOT OK)
[Parallel + Thread Pool + Spin]         196.916   34.597      5.69  (NOT OK)
[Parallel + Thread Pool + Sleep]        32.059    32.355      0.99  (OK)
================================================================================
Executing test: math_operations_in_tight_for_loop_reduction_tree...
Reference binary: ./runtasks_ref_linux
Results for: math_operations_in_tight_for_loop_reduction_tree
                                        STUDENT   REFERENCE   PERF?
[Serial]                                200.065   189.841     1.05  (OK)
[Parallel + Always Spawn]               198.557   55.519      3.58  (NOT OK)
[Parallel + Thread Pool + Spin]         195.579   36.797      5.32  (NOT OK)
[Parallel + Thread Pool + Sleep]        41.008    39.417      1.04  (OK)
================================================================================
Executing test: math_operations_in_tight_for_loop_reduction_tree_async...
Reference binary: ./runtasks_ref_linux
Results for: math_operations_in_tight_for_loop_reduction_tree_async
                                        STUDENT   REFERENCE   PERF?
[Serial]                                196.123   183.502     1.07  (OK)
[Parallel + Always Spawn]               195.809   51.347      3.81  (NOT OK)
[Parallel + Thread Pool + Spin]         195.512   29.132      6.71  (NOT OK)
[Parallel + Thread Pool + Sleep]        34.974    29.707      1.18  (OK)
================================================================================
Executing test: spin_between_run_calls...
Reference binary: ./runtasks_ref_linux
Results for: spin_between_run_calls
                                        STUDENT   REFERENCE   PERF?
[Serial]                                238.154   376.977     0.63  (OK)
[Parallel + Always Spawn]               234.787   192.36      1.22  (OK)
[Parallel + Thread Pool + Spin]         235.452   236.406     1.00  (OK)
[Parallel + Thread Pool + Sleep]        121.11    192.589     0.63  (OK)
================================================================================
Executing test: spin_between_run_calls_async...
Reference binary: ./runtasks_ref_linux
Results for: spin_between_run_calls_async
                                        STUDENT   REFERENCE   PERF?
[Serial]                                242.261   376.192     0.64  (OK)
[Parallel + Always Spawn]               235.36    193.519     1.22  (OK)
[Parallel + Thread Pool + Spin]         235.4     229.247     1.03  (OK)
[Parallel + Thread Pool + Sleep]        121.462   194.465     0.62  (OK)
================================================================================
Executing test: mandelbrot_chunked...
Reference binary: ./runtasks_ref_linux
Results for: mandelbrot_chunked
                                        STUDENT   REFERENCE   PERF?
[Serial]                                296.609   275.69      1.08  (OK)
[Parallel + Always Spawn]               295.098   29.164      10.12  (NOT OK)
[Parallel + Thread Pool + Spin]         297.117   25.831      11.50  (NOT OK)
[Parallel + Thread Pool + Sleep]        25.4      24.446      1.04  (OK)
================================================================================
Executing test: mandelbrot_chunked_async...
Reference binary: ./runtasks_ref_linux
Results for: mandelbrot_chunked_async
                                        STUDENT   REFERENCE   PERF?
[Serial]                                297.946   274.831     1.08  (OK)
[Parallel + Always Spawn]               297.743   29.353      10.14  (NOT OK)
[Parallel + Thread Pool + Spin]         295.95    24.352      12.15  (NOT OK)
[Parallel + Thread Pool + Sleep]        27.117    25.444      1.07  (OK)
================================================================================
Overall performance results
[Serial]                                : All passed Perf
[Parallel + Always Spawn]               : Perf did not pass all tests
[Parallel + Thread Pool + Spin]         : Perf did not pass all tests
[Parallel + Thread Pool + Sleep]        : All passed Perf
</code></pre>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="CS149"></category>
  </entry>
  <entry>
    <title>assignment3</title>
    <link href="https://katyusha-blog.com/posts/cs149/assignment3/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/cs149/assignment3/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-03-09T00:00:00.000Z</updated>
    <summary>assignment3</summary>
    <content type="html"><![CDATA[<p>环境</p>
<p>OS:Windows11 wsl2 6.6.87.2-microsoft-standard-WSL2 Ubuntu 24.04.3 LTS</p>
<p>CPU: Intel Core i7 13620H 8 cores, 10 logic processors, AVX2</p>
<p>GPU:NVIDIA GeForce RTX 4060 Laptop</p>
<h2>assignment3</h2>
<h3>cuda环境配置</h3>
<p>NVIDIA官方为WSL2环境提供了专门的CUDA Toolkit安装包，该版本不包含Linux驱动程序，从而避免了与Windows驱动的冲突</p>
<p>在NVIDIA官网https://developer.nvidia.com/cuda-downloads选择对应的安装选项，按照生成的安装命令执行即可</p>
<p>例如本机WSL2配置命令如下</p>
<pre><code>wget https://developer.download.nvidia.com/compute/cuda/repos/wsl-ubuntu/x86_64/cuda-wsl-ubuntu.pin
sudo mv cuda-wsl-ubuntu.pin /etc/apt/preferences.d/cuda-repository-pin-600
wget https://developer.download.nvidia.com/compute/cuda/13.1.1/local_installers/cuda-repo-wsl-ubuntu-13-1-local_13.1.1-1_amd64.deb
sudo dpkg -i cuda-repo-wsl-ubuntu-13-1-local_13.1.1-1_amd64.deb
sudo cp /var/cuda-repo-wsl-ubuntu-13-1-local/cuda-*-keyring.gpg /usr/share/keyrings/
sudo apt-get update
sudo apt-get -y install cuda-toolkit-13-1
</code></pre>
<p>此后我们再打开<code>~/.bashrc</code>配置环境变量，在最后加上以下内容</p>
<pre><code>export CUDA_HOME=/usr/local/cuda-13.1
export PATH=$CUDA_HOME/bin:$PATH
export LD_LIBRARY_PATH=/usr/local/cuda/lib64:$LD_LIBRARY_PATH
</code></pre>
<p><code>source ~/.bashrc</code>后，使用<code>nvcc --version</code>验证结果如下</p>
<h3>saxpy</h3>
<p>要求我们补全<code>saxpy.cu</code>的代码，并且统计运行的总时间和<code>kernel</code>内计算的时间</p>
<p>需要注意的是，<code>cudaMalloc</code>，<code>cudaFree</code>，<code>cudaMemcpy</code>都是与主线程同步执行，而<code>kernel</code>内的计算是与主线程异步的，统计计算时间之前需要使用<code>cudaDeviceSynchronize()</code></p>
<pre><code>void saxpyCuda(int N, float alpha, float* xarray, float* yarray, float* resultarray) {

    // must read both input arrays (xarray and yarray) and write to
    // output array (resultarray)
    int totalBytes = sizeof(float) * 3 * N;

    // compute number of blocks and threads per block.  In this
    // application we've hardcoded thread blocks to contain 512 CUDA
    // threads.
    const int threadsPerBlock = 512;

    // Notice the round up here.  The code needs to compute the number
    // of threads blocks needed such that there is one thread per
    // element of the arrays.  This code is written to work for values
    // of N that are not multiples of threadPerBlock.
    const int blocks = (N + threadsPerBlock - 1) / threadsPerBlock;

    // These are pointers that will be pointers to memory allocated
    // *one the GPU*.  You should allocate these pointers via
    // cudaMalloc.  You can access the resulting buffers from CUDA
    // device kernel code (see the kernel function saxpy_kernel()
    // above) but you cannot access the contents these buffers from
    // this thread. CPU threads cannot issue loads and stores from GPU
    // memory!
    float* device_x = nullptr;
    float* device_y = nullptr;
    float* device_result = nullptr;
    
    //
    // CS149 TODO: allocate device memory buffers on the GPU using cudaMalloc.
    //
    // We highly recommend taking a look at NVIDIA's
    // tutorial, which clearly walks you through the few lines of code
    // you need to write for this part of the assignment:
    //
    // https://devblogs.nvidia.com/easy-introduction-cuda-c-and-c/
    //
    cudaMalloc(&amp;device_x, totalBytes / 3);
    cudaMalloc(&amp;device_y, totalBytes / 3);
    cudaMalloc(&amp;device_result, totalBytes / 3);
    // start timing after allocation of device memory
    double startTime = CycleTimer::currentSeconds();

    //
    // CS149 TODO: copy input arrays to the GPU using cudaMemcpy
    //

    cudaMemcpy(device_x, xarray, totalBytes / 3, cudaMemcpyHostToDevice);
    cudaMemcpy(device_y, yarray, totalBytes / 3, cudaMemcpyHostToDevice);
   
    // run CUDA kernel. (notice the &lt;&lt;&lt; &gt;&gt;&gt; brackets indicating a CUDA
    // kernel launch) Execution on the GPU occurs here.
    double kernel_time = CycleTimer::currentSeconds();
    saxpy_kernel&lt;&lt;&lt;blocks, threadsPerBlock&gt;&gt;&gt;(N, alpha, device_x, device_y, device_result);
    cudaDeviceSynchronize();
    kernel_time = CycleTimer::currentSeconds() - kernel_time;
    //
    // CS149 TODO: copy result from GPU back to CPU using cudaMemcpy
    //

    cudaMemcpy(resultarray, device_result, totalBytes / 3, cudaMemcpyDeviceToHost);
    
    // end timing after result has been copied back into host memory
    double endTime = CycleTimer::currentSeconds();

    cudaError_t errCode = cudaPeekAtLastError();
    if (errCode != cudaSuccess) {
        fprintf(stderr, "WARNING: A CUDA error occured: code=%d, %s\n",
		errCode, cudaGetErrorString(errCode));
    }

    double overallDuration = endTime - startTime;
    printf("Effective BW of kernel: %.3f ms\t\t[%.3f GB/s]\n", 1000.f * kernel_time, GBPerSec(totalBytes, kernel_time));
    printf("Effective BW by CUDA saxpy: %.3f ms\t\t[%.3f GB/s]\n", 1000.f * overallDuration, GBPerSec(totalBytes, overallDuration));

    //
    // CS149 TODO: free memory buffers on the GPU using cudaFree
    //
    cudaFree(device_x); cudaFree(device_y); cudaFree(device_result);
}
</code></pre>
<p>运行结果如下</p>
<pre><code>Found 1 CUDA devices
Device 0: NVIDIA GeForce RTX 4060 Laptop GPU
   SMs:        24
   Global mem: 8188 MB
   CUDA Cap:   8.9
---------------------------------------------------------
Running 3 timing tests:
Effective BW of kernel: 13.045 ms               [85.673 GB/s]
Effective BW by CUDA saxpy: 153.652 ms          [7.273 GB/s]
Effective BW of kernel: 5.262 ms                [212.398 GB/s]
Effective BW by CUDA saxpy: 135.543 ms          [8.245 GB/s]
Effective BW of kernel: 5.248 ms                [212.972 GB/s]
Effective BW by CUDA saxpy: 136.620 ms          [8.180 GB/s]
</code></pre>
<p>对比同样数据规模的ISPC优化</p>
<pre><code>[saxpy serial]:         [52.145] ms     [28.577] GB/s   [3.835] GFLOPS
[saxpy ispc]:           [51.940] ms     [28.689] GB/s   [3.851] GFLOPS
[saxpy task ispc]:      [31.122] ms     [47.879] GB/s   [6.426] GFLOPS
</code></pre>
<p>发现在计算方面，<code>cuda</code>加速比CPU快得多，但是<code>cudaMemcpy</code>内存通信开销极大，甚至慢于串行执行；而在GPU内部带宽接近于<code>RTX4060</code>的理论带宽 <code>256.0 GB/s</code>。这表明device和host的通信才是性能的瓶颈</p>
<h3>scan</h3>
<p>注意此题测试参考程序依赖于<code>cuda 12</code>的运行时库，而我本地是<code>cuda 13</code>，需要通过以下命令安装<code>cuda 12</code>，并且修改<code>.bashrc</code></p>
<pre><code>wget https://developer.download.nvidia.com/compute/cuda/repos/wsl-ubuntu/x86_64/cuda-wsl-ubuntu.pin
sudo mv cuda-wsl-ubuntu.pin /etc/apt/preferences.d/cuda-repository-pin-600
wget https://developer.download.nvidia.com/compute/cuda/12.8.1/local_installers/cuda-repo-wsl-ubuntu-12-8-local_12.8.1-1_amd64.deb
sudo dpkg -i cuda-repo-wsl-ubuntu-12-8-local_12.8.1-1_amd64.deb
sudo cp /var/cuda-repo-wsl-ubuntu-12-8-local/cuda-*-keyring.gpg /usr/share/keyrings/
sudo apt update
</code></pre>
<p>可以使用以下命令进行版本切换：<code>sudo update-alternatives --config cuda</code></p>
<p>要求我们使用<code>Blelloch</code>算法实现并行前缀和，并调用该函数无锁地实现<code>find_repeats</code>用来并行找出所有满足<code>input[i]==input[i+1]</code>的<code>i</code>构成的序列</p>
<p>首先是并行前缀和，代码如下</p>
<pre><code>__global__ void Kernel_up_sweep(int *arrary, int threads, int stride) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx &lt; threads) {
        int id1 = idx * stride + (stride &gt;&gt; 1), id2 = (idx + 1) * stride;
        id1--; id2--;  
        arrary[id2] = arrary[id1] + arrary[id2];
    }
}

__global__ void Kernel_down_sweep(int *arrary, int threads, int stride) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx &lt; threads) {
        int id1 = idx * stride + (stride &gt;&gt; 1), id2 = (idx + 1) * stride;
        id1--; id2--;
        int x = arrary[id1], y = arrary[id2];
        arrary[id1] = y; arrary[id2] = x + y;
    }
}

void exclusive_scan(int* input, int N, int* result)
{

    // CS149 TODO:
    //
    // Implement your exclusive scan implementation here.  Keep in
    // mind that although the arguments to this function are device
    // allocated arrays, this is a function that is running in a thread
    // on the CPU.  Your implementation will need to make multiple calls
    // to CUDA kernel functions (that you must write) to implement the
    // scan.

    int n = nextPow2(N);
    for (int stride = 2; stride &lt;= n / 2; stride &lt;&lt;= 1) {
        int threads = n / stride, blocks = (threads - 1 + THREADS_PER_BLOCK) / THREADS_PER_BLOCK;
        Kernel_up_sweep&lt;&lt;&lt;blocks, THREADS_PER_BLOCK&gt;&gt;&gt;(result, threads, stride);
        cudaDeviceSynchronize();
    }
    cudaMemset(result + (n - 1), 0, sizeof(int));
    for (int stride = n; stride &gt;= 2; stride &gt;&gt;= 1) {
        int threads = n / stride, blocks = (threads - 1 + THREADS_PER_BLOCK) / THREADS_PER_BLOCK;
        Kernel_down_sweep&lt;&lt;&lt;blocks, THREADS_PER_BLOCK&gt;&gt;&gt;(result, threads, stride);
        cudaDeviceSynchronize();
    }
}
</code></pre>
<p>测试结果如下</p>
<pre><code>Scan Score Table:
-------------------------
-------------------------------------------------------------------------
| Element Count   | Ref Time        | Student Time    | Score           |
-------------------------------------------------------------------------
| 1000000         | 0.968           | 0.999           | 1.25            |
| 10000000        | 10.075          | 8.529           | 1.25            |
| 20000000        | 20.351          | 13.911          | 1.25            |
| 40000000        | 38.031          | 26.655          | 1.25            |
-------------------------------------------------------------------------
|                                   | Total score:    | 5.0/5.0         |
-------------------------------------------------------------------------
</code></pre>
<p>对于<code>find_repeats</code>的实现，我们可以先并行执行得到对于每个<code>i</code>是否满足<code>input[i]==input[i+1]</code>，这构成了一个布尔序列，对于这个布尔序列做一次并行前缀和，得到此前重复的数的数量，也就是当前数如果重复应该被放入结果序列的位置，按照这个做一次<code>scatter</code>即可</p>
<pre><code>__global__ void Kernel_if_repeat(int *arrary, int n, int *output) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx + 1 &lt; n) {
        output[idx] = (arrary[idx] == arrary[idx + 1]);
    }
}

__global__ void Kernel_find_repeats(int *flag, int *index, int n, int *output) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx + 1 &lt; n &amp;&amp; flag[idx]) {
        output[index[idx]] = idx;
    }
}

int find_repeats(int* device_input, int length, int* device_output) {

    // CS149 TODO:
    //
    // Implement this function. You will probably want to
    // make use of one or more calls to exclusive_scan(), as well as
    // additional CUDA kernel launches.
    //    
    // Note: As in the scan code, the calling code ensures that
    // allocated arrays are a power of 2 in size, so you can use your
    // exclusive_scan function with them. However, your implementation
    // must ensure that the results of find_repeats are correct given
    // the actual array length.
    int n = nextPow2(length);
    int *flag = nullptr, *sum = nullptr;
    cudaMalloc(&amp;flag, sizeof(int) * n);
    cudaMalloc(&amp;sum, sizeof(int) * n);
    int blocks = (n + THREADS_PER_BLOCK - 1) / THREADS_PER_BLOCK;
    Kernel_if_repeat &lt;&lt;&lt;blocks, THREADS_PER_BLOCK&gt;&gt;&gt; (device_input, n, flag);
    cudaDeviceSynchronize();
    cudaMemcpy(sum, flag, sizeof(int) * n, cudaMemcpyDeviceToDevice);
    exclusive_scan(sum, n, sum);
    Kernel_find_repeats &lt;&lt;&lt;blocks, THREADS_PER_BLOCK&gt;&gt;&gt; (flag, sum, n, device_output);
    cudaDeviceSynchronize();
    int cnt;
    cudaMemcpy(&amp;cnt, sum + length - 1, sizeof(int), cudaMemcpyDeviceToHost);
    cudaFree(flag); cudaFree(sum);
    return cnt; 
}
</code></pre>
<p>测试结果如下</p>
<pre><code>Find_repeats Score Table:
-------------------------
-------------------------------------------------------------------------
| Element Count   | Ref Time        | Student Time    | Score           |
-------------------------------------------------------------------------
| 1000000         | 1.887           | 2.02            | 1.25            |
| 10000000        | 16.067          | 14.997          | 1.25            |
| 20000000        | 28.853          | 26.017          | 1.25            |
| 40000000        | 56.414          | 50.418          | 1.25            |
-------------------------------------------------------------------------
|                                   | Total score:    | 5.0/5.0         |
-------------------------------------------------------------------------
</code></pre>
<h3>render</h3>
<p><code>make</code>之前需要配置相关的环境</p>
<pre><code>sudo apt update
sudo apt install freeglut3-dev
</code></pre>
<p>提供了一个简单渲染器的顺序实现和<code>cuda</code>实现，其中<code>cuda</code>实现存在错误</p>
<p>在安装<code>feh</code>之后，可以通过以下命令运行顺序实现查看渲染结果<code>./render -r cpuref snow -i</code></p>
<p>其中渲染算法维护了多个圆的信息，每个圆有其圆心位置，半径，速度，颜色，透明度(一个$[0,1]$之间的实数)，算法流程如下</p>
<pre><code>对于每一帧：
	清空图像
	对于每一个圆：
		更新其位置和速度
	对于每一个圆：
		计算出能包含该圆的正方形
		对于这个正方形内的每一个坐标：
			计算这个坐标的中心
			如果这个中心在圆内：
				计算这个圆在此处的颜色
				使用这个颜色的贡献更新坐标像素上
</code></pre>
<p>对于像素的贡献算法如下：</p>
<pre><code>   result_r = C_alpha * C_r + (1.0 - C_alpha) * P_r
   result_g = C_alpha * C_g + (1.0 - C_alpha) * P_g
   result_b = C_alpha * C_b + (1.0 - C_alpha) * P_b
</code></pre>
<p>其中<code>result_r/g/b</code>表示计算贡献后的像素<code>rgb</code>值，<code>C_alpha</code>表示圆的透明度，<code>P_r/g/b</code>表示此前像素的<code>rgb</code>值</p>
<p>注意该贡献算法不满足结合律，即计算出像素的<code>rgb</code>值与圆贡献的顺序相关，此渲染器要求贡献顺序为圆输入顺序</p>
<p>提示告诉我们，<code>cuda</code>版本的<code>render</code>没有满足贡献计算的原子性以及贡献的顺序，需要我们对其进行修改</p>
<p>初始代码贡献计算部分如下</p>
<pre><code>__global__ void kernelRenderCircles() {

    int index = blockIdx.x * blockDim.x + threadIdx.x;

    if (index &gt;= cuConstRendererParams.numCircles)
        return;

    int index3 = 3 * index;

    // read position and radius
    float3 p = *(float3*)(&amp;cuConstRendererParams.position[index3]);
    float  rad = cuConstRendererParams.radius[index];

    // compute the bounding box of the circle. The bound is in integer
    // screen coordinates, so it's clamped to the edges of the screen.
    short imageWidth = cuConstRendererParams.imageWidth;
    short imageHeight = cuConstRendererParams.imageHeight;
    short minX = static_cast&lt;short&gt;(imageWidth * (p.x - rad));
    short maxX = static_cast&lt;short&gt;(imageWidth * (p.x + rad)) + 1;
    short minY = static_cast&lt;short&gt;(imageHeight * (p.y - rad));
    short maxY = static_cast&lt;short&gt;(imageHeight * (p.y + rad)) + 1;

    // a bunch of clamps.  Is there a CUDA built-in for this?
    short screenMinX = (minX &gt; 0) ? ((minX &lt; imageWidth) ? minX : imageWidth) : 0;
    short screenMaxX = (maxX &gt; 0) ? ((maxX &lt; imageWidth) ? maxX : imageWidth) : 0;
    short screenMinY = (minY &gt; 0) ? ((minY &lt; imageHeight) ? minY : imageHeight) : 0;
    short screenMaxY = (maxY &gt; 0) ? ((maxY &lt; imageHeight) ? maxY : imageHeight) : 0;

    float invWidth = 1.f / imageWidth;
    float invHeight = 1.f / imageHeight;

    // for all pixels in the bonding box
    for (int pixelY=screenMinY; pixelY&lt;screenMaxY; pixelY++) {
        float4* imgPtr = (float4*)(&amp;cuConstRendererParams.imageData[4 * (pixelY * imageWidth + screenMinX)]);
        for (int pixelX=screenMinX; pixelX&lt;screenMaxX; pixelX++) {
            float2 pixelCenterNorm = make_float2(invWidth * (static_cast&lt;float&gt;(pixelX) + 0.5f),
                                                 invHeight * (static_cast&lt;float&gt;(pixelY) + 0.5f));
            shadePixel(index, pixelCenterNorm, p, imgPtr);
            imgPtr++;
        }
    }
}
</code></pre>
<p>这是对于每一个圆并行计算对其中像素的贡献，但是由于乱序，会导致结果出错</p>
<p>在串行执行算法中，对每个圆计算和对每个像素计算都具有并行性，但是由于不满足结合律，我们只能考虑后者</p>
<p>一个<code>trivial</code>的做法是对于每一个像素运行一个<code>cuda</code>线程，依次遍历每一个圆尝试计算贡献</p>
<pre><code>__global__ void kernelRenderPixels() {
    int x = blockIdx.x * blockDim.x + threadIdx.x;
    int y = blockIdx.y * blockDim.y + threadIdx.y;
    int width = cuConstRendererParams.imageWidth, height = cuConstRendererParams.imageHeight;
    if (x &gt;= width || y &gt;= height) return;
    float invwidth = 1.f / width, invheight = 1.f / height; 
    float2 pixelCenterNorm = make_float2(invwidth * (static_cast&lt;float&gt;(x) + 0.5f), invheight * (static_cast&lt;float&gt;(y) + 0.5f));
    float4 *imgPtr = (float4*)(&amp;cuConstRendererParams.imageData[4 * (y * width + x)]);
    for (int i = 0; i &lt; cuConstRendererParams.numCircles; i++) {
        float3 p = *(float3*)(&amp;cuConstRendererParams.position[3 * i]);
        shadePixel(i, pixelCenterNorm, p, imgPtr);
    }
}
</code></pre>
<p>测试结果如下</p>
<pre><code>Score table:
------------
--------------------------------------------------------------------------
| Scene Name      | Ref Time (T_ref) | Your Time (T)   | Score           |
--------------------------------------------------------------------------
| rgb             | 0.3232           | 0.2669          | 9               |
| rand10k         | 2.8221           | 39.2085         | 2               |
| rand100k        | 23.3191          | 382.3064        | 2               |
| pattern         | 0.6382           | 5.5301          | 3               |
| snowsingle      | 15.0153          | 343.2084        | 2               |
| biglittle       | 13.4723          | 43.2001         | 5               |
| rand1M          | 154.3743         | 3913.552        | 2               |
| micro2M         | 294.7338         | 7892.6395       | 2               |
--------------------------------------------------------------------------
|                                    | Total score:    | 27/72           |
--------------------------------------------------------------------------
</code></pre>
<p>实际运行效率比<code>CPU</code>串行执行还低（），这是因为更新一个像素需要扫描所有的圆，而实际上许多像素根本不会处在大多数圆内</p>
<p>为了减少冗余计算，可以考虑将整个坐标系分割成多个矩形，若这个矩形和一个圆不相交，那么该矩形中所有像素一定都不在这个圆内</p>
<p>已经为我们提供了<code>circleInBox</code>的接口，直接调用即可</p>
<pre><code>#define BLOCKX 16
#define BLOCKY 16
#define THREADX 16
#define THREADY 16

__global__ void kernelRenderPixels() {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    int idy = blockIdx.y * blockDim.y + threadIdx.y;
    int width = cuConstRendererParams.imageWidth, height = cuConstRendererParams.imageHeight;
    float invwidth = 1.f / width, invheight = 1.f / height;
    int X0 = idx * THREADX, X1 = X0 + THREADX;
    int Y0 = idy * THREADY, Y1 = Y0 + THREADY; 
    if (X0 &gt;= width || Y0 &gt;= height) return;
    float normX0 = X0 * invwidth, normX1 = X1 * invwidth;
    float normY0 = Y0 * invheight, normY1 = Y1 * invheight;
    int num = cuConstRendererParams.numCircles;
    for (int i = 0; i &lt; num; i++) {
        float cirx = cuConstRendererParams.position[3 * i], ciry = cuConstRendererParams.position[3 * i + 1];
        float cirr = cuConstRendererParams.radius[i];
        if (circleInBox(cirx, ciry, cirr, normX0, normX1, normY1, normY0)) {
            float3 p = *(float3*)(&amp;cuConstRendererParams.position[3 * i]);
            for (int j = Y0; j &lt; Y1; j++) {
                float _y = static_cast &lt;float&gt; (j) + 0.5f; 
                float4* imgPtr = (float4*)(&amp;cuConstRendererParams.imageData[4 * (j * width + X0)]);
                for (int k = X0; k &lt; X1; k++) {
                    float _x = static_cast &lt;float&gt; (k) + 0.5f;
                    shadePixel(i, make_float2(invwidth * _x, invheight * _y), p, imgPtr);
                    ++imgPtr;
                }
            }
        }
    }
}

void
CudaRenderer::render() {

    // 256 threads per block is a healthy number
    dim3 blockDim(THREADX, THREADY);
    dim3 gridDim((image-&gt;width + blockDim.x - 1) / blockDim.x, (image-&gt;height + blockDim.y - 1) / blockDim.y);

    kernelRenderPixels&lt;&lt;&lt;gridDim, blockDim&gt;&gt;&gt;();
    cudaDeviceSynchronize();
}
</code></pre>
<p>运行结果如下，效率有了较大提升</p>
<pre><code>Score table:
------------
--------------------------------------------------------------------------
| Scene Name      | Ref Time (T_ref) | Your Time (T)   | Score           |
--------------------------------------------------------------------------
| rgb             | 0.2807           | 0.8516          | 5               |
| rand10k         | 2.729            | 29.2422         | 2               |
| rand100k        | 23.1492          | 234.5919        | 2               |
| pattern         | 0.6532           | 1.1787          | 6               |
| snowsingle      | 14.8701          | 30.8657         | 6               |
| biglittle       | 13.9142          | 416.6634        | 2               |
| rand1M          | 154.5485         | 523.2159        | 5               |
| micro2M         | 296.6468         | 496.183         | 7               |
--------------------------------------------------------------------------
|                                    | Total score:    | 35/72           |
--------------------------------------------------------------------------
</code></pre>
<p>但是我们发现这段代码是对于每个<code>16 * 16</code>的像素块，都串行地与所有圆判断是否相交，这一部分仍然可以并行化</p>
<p>于是，我们考虑仍然使用一个线程处理每一个像素，但是同时，一个线程块内的线程需要并行地计算这个线程块构成的像素矩阵与每个圆的相交情况，然后计算相交的圆对自己这个线程对应的像素的贡献</p>
<p>在实现上来说，我们采用<code>__shared__</code>共享内存来进行线程块内通信，线程块内<code>16*16=256</code>个线程每个每次判断像素矩阵是否与一个圆相交，这样得到了一个布尔数组，采用类似第二部分的实现就可以得到相交的圆的数组，然后每个线程再并行的对自己负责的像素依次尝试用这些圆更新即可。注意我们在计算相交的布尔数组处采用了屏障同步，这就使得后256个圆不会先于前256个圆进行计算，进而保证了正确性</p>
<pre><code>#define BLOCKX 16
#define BLOCKY 16
#define BLOCKSIZE 256
#define SCAN_BLOCK_DIM   BLOCKSIZE  
#include "exclusiveScan.cu_inl"

__global__ void kernelRenderPixels() {
    __shared__ uint isinside[BLOCKSIZE], insidesum[BLOCKSIZE], tmp[BLOCKSIZE &lt;&lt; 1], cir[BLOCKSIZE];
    int width = cuConstRendererParams.imageWidth, height = cuConstRendererParams.imageHeight;
    float invwidth = 1.0f / width, invheight = 1.0f / height;
    int X0 = blockIdx.x * BLOCKX, X1 = min(X0 + BLOCKX, width);
    int Y0 = blockIdx.y * BLOCKY, Y1 = min(Y0 + BLOCKY, height);
    float normX0 = X0 * invwidth, normX1 = X1 * invwidth;
    float normY0 = Y0 * invheight, normY1 = Y1 * invheight;
    int threadid = threadIdx.y * BLOCKX + threadIdx.x;
    int num = cuConstRendererParams.numCircles; 
    int pixelx = X0 + threadIdx.x;
    int pixely = Y0 + threadIdx.y;
    float2 pixel = make_float2(invwidth * (static_cast&lt;float&gt;(pixelx) + 0.5f), invheight * (static_cast &lt;float&gt;(pixely) + 0.5f));
    float4 *imgPtr = (float4*)(&amp;cuConstRendererParams.imageData[4 * (pixely * width + pixelx)]);
    for (int _i = 0; _i &lt; num; _i += BLOCKSIZE) {
        int i = _i + threadid;
        if (i &lt; num) {
            float cirx = cuConstRendererParams.position[3 * i], ciry = cuConstRendererParams.position[3 * i + 1];
            float cirr = cuConstRendererParams.radius[i];
            isinside[threadid] = circleInBox(cirx, ciry, cirr, normX0, normX1, normY1, normY0);            
        }
        else isinside[threadid] = 0;

        __syncthreads();
        sharedMemExclusiveScan(threadid, isinside, insidesum, tmp, BLOCKSIZE); 
        if (isinside[threadid]) {
            cir[insidesum[threadid]] = i;
        }
        __syncthreads();
        if (pixelx &lt; width &amp;&amp; pixely &lt; height) {
            int cirnum = insidesum[BLOCKSIZE - 1] + isinside[BLOCKSIZE - 1];
            for (int j = 0; j &lt; cirnum; j++) {
                int ciridx = cir[j];
                float3 p = *(float3*)(&amp;cuConstRendererParams.position[ciridx * 3]);
                shadePixel(ciridx, pixel, p, imgPtr);
            }               
        }
    }
}

void
CudaRenderer::render() {

    // 256 threads per block is a healthy number
    dim3 blockDim(BLOCKX, BLOCKY);
    dim3 gridDim((image-&gt;width + blockDim.x - 1) / blockDim.x, (image-&gt;height + blockDim.y - 1) / blockDim.y);

    kernelRenderPixels&lt;&lt;&lt;gridDim, blockDim&gt;&gt;&gt;();
    cudaDeviceSynchronize();
}

</code></pre>
<p>测试结果如下</p>
<pre><code>Score table:
------------
--------------------------------------------------------------------------
| Scene Name      | Ref Time (T_ref) | Your Time (T)   | Score           |
--------------------------------------------------------------------------
| rgb             | 0.4447           | 0.4018          | 9               |
| rand10k         | 3.3763           | 3.7078          | 9               |
| rand100k        | 28.7109          | 32.373          | 9               |
| pattern         | 0.5684           | 0.5763          | 9               |
| snowsingle      | 20.157           | 18.984          | 9               |
| biglittle       | 13.8375          | 32.426          | 5               |
| rand1M          | 153.9384         | 153.1018        | 9               |
| micro2M         | 295.5285         | 288.3962        | 9               |
--------------------------------------------------------------------------
|                                    | Total score:    | 68/72           |
</code></pre>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="CS149"></category>
  </entry>
  <entry>
    <title>学习笔记</title>
    <link href="https://katyusha-blog.com/posts/cs149/cs149/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/cs149/cs149/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-19T00:00:00.000Z</updated>
    <summary>学习笔记</summary>
    <content type="html"><![CDATA[<h1>学习笔记</h1>
<h2>三大核心理念</h2>
<ol>
<li>多核执行：发掘程序的并行性，将可以并行的部分交给不同的处理器核执行</li>
<li>SIMD执行：通过向量化，让一个处理器核一条指令对一个向量执行相同的操作</li>
<li>硬件多线程：在一个处理器核中维护多个上下文，当一个线程停顿时(如cache miss)，进行上下文切换执行其它线程，提高利用率</li>
</ol>
<h2>并行化程序的过程</h2>
<h3>Decomposition</h3>
<p>发掘程序的并行性，将问题分解为可并行计算的多个小部分</p>
<p><strong>Amdahl's law</strong>：设 $S$为程序中可以并行执行的部分，运行在$p$个处理器上，那么有加速比$speedup \leq \frac{1}{(1-S)+\frac{S}{p}}$</p>
<p>由此可见，设计的程序的可并行性是加速的决定性因素</p>
<p><strong>ISPC</strong> 基本语法</p>
<h3>Assignment</h3>
<p>并行计算完成的时间，取决于最慢的那个线程</p>
<p>所以每个线程(或每个处理器核)执行的任务量越平均(即负载越平均)，理论上花费的时间就越短</p>
<p>可以将任务按照粒度依次分配给每个进程，粒度越小进程的负载越平均，但是进程间通信的开销也会变大</p>
<p><strong>Cilk</strong> 语法</p>
<h3>Orchestration</h3>
<p>通信是广义的，节点间存在通信，线程间存在通信，线程对内存的访问也是一种通信</p>
<p>共享地址空间系统(多线程共享内存)：所有线程对同一个地址空间进行读写，缓存一致性由硬件自动维护，通过锁和屏障完成同步</p>
<p>消息传递系统(分布式系统)：每个处理核或节点有独立的内存地址空间，需要通过显式发送和接受消息来通信</p>
<p>以下以消息传递系统为例，节点间的通信分为同步通信和异步通信</p>
<p>同步通信采用了阻塞发送/接收的方式达到同步，即一直阻塞直到消息发送/接收成功，编程简单，但是并发度低</p>
<p>异步通信采用非阻塞发送/接收，发送时将消息复制并暂存在缓冲区内，需要接收时再取走消息，高并发，但是编程复杂</p>
<p><strong>算数强度</strong>：计算次数与通信次数的比例</p>
<p>按照通信的类型，可以分为<strong>固有通信</strong>和<strong>人为通信</strong>，前者由算法固有的性质决定，后者由机器工作方式决定</p>
<p>通过改进算法(如使用分块技术)可以减少固有通信</p>
<h3>mapping</h3>
<p>并行任务被映射到对应的硬件上，可以由系统/程序员或者两者都有进行指定</p>
<h2>GPU与CUDA</h2>
<p>GPU的作用：给定一个场景的数学描述，经过模拟生成这个场景的图片，例如对每个像素计算一些输入，然后在每个像素上独立运行来计算一些颜色</p>
<p>在NVIDIA推出<code>CUDA</code>之前，操纵GPU进行科学计算需要将数据包装成图形渲染问题，这是相等低效且复杂的</p>
<p>GPU由很多个小的计算核心组成，所以可以很好地处理逻辑简单的并行计算</p>
<p><code>SM Processing Block</code>组成如下</p>
<p><img src="https://katyusha-blog.com/_astro/cs149-cs149-image-001.BpHI5B6c_VyHvU.webp" alt="image" /></p>
<p>多个<code>SMP</code>组成了一个<code>Stream Multi-processor(SM)</code>，一个<code>SM</code>内的<code>SMP</code>共享内存</p>
<p><img src="https://katyusha-blog.com/_astro/cs149-cs149-image-002.CgJfJiot_1MkfyR.webp" alt="image" /></p>
<p>在CPU上，由编译器在软件层面实现SIMD；GPU上则是在硬件层面，每个SMP对于它的一个<strong>wrap</strong>(多个连续的PC相同的Cuda进程)执行隐式SIMD</p>
<p><strong>CUDA</strong> 语法</p>
<h2>序列数据并行</h2>
<h3>map</h3>
<p><img src="https://katyusha-blog.com/_astro/cs149-cs149-image-003.Csuqnbym_Z2hgbPr.webp" alt="image" /></p>
<p>将某个函数作用于序列，得到一个等长的序列</p>
<p>可并行化的<code>map</code>：要求函数的结果相互之间没有依赖</p>
<h3>fold(reduce)</h3>
<p><img src="https://katyusha-blog.com/_astro/cs149-cs149-image-004.DRiXcjFH_1PHUfd.webp" alt="image" /></p>
<p>从左到右对序列进行二元操作，得到一个标量</p>
<p>可并行化的<code>fold</code>：运算与合并顺序无关，满足交换律</p>
<h3>scan</h3>
<p>对序列每个进行前缀<code>fold</code>，得到的所有标量组合起来，形成一个序列</p>
<p><img src="https://katyusha-blog.com/_astro/cs149-cs149-image-005.E8nXJeK8_1Rkh3X.webp" alt="image" /></p>
<p><code>inclusive scan</code>:对$[0,i]$做<code>scan</code>  <code>exclusive scan</code>:对$[0,i-1]$做<code>scan</code></p>
<p>求前缀和就是一种<code>scan</code>，下面讨论前缀和的并行性，以下假设序列长度为<code>n</code>，处理器数为<code>p</code></p>
<h4>分段并行前缀和</h4>
<p><img src="https://katyusha-blog.com/_astro/cs149-cs149-image-006.Ba-Q6hTO_1Apt5P.webp" alt="image" /></p>
<p>将原序列连续尽量均匀分为<code>p</code>块，每块串行计算内部前缀和，再串行地对于每一块，将该块最后一个前缀和并行地加到下一块的每个前缀和上</p>
<p>时间复杂度为$O(\frac{n}{p}+p)$，显然有$p = \sqrt{n}$的时候最优，复杂度为$O(\sqrt{n})$，但是访存连续，有较好的缓存友好性</p>
<p>实际上本质是分块算法</p>
<h4>Blelloch 前缀和算法</h4>
<p><img src="https://katyusha-blog.com/_astro/cs149-cs149-image-007.CsDujJc3_noVSV.webp" alt="image" /></p>
<p><code>Blelloch</code>算法要求序列大小是2的次幂，不满足则需要进行填充</p>
<p>该算法分为两部分，<code>Up-Sweep</code>向上扫描和<code>Down-Sweep</code>向下扫描</p>
<p>向上扫描部分类似于<code>fenwick tree</code>的建树(沟槽的数据结构还在追我)，对于<code>1-index</code>，<code>c[i]</code>保存$[i-lowbit(i)+1, i]$的和</p>
<p>向下扫描部分则是每层分成若干个2的幂次的子区间，这些子区间的最后一个数第一次被赋值为非0的数时，都保存着$[0,该子区间左端点)$的和，向下递归的时候进行传播</p>
<p>先假设处理器足够多，每一层都能$O(1)$处理，时间复杂度与递归层数同阶为$O(\log_2n)$</p>
<p>总工作量为$O((\frac{n}{2}+\frac{n}{4}+...+2)\times2)=O(n)$，解得处理器数量$P \leq \frac{O(n)}{O(\log_2n)}=O(\frac{n}{log_2n})$，即当处理器数量至少为$O(\frac{n}{log_2n})$时可以达到最优复杂度，但是访存不连续，对缓存不友好</p>
<h3>segmented scan</h3>
<p>我们可以用<code>start-flag</code>法来表示一个序列的序列，具体来说，用第一个序列来按顺序存储所有序列的所有元素，用第二个布尔序列来表示第一个序列对应位置的元素是否是一个新序列的第一个元素</p>
<p><img src="https://katyusha-blog.com/_astro/cs149-cs149-image-008.CagqgCFP_Z15tgn7.webp" alt="image" /></p>
<p>序列反而序列同样可以进行<code>scan</code>操作，在<code>Blelloch</code>算法中根据<code>flag</code>位决定是否进行传播即可</p>
<p><img src="https://katyusha-blog.com/_astro/cs149-cs149-image-009.TDoCHHOI_Z2hkKXE.webp" alt="image" /></p>
<p>基于此，我们可以设计并行的稀疏矩阵乘法</p>
<p><img src="https://katyusha-blog.com/_astro/cs149-cs149-image-010.BId3WsEV_Zc9tTa.webp" alt="image" /></p>
<h3>gather/scatter</h3>
<p><code>gather</code>：<code>output[i]=input[index[i]]</code></p>
<p><code>scatter</code>：<code>output[index[i]]=input[i]</code></p>
<p><img src="https://katyusha-blog.com/_astro/cs149-cs149-image-011.BFAR3fYT_1KFKY6.webp" alt="image" /></p>
<p>注意<code>scatter</code>可以通过<code>sort+map+gather</code>得到</p>
<h3>其他序列操作</h3>
<p><img src="https://katyusha-blog.com/_astro/cs149-cs149-image-012.EG-Fe3CH_Z2iLKOJ.webp" alt="image" /></p>
<h2>分布式系统与并行计算</h2>
<p>现代分布式系统的结构如下，通信的瓶颈一般在于网络带宽</p>
<p><img src="https://katyusha-blog.com/_astro/cs149-cs149-image-013.EZzWpSQ2_ZiumoJ.webp" alt="image" /></p>
<p>相同机架内的节点一般通信较快，而不同机架内的节点则比较缓慢</p>
<h3>分布式文件系统 (DFS)</h3>
<p>对于一份数据，将其按照一定大小(如64-256MB)分割成多个小块，每一块保存多个副本，放入不同的节点中</p>
<p>系统中有一个主节点保存了元数据(所有数据的索引)</p>
<p>在对文件进行查询时，通过主节点中的信息访问任意一个存储了该文件的节点即可</p>
<p>在对文件进行修改时，通过主节点中的信息修改所有存储了该文件的节点</p>
<p>好处在于多个副本可以被并行地查询提高效率，同时也降低了故障率(多个节点同时出错概率极小)</p>
<h3>MapReduce编程模型</h3>
<p>考虑以下串行代码　</p>
<pre><code>int key[], value[], result[];

for (int i = 0; i &lt; n; i++) {
    value[i] = map(key[i]);
}
for (int i = 0; i &lt; n; i++) {
    result[key[i]] = reduce(result[key[i]], value[i]);
}
</code></pre>
<p>即对于所有<code>key</code>相同的元素，将其<code>map</code>操作映射的值进行<code>reduce</code></p>
<p>在考虑如何在分布式系统上并行地实现该代码，以下以<code>wordcount</code>为例，此时<code>map</code>映射结果恒为1，<code>reduce</code>操作为加法</p>
<p>对于分布式的每个节点，尽量在本地进行<code>map</code>操作得到对应的<code>value</code>，如果负载极度不均匀则需要先调度。此后进行屏障同步</p>
<p>按照一种保证同样的<code>key</code>值会被分配到相同<code>reduce</code>节点的分配方式(如<code>key</code>哈希值取模)，将所有的<code>&lt;key, value&gt;</code>传送到对应的<code>reduce</code>节点上进行<code>reduce</code>操作，得到结果后再写回分布式系统</p>
<h3>spark</h3>
<p>咕咕嘎嘎</p>
<h2>DNN的并行优化</h2>
<p>等后面学深度学习再来看，咕咕嘎嘎</p>
<h2>一致性</h2>
<h3>缓存一致性</h3>
<p>为了保持高带宽，一级缓存和二级缓存都由处理器核私有</p>
<p>当多个线程并行执行的时候，如果按照串行执行的缓存管理方式，内存的数据副本存放在处理器私有缓存中，当一个处理器核写数据时，如果不更新该数据在其他缓存中的副本，就会导致相同数据值不同，也就是缓存不一致</p>
<p>我们的目的是达到缓存一致性，即对于内存中的每一个地址，其并行访问的结果与不同线程按照时间排序后的顺序访问的结果相同</p>
<p>一种实现方式是采用嗅探法，以下以<code>MSI</code>缓存一致性协议为例</p>
<p>处理器核之间由一条总线连接起来，每时每刻处理器核都在监听总线，也可以向总线广播信息使得每个处理器核都接收到信息</p>
<p>对于缓存中的每一行，其脏位都存储了信息，表示这一行处于以下三种状态中的哪一种：</p>
<p><code>M</code>(modified)：内存中该行对应的数据未被更新，更新后的数据仅存在于这一行中</p>
<p><code>S</code>(shared)：内存中该行对应的数据已经被更新，并且存在于包含这一行的多行中</p>
<p><code>I</code>(invalid)：这一行的内容已经失效</p>
<p>下面这张图表明了<code>MSI</code>协议下行状态的转移</p>
<p>其中转移条件<code>A/B</code>表示嗅探到了与当前处理器核中一个缓存行相关的广播<code>A</code>，接下来会进行措施<code>B</code>，并将这个缓存行的状态改为箭头指向的状态</p>
<p><img src="https://katyusha-blog.com/_astro/cs149-cs149-image-014.DdmB6AV4_ZOt1Rd.webp" alt="image" /></p>
<p>例如当处于<code>M</code>状态时，嗅探到发生了<code>BusRdX</code>总线读独占，说明是其他处理器核尝试更新该数据并独占，那么就将当前缓存行的数据写回内存，并转移到<code>I</code>状态，其他处理器核再根据内存的信息进行更新</p>
<p><strong>对外可见性的传播可能在不同核、不同 cache line 上表现出不同的生效时机</strong>，所以当前的缓存状态不一定被及时更新，进而可能导致缓存信息不同</p>
<p>基于<code>MSI</code>协议，还有<code>MESI</code>，<code>MOESI</code>等协议通过加入更多状态，提高了性能</p>
<h4>伪共享</h4>
<p>当我们创造了<code>num_thread</code>个<code>int</code>变量，每个线程访问其对应的变量，那么多个线程对不同变量的访问发生在同一缓存行内，导致一些缓存行被频繁地无效化，严重影响性能</p>
<p>解决方法是采用手动填充到缓存行大小，或者采用<code>alignas</code>内存强制对齐到缓存行大小</p>
<h3>内存一致性</h3>
<p>多线程系统中，不同核心观测到的顺序可能与期望的顺序不同，如编译器为了优化性能，执行指令的顺序可能会重排后乱序执行，导致多线程下因对内存访问顺序改变而出错</p>
<p>后面是真听不懂了</p>
<h2>锁与原子变量</h2>
<p><code>死锁</code>：多个线程互相等待对方释放资源后才能执行，导致所有线程都无法继续执行</p>
<p><code>活锁</code>：线程没有阻塞，但是重试运行一直失败，一直在运行但是没有进展</p>
<p><code>饥饿</code>：一些线程取得了进展，但是其他线程因为资源被取得进程的线程占用而暂时无法取得进展</p>
<h3>锁的实现</h3>
<p>锁的本质是内存中一个用来记录当前线程是否应该继续进行的变量</p>
<p>考虑以下在硬件上被实现为原子操作的<code>test_and_set</code>指令</p>
<pre><code>int test_and_set(int *addr) {
    int x = *addr;
    *addr = 1;
    return x;
}
</code></pre>
<p>根据该指令，我们可以设计出$TAS$锁</p>
<pre><code>void Lock(int *lock) {
    while (test_and_set(lock));
}

void Unlock(int *lock) {
    *lock = 0;
}
</code></pre>
<p>在一个线程持有锁的时候，其它线程会反复地对锁尝试读写直到解锁，为保持缓存一致性，这带来了严重的效率问题</p>
<p>考虑以下改进<code>TTAS</code>锁</p>
<pre><code>void Lock(int *lock) {
	while (1) {
        while(*lock);
        if (test_and_set(lock) == 0) return;
	}   
}
</code></pre>
<p>这样，每次不持有锁的线程只会对锁进行读，减少了缓存不命中。直到解锁的时候，所有其它线程才会对锁进行一次读写尝试获得锁</p>
<p>注意以上两者这是非公平锁，即获得锁的顺序取决于操作系统的调度，可能导致线程饥饿</p>
<p>另一种思想被称为<code>ticket lock</code>，思想类似于生活中的拿号排队</p>
<pre><code>struct ticket_lock {
	int ticket;
    int serving;
};

void Lock(ticket_lock *l) {
	int t = atomic_increment(&amp;l-&gt;ticket);
    while (l-&gt;serving &lt; t);
}

void Unlock(ticket *l) {
    l-&gt;serving++;//注意同一时刻最多执行一次Unlock,无需使用原子操作
}
</code></pre>
<p><code>ticket lock</code>被称为公平锁，它是<code>FIFO</code>的，避免了线程饥饿</p>
<h3>原子操作</h3>
<p><code>cuda</code>内置了一系列的原子操作，以<code>atomicCAS</code>为例，其原子执行的逻辑如下</p>
<pre><code>void atomicCAS(int *addr, int compare, int val) {
    int old = *addr;
    *addr = (old == compare)? val : old;
    return old;
}
</code></pre>
<p>基于该函数，我们能实现许多原子操作，例如<code>atomicmin</code></p>
<pre><code>void atomicmin(int *addr, int x) {
    int old, now;
    do {
		old = *addr, now = min(old, x);
    } while (atomicCAS(addr, old, now) != old);
}
</code></pre>
<p>C++的<code>atomic</code>类型在实现上，对于小类型采用原子操作，大类型采用锁实现</p>
<h1>assignments</h1>
<p>环境：</p>
<p>OS:Windows11 wsl2 6.6.87.2-microsoft-standard-WSL2 Ubuntu 24.04.3 LTS</p>
<p>CPU: Intel Core i7 13620H 8 cores, 10 logic processors, AVX2</p>
<p>GPU:NVIDIA GeForce RTX 4060 Laptop</p>
<h2><a href="/posts/cs149/assignment1/">assignment1</a></h2>
<h2><a href="/posts/cs149/assignment2/">assignment2</a></h2>
<h2><a href="/posts/cs149/assignment3/">assignment3</a></h2>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="CS149"></category>
  </entry>
  <entry>
    <title> archlab</title>
    <link href="https://katyusha-blog.com/posts/csapp/lab/archlab/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/csapp/lab/archlab/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-05-25T00:00:00.000Z</updated>
    <summary> Archlab</summary>
    <content type="html"><![CDATA[<h2>Archlab</h2>
<h3>Part A Y86-64程序</h3>
<p>规则：给定你一段C语言代码，需要使用$Y86-64$汇编代码写出与其函数上等价的代码</p>
<p>由于$Y86-64$功能有限，你需要将测试的输入也写在代码中</p>
<p>目录下使用 <code>./yas test.ys</code> 编译 <code>./yis test.yo</code>得到运行的结果</p>
<p>要求对于一个链表进行元素求和，其中C代码如下</p>
<pre><code>/* $begin examples */
/* linked list element */
typedef struct ELE {
    long val;
    struct ELE *next;
} *list_ptr;

/* sum_list - Sum the elements of a linked list */
long sum_list(list_ptr ls)
{
    long val = 0;
    while (ls) {
	val += ls-&gt;val;
	ls = ls-&gt;next;
    }
    return val;
}

/* rsum_list - Recursive version of sum_list */
long rsum_list(list_ptr ls)
{
    if (!ls)
	return 0;
    else {
	long val = ls-&gt;val;
	long rest = rsum_list(ls-&gt;next);
	return val + rest;
    }
}
</code></pre>
<p>比较简单，直接实现即可</p>
<pre><code>##################################################################
#initialization

.pos 0
irmovq stack, %rsp
call main
halt


#sample linked list

.align 8
ele1:
    .quad 0x00a
    .quad ele2
ele2:
    .quad 0x0b0
    .quad ele3
ele3:
    .quad 0xc00
    .quad 0

#main function

main:
    irmovq ele1, %rdi
    call sum_list
    ret

#sum_list function

sum_list:
    irmovq $0, %rax
    jmp test

loop:
    mrmovq (%rdi), %rsi
    addq %rsi, %rax
    mrmovq 8(%rdi), %rdi

test:
    andq %rdi, %rdi
    jne loop
    ret

#set initial adress of %rsp

    .pos 0x200
stack:

</code></pre>
<p>最后需要多打一个换行才能过编译，我也不知道为什么（</p>
<pre><code>##################################################################
#initialization

.pos 0
irmovq stack, %rsp
call main
halt


#sample linked list

.align 8
ele1:
    .quad 0x00a
    .quad ele2
ele2:
    .quad 0x0b0
    .quad ele3
ele3:
    .quad 0xc00
    .quad 0

#main function
main:
    irmovq ele1, %rdi
    irmovq $0, %rax
    call rsum_list
    ret

#recursively calculate the sum of a list

rsum_list:
    pushq %rbp
    andq %rdi, %rdi
    je return
    mrmovq (%rdi), %rbp
    addq %rbp, %rax
    mrmovq 8(%rdi), %rdi
    call rsum_list
return:
    popq %rbp
    ret

#set initial adress of %rsp

    .pos 0x200
stack:

</code></pre>
<p>注意递归结束的时候，需要恢复被调用者保存寄存器的原始值</p>
<pre><code>/* copy_block - Copy src to dest and return xor checksum of src */
long copy_block(long *src, long *dest, long len)
{
    long result = 0;
    while (len &gt; 0) {
	long val = *src++;
	*dest++ = val;
	result ^= val;
	len--;
    }
    return result;
}
/* $end examples */
</code></pre>
<pre><code>##################################################################
#initialization

.pos 0
irmovq stack, %rsp
call main
halt

#sample

.align 8
# Source block
src:
    .quad 0x00a
    .quad 0x0b0
    .quad 0xc00
# Destination block
dest:
    .quad 0x111
    .quad 0x222
    .quad 0x333

#main function

main:
    irmovq src, %rdi
    irmovq dest, %rsi
    irmovq $3, %rdx
    irmovq $0, %rax
    irmovq $8, %rcx
    irmovq $1, %r8
    call copy_block
    ret

#copy function

copy_block:
    pushq %rbx
test:
    andq %rdx, %rdx
    je return
loop:
    mrmovq (%rdi), %rbx
    xorq %rbx, %rax
    rmmovq %rbx, (%rsi)
    addq %rcx, %rdi
    addq %rcx, %rsi
    subq %r8, %rdx
    jmp test
return:
    popq %rbx
    ret

    .pos 0x200
stack:

</code></pre>
<h3>Part B <code>iaddq</code>的实现</h3>
<p>给定你SEQ的实现，要求你补充<code>iaddq</code>指令(即将寄存器加上一个立即数)的实现</p>
<p>按照SEQ的步骤，一步一步判断每个相关信号的值就行</p>
<p>shell中使用以下指令进行测试</p>
<pre><code>make  VERSION=full
./ssim -t ../y86-code/asumi.yo
cd ../y86-code; make testssim
</code></pre>
<pre><code>#/* $begin seq-all-hcl */
####################################################################
#  HCL Description of Control for Single Cycle Y86-64 Processor SEQ   #
#  Copyright (C) Randal E. Bryant, David R. O'Hallaron, 2010       #
####################################################################

## Your task is to implement the iaddq instruction
## The file contains a declaration of the icodes
## for iaddq (IIADDQ)
## Your job is to add the rest of the logic to make it work

####################################################################
#    C Include's.  Don't alter these                               #
####################################################################

quote '#include &lt;stdio.h&gt;'
quote '#include "isa.h"'
quote '#include "sim.h"'
quote 'int sim_main(int argc, char *argv[]);'
quote 'word_t gen_pc(){return 0;}'
quote 'int main(int argc, char *argv[])'
quote '  {plusmode=0;return sim_main(argc,argv);}'

####################################################################
#    Declarations.  Do not change/remove/delete any of these       #
####################################################################

##### Symbolic representation of Y86-64 Instruction Codes #############
wordsig INOP 	'I_NOP'
wordsig IHALT	'I_HALT'
wordsig IRRMOVQ	'I_RRMOVQ'
wordsig IIRMOVQ	'I_IRMOVQ'
wordsig IRMMOVQ	'I_RMMOVQ'
wordsig IMRMOVQ	'I_MRMOVQ'
wordsig IOPQ	'I_ALU'
wordsig IJXX	'I_JMP'
wordsig ICALL	'I_CALL'
wordsig IRET	'I_RET'
wordsig IPUSHQ	'I_PUSHQ'
wordsig IPOPQ	'I_POPQ'
# Instruction code for iaddq instruction
wordsig IIADDQ	'I_IADDQ'

##### Symbolic represenations of Y86-64 function codes                  #####
wordsig FNONE    'F_NONE'        # Default function code

##### Symbolic representation of Y86-64 Registers referenced explicitly #####
wordsig RRSP     'REG_RSP'    	# Stack Pointer
wordsig RNONE    'REG_NONE'   	# Special value indicating "no register"

##### ALU Functions referenced explicitly                            #####
wordsig ALUADD	'A_ADD'		# ALU should add its arguments

##### Possible instruction status values                             #####
wordsig SAOK	'STAT_AOK'	# Normal execution
wordsig SADR	'STAT_ADR'	# Invalid memory address
wordsig SINS	'STAT_INS'	# Invalid instruction
wordsig SHLT	'STAT_HLT'	# Halt instruction encountered

##### Signals that can be referenced by control logic ####################

##### Fetch stage inputs		#####
wordsig pc 'pc'				# Program counter
##### Fetch stage computations		#####
wordsig imem_icode 'imem_icode'		# icode field from instruction memory
wordsig imem_ifun  'imem_ifun' 		# ifun field from instruction memory
wordsig icode	  'icode'		# Instruction control code
wordsig ifun	  'ifun'		# Instruction function
wordsig rA	  'ra'			# rA field from instruction
wordsig rB	  'rb'			# rB field from instruction
wordsig valC	  'valc'		# Constant from instruction
wordsig valP	  'valp'		# Address of following instruction
boolsig imem_error 'imem_error'		# Error signal from instruction memory
boolsig instr_valid 'instr_valid'	# Is fetched instruction valid?

##### Decode stage computations		#####
wordsig valA	'vala'			# Value from register A port
wordsig valB	'valb'			# Value from register B port

##### Execute stage computations	#####
wordsig valE	'vale'			# Value computed by ALU
boolsig Cnd	'cond'			# Branch test

##### Memory stage computations		#####
wordsig valM	'valm'			# Value read from memory
boolsig dmem_error 'dmem_error'		# Error signal from data memory


####################################################################
#    Control Signal Definitions.                                   #
####################################################################

################ Fetch Stage     ###################################

# Determine instruction code
word icode = [
	imem_error: INOP;
	1: imem_icode;		# Default: get from instruction memory
];

# Determine instruction function
word ifun = [
	imem_error: FNONE;
	1: imem_ifun;		# Default: get from instruction memory
];

bool instr_valid = icode in 
	{ INOP, IHALT, IRRMOVQ, IIRMOVQ, IRMMOVQ, IMRMOVQ,
	       IOPQ, IJXX, ICALL, IRET, IPUSHQ, IPOPQ, IIADDQ};

# Does fetched instruction require a regid byte?
bool need_regids =
	icode in { IRRMOVQ, IOPQ, IPUSHQ, IPOPQ, 
		     IIRMOVQ, IRMMOVQ, IMRMOVQ , IIADDQ};

# Does fetched instruction require a constant word?
bool need_valC =
	icode in { IIRMOVQ, IRMMOVQ, IMRMOVQ, IJXX, ICALL , IIADDQ};

################ Decode Stage    ###################################

## What register should be used as the A source?
word srcA = [
	icode in { IRRMOVQ, IRMMOVQ, IOPQ, IPUSHQ  } : rA;
	icode in { IPOPQ, IRET } : RRSP;
	1 : RNONE; # Don't need register
];

## What register should be used as the B source?
word srcB = [
	icode in { IOPQ, IRMMOVQ, IMRMOVQ, IIADDQ  } : rB;
	icode in { IPUSHQ, IPOPQ, ICALL, IRET } : RRSP;
	1 : RNONE;  # Don't need register
];

## What register should be used as the E destination?
word dstE = [
	icode in { IRRMOVQ } &amp;&amp; Cnd : rB;
	icode in { IIRMOVQ, IOPQ, IIADDQ} : rB;
	icode in { IPUSHQ, IPOPQ, ICALL, IRET } : RRSP;
	1 : RNONE;  # Don't write any register
];

## What register should be used as the M destination?
word dstM = [
	icode in { IMRMOVQ, IPOPQ } : rA;
	1 : RNONE;  # Don't write any register
];

################ Execute Stage   ###################################

## Select input A to ALU
word aluA = [
	icode in { IRRMOVQ, IOPQ } : valA;
	icode in { IIRMOVQ, IRMMOVQ, IMRMOVQ , IIADDQ} : valC;
	icode in { ICALL, IPUSHQ } : -8;
	icode in { IRET, IPOPQ } : 8;
	# Other instructions don't need ALU
];

## Select input B to ALU
word aluB = [
	icode in { IRMMOVQ, IMRMOVQ, IOPQ, ICALL, 
		      IPUSHQ, IRET, IPOPQ , IIADDQ} : valB;
	icode in { IRRMOVQ, IIRMOVQ } : 0;
	# Other instructions don't need ALU
];

## Set the ALU function
word alufun = [
	icode == IOPQ : ifun;
	1 : ALUADD;
];

## Should the condition codes be updated?
bool set_cc = icode in { IOPQ , IIADDQ};

################ Memory Stage    ###################################

## Set read control signal
bool mem_read = icode in { IMRMOVQ, IPOPQ, IRET };

## Set write control signal
bool mem_write = icode in { IRMMOVQ, IPUSHQ, ICALL };

## Select memory address
word mem_addr = [
	icode in { IRMMOVQ, IPUSHQ, ICALL, IMRMOVQ } : valE;
	icode in { IPOPQ, IRET } : valA;
	# Other instructions don't need address
];

## Select memory input data
word mem_data = [
	# Value from register
	icode in { IRMMOVQ, IPUSHQ } : valA;
	# Return PC
	icode == ICALL : valP;
	# Default: Don't write anything
];

## Determine instruction status
word Stat = [
	imem_error || dmem_error : SADR;
	!instr_valid: SINS;
	icode == IHALT : SHLT;
	1 : SAOK;
];

################ Program Counter Update ############################

## What address should instruction be fetched at

word new_pc = [
	# Call.  Use instruction constant
	icode == ICALL : valC;
	# Taken branch.  Use instruction constant
	icode == IJXX &amp;&amp; Cnd : valC;
	# Completion of RET instruction.  Use value from stack
	icode == IRET : valM;
	# Default: Use incremented PC
	1 : valP;
];
#/* $end seq-all-hcl */

</code></pre>
<h3>Part C Y86-64程序性能优化</h3>
<h4>写在前面</h4>
<p>这个lab给定了你流水线化的控制代码以及一段代码的C和Y86-64实现，要求你优化流水线的控制代码和汇编代码，提高其运行效率</p>
<pre><code>make  VERSION=full
cd ../ptest; make SIM=../pipe/psim
cd ../ptest; make SIM=../pipe/psim TFLAGS=-i
</code></pre>
<p>可以对修改后的处理器进行测试</p>
<p><code>../misc/yas ncopy.ys &amp;&amp; ./check-len.pl &lt; ncopy.yo</code> 检查汇编代码是否超过1000Byte的限制</p>
<p><code>./correctness.pl</code>检查汇编代码的正确性</p>
<p><code>make drivers &amp;&amp; ./benchmark.pl</code>进行本地跑分，计算CPE</p>
<p>得分细则如下</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-archlab-image-001.DnetESuu_2hiYuy.webp" alt="image" /></p>
<p>C代码如下，函数的功能是将src开始的len个元素全部拷贝到dst对应地址中，并返回源数据中正数个数</p>
<pre><code>/* $begin ncopy */
/*
 * ncopy - copy src to dst, returning number of positive ints
 * contained in src array.
 */
word_t ncopy(word_t *src, word_t *dst, word_t len)
{
    word_t count = 0;
    word_t val;

    while (len &gt; 0) {
	val = *src++;
	*dst++ = val;
	if (val &gt; 0)
	    count++;
	len--;
    }
    return count;
}
/* $end ncopy */
</code></pre>
<p>Y86-64汇编原始代码</p>
<pre><code>##################################################################
# %rdi = src, %rsi = dst, %rdx = len
# You can modify this portion
	# Loop header
	xorq %rax,%rax		# count = 0;
	andq %rdx,%rdx		# len &lt;= 0?
	jle Done		# if so, goto Done:

Loop:	
    mrmovq (%rdi), %r10	# read val from src...
	rmmovq %r10, (%rsi)	# ...and store it to dst
	andq %r10, %r10		# val &lt;= 0?
	jle Npos		# if so, goto Npos:
	irmovq $1, %r10
	addq %r10, %rax		# count++
Npos:	
	irmovq $1, %r10
	subq %r10, %rdx		# len--
	irmovq $8, %r10
	addq %r10, %rdi		# src++
	addq %r10, %rsi		# dst++
	andq %rdx,%rdx		# len &gt; 0?
	jg Loop			# if so, goto Loop:
</code></pre>
<p>跑分结果如下：</p>
<p><code>Average CPE     15.18 Score   0.0/60.0</code></p>
<p>你都交原始代码了还想得分？(</p>
<h4>使用iaddq指令</h4>
<p>我们用Part B中相同的步骤，修改 <code>pipe-full.hcl</code>引入<code>iaddq</code>指令，减少向寄存器反复写入常数的开销</p>
<pre><code>##################################################################
# You can modify this portion
	# Loop header
	xorq %rax,%rax		# count = 0;
	andq %rdx,%rdx		# len &lt;= 0?
	jle Done		# if so, goto Done:

Loop:	
    mrmovq (%rdi), %r10	# read val from src...
	rmmovq %r10, (%rsi)	# ...and store it to dst
	andq %r10, %r10		# val &lt;= 0?
	jle Npos		# if so, goto Npos:	
    iaddq $1, %rax      # count++
Npos:
    iaddq $-1, %rdx     # len--
	irmovq $8, %r10
	iaddq $8, %rdi		# src++
	iaddq $8, %rsi		# dst++
	andq %rdx,%rdx		# len &gt; 0?
	jg Loop			# if so, goto Loop:
</code></pre>
<p><code>Average CPE     13.70 Score   0.0/60.0</code></p>
<p>优化后结果如下，性能略有提升，但仍然是0分</p>
<h4>循环展开以及用条件传送替换条件跳转</h4>
<p>考虑对源代码进行 $8 \times 8$的循环展开，同时使用不同的寄存器存储拷贝的值，减少循环判断和数据相关性</p>
<p>同时，对于统计正数这一部分，我们可以简单地使用条件传送而非条件跳转，避免分支预测错误带来的巨大性能损失</p>
<pre><code>##################################################################
	xorq %rax,%rax		# count = 0;
	andq %rdx,%rdx		# len &lt;= 0?
	jle Done		# if so, goto Done:
    irmovq $1, %r8  #store const 1 in %r8

Judge:
    iaddq $-8, %rdx
    jl Endloop

Loop:
    mrmovq (%rdi), %rbx
    mrmovq 8(%rdi), %rbp
    mrmovq 16(%rdi), %r9
    mrmovq 24(%rdi), %r10
    mrmovq 32(%rdi), %r11
    mrmovq 40(%rdi), %r12
    mrmovq 48(%rdi), %r13
    mrmovq 56(%rdi), %r14

    irmovq $0, %rcx
    andq %rbx, %rbx
    cmovg %r8, %rcx
    addq %rcx, %rax

    irmovq $0, %rcx
    andq %rbp, %rbp
    cmovg %r8, %rcx
    addq %rcx, %rax

    irmovq $0, %rcx
    andq %r9, %r9
    cmovg %r8, %rcx
    addq %rcx, %rax

    irmovq $0, %rcx
    andq %r10, %r10
    cmovg %r8, %rcx
    addq %rcx, %rax

    irmovq $0, %rcx
    andq %r11, %r11
    cmovg %r8, %rcx
    addq %rcx, %rax

    irmovq $0, %rcx
    andq %r12, %r12
    cmovg %r8, %rcx
    addq %rcx, %rax

    irmovq $0, %rcx
    andq %r13, %r13
    cmovg %r8, %rcx
    addq %rcx, %rax

    irmovq $0, %rcx
    andq %r14, %r14
    cmovg %r8, %rcx
    addq %rcx, %rax

    rmmovq %rbx, (%rsi)
    rmmovq %rbp, 8(%rsi)
    rmmovq %r9, 16(%rsi)
    rmmovq %r10, 24(%rsi)
    rmmovq %r11, 32(%rsi)
    rmmovq %r12, 40(%rsi)
    rmmovq %r13, 48(%rsi)
    rmmovq %r14, 56(%rsi)

	iaddq $64, %rdi		# src+=8
	iaddq $64, %rsi		# dst+=8
	jmp Judge

Endloop:
    iaddq $8, %rdx

Judge2:
    andq %rdx, %rdx
    jle Done
Loop2:
    mrmovq (%rdi), %rbx
    irmovq $0, %rcx
    andq %rbx, %rbx
    cmovg %r8, %rcx
    addq %rcx, %rax
    rmmovq %rbx, (%rsi)
    iaddq $8, %rdi
    iaddq $8, %rsi
    iaddq $-1, %rdx
    jmp Judge2
</code></pre>
<p>CPE以及得分如下</p>
<p><code>Average CPE     9.98 Score   10.5/60.0</code></p>
<p>我们发现，在拷贝的数量比较少的时候，CPE的值相当大，甚至比最原始的汇编代码性能还要差，同时，在注释掉对于剩下 $len %8$个数的拷贝与统计的时候，CPE下降到了6.79，足以得到满分，说明该程序性能的瓶颈在于对余数的处理</p>
<h4>对于余数的处理</h4>
<p>考虑对于最后8个数不采用循环，直接类似循环展开依次拷贝并统计</p>
<pre><code>##################################################################
Endloop:
    iaddq $8, %rdx
    jle Done

    mrmovq (%rdi), %rbx
    irmovq $0, %rcx
    andq %rbx, %rbx
    cmovg %r8, %rcx
    addq %rcx, %rax    
    iaddq $-1, %rdx
    rmmovq %rbx, (%rsi)
    jle Done

    mrmovq 8(%rdi), %rbp
    irmovq $0, %rcx
    andq %rbp, %rbp
    cmovg %r8, %rcx
    addq %rcx, %rax    
    iaddq $-1, %rdx
    rmmovq %rbp, 8(%rsi)
    jle Done

    mrmovq 16(%rdi), %r9
    irmovq $0, %rcx
    andq %r9, %r9
    cmovg %r8, %rcx
    addq %rcx, %rax    
    iaddq $-1, %rdx
    rmmovq %r9, 16(%rsi)
    jle Done

    mrmovq 24(%rdi), %r10
    irmovq $0, %rcx
    andq %r10, %r10
    cmovg %r8, %rcx
    addq %rcx, %rax    
    iaddq $-1, %rdx
    rmmovq %r10, 24(%rsi)
    jle Done

    mrmovq 32(%rdi), %r11
    irmovq $0, %rcx
    andq %r11, %r11
    cmovg %r8, %rcx
    addq %rcx, %rax    
    iaddq $-1, %rdx
    rmmovq %r11, 32(%rsi)
    jle Done

    mrmovq 40(%rdi), %r12
    irmovq $0, %rcx
    andq %r12, %r12
    cmovg %r8, %rcx
    addq %rcx, %rax    
    iaddq $-1, %rdx
    rmmovq %r12, 40(%rsi)
    jle Done

    mrmovq 48(%rdi), %r13
    irmovq $0, %rcx
    andq %r13, %r13
    cmovg %r8, %rcx
    addq %rcx, %rax    
    iaddq $-1, %rdx
    rmmovq %r13, 48(%rsi)
    jle Done

    mrmovq 56(%rdi), %r14
    irmovq $0, %rcx
    andq %r14, %r14
    cmovg %r8, %rcx
    addq %rcx, %rax
    iaddq $-1, %rdx
    rmmovq %r14, 56(%rsi)
</code></pre>
<p><code>Average CPE     9.04 Score   29.3/60.0</code></p>
<p>性能略有提升</p>
<p>再考虑到该处理器对于分支的预测是预测进入，而对于后八个数进入 <code>Done</code>的可能性更小，可能会导致分支预测出错导致性能下降，所以我们将跳转改为更可能的进入下一个数的处理</p>
<pre><code>##################################################################
Endloop:
    iaddq $8, %rdx
    jle Done

    mrmovq (%rdi), %rbx
    irmovq $0, %rcx
    andq %rbx, %rbx
    cmovg %r8, %rcx
    addq %rcx, %rax    
    iaddq $-1, %rdx
    rmmovq %rbx, (%rsi)
    jg calc2
    jmp Done

calc2:
    mrmovq 8(%rdi), %rbp
    irmovq $0, %rcx
    andq %rbp, %rbp
    cmovg %r8, %rcx
    addq %rcx, %rax    
    iaddq $-1, %rdx
    rmmovq %rbp, 8(%rsi)
    jg calc3
    jmp Done

calc3:
    mrmovq 16(%rdi), %r9
    irmovq $0, %rcx
    andq %r9, %r9
    cmovg %r8, %rcx
    addq %rcx, %rax    
    iaddq $-1, %rdx
    rmmovq %r9, 16(%rsi)
    jg calc4
    jmp Done

calc4:
    mrmovq 24(%rdi), %r10
    irmovq $0, %rcx
    andq %r10, %r10
    cmovg %r8, %rcx
    addq %rcx, %rax    
    iaddq $-1, %rdx
    rmmovq %r10, 24(%rsi)
    jg calc5
    jmp Done

calc5:
    mrmovq 32(%rdi), %r11
    irmovq $0, %rcx
    andq %r11, %r11
    cmovg %r8, %rcx
    addq %rcx, %rax    
    iaddq $-1, %rdx
    rmmovq %r11, 32(%rsi)
    jg calc6
    jmp Done

calc6:
    mrmovq 40(%rdi), %r12
    irmovq $0, %rcx
    andq %r12, %r12
    cmovg %r8, %rcx
    addq %rcx, %rax    
    iaddq $-1, %rdx
    rmmovq %r12, 40(%rsi)
    jg calc7
    jmp Done

calc7:
    mrmovq 48(%rdi), %r13
    irmovq $0, %rcx
    andq %r13, %r13
    cmovg %r8, %rcx
    addq %rcx, %rax    
    iaddq $-1, %rdx
    rmmovq %r13, 48(%rsi)
    jg calc8
    jmp Done

calc8:
    mrmovq 56(%rdi), %r14
    irmovq $0, %rcx
    andq %r14, %r14
    cmovg %r8, %rcx
    addq %rcx, %rax
    iaddq $-1, %rdx
    rmmovq %r14, 56(%rsi)
</code></pre>
<p><code>Average CPE     8.92 Score   31.5/60.0</code></p>
<p>性能也有所提升</p>
<h4>一些奇怪的优化</h4>
<p>发现从条件传送改回条件跳转效率反而增加了。。。可能是条件传送要求对 <code>%rcx</code>反复进行清零，传送，加法，相关性过高，操作数量也变多了，反而不如条件跳转（</p>
<pre><code>##################################################################
    andq %rbx, %rbx
    jle Test2
    iaddq $1, %rax

Test2:
    andq %rbp, %rbp
    jle Test3
    iaddq $1, %rax
</code></pre>
<p>类似这样修改就行</p>
<p><code>Average CPE     8.50 Score   40.0/60.0</code></p>
<p>效率又提升了</p>
<p>然后发现实验驱动在进入<code>ncopy</code>时<code>%rax</code>的值初始为0，且测试数据中不存在负长度的情况，因此为了榨分可以省掉循环之前的初始化与检查（按一般调用约定，健壮写法仍应显式把返回值寄存器清零并处理<code>len &lt;= 0</code>）</p>
<p><code>Average CPE     8.13 Score   47.4/60.0</code></p>
<h4>我很难受，叫基米来</h4>
<p>现在代码的限制瓶颈是处理余数时由于不知道需要处理的个数，我们只能将数据从内存中加载到寄存器后就立即使用检查其是否大于0，这产生了数据依赖</p>
<p>到了这里已经燃尽了，尝试过将余数按照$2 \times 2$循环展开效率反而下降了，想着提前将余数从内存放进寄存器来减少数据相关，但是会超过编码长度限制，是时候询问伟大的哈基米3.0pro了（</p>
<p>Gemini3.0pro告诉我，可以用类似二叉树的结构高效地处理余数，具体来说，可以先将余数按照0-3和4-7分为左右儿子，对于右儿子，可以直接加载0-3的数进入寄存器中，进而减小了数据依赖，每个节点继续向下分，对于大小为2的右儿子也可以直接加载左儿子寄存器</p>
<p>同时发现循环展开中专门为循环判断设计函数进行跳转是不必要的，可以直接将判断写在循环的末尾</p>
<p>循环结尾改为</p>
<pre><code>##################################################################
Test9:
	iaddq $64, %rdi		# src+=8
	iaddq $64, %rsi		# dst+=8
    iaddq $-8, %rdx
	jge Loop
</code></pre>
<p>使用二叉树结构处理余数部分汇编代码如下</p>
<pre><code>##################################################################
Endloop:
    # -8 &lt;= %rdx &lt;= -1
    iaddq $4, %rdx
    jge Four_to_Seven
    iaddq $4, %rdx
    jmp Zero_to_Three
Four_to_Seven:
    mrmovq (%rdi), %rbx
    mrmovq 8(%rdi), %rbp
    mrmovq 16(%rdi), %r9
    mrmovq 24(%rdi), %r10

    rmmovq %rbx, (%rsi)
    rmmovq %rbp, 8(%rsi)
    rmmovq %r9, 16(%rsi)
    rmmovq %r10, 24(%rsi)

    andq %rbx, %rbx
    jle Notadd1
    iaddq $1, %rax
Notadd1:
    andq %rbp, %rbp
    jle Notadd2
    iaddq $1, %rax
Notadd2:
    andq %r9, %r9
    jle Notadd3
    iaddq $1, %rax
Notadd3:
    andq %r10, %r10
    jle Notadd4
    iaddq $1, %rax
Notadd4:
    iaddq $32, %rdi
    iaddq $32, %rsi

Zero_to_Three:
    # 0 &lt;= %rdx &lt;= 3
    iaddq $-2, %rdx
    jge Two_to_Three
    iaddq $2, %rdx
    jmp Zero_to_One

Two_to_Three:
    mrmovq (%rdi), %rbx
    mrmovq 8(%rdi), %rbp
    andq %rbx, %rbx
    jle Notadd1_2
    iaddq $1, %rax
Notadd1_2:
    andq %rbp, %rbp
    jle Notadd2_2
    iaddq $1, %rax
Notadd2_2:
    rmmovq %rbx, (%rsi)
    rmmovq %rbp, 8(%rsi)
    iaddq $16, %rdi
    iaddq $16, %rsi

Zero_to_One:
    andq %rdx, %rdx
    je Done
    mrmovq (%rdi), %rbx
    rmmovq %rbx, (%rsi)
    andq %rbx, %rbx
    jle Done
    iaddq $1, %rax
</code></pre>
<p><code>Average CPE     7.90 Score   52.1/60.0</code></p>
<p>尝试在余数为2和3的时候进行特判，避免进入左子树</p>
<pre><code>##################################################################
Two_to_Three:
    mrmovq (%rdi), %rbx
    mrmovq 8(%rdi), %rbp
    je Handle_2
    mrmovq 16(%rdi), %r9
    rmmovq %r9, 16(%rsi)
    andq %r9, %r9
    jle Handle_2
    iaddq $1, %rax
Handle_2:
    rmmovq %rbx, (%rsi)
    andq %rbx, %rbx
    jle Notadd1_2
    iaddq $1, %rax
Notadd1_2:
    rmmovq %rbp, 8(%rsi)
    andq %rbp, %rbp
    jle Done
    iaddq $1, %rax
    jmp Done
</code></pre>
<p><code>Average CPE     7.80 Score   54.0/60.0</code></p>
<p>同时我们发现在处理的长度特别小的时候，CPE相当大</p>
<p><code> ncopy 0       26 1       33      33.00 2       33      16.50 3       39      13.00 4       46      11.50 5       53      10.60</code></p>
<p>由于处理器的分支预测逻辑是预测进入，所以我们尝试修改为预测进入左子树</p>
<pre><code>##################################################################
Endloop:
    # -8 &lt;= %rdx &lt;= -1
    iaddq $4, %rdx
    jl Pre_Zero_to_Three

Four_to_Seven:
    mrmovq (%rdi), %rbx
    mrmovq 8(%rdi), %rbp
    mrmovq 16(%rdi), %r9
    mrmovq 24(%rdi), %r10

    rmmovq %rbx, (%rsi)
    rmmovq %rbp, 8(%rsi)
    rmmovq %r9, 16(%rsi)
    rmmovq %r10, 24(%rsi)
    
    andq %rbx, %rbx
    jle Notadd1
    iaddq $1, %rax
Notadd1:
    andq %rbp, %rbp
    jle Notadd2
    iaddq $1, %rax
Notadd2:
    andq %r9, %r9
    jle Notadd3
    iaddq $1, %rax
Notadd3:
    andq %r10, %r10
    jle Notadd4
    iaddq $1, %rax
Notadd4:
    iaddq $32, %rdi
    iaddq $32, %rsi
    jmp Zero_to_Three

Pre_Zero_to_Three:
    iaddq $4, %rdx

Zero_to_Three:
    # 0 &lt;= %rdx &lt;= 3
    iaddq $-2, %rdx
    jl Zero_to_One

Two_to_Three:
    mrmovq (%rdi), %rbx
    mrmovq 8(%rdi), %rbp
    je Handle_2
    mrmovq 16(%rdi), %r9
    rmmovq %r9, 16(%rsi)
    andq %r9, %r9
    jle Handle_2
    iaddq $1, %rax
Handle_2:
    rmmovq %rbx, (%rsi)
    andq %rbx, %rbx
    jle Notadd1_2
    iaddq $1, %rax
Notadd1_2:
    rmmovq %rbp, 8(%rsi)
    andq %rbp, %rbp
    jle Done
    iaddq $1, %rax
    jmp Done

Zero_to_One:
    iaddq $2, %rdx
    je Done
    mrmovq (%rdi), %rbx
    rmmovq %rbx, (%rsi)
    andq %rbx, %rbx
    jle Done
    iaddq $1, %rax
</code></pre>
<p><code>Average CPE     7.65 Score   57.1/60.0</code></p>
<p>调到这里已经产生生理性不适了，再写下去就要堆成屎山了，后面的区域以后再来探索吧（（（</p>
<p>遗憾离场</p>
<pre><code>#/* $begin ncopy-ys */
##################################################################
# ncopy.ys - Copy a src block of len words to dst.
# Return the number of positive words (&gt;0) contained in src.
#
# Include your name and ID here.
#
# Describe how and why you modified the baseline code.
#
##################################################################
# Do not modify this portion
# Function prologue.
# %rdi = src, %rsi = dst, %rdx = len
ncopy:

##################################################################
# You can modify this portion
	# Loop header
	# count = 0;
	# len &lt;= 0?
	# if so, goto Done:

    iaddq $-8, %rdx
    jl Endloop

Loop:
    mrmovq (%rdi), %rbx
    mrmovq 8(%rdi), %rbp
    mrmovq 16(%rdi), %r9
    mrmovq 24(%rdi), %r10
    mrmovq 32(%rdi), %r11
    mrmovq 40(%rdi), %r12
    mrmovq 48(%rdi), %r13
    mrmovq 56(%rdi), %r14

    rmmovq %rbx, (%rsi)
    rmmovq %rbp, 8(%rsi)
    rmmovq %r9, 16(%rsi)
    rmmovq %r10, 24(%rsi)
    rmmovq %r11, 32(%rsi)
    rmmovq %r12, 40(%rsi)
    rmmovq %r13, 48(%rsi)
    rmmovq %r14, 56(%rsi)

    andq %rbx, %rbx
    jle Test2
    iaddq $1, %rax

Test2:
    andq %rbp, %rbp
    jle Test3
    iaddq $1, %rax

Test3:
    andq %r9, %r9
    jle Test4
    iaddq $1, %rax

Test4:
    andq %r10, %r10
    jle Test5
    iaddq $1, %rax

Test5:
    andq %r11, %r11
    jle Test6
    iaddq $1, %rax

Test6:
    andq %r12, %r12
    jle Test7
    iaddq $1, %rax

Test7:
    andq %r13, %r13
    jle Test8
    iaddq $1, %rax

Test8:
    andq %r14, %r14
    jle Test9
    iaddq $1, %rax

Test9:
	iaddq $64, %rdi		# src+=8
	iaddq $64, %rsi		# dst+=8
    iaddq $-8, %rdx
	jge Loop

# handle remainder
Endloop:
    # -8 &lt;= %rdx &lt;= -1
    iaddq $4, %rdx
    jl Pre_Zero_to_Three

Four_to_Seven:
    mrmovq (%rdi), %rbx
    mrmovq 8(%rdi), %rbp
    mrmovq 16(%rdi), %r9
    mrmovq 24(%rdi), %r10

    rmmovq %rbx, (%rsi)
    rmmovq %rbp, 8(%rsi)
    rmmovq %r9, 16(%rsi)
    rmmovq %r10, 24(%rsi)
    
    andq %rbx, %rbx
    jle Notadd1
    iaddq $1, %rax
Notadd1:
    andq %rbp, %rbp
    jle Notadd2
    iaddq $1, %rax
Notadd2:
    andq %r9, %r9
    jle Notadd3
    iaddq $1, %rax
Notadd3:
    andq %r10, %r10
    jle Notadd4
    iaddq $1, %rax
Notadd4:
    iaddq $32, %rdi
    iaddq $32, %rsi
    jmp Zero_to_Three

Pre_Zero_to_Three:
    iaddq $4, %rdx

Zero_to_Three:
    # 0 &lt;= %rdx &lt;= 3
    iaddq $-2, %rdx
    jl Zero_to_One

Two_to_Three:
    mrmovq (%rdi), %rbx
    mrmovq 8(%rdi), %rbp
    je Handle_2
    mrmovq 16(%rdi), %r9
    rmmovq %r9, 16(%rsi)
    andq %r9, %r9
    jle Handle_2
    iaddq $1, %rax
Handle_2:
    rmmovq %rbx, (%rsi)
    andq %rbx, %rbx
    jle Notadd1_2
    iaddq $1, %rax
Notadd1_2:
    rmmovq %rbp, 8(%rsi)
    andq %rbp, %rbp
    jle Done
    iaddq $1, %rax
    jmp Done

Zero_to_One:
    iaddq $2, %rdx
    je Done
    mrmovq (%rdi), %rbx
    rmmovq %rbx, (%rsi)
    andq %rbx, %rbx
    jle Done
    iaddq $1, %rax

##################################################################
# Do not modify the following section of code
# Function epilogue.
Done:
	ret
##################################################################
# Keep the following label at the end of your function
End:
#/* $end ncopy-ys */
    
</code></pre>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="CSAPP"></category>
  </entry>
  <entry>
    <title>bomblab</title>
    <link href="https://katyusha-blog.com/posts/csapp/lab/bomblab/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/csapp/lab/bomblab/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-05-25T00:00:00.000Z</updated>
    <summary>bomblab</summary>
    <content type="html"><![CDATA[<h3>写在前面</h3>
<p>规则：对于每个$phase$，你都需要输入一个字符串，使得$explode_bomb$函数不被运行</p>
<p>在bomb目录下使用<code>objdump -d bomb &gt; bomb.s</code>得到反汇编文件$bomb.s$</p>
<p>$shell$ 中使用 <code>gdb bomb</code>进入$gdb$调试</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-bomb-image-001.iwxm2ddY_fIm81.webp" alt="image" /></p>
<h3>phase_1</h3>
<pre><code>  0000000000400ee0 &lt;phase_1&gt;:
  400ee0:	48 83 ec 08          	sub    $0x8,%rsp
  400ee4:	be 00 24 40 00       	mov    $0x402400,%esi
  400ee9:	e8 4a 04 00 00       	call   401338 &lt;strings_not_equal&gt;
  400eee:	85 c0                	test   %eax,%eax
  400ef0:	74 05                	je     400ef7 &lt;phase_1+0x17&gt;
  400ef2:	e8 43 05 00 00       	call   40143a &lt;explode_bomb&gt;
  400ef7:	48 83 c4 08          	add    $0x8,%rsp
  400efb:	c3                   	ret
</code></pre>
<p>先是额外调整8字节栈空间以满足调用时的栈对齐要求，然后进入了$strings_not_equal$函数中；返回地址已经由调用<code>phase_1</code>时的<code>call</code>指令压栈</p>
<pre><code>  0000000000401338 &lt;strings_not_equal&gt;:
  401338:	41 54                	push   %r12
  40133a:	55                   	push   %rbp
  40133b:	53                   	push   %rbx
  40133c:	48 89 fb             	mov    %rdi,%rbx
  40133f:	48 89 f5             	mov    %rsi,%rbp
  401342:	e8 d4 ff ff ff       	call   40131b &lt;string_length&gt;
  401347:	41 89 c4             	mov    %eax,%r12d
  40134a:	48 89 ef             	mov    %rbp,%rdi
  40134d:	e8 c9 ff ff ff       	call   40131b &lt;string_length&gt;
  401352:	ba 01 00 00 00       	mov    $0x1,%edx
  401357:	41 39 c4             	cmp    %eax,%r12d
  40135a:	75 3f                	jne    40139b &lt;strings_not_equal+0x63&gt;
  40135c:	0f b6 03             	movzbl (%rbx),%eax
  40135f:	84 c0                	test   %al,%al
  401361:	74 25                	je     401388 &lt;strings_not_equal+0x50&gt;
  401363:	3a 45 00             	cmp    0x0(%rbp),%al
  401366:	74 0a                	je     401372 &lt;strings_not_equal+0x3a&gt;
  401368:	eb 25                	jmp    40138f &lt;strings_not_equal+0x57&gt;
  40136a:	3a 45 00             	cmp    0x0(%rbp),%al
  40136d:	0f 1f 00             	nopl   (%rax)
  401370:	75 24                	jne    401396 &lt;strings_not_equal+0x5e&gt;
  401372:	48 83 c3 01          	add    $0x1,%rbx
  401376:	48 83 c5 01          	add    $0x1,%rbp
  40137a:	0f b6 03             	movzbl (%rbx),%eax
  40137d:	84 c0                	test   %al,%al
  40137f:	75 e9                	jne    40136a &lt;strings_not_equal+0x32&gt;
  401381:	ba 00 00 00 00       	mov    $0x0,%edx
  401386:	eb 13                	jmp    40139b &lt;strings_not_equal+0x63&gt;
  401388:	ba 00 00 00 00       	mov    $0x0,%edx
  40138d:	eb 0c                	jmp    40139b &lt;strings_not_equal+0x63&gt;
  40138f:	ba 01 00 00 00       	mov    $0x1,%edx
  401394:	eb 05                	jmp    40139b &lt;strings_not_equal+0x63&gt;
  401396:	ba 01 00 00 00       	mov    $0x1,%edx
  40139b:	89 d0                	mov    %edx,%eax
  40139d:	5b                   	pop    %rbx
  40139e:	5d                   	pop    %rbp
  40139f:	41 5c                	pop    %r12
  4013a1:	c3                   	ret
</code></pre>
<p>阅读地址在$401338$的$strings_not_equal$并结合函数名推断，该函数将$%rdi$和$%rsi$指向的地址的字符串进行比较，若相等则将$%rax$设为0，反之将$%rax$设为1</p>
<p>所以这段汇编代码在$%edi$和$%esi$指向的字符串相同的时候不会爆炸，只需输入内存$0x402400$中的字符串即可</p>
<p><code>(gdb) x/s 0x402400</code></p>
<p>得到 <code>Border relations with Canada have never been better.</code> 即为本题答案</p>
<h3>phase_2</h3>
<pre><code> 0000000000400efc &lt;phase_2&gt;:
  400efc:	55                   	push   %rbp
  400efd:	53                   	push   %rbx
  400efe:	48 83 ec 28          	sub    $0x28,%rsp
  400f02:	48 89 e6             	mov    %rsp,%rsi
  400f05:	e8 52 05 00 00       	call   40145c &lt;read_six_numbers&gt;
</code></pre>
<p>程序首先将$%rbp$和$%rbx$压入栈中保存状态，并为栈分配了40字节的空间，并将$%rsp$栈指针放入$%rsi$作为$read_six_numbers$的第二个参数</p>
<p>接下来我们来看$read_six_numbers$函数</p>
<pre><code> 000000000040145c &lt;read_six_numbers&gt;:
  40145c:	48 83 ec 18          	sub    $0x18,%rsp
  401460:	48 89 f2             	mov    %rsi,%rdx //arg 3
  401463:	48 8d 4e 04          	lea    0x4(%rsi),%rcx //arg 4
  401467:	48 8d 46 14          	lea    0x14(%rsi),%rax
  40146b:	48 89 44 24 08       	mov    %rax,0x8(%rsp) //arg 8
  401470:	48 8d 46 10          	lea    0x10(%rsi),%rax
  401474:	48 89 04 24          	mov    %rax,(%rsp) //arg 7
  401478:	4c 8d 4e 0c          	lea    0xc(%rsi),%r9 //arg 6
  40147c:	4c 8d 46 08          	lea    0x8(%rsi),%r8 // arg 5
  401480:	be c3 25 40 00       	mov    $0x4025c3,%esi //arg 2
  401485:	b8 00 00 00 00       	mov    $0x0,%eax
  40148a:	e8 61 f7 ff ff       	call   400bf0 &lt;__isoc99_sscanf@plt&gt;
  40148f:	83 f8 05             	cmp    $0x5,%eax
  401492:	7f 05                	jg     401499 &lt;read_six_numbers+0x3d&gt;
  401494:	e8 a1 ff ff ff       	call   40143a &lt;explode_bomb&gt;
  401499:	48 83 c4 18          	add    $0x18,%rsp
  40149d:	c3                   	ret
</code></pre>
<p>查表发现其中部分寄存器为参数寄存器，已经在代码中标出</p>
<p>看到这里寄存器指向的地址有点混乱，于是我们进入gdb调试查看相关寄存器的值</p>
<p>通过<code>b explode_bomb</code>在爆炸函数前设置断点，得以保证在刚进入爆炸函数且还未爆炸之前得以停顿进而进行调试</p>
<p>我们随便输入一堆数，然后在断点处检查每个寄存器的值</p>
<p>发现<img src="https://katyusha-blog.com/_astro/csapp-assembly-bomb-image-003.CElwc9DQ_ZQ4mb3.webp" alt="image" /></p>
<p>这类似C/C++中$scanf$的占位符，结合函数名和剩下6个参数寄存器，我们大胆推测这个函数以$%esi$为占位符，读入六个$int$并存放在其他六个参数寄存器中，依次在$%rdx$，$%rcx$，$%r8$，$%r9$和栈中的两个位置</p>
<p>我们手动模拟可以得到以下结果，设最初$%rsp$指向的地址为$p$</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-bomb-image-002.DpCpgp09_Z8ACfR.webp" alt="image" /></p>
<p>可以使用gdb进行验证，同时访问$(%rax)$的值发现是读入的数的个数</p>
<p>成功读入的个数传回$%rax$中，当读入的个数小于等于5时炸弹会爆炸，否则函数正常退出</p>
<p>继续回到$phase_2$中</p>
<pre><code>  400f0a:	83 3c 24 01          	cmpl   $0x1,(%rsp)
  400f0e:	74 20                	je     400f30 &lt;phase_2+0x34&gt;
  400f10:	e8 25 05 00 00       	call   40143a &lt;explode_bomb&gt;
  400f15:	eb 19                	jmp    400f30 &lt;phase_2+0x34&gt;
  400f17:	8b 43 fc             	mov    -0x4(%rbx),%eax
  400f1a:	01 c0                	add    %eax,%eax
  400f1c:	39 03                	cmp    %eax,(%rbx)
  400f1e:	74 05                	je     400f25 &lt;phase_2+0x29&gt;
  400f20:	e8 15 05 00 00       	call   40143a &lt;explode_bomb&gt;
  400f25:	48 83 c3 04          	add    $0x4,%rbx
  400f29:	48 39 eb             	cmp    %rbp,%rbx
  400f2c:	75 e9                	jne    400f17 &lt;phase_2+0x1b&gt;
  400f2e:	eb 0c                	jmp    400f3c &lt;phase_2+0x40&gt;
  400f30:	48 8d 5c 24 04       	lea    0x4(%rsp),%rbx
  400f35:	48 8d 6c 24 18       	lea    0x18(%rsp),%rbp
  400f3a:	eb db                	jmp    400f17 &lt;phase_2+0x1b&gt;
  400f3c:	48 83 c4 28          	add    $0x28,%rsp
  400f40:	5b                   	pop    %rbx
  400f41:	5d                   	pop    %rbp
  400f42:	c3                   	ret
</code></pre>
<p>根据上图可以发现，$%rdx$指向的地址即为$%rsp$，即检查第一个数是否为1，若不是1则直接爆炸</p>
<p>此后将$%rbx$设置为$p+4$(指向第二个数)，$%rbp$设置为$p+24$(指向最后一个数的下一个地址)，并将执行的指令跳转到$*0x400f17$</p>
<p>将$%rax$设置为$%rbx$指向的前一个数，将$p$指向的值乘2以后与$%rbx$比较，若不相等直接爆炸</p>
<p>此后将$%rbx$指向下一个数，检查其若超出了读入的6个数地址范围则安全退出这个函数，否则重复上一行和这一行的内容</p>
<p>可以发现，这一段代码等价于从第二个数开始直到第六个数，检查其是否为前一个数的两倍，全部满足则能安全退出</p>
<p>因此只需要第一个数为1，后面的数每个都为前一个数两倍即可</p>
<p>故答案为 <code>1 2 4 8 16 32</code></p>
<h3>phase_3</h3>
<pre><code> 0000000000400f43 &lt;phase_3&gt;:
  400f43:	48 83 ec 18          	sub    $0x18,%rsp
  400f47:	48 8d 4c 24 0c       	lea    0xc(%rsp),%rcx
  400f4c:	48 8d 54 24 08       	lea    0x8(%rsp),%rdx
  400f51:	be cf 25 40 00       	mov    $0x4025cf,%esi
  400f56:	b8 00 00 00 00       	mov    $0x0,%eax
  400f5b:	e8 90 fc ff ff       	call   400bf0 &lt;__isoc99_sscanf@plt&gt;
  400f60:	83 f8 01             	cmp    $0x1,%eax
  400f63:	7f 05                	jg     400f6a &lt;phase_3+0x27&gt;
  400f65:	e8 d0 04 00 00       	call   40143a &lt;explode_bomb&gt;
  400f6a:	83 7c 24 08 07       	cmpl   $0x7,0x8(%rsp)
  400f6f:	77 3c                	ja     400fad &lt;phase_3+0x6a&gt;
  400f71:	8b 44 24 08          	mov    0x8(%rsp),%eax
  400f75:	ff 24 c5 70 24 40 00 	jmp    *0x402470(,%rax,8)
  400f7c:	b8 cf 00 00 00       	mov    $0xcf,%eax
  400f81:	eb 3b                	jmp    400fbe &lt;phase_3+0x7b&gt;
  400f83:	b8 c3 02 00 00       	mov    $0x2c3,%eax
  400f88:	eb 34                	jmp    400fbe &lt;phase_3+0x7b&gt;
  400f8a:	b8 00 01 00 00       	mov    $0x100,%eax
  400f8f:	eb 2d                	jmp    400fbe &lt;phase_3+0x7b&gt;
  400f91:	b8 85 01 00 00       	mov    $0x185,%eax
  400f96:	eb 26                	jmp    400fbe &lt;phase_3+0x7b&gt;
  400f98:	b8 ce 00 00 00       	mov    $0xce,%eax
  400f9d:	eb 1f                	jmp    400fbe &lt;phase_3+0x7b&gt;
  400f9f:	b8 aa 02 00 00       	mov    $0x2aa,%eax
  400fa4:	eb 18                	jmp    400fbe &lt;phase_3+0x7b&gt;
  400fa6:	b8 47 01 00 00       	mov    $0x147,%eax
  400fab:	eb 11                	jmp    400fbe &lt;phase_3+0x7b&gt;
  400fad:	e8 88 04 00 00       	call   40143a &lt;explode_bomb&gt;
  400fb2:	b8 00 00 00 00       	mov    $0x0,%eax
  400fb7:	eb 05                	jmp    400fbe &lt;phase_3+0x7b&gt;
  400fb9:	b8 37 01 00 00       	mov    $0x137,%eax
  400fbe:	3b 44 24 0c          	cmp    0xc(%rsp),%eax
  400fc2:	74 05                	je     400fc9 &lt;phase_3+0x86&gt;
  400fc4:	e8 71 04 00 00       	call   40143a &lt;explode_bomb&gt;
  400fc9:	48 83 c4 18          	add    $0x18,%rsp
  400fcd:	c3                   	ret
</code></pre>
<p>设最初$%rsp$指向的地址是p,先是分配了24字节的栈空间,此后$%rsp$，$%rcx$，$%rdx$，$%rsi$指向的地址分别为$p-18$,$p-6$,$p-10$,$0x4025cf$,$%rax$的值为0</p>
<p>我们使用 <code>gdb x/s 0x4025cf</code>发现gdb 返回的结果是 <code>0x4025cf:       "%d %d"</code>，说明读入了两个数依次存放在$%rdx$和$%rcx$指向的地址中，若读入的数个数不大于1就爆炸</p>
<p>假设读入的两个数分别为$x$和$y$，$x$大于7也会爆炸</p>
<p>接下来将$%rax$也指向地址$p-10$(x)，然后有一条跳转指令，注意这是一条间接跳转，会让PC地址变为 $0x00402470 +  8 \times x$地址中存放的值</p>
<p>通过 <code>(gdb) x/16x 0x402470</code> 我们可以得到从$0x402470$开始16个单位的值</p>
<p>一个单位为4字节32位，16个单位即为8个64位的指针，结果如下</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-bomb-image-004.BZMpGren_nA0wr.webp" alt="image" /></p>
<p>注意$x86-64$下使用的是小端法</p>
<p>我们不妨从最后一个$explode_bomb$函数开始看，想要不进入这个函数，就需要让最后$%rax$的值等于$%rcx$指向的值(y)</p>
<p><code>jmp    *0x402470(,%rax,8)</code>这一行后面都是对于$%rax$的赋值后跳转到判断$%rax$与y是否相等，所以我们只需要在输入时将y设置为对应跳转时$%rax$赋的值即可</p>
<p>如$x = 0$,$%rax=0xcf=207$或$x = 1$,$%rax = 0x137 = 311$ ...</p>
<p>所以本题一个可能的答案即为 <code>0 207</code></p>
<h3>phase_4</h3>
<pre><code>  40100c &lt;phase_4&gt;: 
  40100c:	48 83 ec 18          	sub    $0x18,%rsp
  401010:	48 8d 4c 24 0c       	lea    0xc(%rsp),%rcx
  401015:	48 8d 54 24 08       	lea    0x8(%rsp),%rdx
  40101a:	be cf 25 40 00       	mov    $0x4025cf,%esi
  40101f:	b8 00 00 00 00       	mov    $0x0,%eax
  401024:	e8 c7 fb ff ff       	call   400bf0 &lt;__isoc99_sscanf@plt&gt;
  401029:	83 f8 02             	cmp    $0x2,%eax
  40102c:	75 07                	jne    401035 &lt;phase_4+0x29&gt;
  40102e:	83 7c 24 08 0e       	cmpl   $0xe,0x8(%rsp)
  401033:	76 05                	jbe    40103a &lt;phase_4+0x2e&gt;
  401035:	e8 00 04 00 00       	call   40143a &lt;explode_bomb&gt;
  40103a:	ba 0e 00 00 00       	mov    $0xe,%edx
  40103f:	be 00 00 00 00       	mov    $0x0,%esi
  401044:	8b 7c 24 08          	mov    0x8(%rsp),%edi
  401048:	e8 81 ff ff ff       	call   400fce &lt;func4&gt;
  40104d:	85 c0                	test   %eax,%eax
  40104f:	75 07                	jne    401058 &lt;phase_4+0x4c&gt;
  401051:	83 7c 24 0c 00       	cmpl   $0x0,0xc(%rsp)
  401056:	74 05                	je     40105d &lt;phase_4+0x51&gt;
  401058:	e8 dd 03 00 00       	call   40143a &lt;explode_bomb&gt;
  40105d:	48 83 c4 18          	add    $0x18,%rsp
  401061:	c3                   	ret
</code></pre>
<p>同样设函数开始时$%rsp$指向的地址为p,在输入前$%rsp$,$%rcx$,$%rdx$指向的地址分别为$p-24$,$p-12$,$p-16$</p>
<p>$0x4025cf$中的格式为 <code>"%d %d"</code>，读入的个数不是2就会爆炸。设读入$%rdx$, $%rcx$中的值分别为$x$和$y$</p>
<p>$x$大于14时会发生爆炸，否则将$%rdx$的值设置为14，$%rsi$的值设置为0，$%rdi$的值设置为$x$，作为参数传入$func4$中</p>
<p>根据退出$func4$后的代码我们知道，只有在$%rax=0$且$p-12$这个地址的值为0的情况下才能安全退出</p>
<pre><code>0000000000400fce &lt;func4&gt;:
 400fce:	48 83 ec 08          	sub    $0x8,%rsp
 400fd2:	89 d0                	mov    %edx,%eax
 400fd4:	29 f0                	sub    %esi,%eax
 400fd6:	89 c1                	mov    %eax,%ecx
 400fd8:	c1 e9 1f             	shr    $0x1f,%ecx
 400fdb:	01 c8                	add    %ecx,%eax
 400fdd:	d1 f8                	sar    $1,%eax
 400fdf:	8d 0c 30             	lea    (%rax,%rsi,1),%ecx
 400fe2:	39 f9                	cmp    %edi,%ecx
 400fe4:	7e 0c                	jle    400ff2 &lt;func4+0x24&gt;
 400fe6:	8d 51 ff             	lea    -0x1(%rcx),%edx
 400fe9:	e8 e0 ff ff ff       	call   400fce &lt;func4&gt;
 400fee:	01 c0                	add    %eax,%eax
 400ff0:	eb 15                	jmp    401007 &lt;func4+0x39&gt;
 400ff2:	b8 00 00 00 00       	mov    $0x0,%eax
 400ff7:	39 f9                	cmp    %edi,%ecx
 400ff9:	7d 0c                	jge    401007 &lt;func4+0x39&gt;
 400ffb:	8d 71 01             	lea    0x1(%rcx),%esi
 400ffe:	e8 cb ff ff ff       	call   400fce &lt;func4&gt;
 401003:	8d 44 00 01          	lea    0x1(%rax,%rax,1),%eax
 401007:	48 83 c4 08          	add    $0x8,%rsp
 40100b:	c3                   	ret
</code></pre>
<p>接下来是这个递归函数$func4$，我太菜了看不懂它叽里咕噜在说写什么，直接运用$OI$知识人肉反编译打表(</p>
<pre><code>#include &lt;bits/stdc++.h&gt;

int rdi, rsi, rdx, rcx, rax;

void f() {
    rax = rdx;
    rax -= rsi;
    rcx = rax;
    rcx &gt;&gt;= 31;
    rax += rcx;
    rax &gt;&gt;= 1;
    rcx = rax + rsi;
    if (rcx &lt;= rdi) goto x400ff2;
    rdx = rcx - 1;
    f(); 
    rax += rax;
    goto x401007;
    x400ff2:
    rax = 0;
    if (rcx &gt;= rdi) goto x401007;
    rsi = rcx + 1;
    f();
    rax = rax + rax + 1;
    x401007:
    return;
}

int main() {
    for (int x = 0; x &lt;= 3; x++) {
        for (int y = 0; y &lt;= 3; y++) {
            rdi = x; rsi = 0; rdx = 14; rcx = y;
            f();
            std::cout &lt;&lt; "x=" &lt;&lt; x &lt;&lt; " y=" &lt;&lt; y &lt;&lt; " rax=" &lt;&lt; rax &lt;&lt; '\n';
        }
    }
}
</code></pre>
<p>结果如下</p>
<pre><code>x=0 y=0 rax=0
x=0 y=1 rax=0
x=0 y=2 rax=0
x=0 y=3 rax=0
x=1 y=0 rax=0
x=1 y=1 rax=0
x=1 y=2 rax=0
x=1 y=3 rax=0
x=2 y=0 rax=4
x=2 y=1 rax=4
x=2 y=2 rax=4
x=2 y=3 rax=4
x=3 y=0 rax=0
x=3 y=1 rax=0
x=3 y=2 rax=0
x=3 y=3 rax=0
</code></pre>
<p>可知并不是所有不大于14的$x$都满足条件；使<code>func4(x, 0, 14)</code>返回0的$x$为二分搜索路径全向左的节点，例如$x=0,1,3,7$，同时$y$必须取0</p>
<p>如取 <code>0 0 </code> 即可</p>
<h3>phase_5</h3>
<pre><code> 0000000000401062 &lt;phase_5&gt;: 
  401062:	53                   	push   %rbx
  401063:	48 83 ec 20          	sub    $0x20,%rsp
  401067:	48 89 fb             	mov    %rdi,%rbx
  40106a:	64 48 8b 04 25 28 00 	mov    %fs:0x28,%rax
  401071:	00 00 
  401073:	48 89 44 24 18       	mov    %rax,0x18(%rsp)
  401078:	31 c0                	xor    %eax,%eax
  40107a:	e8 9c 02 00 00       	call   40131b &lt;string_length&gt;
  40107f:	83 f8 06             	cmp    $0x6,%eax
  401082:	74 4e                	je     4010d2 &lt;phase_5+0x70&gt;
  401084:	e8 b1 03 00 00       	call   40143a &lt;explode_bomb&gt;
  401089:	eb 47                	jmp    4010d2 &lt;phase_5+0x70&gt;
  40108b:	0f b6 0c 03          	movzbl (%rbx,%rax,1),%ecx
  40108f:	88 0c 24             	mov    %cl,(%rsp)
  401092:	48 8b 14 24          	mov    (%rsp),%rdx
  401096:	83 e2 0f             	and    $0xf,%edx
  401099:	0f b6 92 b0 24 40 00 	movzbl 0x4024b0(%rdx),%edx
  4010a0:	88 54 04 10          	mov    %dl,0x10(%rsp,%rax,1)
  4010a4:	48 83 c0 01          	add    $0x1,%rax
  4010a8:	48 83 f8 06          	cmp    $0x6,%rax
  4010ac:	75 dd                	jne    40108b &lt;phase_5+0x29&gt;
  4010ae:	c6 44 24 16 00       	movb   $0x0,0x16(%rsp)
  4010b3:	be 5e 24 40 00       	mov    $0x40245e,%esi
  4010b8:	48 8d 7c 24 10       	lea    0x10(%rsp),%rdi
  4010bd:	e8 76 02 00 00       	call   401338 &lt;strings_not_equal&gt;
  4010c2:	85 c0                	test   %eax,%eax
  4010c4:	74 13                	je     4010d9 &lt;phase_5+0x77&gt;
  4010c6:	e8 6f 03 00 00       	call   40143a &lt;explode_bomb&gt;
  4010cb:	0f 1f 44 00 00       	nopl   0x0(%rax,%rax,1)
  4010d0:	eb 07                	jmp    4010d9 &lt;phase_5+0x77&gt;
  4010d2:	b8 00 00 00 00       	mov    $0x0,%eax
  4010d7:	eb b2                	jmp    40108b &lt;phase_5+0x29&gt;
  4010d9:	48 8b 44 24 18       	mov    0x18(%rsp),%rax
  4010de:	64 48 33 04 25 28 00 	xor    %fs:0x28,%rax
  4010e5:	00 00 
  4010e7:	74 05                	je     4010ee &lt;phase_5+0x8c&gt;
  4010e9:	e8 42 fa ff ff       	call   400b30 &lt;__stack_chk_fail@plt&gt;
  4010ee:	48 83 c4 20          	add    $0x20,%rsp
  4010f2:	5b                   	pop    %rbx
  4010f3:	c3                   	ret
</code></pre>
<p>可以发现读入的是一个字符串，在其长度不为6的时候爆炸</p>
<p>否则将$%rax$设置为0，$%rbx$指向读入字符串的首地址，通过<code>movzbl (%rbx,%rax,1),%ecx</code>访问字符串的第一个字符，将其$and$上15，即取其ASCII码的后四位得到一个整数$k$，以<code>movzbl 0x4024b0(%rdx),%edx</code>访问0x4024b0的后面第$k$个字符并将其最终放入<code>0x10(%rsp,%rax,1)</code>中，重复以上流程六次，然后将地址为$0x40245e$的字符串放入$%esi$中,$%rdi$指向$0x10(%rsp)$即转换后的字符串，将这两个字符串进行比较，若不相等则会爆炸</p>
<p>通过 <code>(gdb) x/s 0x4024b0</code>，我们得到这个地址后面的字符串为 <code>maduiersnfotvbylSo you think you can stop the bomb with ctrl-c, do you?</code></p>
<p>通过 <code>(gdb) x/s 0x40245e</code>可以得到目标字符串为 <code>0x40245e:       "flyers"</code></p>
<p>所以我们只需要输入的每个字符的$ASCII$码二进制后4位转换为十进制分别为9,15,14,5,6,7 就行了</p>
<p>查$ASCII$表发现 <code>Y_.UFG</code>为一组可能的解</p>
<h3>phase_6</h3>
<pre><code> 00000000004010f4 &lt;phase_6&gt;:
  4010f4:	41 56                	push   %r14
  4010f6:	41 55                	push   %r13
  4010f8:	41 54                	push   %r12
  4010fa:	55                   	push   %rbp
  4010fb:	53                   	push   %rbx
  4010fc:	48 83 ec 50          	sub    $0x50,%rsp
  401100:	49 89 e5             	mov    %rsp,%r13
  401103:	48 89 e6             	mov    %rsp,%rsi
  401106:	e8 51 03 00 00       	call   40145c &lt;read_six_numbers&gt;
  40110b:	49 89 e6             	mov    %rsp,%r14
  40110e:	41 bc 00 00 00 00    	mov    $0x0,%r12d
  401114:	4c 89 ed             	mov    %r13,%rbp
  401117:	41 8b 45 00          	mov    0x0(%r13),%eax
  40111b:	83 e8 01             	sub    $0x1,%eax
  40111e:	83 f8 05             	cmp    $0x5,%eax
  401121:	76 05                	jbe    401128 &lt;phase_6+0x34&gt;
  401123:	e8 12 03 00 00       	call   40143a &lt;explode_bomb&gt;
  401128:	41 83 c4 01          	add    $0x1,%r12d
  40112c:	41 83 fc 06          	cmp    $0x6,%r12d
  401130:	74 21                	je     401153 &lt;phase_6+0x5f&gt;
  401132:	44 89 e3             	mov    %r12d,%ebx
  401135:	48 63 c3             	movslq %ebx,%rax
  401138:	8b 04 84             	mov    (%rsp,%rax,4),%eax
  40113b:	39 45 00             	cmp    %eax,0x0(%rbp)
  40113e:	75 05                	jne    401145 &lt;phase_6+0x51&gt;
  401140:	e8 f5 02 00 00       	call   40143a &lt;explode_bomb&gt;
  401145:	83 c3 01             	add    $0x1,%ebx
  401148:	83 fb 05             	cmp    $0x5,%ebx
  40114b:	7e e8                	jle    401135 &lt;phase_6+0x41&gt;
  40114d:	49 83 c5 04          	add    $0x4,%r13
  401151:	eb c1                	jmp    401114 &lt;phase_6+0x20&gt;
  401153:	
</code></pre>
<p>设进入函数时$%rsp$指向的地址为$p$，由$phase_2$的分析可知，读入的六个数(设为$a_0$,$a_1$,$a_2$,$a_3$,$a_4$,$a_5$)地址依次分别在$p-80$，$p-76$，$p-72$，$p-68$，$p-64$，$p-60$</p>
<p>此后，$%rbp$指向了第一个数的地址，检查了这个位置的值，大于6时会爆炸，然后$%rbx$从$%r12$的值开始(初始$%r12$为1)一直到6，检查第$%rbx$个数是否与$%rbp$相等，相等则会爆炸</p>
<p>此后，将$%rbp$指向下一个数，$%r12$的值增加1，重复上一个步骤，直到$%r12$的值等于6，即$%rbp$之后没有其他数</p>
<p>所以当这六个数都在1到6之间且互不相等的时候不会爆炸</p>
<p>手动模拟发现，执行完上述指令后$%rsp$,$%r14$,$%r12$,$%r13$,$%rbp$,$%rax$,$%rbx$的值分别是$p-80$,$p-80$,$6$,$p-60$,$p-60$,$a_6$,$6$</p>
<pre><code>  401153:	48 8d 74 24 18       	lea    0x18(%rsp),%rsi
  401158:	4c 89 f0             	mov    %r14,%rax
  40115b:	b9 07 00 00 00       	mov    $0x7,%ecx
  401160:	89 ca                	mov    %ecx,%edx
  401162:	2b 10                	sub    (%rax),%edx
  401164:	89 10                	mov    %edx,(%rax)
  401166:	48 83 c0 04          	add    $0x4,%rax
  40116a:	48 39 f0             	cmp    %rsi,%rax
  40116d:	75 f1                	jne    401160 &lt;phase_6+0x6c&gt;
</code></pre>
<p>这段指令以$%rax$为指针，从第一个数开始直到最后一个数，将每个数$a_i$都赋值为$7-a_i$，我们不妨设$b_i = 7 - a_i$</p>
<pre><code>  40116f:	be 00 00 00 00       	mov    $0x0,%esi
  401174:	eb 21                	jmp    401197 &lt;phase_6+0xa3&gt;
  401176:	48 8b 52 08          	mov    0x8(%rdx),%rdx
  40117a:	83 c0 01             	add    $0x1,%eax
  40117d:	39 c8                	cmp    %ecx,%eax
  40117f:	75 f5                	jne    401176 &lt;phase_6+0x82&gt;
  401181:	eb 05                	jmp    401188 &lt;phase_6+0x94&gt;
  401183:	ba d0 32 60 00       	mov    $0x6032d0,%edx
  401188:	48 89 54 74 20       	mov    %rdx,0x20(%rsp,%rsi,2)
  40118d:	48 83 c6 04          	add    $0x4,%rsi
  401191:	48 83 fe 18          	cmp    $0x18,%rsi
  401195:	74 14                	je     4011ab &lt;phase_6+0xb7&gt;
  401197:	8b 0c 34             	mov    (%rsp,%rsi,1),%ecx
  40119a:	83 f9 01             	cmp    $0x1,%ecx
  40119d:	7e e4                	jle    401183 &lt;phase_6+0x8f&gt;
  40119f:	b8 01 00 00 00       	mov    $0x1,%eax
  4011a4:	ba d0 32 60 00       	mov    $0x6032d0,%edx
  4011a9:	eb cb                	jmp    401176 &lt;phase_6+0x82&gt;
</code></pre>
<p>看不懂，这家伙又在叽里咕噜说些什么呢（</p>
<p>注意到有一个常量地址 <code>$0x6032d0</code>，于是我们使用 <code>(gdb) x/x 0x6032d0 </code>，发现输出结果为 <code>0x6032d0 &lt;node1&gt;:   0x0000014c</code></p>
<p>这个变量名$node1$有点意思，进一步地，我们使用 <code>(gdb) x/16x 0x6032d0</code> 可以发现输出的结果为</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-bomb-image-005.D3p5pHei_20dMhE.webp" alt="image" /></p>
<p>我们发现，除了$node_6$每个$node_i$的第三个变量都是$node_{i+1}$的地址，可以联想到链表</p>
<p>同时发现每个节点占用4个单位，可以推测是一个64位的指针和两个32位的$int$变量</p>
<p>由于$x86$使用小端法，不难想到节点结构体应该是这样定义的</p>
<pre><code>struct node {
	int val, key;
    node * nxt;
}nd[7];
</code></pre>
<p>链表的结构如下图</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-bomb-image-006.Bl62dRts_S9Vo4.webp" alt="image" /></p>
<p>接下来我们按汇编代码手玩发现，首先通过$%rcx$指向第$i$个数的地址($i$从0取到5)</p>
<p>如果$b_i$的值为1(即$a_i=6$)，就直接将$0x6032d0$（链表的首地址）放入地址为$p-48+8*i$的内存中</p>
<p>否则将$%rdx$指向链表的首地址，并每次移动到$%rbx+8$这个地址(链表下一个节点的地址)，即 $rbx = <em>rbx -&gt; nxt$，直到当前指向的是链表中第$b_i$个节点，然后将当前的地址放入地址为$p-48+8</em>i$的内存中</p>
<p>重复以上过程，直到6个节点地址都被保存下来</p>
<p>我们设重排后的节点从地址$p-48$开始，分别保存的信息为三元组$(val_i,id_i,nxt_i)$</p>
<pre><code>  4011ab:	48 8b 5c 24 20       	mov    0x20(%rsp),%rbx
  4011b0:	48 8d 44 24 28       	lea    0x28(%rsp),%rax
  4011b5:	48 8d 74 24 50       	lea    0x50(%rsp),%rsi
  4011ba:	48 89 d9             	mov    %rbx,%rcx
  4011bd:	48 8b 10             	mov    (%rax),%rdx
  4011c0:	48 89 51 08          	mov    %rdx,0x8(%rcx)
  4011c4:	48 83 c0 08          	add    $0x8,%rax
  4011c8:	48 39 f0             	cmp    %rsi,%rax
  4011cb:	74 05                	je     4011d2 &lt;phase_6+0xde&gt;
  4011cd:	48 89 d1             	mov    %rdx,%rcx
  4011d0:	eb eb                	jmp    4011bd &lt;phase_6+0xc9&gt;
  4011d2:	48 c7 42 08 00 00 00 	movq   $0x0,0x8(%rdx)
</code></pre>
<p>接下来这段汇编代码从重排后的链表第一个节点开始，将$nxt_i$地址指针改为了重排后下一个节点的地址，最后一个节点的$nxt$地址指针被设置为了$0$($NULL$)</p>
<pre><code>  4011d9:	00 
  4011da:	bd 05 00 00 00       	mov    $0x5,%ebp
  4011df:	48 8b 43 08          	mov    0x8(%rbx),%rax
  4011e3:	8b 00                	mov    (%rax),%eax
  4011e5:	39 03                	cmp    %eax,(%rbx)
  4011e7:	7d 05                	jge    4011ee &lt;phase_6+0xfa&gt;
  4011e9:	e8 4c 02 00 00       	call   40143a &lt;explode_bomb&gt;
  4011ee:	48 8b 5b 08          	mov    0x8(%rbx),%rbx
  4011f2:	83 ed 01             	sub    $0x1,%ebp
  4011f5:	75 e8                	jne    4011df &lt;phase_6+0xeb&gt;
  4011f7:	48 83 c4 50          	add    $0x50,%rsp
  4011fb:	5b                   	pop    %rbx
  4011fc:	5d                   	pop    %rbp
  4011fd:	41 5c                	pop    %r12
  4011ff:	41 5d                	pop    %r13
  401201:	41 5e                	pop    %r14
  401203:	c3                   	ret
</code></pre>
<p>此后对于重排后的前5个节点，用$%rbx$指向它，用$%rax$指向它的下一个节点，将它们指向的值进行比较(注意比较时使用的是$%eax$，即使用前32位的$val$值进行比较)，当前一个值小于下一个值的时候发生爆炸，否则整个函数安全退出</p>
<p>所以我们的目标很明确了，只需要使得输入能将链表的$val$值从大到小排序就行了</p>
<p>可以得到链表的顺序按照$id$排序应该为 <code>3 4 5 6 1 2</code>，这就是$b$数组</p>
<p>注意输入的$b_i=7-a_i$，所以输入的$a$数组应该为 <code>4 3 2 1 6 5</code></p>
<h3>结果</h3>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-bomb-image-007.BLcdBq81_1UNMwh.webp" alt="image" /></p>
<p>完结撒花！</p>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="CSAPP"></category>
  </entry>
  <entry>
    <title>cachelab</title>
    <link href="https://katyusha-blog.com/posts/csapp/lab/cachelab/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/csapp/lab/cachelab/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-05-25T00:00:00.000Z</updated>
    <summary>cachelab</summary>
    <content type="html"><![CDATA[<h2>cache lab</h2>
<h3>Part A</h3>
<h4>写在前面</h4>
<p>这部分要求你实现一个模拟缓存运行的C语言程序，支持读，写，修改内存三个操作，其与对于缓存模拟的行为给定的$csim-ref$可执行文件一致，最后输出操作完成后的缓存命中，未命中，替换的次数。缓存的组数，行数，每一行的字节数在执行时以参数给出，替换策略使用$LRU$(least-recently used)，即每次替换块内离上一次引用时间最久的块</p>
<p>但是本部分不要求你输出访问时具体的值，你只需要统计这次操作是否命中，未命中，替换</p>
<p>在运行的时候，我们采用了一下的选项来传递参数</p>
<p><code>linux&gt; ./csim-ref [-hv] -s &lt;s&gt; -E &lt;E&gt; -b &lt;b&gt;-t &lt;tracefile&gt; </code></p>
<p>其中-h -v两个选项可选可不选，且后面没有参数，分别表示打印使用方法，和运行时是否可见化地输出每次缓存访问的结果</p>
<p>-s -E -b 后接一个参数，分别表示组索引位数、每组行数、块偏移位数；因此组数$S=2^s$，块大小$B=2^b$</p>
<p>-t 后接字符串参数，表示读入文件的路径</p>
<h4>读入部分</h4>
<p>怎么感觉是Part A 里面最难的部分（</p>
<p>首先为了从选项中读出参数，我们在linux系统下需要使用$getopt$函数，该函数原型如下</p>
<p><code>int getopt(int argc, char * const argv[], const char *optstring)</code></p>
<p>其中$argc$，$argv$为$main$函数的参数，分别代表参数个数和参数列表</p>
<p>$optstring$为选项字符串，举例来说，<code>getopt(argc, argv, "hvs:E:b:t:")</code>就表示有-h -v -s -E -b -t 这几个选项，其中-s -E -b -t 若选择的话，后面必须带有参数</p>
<p>该函数返回值为选项的ASCII码，同时，包含该函数的 <code>&lt;unistd.h&gt;</code> 和 <code>&lt;getopt.h&gt;</code> 中有名为 $optarg$的指针变量，在每次使用$getopt$时，若该选项有参数，就会被更新为该参数的字符串指针</p>
<p>由此不难写出读入函数</p>
<pre><code>    int op;
    FILE *fp;

    while ((op = getopt(argc, argv, "hvs:E:b:t:")) != EOF) {
        if (op == 'h') {
            Help();
            return 0;
        }
        if (op == 'v') {
            v = 1;
            continue;
        }
        if (op == 's') {
            s = atoi(optarg);//atoi 在 stdlib.h中，传入一个字符串开头的指针，将其转换为整数
            S = (1 &lt;&lt; s);
            continue;
        }
        if (op == 'E') {
            E = atoi(optarg);
            continue;
        }
        if (op == 'b') {
            b = atoi(optarg);
            B = (1 &lt;&lt; b);
            continue;
        }
        if (op == 't') {
            fp = fopen(optarg, "r");//文件指针指向参数标明的文件
            continue;
        }
        Help();
        return 0;//其它异常参数符
    }
</code></pre>
<p>每次操作分为以下四类：</p>
<ol>
<li><code>I address,size</code>表示取address地址开始的size字节指令</li>
<li><code>L address,size</code> 表示加载address地址开始的size字节数据</li>
<li><code>S address,size</code> 表示向address地址开始的size字节写数据</li>
<li><code>M address,size</code> 表示修改address地址开始的size字节数据</li>
</ol>
<p>但是这部分只要我们用缓存处理数据信息，同时又不需要真正地对缓存进行读写，只是模拟是否命中和替换这个过程即可</p>
<p>所以 <code>I</code>操作完全没用， <code>L</code> 和<code>S</code>操作完全等价 （无语</p>
<pre><code>    char opt[5];
    size_t ad;
    int siz;
    while (fscanf(fp, "%s %lx,%d", opt, &amp;ad, &amp;siz) != EOF) {
        ++curtime;
        if (v) {
            printf("%c %lx,%d\n", opt[0], ad, siz);
        }
        if (opt[0] == 'I') continue;
        if (opt[0] == 'L') Load(ad);
        if (opt[0] == 'S') Store(ad);
        if (opt[0] == 'M') Modify(ad);
    }
</code></pre>
<h4>缓存的相关结构</h4>
<p>由于本部分缓存大小未知，需要动态分配内存，这里实现上使用了指针加 <code>malloc</code>动态分配空间</p>
<pre><code>struct row {
    int valid, flag, dfn;
    //有效位 标志位 上次更新的时间戳
};//一行

typedef struct row* set;//一组
typedef set* cache;//整个缓存
cache c;

void Cache_init() {
    c = (cache)malloc(sizeof(set) * S);//分配S个组的空间
    for (int i = 0; i &lt; S; i++) {
        c[i] = (set)malloc(sizeof(struct row) * E);//每一组分配E行的空间
        for (int j = 0; j &lt; E; j++) {
            c[i][j].valid = 0;
            c[i][j].flag = c[i][j].dfn = -1;
        }
    }
}
</code></pre>
<h4>缓存的读写</h4>
<p>我们先对地址使用位运算得到该地址应该被分配到的组数，标识位和偏移量（偏移量好像没用）</p>
<p>为了维护某一组内是否有该地址对应的标志位，我们可以采用平衡树，但是E一般都比较小，没有这个必要（不是我懒得写了），直接E行依遍历比较就行</p>
<p>如果存在标识符相同且有效的，那么发生缓存命中，修改这一行的时间戳，直接返回即可</p>
<p>否则缓存未命中，我们优先找该组空的行，若存在，直接放入并更新时间戳，将其有效位设置为1，此时未命中，也未发生替换</p>
<p>如空的行不存在，我们就将该组中时间戳最小的行替换为需要访问的数据，同样更新时间戳即可，此时未命中并发生替换</p>
<pre><code>void Load(int ad) {
    //m = t + s + b
    // int _b = ad &amp; ((1 &lt;&lt; b) - 1);
    int _s = (ad &gt;&gt; b) &amp; ((1 &lt;&lt; s) - 1);
    int _t = ad &gt;&gt; (s + b);

    struct row* r = c[_s];
    for (int i = 0; i &lt; E; i++) {
        if ((r + i)-&gt;valid &amp;&amp; (r + i)-&gt;flag == _t) {//缓存命中
            if (v) puts("hit");
            ++hit;
            (r + i)-&gt;dfn = curtime;
            return;
        }
    }
    

    struct row* evicp = r;
    for (int i = 1; i &lt; E; i++) {
        if ((r + i)-&gt;dfn == -1 || (r + i)-&gt;dfn &lt; evicp-&gt;dfn) {//优先使用空行
            evicp = r + i;
        }
    }
    
    miss++; 
    printf("miss");
    if (evicp-&gt;dfn != -1) {
        evic++;
        if (v) printf(" eviction");
    }//发生了替换
    putchar('\n');
    evicp-&gt;dfn = curtime; evicp-&gt;valid = 1; evicp -&gt; flag = _t;
    return;
}
</code></pre>
<p>写入和加载的缓存行为在本模拟器中完全一致。修改操作<code>M</code>等价于一次加载后紧跟一次存储：如果第一次访问未命中，则会统计一次未命中并把块载入缓存，随后第二次存储必然命中；如果第一次访问命中，则总共统计两次命中</p>
<pre><code>void Store(int ad) {
    Load(ad);
}

void Modify(int ad) {
    //m = t + s + b
    // int _b = ad &amp; ((1 &lt;&lt; b) - 1);
    int _s = (ad &gt;&gt; b) &amp; ((1 &lt;&lt; s) - 1);
    int _t = ad &gt;&gt; (s + b);
    struct row *r = c[_s]; 
    for (int i = 0; i &lt; E; i++) {
        if ((r + i)-&gt;valid &amp;&amp; (r + i)-&gt;flag == _t) {
            if (v) puts("hit");
            hit += 2;
            (r + i)-&gt;dfn = curtime;
            return;
        }
    }

    struct row* evicp = r;
    for (int i = 1; i &lt; E; i++) {
        if ((r + i)-&gt;dfn == -1 || (r + i)-&gt;dfn &lt; evicp-&gt;dfn) {
            evicp = r + i;
        }
    }
    
    miss++;  hit++;
    if (v) puts("miss");
    if (evicp-&gt;dfn != -1) {
        evic++;
        if (v) printf(" eviction");
    }
    puts(" hit");
    evicp-&gt;dfn = curtime; evicp-&gt;valid = 1; evicp -&gt; flag = _t;
    return;
}
</code></pre>
<p>​</p>
<h4>代码</h4>
<p>主体已经完成了，再加上使用说明即可</p>
<pre><code>#include "cachelab.h"
#include &lt;unistd.h&gt;
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;getopt.h&gt;

int E, s, S, b, B, v, t;
const int m = 64;
int curtime;
int hit, miss, evic;

struct row {
    int valid, flag, dfn;
    //有效位 标志位 上次更新的时间戳
};//一行

typedef struct row* set;//一组
typedef set* cache;//整个缓存
cache c;

void Cache_init() {
    c = (cache)malloc(sizeof(set) * S);//分配S个组的空间
    for (int i = 0; i &lt; S; i++) {
        c[i] = (set)malloc(sizeof(struct row) * E);//每一组分配E行的空间
        for (int j = 0; j &lt; E; j++) {
            c[i][j].valid = 0;
            c[i][j].flag = c[i][j].dfn = -1;
        }
    }
}

/*
Usage: ./csim-ref [-hv] -s &lt;num&gt; -E &lt;num&gt; -b &lt;num&gt; -t &lt;file&gt;
Options:
  -h         Print this help message.
  -v         Optional verbose flag.
  -s &lt;num&gt;   Number of set index bits.
  -E &lt;num&gt;   Number of lines per set.
  -b &lt;num&gt;   Number of block offset bits.
  -t &lt;file&gt;  Trace file.

Examples:
  linux&gt;  ./csim-ref -s 4 -E 1 -b 4 -t traces/yi.trace
  linux&gt;  ./csim-ref -v -s 8 -E 2 -b 4 -t traces/yi.trace
*/

void Help() {
    printf(
"Usage: ./csim-ref [-hv] -s &lt;num&gt; -E &lt;num&gt; -b &lt;num&gt; -t &lt;file&gt;\n"
"Options:\n"
"  -h         Print this help message.\n"
"  -v         Optional verbose flag.\n"
"  -s &lt;num&gt;   Number of set index bits.\n"
"  -E &lt;num&gt;   Number of lines per set.\n"
"  -b &lt;num&gt;   Number of block offset bits.\n"
"  -t &lt;file&gt;  Trace file.\n"

"Examples:\n"
"  linux&gt;  ./csim-ref -s 4 -E 1 -b 4 -t traces/yi.trace\n"
"  linux&gt;  ./csim-ref -v -s 8 -E 2 -b 4 -t traces/yi.trace\n"
    );
}

void Load(int ad) {
    //m = t + s + b
    // int _b = ad &amp; ((1 &lt;&lt; b) - 1);
    int _s = (ad &gt;&gt; b) &amp; ((1 &lt;&lt; s) - 1);
    int _t = ad &gt;&gt; (s + b);

    struct row* r = c[_s];
    for (int i = 0; i &lt; E; i++) {
        if ((r + i)-&gt;valid &amp;&amp; (r + i)-&gt;flag == _t) {//缓存命中
            if (v) puts("hit");
            ++hit;
            (r + i)-&gt;dfn = curtime;
            return;
        }
    }
    

    struct row* evicp = r;
    for (int i = 1; i &lt; E; i++) {
        if ((r + i)-&gt;dfn == -1 || (r + i)-&gt;dfn &lt; evicp-&gt;dfn) {//优先使用空行
            evicp = r + i;
        }
    }
    
    miss++; 
    printf("miss");
    if (evicp-&gt;dfn != -1) {
        evic++;
        if (v) printf(" eviction");
    }//发生了替换
    putchar('\n');
    evicp-&gt;dfn = curtime; evicp-&gt;valid = 1; evicp -&gt; flag = _t;
    return;
}

void Store(int ad) {
    Load(ad);
}

void Modify(int ad) {
    //m = t + s + b
    // int _b = ad &amp; ((1 &lt;&lt; b) - 1);
    int _s = (ad &gt;&gt; b) &amp; ((1 &lt;&lt; s) - 1);
    int _t = ad &gt;&gt; (s + b);
    struct row *r = c[_s]; 
    for (int i = 0; i &lt; E; i++) {
        if ((r + i)-&gt;valid &amp;&amp; (r + i)-&gt;flag == _t) {
            if (v) puts("hit");
            hit += 2;
            (r + i)-&gt;dfn = curtime;
            return;
        }
    }

    struct row* evicp = r;
    for (int i = 1; i &lt; E; i++) {
        if ((r + i)-&gt;dfn == -1 || (r + i)-&gt;dfn &lt; evicp-&gt;dfn) {
            evicp = r + i;
        }
    }
    
    miss++;  hit++;
    if (v) puts("miss");
    if (evicp-&gt;dfn != -1) {
        evic++;
        if (v) printf(" eviction");
    }
    puts(" hit");
    evicp-&gt;dfn = curtime; evicp-&gt;valid = 1; evicp -&gt; flag = _t;
    return;
}


int main(int argc, char *argv[]){
    int op;
    FILE *fp;

    while ((op = getopt(argc, argv, "hvs:E:b:t:")) != EOF) {
        if (op == 'h') {
            Help();
            return 0;
        }
        if (op == 'v') {
            v = 1;
            continue;
        }
        if (op == 's') {
            s = atoi(optarg);//atoi 在 stdlib.h中，传入一个字符串开头的指针，将其转换为整数
            S = (1 &lt;&lt; s);
            continue;
        }
        if (op == 'E') {
            E = atoi(optarg);
            continue;
        }
        if (op == 'b') {
            b = atoi(optarg);
            B = (1 &lt;&lt; b);
            continue;
        }
        if (op == 't') {
            fp = fopen(optarg, "r");//文件指针指向参数标明的文件
            continue;
        }
        Help();
        return 0;//其它异常参数符
    }

    Cache_init();//缓冲初始化，动态分配空间

    char opt[5];
    size_t ad;
    int siz;
    while (fscanf(fp, "%s %lx,%d", opt, &amp;ad, &amp;siz) != EOF) {
        ++curtime;
        if (v) {
            printf("%c %lx,%d\n", opt[0], ad, siz);
        }
        if (opt[0] == 'I') continue;
        if (opt[0] == 'L') Load(ad);
        if (opt[0] == 'S') Store(ad);
        if (opt[0] == 'M') Modify(ad);
    }

    printSummary(hit, miss, evic);
    return 0;
}

</code></pre>
<p>使用 <code>make &amp;&amp; ./test-csim</code>得到以下结果</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-cachelab-image-001.CwwGA_-7_1DQLjO.webp" alt="image" /></p>
<h3>Part B</h3>
<h4>写在前面</h4>
<p>有$32 \times 32$， $64 \times 64$， $61 \times 67$ 三个矩阵$A$，你需要使用C实现矩阵转置得到矩阵$B$，要求使得参数为$(s=5, E=1, b=5)$的缓存产生的不命中次数小于一个给定值，不能自己定义数组，最多使用12个局部变量</p>
<p>手算发现，该缓存有32个组，每组一行，块大小为32字节，即每块能存储8个<code>int</code>。方便起见，以下把一个缓存块称为一行，同时使用0-index</p>
<p>通过访问目录下的$trace.f1$(题目给出的不加优化的转置代码的缓存行为跟踪)发现，$A$的起始地址为$0x0010d080$，$B$的起始地址为$0x0014d080$，刚好相差$2^{18}$，这说明在下标相同时，$A[i][j]$和$B[i][j]$会被分配到相同的一行内，只是标志位不同；同时二者的起始地址都是32的倍数，说明$A[i][8k+0]$到$A[i][8k+7]$都在一行里面，$B$也同理</p>
<p>题目给出的最原始的转置函数如下</p>
<pre><code>void trans(int M, int N, int A[N][M], int B[M][N])
{
    int i, j, tmp;

    for (i = 0; i &lt; N; i++) {
        for (j = 0; j &lt; M; j++) {
            tmp = A[i][j];
            B[j][i] = tmp;
        }
    }    
}
</code></pre>
<p>注意先通过 <code>sudo apt install valgrind </code> 安装 valgrind</p>
<h4>$ 32 \times 32 $</h4>
<p>本部分要求缓存未命中次数小于300次</p>
<p>先在目录下使用<code>make &amp;&amp; ./test-trans -M 32 -N 32</code>对原始函数缓存访问效率进行测试，结果如下</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-cachelab-image-002.CLc6ntTe_1NYv5s.webp" alt="image" /></p>
<p>发现该函数对于缓存访问相当不友好，我们先搞明白1183次不命中是怎么来的</p>
<p>每隔8个int就会产生一个行的偏移，先画出每个int会被放入缓存的哪一行（不会画图，图是偷的，勿喷）</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-cachelab-image-003.DuiGxCIU_Z1kcszm.webp" alt="image" /></p>
<p>1.对于$A$是步长为1的访问，只有在第一次访问一个没有被访问过的组的时候才会不命中，所以会有128次不命中</p>
<p>2.对于$B$是步长为32的访问，每一次都不会命中，所以有1024次不命中</p>
<p>3.对于 $i=j$ 即对角线上的情况，此时$B[i][i]$和$A[i][i]$所在的组一样，写入$B[i][i]$的值的时候，刚好会将$A$所在行替换掉，在下一次读取$A$的值的时候，会将$B$替换掉，这样的情况会发生31次，因为最后一次访问$(31,31)$后不会再读取$A$了</p>
<p>所以一共为128+1024+31=1183次</p>
<p>接下来考虑如何优化，可以使用分块，取块长为8，即每次将一个$8\times8$的$A$矩阵写入其在$B$上对应的位置</p>
<p>这样优化后，$A$在块内以步长为1访问，而$B$在块内按列顺序访问，每一块会在每一列第一次访问的时候不命中</p>
<p>优化后代码如下</p>
<pre><code>    if (M == 32 &amp;&amp; N == 32) {
        for (i = 0; i &lt; N; i += 8) {
            for (j = 0; j &lt; M; j += 8) {
                for (ii = i; ii &lt; i + 8; ii++) {
                    for (jj = j; jj &lt; j + 8; jj++) {
                        B[jj][ii] = A[ii][jj];
                    }
                }
            }
        }
    }
</code></pre>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-cachelab-image-004.BMc0bebW_ZHexRo.webp" alt="image" /></p>
<p>仍然需要进一步的优化，考虑对角线上的块，在转置的时候会发生对$B$进行写入时，替换掉了这一行的$A$，所以可以使用临时变量保存$A$的值，然后放入$B$中，这样减少了访问$A$这一行后面的元素所需要的一次不命中</p>
<pre><code>    if (M == 32 &amp;&amp; N == 32) {
        for (i = 0; i &lt; N; i += 8) {
            for (j = 0; j &lt; M; j += 8) {
                for (ii = i; ii &lt; i + 8; ii++) {
                    a = A[ii][j];
                    b = A[ii][j + 1];
                    c = A[ii][j + 2];
                    d = A[ii][j + 3];
                    e = A[ii][j + 4];
                    f = A[ii][j + 5];
                    g = A[ii][j + 6];
                    h = A[ii][j + 7];
                    B[j ][ii] = a;
                    B[j + 1][ii] = b;
                    B[j + 2][ii] = c;
                    B[j + 3][ii] = d;
                    B[j + 4][ii] = e;
                    B[j + 5][ii] = f;
                    B[j + 6][ii] = g;
                    B[j + 7][ii] = h;
                }
            }
        }
    }
</code></pre>
<p>结果如下，成功通过此题</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-cachelab-image-005.CZ7bdgpQ_188rJz.webp" alt="image" /></p>
<h4>$64\times64$</h4>
<p>本部分要求缓存未命中次数小于1300</p>
<p>再次偷图（ 这是左上角$16 \times 16$的矩阵</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-cachelab-image-006.D9nqivyI_1dQsIw.webp" alt="image" /></p>
<p>如果我们仍然采用$8\times8$分块处理的话，块内会存在严重的抖动，比如$B$内部对组0，组8，组16，组24都有两次访问，都会产生一次替换，这导致缓存命中率相当糟糕，实际测试中不命中次数为4611，与未优化的原始代码次数4723几乎没有差别</p>
<p>为了减少$B$的抖动，我们尝试使用$4\times4$分块</p>
<pre><code>    if (M == 64 &amp;&amp; N == 64) {
        for (i = 0; i &lt; N; i += 4) {
            for (j = 0; j &lt; M; j += 4) {
                for (ii = i; ii &lt; i + 4; ii++) {                                     
                    a = A[ii][j];
                    b = A[ii][j + 1];
                    c = A[ii][j + 2];
                    d = A[ii][j + 3];
                    B[j ][ii] = a;
                    B[j + 1][ii] = b;
                    B[j + 2][ii] = c;
                    B[j + 3][ii] = d;
                }
            }
        }    
    }
</code></pre>
<p>结果如下</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-cachelab-image-007.C0qOwmHO_39hwX.webp" alt="image" /></p>
<p>有所优化但是还未达到本题要求，采用$4\times4$分块的时候，虽然$B$的抖动减少了，但是对$A$中原本连续8个数的访问变成了两次对4个数的访问，这增加了$A$的抖动</p>
<p>到这里就不会了，去看别人博客了（</p>
<p>我们考虑以下的策略</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-cachelab-image-008.u96UH9Yg_1BrByd.webp" alt="image" /></p>
<p>1.先读取$A$矩阵$8\times8$分块的前4行，将黄色部分和绿色部分分别转置后，直接顺序不变拷贝到$B$中，此时未发生缓存替换，只有$A$和$B$的各4次载入</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-cachelab-image-009.CG34dg0J_Z1dCrG1.webp" alt="image" /></p>
<p>2.使用寄存器暂存未放置正确的绿色部分的一行，然后将粉色部分的一列放置到绿色部分的这一行，接着从寄存器中取值将绿色部分这一行放到正确的位置。绿色部分已经存放在了缓存中，可以直接读取，粉色第一列的时候会发生4次载入，热身缓存，正确放置绿色部分每一行的时候都会产生一次对原本存放$A$的组的替换，故一共发生8次缓存不命中</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-cachelab-image-010.CvCnYTR6_2w9Pmn.webp" alt="image" /></p>
<p>3.最后将紫色部分直接转置到正确位置即可，紫色部分每一行和放置的$B$每一列都在缓存中，全部命中</p>
<p>所以对于$8\times8$的块，发生了16次不命中，对$64 \times 64$的块，理论会有1024次不命中</p>
<pre><code>    if (M == 64 &amp;&amp; N == 64) {
        int i, j, ii;
        int a, b, c, d, e, f, g, h;

        for (i = 0; i &lt; N; i += 8) {
            for (j = 0; j &lt; M; j += 8) {
                for (ii = i; ii &lt; i + 4; ii++) {
                    a = A[ii][j];
                    b = A[ii][j + 1];
                    c = A[ii][j + 2];
                    d = A[ii][j + 3];

                    e = A[ii][j + 4];
                    f = A[ii][j + 5];
                    g = A[ii][j + 6];
                    h = A[ii][j + 7];

                    B[j][ii] = a;
                    B[j + 1][ii] = b;
                    B[j + 2][ii] = c;
                    B[j + 3][ii] = d;
                    
                    B[j][ii + 4] = e;
                    B[j + 1][ii + 4] = f;
                    B[j + 2][ii + 4] = g;
                    B[j + 3][ii + 4] = h;
                }

                for (ii = 0; ii &lt; 4; ii++) {
                    a = B[j + ii][i + 4]; 
                    b = B[j + ii][i + 5];
                    c = B[j + ii][i + 6];
                    d = B[j + ii][i + 7];

                    e = A[i + 4][j + ii];
                    f = A[i + 5][j + ii];
                    g = A[i + 6][j + ii];
                    h = A[i + 7][j + ii];

                    B[j + ii][i + 4] = e;
                    B[j + ii][i + 5] = f;
                    B[j + ii][i + 6] = g;
                    B[j + ii][i + 7] = h;

                    B[j + 4 + ii][i] = a;
                    B[j + 4 + ii][i + 1] = b;
                    B[j + 4 + ii][i + 2] = c;
                    B[j + 4 + ii][i + 3] = d;
                }


                for (ii = i + 4; ii &lt; i + 8; ii++) {
                    a = A[ii][j + 4];
                    b = A[ii][j + 5];
                    c = A[ii][j + 6];
                    d = A[ii][j + 7];

                    B[j + 4][ii] = a;
                    B[j + 5][ii] = b;
                    B[j + 6][ii] = c;
                    B[j + 7][ii] = d;
                }
            }
        }    
        return;
    }
</code></pre>
<p>运行结果如下，达到了本题的要求</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-cachelab-image-011.BrzeD4VB_Z2hqg63.webp" alt="image" /></p>
<h4>$67 \times 61$</h4>
<p>本题要求缓存不命中次数小于2000</p>
<p>我们调整参数，发现使用$16 \times 16$分块可以通过此题</p>
<pre><code>    if (M == 67 &amp;&amp; N == 61) {
        int i, j, ii,jj;
        for (i = 0; i &lt; 61; i += 16) {
            for (j = 0; j &lt; 67; j += 16) {
                for (ii = i; ii &lt; i + 16 &amp;&amp; ii &lt; 61; ii++) {
                    for (jj = j; jj &lt; j + 16 &amp;&amp; jj &lt; 67; jj++) {
                        B[jj][ii] = A[ii][jj];
                    }
                }
            }
        }
    }
</code></pre>
<p>结果如下</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-cachelab-image-012.Cz5Uk9fW_jNMxd.webp" alt="image" /></p>
<h4>代码</h4>
<pre><code>/* 
 * trans.c - Matrix transpose B = A^T
 *
 * Each transpose function must have a prototype of the form:
 * void trans(int M, int N, int A[N][M], int B[M][N]);
 *
 * A transpose function is evaluated by counting the number of misses
 * on a 1KB direct mapped cache with a block size of 32 bytes.
 */ 
#include &lt;stdio.h&gt;
#include "cachelab.h"

int is_transpose(int M, int N, int A[N][M], int B[M][N]);

/* 
 * transpose_submit - This is the solution transpose function that you
 *     will be graded on for Part B of the assignment. Do not change
 *     the description string "Transpose submission", as the driver
 *     searches for that string to identify the transpose function to
 *     be graded. 
 */
char transpose_submit_desc[] = "Transpose submission";
void transpose_submit(int M, int N, int A[N][M], int B[M][N])
{

    if (M == 32 &amp;&amp; N == 32) {
        int i, j, ii;
        int a, b, c, d, e, f, g, h;
        for (i = 0; i &lt; N; i += 8) {
            for (j = 0; j &lt; M; j += 8) {
                for (ii = i; ii &lt; i + 8; ii++) {
                    a = A[ii][j];
                    b = A[ii][j + 1];
                    c = A[ii][j + 2];
                    d = A[ii][j + 3];
                    e = A[ii][j + 4];
                    f = A[ii][j + 5];
                    g = A[ii][j + 6];
                    h = A[ii][j + 7];
                    B[j ][ii] = a;
                    B[j + 1][ii] = b;
                    B[j + 2][ii] = c;
                    B[j + 3][ii] = d;
                    B[j + 4][ii] = e;
                    B[j + 5][ii] = f;
                    B[j + 6][ii] = g;
                    B[j + 7][ii] = h;
                }
            }
        }
        return;
    }

    if (M == 64 &amp;&amp; N == 64) {
        int i, j, ii;
        int a, b, c, d, e, f, g, h;

        for (i = 0; i &lt; N; i += 8) {
            for (j = 0; j &lt; M; j += 8) {
                for (ii = i; ii &lt; i + 4; ii++) {
                    a = A[ii][j];
                    b = A[ii][j + 1];
                    c = A[ii][j + 2];
                    d = A[ii][j + 3];

                    e = A[ii][j + 4];
                    f = A[ii][j + 5];
                    g = A[ii][j + 6];
                    h = A[ii][j + 7];

                    B[j][ii] = a;
                    B[j + 1][ii] = b;
                    B[j + 2][ii] = c;
                    B[j + 3][ii] = d;
                    
                    B[j][ii + 4] = e;
                    B[j + 1][ii + 4] = f;
                    B[j + 2][ii + 4] = g;
                    B[j + 3][ii + 4] = h;
                }

                for (ii = 0; ii &lt; 4; ii++) {
                    a = B[j + ii][i + 4]; 
                    b = B[j + ii][i + 5];
                    c = B[j + ii][i + 6];
                    d = B[j + ii][i + 7];

                    e = A[i + 4][j + ii];
                    f = A[i + 5][j + ii];
                    g = A[i + 6][j + ii];
                    h = A[i + 7][j + ii];

                    B[j + ii][i + 4] = e;
                    B[j + ii][i + 5] = f;
                    B[j + ii][i + 6] = g;
                    B[j + ii][i + 7] = h;

                    B[j + 4 + ii][i] = a;
                    B[j + 4 + ii][i + 1] = b;
                    B[j + 4 + ii][i + 2] = c;
                    B[j + 4 + ii][i + 3] = d;
                }


                for (ii = i + 4; ii &lt; i + 8; ii++) {
                    a = A[ii][j + 4];
                    b = A[ii][j + 5];
                    c = A[ii][j + 6];
                    d = A[ii][j + 7];

                    B[j + 4][ii] = a;
                    B[j + 5][ii] = b;
                    B[j + 6][ii] = c;
                    B[j + 7][ii] = d;
                }
            }
        }    
        return;
    }

    if (M == 61 &amp;&amp; N == 67) {
        int i, j, ii,jj, tmp;
        for (i = 0; i &lt; N; i += 16) {
            for (j = 0; j &lt; M; j += 16) {
                for (ii = i; ii &lt; i + 16 &amp;&amp; ii &lt; N; ii++) {
                    for (jj = j; jj &lt; j + 16 &amp;&amp; jj &lt; M; jj++) {
                        tmp = A[ii][jj];
                        B[jj][ii] = tmp;
                    }
                }
            }
        }
    }
}

/* 
 * You can define additional transpose functions below. We've defined
 * a simple one below to help you get started. 
 */ 

/* 
 * trans - A simple baseline transpose function, not optimized for the cache.
 */
char trans_desc[] = "Simple row-wise scan transpose";
void trans(int M, int N, int A[N][M], int B[M][N])
{
    int i, j, tmp;

    for (i = 0; i &lt; N; i++) {
        for (j = 0; j &lt; M; j++) {
            tmp = A[i][j];
            B[j][i] = tmp;
        }
    }    
}

/*
 * registerFunctions - This function registers your transpose
 *     functions with the driver.  At runtime, the driver will
 *     evaluate each of the registered functions and summarize their
 *     performance. This is a handy way to experiment with different
 *     transpose strategies.
 */
void registerFunctions()
{
    /* Register your solution function */
    registerTransFunction(transpose_submit, transpose_submit_desc); 

    /* Register any additional transpose functions */
    registerTransFunction(trans, trans_desc); 

}

/* 
 * is_transpose - This helper function checks if B is the transpose of
 *     A. You can check the correctness of your transpose by calling
 *     it before returning from the transpose function.
 */
int is_transpose(int M, int N, int A[N][M], int B[M][N])
{
    int i, j;

    for (i = 0; i &lt; N; i++) {
        for (j = 0; j &lt; M; ++j) {
            if (A[i][j] != B[j][i]) {
                return 0;
            }
        }
    }
    return 1;
}

</code></pre>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="CSAPP"></category>
  </entry>
  <entry>
    <title>architecture</title>
    <link href="https://katyusha-blog.com/posts/csapp/notes/architecture/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/csapp/notes/architecture/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-05-25T00:00:00.000Z</updated>
    <summary>architecture</summary>
    <content type="html"><![CDATA[<h2>处理器体系结构</h2>
<h3>CISC 与 RISC 指令集</h3>
<p>CISC：复杂指令计算机 (如x86-64)
RISC：精简指令计算机 (如RISC-V)</p>
<p>RISC 相较于CISC通常指令数量更少，编码更规整，寻址模式更简单，对机器级程序实现细节可见......
RISC指令集设计简约，便于流水线优化；CISC指令集表达能力强，常能用更少指令完成同样任务，两者各有优势
比较新的CISC处理器也采用了流水线和微操作等结构，外部以CISC作为接口，内部实现会把复杂指令拆成更简单的操作；RISC在嵌入式领域也有广泛应用</p>
<h3>Y86-64</h3>
<p>本章使用了一种简化的$x86-64$指令集，同时结合了部分$RISC$的特点，称为$Y86-64$指令集</p>
<h4>程序可见状态</h4>
<p>程序可见状态对使用汇编语言的程序员和产生机器级代码的编译器都可见
$Y86-64$中，程序可见状态包括15个寄存器，条件码，PC，虚拟内存，以及状态码</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-001.DBxiIIU0_Z2uabDE.webp" alt="image" /></p>
<h4>可用指令</h4>
<p>$Y86-64$只包含8字节整数操作，指令格式与$x86-64$ AT&amp;T格式类似
<code>mov</code> 指令需要两个前缀，为以下之一：<code>i</code>立即数，<code>r</code>寄存器，<code>m</code>内存 这两个前缀分别为源和目的
操作指令<code>addq subq andq xorq</code>只能对寄存器进行操作，无法操作内存
跳转指令和条件传送指令与$x86-64$保持一致
<code>call ret pushq popq</code>与$x86-64$保持一致
<code>halt</code>指令停止处理器的运行，并将状态码设置为<code>HLT</code></p>
<h4>指令的编码</h4>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-002.XzNuaStg_ZeelnJ.webp" alt="image" /></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-003.BVVS-nxk_Z1RX9Gr.webp" alt="image" /></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-004.DvdXk97p_Z1g7vQF.webp" alt="image" /></p>
<p>我们以指令 <code>rmmov %rsp,0x123456789abcd(%rdx)</code>为例，<code>rmmov</code>的指令编码为40，<code>%rsp %rdx</code>的编码为42，最后的偏移量字节序列为$000123456789abcd$，注意是小端法，按照字节间反序得到偏移量编码为$cdab896745230100$,进而得到整条指令编码为$4042cdab896745230100$</p>
<h4>Y86-64指令异常</h4>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-005.CmbiUA0S_1awoER.webp" alt="image" /></p>
<h4>Y86-64程序</h4>
<p>伪指令：以<code>.</code>开头的指令为汇编器伪指令，使得汇编器调整地址，将汇编代码或者数据存放在指定的地址</p>
<p>实例：</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-006.DxP1cQ1Z_Z1puKfS.webp" alt="image" /></p>
<p>补充：在x86-64和Y86-64语义下，<code>pushq %rsp</code>压入的是执行压栈前的栈指针旧值；<code>popq %rsp</code>会把旧栈顶处弹出的值写入<code>%rsp</code></p>
<h3>硬件控制语言HCL</h3>
<p>HCL只表达硬件设计的控制部分，不关心硬件的具体实现而是考虑怎样将处理器中的各个部分联系起来，是HDL的一个真子集
<a href="/posts/CSAPP/notes/3.architecture/#%E7%A1%AC%E4%BB%B6%E6%8E%A7%E5%88%B6%E8%AF%AD%E8%A8%80hcl">HDL 和 HCL 的对比可以参考本节</a>，以下部分会很快带过</p>
<h4>逻辑门</h4>
<p>逻辑门是活动的，输入变化后输出几乎是立即变化</p>
<h4>组合电路</h4>
<p>组合电路需要满足以下条件</p>
<ol>
<li>逻辑门的输入必须连接到主输入，存储器单元输入，另一个逻辑门的输出三者之一</li>
<li>两个或多个逻辑门输出不能连接在一起</li>
<li>构成的网无环
组合电路的输出值会随着输入值变化，而C中的只有在遇到赋值的时候才会变化
MUX函数</li>
</ol>
<h4>字级的组合电路</h4>
<p>由多个位级的组合门得到字级的抽象
多路MUX的HCL语法</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-007.BEvuxNlg_1Ndq7h.webp" alt="image" /></p>
<p>注意：不要求$select_i$之间互斥，即返回的结果是第一个满足$select_i$后面的值</p>
<p>ALU</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-008.CT54XTBN_Wd9Cx.webp" alt="image" /></p>
<p>集合关系</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-009.D2Pq2k_O_1jEj1s.webp" alt="image" /></p>
<h4>时序逻辑</h4>
<p>组合逻辑不存储任何信息，只是对输入信号输出其对应的函数值</p>
<p>而时序电路受到时钟控制，通常在时钟上升沿更新存储器的值
存储器都采用时序逻辑，更具体来说，分为以下两种存储器</p>
<ol>
<li>随机访问存储器：虚拟内存系统和一组寄存器，存储多个字，采用地址或寄存器标识符进行访问</li>
<li>时钟寄存器：程序计数器，条件码寄存器，状态寄存器，存储单个位或字</li>
</ol>
<h4>存储器的读写</h4>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-010.CzLy1t9I_Z13MvLT.webp" alt="image" /></p>
<p>可以通过修改$srcA$或$srcB$为需要读取的寄存器编号，寄存器文件会组合逻辑地从$valA$,$valB$输出相应寄存器的值；写入则在时钟上升沿根据$dest$完成</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-011.luhnuwHe_dmxGp.webp" alt="image" /></p>
<p>内存的读写也类似：读操作根据地址产生输出值，写操作则在受时钟控制的写入时刻把输入数据写入相应地址</p>
<h3>指令集的顺序实现</h3>
<p>以下以$Y86-64$作为一个简化的模型进行讨论，不考虑流水线化等优化</p>
<h4>处理指令的各个阶段</h4>
<p><strong>1. 取指</strong>：根据指令的确定指令的操作与功能，并得到操作需要用到的寄存器以及常数(如果该指令需要用到这些)，同时计算没有跳转的情况下下一条指令$PC$应该指向的地址$valP$(此时的PC加上当前指令的字节长度)
<strong>2.译码</strong>：通过访问寄存器得到相应的值(如果该指令需要用)
<strong>3.执行</strong>：对于计算操作，ALU计算相应的值，并且设置条件码；对于传送指令，计算传送的地址；对于跳转操作，检查条件码是否成立，得到布尔值$Cnd$
<strong>4.访存</strong>：从内存中读出数据或者向内存写入数据
<strong>5.写回</strong>：向寄存器写入数据
<strong>6.更新PC</strong>：若$Cnd$成立，则将PC设置为跳转的地址，否则设置为$valP$</p>
<h4>Y86-64指令执行的阶段</h4>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-012.DDm8MQzk_2hBmwq.webp" alt="image" /></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-013.CRm-bvbA_woorS.webp" alt="image" /></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-014.DQ8iMgk6_Z2eA1mG.webp" alt="image" /></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-015.B8c2L_sI_TdDCq.webp" alt="image" /></p>
<h3>SEQ 顺序处理器</h3>
<p>引入了一种叫做$SEQ$的顺序执行的处理器，每条指令在一个时钟周期内完成</p>
<h4>SEQ的硬件结构</h4>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-016.ClnoSqP4_24h6XQ.webp" alt="image" /></p>
<p>注意：处理器遵循“用不回读”原则，在同一个时钟周期内即不会修改一个存储器后再读取这个存储器的值</p>
<h4>SEQ的HCL实现</h4>
<p>以下是控制逻辑中必须显示使用的常量在HCL中的定义</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-017.COtXeuOb_Z2q4tWX.webp" alt="image" /></p>
<h5>取指阶段</h5>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-018.D9d3uJTw_ZREwsP.webp" alt="image" /></p>
<p>通过指令第一个字节中包含的指令功能，判断得到是否需要读入寄存器和常数($Need_regids$和$Need_valC$)
当该指令不合法时(当前指令地址不合法或不存在功能码对应的指令)，会将当前指令设置为$nop$所对应的代码
然后根据$Need_regids$，若该值为真，将$rA$,$rB$设置为第二个字节的两个四位二进制数，否则设置为$0xF$(空寄存器)
若$Need_valC$为真，当$Need_redigs$为真的时候在第3到10个字节取得常数，为假的时候在第2到9个字节取得常数
最后，将$valP$(下一个指令起始地址)设置为$PC+1+Need_regids+8*Need_valC$</p>
<p>例：</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-019.BFcXuW2j_1ActG8.webp" alt="image" /></p>
<h5>访问寄存器</h5>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-020.C7sm5n_z_1WReT.webp" alt="image" /></p>
<p>译码，写回两个阶段本质都是对寄存器的访问
通过$srcA$,$srcB$访问相应的寄存器，结果从$valA$,$valB$读出
通过$dstE$,$dstM$进行写入同理
当某个地址访问端口上的值为$0xF$时，则表示不需要读取/写入</p>
<p>例：</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-021.Cctc0pJ8_Z15r8ln.webp" alt="image" /></p>
<h5>执行阶段</h5>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-022.Bx42OVsF_Z1uIv2d.webp" alt="image" /></p>
<p>ALU接收三个参数$aluB$,$aluA$,$alufun$，得到结果$valE$并设置条件码
注意当运算为减法时不符合交换律，需要保证得到的结果为$aluB-aluA$，故需要将$aluB$放在前面</p>
<p>例：</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-023.wynPRRrL_FHRng.webp" alt="image" /></p>
<p>条件码会和指令的功能一同传入黑箱$cond$中，黑箱传出信号决定$Cnd$用于条件跳转和条件传送</p>
<h5>访问内存</h5>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-024.FQWiFy_M_UD6x7.webp" alt="image" /></p>
<p>通过$Mem.read$和$Mem.write$控制是写还是读，$Mem.addr$控制读写的地址，$Mem.data$控制写入值，$valM$得到读取值</p>
<p>例：</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-025.D0gwY3yf_5iDHw.webp" alt="image" /></p>
<h5>更新PC状态</h5>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-026.B0Sdlj1g_79lXY.webp" alt="image" /></p>
<p>PC可能的新值为顺序下一条指令的地址，使用函数或结束函数跳转到的地址，条件跳转的地址
从这几个地址中选择即可</p>
<h3>无反馈的流水线系统</h3>
<p>在SEQ中，我们必须等待上一条指令按照阶段顺序依次完成，一个时钟周期结束后才能开始处理下一条指令，每个单元只在时钟周期的某一段时间内被使用。
通过流水线化，系统的整体延迟虽然增加，但是吞吐量增大。</p>
<h4>流水线基本原理</h4>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-027.5UNj-Yq7_28RnOO.webp" alt="image" /></p>
<p>注：$1ps=10^{-12}s$</p>
<p>延迟：一条指令从头到尾运行完毕需要的时间，这个例子中为$20ps+300ps=320ps$
吞吐量：单位时间内能完成的指令数，即 $\frac{\text{完成指令数}}{\text{执行时间}}$，这个例子中为 $\frac{1}{320ps}=3.12GIPS$（GIPS: 每秒十亿条指令）</p>
<p>流水化后：</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-028.oZ4qRGln_Z27xyiS.webp" alt="image" /></p>
<p>将组合逻辑拆解为几个子阶段，并引入寄存器保存每个子阶段的结果
为了保证电位上升时，所有子阶段的结果都已经计算完毕，能够写入寄存器中，时钟周期需要取子阶段耗时的最大值
每过一个时钟周期，相关数据从寄存器中读取并进入到下一个子阶段，同时在第一阶段加入新的指令，在最后一阶段结束流水线中最早的一条指令
流水化后，因为引入了寄存器，增大了时钟周期，使得执行一条指令的延迟增加了；但是由于能够同时执行多条指令，吞吐量增加了
以该图为例子，时钟周期变为$\max{100+20,100+20,100+20}=120ps$，延迟为$120ps\times3=360ps$，吞吐量为$\frac{1}{\text{时钟周期}}=\frac{1}{120ps}=8.33GIPS$</p>
<h4>流水线加速的限制因素</h4>
<ol>
<li>子阶段划分不一致：时钟周期由最慢的子阶段延迟决定，而部分硬件单元，如$ALU$和内存无法划分成更小的部分</li>
<li>流水线过深：流水线子阶段划分过多，此时寄存器开销是决定性因素，吞吐量提升很小，总延迟反而增加比较多，同时也增大了预测错误，数据冒险的惩罚</li>
</ol>
<h4>五级流水线的实现</h4>
<p>以下简称流水线的五个阶段分别为F(Fetch)，D(Decode)，E(Execute)，M(Memory)，W(Write)</p>
<h5>PC计算阶段提前进行</h5>
<p>PC计算阶段并入取指阶段中，影响PC更新的寄存器来自前一个周期产生的控制信号，使得能够立即得到下一条指令的地址，以便其加入流水线</p>
<h5>插入流水线寄存器</h5>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-029.DYKl8DGk_Z1Oj5c5.webp" alt="image" /></p>
<p>通过插入流水线寄存器保存流水线各阶段之间的信息，使得其得以自底向上流动</p>
<h5>PC的预测</h5>
<p>当目前取指的指令为条件跳转或<code>ret</code>时，必须在访存\执行阶段才能得到跳转指令的地址，处理器通过预测下一个PC值保证流水线尽量被填满
对于条件跳转操作，处理器使用分支预测，例如按照总是选择或从不选择来填充流水线，一种简单而比较有效的预测逻辑是“反向选择，正向不选择”，即向更低地址的跳转预测为真，向更高的预测为假，因为循环的汇编表示都是条件成立时向更小的地址跳转，而循环一般会执行多次，预测只有最后一次才会出错
对于<code>ret</code>操作，简化的Y86-64流水线通常需要暂停取指，直到返回地址从栈中读出；真实处理器常用返回地址栈预测<code>ret</code>目标，预测通常相当准确，除非返回地址栈失配或溢出</p>
<h3>带反馈的流水线系统</h3>
<h4>流水线冒险</h4>
<p>数据冒险：此后的指令会用到当前指令相关的数据，而这个数据还未写入内存/寄存器中导致后续使用了错误的数据
控制冒险：执行跳转，引用，返回等指令时，PC预测可能出现错误</p>
<h4>避免数据冒险</h4>
<h5>暂停</h5>
<p>当下一步继续进行会产生数据冒险时，处理器会动态加入气泡(作用类似<code>nop</code>指令)，来将后续受影响的指令停止在取指阶段，直到最深的一条指令通过写回阶段再继续进行</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-030.Cglp6WF2_Z6Ilx1.webp" alt="image" /></p>
<h5>转发</h5>
<p>在基本的硬件结构中增加一些额外的数据连接和控制逻辑，使得更深的指令得以在数据已经计算完毕但还未来得及写入的情况下，直接将该数据写入受影响的指令的流水线寄存器中，避免了指令的停止</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-031.ByBT-cTL_Z9RHAd.webp" alt="image" /></p>
<h5>转发与暂停的结合</h5>
<p>当更深指令尚未得到与后面受影响的指令相关的数据时(如读取内存的值写入寄存器，直到M阶段才能得到该数据)，处理器会动态产生气泡，直到得到该数据，能够进行转发</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-032.CKNFXjbt_Zuj4zW.webp" alt="image" /></p>
<h4>避免控制冒险</h4>
<p>当跳转指令已经执行完F和D阶段，在E阶段发现预测出错，此时后面两条指令分别在D和F阶段，未对任何程序员可见状态进行修改，只需要在下一个时钟周期时向D和E阶段插入气泡，同时取出正确跳转地址的指令，就能从流水线中排除这两条错误指令</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-architecture-architecture-image-033.DIKXTztM_Z1xJ46H.webp" alt="image" /></p>
<h4>异常处理</h4>
<p>可能存在以下三种内部异常情况：<code>halt</code>指令，指令异常，地址异常。在产生异常时，最流水线深的一条指令(对应源汇编代码中靠前的指令)的异常会被报告到状态码中，在异常状态下，寄存器和内存被禁止更新，导致异常的指令继续沿着流水线传播，直到写回阶段，流水线控制逻辑发现异常并停止执行</p>
<h3>流水线的HCL实现</h3>
<p>咕咕咕</p>
<h3>流水线的性能分析</h3>
<p>我们用$CPI$即执行每条指令所需时钟周期衡量流水线的性能
当执行的指令足够多时，我们可以忽略启动指令经过流水线的周期，此时执行的指令数$C_i$与气泡数$C_b$之和近似等于消耗的时钟周期
所以有$CPI=\frac{C_i+C_b}{C_i}=1.0+\frac{C_b}{C_i}$，其中$\frac{C_b}{C_i}$是平均每条指令插入的气泡数，受到加载处罚，预测错误分支处罚，返回处罚影响，其中预测错误分支处罚为主要因素
比较新的处理器支持超标量操作和乱序执行技术，可以使得$CPI$小于1.0</p>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="CSAPP"></category>
  </entry>
  <entry>
    <title>assembly</title>
    <link href="https://katyusha-blog.com/posts/csapp/notes/assembly/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/csapp/notes/assembly/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-05-25T00:00:00.000Z</updated>
    <summary>assembly</summary>
    <content type="html"><![CDATA[<h2>程序的机器级表示</h2>
<h3>编译过程</h3>
<p>以GCC编译C语言为例
linux&gt; <code>gcc -Og -o hello hello.c</code>
(-Og 优化等级低，避免机器代码严重变形，便于进行调试)</p>
<ol>
<li>预处理：将预处理指令进行替换 (头文件包含，宏展开，条件编译<code>#if #ifdef #endif...</code>)，删除注释，得到处理后的<code>.i</code>C代码</li>
<li>编译：将<code>.i</code>文件进行编译得到<code>.s</code>汇编语言文件</li>
<li>汇编：将<code>.s</code>翻译成机器指令，得到二进制文件 (windows下<code>.obj</code> linux下<code>.o</code>)</li>
<li>链接：将<code>.o</code>文件和所需要的库文件组合在一起(头文件的函数在预处理中只声明，链接定位函数的具体实现)，生成可执行文件</li>
</ol>
<p>linux&gt; <code>gcc -Og -S hello.c</code> 产生一个汇编文件 <code>hello.s</code>
linux&gt; <code>gcc -Og -c hello.c</code> 产生二进制文件<code>hello.o</code>
linux&gt; <code>objdump -d hello.o</code> 实现反汇编</p>
<h3>机器级代码概述</h3>
<p><em>指令集架构(ISA Instruction Set Architecture)</em> 定义机器级代码的格式和行为，虽然指令在ISA层面被描述为顺序执行，但现代处理器采用流水线、乱序执行等技术实现并发执行，最终结果与顺序执行一致
x86系列：<code>8086</code>(16位架构) $\rightarrow$ <code>IA-32</code> (32位架构) $\rightarrow$ <code>x86-64</code>(64位架构)
机器级程序看到的是字节级虚拟地址空间，操作系统和硬件负责将虚拟地址翻译成对应的物理地址
一条机器代码一般只执行非常基本的操作，如寄存器中两个数相加，存储器与寄存器之间传送数据，条件分支转移到新的指令地址等</p>
<p>汇编代码格式：<code>AT&amp;T</code>与<code>Intel</code></p>
<ol>
<li>Intel 代码省略了指示大小的后缀:<code>movq</code> $\rightarrow$ <code>mov</code></li>
<li>Intel 代码省略寄存器前面的"%":<code>%rax</code> $\rightarrow$ <code>rax</code></li>
<li>Intel 代码描述内存的方式不同:<code>(%rax)</code> $\rightarrow$ <code>QWORD PTR [rax]</code></li>
<li>Intel 与 AT&amp;T列出操作数的顺序相反: <code>movq %rax, %rbx</code> $\rightarrow$ <code>mov rbx, rax</code></li>
</ol>
<p><strong>本文采用x86-64 AT&amp;T格式</strong></p>
<p>机器代码的优势：性能优化，访问硬件特性(PC,寄存器)，更难逆向分析</p>
<h3>数据的传输</h3>
<h4>数据格式</h4>
<p>最初的架构为16位，所以Intel用 <strong>字(word)</strong> 表示16位，32位为<strong>双字(double words/long word)</strong>，64位为<strong>四字(quad words)</strong></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-001.DD_f2CVo_Z19J7kn.webp" alt="image" /></p>
<p>GCC生成的部分汇编代码指令都带有一个字符后缀表示操作的大小，如<code>movl</code>表示传送双字</p>
<h4>信息访问</h4>
<p>x86-64 CPU 中含有16个通用寄存器</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-002.DCtGWnWo_2msS2o.webp" alt="image" /></p>
<p>8086中含有<code>%ax</code>到<code>%sp</code>8个通用寄存器
IA32将这8个寄存器扩展为32位 (<code>%eax</code>中的"e":extended)
x86-64将寄存器扩展到64位(<code>%rax</code>中的"r":register整个寄存器)，并采用新的命名方式增加8个寄存器(<code>%r8</code>～<code>%r15</code>，<code>%r8d</code>中的"d":double words，<code>%r8w</code>中的"w":word)
指令通过不同的后缀大小(b,w,l,q)，得以访问寄存器的不同最低字节(1,2,4,8)
小于8字节的数据传送到寄存器中时，若传送字节为1或2，则更高的字节不变；若传送字节为4，则更高的4个字节全置为0(IA32到x86-64扩展导致的)</p>
<h4>操作数指示符</h4>
<p>将寄存器看做数组$R[]$，内存看做数组$M[]$</p>
<ol>
<li>立即数(immediate):<code>$</code>后面接常数 <em>eg.</em> <code>$114514</code></li>
<li>寄存器(register):以指定寄存器的低位1,2,4,8字节为操作数，返回寄存器中的值即$R[r_a]$ <em>eg.</em> <code>%eax</code>返回第一个寄存器的低32位</li>
<li>内存引用：将内存看做很大的字节数组，根据计算的有效地址访问内存位置</li>
</ol>
<p>内存引用的寻址模式：$Imm(r_b,r_i,s)$，其中$Imm$为立即数偏移，$r_b$和$r_i$为64位寄存器，$s$取1,2,4,8(默认为1)
有效地址被计算为$addr=Imm+R[r_b]+R[r_i]*s$，返回$M[addr]$</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-003.CRDeeo7o_ZYCVor.webp" alt="image" /></p>
<h4>数据传送指令</h4>
<p>将数据从一个位置传送到另一个位置的指令</p>
<h5>MOV</h5>
<p><code>MOV S, D</code>指将数据从$S$复制到$D$，其中$S$称为源操作数，$D$称为目的操作数</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-004.yhto8iOB_Z1QJ8T0.webp" alt="image" /></p>
<p>其中源操作数可以取立即数，寄存器，内存；目的操作数可以取寄存器和内存
注意：x86架构中大部分操作(包括<code>MOV</code>)源操作数和目的操作数不能同时为内存，必须通过寄存器进行中转
$movabsq$作用：处理立即数时，$movq$只能传送32位立即数然后在符号扩展到64位，而$movabsq$能将64位立即数直接传送到寄存器，但只能移动立即数</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-005.B04m_JlE_k4kyN.webp" alt="image" /></p>
<h5>MOVZ与MOVS</h5>
<p>两者都是将较小源值复制到较大的目的值，后缀都有两个大小指示符，分别是源的大小和目的的大小
<code>MOVZ</code> 采用零扩展进行复制，无符号数扩展</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-006.DL8loXJk_Z1cEPYF.webp" alt="image" /></p>
<p>注：没有<code>movzlq</code>的原因是使用<code>movl</code>传送4个字节会把前面更高的4个字节全赋为0，两者等价</p>
<p><code>MOVS</code>采用符号扩展进行复制，有符号数扩展</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-007.B13P5euS_22RrTV.webp" alt="image" /></p>
<h5>压入和弹出栈数据</h5>
<p>x86-64架构中，程序栈放在内存中某个区域，寄存器<code>%rsp</code>保存栈顶的地址
不同于C语言中用数组模拟实现栈时栈顶指针指向地址最高，<code>%rsp</code>指向的栈顶地址是栈中最小的</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-008.C6ZG0NgK_xT0gP.webp" alt="image" /></p>
<p><code>pushq %rbp</code>等价于<code>subq $8,%rsp movq %rbp,(%rsp)</code>，即将栈顶指针向前移动8个字节，再将元素入栈
<code>pushq</code>的优势：编码为1个字节，而等价代码编码为8个字节；同时现代x86-64处理器上<code>pushq</code>被高度优化，效率更高
栈内数据在内存中，同样可以用内存寻址法访问</p>
<h4>算术和逻辑操作</h4>
<p>四类操作：加载有效地址，一元操作，二元操作，移位
这里主要讨论<code>leaq</code>的64位形式；其他算术逻辑指令通常能用不同后缀(b,w,l,q)指示操作大小</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-009.CDfH6Crt_1CuuyV.webp" alt="image" /></p>
<h5>加载有效地址</h5>
<p>lea:load effective address
<code>leaq Imm(r_b,r_i,s), r_d</code> 表示采用内存计算的方法算出地址之后，将该有效地址作为数字直接存储到$r_d$中
<em>eg.</em> 设<code>%rbx</code>的值为$x$，则 <code>leaq 6(%rbx,%rbx,4), %rax</code> 表示计算$5 \times x + 6$并存放在<code>%rax</code>中
作用：简洁地表示普通的算术操作</p>
<h5>一元操作和二元操作</h5>
<p>一元操作中的操作数既是源操作数又是目的操作数；二元操作中前者为源操作数，后者为目的操作数
<em>eg.</em> <code>subq %rbx,%rax</code>表示将$R[rax]$减去$R[rbx]$
同样，二元操作也不能源和目的都为内存，需要进行寄存器的中转</p>
<h5>移位操作</h5>
<p><code>SAR</code>表示算术右移(Shift Arithmetic Right)，<code>SHR</code>表示逻辑右移
为了对称，同时有<code>SAL</code>和<code>SHL</code>，但是两者作用完全等价，<code>SHL</code>更为常用
移位操作有两个操作数，前者为移位数(立即数或者<code>%cl</code>中的数 <strong>不能是其他寄存器或内存中的数</strong>)，后者为移位的对象(内存或寄存器)
注：移位操作实际使用的移位位数为指令大小后缀对应的低位，如$sarl$实际移位数位给定数对32取模
<em>eg.</em> <code>shlq $65, %rax</code> 即为将<code>%rax</code>中的数左移1位</p>
<h5>特殊算术操作</h5>
<p>两个64位整数相乘时需要得到128位整数再进行截断，其中16字节的数称为八字(oct words)</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-010.FfFRIeIN_Z1RJj3I.webp" alt="image" /></p>
<p><code>imulq</code> 的第一种形式：<code>imulq S, D</code> 将 <code>D</code> 乘以 <code>S</code>，结果存储在 <code>D</code> 中
<code>imulq</code>的第二种形式：<code>IMUL S</code>，另一个参数在<code>%rax</code>中，乘积得到的128位结果高64位放在<code>%rdx</code>中，低64位放在<code>%rax</code>中($__int128$)</p>
<p><code>idivq</code>(有符号除法):将<code>%rdx</code>看做高64位，<code>%rax</code>看做低64位得到128位被除数，除数作为指令操作数给出，商存放在<code>%rax</code>中，余数存放在<code>%rdx</code>中
当被除数为64位时，<code>cqto</code>指令通过符号扩展将被除数扩展到128位</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-011.CeGjEYrj_Pgn3z.webp" alt="image" /></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-012.D8Q9QqIz_1zTgvV.webp" alt="image" /></p>
<p><code>divq</code>(无符号除法)：事先将<code>%rdx</code>设置为0，可以使用<code>xorq %rdx, %rdx</code></p>
<h3>控制</h3>
<p>使得指令按一定的顺序执行(顺序结构，选择结构，循环结构)</p>
<h4>条件码</h4>
<p>部分指令(如计算，比较和测试，移位等)会设置条件码
注：<code>leaq</code>计算地址，不会设置条件码</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-013.xdOhnSBT_1S8tHu.webp" alt="image" /></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-014.JjlZswsn_KazGY.webp" alt="image" /></p>
<p><code>CMP</code>，<code>TEST</code>进行类似于<code>SUB</code>和<code>AND</code>的计算，不同之处在于不会将结果写入目的位置，而只会设置条件码</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-015.B7Q8gIHP_EgShI.webp" alt="image" /></p>
<p>通过<code>CMP a, b</code>可以得到$b-a$对应的条件码，从而判断$a$和$b$的相对大小；通过<code>TEST a, a</code>可以将$a$的零值和符号等特征反映到条件码中</p>
<h4>访问条件码</h4>
<p><code>SET</code>通过条件码的组合，将是否满足后缀条件(0/1布尔值)传送到一个低位字节中</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-016.7-SHo9Gn_Z1TkLM7.webp" alt="image" /></p>
<p>如假设$a$存储在<code>%rsi</code>中，$b$存储在<code>%rdi</code>中，执行<code>cmpq %rdi, %rsi</code>后
<code>setl %al</code>表示将<code>%rax</code>的最低字节设置为$(bool) (a &lt; b)$，结果字节为0或1</p>
<h4>跳转指令</h4>
<p>将指令执行的顺序改变，跳转到一个指定的位置</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-017.bLvGdcua_JxoF4.webp" alt="image" /></p>
<p>格式类似C中的<code>goto-label</code>：</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-018.COKbbmfE_2gWsAH.webp" alt="image" /></p>
<p><code>jmp</code>被称为无条件跳转，其余被称为条件跳转
只有无条件跳转能执行间接跳转，如<code>jmp *%rax</code>跳转到<code>%rax</code>的值对应地址的指令，<code>jmp *(%rax)</code>跳转到<code>%rax</code>指向内存的值对应地址的指令</p>
<h5>跳转指令的编码</h5>
<p>将汇编代码编码后，跳转目标通常会被编码为1、2或4字节的位移量，其值等于目标地址减去当前跳转指令的<strong>下一条指令</strong>地址(PC相对寻址，使得代码与位置无关，便于加载共享库)</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-019.xeobr9WJ_1Wx6qt.webp" alt="image" /></p>
<p>如图所示，编码后文件每一行冒号前的数字表示该指令编码得到的第一个字节的地址
第二行中通过$0x05 + 0x03 = 0x08$得到这条指令会跳转到地址$08$即第四行执行
第五行中通过$0x0d + 0xf8 = 0x05$(注意是有符号运算)得到这条指令会跳转到地址$05$即第三行执行</p>
<h5>条件传送与条件控制</h5>
<p>汇编使用$JMP$实现$if-else$和$goto-lable$进行跳转被称为条件控制</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-020.CwESlruF_Z2oW7JY.webp" alt="image" /></p>
<p>在一些特殊情况下，使用<code>CMOV</code>(conditional move) 的条件传送更为高效</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-021.DgPcyXAJ_Z2jbjWN.webp" alt="image" /></p>
<p><code>CMOV</code>需要加上条件后缀，操作数大小通常可由寄存器名或汇编器推断；它只支持字、双字和四字形式，不支持单字节条件传送
当条件码满足的时候，<code>CMOV a, b</code> 会将$a$的值写入$b$
使用条件传送实现$if-else$：</p>
<pre><code>if (condition){
	a = if_expr;
}
else {
	a = else_expr;
}
</code></pre>
<p>使用条件传送实现等价的代码为</p>
<pre><code>int x = if_expr;
int y = else_expr;
a = condition? x:y; 
</code></pre>
<p>注意，以下代码不能使用条件传送</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-022.pjbhQOWi_e8WQ5.webp" alt="image" /></p>
<p>原因：不论是否为空指针，都会提前计算$0$和$*xp$，在指针为空的情况下会导致$UB$</p>
<p>相较于条件控制，条件传送在一些情况下更为高效
原因：现代处理器使用流水线来得到高性能，即通过预先确定要执行的指令序列来使得流水线尽量充满。在遇见指令的跳转时，处理器通过分支预测逻辑猜测是否会进行跳转，并按猜测的结果为后续指令预处理。当猜测出错时，处理器只能丢掉已经做过的处理，重新填充流水线导致效率大大降低。在是否跳转极其难以预测的情况下(如<code>if (x &amp; 1)</code>)，性能会受到严重影响。而条件传送没有改变程序计数器(PC)，下一条指令的地址确定，保证了流水线尽量填满。</p>
<p>编译器会在两个分支计算简单且计算代价差异小，分支难以预测，无副作用，优化程度高(-O2 -O3)的情况下优先选择条件传送</p>
<h5>switch 语句</h5>
<p>在开关大跨度稀疏时，<code>switch</code>会被编译为条件控制
在开关小跨度密集时，<code>switch</code>会被编译为跳转表</p>
<p><em>eg.</em> 左侧为C源代码，右侧为与跳转表类似的C实现 (<code>&amp;&amp;</code>表示指向代码位置的指针)</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-023.BKPuGjEY_1Y890N.webp" alt="image" /></p>
<p>编译得到跳转表。表中每一项保存一个目标代码地址或相对位移，相邻表项通常按指针大小排列，并不是相邻的<code>label</code>只相差1个字节</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-024._yL35W-a_2rqPYG.webp" alt="image" /></p>
<p>汇编代码</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-025.GDJaJK8l_Vovlc.webp" alt="image" /></p>
<h4>循环</h4>
<p>将条件测试和跳转组合起来即可得到循环</p>
<p><code>do-while</code></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-026.CziBzIHl_Z25umwt.webp" alt="image" /></p>
<p><code>while</code>有两种形式</p>
<ol>
<li><em>jump to middle</em>(先进入循环，在循环中途再检查条件)</li>
</ol>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-027.Clp7uskp_1eCc88.webp" alt="image" /></p>
<p>2.<em>guarded-do</em>(先检查条件，再进入循环)</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-028.Cewv6deM_29PNkx.webp" alt="image" /></p>
<p><code>for</code></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-029.Cm7kr3vw_20YeHP.webp" alt="image" /></p>
<p>注意：当在$body-statement$中存在$continue$语句时，$continue$应当跳转到$update-expr$部分，否则可能导致死循环等问题
现代编译器会根据循环特征自动选择最优的实现方式，并进行循环展开等优化</p>
<h3>过程</h3>
<p>一种封装代码的方式，隐藏具体的实现，同时提供清晰的接口(类比C中的函数)
过程包含的机制(假设从过程$P$进入过程$Q$)：</p>
<ol>
<li>传递控制：将PC设为$Q$过程起始指令的地址，执行完$Q$过程后再将$PC$设置为$P$中调用$Q$下一条指令的地址</li>
<li>传递数据：$P$将特定的参数传递到过程$Q$中,同时$Q$向$P$传递返回值</li>
<li>内存的分配与释放：为局部变量分配空间，并在过程结束时将这些空间释放</li>
</ol>
<h4>运行时栈</h4>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-030.D2n6n77Q_17xfmJ.webp" alt="image" /></p>
<p>在过程$Q$被执行时，其调用链上的所有信息的挂起在栈上，栈中存放着传递控制，传递数据，内存分配释放的信息
栈帧：过程在寄存器中存放不下，存放在栈中的空间
局部变量都可以存储在寄存器中并且为叶子过程(未调用其他过程的过程)，则不需要栈帧
注:<code>%rsp</code>为栈指针，<code>％rip</code>为PC</p>
<h4>传递控制</h4>
<p>从过程$P$到过程$Q$
<code>call Q</code>将PC跳转到$Q$起始指令的地址，并将<code>call</code>指令的下一条地址(返回地址)压入栈中
<code>ret</code>从栈中弹出返回地址，并将PC设置为返回地址</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-031.Cu61omCV_UU9HN.webp" alt="image" /></p>
<h4>传递数据</h4>
<p>$x86-64$架构中最多可以用6个寄存器来传送参数</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-032.DP_WX2nt_Z1lCzgf.webp" alt="image" /></p>
<p>当传递的参数大于6时，需要通过栈来进行传送(参数构造区)</p>
<p><em>eg.</em></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-033.YaQuT6iF_11kwBR.webp" alt="image" /></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-034.D8OhtsOw_1myhfp.webp" alt="image" /></p>
<p>注：通过栈传送数据时，第7个及以后的参数按从右到左的顺序压入栈中，第7个参数在栈顶（低地址），后续参数依次向高地址排列</p>
<h4>内存的分配与释放</h4>
<p>当寄存器不足以存放局部数据，或者局部数据被取地址(<code>&amp;</code>)需要被分配地址时，会在栈上为这个数据分配空间，形成局部变量</p>
<p><em>eg.</em></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-035.CEGeG9qq_RyBQB.webp" alt="image" /></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-036.DCksNpnW_26jLCM.webp" alt="image" /></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-037.md-qGins_1GytUF.webp" alt="image" /></p>
<p>其中：</p>
<ol>
<li><code>x1 x2 x3</code>被取地址，作为局部变量在栈上分配空间，同时也属于前6个参数，传入寄存器</li>
<li><code>&amp;x1 &amp;x2 &amp;x3</code>属于前6个参数，传入寄存器，又未在该过程后续被直接使用，不是局部变量</li>
<li><code>x4</code>被取地址，作为局部变量在栈上分配空间，同时也在栈上再次分配一个不同的空间，传送这个参数</li>
<li><code>&amp;x4</code>通过在栈上分配空间作为参数传送，但不是局部变量</li>
</ol>
<p>在执行<code>call proc</code>时，会在栈中压入长8个字节的返回地址，使得$x4$和$&amp;x4$相对栈的地址偏移量变为8和16个字节，<code>proc</code>过程得以正确执行
注意：
栈帧内存的释放只是改变了栈指针，没有改变曾经的参数和局部变量的值，意外访问这些位置可能读到旧数据</p>
<h5>寄存器中的局部存储空间</h5>
<p>被调用者保存寄存器：<code>%rbx</code>, <code>%rbp</code>, <code>%r12</code>-<code>%r15</code>（调用前后值不变）
过程$P$调用过程$Q$，当过程$Q$回到过程$P$时，必须保证以上寄存器的值与其调用$Q$前的值相等
这些寄存器可以作为临时变量，保存$P$中的某些信息，防止在$Q$中这些信息被改变(如参数，$P$和$Q$参数可能不同)
实现：将过程中要使用的作为临时变量的寄存器，在过程开始时压入栈中，中间任意变换，结束过程时再从栈中弹出覆盖这些寄存器的值即可</p>
<p>调用者保存寄存器：<code>%rax</code>, <code>%rcx</code>, <code>%rdx</code>, <code>%rsi</code>, <code>%rdi</code>, <code>%r8</code>-<code>%r11</code>（调用后可能被修改）</p>
<p><em>eg.</em></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-038.CTCmQ7rt_Z1A68cU.webp" alt="image" /></p>
<h4>递归过程</h4>
<p>每个过程相关信息在栈上都有私有空间，互不干扰，使得递归能够高效而正确地实现</p>
<p><em>eg.</em></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-039.Bkw-rJfK_X7Ute.webp" alt="image" /></p>
<p>当递归调用是函数体中最后执行的操作时，编译器可能进行尾递归优化，将其转换为循环</p>
<h3>数组的分配与访问</h3>
<p>通过对数组首地址进行运算，得到需要访问的元素的地址
<em>eg.</em> <code>T A[N]</code>会在内存中以$x_A$开始，连续分配$L\times N$大小的空间，其中$L$为单个$T$的大小
<code>A[i]</code>的地址 $&amp;A[i] = x_A + i \times L$
例如，假设$i$的值存放在<code>%rcx</code>中，<code>int</code>数组首地址$x_A$在<code>%rdx</code>中，通过<code>movl (%rdx, %rcx, 4), %rax</code>即可将$A[i]$的值存放在<code>%rax</code>中</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-040.BEHD3Tp6_Zqlsm8.webp" alt="image" /></p>
<p>对于二维数组<code>T A[N][M]</code>来说，有 $&amp;A[i][j] = x_A + L \times i \times M + L \times j = x_A + L \times (i \times M + j)$</p>
<p>以此类推，对于$k$维数组<code>T A[N][][]....</code>来说，其$&amp;A[i][][]...$等于 $x_A$ + $i \times L(A[][]...)$ + $k-1$维中访问$A[][]...$的地址偏移量</p>
<h4>定长数组与变长数组</h4>
<p>编译器对定长数组(数组大小为常量)的优化：
通过指针加减移动进行寻址，而非用乘法进行寻址，提高寻址的效率</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-041.BuFpvFLT_ObffU.webp" alt="image" /></p>
<p>对于变长数组(数组大小为表达式)：</p>
<p>由于数组的大小不确定，无法使用<code>leaq</code>，需要使用<code>imul</code>，时间开销更大，但编译器仍然尽量使用指针提高效率</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-assembly-assembly-image-042.DDN0sYrH_Z2b76Ru.webp" alt="image" /></p>
<h3>异质的数据结构</h3>
<h4>struct 结构</h4>
<p>同一结构内的变量都分配在连续的内存中
<em>eg.</em></p>
<pre><code>struct str{
	int l, r;
	long long val;
}s;
</code></pre>
<p>其中<code>s</code>是一个结构体对象；若<code>%rdx</code>中存放的是指向<code>s</code>的指针，则通过<code>8(%rdx)</code>即可以访问<code>s.val</code></p>
<h4>联合 <code>union</code></h4>
<p><em>eg.</em></p>
<pre><code>struct node_t { 
	nodetype_t type; 
	union { 
		struct { 
			node_t *left; 
			node_t *right; 
		} internal; 
		double data[2]; 
	} info; 
}; 
</code></pre>
<p>其中，<code>internal</code>与<code>data[2]</code>互斥 (若为叶子节点则只有两个值，没有儿子指针；若不是叶子节点，则有两个儿子指针，无权值)
<code>union</code>的大小为其中最大变量的大小，如以上的<code>union</code>中所占大小为16，通过<code>union</code>节省了空间</p>
<p>同时，<code>union</code>也可以实现保持位模式相同的类型转换</p>
<pre><code>double x = 114514.1919810;
unsigned val = (unsigned)x;
</code></pre>
<p>这是普通数值转换：浮点数会向零舍入；如果超出<code>unsigned</code>可表示范围，行为不由C标准保证</p>
<pre><code>union{
	double x;
	uint64_t val;
}u;
u.x = 114514.1919810;
uint64_t val = u.val;
</code></pre>
<p>通过<code>union</code>，可以观察同一段存储的位模式；这里用64位整数保存<code>double</code>的完整二进制串，数值含义会发生改变</p>
<h4>数据对齐</h4>
<p>对于结构体中的变量，编译器通过数据对齐保证其地址为$K$的倍数($K=1,2,4,8$)，进而提高访问的效率
例如假设处理器每次操作从内存中取8个字节，对$double$进行字节对齐，使其不会落在两个不同的内存块中，保证了读写时处理器只会访问一个内存块，进而保证了操作的高效</p>
<p>字节对齐按以下步骤进行：</p>
<ol>
<li>对于结构内的某个变量，在其后面的地址连续填充空隙，直到下一个地址满足下一个变量所需的对齐约束，再将下一个变量放入这个地址中</li>
<li>设$M$为结构内所有变量的最大对齐要求，在最后一个变量之后连续填充空隙，直到结构体总大小为$M$的倍数</li>
</ol>
<p><em>eg.</em></p>
<pre><code>struct str{
	int a;
	char b;
	int c;
	char d;
};
</code></pre>
<p>其对齐后的结果为：
a: 0～3 b:4～4 空隙:5～7 c:8～11 d:12～12 空隙:13～15</p>
<h4>杂项</h4>
<h5>指针</h5>
<p>指针本质是一个二进制串表示的地址，其类型决定了每次对指针运算时的大小偏移量
对于指针类型强制转换，只会改变其每次的偏移量，而不改变指向的地址
<em>eg.</em> <code>int *p; p++;</code>会让<code>p</code>指向的地址向后移动四个字节
指针同样能够指向函数，即指向函数首指令的地址</p>
<h5>缓冲区溢出</h5>
<p>以C中的$gets$为例(不给定目标缓冲区的大小)，当读入的字符大于定义的字符数组长度时，就可能越界访问当前函数的返回地址和栈中保存的其他函数状态</p>
<h6>缓冲区溢出攻击</h6>
<p>通过在输入的字符串中加入可执行代码(攻击代码)，同时又用指向攻击代码的指针覆盖返回地址，导致系统被攻击
对抗溢出攻击</p>
<ol>
<li><strong>栈随机化</strong>：通过执行代码前分配一段长度为$0 \sim n$个随机字节且不使用的空间，使得每次运行时的栈地址不固定，攻击时不一定能跳转到攻击代码
局限：<code>nop sled</code> 通过在攻击代码写入大量的<code>nop</code>,只要能跳转到其中任意一个<code>nop</code>就能执行攻击代码
<em>eg.</em> 32位系统随机地址范围大小大约为$2^{23}$字节，在攻击代码前写入$m$字节大小的<code>nop</code>，执行$\frac{2^{23}}{m}$ 次则一定会跳转到攻击代码
如取$m=256$，则大约需要三万次左右次枚举起始地址，攻击就能成功</li>
<li><strong>栈破坏检测</strong>:通过在栈中插入一个运行时随机生成的金丝雀值(<code>canary</code>)，恢复栈的状态时，若该值被改变，则发生了溢出攻击，程序跳转到错误处理例程
其中<code>canary</code>会和保存在安全位置的原始值比较，攻击者通常难以预测并正确伪造它
注意：现代编译器默认采用栈破坏检测，gcc中可采用<code>-fno-stack-protector</code>将其关闭
3.<strong>限制可执行代码区域</strong>：将栈上的某些部分设置为可读可写但不能执行($NX$)</li>
</ol>
<h4>浮点代码 (AVX2)</h4>
<p><em>"我们不太清楚GCC为什么会生成这样的代码，这样做既没有好处，也没有必要在XMM寄存器中把这个值复制一遍"</em>
看破防了，感觉CSAPP也是讲得稀里糊涂的，以后再补</p>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="CSAPP"></category>
  </entry>
  <entry>
    <title>bits</title>
    <link href="https://katyusha-blog.com/posts/csapp/notes/bits/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/csapp/notes/bits/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-05-25T00:00:00.000Z</updated>
    <summary>bits</summary>
    <content type="html"><![CDATA[<h2>信息的处理和表示</h2>
<h3>数字的存储</h3>
<p>内存被划分为不同大小的字块，32位CPU-&gt;4字节，64位CPU-&gt;8字节
对字长$w$的机器而言，虚拟地址范围为$0～2^w-1$，即有$2^w$个字节
64位架构地址空间限制为48位虚拟地址，约为$256TB$($2^{48}Bytes$)，但是仍然在64位逻辑上处理算术运算
编译器通过保持字节对齐，提高硬件效率
无论32位机器还是64位机器，<code>int32_t</code>和<code>int64_t</code>都分别占4个和8个字节；<code>int</code>和<code>long</code>等C类型的大小取决于具体ABI。32位机器也可以支持64位整数运算，只是通常需要用多条32位指令协同完成
而浮点寄存器有自己的宽度标准，与通用寄存器宽度无关，如$double$始终占8个字节</p>
<p>小端法：最低有效位所在字节存储地址在最前面，类型转换灵活，符合数学思维
大端法：最高有效位所在字节存储地址在最前面，方便阅读识别，便于判断正负
大小端法通常由ISA/系统ABI共同约定，有些架构还支持在不同模式下选择字节序
x86/x86-64使用小端法
PowerPC，网络协议(TCP/IP)使用大端法
ARM, RISC-V, MIPS等同时支持两种字节序</p>
<h3>二进制下的整数</h3>
<h4>1. 补码</h4>
<p>对于 $w$ 位的无符号整数$x$：$x = \sum\limits_{i=0}^{w-1}a_i \times2^i$
对于$w$位的有符号整数$x$ : $x = -2^{w-1} \times a_{w - 1}+\sum\limits_{i=0}^{w-2}a_i \times2^i$(补码表示)
在此种表示方式下，$-x$的补码编码与无符号整数$2^w-x$的编码相同
原理：在$mod$ $2^w$ 意义下 $-x$ 与 $2^w - x$等价，无符号运算在溢出的情况下仍满足加法与乘法结合律，交换律等定律
无符号整数与有符号整数的比较：有符号整数类型转换为无符号整数再进行比较 $eg$: $-1$ $&gt;$ $0U$
有符号整数与无符号整数的转换：
若有符号数$t &lt; 0$，则其无符号解释为$t + 2^w$；若$t \geq 0$，则无符号解释仍为$t$
若无符号数$u &gt; 2^{w-1}-1$，则其有符号解释为$u - 2^w$；否则仍为$u$</p>
<h4>2.符号扩展与数字截断</h4>
<p>符号扩展：在不改变值的情况下提升一个$w$位有符号整数的位数</p>
<p><em>case1:若符号位$a_{w-1} = 0$</em></p>
<p>直接将$a_w$设为$0$，扩展到了$w+1$位</p>
<p><em>case2:若符号位$a_{w-1}=1$</em></p>
<p>将第$a_w$设为$1$，第$a_w$和$a_{w-1}$的贡献从$-2^{w-1}$到$-2^{w}+2^{w-1}$不变，扩展到了$w+1$位
即扩展到第$w+1$位时有$a_w=a_{w-1}$</p>
<p>数字截断到$k$位：保留最低$k$位，舍弃第$k$位及更高位
无符号数数字截断等价于对$2^k$取模</p>
<p>从较小整数类型转换到较大整数类型时，C会先进行整数提升：有符号源通常符号扩展，无符号源通常零扩展，然后再按目标类型解释位模式
<strong>eg.</strong> <em>对于short x; <code>(unsigned)x</code>等价于<code>(unsigned)(int)x</code></em></p>
<h4>3.运算</h4>
<p>对于两个$w$位整数的运算，加法得到的结果最多为$w+1$位，截断第$w+1$位得到$w$位整数，对无符号整数来说等价于对结果$mod$ $2^w$
乘法得到的结果最多为$2w$位，保留低$w$位并舍弃高位得到$w$位整数
$w$位整数与常数相乘的时候，编译器会根据上下文进行优化，优化方式取决于架构的指令集
<strong>eg.</strong> <em>x*14=(x&lt;&lt;3)+(x&lt;&lt;2)+(x&lt;&lt;1)或((x&lt;&lt;3)-x)&lt;&lt;2</em>（乘法分解为位移和加减法）
或<em>imul    eax, edi, 14</em>（编译器认为imul更快，直接使用乘法指令）</p>
<p><strong>逻辑右移</strong>：右移一位后在最高位补0
<strong>算术右移</strong>：右移一位后，若符号位原本为1，则在最高位补1，否则补0
除以2的幂时：
无符号数采用逻辑右移，等价于向下取整
有符号数需要特殊处理：编译器会生成先加偏置再算术右移的代码，以保证向零取整的结果与C语言标准一致</p>
<h3>二进制下的浮点数</h3>
<h4>1.浮点数的表示</h4>
<h5>标准化之前</h5>
<p>对浮点数$x$有：$x=\sum \limits_{i=-j}^{k}a_i \times2^i$
左移，右移在算术上仍然等价于$\times2$ 和 $\div 2$，但是误差大，受位数有限制约
标准浮点数表示法：IEEE浮点数</p>
<h5>IEEE浮点数</h5>
<p>符号位(s) + 阶码位(exp) + 尾数位(frac)
单精度32位 双精度64位 (和英特尔扩展精度80位)
32 = 1 + 8 + 23
64 = 1 + 11 + 52</p>
<p>IEEE浮点标准用$V=(-1)^s \times 2^E * M$表示浮点数</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-data-bits-image-001.DHWNG56p_Z1Ob1IQ.webp" alt="image" /></p>
<p>设$exp=\sum\limits_{i=0}^{k-1}2^ie_i$,$bias=2^{k-1}-1$(无符号整数)
$frac = \sum\limits_{i=0}^{n-1}f_i\times2^{i-n}$(分数)</p>
<p><em>1.规格化的情况($1 \leq exp \leq 2^k-2$)</em>
$E=exp-bias$，$M=1.0 + frac$，那么有$-2^{k-1} + 2 \leq E \leq 2^{k-1} -1$</p>
<p><em>2.非规格化的情况($exp = 0$)</em>
$E=1-bias$，$M=frac$
用来表示$0$和极其接近$0$的值
注：$+0.0$和$-0.0$有不同的位表示，但在普通数值比较中相等</p>
<p><em>3.特殊值($exp = 2^k-1$)</em>
$frac = 0$:$V=INF$
$frac \neq 0$:$V=NaN$</p>
<p>注：
1.引入$bias$的意义：阶码无需引入符号位；对同号规格化浮点数，阶码和尾数的位模式顺序更接近数值大小顺序
2.非规格化情况下$E$取$1-bias$，使得非规格化最大值为$2^{2-{2^{k-1}}} \times (1-{2^{-n}})$，而规格化最小值为$2^{2-{2^{k-1}}}$，二者十分接近</p>
<h4>2.整数转浮点数</h4>
<p>以将(int)114转换为float为例</p>
<p>1.转为二进制下科学计数法
$114 = (1110010)_2 = (1.110010)_2 \times 2 ^ 6$
2.去掉小数点前的1并在末尾添加0直到$n$(23)位，得到尾数部分
$1.110010 -&gt; 11001000000000000000000$
3.将阶数加上偏移值$2^{k-1}-1$(127)，转为二进制得到阶码部分
$6 + 127 = 133 ＝ (10000101)_2$
4.加上符号位，将阶码尾数拼起来
$(01000010111001000000000000000000)_2$</p>
<p>由该过程可知，整数二进制编码与其浮点数表示的尾数部分除第一个1外重合</p>
<h4>3.思考：n位位数不能精确表示的最小正整数</h4>
<p>假设阶码足够大，有$n$位尾数的浮点数不能精确表示的最小正整数为$2^{n+1}+1$</p>
<p>证明：$n$位的$frac$再加上尾数隐式的$1.0$一共$n+1$位，乘$2^E$可以看做左移$E$位，在阶码可以取到无穷大的情况下，可以取遍$1 ～ 2^{n+1}-1$，同时在$frac$全为$0$的情况下，左移$n+1$位得到$2^{n+1}$，即$1～2^{n+1}$都可以表出，而$2^{n+1}+1$无法表出
推论：<code>double</code>能连续精确表示的最大正整数为$2^{53}=9007199254740992$，因此$2^{53}+1$不能被精确表示；但更大的2的幂或足够大的偶数仍可能精确表示
补充:$n$位尾数浮点数在$x$处的误差(可表示数的最小间隔) $ULP(x)=2^{\lfloor \log_2  \lvert x \rvert \rfloor - n}$
警钟撅烂：<code>double r = 1e18+5;</code> $r$的值仍然为$10^{18}$</p>
<h4>4.浮点数的运算</h4>
<p>舍入：找到最接近的值$x$，使其可以按期望的浮点形式表示出来
向零舍入，向上舍入，向下舍入
向偶数舍入：优先向满足条件的最接近的数舍入，若存在两个这样的数，则向最低有效位为偶数的那个数舍入(避免了统计误差)
浮点加法，乘法运算不满足结合律，浮点乘法在加法上不存在分配律
**eg.**对于$float$有$1.14+1e18-1e18=0$（舍入导致1.14丢失），$1.14+(1e18-1e18)=1.14$
无穷运算规则：+∞ + (+∞) = +∞，+∞ + (-∞) = NaN，+∞ × 0 = NaN
NaN的传播特性：任何包含NaN的运算结果都是NaN</p>
<h4>5.C中浮点数与有符号数的转换</h4>
<p>$int$转$float$可能被舍入
$double$转$float$可能会溢出或者被舍入
$float,double$转整数值会向零舍入；若超出目标整数类型范围，C标准认为行为未定义。在常见x86实现中，越界或NaN转换可能产生整数不确定值$(100..00)_2$，即$-2^{w-1}$</p>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="CSAPP"></category>
  </entry>
  <entry>
    <title>ECF</title>
    <link href="https://katyusha-blog.com/posts/csapp/notes/ecf/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/csapp/notes/ecf/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-22T00:00:00.000Z</updated>
    <summary>ECF</summary>
    <content type="html"><![CDATA[<h2>异常控制流</h2>
<p>假设处理器工作时，PC指向的指令地址构成的序列为$a_1,a_2...a_n$，序列$a$就称为处理器的控制流，由$a_i$向$a_{i+1}$的过渡称为控制转移
现代系统通过使控制流发生突变，对系统状态的变化做出反应，这些突变就称为异常控制流(Exceptional Control Flow，<strong>ECF</strong>)</p>
<h3>异常</h3>
<p>异常是一种响应处理器状态变化的机制，既有软件实现的部分，也有硬件实现的部分
其机制如下</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-001.DP9SpAsj_ZtQNwm.webp" alt="image" /></p>
<p>假设处理器正在执行指令$I_{cur}$，此时发生一个事件(如除0，数值溢出，直接内存访问结束)，处理器会检测到该事件的发生，并通过异常表进行控制转移，跳转到相应的异常处理程序进行处理，然后返回继续执行指令或终止程序</p>
<h4>异常的识别与调用</h4>
<p>每一种异常都有着一个唯一的无符号数表示的异常号
在系统启动时，系统会生成一张异常表，该表的第$k$项存放了对应异常号为$k$的异常处理程序指令所在地址
而异常处理程序的调用类似于函数，但是有着一些区别：</p>
<ol>
<li>调用函数后，条件码等信息可能发生变化；调用异常处理程序时这些信息进行了压栈保存，处理结束后弹出，保证了程序的正常运行</li>
<li>调用函数会将返回地址压入用户栈中；而调用异常处理程序会将返回地址和相关信息压入内核栈</li>
<li>函数运行在用户模式下；而异常处理程序运行在内核模式下，有着对系统资源更完全的访问权</li>
</ol>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-002.Cl_Vgh9q_ZSdgLA.webp" alt="image" /></p>
<h4>异常的类型</h4>
<p>异常有以下四种类型：<strong>中断</strong>，<strong>陷阱</strong>，<strong>故障</strong>，<strong>终止</strong>
其中中断属于<strong>异步异常</strong>，而其他三种属于<strong>同步异常</strong></p>
<h5>中断</h5>
<p>外部I/O设备发出信号，导致中断发生。即中断不是由内部指令造成的，所以称为异步异常
I/O设备通过向处理器芯片上的引脚发信号，并将异常号放入系统总线中进而触发中断
其流程如下：</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-001.DP9SpAsj_ZtQNwm.webp" alt="image" /></p>
<p>我们以DMA直接内存访问结束为例，当磁盘访问完成时，处理器收到了对应的异常码，在调用异常处理程序后继续处理下一条指令</p>
<h5>陷阱</h5>
<p>陷阱是有意造成的异常，和中断相似，但区别在于是由内部指令造成的
陷阱最重要的作用是<strong>系统调用</strong>，这是用户程序和内核间的接口，通过访问内核，得以进行文件读写，创建进程等重要操作
汇编中，使用<code>syscall</code>进行系统调用，触发一个到异常处理程序的陷阱</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-004.Du3yCAoN_2dkkBD.webp" alt="image" /></p>
<h5>故障</h5>
<p>故障是处理器处理中出现了错误情况，它可能在被异常处理程序修正后成功重新运行(如缺页异常)，也可能无法被修复导致程序终止</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-001.DP9SpAsj_ZtQNwm.webp" alt="image" /></p>
<p>注意：导致故障时当前指令尚未完成，所以如果成功修复后，需要跳转到当前指令重新执行，而非下一条指令</p>
<h5>终止</h5>
<p>发生了不可修复的严重错误，不返回控制，而是直接转入会结束该程序的<code>abort</code>例程</p>
<h4>linux系统中的异常</h4>
<p>我们以x86-64为例，有256个异常号，其中0～31由架构定义，对x86-64架构都一样；而32～255由操作系统定义，通常用于中断和陷阱</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-006.BcQTcNqm_Z16OsYR.webp" alt="image" /></p>
<p>其中除法错误和一般保护故障(常表现为段错误)通常表示程序逻辑或权限错误，内核一般会向进程发送信号并终止它，而不是静默修复后继续运行</p>
<p>linux为系统调用生成了一张跳转表，每个系统调用都有一个唯一的整数号
汇编语言中，进行系统调用时，将要进行的系统调用的整数号放入<code>%rax</code>中，参数最多六个，依次放入<code>%rdi</code>、<code>%rsi</code>、<code>%rdx</code>、<code>%r10</code>、<code>%r8</code>、<code>%r9</code>中，然后使用指令<code>syscall</code>即可
进行系统调用时，<code>%rcx</code>和<code>%r11</code>会被用作临时变量，分别保存下一条指令地址和程序状态标志而被破坏掉
调用结束后的返回值会覆盖<code>%rax</code>，当其值为负数时，说明系统调用发生了错误</p>
<p>常见系统调用的跳转表如下</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-007.IDjBK474_ZURzLY.webp" alt="image" /></p>
<p>hello world 汇编实现</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-008.B4PmyNe2_ZhEGy9.webp" alt="image" /></p>
<p>事实上，函数调用在高级语言中被高度封装成了函数(如<code>write</code>封装为<code>printf</code>)，但是我们也可以通过相关接口直接进行系统调用</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-009.xmChpgUa_Zllegh.webp" alt="image" /></p>
<h3>进程</h3>
<p>程序是一个静态的文件，而进程则是程序运行起来的动态的实例
上下文是程序运行所需要的各种状态，包括代码，数据，PC，运行栈等，程序在进程的上下文中运行</p>
<h4>逻辑控制流</h4>
<p>我们以单核处理器为例，假设某一时刻存在多个进程都需要运行，处理器在一个时间点上只能推进一个进程
处理器通过短暂，频繁地中断和切换其处理的进程，创造出一种所有进程都在同时进行的宏观表象</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-010.BdyEE_mz_ZWYxCq.webp" alt="image" /></p>
<p>实际上，进程轮流占有处理器，执行一段时间后被抢占，其他进程进行处理。从宏观上来看，就好像每个进程都独占处理器，只是中间发生一些周期性的停顿</p>
<h4>并发流</h4>
<p>当两个进程运行的时间有交集时，就称这两个进程<strong>并发</strong>运行
当两个进程并发地运行在不同的处理器核心或者计算机上时，就称这两个进程<strong>并行</strong>运行
并发和并行最终都达到了<strong>多任务</strong>的结果</p>
<h4>私有地址空间</h4>
<p>操作系统通过为每个进程分配虚拟地址空间，使得宏观上就像每个进程占用整个系统地址空间</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-011.Cf6XBTvl_1I3xN.webp" alt="image" /></p>
<h4>用户模式和内核模式</h4>
<p>用户模式限制了应用可以执行的指令和访问的地址空间
在控制寄存器中，存在一位模式位，当这一位被设置时，进程就处于内核模式，可以执行任何指令集中的指令，访问内存中的任意空间
反之，进程处于用户模式，必须通过异常在异常处理程序中处于内核模式进行操作，然后再回到用户模式中
在linux中，<code>/proc</code>和<code>/sys</code>中存放了内核的信息，大部分只读，小部分可写</p>
<h4>上下文切换</h4>
<p>内核为每个进程都维护了一个上下文，当需要切换该进程执行时，恢复上下文并控制传递给这个进程即可
在进程执行时，内核中的一段称为调度器的代码进行调度的决策，即决定是否对某个正在执行的进程进行抢占，转而执行其他被挂起的进程
例如，当一个进程需要进行磁盘写的时候，调度器可以先上下文切换执行其他进程，当直接内存访问完成后再转而执行该进程
中断也可能引起上下文切换，如系统都能周期性地发出中断信号，让内核判定当前进程已经进行了较长时间，应该进行上下文切换</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-012.D9VAlhcF_Z1p2I2F.webp" alt="image" /></p>
<h3>进程控制</h3>
<p>下文介绍Linux下C程序中的进程控制方法</p>
<h4>获取进程id</h4>
<pre><code>pid_t getpid();
pid_t getppid();
</code></pre>
<p>在Linux中，<code>pid_t</code> 被实现为 <code>int</code>。<code>getpid</code>返回当前进程的pid，<code>getppid</code>返回父进程的pid</p>
<h4>创建和终止进程</h4>
<pre><code>void exit(int status);
</code></pre>
<p><code>exit</code>函数直接终止当前进程，并将退出状态设置为<code>status</code></p>
<pre><code>pid_t fork();
</code></pre>
<p><code>fork</code>创建一个子进程，该子进程被创建时几乎是父进程的一份拷贝，享有私有地址空间，与父进程并发地运行。当前进程为父进程的时候，返回值为创建的子进程的pid；当前进程为新的子进程的时候，返回值为0；由此可以判断当前执行的是子进程还是父进程。子进程与父进程交替执行的顺序未知，由调度器决定</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-013.C6imEhT7_Z1cfPYF.webp" alt="image" /></p>
<p>同时在shell中，也可以为了对一行命令行求值创建进程，该进程被称为<strong>任务</strong>，shell为每个任务创建一个新的进程组
通过<code>|</code> (pipe) 将多个进程连接起来，如<code>linux&gt; ls | sort</code></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-014.X4a45fyt_2jHaxw.webp" alt="image" /></p>
<h4>子进程的回收</h4>
<p>当一个进程终止时，其相关的部分状态会仍然保持在内核中，直到被回收。一个终止但未被回收的进程称为<strong>僵尸进程</strong>(zombie)
父进程可以对终止的子进程进行回收。当一个父进程终止，但是其子进程还没有被回收时，系统会安排pid为1的所有进程的祖先 <code>init</code>进程去回收它们，避免了对于系统资源的占用</p>
<pre><code>pid_t waitpid(pid_t pid, int *statusp, int options);
</code></pre>
<p>该函数默认行为是将当前进程挂起，直到指定的子进程终止，然后将子进程回收，最后返回被回收的子进程的pid
若发生错误，则返回-1。错误原因为没有该子进程时，errno被设置为<code>ECHILD</code>；原因为该函数被信号中断时，设置为<code>EINTR</code></p>
<p>其中，pid表明了需要等待回收的子进程id，若id=-1则为当前进程的全部子进程，否则为pid为该参数的子进程</p>
<p>options能指定该函数的行为，0为默认行为，可以通过以下定义的常量设置其行为</p>
<ol>
<li><code>WNOHANG</code>：当前进程不会被挂起，即只会检查pid是否终止，终止则返回pid，否则返回0</li>
<li><code>WUNTRACE</code>：挂起当前进程，采取上文提到的默认行为，同时检查pid是否终止或挂起</li>
<li><code>WCONTINUED</code>：挂起当前进程，采取上文提到的默认行为，同时检查pid是否终止或从挂起变为执行
以上的选项可以进行或运算(如<code>(WNOHANG | WUNTRACE)</code>)，函数行为是两种行为逻辑上的或</li>
</ol>
<p>在该函数调用完毕后，statusp指向的值会被改为导致子进程返回的信息，可以通过以下几个宏进行查看</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-015.D-wGunF2_Z1kf7Hz.webp" alt="image" /></p>
<p>也可以使用<code>wait(int *statusp)</code>函数，该函数等价于<code>waitpid(-1, statusp, 0)</code></p>
<p>以下是<code>waitpid</code>的一个例子</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-016.YW7tzSdY_Z24mSr0.webp" alt="image" /></p>
<p>父进程和子进程执行顺序未知，子进程可能尚未执行完exit函数，需要重复将父进程挂起等待然后回收终止的子进程</p>
<h4>进程的休眠</h4>
<pre><code>unsigned int sleep(unsigned int secs);
int pause();
</code></pre>
<p><code>sleep</code>将当前进程挂起secs秒，可能被信号中断，返回值为还剩余的秒数
<code>pause</code>会将当前进程一直挂起，直到收到一个信号</p>
<h4>加载并运行程序</h4>
<pre><code>int execve(const char *filename, const char *argv[], const char *envp[])
</code></pre>
<p>该函数直接在当前进程中执行可执行文件<code>filename</code>，参数为<code>argv</code>，环境变量为<code>envp</code>，此后除非找不到<code>filename</code>才返回-1，否则不会返回
<code>execve</code>调用启动代码，将控制和相关参数传递给<code>filename</code>的<code>main</code>函数</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-017.Dcbx4AkH_Z2wT9GW.webp" alt="image" /></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-018.2DBluH-z_rBJLy.webp" alt="image" /></p>
<p>linux下，C提供了<code>getenv</code>，<code>setenv</code>，<code>unsetenv</code>等函数对当前进程的环境变量进行修改</p>
<p>通过<code>fork+execve</code>，我们得以在创建的子进程中运行一个程序</p>
<h3>信号</h3>
<p>异常是处理器层面的，而信号可以理解为程序层面的异常。信号提供了一种机制，内核通知用户进程发生了异常，进程做出相应的反应
例如：shell 中 <code>Ctrl+C</code>使内核向前台进程组发送<code>SIGINT</code>信号请求终止；<code>Ctrl+Z</code>使内核发送<code>SIGTSTP</code>信号请求挂起</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-019.Bl42F59S_8jSfq.webp" alt="image" /></p>
<h4>信号处理的流程</h4>
<p>当一个系统事件发生时，内核会将对应的信号发送给相应的进程，该进程以某种方式对信号做出回应，称为接收了该信号</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-020.hkFHP_3y_Z1JAOr0.webp" alt="image" /></p>
<p>每个进程都有两个内核维护的信号集：<code>pending</code>未决信号集 和 <code>blocked</code> 阻塞信号集</p>
<p>当一个信号产生时：</p>
<ol>
<li>
<p>首先检查该信号在blocked集中的对应位：</p>
<ul>
<li>如果为1（被阻塞）：将pending集中对应位设为1，信号暂时不会被递送</li>
<li>如果为0（未被阻塞）：立即尝试递送信号</li>
</ul>
</li>
<li>
<p>接收信号时：</p>
<ul>
<li>如果进程正在执行相同信号的处理程序，则将pending位设为1，等待当前处理完成</li>
<li>否则，调用信号处理程序</li>
</ul>
</li>
<li>
<p>信号处理完成后：</p>
<ul>
<li>内核检查pending集，如果有被阻塞的信号变为未阻塞，则接收它们</li>
<li>接收完成后，清除相应的pending位（设为0）</li>
</ul>
</li>
</ol>
<p>由于pending集采用位图实现，短时间内多次产生的相同信号：</p>
<ul>
<li>在pending集中只会记录一次（位图只能为0或1）</li>
<li>即使产生了多次，也只会被接收一次
这就是"标准信号不排队"的现象（注意：实时信号是排队的）</li>
</ul>
<h4>发送信号</h4>
<h5>进程组</h5>
<p>通过将每个进程都分配到某个进程组，得以实现对大量进程发送同一信号</p>
<pre><code>pid_t getpgrp();
int setpgid(pid_t pid, pid_t pgid);
</code></pre>
<p><code>getpgrp</code> 返回当前进程所在进程组编号；<code>setpgid</code>将pid进程所属的组设置为<code>pgid</code>，成功返回0，错误返回-1，pid为0时是当前进程，pgid为0时表示分配到以该进程pid为pgid的进程组</p>
<h5>信号的发送</h5>
<p>在shell中，可以使用<code>linux&gt;/bin/kill -SIG PID</code> 向PID发送SIG对应的信号，其中若PID为负数，则对应的进程为PID绝对值对应的进程组号
在C中，也可以使用<code>kill</code>函数</p>
<pre><code>int kill(pid_t pid, int sig);
</code></pre>
<p>用法与<code>/bin/kill</code>类似，只是两个参数位置相反，并且pid=0是是向当前进程所在组的所有进程发送该信号</p>
<pre><code>unsigned int alarm(unsigned int secs);
</code></pre>
<p>在secs秒后，内核向当前进程发送一个<code>SIGALRM</code>信号，新的闹钟会取代旧的闹钟，并返回旧闹钟剩余时间</p>
<h5>接收信号</h5>
<p>C中可以通过<code>signal</code>函数改变除<code>SIGSTOP</code>，<code>SIGKILL</code>以外的信号对应的信号处理程序的行为，原型如下</p>
<pre><code>typedef void (*sighandler_t)(int)
sighandler_t signal(int signum, sighandler_t handler);
</code></pre>
<p>该函数将<code>signum</code>对应的信号处理程序行为改为<code>handler</code>函数，若成功则返回之前的行为对应的函数，否则返回<code>SIG_ERR</code></p>
<p><code>handler</code>可以取以下参数：</p>
<ol>
<li><code>SIG_IGN</code> 将行为设置为忽略该信号</li>
<li><code>SIG_DFL</code>将行为设置为默认行为</li>
<li>传入一个自定义的函数，将行为设置为该函数</li>
</ol>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-021.CCTSkE3C_V0yXL.webp" alt="image" /></p>
<p>事实上，由于较老的Unix系统对信号的处理行为不同，<code>signal</code>函数可移植性比较差，而<code>sigaction</code>函数具有较好的可移植性，但是调用比较复杂。我们可以通过采用<code>sigaction</code>作为实现，定义与<code>signal</code>行为相同的函数</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-022.BMDkmAR6_Zq5CFx.webp" alt="image" /></p>
<h4>信号的阻塞</h4>
<p>隐式阻塞机制：标准信号不排队，会被阻塞
显式阻塞机制：使用 <code>sigprocmask</code>对<code>blocked</code>位进行修改</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-023.B5X_0y6G_oK6J3.webp" alt="image" /></p>
<p>注意：对<code>blocked</code>的修改，需要自己定义一个<code>sigset_t</code>的变量，通过<code>sigfillset</code>等函数对其进行构造，在将该变量作为参数传入<code>sigprocmask</code>中，实现对<code>blocked</code>的修改</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-024.D4AMnr3y_Z2n5x3D.webp" alt="image" /></p>
<h4>信号处理程序的编写原则</h4>
<p>信号处理程序与主程序并发运行，享用同样的全局变量，在编写的时候需要保持谨慎，遵守以下的保守原则</p>
<ol>
<li>信号处理程序要尽量简单，如只是设置标识符，将处理交给主函数</li>
<li>采用异步信号安全的函数，这些函数不会被中断，或者是可重入的</li>
</ol>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-025.g1NRuYmU_Z15HaAJ.webp" alt="image" /></p>
<p>3.运行前保存，运行后恢复<code>errno</code>：信号处理程序可能会修改<code>errno</code>的值，主函数中某些函数返回值可能与该值相关
4.对于所有的信号进行阻塞，然后再恢复原阻塞状态
5.使用<code>sig_atomic_t</code>和<code>volatile</code>定义全局变量，<code>volatile</code>告诉编译器该变量不稳定，不要放入寄存器中，而是直接内存访问，避免因为编译器过高的优化导致对该变量的行为改变；<code>sig_atomic_t</code>保证了该变量读写的原子性，即不会被中断
6.注意标准信号不排队机制：当内核检查到<code>pending</code>位为1时，可能已经接收了多个标准信号，需要对这些信号处理不止一次
例如以下的对<code>SIGCHLD</code>子进程终止的信号处理程序就使用<code>while</code>对信号进行多次处理，防止了僵尸进程</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-026.D2nTWSEt_26r7U6.webp" alt="image" /></p>
<p>7.进行流同步：由于信号处理程序和主函数并发运行，当两者之间存在依赖关系时，如主函数先执行<code>add</code>操作，信号处理程序执行<code>del</code>操作，如果信号处理程序先执行，就可能出错，我们称之为发生了<strong>竞争</strong>。我们可以通过将对应的信号阻塞，当主函数执行完毕后，再取消阻塞，就保证了流同步</p>
<h4>显式地等待信号</h4>
<p>在shell执行前台任务时，会一直等待直到该任务结束，即收到<code>SIGCHLD</code>信号。我们假设信号处理函数会将pid设置为<code>wait</code>函数的返回值，可以写出以下代码</p>
<pre><code>while (!pid) {

}
do something...
</code></pre>
<p>但是这段代码的缺陷是它大量执行了循环的判断，而不是子进程，这导致了处理器资源的浪费，考虑如下的代码</p>
<pre><code>while (!pid) {
	pause();
}
do something...
</code></pre>
<p>看起来这段代码将当前进程挂起，直到收到了<code>SIGCHLD</code>信号。但是如果<code>SIGCHLD</code>信号在条件检查和<code>pause</code>之间发生，就可能丢失唤醒，导致一直被挂起，所以我们需要让“解除阻塞并等待”这个操作具有原子性。可以使用<code>sigsuspend</code>函数</p>
<pre><code>int sigsuspend(const sigset_t *mask);
</code></pre>
<p>该函数暂时地将<code>blocked</code>设置为<code>mask</code>，并执行<code>pause</code>，结束后恢复原来的<code>blocked</code>，且该函数具有原子性</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-027.DolKUKYG_ZIf86v.webp" alt="image" /></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-028.DxhA9Lcv_2hEyvu.webp" alt="image" /></p>
<h3>非本地跳转</h3>
<p>C提供了一种用户级的异常控制流方式，称为非本地跳转，可以直接从一个正在执行的函数转移到另一个函数，使用<code>setjmp</code>，<code>longjmp</code>实现</p>
<pre><code>#include &lt;setjmp.h&gt;

int setjmp(jmp_buf env);
int longjmp(jmp_buf env, int retval);
</code></pre>
<p>通过<code>setjmp</code>，将当前的调用环境放入<code>env</code>中并且返回0；调用<code>longjmp</code>，将控制转移到<code>env</code>被<code>setjmp</code>，调用环境也设置为与<code>env</code>中的相同，并使得转移位置的<code>setjmp</code>返回值为<code>retval</code></p>
<p>注意<code>setjmp</code>不会保存<code>pending</code>和<code>blocked</code></p>
<p>通过非本地跳转，我们得以实现嵌套函数的退出</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-029.D7nEgzi6_Z1wFXOX.webp" alt="image" /></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-030.Bkrf-_uv_ZxyjqU.webp" alt="image" /></p>
<p>通过<code>sigsetjmp(env, savesigs)</code>配合<code>siglongjmp</code>，我们可以在需要时保存并恢复信号屏蔽字<code>blocked</code>；未决信号集<code>pending</code>不会作为跳转环境保存</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-ecf-ecf-image-031.CXD4Tl0L_1wrEqW.webp" alt="image" /></p>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="CSAPP"></category>
  </entry>
  <entry>
    <title>Linking</title>
    <link href="https://katyusha-blog.com/posts/csapp/notes/linking/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/csapp/notes/linking/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-11T00:00:00.000Z</updated>
    <summary>Linking</summary>
    <content type="html"><![CDATA[<h2>链接</h2>
<p>将各种代码和数据片段收集并组合成一个单一文件的过程称为链接
通过链接，我们得以将更小的，更易于管理调试的模块组合起来，形成大型程序，同时便于引入共享库，降低编码难度</p>
<h3>链接概述</h3>
<p><img src="https://katyusha-blog.com/_astro/csapp-linking-linking-image-001.D3BQmWLe_Z1PnUMI.webp" alt="image" /></p>
<p>在shell中，使用<code>linux&gt;gcc -o prog main.c sum.c</code> 调用编译器驱动程序，得到两个模块组合形成的程序prog
具体来说，从$.c$文件开始
1.预处理：驱动程序运行C预处理器(cpp)，对源代码进行替换(头文件包含，宏展开，条件编译<code>#if #ifdef #endif...</code>)，删除注释得到ASCII码的中间$.i$文件
2.编译：驱动程序运行C编译器(<code>cc1</code>)，将$.i$文件进行编译得到$.s$汇编语言文件
3.汇编：驱动程序运行汇编器(as)，将$.s$翻译成机器指令，得到可重定位文件 $.o$
4.链接：驱动程序运行链接器将<code>.o</code>文件和必要的系统目标文件组合在一起，生成可执行文件
5.运行：使用<code>linux&gt;./prog</code> 调用操作系统中的加载器，将指令和数据加载到内存中，并控制转移到这个程序的开头</p>
<h3>静态链接</h3>
<p>静态链接器由一组可重定位目标文件和命令行参数作为输入，生成完全链接的可执行文件
链接器完成以下两个任务：
1.符号解析：将一个符号和一个符号定义关联起来(是全局变量，静态变量，或者函数)
2.重定位：将每个符号定义与一个内存位置关联起来，然后修改所有对这些符号的引用，使它们指向这个内存位置
x86-64 linux使用可执行可链接格式(Executable and Linkable Format，ELF)作为目标文件的格式，下文以其为例</p>
<h4>可重定位目标文件的格式</h4>
<p><img src="https://katyusha-blog.com/_astro/csapp-linking-linking-image-002._7m4Iozi_Z79RBb.webp" alt="image" /></p>
<ul>
<li>ELF头：描述生成该ELF文件的系统的信息，以及该ELF文件的一些基本信息</li>
<li>.text：已编译生成的机器代码</li>
<li>.rodata：只读数据，如常量，printf输出的字符串等</li>
<li>.data：被初始化的全局和静态变量</li>
<li>.bss：未初始化的静态变量或初始化为0的全局和静态变量，这些变量运行时占用内存，但在目标文件中通常只记录大小而不占用实际数据字节</li>
<li>.symtab：存放在该程序中定义和引用的全局变量，静态变量和函数的信息</li>
<li>.rel.text .text部分中需要重定位的位置(引用的外部函数)</li>
<li>.rel.data .data中需要被重定位的位置(引用的外部全局变量)</li>
<li>.debug .line 编译选项-g得到的调试信息</li>
<li>.strtab 存储.symtab和.debug中符号的一个字符串表</li>
</ul>
<h5>符号的类型</h5>
<p>symtab包含以下三种符号：</p>
<ol>
<li>全局符号：由该模块定义，并被其他程序引用的符号</li>
<li>外部符号：由其他模块定义，在该程序中被引用的符号</li>
<li>局部符号：在该模块中定义的静态函数和变量(static)</li>
</ol>
<p>本地变量不会包含在symtab中，而是会在运行栈中被管理；而使用static的符号虽然在symtab中，但是是该模块私有的，不会被其他模块引用</p>
<h5>symtab的构成</h5>
<p>symtab中包含多个符号，每个符号用一个如下结构的条目表示</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-linking-linking-image-003.9K__plub_A3Lha.webp" alt="image" /></p>
<ul>
<li>name：该条目符号在.strtab中的偏移量，从.strtab中这个位置开始一直取字符直到遇到结束符</li>
<li>type：该符号为函数还是变量</li>
<li>binding：该符号是在该模块中定义的还是从外部引用的</li>
<li>section：表明该符号属于哪一节</li>
<li>value：该符号相对于其所在节的地址偏移量</li>
<li>size：该符号的字节大小</li>
</ul>
<p>注意<code>section</code>字段指明了该符号属于哪一节，但除了<code>.data</code>，<code>.text</code>，<code>.bss</code>，<code>.rodata</code>外，它还可能指向三个在ELF中不存在的伪节
1.<code>ABS</code>表示该符号不应该被重定位
2.<code>UNDEF</code>表示在该模块中未定义，即从外部模块引用的符号
3.<code>COMMON</code>表示未初始化的全局变量的试探性定义；对比<code>.bss</code>通常保存未初始化的静态变量以及初始化为0的全局和静态变量</p>
<p>我们可以使用<code>GNU READELF</code>程序查看目标文件内容，一个示例如下</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-linking-linking-image-004.BcNvuVlN_1r0u3M.webp" alt="image" /></p>
<h4>符号解析</h4>
<p><strong>强符号</strong>：函数和已初始化的全局变量
<strong>弱符号</strong>：未初始化的全局变量
不难发现，对于变量来说，弱符号都被分配到<code>COMMON</code>节</p>
<p>链接器使用以下规则处理多重定义的符号：</p>
<ol>
<li>不允许有多个同名的强符号</li>
<li>如果有一个强符号和多个弱符号同名，选择强符号</li>
<li>如果有多个弱符号同名，任意选择一个</li>
</ol>
<p>以下是一个由该规则可能导致的错误</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-linking-linking-image-005.CwEPbKP1_ZFNsj8.webp" alt="image" /></p>
<p>两个模块中，第一个x是强符号，而第二个是COMMON，链接器将它们合并，并按较大的对象大小分配存储，那么对 <code>x</code> 的写入可能覆盖相邻数据，例如修改到 <code>y</code></p>
<p>在C中，可以使用<code>gcc -fno-common</code>这样的选项，让链接器在遇到多重定义的全局符号时报错；而在C++中不支持<code>COMMON</code>，相当于默认使用该行为</p>
<h4>与静态库链接</h4>
<p>静态库是相关目标文件(.o文件)的集合，以存档格式(.a文件)存储，链接静态库时，链接器只复制被程序引用的目标模块
使用AR工具，可以创建函数的一个静态库
<code>linux&gt; gcc -c addvec.c multvec.c</code>
<code>linux&gt; ar rcs libvector.a addvec.o multvec.o</code>
要使用这个静态库，我们直接在<code>main.c</code>中加入<code>#include "vector.h"</code>即可调用<code>addvec</code>，<code>multvec</code>这两个函数(无需声明原型)
然后再使用<code>-static</code>使链接器进行静态链接
<code>linux&gt; gcc -c main.c</code>
<code>linux&gt; gcc -static -o prog main.o ./libvector.a</code></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-linking-linking-image-006.CWeLVFyz_Z1p9eg.webp" alt="image" /></p>
<h5>链接器静态链接的行为</h5>
<p>对于输入文件，链接器按输入顺序从左到右扫描
若当前文件是一个<code>.o</code>目标文件，链接器会无条件把它加入链接，并用其中的定义更新已定义符号集合和未解析符号集合
若当前文件是一个<code>.a</code>存档文件，链接器会扫描其中的目标模块；只有某个成员定义了当前未解析的符号时，才会把该成员加入链接，并继续更新符号集合
结束后，若集合不为空，链接器报错；否则执行合并与重定位，生成可执行文件</p>
<p>由该过程可见，链接存在依赖关系，符号应该先引用，后定义；同时链接器支持重复库，即同一个库多次输入在可以在链接器中不同位置</p>
<h4>重定位</h4>
<p>完成符号解析后，链接器进行重定位，包含两个关键步骤：</p>
<ol>
<li>合并输入模块得到聚合节，并为每个聚合节和其中的符号分配运行时内存地址</li>
<li>根据重定位条目，修改每个符号的引用，使它们指向正确的运行时地址</li>
</ol>
<h4>重定位条目</h4>
<p>重定位条目存在于<code>.rel.data</code>和<code>.rel.text</code>中，作用是告知链接器如何对于一个外部符号进行修改，其结构如下</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-linking-linking-image-007.DKEyKYCD_Z1jw4Ec.webp" alt="image" /></p>
<p>重定位的算法描述如下</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-linking-linking-image-008.BDqLVioG_Z1D14Cy.webp" alt="image" /></p>
<p>为了便于理解，我们直接举下面的例子</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-linking-linking-image-009.-s9A7vkl_Z1suGGl.webp" alt="image" /></p>
<p>注意：linux x86-64使用小端序！！！
<code>array</code>是一个外部变量，我们想要将它的地址作为参数传入<code>%edi</code>中，但是由于地址未知，所以32位都用0来填充，并在这里留下一个类型为<code>R_X86_64_32</code>的重定位条目，表示进行32位绝对重定位。该条目的<code>offset</code>偏移值为$0xa$，即重定位会从该偏移值开始填充32位，<code>addend</code>为0（结构体访问成员时可能不为0）。<code>refptr</code>指向这段填充的0的起始位置，然后将从这个位置开始的4个字节赋为该符号分配的地址的值，得到<code>array</code>的正确地址，并传入<code>sum</code>函数作为参数</p>
<p>同样由于<code>sum</code>的具体代码的内存地址未知，使用0填充并留下一个类型为<code>R_X86_64_PC32</code>的重定位条目，表示进行32位相对重定位(因为<code>call</code> 一般使用相对寻址)，当执行<code>call</code>时，PC指向下一条指令的开头，即PC增加了4，为了补偿这个增量，我们将<code>addend</code>设置为-4，<code>refptr</code>指向这段填充的0的起始位置，然后计算<code>.text</code>聚合节中分配运行内存后，<code>sum</code>函数相对于<code>.text</code>的地址偏移量，并加上<code>addend</code>作为补偿，得到的就是在分配的内存中PC跳转到<code>sum</code>需要的地址偏移量，将这个值存入<code>refptr</code>指向的后4个字节中。当调用<code>sum</code>函数时，PC的值增加这个偏移量，刚好是<code>sum</code>函数的第一条指令</p>
<p>经过重定位，最后得到可执行目标文件</p>
<h3>可执行目标文件</h3>
<p>ELF可执行文件被设计为易于加载到内存，格式如下：</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-linking-linking-image-010.nTb3E92p_WdTbg.webp" alt="image" /></p>
<ul>
<li>ELF头部：描述文件的总体格式，包括程序入口点（entry point）</li>
<li>段头部表：描述可执行文件中的片（segment）到内存段的映射关系</li>
<li>.init：定义初始化代码</li>
<li>.text、.rodata、.data、.bss：程序代码和数据</li>
<li>其他节：如.symtab、.debug等</li>
</ul>
<p>可执行文件通过加载器映射到内存中并运行：</p>
<ol>
<li>创建进程和地址空间</li>
<li>将可执行文件的片（segment）映射到相应的虚拟内存区域，必要时按需调页载入</li>
<li>跳转到程序的入口点（通常是_start）</li>
</ol>
<p>在Linux x86-64系统中，程序入口点不是<code>main</code>函数，而是<code>_start</code>函数，它调用<code>__libc_start_main</code>，后者再调用<code>main</code>函数</p>
<p>运行时的内存分配情况如下：</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-linking-linking-image-011.RCnyFVUP_y5VQs.webp" alt="image" /></p>
<h3>动态链接</h3>
<p>共享库是一个目标模块，该模块在运行的时候可以加载到任意地址，和一个内存中的程序链接起来，该过程称为动态链接
该技术节省了空间，不需要在编译时将库中相应的文件链接过来，而是在运行前或运行中才进行链接；同时也方便了库的更新，不需要每次更新库的时候都对目标文件重新链接</p>
<p>在linux下，动态库文件扩展名为<code>.so</code>，windows中为<code>.dll</code></p>
<p><code>linux&gt; gcc -shared -fpic -o libvector.so addvec.c multvec.c </code>
<code>linux&gt; gcc -o prog main.c ./libvector.so</code>
得到在运行时，可以与<code>libvector.so</code>进行动态链接的可执行文件<code>prog</code></p>
<h4>动态链接的过程</h4>
<p>首先，执行一次静态的链接，生成的部分链接的可执行文件中含有共享库中的重定位和符号表信息，但是没有符号具体的定义
在执行这个部分链接的可执行文件前或运行中，动态链接器动态地将共享库中符号具体定义链接进来，内存中即为完全链接的可执行文件</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-linking-linking-image-012.B0_-Sh3y_ZBHsSd.webp" alt="image" /></p>
<p>linux系统提供了相关的程序接口<code>dlopen()、dlsym()、dlclose()、dlerror()</code>，可以在程序运行中进行动态链接</p>
<h4>位置无关代码</h4>
<p>咕咕咕</p>
<h4>库打桩机制</h4>
<p>打桩允许我们截获对共享库函数的调用，取而代之执行自己的代码，这对于调试大型项目时极其有效</p>
<p>打桩可以在三个不同的阶段进行：</p>
<ol>
<li>编译时打桩</li>
</ol>
<p>例如，为了对于程</p>
<pre><code>gcc -DCOMPILETIME -c mymalloc.c
gcc -I. -o prog main.c mymalloc.o
</code></pre>
<ol>
<li>链接时打桩</li>
</ol>
<pre><code>gcc -DLINKTIME -c mymalloc.c
gcc -c main.c
gcc -Wl,--wrap,malloc -Wl,--wrap,free -o prog main.o mymalloc.o
</code></pre>
<ol>
<li>运行时打桩</li>
</ol>
<pre><code>#define _GNU_SOURCE
#include &lt;dlfcn.h&gt;

void *malloc(size_t size) {
    void *(*mallocp)(size_t size);
    char *error;
    
    mallocp = dlsym(RTLD_NEXT, "malloc"); // 获取libc的malloc
    // ... 打桩代码 ...
    return (*mallocp)(size);
}
</code></pre>
<p>这一章看下来真的太难受了（（（</p>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="CSAPP"></category>
  </entry>
  <entry>
    <title>memory</title>
    <link href="https://katyusha-blog.com/posts/csapp/notes/memory/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/csapp/notes/memory/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-03T00:00:00.000Z</updated>
    <summary>memory</summary>
    <content type="html"><![CDATA[<h2>存储器层次结构</h2>
<p>存储器系统是一个具有不同容量，成本和访问时间的存储设备的层次系统
整体效果是一个大的储存器池，但却以接近最便宜存储设备的成本，做到了接近顶层存储设备的读写效率
存储器层次由快到慢分别为寄存器，高速缓存存储器，主存储器，本地二级存储器，远程二级存储器</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-memory-image-001.CyGmPyt2_23OAea.webp" alt="image" /></p>
<h3>存储技术</h3>
<h4>RAM</h4>
<p>随机访问存储器(Random Access Memory)可分为<strong>DRAM</strong>(Dynamic)和<strong>SRAM</strong>(Static)两类
SRAM 使用六晶体管电路实现，具有双稳态性，只要有电就会保持它的值，在干扰结束时，也会回到稳定状态
DRAM 对干扰十分敏感，易漏电，必须周期性刷新以保持数据；纠错码可以检测或纠正部分位错误，但不能替代刷新</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-memory-image-002.swZA4ssa_Z17GkC1.webp" alt="image" /></p>
<h5>DRAM的读写</h5>
<p>每个DRAM单元存储一位信息，$w$个DRAM单元组成一个超单元，再由$d$个超单元组成一个DRAM芯片
$d$个超单元构成一个$r \times c$的二维阵列，每个超单元有由行列构成的二元组地址$(i, j)$
DRAM芯片通过地址引脚和数据引脚与外部传递信息，通过两次在地址引脚上传入行地址和列地址来确定要读写的超单元，再通过数据引脚传递具体数据。将超单元设计为二维，减少了需要的地址引脚个数，代价是地址需要分两次传入，降低了效率</p>
<h5>内存模块</h5>
<p>内存模块中封装了多个DRAM芯片，我们以8个8M的DRAM芯片，每个超单元存储1个字节为例
当需要访问一个地址为A的字的时候，内存控制器将A翻译成超单元地址$(i,j)$并发送到内存模块中，内存模块再将$(i,j)$广播到每一个DRAM芯片，8个芯片都取出它地址为$(i,j)$的超单元中存储的字节，再将这八个字节合并起来，传送到内存控制器中
将一个字存储在不同的DRAM芯片内的好处：通过提高并行性提高了访问效率，同时减少整个字丢失的可能，提高了可靠性</p>
<h4>ROM</h4>
<p>只读存储器(Read Only Memory) 部分的ROM读写都支持，但是所有的ROM都是非易失性的</p>
<p>PROM (Programmable ROM) 可编程ROM只能使用高电流编程一次
EPROM(Erasable PROM) 可擦写可编程ROM 与 EEPROM(Electrically EPROM) 电子可擦除ROM都能使用特殊手段擦除数据重新编程，但次数有限
flash memory 闪存基于EEPROM，如固态硬盘</p>
<p>ROM中的程序被称为固件(firmware)，在通电下运行，如PC的BIOS</p>
<h4>主存的访问</h4>
<p>数据流通过总线(bus)在处理器和主存之间传送，读事务中数据从主存传送到处理器，写事务中数据从处理器传送到主存
处理器通过系统总线与I/O桥接器连接，主存通过内存总线与I/O桥接器连接，I/O桥接器通过将系统总线和内存总线传入的信号进行翻译
同时，I/O桥接器还连接了I/O总线，我们会在下文提到
以读事务为例</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-memory-image-003.BSt-4VzA_27BeXC.webp" alt="image" /></p>
<h4>磁盘的存储</h4>
<h5>磁盘的构造</h5>
<p>每个磁盘包含了一个或多个<strong>盘片</strong>，每个盘片有着两个<strong>盘面</strong>，盘片绕着中心的主轴，以固定的角速度旋转
每个盘片包含了多个被称为<strong>磁道</strong>的同心圆，每个磁道上都有着多个用来存储信息的<strong>扇区</strong>，每个扇区存储的信息量大小相同，扇区间由用来标记格式化位的<strong>间隙</strong>分开
我们用<strong>柱面</strong>称呼到主轴间距相等的磁道构成的集合</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-memory-image-004.DSpN5aa1_2lVoBv.webp" alt="image" /></p>
<h5>磁盘的存储</h5>
<p>磁盘存储的信息量取决于面密度，即单位面积内可存储的位数；它通常由磁道密度和每条磁道上的线性位密度共同决定</p>
<p>为了简化控制，磁道的扇区数需要尽量保持相等
在技术不发达，面密度比较低的时期，每个磁道的扇区数完全相同，这导致外围的扇区间隙相比内围大了很多，造成了不必要的浪费
现代磁盘使用多区记录的技术，在每个<strong>记录区</strong>中保存了一段连续的柱面，每个记录区中的磁道扇区数相同，记录区间的扇区数不同。通过多区记录，达到了存储空间和控制难度的平衡</p>
<p>为什么标注1TB大小的硬盘电脑显示只有931GB？
这是制造商使用的十进制（$1TB = 1000^4$ 字节）与操作系统使用的二进制（$1TiB = 1024^4$ 字节 $\approx 931GiB$）之间的差异。</p>
<h5>磁盘的读写</h5>
<p>磁盘中的<strong>读/写头</strong>连接到外围的传动臂上，通过绕着传动臂转动，进行寻道以定位到某个给定的磁道上，当磁道上的某个扇区旋转到读写头正下方时，就可以对其进行读写操作</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-memory-image-005.Cf4gC_iQ_1Ck1g0.webp" alt="image" /></p>
<p>对一个扇区的访问时间，取决于一下三个部分：
1.寻道时间：传动臂将读写头移动到指定磁道的时间，这个值取决于读写头之前的位置和传动臂移动的速度
2.旋转时间：等待需要操作的扇区旋转到读写头的正下方，这取决于盘片旋转的角速度和此时目标扇区的位置
3.传送时间：这个扇区从头到尾经过读写头需要的时间，这取决于盘片旋转的角速度</p>
<p>计算的一个示例</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-memory-image-006.DwRHZ557_ZOAyA7.webp" alt="image" /></p>
<p>可见读写的时间主要取决于寻道时间和旋转时间</p>
<p>磁盘经过封装得到<strong>硬盘驱动器</strong>，简称<strong>硬盘</strong>，“硬盘”一词不加修饰时，通常指基于磁盘技术的机械硬盘（Hard Disk Drive, <strong>HDD</strong>），但需注意与固态硬盘（SSD）区分</p>
<h5>逻辑磁盘块</h5>
<p>在逻辑上，我们可以将扇区看成一个大小为$B$的单元结构，使用一个逻辑块号进行寻址，磁盘控制器从操作系统接收这个逻辑块号，将其翻译成一个(盘面，磁道，扇区)的三元组，再对其进行寻址并进行操作</p>
<h4>IO设备的连接</h4>
<p>现代计算机系统使用PCIe（Peripheral Component Interconnect Express）总线作为主要的I/O总线标准，它取代了早期的PCI总线，将包括键鼠，图形卡，磁盘等在内的I/O设备连接到CPU和主存
有几种不同的IO设备使用I/O总线连接
1.外围I/O设备如键鼠，固态硬盘，打印机等通过通用串行总线(Universal Serial Bus,<strong>USB</strong>)连接到USB控制器
2.图形卡(由GPU显卡，散热器，PCB板等组成)
3.机械磁盘通过SCSI(更快更贵)或者SATA接口连接到主机总线适配器，从而连接到I/O总线
4.其他设备如网络适配器等通过主板上的拓展槽连接到I/O总线中</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-memory-image-007.CiUhVzWt_139B5f.webp" alt="image" /></p>
<h4>CPU与IO设备的交互</h4>
<p>CPU通过特定的地址与I/O设备通信。这些地址可能属于独立的I/O端口地址空间，也可能被映射到物理内存地址空间中（称为内存映射I/O）
以磁盘读为例，CPU向磁盘对应的I/O端口发出三条指令，分别是指令字，逻辑块号，存放在主存中的地址
磁盘控制器接收这些信号翻译得到扇区地址，读扇区后无需CPU干涉直接将数据传输到主存，这称为直接内存访问(Direct Memory Access DMA)
磁盘读取的时间远远大于处理器执行其它指令的时间，通过直接内存访问，避免了CPU的等待
DMA传送完成后，磁盘控制器向CPU发送一个中断信号，处理器转入相应的中断处理程序记录I/O操作已经完成，随后再恢复被中断的控制流</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-memory-image-008.Bt7wCqui_ZRUkXv.webp" alt="image" /></p>
<h4>固态硬盘</h4>
<p>固态硬盘(Solid State Disk, <strong>SSD</strong>)封装在I/O总线的插槽上(计算机内部)，采用闪存技术，一个SSD封装由一个或多个闪存芯片和一个闪存翻译器构成，其中
闪存芯片功能上类似于机械磁盘的机械驱动器，而闪存翻译器则类似于磁盘控制器</p>
<p>一个闪存由B个块构成，每个块内又有P个页，只有当一个页所在的块被擦除(所有位都被设置为1)才能对该页进行写，这导致SSD写的效率低于读。同时由于闪存基于EEPROM，所以写入次数有限，现代SSD采用了复杂的逻辑，减小了擦写块的时间代价，以及尝试让块的擦写尽量均匀，增加了SSD的寿命</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-memory-image-009.BkQoe6Y4_Z1oywOK.webp" alt="image" /></p>
<p>SSD价格上比机械硬盘稍贵，并且写入次数有限，但是SSD读写速度远高于机械硬盘，并且随着SSD越来越受欢迎，其与机械硬盘价格差距也越来越小</p>
<h3>局部性</h3>
<h4>数据的局部性</h4>
<p>对内存层次中较低层的访问代价很高，所以局部性通常是影响程序性能的重要因素之一</p>
<p>良好的时间局部性：一个内存位置被引用后，不久之后被再次引用
良好的空间局部性：一个内存位置被引用后，不久之后其附近的位置被再次引用</p>
<p>例如考虑以下代码：</p>
<pre><code>int sum = 0;
for (int i = 1; i &lt;= n; i++) {
	for (int j = 1; j &lt;= n; j++) {
		sum += a[i][j];
	}
}

sum = 0;
for (int j = 1; j &lt;= n; j++) {
	for (int i = 1; i &lt;= n; i++) {
		sum += a[i][j];
	}
}
</code></pre>
<p>第一段循环局部性明显优于第二段，因为第一段按引用步长为1访问数组，而第二段按引用步长为n访问数组</p>
<h4>指令的局部性</h4>
<p>指令也是存储在内存中的，按照内存顺序执行的指令有着良好的局部性，往往循环体越小，循环迭代次数越多，局部性越好</p>
<h3>高速缓存</h3>
<h4>缓存的基本原理</h4>
<h5>缓存的概念</h5>
<p>存储器的每一层都是下一层更大更慢存储器中数据的一个缓冲空间，该过程被称为缓存</p>
<p>相邻的两层会以相同大小的<strong>块</strong>作为传输单位，更上层的存储器维护的是更底层存储器中若干块的副本，数据以块为基本单位在两层之间传输。不相邻层次块的大小可能不同</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-memory-image-010.vFhSts61_1iFhwG.webp" alt="image" /></p>
<h5>缓存访问概述</h5>
<p>1.缓存命中：当需要访问第$k+1$层中块号为$A$的数据时，先在第$k$层中查找是否保存了该数据，如果存在直接高效访问即可，我们称之为缓存命中</p>
<p>2.缓存不命中：当第$k$层中没有该数据时，就从第$k+1$层读出$A$号块。若能采用某种<strong>放置策略</strong>，将$A$号块放入一个空块，则就这样放置并访问；否则采取某种<strong>替换策略</strong>，用$A$号块替换$k$中某个块并访问</p>
<p>3.放置策略与替换策略：为了降低采用策略的代价，这两种策略一般都比较简单。如放置策略就采取简单地将$k+1$层中的每个块都放入在$k$层映射所对应的块中(如对块编号取模得到的数对应的块)，替换策略采用随机替换，最不常使用替换，最近最少使用替换等策略</p>
<p>4.缓存不命中的种类：第一次访问某个块时，缓存中必然没有它，我们称之为<strong>冷不命中</strong>；当放置策略使多个常用块竞争同一组时，可能导致<strong>冲突不命中</strong>，如只有4个组的直接映射缓存反复访问第0、4、0、4......号块，缓存就会一直不命中；当需要访问的数据构成的工作集太大，大于缓存能容纳的块数时发生的缓存不命中，称为<strong>容量不命中</strong></p>
<p>5.缓存的管理：管理缓存的逻辑可以是硬件，软件，或者是两者的结合</p>
<h4>缓存的结构</h4>
<p>现代计算机采用了L1，L2，L3三层缓存，为了方便起见，我们简化为只有L1缓存</p>
<h5>缓存的通用结构</h5>
<p>以在一个m位的系统中进行缓存$k$级读为例</p>
<p>对于一个高速缓存，我们将其分为$S=2^s$个组，每个组内有$E$行，每一行有着一个1位的有效位，$t$位的标记位，以及$B=2^b$个字节的数据块，下标分别为$0, 1, \ldots, B-1$，我们称缓存的数据容量为$C = B \times E \times S$</p>
<p>我们该系统地址有m位，即有$M=2^m$个地址，那么我们用会通过一个长$m$位的数$A$来确定访问的地址
考虑将这个$A$分成三个部分，分别是前$t$位作为标记，中间$s$位作为组索引，最后$b$位作为字节偏移值，满足$m=t+s+b$</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-memory-image-011.JP4x5DGF_Z18YMNJ.webp" alt="image" /></p>
<p>通过组索引定位到缓存中的某一组，再并行地与组中的每一行进行比较，当这一行有效位为1且标记和标记位相等时，以字节偏移值为下标读出的这个字节就是需要读出的值</p>
<p>否则每一行都是有效位为0或和标记位不相等，这说明需要读的数不在$k$级缓存中，从$k+1$中重复该步骤，尝试读出该地址的值，在$k$级缓存中进行放入或者替换即可</p>
<p>本质上是地址到一个能唯一确定某个块的三元组的映射，但是完美利用了二进制的性质，发明这个的人真的是天才，计组真的太美了（</p>
<p>补充：为什么不使用更自然的高s位作为组索引，中间t位作为标记，后b位作为偏移值？
若采用高s位为组索引，那么以连续访问地址为0到$2^{t+b}-1$的数据为例，这些数全都被分到了第一组，没有利用了其它组的，会多次发生缓存不命中，局部性良好的代码反而因此效率降低</p>
<h5>缓存的各种类型</h5>
<p>1.直接映射高速缓存：取 $E=1$，即每组只有一行，此时因为只有一行，替换策略极度简化，直接将新的数据块放入该行即可
2.全相联高速缓存：取$E = \frac{C}{B}$，即只有一组，故可以舍去组索引位，理论上可以对每一行并行判断，但是行数过多，成本昂贵，只适合规模小的高速缓存
3.组相联高速缓存：取 $1 &lt; E &lt; \frac{C}{B}$，即有多个组，每组有多个行，可以在组内并行地判断每一行，同时采取随机替换，最不常使用，最近最少使用等替换策略</p>
<h5>缓存写</h5>
<p>当需要进行写操作时，若对应地址在$k$级缓存中，即缓存命中，则有以下两种方法：
1.直写：同时更新第$k$层和第$k+1$层中的副本。该方法实现简单，但会增加下层访问流量
2.回写：类似于惰性删除，每一行维护一个初始为0的修改位，写的时候只在第$k$层进行写并将修改位设置为1，直到该行要被替换的时候再将修改后的块写回第$k+1$层。该方法减少了下层写流量，但实现更复杂
当缓存不命中时，同样有两种方法：
1.写分配：从第$k+1$层读入包含写地址的块到第$k$层，再在第$k$层执行写操作，常与回写搭配
2.非写分配：不把该块载入第$k$层，而是直接把写操作传递给第$k+1$层，常与直写搭配</p>
<h5>实际的缓存结构</h5>
<p>高速缓存既保存数据(d-cache)，又保存指令(i-cache)，也有数据和指令都保存的统一高速缓存，由于d-cache读写都需要，而i-cache只读，所以分开设计，采用不同的访问模式来优化这两种缓存</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-memory-image-012.BwI_zHlN_ZAPFK3.webp" alt="image" /></p>
<h4>影响缓存效率的因素</h4>
<p>1.高速缓存的大小：增大某一层缓存的大小，可以提高缓存命中率，但是每次缓存访问的时间会增加
2.缓存块大小的影响：在缓存容量固定的情况下，增大块的大小会导致行数减少。在空间局部性优秀的情况下会减少缓存不命中的概率，但是更大的块导致了单次拷贝消耗时间更长，缓存不命中的时候惩罚更大
3.相联度(一组内的行数)：相联度增大后，更不容易发生冲突不命中，但是增加了标记位的位数，以及需要采用更合理的逻辑选择替换行，成本也会增加</p>
<h3>高速缓存友好的代码</h3>
<h4>引用步长的影响</h4>
<p>假设缓存的块大小为$B$字节，对于一个引用步长为$k$的循环，每次迭代的平均缓存不命中次数为$\min(1, \frac{wordsize \times k}{B})$
理解为缓存中最多存放$\frac{B}{wordsize}$个字，所以每$\frac{B}{wordsize \times k}$次循环就会出现缓存不命中，即每次迭代平均不命中$\frac{wordsize \times k}{B}$次
由此可知，取步长$k=1$对于缓存来说命中率最高</p>
<h4>重新排列循环变量</h4>
<p>我们以矩阵乘法的六种循环顺序为例</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-memory-image-013.pZ12VEBT_Z1m80S.webp" alt="image" /></p>
<p>判断程序的性能，我们往往只需要关注最内层循环体的效率，可以先定性地来看
ijk和jik对A数组采用步长为1的访问，对B数组采用步长为n的访问
jki和kji对A数组和C数组都采用了步长为n的访问
kij和ikj对B数组和C数组都采用了步长为1的访问
所以可以得出结论：kij和ikj访问缓存效率最高，ijk和jik次之，jki和kji最差</p>
<p>定量地来看，我们假设缓存可以存放4个double，计算得到的效率表如下，这也印证了我们的分析</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-memory-memory-image-014._-0vRoGO_jYQ0M.webp" alt="image" /></p>
<h4>使用分块技术</h4>
<p>当工作集大于缓存大小的时候，可以通过将工作集划分为多个块，块的大小不大于缓存的大小，能被完全放入缓存当中，高效地对这个块进行访问之后，丢掉这个块，继续载入访问下一个块</p>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="CSAPP"></category>
  </entry>
  <entry>
    <title>optimize</title>
    <link href="https://katyusha-blog.com/posts/csapp/notes/optimize/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/csapp/notes/optimize/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-05-25T00:00:00.000Z</updated>
    <summary>optimize</summary>
    <content type="html"><![CDATA[<h2>优化程序性能</h2>
<h3>程序剖析</h3>
<p>linux下可以使用$GPROF$定量衡量程序中函数的运行时间</p>
<p>在编译的时候，使用<code>linux&gt; gcc -Og -pg test.c -o test</code>，避免优化导致剖析失真，得到为剖析而编译和链接的可执行文件
<code>linux&gt; ./test</code>会运行该文件并生成<code>gmon.out</code>的相关数据文件，通过<code>linux&gt; gprof test</code>进行分析</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-optimize-optimize-image-001.CeAIkBsE_2vPthh.webp" alt="image" /></p>
<p>从左到右的每一列分别为函数运行时间占比，总累积运行时间，当前函数累积运行时间，被调用次数，每次调用平均运行时间</p>
<h3>程序优化的基本原则</h3>
<p>1.选用合适的算法与数据结构
2.以编译器更容易优化的形式编写程序
3.将一个任务分成多个部分，并行地计算</p>
<h3>编译器级别的优化</h3>
<h4>指定优化等级</h4>
<p>以<code>GCC</code>为例，编译时命令行选项从<code>-Og -O1 -O2 -O3</code>优化等级逐步提高，编译器会在行为一致的情况下，对程序进行安全的优化</p>
<h4>函数内联优化</h4>
<p>当使用<code>-O1</code>以及更高的优化或者<code>-finline</code>的时候，编译器会使用内联函数替换来减少函数的调用
例：</p>
<pre><code>int cnt = 0;
int f() {
	return cnt++;
}
int f1() {
	return f() + f() + f() + f();
}
</code></pre>
<p>采用循环展开后，会被优化为</p>
<pre><code>int f1() {
	int ret = cnt * 4 + 6;
	cnt += 4;
	return ret;
}
</code></pre>
<h4>编译器级别优化的局限性</h4>
<p>编译器对于程序只使用安全的优化，在有<strong>内存别名使用</strong> (两个指针指向同一个地址)或者<strong>全局变量修改</strong>时，一些激进的优化可能导致程序行为发生改变，编译器不会采用这些优化</p>
<pre><code>void f1(int *p1, int *p2) {
	*p1 += *p2;
	*p1 += *p2;
}

void f2(int *p1, int *p2) {
	*p1 += 2 * *p2;
}
</code></pre>
<p>在p1,p2指向同一个地址时，若初始值为$x$，<code>f1</code>会先变为$2x$再变为$4x$，而<code>f2</code>会变为$3x$</p>
<pre><code>int cnt = 0;
int f() {
	return cnt++;
}
int f1() {
	return f() + f() + f() + f();
}
int f2() {
	return 4 * f();
}
</code></pre>
<p>编译器无法判断f函数是否会对全局变量造成影响，所以为了保证正确性，不会将f1优化为f2
可见编译器只会进行最基本的优化，我们需要通过改变编写程序的方式来激发其性能</p>
<h3>程序的性能</h3>
<h4>程序性能的衡量</h4>
<p>程序的效率 <strong>CPE</strong>：<strong>Cycles Per Element</strong> 每元素周期数
其中的元素为程序处理的基本数据单元，可以是一个整数，也可能是字符，像素等
显然CPE越小，程序越高效</p>
<h4>对现代处理器的理解</h4>
<p>处理器的效率用其时钟频率决定，通用单位为千兆赫兹<strong>GHZ</strong>，即十亿周期每秒
现代处理器通过乱序处理实现指令级并行，即多条指令可以并行地执行，但同时又呈现出顺序执行的表象
更具体地说，现代处理器中有多个功能单元，不同功能单元被设计处理不同的操作，以下是一个例子</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-optimize-optimize-image-002.BhrA0Hgd_RfCPy.webp" alt="image" /></p>
<p>处理器为了乱序执行的正确性，会在一条指令的所有源操作数都准备就绪，且所需要的功能单元有空闲的时候，将其用调度器发送出去并执行
因此，指令的执行顺序与程序顺序无绝对关系，而与数据就绪时间有关</p>
<h4>功能单元的性能</h4>
<p>延迟：完成一个运算所需要的总时钟周期
发射时间：两个同类型的运算间需要间隔的最小时钟周期。当发射时间为1时，我们称该功能单元运算完全流水化，即该运算的每个阶段逻辑上独立
容量：能执行该运算的功能单元数量
最大吞吐量：发射时间的倒数</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-optimize-optimize-image-003.CMxo4mJ8_ZYV8DO.webp" alt="image" /></p>
<p>延迟界限给出了单个操作CPE的下界，而吞吐量界限给出的是处理一系列操作时CPE的下界</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-optimize-optimize-image-004.BiOeQX0V_Z1H3Al3.webp" alt="image" /></p>
<p>如整数乘法只有一个乘法单元，故吞吐量界限为1
整数加法虽然有四个加法单元，但是受限于只有两个加载单元，导致每个时钟周期最多取两个数据，所以吞吐量界限为0.5</p>
<h3>代码级别的优化</h3>
<p>以下代码以数组变量累积为例，给出最原始的版本</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-optimize-optimize-image-005.zPA24O12_Z1ghvDV.webp" alt="image" /></p>
<h4>减少重复的运算和调用</h4>
<h5>代码移动</h5>
<p>由于循环没有对向量内容进行修改，我们可以提前保存向量的长度这个值，避免反复调用函数造成不必要的开销
该技巧被称为<strong>代码移动</strong>，因为编译器无法确认函数内部是否会对全局状态造成影响，所以默认不会优化
致敬某传奇OI教练在当年重邮打ACM时候写出的<code>for (int i = 1; i &lt;= strlen(s); i++)</code></p>
<h5>保存常量</h5>
<p>在$get_vec_element$函数内部，有边界检查，因为循环确保了不会出现数组越界，我们可以直接用数组下标访问元素，减少了过程调用
但是实际上这段代码数组越界与否，是高度可预测的，所以性能并没有产生显著的提升</p>
<h5>减少内存引用</h5>
<p>我们可以使用局部变量保存累积运算的结果，在循环结束的时候再将该值赋给<code>*dest</code>
有意思的是，程序性能却提高了2-5倍，有了极为显著的提升
修改后的C代码如下</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-optimize-optimize-image-006.CFYufs6M_Z2wGKtt.webp" alt="image" /></p>
<p>优化前后代码编译生成的汇编代码如下</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-optimize-optimize-image-007.DgXWpBpr_1s2GYv.webp" alt="image" /></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-optimize-optimize-image-008.C0mbSbUi_Z1D2kVH.webp" alt="image" /></p>
<p>可以发现，优化后的代码通过将局部变量存放在寄存器中，避免了对于指针指向内存的频繁访问与写回
同样的，因为指针可能指向向量中的元素，这种情况下优化前后的两段代码运行结果存在差异，所以编译器不会进行优化</p>
<h5>循环展开</h5>
<p>对于一个循环，按任意因子$k$进行循环展开，称为$k \times 1$循环展开
以下是源代码$2\times1$循环展开后得到的代码</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-optimize-optimize-image-009.qFcWHaL9_13H1lI.webp" alt="image" /></p>
<p>循环展开减少了循环开销操作，即减少了对于条件判断和循环变量增加的次数进而提高性能
但是提升有限，因为循环运行时间的瓶颈往往在循环内的操作，而非循环本身</p>
<h4>提高并行性</h4>
<h5>使用多个累积变量</h5>
<p>我们可以将一组运算分割成两个或者更多个部分，最后进行合并得到结果，我们将循环展开$k$次，并用$k$个变量记录累积值，称为$k \times k$循环展开</p>
<p>在设置累积变量后，程序性能有了显著的提高，甚至打破了延迟界限
通过累积变量，我们将单个寄存器参与运算变成了多个寄存器都参与运算，减少了数据相关
更进一步地，对于延迟为$L$，容量为$C$的操作来说，在循环展开因子$k \geq L \times C$，同时又不过大导致寄存器溢出(所需寄存器过多，导致处理器将局部变量存放在内存上，反而降低了性能)时，执行该操作的功能单元流水线能被填满
理解：取$k=L \times C$，每个时钟周期会启动$C$个操作，经过$L$个时钟周期后，最先被启动的前$C$个操作已经完成，不会再有数据依赖，刚好可以zai在下一轮循环开始时直接无依赖地启动前$C$个操作</p>
<p>注意：对无符号整数或按补码位模式理解的整数加法和乘法，采用多个累积变量与不采用累积变量只循环展开在模$2^w$意义下是等价的；但C语言中的有符号整数溢出是未定义行为，浮点数也会因为舍入和溢出而不满足这种等价</p>
<h5>重新结合变换</h5>
<p>我们将<code>acc = acc OP data[i] OP data[i + 1];</code>
改为<code>acc = acc OP (data[i] OP data[i + 1]);</code>
发现其$CPE$与$2\times2$循环展开相当
简单来说，第一种实现将两次运算连续作用与acc,构成了一条更长的依赖链；而第二种实现先对两个数据运算后，再与acc进行运算，拆分成了两个可以并行的短链，减少了对于同一个寄存器的连续修改</p>
<h5>使用向量指令</h5>
<p><strong>SIMD</strong>：单指令多数据，用单条指令对整个向量进行操作
流SIMD扩展(SSE)以及后续的<strong>AVX</strong>，可以对向量寄存器中的多个数据元素并行地进行运算；以256位AVX寄存器为例，一次可容纳8个32位元素或4个64位元素</p>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="CSAPP"></category>
  </entry>
  <entry>
    <title>Systematic IO</title>
    <link href="https://katyusha-blog.com/posts/csapp/notes/systematic-io/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/csapp/notes/systematic-io/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-04T00:00:00.000Z</updated>
    <summary>Systematic IO</summary>
    <content type="html"><![CDATA[<h2>系统级I/O</h2>
<p><code>Linux</code>文件本质上是一个包含字节的序列，I/O设备都被模型化为文件，内核通过使用 <code>Unix I/O</code>这一接口，对文件进行操作</p>
<h3>文件的类型</h3>
<p><code>Linux</code>目录层次构成的是一个树形结构，根节点为 <code>/</code> 根目录</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-systematic-io-systematic-io-image-001.DQEcHQvE_1U8rPY.webp" alt="image" /></p>
<p>此处介绍三种最常见的文件</p>
<ol>
<li>普通文件：分为文本文件和二进制文件。前者只含有 <code>ASCII</code> 和 <code>Unicode</code> 字符，采用<code>LF</code>格式，每一行以 <code>'\n'</code>结束；后者是其余的普通文件</li>
<li>目录：目录是存储了一组文件名到文件的映射的文件，存储的映射可以是一个目录。用<code>.</code>表示到自身的映射，<code>..</code>表示到父节点的映射</li>
<li>套接字：与另一个进程进行跨网络通信的文件</li>
</ol>
<h3>文件的打开关闭</h3>
<p><code>open</code>函数实现了打开或新建一个文件</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-systematic-io-systematic-io-image-002.DmhmZ_zH_Z1KrsBl.webp" alt="image" /></p>
<p><code>open</code>函数打开文件<code>filename</code>，采用<code>flags</code>的访问方式，当创建一个新文件时，<code>mode</code>参数指明了它的访问权限位</p>
<p><code>flags</code>参数的取值可以如下：
<code>O_RDONLY</code> <code>O_WRONLY</code> <code>O_RDWR</code> 分别表示只读，只写，同时读写
同时，还可以与以下值进行或运算：
<code>O_CREAT</code> <code>O_TRUNC</code> <code>O_APPEND</code> 分别表示文件不存在时创建，将当前文件截断为空，写改为在当前文件末尾追加</p>
<p>每个进程都有一个<code>umask</code>变量，可通过<code>umask</code>函数设置，当使用<code>mode</code>创建一个新文件时，它的权限为<code>mode &amp; ~umask</code>
<code>mode</code>和<code>umask</code>的取值可以如下，同样支持或运算</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-systematic-io-systematic-io-image-003.Co9UDvke_ZJLFLg.webp" alt="image" /></p>
<p><code>open</code>函数的返回值为一个整数的文件描述符。每个进程通常都会打开三个文件<code>STDIN_FILENO</code>，<code>STDOUT_FILENO</code>，<code>STDERR_FILENO</code>，文件描述符分别为0，1，2</p>
<p>使用<code>close</code>函数，传入一个文件的描述符，就可以关闭该文件</p>
<h3>文件的读写</h3>
<p><img src="https://katyusha-blog.com/_astro/csapp-systematic-io-systematic-io-image-004.Ce5XIQ2X_STbln.webp" alt="image" /></p>
<p>其中，<code>fd</code>为对应文件的描述符，<code>buf</code>为从文件读的数据存放到的地址指针或要写入文件的数据的地址指针，n为最大读写字节数
值得注意的是，当能读的字节数小于要求的字节数n时，会读取剩下的所有字节，然后将文件读对应的指针指向文件的末尾</p>
<h3>RIO包实现健壮的读写</h3>
<p>当遇到<code>EOF</code>或者进行网络通信时，读写操作的字节数会比要求的要少，此时称产生了<strong>不足值</strong>。在网络编程中，我们需要处理这种不足值避免出错，所以需要实现一个RIO(Robust I/O)包</p>
<h4>无缓冲的输入输出函数</h4>
<p>直接在内存和文件之间传输数据，没有应用级缓冲</p>
<p>原型如下</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-systematic-io-systematic-io-image-005.Bl7M1Cm2_iWukR.webp" alt="image" /></p>
<p>实现如下，输入函数会在被信号中断后继续进行，使用了<code>while</code>循环反复调用<code>read</code>函数直到读入要求的字节数，除非遇到了<code>EOF</code>；输出函数类似</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-systematic-io-systematic-io-image-006.BNbrMyuw_Z2uA4xM.webp" alt="image" /></p>
<h4>带缓冲的输入函数</h4>
<p>先将从文件读的数据放入一个缓冲区内，当调用函数时，尝试从该缓冲区内读取数据，这样避免了反复系统调用<code>read</code>函数。例如检查是否换行时，我们需要调用字符串长次<code>read</code>函数，每次读入1字节，这样会反复进入内核。而采用带缓冲的输入，可以一次调用<code>read</code>读入多个字节，再遍历缓冲区检查是否有换行即可</p>
<p>函数原型如下</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-systematic-io-systematic-io-image-007.BuespPrC_2l4b7N.webp" alt="image" /></p>
<p>具体实现如下</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-systematic-io-systematic-io-image-008.BMvLXJl6_Z1ILjs5.webp" alt="image" /></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-systematic-io-systematic-io-image-009.CdMnG1uy_2n8GI1.webp" alt="image" /></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-systematic-io-systematic-io-image-010.Bja-NMyg_19LIrV.webp" alt="image" /></p>
<h3>读取文件信息</h3>
<p>文件的信息，也称为文件的元数据</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-systematic-io-systematic-io-image-011.CGiN_kJ0_1QHS7f.webp" alt="image" /></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-systematic-io-systematic-io-image-012.Btf2s1hS_gN07N.webp" alt="image" /></p>
<h3>目录的读取</h3>
<p>使用 <code>opendir</code>打开一个目录</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-systematic-io-systematic-io-image-013.DkHdhzV6_7Cnjr.webp" alt="image" /></p>
<p>注意<code>opendir</code>返回的是一个<code>DIR *</code>目录流指针；<code>readdir</code>返回的目录项是<code>struct dirent *</code>，该结构体定义如下</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-systematic-io-systematic-io-image-014.DawEyhc5_Z2rV26S.webp" alt="image" /></p>
<p>使用<code>readdir</code>读取下一个目录项</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-systematic-io-systematic-io-image-015.C3YfUgRU_1BG8Kn.webp" alt="image" /></p>
<p>使用<code>closedir</code>关闭一个目录</p>
<h3>共享文件</h3>
<p>每个进程维护了一个描述符表，每个打开文件的操作与其中描述符相同的表项对应，每个表目都指向了打开文件表的一个表项。打开文件表由所有进程共享，它存储了一个被打开文件的打开次数和位置，并指向由所有进程共享的，存放了当前文件元数据的v-node表的对应表项</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-systematic-io-systematic-io-image-016.CaWLr0uU_Z1fy1c6.webp" alt="image" /></p>
<p>当执行<code>fork</code>时，子进程也会复制父进程的描述符表</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-systematic-io-systematic-io-image-017.Cxlp-sVk_22I2T2.webp" alt="image" /></p>
<h4>I/O重定向</h4>
<p>考虑<code>shell</code>中的指令<code>linux&gt; ls &gt; foo.txt</code>，实现了将<code>ls</code>指令的结果标准输出重定向到<code>foo.txt</code>中
对应的 <code>dup2</code>用户级调用原型如下，实现了<code>oldfd</code>描述表表项对<code>newfd</code>表项的覆盖</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-systematic-io-systematic-io-image-018.BOGZ3Se3_1h0SYk.webp" alt="image" /></p>
<p>以<code>dup2(4,1)</code>为例，实现了对描述符为1的<code>STDOUT</code>标准输出的重定向，重定向到了描述符为4的打开操作对应的文件</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-systematic-io-systematic-io-image-019.CfNxpmt8_26XG5F.webp" alt="image" /></p>
<h3>I/O函数的使用</h3>
<p><code>C</code>也提供了被称为<strong>标准I/O库</strong>的高级输入输出函数，通过<strong>FILE</strong>这一类型实现了对文件描述符和流缓冲区的抽象</p>
<p>在大多数情况下，我们会使用标准I/O库；当涉及二进制文件时，不应该使用<code>scanf</code>和<code>rio_readlineb</code>；当涉及网络编程时，一般需要使用RIO函数</p>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="CSAPP"></category>
  </entry>
  <entry>
    <title>Virtual Memory</title>
    <link href="https://katyusha-blog.com/posts/csapp/notes/virtual-memory/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/csapp/notes/virtual-memory/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-03T00:00:00.000Z</updated>
    <summary>Virtual Memory</summary>
    <content type="html"><![CDATA[<h2>虚拟地址</h2>
<p>虚拟地址这一机制为每个进程提供了独立的，私有的地址空间，简化了内存的管理，同时也避免了进程的地址空间被其它进程破坏</p>
<h3>物理寻址和虚拟寻址</h3>
<p>主存(DRAM实现)可以看做一个由$M$个字节组成的数组(不一定连续，中间可能有内存映射I/O区域)，通过数组索引访问内存的方式称为<strong>物理寻址</strong>(故主存也可称物理内存)，一般用于嵌入式</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-001.379xMIU-_Z271gfa.webp" alt="image" /></p>
<p>CPU通过生成的虚拟地址，在处理器芯片上通过内存管理单元<strong>MMU</strong>进行地址翻译，得到对应的物理地址，再根据此对主存进行访问，称为<strong>虚拟寻址</strong></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-002.Cf8XjBR7_2iwDQK.webp" alt="image" /></p>
<p>对于一个n位线性地址空间，可以访问的地址为$0$到$2^n-1$
$n$位地址理论上最多表示$2^n$个不同地址；实际CPU支持的虚拟地址位数常小于寄存器位宽。现代操作系统提供的虚拟地址空间通常大于实际物理内存容量</p>
<h3>虚拟内存与缓存</h3>
<p>虚拟内存在实体上并不存在，虚拟地址只是用来访问物理地址的一个抽象值</p>
<p>虚拟内存被分割为虚拟页，每个虚拟页都是固定大小的连续虚拟地址的集合。物理页同理，且每页大小和虚拟页相同，便于实现虚拟页到物理页的映射，将物理页与虚拟页关联起来</p>
<p>通过虚拟内存这一机制，我们得以实现从磁盘到主存的缓存机制
虚拟页中每一页可能有如下三种情况：</p>
<ol>
<li>未分配的：该页还没有和磁盘上的某部分相关联</li>
<li>未缓存的：该页已经和磁盘上的某部分相关联，但是这些磁盘信息还没有缓存到主存中，所以这一页也没有对应一个物理页</li>
<li>已缓存的：该页已经和磁盘上的某部分相关联，且这些磁盘信息正在主存中，这一页已经对应了一个物理页</li>
</ol>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-003.BorK9Gah_2rICYm.webp" alt="image" /></p>
<p>类比SRAM实现的高速缓存，主存也会发生替换，由于主存不命中的惩罚大的多(从磁盘中读取数据特别慢)，主存DRAM是全相连的，使用了更精细的替换算法，同时采用写回而不是直写</p>
<h4>物理内存缓存的机制</h4>
<h5>页表</h5>
<p>为了明确某个虚拟页处于以上三种情况的哪一种，我们引入了页表这一数据结构
每个进程都有一个页表，存储在物理内存中，是用来存储<strong>页表条目</strong>(Page Table Entry，<strong>PTE</strong>)的一个数组
对于编号为$k$的虚拟页，编号为$k$的页表条目存储了其状态信息
页表条目存储的信息是一个$(valid，address)$的二元组，其中$valid$为0/1，$address$为一个地址指针
(此处是为了简化，实际$address$存储的是物理页号，下文会提到)
当$valid$为1时，表示对应的虚拟页存储在物理地址为$address$的主存中
当$valid$为0时，若$address$不为$NULL$，则表示对应的虚拟页在磁盘上的起始地址为$address$，否则表示该页还未与磁盘相关联</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-004.BGb1dIkE_2r643w.webp" alt="image" /></p>
<p>同时，我们可以通过对PTE上增加一些额外的许可位来控制一个进程的访问权限，进而避免了破坏其它进程的地址空间，如添加$SUP$位表示是否只能在内核模式下访问，$READ$，$WRITE$位表示能否进行读写</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-005.BfIDXHz-_1dvd9h.webp" alt="image" /></p>
<p>当一条指令违反了这些权限时，就会产生异常控制流，shell将其报告为"段错误"</p>
<h5>页命中与缺页</h5>
<p>当访问的虚拟页的信息已经存储在物理页中时，对应的PTE的$valid$位为1，$address$位参与构造物理地址，这就称为页命中</p>
<p>当访问的虚拟页不在物理页中时，对应的PTE的$valid$位为0，此时就会产生一个缺页异常，调用内核中的缺页异常处理程序。内核会选择一个牺牲页，若它被修改过则写回磁盘，然后把需要的虚拟页调入某个物理页，并更新相关PTE。此后异常处理程序结束，重新执行触发缺页的指令，若异常处理成功就会产生页命中</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-006.BwFIpCte_Fn1lD.webp" alt="image" /></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-007.DTntGIiY_DnV8V.webp" alt="image" /></p>
<h5>页分配</h5>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-008.DtBi3rpk_Z2g3UqV.webp" alt="image" /></p>
<h4>虚拟内存的作用</h4>
<ol>
<li>简化链接：每个目标文件使用的内存格式相同，如数据段都从虚拟地址0x400000开始，这极大简化了链接</li>
<li>简化加载：在一个进程中加载目标文件时，加载器直接为加入的数据段和代码段分配虚拟页，并将其对应PTE设置为未缓存的即可</li>
<li>简化共享：不同进程中的不同虚拟页可以对应相同的物理页，当该物理页是共享库时，就完成了共享，不需要将共享库多次载入到主存中</li>
<li>简化内存分配：当分配空间生成了$k$个连续的虚拟页时，对应的物理页不需要连续，可以分散在物理内存中</li>
</ol>
<h3>地址翻译</h3>
<p>传入一个虚拟地址后，MMU采用以下的机制进行地址翻译得到物理地址：</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-009.CcRN-blK_Zpv7O3.webp" alt="image" /></p>
<p>虚拟地址由两部分组成，$p$位的虚拟地址偏移量，和剩下$n-p$位的虚拟页号
虚拟页号表明了这个地址处在第几个虚拟页中，同时也是对应的PTE编号
由于物理页直接对应于一个虚拟页，所以虚拟地址偏移量与物理地址偏移量相同</p>
<p>硬件整体执行的流程如下</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-010.PNOEcEX3_Z1BYtiy.webp" alt="image" /></p>
<h4>TLB加速地址翻译</h4>
<p>一次对虚拟地址的访问，需要MMU访问高速缓存得到PTE，构造出物理地址后再次访问高速缓存得到该地址存储的具体值
为了简化得到PTE的过程，进而减少对高速缓存访问造成的开销，MMU内置了一个关于PTE的缓存，翻译后备缓存器(<strong>TLB</strong>)</p>
<p>类比高速缓存，对于一个有$T=2^t$个组的TLB，组索引来自虚拟页号VPN的低$t$位，页内偏移VPO不参与TLB查找；VPN剩下的高位作为标记位进行校验</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-011.DlT1P1tz_d3khw.webp" alt="image" /></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-012.D-AZ_Kvr_Z1Yd0nN.webp" alt="image" /></p>
<p>同时，各级高速缓存也可能保存PTE的缓存</p>
<h4>多级页表</h4>
<p>现代计算机实际上采用的是多级页表的结构，即上一级页表中存储的是下一级页表的地址，最后一级页表存储的才是虚拟页的信息。这样构成的是一种树形结构</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-013.Cjo2kUWe_hlxuq.webp" alt="image" /></p>
<p>只有当一个页表的下一级至少有一个页是分配了的时候，该页表才非空，会分配空间，这极大节约了空间。TLB会缓存最终的地址翻译结果，因此TLB命中时无需逐级访问页表；但TLB不命中时，多级页表仍可能带来多次内存访问</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-014.Cv67PMp0_ZM6L7f.webp" alt="image" /></p>
<h3>Intel Core i7/Linux 内存系统</h3>
<p>咕咕咕</p>
<h3>内存映射</h3>
<p>通过文件对虚拟内存进行初始化的过程称为内存映射
内存映射分为以下两种：</p>
<ol>
<li>文件映射：使用来自磁盘的文件对虚拟内存进行初始化</li>
<li>匿名映射：映射不依赖普通磁盘文件，内核按需提供初始化为0的页面，因此也被称为“请求二进制零”的页面</li>
</ol>
<h4>共享对象与私有对象</h4>
<p>物理页中的一个对象可以同时被多个进程的虚拟地址使用，分为共享对象和私有对象
共享对象指一个进程对该对象的修改会反映到共享的底层对象或物理页上，并且可以被其它映射该对象的进程看到；私有对象指修改会通过写时复制变为进程私有副本，不会反映到其它进程或原始文件中</p>
<p>共享对象示意如下</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-015.DYFy021y_1xu5Cm.webp" alt="image" /></p>
<p>具体实现上，采用了修改PTE，使得不同的虚拟页面指向同一个物理页面，并且这些对应的PTE都有读写权</p>
<p>私有对象示意如下</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-016.t1W11yjk_KQfc3.webp" alt="image" /></p>
<p>具体而言，私有对象采用了一种“写时复制”(copy-on-write，COW)的机制。在最初不同进程虚拟地址的PTE都指向物理地址中的对象，这些进程对应此对象的PTE上都只有读权限，当一个进程尝试进行写的时候，由于没有写的权力，会触发一个故障处理程序，该故障处理程序将私有对象被访问的物理页拷贝一份在为其新分配的物理空间上，并将该页对应的PTE指向此处，设置读写权。然后再重新执行这个写操作，就能正常执行了。写时复制通过推迟复制操作直到对象被修改，极大节约了物理内存。</p>
<h4>fork execve 与内存</h4>
<p>当执行fork函数时，内核为子进程分配PID，同时将父进程的页表拷贝到子进程中，使它们每个虚拟页指向相同的物理页，然后将PTE权限都设置为只读，采用COW机制即可</p>
<p>当执行execve函数时，先解除原程序在对虚拟空间的映射，使用内存映射将新程序的信息载入虚拟空间，然后将PC跳转到新程序指令起始位置</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-017.DjyO0VtP_l3kQf.webp" alt="image" /></p>
<h4>用户级内存映射mmap</h4>
<p>mmap函数原型如下，实现了用户级的内存映射</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-018.J4RBZtdg_Z7Io0Q.webp" alt="image" /></p>
<p>函数中部分参数的意义如下</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-019.BJjGIAxc_p8554.webp" alt="image" /></p>
<p>对于剩余的参数prot，该参数指定了虚拟内存的权限访问位，可以为以下几种</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-020.810GUhG4_Z2jQFz8.webp" alt="image" /></p>
<p>注意，可以将这个常量采用或运算来给予多个权限</p>
<p>对于参数flags，可以为$MAP_PRIVATE$，$MAP_SHARED$，$MAP_ANON$ （此处应该有爱音唐笑嘿嘿嘿），分别表示共享对象，私有对象，匿名映射。匿名映射可以与前两个进行或运算</p>
<p>要想删除虚拟空间的一部分，可以使用$munmap(start, length)$对start为起点的length字节虚拟空间进行删除</p>
<h3>动态内存分配</h3>
<p>动态内存分配器维护了一个进程的虚拟空间，该区域被称为<strong>堆</strong>
堆从.bss区上面向上生长，内核为其维护了一个$brk$指针指向堆顶，堆的大小定义为brk减去堆的起始地址</p>
<h4>动态内存分配的用户级函数</h4>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-021.C-Lopcv4_Zs2P7S.webp" alt="image" /></p>
<p><code>malloc</code> 返回一个至少为size字节的内存块的起始地址的void类型指针，由于需要内存对齐，这段内存字节大小可能大于size
当发生错误时(如内存不足)，<code>malloc</code>会返回<code>NULL</code>，并可能设置<code>errno</code>
注意：<code>malloc</code> 不会初始化这段内存的值，要初始化为0可以使用<code>calloc</code></p>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-022.W3dA8AJq_Z1iuxh5.webp" alt="image" /></p>
<p>当<code>incr</code>为0时，可以得到当前的<code>brk</code>指针</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-023.CB5c3iwV_ZfhyK0.webp" alt="image" /></p>
<p>注意：<code>free</code>的参数必须为<code>NULL</code>，或为尚未释放过的<code>malloc</code>类函数返回值</p>
<h4>动态分配器的基本原则</h4>
<ol>
<li>对于任意一个释放内存操作，此前必须存在对应的分配内存</li>
<li>立即响应，不能延迟操作</li>
<li>不能依赖额外的复杂全局数据结构；分配器维护空闲块所需的数据结构通常存放在堆本身中</li>
<li>进行内存对齐</li>
<li>不对已经分配的内存块进行操作，避免指针失效</li>
</ol>
<p>动态分配器需要做到效率和效果的平衡
前者用最大吞吐率衡量，即单位时间能完成的分配/释放操作
后者用峰值利用率衡量，具体来说，当前已分配块的有效载荷总和称为<strong>有效载荷</strong>，峰值利用率定义为运行过程中有效载荷峰值与堆大小的比</p>
<h4>隐式空闲链表</h4>
<p>下文假设内存按照8字节对齐，称1个字为4字节</p>
<p>块的结构如下</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-024.BXYHaCiD_Z5CKi0.webp" alt="image" /></p>
<p>块开头和结尾的头部，脚部是完全相同的，大小都为1个字32位。前29位表示这个块的字节大小(头部与脚部地址的差)，由于内存按8字节对齐，所以大小的后三位一定全为0，所以不需要显式地表示出来，可以用来存储其它的信息，比如这个块是空闲的还是已经被分配的。中间分别为这个块的有效载荷和填充。</p>
<p>通过块头部记录的大小可以跳到下一个块，通过空闲块的脚部可以找到前一个块的大小。这样所有块按内存顺序形成隐式链表；它不同于显式链表，并不在每个块中保存前驱/后继指针</p>
<h5>分配内存</h5>
<p>介绍以下三种算法：</p>
<ol>
<li>首次适配：从头开始遍历空闲链表，第一次遇到大小足够的内存块就将其分配到这段内存</li>
<li>下一次适配：从上一次成功适配的位置开始遍历空闲链表，其余步骤同上</li>
<li>最优适配：遍历整个空闲链表，分配到大小足够且最小的那一段内存上</li>
</ol>
<h5>分割空闲块</h5>
<p>当直接分配极度不优时，如申请1个字节的空间，但是只有一个114514字节大的空闲块时，就可能会从这个大块中分割出小块用来满足这个申请</p>
<h5>合并空闲块</h5>
<p>当一个被分配的块被释放时，可能创造连续的空闲块，显然创造的连续空闲块只可能是被释放块与其前后的一共三个块，利用边界标记即可在常数时间内判断并合并相邻空闲块</p>
<h5>获取额外的堆内存</h5>
<p>当空闲块已经最大程度合并，还是无法满足当前的申请时，分配器调用<code>sbrk</code>函数改变堆顶指针，向内核请求新的堆内存，该内存以一个大的空闲块的形式，插入到空闲链表中</p>
<h4>显式空闲链表</h4>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-025.Bv2f9R0n_2m4U4p.webp" alt="image" /></p>
<p>显式空闲链表是对隐式空闲链表的一种优化，空闲块由于为被分配，有效载荷部分为空，可以额外存储信息。于是显式存储了当前空闲块的前一个和后一个空闲块的指针，使得每次寻找空闲块时无需遍历已分配块，降低了时间开销</p>
<h4>分离存储技术</h4>
<p>可以将使用一个大的链表维护堆改为使用多个小的链表进行维护，将这些链表按照块大小范围分为多个类。当进行分配和释放时，只需优先遍历对应大小类的链表，而非整个链表</p>
<p>分离存储的方法包括简单分离存储，分离适配，伙伴系统等</p>
<h4>垃圾收集</h4>
<p>当某个被分配的块使用完毕后，可以无需显式地释放内存，而使用垃圾收集器，定期地识别并回收无用的内存，<code>Java</code>等语言就是利用该机制释放已经分配的块的</p>
<p>具体而言，当一个内存空间被寄存器，全局变量，用户栈中的变量指向时，这个内存一定需要保持有效。所以我们将后面这些变量看做根节点，已分配块看做是堆节点，当发生a对b的引用时，就连一条a到b的有向边，进而构成了一张有向可达图。所有从根节点出发不可达的堆节点显然就是无用的内存，需要被回收。当malloc找不到空闲块时，就会调用垃圾收集器，从所有根节点出发遍历所有可达的节点并标记，再依次遍历堆，释放所有未被标记的堆节点。然后再次尝试malloc，若还是没有合适的空闲块再尝试向内核获取额外的堆内存</p>
<p><img src="https://katyusha-blog.com/_astro/csapp-virtual-memory-virtual-memory-image-026.BqXF_8UW_Z2krmaC.webp" alt="image" /></p>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="CSAPP"></category>
  </entry>
  <entry>
    <title>CS106L Standard C++ Programming</title>
    <link href="https://katyusha-blog.com/posts/modern-cpp/modern-cpp/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/modern-cpp/modern-cpp/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-02-16T00:00:00.000Z</updated>
    <summary>CS106L Standard C++ Programming</summary>
    <content type="html"><![CDATA[<h1>CS106L Standard C++ Programming</h1>
<h2>philosophies of C++ design</h2>
<ol>
<li>程序员拥有完全的控制权，并需要对此负责</li>
<li>在代码中直接表达思想和意图</li>
<li>尽可能在编译时强制执行安全性</li>
<li>不浪费空间和时间</li>
<li>将杂乱的特性模块化</li>
<li>向下兼容</li>
</ol>
<h2>stream</h2>
<p><code>std::ostringstream</code> 定义一个写缓冲区，采用 <code>&lt;&lt;</code> 从缓存区指针处写入字符串覆盖</p>
<p><code>std::istringstream</code> 定义一个读缓存区，采用 <code>&gt;&gt;</code> 依据右变量的类型，从缓冲区指针开始读入并进行类型转换。更具体地说，指针会一直读取并向后移动直到遇到空白或制表符，然后指针重新指向空白或制表符的前一个位置。下一次进行缓存区读时，指针会跳过所有空白和制表符，直到下一个字符</p>
<p>事实上，<code>std::istringstream</code>是一种 <code>std::istream</code>，而 <code>std::cin</code> 是一个 <code>std::istream</code>类型的对象，<code>std::cout</code>同理</p>
<p>使用缓冲区而不是立即输出，避免了系统调用读写的昂贵开销，但 <code>std::cerr</code>是不使用缓存区直接输出</p>
<p>缓冲区的状态：Good/Fail/EOF/Bad bit，可通过一个缓冲区的成员函数进行访问  如<code>.good()</code></p>
<p>当尝试读入的类型于缓存区中识别的类型不一致时，<code>fail</code>位会被设置为1，并且此后对该缓冲区的读入都会被冻结</p>
<h2>modern C++ data type</h2>
<p>类型别名 eg: <code>using  iter = std::vector &lt;int&gt;::iterator</code></p>
<p><code>auto</code> 编译器从初值赋值自动推断类型 注意：用 <code>const</code>为 <code>auto</code>变量赋初值的时候不会有常量性</p>
<p><code>std::tuple</code>类似 <code>std::pair</code>，用来表示多元组，通过 <code>get&lt;i&gt;(x)</code>访问多元组 <code>x</code>的第 <code>i</code>个元素，类似语法通过 <code>set</code>来设置元素的值</p>
<p>统一初始化：按照结构体中变量定义的顺序使用花括号进行初始化</p>
<h2>STL</h2>
<p>ez</p>
<h2>template</h2>
<p><code>C++</code> 使用 <code>template</code> 实现了类似泛型编程的功能</p>
<p>对于模板函数，可以使用 <code>template &lt;class T&gt;</code> 声明使用了类型为 <code>T</code>的参数</p>
<p>在编译的时候，编译器会对应不同的类型进行实例化，替换生成对应类型的函数；你也可以手动进行实例化 <code>mymax&lt;int&gt;(114,514)</code>加快编译速度</p>
<p>同样的，自定义的函数也可以作为模板函数的参数。注意在C++标准库中，部分模板函数只接受谓词函数，即返回布尔值的函数</p>
<p><code>lambda</code> 函数：创建的是一个对象，但是表现得像一个轻量级函数，其声明如下</p>
<p><code>auto fun = [capture-clause](parameters)-&gt;return-value{//body};</code></p>
<p>编译器实际上会把它转化成一个类，由于这个类的名称未知，需要使用 <code>auto</code></p>
<p>其中的 <code>capture-clause</code> 规定了该<code>lambda</code> 函数能够捕获的外部变量</p>
<h2>class</h2>
<p>使用构造函数和析构函数，可以定义一个类被创建和回收时的行为</p>
<p>操作符重载分为成员函数和非成员函数两种，前者在类中被定义，后者在类外被定义</p>
<p>部分操作符只能以其中一种形式进行重载，如我们重载<code>std::cout</code>的 <code>&lt;&lt;</code>，使得其能够输出我们自己定义的一个分数类，就只能采用非成员函数进行定义。在成员函数中，可以使用<code>*this</code>得到一个指向当前类的指针</p>
<p>操作符重载的基本原则是和基本类型规则保持一致，如定义分数类的<code>+=</code>时，为了和普通<code>int a</code> 的 <code>(a += 1) += 2;</code>规则相匹配，运算符的返回值应该是一个对当前变量的引用，而非一个拷贝值</p>
<p>采用<code>friend</code>定义友元函数，使得一个位于类外部的函数能够访问类的私有变量</p>
<p>对于一个类，编译器会自动为其生成四种函数</p>
<ol>
<li>构造函数(<code>default constructor</code>)：在该对象被创建时调用</li>
<li>拷贝构造函数(<code>copy constructor</code>)：在创建一个新的对象，用旧有的对象对其进行初始化赋值时被调用</li>
<li>拷贝赋值函数(<code>copy assignment</code>)：在对于一个已经存在的对象，用旧有的对象对其进行覆盖赋值时被调用</li>
<li>析构函数(<code>destructor</code>)：在一个对象超出其作用域时被调用</li>
</ol>
<p>编译器自动生成的函数可能与我们预期的行为不一样，如对于一个自己实现的变长数组的类，会采用指针指向一个地址，当进行拷贝的时候，另外的一个对象指针也会指向该地址，而不是我们期望的深拷贝，即创建该地址表示的数组的副本</p>
<p>通过<code>std::dynamic_cast</code>，可以判断一个对象是否是某个指定类的实例</p>
<h3>move semantics</h3>
<p>将一个对象插入$vector$的末尾，$push_back$方法会先创建该对象的副本，再将该对象的一个副本插入$vector$末尾，这样就需要创建对象的两个副本；而使用$emplace_back$，会在$vector$内直接创建该对象的副本，更为高效</p>
<p>左值与右值：左值出现在等号左侧，右值在右侧。左值有名称和标识，可以使用取地址符找到该值的地址；右值没有名称或标识，是临时值。</p>
<p>左值引用：<code>&amp;</code></p>
<p>右值引用：<code>&amp;&amp;</code> 可以延长临时值的生命周期，如 <code>auto&amp;&amp; prt = a +b;</code></p>
<p>类中还有着以下两个特殊成员函数：</p>
<ol>
<li>移动构造函数(<code>move constructor</code>)：在创建一个新的对象，用右值对其进行初始化赋值时被调用</li>
<li>移动赋值函数(<code>move assignment</code>)：在对于一个已经存在的对象，用右值对其进行覆盖赋值时被调用</li>
</ol>
<p>以上两个函数均以右值引用作为其参数，对右值引用进行拷贝，由于右值是临时的，保证了该类不会被通过右值的指针修改。在这两个移动函数中，传入的右值引用有地址，是左值，如果直接使用<code>=</code>会调用拷贝函数，造成额外的开销，可以使用<code>std::move(rhs)</code>将其转化为右值，将其视为临时值进行移动。</p>
<p>使用<code>std::move</code>实现高效的<code>swap</code></p>
<pre><code>template &lt;class T&gt;
void swap(T &amp;a, T &amp;b) {
    T tmp = std::move(a);
    a = std::move(b);
    b = std::move(tmp);
}
</code></pre>
<p>时间复杂度取决于类型<code>T</code>的移动构造函数和移动赋值函数的时间复杂度。如<code>std::vector&lt;T&gt;</code>二者都为<code>O(1)</code>，总复杂度也为<code>O(1)</code></p>
<h3>inheritance</h3>
<p>继承：将一个类（派生类）基于另一个类（基类）来构建</p>
<pre><code>class Derived : public Base {
    public:
    	//something
    private:
    	//something
};
</code></pre>
<p>这被称为<code>public继承</code>，基类的所有成员在派生类中访问权限不变。而<code>protected/private继承</code>会将基类的<code>public</code>和<code>protected</code>访问权限变为<code>protected</code>/<code>private</code></p>
<p>在基类中，通过在成员函数前加入关键字<code>virtual</code>得到虚函数，使得该函数可以在派生类中被重写，一旦被声明为virtual，在整个继承链中都是虚函数</p>
<p>通过使用<code>=0</code>可以构建纯虚函数，必须在派生类中进行重写。至少包含一个纯虚函数的类称为“抽象类”，无法被实例化</p>
<p>派生类通过关键字<code>override</code>对基类的虚函数进行重写</p>
<p>基类的虚函数通过关键字<code>final</code>，禁止派生类对该函数重写</p>
<p>注意：当派生类定义了与基类成员中名称相同的变量时，基类的变量会被隐藏，即访问该变量时只能得到派生类的值</p>
<p>当派生类超出其生命周期时，会先调用派生类的析构函数，再调用基类的析构函数，注意基类析构函数应该为虚函数，否则对派生类指向的空间进行释放时，只会调用基类的析构函数，进而可能造成内存泄漏</p>
<pre><code>class Animal {
    public:
        std::string name;
        int age;
        Animal(std::string n, int ag) {
            name = n; age = ag;
        }
        virtual ~Animal() = 0;
};

class Dog: public Animal {
    std::string type;
    public:
        Dog(std::string name, int ag, std::string tp) : Animal(name, ag),type(tp){} 
        virtual void daily() = 0;
        virtual void eat() = 0;
        virtual void sleep() = 0;
    	virtual ~Dog(){}
};

class My_Dog: public Dog {
    public:
        My_Dog(std::string name, int ag, std::string tp) : Dog(name, ag, tp) {}
        void eat() override final {
            std::cout &lt;&lt; "eat meat" &lt;&lt; '\n';
        }
        void sleep() override final {
            std::cout &lt;&lt; "sleep 10 hours per day" &lt;&lt; '\n';
        }
        void daily() override final {
            eat();
            sleep();
        }
    	~My_Dog() override final {
            
        }
};
</code></pre>
<p>模板被称为静态多态性，在编译时确定具体调用哪个函数；而继承被称为动态多态性，在运行时根据对象的实际类型确定调用哪个函数</p>
<h2>namespace</h2>
<p>ez</p>
<h2>RAII &amp; samrt pointers</h2>
<p>考虑以下代码</p>
<pre><code>void fun() {
	int *ptr = new int;
	dosomething...
	delete ptr;
	return;
}
</code></pre>
<p><code>new</code>的作用是在堆上动态分配空间，在<code>fun</code>函数结束后，这些空间不会被释放，除非调用了<code>delete</code>。但是函数主体部分可能有提前<code>return</code>，抛出异常等情况导致<code>delete</code>为被执行。这样就会导致堆上的一块空间一直被占用，这被称为<code>内存泄露</code></p>
<p><code>RAII</code>(Resource Acquisition Is Initialization)：若要获得资源，应该始终在构造函数中进行；若要释放资源，应该始终在析构函数中进行。
如 <code>std::ifstream</code>就符合<code>RAII</code>思想：采用文件名对其缓冲区进行初始化，在超出作用域调用析构函数是会关闭文件，而无需显式地关闭文件。</p>
<p>基于<code>RAII</code>思想，现代C++提供了智能指针，如<code>std::unique_ptr</code> <code>std::shared_ptr</code>
<code>std::unique_ptr</code>会在调用析构函数时自动释放其所指向的堆上空间，需要注意的是，如果对<code>std::unique_ptr</code>执行拷贝操作，可能导致堆上的空间被多次释放而出错，因此<code>std::unique_ptr</code>没有实现赋值拷贝和构造拷贝
而堆上的空间会在指向其的所有<code>std::shared_ptr</code>都调用析构函数后才会释放，注意<code>std::shared_ptr</code>效率上不如<code>std::unique_ptr</code></p>
<h1>A Tour of C++ by Bjarne Stroustrup</h1>
<p>太好了是C++之父我们有救辣</p>
<h2>const &amp; constexpr (C++11)</h2>
<p><code>const</code>修饰的值不会被修改，这个值可以在运行时被计算</p>
<p><code>constexpr</code>修饰的是在编译时计算的常量，可以提高运行时性能</p>
<p>函数必须使用<code>constexpr</code>或<code>consteval</code>(C++20)修饰，才能用于初始化一个<code>constexpr</code>常量；</p>
<p>二者区别在于前者修饰的函数也允许运行时计算，后者只能在编译时计算。同时都必须是纯函数，不能修改全局变量</p>
<h2>NULL &amp; nullptr(C++11)</h2>
<p><code>NULL</code>的本质是一个被定义为0的宏，当作为参数传入函数中时，会被编译器当做一个整数而非指针</p>
<p>而<code>nullptr</code>有自己的类型<code>std::nullptr_t</code>，可以隐式转换为任意指针类型，或者采用<code>static_cast</code>进行显式转换</p>
<h2>enum class</h2>
<p><code>enum</code>(枚举)是一种简单的用户自定义类型，通过使用助记符代替整数，用于表示少量值的集合</p>
<p><code>C</code>风格的枚举为<code>enum</code>，<code>C++</code>则为<code>enum class</code>，后者有独立作用域，避免了命名污染，以下介绍都以<code>enum class</code>为例</p>
<p><code>enum class</code>可以指定枚举的类型与值，默认为<code>int</code>和<code>0-index</code>的整数，语法如下</p>
<pre><code>enum class Days : int {
    Monday = 1,		//default = 0
    Tuesday = 2,	// 1
    Wednesday = 3,	// 2
    Thursday = 4,	// 3
    Friday = 5,		// 4
    Saturday = 6,	// 5
    Sunday = 7		// 6
};
</code></pre>
<p>类似命名空间，通过<code>::</code>访问成员，如<code>Days today = Days::Friday;</code></p>
<p>枚举类不支持隐式类型转换，只能进行显式类型转换，如<code>int today = static_cast&lt;int&gt; (Days::Friday);</code></p>
<p>枚举类成员只默认定义了赋值，初始化和比较，可以为其自定义其他操作符。占用的空间和一个枚举类型的大小相同</p>
<h2>union &amp; std::variant(&lt;variant&gt; C++17 )</h2>
<p><code>union</code>(联合)通过将不会被同时使用的成员放在同一个地址，避免了使用两段地址造成空间浪费。可以采用<code>enum class</code>+<code>union</code>来实现</p>
<p>例如当一棵树只有叶子节点需要保存值的时候，可以这么写</p>
<pre><code>enum class Node_type : bool {
    node_ptr,
    val
};

struct Node {
    Node_type t;
    union value {
        Node *p;
        long long val;
    }v;
    void fun() {
        if (t == Node_type::node_ptr) {
            //do something
        }else {
            std::cout &lt;&lt; v.val &lt;&lt; '\n';
        }
    }
};
</code></pre>
<p>可以使用<code>std::variant</code>简化以上代码</p>
<pre><code>struct Node {
    std::variant &lt;Node*, long long&gt; v; 
    void fun() {
        if (std::holds_alternative&lt;Node *&gt;(v)) {
            //do something
        }else {
            std::cout &lt;&lt; std::get &lt;long long&gt; (v) &lt;&lt; '\n';
        }
    }
};
</code></pre>
<h2>std::initializer_list (&lt;initializer_list&gt; C++11)</h2>
<p>使用花括号，将类型相同的变量组合起来，浅拷贝得到一个只读的临时数组，用于进行类的初始化</p>
<p><code>initializer_list</code>可以使用迭代器进行访问，为了强调只读性不支持<code>[]</code>访问</p>
<h2>throw-try-catch</h2>
<p><code>throw-try-catch</code>提供了应对异常的机制</p>
<p>当控制流执行到某个地方，接下来会发生异常时，可以通过<code>throw</code>退出当前作用域，将控制流转移到该作用域的调用者，同时传递异常，异常可以是任何类型的变量，但为了可维护性一般建议抛出<code>&lt;stdexcept&gt;</code>中的异常类</p>
<p>将可能发生异常的语句放置于<code>try</code>中，这些语句用<code>throw</code>传出的异常可以被<code>catch</code>进行捕获并处理</p>
<p>例如，可以对<code>Vector</code>越界访问抛出<code>&lt;stdexcept&gt;</code>中的<code>std::out_of_range</code></p>
<pre><code>template &lt;class T&gt;
class Vector {
    public:
        Vector(int S): ptr(new T[S]), siz(S) {}
        T&amp; operator[](int index) {
            if (index &lt; 0 || index &gt; siz - 1) throw std::out_of_range{"Vector::operator[]"};
            return *(ptr + index);
        } 
    private:
        T *ptr;
        int siz;
};

Vector &lt;int&gt; vec(114);

auto main() -&gt; int {
    try {
        std::cout &lt;&lt; vec[115] &lt;&lt; '\n';
    }
    catch (const std::out_of_range &amp;err) {
        std::cerr &lt;&lt; err.what() &lt;&lt; '\n';
    }
}
</code></pre>
<p>同时，也可以在<code>catch</code>中使用<code>throw</code>重新抛出异常，向上一级调用传播</p>
<p>通过将函数标明为<code>noexcept</code>，会将任何抛出异常的函数行为都变成<code>std::terminate()</code></p>
<h2>assert (&lt;cassert&gt;) &amp; static_assert (C++11)</h2>
<p><code>assert(cond)</code>(断言)会在运行时检查<code>cond</code>的值，若非真，则会立即终止程序</p>
<p><code>static_assert(cond, s)</code>可以在编译时检查常量表达式<code>cond</code>的值，若非真，则编译错误，并输出<code>s</code>作为编译错误信息</p>
<h2>default functions</h2>
<p>类的基本函数通过以下形式进行定义，可以使用<code>default</code>表示采用编译器生成的默认函数，<code>delete</code>声明不生成该操作函数</p>
<pre><code>class My_Class {
    public:
        My_Class();//defualt constructor
        My_Class(sometype);//customized constructor
        My_Class(const My_Class &amp;);//copy constructor
        My_Class &amp; operator = (const My_Class &amp;);//copy assignment
        My_Class(My_Class &amp;&amp;);//move constructor
        My_Class &amp; operator = (My_Class &amp;&amp;);//move assignment
        ~My_Class();//destructor
};
</code></pre>
<p>定义构造函数的同时，也定义了参数到类的类型转换</p>
<p>考虑以下代码</p>
<pre><code>template &lt;class T&gt;
class Vector {
    public:
        Vector(int S): ptr(new T[S]), siz(S) {}
        T&amp; operator[](int index) {
            if (index &lt; 0 || index &gt; siz - 1) throw std::out_of_range{"Vector::operator[]"};
            return *(ptr + index);
        } 
    private:
        T *ptr;
        int siz;
};

int main() {
    Vector &lt;int&gt; v(10);
    v = 11;
}
</code></pre>
<p>这段代码能够编译成功，是因为最后一行调用了接受<code>int</code>类型的构造函数，完成了<code>int</code>到<code>Vector&lt;int&gt;</code>的隐式类型转换</p>
<p>为了避免这种情况，我们只允许显式类型转换，即按以下方式定义构造函数</p>
<p><code>explicit Vector(int S): ptr(new T[S]), siz(S){}</code></p>
<p>拷贝时注意是深拷贝还是浅拷贝，被<code>std::move</code>的左值最好不要在重新赋值前使用</p>
<h2>relational operators</h2>
<p>当关系操作符的两个操作数地位平等时，一般在类的定义外实现，否则在定义内实现</p>
<p><code>&lt;=&gt;</code> (C++20) 被称为"宇宙飞船符号"，作用是比较两个相同类的操作数，若相等返回0，小于返回负数，大于返回正数(类似<code>strcmp</code>函数)</p>
<p>当<code>&lt;=&gt;</code>被声明为<code>default</code>的时候，会检查每个元素并按字典序进行比较</p>
<p>当未被声明为<code>default</code>的时候，<code>==</code>操作符不会被隐式定义</p>
<h2>user-defined literals</h2>
<p>内置类型拥有字面量，用来表明常量的类型，如<code>0x0d000721u</code>表明了这是一个<code>unsigned int</code>
对于用户自定义类，我们也可以实现类似的字面量，使用<code>operator""</code>表示正在定义字面量操作符
例如定义虚数后缀<code>i</code>实现如下</p>
<pre><code>constexpr complex &lt;T&gt; operator ""i(const T &amp;imag) {
    return {0, imag};
}
</code></pre>
<p>标准库的一些命名空间也提供了一些字面量</p>
<pre><code>using namespace std::literals::string_literals;
using namespace std::literals::string_view_literals;

auto main -&gt; int {
    std::string_view s = "Ciallo"sv;
    std::cout &lt;&lt;　"Ciallo"s &lt;&lt; '\n';
}
</code></pre>
<h2>template argument deduction (C++17)</h2>
<p>在初始化的时候，可以让模板类构造函数自行推导模板类型，无需显式声明</p>
<p>如<code>std::pair p = {114.514, 114};</code></p>
<p>对于容器类，也可以用<code>std::initializer_list</code>达到以上效果，需要注意的是初始化列表所有参数类型必须相同，否则会产生二义性错误</p>
<h2>functor</h2>
<p>仿函数又被称为函数对象(function object)，是一种特殊的类，通过重载<code>()</code>应用操作符，使得其可以像函数一样被调用</p>
<pre><code>template &lt;typename T&gt;
class Less {
    private:
        T val;
    public:
        Less(const T &amp;x) : val(x) {}
        bool operator ()(const T &amp;x) const {return x &lt; val;}
};
    
auto main() -&gt; int {
    Less &lt;std::string&gt; l {"Ciallo"};
    std::cout &lt;&lt; l(static_cast &lt;std::string&gt; ("QWQ")) &lt;&lt; '\n';
}
</code></pre>
<p>仿函数可以被用作函数的参数</p>
<pre><code>template &lt;typename Iter, typename f&gt;
int Count_If(Iter bg, Iter ed, f pred) {
    int ans = 0;
    for (auto it = bg; it != ed; it++) {
        if (pred(*it)) ans++;
    }   
    return ans;
}

auto main() -&gt; int {
    Less l {998244353};
    std::vector &lt;int&gt; vec;
    std::mt19937 Rand(1919810);
    for (int i = 1; i &lt;= 100; i++) {
        vec.emplace_back(Rand());
    }
    std::cout &lt;&lt; Count_If(vec.begin(), vec.end(), l) &lt;&lt; '\n';
}
</code></pre>
<p>事实上，<code>lambada</code>函数也是编译器自动生成的一个仿函数</p>
<h2>finally</h2>
<p>为了实现<code>RAII</code>，相较于使用智能指针，还可以使用作用域终结函数，这种方法对C代码有更好的兼容性</p>
<pre><code>template &lt;typename F&gt;
class Finally {
    private:
        F act;
    public:
        Finally(F &amp;x): act(x) {}
        ~Finally() {
            act();
        }
};

template &lt;typename F&gt;
[[nodiscard]] auto finally(F f) {
    return Finally{f};
} 

void fun(int n) {
    auto *p = malloc(sizeof(int) * n);
    auto fin = finally([&amp;]{free(p);});
    //do something
}
</code></pre>
<p>其中，<code>[[nodiscard]]</code>修饰函数返回值需要用左值接受，否则编译时会警告</p>
<p>当<code>fun</code>生命周期结束的时候，<code>fin</code>调用析构函数释放<code>p</code>指向的空间，相较在每个可能退出的位置释放堆上的空间更简便</p>
<h2>aliases</h2>
<p>在所有的<code>STL container</code>的实现中，都有着这样的代码</p>
<pre><code>template &lt;typename T&gt;
class vector {
    public:
    using value_type = T;
    //something
    private:
    //something
}
</code></pre>
<p>所以，我们可以在模板函数中通过访问实例化类的<code>value_type</code>成员，得知其参数类型，进而构建该类型的模板类</p>
<h2>if constexpr (C++17)</h2>
<p>通过<code>if constexpr(cond)</code>，得以实现编译时<code>if</code>，其中要求<code>cond</code>为常量表达式</p>
<p>通过在编译时计算分支跳转，避免了分支预测错误，提高了运行时效率</p>
<h2>concepts (&lt;concepts&gt; C++20)</h2>
<p>泛型编程中，参数类型需要满足某些需求，模板才能被实例化。满足需求的类就被称为概念</p>
<p>考虑以下模板函数</p>
<pre><code>template &lt;typename Seq, typename Value&gt;
Value Sum(const Seq &amp;s,Value v) {
    for (const auto &amp;x : s) {
        v += x;
    }
    return v;
}
</code></pre>
<p>而这个函数需要保证<code>Seq</code>类支持<code>.begin()</code>  <code>.end()</code>以及迭代器移动；同时<code>Value</code>类支持<code>+=</code>，以及<code>Seq</code>存放的变量类型与<code>Value</code>类有可加性</p>
<p>类型名称指示符<code>typename</code>也是一个概念，它只限制了该参数是一个类</p>
<p><code>STL</code>内置了迭代器概念，如<code>std::random_access_iterator</code>，<code>std::forward_iterator</code>等</p>
<p>我们可以使用<code>requires</code>检查一组表达式是否有效
<code>requires</code>子句接在模板参数列表后，右接一个约束表达式用来限制模板参数
<code>requires</code>表达式产生一个布尔量，描述对类型的要求</p>
<pre><code>template &lt;typename Iter&gt;
requires requires(Iter it, int i) {it[i]; it + i;}
void f(Iter it, int i) {
    //do something
}
</code></pre>
<p>第一个<code>requires</code>为子句，第二个
这样就限制了<code>iter</code>支持下标操作和加法操作</p>
<p>同时，我们也可以形式化地定义概念</p>
<pre><code>template &lt;typename T&gt;
concept Equality_comparable = requires (const T &amp;a, const T &amp;b) {
    {a == b} -&gt; std::convertible_to &lt;bool&gt;;
    {a != b} -&gt; std::convertible_to &lt;bool&gt;;
};
</code></pre>
<p>此处，我们定义的概念需要满足重载了等于和不等于，并且返回值可以被转换为<code>bool</code>类型</p>
<p>所以，上文的<code>Seq</code>类就可以通过定义一个概念来约束</p>
<pre><code>template &lt;class T&gt;
concept Sequence = requires (T seq) {
    typename std::ranges::range_value_t &lt;T&gt;;//参数类型可以被推断
    typename std::ranges::iterator_t &lt;T&gt;;//T提供了迭代器类型
    {seq.begin()} -&gt; std::same_as &lt;std::ranges::iterator_t &lt;T&gt; &gt;;
    {seq.end()} -&gt;  std::same_as &lt;std::ranges::iterator_t &lt;T&gt; &gt;;//有.begin() .end()函数，且返回类型只能是T的迭代器
    requires std::input_iterator &lt;std::ranges::iterator_t&lt;T&gt; &gt;;//迭代器至少是输入迭代器
    requires std::same_as &lt;std::ranges::range_value_t&lt;T&gt;, std::iter_value_t&lt;T&gt; &gt; ;//看不懂喵，ds告诉我是避免代理迭代器
};
</code></pre>
<p>标准库中也提供了一些概念，以上代码可以简写为</p>
<pre><code>template &lt;class T&gt;
concept Sequence = std::ranges::input_range &lt;T&gt;;
</code></pre>
<p>同时，概念放在<code>auto</code>前，用来约束用<code>auto</code>修饰的变量和参数，当不符合概念时，编译会出错，这避免了对<code>auto</code>的滥用</p>
<h2>variadic templates(C++11)</h2>
<p>可变参数模板允许我们接受任意数量，任意类型的参数，具体实现上采用递归</p>
<pre><code>template &lt;typename T&gt;
concept Comparable = requires (T a, T b) {
    {a &gt; b} -&gt; std::convertible_to &lt;bool&gt;;
};

template &lt;Comparable T, Comparable... Tail&gt;
requires (std::same_as &lt;T, Tail&gt; &amp;&amp; ...)
T Max(T head, Tail... tail) {
    T ret1 = std::move(head);
    if constexpr(sizeof...(tail) &gt; 0) {
        T ret2 = Max(tail...);
        if (ret2 &gt; ret1) return ret2;
    }
    return ret1;
}
</code></pre>
<p>使用编译时if而不是运行时if，避免了生成边界情况下参数只有一个的<code>Max</code>函数</p>
<p>但是问题在于可变参数模板由递归实现，开销较大，可以使用数组版本代替</p>
<pre><code>template &lt;Comparable T, Comparable... Tail&gt;
requires (std::same_as&lt;T, Tail&gt; &amp;&amp;...)
T Max(T v, Tail... tail) {
    T values[] = {std::move(v), std::move(tail)...};
    return *std::max_element(std::begin(values), std::end(values));
}
</code></pre>
<h2>perfect forwarding(C++11)</h2>
<p>没看懂，以后来填坑</p>
<h2>const &amp; class</h2>
<p>通过在类方法后加上<code>const</code>限制，使得该类的成员不能被方法修改(<code>mutable</code>修饰的成员除外)，但成员管理的资源可以被修改</p>
<p>对于<code>const</code>对象，其调用的方法必须有<code>const</code>修饰，用来保证其只读的属性</p>
<p>在一个方法被调用时，编译器会根据对象的<code>const</code>属性选择匹配的版本：<code>const</code>对象调用<code>const</code>修饰的方法，<code>non-const</code>对象调用未使用<code>const</code>修饰的方法(如果存在)</p>
<p>所以为了保证类的<code>const</code>正确性，推荐对所有不修改成员的方法使用<code>const</code>修饰，同时为一些方法提供<code>const</code>和<code>non-const</code>两个版本</p>
<h2>std::string &amp; std::string_view (C++17)</h2>
<p>std::string 实现上采用了SSO(短字符串优化)技术，短字符串(一般20个字符以下)会被保存在std::string对象内部，长字符串才会在堆上分配空间
因此对于短字符串的操作速度比长字符串快很多</p>
<p>使用<code>std::string_view</code>，我们可以很方便地实现子串提取等功能
<code>std::string_view</code>本质是一个(指针，长度)的二元组，不拥有其指向的对象，只读</p>
<pre><code>std::string append1(const std::string &amp;s1, const std::string &amp;s2) {
    return s1 + s2;
}

std::string append2(std::string_view s1, std::string_view s2) {
    std::string s {s1};
    return s += s2;
}

auto main() -&gt; int {
    std::string s = "Ciallo0d0007211145141919810"s;
    std::cout &lt;&lt; append1({&amp;s[0], 20}, s) &lt;&lt; '\n';
    std::cout &lt;&lt; append2({&amp;s[0], 20}, s) &lt;&lt; '\n';
}
</code></pre>
<p><code>append1</code>函数参数为一个子串的时候，需要构建一个临时字符串，该字符串需要分配内存
<code>append2</code>函数则只拷贝了指针和长度，拷贝开销极小
需要注意的是，std::string_view不延长生命周期，指向的对象必须存活</p>
<h2>file stream</h2>
<p>在算法竞赛中有这样一种文件输入输出方式</p>
<pre><code>int main() {
    std::ios::sync_with_stdio(0);
    std::cin.tie(0);
    std::cout.tie(0);
    freopen("problem.in", "r", stdin);
    freopen("problem.out", "w", stdout);

    int n;
    std::cin &gt;&gt; n;
    //dosomething
}
</code></pre>
<p>但事实上，这是一种未定义行为，<code>freopen</code>属于C标准库函数，只修改了C标准流的流指针，而<code>std::ios::sync_with_stdio(0)</code>将C++流与C流独立开来
如果我们先<code>freopen</code>再<code>std::ios::sync_with_stdio(0)</code>，则是先修改C流指针再将C++流和C流独立，这样是正确的
更稳妥的写法是使用<code>std::ifstream</code>和<code>std::ofstream</code>(&lt;fstream&gt;)</p>
<pre><code>auto main() -&gt; int {
    std::ifstream fin("problem.in");
    std::ofstream fout("problem.out");

    int n;
    fin &gt;&gt; n;
    //dosomething
}
</code></pre>
<h2>parallel algorithms(&lt;execution&gt; C++17)</h2>
<p>标准库提供了对于并行执行和向量化执行的支持
<code>std::execution</code>中有以下参数：
<code>seq</code>顺序执行 <code>par</code>并行执行 <code>unseq</code>向量化执行 <code>par_unseq</code>并行且向量化执行
例如<code>std::sort</code>可以写成如下形式
<code>std::sort(std::execution::par_unseq, vec.begin(), vec.end());</code>
注意执行策略指标仅仅是一个提示，使用何种程度的并发取决于编译器</p>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="Modern C++"></category>
  </entry>
  <entry>
    <title>(Aside) - LLM, Agents 和 Scaling Law</title>
    <link href="https://katyusha-blog.com/posts/nju-os/aside/ai-agent/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/aside/ai-agent/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-02T00:00:00.000Z</updated>
    <summary>(Aside) - LLM, Agents 和 Scaling Law</summary>
    <content type="html"><![CDATA[<h1>Lecture 4 Aside: Scaling Law 和 Agentic AI</h1>
<blockquote>
<p>核心模型：AI 时代的关键变化不是“模型会写代码”，而是原来不可 scale 的高级智能活动开始被 scale。操作系统仍然重要，因为 OS 训练的是接口、抽象、状态机、硬件边界和系统正确性。</p>
</blockquote>
<h2>1. LLM: 大、语言、模型</h2>
<p>大语言模型可以拆成三个词：</p>
<ul>
<li><strong>大</strong>：足够大的系统会出现稳定的宏观规律和涌现能力。类比 Schrödinger 的问题：单个原子不可预测，但宏观物体可预测；模型参数、数据和计算量足够大后，也会出现训练目标之外的能力。</li>
<li><strong>语言</strong>：语言是人类长期沉淀的世界模型接口。LLM 不是直接接触物理世界，而是从人类文本中学习一个已经被人类社会对齐过的世界表示。</li>
<li><strong>模型</strong>：模型不需要精确复制世界，只需要形成一个足够有用的近似。它通过 token 预测吸收语言结构、知识和推理模式。</li>
</ul>
<p>所以 LLM 的能力不是魔法，而是：</p>
<pre><code>大量数据 + 大模型 + 可扩展训练目标
  -&gt; 学到人类语言中的世界模型
  -&gt; 涌现推理、代码、规划等能力
</code></pre>
<h2>2. Agent: 从回答问题到操作系统</h2>
<p>LLM 本身更像一个强大的策略函数；Agent 则是把它放进一个可行动的闭环：</p>
<pre><code>工作区/记忆 -&gt; 计划 -&gt; 工具调用 -&gt; 观察结果 -&gt; 修正计划 -&gt; 继续执行
</code></pre>
<p>Agent 的关键组件：</p>
<ul>
<li><strong>记忆</strong>：完整工作区、上下文、Git repository，而不是单轮 prompt。</li>
<li><strong>分解</strong>：把任务拆成可执行的步骤。早期 prompt 里的 <code>think step by step</code>，在 Agent 中变成持续的规划和检查。</li>
<li><strong>工具</strong>：搜索、编译、运行测试、编辑文件、调用 sub-agent/skills。工具弥补 LLM 在精确计算、环境感知和真实副作用上的短板。</li>
<li><strong>协议</strong>：MCP 这类协议把工具调用标准化，使 Agent 能接入不同系统。</li>
</ul>
<p>OS 类比：</p>
<pre><code>程序 -&gt; syscall/POSIX -&gt; 操作系统资源
Agent -&gt; MCP/tools -&gt; 外部世界资源
</code></pre>
<p>这个类比很重要：syscall 让普通程序以受控方式操作文件、进程、地址空间；MCP/tools 让 Agent 以受控方式操作代码库、浏览器、数据库、命令行等环境。接口设计决定了能力边界，也决定了错误会以什么方式传播。</p>
<h2>3. Scaling Law: 量变为什么能引起质变</h2>
<p>The Bitter Lesson 的观点：不要把太多人工启发式硬编码进系统，而要构造能利用计算规模的通用方法。历史上的 Google、DeepBlue、AlphaGo 都体现了类似路径：当数据、存储、搜索或计算能持续扩大时，能力曲线会发生质变。</p>
<p>LLM 的 Scaling Law 把这种经验变成了实验规律：</p>
<pre><code>loss 与参数量 N、数据量 D、计算量 C 近似呈幂律关系
</code></pre>
<p>含义不是“只要堆算力就自动正确”，而是：</p>
<ul>
<li>能力提升具有可预测性，投资更大训练规模有实验依据。</li>
<li>训练时 scaling 提升基础能力。</li>
<li>推理时 scaling 通过更长思考、多次采样、搜索、工具调用提升单次任务表现。</li>
</ul>
<p>可以把它理解成两条正交轴：</p>
<pre><code>训练时 scaling: 更大模型 + 更多数据 + 更多 FLOPs
推理时 scaling: 更多中间计算 + 搜索 + 工具 + 验证
</code></pre>
<p>Agentic AI 的意义在于把推理时 scaling 工程化：一次回答不够，就拆任务、调用工具、跑测试、回读结果、迭代。</p>
<h2>4. Prompt Engineering 本质是 Attention Engineering</h2>
<p>Transformer 的核心是 attention。所谓 prompt engineering，本质上是在管理模型注意力：</p>
<ul>
<li>给出正确上下文，避免模型在无关信息上耗费注意力。</li>
<li>显式分解问题，降低单次推理跨度。</li>
<li>定义检查条件，让模型知道什么算完成。</li>
<li>把可验证的部分交给工具，而不是让模型凭语言直觉猜。</li>
</ul>
<p>没有合适的上下文和接口，LLM 容易输出“看似合理但不可验证”的车轱辘话；有工具和检查闭环后，它才更像一个能工作的工程系统。</p>
<h2>5. 软件工程视角: 构建合适的抽象</h2>
<p>软件工程的核心仍然是复杂性分解：</p>
<pre><code>软件生态 -&gt; 系统调用 -&gt; OS 实现
OS/软件生态 -&gt; ISA -&gt; 硬件实现
大型软件 -&gt; 函数接口/模块协议 -&gt; 子系统实现
</code></pre>
<p>接口带来组合性和复用性，但也引入约束：一旦接口改变，调用方和实现方都可能失配。LLM 可以帮忙维护这些连接，但如果接口文档、测试和协议不清晰，它也会把局部看似合理的修改扩散成全局失控。</p>
<p>因此 AI 编程不是取消软件工程，而是提高了对工程边界的要求：</p>
<ul>
<li>写清楚 API、输入输出、错误语义和不变量。</li>
<li>把系统拆成能独立验证的子系统。</li>
<li>让 Agent 面对文件系统、测试脚本、协议文档，而不是只面对一段含糊需求。</li>
</ul>
<h2>6. Vibe Coding 的边界</h2>
<p>AI 可以快速生成框架代码、脚本、应用逻辑和常见模式，但 OS/系统级代码的风险在于：正确性依赖不可含糊的底层事实。</p>
<p>危险区域包括：</p>
<ul>
<li>寄存器、ABI、调用约定、栈布局。</li>
<li>中断、异常、特权级切换。</li>
<li>并发原语、内存序、lost wakeup、data race。</li>
<li>内存管理、页表、地址空间、对象生命周期。</li>
<li>设备寄存器、MMIO、同步和缓存一致性。</li>
</ul>
<p>这些地方不能只看“代码像不像”。必须问：</p>
<pre><code>状态机是什么？
不变量是什么？
硬件/ABI/内核接口保证了什么？
哪里有 UB、竞争、重排或平台假设？
测试能否覆盖错误路径？
</code></pre>
<p>这就是 OS 课在 AI 时代仍然有价值的原因：代码变便宜后，真正稀缺的是系统模型、抽象边界、调试能力和验证意识。</p>
<h2>7. 本讲 takeaway</h2>
<ol>
<li>LLM 的核心是可 scale 的世界模型近似：大模型从语言中吸收人类知识和推理模式。</li>
<li>Agent = LLM + 工作区记忆 + 计划 + 工具 + 反馈闭环；它更像一个能调用 syscall 的程序，而不只是 chatbot。</li>
<li>MCP/tools 对 Agent 的意义类似 syscall/POSIX 对普通程序的意义：提供标准化、受控、可组合的外部能力。</li>
<li>Scaling Law 说明 AI 能力增长有经验规律；推理时 scaling 让 Agent 能通过迭代和验证提升任务质量。</li>
<li>Vibe Coding 适合高层、可快速验证的代码；在 OS、并发、ABI、内存管理等底层场景，必须回到状态机、不变量和硬件事实。</li>
</ol>
<h2>8. 和 OS 主线的连接</h2>
<ul>
<li>第 1 讲强调 “Code is cheap, show me the talk”：AI 让代码生成更便宜，但系统设计和解释能力更贵。</li>
<li>第 2 讲从应用看 OS：程序通过 syscall 访问 OS；本讲对应 Agent 通过 tools/MCP 访问外部世界。</li>
<li>第 3 讲从硬件看 OS：硬件只执行状态迁移；本讲提醒我们 AI 也可能缺乏精确硬件模型，不能用语言流畅性替代系统正确性。</li>
</ul>
<p>最终结论：AI 是强大的外部执行器，但 OS 能力决定你能不能给它正确的抽象、检查它的输出，并在底层出错时定位真实原因。</p>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
  <entry>
    <title>(Aside)从零开始构建 Linux 应用世界</title>
    <link href="https://katyusha-blog.com/posts/nju-os/aside/linux/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/aside/linux/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-11T00:00:00.000Z</updated>
    <summary>(Aside)从零开始构建 Linux 应用世界</summary>
    <content type="html"><![CDATA[<h1>Lecture 12 Aside: 从零开始构建 Linux 应用世界</h1>
<h2>1. 本课定位与三条线索</h2>
<p>这一讲是虚拟化部分的收官，不是新增零散考点，而是把前面学过的系统调用串成一条完整故事线：</p>
<p><code>CPU Reset -&gt; Firmware -&gt; Bootloader -&gt; Kernel -&gt; 第一个进程 -&gt; 真实 Linux 用户态 -&gt; 应用生态</code></p>
<p>核心 message：</p>
<p><code>操作系统给应用程序提供对象和 API；前面讲过的进程、文件描述符、libc、链接加载，都在解释这套 API 怎么工作。</code></p>
<p>虚拟化部分的四块拼图：</p>
<ul>
<li>进程与地址空间</li>
<li>文件描述符与内核对象</li>
<li>C 标准库与系统调用封装</li>
<li>链接、加载与 <code>execve</code></li>
</ul>
<p>本课三条线索：</p>
<ul>
<li>历史线：<code>UNIX -&gt; MINIX -&gt; Linux</code></li>
<li>动手线：<code>initramfs -&gt; 最小 init -&gt; BusyBox -&gt; switch_root -&gt; 可用 Linux</code></li>
<li>生态线：<code>内核 API -&gt; 工具链/运行库 -&gt; 包管理 -&gt; 应用生态</code></li>
</ul>
<hr />
<h2>2. UNIX、fork 与 MINIX</h2>
<p>最早的 UNIX 甚至没有 <code>fork</code>。shell 运行命令的方式很原始：</p>
<ol>
<li>关闭原有文件</li>
<li>把 <code>fd 0/1</code> 接回终端</li>
<li>把自己从内存卸掉</li>
<li>加载目标程序执行</li>
<li>程序退出后再把 shell 重新加载回来</li>
</ol>
<p>也就是说，同一时刻往往只有一个用户态程序在跑。后来的 <code>fork</code> 之所以早期能用很小的代码实现，是因为当时的进程抽象本来就很薄，只涉及少量段边界、寄存器状态和内核 bookkeeping。</p>
<p>MINIX 的意义在于，它把操作系统做成了可完整阅读、修改和教学的系统：</p>
<ul>
<li>MINIX 1：UNIX v7 兼容</li>
<li>MINIX 2：POSIX 兼容</li>
<li>MINIX 3：更成熟，仍在维护</li>
</ul>
<p>它也是 Linus 早期写 Linux 的直接土壤。</p>
<p>MINIX 代表的还是微内核路线：</p>
<ul>
<li>内核尽量小</li>
<li>文件系统、驱动、内存管理尽量放到用户态服务</li>
<li>彼此靠消息传递协作</li>
</ul>
<p>它的问题不是思想错误，而是当年的 IPC 和调度切换开销太大，所以“过早正确”。</p>
<hr />
<h2>3. Linux 的诞生与时代问题</h2>
<p>1991 年 Linus 发出那封 “just a hobby” 邮件时，Linux 还只是一个给 386 机器写的、替代 MINIX 的 UNIX-like 系统。早期 Linux 并不是凭空出现，而是站在已有土壤上长出来的：</p>
<ul>
<li>依赖 MINIX 的环境</li>
<li>依赖 GNU 的编译器和工具</li>
<li>依赖已有的用户态生态</li>
</ul>
<p>Tanenbaum-Linus 论战表面上是“微内核 vs 宏内核”，本质上是：</p>
<ul>
<li>Tanenbaum 强调结构上的先进与优雅</li>
<li>Linus 强调当下硬件、性能和可用性</li>
</ul>
<p>后来的历史说明，技术方向不只看“概念是否先进”，还要看它是否处在合适的硬件和时代上。</p>
<p>课里插入 AI 的意思也类似：今天从 0 到 0.1 做出系统原型，比过去容易得多。重点不是等机会，而是看懂机制、做出最小版本、在可观测现实里迭代。</p>
<hr />
<h2>4. 初始状态与启动链</h2>
<p>这节课从“初始状态”重新串起前面的知识。</p>
<p>进程的初始状态由 <code>execve(path, argv, envp)</code> 决定：</p>
<ul>
<li>建新地址空间</li>
<li>装入 ELF 与解释器</li>
<li>准备初始栈 <code>argc/argv/envp/auxv</code></li>
<li>让 PC 跳到入口</li>
</ul>
<p>计算机系统的初始状态则是：</p>
<ul>
<li>CPU Reset</li>
<li>Firmware 接管</li>
<li>Bootloader 加载内核</li>
<li>内核最终启动第一个用户态进程</li>
</ul>
<p>所以问题落到一句话：</p>
<p><code>第一个进程住在哪里，它怎么把整个 Linux 世界长出来？</code></p>
<p>后半段的主线就是：</p>
<p><code>Firmware -&gt; Bootloader -&gt; Kernel -&gt; initramfs:/init -&gt; switch_root/pivot_root -&gt; 真实根 -&gt; /sbin/init -&gt; systemd services</code></p>
<p>这里要明确区分两个世界：</p>
<ul>
<li>早期启动世界：<code>initramfs</code></li>
<li>发行版世界：真实根文件系统上的用户态</li>
</ul>
<hr />
<h2>5. initramfs 与第一个进程</h2>
<p><code>initramfs</code> 是启动早期的临时根文件系统，不是平时看到的完整 Linux 世界。</p>
<p>它存在是因为内核刚起来时，常常还不能立刻访问真正的根分区，这时可能还缺：</p>
<ul>
<li>块设备驱动</li>
<li>文件系统驱动</li>
<li>LVM/RAID/加密卷支持</li>
<li>找根分区的脚本和配置</li>
</ul>
<p>所以它的任务很明确：</p>
<ol>
<li>加载剩余驱动</li>
<li>找到真实根分区</li>
<li>挂载真实根</li>
<li>切换到真实根</li>
</ol>
<p>最早的第一个用户态进程通常不是 <code>systemd</code>，而是 <code>initramfs</code> 里的 <code>/init</code>。它可以通过内核命令行显式指定：</p>
<p><code>rdinit=/init</code></p>
<p>这个 <code>/init</code> 可以是 ELF，也可以是 shell 脚本。也就是说，“第一个进程”不是魔法，而是可控状态。</p>
<hr />
<h2>6. 最小 init 与 PID 1 panic</h2>
<p>最小实验的目标，是证明：</p>
<p><code>Linux 加载的第一个用户态进程，可以由我们自己构造。</code></p>
<p>做法是写一个只会：</p>
<ul>
<li><code>write</code></li>
<li><code>exit</code></li>
</ul>
<p>的最小程序，把它打包进 <code>initramfs</code>，再用 <code>rdinit=/init</code> 让内核把它当作第一个进程运行。</p>
<p>如果这个最小 <code>/init</code> 直接退出，内核会 panic：</p>
<p><code>Attempted to kill init!</code></p>
<p>原因不是“它权限高”，而是：</p>
<ul>
<li><code>PID 1</code> 是用户态世界的根</li>
<li>它承担进程树和系统服务的生存结构</li>
<li>它死了，内核无法把系统视为仍然正常运行</li>
</ul>
<p>这里对应的不变式是：</p>
<p><code>内核一旦把控制权交给用户态，用户态根必须持续存在。</code></p>
<hr />
<h2>7. BusyBox、initrd 与救援 shell</h2>
<p>真实的 <code>initramfs</code> 通常不是只放一个二进制，而是放一套最小用户态环境。<code>BusyBox</code> 的作用就是一次性提供大量基础命令，例如：</p>
<ul>
<li><code>sh</code></li>
<li><code>mount</code></li>
<li><code>switch_root</code></li>
<li><code>ls/cp/cat</code></li>
<li>各种恢复和诊断工具</li>
</ul>
<p>所以 <code>initramfs</code> 虽然小，但已经是一个真正能跑脚本、能挂文件系统、能排错的用户态世界。</p>
<p>把真实机器的 <code>initrd</code> 解开后，会看到它像一个微型 Linux：</p>
<ul>
<li><code>/init</code> 启动脚本</li>
<li>BusyBox 命令集</li>
<li>文件系统和设备驱动模块</li>
<li>键盘、字体、firmware 等启动资源</li>
</ul>
<p>这说明开机早期并不是“只有一个内核”，而是已经有一个缩小版用户态环境。</p>
<p>开机失败时掉进 BusyBox shell，通常不表示“内核死了”，而表示：</p>
<ul>
<li>内核起来了</li>
<li><code>initramfs</code> 起来了</li>
<li>但从临时根切到真实根这一步失败了</li>
</ul>
<p>常见原因：</p>
<ul>
<li>根分区找不到</li>
<li><code>fstab</code> 写错</li>
<li>磁盘或文件系统损坏</li>
<li>加密卷没解锁</li>
</ul>
<p>所以这个 shell 本质上是早期启动阶段的故障恢复入口。</p>
<hr />
<h2>8. switch_root、systemd 与完整 init</h2>
<p>这节课最关键的系统调用/工具节点是：</p>
<ul>
<li><code>pivot_root(new_root, put_old)</code></li>
<li><code>switch_root</code></li>
</ul>
<p>它们做的不是“再启动一个系统”，而是：</p>
<p><code>把当前进程看到的根目录，从 initramfs 切换到真实根文件系统。</code></p>
<p>典型流程：</p>
<ol>
<li><code>mount</code> 真实根到 <code>/new_root</code></li>
<li><code>pivot_root</code> 或 <code>switch_root</code></li>
<li><code>exec /sbin/init</code></li>
</ol>
<p><code>systemd</code> 通常最终是 <code>PID 1</code>，但它不是最早那个进程。真实过程是：</p>
<ul>
<li>早期 <code>PID 1</code> 是 <code>initramfs</code> 里的 <code>/init</code></li>
<li>切到真实根后，它执行 <code>exec /sbin/init</code></li>
<li><code>exec</code> 替换程序映像，但不改变 PID</li>
</ul>
<p>所以看起来像“systemd 一直都是 PID 1”，其实是 PID 被继承了。</p>
<p>一份能工作的早期 <code>init</code> 脚本，通常会依次做这些事：</p>
<ul>
<li>展开 BusyBox 命令链接</li>
<li>挂载 <code>proc</code>、<code>sysfs</code>、<code>dev</code></li>
<li><code>insmod</code> 加载块设备/网卡驱动</li>
<li><code>mknod</code> 创建设备节点</li>
<li>提供交互 shell 或调试入口</li>
<li>挂载真实根</li>
<li><code>switch_root</code></li>
</ul>
<p>这就是“把 Linux 世界点亮”的最小施工流程。</p>
<hr />
<h2>9. 从最小 Linux 到可用 Linux</h2>
<p>从一个空的 <code>initramfs</code> 出发，逐步补齐：</p>
<ul>
<li>命令行工具</li>
<li>驱动</li>
<li><code>/dev</code> 设备节点</li>
<li><code>proc/sysfs</code></li>
<li>真实根</li>
<li>第二段 init</li>
<li>网络配置</li>
<li><code>httpd</code></li>
</ul>
<p>最终可以让宿主机浏览器真的访问到虚拟机里的服务。</p>
<p>这一段想证明的是：</p>
<p><code>一个可用的 Linux 世界，本质上就是一串对象创建和系统调用拼起来的结果。</code></p>
<hr />
<h2>10. 狭义操作系统、广义操作系统与生态</h2>
<p>狭义的 OS 是：</p>
<ul>
<li>对象</li>
<li>API</li>
</ul>
<p>也就是：</p>
<ul>
<li><code>fork/execve/waitpid</code></li>
<li><code>open/read/write/close</code></li>
<li><code>mount/mknod/stat/socket</code></li>
<li><code>mmap/munmap/mprotect</code></li>
</ul>
<p>广义的 OS 还包括：</p>
<ul>
<li>运行库：<code>libc</code>、<code>libm</code>、<code>libstdc++</code></li>
<li>工具链：<code>gcc</code>、<code>clang</code>、<code>binutils</code>、<code>make</code></li>
<li>包管理：<code>apt</code>、<code>rpm</code>、<code>npm</code>、<code>pip</code></li>
<li>系统管理与应用分发工具</li>
</ul>
<p>“有没有国产操作系统”这类问题，真正难点不在内核，而在生态：</p>
<ul>
<li>运行库</li>
<li>编译工具链</li>
<li>包管理</li>
<li>应用分发</li>
<li>开发者社区</li>
<li>长期维护流程</li>
</ul>
<p>所以：</p>
<p><code>一张系统调用表不等于一个操作系统；真正让 Linux 无处不在的是围绕它长出来的整座生态。</code></p>
<hr />
<h2>11. 本课结论</h2>
<ul>
<li>前面的进程、文件描述符、libc、链接加载，最终都汇到“OS 提供对象和 API”这一句上。</li>
<li>Linux 启动链可以拆得非常具体：<code>Reset -&gt; Firmware -&gt; Bootloader -&gt; Kernel -&gt; initramfs -&gt; /init -&gt; switch_root -&gt; 真实根 -&gt; systemd</code>。</li>
<li><code>initramfs</code> 是临时根，不是最终世界；最早的 <code>PID 1</code> 是它里面的 <code>/init</code>。</li>
<li><code>systemd</code> 是后来的 <code>exec</code> 结果，因此保留了 <code>PID 1</code> 身份。</li>
<li>BusyBox shell 是早期启动失败时的恢复入口，不等于内核已经崩掉。</li>
<li>从最小 init 到联网的 Linux，整个过程都只是系统调用和内核对象的组合，没有魔法。</li>
<li>操作系统真正难的往往不是“内核能不能跑”，而是“上面的生态能不能持续支撑应用世界”。</li>
</ul>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
  <entry>
    <title>(Aside) - 终端和 UNIX Shell</title>
    <link href="https://katyusha-blog.com/posts/nju-os/aside/shell/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/aside/shell/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-05T00:00:00.000Z</updated>
    <summary>(Aside) - 终端和 UNIX Shell</summary>
    <content type="html"><![CDATA[<h1>Shell / Terminal Notes</h1>
<h2>1. 第 8 讲的主线</h2>
<p>这一讲讲的不是零散名词，而是一条完整的人机交互链：</p>
<p><code>人 -&gt; 终端 -&gt; 操作系统 -&gt; shell -&gt; 其他程序</code></p>
<ul>
<li>终端提供字符交互入口</li>
<li>操作系统在这条字符流上附加控制语义</li>
<li>shell 把系统调用组织成一门命令语言</li>
</ul>
<hr />
<h2>2. 几个容易混的名词</h2>
<ul>
<li><code>tty</code>: 终端接口/设备的总称，历史上来自 teletypewriter</li>
<li><code>pty</code>: pseudo terminal，软件实现出来的伪终端机制</li>
<li><code>pts</code>: 某个 pty 的 slave 设备实例，例如 <code>/dev/pts/3</code></li>
<li><code>terminal emulator</code>: 终端模拟器，是用户看见的窗口程序</li>
<li><code>shell</code>: 运行在终端里的命令解释器，如 <code>sh</code>、<code>bash</code>、<code>zsh</code></li>
</ul>
<p>最直白的关系：</p>
<p><code>你 -&gt; 终端模拟器 -&gt; pty master &lt;-&gt; pty slave(/dev/pts/N) -&gt; shell -&gt; 其他程序</code></p>
<p>其中：</p>
<ul>
<li>真正执行命令的是 shell 和它 fork/exec 出来的子进程</li>
<li>terminal emulator 不执行命令，它只负责收输入、显示输出</li>
</ul>
<hr />
<h2>3. 终端、shell、bash 的区别</h2>
<ul>
<li>终端模拟器：窗口和交互界面</li>
<li>shell：命令解释器这一类程序</li>
<li>bash：shell 的一种具体实现</li>
</ul>
<p>所以平时说“打开 terminal”，说的是外层交互环境；里面默认跑着一个 shell。</p>
<hr />
<h2>4. 用户为什么总是从终端进入系统</h2>
<p>终端是人机交互的第一个设备。</p>
<ul>
<li>本地登录：<code>内核 -&gt; init -&gt; getty</code></li>
<li>远程登录：<code>sshd -&gt; fork -&gt; openpty</code></li>
</ul>
<p>这些程序会先做几件事：</p>
<ul>
<li>分配一个终端</li>
<li>让 <code>stdin/stdout/stderr</code> 指向这个终端</li>
<li>建立一个 <code>session</code></li>
<li>把这个 session 关联到 controlling tty</li>
</ul>
<p><code>login</code> 的作用主要是认证和进入用户环境；严格说，session 往往不是 <code>login</code> 创建的，而是它继承的。</p>
<hr />
<h2>5. 终端到底做了什么</h2>
<p>终端本身可以粗略看成“字符输入输出前端”：</p>
<ul>
<li>把按键变成字节流送给操作系统</li>
<li>把程序输出的字节流显示出来</li>
</ul>
<p>但终端不是直接把字符送到某个程序的 <code>stdin</code>。更准确地说：</p>
<ul>
<li>终端把字符送进内核的 tty 子系统</li>
<li>内核再决定把这些字符交给哪个前台进程</li>
</ul>
<p>所以复杂的交互语义主要不在终端模拟器里，而在操作系统的 tty 机制里。</p>
<hr />
<h2>6. Ctrl-C / Ctrl-Z 为什么会“有特殊含义”</h2>
<p>终端自己只负责传字符。</p>
<ul>
<li><code>Ctrl-C</code> 常被编码成字节 <code>0x03</code></li>
<li><code>Ctrl-D</code> 常被编码成字节 <code>0x04</code></li>
</ul>
<p>这些字节有没有特殊含义，取决于内核保存的这条 tty/pts 的状态。</p>
<p>也就是说：</p>
<ul>
<li>按下 <code>Ctrl-C</code></li>
<li>终端把它送进 tty 子系统</li>
<li>操作系统根据当前 tty 配置，把它解释成“给前台进程组发 <code>SIGINT</code>”</li>
</ul>
<p>所以 <code>Ctrl-C</code> 不是按键自己会杀进程，而是内核在当前终端语义下把这个字符解释成了中断请求。</p>
<p><code>stty -a</code> 显示的就是当前 tty 的这组状态。</p>
<hr />
<h2>7. 为什么 vim 通常不会被 Ctrl-C 杀掉</h2>
<p>因为 <code>vim</code> 运行时会修改终端属性，接管键盘输入。</p>
<p>所以：</p>
<ul>
<li>shell 里，<code>Ctrl-C</code> 往往会被 tty 解释成 <code>SIGINT</code></li>
<li>vim 里，输入往往先被 vim 自己读走并处理</li>
</ul>
<p>因此 <code>Ctrl-C</code> 在不同程序里的效果不一样，本质上取决于当前 tty 状态和程序自己的处理方式。</p>
<hr />
<h2>8. session 和 process group 在管什么</h2>
<p>这两个分组解决的是不同层次的问题。</p>
<h3>session</h3>
<p><code>session</code> 是更大的“登录会话”分组：</p>
<ul>
<li>一整组共享同一个登录上下文的进程</li>
<li>通常关联一个 controlling terminal</li>
</ul>
<p>可以理解成“这一整轮终端交互环境”。</p>
<h3>process group</h3>
<p><code>process group</code> 是更小的“作业控制”分组：</p>
<ul>
<li>当前一起工作的那一拨进程</li>
<li>例如一条 pipeline 里的多个进程</li>
</ul>
<p>操作系统收到 <code>Ctrl-C</code> 时，针对的是前台进程组，不是整个 session。</p>
<p>所以：</p>
<ul>
<li>session 管“这一大片是谁的终端会话”</li>
<li>process group 管“当前这一拨进程该一起暂停/继续/收信号”</li>
</ul>
<hr />
<h2>9. Job Control 是什么</h2>
<p>job control 可以看成“终端里的窗口管理”。</p>
<ul>
<li>前台只能有一个 process group</li>
<li><code>Ctrl-Z</code> 把前台作业暂停</li>
<li><code>fg/bg</code> 在前后台之间切换作业</li>
</ul>
<p>它的核心问题不是“怎么运行程序”，而是“多个进程组共享一个终端时，谁现在算前台”。</p>
<hr />
<h2>10. shell 的本质</h2>
<p>shell 不只是“启动程序的工具”，而是一门极简编程语言。</p>
<p>它做的事是：</p>
<ul>
<li>读入一行命令</li>
<li>做文本层面的展开和组合</li>
<li>把命令翻译成系统调用序列</li>
</ul>
<p>典型地会用到：</p>
<ul>
<li><code>open</code></li>
<li><code>dup/dup2</code></li>
<li><code>pipe</code></li>
<li><code>fork</code></li>
<li><code>execve</code></li>
<li><code>waitpid</code></li>
</ul>
<p>所以重定向、管道、后台运行，本质上都是文件描述符和进程控制的编排。</p>
<p>一句话：</p>
<p><strong>shell 是 kernel 外面的壳，它把系统调用组织成了用户可直接编程的命令语言。</strong></p>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
  <entry>
    <title>(Aside)一个 Token 的旅程</title>
    <link href="https://katyusha-blog.com/posts/nju-os/aside/token/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/aside/token/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-22T00:00:00.000Z</updated>
    <summary>(Aside)一个 Token 的旅程</summary>
    <content type="html"><![CDATA[<h1>一个 Token 的旅程</h1>
<p>第 21 讲不是引入一个新 API，而是把前面所有操作系统概念串成一条端到端路径：</p>
<pre><code>用户发出请求
-&gt; 网络与数据中心接住请求
-&gt; 后端把请求排队、鉴权、计费、调度
-&gt; GPU 集群执行 next-token prediction
-&gt; 生成的 token 以 event-stream 形式流式返回
</code></pre>
<p>这条路径的核心不变量是：</p>
<pre><code>控制流上，大量并发请求不能被重线程和阻塞等待拖垮；
数据流上，必须把计算搬到数据/缓存/显存附近；
硬件上，必须把工作重排成规则、同构、局部的并行形式。
</code></pre>
<p>所以本讲其实是在回答一个大问题：你按下回车后，为什么一个看似普通的 HTTP 请求会穿过整个现代计算机系统栈。</p>
<hr />
<h2>1. 从用户视角看请求</h2>
<p>用户看到的只是：</p>
<pre><code>curl https://api.deepseek.com/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer ${DEEPSEEK_API_KEY}" \
  -d '{"model":"deepseek-v4-flash","messages":[...],"stream":true}'
</code></pre>
<p>但这一行命令已经触发了很多系统事件：</p>
<ul>
<li>shell 创建进程，设置 stdin/stdout/stderr。</li>
<li>libc / runtime 发起 socket、connect、send、recv 等系统调用。</li>
<li>DNS 把域名解析成 IP，DNS 本身也可能参与负载均衡。</li>
<li>TCP/TLS 建立连接，HTTP 请求被发往远端。</li>
<li>服务端以 <code>text/event-stream</code> 返回一段段 token。</li>
</ul>
<p>这一层的关键不是“会用 curl”，而是：</p>
<pre><code>API endpoint 不是一台机器；
它是 DNS、路由、负载均衡、API 网关、业务服务和推理集群共同暴露出来的入口。
</code></pre>
<p>域名解析、traceroute、HTTP header 只是我们能从用户侧观察这套系统的几个窗口。</p>
<hr />
<h2>2. 为什么数据中心是核心</h2>
<p>数据中心可以粗略看成：</p>
<pre><code>计算资源 + 存储资源 + 网络资源 + 调度系统 + 运维系统
</code></pre>
<p>互联网时代，它主要支撑 Web、移动应用、搜索、支付、社交、内容分发；AI 时代，它又变成模型训练和推理的基础设施。</p>
<p>这也是课程里“操作系统不是孤立知识点”的具体体现：</p>
<ul>
<li>fd、socket、pipe、mmap 支撑服务间通信和数据搬运。</li>
<li>线程、锁、条件变量、信号量支撑后端队列和 worker pool。</li>
<li>epoll、coroutine、async/await 支撑大量连接的等待型并发。</li>
<li>并行算法、分片、缓存和近似计数支撑规模化吞吐。</li>
<li>SIMD/SIMT/GPU 支撑最终的密集矩阵计算。</li>
</ul>
<p>一句话：</p>
<pre><code>数据中心是 OS 知识的工业级展开。
​```---
## 3. C10K 到 AI 推理瓶颈

C10K 问题问的是：一台服务器如何同时处理一万个连接。

朴素写法是：

​```c
while (true) {
    Request *rq = get_request();
    pthread_create(&amp;tid, NULL, handle_request, rq);
}
</code></pre>
<p>这个模型的问题不在正确性，而在成本模型：</p>
<ul>
<li>每个 OS 线程有栈、TCB、TLS、内核调度实体等资源。</li>
<li>阻塞 I/O 会让线程睡进内核。</li>
<li>大量线程会带来上下文切换、cache 扰动和调度开销。</li>
<li>连接数上来后，线程数不能线性跟着涨。</li>
</ul>
<p>于是系统演化出：</p>
<pre><code>C10K: epoll / Nginx / event-driven server
C10M: 用户态网络栈 / 零拷贝 / DPDK / 更强的 runtime
C10B: CDN / 边缘计算 / service mesh / 分布式调度
</code></pre>
<p>但 AI 推理时代的瓶颈又变了。传统 Web 请求可能主要卡在网络、数据库、缓存和业务逻辑；LLM 请求背后还挂着一次昂贵的 GPU 推理。</p>
<p>所以新的瓶颈是：</p>
<pre><code>并发连接数不再是唯一问题；
每个请求消耗多少 GPU 算力、显存、KV cache 和调度资源，才是核心问题。
​```---
## 4. 一个请求穿过后端

一个 LLM API 请求进入数据中心后，大致会经历：

​```text
DNS / route
-&gt; L4 load balancer
-&gt; L7 reverse proxy
-&gt; API gateway
-&gt; auth / billing / audit / rate limit
-&gt; application server
-&gt; inference queue
-&gt; GPU scheduler
-&gt; CUDA kernels
-&gt; SSE / WebSocket stream
</code></pre>
<p>每一层都有自己的 correctness condition。</p>
<p>鉴权要求：</p>
<pre><code>API key disabled 后不能继续无限使用。
</code></pre>
<p>计费要求：</p>
<pre><code>实际消耗的 token 必须最终进入账单和日志。
</code></pre>
<p>限流要求：</p>
<pre><code>不能因为多节点并发访问同一个 key 就绕过 quota。
</code></pre>
<p>推理队列要求：</p>
<pre><code>请求不能丢，取消和超时不能破坏队列不变量。
</code></pre>
<p>这些问题很快会撞上分布式系统的墙：延迟、故障、重试、乱序、部分失败、CAP 取舍。单机上一个 mutex 可以保护的东西，到了分布式环境里可能变成跨节点共识、幂等接口、日志重放和最终一致性。</p>
<hr />
<h2>5. UNIX 模型为什么不够</h2>
<p>UNIX 很擅长：</p>
<pre><code>open / read / write / pipe
把数据带到本机进程里处理
</code></pre>
<p>这对单机系统非常优雅。但大规模分布式系统更常见的方向是：</p>
<pre><code>把计算带到数据附近。
</code></pre>
<p>原因很直接：数据太大、机器太多、网络太慢且不可靠。</p>
<p>于是系统接口从“字节流”扩展到更高层：</p>
<ul>
<li>GFS / HDFS：把大文件切块复制到多台机器。</li>
<li>BigTable / DynamoDB：用 key-value / table 抽象支撑在线查询。</li>
<li>MapReduce / Spark：限制计算形式，使其能被自动分发和容错。</li>
<li>Serverless / FaaS：用函数描述计算图，让平台负责调度、重试和伸缩。</li>
</ul>
<p>这里和第 19 讲的 Promise / async 模型有相似之处：</p>
<pre><code>程序员描述依赖关系；
运行时/平台负责在事件完成后推进后续计算。
​```---
## 6. 从 Token 到 Tensor

LLM 推理的核心可以压成：

​```text
p(token_{t+1} | token_1 ... token_t) = f_theta(token_1 ... token_t)
</code></pre>
<p>也就是从一个学到的巨大函数里采样下一个 token。</p>
<p>这里的 <code>theta</code> 是参数，<code>f_theta</code> 是 Transformer 计算图。所谓 tensor，本质上就是多维数组：</p>
<pre><code>scalar: []
vector: [C]
matrix: [M, N]
image batch: [B, H, W, C]
attention: [B, Head, T, T]
KV cache: [Layer, B, Head, T, Dim]
</code></pre>
<p>讲义强调 <code>llm.c</code> / <code>gpt.c</code> 的意义就在这里：大模型原理上并不神秘，很多核心路径就是数组、下标、循环、矩阵乘、softmax 和采样。</p>
<p>神秘感主要来自规模：</p>
<pre><code>参数量巨大；
张量巨大；
显存压力巨大；
带宽压力巨大；
调度系统巨大。
​```---
## 7. Attention 的系统视角

Attention 可以从 Q/K/V 理解：

​```text
Q: 当前查询，我要找什么
K: 历史内容的索引，它们能匹配什么
V: 历史内容的值，匹配后实际取回什么
</code></pre>
<p>推理时，每一层都在把当前 token 的表示和已有上下文交互，得到新的表示。实现上，它会落到一系列张量计算：</p>
<pre><code>linear projection
QK^T
softmax
attention weights * V
MLP
layernorm
sampling
</code></pre>
<p>从操作系统/体系结构视角看，关键不是公式本身，而是公式的执行形态：</p>
<pre><code>大量同构算子；
规则但巨大的张量访问；
高带宽需求；
高度依赖 batching、layout、tiling 和 cache。
</code></pre>
<p>这就是第 20 讲 SIMD/SIMT 的直接应用场景。</p>
<hr />
<h2>8. CUDA kernel 在做什么</h2>
<p>CUDA 的 <code>&lt;&lt;&lt;grid, block&gt;&gt;&gt;</code> 可以理解为：</p>
<pre><code>CPU 把 kernel 参数和执行形状提交给 GPU；
GPU 创建大量逻辑线程；
线程按 block / warp 组织执行；
CPU 通常立即返回，GPU 异步推进计算。
</code></pre>
<p>例如矩阵乘里的每个线程或线程块负责输出矩阵的一小块。它和第 18 讲计算图的关系很直接：</p>
<pre><code>节点 = 一个 tile 的本地计算
边 = 全局内存读取、shared memory 同步、kernel 边界
</code></pre>
<p>GPU 适合 LLM，不是因为“GPU 比 CPU 神奇”，而是因为 workload 长这样：</p>
<ul>
<li>同一操作重复在大量元素上。</li>
<li>分支相对少，控制流比较统一。</li>
<li>数据可以被整理成矩阵和张量。</li>
<li>tile 复用可以提高算术强度。</li>
<li>Tensor Core 可以高吞吐执行混合精度矩阵 FMA。</li>
</ul>
<p>真正难点也在这里：</p>
<pre><code>不是写出一个能算的 kernel；
而是让访存、布局、同步、并行粒度和硬件执行单元匹配。
​```---
## 9. Prefill、Decode 与 KV Cache

生成一个回复不是一次性算完，而是反复生成 token。通常分成两个阶段：

​```text
Prefill:
  处理完整 prompt，算出每层的 K/V，写入 KV cache。

Decode:
  每次生成一个新 token；
  读取已有 KV cache；
  用当前 Q 和历史 K/V 做 attention；
  采样出 token；
  把新 token 的 K/V 追加进 cache。
</code></pre>
<p>如果没有 KV cache，每生成一个 token 都要重新计算所有历史 token 的 K/V，代价会非常夸张。KV cache 的作用是把历史上下文变成显存里的可复用状态。</p>
<p>但 KV cache 也带来新的系统问题：</p>
<ul>
<li>它占用大量显存。</li>
<li>多用户共享时需要分配、回收、驱逐。</li>
<li>长上下文会让 cache 线性增长。</li>
<li>batching 时不同请求长度不同，会造成碎片和调度难题。</li>
<li>请求取消、超时、完成时必须正确释放 cache。</li>
</ul>
<p>这就是为什么现代推理引擎会做 PagedAttention、prefix cache、continuous batching、KV cache paging 等优化。它们本质上都在回答：</p>
<pre><code>怎样像管理虚拟内存和 slab allocator 一样管理显存里的上下文状态？
​```---
## 10. 最后返回的其实是一个整数

GPU 算完后，模型输出 logits：

​```text
logits: [vocab_size]
</code></pre>
<p>然后服务端做：</p>
<pre><code>softmax / sampling
-&gt; token id
-&gt; tokenizer decode
-&gt; UTF-8 bytes
-&gt; JSON chunk
-&gt; SSE event-stream
-&gt; reverse proxy / CDN
-&gt; client display
</code></pre>
<p>所以“模型吐出一个字”在系统里其实是：</p>
<pre><code>一个整数经过 tokenizer 反查后，被包装成网络字节流返回。
</code></pre>
<p>流式输出的体验来自循环执行：</p>
<pre><code>decode one token
send one chunk
decode next token
send next chunk
...
</code></pre>
<p>这也解释了为什么首 token 延迟和后续 token 吞吐是两个不同指标：</p>
<ul>
<li>首 token 延迟包含排队、调度、prefill、网络路径等成本。</li>
<li>后续 token 吞吐主要受 decode、KV cache 访问、batching 和 GPU 利用率影响。</li>
</ul>
<hr />
<h2>11. 和前面课程的连接</h2>
<p>这讲可以把前 20 讲压成一张系统图：</p>
<pre><code>进程 / syscall / fd:
  curl、socket、HTTP、日志、pipe、event-stream。

mmap / virtual memory:
  大文件、模型权重映射、显存管理、cache paging。

线程 / 锁 / 条件变量 / 信号量:
  请求队列、worker pool、GPU 调度器、限流器。

并发 bug:
  取消、超时、重复计费、UAF、ABA、死锁、日志丢失。

并行算法:
  batching、work stealing、分片队列、调度图。

协程 / async:
  大量连接、网络等待、流式返回、事件循环。

SIMD / SIMT:
  matmul、attention、tensor core、kernel launch。

分布式系统:
  鉴权、计费、审计、CAP、幂等、重试、容错。
</code></pre>
<p>这就是讲义的核心：操作系统课不是 API 清单，而是一套还原系统的能力。</p>
<hr />
<h2>12. 面向 AI Infra 的检查清单</h2>
<p>分析一个 LLM serving 系统时，可以按这条链问：</p>
<pre><code>1. 请求在哪里等待？等待的是网络、队列、GPU，还是外部服务？
2. 哪些路径是一请求一线程，哪些路径是 event loop / coroutine？
3. 请求队列的 correctness condition 是什么？取消和超时怎么处理？
4. batching 策略是在优化吞吐，还是牺牲尾延迟？
5. KV cache 如何分配、分页、驱逐和回收？
6. kernel 是 compute-bound、memory-bound，还是 launch/sync-bound？
7. tensor layout 是否让访存 coalesced，tile 是否复用到 shared memory？
8. 统计、计费、日志是否允许最终一致？哪些地方必须严格一致？
9. 如果某个节点 crash，哪些操作会重试？是否幂等？
10. P99/P999 latency 的瓶颈是不是被平均吞吐掩盖了？
</code></pre>
<p>真正的系统优化不是“把每层都换成最快技术”，而是先定位瓶颈：</p>
<pre><code>连接多 -&gt; 用 event-driven / coroutine；
队列热 -&gt; 分片 / batching / backpressure；
显存紧 -&gt; KV cache paging / eviction；
算力满 -&gt; tensor layout / fusion / tensor core；
尾延迟差 -&gt; 调度、隔离、限流和负载均衡。
​```---
## 本节知识点总结

​```text
1. 第 21 讲的主线是把一次 LLM 请求还原成完整系统路径，而不是介绍孤立新概念。
2. API endpoint 背后是 DNS、路由、负载均衡、网关、业务服务和推理集群共同组成的入口。
3. C10K 说明一连接一 OS 线程不可扩展；epoll、事件驱动和协程把等待成本从线程成本中解耦。
4. AI 推理时代的新瓶颈是每个请求背后的 GPU 算力、显存和 KV cache，而不只是连接数。
5. 数据中心是 OS 知识的工业化展开：fd、线程、锁、队列、缓存、调度和网络都在其中落地。
6. 分布式系统不能只沿用 UNIX 的字节流模型，很多场景必须把计算带到数据附近。
7. LLM 推理本质上是从学到的函数中采样下一个 token，核心实现落在张量、循环、矩阵乘和 softmax 上。
8. Attention 的 Q/K/V 公式最终会落到规则密集张量计算，因此天然适合 GPU/SIMT。
9. CUDA kernel launch 是 CPU 向 GPU 提交大量逻辑线程的异步执行请求，性能关键在布局、访存、同步和粒度。
10. Prefill 负责构建 KV cache，Decode 负责逐 token 读取和追加 KV cache。
11. KV cache 是 LLM serving 的核心系统状态，问题形态很像虚拟内存、cache 和 allocator。
12. 模型最终返回的是 token id；tokenizer decode 后才变成用户看到的 UTF-8 文本。
13. 首 token 延迟和后续 token 吞吐是不同指标，分别受排队/prefill 和 decode/cache/GPU 利用率影响。
14. 本讲给出的能力不是背技术名词，而是用 first principles 还原未知系统的瓶颈、边界和不变量。
</code></pre>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
  <entry>
    <title>Lab 1:labyrinth</title>
    <link href="https://katyusha-blog.com/posts/nju-os/lab/lab1/lab1/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/lab/lab1/lab1/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-03T00:00:00.000Z</updated>
    <summary>Lab 1:labyrinth</summary>
    <content type="html"><![CDATA[<p>以下内容均由codex生成，代码由codex调试，codex我们喜欢你😍</p>
<p><a href="https://jyywiki.cn/OS/2026/labs/M1.md">M1: 迷宫游戏 (labyrinth)</a></p>
<p>简要题意：补全一个迷宫游戏的后端，这个后端由前端使用命令行的形式调用</p>
<h1>Lab 1: <code>getopt_long</code> 与这次调试踩坑总结</h1>
<h2>1. <code>getopt_long</code> 是做什么的</h2>
<p><code>getopt_long</code> 用来解析命令行参数，把</p>
<pre><code>./labyrinth --map test.map --player 1 --move right
</code></pre>
<p>这种 <code>argv</code> 序列解析成“选项 + 选项参数”的结构，避免手写一堆 <code>strcmp(argv[i], "...")</code>。</p>
<p>常见调用骨架：</p>
<pre><code>static struct option long_options[] = {
    {"map", required_argument, 0, 'm'},
    {"player", required_argument, 0, 'p'},
    {"move", required_argument, 0, 'M'},
    {"version", no_argument, 0, 'v'},
    {0, 0, 0, 0}
};

int opt;
while ((opt = getopt_long(argc, argv, "", long_options, NULL)) != -1) {
    switch (opt) {
        case 'm': map = optarg; break;
        case 'p': player = optarg; break;
        case 'M': move = optarg; break;
        case 'v': version = true; break;
        case '?': return 1;
    }
}
</code></pre>
<h2>2. <code>getopt_long</code> 每个参数的意义</h2>
<p>函数原型：</p>
<pre><code>int getopt_long(int argc, char * const argv[],
                const char *optstring,
                const struct option *longopts,
                int *longindex);
</code></pre>
<ul>
<li><code>argc</code>
命令行参数个数。</li>
<li><code>argv</code>
命令行参数数组。</li>
<li><code>optstring</code>
短选项规则表，例如 <code>"m:p:v"</code> 表示 <code>-m</code> 和 <code>-p</code> 需要参数，<code>-v</code> 不需要参数。
如果只想用长选项，可以传 <code>""</code>。</li>
<li><code>longopts</code>
长选项描述表，也就是 <code>struct option[]</code>。</li>
<li><code>longindex</code>
如果不为 <code>NULL</code>，会写入当前匹配到的是 <code>longopts</code> 的第几项。大多数时候可以传 <code>NULL</code>。</li>
</ul>
<h2>3. <code>struct option</code> 的四个字段</h2>
<pre><code>{"map", required_argument, 0, 'm'}
</code></pre>
<ul>
<li>第 1 项 <code>name</code>
长选项名字，对应 <code>--map</code>。</li>
<li>第 2 项 <code>has_arg</code>
参数模式：
<ul>
<li><code>no_argument</code></li>
<li><code>required_argument</code></li>
<li><code>optional_argument</code></li>
</ul>
</li>
<li>第 3 项 <code>flag</code>
常用时直接写 <code>0</code> 或 <code>NULL</code>。</li>
<li>第 4 项 <code>val</code>
匹配成功时 <code>getopt_long</code> 返回的值，这里返回 <code>'m'</code>。</li>
</ul>
<p>最后必须有一个终止项：</p>
<pre><code>{0, 0, 0, 0}
</code></pre>
<p>否则解析器不知道选项表在哪里结束，属于未定义行为。</p>
<h2>4. 常用全局变量</h2>
<ul>
<li><code>optarg</code>
当前选项对应的参数字符串，例如 <code>--map test.map</code> 时，处理 <code>case 'm'</code> 时 <code>optarg == "test.map"</code>。</li>
<li><code>optind</code>
下一个待处理参数在 <code>argv</code> 中的下标。解析结束后，如果 <code>optind != argc</code>，通常说明还有未消费的多余参数。</li>
<li><code>optopt</code>
出错时记录相关选项。</li>
<li><code>opterr</code>
是否让库自动打印错误信息。很多题目里会设成 <code>0</code>，自己控制报错路径。</li>
</ul>
<h2>5. <code>required_argument</code> 怎么判断有没有参数</h2>
<p>不要靠 <code>optarg == NULL</code> 来手搓判断，应该看 <code>getopt_long</code> 的返回值。</p>
<ul>
<li>正常匹配到 <code>--map file</code>，会返回你设置的 <code>'m'</code>，并且 <code>optarg</code> 指向 <code>"file"</code>。</li>
<li>如果缺少必须参数，通常会返回 <code>'?'</code>。</li>
<li>如果 <code>optstring</code> 以 <code>:</code> 开头，例如 <code>":"</code>，那么“缺少参数”会返回 <code>':'</code>，可以和“非法选项”区分开。</li>
</ul>
<p>典型写法：</p>
<pre><code>while ((opt = getopt_long(argc, argv, ":", long_options, NULL)) != -1) {
    switch (opt) {
        case 'm':
            map = optarg;
            break;
        case ':':
            return 1;  // 缺参数
        case '?':
            return 1;  // 非法选项
    }
}
</code></pre>
<h2>6. <code>getopt_long</code> 的使用顺序</h2>
<p>一条比较稳的主线是：</p>
<ol>
<li>定义 <code>long_options</code></li>
<li>在 <code>while (getopt_long(...) != -1)</code> 里做语法解析</li>
<li>解析结束后检查语义约束</li>
<li>再进入业务逻辑</li>
</ol>
<p>例如：</p>
<ol>
<li>先解析出 <code>map/player/move/version</code></li>
<li>再检查：
<ul>
<li>是否缺 <code>--map</code></li>
<li>是否缺 <code>--player</code></li>
<li><code>player</code> 是否是合法数字字符</li>
<li><code>move</code> 是否是 <code>up/down/left/right</code></li>
</ul>
</li>
<li>最后再 <code>loadMap</code>、<code>movePlayer</code>、<code>saveMap</code></li>
</ol>
<p>这比“边解析边做大量业务操作”更稳，因为语法层和语义层分开了。</p>
<h2>7. 这次自己踩到的坑</h2>
<h3>7.1 空指针先被用了</h3>
<p>原来在 <code>main</code> 里先写了：</p>
<pre><code>strlen(player)
</code></pre>
<p>但如果命令行没有 <code>--player</code>，此时 <code>player == NULL</code>，会直接段错误。</p>
<p>经验：</p>
<ul>
<li>先判空，再使用。</li>
<li>先检查参数是否存在，再做 <code>strlen</code>、索引、转换。</li>
</ul>
<h3>7.2 误以为 <code>malloc</code> 出来就是“可用对象”</h3>
<p>原来写了：</p>
<pre><code>Labyrinth *mp = malloc(sizeof(Labyrinth));
</code></pre>
<p>但没有初始化 <code>rows/cols/map</code>，随后 <code>loadMap</code> 里直接使用 <code>labyrinth-&gt;rows</code>，会把垃圾值当合法下标。</p>
<p>经验：</p>
<ul>
<li><code>malloc</code> 只保证“给你一块内存”，不保证里面是 0。</li>
<li>要么 <code>calloc</code>，要么显式 <code>memset</code>，要么直接用 <code>Labyrinth mp = {0};</code>。</li>
</ul>
<h3>7.3 越界判断写成了开区间</h3>
<p>原来用了：</p>
<pre><code>0 &lt; row &amp;&amp; row &lt; labyrinth-&gt;rows
0 &lt; col &amp;&amp; col &lt; labyrinth-&gt;cols
</code></pre>
<p>这会错误排除第 0 行、第 0 列，导致边界上的合法位置全被判错。</p>
<p>经验：</p>
<ul>
<li>数组下标合法区间是 <code>[0, n)</code>，不是 <code>(0, n)</code>。</li>
</ul>
<h3>7.4 <code>isEmptySpace</code> 没做边界检查</h3>
<p>原来直接访问：</p>
<pre><code>labyrinth-&gt;map[row][col]
</code></pre>
<p>如果传入 <code>(-1, 0)</code> 或 <code>(rows, 0)</code>，就是越界读，单测里正好卡了这个。</p>
<p>经验：</p>
<ul>
<li>所有“带坐标访问数组”的函数，先做边界检查，再读数组。</li>
</ul>
<h3>7.5 <code>switch</code> 漏写 <code>break</code></h3>
<p><code>case 'M'</code> 后漏掉了 <code>break</code>，导致合法的 <code>--move</code> 也会掉进 <code>case '?'</code> 直接报错。</p>
<p>经验：</p>
<ul>
<li><code>switch</code> 里每个分支都要显式确认是“故意 fallthrough”还是“必须 break”。</li>
</ul>
<h3>7.6 把“只看首字母”当成“解析方向”</h3>
<p>原来只看 <code>direction[0]</code>，所以 <code>"diagonal"</code> 会被当成 <code>"down"</code>。</p>
<p>经验：</p>
<ul>
<li>这种有限离散集合，应该做精确字符串匹配。</li>
<li><code>up/down/left/right</code> 是协议，不是“首字母提示”。</li>
</ul>
<h3>7.7 <code>saveMap</code> 名字对了，但行为错了</h3>
<p>原来 <code>saveMap</code> 里：</p>
<ul>
<li>用 <code>"r"</code> 打开文件</li>
<li>用 <code>putchar</code> 往标准输出写</li>
</ul>
<p>所以它根本没有保存文件。</p>
<p>经验：</p>
<ul>
<li>文件写回要检查三件事：
<ul>
<li>打开模式是不是 <code>"w"</code> 或可写模式</li>
<li>写的是不是目标 <code>FILE *</code></li>
<li>写完是否真的关闭/落盘</li>
</ul>
</li>
</ul>
<h3>7.8 混淆了“打印地图”和“报错退出”</h3>
<p>原来没有 <code>--move</code> 时会打印地图，但返回 <code>1</code>。</p>
<p>经验：</p>
<ul>
<li>“查询模式”和“错误模式”要分清。</li>
<li>能正常打印地图，说明程序成功完成了请求，应该返回 <code>0</code>。</li>
</ul>
<h3>7.9 忘了测试框架也会影响命令行解析状态</h3>
<p>这次本地 <code>testkit</code> 会在同一进程里多次调用 <code>main</code>，而 <code>getopt_long</code> 的状态不是自动清空的。</p>
<p>经验：</p>
<ul>
<li>如果 <code>main</code> 可能被测试框架重复调用，要注意 <code>getopt</code> 相关全局状态。</li>
<li>调试“手工运行正常，测试全挂”时，要怀疑框架调用模型，而不只是怀疑业务逻辑。</li>
</ul>
<h2>8. 这次调试后的简化结论</h2>
<ul>
<li><code>getopt_long</code> 负责“把命令行拆成结构化选项”。</li>
<li>判空、合法性检查、业务语义检查，要放在解析之后统一做。</li>
<li>任何可能为空、未初始化、越界的对象，都不能先用后查。</li>
<li>文件 IO 的正确性要分别检查“打开、读写对象、输出目标、关闭”。</li>
<li>段错误大多不是“算法错”，而是对象生命周期、边界、空指针、未初始化状态错。</li>
</ul>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
  <entry>
    <title>Lab 2: pstree</title>
    <link href="https://katyusha-blog.com/posts/nju-os/lab/lab2/lab2/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/lab/lab2/lab2/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-11T00:00:00.000Z</updated>
    <summary>Lab 2: pstree</summary>
    <content type="html"><![CDATA[<p>C++写太多不会写C了，没了stl写出来的代码巨丑无比（</p>
<p><a href="https://jyywiki.cn/OS/2026/labs/M2.md">M2: 打印进程树 (pstree)</a></p>
<p>简化题意：实现一个类似于pstree的进程树打印工具</p>
<h1>Lab 2: <code>pstree</code> 与 <code>/proc</code> 接口笔记</h1>
<h2>1.  <code>/proc</code> 接口</h2>
<ul>
<li><code>/proc</code> 不是普通磁盘目录，而是 <code>procfs</code> 的挂载点。</li>
<li>它对用户态表现成“可以 <code>open/read/...</code> 的文件和目录”，但内容往往是内核在读取时按当前状态动态生成的。</li>
<li>因此 <code>/proc/&lt;pid&gt;/...</code> 看到的是“某个进程当前状态的投影”，不是一份持久化文件。</li>
</ul>
<p>遍历 <code>/proc</code> 下名字为纯数字的目录，可以得到当前时刻“可见的所有进程 pid 候选集合”。</p>
<p>注意这不是原子快照，可能在看到了尝试读取时，文件不存在。</p>
<p><code>/proc/&lt;pid&gt;/comm</code> 暴露进程名，读取结果是一行字符串。</p>
<p><code>/proc/&lt;pid&gt;/stat</code> 中，第四个字段给出了父进程信息。</p>
<h2>2.做法</h2>
<p>对于每个进程都向父进程连接一条边，然后从根开始dfs输出即可。</p>
<p>注意根的id是0,systemd只是第一个用户态进程，事实上很多内核线程挂在 <code>2</code> 下面。</p>
<h2>3. 这次实现里踩过的坑</h2>
<ul>
<li>把 <code>const int MAXN</code> 当成文件作用域数组大小，实际上在 C 里不算这种用途下的编译期常量</li>
<li>只从 <code>PID 1</code> 开始 DFS，导致漏掉 <code>PID 2</code> 及其后代</li>
<li>忽略 <code>/proc</code> 的动态变化，读失败后仍继续使用未初始化数据</li>
</ul>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
  <entry>
    <title>Lab 3: sperf</title>
    <link href="https://katyusha-blog.com/posts/nju-os/lab/lab3/sperf/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/lab/lab3/sperf/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-11T00:00:00.000Z</updated>
    <summary>Lab 3: sperf</summary>
    <content type="html"><![CDATA[<p><a href="https://jyywiki.cn/OS/2026/labs/M3.md">M3: 系统调用性能分析器 (sperf)</a></p>
<p>简化题意：运行 <code>strace</code> 跟踪指定程序，流式读取每次系统调用的耗时，并每隔约 100 ms 输出累计耗时前5高的系统调用。</p>
<h1>Lab 3: <code>sperf</code> 与进程间通信接口笔记</h1>
<h2>1. 整体进程模型</h2>
<p>程序中实际存在三层角色：</p>
<pre><code>sperf 父进程
  └─ strace 子进程
       └─ 被跟踪的目标程序
</code></pre>
<ul>
<li>父进程负责读取、解析、累计和定时输出。</li>
<li><code>fork</code> 出来的子进程通过 <code>execve</code> 变成 <code>strace</code>。</li>
<li><code>strace</code> 再启动目标程序，并把跟踪结果写到自己的标准错误。</li>
</ul>
<p>父进程必须和 <code>strace</code> 并发工作，不能先 <code>waitpid</code>，等它退出后再读取 pipe。否则 pipe 写满后，<code>strace</code> 等待读者，父进程又等待 <code>strace</code> 退出，会形成死锁。</p>
<h2>2. <code>strace</code></h2>
<p>常用命令：</p>
<pre><code>strace -T command arguments...
</code></pre>
<p><code>-T</code> 会在每次系统调用末尾输出该次调用的耗时：</p>
<pre><code>read(3, "abc", 3) = 3 &lt;0.000012&gt;
</code></pre>
<ul>
<li><code>read</code> 是系统调用名。</li>
<li><code>&lt;0.000012&gt;</code> 是这一次系统调用经过的时间。</li>
<li><code>strace</code> 默认把跟踪信息写到 <code>stderr</code>，避免和目标程序的正常 <code>stdout</code> 混在一起。</li>
<li><code>-c</code> 可以在程序结束后生成汇总表，但不适合本实验要求的流式、周期性输出。</li>
</ul>
<p><code>strace</code> 还可能产生信号、退出信息等非系统调用行，因此 parser 不能假设每一行都有 <code>name(...) &lt;time&gt;</code> 的格式。</p>
<h2>3. <code>pipe</code></h2>
<p>接口：</p>
<pre><code>#include &lt;unistd.h&gt;

int pipe(int pipefd[2]);
</code></pre>
<p>成功后：</p>
<ul>
<li><code>pipefd[0]</code> 是读端。</li>
<li><code>pipefd[1]</code> 是写端。</li>
<li>pipe 是内核维护的单向字节流缓冲区，不保留“行”或“消息”的边界。</li>
</ul>
<p>对读端调用 <code>read</code> 时：</p>
<ul>
<li>缓冲区有数据：返回实际读到的字节数。</li>
<li>缓冲区为空，但仍有写端：阻塞等待。</li>
<li>缓冲区为空，并且所有写端引用都已关闭：返回 <code>0</code>，即 EOF。</li>
<li>出错：返回 <code>-1</code>，具体原因在 <code>errno</code> 中。</li>
</ul>
<p>pipe 可读不要求缓冲区已满，只要有任意数据，<code>poll</code> 就可以报告 <code>POLLIN</code>。缓冲区满影响的是写端：此时继续 <code>write</code> 可能阻塞。</p>
<h2>4. <code>fork</code> 与 fd 继承</h2>
<p>接口：</p>
<pre><code>pid_t fork(void);
</code></pre>
<p>返回值：</p>
<ul>
<li><code>0</code>：当前位于子进程。</li>
<li><code>&gt; 0</code>：当前位于父进程，返回值是子进程 PID。</li>
<li><code>-1</code>：创建失败。</li>
</ul>
<p><code>fork</code> 后父子进程拥有各自的 fd table，但复制得到的 fd 表项仍指向相同的内核文件对象。因此，父子双方都可能持有同一个 pipe 端点的引用。</p>
<p>必须及时关闭不用的端：</p>
<ul>
<li>父进程只读，所以关闭 <code>pipefd[1]</code>。</li>
<li>子进程只写，所以关闭 <code>pipefd[0]</code>。</li>
</ul>
<p>这不只是资源清理。父进程如果忘记关闭自己的写端，内核会认为 pipe 未来仍可能收到数据，读端就无法得到 EOF。</p>
<h2>5. <code>dup2</code> 与重定向</h2>
<p>接口：</p>
<pre><code>int dup2(int oldfd, int newfd);
</code></pre>
<p>它让 <code>newfd</code> 指向 <code>oldfd</code> 指向的同一个内核对象。若 <code>newfd</code> 原本已打开，内核会先关闭它。</p>
<p>本实验中：</p>
<pre><code>dup2(pipefd[1], STDERR_FILENO);
</code></pre>
<p>执行后，<code>STDERR_FILENO</code>（fd 2）指向 pipe 写端。此时原来的 <code>pipefd[1]</code> 只是一个多余别名，应当关闭：</p>
<pre><code>close(pipefd[1]);
</code></pre>
<p>不能关闭 <code>STDERR_FILENO</code>，因为后续运行的 <code>strace</code> 正是通过 fd 2 向 pipe 写跟踪信息。</p>
<h2>6. <code>execve</code> 与参数数组</h2>
<p>接口：</p>
<pre><code>int execve(const char *pathname, char *const argv[],
           char *const envp[]);
</code></pre>
<p><code>execve</code> 用新程序映像替换当前进程。成功时不会返回；失败时返回 <code>-1</code>。</p>
<p>它会替换代码、数据、堆栈等用户态内容，但默认保留 fd table，所以在 <code>execve</code> 前完成的 stderr 重定向仍然有效。设置了 <code>FD_CLOEXEC</code> 的 fd 是例外，会在成功执行时关闭。</p>
<p>执行：</p>
<pre><code>strace -T ls -l
</code></pre>
<p>对应的参数数组应满足：</p>
<pre><code>argv[0] = "strace"
argv[1] = "-T"
argv[2] = "ls"
argv[3] = "-l"
argv[4] = NULL
</code></pre>
<p>关键约束：</p>
<ul>
<li><code>argv[0]</code> 是被执行程序约定的程序名。</li>
<li>最后必须有一个空指针，而不是字符 <code>'\0'</code>。</li>
<li>字符串字面量本身已经带结尾 NUL，不需要写成 <code>"strace\0"</code>。</li>
<li><code>execve</code> 不会自动搜索 <code>PATH</code>；需要搜索时应自行解析 <code>PATH</code>，或使用提供 PATH 搜索语义的其他接口。</li>
</ul>
<h2>7. <code>poll</code></h2>
<p>接口：</p>
<pre><code>#include &lt;poll.h&gt;

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
</code></pre>
<p><code>struct pollfd</code>：</p>
<pre><code>struct pollfd {
    int fd;
    short events;
    short revents;
};
</code></pre>
<ul>
<li><code>events</code> 表示希望监听的事件。</li>
<li><code>revents</code> 由内核填写，表示实际发生的事件。</li>
<li><code>timeout</code> 单位是毫秒；<code>0</code> 表示立即返回，<code>-1</code> 表示无限等待。</li>
</ul>
<p>返回值：</p>
<ul>
<li><code>&gt; 0</code>：至少一个 fd 发生事件。</li>
<li><code>0</code>：超时。</li>
<li><code>-1</code>：出错，例如被信号中断。</li>
</ul>
<p>本实验主要关心：</p>
<ul>
<li><code>POLLIN</code>：当前存在普通数据可读。</li>
<li><code>POLLHUP</code>：pipe 的所有写端均已关闭，不会再产生新数据。</li>
<li><code>POLLERR</code>：fd 出现错误。</li>
<li><code>POLLNVAL</code>：fd 无效。</li>
</ul>
<p>父进程只需主动订阅 <code>POLLIN</code>：</p>
<pre><code>pfd.events = POLLIN;
</code></pre>
<p><code>POLLHUP</code>、<code>POLLERR</code> 和 <code>POLLNVAL</code> 即使没有写入 <code>events</code>，也会由内核报告在 <code>revents</code> 中。组合多个事件或检查返回事件时应使用按位或和按位与，不能使用逻辑运算代替。</p>
<p><code>POLLIN</code> 和 <code>POLLHUP</code> 可以同时出现，表示写端已经关闭，但 pipe 中仍有残留数据。不能看到 <code>POLLHUP</code> 就直接退出，最终 EOF 应以 <code>read(...) == 0</code> 为准。</p>
<h2>8. 流式分行</h2>
<p>一次 <code>read</code> 与一行 <code>strace</code> 输出没有对应关系。一次读取可能得到：</p>
<ul>
<li>半行；</li>
<li>恰好一行；</li>
<li>多行；</li>
<li>多行加最后半行。</li>
</ul>
<p>因此需要维护用户态残留缓冲区：</p>
<ol>
<li>把本次读取的数据追加到残留数据后面。</li>
<li>按 <code>'\n'</code> 提取所有完整行并解析。</li>
<li>把最后不完整的一段移动到缓冲区开头。</li>
<li>等待下一次 <code>read</code> 继续拼接。</li>
</ol>
<p>这体现了 pipe 的核心语义：它只提供有序字节流，不提供消息边界。</p>
<h2>9. 每秒约输出 10 次</h2>
<p>100 ms 表示刷新周期，不是统计窗口。每次输出应展示程序开始以来的前缀累计统计：</p>
<pre><code>第 1 次：[0, 100 ms]
第 2 次：[0, 200 ms]
第 3 次：[0, 300 ms]
</code></pre>
<p>打印时机不能只依赖 <code>poll</code> 超时。如果 pipe 持续可读，<code>poll</code> 会不断提前返回，可能永远没有 <code>r == 0</code>。</p>
<p>更稳定的模型是维护一个绝对截止时间：</p>
<ol>
<li>使用单调时钟记录 <code>next_print = now + 100 ms</code>。</li>
<li>每轮把 <code>next_print - now</code> 换算为 <code>poll</code> timeout。</li>
<li>处理完本轮 I/O 后重新读取时间。</li>
<li>若 <code>now &gt;= next_print</code>，输出当前统计并推进截止时间。</li>
</ol>
<p>计量时间间隔适合使用：</p>
<pre><code>#include &lt;time.h&gt;

clock_gettime(CLOCK_MONOTONIC, &amp;ts);
</code></pre>
<p><code>CLOCK_MONOTONIC</code> 不受系统墙钟时间被人工调整的影响。</p>
<h2>10. <code>waitpid</code></h2>
<p>接口：</p>
<pre><code>pid_t waitpid(pid_t pid, int *status, int options);
</code></pre>
<p>父进程在读完 pipe、确认 EOF 后调用 <code>waitpid</code>，回收 <code>strace</code> 子进程并取得退出状态，避免产生僵尸进程。</p>
<p>正确顺序是：</p>
<pre><code>持续 poll/read/parse
        ↓
read 返回 0，确认 EOF
        ↓
waitpid 回收子进程
</code></pre>
<p>不能在读取 pipe 前阻塞等待子进程结束。</p>
<h2>11. 输出 80 个 NUL</h2>
<p><code>printf("\0")</code> 输出不了 NUL，因为 <code>"\0"</code> 对 <code>printf</code> 来说只是长度为 0 的空字符串。</p>
<p>可以逐字节输出：</p>
<pre><code>for (int i = 0; i &lt; 80; i++) {
    fputc('\0', stdout);
}
fflush(stdout);
</code></pre>
<p>也可以构造全零数组后使用 <code>fwrite</code>。<code>fflush(stdout)</code> 用于确保 stdio 缓冲区中的结果及时提交。</p>
<h2>12. 这次实现中踩过的坑</h2>
<ul>
<li>父进程先 <code>waitpid</code>、不读取 pipe，导致 pipe 写满后父子进程互相等待。</li>
<li><code>dup2</code> 后忘记关闭多余 fd 别名，干扰 pipe 写端引用计数和 EOF 判定。</li>
<li>误以为 pipe 缓冲区满才算可读；实际上存在任意数据即可报告 <code>POLLIN</code>。</li>
<li>用按位与组合希望监听的事件，导致 <code>events</code> 变成 0；实际只需订阅 <code>POLLIN</code>，并从 <code>revents</code> 检查 <code>POLLHUP</code>。</li>
<li>看到 <code>POLLHUP</code> 就退出，忽略了 pipe 中可能仍有残留数据。</li>
<li>认为一次 <code>read</code> 可以读完整行，没有考虑短读、多行和半行拼接。</li>
<li><code>execve</code> 的参数数组忘记以 <code>NULL</code> 结尾，或把结尾下标写到分配范围之外。</li>
<li>为参数字符串 <code>malloc</code> 后立刻覆盖指针，造成无意义的内存泄漏。</li>
<li><code>strace</code> 的非系统调用输出也进入 parser，导致未初始化字符串或错误耗时被加入统计。</li>
<li>只在 <code>poll</code> 超时时打印；持续产生 syscall 时，<code>poll</code> 总因 <code>POLLIN</code> 返回而无法稳定刷新。</li>
<li>混用微秒和毫秒，导致 timeout 变成约 5 ms 或出现负值。</li>
<li>使用 <code>printf("\0")</code>，实际上没有输出题目要求的 80 个 NUL。</li>
<li>在 shell 外层写 <code>&gt; /dev/null</code>，连 <code>sperf</code> 自己的标准输出也一起重定向掉了。</li>
</ul>
<h2>13. 总结</h2>
<ul>
<li><code>fork</code> 创建并发执行环境，<code>execve</code> 替换子进程程序映像。</li>
<li><code>pipe</code> 提供内核字节流，<code>dup2</code> 把 <code>strace</code> 的 stderr 接入 pipe。</li>
<li>fd 是否仍然打开由引用决定；pipe EOF 的条件是缓冲区为空且所有写端都关闭。</li>
<li><code>poll</code> 负责等待“I/O 就绪或时间到达”，<code>read</code> 才真正消费数据。</li>
<li>对字节流必须自行恢复行边界，对周期输出必须把输入事件与时钟节拍解耦。</li>
<li><code>waitpid</code> 应在通信完成后回收子进程，而不是阻塞在通信之前。</li>
</ul>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
  <entry>
    <title>Lab 4: crepl</title>
    <link href="https://katyusha-blog.com/posts/nju-os/lab/lab4/crepl/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/lab/lab4/crepl/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-12T00:00:00.000Z</updated>
    <summary>Lab 4: crepl</summary>
    <content type="html"><![CDATA[<p><a href="https://jyywiki.cn/OS/2026/labs/M4.md">M4: C Read-Eval-Print-Loop (crepl)</a></p>
<p>简化题意：维护一个小型 C REPL。用户可以先输入函数定义，把它们加入当前进程的符号环境；之后再输入表达式，程序把表达式包装成函数、动态编译、动态装载并执行，输出结果。</p>
<h1>Lab 4: <code>crepl</code> 与动态链接接口笔记</h1>
<h2>1. 这题真正要做的事</h2>
<p>这题表面上像“解释器”，但实现上并不是自己解析并执行 C 表达式，而是把 <code>gcc</code> 和动态链接器当作后端：</p>
<ol>
<li>用户输入函数定义。</li>
<li>程序生成临时 <code>.c</code> 文件。</li>
<li>调用 <code>gcc -shared -fPIC</code> 编译出 <code>.so</code>。</li>
<li>用 <code>dlopen</code> 把 <code>.so</code> 加入当前进程。</li>
<li>用户输入表达式时，再生成一个只包含 wrapper 的临时 <code>.so</code>。</li>
<li>用 <code>dlsym</code> 找到 wrapper 函数地址并调用，得到表达式值。</li>
</ol>
<p>所以核心不变式是：</p>
<ul>
<li>已定义函数必须能被后续函数和后续表达式看到。</li>
<li>语法错误、未定义符号、编译失败都要被识别出来，而不是默默返回成功。</li>
</ul>
<h2>2. 整体运行模型</h2>
<p>可以把 <code>crepl</code> 理解成“进程内不断扩展的符号环境”：</p>
<pre><code>用户输入函数定义
  -&gt; 生成临时 temp.c
  -&gt; gcc -shared -fPIC temp.c -o tempN.so
  -&gt; dlopen(tempN.so, RTLD_NOW | RTLD_GLOBAL)
  -&gt; 新函数加入当前进程的全局符号环境

用户输入表达式
  -&gt; 生成临时 expr.c
  -&gt; expr.c 中写入历史函数原型 + wrapper
  -&gt; gcc -shared -fPIC expr.c -o exprN.so
  -&gt; dlopen(exprN.so, RTLD_NOW | RTLD_GLOBAL)
  -&gt; dlsym(handle, "expr")
  -&gt; 调用 expr() 得到结果
</code></pre>
<p>这里有两层“可见性”：</p>
<ul>
<li>编译期可见性：新 <code>.c</code> 文件里要有旧函数的声明，不然编译器不知道 <code>test_func()</code> 的类型。</li>
<li>运行期可见性：旧函数所在 <code>.so</code> 必须已经被 <code>dlopen(..., RTLD_GLOBAL)</code> 加入全局环境，不然新库装载时找不到符号。</li>
</ul>
<h2>3. <code>compile_and_load_function()</code> 的过程</h2>
<p><code>compile_and_load_function()</code> 的任务不是“存字符串”，而是把这段函数定义真正变成当前进程里可用的符号。</p>
<p>典型过程：</p>
<ol>
<li>打开临时 <code>temp.c</code>。</li>
<li>把历史函数原型写进去。</li>
<li>把本次函数定义写进去。</li>
<li><code>fork</code> 出子进程。</li>
<li>子进程 <code>execve("/usr/bin/gcc", argv, environ)</code> 执行：<pre><code>gcc -shared -fPIC temp.c -o tempN.so
</code></pre>
</li>
<li>父进程 <code>waitpid</code> 等编译结束。</li>
<li>若编译成功，则 <code>dlopen(tempN.so, RTLD_NOW | RTLD_GLOBAL)</code>。</li>
<li>成功后从函数定义里提取函数原型，保存给后续代码生成使用。</li>
</ol>
<p>这里“保存函数原型”不是为了调用，而是为了后面生成新 <code>.c</code> 文件时补声明，例如：</p>
<pre><code>int f(int x);
int g() { return f(42); }
</code></pre>
<h2>4. <code>evaluate_expression()</code> 的过程</h2>
<p>表达式不能直接 <code>dlsym</code>，因为 <code>dlsym</code> 找的是符号地址，而不是“裸表达式”。<br />
所以表达式必须先被包装成一个真正的函数。</p>
<p>例如用户输入：</p>
<pre><code>test_eval() / 2
</code></pre>
<p>需要临时生成类似：</p>
<pre><code>int test_eval();
int expr() { return test_eval() / 2; }
</code></pre>
<p>然后流程与定义函数类似：</p>
<ol>
<li>写临时 <code>expr.c</code>。</li>
<li><code>gcc -shared -fPIC expr.c -o exprN.so</code>。</li>
<li><code>dlopen(exprN.so, RTLD_NOW | RTLD_GLOBAL)</code>。</li>
<li><code>dlsym(handle, "expr")</code> 得到函数指针。</li>
<li>调用 <code>expr()</code>，把返回值写进 <code>*result</code>。</li>
</ol>
<p>这里 wrapper 最关键的一点是：必须 <code>return expression;</code>，否则函数返回值未定义。</p>
<h2>5. 关键接口</h2>
<h2>5.1 装载共享库：<code>dlopen</code></h2>
<pre><code>void *dlopen(const char *filename, int flags);
</code></pre>
<p>本实验里最重要的 flags：</p>
<ul>
<li><code>RTLD_NOW</code>：现在就解析符号，失败立刻暴露。</li>
<li><code>RTLD_GLOBAL</code>：把这个库导出的符号放进全局可见环境，供后续新库使用。</li>
</ul>
<p>如果前面定义了：</p>
<pre><code>int f() { return 42; }
</code></pre>
<p>后面定义：</p>
<pre><code>int g() { return f() + 1; }
</code></pre>
<p>那么 <code>f</code> 所在的库必须是 <code>RTLD_GLOBAL</code> 加载的，否则装载 <code>g</code> 时可能找不到 <code>f</code>。</p>
<h2>5.2 查找 wrapper：<code>dlsym</code></h2>
<pre><code>void *dlsym(void *handle, const char *symbol);
</code></pre>
<p>用法：</p>
<pre><code>int (*fun)() = dlsym(handle, "expr");
</code></pre>
<p>这里 <code>expr</code> 必须是你生成的 wrapper 名字。<br />
<code>dlsym</code> 找到的是符号地址，所以表达式必须先包成函数。</p>
<h2>5.3 错误信息：<code>dlerror</code></h2>
<pre><code>char *dlerror(void);
</code></pre>
<p>当 <code>dlopen</code> 或 <code>dlsym</code> 失败时，它能给出“未定义符号”“库打不开”等人类可读错误信息。<br />
调动态链接问题时很有用。</p>
<h2>5.4 行编辑和历史：<code>readline</code> / <code>add_history</code></h2>
<pre><code>char *readline(const char *prompt);
void add_history(const char *line);
</code></pre>
<p>作用：</p>
<ul>
<li><code>readline</code> 提供可编辑输入行。</li>
<li><code>add_history</code> 把输入加入历史，之后上下键才能翻。</li>
</ul>
<p>注意：</p>
<ul>
<li>只有 <code>readline()</code> 不够；不调用 <code>add_history()</code>，上下键没有历史可翻。</li>
<li>链接时还需要 <code>-lreadline</code>。</li>
</ul>
<h2>6. Makefile 与链接</h2>
<p>本实验的主程序本身要链接：</p>
<ul>
<li><code>-ldl</code>：为了 <code>dlopen</code> / <code>dlsym</code></li>
<li><code>-lreadline</code>：为了 <code>readline</code> / <code>add_history</code></li>
</ul>
<p>它们本质上属于链接选项，应放进 <code>LDFLAGS</code>，而不是混在只管编译参数的 <code>CFLAGS</code> 里。</p>
<h2>7. 本地测试思路</h2>
<p>这题的本地验证可以分三层：</p>
<ol>
<li>
<p>能否编译 <code>crepl</code> 本身：</p>
<pre><code>make clean
make
</code></pre>
</li>
<li>
<p>手动 REPL 冒烟：</p>
<pre><code>int f() { return 42; }
f()
21 + 21
int g() { return f() / 2; }
g()
undefined_function()
21 +
</code></pre>
</li>
<li>
<p>运行 <code>tests.c</code> 里的 <code>UnitTest</code></p>
<ul>
<li>这些测试会直接调用 <code>compile_and_load_function()</code> 和 <code>evaluate_expression()</code>。</li>
<li>如果 <code>main()</code> 是无限 REPL，测试触发方式要额外处理，否则程序不会自然退出。</li>
</ul>
</li>
</ol>
<h2>8. 这次实现里踩过的坑</h2>
<ul>
<li>把所有动态库都输出到同一个固定路径，如 <code>/tmp/tmp.so</code> 或 <code>/tmp/expr.so</code>，导致 <code>dlopen</code> 复用旧对象，新代码没有真正装入。</li>
<li>把共享库路径写成 <code>.c</code>，把源码文件和 <code>.so</code> 混淆。</li>
<li><code>execve("usr/bin/gcc", ...)</code> 少了开头的 <code>/</code>；<code>execve</code> 不帮你查 <code>PATH</code>。</li>
<li>子进程 <code>execve</code> 失败后没有立刻 <code>_exit(...)</code>，导致失败路径被误当成功路径继续执行。</li>
<li>写完临时 <code>.c</code> 后没 <code>fclose</code> 就编译，结果 <code>gcc</code> 读到半截文件。</li>
<li>只保存了旧函数的存在，却没在新生成的 <code>.c</code> 文件里写旧函数声明，导致编译期找不到 <code>test_func()</code> 的类型。</li>
<li>误以为“之前已经 <code>dlopen</code> 过函数库”就不需要声明；实际上编译期声明和运行期符号解析是两回事。</li>
<li>表达式 wrapper 写成 <code>int expr(){ expression; }</code>，忘了 <code>return</code>，函数返回值未定义。</li>
<li><code>dlsym</code> 要找的是函数名，不是裸表达式；所以必须显式生成 <code>expr()</code> 之类的 wrapper。</li>
<li>在字符串拼接时只覆盖 <code>_buffer</code>，导致前序函数原型没真的写进临时源码。</li>
<li>以为用了 <code>readline</code> 就天然支持上下键历史；实际上还要 <code>add_history()</code>。</li>
<li>头文件加上了 <code>readline</code>，但 Makefile 没加 <code>-lreadline</code>，最终报 <code>undefined reference to readline</code>。</li>
</ul>
<h2>9. 总结</h2>
<ul>
<li>这题的本质不是“自己实现 C 解释器”，而是“把编译器和动态链接器接成一个 REPL”。</li>
<li><code>gcc -shared -fPIC</code> 负责把输入代码变成共享对象。</li>
<li><code>dlopen(..., RTLD_NOW | RTLD_GLOBAL)</code> 负责把函数定义累积进当前进程的符号环境。</li>
<li><code>dlsym</code> 负责从表达式 wrapper 中找到真正可调用的函数入口。</li>
<li>编译期声明和运行期符号可见性必须同时满足，才能让“后定义代码调用先定义函数”稳定成立。</li>
</ul>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
  <entry>
    <title>Lab 5: mymalloc</title>
    <link href="https://katyusha-blog.com/posts/nju-os/lab/lab5/lab5/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/lab/lab5/lab5/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-19T00:00:00.000Z</updated>
    <summary>Lab 5: mymalloc</summary>
    <content type="html"><![CDATA[<h1>Lab 5: 并发内存分配器的设计、落盘与优化复盘</h1>
<h2>1. 实验目标</h2>
<p>本实验要实现 <code>mymalloc(size_t size)</code> 和 <code>myfree(void *ptr)</code>，用自己的 allocator 替代 glibc <code>malloc/free</code>，并在多线程测试中保证正确性和尽量好的性能。</p>
<p>allocator 的核心任务不是“调用一次系统接口返回一块内存”，而是：</p>
<ol>
<li>从 <code>vmalloc</code> 得到页级别的大块内存。</li>
<li>在 allocator 内部切分成用户需要的小块。</li>
<li>维护元数据，使 <code>free(ptr)</code> 能找回这块内存的来源、大小和复用方式。</li>
<li>在多线程下保护内部状态，避免链表破坏、重复分配、数据竞争和死锁。</li>
<li>通过拆锁、分类、缓存等方式降低同步开销。</li>
</ol>
<p>几个最基础的接口结论：</p>
<ul>
<li><code>mymalloc(s)</code> 只需要返回至少能容纳 <code>s</code> 字节的 payload，不需要刚好 <code>s</code> 字节。</li>
<li>内部大小可以按 8 字节、size class 或 page size 向上取整。</li>
<li><code>vmalloc(NULL, length)</code> 返回页级别内存，<code>length</code> 应该按 4096 对齐。</li>
<li><code>vmfree(addr, length)</code> 也应该按页级别归还，不能把 allocator 内部切出来的小块直接 <code>vmfree</code>。</li>
</ul>
<p>因此，本实验的正确性不变量可以概括为：</p>
<pre><code>任意时刻：
  一个字节要么属于某个已分配 payload，
  要么属于 allocator 管理的 free block / free slot，
  不能同时属于两个用户分配，
  也不能丢失到 allocator 无法再次管理的状态。
</code></pre>
<h2>2. 最关键的工程动作：中间结果落盘</h2>
<p>这次实验真正有效的做法不是“一口气写最终 allocator”，而是把每个阶段的中间结果落盘。</p>
<p>原因是 allocator 的错误经常不是普通 WA，而是：</p>
<ul>
<li>随机段错误。</li>
<li>链表被破坏后过很久才爆炸。</li>
<li>多线程下偶发死锁。</li>
<li>free 后复用错误。</li>
<li>benchmark 卡死。</li>
<li>正确性测试通过但性能差几个数量级。</li>
</ul>
<p>如果只保留最终代码，很多关键经验都会丢失：为什么大锁版本是必要的、为什么 per-node lock 不适合、为什么 slot 初始化会炸、为什么 thread-local cache 提升明显。</p>
<p>因此整个过程应该保留这些阶段产物：</p>
<table>
<thead>
<tr>
<th>阶段</th>
<th>落盘内容</th>
<th>价值</th>
</tr>
</thead>
<tbody>
<tr>
<td>大锁 baseline</td>
<td>一个正确但慢的 allocator</td>
<td>建立 split/coalesce 和 header 布局不变量</td>
</tr>
<tr>
<td>独立正确版</td>
<td>在当前框架外另存一份参考实现</td>
<td>后续优化出错时有可回退版本</td>
</tr>
<tr>
<td>本地测试系统</td>
<td><code>/home/katyusha/vibe</code> 下的 tester</td>
<td>弥补课程自带测试偏弱的问题</td>
</tr>
<tr>
<td>性能输出</td>
<td>glibc baseline 与 mymalloc 对比结果</td>
<td>判断优化是改善吞吐还是只修正确性</td>
</tr>
<tr>
<td>当前最终版</td>
<td><code>mymalloc/mymalloc.c</code></td>
<td>保留最新结构：large free-list + small slot/span + thread cache</td>
</tr>
<tr>
<td>实验笔记</td>
<td>本文件</td>
<td>记录设计决策、坑点和下一步优化方向</td>
</tr>
</tbody>
</table>
<p>这个“中间结果落盘”是 allocator 实验里的关键一刀。它把调试过程从“凭记忆试错”变成了“有阶段检查点的系统演进”。</p>
<h2>3. 阶段一：一把全局大锁的正确性版本</h2>
<p>最初的实现目标不是性能，而是保证最小 allocator 语义成立。</p>
<p>大锁版本的模型：</p>
<pre><code>mymalloc:
  lock(global)
    在 free list 找可用块
    找不到则 vmalloc 新页
    必要时 split
  unlock(global)

myfree:
  lock(global)
    通过 ptr 回退到 block header
    插回 free list
    尝试 coalesce
  unlock(global)
</code></pre>
<p>此时数据结构可以只有一个全局 free list：</p>
<pre><code>struct Node {
    struct Node *pre, *nxt;
    size_t size;
};
</code></pre>
<p>每个大块的内存布局：</p>
<pre><code>+----------------+----------------------+
| Node metadata  | user payload         |
+----------------+----------------------+
^                ^
block start      returned pointer
</code></pre>
<p>用户拿到的是 <code>Node</code> 之后的地址；释放时用：</p>
<pre><code>Node *node = (Node *)((char *)ptr - sizeof(Node));
</code></pre>
<p>找回块头。</p>
<p>这一版要先确认以下不变量：</p>
<ul>
<li>free list 中只保存空闲块。</li>
<li>已分配块不在 free list 中。</li>
<li>split 后剩余部分仍然是合法 <code>Node</code>。</li>
<li>coalesce 只合并物理地址相邻的空闲块。</li>
<li>所有链表修改都在同一把锁下完成。</li>
</ul>
<p>这一阶段落盘的意义：即使后续所有性能优化都失败，也仍然有一个语义正确的版本可以回退。</p>
<h2>4. 阶段二：地址有序 free list 与 split/coalesce</h2>
<p>free-list allocator 的关键不是“有一个链表”，而是链表顺序要服务于 coalesce。</p>
<p>如果链表按地址递增维护：</p>
<pre><code>free block A -&gt; free block B -&gt; free block C
地址也满足 A &lt; B &lt; C
</code></pre>
<p>插入一个释放块时，只要检查它的前驱和后继就能判断能否合并：</p>
<pre><code>is_adj(prev, p)
is_adj(p, next)
</code></pre>
<p>物理相邻的判断应该是字节地址判断：</p>
<pre><code>(char *)a + a-&gt;size == (char *)b
</code></pre>
<p>这一点非常重要。早期如果维护的是逻辑区间 <code>[l, r)</code>，但它不对应真实地址，就无法可靠判断两个块是否真的相邻。allocator 管的是虚拟地址空间中的真实区间，不是抽象编号。</p>
<p>分配时的 split 逻辑：</p>
<pre><code>原空闲块:
+-------------------------------+
| size                           |
+-------------------------------+

切出 len:
+---------------+---------------+
| allocated len | remainder      |
+---------------+---------------+
</code></pre>
<p>只有当 remainder 足够容纳 <code>Node</code> 和最小 payload 时才 split。否则整块给用户，避免产生无法管理的碎片。</p>
<p>这一阶段主要解决：</p>
<ul>
<li><code>free</code> 后内存能复用。</li>
<li>相邻 free block 能合并。</li>
<li>大块分配不会无限向系统申请新页。</li>
</ul>
<h2>5. 阶段三：从全局锁拆到 size-class 锁</h2>
<p>全局锁的瓶颈非常直接：</p>
<pre><code>所有线程、所有大小的 malloc/free 都抢同一把锁。
</code></pre>
<p>即使线程 A 申请 32B，线程 B 申请 4096B，它们也完全串行。</p>
<p>第一步拆锁不是给每个节点一把锁，而是按大小分类：</p>
<pre><code>static Node head[20], tail[20];
static int if_init[20];
static spinlock_t list_lock[20];
</code></pre>
<p>每个 size class 有自己的 free list 和锁。</p>
<p>这样做的正确性边界比较清晰：</p>
<ul>
<li>某个 class 的链表只能在持有 <code>list_lock[class]</code> 时修改。</li>
<li>一个 free block 在任意时刻只能属于一个 class。</li>
<li>split、remove、insert、coalesce 必须在同一把 class 锁内完成。</li>
<li>尽量避免一次操作持有多把 class 锁。</li>
</ul>
<p>这里踩过的坑是：如果当前 class 找不到块，就向更大的 class 借块，可能带来跨 class 锁顺序问题。</p>
<p>例如：</p>
<pre><code>线程 A: 持有 class 1，等待 class 2
线程 B: 持有 class 2，等待 class 1
</code></pre>
<p>这就是死锁。</p>
<p>因此当前实现选择了更稳的路线：大块按 <code>getid(size)</code> 进入一个 class，主要在本 class 内申请和回收。这样可能牺牲一点全局利用率，但锁协议简单很多。</p>
<h2>6. 为什么小对象要换成 slot/span</h2>
<p>free list 对大块合适，但对小对象不合适。</p>
<p>如果 24B 对象也走 <code>Node + payload</code>：</p>
<pre><code>+----------------+----------+
| Node metadata  | 24B user |
+----------------+----------+
</code></pre>
<p>问题有三个：</p>
<ol>
<li>元数据占比太高。</li>
<li>小对象分配频率极高，链表操作太频繁。</li>
<li>多线程同 class 小对象会高频抢锁。</li>
</ol>
<p>所以小对象更适合固定大小 slot：</p>
<pre><code>32B class:
  24B 请求 -&gt; 32B slot
  slot 内没有完整 Node header
</code></pre>
<p>大对象仍适合 free list，因为大对象大小差异大，保留 split/coalesce 能减少外部碎片。</p>
<p>因此当前最终结构是混合 allocator：</p>
<pre><code>size &lt;= 2048:
  slot/span allocator

size &gt; 2048:
  size-class free-list allocator
</code></pre>
<h2>7. 阶段四：span 管理</h2>
<p>当前小对象路径使用 span。</p>
<p>一个 span 是一页 4096B：</p>
<pre><code>#define SPAN_SIZE 4096
</code></pre>
<p>span 结构：</p>
<pre><code>struct span {
    size_t class_id;
    size_t cap;
    size_t size;
    int magic;
    void *free_list;
    struct span *nxt;
};
</code></pre>
<p>含义：</p>
<ul>
<li><code>class_id</code>：这个 span 属于哪个 size class。</li>
<li><code>cap</code>：这个 span 中最多有多少 slot。</li>
<li><code>size</code>：当前已经分配出去的 slot 数量。</li>
<li><code>magic</code>：用于在 <code>free(ptr)</code> 时判断它是不是 slot 分配。</li>
<li><code>free_list</code>：span 内部空闲 slot 链表。</li>
<li><code>nxt</code>：挂到 class 的 span 链表中。</li>
</ul>
<p>span 内存布局：</p>
<pre><code>4096B span

+----------------------+---------+---------+---------+-----+
| span metadata         | slot 0  | slot 1  | slot 2  | ... |
+----------------------+---------+---------+---------+-----+
^                      ^
span base              first aligned slot
</code></pre>
<p>空闲 slot 自己的开头存 next 指针：</p>
<pre><code>free slot:
+----------------+
| next pointer   |
+----------------+

allocated slot:
+----------------+
| user data      |
+----------------+
</code></pre>
<p>所以“一个 slot 是否被占用”的判断不是靠单独 bitmap，而是：</p>
<pre><code>如果它在 span-&gt;free_list 链上，就是空闲；
如果它不在 free_list 链上，就视为已分配。
</code></pre>
<p>当前 size class：</p>
<pre><code>const size_t SIZE[] = {32, 64, 128, 256, 512, 1024, 2048};
</code></pre>
<p>这对应一个简化版 slab/slot allocator。</p>
<h2>8. 阶段五：thread-local cache</h2>
<p>slot/span 解决了小对象元数据问题，但如果每次 <code>malloc/free</code> 仍然拿全局 class 锁，多线程性能还是上不去。</p>
<p>因此加入每线程缓存：</p>
<pre><code>struct threadcache {
    void *list[CLASS_NUM];
    size_t count[CLASS_NUM];
};

static thread_local threadcache tcache;
</code></pre>
<p>分配路径：</p>
<pre><code>slot_malloc(size):
  class = getclass(size)

  if 本线程 tcache[class] 非空:
    直接 pop 一个 slot
  else:
    lock(global class)
      批量取 CACHE_BATCH 个 slot
      放入 tcache[class]
    unlock(global class)
    pop 一个 slot 返回
</code></pre>
<p>释放路径：</p>
<pre><code>slot_free(ptr):
  找到 ptr 所在 span
  得到 class_id
  push 到本线程 tcache[class]

  if tcache[class] 太满:
    lock(global class)
      批量 flush 一部分 slot 回 span
    unlock(global class)
</code></pre>
<p>这个优化的本质：</p>
<pre><code>无 thread cache:
  每次小对象 malloc/free 都抢全局锁

有 thread cache:
  大多数 malloc/free 只操作本线程链表
  偶尔 refill/flush 才抢全局锁
</code></pre>
<p>这是当前性能提升最明显的一步。</p>
<p>代价也很明确：</p>
<ul>
<li>每个线程会暂存一些空闲 slot，增加内存滞留。</li>
<li>线程退出时如果不 drain，本地 cache 中的 slot 不会及时回到全局结构。</li>
<li>batch 太小锁竞争多，batch 太大内存滞留多。</li>
</ul>
<p>当前实现使用：</p>
<pre><code>#define CACHE_BATCH 32
#define CACHE_VOLUM 64
</code></pre>
<p>这是一个经验参数，不是理论最优。</p>
<h2>9. 当前最终 allocator 结构</h2>
<p>当前代码可以概括为：</p>
<pre><code>mymalloc(size)
|
+-- align8(size)
|
+-- size &lt;= 2048
|   |
|   +-- slot_malloc
|       |
|       +-- thread-local cache
|       +-- global class lock
|       +-- span available/full list
|       +-- vmalloc new span if needed
|
+-- size &gt; 2048
    |
    +-- list_malloc
        |
        +-- size-class lock
        +-- address ordered free list
        +-- find first enough block
        +-- split
        +-- vmalloc new page block if needed
</code></pre>
<p><code>myfree(ptr)</code> 的分类逻辑：</p>
<pre><code>page-align ptr -&gt; possible span base

if span-&gt;magic == SPAN_MAGIC:
  slot_free(ptr)
else:
  list_free(ptr)
</code></pre>
<p>这是一种实用但不完美的分类方式。</p>
<p>优点：</p>
<ul>
<li>slot 没有 per-object header，节省小对象元数据。</li>
<li>通过页对齐可以快速找到 span metadata。</li>
</ul>
<p>限制：</p>
<ul>
<li>依赖 span 由 4096 对齐页构成。</li>
<li>依赖大块路径不会在对应位置偶然出现相同 magic。</li>
<li>更工程化的实现通常会使用 page map、arena map 或统一 header 来区分来源。</li>
</ul>
<h2>10. 本地测试系统</h2>
<p>课程自带测试对并发 allocator 来说偏弱，尤其是之前看到的 concurrent test 主要检查 <code>malloc_count</code>，不等价于真正验证内存不重叠、free 后复用、多线程随机行为都正确。</p>
<p>因此额外写了本地测试系统，落盘在：</p>
<pre><code>/home/katyusha/vibe
</code></pre>
<p>测试系统包括：</p>
<ul>
<li>基本边界测试：0、1、小对象、跨 class、大对象。</li>
<li>重复分配释放测试：检查复用。</li>
<li>coalesce 测试：检查释放相邻块后能否合并。</li>
<li>single-thread random：随机大小、随机释放顺序。</li>
<li>multi-thread random：多线程随机 malloc/free。</li>
<li>perf-only：吞吐量 benchmark。</li>
<li>glibc baseline：同一套 workload 下对比 glibc malloc。</li>
</ul>
<p>这个测试系统的意义是把“我感觉可以”变成“有本地证据”。</p>
<p>尤其是性能优化时，不能只说“拆锁会更快”，而要看：</p>
<pre><code>single small
single medium
page-ish
threads same class
threads spread classes
</code></pre>
<p>分别有什么变化。</p>
<h2>11. 性能结果与解释</h2>
<p>一次较新的 benchmark 结果大致为：</p>
<pre><code>glibc malloc:
  single small 24B              约 166 Mops/s, 约 6 ns/op
  single medium 512B            约 112 Mops/s, 约 9 ns/op
  single page-ish 4096B         约 0.58 Mops/s, 约 1724 ns/op
  threads same class 32B        约 206 Mops/s, 约 4.9 ns/op
  threads spread classes        约 100 Mops/s, 约 10 ns/op

current mymalloc:
  single small 24B              约 90 Mops/s, 约 11 ns/op
  single medium 512B            约 52 Mops/s, 约 19 ns/op
  single page-ish 4096B         约 2.7 Mops/s, 约 369 ns/op
  threads same class 32B        约 68 Mops/s, 约 15 ns/op
  threads spread classes        约 29 Mops/s, 约 35 ns/op
</code></pre>
<p>这个结果说明：</p>
<ul>
<li>小对象已经不再是灾难级性能，slot + thread cache 有效。</li>
<li>中等对象仍弱于 glibc，主要差在 class 管理、cache 策略和元数据路径。</li>
<li>4096B 附近反而比 glibc 快，可能是测试 workload 下当前路径更直接。</li>
<li>多线程 spread classes 仍明显弱于 glibc，说明全局 class 锁、span 链表和 refill/flush 策略还有优化空间。</li>
</ul>
<p>更重要的是，性能结果证明了中间版本落盘的价值：如果没有大锁 baseline、free-list 版本、slot 版本、thread-cache 版本，就无法判断性能提升来自哪一步。</p>
<h2>12. 主要踩坑</h2>
<h3>12.1 <code>vmalloc</code> 不是输出参数</h3>
<p>错误理解：</p>
<pre><code>vmalloc 把结果写进传入指针
</code></pre>
<p>正确理解：</p>
<pre><code>void *p = vmalloc(NULL, length);
</code></pre>
<p>它直接返回地址。</p>
<p>并且 <code>length</code> 应该是 4096 的倍数。不能对 split 出来的小块调用 <code>vmfree</code>。</p>
<h3>12.2 <code>mymalloc(s)</code> 不要求精确返回 s 字节</h3>
<p>真实 allocator 都会有对齐和 size class。</p>
<p>因此：</p>
<pre><code>mymalloc(24) 返回 32B slot 是合法的。
</code></pre>
<p>只要用户能安全访问前 24B 即可。</p>
<h3>12.3 链表必须基于真实地址</h3>
<p>coalesce 依赖物理相邻。</p>
<p>所以链表中块的范围必须对应真实地址，而不是抽象编号。</p>
<p>正确模型：</p>
<pre><code>block_start + block_size == next_block_start
</code></pre>
<h3>12.4 指针运算容易产生 UB 或 GNU C 依赖</h3>
<p>典型错误：</p>
<pre><code>Node *p;
p + sizeof(Node);   // 错：按 Node 为单位移动
</code></pre>
<p>应该写成：</p>
<pre><code>(char *)p + sizeof(Node)
</code></pre>
<p>当前代码中仍有一些 <code>void *</code> 指针算术和 <code>unsigned long long</code> 地址转换。它们在 GNU C 下通常可工作，但更严谨的写法应该使用：</p>
<pre><code>char *
uintptr_t
</code></pre>
<p>分别处理字节指针和整数地址。</p>
<h3>12.5 per-node lock 不适合当前 allocator</h3>
<p>曾考虑过每个链表节点一把锁：</p>
<pre><code>查找进入节点时加锁
离开时释放
插入/删除时同时锁前驱、当前、后继
</code></pre>
<p>问题是：</p>
<ul>
<li>节点可能被 split 或 coalesce 后消失。</li>
<li>其他线程可能还想访问这个节点的锁，生命周期很难管理。</li>
<li>同时锁多个节点需要严格锁顺序，否则死锁。</li>
<li>allocator 链表操作很短，per-node lock 的开销和复杂性不划算。</li>
</ul>
<p>所以更稳定的拆锁方向是 size-class lock，而不是 node lock。</p>
<h3>12.6 跨 class 借块会引入锁顺序问题</h3>
<p>如果 class A 没有空间就去 class B 找，会出现多锁操作。</p>
<p>除非全局规定严格锁顺序，否则容易死锁。</p>
<p>当前实现避免跨 class 借块，是为了保持锁协议简单。</p>
<h3>12.7 slot 初始化边界错误会导致随机段错误</h3>
<p>span 切 slot 时必须保证：</p>
<pre><code>slot_start + slot_size &lt;= span_start + SPAN_SIZE
</code></pre>
<p>常见错误：</p>
<ul>
<li>next 指针写到了页外。</li>
<li><code>s + slot_size</code> 把 <code>span *</code> 当字节指针。</li>
<li>最后一个 slot 的 next 没有置空。</li>
<li>用 <code>unsigned</code> 保存指针导致 64 位地址截断。</li>
<li>第一个 slot 没有按 8 字节对齐。</li>
</ul>
<p>这类错误很隐蔽，因为第一次分配可能没问题，等 free-list pop 到坏指针时才段错误。</p>
<h3>12.8 空 span 不能轻易立刻 <code>vmfree</code></h3>
<p>理论上 span 全空后可以还给系统。</p>
<p>但当前有 thread-local cache 后，某些 slot 可能还滞留在线程本地 cache 中。如果直接 <code>vmfree(span)</code>，cache 中的 slot 就变成悬垂指针。</p>
<p>因此当前版本注释掉了积极归还 empty span 的逻辑，选择先保留 span 复用。</p>
<p>这是一个合理的阶段性选择：先保证正确性和性能，再做更复杂的 span 生命周期管理。</p>
<h2>13. 当前代码仍需注意的问题</h2>
<p>当前版本已经通过本地正确性测试，并且性能相比早期版本有明显提升，但还有几个工程上不够干净的地方：</p>
<ol>
<li><code>void *</code> 指针算术依赖 GNU C 扩展，应改成 <code>char *</code>。</li>
<li>地址整数转换应优先用 <code>uintptr_t</code>，而不是 <code>unsigned long long</code>。</li>
<li><code>magic</code> 分类方式有小概率误判风险，最好用 page map 或统一 metadata。</li>
<li><code>getclass</code> 对大于 2048 的路径没有显式返回，虽然调用方已经过滤，但函数本身不够完整。</li>
<li>thread-local cache 没有在线程退出时 drain。</li>
<li>span 的 <code>full/available</code> 链表移动仍然有线性查找。</li>
<li>大块 free list 仍是 first-fit 线性扫描。</li>
<li><code>malloc_count</code> 虽是 atomic，但它更像测试辅助变量，不应参与 allocator 核心设计。</li>
</ol>
<p>这些不是当前实验一定要全部解决的问题，但应该在笔记里保留，防止以后把“能过测试”误认为“已经工程完备”。</p>
<h2>14. 后续优化方向</h2>
<p>后续如果继续优化，可以按风险从低到高推进：</p>
<ol>
<li>清理 UB：统一使用 <code>char *</code> 和 <code>uintptr_t</code> 做地址计算。</li>
<li>调整 size class：根据 benchmark workload 优化 class 分布。</li>
<li>调整 <code>CACHE_BATCH</code> 和 <code>CACHE_VOLUM</code>：平衡锁竞争和内存滞留。</li>
<li>给 span 增加更清晰状态：empty、partial、full。</li>
<li>建立 page map：通过页号直接找到 span 或大块 metadata。</li>
<li>支持线程退出时 drain thread-local cache。</li>
<li>大块路径使用更好的数据结构，例如 segregated list 更细分或 tree。</li>
<li>增加更强 stress：跨线程 free、长时间随机 trace、ASan/UBSan、重复 benchmark。</li>
</ol>
<h2>15. 总结</h2>
<p>这次 allocator 的完整路线是：</p>
<pre><code>全局大锁 baseline
  -&gt; 地址有序 free list
  -&gt; split/coalesce
  -&gt; size-class lock
  -&gt; 小对象 slot/span
  -&gt; thread-local cache
  -&gt; 本地 correctness + perf 测试系统
  -&gt; 写入实验笔记复盘
</code></pre>
<p>其中最重要的不是某个具体技巧，而是阶段化方法：</p>
<pre><code>先做正确版本；
再拆锁；
再分类；
再缓存；
每一步都落盘；
每一步都用测试验证。
</code></pre>
<p>allocator 的难点在于它维护的是一套隐式状态机：每块内存不断在 “系统页”、“free block”、“allocated payload”、“free slot”、“thread cache slot” 等状态之间转换。只要某次状态转移没有被锁保护，或者元数据没有同步更新，错误就可能在很久之后才出现。</p>
<p>当前版本已经具备现代 allocator 的简化雏形：</p>
<ul>
<li>大对象用 free list 管理，支持 split 和 coalesce。</li>
<li>小对象用 span/slot 管理，降低元数据开销。</li>
<li>多线程用 thread-local cache 减少锁竞争。</li>
<li>本地测试系统用于验证正确性和性能。</li>
</ul>
<p>这比单纯完成 lab 更重要：它把 malloc 从一个 API 练习，推进到了 runtime memory manager 的设计问题。</p>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
  <entry>
    <title>1.绪论</title>
    <link href="https://katyusha-blog.com/posts/nju-os/os/intro/intro/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/os/intro/intro/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-01T00:00:00.000Z</updated>
    <summary>1.绪论</summary>
    <content type="html"><![CDATA[<p>计算机世界没有魔法。</p>
<h2>应用视角的操作系统</h2>
<p>应用视角的操作系统是一组 <code>API</code>：进程、地址空间、文件描述符、内存映射等资源都通过系统调用访问。</p>
<h3>程序</h3>
<p><code>Everything is a state machine</code>：一切都是状态机。</p>
<p>硬件状态可以粗略看成：</p>
<pre><code>(registers, memory)
</code></pre>
<p>CPU 执行指令，就是把状态迁移到下一个状态。用户态程序也类似：</p>
<pre><code>用户态寄存器 + 用户虚拟地址空间 + OS 维护的进程状态
</code></pre>
<p>虚拟地址空间包含代码段、只读数据、全局变量、堆、栈、<code>mmap</code> 区域等。</p>
<p>注意：<code>PC/RIP</code> 是寄存器，不在栈顶。CPU 根据 <code>PC/RIP</code> 取下一条指令；栈上主要保存返回地址、局部变量、保存的寄存器等函数调用状态。</p>
<p>程序也不是从 <code>main</code> 直接开始。Linux 执行 ELF 时大致是：</p>
<pre><code>execve
  -&gt; 内核加载 ELF，建立地址空间
  -&gt; 准备初始用户栈：argc / argv / envp / auxv
  -&gt; 设置 RIP = ELF entry，通常是 _start
  -&gt; _start 初始化运行时，再调用 main
</code></pre>
<p>所以：</p>
<pre><code>_start 是进程真正入口；
main 是 C/C++ 运行时调用的普通函数。
</code></pre>
<p>函数调用通常会创建栈帧，但这是 ABI/编译器约定；优化后可能内联、尾调用、省略帧指针，因此不能把“每个函数一定有一个栈帧”当成语义保证。</p>
<p><code>C</code> 可以改写成“每行只做一件事”的 Simple C 风格，因此接近机器模型；但现代 C 不是简单的高级汇编，它有 UB、别名规则、对象生命周期和优化语义。</p>
<h3>编译器</h3>
<p>早期编译器可以近似理解为直接翻译语句；现代编译器会在保持可观察行为不变的前提下激进优化。</p>
<p>编译正确性：</p>
<pre><code>对任意符合语言语义的输入，编译后程序与源程序具有相同的可观察行为。
</code></pre>
<p>可观察行为包括程序终止性、<code>volatile</code> 访问、I/O、系统调用产生的外部效果、与外部函数/ABI 的交互等。</p>
<p>如果计算结果不影响可观察行为，编译器可以删除它；但 <code>write/read/mmap/exit</code> 这类有外部副作用的调用不能被当作普通算术随意删除、合并或重排。</p>
<p>系统调用相关优化要分层：</p>
<pre><code>编译器：可能优化 printf("x\n") 这类库调用形式
libc：通过 stdio buffering 减少 write 次数
内核：执行 syscall 后走具体内核路径
</code></pre>
<h3>系统调用指令</h3>
<p>最小的 <code>hello world</code> 不是在 <code>main</code> 中调用 <code>printf</code>，而是从 <code>_start</code> 开始直接执行：</p>
<pre><code>write(1, "hello world\n", 12)
exit(0)
</code></pre>
<p>Linux x86-64 系统调用 ABI：</p>
<pre><code>rax = syscall number
rdi/rsi/rdx/r10/r8/r9 = args
syscall
</code></pre>
<p><code>write()</code> 是 C 库函数，真正进入内核的是 <code>syscall</code> 指令。</p>
<p>用户态程序直接能做的事情：</p>
<pre><code>改变用户态可见寄存器状态；
读写自己有权限的虚拟内存；
通过 syscall/ecall/svc 请求内核服务。
</code></pre>
<p>系统调用会把状态机暂时交给 OS：CPU 切到内核态，内核检查参数和权限，执行服务，再返回用户态；<code>exit</code> 这类系统调用可能不返回。</p>
<p>程序也可能通过异常被动进入内核，例如缺页、除零、非法指令、非法地址访问。非法内存访问通常最终变成 <code>SIGSEGV</code>。</p>
<h3>应用、工具程序、后台程序</h3>
<p>用户态程序大致包括：</p>
<pre><code>Applications：浏览器、IDE、播放器等面向用户任务的程序
Utilities：ls、cat、cp、mv、rm、sort、wc 等工具程序
Daemons：systemd、sshd、cron 等长期运行的后台服务
</code></pre>
<p>它们本质上都是：</p>
<pre><code>用户态计算 + 系统调用
</code></pre>
<p><code>coreutils</code> 是 GNU 提供的一组基础命令行工具集合，例如 <code>ls/cat/cp/mv/rm/mkdir/wc/sort/date/sleep</code>。它不是“系统调用薄包装”的泛称，而是一批标准工具程序；这些程序内部通常调用 libc/POSIX API，最终落到系统调用。</p>
<p>典型例子：</p>
<pre><code>cat file -&gt; openat/read/write/close
ls       -&gt; openat/getdents64/stat/write
</code></pre>
<p><code>daemon</code> 也是普通用户态进程，只是长期运行、通常没有交互式终端，用来提供系统服务。</p>
<p>Linux 下可以用 <code>strace</code> 观察程序发出的系统调用；它看到的是用户程序和内核之间的 syscall 边界，而不是所有普通用户态指令或 libc 内部细节。</p>
<h2>硬件视角的操作系统</h2>
<p>硬件根本不知道有没有操作系统。从硬件看，OS 首先也只是会被 CPU 执行的一段程序；机器只关心状态、初始状态和状态迁移。</p>
<h3>计算机的状态机模型</h3>
<p>最简模型是：</p>
<pre><code>(registers, memory)
</code></pre>
<p>CPU 每执行一条指令，就是把当前状态迁移到下一个状态；<code>PC/RIP</code> 决定下一条从哪里取指。</p>
<p>但只看寄存器和内存还不够，因为真实计算机不是封闭系统。还要加入“外部世界”：I/O 设备，中断线，GPIO 这类最基础的设备引脚等</p>
<p>CPU 和设备通信的典型方式有两种：</p>
<ul>
<li><code>port I/O</code>：例如 x86 的 <code>in/out</code></li>
<li><code>MMIO</code>：把设备寄存器映射到物理地址空间，像访存一样读写GPIO 就是最简单的 <code>MMIO</code> 设备之一</li>
</ul>
<p>对多处理器系统，可以先用一个简化模型理解：</p>
<pre><code>每个 CPU 有自己的寄存器；
处理器之间共享内存和外设；
系统的执行像是在多个 CPU 之间交错选择一步。
</code></pre>
<p>这个模型足够建立直觉；但真实机器上还会叠加 cache、乱序执行和 memory model，因此并发行为会比“轮流单步执行”复杂得多。</p>
<h3>硬件的初始状态</h3>
<p><code>reset</code> 不是普通中断。中断的语义是“打断当前控制流，保存现场，处理完再回来”；<code>reset</code> 的语义是“当前上下文作废，硬件强制回到定义好的初始状态重新开始”。</p>
<p>这就是为什么老式机器常有 <code>reset</code> 按钮：系统可能已经因为内核、驱动或硬件状态异常而不再可靠响应，中断也未必救得回来，只能丢弃当前状态重启。</p>
<h3>固件</h3>
<p>断电后 RAM 内容可能丢失，但如果不加处理，<code>reset</code> 后 CPU 的第一条指令会来自那片不可信的 RAM，导致执行非法指令。</p>
<p>所以<code>reset</code> 后不是从随机 RAM 开始跑，而是从预先约定好的启动入口取指，这个过程由固件<code>firmware</code>控制。</p>
<p><code>firmware</code> 是 reset 后第一个执行的软件，它可以看作“OS 之前的 OS”。</p>
<pre><code>CPU reset
  -&gt; PC 指向 firmware
  -&gt; firmware 初始化最小硬件环境
  -&gt; 初始化 DRAM / 检测设备
  -&gt; 加载后续 bootloader / kernel
</code></pre>
<p>早期 OS能力弱， firmware 往往还长期提供运行时服务；现代系统里，OS 启动后通常会尽快接管设备和资源管理，firmware 更多只负责引导和少量平台服务。</p>
<p><code>BIOS</code>和<code>UEFI</code>是firmware的两种实现标准，其中后者是一种更现代的，更模块化的架构替代</p>
<p><code>BIOS</code> 是传统 PC 固件：16 位实模式、接口简单、历史包袱重。IBM PC 的经典启动流程是：</p>
<pre><code>BIOS 扫描启动设备
  -&gt; 读取启动盘前 512B 到 0x7c00
  -&gt; 检查末尾魔数 0x55AA
  -&gt; 跳转到 0x7c00 执行
</code></pre>
<p><code>UEFI</code> 则不是“更大的 BIOS 中断库”，而是一套更完整的固件执行环境和启动规范。它通常能直接识别分区和文件系统，加载 <code>.efi</code> 程序；本质上更像一个最小固件操作环境。</p>
<p>从 OS 视角，BIOS 和 UEFI 的共同点比差异更重要：</p>
<p>可以把整个启动链压缩成：</p>
<pre><code>CPU Reset -&gt; Firmware -&gt; Bootloader -&gt; Kernel -&gt; 第一个用户进程 
</code></pre>
<p>补充：<code>CIH</code>病毒通过获取足够高的权限，对firmware进行写，导致机器无法启动</p>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
  <entry>
    <title>12.并发 Bugs 和应对</title>
    <link href="https://katyusha-blog.com/posts/nju-os/os/multi-thread/concurrent_bugs/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/os/multi-thread/concurrent_bugs/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-21T00:00:00.000Z</updated>
    <summary>12.并发 Bugs 和应对</summary>
    <content type="html"><![CDATA[<h1>并发 Bugs 与应对</h1>
<p>并发 bug 的统一模型是：程序员在脑中假设了一种执行顺序，但真实机器允许更多 interleaving、重排和可见性延迟。<br />
所以修 bug 的核心不是“多加几把锁”，而是把隐含顺序显式写成同步约束：</p>
<pre><code>共享状态 + 不变量 + happens-before
</code></pre>
<p>只要一个并发程序不能说清楚“哪些状态由谁保护、哪些事件必须先发生”，它就只能靠调度运气运行。</p>
<h2>并发错误的根源</h2>
<p>顺序程序可以近似看成：</p>
<pre><code>state_{i+1} = f(state_i)
</code></pre>
<p>多线程程序则多了一层调度选择：</p>
<pre><code>1. 每个线程内部仍保持局部顺序
2. 全局执行由多个线程的局部步骤交错而成
3. 编译器和处理器还可能在内存模型允许范围内重排
</code></pre>
<p>因此，课堂里把并发 bug 叫做 Learning from Mistakes：不是因为 API 难，而是因为人的顺序直觉会自动补全一些机器没有承诺的东西。</p>
<p>典型错误都可以归结为某种错误假设：</p>
<ul>
<li>死锁：以为“我最终能拿到所有资源”。</li>
<li>数据竞争：以为“别人不会同时访问这个内存位置”。</li>
<li>原子性违反：以为“check 和 act 之间不会插入别的线程”。</li>
<li>ABA：以为“值还是 A 就说明状态没变过”。</li>
<li>TOCTOU：以为“检查完之后对象不会被替换”。</li>
<li>顺序违反：以为“代码顺序就是其他线程观察到的顺序”。</li>
</ul>
<h2>死锁的基本模型</h2>
<p>Deadlock 是一种系统状态：一组线程里的每个成员都在等待组内另一个成员采取行动，甚至可能是等待自己。</p>
<p>最小例子是 AA 型死锁：</p>
<pre><code>mutex_lock(&amp;A);
mutex_lock(&amp;A); // 非递归 mutex: 自己等自己
</code></pre>
<p>这看起来像是显然不会写出的错误，但真实代码里第二次加锁可能藏在 callback、递归、错误处理路径或多层封装里。只要 <code>pthread_mutex_t</code> 是普通非递归锁，同一线程第二次 <code>lock</code> 就会阻塞在自己持有的锁上。</p>
<p>另一个经典形态是 ABBA 型死锁：</p>
<pre><code>// Thread 1
mutex_lock(&amp;A);
mutex_lock(&amp;B);

// Thread 2
mutex_lock(&amp;B);
mutex_lock(&amp;A);
</code></pre>
<p>如果 T1 持有 A 等 B，T2 持有 B 等 A，等待图中出现环，两个线程都无法推进。哲学家吃饭问题就是 ABBA 的环形推广：每个人都拿了左手叉子，然后等待右手叉子。</p>
<h2>死锁的四个必要条件</h2>
<p>Coffman 等人在 1971 年总结了死锁的四个必要条件。关键是“必要条件”的含义：四条必须同时成立，死锁才可能发生；打破任意一条，死锁就不会发生。</p>
<pre><code>1. Mutual Exclusion: 资源互斥占用
2. Wait-For: 持有已有资源，同时等待更多资源
3. No Preemption: 资源不能被外部强制抢走
4. Circular Wait: 等待关系形成环
</code></pre>
<p>这四条不是“满足就一定死锁”的充分条件，而是“死锁发生时一定满足”的必要条件。很多网上速记会把这个逻辑讲反。</p>
<p>对应到代码：</p>
<ul>
<li>AA 死锁主要暴露的是 wait-for：线程已经持有 A，却又等待 A。</li>
<li>ABBA 死锁主要暴露的是 circular wait：<code>A -&gt; B</code> 与 <code>B -&gt; A</code> 构成环。</li>
<li>哲学家吃饭问题暴露的是多节点 circular wait。</li>
</ul>
<h2>破坏死锁条件的方法</h2>
<p>所有死锁治理都可以映射回“打破四条件”。</p>
<h3>破坏互斥占用</h3>
<p>有些资源天生互斥，例如锁保护的共享数据结构、独占设备、文件写入位置。<br />
这条通常最难破坏，因为它本来就是资源正确性的来源。能做的优化往往是减少共享状态，或者把共享资源改成可复制、可合并的结构。</p>
<p>例如并行 reduce 中，每个线程使用私有局部累加器，最后再合并，就相当于减少了对同一个共享计数器的互斥需求。</p>
<h3>破坏持有并等待</h3>
<p>最直接的方法是一把大锁：</p>
<pre><code>mutex_lock(&amp;global);
// 一次性访问所有共享状态
mutex_unlock(&amp;global);
</code></pre>
<p>这样线程不再“持有 A 等 B”，因为它只需要获取一个全局资源。<br />
缺点也明显：并行度会被大锁压扁，临界区占比变大后，Amdahl 瓶颈会非常硬。</p>
<p>另一种思想是 transactional memory：把一段共享内存操作当成事务执行，语义上要求 all or nothing。</p>
<pre><code>atomic {
    A -= 100;
    B += 100;
}
</code></pre>
<p>事务内存的模型是：先乐观执行，记录 read set / write set，提交时检查冲突；无冲突则一次性提交，有冲突则 abort 并回滚重试。<br />
它试图避免程序员手动管理多把锁的顺序，从而降低 wait-for 和 lock ordering 的复杂度。</p>
<p>但它难实现，也不能覆盖所有副作用：</p>
<ul>
<li>I/O、系统调用、打印、网络发送通常无法自然回滚。</li>
<li>HTM 受 cache 容量、中断、调度、系统调用限制，可能无冲突也 abort。</li>
<li>STM 需要软件维护日志、版本号和回滚，开销较大。</li>
<li>事务反复 abort 可能导致活锁或饥饿。</li>
</ul>
<p>所以它是理解“all or nothing 临界区”的好模型，但不是工程里的万能替代品。</p>
<h3>破坏不可抢占</h3>
<p>如果系统能在发现危险时撤销某个线程的资源持有，就可以破坏 no-preemption。数据库事务常用这种方式：检测到死锁后选择一个事务 abort，释放它持有的锁，让其他事务继续。</p>
<p>在普通 C/Pthreads 程序里，这很难，因为线程持有锁时可能已经修改了复杂共享状态、执行了不可回滚的 I/O，外部很难安全地“抢锁”。<br />
因此普通 mutex 世界里更常见的是预防锁顺序问题，而不是运行时强行回滚线程。</p>
<h3>破坏循环等待</h3>
<p>工程上最常用的方法是 lock order：给所有锁规定一个全局顺序，所有线程必须按同一顺序加锁。</p>
<pre><code>A &lt; B &lt; C

允许: lock(A); lock(B); lock(C);
禁止: lock(C); lock(A);
</code></pre>
<p>如果所有边都从低编号锁指向高编号锁，等待图就不可能形成环。这条规则非常朴素，但能处理大量 ABBA 问题。</p>
<h2>动态死锁检测</h2>
<p>死锁的症状通常比数据竞争明显：程序本该继续输出，突然完全静默；用 GDB attach 进去，经常能看到所有线程阻塞在 <code>futex_wait</code> 或 mutex lock 路径上。</p>
<p>但更好的目标不是等它真的卡死，而是在测试时发现潜在环。</p>
<p>lockdep 的核心思路是把上锁顺序转化成图：</p>
<pre><code>线程当前持有 A，再请求 B =&gt; 加边 A -&gt; B
线程当前持有 B，再请求 A =&gt; 加边 B -&gt; A
图里出现环 =&gt; 存在潜在 ABBA 死锁
</code></pre>
<p>伪代码模型：</p>
<pre><code>thread_local vector&lt;mutex_t *&gt; held;
Graph order;

void on_lock(mutex_t *m) {
    for (mutex_t *h : held) {
        order.add_edge(h, m);
    }
    if (order.has_cycle()) {
        warn("potential deadlock");
    }
    held.push_back(m);
}
</code></pre>
<p>讲义中还强调了 <code>LD_PRELOAD</code> 的做法：写一个共享库定义同名 <code>pthread_mutex_lock/unlock</code>，通过动态链接器优先加载我们的版本，就能在不重新编译目标程序的情况下插桩记录锁顺序。Linux 内核的 lockdep 也是同类思想的工程化版本，只是要额外处理 spinlock、rwlock、RCU、中断上下文等复杂情况。</p>
<h2>数据竞争</h2>
<p>数据竞争的判定条件是：</p>
<pre><code>1. 至少两个线程访问同一内存位置
2. 至少一个访问是写
3. 这些访问之间没有 happens-before 关系
</code></pre>
<p>例如：</p>
<pre><code>int x = 0;

void T1() { x++; }
void T2() { x++; }
</code></pre>
<p><code>x++</code> 会被拆成 load / add / store。两个线程可能都读到旧值，再分别写回，导致 lost update。<br />
更重要的是，在 C/C++ 内存模型里，普通变量上的 data race 是 undefined behavior。编译器可以基于“无数据竞争程序”的假设优化代码，因此不能把它只理解为“结果随机一点”。</p>
<p>正确修复有两条主路：</p>
<ul>
<li>用同一把 mutex 保护同一组共享不变量。</li>
<li>如果只是单变量原子读改写，用 atomic 并明确内存序。</li>
</ul>
<p>锁的含义不仅是互斥，还建立 release-acquire happens-before：</p>
<pre><code>T1: 写共享状态; unlock(m)
T2: lock(m); 读共享状态

unlock(m) happens-before lock(m)
</code></pre>
<p>所以“加锁”不是形式动作，必须保护同一个状态条件和同一组不变量。</p>
<h2>ThreadSanitizer 的检测模型</h2>
<p>ThreadSanitizer 通过编译期插桩和运行期记录访问历史来检测 happens-before race：</p>
<pre><code>gcc -fsanitize=thread -g main.c -o main
./main
</code></pre>
<p>它大致维护：</p>
<ul>
<li>每个线程的逻辑时间；</li>
<li>每个同步操作建立的 happens-before；</li>
<li>每个内存位置最近读写历史。</li>
</ul>
<p>当两个访问没有 happens-before，且至少一个是写，就报告 race。<br />
TSan 的限制也要记住：动态工具只能检查这次运行覆盖到的路径；如果错误 interleaving 没被触发，它未必能报告。</p>
<p>相关工具的定位：</p>
<ul>
<li>Eraser：早期 dynamic race detector，核心是 lockset 思想。</li>
<li>Helgrind：Valgrind 套件里的线程错误检测工具，不需要重编译但慢。</li>
<li>ThreadSanitizer：编译插桩，工业里更常用。</li>
<li>KCSAN：Linux 内核中的并发访问检测工具。</li>
</ul>
<h2>Therac-25 的事故教训</h2>
<p>讲义用 Therac-25 强调了一点：并发 bug 不只是输出错几个数字，它可能直接伤害现实世界中的人。</p>
<p>Therac-25 是一台放射治疗设备，软件需要协调操作员输入、治疗模式、靶板位置和射线剂量。事故中的关键问题可以抽象成：</p>
<pre><code>UI 线程快速修改治疗模式
硬件控制线程尚未把保护装置移动到正确位置
软件却继续按“状态已经一致”的假设发射高剂量射线
</code></pre>
<p>这类 bug 的本质是 race condition / order violation：程序以为模式切换、硬件状态更新、剂量控制之间有可靠顺序，但代码没有用同步机制把这个顺序固定下来。<br />
它的工程教训是：涉及设备、权限、安全边界的并发程序，不能只依赖“正常操作路径”测试；必须把异常速度、重复输入、中断、取消、回滚路径都纳入状态机验证。</p>
<h2>原子性违反</h2>
<p>Atomicity violation 是“程序员以为一段代码不可打断，但实际上它可以被插入别的线程”。</p>
<p>典型 check-then-act：</p>
<pre><code>if (ptr != NULL) {
    *ptr = 42;
}
</code></pre>
<p>错误窗口在检查和使用之间：</p>
<pre><code>T1: 读到 ptr != NULL
T2: free(ptr); ptr = NULL
T1: *ptr = 42
</code></pre>
<p>修复不是“多检查一次”，而是让检查和使用属于同一个临界区：</p>
<pre><code>mutex_lock(&amp;m);
while (ptr == NULL) {
    cond_wait(&amp;cv, &amp;m);
}
*ptr = 42;
mutex_unlock(&amp;m);
</code></pre>
<p>条件变量模板之所以要求 <code>while + cond_wait</code>，就是为了在“条件成立且锁在手”的状态下继续执行。<br />
这里的关键不变量是：只要线程从 <code>while</code> 之后继续运行，它仍持有保护 <code>ptr</code> 的锁，因此别的线程不能在 act 前破坏条件。</p>
<h2>ABA 与 Use-After-Free</h2>
<p>ABA 问题是：一个值从 A 变成 B，又变回 A；观察者只看到“还是 A”，于是误以为状态没变过。</p>
<p>在 CAS 和 lock-free 数据结构里很常见：</p>
<pre><code>T1: 读取 top = A
T2: pop A; pop B; push A
T1: CAS(top, A, next_of_A) 成功
</code></pre>
<p>T1 的 CAS 只验证了“top 现在等于 A”，没验证中间有没有发生过结构变化。<br />
Use-After-Free 也可以看成指针层面的 ABA：</p>
<pre><code>T1: 保存指针 p -&gt; 对象 X
T2: free(X)
T2: malloc 得到同一地址，构造对象 Y
T1: 继续通过 p 访问，以为是 X，实际写到 Y
</code></pre>
<p>防御方法通常不是简单加 atomic，而是管理对象生命周期：</p>
<ul>
<li>引用计数；</li>
<li>hazard pointer；</li>
<li>epoch-based reclamation；</li>
<li>给指针附加版本号，CAS 比较 <code>(ptr, version)</code>。</li>
</ul>
<p>也就是说，ABA 的核心是“值相等不代表语义状态相同”。</p>
<h2>TOCTOU</h2>
<p>TOCTOU 是 Time of Check to Time of Use：检查和使用之间存在时间窗口，对象可能被别人替换。</p>
<p>经典模式：</p>
<pre><code>if (is_safe(path)) {
    open(path);
}
</code></pre>
<p>检查的是路径当时指向的对象；使用时路径可能已经被攻击者换成符号链接。讲义里提到的 sendmail 类漏洞就是：setuid root 程序先检查 mailbox 不是 symlink，随后攻击者在 check/use 窗口把它替换成指向 <code>/etc/passwd</code> 的 symlink，最终高权限程序写错目标。</p>
<p>修复原则是：不要检查一个可变名字后再用它；要拿到稳定对象句柄后检查。</p>
<p>例如：</p>
<pre><code>int fd = open(path, O_NOFOLLOW | O_APPEND);
fstat(fd, &amp;st);
// 后续使用 fd，而不是重新解析 path
</code></pre>
<p>文件描述符绑定的是内核 open file object / inode 引用；只要拿到了 fd，后续路径名怎么变，都不会把这个 fd 变成另一个文件。</p>
<h2>顺序违反</h2>
<p>Order violation 是：程序员假设 A 一定先于 B，但没有同步原语保证。</p>
<p>常见初始化发布错误：</p>
<pre><code>// Thread 1
config = load_config();
ready = true;

// Thread 2
while (!ready) {}
use(config);
</code></pre>
<p>这里有两层问题：</p>
<ul>
<li>编译器可能重排或缓存普通变量访问；</li>
<li>CPU 可能让 <code>ready</code> 的写先于 <code>config</code> 对另一个核心可见。</li>
</ul>
<p>如果 <code>ready</code> 和 <code>config</code> 都是普通共享变量，这本身还会形成 data race。正确发布需要 release-acquire：</p>
<pre><code>// Thread 1
config = load_config();
atomic_store_explicit(&amp;ready, true, memory_order_release);

// Thread 2
while (!atomic_load_explicit(&amp;ready, memory_order_acquire)) {}
use(config);
</code></pre>
<p>release 的含义是：发布 <code>ready=true</code> 前的写入不能跑到发布之后。<br />
acquire 的含义是：观察到 <code>ready=true</code> 后，后续读能看到发布者 release 前写入的状态。</p>
<p>如果同步条件更复杂，直接用 mutex + condition variable 往往更清晰。</p>
<h2>防御性编程原则</h2>
<p>并发程序的防御性编程，不是把每个地方都包一把锁，而是让状态关系可检查、可插桩、可复现。</p>
<h3>写出共享状态的不变量</h3>
<p>例如生产者消费者：</p>
<pre><code>0 &lt;= count &lt;= N
</code></pre>
<p>例如转账：</p>
<pre><code>A + B 总额不变
</code></pre>
<p>例如锁顺序：</p>
<pre><code>所有线程只能按 A &lt; B &lt; C 获取锁
</code></pre>
<p>不变量写不出来，后面的锁、条件变量、信号量都只能靠局部直觉拼凑。</p>
<h3>让同步边界尽量小而完整</h3>
<p>临界区应该覆盖共享状态的检查和修改，不能只锁一半：</p>
<pre><code>mutex_lock(&amp;m);
if (balance &gt;= x) {
    balance -= x;
}
mutex_unlock(&amp;m);
</code></pre>
<p>但临界区也不应无限扩大。耗时计算、I/O、RPC、sleep 尽量放到锁外，否则会制造性能瓶颈和新的死锁机会。</p>
<h3>默认使用工具</h3>
<p>并发 bug 不能只靠“我想清楚了”。</p>
<ul>
<li>死锁：GDB attach、线程栈、lockdep、锁顺序图。</li>
<li>数据竞争：ThreadSanitizer、Helgrind、KCSAN。</li>
<li>UAF / ABA：ASan、KASAN、引用计数检查、生命周期审计。</li>
<li>TOCTOU：安全审计、fd-based API、<code>O_NOFOLLOW</code>、权限边界检查。</li>
</ul>
<p>动态工具不能证明无 bug，但能快速打掉大量错误假设。</p>
<h3>不要相信测试次数</h3>
<p>并发 bug 的触发依赖调度。一次、十次、一万次没触发，只能说明测试覆盖的 interleaving 里没出事。</p>
<p>更有效的测试方式是：</p>
<ul>
<li>增加线程数和循环次数；</li>
<li>在关键路径插入随机 yield / sleep；</li>
<li>固定随机种子复现；</li>
<li>用 sanitizer 和断言检查不变量；</li>
<li>把“偶现错误”当作确定存在的逻辑漏洞，而不是环境噪声。</li>
</ul>
<h2>本节小结</h2>
<pre><code>1. 并发 bug 来自“假设的顺序”与“真实允许的执行”不一致。
2. 死锁四条件是必要条件；打破任意一个条件即可排除死锁。
3. AA 是自己等自己，ABBA 是等待图成环；lock order 是最常用防御。
4. Transactional memory 用 all-or-nothing 尝试替代手写锁顺序，但回滚和副作用很难。
5. Data race = 同一内存位置 + 并发访问 + 至少一写 + 无 happens-before。
6. TSan 用运行期 happens-before 推理检测 race，但只能覆盖实际执行路径。
7. Therac-25 说明 race / order violation 在安全关键系统里可能造成真实伤害。
8. Atomicity violation 的核心窗口在 check 和 act 之间。
9. ABA / UAF 说明“值相同”不等于“对象状态相同”。
10. TOCTOU 的修复方向是先获得稳定句柄，再检查和使用。
11. Order violation 需要 release-acquire、mutex/cv 或其他同步原语建立顺序。
12. 并发正确性最终要落回共享状态、不变量、同步边和工具验证。
</code></pre>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
  <entry>
    <title>14.协程、Goroutine、异步编程</title>
    <link href="https://katyusha-blog.com/posts/nju-os/os/multi-thread/coroutineasync/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/os/multi-thread/coroutineasync/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-21T00:00:00.000Z</updated>
    <summary>14.协程、Goroutine、异步编程</summary>
    <content type="html"><![CDATA[<h1>协程、Goroutine 与异步编程</h1>
<p>第 19 讲的入口不是某个新 API，而是一个系统问题：</p>
<pre><code>我们想像调用函数一样随时创建并发任务，
但 OS 线程的创建、栈空间、调度和切换都太贵。
</code></pre>
<p>第 18 讲已经把并行算法抽象成计算图：节点是本地计算，边是依赖或同步。第 19 讲继续追问：当计算图的节点很多、每个节点经常等待 I/O 时，是否还应该为每个节点创建一个 OS 线程？</p>
<p>答案通常是否定的。工程上有两条路线：</p>
<pre><code>1. 让“线程”变轻：coroutine / goroutine / lightweight thread。
2. 改变执行模型：future / promise / async-await / event loop。
</code></pre>
<p>本笔记重点放在第一条路线和它与异步 I/O 的关系上；讲义最后的 JavaScript / Web 计算图模型暂不展开。</p>
<h2>线程开销问题</h2>
<p>线程的核心模型可以写成：</p>
<pre><code>thread = 独立栈 + 独立寄存器上下文 + 内核调度实体 + 共享进程地址空间
</code></pre>
<p>它比进程轻，因为不复制整个地址空间；但它仍然不是免费的。一个 Linux <code>pthread</code> 至少会带来：</p>
<ul>
<li>用户栈：常见默认约 <code>8 MB</code> 虚拟地址空间，物理页通常按需分配。</li>
<li>内核对象：调度实体、内核栈、线程元数据等。</li>
<li>TLS / TCB：线程本地存储和线程控制块。</li>
<li>调度成本：阻塞、唤醒、上下文切换都要进入内核。</li>
<li>缓存扰动：线程迁移、锁竞争和共享 cache line 会破坏局部性。</li>
</ul>
<p>所以课程里说 <code>10000</code> 个线程大约对应 <code>80 GB</code> 虚拟栈空间，这不是夸张修辞，而是在提醒：</p>
<pre><code>线程的抽象很好用，但“每个并发活动一个 OS 线程”无法无限扩展。
</code></pre>
<p>如果 workload 中大量任务只是等待网络、管道、定时器或磁盘事件，OS 线程的大栈和内核调度就会成为主要开销。</p>
<h2>两条工程路线</h2>
<p>课程把问题抽象成下面的愿望：</p>
<pre><code>t1 = spawn(f);
t2 = spawn(g);
t3 = spawn(h);
join(t1);
join(t2);
join(t3);
</code></pre>
<p>理想状态下，<code>spawn/join</code> 的成本应该接近函数调用；现实中，OS 线程远比函数调用重。</p>
<p>因此有两条路线。</p>
<p>第一条是轻量化线程：</p>
<pre><code>保留“像线程一样写”的模型，
但把调度、栈和切换尽量搬到用户态。
</code></pre>
<p>典型代表是：</p>
<ul>
<li>Python generator。</li>
<li>C++20 coroutine。</li>
<li>用户态栈切换库，如 <code>ucontext</code> / <code>setjmp</code> / <code>longjmp</code> / Boost.Context。</li>
<li>Go goroutine。</li>
</ul>
<p>第二条是异步编程：</p>
<pre><code>不再假装每个任务都是一个阻塞线程，
而是显式描述“现在等什么事件，事件完成后继续哪一段代码”。
</code></pre>
<p>典型代表是：</p>
<ul>
<li>Future / Promise。</li>
<li><code>async/await</code>。</li>
<li>event loop。</li>
</ul>
<p>这两条路线表面不同，底层都在处理同一个问题：</p>
<pre><code>当任务进入等待状态时，不要让整个 OS 线程傻等。
</code></pre>
<h2>协程的基本执行模型</h2>
<p>协程可以先抓成一句话：</p>
<pre><code>coroutine = 可以主动挂起并稍后恢复的执行单元
</code></pre>
<p>普通函数只有“调用”和“返回”。协程多了一个中间状态：</p>
<pre><code>running -&gt; yield/suspend -&gt; suspended -&gt; resume -&gt; running
</code></pre>
<p>挂起时，运行时需要保存“以后从哪里继续”以及必要的局部状态。实现上通常有两类：</p>
<ul>
<li>有栈协程：每个协程有自己的用户态栈，切换时保存/恢复栈指针和寄存器。</li>
<li>无栈协程：编译器把函数拆成状态机，局部变量变成状态对象的一部分。</li>
</ul>
<p>Python generator 和 C++20 coroutine 更接近“无栈协程”的教学入口：每次 <code>yield</code> 或 <code>co_yield</code> 都是一个显式挂起点，调用者下次恢复时从挂起点之后继续执行。</p>
<p>重要的是：协程通常由用户态运行时调度，而不是由内核调度。它因此更轻：</p>
<pre><code>OS 不需要为每个协程分配完整线程资源；
切换协程通常不需要陷入内核；
协程栈可以很小或由状态机替代。
</code></pre>
<p>但“轻”不等于“自动并行”。如果一个进程里只有一个 OS 线程在运行协程，那么这些协程只是并发推进，不会在多个 CPU 核上同时执行。</p>
<h2>协程与用户态并发</h2>
<p>把协程说成“用户态并发”方向是对的，但更精确的说法是：</p>
<pre><code>协程是实现用户态并发的一种执行单元。
</code></pre>
<p>协程本身是“可暂停/可恢复的函数”；用户态并发是运行时把许多这样的函数组织起来，让它们在等待点交替推进。</p>
<p>调度方式通常是协作式的：</p>
<pre><code>当前协程运行
-&gt; 遇到 yield / await / 阻塞前的等待点
-&gt; 主动交回控制权
-&gt; 调度器选择另一个可运行协程
</code></pre>
<p>这和 OS 线程的抢占式调度不同。OS 可以在时钟中断、系统调用返回等位置强行切走线程；纯用户态协程如果不主动让出，其他协程就没有机会运行。</p>
<p>因此协程程序的关键不变量是：</p>
<pre><code>所有可能长时间等待的操作，都必须变成“注册等待事件 + yield”。
</code></pre>
<p>否则，一个协程卡住，就可能把同一个 OS 线程上的所有协程一起卡住。</p>
<h2>阻塞系统调用的陷阱</h2>
<p>协程最大的陷阱是：操作系统看不到用户态运行时内部的一百万个协程，它只看到一个或少数几个 OS 线程。</p>
<p>如果某个协程直接调用阻塞系统调用：</p>
<pre><code>read(fd, buf, n);   // 没有数据时阻塞
sleep(1);           // 线程睡眠
</code></pre>
<p>内核阻塞的是承载它的 OS 线程。结果是：</p>
<pre><code>一个协程在内核里等待
-&gt; 整个 OS 线程停止运行
-&gt; 同线程上的其他协程也无法被调度
</code></pre>
<p>这就是讲义里的：</p>
<pre><code>一个协程等待，1,000,000 个都等待。
</code></pre>
<p>另一个典型问题是同步原语和协作式调度组合不当。例如一个协程拿着锁之后 <code>yield</code>，调度器又恢复到需要同一把锁的协程，就可能形成进展性问题。协程运行时必须非常清楚哪些操作会让出控制权，哪些锁和资源在让出时仍被持有。</p>
<h2>非阻塞 I/O 与 <code>epoll</code></h2>
<p>解决阻塞系统调用问题的基本方法是把 I/O 改成非阻塞：</p>
<pre><code>open(..., O_NONBLOCK)
</code></pre>
<p>非阻塞 <code>read</code> 的语义是：</p>
<pre><code>如果现在有数据：返回实际读到的字节数。
如果现在没数据：返回 -1，并设置 errno = EAGAIN / EWOULDBLOCK。
</code></pre>
<p>这给了协程运行时一个机会：</p>
<pre><code>while (read(fd, buf, n) == -1 &amp;&amp; errno == EAGAIN) {
    register_interest(fd, READABLE);
    yield();
}
</code></pre>
<p>但是只靠 <code>O_NONBLOCK</code> 还不够。运行时还需要知道“哪些 fd 现在可读/可写”。这就是 <code>epoll</code> 的位置。</p>
<p><code>epoll</code> 的功能是：</p>
<pre><code>让进程高效等待大量 fd 的就绪事件。
</code></pre>
<p>它不是让单次 <code>read/write</code> 更快，而是避免：</p>
<ul>
<li>为每个连接创建一个 OS 线程。</li>
<li>反复线性扫描所有 fd。</li>
<li>在没有数据的 fd 上空转。</li>
</ul>
<p>典型事件循环是：</p>
<pre><code>1. 协程尝试 read/write。
2. 如果返回 EAGAIN，把 fd 和等待事件注册到 epoll。
3. 当前协程 yield。
4. 调度器运行其他可运行协程。
5. epoll_wait 返回就绪 fd。
6. 运行时恢复等待这些 fd 的协程。
</code></pre>
<p>可以把它压成：</p>
<pre><code>协程负责表达“我要等什么”；
epoll 负责告诉运行时“哪些等待条件已经可能成立”；
调度器负责恢复对应协程。
</code></pre>
<p>这也是高并发网络服务器能用少量线程处理大量连接的基础。</p>
<h2><code>eventfd</code>、<code>timerfd</code> 与统一事件模型</h2>
<p>讲义提到 <code>eventfd</code>、<code>timerfd</code>、<code>io_uring</code>，核心思想是把更多等待对象纳入统一事件循环。</p>
<p><code>timerfd</code> 把定时器表示成 fd：</p>
<pre><code>时间到了 -&gt; fd 可读 -&gt; epoll_wait 返回
</code></pre>
<p><code>eventfd</code> 把用户态/线程间通知表示成 fd：</p>
<pre><code>其他线程写 eventfd -&gt; fd 可读 -&gt; epoll_wait 返回
</code></pre>
<p>这样运行时就可以用同一个 <code>epoll_wait</code> 同时等待：</p>
<ul>
<li>socket 可读可写。</li>
<li>pipe 可读。</li>
<li>定时器到期。</li>
<li>其他线程发来的唤醒事件。</li>
</ul>
<p>这件事的抽象价值很大：</p>
<pre><code>把“等待不同种类事件”统一成“等待 fd 就绪”。
</code></pre>
<p><code>io_uring</code> 则更进一步，用提交队列和完成队列表达真正的异步 I/O，尤其适合传统 <code>epoll</code> 不擅长的磁盘 I/O 场景。</p>
<h2>Goroutine 的工程化模型</h2>
<p>goroutine 可以理解为 Go 对轻量执行流的工程化实现：</p>
<pre><code>goroutine = Go runtime 调度的轻量任务
</code></pre>
<p>写：</p>
<pre><code>go f()
</code></pre>
<p>不是直接创建一个 OS 线程，而是创建一个 goroutine，由 Go runtime 调度到某个 OS 线程上执行。</p>
<p>Go 的调度通常用 <code>G-M-P</code> 模型理解：</p>
<pre><code>G = goroutine，用户态任务。
M = machine，实际 OS 线程。
P = processor，运行 Go 代码所需的调度资源和本地队列。
</code></pre>
<p>多个 <code>G</code> 会复用到少量 <code>M</code> 上运行，形成 <code>M:N</code> 调度。这样 goroutine 可以做到：</p>
<ul>
<li>像线程一样写阻塞风格代码。</li>
<li>栈从很小开始，按需增长。</li>
<li>大量任务由 Go runtime 在用户态调度。</li>
<li>遇到网络 I/O 等等待时，runtime 配合 netpoller 暂停当前 goroutine，转去运行其他 goroutine。</li>
</ul>
<p>所以 goroutine 的关键优势不是“语法短”，而是：</p>
<pre><code>把线程式代码的可读性，和协程式调度的低成本结合起来。
</code></pre>
<p>它也不是凭空解决 CPU 并行。真正同时执行仍然依赖多个 OS 线程和多个 CPU 核；<code>GOMAXPROCS</code> 决定同一时刻可以并行执行 Go 代码的 P 的数量。</p>
<h2>Channel 与通信式同步</h2>
<p>讲义引用了 Effective Go 的一句话：</p>
<pre><code>Do not communicate by sharing memory; instead, share memory by communicating.
</code></pre>
<p>信号量、条件变量、互斥锁能表达同步，但数据如何传递仍然靠程序员手工维护共享内存。如果忘记加锁、锁错对象或破坏不变量，就会回到 data race 和 atomicity violation。</p>
<p>Go channel 把同步和通信合在一起：</p>
<pre><code>done &lt;- id     // 发送：传递数据，也可能阻塞等待接收者
id := &lt;-done   // 接收：取得数据，也可能阻塞等待发送者
</code></pre>
<p>这和 UNIX pipe 的思想接近：</p>
<pre><code>生产者写入 channel / pipe
消费者读取 channel / pipe
数据流本身携带同步关系
</code></pre>
<p>课程里的 Mandelbrot-Go 例子可以这样理解：</p>
<pre><code>多个 goroutine 分块计算像素行
-&gt; 每个 worker 完成后向 done channel 发送完成事件
-&gt; monitor 用 select 同时等待 done 和 timer
-&gt; finish channel 通知主 goroutine 收尾
</code></pre>
<p>这里的 channel 不只是“队列”，也是 happens-before 边：发送完成事件先于接收方观察到完成。</p>
<h2>异步编程的状态机视角</h2>
<p>即使不展开 JavaScript，也需要保留异步编程的一般模型，因为它和协程是同一个问题的另一种表达。</p>
<p><code>async/await</code> 的核心不是“自动多线程”，而是：</p>
<pre><code>把一个顺序函数按 await 切成若干段，
每段在前一个等待事件完成后继续执行。
</code></pre>
<p>编译器或运行时大致会把：</p>
<pre><code>do A
await event1
do B
await event2
do C
</code></pre>
<p>改写成状态机：</p>
<pre><code>state 0: do A; register event1; suspend
state 1: do B; register event2; suspend
state 2: do C; finish
</code></pre>
<p>所以 <code>await</code> 和协程的 <code>yield</code> 在控制流意义上很像：都表示“保存当前状态，等以后恢复”。差别在于：</p>
<ul>
<li>协程路线试图保留类似线程的执行流抽象。</li>
<li>异步路线更明确地把等待点暴露给语言和运行时。</li>
<li>Future / Promise 把“未来完成的值”和依赖关系显式对象化。</li>
</ul>
<p>这也是为什么很多语言的 <code>async/await</code> 看起来像同步代码，但底层并不是阻塞线程，而是在事件完成后继续执行后半段。</p>
<h2>计算图视角下的统一理解</h2>
<p>本讲仍然可以回到计算图：</p>
<pre><code>节点 = 一段可以连续执行的代码
边   = 必须等待的事件、I/O 完成、channel 通信或 join 依赖
</code></pre>
<p>OS 线程模型的问题是：每个节点或每条等待链都可能占用一个重线程。协程和异步编程则尝试把等待中的节点从 OS 线程上摘下来。</p>
<p>可以这样对比：</p>
<pre><code>OS thread:
  等待 I/O 时，线程阻塞在内核里。

Coroutine + epoll:
  等待 I/O 时，协程挂起，OS 线程继续跑其他协程。

Goroutine:
  程序员写阻塞风格代码，Go runtime 负责在等待时挂起 G，复用 M。

Async/await:
  程序员标出 await 点，编译器/运行时把函数拆成事件驱动状态机。
</code></pre>
<p>因此，本讲的主线不是“协程比线程高级”，而是：</p>
<pre><code>真实 workload 里大量并发活动都在等待；
高性能运行时必须把等待成本从 OS 线程成本中解耦出来。
</code></pre>
<h2>常见误解</h2>
<h3>协程是否就是用户态并发</h3>
<p>可以粗略这么说，但更精确的是：</p>
<pre><code>协程是用户态并发的执行单元；
用户态调度器把许多协程组织成并发程序。
</code></pre>
<p>只有一个协程时，它只是一个可暂停函数；有调度器和多个可运行协程时，才体现用户态并发。</p>
<h3>协程是否天然并行</h3>
<p>不是。协程本身只说明“可挂起/恢复”，不说明“能在多个 CPU 核上同时运行”。要并行，需要多个 OS 线程承载协程，或者像 Go runtime 那样把 goroutine 分配到多个 worker thread 上。</p>
<h3><code>epoll</code> 是否负责读写数据</h3>
<p>不是。<code>epoll</code> 只告诉你 fd 是否就绪：</p>
<pre><code>fd readable -&gt; 现在调用 read 比较可能取得数据
fd writable -&gt; 现在调用 write 比较可能写入缓冲区
</code></pre>
<p>真正的数据传输仍然由 <code>read/write/recv/send</code> 完成。就绪也不等于一定能读完整个请求；代码仍然要处理短读、短写、EOF、错误和重试。</p>
<h3><code>async/await</code> 是否就是多线程</h3>
<p>不是。<code>async/await</code> 更像“把函数切成状态机”。它能让单线程在等待 I/O 时继续处理其他事件，但 CPU 密集代码如果一直运行，仍然会占住当前执行线程。</p>
<h2>与系统性能的连接</h2>
<p>协程和异步 I/O 服务的是典型 I/O 密集 workload：</p>
<pre><code>大量连接
少量活跃
大量时间在等待网络、定时器或外部服务
每个请求的 CPU 工作不一定很重
</code></pre>
<p>这类 workload 如果用“一连接一线程”，会被栈空间、调度和 cache 扰动拖垮。如果用 <code>epoll + coroutine</code> 或 runtime netpoller，则可以让少量线程只在事件真正就绪时推进对应任务。</p>
<p>但 CPU 密集型 workload 不会因为换成协程自动变快。此时瓶颈是计算本身，需要回到第 18 讲的并行算法：</p>
<pre><code>画计算图
选择任务粒度
减少同步边
改善局部性
用多线程 / SIMD / GPU 真正并行执行
</code></pre>
<p>对 AI infrastructure 来说，这个边界很重要：</p>
<ul>
<li>请求接入、网络等待、排队、流式返回适合协程/异步 I/O。</li>
<li>tokenizer、batching、调度器可以用 worker pool 和 channel/queue。</li>
<li>GPU kernel 执行、矩阵计算、推理算子优化属于并行计算和硬件局部性问题。</li>
<li>serving 系统常常把两类模型组合起来：上层协程处理大量请求，下层线程池/GPU worker 执行重计算。</li>
</ul>
<h2>本节知识点总结</h2>
<pre><code>1. OS 线程不是免费的：栈、内核对象、调度、上下文切换和缓存扰动都会成为成本。
2. 第 19 讲的核心问题是：如何让大量等待型并发任务不再各占一个重线程。
3. 协程是可挂起/可恢复的执行单元，通常由用户态运行时调度。
4. 协程实现可以有栈，也可以由编译器改写成无栈状态机。
5. 协程轻量不等于自动并行；并行仍然依赖多个 OS 线程和多个 CPU 核。
6. 阻塞系统调用会阻塞承载协程的 OS 线程，因此协程运行时必须配合非阻塞 I/O。
7. `O_NONBLOCK` 让无数据的 `read` 返回 `EAGAIN`，从而允许协程注册等待事件并 `yield`。
8. `epoll` 负责高效等待大量 fd 的就绪事件，是协程网络运行时的重要基础。
9. `eventfd`、`timerfd` 把通知和定时器也纳入 fd 事件模型，方便统一调度。
10. Goroutine 是 Go runtime 调度的轻量任务，通过 `G-M-P` 模型把大量 G 复用到 OS 线程上。
11. Channel 把同步和通信合在一起，发送/接收既传递数据，也建立执行顺序。
12. `async/await` 的本质是把函数按等待点拆成状态机，不是自动创建线程。
13. 协程/异步适合 I/O 密集并发；CPU 密集加速仍要靠并行算法、线程、SIMD 或 GPU。
</code></pre>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
  <entry>
    <title>10.并发控制：条件变量和万能同步方法</title>
    <link href="https://katyusha-blog.com/posts/nju-os/os/multi-thread/cvsync/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/os/multi-thread/cvsync/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-19T00:00:00.000Z</updated>
    <summary>10.并发控制：条件变量和万能同步方法</summary>
    <content type="html"><![CDATA[<h1>条件变量与同步</h1>
<h2>同步问题的基本语义</h2>
<p>互斥解决的是“同一时刻谁能访问共享状态”，同步解决的是“某个事件必须发生在另一个事件之前”。在多线程程序中，很多问题并不是单纯的数据竞争，而是某个线程必须等待共享状态满足某个条件后才能继续。</p>
<p>同步问题可以统一写成：</p>
<pre><code>while (!sync_condition()) {
    wait();
}
proceed();
</code></pre>
<p><code>sync_condition()</code> 是共享状态上的谓词，例如“所有子线程已经结束”“缓冲区非空”“前驱节点全部完成”。条件变量的作用就是把这种等待从忙等变成阻塞等待，并在状态可能改变时唤醒等待者。</p>
<h2>条件变量的状态条件模型</h2>
<p>条件变量本身不保存业务状态，也不表示条件已经成立。真正的条件必须由共享变量记录，并由互斥锁保护。</p>
<p>典型组成是：</p>
<ul>
<li>共享状态：标志位、计数器、队列长度、剩余依赖数等。</li>
<li>互斥锁：保护共享状态的检查与修改。</li>
<li>条件变量：让等待条件的线程睡眠，并在条件可能成立时被唤醒。</li>
</ul>
<p>因此条件变量不是“消息队列”，也不是“带记忆的 signal”。如果没有线程正在等待，一次 <code>cond_signal</code> 或 <code>cond_broadcast</code> 通常不会被保存。正确性来自“共享状态 + mutex + while 检查”的组合，而不是来自通知本身。</p>
<h2><code>cond_wait</code> 的原子释放与等待</h2>
<p><code>cond_wait(&amp;cv, &amp;lk)</code> 的核心语义是：调用时线程必须已经持有 <code>lk</code>；进入等待时，库会原子地释放 <code>lk</code> 并把当前线程加入 <code>cv</code> 的等待队列；被唤醒后，线程会先重新获得 <code>lk</code>，再从 <code>cond_wait</code> 返回。</p>
<p>这个原子性用于避免漏唤醒：</p>
<pre><code>错误模型：
线程 A 检查 condition == false
线程 A 手动 unlock，准备 sleep
线程 B 修改 condition == true，并 signal
线程 A 还没有进入等待队列，通知丢失
线程 A 随后 sleep，可能永久阻塞
</code></pre>
<p>如果 <code>cond_wait</code> 不释放锁，修改条件的线程又无法拿到锁推进状态，等待者会拿着锁睡眠，系统直接失去进展。因此 <code>cond_wait</code> 必须同时满足两点：等待者睡眠时不持有锁；释放锁和进入等待队列之间没有可见空窗。</p>
<h2>条件变量的标准使用模板</h2>
<p>等待者模板：</p>
<pre><code>mutex_lock(&amp;lk);
while (!cond) {
    cond_wait(&amp;cv, &amp;lk);
}
// 此时 cond 成立，并且当前线程持有 lk
do_work_under_condition();
mutex_unlock(&amp;lk);
cond_broadcast(&amp;cv);
</code></pre>
<p>修改者模板：</p>
<pre><code>mutex_lock(&amp;lk);
update_shared_state();
mutex_unlock(&amp;lk);
cond_broadcast(&amp;cv);
</code></pre>
<p>必须使用 <code>while</code> 而不是 <code>if</code>。在 Mesa 语义下，唤醒只表示“条件可能已经改变”，不保证条件在当前线程重新获得锁时仍然成立。可能出现假唤醒、多个线程争抢同一份资源、或者条件被其他线程再次改回去。</p>
<h2>唤醒与重新竞争互斥锁</h2>
<p><code>cond_broadcast(&amp;cv)</code> 唤醒一组等待线程时，这些线程不会同时从 <code>cond_wait</code> 返回。它们只是从条件变量等待队列进入可运行状态，并开始竞争同一把互斥锁。</p>
<p>过程可以理解为：</p>
<pre><code>T0 持有 lk
T0 修改共享状态
T0 cond_broadcast(cv)
等待在 cv 上的线程被唤醒，但仍不能从 cond_wait 返回
T0 mutex_unlock(lk)
被唤醒的线程竞争 lk
只有一个线程获得 lk，并重新检查 while 条件
条件不成立的线程再次 cond_wait
</code></pre>
<p>因此 <code>broadcast + while</code> 是鲁棒组合：<code>broadcast</code> 负责通知所有可能受影响的线程，<code>while</code> 负责筛掉当前条件并不满足的线程。</p>
<h2><code>broadcast</code> 与解锁顺序</h2>
<p>常见模板选择先 <code>cond_broadcast</code> 再 <code>mutex_unlock</code>：</p>
<pre><code>update_shared_state();
cond_broadcast(&amp;cv);
mutex_unlock(&amp;lk);
</code></pre>
<p>这样可以把“共享状态已经修改”和“等待者可以重新检查”绑定在同一个临界区中。被唤醒的线程即使已经可运行，也必须等当前线程释放锁后才能从 <code>cond_wait</code> 返回，因此不会在状态更新尚未完成时抢跑。</p>
<p>严格说，部分实现和部分场景允许在解锁后通知，但这种写法更难证明。课程中的稳妥规则是：修改了可能影响同步条件的共享状态，就在持锁状态下广播，然后释放锁。</p>
<h2>左右括号模型的不变量</h2>
<p>第 15 讲把生产者-消费者问题简化成左右括号打印：</p>
<pre><code>void T_producer() { printf("("); }
void T_consumer() { printf(")"); }
</code></pre>
<p>令 <code>depth</code> 表示当前未被右括号匹配的左括号数量，令 <code>n</code> 表示最大允许嵌套深度。系统需要维护的不变量是：</p>
<pre><code>0 &lt;= depth &lt;= n
</code></pre>
<p>对应的同步条件是：</p>
<pre><code>打印 "(" 的条件：depth &lt; n
打印 ")" 的条件：depth &gt; 0
</code></pre>
<p>左括号线程生产一份未匹配的左括号，使 <code>depth++</code>；右括号线程消费一份未匹配的左括号，使 <code>depth--</code>。因此任何前缀中右括号数量都不能超过左括号数量，并且嵌套深度不能超过缓冲区容量 <code>n</code>。</p>
<h2>左括号生产者的条件变量实现</h2>
<p>左括号线程的代码是生产者逻辑：</p>
<pre><code>void T_producer() {
    mutex_lock(&amp;lk);
    while (!(depth &lt; n)) {
        cond_wait(&amp;cv, &amp;lk);
    }

    assert(depth &lt; n);
    depth++;
    printf("(");

    cond_broadcast(&amp;cv);
    mutex_unlock(&amp;lk);
}
</code></pre>
<p><code>depth++</code> 和 <code>printf("(")</code> 放在同一个临界区内，是因为输出序列本身也是共享结果。若先修改 <code>depth</code> 再让其他线程插入打印，输出字符与深度状态就可能不一致。</p>
<p>右括号线程是对称的消费者逻辑：</p>
<pre><code>void T_consumer() {
    mutex_lock(&amp;lk);
    while (!(depth &gt; 0)) {
        cond_wait(&amp;cv, &amp;lk);
    }

    assert(depth &gt; 0);
    depth--;
    printf(")");

    cond_broadcast(&amp;cv);
    mutex_unlock(&amp;lk);
}
</code></pre>
<p>当一个左括号线程把 <code>depth</code> 从 0 改为 1 并广播后，多个右括号线程可能同时被唤醒，但只有一个线程能先获得锁。第一个右括号线程可能把 <code>depth</code> 重新减为 0，其余右括号线程随后重新检查 <code>depth &gt; 0</code>，发现条件不成立后继续睡眠。</p>
<h2><code>signal</code> 与 <code>broadcast</code> 的选择边界</h2>
<p><code>cond_signal</code> 只唤醒一个等待者，开销较小，但要求程序员能够证明被唤醒的线程一定有机会继续执行。在左右括号例子中，同一个条件变量上可能同时等待 producer 和 consumer。若 producer 释放出机会后唤醒了另一个 producer，而该 producer 的条件并不成立，就可能造成无效唤醒甚至活性问题。</p>
<p><code>cond_broadcast</code> 会唤醒所有等待者，让每个线程重新检查自己的条件。它可能带来额外上下文切换和抢锁开销，但配合 <code>while</code> 更容易保证正确性。课程中的默认策略是：只要某次共享状态修改可能使其他线程的同步条件成立，就使用 <code>broadcast</code>。</p>
<h2>生产者-消费者模型的同步结构</h2>
<p>经典生产者-消费者模型维护一个容量为 <code>N</code> 的有界缓冲区。共享状态可以抽象为当前元素个数 <code>cnt</code>，不变量是：</p>
<pre><code>0 &lt;= cnt &lt;= N
</code></pre>
<p>对应的两个等待条件是：</p>
<pre><code>producer 可继续：cnt &lt; N
consumer 可继续：cnt &gt; 0
</code></pre>
<p>实际代码中通常拆成两个条件变量：</p>
<pre><code>void produce(Object x) {
    mutex_lock(&amp;m);
    while (cnt == N) {
        cond_wait(&amp;not_full, &amp;m);
    }
    put(x);
    cnt++;
    cond_broadcast(&amp;not_empty);
    mutex_unlock(&amp;m);
}

Object consume() {
    mutex_lock(&amp;m);
    while (cnt == 0) {
        cond_wait(&amp;not_empty, &amp;m);
    }
    Object x = take();
    cnt--;
    cond_broadcast(&amp;not_full);
    mutex_unlock(&amp;m);
    return x;
}
</code></pre>
<p>两个不同等待条件对应两个条件变量，是为了减少误唤醒：等待“非满”的 producer 和等待“非空”的 consumer 不应混在同一个等待集合中。</p>
<h2>同步正确性与并行性能边界</h2>
<p>条件变量保证的是同步正确性，不自动带来并行加速。是否退化成串行，取决于临界区占总工作量的比例。</p>
<p>左右括号例子几乎会退化成串行，因为主要工作就是更新 <code>depth</code> 并打印一个字符，而这两件事都必须在锁内完成。它是同步语义教学例子，不是高吞吐并行程序。</p>
<p>真正适合生产者-消费者优化的场景通常具有“短交接、长计算”的结构：</p>
<pre><code>producer:
    x = make_object();      // 锁外并行
    lock();
    enqueue(x);             // 锁内短临界区
    broadcast();
    unlock();

consumer:
    lock();
    x = dequeue();          // 锁内短临界区
    broadcast();
    unlock();
    process(x);             // 锁外并行
</code></pre>
<p>此时锁只串行化共享队列的 push/pop，真正耗时的生产、处理、I/O 或计算在锁外执行。并行度来自锁外工作与流水线重叠，而不是来自条件变量本身。</p>
<h2>任务流水线的生产者-消费者化</h2>
<p>一个模型服务流水线可以被拆成多个生产者-消费者阶段：</p>
<pre><code>请求线程 -&gt; request_queue -&gt; tokenizer workers
tokenizer workers -&gt; token_queue -&gt; batcher
batcher -&gt; batch_queue -&gt; GPU worker
GPU worker -&gt; result_queue -&gt; response workers
</code></pre>
<p>每个队列的 push/pop 由短临界区保护，每个阶段的主体工作在锁外完成。这样 GPU 推理、CPU 分词、响应发送可以重叠运行。队列和条件变量只负责阶段之间的资源交接与等待唤醒。</p>
<h2>万能同步方法的计算图解释</h2>
<p>生产者-消费者模型可以推广到任意同步条件。关键步骤是：</p>
<pre><code>识别必须等待的 sync condition
用共享状态记录事件或资源是否可用
后执行者在 while (!condition) 中等待
先执行者修改共享状态并 broadcast
</code></pre>
<p>在 DAG 计算图中，边 <code>u -&gt; v</code> 表示 <code>v</code> 必须等待 <code>u</code> 完成。每个节点 <code>v</code> 可以维护 <code>n_pending_deps</code>：</p>
<pre><code>mutex_lock(&amp;v-&gt;m);
while (v-&gt;n_pending_deps &gt; 0) {
    cond_wait(&amp;v-&gt;ready_cv, &amp;v-&gt;m);
}
mutex_unlock(&amp;v-&gt;m);
run(v);
</code></pre>
<p>当某个前驱完成后，减少后继节点的剩余依赖数：</p>
<pre><code>mutex_lock(&amp;succ-&gt;m);
succ-&gt;n_pending_deps--;
if (succ-&gt;n_pending_deps == 0) {
    cond_broadcast(&amp;succ-&gt;ready_cv);
}
mutex_unlock(&amp;succ-&gt;m);
</code></pre>
<p>Makefile 的 <code>make -j</code>、动态规划依赖图、神经网络计算图和工作流调度都可以用这个视角理解：每个节点等待前驱生产完成事件，前驱完成后唤醒后继重新检查可执行条件。</p>
<h2>条件变量使用原则</h2>
<ul>
<li>条件变量表达“何时可以继续”，互斥锁保护“谁能访问共享状态”。</li>
<li>条件变量不保存业务状态，条件必须由共享变量表示。</li>
<li><code>cond_wait</code> 必须在持锁时调用，并原子释放锁与进入等待。</li>
<li>被唤醒后必须重新获得互斥锁，才能从 <code>cond_wait</code> 返回。</li>
<li>等待条件必须写成 <code>while (!cond)</code>，不能写成 <code>if</code>。</li>
<li>修改可能影响同步条件的共享状态后，默认使用 <code>cond_broadcast</code>。</li>
<li>临界区只应覆盖共享状态交接；主要计算应尽量放在锁外。</li>
</ul>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
  <entry>
    <title>8.多处理器编程：从入门到放弃</title>
    <link href="https://katyusha-blog.com/posts/nju-os/os/multi-thread/intro/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/os/multi-thread/intro/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-15T00:00:00.000Z</updated>
    <summary>8.多处理器编程：从入门到放弃</summary>
    <content type="html"><![CDATA[<p>多线程不是“多开几个函数一起跑”这么简单。它真正引入的是：<strong>同一地址空间里出现了多个执行流，而“下一步轮到谁执行”不再确定。</strong></p>
<h2>线程的基本执行模型</h2>
<p>从操作系统视角看，线程可以先抓成一句话：</p>
<pre><code>thread = 独立栈 + 独立寄存器上下文 + 共享地址空间
</code></pre>
<p>和进程相比，线程不再复制整个地址空间，而是在同一个进程里共享：</p>
<ul>
<li>代码段</li>
<li>全局变量 / 静态存储区</li>
<li>堆</li>
<li>打开的文件描述符等进程级资源</li>
</ul>
<p>每个线程自己单独拥有：</p>
<ul>
<li><code>PC/RIP</code></li>
<li>通用寄存器</li>
<li>栈</li>
</ul>
<p>所以“多线程程序”的本质不是魔法，而是：</p>
<pre><code>同一份共享内存上，同时挂了多个“CPU 状态机”
</code></pre>
<p>这也是讲义里最核心的模型：<strong>每个线程独立栈，共享全局。</strong></p>
<h2>线程作为并发抽象的动机</h2>
<p>父子进程之间通信和切换代价更大，因此很多“同一任务里的并发活动”更适合放到同一个地址空间里做。线程带来的直接好处是：</p>
<ul>
<li>共享数据便宜，不必每次 IPC</li>
<li>创建 / 销毁成本通常低于新进程</li>
<li>一个进程内部可以更自然地表达并发结构</li>
</ul>
<p>但代价也很直接：<strong>共享内存把顺序程序的确定性打碎了。</strong></p>
<h2>并发与并行的概念区分</h2>
<ul>
<li>并发：逻辑上同时推进。单核靠来回切换也能并发。</li>
<li>并行：物理上同时执行。至少需要多个核。</li>
</ul>
<p>关系是：</p>
<pre><code>parallelism =&gt; concurrency
concurrency =/=&gt; parallelism
</code></pre>
<p>讨论 bug 时，重点通常不是“有没有两个核”，而是“多个执行流是否可以以非确定顺序访问共享状态”。</p>
<h2>线程栈的结构与性质</h2>
<h3>栈的功能与存储内容</h3>
<p>线程栈主要放：</p>
<ul>
<li>调用帧</li>
<li>返回地址</li>
<li>保存的寄存器</li>
<li>局部变量</li>
<li>部分临时对象</li>
</ul>
<p>它不是语言层面的“神秘盒子”，本质上就是线程私有的一段地址空间。</p>
<h3>线程栈大小的来源与决定因素</h3>
<p><code>C</code> 语言标准并不规定线程栈大小；它由 OS、线程库和创建参数共同决定。</p>
<p>在本课讲义和常见 Linux <code>pthread</code> 环境里，实验常见结果是：</p>
<pre><code>每个新线程默认栈大约 8 MB
</code></pre>
<p>这也是为什么讲义里说：<code>10000</code> 个线程大约就会吃掉 <code>80 GB</code> 量级的虚拟地址空间。</p>
<p>要注意：主线程栈和 <code>pthread_create</code> 创建出来的线程栈不一定遵守同一套默认规则；课程里的 <code>8 MB</code> 更接近“新建线程的常见默认值”。</p>
<p>更准确地说，线程栈大小由这些因素决定：</p>
<ul>
<li>OS / ABI 默认策略</li>
<li>线程库默认值，如 <code>pthread</code> 默认栈大小</li>
<li>进程资源限制，如 <code>RLIMIT_STACK</code></li>
<li>显式配置，如 <code>pthread_attr_setstacksize</code></li>
<li>guard page、对齐、TLS 等运行时细节</li>
</ul>
<h3>线程栈的私有性与可访问性</h3>
<p>“独立”指的是：</p>
<ul>
<li>正常调用约定下，每个线程主要在自己的栈上活动</li>
<li>栈帧生命周期和寄存器上下文由该线程自己推进</li>
</ul>
<p>但它们仍然都在<strong>同一个进程虚拟地址空间</strong>里。<br />
所以如果你把一个线程局部变量的地址传给另一个线程，另一个线程仍然可能读写它。换句话说：</p>
<pre><code>线程栈是按使用习惯划分的私有区域，不是硬件隔离的独立地址空间。
</code></pre>
<h2><code>pthread</code> 与线程模型的程序接口</h2>
<p>课程里用了 <code>spawn/join</code> 的极简 API，本质上就是对 <code>pthread_create/pthread_join</code> 的做减法封装。</p>
<p>常见 <code>C</code> 接口可以直接记成：</p>
<pre><code>pthread_create
pthread_join
pthread_mutex_lock / unlock
pthread_cond_wait / signal / broadcast
</code></pre>
<p>调试多线程时，<code>gdb</code> 很重要的命令是：</p>
<pre><code>info threads
thread &lt;id&gt;
set scheduler-locking on
</code></pre>
<p>其中 <code>set scheduler-locking on</code> 的含义是：单步时尽量只让当前线程前进，避免别的线程在你 <code>step</code> 的空档里继续乱跑。</p>
<h2>并发执行中的非确定性</h2>
<p>单线程程序大致可以看成：</p>
<pre><code>state_{n+1} = f(state_n)
</code></pre>
<p>给定初始状态，程序行为基本是确定的。</p>
<p>多线程以后，不再只是“执行下一条语句”，而是变成：</p>
<pre><code>1. 选一个线程
2. 让它执行一步
3. 再选下一个线程
</code></pre>
<p>这个“选哪个线程”的动作通常不受你控制，所以程序从“函数”变成了“带分叉的状态图”。</p>
<p>这就是并发最根上的困难：<strong>不是代码更长了，而是执行路径指数爆炸了。</strong></p>
<h2>读改写操作的竞态条件</h2>
<p><code>sum++</code> 在机器上通常不是一条不可分割的神谕，而更接近：</p>
<pre><code>load sum
add 1
store sum
</code></pre>
<p>两个线程如果都这么做，就可能出现：</p>
<ol>
<li>A 读到 <code>sum = 0</code></li>
<li>B 也读到 <code>sum = 0</code></li>
<li>A 写回 <code>1</code></li>
<li>B 再写回 <code>1</code></li>
</ol>
<p>最后只加了一次。</p>
<p>所以 <code>sum++</code> 的问题本质不是“加法错了”，而是：</p>
<pre><code>一个本来你以为是单步的操作，被撕成了多个可交错的内存动作。
</code></pre>
<p>这和转账里的 <code>check-then-act</code> 是同一类 bug：</p>
<pre><code>if (balance &gt;= amount) balance -= amount;
</code></pre>
<p>两个线程可能都通过检查，然后都扣款。</p>
<h2><code>trace recovery</code> 问题及其复杂性</h2>
<p>讲义提到的 <code>trace recovery</code>，核心是：</p>
<pre><code>已知每个线程各自做了哪些局部动作，是否存在某种合法交错，使全局执行满足目标结果？
</code></pre>
<p>比如 <code>k</code> 个线程各做若干次 <code>sum++</code>，问最终 <code>sum</code> 的最小可能值，本质上就在问：</p>
<ul>
<li>能否安排一条全局执行轨迹</li>
<li>让大量中间结果被后来的写覆盖</li>
<li>最后只留下极少数“有效写入”</li>
</ul>
<p>它难，不是因为表达式复杂，而是因为：</p>
<ul>
<li>每个线程内部顺序必须保持</li>
<li>不同线程之间可以交错</li>
<li>要搜索的合法全局轨迹数目非常大</li>
</ul>
<p>所以这是一个典型的“局部顺序 + 全局约束 + 搜索交错”的问题。讲义里把它归到 <code>NP-complete</code>，直觉上就是：</p>
<pre><code>你需要在指数级候选 interleaving 里，找一个满足目标条件的执行轨迹。
</code></pre>
<p>你可以把它理解成：并发程序的很多“结果可不可达”问题，本质上都像在做组合搜索。</p>
<h2>编译器优化对并发语义的影响</h2>
<p>单线程优化默认依赖一个强假设：</p>
<pre><code>没有别的执行流在偷偷改我的内存
</code></pre>
<p>因此编译器会放心做这些事：</p>
<ul>
<li>把多个 <code>x++</code> 合并成 <code>x += k</code></li>
<li>把循环不变量提到循环外</li>
<li>把某个内存值缓存进寄存器，不再重读</li>
</ul>
<p>这在单线程里完全合理，但在共享内存并发里会炸。</p>
<p>最经典的是：</p>
<pre><code>while (!flag) { }
</code></pre>
<p>如果 <code>flag</code> 不是原子同步对象，编译器可能把它理解成：</p>
<pre><code>flag 在循环体里没被改过，所以读一次就够了
</code></pre>
<p>于是优化成近似死循环。</p>
<p>这也是为什么：</p>
<ul>
<li><code>volatile</code> 不是通用并发同步方案</li>
<li>裸共享变量上的 busy wait 是危险的</li>
</ul>
<h2>处理器执行与内存可见性的偏离</h2>
<p>即使编译器不乱动，硬件也不会老老实实按你脑中的“共享内存教科书图”执行。</p>
<p>现代 CPU 为了性能，至少会做两类事：</p>
<ul>
<li>指令乱序执行</li>
<li>store buffer + cache coherence 延迟传播</li>
</ul>
<p>所以你写：</p>
<pre><code>x = 1;
r = y;
</code></pre>
<p>另一边写：</p>
<pre><code>y = 1;
r = x;
</code></pre>
<p>按顺序一致性直觉，你会以为不可能两个读都看到 <code>0</code>。
但真实硬件上 <code>(0, 0)</code> 是可以出现的，因为：</p>
<ul>
<li>写先进入各自核心的 store buffer</li>
<li>本核后续 load 可能先执行</li>
<li>别的核心还没来得及看到这个写</li>
</ul>
<h2>内存系统中的最终一致性</h2>
<p>这里的“最终一致性”不是数据库课里那套 marketing 词，而是一个很弱的硬件直觉：</p>
<pre><code>如果某个地址不再被继续写入，那么过一段时间后，各核心最终会收敛到同一个值。
</code></pre>
<p>它只保证“最终会一致”，不保证：</p>
<ul>
<li>刚写完别人立刻看到</li>
<li>多个地址按程序顺序一起对外可见</li>
<li>某个复合不变量一直成立</li>
</ul>
<p>所以它很弱，只能说明“副本不会永远分叉”，完全不足以支撑并发程序正确性。</p>
<h2>MESI 协议的保证范围</h2>
<p>根据 MESI 一类缓存一致性协议，很多人会误以为：</p>
<pre><code>某核心一写，别的核心立刻就能观察到
</code></pre>
<p>这不对。</p>
<p><strong>对外可见性的传播可能在不同核、不同 cache line 上表现出不同的生效时机</strong>，所以当前的缓存状态不一定被及时更新，进而可能导致缓存信息不同</p>
<p>MESI 主要保证的是 <strong>cache coherence</strong>，也就是：</p>
<ul>
<li>对同一个 cache line，不会长期存在多个彼此矛盾的可写副本</li>
<li>其他核心以后再读这个地址时，会通过一致性协议收敛到合法的新值</li>
</ul>
<p>但它<strong>不保证</strong>：</p>
<ul>
<li>某次写一完成，所有核心立刻同步完成更新</li>
<li>已经在路上的读操作必须马上改看到新值</li>
<li>多个地址的写入对外具有统一顺序</li>
</ul>
<p>所以更准确的说法是：</p>
<pre><code>MESI 解决“同一地址的副本如何收敛”，不解决“多个地址的可见性顺序如何推理”。
</code></pre>
<p>后者属于 memory consistency / memory model 的范围。</p>
<h2>跨地址可见性与顺序约束问题</h2>
<p>看这个最经典的模式：</p>
<pre><code>data = 42;
flag = 1;
</code></pre>
<p>另一线程：</p>
<pre><code>if (flag == 1) {
  assert(data == 42);
}
</code></pre>
<p>即使 <code>flag</code> 和 <code>data</code> 各自都受 cache coherence 保护，第二个线程仍然可能先看到 <code>flag == 1</code>，却还没看到 <code>data == 42</code>。</p>
<p>原因是：</p>
<ul>
<li>coherence 只管单个地址</li>
<li><code>flag</code> 和 <code>data</code> 是两个不同位置</li>
<li>它们对别的核心可见的先后顺序未必和程序顺序一致</li>
</ul>
<p>所以并发正确性需要的不是“每个地址最终会一致”，而是：</p>
<pre><code>某些写必须在语义上先于某些读可见
</code></pre>
<p>这就需要：</p>
<ul>
<li>mutex</li>
<li>atomic</li>
<li>acquire / release</li>
<li>fence</li>
</ul>
<p>来建立 <code>happens-before</code>。</p>
<h2>线程安全与原子性的区别</h2>
<p>这两个词很容易混：</p>
<ul>
<li><code>thread-safe</code>：多个线程一起调用，不会把库内部状态搞坏</li>
<li><code>atomic</code>：这次操作对外表现得像不可分割的单步</li>
</ul>
<p>例如：</p>
<ul>
<li><code>printf</code> 内部通常加锁，所以是 thread-safe</li>
<li>但它并不意味着整个输出过程对你程序里的其他逻辑是原子的</li>
<li><code>memcpy</code> 即使是 thread-safe，也不代表两个线程同时往同一目标拷贝会得到“某一个完整版本”</li>
</ul>
<p>所以以后看到“线程安全”，先问一句：</p>
<pre><code>它保证的是内部不炸，还是保证对共享状态的操作具备我想要的原子性？
</code></pre>
<h2>对顺序程序直觉的修正</h2>
<p>不是放弃并发编程，而是放弃这些顺序程序直觉：</p>
<ul>
<li>相邻语句会按写的顺序执行</li>
<li>一个表达式就是一步</li>
<li>共享变量的最新值别人立刻能看到</li>
<li>测了很多次没出错就说明程序正确</li>
</ul>
<p>多线程里更靠谱的心智模型是：</p>
<pre><code>线程 = 多个局部顺序流
共享内存 = 可被并发读写的全局状态
正确性 = 在所有允许的 interleaving + 编译器优化 + CPU 重排下都成立
</code></pre>
<p>所以课程后面引入 mutex、condition variable、semaphore、atomic，不是为了“加点 API”，而是为了把并发重新约束回一个可以证明的模型里。</p>
<h2>本节知识点总结</h2>
<pre><code>1. 线程 = 独立栈 + 独立寄存器 + 共享地址空间。
2. 线程栈通常是固定配额；Linux/pthread 常见默认约 8 MB，但本质由 OS/线程库/配置决定。
3. 并发的根问题不是语法，而是“下一步谁执行”带来的非确定性。
4. sum++ / check-then-act 出错，是因为一个你以为的“单步”被撕成了可交错的 load/store。
5. trace recovery 关注“是否存在某种合法执行轨迹”，其搜索空间巨大，所以结果可达性会很难。
6. 编译器会重写代码，CPU 会乱序和延迟传播写入，shared memory 并不是“所有核立刻共享同一个值”。
7. eventual consistency 只保证最终收敛，不保证立即可见，更不保证跨地址顺序。
8. MESI 解决的是单地址 coherence，不是完整并发语义。
9. 并发正确性最终要靠 mutex / atomic / happens-before，而不是靠直觉。
</code></pre>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
  <entry>
    <title>9.并发控制：互斥</title>
    <link href="https://katyusha-blog.com/posts/nju-os/os/multi-thread/mutex/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/os/multi-thread/mutex/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-15T00:00:00.000Z</updated>
    <summary>9.并发控制：互斥</summary>
    <content type="html"><![CDATA[<p>互斥这件事，核心不是“学一个锁 API”，而是把并发程序里一段本来会被任意交错的代码，重新约束回一个可以按顺序程序推理的模型。</p>
<h2>互斥问题的基本模型</h2>
<p>线程共享地址空间后，问题不再是“有没有两个核”，而是“多个执行流会不会以不受控制的顺序访问同一份共享状态”。如果会，那么像 <code>sum++</code>、<code>check-then-act</code> 这样的操作就可能被撕成可交错的 <code>load/add/store</code>，从而破坏不变量。</p>
<p>这时就需要把一段“不能被并发打断”的代码圈出来。这段代码叫 <code>critical section</code>。它是一个<strong>语义边界</strong>，表示：</p>
<ul>
<li>这一段访问了共享状态；</li>
<li>若多个线程同时执行，会破坏正确性；</li>
<li>因而需要某种机制保证它对外表现得像“一个一个来”。</li>
</ul>
<p>所以三者不要混：</p>
<ul>
<li><code>critical section</code>：问题定义，指出哪段代码必须受保护；</li>
<li><code>mutex</code>：最常见的实现机制，用 <code>lock/unlock</code> 把临界区串行化；</li>
<li><code>transactional memory</code>：另一种更高层机制，把一段代码当作事务执行，冲突时回滚重试。</li>
</ul>
<p>也就是说，临界区不是锁；锁只是实现临界区的一种办法。课堂主线关注的是 <code>mutex</code>，因为它最直接、最工程化。</p>
<h2>互斥锁的语义保证</h2>
<p>讲义里最重要的直觉是：<code>lock/unlock</code> 相当于对一段代码做了一次局部的 <code>stop the world</code>。</p>
<pre><code>lock();
// critical section
unlock();
</code></pre>
<p>只要程序员正确使用同一把锁保护相关共享状态，就可以把这段代码近似看成：</p>
<ul>
<li>进入前，别的线程不能同时执行同一把锁保护的临界区；</li>
<li>离开后，临界区里的写入会对下一个拿到锁的人可见。</li>
</ul>
<p>这也是为什么 <code>mutex</code> 不只是“门口放一把钥匙”，它还自带内存顺序语义：</p>
<ul>
<li><code>lock</code> 近似 <code>acquire</code>：拿到锁后，能看到上一个 <code>unlock</code> 之前写入的内容；</li>
<li><code>unlock</code> 近似 <code>release</code>：离开临界区前，把这段代码的写入对后继持锁者发布出去。</li>
</ul>
<p>所以互斥锁解决的不只是“同时只能进一个人”，还顺手建立了 <code>happens-before</code>。这正是它比“自己写两个共享变量轮询”可靠得多的原因。</p>
<h2>互斥锁的使用原则与粒度策略</h2>
<p>锁最常见的 bug 不是算法高深，而是保护边界画错了。最重要的原则有三条：</p>
<ul>
<li>保护同一组共享不变量的代码，必须用同一把锁；</li>
<li><code>lock</code> 和 <code>unlock</code> 必须成对，且异常路径也不能漏；</li>
<li>临界区里尽量不要做 I/O、睡眠、阻塞等待等长耗时操作。</li>
</ul>
<p>一个经典错误是“访问同一个共享变量，却用了两把不同的锁”：</p>
<pre><code>lock_t L, I;

void T1() { lock(&amp;L); sum++; unlock(&amp;L); }
void T2() { lock(&amp;I); sum++; unlock(&amp;I); } // BUG
</code></pre>
<p>看起来都“加锁了”，其实没有互斥，因为两段代码没有被<strong>同一把</strong>锁串行化。</p>
<p>关于锁粒度，课程给出的工程建议非常朴素：<strong>从一把大锁开始</strong>。</p>
<ul>
<li>一把大锁最不容易错；</li>
<li>先把程序写对，再做压力测试和 profile；</li>
<li>只有证明锁竞争真是瓶颈，才拆成细粒度锁。</li>
</ul>
<p>Linux 早期的 <code>Big Kernel Lock</code> 就是这么来的：先让系统在多处理器上“能工作”，再花很多年沿着真实瓶颈慢慢拆。对学习和工程都一样，先正确，再优化。</p>
<h2>互斥与并行性的关系</h2>
<p>乍看上去，互斥就是强行串行化，似乎和并行完全对立；但真正的判断标准是“被串行化的部分到底占多大”。</p>
<p>从固定工作量看，Amdahl's Law 说：</p>
<p>$
S(N) = \frac{1}{s + \frac{1-s}{N}}
$</p>
<p>其中 <code>s</code> 是不可并行部分。它强调的是瓶颈：只要串行部分不为 0，加速比就有上限。锁把临界区串行化，等于增大了 <code>s</code>，所以临界区越大，并行扩展性越差。</p>
<p>从可扩展工作量看，Gustafson's Law 则更乐观：如果总运行时间大致固定，而问题规模可以随处理器数增加，那么绝大多数并行部分都能扩张，多核仍然有意义。</p>
<p>所以二者并不矛盾，只是回答不同问题：</p>
<ul>
<li>Amdahl：固定任务规模下，加核的上限有多高；</li>
<li>Gustafson：在相近时间预算下，机器变强后能把问题做多大。</li>
</ul>
<p>对锁的启发是：不要空想“完全无锁的完美并行”，而是要问“真正被锁住的关键路径有多长，能否足够局部化”。</p>
<h2>互斥实现的历史路径：Peterson 到原子指令</h2>
<p>讲义专门走了一遍历史路径：先尝试纯软件互斥，再承认这条路在现代机器上不对劲，最后让硬件提供原子指令。</p>
<p>Peterson 协议是两线程互斥的经典算法：</p>
<pre><code>int flag[2] = {0, 0};
int turn;

void lock(int self) {
    flag[self] = 1;
    turn = 1 - self;
    while (flag[1 - self] &amp;&amp; turn == 1 - self) {
        ;
    }
}

void unlock(int self) {
    flag[self] = 0;
}
</code></pre>
<p>它在<strong>顺序一致性</strong>假设下是正确的：两个线程都想进时，用 <code>turn</code> 打破平局；如果只有一个线程想进，它可以直接进入。</p>
<p>但它有两个致命边界：</p>
<ul>
<li>只直接适用于 2 个线程；推广到 <code>n</code> 线程虽有 <code>Filter lock</code>、<code>Bakery algorithm</code> 一类方案，但代价高，工程上几乎不用；</li>
<li>它依赖“普通 <code>load/store</code> 原子且按程序顺序生效”的强假设，而现代编译器和 CPU 的重排会破坏这个前提。</li>
</ul>
<p>所以 Peterson 的价值主要是：</p>
<ul>
<li>它说明了互斥问题本身可以被精确定义和证明；</li>
<li>它说明“测试很多次没出错”不等于程序正确；</li>
<li>它也说明在现代机器上，直接靠普通共享变量硬写锁并不是对的努力方向。</li>
</ul>
<p>更现实的解法是让硬件直接给出原子读改写指令，例如 <code>LOCK</code>、<code>LL/SC</code>、<code>CAS</code>、<code>fetch_add</code>。这样“不可分割”不再靠协议模拟，而由 ISA 保证。</p>
<h2>自旋锁的实现机制与适用边界</h2>
<p>有了硬件原子指令之后，最直接的锁实现就是自旋锁：</p>
<pre><code>typedef int spinlock_t;
#define LOCKED 1
#define UNLOCKED 0

void spin_lock(spinlock_t *lock) {
    while (__atomic_exchange_n(lock, LOCKED, __ATOMIC_ACQUIRE) == LOCKED) {
        ;
    }
}

void spin_unlock(spinlock_t *lock) {
    __atomic_store_n(lock, UNLOCKED, __ATOMIC_RELEASE);
}
</code></pre>
<p>它的模型非常简单：</p>
<ul>
<li>抢锁成功就继续；</li>
<li>抢不到就原地 <code>busy wait</code>。</li>
</ul>
<p><code>spinlock</code> 解决的是互斥，但它的等待方式非常激进：拿不到锁时不睡觉，只烧 CPU。它因此只适合很窄的场景：</p>
<ul>
<li>临界区极短；</li>
<li>持锁线程几乎不会被抢占；</li>
<li>等待时间预计远小于一次睡眠/唤醒/调度切换的成本。</li>
</ul>
<p>它的两大缺陷也正来自这里：</p>
<ul>
<li>一个线程持锁时，其他核可能“一核有难，八核围观”，全部空转；</li>
<li>更糟的是持锁线程若被调度出去，等待者还会继续白烧 CPU，直到它重新被调回来。</li>
</ul>
<p>这也是为什么线程数超过 CPU 核数后，spinlock 往往性能断崖式下跌。</p>
<h2>内核参与的阻塞式加锁机制</h2>
<p>只靠 <code>spinlock</code>，线程拿不到锁时唯一能做的就是一直试。但如果想做到“拿不到锁就睡，锁可用时再醒”，线程自己做不到，必须让操作系统参与。</p>
<p>这就是“把锁的实现放入操作系统中”的含义：等待锁不再只是用户态里的循环，而变成：</p>
<ul>
<li>线程发现锁被占用；</li>
<li>进入内核，告诉调度器“我在等这把锁”；</li>
<li>内核把它挂到等待队列，标记为休眠/阻塞；</li>
<li>解锁时内核再唤醒等待者。</li>
</ul>
<p>最朴素的伪代码是：</p>
<pre><code>void lock(int *m) {
    while (syscall(SYS_mutex_acquire, m)) {
        ; // 内核内部会把当前线程睡眠
    }
}

void unlock(int *m) {
    syscall(SYS_mutex_release, m);
}
</code></pre>
<p>这样做的好处是：</p>
<ul>
<li>等待者不再空转；</li>
<li>调度器知道谁在等谁；</li>
<li>可以用睡眠-唤醒代替忙等。</li>
</ul>
<p>代价是：</p>
<ul>
<li>每次 <code>lock/unlock</code> 可能都要进内核；</li>
<li>系统调用和上下文切换都有开销。</li>
</ul>
<p>所以“全靠用户态自旋”和“每次都进内核”都太极端。现代系统采用的是折中路线。</p>
<h2>Futex 的混合实现模型</h2>
<p><code>futex</code> 的全称就是 <code>fast userspace mutex</code>。它不是“另一种高层语义上的锁”，而是一种把用户态原子操作和内核睡眠/唤醒拼起来的底层机制。</p>
<p>核心思想是两条路径：</p>
<ul>
<li><code>fast path</code>：没人竞争时，直接在用户态用原子指令拿锁和放锁，不进内核；</li>
<li><code>slow path</code>：抢不到锁时，调用 <code>futex(FUTEX_WAIT, ...)</code> 让内核把线程挂起；释放锁时若发现有等待者，再 <code>futex(FUTEX_WAKE, ...)</code> 唤醒它们。</li>
</ul>
<p>因此四个概念必须分清：</p>
<ul>
<li><code>spinlock</code>：互斥语义，等待方式是忙等；</li>
<li><code>mutex</code>：互斥语义，等待方式通常是阻塞；</li>
<li><code>futex</code>：底层睡眠/唤醒机制，用来高效实现前者中的阻塞路径；</li>
<li><code>pthread_mutex_t</code>：对程序员暴露的高层 <code>mutex</code> API，glibc 背后常常靠 futex 实现。</li>
</ul>
<p>所以“<code>mutex</code> 和 <code>futex</code> 有什么区别”时，最准确的答案是：</p>
<ul>
<li><code>mutex</code> 是同步语义；</li>
<li><code>futex</code> 是 Linux 里常见的实现机制。</li>
</ul>
<p>讲义里的 benchmark 结论也很直观：多数场景下性能大致是 <code>atomic &gt; mutex &gt; spin &gt; trap</code>。其中 <code>trap</code> 指每次都强行进内核的朴素方案，它慢正说明了为什么 futex 一定要保留用户态快路径。</p>
<h2>相关概念辨析</h2>
<ul>
<li><code>critical section</code>：一段必须看起来原子执行的代码区域，是问题定义；</li>
<li><code>mutex</code>：保证临界区互斥进入的高层原语；</li>
<li><code>transactional memory</code>：另一种实现临界区原子性的机制，冲突时回滚；</li>
<li><code>Peterson</code>：两线程、顺序一致性假设下的纯软件互斥协议，教学价值大于工程价值；</li>
<li><code>spinlock</code>：基于原子交换的忙等锁，适合极短临界区；</li>
<li><code>syscall-based lock</code>：拿不到锁就交给内核阻塞，正确但慢；</li>
<li><code>futex</code>：用户态快路径 + 内核慢路径的混合机制，是现代 <code>pthread_mutex_t</code> 的常见底层。</li>
</ul>
<h2>本节小结</h2>
<pre><code>1. critical section 是“哪段代码必须受保护”，mutex 是“怎么保护”的机制。
2. lock/unlock 不只是串行化，还建立 acquire/release 语义和 happens-before。
3. 锁的第一原则是“同一组共享不变量用同一把锁”，工程上先一把大锁，再按瓶颈细化。
4. Amdahl 讲固定规模下的串行瓶颈上限，Gustafson 讲固定时间下的规模扩展。
5. Peterson 协议能帮助理解互斥证明，但只直接适用于 2 线程，且依赖现代机器并不满足的强假设。
6. spinlock 用忙等换低延迟；一旦等待时间长或线程数超过核数，就会浪费 CPU 甚至崩溃。
7. 把锁“放进操作系统”就是把等待锁这件事交给调度器，用睡眠/唤醒替代空转。
8. futex 不是 mutex 的同义词，而是 Linux 中常用的底层实现机制：无争抢走用户态，争抢走内核。
</code></pre>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
  <entry>
    <title>13.并行算法和数据结构</title>
    <link href="https://katyusha-blog.com/posts/nju-os/os/multi-thread/parallel-algorithmds/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/os/multi-thread/parallel-algorithmds/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-21T00:00:00.000Z</updated>
    <summary>13.并行算法和数据结构</summary>
    <content type="html"><![CDATA[<h1>并行算法与数据结构</h1>
<p>第 18 讲的核心问题不是“并发程序如何写对”，而是“已经写对以后，如何让它随 CPU、线程数甚至机器数增长而加速”。互斥锁、条件变量、信号量能把共享状态的不变量保护起来，但如果所有操作都被压成一个全序，程序就获得了正确性，却失去了 scalability。</p>
<p>本讲可以压缩成一个统一模型：</p>
<pre><code>把共享热点移出高频路径，把同步边压到计算图边界。
</code></pre>
<p>并行算法关注如何把计算拆成大块本地任务；并行数据结构关注如何把全局共享状态拆成局部状态、分片状态或近似状态。二者本质相同：减少必须被 release-acquire 串行化的事件。</p>
<h2>从互斥正确性到可扩展性瓶颈</h2>
<p>并发编程中最常见的操作仍然是 <code>sum++</code> 这一类极短临界区：</p>
<pre><code>void T_sum() {
    mutex_lock(&amp;lk);
    sum++;
    mutex_unlock(&amp;lk);
}
</code></pre>
<p>现实系统中的很多操作都具有同样形态：</p>
<pre><code>buf[len++] = elem;
mapping[key] = value;
counter++;
queue.push(task);
</code></pre>
<p>互斥锁给出的保证很强：</p>
<pre><code>unlock(lk) release
lock(lk)   acquire
</code></pre>
<p>只要所有线程都用同一把锁保护同一组共享状态，每次临界区就可以被理解为某个全局顺序中的一个原子步骤。这解决了 data race、lost update 和不变量破坏问题。</p>
<p>但它也带来硬瓶颈：如果每次 <code>sum++</code> 都必须进入同一把锁，那么无论有 1 个核还是 128 个核，同时能推进这个操作的线程仍然只有一个。临界区越短，锁本身的同步、缓存一致性和调度成本占比越高；线程越多，竞争越重，扩展性越差。</p>
<p>因此，本讲讨论的不是“是否需要同步”，而是：</p>
<pre><code>哪些同步是语义上必要的？
哪些同步只是实现方式造成的热点？
</code></pre>
<h2>Scale Up 与 Scale Out</h2>
<p>Scale up 是单机内增加 CPU、核心、硬件线程，让同一个程序获得更高吞吐。Scale out 是增加机器，让集群整体吞吐增长。</p>
<p>二者的共同条件是：问题必须能分解为大量主要本地执行、少量边界通信的任务。典型结构是：</p>
<pre><code>mutex_lock(&amp;lk);
job = get();
mutex_unlock(&amp;lk);

job-&gt;run();   // 主要是线程本地计算
job-&gt;done();  // 释放后继任务
</code></pre>
<p>只要 <code>job-&gt;run()</code> 的时间远大于获取任务、提交结果、唤醒后继任务的同步时间，系统就有机会 scale。如果任务本身只有几条指令，同步就会吞掉所有并行收益。</p>
<p>这个模型和第 15/16 讲的同步原语直接相连：</p>
<ul>
<li>条件变量适合表达“所有 predecessor 完成后才能继续”。</li>
<li>信号量可以把 predecessor 的完成事件抽象成若干把 key。</li>
<li>互斥锁保护 ready queue、引用计数、完成状态等共享元数据。</li>
<li>真正的并行工作应该尽量位于锁外。</li>
</ul>
<h2>计算图模型</h2>
<p>并行算法的基本抽象是计算图：</p>
<pre><code>节点 = 一段本地计算
边   = 数据依赖 / 同步约束
</code></pre>
<p>如果两个节点之间没有路径依赖，它们就可以并行执行。算法设计的关键不是机械地“开很多线程”，而是设计一张具有足够宽度、足够粗粒度、关键路径足够短的计算图。</p>
<p>评价一张并行计算图时，至少要看三个量：</p>
<pre><code>work  = 总计算量
span  = 关键路径长度
grain = 单个任务的计算粒度
</code></pre>
<p>理论并行度近似受 <code>work / span</code> 限制；实际并行度还受同步、调度、缓存和通信开销限制。一个看似有很多节点的图，如果每个节点都很小，调度和同步会成为主要开销。</p>
<h2>粒度选择与动态规划反例</h2>
<p>以 LCS、编辑距离、矩阵 DP 为例，单个格子的转移通常依赖左、上、左上：</p>
<pre><code>DP[i][j] = f(DP[i - 1][j], DP[i][j - 1], DP[i - 1][j - 1]);
</code></pre>
<p>最天真的并行化是把每个格子当成一个任务。这样会得到大量节点，但每个节点只做极少数加法、比较或赋值，却需要等待多个 predecessor、通知多个 successor。同步成本会远大于计算成本。</p>
<p>更合理的做法是按对角线或按块划分：</p>
<pre><code>1. 同一条反对角线上的格子互不依赖，可以并行。
2. 块内顺序计算，块间按依赖同步。
3. 块越大，同步越少，但可并行宽度下降。
4. 块越小，可并行宽度上升，但同步和调度成本增加。
</code></pre>
<p>这就是 granularity tuning：把任务切到“本地计算显著大于同步开销”的尺度。真正的并行算法优化，经常不是换一个神奇 API，而是重新选择计算图节点的粒度。</p>
<h2>高性能计算的局部性结构</h2>
<p>HPC 的典型任务来自数值密集型科学计算，例如物理模拟、天气预报、有限元、量子化学、线性代数求解和 AI 推理。它们能大规模并行，根源通常是物理世界或数学结构提供了局部性。</p>
<p>例如网格模拟：</p>
<pre><code>每个网格点主要依赖邻近网格点
网格内部可以本地计算
边界处需要交换数据
每一轮 delta t 之后做同步
</code></pre>
<p>这和生产者-消费者或 DAG 执行模型并不冲突，只是规模更大：</p>
<pre><code>单机线程: shared memory + lock/cv/barrier
多机集群: message passing + barrier/reduction
</code></pre>
<p>MPI 和 OpenMP 分别代表两类常见抽象：</p>
<pre><code>#pragma omp parallel for
for (int i = 0; i &lt; n; i++) {
    work(i);
}
</code></pre>
<p>OpenMP 让共享内存机器上的循环并行化非常直接；MPI 则显式表达多进程/多机器之间的消息传递。它们服务的是同一件事：把大规模计算拆成本地执行和边界通信。</p>
<h2>Embarrassingly Parallel</h2>
<p>有些问题几乎不需要同步，被称为 embarrassingly parallel。典型例子包括：</p>
<ul>
<li>Mandelbrot set 中每个像素的迭代计算。</li>
<li>Monte Carlo 模拟中的独立采样。</li>
<li>视频逐帧处理。</li>
<li>fork-based DFS / tree search 的部分场景。</li>
</ul>
<p>这类问题的计算图几乎没有边：</p>
<pre><code>input[i] -&gt; output[i]
</code></pre>
<p>并行化主要问题不是同步正确性，而是任务分配、负载均衡、缓存局部性、I/O 吞吐和结果汇总。Mandelbrot 的每个像素独立，但不同像素迭代次数可能不同，因此静态均分也可能出现负载不均。</p>
<h2>Linpack 与分块线性代数</h2>
<p>HPC-China 100 / Top500 常用 Linpack 作为性能基准，它衡量的是大型稠密线性方程组 <code>Ax = b</code> 的求解能力。</p>
<p>这个基准重要，是因为大量科学计算最终会落到线性代数：</p>
<pre><code>非线性物理系统
-&gt; Newton method
-&gt; 稀疏/稠密线性系统
-&gt; 矩阵分解、矩阵乘、向量运算
</code></pre>
<p>线性代数适合优化的根源是可分块：</p>
<pre><code>矩阵块 = 本地计算单元
块边界 = 数据依赖和通信
</code></pre>
<p>分块同时服务两个目标：</p>
<ul>
<li>并行性：不同块可以分给不同核心、GPU 或机器。</li>
<li>局部性：块能更好地复用 cache、shared memory 或 HBM。</li>
</ul>
<p>这也是后续 SIMD/GPU、AI 推理和 kernel fusion 的基础：不是“并行”这个词本身带来性能，而是把计算组织成硬件喜欢的局部访问模式。</p>
<h2>并行算法的性能判断</h2>
<p>一个并行化方案是否有意义，不能只看线程数，而要看瓶颈在哪里。粗略判断可以用：</p>
<pre><code>T_total = T_local_compute + T_sync + T_comm + T_sched + T_imbalance
</code></pre>
<p>如果优化只增加线程数，却让 <code>T_sync</code>、<code>T_comm</code> 或 <code>T_imbalance</code> 急剧上升，总时间可能反而变差。</p>
<p>常见失败模式：</p>
<ul>
<li>任务太细：调度成本大于计算。</li>
<li>共享热点：所有线程竞争同一个 counter、queue 或 lock。</li>
<li>负载不均：某些线程早早结束，等待慢线程。</li>
<li>通信过密：每一小步都跨线程/跨机器交换数据。</li>
<li>缓存抖动：false sharing 或频繁迁移同一 cache line。</li>
</ul>
<p>并行算法的正确打开方式是先画计算图，再决定任务粒度、调度策略和同步边界。</p>
<h2>并行数据结构的核心矛盾</h2>
<p>并行算法可以把大部分计算变成本地任务，但系统里仍然存在大量数据结构操作密集的场景：</p>
<ul>
<li>操作系统内核对象。</li>
<li>数据库索引和锁表。</li>
<li>网络服务器连接表。</li>
<li>游戏服务器状态表。</li>
<li>高频交易订单簿。</li>
<li>分配器中的 free list。</li>
</ul>
<p>这些场景里，<code>sum++</code> 不是可以忽略的小元数据，而是吞吐瓶颈本身。此时必须重新审视数据结构的语义保证。</p>
<p>强一致版本的 counter 语义是：</p>
<pre><code>每次读都看到一个完全线性化的最新值。
</code></pre>
<p>但很多系统其实只需要弱一点的保证：</p>
<pre><code>读到的值可以略旧，但不能离谱，最终必须收敛。
</code></pre>
<p>这个放松就是性能空间的来源。</p>
<h2>Sloppy Counter</h2>
<p>Sloppy counter 的思想是：每个线程先更新自己的局部计数，累计到阈值后再批量提交到全局计数。</p>
<pre><code>#define BATCH 100

int sum;
int sum_local[MAX_TID];
mutex_t lk;

void T_sum(int tid) {
    if (++sum_local[tid] == BATCH) {
        mutex_lock(&amp;lk);
        sum += sum_local[tid];
        mutex_unlock(&amp;lk);
        sum_local[tid] = 0;
    }
}
</code></pre>
<p>这里放松的是实时可见性，不是最终正确性：</p>
<pre><code>高频路径: local increment，无锁
低频路径: batch flush，上锁
</code></pre>
<p>如果 <code>BATCH = 100</code>，理想情况下只有约 1% 的更新会进入全局锁。吞吐提升来自两个方面：</p>
<ul>
<li>大多数写入落在线程本地 cache line。</li>
<li>全局锁竞争频率下降两个数量级。</li>
</ul>
<p>代价是读者看到的 <code>sum</code> 可能落后于真实总数，最大误差大致受 <code>BATCH * 线程数</code> 控制。工程里可以用时间阈值、主动 flush 或自适应 batch 在准确性和吞吐之间折中。</p>
<h2>一致性放松的语义边界</h2>
<p>Sloppy counter 不是“随便错一点”。它必须先定义清楚允许放松什么：</p>
<pre><code>允许: 短时间读到旧值
禁止: 丢失已经提交的全局更新
禁止: 局部计数永远不合并
禁止: 退出线程时泄漏本地计数
</code></pre>
<p>适合 sloppy 的场景：</p>
<ul>
<li>统计量、监控指标、近似 QPS。</li>
<li>点赞数、连接数、内存使用量的近似读。</li>
<li>内核 per-cpu counter。</li>
</ul>
<p>不适合 sloppy 的场景：</p>
<ul>
<li>银行账户余额。</li>
<li>引用计数的最后一次释放判断。</li>
<li>安全权限状态。</li>
<li>需要严格线性化的队列长度。</li>
</ul>
<p>优化前必须先回答：这个数据结构的 correctness condition 是什么？如果应用真的需要严格 linearizability，就不能靠近似计数逃课。</p>
<h2>Thread-Local Storage</h2>
<p>手写 <code>sum_local[MAX_TID]</code> 有几个问题：</p>
<ul>
<li>需要自己分配和管理线程编号。</li>
<li>容易出现越界、复用 tid、线程退出清理问题。</li>
<li>多个线程的局部计数可能落在同一 cache line，造成 false sharing。</li>
</ul>
<p>语言和运行时提供了 TLS。下面写法表达的是“每个线程一份全局/静态状态”，具体关键字在 C/C++ 标准中略有差异：</p>
<pre><code>thread_local int sum_local;

void T_sum() {
    if (++sum_local == BATCH) {
        mutex_lock(&amp;lk);
        sum += sum_local;
        mutex_unlock(&amp;lk);
        sum_local = 0;
    }
}
</code></pre>
<p><code>thread_local</code> 的语义是：每个线程自动拥有该变量的一份独立实例。它不是普通栈变量；它更像“按线程复制的全局变量”。</p>
<p>这里要区分语言规则：C11 使用 <code>_Thread_local</code>，C23 才把 <code>thread_local</code> 作为标准拼写；C 的块作用域 TLS 声明通常还需要配合 <code>static</code> 或 <code>extern</code>。C++11 的 <code>thread_local</code> 规则更宽，可以出现在块作用域，但它仍然是线程存储期对象，不是每次函数调用重新创建的自动变量。课堂代码强调的重点是：TLS 不能按普通局部变量理解。</p>
<h2>TLS 的实现机制</h2>
<p>普通全局变量、栈变量和 TLS 变量的寻址模型不同：</p>
<pre><code>int x;              -&gt; x(%rip)       // 全局静态存储
void foo(){ int y;} -&gt; y(%rsp)       // 当前线程栈
thread_local int z -&gt; z(%fs)        // 当前线程 TLS 区
</code></pre>
<p>在 x86-64 Linux 上，线程控制块和 TLS 区通常通过 <code>%fs</code> 段寄存器定位。编译器把 <code>thread_local</code> 访问编译成“TLS base + 固定偏移”的访问。不同线程的 TLS base 不同，因此同一个变量名在不同线程中对应不同地址。</p>
<p>实现上还涉及：</p>
<ul>
<li><code>.tdata</code>：带初始值的 TLS 数据。</li>
<li><code>.tbss</code>：零初始化的 TLS 数据。</li>
<li>线程创建时为新线程分配并初始化 TLS 区。</li>
<li>动态库中的 TLS 需要动态链接器协作。</li>
</ul>
<p>所以 TLS 不是语法魔法，而是 ABI、编译器、链接器、线程库共同提供的一块 per-thread 存储机制。</p>
<h2>TLS 的使用边界</h2>
<p>TLS 适合保存“线程本地、可延迟合并”的状态：</p>
<ul>
<li>局部计数器。</li>
<li>allocator thread cache。</li>
<li>per-thread buffer。</li>
<li>日志缓冲区。</li>
<li>随机数生成器状态。</li>
</ul>
<p>但 TLS 也有边界：</p>
<ul>
<li>线程退出时需要 flush 或析构，否则局部状态可能丢失。</li>
<li>大对象 TLS 会增加每个线程的内存占用。</li>
<li>在线程池中，TLS 生命周期绑定 worker，而不是逻辑请求。</li>
<li>跨线程迁移任务时，TLS 可能让状态跟着线程而不是任务走。</li>
</ul>
<p>对 allocator、runtime、serving system 来说，最后一点尤其重要：如果逻辑请求在不同 worker 间迁移，线程本地缓存能提升局部吞吐，但也可能带来内存膨胀和负载不均。</p>
<h2>锁拆分与数据结构局部性</h2>
<p>并行数据结构的基本思路是避免“一把锁保护整个世界”。如果数据结构天然由多个独立部分组成，就可以把锁拆到更小粒度：</p>
<pre><code>global lock          -&gt; per-structure lock
per-structure lock   -&gt; per-bucket lock
per-bucket lock      -&gt; per-element lock / atomic
</code></pre>
<p>哈希表是最典型例子。<code>hash(key)</code> 把 key 映射到 bucket 后，不同 bucket 的操作通常可以并行：</p>
<pre><code>bucket = hash(key) % N;
mutex_lock(&amp;bucket_locks[bucket]);
operate(table[bucket], key);
mutex_unlock(&amp;bucket_locks[bucket]);
</code></pre>
<p>这比全局锁更可扩展，因为互不相关的 key 不再竞争同一把锁。读多写少时，还可以用 reader-writer lock 允许多个 reader 并发。</p>
<p>但细粒度锁会把正确性问题重新带回来：</p>
<ul>
<li>一个操作需要多个 bucket 时，必须规定锁顺序，否则会 ABBA。</li>
<li>resize 会重建整个数组，和所有 bucket 操作冲突。</li>
<li>open addressing 的 tombstone、删除、遍历和并发 resize 很难组合。</li>
<li>锁粒度越细，越需要明确每个不变量由谁保护。</li>
</ul>
<p>所以并行数据结构不是“把锁拆小”这么简单，而是重新定义不变量的所有权。</p>
<h2>原子指令与 Lock-Free 思路</h2>
<p>如果一个操作能用单条或少量原子指令完成，就可以避免进入 mutex：</p>
<pre><code>atomic_fetch_add(&amp;cnt, 1);
</code></pre>
<p>原子操作减少了内核阻塞和调度开销，但并不自动带来无限扩展。多个核心反复修改同一个 atomic counter，仍然会竞争同一条 cache line；硬件必须在核心之间转移该 cache line 的独占权限。</p>
<p>因此：</p>
<pre><code>atomic 解决的是阻塞和临界区管理问题；
local/sharding 解决的是共享 cache line 热点问题。
</code></pre>
<p>Lock-free list、queue、stack 常用 CAS 维护指针结构，但会引入 ABA、内存回收、hazard pointer、epoch reclamation 等生命周期问题。第 17 讲的 ABA/UAF 在这里会重新出现，而且更难调试。</p>
<h2>Hash Table 的并发难点</h2>
<p>哈希表看似天然适合 per-bucket lock，但真实实现有几个麻烦点。</p>
<p>第一，链式哈希和开放寻址的并发语义不同：</p>
<pre><code>separate chaining: bucket 内链表/树可单独保护
open addressing: 查找路径可能跨多个槽位
</code></pre>
<p>开放寻址依赖探测序列，删除还需要 tombstone。并发删除、查找、插入会改变探测路径的语义；遍历时还可能看到中间状态。</p>
<p>第二，resize 是全局结构变化：</p>
<pre><code>old array -&gt; new larger array
rehash all keys
publish new table
retire old table
</code></pre>
<p>简单做法是 resize 时持有全局 write lock，阻止所有并发访问。这样正确但会产生长暂停。复杂做法是渐进式 rehash、双表查询、RCU 发布和延迟回收，但设计空间会迅速变复杂。</p>
<p>第三，多个锁之间需要固定顺序：</p>
<pre><code>resize_lock -&gt; bucket_lock
</code></pre>
<p>如果某条路径先拿 bucket lock 再等 resize lock，另一条路径先拿 resize lock 再等 bucket lock，就会回到第 17 讲的 ABBA 死锁。</p>
<p>这也是讲义提醒“对库函数保持敬畏”的原因：高性能并发容器背后通常有大量不变量和内存回收细节。</p>
<h2>Malloc/Free 的并行化启示</h2>
<p>第 18 讲把并行数据结构自然连到 malloc/free。分配器本质上维护“空闲内存块集合”这个数据结构。简单设计可能会想到 balanced tree：</p>
<pre><code>find-first(size)
delete(block)
insert(freed_block)
coalesce(neighbor)
</code></pre>
<p>但真实 workload 下，小对象分配/释放远比大对象频繁。脱离 workload 做“最优数据结构”往往是错误方向。</p>
<p>更常见的高性能思路是 segregated free lists / slab：</p>
<pre><code>1. 按对象大小分 size class。
2. 每个 slab 只存同一大小的对象。
3. 线程本地维护小对象 freelist。
4. fast path 在本地 freelist O(1) 分配/释放。
5. slow path 才向中心池申请新 slab 或归还空闲 slab。
</code></pre>
<p>这和 sloppy counter 完全同构：</p>
<pre><code>counter local batch      -&gt; allocator thread cache
global counter flush     -&gt; central heap refill/return
近似实时统计             -&gt; 内存占用和碎片折中
</code></pre>
<p>Fast path 覆盖绝大部分请求，必须极短、少同步、cache 友好；slow path 处理 refill、mmap、跨线程释放、归还大块内存等复杂情况，可以慢一些但必须正确。</p>
<h2>Workload 优先原则</h2>
<p>讲义中特别强调：脱离 workload 做优化就是耍流氓。</p>
<p>优化前至少要知道：</p>
<pre><code>请求大小分布
对象生命周期分布
线程间释放比例
分配/释放频率
峰值内存占用
cache miss / lock contention / syscall 占比
</code></pre>
<p>例如 allocator 中通常可以观察到：</p>
<ul>
<li>小对象创建和销毁最频繁。</li>
<li>中对象数量较少但生命周期更长。</li>
<li>大对象通常应由 <code>mmap</code> 等慢路径直接处理。</li>
<li>如果大对象只扫描一遍就释放，很可能是 workload 或程序本身的性能问题。</li>
</ul>
<p>这类观察决定了设计空间：与其为所有大小做一个理论优雅的数据结构，不如让小对象 fast path 极快，让大对象走清晰可靠的 slow path。</p>
<h2>Fast Path 与 Slow Path</h2>
<p>系统优化中常见两层结构：</p>
<pre><code>Fast path:
  覆盖绝大多数情况
  少分支、少同步、局部性好
  失败后转入 slow path

Slow path:
  处理复杂边界情况
  可以加锁、调用系统调用、整理元数据
  维护全局不变量
</code></pre>
<p>CPU cache、TLB、slab allocator、JIT inline cache、网络协议栈、模型 serving batching 都有类似结构。</p>
<p>关键是 fast path 不能破坏 slow path 依赖的不变量。否则快路径越快，错误扩散越快。并行系统里的 fast path 设计尤其需要写清楚：</p>
<pre><code>本地状态什么时候有效？
什么时候必须同步？
退出线程/释放资源时如何归还？
全局状态如何看到本地状态？
</code></pre>
<h2>与 AI Infra 的连接</h2>
<p>第 18 讲的思想可以直接迁移到 AI infrastructure。</p>
<p>GPU kernel 优化：</p>
<pre><code>global memory 访问 -&gt; shared memory / register tiling
全局同步           -&gt; block 内同步或 kernel 边界同步
小任务             -&gt; 合并成更大 tile 提高算术强度
</code></pre>
<p>模型 serving：</p>
<pre><code>单请求立即执行     -&gt; batching
全局队列大锁       -&gt; sharded queues / per-worker queues
频繁 malloc/free   -&gt; arena / cache / pool
严格实时统计       -&gt; approximate counters / periodic aggregation
</code></pre>
<p>分布式训练：</p>
<pre><code>每步全量同步       -&gt; gradient accumulation / overlap communication
中心参数服务器     -&gt; sharding / all-reduce / pipeline parallelism
跨机通信热点       -&gt; topology-aware placement
</code></pre>
<p>这些系统的核心问题仍然是第 18 讲那句话：把本地计算做大，把同步和通信压到边界。</p>
<h2>设计检查清单</h2>
<p>写并行算法或并行数据结构时，可以按下面顺序检查：</p>
<pre><code>1. correctness condition 是什么？
2. 哪些状态必须严格线性化？
3. 哪些状态可以延迟、近似或批量合并？
4. 计算图的节点和边是什么？
5. 任务粒度是否足够大？
6. 是否存在单个锁、单个 atomic、单条 cache line 的热点？
7. resize、退出、失败、取消、跨线程释放等 slow path 是否维护不变量？
8. workload 是否真的匹配这个优化方向？
9. 如何用 benchmark 证明瓶颈被移动或消除？
</code></pre>
<p>最危险的优化是只看到平均路径，不写边界路径。并行数据结构的 bug 往往不在“普通插入一次”，而在 resize、销毁、异常返回、线程退出、内存回收和锁顺序交叉处。</p>
<h2>本节小结</h2>
<pre><code>1. Mutex 解决正确性，但完全 serializability 会限制 scalability。
2. 并行算法的核心是计算图：节点本地计算，边表示同步依赖。
3. 任务粒度太细会让同步、调度和通信成本吞掉并行收益。
4. HPC 能扩展，根源是空间局部性、分块计算和少量边界同步。
5. Embarrassingly parallel 问题几乎没有同步边，主要挑战转为负载均衡和 I/O。
6. 并行数据结构需要先定义一致性语义，再决定能否放松实时可见性。
7. Sloppy counter 用 per-thread local + batch flush 换取吞吐，代价是短期读旧值。
8. thread_local 是 ABI/编译器/线程库支持的每线程静态存储，不是普通局部变量。
9. 细粒度锁、原子操作和 lock-free 结构都可能重新引入死锁、ABA、UAF 和内存序问题。
10. 高性能 malloc/free 的关键是 workload：小对象 fast path，本地缓存，slow path 维护全局不变量。
11. Fast path/slow path 是系统优化的普遍结构，但 fast path 必须服从全局 correctness condition。
12. 本讲到 AI infra 的桥梁是局部性、批量化、分片、缓存、减少同步和压缩通信边界。
</code></pre>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
  <entry>
    <title>11.并发控制：信号量</title>
    <link href="https://katyusha-blog.com/posts/nju-os/os/multi-thread/signal/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/os/multi-thread/signal/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-16T00:00:00.000Z</updated>
    <summary>11.并发控制：信号量</summary>
    <content type="html"><![CDATA[<h1>信号量与同步</h1>
<p>第 16 讲的核心不是再引入一个新的锁 API，而是把“同步”从互斥问题中单独抽出来：同步关心的是某个事件、条件或资源状态必须先发生，另一个线程才能继续执行。</p>
<p>互斥解决的是“同一时刻谁能访问共享状态”；同步解决的是“哪个时刻之后程序才允许继续”。信号量就是一种用计数器表达同步条件的经典机制。</p>
<h2>同步点与 Happens-Before 关系</h2>
<p>同步点可以理解为程序达到某个全局确定状态的瞬间。等待同步的线程并不是单纯为了避免数据竞争，而是在等待一个条件成为真：</p>
<pre><code>while (!sync_condition()) {
    wait();
}
proceed();
</code></pre>
<p>例如：</p>
<ul>
<li>所有子线程都已经结束；</li>
<li>缓冲区中至少有一个可消费对象；</li>
<li>计算图中某个节点的所有前驱都已经完成；</li>
<li>某个生产者已经产生出消费者所需的资源。</li>
</ul>
<p>一旦同步条件成立，程序中就形成了一对顺序约束：</p>
<pre><code>条件达成前的操作 happens-before 条件达成后的操作
</code></pre>
<p>因此，同步原语的本质是把不确定的线程交错重新约束成可证明的先后关系。</p>
<h2>互斥锁同步模型的局限性</h2>
<p>讲义中先给出了一个“用互斥锁实现同步”的思维实验。对计算图中的一条依赖边 <code>u -&gt; v</code>，分配一把锁 <code>e</code>：</p>
<pre><code>main: lock(e); spawn_threads();
u:    work(u); unlock(e);
v:    lock(e); work(v);
</code></pre>
<p><code>main</code> 先把锁拿走，使 <code>v</code> 无法继续；<code>u</code> 完成后释放这把锁，<code>v</code> 才能获得锁并开始执行。这样就用 <code>unlock(e) -&gt; lock(e)</code> 的 release-acquire 语义实现了：</p>
<pre><code>work(u) happens-before work(v)
</code></pre>
<p>这个例子说明，互斥锁的 <code>release</code> 和 <code>acquire</code> 天然带有内存顺序约束，能够表达一次临时的同步事件。</p>
<p>但这只是理解模型，不是规范的 pthread 写法。<code>pthread_mutex_t</code> 通常要求加锁线程和解锁线程一致；让 <code>main</code> 加锁、<code>u</code> 解锁属于 undefined behavior。它的价值在于暴露出更一般的抽象：如果一个同步凭证可以被某个线程释放、被另一个线程获取，那么它就能表达跨线程的先后关系。</p>
<h2>信号量的基本抽象</h2>
<p>信号量可以看作一个带阻塞等待能力的计数器：</p>
<pre><code>semaphore = count + wait queue
</code></pre>
<p>其中 <code>count</code> 表示当前可被消费的凭证数量。信号量提供两个基本操作：</p>
<ul>
<li><code>P</code> / <code>wait</code> / <code>down</code>：申请一个凭证；</li>
<li><code>V</code> / <code>post</code> / <code>up</code> / <code>signal</code>：释放一个凭证。</li>
</ul>
<p>它们的语义是：</p>
<pre><code>P(sem):
    wait until sem-&gt;count &gt; 0;
    sem-&gt;count--;

V(sem):
    sem-&gt;count++;
    wake up possible waiters;
</code></pre>
<p><code>P</code> 成功返回时，程序可以保证曾经存在一个可消费的凭证；<code>V</code> 则创建一个新的凭证，并可能唤醒等待者。初始值 <code>count = k</code> 可以理解为系统一开始已经执行了 <code>k</code> 次 <code>V</code>。</p>
<h2>信号量的实现结构</h2>
<p>讲义中的教学实现可以写成 <code>mutex + condition variable</code>：</p>
<pre><code>void P(sem_t *sem) {
    mutex_lock(&amp;sem-&gt;lk);
    while (!(sem-&gt;count &gt; 0)) {
        cond_wait(&amp;sem-&gt;cv, &amp;sem-&gt;lk);
    }
    sem-&gt;count--;
    mutex_unlock(&amp;sem-&gt;lk);
}

void V(sem_t *sem) {
    mutex_lock(&amp;sem-&gt;lk);
    sem-&gt;count++;
    cond_broadcast(&amp;sem-&gt;cv);
    mutex_unlock(&amp;sem-&gt;lk);
}
</code></pre>
<p>这里 <code>mutex</code> 保护 <code>count</code> 的检查和修改，<code>condition variable</code> 负责在 <code>count == 0</code> 时阻塞等待。这个版本适合解释语义，但不是唯一实现方式。</p>
<p>真实系统中的信号量通常会分成快路径和慢路径：</p>
<ul>
<li>快路径：使用原子操作检查并修改计数器；</li>
<li>慢路径：当计数不足时，进入等待队列、futex 或内核调度机制；</li>
<li>唤醒路径：<code>V</code> 修改计数后唤醒可能阻塞的线程。</li>
</ul>
<p>因此，信号量的底层本质不是“单靠原子操作”，也不是“必须由条件变量实现”，而是“原子计数 + 阻塞唤醒机制”。条件变量只是教学上方便的一种上层模拟方式。</p>
<h2>信号量与互斥锁的关系</h2>
<p>当信号量初始值为 <code>1</code> 时，它可以实现互斥锁：</p>
<pre><code>sem_t sem = SEM_INIT(1);

void lock() {
    P(&amp;sem);
}

void unlock() {
    V(&amp;sem);
}
</code></pre>
<p>此时系统中最多只有一个凭证，所以最多只有一个线程能通过 <code>P</code> 进入临界区。从这个意义上说，互斥锁可以看作二值信号量的特例。</p>
<p>但二者的语义重点不同：</p>
<ul>
<li>互斥锁强调所有权和临界区保护，通常要求同一线程加锁和解锁；</li>
<li>信号量强调凭证数量和同步事件，<code>V</code> 与 <code>P</code> 可以发生在不同线程中；</li>
<li>互斥锁保护“谁能访问共享状态”，信号量表达“还有多少个许可可以被消费”。</li>
</ul>
<p>因此，在写程序时不要把信号量简单当成“更通用的锁”。它更适合表达计数型资源和事件配对。</p>
<h2>一次性事件同步</h2>
<p>信号量可以直接表达一次临时的 happens-before 关系：</p>
<pre><code>sem_t done = SEM_INIT(0);

void A() {
    work_A();
    V(&amp;done);
}

void B() {
    P(&amp;done);
    work_B();
}
</code></pre>
<p>由于 <code>done</code> 初始为 <code>0</code>，<code>B</code> 在 <code>P(&amp;done)</code> 处必须等待。只有 <code>A</code> 执行 <code>V(&amp;done)</code> 后，<code>B</code> 才可能继续。因此可以得到：</p>
<pre><code>work_A() happens-before work_B()
</code></pre>
<p>这正是前面“用互斥锁实现同步”思维实验的规范版本。互斥锁 hack 中的“放回钥匙”被替换成了信号量的 <code>V</code>，等待者通过 <code>P</code> 消费这个事件。</p>
<h2>并发数量控制</h2>
<p>信号量的另一个典型用途是限制并发数量。若某类资源最多允许 <code>n</code> 个线程同时使用，可以令：</p>
<pre><code>sem_t quota = SEM_INIT(n);

void worker() {
    P(&amp;quota);
    use_limited_resource();
    V(&amp;quota);
}
</code></pre>
<p><code>quota</code> 的计数器表示当前剩余的资源许可。只要每个线程进入前执行 <code>P</code>、离开后执行 <code>V</code>，系统中同时使用该资源的线程数就不会超过 <code>n</code>。</p>
<p>这个模型适用于停车场空位、游泳馆手环、餐厅桌位、连接池容量、线程池任务配额等具有自然数量含义的问题。</p>
<h2>线程 Join 与计算图同步</h2>
<p>信号量可以实现线程 <code>join</code>。若主线程需要等待 <code>n</code> 个工作线程结束，可以使用一个初始值为 <code>0</code> 的信号量：</p>
<pre><code>sem_t done = SEM_INIT(0);

void worker() {
    work();
    V(&amp;done);
}

void main_thread() {
    for (int i = 0; i &lt; n; i++) {
        P(&amp;done);
    }
}
</code></pre>
<p>每个工作线程结束时释放一个凭证，主线程需要消费 <code>n</code> 个凭证才能继续。这里 <code>done.count</code> 表示“已经完成但尚未被主线程收集的线程数量”。</p>
<p>在一般计算图中，边 <code>u -&gt; v</code> 表示 <code>v</code> 必须等待 <code>u</code> 完成。可以为每条边分配一个初始值为 <code>0</code> 的信号量：</p>
<pre><code>u: work(u); V(edge_u_v);
v: P(edge_u_v); work(v);
</code></pre>
<p>也可以为每个节点维护一个计数型信号量，使节点在收齐所有前驱凭证后再执行。信号量适合这种模型，是因为依赖关系可以被转化为“需要消费多少个完成事件”。</p>
<h2>生产者消费者模型</h2>
<p>生产者消费者是信号量最自然的应用之一。对于容量为 <code>depth</code> 的缓冲区，可以定义：</p>
<pre><code>sem_t empty = SEM_INIT(depth);
sem_t fill  = SEM_INIT(0);
</code></pre>
<p>其中：</p>
<ul>
<li><code>empty</code> 表示当前空槽数量；</li>
<li><code>fill</code> 表示当前可消费对象数量。</li>
</ul>
<p>基本结构是：</p>
<pre><code>void produce() {
    P(&amp;empty);
    put_item();
    V(&amp;fill);
}

void consume() {
    P(&amp;fill);
    get_item();
    V(&amp;empty);
}
</code></pre>
<p>生产者先消费一个空槽，再产生一个满槽；消费者先消费一个满槽，再释放一个空槽。系统中的核心不变量是：</p>
<pre><code>empty + fill + producer_holding + consumer_holding = depth
</code></pre>
<p>如果 <code>put_item()</code> 和 <code>get_item()</code> 操作真实共享队列，还需要额外的互斥锁保护队列结构。此时信号量负责资源数量，互斥锁负责数据结构一致性。两者职责不同，不能互相替代。</p>
<h2>与条件变量的表达能力对比</h2>
<p>信号量等待的是一个计数器：</p>
<pre><code>count &gt; 0
</code></pre>
<p>条件变量等待的是共享状态上的任意谓词：</p>
<pre><code>predicate(shared_state) == true
</code></pre>
<p>因此，信号量在同步条件可以自然转化为数量时非常简洁，例如：</p>
<ul>
<li>缓冲区中有多少个元素；</li>
<li>还剩多少个空槽；</li>
<li>已经完成多少个子任务；</li>
<li>还允许多少个线程进入某个区域。</li>
</ul>
<p>但当同步条件是复杂的全局状态时，单个计数器往往难以表达。例如：</p>
<pre><code>avail[lhs] &amp;&amp; avail[rhs]
can_put &amp;&amp; !pending_close
pred_done == n_pred
</code></pre>
<p>这些条件更适合用 <code>mutex + condition variable</code> 表达：</p>
<pre><code>mutex_lock(&amp;lk);
while (!predicate(shared_state)) {
    cond_wait(&amp;cv, &amp;lk);
}
update_shared_state();
mutex_unlock(&amp;lk);
</code></pre>
<p>所以二者的关键区别可以概括为：</p>
<ul>
<li>信号量等待一个 token；</li>
<li>条件变量等待一个 predicate。</li>
</ul>
<p>理论上，条件变量可以用信号量和额外状态模拟；反过来，信号量也可以用互斥锁和条件变量实现。但工程上不应只看可实现性，还要看同步条件是否能被清晰建模。</p>
<h2>信号量建模的适用边界</h2>
<p>信号量不是万能同步方法。讲义中的哲学家吃饭问题说明了这一点。</p>
<p>每个哲学家需要同时获得左右两把叉子，对应条件是：</p>
<pre><code>avail[lhs] &amp;&amp; avail[rhs]
</code></pre>
<p>若直接写成：</p>
<pre><code>P(&amp;fork[lhs]);
P(&amp;fork[rhs]);
</code></pre>
<p>可能出现所有哲学家都拿起左手叉子、再一起等待右手叉子的循环等待，进而发生死锁。问题不在于 <code>P/V</code> 本身错误，而在于“同时获得两份资源”这个全局条件没有被一个简单计数器完整表达。</p>
<p>可以通过额外协议修复，例如：</p>
<ul>
<li>限制最多只有四个哲学家同时尝试进餐；</li>
<li>对叉子编号，所有线程按固定顺序获取资源；</li>
<li>引入调度者，由调度者根据全局状态决定谁可以继续。</li>
</ul>
<p>这些方案都说明：使用信号量时必须明确计数器到底代表什么不变量。若计数器无法自然对应同步条件，代码会变得复杂，正确性证明也会变难。</p>
<h2>条件变量模拟的原子性问题</h2>
<p>用信号量模拟条件变量时，最困难的问题是避免漏唤醒。一个朴素实现可能写成：</p>
<pre><code>void wait(cond_t *cv, mutex_t *mutex) {
    cv-&gt;nwait++;
    mutex_unlock(mutex);
    P(&amp;cv-&gt;sleep);
    mutex_lock(mutex);
}

void broadcast(cond_t *cv) {
    mutex_lock(&amp;cv-&gt;lock);
    for (int i = 0; i &lt; cv-&gt;nwait; i++) {
        V(&amp;cv-&gt;sleep);
    }
    cv-&gt;nwait = 0;
    mutex_unlock(&amp;cv-&gt;lock);
}
</code></pre>
<p>这个模型的危险在于：线程可能已经计入等待者数量，但还没有真正执行到 <code>P(&amp;cv-&gt;sleep)</code>；此时另一个线程执行 <code>broadcast</code>，释放的凭证可能被错误线程消费，或导致后续等待者错过唤醒。</p>
<p>条件变量的 <code>cond_wait(&amp;cv, &amp;mutex)</code> 必须保证“释放互斥锁”和“进入等待状态”之间没有可见空窗。若这个 release-wait 动作不能做成不可分割的原子操作，就会出现 lost wakeup。真实系统通常需要 futex、等待队列或内核调度机制来完成这一步。</p>
<h2>同步原语的粒度差异</h2>
<p>信号量和条件变量的粒度差异不是“谁的锁更细”，而是建模对象不同。</p>
<p>信号量的粒度是 token 或资源单位。它把资源数量拆成可消费的许可，适合把同步条件写成 <code>count &gt; 0</code> 的问题。生产者消费者中，<code>empty</code> 和 <code>fill</code> 分别对应空槽和满槽，粒度非常清楚。</p>
<p>条件变量的粒度是共享状态交接点。它通常配合一把互斥锁保护一组共享不变量，等待者在 <code>while (!predicate)</code> 中睡眠，修改者更新状态后唤醒等待者。等待期间线程不持有互斥锁，因此锁只覆盖条件检查和状态修改，不覆盖睡眠时间。</p>
<p>因此：</p>
<ul>
<li>计数型资源问题中，信号量通常更直接；</li>
<li>复杂谓词同步中，条件变量通常更清晰；</li>
<li>二者都需要控制临界区范围，把主要计算放到同步原语之外。</li>
</ul>
<h2>使用原则</h2>
<p>使用信号量时，首先要回答“计数器代表什么”：</p>
<ul>
<li>若它代表剩余资源数，<code>P</code> 是占用资源，<code>V</code> 是释放资源；</li>
<li>若它代表已发生事件数，<code>V</code> 是发布事件，<code>P</code> 是消费事件；</li>
<li>若它代表并发配额，<code>P</code> 是进入受限区域，<code>V</code> 是离开受限区域。</li>
</ul>
<p>一个信号量对应一个清晰的不变量。若无法写出这个不变量，通常说明问题更适合使用条件变量、互斥锁保护的共享状态，或者额外的调度协议。</p>
<p>实践中可以按以下顺序选择同步原语：</p>
<ul>
<li>只需要保护共享数据结构：优先使用互斥锁；</li>
<li>需要等待任意共享状态条件：优先使用条件变量；</li>
<li>需要表达资源数量、事件次数或并发配额：优先使用信号量；</li>
<li>条件复杂且涉及多个资源：先写出全局不变量，再决定是否需要额外调度者或锁顺序。</li>
</ul>
<h2>小结</h2>
<p>信号量是一个带阻塞等待能力的计数器。它通过 <code>P</code> 消费凭证、通过 <code>V</code> 释放凭证，从而表达资源数量、事件配对和 happens-before 关系。</p>
<p>第 16 讲的主线可以概括为：</p>
<ol>
<li>同步的本质是建立跨线程的先后关系。</li>
<li>互斥锁的 release-acquire 语义可以帮助理解同步，但跨线程解锁 mutex 不是规范实现。</li>
<li>信号量把“一个凭证”推广成“多个凭证”，从而支持事件同步和资源计数。</li>
<li>生产者消费者是信号量最自然的模型，因为同步条件本身就是数量。</li>
<li>条件变量更适合任意谓词同步，信号量更适合计数型同步。</li>
<li>所有同步程序最终都要回到共享状态、不变量和 happens-before 关系的证明。</li>
</ol>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
  <entry>
    <title>15.CPU、SIMD 和 GPU</title>
    <link href="https://katyusha-blog.com/posts/nju-os/os/multi-thread/simdgpu/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/os/multi-thread/simdgpu/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-22T00:00:00.000Z</updated>
    <summary>15.CPU、SIMD 和 GPU</summary>
    <content type="html"><![CDATA[<h1>CPU、SIMD 与 GPU</h1>
<p>第 20 讲的主线不是再介绍一种“并行 API”，而是推翻一个已经陪伴我们很多讲的工作模型：</p>
<pre><code>CPU 不是一条指令、一拍、一条指令地老老实实往前走。
</code></pre>
<p>第 13-19 讲里我们经常把处理器抽象成 <code>rv32ima_step()</code>：取指、译码、执行、写回，然后进入下一条指令。这个模型对理解 data race、lock、condition variable、barrier、task graph 已经足够有用；但它隐藏了一个重要事实：现代处理器内部从来就是并行系统。逻辑门天然并行，处理器把这种物理并行包装成“顺序执行的假象”；而 SIMD 和 GPU 则是在某些场景中主动撕开这层假象，让程序员显式利用硬件并行能力。</p>
<p>本讲可以压缩成一句话：</p>
<pre><code>ILP、SIMD、SIMT 的区别，不是“能不能并行”，而是“谁负责发现并行性、谁共享控制流、谁承担分支和访存代价”。
</code></pre>
<h2>顺序执行模型与处理器内部并行性</h2>
<p>CPU ISA 暴露给程序员的是一台按程序顺序执行指令的机器：</p>
<pre><code>x = x + 1;
y = y + 2;
z = x + y;
</code></pre>
<p>从语言和 ISA 语义看，上述语句可以映射成某种顺序指令流；但在硬件里，只要依赖关系允许，处理器就会尝试把多条指令同时推进：</p>
<pre><code>fetch multiple instructions
decode multiple instructions
rename registers
check data dependence
issue independent instructions together
execute out of order
retire in order
</code></pre>
<p>这就是 Instruction-Level Parallelism。逻辑门天生并行，所以“一个周期内只有一条指令活动”本来就不是硬件的自然状态；真正难的是在并行执行之后仍然维持顺序语义。乱序执行、寄存器重命名、按序提交本质上都在做同一件事：</p>
<pre><code>内部尽量并行，外部看起来仍然像顺序机。
</code></pre>
<p>因此第 13 讲里 <code>sum++</code> 之所以会暴露并发问题，并不只是“你开了多个线程”；即使单核 CPU 也可能在流水线、cache、一致性层面并行推进大量事件。现代系统的默认背景噪音本来就是并行。</p>
<h2>指令级并行的能力边界</h2>
<p>ILP 很强，但它依赖处理器自动在有限指令窗口里挖掘独立性。最典型的对比是：</p>
<pre><code>// 强依赖链
for (...) x = x * 3 + 1;

// 独立指令
for (...) {
    x0 = x0 * 3 + 1;
    x1 = x1 * 3 + 1;
    x2 = x2 * 3 + 1;
    x3 = x3 * 3 + 1;
}
</code></pre>
<p>前者每一步都依赖上一步结果，后者给了 CPU 更多可同时发射的独立操作。讲义里 IPC 实测的核心结论正是：</p>
<pre><code>依赖链限制吞吐，独立指令让 CPU 的多发射能力显现出来。
</code></pre>
<p>但 ILP 的问题是：</p>
<ul>
<li>能发现的独立性受限于局部指令窗口。</li>
<li>前后依赖复杂时，乱序引擎无能为力。</li>
<li>它优化的是“同一线程内部几条指令并行”，不是“同一操作对很多数据并行”。</li>
</ul>
<p>当程序形态变成：</p>
<pre><code>for (int i = 0; i &lt; n; i++) c[i] = a[i] + b[i];
</code></pre>
<p>瓶颈就不再是“几条标量指令如何乱序”，而是“完全同构的计算重复了 n 次”。这时更自然的硬件抽象就是数据级并行。</p>
<h2>SIMD 的基本抽象与硬件结构</h2>
<p>SIMD = Single Instruction, Multiple Data。它表达的是：</p>
<pre><code>同一条指令
对多个独立数据
做同一种操作
</code></pre>
<p>例如把 128-bit 寄存器看成 4 个 <code>int32_t</code> 槽位：</p>
<pre><code>[a0 a1 a2 a3] + [b0 b1 b2 b3]
-&gt; [a0+b0 a1+b1 a2+b2 a3+b3]
</code></pre>
<p>这不是编译器把一条向量指令偷偷翻译成 4 次普通加法循环执行，而是硬件真的提供了：</p>
<ul>
<li>更宽的寄存器；</li>
<li>更宽的数据通路；</li>
<li>多个 lane 的并行算术单元；</li>
<li>共享的取指、译码、发射与提交逻辑。</li>
</ul>
<p>因此 SIMD 的关键不在“它也是电路实现”，因为标量 ALU 当然也是电路实现；真正关键的是：</p>
<pre><code>SIMD 通过共享一套控制流成本，驱动多条 lane 同时运算。
</code></pre>
<h2>SIMD 指令的成本摊薄机制</h2>
<p>把一条指令的成本拆开来看：</p>
<pre><code>总成本 = 前端控制成本 + 后端执行成本
</code></pre>
<p>前端控制成本包括：</p>
<ul>
<li>取指；</li>
<li>译码；</li>
<li>寄存器重命名；</li>
<li>发射；</li>
<li>提交。</li>
</ul>
<p>这些环节更接近“按指令条数收费”。因此：</p>
<ul>
<li>标量加法：1 条指令处理 1 个元素；</li>
<li>向量加法：1 条指令处理 4/8/16 个元素；</li>
<li>前端仍然大致只处理 1 条指令。</li>
</ul>
<p>后端执行当然不是零代价。SIMD 需要更宽的寄存器文件、更宽的旁路网络、更高功耗和更难的时序设计。但它不是每多一个元素就复制一整套 CPU 控制逻辑，而是：</p>
<pre><code>共享控制，扩宽 datapath。
</code></pre>
<p>这就是为什么 SIMD 常常看起来“几乎白送”。它并不免费，但增加的主要是数据通路宽度，不是每个元素都重复一次完整的 fetch/decode/commit 流程。</p>
<h2>SIMD 的性能收益来源与适用条件</h2>
<p>SIMD 的收益并不只是“每条指令算得更多”，还来自：</p>
<ul>
<li>更少的动态指令数；</li>
<li>更好的 fetch/decode 摊薄；</li>
<li>更少的循环控制开销；</li>
<li>更高的单位访存有效载荷；</li>
<li>更容易把连续数据组织成顺序访问。</li>
</ul>
<p>但这也意味着 SIMD 并非无条件有效。常见限制包括：</p>
<ul>
<li>数据必须有足够规则的布局；</li>
<li>lane 内最好做相同运算；</li>
<li>分支、shuffle、mask、gather/scatter 会抬高代价；</li>
<li>宽向量指令可能受限于频率、端口或 load/store 带宽。</li>
</ul>
<p>所以真正的优化问题不是“有没有 SIMD 指令”，而是：</p>
<pre><code>能不能把算法重排成硬件喜欢的、规则的、同构的、连续的向量工作流。
</code></pre>
<h2>位并行技术与 Subword Parallelism</h2>
<p>讲义里特别重要的一点是：很多人第一次接触 SIMD，以为它是某个神秘的新概念；其实你很可能早就在用它的思想。</p>
<p>例如 bitset：</p>
<pre><code>arr[x / 64] &gt;&gt; (x % 64)
</code></pre>
<p>它的底层并不是“天然就是 SIMD 指令”，而是：</p>
<pre><code>先用整数压位；
再用一个机器字的各个 bit 表示一组 0/1 状态。
</code></pre>
<p>也就是说，一个 <code>uint64_t</code> 可以看成 64 个布尔位：</p>
<ul>
<li>bit = 1：元素在集合中；</li>
<li>bit = 0：元素不在集合中。</li>
</ul>
<p>于是集合运算变成位运算：</p>
<pre><code>A | B   // 并集
A &amp; B   // 交集
A ^ B   // 对称差
</code></pre>
<p>这本质上是：</p>
<pre><code>一次整数指令
同时处理 64 个 0/1 状态
</code></pre>
<p>这叫 subword parallelism，也常被理解为 SWAR（SIMD Within A Register）风格的技巧。它和真正硬件 SIMD 的关系是：</p>
<ul>
<li>bitset 先利用机器字级并行；</li>
<li>编译器/库实现还可能进一步把多个机器字一起向量化；</li>
<li>但 bitset 本身不等于 SIMD 指令集。</li>
</ul>
<p>因此，“bitset 的底层是不是 SIMD”这个问题，更精确的回答是：</p>
<pre><code>bitset 的本质是按机器字压位并做位并行；
SIMD 是它可能进一步吃到的一层硬件加速。
</code></pre>
<p>也正因为如此，bitset 在算法题里经常非常快，即使编译器根本没有显式生成 AVX 指令：光是把 64 个布尔状态压进一个 word、减少内存流量和循环控制，就已经赚到了。</p>
<h2>Popcount 的分层并行累加模型</h2>
<p>经典 popcount 写法：</p>
<pre><code>x = (x &amp; 0x55555555) + ((x &gt;&gt; 1) &amp; 0x55555555);
x = (x &amp; 0x33333333) + ((x &gt;&gt; 2) &amp; 0x33333333);
x = (x + (x &gt;&gt; 4)) &amp; 0x0f0f0f0f;
</code></pre>
<p>乍看像在做一堆位运算，实际上它在同一个整数里分层合并多个子字段的部分和。这个思路和 SIMD 的一致性很强：</p>
<pre><code>把大寄存器切成很多小槽；
每一轮让这些槽同步做同构运算；
最后汇总结果。
</code></pre>
<p>因此讲义把 bitset、popcount、Bloom filter 放在一脉里非常准确：它们都是“在一个字里做很多份同构计算”的技术，只是历史上软件位技巧先出现，后来硬件把这种模式做成了专门的向量 ISA。</p>
<h2>SIMD 的程序执行模型</h2>
<p>理解 SIMD 时要抓住一个不变量：</p>
<pre><code>SIMD 仍然是 CPU 模型。
</code></pre>
<p>也就是说：</p>
<ul>
<li>仍然是一个线程的控制流；</li>
<li>仍然是一个 PC；</li>
<li>仍然共享同一份地址空间、cache 和同步原语；</li>
<li>只是某些寄存器和 ALU 被加宽了。</li>
</ul>
<p>所以 SIMD 的程序员心智模型更接近：</p>
<pre><code>把“for 循环的一部分迭代”
压缩进一条向量指令
</code></pre>
<p>这和 GPU 的线程模型很不一样。GPU 不是“一个线程里有很多 lane”，而是“很多线程共享一份控制流硬件”。</p>
<h2>GPU 的历史动机与通用化转折</h2>
<p>GPU 的历史背景确实来自图形学，但这里不需要记太多渲染细节，只要抓住因果链即可：</p>
<pre><code>画面越来越复杂
-&gt; 固定图形流水线吞吐不够
-&gt; 厂商开始堆大量面向图形任务的专用并行硬件
-&gt; shader 让这套硬件变得可编程
-&gt; 最终走向 GPGPU 和 CUDA
</code></pre>
<p>早期游戏机的很多图形技巧依赖固定 tile、sprite 和特定硬件寄存器；后来的 3D 图形需求迫使 GPU 从“固定功能单元”走向“可编程 shader”。这一步的意义在于：</p>
<pre><code>GPU 不再只是会画图的专用芯片，
而是变成会并行执行用户代码的大规模吞吐机器。
</code></pre>
<p>图形学部分在本讲里更像历史动机，而不是后面 CUDA 模型的核心难点，因此知道“shader 是 GPU 通用化的拐点”就够了。</p>
<h2>CPU 与 GPU 的设计目标差异</h2>
<p>CPU 的目标更偏向：</p>
<pre><code>尽量加速单条复杂控制流
</code></pre>
<p>因此 CPU 核心里堆了很多昂贵能力：</p>
<ul>
<li>复杂分支预测；</li>
<li>乱序执行；</li>
<li>大 cache；</li>
<li>寄存器重命名；</li>
<li>投机执行；</li>
<li>低延迟单线程优化。</li>
</ul>
<p>GPU 的目标更偏向：</p>
<pre><code>在单位面积和单位功耗下
把吞吐量做大到极致
</code></pre>
<p>因此 GPU 更愿意把晶体管预算花在：</p>
<ul>
<li>更多 ALU；</li>
<li>更多寄存器；</li>
<li>更多可驻留线程上下文；</li>
<li>更高带宽利用能力；</li>
<li>更大规模的同构并行。</li>
</ul>
<p>换句话说：</p>
<pre><code>CPU 强在“聪明地处理复杂控制流”；
GPU 强在“宽而便宜地推动大量统一控制流”。
</code></pre>
<h2>SIMT 的基本抽象</h2>
<p>GPU 的关键抽象不是 SIMD，而是 SIMT：Single Instruction, Multiple Threads。</p>
<p>它看起来和 SIMD 只差一个字母，但程序模型差别很大：</p>
<ul>
<li>SIMD：程序员在一个线程里显式打包多个数据；</li>
<li>SIMT：程序员写很多“普通线程”的标量代码，硬件把同时执行同一指令的线程绑成 warp。</li>
</ul>
<p>例如 CUDA kernel：</p>
<pre><code>__global__ void vec_add(float *a, float *b, float *c, int n) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i &lt; n) c[i] = a[i] + b[i];
}
</code></pre>
<p>从源码上看，每个线程只做一件很普通的事：算自己的 <code>i</code>，然后处理 <code>c[i]</code>。但在硬件里，很多线程会被按 warp 分组，同时执行同一条指令。其关键不变量是：</p>
<pre><code>warp 内线程各有自己的寄存器状态和数据，
但共享取指与控制流推进。
</code></pre>
<p>这就是“一个 PC 管多个线程”的含义。</p>
<h2>SIMT 的面积效率来源</h2>
<p>如果做 32 个完全独立的小 CPU 核，就意味着 32 套：</p>
<ul>
<li>fetch；</li>
<li>decode；</li>
<li>branch prediction；</li>
<li>issue；</li>
<li>control logic。</li>
</ul>
<p>这部分控制逻辑很贵，占 die area 很大。SIMT 的思路是：</p>
<pre><code>让很多线程共享同一套控制逻辑；
每个线程主要保留自己的寄存器上下文和运算通路。
</code></pre>
<p>于是相同芯片面积可以容纳远多于 CPU 的算术吞吐能力。这也是为什么 GPU 的“核心数”能远高于 CPU，但这些核心不能按 CPU 的方式理解：它们不是很多个全功能乱序通用核，而是大量受共享控制流组织的吞吐单元。</p>
<h2>SIMT 与 SIMD 的程序模型差异</h2>
<p>两者都共享一条指令流，但“共享对象”不同。</p>
<p>SIMD：</p>
<pre><code>一个线程
一个 PC
多个 lane
程序员显式管理打包/拆包
</code></pre>
<p>SIMT：</p>
<pre><code>多个线程
一个 warp 级控制流
每个线程像写普通标量程序
硬件负责把线程捆成并行执行组
</code></pre>
<p>所以如果用一句话区分：</p>
<pre><code>SIMD 是“一个线程里的数据并行”；
SIMT 是“很多线程上的统一控制流并行”。
</code></pre>
<p>这也是为什么 CUDA 程序表面看起来像“写了一百万个普通线程”，而不是显式写 <code>_mm256_add_ps</code> 这类向量 intrinsics。</p>
<h2>Warp Divergence 的形成机制与性能后果</h2>
<p>这一点和我们刚才的提问直接相关。GPU 更怕 divergence，不是因为“GPU 没有分支能力”，而是因为它的吞吐模型默认：</p>
<pre><code>同一 warp 内线程尽量走相同控制流。
</code></pre>
<p>例如：</p>
<pre><code>if (tid % 2 == 0) A();
else B();
</code></pre>
<p>如果同一个 warp 里一半线程走 <code>A</code>，另一半线程走 `B``，硬件通常不能像 CPU 那样为每个线程都独立维护一套高性能分支预测与乱序引擎。它更常见的做法是：</p>
<pre><code>先执行 A 路径，屏蔽不用跑 A 的线程；
再执行 B 路径，屏蔽不用跑 B 的线程；
最后重新汇合。
</code></pre>
<p>这就是 warp divergence。它的性能问题不在于算错，而在于：</p>
<pre><code>本来一个 warp 的很多线程可以一起推进；
分支分家后，部分线程会在某个路径上空等；
统一控制流的吞吐优势被浪费。
</code></pre>
<p>因此 GPU 与 CPU 的恐惧对象并不相同：</p>
<ul>
<li>CPU 更怕 branch misprediction；</li>
<li>GPU 更怕 warp 内控制流不一致。</li>
</ul>
<p>这是因为 CPU 的设计哲学是“猜对分支、继续冲”，GPU 的设计哲学是“大家最好别分家，一分家就分阶段串着跑”。</p>
<h2>GPU 控制流开销的精确表述</h2>
<p>把它说成“GPU 控制电路少，所以怕 divergence”方向没错，但更精确的版本应该是：</p>
<pre><code>GPU 把更多硬件预算投到大规模并行数据通路和线程驻留能力上，
而不是为每个执行流都配一套昂贵的复杂控制逻辑；
因此它要求同一 warp 内线程尽量共享控制流，
一旦控制流分化，就会显著损失利用率。
</code></pre>
<p>这比简单说“GPU 核更弱”更准确。GPU 的强弱不是抽象意义上的“弱”，而是：</p>
<pre><code>它不为复杂、不规则、强分支的单线程工作负载做豪华配置，
而是为规则、大规模、同构的吞吐任务做极致优化。
</code></pre>
<h2>Memory Coalescing 与访存规则性</h2>
<p>除了 divergence，GPU 还特别在意同一 warp 的访存是否规则。理想情况是：</p>
<pre><code>thread 0 访问 addr + 0
thread 1 访问 addr + 4
thread 2 访问 addr + 8
...
</code></pre>
<p>这种相邻线程访问相邻地址的模式容易被合并成较少的内存事务，这就是 memory coalescing。它和 SIMD/bitset 那种“把数据排整齐再一起处理”的思想完全同构：</p>
<pre><code>规则布局
-&gt; 更少访存事务
-&gt; 更高带宽利用率
</code></pre>
<p>因此 GPU 编程里经常要同时照顾两件事：</p>
<ul>
<li>warp 内线程尽量走同样分支；</li>
<li>warp 内线程尽量访问连续地址。</li>
</ul>
<p>前者避免控制流利用率塌陷，后者避免带宽利用率塌陷。</p>
<h2>CUDA 的程序抽象与硬件映射</h2>
<p>CUDA 最成功的地方，在于它没有强迫程序员直接操作向量寄存器，而是暴露：</p>
<ul>
<li>grid；</li>
<li>block；</li>
<li>thread；</li>
<li>shared memory；</li>
<li>barrier；</li>
<li>kernel launch。</li>
</ul>
<p>这让程序员可以先用“线程索引 -&gt; 数据索引”的方式表达并行计算，再让硬件把这些线程编组成 warp 执行。因此 CUDA 程序设计常见的第一原则是：</p>
<pre><code>让相邻线程做相邻工作；
让 block 内协作局部数据；
让分支尽量按 warp 对齐。
</code></pre>
<p>这和第 18 讲的“把共享热点移出高频路径，把同步边压到计算图边界”其实是同一个思路在硬件层面的映射：共享状态少、局部性强、边界同步清晰，吞吐才高。</p>
<h2>从 ILP 到 SIMD 再到 SIMT 的并行层次</h2>
<p>把这几种并行机制放到一张图里看，会更容易把握：</p>
<pre><code>ILP:
  硬件在单线程指令流里自动找局部独立性

SIMD:
  程序/编译器显式说明“这些数据可以做同构并行”

SIMT/GPU:
  程序显式暴露海量线程，硬件按 warp 组织统一控制流并行
</code></pre>
<p>因此它们不是替代关系，而是层次关系：</p>
<ul>
<li>ILP 负责榨干单线程局部独立性；</li>
<li>SIMD 负责榨干规则的数据级并行；</li>
<li>GPU/SIMT 负责把规则的大规模任务映射到极端吞吐硬件上。</li>
</ul>
<p>同一个应用完全可能同时使用这三层能力。</p>
<h2>与课程前序内容的联系</h2>
<p>这一讲和前面课程并不是割裂的。</p>
<p>与第 13-17 讲的同步问题相连：</p>
<pre><code>sum++ 为什么不对？
因为“顺序执行”从来不该被当成物理事实。
</code></pre>
<p>与第 18 讲的并行算法相连：</p>
<pre><code>计算图切得再好，
最终仍然要落到具体硬件如何执行节点。
SIMD 适合规则密集向量计算，
GPU 适合海量独立但同构的任务。
</code></pre>
<p>与第 19 讲协程相连：</p>
<pre><code>协程是在软件中复用执行流和调度点；
warp 是在硬件中复用控制流。
</code></pre>
<p>它们当然不是同一层抽象，但都体现了一个共同主题：</p>
<pre><code>不要把“一个执行流 = 一整套昂贵控制状态”当成唯一实现方式。
</code></pre>
<h2>面向 AI 基础设施的迁移理解</h2>
<p>这讲对 AI infrastructure 特别重要，因为今天的大模型训练与推理，本质上就是在吃这些抽象的红利。</p>
<p>矩阵乘、卷积、attention、layernorm、softmax 等 kernel，几乎都要求：</p>
<ul>
<li>大量同构计算；</li>
<li>规则的数据布局；</li>
<li>高带宽利用；</li>
<li>尽量少的分支分化；</li>
<li>尽量强的局部性和 tile 复用。</li>
</ul>
<p>所以 GPU 优化和第 18 讲并行算法的桥梁非常直接：</p>
<pre><code>tile = 更大的本地计算块
shared memory = 局部复用缓存
warp-coherent control flow = 更少分支损失
coalesced memory = 更少通信事务
</code></pre>
<p>如果把视野再放大一点，batching、kernel fusion、operator scheduling、tensor layout、sharding，本质上都在回答同一个问题：</p>
<pre><code>怎样把工作重排成硬件喜欢的统一、规则、局部的并行形式？
</code></pre>
<h2>分析与优化检查清单</h2>
<p>遇到 SIMD / GPU 类优化时，可以按下面顺序问自己：</p>
<pre><code>1. 这段计算是控制流密集，还是数据并行密集？
2. 依赖关系限制在单线程内部，还是很多独立元素之间？
3. 数据布局是否连续、规则、易于打包？
4. 这件事更适合 ILP、SIMD，还是 GPU/SIMT？
5. warp / lane 内是否会出现严重分支分化？
6. 访存是否连续，能否 coalescing / vector load？
7. 优化后的收益来自减少控制成本，还是减少访存成本，还是两者都有？
8. 是否只是算得更宽了，但实际上被 memory-bound 限住？
</code></pre>
<p>真正危险的误区，是把“并行”理解成抽象口号，而不问：</p>
<pre><code>谁在共享控制流？
谁在承担分支成本？
谁在承担访存成本？
</code></pre>
<h2>本节小结</h2>
<pre><code>1. CPU 的顺序执行只是 ISA 层面的语义抽象，现代处理器内部长期依赖 ILP 并行推进多条指令。
2. ILP 是硬件自动发掘的隐式并行，但受限于局部依赖窗口，不能替代显式的数据并行模型。
3. SIMD 的本质是共享一套控制流成本，驱动多个 lane 对独立数据执行同构操作。
4. 一条 SIMD 指令并非“零代价”，但其前端成本按指令条数摊薄，因此不会随元素个数线性增长。
5. bitset、popcount、Bloom filter 等本质上是 subword parallelism；bitset 先是按机器字压位，并不天然等于 SIMD。
6. GPU 的起点来自图形学吞吐危机，而 shader 让 GPU 从固定功能图形硬件演化为可编程并行平台。
7. GPU 的设计目标是极致吞吐，不是豪华单线程控制流；它把更多预算投向算力和线程驻留，而不是复杂控制逻辑。
8. SIMT 与 SIMD 的区别在于：SIMD 是一个线程里的多 lane，SIMT 是很多线程共享统一控制流硬件。
9. warp divergence 的本质不是“不会算”，而是 warp 内控制流分化导致统一执行宽度被浪费。
10. GPU 除了怕 divergence，也怕不规则访存；memory coalescing 和统一控制流一样，都是吞吐成立的必要条件。
11. 从 ILP 到 SIMD 再到 GPU，是并行责任逐步从硬件隐式挖掘转向程序显式暴露的过程。
12. 对 AI infra 而言，tile、layout、batching、fusion、sharding 都是在把问题重排成硬件喜欢的规则并行形式。
</code></pre>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
  <entry>
    <title>3.进程的地址空间</title>
    <link href="https://katyusha-blog.com/posts/nju-os/os/virtualization/address/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/os/virtualization/address/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-03T00:00:00.000Z</updated>
    <summary>3.进程的地址空间</summary>
    <content type="html"><![CDATA[<h2>虚拟地址空间</h2>
<ul>
<li>
<p>地址空间的作用不是“真的给进程一大片 RAM”，而是给它一个连续、受保护、按页管理的虚拟地址视图。</p>
</li>
<li>
<p>进程的最小可执行核心是：寄存器现场 + 虚拟地址空间。</p>
</li>
<li>
<p>MMU 根据页表把虚拟地址翻译成物理地址，并检查读/写/执行权限；非法访问通常会触发 page fault，最后可能变成 <code>SIGSEGV</code>。</p>
</li>
<li>
<p><code>text</code>：机器指令，通常只读、可执行。</p>
</li>
<li>
<p><code>rodata</code>：字符串字面量、只读全局常量。</p>
</li>
<li>
<p><code>data + bss</code>：已初始化/未初始化的全局变量、静态变量。</p>
</li>
<li>
<p><code>heap</code>：动态分配区域；用户态常见接口是 <code>malloc/new</code>，底层常通过 <code>brk/sbrk</code> 或 <code>mmap</code> 扩展。</p>
</li>
<li>
<p><code>mmap</code> 区域：文件映射、匿名映射、共享库、JIT 代码、线程栈、<code>vvar/vdso</code> 等，现代进程里这块很重要。</p>
</li>
<li>
<p>用户栈 <code>stack</code>：函数调用帧、局部变量、返回地址、保存的寄存器。</p>
</li>
</ul>
<p>注意：</p>
<ul>
<li><code>PC/RIP</code>、通用寄存器不在栈里，也不在地址空间里；它们属于寄存器现场。</li>
<li>一个 Unix 进程的完整状态还包括文件描述符、信号状态、调度信息、凭据、cwd、环境变量等；这些由内核维护，不是普通 load/store 能直接看到的内存。</li>
</ul>
<p><code>fork</code> 之后，子进程一开始看见的是父进程的整份地址空间；实现上常靠 Copy-on-Write，先共享物理页，写时再复制。</p>
<h2>mmap munmap mprotect</h2>
<ul>
<li><code>mmap</code> 的本质不是“读文件 API”，而是“建立一段虚拟地址区间与某个后端对象之间的映射关系”。</li>
<li>这个后端对象可以是文件，也可以是匿名内存；映射还会规定权限、共享/私有语义等。</li>
<li>因而 <code>mmap</code> 更像“把文件当内存”，而 <code>fprintf/fscanf</code> 更像“把文件当流”。</li>
<li>两者不是包含关系：<code>fprintf/fscanf</code> 擅长文本格式化与解析，<code>mmap</code> 擅长随机访问、共享、按页懒加载。</li>
<li><code>munmap/mprotect</code> 是与之配套的常见接口，分别解除映射，修改权限。</li>
<li><code>mprotect</code> 可以修改一段映射的权限，例如把页改成只读、不可访问、可执行等。</li>
<li>某些调试/监视机制会利用这一点：先把目标页设成不可写，等程序写它时触发 page fault，再由调试器介入分析。</li>
</ul>
<p>现代 OS 依赖按页懒分配、缺页装入、共享零页、文件页缓存、swap、overcommit 等机制，所以“映射成功”和“能把每一页都写满”不是一回事。</p>
<h2><code>vvar</code> / <code>vdso</code></h2>
<ul>
<li><code>vdso</code> 是内核映射进用户地址空间的一小段用户态代码，用来加速某些原本常要陷入内核的操作，例如取时间。</li>
<li><code>vvar</code> 是与之配套的一小段只读数据页，供 <code>vdso</code> 代码读取时钟基准等信息。</li>
<li>可以粗略理解成：<code>vdso</code> 提供“代码”，<code>vvar</code> 提供“数据”；它们让一些操作在用户态就能完成，减少 syscall 开销。</li>
</ul>
<h2>入侵地址空间</h2>
<ul>
<li>game genie：外接设备，在游戏启动前扫描并修改变量（外挂）</li>
<li>葫芦侠修改器：每次变量发生变化，扫描地址空间，确定是哪一个变量，进而实现破解</li>
<li>透视：将人物的三角形放在障碍物的三角形上面</li>
<li>变速齿轮：劫持关于时间的系统调用</li>
<li>dma：将游戏内的数据向外拷贝</li>
</ul>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
  <entry>
    <title>4.访问操作系统对象；文件描述符</title>
    <link href="https://katyusha-blog.com/posts/nju-os/os/virtualization/file/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/os/virtualization/file/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-04T00:00:00.000Z</updated>
    <summary>4.访问操作系统对象；文件描述符</summary>
    <content type="html"><![CDATA[<h2>操作系统对象与 API</h2>
<ul>
<li>从设计者视角看，OS 需要提供一套简单、稳定、可组合的接口，让上层开发者在其上继续封装。</li>
<li>文件的抽象本质是：<strong>带名字的数据对象（字节序列）</strong>。</li>
<li>UNIX 的关键不是“所有东西真的都是磁盘文件”，而是“很多对象都可以用文件接口访问”。普通文件、目录、设备、<code>/proc/[pid]/...</code>、socket、pipe 都可以纳入这套统一接口。这让 shell、小工具、脚本、LLM agent 都更容易组合系统对象。<code>FHS</code>（Filesystem Hierarchy Standard）规定了目录树的大致职责，让软件和用户能预测文件该放在哪里。</li>
<li>Windows 的思路是专用 API：文件用 <code>CreateFile/ReadFile</code>，进程用 <code>OpenProcess/ReadProcessMemory</code>，表达力更细，但 API 更碎。</li>
</ul>
<h2>文件描述符 fd</h2>
<p>文件的打开状态存放在内核中，因为内核内存相对磁盘读写更快，并且有保护。为了避免使用指针直接访问内存可能出错，unix提供了一个整数fd，作为访问内核的接口</p>
<ul>
<li><code>fd</code>（file descriptor）是进程访问操作系统对象的整数句柄。</li>
<li>约定：<code>0</code> 是 <code>stdin</code>，<code>1</code> 是 <code>stdout</code>，<code>2</code> 是 <code>stderr</code>。</li>
<li><code>open()</code> 通常返回当前最小未使用的 <code>fd</code>；关闭后编号可以复用。</li>
<li><code>fd</code> 是进程级索引，不是系统级全局编号；每个进程有自己的fd table，这个table的每一项再指向内核中的一个文件打开状态。所以不同进程里的相同的fd可以指向完全不同的对象。</li>
<li>课上用一个简化模型理解“打开文件”：</li>
</ul>
<pre><code>struct FILE {
    char *data;
    size_t offset;
};
</code></pre>
<h2>fork与文件打开状态</h2>
<ul>
<li>
<p>Windows 的 <code>handle</code> 和 UNIX 的 <code>fd</code> 类似，都是用户态持有、由内核解释的对象引用。</p>
</li>
<li>
<p>但 <code>handle</code> 的含义更广，可以引用文件、进程、线程、事件、互斥锁等各种内核对象。</p>
</li>
<li>
<p>Windows 倾向于把访问权限直接绑定到 handle 上，并默认不在新进程中继承它们。</p>
</li>
<li>
<p>UNIX <code>fork()</code> 后，子进程复制父进程的 <code>fd table</code>，但表项通常仍指向同一个内核文件打开状态。</p>
</li>
<li>
<p>因而父子会共享同一个 <code>offset</code>：父进程读过之后，子进程再读会从更新后的偏移开始。</p>
</li>
<li>
<p>Windows 没有 <code>fork()</code>；<code>CreateProcess</code> 非显式指定的情况下不继承 handle，所以默认不会共享文件偏移。</p>
</li>
</ul>
<h2>Pipe</h2>
<ul>
<li><code>pipe</code> 的本质不是磁盘文件，而是<strong>被文件接口包装的内核字节流缓冲区</strong>。写端向缓冲区写入并唤醒读端，然后读端从缓冲区读出。过程遵循FIFO顺序。</li>
<li><code>pipe(pipefd)</code> 返回两个 <code>fd</code>：<code>pipefd[0]</code>：读端<code>pipefd[1]</code>：写端</li>
<li>匿名管道是“没有文件名、只靠进程手里的 <code>fd</code> 引用”的 pipe。</li>
<li><code>fork()</code> 之后父子都会继承读端和写端，于是它天然适合父子进程通信，只需要<code>fork</code> 后立刻关闭自己不用的那一端。</li>
<li>Shell 中的 <code>cmd1 | cmd2</code> 底层就是：<code>pipe + fork + dup2 + execve</code>。</li>
</ul>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
  <entry>
    <title>5.C 标准库原理</title>
    <link href="https://katyusha-blog.com/posts/nju-os/os/virtualization/libc/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/os/virtualization/libc/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-07T00:00:00.000Z</updated>
    <summary>5.C 标准库原理</summary>
    <content type="html"><![CDATA[<h2>libc 的定位</h2>
<ul>
<li><code>libc</code> 可以先粗略理解成 C 程序和操作系统之间的一层公共运行时。</li>
<li>但它不是“把几个 syscall 包一层”这么简单。很多看起来像普通库函数的东西，其实都卡在语言、ABI、机器模型和操作系统接口的交界处。</li>
<li>课程里讲 <code>libc</code>，核心不是背函数表，而是理解这层抽象到底替我们吃掉了哪些底层细节。</li>
</ul>
<h2>C 与二进制接口</h2>
<ul>
<li><code>SimpleC</code> 那套模型里，指针、数组、结构体、函数调用，本质上都能落回“寄存器和内存怎么变化”。</li>
<li>但真实世界里的 C 还要和外部二进制世界打交道：
<ul>
<li>可以链接汇编函数；</li>
<li>可以写 inline assembly；</li>
<li>可以直接站在 syscall 边界上。</li>
</ul>
</li>
</ul>
<pre><code>void _start() {
  __asm__("mov $60, %eax\n"
          "xor %edi, %edi\n"
          "syscall");
}
</code></pre>
<ul>
<li>这个例子已经说明，C 并不是只能老老实实等编译器把代码翻成汇编然后结束。</li>
<li>你完全可以绕开大部分运行时，自己从 <code>_start</code> 开始写，自己发 <code>syscall</code>。</li>
<li>也正因为可以这样做，<code>libc</code> 的作用才更清楚：它把这些本来要程序员自己处理的底层活，变成了一套统一接口。</li>
</ul>
<h2>ABI 与平台相关约束</h2>
<ul>
<li>
<p>有些头文件看起来很“静态”，其实一点也不简单，比如 <code>stddef.h</code>、<code>stdint.h</code>、<code>inttypes.h</code>、<code>limits.h</code>、<code>float.h</code>。</p>
</li>
<li>
<p>它们里面那些类型、常量、格式化宏，都和平台字长、整数表示、浮点格式、对齐规则、ABI 约定绑得很紧。</p>
</li>
<li>
<p>例如 <code>PRIdPTR</code>、<code>PRIuPTR</code> 这种东西，本质上就是在适配“这个平台上的指针整数到底该怎么打印”。</p>
</li>
<li>
<p><code>stdarg.h</code> 更典型。<code>printf</code> 这种变参函数要正确工作，前提是运行时知道参数是怎么传进来的。</p>
</li>
<li>
<p>现代 ABI 下，参数未必老老实实全在栈上，往往是一部分进寄存器，一部分进栈；整数参数、浮点参数、向量参数的规则也可能不同。</p>
</li>
<li>
<p>所以 <code>va_list</code> 不是一个脱离机器存在的抽象，它背后直接连着 ABI。</p>
</li>
</ul>
<h2>标准库中的通用计算组件</h2>
<ul>
<li>
<p><code>string.h</code> 里的 <code>memcpy</code>、<code>memmove</code>、<code>strcpy</code></p>
</li>
<li>
<p><code>stdlib.h</code> 里的 <code>atoi</code>、<code>qsort</code>、<code>rand</code></p>
</li>
<li>
<p><code>math.h</code> 里的各种数学和浮点相关函数</p>
</li>
<li>
<p>这些函数表面上像“自己也能写个差不多的版本”，但真要做到标准要求那样可移植、正确、性能不差，就没有那么随手了。</p>
</li>
<li>
<p>例如：</p>
<ul>
<li><code>memcpy</code> 和 <code>memmove</code> 的重叠语义不同；</li>
<li><code>qsort</code> 这种接口其实已经把“对象表示 + 回调 + 比较规则”全揉在一起了；</li>
<li>浮点函数还会碰到 NaN、舍入和异常值。</li>
</ul>
</li>
<li>
<p>所以这部分不是给 syscall 起别名，而是在做真正的库实现。</p>
</li>
</ul>
<h2>stdio 与系统调用封装</h2>
<ul>
<li>
<p>最容易看到的是 <code>stdio</code>。</p>
</li>
<li>
<p><code>FILE *</code> 背后通常连着一个文件描述符，但它又不等于文件描述符。</p>
</li>
<li>
<p><code>stdio</code> 在 <code>fd</code> 之上又维护了一层自己的状态，比如：</p>
<ul>
<li>缓冲区；</li>
<li>当前位置；</li>
<li>EOF 和 error 标志；</li>
<li>锁；</li>
<li>格式化输出逻辑。</li>
</ul>
</li>
<li>
<p>这就是为什么你写的是 <code>printf</code>，<code>strace</code> 里看到的却是 <code>write</code>。</p>
</li>
<li>
<p><code>printf</code> 先在用户态解析格式串、处理 <code>va_list</code>、往缓冲区里填数据，最后才在合适的时候发 <code>write</code>。</p>
</li>
<li>
<p><code>fseek</code>、<code>ftell</code>、<code>feof</code>、<code>vfprintf</code> 这一类接口，也都是这层抽象的一部分。</p>
</li>
<li>
<p>所以这里比较自然的理解方式是：</p>
</li>
</ul>
<pre><code>syscall 提供最原始的机制
libc 在上面组织出更适合应用编程的对象和接口
</code></pre>
<h2>进程控制与运行环境</h2>
<ul>
<li>
<p><code>abort</code> 不只是“退出”，而是给自己发 <code>SIGABRT</code>，通常还要让 core dump 机制接得上。</p>
</li>
<li>
<p><code>exit</code> 也不只是 <code>_exit</code>。正常 <code>exit</code> 之前，<code>libc</code> 还要做不少收尾工作，比如 flush <code>stdio</code> buffer、调用 <code>atexit</code> handler。</p>
</li>
<li>
<p><code>system</code>、<code>popen</code>、<code>pclose</code> 则是在 <code>fork/exec/pipe/wait</code> 这些机制上再包一层更高的接口。</p>
</li>
<li>
<p>环境变量这块也一样。</p>
</li>
<li>
<p>内核在进程刚开始运行时，只是把 <code>argc/argv/envp/auxv</code> 这些原始数据按 ABI 约定放到初始栈里。</p>
</li>
<li>
<p>但 C 程序里看到的 <code>environ</code> 是一个全局符号，它不是内核直接替你维护好的现成变量。</p>
</li>
<li>
<p>这个整理过程还是 runtime/libc 来做。</p>
</li>
</ul>
<h2>C runtime 与程序入口</h2>
<ul>
<li>这一点前面其实已经见过，但放到 <code>libc</code> 这里会更完整。</li>
<li><code>execve</code> 之后，内核大致做的是：</li>
</ul>
<pre><code>加载 ELF
准备初始用户栈：argc / argv / envp / auxv
把 RIP 设到入口点，一般是 _start
开始执行用户态第一条指令
</code></pre>
<ul>
<li>所以后面真正先跑起来的是 <code>_start</code>，不是 <code>main</code>。</li>
<li><code>_start</code> 再去完成最基本的运行时初始化，然后把控制权交给 <code>__libc_start_main</code>，最后才轮到 <code>main</code>。</li>
<li>这也是为什么链接时你会看到 <code>crt1.o</code>、<code>crtbegin.o</code>、<code>crtend.o</code>、<code>crtn.o</code> 这些对象文件。它们都属于 C runtime 这一层。</li>
</ul>
<p>可以把这条链记成：</p>
<pre><code>execve
  -&gt; _start
  -&gt; runtime 初始化
  -&gt; __libc_start_main
  -&gt; main
  -&gt; exit
</code></pre>
<ul>
<li><code>main</code> 只是这条链中间的一个普通函数，不是进程天然的起点。</li>
</ul>
<h2>libc 与 ABI 的联系</h2>
<ul>
<li>
<p>因为很多 <code>libc</code> 功能，表面是“库函数”，底层其实离机器非常近。</p>
</li>
<li>
<p>比如：</p>
<ul>
<li><code>printf</code> 要按 ABI 读变参；</li>
<li><code>_start</code> 要按 ABI 理解初始栈布局；</li>
<li><code>setjmp/longjmp</code> 要保存和恢复寄存器现场；</li>
<li><code>environ</code> 的建立要依赖进程启动时那套约定。</li>
</ul>
</li>
<li>
<p>所以 <code>libc</code> 这一层有点像一条分界线：</p>
<ul>
<li>往下，是机器、ABI、syscall、进程启动细节；</li>
<li>往上，是 C 程序、C++ 标准库、各种更高层运行时。</li>
</ul>
</li>
</ul>
<h2>本节要点</h2>
<ul>
<li>
<p><code>libc</code> 不是几个 Linux 接口的说明书，而是一层可移植运行时。</p>
</li>
<li>
<p>它把很多平台相关、ABI 相关、启动相关的脏活都包起来了。</p>
</li>
<li>
<p>没有这层东西，应用程序就得自己处理：</p>
<ul>
<li>参数怎么从 ABI 边界进来；</li>
<li>初始栈怎么解释；</li>
<li><code>printf</code> 怎么格式化；</li>
<li><code>exit</code> 之前怎么收尾；</li>
<li>堆分配器怎么维护状态。</li>
</ul>
</li>
<li>
<p>从操作系统视角看，理解 <code>libc</code>，本质上就是理解“一个 C 程序到底是怎么真正跑起来的”。</p>
</li>
</ul>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
  <entry>
    <title>6.调试 C 标准库</title>
    <link href="https://katyusha-blog.com/posts/nju-os/os/virtualization/libc-debug/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/os/virtualization/libc-debug/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-09T00:00:00.000Z</updated>
    <summary>6.调试 C 标准库</summary>
    <content type="html"><![CDATA[<h2>libc 调试的状态重构框架</h2>
<ul>
<li>这节课的重点不是“会不会用 gdb 点几下”，而是理解一件事：
调试器为什么能从一堆 <code>PC/寄存器/内存</code> 里，还原出“现在停在第几行、当前栈帧是什么、变量值是多少”。</li>
<li>对 <code>libc</code> 做这件事尤其有价值，因为它正好卡在 <code>C 语言语义 / ABI / 汇编 / 系统调用 / 进程启动</code> 的交界处。</li>
<li>这页笔记可以按下面这条链理解：</li>
</ul>
<pre><code>debug info
  -&gt; gdb 能重构源码级状态
  -&gt; 可以单步看 libc 的真实实现
  -&gt; 进而看懂 printf / va_list / setjmp / vDSO / malloc
</code></pre>
<h2>Debug Info 的语义映射作用</h2>
<ul>
<li>
<p>二进制文件里不只有指令和数据，还可以有额外的调试信息。</p>
</li>
<li>
<p>程序真正运行时的低级状态是：</p>
<ul>
<li><code>PC/RIP</code></li>
<li>通用寄存器、浮点/向量寄存器</li>
<li>用户态地址空间里的内存</li>
</ul>
</li>
<li>
<p>但人类和调试器想看到的是高级语义状态：</p>
<ul>
<li>当前源码文件和行号</li>
<li>当前调用栈</li>
<li>某个局部变量、参数、结构体字段的值</li>
</ul>
</li>
<li>
<p><code>DWARF</code> 的作用就是提供一套“解释规则”，把低级状态翻译成高级状态。</p>
</li>
<li>
<p>它不是把“运行状态本身”存进二进制，而是记录：</p>
<ul>
<li>某个机器码地址对应哪一行源码</li>
<li>在某个 <code>PC</code> 范围内，某个变量位于哪里</li>
<li>如何从当前帧恢复调用者帧</li>
</ul>
</li>
</ul>
<h3>DWARF 的核心信息类别</h3>
<ul>
<li><code>.debug_line</code>
<ul>
<li>机器码地址到源码行号的映射。</li>
<li><code>next</code>、断点停在某行、本质都是在做地址映射。</li>
</ul>
</li>
<li><code>.debug_frame</code> 或同类 unwind 信息
<ul>
<li>描述当前帧的栈展开规则。</li>
<li><code>backtrace</code>、异常展开、profiling 都依赖它。</li>
</ul>
</li>
<li><code>.debug_info</code>
<ul>
<li>变量名、类型、作用域、结构体布局等语义信息。</li>
</ul>
</li>
<li><code>.debug_loclists</code>
<ul>
<li>描述“某个变量在某段 <code>PC</code> 范围内到底在哪里”。</li>
<li>它可能在寄存器里，也可能在 <code>CFA - 24</code> 这样的栈槽里，还可能位置随着 <code>PC</code> 变化。</li>
</ul>
</li>
</ul>
<h2>调试器的程序状态重构机制</h2>
<ul>
<li>核心模型：</li>
</ul>
<pre><code>真实状态 = PC + 寄存器 + 内存
debug info = 如何解释这份状态的规则
gdb = 按规则执行解释
</code></pre>
<h3>1. <code>PC</code> 到源码位置的映射</h3>
<ul>
<li>
<p>调试器先拿当前 <code>PC</code>，去查 <code>.debug_line</code>。</p>
</li>
<li>
<p>得到：</p>
<ul>
<li>当前文件</li>
<li>当前行号</li>
<li>有时还能知道对应的列号、inlined call site</li>
</ul>
</li>
<li>
<p>所以源码行本质上不是 CPU 的概念，而是 debug info 映射出来的概念。</p>
</li>
</ul>
<h3>2. 当前帧到调用栈的展开</h3>
<ul>
<li>
<p>CPU 不会天然保存“调用栈字符串”。</p>
</li>
<li>
<p>它只有：</p>
<ul>
<li>当前 <code>PC</code></li>
<li>当前 <code>SP</code></li>
<li>若干寄存器</li>
<li>栈内存里的返回地址和保存寄存器</li>
</ul>
</li>
<li>
<p><code>.debug_frame</code> 之类的 unwind 信息会告诉调试器：</p>
<ul>
<li>当前帧的 Canonical Frame Address 在哪</li>
<li>返回地址保存在什么位置</li>
<li>哪些寄存器被保存到了栈上</li>
</ul>
</li>
<li>
<p>调试器据此恢复调用者的 <code>PC/SP/寄存器</code>，重复这个过程，就得到 <code>backtrace</code>。</p>
</li>
</ul>
<h3>3. 变量位置到变量值的恢复</h3>
<ul>
<li>
<p>这是最容易被误解的一步。</p>
</li>
<li>
<p>变量 <code>x</code> 并不是天然“就在某个固定地址”。</p>
</li>
<li>
<p>尤其优化后，<code>x</code> 可能：</p>
<ul>
<li>在寄存器里</li>
<li>一部分在寄存器、一部分在栈上</li>
<li>被常量传播掉</li>
<li>生命周期已经结束，根本没有可恢复的位置</li>
</ul>
</li>
<li>
<p>所以调试器不是“扫内存找变量名”，而是：</p>
<ul>
<li>查当前 <code>PC</code> 对应的 loclist</li>
<li>知道 <code>x</code> 现在应从哪个寄存器或哪个栈槽取</li>
<li>再结合类型信息解释这串比特</li>
</ul>
</li>
</ul>
<h3>4. 优化对语义可恢复性的破坏</h3>
<ul>
<li>
<p>因为“源码变量”和“机器状态”之间的一一对应关系被编译器破坏了。</p>
</li>
<li>
<p>典型现象：</p>
<ul>
<li>局部变量显示 <code>&lt;optimized out&gt;</code></li>
<li>单步时行号跳来跳去</li>
<li>一个源码语句对应多个离散机器码位置</li>
<li>调用栈里出现 inlining 造成的“逻辑帧”</li>
</ul>
</li>
<li>
<p>所以调试 <code>libc</code> 或别的系统代码时，<code>-Og -ggdb</code> 往往比 <code>-O2 -g</code> 更友好。</p>
</li>
</ul>
<h2>libc 可调试性的前提</h2>
<ul>
<li>
<p><code>libc</code> 并不是黑盒魔法。</p>
</li>
<li>
<p>只要你手里的二进制和库带着足够的 debug info，<code>gdb</code> 就能一路跟进去。</p>
</li>
<li>
<p>这也是课上强调自己编译一份 <code>musl libc</code> 的原因：</p>
<ul>
<li>符号完整</li>
<li>行号完整</li>
<li>更适合单步</li>
</ul>
</li>
<li>
<p>一旦能单步进 <code>libc</code>，很多原本抽象的接口就都能落回“寄存器和内存怎么变”。</p>
</li>
</ul>
<h2><code>printf</code> 的用户态实现链条</h2>
<ul>
<li>
<p><code>printf</code> 最值得看的不是“会不会打印字符串”，而是它说明了：
用户态库函数可以在完全不进入内核的情况下，先完成大量工作。</p>
</li>
<li>
<p>典型链条是：</p>
</li>
</ul>
<pre><code>printf
  -&gt; vfprintf
  -&gt; 解析 format string
  -&gt; 按 ABI 读取 va_list
  -&gt; 写入 stdio buffer / FILE 结构
  -&gt; 必要时发出 write
</code></pre>
<ul>
<li>所以 <code>strace</code> 里看到的是 <code>write</code>，但你代码里写的是 <code>printf</code>。</li>
<li>两者中间隔着整套用户态实现：
<ul>
<li><code>FILE</code> 对象</li>
<li>缓冲区</li>
<li>EOF / error 标志</li>
<li>格式化逻辑</li>
<li>锁和 flush 策略</li>
</ul>
</li>
</ul>
<h2><code>va_list</code> 的 ABI 依赖性</h2>
<ul>
<li>早期 <code>cdecl</code> 时代，很多人会写出这种 hack：</li>
</ul>
<pre><code>void foo(int n, ...) {
  intptr_t *vargs = (intptr_t *)&amp;n;
}
</code></pre>
<ul>
<li>这依赖一个隐含前提：所有后续参数都连续压在栈上。</li>
<li>现代 ABI 下这个前提通常不成立。</li>
</ul>
<h3>现代 ABI 下的参数分布</h3>
<ul>
<li>
<p>以常见的 <code>x86-64 SysV ABI</code> 为例：</p>
<ul>
<li>前几个整数/指针参数进 <code>rdi/rsi/rdx/rcx/r8/r9</code></li>
<li>前几个浮点参数进 <code>xmm0-xmm7</code></li>
<li>超出的部分才进栈</li>
</ul>
</li>
<li>
<p>所以 <code>va_list</code> 的正确理解不是：</p>
<ul>
<li>“把所有寄存器参数都挪到栈上”</li>
</ul>
</li>
<li>
<p>而是：</p>
<ul>
<li>它提供一种遍历后续实参的机制</li>
<li>编译器/ABI 会保证 <code>va_start</code> 和 <code>va_arg</code> 能按规则取到这些参数</li>
</ul>
</li>
</ul>
<h3><code>va_list</code> 的遍历状态模型</h3>
<ul>
<li>
<p>一个 <code>va_list</code> 背后通常要能描述：</p>
<ul>
<li>还没消费到哪个通用寄存器参数</li>
<li>还没消费到哪个浮点寄存器参数</li>
<li>栈上传参从哪里开始</li>
<li>必要时，寄存器保存区在哪里</li>
</ul>
</li>
<li>
<p>所以这里真正重要的不是语法，而是：
<code>printf</code> 这种库函数已经深深依赖 ABI。</p>
</li>
</ul>
<h2><code>setjmp/longjmp</code> 的现场保存与恢复</h2>
<ul>
<li><code>setjmp/longjmp</code> 是观察 calling convention 的极佳样本。</li>
<li>它们做的不是“回滚整个程序状态”，而是恢复一个合法的继续执行现场。</li>
</ul>
<h3><code>setjmp</code> 的保存对象</h3>
<ul>
<li><code>setjmp(env)</code> 会把“以后还能回到这里继续执行”所需的寄存器现场保存到 <code>env</code>。</li>
<li>至少包括：
<ul>
<li>栈指针</li>
<li>返回位置 / 程序计数器相关信息</li>
<li>ABI 规定必须跨调用保持不变的 callee-saved 寄存器</li>
</ul>
</li>
</ul>
<h3><code>longjmp</code> 的恢复行为</h3>
<ul>
<li><code>longjmp(env, val)</code> 会把这些寄存器恢复出来。</li>
<li>然后伪造一次从 <code>setjmp</code> 返回：
<ul>
<li>第一次 <code>setjmp</code> 返回 <code>0</code></li>
<li><code>longjmp</code> 回来后，<code>setjmp</code> 像是“第二次返回”，值变成 <code>val</code>，若 <code>val == 0</code> 则返回 <code>1</code></li>
</ul>
</li>
</ul>
<h3>可恢复寄存器的 ABI 边界</h3>
<ul>
<li>
<p>依赖具体 ABI。</p>
</li>
<li>
<p>但一般规律是：</p>
<ul>
<li>会恢复“恢复控制流所必须的寄存器”</li>
<li>会恢复 callee-saved 寄存器</li>
<li>不保证恢复 caller-saved 寄存器</li>
</ul>
</li>
<li>
<p>所以一个很好的实验是：</p>
<ul>
<li><code>setjmp</code> 前给各寄存器染色</li>
<li><code>longjmp</code> 前再改一遍</li>
<li>看哪些值回来后被恢复</li>
</ul>
</li>
<li>
<p>你会看到：</p>
<ul>
<li>恢复了的，通常就是 callee-saved</li>
<li>没恢复的，通常就是 caller-saved / call-clobbered</li>
</ul>
</li>
</ul>
<h3>非恢复状态的范围</h3>
<ul>
<li>
<p>它不会回滚整个内存世界。</p>
</li>
<li>
<p>所以：</p>
<ul>
<li>全局变量不会恢复旧值</li>
<li><code>static</code> 变量不会恢复旧值</li>
<li>堆内存不会恢复旧值</li>
<li>文件偏移、fd 状态、锁状态也不会恢复</li>
</ul>
</li>
<li>
<p><code>longjmp</code> 恢复的是“控制流和部分寄存器现场”，不是“时间倒流”。</p>
</li>
</ul>
<h3>自动变量不可靠性的来源</h3>
<ul>
<li>
<p>一个反直觉点是：</p>
<ul>
<li>全局变量在 <code>longjmp</code> 后通常保留修改后的值</li>
<li>非 <code>volatile</code> 自动变量若在 <code>setjmp</code> 后被改动，则 <code>longjmp</code> 后值是未指定的</li>
</ul>
</li>
<li>
<p>原因不是它被“回滚”了，而是编译器可能把它：</p>
<ul>
<li>放在寄存器里</li>
<li>优化掉</li>
<li>重排成源码直觉以外的形式</li>
</ul>
</li>
</ul>
<h2><code>gettimeofday</code> 的 vDSO 快路径</h2>
<ul>
<li>
<p>这部分用来回答一个常见问题：
<code>gettimeofday</code> 到底有没有系统调用？</p>
</li>
<li>
<p>现代 Linux 上，经常不会真的触发一次 trap 进入内核。</p>
</li>
<li>
<p>常见路径是：</p>
</li>
</ul>
<pre><code>libc
  -&gt; 查找 vDSO 中导出的入口
  -&gt; 调用 vDSO 里的用户态代码
  -&gt; 读取内核事先映射给进程的只读时间相关数据
</code></pre>
<ul>
<li>
<p>所以：</p>
<ul>
<li>接口长得像普通 <code>libc</code> 函数</li>
<li>语义上像“向内核取时间”</li>
<li>实现上却可能完全在用户态完成</li>
</ul>
</li>
<li>
<p>这说明 <code>libc</code> 不只是 syscall wrapper。</p>
</li>
<li>
<p>它也会根据平台机制，选择更便宜的路径来实现同一个 API。</p>
</li>
</ul>
<h2><code>malloc</code> 的用户态堆管理本质</h2>
<ul>
<li><code>malloc/free</code> 表面上只有：</li>
</ul>
<pre><code>void *p = malloc(n);
free(p);
</code></pre>
<ul>
<li>
<p>但操作系统并不提供“分配 37 字节”这种系统调用。</p>
</li>
<li>
<p>内核一般提供的是：</p>
<ul>
<li><code>mmap</code></li>
<li>历史上的 <code>sbrk/brk</code></li>
</ul>
</li>
<li>
<p>所以 <code>malloc</code> 的本质是：</p>
<ul>
<li>先向 OS 申请较大的虚拟地址区间</li>
<li>再在用户态维护自己的堆内数据结构</li>
<li>支持 split / reuse / coalesce / metadata / alignment</li>
</ul>
</li>
</ul>
<h3><code>malloc</code> 的正确性与安全负担</h3>
<ul>
<li>
<p>它要求在任意控制流路径上：</p>
<ul>
<li>所有该释放的块最终都能释放</li>
<li>释放后不再访问</li>
<li>多线程时还不能竞态</li>
</ul>
</li>
<li>
<p>这就带来大量经典错误：</p>
<ul>
<li>leak</li>
<li>double free</li>
<li>use-after-free</li>
<li>concurrent use-after-free</li>
</ul>
</li>
<li>
<p>也正因为如此，后来才有：</p>
<ul>
<li>RAII</li>
<li>managed runtime</li>
<li>ownership / borrowing</li>
<li>region / arena / GC 等更高层策略</li>
</ul>
</li>
</ul>
<h2><code>malloc</code> Survey 的方法论启发</h2>
<ul>
<li>
<p>课上引用的那篇 survey 最重要的观点不是“哪个 free list 更厉害”，而是：
真正重要的是理解真实程序行为，而不是只盯着某个数据结构机制。</p>
</li>
<li>
<p>可以把它压成几句：</p>
<ul>
<li>研究长期过分关注 allocator mechanism，忽视 policy</li>
<li>碎片本质上和对象死亡时序、phase behavior、size/order 规律有关</li>
<li>真实程序不是随机 iid 请求流</li>
<li>allocator 的评估应该基于真实 trace，而不是过度依赖 synthetic random traces</li>
</ul>
</li>
<li>
<p>这也解释了为什么：</p>
<ul>
<li>你一开始可能会想到 balanced tree</li>
<li>但真正高质量的 allocator 研究，问题远不止“查找快不快”</li>
</ul>
</li>
<li>
<p>更重要的问题是：</p>
<ul>
<li>什么对象应该尽量放在一起</li>
<li>什么对象不该太早复用</li>
<li>什么时候 coalesce</li>
<li>如何兼顾碎片和 locality</li>
</ul>
</li>
</ul>
<h2>本页的统一主线</h2>
<ul>
<li>这节课看起来同时在讲 <code>printf</code>、<code>setjmp</code>、<code>vDSO</code>、<code>malloc</code>，其实主线是统一的：</li>
</ul>
<pre><code>先用 debug info 把机器状态重新翻译成源码语义
再用 gdb 进入 libc
最后把这些“看似普通的 C 库接口”重新看成
ABI + 用户态状态机 + 内核接口 的组合实现
</code></pre>
<h2>本节结论</h2>
<ul>
<li><code>debug info</code> 不是保存运行状态本身，而是保存“如何解释运行状态”的规则。</li>
<li><code>gdb</code> 能看见源码行、调用栈、局部变量，依赖的是 <code>DWARF + 当前机器状态</code>。</li>
<li><code>printf</code> 的关键不是输出字符串，而是它展示了 <code>va_list + stdio buffer + write</code> 这条完整用户态链路。</li>
<li><code>va_list</code> 不是“把所有参数都压栈”的老式 hack，而是 ABI 约束下对实参的统一遍历机制。</li>
<li><code>setjmp/longjmp</code> 恢复的是控制流现场和部分寄存器，不会回滚全局变量、堆和其他内存状态。</li>
<li><code>gettimeofday</code> 说明 <code>libc</code> 还会利用 <code>vDSO</code> 这种机制，避免不必要的系统调用。</li>
<li><code>malloc</code> 不是“向内核要一小块内存”，而是在用户态基于 <code>mmap/sbrk</code> 维护自己的复杂状态。</li>
<li>理解 <code>libc</code> 的最好方式不是背函数，而是沿着：</li>
</ul>
<pre><code>程序语义
  -&gt; ABI
  -&gt; 汇编和寄存器
  -&gt; 进程地址空间
  -&gt; 系统调用 / vDSO / 堆管理
</code></pre>
<p>一步步把抽象拆开。</p>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
  <entry>
    <title>7.链接和加载</title>
    <link href="https://katyusha-blog.com/posts/nju-os/os/virtualization/link/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/os/virtualization/link/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-11T00:00:00.000Z</updated>
    <summary>7.链接和加载</summary>
    <content type="html"><![CDATA[<h1>链接和加载</h1>
<h2>核心模型</h2>
<p>可执行文件不是“装着机器指令的盒子”，而是一份描述<strong>进程初始状态</strong>的数据结构：</p>
<ul>
<li>哪些文件内容映射到哪些虚拟地址；</li>
<li>每段内存具有哪些读、写、执行权限；</li>
<li>未初始化数据需要补多少个零；</li>
<li>初始 <code>PC/RIP</code> 指向哪里；</li>
<li>动态程序应先运行哪个解释器；</li>
<li>初始栈中的 <code>argc/argv/envp/auxv</code> 如何布置。</li>
</ul>
<p>整个过程可以压缩成：</p>
<pre><code>源文件
  -&gt; 编译/汇编
  -&gt; 可重定位目标文件 .o
  -&gt; 链接：合并片段、解析符号、计算并填写地址
  -&gt; 可执行文件/共享对象
  -&gt; execve
  -&gt; 加载：建立地址空间、映射段、准备初始栈
  -&gt; 从入口地址开始执行
</code></pre>
<p>其中：</p>
<ul>
<li><strong>链接</strong>解决“这些代码和数据最终放在哪里，引用它们时应填什么数字”。</li>
<li><strong>加载</strong>解决“怎样按照可执行文件的描述建立进程初始地址空间”。</li>
</ul>
<p>讲义中“链接就是算数字填 offset，加载就是按描述 <code>mmap</code>”是最重要的去神秘化模型。</p>
<h2>与 <code>Linking.md</code> 的分工</h2>
<p><a href="/notes/csapp/notes/7-linking/">Linking.md</a> 已经系统覆盖了静态链接那一侧：</p>
<ul>
<li><code>.c -&gt; .i -&gt; .s -&gt; .o -&gt; executable</code>；</li>
<li>ELF 的常见 section；</li>
<li>符号解析、强弱符号、<code>COMMON/.bss</code>、<code>-fno-common</code>；</li>
<li>静态库扫描顺序；</li>
<li>基本重定位算法与 <code>R_X86_64_PC32</code> 等例子。</li>
</ul>
<p>这里不再重复那部分细节，只保留进入操作系统视角所必需的一座桥：</p>
<ul>
<li>链接器主要处理 <code>section/symbol/relocation</code>；</li>
<li>加载器主要处理 <code>segment/address space/initial stack</code>；</li>
<li>对 OS 来说，重点不再是“某个重定位项怎么算”，而是“内核如何把 ELF 变成一个真正开始运行的进程”。</li>
</ul>
<h3>Section 与 Segment 的最小区分</h3>
<p>这份笔记只保留一个后面会一直用到的分界：</p>
<ul>
<li><code>section</code> 是链接视图，服务于目标文件组织、符号表、重定位表；</li>
<li><code>segment</code> 是加载视图，服务于虚拟地址映射和 <code>R/W/X</code> 权限。</li>
</ul>
<p>可以粗略记成：</p>
<pre><code>readelf -S 主要在看链接器关心的结构
readelf -l 主要在看加载器关心的结构
</code></pre>
<h2>加载：把 ELF 变成进程</h2>
<p>执行：</p>
<pre><code>execve(path, argv, envp);
</code></pre>
<p>后，当前进程映像被替换。Linux 中 ELF 加载的核心实现位于 <code>fs/binfmt_elf.c</code>，概念流程是：</p>
<ol>
<li>检查 ELF 魔数、体系结构、文件类型等。</li>
<li>读取 Program Header Table。</li>
<li>为每个 <code>PT_LOAD</code> 建立文件支持的虚拟内存映射。</li>
<li>对 <code>p_memsz &gt; p_filesz</code> 的尾部清零，其中常包含 <code>.bss</code>。</li>
<li>设置各段的读、写、执行权限。</li>
<li>映射 <code>vvar/vdso</code> 等内核提供的区域。</li>
<li>在用户栈上放置 <code>argc/argv/envp/auxv</code> 和对应字符串。</li>
<li>设置初始栈指针和程序计数器，返回用户态。</li>
</ol>
<p><code>PT_LOAD</code> 中关键字段的关系是：</p>
<ul>
<li><code>p_offset</code>：数据在 ELF 文件中的偏移；</li>
<li><code>p_vaddr</code>：映射后的虚拟地址；</li>
<li><code>p_filesz</code>：文件中实际存在的字节数；</li>
<li><code>p_memsz</code>：内存中需要占据的字节数；</li>
<li><code>p_flags</code>：<code>R/W/X</code> 权限；</li>
<li><code>p_align</code>：对齐要求。</li>
</ul>
<p>可以用下面的伪代码建立直觉：</p>
<pre><code>for (each PT_LOAD segment) {
    map_file(segment.p_offset,
             segment.p_vaddr,
             segment.p_filesz,
             segment.p_flags);
    zero_fill(segment.p_vaddr + segment.p_filesz,
              segment.p_memsz - segment.p_filesz);
}
</code></pre>
<p>真实内核实现还要处理页对齐、地址随机化、权限检查、错误回滚等细节，不能直接等同于一条用户态 <code>mmap</code>，但抽象上确实是在建立同样的映射关系。</p>
<h3>Initial Process Stack</h3>
<p>程序刚进入用户态时，栈大致包含：</p>
<pre><code>低地址
SP -&gt; argc
      argv[0] ... argv[argc-1], NULL
      envp[0] ... envp[n-1], NULL
      auxv[0] ... AT_NULL
      参数字符串、环境变量字符串等
高地址
</code></pre>
<p>辅助向量 <code>auxv</code> 是内核向用户态运行时传递信息的键值表，例如：</p>
<ul>
<li><code>AT_ENTRY</code>：主程序入口；</li>
<li><code>AT_PHDR</code>：主程序 Program Header 的地址；</li>
<li><code>AT_BASE</code>：动态链接器的装载基址；</li>
<li><code>AT_RANDOM</code>：随机数据地址，可用于栈保护等；</li>
<li><code>AT_SYSINFO_EHDR</code>：vDSO 的 ELF 头地址。</li>
</ul>
<p>内核不会调用 <code>main</code>。它只负责建立满足 ABI 的初始机器状态，并跳到 ELF 入口。</p>
<h2>Shebang：另一种可执行文件格式</h2>
<p><code>#!</code> 不是 shell 自己实现的语法，而是 Linux 内核 <code>fs/binfmt_script.c</code> 支持的一种加载格式。</p>
<p>假设脚本 <code>S</code> 的第一行是：</p>
<pre><code>#!A B C
</code></pre>
<p>在 Linux 上执行 <code>S x y</code>，可以近似理解为内核改为执行：</p>
<pre><code>execve("A", ["A", "B C", "S", "x", "y"], envp)
</code></pre>
<p>Linux 通常把解释器路径后的剩余部分作为一个可选参数；其他 Unix 系统的拆分规则可能不同，因此 shebang 中不宜放复杂参数。</p>
<p>这里的脚本解释器和 ELF 的 <code>PT_INTERP</code> 都由内核识别，但作用不同：</p>
<ul>
<li>shebang 解释器读取并执行脚本文本；</li>
<li>ELF 解释器是动态链接器，负责装载共享对象和完成运行时重定位。</li>
</ul>
<h2>动态链接</h2>
<p>动态链接把应用代码和共享库分开。主程序记录依赖哪些共享对象以及哪些引用仍需运行时解析，而不直接包含全部库代码。</p>
<p>构建阶段产生的动态 ELF 通常包含：</p>
<ul>
<li><code>PT_INTERP</code>：动态链接器路径；</li>
<li><code>PT_DYNAMIC</code>：动态链接元数据；</li>
<li><code>DT_NEEDED</code>：所需共享对象；</li>
<li><code>.dynsym/.dynstr</code>：动态符号与字符串；</li>
<li><code>.rela.dyn/.rela.plt</code>：动态重定位；</li>
<li>GOT/PLT 等间接访问结构。</li>
</ul>
<h3>完整启动路径</h3>
<pre><code>execve(dynamic_program)
  -&gt; 内核映射主程序的 PT_LOAD
  -&gt; 内核映射 PT_INTERP 指定的动态链接器
  -&gt; PC 指向动态链接器入口
  -&gt; 动态链接器读取主程序的 PT_DYNAMIC
  -&gt; 搜索并映射 DT_NEEDED 指定的共享对象
  -&gt; 建立依赖图和全局符号查找范围
  -&gt; 处理重定位、TLS 等运行时状态
  -&gt; 运行必要的初始化代码
  -&gt; 跳到主程序 ELF 入口 _start
  -&gt; C runtime 调用 __libc_start_main
  -&gt; main
</code></pre>
<p>因此动态程序中：</p>
<ul>
<li>主程序通常仍静态包含来自 <code>crt1.o</code> 的 <code>_start</code>；</li>
<li>但 CPU 刚进入用户态时，最先执行的是动态链接器自己的入口；</li>
<li>动态链接器完成工作后，才跳到主程序的 <code>_start</code>；</li>
<li><code>_start</code> 再按 libc ABI 进入 <code>__libc_start_main</code>，最终调用 <code>main</code>。</li>
</ul>
<h2><code>PT_INTERP</code>：ELF 的动态链接器</h2>
<p><code>PT_INTERP</code> 的内容只是一个以 <code>\0</code> 结尾的路径字符串，例如 x86-64 上常见：</p>
<pre><code>glibc: /lib64/ld-linux-x86-64.so.2
musl:  /lib/ld-musl-x86_64.so.1
</code></pre>
<p>可以用以下命令查看：</p>
<pre><code>readelf -l ./a.out | grep interpreter
</code></pre>
<p>内核不会根据主程序的符号推断该用哪个动态链接器，而是按这个路径打开文件。路径不存在时，即使主程序文件本身存在，<code>execve</code> 仍可能返回 <code>ENOENT</code>，shell 表现为“No such file or directory”。</p>
<h3>为什么 glibc 和 musl 的解释器不同</h3>
<p>解释器路径不同不是单纯改了文件名，而是说明程序属于不同的 libc 运行时生态：</p>
<ul>
<li>glibc 动态程序依赖 glibc 的 loader、<code>libc.so.6</code>、符号版本和 GNU ABI 扩展；</li>
<li>musl 动态程序依赖 musl 的 loader/libc 组织方式和对应 ABI 实现；</li>
<li>两者在库名、符号版本、TLS、启动协议、库搜索和若干扩展行为上存在差异。</li>
</ul>
<p>动态链接器必须和它加载的 libc 及共享对象相互配合。把 glibc 程序的 <code>PT_INTERP</code> 机械改成 musl loader，或者反过来，通常会因缺少库、符号版本不匹配、重定位或 ABI 不兼容而失败。</p>
<p>讲义中修改解释器文件名后再建立软链接能够恢复运行，只说明：</p>
<pre><code>内核按 PT_INTERP 字符串寻找文件
</code></pre>
<p>软链接最终仍指向原来兼容的动态链接器，并不能证明不同 loader 可以互换。</p>
<h3>解释器不同带来的实际影响</h3>
<ul>
<li><strong>可启动性</strong>：目标系统必须存在该路径以及兼容的依赖库。</li>
<li><strong>容器兼容</strong>：Alpine 常用 musl；许多预编译 Linux 软件默认面向 glibc，直接复制进去可能无法运行。</li>
<li><strong>ABI 兼容</strong>：同名 C API 不代表二进制实现细节完全兼容。</li>
<li><strong>行为差异</strong>：DNS/NSS、locale、线程、扩展接口、错误处理等可能不同。</li>
<li><strong>调试工具链</strong>：<code>ldd</code>、符号版本、调试符号和 loader 选项也随实现变化。</li>
</ul>
<p>纯静态链接的 ELF 通常没有 <code>PT_INTERP</code>，因而不会遇到动态解释器路径缺失的问题。</p>
<h2>共享库为什么能节省内存</h2>
<p>同一个 <code>.so</code> 会在每个进程中获得各自的虚拟地址区间，但这些虚拟页可以映射到相同的物理文件页：</p>
<pre><code>进程 A 虚拟页 ----\
                   -&gt; 同一个 libc.so 只读物理页
进程 B 虚拟页 ----/
</code></pre>
<p>需要精确区分：</p>
<ul>
<li>代码和只读数据页通常是文件支持、不可写的，可以通过页缓存跨进程共享；</li>
<li>这并不要求用户语义上的 <code>MAP_SHARED</code>，<code>MAP_PRIVATE</code> 的干净只读文件页同样可以共享物理页；</li>
<li><code>.data</code> 等可写状态在每个进程中必须逻辑独立，初始时可以共享底层文件页，写入后通过 Copy-on-Write 得到私有页；</li>
<li>GOT 等运行时会被修改的页通常也是每进程私有的；</li>
<li>每个进程仍有自己的页表项和虚拟地址，ASLR 可以让同一库出现在不同虚拟地址。</li>
</ul>
<p>因此“动态链接在内存中只有一个副本”只能用于描述可共享的干净文件页，不能理解为共享库的全部代码、数据和运行时状态在系统中只有一份。</p>
<h2>PIC：为什么共享库要位置无关</h2>
<p>ASLR 和不同进程的地址布局意味着同一个 <code>.so</code> 不能假设自己总被加载到固定虚拟地址。</p>
<p>位置无关代码（PIC）的核心约束是：</p>
<pre><code>代码不依赖固定装载基址，换一个地址仍能执行
</code></pre>
<p>常见方法包括：</p>
<ul>
<li>使用 PC/RIP-relative 寻址访问本模块附近的代码和只读数据；</li>
<li>通过 GOT 访问装载时才能确定地址的外部数据；</li>
<li>通过 PLT/GOT 调用可能被动态解析或符号抢占的外部函数。</li>
</ul>
<p>PIC 还避免动态链接器修改共享库的只读代码页。若必须修改 <code>.text</code> 中的绝对地址，会产生 text relocation，使代码页变脏并破坏跨进程共享，也与 W^X 和 RELRO 等保护机制冲突。</p>
<h2>GOT 与 PLT</h2>
<p>动态链接把部分“填地址”的工作推迟到运行时。问题是：一条机器指令的位数和寻址范围有限，而且共享对象的最终装载地址未知。</p>
<h3>GOT：存放运行时地址</h3>
<p>Global Offset Table 是一张位于数据区的地址表。代码使用相对寻址先找到 GOT 项，再从表项中取出目标的真实地址：</p>
<pre><code>mov external_object@GOTPCREL(%rip), %rax
movl $1, (%rax)
</code></pre>
<p>动态链接器在启动重定位阶段把外部对象的实际地址写入 GOT。</p>
<h3>PLT：外部函数调用跳板</h3>
<p>Procedure Linkage Table 是一组短小的代码桩。典型外部调用路径是：</p>
<pre><code>call printf@plt
  -&gt; PLT 桩读取对应 GOT 项
  -&gt; 跳到 printf 的真实地址
</code></pre>
<p>GOT 回答“真实地址存在哪里”，PLT 提供“如何通过这个地址完成函数调用”的跳板。</p>
<h3>Lazy Binding</h3>
<p>在延迟绑定模式下：</p>
<ol>
<li><code>printf</code> 第一次被调用时，其 GOT 项尚未指向真正的 <code>printf</code>；</li>
<li>PLT 把控制权交给动态链接器的解析例程；</li>
<li>动态链接器查找符号并把真实地址写入 GOT；</li>
<li>本次及后续调用跳到真实函数。</li>
</ol>
<p>设置：</p>
<pre><code>LD_BIND_NOW=1 ./a.out
</code></pre>
<p>可以要求启动时完成这类函数重定位。现代系统也可能因安全、构建选项或实现策略默认采用立即绑定。</p>
<p>动态调用的额外间接层可能影响分支预测、优化和调用开销，但是否构成性能瓶颈必须结合调用频率、缓存行为和总体 workload 测量。共享库内部已知不可被抢占的符号可以通过 hidden visibility 等方式直接绑定。</p>
<h2>动态符号解析与 <code>LD_PRELOAD</code></h2>
<p>动态链接器为未定义符号在一个有顺序的查找范围中寻找定义。<code>LD_PRELOAD</code> 可以在普通依赖之前加入共享对象，因此常用于符号插桩：</p>
<pre><code>LD_PRELOAD=./libtrace.so ./program
</code></pre>
<p><code>libtrace.so</code> 若导出兼容的 <code>malloc</code>，程序对可抢占 <code>malloc</code> 的引用就可能绑定到它，而不是原实现。</p>
<p>典型用途包括：</p>
<ul>
<li>跟踪 <code>malloc/free</code>；</li>
<li>记录文件和网络调用；</li>
<li>替换时间函数，实现测试用虚拟时间；</li>
<li>替换随机函数，复现实验；</li>
<li>在不修改目标程序的情况下做性能分析。</li>
</ul>
<p>但“同名符号一定覆盖”过于绝对。以下情况可能绕过 preload：</p>
<ul>
<li>程序是静态链接的；</li>
<li>调用被编译器内联；</li>
<li>符号使用 hidden/protected visibility 或本地直接绑定；</li>
<li>程序直接发系统调用，没有经过被 hook 的 libc 包装；</li>
<li>setuid/setgid 等 secure-execution 模式会限制相关环境变量；</li>
<li>函数签名、ABI 或递归处理不正确会导致崩溃。</li>
</ul>
<p>hook 函数若需要调用原实现，通常使用：</p>
<pre><code>dlsym(RTLD_NEXT, "malloc");
</code></pre>
<p>但还必须处理初始化递归、线程安全和重入问题。</p>
<h2>从入口到 <code>main</code></h2>
<h3>动态链接程序</h3>
<pre><code>execve
  -&gt; 内核加载主 ELF 和 PT_INTERP
  -&gt; ld-linux/ld-musl 的入口
  -&gt; 加载 .so、符号解析、动态重定位
  -&gt; 主程序 _start
  -&gt; __libc_start_main
  -&gt; main
  -&gt; exit
</code></pre>
<p>严格来说，动态链接器可能在跳到主程序 <code>_start</code> 之前完成部分初始化，但 C/C++ 构造器通常由后续 runtime 启动流程按 ABI 约定调用。关键不变量是：<code>main</code> 从来不是 ELF 被内核直接跳入的入口。</p>
<h2>观察与验证</h2>
<h3>查看文件类型和 ELF 头</h3>
<pre><code>file ./a.out
hexdump -C ./a.out | head
readelf -h ./a.out
</code></pre>
<h3>查看加载视图</h3>
<pre><code>readelf -l ./a.out
</code></pre>
<p>重点观察：</p>
<ul>
<li><code>LOAD</code>；</li>
<li><code>INTERP</code>；</li>
<li><code>DYNAMIC</code>；</li>
<li>Section to Segment mapping；</li>
<li>各段的 <code>R/W/E</code> 权限。</li>
</ul>
<h3>查看链接视图</h3>
<pre><code>readelf -S ./a.out
readelf -s ./a.out
readelf -r ./a.out
objdump -dr ./a.o
</code></pre>
<p><code>objdump -dr</code> 可以把反汇编和重定位项放在一起，最适合观察 <code>call foo</code> 的占位数是如何等待链接器修补的。</p>
<h3>查看动态依赖和加载过程</h3>
<pre><code>readelf -d ./a.out
ldd ./a.out
LD_DEBUG=libs,reloc ./a.out
LD_SHOW_AUXV=1 ./a.out
</code></pre>
<p>对不可信程序不要随意运行 <code>ldd</code>；优先使用 <code>readelf -d</code> 静态检查其 <code>DT_NEEDED</code>。</p>
<h3>查看运行时映射</h3>
<pre><code>cat /proc/$PID/maps
pmap $PID
lsof /path/to/libfoo.so
</code></pre>
<p>可以观察：</p>
<ul>
<li>主程序和共享库的虚拟地址；</li>
<li>ASLR；</li>
<li><code>r-xp/r--p/rw-p</code> 权限；</li>
<li><code>[stack]</code>、<code>[heap]</code>、<code>[vvar]</code>、<code>[vdso]</code>；</li>
<li>同一共享库被多个进程映射。</li>
</ul>
<h3>对比静态和动态 ELF</h3>
<pre><code>gcc hello.c -o hello-dynamic
gcc hello.c -static -o hello-static

file hello-dynamic hello-static
readelf -l hello-dynamic | grep interpreter
readelf -l hello-static  | grep interpreter
ls -lh hello-dynamic hello-static
</code></pre>
<p>预期现象：</p>
<ul>
<li>动态版本存在 <code>PT_INTERP</code>，静态版本通常没有；</li>
<li>静态版本通常明显更大；</li>
<li><code>ldd hello-static</code> 通常报告它不是动态可执行文件；</li>
<li>两者的 program headers 和运行时 maps 明显不同。</li>
</ul>
<h2>本讲要点</h2>
<ol>
<li>可执行文件是一份进程初始状态的描述，不只是机器指令集合。</li>
<li><a href="/notes/csapp/notes/7-linking/">Linking.md</a> 负责静态链接、ELF section、符号解析和重定位细节；这里主要补 loader/process 视角。</li>
<li>加载器根据 Program Header 建立地址空间、初始栈和入口机器状态。</li>
<li>Section 服务链接视图，Segment 服务加载视图，不能混为一谈。</li>
<li><code>PT_INTERP</code> 指向用户态动态链接器；动态 ELF 最先执行 loader 的入口，而不是主程序 <code>_start</code>。</li>
<li>glibc 与 musl 的解释器属于不同运行时生态，不能靠修改路径随意互换。</li>
<li>PIC 使同一共享库可以装载到不同地址；GOT 保存运行时地址，PLT 为外部函数调用提供跳板。</li>
<li>共享的是 <code>.so</code> 的干净文件页，不是所有进程状态；可写页仍需保持进程隔离。</li>
<li><code>LD_PRELOAD</code> 利用动态符号解析实现非侵入式插桩，但受可见性、内联、静态链接和安全模式等约束。</li>
</ol>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
  <entry>
    <title>2.程序和进程</title>
    <link href="https://katyusha-blog.com/posts/nju-os/os/virtualization/process/" rel="alternate" type="text/html"/>
    <id>https://katyusha-blog.com/posts/nju-os/os/virtualization/process/</id>
    <published>2026-06-22T00:00:00.000Z</published>
    <updated>2026-06-03T00:00:00.000Z</updated>
    <summary>2.程序和进程</summary>
    <content type="html"><![CDATA[<h2>程序与进程</h2>
<ul>
<li>程序是状态机的静态描述：代码规定了状态转移规则，但自己不会运行。</li>
<li>进程是运行中的状态机实例：有当前 <code>PC/寄存器</code>、地址空间、打开文件、信号状态、凭据、工作目录、环境变量等上下文。</li>
<li>因而“程序”更像模板，“进程”更像带现场的执行对象。</li>
<li>不是所有进程的父进程都是 systemd；只有孤儿进程才会被 PID 1 接管。</li>
<li>子进程退出时发 <code>SIGCHLD</code> 给它的父进程，不是直接通知 <code>systemd</code>。</li>
<li>僵尸进程的本质是“已经退出，但父进程还没 <code>wait</code>”；PID 1 接管孤儿后会负责回收。</li>
</ul>
<h2>虚拟化与 CrazyOS</h2>
<ul>
<li>操作系统对 CPU 的核心虚拟化可以抽象成：</li>
</ul>
<pre><code>while (1) {
  p = pick_next();
  run_one_step(p);
}
</code></pre>
<ul>
<li><code>CrazyOS</code> 用用户态模拟器把这个想法落地：每个 <code>proc</code> 维护自己的寄存器和内存，主循环每次只执行当前进程的一条 guest instruction。</li>
<li>于是“并发”首先是交错执行，不是同时执行；哪个进程更快看到输出，取决于它完成同一可观察动作需要多少条指令。</li>
<li>这也是 <code>./crazy-os p2.bin p1.bin | less</code> 中 <code>p2</code> 看起来更快的原因：<code>p2</code> 打印 <code>1,2,3...</code>，<code>p1</code> 打印 <code>10,20,30...</code>，后者每行通常多一位数字，也就多一次 <code>ecall</code> 和更多模拟指令。</li>
</ul>
<h2>进程状态与可观测性</h2>
<ul>
<li>进程的最小可执行核心是：寄存器现场 + 虚拟地址空间。</li>
<li>但一个 Unix 进程的完整状态远不止这些，还包括：
<code>pid/ppid</code>、调度状态、页表与映射、文件描述符表、cwd、<code>umask</code>、信号处理器与 mask、session/process group、uid/gid、资源限制、环境变量等。</li>
<li>用户态可以直接读自己地址空间中的数据，也天然在使用当前寄存器；但内核代管的那部分状态不能直接 load，只能通过内核接口观测。</li>
<li>这些接口既可以是系统调用，也可以是内核导出的伪文件系统，如 <code>/proc</code>。</li>
</ul>
<h2>/proc 与 procfs</h2>
<ul>
<li><code>procfs</code> 是文件系统类型，<code>/proc</code> 是它通常的挂载点；前者是机制，后者是位置。</li>
<li><code>/proc</code> 中的项在接口层面确实是“文件/目录/链接”，可以 <code>open/read/write/stat</code>。</li>
<li>但它们通常不是磁盘上长期存在的普通文件，内容多半由内核在读取时按当前状态动态生成。</li>
<li>因而 <code>/proc/&lt;pid&gt;/maps</code>、<code>/proc/&lt;pid&gt;/status</code>、<code>/proc/&lt;pid&gt;/fd</code> 本质上是“进程状态的文件化视图”。</li>
</ul>
<h2>fork / execve / exit</h2>
<ul>
<li><code>fork()</code> 复制当前进程，返回两次：父进程得到子进程 PID，子进程得到 <code>0</code>；失败返回 <code>-1</code>。</li>
<li>语义上 <code>fork</code> 复制的是整个执行上下文，实现上通常依赖 Copy-on-Write：先共享物理页，写时再真正复制。</li>
<li><code>fork</code> 不是“从初始模板创建新进程”，而是“把当前计算过程分叉成两条执行线”；这使共享预处理结果、zygote process、checkpoint、fork-based DFS 变得自然，避免了对公共子问题的重复计算。</li>
<li>fork bomb <code>:() { : | : &amp; }; :</code> 的危险在于进程数指数增长；系统可能通过 <code>RLIMIT_NPROC</code>、cgroup、PID/内存限制、OOM 等机制缓解</li>
<li><code>execve(path, argv, envp)</code> 用新程序替换当前进程的代码/数据/堆/栈；PID 通常不变，默认文件描述符保留，成功后不返回。</li>
<li>环境变量就是 <code>execve</code> 的 <code>envp</code>：一组传给新进程的 <code>key=value</code> 字符串，如 <code>PATH/HOME/LANG</code>。</li>
<li><code>_exit(status)</code> 终止当前进程，释放资源，并向父进程发送 <code>SIGCHLD</code>；父进程通过 <code>wait/waitpid</code> 回收退出状态。</li>
</ul>
<h2>UNIX 设计哲学</h2>
<ul>
<li><code>fork + execve</code> 的分离体现了“小原语 + 可组合”的哲学。</li>
<li><code>fork</code> 只负责复制当前上下文，<code>execve</code> 只负责替换程序映像；两步之间用户态可以自由做重定向、改环境变量、改工作目录、设置信号处理。</li>
<li>因而 shell 能用 <code>fork + dup2 + execve + wait</code> 组合出管道、重定向、后台任务，而不需要一个臃肿的“万能创建进程”系统调用。</li>
</ul>
]]></content>
    <author>
      <name>katyusha</name>
    </author>
    <category term="NJU OS"></category>
  </entry>
</feed>