Every peripheral on a microcontroller is a set of memory-mapped registers. Today you'll configure GPIO, timers, and UART entirely through direct register writes.
GPIO registers follow a consistent pattern across ARM MCUs. Key registers: MODER (pin mode: input/output/alternate/analog), ODR (output data register: read/write), IDR (input data register: read only), BSRR (bit set/reset: atomic, upper 16 bits reset, lower 16 bits set), PUPDR (pull-up/pull-down). Always enable the GPIO clock in the RCC register before accessing GPIO registers.
General-purpose timers are the most versatile peripheral. Configure: PSC (prescaler) divides the clock, ARR (auto-reload register) sets the period. Timer fires interrupt (update event) every ARR+1 prescaled clocks. For PWM: set channel to PWM mode, set CCR (capture/compare register) for duty cycle. 16-bit timer at 72MHz: PSC=71, ARR=999 → 1kHz PWM with CCR=499 = 50% duty cycle.
UART config: BRR (baud rate register) = fCLK / baud. At 72MHz, 9600 baud: BRR = 7500. Set word length, stop bits, parity in CR1/CR2. Enable TX and RX in CR1. Poll TXE flag before writing to DR. Poll RXNE flag before reading from DR. For interrupt-driven UART, enable RXNE interrupt in CR1, write ISR, enable in NVIC. Never busy-poll in production — always use interrupt or DMA.
// UART transmit without HAL — STM32F1
#include "stm32f1xx.h"
void uart1_init(uint32_t baud) {
// 1. Clock enable: USART1 on APB2, GPIOA on APB2
RCC->APB2ENR |= RCC_APB2ENR_USART1EN | RCC_APB2ENR_IOPAEN;
// 2. Configure PA9 (TX) as alternate function push-pull
GPIOA->CRH &= ~(0xF << 4);
GPIOA->CRH |= (0xB << 4); // AF output push-pull, 50MHz
// 3. Configure PA10 (RX) as input floating
GPIOA->CRH &= ~(0xF << 8);
GPIOA->CRH |= (0x4 << 8); // input floating
// 4. Set baud rate (PCLK2 = 72MHz assumed)
USART1->BRR = 72000000UL / baud;
// 5. Enable USART, TX, RX
USART1->CR1 = USART_CR1_UE | USART_CR1_TE | USART_CR1_RE;
}
void uart1_putchar(char c) {
while (!(USART1->SR & USART_SR_TXE)); // wait for TX buffer empty
USART1->DR = c;
}
void uart1_puts(const char *s) {
while (*s) uart1_putchar(*s++);
// Wait for last byte to finish transmitting
while (!(USART1->SR & USART_SR_TC));
}
int main(void) {
uart1_init(9600);
uart1_puts("Hello from bare-metal UART!
");
char buf[32];
uint32_t count = 0;
while (1) {
// Simple integer to string (no sprintf without libc)
int i = 0, n = count;
if (n == 0) { buf[i++] = '0'; }
while (n > 0) { buf[i++] = '0' + (n % 10); n /= 10; }
buf[i++] = '
'; buf[i++] = '
'; buf[i] = ' ';
// Reverse
for (int a=0, b=i-3; a<b; a++,b--) {
char t=buf[a]; buf[a]=buf[b]; buf[b]=t;
}
uart1_puts(buf);
count++;
for (volatile int d=0; d<1000000; d++);
}
}
void pwm_set_duty(uint8_t percent) that maps 0–100% to 0–ARR.Implement interrupt-driven UART receive. Enable the RXNE interrupt in USART1 CR1 and in the NVIC. Write an ISR that reads each byte into a circular buffer. In main(), check if a complete line (ending in \n) is in the buffer and process the command. Implement: 'LED ON', 'LED OFF', 'STATUS' commands. This is the pattern used in every command-line interface on embedded hardware.