{"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-07-12T08:00:35","modified_gmt":"2026-07-12T08:00:35","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\">Note: This worked as of Debian 13.6 (12 July 2026)<\/p>\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\n\nsudo tar xf \/usr\/src\/linux-source-6.12.tar.xz \\\n  --transform='s|.*\/||' \\\n  \"linux-source-6.12\/drivers\/gpio\/gpio-dln2.c\" \\\n  \"linux-source-6.12\/drivers\/i2c\/busses\/i2c-dln2.c\" \\\n  \"linux-source-6.12\/drivers\/iio\/adc\/dln2-adc.c\" \\\n  \"linux-source-6.12\/drivers\/mfd\/dln2.c\" \\\n  \"linux-source-6.12\/drivers\/spi\/spi-dln2.c\" \\\n  \"linux-source-6.12\/include\/linux\/mfd\/dln2.h\"<\/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\">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<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Addendum<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">On Debian 13, gpiochip is owned by root:root, and so need to be the root user to access it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Perform the following if you want to access this from your own user acount:<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Create A System-Wide gpio Group<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Since Debian 13 does not pre-populate this group for standard user accounts, manually initialize it and bind your profile to it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo addgroup --system gpio\nsudo usermod -aG gpio $USER<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Establish a Permanent udev Rule<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Edit the udev file we create above, and append the following:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>SUBSYSTEM==\"gpio\", KERNEL==\"gpiochip*\", GROUP:=\"gpio\", MODE:=\"0660\"\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Save the document and refresh system configurations<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">To propagate the new hardware rules and apply your modified account permissions instantly without restarting the computer:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo udevadm control --reload-rules &amp;&amp; sudo udevadm trigger newgrp gpio<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Verifying Succesful Startup<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Run a targeted listing command to ensure that the group ownership on the device character files has successfully flipped from root to gpio:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ls -l \/dev\/gpiochip*<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Expected output:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># ls -l \/dev\/gpio*\ncrw-rw---- 1 root gpio 254, 0 Jul 12 12:07 \/dev\/gpiochip0\ncrw-rw---- 1 root gpio 254, 1 Jul 12 12:07 \/dev\/gpiochip1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">As long as the string gpio appears as the group owner and your current session is nested inside that group, you can execute gpiodetect, gpioinfo, or write scripts utilizing libgpiod cleanly without ever typing sudo again.<\/p>\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":6,"href":"https:\/\/blog.shahada.abubakar.net\/index.php?rest_route=\/wp\/v2\/posts\/9598\/revisions"}],"predecessor-version":[{"id":9610,"href":"https:\/\/blog.shahada.abubakar.net\/index.php?rest_route=\/wp\/v2\/posts\/9598\/revisions\/9610"}],"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}]}}