KO koko
Android / 2026SCROLL TO EXPLORE
返回瀑布流
01 / NATIVENDK · CMake · adb · 生命周期1155 lines

从零运行一个 C++ 二进制服务

从 NDK 工程、交叉编译到 adb 启动,完整拆开一个可观测的 native service。

从零运行一个 C++ 二进制服务

目标:在一台可调试的 Android 设备或模拟器上,编译一个最小 native service,推送到 /data/local/tmp,通过 adb shell 启动,并用 Unix Domain Socket 完成一次请求。

先说边界

这是一条开发调试链路。

它不把普通应用伪装成系统服务。

它不绕过设备的安全策略。

它不假设进程拥有 root 权限。

它不依赖某个厂商的私有守护进程。

设备是否允许执行、是否允许访问某个目录,最终由 UID、SELinux 和 ROM 配置决定。

先在模拟器或自己的测试设备上完成实验。

目录结构

最终目录保持简单。

native-service/
├── CMakeLists.txt
├── app/
│   └── main.cpp
├── include/
│   ├── protocol.h
│   └── service.h
├── src/
│   ├── protocol.cpp
│   └── service.cpp
├── scripts/
│   ├── build.sh
│   ├── push.sh
│   └── run.sh
└── README.md

main.cpp 只负责组装依赖。

service.cpp 负责生命周期。

protocol.cpp 负责字节流边界。

脚本负责把本机动作固定下来。

工具链准备

安装 Android Studio。

通过 SDK Manager 安装 Android SDK Platform。

通过 SDK Manager 安装 Android SDK Build-Tools。

通过 SDK Manager 安装 Android NDK。

通过 SDK Manager 安装 Android SDK Platform-Tools。

确认 adb 在 PATH 中。

adb version

确认 NDK 路径。

ls "$ANDROID_HOME/ndk"

Linux 默认路径通常是 ~/Android/Sdk

macOS 默认路径通常是 ~/Library/Android/sdk

Windows 可以在 Android Studio 的 SDK 设置中查看路径。

不要把 NDK 路径写死在源码里。

可以通过环境变量传入版本。

export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/27.2.12479018"

Windows PowerShell 对应写法如下。

$env:ANDROID_NDK_HOME = "$env:ANDROID_HOME\ndk\27.2.12479018"

检查编译器是否存在。

"$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/clang++" --version

不同主机的 prebuilt 目录名称不同。

不要把 Linux 编译器路径复制到 Windows。

最小 CMake 工程

先固定最低 CMake 版本。

cmake_minimum_required(VERSION 3.22.1)
project(native_service LANGUAGES CXX)

开启严格编译选项。

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

定义可执行文件。

add_executable(native_service
    app/main.cpp
    src/protocol.cpp
    src/service.cpp
)

声明头文件目录。

target_include_directories(native_service PRIVATE include)

加入警告。

target_compile_options(native_service PRIVATE
    -Wall
    -Wextra
    -Werror=return-type
)

链接 Android 日志库。

find_library(log-lib log)
target_link_libraries(native_service PRIVATE ${log-lib})

生成的文件是 ELF 可执行文件。

它不是 APK。

它也不是普通 Java 类。

CMakeLists.txt 完整版本

下面是可以直接保存的完整文件。

cmake_minimum_required(VERSION 3.22.1)
project(native_service LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

if(NOT CMAKE_BUILD_TYPE)
    set(CMAKE_BUILD_TYPE Debug)
endif()

add_executable(native_service
    app/main.cpp
    src/protocol.cpp
    src/service.cpp
)

target_include_directories(native_service PRIVATE include)

target_compile_options(native_service PRIVATE
    -Wall
    -Wextra
    -Wpedantic
    -Werror=return-type
)

target_compile_definitions(native_service PRIVATE
    SERVICE_VERSION=\"0.1.0\"
)

find_library(log-lib log)
target_link_libraries(native_service PRIVATE ${log-lib})

Debug 构建保留调试符号。

Release 构建更接近部署产物。

先用 Debug 验证流程。

协议设计

Unix Domain Socket 传输的是字节。

一次 read 不保证拿到一个完整请求。

一次 write 也不保证对端一次拿完。

因此需要长度前缀。

请求格式为四字节长度加正文。

长度使用网络字节序。

正文使用 UTF-8。

最大请求限制为 4096 字节。

超过限制立即拒绝。

protocol.h

#pragma once

#include <cstddef>
#include <cstdint>
#include <string>
#include <string_view>

namespace protocol {

constexpr std::size_t kMaxPayload = 4096;

enum class ReadResult {
    kOk,
    kClosed,
    kTooLarge,
    kIoError,
    kMalformed,
};

struct Message {
    std::string payload;
};

ReadResult readMessage(int fd, Message* message);
bool writeMessage(int fd, std::string_view payload);

}

接口用枚举表达失败原因。

调用方不需要解析错误字符串。

protocol.cpp

#include "protocol.h"

#include <arpa/inet.h>
#include <cerrno>
#include <cstring>
#include <sys/socket.h>
#include <unistd.h>

namespace protocol {

namespace {

bool readExact(int fd, void* buffer, std::size_t size) {
    auto* cursor = static_cast<std::uint8_t*>(buffer);
    std::size_t offset = 0;
    while (offset < size) {
        const ssize_t count = ::read(fd, cursor + offset, size - offset);
        if (count == 0) return false;
        if (count < 0) {
            if (errno == EINTR) continue;
            return false;
        }
        offset += static_cast<std::size_t>(count);
    }
    return true;
}

bool writeExact(int fd, const void* buffer, std::size_t size) {
    const auto* cursor = static_cast<const std::uint8_t*>(buffer);
    std::size_t offset = 0;
    while (offset < size) {
        const ssize_t count = ::write(fd, cursor + offset, size - offset);
        if (count < 0) {
            if (errno == EINTR) continue;
            return false;
        }
        offset += static_cast<std::size_t>(count);
    }
    return true;
}

}

ReadResult readMessage(int fd, Message* message) {
    if (message == nullptr) return ReadResult::kMalformed;
    std::uint32_t encodedLength = 0;
    if (!readExact(fd, &encodedLength, sizeof(encodedLength))) {
        return ReadResult::kClosed;
    }
    const std::uint32_t length = ntohl(encodedLength);
    if (length == 0 || length > kMaxPayload) return ReadResult::kTooLarge;
    message->payload.assign(length, '\0');
    if (!readExact(fd, message->payload.data(), length)) {
        return ReadResult::kIoError;
    }
    return ReadResult::kOk;
}

bool writeMessage(int fd, std::string_view payload) {
    if (payload.empty() || payload.size() > kMaxPayload) return false;
    const auto length = static_cast<std::uint32_t>(payload.size());
    const std::uint32_t encodedLength = htonl(length);
    return writeExact(fd, &encodedLength, sizeof(encodedLength)) &&
           writeExact(fd, payload.data(), payload.size());
}

}

这里没有假设一次读完。

EINTR 会重新尝试。

对端关闭返回 kClosed

长度异常不会分配超大内存。

service.h

#pragma once

#include <atomic>
#include <string>

class NativeService {
public:
    explicit NativeService(std::string socketPath);
    ~NativeService();

    NativeService(const NativeService&) = delete;
    NativeService& operator=(const NativeService&) = delete;

    bool start();
    void stop();
    int run();

private:
    bool prepareSocket();
    void closeSocket();
    int handleClient(int clientFd);

    std::string socketPath_;
    int serverFd_ = -1;
    std::atomic_bool running_{false};
};

服务对象拥有监听文件描述符。

析构时释放资源。

复制被明确禁止。

service.cpp 初始化

#include "service.h"

#include "protocol.h"

#include <android/log.h>
#include <cerrno>
#include <csignal>
#include <cstring>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>

namespace {

constexpr char kLogTag[] = "KokoNativeService";

void logError(const char* message) {
    __android_log_print(ANDROID_LOG_ERROR, kLogTag, "%s: %s", message, std::strerror(errno));
}

void logInfo(const char* message) {
    __android_log_print(ANDROID_LOG_INFO, kLogTag, "%s", message);
}

}

NativeService::NativeService(std::string socketPath)
    : socketPath_(std::move(socketPath)) {}

NativeService::~NativeService() {
    stop();
}

bool NativeService::prepareSocket() {
    serverFd_ = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
    if (serverFd_ < 0) {
        logError("socket");
        return false;
    }

    sockaddr_un address{};
    address.sun_family = AF_UNIX;
    if (socketPath_.size() >= sizeof(address.sun_path)) {
        __android_log_print(ANDROID_LOG_ERROR, kLogTag, "socket path too long");
        closeSocket();
        return false;
    }
    std::strncpy(address.sun_path, socketPath_.c_str(), sizeof(address.sun_path) - 1);
    ::unlink(address.sun_path);

    const auto addressSize = static_cast<socklen_t>(sizeof(sa_family_t) + socketPath_.size() + 1);
    if (::bind(serverFd_, reinterpret_cast<sockaddr*>(&address), addressSize) < 0) {
        logError("bind");
        closeSocket();
        return false;
    }
    if (::listen(serverFd_, 8) < 0) {
        logError("listen");
        closeSocket();
        return false;
    }
    return true;
}

SOCK_CLOEXEC 避免描述符泄漏到子进程。

启动前删除旧 socket 文件。

路径长度必须检查。

监听队列这里只设置为八。

真实工具应按并发模型调整。

service.cpp 处理请求

int NativeService::handleClient(int clientFd) {
    protocol::Message message;
    const auto result = protocol::readMessage(clientFd, &message);
    if (result != protocol::ReadResult::kOk) {
        __android_log_print(ANDROID_LOG_WARN, kLogTag, "read failed: %d", static_cast<int>(result));
        return 1;
    }

    std::string response = "ack:" + message.payload;
    if (!protocol::writeMessage(clientFd, response)) {
        logError("write response");
        return 1;
    }
    return 0;
}

int NativeService::run() {
    if (!prepareSocket()) return 2;
    running_.store(true);
    logInfo("service ready");
    while (running_.load()) {
        const int clientFd = ::accept4(serverFd_, nullptr, nullptr, SOCK_CLOEXEC);
        if (clientFd < 0) {
            if (errno == EINTR) continue;
            logError("accept4");
            break;
        }
        handleClient(clientFd);
        ::close(clientFd);
    }
    closeSocket();
    return 0;
}

void NativeService::stop() {
    if (!running_.exchange(false)) {
        closeSocket();
        return;
    }
    closeSocket();
}

void NativeService::closeSocket() {
    if (serverFd_ >= 0) {
        ::close(serverFd_);
        serverFd_ = -1;
    }
    if (!socketPath_.empty()) ::unlink(socketPath_.c_str());
}

这个版本按顺序处理客户端。

它适合先验证协议。

它没有伪装成系统守护进程。

它没有后台自启逻辑。

它没有跨用户访问逻辑。

main.cpp

#include "service.h"

#include <csignal>
#include <cstdlib>
#include <string>

namespace {
NativeService* gService = nullptr;

void onSignal(int) {
    if (gService != nullptr) gService->stop();
}
}

int main(int argc, char** argv) {
    const std::string socketPath = argc > 1 ? argv[1] : "/data/local/tmp/koko.sock";
    NativeService service(socketPath);
    gService = &service;
    std::signal(SIGINT, onSignal);
    std::signal(SIGTERM, onSignal);
    const int result = service.run();
    gService = nullptr;
    return result;
}

默认 socket 放在临时目录。

生产系统不应该直接复制这个路径。

信号处理只做停止标记。

复杂清理应该留在主循环。

构建脚本

#!/usr/bin/env bash
set -euo pipefail

project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
build_dir="$project_root/build/android-arm64"
ndk="${ANDROID_NDK_HOME:?ANDROID_NDK_HOME is required}"
toolchain="$ndk/build/cmake/android.toolchain.cmake"

cmake -S "$project_root" -B "$build_dir" \
  -G Ninja \
  -DCMAKE_TOOLCHAIN_FILE="$toolchain" \
  -DANDROID_ABI=arm64-v8a \
  -DANDROID_PLATFORM=android-29 \
  -DCMAKE_BUILD_TYPE=Debug

cmake --build "$build_dir" --target native_service -j"$(getconf _NPROCESSORS_ONLN)"

ANDROID_PLATFORM 是最低 API 级别。

它不代表设备当前 API 级别。

选择过高会减少可运行设备。

选择过低可能缺少需要的系统调用。

推送脚本

#!/usr/bin/env bash
set -euo pipefail

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
binary="$script_dir/../build/android-arm64/native_service"

test -x "$binary"
adb wait-for-device
adb push "$binary" /data/local/tmp/koko_native_service
adb shell chmod 700 /data/local/tmp/koko_native_service

只给文件所有者执行权限。

不要默认使用 777

推送前等待设备在线。

如果设备离线,先处理 ADB 状态。

启动脚本

#!/usr/bin/env bash
set -euo pipefail

socket_path="/data/local/tmp/koko.sock"
adb shell rm -f "$socket_path"
adb shell /data/local/tmp/koko_native_service "$socket_path"

这个命令会前台运行。

前台运行便于查看退出码。

需要后台运行时可以交给开发机的终端复用器。

不要一开始就加入复杂守护逻辑。

查看 ELF 信息

file build/android-arm64/native_service

预期架构应为 AArch64。

readelf -h build/android-arm64/native_service

查看动态依赖。

readelf -d build/android-arm64/native_service

查看符号。

nm -C build/android-arm64/native_service | head

Debug 文件通常包含更多符号。

Strip 会影响 native 崩溃定位。

客户端测试

可以先写一个 Python 客户端。

它只用于协议验证。

import socket
import struct

path = "/data/local/tmp/koko.sock"
payload = b"ping"

with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
    client.connect(path)
    client.sendall(struct.pack("!I", len(payload)))
    client.sendall(payload)
    size = struct.unpack("!I", client.recv(4))[0]
    response = client.recv(size)
    print(response.decode("utf-8"))

开发机不能直接访问设备路径。

需要把客户端也放到设备上。

adb shell 'toybox nc -h' || true

不同 ROM 的 toybox 功能可能不同。

更稳定的方式是编译一个测试客户端。

C++ 测试客户端

#include <arpa/inet.h>
#include <cstdint>
#include <cstring>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>

int main() {
    const char* path = "/data/local/tmp/koko.sock";
    const char* text = "ping";
    const auto size = static_cast<std::uint32_t>(std::strlen(text));
    int fd = ::socket(AF_UNIX, SOCK_STREAM, 0);
    sockaddr_un address{};
    address.sun_family = AF_UNIX;
    std::strncpy(address.sun_path, path, sizeof(address.sun_path) - 1);
    if (::connect(fd, reinterpret_cast<sockaddr*>(&address), sizeof(address)) < 0) return 2;
    const auto encoded = htonl(size);
    ::write(fd, &encoded, sizeof(encoded));
    ::write(fd, text, size);
    ::close(fd);
    return 0;
}

测试客户端仍然需要处理部分写入。

这里为了展示链路而保持短小。

正式测试应复用 protocol.cpp

日志链路

Android 日志通过 logcat 查看。

adb logcat -c
adb logcat -v threadtime -s KokoNativeService:I '*:S'

先清理旧日志。

再启动服务。

观察 service ready

如果看不到日志,检查进程是否真的启动。

adb shell ps -A | grep koko_native_service

也可以检查退出码。

adb shell '/data/local/tmp/koko_native_service; echo exit:$?'

生命周期细节

服务启动前先创建 socket。

创建失败就应该退出。

不要继续运行一个没有监听端口的假服务。

客户端连接后读取长度。

长度合法才分配字符串。

读取失败就关闭客户端。

响应写完再关闭客户端。

监听循环遇到 EINTR 时重试。

其他错误记录日志并退出。

停止时关闭监听描述符。

关闭后删除 socket 文件。

并发模型

当前版本是单线程串行模型。

它的优点是状态简单。

它的缺点是慢请求会阻塞后续连接。

第一步不要急着上线程池。

先测量请求耗时。

如果确实需要并发,可以为每个客户端创建线程。

线程必须拥有自己的文件描述符。

共享状态需要互斥保护。

停止时要能唤醒阻塞的 accept

更复杂的版本可以使用 poll

再进一步可以使用 epoll

性能优化之前先固定协议。

权限与 SELinux

/data/local/tmp 通常用于 ADB 调试文件。

它不等于任意目录都可访问。

进程 UID 决定文件权限。

SELinux 决定更高层的访问策略。

chmod 700 不会授予 root 能力。

如果执行被拒绝,先记录完整错误。

adb shell id
adb shell ls -lZ /data/local/tmp/koko_native_service

不要用关闭 SELinux 作为默认排错手段。

那会改变测试环境本身。

如果需要系统级部署,应走设备平台的正式构建和策略流程。

崩溃定位

先保留未 strip 的本机 ELF。

设备上保存 tombstone 的路径受系统控制。

通过 adb logcat 观察 fatal signal。

adb logcat -d | grep -E "Fatal signal|KokoNativeService"

使用 NDK 的 ndk-stack 做符号化。

ndk-stack -sym build/android-arm64 -dump tombstone.txt

崩溃地址必须对应同一份二进制。

重新编译后旧地址可能失效。

常见失败一:找不到编译器

检查 ANDROID_NDK_HOME

检查主机目录名称。

检查 NDK 是否安装完整。

不要混用多个 NDK 的 clang。

先删除构建目录再重新配置。

rm -rf build/android-arm64
./scripts/build.sh

常见失败二:Exec format error

通常是架构不匹配。

查看设备 ABI。

adb shell getprop ro.product.cpu.abilist

查看 ELF machine 字段。

readelf -h native_service | grep Machine

arm64 设备使用 arm64-v8a

x86 模拟器使用 x86_64

不要把宿主机的可执行文件推到设备上。

常见失败三:Permission denied

检查执行位。

adb shell ls -l /data/local/tmp/koko_native_service
adb shell chmod 700 /data/local/tmp/koko_native_service

检查目录挂载属性。

adb shell mount | grep data

检查当前 UID。

adb shell id

不要直接推断这是 SELinux。

先排除文件模式和路径错误。

常见失败四:Address already in use

上一次服务可能还在运行。

adb shell ps -A | grep koko_native_service

结束自己的调试进程。

adb shell pkill -f koko_native_service || true
adb shell rm -f /data/local/tmp/koko.sock

某些设备没有 pkill

可以读取 PID 后使用 kill

adb shell 'pidof koko_native_service'

常见失败五:客户端读不完整

检查是否使用了长度前缀。

检查是否处理了短写入。

检查长度是否使用网络字节序。

检查客户端和服务端的最大长度是否一致。

抓取请求前先打印长度。

不要用一次 read 等价于完整消息。

可观测性清单

启动时打印版本。

打印 socket 路径。

打印当前 UID。

打印 ABI 信息。

打印每次请求耗时。

打印错误码而不是敏感数据。

为每个客户端分配连接序号。

退出时打印原因。

保留最近一次失败的上下文。

不要把完整 payload 写入生产日志。

从二进制到应用层

如果需要由 Android 应用启动 native 进程,必须考虑应用 UID 的限制。

应用不能默认执行任意系统目录中的文件。

应用能访问的私有目录取决于自身沙箱。

JNI 适合把 native 能力封装进应用进程。

独立二进制适合设备调试和平台集成。

两者的生命周期并不相同。

JNI 崩溃会直接影响宿主应用。

独立进程崩溃只影响该进程。

独立进程需要额外的 IPC。

JNI 边界示例

#include <jni.h>

extern "C" JNIEXPORT jstring JNICALL
Java_com_koko_tool_NativeBridge_ping(JNIEnv* env, jobject) {
    return env->NewStringUTF("native-ready");
}

JNI 函数名必须匹配包名和类名。

生产代码更推荐显式注册。

JNIEnv 只能在线程上下文中使用。

不要把 JNIEnv 跨线程保存。

版本策略

记录 NDK 版本。

记录 CMake 版本。

记录最低 API。

记录目标 ABI。

记录设备 Android 版本。

记录厂商和构建号。

同一份二进制在不同 ROM 上可能行为不同。

把设备信息写进调试报告。

结束前的最小验收

设备已连接。

ABI 与构建目标一致。

文件拥有执行权限。

进程能启动。

日志能看到 ready。

socket 文件出现。

客户端能连接。

服务端能返回 ack。

关闭进程后 socket 文件消失。

重复启动不会因为旧文件失败。

错误请求不会让服务崩溃。

一条命令链

./scripts/build.sh
./scripts/push.sh
adb shell 'nohup /data/local/tmp/koko_native_service /data/local/tmp/koko.sock >/data/local/tmp/koko.out 2>&1 &'
adb shell 'sleep 0.2; cat /data/local/tmp/koko.out'
adb shell 'ls -l /data/local/tmp/koko.sock'

这里的 nohup 是否可用取决于设备 toybox。

前台运行更适合第一次调试。

后台运行前先确认前台链路正确。

结语

一个可用的 native service 不在于代码有多大。

它在于协议边界可解释。

它在于失败路径可复现。

它在于启动和停止都能被观察。

它在于不把设备差异藏起来。

先把这条链路跑通,再考虑 Binder、线程池和更高层的服务注册。