Как разрабатывать утилиты для тестов embedded-прошивок без железа: практика Test Driven Development
Часто SDET-инженеры, работающие со встраиваемыми системами, не приступают к работе, пока не получат реальное железо: датчик, микроконтроллер или плату с новым чипом. Такой подход обычно оправдывают тем, что без физического девайса «на столе» писать корректно работающий софт невозможно. Очевидный минус: увеличивается время выхода продукта и нового функционала на рынок. Но разработку можно начать без устройства: все дело в договоренности между командами.
Меня зовут Рустам Ахмадуллин, я старший инженер по системной верификации аппаратуры в YADRO. Расскажу на примере датчика температуры LM75A, как написать API без физического доступа к устройству и его прошивке. Разберем методологию Test Driven Development, при которой разработка начинается с написания автоматизированных тестов, а не самого кода.
Как начать разработку без железа
Для начала посмотрим, как передаются данные: обычно это байты, которые интерпретируются в зависимости от протокола. Предлагаю обратиться к прикладному уровню, где бегают определенные метрики для конкретного устройства. Их можно обернуть в структуры, то есть договориться об интерфейсе.
Поэтому первый шаг к разработке — это описание интерфейса. Затем перед нами встает более сложная задача: поддержка нужного интерфейса. Нам помогут юнит-тесты: методов и функций определенного модуля приложения. Также нам пригодятся интеграционные тесты, которые проверят, что поведение наших интерфейсов сохранится в рамках системы.
Рассмотрим пошагово данные тезисы и отработаем их на примере устройства датчика температуры LM75A:
Фиксируем интерфейсы взаимодействия, чтобы команды разработки занимались реализацией API на своих уровнях.
Проводим юнит-тестирование интерфейсов.
Тестируем API с виртуальными интерфейсами.
Работаем с реальным устройством.
Если вам нравится тема эмуляторов и тестов с ними — ждем вас в отделе разработки встраиваемого ПО:
Фиксируем интерфейсы взаимодействия
В нашем примере есть даташит, поэтому будем отталкиваться от него. Но в командах можно фиксировать интерфейсы в виде некоторых словарей, структур или других типов данных в рамках рабочих протоколов. Главное — высокоуровнево договориться и следовать предложенным конвенциям.
Чтобы получить API для LM75A, можно использовать данные из даташита и получить все методы, но мы в методических целях посмотрим только на чтение температуры.
В даташите есть кусок с формулой преобразования байтовых данных в температуру. В представлении на Python функция чтения температуры будет выглядеть так:
def read_temperature(self):
"""Чтение температуры (11-бит, 2's complement, 0.125°C)"""
data = self._read_register(self.REG_TEMP, 2)
if not data:
return None
temperature = self.convert_from_bytes(data)
return temperature
@staticmethod
def convert_from_bytes(data: bytes | list) -> float:
raw = (data[0] << 8) | data[1]
temp_raw = raw >> 5
if temp_raw & 0x400:
temp_raw -= 0x800
temperature = temp_raw * 0.125
logger.debug(f"{data=}, {raw=}, {temp_raw=}, {temperature=}")
return temperatureВажно, что методы парсинга сырых данных лучше определять в отдельные сущности. Проще будет протестировать уровни API, так как разложение байтовых данных в представление определенных метрик (в нашем примере — температура) — это важная часть приложения, а значит требует повторяемости и контроля со стороны юнит-тестов.
Полный пример реализации класса — под спойлером ниже.
Код класса
import numpy as np
import smbus2
from loguru import logger
class LM75A:
# I2C адрес по умолчанию (A2, A1, A0 = GND → 0x48)
# Адрес зависит от подключения: 0x48, 0x49, ..., 0x4F (всего 8 устройств)
DEFAULT_ADDRESS = 0x48
# Регистры (по документации)
REG_TEMP = 0x00 # Температура (только чтение)
REG_CONF = 0x01 # Конфигурация
REG_TOS = 0x03 # Порог перегрева (Tos)
REG_THYST = 0x02 # Гистерезис (Thyst)
# Биты в регистре конфигурации
SHUTDOWN = 0x01 # Бит 0: 1 = Shutdown, 0 = Normal
OS_COMP_INT = 0x02 # Бит 1: 1 = Interrupt, 0 = Comparator
OS_POL = 0x04 # Бит 2: 1 = Active HIGH, 0 = Active LOW
OS_F_QUE = 0x18 # Биты 3-4: Fault queue (00=1, 01=2, 10=4, 11=6)
def __init__(self, address=DEFAULT_ADDRESS, bus_number=1):
self.address = address
self.bus_number = bus_number
def _write_register(self, reg, value):
"""Запись одного байта в регистр"""
try:
with smbus2.SMBus(self.bus_number) as bus:
bus.write_i2c_block_data(self.address, reg, value)
except Exception as e:
logger.error(f"Ошибка записи в регистр {reg}: {e}")
def _read_register(self, reg, num_bytes=1):
"""Чтение байтов из регистра"""
try:
with smbus2.SMBus(self.bus_number) as bus:
return bus.read_i2c_block_data(self.address, reg, num_bytes)
except Exception as e:
logger.error(f"Ошибка чтения из регистра {reg}: {e}")
return None
def read_temperature(self):
"""Чтение температуры (11-бит, 2's complement, 0.125°C)"""
data = self._read_register(self.REG_TEMP, 2)
if not data:
return None
temperature = self.convert_from_bytes(data)
return temperature
def write_temperature(self, temperature):
"""
Метод для эмулятора, запись в i2c stub
"""
data = self.convert_to_bytes(temperature)
self._write_register(self.REG_TEMP, data)
@staticmethod
def convert_to_bytes(temperature: float) -> bytes:
temp_raw = temperature / 0.125
raw = int(temp_raw) << 5
data = np.int16(raw).tobytes()[::-1]
logger.debug(f"{data=}, {raw=}, {temp_raw=}, {temperature=}")
return data
@staticmethod
def convert_from_bytes(data: bytes | list) -> float:
raw = (data[0] << 8) | data[1]
temp_raw = raw >> 5
if temp_raw & 0x400:
temp_raw -= 0x800
temperature = temp_raw * 0.125
logger.debug(f"{data=}, {raw=}, {temp_raw=}, {temperature=}")
return temperatureНастройки проекта
ОС: ubuntu 24.04,
используем пакетный менеджер uv,
упрощаем жизнь скриптами на базе poethepoet,
тесты пишем на pytest.
Проект с примерами на github.
Подробнее об удобстве и особенностях UV можно прочитать в этих статьях:
Проводим юнит-тестирование интерфейсов API
Пишем юнит-тесты для таблицы:
11-bit binary (2's complement) | Hexadecimal value | Decimal value | Value |
|---|---|---|---|
011 1111 1000 | 3F8 | 1016 | +127.000 °C |
011 1111 0111 | 3F7 | 1015 | +126.875 °C |
011 1111 0001 | 3F1 | 1009 | +126.125 °C |
011 1110 1000 | 3E8 | 1000 | +125.000 °C |
000 1100 1000 | 0C8 | 200 | +25.000 °C |
000 0000 0001 | 001 | 1 | +0.125 °C |
000 0000 0000 | 000 | 0 | 0.000 °C |
111 1111 1111 | 7FF | -1 | -0.125 °C |
111 0011 1000 | 738 | -200 | -25.000 °C |
110 0100 1001 | 649 | -439 | -54.875 °C |
110 0100 1000 | 648 | -440 | -55.000 °C |
Таблица 10. Значение регистра температуры из даташита к датчику температуры LM75A
В этой таблице приведены уже конвертированные данные, но для тестов их нужно преобразовать:
(127.0, [0x7f, 0x00]),
(25.0, [0x19, 0x00]),
(0, [0x00, 0x00]),
(0.125, [0x00, 0x20]),
(-0.125, [0xFF, 0xE0]),
(-25.0, [0xE7, 0x00]),
(-55.0, [0xC9, 0x00]),В первую очередь напишем юнит-тест для конвертера из байт в вещественное число:
from demo_lm75.lm75 import LM75A
def test_converter_25():
assert LM75A.convert_to_bytes(25.0) == b'\x19\x00'
assert LM75A.convert_from_bytes(b'\x19\x00') == 25.0
def test_converter_n25():
assert LM75A.convert_to_bytes(-25.0) == b'\xe7\x00'
assert LM75A.convert_from_bytes(b'\xe7\x00') == -25.0
def test_converter_0():
assert LM75A.convert_to_bytes(0.0) == b'\x00\x00'
assert LM75A.convert_from_bytes(b'\x00\x00') == 0.0Запускаем, как обычно, на pytest:
(demo-lm75) rustam:~/python-progs/demo-lm75$ pytest -v tests/test_converter.py
================================================================== test session starts ==================================================================
platform linux -- Python 3.12.10, pytest-8.4.2, pluggy-1.6.0 -- /home/rustam/python-progs/demo-lm75/.venv/bin/python3
cachedir: .pytest_cache
rootdir: /home/rustam/python-progs/demo-lm75
configfile: pyproject.toml
plugins: mock-3.15.0
collected 3 items
tests/test_converter.py::test_converter_25 PASSED [ 33%]
tests/test_converter.py::test_converter_n25 PASSED [ 66%]
tests/test_converter.py::test_converter_0 PASSED [100%]
=================================================================== 3 passed in 0.14s ===================================================================Следующий уровень — это мок-функции записи в I2C-устройство:
import pytest
from demo_lm75.lm75 import LM75A
@pytest.fixture
def lm75a(mocker):
"""Фикстура: создание экземпляра LM75A с подменой SMBus"""
address = 0x48
lm75a = LM75A(address=address, bus_number=1)
return lm75a
def get_data():
return [
(127.0, [0x7f, 0x00]),
(25.0, [0x19, 0x00]),
(0, [0x00, 0x00]),
(0.125, [0x00, 0x20]),
(-0.125, [0xFF, 0xE0]),
(-25.0, [0xE7, 0x00]),
(-55.0, [0xC9, 0x00]),
]
@pytest.mark.parametrize('temp,vals', get_data())
def test_read_temperature(mocker, lm75a, temp, vals):
"""Тест: чтение положительной температуры"""
mocker.patch.object(
lm75a,
'_read_register',
return_value=vals,
)
temp_ret = lm75a.read_temperature()
assert temp_ret == tempНа этом этапе мы получили покрытие тестами для основных методов общения с платой на базе LM75A.
Тестируем API с виртуальными интерфейсами
Можно сказать, что юнит-тестирования достаточно для реализации API, но стоит проработать интеграционный тест для большего приближения работы с устройством на функциональном уровне. Эту задачу помогают решить виртуальные интерфейсы. К счастью, средствами ОС на базе Linux легко этого добиться. Рассмотрим пример для I2C.
Небольшое введение в виртуальный I2C-стаб
Воспользуемся возможностью i2c-tools создавать виртуальный стаб для I2C:
sudo modprobe i2c-dev
sudo modprobe i2c-stub chip_addr=0x08
i2cdetect -lДалее конфигурируем устройство с нужным нам адресом:
i2c-stub-from-dump 0x48 tools/setup_stub/chip_zeros.dump
tools/setup_stub/chip_zeros.dump — это текстовый дамп с нулями, в общем случае может иметь любое содержимое и изначально эмулировать поведение устройства.
На выходе на виртуальной шине будет устройство. В него можно писать и считывать данные:
i2cdump -y 16 0x48
No size specified (using byte-data access)
0 1 2 3 4 5 6 7 8 9 a b c d e f 0123456789abcdef
00: 19 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ?...............
10: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
20: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
30: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
40: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
50: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
60: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
70: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
90: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
b0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
d0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
e0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
f0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................Мы можем, к примеру, записать в область памяти данные и считать их из скрипта как из реального устройства.
Пример кода записи данных от эмулятора:
import time
from demo_lm75.lm75 import LM75A
from loguru import logger
import numpy as np
def main():
"""
Пример для записи в i2c stub, после поднятия виртуальной шины
.. code-block::
poe activate-emulate
"""
dev = LM75A(address=0x48, bus_number=16)
for i in range(30):
emulatetemp = np.random.randint(10, 110)
logger.info(f"{emulate_temp=}")
dev.write_temperature(temperature=emulate_temp)
time.sleep(1)
if name == "__main__":
main()Запускаем модель в терминале:
(demo-lm75) rustam:~/python-progs/demo-lm75$ python examples/emulator_lm75.py
2026-01-22 12:41:09.905 | INFO | __main__:main:21 - emulate_temp=13
2026-01-22 12:41:09.905 | DEBUG | demo_lm75.lm75:convert_to_bytes:65 - data=b'\r\x00', raw=3328, temp_raw=104.0, temperature=13
2026-01-22 12:41:10.905 | INFO | __main__:main:21 - emulate_temp=16
2026-01-22 12:41:10.906 | DEBUG | demo_lm75.lm75:convert_to_bytes:65 - data=b'\x10\x00', raw=4096, temp_raw=128.0, temperature=16
2026-01-22 12:41:11.907 | INFO | __main__:main:21 - emulate_temp=42
2026-01-22 12:41:11.907 | DEBUG | demo_lm75.lm75:convert_to_bytes:65 - data=b'*\x00', raw=10752, temp_raw=336.0, temperature=42
2026-01-22 12:41:12.908 | INFO | __main__:main:21 - emulate_temp=42
2026-01-22 12:41:12.908 | DEBUG | demo_lm75.lm75:convert_to_bytes:65 - data=b'*\x00', raw=10752, temp_raw=336.0, temperature=42
2026-01-22 12:41:13.908 | INFO | __main__:main:21 - emulate_temp=100
2026-01-22 12:41:13.909 | DEBUG | demo_lm75.lm75:convert_to_bytes:65 - data=b'd\x00', raw=25600, temp_raw=800.0, temperature=100
2026-01-22 12:41:14.909 | INFO | __main__:main:21 - emulate_temp=58
2026-01-22 12:41:14.910 | DEBUG | demo_lm75.lm75:convert_to_bytes:65 - data=b':\x00', raw=14848, temp_raw=464.0, temperature=58
2026-01-22 12:41:15.910 | INFO | __main__:main:21 - emulate_temp=99
2026-01-22 12:41:15.911 | DEBUG | demo_lm75.lm75:convert_to_bytes:65 - data=b'c\x00', raw=25344, temp_raw=792.0, temperature=99Код для чтения температуры:
import time
from demo_lm75.lm75 import LM75A
from loguru import logger
def main():
dev = LM75A(address=0x48, bus_number=16)
for _i in range(30):
logger.info(f"{dev.read_temperature()=}")
time.sleep(1)
if __name__ == "__main__":
main()В другом окне терминала можно запустить боевой код:
(demo-lm75) rustam:~/python-progs/demo-lm75$ python examples/ex_lm75.py
2026-01-22 12:44:57.588 | DEBUG | demo_lm75.lm75:convert_from_bytes:76 - data=[47, 0], raw=12032, temp_raw=376, temperature=47.0
2026-01-22 12:44:57.588 | INFO | __main__:main:10 - dev.read_temperature()=47.0
2026-01-22 12:44:58.588 | DEBUG | demo_lm75.lm75:convert_from_bytes:76 - data=[70, 0], raw=17920, temp_raw=560, temperature=70.0
2026-01-22 12:44:58.589 | INFO | __main__:main:10 - dev.read_temperature()=70.0
2026-01-22 12:44:59.589 | DEBUG | demo_lm75.lm75:convert_from_bytes:76 - data=[69, 0], raw=17664, temp_raw=552, temperature=69.0
2026-01-22 12:44:59.589 | INFO | __main__:main:10 - dev.read_temperature()=69.0
2026-01-22 12:45:00.590 | DEBUG | demo_lm75.lm75:convert_from_bytes:76 - data=[46, 0], raw=11776, temp_raw=368, temperature=46.0Пример тестов на pytest с использованием рассмотренного I2C-стаба:
import pytest
import subprocess
from demo_lm75.lm75 import LM75A
@pytest.fixture(scope='session')
def create_stub():
subprocess.run("poe activate-emulate".split())
dev = LM75A(address=0x48, bus_number=16)
emulator = LM75A(address=0x48, bus_number=16)
yield dev, emulator
subprocess.run("poe deactivate-emulate".split())
@pytest.mark.parametrize("val", [0.0, -25.0, 25.0, 127.0])
def test_write(create_stub, val):
dev, emulator = create_stub
emulator.write_temperature(val)
temp = dev.read_temperature()
assert temp == valМы получили эмулятор устройства и возможность отлаживать сценарии взаимодействия с ним в рамках его интерфейсов. Отмечу, что мы не эмулируем устройство полностью, а только взаимодействие по шине I2C.
Работаем с реальным устройством
Наконец, к нам приехал заказанный датчик — в вашем случае это может быть плата, заказанная из Китая. Теперь все программно-аппаратные компоненты у нас «на столе» и мы можем запустить пример для взаимодействия с устройством:
import time
from demo_lm75.lm75 import LM75A
from loguru import logger
def main():
dev = LM75A(address=0x48, bus_number=16)
for _i in range(30):
logger.info(f"{dev.read_temperature()=}")
time.sleep(1)
if __name__ == "__main__":
main()Убеждаемся, что с реальной железкой мы получили корректные данные.
Выводы
Использование эмуляции и интеграционного/юнит-тестирования позволяет с минимальной отладкой получить полностью рабочий API без реальной железки на столе. При этом юнит-тесты помогают вносить атомарные и контролируемые изменения в ходе командной разработки, что заметно снижает риск случайных ошибок.
Так мы улучшаем взаимодействие между командами тестирования и разработки за счет общего понимания, какие именно данные и метрики циркулируют в интерфейсах устройства.
Если у вас есть вопросы — пишите в комментариях, с удовольствием пообщаюсь.