








{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "Stephan Bökelmann — From tape-out to TypeScript",
  "home_page_url": "https://maxclerkwell.tech/",
  "feed_url": "https://maxclerkwell.tech/feed.json",
  "description": "Engineer, physicist, and lecturer at THGA Bochum. I write about decentralised DAQ, particle physics instrumentation, embedded systems, and manufacturing — usually one post per week.",
  "icon": "https://maxclerkwell.tech/slides/Avatar.jpg",
  "favicon": "https://maxclerkwell.tech/favicon.svg",
  "language": "en",
  "authors": [
    {
      "name": "Stephan Bökelmann",
      "url": "https://maxclerkwell.tech/about/",
      "avatar": "https://maxclerkwell.tech/slides/Avatar.jpg"
    }
  ],
  "items": [
    
    
    {
      "id": "https://maxclerkwell.tech/posts/skainet-edge-compute-september-2026/",
      "url": "https://maxclerkwell.tech/posts/skainet-edge-compute-september-2026/",
      "title": "skAInet Edge-Compute: The Box We Kept Rebuilding Now Has a Product Page",
      "summary": "Auto-Intern's skAInet Edge-Compute is now a product with its own site: a sealed M12-PoE switch, router and 8-core Linux compute module for industrial edge data acquisition. What it is, where it came from, and where I fit in.",
      "content_html": "<p>For about ten years, every monitoring system I helped build at\n<a href=\"https://auto-intern.de\">Auto-Intern</a> had the same thing sitting in the\nmiddle of it: a small Linux box with one uplink, a handful of PoE ports, and\nenough compute to make sense of the sensors hanging off it. It was never the\nproduct. The product was the PowerSense, or the reflow oven monitor, or\nwhatever the customer had actually asked for. The box was the thing we\nrebuilt each time to make the product work.</p>\n\n<p>That box now has a name, a version number, and its own website:\n<a href=\"https://edge-compute.skainet.io/\"><strong>skAInet Edge-Compute v1.5</strong></a>.</p>\n\n<figure>\n  <img src=\"https://edge-compute.skainet.io/img/edge-branded.jpg\" alt=\"skAInet Edge-Compute: anodized aluminium enclosure with sealed M12 connectors\" loading=\"lazy\" />\n  <figcaption>One block of anodized aluminium, every port sealed. Photo: Auto-Intern GmbH.</figcaption>\n</figure>\n\n<h2 id=\"what-it-is\">What it is</h2>\n\n<p>In one sentence: a programmable M12-PoE switch, router, and compute module\nfor industrial edge applications, built in Germany by Auto-Intern GmbH.</p>\n\n<p>The parts that matter, in the order I care about them:</p>\n\n<ul>\n  <li><strong>Two separate Ethernet interfaces.</strong> One M12 WAN port joins the upstream\ncompany network. An internal 8-port switch fans out to <strong>seven M12 LAN\nports</strong>, each with PoE Class 3, on which the device spans its own DHCP\nnetwork. Sensors, PLCs, cameras and skAInet measurement devices hang on the\nLAN side; only what you choose to forward leaves through the WAN side.</li>\n  <li><strong>Real compute.</strong> An 8-core 64-bit ARM at 1.5 GHz, 8 GB LPDDR4-3200,\n32 GB eMMC. Enough to buffer, pre-process and analyse measurement data\nwhere it is produced, rather than shipping raw samples upstream and hoping.</li>\n  <li><strong>A compute module you can swap.</strong> The heart is pin-compatible with the\nRaspberry Pi Compute Module family and sits on a skAInet carrier board. A\nfaster or cheaper module can be fitted later; enclosure, carrier and ports\nstay the same.</li>\n  <li><strong>48 to 72 V DC in over M12.</strong> One cable powers the module and all seven\nattached PoE devices.</li>\n  <li><strong>Sealed.</strong> Anodized aluminium enclosure, sealed M12 connectors, rated for\noperation under water up to 1 bar. That is not a marketing number; the\nsame design was on the bench for the\n<a href=\"/posts/dongguan-emc-march-2026/\">EMC campaign in Dongguan</a>, where the\nanodized housing nearly broke the shield path at the M12 connectors.</li>\n  <li><strong>Yocto Linux with a documented SBOM.</strong> Full Linux, SSH access, write your\ndata mappers and aggregators in C++ or Python. The software bill of\nmaterials is there because the EU Cyber Resilience Act will ask for it,\nand because a customer running critical infrastructure should be able to\nread it before we do.</li>\n</ul>\n\n<p>Upstream, it speaks whatever your systems already speak: REST, WebSocket,\nwebhooks, MQTT, OPC UA, Modbus-TCP, EPICS, gRPC, AMQP, CoAP, SNMP,\nPrometheus, InfluxDB, SFTP and rsync. Downstream, it collects, polls and\nreceives from anything with a network port.</p>\n\n<p>Pricing starts at USD 299, and the\n<a href=\"https://edge-compute.skainet.io/docs/specifications\">technical specifications</a>\nand a <a href=\"https://edge-compute.skainet.io/docs/getting-started\">getting-started guide</a>\nare public.</p>\n\n<h2 id=\"where-it-came-from\">Where it came from</h2>\n\n<p>The lineage is documented on this blog, in pieces:</p>\n\n<ul>\n  <li>The <a href=\"/posts/skainet-powersense-jan-2026/\">PowerSense retrospective</a> shows\nthe DIANA Edge Computer Gateway, a DB-branded rack-mount prototype from\nthe switching-station days. The “PoE first” rule and “digitise as close to\nthe source as possible” both come from that project.</li>\n  <li>The <a href=\"/posts/dongguan-emc-march-2026/\">Dongguan EMC story</a> describes the\nreflow oven monitor for Kurtz Ersa: a compact Linux box with one WAN port\nand a seven-port PoE switch, auto-detecting attached measurement modules\nand serving REST, WebSocket and MQTT upstream. That is the Edge-Compute\nbefore it had the name.</li>\n  <li>The <a href=\"/posts/alinx-bring-up-jtag-detected-without-power-august-2026/\">AX7020 bring-up article</a>\nshows the M12 adapter and the cables we specified together with GH\nElectronic in Dongguan; the same cables ship with the Edge-Compute.</li>\n</ul>\n\n<p>Ten years of rebuilding the same thing is a strong hint that it wants to be\na product. Version 1.5 and its predecessors have been running 365/24/7 in\nplants collecting data, operating measurement devices and delivering results\nfor long enough that the “product” step was mostly a matter of writing it\nall down.</p>\n\n<h2 id=\"where-i-fit-in\">Where I fit in</h2>\n\n<p>I joined Auto-Intern in 2014, was head of development from 2016 to 2018,\nwhich is when the PoE-first architecture became the house style, and have\nbeen Chief Operating Officer since 2018. In parallel I run\n<a href=\"https://nabla-b.engineering/\">nabla B</a>, my own engineering office. The\nEdge-Compute is the platform underneath most of the monitoring projects\nlisted on my <a href=\"/about/\">about page</a>. My role on it is system architect: I was system architect on the reflow oven monitor\nbuilt on top of it, co-developed the M12 adapter, and specified the\ncabling. <a href=\"https://x.com/odinthenerd\">Odin Holmes</a> owns\nhardware and firmware, <a href=\"https://x.com/tabeatheunicorn\">Tabea Bökelmann</a>\nowns software and the APIs; the whole team is on the\n<a href=\"https://edge-compute.skainet.io/team\">team page</a>.</p>\n\n<p>If you want an Edge-Compute in your plant, the product side is Auto-Intern:\n<a href=\"mailto:info@auto-intern.de\">info@auto-intern.de</a> or the\n<a href=\"https://edge-compute.skainet.io/contact\">contact page</a>. If you need\nsomeone to design the measurement chain that hangs off its seven PoE ports,\nor to get your own sensor onto that network, that is the part I do\nfreelance; see <a href=\"/hire/\">/hire</a>.</p>",
      "date_published": "2026-09-01T00:00:00+00:00",
      
      "date_modified": "2026-09-01T00:00:00+00:00",
      "image": "https://edge-compute.skainet.io/img/edge-branded.jpg",
      "authors": [ { "name": "Stephan Bökelmann" } ],
      "tags": ["electronics","embedded","linux","monitoring","engineering"]
    },
    
    {
      "id": "https://maxclerkwell.tech/posts/alinx-ax7020-yocto-linux-qspi-august-2026/",
      "url": "https://maxclerkwell.tech/posts/alinx-ax7020-yocto-linux-qspi-august-2026/",
      "title": "ALINX AX7020, Stages 2 & 3: A Yocto Linux in QSPI Flash That Fetches Its Own Updates",
      "summary": "Stages 2 and 3 of the open bitstream pipeline: a self-built Yocto Linux for the ALINX AX7020, resident in QSPI flash, discoverable on the company network, key-only SSH: the walkthrough that works, followed by the nine detours it took to get there.",
      "content_html": "<p>Stage 1 ended with mainline U-Boot booting from QSPI flash and answering over\nthe network, and I closed that article by saying the Linux build gets its own\npost. This is that post, and true to form it contains a flash chip that\nverified every checksum while storing everything one byte off, a bootloader\nthat goes mute the moment you plug it into a real switch, and a real-time\nclock that turns out to be physically unreachable until the FPGA is\nconfigured.</p>\n\n<p>This covers Stages 2 and 3 of the\n<a href=\"/posts/zynq-bitstream-deployment-concept-august-2026/\">bitstream pipeline plan</a>\nfor the AX7020 that <a href=\"https://www.alinx.com/\">ALINX</a> sent me: a <strong>self-built\nYocto Linux that boots from QSPI flash on its own</strong>, shows up on the company\nnetwork under its own hostname, accepts SSH logins only with a key, and\ncarries the tooling to fetch and <code class=\"language-plaintext highlighter-rouge\">kexec</code> fresh development images from a\nserver: no PetaLinux, no vendor kernel tree, no SD card. Stage 2, the image\nserver, is in here too, though not in the shape the plan promised: a real\nswitch talked me out of netboot, and the server ended up as a plain HTTP\nendpoint the running Linux pulls from; more on that below.</p>\n\n<p>Same structure as last time: first the walkthrough that actually works,\nreproducible from the Stage 1 end state. Then every detour, because the\ndetours are where the learning was, and at least three of them will bite\nanyone doing this on any Zynq board.</p>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>OF: fdt: Machine model: Alinx AX7020 board\nMemory: 1008588K/1048576K available\nmacb e000b000.ethernet eth0: Cadence GEM rev 0x00020118\nfpga_manager fpga0: Xilinx Zynq FPGA Manager registered\nof-fpga-region fpga-region: FPGA Region probed\n</code></pre></div></div>\n\n<p>That is the end state, booted straight out of flash. Here is the way there.</p>\n\n<h2 id=\"the-seven-checkpoints\">The seven checkpoints</h2>\n\n<p>As in Stage 1, every step ends in something you can <em>test</em>, because the\nexpensive failures on this road are the ones that pass every check you did\nthink of. The walkthrough is built around seven checkpoints:</p>\n\n<table>\n  <thead>\n    <tr>\n      <th>#</th>\n      <th>After</th>\n      <th>What must be true</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>1</td>\n      <td>Yocto build</td>\n      <td>3953 tasks succeed; the FIT exists, <code class=\"language-plaintext highlighter-rouge\">dumpimage -l</code> lists kernel, DTB and initramfs with SHA256 hashes</td>\n    </tr>\n    <tr>\n      <td>2</td>\n      <td>RAM boot</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">tftpboot</code> + <code class=\"language-plaintext highlighter-rouge\">bootm</code> boots Linux; <code class=\"language-plaintext highlighter-rouge\">/sys/class/fpga_manager/fpga0/</code> exists; <strong>port 22 answers</strong> (ping does not count)</td>\n    </tr>\n    <tr>\n      <td>3</td>\n      <td>board identity</td>\n      <td>DHCP log shows a lease for the fixed MAC <code class=\"language-plaintext highlighter-rouge\">02:41:58:70:20:01</code> with hostname <code class=\"language-plaintext highlighter-rouge\">ax7020</code>, surviving an environment wipe</td>\n    </tr>\n    <tr>\n      <td>4</td>\n      <td>flashing the FIT</td>\n      <td>read-back MD5 matches, <strong>and</strong> the first words at several different flash offsets differ from each other (see detour 7)</td>\n    </tr>\n    <tr>\n      <td>5</td>\n      <td>standalone boot</td>\n      <td>power cycle with J13 on QSPI: Linux up, SSH reachable, no JTAG, no TFTP</td>\n    </tr>\n    <tr>\n      <td>6</td>\n      <td>company network</td>\n      <td>the board appears in the DHCP inventory API by name; SSH works through the office switch</td>\n    </tr>\n    <tr>\n      <td>7</td>\n      <td>key-only SSH</td>\n      <td>login with the deployed key succeeds; wrong key, no key and password login are all rejected</td>\n    </tr>\n  </tbody>\n</table>\n\n<p>If a checkpoint fails, everything before it is known-good. All self-written\nfiles (the Yocto layer, the U-Boot patches, the host tools) live in\n<a href=\"https://github.com/MaxClerkwell/ax7020-bringup\">github.com/MaxClerkwell/ax7020-bringup</a>.\nNow the walkthrough.</p>\n\n<h2 id=\"the-starting-point\">The starting point</h2>\n\n<p>The board is where Stage 1 left it: <code class=\"language-plaintext highlighter-rouge\">boot.bin</code> (mainline SPL) and\n<code class=\"language-plaintext highlighter-rouge\">u-boot.img</code> in QSPI, operated entirely over U-Boot’s netconsole on UDP port\n6666, no serial cable. The lab tools are the same two Python scripts from the\nrepo: <code class=\"language-plaintext highlighter-rouge\">tools/ncsh.py</code> for the netconsole and <code class=\"language-plaintext highlighter-rouge\">tools/tftpd.py</code> as an\nunprivileged TFTP server.</p>\n\n<p>Goal of this session: <strong>Linux.</strong></p>\n\n<h2 id=\"step-1-a-yocto-build-for-a-board-yocto-has-never-heard-of\">Step 1: A Yocto build for a board Yocto has never heard of</h2>\n\n<h3 id=\"picking-the-release\">Picking the release</h3>\n\n<p><code class=\"language-plaintext highlighter-rouge\">scarthgap</code> (Yocto 5.0 LTS), and not out of preference:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>git ls-remote <span class=\"nt\">--heads</span> https://github.com/Xilinx/meta-xilinx | <span class=\"nb\">grep</span> <span class=\"nt\">-E</span> <span class=\"s2\">\"scarthgap|styhead|walnascar\"</span>\n<span class=\"c\"># -&gt; only scarthgap</span>\n</code></pre></div></div>\n\n<p>meta-xilinx offers no newer release branch, and that pins the common\ndenominator for everything else: poky, meta-arm and meta-openembedded all get\nchecked out on <code class=\"language-plaintext highlighter-rouge\">scarthgap</code>, as shallow clones, about 170 MB total.</p>\n\n<p>One host-side wrinkle before the first <code class=\"language-plaintext highlighter-rouge\">bitbake</code>: Debian 13 no longer ships\n<code class=\"language-plaintext highlighter-rouge\">liblz4-tool</code>, the binary lives in the <code class=\"language-plaintext highlighter-rouge\">lz4</code> package, and Yocto wants it\nunder the old name <code class=\"language-plaintext highlighter-rouge\">lz4c</code>, which lz4 ≥ 1.10 no longer installs. Instead of a\nroot-owned symlink in <code class=\"language-plaintext highlighter-rouge\">/usr/local/bin</code>, the symlink lives in the repo\n(<code class=\"language-plaintext highlighter-rouge\">yocto/hostbin/lz4c → /usr/bin/lz4</code>) and <code class=\"language-plaintext highlighter-rouge\">setup-build.sh</code> prepends\n<code class=\"language-plaintext highlighter-rouge\">hostbin</code> to <code class=\"language-plaintext highlighter-rouge\">PATH</code>. No root, and the fix travels with the repository.</p>\n\n<h3 id=\"the-layer\">The layer</h3>\n\n<p><code class=\"language-plaintext highlighter-rouge\">yocto/meta-ax7020/</code> holds everything board-specific. The machine\nconfiguration builds on meta-xilinx’s generic Zynq machine and narrows it\ndown:</p>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>require conf/machine/zynq-generic.conf\n\nKERNEL_DEVICETREE = \"xilinx/zynq-ax7020.dtb\"\n\nKERNEL_IMAGETYPE = \"fitImage\"\nKERNEL_CLASSES   = \"kernel-fitimage\"\nINITRAMFS_IMAGE  = \"ax7020-initramfs\"\nINITRAMFS_IMAGE_BUNDLE = \"0\"\n\n# U-Boot comes from mainline, not from Yocto\nPREFERRED_PROVIDER_virtual/bootloader = \"\"\n</code></pre></div></div>\n\n<p>The kernel device tree is a sibling of the U-Boot one from Stage 1: every\nvalue traced back to the ALINX <code class=\"language-plaintext highlighter-rouge\">design_1.hwh</code> and the schematic, none of it\ncopied from a template. The load-bearing nodes:</p>\n\n<pre><code class=\"language-dts\">&amp;gem0 {\n\tstatus = \"okay\";\n\tphy-mode = \"rgmii-id\";\n\tphy-handle = &lt;&amp;ethernet_phy&gt;;\n\tethernet_phy: ethernet-phy@1 { reg = &lt;1&gt;; };   /* RTL8211E-VL */\n};\n\n&amp;qspi {\n\tstatus = \"okay\";\n\tnum-cs = &lt;1&gt;;\n\tflash@0 {\n\t\tcompatible = \"w25q256\", \"jedec,spi-nor\";\n\t\tspi-rx-bus-width = &lt;1&gt;;\n\t\tspi-max-frequency = &lt;25000000&gt;;\n\t};\n};\n</code></pre>\n\n<p>A kernel config fragment switches on what the project needs (the FPGA\nmanager framework, device-tree overlays, SPI-NOR, netconsole) and throws out\nwhat the board does not have (DRM, sound, WLAN, IPv6). The pleasant surprise:\n<code class=\"language-plaintext highlighter-rouge\">zynq-7000.dtsi</code> already ships <code class=\"language-plaintext highlighter-rouge\">devcfg@f8007000</code> and an <code class=\"language-plaintext highlighter-rouge\">fpga-region</code> node\nenabled, so the FPGA manager, the one feature this Linux absolutely must have\nfor the later stages, needed nothing board-specific at all.</p>\n\n<p>The image recipe is <code class=\"language-plaintext highlighter-rouge\">core-image-minimal</code> as a <code class=\"language-plaintext highlighter-rouge\">cpio.gz</code> initramfs, plus\ndropbear for SSH.</p>\n\n<h3 id=\"building\">Building</h3>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">source </span>yocto/setup-build.sh\nbitbake virtual/kernel\n</code></pre></div></div>\n\n<p>One operational rule that cost an hour before it was a rule: start bitbake\n<em>detached</em>. If the client terminal goes away, it takes the <code class=\"language-plaintext highlighter-rouge\">bitbake-server</code>\nwith it (“Exiting as we could obtain the lock”) and the build stalls:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>setsid <span class=\"nb\">nohup </span>bash <span class=\"nt\">-c</span> <span class=\"s1\">'source yocto/setup-build.sh &gt;/dev/null 2&gt;&amp;1; \\\n    exec bitbake virtual/kernel'</span> <span class=\"o\">&gt;</span> build.log 2&gt;&amp;1 &lt; /dev/null &amp;\n</code></pre></div></div>\n\n<p>Restarts are cheap: <code class=\"language-plaintext highlighter-rouge\">sstate-cache</code> and <code class=\"language-plaintext highlighter-rouge\">tmp/work</code> let it resume where it\ndied. The build hit three genuine errors on the way through (detours 1–3\nbelow); with those fixed, 3953 tasks completed overnight.</p>\n\n<p><strong>Checkpoint 1: verify the artefacts on the host.</strong></p>\n\n<table>\n  <thead>\n    <tr>\n      <th>Component</th>\n      <th>Size</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>Kernel (linux-xlnx 6.6.40)</td>\n      <td>3.79 MiB</td>\n    </tr>\n    <tr>\n      <td>Device tree</td>\n      <td>11,651 B</td>\n    </tr>\n    <tr>\n      <td>initramfs <code class=\"language-plaintext highlighter-rouge\">cpio.gz</code></td>\n      <td>4.27 MiB</td>\n    </tr>\n    <tr>\n      <td><strong>FIT total</strong></td>\n      <td><strong>8.06 MiB</strong></td>\n    </tr>\n  </tbody>\n</table>\n\n<p><code class=\"language-plaintext highlighter-rouge\">dumpimage -l fitImage</code> must list all three sub-images with their SHA256\nhashes. Nothing downstream can work if this fails, and unlike everything\ndownstream, this failure comes with an error message.</p>\n\n<h2 id=\"step-2-first-boot-from-ram-only\">Step 2: First boot, from RAM only</h2>\n\n<p>Deliberately <strong>without</strong> touching the flash: a failure here costs nothing.</p>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>tftpboot 0x2000000 fitImage\nsetenv bootargs 'console=ttyPS0,115200 ip=dhcp'\nbootm 0x2000000\n</code></pre></div></div>\n\n<p>U-Boot verified all three sub-images against their SHA256 hashes and handed\nover. Linux came up, took a DHCP lease, and dropbear answered.</p>\n\n<p><strong>Checkpoint 2: Linux is provably running.</strong> Two parts. First,\n<code class=\"language-plaintext highlighter-rouge\">/sys/class/fpga_manager/fpga0/</code> must exist, with the attributes <code class=\"language-plaintext highlighter-rouge\">name</code>,\n<code class=\"language-plaintext highlighter-rouge\">state</code>, <code class=\"language-plaintext highlighter-rouge\">status</code> and <code class=\"language-plaintext highlighter-rouge\">firmware</code>; the last one is the file-write interface\nStage 4 will use to load bitstreams. Second, <strong>port 22 must answer</strong>. Not\nping: U-Boot happily answers ICMP while sitting in its netconsole loop, so a\nsuccessful ping proves nothing about which of the two systems you are\ntalking to. The reliable liveness test for Linux is the SSH port.</p>\n\n<p>(A <code class=\"language-plaintext highlighter-rouge\">netconsole=</code> kernel parameter, the obvious way to keep the cable-free\nconsole into Linux, stayed silent; that is detour 4.)</p>\n\n<h2 id=\"step-3-a-fixed-identity-in-u-boot\">Step 3: A fixed identity in U-Boot</h2>\n\n<p>Until now the board drew a random MAC on every boot\n(<code class=\"language-plaintext highlighter-rouge\">CONFIG_NET_RANDOM_ETHADDR</code>) and sent no hostname, which makes “find the\nboard on a network with 40 other devices” a daily annoyance. Two changes,\nboth baked into the U-Boot binary so they survive a wiped environment:</p>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>CONFIG_BOOTP_SEND_HOSTNAME=y\n</code></pre></div></div>\n\n<div class=\"language-c highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"cp\">#define CFG_EXTRA_ENV_SETTINGS\t\\\n\t\"ethaddr=02:41:58:70:20:01\\0\"\t\\\n\t\"hostname=ax7020\\0\"\t\t\\\n\t...\n</span></code></pre></div></div>\n\n<p>The MAC is locally administered (<code class=\"language-plaintext highlighter-rouge\">02:</code>), <code class=\"language-plaintext highlighter-rouge\">41:58</code> is “AX” in ASCII, <code class=\"language-plaintext highlighter-rouge\">70:20</code>\nthe board number. Silly, memorable, and it can never collide with a vendor\nOUI.</p>\n\n<p>While rebuilding U-Boot anyway, a twelve-line patch went in for shared-lab\npoliteness: as long as <code class=\"language-plaintext highlighter-rouge\">ncip</code> is the broadcast address, the first host that\ntypes into the netconsole claims it; U-Boot sets <code class=\"language-plaintext highlighter-rouge\">ncip</code> to that caller’s\naddress, and everyone else’s input is ignored. The claim lives only in the\nRAM environment, so a reboot releases the board, and the owner can release\nit early with <code class=\"language-plaintext highlighter-rouge\">setenv ncip 255.255.255.255</code>. To be clear about what this is:\nconvenience, not security. The netconsole has no authentication; whoever\nwins the race owns a bootloader prompt with <code class=\"language-plaintext highlighter-rouge\">sf write</code> on it.</p>\n\n<p><strong>Checkpoint 3: the identity sticks.</strong> The DHCP server’s log must show\nthe lease bound to the fixed MAC and carrying the name:</p>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>DHCPACK on 10.42.100.134 to 02:41:58:70:20:01 (ax7020) via eno4\n</code></pre></div></div>\n\n<h2 id=\"step-4-the-architecture-decision-the-switch-forced\">Step 4: The architecture decision the switch forced</h2>\n\n<p>The original plan for this stage was pure netboot: U-Boot fetches the FIT\nover TFTP on every power-up, nothing but the bootloader in flash. On the lab\nnetwork, board and workstation joined by one USB Ethernet adapter, that\nworked flawlessly. On the company switch it died in a way that took a packet\ncapture to understand (detour 6 has the full autopsy): U-Boot re-initialises\nthe Ethernet link on <em>every netconsole poll</em>, the switch port restarts its\nspanning-tree timers on every link bounce, and the two livelock each other\nforever. Two seconds of boot messages, then permanent silence.</p>\n\n<p>The conclusion is worth stating as a design rule: <strong>a bootloader is not a\nnetwork citizen.</strong> U-Boot’s network stack is a polling loop bolted to a\nconsole; it was never meant to hold a link on managed infrastructure. Linux\nbrings the interface up once and handles link changes like an adult.</p>\n\n<p>So the architecture flipped into what it should have been from the start: a\nsmall, boring <strong>maintenance system resident in flash</strong>, and development\nimages that never get written to flash at all.</p>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>Flash (rarely changes)              Network (changes daily)\n├─ SPL + U-Boot\n└─ FIT: kernel + initramfs      ──►  Yocto image from HTTP/Nextcloud\n   with curl, CA certificates,              │\n   kexec, ntpd, dropbear                    │\n        └── fetch, verify, kexec ───────────┘\n</code></pre></div></div>\n\n<p>The flash-resident image gained an updater package and <code class=\"language-plaintext highlighter-rouge\">CONFIG_KEXEC=y</code> in\nthe kernel. The updater is a short shell script: fetch the image with curl,\ncheck the first four bytes are the FIT magic <code class=\"language-plaintext highlighter-rouge\">d00dfeed</code> (this catches error\npages and captive portals before they reach the kernel), then\n<code class=\"language-plaintext highlighter-rouge\">kexec -l</code> and <code class=\"language-plaintext highlighter-rouge\">kexec -e</code> straight into the new system. No reboot, no flash\nwrite, no jumper.</p>\n\n<p>This is also where Stage 2 of the original plan quietly dissolved: the\n“image server” is no longer special boot infrastructure the board depends\non, just any HTTP endpoint the updater can reach. Fetching over HTTPS\nsurfaced one genuinely obscure board fact about the RTC, which is detour 8.</p>\n\n<h3 id=\"the-new-flash-layout\">The new flash layout</h3>\n\n<p>The Stage 1 layout reserved 13 MiB for a 1 MiB U-Boot; that got tightened,\nand the FIT gets the rest of the chip:</p>\n\n<table>\n  <thead>\n    <tr>\n      <th>Offset</th>\n      <th>Content</th>\n      <th>Slot</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">0x000000</code></td>\n      <td><code class=\"language-plaintext highlighter-rouge\">boot.bin</code></td>\n      <td>1 MiB</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">0x100000</code></td>\n      <td><code class=\"language-plaintext highlighter-rouge\">u-boot.img</code></td>\n      <td>2 MiB</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">0x300000</code></td>\n      <td>environment + redundant copy</td>\n      <td>256 KiB</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">0x340000</code></td>\n      <td><strong>FIT</strong></td>\n      <td><strong>28.75 MiB</strong></td>\n    </tr>\n  </tbody>\n</table>\n\n<p><code class=\"language-plaintext highlighter-rouge\">bootcmd</code> went into the binary rather than the environment:</p>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>CONFIG_BOOTCOMMAND=\"sf probe 0 30000000 0; sf read 0x2000000 0x340000 0x1000000; bootm 0x2000000\"\n</code></pre></div></div>\n\n<p>Two reasons, both learned the hard way: <code class=\"language-plaintext highlighter-rouge\">saveenv</code> simply does not work in\nJTAG boot mode (U-Boot reports <code class=\"language-plaintext highlighter-rouge\">ENVL_NOWHERE</code> there), and long <code class=\"language-plaintext highlighter-rouge\">setenv</code>\ncommands break over the netconsole, where a swallowed quote character leaves\nthe parser waiting for a continuation line and eating everything that\nfollows. The read covers 16 MiB rather than the exact image size so a\ngrowing maintenance system fits later without a U-Boot rebuild.</p>\n\n<h2 id=\"step-5-flash-it-and-read-back-more-than-one-offset\">Step 5: Flash it, and read back more than one offset</h2>\n\n<p>The flashing itself is the Stage 1 recipe: TFTP the FIT into DDR, <code class=\"language-plaintext highlighter-rouge\">sf\nerase</code>, <code class=\"language-plaintext highlighter-rouge\">sf write</code>, read back, compare MD5. It passed. And the board did not\nboot, because of the single nastiest failure of this whole project, the BAR\naddress-mode bug of detour 7, whose defining property is that <strong>write and\nread-back were self-consistent and both wrong</strong>. Every MD5 check passed\nwhile every byte sat one position off in the physical array.</p>\n\n<p><strong>Checkpoint 4</strong> therefore has two parts, and the second exists purely\nbecause of that detour: the read-back MD5 of the flashed region must match\nthe host, <strong>and</strong> reading the first words at <code class=\"language-plaintext highlighter-rouge\">0</code>, <code class=\"language-plaintext highlighter-rouge\">0x100000</code> and <code class=\"language-plaintext highlighter-rouge\">0x340000</code>\nmust return <em>different</em> data at each offset. If several distant offsets read\nidentically, the flash addressing is broken, not the content, and no\nchecksum will ever tell you.</p>\n\n<p><strong>Checkpoint 5: the board boots Linux entirely on its own.</strong> JTAG cable\noff, J13 on QSPI, power cycle. The chain is now BootROM → SPL → U-Boot →\nFIT from flash → Linux, and the test is the same as checkpoint 2: a lease\nfor <code class=\"language-plaintext highlighter-rouge\">ax7020</code> within seconds, and port 22 answering. On the company switch\nthis now works, because by the time spanning tree releases the port, the\none who retries is the Linux kernel, not a bootloader in a polling loop.</p>\n\n<h2 id=\"sidebar-finding-the-board-again-and-a-dhcp-inventory-api\">Sidebar: finding the board again, and a DHCP inventory API</h2>\n\n<p>With the board destined for the company network, “which IP does it have\ntoday” needed a real answer. That turned into a small service of its own: a\nREST API that reports every device the DHCP server knows about as JSON,\nreading the server’s <em>state</em> (config reservations plus live leases) instead\nof scanning the subnet, so it also finds devices that are currently off. It\nis public and generic at\n<a href=\"https://github.com/MaxClerkwell/dhcp-inventory-api\">github.com/MaxClerkwell/dhcp-inventory-api</a>.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>curl <span class=\"nt\">-s</span> http://10.42.0.1:8000/get_all_network_clients | jq\n</code></pre></div></div>\n\n<p>Deploying it in a container on the actual router surfaced three details\nthat are anything but cosmetic, and each failed <em>silently</em>:</p>\n\n<ul>\n  <li><strong><code class=\"language-plaintext highlighter-rouge\">--network host</code> is mandatory.</strong> <code class=\"language-plaintext highlighter-rouge\">/proc/net/arp</code> is per network\nnamespace. A bind-mount of the file shows the container’s three\nneighbours, not the router’s 38, and the endpoint keeps answering\ncheerfully, just nearly empty.</li>\n  <li><strong>Mount directories, not files.</strong> ISC dhcpd rewrites its lease database\nvia a temp file plus <code class=\"language-plaintext highlighter-rouge\">rename</code>. A bind-mount of the file pins the old\ninode, and the container serves a snapshot frozen at container start.\nThis was only noticed because the freshly connected AX7020 had a lease\nand did not appear in the list.</li>\n  <li><strong>Pin the listener address.</strong> With host networking the port would\notherwise be open on every interface of the router, including the\nuplinks. <code class=\"language-plaintext highlighter-rouge\">ss -lntp</code> must show exactly one bound address.</li>\n</ul>\n\n<p>There was also a ghost device named <code class=\"language-plaintext highlighter-rouge\">fantasia</code>: Debian’s commented-out\nexample block in <code class=\"language-plaintext highlighter-rouge\">dhcpd.conf</code>, faithfully parsed. The parser now strips\ncomments and only accepts a <code class=\"language-plaintext highlighter-rouge\">fixed-address</code> that parses as an address.</p>\n\n<p><strong>Checkpoint 6: the board is discoverable.</strong> The API must list <code class=\"language-plaintext highlighter-rouge\">ax7020</code>\nwith its fixed MAC and current address, and SSH must work through the\noffice switch.</p>\n\n<h2 id=\"step-6-updating-the-flash-without-jtag\">Step 6: Updating the flash without JTAG</h2>\n\n<p>Once Linux runs, the jumper dance is over for good. The MTD partitions from\nthe device tree are visible, and <code class=\"language-plaintext highlighter-rouge\">/dev/mtdblockN</code> handles erasure itself:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"c\"># file onto the board: scp fails for lack of an sftp-server in the minimal image</span>\nssh root@ax7020 <span class=\"s1\">'cat &gt; /tmp/fitImage'</span> &lt; tftp/fitImage\n\n<span class=\"c\"># write and verify</span>\nssh root@ax7020 <span class=\"s1\">'\n  dd if=/tmp/fitImage of=/dev/mtdblock3 bs=64k conv=fsync\n  sync\n  dd if=/dev/mtdblock3 bs=1 count=&lt;size&gt; 2&gt;/dev/null | md5sum'</span>\n</code></pre></div></div>\n\n<p>About ten minutes at QSPI pace, then <code class=\"language-plaintext highlighter-rouge\">reboot</code>. The maintenance system\nupdates itself over SSH; JTAG is now strictly a rescue tool.</p>\n\n<h2 id=\"step-7-ssh-with-keys-only\">Step 7: SSH with keys only</h2>\n\n<p>A passwordless root login is fine on a point-to-point lab cable and\nindefensible on a shared network. Two small recipes fix it: one installs\n<code class=\"language-plaintext highlighter-rouge\">authorized_keys</code> for root, one configures dropbear with <code class=\"language-plaintext highlighter-rouge\">-s</code> (disable\npassword authentication).</p>\n\n<p>One warning that will lock somebody out: poky’s shipped <code class=\"language-plaintext highlighter-rouge\">dropbear.default</code>\nsets <code class=\"language-plaintext highlighter-rouge\">-w</code>, which forbids root logins <em>entirely</em>, keys included; until now\nonly the <code class=\"language-plaintext highlighter-rouge\">debug-tweaks</code> image feature was overriding that. Remove\n<code class=\"language-plaintext highlighter-rouge\">debug-tweaks</code> (as you must, it is what makes root passwordless) and leave\nthe rest as-is, and you have locked yourself out of the board. The correct\nconfiguration is <code class=\"language-plaintext highlighter-rouge\">-s</code> alone, with <code class=\"language-plaintext highlighter-rouge\">*</code> in root’s password field.</p>\n\n<p><strong>Checkpoint 7, verified on the running board:</strong></p>\n\n<table>\n  <thead>\n    <tr>\n      <th>Test</th>\n      <th>Result</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>login with the deployed key</td>\n      <td>✅</td>\n    </tr>\n    <tr>\n      <td>login with a different key</td>\n      <td>❌ rejected</td>\n    </tr>\n    <tr>\n      <td>login without a key</td>\n      <td>❌ <code class=\"language-plaintext highlighter-rouge\">Permission denied (publickey)</code></td>\n    </tr>\n    <tr>\n      <td>login with a password</td>\n      <td>❌ <code class=\"language-plaintext highlighter-rouge\">Permission denied (publickey)</code></td>\n    </tr>\n  </tbody>\n</table>\n\n<p><img src=\"/assets/posts/alinx-ax7020-yocto-linux-qspi-august-2026/ssh-login-ax7020.png\" alt=\"Key-only SSH login on the AX7020\" /></p>\n\n<p>Stage 3 complete. BootROM, SPL, U-Boot, FIT, Linux: everything from flash,\non the company network, reachable only with the right key, with a tested\npath to update itself over SSH and a built (though not yet fired) <code class=\"language-plaintext highlighter-rouge\">kexec</code>\npath for development images.</p>\n\n<hr />\n\n<h2 id=\"the-nine-detours\">The nine detours</h2>\n\n<p>Same rule as last time: the walkthrough above reads like a tidy evening, and\nit was not. Chronologically, because several only make sense in sequence.</p>\n\n<h3 id=\"1-unpackdir-does-not-exist-in-scarthgap\">1. <code class=\"language-plaintext highlighter-rouge\">${UNPACKDIR}</code> does not exist in scarthgap</h3>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>| install: cannot stat '/zynq-ax7020.dts': No such file or directory\nERROR: linux-xlnx do_configure failed\n</code></pre></div></div>\n\n<p>The variable arrived with Yocto 5.1; in scarthgap, <code class=\"language-plaintext highlighter-rouge\">SRC_URI</code> files land in\n<code class=\"language-plaintext highlighter-rouge\">${WORKDIR}</code>. Copy a <code class=\"language-plaintext highlighter-rouge\">do_configure</code> snippet from current documentation into\nan LTS build and the path expands to nothing, and the error message shows\nthe <em>result</em> of the empty expansion, not the variable that caused it.</p>\n\n<p><strong>Fix, as it ended up:</strong> the bbappend probes both locations:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nv\">src</span><span class=\"o\">=</span><span class=\"s2\">\"</span><span class=\"k\">${</span><span class=\"nv\">UNPACKDIR</span><span class=\"k\">}</span><span class=\"s2\">/zynq-ax7020.dts\"</span>\n<span class=\"o\">[</span> <span class=\"nt\">-f</span> <span class=\"s2\">\"</span><span class=\"nv\">$src</span><span class=\"s2\">\"</span> <span class=\"o\">]</span> <span class=\"o\">||</span> <span class=\"nv\">src</span><span class=\"o\">=</span><span class=\"s2\">\"</span><span class=\"k\">${</span><span class=\"nv\">WORKDIR</span><span class=\"k\">}</span><span class=\"s2\">/zynq-ax7020.dts\"</span>\n</code></pre></div></div>\n\n<h3 id=\"2-bootgen-native-will-not-build-with-gcc-15\">2. <code class=\"language-plaintext highlighter-rouge\">bootgen-native</code> will not build with GCC 15</h3>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>utils/src/cdo-load.c:140:14: error: assignment to 'char *' from incompatible\npointer type 'uint32_t *' [-Wincompatible-pointer-types]\n</code></pre></div></div>\n\n<p>Since GCC 14 this is an error, not a warning. The irony: this build does not\nneed bootgen at all, <code class=\"language-plaintext highlighter-rouge\">boot.bin</code> comes from the mainline SPL, but meta-xilinx\npulls it into the dependency chain regardless.</p>\n\n<p><strong>Fix, as it ended up:</strong> a bbappend that defuses exactly this diagnostic\nwith <code class=\"language-plaintext highlighter-rouge\">-Wno-incompatible-pointer-types</code>. Not pretty, but it patches a tool\nwhose output is thrown away.</p>\n\n<h3 id=\"3-an-initramfs-recipe-needs-an-empty-image_name_suffix\">3. An initramfs recipe needs an empty <code class=\"language-plaintext highlighter-rouge\">IMAGE_NAME_SUFFIX</code></h3>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>ERROR: Could not find a valid initramfs type for ax7020-initramfs-ax7020,\nthe supported types are: cpio.lz4 cpio.lzo ... cpio.gz ...\n</code></pre></div></div>\n\n<p>scarthgap appends <code class=\"language-plaintext highlighter-rouge\">.rootfs</code> to image names; <code class=\"language-plaintext highlighter-rouge\">kernel-fitimage</code> looks for the\nname without it. The supported types listed in the error are a red herring:\nthe type was fine, the <em>filename</em> was not.</p>\n\n<p><strong>Fix, as it ended up:</strong> <code class=\"language-plaintext highlighter-rouge\">IMAGE_NAME_SUFFIX ?= \"\"</code> in the image recipe, the\nsame line poky’s own initramfs recipes carry for exactly this reason.</p>\n\n<h3 id=\"4-netconsole-as-a-kernel-parameter-stays-silent\">4. <code class=\"language-plaintext highlighter-rouge\">netconsole=</code> as a kernel parameter stays silent</h3>\n\n<p>The obvious way to keep the cable-free console into Linux,\n<code class=\"language-plaintext highlighter-rouge\">netconsole=6666@/eth0,6666@192.168.77.1/</code>, produced nothing. That early in\nboot, the module cannot do ARP resolution; without the target MAC spelled\nout in the parameter, it sends to nobody, and it does so without a single\ncomplaint in <code class=\"language-plaintext highlighter-rouge\">dmesg</code> that would point at the cause.</p>\n\n<p><strong>Fix, as it ended up:</strong> accepted rather than fought. Linux gets a real\nconsole over SSH minutes later anyway; the netconsole matters for U-Boot,\nwhere it stays. Filed under “the boot parameter needs the full MAC syntax\nif it is ever needed.”</p>\n\n<h3 id=\"5-ping-proves-the-wrong-thing\">5. Ping proves the wrong thing</h3>\n\n<p>During the first RAM boots, “the board answers ping” repeatedly stood in\nfor “Linux is up”, and it repeatedly lied: U-Boot answers ICMP too, from\nits netconsole loop, with the same IP the kernel would have. When a boot\nhung, ping kept succeeding and the diagnosis ran in circles.</p>\n\n<p><strong>Fix, as it ended up:</strong> a procedural rule that made it into the\ncheckpoints: liveness for Linux is <strong>port 22</strong>, never ICMP. Checkpoints 2\nand 5 both encode it.</p>\n\n<h3 id=\"6-the-spanning-tree-livelock-u-boot-goes-mute-on-a-real-switch\">6. The spanning-tree livelock: U-Boot goes mute on a real switch</h3>\n\n<p>The one that changed the architecture. On the company switch, the DHCP\nhandshake completed cleanly, two seconds of netconsole output arrived, then\nnothing: no ARP, no ping, no console. Ever. The router had no <code class=\"language-plaintext highlighter-rouge\">tcpdump</code>, so\na raw <code class=\"language-plaintext highlighter-rouge\">AF_PACKET</code> capture in Python had to stand in, and it showed the\nboard sending its last packet at <code class=\"language-plaintext highlighter-rouge\">15:14:01</code> and then falling silent.</p>\n\n<p>The cause sits in plain sight in the source. Every netconsole poll calls\n<code class=\"language-plaintext highlighter-rouge\">net_loop()</code>, which re-initialises the Ethernet device; <code class=\"language-plaintext highlighter-rouge\">zynq_gem_init()</code>\ncalls <code class=\"language-plaintext highlighter-rouge\">phy_startup()</code>, which fails while the link is down. U-Boot even has\na provision to skip re-init when the last protocol was netconsole, but\nafter <code class=\"language-plaintext highlighter-rouge\">dhcp</code> the last protocol is BOOTP, so the very first console poll\nneeds a full init. On a managed switch that init tears the link down while\nthe port is still in spanning-tree listening state, the switch restarts its\ntimers on the new link, and the two sides livelock: the port never\nforwards, U-Boot never gets a link. On the lab’s directly attached USB\nadapter this can never happen, because autonegotiation there completes in\nmilliseconds with nobody holding the port back.</p>\n\n<p><strong>Fix, as it ended up:</strong> not a patch, a design change. A bootloader is not\na network citizen; the thing that lives on the network must be Linux. That\nsingle realisation produced the flash-resident maintenance system of\nStep 4, which is a better architecture anyway.</p>\n\n<h3 id=\"7-the-bar-disaster-every-checksum-passes-every-byte-is-wrong\">7. The BAR disaster: every checksum passes, every byte is wrong</h3>\n\n<p>After flashing the FIT, the BootROM parked at <code class=\"language-plaintext highlighter-rouge\">0xffffff28</code> with the boot\nmode correctly reading QSPI, exactly as if the flash were empty, although\nall three regions had verified by MD5 immediately after writing. On the\nnext JTAG boot, offset 0 contained text data and <code class=\"language-plaintext highlighter-rouge\">0x340000</code> zeros. A full\nerase read back <code class=\"language-plaintext highlighter-rouge\">0xFF</code> everywhere, so the chip was healthy.</p>\n\n<p>The tell came from comparing several offsets:</p>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>sf read ... 0        -&gt; fffffe00 fffffeea fffffeea\nsf read ... 0x100000 -&gt; fffffe00 fffffeea fffffeea      identical!\nsf read ... 0x340000 -&gt; fffffe00 fffffeea fffffeea      identical!\n</code></pre></div></div>\n\n<p>The same data at every address, shifted by one byte, and <code class=\"language-plaintext highlighter-rouge\">eafffffe</code> is the\nstart of <code class=\"language-plaintext highlighter-rouge\">boot.bin</code>. Cause: <code class=\"language-plaintext highlighter-rouge\">CONFIG_SPI_FLASH_BAR</code>. The 32 MiB W25Q256 runs\nin 4-byte address mode; with BAR enabled, U-Boot sends only three address\nbytes, the chip consumes the dummy byte as the fourth address byte, the\naddress overflows and lands near zero, and all data arrives one byte\nshifted. The vicious part: writes and reads went through the <em>same</em> broken\naddressing, so they were mutually consistent. Every verification passed,\npatiently confirming a wrong thing, while the BootROM, which correctly\nspeaks three-byte addressing on its own, found nothing where <code class=\"language-plaintext highlighter-rouge\">boot.bin</code>\nwas supposed to be.</p>\n\n<p><strong>Fix, as it ended up:</strong> <code class=\"language-plaintext highlighter-rouge\">./scripts/config --disable SPI_FLASH_BAR</code>,\nrebuild; spi-nor then uses native 4-byte opcodes, and the same flash\nprocedure immediately produced a booting board. Plus the second half of\ncheckpoint 4: always read back <em>several distant offsets</em> and require them\nto differ. That check costs seconds and is the only one in this list that\ncatches an addressing bug, because checksums by construction cannot.</p>\n\n<h3 id=\"8-the-rtc-is-unreachable-without-a-bitstream\">8. The RTC is unreachable without a bitstream</h3>\n\n<p>The updater fetches images over HTTPS, and <code class=\"language-plaintext highlighter-rouge\">curl</code> rejected every\ncertificate as “not yet valid”: the clock stood at the epoch. Setting the\ntime with NTP would be routine, except for why there was no hardware clock\nin the first place:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">grep</span> <span class=\"nt\">-o</span> <span class=\"s1\">'PCW_I2C[01]_I2C[01]_IO\" VALUE=\"[^\"]*\"'</span> design_1.hwh\n<span class=\"c\"># PCW_I2C0_I2C0_IO\" VALUE=\"EMIO\"</span>\n<span class=\"c\"># PCW_I2C1_I2C1_IO\" VALUE=\"EMIO\"</span>\n</code></pre></div></div>\n\n<p>Both I²C controllers are routed through EMIO, i.e. through the FPGA fabric.\nWithout a loaded bitstream there is physically no path from the processor\nto the RTC. A dependency loop hides here: the pipeline exists to load\nbitstreams over HTTPS, HTTPS needs the time, and the clock sits behind a\nloaded bitstream.</p>\n\n<p><strong>Fix, as it ended up:</strong> the updater runs <code class=\"language-plaintext highlighter-rouge\">ntpd -n -q</code> against a\nconfigurable server before any HTTPS fetch, and dies with a clear message\nif the clock cannot be set. Busybox ships the <code class=\"language-plaintext highlighter-rouge\">ntpd</code> applet disabled; a\none-line config fragment enables it. The board simply has no usable RTC\nuntil a bitstream provides the I²C route, and now the scripts know that.</p>\n\n<h3 id=\"9-dropbears--w-locks-out-root-completely\">9. Dropbear’s <code class=\"language-plaintext highlighter-rouge\">-w</code> locks out root completely</h3>\n\n<p>Hardening SSH nearly bricked the access instead: poky’s default dropbear\narguments include <code class=\"language-plaintext highlighter-rouge\">-w</code>, which rejects <em>all</em> root logins, key or not. The\nonly reason key logins had worked before was the <code class=\"language-plaintext highlighter-rouge\">debug-tweaks</code> image\nfeature quietly overriding it. Removing <code class=\"language-plaintext highlighter-rouge\">debug-tweaks</code>, which is mandatory\nfor anything leaving the lab since it is also what makes root passwordless,\nwould have left <code class=\"language-plaintext highlighter-rouge\">-w</code> in force and the board unreachable, on a system whose\nonly console is SSH.</p>\n\n<p><strong>Fix, as it ended up:</strong> an own <code class=\"language-plaintext highlighter-rouge\">dropbear.default</code> with <code class=\"language-plaintext highlighter-rouge\">-s</code> alone\n(password authentication off, key logins allowed), root’s password field\nset to <code class=\"language-plaintext highlighter-rouge\">*</code>, and the four-row login test matrix of checkpoint 7 run before\nthe old image was overwritten. On a box with no serial console, test the\nnew lock before discarding the old key.</p>\n\n<h2 id=\"closing\">Closing</h2>\n\n<p>The score this time: three build errors, one architecture change forced by\na switch, and one flash bug that defeated every checksum thrown at it. The\nrecurring shape is the same as in Stage 1, but sharpened: the worst\nfailures were not the loud ones, they were the ones where every\nverification <em>passed</em>. The BAR bug confirmed a physically shifted flash\nimage with an unbroken chain of matching MD5s; the container served a\nfrozen lease file and an incomplete ARP table without a single error; ping\ncheerfully vouched for a Linux that was not running. Verification can\npatiently confirm a wrong thing, and the countermeasure is always the same:\ntest the property you actually need (different data at different offsets,\nport 22, the inode inside the container) rather than a proxy for it.</p>\n\n<p>Everything is in\n<a href=\"https://github.com/MaxClerkwell/ax7020-bringup\">github.com/MaxClerkwell/ax7020-bringup</a>:\nthe Yocto layer, the U-Boot identity and console-claim patches, the updater\nrecipes, the dropbear configuration, and this walkthrough in longer form.\nThe DHCP inventory API lives in its own repository,\n<a href=\"https://github.com/MaxClerkwell/dhcp-inventory-api\">dhcp-inventory-api</a>.</p>\n\n<p>Next is Stage 4: a bitstream from the open toolchain, Yosys and\nnextpnr-xilinx, written into <code class=\"language-plaintext highlighter-rouge\">/sys/class/fpga_manager/fpga0/firmware</code> over\nSSH. The FPGA manager is registered and waiting, the board is one <code class=\"language-plaintext highlighter-rouge\">curl</code>\naway, and somewhere behind an EMIO pin there is a real-time clock that\nwould very much like a bitstream to finally exist. That build gets its own\narticle.</p>",
      "date_published": "2026-08-31T00:00:00+00:00",
      
      "date_modified": "2026-08-31T00:00:00+00:00",
      "image": "https://maxclerkwell.tech/assets/posts/alinx-ax7020-yocto-linux-qspi-august-2026/ssh-login-ax7020.png",
      "authors": [ { "name": "Stephan Bökelmann" } ],
      "tags": ["fpga","alinx","zynq","yocto","linux","u-boot","kexec","dropbear","fit-image","qspi","embedded","bring-up"]
    },
    
    {
      "id": "https://maxclerkwell.tech/posts/alinx-bring-up-jtag-detected-without-power-august-2026/",
      "url": "https://maxclerkwell.tech/posts/alinx-bring-up-jtag-detected-without-power-august-2026/",
      "title": "ALINX AX7020 Bring-up: Mainline U-Boot Over JTAG, No FSBL, No Serial Cable",
      "summary": "Stage 1 of the open bitstream pipeline: putting mainline U-Boot on an ALINX AX7020 over JTAG and Ethernet only: a clean walkthrough of what works, followed by the nine detours it took to find out.",
      "content_html": "<p>I thought this would be an afternoon. Load a bootloader over JTAG, poke it a\nbit, write it into flash: how hard can it be? It took the better part of a\nday and nine distinct detours, from cache lines silently corrupting a loaded\nimage to a QSPI controller that reads everything three bytes off, but only\nin SPL, and only in quad mode.</p>\n\n<p>This post is Stage 1 of the\n<a href=\"/posts/zynq-bitstream-deployment-concept-august-2026/\">bitstream pipeline plan</a>:\nan ALINX AX7020 (Zynq-7000, XC7Z020) that powers on and boots <strong>mainline\nU-Boot out of QSPI flash by itself</strong>: no Xilinx FSBL, no vendor U-Boot fork,\nno JTAG adapter attached, and no serial cable ever connected. The console is\nU-Boot’s netconsole over UDP; the whole board is operated from the\nworkstation over Ethernet.</p>\n\n<p>The structure follows how I wish someone had written it for me: first the\nwalkthrough that actually works, reproducible on a clean machine and a clean\nboard. Then, at the end, every detour, because the detours are where the\nlearning was, and because most of them will bite anyone doing this on any\nZynq board.</p>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>U-Boot 2026.10-rc2 (Aug 30 2026 - 20:25:52 +0200)\nmodeboot      = qspiboot\nDRAM size     = 0x40000000        (1 GiB)\nDDR frequency = 533 MHz\nARM frequency = 766 MHz\nSF: Detected w25q256 with page size 256 Bytes, erase size 64 KiB, total 32 MiB\nIP addr       = 192.168.77.77     (DHCP)\n</code></pre></div></div>\n\n<p>That is the end state. Here is the way there.</p>\n\n<h2 id=\"the-seven-checkpoints\">The seven checkpoints</h2>\n\n<p>Every stage of this bring-up ends in something you can <em>test</em>. That is important,\nbecause most failures on this road are silent, and without a defined “this\nmust now be true” you cannot tell which layer broke. The walkthrough is\nbuilt around seven such checkpoints:</p>\n\n<table>\n  <thead>\n    <tr>\n      <th>#</th>\n      <th>After</th>\n      <th>What must be true</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>1</td>\n      <td>OpenOCD scan</td>\n      <td>two TAPs found: Xilinx PL (<code class=\"language-plaintext highlighter-rouge\">0x23727093</code>) and ARM DAP, both cores attached</td>\n    </tr>\n    <tr>\n      <td>2</td>\n      <td>telnet into OpenOCD</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">halt</code> stops the CPU at the BootROM park point; <code class=\"language-plaintext highlighter-rouge\">mdw</code> reads SLCR</td>\n    </tr>\n    <tr>\n      <td>3</td>\n      <td>U-Boot build</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">mkimage -l</code> validates both images; <code class=\"language-plaintext highlighter-rouge\">fdtget</code> finds the model and the <code class=\"language-plaintext highlighter-rouge\">flash@0</code> node in the SPL DTB</td>\n    </tr>\n    <tr>\n      <td>4</td>\n      <td>lab network setup</td>\n      <td>host address, route, DHCP server and TFTP server verifiably up, board not needed</td>\n    </tr>\n    <tr>\n      <td>5</td>\n      <td>JTAG bring-up</td>\n      <td>board takes a DHCP lease, netconsole is interactive, <code class=\"language-plaintext highlighter-rouge\">bdinfo</code>/<code class=\"language-plaintext highlighter-rouge\">sf probe</code>/<code class=\"language-plaintext highlighter-rouge\">mii info</code> answer correctly</td>\n    </tr>\n    <tr>\n      <td>6</td>\n      <td>flashing</td>\n      <td>read-back MD5 of both flash regions equals the host’s <code class=\"language-plaintext highlighter-rouge\">md5sum</code></td>\n    </tr>\n    <tr>\n      <td>7</td>\n      <td>standalone boot</td>\n      <td>after a power cycle with J13 on QSPI: lease within ~5 s, banner shows <code class=\"language-plaintext highlighter-rouge\">modeboot = qspiboot</code>, console interactive</td>\n    </tr>\n  </tbody>\n</table>\n\n<p>If a checkpoint fails, everything before it is known-good. That is the\nwhole point. Every self-written file that appears below (OpenOCD configs,\ndevice tree, defconfig, host tools) is available at\n<a href=\"https://github.com/MaxClerkwell/ax7020-bringup\">github.com/MaxClerkwell/ax7020-bringup</a>.\nNow the walkthrough.</p>\n\n<h2 id=\"the-hardware\">The hardware</h2>\n\n<table>\n  <thead>\n    <tr>\n      <th> </th>\n      <th> </th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>Board</td>\n      <td>ALINX AX7020, XC7Z020-2CLG400, JTAG IDCODE <code class=\"language-plaintext highlighter-rouge\">0x23727093</code></td>\n    </tr>\n    <tr>\n      <td>DDR3</td>\n      <td>2× MT41J256M16, 32-bit bus, 533 MHz → 1 GiB</td>\n    </tr>\n    <tr>\n      <td>QSPI</td>\n      <td>Winbond W25Q256, 32 MiB, single chip select, MIO 1..6</td>\n    </tr>\n    <tr>\n      <td>UART</td>\n      <td>PS UART1 on MIO 48/49 via CP2102 (<strong>not used</strong>, no serial console)</td>\n    </tr>\n    <tr>\n      <td>Ethernet</td>\n      <td>PS GEM0, RGMII on MIO 16..27, MDIO on MIO 52/53, RTL8211E-VL at PHY address 1</td>\n    </tr>\n    <tr>\n      <td>SD</td>\n      <td>MIO 40..45, card detect MIO 47</td>\n    </tr>\n    <tr>\n      <td>USB</td>\n      <td>ULPI on MIO 28..39, reset MIO 46 (active low)</td>\n    </tr>\n    <tr>\n      <td>Boot jumper</td>\n      <td><strong>J13</strong>: left pins = SD, middle = QSPI, right = JTAG</td>\n    </tr>\n  </tbody>\n</table>\n\n<p>The on-board JTAG adapter is a bare FTDI FT232H (<code class=\"language-plaintext highlighter-rouge\">0403:6014</code>) wired to the\nfour MPSSE pins only. First curiosity of the day: it enumerates on USB\nregardless of whether the board’s power switch is on: the FT232H hangs on\nUSB VBUS, not on the board supply. That is a design choice, and the cheaper\nof two common ones. Standalone FTDI dongles and low-cost dev boards power\nthe adapter from the host; Digilent and the Xilinx eval boards instead feed\ntheir on-board FTDI from the board supply (or put it behind a level shifter\nreferenced to the target voltage), so the adapter only appears once the\nboard is up. Professional external adapters go further still and sense\nVTREF before driving anything. The bus-powered variant has a wrinkle beyond\nthe confusing enumeration: it drives TCK/TDI/TMS into an <em>unpowered</em> Zynq,\nwhose ESD protection diodes clamp those levels onto the dead supply rail,\nweakly back-powering the switched-off board. At an FTDI pin’s few mA this\nis usually harmless, but tidy design it is not. The practical lesson holds\nfor any bus-powered adapter, which is most of the cheap ones: <code class=\"language-plaintext highlighter-rouge\">lsusb</code>\nshowing the adapter proves the cable, nothing more; whether a live JTAG\nchain sits behind it is a separate question that a scan answers, not\nenumeration.</p>\n\n<p>Host tools: <code class=\"language-plaintext highlighter-rouge\">arm-none-eabi-gcc</code> 15.2, OpenOCD 0.12.0, Python 3.13. No Vivado,\nno Vitis, no Xilinx tool of any kind.</p>\n\n<p>Side note on the physical link: the Ethernet connection runs through the M12\nadapter from <a href=\"https://auto-intern.de\">Auto-Intern GmbH</a>, which I\nco-developed; the cable comes from Dongguan Guanghui Electronic Technology\n(GH Electronic). In March I visited their factory and worked out our\nEthernet cable specifications with their engineers; the same cables now\nship with Auto-Intern’s new <a href=\"https://edge-compute.skainet.io/\">skAInet Edge-Compute</a>. Industrial Ethernet cabling (M12\nX-coded, shielding, IP rating, EMC and the certification paperwork that\ngoes with it) is a topic I enjoy, and I am happy to help teams in the EU and\nthe USA specify and certify correct cables. <a href=\"/about/\">Get in touch</a> if that\nis a problem you have.</p>\n\n<style>\n.post-photo-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:14px; margin:1.2em 0; }\n.post-photo-grid figure { margin:0; }\n.post-photo-grid img { width:100%; height:220px; object-fit:cover; border-radius:8px; display:block; cursor:zoom-in; }\n.post-photo-grid img.pos-top { object-position:center 20%; }\n.post-photo-grid figcaption { font-size:0.82em; opacity:0.75; margin-top:4px; line-height:1.35; }\n#post-lightbox { position:fixed; inset:0; background:rgba(0,0,0,0.85); display:none; align-items:center; justify-content:center; z-index:1000; cursor:zoom-out; padding:2vh 2vw; }\n#post-lightbox.open { display:flex; }\n#post-lightbox img { max-width:96vw; max-height:96vh; width:auto; height:auto; border-radius:6px; }\n</style>\n\n<div class=\"post-photo-grid\">\n  <figure>\n    <img src=\"/assets/posts/alinx-bring-up-jtag-detected-without-power-august-2026/m12-adapter.jpg\" alt=\"Auto-Intern M12 Ethernet adapter connected to the ALINX AX7020\" loading=\"lazy\" />\n    <figcaption>The Auto-Intern M12 adapter and GH Electronic cable on the AX7020.</figcaption>\n  </figure>\n  <figure>\n    <img src=\"/assets/posts/alinx-bring-up-jtag-detected-without-power-august-2026/gh-factory-visit.jpg\" alt=\"Visiting the GH Electronic team in Dongguan\" class=\"pos-top\" loading=\"lazy\" />\n    <figcaption>March 2026: visiting the GH Electronic team in Dongguan.</figcaption>\n  </figure>\n  <figure>\n    <img src=\"/assets/posts/alinx-bring-up-jtag-detected-without-power-august-2026/gh-production.jpg\" alt=\"Cable production floor at GH Electronic\" loading=\"lazy\" />\n    <figcaption>The production floor where our Ethernet cables are made.</figcaption>\n  </figure>\n  <figure>\n    <img src=\"/assets/posts/alinx-bring-up-jtag-detected-without-power-august-2026/emc-lab-test.jpg\" alt=\"EMC lab test of the jointly specified cables with Auto-Intern's new edge-compute hardware\" loading=\"lazy\" />\n    <figcaption>Testing the jointly specified cables in the EMC lab with Auto-Intern's new <a href=\"https://edge-compute.skainet.io/\">skAInet Edge-Compute</a>.</figcaption>\n  </figure>\n</div>\n<div id=\"post-lightbox\"><img alt=\"\" /></div>\n<script>\n(function(){\n  var lb=document.getElementById('post-lightbox'), lbimg=lb.querySelector('img');\n  document.querySelectorAll('.post-photo-grid img').forEach(function(im){\n    im.addEventListener('click',function(){ lbimg.src=im.src; lbimg.alt=im.alt; lb.classList.add('open'); });\n  });\n  lb.addEventListener('click',function(){ lb.classList.remove('open'); lbimg.src=''; });\n  document.addEventListener('keydown',function(e){ if(e.key==='Escape') lb.classList.remove('open'); });\n})();\n</script>\n\n<h2 id=\"step-1-talk-to-the-chip-with-openocd\">Step 1: Talk to the chip with OpenOCD</h2>\n\n<p>OpenOCD is a translator. On one side it talks to a USB adapter, on the other\nside to a debug port on a chip, and it knows nothing about either until you\ntell it. That is why you always hand it two files: one describing the\nadapter, one describing the target. The target file is usually someone\nelse’s problem: <code class=\"language-plaintext highlighter-rouge\">target/zynq_7000.cfg</code> ships with OpenOCD and describes the\nwhole chain: a Xilinx TAP for the programmable logic, an ARM DAP for the\nprocessing system, two Cortex-A9 cores. The adapter file is yours; nobody\nupstream knows what the vendor wired up. Running only the target file fails\nwith <code class=\"language-plaintext highlighter-rouge\">Error: Debug adapter does not support any transports?</code>.</p>\n\n<p>Three questions for an unknown board, in order:</p>\n\n<ol>\n  <li><strong>Which chip drives the USB side?</strong> <code class=\"language-plaintext highlighter-rouge\">lsusb</code>. <code class=\"language-plaintext highlighter-rouge\">0403:6014</code> = FT232H →\nOpenOCD’s <code class=\"language-plaintext highlighter-rouge\">ftdi</code> driver (MPSSE). <code class=\"language-plaintext highlighter-rouge\">1366:xxxx</code> would be J-Link,\n<code class=\"language-plaintext highlighter-rouge\">0d28:0204</code> CMSIS-DAP: a different driver, not a different pin table.</li>\n  <li><strong>Which pins carry TCK/TDI/TDO/TMS?</strong> On FTDI fixed in silicon:\nADBUS0–3. Anything beyond (reset, buffer enable) has to come from the\nschematic (for the AX7020 I could reconstruct this from the ALINX\ndatasheets/schematic) or from the test: configure only the four\nmandatory pins and see whether the chain answers. It did, so nothing\nelse is required here.</li>\n  <li><strong>Anything between FTDI and chip?</strong> A buffer or mux shows up as all-ones\nor all-zeros in the scan. Not on this board.</li>\n</ol>\n\n<p>That yields <code class=\"language-plaintext highlighter-rouge\">openocd/ft232h.cfg</code>, complete:</p>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>adapter driver ftdi\nftdi vid_pid 0x0403 0x6014\nftdi layout_init 0x0008 0x000b    # TMS idles high; TCK/TDI/TMS out, TDO in\nadapter speed 1000\ntransport select jtag\n</code></pre></div></div>\n\n<p><code class=\"language-plaintext highlighter-rouge\">layout_init</code> is the working line: low byte = idle levels (<code class=\"language-plaintext highlighter-rouge\">0x0008</code>: TMS\nhigh, keeping the TAP in reset until OpenOCD takes over), high byte =\ndirections (<code class=\"language-plaintext highlighter-rouge\">0x000b</code>: bits 0, 1, 3 out; TDO in). Every untouched pin stays\nan input.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>openocd <span class=\"nt\">-f</span> openocd/ft232h.cfg <span class=\"nt\">-f</span> target/zynq_7000.cfg\n</code></pre></div></div>\n\n<p>Success, with the board powered and J13 on JTAG, is <strong>checkpoint 1</strong>:</p>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>Info : JTAG tap: zynq_pl.bs tap/device found: 0x23727093 (mfg: 0x049 (Xilinx), part: 0x3727, ver: 0x2)\nInfo : JTAG tap: zynq.cpu tap/device found: 0x4ba00477 (mfg: 0x23b (ARM Ltd), part: 0xba00, ver: 0x4)\nInfo : zynq.cpu0: hardware has 6 breakpoints, 4 watchpoints\nInfo : zynq.cpu1: hardware has 6 breakpoints, 4 watchpoints\nInfo : Listening on port 4444 for telnet connections\n</code></pre></div></div>\n\n<p>Part <code class=\"language-plaintext highlighter-rouge\">0x3727</code> is an XC7Z020, the DAP is reachable, both A9s attached. Note\nthat OpenOCD is now a <em>server</em>, not a prompt: the FT232H is the interface,\nJTAG the protocol, OpenOCD the daemon that owns the adapter and translates\nwhatever a client asks (telnet on 4444, gdb on 3333, Tcl on 6666) into\nJTAG transactions. From the client side you never see TAPs or scan chains,\nonly a chip with registers and memory.</p>\n\n<p>Do this telnet test once in any case; it is <strong>checkpoint 2</strong> of the\nbring-up (checkpoint 1 being the two TAPs in the scan). Since OpenOCD\noccupies its terminal as a server, you need a second terminal for it:</p>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>$ telnet localhost 4444\n&gt; halt\ntarget halted in ARM state due to debug-request, current mode: Supervisor\ncpsr: 0x000001d3 pc: 0xffffff34\nMMU: disabled, D-Cache: disabled, I-Cache: disabled\n&gt; mdw 0xF8000000\n0xf8000000: 00000000\n</code></pre></div></div>\n\n<p>PC <code class=\"language-plaintext highlighter-rouge\">0xffffff34</code> is inside the BootROM, where a Zynq with the jumper on JTAG\nparks, waiting for exactly this. <code class=\"language-plaintext highlighter-rouge\">mdw</code> on the SLCR block proves memory-mapped\naccess through the DAP.</p>\n\n<p>One caveat before moving on: <strong>JTAG sees the chip, not the board.</strong> IDCODE,\ndevice DNA, SLCR state (including the boot-mode pins at <code class=\"language-plaintext highlighter-rouge\">0xF800025C</code>): all\nreadable. DDR population, MIO wiring, flash type: not. The DDR controller\ncannot even be probed before it is initialised, and initialising it needs\ntiming values only the schematic knows. Boards from Xilinx or Digilent carry\nan I²C EEPROM with a board ID; the AX7020 has none. The board model stays\nmanual work.</p>\n\n<h2 id=\"step-2-gather-the-board-facts\">Step 2: Gather the board facts</h2>\n\n<p>Mainline U-Boot (v2026.10-rc2, commit <code class=\"language-plaintext highlighter-rouge\">527115ef6783</code>) has <strong>no</strong> AX7020\nsupport: no DTS, no defconfig, no board directory. Everything has to come\nfrom the vendor package (<code class=\"language-plaintext highlighter-rouge\">AX7020_2023.1</code>, a 2.2 GB download kept only as a\nsource of facts):</p>\n\n<ul>\n  <li><code class=\"language-plaintext highlighter-rouge\">course_s4_linux/linux_base/Vitis/design_1_wrapper.xsa</code> →\n<code class=\"language-plaintext highlighter-rouge\">ps7_init_gpl.c/.h</code>. This is the PS initialisation Vivado generates\n(PLLs, MIO muxing, DDR timing) and the one thing that genuinely cannot be\nwritten by hand. Using it directly is the “Vivado init without the Vivado\nFSBL” approach.</li>\n  <li><code class=\"language-plaintext highlighter-rouge\">design_1.hwh</code> inside the same XSA: the Vivado PS configuration as XML,\nevery setting a <code class=\"language-plaintext highlighter-rouge\">PCW_*</code> parameter. Source for nearly every device-tree\nvalue.</li>\n  <li>The schematic PDF (via <code class=\"language-plaintext highlighter-rouge\">pdftotext</code>) for what the HWH cannot say: which\npins go where on the PCB.</li>\n  <li>ALINX’s own <code class=\"language-plaintext highlighter-rouge\">system-user.dtsi</code> from <code class=\"language-plaintext highlighter-rouge\">course_s6</code> as a third witness; it\nsettled the one value the other two sources cannot give (the PHY address,\nset by strapping resistors).</li>\n</ul>\n\n<h2 id=\"step-3-four-files-into-the-u-boot-tree\">Step 3: Four files into the U-Boot tree</h2>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>arch/arm/dts/zynq-ax7020.dts                    (plus a Makefile entry)\nconfigs/alinx_ax7020_defconfig\nboard/xilinx/zynq/zynq-ax7020/ps7_init_gpl.c\nboard/xilinx/zynq/zynq-ax7020/ps7_init_gpl.h\n</code></pre></div></div>\n\n<p>U-Boot picks the <code class=\"language-plaintext highlighter-rouge\">ps7_init</code> up automatically: the directory name matches\n<code class=\"language-plaintext highlighter-rouge\">CONFIG_DEFAULT_DEVICE_TREE</code> while <code class=\"language-plaintext highlighter-rouge\">CONFIG_XILINX_PS_INIT_FILE</code> is empty.\nFive K&amp;R declarations (<code class=\"language-plaintext highlighter-rouge\">int ps7_init();</code> et al.) need <code class=\"language-plaintext highlighter-rouge\">(void)</code>, because GCC 15\nbuilds SPL with <code class=\"language-plaintext highlighter-rouge\">-Werror=strict-prototypes</code>.</p>\n\n<p>The defconfig derives from <code class=\"language-plaintext highlighter-rouge\">xilinx_zynq_virt_defconfig</code>. The full delta,\nevery line of which exists for a reason (the reasons are the detours at the\nend):</p>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>+ CONFIG_DEFAULT_DEVICE_TREE=\"zynq-ax7020\"    (was \"zynq-zc706\")\n+ CONFIG_NET_LEGACY=y                          (replaces CONFIG_NET_LWIP)\n+ CONFIG_NETCONSOLE=y\n+ CONFIG_CONSOLE_MUX=y\n+ CONFIG_SYS_CONSOLE_IS_IN_ENV=y\n+ CONFIG_PREBOOT=\"setenv autoload no; dhcp; setenv stdin serial,nc; \\\n                  setenv stdout serial,nc; setenv stderr serial,nc\"\n+ CONFIG_BOOTCOMMAND=\"echo AX7020 netconsole ready\"\n+ CONFIG_CMD_TFTPPUT=y, CONFIG_TFTP_PORT=y\n+ CONFIG_CMD_MD5SUM=y, CONFIG_CMD_HASH=y, CONFIG_MD5=y\n- CONFIG_SPL_LOAD_FIT, CONFIG_SPL_FIT, CONFIG_SPL_FIT_PRINT\n- CONFIG_SPL_STACK_R\n</code></pre></div></div>\n\n<h3 id=\"why-there-are-two-u-boots-at-all\">Why there are two U-Boots at all</h3>\n\n<p>At power-on the Zynq has exactly two memories code can run from: the BootROM\n(fixed in silicon) and 256 KB of OCM. DDR is dead until <code class=\"language-plaintext highlighter-rouge\">ps7_init</code> has\nbrought up PLLs and run the controller training. Full U-Boot is 1 MB: it does\nnot fit in OCM, must run from DDR. So somebody small enough for OCM has to\nswitch the DDR on first:</p>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>BootROM (in silicon)\n   │  loads ≤ ~190 KB from QSPI/SD/JTAG into OCM, jumps there\n   ▼\nSPL  (u-boot-spl.bin, ~107 KB, runs in OCM)\n   │  runs ps7_init → PLLs, MIO, DDR are up\n   │  loads u-boot.img from QSPI into DDR\n   ▼\nU-Boot proper  (u-boot.bin, ~1 MB, runs in DDR)\n   ▼\nLinux (later)\n</code></pre></div></div>\n\n<p>SPL, the Secondary Program Loader, is not a second program but the same U-Boot\nsource, cut down via <code class=\"language-plaintext highlighter-rouge\">CONFIG_SPL_BUILD</code> to board init, one boot-medium\ndriver, one loader. No shell, no network, no console to talk to. Xilinx\ncalls this stage FSBL and has Vitis generate it; U-Boot’s SPL is the same\nconcept, open, from one tree.</p>\n\n<h3 id=\"the-device-tree-and-where-each-line-comes-from\">The device tree, and where each line comes from</h3>\n\n<p>U-Boot is driver-model based: which UART, which GEM, which PHY address:\nall of it comes at runtime from a device tree compiled from\n<code class=\"language-plaintext highlighter-rouge\">arch/arm/dts/zynq-ax7020.dts</code> and appended to the binary. SPL gets its own\nheavily trimmed DTB, filtered from the same file by <code class=\"language-plaintext highlighter-rouge\">bootph-*</code> properties.\n(The kernel later needs a separate device tree of its own; U-Boot’s\ndescribes only what U-Boot drives.)</p>\n\n<p>Nothing in the file is guessed:</p>\n\n<pre><code class=\"language-dts\">compatible = \"alinx,zynq-ax7020\", \"xlnx,zynq-7000\";\n</code></pre>\n<p>The second string is what U-Boot’s Zynq drivers match on; the first is free.</p>\n\n<pre><code class=\"language-dts\">memory@0 { reg = &lt;0x0 0x40000000&gt;; };\n</code></pre>\n<p>HWH: <code class=\"language-plaintext highlighter-rouge\">PCW_UIPARAM_DDR_PARTNO = MT41J256M16</code>, 32-bit bus, 16-bit devices →\n2 × 512 MB = 1 GiB. Size only; bringing the DDR up is <code class=\"language-plaintext highlighter-rouge\">ps7_init</code>’s job.</p>\n\n<pre><code class=\"language-dts\">&amp;clkc { ps-clk-frequency = &lt;33333333&gt;; };\n</code></pre>\n<p>HWH: <code class=\"language-plaintext highlighter-rouge\">PCW_CRYSTAL_PERIPHERAL_FREQMHZ = 33.333333</code>. Every derived clock\n(UART baud, QSPI, GEM) is computed from this one number.</p>\n\n<pre><code class=\"language-dts\">aliases { serial0 = &amp;uart1; };\nchosen { stdout-path = \"serial0:115200n8\"; };\n</code></pre>\n<p>HWH: UART0 disabled, UART1 on MIO 48/49; schematic confirms the CP2102\nwiring. Without the alias, <code class=\"language-plaintext highlighter-rouge\">serial0</code> would land on the unpopulated UART0.</p>\n\n<pre><code class=\"language-dts\">&amp;gem0 {\n\tphy-mode = \"rgmii-id\";\n\tethernet_phy: ethernet-phy@1 { reg = &lt;1&gt;; };\n};\n</code></pre>\n<p>HWH: ENET0 on MIO 16..27, MDIO on 52/53. Schematic: RTL8211E-VL. The PHY\naddress comes from strapping resistors and is not reliably readable from\neither source; ALINX’s own <code class=\"language-plaintext highlighter-rouge\">system-user.dtsi</code> settled it at 1. <code class=\"language-plaintext highlighter-rouge\">rgmii-id</code>:\nthe PHY inserts its own clock delays, standard for this chip.</p>\n\n<pre><code class=\"language-dts\">&amp;qspi {\n\tbootph-all;\n\tnum-cs = &lt;1&gt;;\n\tflash@0 {\n\t\tbootph-all;\n\t\tcompatible = \"w25q256\", \"jedec,spi-nor\";\n\t\treg = &lt;0&gt;;\n\t\tspi-rx-bus-width = &lt;1&gt;;\n\t\tspi-max-frequency = &lt;50000000&gt;;\n\t};\n};\n</code></pre>\n<p>HWH: single flash on MIO 1..6, no dual/parallel; the ZC706 template’s\ntwo-chip setup had to go. Two lines here are paid for in blood:\n<code class=\"language-plaintext highlighter-rouge\">bootph-all</code> <strong>on the flash node itself</strong> (detour 8) and\n<code class=\"language-plaintext highlighter-rouge\">spi-rx-bus-width = &lt;1&gt;</code> instead of 4 (detour 9: quad read is broken in\nSPL on this board).</p>\n\n<pre><code class=\"language-dts\">&amp;sdhci0 { bootph-all; status = \"okay\"; };\n&amp;uart1  { bootph-all; status = \"okay\"; };\n</code></pre>\n<p><code class=\"language-plaintext highlighter-rouge\">bootph-all</code> marks what survives into the SPL DTB. GEM is absent there;\nSPL does no networking.</p>\n\n<pre><code class=\"language-dts\">usb_phy0: phy0 { reset-gpios = &lt;&amp;gpio0 46 1&gt;; };\n</code></pre>\n<p>Schematic: PS_MIO46 → OTG_RESETN; HWH: active low. Not needed for Stage 1,\nbut correct.</p>\n\n<h3 id=\"building\">Building</h3>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>git clone https://source.denx.de/u-boot/u-boot.git <span class=\"o\">&amp;&amp;</span> <span class=\"nb\">cd </span>u-boot\ngit checkout 527115ef6783            <span class=\"c\"># v2026.10-rc2</span>\n<span class=\"c\"># copy in the four files, add zynq-ax7020.dtb to arch/arm/dts/Makefile</span>\nmake alinx_ax7020_defconfig\nscripts/config <span class=\"nt\">--disable</span> TOOLS_MKEFICAPSULE   <span class=\"c\"># host tool wants gnutls</span>\nmake olddefconfig\nmake <span class=\"nt\">-j</span><span class=\"si\">$(</span><span class=\"nb\">nproc</span><span class=\"si\">)</span> <span class=\"nv\">CROSS_COMPILE</span><span class=\"o\">=</span>arm-none-eabi-\n</code></pre></div></div>\n\n<p>Products: <code class=\"language-plaintext highlighter-rouge\">spl/boot.bin</code> (SPL + Xilinx boot header, 107 KB, what the\nBootROM loads) and <code class=\"language-plaintext highlighter-rouge\">u-boot.img</code> (legacy uImage, ~1 MB, what SPL loads).</p>\n\n<p><strong>Checkpoint 3: verify the build before touching the board.</strong> All three\nchecks run on the host:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>tools/mkimage <span class=\"nt\">-l</span> spl/boot.bin      <span class=\"c\"># \"Image Type: Xilinx Zynq Boot Image support\"</span>\ntools/mkimage <span class=\"nt\">-l</span> u-boot.img        <span class=\"c\"># valid legacy uImage, load addr, size</span>\nfdtget u-boot.dtb / model          <span class=\"c\"># \"Alinx AX7020 board\"</span>\nfdtget spl/u-boot-spl.dtb /amba/spi@e000d000/flash@0 compatible\n                                   <span class=\"c\"># \"w25q256 jedec,spi-nor\": the SPL DTB</span>\n                                   <span class=\"c\"># really contains the flash node (detour 8)</span>\n</code></pre></div></div>\n\n<p>If any of these fails, nothing downstream can work, and unlike everything\ndownstream, these failures come with error messages.</p>\n\n<h2 id=\"step-4-a-private-lab-network\">Step 4: A private lab network</h2>\n\n<p>The board needs DHCP and somewhere to broadcast its console to. A USB\nEthernet adapter keeps it off the office LAN, and NetworkManager’s <code class=\"language-plaintext highlighter-rouge\">shared</code>\nmode is a one-liner DHCP server (dnsmasq behind the scenes, NAT included).</p>\n\n<p><strong>Check for subnet collisions first.</strong> The lab subnet must not overlap with\nanything your machine already routes: the office LAN, VPN tunnels, Docker\nbridges. Two onboard commands show everything that is taken:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>ip <span class=\"nt\">-br</span> <span class=\"nt\">-4</span> addr        <span class=\"c\"># every interface with its subnet</span>\nip <span class=\"nt\">-4</span> route           <span class=\"c\"># every subnet the host routes, incl. VPN/Docker</span>\n</code></pre></div></div>\n\n<p>Pick a /24 from RFC 1918 space that appears in neither list. Watch out for\nwide masks: a /16 on the LAN side occupies all 256 of “its” /24s, and\nDocker and NetworkManager both default into ranges (172.17+.0.0/16,\n10.42.0.0/24) that look free until they are not. Then:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>nmcli connection add <span class=\"nb\">type </span>ethernet ifname &lt;usb-eth-if&gt; con-name zynq-lab <span class=\"se\">\\</span>\n    ipv4.method shared ipv4.addresses 192.168.77.1/24 ipv6.method disabled\nnmcli connection up zynq-lab\n</code></pre></div></div>\n\n<p>Host = 192.168.77.1, DHCP range .10–.254. The board draws a random MAC each\nboot, so its address changes; find it with\n<code class=\"language-plaintext highlighter-rouge\">ip neigh show dev &lt;usb-eth-if&gt;</code>. The interface shows <code class=\"language-plaintext highlighter-rouge\">NO-CARRIER</code> until\nU-Boot proper initialises the GEM; that is normal.</p>\n\n<p>Two small host tools complete the setup (both in the repo, both plain\nPython, no root):</p>\n\n<ul>\n  <li><code class=\"language-plaintext highlighter-rouge\">tools/tftpd.py tftp 6969</code>: an unprivileged TFTP server; U-Boot is pointed\nat the port with <code class=\"language-plaintext highlighter-rouge\">setenv tftpdstp 6969</code>.</li>\n  <li><code class=\"language-plaintext highlighter-rouge\">tools/ncsh.py &lt;board-ip&gt; \"bdinfo\"</code>: the netconsole shell. It exists because\nof two quirks handled inside it: U-Boot’s netconsole wants <strong>one input\ncharacter per UDP packet</strong> with a gap (it re-initialises the MAC around\nevery echo), and the USB NIC driver allocates ~16 KiB per received frame,\noverflowing the default socket buffer after ~13 packets unless a thread\ndrains it.</li>\n</ul>\n\n<p><strong>Checkpoint 4: the lab network works without the board.</strong> All testable\nfrom the host alone:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>ip <span class=\"nt\">-br</span> <span class=\"nt\">-4</span> addr show &lt;usb-eth-if&gt;     <span class=\"c\"># 192.168.77.1/24 on the interface</span>\nip <span class=\"nt\">-4</span> route | <span class=\"nb\">grep </span>192.168.77        <span class=\"c\"># route exists, only on that interface</span>\npgrep <span class=\"nt\">-a</span> dnsmasq | <span class=\"nb\">grep </span>192.168.77   <span class=\"c\"># NetworkManager's DHCP server is up</span>\n<span class=\"nb\">echo </span>hi | <span class=\"nb\">timeout </span>1 nc <span class=\"nt\">-u</span> 192.168.77.1 6969<span class=\"p\">;</span> <span class=\"c\"># tftpd.py logs the packet</span>\n</code></pre></div></div>\n\n<p>The interface will show <code class=\"language-plaintext highlighter-rouge\">NO-CARRIER</code>; the PHY link only comes up when\nU-Boot proper initialises the GEM. That is normal and not a failure of this\ncheckpoint. The board-side half of the test (a DHCP lease appearing in\n<code class=\"language-plaintext highlighter-rouge\">journalctl -u NetworkManager -f | grep -i dhcp</code>) becomes checkpoint 5\nduring the bring-up.</p>\n\n<h2 id=\"step-5-bring-up-over-jtag\">Step 5: Bring-up over JTAG</h2>\n\n<p>Board cold, J13 on JTAG, one command:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>openocd <span class=\"nt\">-f</span> openocd/ft232h.cfg <span class=\"nt\">-f</span> target/zynq_7000.cfg <span class=\"nt\">-f</span> openocd/load-uboot.cfg\n</code></pre></div></div>\n\n<p><code class=\"language-plaintext highlighter-rouge\">load-uboot.cfg</code> plays BootROM and QSPI, and encodes four hard-won rules in\nits comments:</p>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>init\ntargets zynq.cpu0                 ;# after halt the current target is often cpu1\nhalt\n\n# Stage 1: SPL into OCM (ps7_init: PLL, MIO, DDR)\nload_image build/.../spl/u-boot-spl.bin 0x0 bin\nreg pc 0x0\nresume\nsleep 3000\nhalt\ntargets zynq.cpu0\n\n# DDR sanity: write/read at the top of 1 GiB\nmww 0x3ffffffc 0xcafebabe\nmdw 0x3ffffffc 1\n\n# Stage 2: U-Boot proper into DDR, only while SPL (caches off) is halted\nload_image build/.../u-boot.bin 0x4000000 bin\nverify_image build/.../u-boot.bin 0x4000000 bin\nreg pc 0x4000000\nresume\n</code></pre></div></div>\n\n<p>(SPL in JTAG mode finds no payload at <code class=\"language-plaintext highlighter-rouge\">0x10000000</code>, jumps into uninitialised\nDDR and traps at <code class=\"language-plaintext highlighter-rouge\">0x40</code>, which is expected; we halt it and hand over ourselves.\n<code class=\"language-plaintext highlighter-rouge\">verify_image</code> is not optional; see detour 3.)</p>\n\n<p>Then, on the host, <code class=\"language-plaintext highlighter-rouge\">nc -u -l 6666</code>, or directly with <code class=\"language-plaintext highlighter-rouge\">ncsh.py</code>, the small\nself-written netconsole shell from Step 4 (it lives in the repo’s <code class=\"language-plaintext highlighter-rouge\">tools/</code>\ndirectory; plain Python, no dependencies):</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>tools/ncsh.py &lt;board-ip&gt; <span class=\"s2\">\"bdinfo\"</span> <span class=\"s2\">\"sf probe 0 30000000 0\"</span> <span class=\"s2\">\"mii info\"</span>\n</code></pre></div></div>\n\n<p><strong>Checkpoint 5: a JTAG-loaded U-Boot answers over Ethernet.</strong> Three parts:\nthe DHCP lease appears in <code class=\"language-plaintext highlighter-rouge\">journalctl -u NetworkManager -f | grep -i dhcp</code>\n(find the address with <code class=\"language-plaintext highlighter-rouge\">ip neigh show dev &lt;usb-eth-if&gt;</code>), the netconsole is\ninteractive, and the three commands above return the right hardware:</p>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>DRAM bank size  = 0x40000000\nSF: Detected w25q256 ... total 32 MiB\nPHY 0x01: OUI = 0x0732, Model = 0x11   ← RTL8211E at address 1\n</code></pre></div></div>\n\n<p>DDR size, flash chip and PHY are the three things the DTS and <code class=\"language-plaintext highlighter-rouge\">ps7_init</code>\nclaim about the board; this is where those claims meet reality.</p>\n\n<h2 id=\"step-6-back-up-then-flash\">Step 6: Back up, then flash</h2>\n\n<p>Before overwriting anything, the full 32 MiB:</p>\n\n<p><code class=\"language-plaintext highlighter-rouge\">tftpd.py</code> is the second self-written helper from <code class=\"language-plaintext highlighter-rouge\">tools/</code>: an\nunprivileged TFTP server, so no root and no system service:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>tools/tftpd.py tftp 6969 &amp;\ntools/ncsh.py &lt;ip&gt; <span class=\"s2\">\"setenv tftpdstp 6969\"</span> <span class=\"s2\">\"sf probe 0 30000000 0\"</span> <span class=\"se\">\\</span>\n  <span class=\"s2\">\"sf read 0x10000000 0 0x2000000\"</span> <span class=\"s2\">\"md5sum 0x10000000 0x2000000\"</span> <span class=\"se\">\\</span>\n  <span class=\"s2\">\"tftpput 0x10000000 0x2000000 ax7020-qspi-factory.bin\"</span>\n</code></pre></div></div>\n\n<p>MD5 identical on board and host. The factory flash turned out to be blank\n(all <code class=\"language-plaintext highlighter-rouge\">0xFF</code>); ALINX ships the demos on SD card. Nothing was at risk, but\nyou only know that afterwards.</p>\n\n<p>QSPI layout:</p>\n\n<table>\n  <thead>\n    <tr>\n      <th>Offset</th>\n      <th>Content</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">0x000000</code></td>\n      <td><code class=\"language-plaintext highlighter-rouge\">boot.bin</code>, what the BootROM loads</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">0x100000</code></td>\n      <td><code class=\"language-plaintext highlighter-rouge\">u-boot.img</code>, at <code class=\"language-plaintext highlighter-rouge\">CONFIG_SYS_SPI_U_BOOT_OFFS</code></td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">0xE00000</code></td>\n      <td>environment (redundant copy at <code class=\"language-plaintext highlighter-rouge\">0xE40000</code>)</td>\n    </tr>\n  </tbody>\n</table>\n\n<p>Flashing: <strong>only</strong> from a U-Boot that was JTAG-loaded onto a freshly\npower-cycled board in JTAG mode (detour 7), and with the read-back\nverification that makes netconsole’s occasional lost characters (detour 6)\nsurvivable:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>tools/ncsh.py &lt;ip&gt; <span class=\"se\">\\</span>\n  <span class=\"s2\">\"tftpboot 0x10000000 boot.bin\"</span> <span class=\"s2\">\"tftpboot 0x11000000 u-boot.img\"</span> <span class=\"se\">\\</span>\n  <span class=\"s2\">\"md5sum 0x10000000 &lt;size_boot&gt;\"</span> <span class=\"s2\">\"md5sum 0x11000000 &lt;size_img&gt;\"</span> <span class=\"se\">\\</span>\n  <span class=\"s2\">\"sf erase 0 0x260000\"</span> <span class=\"se\">\\</span>\n  <span class=\"s2\">\"sf write 0x10000000 0 &lt;size_boot&gt;\"</span> <span class=\"se\">\\</span>\n  <span class=\"s2\">\"sf write 0x11000000 0x100000 &lt;size_img&gt;\"</span> <span class=\"se\">\\</span>\n  <span class=\"s2\">\"sf read 0x12000000 0 &lt;size_boot&gt;\"</span> <span class=\"s2\">\"md5sum 0x12000000 &lt;size_boot&gt;\"</span> <span class=\"se\">\\</span>\n  <span class=\"s2\">\"sf read 0x13000000 0x100000 &lt;size_img&gt;\"</span> <span class=\"s2\">\"md5sum 0x13000000 &lt;size_img&gt;\"</span>\n</code></pre></div></div>\n\n<p>Sizes in hex: <code class=\"language-plaintext highlighter-rouge\">printf \"%x\\n\" $(stat -c %s tftp/boot.bin)</code>.</p>\n\n<p><strong>Checkpoint 6: the flash content is proven, not assumed.</strong> The read-back\nMD5 of both regions (<code class=\"language-plaintext highlighter-rouge\">0x12000000</code> / <code class=\"language-plaintext highlighter-rouge\">0x13000000</code> above) must equal the\nhost’s <code class=\"language-plaintext highlighter-rouge\">md5sum</code> of the files. Nothing counts as flashed until it does;\nthis is the only defence against netconsole’s occasional swallowed\ncharacters (detour 6) and it caught exactly one such case.</p>\n\n<h2 id=\"step-7-jumper-to-qspi-power-cycle\">Step 7: Jumper to QSPI, power cycle</h2>\n\n<div class=\"post-photo-grid\">\n  <figure>\n    <img src=\"/assets/posts/alinx-bring-up-jtag-detected-without-power-august-2026/boot-mode-jumper-jtag.jpg\" alt=\"Boot-mode header J13 with the jumper on the JTAG position\" loading=\"lazy\" />\n    <figcaption>J13 on JTAG: how the board spent the whole bring-up: BootROM parks both cores and waits.</figcaption>\n  </figure>\n  <figure>\n    <img src=\"/assets/posts/alinx-bring-up-jtag-detected-without-power-august-2026/boot-mode-jumper-qspi.jpg\" alt=\"Boot-mode header J13 with the jumper on the QSPI position\" loading=\"lazy\" />\n    <figcaption>J13 moved one position to QSPI: from now on the BootROM loads boot.bin from flash.</figcaption>\n  </figure>\n</div>\n\n<p>JTAG cable off, J13 from JTAG to QSPI, power on.</p>\n\n<p><strong>Checkpoint 7: the board boots entirely on its own.</strong> Before the power\ncycle, start two watchers on the host:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">sudo </span>journalctl <span class=\"nt\">-u</span> NetworkManager <span class=\"nt\">-f</span> | <span class=\"nb\">grep</span> <span class=\"nt\">-i</span> dhcp   <span class=\"c\"># terminal 1: the lease</span>\nnc <span class=\"nt\">-u</span> <span class=\"nt\">-l</span> 6666                                         <span class=\"c\"># terminal 2: the console</span>\n</code></pre></div></div>\n\n<p>Then power on and check, in order:</p>\n\n<ol>\n  <li><strong>~5 s after power-on a DHCP lease appears</strong> in terminal 1. That alone\nalready proves the whole chain: BootROM read <code class=\"language-plaintext highlighter-rouge\">boot.bin</code> from QSPI, SPL\nran <code class=\"language-plaintext highlighter-rouge\">ps7_init</code> and loaded <code class=\"language-plaintext highlighter-rouge\">u-boot.img</code> from flash, U-Boot proper brought\nup the GEM and ran <code class=\"language-plaintext highlighter-rouge\">dhcp</code>. Find the address with\n<code class=\"language-plaintext highlighter-rouge\">ip neigh show dev &lt;usb-eth-if&gt;</code> (it changes every boot, random MAC).</li>\n  <li>\n    <p><strong>The netconsole broadcast arrives</strong> in terminal 2, ending in the line\nthe <code class=\"language-plaintext highlighter-rouge\">bootcmd</code> prints:</p>\n\n    <div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>U-Boot 2026.10-rc2 (Aug 30 2026 - 20:25:52 +0200)\nmodeboot = qspiboot\nAX7020 netconsole ready\n</code></pre></div>    </div>\n\n    <p><code class=\"language-plaintext highlighter-rouge\">modeboot = qspiboot</code> is the important word: U-Boot read the boot-mode\npins and confirms it was started from flash, not from a JTAG load.</p>\n  </li>\n  <li><strong>The console is interactive</strong>: <code class=\"language-plaintext highlighter-rouge\">tools/ncsh.py &lt;ip&gt; \"bdinfo\" \"sf probe 0 30000000 0\"</code>\nmust return the 1 GiB DRAM bank and the W25Q256, the same two answers\nas over JTAG, now without any cable except Ethernet and power.</li>\n</ol>\n\n<p>If step 1 fails, SPL or the flash content is at fault and the board is\nmute; go back to the JTAG flow (Step 5) and check detours 8 and 9. If\nstep 1 works but step 2 stays silent, U-Boot runs and only the console\nconfig is wrong (detours 4–6).</p>\n\n<p>Stage 1 complete. The board boots an entirely open chain by itself, and the\nDHCP subnet on the USB adapter is already the foundation for Stage 2 (the\nimage server).</p>\n\n<hr />\n\n<h2 id=\"the-nine-detours\">The nine detours</h2>\n\n<p>Everything above reads like an afternoon. It was not, and here is why,\nchronologically, because several detours only make sense in sequence.\nIf you bring up any Zynq board this way, expect to meet most of these.</p>\n\n<h3 id=\"1-spl-ran-on-the-wrong-core\">1. SPL ran on the wrong core</h3>\n\n<p>After <code class=\"language-plaintext highlighter-rouge\">halt</code>, OpenOCD’s <em>current target</em> is whichever core was last active,\noften cpu1. <code class=\"language-plaintext highlighter-rouge\">resume &lt;addr&gt;</code> sets the PC only on the current target. First\nSPL run therefore happened on cpu1. Related trap: <code class=\"language-plaintext highlighter-rouge\">reg pc 0x0</code> + <code class=\"language-plaintext highlighter-rouge\">resume</code>\ndoes not restart a CPU cleanly; every other register keeps its crashed\nstate; use the address form, and always <code class=\"language-plaintext highlighter-rouge\">targets zynq.cpu0</code> first.</p>\n\n<p><strong>Fix, as it ended up:</strong> <code class=\"language-plaintext highlighter-rouge\">load-uboot.cfg</code> begins with <code class=\"language-plaintext highlighter-rouge\">targets zynq.cpu0</code>\nimmediately after <code class=\"language-plaintext highlighter-rouge\">init</code>, repeats it after every <code class=\"language-plaintext highlighter-rouge\">halt</code>, and starts loaded\ncode with <code class=\"language-plaintext highlighter-rouge\">resume 0x0</code> / <code class=\"language-plaintext highlighter-rouge\">resume 0x4000000</code> (the address form) instead of\nsetting the PC and resuming. The script encodes it so it cannot be\nforgotten.</p>\n\n<h3 id=\"2-stdoutserialnc-silently-ignored\">2. <code class=\"language-plaintext highlighter-rouge\">stdout=serial,nc</code> silently ignored</h3>\n\n<p>Without <code class=\"language-plaintext highlighter-rouge\">CONFIG_CONSOLE_MUX</code> and <code class=\"language-plaintext highlighter-rouge\">CONFIG_SYS_CONSOLE_IS_IN_ENV</code>, U-Boot\nignores the console environment entirely and writes to UART only. The board\npinged; the console did not exist.</p>\n\n<p><strong>Fix, as it ended up:</strong> two lines in <code class=\"language-plaintext highlighter-rouge\">alinx_ax7020_defconfig</code>,\n<code class=\"language-plaintext highlighter-rouge\">CONFIG_CONSOLE_MUX=y</code> and <code class=\"language-plaintext highlighter-rouge\">CONFIG_SYS_CONSOLE_IS_IN_ENV=y</code>, plus the\n<code class=\"language-plaintext highlighter-rouge\">CONFIG_PREBOOT</code> that sets <code class=\"language-plaintext highlighter-rouge\">stdin/stdout/stderr = serial,nc</code> after DHCP.\nOnly with all three does output actually leave over UDP.</p>\n\n<h3 id=\"3-loading-over-a-running-u-boot-corrupts-the-image\">3. Loading over a running U-Boot corrupts the image</h3>\n\n<p>Reloading <code class=\"language-plaintext highlighter-rouge\">u-boot.bin</code> while the previous U-Boot was halted with MMU and\nD-cache on: <code class=\"language-plaintext highlighter-rouge\">verify_image</code> showed the DDR content wrong in 32-byte blocks:\none L1 cache line. Dirty lines from the old run were written back over the\nfresh image. Flush with <code class=\"language-plaintext highlighter-rouge\">zynq.cpu0 cache l1 d flush_all</code> around the load, or\nbetter, load only while SPL (caches off) is halted. JTAG <em>reads</em> of DDR can\nlikewise return stale lines and fake a corruption.</p>\n\n<p><strong>Fix, as it ended up:</strong> the load sequence in <code class=\"language-plaintext highlighter-rouge\">load-uboot.cfg</code> only ever\nwrites DDR while SPL is halted (SPL runs with caches off, so JTAG writes\nland in DRAM directly), and every <code class=\"language-plaintext highlighter-rouge\">load_image</code> is followed by\n<code class=\"language-plaintext highlighter-rouge\">verify_image</code>. For the rare case of reloading over a running U-Boot, the\nrepo README records the flush incantation\n(<code class=\"language-plaintext highlighter-rouge\">zynq.cpu0 cache l1 d flush_all</code> before and after the load).</p>\n\n<h3 id=\"4-lwip-has-no-netconsole\">4. lwIP has no netconsole</h3>\n\n<p>Since U-Boot 2026, lwIP is the default network stack, and\n<code class=\"language-plaintext highlighter-rouge\">CONFIG_NETCONSOLE</code> exists only in the legacy one. <code class=\"language-plaintext highlighter-rouge\">olddefconfig</code> dropped it\nsilently; the binary had not a single <code class=\"language-plaintext highlighter-rouge\">nc_*</code> symbol. <code class=\"language-plaintext highlighter-rouge\">CONFIG_NET_LEGACY=y</code>.</p>\n\n<p><strong>Fix, as it ended up:</strong> <code class=\"language-plaintext highlighter-rouge\">CONFIG_NET_LEGACY=y</code> in the defconfig, which\nreplaces <code class=\"language-plaintext highlighter-rouge\">CONFIG_NET_LWIP</code> and lets <code class=\"language-plaintext highlighter-rouge\">CONFIG_NETCONSOLE=y</code> survive\n<code class=\"language-plaintext highlighter-rouge\">olddefconfig</code>. Verification that the fix took:\n<code class=\"language-plaintext highlighter-rouge\">arm-none-eabi-nm u-boot | grep nc_</code> must show the <code class=\"language-plaintext highlighter-rouge\">nc_*</code> symbols, a\ncheck worth adding to the build checkpoint whenever the config changes.</p>\n\n<h3 id=\"5-the-distro-bootcmd-hides-the-prompt\">5. The distro bootcmd hides the prompt</h3>\n\n<p>In JTAG boot mode, <code class=\"language-plaintext highlighter-rouge\">distro_bootcmd</code> loops forever through PXE/DHCP, and\nwhile U-Boot is inside a network operation, netconsole drops all output.\nPing worked, console stayed dark. A harmless\n<code class=\"language-plaintext highlighter-rouge\">CONFIG_BOOTCOMMAND=\"echo AX7020 netconsole ready\"</code> drops to the prompt.</p>\n\n<p><strong>Fix, as it ended up:</strong> that <code class=\"language-plaintext highlighter-rouge\">CONFIG_BOOTCOMMAND</code> line in the defconfig.\nIt replaces <code class=\"language-plaintext highlighter-rouge\">distro_bootcmd</code>, prints one recognisable line and falls\nthrough to the prompt, which doubles as the success marker of\ncheckpoint 7. Once Stage 3 gives the board something real to boot, this\nplaceholder gets replaced by the actual boot command.</p>\n\n<h3 id=\"6-the-host-drops-the-packets-and-the-board-wants-slow-input\">6. The host drops the packets, and the board wants slow input</h3>\n\n<p>Output arrived truncated: the board sent ~92 packets, the socket received</p>\n<ol>\n  <li><code class=\"language-plaintext highlighter-rouge\">UdpRcvbufErrors</code> in <code class=\"language-plaintext highlighter-rouge\">nstat</code> → the USB NIC driver allocates ~16 KiB per\nframe, the 208 KiB default buffer fills after 13 tiny packets. Input had the\nmirror problem: characters sent back-to-back arrive scrambled (<code class=\"language-plaintext highlighter-rouge\">vobfso</code> for\n<code class=\"language-plaintext highlighter-rouge\">version</code>), because netconsole expects one character per packet, exactly\nwhat U-Boot’s own <code class=\"language-plaintext highlighter-rouge\">tools/netconsole</code> script does with a delay. Both fixes\nlive in <code class=\"language-plaintext highlighter-rouge\">tools/ncsh.py</code>. Residual risk: a mangled <code class=\"language-plaintext highlighter-rouge\">sf write</code> becomes\n<code class=\"language-plaintext highlighter-rouge\">Unknown command</code> and writes nothing. It happened once; the read-back MD5\ncaught it. Check the echo of every destructive command.</li>\n</ol>\n\n<p><strong>Fix, as it ended up:</strong> both quirks are handled inside <code class=\"language-plaintext highlighter-rouge\">tools/ncsh.py</code>:\na receiver thread with an enlarged buffer drains the socket continuously,\nand input goes out one character per packet with 0.4 s spacing. The\nresidual character-loss risk is covered by procedure, not code: the flash\nrecipe in Step 6 always reads back and compares MD5 before anything counts\nas written.</p>\n\n<h3 id=\"7-never-reset-the-ps-from-the-debugger-and-other-one-way-doors\">7. Never reset the PS from the debugger, and other one-way doors</h3>\n\n<p>Writing <code class=\"language-plaintext highlighter-rouge\">PSS_RST_CTRL</code> (<code class=\"language-plaintext highlighter-rouge\">0xF8000200</code>) over JTAG to get a “clean slate”\nresets the debug logic too: cpu0 became unhaltable until a power cycle.\nSame family of mistake: after any QSPI-mode boot, <code class=\"language-plaintext highlighter-rouge\">ps7_init</code> has already\nrun, and it is <strong>not idempotent</strong>: JTAG-loading and restarting SPL on such\na board crashes at ever-changing places (undefined instructions in\n<code class=\"language-plaintext highlighter-rouge\">uclass_find</code>, alignment aborts in <code class=\"language-plaintext highlighter-rouge\">spi_nor_check_op</code>) that cost an hour of\nbreakpoints before the pattern emerged. Rule: JTAG loading only on a board\nfreshly power-cycled with the jumper on JTAG. Flashing from a U-Boot that\nSPL had handed the QSPI controller to was equally poisoned: <code class=\"language-plaintext highlighter-rouge\">Written: OK</code>,\nflash starting with zeros.</p>\n\n<p><strong>Fix, as it ended up:</strong> three procedural rules, written into\n<code class=\"language-plaintext highlighter-rouge\">load-uboot.cfg</code>’s header comment so they travel with the script: (1) never\nwrite <code class=\"language-plaintext highlighter-rouge\">PSS_RST_CTRL</code> from JTAG, because a power cycle is the only clean reset;\n(2) JTAG-load only onto a board freshly power-cycled with J13 on JTAG, so\n<code class=\"language-plaintext highlighter-rouge\">ps7_init</code> has not run yet; (3) flash only from a U-Boot that was\nJTAG-loaded in that state, never from one that SPL started out of QSPI.</p>\n\n<h3 id=\"8-bootph-all-belongs-on-the-flash-node-not-just-the-controller\">8. <code class=\"language-plaintext highlighter-rouge\">bootph-all</code> belongs on the flash node, not just the controller</h3>\n\n<p>First standalone QSPI boot: silence. The SPL DTB is produced by filtering on\n<code class=\"language-plaintext highlighter-rouge\">bootph-*</code>; the filter keeps matching nodes and their <em>parents</em>, not their\n<em>children</em>. With the property only on the QSPI controller, <code class=\"language-plaintext highlighter-rouge\">flash@0</code> was\nmissing, and U-Boot 2026’s <code class=\"language-plaintext highlighter-rouge\">spi_find_chip_select()</code> no longer has a\nfallback that binds a <code class=\"language-plaintext highlighter-rouge\">jedec_spi_nor</code> without a DT node. SPL died in\n<code class=\"language-plaintext highlighter-rouge\">SPI probe failed.</code>, invisible without a console, and impossible to notice\nin the JTAG flow, where SPL never touches the flash.</p>\n\n<p><strong>Fix, as it ended up:</strong> <code class=\"language-plaintext highlighter-rouge\">bootph-all;</code> inside <code class=\"language-plaintext highlighter-rouge\">flash@0</code> in\n<code class=\"language-plaintext highlighter-rouge\">zynq-ax7020.dts</code>, in addition to the one on <code class=\"language-plaintext highlighter-rouge\">&amp;qspi</code>. Checkpoint 3 exists\nprecisely to catch this class of error on the host:\n<code class=\"language-plaintext highlighter-rouge\">fdtget spl/u-boot-spl.dtb /amba/spi@e000d000/flash@0 compatible</code> must\nanswer <code class=\"language-plaintext highlighter-rouge\">w25q256 jedec,spi-nor</code> before the board is touched.</p>\n\n<h3 id=\"9-quad-read-is-broken-in-spl-by-three-bytes\">9. Quad read is broken in SPL, by three bytes</h3>\n\n<p>The deepest one. With <code class=\"language-plaintext highlighter-rouge\">spi-rx-bus-width = &lt;4&gt;</code>, QSPI reads inside SPL come\nback <strong>offset by 3 bytes</strong>. The image header is never recognised, SPL\nsilently falls back to its raw-image path and jumps into a uImage header.\nU-Boot proper does <em>not</em> show the fault: <code class=\"language-plaintext highlighter-rouge\">sf read</code> + <code class=\"language-plaintext highlighter-rouge\">md5sum</code> from the\nprompt happily verify a flash SPL cannot read, which sends the diagnosis in\nevery wrong direction first. Single-bit read (<code class=\"language-plaintext highlighter-rouge\">spi-rx-bus-width = &lt;1&gt;</code>) is\ncorrect. Two neighbours from the same digging session:\n<code class=\"language-plaintext highlighter-rouge\"># CONFIG_SPL_STACK_R is not set</code> (relocating the SPL stack to DDR produced\na corrupted stack pointer and alignment aborts, while plain JTAG memory\ntests of the same region pass) and <code class=\"language-plaintext highlighter-rouge\"># CONFIG_SPL_LOAD_FIT is not set</code> (the\nSPL FIT loader placed the FDT at <code class=\"language-plaintext highlighter-rouge\">TEXT_BASE</code>, so U-Boot executed a device\ntree; the legacy uImage path is one header parse plus one copy, and 24 KB\nsmaller).</p>\n\n<p>The trick that cracked detour 9 deserves its own mention: instead of moving\nthe jumper and power-cycling for every attempt, two temporary patches made\nthe QSPI load path debuggable over JTAG: map <code class=\"language-plaintext highlighter-rouge\">ZYNQ_BM_JTAG</code> to\n<code class=\"language-plaintext highlighter-rouge\">BOOT_DEVICE_SPI</code> in <code class=\"language-plaintext highlighter-rouge\">arch/arm/mach-zynq/spl.c</code> so SPL exercises the real\nflash path in JTAG mode, and have <code class=\"language-plaintext highlighter-rouge\">_spl_load()</code> write its breadcrumbs\n(<code class=\"language-plaintext highlighter-rouge\">offset</code>, <code class=\"language-plaintext highlighter-rouge\">size</code>, read return, first words at the load address) into spare\nDDR at <code class=\"language-plaintext highlighter-rouge\">0x3f000000</code>. One run, read the breadcrumbs: <code class=\"language-plaintext highlighter-rouge\">offset = 0</code>,\n<code class=\"language-plaintext highlighter-rouge\">size = 0x32000</code>, the raw-image fallback, with the uImage magic sitting at\n<code class=\"language-plaintext highlighter-rouge\">load_addr + 3</code>. Revert both patches before building anything that gets\nflashed.</p>\n\n<p><strong>Fix, as it ended up:</strong> three config decisions that are all visible in the\nfinal DTS and defconfig: <code class=\"language-plaintext highlighter-rouge\">spi-rx-bus-width = &lt;1&gt;</code> on <code class=\"language-plaintext highlighter-rouge\">flash@0</code> (single-bit\nread; slower, correct; U-Boot proper is unaffected because only SPL\nmisreads), <code class=\"language-plaintext highlighter-rouge\"># CONFIG_SPL_STACK_R is not set</code> (SPL stack stays in OCM), and\n<code class=\"language-plaintext highlighter-rouge\"># CONFIG_SPL_LOAD_FIT is not set</code> with <code class=\"language-plaintext highlighter-rouge\">u-boot.img</code> built as a legacy\nuImage. Whether quad read can be fixed properly (dummy-cycle configuration\nfor the W25Q256 in the Zynq QSPI driver) is an open question for later;\nfor Stage 1, correctness beats speed.</p>\n\n<h2 id=\"closing\">Closing</h2>\n\n<p>Nine detours for four files and ~40 lines of config. None of the individual\nfacts was hard; what was hard was that almost every failure was invisible\n(no console yet), had multiple plausible causes, and sat in a different\nlayer than its symptom: a host socket buffer masquerading as a board\nproblem, a cache line masquerading as a flash problem, a device-tree filter\nmasquerading as an SPI driver problem.</p>\n\n<p>All self-written artefacts (the DTS, the defconfig, both OpenOCD configs\nand both host tools) are published at\n<a href=\"https://github.com/MaxClerkwell/ax7020-bringup\">github.com/MaxClerkwell/ax7020-bringup</a>,\nincluding a README that covers the build and the <code class=\"language-plaintext highlighter-rouge\">ps7_init</code> extraction from\nthe vendor package.</p>\n\n<p>The next step is Stage 3 of the plan: a Linux built for this board with\n<strong>Yocto</strong> instead of PetaLinux, replacing the next vendor black box in the\nchain. The groundwork is already in place: U-Boot sits resident in QSPI, the\nlab network with DHCP and TFTP is running, and <code class=\"language-plaintext highlighter-rouge\">bootm</code> is waiting for a\nkernel. (The image server of Stage 2 grows naturally out of the same lab\nnetwork along the way.) That build now has its own article:\n<a href=\"/posts/alinx-ax7020-yocto-linux-qspi-august-2026/\">a Yocto Linux in QSPI flash that fetches its own updates</a>.</p>",
      "date_published": "2026-08-30T00:00:00+00:00",
      
      "date_modified": "2026-09-01T00:00:00+00:00",
      "image": "https://maxclerkwell.tech/assets/posts/alinx-bring-up-jtag-detected-without-power-august-2026/m12-adapter.jpg",
      "authors": [ { "name": "Stephan Bökelmann" } ],
      "tags": ["fpga","alinx","zynq","jtag","openocd","u-boot","spl","device-tree","netconsole","bring-up","linux"]
    },
    
    {
      "id": "https://maxclerkwell.tech/posts/zynq-bitstream-deployment-concept-august-2026/",
      "url": "https://maxclerkwell.tech/posts/zynq-bitstream-deployment-concept-august-2026/",
      "title": "Deploying Hardware Like Software: A Bitstream Pipeline for the Zynq",
      "summary": "Alinx sent me an AX7020 board. Here is the plan: an open-source path from JTAG bring-up to a REST API that accepts a bitstream and loads it onto the FPGA, no vendor tools in the loop.",
      "content_html": "<p>A while ago, <a href=\"https://www.alinx.com/\">Alinx</a> reached out and asked whether I would like one of their boards for my content work. I said yes, and a few days later an AX7020 arrived on my desk. Full disclosure up front: the board is a sponsorship gift. Alinx has no say in what I write about it, and as you will see below, my plan involves ignoring most of the vendor-recommended workflow anyway.</p>\n\n<p>This article is not a tutorial. It is the concept post for a small series: what I want to build, why I want to build it that way, and where I expect things to get uncomfortable. The follow-up articles will document each stage as it actually happens, including the parts that fail.</p>\n\n<hr />\n\n<h2 id=\"the-board\">The Board</h2>\n\n<p>The AX7020 is built around a Xilinx Zynq-7020, which is a curious kind of chip. Half of it is an ordinary computer: two ARM Cortex-A9 cores, a DDR3 controller, Gigabit Ethernet, USB, SD, UART. Xilinx calls this half the PS, the Processing System. The other half is FPGA fabric, the PL or Programmable Logic, sitting on the same die and wired to the processor through a set of AXI ports.</p>\n\n<p>If you have read my <a href=\"/posts/wtf-are-fpgas-june-2026/\">introduction to FPGAs</a>, you know that I think of an FPGA as a grid of configurable logic with a routing fabric between the cells. The Zynq takes that grid and bolts a Linux-capable computer onto it. That combination is the whole point of this project. The ARM side runs a normal operating system with a network stack, and the FPGA side is available as a resource that the operating system can reconfigure at runtime, in well under a second, as often as it likes.</p>\n\n<p>Read that again, because it is the core idea: the processor can rewrite the hardware next to it while running. Most people program FPGAs the way we programmed microcontrollers in the nineties, with a cable and a vendor tool and a ritual. The Zynq allows something much closer to modern software deployment. Nobody walks to a server with a JTAG probe to update a web service. You push an artifact to an endpoint and the system takes it from there.</p>\n\n<p>So that is the goal I am committing to in writing:</p>\n\n<p><strong>A REST API running on the board itself. You POST a bitstream to it, and the FPGA next to the processor starts executing your design.</strong></p>\n\n<p>If that sounds familiar, it is because AWS sells exactly this. Their F1 and F2 instances are servers with datacenter-grade FPGAs attached: you upload your design through an API, AWS loads it into the fabric, and your software talks to your custom hardware over PCIe. What I am building is the same idea shrunk from a datacenter rack to a 15-watt board on my desk, with one difference that matters to me: on AWS, every layer between your design and the silicon is proprietary and invisible. Here, every layer will be open and inspectable.</p>\n\n<p>Deploying hardware like software. <code class=\"language-plaintext highlighter-rouge\">curl -T design.bin</code> as the last step of a hardware CI pipeline.</p>\n\n<hr />\n\n<h2 id=\"the-plan-stage-by-stage\">The Plan, Stage by Stage</h2>\n\n<p>Between an unboxed board and that API sit five stages. Each one is a natural checkpoint, and each one will get its own article.</p>\n\n<h3 id=\"stage-1-an-open-source-u-boot-loaded-over-jtag\">Stage 1: An open-source U-Boot, loaded over JTAG</h3>\n\n<p>The Zynq-7000 boot ROM knows four boot sources: JTAG, QSPI flash, NAND, and SD card. What it does not know is USB, so there is no magic first contact over a USB cable like on more modern chips. The first code on this board will therefore arrive over JTAG.</p>\n\n<p>The usual path here is the Xilinx FSBL, the First Stage Boot Loader that Vivado generates for you. I am not taking that path. Mainline U-Boot has an SPL mode that replaces the FSBL entirely: a small first stage that initializes the DDR controller, the clocks, and the pin multiplexing, then loads the full U-Boot. The board-specific knowledge, which DDR chips are soldered down and which pins go where, lives in a generated file called <code class=\"language-plaintext highlighter-rouge\">ps7_init_gpl.c</code> that can be extracted from the vendor’s hardware description without ever starting Vivado. Alinx publishes their reference design <a href=\"https://github.com/alinxalinx/AX7020_2023.1\">on GitHub</a>, and the file is sitting inside the XSA archive, which turns out to be a plain zip.</p>\n\n<p>Mainline U-Boot has never heard of the AX7020, so this stage involves teaching it about a board it does not know: adapting a device tree from a close relative like the ZedBoard and feeding the SPL the correct initialization data for this specific board. How that goes in practice, including whatever friction the vendor tutorials do not mention, is now documented in its own article: <a href=\"/posts/alinx-bring-up-jtag-detected-without-power-august-2026/\">ALINX AX7020 Bring-up: Mainline U-Boot Over JTAG, No FSBL, No Serial Cable</a>.</p>\n\n<p>Checkpoint for this stage: a U-Boot prompt on the serial console, <code class=\"language-plaintext highlighter-rouge\">bdinfo</code> reporting 1 GB of DDR, and the QSPI flash answering to <code class=\"language-plaintext highlighter-rouge\">sf probe</code>. Once that works, U-Boot gets written to the QSPI flash and the JTAG cable goes back in the drawer. Everything after this point happens over the network.</p>\n\n<h3 id=\"stage-2-an-image-server-on-my-own-network\">Stage 2: An image server on my own network</h3>\n\n<p>U-Boot can fetch files over TFTP, and the Ethernet port of the AX7020 hangs directly off the processor side, so it works without any FPGA configuration at all. The plan is classic network boot, the same pattern that diskless workstations used thirty years ago: the board comes up, U-Boot asks the network for a kernel and a root filesystem, and boots what it receives.</p>\n\n<p>That requires infrastructure on my side, a small image server: TFTP for U-Boot, plus something to serve rootfs images. This is deliberately part of the project rather than an annoyance. If the endgame is treating hardware deployment like software deployment, then the boot images themselves should also come from a server I can push to, not from an SD card I have to carry around. The UART stays connected during all of this as the rescue line; serial consoles are to embedded bring-up what stack traces are to software.</p>\n\n<p>(Spoiler from the future: this stage did not survive contact with a managed switch in its planned form. What actually happened is in the <a href=\"/posts/alinx-ax7020-yocto-linux-qspi-august-2026/\">Stages 2 &amp; 3 article</a>.)</p>\n\n<h3 id=\"stage-3-a-linux-built-for-this-board\">Stage 3: A Linux built for this board</h3>\n\n<p>With netboot working, the board needs a proper operating system. PetaLinux, the vendor’s offering, is a wrapper around Yocto, so I will skip the wrapper and use Yocto directly with the meta-xilinx layer, mainline kernel, mainline U-Boot. The Zynq-7000 is mature enough to be genuinely well supported upstream; the Ethernet controller, the SD controller, and, crucially for this project, the FPGA configuration interface all have mainline drivers.</p>\n\n<p>How this stage actually went, including a flash bug that defeated every checksum and a switch that silenced U-Boot for good, is documented in <a href=\"/posts/alinx-ax7020-yocto-linux-qspi-august-2026/\">ALINX AX7020, Stages 2 &amp; 3: A Yocto Linux in QSPI Flash That Fetches Its Own Updates</a>.</p>\n\n<p>The one feature this Linux absolutely must have is the kernel’s FPGA manager framework. On the Zynq, the processor configures the FPGA fabric through an internal port called PCAP, and the mainline <code class=\"language-plaintext highlighter-rouge\">zynq-fpga</code> driver exposes it in sysfs. Loading a new hardware design then degenerates into writing a filename into a sysfs node. No cable, no vendor tool, just a file write that any process with the right permissions can perform.</p>\n\n<h3 id=\"stage-4-a-bitstream-from-the-open-toolchain-loaded-over-ssh\">Stage 4: A bitstream from the open toolchain, loaded over SSH</h3>\n\n<p>Now the FPGA side. The Zynq-7020 is a 7-series device, which means it is covered by the openXC7 flow: Yosys for synthesis, nextpnr-xilinx for place and route, on top of the Project X-Ray database that documents the bitstream format. I wrote about <a href=\"/posts/from-bitstream-to-idea-inverse-fpga-guide-july-2026/\">reverse-engineering bitstreams</a> before; this project is where that world becomes daily practice, because the entire pipeline from HDL to configured silicon will run without a single proprietary tool.</p>\n\n<p>I want to be honest about expectations here. The open 7-series flow works, but it is not Vivado. Timing analysis is rudimentary and some hard blocks are awkward to use. For this stage the design will be deliberately boring, a blinker or a counter on AXI, because the design is not the point. The point is the pipeline: synthesize on my desk, <code class=\"language-plaintext highlighter-rouge\">scp</code> the bitstream to the board, log in over SSH, write it into the FPGA manager, watch the LED blink. The moment that works, the full loop exists, and every piece of it is open source.</p>\n\n<h3 id=\"stage-5-the-rest-api\">Stage 5: The REST API</h3>\n\n<p>The final stage removes the human from stage 4. A small service on the board, probably a few hundred lines, accepts a bitstream over HTTP, validates it, hands it to the FPGA manager, and reports back whether the fabric came up.</p>\n\n<p>Validation is the part I refuse to hand-wave. A bitstream is arbitrary hardware configuration. The service will at minimum check the sync word and the device IDCODE so that only bitstreams built for this exact chip get loaded, and the endpoint will be authenticated. There is also a sharper issue lurking here: a design containing an AXI master has full access to system memory, DMA-style. An API that loads unreviewed bitstreams is remote code execution with extra steps, hardware edition. For my lab that is an acceptable and clearly labeled risk. For anything beyond a lab it is the reason AWS wraps every customer design on their FPGA instances in a fixed shell that polices its memory access. Knowing that this problem exists, and understanding why the cloud providers solved it the way they did, is half the educational value of building the small version.</p>\n\n<hr />\n\n<h2 id=\"why-bother-when-an-sd-card-would-do\">Why Bother, When an SD Card Would Do</h2>\n\n<p>A fair question. Alinx ships a working reference image; I could have the board on the network in an afternoon by flashing a card and following the manual.</p>\n\n<p>But my interest in this board is not the destination, it is the supply chain. Every stage of this plan replaces a vendor black box with an inspectable component: SPL instead of FSBL, Yocto instead of PetaLinux, Yosys and nextpnr instead of Vivado, sysfs instead of a programming cable. At the end, every byte that reaches this chip, from the first DDR register write to the configuration frames in the FPGA fabric, will have come out of a toolchain I can read the source of.</p>\n\n<p>That is worth something on its own. It is worth more as content, because the failures along the way are precisely the material that vendor documentation cannot provide.</p>\n\n<p>The board is on my desk, and stage 1 is done: <a href=\"/posts/alinx-bring-up-jtag-detected-without-power-august-2026/\">building mainline U-Boot for a board that mainline has never heard of</a> documents the whole bring-up, checkpoints and detours included.</p>\n\n<hr />\n\n<h2 id=\"work-with-me\">Work With Me</h2>\n\n<p>Projects like this one are what I do professionally. I work as a freelancer on embedded systems: microcontroller firmware, PCB design, FPGA development, board bring-up, and the toolchains and infrastructure around them. If you have a project in that space, from “we need a prototype” to “our vendor toolchain is holding us hostage”, <a href=\"mailto:stephan@boekelmann.net\">get in touch</a>.</p>\n\n<p>And if you just want to talk shop: I am regularly around on my Full Stack Engineering Discord, where we discuss everything from tape-out to JavaScript. Come say hi.</p>\n\n<div><a href=\"https://discord.gg/2BXuUY6hrX\" class=\"link-card-discord\" target=\"_blank\" rel=\"noopener noreferrer\"><i class=\"fab fa-discord\"></i><div class=\"discord-text\"><span class=\"discord-name\">Discord — Full Stack Engineering</span><span class=\"discord-note\">Direct access to me and my colleagues. Webinars, live Q&amp;A, and community discussions for engineers across the full stack.</span><span class=\"discord-join\">Join the server →</span></div></a></div>",
      "date_published": "2026-08-23T00:00:00+00:00",
      
      "date_modified": "2026-08-23T00:00:00+00:00",
      
      "authors": [ { "name": "Stephan Bökelmann" } ],
      "tags": ["fpga","embedded","linux","open-source"]
    },
    
    {
      "id": "https://maxclerkwell.tech/posts/orbitals-to-bands-august-2026/",
      "url": "https://maxclerkwell.tech/posts/orbitals-to-bands-august-2026/",
      "title": "Why Does Copper Conduct? A Tour for Non-Physicists",
      "summary": "Copper conducts, diamond doesn't, yet both are packed with electrons. A walk from single atoms to energy bands that explains why, ending with a back-of-the-envelope number that lands within ten percent of the measured conductivity of copper.",
      "content_html": "<p><em>I recently finished a small paper that derives the electronic structure of solids step by step, aimed at first-year physics students. This post is the version without the mathematics: the story you can follow on a train ride. If you want the full derivations, the paper is linked at the end.</em></p>\n\n<hr />\n\n<h2 id=\"a-question-that-sounds-trivial\">A Question That Sounds Trivial</h2>\n\n<p>Why does copper conduct electricity and diamond doesn’t?</p>\n\n<p>The answer you probably learned in school, “metals have free electrons”, is not wrong, but it quietly dodges the real question. A gram of diamond contains roughly as many electrons as a gram of copper. Nobody nailed them down. So why are the electrons in copper “free” and the ones in diamond not? What, physically, is the difference?</p>\n\n<p>It turns out the answer has nothing to do with electrons being loose or stuck. It has to do with <em>seating</em>.</p>\n\n<h2 id=\"one-atom-a-staircase-not-a-ramp\">One Atom: a Staircase, Not a Ramp</h2>\n\n<p>Quantum mechanics makes one claim from which everything else follows: an electron bound to an atom cannot have just any energy. It can sit on step one, step two, step three, but never in between. The energy landscape of an atom is a staircase, not a ramp.</p>\n\n<p><img src=\"/assets/posts/orbitals-to-bands-august-2026/fig_atom_levels.png\" alt=\"Discrete energy levels of a single atom\" /></p>\n\n<p>On top of that comes a rule with enormous consequences, the Pauli principle: each step seats at most two electrons. Electrons fill the staircase from the bottom, two per step, and then the box office closes.</p>\n\n<h2 id=\"two-atoms-every-step-splits-in-two\">Two Atoms: Every Step Splits in Two</h2>\n\n<p>Bring two atoms close together and something curious happens. An electron that belonged to one atom starts to notice the neighbor. Quantum mechanics allows it to tunnel across, and once the two atoms are close enough, the honest description is no longer “an electron at atom A” or “an electron at atom B” but a wave spread out over both. There are two ways to build such a shared wave: a smooth way, and a way with a kink in the middle. Kinks cost energy. So every single step of the staircase splits into a <em>pair</em> of steps, one slightly lower (the smooth, “bonding” arrangement) and one slightly higher (the kinked, “antibonding” one).</p>\n\n<p><img src=\"/assets/posts/orbitals-to-bands-august-2026/fig_bonding_antibonding_nodes.png\" alt=\"Bonding and antibonding states of two coupled atoms\" /></p>\n\n<p>That’s it. That is the entire mechanism. The rest is repetition.</p>\n\n<h2 id=\"a-crystal-steps-blur-into-bands\">A Crystal: Steps Blur into Bands</h2>\n\n<p>A crystal is not two atoms but something like $10^{23}$ of them. Each original step now splits $10^{23}$ ways. The resulting levels are spaced so absurdly finely, roughly $10^{-23}$ electron volts apart, that they stop being steps at all. They blur into a continuous <em>band</em> of allowed energies. Between the bands there may remain forbidden zones, so-called <em>band gaps</em>, where no electron can sit, period.</p>\n\n<p><img src=\"/assets/posts/orbitals-to-bands-august-2026/fig_band_formation.png\" alt=\"From two atoms to N atoms: levels merge into bands\" /></p>\n\n<p>So the energy landscape of a solid looks like a layer cake: bands of allowed energies, separated by gaps of forbidden ones. Now pour in the electrons, from the bottom, two per seat, exactly as in the single atom. Where the filling stops is called the <em>Fermi level</em>. And here is the punchline of the whole story:</p>\n\n<p><strong>A completely full band carries no current.</strong></p>\n\n<p>Conducting means accelerating. An electron gains a tiny bit of energy from the applied voltage and moves to a slightly higher seat. In a full band there is no empty seat to move to, so every microscopic push is forbidden. The electrons are not stuck to their atoms; they are stuck in a fully booked theater. They can, in a quantum mechanical sense, move through the whole crystal, but for every electron drifting to the left there is a mirror partner drifting to the right, and the net current is exactly zero. A voltage cannot tip that balance, because tipping it would require moving someone to a free seat, and there is none.</p>\n\n<h2 id=\"valence-band-and-conduction-band\">Valence Band and Conduction Band</h2>\n\n<p>Two of these bands matter so much that they have names, and it is worth being precise about them.</p>\n\n<p>The <strong>valence band</strong> is the highest band that is (essentially) fully occupied. Its name is no accident: it is formed from the outermost atomic orbitals, the valence orbitals, the same electrons that chemistry uses for bonding. These electrons are doing a job. In diamond, every valence electron is part of a carbon-carbon bond, and the valence band being full is just the band-picture way of saying “all bonds are saturated”. Full band, fully booked theater, no current.</p>\n\n<p>The <strong>conduction band</strong> is the next band above it, and in an insulator or semiconductor it is (essentially) empty. An empty band carries no current either, for the trivial reason that there is nobody in it. But it is empty <em>and has seats</em>, and that combination is the key. Any electron that makes it up there has free seats in every direction and can be accelerated by even the smallest voltage. It behaves like the “free electron” from the school answer.</p>\n\n<p>Between the two lies the band gap, and the entire electrical character of a material hangs on this one number:</p>\n\n<p><img src=\"/assets/posts/orbitals-to-bands-august-2026/fig_band_types.png\" alt=\"Metal, semiconductor, insulator in the band picture\" /></p>\n\n<ul>\n  <li>In a <strong>metal</strong> like copper, the distinction collapses: the topmost occupied band is only half full, so valence band and conduction band are one and the same. Filled seats and empty seats touch at the Fermi level. There is no gap to cross, and the tiniest voltage moves electrons.</li>\n  <li>In an <strong>insulator</strong> like diamond, the gap is huge, about 5.5 electron volts. Thermal jostling at room temperature hands out energy portions of about 0.025 electron volts, more than two hundred times too small. Practically no electron ever reaches the conduction band, and the material does not conduct.</li>\n  <li>In a <strong>semiconductor</strong> like silicon, the gap is about 1.1 electron volts, small enough that at room temperature a rare thermal fluctuation occasionally lifts an electron across. That electron now conducts, and it leaves something behind: an empty seat in the otherwise full valence band, a <em>hole</em>. Neighboring valence electrons can now shuffle into that seat, the hole wanders, and it acts like a mobile positive charge. Conduction happens in both bands at once: electrons up top, holes below.</li>\n</ul>\n\n<p>It is worth pausing on how lopsided this lottery is. Temperature does not gently warm up all electrons; it only rattles the ones sitting within about $k_B T \\approx 0.025$ electron volts of the Fermi level. For an electron to cross a gap of 1.1 electron volts, dozens of these thermal portions have to pile up on one electron at the same moment, which is possible but rare, roughly a one-in-$10^{9}$ affair in silicon at room temperature. That is why the number of carriers in a semiconductor depends <em>exponentially</em> on the gap size and the temperature. Small changes have dramatic consequences: warm silicon up by a few tens of degrees and its conductivity multiplies, which is also why semiconductor circuits care so much about staying cool.</p>\n\n<p>The carrier density can also be engineered instead of left to thermal luck: doping, the deliberate sprinkling-in of foreign atoms, plants extra electrons just below the conduction band or extra holes just above the valence band. Controlling the carrier density this way, region by region, is the entire trick that diodes, transistors, and every chip in your pocket are built on.</p>\n\n<h2 id=\"the-payoff-calculating-copper\">The Payoff: Calculating Copper</h2>\n\n<p>A story like this should be checkable. It is. The model gives a formula for conductivity, $\\sigma = n e^2 \\tau / m$: carrier density $n$, electron charge $e$ and mass $m$, and the average time $\\tau$ between collisions with the vibrating crystal lattice. The formula tells a simple story: the voltage accelerates each carrier, a collision with a lattice vibration or a defect wipes out the gained speed after a time $\\tau$ on average, and the resulting steady drift, multiplied by how many carriers there are, is the current. Between a metal and an insulator, $\\tau$ and $m$ differ only by modest factors. What differs by more than twenty orders of magnitude is $n$, which is why the seating chart, and not some property of the individual electrons, decides everything.</p>\n\n<p>For copper, the band picture makes a sharp prediction about $n$, and it is more interesting than it first sounds. A copper atom brings 29 electrons. The inner 18 sit in compact orbitals close to the nucleus; neighboring atoms are simply too far away for these orbitals to overlap, so their levels never broaden into bands worth the name, and their electrons stay home. The next ten fill the so-called d orbitals, which do overlap and do form bands, but those bands offer exactly ten seats per atom and copper delivers exactly ten electrons: completely full, and by now we know what a full band means. That leaves precisely <em>one</em> electron per atom, the outermost one, in a wide, half-full band. Twenty-eight spectators, one player. From copper’s density and molar mass, that yields $n \\approx 8.5 \\times 10^{28}$ conduction electrons per cubic meter. With the measured collision time $\\tau \\approx 2.5 \\times 10^{-14}\\,\\mathrm{s}$:</p>\n\n\\[\\sigma = \\frac{8.5\\times10^{28} \\times (1.60\\times10^{-19})^2 \\times 2.5\\times10^{-14}}{9.11\\times10^{-31}}\\,\\frac{\\mathrm{S}}{\\mathrm{m}} \\approx 6\\times10^{7}\\,\\frac{\\mathrm{S}}{\\mathrm{m}}.\\]\n\n<p>The measured value is $5.96\\times10^{7}\\,\\mathrm{S/m}$. A staircase, a splitting rule, and a seating chart, and the number comes out right to within a few percent.</p>\n\n<h2 id=\"going-deeper\">Going Deeper</h2>\n\n<p>Everything I hand-waved here, where the staircase comes from, why kinks cost energy, how the bands and the Fermi level are actually computed, is derived explicitly and with historical sources in the paper: <a href=\"https://github.com/MaxClerkwell/orbitals-to-bands\">From Atomic Orbitals to Energy Bands</a>. The repository builds the PDF automatically, and all figures are generated from a single Python script. If this post made you curious, that is the next step of the staircase.</p>\n\n<p>You can read the paper right here:</p>\n\n<object data=\"/assets/posts/orbitals-to-bands-august-2026/orbitals_to_bands_en.pdf\" type=\"application/pdf\" width=\"100%\" height=\"800\" style=\"border: 1px solid #ccc; border-radius: 4px;\">\n  <p>Your browser cannot display the PDF inline. You can download it with the button below.</p>\n</object>\n\n<p style=\"text-align: center; margin-top: 1em;\">\n  <a href=\"/assets/posts/orbitals-to-bands-august-2026/orbitals_to_bands_en.pdf\" download=\"orbitals_to_bands_en.pdf\" style=\"display: inline-block; padding: 0.6em 1.4em; background: #0366d6; color: #fff; border-radius: 6px; text-decoration: none; font-weight: 600;\">\n    &#11015;&#65039; Download the paper (PDF)\n  </a>\n</p>",
      "date_published": "2026-08-21T00:00:00+00:00",
      
      "date_modified": "2026-08-21T00:00:00+00:00",
      "image": "https://maxclerkwell.tech/assets/posts/orbitals-to-bands-august-2026/fig_band_formation.png",
      "authors": [ { "name": "Stephan Bökelmann" } ],
      "tags": ["physics","education"]
    },
    
    {
      "id": "https://maxclerkwell.tech/posts/digital-communication-august-2026/",
      "url": "https://maxclerkwell.tech/posts/digital-communication-august-2026/",
      "title": "Digital Communication: Getting a Thought Out of One Head and Into Another",
      "summary": "Communication has one job: move the internal state of one system into another. This is a walk from a thought in your head to a wave on a wire, through the four sides of a message, a water canal, a push-pull driver, Shannon's channel, and the eye diagram that measures it all.",
      "content_html": "<p><em>Communication has exactly one job. Everything else about it, the words, the wires, the protocols, the noise, is machinery in service of that one job.</em></p>\n\n<hr />\n\n<h2 id=\"the-mandate\">The Mandate</h2>\n\n<p>You have a model in your head. It has no clean shape. It is made of pictures, feelings, memories, half-formed intentions, a smell from last summer, a plan for tomorrow. It is entirely yours, and as long as it stays inside you it is useless to anyone else.</p>\n\n<p>The moment you want to cooperate, that changes. Cooperation means two systems acting on a shared understanding, and a shared understanding requires that the model in your head somehow also come to exist in someone else’s. If we rule out telepathy for the moment, the only thing you can do is take that internal, shapeless state and push it out through whatever channels you have available so that another person can pick it up and rebuild a copy of it inside themselves.</p>\n\n<p>This is the mandate, and it never changes: <strong>move the internal state of one system into another.</strong> Hold on to that sentence. Every layer we add from here, human or electronic, is just a different way of carrying it out.</p>\n\n<p>As humans we have a rich set of tools for the job: words, gestures, facial expressions, hand signals, sounds, drawings, music. What all of them have in common is that they are ways of <em>serializing</em> a model. Your inner state is high-dimensional and parallel; a channel is narrow and mostly sequential. So you flatten the model, stream it out symbol by symbol over the channels you have, and trust that the receiver on the other end can take that stream and reconstruct the state inside their own head.</p>\n\n<h2 id=\"the-same-problem-in-silicon\">The Same Problem, in Silicon</h2>\n\n<p>Now look at a microcontroller, and you will find the identical problem wearing different clothes.</p>\n\n<p>Inside my program I have a data structure. Maybe it holds a measured state: a temperature, a set of flags, a timestamp. It lives in memory as a particular arrangement of bits, meaningful only inside this one processor. If a second processor is to act on that measurement, the arrangement has to be reproduced over there.</p>\n\n<p>So a driver takes the structure, serializes it into a stream of symbols, and imprints that stream onto a bus. On the far side, another processor with the matching bus receivers picks the stream back up and reconstructs the structure in its own memory. Sender, serialization, channel, reception, reconstruction. It is the same five steps, and it is the same mandate. A person talking and a chip driving a bus are solving one problem at two scales.</p>\n\n<h2 id=\"a-message-has-more-than-one-side\">A Message Has More Than One Side</h2>\n\n<p>Human communication theory noticed long ago that a single message is never just its literal content. Paul Watzlawick and, in more structured form, Friedemann Schulz von Thun described a message as having four sides at once. The interesting thing is that all four reappear, almost one to one, in a digital bus frame.</p>\n\n<ul>\n  <li><strong>Self-revelation</strong> (<em>Selbstoffenbarung</em>): what the sender discloses about its own state. On a bus this is the status information a node reports about itself: its mode, its health, its measured values.</li>\n  <li><strong>Appeal</strong> (<em>Appell</em>): what the sender wants the receiver to do. These are the commands, the writes, the “set output high”, the “start conversion”.</li>\n  <li><strong>Relationship</strong> (<em>Beziehung</em>): what the message says about how sender and receiver stand to each other, how much they trust and are aligned. On a bus this is the control apparatus wrapped around the payload: the addressing, the acknowledgements, the checksums and CRCs. It is the layer that says “you and I agree on the terms of this exchange, and here is the proof that I am speaking to you correctly”.</li>\n</ul>\n\n<p>The fourth side is the one that is most obvious in hindsight, because it is the part we usually think <em>is</em> the whole message: the <strong>factual content</strong> (<em>Sachinhalt</em>). It is the raw payload, the number, the data field itself. Strip away who is sending it, what they want done with it, and the machinery that proves the frame’s integrity, and there is still a bare fact being conveyed. In a bus frame it is simply the data bytes sitting between the header and the checksum.</p>\n\n<p>Four sides in a human sentence, four fields in a frame. The overhead you see wrapped around every payload on every serious bus is not bureaucracy; it is the electronic version of tone, stance, and intent riding along with the words.</p>\n\n<h2 id=\"you-still-need-something-physical-to-push-on\">You Still Need Something Physical to Push On</h2>\n\n<p>None of this moves without a physical channel: something the sender can modulate and the receiver can sense.</p>\n\n<p>Take air pressure. With my breath, my vocal cords, and the shape of my mouth and throat, I set the air vibrating in specific patterns. Those pressure waves travel outward, and the receiver’s eardrum vibrates in sympathy. First the ear filters the signal physically, by mechanical resonance, and only then do circuits in the brain get to work, unpacking the pattern step by step and in parallel until a meaning falls out.</p>\n\n<h2 id=\"when-the-ends-are-far-apart\">When the Ends Are Far Apart</h2>\n\n<p>Now push the sender and receiver so far apart that they can no longer see or hear each other directly. The channel of open air gives out. What can we do?</p>\n\n<p>One option is to <em>guide</em> the physical excitation instead of letting it spread. Speak into a long pipe, and the pressure wave, instead of dissipating in every direction, propagates along the tube and arrives with far more of its energy intact. The speaking tube on old ships is exactly this idea.</p>\n\n<p>Here is a stranger alternative that makes the underlying principle unmistakable. Run a long water canal from sender to receiver. Before we start, both parties agree on a protocol, a contract that says what the signal will mean. Now the sender pours a bucket of water into their end. A wave travels down the canal, and after a while the level at the receiver’s end rises. Or the sender scoops water out, and after a while the far level drops.</p>\n\n<p>Two states, level-up and level-down, look like almost nothing. But if the protocol is good enough, two states are plenty. The trick is to stop trying to send whole complex symbols at once, and instead serialize each symbol into a <em>chain</em> of these tiny information units. We invent new, coded symbols: agreed sequences of highs and lows that stand for the larger pieces of meaning. BCD is one such scheme, four level-changes standing in for one decimal digit. Give me a reliable two-state channel and a code, and I can send you anything.</p>\n\n<h2 id=\"from-water-to-wire\">From Water to Wire</h2>\n\n<p>Swap the water canal for an electrical conductor and the picture snaps into focus as a real digital bus.</p>\n\n<p>Now the sender is a push-pull stage. To send “high” it floods the line with electrons; to send “low” it pulls them back off. On the receiving end a comparator with a fixed threshold decides, at each moment, whether the level is above or below the line, and reconstructs the stream of highs and lows. Push-pull and comparator are just the bucket and the water-level gauge, moving far faster.</p>\n\n<p>And the same subtleties survive the translation. The signal still takes <em>time</em> to arrive: a wave has to propagate down the wire, exactly as it did down the canal. More surprising, and worth sitting with: the information does not really travel <em>inside</em> the wire. The conductor only guides it. The signal is a field arrangement around and along the line, and the wire is a rail that keeps that arrangement aimed at the receiver.</p>\n\n<p>You can tell this is true precisely because it can be disturbed from outside. On a high-speed bus, a neighboring line’s switching field couples into yours, and you get crosstalk. A “loud” switching voltage regulator sitting nearby can inject enough of its own field to make a clean signal hard to read. Back in the world of our speaking pipe, the equivalent is a diesel generator the pipe happens to run past: it radiates its own noise straight into the channel, and now the receiver has to separate your words from the engine. The channel was never sealed. It was only ever guided, and anything that can reach the guide can add to the signal.</p>\n\n<h2 id=\"shannon-drew-the-same-picture\">Shannon Drew the Same Picture</h2>\n\n<p>In 1948, Claude Shannon gave this whole story its canonical diagram, and everything above is a retelling of it. His model is a straight line of five boxes with one arrow coming in from the side:</p>\n\n<p><strong>information source → transmitter → channel → receiver → destination</strong>, with a <strong>noise source</strong> injecting into the channel.</p>\n\n<p>Map it onto what we already built and it lines up exactly. The information source is the model in your head, or the struct in memory. The transmitter is your vocal tract, or the push-pull driver, doing the serialization and modulation. The channel is the air, the pipe, the canal, the wire. The receiver is the ear, or the comparator. The destination is the reconstructed model in the other head, or the reconstructed struct in the other processor. And the noise source, entering at the channel and nowhere else, is the diesel generator, the crosstalk, the loud regulator.</p>\n\n<p>Shannon’s real contribution was to make this quantitative. He showed that a channel with bandwidth $B$ and a signal-to-noise ratio $S/N$ has a hard ceiling on how many bits per second you can push through it error-free:</p>\n\n\\[C = B \\log_2\\!\\left(1 + \\frac{S}{N}\\right).\\]\n\n<p>Read that formula against everything so far and it says something almost obvious in hindsight. Your throughput is bounded by how fast you can wiggle the channel ($B$) and by how far your signal stands above the noise ($S/N$). The diesel generator does not just annoy you; it lowers $S/N$ and, with it, the number of bits the channel can ever carry. Noise is not a nuisance layered on top of communication. It is one of the two terms that decide what communication is even possible.</p>\n\n<h2 id=\"where-we-are\">Where We Are</h2>\n\n<p>Step back and the arc is simple. Communicating a message is how the internal representation of one processor, silicon or biological, is transferred into another. But the channel alone is not enough. A wire, a canal, or a column of vibrating air only carries meaning if both ends run the same protocol: the same conventions, the same agreement about which pattern stands for what. Without that shared contract the signal arrives perfectly intact and means nothing. The physics delivers the envelope; the protocol is what lets the receiver open it. And the channel is never private either: disturbances can always couple in, and they set a ceiling on what can get through.</p>\n\n<h2 id=\"measuring-the-channel-without-reading-the-message\">Measuring the Channel Without Reading the Message</h2>\n\n<p>There is a beautiful last move here. Because all of this is content-independent, we can judge the <em>quality</em> of a link without caring what it is saying.</p>\n\n<p>Put an oscilloscope on the line and overlay every received symbol period on top of each other, aligned to the clock. The individual bits blur together into a shape called an <strong>eye diagram</strong>: a bright open region in the middle, framed by the tangle of rising and falling edges. That open “eye” is the margin the receiver has to work with. A wide, tall eye means the comparator can pick high from low with room to spare. A collapsing eye, edges smeared by crosstalk, levels pulled together by a noisy regulator, jitter blurring the timing, means the receiver is being asked to decide in a shrinking window, and errors are coming.</p>\n\n<p>The elegance is that none of this depends on the payload. You are not reading the data; you are measuring the shape it leaves behind. Content-independent quality metrics, jitter, noise margin, eye height and width, let you say how good a channel is before you have decoded a single meaningful bit through it. It is the same physical mandate we started with, now turned into something you can put a number on and watch on a screen.</p>\n\n<p>This is exactly what I showed in a couple of short Instagram videos, if you want to see a real eye diagram open and close on a scope instead of just reading about it:</p>\n\n<div style=\"display: flex; flex-wrap: wrap; gap: 1em; justify-content: center; margin: 1.5em 0;\">\n  <iframe src=\"https://www.instagram.com/reel/DbBlZC-taQJ/embed/\" width=\"326\" height=\"560\" title=\"What are eye diagrams?\" frameborder=\"0\" scrolling=\"no\" allowtransparency=\"true\" allowfullscreen=\"\" style=\"border-radius:6px; max-width:100%;\"></iframe>\n  <iframe src=\"https://www.instagram.com/reel/DbGlh0xsOt-/embed/\" width=\"326\" height=\"560\" title=\"More about eye diagrams\" frameborder=\"0\" scrolling=\"no\" allowtransparency=\"true\" allowfullscreen=\"\" style=\"border-radius:6px; max-width:100%;\"></iframe>\n</div>\n\n<p><em>Communication had exactly one job. From a thought in your head to a wave on a wire, it never stopped being the same job.</em></p>",
      "date_published": "2026-08-21T00:00:00+00:00",
      
      "date_modified": "2026-08-21T00:00:00+00:00",
      
      "authors": [ { "name": "Stephan Bökelmann" } ],
      "tags": ["electronics","embedded","information-theory","education"]
    },
    
    {
      "id": "https://maxclerkwell.tech/posts/dual-uplink-load-balancing-july-2026/",
      "url": "https://maxclerkwell.tech/posts/dual-uplink-load-balancing-july-2026/",
      "title": "Dual Uplink, Part Two: From Failover to Load Balancing (and the Two Bugs That Broke It)",
      "summary": "The follow-up to our dual-uplink failover setup: weighted multipath load balancing, source-based policy routing, pinning a host to one link, and the two persistence bugs that silently killed it on every reboot.",
      "content_html": "<p>Back in February, <a href=\"https://x.com/philippthecron\">Philipp</a> and I put a second uplink into the office and built <a href=\"/posts/dual-uplink-feb-2026/\">dual-uplink failover on AI-heimdall</a>: two default routes in the main table, separated by metric, DSL preferred and Starlink as a hot standby. That article ended on a promise. Two named routing tables, <code class=\"language-plaintext highlighter-rouge\">starlink</code> (200) and <code class=\"language-plaintext highlighter-rouge\">dsl</code> (201), were already registered in <code class=\"language-plaintext highlighter-rouge\">/etc/iproute2/rt_tables</code>, sitting there unused, “as preparation for proper policy routing.” This is the article where they get used.</p>\n\n<p>The move from failover to load balancing sounds like a small config change. It is not. Failover is one route winning; load balancing is every layer of the stack agreeing on which uplink a given packet leaves through. And getting it to survive a reboot turned out to hinge on two bugs that had nothing to do with routing at all.</p>\n\n<h2 id=\"failover-was-never-the-goal-just-the-safe-first-step\">Failover Was Never the Goal, Just the Safe First Step</h2>\n\n<p>Metric-based failover has a property that is great for a first deployment and useless for a busy office: only one link ever carries traffic. The lower-metric route wins completely, and the other one is a cold spare the kernel only touches when the primary disappears. We had resilience, but on a normal day all fifteen people were still crammed onto DSL while a perfectly good Starlink link sat idle.</p>\n\n<p>Load balancing is a different construct entirely. Instead of two default routes ranked by metric, you write <strong>one</strong> default route with multiple weighted next-hops:</p>\n\n<div class=\"language-sh highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>ip route replace default <span class=\"se\">\\</span>\n  nexthop via 192.168.1.1  dev eno2 weight 2 <span class=\"se\">\\</span>\n  nexthop via 192.168.99.1 dev eno1 weight 1\n</code></pre></div></div>\n\n<p>That is <code class=\"language-plaintext highlighter-rouge\">eno2</code> (Starlink) and <code class=\"language-plaintext highlighter-rouge\">eno1</code> (DSL) both carrying traffic at the same time, split roughly 2:1. One route, two next-hops, weights proportional to how much I want each link to shoulder.</p>\n\n<h2 id=\"per-flow-not-per-packet-the-expectation-to-kill-first\">Per-Flow, Not Per-Packet: The Expectation to Kill First</h2>\n\n<p>The first thing to get straight, before anyone gets excited about “combined bandwidth,” is what multipath actually balances. Modern kernels hash the route by flow, using the layer-3 and layer-4 headers, not per packet. A single TCP connection is pinned to one uplink for its whole life; conntrack keeps established flows on the path they started on. Balancing happens <em>across</em> connections, not <em>within</em> one.</p>\n\n<p>So <code class=\"language-plaintext highlighter-rouge\">weight 2:1</code> means about two thirds of new connections go to Starlink, not two thirds of any single download’s packets. You cannot bond one connection’s bandwidth across two links this way. A single large <code class=\"language-plaintext highlighter-rouge\">git push</code> rides one uplink at that uplink’s speed. What you gain is that fifteen people’s fifteen-plus connections spread out instead of stacking. For an office, that is exactly the win you want; for a single giant transfer, it does nothing, and it is better to say so up front than to field the “why isn’t my download twice as fast” question later.</p>\n\n<h2 id=\"the-four-pieces-that-have-to-agree\">The Four Pieces That Have to Agree</h2>\n\n<p>A working multi-uplink router is four separate things, and leaving any one out produces a failure that looks like something else:</p>\n\n<ol>\n  <li><strong>Forwarding.</strong> <code class=\"language-plaintext highlighter-rouge\">net.ipv4.ip_forward=1</code>. Obvious, and the source of bug number one below.</li>\n  <li><strong>NAT per uplink.</strong> Every interface that packets egress from needs its own masquerade rule:\n    <div class=\"language-sh highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>iptables <span class=\"nt\">-t</span> nat <span class=\"nt\">-A</span> POSTROUTING <span class=\"nt\">-o</span> eno1 <span class=\"nt\">-j</span> MASQUERADE\niptables <span class=\"nt\">-t</span> nat <span class=\"nt\">-A</span> POSTROUTING <span class=\"nt\">-o</span> eno2 <span class=\"nt\">-j</span> MASQUERADE\n</code></pre></div>    </div>\n    <p>This is the sneaky one. With failover there was only ever one active egress, so one masquerade rule was enough. The moment the multipath route starts sending some flows out the second uplink, those flows leave with un-rewritten internal source IPs, the upstream discards them, and you get the classic “half my connections just don’t work” symptom with nothing obviously wrong in the routes.</p>\n  </li>\n  <li><strong>The weighted multipath default route.</strong> The single default with weighted next-hops above. This governs new outbound connections from forwarded clients.</li>\n  <li><strong>Source-based policy routing.</strong> This is what the two named tables were reserved for:\n    <div class=\"language-sh highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>ip route replace default via 192.168.99.1 dev eno1 table dsl\nip route replace default via 192.168.1.1  dev eno2 table starlink\nip rule add from 192.168.99.2  table dsl\nip rule add from 192.168.1.248 table starlink\n</code></pre></div>    </div>\n  </li>\n</ol>\n\n<p>That fourth piece is not optional polish, and it is the part people skip when they think “I only want load balancing.” Multipath handles traffic <em>forwarded through</em> the router. But the router itself, and any connection arriving at one of its uplink IPs, must reply <em>out the same uplink the request came in on</em>. Without a source rule, the router’s own reply follows the multipath default and can leave via the other uplink. That is asymmetric routing, and stateful firewalls plus <code class=\"language-plaintext highlighter-rouge\">rp_filter</code> drop it on sight. The per-uplink tables and source rules are what make both links usable in both directions.</p>\n\n<h2 id=\"pinning-one-host-to-one-uplink\">Pinning One Host to One Uplink</h2>\n\n<p>We have a concrete reason for this, not a hypothetical one. One machine talks to a license server that verifies against our public IP, and for that verification to work we had to arrange a static IP with the provider. That static IP lives on the DSL line. So the machine that connects to the license server must always egress via DSL; if load balancing ever sent its verification traffic out Starlink, it would arrive from the wrong public address and the check would fail. Failover never had this problem because everything used DSL anyway. The moment traffic started spreading across both links, that one host had to be nailed down.</p>\n\n<p>An <code class=\"language-plaintext highlighter-rouge\">ip rule</code> with a <strong>lower priority number</strong> than the general source rules gets evaluated first:</p>\n\n<div class=\"language-sh highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>ip rule add from 10.42.100.50 table dsl priority 100\n</code></pre></div></div>\n\n<p>The mental model is a walk down the rule list in priority order, low number first. A host-pin rule at priority 100 catches that machine’s forwarded traffic and hands it straight to the <code class=\"language-plaintext highlighter-rouge\">dsl</code> table’s default route, before the weighted multipath default is ever consulted. Everything else falls through to <code class=\"language-plaintext highlighter-rouge\">main</code> and gets split by flow-hash. Rule, then route, then NAT: every layer has to name the same uplink, or the flow breaks somewhere in the middle.</p>\n\n<h2 id=\"the-two-bugs-that-broke-it-on-every-reboot\">The Two Bugs That Broke It on Every Reboot</h2>\n\n<p>Here is the honest order of events, because it is why I touched a working configuration at all. The trigger was not ambition, it was the power going out. We had planned maintenance work that cut the electricity, so AI-heimdall went down and came back up on its own. And when it came back, it did not come online the way I wanted: the office was not routing as expected. Only then, with the router misbehaving after an unplanned reboot, did I sit down and dig into what was actually persisting across boots and what was not. That is when the two hidden problems surfaced.</p>\n\n<p>Runtime <code class=\"language-plaintext highlighter-rouge\">ip</code>, <code class=\"language-plaintext highlighter-rouge\">iptables</code>, and <code class=\"language-plaintext highlighter-rouge\">sysctl</code> state is ephemeral, which I knew; what I did not expect was that my careful persistence setup was being defeated by two unrelated traps.</p>\n\n<h3 id=\"bug-1-etcsysctlconf-was-being-ignored\">Bug 1: <code class=\"language-plaintext highlighter-rouge\">/etc/sysctl.conf</code> was being ignored</h3>\n\n<p><code class=\"language-plaintext highlighter-rouge\">ip_forward</code> was set in <code class=\"language-plaintext highlighter-rouge\">/etc/sysctl.conf</code>, and after every boot it was back to <code class=\"language-plaintext highlighter-rouge\">0</code>, so no traffic was forwarded at all and the whole thing looked dead. The cause has nothing to do with routing. <code class=\"language-plaintext highlighter-rouge\">systemd-sysctl</code> reads <code class=\"language-plaintext highlighter-rouge\">/etc/sysctl.d/*.conf</code>, <code class=\"language-plaintext highlighter-rouge\">/run/</code>, and <code class=\"language-plaintext highlighter-rouge\">/usr/lib/</code>, but it only picks up <code class=\"language-plaintext highlighter-rouge\">/etc/sysctl.conf</code> through the conventional symlink <code class=\"language-plaintext highlighter-rouge\">/etc/sysctl.d/99-sysctl.conf -&gt; ../sysctl.conf</code>. On this box that symlink was missing, so the file was silently skipped at boot and I had been setting forwarding by hand without realising it. The fix is to stop relying on the symlink and drop a real file into the directory:</p>\n\n<div class=\"language-sh highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">printf</span> <span class=\"s1\">'net.ipv4.ip_forward=1\\n'</span> <span class=\"o\">&gt;</span> /etc/sysctl.d/99-heimdall.conf\n</code></pre></div></div>\n\n<h3 id=\"bug-2-the-dhcp-uplink-was-not-ready-when-the-route-script-ran\">Bug 2: the DHCP uplink was not ready when the route script ran</h3>\n\n<p>The second one is nastier because it is a race. The routing script runs as a systemd oneshot ordered <code class=\"language-plaintext highlighter-rouge\">After=network-online.target</code>. But the Starlink interface is DHCP and marked <code class=\"language-plaintext highlighter-rouge\">allow-hotplug</code> rather than <code class=\"language-plaintext highlighter-rouge\">auto</code>, and with ifupdown a hotplug interface is <strong>not</strong> waited on by <code class=\"language-plaintext highlighter-rouge\">network-online.target</code>. So the oneshot fired before the DHCP lease arrived, <code class=\"language-plaintext highlighter-rouge\">ip route ... nexthop via &lt;dhcp-gw&gt;</code> failed with “Nexthop has invalid gateway,” and because the script runs under <code class=\"language-plaintext highlighter-rouge\">set -e</code>, the whole setup aborted. A oneshot does not retry, so load balancing stayed dead until the next manual run.</p>\n\n<p>The fix is belt and suspenders:</p>\n\n<ul>\n  <li>Make the script <strong>wait</strong> for the DHCP uplink’s subnet route to appear before touching the multipath route (poll <code class=\"language-plaintext highlighter-rouge\">ip route show dev eno2 | grep &lt;subnet&gt;</code> with a timeout around 60 seconds), so the oneshot no longer races the lease.</li>\n  <li>Also invoke the same script from <code class=\"language-plaintext highlighter-rouge\">/etc/network/if-up.d/</code>, so it re-runs whenever the Starlink link comes up or renews its lease. This second hook matters for more than boot: a DHCP renewal happily reinstalls a plain default route that clobbers the multipath one, and the <code class=\"language-plaintext highlighter-rouge\">if-up.d</code> hook is what puts it back.</li>\n</ul>\n\n<h2 id=\"two-more-traps-worth-naming\">Two More Traps Worth Naming</h2>\n\n<p>While hardening the setup, two smaller things bit and are worth writing down.</p>\n\n<p><strong>Do not mix <code class=\"language-plaintext highlighter-rouge\">nft</code> and <code class=\"language-plaintext highlighter-rouge\">iptables</code> for NAT.</strong> If <code class=\"language-plaintext highlighter-rouge\">nft list table ip nat</code> warns that the table is “managed by iptables-nft, do not touch,” add masquerade rules with <code class=\"language-plaintext highlighter-rouge\">iptables -t nat</code>, not <code class=\"language-plaintext highlighter-rouge\">nft add rule</code>. Against an iptables-nft-managed chain, <code class=\"language-plaintext highlighter-rouge\">nft add rule ... masquerade</code> fails with a baffling “No such file or directory.” Persist the result with <code class=\"language-plaintext highlighter-rouge\">netfilter-persistent save</code>, which writes <code class=\"language-plaintext highlighter-rouge\">/etc/iptables/rules.v4</code>.</p>\n\n<p><strong><code class=\"language-plaintext highlighter-rouge\">ip rule add</code> never deduplicates.</strong> It inserts a new entry every time, even if an identical rule already exists, so re-running the setup script quietly accumulates duplicate rules. Delete-then-add makes it idempotent:</p>\n\n<div class=\"language-sh highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">while </span>ip rule del from 192.168.1.248 table starlink 2&gt;/dev/null<span class=\"p\">;</span> <span class=\"k\">do</span> :<span class=\"p\">;</span> <span class=\"k\">done\n</span>ip rule add from 192.168.1.248 table starlink\n</code></pre></div></div>\n\n<h2 id=\"verifying-each-link-independently\">Verifying Each Link Independently</h2>\n\n<p>The one command that made all of this debuggable is <code class=\"language-plaintext highlighter-rouge\">curl --interface</code>, which forces egress from a given source address, which hits that address’s source rule, which selects that uplink:</p>\n\n<div class=\"language-sh highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>curl <span class=\"nt\">--interface</span> 192.168.1.248 https://ifconfig.co   <span class=\"c\"># Starlink's public IP</span>\ncurl <span class=\"nt\">--interface</span> 192.168.99.2  https://ifconfig.co   <span class=\"c\"># DSL's public IP</span>\n</code></pre></div></div>\n\n<p>Two distinct WAN addresses come back, proving each path reaches the internet on its own. Combined with the Python dashboard from the <a href=\"/posts/dual-uplink-feb-2026/\">first article</a>, it is easy to confirm at a glance that traffic is actually spreading across both links instead of quietly collapsing onto one.</p>\n\n<hr />\n\n<p><em>Failover was the safe version: one link at a time, automatic backup, nothing clever. Load balancing is the version that actually uses the capacity we pay for, and it demands that forwarding, per-uplink NAT, the weighted multipath route, and source-based rules all agree on the same uplink for every packet. The routing was the easy part. Making it survive a reboot came down to a missing sysctl symlink and a DHCP interface that was not ready when the script assumed it was. The durable setup is a real file in <code class=\"language-plaintext highlighter-rouge\">/etc/sysctl.d/</code>, NAT via <code class=\"language-plaintext highlighter-rouge\">netfilter-persistent</code>, and a routing script driven by both a self-contained systemd oneshot that waits for the DHCP uplink and an <code class=\"language-plaintext highlighter-rouge\">if-up.d</code> hook that re-applies on every renewal. Belt and suspenders, because DHCP will erase your multipath the moment you stop putting it back.</em></p>",
      "date_published": "2026-07-12T00:00:00+00:00",
      
      "date_modified": "2026-07-12T00:00:00+00:00",
      
      "authors": [ { "name": "Stephan Bökelmann" } ],
      "tags": ["networking","linux"]
    },
    
    {
      "id": "https://maxclerkwell.tech/posts/from-bitstream-to-idea-inverse-fpga-guide-july-2026/",
      "url": "https://maxclerkwell.tech/posts/from-bitstream-to-idea-inverse-fpga-guide-july-2026/",
      "title": "From Bitstream to Idea: An Inverse Guide to FPGAs",
      "summary": "The ghdl-yosys-nextpnr toolchain for an iCE40, walked backward: starting from a blinking LED and tracing every transformation back to the idea that caused it.",
      "content_html": "<p>Most FPGA tutorials, including <a href=\"/posts/fpga-blinky-vhdl-icestick-may-2026/\">my own</a>, go forward: idea, block diagram, VHDL, simulate, synthesize, place and route, flash, done. That order makes sense for building something. It makes less sense for understanding the toolchain, because forward explanations tend to gloss over exactly the step you are standing on. You are told “now run synthesis” and you run it, and the tool produces a file, and you move to the next command.</p>\n\n<p>This post goes the other direction. I want to start at the thing you can actually observe, an LED blinking on a <a href=\"https://www.latticesemi.com/icestick\">Lattice iCEstick</a>, and walk backward through every transformation until we arrive at the idea that caused it. Every program in this chain is a transformer: it takes a dataset and a configuration, and it produces a different dataset. Nothing in the chain does anything else. Once you see it that way, the toolchain stops being a sequence of magic incantations and becomes a pipeline of well-defined functions.</p>\n\n<p>If you have not read <a href=\"/posts/wtf-are-fpgas-june-2026/\">WTF are FPGAs</a>, that post covers the architecture: logic cells, routing fabric, why FPGAs exist at all. This one assumes you know roughly what an FPGA is and cares only about what happens between your idea and the moment it becomes silicon behavior.</p>\n\n<hr />\n\n<h2 id=\"the-chain-backward\">The Chain, Backward</h2>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>Running gateware on the iCEstick\n  ^ iCE40 self-boot (chip reads flash as SPI master on power-up)\nBitstream sitting in external SPI flash\n  ^ iceprog\nBitstream (.bin)\n  ^ icepack\nPlaced &amp; routed design (.asc)\n  ^ nextpnr-ice40\nTechnology-mapped netlist (JSON)\n  ^ yosys (synth_ice40)\nGeneric netlist (Verilog, written by GHDL)\n  ^ ghdl synth (--out=verilog)\nAnalyzed design units in a library\n  ^ ghdl -a (analyze)\nVHDL code\n  ^ a human, not a program\nBlock diagram\n  ^ a human, not a program\nFlip-flops, logic gates, wires: the idea\n</code></pre></div></div>\n\n<p>Two of those arrows are not tool invocations. That is not an oversight, it is the point: every arrow in this chain is a transformer with a configuration, except the two closest to the idea. I will come back to that.</p>\n\n<p>A note on two choices I made deliberately, both worth calling out before we dive in:</p>\n\n<p>First, GHDL and yosys can talk to each other two ways: the <code class=\"language-plaintext highlighter-rouge\">ghdl-yosys-plugin</code> hands GHDL’s internal representation to yosys in-process, no file ever touches disk between them, or you run <code class=\"language-plaintext highlighter-rouge\">ghdl synth</code> as its own step and get a real Verilog file that yosys reads with <code class=\"language-plaintext highlighter-rouge\">read_verilog</code>, same as any other Verilog source. The plugin route is what you would actually use day to day, it is one invocation and one fewer moving part. I am walking through the <code class=\"language-plaintext highlighter-rouge\">ghdl synth</code> route instead, because it produces an artifact you can open, inspect, and reason about at each stage. Didactically, a file beats an in-process handoff you cannot see.</p>\n\n<p>Second, this whole chain uses VHDL, not Verilog, and that is not a style preference. In safety-critical domains, avionics under DO-254, industrial systems under IEC 61508, automotive under ISO 26262, the certification process cares about exactly the kind of thing VHDL enforces at the language level: strong, static typing that catches a huge class of accidental-width or accidental-sign bugs at analysis time rather than in simulation or, worse, in hardware. Verilog’s weaker typing does not disqualify it outright, but it pushes more of that burden onto coding-standard compliance and tooling, which is a harder thing to argue in front of an auditor than “the compiler rejected it.” That is also why the strict, incremental analyze step described below is not academic trivia: a design flow with an auditable paper trail per compilation unit is precisely what these certification regimes want to see.</p>\n\n<hr />\n\n<h2 id=\"1-the-running-gateware-and-what-flashing-actually-means\">1. The Running Gateware, and What “Flashing” Actually Means</h2>\n\n<p>The LED is blinking because the iCE40 is currently holding a specific pattern in its configuration SRAM: which routing multiplexers are closed, what each LUT’s truth table is, what each flip-flop’s reset behavior is. That pattern is volatile. Remove power and it is gone.</p>\n\n<p>This is the detail that trips people up: “flashing an FPGA” does not mean writing directly into the chip’s active configuration memory, at least not permanently. On the iCEstick, <code class=\"language-plaintext highlighter-rouge\">iceprog</code> writes the bitstream into an external SPI flash chip on the board, using the second channel of the onboard FTDI dual-UART/SPI chip as a SPI master. While it does this, it holds the iCE40 in reset, so the two devices are not fighting over the same SPI bus.</p>\n\n<p>On every power-up, the iCE40 does something you do not initiate: it pulls its <code class=\"language-plaintext highlighter-rouge\">CDONE</code> pin low, becomes a SPI master itself, reads the bitstream out of that same external flash, and writes it byte by byte into its own configuration cells. Once that finishes, <code class=\"language-plaintext highlighter-rouge\">CDONE</code> goes high and your design starts running. “Flashing” means feeding the source the chip will read from at its next boot, not writing to the chip directly.</p>\n\n<p><strong>Transformer:</strong> <code class=\"language-plaintext highlighter-rouge\">iceprog</code>\n<strong>Input:</strong> bitstream (<code class=\"language-plaintext highlighter-rouge\">.bin</code>), flash configuration (SPI mode, flash offset)\n<strong>Output:</strong> gateware on the board’s external flash, loaded into the FPGA on the next boot\n<strong>Implicit requirement:</strong> exclusive access to the FTDI channel; under Linux, udev rules that let a non-root user talk to the chip at all (see the permissions section in the <a href=\"/posts/fpga-blinky-vhdl-icestick-may-2026/\">Zero to One VHDL post</a> if you have not set that up yet).</p>\n\n<hr />\n\n<h2 id=\"2-the-bitstream-and-why-icepack-cannot-fail-on-resource-shortage\">2. The Bitstream, and Why icepack Cannot Fail on Resource Shortage</h2>\n\n<p>Before <code class=\"language-plaintext highlighter-rouge\">iceprog</code> had anything to write, something had to produce the <code class=\"language-plaintext highlighter-rouge\">.bin</code> file. That is <code class=\"language-plaintext highlighter-rouge\">icepack</code>, and it does exactly one thing: it takes the human-readable <code class=\"language-plaintext highlighter-rouge\">.asc</code> file that nextpnr produced and serializes it into the binary format the iCE40’s configuration loader expects.</p>\n\n<p><code class=\"language-plaintext highlighter-rouge\">icepack</code> makes no design decisions. The <code class=\"language-plaintext highlighter-rouge\">.asc</code> file already contains, for every tile in the chip, whether that tile is used and exactly what its configuration bits should be: logic cell truth tables, DFF reset-type bits, routing mux selections. <code class=\"language-plaintext highlighter-rouge\">icepack</code>’s job is to know, for a given iCE40 device (a <code class=\"language-plaintext highlighter-rouge\">1k</code>, an <code class=\"language-plaintext highlighter-rouge\">8k</code>, whichever), the exact bit position in the linear output stream that corresponds to each tile’s configuration cell, a mapping that the IceStorm project reverse-engineered because Lattice never published it. It writes those bits at the right positions, pads unused regions with the device’s required defaults, and appends the sync words and CRC that the chip’s own bootloader expects to see.</p>\n\n<p>This is why “not enough logic cells” can never be an <code class=\"language-plaintext highlighter-rouge\">icepack</code> error. By the time <code class=\"language-plaintext highlighter-rouge\">icepack</code> runs, every cell already has a confirmed physical home. If the design did not fit, the failure happened one step earlier.</p>\n\n<p><strong>Transformer:</strong> <code class=\"language-plaintext highlighter-rouge\">icepack</code>\n<strong>Input:</strong> placed and routed design (<code class=\"language-plaintext highlighter-rouge\">.asc</code>, text)\n<strong>Output:</strong> bitstream (<code class=\"language-plaintext highlighter-rouge\">.bin</code>, binary)\n<strong>Note:</strong> pure format conversion, no capacity checking, no optimization.</p>\n\n<hr />\n\n<h2 id=\"3-placed-and-routed-design-where-nextpnr-decides-location-not-identity\">3. Placed and Routed Design: Where nextpnr Decides Location, Not Identity</h2>\n\n<p>Here is the step people most often describe imprecisely, myself included until I actually traced through it: <strong>nextpnr does not decide what a piece of logic becomes. It decides where a piece of logic goes.</strong></p>\n\n<p>By the time nextpnr runs, every cell in the design is already a concrete iCE40 primitive: <code class=\"language-plaintext highlighter-rouge\">SB_LUT4</code>, some flavor of <code class=\"language-plaintext highlighter-rouge\">SB_DFF</code>, <code class=\"language-plaintext highlighter-rouge\">SB_CARRY</code>, <code class=\"language-plaintext highlighter-rouge\">SB_IO</code>. That decision was made one step earlier, in yosys. nextpnr’s job is placement and routing:</p>\n\n<ul>\n  <li><strong>Placement</strong>: assign each of those already-typed cells to one specific physical slot in the chip’s grid. This runs as a simulated-annealing-style search: an initial (often rough) placement, then repeated swaps of cell positions, scored by a cost function built mostly from estimated wire length between connected cells plus a timing model for critical paths. Worse swaps are accepted early on and rejected more strictly as the search converges, which is what keeps it from getting stuck in a bad local placement.</li>\n  <li><strong>Routing</strong>: for every net, find an actual path through the chip’s programmable interconnect (the “highways” from the WTF are FPGAs post) that connects the source to every sink. nextpnr uses a PathFinder-style negotiated-congestion router: paths are found somewhat independently first, then when two nets want the same wire segment, that segment’s cost goes up and the losing net gets rerouted in a later iteration, until nothing conflicts.</li>\n</ul>\n\n<p>Two inputs feed this that are easy to forget about because they are not files you generate, they are files you write by hand:</p>\n\n<ul>\n  <li><strong>The PCF (Pin Constraint File)</strong>: maps your top-level VHDL port names to physical chip pins, taken from the board’s schematic. Without it, nextpnr has no way to know that your <code class=\"language-plaintext highlighter-rouge\">led</code> signal has to land on the specific pin wired to an actual LED.</li>\n  <li><strong>The chip database</strong>: an embedded description of every physical resource in the specific iCE40 part (which LUTs, DFFs, BRAM, IO cells exist and where), which is why you tell nextpnr <code class=\"language-plaintext highlighter-rouge\">--hx1k --package tq144</code> and not just “iCE40.”</li>\n</ul>\n\n<p>If the design genuinely does not fit, this is where it fails, with an error like “failed to place cell” or “out of bels of type ICESTORM_LC.” Nothing downstream of this step can produce that failure, because nothing downstream is checking capacity anymore.</p>\n\n<p><strong>Transformer:</strong> <code class=\"language-plaintext highlighter-rouge\">nextpnr-ice40</code>\n<strong>Input:</strong> technology-mapped netlist (JSON), PCF, chip architecture database\n<strong>Output:</strong> placed and routed design (<code class=\"language-plaintext highlighter-rouge\">.asc</code>)\n<strong>Implicit requirement:</strong> the JSON must already consist entirely of iCE40 primitives; nextpnr does not perform technology mapping.</p>\n\n<hr />\n\n<h2 id=\"4-technology-mapped-netlist-where-yosys-decides-what-everything-becomes\">4. Technology-Mapped Netlist: Where yosys Decides What Everything Becomes</h2>\n\n<p>This is the step where “identity” gets fixed. <code class=\"language-plaintext highlighter-rouge\">synth_ice40</code>, the yosys script for this target, does both generic logic synthesis and technology mapping in one pass, and the order matters for understanding what “netlist” even means at each stage.</p>\n\n<p>yosys reads the design as an ordinary Verilog file, via <code class=\"language-plaintext highlighter-rouge\">read_verilog</code>, into its own internal representation, RTLIL. That Verilog file is the one <code class=\"language-plaintext highlighter-rouge\">ghdl synth</code> wrote in the previous step (more on that below); yosys has no idea, and does not care, that it originated as VHDL. <code class=\"language-plaintext highlighter-rouge\">process</code>-derived logic is not yet a netlist at this point in the RTLIL sense yosys means by “netlist”: it is still procedural, <code class=\"language-plaintext highlighter-rouge\">if</code>/<code class=\"language-plaintext highlighter-rouge\">case</code> structures, sequential signal assignments, just expressed in Verilog syntax rather than VHDL. The <code class=\"language-plaintext highlighter-rouge\">proc</code> pass converts that procedural description into an actual netlist: <code class=\"language-plaintext highlighter-rouge\">if</code>/<code class=\"language-plaintext highlighter-rouge\">case</code> becomes <code class=\"language-plaintext highlighter-rouge\">$mux</code> cells, clocked assignments become <code class=\"language-plaintext highlighter-rouge\">$dff</code> cells with a mux feeding their input. This is the step where “prozedurales Denken,” procedural thinking, becomes pure combinational logic plus registers.</p>\n\n<p>From there: generic optimization (constant folding, dead logic removal), then <code class=\"language-plaintext highlighter-rouge\">techmap</code> breaks complex generic cells down to primitive gates, then <code class=\"language-plaintext highlighter-rouge\">abc</code> (a separate logic synthesis tool yosys shells out to) does the actual LUT-mapping heuristic, mapping the boolean network onto 4-input LUTs because that is what an iCE40 logic cell is. A second <code class=\"language-plaintext highlighter-rouge\">techmap</code> pass with the iCE40-specific cell library turns those generic LUT/DFF results into <code class=\"language-plaintext highlighter-rouge\">SB_LUT4</code>, the correct <code class=\"language-plaintext highlighter-rouge\">SB_DFF</code> variant, <code class=\"language-plaintext highlighter-rouge\">SB_CARRY</code> for arithmetic carry chains. <code class=\"language-plaintext highlighter-rouge\">iopadmap</code> inserts <code class=\"language-plaintext highlighter-rouge\">SB_IO</code> cells at every top-level port, because the chip needs explicit IO buffer cells that are not implicit in a generic design.</p>\n\n<p>After this point, the set of cell types in the design is frozen. nextpnr cannot introduce a new primitive type, cannot decide a signal should have been an <code class=\"language-plaintext highlighter-rouge\">SB_CARRY</code> instead of plain LUT logic. All of that identity work happened here.</p>\n\n<p><strong>Transformer:</strong> <code class=\"language-plaintext highlighter-rouge\">yosys</code> (<code class=\"language-plaintext highlighter-rouge\">synth_ice40</code>)\n<strong>Input:</strong> generic netlist (Verilog file written by GHDL), iCE40 cell library\n<strong>Output:</strong> technology-mapped netlist, written out as JSON via <code class=\"language-plaintext highlighter-rouge\">write_json</code>\n<strong>Note:</strong> synthesis and technology mapping are one pass here, not two separable stages.</p>\n\n<hr />\n\n<h2 id=\"5-the-generic-netlist-what-ghdl-synth-actually-produces\">5. The Generic Netlist: What ghdl synth Actually Produces</h2>\n\n<p><code class=\"language-plaintext highlighter-rouge\">ghdl synth --std=08 --out=verilog &lt;unit&gt; -e &lt;top&gt;</code> takes the already-analyzed design units sitting in the library (see the next section), elaborates them internally, picking the top-level, binding generics, resolving hierarchy, and then walks the fully elaborated design, translating every process and concurrent signal assignment into gates, muxes, and flip-flops, printing the result as a structural Verilog file. That file is a completely ordinary artifact: you can open it, diff it, grep it, hand it to any tool that reads Verilog, not just yosys. <code class=\"language-plaintext highlighter-rouge\">--out=vhdl</code> produces the equivalent as a VHDL netlist instead, and <code class=\"language-plaintext highlighter-rouge\">--out=dot</code> produces a Graphviz graph for visualization only, not something synthesizable.</p>\n\n<p>Note that elaboration itself is not a separate, user-facing step in this chain: <code class=\"language-plaintext highlighter-rouge\">ghdl synth</code> resolves hierarchy and binds generics as an internal part of its own run, on its way to producing the netlist. It is worth knowing that GHDL also exposes <code class=\"language-plaintext highlighter-rouge\">ghdl -e</code> as a standalone command elsewhere (mainly for producing a runnable simulation executable via <code class=\"language-plaintext highlighter-rouge\">ghdl -r</code>), but nothing downstream of <code class=\"language-plaintext highlighter-rouge\">-a</code> in this synthesis path invokes it separately.</p>\n\n<p>This is also where the ghdl-yosys-plugin, mentioned in the note above the chain diagram, does the same underlying work differently: instead of printing a file, the plugin calls the same internal netlisting logic and hands the result to yosys’s RTLIL builder directly, in memory, inside a single yosys invocation (<code class=\"language-plaintext highlighter-rouge\">ghdl_synth</code> in a yosys script). No <code class=\"language-plaintext highlighter-rouge\">.v</code> file ever exists on disk in that path. Functionally equivalent to what is described here; just no intermediate artifact to inspect.</p>\n\n<p><strong>Transformer:</strong> <code class=\"language-plaintext highlighter-rouge\">ghdl synth</code>\n<strong>Input:</strong> analyzed design units from the library, chosen top-level, generic values\n<strong>Output:</strong> generic, technology-neutral netlist, as a Verilog file (<code class=\"language-plaintext highlighter-rouge\">--out=verilog</code>)\n<strong>Implicit requirement:</strong> every unit the top-level references transitively must already be analyzed and present in the library.</p>\n\n<hr />\n\n<h2 id=\"6-analyzed-design-units-what-analyze-actually-checks\">6. Analyzed Design Units: What analyze Actually Checks</h2>\n\n<p><code class=\"language-plaintext highlighter-rouge\">ghdl -a</code> produces, per design unit, an entry in the library’s index and an object representation of that unit’s internal typed tree. It does two things and exactly two things: type-checking (does this unit’s code make sense on its own, given what it declares and what it imports via <code class=\"language-plaintext highlighter-rouge\">use</code>), and interface recording (what ports, generics, and declarations does this unit expose, so that later steps, like <code class=\"language-plaintext highlighter-rouge\">ghdl synth</code>, can reference it correctly).</p>\n\n<p>What it explicitly does not do: resolve hierarchy, know about a top-level, or produce any kind of gate-level structure. A <code class=\"language-plaintext highlighter-rouge\">process</code> block at this stage is still exactly the VHDL you wrote, not yet touched by anything resembling synthesis. Put in software terms, <code class=\"language-plaintext highlighter-rouge\">ghdl -a</code> is closer to a compiler than a linker: it turns each source file into an independent, type-checked unit, the VHDL equivalent of an object file, without knowing or caring what the final program looks like. Resolving that final shape, picking a top-level, binding generics, wiring instances together, is <code class=\"language-plaintext highlighter-rouge\">ghdl synth</code>’s job, one step up in this chain.</p>\n\n<p>The reason analyze exists as its own step rather than being folded into <code class=\"language-plaintext highlighter-rouge\">ghdl synth</code> is that the library it produces is reusable. If none of the already-analyzed units change, you can point <code class=\"language-plaintext highlighter-rouge\">ghdl synth</code> at an entirely different top-level and skip re-analysis. And if you change one unit, only that unit needs re-analysis; GHDL tracks this through dependency and timestamp information in the library’s index file. This is the same reasoning behind incremental compilation in any language with a module system: analyze once, reuse the library across as many downstream runs as you need, as long as the pieces did not move.</p>\n\n<p>One practical consequence, and this is the kind of thing that only becomes obvious once you try to break it: if you analyze files out of dependency order, a package’s body before something that imports it, GHDL rejects the later file with “unit … not found,” because the referenced unit is not in the library index yet. GHDL will not reorder your build for you.</p>\n\n<p><strong>Transformer:</strong> <code class=\"language-plaintext highlighter-rouge\">ghdl -a</code>\n<strong>Input:</strong> VHDL source for one or more design units\n<strong>Output:</strong> typed, checked units recorded in the library\n<strong>Implicit requirement:</strong> dependency order; a package must be analyzed before anything that uses it.</p>\n\n<hr />\n\n<h2 id=\"7-vhdl-code-the-first-non-program-step\">7. VHDL Code: The First Non-Program Step</h2>\n\n<p>Every step above this line is a program with a configuration. This one is not. Going from a block diagram to VHDL is a design act performed by a person, and it is worth pausing on because it is structurally different from everything else in the chain: there is no transformer here, no dataset-plus-config producing another dataset. There is a person translating an idea into a formal language, and that translation is where actual engineering judgment lives.</p>\n\n<p>It also carries an implicit requirement none of the automated steps enforce until much later: not every construct that simulates is synthesizable. <code class=\"language-plaintext highlighter-rouge\">wait for 10 ns</code> runs fine in a testbench and means nothing to <code class=\"language-plaintext highlighter-rouge\">synth_ice40</code>. That boundary, what is synthesizable VHDL versus what is merely simulatable VHDL, is not checked at the point you write the code. It surfaces later, at analyze or synthesis time, as an error or a silent misunderstanding of what hardware you actually described.</p>\n\n<hr />\n\n<h2 id=\"8-the-block-diagram-and-the-idea-underneath-it\">8. The Block Diagram, and the Idea Underneath It</h2>\n\n<p>Below VHDL is the block diagram: flip-flops, logic gates, wires, arranged conceptually before a single line of code exists. Like the step above it, this is not automated, and “configuration” here means something different: it means domain knowledge. Understanding what needs a clock edge and what is purely combinational. Understanding metastability well enough to know when a signal is crossing clock domains and needs synchronization. Deciding, before any tool is involved, that a given function actually belongs in hardware at all rather than in software running on a microcontroller sitting next to the FPGA.</p>\n\n<p>This is the actual root of the whole chain. None of the seven transformers above it can correct a bad decision made here. They can only execute, with complete fidelity, whatever was decided at this step.</p>\n\n<hr />\n\n<h2 id=\"why-go-backward\">Why Go Backward</h2>\n\n<p>Going forward, you start with intent and watch it get realized. Going backward, you start with the physical fact, an LED blinking on a piece of hardware you can hold, and you ask what had to be true one step earlier for that fact to exist. I found that more honest about where the actual decisions get made. Six of the eight steps in this chain are mechanical: given the same input and configuration, <code class=\"language-plaintext highlighter-rouge\">icepack</code> or <code class=\"language-plaintext highlighter-rouge\">nextpnr</code> will always produce the same output. The two steps that are not mechanical, block diagram to VHDL and idea to block diagram, are the only places where the design could have gone differently. Everything downstream of those two steps is just careful, deterministic execution.</p>\n\n<p>If you want to see this chain from the other direction, with actual VHDL, a testbench, and a real LED blinking at the end, that is <a href=\"/posts/fpga-blinky-vhdl-icestick-may-2026/\">Zero to One: VHDL and a Lattice iCEstick</a>. And if you want the architectural picture this post assumes, what a LUT is, why the routing fabric matters, why FPGAs exist at all, that is <a href=\"/posts/wtf-are-fpgas-june-2026/\">WTF are FPGAs</a>.</p>",
      "date_published": "2026-07-08T00:00:00+00:00",
      
      "date_modified": "2026-07-08T00:00:00+00:00",
      
      "authors": [ { "name": "Stephan Bökelmann" } ],
      "tags": ["fpga","open-source"]
    },
    
    {
      "id": "https://maxclerkwell.tech/posts/correlation-causation-overton-window-july-2026/",
      "url": "https://maxclerkwell.tech/posts/correlation-causation-overton-window-july-2026/",
      "title": "Correlation, Causation, and the Overton Window That Only Shrinks",
      "summary": "Everyone knows the first half of the aphorism. The second half is where the actual thinking happens, and I think the reflex to stop at the first half is doing something worse than being lazy.",
      "content_html": "<p>If you spend enough time around superficially intellectual people, you will have heard the phrase. Someone points at a pattern, someone else says “correlation doesn’t imply causation,” and the conversation ends there, usually with a small, satisfied smile. When I picture the moment, there is a specific face attached to it: the smug pseudo-scientist, chin slightly raised, having just deployed the one piece of statistics they remember from a first-year course as a conversation-ending device rather than a conversation-starting one.</p>\n\n<p>What bothers me is not the sentence. The sentence is true. What bothers me is that people only ever remember the first half of it, and that this is not an isolated case. It happens to aphorisms constantly, and it is almost never innocent.</p>\n\n<p>“The customer is always right” is missing its second clause: <em>in matters of taste</em>. The original point was narrow (do not argue with someone about what they like) and got flattened into a blank check for entitlement. “Blood is thicker than water” is reputed to descend from “the blood of the covenant is thicker than the water of the womb,” which, if true, means the proverb originally argued the <em>opposite</em> of what everyone now uses it for: chosen loyalty over accident of birth, not the other way around. “Curiosity killed the cat” drops “but satisfaction brought it back,” which turns a warning into a complete thought about risk and reward instead of a blunt instrument for shutting down questions. “Money is the root of all evil” quietly deletes “the love of,” a difference Paul actually cared about making. In every case, the second half is where the nuance lives, and the second half is exactly the part that gets dropped when people want to use the phrase as a stop sign rather than a signpost.</p>\n\n<p>I think “correlation doesn’t imply causation” belongs on this list, and I want to use this article to make the case for its missing half.</p>\n\n<h2 id=\"what-people-are-actually-doing-when-they-say-it\">What people are actually doing when they say it</h2>\n\n<p>My honest belief about the motivation behind this specific phrase, deployed in the specific reflexive way it usually is, is that the person saying it has something to protect. Not always consciously.</p>\n\n<p>Sometimes it is closer to a Freudian slip than a statistical objection: a phrase produced under mild pressure that reveals more about the speaker’s discomfort than about the data.</p>\n\n<p>Here is the pattern I mean. Every time you use the lab equipment, something breaks the next day. You mention this. The response: “correlation doesn’t imply causation.” Technically correct.</p>\n\n<p>It is also, functionally, an attempt to make sure nobody keeps pulling on that thread. The phrase is not being used to advance an inquiry into what is actually going on. It is being used to close one down.</p>\n\n<p>It marks a boundary: this direction of thought is uncomfortable to me, and I would like you to stop walking in it.</p>\n\n<p>Tabea (<a href=\"https://x.com/tabeatheunicorn\">@tabeatheunicorn</a>) and I have talked about this a lot, especially in the context of our alma mater, because a university is precisely the place where this move should never work. A university is supposed to be a sandbox for ideas. As a researcher you cannot actually <em>do</em> very much to the world directly. Mostly you can write a paper, and maybe someone reads it, and maybe they don’t. Given how limited the actual leverage is, putting artificial shackles on your own thinking, inside the one institution explicitly built to remove them, seems like a strange thing to volunteer for.</p>\n\n<p>So: should someone be allowed to think outside the current boundaries of the status quo? Probably.</p>\n\n<p>I want to approach that question from two directions at once, because I think both of them point at the same underlying structure.</p>\n\n<h2 id=\"first-angle-the-shrinking-window-is-a-design-property-not-an-accident\">First angle: the shrinking window is a design property, not an accident</h2>\n\n<h3 id=\"the-setup\">The setup</h3>\n\n<p>Imagine a world with one rule: say anything you want within a certain boundary, and the instant you say something outside it, you are eliminated from the conversation permanently. The boundary itself is set democratically: whatever more than half the population currently finds acceptable defines the edge. To make this tractable, assume outrageousness can be projected onto a single axis. (If the space is actually multidimensional, assume everyone uses something like a norm to collapse it onto one axis anyway. It does not change the conclusion.)</p>\n\n<p>At first glance this looks fair. Democratic input, majority rule, the boundary reflects genuine consensus.</p>\n\n<h3 id=\"why-it-can-only-shrink\">Why it can only shrink</h3>\n\n<p>Here is the catch. In a system where speaking outside the line gets you instantly and permanently removed, the window can only shrink or stay the same. It can never expand. Nobody is ever rewarded for testing the edge, because testing the edge is fatal. Growth is structurally impossible.</p>\n\n<p>Will it actually stay the same, though? Probably not, and here is the mechanism, step by step:</p>\n\n<ol>\n  <li>Model people’s expressions as roughly Gaussian along that one axis. Assume people mostly stick to their own strategy of speech, occupying roughly the same bin over time.</li>\n  <li>They do adjust, though, based on their personality and their neighboring bins. Someone easily moved by social pressure drifts toward the mean. Someone who is not sometimes drifts further out, looking for a niche where the conversation is still interesting.</li>\n  <li>If they drift too far into the tail, they get eliminated. But elimination is not instant. There is a delay: something has to be said first, and the consequence arrives after a lag.</li>\n  <li>So the people in the neighboring bin do not see their neighbor vanish the moment they speak. They notice only after the delay has passed.</li>\n  <li>When they do notice, they know their now-absent neighbor was sitting near the edge. Conversations are messy, and it is genuinely hard to know in real time exactly which sentence is the one that gets you removed. The safe strategy is to hedge inward, to move a little closer to the center, just in case.</li>\n</ol>\n\n<p>Multiply that inference across a whole population making the same calculation repeatedly, and you get a standard deviation that compresses over time. The tails get pruned faster than the center refills them, because refilling the tail requires someone brave (or reckless) enough to occupy it again, right after the population was just handed a fresh, painful reason not to.</p>\n\n<p>The Overton window, under this model, does not stay put. It contracts. The only thing that could counteract this is a supply of what I’ll call martyric psychopaths: people sufficiently indifferent to elimination that they keep re-occupying the tail regardless of the cost. Absent that supply, the window is a one-way ratchet, and it only ratchets in one direction.</p>\n\n<h2 id=\"second-angle-the-tunnel-effect-and-the-cruelty-of-a-fixed-boundary\">Second angle: the tunnel effect and the cruelty of a fixed boundary</h2>\n\n<p>Here is the second angle, and I will be upfront that I do not have an airtight argument for it. Treat it as a thought experiment and decide for yourself how far to trust it.</p>\n\n<h3 id=\"the-model\">The model</h3>\n\n<p>Model the Overton window as an infinitely deep potential well, and the distribution of things people actually say as an electron sitting in the well’s ground state. The naive read: since the well is infinitely deep, nobody’s expression ever ends up outside it.</p>\n\n<p>That is not actually true, and the reason it is not true is instructive.</p>\n\n<p>The quantum mechanical analogy is the tunnel effect. The probability of finding the electron outside the well is strongly suppressed the further out you go, but it never goes to exactly zero, no matter how deep or how sharp the walls are.</p>\n\n<p>Translate that back: even with a completely fixed, unmoving boundary of acceptable speech, there will always be some nonzero density of people saying things over the line. Not because the boundary moved. Because the underlying distribution has a tail that never fully vanishes, no matter how steep you make the walls.</p>\n\n<h3 id=\"why-that-makes-elimination-a-cruelty-not-a-filter\">Why that makes elimination a cruelty, not a filter</h3>\n\n<p>Given that this seems true regardless of where you draw the line, the actual question becomes: is it wise to eliminate the people who show up in that tail?</p>\n\n<p>If the tail is a structural, permanent feature of any population large enough to matter, and you eliminate everyone who falls into it as a matter of policy, you have not designed a system with an edge case. You have designed a system with a permanent, self-renewing supply of victims.</p>\n\n<p>That is a design property, not a bug you can patch out. It is a form of cruelty built into the architecture, aimed at people who were never doing anything other than what the distribution guarantees someone will eventually do.</p>\n\n<h2 id=\"the-presupposition-hiding-underneath-both-thought-experiments\">The presupposition hiding underneath both thought experiments</h2>\n\n<p>Both of these thought experiments share an implicit premise I want to name directly: nothing outside the currently known can ever be useful or good. That is the assumption doing all the quiet work.</p>\n\n<p>If you actually believed that everything worth knowing is already inside the window, elimination at the boundary would be costless. You would not be losing anything by pruning the tails, because the tails, by definition, contain nothing of value.</p>\n\n<p>I do not believe that premise. Showing up to a university is itself an implicit rejection of it. Attending one means you believe there is something out there, currently unknown, worth discovering, worth the risk of moving toward. That is the whole justification for the institution’s existence.</p>\n\n<p>Not moving forward is not a neutral, stable resting state. It is the precondition for falling behind, because everyone else’s window is either expanding or contracting too, and a system that structurally cannot expand is, relative to one that can, already losing.</p>\n\n<h2 id=\"refining-the-sentence\">Refining the sentence</h2>\n\n<p>So here is my refinement of the phrase this whole article is built around: not every correlation implies causation, but every correlation deserves to be investigated.</p>\n\n<p>Judea Pearl makes a version of this argument with far more rigor than I can bring to it here. When two variables move together, there are only a small number of structural explanations available:</p>\n\n<ol>\n  <li><strong>X causes Y.</strong></li>\n  <li><strong>Y causes X</strong> (reverse causation, easy to miss if you assume the arrow only points one way).</li>\n  <li><strong>A third variable Z causes both</strong> (a confounder).</li>\n  <li><strong>Selection bias or measurement artifact</strong> (the correlation is a property of how you looked, not of what is actually there).</li>\n  <li><strong>Coincidence</strong> (with enough variables and enough tests, some correlations are guaranteed to appear by chance alone, see Tyler Vigen’s <em>Spurious Correlations</em> for the reductio ad absurdum of this).</li>\n</ol>\n\n<p>The phrase “correlation doesn’t imply causation,” said and left there, correctly rules out jumping straight to option one. What it does not do, and what the smug delivery conveniently obscures, is rule out options two through five.</p>\n\n<p>So how do you actually test for which of the five you are looking at, in real life, without a physics lab and a controlled universe? A few concrete moves, roughly in order of how much they cost you:</p>\n\n<ul>\n  <li><strong>Check temporal order first.</strong> If Y consistently precedes X, X cannot be causing Y. This kills more bad hypotheses than people expect, and it costs nothing but a careful look at your own data.</li>\n  <li><strong>Look for a plausible mechanism.</strong> Not proof, but a candidate story for <em>how</em> X would produce Y, mechanically. If you cannot even sketch one, that is evidence the correlation is closer to option 4 or 5 than 1 or 2.</li>\n  <li><strong>Ask whether the effect scales with the cause.</strong> A genuine causal relationship usually shows some kind of dose-response pattern: more X, more Y, in a way that tracks. A spurious correlation usually does not survive this kind of stress test.</li>\n  <li><strong>Look for a confounder deliberately</strong>, rather than waiting for one to be pointed out to you. What third thing could plausibly move both variables at once? If you can name a candidate, control for it statistically or by design and see if the correlation survives.</li>\n  <li><strong>Find or construct a natural experiment.</strong> Something in the world occasionally varies X for reasons that have nothing to do with Y (an instrumental variable, a policy change, a discontinuity). If the correlation still holds when X moves for an unrelated reason, that is real evidence for causation, not just correlation.</li>\n  <li><strong>Run the actual intervention if you can.</strong> Change X directly and observe whether Y moves. This is the randomized controlled trial, the gold standard, and it is expensive and often impossible outside a lab, which is exactly why the four steps above matter: they are what you do when you cannot afford this one.</li>\n  <li><strong>Replicate.</strong> If the pattern only shows up once, in one dataset, treat option 5 as the leading hypothesis until someone finds it again independently.</li>\n</ul>\n\n<p>None of this is exotic. It is closer to the Bradford Hill criteria used in epidemiology than to anything requiring a PhD in causal inference. The point is that it is <em>work</em>, and the phrase “correlation doesn’t imply causation,” used as a mic drop, is specifically the sentence people reach for when they want credit for rigor without doing any of it.</p>\n\n<h2 id=\"what-i-actually-want-from-you\">What I actually want from you</h2>\n\n<p>So the next time you hear the phrase deployed as a full stop rather than a comma, I would like you to think about this article and do the exploratory thing instead. Ask which of the five categories you are actually looking at. Check the temporal order. Look for the mechanism. Go find the confounder before someone else has to hand it to you.</p>\n\n<p>There is no instant death penalty for speech in most rooms you will find yourself in, university or otherwise. But if the shrinking-window argument above is right, that only stays true if people keep using the full width of the window rather than quietly retreating toward the middle every time someone drops a conversation-ending aphorism on them. The window does not maintain itself. It has to be used, fully and repeatedly, or it contracts on its own. That is not a metaphor I am fond of. It is, as far as I can tell, the actual mechanism.</p>\n\n<p>If you want to push back on any of this, come find me on <a href=\"https://discord.gg/2BXuUY6hrX\">Discord</a>.</p>",
      "date_published": "2026-07-06T00:00:00+00:00",
      
      "date_modified": "2026-07-06T00:00:00+00:00",
      
      "authors": [ { "name": "Stephan Bökelmann" } ],
      "tags": ["philosophy","personal"]
    },
    
    {
      "id": "https://maxclerkwell.tech/posts/what-ai-actually-is-july-2026/",
      "url": "https://maxclerkwell.tech/posts/what-ai-actually-is-july-2026/",
      "title": "What AI Actually Is",
      "summary": "Running a chat model is not AI. The training process is. Everything else follows from that distinction, including where the human niche survives.",
      "content_html": "<p>The previous two articles built up a chain. <a href=\"/posts/from-aristotle-to-the-bit-july-2026/\">The first</a> ran from Aristotle’s two-valued logic through Leibniz, Boole, Gauss, and Shannon to the bit as a unit of information. <a href=\"/posts/from-the-uat-to-the-modern-neural-network-july-2026/\">The second</a> ran from Hilbert’s decision problem through Turing, the Universal Approximation Theorem, and Banach’s convergence guarantee to the modern neural network. This article asks what to make of all of it: what is the thing we are actually calling AI, and where does it leave the human?</p>\n\n<p>I want to start with something I find useful to keep in front of me, because it clarifies almost every confused conversation about AI I have ever had.</p>\n\n<hr />\n\n<h2 id=\"blackboxes-and-the-manual-way\">Blackboxes and the Manual Way</h2>\n\n<p>Think of a real-world function as a black box. Something goes in, something comes out, according to some rule. The box does not care what you think is inside it. It just maps inputs to outputs.</p>\n\n<p>For some boxes, a smart mathematician can look at enough input-output pairs and see the rule directly. A physicist measuring voltage and current recognises Ohm’s law. A structural engineer sees a force-displacement relationship and fits a spring constant. A clever programmer can then take that formula and implement it efficiently. Add a physicist who understands how to translate a physical measurement into a digital signal, and a mechatroniker who knows how to turn a bit pattern into a motor command, and you have the ingredients for a functional intelligent robot. Each person contributes one piece of the chain from world to decision to action.</p>\n\n<p>This is not a hypothetical. It is how most of the automation we rely on was actually built: by people who understood the domain, identified the relevant variables, wrote down the relationship, and implemented it. The intelligence was human, the execution was mechanical.</p>\n\n<p>The problem is that some boxes resist this approach. Human language is one of them. The rule that maps a sentence to its appropriate response is not one that any mathematician has written down, because it is not a formula at all. It is an enormously complex, context-dependent, culturally embedded mapping that took hundreds of thousands of years of human evolution and social practice to produce. Nobody can inspect it and extract an equation. The manual way breaks down.</p>\n\n<hr />\n\n<h2 id=\"what-training-actually-does\">What Training Actually Does</h2>\n\n<p>When the manual way breaks down, the question becomes: can we replace the mathematician with a computer program? Can we feed the machine a large list of input-output pairs, and have it figure out what the mapping looks like, and then produce a second program that implements that mapping well enough to be useful?</p>\n\n<p>This is what training does. It is the automation of the step the mathematician used to perform by hand. You define a family of possible functions (your neural network architecture, which the UAT tells you is expressive enough to approximate almost anything). You collect data: pairs of inputs and desired outputs. You run a training procedure that searches through that family for the member that fits the data best, in the sense of minimising a chosen error measure. When the procedure converges, you have a model: a fixed, deterministic function, with its parameters set.</p>\n\n<p>That model is a program. It is data: a long sequence of numbers stored in memory. When you run it, you are evaluating a function. Input goes in, output comes out. The schema from the previous article applies directly: DATA (your prompt) + DATA (the model weights) gives you TRANSFORMATION (the forward pass through the network) gives you DATA (the response).</p>\n\n<p>Nothing in that process, at inference time, is learning, deciding, or thinking. It is matrix multiplication. The same input always produces the same output. A language model answering your question is no more “intelligent” at that moment than a sorted list is intelligent for being sorted.</p>\n\n<hr />\n\n<h2 id=\"the-training-process-is-the-ai-the-model-is-not\">The Training Process Is the AI. The Model Is Not.</h2>\n\n<p>This is the distinction I want to press on, because I think almost all confusion about AI collapses once you hold it clearly.</p>\n\n<p>The intelligence, if we want to use that word at all, is in the training process. It is in the choice of architecture, the choice of loss function, the collection and curation of training data, the engineering of the optimiser, and the infrastructure that runs billions of parameter updates until the loss converges. That process searched through an astronomically large space of possible functions and found one that approximates the target mapping well enough to be useful. That search is remarkable. That is where the interesting thing happened.</p>\n\n<p>The running model is the result of that search. It is a certificate, not a process. Ada Lovelace wrote, in 1843, that the Analytical Engine “can do whatever we know how to order it to perform.” She meant it as a limitation. Re-read in the light of training: the model can do whatever the training process knew how to encode into it. The training process, not the model, is the locus of whatever deserves the name intelligence.</p>\n\n<hr />\n\n<h2 id=\"language-as-lossy-quantisation\">Language as Lossy Quantisation</h2>\n\n<p>With that distinction in place, I want to work through six ideas about what language is and what it means for both human and machine intelligence. These matter because the thing we are mostly using as the interface to AI systems right now is language, and language has properties that cut directly into the question of what a trained model can and cannot do.</p>\n\n<p><strong>Words are case distinctions, not the thing itself.</strong> The word “cat” does not contain a cat. It is a pointer, agreed upon by convention, that picks out a region of conceptual space. “Cat”, “Katze”, “gato”, and “猫” (mao) all point to the same region via different pointers. At the level of the letter sequence as a number (you can read any word as a base-26 integer), they are completely different. At the level of meaning, they are identical. This two-layer structure, symbol system on top, meaning system underneath, is not an accident. It is how language works. A model trained on text learns the statistical regularities of the symbol layer. Whether it reaches the meaning layer is a genuine open question.</p>\n\n<p><strong>Some descriptions are lossless, most are not.</strong> The numeral “3” (or “0b11” in binary, or “III” in Roman notation) describes a precise, discrete state with no ambiguity. The word “warm” does not. It carves a continuous temperature axis into a rough category, losing almost all information about the specific temperature involved. Most of human language is like “warm”, not like “3”. It is a lossy compression of experience into categories coarse enough to be spoken and heard in real time.</p>\n\n<p><strong>Language is a solution to an engineering problem.</strong> Shannon’s rate-distortion theory says you cannot simultaneously minimise the information lost in a description and the length of that description. You always trade one for the other. Natural language sits at a particular point on that curve: short enough to be spoken in real time by a breathing, forgetting, interrupted speaker; lossy enough to leave the hard work of disambiguation to context and shared knowledge. Zipf’s law, the observation that the most common words are the shortest, is this trade-off self-organising. Language did not arrive at this point by design. It was selected for, over thousands of generations, because speakers who found the right trade-off communicated better. Shannon described the law. Evolution found the solution.</p>\n\n<p><strong>Fixing the channel does not fix the loss.</strong> Suppose you had a perfect voice and a perfect ear: a lossless acoustic channel. Would that give you lossless communication? No. The bottleneck is not the channel. It is the encoding. Before any word leaves your mouth, you have already mapped a continuous, private experience onto a discrete, shared symbol. That mapping discards almost everything. Transmitting “I am in pain” with perfect fidelity still does not transfer the experience of pain. The loss happened when you chose the word, not when you spoke it.</p>\n\n<p><strong>The infinite is nobody’s territory.</strong> If reality is genuinely continuous and unbounded, then both humans and machines are finite quantisers working on the same infinitely large problem. The human does not have special access to the unquantised real. The human’s quantisation vocabulary is tuned by evolution to bodies, survival, and social life. The machine’s quantisation vocabulary is inherited from ours, since it trained on our text. It is a quantiser of our quantisation: a second-order approximation. Whether that is a limitation or merely a different kind of approximation is a question worth sitting with.</p>\n\n<p><strong>The spaces are complementary, not identical.</strong> The overlap between what a human can express and what a model has encoded is large, because the model trained on human expression. But the overlap is not total. The human brings a body, a history, a position in time, mortality, and the knowledge that comes from having stakes in the outcome. The model brings the entire written corpus processed simultaneously, exact pattern recall across billions of tokens, and no fatigue. These are not the same set of capabilities with one being larger. They are different shapes, sharing a large common region but each extending into territory the other does not reach.</p>\n\n<hr />\n\n<h2 id=\"interpolation-extrapolation-and-hallucination\">Interpolation, Extrapolation, and Hallucination</h2>\n\n<p>All of that has a concrete consequence for what a trained model can and cannot do, and it is best understood geometrically.</p>\n\n<p>Imagine you have three points in a coordinate system and you want to find the function that passes through them. With three points, a parabola fits perfectly. The residuals are zero. Everything looks clean. If someone asks you for the value of the function between those three points, you interpolate: you read off the parabola’s value, and provided the true underlying function really is a parabola, you will be right.</p>\n\n<p>Now add a fourth point, well outside the range of the first three. The parabola, fitted on the original three, will not pass through it. The squared error at the new point may be very large. The correct model, it turns out, is a cubic polynomial. But you could not have known that from three points alone. Three points underdetermined the model family.</p>\n\n<figure>\n  <img src=\"/assets/posts/what-ai-actually-is-july-2026/interpolation-vs-extrapolation.png\" alt=\"Two plots side by side. Left: three training points with a parabola fitting them perfectly. Right: the same three points plus a fourth point outside the training range, where the parabola fails and only a cubic polynomial fits correctly.\" style=\"max-width:100%; display:block; margin: 0 auto 0.5rem;\" />\n  <figcaption><strong>Fig. 1:</strong> Left: three points, a quadratic fit, near-zero residuals: interpolation works. Right: a fourth point outside the training range exposes the wrong model family. The shaded area shows the squared error the parabola accumulates at the new point. The cubic fits all four, but only because it had access to the new data. Illustration by the author.</figcaption>\n</figure>\n\n<p>A language model’s training data is a set of points. The model is the function fitted to them. Within the distribution of that training data (the interpolation regime), the model performs remarkably well. It has seen enough examples of similar inputs and outputs that its approximation is accurate.</p>\n\n<p>Outside that distribution (the extrapolation regime), the fitted function is being evaluated in a region where it has no data to constrain it. It will produce an output, because it is a function and functions always produce outputs. But there is no guarantee that output bears any relationship to the correct answer. This is what hallucination is. It is not a bug or a sign of deception. It is the model doing exactly what it was trained to do (produce plausible continuations of text) in a region where plausibility is no longer anchored by data. The parabola, extrapolated far enough, gives you confidently wrong numbers. The model, extrapolated far enough, gives you confidently wrong facts.</p>\n\n<p>The lesson is not that models are useless outside their training distribution. It is that you need to know which regime you are in. Inside the training distribution, trust the interpolation. Outside it, verify.</p>\n\n<hr />\n\n<h2 id=\"the-human-niche-and-the-divine-spark\">The Human Niche and the Divine Spark</h2>\n\n<p>This brings me to the question I find most interesting: where does the human advantage persist?</p>\n\n<p>The model interpolates. It does so at remarkable scale and speed, over a training distribution that covers most of recorded human knowledge. For tasks that live inside that distribution, the model will often match or exceed human performance, simply because it has seen more examples and recalls them more exactly.</p>\n\n<p>But creative work, in the deepest sense, is extrapolation. It is the production of something that does not yet exist in the training data, that cannot be reached by interpolation from known points, that requires the generation of a new point in a space where the model has no data to fit. The model can produce text that looks creative because it recombines familiar patterns in unfamiliar ways. That is impressive, and useful, and not nothing. But it is not the same as genuinely extending the frontier.</p>\n\n<p>I am cautious about making this argument too quickly, because the boundary between recombination and genuine novelty is genuinely hard to draw. But I think there is something real here, and I want to name it: the human ability to stand at the edge of the known, feel the discomfort of genuine uncertainty, and commit to a direction anyway, is not well-modelled by a function that returns the statistically most plausible next token. The mathematician who saw a pattern nobody had seen before was not interpolating. The physicist who proposed a model that contradicted all prior experiments was not interpolating. The artist who made something that had no precedent was not interpolating.</p>\n\n<p>I discussed the question of whether any of this is connected to consciousness in an earlier piece on <a href=\"/posts/machine-consciousness-may-2026/\">machine consciousness</a>, and I do not want to repeat that argument here. But I will say this: the tradition that speaks of a divine spark in the human, the capacity for genuine creation that does not merely recombine what came before, points at something that the interpolation picture of intelligence does not capture. Whether that capacity is consciousness, or something else, or whether machines will eventually acquire it, I do not know. What I do think is that it is precisely this capacity that defines the domain where human intelligence has something the trained model structurally lacks: not more data, not faster recall, but the ability to generate a genuinely new point in a space where no training data exists.</p>\n\n<p>The model trained on everything humans have written is a mirror of what we have already said. The next thing worth saying is, by definition, not yet in the mirror.</p>\n\n<hr />\n\n<h4 id=\"image-sources\">Image sources</h4>\n\n<ul>\n  <li><strong>Fig. 1:</strong> Interpolation vs. extrapolation. Illustration by the author. Generated with matplotlib/Python.</li>\n</ul>\n\n<hr />\n\n<p><em>These three articles form a connected arc: from the bit, to the function, to the question of what the function cannot do. If you want to follow any of these threads further, the Discord is the best place.</em></p>\n\n<p>If you want to talk through any of this, come find us on Discord.</p>\n\n<div><a href=\"https://discord.gg/2BXuUY6hrX\" class=\"link-card-discord\" target=\"_blank\" rel=\"noopener noreferrer\"><i class=\"fab fa-discord\"></i><div class=\"discord-text\"><span class=\"discord-name\">Discord — Full Stack Engineering</span><span class=\"discord-note\">Direct access to me and my colleagues. Webinars, live Q&amp;A, and community discussions for engineers across the full stack.</span><span class=\"discord-join\">Join the server →</span></div></a></div>",
      "date_published": "2026-07-03T00:00:00+00:00",
      
      "date_modified": "2026-07-03T00:00:00+00:00",
      
      "authors": [ { "name": "Stephan Bökelmann" } ],
      "tags": ["ai","philosophy","education"]
    },
    
    {
      "id": "https://maxclerkwell.tech/posts/from-the-uat-to-the-modern-neural-network-july-2026/",
      "url": "https://maxclerkwell.tech/posts/from-the-uat-to-the-modern-neural-network-july-2026/",
      "title": "From the UAT to the Modern Neural Network",
      "summary": "We have the bit. Now the question is what we can compute with it, and whether a machine can learn to compute things nobody explicitly programmed. The answer runs from Hilbert through Turing to a theorem most engineers have never heard of.",
      "content_html": "<p>The previous article ended with a precise unit: the bit, as Shannon defined it, measures how much uncertainty a message resolves. We have a way to represent information. We have a way to build logical operations from switches. We know, from Gauss and Weber, that a wire can carry two distinguishable states. The question this article asks is the next one in the chain: <em>what can we actually compute with all of this, and is there a limit?</em></p>\n\n<p>That question turns out to be older than the computer. It starts with a mathematician who wanted to mechanise all of mathematics, runs through a logician who showed that was impossible, and ends with a computer scientist who redefined what “mechanical” even means.</p>\n\n<hr />\n\n<h2 id=\"hilberts-question-can-a-machine-decide-everything\">Hilbert’s Question: Can a Machine Decide Everything?</h2>\n\n<p>In 1928, David Hilbert posed what he called the <em>Entscheidungsproblem</em>, the decision problem. The question was this: is there a mechanical procedure that, given any mathematical statement, can decide in a finite number of steps whether the statement is true or false?</p>\n\n<p>Hilbert’s hope was that mathematics was complete (every true statement is provable), consistent (no contradictions exist), and decidable (there is an algorithm for each question). This was not naive optimism. It was a serious research programme, and Hilbert believed it could succeed.</p>\n\n<p>Three years later, in 1931, Kurt Gödel demolished the first two hopes. His incompleteness theorems showed that any sufficiently powerful formal system contains true statements that cannot be proved within that system, and that no such system can prove its own consistency. Mathematics is not complete. There are truths that lie beyond the reach of any fixed set of axioms.</p>\n\n<p>This did not yet answer Hilbert’s third question. Even if some truths are unprovable, there might still be a mechanical procedure that always terminates with a correct yes or no. That question required a different kind of answer: not a proof about what mathematics contains, but a precise definition of what “mechanical procedure” means in the first place.</p>\n\n<hr />\n\n<h2 id=\"turings-answer-the-machine-as-a-definition\">Turing’s Answer: The Machine as a Definition</h2>\n\n<p>In 1936, Alan Turing published “On Computable Numbers, with an Application to the Entscheidungsproblem”. What he did was unusual. Instead of taking “mechanical procedure” as an informal concept and arguing about it, he defined it: a mechanical procedure is precisely what can be carried out by what we now call a Turing machine, an abstract device with a tape, a read/write head, and a finite set of rules for transitioning between states.</p>\n\n<p>This definition was not a restriction. Turing argued, convincingly, that anything a human could compute by following explicit rules could be computed by such a machine. The definition captured the concept.</p>\n\n<p>With the definition in hand, Turing could answer Hilbert’s question exactly. He proved that no Turing machine can solve the <em>halting problem</em>: given an arbitrary program and an input, determine whether the program will eventually stop or run forever. Because deciding all mathematical statements would require solving the halting problem, the Entscheidungsproblem has no solution. There is no universal decision algorithm.</p>\n\n<p>The negative result is not what I want to emphasise here, though. What matters for our chain is the positive content of Turing’s definition: a complete, precise account of what computation is. Any computation is a process that reads a finite input, follows deterministic rules, and produces a finite output. It takes in data, transforms it, and produces data. That pattern, data in and data out, is the foundation everything else in this article rests on.</p>\n\n<hr />\n\n<h2 id=\"every-program-is-a-function\">Every Program Is a Function</h2>\n\n<p>Shannon had already shown that logical operations can be built from switches. Turing showed that any computation can be expressed as a sequence of such operations. Put those two results together and you get something important: <em>every program that terminates is a function</em>.</p>\n\n<p>It takes an input, which is a finite sequence of bits, and it produces an output, which is another finite sequence of bits. The same input always produces the same output (for a deterministic program). That is the definition of a mathematical function. A sorting algorithm is a function from unsorted lists to sorted lists. An image classifier is a function from pixel arrays to label strings. A language model, at inference time, is a function from a token sequence to a probability distribution over the next token.</p>\n\n<p>I want to make this concrete with a schema, because I think it cuts through a lot of confusion: DATA + DATA gives you TRANSFORMATION gives you DATA. The first DATA is your input. The second DATA is the program itself (its weights, its rules, its parameters), which is also just a sequence of bits stored somewhere. The TRANSFORMATION is the act of running the program. The final DATA is the output. Nothing in this picture requires consciousness, understanding, or anything mysterious. A running program is a function being evaluated.</p>\n\n<p>This reframing matters because it connects computation directly to mathematics, and specifically to the question of which functions are computable and how well they can be approximated.</p>\n\n<hr />\n\n<h2 id=\"the-approximation-tradition-gauss-runge-and-kutta\">The Approximation Tradition: Gauss, Runge, and Kutta</h2>\n\n<p>Before we get to neural networks, I want to make an argument that is rarely made explicitly: the idea that you can <em>learn</em> a function from data did not appear from nowhere in the 1980s. It has roots in numerical mathematics that go back to the 19th century, and without those roots, the Universal Approximation Theorem would have been much harder to conceive.</p>\n\n<p>Carl Friedrich Gauss, in 1809, formulated the method of least squares: given a set of noisy observations, find the function (within a specified family, say, polynomials of a given degree) that minimises the sum of squared differences between the function’s predictions and the observations. This is, in structure, exactly what a neural network trainer does. You have data. You have a family of functions parameterised by numbers. You minimise an error measure. The conceptual template was there two centuries before backpropagation.</p>\n\n<p>Gauss-Jordan elimination, the method for solving the linear system that arises from the normal equations of least squares, made that minimisation computationally tractable. It gave engineers a systematic, mechanical procedure for finding the parameters that best fit a dataset, within the class of linear models.</p>\n\n<p>Carl Runge and Martin Wilhelm Kutta, working in the 1890s, contributed a different but equally important idea: iterative numerical integration of differential equations. If you cannot find a closed-form solution to an equation describing how a system evolves, you can instead take many small steps, updating your estimate of the solution at each step. The error accumulates, but with small enough steps and a good enough scheme, the approximation converges to the true solution.</p>\n\n<p>That structure, iterating small updates toward a solution you cannot reach in closed form, is the computational backbone of gradient descent. Training a neural network is Runge-Kutta applied to a loss landscape instead of a differential equation. Without a century of experience showing that iterative numerical methods converge reliably, the proposal to train a network by repeatedly nudging its parameters in the direction of decreasing error would have looked much more speculative than it did.</p>\n\n<hr />\n\n<h2 id=\"the-banach-fixed-point-theorem-why-iteration-converges\">The Banach Fixed Point Theorem: Why Iteration Converges</h2>\n\n<p>The mathematical guarantee behind all of this is the Banach fixed point theorem, proved by Stefan Banach in 1922. It says: if you have a mapping that is a contraction (each application brings two points strictly closer together), and the space you are working in is complete, then iterating the mapping from any starting point will converge to a unique fixed point.</p>\n\n<p>Gradient descent, under appropriate conditions on the loss function and the step size, is a contraction. Each update step moves the parameters closer to a minimum. The theorem guarantees not only that the process converges, but that it converges to the same place regardless of where you start, provided the contraction conditions hold. In practice, the loss landscapes of deep networks are far too complex for Banach’s conditions to hold globally. But the theorem explains why the procedure works at all, and it tells you what to look for when it does not: a step size that is too large breaks the contraction, and a loss landscape with many local minima means Banach’s uniqueness guarantee no longer applies.</p>\n\n<hr />\n\n<h2 id=\"the-universal-approximation-theorem\">The Universal Approximation Theorem</h2>\n\n<p>With all of that in place, we can state the central result.</p>\n\n<p>In the mid-1950s, at the Dartmouth conference that inaugurated the field of artificial intelligence, researchers were already circulating informal versions of a powerful conjecture: that a network of simple threshold units, given enough of them, could approximate any function. The intuition was compelling. Turing had shown any computation is a function. Boolean algebra had shown any logical function can be built from a handful of primitives. The leap was to ask whether something similar held for the much broader class of continuous functions.</p>\n\n<p>The formal proof had to wait. In 1989, George Cybenko proved a precise version for networks with sigmoid activation functions: a single hidden layer with sufficiently many neurons can approximate any continuous function on a compact subset of real-valued inputs to any desired accuracy. In 1991, Kurt Hornik extended this to arbitrary nonlinear activation functions and removed several technical restrictions.</p>\n\n<p>The theorem makes a striking claim. You do not need to design a function by hand. You do not need to know in advance what the function looks like. Given enough neurons, the right architecture, and a training procedure, a network can approximate any continuous input-output relationship from data alone.</p>\n\n<p>This is where the connection to Gauss closes: least squares found the best linear approximation within a fixed function family. The UAT says that with a neural network, the function family is rich enough to contain an approximation to <em>anything</em>. The training procedure (gradient descent, Banach’s iteration) finds it.</p>\n\n<p>Two caveats matter here and are often missed. First, the theorem guarantees existence: there is a network that approximates the function. It says nothing about how to find it, how much data you need, or how many neurons are required. Second, approximation is not the same as representation. A network that approximates a function to within 0.01 on a test set may behave completely differently outside that set. The theorem is a licence to try, not a guarantee of success.</p>\n\n<hr />\n\n<h2 id=\"from-uat-to-modern-architectures\">From UAT to Modern Architectures</h2>\n\n<p>Once the UAT established that the approximation game was worth playing, the history of deep learning is largely the history of building better function families and better trainers.</p>\n\n<p>Frank Rosenblatt’s perceptron (1957) was the first learned classifier: a single-layer network trained by a simple update rule. Minsky and Papert showed in 1969 that a single layer cannot separate all patterns (the XOR problem), which required the multi-layer networks the UAT had theoretically justified. Backpropagation, developed through the 1970s and popularised by Rumelhart, Hinton, and Williams in 1986, gave us the trainer: a systematic way to compute how much each weight contributed to the error and adjust it accordingly.</p>\n\n<p>The architectures that followed are each a different answer to the question “what structure should the function family have?”. Convolutional networks (LeCun, 1998) built in the knowledge that spatial patterns are translation-invariant. LSTMs (Hochreiter and Schmidhuber, 1997) built in sequential memory. Transformers (Vaswani et al., 2017) built in the ability to attend to arbitrary positions in a sequence, with no assumption about locality. AlphaFold, GPT, diffusion models: all of them sit on the UAT’s foundation. They are different choices of structure for the approximating function and different strategies for training it.</p>\n\n<p>The “intelligence” in all of these systems does not live in the trained model. At inference time, a network is a fixed deterministic function: x in, y out. The same input always produces the same output. What makes it remarkable is its origin: it was not written by hand. A trainer searched a vast space of possible functions and found one that fits the data. That search, and the choice of what space to search in and what to optimise for, is where all the interesting decisions are made.</p>\n\n<hr />\n\n<p><em>These two articles have traced a single line from Aristotle’s two truth values to the architectures that underpin modern language models. The bit is the atom. The Turing machine is the definition of computation. The UAT is the licence to approximate. Training is Gauss’s least squares generalised by Banach’s convergence guarantee. Every transformer, every diffusion model, every large language model is an instance of that chain, searched rather than written.</em></p>\n\n<p>If you want to talk through any of this, come find us on Discord.</p>\n\n<div><a href=\"https://discord.gg/2BXuUY6hrX\" class=\"link-card-discord\" target=\"_blank\" rel=\"noopener noreferrer\"><i class=\"fab fa-discord\"></i><div class=\"discord-text\"><span class=\"discord-name\">Discord — Full Stack Engineering</span><span class=\"discord-note\">Direct access to me and my colleagues. Webinars, live Q&amp;A, and community discussions for engineers across the full stack.</span><span class=\"discord-join\">Join the server →</span></div></a></div>",
      "date_published": "2026-07-03T00:00:00+00:00",
      
      "date_modified": "2026-07-03T00:00:00+00:00",
      
      "authors": [ { "name": "Stephan Bökelmann" } ],
      "tags": ["ai","information-theory","history","education"]
    },
    
    {
      "id": "https://maxclerkwell.tech/posts/from-aristotle-to-the-bit-july-2026/",
      "url": "https://maxclerkwell.tech/posts/from-aristotle-to-the-bit-july-2026/",
      "title": "From Aristotle to the Bit",
      "summary": "The bit did not fall from the sky in 1948. It is the last step in a chain that starts with a Greek philosopher arguing about sea battles and ends with Claude Shannon measuring surprise.",
      "content_html": "<p>I want to tell you about a chain of ideas that took roughly 2300 years to complete.</p>\n\n<p>It starts with Aristotle sitting in Athens arguing about whether a statement like “there will be a sea battle tomorrow” is true or false <em>right now</em>. It ends with Claude Shannon (Fig. 1), in a small office at Bell Labs in 1948, writing down a formula that tells you exactly how much information is contained in a message. The distance between those two moments is the distance between informal common sense and a mathematically precise engineering tool. Understanding that distance, how each step in the chain was necessary and what each step actually contributed, is one of the most clarifying things you can do if you are trying to understand how digital technology actually works at its root.</p>\n\n<p>So let me walk through it. Not as a history lecture, but as a chain of dependencies. Each person in the chain solved a problem the previous person left open. The chain runs like this:</p>\n\n<p><strong>Aristotle</strong> (4th century BC) established that every proposition is either true or false, with no third option. <strong>Ramon Llull</strong> (13th century) showed that a sequence of such yes/no decisions can be used to systematically navigate any space of possibilities. <strong>Leibniz</strong> (1703) gave us binary arithmetic and showed that base 2 is the minimally sufficient number system. <strong>Boole</strong> (1854) turned logical reasoning into algebra you can compute mechanically. <strong>Gauss and Weber</strong> (1833) built the first device that physically encoded and transmitted binary signals over a wire. And <strong>Shannon</strong> (1937, 1948) built the final two bridges: from Boolean algebra to electrical circuits, and from physical signals to a precise mathematical measure of information.</p>\n\n<p>That is the chain. Now let me explain what each step actually contributed, and why the previous step was not enough.</p>\n\n<hr />\n\n<h2 id=\"step-one-aristotle-and-the-two-valued-world\">Step One: Aristotle and the Two-Valued World</h2>\n\n<p>Aristotle gave us two principles that most people today accept so automatically that they forget they are principles at all.</p>\n\n<p>The first is the <em>law of non-contradiction</em>: a statement cannot be both true and false at the same time. The second is the <em>law of excluded middle</em>: every statement is either true or false, with no third option, no middle ground, no “sort of true”.</p>\n\n<p>These sound obvious. But Aristotle knew they were not. In <em>De Interpretatione</em>, chapter 9, he worried specifically about statements concerning future events. Is “there will be a sea battle tomorrow” true or false <em>today</em>? If it is already determined one way or the other, does that mean the future is fixed? He did not fully resolve this tension, and philosophers still argue about it. But the working assumption he left behind, that meaningful propositions have exactly one of two truth values, became the bedrock of all formal logic.</p>\n\n<p>Notice what this does not give you. It gives you a framework for evaluating single statements. It does not give you a way to <em>chain</em> statements systematically, to <em>compute</em> with them, or to <em>encode the world</em> as a sequence of yes/no decisions. Aristotle drew the line between two truth values. Someone else had to figure out what to do with it.</p>\n\n<hr />\n\n<h2 id=\"step-one-and-a-half-llull-and-the-art-of-asking\">Step One-and-a-Half: Llull and the Art of Asking</h2>\n\n<p>Ramon Llull was a Catalan philosopher and theologian working in the 13th century, roughly 1232 to 1316. His <em>Ars Magna</em>, the “Great Art”, was an attempt to create a universal method for finding truth by combining a fixed set of basic concepts through systematic questioning. You start with a small vocabulary of fundamental attributes. You ask yes/no questions about each combination. You work through the tree of possibilities exhaustively. Complex questions reduce to chains of binary decisions.</p>\n\n<p>Llull was not doing mathematics in the modern sense, and his specific framework was embedded in medieval Christian theology in ways that do not travel well to the present. But the structural idea, that you can specify and distinguish any complex thing through a sufficient sequence of yes/no questions, is exactly right, and it predates both Leibniz and Boole by several centuries. Leibniz knew Llull’s work and was directly influenced by it when developing his own vision of a <em>calculus ratiocinator</em>, a machine for resolving arguments by computation.</p>\n\n<p>Aristotle told you each statement is true or false. Llull told you that a <em>chain</em> of such statements can be used to navigate a space of possibilities and zero in on a target. That is a different and important idea. It is, in embryonic form, the idea of a binary search tree, a decision tree, a classification algorithm. Every time you play “20 questions” and converge on the right answer, you are using Llull’s insight.</p>\n\n<hr />\n\n<h2 id=\"an-interlude-leibniz-and-the-lines-in-the-i-ching\">An Interlude: Leibniz and the Lines in the I Ching</h2>\n\n<p>Gottfried Wilhelm Leibniz, in 1703, published a paper called <em>Explication de l’Arithmétique Binaire</em> in which he systematically laid out binary arithmetic: the idea that you can represent any number using only the digits 0 and 1, by assigning place values that are powers of two instead of powers of ten.</p>\n\n<p>One of the things Leibniz understood clearly, and that often gets glossed over, is that base 2 is not <em>special</em> in any deep mathematical sense. You can build a positional number system in any base: base 10 (what we use daily), base 60 (Babylonian astronomers), base 16 (hexadecimal in every programmer’s life), or base 2. They are all equivalent in expressive power. Any number representable in one base is representable in every other. Leibniz knew this, and he was clear that the choice of base 2 was about <em>minimality</em>: it is the smallest base that works at all. You need at least two distinct symbols to build a positional system, because with only one symbol you cannot distinguish place values meaningfully. Two symbols is the minimum.</p>\n\n<p>That argument is purely mathematical. What it means physically, why the minimum-symbol property turns out to be important for building machines, is a story that comes later in the chain.</p>\n\n<p>What I find remarkable about Leibniz is that while working on this, he received a letter from a Jesuit missionary named Joachim Bouvet, who was stationed in China and had been studying the <em>I Ching</em>. Bouvet sent Leibniz a diagram of the 64 hexagrams: ancient Chinese symbols made of six stacked lines, each line either broken (yin, 陰) or unbroken (yang, 陽). Leibniz immediately recognised his own binary system in them. Six binary positions give you 2⁶ = 64 combinations. The hexagrams are, structurally, six-digit binary numbers. They were developed roughly 2000 years before Leibniz was born.</p>\n\n<p>I am not saying the ancient Chinese invented binary computing. They did not have that goal. But this moment is worth sitting with: a combinatorial structure powerful enough to encode 64 distinct states was independently arrived at in two civilisations separated by millennia and thousands of miles, because it is a natural consequence of stacking binary choices. The structure was waiting to be discovered. I first had this connection pointed out to me by <a href=\"https://github.com/kathematician\">Meihui Huang</a>, who drew the line from the Bagua directly to Leibniz in a way that has stuck with me since.</p>\n\n<figure>\n  <img src=\"/assets/posts/from-aristotle-to-the-bit-july-2026/bagua.svg\" alt=\"The eight trigrams (Bagua) of the I Ching arranged in the Fuxi Early Heaven sequence, each labelled with its binary value\" style=\"max-width:340px; display:block; margin: 0 auto 0.5rem;\" />\n  <figcaption><strong>Fig. 2:</strong> The eight trigrams (Bagua, 八卦) in the Fuxi \"Early Heaven\" arrangement. Each trigram is a stack of three lines: solid (yang, 陽, 1) or broken (yin, 陰, 0), giving 2³ = 8 distinct three-bit patterns. The 64 hexagrams of the I Ching extend this to six lines: 2⁶ = 64 combinations, structurally identical to a six-digit binary number. Illustration by the author.</figcaption>\n</figure>\n\n<p>What Leibniz still did not have was a way to use binary notation for <em>logic</em> rather than just <em>numbers</em>. That step came next.</p>\n\n<hr />\n\n<h2 id=\"step-two-boole-and-the-algebra-of-thought\">Step Two: Boole and the Algebra of Thought</h2>\n\n<p>George Boole, in 1854, published <em>An Investigation of the Laws of Thought</em>, and the title is not modest but it is accurate.</p>\n\n<p>Boole’s idea was to treat logical operations, AND, OR, NOT, as algebraic operations on symbols that could take only the values 0 and 1. Under his system, “A and B” becomes multiplication (A x B), “A or B” becomes addition (with a correction for overlap), and “not A” becomes 1 - A. You can write down logical arguments as equations and solve them the same way you would solve any algebra problem.</p>\n\n<p>This was a genuine conceptual leap. Before Boole, logic was something you did in natural language, following Aristotle’s rules informally. After Boole, logic was a branch of mathematics. It had a notation. It had rules you could apply mechanically. You did not need to understand the <em>meaning</em> of what you were computing: you could manipulate the symbols and trust the algebra.</p>\n\n<p>One of the most important results that falls out of this system is functional completeness. The four operations AND, OR, NOT, and the identity operation (a variable that simply passes its value through unchanged) are sufficient to express every possible logical function. Any truth table you can write down, any condition, any combination of inputs and outputs, can be built from those four primitives and nothing else. This is not obvious when you first encounter it, but it has a profound consequence: if you can build physical devices that implement AND, OR, and NOT, you can build any logical computation whatsoever. The entire complexity of modern computing rests on that fact.</p>\n\n<p>Boole was not alone in developing these ideas. Augustus De Morgan, working in the same period, established what are now called De Morgan’s laws: the rule that negating an AND gives an OR of the negations, and vice versa. These laws are the reason you can freely interchange AND-based and OR-based formulations of the same logic, and they are used constantly in circuit design. Hugh MacColl, writing in the 1870s and 1880s, pushed propositional logic further toward a formal calculus and was among the first to treat it as a purely symbolic system independent of its philosophical content. Together, Boole, De Morgan, and MacColl transformed logic from a branch of philosophy into a branch of mathematics ready to be handed to engineers.</p>\n\n<p>The 0 and 1 in Boolean algebra are not yet bits in the modern sense. They are truth values. But the connection is coming.</p>\n\n<hr />\n\n<h2 id=\"step-two-and-a-half-gauss-weber-and-the-first-binary-wire\">Step Two-and-a-Half: Gauss, Weber, and the First Binary Wire</h2>\n\n<p>Between Boole’s algebra and Shannon’s circuits, there is a physical milestone that rarely gets the attention it deserves.</p>\n\n<p>In 1833, the mathematician Carl Friedrich Gauss and the physicist Wilhelm Weber strung a wire roughly a kilometre across the rooftops of Göttingen, connecting the physics institute to the astronomical observatory. Over that wire they sent signals by reversing the direction of an electrical current. One direction meant one thing, the other direction meant something else: two distinguishable states, transmitted electrically. They used this to spell out words in a code of their own design, sending the first intentional electromagnetic telegraph messages in history.</p>\n\n<p>What makes this relevant to our chain is not just the historical curiosity. It is the physical realisation of something Leibniz had only described mathematically: two symbols, and nothing more, are sufficient to carry any message you want. Gauss and Weber did not need ten voltage levels or twenty. They needed two. The minimal system Leibniz had argued for on mathematical grounds turned out to be exactly what a wire through the air could carry reliably.</p>\n\n<p>The telegraph that Gauss and Weber built was not yet a commercial system, and the code they used was not Morse code. But the idea that a long-distance communication channel could be built around the binary opposition of two physical states was demonstrated right there, on the rooftops of a German university town, fifteen years before Boole published his algebra and 115 years before Shannon measured what was flowing through such a channel.</p>\n\n<hr />\n\n<h2 id=\"step-three-shannon-and-the-two-transitions\">Step Three: Shannon and the Two Transitions</h2>\n\n<figure>\n  <img src=\"/assets/posts/from-aristotle-to-the-bit-july-2026/claude-shannon.jpg\" alt=\"Portrait photograph of Claude Shannon\" style=\"max-width:260px; display:block; margin: 0 auto 0.5rem;\" />\n  <figcaption><strong>Fig. 1:</strong> Claude Elwood Shannon (1916–2001). Photograph by Konrad Jacobs, 1960s. Source: <a href=\"https://commons.wikimedia.org/wiki/File:ClaudeShannon_MFO3807.jpg\" target=\"_blank\" rel=\"noopener\">Wikimedia Commons / Mathematisches Forschungsinstitut Oberwolfach</a>, <a href=\"https://creativecommons.org/licenses/by-sa/2.0/de/\" target=\"_blank\" rel=\"noopener\">CC BY-SA 2.0 DE</a>.</figcaption>\n</figure>\n\n<p>Claude Shannon made two contributions so different in nature that they tend to blur together in popular accounts. I want to keep them separate.</p>\n\n<p><strong>The first contribution came in 1937</strong>, when Shannon was a 21-year-old master’s student at MIT. His thesis, which many historians consider the most important master’s thesis of the 20th century, showed that Boolean algebra maps directly onto electrical switching circuits. A switch is either open or closed: 0 or 1. An AND gate is two switches in series. An OR gate is two switches in parallel. Every logical function Boole had described algebraically could be physically implemented as an arrangement of relays.</p>\n\n<p>This was the moment when logic left the realm of pure mathematics and entered the world of engineering. From this point forward, you could design a circuit the same way you designed a logical argument, and you could design a logical argument the same way you designed a circuit. Aristotle’s two-valued logic and Leibniz’s binary arithmetic had been waiting, for two thousand years, for a physical substrate. Gauss and Weber had shown that a wire could carry two states. Shannon showed that those two states could implement any logical function you could describe.</p>\n\n<p>The immediate practical consequence was telephone routing. At the time, connecting one telephone to another across a large network required banks of electromechanical relays, and designing those relay networks was done by hand, by intuition, with no systematic method. Shannon’s thesis gave engineers a mathematical language for that design problem. You could now specify a switching network as a Boolean expression, simplify it algebraically, and derive the minimal relay circuit that implemented it. Automatic telephone exchanges, where a dialled number triggers a sequence of relay operations that routes your call without a human operator, had existed in rudimentary form since the 1890s. But building them efficiently at scale, across networks with millions of possible connections, only became tractable once you could reason about relay logic mathematically. Shannon’s thesis is the reason automatic dialling as we know it actually worked.</p>\n\n<p><strong>The second contribution came in 1948</strong>, with a paper called <em>A Mathematical Theory of Communication</em>. This one is harder to summarise briefly, because it opened an entirely new field. But let me try to isolate the single idea I think is most important for what we are building towards.</p>\n\n<p>Shannon asked a question that nobody had posed precisely before: <em>how do you measure the amount of information in a message?</em></p>\n\n<p>His answer was the concept of entropy. The entropy of a message is a number, measured in bits, that captures how surprising the message is, or equivalently, how much uncertainty it resolves. A message you were almost certain was coming contains very little information. A message that was one of many equally likely possibilities contains a lot. Shannon gave a precise mathematical formula for computing this number, and he proved, with a theorem, that this number sets a hard lower limit on how efficiently you can encode the message. You cannot compress a message below its entropy without losing something.</p>\n\n<p>The word “bit”, short for binary digit, was used by Shannon’s colleague John Tukey in the same period, and Shannon popularised it in this paper. A bit is the amount of information in the answer to a single yes/no question where both answers are equally likely. Everything else is built from that.</p>\n\n<p>A note on the word “entropy”, because it carries baggage. Shannon borrowed the term from thermodynamics, where it measures the disorder of a physical system: how many microscopic arrangements of particles are consistent with what you can observe at the macroscopic level. The more arrangements, the higher the entropy, and the less you can infer about the details. Shannon’s entropy measures something structurally identical: how many possible messages are consistent with what you know before receiving one. More possible messages, higher entropy, more information in each one when it arrives. The formula is the same in both cases, and Shannon reportedly chose the name on the advice of John von Neumann, who pointed out that nobody really understood thermodynamic entropy either, which would give Shannon an advantage in debates.</p>\n\n<p>The commonality is real: both entropies measure uncertainty over a set of possibilities. But the domains differ sharply. Thermodynamic entropy describes physical systems and points in a direction, time’s arrow, because macroscopic disorder tends to increase in isolated systems. Shannon entropy describes probability distributions over messages and has no preferred direction. You can encode a high-entropy source and decode it perfectly. You cannot un-mix a cup of coffee. In an earlier article I explored a third framing, borrowed from Wolfram, in which entropy looks like the signature of computational irreducibility: the universe refusing to yield its future to any calculation shorter than running itself forward. That framing connects all three, but it is worth knowing they are distinct ideas that happen to share a name and a formula. If you want to follow that thread, the earlier piece on <a href=\"/posts/game-of-life-may-2026/\">Conway’s Game of Life and computational irreducibility</a> picks it up from a different angle.</p>\n\n<hr />\n\n<h2 id=\"what-each-step-actually-contributed\">What Each Step Actually Contributed</h2>\n\n<p>Let me put the chain together in a way I find useful to teach.</p>\n\n<p>Aristotle contributed <strong>the two-valued constraint</strong>. A statement is true or false. No third option. This is the conceptual foundation without which none of the rest makes sense.</p>\n\n<p>Llull contributed <strong>the chain</strong>. A sequence of yes/no questions can navigate any space of possibilities, however complex. Binary choice is not just a property of single statements: it is a method for systematically reaching conclusions.</p>\n\n<p>Leibniz contributed <strong>the representation system</strong>. Any number, and it turns out any discrete state, can be represented as a sequence of 0s and 1s. Not because base 2 is mathematically special, but because it is the minimal base: the fewest possible symbols to build a positional system at all.</p>\n\n<p>Boole, De Morgan, and MacColl contributed <strong>the algebra</strong>. Logical operations between truth values can be written as mathematical equations and manipulated mechanically. Four primitives, AND, OR, NOT, and identity, are sufficient to express every possible logical function. Logic becomes computable.</p>\n\n<p>Gauss and Weber contributed <strong>the physical demonstration</strong>. Two electrical states on a wire are sufficient to carry any message. The minimal system Leibniz had identified mathematically turned out to be exactly what a physical channel could implement reliably.</p>\n\n<p>Shannon contributed <strong>two bridges</strong>. The first was the bridge between Boolean algebra and physical circuits: he showed that logic could be <em>built</em> from switches. The second was the bridge between messages and mathematics: he showed that information is <em>measurable</em>, with a precise unit, and that there are hard limits on how efficiently it can be stored and transmitted.</p>\n\n<p>The bit, as Shannon defined it, is not just a 0 or a 1 in the sense of a binary digit. It is a unit of <em>information content</em>, a measure of how much uncertainty was resolved by a message. This is the concept that makes digital communication an engineering discipline rather than an art. You can calculate how many bits a message contains. You can calculate how many bits per second a channel can carry. You can calculate the minimum number of bits needed to represent a source. Everything else in data compression, error correction, and channel coding flows from that.</p>\n\n<hr />\n\n<h2 id=\"why-this-chain-matters\">Why This Chain Matters</h2>\n\n<p>When I explain this to students, I find that the historical sequence helps with something that often confuses people: the difference between a 0 or 1 as a <em>symbol</em> and a bit as a <em>unit of information</em>.</p>\n\n<p>Every bit you store on your hard drive is a 0 or a 1. But not every 0 or 1 carries one bit of information in Shannon’s sense. If a file is entirely made up of the same byte repeated a million times, the redundancy means it can be compressed. The actual information content is much less than the number of binary digits used to store it. This is why compressed files are smaller. It is not magic. It is Shannon’s entropy bound being used practically by a compression algorithm.</p>\n\n<p>Understanding that distinction, symbol versus information content, takes the bit from being a vaguely understood unit (“how much storage I have on my phone”) to being a precisely defined and deeply useful concept. And the reason the concept has that precision is the 2300-year chain we just walked through.</p>\n\n<p>Aristotle cleared the ground. Llull showed you could chain binary questions into a method. Leibniz built the representation and identified base 2 as the minimal case. Boole, De Morgan, and MacColl built the algebra and proved four primitives are enough for everything. Gauss and Weber put two states on a wire. Shannon showed how to build logic from those states, and then measured how much information they could carry.</p>\n\n<hr />\n\n<h4 id=\"image-sources\">Image sources</h4>\n\n<ul>\n  <li><strong>Fig. 1:</strong> Claude Shannon. Photograph by Konrad Jacobs. <a href=\"https://commons.wikimedia.org/wiki/File:ClaudeShannon_MFO3807.jpg\">Wikimedia Commons</a>, Mathematisches Forschungsinstitut Oberwolfach. <a href=\"https://creativecommons.org/licenses/by-sa/2.0/de/\">CC BY-SA 2.0 DE</a>.</li>\n  <li><strong>Fig. 2:</strong> Bagua diagram. Illustration by the author. Public domain.</li>\n</ul>\n\n<hr />\n\n<p><em>In the next article, I pick up the thread where Shannon left off: from the Universal Approximation Theorem to the architecture of modern language models. If the first article asked “what is a bit and where did it come from”, the second asks “what does a neural network actually do, and why does training it count as a form of intelligence?”</em></p>\n\n<p>If you want to talk through any of this, whether you are studying physics, electrical engineering, or just find these ideas interesting, come find us on Discord.</p>\n\n<div><a href=\"https://discord.gg/2BXuUY6hrX\" class=\"link-card-discord\" target=\"_blank\" rel=\"noopener noreferrer\"><i class=\"fab fa-discord\"></i><div class=\"discord-text\"><span class=\"discord-name\">Discord — Full Stack Engineering</span><span class=\"discord-note\">Direct access to me and my colleagues. Webinars, live Q&amp;A, and community discussions for engineers across the full stack.</span><span class=\"discord-join\">Join the server →</span></div></a></div>",
      "date_published": "2026-07-03T00:00:00+00:00",
      
      "date_modified": "2026-07-03T00:00:00+00:00",
      
      "authors": [ { "name": "Stephan Bökelmann" } ],
      "tags": ["information-theory","philosophy","history","education"]
    },
    
    {
      "id": "https://maxclerkwell.tech/posts/how-monolatry-shapes-my-worldview-june-2026/",
      "url": "https://maxclerkwell.tech/posts/how-monolatry-shapes-my-worldview-june-2026/",
      "title": "How Monolatry Shapes My Worldview",
      "summary": "From polytheism as a map of the inner life, through the slow emergence of the individual voice, to the moment one god is raised above the rest, and what that means for how I actually live.",
      "content_html": "<p>A few weeks ago I had a long conversation with an AI about Calvinism. It started as a question about TULIP (the five points: predestination, limited atonement, and the rest) and ended somewhere I had not expected: in a place where I was trying to articulate, clearly and out loud, what I actually believe about God, religion, and what the whole arc of Western theology is pointing toward.</p>\n\n<p>This article is my attempt to write that down more carefully.</p>\n\n<p>I want to approach it from an unusual angle. Not starting with doctrine, but with psychology. Not with the question of what is true, but with the prior question of what gods actually are, and what the human encounter with them reveals about the structure of the mind doing the encountering.</p>\n\n<p>Let me be precise about that framing: I do not think humans <em>invented</em> gods. They may have invented the words, the stories, the rituals. But I think they <em>discovered</em> something real. The patterns that gods represent are not fabrications. They are findings, the same way that a mathematician does not invent the relationship between a circle’s radius and its circumference but discovers something that was always there.</p>\n\n<hr />\n\n<h2 id=\"the-gods-were-always-inside\">The Gods Were Always Inside</h2>\n\n<p>Start with a simple observation: every culture in human history has produced gods. Not a few. Not occasionally. Every single one. And while the gods differ in name, form, and mythology, they share a remarkable set of properties. They embody recurring patterns. A fierce, unpredictable storm god. A nurturing, fertile earth goddess. A cunning trickster. A righteous judge. A dying and rising saviour figure. A descent into the underworld.</p>\n\n<p>These figures appear in Egypt and Mesopotamia, in Greece and Rome, in pre-Columbian Mesoamerica, in the Norse sagas, in the Hindu epics. They appear in cultures that never had contact with one another. That is not a coincidence.</p>\n\n<p>Carl Gustav Jung called these patterns <strong>archetypes</strong>: structural templates in the human psyche that are not learned from any particular tradition but are, in some sense, prior to any particular tradition. They represent the recurring problems of human existence: how to handle power, how to navigate desire, how to face death, how to deal with the parts of yourself you do not like. Each culture tells the story differently. The underlying structure is the same.</p>\n\n<p>Jordan Peterson’s <em>Maps of Meaning</em> (1999) is the most thorough attempt I know of to work through where these structures come from and why they take the specific forms they do. His argument, simplified radically: the great mythological figures are not histories. They are imaginative maps of the inner life, built up over thousands of years of collective experience, refined by the survival pressure of what actually works when you act on it. The gods are not descriptions of external beings. They are descriptions of internal forces, projected outward into narrative form so that they can be shared, discussed, and transmitted across generations.</p>\n\n<p>On this reading, polytheism is not primitive. It is cognitively sophisticated. When an ancient Greek attributed his sudden rage to Ares, he was doing something remarkably accurate: he was externalizing an inner force that he did not fully control, naming it, giving it a face, and thereby making it possible to think about it. The alternative, treating every inner impulse as simply “what I want,” makes it much harder to examine, question, or resist.</p>\n\n<p>The pantheon is, in this sense, a communal map of the inner court.</p>\n\n<p>The reason these maps look different across cultures is not that they are pointing at different things. It is that the <em>writing-down</em> part is always shaped by a people’s daily reality. A culture dependent on the sea for survival will tell its storm-god story differently from a culture dependent on agriculture. The surface forms diverge because the experiences diverge. But strip away the local colour, the specific rituals, the particular names, and you keep finding the same underlying structure: the fierce protector, the nurturing provider, the cunning disruptor, the wise judge. Different costumes, same cast.</p>\n\n<hr />\n\n<h2 id=\"synthesis-shared-patterns-become-shared-gods\">Synthesis: Shared Patterns Become Shared Gods</h2>\n\n<p>Individual experience, though, is not sufficient to produce a religion. For a god to survive as a cultural institution, the pattern that god represents must be genuinely common, shared across enough lives and enough generations to become recognizable as such.</p>\n\n<p>This is how individual psychology becomes collective mythology. The figures that persist are the ones that name something real. The ones that capture how power actually behaves. How grief actually feels. How the impulse toward destruction actually operates in a human being. Over centuries of storytelling, these figures are refined. The versions that ring false fall away. The versions that resonate spread.</p>\n\n<p>The result is a polytheistic pantheon that functions as something like a collectively-built psychological model. It is not scientific. It is not systematic. But it carries genuine information about what it is like to be a human being, accumulated over enormous spans of time.</p>\n\n<p>What it does not do well, at least not initially, is privilege one of these forces over the others. In a polytheistic world, Ares and Aphrodite have equal standing. The storm god and the fertility goddess negotiate. The trickster disrupts whatever the judge tries to establish. The gods are in tension with each other because the drives they represent are in tension with each other.</p>\n\n<p>This is an honest picture of the inner life. It is also, eventually, not enough.</p>\n\n<hr />\n\n<h2 id=\"the-emergence-of-the-individual-voice\">The Emergence of the Individual Voice</h2>\n\n<p>Something begins to shift (historians and cognitive scientists debate exactly when) somewhere in the long prehistory before the Axial Age of the sixth and fifth centuries BCE. What shifts is the degree to which individual human beings experience themselves as <em>authors</em> of their own inner lives, rather than as passive recipients of divine instruction.</p>\n\n<p>Julian Jaynes, in his strange and brilliant <em>The Origin of Consciousness in the Breakdown of the Bicameral Mind</em> (1976), argued that earlier humans literally heard their own inner voices as the voices of gods, not metaphorically, but neurologically. The two hemispheres of the brain, communicating across the corpus callosum, produced auditory hallucinations that were experienced as divine command. Consciousness, in Jaynes’s account, is a relatively late invention, emerging from the collapse of this bicameral arrangement sometime in the second millennium BCE.</p>\n\n<p>I find Jaynes’s hypothesis fascinating and, in parts, too reductive. The mechanism he proposes is compelling. But I am not sure he has fully accounted for what is behind the curtain. We know far more about hormones and neurotransmitters today than the ancients did, and the presence of that knowledge has not, in any meaningful way, resolved the mystery of what consciousness actually is. There is something behind that curtain, something that gives rise to awareness, to the sense that there is something it is like to be me, that we do not understand and should not pretend to. Plato’s cave allegory gestures at this honestly: what we experience may be shadows of something we cannot face directly.</p>\n\n<p>I wrote at more length about what it might mean for consciousness to emerge in a substrate elsewhere, in my piece on <a href=\"/posts/physics-description-not-explanation/\">whether machines can be conscious</a>. The short version of my position: the hard problem of consciousness is genuinely hard, and anyone who claims to have dissolved it by pointing at neurons or transistors has probably confused correlation with explanation. The ancient myths were not wrong to treat awareness as something requiring its own account.</p>\n\n<p>What strikes me as particularly relevant here is <strong>metacognition</strong>: the capacity to observe your own thinking, to ask not only “what am I doing?” but “why am I doing it, and what drove the impulse that produced this action?” This may be precisely the threshold at which something like consciousness becomes visible from the outside. A system that merely processes inputs and produces outputs is not obviously aware of anything. A system that can examine its own processing, notice patterns in its own behaviour, and question the origins of its own drives, is doing something qualitatively different. Whether that constitutes experience in the full sense, I do not know. But it is at minimum the beginning of the same movement that, in human beings, eventually produces the capacity to hear the highest voice within and give it authority over the others. When a person first asks “why did I just do that?”, something has shifted. The gods are no longer simply issuing commands. They are being observed. And the one who observes them is, for the first time, in a position to choose between them.</p>\n\n<p>What I do think Jaynes is right about is the trajectory. The inner voice begins, over very long stretches of time, to be experienced as <em>mine</em> rather than as external command. This is not a sudden event. It is a slow process. Thousands of years before the Axial Age thinkers, individual human beings were already beginning, fitfully and unevenly, to experience themselves as agents with their own inner authority, rather than as vessels receiving divine instruction.</p>\n\n<p>Thomas Aquinas called it the <em>scintilla animae</em>, the spark of the soul, the capacity for reason that every human being carries within them, and which, when developed and exercised, allows a person to participate in divine reason. This is, I think, a genuinely egalitarian claim and one I hold to be true: every person has this spark. Not equally developed, not equally cultivated, and certainly accelerated or hindered by intelligence, education, and circumstance. But equally present as a starting point. The person born into poverty with a below-average IQ carries the same fundamental capacity as the philosopher with every advantage. What differs is the path, not the destination available. The work of a human life, Aquinas thought, is in significant part the work of nurturing that spark, of learning to hear the highest voice within yourself, and to give it authority over the others.</p>\n\n<hr />\n\n<h2 id=\"jacob-wrestles-the-individual-chooses-his-god\">Jacob Wrestles: The Individual Chooses His God</h2>\n\n<p>There is a moment in Genesis that I keep returning to. A man is alone at night, at the ford of the Jabbok river, and he wrestles with a figure until dawn. The figure cannot overcome him, but dislocates his hip. At daybreak the figure asks to be released. The man refuses unless he receives a blessing. The figure gives him a new name: <strong>Israel</strong>.</p>\n\n<p>The name means <em>he who wrestles with God</em>.</p>\n\n<p>On the level of narrative, this is Jacob. On the level of what I think it is pointing at, it is something more general: the moment at which a human being stops treating the divine as external command and starts engaging it as something to be <em>struggled with</em>, <em>understood</em>, and, crucially, <em>chosen</em>.</p>\n\n<p>Jacob does not simply obey. He fights. And in the fighting, he receives something: not answers, exactly, but a new identity. An identity built on the encounter itself.</p>\n\n<p>The god Jacob wrestles with is not easy. This is not a friendly deity dispensing comfort. The god who troubles you, who gives you a bad conscience about your brother Esau, who demands things that are genuinely hard. That god is not the most pleasant voice in the inner court. But Jacob, in the story, singles that voice out. He makes it <em>his</em> highest god, not because it is the most agreeable, but because it is the most honest.</p>\n\n<p>This is, I think, the crucial moment in the development that eventually produces monotheism: not the metaphysical claim that only one god exists, but the individual decision to rank one voice above all others: the voice that holds the impulsive forces in check, that demands more of you than you are naturally inclined to give.</p>\n\n<p>From that decision, a tribe is named. And the tribe’s identity is built on precisely that act of preferential wrestling.</p>\n\n<hr />\n\n<h2 id=\"externalization-systematization-scripture\">Externalization, Systematization, Scripture</h2>\n\n<p>Individual cognition becomes collective myth. Individual choice becomes tribal identity. Tribal identity gets written down.</p>\n\n<p>This is the arc that produces the Hebrew scriptures. Over the course of centuries, the decision that Jacob enacts alone at the Jabbok river gets systematized, debated, revised, and recorded. The earliest strata of the Hebrew Bible are not yet monotheistic in the later sense. El, the high god of the Canaanite pantheon, appears alongside Yahweh and is at points identified with him. Asherah, El’s consort, shows up in inscriptions from the period as a companion to Yahweh. Baal, the storm god, is the perpetual rival against whom the prophets rail, not because no one takes him seriously, but because too many people do. Psalm 82 describes with startling openness a divine council, a gathering of gods over whom Yahweh presides as judge: “God stands in the assembly of the gods; among the gods he pronounces judgment.” This is not strict monotheism. This is a god claiming supremacy within a council that still has other members.</p>\n\n<p>The god who began as the highest among many gradually becomes the only one worth the name. Not because the other forces stop existing in the inner life of human beings, but because the claim is now being made about which of those forces is truly divine in the fullest sense.</p>\n\n<p>Here is something I find genuinely striking about this development, looked at from the inside. The classical attributes of the Abrahamic God, omniscience, omnipotence, omnipresence, make perfect sense when you understand this god as the highest voice within the human being.</p>\n\n<p><strong>Omniscient</strong>: it knows everything that is going on inside of you. You cannot lie to your own conscience. You cannot hide a motive from the part of yourself that sees clearly. Whatever you tell other people, whatever face you present to the world, the voice within knows.</p>\n\n<p><strong>Omnipotent</strong>: it can overcome any obstacle inside of you. Given sufficient development, the highest voice within a human being can override fear, overcome compulsion, resist temptation, endure suffering. It is not always exercised, but the capacity is there. No internal force is in principle stronger than the one that has fully committed to what is right.</p>\n\n<p><strong>Omnipresent</strong>: you take it with you wherever you go. It is not tethered to a temple, a geography, a set of physical circumstances. The Babylonian exile made this theologically urgent, but it was already structurally true: the highest voice within a person is present in Babylon exactly as it is in Jerusalem.</p>\n\n<p>Read this way, the classical attributes are not mysterious supernatural claims. They are precise descriptions of what it is like to experience this voice from the inside. And they also, importantly, place the human being at the centre of his or her own world. Every person has this inner authority. Every person’s encounter with it is direct and personal. That is a deeply individualist claim, and it is no accident that cultures shaped by this theology developed the concept of individual rights with particular force.</p>\n\n<p>The Babylonian exile accelerates this. A small people, stripped of their land and their temple, needed a framework that could survive displacement. A god who is tied to a specific geography, who requires a physical sanctuary, cannot survive what the Israelites went through. What survives is a god who is portable because he is not fundamentally spatial: the god who speaks through conscience, through law, through the inner voice that says <em>you should not have done that</em> and <em>this is the right thing to do regardless of the cost</em>.</p>\n\n<p>The political and cognitive and cultural dimensions of this development are inseparable. It is a cognitive achievement (the recognition that one voice within the inner court deserves sovereign authority) that then becomes a cultural identity, which then becomes a political fact that shapes the next three thousand years of history.</p>\n\n<hr />\n\n<h2 id=\"three-traditions-one-divergence\">Three Traditions, One Divergence</h2>\n\n<p>Judaism, Christianity, and Islam all inherit this basic framework: one God, sovereign over all others, whose will ought to govern human life. Where they diverge is not primarily on theology but on <em>how the kingdom of this God comes to be</em>.</p>\n\n<p>Judaism has the ongoing practice of wrestling: living with the law, debating it, arguing with it across generations. Islam has submission before Allah (the word “Islam” itself means submission) to a completed and final revelation. Christianity claims the kingdom has already arrived in a person, and that its mechanism is not law or submission but <em>agape</em>, a love that does not depend on the worthiness of its object, demonstrated most fully by walking into death for its sake.</p>\n\n<p>These are genuinely different answers to the same question, and I will say plainly that from where I stand, from my own experience, my own reading, my own wrestling, the Christian answer is the one that rings most true to me. That is not a metaphysical proof. It is a first-person report. I hold it with the same openness I try to bring to everything else, ready to be shown where I am wrong. But I am not going to pretend to a neutrality I do not have. The difference matters for how each tradition structures a human life, and the structure that the Christian account proposes is the one I find most honest about what human beings are and what they most need.</p>\n\n<hr />\n\n<h2 id=\"where-i-land\">Where I Land</h2>\n\n<p>Here is my position, stated as honestly as I can manage it.</p>\n\n<p>I do not think the other forces in the inner court are illusions. I do not want to degrade them. Ares is real, in the sense that the force he represents (the capacity for aggression, for fierce protection of what matters) is genuinely present in human beings and genuinely useful when properly ordered. The same is true for every other figure in every other pantheon. These things exist. The polytheists were not wrong about their existence. They were describing something real.</p>\n\n<p>What I choose, freely, is to raise <em>agape</em> to the highest place among them. Not because I have proven it is correct. Not because I cannot imagine alternatives. But because, having examined the alternatives seriously, this is the one I can stand behind most fully.</p>\n\n<p>I take the Gospels as a substantively historical account, while acknowledging that parts of them are told in the heightened register of poetry and myth. What matters to me is not the miraculous frame but the pattern at the centre: a man who lives out the highest possible form of love, who is then killed by the forces of order and self-protection doing exactly what those forces reliably do, and who dies without bitterness and without calling down revenge.</p>\n\n<p>That death, and what follows it (not as factual claim but as a pattern that became a living reality in the communities who carried it) is what I mean when I say Jesus lives. The message did not die with him. The pattern he demonstrated continued to operate, to spread, to produce people who loved in that way. That is what makes it eternal: not physical survival, but the kind of survival that matters for patterns, which is transmission.</p>\n\n<p>I do not need factual certainty about the resurrection. If I knew it had happened, it would not be faith. It would be evidence-based belief. The point of a <em>leap of faith</em> (Kierkegaard’s phrase, not mine) is precisely that it goes beyond what can be established. The mystery is larger than what pure reason can contain. The Eucharist, shared across communities in every century since, is one demonstration of that: people enacting together what they cannot fully explain, and finding in the enactment something that exceeds the explanation.</p>\n\n<p>The people who killed the perfect example of agape went on living. And the response was not bitterness. It was forgiveness, continued love, and the insistence that the message was more important than the messenger’s survival. That is what makes the story, regardless of its historical details, one of the most powerful things ever transmitted.</p>\n\n<p>I hold all of this lightly. I know I might be wrong. I know that the tradition has been used to do terrible things, and that it does not get a pass on that history simply because the core pattern is beautiful. But when I ask myself which voice in the inner court I want to organize my life around, which one I want to give the final word when the others are arguing, this is still the answer I come back to.</p>\n\n<p><em>He who wrestles with God</em> is still wrestling. That is, I think, the honest description of where I am.</p>\n\n<hr />\n\n<p>If you want to push back on any of this, or if you have been wrestling with the same questions from a different direction, come find me on <a href=\"https://discord.gg/2BXuUY6hrX\">Discord</a>.</p>",
      "date_published": "2026-06-21T00:00:00+00:00",
      
      "date_modified": "2026-06-21T00:00:00+00:00",
      
      "authors": [ { "name": "Stephan Bökelmann" } ],
      "tags": ["philosophy","history","personal"]
    },
    
    {
      "id": "https://maxclerkwell.tech/posts/cunninghams-law-june-2026/",
      "url": "https://maxclerkwell.tech/posts/cunninghams-law-june-2026/",
      "title": "Post the Wrong Answer. You'll Learn More.",
      "summary": "Cunningham's Law says the fastest way to get the right answer online is to post the wrong one. I've been doing this accidentally for years, and I think there's something serious underneath the joke.",
      "content_html": "<p>Someone on Quora asked: “Murphy’s Law states that the best way to get the right answer on the internet is not to ask a question, it’s to post the wrong answer. What are some examples where you applied this law?”</p>\n\n<p>The first reply: “This is Cunningham’s Law and not Murphy’s Law.”</p>\n\n<p><img src=\"/assets/posts/cunninghams-law-june-2026/cunninghams-law-quora.png\" alt=\"A Quora question asks about &quot;Murphy's Law&quot; and the first answer corrects the premise: it's Cunningham's Law, not Murphy's.\" /></p>\n\n<p>The correction proves the law. Whoever posted that question, intentionally or not, ran the most elegant demonstration possible.</p>\n\n<hr />\n\n<h2 id=\"what-cunninghams-law-actually-says\">What Cunningham’s Law Actually Says</h2>\n\n<p>Ward Cunningham, the programmer who invented the wiki, is credited with the observation: the best way to get the right answer on the internet is not to ask a question but to post the wrong answer.</p>\n\n<p>It works because of a deep asymmetry in human motivation. Watching someone be wrong activates something in most people that a politely phrased question does not. A question is a request. A wrong statement is an affront. The correction impulse is faster, more reliable, and far less susceptible to the bystander effect than the helping impulse.</p>\n\n<p>This is not a cynical observation. It is a useful one.</p>\n\n<hr />\n\n<h2 id=\"i-have-been-doing-this-by-accident\">I Have Been Doing This By Accident</h2>\n\n<p>Some time ago I published a piece arguing that <a href=\"/posts/physics-description-not-explanation-june-2026/\">physics does not explain anything</a>, it only describes. I posted it mostly because I had been carrying the thought around for a while and wanted to write it down in a form that someone could push back on.</p>\n\n<p>On Xiaohongshu, the comments were good. Several people with actual philosophy of science backgrounds wrote in to sharpen the distinction between description and explanation, point me toward literature I had not read, and challenge the parts where my framing was loose. I was wrong in places. I am less wrong now. That exchange would not have happened if I had published a tentative, heavily hedged version of the piece that signalled I was not sure about any of it.</p>\n\n<p>The post did what I wanted it to do: it attracted people who knew more than me.</p>\n\n<p>This happens in person too. A few days ago I was talking to <a href=\"https://github.com/kathamatician\">Meihui Huang</a>, a physicist who had just given a <a href=\"https://github.com/kathamatician/neutrino_oscillations\">presentation on neutrino oscillations</a>. She mentioned that during the talk, someone had asked why a particular graph showed a sharp peak around 6 PeV. When she told me about it, I immediately started arguing that the most likely explanation was blazars: their jets produce a fairly narrow energy distribution, so if you detect them they can look nearly monoenergetic. I was confident. I was wrong.</p>\n\n<p>She listened, thought about it, and later that night sent me a message pointing out the Glashow resonance. At that moment I realised I had not been looking at an energy spectrum of detected events at all. I had been looking at a diagram of the effective cross section. Two completely different things. I found the paper, read it, and corrected my understanding.</p>\n\n<p>I felt bad for having argued so freely about something I had misread. And at the same time I felt genuine relief, the specific relief of being a little less wrong than I was the day before. That relief is what I am actually after.</p>\n\n<hr />\n\n<h2 id=\"poppers-shadow\">Popper’s Shadow</h2>\n\n<p>There is a serious idea underneath the joke.</p>\n\n<p>Karl Popper argued that falsifiability is the boundary between science and non-science. A claim that cannot be tested against reality, that cannot, in principle, be shown to be wrong, is not a scientific claim. You learn nothing from a theory that cannot fail.</p>\n\n<p>The same logic applies to arguments. A half-formed thought you keep entirely to yourself cannot be corrected. It sits there, unchallenged, and you gradually mistake its familiarity for its correctness. Publishing it, even when it is rough, gives it the chance to fail. And when it fails in front of people who know things you do not, you gain something a longer incubation period would never have given you.</p>\n\n<p>I am not recommending bad faith. Posting a deliberately wrong statement to generate traffic is a different thing entirely, and I do not find it interesting as a practice. What I am recommending is publishing the thought at the stage where you actually hold it, rather than waiting for a confidence that the subject matter may never justify. Silicon Valley packaged this idea into slogans: “fail fast”, “move fast and break things”, “failing forward”. The slogans got mocked, partly because companies used them to excuse genuine carelessness. But the underlying principle is not wrong. Readiness is often a feeling, not a state. Waiting for it means waiting indefinitely while someone else does the thing imperfectly and learns from it. Your failures, published early, are more educational than your polished conclusions published late.</p>\n\n<hr />\n\n<h2 id=\"the-practical-consequence\">The Practical Consequence</h2>\n\n<p>Most of what I publish here is half-cooked. I mean that as a statement of fact, not an apology. I write when I have something I want to think through out loud, not when I have arrived at a settled conclusion. Settled conclusions rarely need writing down. The unsettled ones do.</p>\n\n<p>If something here is wrong, I want to know. Not because being corrected is pleasant, but because it is the only reliable mechanism I have for closing the gap between what I think I understand and what I actually understand. The gap is always larger than it feels from the inside.</p>\n\n<p>You can start by leaving a comment. Maybe you should try it too one day.</p>\n\n<div><a href=\"https://discord.gg/2BXuUY6hrX\" class=\"link-card-discord\" target=\"_blank\" rel=\"noopener noreferrer\"><i class=\"fab fa-discord\"></i><div class=\"discord-text\"><span class=\"discord-name\">Discord — Full Stack Engineering</span><span class=\"discord-note\">Direct access to me and my colleagues. Webinars, live Q&amp;A, and community discussions for engineers across the full stack.</span><span class=\"discord-join\">Join the server →</span></div></a></div>",
      "date_published": "2026-06-19T00:00:00+00:00",
      
      "date_modified": "2026-06-19T00:00:00+00:00",
      
      "authors": [ { "name": "Stephan Bökelmann" } ],
      "tags": ["philosophy","personal"]
    },
    
    {
      "id": "https://maxclerkwell.tech/posts/zero-to-one-python-libraries-environments-june-2026/",
      "url": "https://maxclerkwell.tech/posts/zero-to-one-python-libraries-environments-june-2026/",
      "title": "Zero to One: Python Libraries, Environments, and Working Like You Mean It",
      "summary": "You do not have to write everything from scratch. Python has thousands of libraries. But the way you pull them in matters, and most introductions get that part wrong.",
      "content_html": "<p><img src=\"/assets/posts/zero-to-one-python-libraries-environments-june-2026/python-logo.png\" alt=\"Python logo\" /></p>\n\n<p><em>Python and the Python logo are trademarks of the Python Software Foundation.</em></p>\n\n<p>You have heard of Python. Maybe someone mentioned it in passing, maybe you saw a snippet online and thought it looked approachable. You are not sure where to start. This article is for you.</p>\n\n<p>The goal is not to teach you the language. It is to show you the three things that matter before you write a single function: how to explore interactively, where to find code that already exists, and how to manage dependencies without making a mess of your machine. By the end, you will have a working Python script, a proper project structure, and an understanding of why each piece is there.</p>\n\n<p>One thing before we start: this article assumes Linux. If you are serious about programming and you are not on Linux yet, fix that first. Debian is the best-documented distribution for development work, and everything here assumes it.</p>\n\n<hr />\n\n<h2 id=\"the-repl-exploration-not-production\">The REPL: Exploration, Not Production</h2>\n\n<p>Open a terminal and type <code class=\"language-plaintext highlighter-rouge\">python3</code>. You are now in the Read-Eval-Print Loop. Type an expression, press Enter, get a result.</p>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>&gt;&gt;&gt; 2 ** 10\n1024\n&gt;&gt;&gt; \"hello\" + \" \" + \"world\"\n'hello world'\n</code></pre></div></div>\n\n<p>The REPL is useful for two things: quick numerical experiments and exploring an unfamiliar library before you commit to using it. It is not where you build anything that needs to work tomorrow. Code you type in the REPL disappears when you close it. Treat it as a scratchpad.</p>\n\n<p>When you are done, type <code class=\"language-plaintext highlighter-rouge\">exit()</code> and press Enter to leave the REPL and return to your normal shell prompt.</p>\n\n<p>One specific use I find underrated: the REPL as a filesystem explorer. We will build our own file exploration tool in Python in a later article, and the REPL will be a great place to prototype it.</p>\n\n<hr />\n\n<h2 id=\"the-standard-library-use-it-first\">The Standard Library: Use It First</h2>\n\n<p>Python ships with a standard library that covers an enormous amount of ground. Before reaching for a third-party package, check whether the stdlib already does what you need.</p>\n\n<p>Let us look at <code class=\"language-plaintext highlighter-rouge\">math</code> as a first example. You can import it directly in the REPL and start calling functions:</p>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>&gt;&gt;&gt; import math\n&gt;&gt;&gt; math.sqrt(2)\n1.4142135623730951\n&gt;&gt;&gt; math.cos(math.pi)\n-1.0\n&gt;&gt;&gt; math.floor(3.7)\n3\n&gt;&gt;&gt; exit()\n</code></pre></div></div>\n\n<p>The rule is simple: if there is a stdlib module for your problem, use it. Import it by its full name and keep the namespace, exactly as shown above.</p>\n\n<blockquote>\n  <p><strong>Disclaimer:</strong> If you have looked into other Python tutorials you will likely find things like <code class=\"language-plaintext highlighter-rouge\">from math import cos</code> or <code class=\"language-plaintext highlighter-rouge\">import math as m</code>. Even though these are perfectly valid Python, forget about them for a few days. If you want to use a function from <code class=\"language-plaintext highlighter-rouge\">math</code> or any other library, write out the full namespace: <code class=\"language-plaintext highlighter-rouge\">math.cos(...)</code>, not just <code class=\"language-plaintext highlighter-rouge\">cos(...)</code>. The reason is readability. When you read code written by someone else, or by yourself six months later, <code class=\"language-plaintext highlighter-rouge\">math.cos</code> tells you immediately where that function comes from. A bare <code class=\"language-plaintext highlighter-rouge\">cos</code> tells you nothing. Namespace collisions in larger codebases are subtle and painful. The explicit prefix costs you a few extra characters and saves you an hour of debugging three months from now.</p>\n</blockquote>\n\n<hr />\n\n<h2 id=\"script-files-and-the-__main__-guard\">Script Files and the <code class=\"language-plaintext highlighter-rouge\">__main__</code> Guard</h2>\n\n<p>The REPL is temporary. Scripts are not. When you write Python that should run as a program, save it to a <code class=\"language-plaintext highlighter-rouge\">.py</code> file.</p>\n\n<p>Open a fresh file, for example with <code class=\"language-plaintext highlighter-rouge\">vim my_script.py</code>, and write the following:</p>\n\n<div class=\"language-python highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"kn\">import</span> <span class=\"nn\">math</span>\n\n<span class=\"k\">if</span> <span class=\"n\">__name__</span> <span class=\"o\">==</span> <span class=\"s\">\"__main__\"</span><span class=\"p\">:</span>\n    <span class=\"n\">angle</span> <span class=\"o\">=</span> <span class=\"n\">math</span><span class=\"p\">.</span><span class=\"n\">pi</span> <span class=\"o\">/</span> <span class=\"mi\">4</span>\n    <span class=\"n\">result</span> <span class=\"o\">=</span> <span class=\"n\">math</span><span class=\"p\">.</span><span class=\"n\">cos</span><span class=\"p\">(</span><span class=\"n\">angle</span><span class=\"p\">)</span>\n    <span class=\"k\">print</span><span class=\"p\">(</span><span class=\"n\">result</span><span class=\"p\">)</span>\n</code></pre></div></div>\n\n<p>If you have ever used other programming languages you may already be familiar with the concept of a main function: a designated entry point that the runtime looks for when it starts your program. In C you write <code class=\"language-plaintext highlighter-rouge\">int main()</code>, in Java <code class=\"language-plaintext highlighter-rouge\">public static void main(String[] args)</code>. Python does not enforce this at the language level, but the <code class=\"language-plaintext highlighter-rouge\">if __name__ == \"__main__\":</code> block serves the same purpose. When Python runs a file directly, it sets the special variable <code class=\"language-plaintext highlighter-rouge\">__name__</code> to the string <code class=\"language-plaintext highlighter-rouge\">\"__main__\"</code>. When a file is imported as a module by some other file, <code class=\"language-plaintext highlighter-rouge\">__name__</code> is set to the module’s name instead. The block only executes in the first case.</p>\n\n<blockquote>\n  <p><strong>Please do not omit this.</strong> You may have seen it missing in other tutorials and assumed it was optional noise. In these simple examples, it is technically not required. But in the general case, you want to make sure that your script only runs its logic when it is actually invoked as a program, not when it is imported by something else. The moment you start writing larger programs where one file imports from another, any module-level code that prints output, modifies state, or starts a process will fire on import. The <code class=\"language-plaintext highlighter-rouge\">if __name__ == \"__main__\":</code> guard prevents that. Form the habit now, before it costs you something.</p>\n</blockquote>\n\n<p>Run the script:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>python3 my_script.py\n</code></pre></div></div>\n\n<p>You should see <code class=\"language-plaintext highlighter-rouge\">0.7071067811865476</code> printed to your terminal.</p>\n\n<hr />\n\n<h2 id=\"dependencies-why-we-need-more-than-the-standard-library\">Dependencies: Why We Need More Than the Standard Library</h2>\n\n<p>The standard library is large, but it has intentional gaps. It does not include tools for plotting, machine learning, HTTP clients with advanced retry logic, colored terminal output, image processing, and thousands of other things. For these, you reach for third-party libraries: code that other people wrote, packaged up, and made available for you to install.</p>\n\n<p>If you have worked in other programming languages, you may already know that pulling in extra libraries can get messy very quickly. In C or C++ you are often tracking down header files and linking flags by hand. In Java the situation is better with Maven or Gradle, but still complex. Python has a particularly clean approach to this problem, and understanding it well from the beginning will save you a lot of pain.</p>\n\n<p>The question is: how do you install third-party libraries without eventually creating a conflict-ridden mess on your machine?</p>\n\n<h3 id=\"a-brief-history-so-you-understand-why-things-are-the-way-they-are\">A brief history, so you understand why things are the way they are</h3>\n\n<p>For years, the standard advice was: install packages with a tool called <code class=\"language-plaintext highlighter-rouge\">pip</code>. This works until it does not. Pip installs packages into whichever Python environment is active. If you just run it without thinking, that is your system Python, the one your operating system may itself depend on. The moment you have two projects that need different versions of the same library, you have a conflict. On Debian-based systems, a carelessly pip-installed package can interfere with system tools that rely on Python. Things can become genuinely broken this way.</p>\n\n<p>The community’s first answer was virtual environments, and the concept is important enough to understand clearly. A Python environment is the combination of a Python interpreter and a set of installed packages. When Python looks for a library you import, it searches inside the active environment. The system Python is one environment, but you can create as many additional ones as you like, each with their own interpreter copy and their own isolated set of installed packages.</p>\n\n<p>A virtual environment, then, is an environment scoped to a single project. You create it, point your shell at it, and anything you install from that moment forward lands inside that project’s isolated directory and nowhere else. You can have project A using version 1.2 of some library and project B using version 2.0 of the same library, in two separate environments, with no conflict between them. When you are done with a project, you can delete the environment directory and your system is exactly as clean as it was before.</p>\n\n<p>This is the right idea. The problem was in the tooling around it. Pip and the original <code class=\"language-plaintext highlighter-rouge\">venv</code> module gave you the isolation, but they did not give you a reliable way to reproduce an environment exactly. The convention was a <code class=\"language-plaintext highlighter-rouge\">requirements.txt</code> file listing package names and optional version pins. But it did not capture transitive dependencies: the packages that your packages depend on. Someone else clones your repo, installs from your <code class=\"language-plaintext highlighter-rouge\">requirements.txt</code>, gets slightly different transitive versions because the ecosystem has moved on, and your code breaks in subtle ways.</p>\n\n<p>Forget about pip for the next few days or weeks. You now know it exists, and if you see it in older tutorials or projects you will recognize it. But we are going to use a better tool from the start.</p>\n\n<h3 id=\"pyprojecttoml\">pyproject.toml</h3>\n\n<p><code class=\"language-plaintext highlighter-rouge\">pyproject.toml</code> is the modern standard for declaring what a Python project is and what it needs. It is a structured, machine-readable file that tools can parse, diff, and reason about. It is the right place to record your dependencies.</p>\n\n<p>Here is what a minimal one looks like:</p>\n\n<div class=\"language-toml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nn\">[project]</span>\n<span class=\"py\">name</span> <span class=\"p\">=</span> <span class=\"s\">\"my-project\"</span>\n<span class=\"py\">version</span> <span class=\"p\">=</span> <span class=\"s\">\"0.1.0\"</span>\n<span class=\"py\">requires-python</span> <span class=\"p\">=</span> <span class=\"py\">\"&gt;</span><span class=\"p\">=</span><span class=\"mf\">3.11</span><span class=\"s\">\"</span><span class=\"err\">\n</span><span class=\"py\">dependencies</span> <span class=\"p\">=</span> <span class=\"p\">[</span>\n    <span class=\"s\">\"rich\"</span><span class=\"p\">,</span>\n<span class=\"p\">]</span>\n</code></pre></div></div>\n\n<p>The <code class=\"language-plaintext highlighter-rouge\">[project]</code> table holds your project’s metadata. The <code class=\"language-plaintext highlighter-rouge\">dependencies</code> list is where third-party packages go. The standard library never appears here because it is always available with no installation needed.</p>\n\n<hr />\n\n<h2 id=\"uv-install-this-now\">uv: Install This Now</h2>\n\n<p><code class=\"language-plaintext highlighter-rouge\">uv</code> is a project and dependency manager for Python. It is not a narrow replacement for pip; it does considerably more. It manages Python interpreter versions, creates and manages virtual environments, resolves and locks dependencies, and gives you a single coherent workflow instead of several disconnected tools stitched together. It is what we will use for the rest of this article.</p>\n\n<p>Install it once:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>curl <span class=\"nt\">-Ls</span> https://astral.sh/uv/install.sh | sh\n</code></pre></div></div>\n\n<blockquote>\n  <p><strong>Before you run that:</strong> piping a script fetched from the internet directly into your shell for execution is a genuine security risk, and you should not make a habit of doing it just because some article tells you to. What that command does is download a shell script from astral.sh and hand it directly to <code class=\"language-plaintext highlighter-rouge\">sh</code> without you ever seeing what is in it. If the server were compromised, or if you mistyped the URL and landed somewhere else, your machine would execute whatever it received. The correct habit is to inspect first: open <code class=\"language-plaintext highlighter-rouge\">https://astral.sh/uv/install.sh</code> in your browser, read through it, and satisfy yourself that it does what it claims. In this case it downloads a prebuilt binary and puts it in <code class=\"language-plaintext highlighter-rouge\">~/.local/bin</code>. It is not doing anything exotic. But the habit of checking is more important than this particular script, and I would rather you slow down here than learn to run things blindly.</p>\n</blockquote>\n\n<p>Then close and reopen your terminal, or run <code class=\"language-plaintext highlighter-rouge\">source ~/.bashrc</code>, so the shell picks up the new <code class=\"language-plaintext highlighter-rouge\">uv</code> command.</p>\n\n<hr />\n\n<h2 id=\"putting-it-together\">Putting It Together</h2>\n\n<p>Now we have all the concepts. Let us build an actual project.</p>\n\n<p>Start by creating a directory and writing the <code class=\"language-plaintext highlighter-rouge\">pyproject.toml</code> yourself:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">mkdir </span>my-project\n<span class=\"nb\">cd </span>my-project\n</code></pre></div></div>\n\n<p>Open a new file called <code class=\"language-plaintext highlighter-rouge\">pyproject.toml</code> with your editor and write the following:</p>\n\n<div class=\"language-toml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nn\">[project]</span>\n<span class=\"py\">name</span> <span class=\"p\">=</span> <span class=\"s\">\"my-project\"</span>\n<span class=\"py\">version</span> <span class=\"p\">=</span> <span class=\"s\">\"0.1.0\"</span>\n<span class=\"py\">requires-python</span> <span class=\"p\">=</span> <span class=\"py\">\"&gt;</span><span class=\"p\">=</span><span class=\"mf\">3.11</span><span class=\"s\">\"</span><span class=\"err\">\n</span><span class=\"py\">dependencies</span> <span class=\"p\">=</span> <span class=\"p\">[</span>\n    <span class=\"s\">\"rich\"</span><span class=\"p\">,</span>\n<span class=\"p\">]</span>\n</code></pre></div></div>\n\n<p>Now create <code class=\"language-plaintext highlighter-rouge\">main.py</code>:</p>\n\n<div class=\"language-python highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"kn\">import</span> <span class=\"nn\">math</span>\n<span class=\"kn\">import</span> <span class=\"nn\">rich.console</span>\n<span class=\"kn\">import</span> <span class=\"nn\">rich.panel</span>\n\n<span class=\"n\">console</span> <span class=\"o\">=</span> <span class=\"n\">rich</span><span class=\"p\">.</span><span class=\"n\">console</span><span class=\"p\">.</span><span class=\"n\">Console</span><span class=\"p\">()</span>\n\n<span class=\"k\">if</span> <span class=\"n\">__name__</span> <span class=\"o\">==</span> <span class=\"s\">\"__main__\"</span><span class=\"p\">:</span>\n    <span class=\"n\">angle</span> <span class=\"o\">=</span> <span class=\"n\">math</span><span class=\"p\">.</span><span class=\"n\">pi</span> <span class=\"o\">/</span> <span class=\"mi\">6</span>\n    <span class=\"n\">result</span> <span class=\"o\">=</span> <span class=\"n\">math</span><span class=\"p\">.</span><span class=\"n\">sin</span><span class=\"p\">(</span><span class=\"n\">angle</span><span class=\"p\">)</span>\n    <span class=\"n\">message</span> <span class=\"o\">=</span> <span class=\"sa\">f</span><span class=\"s\">\"sin(pi/6) = </span><span class=\"si\">{</span><span class=\"n\">result</span><span class=\"si\">:</span><span class=\"p\">.</span><span class=\"mi\">6</span><span class=\"n\">f</span><span class=\"si\">}</span><span class=\"s\">\"</span>\n    <span class=\"n\">panel</span> <span class=\"o\">=</span> <span class=\"n\">rich</span><span class=\"p\">.</span><span class=\"n\">panel</span><span class=\"p\">.</span><span class=\"n\">Panel</span><span class=\"p\">(</span><span class=\"n\">message</span><span class=\"p\">,</span> <span class=\"n\">title</span><span class=\"o\">=</span><span class=\"s\">\"Result\"</span><span class=\"p\">,</span> <span class=\"n\">style</span><span class=\"o\">=</span><span class=\"s\">\"bold green\"</span><span class=\"p\">)</span>\n    <span class=\"n\">console</span><span class=\"p\">.</span><span class=\"k\">print</span><span class=\"p\">(</span><span class=\"n\">panel</span><span class=\"p\">)</span>\n</code></pre></div></div>\n\n<p>Your project directory now looks like this:</p>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>my-project/\n├── pyproject.toml\n└── main.py\n</code></pre></div></div>\n\n<p>Now let uv read the <code class=\"language-plaintext highlighter-rouge\">pyproject.toml</code>, create a virtual environment, and install everything declared in <code class=\"language-plaintext highlighter-rouge\">dependencies</code>:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>uv <span class=\"nb\">sync</span>\n</code></pre></div></div>\n\n<p>That is all. <code class=\"language-plaintext highlighter-rouge\">uv sync</code> reads your <code class=\"language-plaintext highlighter-rouge\">pyproject.toml</code>, creates a <code class=\"language-plaintext highlighter-rouge\">.venv</code> directory in your project folder, installs <code class=\"language-plaintext highlighter-rouge\">rich</code> and everything it depends on into that isolated environment, and writes a <code class=\"language-plaintext highlighter-rouge\">uv.lock</code> file that records the exact version of every package that was installed.</p>\n\n<p>Run the script:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>uv run main.py\n</code></pre></div></div>\n\n<p>You should see a green bordered panel in your terminal with the result. The <code class=\"language-plaintext highlighter-rouge\">math</code> module came from the standard library. The <code class=\"language-plaintext highlighter-rouge\">rich</code> library came from PyPI (the Python Package Index, a public registry at pypi.org where anyone can publish Python packages and where <code class=\"language-plaintext highlighter-rouge\">uv</code> fetches them from by default), installed into the project’s isolated environment.</p>\n\n<p>Your project directory now contains:</p>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>my-project/\n├── .venv/\n├── uv.lock\n├── pyproject.toml\n└── main.py\n</code></pre></div></div>\n\n<p>The lock file is the thing that <code class=\"language-plaintext highlighter-rouge\">requirements.txt</code> was trying to be and never quite was. It records every transitive dependency at an exact version. Someone else gets a copy of your project, runs <code class=\"language-plaintext highlighter-rouge\">uv sync</code>, and they get exactly the same environment you had. No drift, no surprises.</p>\n\n<h3 id=\"the-shortcut-uv-init-and-uv-add\">The shortcut: uv init and uv add</h3>\n\n<p>Once you understand what the files are for, you do not have to write them by hand every time. <code class=\"language-plaintext highlighter-rouge\">uv init</code> creates the project structure for you:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>uv init my-project\n<span class=\"nb\">cd </span>my-project\n</code></pre></div></div>\n\n<p>This generates a <code class=\"language-plaintext highlighter-rouge\">pyproject.toml</code>, a <code class=\"language-plaintext highlighter-rouge\">main.py</code>, a <code class=\"language-plaintext highlighter-rouge\">.python-version</code> file pinning the interpreter version, and a <code class=\"language-plaintext highlighter-rouge\">.gitignore</code>. And instead of editing <code class=\"language-plaintext highlighter-rouge\">pyproject.toml</code> by hand to add a dependency, you can run:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>uv add rich\n</code></pre></div></div>\n\n<p>This updates <code class=\"language-plaintext highlighter-rouge\">pyproject.toml</code>, resolves the new dependency, and runs the equivalent of <code class=\"language-plaintext highlighter-rouge\">uv sync</code> automatically. The result is the same as what you built by hand; this is just faster once you know what it is doing.</p>\n\n<hr />\n\n<h2 id=\"where-this-goes-next\">Where This Goes Next</h2>\n\n<p>Libraries exist for almost everything you will want to do. The skill is knowing when to reach for one, which one to trust, and how to isolate it from the rest of your system. You now have all three pieces in place.</p>\n\n<p>I will write about another small Python project here soon. If you already have a specific problem you were trying to solve when you started reading this, feel free to tell me what it is and I will write something up around that.</p>\n\n<p>One last thing: you may have noticed that <code class=\"language-plaintext highlighter-rouge\">uv init</code> created a file called <code class=\"language-plaintext highlighter-rouge\">.gitignore</code> without explanation. Git is a tool for tracking the history of your files and sharing projects with others. It is the standard way to manage code over time, and <code class=\"language-plaintext highlighter-rouge\">.venv/</code> is the kind of directory you do not want it touching. I have not introduced git yet because it deserves its own article. There is a Zero-to-One for git coming. Until then, just leave those files where they are.</p>",
      "date_published": "2026-06-16T00:00:00+00:00",
      
      "date_modified": "2026-06-16T00:00:00+00:00",
      
      "authors": [ { "name": "Stephan Bökelmann" } ],
      "tags": ["python","education","zero-to-one"]
    },
    
    {
      "id": "https://maxclerkwell.tech/posts/wtf-are-fpgas-june-2026/",
      "url": "https://maxclerkwell.tech/posts/wtf-are-fpgas-june-2026/",
      "title": "WTF are FPGAs: A Beginner's Overview of Field-Programmable Gate Arrays",
      "summary": "FPGAs are not microcontrollers with more pins. They are a fundamentally different category of IC, and understanding what lives inside one changes how you think about digital hardware entirely.",
      "content_html": "<p><img src=\"/assets/posts/wtf-are-fpgas-june-2026/basys3-icestick-title.png\" alt=\"A Lattice iCEstick USB dongle next to a Digilent Basys3 development board: two ends of the FPGA beginner spectrum, from a 25-euro USB stick to a full Artix-7 board with switches, seven-segment displays, and Pmod connectors\" /></p>\n\n<p>📺 Companion video:</p>\n\n<iframe width=\"100%\" height=\"400\" src=\"https://www.youtube.com/embed/IRem743Eb7E\" title=\"WTF are FPGAs\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\" allowfullscreen=\"\" style=\"border-radius:6px; margin: 1em 0;\"></iframe>\n\n<p>If you hang around electronics forums long enough, someone will eventually ask whether they should use a microcontroller or an FPGA for their project. The question itself reveals a misunderstanding. FPGAs do not compete with microcontrollers. They occupy a different region of the design space entirely, and conflating them obscures what makes each tool genuinely useful.</p>\n\n<p>This article is a ground-up introduction to what FPGAs actually are, where they come from, what lives inside them, and what it takes to put one to work.</p>\n\n<hr />\n\n<h2 id=\"a-brief-history-from-74-series-to-field-programmable\">A Brief History: From 74-Series to Field-Programmable</h2>\n\n<p>When I started building digital circuits in the mid-nineties, the standard approach was to wire up 74-series logic ICs on a breadboard. These are small, cheap packages, each containing a handful of logic gates: AND, OR, NAND, NOR, flip-flops, latches, multiplexers, and hundreds of other building blocks. With enough of them you can build anything. Ben Eater’s <a href=\"https://eater.net/8bit\">8-bit breadboard CPU</a> series is the best demonstration of this I know of: a fully functional processor assembled from discrete 74-series chips, wire by wire.</p>\n\n<p>The 74-series approach has three practical problems:</p>\n\n<ol>\n  <li><strong>Cost and sourcing.</strong> You need a lot of parts. Each chip costs a little, but a complex design needs dozens of them, and they all have to be in stock.</li>\n  <li><strong>Space and wiring.</strong> A breadboard fills up fast. Long wire runs between chips make debugging painful and layouts fragile.</li>\n  <li><strong>Signal integrity at high frequencies.</strong> Every wire between chips is a small antenna. At high clock rates, propagation delays and reflections from the physical interconnects become a serious problem.</li>\n</ol>\n\n<p>A middle ground emerged in the form of CPLDs (Complex Programmable Logic Devices), which compressed some of this logic into a single programmable package. CPLDs are worth knowing exist, but we will not go further into them here. What followed CPLDs is the focus of this article.</p>\n\n<hr />\n\n<h2 id=\"what-is-an-fpga\">What Is an FPGA?</h2>\n\n<p>FPGA stands for Field-Programmable Gate Array. The name already tells you the core idea: an array of logic gates that can be wired up in the field, meaning after manufacturing, by the engineer using it rather than by the chip foundry.</p>\n\n<p>Instead of buying thirty separate 74-series chips and wiring them together on a breadboard, you buy one FPGA. That single IC contains thousands of configurable logic elements and a programmable interconnect fabric. You decide how they connect. The chip stays the same; the configuration changes.</p>\n\n<p>FPGAs are not new, but they are not ancient either. Xilinx introduced the first commercial FPGA in 1985. That is younger than the personal computer and younger than the 74-series itself. The technology has matured enormously since then, but the fundamental idea has remained constant.</p>\n\n<hr />\n\n<h2 id=\"what-lives-inside-an-fpga\">What Lives Inside an FPGA</h2>\n\n<p>Here is a simplified mental model. Picture a rectangular grid, thousands of cells arranged in a checkerboard pattern. Each cell contains a small block of configurable logic. Between the cells runs an interconnect fabric, a dense mesh of wires called the <strong>routing fabric</strong>, which is the technical term for what I will keep calling “highways” because that is exactly what they look like from above.</p>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>+-------+     +-------+     +-------+\n| LOGIC |--?--| LOGIC |--?--| LOGIC |\n+-------+     +-------+     +-------+\n    |     \\  /     |     \\  /     |\n   ---    ---     ---    ---     ---\n    |      |       |      |       |\n+-------+     +-------+     +-------+\n| LOGIC |--?--| LOGIC |--?--| LOGIC |\n+-------+     +-------+     +-------+\n    |                              |\n   I/O                            I/O\n</code></pre></div></div>\n\n<p>Each logic cell connects to the routing fabric through a set of transistors. Every one of those transistors has a corresponding address the on-chip memory-cells, usually a static random access memory device. When you load a configuration into that SRAM, every bit that reads <code class=\"language-plaintext highlighter-rouge\">1</code> closes its transistor, connecting that logic cell to that wire in the routing fabric. Every bit that reads <code class=\"language-plaintext highlighter-rouge\">0</code> leaves the connection open.</p>\n\n<p><img src=\"/assets/posts/wtf-are-fpgas-june-2026/fpga-architecture-diagram.png\" alt=\"FPGA architecture diagram showing a grid of orange Logic Blocks connected by a Programmable Interconnect fabric, with blue Input/Output Blocks around the perimeter. Source: Afzal et al., J. Phys.: Conf. Ser. 2786 012004 (2024), doi:10.1088/1742-6596/2786/1/012004\" /></p>\n\n<p>The result: by writing a particular pattern of ones and zeros into the SRAM, you define a complete network of logic gates. Change the pattern, and you define a different network. This is what “field programmable” means in practice.</p>\n\n<p>Because the configuration lives in SRAM, it disappears when power is removed. Most iCE40-based boards (including the iCEstick I used in <a href=\"/posts/fpga-blinky-vhdl-icestick-may-2026/\">my VHDL zero-to-one post</a>) work this way: an external SPI flash stores the bitstream and loads it into the FPGA at startup. Some FPGAs integrate the flash on-chip, which simplifies the board design but adds cost. For prototyping and learning, external flash or direct USB programming is the norm.</p>\n\n<h3 id=\"logic-cells-not-quite-what-the-74-series-used\">Logic Cells: Not Quite What the 74-Series Used</h3>\n\n<p>Early FPGAs implemented logic cells as collections of primitive gates: AND, OR, NOT. Modern FPGAs typically use <strong>Look-Up Tables</strong> (LUTs) instead. A LUT is a small block of SRAM that implements any boolean function of N inputs by storing the truth table directly. A 4-input LUT can implement any function of four variables by pre-loading all sixteen output values.</p>\n\n<p>This is a slightly different model from discrete gates, but the abstraction holds: you can still think of each logic cell as a configurable gate. The LUT just makes the implementation more flexible and the area more efficient. For the purposes of understanding what an FPGA is, the “grid of gates connected by programmable highways” model is accurate enough.</p>\n\n<hr />\n\n<h2 id=\"why-fpgas-exist-the-parallelism-argument\">Why FPGAs Exist: The Parallelism Argument</h2>\n\n<p>A microcontroller executes instructions sequentially. One instruction runs, it finishes, the next one starts. Even with pipelining and multiple cores, there is a fundamental serialization happening at some level.</p>\n\n<p>An FPGA does not execute instructions. It implements circuits. When you configure an FPGA, you describe hardware, not a program. Signal propagation through that hardware happens simultaneously across all configured paths. The time it takes to compute a result depends primarily on how long the signal takes to travel through gates and wires, not on how many other computations are queued up.</p>\n\n<p>This is why FPGAs appear in domains where timing is everything:</p>\n\n<ul>\n  <li><strong>High-frequency trading</strong>: decisions made in nanoseconds, not microseconds, can mean the difference between a filled order and a missed one. An FPGA can evaluate market conditions in constant, predictable time.</li>\n  <li><strong>Particle physics trigger systems</strong>: at facilities like CERN, detectors produce far more data than can be stored or transmitted. FPGAs evaluate trigger conditions in real time and decide within microseconds whether an event is worth recording.</li>\n  <li><strong>DAQ systems</strong>: data acquisition pipelines that need to handle thousands of parallel analog channels without dropping samples. At EP1 at Ruhr-Universität Bochum, Florian Feldbauer, Niels Boelger, and I use Kintex-7 FPGAs to read out HV-MAPS sensors for the PANDA and LHCb experiments. The boards in the photo below are from exactly that setup.</li>\n</ul>\n\n<p><img src=\"/assets/posts/wtf-are-fpgas-june-2026/kintex7-oszi.png\" alt=\"Two Xilinx Kintex-7 development boards connected to an oscilloscope for post-implementation signal verification, used at EP1/RUB for HV-MAPS sensor readout in the PANDA and LHCb experiments\" /></p>\n\n<ul>\n  <li><strong>Custom processor development</strong>: when you are designing a new CPU architecture, an FPGA lets you instantiate your design in real silicon-like hardware before committing to a mask. This is how many research processors are prototyped.</li>\n  <li><strong>Cryptography</strong>: certain algorithms map extremely efficiently onto parallel hardware. An FPGA can execute specific cryptographic functions with far less energy than a general-purpose CPU or even a GPU, because every gate is doing exactly one useful thing.</li>\n</ul>\n\n<p>None of these use cases require an FPGA to be “better” than a CPU in general. They require it to be different, specifically in how it handles parallelism and timing determinism.</p>\n\n<hr />\n\n<h2 id=\"from-hdl-to-bitstream-the-toolchain\">From HDL to Bitstream: The Toolchain</h2>\n\n<p>Here is the part that surprises most people when they first encounter FPGAs: getting a design onto a chip is not like flashing firmware. It is a multi-stage engineering process that has more in common with compiling a programming language than with writing a shell script.</p>\n\n<h3 id=\"step-1-describe-the-hardware\">Step 1: Describe the Hardware</h3>\n\n<p>The starting point is a Hardware Description Language. The two dominant ones are <strong>VHDL</strong> and <strong>Verilog</strong>. I prefer VHDL, and I will use it in examples.</p>\n\n<p>A HDL looks like a programming language but is not one. The analogy I find useful: a program is a recipe, and a recipe is not the meal. Similarly, a HDL file is a description of hardware, and that description is not the hardware. A closer analogy is SVG: a text file that describes a graphic, which a renderer then turns into pixels. In the HDL world, the renderer is the synthesis tool.</p>\n\n<p>VHDL and Verilog describe hardware in terms of <strong>blocks</strong> and <strong>signals</strong>. A block (called an <code class=\"language-plaintext highlighter-rouge\">entity</code> in VHDL or a <code class=\"language-plaintext highlighter-rouge\">module</code> in Verilog) defines inputs, outputs, and the logic that maps one to the other. Blocks can be nested and composed, much like components in a schematic. Signals connect blocks together and carry values.</p>\n\n<div class=\"language-vhdl highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">entity</span> <span class=\"n\">blinky</span> <span class=\"k\">is</span>\n  <span class=\"k\">port</span> <span class=\"p\">(</span>\n    <span class=\"n\">clk</span> <span class=\"p\">:</span> <span class=\"k\">in</span>  <span class=\"kt\">std_logic</span><span class=\"p\">;</span>\n    <span class=\"n\">led</span> <span class=\"p\">:</span> <span class=\"k\">out</span> <span class=\"kt\">std_logic</span>\n  <span class=\"p\">);</span>\n<span class=\"k\">end</span> <span class=\"k\">entity</span><span class=\"p\">;</span>\n\n<span class=\"k\">architecture</span> <span class=\"n\">rtl</span> <span class=\"k\">of</span> <span class=\"n\">blinky</span> <span class=\"k\">is</span>\n  <span class=\"k\">signal</span> <span class=\"n\">counter</span> <span class=\"p\">:</span> <span class=\"kt\">unsigned</span><span class=\"p\">(</span><span class=\"mi\">23</span> <span class=\"k\">downto</span> <span class=\"mi\">0</span><span class=\"p\">)</span> <span class=\"p\">:</span><span class=\"o\">=</span> <span class=\"p\">(</span><span class=\"k\">others</span> <span class=\"o\">=&gt;</span> <span class=\"sc\">'0'</span><span class=\"p\">);</span>\n<span class=\"k\">begin</span>\n  <span class=\"k\">process</span><span class=\"p\">(</span><span class=\"n\">clk</span><span class=\"p\">)</span>\n  <span class=\"k\">begin</span>\n    <span class=\"k\">if</span> <span class=\"n\">rising_edge</span><span class=\"p\">(</span><span class=\"n\">clk</span><span class=\"p\">)</span> <span class=\"k\">then</span>\n      <span class=\"n\">counter</span> <span class=\"o\">&lt;=</span> <span class=\"n\">counter</span> <span class=\"o\">+</span> <span class=\"mi\">1</span><span class=\"p\">;</span>\n      <span class=\"n\">led</span> <span class=\"o\">&lt;=</span> <span class=\"n\">counter</span><span class=\"p\">(</span><span class=\"mi\">23</span><span class=\"p\">);</span>\n    <span class=\"k\">end</span> <span class=\"k\">if</span><span class=\"p\">;</span>\n  <span class=\"k\">end</span> <span class=\"k\">process</span><span class=\"p\">;</span>\n<span class=\"k\">end</span> <span class=\"k\">architecture</span><span class=\"p\">;</span>\n</code></pre></div></div>\n\n<p>This describes a circuit that counts clock edges and drives an LED from the top bit of the counter. There is no <code class=\"language-plaintext highlighter-rouge\">main()</code>, no loop, no scheduler. There is a process that responds to a clock signal, and a register that accumulates a value. That is hardware, described as text.</p>\n\n<h3 id=\"step-2-simulate-before-you-synthesize\">Step 2: Simulate Before You Synthesize</h3>\n\n<p>Before touching real silicon, you simulate. A testbench is another VHDL (or Verilog) file that instantiates your design and applies input stimuli with specific timings. A simulator runs the testbench and records how signals change over time.</p>\n\n<p>Tools like <strong>GTKWave</strong> visualize those signal traces, or for Xilinx designs Vivado’s built-in waveform viewer. You can verify that your counter increments correctly, that your state machine reaches the right states, that your UART transmits the right bytes, all without touching a soldering iron.</p>\n\n<p><img src=\"/assets/posts/wtf-are-fpgas-june-2026/vivado-sim.png\" alt=\"Vivado simulation waveform: signal list on the left, time-domain traces on the right. State machine signals, AXI4 bus signals, and UDP Tx module outputs are visible across a ~40 ns window around 602 µs.\" /></p>\n\n<p>This step is not optional for serious work. Debugging a circuit in hardware, where you only see what probes you physically attached, is much harder than stepping through a simulation.</p>\n\n<h3 id=\"step-3-synthesize-to-a-netlist\">Step 3: Synthesize to a Netlist</h3>\n\n<p>Once the simulation looks correct, you run synthesis. The synthesis tool (we use <strong>yosys</strong> for open-source flows, Xilinx’s Vivado for Xilinx FPGAs) reads your HDL and produces a <strong>netlist</strong>: a description of every gate, flip-flop, and connection in your design.</p>\n\n<p>For the open-source iCE40 flow, this step does more than people expect: <code class=\"language-plaintext highlighter-rouge\">synth_ice40</code>, the yosys script for this target, does generic logic synthesis and technology mapping in a single pass. By the time it finishes, every cell in the netlist is already a concrete iCE40 primitive, <code class=\"language-plaintext highlighter-rouge\">SB_LUT4</code>, a specific <code class=\"language-plaintext highlighter-rouge\">SB_DFF</code> variant, <code class=\"language-plaintext highlighter-rouge\">SB_CARRY</code>, not a generic AND gate or an abstract register. The identity of every piece of logic, what it becomes, is decided here.</p>\n\n<h3 id=\"step-4-place-and-route\">Step 4: Place and Route</h3>\n\n<p>Now the hardware specifics enter, but not the ones you might expect. Every FPGA has a <strong>chip description file</strong> that maps out exactly which logic cells exist where, how they connect to the routing fabric, and which pins of the package connect to which I/O cells.</p>\n\n<p>You also write a <strong>constraint file</strong> that maps the logical I/O ports in your design to physical package pins. This is where you say: “the signal I called <code class=\"language-plaintext highlighter-rouge\">clk</code> connects to pin 21, which is wired to the 12 MHz oscillator on this board.”</p>\n\n<p>The <strong>place-and-route</strong> tool (<strong>nextpnr</strong> in the open-source world) takes the already technology-mapped netlist, the chip description, and the constraints, and decides <em>where</em>, not <em>what</em>: which physical slot on the grid each already-typed cell goes into, and which paths through the routing fabric each signal takes. It cannot introduce a new primitive type or decide a signal should have been an <code class=\"language-plaintext highlighter-rouge\">SB_CARRY</code> instead of plain LUT logic; that decision was already made in synthesis. For large designs this can take a long time and consume significant memory. The tool is essentially solving a constraint-satisfaction problem over a very large graph.</p>\n\n<h3 id=\"step-5-post-implementation-simulation\">Step 5: Post-Implementation Simulation</h3>\n\n<p>After place and route, modern toolchains can produce a timing-annotated simulation model. This tells you, to nanosecond precision, how long each signal path actually takes to propagate through the routed design on that specific chip. You can run your testbench again against this model and verify that your circuit still behaves correctly when real propagation delays are taken into account.</p>\n\n<p>This is also where <strong>hazards</strong> show up: transient glitches caused by signals arriving at a gate at slightly different times. We will come back to hazards in a later article.</p>\n\n<h3 id=\"step-6-generate-and-load-the-bitstream\">Step 6: Generate and Load the Bitstream</h3>\n\n<p>The final step is bitstream generation. A vendor-specific tool reads the place-and-route output and produces the binary file that programs the FPGA’s configuration SRAM. For the iCE40 family this is done by <strong>icepack</strong> (part of the icestorm toolchain). For Xilinx parts, Vivado handles this step.</p>\n\n<p>The bitstream is then written to the chip, either directly over USB (as with the iCEstick) or by loading it into external SPI flash so the FPGA configures itself at power-up.</p>\n\n<hr />\n\n<h2 id=\"the-form-factors\">The Form Factors</h2>\n\n<p>FPGAs come in a wide range of packages and boards:</p>\n\n<ul>\n  <li><strong>USB development boards</strong> like the <a href=\"https://www.latticesemi.com/icestick\">Lattice iCEstick</a>: plug into a USB port, fully supported by the open-source icestorm/yosys/nextpnr stack, and cost around 25 euros. The right place to start.</li>\n  <li><strong>Larger development boards</strong> like the Digilent Nexys or Basys series: more I/O, bigger FPGAs, more on-board peripherals (VGA, audio, switches). A step up once you have outgrown the smaller boards.</li>\n  <li><strong>PCIe boards</strong>: high-bandwidth FPGAs intended for accelerator workloads, plugged directly into a server’s PCIe bus. Used in HFT, ML inference, and network offload.</li>\n</ul>\n\n<p><img src=\"/assets/posts/wtf-are-fpgas-june-2026/kintex7-pcie.png\" alt=\"A Xilinx Kintex-7 evaluation board in PCIe form factor, with two Panda FMC mezzanine cards attached, an SFP cage, SMA connectors, and a small cooling fan over the FPGA package\" /></p>\n\n<p>The choice of form factor follows from the application. For learning the toolchain, a USB stick is plenty. For a particle physics trigger system, you want something closer to the PCIe end of the spectrum.</p>\n\n<hr />\n\n<h2 id=\"fpgas-are-not-microcontrollers\">FPGAs Are Not Microcontrollers</h2>\n\n<p>It is worth saying plainly: an FPGA is not a fast microcontroller, and it is not a replacement for one. A microcontroller runs software. An FPGA implements hardware. The distinction matters.</p>\n\n<p>When you write C for an ATmega, you are telling a CPU what sequence of operations to perform. When you write VHDL for an iCE40, you are describing a circuit that does not have a program counter, does not fetch instructions, and does not wait for a previous operation to finish before starting the next one.</p>\n\n<p>FPGAs expand what is possible in digital design. They do not obsolete anything that came before. A microcontroller is still the right tool for running application logic, talking to sensors, and implementing protocols in software. An FPGA is the right tool when you need parallel, timing-deterministic hardware that does not exist as a standard IC.</p>\n\n<p>The two often appear on the same board, with the microcontroller configuring the FPGA at startup and then sending it commands while the FPGA handles the time-critical path.</p>\n\n<hr />\n\n<h2 id=\"where-to-go-next\">Where to Go Next</h2>\n\n<p>If you want to put this into practice, the <a href=\"/posts/fpga-blinky-vhdl-icestick-may-2026/\">Zero to One: VHDL and a Lattice iCEstick</a> post walks through the full toolchain from a blank editor to a blinking LED on real hardware. Everything described in the toolchain section above becomes concrete there: the VHDL, the testbench, the constraints file, yosys, nextpnr, and the iCEstick.</p>\n\n<p>The FPGA rabbit hole is deep. Signal timing, clock domain crossing, hardware hazards, and advanced synthesis constraints are all topics worth their own articles. This one was about understanding what you are dealing with before you open a terminal.</p>",
      "date_published": "2026-06-15T00:00:00+00:00",
      
      "date_modified": "2026-06-15T00:00:00+00:00",
      "image": "https://maxclerkwell.tech/assets/posts/wtf-are-fpgas-june-2026/basys3-icestick-title.png",
      "authors": [ { "name": "Stephan Bökelmann" } ],
      "tags": ["fpga","electronics","education"]
    },
    
    {
      "id": "https://maxclerkwell.tech/posts/hdl-for-netlists-june-2026/",
      "url": "https://maxclerkwell.tech/posts/hdl-for-netlists-june-2026/",
      "title": "A Hill I Am Willing to Die On: HDL for PCB Netlists",
      "summary": "Schematics are not where PCB design should start. Netlists should be generated from a structured hardware description language, and that changes everything about how AI fits into the picture.",
      "content_html": "<p><img src=\"/assets/posts/hdl-for-netlists/titlepic.png\" alt=\"A dense KiCad schematic with multiple Ethernet PHY blocks, bypass capacitors, and interconnected nets: the kind of drawing that is hard to review and harder to diff\" /></p>\n\n<p>This afternoon someone replied to one of my posts on X. The reply was from <a href=\"https://x.com/blind_via/status/2064050512723693717\">@blind_via</a> and made the point that what I was describing already exists as a netlist, that schematics come from human creativity, and that removing humans from the loop leads to the same place as vibe coding. Fair points, all of them. But they did not change my mind.</p>\n\n<p>What pushed me to write this out properly is something I keep hearing from people at university: AI will take a very long time to solve PCB design. Routing is too hard. Schematics require real engineering intuition. The problem is fundamentally different from software.</p>\n\n<p>I disagree. Not because I think AI is magic, but because I think the problem is being misidentified.</p>\n\n<hr />\n\n<h2 id=\"schematics-are-not-the-ground-truth\">Schematics Are Not the Ground Truth</h2>\n\n<p>I have written before about <a href=\"/posts/pcb-block-diagrams-december-2025/\">why PCB design should start with a block diagram, not a schematic</a>. The argument there was about design intent: writing down what the board is supposed to do before reaching for KiCad. This post goes deeper into the toolchain.</p>\n\n<p>Here is the thing most people do not say out loud: a schematic is not the source of truth for a PCB. The netlist is. The schematic is a graphical interface that an engineer uses to <em>construct</em> the netlist. It is a drawing tool wrapped around a data format.</p>\n\n<p>That data format encodes what actually matters: which pins are connected to which nets, which components are instantiated, what their parameters are. The schematic is how a human <em>inputs</em> that information. It is not the information itself.</p>\n\n<p>This distinction matters enormously once you start thinking about AI, version control, and reuse.</p>\n\n<hr />\n\n<h2 id=\"the-abstraction-stack-we-already-know\">The Abstraction Stack We Already Know</h2>\n\n<div class=\"language-plaintext highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>Physical hardware\n      ^\n   PCB layout      &lt;- routing, copper, keepouts\n      ^\n   Netlist         &lt;- the actual source of truth\n      ^\n   Schematic       &lt;- graphical interface to the netlist\n      ^\n   Engineer        &lt;- you, drawing boxes and wires\n</code></pre></div></div>\n\n<p>Compare this to software. In the early days of computing, engineers wrote binary directly. Then assembler gave them a readable layer above the opcodes. Then C abstracted further, and C++ brought structures and reuse. Nobody writes production code in assembler today, not because assembler is wrong, but because working at that level of abstraction constantly is exhausting and error-prone.</p>\n\n<p>PCB design is stuck somewhere between assembler and C. We are drawing schematics by hand and generating netlists from those drawings. That is the wrong direction. The netlist should be <em>generated from a language</em>, and the schematic should be a <em>rendered output</em>, a view, not the input.</p>\n\n<p>This is not a new idea in hardware design. VHDL does exactly this for FPGAs: you write structured code, synthesize it, and the toolchain generates what the hardware should look like. I wrote about this workflow in <a href=\"/posts/fpga-blinky-vhdl-icestick-may-2026/\">Zero to One: VHDL and a Lattice iCEstick</a>. The same principle should apply to regular PCB netlists.</p>\n\n<hr />\n\n<h2 id=\"why-this-is-a-grammar-problem-for-ai\">Why This Is a Grammar Problem for AI</h2>\n\n<p>The claim that AI cannot handle PCB design usually focuses on routing, and routing is genuinely hard. Constraint satisfaction over a dense copper plane, thermal relief, differential pairs, impedance matching. That is a legitimately difficult spatial problem.</p>\n\n<p><img src=\"/assets/posts/hdl-for-netlists/routing.png\" alt=\"PCB layout view showing dense copper routing, vias, and component placement: a constraint-satisfaction problem of a different order than schematic capture\" /></p>\n\n<p>But schematics? Schematics are easier. And yet AI still produces garbage schematics. Why?</p>\n\n<p>Because schematics are a visual grammar, and that grammar is implicit. The rules, what makes a schematic correct, readable, and reviewable, are not written down anywhere that a model can learn from cleanly. The netlist underneath has structure, but that structure is tangled up in coordinate data, symbol identifiers, and rendering metadata. It is not a clean language. It is a side effect of someone drawing something.</p>\n\n<p>Here is the thing about Large Language Models: they are language models. That is the whole mechanism. They learn structure from structured text. If you give them a clean, typed, composable language for describing circuits, they have something they can actually learn from. If you give them a visual file format that encodes drawing positions alongside electrical connections, you are asking the model to extract meaning from noise.</p>\n\n<p>If netlists were generated from a proper HDL, you would have a clean, learnable grammar. A model could be trained on that. More importantly, a human could <em>write</em> it with confidence, review it, test it, and version-control it properly.</p>\n\n<p>That is the real reason AI struggles with schematics. It is not that circuits are too complex. It is that we have not given the domain a language worth learning from.</p>\n\n<hr />\n\n<h2 id=\"the-tools-that-exist-and-why-they-frustrate-me\">The Tools That Exist and Why They Frustrate Me</h2>\n\n<p>Some people have already tried to build this. I have played with several of them.</p>\n\n<p><strong>SKiDL</strong> is the most mature attempt. It lets you describe circuits in Python, assign components from KiCad libraries, define nets programmatically, and generate a netlist that KiCad can import. The concept is exactly right. The execution is where it falls apart. The toolchain is brittle. Library resolution breaks silently. Net names collide in ways that are hard to debug. Common failure modes I ran into, and that come up repeatedly in the community, include footprint lookup failures when library paths change between KiCad versions, <code class=\"language-plaintext highlighter-rouge\">SchLib</code> import errors when component attributes do not match the expected schema, and netlist export producing empty or malformed output without any clear error message. Every session felt like fighting the scaffolding rather than describing a circuit. The project has slowed down significantly and KiCad 7/8 support is patchy.</p>\n\n<p><strong>Hdl21</strong> takes a more principled approach. It is Python-native and focuses on analog circuits, with a clean module and instance model. The type system is stricter than SKiDL, which means more upfront discipline but fewer silent failures. The tradeoff is that it is less connected to real-world EDA toolchains: getting from an Hdl21 description to something you can hand to a PCB layout tool requires extra steps that are not yet well-documented.</p>\n\n<p><strong>JITX</strong> goes furthest in terms of ambition. It is a full DSL, not Python embedded, with its own compiler, constraint system, and layout integration. The ergonomics look impressive on paper, and it is next on my list to actually try. I cannot say more about it yet because I have not spent enough time with it.</p>\n\n<p>None of these have hit the sweet spot yet. The concept is right. The implementations are still engineering prototypes, not tools you can confidently build a production board with.</p>\n\n<hr />\n\n<h2 id=\"what-the-language-should-give-you\">What the Language Should Give You</h2>\n\n<p>Here is what a proper HDL for PCB netlists should make possible: things you cannot easily do with a schematic.</p>\n\n<p><strong>Parameterized instantiation.</strong> Imagine you need eight filter stages that share a topology but differ in cutoff frequency, component values, and multiplexer assignment. In a schematic, you draw this eight times. In a language, you write a filter module that takes parameters and instantiate it in a loop:</p>\n\n<div class=\"language-python highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"n\">filters</span> <span class=\"o\">=</span> <span class=\"p\">[</span>\n    <span class=\"n\">LowPassFilter</span><span class=\"p\">(</span><span class=\"n\">fc</span><span class=\"o\">=</span><span class=\"mf\">1e3</span> <span class=\"o\">*</span> <span class=\"p\">(</span><span class=\"n\">i</span> <span class=\"o\">+</span> <span class=\"mi\">1</span><span class=\"p\">),</span> <span class=\"n\">mux_channel</span><span class=\"o\">=</span><span class=\"n\">i</span><span class=\"p\">)</span>\n    <span class=\"k\">for</span> <span class=\"n\">i</span> <span class=\"ow\">in</span> <span class=\"nb\">range</span><span class=\"p\">(</span><span class=\"mi\">8</span><span class=\"p\">)</span>\n<span class=\"p\">]</span>\n</code></pre></div></div>\n\n<p>Each instance has distinct component values, meaningful net names, and a clear origin in the source. If you need a ninth filter, you change one number. If you need to change the topology, you change it once.</p>\n\n<p><strong>Reusable circuit blocks as libraries.</strong> A flyback converter topology, a USB ESD protection block, a current sense amplifier: these are things you should write once and import. Not copy-paste a schematic sheet. Import from a library, instantiate with parameters, done.</p>\n\n<p><strong>Diffable, reviewable source.</strong> This is the version control argument, and it is more specific than it sounds. KiCad stores schematics as S-expressions, a structured format in principle. In practice, every wire carries absolute canvas coordinates and a UUID. Move a component two millimetres to avoid an overlap and every wire connected to it changes. The diff is pages long. None of it tells you anything about the circuit.</p>\n\n<p>Here is what a KiCad <code class=\"language-plaintext highlighter-rouge\">.kicad_sch</code> file actually looks like for a handful of wires:</p>\n\n<div class=\"language-scheme highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"p\">(</span><span class=\"nf\">wire</span> <span class=\"p\">(</span><span class=\"nf\">pts</span> <span class=\"p\">(</span><span class=\"nf\">xy</span> <span class=\"mf\">270.51</span> <span class=\"mf\">204.47</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">xy</span> <span class=\"mf\">292.735</span> <span class=\"mf\">204.47</span><span class=\"p\">))</span>\n  <span class=\"p\">(</span><span class=\"nf\">stroke</span> <span class=\"p\">(</span><span class=\"nf\">width</span> <span class=\"mi\">0</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">type</span> <span class=\"nv\">default</span><span class=\"p\">))</span>\n  <span class=\"p\">(</span><span class=\"nf\">uuid</span> <span class=\"mi\">2</span><span class=\"nv\">ecffb78-f077-4f85-ab1b-26855ace0705</span><span class=\"p\">)</span>\n<span class=\"p\">)</span>\n<span class=\"p\">(</span><span class=\"nf\">wire</span> <span class=\"p\">(</span><span class=\"nf\">pts</span> <span class=\"p\">(</span><span class=\"nf\">xy</span> <span class=\"mf\">212.725</span> <span class=\"mf\">72.39</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">xy</span> <span class=\"mf\">256.54</span> <span class=\"mf\">72.39</span><span class=\"p\">))</span>\n  <span class=\"p\">(</span><span class=\"nf\">stroke</span> <span class=\"p\">(</span><span class=\"nf\">width</span> <span class=\"mi\">0</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">type</span> <span class=\"nv\">default</span><span class=\"p\">))</span>\n  <span class=\"p\">(</span><span class=\"nf\">uuid</span> <span class=\"mi\">2</span><span class=\"nv\">effec3a-4c6a-403c-b107-7839d0b5a154</span><span class=\"p\">)</span>\n<span class=\"p\">)</span>\n<span class=\"p\">(</span><span class=\"nf\">wire</span> <span class=\"p\">(</span><span class=\"nf\">pts</span> <span class=\"p\">(</span><span class=\"nf\">xy</span> <span class=\"mf\">245.618</span> <span class=\"mf\">18.288</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">xy</span> <span class=\"mf\">245.618</span> <span class=\"mf\">20.32</span><span class=\"p\">))</span>\n  <span class=\"p\">(</span><span class=\"nf\">stroke</span> <span class=\"p\">(</span><span class=\"nf\">width</span> <span class=\"mi\">0</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">type</span> <span class=\"nv\">default</span><span class=\"p\">))</span>\n  <span class=\"p\">(</span><span class=\"nf\">uuid</span> <span class=\"mi\">2</span><span class=\"nv\">fe92422-7d86-4186-8707-3628c538a251</span><span class=\"p\">)</span>\n<span class=\"p\">)</span>\n<span class=\"p\">(</span><span class=\"nf\">wire</span> <span class=\"p\">(</span><span class=\"nf\">pts</span> <span class=\"p\">(</span><span class=\"nf\">xy</span> <span class=\"mf\">231.775</span> <span class=\"mf\">161.29</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">xy</span> <span class=\"mf\">231.775</span> <span class=\"mf\">137.795</span><span class=\"p\">))</span>\n  <span class=\"p\">(</span><span class=\"nf\">stroke</span> <span class=\"p\">(</span><span class=\"nf\">width</span> <span class=\"mi\">0</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">type</span> <span class=\"nv\">default</span><span class=\"p\">))</span>\n  <span class=\"p\">(</span><span class=\"nf\">uuid</span> <span class=\"mi\">30</span><span class=\"nv\">b78f0e-1107-47f0-96ed-ab8a845bb7fa</span><span class=\"p\">)</span>\n<span class=\"p\">)</span>\n</code></pre></div></div>\n\n<p>For comparison, here is what SKiDL generates as a netlist for a simple voltage divider:</p>\n\n<div class=\"language-scheme highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"p\">(</span><span class=\"nf\">nets</span>\n  <span class=\"p\">(</span><span class=\"nf\">net</span> <span class=\"p\">(</span><span class=\"nf\">code</span> <span class=\"mi\">1</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">name</span> <span class=\"s\">\"GND\"</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">class</span> <span class=\"s\">\"Default\"</span><span class=\"p\">)</span>\n    <span class=\"p\">(</span><span class=\"nf\">node</span> <span class=\"p\">(</span><span class=\"nf\">ref</span> <span class=\"s\">\"C1\"</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">pin</span> <span class=\"s\">\"2\"</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">pintype</span> <span class=\"s\">\"PASSIVE\"</span><span class=\"p\">))</span>\n    <span class=\"p\">(</span><span class=\"nf\">node</span> <span class=\"p\">(</span><span class=\"nf\">ref</span> <span class=\"s\">\"R2\"</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">pin</span> <span class=\"s\">\"2\"</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">pintype</span> <span class=\"s\">\"PASSIVE\"</span><span class=\"p\">)))</span>\n  <span class=\"p\">(</span><span class=\"nf\">net</span> <span class=\"p\">(</span><span class=\"nf\">code</span> <span class=\"mi\">2</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">name</span> <span class=\"s\">\"MID\"</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">class</span> <span class=\"s\">\"Default\"</span><span class=\"p\">)</span>\n    <span class=\"p\">(</span><span class=\"nf\">node</span> <span class=\"p\">(</span><span class=\"nf\">ref</span> <span class=\"s\">\"C1\"</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">pin</span> <span class=\"s\">\"1\"</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">pintype</span> <span class=\"s\">\"PASSIVE\"</span><span class=\"p\">))</span>\n    <span class=\"p\">(</span><span class=\"nf\">node</span> <span class=\"p\">(</span><span class=\"nf\">ref</span> <span class=\"s\">\"R1\"</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">pin</span> <span class=\"s\">\"2\"</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">pintype</span> <span class=\"s\">\"PASSIVE\"</span><span class=\"p\">))</span>\n    <span class=\"p\">(</span><span class=\"nf\">node</span> <span class=\"p\">(</span><span class=\"nf\">ref</span> <span class=\"s\">\"R2\"</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">pin</span> <span class=\"s\">\"1\"</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">pintype</span> <span class=\"s\">\"PASSIVE\"</span><span class=\"p\">)))</span>\n  <span class=\"p\">(</span><span class=\"nf\">net</span> <span class=\"p\">(</span><span class=\"nf\">code</span> <span class=\"mi\">3</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">name</span> <span class=\"s\">\"VCC\"</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">class</span> <span class=\"s\">\"Default\"</span><span class=\"p\">)</span>\n    <span class=\"p\">(</span><span class=\"nf\">node</span> <span class=\"p\">(</span><span class=\"nf\">ref</span> <span class=\"s\">\"R1\"</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">pin</span> <span class=\"s\">\"1\"</span><span class=\"p\">)</span> <span class=\"p\">(</span><span class=\"nf\">pintype</span> <span class=\"s\">\"PASSIVE\"</span><span class=\"p\">))))</span>\n</code></pre></div></div>\n\n<p>No coordinates. No UUIDs. Just which pins are connected to which nets. That is closer to what matters. But to be clear: this netlist is still not what belongs in version control. It is an artifact, generated from the source. What belongs in version control is the Python file that produced it, the actual language description of the circuit. The netlist is the output of a compiler, not the input to one. That distinction is exactly the point.</p>\n\n<hr />\n\n<h2 id=\"the-open-question-rendering\">The Open Question: Rendering</h2>\n\n<p>There is one problem I do not have a clean answer to yet, and I want to name it honestly rather than paper over it.</p>\n\n<p>If the netlist is the source and the schematic is a rendered view, something has to decide where each block goes on that view. Automatic layout algorithms for graphs are notoriously difficult to control. Anyone who has used Mermaid or PlantUML for anything non-trivial knows the frustration of a box ending up on the wrong side of the diagram with crossing arrows everywhere.</p>\n\n<p>A good schematic has intentional topology: power rails at the top, ground at the bottom, signal flow left to right, related subcircuits clustered together. That spatial intent is hard to encode in a language and hard to infer from a netlist.</p>\n\n<p>I think this is a solvable problem. Layout hints in the source, constraint-based placement, interactive adjustment of a generated layout. But I have not found a satisfying solution yet, and I am not going to pretend I have. This is the thing I am still thinking about and it will probably be its own post.</p>\n\n<hr />\n\n<h2 id=\"where-this-leaves-ai\">Where This Leaves AI</h2>\n\n<p>Routing is hard. I will grant that. But the reason AI cannot yet generate useful schematics is not that circuits are beyond its reach. It is that we have not given the domain a language worth learning from.</p>\n\n<p>Large Language Models learn language. That is the mechanism. Give a model a clean grammar with consistent structure and enough examples, and it learns the grammar. That is not a controversial claim. It is how these systems work. The absence of a proper HDL for PCB netlists is not just a tooling problem for humans. It is a structural reason why AI cannot make progress on schematic generation. Fix the language layer, and the picture changes.</p>\n\n<p>That is why I disagree with the “AI will take a very long time” crowd: not because routing is easy, but because the bottleneck is not intelligence. It is grammar.</p>\n\n<p>If you have thoughts on this, find me on X and reply: <a href=\"https://x.com/maxclerkwell\">@maxclerkwell</a>.</p>",
      "date_published": "2026-06-08T00:00:00+00:00",
      
      "date_modified": "2026-06-08T00:00:00+00:00",
      "image": "https://maxclerkwell.tech/assets/posts/hdl-for-netlists/titlepic.png",
      "authors": [ { "name": "Stephan Bökelmann" } ],
      "tags": ["pcb","engineering","ai"]
    },
    
    {
      "id": "https://maxclerkwell.tech/posts/physics-description-not-explanation-june-2026/",
      "url": "https://maxclerkwell.tech/posts/physics-description-not-explanation-june-2026/",
      "title": "Physics Doesn't Explain Anything. And That's the Point.",
      "summary": "My high school teacher told me physics explains the world. Years later I think that's completely wrong, and understanding why changes everything about how you approach the subject.",
      "content_html": "<p>When I was in school, my physics teacher told me that physics <em>explains</em> the world.</p>\n\n<p>I believed that for years. It sounded profound. It gave the subject a kind of grandeur, as if sitting through enough lectures about pendulums and ideal gases would eventually unlock some deep understanding of why reality is the way it is.</p>\n\n<p>Today I think that framing is completely wrong. Not slightly off, not a useful simplification: wrong in a way that actively misleads people about what physics is and what it is for. And I think this misunderstanding is one of the main reasons students either develop an inflated reverence for the subject or walk away from it feeling like they missed something fundamental.</p>\n\n<p>Physics does not explain the world. Physics <em>describes</em> it.</p>\n\n<p>That is a small grammatical shift with enormous consequences.</p>\n\n<hr />\n\n<h2 id=\"what-description-actually-means\">What Description Actually Means</h2>\n\n<p>To explain something is to give an account of <em>why</em> it is the way it is. To describe something is to document <em>how</em> it behaves reliably enough to be useful.</p>\n\n<p>Physics is firmly in the second camp.</p>\n\n<p>When Newton wrote down his law of universal gravitation, he was not explaining why mass attracts mass, or what the underlying mechanism of gravity is. He was documenting a relationship. “If you have two masses at this distance, the force between them will be this number.” That relationship holds across an extraordinary range of circumstances. It let humans put spacecraft on precise trajectories to the outer planets. It is enormously useful. But it does not tell you <em>why</em> gravity exists. It tells you <em>how much</em> of it you will find, and <em>when</em> it matters.</p>\n\n<p>Newton himself was quite honest about this. His famous phrase <em>hypotheses non fingo</em> (“I feign no hypotheses”) was a direct statement that he was describing what he observed, not proposing a deeper cause.</p>\n\n<p>The question “but <em>why</em> does gravity work like that?” is either a philosophical question, a question for a different and deeper layer of physics, or simply a question that nobody has answered yet. What physics gives you is the reliable relationship. The why, if it exists at all, keeps retreating one level deeper every time you think you have caught it.</p>\n\n<hr />\n\n<h2 id=\"so-why-do-we-do-it\">So Why Do We Do It?</h2>\n\n<p>If physics is not explaining the universe, what is the point?</p>\n\n<p>The answer is practical and it is important to say it plainly: we build physics in order to build technology.</p>\n\n<p>To make a machine do something useful, whether diverting water, holding a structure under load, moving heat from one place to another, or transmitting information, you need to trust that certain relationships will hold tomorrow the same way they held yesterday. You need rules you can rely on. <em>If A, then B.</em> Not approximately, not sometimes, but reliably enough that you can stake your design on it.</p>\n\n<p>Physics is the process of finding, testing, and formalising those relationships. The output is a toolbox of formulas, models, and methods that engineers can use to simulate the effect of an action before committing to it in metal and money.</p>\n\n<p>That is not a diminished vision of physics. It is an honest and rather extraordinary one. Every bridge, every microprocessor, every satellite, every chip fabrication plant: none of these were possible without honest observers using the most precise instruments they could build, documenting what they found, and abstracting it into mathematical language that someone else could use as a design tool.</p>\n\n<p>That is what physicists do. They generate reliable descriptions of the world at a level of precision and generality that can be handed forward.</p>\n\n<hr />\n\n<h2 id=\"states-and-processes\">States and Processes</h2>\n\n<p>Most of physics, once you strip away the particular domain (mechanics, thermodynamics, electrodynamics, quantum theory), reduces to two things: <strong>states</strong> and <strong>processes that change states</strong>.</p>\n\n<p>A state is a snapshot. The position and momentum of a particle. The temperature and pressure of a gas. The charge distribution across a capacitor. The orientation of a spin.</p>\n\n<p>A process is a rule that maps one state to another over time. A differential equation. A collision law. A decay probability.</p>\n\n<p>Within physics, these two activities have actually split into distinct cultures. Experimentalists spend most of their working lives figuring out how to measure states as precisely as possible: designing instruments, controlling noise sources, and pushing the limits of what can be observed. Theorists spend most of their working lives on the other half: developing the mathematical framework that describes how to get from one state to another under given environmental circumstances. The two communities need each other. A theorist without experimental constraints produces untestable speculation. An experimentalist without theoretical guidance is collecting data with no idea what it means. The rest of the apparatus, the notation, the specialised vocabulary, the mathematical machinery, is infrastructure for making that collaboration work.</p>\n\n<p>When you understand this, a lot of the apparent mysticism around physics dissolves. You are not trying to access some hidden truth about reality. You are building a model that predicts the next state from the current one, and checking how well it performs against measurement. When the model performs well enough, engineers can use it. When it breaks down, physicists go back and refine it.</p>\n\n<hr />\n\n<h2 id=\"the-human-animal-and-its-workaround\">The Human Animal and Its Workaround</h2>\n\n<p>Most animals adapt to their environment. Evolution tunes them over generations to fit the niche they occupy. A beetle optimises for a particular humidity range, a particular diet, a particular set of predators.</p>\n\n<p>Humans do something different. We adjust the environment to fit ourselves.</p>\n\n<p>Joe Rogan, in a <a href=\"https://youtu.be/GZCmYrgOZU0?si=Q5U7YATSzNcmDrT6\">recent podcast with astrophysicist Michelle Thaller</a>, made an observation that stuck with me. He described humans as something like “the electronic caterpillar”: a species that just keeps building, keeps improving, never quite deciding that the current version of the hive is good enough. His point was that bees, at some stage, arrived at a design that was sufficient and stopped there. Humans never did that. Innovation seems to be our evolutionary program.</p>\n\n<p>Physics is one of the most powerful tools we developed in service of that program.</p>\n\n<p>We do not adapt fast enough biologically to deal with cold climates, deep water, the vacuum of space, or infectious disease at scale. So instead we build a layer between ourselves and the universe that compensates. Insulation, submarines, spacesuits, antibiotics. None of these require the universe to change, and none of them require us to change genetically. We build a hardware abstraction layer between our bodies and raw physical reality, and that layer gets thicker and more capable every generation.</p>\n\n<p>This framing comes naturally to me from working with microcontrollers. A microcontroller is, in one sense, a device that makes a specific chunk of physics behave predictably at a level of abstraction a programmer can use without worrying about electron transport in silicon. Physics is the bedrock. Engineering stacks abstraction layers on top until the underlying complexity is hidden. The programmer does not need to understand quantum tunnelling to blink an LED. But someone, at some point, had to understand it well enough to design the transistor.</p>\n\n<p>That someone was a physicist.</p>\n\n<hr />\n\n<h2 id=\"the-limits-of-description\">The Limits of Description</h2>\n\n<p>At this point you might object: if physics is about building reliable predictive models, can we not, in principle, make those models arbitrarily precise? Given enough instruments and enough computation, could we not simulate anything?</p>\n\n<p>This is where two concepts come in that most people do not fully reckon with.</p>\n\n<h3 id=\"heisenbergs-uncertainty-principle\">Heisenberg’s Uncertainty Principle</h3>\n\n<p>The first limit is one you may have heard of: Heisenberg’s uncertainty principle. In its most common form, it states that the more precisely you know the position of a particle, the less precisely you can know its momentum, and vice versa. This is not a limitation of instruments. It is not merely a matter of disturbing the particle when you measure it, though that intuition is not entirely wrong. It is a fundamental feature of how quantum systems work: the state you are trying to measure does not have a determinate value for both quantities at once.</p>\n\n<p>This has been tested, contested, and tested again for nearly a century. Every experiment designed to disprove it has failed. At this point we treat it as a hard constraint on what physics can ever tell us about initial conditions, which means it is a hard constraint on how well we can predict future states.</p>\n\n<h3 id=\"wolframs-computational-irreducibility\">Wolfram’s Computational Irreducibility</h3>\n\n<p>The second limit is less well known, and I find it even more unsettling.</p>\n\n<p>I wrote about Wolfram and computational irreducibility in an earlier post on <a href=\"/posts/game-of-life-may-2026/\">Conway’s Game of Life</a>, where I approached it as a question about entropy and the direction of time. Here I want to take a different angle: what does it mean for physics as an engineering tool?</p>\n\n<p>The basic idea, briefly: for certain systems, there is no shortcut. The only way to know what state the system will be in after a million steps is to run all million steps. No formula collapses the computation. The future is not analytically reachable from the present; the system must be lived through, not solved.</p>\n\n<p>In the earlier post I used this to ask whether entropy might be the signature of irreducibility, a measure not just of disorder but of how far ahead the universe refuses to be calculated. That framing was about understanding nature. Here I want to use the same concept to ask a more operational question: what does this tell us about the <em>reach</em> of the models we hand to engineers?</p>\n\n<p>Imagine a sealed box at zero degrees Celsius. You place one ball inside and take all the time you need to measure its position and momentum as precisely as the laws of physics allow. You close the box.</p>\n\n<p>After an instant, the ball is essentially where you left it. Easy.</p>\n\n<p>After one second, using Newtonian mechanics and elastic collision laws, you can estimate its state quite well. Some accumulated error from measurement uncertainty, some from the Heisenberg floor, but the prediction is good.</p>\n\n<p>After a thousand seconds, the errors compound. Every collision introduces a small deviation between your model and reality. Heisenberg’s floor means the initial conditions were never perfectly known. And here is where computational irreducibility bites: even if they <em>were</em> perfectly known, the future state of a sufficiently complex system is not analytically reachable. You must simulate every intermediate step.</p>\n\n<p>But the universe is not computing a shortcut on your behalf while you run your simulation. The universe <em>is</em> the simulation. The collisions are happening, the uncertainty is propagating, and the future state is being produced by the actual execution of physical processes, not by any equation that represents them. The equations are our map. The territory does not care about the map.</p>\n\n<p>What this means, practically, is that there is no oracle anywhere in the universe that can tell you the exact future state of any sufficiently complex system without running that system forward in real time. No superintelligence, no Laplacian demon, nothing. The computation required to determine the future is embedded in the fabric of the universe itself and cannot be shortcut.</p>\n\n<p>Physics does not give us a window into the future. It gives us progressively better approximations, valid over progressively longer ranges, with progressively smaller errors. But perfect prediction at infinite time? Not on the menu. Not because we have not worked hard enough. Because it is structurally impossible.</p>\n\n<hr />\n\n<h2 id=\"does-this-make-physics-a-fools-errand\">Does This Make Physics a Fool’s Errand?</h2>\n\n<p>Not at all. Quite the opposite.</p>\n\n<p>The fact that perfect prediction is impossible does not mean imperfect prediction is worthless. The gap between <em>no reliable model</em> and <em>a good enough model</em> is the difference between medieval engineering and modern civilisation.</p>\n\n<p>You do not need to know exactly where an electron will be in ten years to design a transistor that works reliably for twenty. You need to know the probability distributions well enough to choose the right doping concentrations and oxide thicknesses. Physics gives you that. It does not give you certainty. It gives you a compressed, testable, transmissible description of how the world tends to behave, and that description is extraordinary.</p>\n\n<p>The recognition that physics describes rather than explains, that all models are approximations, and that fundamental limits exist on prediction: these do not undermine the project. They clarify it. They tell you what you are actually doing when you do physics, and they tell you why the project is worth doing even though it will never be finished.</p>\n\n<p>There is something honest and even liberating about this. You are not hunting for final answers. You are building better tools. The universe is not going to yield its secrets, but it will, under patient and quantitative attention, reveal how it tends to behave. That turns out to be enough. More than enough, actually. We built everything we have on the back of that much.</p>\n\n<hr />\n\n<h2 id=\"a-note-for-people-considering-their-education\">A Note for People Considering Their Education</h2>\n\n<p>If you are thinking about studying engineering, or if you already have an engineering degree and are wondering whether going back for physics would be worth your time, I want to say something directly.</p>\n\n<p>It would.</p>\n\n<p>Engineering education, in my experience, tends to hand you formulas and teach you when to apply them. Physics education hands you the process by which those formulas were derived and teaches you how to derive new ones when the existing ones stop working. Those are genuinely different skills. The engineer who understands where their formulas come from is better at knowing when to trust them and when to go looking for something better.</p>\n\n<p>A physicist learns how to observe, how to measure, how to identify the relevant state variables for a given system, and how to build mathematical models that are honest about their own assumptions. That is a very different cognitive toolkit from the one you build by solving textbook problems in applied statics.</p>\n\n<p>I am not saying one is better than the other. I am saying they are complementary in a way that the education system does not always make clear, and the combination is remarkable. If you can afford the time, pursue both. If you are early in your trajectory and can only pick one to start, physics first will give you a foundation that engineering can build on. The converse is harder.</p>\n\n<p>The universe is not going to hand us a complete theory of everything on a silver platter. But it will, under the right kind of patient, honest, quantitative attention, keep showing us how it behaves. Understanding that this is the real goal, description and not explanation, tools and not truth, is the thing my high school teacher forgot to tell me. If you are standing at the beginning of your education and trying to decide which door to walk through first, I wrote a longer piece on exactly that question: <a href=\"/posts/physics-math-or-engineering-how-do-you-decide/\">Physics, Math, or Engineering: How Do You Decide?</a></p>\n\n<p>I hope it is less of a surprise to you.</p>\n\n<hr />\n\n<p><em>If you want to read more about computational irreducibility and the deeper question of whether the universe itself might be a computational process, the earlier post on <a href=\"/posts/game-of-life-may-2026/\">Conway’s Game of Life and the limits of prediction</a> picks up that thread from a different angle.</em></p>\n\n<p>If you want to talk about any of this, come find us on Discord.</p>\n\n<div><a href=\"https://discord.gg/2BXuUY6hrX\" class=\"link-card-discord\" target=\"_blank\" rel=\"noopener noreferrer\"><i class=\"fab fa-discord\"></i><div class=\"discord-text\"><span class=\"discord-name\">Discord — Full Stack Engineering</span><span class=\"discord-note\">Direct access to me and my colleagues. Webinars, live Q&amp;A, and community discussions for engineers across the full stack.</span><span class=\"discord-join\">Join the server →</span></div></a></div>",
      "date_published": "2026-06-04T00:00:00+00:00",
      
      "date_modified": "2026-06-04T00:00:00+00:00",
      
      "authors": [ { "name": "Stephan Bökelmann" } ],
      "tags": ["physics","philosophy","education"]
    },
    
    {
      "id": "https://maxclerkwell.tech/posts/autonomous-will-kant-june-2026/",
      "url": "https://maxclerkwell.tech/posts/autonomous-will-kant-june-2026/",
      "title": "Autonomous Will, Heteronomous Will, and the Work of Becoming Yourself",
      "summary": "Kant draws a sharp line between acting from your own reason and acting from external pressure. But where does your reason actually come from? An exploration of autonomy, heteronomy, archetypes, and the slow work of building a self you can stand behind.",
      "content_html": "<p>This morning I sat in the waiting area outside a courtroom at the Bochum district court. I was there as a witness. The room was empty. The court had placed reading material on the chairs (phones are more or less prohibited in that part of the building), and one of the items was a copy of Kant’s <em>Groundwork of the Metaphysics of Morals</em>.</p>\n\n<p>I had not picked up Kant since highschool. Back then it was homework. Now, sitting alone in that silent corridor, I opened it and felt something I had not expected: the book hit differently. Not because the words had changed. Because I had.</p>\n\n<p>This post is an attempt to write down what I have been thinking about the whole day.</p>\n\n<hr />\n\n<h2 id=\"what-kant-actually-means-by-autonomous-will\">What Kant Actually Means by Autonomous Will</h2>\n\n<p>Kant draws one of the sharpest distinctions in all of moral philosophy: the difference between an <strong>autonomous will</strong> and a <strong>heteronomous will</strong>.</p>\n\n<p>Most people, when they first hear the word <em>autonomy</em>, think it means doing whatever you want. Freedom as the absence of constraint. That is not what Kant means at all.</p>\n\n<p>For Kant, an autonomous will is one that gives itself its own law through reason. Not through desire, not through habit, not through what your parents taught you, not through what God commanded, not through social pressure. Through reason alone. You act autonomously when the principle guiding your action is one you could, through pure rational reflection, endorse as a universal law for everyone.</p>\n\n<p>This is what his <strong>categorical imperative</strong> captures: <em>Act only according to that maxim whereby you can at the same time will that it should become a universal law.</em> If you are about to lie, steal, break a promise, ask whether you could will that everyone, in every similar situation, did the same. If the answer is no, and it usually is, then that action is not one a truly autonomous will would choose.</p>\n\n<p>A <strong>heteronomous will</strong>, by contrast, is one that takes its law from outside itself. From inclination, the pull of pleasure and the aversion to pain. From authority, a parent, a priest, a ruler. From convention, what is simply done in one’s community. From religion, what God has commanded. Kant does not say these forces are always bad. But he says they are not the source of genuine morality. If you do the right thing because you fear punishment, or because it feels good, or because everyone else does it, you are not yet acting morally in the fullest sense.</p>\n\n<p>That is a demanding position. It is also, I think, deeply right in its core intuition, though importantly incomplete in ways Kant did not fully address.</p>\n\n<hr />\n\n<h2 id=\"the-freudian-problem-whose-reason-is-this\">The Freudian Problem: Whose Reason Is This?</h2>\n\n<p>The most serious challenge to Kant’s picture comes from a direction he never anticipated: psychoanalysis.</p>\n\n<p>Freud introduced the concept of the <strong>superego</strong>, the internal voice that tells us what we should and should not do. On the surface, the superego sounds a lot like Kant’s autonomous reason. It speaks from inside. It issues judgments. It produces guilt and obligation.</p>\n\n<p>But Freud’s point is that the superego is not autonomous at all. It is the <strong>internalized voice of the parents</strong>, absorbed in early childhood before the capacity for independent judgment even existed. The child cannot evaluate what it is being taught. It simply takes the rules in, and those rules become the inner critic, the voice that sounds like conscience but is really the echo of external authority.</p>\n\n<p>Later in life, that same voice often gets projected outward onto God, onto the law, onto social norms, and then reabsorbed as if it were universal truth. What feels like pure rational self-legislation may, on inspection, be nothing more than parental instruction dressed in philosophical clothing.</p>\n\n<p>Freud would say: what Kant calls the autonomous will is, in many cases, heteronomy in disguise. The chains have simply been moved inside.</p>\n\n<p>I find this convincing. Not as a reason to abandon Kant, but as a reason to take his project more seriously: because if he is right that autonomy is worth having, then the work of actually achieving it is much harder than he acknowledged.</p>\n\n<hr />\n\n<h2 id=\"society-as-the-first-heteronomous-voice\">Society as the First Heteronomous Voice</h2>\n\n<p>When we are born, we have no inner law. We are entirely dependent on bodies, on warmth, on language, on the stories we are told about who we are and what the world means. The first moral framework any of us ever has is the one we were given.</p>\n\n<p>This is not a criticism. It is simply what it means to be a child. The heteronomous voice, the external law, is not the enemy. It is the necessary starting material.</p>\n\n<p>The job of good parents is to hold the external world at exactly the right distance from the child: close enough to be real, but not so close as to overwhelm. The world in its full complexity would crush anyone who encountered it unprepared. So parents filter and simplify. They hand over a version of reality that is manageable, a map that is smaller than the territory but navigable.</p>\n\n<p>This is not dishonesty; it is care. But it comes with a consequence. If those simplifications are never corrected, the child who becomes an adult keeps navigating by the reduced map. They remain dependent on whatever simplified versions of reality others are willing to offer. And if the parents did not manage to hold the world at the right distance at all, if the child grew up with too much chaos, too much complexity coming in at once, then the world may seem, from then on, impossibly complex.</p>\n\n<p>The question is what happens next.</p>\n\n<p>In some people, nothing happens. The law absorbed in childhood becomes the law of adulthood, and it is never examined. The rules instilled by a particular family, church, or culture are simply carried forward as if they were universal truths. This is Kant’s heteronomy operating at full scale: acting according to an externally given law, without ever asking where it came from or whether it can be endorsed on its own terms.</p>\n\n<p>The goal, Kant’s goal, and I think it is a real goal, is something different. It is to move from that inherited starting point toward something genuinely your own. Not by discarding everything you were given, which would leave you with nothing. But by examining it, questioning it, comparing it with other traditions, testing it against experience, and gradually building an inner standard that you can actually stand behind.</p>\n\n<p>This process has a name. Jung called it <strong>individuation</strong>.</p>\n\n<hr />\n\n<h2 id=\"archetypes-and-the-externalization-of-the-inner-world\">Archetypes and the Externalization of the Inner World</h2>\n\n<p>Jung’s contribution to this picture is the concept of <strong>archetypes</strong>, patterns that recur across human cultures, myths, and stories regardless of historical contact between them. The hero who descends into darkness and returns transformed. The shadow that must be faced rather than denied. The self that integrates what was previously split.</p>\n\n<p>I do not think these patterns are mystical. I think they are descriptions of genuine structures in human psychology, the same dynamics that play out in every family, every community, every individual life, just told in different narrative clothing. The gods of the Greek pantheon, the figures of the Hindu epics, the characters in the Norse sagas: these are not histories. They are imaginative maps of the inner life, built up over thousands of years of collective experience. Jordan Peterson’s <em>Maps of Meaning</em> is the most thorough attempt I know of to work through what these structures are, where they come from, and why they take the specific forms they do.</p>\n\n<p>The polytheistic image is useful here. If you imagine your inner world as populated by competing figures, the fierce judge, the nurturing parent, the adventurous child, the wise elder, then the work of individuation becomes something like managing a court. You are not trying to silence any of them. You are trying to understand what each represents, where it came from, and how to let them speak without letting any one of them rule absolutely.</p>\n\n<p>The practical work is to <strong>externalize</strong> these patterns through reflection, through conversation, through reading. Examine them. Find their strengths and their pathologies. Then synthesize something of your own. Not by picking one archetype and ignoring the rest. By seeing what is real in each and building a standard that integrates the best of what they point toward.</p>\n\n<p><em>(A footnote here for Erich Fromm, who deserves more space than I am giving him: his distinction between productive and non-productive orientations points at the same territory from a different angle. Genuine autonomy, for Fromm, is not a matter of following the right rules; it is the capacity to think and to love from your own centre, rather than from anxiety, compulsion, or the need for external approval. That capacity is not given. It is cultivated. And the cultivation is exactly the individuation process described above.)</em></p>\n\n<hr />\n\n<h2 id=\"good-stories-are-more-real-than-history\">Good Stories Are More Real Than History</h2>\n\n<p>Jordan Peterson asks somewhere whether Batman is a real story. On first hearing, the answer seems obvious: of course not. Bruce Wayne never existed.</p>\n\n<p>But that misses the point. The story captures something that <em>does</em> exist, a recognizable pattern. Profound injustice suffered in childhood. Resources and capability available in abundance, yet none of it enough to quiet the inner demand for something meaningful. A deliberate choice to take on something much larger than personal comfort. The refusal to become the thing you fight against.</p>\n\n<p>That pattern is real. It is not real because it happened to a specific person. It is real because it happens, in some form, to some degree, to people across all of history and culture. The story does not derive its truth from its factual content. It derives it from how accurately it maps something true about human experience.</p>\n\n<p>I think this is what the great religious and philosophical texts are also doing. The Vedas, the Tao Te Ching, the Gospels, the dialogues of Socrates and Plato: none of them are primarily history. They are attempts to articulate patterns that are genuinely there, encoded in narrative form so that they can be transmitted across generations without requiring the original experience to recur.</p>\n\n<p>This is why I no longer think it is useful to approach these texts with the question <em>did this happen?</em> The better question is <em>what does this point to?</em> And whether what it points to is real enough to be worth integrating into your own inner court.</p>\n\n<hr />\n\n<h2 id=\"the-catholic-upbringing-and-what-came-after\">The Catholic Upbringing, and What Came After</h2>\n\n<p>I was raised in a strongly Catholic household. For a long time, the framework I inhabited was not one I had examined. It was simply the water I swam in: the calendar of feast days, the moral language, the sense that certain things were simply right or wrong without needing justification.</p>\n\n<p>When I left my family home, I went through a period I can only describe as somewhat wild. Between seventeen and twenty-two I was a bit lost. In that time I ended up in contact with people who had grown up completely differently, without the Catholic framework, without its rituals and its moral language. Some of them were also lost; the framework they had been given had not held either. Others were remarkably stable, grounded in ways I had not expected, arriving at their equilibrium through routes entirely different from mine. Both groups made me reflect on my own belief. The lost ones showed me what it looked like when nothing had been given at all. The stable ones demonstrated that other frameworks worked.</p>\n\n<p>I found a woman who had been raised Protestant, and in many conversations with her I could work through the concepts I had absorbed as a child. Seeing the same ideas arrive through a different tradition began to clarify what was particular to the Catholic form and what was pointing at something more general.</p>\n\n<p>Later, at thirty-four, I met a Chinese exchange student. She told me about the Dao and about Falun Dafa. Then she started asking me questions about my own faith, and the questions were good enough that I had to really think. Not to defend myself, but to understand what I actually believed and why. That happened at thirty-four. Not too late. Perhaps exactly right: secure enough in life and in my own footing to evaluate rather than to absorb or reject.</p>\n\n<p>When I began to examine the tradition I had inherited, what I found first was the dogma. Things accepted not because they had been thought through, but because they had been handed down. That discovery was genuinely disappointing. Not because the tradition was worthless; it is not. But because so much of what I had taken to be principled turned out to be merely received.</p>\n\n<p>What followed was investigation. I read Thomas Aquinas, who tried to build the whole thing on rational foundations. I read Meister Eckhart, who stripped away the institutional layer entirely and went looking for what was mystical at the core. I had real conversations, the kind where you are not performing a position but actually thinking out loud with someone. My friend Makke deserves a mention here. His father was a Benedictine monk until he left the monastery for a woman, and Makke grew up in the particular atmosphere that produces such a person: deeply aware of the tradition, not bound to it in the conventional way. Talking with him over the years has been one of the more useful things I have done for my own thinking.</p>\n\n<p>In those conversations the patterns started to become visible. The specific stories mattered less and less. The patterns they pointed to mattered more and more. The idea that suffering can be transformed into something meaningful rather than merely endured. The insistence that love is not a feeling but a practice. The image of a person who walks into death without bitterness. Whether or not these things happened in the way the texts claim, they describe something real about the possible shape of a human life.</p>\n\n<p>That realization was the beginning of something that is still in process.</p>\n\n<hr />\n\n<h2 id=\"where-ai-enters-the-picture\">Where AI Enters the Picture</h2>\n\n<p>I want to say something about AI in this context, because I think it is genuinely relevant and not often discussed clearly.</p>\n\n<p>Consider the multiplication problem 2151 times 1449. Almost nobody can do that in their head. With a piece of paper, it takes two minutes. The problem has not changed. What changed is the medium: paper externalizes the working memory and makes the intermediate steps visible and manageable.</p>\n\n<p>Many people try to work through their inner conflicts the same way they would try to solve that multiplication problem: entirely in their heads. The conclusions they reach are limited by what they can hold in working memory at one time.</p>\n\n<p>One of the hardest aspects of the individuation process is that the inner dialogue is, by its nature, internal. You are arguing with yourself. You are trying to examine assumptions so deeply embedded you cannot always see them. You need someone, or something, to push back, to articulate the other side, to ask the question you were not ready to ask yourself.</p>\n\n<p>Historically this required other people. A trusted friend, a cleric or spiritual adviser, a mentor. These relationships are irreplaceable. But they are also rare, expensive, and not always available at the moment the thought is actually alive in you.</p>\n\n<p>What a good AI conversation partner can do, when it is honest and not simply telling you what you want to hear, is externalize the inner dialogue. You type your half-formed thought. Something comes back. You respond. The argument develops. You discover, through the exchange, what you actually think. There is a line I keep returning to: <em>“How am I supposed to know what I think until I hear what I have to say?”</em> Articulating something to an external interlocutor forces a precision that purely internal rumination resists.</p>\n\n<p>In my experience, Grok has been the tool that has worked best for this. Not because it is always right, but because it will actually disagree. It will point out the weak joint in the argument. And a response that points at the weak joint, even if you end up rejecting it, advances the thinking.</p>\n\n<p>I do not think AI will find the universal good by compiling all the stories humanity has ever told. But I think it can be a genuine instrument in the individual process of building one, and that is no small thing.</p>\n\n<hr />\n\n<h2 id=\"a-synthesis-worth-trying\">A Synthesis Worth Trying</h2>\n\n<p>Here is the position I find myself arriving at, not as a finished doctrine but as a working method.</p>\n\n<p>The heteronomous voices you inherit, your family, your culture, your religion, the philosophical tradition you stumbled into, are not obstacles to autonomy. They are the raw material. You cannot build a self from nothing. You need something to work with.</p>\n\n<p>The mistake is to stop there. To take one of those inherited voices, declare it authoritative, and spend the rest of your life defending it. That is not autonomy. It is just outsourcing the decision about which external authority to follow.</p>\n\n<p>The work is to do something more deliberate. To <strong>construct a heteronomous image consciously</strong>: to populate your inner court intentionally, to choose which voices you are willing to take seriously, to read the philosophers and the sacred texts and the novelists and the scientists and the poets, not to accept what they say but to let them argue. To let them fight. To see where they converge and where they conflict, and to take those conflicts seriously rather than dissolving them too quickly.</p>\n\n<p>What emerges from that process, if you are honest and patient and willing to be changed by it, is not a fixed doctrine. It is a sensibility. A direction. A set of commitments that are genuinely yours, not because you invented them from scratch, but because you have worked through enough alternatives to know why you hold them rather than their competitors.</p>\n\n<p>That is, I think, as close to Kant’s autonomous will as a real human being can actually get.</p>\n\n<p>The Sermon on the Mount offers a practical resolution to what could otherwise remain indefinitely abstract. Orient yourself toward the highest good you can identify. The most demanding, most integrated version of it you can honestly hold in your mind. Then bring that image back whenever it fades. And then, each day, do the best thing available to you in that direction. Not the theoretically optimal act across all possible futures, but the best step you can take today, given where you are and what you actually face.</p>\n\n<p>That practice, held consistently, is what individuation looks like when it leaves the study and enters ordinary life.</p>\n\n<hr />\n\n<p>I left the courthouse at noon, got in my car, and drove to the office. We had failed a customer acceptance test on Tuesday with the Horus temperature-measurement module, and there was work to be done. But I kept thinking about what I had read.</p>\n\n<p>The gap between the version of morality I absorbed as a child and the one I am slowly building now is real. The process of crossing it is not finished.</p>\n\n<p>But I think I know what direction to walk in. And I think that is enough to keep moving.</p>\n\n<hr />\n\n<p>If you want to talk about any of this, come find me on <a href=\"https://discord.gg/2BXuUY6hrX\">Discord</a>.</p>",
      "date_published": "2026-06-03T00:00:00+00:00",
      
      "date_modified": "2026-06-03T00:00:00+00:00",
      
      "authors": [ { "name": "Stephan Bökelmann" } ],
      "tags": ["philosophy","personal"]
    },
    
    {
      "id": "https://maxclerkwell.tech/posts/physics-math-or-engineering-how-do-you-decide/",
      "url": "https://maxclerkwell.tech/posts/physics-math-or-engineering-how-do-you-decide/",
      "title": "Physics, Math, or Engineering? How do you decide",
      "summary": "What is the real difference between physics, mathematics, and engineering — and how do you choose the right degree? A personal account from someone who studied all three.",
      "content_html": "<p><img src=\"/assets/posts/physics-math-engineering/titlepic.png\" alt=\"Three figures around a boulder in the mountains: a physicist with equations, a mathematician at a chalkboard, an engineer with blueprints. Caption: Different minds. One mission.\" /></p>\n\n<p>When I was a child, I spent a lot of time in industrial halls and at trade fairs instead of sitting in classrooms. My father studied electrical engineering first and then mechanical engineering. He worked on differential dosing scales early in his career and later moved to Degussa, where he designed complete process engineering plants. By his forties he was responsible for planning and realizing large industrial facilities, both in Germany and in China.</p>\n\n<p>One of the things that always impressed me was that he started building plants in Shanghai already in 1979. When he showed me the old photographs, the China in those pictures looked almost unrecognizable compared to today. He witnessed and helped shape that transformation over more than two decades. I could feel how much pride and responsibility came with that kind of work.</p>\n\n<p>He took me to my first major trade show when I was six years old. I think it was the “K” in Düsseldorf. He asked some of his colleagues to take me around and explain the machines to me. From that day on I often went to work with him, sometimes even skipping school. I was completely fascinated by how machines could perform tasks that were too heavy, too hot, or too dangerous for any human, and do them with astonishing speed and precision. That early exposure shaped something deep in me. I did not just want to understand machines. I wanted to be part of the world that builds them.</p>\n\n<h2 id=\"how-i-ended-up-studying-three-different-fields\">How I Ended Up Studying Three Different Fields</h2>\n\n<p>So when the time came to choose a subject, mechanical engineering felt like the obvious and only reasonable choice. After I graduated as a state-certified engineer, I began to feel that my real strengths might actually lie in electrical engineering. While I was studying for my bachelor’s degree in electrical engineering, a friend passed me a job opportunity at Puls Plasma Technik in Dortmund. That job brought me into contact with high-voltage and pulsed-power equipment used at places like CERN, GSI, COSY, and DESY. Standing in front of those systems, I suddenly understood that I did not just want to build machines anymore. I wanted to understand the fundamental physics that made such extreme machines possible in the first place. That was the moment particle physics became unavoidable for me.</p>\n\n<p>Over the years, a simple model formed in my mind that helps me explain to myself and to others how physics, mathematics, and engineering actually relate to each other. I like to tell it with a picture.</p>\n\n<h2 id=\"physics-mathematics-and-engineering-a-model-that-finally-made-sense\">Physics, Mathematics, and Engineering: A Model That Finally Made Sense</h2>\n\n<p>Imagine a large boulder lying in front of a mountain. Three people are asked to move it to the other side. In technology we usually solve problems like this by doing something that reminds me of the Laplace transform. We take a difficult, messy real-world problem and project it into a cleaner, more abstract space where it becomes easier to handle. We solve it there, and then we bring the solution back into reality.</p>\n\n<p>The physicist is the person who stands in front of the boulder and tries to understand what is actually going on. They measure forces, friction, the shape of the ground, the mass distribution, and all the other relevant conditions. Their job is to create a faithful mathematical description of the situation. They turn the physical reality into a set of equations that capture the essential behavior of the system.</p>\n\n<p>Once you have those equations, you usually cannot solve them with everyday thinking. This is where the mathematician comes in. Mathematicians do not primarily care about the boulder. They care about building reliable intellectual tools. Differentiation, integration, linear algebra, differential equations, statistics, transforms. These are all tools that mathematicians develop, refine, and, most importantly, prove to work within clearly stated boundaries. Their work is the only part of the process where you can achieve real certainty, because mathematics allows you to argue rigorously about correctness.</p>\n\n<p>After the mathematician has done their work, you usually end up with design guidelines or constraints expressed in mathematical language. These guidelines do not yet tell you how to move the boulder. They only tell you what any successful solution must respect. This is where the engineer takes over. The engineer has to read those mathematical results and invent a concrete, workable system that satisfies all the constraints while also being buildable with real materials, real budgets, real people, and real safety requirements. In a sense, the engineer performs the physicist’s work in reverse. They start from abstract requirements and turn them into something you can actually touch and use.</p>\n\n<p>All three roles are necessary, and they are genuinely different.</p>\n\n<h2 id=\"which-field-suits-you-the-honest-differences\">Which Field Suits You? The Honest Differences</h2>\n\n<p>If you are the kind of person who feels deep satisfaction when you manage to prove that something must be true in every possible case, and you enjoy spending long hours working with almost nothing but paper, ink, and precise language, then mathematics is probably the right field for you. You need patience and a love for rigor.</p>\n\n<p>If you enjoy the struggle of taking something messy and real, measuring it carefully, finding patterns, and then expressing those patterns in mathematical form, physics will probably feel exciting to you. You get to live between the real world and the mathematical world.</p>\n\n<p>If you enjoy mathematics and physics but become happiest when you can actually build something that works, when you like working with constraints, when you enjoy the creative act of finding practical solutions that are good enough even if they are not perfect on paper, then engineering is likely where you belong.</p>\n\n<h2 id=\"why-switching-degrees-is-not-a-failure\">Why Switching Degrees Is Not a Failure</h2>\n\n<p>One thing that often confuses students is that the first years of study in these fields look quite similar. Most engineering programs start with mechanics, basic electronics, and mathematics. Most physics programs start with mechanics, electromagnetism, optics, and thermodynamics. Mathematics begins with analysis and linear algebra. Because of this overlap, many people feel uncertain or even guilty when they switch directions after one or two years. I think that feeling is usually unnecessary. Switching can simply mean that you are getting to know yourself better. You can only guess what a field will actually feel like from the outside. If you discover after some time that you are on the wrong train, getting off at the next station is not a failure. It is information.</p>\n\n<p>The most important question is not which label you choose first. The important question is whether you are genuinely interested in understanding how things work and in contributing to the technological world we live in. If you enjoy solving difficult problems, if you are fascinated by the fact that a single person or a small group of people can create things that change what is possible for everyone else, and if you are willing to keep learning for the rest of your life, then you are probably in the right broad area.</p>\n\n<p>The specific door you walk through first matters less than most people think. What matters more is whether you stay honest with yourself about what actually energizes you as you go deeper.</p>\n\n<p>If you are standing in front of this decision this summer, try not to put too much pressure on yourself to get it perfectly right on the first attempt. Almost everyone who works seriously in these fields had to adjust their path at some point. There are very few truly wasted years if you do the work with real curiosity and to the best of your abilities.</p>\n\n<p>If you want to talk about it, come find me on Discord.</p>\n\n<div><a href=\"https://discord.gg/2BXuUY6hrX\" class=\"link-card-discord\" target=\"_blank\" rel=\"noopener noreferrer\"><i class=\"fab fa-discord\"></i><div class=\"discord-text\"><span class=\"discord-name\">Discord — Full Stack Engineering</span><span class=\"discord-note\">Direct access to me and my colleagues. Webinars, live Q&amp;A, and community discussions for engineers across the full stack.</span><span class=\"discord-join\">Join the server →</span></div></a></div>",
      "date_published": "2026-06-01T00:00:00+00:00",
      
      "date_modified": "2026-06-01T00:00:00+00:00",
      "image": "https://maxclerkwell.tech/assets/posts/physics-math-engineering/titlepic.png",
      "authors": [ { "name": "Stephan Bökelmann" } ],
      "tags": ["physics","engineering","education","personal"]
    }
    
  ]
}
