[Перевод] Архитектура ассемблера Go

image Привет, Хабр! Меня зовут Марко Кевац, я системный программист Badoo в команде «Платформа», и я очень люблю Go. Если сложить эти две вещи, то вы поймёте, насколько я люблю ассемблер в Go.

Совсем недавно мы рассказывали на Хабре о том, на каких конференциях побывали. Одной из них была GopherCon 2016, где практически всем запомнился доклад Роба «Командира» Пайка про Go-шный ассемблер. Представляю вам перевод его доклада, оформленный в виде статьи. Я постарался дать в тексте как можно больше ссылок на релевантные статьи в «Википедии».

Ассемблер? Это что-то древнее, как мамонт!


088e0fe48d6fdcadab8802311b06c973.png
Роб Пайк выступает на GopherCon 2016

Спасибо! Это самая воодушевлённая реакция на доклад о языке ассемблера за многие годы. Вы можете спросить: зачем нам вообще говорить об ассемблере? Причина есть, но о ней позже. Также вы можете спросить, какой интерес может представлять язык ассемблера. Давайте вернёмся в 1970 год и заглянем в руководство IBM: «Важнее всего понять следующую особенность языка ассемблера: он позволяет программисту использовать все функции компьютера System/360, как если бы он программировал на уровне машинных кодов System/360».

Во многих отношениях эти слова устарели, но основная идея по-прежнему верна: ассемблер — это способ взаимодействия с компьютером на самом базовом уровне. На самом деле, язык ассемблера необходим даже сегодня. Было время, когда в распоряжении программиста был только ассемблер.

Потом появились так называемые языки высокого уровня, например, Фортран и Кобол, которые медленно, но верно вытесняли ассемблер. Какое-то время даже язык С по сравнению с ассемблером считался высокоуровневым. Но потребность в ассемблере сохраняется и сегодня — из-за того уровня доступа к компьютеру, который он предоставляет.

Ассемблер необходим для начальной загрузки среды, для работы стеков, для переключения контекста. В Go, кстати, переключение между горутинами тоже реализовано на ассемблере. Есть ещё вопрос производительности: иногда можно вручную написать код, который работает эффективнее, чем результат работы компилятора. Например, существенная часть пакета math/big стандартной библиотеки Go написана на ассемблере, потому что базовые процедуры этой библиотеки гораздо более эффективны, если вы обходите компилятор и реализуете что-то лучше, чем сделал бы он. Иногда ассемблер нужен для работы с новыми и необычными функциями устройств или с возможностями, недоступными для более высокоуровневых языков — например, новыми криптографическими инструкциями современных процессоров.

Но для меня самое важное в языке ассемблера заключается в том, что его терминами многие люди мыслят о компьютере. Язык ассемблера даёт представление о наборах инструкций, принципах работы компьютера. Даже если вы не программируете на ассемблере — и я надеюсь, что здесь таких нет, — вам стоит иметь представление об ассемблере, просто чтобы понимать, как работает компьютер. Однако я не буду уделять слишком много внимания самому языку ассемблера, и это соответствует идее моего доклада.

Много разных и хороших ассемблеров


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

IBM System/360

1        PRINT NOGEN
2 STOCK1 START 0
3 BEGIN  BALR  11,0
4        USING *,11
5        MVC   NEWOH,OLDOH
6        AP    NEWOH,RECPT
7        AP    NEWOH,ISSUE
8        EOJ
11 OLDOH DC    PL4'9'
12 RECPT DC    PL4'4'
13 ISSUE DC    PL4'6'
14 NEWOH DS    PL4
15       END   BEGIN

Это ассемблер IBM System/360. Именно про этот компьютер была цитата в начале. Не обращайте внимания на смысл, просто взгляните.

А это — чтобы показать общую картину.

Apollo 11 Guidance Computer

# TO ENTER A JOB REQUEST REQUIRING NO VAC AREA:

          COUNT     02/EXEC
                
NOVAC     INHINT
          AD        FAKEPRET     # LOC(MPAC +6) - LOC(QPRET)
          TS        NEWPRIO      # PRIORITY OF NEW JOB + NOVAC C(FIXLOC)

          EXTEND
          INDEX     Q            # Q WILL BE UNDISTURBED THROUGHOUT.
          DCA       0            # 2CADR OF JOB ENTERED.
          DXCH      NEWLOC
          CAF       EXECBANK
          XCH       FBANK
          TS        EXECTEM1
          TCF       NOVAC2       # ENTER EXECUTIVE BANK.

Это — ассемблерный код для бортового управляющего компьютера корабля «Аполлон-11». Все его программы были написаны полностью на ассемблере. Ассемблер помог нам добраться до Луны.

PDP-10

TITLE   COUNT
 
A=1                             ;Define a name for an accumulator.

START:  MOVSI A,-100            ;initialize loop counter.
                                ;A contains -100,,0
LOOP:   HRRZM A,TABLE(A)        ;Use right half of A to index.
        AOBJN A,LOOP            ;Add 1 to both halves (-77,,1 -76,,2 etc.)
                                ;Jump if still negative.
        .VALUE                  ;Halt program.

TABLE:  BLOCK 100               ;Assemble space to fill up.

END START                       ;End the assembly.

Это ассемблер для PDP-10, и он весьма подробно прокомментирован, если сравнивать с другими примерами.

PDP-11

/ a3 -- pdp-11 assembler pass 1

assem:
        jsr     pc,readop
        jsr     pc,checkeos
        br      ealoop
        tst     ifflg
        beq     3f
        cmp     r4,$200
        blos    assem
        cmpb    (r4),$21   /if
        bne     2f
        inc     ifflg
2:
        cmpb    (r4),$22   /endif
        bne     assem
        dec     ifflg
        br      assem

Это фрагмент для PDP-11. Более того, это кусок кода для ассемблера в Unix v6 — который, разумеется, написан на ассемблере. Язык С стал использоваться позже.

Motorola 68000

strtolower      public
                link    a6,#0           ;Set up stack frame
                movea   8(a6),a0        ;A0 = src, from stack
                movea   12(a6),a1       ;A1 = dst, from stack
loop            move.b  (a0)+,d0        ;Load D0 from (src)
                cmpi    #'A',d0         ;If D0 < 'A',
                blo     copy            ;skip
                cmpi    #'Z',d0         ;If D0 > 'Z',
                bhi     copy            ;skip
                addi    #'a'-'A',d0     ;D0 = lowercase(D0)
copy            move.b  d0,(a1)+        ;Store D0 to (dst)
                bne     loop            ;Repeat while D0 <> NUL
                unlk    a6              ;Restore stack frame
                rts                     ;Return
                end

Cray-1
ident slice
         V6        0               ; initialize S
         A4        S0              ; initialize *x
         A5        S1              ; initialize *y
         A3        S2              ; initialize i
loop     S0        A3
         JSZ       exit            ; if S0 == 0 goto exit
         VL        A3              ; set vector length
         V11       ,A4,1           ; load slice of x[i], stride 1
         V12       ,A5,1           ; load slice of y[i], stride 1
         V13       V11 *F V12      ; slice of x[i] * y[i]
         V6        V6 +F V13       ; partial sum
         A14       VL              ; get vector length of this iteration
         A4        A4 + A14        ; *x = *x + VL
         A5        A5 + A14        ; *y = *y + VL
         A3        A3 - A14        ; i = i - VL
         J        loop
 exit

Это ассемблер для Motorola 68000, а это — для Cray-1. Этот пример мне нравится, он из диссертации Роберта Грисемеера (Robert Griesemer). Напоминает о том, как всё начиналось.

5d4474ba8ef74ac8c8a340573d8d7a3a.png
Роберт Грисемеер выступает на GopherCon 2015

Вы можете заметить, что всё это разные языки, но в чём-то они похожи: у них есть общая, очень чёткая, структура.

Инструкции

subroutine header
label:
    instruction operand...    ; comment
    ...

Операнды
register
literal constant
address
register indirection (register as address)
...

Программы на ассемблере обычно записываются в столбик: слева — метки, потом инструкции, операнды и, наконец, справа — комментарии. В роли операндов обычно выступают регистры, константы или адреса памяти, но синтаксически для разных архитектур они довольно похожи. Есть и исключения. Пример Cray выделяется на общем фоне: команда сложения записывается в виде символа +, как в арифметическом выражении. Но смысл везде один и тот же: вот это команда сложения, а это — регистры. То есть всё это в действительности одно и то же.

Другими словами, даже давным-давно, даже в мои времена, процессоры были примерно такими же, как сейчас. Есть контрпримеры, но большая часть процессоров (и уж точно все процессоры, на которых работает Go), по сути, остались прежними, если не обращать внимания на детали. Если не углубляться в подробности, то можно прийти к интересному выводу: для всех этих компьютеров можно составить общую грамматику. Для понимания этого факта потребовалось около 30 лет.

Гениальная идея Кена


ced28f063ce9d0b09ecbf6eb36ab1df9.png
Слева направо: Роберт Грисемеер, Роб Пайк, Кен Томпсон

Примерно в середине 1980-х годов Кен Томпсон (Ken Thompson) и я начали думать над разработкой, которая потом превратилась в Plan 9. Кен написал новый компилятор С, который лёг в основу компилятора С в инструментах Go и использовался до недавнего времени. Это было на компьютере с симметричной многопроцессорной архитектурой компании Sequent, в котором использовались процессоры National 32000. По-моему, это был первый 32-разрядный микропроцессор, доступный в широкой продаже в виде интегральной микросхемы. Но Кен сделал одну интересную штуку — её некоторые тогда не поняли, но потом она оказалась очень важной. Это вообще характерная особенность Кена.

Компилятор не генерировал машинные инструкции — он выдавал нечто вроде псевдокода. А потом вторая программа (по сути, линкер) принимала результат работы компилятора и преобразовывала эти псевдоинструкции в настоящие инструкции.

Инструкция наподобие

MOVW    $0, var

могла стать просто
XORW    R1, R1
STORE   R1, var

Приведу абстрактный пример. Есть, например, инструкция MOVW, которая помещает ноль в переменную. Выданный линкером код, который будет выполняться на компьютере, может состоять, например, из инструкций XORW, для которой в качестве обоих операндов указан один и тот же регистр (то есть она обнуляет значение регистра), и STORE, которая помещает значение этого регистра в переменную. Не беспокойтесь насчёт деталей: суть здесь в том, что инструкции, которые выполняет компьютер, могут не совсем соответствовать тому, что мы вводим в ассемблер.

Этот процесс составления реальных инструкций на основе псевдоинструкций мы называем выбором инструкций. Отличный пример псевдоинструкции — инструкция возвращения из функции, которую Кен назвал RET. Она так и называется уже 30 лет, но её реализация зависит от того, на каком компьютере она выполняется. В руководствах к некоторым компьютерам её так и называют RET, но в других это может быть переход по адресу, который содержится в регистре, или перенаправление на адрес в специальном регистре, или что-нибудь совсем другое.

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

db99b1928f554b429f7a0f5a0a27d965.png

В результате процесс выглядит приблизительно так. Верхняя строчка примерно соответствует традиционным архитектурам. По-моему, GCC и сегодня работает так же. Это компилятор, он принимает высокоуровневый код и преобразует его в код на языке ассемблера. Ассемблер генерирует реальные инструкции, а линкер связывает отдельные части, создавая бинарник. Две нижние строки показывают, как обстоит дело в Plan 9. Здесь ассемблер, по сути, разделили пополам: одна половина осталась в компиляторе, а вторая — стала частью линкера. Стрелки, пересекающие красную черту, — это потоки псевдоинструкций в двоичном представлении. Ассемблер в Plan 9 служит одной цели: он предоставляет вам возможность записывать в текстовом виде инструкции, преобразующиеся в псевдоинструкции, которые обрабатывает линкер.

Ассемблер Go


Такая ситуация сохранялась много поколений. И ассемблеры Plan 9 ей соответствовали: это были отдельные программы на С с грамматикой Yacc, своей для каждой архитектуры. Они образовывали своеобразный набор или пакет, но это были отдельные, независимо написанные, программы. У них был общий код, но не совсем… В общем, всё было сложно. А потом начал появляться Go… когда это было… в 2007 году мы начали… в эту кучу программ со смешными именами добавились ещё компиляторы Go — 8g и 6g. И в них использовалась та же модель. Они соответствуют средней строчке на диаграмме. И то, как было реализовано это разделение, давало множество преимуществ для внутреннего устройства Go, но сегодня я не успею подробно об этом рассказать.

232a313fe95a2758588480f685e04a9e.png
Расс Кокс выступает на GopherCon 2015

В Go 1.3 мы захотели избавиться от всего кода на С и реализовать всё исключительно на Go. Это потребовало бы времени. Но в выпуске Go 1.3 мы запустили этот процесс. Началось всё с того, что Расс (Russ Cox) взял большой кусок линкера и отделил его. Так появилась библиотека liblink, и большую её часть занимал так называемый алгоритм выбора инструкций. Сейчас эта библиотека называется obj, но тогда она называлась liblink. Компилятор использовал эту библиотеку liblink, чтобы преобразовывать псевдоинструкции в настоящие инструкции. В пользу такого решения было несколько аргументов.

Самый важный из них — ускорение процесса сборки. Даже несмотря на то, что компилятор теперь проделывает больше работы — сейчас он выбирает инструкции, тогда как раньше этим занимался линкер. Благодаря такому устройству, он проделывает это только один раз для каждой библиотеки. Раньше, если вам нужно было скомпоновать пакет fmt, например, выбор инструкций для Printf выполнялся бы каждый раз заново. Очевидно, это глупо. Теперь мы делаем это один раз, и компоновщику не приходится этим заниматься. В результате компилятор работает медленнее, но сборка в целом ускоряется. Ассемблер может быть устроен так же и может использовать библиотеку obj.

Самая важная особенность этого этапа была в том, что для пользователя ничего не изменилось: входной язык остался прежним, вывод остался прежним, это всё тот же двоичный файл, только детали другие. Вот общая схема старой архитектуры, я её уже показывал: компилятор, ассемблер, линкер. В мире Plan 9 линкером управляет компилятор или ассемблер.

Старая архитектура:

e935b73a5b78410c86a0ce26dd5a6510.png

Новая архитектура:

60c6bce59ed840ad81837f6eb637e731.png

В версии 1.3 мы перешли на вот такое устройство. Как видите, теперь у нас получился намного более традиционный линкер: он получает реальные инструкции, потому что библиотека obj, которая выполняет выбор инструкций, перестала быть частью компоновщика и стала последним этапом компилятора и ассемблера. То есть теперь ассемблер и компилятор больше соответствуют старой схеме, если включить ассемблер в процесс.

Этот новый ассемблер Go — странная штука. Ничего похожего просто не существует. Что он делает? Он преобразует текстовые описания псевдоинструкций в реальные инструкции для линкера. В 1.5 мы сделали большой шаг — избавились от С. Для этого мы проделали большую подготовительную работу в 1.3 и 1.4, и теперь эта задача, наконец, решена. Расс написал транслятор, который преобразовывал старые исходники компиляторов Go (и, кстати, линкеров), написанные на С, в программы на Go.

Старая библиотека liblink была встроена в набор библиотек, который в совокупности мы здесь называем obj. В результате есть штука, которая называется obj, это портируемая часть, и подкаталоги с машиннозависимыми частями, которые хранят сведения об особенностях каждой архитектуры. Этой работе посвящено несколько докладов: это сама по себе интересная история. Два года назад Расс выступил на GopherCon с докладом, но он уже довольно устарел. В действительности мы поступили совсем не так, как он тогда рассказал. А потом на GopherFest 2015 я представил более общий, но более точный обзор изменений в версии 1.5.

GOOS=darwin GOARCH=arm go tool compile prog.go

Так вот. Раньше компиляторы назывались 6g, 8g и другими странными именами. Теперь это одна программа под названием compile. Вы запускаете инструмент compile и настраиваете его, устанавливая значения стандартных переменных среды GOOS (произносится «goose», как «гусь») и GOARCH (произносится «gorch», как «пьяный похотливый парень в баре») — именно так их названия произносятся официально. Для линкера мы сделали то же самое. Есть инструмент link, вы устанавливаете значения GOOS и GOARCH — и можете компилировать свою программу. Вы можете задаться вопросом: «Как один компилятор может поддерживать все эти архитектуры? Мы-то знаем, что кроссплатформенная компиляция — это очень, очень сложно». На самом деле, нет. Вам просто нужно заранее всё подготовить. Обратите внимание вот на что: здесь есть только один входной язык — Go. С точки зрения компилятора результат работы тоже один — псевдоинструкции в двоичной форме, которые передаются в библиотеку obj. То есть нам нужно только настроить библиотеку obj, задав значения переменных на момент запуска инструмента. Скоро вы узнаете, как это делается.

Что касается ассемблера, мы выполняли машинное транслирование ассемблеров с языка С на Go, но это не идеальное решение, мне оно не нравилось. Я предложил написать на Go с нуля единственную программу, которая бы заменила их все, — asm. Настройка опять же выполнялась бы исключительно через GOOS и GOARCH. Вы можете заметить, что язык ассемблера и Go — не одно и то же. У каждого процессора есть свой набор инструкций, свой набор регистров, это не единый язык вывода. Что с этим делать? Но на самом деле, они, по сути, одинаковы. Смотрите.

package add

func add(a, b int) int {
    return a + b
}

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

32-bit x86 (386)

TEXT add(SB), $0-12
    MOVL    a+4(FP), BX
    ADDL    b+8(FP), BX
    MOVL    BX, 12(FP)
    RET

64-bit x86 (amd64)
TEXT add(SB), $0-24
    MOVQ    b+16(FP), AX
    MOVQ    a+8(FP), CX
    ADDQ    CX, AX
    MOVQ    AX, 24(FP)
    RET

32-bit arm
TEXT add(SB), $-4-12
    MOVW    a(FP), R0
    MOVW    b+4(FP), R1
    ADD     R1, R0
    MOVW    R0, 8(FP)
    RET

64-bit arm (arm64)
TEXT add(SB), $-8-24
    MOVD    a(FP), R0
    MOVD    b+8(FP), R1
    ADD     R1, R0
    MOVD    R0, 16(FP)
    RET

S390 (s390x)
TEXT add(SB), $0-24
    MOVD    a(FP), R1
    MOVD    b+8(FP), R2
    ADD     R2, R1, R1
    MOVD    R1, 16(FP)
    RET

Вот вариант для 32-разрядной архитектуры. Не задумывайтесь о деталях — просто взгляните на картину в целом. Вот результат для 64-разрядной архитектуры x86, её ещё называют AMD64, вот — для 32-разрядной архитектуры ARM, для 64-разрядной архитектуры ARM, а вот — для архитектуры IBM System/390; для нас они новые, но для всех остальных явно нет.

64-bit MIPS (mips64)

TEXT add(SB), $-8-24
    MOVV    a(FP), R1
    MOVV    b+8(FP), R2
    ADDVU   R2, R1
    MOVV    R1, 16(FP)
    RET

64-bit Power (ppc64le)
TEXT add(SB), $0-24
    MOVD    a(FP), R2
    MOVD    b+8(FP), R3
    ADD     R3, R2
    MOVD    R2, 16(FP)
    RET

Вот код для 64-разрядной архитектуры MIPS, вот — для 64-разрядной архитектуры POWER. Вы можете заметить, что они похожи. Причина в том, что они, по сути, являются одним и тем же языком. Отчасти потому, что они так устроены: по сути дела, мы 30 лет использовали ассемблер National 32000, меняя только лишь железо, на котором он использовался. Но ещё и потому, что некоторые из них действительно идентичны. Это просто инструкции, регистры, операнды, константные значения, метки — всё одно и то же. Единственное важное отличие заключается в том, что у инструкций и регистров разные имена. Ещё иногда отличаются смещения, но это зависит от размера машинного слова.

Всё это сводится к ассемблеру National 32000, который написал Кен. Это язык ассемблера National 32000, каким его себе представляет Кен, адаптированный для современного PowerPC. Таким образом, у нас есть всё необходимое — общий язык ввода, библиотека obj в бэкэнде — и мы можем писать на ассемблере. При таком подходе возникает проблема: если взять руководство National или PowerPC и взглянуть на язык ассемблера, то окажется, что он выглядит не так. У него другой синтаксис, иногда другие имена инструкций, потому что на каком-то уровне это фактически псевдоинструкции. На самом деле, это неважно.

Непосвящённого человека внешний вид ассемблера Go — все эти заглавные буквы и странные штуки — может серьёзно смутить. Но поскольку для всех этих компьютеров у нас есть общий язык ассемблера, мы можем получить замечательный результат, о котором речь пойдёт ниже. Поэтому мы считаем, что это оправданный компромисс и достигнуть его не очень сложно. Стоит научиться программировать на 68000 и на ассемблере Кена — и вы автоматически сможете писать программы для PowerPC. Какая разница?

Как же это работает? Ассемблер версии 1.5 я считаю идеалом ассемблера. Дайте ему любой компьютер — и он транслирует ассемблер для него. Это новая программа, написанная полностью на Go. В ней есть общие лексический и синтаксический анализаторы, которые просто принимают ваш входной код, всё что вы ему дадите, и преобразуют инструкции в структуры данных, описывающие инструкции в двоичной форме, а потом передают результат новой библиотеке obj, в которой содержится информация о конкретных платформах.

Большая часть кода, который лежит в основе ассемблера, полностью портируема, она не содержит ничего, никакой интересной информации об архитектурах, но там есть таблица со сведениями об именах регистров. Ещё есть несколько вещей, которые связаны с работой операндов, но там это довольно просто. И всё это настраивается при запуске программы в соответствии со значением переменной GOARCH. GOOS используется в очень, очень редких случаях, в которые мы не будем углубляться, — основные характеристики определяет GOARCH. Ещё есть внутренний пакет для ассемблера, который называется arch, он создаёт эти таблицы на лету, динамически извлекая их из библиотеки obj. А вот фрагмент настоящего кода.

import (
    "cmd/internal/obj"
    "cmd/internal/obj/x86"
)

func archX86(linkArch *obj.LinkArch) *Arch {
    register := make(map[string]int16)
    // Create maps for easy lookup of instruction names etc.
    for i, s := range x86.Register {
        register[s] = int16(i + x86.REG_AL)
    }
    instructions := make(map[string]obj.As)
    for i, s := range obj.Anames {
        instructions[s] = x86.As(i)
    }
    return &Arch{
        Instructions:   instructions,
        Register:       register,
        ...
    }
}

Он немного упрощён, но суть передаёт. Это arch, внутренний пакет ассемблера. Это процедура, которая настраивает весь ассемблер для архитектуры x86. Этот код используется и в 32-, и в 64-разрядной архитектуре, они с этой точки зрения идентичны. А потом мы просто запускаем цикл… Цикл, перебирающий имена регистров из библиотеки obj, определённой в пакете x86 для процедур obj. И мы просто настраиваем карту, которая сопоставляет имена регистров и двоичный код в соответствии с данными из obj. А потом делаем то же самое для инструкций.

Эти коды на самом деле не являются кодами инструкций, вы не найдёте их в руководстве. Это буквально алфавитный перечень всех вещей — повторю, что это лишь псевдоинструкции, а не настоящие команды. Но сейчас мы знаем все имена всех регистров и все инструкции. И описание архитектуры, которое мы вернём, содержит только эти две карты. Вот имена инструкций, которые вы знаете, вот имена регистров, которые вы знаете, и ещё немного вещей, которые я не упомянул, но там всё довольно просто. Это вся необходимая информация для того, чтобы преобразовывать язык ассемблера в инструкции для этих компьютеров. На самом деле, парсер просто проводит сопоставление строк, чтобы найти нужные инструкции. Вы в столбце 1, он содержит слово, это инструкция, ищем её в таблице инструкций. Это довольно просто.

ADDW AX, BX

&obj.Prog{
    As: arch.Instructions["ADDW"],
    From: obj.Addr{Reg: arch.Register["AX"]},
    To: obj.Addr{Reg: arch.Register["BX"]},
    ...
}

Вот пример. Это инструкция «добавить слово» (ADDW) для процессора 386, которую вы можете использовать в реальном коде или обнаружить в выводе компилятора. Она выглядит так: ADDW AX, BX. Ассемблер знает, что в первом столбце содержится инструкция. Всё, что он делает, — проводит поиск по списку инструкций, который содержится в структуре для архитектуры, индексирует карту по ADDW и получает код. Он помещает его в величину с именем A — странное название, но уж какое есть. Далее идут источник и приёмник, два операнда. То же самое — ищем регистр AX, ищем регистр BX. На самом деле, всё немного сложнее, но смысл такой: вы просто ищете слова, которые подаются на вход, помещаете их в структуру данных и передаёте библиотеке obj. Суть в том, что это просто обработка текста. Всё, что делает ассемблер, — обрабатывает текст. Он ничего не знает о семантике архитектуры этих устройств.  

Ещё он должен выполнить проверки, убедиться, что результат лексически и синтаксически корректен. Это легко осуществляется стандартными методами анализа. То есть ассемблер анализирует, например, операнды, на своём ли они месте… Там есть несколько ухищрений и некрасивых специальных случаев, потому что в некоторых компьютерах используются странные режимы операндов, которых нет в других, но и их достаточно просто применить. Это не проблема.
Самое важное здесь то, что все семантические проверки выполняет библиотека obj. Базовое правило здесь такое: если структура, которая описывает инструкцию, передаётся в библиотеку obj, и та обрабатывает её без ошибок, причина в том, что библиотека obj знает, как преобразовать её в инструкцию для физического компьютера. Поэтому если код парсится, а библиотека obj его обрабатывает, то всё готово. Это вся идея.

Она, очевидно, безумна, но работает, и мы можем доказать это на практике. Тестирование мы проводили так. Помните, у нас были старые ассемблеры, они были написаны на С, и мы буквально брали программы… я написал ассемблер и библиотеку obj для него, мы объединили их, а потом взяли программу и собирали её с помощью старого ассемблера и нового. И мы повторяли это снова и снова, пока новый ассемблер не выдал то же, что и старый, с точностью до мельчайших частей. А потом мы взяли следующую программу. К тому времени, как мы обработали весь стандартный пакет, у нас был рабочий ассемблер. Вот, в общем-то, и всё. Такое A/B-тестирование — превосходный способ проводить подобные проверки. Оно сработало просто отлично.

Вначале я провёл его для 386, потом — для AMD64. Для каждой архитектуры был ряд особых случаев, но со временем ситуация улучшалась. К тому моменту, как мы добрались до PowerPC, добавление в ассемблер поддержки этой новой архитектуры заняло пару часов — настолько это было просто. Самой удивительной и, на мой взгляд, самой интересной особенностью этой работы было то, что при написании ассемблера с поддержкой этих четырёх архитектур я не заглянул ни в одно руководство к аппаратной платформе. Эта задача сводится исключительно к обработке текста на основе того факта, что вся нужная информация хранится в библиотеке obj, поэтому мне просто незачем всё это знать.

По-моему, это довольно круто. Так вот, в итоге у нас теперь есть одна программа на Go, которая заменяет кучу разных программ на С и Yacc. Поддерживать одну программу намного проще, чем несколько. Исправлять ошибки в одной программе тоже гораздо проще. Программа написана на Go, поэтому её можно должным образом тестировать, что хорошо. Она зависит от библиотеки, от которой зависит всё, то есть правильность обеспечивается почти автоматически, и это отлично. Я преувеличил, но в целом это так. И у нас есть новый ассемблер, который практически идеально совместим со всеми старыми. Области несовместимости по большей части соответствуют различиям между ассемблерами и небольшим особенностям работы разных штук. Но сейчас всё полностью унифицировано. Мы создали действительно универсальный язык ассемблера для всех этих платформ. И портируемость обеспечивается очень просто.

Я показал, кажется, все архитектуры, которые он сейчас поддерживает, когда мы просматривали те примеры функции сложения, и многие из них были созданы людьми из open-source-сообщества. Если вы зайдёте в репозиторий Git и взглянете на коммиты, которые добавляют эти новые архитектуры, то увидите, что объём информации, необходимый для поддержки нового компьютера, в действительности очень небольшой. Это здорово. По-моему, это говорит в пользу выбранного подхода. Мне приятно видеть, с какой лёгкостью open-source-сообщество добавляет новые архитектуры.

Как я говорил, ассемблер настраивается путём загрузки, по сути, таблиц, известных библиотеке obj и представляющих архитектуру компьютера. Таблицы ведь можно генерировать? Мне не нравится писать таблицы от руки. На самом деле, не совсем так, но… дизассемблеры — я рассказываю не про них, но их использует инструмент pprof, и некоторые другие компоненты — по-моему, их все, или, по крайней мере, значительную их часть, создал Расс одним тоскливым днём. Он тогда написал программу, обрабатывающую описание инструкций в формате PDF и преобразующую его в битовые шаблоны, которые нужны для кодирования инструкций. У нас есть описание компьютера, которое компьютер может прочитать, — почему бы не прочитать его компьютером? В результате дизассемблеры были сгенерированы на компьютере. В другом направлении всё это не так просто, там есть свои особенности. Прежде всего такое преобразование должно быть абсолютно точным. Если дизассемблер ошибётся, это не страшно.

Отсутствие ошибок — это приятно, но особые случаи не очень важны. А вот в другом направлении результат должен быть абсолютно точным. Поэтому такая задача намного сложнее. Но Расс работает над ней. Я надеялся, что к сегодняшнему дню она будет решена, но пока не удалось. Однако я надеюсь, что к концу года вы сможете дать нам PDF с описанием инструкций новой архитектуры, мы нажмём на кнопку — и вернём вам рабочий ассемблер. Цель здесь именно такая.
Это значит, что у нас есть ассемблер, большей частью сгенерированный на компьютере, по крайней мере, после завершения предварительной ручной работы.

Заключение


В заключение скажу вот что. Язык ассемблера везде по сути одинаков, несмотря на то, что некоторые утверждают обратное. И в области портируемости мы дошли до того, что именно таким его надо считать. На основе этих идей мы можем создать подлинно общий язык ассемблера, который позволит говорить с компьютером на самом базовом уровне, но не учить для этого каждый раз новый синтаксис и не осваивать новый инструмент. Если у вас есть такая единая среда, с которой аккуратным и прозрачным образом взаимодействуют компилятор и линкер, писать на ассемблере намного проще. Этого можно добиться путём динамической загрузки таблиц, автоматически составленных на основе пары переменных среды. Они необходимы — печально, но это так. И однажды — надеюсь, ещё в этом году — мы сможем создавать эти таблицы для архитектур автоматически. Как видите, мы взяли язык ассемблера, который часто относят к наименее портируемым вещам, и создали для него портируемое решение.

Это и есть путь Go. Спасибо.

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

© Habrahabr.ru