Simulating Open-Drain GPIO in Software

In today's day and age, it's rare to find a modern microcontroller that does not support configurable GPIO. We can easily take these configuration options for granted, especially when interacting with circuits and communication busses (e.g. I2C) that require GPIO to be configured in open-drain mode.

As embedded developers we do not always get to work with the latest-and-greatest parts. Sometimes we end up working with tiny or cheap processors (e.g. AVR), and other times we need to support a legacy part.

Fortunately, it's quite simple to recreate open-drain GPIO behavior in software.

Table of Contents:

  1. Open-Drain Support in Software
    1. Defining Types
    2. Setting Pin States
    3. Reading Pin Values
    4. Initial Configuration
  2. Further Reading

Open-Drain Support in Software

To enable open-drain support, we'll need to support three primary operations:

  1. Actively drive output low
  2. Put the port in input mode with a pull-up (logical 1)
  3. Put the port in high-impedance mode (floating input with no pull-up or pull-down enabled)

If the open-drain circuit has an external pull-up resistor, operation #2 is not necessary. However, in situations where there is not an external pull-up resistor, you will need to rely on the microcontroller's internal pull-ups.

We will create simple wrapper functions to enable these modes. I'll be using pseudo-code for these examples, since GPIO interfaces vary widely.

Defining Types

First, rather than using a plain 0 or 1 value, we'll define a custom type to describe the three possible states:

typedef enum
{
    OD_LOW = 0,
    OD_HIGH = 1,
    OD_HIGH_Z = 2
} opendrain_state_t;

As an alternative to the less-descriptive bool, we'll also create a helper type that can be used for initial configuration:

typedef enum
{
    OD_CONFIG_NO_PULLUP = 0,
    OD_CONFIG_PULLUP = 1,
} opendrain_config_t;

Setting Pin States

Now that we have our states defined, we can create a function to manage an open-drain pin:

void setOpenDrainPin(void* port, unsigned pin, opendrain_state_t state)
{
    switch(state)
    {
        case OD_LOW:
            setPinMode(port, pin, OUTPUT);
            setOutput(port, pin, 0);
            break;
        case OD_HIGH:
            setPinMode(port, pin, INPUT_PULLUP);
            break;
        case OD_HIGH_Z:
            // No pull-up in hi-z mode
            setPinMode(port, pin, INPUT);
            break;
    }
}

We can also create helper functions:

void setOpenDrainHigh(void * port, unsigned pin)
{
    setOpenDrainPin(port, pin, OD_HIGH);
}
void setOpenDrainLow(void * port, unsigned pin)
{
    setOpenDrainPin(port, pin, OD_LOW);
}
void setOpenDrainHiZ(void * port, unsigned pin)
{
    setOpenDrainPin(port, pin, OD_HIGH_Z);
}

Reading Pin Values

To read the state of an open-drain pin, no special behavior is necessary. Simply follow your normal procedures for reading the value of the pin.

Initial Configuration

We can then create a function which we will use to configure our open-drain pins:

void configureOpenDrainPin(void* port, unsigned pin, 
    opendrain_config_t config)
{
    switch(config)
    {
        case OD_CONFIG_NO_PULLUP:
            setOpenDrainHiZ(port, pin);
            break;
        case OD_CONFIG_PULLUP:
            setOpenDrainHigh(port, pin);
            break;
    }
}

This allows you to configure your pin in a straightforward way that is easier for other users to understand.

You could also expand support to limit the operations of the setOpenDrainPin function based on the initial configuration of that pin.

Further Reading