普通视图

如何降级 iOS / iPadOS App

2025年11月29日 06:33

起因是发现 Wakeup 课程表更新后被塞入了一大堆冗余功能(搜题、提示登录等),本来想去官方反馈一下好歹给一个关闭的选项,结果到了 QQ 频道发现此 App 已经被售出并商业化,只能自己设法回退版本。

谷歌搜索之后,得到了如下解决方法,在此记录一下,方便以后使用。

ipatool

首先,下载 ipatool

brew install ipatool

然后,执行登录:

ipatool auth login -e <your_apple_id_email>

随后输入提示和 2FA 码,完成登录:

9:52PM INF email=<your_apple_id_email> name="Your Name" success=true

使用 search 命令搜索你想要降级的 App:

ipatool search <app_name>

此例中,搜索:

ipatool search Wakeup --format json

得到如下 JSON 结果:

{
    "level": "info",
    "count": 5,
    "apps": [
        {
            "id": 1553402284,
            "bundleID": "com.wakeup.schedule",
            "name": "WakeUp课程表-超级好用的课程表",
            "version": "6.0.80",
            "price": 0
        },
        // ...
    ],
    "time": "2025-11-28T22:21:57+08:00"
}

很明显,第一个是我们要的,记录下来他的 bundleID,也即 com.wakeup.schedule

使用 list-versions 命令列出该 App 的所有版本

ipatool list-versions -b com.wakeup.schedule --format json

得到结果如下:

{
    "level": "info",
    "externalVersionIdentifiers": [
        "840461360",
        "842067932",
        "842408761",
        "842433648",
        "842569452",
        "842608243",
        // ...
    ],
    "bundleID": "com.wakeup.schedule",
    "success": true,
    "time": "2025-11-28T22:21:43+08:00"
}

你可以直接使用 jq 来对结果进行排序:

ipatool list-versions -b com.wakeup.schedule --format json | jq '.externalVersionIdentifi
ers | sort_by(tonumber) | reverse'

得到结果如下:

[
  "879551069",
  "879389049",
  "878503093",
  "878288344",
  "878285945",
  "877806886",
  "877585055",
  "877474991",
  "877453456",
  "876777068",
  // ...
]

当前的最新版本是 6.0.40,最后一个无广告、冗余功能版本是 6.0.32。然而并不是往回数 8 个版本就是 6.0.32,我们可以通过 get-version-metadata 命令来获取实际版本:

ipatool get-version-metadata -b com.wakeup.schedule --external-version-id 877585055

得到结果:

10:36PM INF displayVersion=6.0.32 externalVersionID=877585055 releaseDate=2021-03-14T08:00:00Z success=true

可以看到,877585055 对应的版本才是 6.0.32。

找到这个 externalVersionId 之后,我们可以使用 download 命令下载该版本:

ipatool download -b com.wakeup.schedule --external-version-id 877585055

然后我们就能得到对应的 ipa 安装包文件 com.wakeup.schedule_1553402284_6.0.32.ipa

Apple Configurator

首先,下载 Apple Configurator,安装后打开。

使用数据线连接到你的 iPhone,完成授权后直接将上一步下载得到的 IPA 文件直接拖入到你的 iPhone 中,即可完成安装。

此时,会提示你「已存在名为“WakeUp课程表”的 App」,选择「替换」即可。

apple-configurator

Reference

💾

  •  

更适合北大宝宝体质的 xv6 OS Lab 踩坑记 - Part5

2025年11月23日 04:49

[!CAUTION]

致各位同学:本笔记的撰写目的是用作参考,请勿直接抄袭,否则后果自负。

Part5 的核心就是两个关键词:

  • 写时复制(Copy-on-Write, COW)
  • 懒分配(Lazy Allocation)

助教给了两个对应的测例:

  • test_mem_cow
  • test_mem_lazy_allocation

我们要做的,是把内核的内存子系统改造成支持这两种机制,并且通过 getpgcnt / getprocsz 这样的辅助系统调用,把内核里 “真实占用的物理页数” 和 “进程逻辑地址空间大小” 暴露给测试程序,从而通过评测。

评测框架

在 Part4 里我们通过 SCHEDULER_TYPE + ENABLE_JUDGER 这套宏体系,把调度算法的测试框架接了进来。Part5 做的是同一套事情,只不过这次切的是内存机制而不是调度器。

Makefile 中新增了一组宏(简化版):

# Part 5: 选择内存机制测试类型
TYPE =

ifneq ($(TYPE),)
  CFLAGS += -DTYPE
  USER_CFLAGS += -DTYPE
endif

ifeq ($(TYPE), COW)
	TEST_PROGRAM = test_mem_cow
	CFLAGS += -DTYPE_COW
	USER_CFLAGS += -DTYPE_COW
else ifeq ($(TYPE), LAZY)
	TEST_PROGRAM = test_mem_lazy_allocation
	CFLAGS += -DTYPE_LAZY_ALLOCATION
	USER_CFLAGS += -DTYPE_LAZY_ALLOCATION
endif

含义:

  • TYPE=COW 时:
    • 编译器会看到 TYPETYPE_COW 两个宏
    • TEST_PROGRAM 被设置为 test_mem_cow
  • TYPE=LAZY 时:
    • 编译器会看到 TYPETYPE_LAZY_ALLOCATION 两个宏
    • TEST_PROGRAM 被设置为 test_mem_lazy_allocation

配合 Part4 中,使用 run_test 命令启动时注入的 ENABLE_JUDGER 宏,就可以类似这样跑评测:

make run_test TYPE=COW
make run_test TYPE=LAZY

为了在同一个用户态框架下既支持 Part4,又支持 Part5,xv6-user/init.cxv6-user/testcases/judger.cxv6-user/testcases/test.h 都做了一套条件编译拆分:

  • 当定义了 ENABLE_JUDGER && SCHEDULER_TYPE 时,走 Part4 调度器评测逻辑;
  • 当定义了 ENABLE_JUDGER && TYPE 时,走 Part5 COW / 懒分配评测逻辑。

比如 xv6-user/init.c 里:

// Part 4
#if defined(ENABLE_JUDGER) && defined(SCHEDULER_TYPE)
  // ... 调度器测试框架 ...
// Part 5
#elif defined(ENABLE_JUDGER) && defined(TYPE)
  // ... 内存机制测试框架 ...
#else
  // ... 平时本地多测例 init ...
#endif

两块框架本质上是同一个套路:

  1. initTEST_PROGRAM 启动指定测例子进程;
  2. 把测例的标准输出通过 pipe 收集到 test_outputs
  3. 再启动 judger,把 “类型编号 + 输出字符串” 作为参数扔过去;
  4. judger 在用户态用字符串匹配判断是否 AC,并在最后打印 SCORE: x

Part5 的 judger 逻辑更简单,只看输出里有没有:

Copy-on-Write Test Completed Successfully
Lazy Allocation Test Completed Successfully

同时确保没有 ERROR 字样。

初始物理页表管理机制

这里,我们需要先介绍一下原有的物理页表管理机制:

核心数据结构和函数都定义在 kernel/kalloc.c 中:

// kernel/kalloc.c
struct {
  struct spinlock lock; // 用于保护物理内存分配器的互斥锁(自旋锁)
  struct run *freelist; // 用于管理空闲物理页的链表
  uint64 npage; // 当前系统中空闲的物理页数
} kmem;

struct run {
  struct run *next;
};

其中,freelist 是一个单向链表,每个节点都是一个空闲物理页,next 指向下一个空闲物理页。

你可能会感到疑惑,画个图或许会好理解一些:

mem

物理页地址和 run 指针是同一个值,只是在空闲时把页首当作链表节点,分配给别人时恢复成全为负载的普通分配页

kinit() 是物理内存分配器的初始化函数,负责设置初始状态:

void
kinit()
{
  initlock(&kmem.lock, "kmem");
  kmem.freelist = 0;
  kmem.npage = 0;
  freerange(kernel_end, (void*)PHYSTOP);
  #ifdef DEBUG
  printf("kernel_end: %p, phystop: %p\n", kernel_end, (void*)PHYSTOP);
  printf("kinit\n");
  #endif
}

void
freerange(void *pa_start, void *pa_end)
{
  char *p;
  p = (char*)PGROUNDUP((uint64)pa_start);
  for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE)
    kfree(p);
}

freerange() 是一个辅助函数,用于将一段物理地址范围(pa_startpa_end)内的物理页释放回空闲链表。

// Free the page of physical memory pointed at by v,
// which normally should have been returned by a
// call to kalloc().  (The exception is when
// initializing the allocator; see kinit above.)
void
kfree(void *pa)
{
  struct run *r;

  if(((uint64)pa % PGSIZE) != 0 || (char*)pa < kernel_end || (uint64)pa >= PHYSTOP)
    panic("kfree");

  // Fill with junk to catch dangling refs.
  memset(pa, 1, PGSIZE);

  r = (struct run*)pa;

  acquire(&kmem.lock);
  r->next = kmem.freelist;
  kmem.freelist = r;
  kmem.npage++;
  release(&kmem.lock);
}

kfree() 是物理内存释放函数,负责将一页物理内存释放回空闲链表。其会将一个物理页填充垃圾数据(0x01),并将该物理页添加到空闲链表的头部,最后增加空闲页计数器 npage

// Allocate one 4096-byte page of physical memory.
// Returns a pointer that the kernel can use.
// Returns 0 if the memory cannot be allocated.
void *
kalloc(void)
{
  struct run *r;

  acquire(&kmem.lock);
  r = kmem.freelist;
  if(r) {
    kmem.freelist = r->next;
    kmem.npage--;
  }
  release(&kmem.lock);

  if(r)
    memset((char*)r, 5, PGSIZE); // fill with junk
  return (void*)r;
}

kalloc() 是物理内存分配函数,负责从空闲链表中分配一页物理内存。其会从 freelist 中取出一页物理内存,并减少空闲页计数器 npage,最后返回该物理页的地址。

辅助系统调用

要在用户态测试 “物理页数” 和 “逻辑地址空间大小”,直接从 C 语言里算是不行的,只能靠内核给我们提供观测接口。

所以,我们需要在 kernel/include/sysnum.h 里新增两个系统调用号:

#define SYS_getprocsz  500   // 获取进程的内存使用情况
#define SYS_getpgcnt   501   // 获取当前已分配物理内存的页数

sys_getprocsz

sys_getprocsz 可以直接干净实现:

// kernel/sysproc.c
/**
 * @brief 实现 getprocsz 系统调用,获取进程的堆顶地址。
 * @return 进程的堆顶地址
 */
uint64 sys_getprocsz(void) {
  acquire(&myproc()->lock);
  int sz = myproc()->sz;
  release(&myproc()->lock);
  return sz;
}

回顾一下我们在 Part2 中提到的,用户态地址空间结构:

+------------------+  <-- MAXVA = 0x4000000000
|   Guard Page     |  (无效页,防护用)
+------------------+  <-- TRAMPOLINE = 0x3FFFFFF000
|  Trampoline Page |  (用户/内核共享的一页)
+------------------+  <-- TRAPFRAME = 0x3FFFFFE000
|  Trapframe Page  |  (每进程陷入帧)
+------------------+
|  ...             |  区间: [MMAPBASE, TRAPFRAME)
+------------------+  <-- MMAPBASE = 0x60000000
|  mmap region     |  (mmap 最高地址在此,向下扩展 ↓)
+------------------+
|  ...             |  (未用/保留/映射空洞)
+------------------+
|  heap (upward)   |  (从 data/bss 之后向上增长 ↑)
+------------------+
|  User Stacks     |  (用户栈区,1 页大小,固定大小,不增长)
+------------------+  <-- STACKBASE
|   Guard Page     |  (无效页,1 页大小,防护用)
+------------------+
|  data / bss      |
+------------------+
|  text            |
+------------------+  <-- 0x00000000

这里的 p->sz 是进程用户态地址空间的 “逻辑大小”,本质上就是当前的 “堆顶” 地址,也就是用户态地址空间中,heap 区域的顶部地址(注意这里布局和标准的 Linux 地址空间布局不一致)。

sys_getpgcnt

getpgcnt 的语义是:全局维度上,当前系统实际占用了多少物理页。语义上,其应当是所有可用物理页数减去空闲物理页数,这在原始的代码框架中可以表示为:

uint64 total_pages = ((void*)PHYSTOP) / PGSIZE; // 所有可用物理页数
uint64 used_pages = total_pages - kmem.npage; // 已使用物理页数

由于我们后续会改造物理内存分配器,因此这里暂时先不实现 getpgcnt 系统调用,占位即可:

/**
 * @brief TODO: 实现 getpgcnt 系统调用,获取当前已分配物理内存的页数。
 * @return 当前已分配物理内存的页数
 */
uint64 sys_getpgcnt(void) {
  return 0;
}

test_mem_cow / test_mem_lazy_allocation 都是通过多次调用 getpgcnt(),来观察 fork /sbrk/ 读写等操作对 “物理页数” 造成的影响,从而间接验证 COW 和懒分配是否工作正常。

由于他们彼此机制上并不冲突,并且正确实现后对于已有测例均无影响,所以我们在后续的实现中可以不像 Part4 那样到处做条件编译,直接实现即可。

物理内存分配器改造

COW 的核心是 “同一物理页被多个进程共享”,而原先的 kfree() 默认 “一次 free 就回收这一页”,现在则必须 “直到没人引用这页,才能真正回收”。

所以,我们必须引入新的机制来管理物理页的引用计数。这里为了方便,我们删掉了原有的 npage,替换为语义更明确的 totalpagesfreepages

// kernel/kalloc.c
#define MAX_PHYS_PAGES (PHYSTOP / PGSIZE)

struct {
  struct spinlock lock;
  struct run *freelist;
  uint64 freepages; // 空闲页数
  uint64 totalpages; // 总分配物理页数(包括空闲页),小于等于 MAX_PHYS_PAGES
  int refcnt[MAX_PHYS_PAGES]; // 引用计数,用于 COW
} kmem;

通过 pa >> PGSHIFT 直接当作 refcnt 数组下标(中间有个 pa2index() 做安全检查):

// kernel/include/riscv.h
#define PGSIZE 4096 // bytes per page
#define PGSHIFT 12  // bits of offset within a page

// kernel/kalloc.c
/**
 * @brief 将物理地址转换为 refcnt 索引
 * @param pa 物理地址,要求必须对齐到 PGSIZE
 * @return refcnt 数组的索引
 */
static inline int
pa2index(uint64 pa)
{
  if(pa % PGSIZE)
    panic("pa2index");
  if(pa >= PHYSTOP)
    panic("pa2index");
  return pa >> PGSHIFT;
}

辅助接口:

/**
 * @brief 增加引用计数
 * @param pa 物理地址
 * @note 增加引用计数,用于 COW
 */
void
incref(uint64 pa)
{
  acquire(&kmem.lock);
  int idx = pa2index(pa);
  kmem.refcnt[idx]++;
  release(&kmem.lock);
}

/**
 * @brief 获取引用计数
 * @param pa 物理地址
 * @return 引用计数
 */
int
getref(uint64 pa)
{
  acquire(&kmem.lock);
  int idx = pa2index(pa);
  int cnt = kmem.refcnt[idx];
  release(&kmem.lock);
  return cnt;
}

kinit

kinit() 负责初始化物理内存分配器。

这里没啥修改,主要是将新增两个字段 freepagestotalpages 初始化为 0。

void
kinit()
{
  initlock(&kmem.lock, "kmem");
  kmem.freelist = 0;
  kmem.freepages = 0;
  kmem.totalpages = 0;
  freerange(kernel_end, (void*)PHYSTOP);
  #ifdef DEBUG
  printf("kernel_end: %p, phystop: %p\n", kernel_end, (void*)PHYSTOP);
  printf("kinit\n");
  #endif
}

kalloc

kalloc() 负责分配物理内存。

  • freelist 取出一页;
  • freepages--
  • 把对应的 refcnt[idx] 置为 1( 初始只有一个引用 );
// Allocate one 4096-byte page of physical memory.
// Returns a pointer that the kernel can use.
// Returns 0 if the memory cannot be allocated.
void *
kalloc(void)
{
  struct run *r;

  acquire(&kmem.lock);
  r = kmem.freelist;
  if(r) {
    kmem.freelist = r->next;
    // 这里必然是初次分配,所以减少空闲页数,并设置引用计数为 1
    kmem.freepages--;
    kmem.refcnt[pa2index((uint64)r)] = 1;
  }
  release(&kmem.lock);

  if(r)
    memset((char*)r, 5, PGSIZE); // fill with junk
  return (void*)r;
}

kfree

kfree() 负责释放物理内存。

这里不再直接无脑回收到 freelist:

  1. 根据物理地址拿到 idx;
  2. 检查 refcnt[idx] >= 1,否则 panic(说明逻辑有 bug);
  3. refcnt[idx]--
  4. 如果减完后仍 > 0,说明还有别的 PTE 在用这页,直接返回,不真正回收
  5. 只有减到 0 时,才把这页挂回 freelist,并 freepages++
// Free the page of physical memory pointed at by v,
// which normally should have been returned by a
// call to kalloc().  (The exception is when
// initializing the allocator; see kinit above.)
void
kfree(void *pa)
{
  struct run *r;

  if(((uint64)pa % PGSIZE) != 0 || (char*)pa < kernel_end || (uint64)pa >= PHYSTOP)
    panic("kfree");

  // 首先解析物理地址,并转换为 refcnt 索引
  uint64 addr = (uint64)pa;
  int idx = pa2index(addr);

  // 获取锁,并检查引用计数是否大于 0
  // 若引用计数小于 1,则 panic
  // 若引用计数大于 0,则递减引用计数,并返回
  // 若引用计数等于 0,则说明这是最后一次引用,可以真正释放物理页,填充垃圾值,并重新挂回 freelist
  acquire(&kmem.lock);
  if(kmem.refcnt[idx] < 1)
    panic("kfree");
  kmem.refcnt[idx]--;
  if(kmem.refcnt[idx] > 0){
    release(&kmem.lock);
    return;
  }

  // Fill with junk to catch dangling refs.
  memset(pa, 1, PGSIZE);

  r = (struct run*)pa;

  r->next = kmem.freelist;
  kmem.freelist = r;
  kmem.freepages++;
  release(&kmem.lock);
}

freerange

kinit() 调用 freerange(kernel_end, PHYSTOP) 时,就顺便把 totalpagesrefcnt 初始化好:

void
freerange(void *pa_start, void *pa_end)
{
  char *p;
  p = (char*)PGROUNDUP((uint64)pa_start);
  for (; p + PGSIZE <= (char*)pa_end; p += PGSIZE) {
    // 现在在初始阶段会计数总分配物理页数,并调用 incref 增加引用计数(对于 COW 来说,这里就是设置为 1)
    kmem.totalpages++;
    incref((uint64)p);
    kfree(p);
  }
}

allocated_pages

最终 totalpages 基本等于 “物理内存总页数”,而 freepages 则随着运行动态变化。getpgcnt 就从这两个数字做减法即可。

// kernel/sysproc.c
/**
 * @brief 实现 getpgcnt 系统调用,获取当前已分配物理内存的页数。
 * @return 当前已分配物理内存的页数
 */
uint64 sys_getpgcnt(void) {
  return allocated_pages();
}

// kernel/kalloc.c
/**
 * @brief 获取总使用物理页数
 * @return 总使用物理页数
 * @note 总使用物理页数 = 总分配物理页数 - 空闲物理页数
 */
uint64
allocated_pages(void)
{
  uint64 freepages, totalpages;
  acquire(&kmem.lock);
  freepages = kmem.freepages;
  totalpages = kmem.totalpages;
  release(&kmem.lock);
  if (totalpages < freepages) {
    return 0;
  }
  return totalpages - freepages;
}

懒分配

懒分配的目标是:

  1. sbrk / brk 只改变进程的逻辑地址空间上限,不立刻分配物理页
  2. 等到真正访问某个地址时,再在缺页异常里分配对应页

growproc

堆分配最终是调用了 growproc() 函数,其进而调用了 uvmalloc()uvmdealloc(),最终封装的还是 kalloc()kfree()

其整体逻辑可以理解如下:

  • 如果 n > 0,则调用 uvmalloc() 在 $[\texttt{oldsz},\ \texttt{newsz})$ 区间里一页一页地使用 kalloc() 分配物理内存。
  • 如果 n < 0,则调用 uvmdealloc() 在 $[\texttt{newsz},\ \texttt{oldsz})$ 区间里一页一页地使用 kfree() 释放物理内存。
// kernel/proc.c
// Grow or shrink user memory by n bytes.
// Return 0 on success, -1 on failure.
int
growproc(int n)
{
  uint sz;
  struct proc *p = myproc();

  sz = p->sz;
  if(n > 0){
    if((sz = uvmalloc(p->pagetable, p->kpagetable, sz, sz + n)) == 0) {
      return -1;
    }
  } else if(n < 0){
    sz = uvmdealloc(p->pagetable, p->kpagetable, sz, sz + n);
  }
  p->sz = sz;
  return 0;
}

为了实现懒分配,我们需要将分配时机延迟到最后,也即真正访问某个地址时,再在缺页异常里分配对应页。

所以,我们修改 growproc() 的逻辑:

  • 扩大堆:只改 sz,不分配物理页
  • 缩小堆:uvmdealloc + 更新 sz,回收超过 new_sz 的那部分映射
// Grow or shrink user memory by n bytes.
// Return 0 on success, -1 on failure.
int
growproc(int n)
{
  struct proc *p = myproc();
  uint64 sz = p->sz;

  if (n > 0) {
    // 懒分配,此时不进行 uvmalloc 分配物理页,只更新 sz,等到用到的时候再通过触发缺页异常来分配物理页
    uint64 newsz = sz + (uint64)n;
    if (newsz < sz)
      return -1;
    if (newsz >= MMAPBASE)
      return -1;
    p->sz = newsz;
  } else if(n < 0){
    uint64 delta = (uint64)(-n);
    uint64 newsz = (delta > sz) ? 0 : sz - delta;
    uvmdealloc(p->pagetable, p->kpagetable, sz, newsz);
    p->sz = newsz;
  }
  return 0;
}

对应到 sbrk() / brk() 系统调用,就意味着:

  • sbrk(SIZE) 之后,getprocsz 会变大,但 getpgcnt 暂时不变
  • 只有访问堆上的新地址时,才会触发缺页异常,由异常处理程序负责真正分配页

lazy_handler

类似之前 Part2 中 VMA 的懒分配,我们在 kernel/trap.c 中增加了一个 lazy_handler() 专门处理堆上的懒分配缺页异常。

其首先进行基础权限检查,这要求访问的地址 stval

  • < p->sz(逻辑堆顶);
  • < MMAPBASE(避免踩到 mmap 区间);

若通过检查,然后发现对应页表项目前还没分配,就在这里分配一页物理内存,并同时映射到用户页表和内核页表(注意内核页表不要带 PTE_U)。

这里有关页面映射的 API 以及相关机制的详细讲解,可以参考 Part2 的笔记。

// kernel/trap.c
/**
 * @brief 处理堆懒分配的缺页异常
 * @param p 进程
 * @param stval 异常地址
 * @return 0 成功,-1 失败
 */
static int
lazy_handler(struct proc *p, uint64 stval)
{
  // 地址超出堆上限地址或者 mmap 区上限地址,返回错误
  if (stval >= p->sz || stval >= MMAPBASE) {
    return -1;
  }

  // 仿照 vma_handler 的逻辑,分配一页物理内存,并映射到用户页表、内核页表
  uint64 va_page_start = PGROUNDDOWN(stval);
  pte_t* pte = walk(p->pagetable, va_page_start, 0);
  // 如果查到有效 PTE,说明实际已经分配了物理页,直接返回错误
  if (pte && (*pte & PTE_V)) {
    return -1;
  }

  // 分配一页物理内存
  char* mem = kalloc();
  // 分配失败,返回错误
  if (mem == 0) {
    printf("lazy_handler(): out of memory\n");
    p->killed = 1;
    return 0;
  }
  // 将新分配的页清零
  memset(mem, 0, PGSIZE);

  // 用户态页表项标志位
  int pte_flags = PTE_W | PTE_X | PTE_R | PTE_U;
  if (mappages(p->pagetable, va_page_start, PGSIZE, (uint64)mem, pte_flags) != 0) {
    kfree(mem);
    printf("lazy_handler(): mappages failed\n");
    p->killed = 1;
    return 0;
  }
  // 内核态页表项标志位
  int kpte_flags = pte_flags & ~PTE_U;
  if (mappages(p->kpagetable, va_page_start, PGSIZE, (uint64)mem, kpte_flags) != 0) {
    kfree(mem);
    vmunmap(p->pagetable, va_page_start, 1, 1);
    printf("lazy_handler(): kernel mappages failed\n");
    p->killed = 1;
    return 0;
  }

  return 0;
}

这个 Handler 最后会和其他 Handler 一起,在 usertrap() 中检测到缺页异常时被顺序调用,稍后会给出具体实现。

写时复制

COW 的 “用户视角” 很简单:fork() 后父子进程各有一份地址空间;在任何一方第一次写某页时,才真正拷贝一份物理页。

要在 xv6 里做这件事,大致分三步:

  1. fork 时不再立即拷贝物理页,而是让父子共享页,把 PTE 标成 COW
  2. 为每个物理页维护引用计数,防止被提前 kfree
  3. 写缺页时做 “按需复制”

PTE_COW

为了实现 COW,我们需要新的 PTE 标志位,在 kernel/include/riscv.h 中新增一下:

#define PTE_COW (1L << 8) // COW 写时复制标志

这个位只在 “用户页表” 里有意义,用来标记这是一个 COW 页。内核页表中不会依赖这个标记来做判断。

uvmcopy

fork 时会调用 uvmcopy 来将父进程的地址空间(即用户页表)复制到子进程。

你可能会好奇为什么这里不用复制内核态页表,答案是其这部分在 fork 里调用 allocproc() (从而调用 proc_kpagetable())时已经自动复制好了。

其实内核态页表实际上可以看做以下三部分:

  • 全局 kernel_pagetable:在 proc_kpagetable() 里直接拷贝,提供所有进程都会用到的内核虚拟空间映射,包含内核代码 / 数据、设备寄存器、trampoline 等,全局共享,从而进入内核后就能无缝访问这些常驻资源
  • 私有内核栈:在 proc_kpagetable() 里单独分配,用于内核态执行系统调用、中断或缺页处理时需要栈空间保存寄存器和局部变量
  • 用户页表的复制,但是不带 PTE_U:在 uvmallocuvmcopy 等路径会把用户页的映射复制到 kpagetable。让内核能够直接访问该进程的用户物理页(拷贝参数、处理 COW、补全缺页等),但限制只能内核态使用

所以,uvmcopy() 只需要处理最后一部分即可,即同时完成:

  1. 把父进程每一页的映射复制到子进程的用户页表里,同时考虑是否要把该页降权为 COW(无写权限 + PTE_COW
  2. 把同一批映射同步到子进程的内核页表(不带 PTE_UPTE_COW),以便未来内核访问(内核访问需要写权限但不会依赖 PTE_COW
  3. 一旦决定共享页(need_cow),就要给物理页做 incref 并把父亲的 PTE 也改成 COW(无写权限 + PTE_COW
  4. 任意一步失败都要把已经做出的更改撤销(包括父页表),否则 fork 失败后父进程就陷入页表无写权限的情况

设置 PTE_COW 的条件:父页可写(PTE_W)或已是 COW(PTE_COW),前者好理解,后者对应父子进程第一次 fork 后,第二次 fork 时的情况,此时第一次 fork 时已经共享了页,第二次 fork 时仍需要再次共享页。

// kernel/vm.c
/**
 * @brief 回滚 COW 操作,将 PTE_COW 标记的页恢复为可写状态
 * @param pagetable 页表
 * @param upto 要回滚的结束地址
 * @note 用于在 fork 失败时恢复父进程的页表状态
 */
static void
revert_cow(pagetable_t pagetable, uint64 upto) {
  for (uint64 va = 0; va < upto; va += PGSIZE) {
    pte_t* pte = walk(pagetable, va, 0);
    // 页表项不存在,跳过
    if (pte == 0)
      continue;
    // 页表项无效,跳过
    if ((*pte & PTE_V) == 0)
      continue;
    // 页表项不是 COW 页,跳过
    if ((*pte & PTE_COW) == 0)
      continue;
    // 获取物理页地址
    uint64 pa = PTE2PA(*pte);
    // 如果物理页引用计数为 1,则恢复为可写状态
    if (getref(pa) == 1) {
      // 强制加写权限,移除 COW 位
      uint64 flags = (PTE_FLAGS(*pte) | PTE_W) & ~PTE_COW;
      *pte = PA2PTE(pa) | flags;
    }
  }
}

// Given a parent process's page table, copy
// its memory into a child's page table using
// copy-on-write(COW) semantics.
// returns 0 on success, -1 on failure.
// frees any allocated pages on failure.
int
uvmcopy(pagetable_t old, pagetable_t new, pagetable_t knew, uint64 sz)
{
  pte_t *pte;
  uint64 pa, i = 0, ki = 0;
  uint flags;

  while (i < sz){
    if((pte = walk(old, i, 0)) == NULL)
      panic("uvmcopy: pte should exist");
    if((*pte & PTE_V) == 0)
      panic("uvmcopy: page not present");
    pa = PTE2PA(*pte);
    flags = PTE_FLAGS(*pte);
    uint64 child_flags = flags;
    int need_cow = 0;

    // 如果父页是可写或已经是 COW,说明需要共享页
    // 已经是 COW 的情况:fork() 之后又有 fork()
    if ((flags & PTE_W) || (flags & PTE_COW)) {
      // 移除 PTE_W,增加 PTE_COW
      child_flags &= ~PTE_W;
      child_flags |= PTE_COW;
      need_cow = 1;
    }

    // 将子用户页表项相应虚拟页映射到父进程对应页表项的物理页,权限为 child_flags
    if (mappages(new, i, PGSIZE, pa, child_flags) != 0) {
      goto err;
    }
    i += PGSIZE;

    // 内核态页表项需要先移除 PTE_U 和 PTE_COW 标志
    // PTE_COW 在内核态页表项中没有意义,发生写异常时是根据用户态页表项的 PTE_COW 位来决定是否触发写时复制
    // 如果触发,会清除同一物理页的所有用户态页面 PTE_COW 位,并新分配物理页然后拷贝数据、更新内核态用户态页表项
    uint64 kchild_flags = child_flags;
    kchild_flags &= ~PTE_U;
    kchild_flags &= ~PTE_COW;
    if (mappages(knew, ki, PGSIZE, pa, kchild_flags) != 0) {
      goto err;
    }
    // 增加物理页引用计数
    incref(pa);

    // 如果需要触发写时复制,则更新父页表项,设置权限与 child_flags 相同
    // 即无 PTE_W,有 PTE_COW
    if (need_cow) {
      *pte = PA2PTE(pa) | child_flags;
    }
    ki += PGSIZE;
  }

  // 刷新 TLB
  sfence_vma();
  return 0;

 err:
  vmunmap(knew, 0, ki / PGSIZE, 0);
  vmunmap(new, 0, i / PGSIZE, 1);
  revert_cow(old, i);
  sfence_vma();
  return -1;
}

写时复制真正发生的地方

上述修改仅仅是完成了父子进程页表的复制、权限的设置,但并没有真正实现写时复制。

写时复制真正发生的地方是:

  1. 当父子进程中任意一方尝试写共享页时,会触发缺页异常,然后在 usertrap() 中由异常处理程序处理。
  2. 当内核主动写用户页时,会调用 copyout2()。此时也需要进行 COW 判断是否需要进行写时复制。

其实第二点理论上来讲可以通过内核页表 + kerneltrap() 来实现,但我们考虑到简便性,加之之前也没在内核页表中设置 PTE_COW 以让内核能比较好地判断是否需要 COW,所以直接把逻辑留在 copyout2() 即可。

cow_handler

usertrap() 一旦看到 scause == 15 并且对应 PTE 带 PTE_COW,就会先走 cow_handler(),而 cow_handler() 的主体就是调用 cow_make_writable()

// kernel/trap.c
/**
 * @brief 处理写时复制时的缺页异常
 * @param p 进程
 * @param scause 异常原因
 * @param stval 异常地址
 * @return 0 成功,-1 失败
 */
static int
cow_handler(struct proc *p, uint64 scause, uint64 stval)
{
  // scause 15:存储 / AMO 页面故障
  // 写时复制异常只能是存储访问异常
  if (scause != 15) {
    return -1;
  }

  uint64 va = PGROUNDDOWN(stval);
  pte_t *pte = walk(p->pagetable, va, 0);
  if (pte == 0) {
    return -1;
  }
  // 页表项不是 COW 页,直接返回
  if ((*pte & PTE_COW) == 0) {
    return -1;
  }

  // 处理写时复制
  if (cow_make_writable(p, va) < 0) {
    printf("cow_handler(): out of memory\n");
    p->killed = 1;
  }
  return 0;
}

cow_make_writable()kernel/vm.c 里,把恢复写权限还是新分配页这两个分支拆得很清楚:

  • getref(pa) == 1 代表只有当前进程在用:直接改用户 PTE / 内核 PTE 的标志位,恢复 PTE_W 并清空 PTE_COW,再 sfence_vma() 刷新 TLB 就能继续写
  • 若引用计数大于 1,就说明至少两个进程在共享:此时 kalloc() 一页,把旧数据 memmove() 过去,再分别更新用户页表和内核页表;最后 kfree(old_pa) 让引用计数减一(真正是否回收交给 kfree() 内部判断)
  • 任何异常都会让 cow_handler() 把当前进程标记为 killed,以免写错页。
// kernel/vm.c
/**
 * @brief 对给定虚拟地址所在的页,如果是 COW 页,则根据引用计数决定是直接恢复写权限还是复制一份新页,同时同步更新用户页表和内核页表。
 * @param p 进程
 * @param va 虚拟地址
 * @return 0 成功,-1 失败
 */
int
cow_make_writable(struct proc *p, uint64 va)
{
  pagetable_t pagetable = p->pagetable;
  uint64 va0 = PGROUNDDOWN(va);
  pte_t* pte = walk(pagetable, va0, 0);
  // 页表项不存在或无效,返回错误
  if (pte == 0 || (*pte & PTE_V) == 0)
    return -1;
  // 页表项不是 COW 页,直接返回
  if((*pte & PTE_COW) == 0)
    return 0;

  // 获取物理页地址
  uint64 pa = PTE2PA(*pte);
  int ref = getref(pa);
  // 引用计数小于 1,panic
  if (ref < 1) {
    panic("cow_make_writable");
  }

  // 引用计数为 1,说明是最后一个引用
  if (ref == 1) {
    // 直接恢复 PTE_W 位、移除 PTE_COW 位
    uint64 flags = (PTE_FLAGS(*pte) | PTE_W) & ~PTE_COW;
    *pte = PA2PTE(pa) | flags;
    // 类似地更新内核页表
    pte_t* kpte = walk(p->kpagetable, va0, 0);
    if(kpte == 0)
      panic("cow_make_writable kpte");
    uint64 kflags = (PTE_FLAGS(*kpte) | PTE_W) & ~PTE_COW;
    *kpte = PA2PTE(pa) | kflags;
    sfence_vma();
    return 0;
  }

  // 引用计数 > 1,触发写时复制,需要分配新页、复制数据、更新父进程和其内核页表
  char* mem = kalloc();
  if(mem == 0)
    return -1;
  memmove(mem, (char*)pa, PGSIZE);
  // 更新用户页表,设置 PTE_W 位、移除 PTE_COW 位
  uint64 flags = (PTE_FLAGS(*pte) | PTE_W) & ~PTE_COW;
  *pte = PA2PTE((uint64)mem) | flags;
  // 类似地更新内核页表
  pte_t* kpte = walk(p->kpagetable, va0, 0);
  if(kpte == 0)
    panic("cow_make_writable kpte");
  uint64 kflags = (PTE_FLAGS(*kpte) | PTE_W) & ~PTE_COW;
  *kpte = PA2PTE((uint64)mem) | kflags;
  sfence_vma();
  kfree((void*)pa);
  return 0;
}

copyout2

只要内核里有把数据写回用户缓冲区的地方(例如 sys_waitfilepipenanosleep 等),最后都会走 copyout2()。如果 copyout2() 没有 COW 逻辑,内核直接 memmove 就会绕过写缺页,导致父子共享页被一起污染。

所以,这里需要把 copyout2() 改成逐页处理 + 每页先调用 cow_make_writable() 确保可写。

逻辑顺序是:

  1. 检查 dstva + len 是否超过 p->sz,防止写到堆之外
  2. 逐页循环,每次定位 va0 = PGROUNDDOWN(dstva)
  3. 先执行 cow_make_writable() 保证该页处于可写状态(如果还是 COW,会在这里做真正的复制)
  4. 计算本页剩余可写字节 nmemmove()
  5. 继续跳到下一页直到写完

这样 copyout2() 既能满足过去的语义,又能在 COW 环境里正确地触发缺页。

/**
 * @brief 将内核空间的数据拷贝到用户空间
 * @param dstva 目标虚拟地址
 * @param src 源数据
 * @param len 长度
 * @return 0 成功,-1 失败
 */
int
copyout2(uint64 dstva, char *src, uint64 len)
{
  struct proc *p = myproc();
  uint64 sz = p->sz;
  if (dstva + len > sz || dstva >= sz) {
    return -1;
  }
  // 这里原先是直接一个大的 memmove,但是我们现在要处理 COW,所以必须保证每次复制都在一个整页以内
  while (len > 0) {
    uint64 va0 = PGROUNDDOWN(dstva);
    // 处理 COW,确保目标页可写
    if (cow_make_writable(p, va0) < 0){
      return -1;
    }
    // 拷贝数据,初次拷贝可能非整页,而是复制了 [dstva, va0+PGSIZE) 之间的数据
    // 后续拷贝时,n 就是整页大小 PGSIZE
    // 最后一次拷贝时,n = len <= PGSIZE
    uint64 n = PGSIZE - (dstva - va0);
    if (n > len)
      n = len;
    memmove((void *)dstva, src, n);
    len -= n;
    src += n;
    dstva = va0 + PGSIZE;
  }
  return 0;
}

usertrap

至此,我们基本上就已经完成了懒分配 + 写时复制的全部逻辑,最后只需要将三个和缺页相关的 Handler 合并在一起,从而保证在 usertrap() 中能够正确地处理缺页异常即可:

  • cow_handler():处理写时复制缺页异常
  • vma_handler():处理 mmap 区缺页异常,需要从之前的代码中提取出来
  • lazy_handler():处理堆区懒分配缺页异常
// kernel/trap.c
/**
 * @brief 处理用户页缺页异常
 * @param p 进程
 * @param scause 异常原因
 * @param stval 异常地址
 * @return 0 成功,-1 失败
 */
static int
handle_user_page_fault(struct proc *p, uint64 scause, uint64 stval)
{
  if (cow_handler(p, scause, stval) == 0) {
    return 0;
  }
  if (vma_handler(p, scause, stval) == 0) {
    return 0;
  }
  if (lazy_handler(p, stval) == 0) {
    return 0;
  }
  return -1;
}

usertrap() 里简化成:

// kernel/trap.c
uint64 scause = r_scause();
uint64 stval  = r_stval();

if (scause == 12 || scause == 13 || scause == 15) {
  if (handle_user_page_fault(p, scause, stval) < 0) {
    printf("usertrap(): segfault pid=%d %s, va=%p\n", p->pid, p->name, stval);
    p->killed = 1;
  }
}

调用顺序按照 “最专门 → 最通用” 的思路排列:

  1. cow_handler():只有写 COW 页才会触发,且一定是 scause == 15
  2. vma_handler():mmap 区既可能因为懒分配缺页,也可能因为权限不允许导致 fault,它们都在这里完成
  3. lazy_handler():最后兜底堆区懒分配

如果三个 Handler 都回 -1,就说明这次缺页属于真正的非法访问,usertrap() 会打印 segfault 并标记进程 killed

测试

COW

make run_test TYPE=COW

得到输出:

hart 0 init done
Starting test program: test_mem_cow
Target type: COW

init: starting test_mem_cow
testing output size:539, contents:
Testing Copy-on-Write
Initial physical pages: 43
Physical pages after initialization: 44 (should be +1)
PhPhysical pages afteysical pages after fork: 57
Physicalr fork: 56
 pages before child read: 57
Child read sum: 522240
Physical pages after child read: 57 (should be same)
Physical pages after child write: 58 (should be increased)
wait status:0
Parent read sum: 522240
Physical pages after child exit: 44 (should be same as before read)
Physical pages after parent write: 44 (should be same)
Copy-on-Write Test Completed Successfully
init: process pid=2 exited
init: test execution completed, starting judger
Judger: Starting evaluation
Test1 output:
Testing Copy-on-Write
Initial physical pages: 43
Physical pages after initialization: 44 (should be +1)
PhPhysical pages afteysical pages after fork: 57
Physicalr fork: 56
 pages before child read: 57
Child read sum: 522240
Physical pages after child read: 57 (should be same)
Physical pages after child write: 58 (should be increased)
wait status:0
Parent read sum: 522240
Physical pages after child exit: 44 (should be same as before read)
Physical pages after parent write: 44 (should be same)
Copy-on-Write Test Completed Successfully

TEST 1 PASSED
SCORE: 1
init: judger completed

LAZY

make run_test TYPE=LAZY

得到输出:

hart 0 init done
Starting test program: test_mem_lazy_allocation
Target type: Lazy Allocation

init: starting test_mem_lazy_allocation
testing output size:309, contents:
Testing Lazy Allocation
Initial physical pages: 43
Physical pages after sbrk: 43 (should be same as initial)
Physical pages after first access: 44 (should be +1)
Physical pages after mid access: 45 (should be +2)
Physical pages after last access: 46 (should be +3)
Lazy Allocation Test Completed Successfully
init: process pid=2 exited
init: test execution completed, starting judger
Judger: Starting evaluation
Test2 output:
Testing Lazy Allocation
Initial physical pages: 43
Physical pages after sbrk: 43 (should be same as initial)
Physical pages after first access: 44 (should be +1)
Physical pages after mid access: 45 (should be +2)
Physical pages after last access: 46 (should be +3)
Lazy Allocation Test Completed Successfully

TEST 2 PASSED
SCORE: 1
init: judger completed

💾

  •  

更适合北大宝宝体质的 xv6 OS Lab 踩坑记 - Part4

2025年10月27日 21:40

[!CAUTION]

致各位同学:本笔记的撰写目的是用作参考,请勿直接抄袭,否则后果自负。

另外,请注意,本文撰写基于我修改过的 initcode.SMakefile 文件,如果你是基于原始仓库所编写的,可能需要手动进行一些初始化修改。

Part4 的核心任务是实现三种不同的进程调度算法:轮转调度(Round-Robin, RR)、优先级调度(Priority Scheduling)和多级反馈队列调度(Multi-level Feedback Queue, MLFQ)。

助教提供了一个模板仓库,其中包含测试程序和 judger 框架,然而我并不想在其上开始完成我的 Lab(因为其是从更基础的原始 xv6-k210 仓库开始完成了一些基础删改得来的),而是在我已经实现了 Part 1、2、3、7 各种系统调用的基础上完成改造。

为了支持这三种调度算法的切换和测试,我们需要对助教的修改进行 cherry pick。同时,为了保证向后兼容,我们需要通过利用各种宏来完成条件编译。

Makefile 与条件编译

为了方便地在不同的调度算法之间切换,我们对 Makefile 进行了深度改造。仿照助教修改的 Makefile,我们定义 SCHEDULER_TYPE 变量,我们可以在编译时向 C 编译器传递不同的宏定义(-D),从而控制哪些代码块参与编译。这里我们同时对 k210 平台的部分进行了删除,以保证简洁。

你可以在 这里 看到我最终修改后的 Makefile。

简而言之,我实现了:

  1. 对于 make local 命令,完全类似之前。但如果在 Makefile 里手动修改 SCHEDULER_TYPE 变量,那么可以启用对应的调度算法与单一测例。如果不指定,会是默认的简化版 RR 调度算法。
  2. 对于 make run_test 命令,编译时注入 ENABLE_JUDGER=1 参数以及对应宏,会启用自动化测试框架。此时可以通过如下三个命令对特定调度算法进行测试并获得得分:
    • make run_test SCHEDULER_TYPE=RR
    • make run_test SCHEDULER_TYPE=PRIORITY
    • make run_test SCHEDULER_TYPE=MLFQ

扩展 PCB 与新增系统调用

不同的调度算法需要在进程控制块 struct proc 中存储不同的信息。

  • RR 调度:需要记录每个进程的时间片长度 timeslice 和当前剩余的时间片 slice_remaining
  • 优先级调度:需要记录每个进程的静态优先级 priority
  • MLFQ 调度:需要更复杂的字段,包括动态优先级 priority、用户设置的基础优先级 base_priority(用于同级队列的优先级判定),以及用于动态调整优先级的统计信息,如 cpu_ticks(CPU 使用时间)、sleep_ticks(睡眠时间)等。
// kernel/include/proc.h
struct proc {
  // ...
  #ifdef SCHEDULER_RR
  int timeslice;
  int slice_remaining;
  #endif

  #ifdef SCHEDULER_PRIORITY
  int priority;
  #endif

  #ifdef SCHEDULER_MLFQ
  int priority;
  int ticks_used;
  int eval_ticks;
  int cpu_ticks;
  int sleep_ticks;
  int base_priority;
  #endif
  // ...
};

为了让用户程序能够与调度器交互,我们新增了三个系统调用:

#define SYS_set_timeslice 400 // RR 算法:设置当前进程的时间片
#define SYS_set_priority 401  // PRIORITY / MLFQ 算法:设置当前进程的优先级
#define SYS_get_priority 402  // PRIORITY / MLFQ 算法:获取当前进程的优先级

这些系统调用的实现位于 kernel/sysproc.c 中,它们会根据传入的参数修改当前进程 proc 结构体中对应的字段。

注意,与最初的 shutdown 系统调用一样,这里新增的三个系统调用需要在 user.husys.pl 中进行声明和注册,否则用户态的测试程序无法直接使用它们。

为了实现各个调度算法,我们主要需要关注如下内容:

  1. proc.h 中的 PCB 结构体 struct proc,其需要根据不同的调度算法进行扩展。
  2. proc.c 中:
    • scheduler() 调度器函数,其负责选择下一个要运行的进程
    • yield() 函数,其负责主动切换当前进程的 state,从 RUNNINGRUNNABLE,然后调用 sched() 函数让出 CPU
    • sched() 函数负责切换到调度器
  3. trap.c 中时钟中断处理函数 kerneltrap()usertrap(),其负责处理时钟中断(即 which_dev == 2 时)并触发调度。
  4. sysproc.c 中的 sleep() 函数,其在使用 MLFQ 调度算法时需要记录睡眠 ticks,从而判断进程是 CPU 密集型还是 I/O 密集型。
  5. sysfile.c 中对新增 dup2 系统调用的实现,这个按照助教的模板抄一下就行,主要是为了评测使用。

原框架自带的调度算法

正如助教在文档里所说,xv6 原本就自带了一套非常基础的调度算法。理解它的工作原理是实现新算法的基础。

算法原理

原版 xv6 的调度算法可以看作一个最朴素的、基于数组轮询的 轮转调度(Round-Robin) 算法。它没有优先级的概念,也没有为每个进程维护独立的时间片。其核心逻辑是:

  1. 顺序扫描:调度器 scheduler() 在一个死循环中,从头到尾依次遍历全局的进程数组 proc[]

  2. 选择第一个就绪进程:当它找到第一个状态为 RUNNABLE 的进程时,就选择该进程投入运行(先设置状态为 RUNNING,然后执行 swtch 切换到进程)

    如果一轮扫描都没有找到 RUNNABLE 进程,调度器会执行 wfi(wait-for-interrupt)等待,直到有新的中断唤醒。

  3. 时钟中断驱动抢占:一个硬件时钟会周期性地产生中断。当中断发生时,如果当前有进程正在运行,中断处理程序会强制调用 yield(),使当前进程放弃 CPU。

  4. 让出与重调度yield() 函数将当前进程的状态从 RUNNING 改回 RUNNABLE,然后也是通过 swtch 切换回调度器。

    注意这里切回调度器后不是说重新开始从头执行 scheduler() 函数,而是继续到 swtch() 的下一行,这类似一个 goto 跳转。

    从而,调度器不会从头开始遍历 proc[NPROC] 数组,而是会继续寻找数组中下一个 RUNNABLE 的进程。

这个算法保证了没有任何进程会被 “饿死”,因为时钟中断确保了没有进程可以永远独占 CPU。但它的效率不高,因为它对所有进程一视同仁,无法区分任务的紧急性。

你可能还会好奇 swtch() 函数在哪里,好像没找到它的定义,实际上它定义在 kernel/swtch.S 里,已经被写成了汇编形式,主要干的事情就是保存 / 恢复寄存器:

# Context switch
#
#   void swtch(struct context *old, struct context *new);
#
# Save current registers in old. Load from new.


.globl swtch
swtch:
        sd ra, 0(a0)
        sd sp, 8(a0)
        sd s0, 16(a0)
        sd s1, 24(a0)
        sd s2, 32(a0)
        sd s3, 40(a0)
        sd s4, 48(a0)
        sd s5, 56(a0)
        sd s6, 64(a0)
        sd s7, 72(a0)
        sd s8, 80(a0)
        sd s9, 88(a0)
        sd s10, 96(a0)
        sd s11, 104(a0)

        ld ra, 0(a1)
        ld sp, 8(a1)
        ld s0, 16(a1)
        ld s1, 24(a1)
        ld s2, 32(a1)
        ld s3, 40(a1)
        ld s4, 48(a1)
        ld s5, 56(a1)
        ld s6, 64(a1)
        ld s7, 72(a1)
        ld s8, 80(a1)
        ld s9, 88(a1)
        ld s10, 96(a1)
        ld s11, 104(a1)

        ret

实现细节

该算法的实现分散在两个关键文件中:

  1. kernel/proc.c 中的 scheduler() 函数:

    这是调度的核心循环。代码非常直白,就是一个 for 循环遍历 proc 数组。

    void
    scheduler(void)
    {
      struct proc *p;
      struct cpu *c = mycpu();
      // ...
      c->proc = 0;
      for(;;){ // 无限循环
        intr_on();
        for(p = proc; p < &proc[NPROC]; p++) { // 从头遍历进程数组
          acquire(&p->lock);
          if(p->state == RUNNABLE) { // 找到第一个可运行的进程
            // ...
            p->state = RUNNING;
            c->proc = p;
            swtch(&c->context, &p->context); // 切换到该进程执行
            // ...
            c->proc = 0;
          }
          release(&p->lock);
        }
      }
    }
    
  2. kernel/trap.c 中的时钟中断处理:

    这是实现 “抢占” 的关键。当进程在用户态或内核态运行时,时钟中断都会发生。中断处理程序 usertrap()kerneltrap() 会判断中断类型。

    // in usertrap()
    // give up the CPU if this is a timer interrupt.
    if(which_dev == 2)
      yield();
    
    // in kerneltrap()
    // give up the CPU if this is a timer interrupt.
    if(which_dev == 2 && myproc() != 0 && myproc()->state == RUNNING) {
      yield();
    }
    

    devintr() 函数会识别出时钟中断并返回 2。当中断发生时,代码会调用 yield()

    yield() 函数(位于 proc.c)则负责将当前进程状态置为 RUNNABLE 并调用 sched(),将控制权交还给调度器,从而完成一次抢占和重调度。

你可能还会注意到,usertrap()kerneltrap() 在处理时钟中断时,判断条件有所不同,内核态中断处理程序多了一些条件,这个差异源于它们处理中断时所处的 上下文(Context) 完全不同。

usertrap() 对应用户态中断,当 CPU 正在执行 用户态 代码时,发生了一个中断或异常(如系统调用、缺页、时钟中断),就会进入 usertrap()

此时 必然 有一个用户进程正在 CPU 上运行。因此,myproc() 一定会返回一个有效的进程指针,并且该进程的状态必然是 RUNNING。在这种情况下,直接调用 yield() 来让出 CPU 是完全安全的,无需额外检查。

kerneltrap() 对应内核态中断,当 CPU 已经在执行 内核态 代码时,又发生了一个中断(通常是外部设备中断,如时钟或磁盘),就会进入 kerneltrap()

内核态执行的代码并不总是代表某个特定进程在运行,所以必须多一些额外判断:

  • myproc() != 0:CPU 可能会在没有关联任何进程的情况下执行内核代码。一个典型的例子就是调度器 scheduler() 本身。在 scheduler() 循环中,选定下一个进程之前(c->proc = p; 执行之前),myproc() 会返回 0。如果此时恰好发生时钟中断,myproc() 就是 NULL,若不加判断直接调用 yield() 就会导致内核崩溃(panic)。
  • myproc()->state == RUNNING:即使 myproc() 非空,也不能保证其状态是 RUNNING。例如,一个进程可能因为等待 I/O 而调用了 sleep(),在 sleep() 函数内部,它的状态已经被设置为 SLEEPING,但它仍然在执行内核代码(直到调用 sched() 切换走)。如果此时发生时钟中断,我们不应该对一个 SLEEPING 状态的进程执行 yield(),因为 yield() 的前提是进程主动放弃 CPU 并回到 RUNNABLE 状态,这与 SLEEPING 的逻辑是冲突的。

这些额外的判断条件确保了只有当一个 真正处于运行状态的进程 在执行内核代码时遭遇时钟中断,才会触发抢占式调度。这避免了在调度器、进程切换等内核关键路径中发生中断时,因上下文不确定而导致的系统崩溃。

轮转调度(Round-Robin, RR)

算法原理

轮转调度(RR)是最简单、最公平的抢占式调度算法之一。它的核心思想是,系统维护一个先进先出(FIFO)的就绪队列,所有就绪进程按到达顺序排队。调度器每次从队列头部取出一个进程,并给予其一个固定的时间片(Time Slice)在 CPU 上运行。

  • 如果进程在时间片结束前完成,它会主动释放 CPU。
  • 如果时间片耗尽时进程仍在运行,它将被强制剥夺 CPU(抢占),并被移动到就绪队列的末尾,等待下一轮调度。

RR 算法的优点是实现简单、公平,能保证每个进程都能在一定时间内获得响应,因此响应时间表现较好。缺点是上下文切换较为频繁,且无法区分任务的紧急程度。

测例说明

观察助教提供的测例 test_proc_rr.c,可以发现三个进程所做的事情是一样的,唯一的区别在于测例会调用 set_timeslice() 系统调用来为三个进程设立不同的最大运行时间片。

int main() {
    printf("Testing RR Scheduler - Basic\n");

    int pid1, pid2, pid3;
    if((pid1=fork())==0) {
        set_timeslice(1);
        task(1);
        exit(0);
    }
    if((pid2=fork())==0) {
        set_timeslice(2);
        task(2);
        exit(0);
    }
    if((pid3=fork())==0) {
        set_timeslice(3);
        task(3);
        exit(0);
    }
    wait(0);
    wait(0);
    wait(0);

    printf("RR Basic Test Completed\n");
    exit(0);
}

那么,在 scheduler 的每一轮调度 for 循环中,P3 能连续执行 3 个时间单位,P2 能执行 2 个,而 P1 只能执行 1 个。

也就是说,假设我们认为他们在 proc[NPROC] 数组中恰好排序是 P1、P2、P3,一切时间中断的发生都很理想,那么理论上就会发生:

  1. scheduler() 调度到 P1,其 slice_remaining 被重置为通过 set_timeslice(1) 系统调用设置的时间片长度 1,开始一个新的调度周期。
  2. 第 1 次时间中断发生,在 usertrap() / kerneltrap() 中,将 P1->slice_remaining 减 1,此时 P1->slice_remaining 为 0,因此会调用 yield() 主动让出 CPU,从而触发一次重新调度。
  3. scheduler() 继续 for 循环,选择 P2 运行,其 slice_remaining 被重置为 2,开始一个新的调度周期。
  4. 第 2 次时间中断发生,在 usertrap() / kerneltrap() 中,将 P2->slice_remaining 减 1,此时 P2->slice_remaining 为 1,因此会直接 return 不会调用 yield(),从而继续运行 P2。
  5. 第 3 次时间中断发生,在 usertrap() / kerneltrap() 中,将 P2->slice_remaining 减 1,此时 P2->slice_remaining 为 0,因此会调用 yield() 主动让出 CPU,从而触发一次重新调度。
  6. scheduler() 继续 for 循环,选择 P3 运行,其 slice_remaining 被重置为 3,开始一个新的调度周期。
  7. 第 4 次时间中断发生,在 usertrap() / kerneltrap() 中,将 P3->slice_remaining 减 1,此时 P3->slice_remaining 为 2,因此会直接 return 不会调用 yield(),从而继续运行 P3。
  8. 第 5 次时间中断发生,在 usertrap() / kerneltrap() 中,将 P3->slice_remaining 减 1,此时 P3->slice_remaining 为 1,因此会直接 return 不会调用 yield(),从而继续运行 P3。
  9. 第 6 次时间中断发生,在 usertrap() / kerneltrap() 中,将 P3->slice_remaining 减 1,此时 P3->slice_remaining 为 0,因此会调用 yield() 主动让出 CPU,从而触发一次重新调度。
  10. scheduler() 重新开始一轮 for 循环,选择 P1 运行...

这意味着在相同的时间(或者理解为 scheduler 内层对 proc[NPROC] 的一次遍历)内,P3 获得的 CPU 时间最多,其次是 P2,最少的是 P1,而又因为他们的任务是相同的,所以预期结果是:

Testing RR Scheduler - Basic
RR Scheduler Process 3 completed
RR Scheduler Process 2 completed
RR Scheduler Process 1 completed
RR Basic Test Completed

实现细节

首先,我们在 proc.h 中对 struct proc 进行扩展:

// kernel/include/proc.h
struct proc {
  // ...
  #ifdef SCHEDULER_RR
  int timeslice;                // 进程设定的基础时间片长度
  int slice_remaining;          // 当前调度周期内剩余的时间片
  #endif
  // ...
};

接着,在 sysproc.c 中新增 sys_set_timeslice 系统调用,其可以直接修改上述新增 PCB 字段:

// kernel/sysproc.c
#ifdef SCHEDULER_RR
/**
 * @brief RR 算法所需内核函数,设置当前进程的时间片
 * @param timeslice 新的时间片长度
 * @return 0 表示系统调用成功返回,-1 表示参数解析失败
 */
uint64 sys_set_timeslice(void) {
  int timeslice;
  if (argint(0, &timeslice) < 0) {
    return -1;
  }
  struct proc* p = myproc();
  // 合法性校验
  if (timeslice < 1) {
    return -1;
  }
  acquire(&p->lock);
  p->timeslice = timeslice;
  p->slice_remaining = timeslice;
  release(&p->lock);
  return 0;
}
#endif

接下来就是实现 RR 算法。

我们先在 proc.c 中对 scheduler() 函数进行改造,确保每个 RUNNABLE 进程的 slice_remaining 都被重置为 timeslice,以及时间片至少为 1:

// kernel/proc.c
void scheduler(void) {
  // ...
  c->proc = 0;
  for(;;){
    // Avoid deadlock by ensuring that devices can interrupt.
    intr_on();

    int found = 0;
    for(p = proc; p < &proc[NPROC]; p++) {
      acquire(&p->lock);
      if (p->state == RUNNABLE) {
        #ifdef SCHEDULER_RR
        // RR: 确保时间片至少为 1
        if (p->timeslice < 1) {
          p->timeslice = 1;
        }
        p->slice_remaining = p->timeslice;
        #endif
        // ...
      }
      release(&p->lock);
    }
    // ...
  }
}

接下来我们进行时钟中断处理,这是实现抢占的关键。每次发生时钟中断(trap.c 内的 usertrap()kerneltrap()),都会调用 rr_on_timer_tick() 函数:

// kernel/proc.c
#ifdef SCHEDULER_RR
/**
 * @brief RR 算法所需内核函数,处理时间片递减与抢占逻辑
 * @return void
 */
void rr_on_timer_tick(void) {
  struct proc* p = myproc();
  // 无进程时无需处理
  if (p == 0) {
    return;
  }
  // 仅在进程实际运行时才计时
  if (p->state != RUNNING) {
    return;
  }
  // 仍有剩余时间片时递减
  if (p->slice_remaining > 0) {
    p->slice_remaining--;
  }
  // 时间片耗尽需让出 CPU
  if (p->slice_remaining <= 0) {
    yield();
    return;
  }
}
#endif

这个函数每次在时钟中断发生时,被 usertrap()kerneltrap() 调用,进行合法性检查后,就会对当前进程的 slice_remaining 减 1。一旦减到 0,说明时间片耗尽,就调用 yield() 主动让出 CPU,从而触发一次重新调度。

// kernel/trap.c
void
usertrap(void)
{
  // ...
  // give up the CPU if this is a timer interrupt.
  if (which_dev == 2) {
    #ifdef SCHEDULER_RR
    // RR 算法:进入时间中断后,处理时间片递减与抢占逻辑
    rr_on_timer_tick();
    #else
    // 默认:直接让出 CPU
    yield();
    #endif
  }

  usertrapret();
}

void
kerneltrap() {
  // ...
  if (which_dev == 2) {
    #ifdef SCHEDULER_RR
    // RR 算法:进入时间中断后,处理时间片递减与抢占逻辑
    rr_on_timer_tick();
    #else
    // 默认:直接让出 CPU
    if (myproc() != 0 && myproc()->state == RUNNING) {
      yield();
    }
    #endif
  }
  // ...
}

最后,我们还需要修改 proc.hproc.c 中的一些其他函数,从而保证其他系统调用的正确性,具体如下:

  • allocprocfreeproc 需要额外初始化 / 重置 RR 的时间片信息。
  • fork() / clone() 在复制 PCB 时会继承父进程的 timesliceslice_remaining
// kernel/proc.h
#ifdef SCHEDULER_RR
#define DEFAULT_TIMESLICE 1
#endif

// kernel/proc.c
static struct proc*
allocproc(void)
{
    // ...
found:
  p->pid = allocpid();

  #ifdef SCHEDULER_RR
  // RR: 初始化时间片相关字段
  p->timeslice = DEFAULT_TIMESLICE;
  p->slice_remaining = DEFAULT_TIMESLICE;
  #endif
  // ...
}

static void
freeproc(struct proc *p)
{
  // ...
  #ifdef SCHEDULER_RR
  p->timeslice = DEFAULT_TIMESLICE; // 重置时间片为默认值以供下次复用
  p->slice_remaining = 0; // 重置剩余时间片以避免旧值影响
  #endif
}

int
fork(void)
{
  // ...
  // copy tracing mask from parent.
  np->tmask = p->tmask;

  #ifdef SCHEDULER_RR
  // fork 时沿用父进程的时间片配置
  np->timeslice = p->timeslice;
  np->slice_remaining = p->timeslice;
  #endif

  // copy saved user registers.
  *(np->trapframe) = *(p->trapframe);
  // ...
}

int
clone(void)
{
  // ...
  // copy tracing mask from parent.
  np->tmask = p->tmask;

  #ifdef SCHEDULER_RR
  // fork 时沿用父进程的时间片配置
  np->timeslice = p->timeslice;
  np->slice_remaining = p->timeslice;
  #endif

  // copy saved user registers.
  *(np->trapframe) = *(p->trapframe);
  // ...
}

优先级调度(PRIORITY)

算法原理

优先级调度是一种抢占式调度算法,它为每个进程分配一个优先级。调度器总是选择当前所有就绪进程中优先级最高的那个来运行。在我们的实现中,约定 优先级的数值越小,代表优先级越高

当一个更高优先级的进程进入就绪状态时,它可以立即抢占当前正在运行的低优先级进程。这种策略能保证高优先级的关键任务被优先处理。

测例说明

test_proc_priority.c 创建了三个进程 P1、P2、P3,它们执行完全相同的 CPU 密集型任务。不同之处在于,它们通过 set_priority() 系统调用被赋予了不同的优先级:

  • P1:priority = 10(最高)
  • P2:priority = 20(中等)
  • P3:priority = 30(最低)

由于优先级调度总是选择优先级最高的进程运行,因此 P1 会持续获得 CPU 时间,直到它完成任务。然后轮到 P2,最后是 P3。因此,预期的完成顺序是 P1 -> P2 -> P3。

Testing Priority Scheduler - Basic
Priority Scheduler Process 1 completed
Priority Scheduler Process 2 completed
Priority Scheduler Process 3 completed
Priority Basic Test Completed

实现细节

首先,在 proc.h 中对 struct proc 进行扩展:

// kernel/include/proc.h
struct proc {
  // ...
  #ifdef SCHEDULER_PRIORITY
  // 优先级调度所需的 PCB 字段
  int priority;                 // 数值越小代表优先级越高
  #endif
  // ...
};

与 RR 类似,我们依靠系统调用让用户态进程自行设置优先级。

sysproc.c 中:

  • 新增了 sys_set_priority() 系统调用,负责解析参数并更新当前进程的 priority
  • 新增了 sys_get_priority() 系统调用,负责获取当前进程的优先级。
// kernel/sysproc.c
#ifdef SCHEDULER_PRIORITY
/**
 * @brief 优先级调度算法所需内核函数,设置当前进程的优先级
 * @param priority 新的优先级
 * @return 0 表示系统调用成功返回,-1 表示参数解析失败
 */
uint64 sys_set_priority(void) {
  int priority;
  if (argint(0, &priority) < 0) {
    return -1;
  }
  struct proc* p = myproc();

  // 优先级调度:拒绝负值优先级
  if (priority < 0) {
    return -1;
  }
  acquire(&p->lock);
  p->priority = priority;
  release(&p->lock);

  return 0;
}

/**
 * @brief 优先级调度算法所需内核函数,实现 get_priority 系统调用,获取当前进程的优先级。
 * @return 当前进程的优先级
 */
uint64 sys_get_priority(void) {
  struct proc* p = myproc();
  acquire(&p->lock);
  int priority = p->priority;
  release(&p->lock);
  return priority;
}
#endif

真正的核心仍然落在 scheduler() 上。优先级模式下,调度器在每次循环中都会完整扫描 proc 表,挑出所有 RUNNABLE 进程里 priority 最小的那个。如果遇到优先级相同的候选者,它会进一步比较 pid,从而保持执行次序的确定性。

这里由于和默认的行为差异较大,所以直接重写了 shceduler() 函数。不过其实大体逻辑也没变,主要就是把原先的 for 循环找到第一个可用进程改为了遍历查找找到最优先的进程。

// kernel/proc.c
#ifdef SCHEDULER_PRIORITY
#define DEFAULT_PRIORITY 20
#endif

void
scheduler(void)
{
  struct proc *p;
  struct cpu *c = mycpu();
  extern pagetable_t kernel_pagetable;

  #ifdef SCHEDULER_PRIORITY
  c->proc = 0;
  for(;;){
    // Avoid deadlock by ensuring that devices can interrupt.
    intr_on();

    int found = 0;
    struct proc *selected = NULL;
    // SCHEDULER_PRIORITY:遍历整个进程表比较优先级,找到优先级最高(Priority最小)的进程
    for(p = proc; p < &proc[NPROC]; p++) {
      acquire(&p->lock);
      if (p->state != RUNNABLE) {
        release(&p->lock); // 非 RUNNABLE 直接释放锁
        continue;
      }
      if (selected == NULL
        || p->priority < selected->priority
        || (p->priority == selected->priority && p->pid < selected->pid)) {
        if (selected != NULL) {
          release(&selected->lock);
        }
        selected = p;
        continue;
      }
      // 非候选者立即释放锁
      release(&p->lock);
    }
    p = selected;

    if (p != NULL) {
      if (p->state == RUNNABLE) {
        // Switch to chosen process.  It is the process's job
        // to release its lock and then reacquire it
        // before jumping back to us.
        // printf("[scheduler]found runnable proc with pid: %d\n", p->pid);
        p->state = RUNNING;
        c->proc = p;
        w_satp(MAKE_SATP(p->kpagetable));
        sfence_vma();
        swtch(&c->context, &p->context);
        w_satp(MAKE_SATP(kernel_pagetable));
        sfence_vma();
        // Process is done running for now.
        // It should have changed its p->state before coming back.
        c->proc = 0;

        found = 1;
      }
      release(&p->lock);
    }
    if (found == 0) {
      intr_on();
      asm volatile("wfi");
    }
  }
  #endif
}

因为时钟中断仍然会调用 yield(),所以一旦更高优先级的进程变为 RUNNABLE,它会在下一次调度循环中立即接管 CPU。这种扫描 - 抢占的节奏与 RR 的结构保持一致,只是把先来先服务换成了按 priority 重新排队。

由于这里不存在动态变化的 PCB 字段需要我们去维护,所以对于优先级调度算法,我们无需更改 trap.c 中的时间中断处理函数。

不过我们还是需要继续修改 proc.c 中的一些已有函数,从而保证其他系统调用的正确性,具体如下:

  • allocprocfreeproc 需要额外初始化 / 重置优先级为默认优先级。
  • fork() / clone() 在复制 PCB 时会继承父进程的 priority
// kernel/proc.c
static struct proc*
allocproc(void)
{
    // ...
found:
  p->pid = allocpid();

  #ifdef SCHEDULER_PRIORITY
  // 优先级调度算法:初始化优先级
  p->priority = DEFAULT_PRIORITY;
  #endif
  // ...
}

static void
freeproc(struct proc *p)
{
  // ...
  #ifdef SCHEDULER_PRIORITY
  p->priority = DEFAULT_PRIORITY; // 恢复默认优先级便于复用
  #endif
}

int
fork(void)
{
  // ...
  // copy tracing mask from parent.
  np->tmask = p->tmask;

  #ifdef SCHEDULER_PRIORITY
  // 子进程继承父进程的优先级
  np->priority = p->priority;
  #endif

  // copy saved user registers.
  *(np->trapframe) = *(p->trapframe);
  // ...
}

int
clone(void)
{
  // ...
  // copy tracing mask from parent.
  np->tmask = p->tmask;

  #ifdef SCHEDULER_PRIORITY
  // 子进程继承父进程的优先级
  np->priority = p->priority;
  #endif

  // copy saved user registers.
  *(np->trapframe) = *(p->trapframe);
  // ...
}

多级反馈队列(MLFQ)

相比 RR / PRIORITY,MLFQ 不仅要在调度点比较优先级,更要持续观测每个进程的行为,然后用反馈去动态调节调度策略。

算法原理

MLFQ 的目标是兼顾交互响应和整体吞吐,核心机制可以拆成三件事:

  1. 多级队列 + 抢占:优先级越高(数值越小)队列的时间片越短,但排在前面的队列永远先调度。高优先级进程一旦就绪,可以立即抢占低优先级进程。
  2. 行为反馈:新进程会落在较高优先级;如果持续吃满时间片,被认定为 CPU 密集型并降级;如果频繁 sleep 或提前主动让出 CPU,则更像 I/O 密集型,可以提升或保持优先级。
  3. 动态时间片:不同优先级的进程拥有不同长度的时间片,保证高优先级任务即使频繁被调度,也不会长时间霸占 CPU。

测例说明

助教提供的 test_proc_mlfq.c 同时发射了 5 类典型 workload:

| 进程 | 行为画像 | 初始优先级 | Judger 关心的现象 | | ---- | ----------- | ---------- | -------------------------------- | | P1 | 纯 CPU 密集 | 10 | 完成最晚,优先级数值被不断增大 | | P2 | 频繁 sleep | 1 | 总是最早完成,优先级保持在最高档 | | P3 | CPU 密集 | 2 | 被调整到更低优先级层 | | P4 | IO 密集 | 5 | 靠更长的 sleep 拿到优先级提升 | | P5 | 混合型 | 3 | 评分脚本观察它是否在中间层徘徊 |

Judger 从进程输出里验证两件事:

  1. 完成顺序要体现 “IO 密集优先,CPU 密集垫底”。
  2. 每个进程 set_priority() 后打印的最终优先级要符合行为反馈(CPU-heavy 数字变大,IO-heavy 数字变小)。

因此,最终的输出可以如下所示(但实现不同具体的数也可能不同,助教文档里要求最高优先级的 I/O 密集型进程(P2)应最先完成,而最低优先级的 CPU 密集型进程(P1)应最后完成):

Testing MLFQ Scheduler - Basic
MLFQ Scheduler Process 2 with initial priority 1 and final priority 1 completed
MLFQ Scheduler Process 5 with initial priority 3 and final priority 1 completed
MLFQ Scheduler Process 4 with initial priority 5 and final priority 1 completed
MLFQ Scheduler Process 3 with initial priority 2 and final priority 20 completed
MLFQ Scheduler Process 1 with initial priority 10 and final priority 20 completed
MLFQ with Priorities Test Completed

数据结构与常量

同样,我们需要先对 PCB 进行扩展:

// kernel/include/proc.h
#ifdef SCHEDULER_MLFQ
#define DEFAULT_PRIORITY 5 // 默认分配给新进程的优先级
#define MLFQ_MIN_PRIORITY_LEVEL 1 // MLFQ:最高优先级对应的数值
#define MLFQ_MAX_PRIORITY_LEVEL 20 // MLFQ:最低优先级对应的数值
#define MLFQ_EVAL_TICKS 5 // MLFQ:统计窗口长度,单位为 tick
#define MLFQ_CPU_DOM_RATIO 2 // MLFQ:CPU 压制阈值,CPU 使用超过休眠两倍视为 CPU 密集
#define MLFQ_SLEEP_DOM_RATIO 2 // MLFQ:休眠压制阈值,休眠超过 CPU 两倍视为 I/O 密集

#define MIN(a, b) ((a) < (b) ? (a) : (b))
#define MAX(a, b) ((a) > (b) ? (a) : (b))
#endif

struct proc {
  // ...
  #ifdef SCHEDULER_MLFQ
  // MLFQ 算法所需的 PCB 字段
  int priority;                 // 动态优先级,数值越小代表优先级越高
  int ticks_used;               // 记录当前时间片已消耗的 tick 数
  int eval_ticks;               // 当前统计窗口内累计 tick 数
  int cpu_ticks;                // 最近窗口内的 CPU 使用 tick 数
  int sleep_ticks;              // 最近窗口内的休眠 tick 数
  int base_priority;            // 记录用户设置的基础优先级,用于同级队列的 FIFO 判定
  #endif
};

其中:

  • priority:用于跨队列比较
  • base_priority:记录 set_priority() 的原始输入,用来在同一级别里保持次序
  • eval_ticks / cpu_ticks / sleep_ticks:组成 “滑动窗口”,至少运行 MLFQ_EVAL_TICKS 后才会触发第一次反馈判断调整动态优先级,每次调整优先级后重置窗口内统计数据
  • ticks_used:记录当前时间片已消耗的 tick 数,在时间片耗尽时触发抢占

系统调用接口

MLFQ 模式可以与 PRIORITY 模式复用一部分 set_priority() / get_priority(),但在 sys_set_priority() 内要额外重置 MLFQ 统计数据:

// kernel/sysproc.c
#if defined(SCHEDULER_PRIORITY) || defined(SCHEDULER_MLFQ)
/**
 * @brief 优先级 / MLFQ 调度算法所需内核函数,设置当前进程的优先级
 * @param priority 新的优先级
 * @return 0 表示系统调用成功返回,-1 表示参数解析失败
 */
uint64 sys_set_priority(void) {
  int priority;
  if (argint(0, &priority) < 0) {
    return -1;
  }
  struct proc* p = myproc();

  #ifdef SCHEDULER_PRIORITY
  // 优先级调度:拒绝负值优先级
  if (priority < 0) {
    return -1;
  }
  acquire(&p->lock);
  p->priority = priority;
  release(&p->lock);
  #endif

  #ifdef SCHEDULER_MLFQ
  acquire(&p->lock);
  // MLFQ:裁剪优先级到合法区间,并更新动态优先级、重置统计数据
  p->priority = mlfq_clamp_priority(priority);
  p->base_priority = p->priority;
  p->ticks_used = 0;
  p->eval_ticks = 0;
  p->cpu_ticks = 0;
  p->sleep_ticks = 0;
  release(&p->lock);
  #endif

  return 0;
}

/**
 * @brief 优先级 / MLFQ 算法所需内核函数,实现 get_priority 系统调用,获取当前进程的优先级。
 * @return 当前进程的优先级(占位实现固定返回0)
 */
uint64 sys_get_priority(void) {
  struct proc* p = myproc();
  acquire(&p->lock);
  int priority = p->priority;
  release(&p->lock);
  return priority;
}
#endif

运行期统计与优先级反馈

为了方便,我在 kernel/proc.c 里创建了一组工具函数负责维护窗口数据:

// kernel/proc.c
#ifdef SCHEDULER_MLFQ
/**
 * @brief MLFQ:裁剪优先级到合法区间
 * @param priority 优先级
 * @return 裁剪后的优先级
 */
inline int mlfq_clamp_priority(int priority) {
  return MIN(MLFQ_MAX_PRIORITY_LEVEL, MAX(MLFQ_MIN_PRIORITY_LEVEL, priority));
}

/**
 * @brief MLFQ:根据优先级确定时间片,优先级越高(level 越小)时间片越短
 * @param priority 优先级
 * @return 时间片长度
 */
static inline int mlfq_timeslice_for_priority(int priority) {
  // 优先级经过裁剪后参与判断
  int level = mlfq_clamp_priority(priority);
  if (level <= 2) {
    return 1;
  }
  if (level <= 5) {
    return 2;
  }
  if (level <= 8) {
    return 3;
  }
  if (level <= 12) {
    return 4;
  }
  return 5;
}

/**
 * @brief MLFQ:清空评估窗口内统计数据
 * @param p 进程指针
 */
static void mlfq_reset_window(struct proc* p) {
  p->eval_ticks = 0;
  p->cpu_ticks = 0;
  p->sleep_ticks = 0;
}

/**
 * @brief MLFQ:根据 CPU 与休眠占比尝试调整优先级
 * @param p 进程指针
 */
static void mlfq_try_adjust_priority(struct proc* p) {
  // 不足一个评估窗口无需调整
  if (p->eval_ticks < MLFQ_EVAL_TICKS) {
    return;
  }
  int cpu_ticks = p->cpu_ticks;
  int sleep_ticks = p->sleep_ticks;
  // CPU 占比高,执行降级
  if (cpu_ticks > 0 && cpu_ticks >= sleep_ticks * MLFQ_CPU_DOM_RATIO) {
    if (p->priority < MLFQ_MAX_PRIORITY_LEVEL) {
      p->priority = mlfq_clamp_priority(p->priority + 1);
    }
  }
  // 休眠占比高,执行升级
  else if (sleep_ticks > 0 && sleep_ticks >= cpu_ticks * MLFQ_SLEEP_DOM_RATIO) {
    if (p->priority > MLFQ_MIN_PRIORITY_LEVEL) {
      p->priority = mlfq_clamp_priority(p->priority - 1);
    }
  }
  mlfq_reset_window(p);
}

时钟中断路径

接下来就是要完成中断处理。

每个时间中断到来时,usertrap() / kerneltrap() 会调用 mlfq_on_timer_tick()。其记录运行中的进程用了多少 CPU 时间,并在时间片耗尽时触发抢占:

// kernel/proc.c
#ifdef SCHEDULER_MLFQ
/**
 * @brief MLFQ 算法所需内核函数,时间中断时更新调度信息
 * @return void
 */
void mlfq_on_timer_tick(void) {
  struct proc* p = myproc();
  // 无进程时无需处理
  if (p == 0) {
    return;
  }
  // 仅对运行态进程计数
  if (p->state != RUNNING) {
    return;
  }
  int need_yield = 0;
  acquire(&p->lock);
  p->ticks_used++;
  p->eval_ticks++;
  p->cpu_ticks++;
  // 尝试根据更新后的数据调整优先级
  mlfq_try_adjust_priority(p);
  // 根据最新优先级计算时间片
  int slice = mlfq_timeslice_for_priority(p->priority);
  // 时间片耗尽需切换
  if (p->ticks_used >= slice) {
    p->ticks_used = 0;
    need_yield = 1;
  }
  release(&p->lock);
  if (need_yield) {
    yield();
  }
}
#endif

ticks_used 只记录当前片段的进度,所以一旦 yield() 触发就被清零(在 yield() 内处理,后面会讲)。

对应的,我们需要在时间中断发生的地方,即 usertrap()kerneltrap() 处调用它:

// kernel/trap.c
void
usertrap(void)
{
  // ...
  // give up the CPU if this is a timer interrupt.
  if (which_dev == 2) {
    #ifdef SCHEDULER_RR
    // RR 算法:进入时间中断后,处理时间片递减与抢占逻辑
    rr_on_timer_tick();
    #elif defined(SCHEDULER_MLFQ)
    // MLFQ 算法:进入时间中断后,处理时间片递减、动态优先级调整与抢占逻辑
    mlfq_on_timer_tick();
    #else
    // 默认:直接让出 CPU
    yield();
    #endif
  }

  usertrapret();
}

void
kerneltrap() {
  // ...
  if (which_dev == 2) {
    #ifdef SCHEDULER_RR
    // RR 算法:进入时间中断后,处理时间片递减与抢占逻辑
    rr_on_timer_tick();
    #elif defined(SCHEDULER_MLFQ)
    // MLFQ 算法:进入时间中断后,处理时间片递减、动态优先级调整与抢占逻辑
    mlfq_on_timer_tick();
    #else
    // 默认:直接让出 CPU
    if (myproc() != 0 && myproc()->state == RUNNING) {
      yield();
    }
    #endif
  }
  // ...
}

睡眠路径与 I/O 识别

为了让调度器意识到 I/O 行为,sys_sleep() 需要在返回前把实际睡眠的 tick 数上报给 mlfq_account_sleep()

// kernel/proc.c
#ifdef SCHEDULER_MLFQ
/**
 * @brief MLFQ:记录 sys_sleep 调用带来的休眠时间
 * @param p 进程指针
 * @param sleep_ticks 休眠时间
 * @return void
 */
void mlfq_account_sleep(struct proc* p, int sleep_ticks) {
  if (p == 0 || sleep_ticks <= 0) {
    return;
  }
  acquire(&p->lock);
  p->sleep_ticks += sleep_ticks;
  p->eval_ticks += sleep_ticks;
  mlfq_try_adjust_priority(p);
  release(&p->lock);
}
#endif

// kernel/sysproc.c
uint64
sys_sleep(void)
{
  int n;
  uint ticks0;

  #ifdef SCHEDULER_MLFQ
  // MLFQ 算法需要记录每次 sleep 的休眠 tick 数累积,从而判断 I/O 密集还是 CPU 密集
  int slept = 0;
  #endif

  if(argint(0, &n) < 0)
    return -1;
  acquire(&tickslock);
  ticks0 = ticks;
  while(ticks - ticks0 < n){
    if(myproc()->killed){

      #ifdef SCHEDULER_MLFQ
      slept = ticks - ticks0;
      #endif

      release(&tickslock);

      #ifdef SCHEDULER_MLFQ
      if (slept > 0) {
        mlfq_account_sleep(myproc(), slept);
      }
      #endif

      return -1;
    }
    sleep(&ticks, &tickslock);
  }
  release(&tickslock);

  #ifdef SCHEDULER_MLFQ
  slept = ticks - ticks0;
  if (slept > 0) {
    mlfq_account_sleep(myproc(), slept);
  }
  #endif

  return 0;
}

调度器与上下文切换

scheduler() 的遍历逻辑和 PRIORITY 版本类似,只是比较条件改为了:

  1. 先比较 priority
  2. 相同时,比较 base_priority
  3. 还相同时,比较 pid

同时,所有候选者都要在离开时释放锁:

// kernel/proc.c
void
scheduler(void)
{
  struct proc *p;
  struct cpu *c = mycpu();
  extern pagetable_t kernel_pagetable;

	#ifdef SCHEDULER_MLFQ
  c->proc = 0;
  for(;;){
    intr_on();
    int found = 0;
    struct proc *selected = NULL;
    for(p = proc; p < &proc[NPROC]; p++) {
      acquire(&p->lock);
      if (p->state != RUNNABLE) {
        release(&p->lock);
        continue;
      }
      // MLFQ:优先按当前优先级、基础优先级、pid 依次比较,选择需要执行的进程
      if (selected == NULL
          || p->priority < selected->priority
          || (p->priority == selected->priority && p->base_priority < selected->base_priority)
          || (p->priority == selected->priority && p->base_priority == selected->base_priority && p->pid < selected->pid)) {
        if (selected != NULL) {
          release(&selected->lock);
        }
        selected = p;
        continue;
      }
      // 非最佳候选立即释放锁
      release(&p->lock);
    }
    p = selected;

    if (p != NULL) {
      if (p->state == RUNNABLE) {
        // Switch to chosen process.  It is the process's job
        // to release its lock and then reacquire it
        // before jumping back to us.
        // printf("[scheduler]found runnable proc with pid: %d\n", p->pid);
        p->state = RUNNING;
        c->proc = p;
        w_satp(MAKE_SATP(p->kpagetable));
        sfence_vma();
        swtch(&c->context, &p->context);
        w_satp(MAKE_SATP(kernel_pagetable));
        sfence_vma();
        // Process is done running for now.
        // It should have changed its p->state before coming back.
        c->proc = 0;

        found = 1;
      }
      release(&p->lock);
    }
    if (found == 0) {
      intr_on();
      asm volatile("wfi");
    }
  }
  #endif
}

切换出去后 yield()sleep() 等路径都需要把 ticks_used 归零,避免旧的片段长度影响下一次调度:

// kernel/proc.c
void
yield(void)
{
  struct proc *p = myproc();
  acquire(&p->lock);
  p->state = RUNNABLE;
  #ifdef SCHEDULER_MLFQ
  // MLFQ:主动让出 CPU 时清空时间片计数
  p->ticks_used = 0;
  #endif
  sched();
  release(&p->lock);
}

void
sleep(void *chan, struct spinlock *lk)
{
  struct proc *p = myproc();

  // ...
  p->state = SLEEPING;
  #ifdef SCHEDULER_MLFQ
  // MLFQ:休眠时清空当前时间片进度
  p->ticks_used = 0;
  #endif

  sched();
  // ...
}

最后,和 RR 一样,由于这些字段都是可能动态变化的,所以我们需要进程的全周期对其进行维护,具体如下:

  • allocprocfreeproc 需要额外初始化 / 重置 MLFQ 统计数据。
  • fork() / clone() 在复制 PCB 时会继承父进程的 prioritybase_priority,但仍然把窗口统计清零,避免子进程直接沿用父进程的历史观测数据。

测试评分与其他 Bug

在实现上述算法时,我们仍然可以像之前一样,通过修改 init.cchar* tests[] 来控制运行程序,从而得到输出。

char* tests[] = {
  // ...
  // part 4
  #ifdef SCHEDULER_RR
    "test_proc_rr",
  #endif
  #ifdef SCHEDULER_PRIORITY
    "test_proc_priority",
  #endif
  #ifdef SCHEDULER_MLFQ
    "test_proc_mlfq",
  #endif
  // ...
};

然而,这样无法正确启动 Judger,可是如果你直接将 init.c 更换为助教提供的模板仓库里的版本,你会发现报错 panic: init exiting

panic: init exiting
backtrace:
0x000000008020017e
0x000000008020264e
0x0000000080203802
0x00000000802035e8
0x0000000080202dd4

进一步排查发现,只要在 main() 函数外声明函数,即使不作操作、不调用,也会导致问题,怀疑是编译过程的问题,然而我将助教的 Makefile 进行了 cherry pick 后,仍然发现还是不能正常工作。

我发现助教的 Makefiledump 这一步生成 init.asm 的时候能保证 main 函数位于 .text 段的 0x0 起始位置,但是我的不行,即使后续 make 时助教的版本会覆写,但是能保证此时生成 initcode.h 时是对的就行,因为 userinit 中固定写死了 p->trapframe->epc = 0x0;,这是导致新增函数后出现问题的原因。

首先我们知道,我们现在实际上是将整个 init.c 编译后打入了 initcode.h,这会导致编译后行为与预期行为的差异。仔细观察 init.asm,就会发现编译后先声明的 print_test_program 出现在了 .text 段起始的 0x0,而不是 main

// xv6-user/init.asm
xv6-user/_init:     file format elf64-littleriscv


Disassembly of section .text:

0000000000000000 <print_test_program>:
# ...
0000000000000062 <main>:

所以,我们先将 print_test_program() 删去,函数体挪入 main(),然后继续运行,此时报错:

usertrap(): segfault pid=1 initcode, va=0x0000000000001180
panic: init exiting
backtrace:
0x000000008020017e
0x0000000080202620
0x0000000080203020

这里发现变成了段错误,排查后发现是因为 init 进程的用户态地址空间仍然只有最开始的那 1 页,而我们在 init.c 里塞进了更多的复杂逻辑之后,.text/.rodata/.bss + stack 的组合体已经明显超过了 4 KB。usertrap() 打印出的 fault address0x1180(即 4480 字节,大于 4096 一页大小了),而 uvminit() 只为 init 进程映射了 [0x0, 0x1000) 这一个页,所以一旦访问超过 0x1000 的地址就会触发缺页异常并 panic。

继续深入,我们可以进一步分析一下 init 进程的内存布局到底长啥样:

userinit() 先调用 uvminit(p->pagetable, p->kpagetable, initcode, sizeof(initcode)),把 kernel/include/initcode.h 里那段 ELF 扔进 唯一的一页用户内存里,并把 p->sz 设成 PGSIZE。紧接着,它把

p->trapframe->epc = 0x0;   // 总是假定入口在 0
p->trapframe->sp  = PGSIZE;// 也就是 0x1000

组合在一起就得到了类似下面的布局:

0x1000 ├────────────────────┤  ← 自 0x1000 起向低地址生长
       │      stack         │ 
       │        ↓           │
       ├────────────────────┤
       │ .text/.rodata/.bss │  ← init.c 编译出的所有指令与静态数据
       │        ……          │
0x0000 └────────────────────┘

原来情况下可能是因为代码量不大,所以不没有把 4 KB 撑爆,但现在助教的 Judger 流程整合进 init.c 之后,情况完全变了:

  • char test_outputs[MAX_OUTPUT_SIZE] 直接占了 1024 字节,外加一堆 argv[]pipefd[]judger_argv[] 等局部变量,栈帧会持续向下膨胀;
  • 无论 test_outputs 放到 main 函数里面(局部变量,会放在 stack 上创建)还是外面(编译器会把它放进 .bss,同样也和 .text 共用这一页),都脱离不了这一页的范围;
  • 再加上字符串字面量("Testing ..." 这种)落在 .rodatapipe / dup2 的临时缓冲落在 stack,一旦任意一块越界,就会踩到还未映射的 0x1000 往上,于是就看到了 va=0x1180 的页故障。

所以问题不是 test_outputs 放哪,而是只给了 4 KB。函数外时 .bss 越界;函数内时栈向下生长越界,本质相同。

既然如此,我们只需要在 userinit() 里把栈撑大就可以了:

// kernel/proc.c
void
userinit(void)
{
  // ...
  uvminit(p->pagetable , p->kpagetable, initcode, sizeof(initcode));
  p->sz = PGSIZE;

  // 额外申请多页空间用作运行时栈,避免 initcode 和 init 的栈空间冲突
  const int extra_stack_pages = 4;
  uint64 newsz = PGSIZE * (1 + extra_stack_pages);
  uint64 allocsz = uvmalloc(p->pagetable, p->kpagetable, p->sz, newsz);
  if(allocsz == 0){
    freeproc(p);
    panic("userinit: uvmalloc");
  }
  p->sz = allocsz;

  // prepare for the very first "return" from kernel to user.
  p->trapframe->epc = 0x0;      // user program counter
  // 原先:p->trapframe->sp = PGSIZE;  // user stack pointer
  p->trapframe->sp = p->sz;     // user stack pointer
  // ...
}

也就是在把 initcode 拷到第一页之后,再追加 4 页(共 20 KB)给用户态,并且让 sp 指向整个地址空间的顶部。这样一来布局就变成:

0x5000 ├────────────────────┤  ← 新的栈顶 (p->sz)
...    │      stack         │
       │        ↓           │
0x1000 ├────────────────────┤  ← 原先的页边界
       │ .text/.rodata/.bss │  (仍然在第 1 页)
0x0000 └────────────────────┘

这下,无论 test_outputs 放哪,都可以正常启动评测流程了。

但是这么做还是非常的不优雅,如果未来要在 init.c 里声明别的函数,就还是需要将其挪到 main 函数里面,我们始终无法自由地声明函数。同时如果这个 init.c 继续变大变复杂,那么我们可能又得手动调整初始分配的栈空间大小,非常不方便。

所以,这里我们最好的解法就是还原原本的方法,从框架代码自带的 initcode.S 生成 initcode.h,从而自动拉起 /init。这样初始代码的入口可以保证在 0x0,而我们在 init.c 里怎么写也没关系了。

重新回顾 Makefile 会发现原本的代码就存在一个 $U/initcode 的编译目标,只不过其是在 32 位 RISC-V 下得到的,所以我们使用不了它的产物,我们只需要将之改为 64 位 RISC-V 的写法即可:

# xv6-user/initcode.S
# Initial process that execs /init.
# This code runs in user space.

#include "include/sysnum.h"

	.text
	.option nopic

# exec(init, argv)
.globl start
start:
        la a0, init
        la a1, argv
        li a7, SYS_exec
        ecall

# for(;;) exit();
exit:
        li a7, SYS_exit
        ecall
        jal exit

# char init[] = "/init\0";
init:
  .asciz "/init"

# char *argv[] = { init, 0 };
.section .rodata
.p2align 2
argv:
  .dword init
  .dword 0

注意,走希冀平台评测时必须将完整的 init.c 程序编译后硬编码到 initcode.h 中,因为希冀平台评测时所提供的预编译 sdcard.img 中没有 init 程序,我们无法通过自举代码来拉起 /init 进程。

这里我们重新更改 Makefile 中的 dump 目标:

# 如果是提交到希冀平台,因为平台提供的 sdcard.img 挂载里没有 init.c 文件
# 所以需要硬编码完整的 init.c 程序的机器码到 initcode.h 中
HARD_CODE_INIT = 0

ifeq ($(HARD_CODE_INIT), 1)
dump: userprogs
	@echo "HARD_CODE_INIT is 1, compile the entire init.c program into initcode.h directly."
	@$(TOOLPREFIX)objcopy -S -O binary $U/_init tmp_initcode
	@od -v -t x1 -An tmp_initcode | sed -E 's/ (.{2})/0x\1,/g' > kernel/include/initcode.h 
	@rm tmp_initcode
else
dump: $U/initcode
	@echo "HARD_CODE_INIT is 0, compile the bootstrap fragment initcode.S normally."
	@od -v -t x1 -An $U/initcode | sed -E 's/ (.{2})/0x\1,/g' > kernel/include/initcode.h
endif

# ...
# 希冀平台所使用的编译命令
all:
	@$(MAKE) clean
	@$(MAKE) dump HARD_CODE_INIT=1
	@$(MAKE) build
	@cp $(T)/kernel ./kernel-qemu
	@cp ./bootloader/SBI/sbi-qemu ./sbi-qemu

# 本地测试所使用的编译命令
local:
	@$(MAKE) clean
	@$(MAKE) dump
	@$(MAKE) build
	@$(MAKE) fs
	@$(MAKE) run

现在,就能一切正常工作了,而且符合逻辑,非常完美。

我们还可以通过修改 Makefile 来注入宏,影响 make run_testmake local 的行为,从而实现向后兼容(这里还是建议在 这里 看完整的 Makefile):

# 希冀平台所使用的编译命令
all:
	@$(MAKE) clean
	@$(MAKE) dump HARD_CODE_INIT=1
	@$(MAKE) build
	@cp $(T)/kernel ./kernel-qemu
	@cp ./bootloader/SBI/sbi-qemu ./sbi-qemu

# 助教提供的功能性测试平台所使用的编译命令
run_test:
	@$(MAKE) clean
	@$(MAKE) dump ENABLE_JUDGER=1
	@$(MAKE) build ENABLE_JUDGER=1
	@$(MAKE) fs ENABLE_JUDGER=1
	@$(MAKE) run ENABLE_JUDGER=1

# 本地测试所使用的编译命令
local:
	@$(MAKE) clean
	@$(MAKE) dump
	@$(MAKE) build
	@$(MAKE) fs
	@$(MAKE) run

然后对应的,在 init.c 里根据是否有定义 ENABLE_JUDGER 来进行条件编译即可:

// xv6-user/init.c

#ifdef ENABLE_JUDGER

// 助教给的代码

#else

// 我们原本的代码

#endif

至此,我们就完成了 Part 4。

💾

  •  

如何高效利用录播

2025年10月15日 14:56

由于身处医学部,修读信双的我一节线下课都去不了,所有信双课程都必须自学,所以在过往的三年里,为了监督自己学习,我为大部分课程都同步撰写了笔记,在此过程中,AI 使我受益匪浅,它不仅能够帮我答疑解惑,详细地讲解 Slides 中没写明白的地方,还能帮我省去许多苦力活,敲出许多近乎完美的 LaTeX 代码,形成高质量、通俗易懂的笔记,成为了我信双学习生涯不可或缺的助力。

在过往的学期内,很多的笔记都是我对着 PPT 硬问 AI 然后结合录播自行增改所撰写,但随着 Gemini 的横空出世,我总结出了一套很棒的 Pipeline,感觉很适合信科课程的学习,在此分享给大家,希望能对大家有所帮助。

  1. 从教学网使用 PKU-Art 下载录播视频
  2. 将录播视频上传至 通义听悟 (学生认证后送 500h,识别准确度不错,但对英文名词略差),设置中英文混合识别,识别出文字内容,导出 docx 格式的录音稿
  3. 使用自定义 Prompt(见后附注),利用上下文长达 1M 的 gemini-2.5-pro 模型生成 Markdown 笔记(方式见后),建议不要全文送入,尽管上下文可以 Cover,但有可能对于重点和细节的关注有所下降,我一般就是一节课(约 50 min)一次输入。
  4. 利用 Typora 整理多段内容,形成初版笔记,通读之当做预习
  5. 随后倍速播放课程录播,尝试在初版笔记基础上增删改查,并对现有笔记中没有的、在 PPT 中的内容进行补充,从而同时覆盖老师口述内容与 PPT 内容,形成最终笔记

关于 AI 的使用:

  1. 如果你是北京大学学生,可以使用官方的 DeepSeek API 模型,但不推荐,比较菜。
  2. 如果你有更高的需求,可以考虑通过一些第三方中转,如 yunwu.ai (不带邀请的链接是:yunwu.ai)以相对廉价的成本使用先进模型。
  3. 得到 API Key 后,推荐使用公益的 LCPU Lobe Chat 网页版来获得更好的使用体验,你也可以进一步下载 其本地 App 客户端 来获得快速唤起等更方便的功能。

尽管使用了 AI,但如果用心整理笔记,仍会花费接近甚至超过课程的原始时间(对我而言,基本上是原始时间的 1.5x),不过的确能在此过程中充分理解课程内容。

希望对大家有所帮助。

{TEXT}
根据以上转录稿(请注意,其中可能存在大量的识别错误,你可能需要自行猜测并修正之)以及所给 PPT 内容,撰写详细清楚的笔记,要求使用 markdown。 风格类似
```md
## 线性模型

线性模型:线性模型就是要学习特征 $X$ 的一种线性组合来进行预测,进行运算 $y = wX + b$,其中 $w$ 是 $X$ 的权重,$b$ 是偏置,$y$ 是预测值。

我们希望通过学习得到最优的 $w$ 和 $b$,使得预测值 $y$ 与真实值(ground truth) $y_{GT}$ 的误差最小。

其中,$X$ 具有 $n$ 个特征,$X = (x_1, x_2, ..., x_n)$,其每个分量都代表一个特征,$y = w_1x_1 + w_2x_2 + ... + w_nx_n + b$,是 $X$ 各个特征的线性组合。

**线性回归**:给定数据 $D = \{(x_1, y_1), (x_2, y_2), \ldots, (x_n, y_n)\}$, 用一个线性模型估计最接近真实 $y$ 的连续标量: $f(x_i)=w^T \cdot x_i + b$, 也就是要 $f(x_i) \approx y_i$

其中,$(w, b)$ 是要学习的模型参数。

也就是要:

$$
f^{*} = \arg \min_f \mathbb{E} [ ( f ( X )-Y )^{2} ]
$$

由于我们不能无限地获得数据,所以我们只能通过有限的数据来估计这个期望,也就是:

$$
f^{*}= \arg \min_f \frac{1}{N} \sum_{i=1}^{N} ( f ( x_i )-y_i )^{2}
$$

这也被称为 Empirical mean(经验均值)。

其中,$n$ 是数据的数量,$x_i$ 是第 $i$ 个数据的全部特征,$y_i$ 是第 $i$ 个数据的真实值,$f(x_i)$ 是第 $i$ 个数据的预测值。
```
如你所见,我希望笔记可读性很强,详细介绍所有出现的公式、符号定义,深入讲解讲稿中的核心 insight 与重要知识点,同时兼顾思维的连贯性,我希望你尽量少运用加粗语法与列表语法,并尽量使用中文标点符号,除非必要情况。

请直接输出 md 格式内容,无需外包代码块。

💾

  •  

更适合北大宝宝体质的 xv6 OS Lab 踩坑记 - Part7

2025年10月13日 09:15

[!CAUTION]

致各位同学:本笔记的撰写目的是用作参考,请勿直接抄袭,否则后果自负。

另外,请注意,本文撰写基于我修改过的 initcode.SMakefile 文件,如果你是基于原始仓库所编写的,可能需要手动进行一些初始化修改。

Part7 的测试样例涉及了多个文件系统相关的系统调用,它们大多是已有功能的 “增强版”,提供了更灵活的路径处理方式。同时,也引入了对挂载(mount)机制的初步支持(?)。

本次需要额外实现的系统调用包括:

  • dup3
  • getdents
  • mkdirat
  • unlinkat
  • mount / umount
  • fstat

还有一些可以直接修改系统调用号即可实现的系统调用,这里不再做展开了。

dup / dup3

dup:创建指向同一打开文件对象的副本,文件偏移量和状态共享。

int dup(int oldfd);

参数:

  • oldfd:要复制的已打开文件描述符。

返回值:

  • 成功:返回新的(最低可用)文件描述符。
  • 失败:返回 -1,并设置 errno

测试仓库说明和标准文档出入不大。

#define SYS_dup 23

xv6-k210 默认已经实现 dup

dup3dup 系统调用的一个扩展版本。dup 的作用是复制一个现有的文件描述符,返回一个新的、未被使用的文件描述符,这两个描述符指向同一个打开的文件实例(struct file)。而 dup3 则更进一步,它允许调用者 指定 新的文件描述符的值。

int dup3(int oldfd, int newfd, int flags);

参数:

  • oldfd:源文件描述符。
  • newfd:目标文件描述符(若已打开则会被重用为副本;若等于 oldfd 则失败)。
  • flags:目前仅支持 O_CLOEXEC;其他值会失败。

返回值:

  • 成功:返回 newfd
  • 失败:返回 -1,并设置 errno

说明:若 oldfd == newfd 则返回 EINVAL

测试仓库说明和标准文档出入不大。

#define SYS_dup3 24

dup3 在需要进行输入输出重定向时非常有用。例如,我们想把标准输出(stdout,通常是 fd=1)重定向到一个文件,可以先用 openat 打开文件得到 file_fd,然后调用 dup3(file_fd, 1),这样之后所有写入到 fd=1 的数据都会被写入到文件中。

实现 sys_dup3 的逻辑很清晰:

  1. 从用户态获取旧的文件描述符 old_fd 和新的文件描述符 new_fd
  2. 进行参数检查:old_fd 必须是一个有效的文件描述符,new_fd 必须在合法范围内(0 到 NOFILE),且 old_fdnew_fd 不能相同。
  3. 获取当前进程的 PCB (struct proc)。
  4. 核心逻辑:检查 p->ofile[new_fd] 是否已经被占用。如果 new_fd 已经指向了一个打开的文件,我们需要先调用 fileclose() 将其关闭,释放资源。
  5. 调用 filedup() 复制 old_fd 指向的 struct file 实例。
  6. p->ofile[new_fd] 指向这个新复制的 struct file 实例。
  7. 返回 new_fd
/**
 * @brief 实现 dup3 系统调用,复制文件描述符
 * @param old_fd 被复制的文件描述符
 * @param new_fd 指定的新的文件描述符
 * @return 成功则返回新的文件描述符 new_fd,失败返回 -1
 * @note dup3 与 dup 的主要区别在于可以指定 new_fd。如果 new_fd 已经被占用,会先关闭它。
 */
uint64
sys_dup3(void)
{
  struct file* f;
  int old_fd, new_fd;

  // 1. 获取参数
  if (argfd(0, &old_fd, &f) < 0 || argint(1, &new_fd) < 0) {
    return -1;
  }

  // 2. 参数检查
  if (new_fd < 0 || new_fd >= NOFILE) {
    return -1;
  }
  if (new_fd == old_fd) {
    return -1;
  }

  struct proc* p = myproc();

  // 3. 如果 new_fd 已被占用,先关闭
  if (p->ofile[new_fd] != NULL) {
    fileclose(p->ofile[new_fd]);
  }

  // 4. 复制文件实例并赋给 new_fd
  p->ofile[new_fd] = filedup(f);

  return new_fd;
}

注意,这里还需要修改一下 param.h 中的 NOFILE,将每个进程能打开的最大文件数从 16 增加一些,这是因为测试代码使用了一个 110 的大 fd。

// kernel/include/param.h
#define NOFILE      256  // open files per process

getdents

FAT32 目录的物理存储

一般来讲,我们很容易直观上认为文件系统是像 “树” 一样组织的,这在逻辑上没错。

但在物理磁盘上,一个目录的内容其实是线性存储的。如果你还记得 ICS 的知识,就应该知道磁盘是由很多扇区组成的,如果你忘了,可以参考 这里 回顾一下。

那么,操作系统是如何完成这个从物理的线性存储到逻辑的树形存储的转换的呢?答案就是目录项。

假设让你来设计 实际物理存放 文件名的数据结构,你会怎么设计?是直接使用一个类似如下的结构体吗?

#define FAT32_MAX_PATH      260
struct dirent {
    char  filename[FAT32_MAX_FILENAME + 1];
}

可以是可以,然而这样太差了,一般的目录名根本用不了这么多,这么存绝大多数短名文件会浪费空间。

所以,FAT32 的目录项采用了变长存储(fat32.c):

typedef struct short_name_entry {
    char        name[CHAR_SHORT_NAME];
    uint8       attr;
    uint8       _nt_res;
    uint8       _crt_time_tenth;
    uint16      _crt_time;
    uint16      _crt_date;
    uint16      _lst_acce_date;
    uint16      fst_clus_hi;
    uint16      _lst_wrt_time;
    uint16      _lst_wrt_date;
    uint16      fst_clus_lo;
    uint32      file_size;
} __attribute__((packed, aligned(4))) short_name_entry_t;

typedef struct long_name_entry {
    uint8       order;
    wchar       name1[5];
    uint8       attr;
    uint8       _type;
    uint8       checksum;
    wchar       name2[6];
    uint16      _fst_clus_lo;
    wchar       name3[2];
} __attribute__((packed, aligned(4))) long_name_entry_t;

union dentry {
    short_name_entry_t  sne;
    long_name_entry_t   lne;
};

一个 32 字节的目录项,要么是 SNE,要么是 LNE,一个完整的目录项都是由 1 个 SNE 和若干个(可以是 0 个) LNE 组成的,顺序是 LNE 在前,SNE 在后。

上面代码中,以 _ 开头的字段实际上未使用,你大致阅读就可以发现,目录项有两种类型:

  1. 短文件名目录项(Short Name Entry, SNE):这是经典 8.3 格式(CHAR_SHORT_NAME = 11,8 个字符主文件名 + 3 个字符扩展名,即要求主文件名不超过 8 个字符,扩展名不超过 3 个字符)的目录项。它包含了文件的所有元数据。
  2. 长文件名目录项(Long Name Entry, LNE):一个长文件名会被切分成多个 13(上述代码中的 name1name2name3) 字符的片段,每个片段存放在一个 LNE 中。多个 LNE 会紧挨着放在它们对应的 SNE 前面。LNE 的 attr 字段有一个特殊的 ATTR_LONG_NAME 标记,然后 order 字段表示 LNE 的顺序。

什么是 8.3 格式?如果一个文件名很短,一个 SNE 就足够表示的话(直接存在 SNEname 字段里),那么就不会使用 LNE;反之,这个文件名会超出 CHAR_SHORT_NAME 的长度,那么就会使用 LNE,而此时 SNEname 字段会用于校验 LNE 的完整性(通过截取片段实现,比如 Annual Financial Report 2023.docx 会被处理成 ANNUAL~1.DOC)。

比如,一个名为 KERNEL.C 的文件,就满足 SNE 的 8.3 格式,那么在 SNEname 字段中会这样存储(以字符数组表示):

['K', 'E', 'R', 'N', 'E', 'L', ' ', ' ', 'C', ' ', ' ']
  • KERNEL 占 6 字节,后面补 2 个空格。
  • C 占 1 字节,后面补 2 个空格。
  • 不含 .,隐式分割。

现在,我们已经大概理解了目录项的名字的存储方式,那么,给定一个目录项,我们如何获取他的数据呢?

回顾结构体,你会发现 SNE 中除了 name 字段,其他字段都是文件的元数据,比如文件大小、属性这些一眼就知道是啥的,除此之外,还有两个字段 fst_clus_hifst_clus_lo,他们是什么呢?

uint16      fst_clus_hi;
uint16      fst_clus_lo;

他们实际上是文件的起始簇号的高 16 位和低 16 位。

簇(Cluster) 是 FAT32 管理磁盘空间的最小单位,它由一个或多个连续的 扇区(Sector) 组成(在我们的 xv6 中,1 簇 = 8 扇区 = 4096 字节)。一个文件的数据就是存储在一个或多个簇里,这些簇通过 FAT 表(File Allocation Table)形成一个链表,不要求在磁盘上连续。上述两个字段拼在一起就是文件的起始簇号,也就是链表的头结点。

那么,现在我们也知道如何从一个文件的目录项中获取他的数据了:

  1. 根据 SNEfst_clus_hifst_clus_lo 字段,拼出文件的起始簇号。
  2. 根据文件的起始簇号,通过 FAT 表找到文件的簇链的头结点。
  3. 顺着链表读取每个簇的数据,直到读完整个文件。

但是,我们还有一个问题没有解决:如何获取一个目录的子成员(子目录项)呢?

这里我们要指出,目录实际上也是一种文件,它的 “内容” 就是一串 32 字节的目录项(含 LNESNE)。

所以,获取一个目录的子成员(子目录项)实际上和读取一个文件差不了太多,都是先确定这个父目录的数据簇链,然后连续读取多个簇的数据,再把数据拼起来,得到完整数据后,按照若干个 LNE 和一个 SNE 的顺序,逐个解析,从而得到每个子目录项的元数据。

enext

getdents 的实现严重依赖于一个辅助函数 enext(entry next,定义在 fat32.c)。这个函数的作用就是:给定一个目录 dp 和一个起始偏移量 off,找到并返回下一个 有效 的目录项。

enext 的具体流程不需要关心,你就知道它的输入输出如下即可:

int enext(struct dirent *dp, struct dirent *ep, uint off, int *count);

参数:

  • dp:目录 dirent 指针。
  • ep:输出参数,用于存储找到的目录项。
  • off:起始偏移量。
  • count:输出参数,用于存储找到的目录项的数量。

返回值:

  • 成功:返回 0,并将找到的目录项信息填充到 ep 中。
  • 失败:返回 -1。

sys_getdents 的实现

功能:获取目录的条目。

int getdents(unsigned int fd, struct linux_dirent *dirp, unsigned int count);

参数:

  • fd:打开的目录文件描述符。
  • dirp:用户缓冲区,用于接收目录项。
  • count:缓冲区大小(字节)。

返回值:

  • 成功:返回读入的字节数;返回 0 表示目录结束。
  • 失败:返回 -1,并设置 errno

测试仓库说明和标准文档出入不大。

#define SYS_getdents64 61

getdents 是 “get directory entries” 的缩写,是 lsfind 这类命令的底层实现基础。它允许用户程序像读文件一样,一块一块地读取一个目录下的所有 逻辑目录项 的信息。

有了对底层存储和 enext 的理解,sys_getdents 的逻辑就清晰了。它本质上是一个循环,不断调用 enext 来获取目录项,然后把内核态的 struct dirent 格式转换成用户态需要的 struct dirent64 格式,再拷贝到用户空间。

为了对齐 Linux 的接口,我们需要定义一个新的结构体 dirent64,它是 SNELNE 这两种 物理目录项 的上层封装,我们也可以叫他 逻辑目录项,它实际上已经可以用于用户态编程了,它包含了:

  • d_ino:inode 号,FAT32 不支持,直接设为 0 就行
  • d_off:偏移量,记录的是当前这个 dirent64 逻辑目录项读完后,下一个逻辑目录项在父目录数据流中的起始字节位置。每次读取完一个 逻辑目录项 后,偏移量增加 count * 32,即经过的 物理目录项 的数量乘以 32。
  • d_reclen:记录长度,固定为 sizeof(struct dirent64)。记录当前 dirent64 结构体在返回给用户的缓冲区中所占的总字节数,用于帮助程序在缓冲区内定位到下一个 dirent64
  • d_type:文件类型,根据 struct direntattribute 字段判断,如果是目录,则设为 DT_DIR,否则设为 DT_REG
  • d_name:文件名,从 struct direntfilename 字段拷贝过来

注意,getdents 系统调用实际上可以被连续多次调用,每次调用都会从上一次调用结束的位置开始,继续读取目录项,直到读取到目录末尾。

// kernel/include/fat32.h
#define DT_UNKNOWN 0
#define DT_FIFO 1
#define DT_CHR 2
#define DT_DIR 4
#define DT_BLK 6
#define DT_REG 8
#define DT_LNK 10
#define DT_SOCK 12
#define DT_WHT 14

struct dirent64 {
    uint64 d_ino;
    uint64 d_off;
    unsigned short d_reclen;
    unsigned char d_type;
    char d_name[FAT32_MAX_FILENAME + 1];
};

sys_getdents 的实现逻辑是一个循环:

  1. 从用户态获取文件描述符 fd、一个用于存放结果的缓冲区 addr 和缓冲区的长度 len
  2. 检查 fd 对应的文件是否是一个目录,以及是否可读。
  3. 在一个 while 循环中,不断地从目录中读取下一个目录项,直到缓冲区装满或者目录读取完毕。
    • 调用 enext(f->ep, &de, f->off, &count) 函数,这个函数会从文件 f 的当前偏移 f->off 处开始,查找下一个有效的目录项,并将其信息填充到 de 中。
    • 如果 enext 成功返回,说明找到了一个目录项。
    • 我们将内核的 struct dirent de 里的信息,转换成用户态需要的 struct dirent64 out 格式。
    • 调用 copyout2out 拷贝到用户空间的 addr 处。
    • 更新 addr 指针和已读取的字节数 nread
    • 更新文件偏移 f->off,准备下一次读取。
  4. 循环结束后,返回总共读取的字节数 nread
/**
 * @brief 实现 getdents 系统调用,读取目录项
 * @param fd 目录的文件描述符
 * @param addr 用户空间缓冲区的地址,用于存放读取结果
 * @param len 缓冲区的长度
 * @return 成功则返回读取的字节数,读到目录末尾返回 0,失败返回 -1
 * @note getdents 是 ls 等命令的底层实现。
 */
uint64 sys_getdents(void) {
  int fd, len;
  uint64 addr;
  struct file* f;
  int nread = 0;
  int reclen = (int)sizeof(struct dirent64);

  if (argfd(0, &fd, &f) < 0 || argaddr(1, &addr) < 0 || argint(2, &len) < 0) {
    return -1;
  }

  // 缓冲区太小,至少要能装下一个目录项
  if (len < reclen) {
    return 0;
  }

  if (fd < 0 || fd >= NOFILE) {
    return -1;
  }

  // 必须是目录且可读
  if (f->readable == 0) {
    return -1;
  }

  if (f->ep == 0 || !(f->ep->attribute & ATTR_DIRECTORY)) {
    return -1;
  }

  // 循环读取,直到缓冲区满或目录读完
  while (nread + reclen <= len) {
    struct dirent de;
    int count = 0;
    int ret;

    elock(f->ep);
    // enext 会找到下一个有效的目录项,并填充到 de 中
    // 它会跳过空目录项和 LNE,直接返回一个完整的 SNE
    while ((ret = enext(f->ep, &de, f->off, &count)) == 0) {
      f->off += count * 32;
    }
    eunlock(f->ep);

    if (ret == -1) { // 读到目录末尾
      return nread; // 退出循环,返回已经读取的字节数
    }

    // 将内核的 dirent 格式转换为用户态的 dirent64 格式
    struct dirent64 out;
    out.d_ino = 0; // FAT32 没有 inode number 的概念
    out.d_off = f->off;
    out.d_reclen = sizeof(struct dirent64);
    if (de.attribute & ATTR_DIRECTORY) {
      out.d_type = DT_DIR;
    }
    else {
      out.d_type = DT_REG;
    }
    safestrcpy(out.d_name, de.filename, FAT32_MAX_FILENAME + 1);

    // 拷贝到用户空间
    if (copyout2(addr, (char*)&out, sizeof(out)) < 0) {
      return -1;
    }

    // 更新指针和计数器
    addr += sizeof(out);
    nread += sizeof(out);
    f->off += count * 32;
  }

  return nread;
}

mkdirat

功能:在指定目录下创建子目录。

int mkdirat(int dirfd, const char *pathname, mode_t mode);

参数:

  • dirfd:目录文件描述符;可用特殊值 AT_FDCWD 表示相对当前工作目录。
  • pathname:要创建的目录路径(可相对 dirfd)。
  • mode:权限模式位(会受进程 umask 掩码影响)。

返回值:

  • 成功:返回 0。
  • 失败:返回 -1,并设置 errno

测试仓库说明和标准文档出入不大。

#define SYS_mkdirat 34

mkdiratmkdirat 版本,它允许我们基于一个目录文件描述符 dirfd 来创建新的目录,这比单纯依赖当前工作目录要更安全和灵活。

这里和我们在实现 openat 时类似,只需要在 mkdir 的基础上,根据新传入的 dirfd 解析出正确的 path 即可。

实现 sys_mkdirat 的逻辑非常直观:

  1. 解析 dirfd, path, mode 三个参数。
  2. 调用我们在 Part2 中实现的 get_path(path, dirfd) 函数,这个函数会处理 dirfdpath 的各种组合(绝对路径、相对路径、AT_FDCWD),最终将 path 转换为一个内核可以直接使用的绝对路径。
  3. 调用 xv6 文件系统提供的 create(path, T_DIR, 0) 函数来创建目录。T_DIR 告诉 create 我们要创建的是一个目录。
  4. create 函数会返回一个锁住的 dirent,我们需要解锁并释放它,然后返回成功。
/**
 * @brief 实现 mkdirat 系统调用,在指定位置创建目录
 * @param dirfd 目录文件描述符
 * @param path 目录路径
 * @param mode 创建模式(本次实验中未使用)
 * @return 成功返回 0,失败返回 -1
 * @note 核心是复用 get_path 将路径转换为绝对路径。
 */
uint64
sys_mkdirat(void)
{
  char path[FAT32_MAX_PATH];
  int dirfd, mode;
  struct dirent* ep;

  if (
    argint(0, &dirfd) < 0 ||
    argstr(1, path, FAT32_MAX_PATH) < 0 ||
    argint(2, &mode) < 0) {
    return -1;
  }

  if (strlen(path) == 0) {
    return -1;
  }

  // 将路径转换为绝对路径
  if (get_path(path, dirfd) < 0) {
    return -1;
  }

  // 调用底层 create 函数创建目录
  ep = create(path, T_DIR, 0);
  if (ep == NULL) {
    return -1;
  }

  // 释放资源并返回
  eunlock(ep);
  eput(ep);
  return 0;
}

unlinkat

删除目录项(对普通文件是 “解除链接”;实际回收取决于是否仍有打开引用)。

int unlinkat(int dirfd, const char *pathname, int flags);

参数:

  • dirfd:目录文件描述符;AT_FDCWD 表示相对当前工作目录。
  • pathname:要删除的路径(可相对 dirfd)。
  • flags:支持 AT_REMOVEDIR(删除目录);为 0 时删除非目录名称。

返回值:

  • 成功:返回 0。
  • 失败:返回 -1,并设置 errno

说明:

  • 若目标是目录且未设置 AT_REMOVEDIR 会失败(EPERM)。
  • 符号链接的删除作用于链接自身,不跟随到目标。

测试仓库说明和标准文档出入不大。

#define SYS_unlinkat 35

unlinkat 的功能是移除文件的链接,当链接数为 0 时,文件就被删除了,它也可以用来删除空目录,它是 rm 命令的底层实现

flags 参数是区分这两种行为的关键。

实现逻辑稍微复杂一些,因为它需要处理文件和目录两种情况:

  1. 解析 dirfd, path, flags 三个参数。
  2. 调用 get_path(path, dirfd) 转换为绝对路径。
  3. 调用 ename(path) 获取路径对应的 dirent
  4. 核心判断逻辑
    • 检查 dirent 是否是一个挂载点,如果是,则不允许删除。
    • 如果目标是目录 (ATTR_DIRECTORY):
      • 检查 flags 是否包含 AT_REMOVEDIR,如果不包含,则说明用户意图删除文件而非目录,操作非法,返回错误。
      • 检查目录是否为空 (isdirempty),如果不为空,不能删除,返回错误。
    • 如果目标是文件:
      • 检查 flags 是否包含 AT_REMOVEDIR,如果包含,则说明用户意图删除目录而非文件,操作非法,返回错误。
  5. 加锁,调用 eremove(ep) 执行删除操作,然后解锁并释放资源。
/**
 * @brief 实现 unlinkat 系统调用,删除文件或目录
 * @param dirfd 目录文件描述符
 * @param path 文件或目录的路径
 * @param flags 标志位,AT_REMOVEDIR 用于删除目录
 * @return 成功返回 0,失败返回 -1
 * @note 需要根据 flags 和文件类型(文件/目录)进行精细的判断。
 */
uint64 sys_unlinkat(void) {
  char path[FAT32_MAX_PATH];
  int dirfd, flags;
  struct dirent* ep;

  if (
    argint(0, &dirfd) < 0 ||
    argstr(1, path, FAT32_MAX_PATH) < 0 ||
    argint(2, &flags) < 0
  ) {
    return -1;
  }

  if (strlen(path) == 0) {
    return -1;
  }

  if (get_path(path, dirfd) < 0) {
    return -1;
  }

  char* basename = path;
  char* p = path;

  // 禁止删除 "." 和 ".."
  // 找到最后一个 '/'
  while (*p) {
    if (*p == '/') {
      basename = p + 1;
    }
    p++;
  }
  // 现在 basename 指向路径的最后一部分
  if (strncmp(basename, ".", 1) == 0 || strncmp(basename, "..", 2) == 0) {
    return -1;
  }

  // 获取路径对应的 dirent
  ep = ename(path);
  if (ep == NULL) {
    return -1;
  }
  elock(ep);
  if (ep->attribute & ATTR_DIRECTORY) {
    eremove(ep);
  }

  // 不允许删除挂载点
  if (is_mounted(ep)) {
    eunlock(ep);
    eput(ep);
    return -1;
  }

  // 根据文件类型和 flags 进行判断
  if (ep->attribute & ATTR_DIRECTORY) {
    // 意图删除目录,但 flags 不对
    if (!(flags & AT_REMOVEDIR)) {
      eunlock(ep);
      eput(ep);
      return -1;
    }
    // 目录非空
    if (!isdirempty(ep)) {
      eunlock(ep);
      eput(ep);
      return -1;
    }
  }
  else {
    // 意图删除文件,但 flags 不对
    if (flags & AT_REMOVEDIR) {
      eunlock(ep);
      eput(ep);
      return -1;
    }
  }

  // 执行删除
  elock(ep->parent);
  eremove(ep);
  eunlock(ep->parent);
  eunlock(ep);
  eput(ep);
  return 0;
}

fstat

获取文件状态信息(不跟随符号链接,因为针对已打开的 fd)。

int fstat(int fd, struct stat *statbuf);

参数:

  • fd:已打开文件描述符
  • statbuf:输出缓冲区,返回文件的元数据

返回值:

  • 成功:返回 0,并填充 statbuf
  • 失败:返回 -1,并设置 errno

测试仓库说明和标准文档出入不大。

#define SYS_fstat 80

直接调用已经 xv6-k210 已经实现的 sys_fstat 可以通过本地测试,但是远程测试会有一个点无法通过。

fstat 系统调用本身没有啥变化,不过测试样例是在 Linux 标准下编译的,所以它的 struct stat 和 xv6-k210 现有的有些出入。

这里只需要仿照 Linux 的标准,增加一个 kstat 结构体即可,然后基本上也就是按照字面意思填填字段就行了:

// kernel/include/stat.h
struct kstat {
  uint64 st_dev;
  uint64 st_ino;
  uint st_mode;
  uint32 st_nlink;
  uint32 st_uid;
  uint32 st_gid;
  uint64 st_rdev;
  unsigned long __pad;
  long int st_size;
  uint32 st_blksize;
  int __pad2;
  uint64 st_blocks;
  long st_atime_sec;
  long st_atime_nsec;
  long st_mtime_sec;
  long st_mtime_nsec;
  long st_ctime_sec;
  long st_ctime_nsec;
  unsigned __unused[2];
};

我们来看一下关键字段的含义以及在 ekstat 中是如何填充的:

/**
 * @brief 将 dirent 信息填充到 kstat 结构体中
 * @param de 源 dirent
 * @param st 目标 kstat 结构体指针
 * @note 这是 fstat 的核心辅助函数,用于对齐 Linux 的 stat 结构。
 */
void ekstat(struct dirent* de, struct kstat* st)
{
    memset(st, 0, sizeof(*st));
    st->st_dev = de->dev;
    st->st_ino = 0;

    st->st_mode = (de->attribute & ATTR_DIRECTORY) ? DT_DIR : DT_REG;

    st->st_nlink = 1;
    st->st_rdev = 0;
    st->st_size = de->file_size;
    st->st_blksize = 4096;
    st->st_blocks = (st->st_size + 511) / 512;
}
  • uint64 st_dev:文件所在的设备号。我们直接用 de->dev 填充。
  • uint64 st_ino:Inode 号。FAT32 没有 Inode 的概念,所以我们填 0。
  • uint st_mode:文件类型和权限。我们通过检查 de->attribute 是否有 ATTR_DIRECTORY 标志,来决定填充 DT_DIR(目录)还是 DT_REG(普通文件)。
  • uint32 st_nlink:硬链接数量。FAT32 也不支持硬链接,所以我们简单地填 1。
  • long int st_size:文件大小(字节)。直接用 de->file_size 填充。
  • uint32 st_blksize:文件系统 I/O 操作的最佳块大小。填充为 FAT32 文件系统的簇(Cluster)的大小,所以我们填常见默认值 4096 即可。
  • uint64 st_blocks:文件占用的块数(固定以 512 字节为一块,和 st_blksize 无关)。计算方式是 (st_size + 511) / 512,这是一个向上取整的技巧。

ekstat 的实现

对比旧的 estat,新的 ekstat 提供了更丰富、更符合 Linux 标准的信息,特别是 st_modest_blksizest_blocks,这些都是通过测试的关键。

最后,改一下 sys_fstat 调用的 file.c 中的 filestat 函数,把 estat 换成 ekstat,并将 struct stat 换成 struct kstat 即可。

// Get metadata about file f.
// addr is a user virtual address, pointing to a struct stat.
int
filestat(struct file *f, uint64 addr)
{
  // struct proc *p = myproc();
  // 这行修改为使用 kstat 结构体
  struct kstat st;

  if(f->type == FD_ENTRY){
    elock(f->ep);
    // 这行修改为使用 ekstat 函数
    ekstat(f->ep, &st);
    eunlock(f->ep);
    // if(copyout(p->pagetable, addr, (char *)&st, sizeof(st)) < 0)
    if(copyout2(addr, (char *)&st, sizeof(st)) < 0)
      return -1;
    return 0;
  }
  return -1;
}

mount / umount

mount 是 Unix/Linux 系统中一个非常核心的概念,它允许我们将一个存储设备(比如另一个硬盘分区)的根目录 “附加” 到当前文件系统的一个现有目录上。这个现有目录就被称为 “挂载点”。

mount 将文件系统或绑定挂载到目标路径。

int mount(const char *source, const char *target,
          const char *filesystemtype, unsigned long mountflags,
          const void *data);

参数(mount):

  • source:块设备路径、绑定源路径或特殊文件系统标识(如 "proc");可为 NULL/"none" 取决于类型
  • target:挂载点目录(需已存在)
  • filesystemtype:文件系统类型(如 "ext4""proc""tmpfs" 等)
  • mountflags:挂载标志位组合,如 MS_RDONLYMS_NOSUIDMS_NODEVMS_NOEXECMS_RELATIMEMS_BINDMS_REMOUNTMS_SHARED/MS_PRIVATE
  • data:可选的文件系统特定选项,通常为以逗号分隔的 "key=value" 字符串或类型特定结构

返回值:

  • 成功:返回 0
  • 失败:返回 -1,并设置 errno

umount2 卸载挂载点;若有占用(打开文件、当前工作目录在该挂载)可能失败。

int umount2(const char *target, int flags);

参数:

  • target:挂载点路径。
  • flags:如 MNT_FORCE(强制,仅部分网络 FS)、MNT_DETACH(懒卸载)、MNT_EXPIREUMOUNT_NOFOLLOW 等。

返回值:

  • 成功:返回 0。
  • 失败:返回 -1,并设置 errno

测试仓库说明和标准文档出入不大,不过这些字段也不需要太过在意,因为我们不涉及实际的挂载逻辑

#define SYS_mount 40
#define SYS_umount2 39

根据提示,本次实验中的 mountumount 并不需要实现真正的挂载逻辑,只需要 “假装” 成功,即直接返回 0 即可通过本地和远程的所有测试。

助教的原话是:mount 的逻辑比较奇怪,不同系统的差距太大了,所以没必要细扣。

但是,这里我们还是稍微维护一个挂载点列表,以便在 unlinkat 等操作中能检查一个目录是否是挂载点。

为此,我们定义了 struct mount 结构体和全局的 mounts 数组。

// kernel/include/fat32.h
#define NMOUNT 16

struct mount {
    struct dirent* de;
    char path[FAT32_MAX_PATH];
    int used;
};

// kernel/sysfile.c
struct mount mounts[NMOUNT];

sys_mount 的实现非常简单:找到一个空闲的 mount 槽位,记录下挂载点的信息即可。sys_umount 则是根据路径找到记录并清除它。

/**
 * @brief 实现 mount 系统调用(伪实现)
 * @param src 源设备(未使用)
 * @param dst 挂载点路径
 * @param fstype 文件系统类型(未使用)
 * @param flags 标志(未使用)
 * @param data 数据(未使用)
 * @return 成功返回 0,失败返回 -1
 * @note 仅记录挂载点信息,不执行实际挂载操作。
 */
uint64 sys_mount(void) {
  char src[FAT32_MAX_PATH];
  char dst[FAT32_MAX_PATH];
  char fstype[32];
  int flags;
  uint64 data;

  if (
    argstr(0, src, FAT32_MAX_PATH) < 0 ||
    argstr(1, dst, FAT32_MAX_PATH) < 0 ||
    argstr(2, fstype, sizeof(fstype)) < 0 ||
    argint(3, &flags) < 0 ||
    argaddr(4, &data) < 0
  ) {
    return -1;
  }

  if (get_path(dst, AT_FDCWD) < 0) {
    return -1;
  }

  struct dirent* dst_ep = ename(dst);
  if (dst_ep == NULL) {
    return -1;
  }

  elock(dst_ep);
  if (!(dst_ep->attribute & ATTR_DIRECTORY)) {
    eunlock(dst_ep);
    eput(dst_ep);
    return -1;
  }

  // 检查是否是挂载点,实现逻辑见后文,实际上没用,可以删掉
  if (is_mounted(dst_ep) || find_mount(dst) >= 0) {
    eunlock(dst_ep);
    eput(dst_ep);
    return -1;
  }

  int idx = -1;
  for (int i = 0; i < NMOUNT; i++) {
    if (!mounts[i].used) {
      idx = i;
      break;
    }
  }
  if (idx == -1) {
    eunlock(dst_ep);
    eput(dst_ep);
    return -1;
  }
  mounts[idx].de = edup(dst_ep);
  mounts[idx].used = 1;
  safestrcpy(mounts[idx].path, dst, FAT32_MAX_PATH);
  eunlock(dst_ep);
  eput(dst_ep);
  return 0;
}

/**
 * @brief 实现 umount 系统调用(伪实现)
 * @param path 挂载点路径
 * @param flags 标志(未使用)
 * @return 成功返回 0,失败返回 -1
 * @note 仅清除挂载点信息,不执行实际卸载操作。
 */
uint64 sys_umount(void) {
  char path[FAT32_MAX_PATH];
  int flags;

  if (
    argstr(0, path, FAT32_MAX_PATH) < 0 ||
    argint(1, &flags) < 0
  ) {
    return -1;
  }

  if (get_path(path, AT_FDCWD) < 0) {
    return -1;
  }

  int idx = find_mount(path);
  if (idx < 0) {
    return -1;
  }

  if (mounts[idx].de) {
    eput(mounts[idx].de);
  }

  mounts[idx].de = NULL;
  safestrcpy(mounts[idx].path, "", FAT32_MAX_PATH);
  mounts[idx].used = 0;

  return 0;
}

这里额外实现 is_mountedfind_mount 这两个辅助函数,它们通过遍历 mounts 数组来检查一个 dirent 或路径是否是挂载点。

// kernel/fat32.c

// 全局挂载点数组的外部声明
extern struct mount mounts[NMOUNT];

/**
 * @brief 检查一个 dirent 是否是挂载点
 * @param de 要检查的 dirent
 * @return 如果是挂载点返回 1,否则返回 0
 */
int is_mounted(const struct dirent* de) {
    for (int i = 0; i < NMOUNT; i++) {
        if (mounts[i].used && mounts[i].de == de) {
            return 1;
        }
    }
    return 0;
}

/**
 * @brief 根据路径查找挂载点
 * @param path 要查找的路径
 * @return 如果找到,返回 mount 数组的索引,否则返回 -1
 */
int find_mount(const char* path) {
    for (int i = 0; i < NMOUNT; i++) {
        if (mounts[i].used && strncmp(mounts[i].path, path, FAT32_MAX_PATH) == 0) {
            return i;
        }
    }
    return -1;
}

💾

  •  

更适合北大宝宝体质的 xv6 OS Lab 踩坑记 - Part2

2025年10月11日 11:56

[!CAUTION]

致各位同学:本笔记的撰写目的是用作参考,请勿直接抄袭,否则后果自负。

另外,请注意,本文撰写基于我修改过的 initcode.SMakefile 文件,如果你是基于原始仓库所编写的,可能需要手动进行一些初始化修改。

注意:此部分在 Part3 之后完成。

本部分包含 3 个测试样例,但是其中 mmap 依赖于 openat 的实现,而 open 测例实际上也同样依赖于 openat,所以我们实际上可以在本部分同时完成如下系统调用(按照依赖顺序):

  • open
  • openat
  • brk
  • mmap
  • munmap

其中,brk 不依赖于 openat,所以其实上我在仓库里的最先完成的是 brk 系统调用。但为了让行文逻辑更加通顺,我将先进行文件系统相关的讲解与相关系统调用的实现,然后再介绍内存管理相关的系统调用及其实现

文件系统

要理解 openat 的实现,必须先弄清内核中文件相关的各个数据结构是如何串联起来的。这可能需要阅读如下部分的源码(如果你对 ICS 已经忘的差不多了,也可以回看一下 ICS 第十章系统级 I/O):

  • kernel/include/proc.h
  • kernel/include/file.h
  • kernel/include/fat32.h

在更早实现的 Part3 中,我们介绍了 进程控制块(Process Control Block,PCB) 的概念,简单来讲其就是管理一个进程的核心数据结构。其定义在 kernel/include/proc.h 中的 struct proc。 它是我们串联起文件系统相关数据的起点。

// Per-process state
struct proc {
  struct spinlock lock;

  // p->lock must be held when using these:
  enum procstate state;        // Process state
  struct proc *parent;         // Parent process
  void *chan;                  // If non-zero, sleeping on chan
  int killed;                  // If non-zero, have been killed
  int xstate;                  // Exit status to be returned to parent's wait
  int pid;                     // Process ID

  // these are private to the process, so p->lock need not be held.
  uint64 kstack;               // Virtual address of kernel stack
  uint64 sz;                   // Size of process memory (bytes)
  pagetable_t pagetable;       // User page table
  pagetable_t kpagetable;      // Kernel page table
  struct trapframe *trapframe; // data page for trampoline.S
  struct context context;      // swtch() here to run process
  struct file *ofile[NOFILE];  // Open files
  struct dirent *cwd;          // Current directory
  char name[16];               // Process name (debugging)
  int tmask;                    // trace mask
};

每个进程的 PCB 中都有一个数组 struct file *ofile[NOFILE];,这被称为 进程打开文件表。当用户程序调用 openopenat 成功后,内核返回一个小的非负整数,这就是 文件描述符(File Descriptor,fd),它本质上就是 ofile 数组的索引。

再接着走,我们来到了定义在 kernel/include/file.h 中的 struct file 打开文件实例。这个结构体代表了一个被打开的文件在内存中的状态,它不是文件本身,而是一个 “句柄”。它包含了重要的运行时信息,比如读写权限 (readable, writable) 和当前的读写偏移量 (off)。多个进程打开同一个文件,会得到不同的 struct file 实例。

struct file {
  enum { FD_NONE, FD_PIPE, FD_ENTRY, FD_DEVICE } type;
  int ref; // reference count
  char readable;
  char writable;
  struct pipe *pipe; // FD_PIPE
  struct dirent *ep;
  uint off;          // FD_ENTRY
  short major;       // FD_DEVICE
};

最后,我们来到了定义在 kernel/include/fat32.h 中的 struct dirent 目录项 / 索引节点(Directory Entry)。这个结构体代表了一个文件在磁盘上的 元数据,比如文件名、文件大小、属性(是目录还是文件)以及指向数据块的指针等。所有打开同一个文件的 struct file 实例,它们的 ep 指针都指向同一个 struct dirent

struct dirent {
    char  filename[FAT32_MAX_FILENAME + 1];
    uint8   attribute;
    // uint8   create_time_tenth;
    // uint16  create_time;
    // uint16  create_date;
    // uint16  last_access_date;
    uint32  first_clus;
    // uint16  last_write_time;
    // uint16  last_write_date;
    uint32  file_size;

    uint32  cur_clus;
    uint    clus_cnt;

    /* for OS */
    uint8   dev;
    uint8   dirty;
    short   valid;
    int     ref;
    uint32  off;            // offset in the parent dir entry, for writing convenience
    struct dirent *parent;  // because FAT32 doesn't have such thing like inum, use this for cache trick
    struct dirent *next;
    struct dirent *prev;
    struct sleeplock    lock;
};

稍微借用一下程设的知识来讲, filedirent 其实就相当于:

  • dirent 就是类的定义,定义了最基本的元数据
  • file 就是 dirent 这个类的实例,实例化出来之后会加一些别的字段来实现功能,但是不同的实例指向同一个文件(继承同一个类)

$$ \texttt{struct proc} \xrightarrow{\texttt{ofile[fd]}} \texttt{struct file} \xrightarrow{\texttt{ep}} \texttt{struct dirent} $$

这完全对应 ICS 中的内容:

proc_file_dirent

在这张图中:

  • 左侧的描述符表就是每个进程控制块 struct proc 中的 struct file *ofile[NOFILE]; 数组
    • 表中的 fd 0, fd 1, fd 2 等就是我们常说的 文件描述符 (file descriptor)。它本质上就是这个 ofile 数组的 索引
    • ofile 数组的每个槽位存放的是一个 指针,指向中间这一列的 “打开文件表” 中的一个条目。
  • 中间的打开文件表实际上是对应的是内核中全局的 struct file 实例(file.h)。
    • 内核会有一个 全局共享file 结构体池 ftable(见 file.c),filealloc() 函数会从中分配一个未使用的 struct file
    • 图中的 refcnt 字段,对应我们 struct file 中的 ref 成员,即 引用计数,它记录了有多少个来自不同进程 ofile 表的指针指向了这个 struct file 实例。
    • 每个 struct file 实例通过一个指针指向右侧的 v-node 表中的一个条目。这对应 struct file 中的 ep 指针。
  • 右侧的 v-node 表对应的是内核中缓存的 struct dirent 实例,它代表了磁盘上文件的 元数据
    • 图中的 文件访问文件大小文件类型 等字段,对应我们 struct dirent 中的 attributefile_size 等成员。这些信息是文件的固有属性,与文件被如何打开无关。

理解这些内容,才能让我们正确理解如何实现 openat 等 API,以及如何在后续内存管理时正确处理父子进程的 VMA(即 forkclone 调用的修改)。

openat

先讲一下原始已有的 open 系统调用(sysfile.c)。

open 是最基础的文件操作函数,用于获取一个文件的句柄(文件描述符),以便后续进行读写等操作。

根据 官方文档,我们得到其在 Linux 下的标准用法:

int open(const char *pathname, int flags, ... /*, mode_t mode */);

参数:

  • pathname:一个字符串,表示要打开或创建的文件的路径。路径可以是绝对路径(以 / 开头)或相对路径(相对于当前工作目录)。
  • flags:一个整数,通过位掩码(bitmask)组合了多个标志位,用于控制文件的打开方式。常见的标志有 O_RDONLY (只读), O_WRONLY (只写), O_RDWR (读写), O_CREATE (文件不存在则创建), O_APPEND (追加模式) 等。
  • mode:一个可选参数,仅在 flags 中包含 O_CREATE 标志时有效。它指定了新创建文件的权限位。

返回值:

  • 成功:返回一个新的、非负整数的文件描述符(file descriptor)。
  • 失败:返回 -1,并设置 errno

然而,xv6-k210 这个框架提供的 open 实际上只支持 pathnameflags 两个参数,甚至还 非常具有迷惑性地将 flags 参数实现为了 omode 参数!

要阅读明白 open 函数的代码,你可能需要大致知道一下如下以 e(entry)开头的 API,他们是 struct dirent 这个核心数据结构的一系列操作函数。它们构成了文件系统操作的底层接口。

  • ename(path)Entry Name。根据路径名(path)查找并返回对应的 dirent可以处理相对目录
  • elock(ep)Entry Lock。对一个 dirent 加锁,防止并发访问导致数据不一致。
  • eunlock(ep)Entry Unlock。解锁。
  • eput(ep)Entry Put (释放)。减少一个 dirent 的引用计数。当引用计数为零时,回收该 dirent 占用的内存资源。
  • edup(ep)Entry Duplicate (复制)。增加 dirent 的引用计数,通常在有新的指针指向它时调用。
  • etrunc(ep)Entry Truncate。清空(截断)一个文件 dirent 的所有数据,使其大小变为 0。

sys_open 的核心是将一个文件路径转换为一个文件描述符。

它的过程分为三步:

  1. 路径解析:调用 enamecreate 函数,根据路径字符串是绝对路径(以 / 开头)还是相对路径,分别从根目录或当前工作目录开始查找,最终定位到磁盘上的文件元数据(dirent)。

  2. 资源分配:内核在内存中分配两个核心结构:一个全局的 struct file(代表打开的文件实例)和一个进程私有的文件描述符 fd(一个整数)。

  3. 关联返回:将 fd 指向 struct filestruct file 再指向 dirent,完成关联。最后将 fd 这个整数返回给用户程序。

然而,根据测试文档的说明:

#define SYS_openat 56

输入:

  • fd:文件所在目录的文件描述符
  • filename:要打开或创建的文件名。如为绝对路径,则忽略 fd。如为相对路径,且 fdAT_FDCWD,则 filename 是相对于当前工作目录来说的。如为相对路径,且 fd 是一个文件描述符,则 filename 是相对于 fd 所指向的目录来说的
  • flags:必须包含如下访问模式的其中一种:O_RDONLYO_WRONLYO_RDWR。还可以包含文件创建标志和文件状态标志
  • mode:文件的所有权描述。详见 man 7 inode

返回值:

  • 成功执行,返回新的文件描述符
  • 失败,返回 -1
int fd, const char *filename, int flags, mode_t mode;
int ret = syscall(SYS_openat, fd, filename, flags, mode);

可以看到,我们实际上是要实现 open 的增强版 openat

openat 相较于 open 提供了一种更安全、更灵活的文件打开方式,它避免了因当前工作目录改变而导致的竞态条件问题(通过多出来的 dirfd 实现)

根据 官方文档,我们得到其在 Linux 下的标准用法:

int openat(int dirfd, const char *pathname, int flags, ... /*, mode_t mode */);

参数:

  • dirfd:一个目录的文件描述符。pathname 的解析将基于这个目录文件描述符所指向的目录
  • pathname:一个字符串,表示要打开或创建的文件的路径
    • 如果 pathname 是绝对路径,则 dirfd 参数被忽略
    • 如果 pathname 是相对路径,则它是相对于 dirfd 所指定的目录来解析的
    • 如果 dirfd 的值为一个特殊的常量 AT_FDCWD,则相对路径是相对于当前工作目录来解析的(此时 openat 的行为与 open 完全相同)
  • flags:与 open 中的 flags 参数意义相同
  • mode:与 open 中的 mode 参数意义相同

返回值:

  • 成功:返回一个新的、非负整数的文件描述符
  • 失败:返回 -1,并设置 errno

从而我们知道,我么只需要在原有 open 的基础上,增加一个参数 dirfd 即可。

然而,为了支持 dirfd 我们还需要实现一个函数 get_path 来能够解析出这个传入的文件描述符的相对路径(为什么需要见上参数说明),这部分需要使用递归来实现。

我们先在 include/fcntl.h 中进行到 Linux 标准的一些宏参数对齐 / 定义:

#define O_RDONLY  0x000
#define O_WRONLY  0x001
#define O_RDWR    0x002
#define O_APPEND  0x004
#define O_CREATE  0x040 // 更改,原值 0x200
#define O_TRUNC   0x400
#define O_DIRECTORY 0x4000 // 新增,对齐 Linux 标准

#define AT_FDCWD     -100 // 新增,对齐 Linux 标准
#define AT_REMOVEDIR 0x200 // 新增,对齐 Linux 标准

接着在 sysnum.h 中进行系统调用号对齐:

#define SYS_open        55   // 打开文件,基于当前目录或者直接使用绝对路径
#define SYS_openat      56   // 打开文件,基于指定的 fd 所代表的目录或者直接使用绝对路径

然后,参照 open 函数,在 sysfile.c 中进行实现。

先实现 get_path 函数:

/**
 * @brief 递归地获取一个目录条目的绝对路径。
 * @param de        目标目录条目。
 * @param path_buf  用于存储结果的输出缓冲区。
 * @param buf_size  缓冲区的总大小。
 * @return 成功返回 0,失败返回 -1。
 */
static int get_abspath(struct dirent* de, char* path_buf, int buf_size) {
  // 递归退出,已经到达根目录
  if (de == NULL || de->parent == NULL) {
    if (buf_size < 2) {
      return -1;
    }
    strncpy(path_buf, "/", buf_size);
    return 0;
  }
  if (get_abspath(de->parent, path_buf, buf_size) < 0) {
    return -1;
  }
  int parent_len = strlen(path_buf);

  // 非根目录需要追加一个 /
  if (parent_len > 1) {
    if (parent_len + 1 >= buf_size) {
      return -1;
    }
    path_buf[parent_len++] = '/';
    path_buf[parent_len] = '\0';
  }

  safestrcpy(path_buf + parent_len, de->filename, buf_size - parent_len);
  return 0;
}

/**
 * @brief 将路径参数安全地转换为绝对路径。
 * @param path  输入的路径字符串 (in),转换后的绝对路径 (out)。缓冲区大小应为 FAT32_MAX_PATH。
 * @param fd    目录文件描述符 dirfd。
 * @return 成功返回 0,失败返回 -1。
 */
int get_path(char* path, int fd) {
  if (path == NULL) {
    return -1;
  }
  // 绝对路径无需处理
  if (path[0] == '/') {
    return 0;
  }
  // 预处理 './'
  if (path[0] == '.' && path[1] == '/') {
    path += 2;
  }
  char base_path[FAT32_MAX_PATH];
  struct dirent* base_de = NULL;
  // 相对当前目录进行定位
  if (fd == AT_FDCWD) {
    base_de = myproc()->cwd;
  }
  // 相对于指定的 fd 定位
  else {
    if (fd < 0 || fd >= NOFILE) {
      return -1;
    }
    struct file* f = myproc()->ofile[fd];
    if (f == NULL || !(f->ep->attribute & ATTR_DIRECTORY)) {
      return -1;
    }
    base_de = f->ep;
  }
  // 获取绝对路径
  if (get_abspath(base_de, base_path, FAT32_MAX_PATH) < 0) {
    return -1;
  }
  // 使用一个临时缓冲区来安全地拼接最终路径
  char final_path[FAT32_MAX_PATH];

  safestrcpy(final_path, base_path, FAT32_MAX_PATH);
  int base_len = strlen(final_path);

  // 非根目录需要追加一个 /
  if (base_len > 1) {
    if (base_len + 1 >= sizeof(final_path)) {
      return -1;
    }
    final_path[base_len++] = '/';
    final_path[base_len] = '\0';
  }

  safestrcpy(final_path + base_len, path, FAT32_MAX_PATH - base_len);
  safestrcpy(path, final_path, FAT32_MAX_PATH);

  return 0;
}

然后,实现 sys_openat 函数:

/**
 * @brief 实现 openat 系统调用
 * @param dirfd 目录文件描述符
 * @param path 文件路径
 * @param flags 标志位,等于原 open 函数的 omode
 * @param mode 模式,规定当文件被新创建时应有的文件权限
 * @return 文件描述符
 * @note openat 相较于 open 多了一个参数,即目录文件描述符,它会根据 dirfd + path 来确定文件绝对路径,然后复用 open 的逻辑
 * @note sys_open: 相对路径基于当前工作目录,或直接使用绝对路径。
 * @note sys_openat: 相对路径基于指定的 fd 所代表的目录,或直接使用绝对路径。
 */
uint64
sys_openat(void) {
  char path[FAT32_MAX_PATH];
  int dirfd, flags, mode, fd;
  struct file* f;
  struct dirent* ep;

  if (
    argint(0, &dirfd) < 0 ||
    argstr(1, path, FAT32_MAX_PATH) < 0 ||
    argint(2, &flags) < 0 ||
    argint(3, &mode) < 0
    ) {
    return -1;
  }

  if (strlen(path) == 0) {
    return -1;
  }

  // openat 相较于 open 最重要的不同:从 dirfd 将路径转换为绝对路径
  if (get_path(path, dirfd) < 0) {
    return -1;
  }

  if (flags & O_CREATE) {
    ep = create(path, T_FILE, mode);
    if (ep == NULL) {
      return -1;
    }
  }
  else {
    if ((ep = ename(path)) == NULL) {
      return -1;
    }
    elock(ep);
    // 注意下面这行,目的是:如果一个文件是目录,那么禁止任何带有“写”意图的打开方式
    // 这里需要修改第二个条件,判断是否可写的条件从 flags != O_RDONLY 改为 flags & (O_WRONLY | O_RDWR)
    // 测试发现传入的 ep->attribute 为 16,也就是 ATTR_DIRECTORY 0x10
    // 如果使用 flags != O_RDONLY 得到 1 导致判断为真,导致返回 -1
    // 如果使用 flags & (O_WRONLY | O_RDWR) 得到 0x10 & (0x01 | 0x02) = 0,导致判断为假,不会返回 -1
    // 对于 open() 在此处的判断类似
    if ((ep->attribute & ATTR_DIRECTORY) && (flags & (O_WRONLY | O_RDWR))) {
      eunlock(ep);
      eput(ep);
      return -1;
    }
  }

  if ((f = filealloc()) == NULL || (fd = fdalloc(f)) < 0) {
    if (f) {
      fileclose(f);
    }
    eunlock(ep);
    eput(ep);
    return -1;
  }

  if (!(ep->attribute & ATTR_DIRECTORY) && (flags & O_TRUNC)) {
    etrunc(ep);
  }

  f->type = FD_ENTRY;
  f->off = (flags & O_APPEND) ? ep->file_size : 0;
  f->ep = ep;
  f->readable = !(flags & O_WRONLY);
  f->writable = (flags & O_WRONLY) || (flags & O_RDWR);

  eunlock(ep);

  return fd;
}

值得注意的是,在 sys_openatsys_open 中,我们修复了一个判断目录写权限的 bug。

if((ep->attribute & ATTR_DIRECTORY) && omode != O_RDONLY) // 原代码
if((ep->attribute & ATTR_DIRECTORY) && (flags & (O_WRONLY | O_RDWR))) // 新代码

这是由于,O_RDONLY 的值是 0,如果用户以 O_CREATE(0x40)方式打开一个目录,omode(即 0x40)不等于 O_RDONLY(0),判断为真,导致操作失败。而实际上 O_CREATE 并不包含写的意图。新的判断 (flags & (O_WRONLY | O_RDWR)) 使用位掩码,它精确地检查 flags 中是否包含 O_WRONLY(0x01)或 O_RDWR(0x02)这两个表示 “写” 的位,这才是正确的逻辑。

注:对于 sys_open 也可以进行相同的修改,但实际上我们修改了系统调用号后 sys_open 不会再被调用了。

内存管理

学过 ICS 的应该都知道 Linux 的标准虚拟内存布局(忘了的话可以参考我当助教时做的 Slide 回顾一下):

va

其中:

  • 堆是从低地址向高地址增长的
  • 栈是从高地址向低地址增长的
  • 映射区域在堆和栈之间,应当避免和二者相撞

在 xv6 中,有关物理内存到虚拟内存之间的地址翻译(PTE、MMU)的部分基本已经被完成好了,我们要完成的绝大部分工作都是在已有的虚拟内存上完成的。

如果你写过 Malloc Lab,那你一定不会对堆感到陌生。

在写过的很多的 C 程序中,我们都用过 malloc 函数来动态申请内存。这块可以动态增长和缩小的内存区域,就是 堆(Heap)。对于操作系统内核而言,它必须为每个进程管理堆区。

在 xv6 中,进程控制块 struct proc (定义于 proc.h) 中有一个至关重要的成员:uint64 sz。这个 sz 变量记录了该进程用户虚拟地址空间的总大小,从地址 0 开始,一直到 szsz 所指向的地址,就是堆区的末尾,我们称之为 program break

malloc 发现现有堆空间不足需要扩大 / 释放空间需要缩小时,它就会通过系统调用 sbrk(shift break)或者 brk(change break)向内核请求调整 program break 的位置。

在 xv6 中,默认只实现了 sbrk,我们只需要稍作调整即可实现 brk

brk

注意,brk() 函数用于直接设置 program break 的结束地址,sbrk() 函数用于按照指定增量(可正可负)调整堆的大小。

根据 官方文档,我们得到其在 Linux 下的标准用法:

int brk(void *addr);
int sbrk(intptr_t increment);

参数:

  • addr:一个指针,指向用户程序期望设置的新的程序中断点(program break)的地址。这个地址是堆区的上边界。
  • increment:一个整数,表示要增加或减少的内存大小。如果为正数,则增加内存;如果为负数,则减少内存。如果为 0,则返回当前 program break 的地址。

返回值:

  • 成功:返回 0,特别地,sbrk(0) 返回当前 program break 的地址。
  • 失败:返回 -1,并设置 errno

而测试文档要求如下:

#define SYS_brk 214

功能:修改数据段的大小;

输入:指定待修改的地址;

返回值:

  • 成功:返回 0
  • 失败:返回 -1
uintptr_t brk;
uintptr_t ret = syscall(SYS_brk, brk);

根据测试样例源代码,我们发现,对于特殊调用 brk(0),其返回值应当为当前堆的终点地址。

/*
 * 测试通过时应输出:
 * "Before alloc,heap pos: [num]"
 * "After alloc,heap pos: [num+64]"
 * "Alloc again,heap pos: [num+128]"
 *
 * Linux 中brk(0)只返回0,此处与Linux表现不同,应特殊说明。
 */
void test_brk(){
    TEST_START(__func__);
    intptr_t cur_pos, alloc_pos, alloc_pos_1;

    cur_pos = brk(0);
    printf("Before alloc,heap pos: %d\n", cur_pos);
    brk(cur_pos + 64);
    alloc_pos = brk(0);
    printf("After alloc,heap pos: %d\n",alloc_pos);
    brk(alloc_pos + 64);
    alloc_pos_1 = brk(0);
    printf("Alloc again,heap pos: %d\n",alloc_pos_1);
    TEST_END(__func__);
}

所以,我们要实现的 brk() 实际上是一个标准定义下的 brk()sbrk(0) 的结合体。

仿照 sbrk() 的已有代码,很容易就能得到 brk()

/**
 * @brief 实现 brk 系统调用,用于调整程序数据段(Heap,堆)的大小。
 * @param addr 新的数据段结束地址
 * @return 0 成功,-1 失败,特别地,brk(0) 返回当前堆的终点地址
* @note 注意 brk(break) 是直接设置堆的终点,而 sbrk(shift break) 是按照指定增量(可正可负)s调整堆的大小。
 */
uint64 sys_brk(void) {
  uint64 addr, new_addr;
  int delta;

  if (argaddr(0, &new_addr) < 0) {
    return -1;
  }

  addr = myproc()->sz;

  if (new_addr == 0) {
    return addr;
  }

  delta = new_addr - addr;

  if (growproc(delta) < 0) {
    return -1;
  }
  return 0;
}

内存机制

页表与内存管理

完成 brk() 很简单,但是实际上如果你继续深究一下这个真正发挥作用的 growproc(delta) 函数,就能发现这一套内存机制是如何发挥作用的了。

学过 ICS 的都知道,机器有物理内存和虚拟内存两套内存,虚拟内存是操作系统提供给进程的抽象,然而两套地址之间还是需要由内核建立映射关系,映射的最小粒度是一个页(PGSIZE,定义在 riscv.h,回忆一下 PPO = VPO),也就是说物理页和虚拟页之间一定是页对齐的。

growproc 函数决定需要为进程增加堆空间时,它会调用 uvmalloc,反之它会调用 uvmdealloc,这些 uvm 的前缀代表 User Virtual Memory(用户虚拟内存),而后续一些函数的 kvm 前缀代表 Kernel Virtual Memory(内核虚拟内存)。

先介绍第一条路径,增加内存。

$$ \texttt{uvmalloc} \rightarrow \texttt{mappages} \rightarrow \texttt{walk} \rightarrow \texttt{kalloc} $$

uvmalloc 是一个批量处理的函数,负责为一段新的虚拟地址空间(oldsznewsz)分配物理内存并建立映射,还需要用户态页表 pagetable 和内核态页表 kpagetable 作为定位所需的参数传入。

uint64
uvmalloc(pagetable_t pagetable, pagetable_t kpagetable, uint64 oldsz, uint64 newsz)
{
  char *mem;
  uint64 a;

  oldsz = PGROUNDUP(oldsz); // 向上对齐到页边界
  for(a = oldsz; a < newsz; a += PGSIZE){
    mem = kalloc(); // 1. 分配物理页
    if(mem == NULL){
      uvmdealloc(pagetable, kpagetable, a, oldsz); // 分配失败,回滚
      return 0;
    }
    memset(mem, 0, PGSIZE); // 2. 清零物理页

    // 3. 映射到用户页表
    if (mappages(pagetable, a, PGSIZE, (uint64)mem, PTE_W|PTE_X|PTE_R|PTE_U) != 0) {
      // ... 错误处理与回滚 ...
    }
    // 4. 映射到内核页表
    if (mappages(kpagetable, a, PGSIZE, (uint64)mem, PTE_W|PTE_X|PTE_R) != 0){
      // ... 错误处理与回滚 ...
    }
  }
  return newsz;
}

uvmalloc 的核心是一个循环,它以页(PGSIZE)为单位,为每个新的虚拟页面 a 执行一套标准流程:

  1. kalloc():从物理内存池中取出一页。
  2. memset():将其清零,确保数据安全。
  3. mappages()两次调用 mappages 建立映射。一次是为用户页表(带 PTE_U 标志,允许用户态访问),一次是为内核页表(不带 PTE_U 标志,仅限内核态访问)。这种双重映射可以让内核可以在不切换页表的情况下直接访问用户空间数据。

继续往下走,我们来到了 mappages ,它负责将一段连续的虚拟地址映射到物理地址。

int
mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm)
{
  uint64 a, last;
  pte_t *pte;

  a = PGROUNDDOWN(va);
  last = PGROUNDDOWN(va + size - 1);

  for(;;){
    // 关键:找到PTE的位置
    if((pte = walk(pagetable, a, 1)) == NULL)
      return -1;
    if(*pte & PTE_V)
      panic("remap"); // 防止重复映射

    // 核心:填写PTE,建立映射
    *pte = PA2PTE(pa) | perm | PTE_V;

    if(a == last) break;
    a += PGSIZE;
    pa += PGSIZE;
  }
  return 0;
}

mappages 的核心是调用 walk(..., 1) 来找到或创建页表项(PTE),然后将物理地址和权限填入其中,并设置有效位 PTE_V

回顾一下页表的概念,实际上就是一棵树,树的每个节点都是一个页表项(PTE),每个页表项包含下一级页表的基址(物理地址,高位)和权限控制项(低位),最后一级页表的页表项中包含物理页号(PPN)和权限控制项。

multi-level_page_table

在 RISC-V Sv39 分页方案中,采用三级页表,一个虚拟地址被这样使用:

$$ \underbrace{\text{...}}{\text{63-39位}}\ |\ \underbrace{\text{L2 Idx}}{38-30}\ |\ \underbrace{\text{L1 Idx}}{29-21}\ |\ \underbrace{\text{L0 Idx}}{20-12}\ |\ \underbrace{\text{Offset}}_{11-0} $$

其中前面 69~39 位不使用。

这里可以对比一下 ICS 课本上 Core i7 的地址翻译,让你快速回顾一下什么是 PTE:

pte1

pte2

walk 的任务就是利用 L2,L1,L0 这三级索引,对于一个虚拟地址 va,从顶级页表开始,一步步找到最终的 PTE。

/**
 * @brief 查找或创建虚拟地址对应的页表项(PTE)
 * @param pagetable 当前页表
 * @param va 虚拟地址
 * @param alloc 是否允许分配新的页表页
 * @return 页表项(PTE)的地址,如果分配失败则返回 NULL
 * @note 从 L2 开始,逐步找到 L0 页表,如果下级页表不存在且 alloc 为真则分配新的页表页,反之退出
 * @note 如果最终 L0 页表存在,则返回 L0 页表中对应的 PTE 地址
 */
pte_t *
walk(pagetable_t pagetable, uint64 va, int alloc)
{
  if(va >= MAXVA)
    panic("walk");

  // 从L2页表开始,走到L1页表
  for(int level = 2; level > 0; level--) {
    // PX(level, va) 宏提取出va中对应级别的9位索引
    pte_t *pte = &pagetable[PX(level, va)];

    if(*pte & PTE_V) { // Case 1: 下一级页表已存在
      // 从PTE中提取下一级页表的物理地址
      pagetable = (pagetable_t)PTE2PA(*pte);
    } else { // Case 2: 下一级页表不存在
      if(!alloc || (pagetable = (pde_t*)kalloc()) == NULL)
        return NULL; // 如果不允许分配,或者分配失败,则返回失败

      // 分配成功,初始化新页表
      memset(pagetable, 0, PGSIZE);

      // 将新页表的地址填入当前PTE,并设为有效,完成链接
      *pte = PA2PTE(pagetable) | PTE_V;
    }
  }
  // 循环结束时,pagetable已经是L0页表的地址
  // 返回va在L0页表中对应的那个PTE的地址
  return &pagetable[PX(0, va)];
}

在最底层,会调用 kallockfree,和之前所讲过的存在全局共享的文件池一样,这里的物理内存池也是全局的,维护为一个链表。

  • kalloc():当被调用时,它就从这个链表的头部取下一个空闲页,并返回它的地址。如果链表为空,表示物理内存耗尽,返回 NULL
  • kfree(void *pa):当被调用时,它将传入的、不再使用的物理页 pa 重新加回到空闲链表的头部。

然后是第二条路径,释放内存。

$$ \texttt{uvmdealloc} \rightarrow \texttt{vmunmap} \rightarrow \texttt{walk} \rightarrow \texttt{kfree} $$

这里就不展开详细讲了,如果你理解了前面的内容,很容易就能对照源码理解它们的工作流程。

API 总结

这里简单总结到现在为止涉及的相关 API,这对我们理解整个虚拟内存管理机制至关重要。

// 进程级内存管理 API
int growproc(int n);

growproc 是一个高层封装,专门用于调整 当前进程 的堆大小。它接收一个增量 n,内部判断是该增加还是缩减内存,并调用下面的 uvmallocuvmdealloc 来完成实际工作。

// 用户虚拟内存(UVM) API
uint64 uvmalloc(pagetable_t pagetable, pagetable_t kpagetable, uint64 oldsz, uint64 newsz);
uint64 uvmdealloc(pagetable_t pagetable, pagetable_t kpagetable, uint64 oldsz, uint64 newsz);

uvmalloc 负责为一段新的虚拟地址空间(oldsznewsz内部会进行页对齐 )分配物理内存并建立映射,还需要用户态页表 pagetable 和内核态页表 kpagetable 作为定位所需的参数传入。

uvmdeallocuvmalloc 的逆操作,负责批量解除映射并释放物理内存。

// 页表操作 API
int mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm);
void vmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free);
pte_t *walk(pagetable_t pagetable, uint64 va, int alloc);

mappages 是一个更底层的映射函数,它将一段给定的虚拟地址 va 映射到一段给定的物理地址 pa。它不负责分配物理内存,只负责在页表中建立映射关系。

vmunmap 则是 mappages 的逆操作,负责解除一段虚拟地址的映射,并通过 do_free 参数灵活控制是否释放底层物理内存。

walk 是所有页表操作的核心。它负责在多级页表中逐级定位,根据给定的虚拟地址 va 找到其对应的最终页表项(PTE)的地址。alloc 参数是其关键,决定了在路径不通时是创建新的页表(alloc=1)还是直接返回失败(alloc=0)。

// 物理内存管理 API
void *kalloc(void);
void kfree(void *pa);

kallockfree 是最底层的物理内存管理器。

  • kalloc 从空闲物理页链表中分配一个 4KB 的物理页。
  • kfree 则将一页物理内存归还到空闲内存池链表中。

内存映射

内存映射,即 mmap(memory map),是一个强大的系统调用,它能将文件内容或一块匿名内存映射到进程的虚拟地址空间。之后,程序就可以像访问普通数组一样读写这块内存,而无需调用 readwrite,极大地提高了 I/O 效率。

| 特性 | 文件映射 | 匿名映射 | | :----------- | :----------------------------------------------------------- | :------------------------------- | | 后端存储 | 磁盘文件 | (内核提供零页) | | 内容来源 | 来自文件内容 | 初始化为全零 | | 持久性 | MAP_SHARED 下可持久化到文件 MAP_PRIVATE 使用 COW,只对自己可见 | 随进程结束而消失 | | 主要用途 | 高性能文件 I/O、加载可执行文件和动态库 | 申请大块内存、父子进程间共享内存 |

映射文件似乎还好理解,将磁盘文件和虚拟内存直接绑在一起,我对内存读写最后就是对文件读写。但是匿名映射是什么鬼?如果我需要一块内存为什么不是直接使用 malloc 呢?

还是回想一下 Malloc Lab,我们当时付出了巨大的努力来手动管理堆,从而实现一个性能优秀的 malloc 函数。而匿名映射不同,它的核心也是向操作系统高效地申请 大块连续内存,但它绕过了 C 库的堆管理器(malloc),直接操作虚拟内存,从而规避了堆的碎片问题和管理开销。当释放(munmap)时,内存会立即归还给操作系统,而不是留在进程的内存池中。

做个比喻,那就是 malloc 函数更像是一个零售商,而 mmap 函数更像是一个批发商。

而除此之外,也是实现父子进程间内存共享(IPC)的一种关键机制。

当一个进程调用 fork() 或者 clone() 创建子进程时(exec() 会直接销毁虚拟地址空间并重建,所以原有内存映射会被丢弃),子进程会继承父进程的整个虚拟地址空间,包括代码、数据、堆栈以及所有内存映射区域。根据标志位的不同,这种继承行为也有所不同:

  1. MAP_PRIVATE(私有映射,默认):子进程继承了映射,但采用 写时复制(Copy-on-Write, COW) 策略。

    起初,父子进程共享同一块物理内存。但一旦任何一方尝试写入这块内存,内核会为写入方创建一个该内存页的私有副本,并修改其页表指向这个新副本。从此,它们就拥有了各自独立的内存,互不影响。

  2. MAP_SHARED(共享映射):这正是实现 IPC 的关键。

    当使用此标志时,COW 机制被禁用。子进程继承映射后,其页表条目与父进程指向 完全相同的物理内存页。任何一方对这块内存的写入,都会直接修改这块物理内存,因此另一方能立刻看到变化。这就像父子两人共同操作同一块物理白板,而不是各自拿着复印件。

关于这里,你可以在 我的 Slide 里快速回顾一下

map_shared

map_privated

VMA

想要正确实现 mmapmunmap,我们必须引入一个新的数据结构 VMA(Virtual Memory Area),它记录了进程虚拟内存的一片区域。

为了给 mmap 区域分配虚拟地址空间,我们在 memlayout.h 中定义了一个常量 MMAPBASE 作为 mmap 区域的顶部。

// memlayout.h
// mmap 的最高地址,和 TRAPFRAME 之间间隔的地址用于用户栈
// mmap 永远向下扩展
#define MMAPBASE                0x60000000L

新的映射会从这个地址开始 向下 寻找可用的空间。这种布局使得向上增长的堆和向下增长的 mmap 区域可以避免冲突,同时避免与用户栈冲突。

现在,整体的内存布局如下。

内核态虚拟地址空间:

+----------------------------------+
| Guard Page (invalid)             |  MAXVA = 0x4000000000
+----------------------------------+
| Trampoline Page                  |  TRAMPOLINE = 0x3FFFFFF000
+----------------------------------+
| Trapframe (unmapped in kernel)   |  TRAPFRAME  = 0x3FFFFFE000
+----------------------------------+
| Kernel Stacks Region             |  start ~= VKSTACK = 0x3EC0000000
| [guard][stack] per core/process  |
+----------------------------------+
| RAM Direct Map (high half)       |
| QEMU: [0x3F80200000,0x3F80600000)|
| K210: [0x3F80020000,0x3F80600000)|
+----------------------------------+
| MMIO Direct Map                  |
| CLINT_V   = 0x3F02000000         |
| PLIC_V    = 0x3F0C000000         |
| UART_V    = 0x3F10000000 (QEMU)  |
| VIRTIO0_V = 0x3F10001000 (QEMU)  |
| UART_V    = 0x3F38000000 (K210)  |
| ...                              |
+----------------------------------+
| Low addresses (user space area)  |
| 0x00000000 ...                   |
+----------------------------------+

用户态内存地址空间:

+------------------+  <-- MAXVA = 0x4000000000
|   Guard Page     |  (无效页,防护用)
+------------------+  <-- TRAMPOLINE = 0x3FFFFFF000
|  Trampoline Page |  (用户/内核共享的一页)
+------------------+  <-- TRAPFRAME = 0x3FFFFFE000
|  Trapframe Page  |  (每进程陷入帧)
+------------------+
|  ...             |  区间: [MMAPBASE, TRAPFRAME)
+------------------+  <-- MMAPBASE = 0x60000000
|  mmap region     |  (mmap 最高地址在此,向下扩展 ↓)
+------------------+
|  ...             |  (未用/保留/映射空洞)
+------------------+
|  heap (upward)   |  (从 data/bss 之后向上增长 ↑)
+------------------+
|  User Stacks     |  (用户栈区,1 页大小,固定大小,不增长)
+------------------+  <-- STACKBASE
|   Guard Page     |  (无效页,1 页大小,防护用)
+------------------+
|  data / bss      |
+------------------+
|  text            |
+------------------+  <-- 0x00000000

注意内核态和用户态采用的是不同的虚拟内存地址空间,这依赖于他们各自有不同的页表来实现。

同时注意,经 @Ray Cao 指出,这里的用户态栈区的分配实际上和 Linux 标准的栈区分配不一致,根据 exec.c 中的代码,实际上是在加载完程序后,直接在 data/bss 之上新分配两个 page,并把地址高的 page 作为用户栈区(不会增长),然后留了一页 Guard Page 作为防护,非常的抽象,参见 原始代码

为了管理这些可能不连续的 mmap 区域,我们仿照 Linux 标准的定义,引入了 VMA (Virtual Memory Area) 的概念。

我们首先在 vm.h 中定义了 struct vma 和一些与之相关的宏,包括单进程最大数量 NVMA、权限与映射等位掩码。其中权限与映射这些位掩码来自 Linux 标准定义。

  • PROT_*(Protection):表示内存的访问权限
  • MAP_*(Flags):描述映射的行为
    • MAP_SHARED 表示对内存的修改会写回文件,并对其他映射该文件的进程可见
    • MAP_PRIVATE 表示修改只在当前进程内可见(** 需要通过写时复制实现,暂时简化掉了 ** )
    • MAP_ANONYMOUS 表示不关联任何文件,映射一块匿名的、初值为 0 的内存
// vm.h
#define NVMA 16

#define PROT_READ       (1 << 0)
#define PROT_WRITE      (1 << 1)
#define PROT_EXEC       (1 << 2)

#define MAP_SHARED      0x01
#define MAP_PRIVATE     0x02
#define MAP_FIXED       0x10
#define MAP_ANONYMOUS   0x20

struct vma {
    int valid;              // 此 VMA 条目是否有效
    uint64 start;           // 虚拟地址起始点
    uint64 end;             // 虚拟地址结束点 (不包含,[start, end) 是可用范围)
    int prot;               // 访问权限 (PROT_READ, PROT_WRITE, PROT_EXEC)
    int flags;              // 映射标志 (MAP_SHARED, MAP_PRIVATE, MAP_ANONYMOUS)
    struct file* vm_file;   // 指向被映射的 file 结构体,匿名映射时为 NULL
    uint64 offset;          // 文件内的偏移量
};

同时,在 struct procproc.h)中增加了一个 VMA 数组 struct vma vmas[NVMA];,让每个进程都能管理最多 NVMA 个独立的内存映射区域。

// proc.h
struct proc {
  // ...
  // vma 相关
  struct vma vmas[NVMA];
};

这里强烈建议先回顾一下我 Slide 里从 这页 开始的一系列内容。

vma

VMA 的生命周期

VMA 必须先在进程的整个生命周期中被正确管理,这就要求我们在实现实际使用它们的 mmapmunmap 之前,先修改一些已有的内核函数,来正确处理生命周期。

初始化 allocproc

创建新进程时,循环初始化其 vmas 数组,将所有 valid 标志置为 0:

// proc.c
static struct proc*
allocproc(void)
{
  // ...
  // vma 初始化
  for (int i = 0; i < NVMA; i++) {
    p->vmas[i].valid = 0;
  }
  // ...

  return p;
}

父子进程 fork / clone

子进程需要继承父进程的地址空间,包括所有 VMA。

通过结构体赋值 np->vmas[i] = p->vmas[i] 按值复制 VMA 元数据。对于文件映射,必须调用 filedup(np->vmas[i].vm_file) 来增加文件的引用计数,确保父子进程共享同一个文件映射。

// proc.c
int
fork(void)
{
  // ...
	safestrcpy(np->name, p->name, sizeof(p->name));

  // 父子进程应当具有相同的 vma,进行拷贝
  // 需要在设置 RUNNABLE 之前进行
  for (int i = 0; i < NVMA; i++) {
    if (p->vmas[i].valid) {
      np->vmas[i] = p->vmas[i];
      if (np->vmas[i].vm_file) {
        filedup(np->vmas[i].vm_file);
      }
    }
  }

  pid = np->pid;

  np->state = RUNNABLE;

  // ...
}
int
clone(void)
{
  // 同上修改
}

执行新进程 exec

当一个进程执行新程序时,其旧的地址空间(包括所有 VMA)都应被销毁。因此,exec 中会遍历并清空所有 VMA。对于文件映射,需要调用 fileclose 关闭文件,并将其指针置为 NULL。

// exec.c
int exec(char *path, char **argv)
{
  // ...
  p->trapframe->sp = sp; // initial stack pointer

  // 在释放旧页表之前,清理所有旧的 VMA
  for (i = 0; i < NVMA; i++) {
    struct vma* v = &p->vmas[i];
    if (v->valid) {
      if (v->vm_file) {
        fileclose(v->vm_file);
        v->vm_file = NULL;
      }
    }
    v->valid = 0;
  }

  proc_freepagetable(oldpagetable, oldsz);
  w_satp(MAKE_SATP(p->kpagetable));
  // ...
}

进程退出 exit

进程退出时,必须释放其所有资源。代码会调用内部函数 vma_free 来执行写回(对 MAP_SHARED)、解除页表映射、释放物理页和文件引用等资源释放操作。

// vm.h
void vma_writeback(struct proc* p, struct vma* v);
void vma_free(struct proc* p);

// vm.c
/**
 * @brief 将 VMA 中的数据写回物理内存
 * @param p 进程 PCB 指针
 * @param v 要写回的 VMA 指针
 */
void vma_writeback(struct proc* p, struct vma* v) {
  if (v->valid == 0) {
    return;
  }

  if (!(v->flags & MAP_SHARED) || !(v->prot & PROT_WRITE) || !(v->vm_file)) {
    return;
  }

  if (v->vm_file->writable == 0) {
    return;
  }

  for (uint64 va = v->start; va < v->end; va += PGSIZE) {
    uint64 pa = walkaddr(p->pagetable, va);
    if (pa == 0) {
      continue;
    }
    uint64 file_offset = v->offset + (va - v->start);
    elock(v->vm_file->ep);
    ewrite(v->vm_file->ep, 0, pa, file_offset, PGSIZE);
    eunlock(v->vm_file->ep);
  }
}
/**
 * @brief 释放进程的 VMA
 * @param p 进程 PCB 指针
 */
void vma_free(struct proc* p) {
  for (int i = 0; i < NVMA; i++) {
    struct vma* v = &p->vmas[i];
    if (v->valid) {
      // 取消映射并决定是否释放物理页
      // 如果是共享映射(MAP_SHARED),则不释放物理内存,
      // 否则(私有或匿名映射)则释放。
      int do_free = (v->flags & MAP_SHARED) ? 0 : 1;
      vmunmap(p->pagetable, v->start, (v->end - v->start) / PGSIZE, do_free);

      // 如果是文件映射,关闭文件
      if (v->vm_file) {
        fileclose(v->vm_file);
        v->vm_file = NULL;
      }

      v->valid = 0;
    }
  }
}

// proc.c
void
exit(int status)
{
  // ...
  eput(p->cwd);
  p->cwd = 0;

  // 在进程变成 ZOMBIE 之前,释放所有 VMA
  vma_free(p);

  // we might re-parent a child to init. we can't be precise about
  // waking up init, since we can't acquire its lock once we've
  // acquired any other proc lock. so wake up init whether that's
  // necessary or not. init may miss this wakeup, but that seems
  // harmless.
  acquire(&initproc->lock);
  wakeup1(initproc);
  release(&initproc->lock);
  // ...
}

注意这里写回在实际操作系统中应当是使用了 脏位(Dirty Bit) 的,但是这里出于简便起见,只进行判断写回条件是否满足后就直接写回,理论上来讲这会造成一些多余操作,但正确性是对的。

堆的 VMA

vma

在之前出现的这张图中,我们可以发现实际上进程的虚拟地址空间实际上都是有 VMA 来组成的,但是我们并没有回去修改相对应的 growproc 及一系列调用,这实际上是一个简化的实现,我们只用 VMA 来管理由 mmapmunmap 来映射的页面。

mmap

完成了上述知识梳理和 VMA 的加入后,我们终于可以开始真正实现 mmap 系统调用了。

根据 官方文档,我们得到其在 Linux 下的标准用法:

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

参数:

  • addr:建议的映射起始地址,通常设为 NULL,由内核自动选择一个合适的地址。
  • length:要映射的内存区域的长度,单位为字节。
  • prot:指定映射内存的保护权限,由 PROT_READ (可读), PROT_WRITE (可写), PROT_EXEC (可执行) 等标志位通过按位或 | 组合而成。
  • flags:指定映射的类型和属性,由 MAP_SHARED (共享映射), MAP_PRIVATE (私有映射), MAP_ANONYMOUS (匿名映射) 等标志位组合而成。
  • fd:要映射的文件描述符。如果进行匿名映射 (MAP_ANONYMOUS),此参数应为 -1。
  • offset:文件内的偏移量,指定从文件的哪个位置开始映射。它必须是系统页面大小的整数倍。

返回值:

  • 成功:返回一个指向映射区域起始地址的指针。
  • 失败:返回 MAP_FAILED (通常是 (void *) -1),并设置 errno

对于 mmap,测试文档和标准用法描述一致。

我们先修改 sysnum.h

#define SYS_munmap     215   // 释放内存映射
#define SYS_mmap       222   // 映射文件或设备到内存

我们还需要使用一个辅助函数 mmap_find_addr 来从 MMAPBASE 向下搜索,找到可用虚拟空间地址:

// vm.h
uint64 mmap_find_addr(struct proc* p, uint64 len);

// vm.c
/**
 * @brief 在进程的地址空间中找到一个可用的地址,用于映射文件
 * @param p 进程 PCB 指针
 * @param len 需要映射的长度,是一个 PGSIZE=4096 的整倍数
 * @return 找到的地址,0 表示失败
 * @note 从 MMAPBASE 开始向下搜索,直到找到一个足够大的、不与现有 VMA 或堆栈冲突的空闲区域
 */
uint64 mmap_find_addr(struct proc* p, uint64 len) {
  uint64 addr = MMAPBASE;

  if (len % PGSIZE != 0) {
    return 0;
  }

  while (1) {
    addr -= len;

    // 如果一直找到了和栈顶重叠,则返回失败
    if (addr < p->sz) {
      return 0;
    }

    int conflict = 0;
    for (int i = 0; i < NVMA; i++) {
      struct vma* v = &p->vmas[i];
      if (v->valid && v->start <= addr && v->end >= addr) {
        conflict = 1;
        addr = v->start;
        break;
      }
    }
    if (!conflict) {
      return addr;
    }
  }
}

然后,我们就可以在 sysproc.c 实现 mmap 了:

/**
 * @brief 实现 mmap 系统调用,将文件映射到进程的地址空间。
 * @param addr 映射的起始地址,只支持 0,即系统自动选择地址
 * @param len 映射的长度,会向上取整到 PGSIZE 的整倍数
 * @param prot 映射的权限
 * @param flags 映射的标志,只支持 MAP_SHARED 和 MAP_ANONYMOUS,不支持 MAP_FIXED
 * @param fd 文件描述符
 * @param offset 文件偏移量,必须是 PGSIZE 的整倍数
 * @return 映射的起始地址,-1 表示失败
 */
uint64 sys_mmap(void) {
  uint64 addr, len;
  int prot, flags, fd, offset;
  struct proc* p = myproc();

  if (
    argaddr(0, &addr) < 0 ||
    argaddr(1, &len) < 0 ||
    argint(2, &prot) < 0 ||
    argint(3, &flags) < 0 ||
    argint(4, &fd) < 0 ||
    argint(5, &offset) < 0
  )
    return -1;

  if (len == 0) {
    return -1;
  }

  // 偏移量必须是 PGSIZE 的整倍数
  if (offset % PGSIZE != 0) {
    return -1;
  }

  // 不支持用户指定地址或者固定地址映射
  if (addr != 0 || (flags & MAP_FIXED)) {
    return -1;
  }

  // 向上取整到 PGSIZE 的整倍数
  len = PGROUNDUP(len);

  // 寻找一个可用的 VMA 位置
  struct vma* v = NULL;
  for (int i = 0; i < NVMA; i++) {
    if (!p->vmas[i].valid) {
      v = &p->vmas[i];
      break;
    }
  }
  // 没有可用的 VMA 位置,返回失败
  if (v == NULL) {
    return -1;
  }

  uint64 va = mmap_find_addr(p, len);
  // 虚拟空间地址不足,返回失败
  if (va == 0) {
    return -1;
  }

  struct file* f = NULL;

  // 如果不是匿名映射,则需要获取文件描述符对应的文件
  if (!(flags & MAP_ANONYMOUS)) {
    // 检查是否为合法的文件描述符
    if (fd < 0 || fd >= NOFILE || (f = p->ofile[fd]) == NULL) {
      return -1;
    }
  }

  v->start = va;
  v->end = va + len;
  v->prot = prot;
  v->flags = flags;
  v->offset = offset;
  // 如果是文件映射,则需要增加文件的引用计数,并保存文件指针
  if (f) {
    v->vm_file = filedup(f);
  }
  else {
    v->vm_file = NULL;
  }
  v->valid = 1;

  return va;
}

filedup(f) 的作用是 增加文件的引用计数。这确保了即使程序后续调用 close(fd) 关闭了原始的文件描述符,只要 mmap 映射还存在,内核就不会真正关闭文件,VMA 依然可以访问它。

懒分配

注意这里,我们并没有完成实际的物理页面的复制,而仅仅完成了 VMA 的建立。这是因为我们使用了 懒分配(Lazy Allocation),只有当进程真正访问到这块内存的虚拟地址时,我们才会真正分配物理页面。

当用户程序第一次访问 mmap 返回的地址时,由于该虚拟地址在页表中没有对应的物理地址,CPU 会触发一个 缺页异常 (Page Fault)。这是一种特殊的 陷阱 (Trap)

陷阱会中断程序的正常执行,将控制权交给内核。硬件会自动完成以下工作:

  1. 将当前的用户寄存器(程序计数器 epc、栈指针 sp 等)保存到该进程的陷阱帧 (trapframe) 结构中。
  2. 切换到内核栈。
  3. 切换到内核页表。
  4. 跳转到内核预设的陷阱处理入口 usertrap(位于 trap.c

我们在 trap.cusertrap 函数中添加了处理缺页异常的逻辑:

// trap.c
void
usertrap(void)
{
  // ...
  else if((which_dev = devintr()) != 0){
    // ok
  } 
  else {
    // 获取触发异常的原因和地址
    uint64 scause = r_scause();
    uint64 stval = r_stval(); // stval 寄存器保存了导致异常的地址

    // 检查是否为缺页异常
    // 13: Load page fault (读缺页)
    // 15: Store/AMO page fault (写缺页)
    // 12: Instruction page fault (取指缺页)
    if (scause == 12 || scause == 13 || scause == 15) {
      // 在当前进程的 VMA 列表中查找包含 stval 的区域
      struct vma* v = 0;
      for (int i = 0; i < NVMA; i++) {
        if (p->vmas[i].valid && stval >= p->vmas[i].start && stval < p->vmas[i].end) {
          v = &p->vmas[i];
          break;
        }
      }

      if (v == 0) {
        // 地址不属于任何一个合法的 VMA,这是一个段错误 (Segmentation Fault)
        printf("usertrap(): segfault pid=%d %s, va=%p\n", p->pid, p->name, stval);
        p->killed = 1;
      }
      else {
        // 找到了 VMA,检查访问权限是否合法
        if (
          (scause == 12 && !(v->prot & PROT_EXEC)) ||    // 取指缺页,但 VMA 不可执行
          (scause == 13 && !(v->prot & PROT_READ)) ||    // 读缺页,但 VMA 不可读
          (scause == 15 && !(v->prot & PROT_WRITE))      // 写缺页,但 VMA 不可写
        ) {
          // 权限不匹配,这是保护错误 (Protection Fault)
          printf("usertrap(): protection fault pid=%d %s, va=%p\n", p->pid, p->name, stval);
          p->killed = 1;
        }
        else {
          // 权限检查通过,开始处理缺页,为该地址分配物理内存并建立映射

          // 计算缺页地址所在的页的起始地址
          uint64 va_page_start = PGROUNDDOWN(stval);

          // 分配一页物理内存
          char* mem = kalloc();
          if (mem == 0) {
            printf("usertrap(): out of memory\n");
            p->killed = 1;
          }
          else {
            // 将新分配的页清零
            memset(mem, 0, PGSIZE);

            // 如果是文件映射,从文件中读取相应内容到新分配的页
            if (v->vm_file) {
              elock(v->vm_file->ep);
              // 计算文件内的偏移量:VMA 文件偏移 + 页在 VMA 内的偏移
              uint64 file_offset = v->offset + (va_page_start - v->start);
              // 从文件读取一页内容到内核地址 mem
              eread(v->vm_file->ep, 0, (uint64)mem, file_offset, PGSIZE);
              eunlock(v->vm_file->ep);
            }

            // 根据 VMA 的保护权限,设置页表项 PTE 的标志位
            int pte_flags = PTE_U; // PTE_U 表示用户态可访问
            if (v->prot & PROT_READ) pte_flags |= PTE_R;
            if (v->prot & PROT_WRITE) pte_flags |= PTE_W;
            if (v->prot & PROT_EXEC) pte_flags |= PTE_X;

            // 调用 mappages 将物理页 mem 映射到用户虚拟地址 va_page_start
            if (mappages(p->pagetable, va_page_start, PGSIZE, (uint64)mem, pte_flags) != 0) {
              // 映射失败,释放刚分配的页
              kfree(mem); 
              printf("usertrap(): mappages failed\n");
              p->killed = 1;
            }
            // 同样需要映射到内核页表
            if (mappages(p->kpagetable, va_page_start, PGSIZE, (uint64)mem, pte_flags & ~PTE_U) != 0) {
              kfree(mem);
              vmunmap(p->pagetable, va_page_start, 1, 1);
              p->killed = 1;
            }
          }
        }
      }
    }
    else {
      // 如果不是缺页异常,按原逻辑处理未知异常
      printf("\nusertrap(): unexpected scause %p pid=%d %s\n", r_scause(), p->pid, p->name);
      printf("            sepc=%p stval=%p\n", r_sepc(), r_stval());
      p->killed = 1;
    }
  }
  // ...
}

munmap

munmap 用于解除先前由 mmap 创建的内存映射。一旦解除映射,再次访问该地址范围将导致段错误 (Segmentation Fault)。

根据 官方文档,我们得到其在 Linux 下的标准用法:

int munmap(void *addr, size_t length);

参数:

  • addr:要解除映射的内存区域的起始地址,通常是之前 mmap 调用返回的地址。
  • length:要解除映射的内存区域的长度。

返回值:

  • 成功:返回 0。
  • 失败:返回 -1,并设置 errno

对于 munmap,测试文档和标准用法描述一致,在现有基础上直接实现即可。

/**
 * @brief 实现 munmap 系统调用,取消映射进程的地址空间。
 * @param addr 映射的起始地址
 * @param len 映射的长度,会向上取整到 PGSIZE 的整倍数
 * @return 0 成功,-1 失败
 */
uint64 sys_munmap(void) {
  uint64 addr;
  int len;
  struct proc* p = myproc();

  if (argaddr(0, &addr) < 0 || argint(1, &len) < 0) {
    return -1;
  }

  // 地址和长度都需要页对齐。
  if (addr % PGSIZE != 0) {
    return -1;
  }
  len = PGROUNDUP(len);
  if (len == 0) {
    return 0; // unmap 长度为0是无操作,直接成功。
  }

  // 遍历查找要 unmap 的 VMA。
  // 这个实现简化为:必须完整地 unmap 一个或多个已存在的 VMA。
  // 不支持部分 unmap(那会使一个 VMA 分裂成两个)。
  for (int i = 0; i < NVMA; i++) {
    struct vma* v = &p->vmas[i];
    // 检查地址和长度是否精确匹配一个 VMA。
    if (v->valid && v->start == addr && (v->end - v->start) == len) {

      // 写回
      vma_writeback(p, v);

      // 决定是否释放物理页。
      int do_free = (v->flags & MAP_SHARED) ? 0 : 1;

      // 调用 vmunmap 清理页表和物理内存。
      vmunmap(p->pagetable, addr, len / PGSIZE, do_free);

      // 释放对文件的引用。
      if (v->vm_file) {
        fileclose(v->vm_file);
        v->vm_file = NULL;
      }

      // 将 VMA 标记为无效。
      v->valid = 0;

      return 0; // 成功。
    }
  }

  return -1; // 没有找到匹配的 VMA。
}

这里的实现实际上有一些问题,真实的 munmap 应当支持只取消一部分的 VMA(从而可能分裂出新的两个 VMA),而不必须完整的取消一整个 VMA,但这里简化起见直接不实现撕裂了,这已经足以通过所以测试了。

同时,还有一个值得注意的地方是,我们还需要进一步修改位于 vm.cvmunmap 函数,由于我们现在使用了懒加载,而测试代码中实际上并没有在利用 mmap 映射完毕后对得到的内存进行任何读写操作就进行了取消映射 munmap所以实际上这段内存对应的页表 PTE 的有效位 PTE_V 仍然为 0 (因为没有用,也就没有进到之前写的中断里发生修改),而在原有的 vmunmap 函数中,这种情况会引发 panic 错误,我们需要将之改为 continue 继续检查。

// vm.c
/**
 * @brief 移除从 va 开始的 npages 个页面的映射。va 必须页对齐
 * @param pagetable 目标用户页表
 * @param va 要取消映射的起始虚拟地址,必须页对齐
 * @param npages 要取消映射的页面数量
 * @param do_free 如果为 1,则释放页面对应的物理内存;如果为 0,则只取消映射
 * @note 在原有基础上进行修改以支持懒加载(Lazy Allocation)
 * @note 如果一个页面因为从未被访问而尚未建立映射,本函数会静默地跳过,而不会触发 panic
 */
void
vmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free)
{
  uint64 a;
  pte_t *pte;

  // 检查起始地址是否页对齐
  if ((va % PGSIZE) != 0)
    panic("vmunmap: not aligned");

  // 遍历所有需要取消映射的页面地址
  for (a = va; a < va + npages * PGSIZE; a += PGSIZE) {
    // 尝试查找该虚拟地址对应的页表项(PTE),不分配新的页目录(alloc=0)。
    pte = walk(pagetable, a, 0);

    // 懒加载时,mmap 区域直到被访问前,其页表项甚至中间的页目录都可能不存在
    // 所以,如果 walk 返回 NULL,即页表项不存在(没创建),或者 PTE 的有效位为 0(页尚未映射),都是正常的
    if (pte == 0 || (*pte & PTE_V) == 0) {
      // 继续找下一个页面,忽略未映射的页面,不触发 panic
      continue;
    }
    // 页面被映射,但是不是叶子节点,说明页表结构有问题
    if (PTE_FLAGS(*pte) == PTE_V) {
      panic("vmunmap: not a leaf");
    }
    // 如果 do_free 标志被设置,则释放该页表项指向的物理内存
    if (do_free) {
      uint64 pa = PTE2PA(*pte);
      kfree((void*)pa);
    }

    // 将页表项清零,使其无效,完成取消映射
    *pte = 0;
  }
}

其他 bug

由于我们现在引入了内存映射,所以在原有代码的 copyin2 中,对于地址合法性的检查需要进行修改。

原来是:

int
copyin2(char *dst, uint64 srcva, uint64 len)
{
  uint64 sz = myproc()->sz;
  if (srcva + len > sz || srcva >= sz) {
    return -1;
  }
  memmove(dst, (void *)srcva, len);
  return 0;
}

这个只检查了是否超过堆上界 sz,而不支持我们通过文件映射获得的 mmap 区域的地址,所以我们需要修改,直接把它改成对于 copyin 的简单封装即可。

/**
 * @brief 修复版 copyin2,用于将用户空间的数据拷贝到内核空间
 * @param dst 目标地址(内核空间)
 * @param srcva 源地址(用户空间)
 * @param len 长度
 * @return 0 成功,-1 失败
 * @note 修复了 copyin2 的边界检查问题,即 sz 是堆的上边界(堆顶之后紧接着的第一个无效地址),但是我们可能会从 mmap 的映射区中进行数据读取,从而导致越界,所以这里直接改为 copyin 的简单封装
 */
int
copyin2(char* dst, uint64 srcva, uint64 len) {
  pagetable_t pagetable = myproc()->pagetable;
  return copyin(pagetable, dst, srcva, len);
}

💾

  •  

更适合北大宝宝体质的 xv6 OS Lab 踩坑记 - Part3

2025年10月5日 10:41

[!CAUTION]

致各位同学:本笔记的撰写目的是用作参考,请勿直接抄袭,否则后果自负。

另外,请注意,本文撰写基于我修改过的 initcode.SMakefile 文件,如果你是基于原始仓库所编写的,可能需要手动进行一些初始化修改。

由于尝试了许久在完成 Part 0、1 后直接写 Part 2 都失败了,所以按照 PPT 的顺序先写 Part 3 了。

本部分包含 10 个测试样例:

  • wait
  • waitpid
  • clone
  • fork
  • execve
  • getppid
  • exit
  • yield
  • gettimeofday
  • sleep

其中,forkexecveexit 等系统调用在原始 xv6 中已有实现,我们只需确保系统调用号对齐即可,无需额外编码。我们的工作重点将放在 waitpidclone 等新的进程管理调用以及 gettimeofdaysleep 等时间相关的系统调用上。

注意,本部分实验需要实现的系统调用存在一些依赖关系,比如 wait / waitpid 依赖于 clone 的实现,你可能需要注意你的实现顺序从而能够及时调试。

准备工作

为了 Debug,我还引进了一个新的 recompile_test.sh,这个代码可以在修改测试用例(比如添加打印)的源代码后,直接重新打包 riscv64 目录到当前文件夹,不过如果你要使用,可能需要微调下相关路径:

#! /bin/bash

cd ~/OS/testsuits-for-oskernel/
sudo rm -rf ./riscv-syscalls-testing/user/build/
sudo rm -rf ./riscv-syscalls-testing/user/riscv64
docker run -ti --rm -v ./riscv-syscalls-testing:/testing -w /testing/user --privileged=true docker.educg.net/cg/os-contest:2024p6 /bin/bash -c "sh build-oscomp.sh"
cd ~/OS/xv6-os/
cp -r ~/OS/testsuits-for-oskernel/riscv-syscalls-testing/user/build/riscv64 .

时钟频率与 Ticks

在实现时间相关的系统调用前,我们首先需要理解这一套系统的时间到底是如何运作的。

原始 xv6-k210 的配置是针对 K210 硬件的,其时钟频率为 7.8 MHz,这点可以在 bootloader/SBI/rustsbi-k210/kendryte-k210.dtsi 中所描述的设备树中,根据如下这行确认:

timebase-frequency = <7800000>;

然而,我们现在是在 QEMU 虚拟机上运行,其模拟的硬件时钟频率是 10 MHz,也即 $10^{7}$ Hz。

这点可以根据 官方仓库的 README 中得到确认:

 _____         _     _  __                    _
|_   _|__  ___| |_  | |/ /___ _ __ _ __   ___| |
  | |/ _ \/ __| __| | ' // _ \ '__| '_ \ / _ \ |
  | |  __/\__ \ |_  | . \  __/ |  | | | |  __/ |
  |_|\___||___/\__| |_|\_\___|_|  |_| |_|\___|_|
================================================
| boot hart id          |                    6 |
| smp                   |                    8 |
| timebase frequency    |          10000000 Hz |
| dtb physical address  |           0x87e00000 |
------------------------------------------------

当然,你也可以在容器内依次执行如下代码获取 QEMU 的设备树文件,从而确认:

apt-get install device-tree-compiler
qemu-system-riscv64 -machine virt,dumpdtb=virt.dtb
dtc -I dtb -O dts -o virt.dts virt.dtb

你会在其中检索得到:

timebase-frequency = <0x989680>;

而这个十六进制数转换成十进制就是 10,000,000,即 10 MHz。

这个数字代表什么?它代表在 QEMU 这个模拟出来的 RISC-V 平台上,硬件计时器(Timer)每秒钟跳动 10,000,000 次。而这个值,正是你使用 r_time() 获取得到的 tick 数。

然而,这个 tick 数并不等价于全局变量 ticks(定义在 kernel/include/timer.h)!!!

这个全局变量 ticks,是 时钟中断的发生计数,它依赖于 时钟中断的触发频率,也即定义在 kernel/include/param.h 中的一个名为 INTERVAL 的宏。

在下文中,我们约定,如此区分两种 tick:

  1. 硬件 tick:通过 r_time() 获取,以硬件时钟频率(在 QEMU 下,为 10 MHz)增长。
  2. 操作系统 tick:全局变量 ticks,在每次时钟中断时加一,而每次时钟中断间隔 INTERVAL 个硬件 tick

举个例子,对于原始仓库,这个宏原本长这样:

#define INTERVAL     (390000000 / 200) // timer interrupt interval

而我们前文又说了,对于 k210 平台,硬件时钟频率为 7.8 MHz,那么在 k210 上:

  1. 通过 r_time() 获取到的硬件 tick 数,每秒钟增加 $7.8 \times 10^6$。

  2. 通过全局变量 ticks 获取到的操作系统 tick 数,每秒钟增加 4,也即每秒钟发生 4 个时钟中断,计算方式如下: $$ \begin{aligned} \frac{1}{\text{INTERVAL} * \text{second per hardware tick}} &= \frac{1}{(390000000 / 200) * \text{second per hardware tick}} \ &= \frac{7.8 \times 10^6}{3.9 \times 10^8 / 200} \ &= 4 \end{aligned} $$

以下是一个对比表:

| 对比项 | r_time() 返回值 (硬件 Tick) | 全局变量 ticks (操作系统 Tick) | | ------------ | ----------------------------- | ------------------------------------------ | | 本质 | 硬件计数器 | 软件计数器 | | 更新频率 | 硬件时钟频率 | 1 / (INTERVAL * second per hardware tick) | | 精度 | 非常高 | 较低 | | 谁来更新 | CPU 硬件自动更新 | 操作系统中断服务程序 |

只有理解了这个,我们才能完成 gettimeofdaynanosleep 两个系统调用。

gettimeofday

根据助教提供的 官方文档,我们得到其在 Linux 下的标准用法:

struct timeval {
    time_t      tv_sec;     /* seconds */
    suseconds_t tv_usec;    /* microseconds */
};

struct timezone {
    int tz_minuteswest;     /* minutes west of Greenwich */
    int tz_dsttime;         /* type of DST correction */
};

int gettimeofday(struct timeval *tv, struct timezone *tz);

参数:

  • tv:一个指向 timeval 结构体的指针,用来存放结果。
  • tz:一个指向 timezone 结构体的指针,用来存放时区信息。

输出:

  • 成功:返回 0,结果存放在参数位置。
  • 失败:返回 -1,并设置 errno 错误码。

但还是要注意,以上仅供参考,我们实际上还是要结合 测试仓库 的代码和文档,得到要求如下:

#define SYS_gettimeofday 169
  • 功能:获取时间;
  • 输入:timespec 结构体指针用于获得时间值;
  • 返回值:成功返回 0,失败返回 -1;
struct timespec *ts;
int ret = syscall(SYS_gettimeofday, ts, 0);

而阅读测试仓库的代码:

// riscv-syscalls-testing/user/include/stddef.h
typedef struct
{
    uint64 sec;  // 自 Unix 纪元起的秒数
    uint64 usec; // 微秒数
} TimeVal;

// riscv-syscalls-testing/user/lib/syscall.c
int64 get_time()
{
    TimeVal time;
    int err = sys_get_time(&time, 0);
    if (err == 0)
    {
        return ((time.sec & 0xffff) * 1000 + time.usec / 1000);
    }
    else
    {
        return -1;
    }
}

我们很容易发现这里的输入输出是:

输入:一个指向 TimeVal 结构体的指针,作为参数 0 传入

输出:

  1. 成功:返回 0,结果存放在参数位置。
  2. 失败:返回 -1,并设置 errno 错误码。

结合我们刚才讲过的硬件 tick 和操作系统 tick 的差异,我们修改 kernel/include/param.h 中的相关宏如下:

/*
注意区分硬件 tick 和操作系统 tick:
- 硬件 tick:通过 r_time() 获取到的 tick 数,按照 CLOCK_FREQ 频率增长
- 操作系统 tick:通过 ticks 全局变量获取到的 tick 数,每 INTERVAL 个硬件 tick 增长 1 次

通过 README 或者在容器内获取设备树可以发现 QEMU 的硬件时钟计数器的更新频率(timebase-frequency,即 r_time() 获取到的硬件 tick 更新频率)为 virt 机器默认的 10000000 Hz(10 MHz)
而读取 bootloader/SBI/rustsbi-k210/kendryte-k210.dtsi 可以发现 K210 的硬件时钟计数器的更新频率为 7800000 Hz(7.8 MHz),所以我们发现这里实际上是原来是每 50 秒 200 个时钟中断,也即每秒 4 个时钟中断,ticks 这个全局变量按照 4ticks/s 增长,非常慢
所以这里改写为 (CLOCK_FREQ / 200),即每秒触发 200 个时钟中断,也即 200 ticks/s,ticks 这个全局变量按照 200ticks/s 增长
*/
// #define INTERVAL     (390000000 / 200) // timer interrupt interval
#define CLOCK_FREQ   10000000 // 10 MHz
#define TICKS_PER_SECOND    200 // 每秒时钟中断次数
#define INTERVAL     (CLOCK_FREQ / TICKS_PER_SECOND) // timer interrupt interval

于是,我们得到代码如下:

/**
 * @brief 实现 gettimeofday 系统调用,获取当前时间。
 * @param addr timespec 结构体存到的目标地址
 * @return 0 成功,-1 失败
 * @note 注意,根据测试样例的要求,需要返回 tv_usec 微秒而不是 Linux 标准中的 tv_nsec 纳秒
 */
uint64 sys_gettimeofday(void) {
  struct timespec ts;
  uint64 htick = r_time(); // 硬件(hardware) tick,注意全局变量 ticks 是操作系统(os) tick,中间差了 200 倍

  ts.tv_sec = htick / CLOCK_FREQ; // 换算成秒
  ts.tv_usec = (htick % CLOCK_FREQ) * 1000000 / CLOCK_FREQ; // 换算成微秒, 1μs = 10^-6 s

  if (get_and_copyout(0, (char *)&ts, sizeof(ts)) < 0) {
    return -1;
  }
  return 0;
}

nanosleep

注意一下,sleep 测试用例实际上调用的是 nanosleep 系统调用,所以直接使用已有实现是不行的。

根据助教提供的 官方文档,我们得到其在 Linux 下的标准用法:

int nanosleep(const struct timespec *duration, struct timespec *rem);

参数:

  • duration:一个指向 timespec 结构体的指针,用来存放要求睡眠时间。
  • rem:一个指向 timespec 结构体的指针,用来在提前唤醒时,返回剩余睡眠时间。

然后你再去看 测试仓库 的文档:

#define SYS_nanosleep 101
  • 功能:执行线程睡眠,sleep() 库函数基于此系统调用;
  • 输入:睡眠的时间间隔;
  • 返回值:成功返回 0,失败返回 -1;

你再看文档中的代码,你会发现这里极其弱智的给了一个这样的定义:

struct timespec {
    time_t tv_sec;        /* 秒 */
    long   tv_nsec;       /* 纳秒, 范围在0~999999999 */
};
const struct timespec *req, struct timespec *rem;
int ret = syscall(SYS_nanosleep, req, rem);

看出哪里有问题了吗?这里是彼此冲突的!前面 gettimeofday 的计算明明第二个字段是符合 Linux 标准的微秒,而这里却是纳秒(甚至看起来约定了两个不同的结构体 TimeValtimespec)!

但是,如果你再去搜一下代码,你就会发现这里纯纯是文档写错了,全仓库都没有 timespec 这玩意的定义,只有 TimeVal

你能感受我的无语吗?官方文档纯瞎写,真的难绷吧。

好吧,让我们忽视这个弱智错误,假定应当是符合 Linux 标准和它自身代码的约定,即结构体里存放秒和微秒,那么我们只需要再注意这里应当利用 操作系统 tick,即 ticks 全局变量完成换算即可,模仿已有的 sys_sleep 调用,我们得到:

/**
 * @brief 实现 nanosleep 系统调用,睡眠指定时间。
 * @param req_addr 输入参数,指定的睡眠时间结构体 timespec 存放的地址,需要使用 copyin2 从用户空间拷贝到内核空间
 * @param rem_addr 输出参数,实际睡眠时间结构体 timespec 存放的地址,若实际睡眠时间小于指定睡眠时间,则返回剩余睡眠时间,反之返回 0,需要使用 copyout2 从内核空间拷贝到用户空间
 * @return 0 成功,-1 失败
 * @note 注意,根据测试样例的要求,需要睡眠时间以纳秒为单位
 */
uint64
sys_nanosleep(void)
{
  uint64 req_addr, rem_addr;
  struct timespec req_tv; // tv: timeval
  if (argaddr(0, &req_addr) < 0 || argaddr(1, &rem_addr) < 0) {
    return -1;
  }
  // 从用户空间拷贝到内核空间
  if (copyin2((char *)&req_tv, req_addr, sizeof(struct timespec)) < 0) {
    return -1;
  }

  uint64 target_ticks = req_tv.tv_sec * TICKS_PER_SECOND + req_tv.tv_usec * TICKS_PER_SECOND / 1000000;

  uint64 ticks0;
  acquire(&tickslock);
  ticks0 = ticks;
  while (ticks - ticks0 < target_ticks) {
    if (myproc()->killed) {
      // 如果输出参数 rem_addr 不为空,则返回剩余睡眠时间
      if (rem_addr != NULL) {
        uint64 elapsed_ticks = ticks - ticks0;
        uint64 rem_ticks = (target_ticks > elapsed_ticks) ? (target_ticks - elapsed_ticks) : 0;
        struct timespec rem_tv;
        rem_tv.tv_sec = rem_ticks / TICKS_PER_SECOND;
        rem_tv.tv_usec = (rem_ticks % TICKS_PER_SECOND) * 1000000 / TICKS_PER_SECOND;
        if (copyout2(rem_addr, (char *)&rem_tv, sizeof(struct timespec)) < 0) {
          release(&tickslock);
          return -1;
        }
      }
      release(&tickslock);
      return -1;
    }
    sleep(&ticks, &tickslock);
  }
  release(&tickslock);
  return 0;

}

注:写到这里的时候参阅了一些前人代码,发现几乎没有人实现了正确的换算和符合文档行为的 rem 参数,只能说这实验真的是草台班子。

clone

clone() 函数的作用是创建一个与当前进程(父进程)几乎一模一样的新进程(子进程)。

要理解 clone() 的行为,让我们首先从 Gemini 哪里补一些课。

进程控制块(Process Control Block,PCB) 是操作系统内核中用于描述和管理一个进程的核心数据结构。在 xv6 操作系统中,其具体实现为 struct proc(参见 kernel/include/proc.h)。它包含了内核管理进程所需的所有信息,例如:

  • 进程 ID(PID):唯一的进程标识符。
  • 进程状态:如 RUNNING(运行中)、SLEEPING(等待中)等。
  • 内存管理信息:指向该进程页表的指针,定义了其虚拟地址空间。
  • 内核栈:进程在内核态执行时使用的栈。
  • 上下文信息:指向陷阱帧(trapframe)和内核线程上下文(context)的指针,用于进程切换和中断处理。

allocproc() 是一个内核函数,其主要作用是 分配并初始化一个新的 PCB。具体步骤如下:

  1. 在内核的进程表中查找一个未被使用的 struct proc 条目。
  2. 如果找到,将其状态初始化(例如,设置为 USED),并分配一个唯一的 PID。
  3. 为该进程分配一个内核栈。
  4. 准备好从内核态返回用户态所需的初始上下文,这通常涉及在内核栈顶设置一个陷阱帧和初始的内核线程上下文。

陷阱帧(Trapframe) 是一个至关重要的数据结构,用于在进程从 用户态 切换到 内核态 时,保存 CPU 的完整状态(即上下文)。在 xv6 中,其具体实现为 struct trapframe(参见 kernel/include/trap.h)。当发生系统调用、设备中断或异常时,CPU 必须暂停当前用户程序的执行并转入内核。陷阱帧的作用就是像一张 “快照”,精确记录下暂停瞬间的所有 CPU 寄存器状态,以便内核处理完毕后能完美恢复现场,让用户进程无感知地继续运行。

当发生切换时,硬件或内核代码会将用户态的所有关键寄存器值保存在一个位于内核栈上的 trapframe 结构中。这样做是为了在内核处理完相应事件后,能够精确地恢复这些寄存器值,让进程从中断处无缝地继续执行,就好像什么都没发生过一样。

struct trapframe 包含的主要信息可分为两类:

  • 用户态上下文:保存了用户进程执行时的所有关键寄存器值。

    • epc(Exception Program Counter):异常程序计数器。保存了触发陷阱的用户指令的地址。这是最重要的字段,内核处理完毕后将根据此地址返回,继续执行用户代码。
    • sp(Stack Pointer):用户栈指针。记录了用户进程的栈顶位置。
    • 通用寄存器:如 ra(返回地址)、gp(全局指针)、a0-a7(函数参数与返回值)、s0-s11(被调用者保存的寄存器)、t0-t6(临时寄存器)。这些寄存器完整地定义了进程在用户态的计算状态,必须全部保存和恢复。
  • 内核态切换信息:保存了进入内核态所需的目标信息。这些字段由内核在创建进程时预设好,在陷入(trap)时被加载到 CPU 中。

    • kernel_satp:指向 内核页表 的指针。CPU 进入内核态后,需要切换到内核的地址空间,此字段提供了页表基地址。
    • kernel_sp:指向当前进程 内核栈的栈顶。内核代码执行需要自己的栈空间,与用户栈隔离。
    • kernel_trap:指向内核中 陷阱处理函数usertrap)的地址。保存完用户态上下文后,程序将跳转到这里开始执行内核代码。
    • kernel_hartid:记录当前执行的 CPU 核心(hart)的 ID。

每个进程都有一个自己的陷阱帧,通常由其进程控制块(PCB)中的指针 p->trapframe 指向。这个结构是连接用户态和内核态的关键桥梁。

好的,补课结束!

根据助教给的 官方文档,我们得到其在 Linux 下的标准用法:

int clone(int (*fn)(void *), void *child_stack, int flags, void *arg);

参数:

  • fn:一个函数指针,用于在子进程中执行
  • child_stack:一个指向子进程堆栈的指针
  • flags:一个标志位,用于指定子进程的执行方式
  • arg:一个参数,用于传递给子进程

返回值:

  • 成功:返回子进程的 PID
  • 失败:返回 -1

然后再去看 测试仓库 的文档:

#define SYS_clone 220

输入:

  • flags:创建的标志,如 SIGCHLD
  • stack:指定新进程的栈,可为 0
  • ptid:父线程 ID
  • tls:TLS 线程本地存储描述符
  • ctid:子线程 ID

返回值:

  • 成功:返回子进程的线程 ID
  • 失败:返回 -1
pid_t ret = syscall(SYS_clone, flags, stack, ptid, tls, ctid)

这个测试点很恶心的一点,在于你光看上面这个没有一点用的文档说明是根本无从得知怎么实现的,注意到 Linux 标准实现要求传入了一个 fn 函数指针以及一个 arg 参数,而这里完全没有相关说明,你必须再去深入一些,阅读测试点源码:

// riscv-syscalls-testing/user/include/unistd.h
pid_t clone(int (*fn)(void *arg), void *arg, void *stack, size_t stack_size, unsigned long flags);

// riscv-syscalls-testing/user/src/oscomp/clone.c
#include "stdio.h"
#include "stdlib.h"
#include "unistd.h"

size_t stack[1024] = {0};
static int child_pid;

static int child_func(void){
    printf("  Child says successfully!\n");
    return 0;
}

void test_clone(void){
    TEST_START(__func__);
    int wstatus;
    child_pid = clone(child_func, NULL, stack, 1024, SIGCHLD);
    assert(child_pid != -1);
    if (child_pid == 0){
	exit(0);
    }else{
	if(wait(&wstatus) == child_pid)
	    printf("clone process successfully.\npid:%d\n", child_pid);
	else
	    printf("clone process error.\n");
    }

    TEST_END(__func__);
}

int main(void){
    test_clone();
    return 0;
}

然后,还是没有思路对不对?好像只是知道了这里新开了一个 8KB 的空间作为这个进程的栈之外,毫无用处?还是不知道 fnarg 弄到哪里去了?

是的,想要搞清楚,你还得继续深入,在容器内反编译出来编译好的测试程序,也即在容器内执行:

riscv64-linux-gnu-objdump -d riscv64/clone > clone.asm

根据反汇编结果,你才终于能找到一些蛛丝马迹:

riscv64/clone:     file format elf64-littleriscv


Disassembly of section .text:

0000000000001000 <_start>:
    1000:	850a                	mv	a0,sp
    1002:	0f40006f          	j	10f6 <__start_main>

0000000000001006 <child_func>:
    1006:	1141                	addi	sp,sp,-16
    1008:	00001517          	auipc	a0,0x1
    100c:	02850513          	addi	a0,a0,40 # 2030 <__clone+0x2a>
    1010:	e406                	sd	ra,8(sp)
    1012:	306000ef          	jal	ra,1318 <printf>
    1016:	60a2                	ld	ra,8(sp)
    1018:	4501                	li	a0,0
    101a:	0141                	addi	sp,sp,16
    101c:	8082                	ret

000000000000101e <test_clone>:
    101e:	1101                	addi	sp,sp,-32
    1020:	00001517          	auipc	a0,0x1
    1024:	03050513          	addi	a0,a0,48 # 2050 <__clone+0x4a>
    1028:	ec06                	sd	ra,24(sp)
    102a:	e822                	sd	s0,16(sp)
    102c:	2ca000ef          	jal	ra,12f6 <puts>
    1030:	00003517          	auipc	a0,0x3
    1034:	0e050513          	addi	a0,a0,224 # 4110 <__func__.1191>
    1038:	2be000ef          	jal	ra,12f6 <puts>
    103c:	00001517          	auipc	a0,0x1
    1040:	02c50513          	addi	a0,a0,44 # 2068 <__clone+0x62>
    1044:	2b2000ef          	jal	ra,12f6 <puts>
    1048:	4745                	li	a4,17
    104a:	40000693          	li	a3,1024
    104e:	00001617          	auipc	a2,0x1
    1052:	0ba60613          	addi	a2,a2,186 # 2108 <stack>
    1056:	4581                	li	a1,0
    1058:	00000517          	auipc	a0,0x0
    105c:	fae50513          	addi	a0,a0,-82 # 1006 <child_func>
    1060:	5ab000ef          	jal	ra,1e0a <clone>

 // 中间无关代码省略之

0000000000001e0a <clone>:
    1e0a:	85b2                	mv	a1,a2
    1e0c:	863a                	mv	a2,a4
    1e0e:	c191                	beqz	a1,1e12 <clone+0x8>
    1e10:	95b6                	add	a1,a1,a3
    1e12:	4781                	li	a5,0
    1e14:	4701                	li	a4,0
    1e16:	4681                	li	a3,0
    1e18:	2601                	sext.w	a2,a2
    1e1a:	1ec0006f          	j	2006 <__clone>

0000000000002006 <__clone>:
    2006:	15c1                	addi	a1,a1,-16
    2008:	e188                	sd	a0,0(a1)
    200a:	e594                	sd	a3,8(a1)
    200c:	8532                	mv	a0,a2
    200e:	863a                	mv	a2,a4
    2010:	86be                	mv	a3,a5
    2012:	8742                	mv	a4,a6
    2014:	0dc00893          	li	a7,220
    2018:	00000073          	ecall
    201c:	c111                	beqz	a0,2020 <__clone+0x1a>
    201e:	8082                	ret
    2020:	6582                	ld	a1,0(sp)
    2022:	6522                	ld	a0,8(sp)
    2024:	9582                	jalr	a1
    2026:	05d00893          	li	a7,93
    202a:	00000073          	ecall

你需要像 ICS 的 bomblab 一样阅读代码,才能理清楚,在调用 sys_clone 之前,实际上的内存布局是这样的:

         高地址  ^
                |
       0x2508   +----------------+ <-- 阶段二计算出的栈顶
                |  arg (NULL/0)  | <-- sd a3, 8(a1) 写入这里 (地址 0x2500)
       0x2500   +----------------+
                |fn (&child_func)| <-- sd a0, 0(a1) 写入这里 (地址 0x24F8)
       0x24F8   +----------------+ <-- 最终传给内核的 a1 指针
                |                |
                |(useable space) |
                |      ...       |
                |                |
       0x2108   +----------------+ <-- stack 数组基地址
                |
         低地址  v

也就是说,实际上 argfn 两个参数被从栈顶进行了两次压栈,才得到了最终传给内核的 a1 指针。

现在,让我们回到 kernel/proc.c ,我们终于可以参照 fork() 函数,稍作修改,得到 clone() 函数:

// kernel/proc.c

int
clone(void)
{
  // ... 与 fork 类似的进程分配、内存拷贝等 ...
  struct proc* p = myproc();
  uint64 stack;

  // ...
  *(np->trapframe) = *(p->trapframe);

  argaddr(1, &stack); // 从第二个参数获取用户指定的栈顶地址
  // 如果用户提供了栈地址
  if (stack != NULL) {
    uint64 fn, arg;
    // 从用户指定的栈中读取 fn 和 arg
    // 注意:这里需要确保 stack 是有效的用户地址
    // 注意 copyin 比 copyin2 更安全,未修改前 copyin2 只做了简单的边界检查 srcva + len > sz
    // 而 sz 与映射页表无关,从而无法处理映射页表
    if (copyin(p->pagetable, (char*)&fn, stack, sizeof(fn)) < 0 ||
      copyin(p->pagetable, (char*)&arg, stack + 8, sizeof(arg)) < 0) {
      freeproc(np);
      release(&np->lock);
      return -1;
    }
  }

  // 让子进程的返回值为 0
  np->trapframe->a0 = 0;
  // ...
}

然后,再在 kernel/sysproc.c 中类似的添加一行调用即可:

/**
 * @brief 实现 clone 系统调用,创建子进程/线程。
 * @return 0 成功,-1 失败
 */
uint64 sys_clone(void) {
  return clone();
}

wait / waitpid

waitwaitpid 的功能十分相近,都用于等待子进程结束。

依旧是老样子,根据助教给的 wait 官方文档waitpid 官方文档

我们得到其在 Linux 下的标准用法:

pid_t wait(int *wstatus);
pid_t waitpid(pid_t pid, int *wstatus, int options);

参数:

  • pid:要等待的子进程的 PID
  • wstatus:一个指向整数的指针,用来存放子进程的退出状态
  • options:一个标志位,用于指定等待方式

返回值:

  • 成功:返回子进程的 PID,并且将子进程的退出状态存放在 wstatus 指针所指向的内存中
  • 失败:返回 -1

然后再去看 测试仓库 的文档:

#define SYS_wait4 260

功能:等待进程改变状态;

输入:

  • pid:指定进程 ID,可为 - 1 等待任何子进程;
  • status:接收状态的指针;
  • options:选项:WNOHANG,WUNTRACED,WCONTINUED;

返回值:

  • 成功:返回进程 ID;如果指定了 WNOHANG,且进程还未改变状态,直接返回 0
  • 失败:返回 -1
pid_t pid, int *status, int options;
pid_t ret = syscall(SYS_wait4, pid, status, options);

原始 xv6 只提供了一个简单的 wait,而显然我们需要实现的 waitpid 是一个功能更强大的版本,可以等待指定的子进程。

不过,这也只需要对 kernel/proc.cwait 函数做一些简单的微调即可。

首先,我们需要修改 kernel/proc.cwait 函数的签名,使其能够接受一个 wpid 参数,用于指定要等待的子进程 ID。

// kernel/proc.c

// 原签名: int wait(uint64 addr)
// 新签名:
int
wait(int wpid, uint64 addr)
{
  // ...
}

wpid 的含义如下:

  • wpid > 0:等待进程 ID 为 wpid 的子进程。
  • wpid == -1:等待任意一个子进程(这也就是 wait 系统调用的行为)。
  • 对于其他情况,我们选择面向测试用例编程,直接报错处理。

接着,在 wait 函数的循环中,加入对 wpid 的判断逻辑:

// kernel/proc.c wait() 函数内

if (np->parent == p) {
  // 如果指定了 wpid,但当前遍历到的子进程 np 不是目标,则跳过
  if (wpid > 0 && np->pid != wpid) {
    havekids = 1; // 标记仍然有其他子进程存在
    continue;
  }
  // ... 找到目标子进程(或任意子进程)后的处理逻辑
}

另一个关键的修改是关于子进程的退出状态码。

根据 POSIX 标准,wait 系列函数返回的状态码 status 是一个位域,它的低 16 位包含了状态信息,可以分为两部分:

  1. 低 8 位:如果子进程是被信号终止或停止的,这里存储了信号编号。
  2. 高 8 位:如果子进程是正常退出的,这里存储了退出码(exit code)。

因此,我们需要将 np->xstate 左移 8 位。这个修改是为了让测试用例中的 WEXITSTATUS(status) 宏(它会右移 8 位来提取退出码)能够正常工作。

// kernel/proc.c wait() 函数内

// ...
status = np->xstate << 8;
if (addr != 0 && copyout2(addr, (char*)&status, sizeof(status)) < 0) {
  // ... 错误处理
}
// ...

同理,我们还需要微调一下原有的 sys_wait() 实现,使之符合新签名格式:

// kernel/sysproc.c

uint64
sys_wait(void)
{
  uint64 p;
  if(argaddr(0, &p) < 0)
    return -1;
  // 原来是 wait(p);
  return wait(-1, p);
}

sched_yield

sched_yield 系统调用让当前进程主动放弃 CPU,让调度器去选择另一个可运行的进程来执行。实现非常简单,直接调用内核的 yield() 函数即可。

// kernel/sysproc.c

uint64
sys_sched_yield(void) {
  yield();
  return 0;
}

getppid

getppid 用于获取当前进程的父进程 ID(Parent Process ID)。

结合前面介绍过的 PCB 结构信息,我们只需要在 kernel/sysproc.c 中添加实现即可。

// kernel/sysproc.c

uint64
sys_getppid(void)
{
  return myproc()->parent->pid;
}

myproc() 函数返回当前正在执行的进程的 struct proc 指针,我们直接访问其 parent 成员并返回 pid 即可。

测试

至此,我们已经完成了 Part3 的全部内容,只需要进行测试即可:

qwe
make clean
make local

得到输出:

hart 0 init done
// 前略
init: starting sh
========== START test_wait ==========
This is child process
wait child success.
wstatus: 0
========== END test_wait ==========
init: starting sh
========== START test_waitpid ==========
This is child process
waitpid successfully.
wstatus: 3
========== END test_waitpid ==========
init: starting sh
========== START test_clone ==========
  Child says successfully!
clone process successfully.
pid:12
========== END test_clone ==========
init: starting sh
========== START test_fork ==========
  child process.
  parent process. wstatus:0
========== END test_fork ==========
init: starting sh
========== START test_execve ==========
  I am test_echo.
execve success.
========== END main ==========
init: starting sh
========== START test_getppid ==========
  getppid success. ppid : 1
========== END test_getppid ==========
init: starting sh
========== START test_exit ==========
exit OK.
========== END test_exit ==========
init: starting sh
========== START test_yield ==========
  I am child process:   I am child process: 21. iteration 1.
  I am child process: 22. iteration 2.
20. iteration 0.
  I am child process: 21. iteration 1.
  I am child process: 20. iteration 0.
  I am child process: 21. iteration 1.
  I am child process: 22. iteration 2.
  I am child process: 20. iteration 0.
  I am child process: 21. iteration 1.
  I am child process: 22. iteration 2.
  I am child process: 21. iteration 1.
  I am child process: 22. iteration 2.
  I am child process: 20. iteration 0.
  I am child process: 22. iteration 2.
  I am child process: 20. iteration 0.
========== END test_yield ==========
init: starting sh
========== START test_gettimeofday ==========
gettimeofday success.
start:836, end:911
interval: 75
========== END test_gettimeofday ==========
init: starting sh
========== START test_sleep ==========
sleep success.
========== END test_sleep ==========

注意这里,测试 yield 系统调用的时候,可能出现错行,这可能是因为 xv6-k210 中并不保证 printf 的原子性,但这不影响测试通过。

你可以通过调低 TICKS_PER_SECOND 这个宏来避免。

💾

  •  

更适合北大宝宝体质的 xv6 OS Lab 踩坑记 - Part1

2025年10月3日 00:20

[!CAUTION]

致各位同学:本笔记的撰写目的是用作参考,请勿直接抄袭,否则后果自负。

另外,请注意,本文撰写基于我修改过的 initcode.SMakefile 文件,如果你是基于原始仓库所编写的,可能需要手动进行一些初始化修改。

在完成 Part0 的准备工作后,你已经了解了 xv6 操作系统的基本运行流程,以及如何使用 Docker 和 Make 工具进行项目构建。在 Part 1 中,我们将深入理解系统启动的关键步骤,理解本地运行与平台评测的差异,并开始实现第一批系统调用。

本部分包含 5 个测试样例:

  • getcwd
  • write
  • getpid
  • times
  • uname

关于平台提交所需要准备的事项都已经在 Part0 中详细介绍过了,主要就是需要做一个 make all 确保 kernel-qemusbi-qemu 被正确拷贝到根目录即可,同时为了同步本地和平台的测试,我们需要将 riscv64/ 目录下的测试样例拷贝到 fs.img 的根目录下。

initcode

在开始之前,我们还是先回顾一下系统的整体启动流程:

  1. QEMU 启动:QEMU 模拟器为操作系统提供了虚拟的硬件环境,包括 CPU、内存和硬盘。
  2. 引导加载(Bootloader)RustSBI 程序首先运行,它负责初始化虚拟硬件,并将内核文件加载到内存中。
  3. 内核运行:CPU 开始执行内核代码。内核对各项系统服务进行初始化,例如进程管理和内存管理。
  4. 内核初始化,挂载 fs.img:内核初始化后,会挂载 fs.img 文件系统。
  5. 创建第一个进程,运行 initcode:内核创建第一个进程,并运行 initcode 程序。
  6. initcode 执行 exec("/init")initcode 程序执行 exec("/init") 系统调用,加载 /init 程序。
  7. /init 程序接管,开始执行测试或启动 Shell/init 程序接管,开始执行测试或启动 Shell。

完成以上步骤后,操作系统启动完毕,并将控制权交给用户程序。

可以看到,initcode 在其中发挥一个承上启下的作用,它引导了第一个进程的创建和启动,实现了整个系统的 自举

什么是自举?

自举(Bootstrapping)这个词源于英文谚语 “pull oneself up by one's bootstraps”(拉着自己的鞋带把自己提起来),比喻从一个极小的起点,依靠自身力量发展壮大。

在内核创建第一个进程时,完整的用户态环境(如动态链接器、标准库 libc 等)还不存在。initcode 的唯一使命就是调用 exec 系统调用,去加载并运行真正的用户态初始化程序(如 /init)。如果 initcode 本身是需要复杂加载过程的程序(如 ELF 格式),就会陷入 “谁来加载第一个加载器” 的悖论。

所以,initcode 必须是一段纯粹的机器码,而且不能依赖任何外部库。从而内核可以非常简单地将这段代码字节流直接复制到新进程的内存空间中,然后把 CPU 的控制权交给它,无需任何解析或链接操作。

initcode 的源代码在 xv6-user/initcode.S 文件中,具体的流程细节我们暂时还不需要关心,我们只需要大概知道它的工作方式如下:

  1. initcode 被编译成二进制机器码。
  2. 这些机器码以一个 C 语言数组 (uchar initcode[]) 的形式,被直接包含在内核的可执行文件中。

内核创建第一个进程的详细流程如下:

  1. 内核调用 userinit() 函数,在内存中为第一个进程(PID=1)分配数据结构。
  2. 内核不从硬盘加载文件,而是直接将 initcode[] 数组中的机器码,复制到这个新进程的内存空间中。
  3. 内核调度器开始运行该进程。
  4. 该进程执行的指令就是 initcode 的内容,即发起 exec("/init") 系统调用。
  5. 这个系统调用从用户态切换到内核态。此时,内核收到了一个来自有效用户进程的 exec 请求。
  6. 内核处理这个请求,从 fs.img 文件系统中找到 /init 程序,将其加载到该进程的内存中,覆盖掉原来的 initcode,然后返回用户态,开始执行 /init 程序的 main 函数。

通过这种方式,系统完成了第一个用户进程的加载和启动。

那么,为什么助教在视频中还发生了需要手动修改 initcode 的情况呢?如果这些东西看上去都是已经做好的,我们似乎并没有道理要修改其源码啊?

答案是我们所基于的这个 k210 框架是一个非常早的项目,它编译 initcode 时使用的 initcode.S 是基于 32 位 RISC-V 架构的,而我们现在的 RISC-V 架构是 64 位,所以其不能直接运行。

同时,其还依赖了 include/sysnum.h,并且使用了其中类似 SYS_exec 的宏,这就导致,如果你修改了 include/sysnum.h 中的系统调用号,你就需要重新生成 initcode(至于为什么需要修改,我们将在后文加以解释)。

而如果每次都需要复制机器码到 proc.c 中的 initcode 数组中,那就太麻烦了。所以这里有一个相对简便的方法,就是使用脚本根据 initcode.S 生成 initcode.h,然后我们再利用 #include 语法,直接在 proc.c 中引入即可。

这里,助教选择了直接将 init.c 编译出机器码,然后通过脚本生成对应的 initcode.h 文件,而不是理想的根据短片段 initcode.S 生成 initcode.h 文件后再拉起 /init 进程。这非常的不优雅,因为这么做实际上是将整个 init 程序完全打入了 initcode.h 文件中,又大又容易变化,而且后续会带来堆栈空间、入口定位错误不足的问题(简单说就是在 uvminit 中是按照 initcode.S 设计的,默认只分配一页内存并且强制从 .text 段的 0x0 开始执行,而 init.c 编译出的机器码可能远远超过一页,从而导致堆栈空间不足,同时如果在 init.c 前面声明别的函数,那么会导致 main 函数入口不再是 0x0,从而导致入口定位错误,详细分析见 Part 4 笔记)。

然而,评测时这么做是必须的,因为希冀平台评测时所提供的预编译 sdcard.img 中没有 init 程序,我们无法通过自举代码来拉起 /init 进程。而且,我们在编译阶段也无法访问 sdcard.img,将 init 程序拷贝到其中。我们更无法修改启动命令换用我们的 fs.img 进行评测。

但是我们知道,更优雅的做法还是将 initcode.S 改为适配 64 位 RISC-V 架构的写法,然后按照理想设计,直接根据 initcode.S 生成 initcode.h 文件后,执行后再拉起 /init 进程。

所以,我们这里可以选择一个折中的方案:

  • 对于平台所使用的 make all 命令编译过程,我们使用助教的办法,硬编码完整的 init 程序的机器码到 initcode.h 中;
  • 而对于本地测试,我们使用理想的做法,根据短的自举片段 initcode.S 生成 initcode.h 文件后,执行后再拉起 /init 进程。

这一点我已经通过在初始化本仓库的时候就完成了,如果你是直接克隆本仓库,将会无感体验。但如果你是根据助教的流程走的,那么建议你如下操作(同 Part 4 笔记):

观察 Makefile 会发现原本的代码就存在一个 $U/initcode 的编译目标,只不过其是在 32 位 RISC-V 下得到的,所以我们使用不了它的产物,我们只需要将之改为 64 位 RISC-V 的写法即可:

# xv6-user/initcode.S
# Initial process that execs /init.
# This code runs in user space.

#include "include/sysnum.h"

	.text
	.option nopic

# exec(init, argv)
.globl start
start:
        la a0, init
        la a1, argv
        li a7, SYS_exec
        ecall

# for(;;) exit();
exit:
        li a7, SYS_exit
        ecall
        jal exit

# char init[] = "/init\0";
init:
  .asciz "/init"

# char *argv[] = { init, 0 };
.section .rodata
.p2align 2
argv:
  .dword init
  .dword 0

然后重新更改我们的 Makefile 中的 dump 目标和 all 目标:

# 如果是提交到希冀平台,因为平台提供的 sdcard.img 挂载里没有 init.c 文件
# 所以需要硬编码完整的 init.c 程序的机器码到 initcode.h 中
HARD_CODE_INIT = 0

ifeq ($(HARD_CODE_INIT), 1)
dump: userprogs
	@echo "HARD_CODE_INIT is 1, compile the entire init.c program into initcode.h directly."
	@$(TOOLPREFIX)objcopy -S -O binary $U/_init tmp_initcode
	@od -v -t x1 -An tmp_initcode | sed -E 's/ (.{2})/0x\1,/g' > kernel/include/initcode.h 
	@rm tmp_initcode
else
dump: $U/initcode
	@echo "HARD_CODE_INIT is 0, compile the bootstrap fragment initcode.S normally."
	@od -v -t x1 -An $U/initcode | sed -E 's/ (.{2})/0x\1,/g' > kernel/include/initcode.h
endif

# ...
# 希冀平台所使用的编译命令
all:
	@$(MAKE) clean
	@$(MAKE) dump HARD_CODE_INIT=1
	@$(MAKE) build
	@cp $(T)/kernel ./kernel-qemu
	@cp ./bootloader/SBI/sbi-qemu ./sbi-qemu

# 本地测试所使用的编译命令
local:
	@$(MAKE) clean
	@$(MAKE) dump
	@$(MAKE) build
	@$(MAKE) fs
	@$(MAKE) run

而后我们最终只需要在容器内手动执行 make dump 命令,就可以生成 kernel/include/initcode.h 文件,然后我们再修改 kernel/proc.c 文件,引入 initcode.h 即可:

// a user program that calls exec("/init")
// od -t xC initcode
uchar initcode[] = {
  // 0x17, 0x05, 0x00, 0x00, 0x13, 0x05, 0x45, 0x02,
  // 0x97, 0x05, 0x00, 0x00, 0x93, 0x85, 0x35, 0x02,
  // 0x93, 0x08, 0x70, 0x00, 0x73, 0x00, 0x00, 0x00,
  // 0x93, 0x08, 0x20, 0x00, 0x73, 0x00, 0x00, 0x00,
  // 0xef, 0xf0, 0x9f, 0xff, 0x2f, 0x69, 0x6e, 0x69,
  // 0x74, 0x00, 0x00, 0x24, 0x00, 0x00, 0x00, 0x00,
  // 0x00, 0x00, 0x00, 0x00
  #include "include/initcode.h"
};

这样,我们就可以在 proc.c 中直接使用 initcode.h 中的机器码,而无需手动复制。

并且,我们并不需要每次修改代码都执行这一段代码,除非我们修改了 sysnum.h 中的系统调用号,否则我们可以一直保持这个 initcode.h 内容不变,也就不需要重新生成。

评测流程

测试程序

说了这么多,我们还是要明白自己在做什么。我们最核心的内容是,我们要完成的是一个 xv6 内核,而内核最主要的职责是提供系统调用,从而让用户程序(即测试程序)能够正常运行并产生预期的输出。

从而,我们并不是像 ICS 的 Lab 一样,编写代码完成一个个很复杂的用户程序,而是需要实现一系列系统调用,从而让已经在标准 Linux 下编译好的二进制用户程序能够在我们的内核上正常运行。而这些编译好的用户程序,就是我们在 Part0 中得到的 riscv64/ 目录下的那些二进制文件,他们的源码在 testsuits-for-oskernel/riscv-syscalls-testing/user/src/ 目录下,会测试同名的系统调用,并完成输出的比较。

  • 每个测试程序(例如 getcwd)被 init 启动后,会调用特定的系统调用。
  • 测试程序会根据系统调用的行为和返回值来判断功能是否正确,然后通过 printf 函数打印出标准格式的结果信息。
  • printf 的内容会通过 write 系统调用,由 QEMU 输出到标准输出流。
  • 评测系统在后台执行 QEMU 时,会捕获其所有的标准输出,并保存到一个文本文件中。
  • 测试运行结束后,评测脚本会将捕获到的输出内容与预先定义的标准答案文件进行文本比对。
  • 如果输出内容与标准答案完全一致,则测试通过。
  • 所有测试程序执行完毕后,init.c 调用 shutdown() 系统调用,通知 QEMU 退出。这可以标志评测结束,并避免因程序挂起而超时。

因此,我们虚拟磁盘中实际上包含两类程序,它们的用途不同。

| 特性 | 用户程序 (xv6-user/*.c) | 测试程序 (riscv64/*) | | -------------------------------- | ------------------------------- | ------------------------------------ | | 来源 | 项目提供的源代码 (ls.c 等) | 基于测试仓库代码预编译的二进制文件 | | 编译 | 通过 make build 在本地编译 | 无需编译,直接拷贝至虚拟磁盘 | | 用途 | 提供 Shell 环境和常用命令行工具 | 自动化测试内核的系统调用实现是否正确 | | 在虚拟磁盘 fs.img 中的位置 | /bin/ls, /bin/cat 等 | /getcwd, /write 等 |

想要检查这里,你只需要运行 qwe 进入容器后,执行 mount fs.img /mnt,然后再 cd /mnt,即可进行检查了。

修改 init.c

为了使得评测流程正常工作,我们还需要修改 init.c。回顾一下,init.c 编译出来的其实就是系统启动后的 /init 程序,而原始的 init.c 其实只干了一件事情就是启动一个 sh

if(pid == 0){
    exec("sh", argv);
    printf("init: exec sh failed\n");
    exit(1);
}

所以,我们需要修改 init.c,使其不再启动 sh,而是按顺序执行预先编译好的测试程序,并添加一行 shutdown() 调用,以确保系统正常关机。

// init: The initial user-level program

#include "kernel/include/types.h"
#include "kernel/include/stat.h"
#include "kernel/include/file.h"
#include "kernel/include/fcntl.h"
#include "xv6-user/user.h"

// char *argv[] = { "sh", 0 };
char* argv[] = { 0 };
char* tests[] = {
  "getcwd",
  "write",
  "getpid",
  "times",
  "uname",
};

int counts = sizeof(tests) / sizeof((tests)[0]);


int
main(void)
{
  int pid, wpid;

  // if(open("console", O_RDWR) < 0){
  //   mknod("console", CONSOLE, 0);
  //   open("console", O_RDWR);
  // }
  dev(O_RDWR, CONSOLE, 0);
  dup(0);  // stdout
  dup(0);  // stderr

  for(int i = 0; i < counts; i++){
    printf("init: starting sh\n");
    pid = fork();
    if(pid < 0){
      printf("init: fork failed\n");
      exit(1);
    }
    if(pid == 0){
      exec(tests[i], argv);
      printf("init: exec %s failed\n", tests[i]);
      exit(1);
    }

    for(;;){
      // this call to wait() returns if the shell exits,
      // or if a parentless process exits.
      wpid = wait((int *) 0);
      if(wpid == pid){
        // the shell exited; restart it.
        break;
      } else if(wpid < 0){
        printf("init: wait returned an error\n");
        exit(1);
      } else {
        // it was a parentless process; do nothing.
      }
    }
  }
  shutdown();
  return 0;
}

如此一来,以后继续完成 Lab 的剩余部分的时候,我们只需要将测试程序的名称添加到 tests 数组中,然后重新编译内核,就可以进行测试了。

简而言之,自动化评测流程的核心就是 修改 init.c捕获并比对输出

系统调用号对齐

在开始实现 Part 1 的系统调用之前,我们需要先解决一个关键问题:系统调用号的对齐

为什么需要系统调用号对齐?答案是 xv6-k210 项目本身已经实现了一些基本的系统调用,例如 writereadforkexec 等(具体有那些可以参见项目最原始的 sysnum.h)。这些系统调用让内核能够启动并运行 shell。

但是,这些系统调用在 xv6-k210 中使用的调用号,与测试用例使用的标准 Linux 系统调用号 不同

例如:

  • xv6-k210 原有的 SYS_write16
  • 而 Linux 标准的 SYS_write64

而我们的测试程序是基于标准 Linux 系统调用号编译的,它们在调用 write 时会使用系统调用号 64。如果我们的内核还在使用 16 作为 SYS_write 的编号,测试程序就无法正确调用到实现好的 sys_write 函数,也就会导致 unknown sys call 的报错。

同理,如果你修改了系统调用号,你就必须要重新生成 initcode,因为其中也会需要进行系统调用,否则就会出现类似如下报错:

pid 72 initcode: unknown sys call 16
pid 72 initcode: unknown sys call 2

所以这个时候,你只需要重新走一遍编译流程即可:

make clean
make build platform=qemu
./gen_initcode.sh
make local

现在我们已经了解了为什么需要对齐,那么接下来所需要做的就是参考 官方文档 进行一个对齐即可。

在这里,我们需要修改 kernel/include/sysnum.h 文件,将系统调用号改为标准的 Linux 调用号。

#ifndef __SYSNUM_H
#define __SYSNUM_H

// System call numbers


// Filesystem related (文件系统相关)
#define SYS_open        56   // 打开文件
#define SYS_close       57   // 关闭文件
#define SYS_read        63   // 从文件读取数据
#define SYS_write       64   // 向文件写入数据
#define SYS_fstat       80   // 获取文件状态
#define SYS_pipe        59   // 创建管道
#define SYS_dup         23   // 复制文件描述符
#define SYS_mkdir        7   // 创建目录
#define SYS_mkdirat     34   // 在指定目录下创建目录
#define SYS_chdir       49   // 改变当前工作目录
#define SYS_getcwd      17   // 获取当前工作目录
#define SYS_readdir     27   // 读取目录项
#define SYS_rename      26   // 重命名文件或目录
#define SYS_remove      117  // 删除文件或目录
#define SYS_unlinkat    35   // 在指定目录下删除文件
#define SYS_dev         21   // 设备文件操作


// Process management related (进程管理相关)
#define SYS_fork         1   // 创建子进程
#define SYS_clone      220   // 创建子进程/线程(更灵活的fork)
#define SYS_exec       221   // 执行新程序
#define SYS_exit        93   // 终止当前进程
#define SYS_wait         3   // 等待子进程结束
#define SYS_wait4      260   // 等待子进程结束(更通用的版本)
#define SYS_kill         6   // 向进程发送信号
#define SYS_getpid     172   // 获取当前进程ID
#define SYS_getppid    173   // 获取父进程ID
#define SYS_sleep       13   // 使进程休眠(秒)
#define SYS_nanosleep  101   // 使进程休眠(纳秒)
#define SYS_sched_yield 124  // 主动让出CPU
#define SYS_times      153   // 获取进程的执行时间


// Memory management related (内存管理相关)
#define SYS_sbrk        12   // 调整程序数据段(堆)的大小
#define SYS_brk        214   // 直接设置程序数据段的结束地址


// Others (其他)
#define SYS_gettimeofday 169 // 获取当前时间
#define SYS_uptime      14   // 获取系统自启动以来的运行时间
#define SYS_sysinfo     19   // 获取通用系统信息
#define SYS_uname      160   // 获取操作系统名称和版本等信息
#define SYS_shutdown   210   // 关闭系统
#define SYS_trace       18   // 用于调试,追踪系统调用
#define SYS_test_proc   22   // 自定义的测试调用

#endif

shutdown

根据文档所述,我们需要添加 shutdown 这一系统调用,用于避免 init 程序 return 后出现的 panic: init exiting 输出。

然而,经过我的实际测试,如果你已经按照前文操作,在不修改任何其他代码、只修改 initcode 机器码的情况下,其实完全不会出现这个问题。

询问助教,得到如下答复:上平台的时候需要 shutdown 进行退出,否则可能会产生超时等问题。如果上平台能正常退出有分,那你如何实现都可以。

所以,我们在这里还是先按照文档,进行一个添加。文档提供了各个步骤,但是没有详细解释为什么要这么做,我们这里进行一个补充讲解。

要添加 shutdown 系统调用,首先我们需要在 xv6-user/usys.pl 文件末尾添加:

entry("shutdown")

这会自动生成用户态的系统调用存根代码。

usys.pl 是一个 Perl 脚本,用于 自动生成汇编代码,为用户程序提供系统调用接口。它生成的是 usys.S 文件,如果你上过 ICS 课程做过 Bomblab 就会感觉非常熟悉:

# generated by usys.pl - do not edit
#include "kernel/include/sysnum.h"
.global shutdown
shutdown:
 li a7, SYS_shutdown    # 将系统调用号加载到 a7 寄存器
 ecall                  # 触发系统调用陷入内核
 ret                    # 返回用户态

但是,并不是我们添加任何一个系统调用都需要在这里进行注册,比如我们将在 Part 1 中完成的剩余几个系统调用就不需要,这是为什么呢?

答案是,由于我们的测评程序是预编译的外部二进制文件,所以他们已经自带了封装,他们甚至已经将这段汇编代码转为了更底层的二进制机器码,也即他们直接在需要的地方封装生成了诸如

 li a7, SYS_fork        # 将系统调用号(Linux 标准)加载到 a7 寄存器
 ecall                  # 触发系统调用陷入内核
 ret                    # 返回用户态

这样的代码,那当然不需要再生成这种标号了。

所以,需要在 usys.pl 注册的函数其实是你在 xv6 自己的用户程序(xv6-user/ 下的 .c 文件,如 init.c)想调用的函数,从而对于现在的 shutdown,我们需要进行注册。

你可以在完成 Part 1 后,随便在 init.c 中添加一个比如说 times() 的调用,然后类似的在 xv6-user/user.h 进行一个声明,但不进行 usys.pl 的注册,这是你就会发现,运行后报编译错误:

riscv64-linux-gnu-ld: xv6-user/init.o: in function `main':
/xv6/xv6-user/init.c:63: undefined reference to `times'

usys.pl 完成注册后,我们还需要在 sysnum.hsyscall.c 添加映射:

kernel/include/sysnum.h 中添加(前文已完成):

#define SYS_shutdown   210   // 关闭系统

kernel/syscall.c 中添加函数声明和映射:

// 在文件开头添加声明
extern uint64 sys_shutdown(void);

// 在 syscalls 数组中添加映射
static uint64 (*syscalls[])(void) = {
  // ... 其他系统调用 ...
  [SYS_shutdown]    sys_shutdown,
};

// 在 sysnames 数组中添加名称(用于调试)
static char *sysnames[] = {
  // ... 其他系统调用名称 ...
  [SYS_shutdown]    "shutdown",
};

然后,我们还需要在内核实现 sys_shutdown 函数。

这一步在 kernel/sysproc.c 中实现:

#include "include/types.h"
#include "include/sbi.h"

/**
 * @brief 实现 shutdown 系统调用,基于 SBI 调用实现
 * @return 0 成功,-1 失败
 */
uint64
sys_shutdown(void) {
    sbi_shutdown();
    return 0;
}

这个实现非常简单,只是调用了 SBI 层提供的关机功能。

这里又会有两个问题:

  1. 我们如何决定要在哪个 sys[?].c 中实现我们的系统调用?
  2. 为什么这里可以直接使用 SBI 的函数?

对于 1,决定在哪个 sys[?].c 文件中实现系统调用,主要依据该系统调用的功能类别。

观察可以发现,在 kernel 下,有四个文件分别以 sys[?].c 命名:

  • sysproc.c:用于实现与进程管理相关的系统调用,如:fork(), exec(), wait(), kill(), getpid()
  • sysfile.c:用于实现与文件和文件系统相关的系统调用,如:open(), read(), write(), close(), stat()
  • sysctl.c:用于 K210 芯片的系统控制器驱动,在指定 platform=qemu 时根本不会参与编译、链接。
  • syscall.c:用于实现系统调用分发,包含一个系统调用表(一个函数指针数组),根据用户传入的系统调用号,从这个表中查找到对应的实现函数(这些函数位于 sysproc.csysfile.c 等文件中)并调用它

从而,我们得知,对于 Part 1,最合理的组织方式是:

  • sysproc.c:实现 shutdowngetpidunametimes 系统调用
  • sysfile.c:实现 writegetcwd 系统调用
  • syscall.c:仅实现系统调用分发

对于 2,为什么这里可以直接使用 SBI 的函数?

先讲一下什么是 SBI。SBI (Supervisor Binary Interface) 是 RISC-V 架构定义的标准接口,用于:

  • Supervisor 模式(内核,S-mode)向 Machine 模式(固件,M-mode)请求服务
  • 通过 ecall 指令触发环境调用(Environment Call)

当程序运行到这行 sbi_shutdown() 时,实际上已经从用户态(U-mode)通过 ecall 指令陷入到了内核态(S-mode)。如果你点开这个函数看,你就会发现其本质就是经历了一系列的宏魔法,最终调用了 ecall 指令,从 S 模式再次陷入到 M 模式(Machine 模式),由底层的 SBI 固件(RustSBI)处理关机请求。

static inline void sbi_shutdown(void)
{
	SBI_CALL_0(SBI_SHUTDOWN);
}

最后,我们还需要在 xv6-user/user.h 中添加用户态声明以处理链接:

int shutdown(void); // call sbi_shutdown

现在,我们终于实现了 shutdown 系统调用,并且由于我们已经在 usys.pl 中完成了注册,所以可以直接在 xv6 自己的用户程序代码中直接使用。

xv6-user/init.cmain 函数末尾添加,我们便完成了这一部分:

int main(void) {
    // ... 测试程序执行逻辑 ...

    shutdown();
    return 0;
}

getcwd

首先贴一下原本仓库的代码:

// get absolute cwd string
uint64
sys_getcwd(void)
{
  uint64 addr;
  if (argaddr(0, &addr) < 0)
    return -1;

  struct dirent *de = myproc()->cwd;
  char path[FAT32_MAX_PATH];
  char *s;
  int len;

  if (de->parent == NULL) {
    s = "/";
  } else {
    s = path + FAT32_MAX_PATH - 1;
    *s = '\0';
    while (de->parent) {
      len = strlen(de->filename);
      s -= len;
      if (s <= path)          // can't reach root "/"
        return -1;
      strncpy(s, de->filename, len);
      *--s = '/';
      de = de->parent;
    }
  }

  // if (copyout(myproc()->pagetable, addr, s, strlen(s) + 1) < 0)
  if (copyout2(addr, s, strlen(s) + 1) < 0)
    return -1;

  return 0;

}

根据助教提供的 官方文档,我们得到其在 Linux 下的标准用法:

getcwd(char *buf, size_t size):获取当前程序所在的文件夹路径

参数:

  • buf:你提供的一块内存(字符数组),用来存放路径结果。
  • size:你提供的内存块的大小。

输出:

  • 成功:返回一个指向 buf 的指针,此时 buf 里已经存好了路径字符串。
  • 失败:返回 NULL

从而,我们发现,我们需要在原有代码的基础上,添加一个参数 size,用于限制路径字符串的长度。

于是,我们得到代码如下:

/**
 * @brief 实现 getcwd 系统调用
 * @note 这段代码的实现是这样的,先构建一个 path 缓冲区,然后从末尾开始写,形成类似 [ ...垃圾数据... | /home/user\0 ] 这样的路径
 * @note 然后,再利用 memmove 将这个路径移动到开始,形成 [ /home/user\0 | ...垃圾数据... ] 这样的路径
 * @note 最后,再利用 copyout2 将这个路径从内核栈中拷贝到用户空间
 * @return 0 成功,-1 失败
 */
uint64
sys_getcwd(void) {
  uint64 addr;
  int size;
  if (argaddr(0, &addr) < 0 || argint(1, &size) < 0)
    return NULL;

  struct dirent* de = myproc()->cwd;
  char path[FAT32_MAX_PATH];

  char* s = path + sizeof(path) - 1;
  *s = '\0';

  if (de->parent == NULL) {
    s--;
    *s = '/';
  }
  else {
    while (de->parent) {
      int len = strlen(de->filename);
      s -= len;
      if (s < path)
        return NULL;
      memmove(s, de->filename, len);

      s--;
      if (s < path)
        return NULL;
      *s = '/';

      de = de->parent;
    }
  }

  memmove(path, s, strlen(s) + 1);

  int path_length = strlen(path) + 1;
  if (path_length > size) {
    return NULL;
  }

  if (copyout2(addr, path, strlen(path) + 1) < 0)
    return NULL;

  return addr;
}

注意在这里我们的函数列表是 void,请记住,我们现在是在进行内核编程,而不是编写一个简单的用户程序,可以直接通过函数传参来获得参数,学过 ICS 的同学都知道,用户态编程我们会使用寄存器或者压栈的方式来传参,但是我们现在是进行系统调用,已经发生了从用户态到内核态的转换,此时,用户空间的参数(addr 目标地址和 size 地址大小)并不会像普通函数调用那样被压入内核的函数栈。

这是因为,用户进程有自己独立的虚拟地址空间。内核也有自己的地址空间,两者之间是独立的。

所以,在陷入内核时,会进行一个叫做 上下文保存 的操作,CPU 会把用户态的寄存器值(包括存有参数的那些寄存器)保存在一个叫做 陷阱帧 (Trapframe) 的内核数据结构中。

相对的,当我们从内核态返回用户态时,也会进行一个叫做 上下文恢复 的操作,CPU 会把之前保存在陷阱帧(Trapframe)里的、属于用户态的寄存器值恢复,并且恢复到用户态的虚拟地址空间。

从而,我们要想获得函数调用的参数,就必须使用特殊的函数 argaddr()argint() 从当前进程的陷阱帧中安全地提取出用户传递的参数

  • argaddr(0, &addr):获取第 0 个参数,它应该是一个地址(指针),并把它存入内核变量 addr 中。
  • argint(1, &size):获取第 1 个参数,它应该是一个整数,并把它存入内核变量 size 中。

并且,当我们想要将返回值返回给用户时,必须使用特殊的函数 copyout2() 将内核空间的数据拷贝到用户空间。

最后,简单贴一下这里用到的两个函数的签名和说明:

  • memmove(void *dest, const void *src, size_t n):将 src 指向的内存块的前 n 个字节复制到 dest 指向的内存块中。
  • copyout2(void *dest, const void *src, size_t n):将 src 指向的内存块(内核空间)的前 n 个字节复制到 dest 指向的内存块(用户空间)中(其实也就是封装了一下 memmove 函数)。

write / getpid

直接修改系统调用号就行,不需要自己实现,没啥可说的。

times

根据助教提供的 官方文档,我们得到其在 Linux 下的标准用法:

clock_t times(struct tms *buf);:获取当前进程及其已终结子进程的 CPU 使用时间。

参数:

  • buf:你提供的一个指向 struct tms 结构体的指针,用来存放结果。

输出:

  • 成功:返回一个 clock_t 类型的值,表示系统某个时间点以来经过的时钟节拍数。同时,buf 指向的结构体被成功填充了时间信息。
  • 失败:返回 (clock_t) -1

这个测试点比较逆天的是,它真的只校验返回值合理性,只需要确保 tms 结构体内的值的含义符合预期即可。

这意味着你哪怕创建一个全 0 的结构体也无所谓,只需要把结构体按照示例弄出来,字段对就行了。

我们在 kernel/include/timer.h 中添加结构体定义:

// 用于 sys_times 系统调用所定义的结构体
// ref: https://man7.org/linux/man-pages/man2/times.2.html
struct tms {
    long tms_utime; // 用户态时间,user time
    long tms_stime; // 系统态时间,system time
    long tms_cutime; // 子进程用户态时间,child user time
    long tms_cstime; // 子进程系统态时间,child system time
};

然后结合 kernel/timer.c 中定义的全局变量 ticks 和自旋锁 tickslock 来进行返回即可:

/**
 * @brief 实现 times 系统调用,返回自启动以来经过的操作系统 tick 数。
 * @param addr tms 结构体存到的目标地址
 * @return 0 成功,-1 失败
 */
uint64 sys_times(void) {
  struct tms tms;

  acquire(&tickslock);
  tms.tms_utime = tms.tms_stime = tms.tms_cutime = tms.tms_cstime = ticks;
  release(&tickslock);

  if (get_and_copyout(0, (char *)&tms, sizeof(tms)) < 0) {
    return -1;
  }

  return 0;
}

这里用到了一个辅助函数,是对 argaddrcopyout2 的一个简单封装:

/**
 * @brief 从系统调用参数(a0-a5寄存器)中获取一个用户空间的目标地址,然后将内核中的某块数据拷贝到这个目标地址去。
 * @param arg_index 系统调用参数的索引
 * @param dest 目标地址
 * @param size 数据大小
 * @return 0 成功,-1 失败
 */
int get_and_copyout(uint64 arg_index, char* src, uint64 size) {
  uint64 dest_addr;
  if (argaddr(arg_index, &dest_addr) < 0) {
    return -1;
  }
  if (copyout2(dest_addr, src, size) < 0) {
    return -1;
  }
  return 0;
}

现在如果进行测试,你会发现输出是:

========== START test_times ==========
mytimes success
{tms_utime:0, tms_stime:0, tms_cutime:0, tms_cstime:0}
========== END test_times ==========

题外话,也有看到前辈使用 r_time() 函数基于硬件 CPU 周期数来写的,即:

// System call to get process times
uint64 sys_times(void) {
    struct tms tm;
    uint tick = r_time();

    tm.tms_utime = tm.tms_stime = tm.tms_cutime = tm.tms_cstime = tick / 1000000;

    if (get_and_copy(0, &tm, sizeof(tm)) < 0)
        return -1;

    return 0;
}

如果对其进行测试,你会发现输出是:

========== START test_times ==========
mytimes success
{tms_utime:3, tms_stime:3, tms_cutime:3, tms_cstime:3}
========== END test_times ==========

虽然都能过点,但是我认为第一种才是对的,因为如果依靠 CPU 周期数来进行计算,显然和接口描述不太一致,而且 1000000 也不知道是哪里弄出来的魔法数字...

uname

没啥说的,和 times 几乎一样,唯一的区别是结构体要求变了,参照 官方代码,我们在 kernel/sysproc.c 下创建即可:

/**
 * @brief 实现 uname 系统调用,返回操作系统名称和版本等信息。
 * @param addr 目标地址
 * @return 0 成功,-1 失败
 */
uint64 sys_uname(void) {
  struct uname_info {
    char sysname[65];
    char nodename[65];
    char release[65];
    char version[65];
    char machine[65];
    char domainname[65];
  };

  // 这个数据当前是准备在内核的栈内存中的
  struct uname_info info = {
    "xv6",
    "xv6-node",
    "1.0.0",
    "1.0.0",
    "arthals",
    "localhost"
  };

  if (get_and_copyout(0, (char *)&info, sizeof(info)) < 0) {
    return -1;
  }

  return 0;
}

测试

至此,我们已经完成了 Part1 的全部内容,只需要进行测试即可:

qwe
make clean
make local

得到输出:

hart 0 init done
init: starting sh
========== START test_getcwd ==========
getcwd: / successfully!
========== END test_getcwd ==========
init: starting sh
========== START test_write ==========
Hello operating system contest.
========== END test_write ==========
init: starting sh
========== START test_getpid ==========
getpid success.
pid = 4
========== END test_getpid ==========
init: starting sh
========== START test_times ==========
mytimes success
{tms_utime:1, tms_stime:1, tms_cutime:1, tms_cstime:1}
========== END test_times ==========
init: starting sh
========== START test_uname ==========
Uname: xv6 xv6-node 1.0.0 1.0.0 arthals localhost
========== END test_uname ==========

就代表我们完成了这部分实验。

💾

  •  

更适合北大宝宝体质的 xv6 OS Lab 踩坑记 - Part0

2025年10月3日 00:17

[!CAUTION]

致各位同学:本笔记的撰写目的是用作参考,请勿直接抄袭,否则后果自负。

另外,请注意,本文撰写基于我修改过的 initcode.SMakefile 文件,如果你是基于原始仓库所编写的,可能需要手动进行一些初始化修改。

操作系统的大作业看起来令人生畏,初次上手根本不知道各个部分在干啥,也不知道整个项目是如何跑起来的。文档看似很多但是东一块西一块的完全串不起来,所以我动笔记录一下自己完成这个大作业的过程,希望能帮助到后来者。

项目基础流程

xv6 OS Lab 的核心目标是通过修改一个简单的操作系统内核,来学习操作系统的核心概念。

在开始之前,让我们首先介绍一下整个项目是如何跑起来的:

  1. QEMU 启动:QEMU 模拟器为操作系统提供了虚拟的硬件环境,包括 CPU、内存和硬盘。
  2. 引导加载(Bootloader)RustSBI 程序首先运行,它负责初始化虚拟硬件,并将内核文件加载到内存中。
  3. 内核运行:CPU 开始执行内核代码。内核对各项系统服务进行初始化,例如进程管理和内存管理。
  4. 内核初始化,挂载 fs.img:内核初始化后,会挂载 fs.img 文件系统。
  5. 创建第一个进程,运行 initcode:内核创建第一个进程,并运行 initcode 程序。
  6. initcode 执行 exec("/init")initcode 程序执行 exec("/init") 系统调用,加载 /init 程序。
  7. /init 程序接管,开始执行测试或启动 Shell/init 程序接管,开始执行测试或启动 Shell。

完成以上步骤后,操作系统启动完毕,并将控制权交给用户程序。

常用命令

在整个项目的运行过程中,你会经常性地使用到 Docker 和 Make 两个工具。

其中,Docker 需要进行安装,而 Make 命令应当打包在了 Docker 环境中。

Docker

安装 Docker 可以详见助教提供的文档,或者参照 官方指引 安装:

# Add Docker's official GPG key:
sudo apt-get update
sudo apt-get install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# Add the repository to Apt sources:
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update

然后安装:

sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

不过你可能遇到 Could not handshake 问题,此时我们强制使用 IPv4 来安装:

sudo apt-get -o Acquire::ForceIPv4=true install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

你可能还需要将当前用户添加到 docker 组:

sudo usermod -aG docker $USER

然后重启终端(Ctrl + D,或者 exit,然后重新 ssh 到服务器)。

考虑到 Clab 位于境内,所以你可能需要先添加 Docker 镜像源:

sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json <<-'EOF'
{
  "registry-mirrors": [
    "https://docker.1panel.live/"
  ]
}
EOF
sudo systemctl daemon-reload
sudo systemctl restart docker

助教提供了一个一键启动 Docker 的命令,我们对之稍加修改,得到

docker run -ti --rm -v ./:/xv6 -w /xv6 --privileged=true docker.educg.net/cg/os-contest:2024p6 /bin/bash

其中:

  • -v ./:/xv6 表示将当前目录挂载到容器内的 /xv6 目录
  • -w /xv6 表示将工作目录设置为 /xv6,这样你每次启动 Docker 后都会自动进入 /xv6 目录
  • -ti 表示打开一个交互式的伪终端,一般和 /bin/bash 连用(这里其实是 -t -i,所以写成 -it 一样);
  • --rm 表示每次退出容器后自动删除,在我的使用场景下不需要向评测镜像里安装写入其他东西,所以如此设置;也可以使用 --restart=always 指定每次都会重启容器,该选项和 --rm 互斥;
  • --privileged=true 指定启动特权容器,拥有容器内的所有 capabilities,否则后文 make fs 会出错;
  • docker.educg.net/cg/os-contest:2024p6 对应助教提供的镜像名称和 tag。

很长对吧,出于简便起见,你可以直接使用别名(Alias),通过在 ~/.bashrc 或者 ~/.zshrc 中添加如下内容:

alias qwe='docker run -ti --rm -v ./:/xv6 -w /xv6 --privileged=true docker.educg.net/cg/os-contest:2024p6 /bin/bash'

然后重载一下 Shell(删了重新创建,或者使用 . ~/.bashrc 或者 . ~/.zshrc)。

这样,你每次就可以直接使用 qwe 进入 Docker 环境了。

Make

Make 是一个用来进行项目构建的工具,其可以将一系列命令组合成一个命令,从而简化操作。

Makefile 文件定义了这些简便的命令背后要执行的命令,他们的依赖链如下:

runfsbuild → ($T/kernel + userprogs)

其中你需要重点关注以下几个命令:

make build

build: $T/kernel userprogs

此命令执行两个核心任务:

  1. $T/kernel 编译内核可执行文件
    • 它依赖于一系列的内核目标文件 $(OBJS)
    • make 会自动查找并执行编译命令,将每个 .c.S 源文件(如 printf.c, vm.c)编译成对应的 .o 目标文件。
    • 所有 .o 文件编译完成后,make 会执行链接命令,将它们链接成最终的内核文件 $T/kernel
  2. userprogs 编译所有用户程序
    • 它依赖于 $(UPROGS) 列表中的所有用户程序(如 $U/_sh)。
    • 对于每个用户程序(以 _sh 为例),它依赖于对应的 .o 文件(sh.o)和用户库 $(ULIB)
    • make 先将 sh.c 编译成 sh.o,然后将其与用户库链接,生成可执行文件 $U/_sh

make fs

fs: $(UPROGS)
	@if [ ! -f "fs.img" ]; then \
		echo "making fs image..."; \
		dd if=/dev/zero of=fs.img bs=512k count=512; \
		mkfs.vfat -F 32 fs.img; fi
	@mount fs.img $(dst)
	@if [ ! -d "$(dst)/bin" ]; then mkdir $(dst)/bin; fi
	@cp README $(dst)/README
	@for file in $$( ls $U/_* ); do \
		cp $$file $(dst)/$${file#$U/_};\
		cp $$file $(dst)/bin/$${file#$U/_}; done
	@cp -r riscv64/* $(dst)
	@umount $(dst)

此命令用于创建文件系统镜像,生成 fs.img 虚拟磁盘文件。

  1. 创建镜像文件:如果 fs.img 不存在,则创建一个 256MB 的空白文件。
  2. 格式化:将该文件格式化为 FAT32 文件系统。
  3. 拷贝文件:将 make build 生成的所有用户程序和 riscv64/ 目录下的所有测试程序,拷贝到 fs.img 中。

注意:make fs 必须在 make build 之后执行。

这里相对于原版仓库还有一行额外的修改,即添加 @cp -r riscv64/* $(dst) 这一行,将 riscv64/ 目录下的所有测试程序拷贝到 fs.img 中。这是出于评测需要所添加的。

你可能会好奇为什么需要额外创建一个 fs.img 作为虚拟磁盘,而不是直接将所有东西都打包到内核中,其实就和你自己电脑一样,你的操作系统内核本身是不包含任何用户程序(如 shlscat 等命令)的,它只负责最核心的功能(如进程管理、内存管理、文件系统等)。所以,内核会在启动后,通过挂载 fs.img 的方式将所有用户程序加载到内存中,从而才能运行这些用户程序。

而且,fs.img 作为虚拟磁盘,是持久化存储设备,它会被 QEMU 模拟器作为块设备挂载,为操作系统提供了一个可以进行文件和目录操作(如读写、创建、删除)的空间,从而能够测试文件系统的相关功能。

make run

此命令使用 QEMU 模拟器来运行操作系统。

run: build
	@$(QEMU) $(QEMUOPTS)
  • 它会加载 target/kernel 作为内核运行。
  • 它会将 fs.img 作为虚拟硬盘挂载。

注意:make run 依赖 make build。此命令不会自动更新 fs.img,如果用户程序有变动,需要先手动执行 make fs

make local

这段代码需要你手动添加。

local:
	@make build platform=qemu
	@make fs
	@$(QEMU) $(QEMUOPTS)

基本上就是把前面这些东西按照依赖顺序组合在一起执行一遍,从而简化操作流程。

make all

这段代码需要你手动添加。

all: build
	@cp $(T)/kernel ./kernel-qemu
	@cp ./bootloader/SBI/sbi-qemu ./sbi-qemu

这个是出于评测需要添加的,它会将内核和引导程序(sbi-qemu)拷贝到当前目录,从而方便评测系统进行评测。

ref:需要在 Makefile 里指定 target all 的行为,这将编译你的项目内核,并产生 kernel-qemu 这个二进制文件和 sbi-qemu 这个二进制文件(我们在本地运行时,它已经被放在 bootloader 目录下了,无需重新编译),这两个文件需要出现在根目录下,因此请自行在 Makefile 里用 cp 指令把它们以正确的名字放到正确的位置。

make clean

clean:
	rm -f *.tex *.dvi *.idx *.aux *.log *.ind *.ilg \
	*/*.o */*.d */*.asm */*.sym \
	$T/* \
	$U/initcode $U/initcode.out \
	$K/kernel \
	.gdbinit \
	$U/usys.S \
	$(UPROGS)

此命令用于删除所有编译生成的中间文件和最终产品,如 .o 文件和 target/ 目录的内容。

注意make clean 不会删除 fs.img 文件。如需重新生成,要手动删除该文件。

测评相关

不是很懂流程,但按照文档操作就完了,你需要从 oscomp/testsuits-for-oskernel 这个仓库下载测试样例并编译(需要先切换到 main 分支):

git clone https://github.com/oscomp/testsuits-for-oskernel.git
cd testsuits-for-oskernel
git checkout main
rm -rf .git

然后,使用如下命令进入 Docker 环境:

docker run -ti --rm -v ./riscv-syscalls-testing:/testing -w /testing/user --privileged=true docker.educg.net/cg/os-contest:2024p6 /bin/bash

进入容器后,直接执行:

sh build-oscomp.sh

执行完毕后,使用 Ctrl + D 退出容器,打包出来的产物在 ./riscv-syscalls-testing/user/build/riscv64 目录下。

将这个目录完整复制到你的项目根目录下即可。

你也可以直接 clone 本项目,然后直接使用 git reset --hard 命令到第一次提交即可。

完成以上内容后,你便完成了所有的准备工作,可以开始做 Lab 了。

你可以使用 qwe 命令进入临时容器,然后使用 make local 命令来进行一个简单的测试,你得到的结果应该类似于:

root@3b8b89279200:/xv6# make local
make[1]: Entering directory '/xv6'
make[1]: Nothing to be done for 'build'.
make[1]: Leaving directory '/xv6'
make[1]: Entering directory '/xv6'
make[1]: Leaving directory '/xv6'
[rustsbi] RustSBI version 0.3.0-alpha.2, adapting to RISC-V SBI v1.0.0
.______       __    __      _______.___________.  _______..______   __
|   _  \     |  |  |  |    /       |           | /       ||   _  \ |  |
|  |_)  |    |  |  |  |   |   (----`---|  |----`|   (----`|  |_)  ||  |
|      /     |  |  |  |    \   \       |  |      \   \    |   _  < |  |
|  |\  \----.|  `--'  |.----)   |      |  |  .----)   |   |  |_)  ||  |
| _| `._____| \______/ |_______/       |__|  |_______/    |______/ |__|
[rustsbi] Implementation     : RustSBI-QEMU Version 0.2.0-alpha.2
[rustsbi] Platform Name      : riscv-virtio,qemu
[rustsbi] Platform SMP       : 1
[rustsbi] Platform Memory    : 0x80000000..0x82000000
[rustsbi] Boot HART          : 0
[rustsbi] Device Tree Region : 0x81000000..0x81000ef2
[rustsbi] Firmware Address   : 0x80000000
[rustsbi] Supervisor Address : 0x80200000
[rustsbi] pmp01: 0x00000000..0x80000000 (-wr)
[rustsbi] pmp02: 0x80000000..0x80200000 (---)
[rustsbi] pmp03: 0x80200000..0x82000000 (xwr)
[rustsbi] pmp04: 0x82000000..0x00000000 (-wr)
  (`-.            (`-.                            .-')       ('-.    _   .-')
 ( OO ).        _(OO  )_                        .(  OO)    _(  OO)  ( '.( OO )_
(_/.  \_)-. ,--(_/   ,. \  ,--.                (_)---\_)  (,------.  ,--.   ,--.) ,--. ,--.
 \  `.'  /  \   \   /(__/ /  .'       .-')     '  .-.  '   |  .---'  |   `.'   |  |  | |  |
  \     /\   \   \ /   / .  / -.    _(  OO)   ,|  | |  |   |  |      |         |  |  | | .-')
   \   \ |    \   '   /, | .-.  '  (,------. (_|  | |  |  (|  '--.   |  |'.'|  |  |  |_|( OO )
  .'    \_)    \     /__)' \  |  |  '------'   |  | |  |   |  .--'   |  |   |  |  |  | | `-' /
 /  .'.  \      \   /    \  `'  /              '  '-'  '-. |  `---.  |  |   |  | ('  '-'(_.-'
'--'   '--'      `-'      `----'                `-----'--' `------'  `--'   `--'   `-----'
hart 0 init done
init: starting sh
-> / $

这个时候,使用 Ctrl + A(这个是 QEMU 的前缀组合键,表明你接下来输入的命令是给 QEMU 的,而不是给虚拟机的),再按下 X 键,即可退出 QEMU。再输入一次 Ctrl + D,即可退出容器。

💾

  •  

如何彻底解决 Cursor Remote-SSH Server 下载问题

2025年8月17日 09:02

由于众所周知的原因,某些情况下国内进行下载会遇到一些问题。我尝试过如下方法,但都不太稳定,完全搞不明白这个插件的下载黑箱:

  1. 启用 remote.SSH.localServerDownload 选项,本地下载后 scp 上去
  2. 启用 remote.SSH.useCurlAndWgetConfigurationFiles 选项,然后在远程服务器的 ~/.wgetrc 中配置代理

终于,在又一次被蜗牛一样的下载速度恶心到后,我谷歌了一下,最终得到了最可控、稳定的解决方案:使用脚本进行下载,然后直接解压到目标目录。

这个脚本支持如下功能:

  • 支持 SSH 别名和 user@host
  • 支持远程直接下载 (-r, --remote),直接在远程服务器上进行下载,默认行为是在本地下载后 scp 上去
  • 支持通过代理下载 (-p, --proxy),默认不开启代理

假设你将其放到了某个环境变量目录后(比如我就放到了 ~/APTX4869 目录下),重命名为 cs(cursor server),那么你就可以通过如下命令进行下载:

cs art
cs art -r
cs user@host -r
cs user@host -p http://127.0.0.1:7890

本脚本修改自 这里,感谢原作者。

#!/bin/bash

# =========================================================
# Cursor Remote Server Deployment Script (cs) - v4 (Final)
#
# 功能:
# - 支持 SSH 别名和 user@host
# - 支持远程直接下载 (-r, --remote)
# - 支持通过代理下载 (-p, --proxy)
# - 自动清理本地临时文件
# =========================================================

# ==================== 架构配置 ====================
REMOTE_ARCH="x64" # 可选 "x64" 或 "arm64"
REMOTE_OS="linux"

# ==================== 脚本函数 ====================

# 打印带颜色的消息
print_message() {
    local color=$1 message=$2
    case $color in
        "green") echo -e "\033[0;32m$message\033[0m" ;;
        "red")   echo -e "\033[0;31m$message\033[0m" ;;
        "yellow")echo -e "\033[0;33m$message\033[0m" ;;
        "blue")  echo -e "\033[0;34m$message\033[0m" ;;
        *)       echo "$message" ;;
    esac
}

# 打印用法
print_usage() {
    print_message "red" "错误:参数不正确。"
    print_message "yellow" "用法: $0 [选项] <ssh_alias | user@host>"
    print_message "yellow" "  <ssh_alias|user@host>: 目标主机 (必需)。"
    print_message "yellow" "选项:"
    print_message "yellow" "  -r, --remote:          在远程服务器上直接下载,不经过本地。"
    print_message "yellow" "  -p, --proxy <URL>:     使用代理下载 (例如 http://127.0.0.1:7890)。"
    print_message "yellow" "  -h, --help:            显示此帮助信息。"
    exit 1
}

# 检查命令是否存在
check_command() {
    if ! command -v "$1" &> /dev/null; then
        print_message "red" "错误: 命令 '$1' 未找到,请先安装。"
        exit 1
    fi
}

# 获取 Cursor 版本信息
get_cursor_version() {
    print_message "blue" "正在获取本地 Cursor 版本信息..."
    local version_info
    if ! version_info=$(cursor --version 2>/dev/null); then
        print_message "red" "错误: 'cursor' 命令执行失败。请确保 Cursor 已正确安装并位于 PATH 中。"
        exit 1
    fi
    CURSOR_VERSION=$(echo "$version_info" | sed -n '1p')
    CURSOR_COMMIT=$(echo "$version_info" | sed -n '2p')
    print_message "green" "成功获取 Cursor 信息 (版本: $CURSOR_VERSION, Commit: $CURSOR_COMMIT)"
}

# 在本地下载服务器文件
download_locally() {
    print_message "blue" "准备在本地下载 Cursor Server..."
    local PROXY_OPT=""
    if [ -n "$PROXY_URL" ]; then
        PROXY_OPT="--proxy $PROXY_URL"
        print_message "yellow" "使用本地代理: $PROXY_URL"
    fi
    
    DOWNLOAD_PATH="$LOCAL_DOWNLOAD_DIR/$DOWNLOAD_FILENAME"
    print_message "yellow" "下载链接: $DOWNLOAD_URL"
    
    if curl -L --progress-bar $PROXY_OPT "$DOWNLOAD_URL" -o "$DOWNLOAD_PATH"; then
        print_message "green" "Cursor Server 下载成功!"
    else
        print_message "red" "下载失败!"
        exit 1
    fi
}

# 上传并部署
upload_and_deploy() {
    print_message "blue" "准备上传到远程服务器: $REMOTE_TARGET"
    
    print_message "yellow" "正在上传并部署..."
    # 通过 && 将创建、上传、解压、清理命令串联执行,确保原子性
    ssh "$REMOTE_TARGET" "mkdir -p $REMOTE_SERVER_PATH" && \
    scp "$DOWNLOAD_PATH" "${REMOTE_TARGET}:${REMOTE_TMP_TAR_PATH}" && \
    ssh "$REMOTE_TARGET" "tar -xzf $REMOTE_TMP_TAR_PATH -C $REMOTE_SERVER_PATH --strip-components=1 && rm $REMOTE_TMP_TAR_PATH"
    
    if [ $? -ne 0 ]; then
        print_message "red" "上传或远程部署失败!"
        exit 1
    fi
    print_message "green" "部署成功!"
}

# 清理本地下载目录
cleanup_local() {
    print_message "blue" "清理本地临时目录..."
    if [ -d "$LOCAL_DOWNLOAD_DIR" ]; then
        rm -rf "$LOCAL_DOWNLOAD_DIR"
        print_message "green" "本地临时目录已删除: $LOCAL_DOWNLOAD_DIR"
    fi
}

# 在远程服务器上直接部署
deploy_remotely() {
    print_message "blue" "准备在远程服务器上直接下载和部署: $REMOTE_TARGET"
    
    local REMOTE_PROXY_CMD=""
    if [ -n "$PROXY_URL" ]; then
        REMOTE_PROXY_CMD="export https_proxy='${PROXY_URL}' http_proxy='${PROXY_URL}';"
        print_message "yellow" "将在远程服务器上使用代理: $PROXY_URL"
    fi

    # 核心修正: 移除了路径变量周围的引号,以确保远程 shell 能正确进行 tilde(~) 扩展
    ssh "$REMOTE_TARGET" "
        ${REMOTE_PROXY_CMD}
        set -e # 如果任何命令失败,立即退出脚本
        
        echo -e '\033[0;33m正在检查远程 wget 或 curl 命令...\033[0m'
        if ! command -v wget &> /dev/null && ! command -v curl &> /dev/null; then
            echo -e '\033[0;31m错误: 远程服务器上未找到 wget 或 curl。\033[0m'; exit 1;
        fi
        
        echo -e '\033[0;33m正在创建目录...\033[0m'
        mkdir -p $REMOTE_SERVER_PATH
        
        echo -e '\033[0;33m正在下载文件 (通过代理: ${PROXY_URL:-无})...\033[0m'
        if command -v wget &> /dev/null; then
            wget -q -O $REMOTE_TMP_TAR_PATH \"$DOWNLOAD_URL\"
        else
            curl -L -o $REMOTE_TMP_TAR_PATH \"$DOWNLOAD_URL\"
        fi
        
        echo -e '\033[0;33m正在解压文件...\033[0m'
        tar -xzf $REMOTE_TMP_TAR_PATH -C $REMOTE_SERVER_PATH --strip-components=1
        
        echo -e '\033[0;33m正在清理临时文件...\033[0m'
        rm $REMOTE_TMP_TAR_PATH
        
        echo -e '\033[0;32m远程部署成功!\033[0m'
    "
    if [ $? -ne 0 ]; then
        print_message "red" "远程部署脚本执行失败!"
        exit 1
    fi
}

# ==================== 主程序 ====================

# --- 参数解析 ---
REMOTE_DOWNLOAD=false
REMOTE_TARGET=""
PROXY_URL=""

if [ "$#" -eq 0 ]; then
    print_usage
fi

while [[ "$#" -gt 0 ]]; do
    case $1 in
        -r|--remote)
            REMOTE_DOWNLOAD=true
            shift
            ;;
        -p|--proxy)
            if [ -z "$2" ]; then
                print_message "red" "错误: -p/--proxy 选项需要一个代理地址参数。"
                print_usage
            fi
            PROXY_URL="$2"
            shift 2
            ;;
        -h|--help)
            print_usage
            ;;
        -*)
            print_message "red" "未知选项: $1"
            print_usage
            ;;
        *)
            if [ -n "$REMOTE_TARGET" ]; then
                print_message "red" "错误:只能指定一个目标主机。"
                print_usage
            fi
            REMOTE_TARGET="$1"
            shift
            ;;
    esac
done

if [ -z "$REMOTE_TARGET" ]; then
    print_usage
fi

# --- 脚本执行 ---
check_command "cursor"
check_command "ssh"
if [ "$REMOTE_DOWNLOAD" = false ]; then
    check_command "curl"
    check_command "scp"
fi

get_cursor_version

# 定义通用变量
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
LOCAL_DOWNLOAD_DIR="$SCRIPT_DIR/cursor_downloads"
DOWNLOAD_URL="https://cursor.blob.core.windows.net/remote-releases/${CURSOR_VERSION}-${CURSOR_COMMIT}/vscode-reh-${REMOTE_OS}-${REMOTE_ARCH}.tar.gz"
DOWNLOAD_FILENAME="cursor-server-${CURSOR_VERSION}-${CURSOR_COMMIT}-${REMOTE_OS}-${REMOTE_ARCH}.tar.gz"
REMOTE_SERVER_PATH="~/.cursor-server/cli/servers/Stable-${CURSOR_COMMIT}/server/"
REMOTE_TMP_TAR_PATH="~/.cursor-server/cursor-server.tar.gz"

# 确认操作
print_message "blue" "--------------------------------------------------"
print_message "blue" "目标主机: $REMOTE_TARGET"
print_message "blue" "远程路径: $REMOTE_SERVER_PATH"
if [ "$REMOTE_DOWNLOAD" = true ]; then
    print_message "blue" "模式: 远程直接下载"
else
    print_message "blue" "模式: 本地下载后上传"
fi
if [ -n "$PROXY_URL" ]; then
    print_message "blue" "代理地址: $PROXY_URL"
fi
print_message "blue" "--------------------------------------------------"
read -p "$(print_message 'yellow' '是否继续? [y/N]: ')" -r confirmation

if [[ ! $confirmation =~ ^[Yy]$ ]]; then
    print_message "yellow" "操作已取消。"
    exit 0
fi

# 根据模式执行
if [ "$REMOTE_DOWNLOAD" = true ]; then
    deploy_remotely
else
    mkdir -p "$LOCAL_DOWNLOAD_DIR"
    download_locally
    upload_and_deploy
    cleanup_local
fi

print_message "green" "脚本执行完毕!"

💾

  •  

雀魂 MajsoulMax / Akagi / MajsoulCopilot / MajsoulHelper 使用教程

2025年7月17日 23:03

import { GithubCard } from 'astro-pure/advanced' import { Aside } from 'astro-pure/user'

  1. 本文及出现的我的相关项目仅供学习参考之用,请勿用于商业用途,请使用者于下载 24 小时内删除相关内容。
  2. 不建议使用 AI 代打(但你可以适当使用 AI 辅助学习如何打牌可以最大化牌效、如何防守等),这于你的技术水平无益,AI 上去的分也没有意义,而且使用自动代打极其容易被封号。
  3. 如果本文或相关项目对你有帮助,欢迎 Star。
  4. 四麻游戏,再也不玩了,这个 b 游戏就不知道是谁在赢,拼尽全力无法战胜旺仔和猫粮,怒删之 —— 2025.08.26

MajsoulMax

雀魂 Max 可用于本地解锁全角色、皮肤、装扮等。

原始 Python 仓库:

基于 Rust 重构的仓库:

在前者基础上,外加 TinyProxy 做鉴权并封装为了 Docker 镜像:

MajsoulMax 初始化的时候会自动下载更新 liqi 这一依赖,请保证你的网络环境可以正常访问 GitHub。

同时,建议关闭 MajsoulMax 的 helper 功能。

解锁原理

首先我们要明白,MITM(Man-in-the-Middle) 中间人攻击的本质是在雀魂游戏进程和服务器之间插入一个代理。当游戏客户端发送请求到服务器,或服务器返回响应给客户端时,这些网络流量都会经过我们启动的代理程序。

也即,原来是:

game -> server

现在变成了:

game -> proxy -> server

代理程序会识别出特定的游戏数据包(Protobuf 格式),并根据预设的规则在本地对其进行实时修改,将“未拥有”的角色或装扮数据修改为“已拥有”。修改后的数据包再被发送到游戏客户端,从而在视觉上欺骗客户端,让我们看到已经解锁了全角色和装扮。这个过程只发生在你的电脑或手机上,服务器端的数据完全没有改变。

由于他只是一个代理,所以部署在本地或者服务器 VPS 上都可以,而如果部署在 VPS 上,便可以实现全平台使用,且数据共享。

各个版本的差异:

  • Python:基于 mitmproxy 实现,兼容性最好,但需要手动安装管理依赖,可以比较方便的实现上下游代理链,兼容本地 AI 使用。
  • Rust / Docker:基于 hudsucker 实现,兼容性较差,优点是已经完全编译为二进制文件,快速启动无需安装依赖。

注意,Rust / Docker 所基于的 omjadas/hudsucker 项目有一个很令人困惑的地方,即其虽然身为会进行 MITM 的节点,但是对外提供的是 HTTP 的代理节点而非 HTTPS 的代理节点。这导致你必须在填写代理软件的时候,填写 HTTP 代理,并且同时信任其自签名的证书,而且在对流量进行代理链式配置的时候(如搭配本地 AI 软件),也会存在一些问题。

这点具体表现在:

  1. 游戏初始化的时候不能过双层代理链(解锁 + AI),只能插入一层代理

  2. 通过 Surge 测速的时候,日志会显示

    WARN hudsucker::proxy::internal: Unknown protocol, read '[48, 45, 41, 44]' from upgraded connection
    

    不过这并不影响使用。

我尝试了一些办法,但始终无法解决这个问题,只能做简易 Patch,详情见后。

启动代理

请首先阅读各项目的 README 文档,了解启动方法。

Python

git clone https://github.com/Avenshy/MajsoulMax.git
cd MajsoulMax
pip install -r requirements.txt
mitmdump -p 23410 -s addons.py

Rust

无需下载源码,直接在 Releases 根据你的平台下载二进制文件,解压出来后运行 majsoul_max_rs 即可。

Docker

目前只支持 Linux 平台。

  1. 拉取并启动服务

    创建并进入目录:

    mkdir majsoul && cd majsoul
    

    然后创建 docker-compose.yml

    services:
        majsoul-proxy:
            image: arthals/majsoul-max-rs:latest
            restart: unless-stopped
            ports:
                # 将容器的 23411 端口映射到宿主机的 8888 端口
                - '8888:23411'
            volumes:
                - ./app:/app
            environment:
                - username=username
                - password=password
                # Github 代理下载
                - download_url=https://ghproxy.net/https://github.com/Xerxes-2/MajsoulMax-rs/releases/download/0.6.7/majsoul_max_rs-0.6.7-x86_64-unknown-linux-gnu.tar.gz
                # 原始下载
                # - download_url=https://github.com/Xerxes-2/MajsoulMax-rs/releases/download/0.6.7/majsoul_max_rs-0.6.7-x86_64-unknown-linux-gnu.tar.gz
                # 可选:代理下载
                # - http_proxy=${HTTP_PROXY:-http://172.17.0.1:7890}
                # - https_proxy=${HTTPS_PROXY:-http://172.17.0.1:7890}
    

    启动容器:

    docker compose up -d
    

    默认会:

    • 映射宿主机 8888 端口到容器 23411 端口。
    • 使用账号 username/password 进行 Basic Auth 认证。
    • 通过 download_url 环境变量自动下载 GNU 版本的可执行文件。

    如需修改端口或账号密码,请直接编辑 docker-compose.yml 对应字段即可。

  2. 验证运行

    curl -k -x http://username:password@127.0.0.1:8888 https://baidu.com --head
    

    返回 HTTP/1.1 200 OK 即代表代理工作正常。

    然后你需要放行你服务器的 8888(或同自定义)端口,使之可以在外网访问。

信任证书

  • 对于原始 Python 仓库,需要信任 ~/.mitmproxy/ 下的 mitmproxy-ca.pem 证书。这个证书是本地自动生成的,非常安全。
  • 对于 Rust 版本或者 Docker 封装版本,需要信任 hudsucker.cer 证书。这个证书是在源码中写死的,如果你担心安全性,想要更换证书,你需要下载源码替换后重新编译。

以下以 hudsucker.cer 证书为例,讲解步骤:

macOS

  1. 将证书拖入到 钥匙串访问-系统-证书

    macOS1

  2. 右键-显示简介-信任,调整为始终信任,然后关闭,输入密码确认。

    macOS2

iOS / iPadOS

  1. 将下载好的 hudsucker.cer 隔空投送到 iPhone/iPad 上,进入 设置-已下载描述文件,点击安装

  2. 前往 通用-关于本机-证书信任设置,打开 Hudsucker Industries 的选项

    iOS

Windows / Android

点击下载下来的 hudsucker.cer 证书文件,跟随指引安装证书即可。

代理配置

原始项目需要使用 Proxifier 来进行流量代理,然而我们有更好的选择,那就是直接利用 Surge / Clash 来进行规则分流代理。

请注意:

  • 如果是本地客户端,请开启 TUN / 增强模式以确保正确代理进程流量。
  • 如果你使用网页版或者不方便使用 PROCESS-NAME 规则的情况,请使用后文所列的域名关键字或者 IP 分流规则。
  • 如果你使用的是支持覆写的代理软件,请参考他们各自的配置方法,便可实现在原有代理配置的基础上添加节点和规则,如 Stash / Mihomo Party

以下配置在 macOS Steam 客户端和 iOS / iPadOS 港服客户端测试通过,注意替换相关字段(IP、端口、协议、账号密码)为你的实际值。

Python

提供的是本地 HTTPS 节点,无需账号密码。

Clash 配置示例:

proxies:
    - name: Majsoul
      port: 23410
      server: 127.0.0.1
      tls: true
      type: http
proxy-groups:
    - name: 🀄 雀魂麻将
      proxies:
          - Majsoul
          - DIRECT
      type: select
rules:
    - PROCESS-NAME,雀魂麻將,🀄 雀魂麻将
    - PROCESS-NAME,jantama_mahjongsoul.exe,🀄 雀魂麻将
    - PROCESS-NAME,Jantama_MahjongSoul.exe,🀄 雀魂麻将

Surge 配置示例:

[Proxy]
Majsoul = https, 127.0.0.1, 23410

[Proxy Group]
🀄 雀魂麻将 = select, Majsoul, DIRECT

[Rule]
PROCESS-NAME,雀魂麻將,🀄 雀魂麻将

Rust

提供的是本地 HTTP 节点,无需账号密码。

Clash 配置示例:

proxies:
    - name: Majsoul
      port: 23410
      server: 127.0.0.1
      tls: false
      type: http
proxy-groups:
    - name: 🀄 雀魂麻将
      proxies:
          - Majsoul
          - DIRECT
      type: select
rules:
    - PROCESS-NAME,雀魂麻將,🀄 雀魂麻将
    - PROCESS-NAME,jantama_mahjongsoul.exe,🀄 雀魂麻将
    - PROCESS-NAME,Jantama_MahjongSoul.exe,🀄 雀魂麻将

Surge 配置示例:

[Proxy]
Majsoul = http, 127.0.0.1, 23410

[Proxy Group]
🀄 雀魂麻将 = select, Majsoul, DIRECT

[Rule]
PROCESS-NAME,雀魂麻將,🀄 雀魂麻将

Docker

提供的是远程 HTTP 节点,需要账号密码、IP 端口等配置,此时服务器作为中间代理,而客户端设备只需要信任证书后配置节点和分流规则即可。

Docker 的配置和 Rust 非常类似,只是多了一个鉴权部分。

Clash:

proxies:
    - name: Majsoul
      port: your_service_port
      server: your_server_ip
      tls: false
      type: http
      username: username
      password: password

Surge:

[Proxy]
Majsoul = http, your_server_ip, 8888, username, password

如果你是桌面端,请参考之前的 Rust 版本配置,只需替换 proxies 字段即可,无需对规则做操作;

如果你是移动端设备(如 iOS / iPadOS 无法使用 PROCESS-NAME 规则,会被忽略)或者是安卓但不确定 PROCESS-NAME 是否正确,以及使用的网页版进行游戏,那你需要将规则改为域名关键字或者 IP 分流,如下:

Clash:

rules:
    - DOMAIN-KEYWORD,majsoul,🀄 雀魂麻将
    - DOMAIN-KEYWORD,maj-soul,🀄 雀魂麻将
    - DOMAIN-KEYWORD,catmjstudio,🀄 雀魂麻将
    - DOMAIN-KEYWORD,catmajsoul,🀄 雀魂麻将
    - IP-CIDR,146.66.155.0/24,🀄 雀魂麻将
    - IP-CIDR,185.25.182.18/32,🀄 雀魂麻将
    - IP-CIDR,203.107.63.200/32,🀄 雀魂麻将

Surge:

[Rule]
DOMAIN-KEYWORD,majsoul,🀄 雀魂麻将
DOMAIN-KEYWORD,maj-soul,🀄 雀魂麻将
DOMAIN-KEYWORD,catmjstudio,🀄 雀魂麻将
DOMAIN-KEYWORD,catmajsoul,🀄 雀魂麻将
IP-CIDR,146.66.155.0/24,🀄 雀魂麻将
IP-CIDR,185.25.182.18/32,🀄 雀魂麻将
IP-CIDR,203.107.63.200/32,🀄 雀魂麻将

Akagi

Akagi 可以提供 AI 雀魂分析,帮你分析下一步应当打什么牌,并且具有一个十分现代化的 TUI(Terminal UI),对于 Windows 客户端,还可以下载编译好的版本完成自动代打,不过我没尝试过。

akagi

AI 原理

和解锁类似,AI 的工作流程也是通过 MITM 来截获对局信息,从而还原出牌局状况,然后送入 AI 来进行分析。所以你需要类似的完成信任证书、配置软件分流的操作。

Akagi 使用的证书和 MajsoulMax 的 Python 版本一致,位于 ~/.mitmproxy

对于 Windows 用户,可以直接下载编译好的 exe 可执行文件;对于 macOS 用户,则必须手动执行 py 脚本。

启动 AI

git clone https://github.com/shinkuan/Akagi.git
cd Akagi
pip install -r requirements.txt
# 然后按照原仓库的 README 走
# 先按照 For Developer 走,配置好 libriichi 的依赖
# 然后同前文(macOS)或者 README(Windows) 一样,安装证书。
python run_akagi.py

原始仓库提供了一个基础的 Mortal 版本,不过你可以在 这个 issue 下载到更新的权重。

需要注意的是,如果你替换了权重,那你也需要相应的修改 mjai_bot/mortal 下的 model.pybot.py,注意 issue 直接下载下来的 model.py 直接拖进去是不行的,这两个文件都需要稍微的改一改导入语句。

MajsoulCopilot

类似 Akagi 的项目,也是可以本地提供 AI 辅助,不同的是其同时支持 Windows / macOS 的自动打牌功能,而 Akagi 虽然也支持,但只支持 Windows 平台。

我的 Fork 版本(修了一些 macOS 上的证书信任检测问题,支持兼容雀魂 Max 解锁,但 PR 尚未合并,推荐使用):

自动打牌原理

自动打牌基本就是通过在浏览器里定位控件元素,并模拟鼠标操作,来完成自动打牌、自动开启对局等功能。

然而,其不仅存在一些限制(浏览器分辨率、尺寸等),而且浏览器的渲染界面(至少在 macOS 上)精度不如客户端,体验也有所残缺,且自动打牌很容易被封号,所以不建议使用。

启动 Copilot

参见原文 README 即可。

与 Akagi 不同的是,对于 MahjongCopilot,只需要将模型文件拖入即可,无需再额外修改代码。

值得注意的一点是,MahjongCopilot 与前文的项目有所不同的一点是,其做了一层隔离,所需要信任的证书位于 ./mitm_config 下而非默认的 ~/.mitmproxy,这一点在联合使用的时候可能需要注意,但代码应当会自动完成信任过程。

联合使用

MajsoulHelper

MajsoulHelper 是由我重新编排整理的一个新的项目,其修改了 MajsoulMax 和 Akagi 的原始代码,使得这些服务能够更好的在服务器上进行部署。

简单来说,包括如下核心修改:

  1. 为 MajsoulMax 增加 HTTP Auth 鉴权。
  2. 移除 Akagi 的 TUI 部分,完全转为后端服务,其在收到数据包后会进行 AI 计算分析,然后将结果更新,并启动一个对外的 WebSocket 服务器,从而允许前端进行渲染。
  3. 增加了 AkagiFrontend 项目,其是一个 React 编写的前端页面,可以与 WebSocket 服务器进行通信,并进行 Web 或者 LiveStream 渲染,从而允许你在手机上利用画中画(PiP)功能同时观看游戏界面与 AI 分析。
  4. 将所有服务完全容器化,可以轻松部署到任何服务器上,也可以利用端口转发进行前后端分离。

akagi-frontend

具体的使用方式参见下述项目的 README 即可。

数据代理链条如下:

game -> MajsoulMax(23410, http) -> akagi(7880, http internal) -> server

显示链条如下:

game -> MajsoulMax(23410, http) -> akagi(7880, http internal) -> akagi_data_server(8765, ws) -> akagi_frontend(4173, http)

Akagi + MajsoulMax

由于二者都需要进行 MITM,所以你需要配置代理链让流量串行经过两个节点,并且需要同时信任二者的证书。

注意,联用二者的时候,千万不要同时使用 PROCESS-NAME 和 DOMAIN-KEYWORD / IP-CIDR 规则,否则容易导致回环代理,出现 Bug。

这里有基于 MajsoulMax-rs 和基于 MajsoulMax 的两种配置,配置相近,不同点在于:

  1. MajsoulMax-rs(Rust)启动的是 HTTP 代理(基于 hudsucker),且链式代理时,初始化的时候会遇到问题
  2. MajsoulMax(Python)启动的是 HTTPS 代理(基于 mitmproxy),可以完美进行链式代理。

虽然听起来后者更好,但还是建议按照前者走,原因无他,方便快捷(无需切换 Python 环境等)。

Rust 版本代理链条如下:

game -> majsoul_max_rs(23410, http) -> akagi(7880, https) -> server

Python 版本代理链条如下:

game -> MajsoulMax(23410, https) -> akagi(7880, https) -> server

建议的配置如下:

Clash:

proxies:
    - name: Majsoul
      port: your_service_port
      server: your_server_ip
      tls: false
      type: http
      username: username
      password: password
    - name: MajsoulLocal
      port: 23410
      server: 127.0.0.1
      tls: false
      type: http
    - name: Akagi
      port: 7880
      server: 127.0.0.1
      tls: true
      type: http
proxy-groups:
    - name: 🀄 雀魂麻将
      proxies:
          - Majsoul
          - MajsoulLocal
          - DIRECT
          - 🔰 节点选择
      type: select
rules:
    - PROCESS-NAME,雀魂麻將,🀄 雀魂麻将
    - PROCESS-NAME,majsoul_max_rs,Akagi

Surge:

[Proxy]
Majsoul = http, your_server_ip, your_service_port, username, password
MajsoulLocal = http, 127.0.0.1, 23410
Akagi = http, 127.0.0.1, 7880

[Proxy Group]
🀄 雀魂麻将 = select, Majsoul, MajsoulLocal, DIRECT, 🔰 节点选择

[Rule]
PROCESS-NAME,雀魂麻將,🀄 雀魂麻将
PROCESS-NAME,majsoul_max_rs,Akagi

这么配置的好处在于,你可以在不想用 AI 的时候分流到服务器上的 Majsoul 节点进行简单的解锁,而在需要 AI 的时候,先通过服务器节点或者直连进行初始化,然后再换到本地的 MajsoulLocal 节点即可。

如果你实在嫌麻烦,你还可以让 LLM 给你写一个基于 Clash / Surge HTTP API 的自动化脚本,来完成这个过程。

MahjongCopilot + MajsoulMax

参见 我的 Fork 的 README 即可。

settings.json 中设置 "majsoulmax_proxy": "http://127.0.0.1:23410"

随后,请以如下方式启动 MajsoulMax:

mitmdump -p 23410 --mode upstream:http://127.0.0.1:10999 -s addons.py --ssl-insecure

请注意,如果你修改了 MajsoulMax 或者 MahjongCopilot 的代理端口,请相应修改对应端口,并且确保 MajsoulMax 和 MahjongCopilot 的自签名证书均正确安装(这两者是不同的,前者默认使用 ~/.mitmproxy/ 下的证书,而后者使用 ./mitm_config/ 下的证书)。

最终代理链为:

game -> MajsoulMax(23410, https) -> MahjongCopilot(10999, https) -> server

分流类似前文,在此不再赘述。

💾

  •  

策略 II

2025年4月18日 03:34

从模仿学习到强化学习

模仿学习(IL)使用固定的专家数据进行离线学习(Offline Learning),通过行为克隆(BC)等方式模仿专家策略。其主要局限在于难以处理专家数据未覆盖的状态(OOD)

如果专家演示也有对错误状态或偏离专家轨迹情况的处理,那也能学的不错。

强化学习(RL)允许智能体与环境在线交互,通过试错和环境反馈(奖励)学习。这使得 RL 能够探索更广泛的状态空间并学习处理未知情况。

离线学习(Offline Learning):指学习过程无法干预数据的产生过程。我们只能使用一个预先收集好的、固定的数据集进行学习。模仿学习中的 BC 就是典型的离线学习。

在线学习(Online Learning):指智能体在学习过程中可以主动与环境交互,实时产生新的数据,并利用这些新数据更新自己的策略。强化学习通常可以在线进行。

与 BC 不同,RL 允许智能体与环境进行交互(从而可以探索到状态空间中更广泛的区域),可以做 Online 学习(但不是所有的 RL 算法都是 Online 的)。

强化学习基础与目标

强化学习的目标是找到一个最优策略参数 $\theta^*$,使得在该策略下产生的轨迹的期望回报最大化。即优化目标函数 $J(\theta)$:

$$ J(\theta) = \mathbb{E}{\tau \sim p\theta(\tau)} [R(\tau)] = \mathbb{E}{\tau \sim p\theta(\tau)} \left[ \sum_{t=0}^{T} r(s_t, a_t) \right] $$

这里,$p_\theta(\tau)$ 表示由策略 $\pi_\theta$ 与环境交互产生的轨迹 $\tau$ 的概率分布,这个分布由策略 $\pi_\theta$ 和环境共同决定。

由于策略和环境都可能具有随机性,单次轨迹的回报 $R(\tau)$ 可能不同。因此,我们的目标是在所有可能轨迹的分布上,最大化期望回报。我们主要关注 有限时间步(finite horizon) 的情况,即任务在 $T$ 步内完成。

策略梯度(Policy Gradient)

直接优化 $J(\theta)$ 通常很困难,因为期望的计算涉及到对所有可能轨迹的积分或求和,这在连续或高维状态动作空间中是难以处理的。

蒙特卡洛近似(Monte Carlo Approximation)

蒙特卡洛(Monte Carlo):多次采样求平均,从而近似地计算期望。

使用当前的策略 $\pi_\theta$ 与环境交互,生成 $N$ 条轨迹 $\tau^{(1)}, \tau^{(2)}, \ldots, \tau^{(N)}$。然后用这些样本的平均回报来近似期望回报:

$$ J(\theta) \approx \frac{1}{N} \sum_{i=1}^{N} R(\tau^{(i)}) = \frac{1}{N} \sum_{i=1}^{N} \sum_{t=0}^{T} r(s_t^{(i)}, a_t^{(i)}) $$

虽然我们可以近似 $J(\theta)$ 的值,但为了使用梯度上升(Gradient Ascent)方法来优化 $\theta$,我们需要计算目标函数关于参数 $\theta$ 的梯度 $\nabla_\theta J(\theta)$。

直接对蒙特卡洛近似形式求梯度是困难的,因为轨迹的生成过程 $\tau \sim p_\theta(\tau)$ 本身就依赖于 $\theta$。

策略梯度定理(Policy Gradient Theorem)

从期望的定义出发:

$$ J(\theta) = \int p_\theta(\tau) R(\tau) \mathrm{d}\tau $$

对其求梯度:

$$ \nabla_\theta J(\theta) = \nabla_\theta \int p_\theta(\tau) R(\tau) \mathrm{d}\tau = \int \nabla_\theta p_\theta(\tau) R(\tau) \mathrm{d}\tau $$

这里用到了梯度和积分可以交换顺序的假设。

引理(对数导数技巧):对于任何概率密度函数 $p_\theta(x)$,有 $\nabla_\theta p_\theta(x) = p_\theta(x) \nabla_\theta \log p_\theta(x)$。

证明:

应用链式法则于 $\log p_\theta(x)$:

$$ \begin{aligned} \nabla_\theta \log p_\theta(x) &= \left( \frac{\mathrm{d}}{\mathrm{d} p_\theta(x)} \log p_\theta(x) \right) \nabla_\theta p_\theta(x) \ &= \frac{1}{p_\theta(x)} \nabla_\theta p_\theta(x) \end{aligned} $$

这个等式成立的前提是 $p_\theta(x) > 0$。因为我们通常在概率密度函数的支撑集(support)上进行计算,这些地方的概率值是正的,所以这个假设通常是合理的。

现在,我们只需要将上式两边同时乘以 $p_\theta(x)$ 即可得到我们想要证明的公式:

$$ \begin{aligned} p_\theta(x) \nabla_\theta \log p_\theta(x) &= p_\theta(x) \left( \frac{1}{p_\theta(x)} \nabla_\theta p_\theta(x) \right) \ &= \nabla_\theta p_\theta(x) \end{aligned} $$

也即:

$$ \nabla_\theta p_\theta(x) = p_\theta(x) \nabla_\theta \log p_\theta(x) $$

将这个技巧应用于 $p_\theta(\tau)$:

$$ \nabla_\theta p_\theta(\tau) = p_\theta(\tau) \nabla_\theta \log p_\theta(\tau) $$

代入梯度表达式:

$$ \begin{aligned} \nabla_\theta J(\theta) &= \int \nabla_\theta p_\theta(\tau) R(\tau) \mathrm{d}\tau \ &= \int p_\theta(\tau) \nabla_\theta \log p_\theta(\tau) R(\tau) \mathrm{d}\tau \ &= \mathbb{E}{\tau \sim p\theta(\tau)} [\nabla_\theta \log p_\theta(\tau) R(\tau)] \end{aligned} $$

这个结果非常重要,它表明,目标函数的梯度可以表示为一个期望 (蒙特卡洛:来了嗷!)。

这意味着我们可以再次使用蒙特卡洛方法来估计这个梯度:采样 $N$ 条轨迹 $\tau^{(i)} \sim p_\theta(\tau)$,然后计算:

$$ \nabla_\theta J(\theta) \approx \frac{1}{N} \sum_{i=1}^{N} \nabla_\theta \log p_\theta(\tau^{(i)}) R(\tau^{(i)}) $$

请注意,这个梯度表达式中并没有出现奖励函数 $R(\tau)$ 关于 $\theta$ 的梯度 $\nabla_\theta R(\tau)$。

梯度是通过 $\nabla_\theta \log p_\theta(\tau)$ 传入的。这意味着强化学习不需要奖励函数本身是可导的(极其重要!!!),甚至不需要知道奖励函数的具体形式。我们只需要能够从环境中获得每个时间步的奖励值 $r(s_t, a_t)$ 即可。

这极大地扩展了强化学习的应用范围,可以处理奖励是稀疏的、非连续的 (例如,任务成功为 1,失败为 0)等复杂情况。

利用马尔科夫性:

$$ p_\theta(\tau) = p(s_0) \prod_{t=0}^{T-1} \pi_\theta(a_t | s_t) p(s_{t+1} | s_t, a_t) $$

其中:

  • $p(s_0)$ 是初始状态分布的概率
  • $\pi_\theta(a_t | s_t)$ 是策略在状态 $s_t$ 选择动作 $a_t$ 的概率
  • $p(s_{t+1} | s_t, a_t)$ 是环境的状态转移概率,即在状态 $s_t$ 执行动作 $a_t$ 后转移到状态 $s_{t+1}$ 的概率

取对数:

$$ \log p_\theta(\tau) = \log p(s_0) + \sum_{t=0}^{T-1} \left( \log \pi_\theta(a_t | s_t) + \log p(s_{t+1} | s_t, a_t) \right) $$

现在对 $\theta$ 求梯度 $\nabla_\theta$:

$$ \nabla_\theta \log p_\theta(\tau) = \nabla_\theta \log p(s_0) + \sum_{t=0}^{T-1} \left( \nabla_\theta \log \pi_\theta(a_t | s_t) + \nabla_\theta \log p(s_{t+1} | s_t, a_t) \right) $$

注意到:

  1. 初始状态分布 $p(s_0)$ 通常与策略参数 $\theta$ 无关,所以 $\nabla_\theta \log p(s_0) = 0$
  2. 环境的动态 $p(s_{t+1} | s_t, a_t)$ 描述的是环境模型中的状态转移概率,它也不依赖于我们正在学习的策略参数 $\theta$,因此 $\nabla_\theta \log p(s_{t+1} | s_t, a_t) = 0$

环境模型:包括状态转移概率 $p(s_{t+1} | s_t, a_t)$ 和奖励函数 $r(s_t, a_t)$,真实世界一般都拿不到。

  • Model-Free:我们不需要知道(甚至不需要学习)环境的模型。我们只需要能够与环境交互并从中采样即可(本课程主要是这个,在模拟器里可以随便模拟,也不需要显式建模)
  • Model-Based:会尝试利用神经网络去学习环境的模型,并利用模型进行规划或生成模拟数据(真实世界的 RL 一般需要用这个)

由此,梯度表达式简化为:

$$ \nabla_\theta \log p_\theta(\tau) = \sum_{t=0}^{T-1} \nabla_\theta \log \pi_\theta(a_t | s_t) $$

所以:

$$ \begin{aligned} \nabla_\theta J(\theta) &= \mathbb{E}{\tau \sim p\theta(\tau)} [\nabla_\theta \log p_\theta(\tau) R(\tau)] \ &= \mathbb{E}{\tau \sim p\theta(\tau)} \left[ \left( \sum_{t=0}^{T-1} \nabla_\theta \log \pi_\theta(a_t | s_t) \right) R(\tau) \right] \end{aligned} $$

由此,我们得到 最终的蒙特卡洛策略梯度估计

使用 $N$ 条采样轨迹 $\tau^{(1)}, \ldots, \tau^{(N)}$,其中 $\tau^{(i)} = (s_0^{(i)}, a_0^{(i)}, \ldots, s_T^{(i)}, a_T^{(i)})$ 且 $R(\tau^{(i)}) = \sum_{t=0}^{T} r(s_t^{(i)}, a_t^{(i)})$,策略梯度可以近似为:

$$ \hat{g} = \frac{1}{N} \sum_{i=1}^{N} \left[ \left( \sum_{t=0}^{T-1} \nabla_\theta \log \pi_\theta(a_t^{(i)} | s_t^{(i)}) \right) R(\tau^{(i)}) \right] $$

这个估计值 $\hat{g}$ 就是我们用来更新策略参数 $\theta$ 的梯度方向。

基础策略梯度算法(REINFORCE)

基于上述推导,我们可以得到一个基础的策略梯度算法流程(REINFORCE 算法):

  1. 初始化策略参数 $\theta$(例如,随机初始化神经网络的权重)。
  2. 循环以下步骤:
    1. 使用当前的策略 $\pi_\theta$ 与环境交互,采样 $N$ 条轨迹 ${\tau^{(i)}}_{i=1}^N$。
    2. 对于每条轨迹 $\tau^{(i)}$,计算其总回报 $R(\tau^{(i)}) = \sum_{t=0}^{T} r(s_t^{(i)}, a_t^{(i)})$。
    3. 计算策略梯度估计值 $\hat{g} = \frac{1}{N} \sum_{i=1}^{N} \left[ \left( \sum_{t=0}^{T-1} \nabla_\theta \log \pi_\theta(a_t^{(i)} | s_t^{(i)}) \right) R(\tau^{(i)}) \right]$。
    4. 使用梯度上升更新策略参数:$\theta \leftarrow \theta + \alpha \hat{g}$,其中 $\alpha$ 是学习率。

这个算法的直观意义是:

  • 对于回报 $R(\tau^{(i)})$ 较高的轨迹,我们会增大该轨迹中采取的动作 $a_t^{(i)}$ 在对应状态 $s_t^{(i)}$ 下被选中的概率(通过增大 $\log \pi_\theta(a_t^{(i)} | s_t^{(i)})$)
  • 对于回报较低的轨迹,则会减小其中动作被选中的概率。
  • 更新的幅度由整条轨迹的总回报 $R(\tau^{(i)})$ 来加权。

同策略(On-Policy):用于计算梯度 $\hat{g}$ 的轨迹 ${\tau^{(i)}}$ 必须是由当前正在优化的策略 $\pi_\theta$ 生成的。一旦策略参数 $\theta$ 被更新(步骤 d),之前采样得到的轨迹就不能再用于下一次的梯度计算了,因为它们是由旧策略生成的,不再符合新策略 $\pi_{\theta_{new}}$ 下的轨迹分布 $p_{\theta_{new}}(\tau)$。因此,在每次迭代中,我们都需要重新采样一批新的轨迹。

这种 On-Policy 的特性导致了策略梯度方法通常具有较高的 样本复杂度 (Sample Complexity),即需要大量的与环境交互的样本才能学习好策略,因为每次更新后数据就被丢弃了。这也是后续算法(如 PPO)试图改进的一个重要方面。

试错学习(Trial-and-Error):REINFORCE 体现了强化学习的核心思想 —— 试错。智能体尝试不同的动作,环境根据结果给出奖励。算法通过梯度更新,使得带来高奖励的动作(“好的尝试”)在未来更有可能被选中,而带来低奖励或惩罚的动作(“坏的尝试” 或 “错误”)则被抑制。

这个过程就像学习骑自行车,通过不断尝试和调整,逐渐学会保持平衡(获得 “不摔倒” 这个隐含的高奖励)。

策略梯度与行为克隆的对比

策略梯度(Policy Gradient, PG)方法和行为克隆(Behavior Cloning, BC)都是学习一个从状态 $s$ 到动作 $a$ 的映射(策略 $\pi_\theta(a|s)$),通常使用神经网络作为参数化模型 $\theta$。然而,它们的学习目标和更新规则有本质区别。

行为克隆的目标是最大化专家演示数据 $D_{expert} = {(s_i, a_i)}$ 的对数似然,可以通过蒙特卡洛估计来近似:

$$ \begin{aligned} \arg \max_\theta J_{BC}(\theta) &= \sum_{(s, a) \in D_{expert}} \log \pi_\theta(a|s) \ &\approx \arg \max_\theta \frac{1}{N} \sum_{i=1}^{N} \left[ \sum_{t=0}^{T-1} \log \pi_\theta(a_i^{(t)}|s_i^{(t)}) \right] \end{aligned} $$

其梯度为:

$$ \begin{aligned} \nabla_\theta J_{BC}(\theta) &= \sum_{(s, a) \in D_{expert}} \nabla_\theta \log \pi_\theta(a|s) \ & \approx \frac{1}{N} \sum_{i=1}^{N} \left[ \sum_{t=0}^{T-1} \nabla_\theta \log \pi_\theta(a_i^{(t)}|s_i^{(t)}) \right] \end{aligned} $$

行为克隆试图让策略网络在专家访问过的状态 $s$ 下,输出专家采取的动作 $a$ 的概率尽可能高。它假设专家演示中的所有状态 - 动作对都是最优且等价重要的

策略梯度的目标是最大化期望回报 $J(\theta) = \mathbb{E}{\tau \sim p\theta(\tau)} [R(\tau)]$,其梯度(使用蒙特卡洛估计)为:

$$ \nabla_\theta J(\theta) \approx \hat{g} = \frac{1}{N} \sum_{i=1}^{N} \left[ \left( \sum_{t=0}^{T-1} \nabla_\theta \log \pi_\theta(a_t^{(i)} | s_t^{(i)}) \right) R(\tau^{(i)}) \right] $$

策略梯度也通过 $\nabla_\theta \log \pi_\theta(a_t | s_t)$ 项来调整动作的概率,但它引入了一个关键的权重因子:整条轨迹的回报 $R(\tau^{(i)})$

行为克隆可以看作是策略梯度的一种特殊情况,即假设所有演示轨迹的回报 $R(\tau)$ 都等于 1(或者某个常数)。它平等地对待演示数据中的每一个动作,试图无差别地模仿。

策略梯度则根据动作实际带来的结果也即 $R(\tau)$ 来调整策略。

  • 回报高的轨迹中的 $(s_t, a_t)$ 对会被赋予更大的权重,使得这些 “好” 动作的概率增加
  • 回报低的(甚至可能是负回报的)轨迹中的 $(s_t, a_t)$ 对会被赋予较小的(或负的)权重,使得这些 “坏” 动作的概率降低。

行为克隆的问题:由于无差别模仿,行为克隆会学习演示数据中的所有行为,包括专家可能存在的噪声、次优动作或不必要的习惯(例如演示者操作时手部的轻微抖动)。它无法区分哪些动作对于完成任务是关键的,哪些是无关紧要甚至有害的。

此外,如果演示数据过于 “完美”,只包含最优轨迹,那么策略在遇到训练时从未见过的、略微偏离的状态时,可能会因为缺乏相应的纠错经验而表现很差(Distribution Shift)。

如果你想让 BC 足够好:

  1. 正确覆盖所有的完美轨迹,且你训练的模型能够正确地 follow 这些轨迹
  2. 对各种 error 的 corner case 都有拽回来的部分覆盖,但不要有导致 error 发生的部分
  3. 省流就是尽最大可能避免与真实世界的 Distribution Shift

显然这比较困难。

  • BC:不断调 Demenstration,尝试满足上述条件
  • RL:不断地在环境中尝试

策略梯度(REINFORCE)的挑战

基础的策略梯度算法(REINFORCE)虽然原理简洁且不依赖模型和可导奖励,但在实际应用中面临严峻挑战:

高方差(High Variance)/ 嘈杂(Noisy)

蒙特卡洛方法通过采样 $N$ 条轨迹来估计梯度 $\nabla_\theta J(\theta)$。然而,由于环境和策略的随机性,单条轨迹的回报 $R(\tau^{(i)})$ 可能有很大波动。尤其是在复杂任务和长时序(large $T$)问题中,轨迹空间极其巨大,有限的 $N$ 条样本可能远不足以精确估计期望梯度。

这导致每次计算出的梯度估计值 $\hat{g}$ 噪声很大,围绕真实梯度方向剧烈波动。虽然理论上这个估计是 无偏 的(当 $N \to \infty$ 时收敛到真值),但在 $N$ 有限时,高方差会使得训练过程不稳定,收敛缓慢,甚至可能发散。

更直白的讲,梯度估计的随机性大,会导致即使使用相同的超参数,仅因采样轨迹不同,多次训练的结果(性能、学习曲线)也可能差异巨大,缺乏稳定性。这与结果通常更一致的监督学习不同,导致需要进行大 Batch Size 以及对超参数的充分试错。

样本效率低下(Low Sample Efficiency)

REINFORCE 是 On-Policy (同策略)算法。一旦策略参数 $\theta$ 更新,之前采集的数据就 “过时” 了,不能用于下一次梯度计算。这导致算法需要大量的交互样本才能学习,尤其对于交互成本高昂的环境(如真实机器人),这种样本效率是难以接受的。

On-Policy 与 Off-Policy 学习

On-Policy 和 Off-Policy 都属于 Online Learning,因为你需要持续地和环境交互,然后根据交互数据来更新策略。

  • On-Policy(同策略):学习算法使用的数据必须由当前正在优化的策略产生。每次策略更新后,旧数据失效。
    • 例如:REINFORCE、SARSA
    • 通常效果更好,直接优化当前策略的表现
    • 样本效率低 (贵)
  • Off-Policy(异策略):学习算法可以使用由不同策略(例如过去的策略、专家策略或其他探索策略)产生的数据。通常会使用重要性采样(Importance Sampling)等技术来修正数据分布不匹配的问题。
    • 例如:Q-Learning、DDPG、SAC
    • 样本效率高,可以利用历史数据(通常存储在 Replay Buffer 中)
    • 缺点是效果不一定好,优化目标与数据生成分布不一致可能导致问题(老是去学以前已经改正的)

高斯策略(Gaussian Policy)

随机策略(stochastic policy):输出的是一个概率分布而不是一个确定的动作。

高斯策略:实际执行的动作 $a_t$ 则从一个以 $\mu_\theta(s_t) = f(s_t)$ 为均值、协方差矩阵为 $\Sigma$ 的高斯分布中采样得到:

$$ \pi_\theta(a_t|s_t) = \mathcal{N}(\mu_\theta(s_t); \Sigma) = \mathcal{N}(f(s_t); \Sigma) $$

我们约定,$k$ 是动作空间的维度,$p$ 是参数的维度。

对于多元高斯分布,其概率密度函数的对数为:

$$ \begin{aligned} \log \pi_\theta(a_t|s_t) &= -\frac{1}{2} (a_t - \mu_\theta(s_t))^\top \Sigma^{-1} (a_t - \mu_\theta(s_t)) - \frac{k}{2}\log(2\pi) - \frac{1}{2}\log|\det(\Sigma)| \ &= -\frac{1}{2} | \mu_\theta(s_t) - a_t |^2_{\Sigma} + \text{const} \end{aligned} $$

$$ \nabla_\theta \log \pi_\theta(a_t|s_t) = \left(\frac{\partial \mu_\theta(s_t)}{\partial \theta}\right)^\top \Sigma^{-1} (a_t - \mu_\theta(s_t)) $$

其中,$| \mathbf{x} |^2_{\Sigma} = \mathbf{x}^\top \Sigma^{-1} \mathbf{x}$。如果协方差矩阵 $\Sigma$ 是一个对角矩阵,并且所有对角线元素都相等,即 $\Sigma = \sigma^2 I$,那结果就是 L2。

证明:

引理:

  1. 链式法则:令 $\mathbf{y}(\theta) = f(\mathbf{s}t) - \mathbf{a}t$,$g(\mathbf{y}) = \mathbf{y}^\top \Sigma^{-1} \mathbf{y}$,则 $\nabla\theta g(\mathbf{y}(\theta)) = \left(\frac{\partial \mathbf{y}}{\partial \theta}\right)^\top \nabla\mathbf{y} g(\mathbf{y})$
  2. 对于对称矩阵 $A$,$\nabla_\mathbf{x} (\mathbf{x}^\top A \mathbf{x}) = 2 A \mathbf{x}$。

所以,

$$ \begin{aligned} \nabla_\theta \log \pi_\theta(a_t|s_t) &= \nabla_\theta \left( -\frac{1}{2} (a_t - \mu_\theta(s_t))^\top \Sigma^{-1} (a_t - \mu_\theta(s_t)) \right) \ &= -\frac{1}{2} \nabla_\theta \left( (\mu_\theta(s_t) - a_t)^\top \Sigma^{-1} (\mu_\theta(s_t) - a_t) \right) \ &= -\frac{1}{2} \nabla_\theta \left( \mathbf{y}(\theta)^\top \Sigma^{-1} \mathbf{y}(\theta) \right) \quad (\text{令 } \mathbf{y}(\theta) = \mu_\theta(s_t) - a_t) \ &= -\frac{1}{2} \left(\frac{\partial \mathbf{y}}{\partial \theta}\right)^\top (\nabla_\mathbf{y} (\mathbf{y}^\top \Sigma^{-1} \mathbf{y})) \quad (\text{应用链式法则}) \ &= -\frac{1}{2} \left(\frac{\partial (\mu_\theta(s_t) - a_t)}{\partial \theta}\right)^\top (2 \Sigma^{-1} \mathbf{y}) \quad (\text{应用引理 2}) \ &= -\frac{1}{2} \left(\frac{\partial \mu_\theta(s_t)}{\partial \theta}\right)^\top (2 \Sigma^{-1} (\mu_\theta(s_t) - a_t)) \ &= - \left(\frac{\partial \mu_\theta(s_t)}{\partial \theta}\right)^\top \Sigma^{-1} (\mu_\theta(s_t) - a_t) \ &= \left(\frac{\partial \mu_\theta(s_t)}{\partial \theta}\right)^\top \Sigma^{-1} (a_t - \mu_\theta(s_t)) \end{aligned} $$

部分可观测性(Partial Observability)

在许多现实场景中,智能体无法获取环境的完整状态 $s_t$,只能得到一个观测值 $o_t$(例如,来自摄像头的图像)。这种情况被称为部分可观测马尔可夫决策过程(Partially Observable Markov Decision Process, POMDP)。此时,策略变为基于观测值的 $\pi_\theta(a_t|o_t)$。

一个重要的结论是:即使在部分可观测的情况下,策略梯度的基本形式依然成立。我们可以将推导过程中的 $s_t$ 替换为 $o_t$,得到:

$$ \nabla_\theta J(\theta) = \mathbb{E}{\tau \sim p\theta(\tau)} \left[ \left( \sum_{t=0}^{T-1} \nabla_\theta \log \pi_\theta(a_t | o_t) \right) R(\tau) \right] $$

其中 $\tau = (o_0, a_0, o_1, a_1, \ldots)$。这是因为策略梯度的推导并不依赖于状态的马尔可夫性质。

注意:虽然公式形式不变,但策略的学习效果现在受限于观测 $o_t$ 所包含的信息量。如果 $o_t$ 缺失了做出最优决策所必需的关键状态信息,那么即使使用策略梯度,也无法学到最优策略

在这种情况下,一种常用的方法是 利用历史信息,例如使用循环神经网络(RNN)作为策略网络,输入 $o_t$ 和之前的隐藏状态,以捕捉时间上的依赖关系。

降低策略梯度方差的技术

为了缓解 REINFORCE 的高方差问题,可以采用以下技巧:

奖励转置(Reward-to-Go)

原始的 REINFORCE 算法中,在计算 $t$ 时刻的梯度项 $\nabla_\theta \log \pi_\theta(a_t | s_t)$ 时,使用了整条轨迹的总回报 $R(\tau) = \sum_{t'=0}^{T} r_{t'}$ 作为权重。

思考:在 $t$ 时刻采取的动作 $a_t$ 只能影响从 $t$ 时刻及之后获得的奖励 $(r_t, r_{t+1}, \ldots, r_T)$,而无法影响 $t$ 时刻之前的奖励 $(r_0, \ldots, r_{t-1})$。因此,将过去的奖励也包含在权重中,引入了与当前决策无关的噪声。

改进:只使用从当前时刻 $t$ 开始到轨迹结束的累积奖励,即 奖励转置(Reward-to-Go),作为权重:

$$ \hat{Q}(s_t, a_t) = \sum_{t'=t}^{T} r(s_{t'}, a_{t'}) $$

修改后的策略梯度估计变为:

$$ \hat{g}{rtg} = \frac{1}{N} \sum{i=1}^{N} \sum_{t=0}^{T-1} \nabla_\theta \log \pi_\theta(a_t^{(i)} | s_t^{(i)}) \hat{Q}(s_t^{(i)}, a_t^{(i)}) $$

这种方法考虑了动作的因果影响,即一个动作只对未来的奖励负责

理论上可以证明,使用 Reward-to-Go 仍然是 $\nabla_\theta J(\theta)$ 的无偏估计,并且通常具有比使用总回报 $R(\tau)$ 更低的方差。

基线(Baseline)

另一个问题是,策略梯度对奖励的绝对值敏感。如果所有轨迹的回报都是正的(即使有好有坏),那么所有动作都会在一定程度上被 “鼓励”(梯度项为正)。我们更希望的是:比平均水平好的动作被鼓励,比平均水平差的动作被抑制。这可以同时降低方差,增强训练稳定性。

思路:从回报项中减去一个只依赖于状态 $s_t$ 的基线 $b(s_t)$。这个基线不依赖于具体采取的动作 $a_t$。

$$ \nabla_\theta J(\theta) = \mathbb{E}{\tau \sim p\theta(\tau)} \left[ \sum_{t=0}^{T-1} \nabla_\theta \log \pi_\theta(a_t | s_t) (\hat{Q}(s_t, a_t) - b(s_t)) \right] $$

可以证明,只要基线 $b(s_t)$ 不依赖于动作 $a_t$,减去它不会改变梯度的期望值(即估计仍然是无偏的),也即:

$$ \mathbb{E}{a_t \sim \pi\theta(\cdot|s_t)}[\nabla_\theta \log \pi_\theta(a_t|s_t) b(s_t)] = 0 $$

证明:

$$ \begin{aligned} \mathbb{E}{a_t \sim \pi\theta(\cdot|s_t)}[\nabla_\theta \log \pi_\theta(a_t|s_t) b(s_t)] &= b(s_t) \mathbb{E}{a_t \sim \pi\theta(\cdot|s_t)}[\nabla_\theta \log \pi_\theta(a_t|s_t)] \ &= b(s_t) \int \pi_\theta(a_t|s_t) \nabla_\theta \log \pi_\theta(a_t|s_t) \mathrm{d}a_t & & \text{(期望定义)} \ &= b(s_t) \int \pi_\theta(a_t|s_t) \frac{\nabla_\theta \pi_\theta(a_t|s_t)}{\pi_\theta(a_t|s_t)} \mathrm{d}a_t & & \text{(对数导数技巧)} \ &= b(s_t) \int \nabla_\theta \pi_\theta(a_t|s_t) \mathrm{d}a_t \ &= b(s_t) \nabla_\theta \int \pi_\theta(a_t|s_t) \mathrm{d}a_t \ &= b(s_t) \nabla_\theta (1) & & \text{(概率密度积分为 1)} \ &= b(s_t) \times 0 \ &= 0 \end{aligned} $$

目标:选择合适的基线 $b(s_t)$ 来最小化梯度估计的方差。

最优基线:虽然减去任何有效的基线都不会引入偏差,但不同的基线对降低方差的效果不同。最优的基线通常难以计算。

证明:我们可以分析梯度估计的方差。

令 $g(\tau, b) = \nabla_\theta \log p_\theta(\tau) (R(\tau) - b)$。

$$ \mathrm{Var}[g(\tau, b)] = \mathbb{E}[g(\tau, b)^2] - (\mathbb{E}[g(\tau, b)])^2 $$

由于 $\mathbb{E}[g(\tau, b)] = \mathbb{E}[\nabla_\theta \log p_\theta(\tau) R(\tau)]$(因为基线项期望为 0),它不依赖于 $b$。因此,最小化方差等价于最小化 $\mathbb{E}[g(\tau, b)^2]$:

$$ \mathbb{E}[g(\tau, b)^2] = \mathbb{E}[(\nabla_\theta \log p_\theta(\tau))^2 (R(\tau) - b)^2] $$

对 $b$ 求导并令其为 0:

$$ \frac{\mathrm{d}}{\mathrm{d}b} \mathbb{E}[(\nabla_\theta \log p_\theta(\tau))^2 (R(\tau) - b)^2] = \mathbb{E}[(\nabla_\theta \log p_\theta(\tau))^2 \times 2(R(\tau) - b) \times (-1)] = 0 $$

$$ \mathbb{E}[(\nabla_\theta \log p_\theta(\tau))^2 (R(\tau) - b)] = 0 $$

$$ \mathbb{E}[(\nabla_\theta \log p_\theta(\tau))^2 R(\tau)] = b , \mathbb{E}[(\nabla_\theta \log p_\theta(\tau))^2] $$

解出最优基线 $b^*$:

$$ b^* = \frac{\mathbb{E}[(\nabla_\theta \log p_\theta(\tau))^2 R(\tau)]}{\mathbb{E}[(\nabla_\theta \log p_\theta(\tau))^2]} $$

这个最优基线 $b^*$ 可以看作是回报 $R(\tau)$ 的期望,但使用梯度幅度的平方 $(\nabla_\theta \log p_\theta(\tau))^2$ 进行了加权。

采样均值基线

$$ b = \frac{1}{N} \sum_{i=1}^N R(\tau^{(i)}) $$

这里也可以使用平均 Reward-to-Go 作为基线。

这虽然不是最优的,但通常也能提供不错的方差降低效果。

注意,如果使用蒙特卡洛算法,不同的 $b$ 的选择的确会影响采样计算出的 $\nabla_\theta J(\theta)$ 近似值,但是这是由于采样不足,$N$ 不够大造成的。

状态价值函数基线

状态价值函数 $V^{\pi_\theta}(s_t)$:表示从状态 $s_t$ 开始,遵循策略 $\pi_\theta$ 之后所能获得的期望(折扣)Reward-to-Go 回报,它只依赖于状态 $s_t$ 和策略 $\pi_\theta$。

$$ V^{\pi_\theta}(s_t) = \mathbb{E}{\tau \sim p\theta(\tau)} \left[ \sum_{t'=t}^{T} \gamma^{t'-t} r_{t'} \middle| s_t \right] = \mathbb{E}{a_t \sim \pi\theta(\cdot|s_t)} [Q^{\pi_\theta}(s_t, a_t)] $$

动作价值函数 $Q^{\pi_\theta}(s_t, a_t)$:表示在状态 $s_t$ 采取动作 $a_t$ 后,再遵循策略 $\pi_\theta$ 所能获得的期望(折扣)Reward-to-Go 回报,它依赖于状态 $s_t$、动作 $a_t$ 和策略 $\pi_\theta$。

$$ Q^{\pi_\theta}(s_t, a_t) = \mathbb{E}{\tau \sim p\theta(\tau)} \left[ \sum_{t'=t}^{T} \gamma^{t'-t} r_{t'} \middle| s_t, a_t \right] = r(s_t, a_t) + \gamma \mathbb{E}{s{t+1} \sim P(\cdot|s_t, a_t)} [V^{\pi_\theta}(s_{t+1})] $$

优势函数(Advantage Function) $A^{\pi_\theta}(s_t, a_t)$:在状态 $s_t$ 采取特定动作 $a_t$ 相对于平均动作(也就是 $V^{\pi_\theta}(s_t)$ 作为基线)的好坏程度

$$ \begin{aligned} A^{\pi_\theta}(s_t, a_t) &= Q^{\pi_\theta}(s_t, a_t) - V^{\pi_\theta}(s_t) \ &= r(s_t, a_t) + \gamma \mathbb{E}{s{t+1} \sim P(\cdot|s_t, a_t)} [V^{\pi_\theta}(s_{t+1})] - V^{\pi_\theta}(s_t) \end{aligned} $$

这里引入了折扣因子 $\gamma \in [0, 1)$,它的作用是:

  1. 确保在无限时间步长问题中,累积回报是有限的。
  2. 表示对未来奖励的不确定性或对即时奖励的偏好。$\gamma$ 越小,越看重眼前的奖励。
  3. 隐式地鼓励尽早完成任务:因为越往后的奖励会被 $\gamma$ 折扣得越多,所以总回报最高的方式通常是尽快获得奖励。

现在,策略梯度现在可以写为:

$$ \nabla_\theta J(\theta) = \mathbb{E}{(s_t, a_t) \sim \pi\theta} [ \nabla_\theta \log \pi_\theta(a_t | s_t) A^{\pi_\theta}(s_t, a_t) ] $$

使用 $V(s_t)$ 作为基线后,权重项变为:

$$ \begin{aligned} \hat{A}(s_t, a_t) &= \hat{Q}(s_t, a_t) - \hat{V}(s_t) \ &= r(s_t, a_t) + \gamma \hat{V}(s_{t+1}) - \hat{V}(s_t) \ \end{aligned} $$

这里直接暴力地对期望 $\mathbb{E}{s{t+1} \sim P(\cdot|s_t, a_t)} [V^{\pi_\theta}(s_{t+1})]$ 进行蒙特卡洛估计。

$\hat{A}(s_t, a_t)$ 是优势函数的估计值。

  • $\hat{A}(s_t, a_t) > 0$:动作 $a_t$ 比平均表现要好,应该增加其概率
  • $\hat{A}(s_t, a_t) < 0$:动作 $a_t$ 比平均表现要差,应该降低其概率

估计 $V(s_t)$ 的方法

蒙特卡洛

计算在所有 $N$ 条轨迹中经过状态 $s_t$ 的样本的平均 Reward-to-Go 回报:

$$ \hat{V}(s_t) = \frac{1}{N} \sum_{i=1}^{N} \sum_{t'=t}^{T} \gamma^{t' - t} r(s_{t'}, a_{t'}) $$

神经网络

使用另一个神经网络(称为 Critic)来学习并预测 $V(s_t)$:

$$ \hat{V}(s) = \hat{V}_{\phi}(s) $$

不要被形式迷惑,这里就是要设法学一个 $s_t$ 的值函数。

所以,我们可以准备数据集:

$$ \mathcal{D} = { (s_{i,t}, \underbrace{r(s_{i,t}, a_{i,t}) + \gamma \hat{V}{\phi}^{\pi}(s{i,t+1})}{y{i,t}}) } $$

其中,$s_{i,t}$ 是在第 $i$ 条轨迹、时刻 $t$ 遇到的状态。

然后,使用神经网络来监督学习就行。

自举(Bootstrap):使用了一个基于当前函数估计的值 $\hat{V}{\phi}^{\pi}(s{i,t+1})$ 来更新同一个函数在另一个点 $s_{i,t}$ 的估计 $\hat{V}{\phi}^{\pi}(s{i,t})$。

关于自举有一个很形象的例子:在河里拽自己的鞋带把自己拽起来。

Actor-Critic

重新回顾 “基线” 这一概念,再结合使用神经网络来估计 $V(s_t)$ 的方法以及策略梯度的公式:

$$ \nabla_\theta J(\theta) = \mathbb{E}{(s_t, a_t) \sim \pi\theta} [ \nabla_\theta \log \pi_\theta(a_t | s_t) A^{\pi_\theta}(s_t, a_t) ] $$

我们就可以很自然的想到 Actor-Critic 方法。

  • Actor(演员):指策略网络 $\pi_\theta(a_t|s_t)$,负责根据状态 $s_t$ 做出动作决策,决定此步的 $r(s_t, a_t)$ 进而影响 $A(s_t, a_t)$
  • Critic(评论家):指价值网络($V_{\phi}(s_t)$ 或者 $Q_{\phi}(s_t, a_t)$,$\phi$ 表示其参数),负责评估 Actor 所处的状态 $s_t$ 或采取的动作 $a_t$ 的好坏(即估计 $V$ 值或 $Q$ 值,进而计算优势 $A$ 值)

在训练完成后,真正推理(干活)的时候,不用 Critic,只用 Actor。

Batch Actor-Critic

循环:

  1. 收集一批完整的轨迹数据
  2. 用这批数据一次性或多次迭代地更新 Critic $\hat{V}_\phi^\pi$(拟合蒙特卡洛回报或 TD 目标)
  3. 用更新后的 Critic 计算整批数据的优势: $$ \hat{A}^\pi(s_t, a_t) = r(s_t, a_t) + \gamma \hat{V}\phi^\pi(s{t+1}) - \hat{V}_\phi^\pi(s_t) $$
  4. 计算整批数据的平均策略梯度: $$ \nabla_\theta J(\theta) = \mathbb{E}{(s_t, a_t) \sim \pi\theta} [ \nabla_\theta \log \pi_\theta(a_t | s_t) \hat{A}^\pi(s_t, a_t) ] $$
  5. 更新 Actor: $$ \theta \leftarrow \theta + \alpha \nabla_\theta J(\theta) $$

Online Actor-Critic

循环:

  1. 在当前状态 $s$,根据策略选择动作 $a \sim \pi_\theta(a|s)$
  2. 执行动作 $a$,观察到奖励 $r$ 和下一个状态 $s'$ 获得一个转换 $(s, a, r, s')$
  3. 立即使用这个转换来更新 Critic $\hat{V}\phi^\pi$(通常使用 TD 目标 $\delta$) $$ \delta = r + \gamma \hat{V}\phi^\pi(s') - \hat{V}\phi^\pi(s) \ L(\phi) \doteq \frac{1}{2} \delta^2 = \frac{1}{2} \left( (r + \gamma \hat{V}\phi^\pi(s')) - \hat{V}\phi^\pi(s) \right)^2 \ \nabla\phi L(\phi) = \frac{\partial L(\phi)}{\partial \delta} \frac{\partial \delta}{\partial \hat{V}\phi^\pi(s)} \nabla\phi \hat{V}\phi^\pi(s) = - \delta \nabla\phi \hat{V}\phi^\pi(s) \ \hat{V}\phi^\pi(s) \leftarrow \hat{V}\phi^\pi(s) + \beta \nabla\phi L(\phi) $$
  4. 立即计算优势函数的估计值,通常就是 TD 误差本身: $$ \hat{A}^\pi(s, a) = \delta = r + \gamma \hat{V}\phi^\pi(s') - \hat{V}\phi^\pi(s) $$
  5. 立即更新 Actor: $$ \theta \leftarrow \theta + \alpha \nabla_\theta J(\theta) \approx \theta + \alpha \nabla_\theta \log \pi_\theta(a|s) \hat{A}^\pi(s, a) $$

Online vs. Batch

  • Online:更新更频繁(每一步都可能更新),数据利用率可能更高(效率高),能适应非平稳环境但单步更新可能带来高方差
  • Batch:更新基于更多数据(如走完一整条轨迹才更新),梯度估计更稳定(方差较低)但需要存储更多数据,更新频率较低

网络架构

ac_arch

  • 分离网络:Actor 和 Critic 使用独立的神经网络。简单稳定,但无特征共享。
  • 共享网络:Actor 和 Critic 共享部分底层网络。参数效率高,但训练可能更复杂。

同步 / 异步

parallel

即使在 Online AC 中,也常常收集一个小批量数据来更新 Critic $\hat{V}_\phi^\pi$ 和 Actor $\theta$,因为这有助于稳定学习过程,降低梯度估计的方差。

并行化(Parallelization):使用多个并行的 Actor(workers)同时在环境中收集经验,可以显著提高数据采集速度和多样性,进一步稳定训练。

并行又可分为同步(Synchronous)和异步(Asynchronous)。同步并行存在同步点,整体速度受限于最慢的 worker。异步并行则没有同步点,会更快。

💾

  •  

策略 I

2025年4月16日 06:38

条件抓取生成模型(Conditional Grasp Generative Model)

问题定义与挑战

目标:在一个包含多个物体的杂乱场景(例如,一个箱子里的物品)中,规划灵巧手的抓取动作。

核心挑战:

  1. 避免碰撞:抓取目标物体的同时,要尽量避免与场景中的其他物体或环境发生不必要的碰撞。
  2. 泛化性:模型需要能够泛化到新的物体(不同的几何形状)和新的场景布局(不同的物体分布)。

与单物体抓取的区别:相比于抓取一个孤立的物体,杂乱场景中的抓取规划要复杂得多,因为它需要同时考虑物体间的相互作用和潜在的碰撞。

进阶问题:有些研究会先通过 非抓握操作(Non-prehensile Manipulation,如推、拨) 将目标物体分离出来,简化后续抓取。

DexGraspNet

核心是利用大规模 合成数据(Synthetic Data) 进行训练,并通过深度学习模型来学习抓取策略。

dex_grasp_net

场景理解模块(Scene Understanding)

输入:场景的点云数据。

任务:

  • 预测场景中每个点的 抓取可能性(Graspness):哪些区域适合进行抓取。
  • 区分前景 物体(Objectness) 与背景(如桌面)。

方法:使用一个点云处理网络(如基于稀疏卷积的网络)进行监督学习,标签(Graspness, Objectness)从合成数据中自动生成(由合成数据提供监督信号)。

局部区域提议与特征提取(Local Region Proposal & Feature Extraction)

动机:直接使用整个场景的全局特征(Global Feature)来指导抓取生成存在困难

  • 弱关联性:全局特征与特定位置的抓取动作之间的关联可能不够强,导致以之为条件的条件生成模型学习效果不佳,甚至退化为无条件生成

    老师提到,conditon 最好要和输出结果有很强的 correlation,这样效果更好,且更好建模、泛化。

  • 泛化性差:新场景的全局特征可能与训练数据差异巨大,导致模型难以迁移。

方法:

  • 根据第一步预测的 Graspness Score,选择得分最高的点 (例如 Top 1%)。
  • 围绕这些高分点,提取局部区域(Local Region)的点云。
  • 从这些局部点云区域中提取局部特征(Local Feature)。这些局部特征(如平坦表面、边缘、角落等 几何信息 )在不同场景中更可能重复出现,有助于提升泛化性。

条件抓取生成模块(Conditional Grasp Generation)

输入:上一步提取的局部特征。

任务:生成有效的抓取位姿,包括末端执行器的 6D 位姿(位置 $T$ 和姿态 $R$)以及手的形态(手指配置 $\theta$)。

面临的挑战:抓取的 多模态性(Multi-modality)。对于同一个物体或区域,通常存在多种有效的抓取方式(多峰分布)。如果直接使用回归(Regression)预测单一抓取,模型倾向于输出所有可能抓取的 “平均值”,而这个平均抓取往往是无效的(Mode Average 问题)。

开车避障时,你可以选择左打方向盘或者右打方向盘,但模型为了降低 Loss,会输出平均值 —— 啥都不动,直直撞上去。

解决方案:解耦建模,将抓取生成分解为两个步骤。

  1. 建模末端 6D 位姿的分布:认为末端位姿 $(T, R)$ 的选择具有明显的 多模态特性

    所以,使用一个 条件生成模型 (如 Diffusion Model)来学习在给定局部特征条件下的 位姿分布 $p(T, R | \text{local_feature})$,并从中采样得到 $(T,R)$

  2. 预测手型:假设当末端位姿 $(T, R)$ 固定后,最优的手指形态 $\theta$ 的不确定性大大降低(近似单峰分布)。

    因此,可以使用一个 回归模型,根据采样得到的 $(T, R)$ 和局部特征来预测手型 $\theta = f(T, R, \text{local_feature})$。

生成过程:先从学习到的分布 $p(T, R | \text{local_feature})$ 中采样一个或多个候选的末端位姿 $(T, R)$,然后对每个采样出的位姿预测对应的手型 $\theta$。

实验结果与分析

消融实验证实,使用局部特征作为条件相比于使用全局特征,抓取成功率有显著提升,这验证了局部特征在增强关联性和泛化性方面的关键作用。

Scaling Law:抓取性能与训练数据的规模(抓取样本数量和场景多样性)显著相关,使用合成数据可以大幅提升成功率(10 万~ 1000 万),但存在边界收益递减的问题。

优点

  1. 有效的数据合成管线
  2. 设计了一个 端到端 的框架

局限性

  1. 抓取类型:仅处理包覆式抓取(Power Grasp),没处理指尖抓取(Precision Grasp),如用指尖捏取小物体
  2. 抓取闭合类型:主要使用力封闭抓取(Force-Closure Grasp),但是存在非力封闭的场景(如托起物体)

透明与高反光物体

问题概述

尽管像 GraspNet 这样的方法在许多物体上表现良好,但它们在处理透明(Transparent)或高反光(Highly Specular/Shiny)物体时会遇到巨大挑战。

主要原因在于,目前商用的深度传感器(Commercial Depth Sensor),如基于飞行时间(Time-of-Flight, ToF)或结构光(Structured Light)的传感器,其工作原理依赖于对光线传播的特定假设,例如:

  • 它们假设光线照射到物体表面后会直接反射回来。
  • 结构光方法假设投射的特定光图案(Pattern)在物体表面会发生可预测的漫反射(Diffuse Reflection),通过观察反射图案的变形来推算深度。

然而,对于透明物体,大部分光线会发生折射(Refraction)并穿透物体,而不是反射。对于高反光物体,光线会发生镜面反射(Specular Reflection),形成高光区域,这与传感器通常假设的漫反射模型不符。

这些问题会 导致点云的质量(quality)变差,所以在深度传感器看来,透明或高反光物体的几何结构往往是残缺不全的。

transparent_and_shiny

由于输入的点云质量低下,依赖于几何信息进行抓取规划的方法自然难以有效工作。

ASGrasp

asgrasp

核心目标:深度修复,获得高质量的深度信息。

ASGrasp 采用基于学习的深度感知(Learning-based Depth Sensing)方法,而不依赖固定物理模型的传统方法。

  1. 合成数据驱动:利用图形学渲染技术生成大量的合成数据。每条数据包含一个渲染出的场景图像(RGB Image)和与之对应的 “完美” 深度图或点云(Ground Truth Depth/Point Cloud)。
  2. 监督学习:将(图像,真实深度)作为 配对的监督信号,训练一个深度学习网络。这个网络学习从输入的(可能有问题的)传感器图像直接预测出准确的深度信息 $f: \text{Image} \rightarrow \text{Depth}$。

显然,依赖于合成数据的方法主要挑战在 泛化性(Generalization) 问题。

为了解决泛化性问题,合成数据必须具有足够的 多样性(Diversity)

域随机化(Domain Randomization):在生成合成数据时,尽可能地随机化各种环境和物体参数,使得训练数据覆盖足够广泛的分布,防止对特定条件产生过拟合(overfit),从而让模型对真实世界中未曾见过的变化更具鲁棒性。

域随机化的方面包括:

  1. 物体和布局(Objects and Layout)
  2. 材质(Materials)
  3. 背景(Backgrounds)
  4. 光照(Illumination)
  5. 相机视角(Camera Viewpoints)

除了使用多样化合成数据进行训练。ASGrasp 另一核心是 多模态立体视觉 (Multi-modal Stereo Vision)方案,它同时利用了红外图像(Infrared,IR)和彩色图像(RGB)来估计深度。且其中使用了类似双目视觉的 立体匹配(Stereo Matching) 方法来得到深度信息。

ASGrasp 的独特之处在于其 混合匹配策略

  1. 它首先利用 红外图像对(IR Image Pair) 进行双目立体匹配,红外成像对于某些在可见光下难以处理的材质(如透明、高反光)可能提供更稳定的特征。
  2. 同时,它将 彩色图像(RGB Image) 作为 额外的上下文信息(Additional Context) 融入匹配过程。RGB 图像提供了丰富的颜色和纹理信息,可以帮助消除歧义(disambiguate),或者在 IR 信息不足时提供补充。

在网络结构层面,ASGrasp 采用了在立体匹配领域常见的技术:

  1. 相关性金字塔 / 代价体(Correlation Pyramid / Cost Volume):编码不同视差下的匹配代价。
  2. 由粗到精(Coarse-to-Fine)的优化策略:逐步细化深度图,提高精度。

通过这种方式,ASGrasp 能够生成高质量的深度图,尤其是在处理传统方法难以应对的透明和高反光物体时表现出色。

而拥有了更准确的深度图后,就可以将其输入到后续的抓取规划块中,能够为这些原本难以感知的物体生成有效的抓取位姿。

可供性(Affordance)

前面的讨论主要集中在如何通过视觉感知来抓取物体。然而,机器人的能力不应仅限于抓取,更进一步需要执行各种 操作(Manipulation / Operation)

可供性(Affordance):指一个物体所能支持或提供的交互方式或操作可能性。

它描述了环境或物体向交互者(人或机器人)提供的潜在行动可能性。

例如:

  • 一个抽屉的可供性是它可以被拉出(Pullable)或推入(Pushable)。
  • 一扇门的可供性在于它的门把手可以被抓住(Graspable),并且门可以被打开(Openable)。
  • 一把刀作用于一个水果时,水果的可供性在于它可以在某些区域被切割(Cuttable)。

在机器人学中,我们关注的可供性通常是指:为了让机器人完成某个特定的操作任务,它应该与物体的 哪个区域(Where) 进行交互,以及应该 以何种方式(How) 进行交互。

可供性通常被表示为热力图,也称 可供性图(Affordance Map)。对于不同的动作会有不同的可供性图,指示那个地方适合此类动作。

因此,通过预测这样的可供性地图,机器人就能知道哪些区域是执行特定操作的有效接触点。

Where2Act

利用学习方法来预测物体可供性的工作。

  1. 数据收集:让机器人在仿真或真实环境中对各种物体(尤其是带有可活动部件的铰接物体,articulated objects)进行大量的随机交互尝试(推、拉、拽等)。
  2. 标注:记录下哪些尝试成功了(如成功打开抽屉),哪些失败了。成功的交互区域和方式就构成了正样本训练数据。
  3. 模型训练:训练一个深度学习模型,输入物体的视觉信息(如图像和 / 或点云),输出其可供性图。

Pipeline

where_2_act

输入:2D / 3D

特征融合:将 2D 和 3D 特征进行融合,得到每个点的综合特征 $f_p$。

输出预测:基于融合后的特征 $f_p$,模型会预测多个信息:

  • 交互点(Contact Point):预测哪些点适合进行交互(输出表示可交互性的分数 $a_p$,affordance)。
  • 交互方向(Interaction Direction):预测在某个点上,应该沿着哪个方向进行交互(输出方向 $R_{z|p}$)。这可能需要对方向进行离散化或直接回归。
  • 成功置信度(Success Confidence):预测在该点以预测方向进行交互的成功概率或置信度(输出成功得分 $s_{R|p}$)。

VAT-Mart

VAT-Mart 进一步扩展了可供性的概念,认为仅仅预测交互点和初始方向可能不足以完成复杂的、需要遵循特定路径的操作。

例如,打开一个旋转门,如果只是沿着一个固定方向拉门把手,很快就会因为运动轨迹不匹配而失败。正确的操作需要沿着门转动的弧线运动。

VAT-Mart 不仅预测可供性区域(affordance),还预测出一整条 操作轨迹(trajectory)

视觉驱动的开环方法总结

应用

利用视觉输入进行预测:

  • 预测 物体位姿(Object Pose):通常需要物体的 CAD 模型和抓取标注。
  • 预测 抓取位姿(Grasp Pose):可以直接预测抓取点和姿态,无需 CAD 模型或预定义抓取。
  • 预测 可供性(Affordance):超越简单抓取,指导更广泛的交互操作。

运动规划(Motion Planning):利用预测出的目标(如抓取位姿或交互点 / 轨迹),结合环境信息(避障),规划出机器人手臂的运动路径。

实际执行中,运动规划往往需要结合一些启发式(heuristics)规则或技巧(tricks)来提高成功率和鲁棒性。例如 预抓取(Pre-grasp)位置,先移动到目标抓取点附近的一个安全位置,再直线接近并闭合夹爪,可以避免不必要的碰撞。

局限性

操作复杂度有限:通常只能处理一些预定义好的、相对简单的操作(如开抽屉、开柜门)。更复杂的操作(如转笔)超出了当前基于可供性预测和运动规划的框架能力。主要瓶颈在于启发式规则的设计。

开环执行:规划一次,执行到底。系统根据初始的视觉观测进行规划(抓取位姿、运动轨迹等),然后执行这个预先计算好的计划,在执行过程中 不再接收新的视觉反馈 来调整动作。就像闭着眼睛做事一样。

显然,对于开环来说,一旦执行过程中出现预期之外的情况,例如物体被意外碰到、滑动,或者初始感知 / 规划存在误差,整个任务很可能失败,因为系统无法根据实时变化进行调整。

但是,通过 高频率地重复 “感知 - 规划 - 执行” 的循环,将开环系统近似转化为闭环系统。

策略学习

策略学习(Policy Learning) 旨在解决开环抓取和规划中动态特性不足、无法及时根据环境状态调整的问题。

策略学习的核心在于构建一个能够根据环境状态变化采取合理策略的方案,本质上就是一个 Policy。Policy 拥有闭环执行的潜力,能够更好地适应场景状态的变化,从而使机器人操作更加鲁棒和高动态。

基础约定

  • 状态(State)$s_t$:环境的状态,这些状态通常隐藏在观测之下
  • 观测(Observation)$o_t$:对环境的观测,例如点云或图像。观测蕴含状态的信息,但通常是局部的、片面的。观测是状态的体现,状态是观测的本质
  • 动作(Action)$a_t$:在特定状态或观测下采取的策略,Policy 的目标就是根据场景中的状态变化动态地做出响应
  • 策略(Policy)$\pi(a_t|s_t)$ / $\pi(a_t|o_t)$:Policy 定义了在特定状态 / 观测下应该采取什么样的动作。通常用参数 $\theta$ 来参数化 Policy,记作 $\pi_\theta$

如果 Policy 基于状态 $s_t$ 来决定动作 $a_t$,则称该 Policy 为 Fully Observed 的策略。这意味着所有环境状态都是可观测的,虽然在现实中,我们通常只能获得部分观测。

我们的目标即为学习到这个策略。

模仿学习(Imitation Learning)

策略学习中最简单的方法就是监督学习,即模仿专家的行为。专家在每个状态或观测下给出正确的动作,然后通过监督学习训练 Policy。

示例:Point Goal Navigation

目标:在场景中导航到一个目标点。

传统方法(如 A* 算法):在已知地图的情况下可以找到最短路径。

策略学习:将传统路径规划算法作为老师(提供监督信号),指导 Policy 在每一步应该如何走才能更快到达目标点。

与传统方法的区别:即使没有地图,也可以通过训练一个基于视觉观测的 Policy,从而将策略应用到未建立地图的新场景中。

这是一种典型的模仿学习策略。

模仿学习的执行过程

策略执行过程可以概括如下:

  1. 观测(Observation)$o_t$:观测环境,可能蕴含环境状态的描述。
  2. 策略(Policy)$\pi(a_t|o_t)$:根据观测,Policy 决定采取的动作。
  3. 状态转移(State Transition):采取动作后,环境状态发生改变。

通过这样的方式,逐步迭代执行。

Markov 假设

定义:在任何状态下做判断时,只需根据当前状态来决定接下来应该采取什么动作,无需考虑过去经历了哪些状态。当前状态已经包含了过去历史的充分信息。

Markov 假设并非总是成立。 例如,司机超车时会根据自己的一些历史信息(如右后方是否有车辆)来决定是否变道。

Behavior Cloning

Behavior Cloning (BC) 是一种基本的模仿学习方案。根据观测,数据集包含专家在特定状态下采取的动作。通过监督学习,建立从观测到动作的映射关系,并使用动作层面的监督信号进行梯度回传,从而训练 Policy。

BC 将模仿学习问题视为一个监督学习问题。给定专家在状态 $s$ 下采取的动作 $a$ 的数据集 $D = {(s_i, a_i)}{i=1}^N$,行为克隆的目标是学习一个策略 $\pi\theta(a|s)$,使得在给定状态 $s$ 时,策略输出的动作 $a$ 尽可能接近专家动作 $a^*$。通常通过最小化预测动作与专家动作之间的差异来实现,例如使用均方误差损失:

$$ \theta^* = \arg \min_\theta \sum_{(s_i, a_i^) \in D} || \pi_\theta(s_i) - a_i^ ||^2 $$

BC 历史

1989 年,研究人员使用神经网络处理视觉输入,并将其映射到车辆的行为(方向盘转动角度、油门、刹车等)。这是 Behavior Cloning 的雏形。

2016 年,研究人员尝试使用深度学习方案改进 Behavior Cloning,用于自动驾驶。相比于早期的思路,这些改进包括更深的网络、更多的数据,以及更好的校正机制。即使使用基本的 Behavior Cloning,也能展示出不错的自动驾驶能力。

Distribution Shift

定义:模仿学习依赖于训练数据和测试数据具有较好的分布一致性。当这种分布一致性被打破时,模型很难泛化到测试集。而且,随着时间的推移,偏差会不断增大,尤其是在长序列任务中。一开始可能与训练分布一致,但执行步数越多,偏差越大,最终完全不可回头。

distribution_shift

这是因为:

  1. 专家演示数据通常只覆盖了状态空间中很小的一部分,即专家成功执行任务时所经历的状态。
  2. 学习到的策略 $\pi_\theta$ 不可能完美复制专家策略 $\pi^*$。即使是很小的误差,也会导致智能体在执行过程中逐渐偏离专家的状态分布。
  3. 一旦智能体进入训练数据中未曾出现过的状态,行为克隆训练出的策略可能无法做出正确的决策,导致错误累积,最终可能完全失败。

就像在一个陌生的环境中,有人带路,但之后开始乱走,走到一个完全陌生的环境,就会迷路。除非有专家重新指导,否则无法回到正确的轨迹。

BC 的实际应用

尽管有局限性,但当数据足够大时,Behavior Cloning 仍然可以表现出不错的性能。

遥操作可以通过动捕或机器人主从同步等方式获取专家数据。有了这些数据,就可以通过 BC 进行学习。

  • 合成数据:量大,但可能存在外观(Appearance)和物理(Physics)方面的差异,导致从虚拟环境学习的技能难以迁移到真实场景。需要进行充分的 域随机化(Domain Randomization)
  • 遥操作数据:在真实 / 虚拟场景(虚拟场景中便于数据增强)中采集,可以减少 Appearance 和 Physics 的差异。但代价高昂,且仍然可能存在泛化问题

解决 Distribution Shift 的思路

既然 Distribution Shift 来自于分布的不同,那么解决 Distribution Shift 的核心在于让这两个分布更加对齐(Alignment)。

有两种主要思路:

  1. 改变 $p_{\text{data}}(o_t)$:扩充专家数据的轨迹,使其能够覆盖策略执行过程中可能出现的状态空间。
  2. 改变 $p_{\pi}(o_t)$:给定专家轨迹,更好地拟合专家的轨迹,避免偏离专家的路线。

Dataset Aggregation

Dataset Aggregation(DAgger)是一种改变 $p_{\text{data}}(o_t)$ 的方法,旨在扩充训练数据,使其能够覆盖策略执行过程中可能出现的状态空间。

其核心思想是在训练过程中主动收集策略在执行时遇到的状态,并向专家请教这些状态下的正确动作,然后将这些新的数据加入训练集中。

  1. 初始化:使用初始的专家数据集 $D$ 训练一个初始策略 $\pi_1$。
  2. 迭代执行 (对于 $i = 1, 2, ..., N$):
    1. 执行策略:让当前的策略 $\pi_i$ 在环境中执行,收集遇到的状态序列 $s_1, s_2, ...$ (Rollout)
    2. 专家标注:对于收集到的状态 $s_t$,查询专家策略 $\pi^$,得到专家会采取的动作 $a_t^ = \pi^*(s_t)$。
    3. 数据聚合:将新的状态 - 动作对 $(s_t, a_t^)$ 加入到数据集 $D$ 中,即 $D \leftarrow D \cup {(s_t, a_t^)}$。
    4. 重新训练:使用聚合后的数据集 $D$ 重新训练策略,得到 $\pi_{i+1}$。
  3. 输出:最终得到的策略 $\pi_N$。

通过这种方式,监督数据集不断增长,覆盖实际执行过程中可能看到的各种状态,从而使 Policy 更加可控。

问题:出错了再标注的话,可能会对策略的准确性有所伤害(因为这种情况下你学到的不是完美的 Policy),但通常因为看到新的状态带来的学习经验收益更大。

从最优解中获取(From optimal solution)

利用传统算法,来构建一个传统的最优求解器(如 A* 搜索)。当学习的策略偏离最优路径时,可以使用这个求解器来提供完美的纠正动作,指导策略回到正轨。

从教师策略中学习(From a teacher solution)

我们可以假设存在一个 教师策略,它拥有比 学生策略 更多的 特权信息(Privileged Knowledge),例如仿真环境中的真实状态、物体精确的姿态或物理属性等。

利用这些特权信息,教师策略可以更容易地规划出最优动作。然后,让只能看到部分观测(如图像、点云)的学生策略去模仿教师策略的行为。

这样,即使学生策略偏离了,教师策略也能根据当前状态(利用其特权信息)给出在线的、正确的指导动作。这与仅提供固定的专家轨迹不同,教师策略具有在线适应和纠错能力。

非马尔可夫性与历史信息

传统的行为克隆通常假设环境满足马尔可夫性(Markov Property),即当前动作仅依赖于当前观测状态 $o_t$。然而,在现实世界中,我们通常只能获得 部分观测(Partial Observation) $o_t$,它并不包含环境的完整状态 $s_t$。

例如,一个经验丰富的司机在超车时,其决策可能依赖于几秒前看到的后视镜信息(即历史观测),即使当前观测 $o_t$ 中并没有显示那辆车。在这种情况下,仅根据当前观测 $o_t$ 学习动作 $a_t$ 的策略 $\pi(a_t|o_t)$ 会遇到困难:对于相同的观测 $o_t$,由于历史信息的不同,专家可能采取不同的动作(例如有时超车,有时不超车)。模型试图拟合这种一对多的映射关系,可能会学到一个无效的 “平均” 行为。

一个自然的解决方案是 引入历史信息,即将过去的观测序列 $(o_{t-k}, ..., o_t)$ 作为策略的输入,学习 $\pi(a_t | o_{t-k}, ..., o_t)$。这通常可以通过循环神经网络(RNN)或 Transformer 等序列模型实现。

然而,引入历史信息也带来了新的问题:

  1. 过拟合(Overfitting):输入维度大大增加,模型更容易在训练数据上过拟合,学到一些 spurious correlations(虚假关联),导致泛化能力下降。

  2. 因果混淆(Causal Confusion):模型在学习时,可能错误地将相关性当成了因果关系。

    例如,在自动驾驶数据中,每次踩刹车(action)都伴随着前方出现行人(cause)和刹车灯亮起(effect/correlation)。模型如果只学习到了 “观测到刹车灯亮起” 与 “踩刹车” 之间的关联,而忽略了 “看到行人” 这个真正的原因,就会做出错误的决策。它可能会认为只要刹车灯没亮,就不需要刹车,即使前方有行人。引入历史信息会使得输入维度更高,潜在的虚假关联更多,从而加剧因果混淆的风险。

多峰行为

和之前 DexGrapsNet 提到的一样,专家在面对同一个状态时,可能会有多种同样合理的行为选择。例如,在避障时,专家可能有时选择从左边绕过障碍物,有时选择从右边绕过。这种行为被称为 多峰行为

multi_task_learning

如果使用标准的行为克隆(例如,一个简单的多层感知机 MLP 直接回归动作),模型会试图拟合所有这些不同的专家动作。

  • 对于离散动作,这可能导致模型在不同动作间犹豫不决

  • 对于连续动作(如方向盘角度),模型可能会输出所有专家动作的平均值。

在上面的避障例子中,如果专家演示中左右绕行的概率各半,模型的平均输出可能是 “直行”,直接撞上障碍物。

多峰行为解决方案:对动作分布进行建模

为了解决多峰行为问题,我们需要使用更强大的模型来显式地建模动作的 分布 $\pi(a|s)$,而不是仅仅预测一个单一的确定性动作。

高斯混合模型(Gaussian Mixture Models, GMM)

假设动作分布可以由多个高斯分布的加权和来表示。策略网络输出每个高斯分量的均值(mean)、方差(variance)以及它们的权重(weight)。

$$ p(a|s) = \sum_{k=1}^K w_k(s) \mathcal{N}(a | \mu_k(s), \Sigma_k(s)) $$

其中 $K$ 是预先设定的模式(mode)数量。这种方法的优点是简单直观,但难点在于如何预先确定合适的 $K$ 值。

基于隐变量的模型(Latent Variable Models)

例如变分自编码器(Variational Autoencoder, VAE)。

将动作的生成过程建模为一个包含随机隐变量 $z$ 的条件生成模型 $p(a|s, z)$。通过从隐空间 $z$ 中采样,可以生成多样的动作。

例如,ALOHA 工作就使用了条件 VAE(CVAE)来建模动作分布,其策略 $\pi(a|s)$ 通过先从一个条件先验 $p(z|s)$ 中采样隐变量 $z$,再通过解码器 $p(a|s, z)$ 生成动作。训练时通过最大化证据下界(ELBO)来学习。

$$ \log p(a|s) \ge \mathbb{E}{q(z|s,a)}[\log p(a|s,z)] - D{KL}(q(z|s,a) || p(z|s)) $$

扩散模型(Diffusion Models)

扩散模型可以用来建模复杂的动作分布。

其核心思想是通过一个逐步去噪的过程从纯噪声中生成目标数据(这里是动作)。Diffusion Policy 这项工作就是将扩散模型应用于模仿学习。给定状态 $s$,模型学习一个去噪网络,该网络可以迭代地将一个随机噪声向量转化为符合专家行为分布的动作 $a$。

generate_model

diffusion

自回归建模(Autoregressive Modeling)

对于高维度的动作空间(例如,机械臂的多个关节角度),可以将动作 $a = (a_1, a_2, ..., a_d)$ 的联合分布分解为一系列条件概率的乘积:

$$ p(a|s) = p(a_1|s) p(a_2|s, a_1) \cdots p(a_d|s, a_1, ..., a_{d-1}) $$

然后,对每一维的条件概率 $p(a_i | s, a_1, ..., a_{i-1})$ 进行建模。

一个常用的技巧是先将每一维的连续动作 $a_i$ 进行 离散化(Discretization),将其值域划分为若干个区间(bins)。然后,将建模问题转化为预测在给定条件(状态 $s$ 和之前的动作维度 $a_1, ..., a_{i-1}$)下,当前动作维度 $a_i$ 属于哪个离散区间的概率分布。这变成了一个分类问题,可以用神经网络输出每个区间的概率。通过这种自回归和离散化的方式,可以将复杂的高维连续动作分布建模问题,转化为一系列相对简单的、一维离散概率分布的建模问题。在生成动作时,按顺序依次对每一维进行采样。

多任务学习(Multi-task Learning)

在许多实际场景中,我们收集到的专家数据可能包含执行不同任务(或同一任务的不同目标)的轨迹。例如,导航数据可能包含去往不同目的地的轨迹。

与其为每个任务单独训练一个策略(这会减少每个任务可用的数据量),不如采用 多任务学习 的思路。我们可以训练一个 目标条件化(Goal-conditioned) 的策略 $\pi(a|s, g)$,该策略不仅依赖当前状态 $s$,还依赖于当前要完成的目标 $g$。

这样做的好处是:

  1. 数据效率:所有任务的数据可以一起用来训练一个共享的模型,增加了有效训练数据量。
  2. 知识共享:不同任务之间可能存在共享的子结构或技能(例如,从北大无论开车去哪里,都得先开出北大东门)。多任务学习使得模型可以学习这些共享的知识,并互相促进,可能比单任务学习效果更好。

然而,多任务学习也引入了 目标空间的分布偏移:除了状态空间 $s$ 可能存在分布偏移外,目标空间 $g$ 也可能存在分布偏移。如果在测试时遇到一个训练时从未见过的目标 $g$,策略的泛化能力就面临考验。

模仿学习的局限性

尽管模仿学习(尤其是结合了 DAgger 和先进模型结构后)非常强大,甚至催生了许多成功的应用(如一些基于大模型的机器人控制),但它仍然有其局限性:

  1. 依赖专家数据:需要大量高质量的专家演示数据,获取成本可能很高。
  2. 无法超越专家:策略的性能上限受限于专家的水平。
  3. 不适用于高度动态或不稳定的任务:对于那些需要精确反馈和快速调整的任务(例如,让机器人用指尖转笔),微小的误差就可能导致失败。在这种情况下,仅仅模仿轨迹可能不足以学习到鲁棒的策略,因为系统对状态扰动非常敏感,而专家数据可能无法覆盖所有可能的微小扰动及其纠正措施。

强化学习

懒得详细写了,当年学过强化学习课程已经被狠狠摧残过一遍了。

推荐参照 动手学强化学习 自学。

马尔可夫决策过程(Markov Decision Process,MDP)

$$ \mathcal{M} = {S, \mathcal{A}, \mathcal{T}, r} $$

其中:

  • $\mathcal{A}$:动作空间 (Action Space),智能体可以采取的动作。
  • $\mathcal{T}$:状态转移算子 (Transition Operator),现在依赖于状态和动作,$p(s_{t+1}|s_t, a_t)$。
  • $r$:奖励函数 (Reward Function),$r:S \times \mathcal{A} \to \mathbb{R}$,表示在状态 $s_t$ 执行动作 $a_t$ 后获得的即时奖励 $r(s_t, a_t)$。

部分可观测马尔可夫决策过程(POMDP)

部分可观测马尔可夫决策过程(Partially Observable Markov Decision Process, POMDP)是 MDP 的扩展,其中智能体只能观测到部分状态。

$$ \mathcal{M} = {S, \mathcal{A}, \mathcal{O}, \mathcal{T}, \mathcal{E}, r} $$

其中:

  • $\mathcal{O}$:观测空间 (Observation Space),智能体可以观测到的状态
  • $\mathcal{E}$:观测概率 (Observation Probability),$p(o_t|s_t, a_t)$,描述在真实状态 $s_t$ 下,观测到 $o_t$ 的概率 $p(o_t|s_t)$
  • $\mathcal{T}$:状态转移算子 (Transition Operator),$p(s_{t+1}|s_t, a_t)$

此时,智能体 无法直接知道 当前的真实状态 $s_t$,只能得到一个与 $s_t$ 相关的观测 $o_t$。

强化学习的目标

强化学习:学习一个策略(policy) $\pi_\theta(a|s)$ (由参数 $\theta$ 决定),使得在一个轨迹(trajectory) $\tau =(s_1, a_1, s_2, a_2, ...)$ 上的累积奖励期望最大化。

$$ \begin{aligned} \theta^* &= \arg\max_\theta \mathbb{E}{\tau \sim p\theta(\tau)} \left[ \sum_t r(s_t, a_t) \right] \ &= \arg\max_\theta \mathbb{E}{(s_t, a_t) \sim p\theta(s_t, a_t)} \left[ r(s_t, a_t) \right] \end{aligned} $$

其中,$p_\theta(\tau) = p(s_1) \prod_{t=1}^T \pi_\theta(a_t|s_t) p(s_{t+1}|s_t, a_t)$ 是轨迹 $\tau$ 出现的概率。

注意这个式子暗含了马尔可夫性,因为状态转移概率 $p(s_{t+1}|s_t, a_t)$ 只依赖于 $s_t$ 和 $a_t$)。

有限时间界(Finite Horizon):最大化固定步数 $T$ (有限时间)内的总奖励期望。

$$ \theta^* = \arg\max_\theta \sum_{t=1}^T \mathbb{E}{(s_t, a_t) \sim p\theta(s_t, a_t)} [r(s_t, a_t)] $$

其中 $p_\theta(s_t, a_t)$ 是在 $t$ 时刻访问状态 - 动作对 $(s_t, a_t)$ 的概率(边际分布)。

RL 优化的是期望奖励:即使奖励函数本身不平滑,期望奖励 $\mathbb{E}{\pi\theta}[r(x)]$ 通常是关于策略参数 $\theta$ 平滑的,这使得基于梯度的优化方法成为可能。

💾

  •  

视觉与抓取 III

2025年4月4日 00:00

抓取

Form Closure 与 Force Closure

  • Form Closure(形闭合):这是一种纯粹基于几何的定义。指的是接触点(contact points)形成了一个 “笼子”,将物体完全包住。在不移动接触点的情况下,物体从几何上无法从这个 “笼子” 中逃逸。可以认为这是一种最理想、最稳固的包裹式抓取接触状态。其不依赖于摩擦力
  • Force Closure(力闭合):这个概念考虑了接触点的力和摩擦力。它指的是,虽然接触点可能没有形成几何上的 “笼子”,但通过在这些接触点上施加适当的力(利用摩擦力),可以抵抗施加在物体上的任意方向的力(force)和力矩(torque)。换句话说,只要夹爪(或手指)能提供足够大的力,理论上就能抵抗任何外来的扰动,或者能让物体产生任意方向的加速度和角加速度。其依赖于摩擦力

它们之间存在一个重要的关系:

$$ \text{Form Closure} \subset \text{Force Closure} \subset \text{Successful Grasp} $$

也即,严苛程度上:

$$ \text{Successful Grasp} \leq \text{Force Closure} \leq \text{Form Closure} $$

这意味着:

  • 如果一个抓取是 Form Closure,那么它一定也是 Force Closure。
  • 如果一个抓取是 Force Closure,那么它在理想情况下(夹爪力量足够)一定能成功抓起物体。
  • 但是反过来不一定成立。
    • 一个成功的抓取不一定是 Force Closure,比如轻轻托起一个物体,它只抵抗了垂直方向的力,如果施加一个水平方向的力,它就会滑动
    • 一个 Force Closure 也不一定是 Form Closure,比如用两个手指平行夹住一个方块的两侧,这不是 Form Closure,因为物体可以上下滑动。但如果考虑摩擦力,只要能施加足够的夹紧力,它可能是一个 Force Closure,能够抵抗各个方向的外力。

摩擦锥(Friction Cone)

为了理解 Force Closure,我们需要引入摩擦锥的概念。

考虑一个简单的物理场景:一个滑块放在水平面上,两者之间的静摩擦系数为 $\mu$。

显然,如果我们对滑块施加一个法向力(正压力) $N$,就能利用摩擦力将之固定在平面上。

现在考虑如下情形:如果施加一个与法线方向成 $\theta$ 角的力 $F$ 作用在接触点上。

friction_cone

这个力 $F$ 可以分解为法向分量 $F_{\perp} = F \cos \theta$ 和切向分量 $F_{\parallel} = F \sin \theta$。

为了使滑块不发生滑动,切向力必须小于等于最大静摩擦力,即:

$$ F_{\parallel} \le \mu F_{\perp} $$

代入分解后的力,得到:

$$ F \sin \theta \le \mu (F \cos \theta) $$

假设 $F \cos \theta > 0$,我们可以得到:

$$ \tan \theta \le \mu $$

令摩擦角 $\alpha = \arctan \mu$。这意味着,只要施加的力 $F$ 与接触面法线方向的夹角 $\theta$ 不超过 $\alpha$,无论这个力 $F$ 有多大(在理想情况下,假设物体和接触面都是刚体且不会被破坏),滑块都不会发生滑动。这种情况称为 自锁(self-locking)

在三维空间中,所有满足这个条件的力 $F$ 的方向构成了一个圆锥,称为 摩擦锥(Friction Cone)。这个锥体的轴线是接触点的法线方向,其半顶角就是摩擦角 $\alpha = \arctan \mu$。

任何作用在接触点且方向向量位于此摩擦锥内部(或边界上)的力,都不会导致该接触点发生滑动(不会有滑动摩擦,都是静摩擦)。

Force Closure 的数学定义

定义:一组摩擦接触实现 力闭合(force closure),如果其 力旋量锥(wrench cones)正向张成(positive span) 是整个 力旋量空间(wrench space)

思考一下,作用在一个刚体上的力的效果,它不仅会使物体 平移(力),还会使物体 旋转(力矩),而这就引入了六个自由度。

而为了同时描述作用在刚体上的力和力矩的 整体效果,我们将力和力矩组合成一个单一的向量,称为 力旋量(Wrench)

  • 在二维平面中,物体有 2 个平移自由度(在平面内)和 1 个旋转自由度(绕垂直于平面的轴)。因此,力旋量是一个 3 维向量:

    $$ \mathcal{F} = \begin{bmatrix} f_x \ f_y \ \tau_z \end{bmatrix} \in \mathbb{R}^3 $$

    前两个分量是平面内的力,最后一个分量是绕垂直轴的力矩。

  • 在三维空间中,物体有 3 个平移自由度和 3 个旋转自由度。因此,力旋量是一个 6 维向量: $$ \mathcal{F} = \begin{bmatrix} \mathbf{f} \ \boldsymbol{\tau} \end{bmatrix} = \begin{bmatrix} f_x \ f_y \ f_z \ \tau_x \ \tau_y \ \tau_z \end{bmatrix} \in \mathbb{R}^6 $$ 前三个分量是力,后三个分量是力矩。

现在,我们可以更精确地定义 Force Closure。一个抓取被称为 Force Closure,是指所有接触点的摩擦锥组合起来,能够产生抵抗任意施加于物体的 力旋量(Wrench) 的能力(和最初那个定义等价)。

我们将空间中的每个摩擦锥用一定数量(记为 $k$,课中选择为 $k = 6$)的力旋量组成的多面体锥来近似,从而摩擦锥可以表示为这 $k$ 个力旋量的线性组合。

接触点决定力,方向决定力矩

如此考虑所有的摩擦锥,我们定义 抓取矩阵 F(Grasp Matrix F)

$$ F = \begin{bmatrix} \mathcal{F}_1 & \cdots & \mathcal{F}_j \end{bmatrix} \in \mathbb{R}^{n \times j},\ n = 3 \text{ or } 6,\ j = k \times C $$

其中,$C$ 是接触点(摩擦锥)的数量,$k$ 是为了近似每个摩擦锥所使用的力旋量数量(也即用多少面体锥来近似摩擦锥)。

那么,力闭合的数学化表达(充要条件)就是:

$$ \text{rank}(F) = n \text{ (3 or 6)} \ Fk = 0 \text{ for some } k \in \mathbb{R}^j, k_i \ge \epsilon > 0 \text{ for all } i $$

第一个条件

$$ \text{rank}(F) = n \text{ (3 or 6)} $$

这个条件意味着 $F$ 的 $j$ 个列向量 $\mathcal{F}_1, \dots, \mathcal{F}_j$ 能够张成整个 $n$ 维的任务空间 $\mathbb{R}^n$。

物理意义:为了能够抵抗任意方向的外部扰动(力 / 力矩),我们施加的接触力 / 力旋量的组合必须能够产生任意方向的合力 / 合力旋量。如果 $\mathrm{rank}(F) < n$,那么 $F$ 的列向量只能张成 $\mathbb{R}^n$ 的一个子空间。这意味着存在某些方向的外部扰动,无论我们如何调整接触力的大小(即对 $\mathcal{F}_i$ 进行线性组合),都无法产生一个能够与之平衡的合力 / 合力旋量。

第二个条件

$$ Fk = 0 \text{ for some } k \in \mathbb{R}^j, k_i \ge \epsilon > 0 \text{ for all } i $$

这个条件意味着存在一个线性组合,使得合力 / 合力矩为零,并且这个组合中的 所有系数 $k_i$ 都必须是严格正的 (大于某个很小的正常数 $\epsilon$)。这意味着我们可以通过同时施加正向的力(或在摩擦锥内的力)来实现力的平衡。

为什么需要严格大于零($>\epsilon$)?这保证了原点不在凸锥的边界上。如果原点在边界上,可能存在某些方向的扰动,虽然理论上可以被平衡,但在实际中(考虑到力的限制、接触的不确定性等)可能无法稳定地抵抗。严格大于零提供了鲁棒性,使得抓取更加稳定,并且能够抵抗微小的扰动。

物理 / 几何意义:这个条件与凸包(Convex Hull)或锥组合(Conic Combination)的概念紧密相关。具体来说,它等价于零向量(原点)严格位于由接触力旋量向量 ${\mathcal{F}_1, \dots, \mathcal{F}_j}$ 生成的凸锥(Convex Cone)的内部。

合并条件

如果这两个条件都满足,那么对于施加在物体上的任何外部力旋量 $w_{ext}$(或者等价地,对于想要让物体产生的任何加速度 $a$ 和角加速度 $\alpha$,它们对应一个需要施加的力旋量 $w_{req}$),我们都能找到一组非负的系数 $k' = [k'_1, k'_2, \ldots, k'J]^\top$ ($k'i \ge 0$),使得 $Fk' = -w{ext}$ (或 $Fk' = w{req}$)。

因为所有 $k'_i \ge 0$,这意味着所需的接触力都在各自(近似的)摩擦锥内,因此不会发生滑动。

不过,这个理论推导假设接触点可以施加任意大的力。在实际机器人中,执行器(电机)的力 / 力矩是有限的。所以,即使一个抓取满足 Force Closure 条件,如果需要抵抗的外力过大或需要产生的加速度过大,超出了机器人的能力范围,抓取仍然会失败。

Force Closure 应用

Force Closure 的概念是合成大规模抓取标注数据集(Grasp Data Synthesis)的关键技术之一。

合成抓取数据集的两个经典方法:

  • 利用 Force Closure 大量生成抓取标签
  • 在 Simulater 中设置不同的重力方向($x,y,z,-x,-y,-z$),看会不会掉出来,来近似判断

GraspNet-1B 数据集

GraspNet-1B 数据集的生成流程大致如下:

  1. 获取物体模型:通过 3D 扫描收集一批物体的三维模型。
  2. 物体上抓取姿态采样:对每个物体模型,在其表面采样大量的候选抓取位姿(gripper pose),包括位置和朝向。例如,可以在物体表面均匀采样点(FPS 算法),然后将夹爪中心对准采样点,朝向可以基于表面法线并加入随机旋转。
  3. Force Closure 筛选:对每个采样得到的抓取姿态,给定一个摩擦系数 $\mu$(例如 $\mu=0.8$),使用前面所述的数学条件判断它是否满足 Force Closure。只保留满足条件的抓取姿态作为该物体的有效抓取标签。
  4. 场景生成与物体位姿标注:创建包含多个物体的三维场景(例如,将物体随机摆放在桌面上)。需要知道场景中每个物体的精确 6D 位姿。GraspNet 最初通过将真实物体摆放在桌面上,然后使用 RGB-D 传感器数据和物体模型进行匹配来标注位姿。(现在可以完全在仿真环境中生成场景和物体的精确位姿)。
  5. 抓取标签转换与碰撞检测:将步骤 3 中得到的物体中心坐标系下的有效抓取标签,利用步骤 4 中得到的物体位姿,转换到场景坐标系下。然后,检查在这个场景中,当夹爪移动到抓取位置(以及接近过程)时,是否会与场景中的其他物体发生碰撞。去除会发生碰撞的抓取标签。
  6. 多视角渲染:对于每个生成好的带有有效、无碰撞抓取标签的场景,从多个不同的虚拟相机视角进行渲染,生成 RGB 图像、深度图、点云等数据,从而在人工参与恒定的情况下扩大数据集。每一个数据点就构成了一个(输入数据,有效抓取标签)的配对。

关于摩擦系数的讨论

GraspNet 数据集实际上为不同的 $\mu$ 值(如从 0.8 到 0.1)都进行了筛选并存储了标签。

$\mu$ 值越低,对抓取的要求越高(更接近 Form Closure),这样的抓取在低摩擦表面上更可能成功。

训练时,有时会选择使用在较低 $\mu$(如 0.1)下仍然满足 Force Closure 的标签,认为这些是更高质量、更鲁棒的抓取,在真实世界中会拥有最好的泛化性,尽管这会大大减少标签数量(从 10 亿减少到几百万)。

这是一个标签数量和质量之间的权衡(Trade-off)。

意义

GraspNet-1B 的生成流程在当时是开创性的,但也有其局限性。例如,它依赖于扫描的真实物体和在真实桌面上进行的位姿标注,限制了物体种类和场景背景的多样性。

如今,随着高质量三维模型库(如 ObjectVerse XL 包含千万级模型)和逼真渲染技术的发展,完全可以在仿真环境中生成更大规模、更多样化的抓取数据集。物体模型、场景布局、纹理、光照等都可以程序化生成,无需依赖真实扫描和物理摆放,这大大提高了效率和数据的泛化潜力(还是王老师一直强调的观点,合成数据的潜力是巨大的 )。

尽管 GraspNet-1B 的物体和背景多样性有限,但它证明了使用基于三维几何信息(如点云)作为输入的模型,即使只在相对有限的数据上训练,也能学到在杂乱场景中进行抓取的有效策略。这说明三维几何本身提供了强大的先验信息。 然而,若要训练能直接从二维图像(RGB 或 RGB-D)输入的模型,并使其泛化到未见过的物体和环境,就需要更大规模、更多样性的合成数据。

抓取检测问题(Grasp Detection)

将抓取问题形式化(Formulate)为一个检测问题,是解决机器人抓取的一种常用方法。

目标:给定场景的某种表示(如点云、RGB-D 图像、体素网格),算法需要输出一系列候选的抓取姿态(Grasp Poses)。每个姿态通常包含位置(3 DoF)、朝向(3 DoF)和夹爪宽度(1 DoF),并附带一个质量评分(Quality Score)或成功概率。

输入模态

三维几何表示

举例:点云(Point Cloud)、体素网格(Voxel Grid)、截断符号距离场(TSDF - Truncated Signed Distance Function)。

  • 点云:最直接的表示方式,每个点包含位置和法线信息。
  • 体素网格:体素就是三维空间中的像素(小方格),通过将空间均匀划分,就得到体素网格。
  • TSDF:一种常见的体素网格表示。每个体素存储一个值,表示该体素中心到最近物体表面的有符号距离,并且这个距离值通常会被截断在一个范围内(例如 -10cm 到 +10cm)。正值表示在表面外,负值表示在表面内,0 表示在表面上。

由于抓取的物理稳定性主要取决于物体的局部几何形状(决定了接触点、法线、曲率等),而不是颜色或纹理,所以直接使用几何信息作为输入被认为更直接、更有效,尤其是在 GraspNet-1B 这类几何信息丰富但视觉外观多样性有限的数据集上(意思就是 3D 比 2D 信息更好,不需要用 RGB 反推几何信息,而是直接就是和任务密切相关的几何信息)。

这里老师还提到了一个 Partical / Complete 的说法挺有意思的,就是说你想建模完整的三维场景,那就需要多视角的数据,否则单视角会因为重叠而导致信息缺失。

二维图像表示

举例:RGB 图像、深度图像(Depth Image)。

2D 信息往往隐式包含几何信息。

基于 TSDF 的抓取(VGN)

VGN

VGN 直接在三维体素空间中对抓取位姿进行预测。

输入:一个表示了场景几何的 3D 体素网格,例如一个 40x40x40 的 TSDF 网格。

网络结构:通常采用类似 U-Net 的 3D 全卷积网络结构。

输出:总体预测三个体素网格,即对于输出网格中的每一个体素,网络预测:

  1. 抓取质量(Grasp Quality/Score):一个标量值,表示以该体素为中心的抓取成功的概率或质量。

  2. 抓取朝向(Grasp Orientation):描述夹爪应该如何旋转。通常采用四元数格式。

  3. 抓取宽度(Grasp Width):一个标量值,表示执行抓取时夹爪需要张开的宽度

    预测宽度的主要目的是防止夹爪过宽向外碰撞,而不是为了确定要多宽才能夹,实际操作都是直接夹到不能继续为止(工程 Trick)。

抓取任务评估

常用任务:清理桌面(Table Clearing)或箱中取物(Bin Picking)。这类任务的目标是将一个杂乱堆叠的物体集合逐一抓取并移除。

评估指标:

  • 抓取成功率(Success Rate):成功抓取次数 / 总尝试抓取次数。
  • 清理率(Percentage Cleard):成功移除的物体数量 / 场景中总物体数量。
  • 规划时间(Planning Time):接收输入与返回抓取之间的时间间隔

特点:

  • 非特定对象(Object Agnostic):算法通常不区分物体身份,哪个物体看起来最好抓(预测得分最高)就先抓哪个。
  • 非任务导向(Non-Task-Oriented):不关心抓取物体后的具体用途(即无语义信息,不关心是递给别人、是用来倒水、还是装配),只关心能否稳定地把物体 “提起来”。
  • 过程简化:评估时,抓取后的放置阶段可能被简化,例如直接移动到一个固定区域放下,甚至允许在移动过程中发生碰撞,只要物体被成功从初始位置拿起就算成功。

后处理

  • 通过 高斯平滑 提升预测的鲁棒性和区域一致性。

  • 通过 距离掩膜 保证抓取的物理和运动学可行性,如果 TSDF 值高过阈值,那就认为距离表面太深了手指不可达,将其 Mask 掉。

  • 通过 NMS 以抓取质量分数指标去除冗余预测,得到精简且有代表性的抓取候选集。

    但这里老师也说了,仅仅这样不够好,因为光看 Grasp Quality 的话,没考虑 Orientation / Width,即使前面这个准了后面不准也没用,所以光靠前面抑制其实也不太好

损失函数

VGN 的损失函数通常是针对前文所述三个输出分别计算,然后加权求和。

  • 质量损失:通常使用二元交叉熵损失(Binary Cross-Entropy Loss),因为这里是一个 0/1 二分类变量

    但老师后面又说了这个抓取质量的指标显然不是一个阶跃的,而是 具有一定平滑性 的,在一个点能抓起来,其附近也应当能抓起来,这就是为什么要进行高斯核平滑后处理

  • 方向损失:L2,但只对那些真实抓取标签为正的体素(就是真的能抓起来的地方)计算。

  • 宽度损失:同上

Sim2Real Gap

VGN 使用了大量合成数据进行训练,但能够在真实机器人上较好地工作(Sim2Real Transfer),关键原因在于其依赖的是几何表征,它不考虑颜色、纹理等视觉信息,只关注物体的形状和抓取器的几何匹配。

Sim2Real 的工作都会有 Gap,但是否 Work 要看 Gap 重不重要,影响大不大。

  • 合成数据中的深度信息是完美的,而真实传感器采集的深度图存在噪声
  • VGN 使用的 TSDF 表征,特别是当体素分辨率不高时(例如,40x40x40 的格子,每个格子边长可能达到厘米级),对几毫米级别的深度噪声不敏感。小的表面凹凸或噪声在体素化后会被平滑掉,不会显著改变 TSDF 的值。
  • 因此,即使训练于完美深度数据,模型在面对带噪声的真实深度时,性能下降有限。

对于夹爪式抓取,现实中的成功率往往不低于甚至高于仿真(Sim2Real 甚至可以是负的!)

  • 力闭合与变形:夹爪在闭合时通常会持续施力直至完全闭合或达到力 / 行程限制。这个过程可以轻微移动物体、压紧物体,甚至使软性物体发生形变,从而形成更稳固的接触面。这些物理效应在标准仿真中可能未被完全模拟,但在现实中是有利的。
  • 摩擦力问题:如果担心仿真中摩擦系数不准(如仿真中认为能抓住,现实中太滑抓不住),可以通过简单的工程手段解决,例如在夹爪指尖贴上高摩擦系数的材料(如橡胶垫)。
  • 仿真中的 Artifacts:有时仿真环境自身的问题(如碰撞检测不准、物理模拟不稳定)反而会导致仿真中抓不住,而现实中没问题。

机器人学是一个应用学科,最终目标是解决问题,真机表现是最终检验标准。

VGN 的局限性

  • 多视角依赖:对于相互遮挡严重的场景(cluttered scene),单视角可能无法看到被遮挡物体的完整几何形状,导致 TSDF 不准确,进而无法规划出好的抓取。VGN 需要较好的多视角观测来构建完整的场景 TSDF。
  • 精度限制:由于使用体素表示,其抓取位姿(特别是平移)的精度受限于体素大小。理论上的最高平移精度约为半个体素边长。这对于需要高精度操作的任务可能是个问题。
  • 计算 / 内存与精度的权衡:提高体素分辨率可以提升精度,但会导致内存和计算量急剧增加(通常是分辨率的三次方)。

基于点云的抓取(GSN / GraspNet)

点云是另一种重要的三维表示。

  • 轻量级 / 高效性:点云只表示物体表面,不像体素需要表示整个空间(包括空白区域)。对于同样场景,点云的点数通常远少于体素数(几万点 vs 几十万体素)。
  • 高分辨率 / 精度:理论上,点云中每个点的坐标可以是连续值,可以达到很高的空间分辨率,只要相机 / LiDAR 精度足够。

GraspNet 架构

GraspNet

GraspNet 将复杂的六自由度抓取姿态预测分解为一系列更简单的问题。

先大致说一下方法:

  1. 在表面选择接触点
  2. 在接触点为中心的一个半球面上均匀采样 256 个方向,得到一个旋转轴
  3. 绕旋转轴旋转夹爪
  4. 沿旋转轴深入夹爪

整个过程将原本抓取位姿的 6 DoF 自由度进行了多阶段划分

  1. 位移的 3 DoF
    1. 接触点的选择带来了 2 DoF
    2. 深入夹爪带来了 1 DoF
  2. 旋转的 3 DoF
    1. 旋转轴的轴向带来了 2 DoF
    2. 夹爪绕旋转轴旋转角度带来了 1 DoF

真实操作流程:

  1. 网络首先在输入点云的每个点上预测一个 “可抓取性” 分数(Graspness Score),表示该点作为抓取接触点的优劣程度。

  2. 保留分数高的点作为候选抓取中心点(从 N 个点降到 M 个点)。

  3. 对于每个候选点,预测最佳的抓取器接近方向(Approach Vector / View)。这通常是在以该点为中心的半球面上采样多个方向进行评估。

  4. 对于选定的 “点 - 方向” 对,需要确定绕着接近方向的旋转角(In-plane Rotation Angle)以及夹爪最终的张开宽度或深入深度(Depth)。

    Cylinder Grouping:在候选点附近,沿着接近方向定义一个圆柱体区域,聚合该区域内所有点的特征。这个聚合后的特征被用来预测最佳的旋转角和深度 / 宽度。

GraspNet 成功的本质

  1. 点云的优良性质:准确、轻量、效率高、精度高
  2. 架构:端到端网络,多阶段设计,每个阶段都有监督信号,稳定
  3. 泛化性:局部性与平移等变性
    1. 局部性:Cylinder Grouping 聚合,依赖候选点周围的局部几何信息判断,而不太关心场景中其他远处的物体。
    2. 平移等变性(Translation Equivariance):类似二维情形,模型学习到的几何模式识别能力不随物体在空间中的位置变化而失效。

GraspNet 的核心在于学习 局部几何特征(Local Geometric Features) 与抓取成功的关系。

例如,一对平行的小平面、一个合适的边缘或角落,这些局部形状无论出现在哪个物体上,都可能指示一个好的抓取点。当模型在训练数据(如 GraspNet-1B 的数百个物体)中见识了足够多样的局部几何模式后,就能泛化到包含相似局部几何的新物体上,即使整体形状从未见过。

这个局部泛化是非常本质的,因为它对某一位置是否适合抓进行了深入学习。

抓取的条件生成模型

无论是 VGN 还是 GraspNet,它们本质上是 检测(Detection) 方法。它们从场景中预测(检测)出一系列离散的、得分较高的抓取候选。最后通常还需要进行非极大值抑制(NMS)来去除冗余的候选。然而,理论上一个物体可能有无限多种抓取方式,检测式方法只能给出有限的几个解。

随着生成模型(如 GANs、VAEs、Diffusion Models)的发展,研究者开始探索直接生成抓取姿态的方法。目标是学习抓取姿态的 分布(Distribution),然后从中采样。

动机:对于具有高自由度(如 20+ DOF)的灵巧手(Dexterous Hand),抓取姿态空间巨大,传统的采样 + 评估或检测方法变得非常困难。生成模型提供了一种直接建模和采样高维复杂分布的途径。

不过,训练强大的生成模型通常需要极大规模的数据集(DexGraphNet 使用了一个包含 10 亿级别抓取样本的数据集)。生成如此规模的灵巧手抓取数据本身就是一个挑战,需要专门设计的抓取规划与优化管线。

该工作采用 条件扩散模型

  1. 首先,类似 GraspNet,在点云上识别出潜在的、适合抓取的接触点
  2. 在选定的接触点周围(用一个球形区域) 提取局部几何特征 $F$。这个特征 $F$ 编码了该点附近的形状信息
  3. 条件扩散,逐步去噪,学习出条件概率分布

如果你对扩散模型 / 条件扩散模型 / DDIM 的推导感兴趣,笔者推荐如下内容:

💾

  •  

VLA Frontier

2025年3月27日 07:39

AIGC Declaration

本文使用了 AIGC 来提高效率,其中可能存在谬误,我已尽力检查并校对,但仍不保证完全准确,欢迎指正。

本文依赖于我编写的 arXiv Tex 源码获取 Pipeline,这里是 Repo,欢迎使用!

HybridVLA

Paper

hybirdvla

Insight

  1. 传统自回归(AR,RT-2/OpenVLA)方法为了将动作作为 token 用 LLM 去预测,将动作离散化,破坏了动作连续性
  2. 扩散方法(Diffusion,CogACT/DiVLA)的扩散头独立于 LLM,无法利用语言模型的推理能力
  3. 设计一种办法协同 AR 和 Diffusion,从而兼顾两者的优点,同时充分利用 LLM

Method

Arch

Backbone:

  1. Vison Encoder:DINOv2(语义特征)+ SigLIP(细粒度特征)
  2. Prompt Encoder:LLAMA-2 (7B) / Phi-2 (2.7B)

整体 Token 序列结构:

$$ \text{Input Tokens} = \underbrace{[V_1,...,V_N]}{\text{视觉}} \oplus \underbrace{[L_1,...,L_M]}{\text{语言}} \oplus \underbrace{[R]}_{\text{机器人状态}} \oplus \underbrace{[\text{}, a^{i}t, i, \text{}]}{\text{扩散部分}} \oplus \underbrace{[A^{ar}_1,...,A^{ar}K]}{\text{AR 动作}} $$

  1. 编码后(V,L,R),插入一个特殊的扩散开始 Token $\text{}$ 与掩码 $\text{}$ $$ \text{Input Tokens} = \underbrace{[V_1,...,V_N]}{\text{视觉}} \oplus \underbrace{[L_1,...,L_M]}{\text{语言}} \oplus \underbrace{[R]}_{\text{机器人状态}} \oplus \text{} \oplus \text{} $$

  2. 然后进行扩散 Token 预测,使用得到的 Token 进行去噪,得到扩散动作 $a^d$

    $$ a^d = a^0 = [\Delta x, \Delta y, \Delta z, \text{Roll}, \text{Pitch}, \text{Yaw}, \text{Gripper(0/1)}] $$

  3. 对得到的扩散动作 $a^d$,重新使用 MLP 映射回 LLM,得到 $e_{a^d}$,插入特殊的扩散结束 Token $\text{}$,重构得到序列

    $$ [V][L][R][\text{}][e_{a^d}][\text{}][\text{}] $$

  4. 基于新序列预测 AR Token,再经过 Detokenizer,得到动作 $a^{ar}$(动作离散到 256 个动作区间,概率值)

  5. 计算 AR 动作置信度 $c^{ar}$

    $$ c^{ar} = \frac{1}{7}\sum_{k=1}^7 \max(p(A_k)) $$

  6. 根据置信度,判断是要融合 AR 动作与扩散动作还是直接使用扩散动作 $$ a_{final} = \begin{cases} 0.5a^d + 0.5a^{ar}, & \text{if } c^{ar} > 0.96 \ a^d, & \text{otherwise} \end{cases} $$

直观理解

  1. 扩散模式:自动驾驶(精确控制油门 / 刹车)
  2. AR 模式:语音导航("前方路口左转")
  3. 当导航指令清晰时(高置信度),自动驾驶会参考语音提示;当导航模糊时,完全依赖自动驾驶

现在,HybridVLA 既保持了语言模型的强推理能力,又获得了物理级的动作连续性,突破了传统 VLA 模型的性能瓶颈。

Loss function

$$ \mathcal{L}{dif}=E{a,i,c}||\epsilon-\epsilon_\pi(a_t^i,i,c)||^2 \ \mathcal{L}{hybrid}=\mathcal{L}{dif}+\mathcal{L}_{ce} $$

Trick

  • KV 缓存加速
  • 降低 Diffusion 去噪步数以加速生成

Question

为什么 AR 不加 diffusion,难道没语义了吗

ManipLLM

Paper

manipllm

Why

  • 基于有限数据集学习的方法见过的物品类别是有限的,难以泛化到现实世界
  • 过往的模型无法解释自身的结果(可解释性差),是个黑箱

Insight

  1. 通过 类别 → 区域 → 位姿 的渐进式训练将 MLLM(多模态大语言模型,Multimodal Large Languege Model)基于互联网级别数据所习得的常识和推理能力与之前看似黑箱的机器人操作去逐渐对齐,类似 COT 思维链完成渐进式思考,从而得到由粗到细的高可解释性动作预测
  2. 直接让 MLLM 去对图片进行预测哪里可以动可能效果是不 OK 的,但根据 Affordance Map 生成若干个点来让 MLLM 进行选择(选择题比填空题好做)是 OK 的。

Method

Arch

Backbone:

  • 视觉编码器:CLIP 的 ViT
  • 文本编码器:LLaMa 的 Tokenizer
  • 多模态对齐:通过适配器(Adapter)将视觉特征与 LLaMa 的文本空间对齐,仅微调适配器参数,保留 MLLM 原有知识。

Loss Function

$\mathcal{L}_A$ 可供性损失

目标:教会模型识别物体表面可操作区域

训练方式:

  1. 首先根据可供性图 $\mathcal{A}$ 来在图片中随机选择一系列点,包括 $n$ 个正样本($\mathcal{A} \geq 0.8$)、 $n$ 个负样本($\mathcal{A} \geq 0.8$),分别标记为 1、0
  2. 将点的位置送入 MLLM,进行提问:“确定在以下每个点上操作是否可以有效地操纵图像中的对象?” + ${x_i, y_i}^{2n}_{i=1}$
  3. 获得模型输出词元概率序列 ${p_i}^n_{i=1}$,注意这里不是 0/1,而是 LLM 输出此处为 True 这个词元的概率
  4. 计算交叉熵损失: $$ \mathcal{L}A = -\frac{1}{2n} \sum{i=1}^{2n} \left[ y_i \log p_i + (1 - y_i) \log (1 - p_i) \right] $$
$\mathcal{L}_M$ 语言建模损失

目标:通过 “填空” 训练模型预测被遮挡的位姿参数

训练方式 MLM(Mask Language Modeling,完形填空) :

  1. 随机遮挡坐标或方向分量,如将 “接触点是 $(80,120)$” 改为 “接触点是 $(\text{[MASK]},120)$”
  2. 每个被遮挡值离散化为 100 个区间
  3. 模型预测被遮挡位置的类别概率分布 $q_j$,计算交叉熵(真实标签以 one-hot 编码,$c_j$ 为真实类别编号): $$ \mathcal{L}M = -\sum{j \in \text{masked}} \log q_j[c_j] $$
$\mathcal{L}_F$ 位姿预测损失

目标:直接训练模型预测完整位姿参数,包括:

  • 接触点坐标 $(x, y)$
  • 夹爪上方向 $(x_u, y_u, z_u)$
  • 夹爪前方向 $(x_f, y_f, z_f)$

注:三维空间坐标由深度图投影得到。

训练方式:类似 $\mathcal{L}_M$,用 MLM 方式来计算损失

总损失

$$ \mathcal{L} = \mathcal{L}_A + \mathcal{L}_M + \mathcal{L}_F $$

注意这里,$\mathcal{L}_A$ 提供的区域先验可以帮助 $\mathcal{L}_M$ 和 $\mathcal{L}_F$ 更准确定位接触点。

  1. $\mathcal{L}_A$ 先教会模型 “哪里能操作”
  2. $\mathcal{L}_M$ 再训练 “如何补全参数”
  3. $\mathcal{L}_F$ 最终实现 “端到端预测”

主动阻抗适应策略

问题:方向预测可能存在误差

解决办法:在初始方向附近随机添加多个扰动方向,随后挨个试,每个施加一个固定的阻抗力,测量位移,选择最大的位移方向。

测试时适应(TTA)

问题:Sim-to-Real 差异(如光照、纹理变化)导致位姿预测偏移。

策略:在线更新视觉适配器(Visual-Adapter,连接 CLIP 视觉编码器和 LLaMa 语言模型,参数很少,就是一个轻量 MLP)参数

  1. 输入当前测试样本的位姿预测结果 $(x,y)$
  2. 根据实际操作成败生成二元标签(成功 → “yes”,失败 → “no”)
  3. 通过 $\mathcal{L}_A$ 微调视觉适配器,适应真实场景的视觉特征。

π0

pi0

Why

  1. 现有数据集太少,无法习得通用能力
  2. 基于 AR 的动作生成方法难以实现高频控制(但现有的基于扩散的模型已经改进了一些),流匹配是扩散的一种变体,适合生成高频、复杂、精细的动作块

Insight

  1. VLM + Flow Matching = new VLA
  2. 不能只在高质量数据集上训练,否则鲁棒性(容错性)不强,无法在真实世界中使用,解决方案是先在低质量、大量的混合机器人数据上学习,然后再在高质量数据集上进行微调,精进技能

Method

基本就是引入了流匹配来替换扩散模型,这是一种相较于扩散更直观的生成模型,关于流匹配的推导、代码和直观讲解可以参见 Meta 的综述

flow_matching

flow_matching_with_cond

RoboFlamingo

Paper / 作者解读

roboflamingo

Insight

感觉没啥新的,可能是我看的顺序问题,先看了今年 / 去年的,潜移默化地感觉这个结构似乎已经是一个范式了。

Method

Arch

  1. ViT(预训练) + Resampler 下采样(通过自注意力机制实现)降低 Token 数量,得到视觉 Token

    $$ \hat{X}_t^v=\text{ViT}(I_t,G_t) \ \text{Resampler: }K_R=\hat{X}_t^vW_K^R, V_R=\hat{X}_t^vW_V^R, X_t^v=\text{softmax}(\frac{Q_RK_R^T}{\sqrt{d}})V_R $$

  2. LLM(预训练)得到文本 Token

    $$ X = X_t^1=\text{LLM}(L_t) $$

  3. 特征融合:堆叠 $L$ 层解码器,每层结构包括:

    1. 使用交叉注意力,以 Text Token 做 Query,Visual Token 做 Key / Value,进行残差连接
    2. 随后进行自注意力,依旧进行残差连接,从而完成视觉与语言特征的融合

    $$ \begin{aligned} &\hat{X}_t^l=\text{Tanh}(\alpha)\cdot\text{MLP}(A(X_t^lW_Q^C,X_t^vW_K^C,X_t^vW_V^C))+X_t^l,\ &X_t^{l+1}=\text{MLP}(A(\hat{X}_t^lW_Q^S,\hat{X}_t^lW_K^S,\hat{X}_t^lW_V^S))+\hat{X}_t^l \end{aligned} $$

  4. max pooling 后送入策略头,以一个循环模型(LSTM)进行时序建模,直接预测 7 DoF 动作 $$ \tilde{X}_t=\mathrm{MaxPooling}(X_t)\ h_t=\mathrm{LSTM}(\tilde{X}t,h{t\boldsymbol{-}1})\ a_t^{pose},a_t^{gripper}=\mathrm{MLP}(h_t) $$

Train

监督信号:专家示范动作

  • 位姿预测:MSE 损失
  • 夹爪状态:BCE 损失
  • 总损失: $$ \mathcal{L} = \sum_t |a_t^{pose} - \hat{a}_t^{pose}|^2 + \lambda \cdot \text{BCE}(a_t^{grip}, \hat{a}_t^{grip}) $$

微调策略

  • 仅训练:重采样器参数 + 交叉注意力层 + 策略头
  • 冻结:ViT 参数 + 语言模型参数
  • 结果:参数量 <1% 的微调,高效且防过拟合

RoboMamba

Paper

Mamba

mamba

Mamba Youtube 讲解 / CSDN

传统模型的问题:

  1. Transformer 自注意力机制的计算复杂度为 $O(L^2)$($L$ 为序列长度),资源需求量大

  2. RNN 等在反向传播的时候需要沿着时间维度逐步进行(Backpropagation through time),无法并行训练;且长程依赖关系容易造成梯度消失 / 爆炸,尽管 LSTM 等通过门控机制缓解,但并未完美解决。

    RNN 的本质是一个这样的函数:

    $$ h_{t+1} = f(h_t, x_{t+1}) $$

SSM

  1. 本质类似 RNN,但是在训练的时候无需像 LSTM 一样总要等到隐状态沿着时间维度完整前传,而是类似 Transformer,可以并行地处理所有 Token
    1. 隐状态之间没有非线性,而是具有了很好的线性性质,可以直接化为一个完整的矩阵乘法
    2. 没有时间依赖性(线性非时变系统),$A$ 和 $B$ 在整个前向推理过程中不变,从状态 1 转到状态 2,和从状态 2 转到状态 3 是一样的,换句话说聚合信息的方式是恒定的
  2. 推理时像无隐状态的线性 RNN,可以并行地推导所有步骤的输出,而无需像 Transformer 一样以自回归地形式一个 Token 一个 Token 地输出(因为 Transformer 在推理过程中的注意力矩阵是动态构建的)

以下为 S4 的数学推导,摘录整理自 这里,补全了最后一步的跳步。

状态空间模型将系统的状态、输入和输出关系表示为:

$$ \begin{aligned} \dot{x}(t) &= A(t)x(t) + B(t)u(t)\ y(t) &= C(t)x(t) + D(t)u(t) \end{aligned} $$

其中,$A,B,C,D$ 是系数矩阵,$x(t)$ 是状态向量,$u(t)$ 是输入向量,$y(t)$ 是输出向量。

假定系数矩阵不随时间变化,这可以简化为线性非时变系统:

$$ \begin{aligned} \dot{x}(t) &= Ax(t) + Bu(t)\ y(t) &= Cx(t) + Du(t) \end{aligned} \tag{1} $$

容易发现,核心其实是第一个式子,但若直接对状态方程积分:

$$ x(t) = x(0) + \int_0^t (Ax(\tau) + Bu(\tau))\mathrm{d}\tau $$

积分项包含 $x(\tau)$ 本身,但我们无法获取连续时间内所有 $x(\tau)$ 值,导致积分无法完成。

所以,我们将上式转换为离散形式:

$$ x(k+1) = x(k) + \sum_{i=0}^k (Ax(i) + Bu(i))\Delta t $$

但这仍需要改造原方程,消除 $\dot{x}(t)$ 表达式中的 $x(t)$ 从而可以积分。

构造辅助函数 $\alpha(t)x(t)$ 并求导:

$$ \frac{\mathrm{d}}{\mathrm{d}t}[\alpha(t)x(t)] = \alpha(t)\dot{x}(t) + x(t)\frac{\mathrm{d}\alpha(t)}{\mathrm{d}t} $$

代入状态方程 $(1)$:

$$ \frac{\mathrm{d}}{\mathrm{d}t}[\alpha(t)x(t)] = \alpha(t)(Ax(t) + Bu(t)) + x(t)\frac{\mathrm{d}\alpha(t)}{\mathrm{d}t} $$

合并 $x(t)$ 的相关系数:

$$ \frac{\mathrm{d}}{\mathrm{d}t}[\alpha(t)x(t)] = \left(A\alpha(t) + \frac{\mathrm{d}\alpha(t)}{\mathrm{d}t}\right)x(t) + B\alpha(t)u(t) \tag{2} $$

为消除导数中的 $x(t)$,令其系数为 $0$:

$$ A\alpha(t) + \frac{\mathrm{d}\alpha(t)}{\mathrm{d}t} = 0 $$

解得:

$$ \alpha(t) = e^{-At} $$

代入 $(2)$:

$$ \frac{\mathrm{d}}{\mathrm{d}t}[e^{-At}x(t)] = Be^{-At}u(t) $$

对此式积分:

$$ e^{-At}x(t) = x(0) + \int_0^t e^{-A\tau}Bu(\tau)\mathrm{d}\tau $$

整理得到:

$$ x(t) = e^{At}x(0) + \int_0^t e^{A(t-\tau)}Bu(\tau)\mathrm{d}\tau $$

在离散系统中:

  • 定义采样时刻 $t_k$ 和 $t_{k+1}$,采样间隔 $T = t_{k+1} - t_k$
  • 将连续时间积分区间分成离散子区间:

$$ x(t_{k+1}) = e^{A(t_{k+1}-t_k)}x(t_k) + \int_{t_k}^{t_{k+1}} e^{A(t_{k+1}-\tau)}Bu(\tau)\mathrm{d}\tau \tag{3} $$

采用零阶保持法,假设 $u(t)$ 在采样间隔内保持恒定:

$$ \int_{t_k}^{t_{k+1}} e^{A(t_{k+1}-\tau)}Bu(\tau)\mathrm{d}\tau = \int_{t_k}^{t_{k+1}} e^{A(t_{k+1}-\tau)}\mathrm{d}\tau \cdot Bu(t_k) $$

代入 $(3)$,并使用 $T = t_{k+1} - t_k$:

$$ x(t_{k+1}) = e^{AT}x(t_k) + \int_{t_k}^{t_{k+1}} e^{A(t_{k+1}-\tau)}\mathrm{d}\tau \cdot Bu(t_k) $$

引入变量替换 $\lambda = t_{k+1} - \tau$:

$$ x(t_{k+1}) = e^{AT}x(t_k) + Bu(t_k)\int_0^T e^{A\tau}\mathrm{d}\tau $$

原文这里略有跳步,只需要展开矩阵指数然后假设 $A$ 可逆从而合并系数再重新合并成矩阵指数即可:

$$ e^{A\tau} = I + A\tau + \frac{(A\tau)^2}{2!} + \frac{(A\tau)^3}{3!} + \dots $$

$$ \begin{aligned} \int_0^T e^{A\tau} \mathrm{d}\tau &= \int_0^T \left( I + A\tau + \frac{(A\tau)^2}{2!} + \frac{(A\tau)^3}{3!} + \dots \right) \mathrm{d}\tau \ &= \int_0^T I \mathrm{d}\tau + \int_0^T A\tau \mathrm{d}\tau + \int_0^T \frac{(A\tau)^2}{2!} \mathrm{d}\tau + \dots \ &= T \cdot I + \frac{A T^2}{2} + \frac{A^2 T^3}{3 \cdot 2!} + \frac{A^3 T^4}{4 \cdot 3!} + \dots \ &= \sum_{k=0}^{\infty} \frac{A^k T^{k+1}}{(k+1)!} \ &= \sum_{m=1}^{\infty} \frac{A^{m-1} T^m}{m!} \quad \text{换元:} m = k+1 \ &= A^{-1} \sum_{m=1}^{\infty} \frac{(A T)^m}{m!} \ &= A^{-1} (e^{A T} - I) \end{aligned} $$

最终离散时间状态方程:

$$ x(t_{k+1}) = e^{AT}x(t_k) + (e^{AT} - I)A^{-1}Bu(t_k) $$

容易想到这里还是会存在类似 RNN 的长程依赖问题,Mamba 最终其实相对于 S4 做了很多改进,包括 HiPPO(处理远程依赖性)等,这里就没详细去看了(逃)

Insight

robomamba

Training

robomamba_training

对齐预训练

数据:LLaVA 图像 - 文本对

目的:使用单一 MLP 对齐视觉特征编码与 Mamba 词嵌入

冻结 CLIP、Mamba,仅微调 Project MLP 投影层。

令对齐预训练数据集为 $\mathcal{D}a = {(I_k, T_k)}{k=1}^N$,其中:

  • $I_k \in \mathbb{R}^{W \times H \times 3}$:图像输入
  • $T_k = [t_1^{(k)}, t_2^{(k)}, ..., t_L^{(k)}]$:对应的文本描述(token 序列)

那么:

$$ p(y|I) = \text{Softmax}(\text{Mamba}([\text{Proj}(\text{Emb}(I)); \text{}])) \ \mathcal{L}a = -\sum{k=1}^N \sum_{t=1}^{L_k} \log p(t_t^{(k)} | t_{<t}^{(k)}, I_k) $$

指令协同训练

目的:学习长程规划、物理常识等技能

数据:$\mathcal{D}c = \mathcal{D}{gen} \cup \mathcal{D}_{robot}$ 为混合指令数据集

  • $\mathcal{D}_{gen}$:通用视觉指令数据(如 ShareGPT4V)
  • $\mathcal{D}_{robot}$:高级机器人指令数据(如 RoboVQA)

冻结 CLIP,微调 Project MLP 投影层、Mamba。

先在通用的上面训练,然后再在高级数据集上训练,损失函数为交叉熵。

这里不知道有没有采用渐进式混合 $\mathcal{L}c = \lambda \mathcal{L}{gen} + (1-\lambda)\mathcal{L}_{robot}$

这个阶段挺重要的,原文说跳过此处训练直接进行动作微调时,成功率从 82.3% 骤降至 47.1%。

动作微调

目的:训练动作策略头,获得操作能力

冻结 CLIP、Project MLP 投影层、Mamba,仅调整策略头。

$$ \begin{align} \mathcal{L}{pos} &= \frac 1N {\sum{i=1}^N |a_\mathrm{pos} - a^{gt}\mathrm{pos}|} \ \mathcal{L}{dir} &= \frac 1N {\sum_{i=1}^N \arccos\left (\frac{{\text{Trace}\Big(a^{gt}\mathrm{dir}}^\top a\mathrm{dir}\Big)-1}{2}\right )} \end{align} $$

注:两个旋转矩阵的乘积 $R^\top R_{gt}$ 表示相对旋转;对于旋转矩阵 $R$,其迹与旋转角度 $\theta$ 满足:

$$ \text{Trace}(R) = 1 + 2\cos\theta $$

从而通过迹可直接计算两个旋转矩阵之间的角度差异。

GR-1

Paper / Project Page

Generative Robot-1

gr1

Insight

  1. 数据瓶颈突破:传统视觉机器人操作受限于小规模机器人数据(高采集成本),而视频数据与机器人轨迹具有内在一致性(时间序列 + 多模态)
  2. 统一建模优势:GPT-style Transformer 可同时处理语言、图像、机器人状态,避免传统方法中多模块拼接的复杂性
  3. 预训练 - 微调协同:视频预测任务(预测未来帧)隐式学习物理规律,迁移到机器人动作推理时提升泛化能力

核心贡献:首次证明大规模视频生成预训练可迁移到机器人操作,统一 GPT 架构实现多模态 - 多任务端到端学习。

Method

Arch

Backbone:

  1. Vision Encoder:MAE 预训练的 ViT(图像 → patch tokens + CLS token)
  2. Language Encoder:冻结的 CLIP 文本编码器
  3. State Encoder:MLP 编码机器人末端位姿(6D)和夹爪状态(二进制)

Token 序列构造:

首先,所有模态的嵌入(图像、语言、状态)都通过线性变换映射到同一维度 $d$,然后将所有模态的 Token 拼接成一个序列。

视频生成预训练时:

$$ \text{Input Tokens} = \underbrace{[l]}{\text{语言}} \underbrace{[o{t-h}]}{\text{图像}} \underbrace{[\text{OBS}]}{\text{视频预测 cls}} \oplus \cdots \oplus [l][o_t][\text{OBS}] $$

使用机器人数据微调时:

$$ \text{Input Tokens} = \underbrace{[l]}{\text{语言}} \underbrace{[s{t-h}]}{\text{状态}} \underbrace{[o{t-h}]}{\text{图像}} \underbrace{[\text{OBS}]}{\text{视频预测 cls }} \underbrace{[\text{ACT}]}_{\text{动作预测 cls}} \oplus \cdots \oplus [l][s_t][o_t][\text{OBS}][\text{ACT}] $$

  1. 模态对齐:语言 Token $l$ 在每个时间步重复,防止被其他模态掩盖
  2. 因果注意力掩码:只能往前看,不能往后看
    • 预训练时掩码未来 $\text{[OBS]}$ Token
    • 微调时同时掩码 $\text{[OBS]}$ 和 $\text{[ACT]}$ Token
  3. 时间嵌入:每个时间步添加可学习的时间戳编码

训练流程

gr1_encoder_decoder

预训练阶段(视频生成)

输入:语言描述 + 历史帧序列

输出:未来帧预测(MSE 损失,和 MAE 重构损失一样,直接就是判断像素差)

$$ \mathcal{L}{\text{video}} = \frac{1}{H \times W} \sum{i=1}^H \sum_{j=1}^W \left( \hat{o}{t+\Delta t}(i,j) - o{t+\Delta t}(i,j) \right)^2 $$

  • $\hat{o}_{t+\Delta t}$:预测的未来帧
  • $o_{t+\Delta t}$:真实的未来帧
微调阶段(机器人操作)

输入:语言指令 + 历史状态 / 图像序列

输出:动作(连续位移 + 夹爪开合) + 未来帧预测

动作损失(Smooth L1):

$$ \mathcal{L}{\text{arm}} = \frac{1}{N} \sum{i=1}^N \begin{cases} 0.5 (a_{\text{arm}}^i - \hat{a}{\text{arm}}^i)^2, & \text{if } |a{\text{arm}}^i - \hat{a}{\text{arm}}^i| < 1 \ |a{\text{arm}}^i - \hat{a}_{\text{arm}}^i| - 0.5, & \text{otherwise} \end{cases} $$

  • $N$:批量大小(Batch Size)
  • $a_{\text{arm}}$:真实动作,$\hat{a}_{\text{arm}}$:预测动作,就是位移和旋转那六个自由度的数值

Smooth L1 Loss 是回归任务中常用的损失函数,结合了 L1 Loss 和 L2 Loss 的优点。其公式为:

$$ \text{SmoothL1}(x) = \begin{cases} 0.5x^2 & \text{当 } |x| < 1 \ |x| - 0.5 & \text{其他情况} \end{cases} $$

其中 $x = y_{\text{pred}} - y_{\text{true}}$ 表示预测值与真实值的差。

特点

  1. 在 $|x| < 1$ 时使用二次函数(类似 L2 Loss),梯度平缓,避免离群值梯度爆炸;
  2. 在 $|x| \geq 1$ 时使用线性函数(类似 L1 Loss),降低大误差时的梯度幅值;
  3. 在 $x=0$ 处可导,优化更稳定。

夹爪动作损失(Binary Cross-Entropy):

$$ \mathcal{L}{\text{gripper}} = -\frac{1}{N} \sum{i=1}^N \left[ y_i \log p_i + (1 - y_i) \log (1 - p_i) \right] $$

  • $y_i$:真实标签(0 或 1)
  • $p_i$:预测为张开状态的概率

总损失:

$$ \mathcal{L}{\text{finetune}} = \mathcal{L}{\text{arm}} + \mathcal{L}{\text{gripper}} + \mathcal{L}{\text{video}} $$

TinyVLA

Paper / Project Homepage

Insight

  1. 传统 VLA 模型依赖大型 VLM + AR,速度慢、推理延迟高
  2. 数据依赖问题

Method

  1. 使用小型 VLM Backbone
  2. 冻结预训练权重,仅微调部分参数(LoRA),保留多模态理解能力,减少数据依赖
  3. 使用扩散策略头来生成最终动作,以多模态主干输出的嵌入(图像 + 语言指令)作为扩散过程的控制条件

DiffusionVLA

Paper

没看懂他的 FiLM 注入模块是如何实现的。

推理标记通过 FiLM 层注入策略模型,FiLM 层对策略内部投影层的参数进行缩放和偏移。

film_vs_transformer

Reference

CogACT

Paper

Condition and Action

cogact

Insight

  1. VLM 直接将动作离散化为 Token 预测,忽略了动作的连续性和多模态性,导致成功率差、精度低
  2. 动作信号具有连续性、多模态性(同一任务有多个可行轨迹)、时序相关性,与语义 Token 有本质不同
  3. 模仿人脑功能划分,用 VLM 处理认知(理解任务),DiT 处理动作生成

Method

Arch

Backbone:

  1. Vision Encoder:DINOv2 + SigLIP
  2. LLM:LLaMA-2 7B
  3. Action:DiT

整体 Token 序列结构:

$$ \text{Input Tokens} = \underbrace{[V_1,...,V_{N_v}]}{\text{视觉}} \oplus \underbrace{[L_1,...,L{N_l}]}{\text{语言}} \oplus \underbrace{[C]}{\text{认知}} $$

使用因果注意力机制聚合信息后,得到认知特征 $f_t^c \in \mathbb{R}^{d_c}$。

$f_t^c, (a_t^i, a_{t+1}^i, ..., a_{t+N}^i)$ 作为动作模块的条件,进行条件扩散生成。

也即,训练网络学会从带噪声(人为加噪)的动作序列 $(a_t^i, a_{t+1}^i, ..., a_{t+N}^i)$ 中恢复出干净的动作序列 $(a_t, a_{t+1}, ..., a_{t+N})$。

其中:

  • 符号 $i$ 表示去噪步骤的索引,会通过位置编码加入到认知特征 $f_t^c$ 中
  • $t$ 表示时间步

Loss function

$$ \mathcal{L}_{\text{MSE}} = \mathbb{E}||\boldsymbol{\hat{\epsilon}}^i - \boldsymbol{\epsilon}||_2 $$

其中:

  • $\boldsymbol{\epsilon}$:扩散过程添加的高斯噪声
  • $\boldsymbol{\hat{\epsilon}}^i$:第 i 步去噪时预测的噪声

扩散模型通过预测噪声间接建模动作分布,避免直接回归的模态坍缩问题。

AAE (Adaptive Action Ensemble)

可以看到,我们每步根据观测信息最终会预测一个 Action Chunk,但推理的时候它们会彼此重叠,没有充分利用信息;而如果每个时间步都只用 Action Chunk 的最开始一部分,又会导致动作不平滑。

为此,作者提出了一种自适应动作聚合的方式,通过余弦相似度来为不同时间步预测的同一时刻的动作进行加权:

$$ \hat{\boldsymbol{a}}t = \sum{k=0}^{K} w^{\text{ada}}k \cdot \boldsymbol{a}{t}|\boldsymbol{o}_{t-k} $$

其中:

  • $\hat{\boldsymbol{a}}_t$:最终预测的动作
  • $w^{\text{ada}}_k$:加权系数
  • $\boldsymbol{a}{t}|\boldsymbol{o}{t-k}$:第 $t-k$ 步预测的第 $t$ 步动作
  • $\boldsymbol{o}_{t-k}$:第 $t-k$ 步的观测信息
  • $K$:采用最近几次的历史动作预测,基于训练集动作的标准偏差来确定

cogact_aae

加权系数的计算方式:

$$ w_k^{\text{ada}} = \exp(\alpha \cdot \langle \boldsymbol{a}_t|\boldsymbol{o}_t, \boldsymbol{a}t|\boldsymbol{o}{t-k} \rangle) $$

  • $\langle \cdot,\cdot \rangle$:余弦相似度(取值范围 $[-1,1]$)
  • $\alpha$:温度系数,超参数
  • $\boldsymbol{a}t|\boldsymbol{o}{t-k}$:基于历史观测 $\boldsymbol{o}_{t-k}$ 预测的当前时刻动作

实际使用时会进行 softmax 归一化:

$$ \hat{w}k = \frac{w_k^{\text{ada}}}{\sum{j=0}^K w_j^{\text{ada}}} $$

本质就是 相似度越高 → 权重越大,从而保留相同动作模式,并且实现平滑过渡。

PointVLA

Paper

pointvla

Insight

  • 现有 VLA 模型(如 OpenVLA、DexVLA)依赖 2D 图像输入,难以处理需要深度感知的任务;重新训练包含 3D 数据的 VLA 模型成本高昂,而丢弃已有的大规模 2D 数据集会造成资源浪费
  • 所以,选择将 3D 点云信息嵌入后注入动作专家模块,然而直接微调 VLM 主干会引发灾难性遗忘,不加选择的注入动作专家模块也会引发性能暴跌
  • 通过这种方式,作者实现了不破坏预训练 VLA,同时高效融合 3D 点云信息

Method

Arch

Backbone:

  • VLM:Qwen2-VL,2B
  • Action:ScaleDP,1B,Diffusion 变体

3D injector:在选定层执行 $h_{\text{new}} = h_{\text{2D}} + \text{MLP}(f_{\text{3D}})$,其中 $h_{\text{2D}}$ 为原动作专家选定的几个层的隐藏状态。

这里 $f_{\text{3D}}$ 有一个分层卷积设计,而且是从头开始训练的。作者发现,预训练的 3D 视觉编码器会阻碍性能,往往在新环境中难以成功学习机器人行为。

Skip-Block

由于要额外引入 3D 注入,所以作者探究了一下在动作专家模块中模块对性能的影响,从而选择影响较小的层去注入信息(这个思想类似于模型剪枝的时候的操作)。

作者发现,动作专家模块的前 11 层影响很大,后续层则可以进行替换或注入,这比较符合直接,前期对 2D 及语义特征的处理还相对低级,自然会比较重要,对性能影响大。

DexVLA

Paper

dexvla

Insight

  • 之前的 VLA 明显在 LLM 和 Action 部分大小失衡,过度扩展视觉语言模块(VLM 参数达 3B-7B),而动作专家部分(action expert)仍停留在百万参数级别,成为性能瓶颈

Method

Arch

Backbone:

  • VLM:Qwen2-VL,2B
  • Action:ScaleDP,1B,Diffusion 变体,具有多个策略头,可以适配多种不同的下游机型

Training

很怪的训练方法,分阶段训练不是没见过,但都是整体 pipeline 不变,只改变冻结部分的,本文的训练在不同阶段的 pipeline 都变了,前一阶段用的部分再后续阶段直接丢掉了。

  1. 阶段 1:仅用跨形态数据预训练动作专家,也就是扩散部分,学习低级运动技能(如抓取、移动)。语义部分 不是靠 VLM,而是暂时性靠另外一个 ViT/DistilBERT 编码,随后经过 FiLM+ResNet 来进行整合,送入扩散部分。
  2. 阶段 2:绑定 VLM 与动作专家,冻结 VLM 的视觉编码器,联合训练视觉到 Token 的投影层以及扩散专家,用特定形态数据对齐视觉 - 语言 - 动作映射。舍弃上一阶段的 FiLM+ResNet 不分。
  3. 阶段 3:全模型微调,微调时引入 高质量子步骤推理标注数据,使模型能自动分解长期任务(如 “叠衣服” 分解为展平、对齐袖子等)。

💾

  •  

视觉与抓取 II

2025年3月26日 07:26

迭代最近点算法(ICP)

动机

在机器人抓取任务中,物体的位姿估计精度直接影响抓取成功率。

以 YCB 数据集为例,当预测位姿的平移误差超过 2cm 时,抓取成功率会显著降低至 60% 以下。对于细长物体(如粉笔、剪刀),即使 2.5mm 的误差也可能导致抓取失败。

这种敏感性源于:

  1. 机械臂运动误差:沿特定方向的平移误差可能推翻物体
  2. 夹爪闭合策略:夹爪开合宽度需要与物体尺寸精确匹配
  3. 旋转容错性:旋转误差(如绕 Z 轴 30°)通常比平移误差更宽容

而对于 PoseCNN,仅 32% 的预测能达到 2cm 内的平移精度。这种误差水平难以满足实际抓取需求,因此需要后续优化。

posecnn_with_without_icp

算法原理与流程

ICP 用于优化初始位姿估计,通过迭代优化使源点云和目标点云对齐。

  • 源点云(Source/Moved Data): $P = {p_1, p_2, \dots, p_n}$,其中每个 $p_i \in \mathbb{R}^3$。点云可以表示为矩阵 $P \in \mathbb{R}^{3 \times n}$。
  • 目标点云(Target/True Data): $Q = {q_1, q_2, \dots, q_m}$,其中每个 $q_j \in \mathbb{R}^3$。点云可以表示为矩阵 $Q \in \mathbb{R}^{3 \times m}$。

注意:$n$ 和 $m$ 分别是源点云和目标点云中点的数量,它们可以不相等($n \neq m$)。

ICP 通过迭代优化寻找最佳的旋转矩阵 $\hat{R} \in \mathbb{SO}(3)$ 和平移向量 $\hat{T} \in \mathbb{R}^{3 \times 1}$,使得变换后的源点云 $P$ 与目标点云 $Q$ 尽可能对齐。

算法迭代步骤如下:

  1. 数据中心化(Make data centered)

    • 计算点云 $P, Q$ 的质心(均值):$\bar{P} = \frac{1}{n} \sum_{i=1}^n p_i, \bar{Q} = \frac{1}{m} \sum_{j=1}^m q_j$。
    • 将点云中心化:$\tilde{p}_i = p_i - \bar{P}, \tilde{q}_j = q_j - \bar{Q}$。得到中心化后的源点云矩阵 $\tilde{P} = [\tilde{p}_1, \dots, \tilde{p}_n] \in \mathbb{R}^{3 \times n}$ 和目标点云矩阵 $\tilde{Q} = [\tilde{q}_1, \dots, \tilde{q}_m] \in \mathbb{R}^{3 \times m}$。
    • 这一步的目的是先去除位移 $t$ 的影响
  2. 对应点匹配(Correspondence Search)

    • 对于当前源点云 $P$ 中的每一个点 $p_i$,在目标点云 $Q$ 中找到其最近邻点 $q_{j_i}$:

      $$ q_{j_i} = \operatorname{argmin}_{q_j \in \tilde{Q}} | \tilde{q}_j - \tilde{p}_i |^2_2 $$

    • 形成一个与 $P$ 点一一对应的目标点子集(correspondences)$P_{corr_pts} = { q_{j_1}, q_{j_2}, \dots, q_{j_n} }$

    • 得到对应目标点云矩阵 $\tilde{P}{corr} = [\tilde{q}{j_1}, \dots, \tilde{q}_{j_n}] \in \mathbb{R}^{3 \times n}$。

  3. 位姿求解(Pose Estimation using Orthogonal Procrustes)

    • 目标是找到最优旋转 $\hat{R}$,最小化中心化点云之间的距离:

      $$ \hat{R} = \operatorname{argmin}{R \in \mathbb{SO}(3)} |\tilde{P}{corr} - R\tilde{P}|_F^2 $$

    • 计算协方差矩阵 $K = \tilde{P}{corr} \tilde{P}^\top = \sum{i=1}^n \tilde{q}_{j_i} \tilde{p}_i^\top$,这是一个 $3 \times 3$ 矩阵。

    • 对 $K$ 进行 SVD 分解:$K = U D V^\top$。

    • 计算最优旋转矩阵 $\hat{R}$ (确保是旋转矩阵,处理可能的反射情况):

      $$ \hat{R} = U \begin{bmatrix} 1 & 0 & 0 \ 0 & 1 & 0 \ 0 & 0 & \det(UV^\top) \end{bmatrix} V^\top $$

    • 计算最优平移向量 $\hat{T}$: $$ \hat{T} = \bar{P}_{corr} - \hat{R} \bar{P} $$

    • 这里的详细推导可以参见前一章笔记的正交 Procrustes 问题

  4. 更新与迭代(Update P and Iterate)

    • 使用求得的 $\hat{R}, \hat{T}$ 更新 原始 源点云 $P$ 的位姿:

      $$ P_{new} = \hat{R} P + \hat{T} $$

      (这里 $P$ 是 $3 \times n$ 矩阵,$\hat{T}$ 是 $3 \times 1$ 向量,需要广播加到 $P$ 的每一列)

    • 将 $P_{new}$ 作为下一次迭代的输入源点云。

    • 重复步骤 2-4,直到满足收敛条件($\hat{R}, \hat{T}$ 变化足够小,或者达到最大迭代次数)。

ICP 收敛性

由于计算对应点匹配的时候 可能会导致非一一映射问题(好几个点离同一个点最近),此时必然无法找到一个完美的变换(不可能两个不同的点经过仿射变换到了同一个点)。

所以,ICP 并没有收敛保证,可能卡在局部最优(local minimum),但其对于 PoseCNN 的性能表现还是有很强的提升。

ICP 算法的问题

优点

  • 操作简便,无需进行点云分割或特征提取。
  • 当初始估计较为准确时,具有不错的精度和收敛性。

缺点

  • 寻找最近对应点计算成本高(可通过下采样密集点云或采用小样本匹配以加快迭代速度来降低)。
  • ICP 每次迭代太耗时,还会迭代很多次,所以后来提出了一些算法来加速。
  • 仅考虑点对点距离,未充分利用点云结构信息。
  • 对初始估计的准确性高度依赖。

类别级位姿估计(Category-Level Pose Estimation)

实例级别(Instance-Level)的位姿估计都要求我们知道物体的完整建模,否则我们缺乏目标,无法进行估计。

不过,对于一些有自然定义的 pose,它会具有一个天然的参考性(从而提供一个类别级的参考系),从而可以从 Instance level 延拓到 Category level,直接对这一类别的物体的 pose 进行预测。

这是王鹤老师在 CVPR 2019 Oral 的工作,原始论文可以参见 这里

这是如何做到的?当物体缺乏实例级别的 CAD 模型时,那就建立类别级的统一参考系。

这里的核心思想是 通过归一化操作定义标准化物体空间 Normalized Object Coordinate Space(NOCS)

  1. 旋转对齐 (Rotation Alignment):通过先验,使用物体的方向,对齐坐标系
  2. 平移归一化 (Translation Normalization):计算 Bounding box,将包围盒中心平移至坐标系原点
  3. 尺寸归一化 (Scale Normalization):通过对角线长度限制 Bounding box 的大小(限制对角线长度为 $1$,那么一定能装到 $1\times 1\times 1$ 的 Bounding box 内)

举个栗子 🌰

对于茶杯我们总是知道其大致形状的(先验)。

  1. 然后对齐物品的朝向,如把茶杯手柄的方向统一规定为某一轴的正方向,从而对齐 $R$
  2. 使用一个正方体的 bounding box 来框起来物体,然后强制把其中心定位在 $(0,0,0)$,从而对齐 $t$
  3. 归一化 Bounding box 的大小,从而对齐同一类物体的 size

nocs_1

nocs_2

nocs_3

好,现在有了参考系,那怎么用呢?

首先,我们指出一下该算法和 ICP 算法的本质区别:

  1. ICP 算法需要很强的先验知识,我们需要完整的知道物体的本身建模,然后以 RGBD 或者 RGB 重建得到的点云去与物体本身建模点云配准,由于求位姿的算法需要一个变换前后的坐标对,所以我们需要先进行最近邻匹配(也就是这一步导致了收敛性的缺失以及迭代速度的变慢),然后据此迭代得到物体位姿 $(R,t)$
  2. NOCS 算法不再需要完整的知道知道物体的本身建模,而是通过标准化的 NOCS 空间隐式地引入了对于某一类物体的、相较于 ICP 算法更粗粒度的几何先验,降低了对于高精建模的依赖,我们(使用合成数据)训练得到一个神经网络,可以从 RGB 图像直接为每一个像素预测其在 NOCS 中的对应点 $(x,y,z)$,随后将其与 RGBD 重建得到的点云信息进行配准,这里根据像素关系,可以天然形成数量相同的变换前后的坐标对,所以不再需要找到最近邻(Correspondence)。而后,我们可以直接用 Umeyama 算法(和 ICP 去除最近邻匹配的后半段类似)来重建得到 7 DoF 物体位姿 $(s,R,t)$

整个 NOCS 过程可以被建模为如下数学形式:

给定两组对应点云:

  • 规范空间点 $\mathbf{p}_i \in \mathbb{R}^3$(来自 NOC Map 预测)
  • 真实空间点 $\mathbf{q}_i \in \mathbb{R}^3$(来自深度图反投影)

寻找相似变换参数 $(s, R, t)$ 使得:

$$ sR\mathbf{p}_i + t = \mathbf{q}_i \quad \forall i $$

接着,我们给出算法的过程。

nocs_arch

nocs_arch_2

  1. 输入 RGBD 图像,提取 RGB 信息,使用 Mask R-CNN(如果没学过,可以参见我在 AI 基础写的 这篇笔记)获得 ROI(感兴趣区域,Region of Interest),分割物体
  2. 对于分割出的物体,对其每个像素预测其对应的 NOCS 空间坐标 $(x,y,z)$,得到 NOCS Map
  3. 利用 Depth 图像和相机内参,将 NOCS Map 中的点反投影(Back Projection)到三维空间中,得到点云数据
  4. 通过 NOCS Map 和 Depth 图像得到的点云数据,进行 Pose Fitting,利用 Umeyama 算法,计算得出物体的 7DoF 位姿(缩放 + 旋转 + 平移),缩放系数的计算就是简单的用 NOCS Map 的各轴向长度与物体实际点云各轴向作了一个除法。而反过来计算 Bounding Box 的时候,则利用了 NOCS 建模时令物体中心处在原点从而具有的对称性,以预测出的 NOCS Map 各轴向最大绝对值乘 2 再乘缩放系数作为了 Bounding Box 的各轴向尺寸

Umeyama 算法和前文类似,再次不再赘述。

了解了过程之后,一个很自然的问题就是:为什么不能直接用神经网络去根据 RGB 图像和 RGBD 反投影得到的深度图预测 6DoF 位姿?

  1. 首先,实验能证明这种方法比直接回归要好;
  2. 其次,直观的理解上可以想到,回归是一个从 3D $\to$ 6D 的直接预测,而 NOCS 是首先建立了 2D $\to$ 3D 的对应关系,然后将 6D 的位姿变换成了从 NOCS 3D 到 Depth 3D 的一个几何优化问题,明显后者比前者更符合直觉。
  3. 除此之外,NOCS 方法还充分利用了形状 / 几何先验,通过规范空间强制同类物体共享几何分布特征,使网络能学习类别级别的形状规律,学习起来会具有协同效应(Synergy),提升了对未见物体的泛化能力。

合成数据

刚才介绍过了 NOCS 方法,那么现在最大的问题就在于如何去训练这样一个从二维 RGB 图像重建到 NOCS 空间的神经网络了。

在类别级物体姿态估计任务中,真实数据标注面临两大挑战:

  1. 标注成本过高
  2. 类别泛化性不足

因此,直接去使用真实数据是很难成功的,所以很自然地,我们想要使用合成数据来进行训练。

但是,模型在合成数据($\mathcal{D}{syn}$)和真实数据($\mathcal{D}{real}$)上的往往存在差异,也即 Sim2Real Gap,这是由于这二者的分布是不同的,直接用真实数据去测试在合成数据上 Work 的方法,往往会导致性能暴跌。

为此,王老师提出了一种新的数据合成办法,也就是 Mixed Reality Data

mixed_reality_data

这种数据中,背景是真实的,而需要分割的前景是合成的(从而我们可以直接获得训练 NOCS 模型所需的监督信号),从而可以很轻易地获取到几十万量级的数据。

但是,在实践过程中,发现简单地使用这个方法还是会存在较大的 Sim2Real Gap,这是由于合成背景和前景照片的时候,分界太过明显,从而导致分割的 Mask R-CNN 学习到的经验难以应用到真实世界。

为了解决这个问题,王老师又提出了使用 Co-Training 的方案,即同时结合过往 Image Segmentation 领域收集的真实数据集(Coco)与我们的合成数据集来一同对 Mask R-CNN 进行 混合训练,但前者不参与后续的 NOCS 映射训练,只为分割提供监督信号。

王老师认为,这种合成数据的使用在具身智能领域是必不可少的,因为训练学习所需的真实数据很难大规模、轻易地获取到。

王老师还提到,目前 Pose Estimation 领域最 work 的模型(FoundationPose)就是纯合成数据训练出来的,不过他们的合成过程会更加精细

sota_pose_estimator

对于预测得到的位姿,有时候还需要 Refinement,比如之前介绍的 ICP 算法。

然而,ICP 算法同时需要点云与物体表面 mesh,真实情况下可能两者都没有,所以现在这个问题完全用神经网络来做,而其训练的数据全靠合成。

运动规划的层级

$$ \text{pose} \to \text{grasp} \to \text{motion planning} \to \text{control} $$

  1. 一代技术:工业机器人,完全的轨迹重放,无环境感知能力
  2. 二代技术:位姿预测,但需要物体预先定义,轨迹通过位姿进行预测规划得到
  3. 三代技术:抓取预测
  4. 四代技术:动作规划预测,神经网络端到端直接输出动作轨迹 Action / Trajectory,可以进行闭环纠错
  5. 五代技术:完全的闭环控制,大语言模型指导进行语义推理

开环控制如果 pose estimation 足够快,也能搞成闭环。

抓取(Grasp)

抓取:指通过在接触点施加力和力矩,以期望的方式约束物体运动的过程。

Force Closure

定义:通过摩擦力 维持平衡的约束状态,如果施加在摩擦接触点上的一组力足以补偿施加在物体上的任何外部力,则称为力闭合。

王鹤老师原话:以某一组力在某一组接触点(Contact Point)抓取起来后,物体需要任意方向的加速度,都可以提供。

Force Closure 是判断抓取质量的一个重要指标。

Form Closure

定义:仅仅通过 几何约束 完全限制刚体运动的状态( 不依赖摩擦力 )。

根据定义,不难推知,严苛程度上:抓起来 ≤ force closure ≤ form closure

在规划机器人手的抓取时,力闭合是一个很好的最低要求。形闭合通常过于严格,需要太多接触点。

回归与生成

传统的抓取问题可以看作是一个回归问题,即预测唯一的抓取位姿。然而,由于遮挡和对称性,一个物体通常存在多个可行的抓取(多峰分布)。因此,将抓取建模为一个生成问题更为合适。

💾

  •  

RL in VLA

2025年3月22日 08:34

iRe-VLA

Paper

ire_vla

Insight

  1. RL 只用以更新少部分参数,即 Action 头,从而避免 RL 大规模更新参数的不稳定。
  2. SFT 来更新 LLM,更加稳定
  3. 训练过程:先 SFT,然后迭代进行 RL(PPO,on-policy)和 SFT

Intresting

  1. LLM 用以高层规划(分解任务,无法直接应用于物理世界)或者低层控制信号(LLM 中引入 Action Token 或者后接动作头)
  2. RL 直接用以提升 VLA 输出的低层控制信号
  3. RL 得到的新成功轨迹加入数据集,on-policy
  4. RL 用以探索,SFT 用以记忆

Arch

Backbone:BLIP

Componentes:LoRA,TokenLearner(压缩多 token 到单 token)

Reward Signal:MSE (SFT), 01 Sparse (RL)

Result

当在线数据 $|D_{\text{RL}}| > 0.3|D_e|$ 时,超越纯模仿学习的涌现能力(应对遮挡、动态干扰)。

RLPD

Paper

Efficient Online Reinforcement Learning with Offline Data

rlpd

Insight

  1. 对称采样:50% 在线数据 + 50% 离线数据,去除对于离线数据质量的假设
  2. LayerNorm 约束价值函数 $Q$,抑制 OOD 时的过度自信(价值外推),稳定值函数
  3. 高效采样:增加数据回放比 UTD,采用随机集成蒸馏(见下述算法)

Algorithm

$$ \begin{array}{l} \hline \textbf{算法} \ \text{在线强化学习结合离线数据 RLPD} \ \hline \text{初始化:} \ \quad \text{层归一化,集成规模 } E,\ \text{梯度步数 } G,\ \text{网络架构} \ \quad \text{评论家参数 } \theta_1,...,\theta_E\ (\theta'i \leftarrow \theta_i),\ \text{策略参数 } \phi \ \quad \text{折扣因子 } \gamma,\ \text{温度系数 } \alpha,\ \text{EMA 权重 } \rho,\ \text{目标子集 } Z \in {1,2} \ \quad \text{经验池 } \mathcal{R} = \varnothing,\ \text{离线数据集 } \mathcal{D} \ \hline \text{主循环:} \ \quad \text{获取初始状态 } s_0 \ \quad \text{循环 } t=0 \text{ 至 } T: \ \qquad \text{执行动作 } a_t \sim \pi\phi(\cdot|s_t),\ \text{存储转移 } (s_t, a_t, r_t, s_{t+1}) \text{ 至 } \mathcal{R} \ \hline \qquad \text{训练步骤 (重复 } G \text{ 次):} \ \qquad\quad \text{采样 } b_R \leftarrow \frac{N}{2} \text{ 自 } \mathcal{R},\ b_D \leftarrow \frac{N}{2} \text{ 自 } \mathcal{D} \ \qquad\quad \text{合并批次 } b = b_R \cup b_D \ \qquad\quad \text{计算目标值:} \ \qquad\qquad \mathcal{Z} \leftarrow \text{随机选取 } Z \text{ 个索引(从 } {1,...,E} \text{)} \ \qquad\qquad y = r + \gamma \big[\min_{i\in\mathcal{Z}} Q_{\theta'i}(s', \tilde{a}')\big] + \gamma\alpha \log \pi\phi(\tilde{a}'|s') \ \qquad\qquad \text{其中 } \tilde{a}' \sim \pi_\phi(\cdot|s') \ \hline \qquad\quad \text{评论家更新:} \ \qquad\qquad \text{循环 } i=1 \text{ 至 } E: \ \qquad\qquad\quad \theta_i \leftarrow \arg\min \frac{1}{N}\sum (y - Q_{\theta_i}(s,a))^2 \ \qquad\qquad \theta'i \leftarrow \rho\theta'i + (1-\rho)\theta_i \ \hline \qquad\quad \text{策略更新:} \ \qquad\qquad \phi \leftarrow \arg\max \frac{1}{E}\sum{i=1}^E Q{\theta_i}(s,\tilde{a}) - \alpha \log \pi_\phi(\tilde{a}|s) \ \qquad\qquad \text{其中 } \tilde{a} \sim \pi_\phi(\cdot|s) \ \hline \end{array} $$

Result

收敛变快(300k vs 1M),效果提升。

HIL-SERL

Paper / Homepage / Code

Human in Loop SERL,双臂任务

hil_serl

Insight

主动学习、人在回路:系统向模型请求可能的修正,offline 更新

Arch

Backbone:ResNet-10

Reward:01 Sparse (MLP)

AC 架构:

  • Actor:采样,送到 replay buffer,可以人为干预
  • Learner:学习,RLPD 均等采样

两个缓冲区:

  • 人类示范(离线)
  • 策略实施(RL buffer)

对于人类产生的干预数据:

  • actions 同时放到两个缓冲区(RL buffer + Demo buffer)
  • P 概率转移只放到 RL buffer

单独用 DQN 学习抓握(夹爪建模为离散动作),输出动作基于 EEF 当前坐标系,抗干扰。

RLDG

Paper

Reinforcement Learning Distilled Generalist

rldg

Insight

  1. 使用 RL 生成高质量微调数据,微调 HIL-SERL
  2. 数据质量 > 数据数量

ConRFT

Paper

Consistency-based Reinforced Fine-Tuning

conrft

Math

离线 Critic 损失

$$ \mathcal{L}{Q}^{offline}(\theta) = \alpha\left(\mathbb{E}{s\sim\mathcal{D},a\sim\pi}[\max(Q_{\theta},V^{\mu})] - \mathbb{E}{s,a\sim\mathcal{D}}[Q{\theta}]\right) + \frac{1}{2}\mathbb{E}[(Q_{\theta}-\mathcal{B}^{\pi}\overline{Q})^2] $$

  • $\max(Q_{\theta},V^{\mu})$:防止 OOD(分布外)动作的高估
  • $\mathbb{E}[(Q_{\theta}-\mathcal{B}^{\pi}\overline{Q})^2]$:稳定 Q 值估计,防止离线数据不足导致的过拟合

一致性策略

$$ \pi_{\psi}(a|s) = f_{\psi}(a^k, k | E_{\phi}(s)) $$

  • $f_{\psi}$ 一致性策略是一个基于扩散模型的策略,负责去噪并生成最终动作。其目标是学习从单位高斯分布 $\mathcal{N}(0,I)$ 的随机噪声动作 $a^k$ 到专家动作分布 $a \sim \pi^*(a|s)$ 的映射。映射过程以当前状态编码 $E_{\phi}(s)$ 为条件。
  • $a^k \sim \mathcal{N}(0, kI)$ 是第 $k$ 步的含噪声动作将扩散时间步 $[\epsilon, K]$ 划分为 $M$ 个子区间(边界为 $k_1=\epsilon \le \dots \le k_M=K$),每个子区间对应一个噪声尺度 $k_m$。例如,$\epsilon=0.002$ 表示初始噪声尺度极小,$K$ 为最大噪声尺度。

$$ \mathcal{L}{\pi}^{offline}(\psi) = -\eta\mathbb{E}[Q(s,a)] + \beta\mathbb{E}[d(f{\psi}(a+k_mz),a)] $$

  • $-\eta\mathbb{E}[Q(s,a)]$:引导策略朝高回报方向优化

  • $\beta\mathbb{E}[d(f_{\psi}(a+k_mz),a)]$:迫使策略在不同噪声尺度下保持动作预测的一致性,也即约束动作与演示数据的一致,解决人类演示的次优问题

    对任意中间扩散步 $k_m$,若向专家动作 $a$ 添加噪声 $k_m z$ 得到扰动动作 $a + k_m z$,一致性策略 $f_{\psi}$ 应能将其映射回原始专家动作 $a$。

Insight

  • 人在回路
  • 一致性策略保证鲁棒性,但在线学习阶段逐步降低 $\beta$(BC 权重),实现从模仿到自主探索的平滑过渡
  • 反馈信号中存在时间惩罚,引导快速完成任务

GRAPE

Paper

Generalizing Robot Policy via Preference Alignment

grape

Math

TPO 轨迹偏好优化损失(Trajectory-wise Preference Optimization Loss,类似 DPO): $$ \mathcal{L}{\text{TPO}} = -\mathbb{E} \left[ \log \sigma \left( \beta \left( \log \frac{\pi\theta(\zeta_w)}{\pi_{\text{ref}}(\zeta_w)} - \log \frac{\pi_\theta(\zeta_l)}{\pi_{\text{ref}}(\zeta_l)} \right) \right) \right] $$

  • $\beta$:温度系数,调节策略更新的强度(类比 “学习率”),越大这个 Loss 也越大,策略对比越强,更关注优选 / 劣选轨迹的差异;越小越保守更新,这项损失不重要。
  • $\pi_\theta$:待优化的策略(参数为 $\theta$)
  • $\pi_{\text{ref}}$:参考策略(预训练的初始策略)
  • $\zeta_w, \zeta_l$:优选轨迹(winning)和劣选轨迹(losing)

Insight

  • 对比学习,增大优选轨迹概率比,降低劣选轨迹概率比

  • 存在外部 Critic,由强大 LLM(GPT4o)给出,而非手动设计,某一时刻的成本为后续成本的乘积: $$ R_{\text{ext}}(\zeta) = \prod_{i=1}^{\mathbf{S}} e^{-C^{S_i}({\kappa_{S_i}})} $$ 其中:

    • $\mathbf{S}$:子系统的总数
    • ${\kappa_{S_i}}$:子任务 $S_i$ 的动态参数集合,如关节角度、速度、接触力等实时状态
    • $C^{S_i}$:子任务 $S_i$ 的成本函数,由 LLM 给出
  • 完整的 Reward 同时包括外部 Critic、模型自身、以及成功与否信息加权,用以判断 $\zeta_w, \zeta_l$: $$ R_{\text{GCPG}}(\zeta) = \lambda_1 R_\text{self}(\zeta) + \lambda_2 R_\text{ext}(\zeta) + \lambda_3 I_{\text{success}}(\zeta) $$ 其中: $$ R_\text{self}(\zeta) =\log(\pi(\zeta, q)) = \log(\prod_{i=1}^T\pi(a_i \mid(o_i, q))) $$

Algorithm

$$ \begin{array}{l} \hline \textbf{算法} \ \text{迭代偏好优化算法} \ \hline \text{初始化:} \ \quad \text{基础 VLA 策略 } \pi_\theta,\ \text{任务指令集 } Q = {q_i},\ \text{阶段分解器 } \mathcal{M}D \ \quad \text{最大迭代次数 } K,\ \text{奖励权重 } {\lambda_1, \lambda_2, \lambda_3} \ \quad \text{阶段关键点 } {\kappa{S_i}},\ \text{成本函数 } {C^{S_i}j}\ \text{及阈值 } {\tau^{S_i}j} \ \hline \text{主循环:} \ \quad \text{循环 } k=1 \text{ 至 } K: \ \qquad \text{用 } \pi\theta \text{ 和 } Q \text{ 采样轨迹集 } \mathcal{D}^k = {\zeta_i}{i=1}^M \ \qquad \text{循环轨迹 } \zeta \in \mathcal{D}^k: \ \qquad\quad \text{分解 } \zeta \text{ 为多阶段 } S\ \text{(阶段分解)} \ \qquad\quad \text{计算各阶段成本 } C_{S_i}\ \text{(阶段成本)} \ \qquad\quad \text{计算外部奖励 } R_{\text{ext}}(\zeta)\ \text{(全局成本)} \ \qquad\quad \text{计算策略自奖励 } R_{\text{self}}(\zeta)\ \text{(轨迹自评估)} \ \qquad\quad \text{验证任务成功指标 } I_{\text{success}}(\zeta)\ \text{(成功判别)} \ \qquad\quad \text{聚合 GCPG 奖励 } R_{\text{GCPG}}(\zeta)\ \text{(综合奖励)} \ \hline \qquad \text{按 } R_{\text{GCPG}}(\zeta) \text{ 排序 } \mathcal{D}^k \ \qquad \text{从 top-}m \text{ 和 bottom-}m \text{轨迹生成配对 } {\zeta_w, \zeta_l} \ \qquad \text{用 TPO 损失更新 } \pi_\theta\ \text{(偏好对齐)} \ \hline \text{返回:优化策略 } \pi^* \ \hline \end{array} $$

ASAP

Paper

Aligning Simulation and Real-World Physics

asap

Insight

  1. 预训练得到基础策略(模拟环境中)

  2. 后训练收集现实数据,模拟重放,获取跟踪误差,训练 delta 模型来补偿差异,形成残差校正项,通过动作空间修正隐式补偿,而不是像 SysID 一样显式建模物理参数来修正差异

    骑自行车时,人脑自动补偿重心偏移,而非计算力学方程

    $$ s_{t+1} = f^{\text{ASAP}}(s_t, a_t) = f^\text{sim}(s_t, a_t + \pi^\Delta(s_t, a_t)) $$

  3. 非对称 AC 架构

    1. Actor 网络仅依赖本体感知输入(关节位置 / 速度、基座姿态、时间相位)
    2. Critic 网络额外访问特权信息(参考动作轨迹、全局位置)

Arch

  1. PPO,AC
  2. Reward:$r_t = r_{\text{task}} + r_{\text{penalty}} + r_{\text{regularization}}$
    • 任务奖励(身体位置 / 旋转 / 速度匹配)
    • 惩罚项(关节极限、扭矩超限)
    • 正则化(动作平滑性)

💾

  •  

视觉与抓取 I

2025年3月17日 09:12

抓取

抓取(grasping):通过末端执行器(end-effector)对物体施加约束(力和扭矩),以期望的方式限制物体运动的过程。

抓取合成(grasping synthesis):对于夹爪位姿或者关节控制的高维搜索 / 优化问题。

vision_grasping_robot_sequence

  • 抓握式操作 (Prehensile Manipulation):通过完全约束物体自由度实现精确控制
  • 非抓握式操作 (Non-prehensile Manipulation):利用推、滑等接触力学原理调整物体状态,适用于薄片状物体或预处理场景,不是所有动作都需要抓取

抓取的自由度

抓取姿势(Grasp Pose):手的位置、方向和关节状态

  • 4-DoF 抓取:仅需平移和绕重力轴旋转,适用于结构化环境、固定位置(如流水线物料分拣)

    $$ (x, y, z, \theta_z) $$

    rpy

    yaw

  • 6-DoF 抓取:允许任意方向接近,处理非结构化场景(即更复杂的任务如杂乱堆叠物体) $$ (x, y, z, \theta_x, \theta_y, \theta_z) $$

  • 手指自由度

    • 平行夹爪:开 / 关,1 DoF
    • 灵巧手(Dexterous Hand):21 DoF

开环抓取与闭环抓取

开环控制是指 不使用反馈机制 的控制系统。

  1. 控制命令直接发送给系统,不基于系统当前状态或结果进行调整
  2. 输入与输出之间没有信息回路
  3. 系统不会根据执行结果来自动修正控制信号

开环抓取:基于视觉位姿估计,预测抓取位姿,执行抓取,视觉只会用到一次,如果失败(如掉落、没抓起来),不会尝试修正,“蒙着眼睛做事情”。

闭环抓取:基于视觉位姿估计,预测抓取位姿,执行抓取,如果抓取失败,则调整抓取位姿,重新抓取。

开环抓取系统

一般处理流程:

  1. 视觉感知
  2. 位姿估计
  3. 运动规划
  4. 控制执行

对已知物体的抓取

由于物体信息已知,可以通过对物体的位姿进行预测。也就是在物体自身坐标系下进行抓取标注,然后转换到世界坐标系下。

  1. RGB 图像,若满足

    1. 相机内参(将三维空间点投影到二维图像平面的关键参数,包括焦距、主点灯)已知:逆向的关键

    2. 物体大小已知:避免歧义(ambiguity)

      why_pigeon_so_big

      道理我都懂,但是这个鸽子怎么这么大?

    3. 物体无对称性

    那么其可以唯一对应一个位姿

  2. 点云(Point Cloud)图像,只需满足物体 无对称性,那么就可以唯一对应一个位姿。

Iterative Closest Point (ICP) 算法

流程:

  1. 初始化变换估计 $T_0 = (R_0, t_0)$

  2. 迭代直至收敛:

    1. 数据关联:确立变换后最近邻点对,建立模板点云 $M$ 与场景点云 $S$ 对应关系

      $$ C = { (m_i, s_j) | s_j = \arg \min_{s \in S} | T_k m_i - s | } $$

    2. 变换求解:最小化对应点距离 $$ T_{k+1} = \arg \min_T \sum_{(m,s) \in C} | Tm - s |^2 $$

问题:比较怕物体被挡住造成 点云缺失

对未知物体的抓取

直接预测抓取位姿。

也有算法可以从见过同一类别物体进行泛化。

旋转回归(Rotation Regression)

回归:估计连续变量。

旋转回归:一种特殊的回归任务,对于输入信号,经由神经网络估计连续的旋转变量。

Typora 2025-03-18 16.13.52

其中,表示方式空间 $R$ 可以是四元数、欧拉角等,而 $X$ 是 $\mathbb{SO}(3)$ 群。

回顾一下 $\mathbb{SO}(3)$ 群的定义,$\mathbb{SO}(3)$ 是特殊正交群(Special Orthogonal group),由所有三维旋转矩阵组成。

3D 旋转矩阵 $R$ 是一个 $3\times3$ 矩阵,满足以下条件:

  • $R^{\top}R = I$ (正交性)
  • $\det R = +1$ (保持右手坐标系)

$\mathbb{SO}(2) / \mathbb{SO}(3)$ 具有很好的连续性,没有跳变点的存在。

与普通回归不同,旋转表示在非线性空间、非欧空间中,所以对于之前所讲过的所有旋转的表达方式,简单地使用 MSE 来作为监督信号都会不够理想。

这是因为,CNN 理应具有连续性,对于输入的微小变动,其输出不应当造成很大的改变。

而如果对于某一旋转表达方式,存在这种 Ground Truth 监督信号的跳变,神经网络为了拟合这种跳变点,就会导致其权重矩阵 $W$ 出现一些很大的参数,造成数值不稳定性的同时,为之消耗大量的注意力,大部分的训练过程都去拟合这种跳变而不是其他占比更多、更泛用的部分,这是非常不好的。并且这一过程是 Loss 无关的,是由于选择了不好的表达方式造成的本质性问题。

所以,理想的表达方式,应当满足:

  1. 双射,表达方式到 $\mathbb{SO}(3)$ 群是一一映射的,否则特定旋转时可能出现多种等价表示,这使得神经网络难以学习
  2. 连续, $\mathbb{SO}(3)$ 群中任何一点附近的连续变化,其对应的表达方式应当也是连续变化,也即不存在性质不好的 奇点(Singularities)

欧拉角

欧拉角使用三个角度(通常表示为 $\alpha$、$\beta$、$\gamma$)来描述绕三个主轴的连续旋转,完整的旋转矩阵可以通过组合这些基本旋转得到:

$$ R = R_x(\alpha)R_y(\beta)R_z(\gamma) $$

问题:欧拉角的表达方式天然存在非双射、万象节锁的问题

举例:考虑 2D 的情况,此时使用单一自由度 $\theta$ 来代表绕轴旋转的角度。

euler_angle_rotation_discontinuity

绕旋转轴转 $0$ 和 $2\pi$ 是一样的,但是在实际的 $\mathbb{SO}(2)$ 中是连续的。

一个解决方法是 引入冗余维度,把低维空间中的的不连续改成高维空间中的连续,如 $\theta \to (x,y)$,后者是连续的,且能反向求解出前者。

轴角

轴角表示由一个单位向量 $\mathbf{e} = [e_x, e_y, e_z]^{\top}$(表示旋转轴)和一个标量 $\theta$(表示旋转角度)组成:

$$ (\text{axis}, \text{angle}) = (\mathbf{e}, \theta) $$

可以使用罗德里格旋转公式(Rodrigues' rotation formula)将轴角表示转换为旋转矩阵:

$$ R = I + (\sin\theta)K + (1-\cos\theta)K^2 $$

其中 $K = [\mathbf{e}]_\times$ 是其叉乘矩阵:

$$ K = \begin{bmatrix} 0 & -e_z & e_y \ e_z & 0 & -e_x \ -e_y & e_x & 0 \end{bmatrix} $$

问题: 当 $\theta = 0$ 时,任何轴都表示单位旋转(即不旋转);当 $\theta = \pi$ 时,绕某个轴的旋转 $(\mathbf{e}, \pi)$ 和绕它的反方向 $(-\mathbf{e}, \pi)$ 表示相同的旋转。

四元数

四元数是复数的一种推广,形式为:

$$ q = w + xi + yj + zk $$

其中 $w$ 是实部,向量 $\mathbf{v} = (x, y, z)$ 是虚部,且 $i^2 = j^2 = k^2 = ijk = -1$。

任何一个旋转,即绕某个单位向量 $\hat{\omega}$ 旋转 $\theta$ 角度,对应的四元数可以表示为:

$$ q = \left[\cos\frac{\theta}{2}, \sin\frac{\theta}{2} \hat{\omega}\right] $$

问题:四元数存在 “双重覆盖” 关系。

我们可以很容易地发现:

$$ \begin{aligned} q &= \left[\cos\frac{\theta}{2}, \sin\frac{\theta}{2} \hat{\omega}\right] \ -q &= \left[-\cos\frac{\theta}{2}, -\sin\frac{\theta}{2}\hat{\omega}\right] \ &= \left[\cos(\pi - \frac{\theta}{2}), \sin(\pi - \frac{\theta}{2}) (-\hat{\omega})\right] \end{aligned} $$

是等价的($-q$ 意味着同一旋转轴但是翻转正方向,然后旋转 $2\pi - \theta$)。

double_coverage

为此,我们通常约束四元数位于上半球(即 $w \geq 0$),但这又会引入新的不连续性:

  1. 临近球大圆的不连续性

    quaternion_double_coverage_fix_issue

  2. 球大圆上的不连续性:由于双重覆盖,我们只能取一个半圆,但是在这个切面圆的直径上,我们还是只能选取两个切点中的一个(否则又存在双重覆盖问题,$q = -q$),而这么选取的话,在这个点附近,依旧有类似欧拉角的跳变存在(还是那个原因,在这个点附近的微小变动会引发跳变)

    quaternion_issue

6D 表示

为了解决不连续性问题,我们放弃了选择上述方法,改为回到旋转矩阵本身。

直接尝试拟合旋转矩阵,会引入 9 个数的自由度,我们还需要映射到 $\mathbb{SO}(3)$,所以引入进行施密特正交化以满足旋转矩阵条件:

  1. 第一列标准化
  2. 第二列只保留垂直于第一列的分量,然后标准化
  3. 第三列通过第一列和第二列的叉乘确定

形式化表示为:

$$ f_{GS}\left(\begin{bmatrix} \mathbf{a}_1 & \mathbf{a}_2 \end{bmatrix}\right) = \begin{bmatrix} \mathbf{b}_1 & \mathbf{b}_2 & \mathbf{b}_3 \end{bmatrix} $$

其中:

$$ \mathbf{b}_i = \begin{cases} N(\mathbf{a}_1) & \text{if } i = 1 \ N(\mathbf{a}_2 - (\mathbf{b}_1 \cdot \mathbf{a}_2)\mathbf{b}_1) & \text{if } i = 2 \ \mathbf{b}_1 \times \mathbf{b}_2 & \text{if } i = 3 \end{cases} $$

其中 $N(\mathbf{v})$ 表示向量 $\mathbf{v}$ 的归一化。

这种表示实际上只有 6 个自由度,所以我们叫它 6D 表示方法。

然而,这个方法固然简单,但是他引入了新的问题:拟合得到的 9 个数彼此并不等价。

  1. 对于第一列,是一等公民,直接归一化
  2. 对于第二列,是二等公民,需要移除平行于第一列的分量
  3. 对于第三列,甚至完全不考虑它的数值,正交系的三个向量直接由前两个叉乘得到

所以,这种表示方式与传统的 L2 Norm 的损失函数并不协调。

当然我们可以相对应地分优先级,第一列直接算,第二列需要加权,第三列直接排除在损失函数之外,但直觉上就会感觉到不平衡的存在 —— 神经网络输出的各个神经元本应等价,但是你算 Loss 的时候还要排除,哪有这样的道理?

9D 表示

9D 表示直接使用完整的旋转矩阵(9 个元素)作为表示。为将神经网络的欧几里得输出映射到 $\mathbb{SO}(3)$,同时满足前述要求:

  1. 双射
  2. 连续
  3. 等价

我们使用奇异值分解(SVD)对之进行正交化:

$$ \hat{R} = U\begin{bmatrix} 1 & 0 & 0 \ 0 & 1 & 0 \ 0 & 0 & \det(UV) \end{bmatrix}V^{\top} $$

其中 $U$ 和 $V$ 是对神经网络预测除的矩阵进行 SVD 分解得到的正交矩阵,$\det(UV)$ 项确保结果矩阵的行列式为 +1,满足旋转矩阵的性质。

SVD 的基本过程

给定任意矩阵 $M \in \mathbb{R}^{3 \times 3}$,其奇异值分解(SVD)为:

$$ M = U \Sigma V^{\top} $$

其中:

  • $U$ 和 $V$ 是正交矩阵($U U^{\top} = V V^{\top} = I$)
  • $\Sigma$ 是对角矩阵,对角线元素为奇异值 $\sigma_1 \geq \sigma_2 \geq \sigma_3 \geq 0$

对于我们预测的旋转矩阵而言,这里分解得到的奇异值会很接近 1,但不一定就是 1,所以直接换掉它来使之满足正交化条件。

优势:CNN Friendly

  • 不区分对待矩阵的每一行,实现完全连续、一一映射的表示
  • 与神经网络的欧几里得输出空间兼容

增量旋转预测

对于预测增量旋转(delta rotation),即 $\mathbb{SO}(3)$ 在单位矩阵 $I$ 附近的小范围旋转,前面几种表示方式实际上都可以,因为此时在这个邻域没有了我们考虑了半天的奇点(Singularities)问题。

而且,此时由于四元数等表示方式需要预测参数更少,学习起来甚至可能更快。

Rotation Fitting

使用神经网络先预测物体坐标或对应关系,然后解算旋转。具体步骤包括:

  1. 对物体表面的每个像素,预测其在物体建模模型上的 3D 坐标
  2. 基于这些对应关系拟合旋转矩阵

这种方法建立了模型坐标系(model) $(x_i^M, y_i^M, z_i^M)$ 和相机坐标系(camera) $(x_i^C, y_i^C, z_i^C)$ 两个坐标系之间的对应关系。

我们的目标是找到将模型坐标系转换到相机坐标系的最优变换矩阵(要求物体大小不变)。

model_to_camera_coordinates

这要求物体是见过的、标注过的,不然没法比对(缺乏 $(x_i^M, y_i^M, z_i^M)$ 模型坐标系基础定义)。

  • 有 Depth 信息:3d to 3d,$(u,v, d) \to (x_i^M, y_i^M, z_i^M)$
  • 没有 Depth 信息:2d to 3d,$(u,v) \to (x_i^M, y_i^M, z_i^M)$

正交 Procrustes 问题

给定两组对应的 3D 点集,不考虑位移 $t$ 的纯旋转拟合(求解它们之间的最优旋转矩阵)可以形式化为正交 Procrustes 问题,这是一个矩阵逼近问题。

定义:给定矩阵 $\mathbf{M} \in \mathbb{R}^{n \times p}$ 和 $\mathbf{N} \in \mathbb{R}^{n \times p}$,我们需要求解:

$$ \hat{\mathbf{A}} = \arg\min_{\mathbf{A} \in \mathbb{R}^{p \times p}} |\mathbf{M}^{\top} - \mathbf{AN}^{\top}|F^2 = \arg\min{\mathbf{A} \in \mathbb{R}^{p \times p}} |\mathbf{M} - \mathbf{NA}^{\top}|_F^2 \ \text{subject to} \quad \mathbf{A}^{\top}\mathbf{A} = \mathbf{I} $$

其中,$|\cdot|_F$ 表示 Frobenius 范数,定义为:

$$ |X|F = \sqrt{\text{trace}(X^{\top}X)} = \sqrt{\sum{i,j} x_{ij}^2} $$

这里:

  • $\mathbf{M}$ 可以表示目标坐标系中的点集(例如相机坐标系)

  • $\mathbf{N}$ 表示源坐标系中的对应点集(例如模型坐标系)

  • 求解的 $\mathbf{A}$ 即为从 $\mathbf{N}$ 到 $\mathbf{M}$ 的最优旋转矩阵

  • 约束条件 $\mathbf{A}^{\top}\mathbf{A} = \mathbf{I}$ 确保 $\mathbf{A}$ 是正交矩阵,保证了纯旋转变换(不包含缩放或剪切)。

正交 Procrustes 问题有一个优雅的解析解,可以通过奇异值分解(SVD)获得。如果我们对矩阵 $\mathbf{M}^{\top}\mathbf{N}$ 进行 SVD 分解:

$$ \mathbf{M}^{\top}\mathbf{N} = \mathbf{UDV}^{\top} $$

那么最优旋转矩阵为:

$$ \hat{\mathbf{A}} = \mathbf{UV}^{\top} $$

数学证明

首先回顾迹运算的性质:

  1. 线性性质:$\text{tr}(A + B) = \text{tr}(A) + \text{tr}(B)$
  2. 循环性质:$\text{tr}(ABC) = \text{tr}(BCA) = \text{tr}(CAB)$
  3. 转置性质:$\text{tr}(A^{\top}) = \text{tr}(A)$
  4. 标量提取:$\text{tr}(cA) = c·\text{tr}(A)$,其中 $c$ 为标量
  5. 与 Frobenius 范数的关系:$|A|_F^2 = \text{tr}(A^{\top}A) = \text{tr}(AA^{\top})$

利用迹运算的性质和 $\mathbf{A}$ 是正交矩阵的条件($\mathbf{A}^{\top}\mathbf{A} = \mathbf{I}$):

$$ \begin{aligned} |\mathbf{M} - \mathbf{NA}^{\top}|_F^2 &= \text{tr}((\mathbf{M} - \mathbf{NA}^{\top})^{\top}(\mathbf{M} - \mathbf{NA}^{\top}))\ &= \text{tr}(\mathbf{M}^{\top}\mathbf{M} - \mathbf{M}^{\top}\mathbf{NA}^{\top} - \mathbf{AN}^{\top}\mathbf{M} + \mathbf{AN}^{\top}\mathbf{NA}^{\top}) \ &= \text{tr}(\mathbf{M}^{\top}\mathbf{M}) - \text{tr}(\mathbf{M}^{\top}\mathbf{NA}^{\top}) - \text{tr}(\mathbf{AN}^{\top}\mathbf{M}) + \text{tr}(\mathbf{AN}^{\top}\mathbf{NA}^{\top}) \ &= \text{tr}(\mathbf{M}^{\top}\mathbf{M}) - \text{tr}(\mathbf{M}^{\top}\mathbf{NA}^{\top}) - \text{tr}((\mathbf{M}^{\top}\mathbf{NA}^{\top})^{\top}) + \text{tr}(\mathbf{N}^{\top}\mathbf{N}\mathbf{A}^{\top}\mathbf{A}) \ &= \text{tr}(\mathbf{M}^{\top}\mathbf{M}) - 2\text{tr}(\mathbf{M}^{\top}\mathbf{NA}^{\top}) + \text{tr}(\mathbf{N}^{\top}\mathbf{N}) \end{aligned} $$

注意到第一项 $\text{tr}(\mathbf{M}^{\top}\mathbf{M})$ 和第三项 $\text{tr}(\mathbf{N}^{\top}\mathbf{N})$ 都不依赖于 $\mathbf{A}$,因此最小化目标函数等价于最大化第二项 $\text{tr}(\mathbf{M}^{\top}\mathbf{NA}^{\top})$。

当我们有 SVD 分解 $\mathbf{M}^{\top}\mathbf{N} = \mathbf{UDV}^{\top}$ 时,可以将迹运算展开:

$$ \begin{aligned} \text{tr}(\mathbf{M}^{\top}\mathbf{NA}^{\top}) &= \text{tr}(\mathbf{UDV}^{\top}\mathbf{A}^{\top}) \ &= \text{tr}(\mathbf{UD}(\mathbf{AV})^{\top}) \ &= \text{tr}((\mathbf{AV})^{\top}\mathbf{UD}) \quad (\text{循环性质,左乘正交矩阵逆,右乘正交矩阵}) \ &= \sum_{i=1}^{d}[(\mathbf{AV})^{\top}\mathbf{U}]_{ii}d_i \end{aligned} $$

其中 $d_i$ 是矩阵 $\mathbf{D}$ 对角线上的第 $i$ 个元素,$d$ 是 $\mathbf{M}^{\top}\mathbf{N}$ 的非零奇异值的数量。

为了最大化上述和式,我们需要使 $(\mathbf{AV})^{\top}\mathbf{U}$ 的对角元素尽可能大。由于 $\mathbf{AV}$ 和 $\mathbf{U}$ 都是正交矩阵,因此 $(\mathbf{AV})^{\top}\mathbf{U}$ 也是正交矩阵,其对角元素的绝对值不能超过 1(否则对应的列 / 行的 $L_2$ 范数会超过 1)。

因此,该和式在所有 $(\mathbf{AV})^{\top}\mathbf{U}$ 的对角元素都等于 1 时达到最大值,即:

$$ \begin{aligned} (\mathbf{AV})^{\top}\mathbf{U} &= \mathbf{I} \ \mathbf{AV} &= \mathbf{U} \ \mathbf{A} &= \mathbf{UV}^{\top} \end{aligned} $$

后处理

正交 Procrustes 问题的基本约束 $\mathbf{A}^{\top}\mathbf{A} = \mathbf{I}$ 保证了 $\mathbf{A}$ 是一个正交矩阵。但正交矩阵即可以是旋转($\det \mathbf{A} = +1$),也可以是 反射 (改变手性,$\det \mathbf{A} = -1$)

所以,如果计算出的 $\det(\mathbf{UV}^{\top}) = -1$,表明 $\mathbf{UV}^{\top}$ 是一个反射。为了得到最接近的纯旋转,我们通过修改 SVD 中间对角矩阵 $\mathbf{D}$ 的最后一个元素符号来 “翻转” 这个反射。具体做法就是将解修正为:

$$ \hat{\mathbf{A}} = \mathbf{U}\begin{pmatrix} 1 & 0 & 0 \ 0 & 1 & 0 \ 0 & 0 & \det(\mathbf{UV}^{\top}) \end{pmatrix}\mathbf{V}^{\top} $$

直观上,这代表选择翻转关联性最弱的方向,是因为这样做对整体对齐效果(即 Frobenius 范数或等价的迹最大化目标)的影响是最小的。

位移求解

可以想到,一旦旋转矩阵确定,那么位移向量 $t$ 就非常好解了(计算变换前后差值即可)。

将一个变换矩阵转换为刚才说的正交 Procrustes 问题,也只需要对两个原始点集 $\mathbf{M}$ 和 $\mathbf{N}$ 分别减去各自的几何中心即可。

步骤:

  1. 中心化

    • 计算两个点集的质心:$\overline{\mathbf{M}}$(M 的均值), $\overline{\mathbf{N}}$(N 的均值)。
    • 得到中心化后的点集:$\tilde{\mathbf{M}} = \mathbf{M} - \overline{\mathbf{M}}$, $\tilde{\mathbf{N}} = \mathbf{N} - \overline{\mathbf{N}}$。
  2. 求解旋转 $\hat{\mathbf{R}}$:对中心化后的点集 $\tilde{\mathbf{M}}$ 和 $\tilde{\mathbf{N}}$ 应用 带约束的正交 Procrustes 算法 (要求 $\det(\mathbf{R})=+1$),求解最优旋转 $\hat{\mathbf{R}}$,使得 $\tilde{\mathbf{M}}^{\top} \approx \hat{\mathbf{R}}\tilde{\mathbf{N}}^{\top}$。

  3. 求解平移 $\hat{\mathbf{T}}$:利用已求得的 $\hat{\mathbf{R}}$ 和原始点集的质心计算最优平移: $$ \hat{\mathbf{T}} = \overline{\mathbf{M}^{\top} - \hat{\mathbf{R}} \mathbf{N}^{\top}} $$

问题

草,刚上完的计算机视觉导论还在追我!

对于 Outlier 较为敏感,使用 RANSAC 算法即可。

以下内容直接摘录自 CV 导论笔记,看过的可以直接跳。

最小二乘法(Least Square Method)

定义:假设有一组数据点 $(x_i, y_i)$,我们希望通过直线 $y = mx + b$ 拟合这些点。

其能量函数(损失函数)为:

$$ E = \sum_{i=1}^n (y_i - mx_i - b)^2 $$

not_roboust_outliner

最小二乘法的一个问题是对细微噪声 鲁棒(robust),但是对于 离群点(Outliers) 敏感。如图,为了照顾一个离群点,整个直线发生了很大的旋转。

RANSAC(RANdom SAmple Consensus,随机抽样一致算法)

动机:我们想要一个自动化的算法,可以确定离群点(outliers)并排除之。

想法:我们希望找到一条直线,使得这条直线有最多的内点(inliner)。

RANSAC loop:假设这条直线需要 2 个点(或选择 $n$ 个点,但选择最少的 2 个点可以保证这些点中没有 outliers 的概率最大)来确定:

  1. 随机选择 $k$ 组能确定这条直线的点(即从所有点中选出一个 $k \times 2$ 的矩阵)。
  2. 对每一组点计算出一条直线(使用 SVD)。
  3. 对每一组点的直线,计算所有点到这条直线的距离;若距离小于阈值,则认为该点是这条直线的内点(inliner)。
  4. 找到内点数量最多的直线,若数量超过阈值,则认为这条直线是最优的。
  5. 对最优直线,用其所有内点重新计算一次直线。
  6. 重复上述步骤,直到内点数量不再增加。

注意:此过程可推广到 $n$ 维空间,只需选择 $\geq n$ 个点来确定一个 $n-1$ 维的超平面。

实际上,从今天的视角来看,此循环(loop)不再必需,因为我们可以并行地提出所有假设(Hypothesis),CV 导论中将此留作作业。

Instance level

对物体级别的位姿变换预测,要求每个物体都已知(完整建模),典型算法如 PoseCNN,如果结合 ICP 算法可以在位移幅度较小的情况下更快的提升准确率(下节课详细讲)。

posecnn_result

Catagory level

对同一类别物体的位姿变换预测,这类物品通常具有共有结构,如茶杯具有相近的几何形状,可以用于定位(下节课详细讲)。

在同类别物体中进行泛化,但也因此受限,没见过的类别不行。

大小不知道,能给出旋转 Rotation 不能给平移 Translation,因为可能沿着物体光轴走,还是那个鸽子为什么这么大的问题,所以 Catagory level 必须要知道大小。

why_pigeon_so_big_2

那么如何在同一类别物体的不同尺寸之间进行泛化呢,答案是类似归一化的想法,把同一类别的东西缩放到一个标准的 1x1x1 box 内,将其几何中心归一化到 box 中心,从而统一他们的尺度。

💾

  •  

机器人学 III

2025年3月12日 10:26

运动规划

配置空间(Configuration Space)

定义:机器人的所有可能关节状态构成的抽象空间,记为 $\mathcal{C}-\text{space}$。

  • Q 表示法:$Q = (q_1, q_2, \ldots, q_n)$,其中 $q_i$ 为第 $i$ 个关节的位置参数(如角度或位移)。
  • 自由空间(Free Space)$\mathcal{C}_{\text{free}}$:不与障碍物碰撞的合法配置集合。
  • 障碍空间(Obstacle Space)$\mathcal{C}_{\text{obs}}$:与障碍物发生碰撞的非法配置集合。

路径规划问题:在 $\mathcal{C}{\text{free}}$ 中寻找从起点 $Q{\text{start}}$ 到目标 $Q_{\text{goal}}$ 的连续路径。

挑战:避障、长程规划、高维空间规划

碰撞检测(Collision Detection)

基本挑战

问题定义:给定一个 $q_{\text{pose}}$,判断机械臂是否与环境发生碰撞(collision)。也即判断其是在 $\mathcal{C}{\text{free}}$ 中还是在 $\mathcal{C}{\text{obs}}$ 中。

几何复杂度:机械臂与环境的高精度三维模型(如三角网格 / 面片,mesh)直接检测碰撞计算量很大。

计算瓶颈:检测两个含 $10^5$ 三角面片的模型是否碰撞需 $O(10^{10})$ 次面片相交判断。

球体包裹法(Bounding Spheres)

思想:用球体序列近似机械臂连杆(如下图)。

bounding_spheres

碰撞检测公式:两球体中心 $\mathbf{p}_i, \mathbf{p}_j$ 满足 $|\mathbf{p}_i - \mathbf{p}_j| \leq (r_i + r_j)$ 时碰撞。

优缺点:

  • 优点:计算高效($O(n^2)$ 复杂度,$n$ 为球体数)。
  • 缺点:保守性导致可行解丢失,限制了模型对于更精细物体的操作能力
    • 你不能通过球体近似抓起来一个很小的面片
    • 球体近似还可能导致虚假自碰撞(self-collision,即不同连杆之间的碰撞)

凸包分解(Convex Decomposition)

思想:将凹几何体分解为多个凸包(Convex Hull),利用凸包相交检测算法加速。

原因:检测多个凸起来的物体之间是否发生碰撞是很很高效的(类似之前的球体近似),但是检测凸起来的物体和凹进去的物体之间是否发生碰撞是比较困难的。

分类:

  • 凸包(Convex-Hull):生成单一的凸网格,效率最高但精度较低。
  • 精确凸分解(Exact Convex Decomposition):属于 NP-hard 问题,不实用,因为会产生大量的聚类。
  • 近似凸分解(Approximate Convex Decomposition, ACD):确定网格三角形的划分,使用最少的聚类数量,同时确保每个聚类的凹度低于用户定义的阈值。

convex_hull_mesh_decomposition

优缺点:

  • 优势:比球体更精确,减少保守性误差。
  • 缺点:凹形物体的高效凸分解仍是几何处理中的待研究问题。

insight:问题做不出来不一定是自己的问题,也有可能是更底层的 simulation 有问题。

运动规划算法

问题定义:既然已经有了检测 $q_{\text{pose}}$ 是否与环境发生碰撞的算法,那么接下来的任务就是在 $\mathcal{C}{\text{free}}$ 中找到一条从 $Q{\text{start}}$ 到 $Q_{\text{goal}}$ 的路径(路径上所有点都在 $\mathcal{C}_{\text{free}}$ 中)。

局限性

运动规划具有局限性,因为有些情况我们是可以容忍的,但会被之排除。

比如,我们的操作是具有弹性的,如用手去抓东西,尽管手会变形,但不影响可行性,然而基于碰撞检测的方法会将解排除。

即便如此,运动规划算法仍然具有其价值,因为对于很多基础问题,基于模拟的采样效率优于去真实环境中采集数据(RL),这能提供大量可行的轨迹数据,从而为 RL 提供数据来源。

概率路图法(Probabilistic Roadmap, PRM)

步骤:

  1. 采样:在 $\mathcal{C}_{\text{free}}$ 中随机生成 $N$ 个配置点 ${Q_1, Q_2, \ldots, Q_N}$。通常会在 $\mathcal{C}-\text{space} \subset \mathbb{R}^n$ 中对各个维度进行均匀离散化,然后随机采样。

    注意,这里暗含了对 $\mathcal{C}-\text{space}$ 的均匀采样必然也是对 $\mathcal{C}_{\text{free}}$ 的均匀采样(因为概率密度函数 PDF 恒为常数)。

  2. 建图:连接邻近点形成图结构,剔除与 $\mathcal{C}_{\text{obs}}$ 碰撞的边。

  3. 查询:在图搜索(如 A* 算法)中寻找 $Q_{\text{start}}$ 到 $Q_{\text{goal}}$ 的路径。

特点:预计算路图可复用,适合多查询场景。

伪代码(注意符号 $N,n$ 的定义与上文有所出入):

$$ \begin{array}{l} \textbf{function} \ \text{概率路线图}(n, k, q_{start}, q_{goal}) \ \textbf{returns} \ \text{一条从起点到目标的路径} \ \quad \text{// 输入:} n: \text{路线图中采样节点的数量}, k: \text{为每个配置检查的最近邻居数量}, q_{start}, q_{goal} \ \quad V \leftarrow {q_{start}, q_{goal}} \ \quad E \leftarrow \varnothing \ \quad \textbf{while} \ |V| < n \ \textbf{do} \ \quad \quad \textbf{repeat} \ \quad \quad \quad q \leftarrow \text{在}\ C \ \text{中的一个随机配置} \ \quad \quad \textbf{until} \ q \ \text{在} \ C_{free} \ \text{中} \ \quad \quad V \leftarrow V \cup {q} \ \quad \textbf{end} \ \quad \textbf{for each} \ q \in V \ \textbf{do} \ \quad \quad N_q \leftarrow \text{根据距离函数从} \ V \ \text{中选择的} \ q \ \text{的} \ k \ \text{个最近邻居} \ \quad \quad \textbf{for each} \ q' \in N_q \ \textbf{do} \ \quad \quad \quad \textbf{if} \ (q, q') \notin E \ \text{and} \ (q, q') \in C_{free} \ \textbf{then} \ \quad \quad \quad \quad E \leftarrow E \cup {(q, q')} \ \quad \quad \quad \textbf{end} \ \quad \quad \textbf{end} \ \quad \textbf{end} \ \quad \textbf{return} \ \text{使用 Dijkstra 算法寻找从} \ q_{start} \ \text{到} \ q_{goal} \ \text{的路径} \ \end{array} $$

如何判断一条线是否全在 $\mathcal{C}{\text{free}}$ 中,即 $(q, q') \in C{free}$?

答:在其上线性采样一些点(可以采用二分法加快尝试效率),然后判断这些点是否在 $\mathcal{C}{\text{free}}$ 中。如果都是,则认为这条线全在 $\mathcal{C}{\text{free}}$ 中。如果有任何一个点不在 $\mathcal{C}{\text{free}}$ 中,则认为这条线不在 $\mathcal{C}{\text{free}}$ 中。

高斯采样

考虑如下情形:

rpm_not_applicable

在这种情况下,如果仍然使用均匀采样,那么狭窄路径由于所占面积比例较小,其中点被采样到的概率也会非常小,导致难以求解。

所以我们需要使用 高斯采样

  1. 首先均匀生成样本点:在配置空间中均匀随机生成一个样本点 $q_1$
  2. 高斯分布生成第二个点:以 $q_1$ 为均值,$\sigma^2$ 为方差,从高斯分布 $\mathcal{N}(q_1, \sigma^2)$ 中生成第二个样本点 $q_2$
  3. 筛选添加条件:如果 $q_1 \in C_{\text{free}}$ 且 $q_2 \notin C_{\text{free}}$,则添加 $q_1$ 到图中

高斯采样中节点 $q_2$ 由节点 $q_1$ 的高斯分布 $\mathcal{N}(q_1, \sigma^2)$ 生成,避免了在 C 空间中的多次插值和碰撞检测,提高了采样效率

  • 太大的 $\sigma$ 难以对狭窄通道采样
  • 太小的 $\sigma$ 采样效率不高,且得到的采样点距离障碍物太近,容易和障碍物发生碰撞。

uniform_vs_gaussian_sampling

可以看到,这么采样之后,我们得到的点大多会分布在自由空间的边界附近,也即 边界偏好。通过这种方法,我们可获取地图中的连通信息,有更大的概率找到关键通路。

但是这种方式的弊端在于其 采样效率也有可能会降低,我们可能需要采样更多的次数才能找到足够多的、满足条件的点。而且仍然存在冗余,如凹陷、障碍物转角区域的路标点。

桥采样

桥采样是高斯采样的一种变体:

  1. 均匀生成 $q_1$
  2. 从高斯分布 $\mathcal{N}(q_1, \sigma^2)$ 生成 $q_2$
  3. 计算中点 $q_3 = (q_1 + q_2) / 2$
  4. 当 $q_1, q_2 \in C_{\text{obs}}$ 而 $q_3 \in C_{\text{free}}$ 时,添加中点 $q_3$

bridge_sampling

这种采样方式更适合在狭窄通道处构建 “桥梁”,但是问题是非窄桥的地方采样会更少了。

总结

上述采样方法各有优劣,所以一般情况下,我们会结合这几种采样方法,从而尝试尽可能的提高获得可行解的概率。

PRM 更适合场景是静态的情况,因为它对空间的覆盖很好,而这种情况下,任意重新给定起点和终点(如果不在图中,我们找到其最近的点然后尝试建边),我们就可以很快得到新的路径。

但如果场景是动态的,那么我们需要重新构建路图,效率就会降低。

快速扩展随机树(Rapidly-exploring Random Tree, RRT)

步骤:

  1. 生长树:从 $Q_{\text{start}}$ 出发,向随机采样点扩展树分支。
  2. 目标偏置:以 $1 - \beta$ 的概率向 $Q_{\text{goal}}$ 方向尝试扩展树,以 $\beta$ 的概率向随机采样点扩展树。
  3. 终止条件:树分支到达 $Q_{\text{goal}}$ 邻域。

这里利用了一些 RL 中的思想,即 平衡探索与利用(exploration vs exploitation)。我们固然希望更快的找到目标,但是如果我们只向目标扩展,那么我们可能会错过一些更好的路径,甚至根本找不到路径。这就要求我们在其中寻得一个平衡。

这也是为什么我们在算法中引入了一个参数 $\beta$,它控制了我们向目标扩展的概率。

rrt_pathfinding_algorithm_diagram

伪代码:

$$ \begin{array}{l} \textbf{function} \ \text{RRT 扩展算法}(n, \epsilon, \beta, q_{start}, q_{goal}) \ \textbf{returns} \ \text{一条从起点到目标的路径} \ \quad \text{// 输入:} n: \text{树中采样节点的数量}, \epsilon: \text{步长}, \beta: \text{采样目标点的概率}, q_{start}, q_{goal} \ \quad V \leftarrow {q_{start}} \ \quad E \leftarrow \varnothing \ \quad \textbf{for} \ i = 1 \rightarrow n \ \textbf{do} \ \quad \quad \textbf{if} \ rand(0, 1) < \beta \ \textbf{then} \ \quad \quad \quad q_{target} \leftarrow q_{goal} \ \quad \quad \textbf{else} \ \quad \quad \quad q_{target} \leftarrow \text{从} \ C_{free} \ \text{中均匀随机采样} \ \quad \quad \textbf{end} \ \quad \quad q_{near} \leftarrow \text{V 中离} \ q_{target} \ \text{最近的邻居} \ \quad \quad q_{new} \leftarrow q_{near} + \frac{\epsilon}{|q_{near}-q_{target}|}(q_{target} - q_{near}) \ \quad \quad \textbf{if} \ q_{new} \notin V \ \text{and} \ q_{new} \in C_{free} \ \text{and} \ (q_{near}, q_{new}) \in C_{free} \ \textbf{then} \ \quad \quad \quad V \leftarrow V \cup {q_{new}} \ \quad \quad \quad E \leftarrow E \cup {(q_{near}, q_{new})} \ \quad \quad \textbf{end} \ \quad \textbf{end} \ \quad \textbf{return} \ \text{使用 Dijkstra 算法寻找从} \ q_{start} \ \text{到} \ q_{goal} \ \text{的路径} \ \end{array} $$

RRT 方法需要根据问题和经验进行参数调节,这包括探索参数 $\beta$、步长 $\epsilon$ 和采样点数量 $n$。

  • 较大的 $\epsilon$:
    • 优点:加快树的扩展速度
    • 缺点:可能会跳过狭窄通道,导致路径不可行,导致难以在复杂环境中生成有效的新样本
  • 较小的 $\epsilon$:
    • 优点:更精确地探索空间
    • 缺点:扩展速度慢,生成大量的节点增加计算负担,增加迭代次数

RRT-Connect

RRT-Connect 是对基本 RRT 算法的一种改进,具有以下特点:

  1. 双向树生长:同时从起点 $q_{start}$ 和目标点 $q_{goal}$ 分别生长两棵树,而不是只从起点生长一棵树,这样可以加快搜索效率。
  2. 定向生长策略:让两棵树相向生长,每棵树扩展的目标会选择另一棵树最近更新的叶子节点而不是根节点,这大大提高了两棵树相连接的效率
  3. 贪婪扩展:使用多种 $\epsilon$ 步长进行更贪婪的树扩展,而不是单步扩展,加速树的生长速度

这种双向搜索策略显著提高了路径规划的效率,尤其是在复杂环境中。

捷径算法(Shortcutting)

RRT 和 RRT-Connect 不是渐近最优的(即使采样无限多,也不能保证找到最优路径)

  • PRM(概率路线图)算法具有渐近最优性,但需要海量采样才能实现
  • PRM 和 RRT 常产生不自然的 "抖动" 路径(下图图 1),缺乏平滑性

shortcutting

捷径算法:通过直接连接路径上不相邻的点(如果连线在自由空间中),尝试消除不必要的弯路,是一种已经得到了可行路径的后处理方法。

多次重启

单次 RRT 之后多次 Shortcutting,效果不一定会变好,因为这可能仅仅是平滑了一下路径,但是没有根本性地优化掉冗余的主干路径。

所以,我们可以尝试多次 RRT,并对多条可行路径并行地进行优化(即 Shortcutting),然后再从中选择最优的路径,从而规避局部最优解。

比如下面这张图,实际上上面存在更优解,但是单次 RRT/RRT-Connect 找到的是下面的次优解。这种情况下单纯使用 Shortcutting 是无效的。

shortcutting_not_applicable

控制系统

控制系统的核心目标

在机器人系统中,控制论的核心任务是 将已知的理想行为完美执行。而控制系统本质是对一些你不知道、无法避免的 error 进行一种反馈。因为现实不存在说到做到,总是会有误差的存在。

开环与闭环控制

开环控制(Feedforward, FF):直接执行预设动作,认为 FK(前向运动学)是没有误差的,所以它依赖精确建模但缺乏误差修正能力。

简而言之:就像闭着眼睛做事一样

  • 不使用状态估计器,即不会估计系统当前的真实状态
  • 没有反馈机制,因此容易受到噪声和外部干扰影响
  • 依靠 预先设定 的启发式方法来尝试达到目标状态

ff

闭环控制(Feedback, FB):引入实时反馈,构建反馈回路。

  • 能够有效地达到并维持期望状态
  • 可以主动抵抗外部干扰的影响,稳定本来不稳定的系统

fb

控制系统的性能评价

我们总是希望能够尽快达到理想状态并保持在该状态。

  • 最小化稳态(Steady-State)误差
  • 最小化调节时间,快速达到稳态
  • 最小化稳态附近的振荡

性能评价指标

首先,定义误差函数(Error Function):

  • 期望状态:$\theta_d$(destination)
  • 当前状态:$\theta$
  • 误差:$\theta_e = \theta_d - \theta$

然后,就可以定义性能评价指标:

  1. 稳态误差(Steady-State Error):表示系统到达稳态后的残余误差

    $$ e_{ss} = \lim_{t\to\infty} \theta_e(t) $$

    理想系统应满足 $e_{ss}=0$

  2. 调节时间(Settling Time):误差首次进入并保持在 $\pm 2%$ 误差带所需时间

  3. 超调量(Overshoot):系统响应超过稳态值的程度,最开始过去不算 $$ \text{overshoot} = |a/b| \times 100% $$ 其中,$a$ 表示最大偏移量,$b$ 表示最终稳态值

performance_evaluation_metrics

P 控制(Proportional Control)

在控制系统中,P 控制是将错误信号转换为命令的基本方法,控制信号与误差大小成正比。

  • $\theta(t)$:$t$ 时刻系统实际状态
  • $\theta_d(t)$:期望状态(目标状态)
  • $\theta_e(t)$:误差状态,$\theta_e(t) = \theta_d(t) - \theta(t)$
  • $K_p$:比例系数

比例控制的基本表达式

$$ P = K_p\theta_e(t) $$

一阶形式

当控制信号改变状态的导数(即控制速度信号)时:

$$ \dot{\theta}(t) = P = K_p\theta_e(t) $$

根据误差定义和状态导数关系:

$$ \theta_e(t) = \theta_d(t) - \theta(t) \ \dot{\theta}_e(t) = \dot{\theta}_d(t) - \dot{\theta}(t) $$

将控制方程代入:

$$ \dot{\theta}_e(t) = \dot{\theta}_d(t) - K_p\theta_e(t) $$

如果期望状态以恒定速度移动:

$$ \dot{\theta}_d(t) = c $$

则误差动态方程为:

$$ \dot{\theta}_e(t) + K_p\theta_e(t) = c $$

首先求解特征方程:

$$ \dot{\theta}_e(t) + K_p\theta_e(t) = 0 $$

求解过程(以防有同学已经忘光了 ODE):

$$ \begin{aligned} \dot{\theta}_e(t) &= -K_p\theta_e(t) \ \frac{\mathrm{d}\theta_e(t)}{\mathrm{d}t} &= -K_p\theta_e(t) \ \frac{\mathrm{d}\theta_e(t)}{\theta_e(t)} &= -K_p \mathrm{d}t \ \int \frac{\mathrm{d}\theta_e(t)}{\theta_e(t)} &= -K_p \int \mathrm{d}t \ \ln|\theta_e(t)| &= -K_p t + C_1 \ |\theta_e(t)| &= e^{-K_p t + C_1} = e^{C_1} \cdot e^{-K_p t} \ C &= e^{C_1} \ |\theta_e(t)| &= C \cdot e^{-K_p t} \ \end{aligned} $$

得到齐次方程的通解:

$$ \theta_e(t) = Ce^{-K_pt} $$

其中 $C$ 为常数。

然后观察原始方程,容易发现特解:

$$ \theta_{A} = \frac{c}{K_p} $$

所以通解为:

$$ \theta_e(t) = \frac{c}{K_p} + Ce^{-K_pt} $$

应用初始条件 $\theta_e(0)$ 确定常数 $C$:

$$ \theta_e(0) = C + \frac{c}{K_p} \Rightarrow C = \theta_e(0) - \frac{c}{K_p} $$

最终,我们得到:

$$ \theta_e(t) = \frac{c}{K_p} + \left(\theta_e(0) - \frac{c}{K_p}\right)e^{-K_pt} $$

结论分析

  1. 当 $c=0$(目标静止)时:

    $$ \theta_e(t) = \theta_e(0)e^{-K_pt} $$

    误差呈指数衰减至零,系统最终收敛到目标状态。

  2. 当 $c\neq0$(目标移动)时:

    • 随着 $t\rightarrow\infty$,$e^{-K_pt}\rightarrow0$
    • 稳态误差:$\lim_{t\rightarrow\infty}\theta_e(t) = \frac{c}{K_p}$
    • 系统存在永久稳态误差,误差大小与目标速度 $c$ 成正比,与比例增益 $K_p$ 成反比,所以增大 $K_p$ 可以减小稳态误差

二阶形式

如果控制信号改变状态的二阶导数(力或力矩信号):

$$ \ddot{\theta}(t) = P = K_p\theta_e(t) $$

则会导致状态振荡且不稳定。

PI 控制(Proportional-Integral Control)

PI 控制结合了比例控制和积分控制:

$$ PI = K_p \theta_e(t) + K_i \int_0^t \theta_e(\tau) \mathrm{d}\tau $$

其中:

  • $K_p$:比例系数
  • $K_i$:积分系数
  • $\theta_e(t)$:误差

如果控制信号作用于状态导数(如速度信号):

$$ \dot{\theta}(t) = PI = K_p \theta_e(t) + K_i \int_0^t \theta_e(\tau) \mathrm{d}\tau $$

定义误差导数 $\dot{\theta}_e(t) = \dot{\theta}_d(t) - \dot{\theta}(t)$,也即 $\dot{\theta}_d(t) = \dot{\theta}_e(t) + \dot{\theta}(t)$,两边求导得到:

$$ \ddot{\theta}_d(t) = \ddot{\theta}_e(t) + K_p \dot{\theta}_e(t) + K_i \theta_e(t) $$

如果 $\ddot{\theta}_d(t) = 0$(目标加速度为零),动态方程化为:

$$ \ddot{\theta}_e(t) + K_p \dot{\theta}_e(t) + K_i \theta_e(t) = 0 $$

这是一个二阶常系数齐次微分方程。

PPT 上没有,回忆一下高数:

对于齐次线性常系数二阶微分方程:

$$ y'' + py' + qy = 0, $$

其特征方程为:

$$ \lambda^2 + p\lambda + q = 0, $$

特征根 $\lambda_1, \lambda_2$ 的不同情况对应微分方程的通解如下:

  1. 两相异实根 $\lambda_1, \lambda_2$:

    $$ y = C_1 e^{\lambda_1 x} + C_2 e^{\lambda_2 x}. $$

  2. 二重根 $\lambda_1$:

    $$ y = (C_1 + C_2 x)e^{\lambda_1 x}. $$

  3. 共轭复根 $\lambda_{1,2} = a \pm i\beta$: $$ y = e^{ax}(C_1 \cos \beta x + C_2 \sin \beta x). $$

解的形式由方程特征根决定,特征方程为:

$$ r^2 + K_p r + K_i = 0 $$

其解的形式决定系统的阻尼特性:

  1. 过阻尼 (Overdamped,下图 I):两个实根,系统缓慢收敛。
  2. 临界阻尼 (Critically damped,下图 II):双重实根,快速无振荡收敛。
  3. 欠阻尼 (Underdamped,下图 III):共轭复根,系统振荡收敛。

overdamped_critical_underdamped

P 控制与 PI 控制比较

  1. P 控制

    • 仅能消除静态误差(目标静止时)。
    • 对于目标移动(如恒定速度),存在稳态误差,在下图中,可以看到 P 控制没有最后和 $\theta$ 存在一个恒定差距,$\theta_e \to c \neq 0$
  2. PI 控制

    • 通过积分项消除稳态误差,在下图中,可以看到 PI 控制没有最后可以和 $\theta$ 重合,$\theta_e \to 0$
    • 对恒定速度目标控制效果更好(可以消除稳态误差),但对复杂轨迹不能完全消除。

pi_vs_p_control

PI 控制通过引入积分项,解决了 P 控制中的稳态误差问题,但会引入更多复杂性(如可能的振荡)。调整 $K_p$ 和 $K_i$ 的值可改变系统性能,如响应速度和稳定性。

PD 控制(Proportional-Derivative Control)

PD 控制结合了比例控制和微分控制:

$$ PD = K_p \theta_e(t) + K_d \frac{\mathrm{d}}{\mathrm{d}t}\theta_e(t) $$

其中:

  • $K_p$:比例系数
  • $K_d$:微分系数
  • $\theta_e(t)$:误差
  • $\frac{\mathrm{d}}{\mathrm{d}t}\theta_e(t)$:误差变化率

根据误差定义 $\theta_e(t) = \theta_d(t) - \theta(t)$,可得:

$$ \ddot{\theta}_e(t) = \ddot{\theta}_d(t) - \ddot{\theta}(t) $$

将误差加速度表达式代入控制方程:

$$ \ddot{\theta}_e(t) = K_p \theta_e(t) + K_d \dot{\theta}_e(t) $$

重新整理得到:

$$ \ddot{\theta}_e(t) + K_d \dot{\theta}_e(t) + K_p \theta_e(t) = \ddot{\theta}_d(t) $$

如果 $\ddot{\theta}_d(t) = 0$(目标加速度为零),动态方程简化为:

$$ \ddot{\theta}_e(t) + K_d \dot{\theta}_e(t) + K_p \theta_e(t) = 0 $$

后续类似 PI 控制,但 $K_p$ 位置有所改变。

解的形式由方程特征根决定,特征方程为:

$$ r^2 + K_d r + K_p = 0 $$

根据特征根的性质,系统表现出不同的动态行为:

  1. 过阻尼:两个实根,系统无振荡地缓慢收敛。
  2. 临界阻尼:二重实根,系统以最快速度无振荡收敛。
  3. 欠阻尼:一对共轭复根,系统呈振荡收敛状态。

PID 控制(Proportional-Integral-Derivative Control)

PID 控制结合了 P、I、D 三种控制方式:

$$ PID = K_p \theta_e(t) + K_i \int_0^t \theta_e(\tau)\mathrm{d}\tau + K_d \frac{\mathrm{d}}{\mathrm{d}t}\theta_e(t) $$

比例项(Proportional)

$K_p$ 控制当前状态

$$ u_P(t) = K_p \theta_e(t) $$

  • $K_p$ 增大可 加快响应速度,因为我们会更希望快速减少 $\theta_e(t)$
  • 单独使用会产生稳态误差(P 控制),如机械臂关节受摩擦力时无法完全归零

积分项(Integral)

$K_i$ 控制历史累积

$$ u_I(t) = K_i \int_0^t \theta_e(\tau)\mathrm{d}\tau $$

  • 对持续误差进行累积补偿,消除稳态误差

微分项(Derivative)

$K_d$ 预测未来趋势

$$ u_D(t) = K_d \frac{\mathrm{d}}{\mathrm{d}t}\theta_e(t) $$

  • 与误差的变化率成正比,抑制超调和振荡
  • 当误差增加时提供更强的控制作用
  • 当误差减小时提供更温和的控制作用

总结

调高各个系数的影响:

| 参数(Parameter) | 上升时间(Rise time) | 超调量(Overshoot) | 调节时间(Settling time) | 稳态误差(Steady-state error) | 稳定性(Stability) | | ----------------- | --------------------- | ------------------- | ------------------------- | ------------------------------ | ------------------- | | $K_p$ | 减小 | 增大 | 小变化 | 减小 | 变差 | | $K_i$ | 减小 | 增大 | 增加 | 消除 | 变差 | | $K_d$ | 小变化 | 减小 | 减小 | 理论上无影响 | 如果 $K_d$ 小则改善 |

仿真实现

$$ \text{force} = \text{stiffness} * (\text{targetPosition} - \text{Position}) + \text{damping} *((\text{targetVelocity} - \text{Velocity})) $$

  • Stiffness(刚度) 类似于 $k_p$ (比例增益),用于调整位置误差的影响。
  • Damping(阻尼) 类似于 $k_d$ (微分增益),用于调整速度误差的影响。

💾

  •  

机器人学 II

2025年3月5日 17:53

四元数

[!TIP]

强烈推荐参考 Krasjet / Quaternion 以获得直观且详细的性质证明推导。

小小的吐槽:王老师上节课明明刚说四元数不重要不要求掌握,结果这节课花了绝大部分时间来推导 hhh。

定义

四元数是复数的推广,表示为:

$$ q = w + xi + yj + zk $$

其中:

  • $w$ 是实数部分;
  • $x, y, z$ 是虚数部分;

$i, j, k$ 是虚数单位,满足以下关系:

$$ i^2 = j^2 = k^2 = ijk = -1 $$

反交换性质:

$$ ij = k = -ji, \quad jk = i = -kj, \quad ki = j = -ik $$

向量形式

$$ q = (w, \bold{v}), \quad \bold{v} = (x, y, z) $$

运算性质

乘法:对于两个四元数 $q_1 = (w_1, \bold{v}_1)$ 和 $q_2 = (w_2, \bold{v}_2)$,其乘法定义为:

$$ \begin{aligned} q_1 q_2 &= (w_1 w_2 - \bold{v}_1^{\top} \bold{v}_2, , w_1 \bold{v}_2 + w_2 \bold{v}_1 + \bold{v}_1 \times \bold{v}_2) \ &= (w_1 w_2 - \bold{v}_1 \cdot \bold{v}_2, , w_1 \bold{v}_2 + w_2 \bold{v}_1 + \bold{v}_1 \times \bold{v}_2) \end{aligned} $$

这被称为 Graßmann 积。

注意:四元数的乘法 不可交换,即 $q_1 q_2 \neq q_2 q_1$。

共轭

$$ q^* = (w, -\bold{v}) $$

模长

$$ |q|^2 = w^2 + \bold{v}^{\top} \bold{v} = qq^* = q^*q $$

$$ q^{-1} = \frac{q^*}{|q|^2} $$

这是模长的直接推论。

几何意义与应用

单位四元数:若四元数的模长为 $1$,即 $|q| = 1$,则称其为单位四元数。单位四元数可表示三维空间中的旋转。其还具有性质 $q^{-1} = q^*$。

纯四元数:若四元数的实部为 $0$,即 $q = (0, \bold{v})$,则称其为纯四元数。纯四元数可以表示三维空间中的向量。

旋转表示:任何一个旋转,都可以表示为绕某个单位向量 $\hat{\omega}$ 旋转 $\theta$ 角度(证明见后)。

那么,对应的四元数可以表示为:

$$ q = \left[\cos\frac{\theta}{2}, \sin\frac{\theta}{2} \hat{\omega}\right] $$

注意,旋转到四元数存在 “双重覆盖” 关系,我们可以很容易地发现:

$$ \begin{aligned} q &= \left[\cos\frac{\theta}{2}, \sin\frac{\theta}{2} \hat{\omega}\right] \ -q &= \left[-\cos\frac{\theta}{2}, -\sin\frac{\theta}{2}\hat{\omega}\right] \ &= \left[\cos(\pi - \frac{\theta}{2}), \sin(\pi - \frac{\theta}{2}) (-\hat{\omega})\right] \end{aligned} $$

是等价的($-q$ 意味着同一旋转轴但是翻转正方向,然后旋转 $2\pi - \theta$)。

double_coverage

相应地,从四元数恢复轴角表示:

$$ \theta = 2 \arccos(w), \quad \hat{\omega} = \begin{cases} \frac{\bold{v}}{\sin(\theta/2)}, & \theta \neq 0 \ 0, & \theta = 0 \end{cases} $$

其中,$w$ 是单位四元数的实部,四元数的一种常见表示就是 $(w,x,y,z)$。

四元数与旋转

向量旋转:任意向量 $\mathbf{v}$ 沿着以 单位向量 定义的旋转轴 $\mathbf{u}$ 旋转 $\theta$ 度得到 $\mathbf{v}'$,那么:

令向量 $\mathbf{v}$ 的四元数形式 $v = [0, \mathbf{v}]$,旋转四元数 $q = \left[\cos\left(\frac{\theta}{2}\right), \sin\left(\frac{\theta}{2}\right)\mathbf{u}\right]$

则旋转后的向量 $\mathbf{v}'$ 可表示为:

$$ \mathbf{v}' = qv q^* = qv q^{-1} $$

如果是给定四元数 $q$ 旋转向量 $\mathbf{v}$ ,那么设 $q = [w, \mathbf{r}]$ 是单位四元数(即 $w^2 + |\mathbf{r}|^2 = 1$),向量 $\mathbf{v}$ 的四元数形式为 $v = [0, \mathbf{v}]$。

则:

$$ \begin{aligned} qvq^* &= [w, \mathbf{r}][0, \mathbf{v}][w, -\mathbf{r}] \ &= [ - \mathbf{r} \cdot \mathbf{v}, w\mathbf{v} + \mathbf{r} \times \mathbf{v} ][w, -\mathbf{r}] \ &= [0, (1-2|\mathbf{r}|^2)\mathbf{v} + 2(\mathbf{r} \cdot \mathbf{v})\mathbf{r} + 2w(\mathbf{r} \times \mathbf{v})] \end{aligned} $$

最后一个等式的展开计算如下

实部:

$$ \begin{aligned} &= (- \mathbf{r} \cdot \mathbf{v})w - (w\mathbf{v} + \mathbf{r} \times \mathbf{v}) \cdot (-\mathbf{r}) \ &= -w (\mathbf{r} \cdot \mathbf{v}) + w (\mathbf{v} \cdot \mathbf{r}) + (\mathbf{r} \times \mathbf{v}) \cdot \mathbf{r} \ &= 0 \quad \end{aligned} $$

虚部:

$$ \begin{aligned} &= (- \mathbf{r} \cdot \mathbf{v})(-\mathbf{r}) + w (w\mathbf{v} + \mathbf{r} \times \mathbf{v}) + (w\mathbf{v} + \mathbf{r} \times \mathbf{v}) \times (-\mathbf{r}) \ &= (\mathbf{r} \cdot \mathbf{v})\mathbf{r} + w^2 \mathbf{v} + w (\mathbf{r} \times \mathbf{v}) - w (\mathbf{v} \times \mathbf{r}) - (\mathbf{r} \times \mathbf{v}) \times \mathbf{r} \ &= (\mathbf{r} \cdot \mathbf{v})\mathbf{r} + w^2 \mathbf{v} + 2w (\mathbf{r} \times \mathbf{v}) - \big[ (\mathbf{r} \cdot \mathbf{r})\mathbf{v} - (\mathbf{v} \cdot \mathbf{r})\mathbf{r} \big] \ &= (1 - 2|\mathbf{r}|^2)\mathbf{v} + 2(\mathbf{r} \cdot \mathbf{v})\mathbf{r} + 2w (\mathbf{r} \times \mathbf{v}) \end{aligned} $$

其中利用了叉乘展开式:

$$ a \times b \times c = (a \cdot c)b - (a \cdot b)c $$

以及单位四元数约束条件 $w^2 + |\mathbf{r}|^2 = 1$,将 $w^2 = 1 - |\mathbf{r}|^2$ 代入后合并同类项。

接下来证明这个结果与罗德里格旋转公式等价即可。

$$ qvq^* = [0, (1-2|\mathbf{r}|^2)\mathbf{v} + 2(\mathbf{r} \cdot \mathbf{v})\mathbf{r} + 2w(\mathbf{r} \times \mathbf{v})] $$

我们有:

  • $w = \cos(\frac{\theta}{2})$
  • $\mathbf{r} = \sin(\frac{\theta}{2})\mathbf{u}$,且 $\mathbf{u}$ 是单位向量,$|\mathbf{u}| = 1$。

所以:

$$ \begin{aligned} 1 - 2|\mathbf{r}|^2 &= 1 - 2\sin^2\left(\frac{\theta}{2}\right) = \cos(\theta) \ \ 2(\mathbf{r} \cdot \mathbf{v})\mathbf{r} &= 2 \left(\sin\left(\frac{\theta}{2}\right)(\mathbf{u} \cdot \mathbf{v})\right) \left(\sin\left(\frac{\theta}{2}\right)\mathbf{u}\right) \ &= 2 \sin^2\left(\frac{\theta}{2}\right) (\mathbf{u} \cdot \mathbf{v}) \mathbf{u} \ &= (1 - \cos(\theta)) (\mathbf{u} \cdot \mathbf{v}) \mathbf{u} \ \ 2w(\mathbf{r} \times \mathbf{v}) &= 2 \cos\left(\frac{\theta}{2}\right) \left(\sin\left(\frac{\theta}{2}\right)(\mathbf{u} \times \mathbf{v})\right) \ &= \left(2 \sin\left(\frac{\theta}{2}\right) \cos\left(\frac{\theta}{2}\right)\right) (\mathbf{u} \times \mathbf{v}) \ &= \sin(\theta) (\mathbf{u} \times \mathbf{v}) \end{aligned} $$

将以上结果代回到 $\mathbf{v}'$ 的表达式中:

$$ \begin{aligned} \mathbf{v}' &= (1-2|\mathbf{r}|^2)\mathbf{v} + 2(\mathbf{r} \cdot \mathbf{v})\mathbf{r} + 2w(\mathbf{r} \times \mathbf{v}) \ &= (\cos(\theta))\mathbf{v} + (1 - \cos(\theta)) (\mathbf{u} \cdot \mathbf{v}) \mathbf{u} + (\sin(\theta)) (\mathbf{u} \times \mathbf{v}) \end{aligned} $$

正是罗德里格旋转公式的结果。

旋转组合:两个旋转 $q_1$ 和 $q_2$ 的组合等价于四元数的乘法:

$$ q_2 (q_1 x q_1^) q_2^ = (q_2 q_1) x (q_1^* q_2^*) $$

虽然四元数不满足交换律,但其满足结合律(可以证明四元数存在对应的四维矩阵,所以矩阵的性质也是四元数的性质)。

注意:

  • 四元数的旋转表示具有 $3$ 个自由度(四个参数加一个单位模长约束)。
  • 几何上,单位四元数可以看作 $4$ 维球面 $S^3$ 的壳。

四元数与旋转矩阵

从四元数到旋转矩阵

因为我们有 $\mathbf{v}' = q \mathbf{v} q^{-1}$ (这里假设 $\mathbf{v}$ 是向量, $q$ 是单位四元数, $\mathbf{v}'$ 是旋转后的向量,并且我们将向量 $\mathbf{v}$ 视为纯四元数 $[0, \mathbf{v}]$ 进行运算),我们可以计算出对应的旋转矩阵为:

令单位四元数 $q = w + x\mathbf{i} + y\mathbf{j} + z\mathbf{k} = [w, (x, y, z)]$,则旋转矩阵 $R(q)$ 为:

$$ R(q) = \begin{bmatrix} 1 - 2y^2 - 2z^2 & 2xy - 2zw & 2xz + 2yw \ 2xy + 2zw & 1 - 2x^2 - 2z^2 & 2yz - 2xw \ 2xz - 2yw & 2yz + 2xw & 1 - 2x^2 - 2y^2 \end{bmatrix} $$

证明:使用三个基向量挨个求就行。

令 $\mathbf{r} = (x, y, z)$。

令 $\mathbf{v} = \mathbf{e}_1 = (1, 0, 0)$。

  • $|\mathbf{r}|^2 = x^2 + y^2 + z^2$
  • $\mathbf{r} \cdot \mathbf{e}_1 = x$
  • $\mathbf{r} \times \mathbf{e}_1 = (x, y, z) \times (1, 0, 0) = (0, z, -y)$

$$ \begin{aligned} \mathbf{v}'_1 &= (1-2(x^2+y^2+z^2))\mathbf{e}_1 + 2x\mathbf{r} + 2w(\mathbf{r} \times \mathbf{e}_1) \ &= (1-2x^2-2y^2-2z^2)(1, 0, 0) + 2x(x, y, z) + 2w(0, z, -y) \ &= (1-2x^2-2y^2-2z^2 + 2x^2, 2xy + 2wz, 2xz - 2wy) \ &= (1 - 2y^2 - 2z^2, 2xy + 2zw, 2xz - 2yw) \end{aligned} $$

这就是矩阵 $R$ 的第一列。

令 $\mathbf{v} = \mathbf{e}_2 = (0, 1, 0)$。

  • $\mathbf{r} \cdot \mathbf{e}_2 = y$
  • $\mathbf{r} \times \mathbf{e}_2 = (x, y, z) \times (0, 1, 0) = (-z, 0, x)$

$$ \begin{aligned} \mathbf{v}'_2 &= (1-2(x^2+y^2+z^2))\mathbf{e}_2 + 2y\mathbf{r} + 2w(\mathbf{r} \times \mathbf{e}_2) \ &= (1-2x^2-2y^2-2z^2)(0, 1, 0) + 2y(x, y, z) + 2w(-z, 0, x) \ &= (2xy - 2wz, 1-2x^2-2y^2-2z^2 + 2y^2, 2yz + 2wx) \ &= (2xy - 2zw, 1 - 2x^2 - 2z^2, 2yz + 2xw) \end{aligned} $$

这就是矩阵 $R$ 的第二列。

令 $\mathbf{v} = \mathbf{e}_3 = (0, 0, 1)$。

  • $\mathbf{r} \cdot \mathbf{e}_3 = z$
  • $\mathbf{r} \times \mathbf{e}_3 = (x, y, z) \times (0, 0, 1) = (y, -x, 0)$

$$ \begin{aligned} \mathbf{v}'_3 &= (1-2(x^2+y^2+z^2))\mathbf{e}_3 + 2z\mathbf{r} + 2w(\mathbf{r} \times \mathbf{e}_3) \ &= (1-2x^2-2y^2-2z^2)(0, 0, 1) + 2z(x, y, z) + 2w(y, -x, 0) \ &= (2xz + 2wy, 2yz - 2wx, 1-2x^2-2y^2-2z^2 + 2z^2) \ &= (2xz + 2yw, 2yz - 2xw, 1 - 2x^2 - 2y^2) \end{aligned} $$

这就是矩阵 $R$ 的第三列。

将 $\mathbf{v}'_1, \mathbf{v}'_2, \mathbf{v}'_3$ 作为列向量组合起来,就得到了图片中给出的旋转矩阵 $R(q)$:

$$ R(q) = \begin{bmatrix} 1 - 2y^2 - 2z^2 & 2xy - 2zw & 2xz + 2yw \ 2xy + 2zw & 1 - 2x^2 - 2z^2 & 2yz - 2xw \ 2xz - 2yw & 2yz + 2xw & 1 - 2x^2 - 2y^2 \end{bmatrix} $$

证毕。

从旋转矩阵到四元数

根据上一步结果,旋转矩阵 $R$ 的迹(trace)满足:

$$ \text{tr}(R) = 3 - 4(x^2 + y^2 + z^2) = 4w^2 - 1 $$

我们可以计算四元数的分量为:

$$ \begin{aligned} w &= \frac{\sqrt{\text{tr}(R)+1}}{2} \ x &= \frac{R_{32}-R_{23}}{4w} \ y &= \frac{R_{13}-R_{31}}{4w} \ z &= \frac{R_{21}-R_{12}}{4w} \end{aligned} $$

其中 $R_{ij}$ 表示矩阵 $R$ 的第 $i$ 行第 $j$ 列的元素。这些公式在 $w \neq 0$ 时有效。

四元数的距离

这部分证明亦可参见 Krasjet / Quaternion 第 4 节・四元数插值(第 37 页)。

在单位三维球面 $S^3$ 上,或两个四元数 $(q_1, q_2)$ 之间的角度:

$$ \langle p, q \rangle = \arccos(p \cdot q) $$

证明:设 $p = (p_w, \mathbf{p}_v)$ 和 $q = (q_w, \mathbf{q}_v)$,那么显然,从 $p$ 旋转到 $q$ 的相对旋转可以由四元数乘法 $\Delta q = q p^*$ 表示。

$$ \begin{aligned} \Delta q &= q p^* \ &= (q_w, \mathbf{q}_v)(p_w, -\mathbf{p}_v) \ &= (q_w p_w - \mathbf{q}_v \cdot (-\mathbf{p}_v), q_w(-\mathbf{p}_v) + p_w \mathbf{q}_v + \mathbf{q}_v \times (-\mathbf{p}_v)) \ &= (q_w p_w + \mathbf{q}_v \cdot \mathbf{p}_v, \dots) \end{aligned} $$

所以,$\Delta q$ 的实部 $\text{Re}(\Delta q) = q_w p_w + \mathbf{q}_v \cdot \mathbf{p}_v$。

这正好是 $p$ 和 $q$ 作为 4D 向量的点积 $p \cdot q$。

$$ \text{Re}(\Delta q) = p \cdot q = \cos \langle p, q \rangle\ \langle p, q \rangle = \arccos(p \cdot q) $$

对应旋转之间的距离:

$$ \text{dist}(p, q) = 2 \arccos(|p \cdot q|) $$

或等价地:

$$ \text{dist}(p, q) = 2 \min {\langle p, q \rangle, \langle p, -q \rangle} $$

这里需要在两个值之间取最小值的原因也可以参见 Krasjet / Quaternion 第 5.4 节・双倍覆盖带来的问题(第 46 页)。

回顾之前四元数与旋转的关系,不难得知两个旋转 $(R_1, R_2)$ 的距离与其对应四元数 $q(R_1)$ 和 $q(R_2)$ 在球面上的距离成线性关系(前者是后者的两倍)。

unit_circle_and_rotation_diagram

四元数插值

这部分证明可以参见 Krasjet / Quaternion 第 5 节・四元数插值(第 41 页)。

线性插值(Linear Interpolation, Lerp)

$$ q(t) = (1-t)q_1 + tq_2 $$

lerp

归一化线性插值(Normalized Linear Interpolation, Nlerp)

$$ q(t) = \frac{(1-t)q_1 + tq_2}{|(1-t)q_1 + tq_2|} $$

省流:就是除个模长,让他恢复为单位四元数。

nlerp

球面线性插值(Spherical Linear Interpolation, Slerp)

以上两种插值都有问题,他们实际上是线性切分了弦长,而不是弧长,这会导致在转动的时候的角速度不均匀:

nlerp

所以,我们要引入新的插值方式,这就是球面线性插值(Spherical Linear Interpolation, Slerp):

$$ q(t) = \frac{\sin((1-t)\theta)}{\sin(\theta)} q_1 + \frac{\sin(t\theta)}{\sin(\theta)} q_2 $$

其中 $\theta$ 是 $q_1$ 和 $q_2$ 之间的夹角,$\theta = \arccos(q_1 \cdot q_2)$。

slerp

证明的一个方法在 Krasjet / Quaternion 第 5.3 节・球面线性插值(第 43 页)。

不过老师的 Slide 上有另一种更简单直观的利用三角函数性质的证明方法:

vector_geometry_angle_diagram

$$ \begin{aligned} \alpha+\beta&=\psi\ \mathbf{v}(t)&=w_0\mathbf{v}0+w_1\mathbf{v}1\ \frac{\sin\alpha}{w_1}&=\frac{\sin\beta}{w_0}=\frac{\sin(\pi-\psi)}1=\sin\psi\ w{0}&=\frac{\sin\beta}{\sin\psi}\ w{1}&=\frac{\sin\alpha}{\sin\psi}\ \psi&=\cos^{-1}(\mathbf{v}_0\cdot\mathbf{v}_1) \end{aligned} $$

第三个式子利用了三角形的性质:

$$ \frac{A}{\sin\alpha}=\frac{B}{\sin\beta}=\frac{C}{\sin\gamma} $$

球面均匀采样

考虑我们如何随机采样一个旋转。

引理:在 $\mathbb{SO}(3)$ 中均匀采样旋转矩阵等价于从单位四元数的集合 $\mathbb{S}(3)$ 中均匀采样。

原因:两个旋转之间的距离与对应的四元数在单位球面上的距离成线性关系。

那么,如何均匀采样 $\mathbb{S}(3)$ 呢?

方法:从四维标准正态分布 $\mathcal{N}(0, I_{4 \times 4})$ 中随机采样一个变量,并将其归一化,从而得到(直接解释为)单位四元数。

原因:由于标准正态分布是各向同性的(即在所有方向上均匀分布),所以采样得到的单位四元数在 $\mathbb{S}(3)$ 中也是均匀分布的。

随后,采样得到的单位四元数也就可以转换为对应的旋转矩阵(如果需要)。

有趣的事实

对于神经网络来讲,最好的旋转表示方法是 9 个数的旋转矩阵。因为其他的表示方法均可能出现对于输入的微小扰动,即一个小的旋转,出现一个跳变,而只有最初最冗余的 $\mathbb{R}^{3\times3}$ 旋转矩阵保证其必然是连续的( 即连续性 ),而这对于神经网络是很好的性质。

各旋转表示方式对比

| Representation | Inverse? | Composing? | Any local movement in SO(3) can be achieved by local movement in the domain? | | --------------- | ----------- | ----------- | ---------------------------------------------------------------------------- | | Rotation Matrix | ✔️ | ✔️ | N/A | | Euler Angle | Complicated | Complicated | No | | Angle-axis | ✔️ | Complicated | ? | | Quaternion | ✔️ | ✔️ | ✔️ |

  • 旋转矩阵:可逆、可组合(矩阵连乘)、但在 $\mathbb{SO}(3)$ 上移动不直接(9 D - 6 约束 = 3DoF)
  • 欧拉角:逆向复杂、组合复杂、因为 Gimbal lock 的存在,与 $\mathbb{SO}(3)$ 不能平滑映射
  • 轴角:可逆、组合复杂、大部分情况下可以与 $\mathbb{SO}(3)$ 平滑映射,但是在边界情况(如旋转 $0$ 度时)不行
  • 四元数:完美

运动规划

形式化表述

配置空间 (Configuration Space)

定义:配置空间(Configuration spcae,C-space)是 $ \mathbb{R}^n $ 的一个子集,包含系统的所有可能状态(即状态空间)。

  • $C$:配置空间,表示所有可能状态的集合。
  • $C_{\text{free}} \subseteq C$:自由空间,包含所有有效状态(无碰撞)。
  • $C_{\text{obs}} \subseteq C$:障碍空间,表示有障碍的无效状态。
  • $C_{\text{free}} \cup C_{\text{obs}} = C$
  • $C_{\text{free}} \cap C_{\text{obs}} = \varnothing$

问题定义

configuration_space_pathfinding

给定:

  • 自由空间 $C_{\text{free}}$。
  • 起始状态 $q_{\text{start}} \in C_{\text{free}}$。
  • 目标状态 $q_{\text{goal}} \in C_{\text{free}}$。

目标:计算一系列动作,使机器人从 $q_{\text{start}}$ 移动到 $q_{\text{goal}}$。

注意,这里的符号 $q$ 不是四元数(quaternion)的意思,其是配置空间中的一个点,即状态。

例如,对于一个机械臂,其配置空间可能是 $\mathbb{R}^n$,那么 $q$ 就是关节的角度组合之一 $(\theta_1, \theta_2, \dots, \theta_n)$。

挑战

  1. 避免障碍物:确保路径始终在 $C_{\text{free}}$ 内。
  2. 长规划时间:路径可能较长,需要优化。
  3. 高维空间:配置空间维度可能很高(例如多关节机器人)。

💾

  •  
❌