Chapter 12: UART driver and printf¶
What: a polled UART1 driver and a tiny
printfclone that uses it. By the end of the chapter your bare-metal program can sayHello, world!instead of blink.Why: debugging bare-metal without
printfis doable but slow. Adding text output changes everything. Every later chapter in Part II usesprintffreely.Focus: the UART baud-divisor formula and the status-register polling loop. Both repeat across most UART implementations. After this chapter you will recognize them anywhere.
12.1 Which UART, and on which pins¶
The i.MX6ULL has eight UART controllers, UART1 through UART8. The Point Atom MINI uses UART1 as its debug console. The UART1 TX and RX pads are connected to the board’s integrated USB-to-TTL bridge, which then connects to the host through the USB-TTL or DEBUG USB port described in Chapter 8.
The signal path is:
i.MX6ULL UART1 pads -> onboard USB-TTL bridge -> debug USB connector -> host serial device
No external CP2102, CH340, FTDI adapter, or jumper wiring is needed. The bridge chip may itself be a CH340 or similar device, but it is already installed on the board.
For our purposes:
Module: UART1, base address
0x02020000.Pads:
UART1_TX_DATA(ALT0 = UART1_TX_DATA),UART1_RX_DATA(ALT0 = UART1_RX_DATA).Pad IOMUXC registers:
IOMUXC_SW_MUX_CTL_PAD_UART1_TX_DATAat0x020E0084, RX at0x020E0088. (Verify against your RM.)Daisy-chain register:
IOMUXC_UART1_RX_DATA_SELECT_INPUTat0x020E0624. For theUART1_RX_DATApad used here, the required daisy value is3.
Why RX needs a daisy-chain register¶
Many i.MX6ULL peripheral inputs can arrive through more than one package pad. UART1 RX is one example. Several pads have an alternate function that can feed the UART1 receiver. The UART therefore needs two separate selections:
Pad MUX selection: choose which function the physical pad performs.
Peripheral input selection: choose which eligible pad the UART1 receiver listens to.
The second selection is the daisy-chain register. Despite the name, signals are not passed through devices in a serial chain. It is an input multiplexer inside the SoC:
candidate pad A ----\
candidate pad B -----+--> SELECT_INPUT mux --> UART1 RX logic
UART1_RX_DATA pad ---/
^
daisy value selects one path
For our board, both settings are required:
REG(IOMUX_MUX_RX) = 0; /* ALT0: this pad performs UART1_RX_DATA */
REG(IOMUX_DAISY) = 3; /* UART1 listens to this pad's input path */
These two numbers belong to different registers. Daisy value 3 does not mean ALT3. The pad uses ALT0, while the UART input selector uses candidate 3.
The upstream Linux pin-function header describes the same route with this five-value tuple:
MX6UL_PAD_UART1_RX_DATA__UART1_DCE_RX 0x0088 0x0314 0x0624 0 3
The tuple means:
Value |
Meaning |
|---|---|
|
MUX_CTL register offset |
|
PAD_CTL register offset |
|
SELECT_INPUT register offset |
|
pad mux mode, ALT0 |
|
input daisy value |
TX usually does not need this extra selection. Once the TX pad is muxed to UART1 TX, the UART drives that pad outward. RX travels inward, so the SoC must know which possible pad to connect to the receiver.
If the daisy value is wrong, TX can still print correctly while RX receives nothing. A voltage transition may reach the physical RX pad, but the UART receiver is connected internally to a different candidate path.
The UART1 controller is on AIPS-1. Its clock gate is in CCM_CCGR5, bits 24-25 (CG12). UART1’s input clock, uart_clk_root, has a default of 80 MHz (PLL3 / 6, with the post-divider set to 1). We’ll use that.
12.2 Baud rate, the i.MX way¶
Most UART chips compute baud as f_in / (16 × divisor). I.MX is the same shape but with two divisor stages, so it can hit awkward baud rates:
baud = (f_uart_clk / 16) × (UBIR + 1) / (UBMR + 1)
UBIRis a 16-bit numerator register (Baud Rate Numerator).UBMRis a 16-bit denominator register (Baud Rate Modulator).The factor of 16 is the oversampling rate, fixed.
There’s a fractional adjustment (
UFCR.RFDIVfield) that further divides f_uart_clk by 1 / 2 / 4 / etc.. We leave it at “divide by 1” for now.
For our case:
f_uart_clk = 80 MHzTarget baud = 115200
We want
(UBIR + 1) / (UBMR + 1) = 115200 × 16 / 80 000 000 = 0.02304
The simplest values that yield this ratio cleanly are (UBIR+1) = 71, (UBMR+1) = 3083. (Choice not unique. We pick small numerators when possible.) So:
UBIR = 70UBMR = 3082
If exact match is impossible, the chip rounds. Most receivers tolerate up to about 3% baud error. A mismatched baud rate appears as unreadable characters that look like ASCII but are not.
12.3 Register map (the ones we actually use)¶
The UART has dozens of registers. We use six:
Register |
Offset |
Purpose |
|---|---|---|
|
|
Receive data (read) |
|
|
Transmit data (write) |
|
|
Control 1 (enable) |
|
|
Control 2 (TX/RX/8N1) |
|
|
Control 3 (various) |
|
|
Control 4 (DMA off, RX threshold) |
|
|
FIFO control + clock div |
|
|
Status 1 (TRDY = TX FIFO has room) |
|
|
Status 2 (TXDC = TX complete, RDR = RX data ready) |
|
|
Escape character (we ignore) |
|
|
Escape timer (we ignore) |
|
|
Baud numerator |
|
|
Baud denominator |
|
|
UART test register. The TX FIFO full bit lives here. |
The full list is RM Table 55-3. We will not visit most of them.
Three bits we will touch by name:
UCR1.UARTEN(bit 0), overall UART enable.UCR2.SRST(bit 0), software reset, active-low. Clear the bit to assert reset. Set it to release. (Yes, the polarity is unusual. That is what the RM says.)UCR2.TXEN | UCR2.RXEN(bits 1 and 2), TX and RX enables.USR1.TRDY(bit 13), TX FIFO has space for at least one byte.USR2.RDR(bit 0), receive data ready.
12.4 The driver, top to bottom¶
uart.h:
#ifndef UART_H
#define UART_H
#include <stdint.h>
void uart_init(void);
void uart_putc(char c);
void uart_puts(const char *s);
int uart_getc(void); /* -1 if no data */
#endif
uart.c:
#include "uart.h"
#define REG(addr) (*(volatile uint32_t *)(addr))
#define UART1_BASE 0x02020000
#define UART_URXD (UART1_BASE + 0x000)
#define UART_UTXD (UART1_BASE + 0x040)
#define UART_UCR1 (UART1_BASE + 0x080)
#define UART_UCR2 (UART1_BASE + 0x084)
#define UART_UCR3 (UART1_BASE + 0x088)
#define UART_UCR4 (UART1_BASE + 0x08C)
#define UART_UFCR (UART1_BASE + 0x090)
#define UART_USR1 (UART1_BASE + 0x094)
#define UART_USR2 (UART1_BASE + 0x098)
#define UART_UBIR (UART1_BASE + 0x0A4)
#define UART_UBMR (UART1_BASE + 0x0A8)
#define UART_UTS (UART1_BASE + 0x0B4)
#define CCM_CCGR5 0x020C407C
#define IOMUX_MUX_TX 0x020E0084
#define IOMUX_MUX_RX 0x020E0088
#define IOMUX_PAD_TX 0x020E0310
#define IOMUX_PAD_RX 0x020E0314
#define IOMUX_DAISY 0x020E0624
#define USR1_TRDY (1u << 13)
#define USR2_RDR (1u << 0)
void uart_init(void)
{
/* 1. Gate the clock to UART1. CG12 (bits 24-25) of CCGR5 = 0b11. */
REG(CCM_CCGR5) |= (3u << 24);
/* 2. Pinmux: ALT0 on both TX and RX pads. */
REG(IOMUX_MUX_TX) = 0;
REG(IOMUX_MUX_RX) = 0;
REG(IOMUX_PAD_TX) = 0x000010B0; /* Push-pull, 50 MHz, no pull */
REG(IOMUX_PAD_RX) = 0x000130B1; /* With keeper for stable idle level */
REG(IOMUX_DAISY) = 3; /* select UART1_RX_DATA input path */
/* 3. Soft-reset the UART (SRST is active-low: clear to assert). */
REG(UART_UCR2) = 0;
while (!(REG(UART_UCR2) & 1)) { /* wait for SRST high (release) */ }
/* 4. Disable while configuring. */
REG(UART_UCR1) = 0;
/* 5. No hardware flow control; 8N1; RX+TX enable; release SRST. */
REG(UART_UCR2) = (1u << 14) /* IRTS (ignore RTS) */
| (1u << 5) /* WS = 8 data bits */
| (1u << 2) /* TXEN */
| (1u << 1) /* RXEN */
| (1u << 0); /* SRST released */
/* 6. UCR3.RXDMUXSEL must be 1 for receive to work on the externally-muxed
path. This is documented in the errata. */
REG(UART_UCR3) |= (1u << 2);
/* 7. No DMA, no escape detection. */
REG(UART_UCR4) = (1u << 0); /* DREN: receive-ready interrupt enable bit. We do not use IRQ yet. */
/* 8. FIFO control: RX trigger = 1, TX trigger = 2, RFDIV = /1.
UFCR fields:
RXTL bits 5:0 RX FIFO trigger level
RFDIV bits 9:7 reference freq divider (0b101 = /1 on i.MX6ULL)
TXTL bits 15:10 TX FIFO trigger level
*/
REG(UART_UFCR) = (2u << 10) /* TXTL = 2 */
| (5u << 7) /* RFDIV = /1 */
| (1u << 0); /* RXTL = 1 */
/* 9. Baud: 115200 from f_uart = 80 MHz.
UBIR = 70, UBMR = 3082. (UBIR must be written before UBMR!) */
REG(UART_UBIR) = 70;
REG(UART_UBMR) = 3082;
/* 10. Enable UART. */
REG(UART_UCR1) = (1u << 0); /* UARTEN */
}
void uart_putc(char c)
{
while (!(REG(UART_USR1) & USR1_TRDY)) { /* spin until TX has room */ }
REG(UART_UTXD) = (uint8_t)c;
}
void uart_puts(const char *s)
{
while (*s) {
if (*s == '\n') uart_putc('\r');
uart_putc(*s++);
}
}
int uart_getc(void)
{
if (!(REG(UART_USR2) & USR2_RDR)) return -1;
return (int)(REG(UART_URXD) & 0xFF);
}
Read uart_init() carefully. Each line is in the RM, and each line costs someone an afternoon when it is skipped. A few specific points:
UBIR must be written before UBMR. The order matters. The controller’s internal divider is latched on the UBMR write. Reverse and you’ll get baud rates 6.8% off, which still looks like text but has occasional corruption.
The
\n→\r\ntranslation inuart_putsis here because simple terminals, andpicocomby default, expect CRLF endings to advance to a new line and return to column 0. Our bare-metal program must do this itself.UCR3.RXDMUXSEL = 1is required by the errata. Without it, RX appears dead.
12.5 A 200-line printf¶
You should use a third-party printf (mpaland/printf is excellent) in real projects. For the book we write our own, ~120 lines.
mini_printf.c:
#include <stdarg.h>
#include <stdint.h>
#include "uart.h"
static void emit_char(char c) { uart_putc(c); }
static void emit_str(const char *s) { while (*s) emit_char(*s++); }
static void emit_uint(unsigned long v, unsigned base, int width, char pad)
{
char buf[32];
const char *digits = "0123456789abcdef";
int i = 0;
if (v == 0) buf[i++] = '0';
while (v) { buf[i++] = digits[v % base]; v /= base; }
while (i < width) buf[i++] = pad;
while (i--) emit_char(buf[i]);
}
static void emit_int(long v, int width, char pad)
{
if (v < 0) { emit_char('-'); v = -v; if (width) width--; }
emit_uint((unsigned long)v, 10, width, pad);
}
int mini_vprintf(const char *fmt, va_list ap)
{
while (*fmt) {
if (*fmt != '%') { emit_char(*fmt++); continue; }
fmt++; /* skip '%' */
char pad = ' ';
int width = 0;
if (*fmt == '0') { pad = '0'; fmt++; }
while (*fmt >= '0' && *fmt <= '9') { width = width*10 + (*fmt - '0'); fmt++; }
switch (*fmt) {
case 'c': emit_char((char)va_arg(ap, int)); break;
case 's': emit_str(va_arg(ap, const char *)); break;
case 'd': emit_int (va_arg(ap, int), width, pad); break;
case 'u': emit_uint(va_arg(ap, unsigned), 10, width, pad); break;
case 'x': emit_uint(va_arg(ap, unsigned), 16, width, pad); break;
case 'p': emit_str("0x"); emit_uint((uintptr_t)va_arg(ap, void*), 16, 8, '0'); break;
case '%': emit_char('%'); break;
default: emit_char('%'); emit_char(*fmt); break;
}
if (*fmt) fmt++;
}
return 0;
}
int printf(const char *fmt, ...)
{
va_list ap;
va_start(ap, fmt);
int r = mini_vprintf(fmt, ap);
va_end(ap);
return r;
}
Features we support: %c %s %d %u %x %p %%. Width and 0-padding. Negative %d.
Features we do not support: %f (we have no floats in the kernel of this book), %lld, %ll, locales, precision (%.5s), left-justification (%-5d), %n. Cover them when you need them.
A note on va_arg(ap, unsigned): AAPCS promotes unsigned short and unsigned char to unsigned int when passing to a variadic function. So unsigned is the correct type. For unsigned long on 32-bit Linux/ARM it would be the same size. We keep it simple.
12.6 main() that actually says hello¶
#include "uart.h"
int printf(const char *fmt, ...);
int main(void)
{
uart_init();
printf("\r\nHello, i.MX6ULL bare-metal world!\r\n");
printf("CPU running at boot-default clock.\r\n");
printf("This text travels at 115200 baud.\r\n");
printf("printf supports %%d=%d %%u=%u %%x=0x%08x %%s=\"%s\" %%c=%c\r\n",
-42, 0xCAFE, 0xDEADBEEF, "rainbow", 'Z');
/* Echo loop so you can confirm RX works. */
printf("\r\nType characters. They will echo back.\r\n> ");
for (;;) {
int c = uart_getc();
if (c >= 0) uart_putc((char)c);
}
}
Keep the board’s integrated USB-TTL port connected. In one host terminal, open the serial device found in Chapter 8:
$ picocom -b 115200 /dev/ttyUSB0
If the bridge appeared as /dev/ttyACM0, use that path instead. This connection carries UART text. The separate USB-OTG connection carries SDP commands from uuu.
In another host terminal, build and load the image through the board’s USB-OTG port:
$ make
$ ~/imx6ull/scripts/mkimx.py led.bin led.imx --load 0x00907400 --entry 0x00908400
$ uuu -b sdp led.imx
In the terminal connected through the integrated USB-TTL bridge:
Hello, i.MX6ULL bare-metal world!
CPU running at boot-default clock.
This text travels at 115200 baud.
printf supports %d=-42 %u=51966 %x=0xdeadbeef %s="rainbow" %c=Z
Type characters. They will echo back.
> hello
The printed text confirms the UART1 TX path through the onboard bridge. The echo confirms the UART1 RX path through the same bridge.
12.7 Why polled UART, not interrupt-driven¶
We are deliberately using polling. Reasons:
No interrupt controller yet. The GIC will be set up properly in Chapter 15.
Polling is enough for
printf. Even at 115200 baud, transmitting one character takes 87 µs. Worst case we spin 87 µs per character. For diagnostic output that’s fine. In a high-throughput application it wouldn’t be.Polling shows the status bits directly. After you do it once, the interrupt version is the same hardware flow, but the FIFO threshold triggers an ISR.
We will write an interrupt-driven echo as a lab in Chapter 15.
12.8 Lab¶
Build, push via SDP, and observe
Hello, world. Use USB-OTG foruuuand the integrated USB-TTL port forpicocom. Type several characters and confirm that each one echoes.Measure the baud error. Insert a
forloop that emits'U'(0x55, the canonical alternating-bit-pattern character) 1 million times. Capture on a scope. Measure one bit period. Compute actual baud. Compare to 115200. Should be within 1%.Add
%btomini_printf, binary representation, for register dumps. Use it to dumpUCR1,UCR2,USR1,USR2at startup.Print system info. Read OCOTP_CFG0 and OCOTP_CFG1 (RM Chapter 37) and print the chip’s unique ID.
Stress test. Send 10 KB of text through the board’s USB-TTL serial device and confirm it is echoed. We do not use hardware flow control, so the host script must not send faster than the polled receiver can consume data.
12.9 Pitfalls¶
Wrong RFDIV in UFCR. Setting
RFDIV = 0divides by 6, not 1. Symptom: baud rate is six times too slow. The encoding is: 000=/6, 001=/5, 010=/4, 011=/3, 100=/2, 101=/1. Always0b101.Forgot to release SRST. Symptom: UART silent.
UCR2.SRST = 0means asserted. Set it to release.Wrong daisy-chain (SELECT_INPUT). Symptom: TX works through the onboard bridge, but typed characters do not echo.
UART1_RX_DATA_SELECT_INPUTmust select the pad physically connected to the bridge.Using the wrong USB connector. The USB-TTL port appears as
/dev/ttyUSBxor/dev/ttyACMxand carries console text. The USB-OTG port appears as the i.MX6ULL SDP device and is used byuuu.CRLF vs LF.
picocomdefaults to translating LF to CRLF on receive. Newer terminals don’t. If your output is “stairstepped,” your\nis not being followed by\r. Ouruart_putshandles it.printfwithfloats. Compiles, runs, and prints wrong output because we never wrote%f. Do not pass floats to thisprintf.UBIR after UBMR. Discussed in §12.4. Write UBIR first.
Forgot the CCGR. If the UART is silent, check the clock gate before debugging the UART registers.
12.10 Going deeper¶
IMX6ULLRM Chapter 55: UART. Read once cover-to-cover. You’ll come back.
AN3956: Configuring the i.MX UART Module. Concise. Useful.
mpaland/printfat<https://github.com/mpaland/printf>, a production-quality tiny printf, MIT-licensed.The 16550 UART datasheet: every embedded engineer should read this once. It’s the platonic UART.
Linux source:
drivers/tty/serial/imx.c: the same hardware, the same registers, vastly more sophisticated driver. Read it after Chapter 12 here. You’ll recognize every bit.
Next chapter: Chapter 13: CCM clock tree bring-up. So far we’ve been running on whatever clock the ROM left us. Time to take ownership. CCM: Clock Controller Module. It selects clock sources, dividers, and gates for the SoC.