做鸿蒙 PC 方向的网络组件适配时,第一块硬骨头往往不是界面,也不是沙箱,而是像 c-ares 这种“又小又挑剔”的 C 库。c-ares 用纯 C 实现了完整的异步 DNS 解析能力,几乎只跟 socket、标准 C 库打交道,却承担了 curl、Node.js 等大量开源项目底层的域名解析职责。想在鸿蒙 PC 上跑起命令行网络工具,或者复用已有的 C/C++ 网络栈,把 c-ares 交叉编译成“鸿蒙户口”是绕不开的第一步。
这篇文章是一份可复现的实战记录:使用 OpenHarmony SDK 自带的 clang 工具链,通过 CMake 的 ohos.toolchain.cmake 把 c-ares 编译成鸿蒙 PC(x86_64)可用的静态库,再写一个最小异步解析程序,推送到鸿蒙 PC 上跑通。整个过程我踩了四五次坑,包括工具链 target 配错、resolv.conf 读不到、静态库和动态库选择失误等等。这篇文章会把能绕开的坑先给你标出来,适合正在做鸿蒙原生库移植的开发者,也适合想彻底弄懂交叉编译原理的技术爱好者。
1. 为什么要在鸿蒙 PC 上移植 c-ares
1.1 c-ares 在网络栈里的角色
传统系统调用getaddrinfo是同步阻塞的,在网络抖动时会卡住整个调用方。c-ares 的思路是把 DNS 查询拆成 socket 事件,放进一个事件循环里逐步处理,查询没完成时立刻返回控制权,从机制上解决了阻塞问题。它还支持一次并发发起多个查询、UDP 失败自动切 TCP、EDNS0 扩展,以及较新版本里的 DoH(DNS over HTTPS)能力。
生态上的地位同样重要。curl 编译时可以直接选用 c-ares 作为域名解析后端,配置项就是--enable-ares;Node.js 的dns.resolve系列 API 底层也大量依赖 c-ares 的认证逻辑。换句话说,这个库不只是一段“解析域名的代码”,它已经是很多基础设施项目里默认的异步解析实现。想往鸿蒙 PC 上迁命令行网络工具,大概率迟早要跟它碰面。
从移植角度看,c-ares 是典型的“麻烦库”:它不依赖任何第三方动态库,不用 OpenSSL,不用 JSON,全部源码清清爽爽,但偏偏要处理操作系统相关的 socket 行为、文件描述符、resolv.conf 格式和线程安全。这种库交叉编译起来最能暴露工具链配置里的各种隐性差异。
1.2 移植方案的取舍:为什么必须交叉编译
最直觉的“拷贝大法”不建议尝试。把开发机上基于 glibc 编译出来的 libcares.so 直接丢到鸿蒙 PC 上,大概率会碰到一连串问题:鸿蒙 PC 的 C 库是 musl 定制版,跟 glibc 在头文件声明和 ABI 细节上有差异;动态加载器的搜索路径和格式也不一样。想真正跑起来,必须拥有一个面向鸿蒙目标环境的编译工具链。
可行的路线有三条。第一条是在鸿蒙 PC 上本地安装编译器直接编,问题是设备端通常没有现成的完整编译环境,装工具链本身就非常折腾。第二条是在 Linux 开发机上用 OpenHarmony SDK 提供的 NDK 工具链做交叉编译,这也是官方硬件厂商和开源社区普遍采用的路线。第三条是手工敲 clang 参数,完全不用 CMake 封装,适合用最小例子理解原理,但不适合批量移植。
最后在库形态上,我推荐优先编译静态库。鸿蒙 PC 还没有像传统 Linux 发行版那样成熟的包管理器,动态库的部署、签名、版本管理都需要额外考虑。静态库直接链接进可执行文件,整个命令行工具就只有一个二进制,推送到设备上就能跑,省掉很多运行时依赖问题。代价是可执行文件体积会多出几百 KB,这对一次性的系统命令行工具来说完全值得。
2. 编译环境准备:把鸿蒙工具链理顺
2.1 SDK 获取与三个关键目录
交叉编译 c-ares 之前,先把鸿蒙 Native 开发套件准备好。开发 IDE 自带的 SDK 目录里已经包含了编译所需的全部组件,也可以用开源社区分发的 SDK 包。安装完成后,建议先用环境变量统一指向 SDK 根目录,后面所有命令都复用这个变量,减少拼错路径的概率。
export OHOS_SDK=/your/actual/ohos-sdk/path设置好后重点确认三个目录是否齐全。第一个是编译器目录$OHOS_SDK/native/llvm/bin,里面应该有 clang、clang++、llvm-ar 等工具;第二个是系统头文件与库的根目录$OHOS_SDK/native/sysroot,交叉编译时找头文件、找 C 库全依赖它;第三个是官方 CMake 工具链文件$OHOS_SDK/native/build/cmake/ohos.toolchain.cmake,后面 CMake 方案直接用它即可。
ls "$OHOS_SDK/native/build/cmake/" ls "$OHOS_SDK/native/llvm/bin/" | head -20 ls "$OHOS_SDK/native/sysroot/"如果这三个目录都有内容,说明 SDK 基本可用。版本上建议用新一点的 SDK 版本,旧版 NDK 的 sysroot 在某些网络头文件上不完整,容易在编译期报稀奇古怪的错误。工具链一旦选定了,后面整条编译链都用同一个 SDK,不要中间混用新旧版本。
2.2 ohos.toolchain.cmake 内部做了什么
很多第一次接触的人会好奇:为什么不直接给 CMake 传-DCMAKE_C_COMPILER=clang就完事了?因为交叉编译不是只换一个编译器那么简单。同一个 clang 在不同--target参数下产生的代码是完全不同的,它还必须有 sysroot 隔离系统头文件和系统库,避免误用开发机上的 glibc 头文件。
ohos.toolchain.cmake把这堆杂事一次性封装好了。它会把编译器指向 SDK 内的 clang,把 sysroot 指向native/sysroot,并根据你传入的OHOS_ARCH参数设置对应的 target 三元组。比如x86_64会映射成x86_64-linux-ohos,arm64-v8a会映射成aarch64-linux-ohos。此外它还设置了查找根路径,保证 CMake 在 find_package 时不会跑到宿主机目录里去瞎找库。
理解这一层之后,遇到“编译出来怎么是 x86_64 Linux 的可执行文件”“怎么链接到宿主机 libc 了”这类问题时,你就知道多半是 sysroot 没生效或者 target 没传对,而不是工具链坏了。
2.3 目标架构与设备连接确认
鸿蒙 PC 常见目标架构是 x86_64,不过也有基于 ARM 处理器的主机形态。动手编译前,先确认设备到底是什么架构,可以省掉一次白干的时间。通过 hdc(华为设备连接工具的通用命令)连上设备后,直接执行:
hdc list targets hdc shell uname -muname -m输出x86_64时,CMake 里的OHOS_ARCH就填x86_64;输出aarch64时填arm64-v8a。如果 hdc 命令不在 PATH 里,去 SDK 的toolchains目录下找,或者把该目录加进 PATH。
这一步同时也验证了设备连接是通的。后面推送可执行文件、查看运行输出全靠 hdc,如果这里就卡住,先排查开发者模式的 USB 调试开关,再排查 hdc 版本与设备系统版本的匹配情况。
3. c-ares 交叉编译全流程
3.1 源码获取与版本管理
c-ares 的源码在托管平台上有现成仓库,直接克隆即可。为了可复现,我强烈建议锁定到一个具体的 release tag,而不是直接用 master 分支。master 上随时可能改构建脚本,今天能过跑的配置明天可能就挂,排错时会非常头疼。
git clone https://github.com/c-ares/c-ares.git cd c-ares git checkout v1.28.1如果更习惯用 release 压缩包,下载 tar 包后也请留意包内是否自带 configure 和 CMakeLists.txt。官方 release 包一般都会带上这两个,省去执行 buildconf 的步骤。从 git 克隆的代码则必须先跑./buildconf生成 configure,这个差异会造成“明明照着教程敲,为什么说找不到 configure”的困惑。
3.2 CMake 交叉编译:推荐路线
CMake 方案是目前最顺手的路线,因为官方 toolchain 文件已经把交叉编译的细节处理好了。下面这组命令可以直接复制使用,注意把OHOS_SDK换成你自己的路径。
export OHOS_SDK=/your/actual/ohos-sdk/path export TARGET_ARCH=x86_64 rm -rf build-cross install cmake -S . -B build-cross \ -DCMAKE_TOOLCHAIN_FILE="$OHOS_SDK/native/build/cmake/ohos.toolchain.cmake" \ -DOHOS_ARCH=$TARGET_ARCH \ -DCMAKE_INSTALL_PREFIX="$PWD/install" \ -DCMAKE_BUILD_TYPE=Release \ -DCARES_STATIC=ON \ -DCARES_SHARED=OFF \ -DCARES_BUILD_TOOLS=ON \ -DCARES_BUILD_TESTS=OFF cmake --build build-cross -j"$(nproc)" cmake --install build-cross几个关键选项的含义值得细说。-DOHOS_ARCH=x86_64决定了最终代码运行在什么处理器上,前面确认设备架构就是为了这一步。CARES_STATIC=ON与CARES_SHARED=OFF组合起来就是只要静态库,不要动态库。CARES_BUILD_TOOLS=ON会额外生成 adig、ahost、acountry 三个命令行小工具,这三兄弟既是现成的验证工具,也是理解 c-ares API 的活教材。CARES_BUILD_TESTS=OFF主要是为了省事,交叉编译环境里跑宿主机测试程序意义不大,还会制造一推潜在的环境依赖。
有些教程里还会加-DOHOS_STL=c++_shared,这个选项是给 C++ 运行时准备的,c-ares 本身是纯 C 库,不加也没问题。如果以后要在 c-ares 上层接 C++ 组件,再统一考虑 STL 的运行时形态即可。
3.3 configure 交叉编译:备选路线
有些开源项目的构建体系还是 autotools,没有 CMakeLists,或者你需要在老版本 c-ares 上做验证,那就必须走 configure 路线。core 的反直觉点在于,你必须手动把--target和 sysroot 消息喂给 clang,否则它会默认按宿主机编译。
cd c-ares ./buildconf # 如果是 git 源码需要生成 configure;release 包可跳过 export OHOS_SDK=/your/actual/ohos-sdk/path export CC="$OHOS_SDK/native/llvm/bin/clang --target=x86_64-linux-ohos" export CFLAGS="--sysroot=$OHOS_SDK/native/sysroot -O2" export LDFLAGS="--sysroot=$OHOS_SDK/native/sysroot" ./configure --host=x86_64-linux-ohos \ --build=x86_64-pc-linux-gnu \ --disable-shared \ --enable-static \ --prefix="$PWD/install" make -j"$(nproc)" make install这里最容易搞混的是--host和--build。--build是指当前这台开发机的环境,--host是指编译出来的程序将要运行的环境。我们要让程序跑在鸿蒙 PC 上,所以--host=x86_64-linux-ohos;编译动作发生在 Linux 开发机上,所以--build=x86_64-pc-linux-gnu。--target在这种场景下一般不用管,它是为“编译器要生成哪个目标平台的代码”这种交叉编译器本身而设计的参数,普通库编译用不到。
autotools 在 configure 阶段会执行一系列诡异的小测试程序,以此推断系统能力。这些测试程序是编译出来在当前机器上运行的,所以 configure 本身要能在这个开发机上执行;真正生成的库代码才是给鸿蒙用的。这个双重身份是交叉编译最容易概念混淆的地方,理解透了你排错就胸有成竹了。
3.4 产物检查清单
编译安装完成后,不要急着推设备,先在开发机上做两件快速检查。第一件是用file命令确认产物里没有混入宿主机代码,第二件是看看安装目录结构是否完整。
file install/lib/libcares.a find install -type f | sortlibcares.a是 ar 归档格式,里面的成员是若干.o目标文件。想更彻底地确认成员架构,可以用 llvm-ar 列出成员,挑一个出来再跑file。实际上,最可靠的验证方式还是后面推送到鸿蒙 PC 上跑一次,开发机上的检查只是提前挡掉一些低级错误。
安装目录里还会看到include/ares.h、include/ares_version.h这些头文件。后续写代码时,编译器的头文件搜索路径必须指向这个 install/include,否则会碰到“找不到 ares.h”的问题。
4. 在鸿蒙 PC 上验证:从最小 Demo 到命令行工具
4.1 最小异步 DNS 解析 Demo
编译通过只是第一步,真正让人放心的是在鸿蒙 PC 上跑通一个实际的 DNS 解析。下面这个 demo 展示了 c-ares 最核心的异步流程:初始化库、创建 channel、提交解析请求、进入事件循环、在回调里拿到 IP。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <netdb.h> #include <netinet/in.h> #include <arpa/inet.h> #include <sys/select.h> #include <ares.h> static void on_result(void *arg, int status, int timeouts, struct hostent *he) { (void)arg; (void)timeouts; if (status != ARES_SUCCESS) { printf("resolve failed: %s\n", ares_strerror(status)); return; } printf("official name: %s\n", he->h_name); for (char **p = he->h_addr_list; *p != NULL; ++p) { char ip[INET6_ADDRSTRLEN] = {0}; inet_ntop(he->h_addrtype, *p, ip, sizeof(ip)); printf(" -> %s\n", ip); } } int main(void) { ares_channel channel; fd_set rfds, wfds; struct timeval tv, *tvp; int nfds; if (ares_library_init(ARES_LIB_INIT_ALL) != ARES_SUCCESS) { fprintf(stderr, "ares_library_init failed\n"); return 1; } if (ares_init(&channel) != ARES_SUCCESS) { fprintf(stderr, "ares_init failed\n"); return 1; } const char *dns = getenv("DNS_SERVER"); if (dns && *dns) { if (ares_set_servers_csv(channel, dns) != ARES_SUCCESS) { fprintf(stderr, "set dns server %s failed\n", dns); } } printf("resolving example.com ...\n"); ares_gethostbyname(channel, "example.com", AF_INET, on_result, NULL); for (;;) { FD_ZERO(&rfds); FD_ZERO(&wfds); nfds = ares_fds(channel, &rfds, &wfds); if (nfds == 0) break; tvp = ares_timeout(channel, NULL, &tv); if (select(nfds, &rfds, &wfds, NULL, tvp) < 0) break; ares_process(channel, &rfds, &wfds); } ares_destroy(channel); ares_library_cleanup(); return 0; }几个细节可能决定成功与失败。ares_fds返回的是事件循环需要的最大文件描述符加一,可以直接送给 select 作为第一个参数。每次循环开始都要FD_ZERO,因为 select 会改写 fd_set。ares_timeout给出下一次事件的最长等待时间,不设的话遇到 UDP 丢包可能会一直卡着。
设置 DNS 服务器的代码很有必要。鸿蒙 PC 上的/etc/resolv.conf经常不存在或者内容为空,c-ares 自己读不到服务器列表,查询就会直接失败。demo 里通过环境变量DNS_SERVER传入服务器地址,比如192.168.1.1,这比硬编码灵法得多。
4.2 交叉编译 Demo 并推送到设备
写好的 demo 用一条 clang 命令就能完成交叉编译。注意源文件在前、静态库在后,这是链接器处理静态库的顺序要求。头文件路径要指到 install 目录,不能指到宿主机系统路径。
"$OHOS_SDK/native/llvm/bin/clang" \ --target=x86_64-linux-ohos \ --sysroot="$OHOS_SDK/native/sysroot" \ -I./install/include \ dns_demo.c ./install/lib/libcares.a \ -o dns_demo这里没有加-lpthread,因为 c-ares 在鸿蒙的 musl libc 下通常不需要显式链接 pthread。如果你自己动手时遇到undefined reference to pthread_*,再加也不迟,加了不会有什么副作用。
推送到鸿蒙 PC 上运行:
hdc file send dns_demo /data/local/tmp/ hdc shell chmod 755 /data/local/tmp/dns_demo hdc shell "DNS_SERVER=192.168.1.1 /data/local/tmp/dns_demo"把192.168.1.1换成你设备当前网络环境里实际可用的 DNS 服务器。正常输出类似:
resolving example.com ... official name: example.com. -> 93.184.216.34说明静态库在鸿蒙 PC 上完全可用。如果看到的是 “resolve failed”,去参照第 5 部分的排查表,多数是服务器列表没设置对,或者设备网络本身就不通。
4.3 顺带验证自带工具 adig
编译 c-ares 时如果开启了CARES_BUILD_TOOLS=ON,install/bin 目录下会多出 adig、ahost、acountry 三个命令行工具。这些工具可以直接作为实用程序使用,不需要再写任何代码。
hdc file send install/bin/adig /data/local/tmp/ hdc shell chmod 755 /data/local/tmp/adig hdc shell /data/local/tmp/adig example.com需要注意,adig 会沿用 c-ares 默认配置,也就是读取系统的/etc/resolv.conf。鸿蒙 PC 上如果该文件缺失或内容为空,adig 会报 DNS 服务器故障。这不代表库有问题,只是没给它喂服务器。想知道设备上到底有没有 resolv.conf,可以先执行:
hdc shell cat /etc/resolv.conf没有的话,最干净的做法是在自己的工具里显式调用ares_set_servers_csv。这也再次印证了为什么在代码里显式管理 DNS 服务器列表是鸿蒙移植里的关键经验,而不是锦上添花。
5. 高频踩坑实录与排障速查
5.1 踩坑速查表
折腾 c-ares 交叉编译的这段时间,我整理了一张高频率问题的速查表,按症状、根因、对策三段排好。遇到问题时先查表,比重新搜遍网页高效得多。
| 症状 | 根因 | 对策 |
|---|---|---|
编译时找不到 system headers,比如stdlib.h not found | sysroot 没传递到编译器 | 在 CFLAGS 里加--sysroot=$OHOS_SDK/native/sysroot |
链接时找不到-lc | 编译器拿不到鸿蒙的 C 库 | 确认 sysroot 路径正确,或改用官方 toolchain 文件 |
生成的可执行文件在鸿蒙 PC 上报Exec format error | target 未设为 x86_64-linux-ohos,编出了宿主机 ELF | 检查 clang 命令里的--target或 CMake 里的 OHOS_ARCH |
| CMake 缓存里架构串掉 | 同一个 build 目录换过 OHOS_ARCH | 删除 build 目录重新配置,不要复用缓存 |
| adig 报 DNS 服务器故障 | 设备没有 /etc/resolv.conf,c-ares 服务器列表为空 | 代码里调ares_set_servers_csv,或构造可用的 resolv.conf |
| 动态链接时报找不到 libcares.so | 只推了可执行文件没推 .so,或路径没设置 | 推送 .so 到应用目录并设置 LD_LIBRARY_PATH,或改成静态链接 |
链接时undefined reference to pthread_* | 部分环境要求显式链接线程库 | 编译命令追加-lpthread |
| 解析结果总是超时或 SERVFAIL | UDP 53 端口不通,DNS 地址不可达 | 先用 ping 或 curl 验证设备网络,再换可用的 DNS 地址 |
表中的前四行都是工具链层面的问题,占了实际操作中百分之七十的出错比例。尤其要注意最后一行:DNS 解析失败不一定是 c-ares 库出了问题,先确认设备本身能不能访问外网,否则很容易白调半天。
5.2 三个必须记住的实操经验
第一,能静态就不要动态。命令行工具在鸿蒙 PC 上部署最怕动态库依赖,一次推送一个二进制最省心。真需要动态库时,一定要把 .so 一起推过去,并且明确知道运行时搜索路径是什么,别指望系统帮你找到。
第二,每次换 SDK 版本或换架构时,把 build 目录彻底删掉。CMake 的交叉编译缓存非常记仇,残留的旧配置会让新架构编译产生各种行为异常。重新建目录配置虽然多花两分钟,但比排查诡异问题划算得多。
第三,要把“交叉编译环境”和“运行环境”彻底分开理解。configure 里探测用的临时程序是给开发机跑的,生成的库是给鸿蒙 PC 跑的,两者不能混。排错时只要分清“当前这个报错发生在编译阶段还是运行阶段”,问题的定位范围立刻缩小一大半。
6. 后续扩展:让 c-ares 真正发挥价值
6.1 给 curl 接上 c-ares
如果你在鸿蒙 PC 上准备编译 curl,可以把 c-ares 作为解析后端。传统同步解析在网络异常时会让 curl 整体阻塞,换成 c-ares 后域名解析变成了事件驱动的一部分,多请求场景下的体验会更好。
编译 curl 时,在 configure 参数里加上--enable-ares就行。如果 c-ares 不是装在全系统路径,用--with-ares=/path/to/install指定安装前缀。这样 curl 会去指定目录下找头文件和库,最终生成的 curl 就带上了异步解析能力。但要注意,这条路线需要你的 c-ares 安装位置对 curl 的构建系统可见,头文件和库路径都要对得上。
6.2 基于 ares_query 做定制 DNS 工具
c-ares 不只是能做 A 记录解析,它还提供ares_query、ares_search这类底层接口,可以自己拼装 DNS 问题、解析任意类型记录。利用这些接口,可以在鸿蒙 PC 上做一个完全可控的 DNS 查询工具,比如批量解析一批域名、统计每个域名的解析耗时、输出 TTL 信息等等,这些是系统自带工具给不了的。
实现套路是提交查询后用第 4 章那套事件循环驱动,在回调里拿到完整 DNS 响应缓冲区,再手工解析答案段。数据包结构不复杂,先解析头部,拿到 ANCOUNT 后按记录类型依次跳转。这个做法对想深入研究 DNS 协议的人来说,是很合适的练手项目。
6.3 接入鸿蒙应用框架的路径
嵌入式网络组件最终要服务于上层应用。c-ares 本身是 C 库,鸿蒙应用层通常通过 NAPI 把 C/C++ 能力暴露给 JavaScript。这里核心的问题是事件循环怎么设计:c-ares 需要事件驱动,不能直接在 JS 主线程里死循环 select。常见做法是把它放进一个独立的 native 线程或 worker 线程,解析完成后通过回调通知 JS 层。
另外一个方案是把 c-ares 产生的 fd 注册到应用自己的事件循环里,比如鸿蒙侧类似的 epoll 循环,这样不会额外开线程,资源开销更小。代价是代码复杂度高一些,需要处理 fd 生命周期、channel 销毁时机等细节。不管哪种方式,channel 本身的线程安全问题都要注意,多线程共享同一个 channel 时必须加锁,或者给每个线程创建独立 channel。
6.4 一点个人体会
这次 c-ares 移植,让我最感慨的不是命令本身,而是“宿主与目标边界”的环境认知。交叉编译最麻烦的不是记忆参数,而是分清什么东西跑在开发机上,什么东西跑在鸿蒙 PC 上。configure 探测程序跑在开发机、生成的库跑在鸿蒙、resolv.conf 是鸿蒙在读取、编译缓存是开发机在维护——这几个概念理顺以后,很多报错一眼就能猜出原因。
如果准备在鸿蒙 PC 上做大量本地库移植,我建议先拿 c-ares 这种依赖少、逻辑清晰的纯 C 库试手。先把工具链、交叉编译、设备验证这套流程完整走通一遍,后面再碰那些依赖 OpenSSL、zlib 的复杂库,至少不会被基础环节绊倒。移植库本质上是在跟“环境一致性”作对,而交叉编译只是把这个问题提前摆到桌面上而已。