Выпуск 6
[Main] [TMC 1] [TMC 2] [About]
[ 1 2 3 4 5 6 7 * ]


Доброе время суток, уважаемые читатели!

Наконец-то свет увидел еще один выпуск рассылки. Со времен последнего прошло много времени, и материал, вероятно, придется восстанавливать в памяти. Жаль делать столь длительные промежутки между выпусками, однако, по-другому пока никак не получается. Моя теперешняя научная работа в университете, к сожалению, не связана с предметом рассмотрения этой рассылки, поэтому мне удается размышлять, изучать и записывать новые идеи очень редко.

С этого выпуска курс немного изменится. Масштаб, на который я рассчитывал, выполнять не удается, поэтому придется опять довольствовать только своими возможностями. Пристальное рассмотрение той архитектуры классов, которую я разработал (на самом деле, она претерпела несколько очень значительных изменений с момента создания до опубликования) дало мне понять, что такую гибкость пока применять некуда. Решение состоит в том, чтобы сделать небольшой эмулятор простого процессора (вероятно, более простого, чем в TMC 1).

Начнем с арифметики. Размер байта в этой архитектуре примем равным 12 битам. Не хотелось бы обосновывать это решение, потому что оно было принято почти случайно. На самом деле, на данном этапе это пока не так важно. Итак, арифметика. Существует много способов представления чисел (далее речь пойдет только о целых числах) в вычислительных машинах (далее вместо сокращения ВМ я буду использовать более привычное - ПК Персональный Компьютер, поскольку в дальнейшем буду описывать только их). Если следовать книге "Программирование на МАКРО-11 и организация PDP-11" Дж. У. Сичановского, то их три:

  1. Знак-модуль. Как следует из названия, в этом случае для представления числа используется какое-то количество бит для представления модуля числа и еще один бит - для указания знака числа. Достоинство этого метода заключается в том, что числа представляются просто, однако, у этого способа есть серьезные недостатки. Во-первых, это довольно сложный алгоритм сложения и вычитания, а во-вторых, наличие двойного представления нуля: +0 и -0.
  2. Дополнение до единицы. При этом способе записи, отрицательное число записывается так: берется соответствующее ему положительное число, дописывается слева нулями, а затем инвертируется (т.е. все 0 превращаются в 1, а все 1 - в 0). Это способ хорош тем, что числа могут складываться и вычитаться без проверки знака (мне кажется, эти арифметические утверждения элементарными, поэтому я их не уточняю. При желании вы можете либо сами проделать соответствующие проверки, либо обратиться к книге). Однако, этот способ тоже обладает рядом недостатков: также существует двоякое представление нуля и необходимо производить циклический перенос при сложении.
  3. Двоичное дополнение. Отрицательное число в этом случае записывается как двоичное дополнение положительного числа, равного ему по модулю. Осуществляется это так: вычитается число фиксированной длины (дополненное слева нулями) из целого числа, на единицу большего разрядной сетки ПК (т.е. для n-разрядной это число 2^n). Этот способ лишен проблемы двоякого представления нуля. К тому же существует эффективный способ вычисления двоичного дополнения. Операции сложения и вычитания при этом способе представления выполняются наиболее просто.

Именно третий способ принят для представления чисел в большинстве ПК. Например, в семействе x86. Обратим внимание вот на какое обстоятельство: поскольку для представления чисел в ПК используется фиксированное количество бит, неизбежны переполнения. Например, если разрядная сетка нашего ПК - 12 (т.е. используется 12 бит для представления чисел), то при попытке сложить 4095 и 1 получим переполнение (число 4096 есть 2^12, поэтому оно не поместится в регистр и результат будет неверен). Способы реакции на такую ситуацию как переполнение существуют разные. Например, при переполнении при арифметических операциях в FPU (сопроцессор, применявшийся в семействе x86) возникает исключение. Обычно при возникновении исключения запускается довольно сложный механизм реакции. Он громоздок и непрактичен, поэтому в случае целых чисел, которыми оперирует ЦП в регистрах, обычно применяется другой способ дать знать программисту о переполнении. А именно, через регистр состояния (или флагов).

Прежде чем двигаться дальше, нужно отметить, что в некоторых задачах не требуется представления отрицательных чисел. В этом случае, большинство ПК имеют возможность позволить работать программисту с неотрицательными числами. При использовании третьего способа для представления чисел, в операциях сложения и вычитания становится не важно знаковое число или нет. Однако, переполнения могут быть разными. Например, мы работаем с целыми числами. Диапазон их от -2048 до +2047 (используются те же 12 бит). Тогда если к 2047 прибавить 1 получится 2048, что не умещается в это представление. С другой стороны, если мы работаем с целыми неотрицательными числами (диапазон от 0 до 4095), то сложение 2047 и 1 не приведет к переполнению. Рассмотрим как это все реализовано в x86.

В архитектуре x86 флаг переполнения при сложении/вычитании над неотрицательными числами называется CF - Carry Flag и устанавливается после операции сложения, если происходил перенос в крайний левый разряд (которого реально нет в регистре). Аналогично, он устанавливается, если в результате операции вычитания происходил "заем" из несуществующего разряда. В противном случае, он сброшен. После операции сложения или вычитания программист может проанализировать этот флаг и обнаружить факт переполнения.

Для знаковых целых чисел для указания переполнения используется другой флаг: OF - Overflow Flag. Логика его установки в битовых терминах чуть сложнее и я опущу рассказ об этом. Важно, что этот флаг показывает на переполнение при знаковых операциях. Совершенно аналогично, этот флаг может быть проанализирован программистом.

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

Для того, чтобы получить подобную логику в нашем эмуляторе можно выбрать один из двух способов. Первый заключается в том, что мы будем производить сложение вручную и по ходу проверять переносы и займы. Этот способ, очевидно, медленный. Второй основан на том, что у нас есть возможность использовать для арифметических операций большие по ширине типы данных и проверять "постфактум" результат, устанавливая соответствующие флаги. Этот способ, хотя и не моделирует реальных процессов, происходящих в ПК, более быстр. Для его поддержки введем класс Byte12. Хочу заметить, что он совсем не вписывается в архитектуру, которую я предлагал раньше. На самом деле, это не так важно, поскольку операции пересылки данных в память и из нее можно построить и с использованием этого класса. На этом этапе важно заметить, что предложенная ранее модель не универсальна и не учитывает специфику проблемы. Уже при попытке работы с нестандартной шириной типа данных мы столкнулись с необходимостью создавать новый класс.

Вот как можно, например, реализовать класс Byte12:

class Byte12	{	
public:
	Byte12(int value=0);	
	Byte12(const Byte12& b);	// copy-конструктор

	Byte12 operator+(const Byte12& b) const; // сложение байтов
	Byte12 operator-(const Byte12& b) const;  // вычитание байтов
	Byte12 operator-() const;                            // унарный минус
	Byte12& operator=(const Byte12& b);
	Byte12& operator=(int i);

bool isCarrySet() const;	// проверить флаг 
// переполнения
// при работе с беззнаковыми 
// числами
	bool isOverflowSet() const;		// аналогично для знаковых

	int getSigned() const;	// Возвращает двоичное дополнение

protected:

	unsigned int data; // собственно данные
	bool isCarry;     
	bool isOverflow;

friend std::ostream& operator<<(std::ostream& oStrm, const Byte12& b); 
};

Реализация этого интерфейса тривиальна и может быть организована, например, так:

Byte12::Byte12(int value) : data(value & 0xfff), isCarry(false), 
isOverflow(false) {}
Byte12::Byte12(const Byte12& b) : data(b.data), isCarry(b.isCarry), 
isOverflow(b.isOverflow) {}

Byte12 Byte12::operator+(const Byte12& b) const
{
	int ut=data+b.data;
	int st=getSigned()+b.getSigned();
	Byte12 ret(ut);

	ret.isCarry=(ut>4095) ? true : false;
	ret.isOverflow=(st<-2048 || st>2047) ? true : false;

	return ret;
}

Byte12 Byte12::operator-(const Byte12& b) const
{
	int ut=data-b.data;
	int st=getSigned()-b.getSigned();
	Byte12 ret(ut);
	
	ret.isCarry=(ut<0) ? true : false;
	ret.isOverflow=(st<-2048 || st>2047) ? true : false;

	return ret;
}

Byte12 Byte12::operator-() const
{
	Byte12 tmp;
	
	tmp=Byte12(0)-*this;
	
	return tmp;
}

Byte12& Byte12::operator=(const Byte12& b)
{
	data=b.data;
	isCarry=b.isCarry;
	isOverflow=b.isOverflow;
	return *this;
}

Byte12& Byte12::operator=(int i)
{
	data=i & 0xfff;
	isOverflow=false;
	isCarry=false;
	return *this;
}

bool Byte12::isCarrySet() const
{
	return isCarry;
}

bool Byte12::isOverflowSet() const
{
	return isOverflow;
}

int Byte12::getSigned() const
{
	if (data<=2047) 
		return data;
	else
		return (int)data-4096;
}



std::ostream& operator<<(std::ostream& oStrm, const Byte12& b)
{
//	oStrm<2047) oStrm<<"(-"<<(-b).data<<")";
	if (b.isCarry)	oStrm<<"[C]";
	if (b.isOverflow)	oStrm<<"[O]";
	return oStrm;
}

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

Спасибо за внимание.

Автор рассылки: Виктор Петренко (Top)

[Main] [TMC 1] [TMC 2] [About]
[ 1 2 3 4 5 6 7 * ]