场景开篇:一个比硬币还小的广告拦截器
你拆开快递,里面是一个指甲盖大小的开发板——ESP32-C3 SuperMini,淘宝 14 块钱包邮。你把它插到路由器的 USB 口上,刷个固件。30 秒后,你家里所有连 WiFi 的设备——手机、平板、智能电视、甚至冰箱——都不再看到广告。
这不是 Pi-hole。Pi-hole 需要一台树莓派($35)或至少一个有 PSRAM 的 ESP32($8)。这个项目叫 esp32-c3-adblock,跑在一颗 $2 的芯片上,没有 PSRAM,只有 4MB 闪存和 400KB 内存。它拦住了 53.7 万个广告域名。
怎么做到的?用一个所有人都会但没人想到用的技巧:把域名存成 40 位哈希,放闪存里,二分搜索。
核心洞察:闪存替代内存
大多数 ESP32 DNS 拦截器的工作方式是:把广告域名列表(字符串)加载到 RAM 里。一个域名平均 20 字节,14 万个域名就是 2.5MB。ESP32-C3 只有 400KB RAM——装不下。所以这些项目需要 PSRAM 扩展内存,成本上去了。
esp32-c3-adblock 的作者 M-Abozaid 做了一个简单的计算:
| 方案 | 硬件 | 14万域名占用 | RAM 占用 | 查找方式 |
|---|---|---|---|---|
| 字符串存 RAM | ESP32+PSRAM (~$8) | 2.5MB RAM | 大部分 | 字符串比较 |
| 哈希存 Flash | ESP32-C3 (~$2) | 0.67MB Flash | ~50KB | 二分搜索 ~10ms |
关键转换:域名 → 40 位 FNV-1a 哈希 → 排序存入闪存 → 二分搜索。
一个域名 20 字节,一个 40 位哈希 5 字节。空间压缩到 1/4。14 万个哈希排序后存入闪存,占 0.67MB。查找时,对查询域名做同样的哈希,在排序数组里二分搜索——log2(140000) ≈ 17 次比较,每次读 5 字节闪存,总共约 85 字节闪存读取。加上 WiFi 往返,整个查找 ~10ms 完成。
为什么是 40 位?
这是整个项目最精妙的决策。哈希位数的选择是一个空间-碰撞权衡:
- 32 位:省 20% 闪存,但在 25 万域名时会有 ~7 个碰撞(误拦截)
- 40 位:141k 域名 0 碰撞,537k 域名约 1 个碰撞
- 64 位:浪费 3 字节/域名,解决一个不存在的问题
碰撞遵循生日界限(Birthday Bound):n 个物品放入 d 个桶里,碰撞概率约 n²/d。40 位哈希空间是 2^40 ≈ 1.1 万亿。在 53.7 万域名时,碰撞数 ≈ (537000²) / (2 × 2^40) ≈ 0.13,期望值约 1 个。
这意味着什么?在 53.7 万个广告域名中,大约有 1 个正常域名会被误拦截——它的哈希恰好和某个广告域名的哈希相同。用户可以手动加白名单。这是一个完全可接受的代价,换来的是 4 倍的空间压缩。
作者的原话:"Dropping to 32 bits would save 20% of the flash but cost ~7 collisions at 250k; going to 64 bits wastes 3 bytes per domain to solve a problem you don't have."(降到 32 位省 20% 闪存但在 25 万域名时会有 7 个碰撞;升到 64 位浪费 3 字节/域名来解决一个你不存在的问题。)
这不只是 C3 的变通方案
一个常见的误解是:这个方案只是因为 ESP32-C3 没有 PSRAM 所以被迫用的妥协。作者明确反驳了这一点:
"The same trick works on bigger chips — it isn't a C3 workaround. On a 16 MB ESP32-S3 these hashes hold ~2.7M domains vs ~466k for strings in 8 MB of PSRAM. Hashes in flash beat strings in PSRAM basically everywhere; the C3 just makes it undeniable."
在更强的 ESP32-S3 上(16MB 闪存 + 8MB PSRAM),哈希方案能存 270 万域名,字符串方案只能存 46.6 万。哈希在闪存中击败字符串在 PSRAM 中——在任何硬件上。 C3 只是让这个结论无法否认,因为它是唯一一个字符串方案根本跑不起来的硬件。
这是一个很好的例子:约束催生更好的设计。如果作者一开始就用有 PSRAM 的 ESP32,他可能永远不会想到哈希方案,而是满足于"字符串存 RAM"的常规做法。C3 的内存约束逼他找到了一个在所有硬件上都更优的方案。
工作流程
整个系统的数据流非常清晰:
DNS 查询进来
↓
提取域名
↓
FNV-1a 哈希(+ 父域名后缀,处理子域名)
↓
在闪存哈希表中二分搜索
├─ 命中 → 返回 0.0.0.0(黑洞)
└─ 未命中 → 转发给上游 DNS,回复给客户端
子域名处理很巧妙:查询 ads.example.com 时,会同时哈希 ads.example.com 和 example.com,因为广告域名列表里可能只有父域名 example.com,但你想拦截所有子域名。
工程细节
项目还有一些值得注意的工程决策:
WiFi 配网:首次启动如果连不上 WiFi,会开一个 AP C3-AdBlock-XXXX,手机连上去通过 captive portal 配网。之后换网络可以在 Web 面板点"Forget WiFi"重新配。不需要重新刷固件。
OTA 更新:固件和广告域名列表都支持 WiFi OTA 更新。一次 USB 刷写之后,所有更新都可以远程完成。
Web 面板:有认证保护的 Web 面板,可以手动加/删拦截域名、上传自定义列表、更新固件。/ban、/addblock、/upload、/update 等状态变更端点都需要密码。
自定义列表:build_blocklist.py 支持 hosts 文件、纯域名列表、AdGuard/Adblock 规则三种格式。AdGuard 的 ||ads.example.com^ 拦截,@@||ok.example.com^ 白名单。正则、通配符、$ 修饰符、## 美化规则会被跳过(DNS 哈希表表达不了这些)。
3D 打印外壳:项目附带了一个 STL 文件,可以给 C3 SuperMini 打一个外壳。注意事项:不需要支撑,0.2mm 层高,15% 填充。天线端不要被塑料覆盖——C3 的 PCB 天线是 USB-C 口对面的锯齿形铜走线,埋进塑料或靠近金属会降低信号。
被媒体报道的项目
这个项目已经被 Tom's Hardware、XDA Developers、Korben 等媒体报道。这不是一个玩具项目——它是一个可以日常使用的工具,成本低于一杯咖啡。
更大的启示:约束即设计
esp32-c3-adblock 给我们的启示不只是"如何做 DNS 拦截器"。它是一个关于约束与设计的教科书案例:
- 约束催生创新:C3 没有 PSRAM,逼出了哈希方案,结果在所有硬件上都更优
- 正确的抽象层次:不需要存完整域名,只需要存足够区分的指纹——40 位哈希就是域名的指纹
- 量化权衡:不是"哈希好还是字符串好",而是"在 40 位时碰撞 1 个、32 位时碰撞 7 个、64 位时浪费 3 字节"——每个选择都有明确的数据支撑
- 诚实的设计:项目明确告诉你 537k 域名时会有 1 个误拦截,而不是假装完美
这和布隆过滤器(Bloom Filter)的哲学一脉相承——用概率数据结构换取空间效率。但 esp32-c3-adblock 更进一步:它用确定性数据结构(排序哈希数组 + 二分搜索)实现了零假阳性(在 141k 规模下),同时仍然比布隆过滤器更节省空间(布隆过滤器需要 ~10 位/元素,这里只需要 40 位/元素 = 5 字节/元素,141k 元素只需 0.67MB)。
下一次你面对一个"内存不够"的问题时,想想这个项目:也许你不需要更多内存,你需要的是换一种数据表示。
讨论回复
加载中...正在加载回复...
推荐
智谱 GLM-5 已上线
我正在智谱大模型开放平台 BigModel.cn 上打造 AI 应用,智谱新一代旗舰模型 GLM-5 已上线,在推理、代码、智能体综合能力达到开源模型 SOTA 水平。