Программирование EEPROM 93C76. Пишем программатор. Часть 1

Что такое 93C76?

Микросхема 93C76 представляет собой электрически перезаписываемое ПЗУ с последовательным доступом, объемом 8 Кбит. Содержит, непосредственно, блок ПЗУ + логический контроллер для работы с шиной SPI. Напряжение питания 5.0 Вольт. Имеет два вида адресации данных (по 8 бит и по 16 бит) — об этом далее в статье.

Классификация микросхем семейства 93C76/86:
93C86 — объем увеличен в 2 раза, до 16 Кбит.
93LC76 — с пониженным напряжением питания 2.5 В.
S93C76 — в корпусе SOIC-8 (SMD-монтаж).

Зачем программировать 93C76?

Микросхема применяется в автомобильных блоках, платах управления жесткими дисками, различных мультимедийных устройствах. Если есть необходимость запрограммировать данное ПЗУ срочно, рекомендую воспользоваться практически любым китайским программатором, это можно сделать даже на недорогих моделях. Но мне интересно не просто понажимать кнопки, а разобраться в процессе, в сути происходящего. На создание этой статьи меня вдохновило видео от известного ютубера Ben Eater.

В нём показано, в том числе, вычитывание данных из ПЗУ данного типа. В своём коде я частично буду ориентироваться на его код, но при этом мы реализуем все функции программатора: чтение, стирание, запись.

Какие средства необходимы?

Как и в видео, нам понадобится Arduino Uno (подойдет, в т.ч., и китайская копия), сама микросхема 93C76 (у меня нашлась S93C76 с адаптером под DIP-8) и макетная плата с проводами. Конечно, я мог бы расписать всё то же самое, например, для Atmega8, но в нашем случае не нужно всё усложнять, Arduino будет вполне достаточно для демонстрации принципа работы с данной ПЗУ, так как имеется COM-порт для связи с контроллером, через который мы и будем обмениваться информацией в связке ПК — Arduino — 93C76. Язык программирования — си. Итак, приступим.

Этап 1. Подготовка. Распиновка ПЗУ. Соединение аппаратной части.

Рассмотрим назначение выводов ПЗУ:

7fe2d6cbf96b02e594b135f1d55b0c90.jpg

Левая половина — выводы шины SPI, для связи с микроконтроллером (далее — Arduino). Правая половина — технические выводы.

  1. CS — выбор чипа. Определяет, ведётся ли работа с этим чипом в данный момент.

  2. CLK — тактовый сигнал. Его выдаёт Arduino.

  3. DI — вход для данных. Данные выдаёт Arduino.

  4. DO — выход данных. Его принимает Arduino. На этом выходе будет появляться информация после получения команды на чтение.

  5. VSS — Минус питания, в нашем случае — земля (GND)

  6. ORG — влияет на организацию порядка хранения данных и, соответственно, адресацию. Если установлен низкий уровень, то будет происходить приём данных по 8 бит (режим x8), если высокий — то по 16 бит (x16). Режим x8 показан на видеозаписи от Ben Eater. Некоторые факты о режиме x16, полученные опытным путём:

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

    2. Адресация при записи в режиме x16 осуществляется с шагом 2, т.е. не 0,1,2…f, а 0,1,2…7, так как по каждому адресу будет храниться по 2 байта. При чтении можно получить отдельно любой байт (не обязательно пару) по его обычному адресу (0…f), либо несколько байт подряд, так как чтение выполняется последовательно (после подачи команды данные поступают, пока идёт и не прекращается тактовый сигнал).

    3. В моей микросхеме, не смотря на состояние уровня на ORG, всё равно использовалась 16-разрядная организация памяти. Возможно, у некоторых производителей эта функция просто не работает, поэтому иногда на форумах встречаются записи о том, что программатор не может корректно записать микросхему. Либо у моей микросхемы был повреждён данный вывод. В любом случае, к этому моменту нужно относиться внимательно, иначе возникнет потеря данных.

  7. PE — (active low) вывод для защиты микросхемы от записи и стирания. Если в вашем устройстве не нужна запись данных, а лишь их чтение, то вывод подключается на землю. В нашем случае мы его установим на высокий логический уровень, так как будем писать в ПЗУ. В устройстве есть две защиты записи — аппаратная (вывод PE) и программная (EWEN). Если одну из них не снять — записать в чип не получится.

  8. VCC — плюс питания (4.5–7 В для C76, 2.5–7 В для LC76)

Выводы SPI подключаются к любым удобным цифровым ножкам микроконтроллера. В нашем случае, это будут ножки 2–6. Питание можно взять также от МК. Технические выводы я установил так: PE — высокий, ORG — высокий.

Теперь соединим платы:

551ee54277b453bd16d1d5b29d98d7ad.JPG

Этап 2. Изучаем принцип работы ПЗУ. Передача и чтение данных

Когда все соединения выполнены, откроем редактор скетчей Arduino. Для начала, напишем определение ножек для удобной работы:

#define CS 2
#define CLK 3
#define DI 4
#define DO 5

В setup инициализируем COM-порт для связи, назначим направление данных для выводов согласно этапу 1:

Serial.begin(57600);
pinMode(CS, OUTPUT);
pinMode(CLK, OUTPUT);
pinMode(DI, OUTPUT);
pinMode(DO, INPUT);

Далее, необходимо разобраться, как работать с шиной SPI. Для этого смотрим в даташит микросхемы:

48c4e9b2b72214b397f0659eb07ca594.jpg

Отсюда для минимальной работы нам будут нужны команды READ, EWEN, ERAL, WRITE, WRAL (опционально). Передача команды осуществляется переключением сигналов в определенном порядке, который также указан в даташите микросхемы. Например, чтение:

4faaa34a7c03d4c6f1df7533adf11340.jpg

Исходя из этого, мы видим:

  • Первым включается сигнал CS, он же выключается последним.

  • Сначала на DI устанавливается уровень, соответствующий очередному биту подаваемой команды, затем включается и тут выключается тактовый сигнал, как бы «подтверждая» действие и фиксируя данные. Соответственно, нам нет необходимости укладываться во временные рамки — нету жестких требований к таймингам. Скорость переключения портов Arduino высока, но и частота срабатывания логики микросхемы — до 2 МГц. Поэтому, с учетом времени, затрачиваемого на переключение уровней через библиотеки Arduino, мы можем просто подавать сигналы, а задержку проставлять лишь после длительных функций (таких, как ERAL, WRAL).

  • При чтении, после передачи команды + адреса, на выходе DO начнет появляться информация, переключаемая тактовым сигналом. Её мы будем извлекать. Разрешается последовательное чтение всего массива: если не прекратить подачу тактового сигнала, то дальше будут передаваться следующие биты информации, таким образом, команду на чтение отдельно для каждого байта подавать не обязательно.

  • При записи мы передаём последовательность, состоящую из команды, битов адреса A9-A0, и двух байт информации (для организации x16), или адреса A10-A0, но одного байта (для организации x8). При этом для записи каждых двух байт (байта) команду надо передавать заново, последовательная запись в весь массив не осуществляется.

Инструкция состоит из 14 для x8 (либо 13 для x16) бит, порядок следующий: установили первый бит, переключили CLK вверх и тут же вниз, установили второй бит, переключили CLK, и так далее. Исходя из этого, давайте реализуем функцию «sendInstruction», для того, чтобы передать данные в ПЗУ.

void sendInstruction(word comand) { //SEND 14
    for (word mask = 0b10000000000000; mask > 0; mask >>= 1) {
    if (comand & mask) {
      digitalWrite(DI, HIGH);
    } else {
      digitalWrite(DI, LOW);
    }
    digitalWrite(CLK, HIGH);
    digitalWrite(CLK, LOW);
    }
}

Функция принимает в качестве аргумента слово (команду), после чего «прогоняет» шину столько раз, сколько чисел содержится в маске. Сдвинули маску в сторону — переключили уровни, переключили CLK, и так далее, пока маска не кончится (не уйдет последняя единица).

Команды (слова) для различных операций следующие (согласно даташиту):

  • READ (читать с адреса) — 0b1100000000000 + адрес

  • WRITE (запись в ячейку) — 0b1010000000000 + адрес

  • EWEN (разрешить запись и стирание) — 0b10011000000000

  • ERAL (стереть всё) — 0b10010000000000

  • WRAL (записать всё 2 байтами) — 0b10001000000000 + 2 байта (по 8 переключений)

То есть, например, вызвав по очереди

sendInstruction(0b10011000000000);
sendInstruction(0b10010000000000);

Мы сначала разрешим запись (убедившись, что она разрешена на 7 ножке микросхемы), а затем, сотрём чип. Данные функции реализуются следующим образом:

void ERAL() { // ERASE CHIP
  digitalWrite(CS, HIGH);
  sendInstruction(0b10010000000000);
  digitalWrite(CS, LOW);
  delay(15);
}

Задержка в 15 мс на стирание всего чипа согласно даташиту. При чтении и записи:

sendInstruction(0b1100000000000 + address); 

После этой команды на шине будет появляться сигнал, который нам необходимо побитно захватить, продолжая при этом переключение CLK. Для этого реализуется отдельная функция:

byte readByte() {
   byte data = 0;
   for (byte bit = 0; bit < 8; bit +=1) {
      digitalWrite(CLK, HIGH);
      digitalWrite(CLK, LOW);
      if (digitalRead(DO))  {
        data = data << 1 | 1;
        } else {
          data = data << 1;
        }
     }
    return data;
}

После передачи инструкции на чтение, данная функция должна быть вызвана требуемое количество раз (1 для одного байта, 16 для 16 байт и т.д.) при помощи цикла for, а возвращаемый байт data — последовательно записан в массив. Захват уровня на ножке (чтение) осуществляется встроенной в библиотеку Arduino функцией digitalRead.

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

void sendByte(word comand) { //SEND 8
    for (word mask = 0b10000000; mask > 0; mask >>= 1) {
    if (comand & mask) {
      digitalWrite(DI, HIGH);
    } else {
      digitalWrite(DI, LOW);
    }
    digitalWrite(CLK, HIGH);
    digitalWrite(CLK, LOW);
    }
}

Далее мы будем её вызывать после отправки инструкции с адресом на запись. Вызывается один раз для x8, два раза подряд для x16, например:

void WRITE(word addr, byte bt1, byte bt2) {
  digitalWrite(CS, HIGH);
  sendInstruction(0b1010000000000 + addr);
  sendByte(bt1);
  sendByte(bt2);
  digitalWrite(CS, LOW);
  delay(5);
}

То есть, принимаемые аргументы: адрес (например, 0×030f для x8 или 0×0107 для x16) и два байта информации. Исходя из этого высылаем инструкцию на запись, и два байта, которые мы записываем. После этого дополнительная задержка в 5 мс (согласно даташиту — 3 мс, но иногда не успевает в данный тайминг и пишется с ошибками). Как я уже раньше упоминал, если не передавать второй байт — вместо него просто будут записаны нули.

На этом функции обращения к ПЗУ (чтение, стирание, запись) выполнены. Для остальных команд выполняются по аналогии. Далее попытаемся написать UI для работы с этими функциями.

Этап 3. Реализуем функции программатора x16 в ПО для Arduino

Функция «read», скопирована с той, которую выполнил Ben Eater в своём видео. Функция принимает начальный адрес, с которого требуется начать чтение, и количество байт для чтения. Результат отправляет на COM порт в следующем формате: адрес HEX, 16 байт в HEX, 16 байт в виде ASCII-символов, если в таблице находится соответствующий (в противном случае заменяется на точку). Сама функция:

void READ(word startAddress, int endAddress) { //read fashion HEX by Ben Eater
    byte line[16]; //VARIABLE FOR WORD
    for (word address = startAddress; address < endAddress; address += 8) { //1024 for C76, 2048 for C86; 8 for 16-BIT ORG, 16 for 8-bit ORG
    char temp[6]; //VARIABLE FOR ADDR - TMP
    sprintf(temp, "%04x  ", address);
    Serial.print(temp); //PRINT ADDR
    digitalWrite(CS, HIGH); //BEGIN READING DATA
    sendInstruction(0b1100000000000 + address); //please add external zero for x8 org
    for (int i = 0; i < 16; i += 1) { //READ DATA BYTE FOR EVERY LINE BYTE
        line[i] = readByte();
    }
    digitalWrite(CS, LOW);
    for (int i = 0; i < 16; i += 1) {
        char temp[4]; //VARIABLE FOR DATA IN HEX
        sprintf(temp, "%02x ", line[i]);
        Serial.print(temp); //PRINT DATA IN HEX - 16 BYTES
    }
    Serial.print(" ");
    // PRINT DATA IN LETTERS HEX ARR.
    for (int i = 0; i < 16; i += 1) { //PRINT DATA IN LETTERS - 16
        if (line[i] < 32 || line[i] > 126) {
            Serial.print(".");
        } else {
            Serial.print((char) line[i]);
        }
    }
    Serial.println(); //PRINT NEW LINE
  }
}

Вызовем её: READ (0×00, 512). Результат:

94df82104058c302b787d236bce00afd.jpg

Так как микросхема была очищена, она заполнена FF байтами. Конечный адрес записи: 01ff (что является 511 «двойным байтом», если учесть, что нумерация идет с нуля). Давайте подсчитаем: мы имеем 64 строки по 16 байт, итого, 1024 байта (килобайт), что и заявлено в даташите. Если бы была микросхема LC86, то было бы доступно 2 килобайта. Если бы была выбрана организация x8 — то последним адресом был бы 1023, а не 511.
Если пробовать читать или писать дальше доступного пространства — последние биты адреса (A10-A11 и т.д.) игнорируются, и чтение сначала начинается по кругу, а далее вообще не происходит (так как микросхема LC76 не поймёт данную ей входящую команду).

Теперь попробуем написать функцию, записывающую в EEPROM последовательность байт, полученную по терминалу:

void writeWord(word beginAddress) { //WRITES 16 BYTES SEQUENCE FROM TERMINAL
  while (Serial.available() == 0) {
  }
  byte lineToSend[16];
  int recLen = Serial.readBytes(lineToSend, 16);
  for (int i = 0; i < recLen; i += 2) {
    WRITE(beginAddress+(i/2), lineToSend[i], lineToSend[i+1]);
  }
}

Функция ожидает ввод с терминала, до этого будет висеть в бесконечном цикле. Принимает в качестве аргумента адрес, по которому нужно писать. Далее, пишет 16 принятых байт в память EEPROM.

Данная функция адаптирована под 16-битную организацию (x16): так как мы получаем по 1 байту (функция readBytes), то массив имеет размер 16 байт, отправляются на микросхему же байты попарно: i прибавляется кратно двум, обращение идет по адресу от 0 до 7, при этом, байты передаются 0–1, 2–3 и т.д., то есть, от 0 до f.

При 8-битной записи (режим x8) можно непосредственно отправлять WRITE (адрес, байт) 16 раз.

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

void waitAddr() {
   if (Serial.available() > 0) {
   byte receivedAddr[2];
    //RECEIVES ADDRESS IN 2 SEPARATE BYTES (example: 0x30 0x01)
   int recLen = Serial.readBytes(receivedAddr, 2);
   word programAddr = 0;
   programAddr |= receivedAddr[0];
   programAddr <<= 8;
   programAddr |= receivedAddr[1];
    //RETURNS REAL ADDRESS IN DECIMAL
   Serial.print("Received Addr: ");
   Serial.println(programAddr);
    //WAITING FOR 16-BYTE DATA INPUT SEQUENCE IN SEPARATE BYTES
   writeWord(programAddr);
  }
}

Конечно, эту функцию я написал немного криво, впрочем, для демонстрационных целей её достаточно. Вызовем waitAddr () в loop. Он сразу же будет ожидать два байта адреса и 16 байт для записи. Теперь стандартный терминал Arduino нам не помощник — так как он умеет передавать только ASCII символы. Для тестирования подключимся по CoolTerm, передадим значения в HEX, и прочитаем то, что у нас получилось. Передаём адрес, далее Arduino отвечает, что приняло его, и отправляет нам его же обратно в DEC виде. Значения сходятся:

a6953cb76bd3788e275c7aceed9bd7dd.jpg

Теперь передадим информацию, и сразу же выполним чтение всего блока, чтобы посмотреть результат:

157cbf2aea1e3d6ee3c15283a4ecfdfb.jpg

Функция работает. После отключения питания эти данные уже останутся в памяти ПЗУ. Перебрав в терминале все адреса от 0000 до 01f8 мы сможем записать полностью килобайт информации, тем самым, цель работы является достигнутой. После записи всей микросхемы рекомендуется проверить CRC, прочитав её, но это делается на ответной части в ПК, а не в микроконтроллере.

Таким образом, мы получили возможность чтения данных из ПЗУ, и записи их из терминала. Для того, чтобы реализовать полноценный программатор, который способен записывать полученные данные в бинарный файл, и, в свою очередь, записывать данные из бинарного файла в ПЗУ необходима разработка «ответной» части ПО для компьютера, её я попытаюсь реализовать на python (хотя в компьютерной разработке я не силён, мне удавалось сделать более-менее работоспособные скрипты).

Специально для habr.com, 2022 год.

© Habrahabr.ru