machine.PinInputPullup Does Not Work on Some GPIO Pins

How I fixed a tiny issue in TinyGo.
A cute, tiny gopher.

Thank you for reading this post. Your reward is this cute, tiny gopher.

Show QR code Hide QR code
QR code linking to https://navendu.me/posts/tinygo-3477/

Ever since I started using Go, I have been finding excuses to use it everywhere. Fortunately, enough Gophers feel the same, and I was able to use Go everywhere.

I discovered TinyGo while working on WebAssembly plugins for Apache APISIX. I can write proxy-agnostic plugins, and that too in Go instead of Lua or, God forbid, C/C++? Consider me sold.

TinyGo was built to compile Go for embedded devices. I started taking programming seriously after I discovered microcontrollers and Arduino. When I was a teenager, I realized that I don’t have to get a specific comparator (LM393 is etched into my brain) to compare voltages, and instead I can just write an if statement with the < operator. My transition from analog to digital electronics snowballed into a career of writing code.

So it is fair to say, life is coming full circle now. Overwhelmed by LLMs at my day job, where I don’t read the code anymore (as expected of me), I decided to go back to real engineering and got myself an ESP32 board to play with. Apparently this is the new AI-driven midlife crisis for nerds like me.

After I set up the board and got that initial release of dopamine from the “hello world!” of electronics—blinking the onboard LED—I was hooked. I was ambitious. I wanted to design circuit boards and churn out 3D-printed cases. I wanted to take back the joy of building that LLMs stole from me (or I voluntarily surrendered).

I wanted to play with the WiFi and Bluetooth capabilities in the ESP32 board and build something cool. I checked the latest TinyGo release, and they had just added a lot of support for these exact capabilities in the ESP32. Clearly a divine intervention.

A broader goal was to learn more about low-level, embedded hardware programming. Maybe I will be able to contribute to TinyGo. Maybe I will be able to fix some bugs, or even write drivers for obscure components, and all the while learn how this works.

I decided to check the open issues to see if I could fix anything while I wait for all the components to show up (I just had the ESP32 and a breadboard and some wires; I hate moving), and that’s when I found the titular “machine.PinInputPullup Does Not Work on Some GPIO Pins” issue.

Issue #3477

Issue #3477 was opened three years ago and had a clear description:

Several GPIO pins on the ESP32 do not work with machine.PinInputPullup. It seems like they are configured as PinInput instead. These same pins do work in INPUT_PULLUP mode in C++ under the ESP32 Arduino Core.

The problem GPIO pins are GPIO32, GPIO33, GPIO25, GPIO26, GPIO27, GPIO12, GPIO13, GPIO4, and GPIO2.

The issue said that internal pull-up resistors for these listed GPIO pins do not work after setting machine.PinInputPullup. There were also a couple more comments in the issue thread, which I will get to later, and a spam comment (clearly AI-generated spam).

Reproducing the Issue

The issue description also had the code to reproduce the issue:

package main

import (
	"machine"
	"time"
)

const pin = machine.GPIO13

func main() {
	pin.Configure(machine.PinConfig{Mode: machine.PinInputPullup})

	time.Sleep(time.Millisecond * 500)
	print("Reading pin #", pin, ": ")

	for {
		value := pin.Get()
		if value {
			print("1")
		} else {
			print("0")
		}
		time.Sleep(time.Millisecond * 100)
	}
}

I ran this on my ESP32 and, in addition to the diagnosis from pin.Get(), I checked the voltages on these pins. The pull-up resistors were not working. The signal should be high (and pin.Get() should be showing 1) with the pull-up resistor enabled, but it was otherwise.

My first hunch was that perhaps these pins did not have pull-up resistors at all. I looked at the datasheet for ESP32 and found this nested in Appendix A.1:

GPIO pins 34-39 are input-only. These pins do not feature an output driver or internal pull-up/pull-down circuitry.

If they explicitly mention that GPIO pins 34-39 don’t have pull-up/pull-down resistors, it must mean that the rest of the GPIO pins do. That seemed to be a reasonable assumption.

I wrote a small test script to go through all the other GPIO pins to see which worked and which didn’t:

package main

import (
	"machine"
	"time"
)

var pins = []machine.Pin{19, 21, 22, 23, 4, 12, 13, 14, 15, 25, 26, 27, 32, 33}

func read(p machine.Pin, mode machine.PinMode) bool {
	p.Configure(machine.PinConfig{Mode: mode})
	time.Sleep(20 * time.Millisecond)
	return p.Get()
}

func main() {
	time.Sleep(2 * time.Second)
	for {
		println("pin pullup pulldown pass")
		pass := 0
		for _, p := range pins {
			up := read(p, machine.PinInputPullup)
			down := read(p, machine.PinInputPulldown)
			ok := up && !down
			if ok {
				pass++
			}
			println(int(p), up, down, ok)
		}
		println("passed", pass, "of", len(pins))
		println()
		time.Sleep(3 * time.Second)
	}
}

And as reported, only four of the fourteen pins had working pull-up/pull-down mechanisms:

pin pullup pulldown pass
19 true false true
21 true false true
22 true false true
23 true false true
4 false false false
12 false false false
13 false false false
14 true true false
15 true true false
25 false false false
26 false false false
27 false false false
32 false false false
33 false false false
passed 4 of 14

With this data, I could summarize that pin.Configure() does indeed work as it worked for those four pins. Unless I missed something.

What Am I Missing?

I started by looking at the pin.Configure() function. It wraps around this function:

func (p Pin) configure(config PinConfig, signal uint32) {
	// ...

	var muxConfig uint32 // The mux configuration.

	// Configure this pin as a GPIO pin.
	const function = 3 // function 3 is GPIO for every pin
	muxConfig |= (function - 1) << esp.IO_MUX_GPIO0_MCU_SEL_Pos

	// Make this pin an input pin (always).
	muxConfig |= esp.IO_MUX_GPIO0_FUN_IE

	// Set drive strength: 0 is lowest, 3 is highest.
	muxConfig |= 2 << esp.IO_MUX_GPIO0_FUN_DRV_Pos

	// Select pull mode.
	if config.Mode == PinInputPullup {
		muxConfig |= esp.IO_MUX_GPIO0_FUN_WPU
	} else if config.Mode == PinInputPulldown {
		muxConfig |= esp.IO_MUX_GPIO0_FUN_WPD
	}

	// Configure the pad with the given IO mux configuration.
	p.mux().Set(muxConfig)

	// ...
}

Each pin has one 32-bit “IO MUX” config register. The above function builds up each bit in the register and writes it in one shot. In the highlighted lines, it sets the IO_MUX_GPIO0_FUN_WPU and IO_MUX_GPIO0_FUN_WPD bits for pull-up or pull-down respectively depending on the configuration.

The important find here, or what I did not find here, is any special handling for the four pins that worked. I hit a dead end. Fortunately a comment on the issue thread gave me a direction to explore:

My assumption would be that the pin is incorrectly configured somehow and there is another register that needs to be set correctly.

Another register? I had no clue what this meant, so I went to the datasheet again. Something like this must be documented in the datasheet.

And sure enough, I found the pin overview table (Table 2-1), which gave some hints into what might be happening. I lined up the pins where the pull-up/pull-down resistors worked against the ones that failed:

NameNo.TypeFunctionPull works?
GPIO2239I/OGPIO22, U0RTS, VSPIWP, EMAC_TXD1Yes
GPIO1938I/OGPIO19, U0CTS, VSPIQ, EMAC_TXD0Yes
GPIO2142I/OGPIO21, VSPIHD, EMAC_TX_ENYes
GPIO2336I/OGPIO23, HS1_STROBE, VSPIDYes
GPIO424I/OGPIO4, ADC2_CH0, RTC_GPIO10, TOUCH0, EMAC_TX_ERNo
GPIO2514I/OGPIO25, ADC2_CH8, RTC_GPIO6, DAC_1, EMAC_RXD0No
GPIO2615I/OGPIO26, ADC2_CH9, RTC_GPIO7, DAC_2, EMAC_RXD1No
GPIO3212I/OGPIO32, ADC1_CH4, RTC_GPIO9, TOUCH9, 32K_XPNo
GPIO3313I/OGPIO33, ADC1_CH5, RTC_GPIO8, TOUCH8, 32K_XNNo

Now, I was pretty proud of my sleuthing here, so I won’t rob you of a chance. What do the failing pins have in common that no working pins have?

On top of the GPIO function, all these pins seem to have other functions. But all failing pins have an RTC_GPIO function while none of the working ones do. Surely that’s not a coincidence?

The issue had mentioned that the pull-up resistor worked with the ESP32 Arduino Core. So it is a TinyGo issue. The comment on the issue had mentioned another register might be controlling the pull-up resistors.

I found comments in the TinyGo codebase mentioning the official ESP-IDF SDK. After all, TinyGo is trying to do the exact same thing to the exact same hardware as the ESP-IDF SDK. Does it do anything different here?

Source Code

After some searching, I found the gpio_set_pull_mode() function, which seems to be calling gpio_pullup_en():

esp_err_t gpio_set_pull_mode(gpio_num_t gpio_num, gpio_pull_mode_t pull)
{
    // ...
    switch (pull) {
    case GPIO_PULLUP_ONLY:
        gpio_pulldown_dis(gpio_num);
        gpio_pullup_en(gpio_num);
        break;
    // ...
    }
}

Nothing interesting there, but inside the gpio_pullup_en() function, I found this:

esp_err_t gpio_pullup_en(gpio_num_t gpio_num)
{
    GPIO_CHECK(GPIO_IS_VALID_OUTPUT_GPIO(gpio_num), "GPIO number error (input-only pad has no internal PU)", ESP_ERR_INVALID_ARG);

    if (!rtc_gpio_is_valid_gpio(gpio_num) || GPIO_RTCIO_ARE_INDEPENDENT) {
        // ...
        gpio_hal_pullup_en(gpio_context.gpio_hal, gpio_num);
        // ...
    } else {
        rtc_gpio_pullup_en(gpio_num);
    }

    return ESP_OK;
}

And there it was. If the pin does not have an RTC function, it enables the pull-up resistor through gpio_hal_pullup_en(), which sets the IO MUX bit, like what it did in TinyGo. But in the else branch, i.e., if the pin does have an RTC function, it calls rtc_gpio_pullup_en(), which is not in TinyGo.

That was the bug. The pull-up resistor needs to be enabled through a different register that was ignored.

The actual register write happens in rtcio_ll_pullup_enable():

static inline void rtcio_ll_pullup_enable(int rtcio_num)
{
    if (rtc_io_desc[rtcio_num].pullup) {
        SET_PERI_REG_MASK(rtc_io_desc[rtcio_num].reg, rtc_io_desc[rtcio_num].pullup);
    }
}

The rtc_io_desc lookup table has the map:

//                     REG                  ...  Pullup               Pulldown             ...  gpio number
{RTC_IO_ADC_PAD_REG,   ...                       0,                   0,                   ...}, //34
// ...
{RTC_IO_PAD_DAC1_REG,  ...  RTC_IO_PDAC1_RUE_M,  RTC_IO_PDAC1_RDE_M,  ...}, //25
{RTC_IO_PAD_DAC2_REG,  ...  RTC_IO_PDAC2_RUE_M,  RTC_IO_PDAC2_RDE_M,  ...}, //26
// ...

I also found the “RTC IO MUX Pin Summary” table (6.11-1) in the ESP32 Technical Reference Manual. The rtc_io_desc lookup table maps perfectly.

For each RTC_GPIO pin, we now know which RTC_IO_* and which RUE/RDE bit to set. I added this to TinyGo in pull request #5578.

The Fix

The fix mirrors ESP-IDF. The configure() function still sets the IO MUX bits as before, but now it also calls a new helper, configureRTCPull() that writes the RTC_IO registers:

func (p Pin) configure(config PinConfig, signal uint32) {
	// ... existing IO MUX setup
	p.mux().Set(muxConfig)

	// Internal pull resistors for pins with RTC function ignore
	// the IO_MUX_GPIO0_FUN_WPU and IO_MUX_GPIO0_FUN_WPD bits set
	// above and are instead controlled by the RTC_IO registers.
	p.configureRTCPull(config.Mode)
	// ...
}

// configureRTCPull applies the pullup/pulldown setting to a pin.
func (p Pin) configureRTCPull(mode PinMode) {
	var rue, rde uint32
	switch mode {
	case PinInputPullup:
		rue = 1
	case PinInputPulldown:
		rde = 1
	}

	switch p {
	// ...
	case 25:
		esp.RTC_IO.SetPAD_DAC1_PDAC1_RUE(rue)
		esp.RTC_IO.SetPAD_DAC1_PDAC1_RDE(rde)
	case 26:
		esp.RTC_IO.SetPAD_DAC2_PDAC2_RUE(rue)
		esp.RTC_IO.SetPAD_DAC2_PDAC2_RDE(rde)
	// ...
	}
}

I’m unsure if this is the right fix. Maybe the maintainers would prefer a less “patchy” fix. But from what I know, this has the least intervention. As an open source maintainer myself, I would prefer such minor fixes over a newcomer proposing a complete overhaul.

I ran the same test script. All the pins passed this time around:

pin pullup pulldown pass
19 true false true
21 true false true
22 true false true
23 true false true
4 true false true
12 true false true
13 true false true
14 true false true
15 true false true
25 true false true
26 true false true
27 true false true
32 true false true
33 true false true
passed 14 of 14

Webmentions • Last updated at 11:31 AM, 10th August 2026

Have you written a response to this? Send me a webmention by entering the URL.