Картинка для привлечения внимания
Картинка для привлечения внимания

По итогам 2023 года ролики о старых цифровых камерах набрали суммарно более 1 миллиарда просмотров в TikTok. В свою очередь на YouTube блогеры актино обозревают цифромыльницы с призывом приобщиться к тренду. На фоне становления Y2K эстетики и смещения интересов в области фотографии в прошлое я предлагаю заглянуть немного подальше и посмотреть, как всё начиналось.

Если вам интересно, как связаны между собой зарождение цифровой эпохи в фотографии, странная самоделка, и причём здесь вынесенная в название оперативная память – добро пожаловать под кат!

Введение

Как это часто бывает, тут не обойтись без личной истории. Для меня в фотографии сам процесс и его технические аспекты важны не меньше, чем результат. Рано или поздно такой подход приводит к мысли о создании самодельного устройства для захвата мгновений ради удовлетворения внутреннего ребёнка. Не я первый – не я последний, например, в интернете можно найти работы энтузиастов, воссоздавших старинные форматные камеры и снимающих на них же с использованием коллоидного процесса.
К сожалению, плотник из меня неважный и я совсем не силён в химии, но мало-мальски разбираюсь в цифровой технике.

Первыми в голову приходят следующие мысли:

  • Попробовать сделать что-нибудь на базе имеющихся в продаже датчиков, например, от OmniVision, но радости такая самоделка принесёт немного. По сути это маленькая готовая камера, порой даже с поддержкой автофокуса и оптикой в комплекте.

  • Однажды я узнал про сканирующие задники для больших форматных камер – можно попробовать раздобыть ПЗС-линейку или похожий контактный датчик и сделать что-то подобное. К сожалению, зачастую необходимые детали существуют только в каталоге производителя – найти и купить их в наших реалиях сложно. Также из принципа действия следует не столько недостаток, сколько особенность – снимать такой камерой можно только статичные сюжеты.

Изготовить фотодатчик самому, конечно, невозможно, однако даже с готовыми компонентами есть место творчеству.

Циклоп и радиолюбители

Та самая камера. Источник: Wikipedia
Та самая камера. Источник: Wikipedia

В далёком 1975 году американская компания Cromemco внезапно представила полностью твердотельную камеру под названием Cyclops c возможностью подключения к микро-ЭВМ, таким как Altair 8800. Применённое инженерами решение находилось где-то на грани наглости и накуренности – вместо электронной передающей трубки прибор смотрел на мир с помощью чипа динамической памяти. За 50 долларов можно было заказать необходимые детали для самостоятельной сборки камеры (версию для подключению к осциллографу), включая тот самый датчик; схема устройства в свою очередь публиковалась в журнале Popular Electronics.

Страница из журнала
Страница из журнала

Анализ принципа действия

В микросхемах памяти динамического типа функции запоминающей ячейки выполняет конденсатор, образованный внутри МОП-структуры. Информация представляется в виде наличия или отсутствия заряда. Согласно книге «Микросхемы памяти и их применение» из серии «Массовая радиобиблиотека» логическому нулю соответствует наличие заряда, единице – его отсутствие (об этом мы ещё обязательно поговорим). Из-за несовершенства нашего мира время сохранения заряда ячейкой ограничено, поэтому такой памяти требуется регулярная перезапись – регенерация.

Мы воспользуемся тем обстоятельством, что под действием света ячейка начинает разряжаться быстрее и в конечном итоге меняет своё состояние. Объяснения этому явлению ни в сети, ни в литературе я так и не нашёл, однако рискну предположить, что паразитный p-n переход между стоком и истоком n-МОП транзистора в составе ячейки ведёт себя подобно фотодиоду, позволяя конденсатору разряжаться на шину данных.

Подбор компонентов

Выбираем память

Ради справедливости стоит сказать, что та самая микросхема была заказной – ячейки памяти были расположены в виде массива из 32 строк и 32 колонок не только логически. Также я видел в сети упоминания похожих микросхем на 64К ячеек от компании Micron, шедших с прозрачным окошком в корпусе с завода и созданных для тех же целей. Оба варианта давно не найти, но можно попробовать перепрофилировать под эти цели серийную память. Опыт Германа Курца с зарубежной памятью типа 4164 очень вдохновляет.

Для получения картинки, очевидно, нужно подвергнуть кристалл воздействию света, а это значит, что нужно как-то вскрыть корпус микросхемы, не повредив сам кристалл и приваренную к нему золотую проволоку, ведущую к выводам корпуса. @BarsMonster травил пластиковые корпуса дымящей азотной кислотой, но у меня нет доступа ни к кислоте, ни к хорошей вытяжке, зато есть паяльник и доступ к гравёру с алмазным диском – самое время обратить внимание на память в металлокерамических корпусах.

Первое, что приходит в голову – это отечественная серия К565, и я полез на известный сайт объявлений. Шутки-шутками, но среди интересующих меня объявлений не было ни одного из Default City – всё в других городах. Было несколько предложений К565РУ3 в больших количествах, чудом не доставшихся варварам-аффинажникам, но выбор пал на более современные К565РУ5 (тот же продавец, у которого я заказал комплект из вожделенных микросхем, предлагал ещё и РУ7, но уже по какой-то совсем заоблачной цене). Дело тут не в большей ёмкости – РУ5 менее капризна в плане питания и её намного сложнее убить по невнимательности. Более старые РУ1 и РУ3 требуют сразу три напряжения питания: +12, +5 и -5 В, причём подавать и снимать их нужно в корректной последовательности во избежание теплового пробоя и выхода кристалла из строя. С современной элементной базой организовать всё это несложно, порой в даташитах к DC-DC преобразователям можно найти даже пример разводки платы, но от неправильного аварийного отключения это не спасает, особенно когда процесс разработки итеративный, ну или рядом попросту бегают котики.

Есть решение – мне поможет микроконтроллер!

Времена MS-DOS и параллельного порта давно прошли, а это значит, что от использования микроконтроллера с целью подружить память с современными машинами не отвертеться. Поначалу среди всего многообразия, предоставляемого рынком, мой взгляд достаточно быстро упал на старый-добрый ATmega328P в составе китайского клона Arduino Nano. Не спешите кидаться камнями и тухлыми яйцами – я прекрасно понимаю тех, кто презирает платформу, но считаю, что в моём случае выбор вполне оправдан. Главным критерием выбора были питание от 5В и как следствие – пятивольтовые уровни, память гарантированно можно подключить напрямую к микроконтроллеру без усложнений в виде сопряжения логических уровней. Как писал @aronsky, для моргания светодиодом это слишком мощно, а для обработки изображений – слишком слабо, но для того, чтобы устроить ногодрыг и отправить результаты большому брату по UART её более чем достаточно. Более того, на плате уже есть вся необходимая для экспериментов обвязка в лице стабилизатора и USB-UART преобразователя, а в сам камень уже зашит загрузчик – шей сколько влезет, не доставая программатор.

Схема
Схема

Изначальный вариант схемы предполагал наличие триггера-защёлки на выходе микросхемы памяти, но в процессе выяснилось, что необходимость в нём отсутствует.

Пишем прошивку и клиентскую программу

Программируем микроконтроллер

Временные диаграммы
Временные диаграммы

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

  1. Подать сигнал чтения (1)/разрешения записи (0);

  2. Подать на адресную шину код адреса строки и вместе с ним стробирующий сигнал ~RAS;

  3. После небольшой задержки, которая зависит от типа микросхемы, подать адрес столбца и стробирующий сигнал ~CAS;

  4. В случае записи – подать записываемое значение на вход DI, иначе – прочитать значение с выхода DO;

  5. По окончании снять стробирующие сигналы в обратном порядке и выдержать нормированную задержку.

Страничная запись
Страничная запись

Но читать и писать по одному биту это слишком медленно – микросхема может работать в страничном режиме, когда мы пишем или читаем сразу несколько ячеек. Процесс очень похож на обычные чтение и запись, только адрес столбца и сигнал ~CAS подаются несколько раз. В книге не было временных диаграмм для этих режимов, но их можно подсмотреть в документации на зарубежную память типа 4164. Понятно, что задержки будут другими, но лишним не будет.

Ещё один необходимый режим – принудительная регенерация. В нашем случае мы по сути специально портим данные, чтобы получить изображение, и он нам не особо нужен, но перед началом работы память нужно инициализировать. К565РУ5 выходит в рабочий режим спустя 2 мс и 16 циклов регенерации после подачи питания. Для этого мы просто перебираем строки с сигналом ~CAS.

Поначалу при написании программы для микроконтроллера я допустил одну досадную ошибку, которую заметил не сразу. При скорости UART 57600 бод попытка считать память целиком и передать её содержимое на компьютер занимала около 25 секунд, при этом записанные данные практически не портились (несколько битых ячеек всё-таки было). Проблема крылась в функции формирования задержек – по ошибке я вбил в калькулятор несколько лишних нулей и вместо 62,5 нс на такт получил 0,625. Я как можно быстрее стёр это позорище; по сути мне требовались задержки в единицы тактов – издевательство над таймером в итоге превратилось во что-то такое:

// Задержка в N тактов
template <uint8_t N = 1>
void delay_cycles() {
	__asm__ __volatile__ ("nop");
	delay_cycles<N - 1>();
}

// База рекурсивного шаблона задержки
template <>
inline void delay_cycles<0>() {}

С другой стороны теперь я на практике убедился в том, что память может хранить данные на порядки дольше, чем написано в паспорте (в теории РУ5 требует принудительной регенерации каждые 2 мс, иначе сохранность данных не гарантирована). Эта возможность может пригодиться как для формирования более длинных выдержек, так и для передачи данных. Напомню, что у микроконтроллера всего 2 Кб оперативной памяти, в то время как ёмкость РУ5 – все 8, поэтому мы вынуждены прочитать порцию данных, отправить её и дальше читать следующую. В моём случае считывание происходит построчно – 256 посылок по 32 байта.

Пишем клиент

Одно дело смотреть на то, как байты бегут в окошке терминальной программы, другое – превратить этот поток в картинку и сохранить её в файл. Нужна клиентская программа которая будет на стороне «большого» компьютера принимать и обрабатывать данные.

Предварительная версия клиента
Предварительная версия клиента

Вообще моей «первой любовью» в мире разработки была Java, однако по превратности судьбы мне приходилось работать преимущественно с .NET и C#, поэтому с выбором инструментов долго думать не пришлось.

Microsoft любезно предоставляет класс System.IO.Ports.SerialPort для работы с UART, поэтому больших сложностей не возникло. Наверное, основное, на что стоит обратить внимание, это асинхронный метод получения данных – мы ждём завершения приёма в буфер в отдельном потоке, не блокируя UI.

var progressHandler = new Progress<int>(value => progressBar.Value = value);
var progress = progressHandler as IProgress<int>;

byte[] buffer = new byte[8192];

await Task.Run(() =>
{
    // Пишем и читаем память по команде с компьютера
    // Ждём 4 байта: мнемонику 0xBADBEE и число - как долго экспонируем кристалл
    byte[] command = { 0xBA, 0xDB, 0xEE, (byte)shutterSpeed };
    _serialPort.Write(command, 0, 4);

    for (int i = 0; i < 8192; i++)
    {
        buffer[i] = (byte)_serialPort.ReadByte();
        if (progress != null)
        {
            progress.Report(i);
        }
    }
});

Самый душный этап и беда, откуда не ждали

Вскрываем кристалл

Теоретические выкладки и написание ПО – это хорошо, но рано или поздно приходит пора проверить жизнеспособность всего этого на практике.

Пора. Вскрывать. Корпус.

Одна из вскрытых микросхем
Одна из вскрытых микросхем

Сначала я думал срезать крышку скальпелем или отпаять её. В процессе выяснилось, что запаян корпус на совесть, и просто так срезать крышку не выйдет. Отп��ять крышку мне удалось только с добавлением сплава Розе (не делайте так, это очень плохая практика), причём в обоих случаях кристаллы были испорчены – первый я заляпал припоем, второй перегрел. Более здравой оказалась идея срезать крышку гравёром – тут всё получилось; для защиты от механических повреждений вместо металлической крышки было приклеено предметное стекло для оптического микроскопа (прошу прощения за качество фото, макрообъектива под рукой нет).

Геометрия и паттерны

Сначала я даже подумал, что повредил и этот кристалл – независимо от того, что я в него записывал, всё время считывался паттерн, который можно увидеть на скриншоте (чёрные ячейки соответствуют нулю). Однако стоило мне уйти в тёмную комнату или прикрыть окошко плотным картоном, как память начинала отдавать ровно то, что в неё записывали.

Читаем засвеченный кристалл
Читаем засвеченный кристалл

На этом же этапе в процессе экспериментов с режимом записи и частичным прикрытием окошка выяснилось несколько вещей:

  1. Память необязательно забивать нулями – под действием света единички превращаются в нолики точно так же, как и нолики в единички. Как это соотносится с тем, что написано в книге по поводу соответствия наличия заряда нулю – хороший вопрос. Ну и похоже, что моё предположение оказалось неверным. Ждём знающих людей в комментариях.

  2. В зависимости от того, чем была инициализирована память, на свет реагировали либо тёмные участки паттерна, либ�� светлые.

  3. В считанном паттерне явно прослеживаются блоки 64 на 64 ячейки, это пригодится нам в дальнейшем.

Первые два пункта позволяют ввести компенсацию: инициализировать память инверсным паттерном и интерпретировать цвет ячейки в зависимости от её расположения на стороне клиента.

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

Микрофотография кристалла. Источник: Zeptobars
Микрофотография кристалла. Источник: Zeptobars

Задача ясна с порога: найти соответствия между логическими адресами и положением ячеек на кристалле. Если посмотреть на кристалл внимательнее, то можно выделить 16 отдельных зон, на которых расположены ячейки.

We need to go deeper

То, что получилось на данном этапе – уже неплохо, но для дальнейших опытов было бы неплохо наблюдать за изменениями в реальном времени, а не получать изображение по запросу вручную.

Кроме платки с 328-й Мегой у меня также есть «Синяя таблетка» на базе STM32F103C8T6, у которого на борту не просто значительно больше ресурсов, но ещё есть аппаратный USB со скоростью до 12 Мб/с – то, что нужно! Даже не пришлось заниматься сопряжением уровней – у камня оказалось достаточно толерантных к высоким уровням выводов.

В прошивке нет ничего сверхъестественного: микроконтроллер в бесконечном цикле очищает память, вычитывает содержимое и отправляет по USB.
USB и GPIO сконфигурированы с помощью CubeIDE. Я, к сожалению, не прожжённый эмбеддер, поэтому сделать USB на чистом CMSIS для меня пока сложновато, хотя наверное это могло бы улушить производительность по сравнению с использованием HAL.

Я использую USB Communication Device Class, но не в режиме виртуального COM-порта, а в связке с libusb согласно этому руководству от ST.

Также я написал небольшой класс-обёртку, чтобы заменить громоздкие обращения к регистрам и вызовы функций HAL на более читаемые конструкции вида Pin.set(); Pin.reset();

#ifndef PIN_H_
#define PIN_H_

#include "stm32f1xx_hal.h"

class Pin {

    GPIO_TypeDef *port_;
    uint16_t pin_;

public:

    Pin(GPIO_TypeDef *port_, uint16_t pin_) :
    port_(port_), pin_(pin_) {
    }
    
    void mode(uint32_t mode) {
    	GPIO_InitTypeDef GPIO_InitStruct;
    	GPIO_InitStruct.Pin = pin_;
    	GPIO_InitStruct.Mode = mode;
    	GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
    	GPIO_InitStruct.Pull = GPIO_NOPULL;
    	HAL_GPIO_Init(port_, &GPIO_InitStruct);
    }
    
    inline bool read() const {
    	return ((port_->IDR >> pin_) & 1U) != 0;
    }
    
    inline void set() const {
    	port_->BSRR = (1UL << pin_);
    }
    
    inline void reset() const {
    	port_->BRR = (1UL << pin_);
    }
    
    inline void set(bool value) const {
    	if (value) {
    		this->set(); // Установить высокое напряжение
    	} else {
    		this->reset(); // Установить низкое напряжение
    	}
    }

    inline void toggle() const {
    	port_->ODR ^= (1UL << pin_);
    }
};

#endif

В результате функции чтения/записи заметно упростились:

// Адресная шина
Pin A0(GPIOB, 12);
Pin A1(GPIOB, 13);
Pin A2(GPIOB, 14);
Pin A3(GPIOB, 15);
Pin A4(GPIOA, 8);
Pin A5(GPIOA, 9);
Pin A6(GPIOA, 10);
Pin A7(GPIOB, 7);

// RAS, CAS, WR
Pin RAS(GPIOB, 8);
Pin CAS(GPIOA, 15);
Pin WR(GPIOB, 3);

// DIN, DOUT
Pin DIN(GPIOB, 6);
Pin DOUT(GPIOB, 4);

//***

void write(uint8_t row, uint8_t column, uint8_t value) {
	set_address(row);
	RAS.reset();
	WR.reset();
	delay_cycles<4>();

	set_address(column);
	CAS.reset();
	DIN.set(value);
	// Задержка 80 нс
	delay_cycles<6>();

	WR.set();
	RAS.set();
	CAS.set();
	// После окончания цикла нужно выдержать паузу в 150 нс
	delay_cycles<11>();
}

uint8_t read(uint8_t row, uint8_t column) {
	set_address(row);
	RAS.reset();
	WR.set();
	delay_cycles<4>();

	set_address(column);
	CAS.reset();
	// Задержка 175 нс; TA_CAS + TSU_(RAS-CAS)
	delay_cycles<13>();

	uint8_t ret = DOUT.read();

	RAS.set();
	CAS.set();
	// После окончания цикла нужно выдержать паузу в 150 нс
	delay_cycles<11>();
	return ret;
}

Для отправки данных используется простой кольцевой буфер на 64 байта (по размеру пакета USB). К сожалению, нельзя сказать, что полуившееся решение достаточно эффективно. На каждый кадр имеем 256 вызовов очистки (нельзя очистить более одной строки за раз), аналогично 256 вызовов чтения и 128 посылок. При использовании inline-функций, прямом обращении к регистрам вместо HAL-вызовов там, где это возможно и включенной оптимизации -O3 полчается примерно 9-10 кадров в секунду.

Обработка данных и демонстрация работы

На стороне клиента из принятого массива формируется объект типа Bitmap.

var src = new Bitmap(128, 512, PixelFormat.Format1bppIndexed);
BitmapData data = src.LockBits(new Rectangle(0, 0, src.Width, src.Height),
ImageLockMode.WriteOnly, src.PixelFormat);

Marshal.Copy(buffer, 0, data.Scan0, buffer.Length);
src.UnlockBits(data);

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

private void SeparateRows(Bitmap bitmap, int startRow, int endRow)
{
    List<int> evenRows = new List<int>();
    List<int> oddRows = new List<int>();

    for (int i = startRow; i < endRow; i++)
    {
        if ((i % 2) == 0)
            evenRows.Add(i);
        else
            oddRows.Add(i);
    }

    List<int> reorderedRows = new List<int>(evenRows.Count + oddRows.Count);
    reorderedRows.AddRange(evenRows);
    reorderedRows.AddRange(oddRows);

    Dictionary<int, int> newPositions = new Dictionary<int, int>();
    for (int i = 0; i < reorderedRows.Count; i++)
        newPositions[reorderedRows[i]] = i + startRow;

    using (var src = bitmap.Clone(new RectangleF(0, 0, bitmap.Width, bitmap.Height), bitmap.PixelFormat))
    using (Graphics g = Graphics.FromImage(bitmap))
    {
        for (int current = startRow; current < endRow; current++)
        {
            int target = newPositions[current];
            g.DrawImage(src, new Rectangle(0, target, bitmap.Width, 1), new Rectangle(0, current, bitmap.Width, 1), GraphicsUnit.Pixel);
        }     
    }
}

Здесь производительность временно принесена в жертву читаемости, я планирую переписать этот фрагмент с использованием прямого доступа к памяти вместо рисования с помощью объекта Graphics по аналогии с созданием исходного Bitmap.

Процесс повторяется несколько раз как для всего изображения, так и для разных регионов:

var workingCopy = src.Clone(new RectangleF(0, 0, src.Width, src.Height), PixelFormat.Format24bppRgb);
SeparateRows(workingCopy, 0, workingCopy.Height);

for (int i = 0; i < 8; i++)
{
    SeparateRows(workingCopy, i * 64, ((i + 1) * 64 - 1));
}

Заключение

Причины появления паттерна при засветке кристалла выяснить так и не удалось. Его можно компенсировать, но ценой серьёзной потери производительности, т.к. заранее посчитанный буфер не влезает в память. Финальное изображение по-прежнему идёт блоками как из-за малого размера кристалла, так и несовершенства обработки и деления его на зоны.

Меня как будто преследует проклятие незавершённости – начинание доходит до стадии демонстрации концепции, но дальше начинает сказываться нехватка знаний и навыков. Так или иначе, это было интересно; надеюсь, что не только мн�� одному.

P.S. Вместо рекламы телеграм-канала: никому часом не нужен младший шарпист?

Комментарии (1)


  1. BarsMonster
    29.10.2025 17:19

    Хехе, не раз думал сделать что-то подобное, и даже пытался запилить на EEPROM - но пока неудачно.

    По паттерну - нужно калибровать по черному и белому кадру (т.е. для каждого пикселя - нужно помнить уровень черного и белого), без этого никак... Эти калибровочные данные будут зависить от напряжения питания и температуры.

    Разброс транзисторов большой, так что без этого никуда. В МК памяти не хватит - а вот в настольнике вполне должно получится.