7,696
社区成员
发帖
与我相关
我的任务
分享犀牛派 X1 GPIO 入门实战 01
从 C++ 点亮 LED 到 ROS 2 节点
拿到犀牛派后,很多人会先尝试视觉识别、定位或导航。这次我们先从一个更小的实验开始:用 GPIO 点亮一颗 LED,再把控制逻辑放进 ROS 2 节点。
这篇文章以犀牛派 X1 为例,使用 Ubuntu 22.04、ROS 2 Humble 和 libgpiod 1.x。我们会依次完成接线、命令行验证、C++ 控制和 ROS 2 定时闪烁。学完后,你应该能分清物理针脚编号与 GPIO line offset,也能在自己的节点中完成 GPIO 的申请、写入和释放。

图 1 犀牛派 X1 开发板。
|
项目 |
本文环境 |
|
开发板 |
犀牛派 X1 |
|
操作系统 |
Ubuntu 22.04 |
|
ROS 2 |
Humble |
|
GPIO 库 |
libgpiod 1.x C API |
|
编译工具 |
g++、CMake、colcon |
|
编辑器 |
VS Code,可换成自己习惯的编辑器 |
本文默认已经安装 ROS 2 Humble。GPIO 部分先独立运行,后面再接入 ROS 2。
Linux 用户态程序适合本例的低频 LED 控制,但普通 sleep 和 ROS 2 定时器都不提供硬实时保证。对于高频 PWM、精确时序等需求,需要结合硬件外设或 MCU 设计,不能把本例的控制方式直接套过去。
裸 LED 必须串联限流电阻。 电阻值需要结合 LED 的正向压降、目标电流和板卡允许的 GPIO 电流选择。如果使用自带限流电阻的 LED 模块,则按模块说明接线。
可以先用下面的关系估算电阻:
R ≈ (供电电压 − LED 正向压降) / LED 电流
例如,仅作计算演示:假设供电为 3.3 V、LED 正向压降为 2.0 V、目标电流为 1 mA,则计算得到约 1.3 kΩ。这个例子不代表 X1 的 GPIO 额定电流;实际选择还要核对官方电气规格和 GPIO 带载时的输出电压。
本例涉及三种容易混淆的编号:
|
名称 |
含义 |
本文示例 |
|
物理针脚编号 |
排针上实际的位置 |
PIN7 |
|
板卡功能名称 |
官方引脚图中的标注 |
GPIO_00 |
|
GPIO line offset |
某个 gpiochip 内的偏移编号 |
gpiochip0 中的 0 |

图 2 X1 部分排针定义。接线前请同时核对官方完整引脚图和板上针脚方向。
按照图中的排针定义,本文用到 PIN1 的 3.3V OUT、PIN6 的 GND,以及 PIN7 的 GPIO_00。程序沿用本次实验中的 /dev/gpiochip0、offset 0。
物理 PIN7 并不意味着程序里要填 offset 7。 不同硬件版本或系统镜像的映射可能不同;运行前应结合对应版本的官方资料,确认控制器与 line offset 确实对应目标排针。后面的 gpioinfo 能显示系统识别的 line 和占用情况,但不能单独证明物理接线位置。
接线或改线前先断电。LED 正极通常为长脚,负极通常为短脚;引脚剪短或器件不同的情况下,以器件标记或数据手册为准。
可以先用 3.3 V 电源检查 LED 的方向和连接:

图 3 使用 3.3 V 电源检查 LED 方向和连接。
确认 LED 能亮后,再断电改为 GPIO 推挽输出接法:

图 4 GPIO 推挽输出接线,写入 1 点亮,写入 0 熄灭。
在后面的普通输出示例中,GPIO 写入 1 时点亮 LED,写入 0 时熄灭 LED。不要把旁边的 5 V 电源针脚接入 GPIO。
进入开发板的终端,安装编译工具和 GPIO 库:
sudo apt update
sudo apt install build-essential cmake pkg-config gpiod libgpiod-dev
确认命令行工具和开发库的版本:
gpioset --version
pkg-config --modversion libgpiod
本文后续命令和 C++ 代码使用 libgpiod 1.x。 如果查到的是 2.x,不要直接照搬:2.x 的命令行参数和 C API 都有变化。另一个容易混淆的地方是,Ubuntu 的 libgpiod2 软件包名中的 2 是 ABI 标识,并不表示它提供 libgpiod 2.x API。
查看 GPIO 控制器和 line:
gpiodetect
gpioinfo gpiochip0
如果提示没有权限,可以使用 sudo 重试;如果系统中的控制器名称不同,则先确认映射,再替换命令中的 gpiochip0。还要检查目标 line 是否已被其他程序或驱动占用。
确认接线和映射后,先从命令行点亮 LED:
# libgpiod 1.x:保持申请,直到收到退出信号
sudo gpioset --mode=signal gpiochip0 0=1
命令保持运行时,程序会持续占用这条 line。按 Ctrl+C 结束后,可以再执行以下命令验证低电平:
sudo gpioset --mode=signal gpiochip0 0=0
验证完成后再次按 Ctrl+C,释放 line,再运行下一节的 C++ 程序。
这里的 --mode=signal 很关键:libgpiod 1.x 的 gpioset 默认设置后就退出,退出后会释放 GPIO 请求,释放后的电平不保证保持。因此,不能把一条瞬间退出的 gpioset 命令当作永久开关。如果程序提示 Device or resource busy,先检查是否还有 gpioset 在运行。
在自己的目录中新建 led_on.cpp,写入以下代码。程序申请 GPIO 后点亮 LED,等待约 5 秒,再写入低电平并释放资源。
#include <gpiod.h>
#include <chrono>
#include <csignal>
#include <cstdio>
#include <thread>
namespace {
volatile std::sig_atomic_t stop_requested = 0;
void handle_signal(int)
{
stop_requested = 1;
}
} // namespace
int main()
{
// 示例采用 libgpiod 1.x;运行前确认芯片、line offset 和实际接线。
const char* chip_path = "/dev/gpiochip0";
const unsigned int line_offset = 0;
const int kLedOn = 1;
const int kLedOff = 0;
if (std::signal(SIGINT, handle_signal) == SIG_ERR ||
std::signal(SIGTERM, handle_signal) == SIG_ERR) {
std::perror("signal");
return 1;
}
gpiod_chip* chip = gpiod_chip_open(chip_path);
if (chip == nullptr) {
std::perror("gpiod_chip_open");
return 1;
}
gpiod_line* line = gpiod_chip_get_line(chip, line_offset);
if (line == nullptr) {
std::perror("gpiod_chip_get_line");
gpiod_chip_close(chip);
return 1;
}
// 默认是推挽输出;申请时先输出低电平。
if (gpiod_line_request_output(line, "x1-led-demo", kLedOff) < 0) {
std::perror("gpiod_line_request_output");
gpiod_chip_close(chip);
return 1;
}
int exit_code = 0;
if (!stop_requested) {
if (gpiod_line_set_value(line, kLedOn) < 0) {
std::perror("gpiod_line_set_value(on)");
exit_code = 1;
} else {
const auto deadline = std::chrono::steady_clock::now() +
std::chrono::seconds(5);
while (!stop_requested &&
std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
}
// 信号处理函数不操作 GPIO;由主线程在退出前尽力熄灯。
if (gpiod_line_set_value(line, kLedOff) < 0) {
std::perror("gpiod_line_set_value(off)");
exit_code = 1;
}
gpiod_line_release(line);
gpiod_chip_close(chip);
return exit_code;
}
这段代码的关键步骤如下:
这里通过 gpiod.h 在 C++ 中调用 libgpiod 的 C API,后续 ROS 2 节点也会使用相同的接口。
在 led_on.cpp 所在目录执行:
g++ -std=c++14 -Wall -Wextra -pthread led_on.cpp -o led_on \
$(pkg-config --cflags --libs libgpiod)
sudo ./led_on
pkg-config 会给出 libgpiod 所需的编译与链接选项。仅包含 gpiod.h 还不够;如果忘记链接库,通常会在链接阶段看到 undefined reference to gpiod_*。
预期现象是 LED 点亮约 5 秒,随后程序在释放 GPIO 前写入低电平。

图 5 实验中的 LED 点亮效果。完整接线以本节文字说明为准,裸 LED 需要串联限流电阻。
程序正常退出时会尽力把 LED 置于熄灭状态,但这不等于 GPIO 释放后仍能保证电平。强制终止、断电等情况也不能依赖软件清理;如果项目要求确定的默认状态,需要配合合适的硬件电路。
如果你使用 VS Code,可以安装 Microsoft 的 C/C++ 扩展。Code Runner 只是可选的运行工具;建议先在终端完成一次编译和运行,方便区分代码、链接和权限问题。
想通过 Code Runner 一键运行时,可以在工作目录的 .vscode/settings.json 中加入以下配置。如果文件已有配置,只合并对应字段,不要覆盖其他设置。
{
"code-runner.executorMap": {
"cpp": "cd \"$dir\" && g++ -std=c++14 -Wall -Wextra -pthread \"$fileName\" -o \"$fileNameWithoutExt\" -lgpiod && sudo \"./$fileNameWithoutExt\""
},
"code-runner.runInTerminal": true
}
其中 -lgpiod 用于链接 GPIO 库,-pthread 配合示例中的线程等待,runInTerminal 让程序在集成终端中运行,便于查看输出和输入 sudo 密码。这里使用 sudo 是为了完成本地 GPIO 实验;长期使用时,应按系统的设备权限机制给相应用户配置访问权限。
普通推挽输出可以主动输出高、低电平。开漏输出则只主动拉低;写入逻辑 1 时,GPIO 停止主动驱动,呈高阻态。以下说明均以没有设置 ACTIVE_LOW 为前提。
如果要尝试开漏输出,先断电,将接线改为:

图 6 GPIO 开漏输出接线,写入 0 点亮,写入 1 时释放输出。
此时 GPIO 拉低后,电流从 3.3 V 电源经过电阻和 LED 流入 GPIO,LED 点亮。GPIO 释放为高阻态时,LED 熄灭。
申请开漏输出的关键调用是:
// line 已经通过 gpiod_chip_get_line() 获得。
// 初始写入 1,表示释放输出,LED 保持熄灭。
int ret = gpiod_line_request_output_flags(
line, "led_open_drain",
GPIOD_LINE_REQUEST_FLAG_OPEN_DRAIN, 1);
// 必须检查 ret;申请失败时不能继续读写这条 line。
这是一段接口说明,不能只替换申请语句就直接运行原来的推挽示例。接线、点亮值和退出时的熄灭值都需要同步修改:
|
操作 |
推挽接法 |
本节开漏接法 |
|
初始熄灭值 |
0 |
1 |
|
点亮 LED |
写入 1 |
写入 0 |
|
熄灭 LED |
写入 0 |
写入 1 |
如果开漏申请失败,应检查平台支持情况和错误信息,不能直接把普通输出当作等价替代。
开漏模式不保证 LED 更亮,也不会自动提供上拉。 LED 亮度取决于实际电流,不能只凭测得约 3.3 V 就确定亮度差异的原因;需要结合电阻、LED 特性和带载电压判断。即使使用开漏接法,电流仍经过 GPIO,仍受允许灌电流的限制。需要驱动更大负载时,应使用合适的三极管、MOSFET 或专用驱动器。
下面回到第 2 节的推挽接法:PIN7 → 限流电阻 → LED → PIN6。如果刚做过开漏实验,请先断电改线。
ROS 2 示例每隔 1 秒翻转一次输出,LED 亮 1 秒、灭 1 秒,完整周期约为 2 秒。GPIO 初始化只在节点启动时执行一次,定时器回调负责更新输出。
确认已安装 ROS 2 Humble 后,在普通用户终端中执行:
source /opt/ros/humble/setup.bash
sudo apt install python3-colcon-common-extensions
mkdir -p ~/gpio_ws/src
cd ~/gpio_ws/src
ros2 pkg create gpio_led --build-type ament_cmake --dependencies rclcpp
接下来新建 gpio_led/src/gpio_led_node.cpp,并将包内的 CMakeLists.txt 和 package.xml 替换为下文内容。最终目录结构如下:
gpio_ws/
└── src/
└── gpio_led/
├── CMakeLists.txt
├── package.xml
└── src/
└── gpio_led_node.cpp
src/gpio_led_node.cpp:
#include <gpiod.h>
#include <rclcpp/rclcpp.hpp>
#include <cerrno>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <limits>
#include <memory>
#include <stdexcept>
#include <string>
#include <system_error>
class GpioLedNode : public rclcpp::Node
{
public:
GpioLedNode() : Node("gpio_led_node")
{
// 构造失败时不会调用本类析构函数,必须在 catch 中释放资源。
try {
const auto chip_path =
declare_parameter<std::string>("chip_path", "/dev/gpiochip0");
const auto line_offset =
declare_parameter<std::int64_t>("line_offset", 0);
if (line_offset < 0 ||
static_cast<std::uint64_t>(line_offset) >
std::numeric_limits<unsigned int>::max()) {
throw std::invalid_argument("line_offset 超出 unsigned int 范围");
}
chip_ = gpiod_chip_open(chip_path.c_str());
if (chip_ == nullptr) {
throw std::system_error(errno, std::generic_category(),
"gpiod_chip_open");
}
line_ = gpiod_chip_get_line(
chip_, static_cast<unsigned int>(line_offset));
if (line_ == nullptr) {
throw std::system_error(errno, std::generic_category(),
"gpiod_chip_get_line");
}
// 默认推挽输出,初始低电平;适用于高电平点亮的接线。
if (gpiod_line_request_output(line_, "ros2-gpio-led", 0) < 0) {
throw std::system_error(errno, std::generic_category(),
"gpiod_line_request_output");
}
line_requested_ = true;
timer_ = create_wall_timer(std::chrono::seconds(1), [this]() {
const int next_value = current_value_ == 0 ? 1 : 0;
if (gpiod_line_set_value(line_, next_value) < 0) {
const int saved_errno = errno;
timer_->cancel();
RCLCPP_ERROR(get_logger(),
"GPIO 写入失败,已停止定时器:%s",
std::strerror(saved_errno));
return;
}
current_value_ = next_value;
RCLCPP_INFO(get_logger(), "GPIO 电平:%d", current_value_);
});
RCLCPP_INFO(get_logger(), "已申请 %s,line offset=%lld",
chip_path.c_str(), static_cast<long long>(line_offset));
} catch (...) {
release_gpio();
throw;
}
}
~GpioLedNode() override
{
// main 使用单线程 spin,退出 spin 后才销毁节点。
timer_.reset();
release_gpio();
}
private:
void release_gpio() noexcept
{
if (line_requested_) {
if (gpiod_line_set_value(line_, 0) < 0) {
// 此时 ROS 上下文可能已关闭,因此直接输出到标准错误。
std::perror("退出时设置 GPIO 低电平失败");
}
gpiod_line_release(line_);
line_requested_ = false;
}
line_ = nullptr;
if (chip_ != nullptr) {
gpiod_chip_close(chip_);
chip_ = nullptr;
}
}
gpiod_chip* chip_ = nullptr;
gpiod_line* line_ = nullptr;
bool line_requested_ = false;
int current_value_ = 0;
rclcpp::TimerBase::SharedPtr timer_;
};
int main(int argc, char* argv[])
{
try {
rclcpp::init(argc, argv);
{
auto node = std::make_shared<GpioLedNode>();
rclcpp::spin(node);
}
rclcpp::shutdown();
return 0;
} catch (const std::exception& error) {
std::fprintf(stderr, "gpio_led_node:%s\n", error.what());
if (rclcpp::ok()) {
rclcpp::shutdown();
}
return 1;
}
}
与前面的独立程序相比,这里把 GPIO 资源放进节点对象中管理,用 create_wall_timer() 代替主线程中的等待。节点还暴露了 chip_path 和 line_offset 参数,便于在确认映射后调整目标 GPIO。
GPIO 写入失败时,节点会记录错误并停止定时器,不再继续翻转输出。此时 LED 可能保留此前状态,节点也不会自动退出;请查看日志,按 Ctrl+C 停止节点后排查。
CMakeLists.txt:
cmake_minimum_required(VERSION 3.8)
project(gpio_led)
set(CMAKE_CXX_STANDARD 14)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(PkgConfig REQUIRED)
pkg_check_modules(GPIOD REQUIRED IMPORTED_TARGET libgpiod)
if(NOT GPIOD_VERSION VERSION_LESS "2.0")
message(FATAL_ERROR
"This example uses the libgpiod 1.x C API; found ${GPIOD_VERSION}.")
endif()
add_executable(gpio_led_node src/gpio_led_node.cpp)
ament_target_dependencies(gpio_led_node rclcpp)
target_link_libraries(gpio_led_node PkgConfig::GPIOD)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(gpio_led_node PRIVATE -Wall -Wextra -Wpedantic)
endif()
install(TARGETS gpio_led_node
DESTINATION lib/${PROJECT_NAME})
ament_package()
这里既要声明 rclcpp 依赖,也要链接 libgpiod。install(TARGETS ...) 则让编译后的可执行文件安装到 ROS 2 能找到的位置,否则可能出现包已构建、ros2 run 却找不到程序的情况。
package.xml:
<?xml version="1.0"?>
<package format="3">
<name>gpio_led</name>
<version>0.0.1</version>
<description>犀牛派 X1 GPIO LED 入门示例,使用 libgpiod 1.x 和 ROS 2。</description>
<maintainer email="your-name@example.com">Your Name</maintainer>
<!-- 发布代码前按实际情况填写维护者信息和许可证。 -->
<license>TODO</license>
<buildtool_depend>ament_cmake</buildtool_depend>
<build_depend>pkg-config</build_depend>
<depend>rclcpp</depend>
<depend>libgpiod</depend>
<export>
<build_type>ament_cmake</build_type>
</export>
</package>
其中维护者和许可证是示例包的元数据,整理成自己的项目时按实际情况修改。
回到工作空间根目录,以普通用户身份构建:
cd ~/gpio_ws
source /opt/ros/humble/setup.bash
colcon build --packages-select gpio_led
source install/setup.bash
ros2 run gpio_led gpio_led_node
如果当前用户没有 /dev/gpiochip0 的访问权限,会出现 Permission denied。本地验证时,可以启动一个临时 root shell,并在这个 shell 中重新加载 ROS 2 和工作空间环境:
# 在普通用户终端中,先进入刚刚构建的工作空间
cd ~/gpio_ws
sudo bash
# 以下命令在上面打开的 root shell 中执行
source /opt/ros/humble/setup.bash
source ./install/setup.bash
ros2 run gpio_led gpio_led_node
验证完成后按 Ctrl+C 停止节点,再输入 exit 退出 root shell。编译仍使用普通用户完成。正式使用时,建议按照板卡和系统的权限管理方式配置 GPIO 访问权限,不要长期依赖 root 运行节点。
需要调整 GPIO 参数时,在具备设备访问权限并加载了环境的终端中执行:
ros2 run gpio_led gpio_led_node --ros-args \
-p chip_path:=/dev/gpiochip0 \
-p line_offset:=0
这些参数只指定软件访问位置,不能代替物理引脚映射核对。
|
现象或报错 |
优先检查 |
|
找不到 gpiod.h |
是否安装 libgpiod-dev;开发库是否为 1.x |
|
undefined reference to gpiod_* |
编译命令或 CMake 是否链接 libgpiod |
|
Permission denied |
当前用户是否有 /dev/gpiochip* 访问权限 |
|
Device or resource busy |
是否有 gpioset、其他进程或内核驱动占用目标 line |
|
No such file or directory |
控制器路径是否存在,是否误用了其他镜像的编号 |
|
命令执行后 LED 一闪或状态不稳定 |
libgpiod 1.x 的 gpioset 是否指定了保持模式 |
|
代码能运行,但 LED 不亮 |
LED 极性、限流电阻、面包板连接、共地及物理针脚映射 |
|
ROS 2 找不到包或可执行文件 |
是否加载 install/setup.bash,CMake 是否有安装规则 |
|
ROS 2 启动后不再闪烁 |
查看 GPIO 初始化或写入错误日志,确认定时器是否因错误停止 |
排查时建议沿着“接线和限流电阻 → GPIO 映射及权限 → 命令行 → C++ → ROS 2”的顺序逐层确认。前一层能独立工作,再往下一层走,问题通常更容易定位。
这次实验先把 GPIO 控制的基本流程跑通。后续需要控制其他外设时,可以继续沿用资源管理和错误处理的思路,再根据设备的电气要求选择合适的驱动电路。
感谢阿加犀官方社区的 GPIO 实操帖子,它是这次实验的参考起点:
GPIO 的电气规格和排针映射,请以所用 X1 硬件版本及系统镜像对应的官方资料为准。