diff --git a/Cargo.lock b/Cargo.lock
index 964ef8ee15f0cdb740738ce0c2e076dc20a12b62..bd83b795d85525eb755fe217fd6f4d27a3e89386 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -524,6 +524,30 @@ dependencies = [
  "stable_deref_trait",
 ]
 
+[[package]]
+name = "i2c-bootloader"
+version = "0.1.0"
+dependencies = [
+ "cortex-m",
+ "cortex-m-rt",
+ "defmt",
+ "defmt-rtt",
+ "embassy-executor",
+ "embassy-stm32",
+ "embassy-time",
+ "i2c2-slave",
+ "panic-probe",
+]
+
+[[package]]
+name = "i2c2-slave"
+version = "0.1.0"
+dependencies = [
+ "defmt",
+ "embassy-stm32",
+ "embassy-sync",
+]
+
 [[package]]
 name = "ident_case"
 version = "1.0.1"
diff --git a/Cargo.toml b/Cargo.toml
index 4be5d623afeb40e27fb1aeba74167bf8d0d522ec..dd1733205d895c1a51ddb2d722218f6253b91f5e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,5 @@
 [workspace]
-members = ["controller"]
+members = ["controller", "i2c-bootloader", "i2c2-slave"]
 resolver = "2"
 
 [profile.release]
diff --git a/i2c-bootloader/Cargo.toml b/i2c-bootloader/Cargo.toml
new file mode 100644
index 0000000000000000000000000000000000000000..17ea1d7b7778cfa2f61620f83010e76863654166
--- /dev/null
+++ b/i2c-bootloader/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "i2c-bootloader"
+version = "0.1.0"
+authors = ["Samuel Tardieu <sam@rfc1149.net>"]
+edition = "2021"
+
+[dependencies]
+cortex-m = { version = "0.7.7", features = ["critical-section-single-core", "inline-asm"] }
+cortex-m-rt = "0.7.3"
+defmt = "0.3.5"
+defmt-rtt = "0.4.0"
+embassy-executor = { git = "https://github.com/embassy-rs/embassy", features = ["arch-cortex-m", "executor-thread", "defmt", "integrated-timers"] }
+embassy-stm32 = { git = "https://github.com/embassy-rs/embassy", features = ["defmt", "stm32f103c8", "unstable-pac", "time-driver-tim4", "memory-x"] }
+embassy-time = { git = "https://github.com/embassy-rs/embassy", features = ["defmt", "defmt-timestamp-uptime", "tick-hz-32_768"] }
+i2c2-slave = { path = "../i2c2-slave" }
+panic-probe = { version = "0.3.1", features = ["print-defmt"] }
diff --git a/i2c-bootloader/src/main.rs b/i2c-bootloader/src/main.rs
new file mode 100644
index 0000000000000000000000000000000000000000..b209a13597152f898b4081f0d9fef87c62169ae6
--- /dev/null
+++ b/i2c-bootloader/src/main.rs
@@ -0,0 +1,62 @@
+#![no_std]
+#![no_main]
+
+use defmt_rtt as _;
+use embassy_executor::Spawner;
+use embassy_stm32::{
+    gpio::{Level, Output, Speed},
+    interrupt::Priority,
+    peripherals::PB15,
+    time::mhz,
+    Config,
+};
+use embassy_time::Timer;
+use panic_probe as _;
+
+#[embassy_executor::main]
+async fn main(spawner: Spawner) {
+    let mut config = Config::default();
+    config.rcc.hse = Some(mhz(8));
+    config.rcc.sys_ck = Some(mhz(72));
+    config.rcc.pclk1 = Some(mhz(36));
+    let p = embassy_stm32::init(config);
+
+    defmt::info!("I²C bootloader starting");
+
+    spawner.spawn(blink(p.PB15)).unwrap();
+
+    let mut i2c2 = i2c2_slave::I2C2::new(p.I2C2, p.PB10, p.PB11, 0x57, Priority::P3);
+    loop {
+        let mut buffer = [0; 8];
+        match i2c2.receive(&mut buffer).await {
+            Ok(n) => {
+                defmt::info!("Received command {:#x}, reversing", &buffer[..n]);
+                buffer[..n].reverse();
+                i2c2.send(&buffer[..n]).unwrap();
+            }
+            Err(i2c2_slave::Error::ExtraBytes(n)) => {
+                defmt::info!(
+                    "Received {} extra bytes after command {:#x}, sending 0xDE 0xAD",
+                    n,
+                    buffer
+                );
+                i2c2.send(&[0xDE, 0xAD]).unwrap();
+            }
+            Err(e) => {
+                defmt::info!("Received another error: {:?}", defmt::Debug2Format(&e));
+            }
+        }
+    }
+}
+
+#[embassy_executor::task]
+async fn blink(pin: PB15) {
+    let mut led = Output::new(pin, Level::High, Speed::Low);
+    loop {
+        for _ in 0..4 {
+            led.toggle();
+            Timer::after_millis(100).await;
+        }
+        Timer::after_secs(1).await;
+    }
+}
diff --git a/i2c2-slave/Cargo.toml b/i2c2-slave/Cargo.toml
new file mode 100644
index 0000000000000000000000000000000000000000..336c27dc1d9e15b9c353e13998cf5747cb543297
--- /dev/null
+++ b/i2c2-slave/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "i2c2-slave"
+version = "0.1.0"
+authors = ["Samuel Tardieu <sam@rfc1149.net>"]
+edition = "2021"
+
+[dependencies]
+defmt = "0.3.5"
+embassy-stm32 = { git = "https://github.com/embassy-rs/embassy", features = ["defmt", "stm32f103c8", "unstable-pac", "time-driver-tim4"] }
+embassy-sync = { git = "https://github.com/embassy-rs/embassy", features = ["defmt"] }
diff --git a/i2c2-slave/src/lib.rs b/i2c2-slave/src/lib.rs
new file mode 100644
index 0000000000000000000000000000000000000000..6b51869b3f2079ea97aca51ac80a2f96f72df5b3
--- /dev/null
+++ b/i2c2-slave/src/lib.rs
@@ -0,0 +1,286 @@
+#![no_std]
+
+use embassy_stm32::{
+    gpio::low_level::{AFType, Pin},
+    i2c::{self, SclPin},
+    interrupt,
+    interrupt::{InterruptExt, Priority},
+    pac,
+    peripherals::{self, PB10, PB11},
+};
+use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, channel::Channel};
+
+enum I2cRxEvent {
+    Byte(u8),
+    Stop,
+}
+
+static RX_QUEUE: Channel<CriticalSectionRawMutex, I2cRxEvent, 9> = Channel::new();
+static TX_QUEUE: Channel<CriticalSectionRawMutex, u8, 8> = Channel::new();
+static mut STATE: State = State::Idle { after_write: false };
+
+#[derive(Debug)]
+pub enum Error {
+    ExtraBytes(usize),
+    FifoFull,
+}
+
+pub type Result<T, E = Error> = core::result::Result<T, E>;
+
+#[derive(Clone, Copy, Debug)]
+enum State {
+    Idle { after_write: bool },
+    ReadInProgress { waiting_for_data: bool },
+    WriteInProgress,
+}
+
+impl State {
+    fn get_state() -> Self {
+        unsafe { STATE }
+    }
+
+    fn set_state(state: Self) {
+        defmt::trace!("Setting new state to {:?}", defmt::Debug2Format(&state));
+        unsafe { STATE = state }
+    }
+
+    fn is_write(&self) -> bool {
+        matches!(
+            self,
+            Self::Idle { after_write: true } | Self::WriteInProgress
+        )
+    }
+}
+
+pub struct I2C2 {
+    _pb10: PB10,
+    _pb11: PB11,
+    _i2c2: peripherals::I2C2,
+}
+
+impl I2C2 {
+    #[must_use]
+    pub fn new(i2c2: peripherals::I2C2, pb10: PB10, pb11: PB11, addr: u16, prio: Priority) -> Self {
+        // Set pins in AF mode
+        pb10.set_as_af(SclPin::af_num(&pb10), AFType::OutputOpenDrain);
+        pb11.set_as_af(i2c::SdaPin::af_num(&pb11), AFType::OutputOpenDrain);
+        // Turn I2C2 clock on
+        pac::RCC.apb1enr().modify(|w| w.set_i2c2en(true));
+        // Reset I2C2 peripheral
+        pac::RCC.apb1rstr().modify(|w| w.set_i2c2rst(true));
+        pac::RCC.apb1rstr().modify(|w| w.set_i2c2rst(false));
+        pac::I2C2.cr1().write(|_| ());
+        // Configure address
+        pac::I2C2.oar1().write(|w| w.set_add(addr << 1));
+        // Enable event interrupt for I2C2 and 2MHz frequency
+        pac::I2C2.cr2().write(|w| {
+            w.set_itevten(true);
+            w.set_iterren(true);
+            w.set_freq(36);
+        });
+        // Enable I2C2
+        pac::I2C2.cr1().write(|w| {
+            w.set_pe(true);
+        });
+        // Set ack generation
+        pac::I2C2.cr1().modify(|w| {
+            w.set_ack(true);
+        });
+        interrupt::I2C2_EV.set_priority(prio);
+        interrupt::I2C2_ER.set_priority(prio);
+        unsafe {
+            interrupt::I2C2_EV.enable();
+            interrupt::I2C2_ER.enable();
+        }
+        Self {
+            _pb10: pb10,
+            _pb11: pb11,
+            _i2c2: i2c2,
+        }
+    }
+
+    pub async fn receive(&mut self, buffer: &mut [u8]) -> Result<usize> {
+        for (i, b) in buffer.iter_mut().enumerate() {
+            match RX_QUEUE.receive().await {
+                I2cRxEvent::Byte(v) => *b = v,
+                I2cRxEvent::Stop => return Ok(i),
+            }
+        }
+        for extra in 0..usize::MAX {
+            if matches!(RX_QUEUE.receive().await, I2cRxEvent::Stop) {
+                return Err(Error::ExtraBytes(extra));
+            }
+        }
+        unreachable!();
+    }
+
+    pub fn send(&mut self, data: &[u8]) -> Result<()> {
+        defmt::trace!("Enqueuing {:?}", data);
+        // Enqueue data to send next
+        for &b in data {
+            if TX_QUEUE.try_send(b).is_err() {
+                return Err(Error::FifoFull);
+            }
+        }
+        // If the controller is already waiting for data (and
+        // stretching the clock down), send it right away by
+        // trigerring an interrupt.
+        if matches!(
+            State::get_state(),
+            State::ReadInProgress {
+                waiting_for_data: true
+            }
+        ) {
+            pac::I2C2.cr2().modify(|w| {
+                w.set_itbufen(true);
+            });
+        }
+        Ok(())
+    }
+
+    fn empty_tx_queue() {
+        while TX_QUEUE.try_receive().is_ok() {}
+    }
+}
+
+#[interrupt]
+unsafe fn I2C2_EV() {
+    let sr1 = pac::I2C2.sr1().read();
+    defmt::info!(
+        "I2C2_EV, sr1 = {:#06x}, cr1 = {:#06x}, cr2 = {:#06x}",
+        sr1.0,
+        pac::I2C2.cr1().read().0,
+        pac::I2C2.cr2().read().0
+    );
+    if sr1.addr() {
+        defmt::trace!("I2C2_EV addr");
+        // Addr event is cleared by reading sr2
+        let sr2 = pac::I2C2.sr2().read();
+        // If a write operation was in progress, send a simulated Stop to
+        // the RX queue.
+        let previous_state = State::get_state();
+        if matches!(previous_state, State::WriteInProgress)
+            && RX_QUEUE.try_send(I2cRxEvent::Stop).is_err()
+        {
+            defmt::error!("Cannot send STOP event due to repeated start to the receive queue");
+        }
+        let new_state = if sr2.tra() {
+            let outgoing = if !previous_state.is_write() {
+                // Another read transaction happened just before this one,
+                // send 0 to avoid locking the bus.
+                defmt::trace!("Read transaction without write, sending 0 to prevent bus locking");
+                Some(0)
+            } else if let Ok(b) = TX_QUEUE.try_receive() {
+                // If bytes are ready to send already, send the first
+                // one and enable the buffer events to receive the
+                // next TXE.
+                defmt::trace!("Sending already ready byte {:#04x}", b);
+                Some(b)
+            } else {
+                // Otherwise, indicate that data is waiting.
+                defmt::trace!("No data ready, stretching clock");
+                None
+            };
+            outgoing
+                .clone()
+                .map(|b| pac::I2C2.dr().write(|w| w.set_dr(b)));
+            State::ReadInProgress {
+                waiting_for_data: outgoing.is_none(),
+            }
+        } else {
+            // In case of a write request, fill the transmit buffer
+            // and enable the buffer events to receive only the RXNE
+            // events, and not the TXE ones. Also, empty the receive
+            // buffer in case it already contains something. The
+            // outgoing buffer will later be cleared when the stop
+            // event is received.
+            pac::I2C2.dr().modify(|_| ());
+            pac::I2C2.cr2().modify(|w| {
+                w.set_itbufen(true);
+            });
+            defmt::trace!("Current state of CR1: {:#06x}", pac::I2C2.cr1().read().0);
+            defmt::trace!("Current state of CR2: {:#06x}", pac::I2C2.cr2().read().0);
+            // Empty a previously filled TX queue in case a read request
+            // arrives right after.
+            I2C2::empty_tx_queue();
+            defmt::trace!("Waiting for RXNE or STOPF event");
+            State::WriteInProgress
+        };
+        State::set_state(new_state);
+        pac::I2C2.cr2().modify(|w| {
+            w.set_itbufen(!matches!(
+                new_state,
+                State::ReadInProgress {
+                    waiting_for_data: true
+                }
+            ))
+        });
+    } else if sr1.rxne() {
+        defmt::trace!("I2C2_EV RXNE");
+        let b = pac::I2C2.dr().read().0 as u8;
+        defmt::trace!("Received byte {:#04x}", &b);
+        if RX_QUEUE.try_send(I2cRxEvent::Byte(b)).is_err() {
+            defmt::error!("I2C byte has overfilled the buffer");
+        }
+    } else if sr1.txe() {
+        defmt::trace!("I2C2_EV TXE");
+        if let Ok(b) = TX_QUEUE.try_receive() {
+            defmt::trace!("Sending byte {:#04x}", b);
+            pac::I2C2.dr().write(|w| w.set_dr(b));
+        } else {
+            defmt::trace!("Nothing to send, storing 0 in DR to bus locking");
+            pac::I2C2.dr().write(|w| w.set_dr(0));
+        }
+    } else if sr1.stopf() {
+        defmt::trace!(
+            "I2C2_EV STOPF while state is {:?}",
+            defmt::Debug2Format(&State::get_state())
+        );
+        // Clear stopf by writing to cr1
+        pac::I2C2.cr1().modify(|_w| ());
+        // Opt out of the TXE/RXNE interrupt
+        pac::I2C2.cr2().modify(|w| {
+            w.set_itbufen(false);
+        });
+        // Do a clearing sequence as recommended in the reference manual
+        // by reading and writing dr
+        pac::I2C2.dr().modify(|_w| ());
+        // If receiving, indicate the end of data
+        let previous_state = State::get_state();
+        if matches!(previous_state, State::WriteInProgress) {
+            if RX_QUEUE.try_send(I2cRxEvent::Stop).is_err() {
+                defmt::error!("Unable to send stop event to the buffer");
+            }
+            State::set_state(State::Idle { after_write: true });
+        } else {
+            State::set_state(State::Idle { after_write: false });
+        }
+    } else {
+        defmt::error!("I2C2_EV interrupt without cause");
+    }
+}
+
+#[interrupt]
+unsafe fn I2C2_ER() {
+    let sr1 = pac::I2C2.sr1().read();
+    if sr1.af() {
+        defmt::trace!("I2C2_ER NACKF");
+        pac::I2C2.sr1().modify(|w| w.set_af(false));
+        // Nack are only used when transmitting, opt out of the RXNE interrupt
+        pac::I2C2.cr2().modify(|w| {
+            w.set_itbufen(false);
+        });
+        // Flush unsend bytes
+        while let Ok(b) = TX_QUEUE.try_receive() {
+            defmt::trace!("Discarding {:#04x}", b);
+        }
+        State::set_state(State::Idle { after_write: false });
+    } else {
+        defmt::error!(
+            "I2C2_ER sr1={:#06x} sr2={:#06x}",
+            sr1.0,
+            pac::I2C2.sr2().read().0
+        );
+        panic!();
+    }
+}