
«Что есть "реальность"? И как определить её? Весь набор ощущений: зрительных, осязательных, обонятельных — это сигналы рецепторов, электрические импульсы, воспринятые мозгом», — Морфеус, фильм «Матрица»
Если процессор — это мозг компьютера, то может ли он быть ещё и частью некой виртуальной реальности? Симулированная память, программно-определяемая периферия, искусственно сгенерированные прерывания...
Моим первым компьютером был 286 с 1 МБ ОЗУ и жёстким диском на 50 МБ (если я правильно помню). Поэтому я решил взять процессор 286 и попробовать симулировать остальную часть компьютера вокруг него. Или хотя бы сделать так, чтобы он мог запускаться и выполнять какой-то простой ассемблерный код.
Два года назад я купил два процессора Harris 80C286-12. Мои воспоминания довольно туманны, но кажется, буква C в их маркировке важна, потому что она означает меньшую чувствительность к точности таймера (12 в конце означает, что процессор предпочитает работать на 12 МГц), и что на нём даже допустимо пошаговое выполнение.
Поначалу я не добился особых успехов, и мой проект перекочевал в ящик, но в этом году я снова к нему вернулся и решил разобраться, что же пошло не так.
Соединения

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

Таблица помогла и в идентификации различных входов и выходов, что пригодится позже, когда мы будем подключаться к Raspberry Pi. Как видите, требуется целых 57 контактов, то есть больше, чем может обеспечить Pi. На помощь мне пришёл расширитель портов ввода-вывода MCP23S17. Хоть он и не позволит нам работать с процессором на умопомрачительной скорости 12 МГц, к счастью, цель наша в другом.
Чип содержит 16 контактов ввода-вывода, поэтому нам понадобится четыре из них. Хоть каждый контакт можно по отдельности конфигурировать как вход или выход, я попытался сгруппировать их логически. У расширителя портов есть сторона A и сторона B, на каждой из которых по 8 контактов; окончательный результат выглядел так:
┌───┬──┬───┐ ┤ └──┘ ├ ┤ ├ ┤ FLAG ├ ERROR ┤ ├ BUSY ┤ ADDR:100 ├ INTR READY ┤ ├ NMI RESET ┤B A├ PEREQ CLK ┤ ├ HOLD └──────────┘ ┌───┬──┬───┐ HLDA ┤ └──┘ ├ A23 COD/INTA ┤ ├ A22 M/IO ┤ MISC ├ A21 LOCK ┤ ├ A20 BHE ┤ ADDR:011 ├ A19 S1 ┤ ├ A18 S0 ┤B A├ A17 PEACK ┤ ├ A16 └──────────┘ ┌───┬──┬───┐ A8 ┤ └──┘ ├ A7 A9 ┤ ├ A6 A10 ┤ ADDR ├ A5 A11 ┤ ├ A4 A12 ┤ ADDR:010 ├ A3 A13 ┤ ├ A2 A14 ┤B A├ A1 A15 ┤ ├ A0 └──────────┘ ┌───┬──┬───┐ D8 ┤ └──┘ ├ D7 D9 ┤ ├ D6 D10 ┤ DATA ├ D5 D11 ┤ ├ D4 D12 ┤ ADDR:001 ├ D3 D13 ┤ ├ D2 D14 ┤B A├ D1 D15 ┤ ├ D0 └──────────┘
Pi общается с расширителями через SPI. Существует множество решений этой задачи. Я выбрал такое, в котором все чипы активны одновременно, а Pi передаёт им сообщения по их аппаратному адресу.

В данном случае контакт RESET (соединённый фиолетовым кабелем) не обязательно должен управляться Pi, но в процессе отладки я попробовал его подсоединить в надежде, что это поможет, а потом так и оставил. Теперь нужно только соединить всё кучей проводов и переходить к программированию.

Расширение ввода-вывода
Нам нужна лишь относительно малая доля возможностей MCP23S17, достаточно только сконфигурировать направление контактов ввода-вывода и считывать/записывать соответствующие регистры. Конфигурирование выполняется путём изменения значений регистров. Сначала нам нужно включить использование аппаратной адресации. По умолчанию все чипы имеют адрес 000, поэтому если мы отправим сообщение изменения регистра на этот адрес (задаваемое битом HAEN в регистре IOCON), то аппаратная адресация включится одновременно на всех четырёх чипах.
Спустя несколько часов (дней) экспериментов выяснилось, что одного этого будет недостаточно для правильной работы. Нужно также отправлять то же собщение на сам сконфигурированный аппаратный адрес, чтобы включить аппаратную адресацию (да, это странно). То есть, если, например, мы присвоим аппаратный адрес 101, то нам нужно будет повторно отправлять на 101 исходное сообщение изменения регистра, которое мы ранее отправляли на 000.
Разобравшись с аппаратной адресацией, нам нужно задать нужное направление регистров IODIRA и IODIRB каждого чипа. Благодаря нашей группировке можно сконфигурировать всю сторону за раз для чтения (11111111) или записи (00000000). Дополнительную информацию можно найти в спецификации чипа.
Изначально я работал с Pi Zero, но позже остановился на Pi Pico с MicroPython. Для управления чипами расширителя я создал следующий маленький класс:
class MCP23S17: IODIRA = 0x00 IODIRB = 0x01 IOCON = 0x0B GPIOA = 0x12 GPIOB = 0x13 def __init__(self, address, spi, cs): self.__address = address self.__spi = spi self.__cs = cs def init(self): self.__writeRegister(0b01000000, self.IOCON, 0b00001000) self.writeRegister(self.IOCON, 0b00001000) def writeRegister(self, reg, value): self.__writeRegister(self.__address, reg, value) def readRegister(self, reg): tx = bytearray([self.__address | 1, reg, 0]) rx = bytearray(3) self.__cs.value(0) self.__spi.write_readinto(tx, rx) self.__cs.value(1) return rx[2] def __writeRegister(self, address, reg, value): self.__cs.value(0) self.__spi.write(bytes([address, reg, value])) self.__cs.value(1)
Как можно заметить, в init мы задаём значение регистра IOCON дважды. Для общения с процессором можно использовать этот класс следующим образом:
spi = SPI(0, baudrate=1000000, sck=Pin(2), mosi=Pin(3), miso=Pin(4)) cs = Pin(5, mode=Pin.OUT, value=1) rst = Pin(6, mode=Pin.OUT, value=0) chip_data = MCP23S17(0b01000010, spi, cs) chip_addr = MCP23S17(0b01000100, spi, cs) chip_misc = MCP23S17(0b01000110, spi, cs) chip_flag = MCP23S17(0b01001000, spi, cs) rst.value(1) chip_data.init() chip_addr.init() chip_misc.init() chip_flag.init() chip_data.writeRegister(MCP23S17.IODIRA, 0xff) chip_data.writeRegister(MCP23S17.IODIRB, 0xff) chip_addr.writeRegister(MCP23S17.IODIRA, 0xff) chip_addr.writeRegister(MCP23S17.IODIRB, 0xff) chip_misc.writeRegister(MCP23S17.IODIRA, 0xff) chip_misc.writeRegister(MCP23S17.IODIRB, 0xff) chip_flag.writeRegister(MCP23S17.IODIRA, 0x00) chip_flag.writeRegister(MCP23S17.IODIRB, 0x00)
Поначалу здесь не хватало вызовов init , и я не понимал, почему ничего не работает. Большинство контактов сконфигурировано для чтения; для записи нужно только задать флаги.
Исходное состояние
Прежде, чем что-либо делать, нам нужно выполнить сброс (RESET) процессора. Для этого флаг RESET должен быть активным как минимум 16 тактов, а его включение и отключение должно быть синхронизировано с флагом таймера. Сначала, чтобы упростить себе жизнь, я создал несколько констант для флагов:
# chip_flag GPIOA FLAG_ERROR = 0x20 FLAG_BUSY = 0x10 FLAG_INTR = 0x08 FLAG_NMI = 0x04 FLAG_PEREQ = 0x02 FLAG_HOLD = 0x01 # chip_flag GPIOB FLAG_CLK = 0x80 FLAG_RESET = 0x40 FLAG_READY = 0x20 # chip_misc GPIOB FLAG_PEACK = 0x80 FLAG_S0 = 0x40 FLAG_S1 = 0x20 FLAG_BHE = 0x10 FLAG_LOCK = 0x08 FLAG_M_IO = 0x04 FLAG_COD_INTA = 0x02 FLAG_HLDA = 0x01
Стоит сравнить это с более простым сопоставлением контактов MCP23S17. Мы обрабатываем каждую группу из 8 контактов, как 8 бит / 1 байт данных. Например, в байте со стороны MISC GPIOB чипа флаг HLDA — это младший бит, а PEACK — старший.
PEACK ↓ 10100111 ↑ HLDA
Разобравшись с флагами, мы можем выполнить RESET:
for i in range(17): chip_flag.writeRegister(MCP23S17.GPIOB, FLAG_CLK | FLAG_RESET) time.sleep(0.001) chip_flag.writeRegister(MCP23S17.GPIOB, FLAG_RESET) time.sleep(0.001)
Интервалы sleep были выбраны более-менее произвольно; мы не обязаны придерживаться какого-то строгого тайминга. Во время RESET процессор должен войти в определённое состояние. Мы можем проверить это следующим блоком кода:
data = chip_addr.readRegister(MCP23S17.GPIOA) print('A7-0: ' + str(bin(data))) data = chip_addr.readRegister(MCP23S17.GPIOB) print('A15-8: ' + str(bin(data))) data = chip_misc.readRegister(MCP23S17.GPIOA) print('A23-16: ' + str(bin(data))) data = chip_misc.readRegister(MCP23S17.GPIOB) print('PEACK, S0, S1, BHE, LOCK, M/IO, COD/INTA, HLDA: ' + str(bin(data)))
Ожидаемые значения выглядят так:
A7-0: 0b11111111 A15-8: 0b11111111 A23-16: 0b11111111 PEACK, S0, S1, BHE, LOCK, M/IO, COD/INTA, HLDA: 0b11111000
Как ни странно, вместо этого я увидел такое:
A7-0: 0b11111111 A15-8: 0b11111000 A23-16: 0b11111111 PEACK, S0, S1, BHE, LOCK, M/IO, COD/INTA, HLDA: 0b11111000
Сложно не заметить, что значения во второй и четвёртой строке одинаковы. Я проверил все соединения, всё разобрал, выполнил отладку через светодиоды, чтобы убедиться, что записываемые мной значения записываются туда, куда нужно, заменил чип, привязанный к контактам A15-8, поменял процессор на запасной, перечитал код тысячу раз, но ничто не помогло.

А потом я обнаружил упомянутый выше трюк с аппаратной адресацией, и всё начало работать идеально. Если всё проходит нормально, то мы можем сбросить флаг RESET, после чего начнётся процесс загрузки.
chip_flag.writeRegister(MCP23S17.GPIOB, FLAG_CLK | FLAG_RESET) time.sleep(0.001) chip_flag.writeRegister(MCP23S17.GPIOB, 0) time.sleep(0.001)
Инициализация
После этого в пределах 50 тактов процессор должен начать считывать из адреса 0xFFFFF0 первую команду для исполнения. Флаги COD/INTA, M/IO, S0 и S1 определяют, что намеревается делать процессор.
|
|
|
| Цикл шины |
|---|---|---|---|---|
0 | 0 | 0 | 0 | Подтверждение прерывания |
0 | 1 | 0 | 0 | Остановка/отключение |
0 | 1 | 0 | 1 | Чтение данных из памяти |
0 | 1 | 1 | 0 | Запись данных в память |
1 | 0 | 0 | 1 | Чтение ввода-вывода |
1 | 0 | 1 | 0 | Запись ввода-вывода |
1 | 1 | 0 | 1 | Чтение команды из памяти |
Менее интересные операции я не стал заносить в таблицу; их можно посмотреть в спецификации. Для нашего небольшого теста мне нужны лишь четыре:
остановка/отключение
запись данных в память
чтение данных из памяти
чтение команды из памяти
Итак, мы начнём отправлять тактовые сигналы и подождём, пока не достигнем первой операции чтения команды из памяти:
cycle = 1 while True: print(f'#{cycle}') chip_flag.writeRegister(MCP23S17.GPIOB, FLAG_CLK) time.sleep(0.001) chip_flag.writeRegister(MCP23S17.GPIOB, 0) time.sleep(0.001) data = chip_misc.readRegister(MCP23S17.GPIOB) PEACK = data & FLAG_PEACK S0 = data & FLAG_S0 S1 = data & FLAG_S1 BHE = data & FLAG_BHE LOCK = data & FLAG_LOCK M_IO = data & FLAG_M_IO COD_INTA = data & FLAG_COD_INTA HLDA = data & FLAG_HLDA if not COD_INTA and M_IO and not S1 and not S0: print('halt / shutdown') sys.exit(0) elif not COD_INTA and M_IO and not S1 and S0: print('Memory data read') elif not COD_INTA and M_IO and S1 and not S0: print('Memory data write') elif COD_INTA and M_IO and not S1 and S0: print('Memory instruction read') time.sleep(0.01) cycle += 1
При успешном достижении этого этапа мы можем начать посылать, например, команды NOP (0x90). Переключим шину данных в режим записи, отправим в неё команду NOP, отправим тактовый сигнал, а затем снова переключим шину данных в режим чтения.
chip_data.writeRegister(MCP23S17.IODIRA, 0x00) chip_data.writeRegister(MCP23S17.IODIRB, 0x00) chip_data.writeRegister(MCP23S17.GPIOA, 0x90) chip_data.writeRegister(MCP23S17.GPIOB, 0x90) chip_flag.writeRegister(MCP23S17.GPIOB, FLAG_CLK) time.sleep(0.001) chip_flag.writeRegister(MCP23S17.GPIOB, 0) time.sleep(0.001) chip_data.writeRegister(MCP23S17.IODIRA, 0xFF) chip_data.writeRegister(MCP23S17.IODIRB, 0xFF)
Сложные математические операции
Всё это хорошо и здорово, но давайте перейдём к чему-нибудь более интересному, к тому, что требует и чтения, и записи в память: к простой маленькой программе, которая считывает два числа из памяти, складывает их и записывает результат обратно в память.
Так как мы начинаем очень близко к концу памяти (0xFFFFF0), места у нас не так много, поэтому сначала нам нужно куда-нибудь перейти.
reset.asm:
[cpu 286] org 0xfff0 jmp 0x0000:0x0500
Потом идёт сложение:
add.asm:
[cpu 286] org 0x0500 xor ax, ax mov ds, ax mov ax, [num1] add ax, [num2] mov [result], ax hlt ; Данные num1 dw 0x1234 num2 dw 0x000a result dw 0x0000
При помощи программы nasm можно сгенерировать из этого двоичный файл:
$ nasm reset.asm $ nasm add.asm
Затем при помощи короткого скрипта на Python мы можем преобразовать его в удобный для Python формат, чтобы можно было загрузить его в виртуальную память:
hex_dump.py:
import sys with open(sys.argv[1], "rb") as f: data = f.read() hex_values = ", ".join(f"0x{byte:02x}" for byte in data) print(f"[{hex_values}]")
$ python hex_dump.py reset [0xea, 0x00, 0x05, 0x00, 0x00] $ python hex_dump.py add [0x31, 0xc0, 0x8e, 0xd8, 0xa1, 0x0f, 0x05, 0x03, 0x06, 0x11, 0x05, 0xa3, 0x13, 0x05, 0xf4, 0x34, 0x12, 0x0a, 0x00, 0x00, 0x00]
Для симуляции памяти я написал небольшой класс:
class Memory: def __init__(self): self.__data = {} def load(self, base, data): for i, b in enumerate(data): self.__data[base + i] = b def __getitem__(self, address): return self.__data.get(address, 0x00) def __setitem__(self, address, value): self.__data[address] = value & 0xFF
Это простой dict со вспомогательной функцией, позволяющий загружать данные в произвольные адреса. Что мы потом и делаем при помощи кода, сгенерированного nasm:
MEMORY = Memory() MEMORY.load(0x000500, [ 0x31, 0xc0, 0x8e, 0xd8, 0xa1, 0x0f, 0x05, 0x03, 0x06, 0x11, 0x05, 0xa3, 0x13, 0x05, 0xf4, 0x34, 0x12, 0x0a, 0x00, 0x00, 0x00 ]) MEMORY.load(0xfffff0, [ 0xea, 0x00, 0x05, 0x00, 0x00 ])
Осталось только обработать разные случаи. Но для начала нам нужно поговорить о флаге BHE и контакте A0.
|
| Функция |
|---|---|---|
0 | 0 | Передача слова |
0 | 1 | Передача байта в верхней половине шины данных ( |
1 | 0 | Передача байта в нижней половине шины данных ( |
То есть во время операции, задействующей шину данных, мы можем выполнять чтение/запись всей шины данных, её верхней части или нижней части.
В нашем случае чтение данных из памяти очень похоже на чтение команды из памяти, поэтому мы можем обрабатывать оба действия одним и тем же кодом. Достаточно лишь обрабатывать упомянутые выше флаги и использовать фальшивую память.
address = (a3 << 16) + (a2 << 8) + a1 if not COD_INTA and M_IO and not S1 and S0: print('Memory data read 0x{:06X}'.format(address)) else: print('Memory instruction read 0x{:06X}'.format(address)) if not BHE and not A0: print('Word transfer 0x{:02X}{:02X}'.format(MEMORY[address + 1], MEMORY[address])) chip_data.writeRegister(MCP23S17.IODIRA, 0x00) chip_data.writeRegister(MCP23S17.IODIRB, 0x00) chip_data.writeRegister(MCP23S17.GPIOA, MEMORY[address]) chip_data.writeRegister(MCP23S17.GPIOB, MEMORY[address + 1]) elif not BHE and A0: print('Byte transfer on upper half of data bus 0x{:02X}'.format(MEMORY[address])) chip_data.writeRegister(MCP23S17.IODIRB, 0x00) chip_data.writeRegister(MCP23S17.GPIOB, MEMORY[address]) elif BHE and not A0: print('Byte transfer on lower half of data bus 0x{:02X}'.format(MEMORY[address])) chip_data.writeRegister(MCP23S17.IODIRA, 0x00) chip_data.writeRegister(MCP23S17.GPIOA, MEMORY[address]) chip_flag.writeRegister(MCP23S17.GPIOB, FLAG_CLK) time.sleep(0.001) chip_flag.writeRegister(MCP23S17.GPIOB, 0) time.sleep(0.001) chip_data.writeRegister(MCP23S17.IODIRA, 0xFF) chip_data.writeRegister(MCP23S17.IODIRB, 0xFF)
Это ненамного сложнее, чем наше исходное решение с NOP, но здесь есть небольшая тонкость, о которую можно легко споткнуться. В каком порядке мы должны помещать байты в шину данных? Регистр GPIOA представляет младший байт шины данных, а GPIOB — старший. Поэтому, например, наша начальная команда JMP (0xea00) будет передаваться в виде 0x00ea (little-endian).
Сейчас вам стоит вернуться ненадолго назад и обратить внимание, что nasm уже выполнил похожие замены. Например, значение 0x1234, используемое для сложения, хранится в памяти, как 0x3412.
Запись данных в память реализовать очень легко; достаточно всего лишь использовать фальшивую память:
address = (a3 << 16) + (a2 << 8) + a1 print('Memory data write 0x{:06X}'.format(address)) if not BHE and not A0: print('Word transfer 0x{:02X}{:02X}'.format(d2, d1)) MEMORY[address] = d1 MEMORY[address + 1] = d2 elif not BHE and A0: print('Byte transfer on upper half of data bus 0x{:02X}'.format(d2)) MEMORY[address] = d2 elif BHE and not A0: print('Byte transfer on lower half of data bus 0x{:02X}'.format(d1)) MEMORY[address] = d1
Здесь тоже нужно соблюдать порядок little-endian, хоть и во время исполнения я не встречал случаев попыток записи в память одновременно двух байт.
А во время остановки/отключения мы просто выводим результат сложения из памяти и выполняем выход:
print('Result: 0x{:04X}'.format((MEMORY[0x000514] << 8) + MEMORY[0x000513])) sys.exit(0)
Окончательный результат
В конечном итоге, выполнение программы должно сгенерировать подобный вывод, при котором можно наблюдать, как она считывает первую команду JMP, выполняет переход на новый адрес, продолжает считывать команды оттуда, считывает два числа для сложения в памяти, а затем записывает результат обратно в память:
RESET A7-0: 0b11111111 A15-8: 0b11111111 A23-16: 0b11111111 PEACK, S0, S1, BHE, LOCK, M/IO, COD/INTA, HLDA: 0b11111000 START #40 Чтение команды из памяти 0xFFFFF0 Передача слова 0x00EA #43 Чтение команды из памяти 0xFFFFF2 Передача слова 0x0005 #46 Чтение команды из памяти 0xFFFFF4 Передача слова 0x0000 #49 Чтение команды из памяти 0xFFFFF6 Передача слова 0x0000 #52 Чтение команды из памяти 0xFFFFF8 Передача слова 0x0000 #67 Чтение команды из памяти 0x000500 Передача слова 0xC031 #70 Чтение команды из памяти 0x000502 Передача слова 0xD88E #73 Чтение команды из памяти 0x000504 Передача слова 0x0FA1 #76 Чтение команды из памяти 0x000506 Передача слова 0x0305 #79 Чтение команды из памяти 0x000508 Передача слова 0x1106 #82 Чтение команды из памяти 0x00050A Передача слова 0xA305 #85 Чтение команды из памяти 0x00050C Передача слова 0x0513 #88 Чтение данных из памяти 0x00050F Byte transfer on upper half of data bus 0x34 #91 Чтение данных из памяти 0x000510 Передача байта в нижнюю половину шины данных 0x12 #94 Чтение команды из памяти 0x00050E Передача слова 0x34F4 #99 Чтение данных из памяти 0x000511 Передача байта в верхнюю половину шины данных 0x0A #102 Чтение данных из памяти 0x000512 Передача байта в нижнюю половину шины данных 0x00 #115 Memory data write 0x000513 Передача байта в верхнюю половину шины данных 0x0A #116 Запись данных в память 0x000513 Передача байта в верхнюю половину шины данных 0x3E #119 Запись данных в память 0x000514 Передача байта в нижнюю половину шины данных 0x12 #120 Запись данных в память 0x000514 Передача байта в нижнюю половину шины данных 0x12 #123 Останов Результат: 0x123E
Было невероятно радостно впервые видеть правильный окончательный результат в конце исполнения. Думаю, я достиг того этапа, на котором можно остановиться и отдохнуть.
Разумеется, это лишь самое начало; мне предстоит ещё многое изучить. Стоит исследовать спецификацию процессора, или, возможно, подумать о том, как реализовать различную периферию (например, клавиатуру или текстовый дисплей).
Однако совершенно точно то, что для процессора эта реальность совершенно не виртуальна. Ему не важно, откуда поступают электрические сигналы, если они совместимы с его собственной внутренней реальностью.
