python my_script.py 进去之后,到底发生了什么?」今天我们一次性把这个问题搞清楚。我们会从最底层的硬件谈起,逐层向上爬:硬件 → 内核 → 用户空间 → 你的 Python 代码。中途解释 7 个核心概念:系统分层、OS 职责、内核态/用户态、syscall、进程本质、地址空间布局、hello world 的完整旅程。如果今天结束时你能跟同事说清楚「为什么 open() 是个系统调用而 list.append() 不是」,今天就值。
现代计算机系统不是单一层,它是被有意切成多层的——每一层只对上一层暴露干净的接口,藏起内部的脏活累活。从下到上:
这个分层有几个重要含义:
你做 AI 时熟悉的所有东西——PyTorch、CUDA、HuggingFace transformers——全部坐在 Layer 4。它们要做任何"摸得到真实世界"的事(读文件、申请显存、发网络包),都得通过 Layer 2 这道门。Layer 2 就是操作系统。
不同教材会列出五条十条,但归根结底只有三件事:
你机器上的 CPU 核数、内存大小、磁盘空间是有限的,但同时跑着几百个进程:Chrome、Spotify、Slack、IDE、Python 解释器、Docker daemon……谁能用 CPU?谁能用多少内存?谁先磁盘 IO?这些都由内核来仲裁。
类比:操作系统是机场塔台。飞机(进程)想起飞降落都得报告,塔台分配跑道(CPU 核)、滑行道(内存带宽)、停机位(磁盘 IO),还要处理紧急情况(中断)。
硬件的真实接口非常难用。磁盘真正暴露的是「扇区编号 + 字节偏移」,网卡暴露的是 DMA 寄存器,键盘暴露的是扫描码。如果让每个程序都直接和硬件打交道,你的 Python 代码会变成这样:
# 没有 OS 抽象的世界,写一个读文件要这样
disk_io_send_command(controller=0, sector=14921, count=8, dma_addr=0x7000)
wait_for_interrupt(irq=14)
# ...还有 800 行错误处理
有了 OS 抽象,你的代码变成这样:
with open('data.txt') as f:
content = f.read()
OS 把磁盘抽象成「文件」,把网卡抽象成「socket」,把内存抽象成「连续的虚拟地址空间」。这些抽象是数十年软件工程的最大馈赠之一。
你机器上同时跑的几百个进程互不信任:Chrome 不应该能读到你 1Password 的内存;恶意网页加载的脚本不应该能改你的系统文件。OS 通过虚拟内存 + 权限位 + 进程命名空间等机制强制隔离。
这是所有沙箱、所有安全产品的底层基石。Docker 容器、K8s Pod、Agent 沙箱,本质上都是在用 OS 提供的隔离原语搭高级建筑。
OS 怎么保证你的 Python 代码不能直接乱碰硬件、不能读别人的内存?答案是:CPU 自己提供硬件支持。
现代 x86_64 CPU 内部有一个叫 CPL(Current Privilege Level)的 2 位状态字段,可以是 0/1/2/3,分别对应 4 个特权级("环")。实际上只用两个:
| 特权级 | 俗称 | 能干什么 | 谁住在这里 |
|---|---|---|---|
| Ring 0 | 内核态 / kernel mode | 所有特权指令:操作页表、IO 端口、关中断、改 CR3 等 | Linux 内核、设备驱动 |
| Ring 3 | 用户态 / user mode | 只能用普通指令:算术、跳转、内存读写(且仅限自己的地址空间) | 你的 Python、Chrome、所有 app |
关键性质:CPU 在硬件层面禁止用户态执行特权指令。你写一段 C 代码 asm("cli")(关中断指令)试图关闭中断,CPU 会立刻抛出 General Protection Fault,内核把你 kill 掉。这是物理上的保护,不是软件约定。
这就引出一个新问题:用户态做不了特权操作,但用户程序又必须做这些事(比如读文件,本质上是要操作磁盘控制器)。怎么办?答案就是 系统调用 syscall。
系统调用是用户态向内核态请求服务的受控通道。你不能随便跳到内核代码执行,但你可以"敲门"——通过专门的指令,告诉 CPU「我要执行 N 号系统调用」,CPU 帮你切换到 Ring 0,跳到内核预先注册好的入口函数。
在 x86_64 Linux 上,syscall 的流程大致是:
open 是 2、read 是 0、write 是 1)放到寄存器 %rax%rdi, %rsi, %rdx, %r10, %r8, %r9syscall 指令LSTAR 指向的内核函数 entry_SYSCALL_64sys_call_table),根据 %rax 找到对应的内核函数sys_read),结果放到 %raxsysret 指令切回 Ring 3,控制权回到用户程序整个过程是 CPU 硬件 + 内核软件配合完成的,对用户程序来说看起来"只是调了个函数"。
这是一个最容易混淆的点。看下面的 Python 代码:
x = [1, 2, 3]
x.append(4) # 不是 syscall,纯 Python/C 内存操作
y = sum(x) # 不是 syscall
with open('a.txt') as f: # open() 触发 syscall: openat
data = f.read() # f.read() 触发 syscall: read
import socket
s = socket.socket() # 触发 syscall: socket
s.connect(('1.1.1.1',80))# 触发 syscall: connect
print('hi') # 触发 syscall: write (写到 stdout fd=1)
规律:只要涉及"和外部打交道"(文件、网络、进程管理、内存大块申请),就一定要走 syscall。纯内存计算、纯字节码执行不需要。
epoll 的核心动机。
Linux 上有个神器 strace(macOS 上对应 dtruss,但需要关 SIP)。它能拦截一个进程发出的所有 syscall。试一下:
# Linux:
strace -e openat,read,write python3 -c "print('hi')"
# 你会看到类似(精简后):
openat(AT_FDCWD, "/usr/lib/python3/...", O_RDONLY|...) = 3
read(3, "...", 4096) = 1234
...
write(1, "hi\n", 3) = 3
+++ exited with 0 +++
每一行就是一次"敲门"。一个简单的 print('hi') 背后可能有几百次 syscall(加载 Python 解释器要打开数十个 .so 文件、读它们的元信息……)。
现在我们有了基础。终于可以问:「进程到底是什么?」
一个常见的错误理解是:「进程就是一个正在运行的程序」。这话不算错,但太模糊。更精确的定义:
进程 = 一个独立的虚拟地址空间 + 一组关联资源 + 内核里的控制块(PCB)
每个进程都"以为自己独占整个内存"。OS 给每个进程一份虚拟地址空间,64 位系统上理论上是 256 TB(实际限制小很多)。两个进程都看到地址 0x400000,但映射到的真实物理内存完全不同。这种"看起来共享、实则隔离"的魔法是由 MMU + 多级页表实现的(Week 2 详谈)。
除了地址空间,进程还拥有:
内核为每个进程维护一个叫 PCB(Process Control Block)的数据结构。在 Linux 内核源码里它叫 struct task_struct,定义在 include/linux/sched.h,约 3000 行,包含 200+ 字段。重要的几个:
struct task_struct {
pid_t pid; // 进程 ID
pid_t tgid; // 线程组 ID(同一进程的所有线程共享)
struct task_struct *real_parent;// 父进程指针
struct list_head children; // 子进程链表
long state; // R/S/D/T/Z(明天讲)
struct mm_struct *mm; // 地址空间描述符 ← 关键
struct files_struct *files; // 文件描述符表 ← 关键
struct signal_struct *signal; // 信号处理
cred_t *cred; // 用户身份与权限
// ... 还有 100+ 字段
};
所以当你 ps aux 看到一个进程,你实际上看到的是内核里一个 task_struct 的可视化投影。这个结构体就是进程的"身份证 + 户口本"。
每个进程的虚拟地址空间不是混沌的,它被分成若干 段(segment)。从低地址到高地址:
几个关键事实:
SIGSEGV,进程挂掉。x = [1,2,3] 这个 list 对象在堆上。你可以用一个命令直接看你 Python 进程的地址空间(Linux 上):
# 起一个 Python,让它睡着
python3 -c "import time; time.sleep(1000)" &
PID=$!
# 看它的地址空间
cat /proc/$PID/maps | head -30
# 你会看到一堆地址范围,标着 r-xp(代码) / rw-p(数据) / r--p(只读) 等
把今天讲的所有东西串起来。看这个最简单的 C 程序:
// hello.c
#include <stdio.h>
int main() {
printf("hello, world\n");
return 0;
}
它从源码到屏幕显示 "hello, world" 的完整旅程是:
cpp 展开 #include,把 stdio.h 的内容拷进来。cc1 把 C 源码翻译成汇编。as 把汇编翻译成机器码,产出 hello.o(重定位目标文件)。ld 把 hello.o 和 libc 等库链接,产出 hello(ELF 可执行文件)。当你在 shell 里敲 ./hello:
fork() 系统调用,复制出一个子进程(COW 优化,下次讲)。execve("./hello", ...) 系统调用,请求内核「把我变成 ./hello 这个程序」。task_struct(如果是 exec 则替换原有的地址空间)。ld-linux-x86-64.so,跳到它的入口。_start 函数(不是 main!),经过 glibc 的初始化代码,最终调用 main。printf("hello, world\n") 是个 libc 函数,先做格式化,把字符串写到 stdio 缓冲区。\n 触发 flush,调用 write(1, "hello, world\n", 13) 系统调用。syscall 指令,切到 Ring 0。sys_write 把字符串拷到 stdout 对应的设备(终端模拟器的 pty)。main 返回,glibc 调用 exit(0) → sys_exit_group 系统调用,内核回收 task_struct 和地址空间。从源码到屏幕,跨了 4 个抽象层、若干次特权级切换、几十次 syscall。这就是你每次跑 Python 的本质——只是 Python 解释器把流程包装得更长。
今天讲的概念都是底层基础,但它们直接决定你的 Agent 安全产品能做什么、不能做什么:
1. 沙箱的最强边界来自 OS 层级。LLM Agent 经常要执行不可信代码(用户给的 tool input、模型生成的 Python)。任何"语言层沙箱"都不够安全——Python 的 RestrictedPython、JavaScript 的 vm 模块都被反复绕过。真正可靠的沙箱基于 OS 隔离原语:fork 出独立进程 + seccomp 限制 syscall + cgroups 限制资源 + 独立 namespace 屏蔽 host 信息。你今天理解的"特权级 + syscall + 进程隔离"就是这套沙箱的物理基础。
2. 监控可疑行为的最佳层级是 syscall。LLM 生成的代码再千变万化,要做"实际危害的事"(开后门、读敏感文件、连外网)必然要发 syscall。所以基于 eBPF/Falco 监控 syscall(特别是 execve、connect、open)是最不可绕过的检测方法。你今天对 syscall 流程的理解是 Week 2 周六 eBPF 课的前置。
3. Prompt injection 的最终落点是进程创建。攻击者通过 indirect prompt injection 让 Agent 调一个 tool,最终意图往往是「在你的机器上创建一个新进程做坏事」(典型如反弹 shell)。理解 fork + execve 流程,你就能在最关键的点设监控:「任何 LLM 触发的 execve 都要审查可执行文件路径和参数」。
4. 推理服务的资源耗尽攻击都映射到 OS 概念。攻击者发超长 prompt → 申请大量内存 → 触发 OOM killer → 杀掉你的进程。攻击者诱导循环生成 → 占满 GPU + CPU → CFS 调度器无法保证其他用户的服务质量。今天的"资源管理"这一节直接对应这些场景。
5. 你以后看大模型 OOM 的诊断思路:先看 dmesg 是不是 kernel OOM killer 出手;查 /proc/[pid]/status 的 VmRSS 字段看进程真实内存;ps 看 STAT 列;strace 看是否阻塞在某个 syscall。这些工具你以后会反复用。
110 分钟 · 系统调用追踪
在 mac 终端运行下列命令,把你看到的内容理一遍:
# 1. 看一个 Python 启动会触碰多少文件(系统库加载)
# mac 上用 dtrace 或者直接看进程行为:
python3 -c "import sys; print(sys.version)" 2>&1 | head -5
# 2. 看你 shell 自己的 PID 和它的限制
echo "我的 shell PID: $$"
ulimit -a # 看你的 shell 的各种资源限制
问题:找到 "stack size" 那一行的值(字节数),换算成 MB。这个值就是你 Python 程序的栈空间上限,超过会爆栈。
215 分钟 · 进程对象的实地观察
开两个终端窗口。窗口 A 跑一个无限 sleep 的 Python:
python3 -c "import time; time.sleep(99999)"
窗口 B 找到这个进程的信息:
ps aux | grep "sleep(99999)" | grep -v grep
# 记下 PID
ps -p <PID> -o pid,ppid,user,stat,vsz,rss,command
# vsz = 虚拟内存大小(地址空间总量)
# rss = 实际占用物理内存
问题:vsz 和 rss 通常相差很大,为什么?回顾今天讲的虚拟地址空间概念。
35 分钟 · 思考题
你的 Agent 安全产品要"在用户 LLM 调用任何工具前做一次审查"。下面三种实现方案,从安全角度排序(最安全到最不安全),并说明理由:
tool.execute() 调用libc 的 execve() 函数execve syscall提示:想清楚每一层的"逃逸面"——攻击者要绕过这一层需要什么能力?