A custom PLC looks like a simple firmware project: read some inputs, run some logic, switch some relays. The hardware is where most of the work seems to be. Then the first field test happens, and the bugs turn out to be in four small places: how the I/O is numbered, when the RS-485 driver turns off, where the retained values live, and what the outputs do while the MCU is still in reset.

This post walks through those four places on a real board: an STM32F103 in a 100-pin package with 24 optocoupled inputs (X00–X27), 16 relay or MOSFET outputs (Y00–Y17), three RS-485 ports, an RS-232 port, W5500 Ethernet and a W25Q128 SPI flash. The hardware side of the same board is documented in a PLC PCB design case study; here we stay on the firmware side.

The code is written against the STM32 HAL for readability. The same ideas apply to LL or register-level code.

1. Octal I/O numbering and a table-driven input map

PLC terminals in the Mitsubishi tradition are numbered in octal: X00–X07, then X10–X17, then X20–X27. There is no X08 or X09. Twenty-four inputs end at X27, not X23, and that trips up anyone who indexes them as decimal.

C can help, with a caution. An octal literal is written with a leading zero, so 017 is 15. That maps neatly to terminal X17, but a leading zero is also one of C's classic footguns: 010 is 8, not 10. An explicit enum is safer than relying on readers to notice the zero:

typedef enum { X00 = 0, X01, X02, X03, X04, X05, X06, X07, X10 = 8, X11, X12, X13, X14, X15, X16, X17, X20 = 16, X21, X22, X23, X24, X25, X26, X27, X_COUNT } input_id_t; 

The physical pins rarely line up with the terminal order, because the layout puts each optocoupler where it routes best. A lookup table keeps the mapping in one place:

typedef struct { GPIO_TypeDef *port; uint16_t pin; } io_pin_t; static const io_pin_t input_map[X_COUNT] = { [X00] = { GPIOE, GPIO_PIN_0 }, [X01] = { GPIOE, GPIO_PIN_1 }, /* ... one entry per terminal, taken from the netlist ... */ }; 

On a typical optocoupled input, an energised terminal turns the phototransistor on and pulls the MCU pin low. The firmware should invert once, at the edge, so the rest of the code sees 1 as "input on". A simple counter-based debounce, called from a 1 ms tick, does the rest:

#define DEBOUNCE_SCANS 3  static uint8_t bounce[X_COUNT]; static uint32_t inputs; /* bit n = debounced state of input n */ void inputs_scan_1ms(void) { for (int i = 0; i < X_COUNT; i++) { uint32_t raw = (HAL_GPIO_ReadPin(input_map[i].port, input_map[i].pin) == GPIO_PIN_RESET); uint32_t cur = (inputs >> i) & 1u; if (raw != cur) { if (++bounce[i] >= DEBOUNCE_SCANS) { inputs ^= (1u << i); bounce[i] = 0; } } else { bounce[i] = 0; } } } 

One exception: on this board, four inputs (X00, X01, X03 and X04) use high-speed 6N137 optocouplers instead of the slower EL357N, because they are meant for encoders and counters. Those should not go through a 3 ms debounce. Route them to timer inputs or EXTI interrupts instead.

2. RS-485 direction control: wait for TC, not TXE

RS-485 is half-duplex. The transceiver's driver-enable pin (DE, usually tied to the inverted receiver-enable /RE) must be high while transmitting and low the rest of the time, or the node holds the bus and nobody else can talk. Newer STM32 families such as F0, F3, L4 and G0 can drive DE in hardware from the USART. The STM32F1 cannot, so firmware has to switch it.

The classic bug is switching DE back too early. The USART has two flags that sound similar:

  • TXE (transmit data register empty) sets when the byte moves into the shift register. The byte has not left the pin yet.
  • TC (transmission complete) sets when the last stop bit has been shifted out.

Drop DE on TXE, or when the DMA channel reports that it has finished copying the buffer, and the transceiver turns off while the last byte is still on the wire. The receiver sees a truncated frame and the CRC check fails, which looks like noise until you put a scope on DE.

With blocking HAL calls, the timing is handled for you, because HAL_UART_Transmit() waits for TC before it returns:

static inline void rs485_dir_tx(void) { HAL_GPIO_WritePin(RS485_DE_GPIO_Port, RS485_DE_Pin, GPIO_PIN_SET); } static inline void rs485_dir_rx(void) { HAL_GPIO_WritePin(RS485_DE_GPIO_Port, RS485_DE_Pin, GPIO_PIN_RESET); } void rs485_send(UART_HandleTypeDef *huart, const uint8_t *buf, uint16_t len) { rs485_dir_tx(); HAL_UART_Transmit(huart, (uint8_t *)buf, len, 100); /* returns after TC */ rs485_dir_rx(); } 

With DMA, switch back in HAL_UART_TxCpltCallback(). In normal DMA mode the HAL enables the TC interrupt after the DMA transfer finishes and calls this callback only when TC sets, so the USART's global interrupt must be enabled in the NVIC:

void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart == &huart2) { rs485_dir_rx(); } } 

In register-level or LL code, the rule is the same: wait for USART_SR_TC, then drop DE.

Two more details matter on a multi-drop bus. With three RS-485 ports, each needs its own DE pin and its own state; a shared flag between ports is a hard bug to find. And if the port runs Modbus RTU, the 3.5-character silent interval between frames is part of the protocol, so a node that starts transmitting before the previous sender has released the bus can still collide, even with DE timing correct.

3. Retentive memory: SPI flash is not FRAM

PLCs keep some values across power cycles: counters, setpoints, latched states. An earlier revision of this board used FRAM for that. The current one uses a W25Q128 SPI NOR flash. Both connect over SPI, which makes them look interchangeable. They are not.

SPI NOR flash (W25Q128)

FRAM

Write granularity

Program 256-byte pages; erase 4 KB sectors before rewriting

Any byte, any time

Write time

Sector erase takes tens of milliseconds

Bus speed, no erase

Endurance

About 100,000 erase cycles per sector

Many orders of magnitude higher

Two consequences follow for firmware.

You cannot save on power failure by erasing first. By the time the 24 V rail collapses, there may not be enough hold-up time to erase a sector and write it. The usual fix is to keep the next sector pre-erased, so a power-fail save is only a page program.

You cannot rewrite the same location every scan. At one write per second, a single sector reaches 100,000 cycles in about 28 hours. Retained data should be appended as records across a ring of sectors, each record carrying a sequence number and a CRC, and the newest valid record is found at boot:

typedef struct { uint32_t seq; /* increments on every save */ uint32_t counters[8]; uint16_t setpoints[8]; uint32_t crc; /* over all fields above */ } retain_rec_t; /* Boot: scan every slot in the ring, keep the highest seq whose CRC checks. */ /* Save: write the next slot; when a sector fills, move to the next one and */ /* erase the sector after it in the background, so one is always ready. */ 

If the values change often and must survive every power loss, FRAM remains the simpler part. Flash works, but only with this kind of wear-levelling and a reliable early warning that power is failing. The STM32's internal PVD watches the 3.3 V rail, which is too late; an early warning needs the 24 V input monitored before the regulators.

4. What the outputs do before main() runs

Firmware engineers tend to think of output state as something set in MX_GPIO_Init(). On a PLC, the more important question is what the outputs do before that line runs.

During reset, and until the GPIO clocks and modes are configured, STM32 pins are floating inputs. Whatever the output stage does with a floating pin is what the machine does during power-up, a watchdog reset or a firmware update. If an optocoupler or a pull-up in the drive path inverts the logic, a floating pin can mean an energised relay for the first tens of milliseconds, and every reset becomes a short, unintended pulse on every output.

Firmware cannot fix this, because the problem exists before firmware runs. The safe-state rule belongs in hardware: a floating MCU pin must mean "output off". What firmware can do is make the intended state explicit and early:

int main(void) { HAL_Init(); SystemClock_Config(); /* ODR can only be written once the port clock is on. Set the safe level */ /* first, then switch the pins to outputs, so they never drive the wrong */ /* level, even for one instruction. */ __HAL_RCC_GPIOD_CLK_ENABLE(); outputs_write_all_off(); MX_GPIO_Init(); /* ... */ } 

If you use STM32CubeMX, setting each output's initial level in the .ioc file has the same effect: the generated MX_GPIO_Init() writes the level before it configures the pin as an output.

The same logic applies to the watchdog. If the scan loop stops, the independent watchdog should reset the MCU, and the hardware safe state should take over while it restarts.

One last check before writing any of this: count your UARTs. This board has four serial ports. The STM32F103xB has three USARTs; the high-density xC, xD and xE parts add UART4 and UART5. The part number on the BOM has to match the peripherals the firmware expects, or the fourth port does not exist.

Summary

  • Number I/O in octal with an explicit enum, and map terminals to pins through one table.
  • Debounce standard inputs; send counter inputs to timers.
  • Drop RS-485 DE on TC, never on TXE or on DMA completion alone.
  • Treat SPI flash as wear-limited storage: append records, pre-erase, and detect power failure early.
  • Make "pin floating" mean "output off" in hardware, and set output levels before enabling the pins.

References

  • STMicroelectronics, RM0008 reference manual for STM32F101xx–F107xx, USART chapter, status register flags TXE and TC (verify link)
  • Analog Devices, MAX3483/MAX3485 3.3 V RS-485 transceivers (verify link)