Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ C++23 现代 BusyBox 替代品(单二进制,123 applet,399 GTest,418 KB
- [document/ai/ROADMAP.md](document/ai/ROADMAP.md) — Phase 全树 + 状态(薄索引,链向 [document/todo/](document/todo/))
- [document/ai/PLAN.md](document/ai/PLAN.md) — 当前焦点批级进度(批级,最易变)
- [document/ai/CODING-TASTE.md](document/ai/CODING-TASTE.md) — 编码/注释风格权威(写代码前读)
- [document/ai/COVERAGE.md](document/ai/COVERAGE.md) — 测试/正确性/差分标尺(覆盖率怎么算、怎么防退化,季级)
- [document/notes/](document/notes/) — 批级工作记录(`<date>-<topic>.md`)

## 始终遵守(每条便宜,违规代价大)
Expand Down
61 changes: 61 additions & 0 deletions document/ai/COVERAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# CFBox 覆盖率与正确性 标尺

> 这份文档讲的是工作流程——怎么确认程序是对的、怎么防止它以后悄悄变错。大概一个季度才动一次。它放在 `document/ai/` 下,从 [CLAUDE.md](../../CLAUDE.md) 和 [DIRECTIVES.md](DIRECTIVES.md) 指过来,和 [CODING-TASTE.md](CODING-TASTE.md)(编码风格那篇)是平级的。
>
> 一句话:正确性是这种嵌入式产品的命根子。这份文档只管两件事——"怎么知道它是对的" 与 "怎么防止它悄悄变错"。

## 一、我们的基本态度(三条)

**1. "覆盖率" 其实有三种意思,别混成一团:**

- **代码行/分支覆盖率**(用 gcov 这类工具量的那种):这只是个参考信号,告诉你"哪些代码分支从来没被任何测试碰到过",帮我们重构时找方向。它**证明不了程序是对的**,所以不拿它当合并的硬性门槛。
- **功能正确性**(程序行为到底对不对):这才是真正顶用的柱子。单元测试(GTest)、集成测试、还有和 BusyBox/GNU 比对输出,都属于这一层。
- **端到端测试**(让 cfbox 当 1 号进程、在模拟器里把整个系统启动起来):能让我们更有信心,但目前只是浅浅跑一下,以后再加深。

**2. 拿参考实现当 "标准答案":**
现在 "所有测试通过" 只能说明 "cfbox 的输出和写测试的人当初想的一样",**说明不了 "和 BusyBox/POSIX 一样"**。所以要做**对照测试**:把 cfbox 的输出和 BusyBox 或系统自带 coreutils 的输出逐字节比——后者就是 "标准答案"。`competition/busybox`(仓库里那棵完整 BusyBox 源码树)就是这个标准答案的来源。

**3. 设数量下限,而且只许升不许降:**
测试数量只能增加、不能偷偷删;新加的 applet 必须带测试;覆盖率指标等稳定下来之后,也只许升不许降。

## 二、每次改正确性相关的代码,走这四步

1. **造真实场景**:拿真实的用户用法、真正跑得多的路径来测,别写那种没意义的空转循环。
2. **和标准答案对齐**:先在本地拿对照测试当安全网(改完代码跑一遍,看输出有没有偏离 BusyBox/GNU);挂到 CI 上是后面的事。
3. **保证行为不变**:单元测试 + 对照测试都过了才合并。
4. **加进回归集**:把新测试固定下来,防止以后又被改坏。

## 三、参考答案(标准答案)怎么选

- **冲突时怎么取舍**(沿用 [compatibility-policy.md](../todo/compatibility-policy.md)):POSIX 标准第一 > BusyBox 的高频行为 > GNU 的增强选项。比如 `ls -l` 的格式要对齐 BusyBox,而不是 GNU coreutils 那种更花哨的。所有 "故意不一样" 的差异都要写进文档。
- **起步先用系统自带的 coreutils**(`/usr/bin/cat`、`/usr/bin/sort` 这些):零成本、机器上就有;而且 cat/head/tail/wc/sort 这几个流式命令,GNU 和 BusyBox 的输出基本一样。起步的目标只是 "抓输出漂移",不是 "认证 POSIX 合规"。`competition/busybox/testsuite/` 里那 69 个现成的测试用例,当输入素材补充进来。
- **升级**:等遇到必须对齐 BusyBox 的命令(ls、find 这些),再编译 BusyBox 来当标准答案。
- **和性能维度的关系(重要)**:将来一旦编译了 BusyBox,**同一份 BusyBox 二进制顺手也拿来当性能对比的基线**——比 cfbox 和 busybox 谁快、用 perf 看热点差异。一份二进制,既当正确性标准答案,又当性能参照。(性能那篇标尺还没立,到时候接上。)
- **必须有 "可接受的差异" 白名单**:对照测试要配一个 `tests/differential/known_diffs.yaml`,把 "本来就预期会不一样" 的地方(比如 grep 用 POSIX 正则、ls 列宽、df 列顺序)标成 "可接受的差异"。不然第一天跑起来全是噪音,没人会再看它。

## 四、检查门:哪些会挡合并,哪些只提醒

**会挡住合并的硬性检查(CI 里不通过就红):**

- **测试数量下限**:用 `ctest -N` 数出测试个数,必须 ≥ `tests/expected_count.txt` 里记的数;[run_all.sh](../../tests/integration/run_all.sh) 里那个 `total_pass`(现在是个没人用、从不增加的死变量)要重新启用、加一个 "至少 N 个通过" 的下限。目的:防止有人偷偷删测试文件或删测试用例。
- **新 applet 必须带测试**:CI 检查 "注册表(APPLET_REGISTRY)里每个命令名,都得有对应的 `test_<名字>.cpp` 或 `test_<名字>.sh`"。堵住 "现在有 24~29 个命令一个测试都没有" 这种缺口继续扩大。
- **对照冒烟测试**:对 5 个流式命令(cat/head/tail/wc/sort)跑 "三样一起比"——标准输出、错误输出、退出码。**一开始只报告、不挡**(跑出差异只记录、不阻断合并),稳定几周后再升级成硬门。

**只提醒、不挡的软性检查:**

- **代码覆盖率**:Debug 构建加上 `--coverage` 编译开关,用 gcovr 或 lcov 生成报告。**只看数据、不设门槛**;等稳定了再走 "只升不降"。

## 五、附录:CFBox 现在的实际情况(2026-06-28 查出来的)

- **测试家底**:436 个 GTest 单元测试 + 56 个集成测试(但都是 "自己写预期值" 的那种)+ **0 个对照测试 + 0 个覆盖率插桩**。
- **缺口**:24~29 个命令完全没有测试(ar/cpio/df/ed/install/mkfifo/mknod/pgrep/pidof/tee/sysctl/who 这些);没有测试数量下限门([run_all.sh:13](../../tests/integration/run_all.sh#L13) 的 `total_pass` 是死变量,只有 `total_fail` 被检查);集成测试里有 21 处用的是 "包含某段子串就算过" 的宽松匹配,会把列宽、数字对齐这种格式漂移漏过去;**[compatibility-policy.md 第 76-96 行](../todo/compatibility-policy.md#L76-L96)明明白白规定了一堆命令的退出码该怎么返回,但集成测试绝大多数只看标准输出、不看退出码**——规定和测试对不上。
- **已知的真 bug**:`sort -n -r` 的排序比较器违反了 C++ 对排序器的要求(strict-weak-ordering,属于未定义行为),[sort.cpp:69-77](../../src/applets/sort.cpp#L69-L77)。这是本维度第一个要修的,而且按规矩必须等有了对照测试之后才动手修。
- **CI 的隐患**:CI 的 native 那个任务,命令里没写 `-DCMAKE_BUILD_TYPE=Debug`([ci.yml:31](../../.github/workflows/ci.yml#L31)),结果 ASan/UBSan(地址/未定义行为检查)实际上**根本没在 CI 里跑**——本该有的安全网其实没张开。(这条归到性能/基建那篇标尺。)
- **标准答案资产在手但没接上**:`competition/busybox` 是完整的 BusyBox 源码树,还自带 `testsuite/` 里 69 个现成测试;phase-0a 那份文档把对照测试的整套设计都写好了,但一行代码都没落地。

## 六、确认这份标尺之后,按这个顺序干活

- **第 1 批**:搭最小的对照测试(5 个流式命令,拿系统 coreutils 当标准答案)+ 配 "可接受差异" 白名单。
- **第 2 批**:加 "测试数量下限" 门 + "新 applet 必须带测试" 门。
- **第 3 批**:在对照测试的保护下修 `sort -n -r` 那个未定义行为 bug,并补上和 coreutils `sort -n -r` 对照的用例。
- (覆盖率插桩、编译 BusyBox 当性能基线,等到了性能维度再做。)
1 change: 1 addition & 0 deletions document/ai/DIRECTIVES.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
- 命名:类型 `PascalCase`、函数/变量 `snake_case`、私有成员后缀 `_`、命名空间 `cfbox::*`(`base`/`fs`/`io`/`args`/`stream`/`applet`/`help`/`term`/`tui`/`proc`/`compress`/`checksum`/`awk`/`sh`/`init`/`utf`/`util`)、宏 `CFBOX_*`(`UPPER_SNAKE`)。
- 注释一律英文;机械风格以 [.clang-format](../../.clang-format) 为准,跑 clang-format 不手调。
- Result:`auto v = CFBOX_TRY(expr)`(宏展开判 `!v` 后 `return std::unexpected`);成功 `return value;` / `Result<void>` 用 `return {};`;失败 `return std::unexpected(base::make_error(code, msg));`。
- 测试 / 覆盖率 / 差分(对照)正确性标尺见 [COVERAGE.md](COVERAGE.md):三种"覆盖率"语义、对照测试(标准答案)、测试数量只升不降。

## C. 操作模型(长期,Claude 主力开发)

Expand Down
146 changes: 146 additions & 0 deletions document/promo-v0.3.0.zh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# 一个月后的CFBox:CFBox v0.3.0发布,从QEMU的玩具走上了i.MX6ULL的真机启动

> 一个用现代 C++23 重写的 BusyBox 替代品。单二进制、零运行时依赖、能当 PID 1。
> 从 v0.1.0 到 v0.3.0,它从一个「概念验证」长成了「能在真实硬件上开始尝试替代 BusyBox 的工具集」。

---

## TL;DR

CFBox 用 C++23(`std::expected`、禁异常/RTTI、手写 deflate 替代 zlib)把 BusyBox 那套核心命令重做了一遍,编译出来**一个 418 KB 的单文件**,通过符号链接分发 123 个命令。v0.3.0 这次,我们让它**真的在 NXP i.MX6ULL 板子上当 PID 1,替代了 BusyBox**。

---

## v0.1.0 → v0.3.0,发生了什么

v0.2.0是一次小更新,所以的话我们跟v0.1.0的比。

先把两张牌亮在桌面上,所有数字都是我从仓库里现扒的——注册表条目数、`ctest` 实跑、size-opt strip 之后的体积,不是拍脑袋:

| 指标 | v0.1.0 | v0.3.0 | 变化 |
|------|:------:|:------:|------|
| Applet 数 | 109 | **123** | +14 |
| GTest 单元测试 | 331 | **399** | +68 |
| size-opt 二进制 | 446 KB | **418 KB** | **−28 KB(−6.3%)** |
| 每 applet 均摊 | ~4.1 KB | **~3.4 KB** | 更省 |
| PID 1 验证 | QEMU aarch64 演示 | **i.MX6ULL 真机端到端** | 质变 |
| armhf 静态构建 | — | **~1.2 MB(自包含,直跑当 PID 1)** | 新增 |

你大概率会先盯上第三行——命令多了 14 个,测试多了 68 个,二进制反而瘦了 28 KB。说实话这不太符合直觉,通常加功能就是往大了长,加着加着就膨胀了。但这回我们反着走,靠的不是砍东西,是 v0.2.0 那阵子把 C++ 这些年欠下的「隐性体积税」给补交了。这个事单独拎出来讲。

---

## 命令多了,二进制反而瘦了

先把最显眼的一个拎出来:iostream。这玩意儿在 C++ 二进制里是出了名的体积大头,但凡你 `#include` 过一次 `<iostream>`,链接器就给你拽进来一大坨 locale、缓冲、格式化的运行时。v0.2.0 那轮我们下了狠手,把它整个家族——`<fstream>`、`<sstream>`、`<iostream>`——全量干掉,一个 fstream 符号都不留,文件读写全换回 `<cstdio>` 的 `FILE*` 配 `fgets` / `fprintf`。光这一刀,体积就松了一大截。

再一个是 `std::stoi`。这东西在 `-fno-exceptions` 底下藏着一个特别阴的坑:字符串解析失败的时候,它不给你返回错误码,而是直接 `abort()` 把整个进程干掉。放在一个普通工具里顶多是难用,但 CFBox 是要当 PID 1 的——init 进程被一个非法数字直接带走,那场面不要太刺激。所以全量换成 `std::strtol` 配 errno,雷拆了,异常运行时那一份开销也顺手省了。

剩下还有两块收尾的活。一块是把 100 多个 applet 各写各的 `fprintf(stderr, "cfbox xxx: ...")` 收敛到一个 `CFBOX_ERR` 宏,去重了一堆重复的格式化代码;另一块是给 `fs_util` 补上 `chown` / `lchown` / `for_each_entry()`,让 chmod、chown、chgrp 这几个不再各自把递归遍历重写一遍。

你会发现,这几件事没一件是「加功能」,全是把之前随手写的、能跑就行的那部分拧紧。但加起来的效果就是这么直白:命令越加越多,二进制反倒越来越小。这也是我们敢拿一个 C++ 项目,去和纯 C 的 BusyBox 掰体积的底气所在。

---

## 从 QEMU 里跑,到 i.MX6ULL 上板当 PID 1

这大概是 v0.3.0 花力气最多的一块,也是整个版本真正想交付的东西。

我们回头看 v0.1.0,`init` 这个 applet 其实早就有了,但老实说它当时更像一个「演示」——只在 QEMU aarch64 里把启动流程跑过一遍,本质上还是模拟器里的玩具。你真要把它搁到一块板子上、让它从上电那一刻起就作为 PID 1 把整个用户态管起来,中间差的那段路,远比「写一个能解析 inittab 的 init」要长得多。这一次,我们把这段路走通了:在 NXP i.MX6ULL(Cortex-A7,armhf)上,CFBox 作为 [imx-forge](../projects/imx-forge-demo) rootfs 的 PID 1 和工具集,撑起了完整一条启动闭环,实打实替代了 BusyBox。

为了把这条链路补全,我们新增了六个 applet,正好一个萝卜一个坑,卡在启动链的每个缺口上:

| 启动阶段 | CFBox 承担 |
|---------|-----------|
| PID 1 | `init` —— 解析 busybox 格式 `/etc/inittab`,支持 `sysinit` / `askfirst` / `respawn` / `ctrlaltdel` / `shutdown` |
| rcS | `mount -a` / `mount -t devpts devpts /dev/pts` / `mdev -s`(冷启动扫 `/sys` 建 `/dev` 节点) |
| console | `askfirst` → 打印 `Please press Enter to activate this console.` → 回车 → 进 CFBox `sh` |
| 关机 | `umount -a -r` / `swapoff -a` / `reboot` |

新加进来的这六个分别是:`mount`(`-a`/`-t`/`-o`,会读 fstab)、`mdev`(`-s` 冷启动扫描,照 `/sys` 给 `/dev` 补节点)、`umount`(`-a`/`-r`/`-f`)、`swapoff`,以及 `reboot` 和 `poweroff`。

启动起来之后的验证,是真的在板子上敲的那种,不是跑分:

- `/proc/cpuinfo` 老老实实打印出 `ARMv7 Processor rev 5 (v7l)`,确认这玩意儿确实跑在 i.MX6ULL 上
- 回车进 CFBox 自己的 `sh`,`ls` / `cat` / `df` / `ps` / `uname` / `free` 全部正常 dispatch,没一个是 BusyBox 在背后代劳
- 从上电、rcS、askfirst 拉起 console,一直到关机那一下的 `umount -a -r` / `swapoff -a` / `reboot`,整条链路里 BusyBox 一次都没有介入

![cfbox on i.MX6ULL: rcS → askfirst → console](screenshots/enter_shell.png)

事情到这里,CFBox 才算从「一个挺能打的练手项目」迈过了「能塞进真实嵌入式系统当组件用」那道坎。

---

## CFBox 到底想证明什么

说句心里话,CFBox 一直想验证的就一件事:用现代 C++,也能写出和 BusyBox 一样小、一样快、搞不好还更安全一点的 box。

手段其实都很笨、很基础,但每一条都是实打实省开销的。我们全局禁了异常和 RTTI(`-fno-exceptions -fno-rtti`),错误全部走 `cfbox::base::Result<T>`(就是 `std::expected`)配 `CFBOX_TRY`,没有异常运行时那一坨零额外成本;对外零运行时依赖,压缩这边自己手写了一套轻量 deflate/inflate 把 zlib 替了,单文件静态链接就能扔出去。

性能这边也跑了一下,不是 PPT 上的数:

| 操作 | 数据规模 | 耗时 |
|------|---------|------|
| `grep -c` | 10 MB | 54 ms |
| `cat` | 10 MB | 63 ms |
| `wc` | 10 MB | 17 ms |
| `sort` | 100K 行 | 32 ms |
| `diff` | 100K 行(相似文件) | 79 ms |

这里头 grep、cat、wc 全是流式处理,喂 `/dev/urandom` 这种无限流不会内存爆炸;diff 用的是 Myers O(ND) 算法;grep 和 sed 用 POSIX 的 `regex_t` 顶掉了又慢又重的 `std::regex`;sort 则是预计算好排序 key,避免比较器里反复分配。工程纪律这块也不想糊弄:399 个 GTest 加 54 套集成脚本全绿,ASan 零泄漏,编译零 warning(`-Werror` 顶着),CI 覆盖原生构建、armhf / aarch64 交叉编译,以及 QEMU 的用户模式和系统模式。

---

## 拉个坐标看看自己站在哪

| 项目 | 语言 | 体积 | Applets | 体积/Applet |
|------|------|------|---------|-------------|
| **CFBox (size-opt)** | **C++23** | **418 KB** | **123** | **~3.4 KB** |
| Toybox | C | ~500 KB | 238 | ~2.1 KB |
| BusyBox (full) | C | ~1.7 MB | 274 | ~9 KB |
| uutils/coreutils | Rust | ~11 MB | ~100 | ~110 KB |

CFBox 比 BusyBox 小 3 到 4 倍,而且在差不多这个体积下,该有的东西一样没落下:一个完整的 awk 解释器、一整套归档工具(tar/cpio/ar/unzip/gzip)、diff/patch、一整套进程工具(ps/top/pstree/pgrep/pmap),外加一个内置的 TUI 框架。

---

## 顺手填的几个坑

除了启动这条主线,v0.3.0 还顺手收拾了几个让 CFBox 在真终端上能正常用的坑。

一个是 `tail -f`。说真的,一个 box 要是没有 follow 模式,看日志基本没法用,v0.1.0 那会儿这功能是缺的。这次补的是 fd-based 的 follow:`fstat` 轮询配 64 KiB 的 quantum,`-F` 带一个 drain-switch 应对日志轮转,收到 SIGINT 就干净退出,而不是甩一个错误码出来恶心你。

另一个坑就特别有画面感了:串口控制台上,退格键按了不擦字。你想想,在串口终端上一个字一个字敲命令,敲错了一个退不掉,那血压直接拉满。根因其实不复杂,是 termios 里那个 `VERASE` 没设对,默认值不是 Ctrl-H,所以在串口上根本触发不了擦除。设成 `VERASE=Ctrl-H` 就好了。顺带把 cooked tty、默认 PATH、`ls -l` 的 owner/group 显示这几样也一起修了。

---

## 想自己跑一下的话

```bash
# 原生构建 + 测试
cmake -B build && cmake --build build
ctest --test-dir build --output-on-failure # 399 个单元测试
./build/cfbox echo "Hello, World!"

# armhf 静态构建(自包含,可以直接当 PID 1 跑)
cmake -B build-armhf-static \
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchain/Toolchain-armhf.cmake \
-DCMAKE_BUILD_TYPE=Release \
-DCFBOX_OPTIMIZE_FOR_SIZE=ON \
-DCFBOX_STATIC_LINK=ON
cmake --build build-armhf-static -j$(nproc) # 产物 ~1.2 MB
```

GitHub:<https://github.com/Awesome-Embedded-Learning-Studio/CFBox>,License 是 MIT。

---

## 接下来干嘛

v0.3.0 把「能当 PID 1」这条路打通了,接下来我们回到 Phase 2 的核心命令深化:`cp -a` 归档模式、全面的 POSIX `test`、`ls -R` 和 `--color`、`grep -A/-B/-C`、`find` 的布尔表达式、`sh` 的 case / heredoc / 函数。再往后就是 Phase 3 的网络最小闭环了。

我们的思路一直没变:不靠堆 applet 数量凑数,而是按真实运维频率,把那些高频命令的功能深度一点点做到位——场景闭环优先于数量。

---

走到真机上这一版,可以庆祝一下了。CFBox 是个会长期长下去的事情,欢迎来 GitHub 提 issue、提 PR,哪怕只是来聊聊「C++ 到底能不能写好一个 box」也行。完结撒花。
Loading
Loading