Чтение и запись рулевой рейки BMW с ЭУР (EPS) по FlexRay
Небольшой агент под MPC5643L, работающий прямо в рейке: читает и пишет флеш и EEPROM рейки по FlexRay — без JTAG и без вскрытия корпуса.
Заметки со стенда, со стороны ремонта. Всё описанное выполнялось на снятых с машины узлах на моём собственном стенде — как работа по восстановлению и диагностике, и всё это получено из прошивки: образ открыт в Ghidra и дизассемблирован, выверен по моим собственным дампам и по большому объёму известного открытого текста. Заводской защищённый доступ (security access) OEM я не публикую — это не моё, и в агенте его всё равно нет.
Коротко
Блок управления рулевым управлением у заметной части модельного ряда BMW F-серии (внутренний тег загрузчика 000019EE, далее «19EE») построен на NXP MPC5643L — двухъядерном PowerPC с режимом lockstep и системой команд VLE, а его калибровочные данные разделены между встроенной флеш C90FL и внешней SPI EEPROM M95640. Заводское ПО общается с ним по FlexRay.
Я хотел читать и писать обе памяти на стенде, по FlexRay, не подключая JTAG-пробник к каждому отдельному узлу. Путь к этому — крошечный автономный агент, загружаемый в SRAM рейки по FlexRay через механизм загрузки, который я откопал в прошивке с помощью Ghidra; агент отвечает на операции с памятью, отправляя данные обратно на мой кабель. Этот материал разбирает агент: обвязку FlexRay, драйверы EEPROM и флеш, а также один протокольный приём, который вылечил нестабильное чтение.

стенд. Блок MS561 для ЭУР (питание и диагностика), кабель FlexRay, рейка 19EE, ThinkPad с запущенным MS561 и осциллограф сверху.
Объект
MPC5643L — это MCU с упором на функциональную безопасность: два ядра e200z4 в режиме lockstep, ECC повсюду и встроенный на кристалл FlexRay-контроллер E-Ray от Freescale/NXP. Цифры, которые важны для всего дальнейшего:
| Что | Где |
|---|---|
| Ядро | e200z4, PowerPC VLE (переменная длина команд), big-endian |
| Флеш кода | контроллер C90FL по адресу 0xC3F88000, флеш отображена на базовый адрес 0x0 |
| «block0» | 0x0000–0x3FFF (16 КБ) — калибровка + конфигурационная CRC-32 |
| EEPROM | внешняя M95640 (64 Кбит, SPI) на DSPI_B по адресу 0xFFF94000 |
| FlexRay | контроллер связи (CC) E-Ray от NXP по адресу 0xFFFE0000, message RAM в системной SRAM |
Один подвох с VLE сразу: большинство штатных сборок objdump не декодируют VLE — binutils образца 2005 года выдаёт мусор. Статический анализ я делал в Ghidra с языком PowerPC:BE:64:VLE-32addr. Ошибётесь с режимом — и весь образ декодируется в бессмыслицу.

чип. Основной кристалл рейки — SPC5643LFMLQ1, тот самый MPC5643L — а слева от него маленькая 8-выводная SPI EEPROM M95640.
Почему агент, а не «просто JTAG по всему»
Долгое время единственным способом добраться до этих данных была грубая «хирургия»: вскрыть корпус блока управления, выпаять EEPROM, прочитать и перепрограммировать её на дешёвом внешнем программаторе, а потом впаять обратно. Способ работает — многие мастерские до сих пор так и делают, — но он медленный и капризный, и каждый такой цикл — очередной шанс оторвать контактную площадку или перегреть микросхему. JTAG аккуратнее, и именно его я использовал на этапе реверс-инжиниринга, но он всё равно означает вскрытие герметичного корпуса ради доступа к отладочным площадкам. Ни то, ни другое не хочется повторять с узлом за узлом на стенде.

этап RE. Как я читал её при реверс-инжиниринге: корпус вскрыт, JTAG-программатор P&E на отладочном разъёме рейки. Для лаборатории — нормально, но повторять с каждым узлом не хочется.
Агент избавляет от всего этого. Он заходит внутрь по FlexRay — по шине, которая и так выведена на разъём, — так что узел остаётся закрытым: не нужно вскрывать корпус, выпаивать микросхему и отрывать площадки.
Копаясь в прошивке в Ghidra, я нашёл, что механизм уже встроен: в загрузчике есть путь, который затягивает небольшую программу в свободную SRAM рейки по FlexRay и запускает её. Поэтому я написал собственный агент под этот путь, задача которого проста:
опрашивать слот FlexRay на предмет команд, выполнять операцию с памятью и отвечать в другом слоте.
Ни libc, ни RTOS, ни зависимостей. Указатель стека и точку входа он получает от загрузчика, выдаёт сигнатуру «я жив», чтобы я мог убедиться, что работает именно мой код, и затем крутится в цикле обработки команд.
Шаг первый: разблокировка инженерного режима
Прежде чем всё это заработает, рейку нужно перевести в её инженерный режим, а он закрыт защищённым доступом UDS (security access). Последовательность — обычный UDS:
- DiagnosticSessionControl в расширенную сессию (
10 03), затем в инженерную сессию (10 42). - SecurityAccess — уровень «13/14»: запрос seed (
27 13), рейка возвращает 8-байтовый seed; вычисляем ответ; отправляем ключ (27 14). Ключ — это 4-байтовое поле длины, за которым следует 128-байтовая подпись. - RoutineControl Start (
31 01 03 0C) — именно это и взводит инженерный режим. Процедуру 030C я зафиксировал в Ghidra; её диспетчер RoutineControl находится по адресу0x635a0.
Единственное, что я оставляю при себе, — средний шаг: как формируется эта подпись и какой за ней стоит ключ. Это остаётся на моём стенде. Как её «варит» BMW? Идите спрашивайте у них. 😉
Шаг второй: как доставить агент в чип
В прошивке есть загрузчик, который принимает кусок кода в свободную SRAM по FlexRay и запускает его. Я разобрал его в Ghidra:
- Команда SETUP (
00 03) переводит приёмную задачу загрузчика (задача RTOS; её диспетчер по адресу0x80000, таблица обработчиков по0x40009098) в режим приёма; рейка отвечает подтверждением (00 08 51). - Приёмник кадров по адресу
0x8c664копирует входящие данные в SRAM только если признак состояния по адресу0x7e40взведён в0xFE/0xFF. Иначе каждый кадр молча отбрасывается. - Взводит этот признак сам вход в инженерный режим: сессия UDS устанавливает байт состояния (
0x7944) в1, RoutineControl030Cотрабатывает свой обработчик, а процедура входа по адресу0x81010записывает0x7e40 = 0xFEи запускает задачу.
Подвох, стоивший мне времени: SETUP принимается, подтверждение возвращается, но пакет всё равно не «долетает». Дамп RAM рейки (ICDPPCNEXUS Hotsync, без сброса) показал 0x7e40 = 0 — приёмная задача не была взведена, поэтому каждый кадр отваливался. Слать быстрее не помогает: приёмник должен быть взведён и оставаться взведённым к моменту прихода потока, так что порядок такой: взвести 030C, дождаться подъёма кластера FlexRay, затем выдать пакет. Пока признак удерживается, код попадает в SRAM, запускается, и агент заявляет о себе своим кадром «я жив» (00 4C 8A A0 A1 … BF). Без JTAG, корпус закрыт.
FlexRay глазами E-Ray
Всё проходит через два буфера сообщений FlexRay (message buffers, MB): один агент слушает, в другом отвечает. Message RAM контроллера E-Ray — это массив заголовков плюс область данных, а конфигурация буфера лежит в регистрах по адресу ERAY_BASE + 0x100 + idx*8.

на линии. Кабель снимает линию FlexRay RX рейки, пока я разбирался с форматом кадров — здесь запись в реальном времени на ~580 тыс. фронтов/с при частоте дискретизации 275 МГц.
eng_agent.c — карта буферов сообщений E-Ray
#define ERAY_BASE 0xFFFE0000u /* E-Ray CC; MVR @ +0 reads 0xA268 */
#define FR_MEMBASE 0x40002A00u /* message RAM base = SYMBADHR:SYMBADLR */
#define MBCCSR(i) (*(volatile u16 *)(ERAY_BASE + 0x100u + (u32)(i)*8u + 0u))
#define MBCCFR(i) (*(volatile u16 *)(ERAY_BASE + 0x100u + (u32)(i)*8u + 2u))
#define MBFIDR(i) (*(volatile u16 *)(ERAY_BASE + 0x100u + (u32)(i)*8u + 4u))
#define MBIDXR(i) (*(volatile u16 *)(ERAY_BASE + 0x100u + (u32)(i)*8u + 6u))
#define MEM16(off) (*(volatile u16 *)(FR_MEMBASE + (u32)(off)*2u))
/* MBCCSR bits */
#define MB_MTD 0x1000u /* transmit direction */
#define MB_CMT 0x0800u /* commit (TX) */
#define MB_LCKT 0x0200u /* lock toggle */
#define MB_DVAL 0x0008u /* data valid (RX) */
#define MB_LCKS 0x0002u /* locked status */
#define MB_MBIF 0x0001u /* interrupt flag */
#define RX_MB 2u /* I listen on slot 3 (frameID 3 = MB index 2) */
#define TX_MB 0u /* I answer on slot 1 (frameID 1 = MB index 0) */
#define KEEP 0xF900u /* config bits I must preserve on every CSR write */
Чтобы обратиться к буферу, его нужно заблокировать (переключить LCKT и убедиться, что LCKS вернулся установленным). Вот что меня здесь укусило:
Передающий MB аппаратно повторно передаёт своё содержимое в каждом цикле FlexRay. Пока контроллер копирует буфер на шину, блокировка принадлежит ему, и одна попытка «переключил и надеюсь» проигрывает гонку чаще, чем хотелось бы.
Наивная одноразовая блокировка молча возвращает «не получил», ответ так и не уходит, дальний конец видит, что блок замолчал, и чтение обрывается или ползёт, пока другая сторона снова и снова переспрашивает. Решение — крутиться в цикле, пока контроллер не отдаст буфер: свободное окно есть в каждом цикле. Повторять безопасно: отклонённая запись LCKT просто игнорируется (статус не меняется), так что состояние не испортишь, и ты останавливаешься в тот же миг, когда LCKS считывается установленным, — поэтому никогда не снимешь блокировку, которую уже держишь.
mb_lock — крутимся, пока CC не отдаст буфер
/* Lock a MB. Return 1 if locked, 0 if the CC never yielded it.
The TX MB retransmits every cycle and the CC owns it while copying out,
so a single LCKT write often loses the race. Spin until a free window. */
static int mb_lock(u32 i)
{
u32 spin;
for (spin = 0; spin < 200000u; spin++) {
MBCCSR(i) = (u16)((MBCCSR(i) & KEEP) | MB_LCKT);
if (MBCCSR(i) & MB_LCKS) return 1;
}
return 0;
}
static void mb_unlock(u32 i) { MBCCSR(i) = (u16)((MBCCSR(i) & KEEP) | MB_LCKT); }
static void mb_clrflag(u32 i) { MBCCSR(i) = (u16)((MBCCSR(i) & KEEP) | MB_MBIF); }
Тогда опрос команды выглядит так: заблокировать, проверить DVAL (новый кадр?), прочитать заголовок, чтобы узнать, где лежит полезная нагрузка и какой она длины, скопировать её, разблокировать. Отправка ответа — зеркальное отражение.
rx_poll / tx_send — одна команда на вход, один ответ на выход
/* Poll the RX slot. On a new frame copy up to maxw words into dst, return #words. */
static int rx_poll(u16 *dst, int maxw)
{
u16 idx, hdr, dataoff, len, p;
if (!mb_lock(RX_MB)) return 0;
if (!(MBCCSR(RX_MB) & MB_DVAL)) { mb_unlock(RX_MB); return 0; }
mb_clrflag(RX_MB);
idx = MBIDXR(RX_MB);
hdr = (u16)(idx * 5u); /* header = idx*5 halfwords */
dataoff = (u16)(MEM16(hdr + 3) / 2u); /* header[3] = data byte offset */
len = (u16)(MEM16(hdr + 1) & 0x7Fu); /* header[1] low 7 bits = words */
if (len > maxw) len = (u16)maxw;
for (p = 0; p < len; p++) dst[p] = MEM16(dataoff + p);
mb_unlock(RX_MB);
return (int)len;
}
/* Write the response words into the TX slot and commit. */
static void tx_send(const u16 *src, int words)
{
u16 idx, hdr, dataoff; int p;
if (!mb_lock(TX_MB)) return; /* CC busy: skip; the far side re-asks (robust) */
mb_clrflag(TX_MB);
idx = MBIDXR(TX_MB);
hdr = (u16)(idx * 5u);
dataoff = (u16)(MEM16(hdr + 3) / 2u);
for (p = 0; p < words; p++) MEM16(dataoff + p) = src[p];
MBCCSR(TX_MB) = (u16)((MBCCSR(TX_MB) & KEEP) | MB_CMT); /* commit */
mb_unlock(TX_MB);
mb_clrflag(TX_MB);
}
Приём, сделавший чтение надёжным: эхо в ответе
У поведения «повторяет каждый цикл» есть и второе следствие. Поскольку TX-буфер продолжает выкладывать своё последнее содержимое на шину, приёмник может защёлкнуть устаревшую копию предыдущего ответа — особенно при первом чтении после смены состояния: ты оказываешься на один запрос позади, спрашиваешь адрес N, а получаешь байты для N-1, и выглядит это почти правильно — ровно до того момента, когда становится совсем неправильно.
Задержки на установление и повторы это маскируют, но это гонка, и это медленно. Детерминированное решение — сделать каждый ответ самоидентифицирующимся. Агент вставляет в кадр два дополнительных слова: эхо-маркер (запрошенный адрес либо фиксированное контрольное значение для записи) и магическую метку ответа. После этого кабель отвергает любой ответ, эхо которого не совпадает с только что отправленным запросом. Никакого тайминга, никаких догадок — неправильный кадр отбрасывается структурно.
build_response — кадр ответа + эхо-метка
#define REPLY_MAGIC 0x4321u /* "this is a real data reply" tag */
#define WRPOS_ECHO 0x5A5Au /* sentinel for the destructive write reply */
/* Build "00 4C 8A <32 data bytes>" from 32 source bytes into the frame buffer. */
static void build_response(u16 *frame, const u8 *data32)
{
u8 b[36]; int i;
b[0] = 0x00; b[1] = 0x4C; b[2] = 0x8A; /* my fixed reply header */
for (i = 0; i < 32; i++) b[3 + i] = data32[i];
for (i = 0; i < 62; i++) frame[i] = 0;
/* pack byte pairs little-end-first into each MB halfword (verified on the bench) */
for (i = 0; i < 18; i++) frame[i] = (u16)((b[2*i + 1] << 8) | b[2*i]);
}
/* ...and at each call site, right before tx_send: */
frame[18] = (u16)(addr & 0xFFFFu); /* echo the requested address */
frame[19] = REPLY_MAGIC; /* tag it as a fresh data reply */
С эхом на месте полные чтения по 16 КБ каждый раз возвращаются целиком, а не обрываются на полпути. Если вы когда-нибудь воевали со статическими слотами FlexRay, вы понимаете, какую плавающую ошибку это убивает.

чтение. Полное чтение flash block0 в MS561: 16 КБ получено, hex на экране, и консоль завершается строками flash block0 read OK (16384 bytes) и Flash integrity OK. Агент поднят (Agent connected), и на узле нет JTAG.
Чтение EEPROM — возвращаем к жизни выводы SPI
M95640 висит на DSPI_B. Подвох: прошивка читает её один раз при загрузке, а затем снимает мультиплексирование с выводов, так что к моменту запуска агента эти выводы — уже не SPI. Первым делом их нужно вернуть обратно: назначить CS/SCK/SOUT как выходы, а SIN как вход через регистры управления выводами SIUL, затем поднять DSPI_B как медленный, безопасный 8-битный мастер. (Номера выводов и CTAR взяты прямо из собственной загрузочной процедуры работы с EEPROM в рейке.)
Классическое чтение SPI-EEPROM — это код операции 0x03, 16-битный адрес, затем холостые (dummy) байты, выдаваемые тактами, чтобы считать данные обратно. Единственная тонкость на этом DSPI: сигнал выбора кристалла (chip-select) должен оставаться активным на протяжении всей транзакции, поэтому кадры команды/адреса/холостой передаёшь встык с установленным битом продолжения, снимая CS только на последнем. Одно ожидание на каждый байт приводит к опустошению FIFO и снятию CS посреди передачи. Поэтому каждый байт данных — это свой аккуратный пакет из 4 кадров.
spi_ee_read — пакетное чтение M95640 по DSPI_B
#define DSPI_B 0xFFF94000u
#define DSPI_MCR (*(volatile u32 *)(DSPI_B + 0x00u))
#define DSPI_CTAR0 (*(volatile u32 *)(DSPI_B + 0x0Cu))
#define DSPI_SR (*(volatile u32 *)(DSPI_B + 0x2Cu))
#define DSPI_PUSHR (*(volatile u32 *)(DSPI_B + 0x34u))
#define DSPI_POPR (*(volatile u32 *)(DSPI_B + 0x38u))
static void spi_ee_init(void)
{
*(volatile u16 *)0xC3F9004Au = 0x0600u; /* PCR5 CS0 out */
*(volatile u16 *)0xC3F9004Cu = 0x0600u; /* PCR6 SCK out */
*(volatile u16 *)0xC3F9004Eu = 0x0600u; /* PCR7 SOUT out */
*(volatile u16 *)0xC3F90050u = 0x0100u; /* PCR8 SIN in */
DSPI_MCR = 0x80010C00u; /* master, PCS0 idle-high, flush FIFOs, running */
DSPI_CTAR0 = 0x38004448u; /* 8-bit frame, mode0, slow baud (safe) */
}
/* READ (0x03): each byte is a 4-frame burst [cmd, addrHi, addrLo(CONT), dummy].
The 4th RX byte is the data; CS stays low for the whole burst. */
static void spi_ee_read(u16 addr, u8 *dst, int len)
{
int i;
spi_ee_init();
for (i = 0; i < len; i++) {
u16 a = (u16)(addr + i);
volatile u32 to = 0;
DSPI_MCR = 0x80010C00u; /* flush FIFOs */
DSPI_SR = 0xFFFF0000u; /* clear status */
DSPI_PUSHR = 0x80010000u | 0x03u; /* READ (CONT) */
DSPI_PUSHR = 0x80010000u | (u32)(a >> 8); /* addr hi(CONT) */
DSPI_PUSHR = 0x80010000u | (u32)(a & 0xFFu); /* addr lo(CONT) */
DSPI_PUSHR = 0x00010000u | 0xFFu; /* dummy, drop CS */
while (((DSPI_SR >> 4) & 0xFu) < 4u) if (++to > 200000u) break;
(void)DSPI_POPR; (void)DSPI_POPR; (void)DSPI_POPR; /* cmd/addr phases */
dst[i] = (u8)(DSPI_POPR & 0xFFu); /* 4th frame = data */
}
}
Запись EEPROM — только те байты, что реально изменились
Запись M95640 — хрестоматийный танец: WREN (разрешение записи, 0x06), затем WRITE (0x02) + адрес + данные, после чего опрашиваем бит WIP регистра состояния, пока не завершится цикл записи (~5 мс). Снова весь блок cmd+addr+data идёт одним пакетом с CS в низком уровне.
spi_ee_write_byte — WREN / WRITE / опрос WIP
static void spi_ee_write_byte(u16 addr, u8 val)
{
volatile u32 to = 0;
spi_ee_init();
/* WREN */
DSPI_SR = 0xFFFF0000u;
DSPI_PUSHR = 0x00010000u | 0x06u;
to = 0; while (((DSPI_SR >> 4) & 0xFu) < 1u) if (++to > 200000u) break;
(void)DSPI_POPR;
/* WRITE 0x02 + addrHi + addrLo + data (CS held, drop after data) */
DSPI_MCR = 0x80010C00u; DSPI_SR = 0xFFFF0000u;
DSPI_PUSHR = 0x80010000u | 0x02u;
DSPI_PUSHR = 0x80010000u | (u32)(addr >> 8);
DSPI_PUSHR = 0x80010000u | (u32)(addr & 0xFFu);
DSPI_PUSHR = 0x00010000u | (u32)val;
to = 0; while (((DSPI_SR >> 4) & 0xFu) < 4u) if (++to > 200000u) break;
(void)DSPI_POPR; (void)DSPI_POPR; (void)DSPI_POPR; (void)DSPI_POPR;
/* poll WIP until the write cycle completes (~5 ms) */
to = 0; while ((spi_ee_rdsr() & 0x01u) && (++to < 100000u)) { }
}
Два сознательных решения в пользу безопасности на стороне управления этим:
- Команда записи несёт защитный байт. Запись выполняется, только если в слове команды присутствует фиксированное защитное значение. Случайный или искажённый кадр не может непреднамеренно записать EEPROM — он просто отбрасывается.
- Хост пишет разницу, а не целый блок. На стороне MS561 я держу последнее полное чтение EEPROM как снимок; запись затрагивает только те байты, что отличаются от него (и никогда — изменчивые рабочие поля), после чего считывает окно обратно и проверяет, что всё легло как надо. Если вы когда-нибудь «окирпичивали» модуль, переписав байт, который не собирались трогать, вы поймёте, зачем это.
В мастерской это обычно та самая сторона данных при ремонте с перескоком ремня. BMW регистрирует это как код неисправности (DTC) 0x482452 — «EPS steering angle sensor: belt jump detected» в нашем декодере — с аппаратным парным кодом 0x4822D9 («…steering angle invalid») и счётчиком перескоков ремня в DID 0xE33C. Несмотря на название, сам ремень обычно цел; просто сохранённые в модуле данные больше не соответствуют рейке, и перепрошивкой или кодированием это не сбросить. Привести данные в порядок позволяет именно обратная запись правильного содержимого EEPROM.

запись eeprom. Запись EEPROM и чтение сразу обратно для проверки: EEPROM write verified — 1151 byte(s). Затрагиваются только изменённые байты, и обратное считывание подтверждает каждый из них.
Запись флеш — калибровка L/R, с расчётом CRC прямо в устройстве
У 19EE левый и правый руль — не косметика: на праворульной рейке мотор усилителя крутится в другую сторону, поэтому флаг должен соответствовать машине. Всё сводится к двум байтам-флагам в block0, а block0 защищён CRC-32, которую проверяет прошивка. Поменяешь флаги, не исправив CRC, — и блок управления отвергнет калибровку.
Рецепт я вскрыл на стенде, сравнив подлинный дамп LEFT с подлинным дампом RIGHT:
block0[0xE9]=0x00для LEFT,0x01для RIGHTblock0[0x4E9]=0xFFдля LEFT,0xFEдля RIGHT- стандартная CRC-32 zlib/PKZIP (отражённый полином
0xEDB88320) поblock0[0 .. 0x3E44), сохранённая в формате big-endian по адресу0x3FCC
Переключение одной рейки затрагивает ровно эти шесть байтов — и больше ничего. Байтов-флагов два, а не один, потому что это избыточная пара: 0x4E9 содержит дополнение до единицы (one's-complement) от 0x0E9 (0x00/0xFF, 0x01/0xFE), ровно на 0x400 дальше, и начало block0 зеркально продублировано — как побитовое НЕ — по тому же смещению +0x400. Это та самая защитная схема хранения «значение плюс его инверсия», которую используют блоки управления, и оба байта попадают в область CRC-32 — так что корректное изменение означает согласованную установку пары и пересчёт CRC.
CRC в точности совпала с обоими эталонными дампами — именно так я и понял, что взял верный диапазон и верный алгоритм. Поэтому агент несёт собственную крошечную побитовую CRC-32 (без таблицы — чтобы агент оставался компактным) и пересчитывает контрольную сумму прямо в рейке после правки флагов, так что прошиваемый образ получается самосогласованным.
crc32_zlib — прямо в устройстве, без таблицы
/* CRC-32 (zlib/PKZIP: reflected poly 0xEDB88320, init/xorout 0xFFFFFFFF), bit-serial. */
static u32 crc32_zlib(const u8 *p, u32 n)
{
u32 c = 0xFFFFFFFFu, i; int k;
for (i = 0; i < n; i++) {
c ^= p[i];
for (k = 0; k < 8; k++) c = (c & 1u) ? ((c >> 1) ^ 0xEDB88320u) : (c >> 1);
}
return c ^ 0xFFFFFFFFu;
}
Само программирование — это контроллер C90FL: разблокировать блок, стереть его, затем перепрограммировать двойное слово за двойным словом, пропуская чистые (0xFF) двойные слова. Ничего экзотического, но каждый шаг — это «взвести, подать высокое напряжение, дождаться DONE, проверить PEG (program/erase good)», а ошибка здесь — это мёртвая рейка, так что стоит быть аккуратным и повторно заблокировать блок после.
flash_write_block0 / flash_set_position — стирание, программирование, CRC
#define CFLASH_BASE 0xC3F88000u
#define CF_MCR (*(volatile u32 *)(CFLASH_BASE + 0x00u))
#define CF_LML (*(volatile u32 *)(CFLASH_BASE + 0x04u))
#define CF_SLL (*(volatile u32 *)(CFLASH_BASE + 0x0Cu))
#define CF_LMS (*(volatile u32 *)(CFLASH_BASE + 0x10u))
#define MCR_PGM 0x10u
#define MCR_ERS 0x04u
#define MCR_EHV 0x01u
#define MCR_DONE 0x400u
#define MCR_PEG 0x200u
#define LML_PW 0xA1A11111u
#define SLL_PW 0xC3C33333u
static u8 blk[0x4000];
static int flash_write_block0(void)
{
int i; volatile u32 to;
if (!(CF_MCR & MCR_DONE)) return -1;
CF_LML = LML_PW; CF_LML = 0x001303FEu; /* unlock block0 */
CF_SLL = SLL_PW; CF_SLL = 0x001303FEu;
/* erase block0 */
CF_MCR = MCR_ERS;
CF_LMS = 0x00000001u; /* select block0 */
*(volatile u32 *)0x0 = 0xFFFFFFFFu; /* interlock write */
CF_MCR = MCR_ERS | MCR_EHV;
to = 0; while (!(CF_MCR & MCR_DONE)) if (++to > 40000000u) break;
if (!(CF_MCR & MCR_PEG)) { CF_MCR = MCR_ERS; CF_MCR = 0u; CF_LMS = 0u; return -2; }
CF_MCR = MCR_ERS; CF_MCR = 0u; CF_LMS = 0u;
/* reprogram non-blank doublewords from the SRAM snapshot */
for (i = 0; i < 0x4000; i += 8) {
u32 w0 = ((u32)blk[i] << 24) | ((u32)blk[i+1] << 16) | ((u32)blk[i+2] << 8) | blk[i+3];
u32 w1 = ((u32)blk[i+4] << 24) | ((u32)blk[i+5] << 16) | ((u32)blk[i+6] << 8) | blk[i+7];
if (w0 == 0xFFFFFFFFu && w1 == 0xFFFFFFFFu) continue;
CF_MCR = MCR_PGM;
*(volatile u32 *)(u32)i = w0;
*(volatile u32 *)(u32)(i + 4) = w1;
CF_MCR = MCR_PGM | MCR_EHV;
to = 0; while (!(CF_MCR & MCR_DONE)) if (++to > 4000000u) break;
if (!(CF_MCR & MCR_PEG)) { /* re-lock and bail -> restore from backup */ return -3; }
CF_MCR = MCR_PGM; CF_MCR = 0u;
}
CF_LML = LML_PW; CF_LML = 0x001303FFu; /* re-lock */
CF_SLL = SLL_PW; CF_SLL = 0x001303FFu;
return 0;
}
/* snapshot block0, flip L/R flags, recompute CRC, commit */
static int flash_set_position(int right)
{
int i; u32 crc;
if (!(CF_MCR & MCR_DONE)) return -1;
for (i = 0; i < 0x4000; i++) blk[i] = *(const volatile u8 *)(u32)i;
if (right) { blk[0x0E9] = 0x01u; blk[0x4E9] = 0xFEu; }
else { blk[0x0E9] = 0x00u; blk[0x4E9] = 0xFFu; }
crc = crc32_zlib(blk, 0x3E44u);
blk[0x3FCC] = (u8)(crc >> 24); blk[0x3FCD] = (u8)(crc >> 16); /* big-endian */
blk[0x3FCE] = (u8)(crc >> 8); blk[0x3FCF] = (u8)(crc);
return flash_write_block0();
}
Стирание флеш — самое опасное, что может сделать агент, поэтому команда, запускающая его, спрятана за тройной защитой: выделенный код операции, контрольный байт и магическое слово — все должны совпасть в одном кадре, прежде чем будет вызвана flash_set_position. Один неверный бит где угодно — и запрос игнорируется. Ответ несёт отдельное эхо-контрольное значение (не адрес) и код возврата контроллера в хвосте, так что хост может понять, что этот кадр — подтверждение после записи, и проверить результат обратным считыванием.
Я переключил тестовую рейку LEFT→RIGHT→LEFT, выверил дампы в каждую сторону и оставил её в исходной конфигурации LEFT. ret=0, обратное считывание совпадает, обратимо.

запись L/R. Переключение L/R во флеш, считанное обратно для подтверждения: Write verified: steering = RIGHT. Safe to switch off the power. Изменённая строка подсвечена в дампе, а CRC пересчитана прямо в рейке.
Цикл обработки команд целиком
Четыре команды — чтение флеш, чтение EEPROM, запись EEPROM, запись L/R — на каждую ответ идёт в TX-слоте с эхо-меткой. Точка входа записывает сигнатуру «я жив» (00 4C 8A A0 A1 A2 … BF) перед входом в цикл, так что я могу подтвердить, что мой код загружен и работает исключительно с шины, без подключённого JTAG.
agent_main — диспетчер
void agent_main(void)
{
static u16 frame[64];
static u8 buf[32];
u16 cmd[20]; int n, i, ret; u32 addr;
for (;;) {
n = rx_poll(cmd, 20);
if (n < 3) continue;
if (/* READ flash */ cmd[0] == 0x0017u && (cmd[1] & 0xFF00u) == 0x6300u) {
addr = ((u32)(cmd[1] & 0xFFu) << 16) | cmd[2];
for (i = 0; i < 32; i++) buf[i] = *(const volatile u8 *)(addr + i);
build_response(frame, buf);
frame[18] = (u16)(addr & 0xFFFFu); frame[19] = REPLY_MAGIC;
tx_send(frame, 62);
}
else if (/* READ eeprom */ cmd[0] == 0x0025u && (cmd[1] & 0xFF00u) == 0x6300u) {
addr = ((u32)(cmd[1] & 0xFFu) << 16) | cmd[2];
spi_ee_read((u16)addr, buf, 32);
build_response(frame, buf);
frame[18] = (u16)(addr & 0xFFFFu); frame[19] = REPLY_MAGIC;
tx_send(frame, 62);
}
else if (/* WRITE eeprom (guarded) */ cmd[0] == 0x00B7u /* + guard check */) {
/* ... write the diff bytes, read the window back, reply with read-back ... */
}
else if (/* WRITE L/R (triple-guarded, destructive) */ cmd[0] == 0x00B9u /* + guards */) {
ret = flash_set_position((cmd[1] >> 8) & 0x1);
for (i = 0; i < 30; i++) buf[i] = *(const volatile u8 *)(u32)i;
buf[30] = (u8)(ret & 0xFF); buf[31] = (u8)((ret >> 8) & 0xFF);
build_response(frame, buf);
frame[18] = 0x5A5Au; frame[19] = REPLY_MAGIC;
tx_send(frame, 62);
}
}
}
«Шифрование», вкратце — и почему секрет мне так и не понадобился
Память возвращается не в открытом виде; путь чтения в прошивке XOR-ит её с гаммой (keystream). Её часто принимают за стойкий шифр — это не так. После сбора известного открытого текста (стёртые области 0xFF плюс область кода, почти одинаковая у разных узлов) гамма оказывается линейной над GF(2) функцией от адреса — это LFSR/CRC по адресу, а не настоящий блочный шифр. Есть и статистический признак: старший бит (MSB) каждого 32-битного слова гаммы смещён — именно это и выдаёт дешёвый линейный генератор.
Поскольку она линейна, гамма полностью восстанавливается из известного открытого текста: решаешь небольшую систему на известном блоке, распространяешь на остальное по одному известному слову на блок — и можешь расшифровать/зашифровать любой узел, так и не извлекая ни seed, ни секретный ключ. Так что никакого секрета, который нужно скрывать от агента, нет — он его и не несёт. Он отдаёт сырые байты, а вычисления выполняет хост. Последовательность защищённого доступа я разобрал выше; единственное, что я придерживаю, — как формируется ключ, и это остаётся в моём ПО MS561.
Форму уязвимости я опишу — слабую обфускацию надо называть слабой, — но не приведу ни поузловые константы, ни рукопожатие доступа. Эта часть — не моё, чтобы её раздавать.
Замечание об оригинальности
Я слышал утверждение, что мои инструменты — «точные копии 1:1» инструментов одного вендора, так что одно замечание на этот счёт.
Всё на этой странице получено из реверс-инжиниринга прошивки в Ghidra: карты регистров, гонка с блокировкой, тайминги SPI, рецепт CRC из моих собственных дампов left/right, исходник агента выше. Метод здесь приведён полностью, с исходным кодом.
Сам протокол — мой собственный: эхо-ответ 0x4321, заголовок ответа 00 4C 8A, блокировка «крутись, пока CC не отдаст», тройная защита стирания. Если это дословно всплывёт в чужом бинарнике — делайте вывод сами, в какую сторону всё шло.
Merhaba. Приятного чтения. 👋
Результаты
| Операция | Транспорт | Статус |
|---|---|---|
| Чтение флеш (блоки по 16 КБ, полный образ) | FlexRay, без JTAG | diff-0 относительно эталона, многократно проверено (~27 с / 16 КБ) |
| Чтение EEPROM (вся M95640) | FlexRay, без JTAG | побайтово точно, проверено (~14 с / 8 КБ) |
| Запись EEPROM (только изменённые байты) | FlexRay, без JTAG | обратное считывание — СОВПАДЕНИЕ, обратимо |
| Запись флеш (флаги L/R + CRC) | FlexRay, без JTAG | ret=0, проверено обратным считыванием, обратимо |
Ни JTAG-пробника на узле, ни секрета, зашитого в агент, — лишь небольшая программа в SRAM рейки, отвечающая на запросы по FlexRay, и стенд, который теперь умеет читать и писать рейку 19EE от начала до конца.
Остальная часть агента — полный хостовый драйвер и набор команд — остаётся на моём стенде, но описанное выше было самым сложным.
Источники
- NXP, MPC5643L Microcontroller Reference Manual — Flash Memory Array and Control (C90FL): регистры блокировки LML/SLL (смещения
0x04/0x0C) и их пароли для изменения блокировки,0xA1A11111и0xC3C33333. - Сообщество NXP, программирование регистров блокировки C90FL на родственных кристаллах MPC5xxx: пример MPC5644A, FLASH-блок MPC5744P.
Благодарности
Особая благодарность нашим коллегам из Бразилии и Мексики за помощь в разработке этого решения. Мы очень ценим вашу поддержку и сотрудничество!