From 6f5c40563e451a2fc30f5d0cb0415b57dbaa28b6 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:20:02 +0200 Subject: [PATCH 1/3] LinuxGPIOPin: throw on rejected GPIO reads, writes and acquisitions --- cores/ardulinux/linux/gpio/LinuxGPIOPin.cpp | 84 +++- cores/ardulinux/linux/gpio/LinuxGPIOPin.h | 75 ++- tests/unit/CMakeLists.txt | 29 ++ tests/unit/fake_gpiod.cpp | 192 ++++++++ tests/unit/fake_gpiod.h | 58 +++ tests/unit/test_linux_gpio.cpp | 481 ++++++++++++++++++++ 6 files changed, 906 insertions(+), 13 deletions(-) create mode 100644 tests/unit/fake_gpiod.cpp create mode 100644 tests/unit/fake_gpiod.h create mode 100644 tests/unit/test_linux_gpio.cpp diff --git a/cores/ardulinux/linux/gpio/LinuxGPIOPin.cpp b/cores/ardulinux/linux/gpio/LinuxGPIOPin.cpp index d732034..061817e 100644 --- a/cores/ardulinux/linux/gpio/LinuxGPIOPin.cpp +++ b/cores/ardulinux/linux/gpio/LinuxGPIOPin.cpp @@ -182,7 +182,18 @@ gpiod_line *LinuxGPIOPin::getLine(const char *chipLabel, const char *linuxPinNam struct gpiod_line_config *line_cfg; struct gpiod_request_config *req_cfg = NULL; struct gpiod_line_request *line = NULL; - offset = gpiod_chip_get_line_offset_from_name(chip, linuxPinName); + // Returns -1 (ENOENT) for an unknown name; assigning that to the unsigned + // member would request offset 4294967295 instead of reporting the typo. + int named_offset = gpiod_chip_get_line_offset_from_name(chip, linuxPinName); + if (named_offset < 0) { + gpiod_chip_close(chip); + chip = NULL; + char msg[128]; + snprintf(msg, sizeof(msg), "Error, no GPIO line named '%s' on %s", + linuxPinName ? linuxPinName : "?", chipLabel ? chipLabel : "?"); + throw std::invalid_argument(msg); + } + offset = (unsigned int) named_offset; settings = gpiod_line_settings_new(); gpiod_line_settings_set_direction(settings, GPIOD_LINE_REQUEST_DIRECTION_AS_IS); line_cfg = gpiod_line_config_new(); @@ -229,12 +240,26 @@ gpiod_line *LinuxGPIOPin::getLine(const char *chipLabel, const int linuxPinNum) if (!chip) throw std::invalid_argument("GPIO chip not found"); + // Guard before either library path: a negative offset (a config parser's + // "unset" sentinel, say) is unsigned on both sides -- v2's `offset` member + // and v1's gpiod_chip_get_line() -- so it would wrap to 4294967295 and be + // requested as if it were a real line. Deliberately outside the #if: the + // check needs no version-specific API. + if (linuxPinNum < 0) { + gpiod_chip_close(chip); + chip = NULL; + char msg[128]; + snprintf(msg, sizeof(msg), "Error, invalid GPIO line offset %d on %s", + linuxPinNum, chipLabel ? chipLabel : "?"); + throw std::invalid_argument(msg); + } + #if GPIOD_V == 2 struct gpiod_line_settings *settings; struct gpiod_line_config *line_cfg; struct gpiod_request_config *req_cfg = NULL; struct gpiod_line_request *line = NULL; - offset = linuxPinNum; + offset = (unsigned int) linuxPinNum; settings = gpiod_line_settings_new(); gpiod_line_settings_set_direction(settings, GPIOD_LINE_REQUEST_DIRECTION_AS_IS); line_cfg = gpiod_line_config_new(); @@ -262,7 +287,9 @@ gpiod_line *LinuxGPIOPin::getLine(const char *chipLabel, const int linuxPinNum) } return line; #else - auto line = gpiod_chip_get_line(chip, linuxPinNum); + // The negative guard above makes this conversion safe; make it explicit so + // the intent is not mistaken for the sign bug that guard exists to prevent. + auto line = gpiod_chip_get_line(chip, (unsigned int) linuxPinNum); struct gpiod_line_request_config request = { consumer, GPIOD_LINE_REQUEST_DIRECTION_AS_IS, 0}; @@ -296,10 +323,32 @@ LinuxGPIOPin::~LinuxGPIOPin() { gpiod_chip_close(chip); } +/** + * Report a libgpiod failure on this line as an exception. + * + * assert() is not usable here: it is compiled out under NDEBUG, which is what + * release builds define, so a runtime gpiod error would go unreported. + */ +void LinuxGPIOPin::throwLineError(const char *op) const { + char msg[160]; +#if GPIOD_V == 2 + snprintf(msg, sizeof(msg), "Error, cannot %s GPIO line %u ('%s', pin %u): %s", + op, offset, getName(), (unsigned) getPinNum(), strerror(errno)); +#else + snprintf(msg, sizeof(msg), "Error, cannot %s GPIO '%s' (pin %u): %s", + op, getName(), (unsigned) getPinNum(), strerror(errno)); +#endif + log(SysGPIO, LogError, "%s", msg); + throw std::runtime_error(msg); +} + /// Read the low level hardware for this pin PinStatus LinuxGPIOPin::readPinHardware() { int res = gpiod_line_get_value(line); - assert(res == 0 || res == 1); // FIXME throw instead + // gpiod reports failure as GPIOD_LINE_VALUE_ERROR (-1). Returning it would + // cache -1 as the pin state and fire a phantom ISR from refreshState(). + if (res != 0 && res != 1) + throwLineError("read"); // log(SysGPIO, LogDebug, "readPinHardware(%s, %d)", getName(), res); return (PinStatus) res; @@ -309,13 +358,26 @@ void LinuxGPIOPin::writePin(PinStatus s) { // some libraries have been observed failing to set the pin mode to output. if (GPIOPin::getPinMode() != OUTPUT) setPinMode(OUTPUT); - GPIOPin::writePin(s); // update status + // Drive the hardware before caching. GPIOPin::writePin() records `s` as the + // pin's state, and once the mode is OUTPUT refreshState() stops re-reading + // the hardware, so a value cached for a write that never landed would be + // returned by digitalRead() forever. int res = gpiod_line_set_value(line, s); - assert(res == 0); + if (res != 0) + throwLineError("write"); + + GPIOPin::writePin(s); // update status } void LinuxGPIOPin::setPinMode(PinMode m) { +#if GPIOD_V == 2 + // Cache the mode up front: the output-value seed below reads readPin(), which + // must return the cached level rather than touching the hardware. If the + // reconfigure then fails, the cache is rolled back to `previous` -- leaving it + // moved would gate refreshState() on a direction the line does not have. + const PinMode previous = GPIOPin::getPinMode(); +#endif GPIOPin::setPinMode(m); #if GPIOD_V == 1 // The gpiod call below does not play well with an already claimed GPIO @@ -357,11 +419,17 @@ void LinuxGPIOPin::setPinMode(PinMode m) { } line_cfg = gpiod_line_config_new(); ret = gpiod_line_config_add_line_settings(line_cfg, &offset, 1, settings); - if (ret != 0) - log(SysGPIO, LogError, "gpiod_line_config_add_line_settings failed: %d", ret); + int add_ret = ret; + if (add_ret != 0) + log(SysGPIO, LogError, "gpiod_line_config_add_line_settings failed: %d", add_ret); ret = gpiod_line_request_reconfigure_lines(line, line_cfg); if (ret != 0) log(SysGPIO, LogError, "gpiod_line_request_reconfigure_lines failed: %d", ret); + // Either failure means the line kept its old direction, so the cache must + // too. add_line_settings is checked in its own right rather than trusting + // the reconfigure to fail on an empty config: the two are independent. + if (add_ret != 0 || ret != 0) + GPIOPin::setPinMode(previous); gpiod_line_config_free(line_cfg); gpiod_line_settings_free(settings); diff --git a/cores/ardulinux/linux/gpio/LinuxGPIOPin.h b/cores/ardulinux/linux/gpio/LinuxGPIOPin.h index a336697..52acf7b 100644 --- a/cores/ardulinux/linux/gpio/LinuxGPIOPin.h +++ b/cores/ardulinux/linux/gpio/LinuxGPIOPin.h @@ -59,7 +59,15 @@ */ class LinuxGPIOPin : public GPIOPin { gpiod_line *line; ///< Acquired GPIO line handle (type aliased for v1/v2) - gpiod_chip *chip; ///< GPIO chip handle (kept open for reconfiguration) + /** + * GPIO chip handle. + * + * Under gpiod v1 the chip is held open for the lifetime of the pin and + * closed by the destructor. Under gpiod v2 a line request outlives the + * chip it came from, so getLine() closes the chip as soon as the request + * succeeds and resets this to NULL; the destructor's close is then a no-op. + */ + gpiod_chip *chip; public: @@ -73,6 +81,11 @@ class LinuxGPIOPin : public GPIOPin { * @param linuxPinName Name of the GPIO line within the chip. * @param ardulinuxPinName Human-readable name for log messages (defaults to * linuxPinName if NULL). + * @throws std::invalid_argument if the chip is not found, the chip has no + * line by that name, or the line cannot be acquired. Acquisition is + * deliberately a construction-time failure: a pin that cannot be + * claimed is a configuration error, and the caller should learn that + * when it binds the pin rather than on first use. */ LinuxGPIOPin(pin_size_t n, const char *chipLabel, const char *linuxPinName, const char *ardulinuxPinName = NULL); @@ -83,6 +96,9 @@ class LinuxGPIOPin : public GPIOPin { * @param chipLabel Label of the gpiochip device. * @param linuxPinNum Zero-based offset of the GPIO line within the chip. * @param ardulinuxPinName Human-readable name for log messages. + * @throws std::invalid_argument if the chip is not found, the offset is + * negative, or the line cannot be acquired. See the by-name + * constructor for why this fails at construction time. */ LinuxGPIOPin(pin_size_t n, const char *chipLabel, const int linuxPinNum, const char *ardulinuxPinName); @@ -90,7 +106,15 @@ class LinuxGPIOPin : public GPIOPin { ~LinuxGPIOPin(); protected: - /** Read the current hardware pin level via gpiod_line_get_value(). */ + /** + * Read the current hardware pin level via gpiod_line_get_value(). + * + * @return LOW or HIGH. + * @throws std::runtime_error if libgpiod reports an error. The value is + * never passed through: gpiod signals failure with + * GPIOD_LINE_VALUE_ERROR (-1), which is not a valid PinStatus and + * would otherwise be cached as pin state and fire a spurious ISR. + */ virtual PinStatus readPinHardware(); /** @@ -98,6 +122,15 @@ class LinuxGPIOPin : public GPIOPin { * * Some libraries omit the pinMode(OUTPUT) call; this method silently * promotes the pin to output to avoid a silent no-op. + * + * The hardware is driven before the new level is cached, so a rejected + * write leaves the cached state untouched. That ordering matters: once the + * mode is OUTPUT, refreshState() stops re-reading the hardware, so a value + * cached for a write that never landed would be returned by digitalRead() + * for the rest of the process's life. + * + * @param s Logic level to drive. + * @throws std::runtime_error if libgpiod rejects the write. */ virtual void writePin(PinStatus s); @@ -106,10 +139,26 @@ class LinuxGPIOPin : public GPIOPin { * * Uses gpiod_line_release + gpiod_line_request_* (v1) or * gpiod_line_request_reconfigure_lines (v2). + * + * A failed reconfiguration is logged at LogError and does not throw: this is + * reached from writePin()'s auto-promotion path, where throwing would turn a + * recoverable reconfiguration into a lost write. The cached mode is rolled + * back instead, because the line kept its old direction and `mode` is what + * gates refreshState() -- a stale OUTPUT would stop all hardware reads and + * freeze digitalRead() at its last cached level. + * + * @param m Direction and bias to apply. */ virtual void setPinMode(PinMode m); - unsigned int offset; ///< Line offset within the chip (used by gpiod v2) + /** + * Line offset within the chip. + * + * Assigned by the gpiod v2 paths in getLine(); the v1 paths address the + * line through its own handle and never read this. Initialised anyway so + * the member is never indeterminate, since it is declared unconditionally. + */ + unsigned int offset = 0; private: /** @@ -117,7 +166,9 @@ class LinuxGPIOPin : public GPIOPin { * * @param chipLabel gpiochip label or device name. * @param linuxPinNum Line offset within the chip. - * @return Acquired line handle; throws std::invalid_argument on failure. + * @return Acquired line handle. + * @throws std::invalid_argument if the chip is not found, the offset is + * negative, or the line cannot be acquired. */ gpiod_line *getLine(const char *chipLabel, const int linuxPinNum); @@ -126,10 +177,24 @@ class LinuxGPIOPin : public GPIOPin { * * @param chipLabel gpiochip label or device name. * @param linuxPinName Line name as reported by the kernel. - * @return Acquired line handle; throws std::invalid_argument on failure. + * @return Acquired line handle. + * @throws std::invalid_argument if the chip is not found, the chip has no + * line by that name, or the line cannot be acquired. */ gpiod_line *getLine(const char *chipLabel, const char *linuxPinName); + /** + * Throw a std::runtime_error identifying this pin and the failed operation. + * + * Shared by readPinHardware() and writePin() so both report a libgpiod + * failure in the same form, including errno, the line offset (gpiod v2), + * the pin name and the Arduino pin number. + * + * @param op Verb naming the failed operation, e.g. "read" or "write". + * @throws std::runtime_error always; the function never returns. + */ + [[noreturn]] void throwLineError(const char *op) const; + /** @defgroup gpiod_v2_compat gpiod v2 compatibility shims * * gpiod v2 replaced the gpiod_line / gpiod_line_request split with a diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 2ad0a4d..66c53e1 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -84,3 +84,32 @@ if(LIBGPIOD_FOUND) endif() catch_discover_tests(ardulinux-i2c-tests) endif() + +# ─── LinuxGPIOPin tests ────────────────────────────────────────────────────── +# LinuxGPIOPin drives real GPIO character devices, which a unit test cannot +# open (/dev/gpiochip* needs hardware, or gpio-sim plus root). fake_gpiod.cpp +# defines the libgpiod symbols LinuxGPIOPin.cpp references; because those +# definitions come from an object file linked directly into this executable, +# they win over the shared library and no device is ever touched. +# +# A separate executable is required so the fake's gpiod_* symbols cannot +# displace the real ones in ardulinux-tests, which links libgpiod for the I2C +# and SPI paths. Built only when LIBGPIOD_FOUND, since LinuxGPIOPin.cpp is +# guarded by ARDULINUX_HARDWARE and needs . +if(LIBGPIOD_FOUND) + add_executable(ardulinux-gpio-tests + test_linux_gpio.cpp + fake_gpiod.cpp + ${CMAKE_SOURCE_DIR}/cores/ardulinux/linux/gpio/LinuxGPIOPin.cpp + ) + target_link_libraries(ardulinux-gpio-tests PRIVATE ardulinux-base Catch2::Catch2WithMain) + target_include_directories(ardulinux-gpio-tests PRIVATE + ${CMAKE_SOURCE_DIR}/cores/ardulinux + ${CMAKE_CURRENT_SOURCE_DIR} + ) + if(CMAKE_BUILD_TYPE STREQUAL "Coverage") + target_compile_options(ardulinux-gpio-tests PRIVATE --coverage -O0 -g) + target_link_options(ardulinux-gpio-tests PRIVATE --coverage) + endif() + catch_discover_tests(ardulinux-gpio-tests) +endif() diff --git a/tests/unit/fake_gpiod.cpp b/tests/unit/fake_gpiod.cpp new file mode 100644 index 0000000..cdeed8b --- /dev/null +++ b/tests/unit/fake_gpiod.cpp @@ -0,0 +1,192 @@ +// ArduLinux - Arduino API for Linux +// Copyright (c) 2026-27 l5yth +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +/** + * @file fake_gpiod.cpp + * @brief In-process stand-in for the libgpiod v2 C API. + * + * Defines every libgpiod symbol LinuxGPIOPin.cpp references. Because these + * definitions live in an object file linked directly into the test binary, + * they take precedence over the shared library, so no GPIO character device is + * ever opened. Behaviour is steered through the global ::fake instance. + * + * The opaque libgpiod structs are given trivial definitions here; the code + * under test only ever passes the pointers back, never dereferences them. + */ + +#include "fake_gpiod.h" + +#include +#include +#include + +FakeGpiod fake; + +struct gpiod_chip { int unused; }; +struct gpiod_chip_info { int unused; }; +struct gpiod_line_settings { int unused; }; +struct gpiod_line_config { int unused; }; +struct gpiod_request_config { int unused; }; +struct gpiod_line_request { int unused; }; + +/// Singleton handles handed out by the fake; identity is all the tests need. +static gpiod_chip g_chip; +static gpiod_line_request g_request; + +extern "C" { + +struct gpiod_chip *gpiod_chip_open(const char *path) +{ + (void) path; + if (fake.chip_open_fails) { + errno = fake.fail_errno; + return NULL; + } + fake.chip_open_count++; + return &g_chip; +} + +void gpiod_chip_close(struct gpiod_chip *chip) +{ + // Mirrors libgpiod >= 2.0, which returns early on NULL rather than + // aborting. Counted separately so tests can prove the destructor never + // closes a live handle twice. + if (!chip) { + fake.chip_close_null_count++; + return; + } + fake.chip_close_count++; +} + +struct gpiod_chip_info *gpiod_chip_get_info(struct gpiod_chip *chip) +{ + (void) chip; + return NULL; // label scan is not exercised; the /dev fast path is used +} + +void gpiod_chip_info_free(struct gpiod_chip_info *info) { (void) info; } + +const char *gpiod_chip_info_get_label(struct gpiod_chip_info *info) +{ + (void) info; + return NULL; +} + +int gpiod_chip_get_line_offset_from_name(struct gpiod_chip *chip, const char *name) +{ + (void) chip; + (void) name; + if (fake.name_lookup_offset < 0) + errno = ENOENT; + return fake.name_lookup_offset; +} + +struct gpiod_line_settings *gpiod_line_settings_new(void) +{ + return (struct gpiod_line_settings *) malloc(sizeof(struct gpiod_line_settings)); +} + +void gpiod_line_settings_free(struct gpiod_line_settings *settings) { free(settings); } + +int gpiod_line_settings_set_direction(struct gpiod_line_settings *settings, + enum gpiod_line_direction direction) +{ + (void) settings; + fake.last_direction = (int) direction; + return 0; +} + +int gpiod_line_settings_set_output_value(struct gpiod_line_settings *settings, + enum gpiod_line_value value) +{ + (void) settings; + fake.last_output_value = (int) value; + return 0; +} + +struct gpiod_line_config *gpiod_line_config_new(void) +{ + return (struct gpiod_line_config *) malloc(sizeof(struct gpiod_line_config)); +} + +void gpiod_line_config_free(struct gpiod_line_config *config) { free(config); } + +int gpiod_line_config_add_line_settings(struct gpiod_line_config *config, + const unsigned int *offsets, size_t num_offsets, + struct gpiod_line_settings *settings) +{ + (void) config; + (void) settings; + if (num_offsets) + fake.last_offset = offsets[0]; + return fake.add_line_settings_ret; +} + +struct gpiod_request_config *gpiod_request_config_new(void) +{ + return (struct gpiod_request_config *) malloc(sizeof(struct gpiod_request_config)); +} + +void gpiod_request_config_free(struct gpiod_request_config *config) { free(config); } + +void gpiod_request_config_set_consumer(struct gpiod_request_config *config, + const char *consumer) +{ + (void) config; + fake.last_consumer = consumer; +} + +struct gpiod_line_request *gpiod_chip_request_lines(struct gpiod_chip *chip, + struct gpiod_request_config *req_cfg, + struct gpiod_line_config *line_cfg) +{ + (void) chip; + (void) req_cfg; + (void) line_cfg; + if (fake.request_lines_fails) { + errno = fake.fail_errno; + return NULL; + } + return &g_request; +} + +void gpiod_line_request_release(struct gpiod_line_request *request) +{ + (void) request; + fake.line_release_count++; +} + +enum gpiod_line_value gpiod_line_request_get_value(struct gpiod_line_request *request, + unsigned int offset) +{ + (void) request; + (void) offset; + if (fake.get_value_ret < 0) + errno = fake.fail_errno; + return (enum gpiod_line_value) fake.get_value_ret; +} + +int gpiod_line_request_set_value(struct gpiod_line_request *request, unsigned int offset, + enum gpiod_line_value value) +{ + (void) request; + (void) offset; + fake.last_written = (int) value; + if (fake.set_value_ret != 0) + errno = fake.fail_errno; + return fake.set_value_ret; +} + +int gpiod_line_request_reconfigure_lines(struct gpiod_line_request *request, + struct gpiod_line_config *config) +{ + (void) request; + (void) config; + if (fake.reconfigure_ret != 0) + errno = fake.fail_errno; + return fake.reconfigure_ret; +} + +} // extern "C" diff --git a/tests/unit/fake_gpiod.h b/tests/unit/fake_gpiod.h new file mode 100644 index 0000000..d02e96b --- /dev/null +++ b/tests/unit/fake_gpiod.h @@ -0,0 +1,58 @@ +// ArduLinux - Arduino API for Linux +// Copyright (c) 2026-27 l5yth +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +#pragma once + +/** + * @file fake_gpiod.h + * @brief Control surface for the in-process libgpiod v2 fake. + * + * LinuxGPIOPin talks to real GPIO character devices, which unit tests cannot + * open: /dev/gpiochip* requires hardware (or the gpio-sim kernel module plus + * root). fake_gpiod.cpp instead defines the handful of libgpiod symbols that + * LinuxGPIOPin.cpp references, so the test binary links against the fake and + * never reaches the real library. Tests drive the error paths by setting the + * knobs below and assert on the counters afterwards. + * + * @see fake_gpiod.cpp for the symbol definitions. + */ +struct FakeGpiod { + // ─── Knobs: make a libgpiod call fail ──────────────────────────────────── + + /** Make gpiod_chip_open() return NULL (chip missing / not permitted). */ + bool chip_open_fails = false; + /** Make gpiod_chip_request_lines() return NULL (line already claimed). */ + bool request_lines_fails = false; + /** Offset reported by gpiod_chip_get_line_offset_from_name(); -1 = no such name. */ + int name_lookup_offset = 7; + /** Return value of gpiod_line_config_add_line_settings(). */ + int add_line_settings_ret = 0; + /** Value reported by gpiod_line_request_get_value(); -1 = GPIOD_LINE_VALUE_ERROR. */ + int get_value_ret = 1; + /** Return value of gpiod_line_request_set_value(); -1 signals failure. */ + int set_value_ret = 0; + /** Return value of gpiod_line_request_reconfigure_lines(). */ + int reconfigure_ret = 0; + /** errno the fake sets before returning a failure, so strerror() has input. */ + int fail_errno = 16 /* EBUSY */; + + // ─── Counters and captured arguments ───────────────────────────────────── + + int chip_open_count = 0; ///< successful gpiod_chip_open() calls + int chip_close_count = 0; ///< gpiod_chip_close() calls with a live handle + int chip_close_null_count = 0; ///< gpiod_chip_close() calls with NULL + int line_release_count = 0; ///< gpiod_line_request_release() calls + unsigned last_offset = 0; ///< offset handed to add_line_settings() + int last_direction = -1; ///< direction handed to set_direction() + int last_output_value = -1; ///< value handed to set_output_value() + int last_written = -1; ///< value handed to set_value() + const char *last_consumer = nullptr; ///< consumer handed to set_consumer() + + /** Restore every knob and counter to its default. Call at test start. */ + void reset() { *this = FakeGpiod(); } +}; + +/** The single fake instance shared by the fake symbols and the tests. */ +extern FakeGpiod fake; diff --git a/tests/unit/test_linux_gpio.cpp b/tests/unit/test_linux_gpio.cpp new file mode 100644 index 0000000..317d087 --- /dev/null +++ b/tests/unit/test_linux_gpio.cpp @@ -0,0 +1,481 @@ +// ArduLinux - Arduino API for Linux +// Copyright (c) 2026-27 l5yth +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +/** + * @file test_linux_gpio.cpp + * @brief Unit tests for LinuxGPIOPin against a faked libgpiod v2. + * + * Every libgpiod call LinuxGPIOPin makes is served by fake_gpiod.cpp, so these + * tests exercise the real LinuxGPIOPin.cpp source without a GPIO character + * device. The chip label "null" is used throughout: find_chip_by_label() tries + * "/dev/