在嵌入式 Linux 系统中,通过 GPIO 采集数字输入(DI)和控制数字输出(DO)是常见的需求。本文以一份真实的 BMS(电池管理系统)采集程序 Collect.cpp 为例,详细梳理其中关于 GPIO 操作的实现细节,包括数据结构设计、多芯片支持、输入滤波、输出控制以及线程周期管理,适合开发者参考或撰写博客。
1. 程序功能概述
Collect.cpp 是一个周期性运行的后台进程,主要任务:
-
读取多个 DI 引脚状态(如接触器状态、急停开关、烟感信号等),经过 5 次连续确认滤波 后更新到共享内存。
-
从共享内存中读取控制命令(如继电器开合指令),实时写入对应的 DO 引脚。
-
以 10ms 为固定周期循环执行,保证实时性。
程序使用 libgpiod 操作 GPIO,支持多个 GPIO 芯片(gpiochip0、gpiochip1、gpiochip2、gpiochip3),并通过共享内存与其它进程通信。
2. GPIO 映射表设计
2.1 DI 映射结构体
cpp
struct DiMapping {
unsigned int chipNum; // GPIO chip 编号(0,1,2,3...)
unsigned int line_offset; // 引脚偏移量(0~31)
int point_id; // 共享内存中的点位ID
const char* description;
unsigned int useState; // 1: 使用共享内存,0: 不使用
unsigned int negation; // 1: 读取后取反,0: 不取反
};
-
chipNum和line_offset唯一确定一个 GPIO 引脚。 -
point_id对应共享内存中该信号的索引,便于上层逻辑通过统一 ID 访问。 -
useState允许在不删除定义的情况下临时禁用某个引脚(例如硬件未连接)。 -
negation支持硬件电平与逻辑电平相反的场合(如低电平有效)。
2.2 DI 映射表实例
cpp
DiMapping diMap[] = {
{2, 26, 611, "GPIO2_D2", 1, 0}, // negRelay_flag
{3, 8, 612, "GPIO3_B0", 1, 0}, // posRelay_flag
{0, 17, 613, "GPIO0_C1", 1, 0}, // preRelay_flag
{0, 23, 614, "GPIO0_C7", 1, 0}, // circuitBreaker_flag
{2, 24, 615, "GPIO2_D0", 1, 0}, // fuse_flag
{2, 25, 616, "GPIO2_D1", 1, 0}, // stop_flag
{2, 27, 617, "GPIO2_D3", 1, 0}, // smoke_flag
{2, 28, 617, "GPIO2_D4", 0, 0}, // 未使用
{3, 12, 617, "GPIO3_B4", 0, 0} // 未使用
};
2.3 DO 映射结构体及映射表
cpp
struct DoMapping {
int point_id;
unsigned int chipNum;
unsigned int line_offset;
const char* description;
unsigned int useState;
unsigned int negation;
};
DoMapping doMap[] = {
{100, 0, 20, "GPIO0_C4", 1, 0}, // posRelayCmd
{101, 0, 8, "GPIO0_B0", 1, 0}, // negRelayCmd
{102, 0, 6, "GPIO0_A6", 1, 0}, // preRelayCmd
// ... 共 14 个输出
{119, 3, 7, "GPIO3_A7", 1, 0} // K7 dryContactCmd7
};
3. 多芯片 GPIO 初始化
由于引脚分布在多个 gpiochip 上,程序为每个 useState==1 的引脚独立打开对应的 gpiod_chip 并申请 line,同时保存句柄用于后续操作和释放。
cpp
static gpiod_line *g_di_lines[diCount] = {nullptr};
static gpiod_chip *g_di_chips[diCount] = {nullptr};
// DO 同理
bool init_gpio() {
for (int i = 0; i < diCount; ++i) {
if (diMap[i].useState == 0) continue;
gpiod_chip *chip = gpiod_chip_open_by_number(diMap[i].chipNum);
if (!chip) { /* 错误处理 */ }
gpiod_line *line = gpiod_chip_get_line(chip, diMap[i].line_offset);
gpiod_line_request_input(line, "collect_in");
g_di_lines[i] = line;
g_di_chips[i] = chip;
}
// 类似初始化 DO(request_output)
}
这种设计避免了全局打开一个 chip 然后反复 get_line 的混乱,每个引脚独立管理,便于调试和释放。
4. DI 读取与 5 次连续确认滤波
为什么需要滤波?
机械触点或外部干扰可能导致电平瞬间跳变。直接更新共享内存会引起上层逻辑误判。采用"连续 5 次相同才确认"的软件滤波,有效消除抖动。
4.1 滤波实现
cpp
void read_di() {
static uint8_t di_last_value[diCount] = {0};
static uint8_t di_stable_count[diCount] = {0};
LOCK(&g_shared->mutex);
for (int i = 0; i < diCount; ++i) {
if (diMap[i].useState == 0) continue;
int val = gpiod_line_get_value(g_di_lines[i]);
if (diMap[i].negation) val = !val;
uint8_t new_val = val;
if (di_stable_count[i] == 0) {
// 首次读取直接确认并设置计数为5(避免后续重复更新)
di_last_value[i] = new_val;
di_stable_count[i] = 5;
// 更新共享内存
update_shared_memory(diMap[i].point_id, new_val);
} else {
if (new_val == di_last_value[i]) {
if (di_stable_count[i] < 5) di_stable_count[i]++;
if (di_stable_count[i] == 5) {
update_shared_memory(diMap[i].point_id, new_val);
}
} else {
di_last_value[i] = new_val;
di_stable_count[i] = 1;
}
}
}
UNLOCK(&g_shared->mutex);
}
4.2 逻辑说明
-
首次启动:直接确认并计数 5,保证初始状态立即生效。
-
状态稳定:连续相同值累计计数,达到 5 次才更新共享内存。
-
状态变化:一旦新值与上次不同,重置计数为 1,不更新内存,直到再次连续 5 次相同。
-
当计数达到 5 后,后续相同读数不再重复写内存,减少互斥锁竞争。
5. DO 更新逻辑
DO 更新相对简单:从共享内存的 sys_state_ctrl 数组中读取对应 point_id 的命令值,然后一次性设置所有 DO。
cpp
void update_do() {
uint8_t cmdValues[doCount] = {0};
// 加锁读取共享内存中的命令
LOCK(&g_shared->mutex);
for (int i = 0; i < doCount; ++i) {
if (doMap[i].useState == 0) continue;
for (int j = 0; j < MAX_POINTS; ++j) {
if (g_shared->sys_state_ctrl.points[j].point_id == doMap[i].point_id) {
cmdValues[i] = g_shared->sys_state_ctrl.points[j].value.u8;
break;
}
}
}
UNLOCK(&g_shared->mutex);
// 无锁写入硬件
for (int i = 0; i < doCount; ++i) {
if (doMap[i].useState == 0) continue;
uint8_t val = cmdValues[i];
if (doMap[i].negation) val = !val;
gpiod_line_set_value(g_do_lines[i], val ? 1 : 0);
}
}
分离锁 的设计:先加锁读取所有命令值到本地数组,释放锁后再逐个写入 GPIO。这样做缩短了锁持有时间,避免在慢速 I/O 期间阻塞其他进程访问共享内存。
6. 采集线程与周期控制
cpp
#define COLLECT_CYCLE_MS 10
void collect_thread() {
while (g_collect_running.load()) {
auto start = std::chrono::steady_clock::now();
read_di();
update_do();
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start).count();
if (elapsed < COLLECT_CYCLE_MS)
std::this_thread::sleep_for(
std::chrono::milliseconds(COLLECT_CYCLE_MS - elapsed));
}
}
-
使用
std::chrono::steady_clock测量实际执行时间,动态补偿睡眠,保证稳定的 10ms 周期。 -
g_collect_running是std::atomic<bool>,在SIGINT/SIGTERM信号处理函数中置为false,实现安全退出。
7. 资源清理
程序退出时(收到信号后主循环结束),调用 cleanup_gpio() 释放所有 GPIO 资源:
cpp
void cleanup_gpio() {
for (int i = 0; i < diCount; ++i) {
if (g_di_lines[i]) gpiod_line_release(g_di_lines[i]);
if (g_di_chips[i]) gpiod_chip_close(g_di_chips[i]);
}
// DO 同理
}
确保每个 gpiod_line 和 gpiod_chip 都被正确释放,避免资源泄漏。
8. 技术亮点总结
| 特性 | 实现方式 | 优点 |
|---|---|---|
| 多芯片支持 | 每个引脚独立打开 chip,保存句柄 | 灵活适应不同 GPIO 控制器 |
| 软件滤波 | 连续 5 次相同值确认 | 消除触点抖动,提升可靠性 |
| 取反支持 | negation 字段 |
兼容低电平有效的硬件设计 |
| 动态周期控制 | steady_clock + 动态睡眠 |
周期稳定,不受执行时间波动影响 |
| 短锁设计 | 分离读取命令和硬件写入 | 减少锁竞争,提高并发性 |
| 优雅退出 | std::atomic<bool> + 信号处理 |
安全释放资源,避免数据损坏 |
9. 完整代码结构
text
main()
├── signal() 注册 SIGINT/SIGTERM 处理
├── attach_shared_memory() // 映射共享内存
├── init_gpio() // 初始化 DI/DO
├── std::thread(collect_thread).detach()
├── while (g_collect_running) sleep(1)
└── cleanup_gpio()
10. 适用场景与扩展
-
该模式适用于任何需要周期性采集数字输入和控制数字输出的嵌入式 Linux 项目。
-
可以轻松扩展到更多 GPIO,只需修改映射表。
-
滤波次数可根据硬件抖动情况调整(例如改为 3 次或 10 次)。
-
若需要更高精度,可将
COLLECT_CYCLE_MS改为 1ms 并使用nanosleep或timerfd。
结语
本文详细剖析了 Collect.cpp 中 GPIO 相关代码的设计与实现。从数据结构、初始化、滤波算法到线程周期控制,每一步都体现了嵌入式实时系统的典型实践。希望这篇博客能为你的 Linux GPIO 编程提供有价值的参考。