Embedded HandbookПриложения
Все разделы › XIII. Другие языки и их сборщики
STM32F103, STM32F407 · уровень средний · 15 мин чтения
Открыть в интерактивном справочнике

115б. Последовательный порт со стороны компьютера: мосты, драйверы, терминалы, задержки

Раздел 16 устроил порт со стороны STM32: кадр, делитель, TXE и TC. Раздел 20.7 — виртуальный порт на USB самого камня, раздел 194 — сырой режим порта в Linux на языке C. Между проводом TX и строкой в программе на компьютере стоит ещё целая цепочка: мост или отладчик, драйвер, имя порта, терминал с его настройками. Здесь — кто в этой цепочке, как порт называется в Windows и Linux, почему плата перезагружается при открытии порта, откуда берутся задержки и почему порт USB CDC пропадает.

Опоры: исходники pyserial 3.5 из образа stm-pio:1 (list_ports_linux.py, serialposix.py, serialwin32.py, терминал miniterm.py), правила udev PlatformIO из того же образа, файлы INF драйверов из хранилища драйверов Windows этой машины (CH341SER.INF, stlink_vcp.inf, stmcdc.inf, usbser.inf), заголовки ядра Linux 6.1 из stm-sys:1, библиотека USB Device и пример CDC_Standalone из пакета STM32Cube F1 1.8.7. Опыты — на виртуальной паре портов (псевдотерминалы Linux в stm-arm:1) и с прошивкой в QEMU netduinoplus2 через -serial pty. Живых мостов и плат под рукой не было: всё, что зависит от железа, помечено.

Как это называется в документации

Оригинал Перевод
USB-to-UART bridge мост USB–UART, переходник
virtual COM port (VCP) виртуальный COM-порт
CDC ACM класс USB «последовательный порт»
DTR (Data Terminal Ready), RTS (Request To Send) сигналы управления: «терминал готов», «запрос на передачу»
local echo местное эхо: терминал сам показывает набранное
line ending: CR, LF, CRLF конец строки: 0x0D, 0x0A или оба подряд
SET_LINE_CODING, SET_CONTROL_LINE_STATE запросы CDC: формат линии; состояние DTR и RTS
round-trip latency круговая задержка: от отправки вопроса до ответа
exclusive access монопольный доступ к порту

115б.1 Кто стоит между прибором и программой

Путей три, и для программы они выглядят одинаково — как последовательный порт:

Два последних — класс CDC ACM, и драйвер у них один. stlink_vcp.inf и stmcdc.inf от ST не несут своего драйвера: оба лишь указывают на usbser.sys. А usbser.inf самой Windows (на этой машине — версия 10.0.26100) сам подхватывает любое устройство класса 02, подкласса 02 (USB\Class_02&SubClass_02) как «USB Serial Device». В ядре Linux это разные драйверы: для мостов — CONFIG_USB_SERIAL_CH341, _CP210X, _FTDI_SIO, _PL2303, для CDC — CONFIG_USB_ACM; в ядре Debian 6.1 все они собраны модулями.

Три пути от STM32 до программы: USART через мост USB–UART с драйвером чипа даёт /dev/ttyUSB0 или COMn; USART2 через ST-LINK на плате и USB самого STM32 с классом CDC идут через драйвер CDC ACM, usbser.sys в Windows, и дают /dev/ttyACM0 или COMn; при сбросе платы порт моста и отладчика остаётся, а порт USB CDC исчезает


115б.2 Имена портов: COMn, /dev/ttyUSB, /dev/ttyACM

Windows даёт порту номер COMn. pyserial перечисляет устройства классов «Ports» и «Modem» — все четыре INF выше ставят устройство в класс Ports. Для COM1…COM8 имя передаётся как есть, начиная с COM9 pyserial сам дописывает \\.\ — так требует Windows (serialwin32.py).

Linux называет порт по драйверу. pyserial ищет порты по шаблонам (list_ports_linux.py), главные из них:

Имя Что это
/dev/ttyS* встроенный порт
/dev/ttyUSB* мост со своим драйвером: CH340, CP210x, FT232, PL2303
/dev/ttyACM* класс CDC ACM: порт ST-LINK, USB CDC самого STM32
/dev/ttyAMA* встроенный порт ARM (Raspberry Pi)
/dev/rfcomm* порт поверх Bluetooth

Номер в имени — порядок подключения, а не разъём: второй переходник станет ttyUSB1. Постоянное имя дают символические ссылки udev в /dev/serial/by-id; list_ports.comports(include_links=True) добавляет к списку и ссылки на найденные порты. Открыть порт мешает чаще всего не имя, а права: порты принадлежат группе dialout (раздел 194.5).

Для USB-порта pyserial собирает описание из дескрипторов. hwid строится так (list_ports_common.py), для подставленных в опыте значений:

USB VID:PID=0483:374B SER=066DFF LOCATION=1-1.2:1.2

Выбирать порт по номеру — лотерея, по VID:PID — надёжно:

def find_port(vid, pid):
    """Первый порт с нужными VID:PID — имя COMn или /dev/tty… не важно."""
    for p in list_ports.comports():
        if (p.vid, p.pid) == (vid, pid):
            return p.device
    return None

На подставленном списке функция нашла /dev/ttyACM0 по 0483:374B, /dev/ttyUSB0 по 1a86:7523 и вернула None для FT232, которого в списке не было. Два одинаковых переходника различают по serial_number — если у моста он есть.

На Windows-машине, где писался раздел, pyserial нашёл 7 портов, COM3…COM10 — и ни одного кабеля: все семь — «Standard Serial over Bluetooth link», vid у них пустой. Порт «наугад» здесь почти наверняка чужой.


115б.3 Терминал: CR и LF, местное эхо, шестнадцатеричный вид

Терминал — программа, которая шлёт в порт нажатые клавиши и печатает принятое. У всех терминалов три одинаковые настройки, и все три ломают разговор с прибором. Опыт — miniterm из pyserial, настоящий терминал, которому клавиши «нажимались» через псевдотерминал. На том конце — прошивка в QEMU: она не повторяет принятое, а на строку отвечает ok <текст> rx=<байты строки>. Прошивка собрана в двух вариантах: конец строки только \n, как cdc_line в разделе 20.7, и «\r или \n, пустые строки пропустить», как консоль термостата в разделе 196.

Конец строки. Клавиша Enter уходит в порт как CR, LF или оба — по настройке терминала; у miniterm по умолчанию CRLF. Набрано get и Enter:

Терминал шлёт Прошивка «конец — LF» Прошивка «CR или LF»
CR молчит 67 65 74 0D
LF 67 65 74 0A 67 65 74 0A
CRLF 67 65 74 0D 0A 67 65 74 0D

Прошивке «только LF» терминал с настройкой CR не дождётся ответа никогда: строка так и не кончилась. Прошивка «CR или LF» отвечает на всё, а 0A после 0D принимает за пустую строку и пропускает. Отсюда правило: прибор принимает любой конец строки, а сам шлёт \r\n — его понимают все терминалы.

Местное эхо. Прибор принятое не повторяет, поэтому набранное get на экране не видно — только ответ ok get rx=67 65 74 0A. С ключом --echo терминал сам печатает набранное, и на экране get, а под ним ответ. Если прибор и сам отвечает эхом, а в терминале включено местное эхо, каждая буква удвоится.

Шестнадцатеричный вид. Для двоичного протокола текстовый вид бесполезен: 0x00, 0x0D и байты старше 0x7F на экране не различить. С ключом --encoding hexlify miniterm и принятое показывает, и набранное понимает в шестнадцатеричном виде: набрано 70 69 6E 67 0A — ушло ping и LF, на экране ответ 70 6F 6E 67 0D 0A, то есть pong\r\n. Для отладки есть и фильтр --filter debug: он подписывает каждую порцию [TX:…] и [RX:…].

Выход из miniterm — Ctrl+], меню — Ctrl+T.


115б.4 DTR и RTS: почему плата перезагружается при открытии порта

Кроме TX и RX у порта есть сигналы управления, оставшиеся от модемов: DTR — «терминал готов», RTS — «запрос на передачу». Мост выводит их на свои ножки. В USB CDC они приходят прибору запросом SET_CONTROL_LINE_STATE (0x22): бит 0 — DTR, бит 1 — RTS (заголовок cdc.h ядра Linux). В шаблоне ST usbd_cdc_if_template.c этот запрос пустой — «Add your code here»: прошивка может, например, не слать ничего, пока DTR не поднят, то есть пока порт никто не открыл.

Плата перезагружается при открытии порта, если DTR или RTS заведены на сброс, — так делают платы с автоматическим входом в загрузчик. А pyserial при открытии поднимает оба: в serialutil.py начальное состояние DTR и RTS — True, и open сразу выставляет его. В опыте перехвачен вызов, который pyserial делает при открытии:

Как открыли Вызов Состояние после
serial.Serial(port, 115200) TIOCMBIS, DTR — поднять DTR и RTS — True
open_quiet(port) TIOCMBIC, DTR — опустить DTR и RTS — False

На виртуальной паре проводов DTR нет: вызов возвращает ENOTTY, pyserial эту ошибку молча пропускает, и до RTS дело не доходит. На настоящем порту за DTR следует RTS. Чтобы не дёргать линии, их состояние задают до открытия:

def open_quiet(port, baud=115200):
    """Открыть порт, не поднимая DTR и RTS: плата с автосбросом не перезагрузится."""
    s = serial.Serial()
    s.port, s.baudrate, s.timeout = port, baud, 1
    s.dtr = False
    s.rts = False
    s.open()
    return s

В miniterm то же — ключи --dtr 0 --rts 0; по ходу работы линии переключают Ctrl+T Ctrl+D (DTR) и Ctrl+T Ctrl+R (RTS). Линии может трогать не только программа: в CH341SER.INF есть закомментированная строка, которая ставит на порт фильтр Windows serenum. По умолчанию WCH его не включает; что этот фильтр делает с DTR и RTS, здесь не проверено.


115б.5 Задержка: кадры USB, такт моста, ответ пачкой

Круговая задержка — от записи вопроса до прихода ответа. Сам UART здесь почти ни при чём: ответ pong\r\n — 6 байт, на 115 200 бод это полмиллисекунды. Время съедают звенья посередине:

Опыт — модель на виртуальной паре: «прибор» отвечает pong на ping мгновенно, но ответ уходит в порт только по такту, как у моста ST. Программа — pyserial, время — time.perf_counter:

def ask(s, cmd):
    """Команда и ответ одной строкой; время круга — по perf_counter."""
    t0 = time.perf_counter()
    s.write(cmd + b"\n")
    reply = s.readline()
    return reply, time.perf_counter() - t0
Модель Вопрос и ответ, медиана 200 обменов подряд 100 вопросов одной записью Чтений на 100 ответов
без такта 0,065 мс 14,255 мс 0,141 мс 1
такт 1 мс 0,998 мс 199,722 мс 0,999 мс 1
такт 5 мс 4,999 мс 999,735 мс 5,007 мс 1

Прошивка в QEMU отвечала на ping за 0,202 мс (медиана из 200). Это не USB: псевдотерминал и QEMU — самые быстрые звенья, какие бывают, и показывают только, что задержку добавляет такт, а не программа.

Вывод из таблицы: каждый обмен «вопрос — ответ» стоит целый такт. Ответ готов сразу, но ждёт ближайшего такта, а следующий вопрос уходит сразу после ответа — и снова ждёт полный такт. 200 вопросов подряд при такте 5 мс — секунда, а 100 вопросов одной записью — один такт, и все 100 ответов пришли одним чтением. Отсюда два правила:


115б.6 USB CDC: скорость «не важна», порт пропадает при сбросе

Скорость. У порта USB CDC самого STM32 нет UART — байты идут пакетами USB до 64 байт (CDC_DATA_FS_MAX_PACKET_SIZE, раздел 20.7), и число бод ни на что не влияет. Виртуальная пара ведёт себя так же: 1000 байт туда и обратно при «9600» — 0,109 мс, при «115200» — 0,106 мс. Настоящий UART на 9600 бод вёз бы их в одну сторону 1041,667 мс.

Выставленная программой скорость всё же доходит до прибора — запросом SET_LINE_CODING (0x20): 7 байт, скорость 4 байтами, затем стоп-биты, чётность и число бит (struct usb_cdc_line_coding в заголовке ядра). В шаблоне ST скорость просто сохраняется. А в примере CDC_Standalone, где STM32 работает мостом, по этому запросу перенастраивается UART — у моста скорость важна, у «чистого» CDC нет.

Порт пропадает при сбросе. Мост и ST-LINK — отдельные микросхемы, и их порт живёт, пока плата перезагружается. Порт USB CDC — это прошивка: сброс, перепрошивка, HardFault — и устройство уходит с шины, а порт исчезает. Программа, державшая его открытым, получает ошибку. В опыте «прибор» на виртуальной паре остановлен посреди чтения, и pyserial ответил:

device reports readiness to read but returned no data (device disconnected or multiple access on port?)

Вернуться прибор может под другим именем: если старое ещё занято (программа не закрыла порт), новому дают следующее — ttyACM1 вместо ttyACM0. Поэтому программу пишут так, чтобы она открывала порт заново по постоянному имени — ссылке из /dev/serial/by-id или по VID:PID через find_port:

def read_lines(port, handle):
    """Читать строки; порт пропал — подождать и открыть заново по тому же имени."""
    while True:
        try:
            with serial.Serial(port, 115200, timeout=1) as s:
                for line in iter(s.readline, None):
                    if line and handle(line) is False:
                        return
        except serial.SerialException as e:
            print("порт пропал:", e)
            time.sleep(0.5)

В опыте прибор слал строку каждые 20 мс и был остановлен; старое имя в это время держала другая программа, и новый прибор получил другое /dev/pts/N, но ссылка осталась той же. read_lines получила 15 строк от первого прибора, одну ошибку и ещё 15 строк от второго.


115б.7 Грабли

Порт открыт, а байты приходят через раз. Порт держит ещё одна программа: второй терминал, монитор порта в среде, забытый скрипт. В Linux открытие не монопольное, и две программы делят поток как придётся. В опыте прибор выдал 1800 байт двум открытым pyserial: одна получила 841 байт, другая 951, восемь байт не досталось никому, у обеих — по 60 с лишним ошибок с той же подсказкой «multiple access on port?», что и при пропавшем приборе. Кто держит порт, покажет fuser или обход /proc:

def who_holds(dev):
    """Кто держит порт открытым — то же делает fuser: обход /proc/*/fd."""
    real, pids = os.path.realpath(dev), []
    for pid in filter(str.isdigit, os.listdir("/proc")):
        try:
            fds = os.listdir("/proc/%s/fd" % pid)
            if any(os.path.realpath("/proc/%s/fd/%s" % (pid, fd)) == real for fd in fds):
                pids.append(int(pid))
        except OSError:
            pass
    return pids

exclusive=True не защитил. pyserial ставит на порт блокировку flock: второй Serial(port, exclusive=True) в опыте упал с «Could not exclusively lock port», а третий, открытый без этого ключа, спокойно открылся. Блокировка работает только между программами, которые её спрашивают; miniterm, например, спрашивает по умолчанию.

Порт захватывает служба. Кроме своих программ, новый порт в Linux могут открыть системные службы. Правила udev PlatformIO ставят мостам CH340, CP210x, FT232 и PL2303, порту STM32 0483:5740 и всем устройствам ST метку ID_MM_DEVICE_IGNORE — «не трогать» для службы модемов. Второй вид захвата — консоль и служба входа на порту (раздел 194.5).

Windows: порт не открывается, «Отказано в доступе». Порт уже открыт. В Windows открытие монопольное: pyserial передаёт CreateFile нулевой режим совместного доступа. Закрыть терминал или монитор порта в среде, потом открывать.

COM-портов много, а платы среди них нет. На машине автора семь портов, и все — Bluetooth. Искать по VID:PID (find_port), а не брать первый.

Плата перезагружается при каждом открытии. DTR или RTS заведены на сброс, а pyserial их поднимает. Задать dtr и rts до open (open_quiet) или --dtr 0 --rts 0 в miniterm.

Команда ушла, ответа нет. Терминал шлёт CR, а прошивка ждёт LF. Прошивка должна принимать и то и другое, как консоль в разделе 196.

Не видно, что печатаешь, или каждая буква дважды. Местное эхо выключено при молчащем приборе — или включено при приборе с эхом.

Опрос из 200 вопросов идёт секунду. Каждый обмен стоит такт моста или кадр USB. Спрашивать пачкой, слать телеметрию потоком.

После перепрошивки программа падает с ошибкой порта. Порт USB CDC исчез вместе с прибором и вернулся, возможно, под другим именем. Переоткрывать по постоянному имени (read_lines).

«Permission denied» в Linux. Пользователь не в группе dialout (раздел 194.5).


115б.8 Итоги

Дальше: раздел 115в «Python рядом с прибором: порт, байты, журнал, скрипт-испытатель» — на pyserial пишется испытатель, который гоняет прибор по сценарию и ведёт журнал обмена.

Открыть в интерактивном справочнике