diff --git a/hw/char/esp32_c3_uart.c b/hw/char/esp32_c3_uart.c
index 4a8e0b163a42d309ba4a97ee20f8f19419bfbd4b..5689bb3e42774cf9bfb472e92cf7824df3fb768b 100644
--- a/hw/char/esp32_c3_uart.c
+++ b/hw/char/esp32_c3_uart.c
@@ -24,68 +24,158 @@
 #include "hw/qdev-properties-system.h"
 #include "migration/vmstate.h"
 #include "trace.h"
+#include "trace/trace-hw_char.h"
+
+static void uart_rx_fifo_push(ESP32C3UARTState* s, uint8_t byte)
+{
+    s->rx_fifo[s->rx_fifo_head] = byte;
+    s->rx_fifo_head++;
+
+    if (s->rx_fifo_head >= ESP32_C3_UART_FIFO_LENGTH) {
+        s->rx_fifo_head = 0;
+    }
+
+    /* Increment number of bytes in RX FIFO tracked by control register */
+    uint32_t bytes = s->regs[R_UART_STATUS] & R_UART_STATUS_UART_RXFIFO_CNT_MASK;
+    bytes += 1;
+    s->regs[R_UART_STATUS] |= bytes;
+}
+
+static void uart_tx_fifo_push(ESP32C3UARTState* s, uint8_t byte)
+{
+    s->tx_fifo[s->tx_fifo_head] = byte;
+    s->tx_fifo_head++;
+
+    if (s->tx_fifo_head >= ESP32_C3_UART_FIFO_LENGTH) {
+        s->tx_fifo_head = 0;
+    }
+
+    /* Increment number of bytes in TX FIFO tracked by control register */
+    uint32_t bytes = s->regs[R_UART_STATUS] & R_UART_STATUS_UART_TXFIFO_CNT_MASK;
+    bytes += 1;
+    s->regs[R_UART_STATUS] |= bytes;
+}
+
+static bool uart_rx_fifo_pull(ESP32C3UARTState* s, uint8_t *out)
+{
+    if (s->rx_fifo_tail > s->rx_fifo_head) {
+        trace_esp32_c3_uart_rx_fifo_underrun();
+        return FALSE;
+    }
+
+    *out = s->rx_fifo[s->rx_fifo_tail];
+
+    s->rx_fifo_tail++;
+
+    if (s->rx_fifo_tail >= ESP32_C3_UART_FIFO_LENGTH) {
+        s->rx_fifo_tail = 0;
+    }
+
+    /* Decrement number of bytes in RX FIFO tracked by control register */
+    uint32_t bytes = s->regs[R_UART_STATUS] & R_UART_STATUS_UART_RXFIFO_CNT_MASK;
+    bytes -= 1;
+    s->regs[R_UART_STATUS] |= bytes;
+
+    return TRUE;
+}
+
+static bool uart_tx_fifo_pull(ESP32C3UARTState* s, uint8_t *out)
+{
+    if (s->tx_fifo_tail > s->tx_fifo_head) {
+        trace_esp32_c3_uart_tx_fifo_underrun();
+        return FALSE;
+    }
+
+    *out = s->tx_fifo[s->tx_fifo_tail];
+
+    s->tx_fifo_tail++;
+
+    if (s->tx_fifo_tail >= ESP32_C3_UART_FIFO_LENGTH) {
+        s->tx_fifo_tail = 0;
+    }
+
+    /* Decrement number of bytes in TX FIFO tracked by control register */
+    uint32_t bytes = s->regs[R_UART_STATUS] & R_UART_STATUS_UART_TXFIFO_CNT_MASK;
+    bytes -= 1;
+    s->regs[R_UART_STATUS] |= bytes;
+
+    return TRUE;
+}
 
 static uint64_t uart_read(void *opaque, hwaddr addr, unsigned int size)
 {
-    /* Note that this is for reading I/O memory, not FIFO */
-    /*
+    /* Note that this is for reading I/O memory, not FIFO memory */
     ESP32C3UARTState *s = ESP32_C3_UART(opaque);
-
     uint64_t r;
-    TODO: Check if enabled
-    if (!s->rx_enabled) {
-        return 0;
-    }
+    uint8_t byte;
 
     switch (addr) {
+    case A_UART_FIFO:
+        if (!uart_rx_fifo_pull(s, &byte)) {
+            r = 0;
+        } else {
+            r = byte;
+        }
+        break;
     default:
         r = s->regs[addr / 4];
         break;
     }
 
-    trace_esp32_c3_uart_read(addr, r, size);
 
+    trace_esp32_c3_uart_read(addr, r, size);
     return r;
-    */
-    return 0;
 }
 
 static gboolean uart_transmit(void *do_not_use, GIOCondition cond, void *opaque)
 {
-    /*
     ESP32C3UARTState *s = ESP32_C3_UART(opaque);
     int r;
-    if (s->tx_fifo_pos == 0) {
+
+    if (s->tx_fifo_head == 0) {
+        /* Don't have any data */
         return FALSE;
     }
-    */
+
     /* TODO: Bounds check / wraparound */
-    /*
-    uint8_t c = s->tx_fifo[s->tx_fifo_pos];
-    s->tx_fifo_pos++;
+    uint8_t c;
+    if (!uart_tx_fifo_pull(s, &c)) {
+        /* FIFO underrun */
+        return FALSE;
+    }
 
     r = qemu_chr_fe_write(&s->chr, &c, 1);
     return TRUE;
+
+
+    /* TODO: Raise interrupt */
+
+    /* TODO: Figure out what this watch_tag thing is about */
+
     if (r <= 0) {
+        /*
         s->watch_tag = qemu_chr_fe_add_watch(&s->chr, G_IO_OUT | G_IO_HUP,
                                              uart_transmit, s);
         if (!s->watch_tag) {
             goto buffer_drained;
         }
+        */
         return FALSE;
     }
 
+/*
 buffer_drained:
     s->reg[R_UART_TXDRDY] = 1;
     s->pending_tx_byte = false;
     return FALSE;
-    */
     return FALSE;
+*/
 
 }
 
 static void uart_cancel_transmit(ESP32C3UARTState *s)
 {
+    /* TODO: Figure out what this watch_tag business is about */
     /*
     if (s->watch_tag) {
         g_source_remove(s->watch_tag);
@@ -97,30 +187,29 @@ static void uart_cancel_transmit(ESP32C3UARTState *s)
 static void uart_write(void *opaque, hwaddr addr,
                        uint64_t value, unsigned int size)
 {
+    uint8_t byte;
     ESP32C3UARTState *s = ESP32_C3_UART(opaque);
 
     trace_esp32_c3_uart_write(addr, value, size);
 
-    /* TODO: Check if enabled */
-
     switch (addr) {
     case A_UART_FIFO:
-        if (s->tx_fifo_pos < ESP32_C3_UART_FIFO_LENGTH) {
-            s->tx_fifo[s->tx_fifo_pos] = value;
-            s->tx_fifo_pos++;
-            s->pending_tx = true;
-        } else {
-            /* TODO: Log */
-            return;
-        }
+        /*
+         * FIXME:
+         * The hardware doesn't allow writing this register.
+         * We only allow it here until sending is implemented properly.
+        */
+        byte = (value & R_UART_FIFO_UART_RXFIFO_RD_BYTE_MASK) << R_UART_FIFO_UART_RXFIFO_RD_BYTE_SHIFT;
+        uart_tx_fifo_push(s, byte);
         uart_transmit(NULL, G_IO_OUT, s);
         break;
     default:
-        /* TODO: Log */
+        qemu_log_mask(LOG_UNIMP, "Write to unimplemented UART control register may not have any effect\n");
         s->regs[addr / 4] = value;
         break;
     }
-    /* TODO: Update IRQ */
+
+    /* TODO: Update IRQ (once interrupt controller is implemented) */
 }
 
 static const MemoryRegionOps uart_ops = {
@@ -140,41 +229,38 @@ static void esp32_c3_uart_reset(DeviceState *dev)
     memset(s->rx_fifo, 0, sizeof(s->rx_fifo));
     memset(s->tx_fifo, 0, sizeof(s->tx_fifo));
 
-    s->rx_fifo_pos = 0;
-    s->tx_fifo_pos = 0;
+    s->rx_fifo_head = 0;
+    s->rx_fifo_tail = 0;
+    s->tx_fifo_head = 0;
+    s->tx_fifo_tail = 0;
     s->pending_tx = false;
+    s->rx_enabled = false;
+    s->tx_enabled = false;
 }
 
 static void uart_receive(void *opaque, const uint8_t *buf, int size)
 {
-
-    ESP32C3UARTState *s = ESP32_C3_UART(opaque);
     int i;
-
-    if (size == 0 || s->rx_fifo_pos >= ESP32_C3_UART_FIFO_LENGTH) {
-        // TODO: Log
-        return;
-    }
+    ESP32C3UARTState *s = ESP32_C3_UART(opaque);
 
     for (i = 0; i < size; i++) {
-        uint32_t pos = s->rx_fifo_pos % ESP32_C3_UART_FIFO_LENGTH;
-        s->rx_fifo[pos] = buf[i];
-        s->rx_fifo_pos++;
+        uart_rx_fifo_push(s, buf[i]);
     }
 
-    // TODO: Signal RX ready
+    /* TODO: Raise interrupt */
+
 }
 
 static int uart_can_receive(void *opaque)
 {
     ESP32C3UARTState *s = ESP32_C3_UART(opaque);
 
-    /* Does FIFO have space? */
-    // TODO: Check whether receiver is enabled
-    if (s->rx_fifo_pos < ESP32_C3_UART_FIFO_LENGTH) {
-        return ESP32_C3_UART_FIFO_LENGTH - s->rx_fifo_pos;
+    /* Is receiving enabled? */
+    if (!s->rx_enabled) {
+        return 0;
     }
-    return 0;
+
+    return 1;
 }
 
 static void uart_event(void *opaque, QEMUChrEvent event)
@@ -208,9 +294,9 @@ static void esp32_c3_uart_init(Object *obj)
 
 static int esp32_c3_uart_post_load(void *opaque, int version_id)
 {
-    // ESP32C3UARTState *s = ESP32_C3_UART(opaque);
-
+    /* TODO: Send pending bytes */
     /*
+    ESP32C3UARTState *s = ESP32_C3_UART(opaque);
 
     if (s->pending_tx_byte) {
         s->watch_tag = qemu_chr_fe_add_watch(&s->chr, G_IO_OUT | G_IO_HUP,
@@ -229,8 +315,13 @@ static const VMStateDescription esp32_c3_uart_vmstate = {
         VMSTATE_UINT32_ARRAY(regs, ESP32C3UARTState, ESP32_C3_UART_IOMEM_SIZE_WORDS),
         VMSTATE_UINT8_ARRAY(rx_fifo, ESP32C3UARTState, ESP32_C3_UART_FIFO_LENGTH),
         VMSTATE_UINT8_ARRAY(tx_fifo, ESP32C3UARTState, ESP32_C3_UART_FIFO_LENGTH),
-        VMSTATE_UINT32(rx_fifo_pos, ESP32C3UARTState),
-        VMSTATE_UINT32(tx_fifo_pos, ESP32C3UARTState),
+        VMSTATE_UINT32(rx_fifo_head, ESP32C3UARTState),
+        VMSTATE_UINT32(rx_fifo_tail, ESP32C3UARTState),
+        VMSTATE_UINT32(tx_fifo_head, ESP32C3UARTState),
+        VMSTATE_UINT32(tx_fifo_tail, ESP32C3UARTState),
+        VMSTATE_BOOL(rx_enabled, ESP32C3UARTState),
+        VMSTATE_BOOL(tx_enabled, ESP32C3UARTState),
+        VMSTATE_BOOL(pending_tx, ESP32C3UARTState),
         VMSTATE_END_OF_LIST()
     }
 };
diff --git a/hw/char/trace-events b/hw/char/trace-events
index 049b5f0e7fe8b47b03df04fae0f97a0f553899ed..3da84626d4742ba15af5b496e94dfd5a9894c7e6 100644
--- a/hw/char/trace-events
+++ b/hw/char/trace-events
@@ -109,3 +109,10 @@ sh_serial_write(char *id, unsigned size, uint64_t offs, uint64_t val) "%s size %
 # esp32_c3_uart.c
 esp32_c3_uart_read(uint64_t addr, uint64_t r, unsigned int size) "addr 0x%" PRIx64 " value 0x%" PRIx64 " size %u"
 esp32_c3_uart_write(uint64_t addr, uint64_t value, unsigned int size) "addr 0x%" PRIx64 " value 0x%" PRIx64 " size %u"
+esp32_c3_uart_tx(void) "UART TX"
+esp32_c3_uart_rx(void) "UART RX"
+esp32_c3_uart_rx_fifo_read(void) "UART RX FIFO read"
+esp32_c3_uart_rx_fifo_underrun(void) "UART RX FIFO underrun, no data"
+esp32_c3_uart_tx_fifo_underrun(void) "UART TX FIFO underrun, no data"
+esp32_c3_uart_read_disabled(void) "UART read despite RX being disabled"
+esp32_c3_uart_write_disabled(void) "UART write despite TX being disabled"
diff --git a/include/hw/char/esp32_c3_uart.h b/include/hw/char/esp32_c3_uart.h
index b06769f2bba84a363918396ec1ac5c9d6bf9e4ac..3b5a48db14199bbe9b4bf5fd62ddfbb370db49c7 100644
--- a/include/hw/char/esp32_c3_uart.h
+++ b/include/hw/char/esp32_c3_uart.h
@@ -37,9 +37,13 @@
 
 /* These are all relative to the base address */
 
-REG32(UART_FIFO, 0x0000) /* FIFO read / write operations */
+REG32(UART_FIFO, 0x0000) /* FIFO read via this register */
+    FIELD(UART_FIFO, UART_RXFIFO_RD_BYTE, 0, 8);
 REG32(UART_CLKDIV, 0x0014)
 REG32(UART_RX_FILT, 0x0018)
+REG32(UART_STATUS, 0x001C)
+    FIELD(UART_STATUS, UART_RXFIFO_CNT, 0, 10)
+    FIELD(UART_STATUS, UART_TXFIFO_CNT, 16, 10)
 REG32(UART_CONF0, 0x0020)
     FIELD(UART_CONF0, PARITY, 0, 1)         /* Parity config */
     FIELD(UART_CONF0, PARITY_EN, 1, 1)      /* Parity enable */
@@ -55,12 +59,16 @@ typedef struct {
     MemoryRegion iomem; /* Config registers memory region */
     uint32_t regs[ESP32_C3_UART_IOMEM_SIZE_WORDS]; /* Actual contents of config registers */
 
-    MemoryRegion fifo;  /* FIFO RAM memory region */
+    MemoryRegion fifo;  /* UART FIFO RAM memory region */
     uint8_t rx_fifo[ESP32_C3_UART_FIFO_LENGTH]; /* RX FIFO RAM */
     uint8_t tx_fifo[ESP32_C3_UART_FIFO_LENGTH]; /* TX FIFO RAM */
 
-    unsigned int rx_fifo_pos; /* How many bytes are waiting in RX FIFO */
-    unsigned int tx_fifo_pos; /* How many bytes are waiting in TX FIFO */
+    unsigned int rx_fifo_head; /* Head of RX FIFO (bytes are appended here) */
+    unsigned int rx_fifo_tail; /* Tail of RX FIFO (bytes are read from here) */
+    unsigned int tx_fifo_head; /* Head of TX FIFO (bytes are appended here) */
+    unsigned int tx_fifo_tail; /* Tail of TX FIFO (bytes are read from here) */
+    bool rx_enabled;
+    bool tx_enabled;
     bool pending_tx;
 } ESP32C3UARTState;