海思 Hi3798MV100 C 语言 DNS 广告过滤方案
原创 本文遵循CC 4.0 BY-SA版权协议
声明:本项目为独立开发的技术原型,仅用于嵌入式网络编程学习研究;项目和AdGuard及其官方产品无任何从属、衍生关系。Adblock仅指代通用过滤规则语法格式。
本文介绍基于C语言从零实现轻量级DNS广告过滤器,专为海思Hi3798MV100等低算力ARMv7嵌入式设备优化。参考主流Adblock风格DNS过滤产品的业务模型,拆分为26个模块,采用桩化开发、逐模块接入策略,在x86环境验证后交叉编译至armhf平台。关键技术包括异步上游DNS、Adblock语法过滤引擎、线程安全规则热更新及JSON Web API。实测显著降低CPU与内存开销,规避Go语言实现版本在老旧硬件上的GC抖动与OOM问题。
代码放在:https://gitee.com/aiguodexiaolou/adg-c-demo 大框架已经拉起来了,属于毛坯原型,大家可以根据自身需求继续完善细节。 C语言移植性强,可适配多种嵌入式设备,因此选择原生C作为开发语言。 本工程为C语言独立原型实现,并非基于任何第三方Go源码复制或二次衍生开发。 ⚠️重要说明:本项目属于技术原型 Demo,是独立开发的轻量DNS过滤实现,和AdGuard官方产品无关。配套 C 源码正在整理,仓库会持续更新。
当前广告规则硬编码写在源码内部,如果修改过滤规则需要重新编译程序; 后端 JSON API 完整可用,机顶盒端不自带网页管理后台,直接访问 ip:8080 返回404;网页仪表盘需要在本地电脑运行配套脚本使用; 仅适配海纳思 HiNAS Ubuntu20.04 hi3798mv100 armhf;不兼容普通 OpenWrt 固件。
本文记录一次完整的实战工程:在海思Hi3798MV100老机顶盒上部署同类Go语言DNS过滤程序时,频繁出现死机重启问题。排查后判断是Go运行时在低算力嵌入式设备资源开销过大,因此采用原生C语言开发DNS广告过滤核心,交叉编译部署到机顶盒,接管家庭DNS完成局域网广告过滤。所有技术细节均经过真机验证,适合对DNS、网络编程、嵌入式交叉编译感兴趣的读者。文末附源码开源说明。
目录
前言:Go版本在老机顶盒遇到的问题 一、项目背景与目标 二、工程架构:业务模型拆分为 26 个模块 三、编译演进:x86 先跑通,再交叉编译 四、关键技术点与端到端验证 五、交叉编译 ARM:全程踩坑实录 六、部署到海思盒子 七、规则管理 八、踩坑清单(完整汇总) 九、经验总结 十、后续规划 十一、源码开源:完整工程包发布 十二、作者署名与发布小贴士
前言:Go版本在老机顶盒遇到的问题
老电视盒子Hi3798mv100普遍为ARMv7架构,单核/双核A9,内存只有512MB‑1GB,eMMC闪存性能弱,内核版本老旧。直接运行Go语言开发的DNS过滤程序会出现典型故障现象:空载运行还算稳定,局域网设备变多、DNS查询流量上涨,CPU持续占满,温度快速飙升,触发温控降频,最后整机死机重启。
Go 在弱硬件上面的痛点
- GC垃圾回收抖动:Go的三色标记并发GC,在内存紧张环境会频繁触发扫描,瞬时抢占CPU,功耗升高、温度上涨,最终触发死机重启。
- Runtime自带内存开销:Go静态编译二进制自带完整runtime、goroutine调度器,程序启动就会预分配大量内存,内存开销显著高于C语言DNS程序。
- Goroutine调度开销:goroutine并发模型在多核服务器表现优秀,但在单核弱CPU环境,大量协程调度会带来额外消耗;这类程序同时处理DNS解析、广告过滤、日志统计,并发请求上来后调度开销会被进一步放大。
注意区分:Go语言本身可以正常交叉编译ARMv7,不是编译器不兼容;是runtime运行时开销,在CPU、内存极度受限设备上难以承受。大量Hi3798机顶盒用户部署这类Go版DNS过滤程序都遇到过"空载没事,流量上来就死机重启",和本次现象完全吻合。
C语言实现带来的改善
C没有runtime、没有自动垃圾回收,内存全部手动管理:
- 编译产物二进制体积小巧,启动无额外内存预分配;
- 不存在GC停顿、GC抢占CPU的问题;
- 可以精细管控内存分配、连接池,按需申请、按需释放;
- GCC编译器对老旧ARMv7指令集优化成熟,对老硬件更加友好。
补充:成熟C实现的dnsmasq也广泛用于OpenWrt机顶盒,但dnsmasq缺少Adblock高级过滤语法、查询统计这类增值能力。
额外容易忽略的死机诱因(多重因素叠加)
- eMMC存储IO瓶颈:Hi3798内置闪存性能差,程序持续写查询日志,高频磁盘IO拉高CPU、加剧发热;很多用户部署时没有关闭查询日志。
- 内核与驱动缺陷:老盒子内核版本老旧,网络、USB驱动不稳定;程序跑在USB外置存储时,USB链路异常也会造成进程卡死,表象类似CPU故障。
- OOM内存溢出:Go版本内存占用高,内存耗尽触发Linux OOM killer,杀掉进程,极端情况整机卡死;C实现内存可控,大幅降低OOM概率。
- 硬件温控限制:老机顶盒散热简陋,CPU负载一高温度快速触达阈值,硬件看门狗直接重启;C实现降低平均负载,温度下降,死机现象随之消失。
折中备选方案(不想从零开发C工程)
- dnsmasq‑full + shell脚本自动更新hosts规则,纯C原生,资源占用极低;缺点缺少Web面板,过滤能力弱。
- stubby(C实现DoT上游) + dnsmasq组合,实现加密DNS+本地域名过滤。
- Go版本裁剪编译:编译时关闭统计、查询日志、WebUI,减少goroutine与内存开销,仅能缓解问题,无法消除Go runtime本身开销;高压力场景依旧会GC抖动,适合临时救急。
一、项目背景与目标
1.1 我们要做什么
目标非常直接:不使用这类Go语言实现的DNS过滤程序,改用C语言从零实现DNS广告过滤核心,交叉编译后部署到海思 Hi3798MV100 机顶盒,接管家庭DNS,实现局域网DNS广告过滤。
📌重要:本为原型Demo,不支持开箱即用网页后台,仅提供JSON接口,网页仪表盘需要本地运行脚本。
1.2 硬性约束(贯穿全程)
| 约束 | 说明 |
|---|---|
| 原生 C 工具链 | 编译必须用 gcc/cc,禁止 g++,禁止包装/解释层 |
| 工程修复红线 | 禁止修改业务模块代码,只允许改工程配置 / CMake / 黏合层 / 桩(stub) |
| 不买额外硬件 | 不购买 TTL 串口线等调试设备,盒子控制走 SSH / Web / ttyd 免线通道 |
1.3 运行环境
- 编译机:VMware Kali Linux(x86_64,192.168.118.129,用户 kali/kali123)
- 目标机:海思盒子 hi3798mv100,armv7l(32 位 ARM),Ubuntu 20.04 海纳思 HiNAS 系统(192.168.3.150,root/ecoo1234)
- 家庭路由器:华为 AX3 Pro(PPPoE 拨号,DHCP 下发 DNS)
二、工程架构:业务模型拆分为 26 个模块
参考主流Adblock风格DNS过滤产品的业务逻辑,拆分为M01~M26模块,整体分为6层。
| 模块 | 名称 | 层级 | 作用 |
|---|---|---|---|
| M01 | core_util | L0 基础设施 | 核心工具库(内存、字符串、arena内存池) |
| M02 | logging | L0 | 日志模块 |
| M03 | threadpool | L0 | 线程池与任务队列 |
| M04 | lru_cache | L0 | 通用 LRU 缓存库 |
| M05 | event_loop | L1 传输层 | 事件循环适配层(libuv) |
| M06 | http_client | L1 | HTTP(S) 客户端(libcurl) |
| M07 | tls_mgr | L1 | TLS 证书管理 |
| M08 | dns_msg | L2 DNS 协议 | DNS 消息编解码 |
| M09 | net_util | L2 | 网络工具库 |
| M10 | dns_server | L3 核心服务 | DNS 监听与请求处理链(核心) |
| M11 | filter_engine | L3 | 过滤规则引擎(支持Adblock语法) |
| M12 | rule_updater | L3 | 规则列表更新器【未接入主程序】 |
| M13 | upstream_mgr | L3 | 上游 DNS 客户端管理(异步转发) |
| M14 | dns_cache | L3 | DNS缓存(命中/写回) |
| M15 | access_control | L3 | 客户端访问控制 |
| M16 | dns64 | L3 | DNS64(IPv4/IPv6 转换) |
| M17 | querylog | L4 业务模块 | 查询日志 |
| M18 | stats | L4 | 统计(总请求/拦截/缓存命中/TopN) |
| M23 | config | L5 管理配置 | 配置管理 |
| M24 | web_api | L5 | Web 后台API(仅JSON接口,无静态网页分发) |
| M25 | app | L6 应用层 | 应用装配与生命周期 |
| M21 | ip_hijack | - | 源码残缺未接入 |
说明:M19~M26中间缺失编号模块,在原型演进中逐步补齐;核心业务链路以M10 dns_server为骨架,形成完整DNS请求处理链。 源码包内adg_c/下按M01‑M26建立对应目录,每个目录存放.c/.h源码、CMakeLists、单元测试tests,未完成模块保留stubs桩文件。
三、编译演进:x86 先跑通,再交叉编译
3.1 桩化最小编译闭环
优先保证工程可以编译出最小可执行二进制,不追求完整功能。 未完成模块使用stubs桩实现,CMake编译列表移除真实模块源码; 补齐头文件、宏、缺失符号; appmain.c移除未完成模块的初始化、销毁逻辑;
编译:
cd build && make -j4
使用dig验证53端口应答,桩模式返回固定测试IP。 编译入口固定为 cd /home/kali/adg_c/build && make -j4,工程根目录无顶层Makefile。
3.2 逐模块接入真实代码
每个模块统一接入流程,降低变更风险:
- 备份原有桩文件,替换为真实业务源码;
- 编写mXX_compat桥接兼容层,完成桩契约与真实API转换;
- CMake移除stub,加入真实源码+compat桥接层;
- 增量编译,执行模块自带单元测试。
3.3 业务链接入主程序
M10 责任链 DNS完整请求处理链: precheck / special / access / filter / cache / upstream / stats / querylog / cache_store
过滤引擎 node_filter_request调用fe_check_request执行过滤; 支持Adblock语法:||domain^子域匹配、@@白名单、基础正则规则; 使用atomic快照,保证规则热更新线程安全。
统计桥接 完成桩结构体st_entry_t向真实统计结构体映射; NXDOMAIN / REFUSED应答标记为拦截; 设置DNS_REQ_FLAG_CACHE_HIT标志统计缓存命中。
异步上游DNS M13 um_resolve实现异步回调,使用DNS_CHAIN_SUSPEND挂起请求,等待上游DNS应答,不阻塞事件循环。
Web API 说明 ⚠️仅实现JSON接口:/stats、/querylog、/health;没有实现静态文件分发,访问根路径/返回404。网页dashboard.html为独立静态文件,机顶盒程序不会自动对外提供。
四、关键技术点与端到端验证
4.1 验证方法
dig验证DNS解析与拦截
dig @127.0.0.1 黑名单域名 # 广告域名返回NXDOMAIN
dig @127.0.0.1 example.com # 正常域名返回NOERROR
curl调用JSON接口查看统计
curl 127.0.0.1:8080/stats
curl '127.0.0.1:8080/querylog?limit=10'
curl 127.0.0.1:8080/health
网页仪表盘:在本地电脑执行python server.py代理访问静态dashboard.html,机顶盒端无法直接打开网页面板。
4.2 实测数据
| 场景 | 结果 |
|---|---|
| x86功能验证 | 广告黑名单返回NXDOMAIN,正常域名解析正常;stats、querylog计数完全匹配实际请求 |
| 缓存命中验证 | 重复dig同一域名,cache_hits计数+1 |
| 海思盒子真机部署 | 内置50条广告域名全部拦截;baidu、example可以正常解析 |
五、交叉编译 ARM:全程踩坑实录
5.1 环境准备
盒子为armv7l 32位,无法本机编译,在Kali做交叉编译。 安装交叉编译工具链
apt install gcc-arm-linux-gnueabihf
构建sysroot环境 从ports.ubuntu.com下载focal armhf开发包,解压到/home/kali/focal-cross,包含libc、libuv、libcurl、openssl、linux‑libc‑dev内核头文件。
toolchain‑armhf.cmake
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
set(CMAKE_C_COMPILER arm-linux-gnueabihf-gcc)
set(CMAKE_FIND_ROOT_PATH /home/kali/focal-cross)
set(CMAKE_SYSROOT /home/kali/focal-cross)
5.2 三个致命坑
- linux/errno.h: No such file or directory ------ sysroot缺少内核头文件,补linux‑libc‑devarmhf包。
- libcurl/libuv未定义引用 ------ 增加链接参数:
-Wl,--allow-shlib-undefined - pthread符号未定义 ------ sysroot内libpthread.so是绝对路径符号链接,会加载宿主机glibc的stub;修改为相对符号链接,编译增加
-lpthread。
5.3 产物确认
file adg_c_dns
输出示例:
ELF 32-bit LSB pie executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-armhf.so.3, BuildID[sha1]=b22572dcd4cdcfb0726132de84e1ca685dbff11e, for GNU/Linux 3.2.0, not stripped
GLIBC需求 ≤2.28,兼容Ubuntu20.04; 依赖:libpthread.so.0 / libuv.so.1 / libcurl.so.4 / libssl.so.1.1 / libcrypto.so.1.1 / libc.so.6。
5.4 编译注意事项
修改CMakeLists后必须删除build目录重新cmake;仅修改黏合层可以增量编译。 后台编译命令:
nohup bash -c "make -j4 > /tmp/build.log 2>&1; echo EXIT=$? > /tmp/exit.log" &
六、部署到海思盒子
6.1 部署步骤
SFTP上传二进制到/opt/adg_c_dns/adg_c_dns
chmod +x /opt/adg_c_dns/adg_c_dns
创建systemd服务文件 /etc/systemd/system/adg_c_dns.service
[Unit]
Description=adg-c-dns C语言DNS过滤原型
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/adg_c_dns
ExecStart=/opt/adg_c_dns/adg_c_dns
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
启动并设置开机自启
systemctl daemon-reload
systemctl enable adg_c_dns.service
systemctl start adg_c_dns.service
验证运行状态
systemctl is-active adg_c_dns.service
ss -tulpn | grep ':53 '
dig @127.0.0.1 www.baidu.com
6.2 接管局域网DNS
路由器DHCP DNS填写机顶盒IP 192.168.3.150,局域网全部设备自动使用本DNS。
七、规则管理
广告过滤规则硬编码在黏合层appmain.c函数adg_install_filter_rules()内部,使用static const char rules\[\]存储Adblock语法规则。 ⚠️修改规则流程:修改源码数组 → 重新编译 → 上传二进制覆盖 → 重启服务。不支持运行时加载外部规则文件。 默认内置50条常见广告、追踪域名。
八、踩坑清单(完整汇总)
| # | 坑点 | 说明 / 解法 |
|---|---|---|
| 1 | 编译入口错用工程根目录 | 报"无 Makefile",必须用 build/ 子目录 |
| 2 | zsh 通配符 --include=*.c 报 nomatch | 改用 bash -c 或引号包裹 |
| 3 | 三引号字符串不能嵌套 | 本地生成脚本不要嵌套三引号,独立存为py文件再上传 |
| 4 | 接入真实上游模块后请求挂起超时 | M10 dns_req_resume异步分支标志问题,临时修改pending_resume=true修复,触及业务代码修改红线 |
| 5 | 事件循环没有注入,IO不执行 | M13需要注入主uv_loop_t实例,否则定时器、IO不会触发 |
| 6 | 桩代码掩盖依赖问题 | stub同步模拟返回,接入真实异步逻辑后才暴露bug |
| 7 | 调试手段 | fprintf打印、strace、tcpdump‑i any port 53,回环接口也要抓包 |
| 8 | pkill杀进程 | 使用pkill -x adg_c_dns精确匹配;避免‑f误杀shell脚本 |
| 9 | LRU缓存容量分片 | capacity会按分片均分;需要精确容量设置shard_count=1 |
| 10 | 统计日志时间戳缺失 | querylog写入不会自动填充timestamp,上层调用时赋值 |
| 11 | CMake变更必须重生成构建目录 | 只修改黏合层源码可以增量编译 |
| 12 | sysroot libpthread符号链接 | 绝对路径链接会加载宿主机库,改为相对链接 |
九、经验总结
9.1 给复现者的建议
准备:x86 Linux编译环境 + armv7海思盒子; 开发顺序:桩最小闭环 → x86功能调通 → 交叉编译 → 真机部署; 交叉编译坑最多,对照踩坑清单可以节省大量时间; 遵守工程红线:业务模块尽量不动,改动集中在黏合层、CMake配置、stub桩。
9.2 方法论沉淀
先闭环,再完善,先用桩跑通整体流程,再逐个替换真实模块,风险可控; compat桥接隔离变更,业务代码不动,接口适配全部放在桥接层; 先提取真实函数签名,再写装配代码,避免设想API和真实代码不一致; 交叉编译三件套:正确工具链、完整sysroot内核头文件、修复符号链接; 三层验证:digDNS功能 + curl接口校验统计 + 盒子真机测试。
十、后续规划
- M21 ip_hijack模块补全接入;
- M12 rule_updater接入,实现规则运行时更新,不再需要改源码重编译;
- ARM平台并发压力、稳定性调优;
- HTTP增加静态文件分发,实现盒子端直接访问网页仪表盘;
- 完成aarch64交叉编译,适配更多arm盒子。
十一、源码开源:完整工程包发布
仓库地址:https://gitee.com/aiguodexiaolou/adg-c-demo
11.1 工程目录结构
- adg_c/:顶层构建脚本,支持x86/armhf
- CMakeLists.txt:编译配置必读
- README.md:项目限制、编译运行依赖必读
- LICENSE:协议声明
- toolchain‑armhf.cmake:armhf交叉编译配置
- cmake/:备用cmake片段
- third_party/uthash.h:第三方哈希库
- 黏合层/:appmain、兼容桥接代码
- webui/:dashboard.html + server.py本地调试脚本
- deploy/:systemd示例服务文件
- M01‑M26:全部模块源码,保留stubs桩、tests单元测试
11.2 编译使用指引
x86本机编译:
cd adg_c
mkdir build && cd build
cmake .. && make -j4
armhf交叉编译:
apt install gcc-arm-linux-gnueabihf
# 提前准备好focal-cross sysroot
cd adg_c
mkdir build_focal && cd build_focal
cmake -DCMAKE_TOOLCHAIN_FILE=../toolchain-armhf.cmake .. && make -j4
编译产物:adg_c_dns。 二进制上传盒子,参考第六章systemd配置部署;网页面板只能本地电脑运行server.py。
11.3 重要工程限制(必读)
- M12 rule_updater未接入;广告规则硬编码,修改规则需要重新编译;
- M21 ip_hijack残缺,部分模块stub桩实现;
- 暂不支持DoH / DoT、DHCP服务;
- HTTP仅JSON接口,机顶盒端没有网页管理后台;
- 仅适配 armhf Ubuntu20.04海纳思HiNAS;不兼容OpenWrt;
- 原型Demo,不建议直接用于7×24小时生产环境。
11.4 开源协议 声明
本工程为C语言独立原型实现,并非基于AdGuard官方Go源码复制或二次衍生开发; 源码使用MIT协议; 第三方依赖:libuv(MIT)、libcurl、OpenSSL(Apache‑2.0)、uthash(BSD); 本文档文字:CC‑BY‑NC‑SA 4.0 国际协议,转载需要署名、禁止商用、衍生作品同协议。