Bongjun Jang

Odroid 개발보드에서 Zig와 C를 같이 사용하기

Odroid-N2+ Development Board

Odroid-N2+ 개발 보드

Odroid-N2+ 개발 보드를 구입해서 네트워크 연결을 관리하는 프로그램을 작성하는 실험을 해보고 있다. 개발 보드에 설치된 우분투 리눅스에서 블루투스나 ZigBee 등 네트워크 프로그래밍을 어떻게 하는지 방법을 찾아보고 있다.

제일 먼저 리눅스의 블루투스 서브시스템인 BlueZ를 사용해보고 싶었다. ZigBee 도 사용해보고 싶은데 ZigBee 동글과 센서를 알리에서 주문했더니 배송이 꽤 걸린다고 해서 블루투스 먼저 시도해봤다.

지금 사용하는 N2+ 보드는 성능이 꽤 나오는 보드이지만, 더 성능이 제한되는 소형 보드에서도 실행 가능한 프로그램이면 좋겠어서 C나 C++의 성능이 나오는 프로그래밍 언어를 찾아보았다.

Zig를 선택하고 빌드 시스템 구축하기

제일 먼저 내가 익숙해하면서도 빠른 개발이 가능한 Python을 검토해보았다. Python도 저수준의 프로그래밍이 가능하지만, 파이썬에서 BlueZ를 사용할 수 있는 pybluez 프로젝트가 개발이 중단되어 있는 상태라 Python은 제외하였다.

C나 C++를 사용해야 싶었지만 언어의 개발 경험이 좋지도 않고 최근 C나 C++을 대체할 수 있는 언어가 많이 나와있기 때문에 대체 언어들을 찾아보았다.

제일 먼저 살펴본 언어는 그 유명한 Rust다. Rust는 C와 상호운용이 가능하면서도 메모리 안전한 프로그램을 작성하고, 성능도 준수한 프로그램을 작성할 수 있다. 그런데 C로 작성된 BlueZ 라이브러리를 사용하려면 C로 작성된 프로그램과 Rust로 작성한 프로그램을 링킹하는 과정이 필요하다. 이러면 Rust 컴파일러와 C 컴파일러를 같이 사용하면서 빌드 시스템이 복잡해질 수 있기 때문에 일단 여기까지 조사하고 넘어갔다.

Zig Programming Language

Zig Programming Language

그 다음 살펴본 언어는 Zig 다. Zig는 Rust처럼 C 스타일 프로그래밍 언어로, 메모리 안전성과 명시적인 제어 흐름, 컴파일시간 계산이 특징인 언어다. Zig의 강점은 C, C++ 등 다른 언어로 작성된 프로그램과 상호운용이 아주 쉽다는 점이다. Zig의 빌드 시스템(zig build)을 이용하면 C, C++로 작성된 언어를 쉽게 Zig 프로그램과 통합할 수 있다. 프로그래밍하는 것처럼 빌드 시스템을 꾸릴 수 있기 때문에 빌드 시스템을 구축하는 것도 어렵지 않다.

Zig 프로젝트와 BlueZ 통합하기

BlueZ 라이브러리를 Zig 코드와 통합해보자. 일단 BlueZ » Download 에서 유저 공간에서 사용할 수 있는 BlueZ 패키지를 내려받고 프로젝트에 위치시킨다. 프로젝트 구조는 다음과 같다. (tree -L 2)

.
|-- build.zig
|-- libs
|   `-- bluez-5.66
|       |-- ...
|       |-- lib
|       |-- src
|       |-- test
|       |-- tools
|       `-- unit
|-- src
    |-- bluetoothlib.zig
    `-- main.zig

libs 폴더를 두어 여기에 bluez-5.66 패키지를 위치시켰다. BlueZ에서 제공하는 라이브러리를 사용할 것이므로 bluez-5.66/lib에 있는 bluetooth.c, uuid.c, sdp.c, hci.c를 컴파일해야 한다. 이 소스코드들은 bluez-5.66에 있는 헤더파일들에 선언된 프로토타입과 전처리 매크로가 필요하므로 컴파일 옵션에 -I로 추가한다. 또한 BlueZ는 libc에 의존성이 있으므로 exe.linkLibc()를 통해 링킹해준다. libc의 함수들을 사용할 수 있도록 /usr/include 또한 -I 옵션으로 포함시켜준다. Zig의 빌드시스템에는 암묵적으로 libc가 포함되지 않아 모두 명시적으로 링킹해주어야한다.

// build.zig

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const exe = b.addExecutable(.{
        .name = "opera",
        .root_source_file = .{ .path = "src/main.zig" },
        .target = target,
        .optimize = optimize,
    });
    exe.linkLibC();
    exe.addCSourceFiles(&.{
        "./libs/bluez-5.66/lib/bluetooth.c",
        "./libs/bluez-5.66/lib/hci.c",
        "./libs/bluez-5.66/lib/uuid.c",
        "./libs/bluez-5.66/lib/sdp.c",
    }, &.{
        "-Ilibs/bluez-5.66",
        "-I/usr/include",
    });

    b.installArtifact(exe);

    const run_cmd = b.addRunArtifact(exe);

    run_cmd.step.dependOn(b.getInstallStep());

    if (b.args) |args| {
        run_cmd.addArgs(args);
    }

    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);

    const unit_tests = b.addTest(.{
        .root_source_file = .{ .path = "src/main.zig" },
        .target = target,
        .optimize = optimize,
    });

    const run_unit_tests = b.addRunArtifact(unit_tests);

    const test_step = b.step("test", "Run unit tests");
    test_step.dependOn(&run_unit_tests.step);
}

시스템에 연결된 블루투스 디바이스를 찾아 소켓을 여는 간단한 프로그램을 작성해보자.

// main.zig
const std = @import("std");
const btlib = @import("bluetoothlib.zig");

pub fn main() !void {
    const dev_id = try btlib.get_route();
    const sock = try btlib.get_socket(dev_id);
    std.debug.print("dev_id = {d}, sock = {d}\n", .{ dev_id, sock });
}

bluetoothlib.zig에서는 BlueZ Library에서 정의한 함수에 접근할 수 있도록 C 구조체와 함수의 프로토타입을 지정한다. 그리고 Zig 스타일의 함수로 감싸 퍼블릭 함수로 제공한다.

// bluetoothlib.zig

// C API for the Linux Bluetooth stack

/// Bluetooth device address is a contiguous 6-byte array
pub const bdaddr_t = extern struct {
    b: [6]u8 align(1),
};

extern "c" fn hci_get_route(bdaddr: ?*bdaddr_t) c_int;
extern "c" fn hci_devid(str: *const u8) c_int;

pub fn get_route() !i32 {
    const dev_id = hci_get_route(null);
    if (dev_id < 0) {
        return BluetoothError.AdapterNotFound;
    }
    return @as(i32, dev_id);
}

pub fn get_socket(dev_id: i32) !i32 {
    const sock = hci_open_dev(dev_id);
    if (sock < 0) {
        return BluetoothError.CantOpenSocket;
    }
    return @as(i32, sock);
}

크로스 컴파일 개발 환경 만들기

내 랩탑은 x86 리눅스이므로 zig build를 실행하면 x86-64-linux-gnu 로 컴파일된다. 컴파일 속도는 내 랩탑이 빠르므로 내 랩탑에서 빌드하고 그 결과를 개발 보드에서 실행하고 싶다. 따라서 빌드 시에 컴파일 대상을 aarch64-linux-gnu로 변경한다. aarch64 리눅스 시스템의 GNU LIBC(GLIBC)를 사용한다는 뜻이다. 다음과 같이 Makefile을 작성해 ssh를 이용해 개발 보드에서 실행해보자.

TARGET_BINARY = ./zig-out/bin/opera
TARGET_MACHINE = /* REDACTED */

.PHONY: build send run connect

all: build send run

build:
	zig build -Dtarget=aarch64-linux-gnu
	@echo "Build complete."

send:
	scp $(TARGET_BINARY) $(TARGET_MACHINE):~
	@echo "Binary sent to target machine."

run:
	ssh $(TARGET_MACHINE) ~/opera
	@echo "Target binary executed on target machine."

connect:
	ssh $(TARGET_MACHINE)
>> make
dev_id = 0, sock = 3

잘 된다!

배운 것들