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:
Open-Drain Support in Software
To enable open-drain support, we’ll need to support three primary operations:
- Actively drive output low
- Put the port in input mode with a pull-up (logical 1)
- 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.