STM32F4 Embedded Rust на уровне HAL: I2C-датчики температуры и давления с BMP180


Эта запись в блоге является седьмой из серии постов, в которых я исследую различные периферийные устройства микроконтроллера STM32F401RE, используя встроенный Rust на уровне HAL. Пожалуйста, имейте в виду, что некоторые концепции в новых постах могут зависеть от концепций в предыдущих постах.

Если вам понравился этот материал, пожалуйста, не забудьте подписаться на рассылку новостей здесь, чтобы быть в курсе новых статей блога.

Введение

В этом посте я буду конфигурировать и настраивать периферийное устройство I2C stm32f4xx-hal для сбора данных измерения температуры окружающей среды с цифрового датчика давления BMP180. Обратите внимание, что BMP180 предоставляет данные как о температуре, так и о давлении. Измерение температуры будет непрерывно собираться и отправляться на терминал ПК по UART. Я также буду использовать приложение/код последовательной связи UART из предыдущего сообщения. Кроме того, я не буду использовать никаких прерываний.

🚨 Важное замечание:

BMP180 предоставляет данные с датчиков температуры и давления, которые собираются очень похожим образом. Я решил собирать только данные о температуре, так как уравнения преобразования меньше. Моя цель в этой статье — сосредоточиться на работе с I2C, а не на особенностях самого BMP180. Однако предоставленный код может быть легко расширен для сбора и преобразования данных о давлении.

Предварительные знания

Чтобы понять содержание этой статьи, вам необходимо следующее:

  • Базовые знания кодирования на языке Rust.
  • Знакомство с базовым шаблоном для создания встроенных приложений в Rust.
  • Знакомство с основами коммуникации I2C.
  • Знакомство с основами коммуникации UART.

Настройка программного обеспечения

Весь код, представленный в этом посте, а также инструкции по настройке среды и инструментария доступны в git-репо apollolabsdev Nucleo-F401RE. Обратите внимание, что если код в git-репозитории немного отличается, это означает, что он был изменен для улучшения качества кода или с учетом обновлений HAL/Rust.

В дополнение к вышесказанному, вам необходимо установить какой-либо терминал последовательной связи на вашем компьютере. Некоторые рекомендации включают:

Для Windows:

  • PuTTy
  • Teraterm

Для Mac и Linux:

  • minicom

Некоторые инструкции по установке для различных операционных систем можно найти в Discovery Book.

Установка оборудования

Материалы

  • Плата Nucleo-F401RE

  • Датчик барометрического давления и температуры BMP 180 GY-68

Подключения

  • Вывод SCL модуля BMP180 подключен к выводу PB8 платы Nucleo.
  • Вывод SDA модуля BMP180 подключен к выводу PB9 платы Nucleo.
  • Вывод Vcc модуля BMP180 подключен к выводу 3,3 В платы Nucleo.
  • Контакт GND модуля BMP180 подключен к GND платы Nucleo.
  • Линия UART Tx, которая подключается к ПК через встроенный USB-мост, проходит через контакт PA2 микроконтроллера. Это жестко подключенный вывод, то есть вы не можете использовать какой-либо другой для этой установки. Если вы не используете другую плату, отличную от Nucleo-F401RE, то для определения номера вывода необходимо обратиться к соответствующей документации (справочному руководству или техническому описанию).

Дизайн программного обеспечения

К счастью, дизайн программного обеспечения для этого приложения можно получить из технического описания BMP180. Если копнуть глубже, то в техническом описании BMP180 содержится полная блок-схема, описывающая алгоритмические шаги. В блок-схеме также приведен список формул преобразования, необходимых для расчета компенсированных температуры и давления. Часть с отображением значения температуры — это, по сути, шаг, на котором я буду отправлять результат по UART.

Из приведенной выше блок-схемы я буду реализовывать только те части, которые связаны со сбором данных о температуре. Этот же код может быть легко расширен и для давления. Нужно будет только реализовать дополнительные уравнения.

Реализация кода

Импорт ящиков

В данной реализации необходимы следующие крейты:

  • Крейт cortex_m_rt для кода запуска и минимального времени выполнения для микроконтроллеров Cortex-M.
  • Крейт core::fmt позволит нам использовать макрос writeln! для простой печати.
  • Крейт panic_halt для определения поведения паники, чтобы останавливаться при панике.
  • Крейт stm32f4xx_hal для импорта аппаратных абстракций устройств микроконтроллеров STMicro серии STM32F4 поверх API доступа к периферии.
use core::fmt::Write;
use cortex_m_rt::entry;
use panic_halt as _;
use stm32f4xx_hal::{
    i2c::Mode,
    pac::{self},
    prelude::*,
    serial::config::Config,
};
Вход в полноэкранный режим Выход из полноэкранного режима

Код конфигурации периферии

Конфигурация периферии I2C:

1️⃣ Получение хэндла для периферийных устройств устройства: Во встроенном Rust, как часть паттерна проектирования singleton, мы сначала должны получить периферийные устройства уровня PAC. Это делается с помощью метода take(). Здесь я создаю обработчик периферийного устройства с именем dp следующим образом:

let dp = pac::Peripherals::take().unwrap();
Вход в полноэкранный режим Выход из полноэкранного режима

2️⃣ Настройте системные часы: Системные часы должны быть настроены, так как они необходимы для настройки периферийных устройств I2C и UART. Для настройки системных часов нам нужно сначала продвинуть структуру RCC из PAC и ограничить ее с помощью метода constrain() (подробнее о методе constrain здесь), чтобы дать доступ к структуре cfgr. После этого мы создаем хэндл clocks, который предоставляет доступ к настроенным (и замороженным) системным часам. Часы настроены на использование частоты HSE 8 МГц путем применения метода use_hse() к структуре cfgr. Частота HSE определена в справочном руководстве к плате разработки Nucleo-F401RE. Наконец, метод freeze() применяется к структуре cfgr для замораживания конфигурации часов. Обратите внимание, что замораживание часов является защитным механизмом HAL, чтобы избежать изменения конфигурации часов во время выполнения. Из этого следует, что периферийные устройства, которым требуется информация о часах, будут принимать только замороженную конфигурационную структуру Clocks.

let rcc = dp.RCC.constrain();
let clocks = rcc.cfgr.use_hse(8.MHz()).freeze();
Вход в полноэкранный режим Выход из полноэкранного режима

2️⃣ Продвигаем GPIO-структуры уровня PAC и получаем хэндлы для пинов SDA и SCL: Здесь мне нужно настроить и получить хэндлы для пинов SDA и SCL, чтобы они могли управляться периферийным устройством I2C. Как было показано ранее, контакты SDA и SCL подключены к PB9 и PB8, соответственно. Поэтому, прежде чем я смогу получить какие-либо ручки, мне нужно раскрутить структуру pac-уровня GPIOB, чтобы иметь возможность создавать ручки для отдельных выводов. Для этого я использую метод split() следующим образом:

let gpiob = dp.GPIOB.split();
Войти в полноэкранный режим Выйти из полноэкранного режима

Далее я получаю хэндлы для sda и scl следующим образом:

let scl = gpiob.pb8;
let sda = gpiob.pb9;
Войти в полноэкранный режим Выйти из полноэкранного режима

3️⃣ Настраиваем периферийный канал I2C: Если посмотреть на распиновку платы Nucleo-F401RE, то выводы PB8 и PB9 линий SDA и SCL подключаются к периферийному устройству I2C1 микроконтроллера. Это означает, что нам необходимо сконфигурировать I2C1 и каким-то образом передать его на ручки выводов, которые мы хотим использовать. Для конфигурирования/включения последовательного периферийного канала у нас есть два варианта, как и в случае с некоторыми другими периферийными устройствами. Первый — использовать хэндл периферийного устройства dp для прямого доступа к I2C и инстанцировать экземпляр передатчика с помощью метода i2c из признаков расширения I2C. Второй — использовать метод new в структуре абстракции I2c для инстанцирования экземпляра I2C1. Обратите внимание, что оба варианта — это разные способы сделать одно и то же!

Для первого варианта, если мы рассмотрим сигнатуру метода i2c в признаках расширения I2C, она выглядит следующим образом:

fn i2c<SCL, SDA>(
    self,
    pins: (SCL, SDA),
    mode: impl Into<Mode>,
    clocks: &Clocks
) -> I2c<Self, (SCL, SDA)>
Войти в полноэкранный режим Выйти из полноэкранного режима

Метод принимает три параметра, экземпляр pins в виде кортежа, режим и ссылку на замороженный экземпляр Clocks. Таким образом, мы можем создать хэндл i2c для I2C1 следующим образом:

let mut i2c = dp.I2C1.i2c(
        (scl, sda),
        Mode::Standard {
            frequency: 100.kHz(),
        },
        &clocks,
    );
Войти в полноэкранный режим Выход из полноэкранного режима

scl, sda и clocks — это дескрипторы, которые мы создали ранее. Mode — это перечисление, которое содержит информацию о режиме, необходимую для периферийного устройства I2C, и имеет две следующие опции:

pub enum Mode {
    Standard {
        frequency: Hertz,
    },
    Fast {
        frequency: Hertz,
        duty_cycle: DutyCycle,
    },
}
Войти в полноэкранный режим Выйти из полноэкранного режима

В моем случае я выбрал опцию Standard с частотой 100 кГц для работы. В техническом описании BMP180 указано, что устройство может обрабатывать до 3,4 Мбит/с, поэтому я выбрал произвольное значение ниже заявленного предела. Второй вариант — это опция Fast, которая позволяет также контролировать рабочий цикл.

Альтернативно, второй вариант с использованием абстракции I2C выглядит следующим образом:

let mut i2c = I2c::new(
        dp.I2C1,
        (scl, sda),
        Mode::Standard {
            frequency: 300.kHz(),
        },
        &clocks,
    );
Войти в полноэкранный режим Выйти из полноэкранного режима

Видно, что основное различие здесь в том, что new применяется как метод экземпляра на структуре I2c. Здесь можно заметить, что метод new также принимает четвертый параметр, который является экземпляром периферийного устройства I2C I2C1. Это можно увидеть в сигнатуре метода экземпляра tx в документации, которая выглядит следующим образом:

pub fn new(
    i2c: I2C,
    pins: (SCL, SDA),
    mode: impl Into<Mode>,
    clocks: &Clocks
) -> Self
Войти в полноэкранный режим Выход из полноэкранного режима

На этом конфигурация I2C закончена, теперь перейдем к UART.

Конфигурация периферийного устройства последовательной связи UART:

1️⃣ Получите ручку и настройте пин последовательной передачи (Tx): Поскольку кнопкой Tx является PA2, мне нужно создать хэндл для gpioa. Однако, поскольку вывод не используется как обычный вход или выход GPIO, это означает, что вывод должен быть подключен к другому периферийному устройству внутри микроконтроллера. Для этого вывод может быть сконфигурирован с помощью метода into_alternate() следующим образом:

let gpioa = dp.GPIOA.split();    
let tx_pin = gpioa.pa2.into_alternate();
Вход в полноэкранный режим Выход из полноэкранного режима

2️⃣ Настройте канал последовательной периферии: Если посмотреть на распиновку платы Nucleo-F401RE, то вывод PA2 линии Tx подключается к периферийному устройству USART2 в микроконтроллере. Это означает, что нам необходимо сконфигурировать USART2 и каким-то образом передать его на ручку вывода, который мы хотим использовать. Это делается следующим образом:

    let mut tx = dp
        .USART2
        .tx(
            tx_pin,
            Config::default()
                .baudrate(115200.bps())
                .wordlength_8()
                .parity_none(),
            &clocks,
        )
        .unwrap();
Войти в полноэкранный режим Выход из полноэкранного режима

tx_pin и clocks — это хэндлы, которые мы создали ранее. Config — это тип struct, который содержит конфигурационную информацию, необходимую для настройки периферийного устройства UART. Здесь я создаю экземпляр Config с признаком default сначала для настройки параметров по умолчанию. После этого я применяю методы baudrate, wordlength_8 и parity_none для настройки периферийного устройства UART на нужные мне параметры. Полный список методов Config можно найти здесь. Я настроил параметры UART, как показано на рисунке, на скорость 115200 бит/с в бодах с 8 битами данных и без четности, также обычно называемой 8N1. Наконец, поскольку метод tx возвращает результат, мы должны развернуть его с помощью метода unwrap.

📝 Примечание:

Более подробно о настройке UART можно прочитать в блоге UART Serial Communication.

Конфигурация периферийных устройств таймера и задержки:

В алгоритме необходимо ввести задержку, чтобы дождаться окончания преобразования АЦП в BMP180. Я буду использовать TIM1 и создам миллисекундную задержку delay следующим образом:

let mut delay = dp.TIM1.delay_ms(&clocks);
Вход в полноэкранный режим Выход из полноэкранного режима

На этом конфигурация закончена! Теперь перейдем к коду приложения.

Код приложения

В описанном программном проекте первый шаг требует, чтобы мы считали кучу калибровочных данных из EEPROM BMP180. Для этого мне нужно создать и инициализировать некую структуру для сохранения калибровочных данных. Я назвал тип struct Coeffs и определил его следующим образом:

    struct Coeffs {
        ac5: i16,
        ac6: i16,
        mc: i16,
        md: i16,
    }
Вход в полноэкранный режим Выход из полноэкранного режима

После этого я создаю calib_coeffs типа Coeffs, инициализированный всеми нулями:

    let mut calib_coeffs = Coeffs {
        ac5: 0,
        ac6: 0,
        mc: 0,
        md: 0,
    };
Вход в полноэкранный режим Выход из полноэкранного режима

📝 Примечание:

В техническом описании для BMP180 есть 11 различных калибровочных коэффициентов, которые должны быть захвачены. Здесь я фиксирую только те, которые необходимы для расчета температуры.

Далее я определяю кучу констант, которые отражают адреса калибровочных коэффициентов в EEPROM BMP180, I2C адрес самого BMP180 (BMP180_ADDR) и адрес для получения ID устройства BMP180 (REG_ID_ADDR).

    const BMP180_ADDR: u8 = 0x77;
    const REG_ID_ADDR: u8 = 0xD0;
    const AC5_MSB_ADDR: u8 = 0xB2;
    const AC6_MSB_ADDR: u8 = 0xB4;
    const MC_MSB_ADDR: u8 = 0xBC;
    const MD_MSB_ADDR: u8 = 0xBE;
    const CTRL_MEAS_ADDR: u8 = 0xF4;
    const MEAS_OUT_LSB_ADDR: u8 = 0xF7;
    const MEAS_OUT_MSB_ADDR: u8 = 0xF6;
Вход в полноэкранный режим Выход из полноэкранного режима

Я также определяю две переменные, массив [u8; 2], который я назвал rx_buffer и i16, названный rx_word. Я буду использовать rx_buffer позже для буферизации данных, которые я считываю через I2C с BMP180. rx_word будет использоваться для реконструирования считанных байтов в 16-битное значение.

let mut rx_buffer: [u8; 2] = [0; 2];
let mut rx_word: i16;
Вход в полноэкранный режим Выход из полноэкранного режима

Прежде чем что-то делать, мы должны понять, как BMP180 обменивается данными по I2C. По существу, связь BMP180 работает следующим образом: сначала происходит цикл записи (управления), указывающий, какой внутренний адрес EEPROM BMP180 мы хотим прочитать. Во-вторых, есть цикл чтения, в котором предоставляются данные по адресу, который был запрошен в цикле записи.

Первое, что мне нужно прочитать в соответствии с алгоритмическими шагами — это коэффициенты калибровки. Однако в техническом описании рекомендуется, чтобы перед чтением любых калибровочных коэффициентов из BMP180 был прочитан идентификатор устройства BMP180 в качестве проверки на вменяемость. Устройство должно вернуть значение 0x55. Для этого, покопавшись в документации по методам I2C, я нашел методы write и read со следующими подписями:

pub fn write(&mut self, addr: u8, bytes: &[u8]) -> Result<(), Error>
Вход в полноэкранный режим Выход из полноэкранного режима

и

pub fn read(&mut self, addr: u8, buffer: &mut [u8]) -> Result<(), Error>
Ввести полноэкранный режим Выход из полноэкранного режима

Хотя в документации не было много описания, можно увидеть, что оба они более или менее похожи с небольшим отличием. Обе принимают два аргумента и возвращают Result. Первый аргумент addr — это адрес устройства, в нашем случае это будет адрес устройства BMP180 BMP180_ADDR. Второй аргумент для метода writebytes и представляет собой фрагмент массива, содержащий записываемые байты. Вторым аргументом метода read является buffer и представляет собой изменяемый фрагмент массива, содержащий байты, возвращаемые адресуемым устройством.

Мое внимание привлек еще один метод, который показался мне полезным. Это был метод write_read. И снова, хотя описание было не очень большим, я понял его работу, немного поэкспериментировав. Метод write_read выполняет цикл записи, за которым сразу же следует цикл чтения. Это будет полезно во многих контекстах и сэкономит несколько строк кода. Однако мы увидим, что мне по-прежнему нужны отдельные методы read и write. Метод write_read имеет следующую сигнатуру:

pub fn write_read(
    &mut self,
    addr: u8,
    bytes: &[u8],
    buffer: &mut [u8]
) -> Result<(), Error>
Войти в полноэкранный режим Выйти из полноэкранного режима

Параметры более или менее те же, что и у предыдущих методов, только объединены в один.

Итак, теперь, когда у меня есть все необходимое, чтобы провести проверку на вменяемость, вот код, который я написал:

    i2c.write_read(BMP180_ADDR, &[REG_ID_ADDR], &mut rx_buffer).unwrap();
    if rx_buffer[0] == 0x55 {
        writeln!(tx, "Device ID is {}r", rx_buffer[0]).unwrap();
    } else {
        writeln!(tx, "Device ID Cannot be Detected r").unwrap();
    }
Войти в полноэкранный режим Выйти из полноэкранного режима

Анализируя код, первым аргументом является адрес устройства BMP180_ADDR, вторым аргументом является фрагмент, содержащий адрес получения ID устройства REG_ID_ADDR, и, наконец, третьим аргументом является буфер приема rx_buffer. Обратите внимание, что все аргументы, которые я передаю, я создал в более ранней точке приложения. Следующий оператор if проверяет правильность полученного ID и посылает соответствующее сообщение по UART. Обратите внимание, что, согласно спецификации BMP180, поскольку REG_ID_ADDR возвращает только один байт, мне нужно было проверить только первый индекс в буфере rx_buffer[0].

Теперь, когда я убедился, что устройство может быть обнаружено, давайте приступим к первому шагу алгоритма — сбору калибровочных коэффициентов. Вот код для получения калибровочного коэффициента AC5:

    i2c.write_read(BMP180_ADDR, &[AC5_MSB_ADDR], &mut rx_buffer)
        .unwrap();
    rx_word = ((rx_buffer[0] as i16) << 8) | rx_buffer[1] as i16;
    writeln!(tx, "AC5 = {} r", rx_word).unwrap();
    calib_coeffs.ac5 = rx_word;
Вход в полноэкранный режим Выход из полноэкранного режима

Как можно заметить, я использовал метод write_read точно так же, как и ранее. Однако здесь есть три отличия:

  1. Помните, я упоминал, что BMP180 предоставляет 16-битное значение для каждого коэффициента калибровки. Поскольку I2C обменивается данными в байтах, каждый коэффициент разбивается на два байта MSB и LSB, каждый из которых имеет свой собственный адрес в EEPROM BMP180. Это означает, что технически мне нужно будет получить каждый адрес отдельно и восстановить 16-битное значение. Однако оказывается, что если послать BMP180 только MSB адрес (AC5_MSB_ADDR в данном случае), то устройство отправит обратно MSB и LSB без необходимости отдельно адресовать LSB. MSB будет расположен в первом индексе буфера, а LSB — во втором. Итак, суть в том, что мне нужно было вызвать метод read_write только один раз, используя AC5_MSB_ADDR для получения как AC5_MSB_ADDR, так и AC5_LSB_ADDR.
  2. Поскольку обратно будет предоставлен i16, строка rx_word = ((rx_buffer[0] as i16) << 8) | rx_buffer[1] as i16; берет MSB биты из rx_buffer[0], приводит их к i16 и сдвигает 1 байт (8 раз) влево, используя < <, затем OR результат с помощью оператора | с LSB битами в rx_buffer[1], которые также приведены к i16. Результат сохраняется в rx_word и затем передается по каналу UART.
  3. Слово rx_word хранится в структуре calib_coeffs, которую я создал ранее в операторе calib_coeffs.ac5 = rx_word;. Это также позволит мне повторно использовать rx_word для следующих операций.

Предыдущий код повторяется точно таким же образом три раза для получения коэффициентов AC6, MC и MD. Очевидно, что единственным отличием будет адрес, который я пошлю BMP180, и имя члена, который я сохраню в структуре calib_coeffs.

Теперь, когда калибровочные коэффициенты доступны, можно запустить цикл измерения. Первым шагом, как указано в программном проекте, является запуск измерения температуры в BMP180 путем записи 0x2E в регистр с адресом 0xF4 (вводится как CTRL_MEAS_ADDR). Это делается с помощью двух циклов записи, сначала посылается 0x2E, а затем CTRL_MEAS_ADDR. Это может быть сделано и в одной строке. Поскольку метод write в своей сигнатуре принимает фрагмент в параметре bytes, все байты, которые необходимо отправить, можно включить в фрагмент следующим образом:

i2c.write(BMP180_ADDR, &[CTRL_MEAS_ADDR, 0x2E]).unwrap();
Войти в полноэкранный режим Выйти из полноэкранного режима

Этот оператор отправит на BMP180 CTRL_MEAS_ADDR, за которым последует значение 0x2E. Для тех, кто знаком с терминами I2C, хотя я не проверял на низком уровне, да и в документации не указано, но здесь должен происходить «повторный старт».

После запуска измерения температуры BMP180, согласно техническому описанию, нам нужно подождать не менее 4,5 мс. Поэтому я жду 5 мс для подстраховки, используя созданный ранее хэндл delay:

 delay.delay_ms(5_u32);
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь измерение температуры должно быть готово к сбору данных путем чтения MSB и LSB адресов EEPROM BMP180. Это можно сделать, используя предыдущий подход, когда я посылаю только MSB адрес. Здесь я показываю другой подход, где я считываю MSB и LSB отдельно, просто чтобы доказать, что это работает 😃:

        i2c.write(BMP180_ADDR, &[MEAS_OUT_MSB_ADDR]).unwrap();
        i2c.read(BMP180_ADDR, &mut rx_buffer).unwrap();
        rx_word = (rx_buffer[0] as i16) << 8;

        i2c.write(BMP180_ADDR, &[MEAS_OUT_LSB_ADDR]).unwrap();
        i2c.read(BMP180_ADDR, &mut rx_buffer).unwrap();
        rx_word |= rx_buffer[0] as i16;
Вход в полноэкранный режим Выход из полноэкранного режима

Наконец, поскольку полученное значение температуры не скомпенсировано. В техническом описании приведена куча формул для расчета компенсированной температуры на основе калибровочных коэффициентов, которые были собраны ранее. Следующий код реализует формулы из таблицы данных для расчета температуры с последующей отправкой результата по UART:

        let x1 = (rx_word as i32 - calib_coeffs.ac6 as i32) * (calib_coeffs.ac5 as i32) >> 15;
        let x2 = ((calib_coeffs.mc as i32) << 11) / (x1 + calib_coeffs.md as i32);
        let b5 = x1 + x2;
        let t = ((b5 + 8) >> 4) / 10;

        // Print Temperature Value
        writeln!(tx, "Temperature = {:} r", t).unwrap();
Вход в полноэкранный режим Выход из полноэкранного режима

Обратите внимание, что значения приводятся к виду i32, чтобы предотвратить переполнение при операциях умножения.

Вот и все!

Полный код приложения

Здесь представлен полный код реализации, описанной в этом посте. Кроме того, вы можете найти полный проект и другие материалы в git-репо apollolabsdev Nucleo-F401RE.

#![no_std]
#![no_main]

// Imports
use core::fmt::Write;
use cortex_m_rt::entry;
use panic_halt as _;
use stm32f4xx_hal::{
    i2c::Mode,
    pac::{self},
    prelude::*,
    serial::config::Config,
};

#[entry]
fn main() -> ! {
    // Setup handler for device peripherals
    let dp = pac::Peripherals::take().unwrap();

    // I2C Config steps:
    // 1) Need to configure the system clocks
    // - Promote RCC structure to HAL to be able to configure clocks
    let rcc = dp.RCC.constrain();
    // - Configure system clocks
    // 8 MHz must be used for the Nucleo-F401RE board according to manual
    let clocks = rcc.cfgr.use_hse(8.MHz()).freeze();
    // 2) Configure/Define SCL and SDA pins
    let gpiob = dp.GPIOB.split();
    let scl = gpiob.pb8;
    let sda = gpiob.pb9;
    // 3) Configure I2C perihperal channel
    // We're going to use I2C1 since its pins are the ones connected to the I2C interface we're using
    // To configure/instantiate serial peripheral channel we have two options:
    // Use the i2c device peripheral handle and instantiate a transmitter instance using extension trait
    let mut i2c = dp.I2C1.i2c(
        (scl, sda),
        Mode::Standard {
            frequency: 100.kHz(),
        },
        &clocks,
    );
    // Or use the I2C abstraction
    // let mut i2c = I2c::new(
    //     dp.I2C1,
    //     (scl, sda),
    //     Mode::Standard {
    //         frequency: 300.kHz(),
    //     },
    //     &clocks,
    // );

    // Serial config steps:
    // 1) Need to configure the system clocks
    // Already done earlier for I2C module
    // 2) Configure/Define TX pin
    // Use PA2 as it is connected to the host serial interface
    let gpioa = dp.GPIOA.split();
    let tx_pin = gpioa.pa2.into_alternate();
    // 3) Configure Serial perihperal channel
    // We're going to use USART2 since its pins are the ones connected to the USART host interface
    // To configure/instantiate serial peripheral channel we have two options:
    // Use the device peripheral handle to directly access USART2 and instantiate a transmitter instance
    let mut tx = dp
        .USART2
        .tx(
            tx_pin,
            Config::default()
                .baudrate(9600.bps())
                .wordlength_8()
                .parity_none(),
            &clocks,
        )
        .unwrap();

    let mut delay = dp.TIM1.delay_ms(&clocks);

    struct Coeffs {
        ac5: i16,
        ac6: i16,
        mc: i16,
        md: i16,
    }

    let mut calib_coeffs = Coeffs {
        ac5: 0,
        ac6: 0,
        mc: 0,
        md: 0,
    };

    const BMP180_ADDR: u8 = 0x77;
    const REG_ID_ADDR: u8 = 0xD0;
    const AC5_MSB_ADDR: u8 = 0xB2;
    const AC6_MSB_ADDR: u8 = 0xB4;
    const MC_MSB_ADDR: u8 = 0xBC;
    const MD_MSB_ADDR: u8 = 0xBE;
    const CTRL_MEAS_ADDR: u8 = 0xF4;
    const MEAS_OUT_LSB_ADDR: u8 = 0xF7;
    const MEAS_OUT_MSB_ADDR: u8 = 0xF6;

    let mut rx_buffer: [u8; 2] = [0; 2];
    let mut rx_word: i16;

    // Read Device ID as Sanity Check
    i2c.write(BMP180_ADDR, &[REG_ID_ADDR]).unwrap();
    i2c.read(BMP180_ADDR, &mut rx_buffer).unwrap();
    // OR 
    // i2c.write_read(BMP180_ADDR, &[REG_ID_ADDR], &mut rx_buffer)
    //     .unwrap();
    if rx_buffer[0] == 0x55 {
        writeln!(tx, "Device ID is {}r", rx_buffer[0]).unwrap();
    } else {
        writeln!(tx, "Device ID Cannot be Detected r").unwrap();
    }

    // Read Calibration Coefficients
    // Read AC5
    i2c.write_read(BMP180_ADDR, &[AC5_MSB_ADDR], &mut rx_buffer)
        .unwrap();
    rx_word = ((rx_buffer[0] as i16) << 8) | rx_buffer[1] as i16;
    writeln!(tx, "AC5 = {} r", rx_word).unwrap();
    calib_coeffs.ac5 = rx_word;

    // Read AC6
    i2c.write_read(BMP180_ADDR, &[AC6_MSB_ADDR], &mut rx_buffer)
        .unwrap();
    rx_word = ((rx_buffer[0] as i16) << 8) | rx_buffer[1] as i16;
    writeln!(tx, "AC6 = {} r", rx_word).unwrap();
    calib_coeffs.ac6 = rx_word;

    // Read MC
    i2c.write_read(BMP180_ADDR, &[MC_MSB_ADDR], &mut rx_buffer)
        .unwrap();
    rx_word = ((rx_buffer[0] as i16) << 8) | rx_buffer[1] as i16;
    writeln!(tx, "MC = {} r", rx_word).unwrap();
    calib_coeffs.mc = rx_word;

    // Read MD
    i2c.write_read(BMP180_ADDR, &[MD_MSB_ADDR], &mut rx_buffer)
        .unwrap();
    rx_word = ((rx_buffer[0] as i16) << 8) | rx_buffer[1] as i16;
    writeln!(tx, "MD = {} r", rx_word).unwrap();
    calib_coeffs.md = rx_word;

    // Application Loop
    loop {
        // Kick off Temperature Measurement by writing 0x2E in register 0xF4
        i2c.write(BMP180_ADDR, &[CTRL_MEAS_ADDR, 0x2E]).unwrap();
        // Wait 4.5 ms for measurment to complete as specified by the datasheet
        delay.delay_ms(5_u32);

        // Collect Temperature Measurment
        // Read Measurement MSB
        // Achieving same as above using an alternate method syntax here to do a write followed by read
        i2c.write(BMP180_ADDR, &[MEAS_OUT_MSB_ADDR]).unwrap();
        i2c.read(BMP180_ADDR, &mut rx_buffer).unwrap();
        rx_word = (rx_buffer[0] as i16) << 8;
        // Read Measurement LSB
        i2c.write(BMP180_ADDR, &[MEAS_OUT_LSB_ADDR]).unwrap();
        i2c.read(BMP180_ADDR, &mut rx_buffer).unwrap();
        rx_word |= rx_buffer[0] as i16;

        // Uncomment following line to print raw uncompenstated temperature value
        //writeln!(tx, "UT = {} r", rx_word).unwrap();

        // Calculate Temperature According to Datasheet Formulas
        let x1 = (rx_word as i32 - calib_coeffs.ac6 as i32) * (calib_coeffs.ac5 as i32) >> 15;
        let x2 = ((calib_coeffs.mc as i32) << 11) / (x1 + calib_coeffs.md as i32);
        let b5 = x1 + x2;
        let t = ((b5 + 8) >> 4) / 10;

        // Print Temperature Value
        writeln!(tx, "Temperature = {:} r", t).unwrap();
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Дальнейшие эксперименты/идеи:

Некоторые идеи для экспериментов включают:

  • Расширить код для сбора данных измерения давления, включая данные калибровки, и вычисления барометрического давления.
  • BMP180 имеет различные режимы точности, в которых могут быть получены более точные измерения, но также и разное время ожидания. Вы можете рефакторить код или даже создать функции, которые могут выбирать один из нужных режимов.

Заключение

В этом посте было создано приложение для измерения температуры и давления с помощью I2C, управляющее и собирающее данные с внешнего датчика BMP180. Для этого использовалась периферия I2C для микроконтроллера STM32F401RE на плате разработки Nucleo-F401RE. Полученные результаты измерений также передаются на главный ПК через UART-соединение. Весь код был основан на опросе (без прерываний). Кроме того, весь код был создан на уровне HAL с использованием stm32f4xx Rust HAL. Есть вопросы? Поделитесь своими мыслями в комментариях ниже 👇. Если вы нашли это полезным, не забудьте подписаться на рассылку новостей здесь, чтобы быть в курсе новых статей блога.

Оцените статью
devanswers.ru
Добавить комментарий