«Что есть "реальность"? И как определить её? Весь набор ощущений: зрительных, осязательных, обонятельных — это сигналы рецепторов, электрические импульсы, воспринятые мозгом», — Морфеус, фильм «Матрица»

Если процессор — это мозг компьютера, то может ли он быть ещё и частью некой виртуальной реальности? Симулированная память, программно-определяемая периферия, искусственно сгенерированные прерывания...

Моим первым компьютером был 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/INTAM/IOS0 и S1 определяют, что намеревается делать процессор.

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.

BHE

A0

Функция

0

0

Передача слова

0

1

Передача байта в верхней половине шины данных (D15 - D8)

1

0

Передача байта в нижней половине шины данных (D7 - D0)

То есть во время операции, задействующей шину данных, мы можем выполнять чтение/запись всей шины данных, её верхней части или нижней части.

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

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

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

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

Однако совершенно точно то, что для процессора эта реальность совершенно не виртуальна. Ему не важно, откуда поступают электрические сигналы, если они совместимы с его собственной внутренней реальностью.