manipulationrobot-armcmakeeigencppgoogletestpython

Dựng project C++ cho robot controller: CMake & Eigen

Setup C++/CMake/Eigen, GoogleTest và Python env để làm nền cho robot arm controller trong toàn bộ series.

Nguyễn Anh Tuấn13 tháng 6, 202614 phút đọc
Dựng project C++ cho robot controller: CMake & Eigen

Trong bài mở đầu của series, chúng ta đã xác định mục tiêu: tự xây một robot arm controller cổ điển theo kiểu công nghiệp, đủ sạch để đi từ mô hình robot, forward kinematics, inverse kinematics, MoveJ, MoveL, jogging cho tới motion planning. Bài này không đi sâu vào toán học robot. Thay vào đó, ta dựng nền móng kỹ thuật: một project C++ build được bằng CMake, dùng Eigen cho đại số tuyến tính, có executable demo, có unit test bằng GoogleTest, và có môi trường Python riêng để prototype thuật toán nhanh trước khi port sang C++.

Nghe có vẻ "setup", nhưng đây là phần quyết định chất lượng của 13 bài còn lại. Nếu project không có cấu trúc rõ, mỗi bài sau sẽ biến thành một đống file rời: lúc thì include path sai, lúc thì test không chạy, lúc thì Python prototype dùng convention khác C++. Robot controller rất nhạy với chi tiết nhỏ: radian hay degree, ma trận nhân bên trái hay bên phải, frame nào là base, frame nào là tool, joint vector có đúng thứ tự không. Một skeleton tốt giúp ta kiểm chứng từng lớp thay vì debug mọi thứ cùng lúc.

Logo CMake dùng cho build system C++ - nguồn: cmake.org
Logo CMake dùng cho build system C++ - nguồn: cmake.org

Kết quả sau bài này

Sau khi làm xong, bạn sẽ có một thư mục arm-controller/ với cấu trúc:

arm-controller/
├── CMakeLists.txt
├── include/
│   └── arm_controller/
│       └── math.hpp
├── src/
│   ├── math.cpp
│   └── main.cpp
├── tests/
│   └── test_math.cpp
└── python/
    ├── requirements.txt
    └── prototype_fk.py

Phần C++ sẽ build ra:

  • arm_controller: thư viện chứa logic controller, kinematics, trajectory trong các bài sau.
  • app: executable demo để chạy thử thuật toán từ terminal.
  • arm_controller_tests: test binary chạy qua ctest.

Phần Python sẽ có môi trường .venv riêng với numpy, roboticstoolbox-python, spatialmath-pythonmatplotlib. Ta dùng Python để thử ý tưởng nhanh: vẽ trajectory, kiểm chứng transform, so sánh FK/IK với thư viện tham chiếu. Sau đó C++ mới là code runtime chính.

Cài toolchain trên Ubuntu

Trên Ubuntu, cách đơn giản nhất là cài compiler, CMake, Git, Ninja và Eigen từ apt:

sudo apt update
sudo apt install -y build-essential clang cmake ninja-build git libeigen3-dev python3 python3-venv python3-pip

build-essential cung cấp GCC/G++, make và các header cơ bản để build C/C++. clang không bắt buộc, nhưng rất hữu ích nếu bạn muốn so sánh warning giữa GCC và Clang. cmake là build-system generator. ninja-build là backend build nhanh và gọn hơn Make trong nhiều project. libeigen3-dev cài Eigen vào hệ thống; trên Ubuntu, header thường nằm dưới /usr/include/eigen3.

Kiểm tra phiên bản:

gcc --version
g++ --version
clang++ --version
cmake --version
ninja --version
python3 --version

Trong series này ta yêu cầu CMake tối thiểu 3.16. Lý do không phải vì mọi tính năng đều cần đúng 3.16, mà vì đây là mốc đủ phổ biến trên các distro cũ như Ubuntu 20.04, đồng thời đã có FetchContent, target-based CMake và workflow cmake -S . -B build rất ổn định. Eigen yêu cầu CMake thấp hơn nhiều cho CMake integration, nhưng project controller của ta nên đặt chuẩn cao hơn để tránh cách viết CMake cổ điển.

Nếu cmake --version thấp hơn 3.16, bạn có ba lựa chọn: nâng Ubuntu, cài CMake từ Kitware apt repository, hoặc dùng binary release từ cmake.org. Với người mới, tránh tự build CMake từ source trừ khi thật sự cần.

Vì sao dùng CMake và Eigen?

C++ robot controller thường cần ba thứ: hiệu năng ổn định, kiểm soát layout dữ liệu, và khả năng build trên nhiều máy. CMake giải quyết phần build: nó sinh project cho Ninja, Make, Visual Studio, Xcode; quản lý include path, compiler option, dependency và test target. Cách viết hiện đại là target-based: thay vì dùng include_directories() toàn cục, ta gắn include path và thư viện trực tiếp vào target nào cần chúng.

Eigen là thư viện header-only cho vector, matrix và phép biến đổi tuyến tính. Với robotics cổ điển, Eigen đủ nhẹ để dùng cho VectorXd, Matrix4d, Isometry3d, Jacobian, pseudo-inverse và các phép nội suy cơ bản. Quan trọng hơn, Eigen export CMake target Eigen3::Eigen, nên ta có thể liên kết target này bằng target_link_libraries() thay vì hardcode /usr/include/eigen3.

Logo Eigen trong tài liệu CMake guide - nguồn: libeigen.gitlab.io
Logo Eigen trong tài liệu CMake guide - nguồn: libeigen.gitlab.io

Một nguyên tắc cần nhớ: trong CMake hiện đại, dependency nên đi qua target. Nếu arm_controller dùng Eigen trong public header, ta link Eigen theo scope PUBLIC; nếu Eigen chỉ dùng trong file .cpp, ta link PRIVATE. Trong skeleton bên dưới, header math.hpp include Eigen, nên dùng PUBLIC.

Tạo cấu trúc thư mục

Bắt đầu từ một thư mục trống:

mkdir -p arm-controller/include/arm_controller arm-controller/src arm-controller/tests arm-controller/python
cd arm-controller

Ta tách include/src/ để thư viện có public API rõ ràng. Trong các bài sau, header public sẽ chứa kiểu dữ liệu như JointState, RobotModel, Pose, TrajectoryPoint. File .cpp chứa implementation. Thư mục tests/ chứa unit test chạy nhanh, không phụ thuộc phần cứng. Thư mục python/ chứa prototype, notebook hoặc script vẽ đồ thị.

Tạo header đầu tiên:

// include/arm_controller/math.hpp
#pragma once

#include <Eigen/Dense>

namespace arm_controller {

Eigen::Vector3d addVectors(const Eigen::Vector3d& a, const Eigen::Vector3d& b);

double clamp(double value, double lower, double upper);

}  // namespace arm_controller

Tạo implementation:

// src/math.cpp
#include "arm_controller/math.hpp"

#include <algorithm>

namespace arm_controller {

Eigen::Vector3d addVectors(const Eigen::Vector3d& a, const Eigen::Vector3d& b) {
    return a + b;
}

double clamp(double value, double lower, double upper) {
    return std::clamp(value, lower, upper);
}

}  // namespace arm_controller

Tạo app demo:

// src/main.cpp
#include "arm_controller/math.hpp"

#include <Eigen/Dense>
#include <iostream>

int main() {
    const Eigen::Vector3d q1(0.1, 0.2, 0.3);
    const Eigen::Vector3d q2(0.4, 0.5, 0.6);
    const Eigen::Vector3d q = arm_controller::addVectors(q1, q2);

    std::cout << "q = " << q.transpose() << '\n';
    std::cout << "clamp(1.5, 0.0, 1.0) = "
              << arm_controller::clamp(1.5, 0.0, 1.0) << '\n';

    return 0;
}

Tạo test:

// tests/test_math.cpp
#include "arm_controller/math.hpp"

#include <gtest/gtest.h>

TEST(MathTest, AddsEigenVectors) {
    const Eigen::Vector3d a(1.0, 2.0, 3.0);
    const Eigen::Vector3d b(4.0, 5.0, 6.0);

    const Eigen::Vector3d result = arm_controller::addVectors(a, b);

    EXPECT_DOUBLE_EQ(result.x(), 5.0);
    EXPECT_DOUBLE_EQ(result.y(), 7.0);
    EXPECT_DOUBLE_EQ(result.z(), 9.0);
}

TEST(MathTest, ClampsValueIntoRange) {
    EXPECT_DOUBLE_EQ(arm_controller::clamp(1.5, 0.0, 1.0), 1.0);
    EXPECT_DOUBLE_EQ(arm_controller::clamp(-0.2, 0.0, 1.0), 0.0);
    EXPECT_DOUBLE_EQ(arm_controller::clamp(0.4, 0.0, 1.0), 0.4);
}

Đây là code nhỏ, nhưng đã chạm đúng các mảnh ta cần: public header, thư viện, executable, Eigen type, namespace, test.

CMakeLists.txt đầy đủ

Đặt file này ở root của arm-controller/:

cmake_minimum_required(VERSION 3.16)

project(
    ArmController
    VERSION 0.1.0
    DESCRIPTION "Classical robot arm controller examples"
    LANGUAGES CXX
)

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

find_package(Eigen3 REQUIRED NO_MODULE)

add_library(arm_controller
    src/math.cpp
)

target_include_directories(arm_controller
    PUBLIC
        ${CMAKE_CURRENT_SOURCE_DIR}/include
)

target_link_libraries(arm_controller
    PUBLIC
        Eigen3::Eigen
)

target_compile_options(arm_controller
    PRIVATE
        -Wall
        -Wextra
        -Wpedantic
)

add_executable(app
    src/main.cpp
)

target_link_libraries(app
    PRIVATE
        arm_controller
)

include(FetchContent)

FetchContent_Declare(
    googletest
    URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.zip
)

set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)

enable_testing()

add_executable(arm_controller_tests
    tests/test_math.cpp
)

target_link_libraries(arm_controller_tests
    PRIVATE
        arm_controller
        GTest::gtest_main
)

include(GoogleTest)
gtest_discover_tests(arm_controller_tests)

Giải thích từng phần:

cmake_minimum_required(VERSION 3.16) đặt version tối thiểu. Dòng này nên nằm đầu file để CMake áp dụng policy tương ứng. Không nên đặt quá thấp chỉ để "cho chắc", vì CMake policy cũ có thể làm hành vi dependency khó đoán.

project(...) khai báo tên project, version, mô tả và ngôn ngữ. LANGUAGES CXX nói rõ project chỉ cần C++, tránh CMake tự kiểm tra compiler C không cần thiết.

Ba dòng CMAKE_CXX_STANDARD chọn C++17, bắt buộc compiler hỗ trợ chuẩn này, và tắt extension kiểu gnu++17. Robot controller nên dùng chuẩn rõ ràng để build nhất quán giữa GCC, Clang và CI.

find_package(Eigen3 REQUIRED NO_MODULE) yêu cầu CMake tìm Eigen qua config package do Eigen cung cấp. REQUIRED làm configure fail ngay nếu chưa cài Eigen. NO_MODULE ưu tiên config package thay vì FindEigen3.cmake kiểu cũ. Sau dòng này, ta có target Eigen3::Eigen.

add_library(arm_controller src/math.cpp) tạo thư viện chính. Trong các bài sau, ta sẽ thêm robot_model.cpp, kinematics.cpp, trajectory.cpp, jogging.cpp vào target này. Việc gom logic vào thư viện giúp app, test và sau này Python binding hoặc ROS2 node dùng chung code.

target_include_directories(arm_controller PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include) nói rằng target này expose header trong include/. Scope PUBLIC nghĩa là chính arm_controller cần include path này, và target nào link tới arm_controller cũng được kế thừa include path. Nhờ vậy app có thể #include "arm_controller/math.hpp" mà không cần tự khai báo include path lần nữa.

target_link_libraries(arm_controller PUBLIC Eigen3::Eigen) gắn Eigen vào thư viện. Vì public header của ta include <Eigen/Dense>, scope phải là PUBLIC. Nếu đặt PRIVATE, app hoặc test có thể compile fail khi include header.

target_compile_options(...) bật warning cho thư viện. Với beginner, warning là bạn đồng hành tốt: nó bắt biến không dùng, conversion đáng ngờ, chữ ký hàm thiếu rõ ràng. Khi project lớn hơn, ta có thể thêm option theo compiler bằng generator expression, nhưng skeleton này giữ đơn giản.

add_executable(app src/main.cpp) tạo chương trình demo. Tên binary sẽ là app, nằm trong build/ sau khi build.

target_link_libraries(app PRIVATE arm_controller) liên kết app với thư viện. App dùng API của thư viện, nhưng không expose tiếp cho target nào khác, nên scope là PRIVATE.

include(FetchContent) load module FetchContent. Theo tài liệu CMake, module này tải dependency ở configure time để dependency có thể được dùng ngay bằng add_subdirectory() hoặc target CMake.

FetchContent_Declare(googletest ...) khai báo nguồn tải GoogleTest. GoogleTest quickstart chính thức cũng dùng FetchContent cho CMake. Trong project thật, bạn có thể pin theo tag hoặc commit hash. Pin theo tag dễ đọc; pin theo commit hash tái lập tốt hơn.

set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) chủ yếu hữu ích trên Windows để tránh mismatch runtime library. Trên Linux, dòng này không gây hại.

FetchContent_MakeAvailable(googletest) tải và add GoogleTest vào build. Sau dòng này, target GTest::gtest_main khả dụng.

enable_testing() bật CTest cho project. Không có dòng này, ctest sẽ không biết test nào cần chạy.

add_executable(arm_controller_tests tests/test_math.cpp) tạo binary test. Test cũng là một executable C++ bình thường, chỉ khác ở chỗ nó link GoogleTest.

target_link_libraries(arm_controller_tests PRIVATE arm_controller GTest::gtest_main) liên kết test với thư viện cần kiểm tra và test main do GoogleTest cung cấp.

include(GoogleTest)gtest_discover_tests(arm_controller_tests) cho phép CMake tự khám phá test case từ binary GoogleTest. Kết quả là ctest hiển thị test theo từng test case thay vì chỉ thấy một binary chung.

Build out-of-source

Chạy configure:

cmake -S . -B build -G Ninja

-S . nghĩa là source directory là thư mục hiện tại, nơi có CMakeLists.txt. -B build nghĩa là build directory là build/. Đây gọi là out-of-source build: toàn bộ file sinh ra như CMakeCache.txt, object file, dependency download, binary đều nằm trong build/, còn source tree vẫn sạch.

Nếu không dùng Ninja, bỏ -G Ninja:

cmake -S . -B build

Build:

cmake --build build -j

cmake --build build gọi backend đã được generate. Nếu backend là Ninja thì CMake gọi Ninja; nếu là Make thì gọi Make. -j cho phép build song song. Đây là cách portable hơn so với gọi trực tiếp make hoặc ninja.

Chạy app:

./build/app

Kết quả kỳ vọng:

q = 0.5 0.7 0.9
clamp(1.5, 0.0, 1.0) = 1

Chạy test:

ctest --test-dir build --output-on-failure

Nếu mọi thứ đúng, bạn sẽ thấy các test pass. Nếu test fail, --output-on-failure in log chi tiết để debug.

Debug build và Release build

Với generator single-config như Ninja hoặc Unix Makefiles, chọn build type lúc configure:

cmake -S . -B build-debug -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build-debug -j

cmake -S . -B build-release -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build-release -j

Giữ hai build directory riêng giúp bạn chuyển giữa debug và release mà không xóa cache. Debug có symbol để dùng gdb hoặc lldb. Release bật optimization, phù hợp khi benchmark IK, trajectory generator hoặc servo loop.

Trong robot controller, đừng benchmark bằng Debug build. Eigen trong Debug có thể chậm hơn rất nhiều, và bạn sẽ đánh giá sai thuật toán. Ngược lại, đừng debug logic phức tạp bằng Release nếu stack trace bị tối ưu hóa quá mạnh.

Dựng Python environment song song

Python không thay thế C++ runtime trong series này. Nó là phòng thí nghiệm. Với vài dòng NumPy, bạn có thể kiểm tra công thức FK, vẽ quỹ đạo, thử damped least squares IK, hoặc so sánh transform với Robotics Toolbox for Python. Khi kết quả ổn, ta port logic sang C++.

Swift simulator trong Robotics Toolbox for Python - nguồn: petercorke.github.io
Swift simulator trong Robotics Toolbox for Python - nguồn: petercorke.github.io

Tạo python/requirements.txt:

numpy
roboticstoolbox-python
spatialmath-python
matplotlib

Tạo virtual environment:

python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -r python/requirements.txt

Theo Python Packaging User Guide, venv tạo một Python installation cô lập trong thư mục .venv, và khi activate, python cùng pip trong shell sẽ trỏ vào môi trường đó. Vì vậy package robotics của project này không đụng vào Python hệ thống.

Tạo prototype nhỏ:

# python/prototype_fk.py
import numpy as np
from spatialmath import SE3
import roboticstoolbox as rtb

def main() -> None:
    robot = rtb.models.DH.Puma560()
    q = np.array([0.0, -0.4, 0.3, 0.0, 0.2, 0.0])
    T = robot.fkine(q)

    print("q:", q)
    print("End-effector pose:")
    print(T)

    offset = SE3.Tx(0.05)
    print("Pose with 5 cm tool offset:")
    print(T * offset)

if __name__ == "__main__":
    main()

Chạy:

python python/prototype_fk.py

Trong các bài sau, ta sẽ dùng Python để tạo dữ liệu kiểm chứng cho C++. Ví dụ: Python tính FK cho một robot mẫu, C++ phải trả về ma trận gần giống trong tolerance 1e-9. Với IK, Python giúp vẽ sai số theo iteration. Với trajectory, Python giúp plot vị trí, vận tốc, gia tốc và jerk trước khi ta viết profile generator trong C++.

Quy ước dữ liệu ngay từ đầu

Skeleton build được mới là bước một. Bước hai là chọn quy ước nhất quán:

Thành phần Quy ước trong series
Góc khớp radian
Vận tốc khớp radian/second
Pose Eigen::Isometry3d hoặc ma trận 4x4
Vector joint Eigen::VectorXd khi số bậc tự do runtime, Eigen::Matrix<double, 6, 1> khi cố định 6 DOF
Tên header include/arm_controller/<module>.hpp
Namespace arm_controller
Test tolerance bắt đầu từ 1e-9 cho phép toán đơn giản, nới dần khi có lặp số

Đừng xem bảng này là chi tiết phụ. Nhiều bug robot đến từ convention bị trộn: URDF dùng meter, một đoạn code dùng millimeter; Python prototype trả về transform base-to-tool, C++ lại hiểu tool-to-base; input dashboard là degree nhưng solver nhận radian. Ta sẽ bắt đầu nghiêm từ bài setup để các bài robot model, FK/IK/Jacobianjogging servo không phải sửa lại nền.

Checklist khi lỗi build

Nếu find_package(Eigen3 REQUIRED NO_MODULE) fail, kiểm tra:

dpkg -L libeigen3-dev | grep Eigen3Config.cmake

Nếu không có output, cài lại:

sudo apt install --reinstall libeigen3-dev

Nếu GoogleTest tải lỗi, thường là do mạng hoặc proxy. Chạy lại configure:

cmake -S . -B build -G Ninja

Nếu muốn làm việc offline, bạn có thể dùng submodule hoặc package manager như vcpkg/Conan, nhưng series này giữ FetchContent để beginner có ít bước nhất.

Nếu #include "arm_controller/math.hpp" fail, kiểm tra target_include_directories(arm_controller PUBLIC ...). Đừng sửa bằng cách thêm -I thủ công vào command line; đó là dấu hiệu CMake target chưa mô tả đúng dependency.

Nếu ctest báo không có test, kiểm tra đã có enable_testing()gtest_discover_tests(...). Ngoài ra, hãy chạy ctest --test-dir build -N để chỉ liệt kê test mà không thực thi.

Tài liệu tham khảo

Kết luận

Bạn đã có một skeleton đủ nghiêm túc cho robot arm controller: C++17, CMake target rõ ràng, Eigen qua Eigen3::Eigen, executable demo, GoogleTest qua FetchContent, out-of-source build, và Python virtual environment để prototype. Đây là project nhỏ, nhưng nó đã có hình dáng của một codebase có thể mở rộng.

Bài tiếp theo sẽ dùng nền này để dựng robot model: link, joint, limit, frame tree, transform chain và dữ liệu cần thiết để FK/IK không còn là công thức rời rạc.

Bài viết liên quan

NT

Nguyễn Anh Tuấn

Robotics & AI Engineer. Building VnRobo — sharing knowledge about robot learning, VLA models, and automation.

Khám phá VnRobo

Bài viết liên quan

Hệ tọa độ robot: Base, Tool, User Frame và TCP
manipulation

Hệ tọa độ robot: Base, Tool, User Frame và TCP

13/6/202611 phút đọc
NT
Classical Robot Arm Control: Roadmap & Controller Stack
manipulation

Classical Robot Arm Control: Roadmap & Controller Stack

13/6/202616 phút đọc
NT
MoveC và MoveP: cung tròn và đường quá trình
manipulation

MoveC và MoveP: cung tròn và đường quá trình

13/6/202616 phút đọc
NT