{"id":9598,"date":"2026-06-27T15:26:21","date_gmt":"2026-06-27T15:26:21","guid":{"rendered":"https:\/\/blog.shahada.abubakar.net\/?p=9598"},"modified":"2026-06-27T15:47:58","modified_gmt":"2026-06-27T15:47:58","slug":"turning-a-raspberry-pi-pico-into-a-libgpiod-gpio-adapter-for-a-debian-13-pc","status":"publish","type":"post","link":"https:\/\/blog.shahada.abubakar.net\/?p=9598","title":{"rendered":"Turning a Raspberry Pi Pico into a libgpiod GPIO Adapter for a Debian 13 PC"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">When developing for embedded Linux, I prefer to do the heavy lifting on my desktop PC and port the code over later. However, a common roadblock is that most desktops lack native GPIO support. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Today, I explored ways to bridge this gap and discovered a simple, affordable solution to add GPIO capabilities to a standard PC. Crucially, it offers full support for <code>libgpiod<\/code> (the modern GPIO API), which makes the resulting code highly portable. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I asked my friend Claude to summarize my findings in the blog post below.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"1024\" src=\"https:\/\/blog.shahada.abubakar.net\/wp-content\/uploads\/2026\/06\/IMG_20260627_234159-1024x1024.jpg\" alt=\"\" class=\"wp-image-9603\" srcset=\"https:\/\/blog.shahada.abubakar.net\/wp-content\/uploads\/2026\/06\/IMG_20260627_234159-1024x1024.jpg 1024w, https:\/\/blog.shahada.abubakar.net\/wp-content\/uploads\/2026\/06\/IMG_20260627_234159-300x300.jpg 300w, https:\/\/blog.shahada.abubakar.net\/wp-content\/uploads\/2026\/06\/IMG_20260627_234159-150x150.jpg 150w, https:\/\/blog.shahada.abubakar.net\/wp-content\/uploads\/2026\/06\/IMG_20260627_234159-768x768.jpg 768w, https:\/\/blog.shahada.abubakar.net\/wp-content\/uploads\/2026\/06\/IMG_20260627_234159-1536x1536.jpg 1536w, https:\/\/blog.shahada.abubakar.net\/wp-content\/uploads\/2026\/06\/IMG_20260627_234159-80x80.jpg 80w, https:\/\/blog.shahada.abubakar.net\/wp-content\/uploads\/2026\/06\/IMG_20260627_234159-320x320.jpg 320w, https:\/\/blog.shahada.abubakar.net\/wp-content\/uploads\/2026\/06\/IMG_20260627_234159.jpg 1558w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p class=\"wp-block-paragraph\">Sometimes you want a handful of GPIO lines on a regular PC \u2014 to drive an LED, read a button, bit-bang a sensor, or wire up a hobby KVM \u2014 without buying a dedicated USB-GPIO chip. It turns out a Raspberry Pi Pico (the ~$4 RP2040 board) can do exactly that, and the lines show up as a normal <code>gpiochip<\/code> device that <code>libgpiod<\/code> manages just like any other GPIO controller.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This post walks through the whole thing on a Debian 13 (trixie) desktop: how the trick works, how to build and flash the firmware, how to build the kernel drivers Debian no longer ships, and finally how to blink the Pico&#8217;s onboard LED through <code>libgpiod<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The problem: a Pico is not a gpiochip<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>libgpiod<\/code> is just userspace tooling on top of the Linux kernel&#8217;s GPIO character-device interface \u2014 the <code>\/dev\/gpiochipN<\/code> nodes. Those nodes are created by <em>kernel drivers<\/em>. A bare Pico is a microcontroller running its own firmware; the host kernel has no idea its pins exist, so nothing appears and there&#8217;s nothing for <code>libgpiod<\/code> to talk to.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To make the Pico&#8217;s lines manageable by <code>libgpiod<\/code>, we need a kernel driver on the PC that exposes them as a <code>gpiochip<\/code>. We&#8217;re not going to write one. Instead we&#8217;ll make the Pico <em>impersonate a device that already has a mainline kernel driver<\/em>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">How the solution works: the DLN-2 disguise<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.diolan.com\/\">Diolan DLN-2<\/a> is a commercial USB-to-I2C\/SPI\/GPIO adapter. Crucially, Linux has shipped mainline drivers for it since kernel 3.18 \u2014 a small family of modules:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>dln2<\/code> \u2014 the MFD (multi-function device) core that talks the USB protocol<\/li>\n\n\n\n<li><code>gpio-dln2<\/code> \u2014 registers the GPIO lines as a <code>gpiochip<\/code><\/li>\n\n\n\n<li><code>i2c-dln2<\/code> \u2014 registers an I2C adapter (<code>\/dev\/i2c-N<\/code>)<\/li>\n\n\n\n<li><code>spi-dln2<\/code> \u2014 registers an SPI controller<\/li>\n\n\n\n<li><code>dln2-adc<\/code> \u2014 exposes the ADC through the IIO subsystem<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">So if the Pico speaks the DLN-2 USB protocol, the kernel&#8217;s existing <code>dln2<\/code> stack binds to it and we get a real <code>gpiochip<\/code> for free. That&#8217;s exactly what <a href=\"https:\/\/github.com\/notro\/pico-usb-io-board\">notro&#8217;s <code>pico-usb-io-board<\/code><\/a> firmware does: it turns the Pico into a USB I\/O board implementing the DLN-2 protocol (plus two CDC UARTs as a bonus).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The full data path looks like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>  your program\n       \u2502\n   libgpiod (userspace)\n       \u2502\n  \/dev\/gpiochipN\n       \u2502\n  gpio-dln2  \u2500\u2500\u2510\n  i2c-dln2     \u251c\u2500 kernel modules\n  spi-dln2   \u2500\u2500\u2518\n       \u2502\n     dln2 (MFD core)\n       \u2502  USB\n       \u25bc\n  Raspberry Pi Pico\n  running pico-usb-io-board firmware\n  (speaks the DLN-2 protocol)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two wrinkles make this a little more involved than &#8220;flash and go,&#8221; and they&#8217;re the reason for most of this post:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>The firmware needs building.<\/strong> notro&#8217;s repo was archived in January 2025 and is pinned to an old Pico SDK, so it no longer compiles against a current toolchain. We&#8217;ll use the <a href=\"https:\/\/github.com\/tao-j\/pico-usb-io-board\"><code>tao-j<\/code> fork<\/a>, whose key commit updates the bundled <code>pico-sdk<\/code> to 2.2.0 and fixes the resulting compile errors. (It changes nothing about runtime behavior \u2014 same protocol, same USB ID, same kernel contract.)<\/li>\n\n\n\n<li><strong>Debian 13 dropped the drivers.<\/strong> trixie&#8217;s stock kernel no longer builds the DLN-2 modules. We&#8217;ll build them ourselves with DKMS so they survive kernel upgrades.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">One honest caveat before we start: this is GPIO <em>over USB<\/em>. It&#8217;s great for setting outputs, reading inputs, and catching button\/edge events, but USB round-trip latency means it is <strong>not<\/strong> suitable for microsecond-accurate timing or fast bit-banging. For that you&#8217;d write Pico-side firmware to handle the timing-critical part locally.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What you&#8217;ll need<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>A Raspberry Pi Pico (RP2040). The steps target the original Pico; a Pico 2 \/ RP2350 is possible but needs extra board-config work not covered here.<\/li>\n\n\n\n<li>A Debian 13 (trixie) desktop, kernel <code>6.12.x<\/code> (this post was written against <code>6.12.94+deb13-amd64<\/code>).<\/li>\n\n\n\n<li>A USB cable and the Pico&#8217;s BOOTSEL button.<\/li>\n\n\n\n<li>Root access on the PC.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Part 1 \u2014 Build the firmware and flash it<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">1.1 Install the build toolchain<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The Pico SDK cross-compiles with the ARM bare-metal toolchain and builds a couple of native host tools along the way, so you need both:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo apt update\nsudo apt install git cmake build-essential python3 \\\n     gcc-arm-none-eabi libnewlib-arm-none-eabi libstdc++-arm-none-eabi-newlib<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">1.2 Clone the fork (with submodules)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><code>pico-sdk<\/code> is a git <strong>submodule<\/strong> of the project, and the SDK has its own submodules (TinyUSB). A recursive clone pulls everything \u2014 this is the single most common thing people get wrong:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>git clone --recurse-submodules https:\/\/github.com\/tao-j\/pico-usb-io-board.git\ncd pico-usb-io-board<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If you ever forget the flag, fix it with:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>git submodule update --init --recursive<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">1.3 Build<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The project wraps the normal CMake flow in a script:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>.\/build.sh<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">(You can set <code>BUILD_DIR<\/code> to redirect the build output elsewhere.) When it finishes, find the firmware image:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>find . -name '*.uf2'<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That <code>.uf2<\/code> is what you flash.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">1.4 Flash via BOOTSEL<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The Pico&#8217;s ROM bootloader presents itself as a USB mass-storage drive, so flashing is just a file copy \u2014 no special tool required:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Unplug the Pico.<\/li>\n\n\n\n<li>Press and <strong>hold the BOOTSEL button<\/strong>.<\/li>\n\n\n\n<li>While holding it, plug the Pico into the PC.<\/li>\n\n\n\n<li>Release BOOTSEL once a drive named <strong><code>RPI-RP2<\/code><\/strong> appears.<\/li>\n\n\n\n<li>Copy the firmware onto it:<\/li>\n<\/ol>\n\n\n\n<pre class=\"wp-block-code\"><code>cp .\/build\/pico-usb-io-board.uf2 \/media\/$USER\/RPI-RP2\/   # adjust path to your .uf2\nsync<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The Pico reboots automatically as the copy completes, the drive disappears, and it comes back running the firmware. Confirm it enumerated:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>lsusb | grep 1d50:6170<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">You should see the device at USB ID <code>1d50:6170<\/code>. (That&#8217;s an Openmoko FOSS product ID the firmware uses \u2014 remember it, because it matters in Part 3.)<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Part 2 \u2014 Build the dln2 kernel drivers for Debian 13<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">At this point the Pico is on the bus, but nothing binds to it. If you try to load the driver:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># modprobe -b dln2\nmodprobe: FATAL: Module dln2 not found in directory \/lib\/modules\/6.12.94+deb13-amd64<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This isn&#8217;t a missing package \u2014 it&#8217;s a config choice. Debian trixie trimmed a number of modules that earlier releases shipped, and the DLN-2 family is in that set. Confirm it on your machine:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>grep -E 'DLN2' \/boot\/config-$(uname -r)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If you get <code># CONFIG_MFD_DLN2 is not set<\/code> or no output at all, the modules simply weren&#8217;t built. The good news: the driver sources are mainline and self-contained, so we can compile just those five files against our installed kernel headers and wrap them in DKMS so they auto-rebuild on every kernel update.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">2.1 Install DKMS, headers, and kernel source<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo apt install dkms build-essential linux-headers-$(uname -r) linux-source-6.12<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">2.2 Stage the driver source under \/usr\/src<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">DKMS requires the source to live permanently in <code>\/usr\/src\/&lt;name&gt;-&lt;version&gt;\/<\/code>, since it re-reads it on every rebuild. Extract the five driver files there:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo mkdir -p \/usr\/src\/dln2-1.0\ncd \/usr\/src\/dln2-1.0\nsudo tar xf \/usr\/src\/linux-source-6.12.tar.xz --wildcards --strip-components=4 \\\n  '*\/drivers\/mfd\/dln2.c' \\\n  '*\/drivers\/gpio\/gpio-dln2.c' \\\n  '*\/drivers\/i2c\/busses\/i2c-dln2.c' \\\n  '*\/drivers\/spi\/spi-dln2.c' \\\n  '*\/drivers\/iio\/adc\/dln2-adc.c'<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Confirm you ended up with five flat <code>.c<\/code> files:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ls \/usr\/src\/dln2-1.0\n# dln2-adc.c  dln2.c  gpio-dln2.c  i2c-dln2.c  spi-dln2.c<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>--strip-components=4<\/code> drops the <code>*\/drivers\/&lt;subsys&gt;\/<\/code> path prefixes so the files land flat. If your <code>tar<\/code> puts them in subdirectories instead, just move them up:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo find . -name '*.c' -exec mv -t . {} +\nsudo rm -rf drivers<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">You do <strong>not<\/strong> need <code>dln2.h<\/code> \u2014 it ships in <code>linux-headers<\/code> as <code>include\/linux\/mfd\/dln2.h<\/code> and resolves at build time.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">2.3 Add the Makefile and dkms.conf<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo tee \/usr\/src\/dln2-1.0\/Makefile &gt;\/dev\/null &lt;&lt;'EOF'\nobj-m += dln2.o gpio-dln2.o i2c-dln2.o spi-dln2.o dln2-adc.o\nEOF<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo tee \/usr\/src\/dln2-1.0\/dkms.conf &gt;\/dev\/null &lt;&lt;'EOF'\nPACKAGE_NAME=\"dln2\"\nPACKAGE_VERSION=\"1.0\"\nAUTOINSTALL=\"yes\"\n\nMAKE&#91;0]=\"make -C ${kernel_source_dir} M=${dkms_tree}\/${PACKAGE_NAME}\/${PACKAGE_VERSION}\/build modules\"\nCLEAN=\"make -C ${kernel_source_dir} M=${dkms_tree}\/${PACKAGE_NAME}\/${PACKAGE_VERSION}\/build clean\"\n\nBUILT_MODULE_NAME&#91;0]=\"dln2\"\nBUILT_MODULE_NAME&#91;1]=\"gpio-dln2\"\nBUILT_MODULE_NAME&#91;2]=\"i2c-dln2\"\nBUILT_MODULE_NAME&#91;3]=\"spi-dln2\"\nBUILT_MODULE_NAME&#91;4]=\"dln2-adc\"\n\nDEST_MODULE_LOCATION&#91;0]=\"\/updates\/dkms\"\nDEST_MODULE_LOCATION&#91;1]=\"\/updates\/dkms\"\nDEST_MODULE_LOCATION&#91;2]=\"\/updates\/dkms\"\nDEST_MODULE_LOCATION&#91;3]=\"\/updates\/dkms\"\nDEST_MODULE_LOCATION&#91;4]=\"\/updates\/dkms\"\nEOF<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>AUTOINSTALL=\"yes\"<\/code> is the line that makes DKMS rebuild against future kernels automatically. The <code>DEST_MODULE_LOCATION<\/code> entries are effectively forced to <code>\/updates\/dkms<\/code> by modern DKMS but still have to be present.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">2.4 Register, build, install<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo dkms add -m dln2 -v 1.0\nsudo dkms build -m dln2 -v 1.0\nsudo dkms install -m dln2 -v 1.0<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Verify and load:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>dkms status dln2          # should report: installed\nsudo modprobe dln2        # should now succeed<\/code><\/pre>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\"><strong>Secure Boot note:<\/strong> if Secure Boot is enabled (<code>mokutil --sb-state<\/code> says enabled), unsigned out-of-tree modules won&#8217;t load. Enroll a Machine Owner Key (<code>mokutil --import<\/code>, reboot to confirm) and configure DKMS signing so each build is signed. If Secure Boot is disabled, ignore this.<\/p>\n<\/blockquote>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Part 3 \u2014 Bind the driver to the Pico<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Loading <code>dln2<\/code> still doesn&#8217;t grab the Pico, for a subtle reason: the firmware advertises USB ID <code>1d50:6170<\/code>, but the in-kernel <code>dln2<\/code> driver only matches Diolan&#8217;s real ID. So we have to tell the driver to also accept our ID, via a udev rule that loads the module and writes the ID to the driver&#8217;s <code>new_id<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo tee \/etc\/udev\/rules.d\/40-usb-dln2.rules &gt;\/dev\/null &lt;&lt;'EOF'\nACTION==\"add\", SUBSYSTEM==\"usb\", \\\n        ATTR{idVendor}==\"1d50\", ATTR{idProduct}==\"6170\", \\\n        RUN+=\"\/sbin\/modprobe -b dln2\"\n\nACTION==\"add\", SUBSYSTEM==\"drivers\", ENV{DEVPATH}==\"\/bus\/usb\/drivers\/dln2\", \\\n        ATTR{new_id}=\"1d50 6170 ff\"\nEOF\n\nsudo udevadm control --reload<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Now replug the Pico and check:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>gpiodetect<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">You should see something like:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>gpiochip0 &#91;INTC1056:00] (473 lines)\ngpiochip1 &#91;dln2] (29 lines)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>gpiochip1 [dln2]<\/code> is your Pico, now a first-class GPIO controller on the system.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Part 4 \u2014 Demonstration: light the onboard LED<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The Pico&#8217;s onboard LED is wired to <strong>GPIO 25<\/strong>, and on this firmware GPIO 25 is &#8220;sticky&#8221; \u2014 it holds its state after the controlling process exits, which makes it a perfect test pin.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">A note on libgpiod v1 vs v2<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Debian 13 ships <strong>libgpiod v2<\/strong>, whose command-line syntax differs from the v1 examples you&#8217;ll find all over the web. Two changes trip people up:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The chip is passed with <code>-c<\/code>, not as a positional argument: <code>gpioset -c &lt;chip&gt; &lt;line&gt;=&lt;value&gt;<\/code>.<\/li>\n\n\n\n<li>That <code>-c<\/code> value is the chip&#8217;s <strong>device name or path<\/strong> (<code>gpiochip1<\/code> \/ <code>\/dev\/gpiochip1<\/code>), <strong>not<\/strong> the label in brackets (<code>dln2<\/code>). Passing the label gives <code>cannot find GPIO chip character device 'dln2'<\/code>.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">So the literal command is:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>gpioset -c gpiochip1 25=1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">\u2026and the LED lights up.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Do it the robust way<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The chip <em>number<\/em> isn&#8217;t stable \u2014 <code>gpiochip1<\/code> today might enumerate as <code>gpiochip2<\/code> after a reboot or replug, depending on ordering against the host&#8217;s own controllers. Don&#8217;t hardcode it. Resolve the chip by its <code>dln2<\/code> label instead:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>chip=$(gpiodetect | awk '\/\\&#91;dln2\\]\/{print $1}')\n\ngpioset -c \"$chip\" 25=1     # LED on\ngpioset -c \"$chip\" 25=0     # LED off<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That always finds the Pico whatever number it landed on.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Reading and watching pins<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The same <code>-c<\/code> pattern applies to the rest of the toolset:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>gpioget -c \"$chip\" 12       # read an input pin\ngpiomon -c \"$chip\" 12       # watch for edge events<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">One behavioral gotcha: in v2, <code>gpioset<\/code> exits immediately by default and releases the line. GPIO 25 holds its value because it&#8217;s sticky, but on a normal output pin releasing the line drops it back to default. To hold an ordinary output, keep the process alive:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>gpioset -c \"$chip\" -t0 12=1   # hold 12 high until Ctrl-C<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Pin caveats<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A few pins behave specially on this firmware: GPIO 23 and 24 aren&#8217;t on the Pico header and will error; GPIO 4 and 5 get claimed as I2C pins, and GPIO 17 as SPI chip-select, if those sub-drivers load. If you want 4, 5, or 17 as plain GPIO, prevent <code>i2c-dln2<\/code> \/ <code>spi-dln2<\/code> from loading.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Wrapping up<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">With three pieces in place \u2014 the DLN-2-speaking firmware on the Pico, the DKMS-built <code>dln2<\/code> kernel modules, and the udev rule that binds them \u2014 a $4 microcontroller becomes a genuine <code>libgpiod<\/code>-managed GPIO controller on your Debian desktop. Because the kernel sees a standard <code>gpiochip<\/code>, everything downstream just works: the <code>gpio*<\/code> CLI tools, the C\/C++\/Python <code>libgpiod<\/code> bindings, and any software that already speaks to GPIO character devices. You also get an I2C adapter, an SPI controller, an ADC, and two UARTs from the same firmware as a bonus.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The DKMS wrapper means a kernel upgrade won&#8217;t silently break your setup \u2014 DKMS rebuilds the modules automatically, and <code>dkms status<\/code> flags it if a future kernel ever needs refreshed source. Just keep the USB-over-latency limitation in mind, and this is a remarkably cheap and flexible way to get real GPIO onto a PC.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Credits<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/github.com\/notro\/pico-usb-io-board\">notro\/pico-usb-io-board<\/a> \u2014 the original DLN-2 firmware for the Pico.<\/li>\n\n\n\n<li><a href=\"https:\/\/github.com\/tao-j\/pico-usb-io-board\">tao-j\/pico-usb-io-board<\/a> \u2014 the fork that keeps it building against current Pico SDK (2.2.0).<\/li>\n\n\n\n<li>The DLN-2 kernel drivers are mainline Linux, originally contributed by Octavian Purdila, Daniel Baluta, and Laurentiu Palcu.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n","protected":false},"excerpt":{"rendered":"<p>When developing for embedded Linux, I prefer to do the heavy lifting on my desktop PC and port the code over later. However, a common roadblock is that most desktops lack native GPIO support.&#46;&#46;&#46;<\/p>\n","protected":false},"author":1,"featured_media":9603,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[116],"tags":[],"class_list":["post-9598","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-linux"],"_links":{"self":[{"href":"https:\/\/blog.shahada.abubakar.net\/index.php?rest_route=\/wp\/v2\/posts\/9598","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/blog.shahada.abubakar.net\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/blog.shahada.abubakar.net\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/blog.shahada.abubakar.net\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/blog.shahada.abubakar.net\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=9598"}],"version-history":[{"count":4,"href":"https:\/\/blog.shahada.abubakar.net\/index.php?rest_route=\/wp\/v2\/posts\/9598\/revisions"}],"predecessor-version":[{"id":9604,"href":"https:\/\/blog.shahada.abubakar.net\/index.php?rest_route=\/wp\/v2\/posts\/9598\/revisions\/9604"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/blog.shahada.abubakar.net\/index.php?rest_route=\/wp\/v2\/media\/9603"}],"wp:attachment":[{"href":"https:\/\/blog.shahada.abubakar.net\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=9598"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/blog.shahada.abubakar.net\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=9598"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/blog.shahada.abubakar.net\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=9598"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}