Embedded HandbookПриложения
Все разделы › XVI. Печатная плата
STM32F101 · уровень средний · 13 мин чтения
Открыть в интерактивном справочнике

133. Altium Designer: проект, правила, выпуск

Altium Designer — самая крупная из трёх программ: схема, плата, библиотеки, выпуск документации и файлов для завода живут в одном проекте. На машине — Altium Designer Agile 26.0.0.14.

Здесь разобран проект из комплекта примеров — Bluetooth Sentinel с микроконтроллером STM32F101 — и разобран без самого Altium: проект, правила и задание на выпуск прочитаны Python'ом, а плату для сверки открыл KiCad. Так смотрят чужой проект, когда Altium под рукой нет, — и так проверяют, что проект выдержит перенос в другую программу.

Когда Altium под рукой, то же самое находят в меню редактора платы (названия — из установленной версии 26):

Что Где в Altium
правила (133.3) Design / Rules
стек слоёв (133.4) Design / Layer Stack Manager
проверка правил Tools / Design Rule Check
выпуск по заданию (133.5) открыть .OutJob, у контейнера выходов — Generate content
чужой проект из другой программы File / Import Wizard

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

Оригинал Перевод
PCB project (.PrjPcb) проект платы
schematic document (.SchDoc) лист схемы
PCB document (.PcbDoc) документ платы
Output Job (.OutJob) задание на выпуск
design rule правило проектирования
query, scope запрос: к каким объектам относится правило
layer stack стек слоёв
overlay шелкография
solder mask, paste mask маска, трафарет пасты
NC Drill файл сверловки
device sheet готовый лист-блок из библиотеки
compound file составной документ: файловая система внутри файла

133.1 Проект — текстовый файл

.PrjPcb — текст в формате INI: разделы в квадратных скобках, строки «ключ=значение». В примере:

Раздел Сколько Что это
[DocumentN] 12 документы проекта
[DeviceSheetN] 17 готовые листы-блоки из библиотеки
[ConfigurationN] 3 конфигурации выпуска: Documentation, Assembly, Fabrication
[OutputGroupN] 10 группы выходных файлов

Документов 12: пять листов схемы .SchDoc, одна плата .PcbDoc, три задания на выпуск .OutJob, два .Harness и один .Annotation. Проект удобно хранить в git: он текстовый, и правка в нём видна строкой.

Проект Altium: текстовый проект ссылается на листы схемы, плату и задания на выпуск; плату для сверки открывает KiCad

Ловушка — в первых трёх байтах: файл начинается с метки порядка байтов UTF-8, и стандартный разбор INI в Python на ней спотыкается. Читать надо в кодировке utf-8-sig:

def ini(path):
    cp = configparser.ConfigParser(interpolation=None, strict=False)
    cp.optionxform = str
    cp.read(path, encoding="utf-8-sig")          # файл начинается с метки порядка байтов UTF-8
    return cp

133.2 Схема и плата — составные документы

.SchDoc и .PcbDoc начинаются с одних и тех же восьми байтов D0 CF 11 E0 A1 B1 1A E1 — это составной документ OLE, файловая система внутри файла, как у старых документов Word. У платы внутри 47 потоков данных — по одному на вид объектов: Rules6/Data — правила, Components6/Data — компоненты, Nets6/Data — цепи, Board6/Data — настройки и стек слоёв, Tracks6/Data — дорожки.

В потоках правил, компонентов, цепей и схемы — текстовые записи вида |КЛЮЧ=значение|…, каждая оканчивается нулевым байтом. Вытащить их — десять строк:

def kv(rec):
    d = {}
    for part in rec.split("|"):
        if "=" in part:
            k, v = part.split("=", 1)
            d.setdefault(k.upper(), v)
    return d


def stream(ole, name):
    s = ole.openstream(name).read().decode("latin-1")
    return [kv(m.group(0)) for m in re.finditer(r"\|[^\x00]+", s)]

Открывает составной документ пакет olefile; здесь он в образе stm-ole:1.

Лист схемы с микроконтроллером начинается записью-заголовком «Protel for Windows - Schematic Capture Binary File Version 5.0», за ней 948 записей. Вид записи — число RECORD, а что за объект, видно по ключам:

RECORD Сколько Ключи Что это
1 16 LIBREFERENCE, PARTCOUNT компонент
2 130 NAME, DESIGNATOR, ELECTRICAL вывод
27 85 LOCATIONCOUNT, X1, Y1 провод
25 53 TEXT метка цепи
34 16 TEXT, NAME обозначение: U2, C15, R9 …
41 331 NAME, TEXT параметр

Микроконтроллер — MC-ARM-STM32F101-SQ37: в описании «36-Pin VFQFPN» и «32 kB Flash, 6 kB Internal RAM». На листе он нарисован двумя частями — обозначение U2 встречается дважды. На плате у него посадочное место VFQFPN36_N.


133.3 Правила платы

Правил в примере 38. У каждого — имя, вид (RULEKIND), приоритет и область действия. Правила с числами:

Правило В файле В миллиметрах
Clearance — зазор 3.937mil 0,1
Width — ширина дорожки 3.937–19.685mil, предпочтительная 3.937mil 0,1–0,5, предпочтительная 0,1
RoutingVias — переходное отверстие 19.685mil, отверстие 11.811mil 0,5, отверстие 0,3
HoleToHoleClearance — между отверстиями 3.937mil 0,1
ComponentClearance — между корпусами 7.874mil 0,2
DiffPairsRouting — дифференциальная пара 10mil 0,254

Длины Altium хранит в милах, даже когда плату разводили в миллиметрах: 3,937 мил — это 0,1 мм, записанные в другой единице. Перевод — мил × 0,0254.

Область действия задаётся запросом. Общие правила относятся ко всем объектам (All), частные — к выбранным: (CompPinCount < 5) — корпуса меньше чем с пятью выводами, IsSOIC, IsBGA, IsVia, InComponentClass('PCBModule'). Среди правил одного вида действует старшее — с меньшим числом приоритета: у разводки выводов частные правила стоят с приоритетами 1–4, общее Fanout_Default — с приоритетом 5.

Пять правил FanoutControl примера: для вывода SOIC-корпуса совпадают запросы IsSOIC с приоритетом 3 и All с приоритетом 5 — действует Fanout_SOIC, старшее из совпавших


133.4 Стек слоёв

Плата четырёхслойная. Стек из потока Board6/Data:

Слой Толщина εr
Top Solder — маска 0,3937 мил = 0,01 мм 3,5
Top Layer — медь 1,4173 мил = 0,036 мм
Dielectric 1 12,5984 мил = 0,32 мм 4,8
Mid-Layer 1 — медь 0,036 мм
Dielectric 2 0,32 мм 4,8
Mid-Layer 2 — медь 0,036 мм
Dielectric 3 0,4921 мил = 0,0125 мм 4,8
Bottom Layer — медь 0,036 мм
Bottom Solder — маска 0,01 мм 3,5

Стек примера несимметричный: третий диэлектрик всего 12,5 мкм. Для заказа так не делают — стек берут из стандартных у завода, симметричный относительно середины платы, иначе её выгнет при пайке.

Сумма — 32,1455 мил, 0,8165 мм:

    out["thickness_mm"] = round(sum((l["copper_mil"] or 0) + (l["diel_mil"] or 0) for l in stack) * MIL, 4)

KiCad, открыв эту плату, получил ту же толщину — 0,8165 мм (133.6).

Стек платы примера в масштабе: четыре медных слоя по 0,036 мм, диэлектрики 0,32, 0,32 и 0,0125 мм, маски по 0,01 мм — всего 32,1455 мил = 0,8165 мм


133.5 Выпуск: задание .OutJob

Файлы для завода Altium выпускает по заданию — .OutJob, тоже текст INI. В Fabrication.OutJob примера три выхода: ODB++, Gerber и NC Drill. Настройки, которые должен знать завод:

Выход Настройка Значение
Gerber единицы Imperial — дюймы
знаков после запятой 5
нули SuppressLeadingZeroes — ведущие опущены
NC Drill единицы Imperial — дюймы
формат 2 знака до запятой, 5 после — «2:5»
нули SuppressTrailingZeroes — хвостовые опущены
металлизированные отдельно False — все отверстия в одном файле

Если завод прочитает дюймы как миллиметры или возьмёт не тот формат, отверстия уйдут мимо площадок. Поэтому единицы и формат сверловки пишут ещё и в сопроводительном письме.

Сам проект знает больше выходов, чем использует это задание: в группе Fabrication Outputs их 12 — среди них Gerber X2 и IPC-2581, в группе Netlist Outputs — 21 формат списка соединений, в Export Outputs — 11, от STEP до Specctra.


133.6 Проверка чужого проекта без Altium

Скрипт altium_read.py читает проект целиком — документы, правила, стек, компоненты, цепи, лист с микроконтроллером, задание на выпуск — и отдаёт JSON. Для сверки ту же плату открывает KiCad 9: у него есть импорт Altium.

С первой попытки KiCad плату не открыл: LoadBoard вернул пустой результат — по файлу формат не угадывается. Формат надо назвать:

b = pcbnew.LoadBoard(path, pcbnew.PCB_IO_MGR.ALTIUM_DESIGNER)
Что Разбор файла KiCad после импорта
компонентов 73 73
цепей 57 57 и служебная «без цепи»
медных слоёв 4 4
толщина 0,8165 мм 0,8165 мм
зазор, наименьший 0,1 мм 0,1 мм
дорожка, наименьшая 0,1 мм 0,1 мм
переходное, наименьшее 0,5 мм, отверстие 0,3 мм 0,5 мм, отверстие 0,3 мм
между отверстиями 0,1 мм 0,1 мм

Два независимых чтения сошлись. Но перенос не бесплатен: 28 слоёв Altium KiCad сопоставить не смог и пропустил, три шрифта заменил своими. А классы цепей — All Nets из Altium и свой Default — получили настройки самого KiCad: 0,2 мм зазор и 0,2 мм дорожка, а не 0,1 мм из правил примера. Минимумы платы перенеслись, классы — нет; после импорта их задают заново.


133.7 Правила примера против профиля из раздела 130

Профиль из раздела 130 — правила, с которыми KiCad создаёт новую плату. Правила примера тоньше в четырёх местах:

Правило Пример Профиль 130
зазор 0,1 мм 0,2 мм
дорожка 0,1 мм 0,2 мм
переходное 0,5 мм 0,6 мм
между отверстиями 0,1 мм 0,25 мм

Это не ошибка примера: на плате мелкие корпуса — посадочные места VFQFPN36_N, CSR-CSR1010A05-IQQM-R-QFN32_M, LGA16-3X3X1_L. Но это требование к заводу: такую плату заказывают там, где 0,1 мм — обычная возможность, а не предел. Сверяют до разводки, не после.


133.8 Грабли

Симптом Причина
configparser: «File contains no section headers» на .PrjPcb файл начинается с метки порядка байтов UTF-8 — читать utf-8-sig
в правилах «3.937mil» вместо 0,1 мм Altium хранит длины в милах, даже у метрической платы
KiCad не открывает .PcbDoc, LoadBoard вернул пустое формат не угадан — указать PCB_IO_MGR.ALTIUM_DESIGNER
после импорта в KiCad пропали слои 28 слоёв не сопоставлены — сверять стек после импорта
классы цепей в KiCad — 0,2 мм, а в Altium было 0,1 в классы перешли настройки KiCad, не правила Altium
завод сверлит мимо формат сверловки 2:5 в дюймах не передан заводу
завод не берёт заказ правила тоньше его возможностей — сверять до разводки

133.9 Итоги

Полные исходники

altium_read.py — чтение проекта Altium без Altium: документы, правила, стек, выпуск.

# -*- coding: utf-8 -*-
"""Раздел 133: что лежит в проекте Altium Designer — без самого Altium. Запуск в образе stm-ole:1.

python altium_read.py <папка проекта> <имя>.PrjPcb     → JSON в stdout

Проект .PrjPcb и задание на выпуск .OutJob — текст в формате INI. Схема .SchDoc и плата .PcbDoc —
составные документы OLE: внутри потоки по видам объектов (Rules6/Data, Components6/Data, …),
в потоках — текстовые записи «|КЛЮЧ=значение|…», каждая оканчивается нулевым байтом. Длины
в записях — с единицами: «3.937mil»."""
import configparser, json, os, re, sys
import olefile

MIL = 0.0254                                     # мм в одном миле


def kv(rec):
    d = {}
    for part in rec.split("|"):
        if "=" in part:
            k, v = part.split("=", 1)
            d.setdefault(k.upper(), v)
    return d


def stream(ole, name):
    s = ole.openstream(name).read().decode("latin-1")
    return [kv(m.group(0)) for m in re.finditer(r"\|[^\x00]+", s)]


def mil(v):
    m = re.fullmatch(r"(-?[\d.]+)mil", (v or "").strip())
    return float(m.group(1)) if m else None


def ini(path):
    cp = configparser.ConfigParser(interpolation=None, strict=False)
    cp.optionxform = str
    cp.read(path, encoding="utf-8-sig")          # файл начинается с метки порядка байтов UTF-8
    return cp


def sections(cp, prefix):
    return [cp[s] for s in cp.sections() if re.fullmatch(prefix + r"\d+", s)]


def main(folder, prj):
    cp = ini(os.path.join(folder, prj))
    docs = [s["DocumentPath"] for s in sections(cp, "Document")]
    out = {"documents": docs,
           "device_sheets": len(sections(cp, "DeviceSheet")),
           "configurations": [s["Name"] for s in sections(cp, "Configuration")],
           "output_groups": {s.get("Name"): sum(1 for k in s if re.fullmatch(r"OutputType\d+", k))
                             for s in sections(cp, "OutputGroup")}}

    # ── плата ──
    pcb = os.path.join(folder, next(d for d in docs if d.lower().endswith(".pcbdoc")))
    out["pcb_magic"] = open(pcb, "rb").read(8).hex()
    ole = olefile.OleFileIO(pcb)
    out["pcb_streams"] = len([s for s in ole.listdir() if s[-1] == "Data"])
    rules = []
    for r in stream(ole, "Rules6/Data"):
        if "RULEKIND" in r:
            rules.append({"name": r.get("NAME"), "kind": r["RULEKIND"], "priority": int(r.get("PRIORITY", "0")),
                          "scope1": r.get("SCOPE1EXPRESSION"), "scope2": r.get("SCOPE2EXPRESSION"),
                          **{k.lower(): mil(r[k]) for k in ("GAP", "MINLIMIT", "MAXLIMIT", "PREFEREDWIDTH", "MINWIDTH",
                                                              "MAXWIDTH", "MINHOLEWIDTH", "MAXHOLEWIDTH") if k in r}})
    out["rules"] = rules
    board = stream(ole, "Board6/Data")[0]
    stack, i = [], 0
    while "V9_STACK_LAYER%d_NAME" % i in board:
        p = "V9_STACK_LAYER%d_" % i
        stack.append({"name": board[p + "NAME"], "copper_mil": mil(board.get(p + "COPTHICK")),
                      "diel_mil": mil(board.get(p + "DIELHEIGHT")), "er": float(board.get(p + "DIELCONST") or 0) or None})
        i += 1
    out["stack"] = stack
    out["copper_layers"] = [l["name"] for l in stack if l["copper_mil"]]
    out["thickness_mm"] = round(sum((l["copper_mil"] or 0) + (l["diel_mil"] or 0) for l in stack) * MIL, 4)
    comps = [c for c in stream(ole, "Components6/Data") if "PATTERN" in c]
    out["components"] = len(comps)
    out["patterns"] = sorted({c["PATTERN"] for c in comps})
    out["nets"] = len([n for n in stream(ole, "Nets6/Data") if "NAME" in n])

    # ── лист схемы с микроконтроллером ──
    for d in docs:
        if not d.lower().endswith(".schdoc"):
            continue
        recs = stream(olefile.OleFileIO(os.path.join(folder, d)), "FileHeader")
        mcu = [r for r in recs if r.get("RECORD") == "1" and "STM32" in r.get("LIBREFERENCE", "")]
        if not mcu:
            continue
        kinds = {}
        for r in recs[1:]:
            kinds[r.get("RECORD")] = kinds.get(r.get("RECORD"), 0) + 1
        out["sheet"] = {"file": d, "header": recs[0].get("HEADER"), "records": len(recs) - 1,
                        "kinds": {k: kinds.get(k, 0) for k in ("1", "2", "25", "27", "34", "41")},
                        "keys": {k: sorted(next(r for r in recs if r.get("RECORD") == k)) for k in ("1", "2", "25", "27", "34", "41")},
                        "magic": open(os.path.join(folder, d), "rb").read(8).hex(),
                        "mcu": mcu[0]["LIBREFERENCE"], "mcu_parts_drawn": len(mcu),
                        "mcu_description": mcu[0].get("COMPONENTDESCRIPTION", ""),
                        "designators": sorted(r.get("TEXT") for r in recs if r.get("RECORD") == "34")}

    # ── выпуск для завода ──
    oj = ini(os.path.join(folder, "Fabrication.OutJob"))
    g = oj["OutputGroup1"]
    outs = []
    for i in range(1, 20):
        t = g.get("OutputType%d" % i)
        if t:
            item = dict(p.split("=", 1) for p in g.get("Configuration%d_Item1" % i, "").split("|") if "=" in p)
            outs.append({"type": t, "name": g.get("OutputName%d" % i), "settings": item})
    out["fabrication"] = outs
    json.dump(out, sys.stdout, ensure_ascii=False, indent=1)


if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])

Дальше: раздел 134 — другие программы и перенос плат между ними.

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