Memory in the PC
Atrevida Game Programming Tutorial #4
In this chapter, we will learn about the PC's memory. We will learn how
memory is addressed and organized. We'll also see how to read and write
to specific memory addresses using C and C++.
Memory addressingThe Intel 8088 is the microprocessor used in the original IBM PC. Intel, when designing this chip, decided that the maximum amount of memory that could be addressed with the 8088 was to be one megabyte. One megabyte is equivalent to 2^20 (that's 1048576) bytes.
(Just as a clarification: remember that a "kilobyte" ("K") normally refers to 2^10 (or 1024) bytes, not the 1000 that we usually mean by "kilo" when dealing with other quantites or units. And a megabyte ("MB") is not a million bytes (although it's close); it's 2^20 bytes.)
To access all of these bytes, we could assign each memory location an address: the first byte would have the address 0, the second would have the address 1, the third would have address 2, and so on, and the last byte would have the address 1048575 dec (because we started at zero, not one). The addresses created using the scheme would be called physical addresses. But, when we are programming for the 8088, we must use a more complex system, which has advantages and disadvantages. To understand this system and why it is used, we need to examine a part of the original PC's bus.
The PC uses a circuit design called a bus to allow communication between different parts of the computer. I like to think of the bus as a sort of a "highway", and the CPU (the 8088), the memory chips, all of the other support chips, the cards in the expansion slots, and so on are all connected to this highway.
The PC's bus is divided into several parts: the control bus, the address bus, the data bus, and the power lines. We're concerned with the address bus, which has a width of 20 bits. The CPU can transmit an address to the memory chips over the address bus, and because the address bus' width is 2^20, all 2^20 possible addresses can be accessed. That is, physical addresses can be sent on the address bus.
This is fine, but the 8088 is a 16-bit processor. (Many people think of the 8088 as an 8-bit processor; it really is a 16-bit processor, but it is designed to connect to a 8-bit data bus, whereas 16-bit processors usually use 16-bit data buses.) So, if the 8088 can only handle 16-bit (word-sized) numbers, we somehow have to construct 20-bit addresses out of 16-bit values.
What Intel came up with was a scheme called segmented addressing. A segmented address consists of two 16-bit parts, the segment and the offset. When a segment and an offset are combined, using a technique described later, they form a physical address. (Recall that 16-bit numbers, including segments and addresses, have ranges of 0..65535 dec, or 0..FFFF hex.)
The segment address can be thought of as specifying the "neighborhood" that a particular memory cell resides in. Segments can start in memory every sixteen bytes, so the first segment (segment 0) starts at physical address 0, the second segment (segment 1) starts at physical address 16 dec (10 hex), the third segment (segment 2) starts at physical address 32 dec (20 hex), and so on. The last segment, segment 65535 dec (or FFFF hex), then has the physical address 1048560 dec (FFFF0 hex). Note that the sixteen-byte difference between different segments is often called a paragraph. So, to get a physical address from a segment, we simply multiply the segment by 16 dec (10 hex). (Where does a segment end? Normally, we consider segments to be 64K in length, although this is not really specified anywhere. Often it is convenient to consider a particular segment to be shorter than 64K.)
An offset lets us specify with greater precision which byte we wish to access. A segment alone only lets us access every sixteenth byte. But if we add an offset to the physical address obtained from a segment, we can now access those bytes in between. In fact, we can access any bytes up to 65535 bytes after the start of the segment, because the limit of the offset is 65535 dec.
So, for example, say we have the segment 5 dec and the offset 7 dec. Let's calculate the physical address. First, we calculate the physical address using just the segment: 5 dec * 16 dec = 80 dec. Now we add the offset to the physical address: 80 dec + 7 dec = 87 dec. Our physical address is 87 dec.
If we recall from a previous chapter our bit-shifting method for multiplying by a power of two, we can use the method that is always given in books on assembly language. To convert a segmented address, we take the segment, shift it left by four bits (or one hex digit), and then add the offset.
Before we continue, I want to mention the specific syntax for segmented addresses. The segment and offset are written as four-digit hexadecimal numbers, with a colon between them. So, for the segment 5 dec and the offset 7 dec, our syntax lets us write: 0005:0007. We might then see higher addresses, such as A8D9:10F4. Always remember that hex numbers are used in this format.
If we try to convert a physical address to a segmented address, we will discover that there is often more than one way to specify a physical address with a segmented address. Let's take the physical address 20 dec. We could create the segmented address 0000:0014 (14 hex = 20 dec). But we could also create the segmented address 0001:0004 (1 * 16 dec + 4 dec = 20 dec). Both refer to the same physical address! (Many physical addresses can be represented with 4096 different segment:offset pairs. And some addresses, such as 0000:000B, can't be represented with any other segment:offset pairs.)
As a piece of trivia, if a segmented address has an offset that is less than 16 dec (10 hex), that segment:offset pair is called normalized. You can normalize an address by dividing the offset by 16 dec (10 hex), and then adding the integer quotient to the segment, and setting the offset to the remainder of the division (the modulo).
Briefly, advantages of the segmented addressing system include: 1. the flexibility associated with having segments and registers (we'll see why in the chapters dealing with assembler), and 2. we gain access to more than 64K of memory (if we used physical addresses, and we used word-sized numbers to store addresses, we could only access addresses in the range 0..65535 dec). Some disadvantages are: 1. segmented addresses are harder to use than physical addresses, 2. storing an address takes up 32 bits, when only 20 bits are necessary to store the equivalent physical address, and 3. this particular scheme limits us to one megabyte of memory.
Memory organizationThe designers of the original PC and DOS decided to partition the one-megabyte address space into sixteen 64K segments, with the following definitions:
Segment (hex) TOP OF FFFF +---------------------------+ MEMORY | | ROM BIOS F000 +---------------------------+ | | Cartridge ROM Area, ROM BIOS, E000 +---------------------------+ or EMS Window | | Cartridge ROM Area, BIOS D000 +---------------------------+ Extensions, or EMS Window | | BIOS Extensions or EMS Window C000 +---------------------------+ | | Video Memory (Text) B000 +---------------------------+ | | Video Memory (Graphics) A000 +---------------------------+ <--- 640K Working RAM Limit | | Working RAM 9000 +---------------------------+ | | Working RAM 8000 +---------------------------+ | | Working RAM 7000 +---------------------------+ | | Working RAM 6000 +---------------------------+ | | Working RAM 5000 +---------------------------+ | | Working RAM 4000 +---------------------------+ | | Working RAM 3000 +---------------------------+ | | Working RAM 2000 +---------------------------+ | | Working RAM 1000 +---------------------------+ BOTTOM OF | | Working RAM, plus system MEMORY 0000 +---------------------------+ information (eg. interrupt vector table)
This table was adapted from similar tables in two books, "The Peter Norton Programmer's Guide to the IBM PC", and "The Indispensable PC Hardware Book, 2nd edition". (References are given at end of this chapter.)
Let's look briefly at each segment. The first segment, starting at 0000:0000, contains operating system code and data. The very first 1K of memory is dedicated for use as what is called an interrupt vector table. Parts of the operating system, such as COMMAND.COM, IO.SYS or IBMBIOS.SYS, and MSDOS.SYS or IBMDOS.SYS may reside in this segment (or, some parts may reside elsewhere in memory). In addition, many status bytes are stored in this segment; these store information about the keyboard, disk drives, video display, and other components.
Any memory left over in the first segment, in addition to all of the remaining segments up to the 640K limit, can be used as "working RAM", or memory for use by programs. (If your machine were to have less than 640K installed, there would be a "hole" in the memory map above).
Segment A000 hex is set aside for use with graphics modes of the computer's video card. As we will see in the chapters on VGA programming, changing the contents of memory in this segment can change the image displayed on the screen. (Other graphics adapters, such as the older CGA and EGA, also use this area. Machines without graphics video cards have a 64K hole in memory here.)
Segment B000 hex is used as display memory for the text (non-graphical) modes.
Segment C000 hex can hold updates to the BIOS. (The BIOS will be covered in later chapters; in short, the BIOS provides simple functions for accessing certain parts of the hardware. These routines can be called by our programs.)
If a cartridge is inserted into the PC, then segments D000 and/or E000 hex are replaced with the contents of the cartridge. (Norton writes that support for cartridges can be added to most models of PC's. He adds that the IBM PCjr was the only machine to really use cartridges (Norton, p. 17).) More recently, parts of these segments can be used as windows into EMS memory, or for extensions to the BIOS.
Segment F000 hex holds the ROM BIOS. This segment can also include diagnostic functions, and may even include a version of "cassette BASIC" (for all those PC's without disk drives, and hence, no disk operating systems).
(If some of the things mentioned above seem a little silly, remember that the IBM PC was introduced in 1981, and many of the decisions were made to reduce the cost of the computer. The first PC's were shipped with either 16K or 64K of RAM (depending on whose "historical account" you read), and cassettes were considered a reasonable, inexpensive alternative to floppy disks. Floppy disks could store only 160K. You could get either the text-only MDPA (monochrome display and printer adapter) or the four-color-graphics CGA (color graphics adapter) as video cards, and if you were willing to use 40-column screens, you could hook up the PC to a television set.)
"Little endian" number storageIntel made an odd decision when designing the 8088 and its other microprocessors. It decided to use what is called the "little endian" format for storing multi-byte values, instead of the "big endian" format that many other processor manufacturers used.
First, let's examine the big endian format. This is the way that we would expect numbers to be stored in memory. If we had the binary number (a word) "1010111100101101 bin", we would expect the left-hand, or most-significant, byte to be stored first, and then the right-hand, or least-significant, byte to be stored in the byte of memory immediately after the first. That number would be stored like this...
Memory Address: x x+1 Value in Memory: 10101111 00101101
...where x is some arbitrary (physical) address in memory.
But with little endian format, we order the bytes in reverse. The bits in each byte stay in the same order, but the bytes themselves are stored in a backwards order. So, if we were to take our word from above, "1010111100101101 bin", and store it in memory in little-endian format, we would see:
Memory Address: x x+1 Value in Memory: 00101101 10101111
Note that single bytes are stored the same way with both systems. How are types larger than words stored in little-endian format? Here is how we would store the double-word "10000101 11110011 11100111 10101010 bin":
Memory Address: x x+1 x+2 x+3 Value in Memory: 10101010 11100111 11110011 10000101
In each of the above cases, we would say that the number starts at address x.
The reason that is commonly given for the little-endian format is that this format makes it easier to add to or increment a multi-byte number: if there is an overflow, you just increment the address (by one byte), and then add one to the byte at that address.
For me, constantly worrying about little-endian numbers just gives me a headache; I suppose I am just more accustomed to writing from left to right. Motorola's 68000 processor family (used in Macs, Amigas, and other nice machines) is probably the most famous example of a processor family that uses the big-endian format.
Reading from and writing to memory with C and C++Turbo and Borland C/C++ provide functions for accessing specific memory locations. If you "#include <dos.h>", you can write a byte to memory using:
void pokeb (unsigned int segment, unsigned int offset, char value);
So, "pokeb (0x760F, 0x00AE, 125);" would poke (set) the byte at 760F:00AE to 125 dec. To write a word to memory, you can use:
void poke (unsigned int segment, unsigned int offset, int value);
With poke(), the word value is stored using the little-endian format. So, "poke (0xA320, 0xCF18, 0x0F14);" would store 0F14 hex at A320:CF18. That is, A320:CF18 will contain 14 hex, and A320:CF19 will contain 0F hex.
The functions peek() and peekb() are used for peeking (reading) words and bytes from memory locations:
int peek(unsigned int segment, unsigned int offset); char peekb(unsigned int segment, unsigned int offset);
For example, if x is an int or unsigned int, and c is a char or unsigned char, "x = peek(0x0000, 0x0000);" would store the word at 0000:0000 in x, and "c = peekb(0xF3CB, 0x194D);" would store the byte at F3CB:194D in c.
There is another method which seems to be more widely used than "peeking and poking". We can use far pointers.
Pointers, of course, are just addresses of certain variables in memory. Although it depends on how you have your compiler options set up, particularly the memory model, pointers that you normally use in C or C++ are near pointers. I don't want to get into a full discussion of memory models, but briefly, in the "smaller" memory models, your program's data is stored in a segment called a data segment, and/or a segment called the stack segment, which overlaps with the data segment. Near pointers can only point to variables stored in the data segment and stack segment, and so the segment is always constant. Because the segment is constant, only the offset needs to be stored or manipulated.
Far pointers include both the segment and the offset, and so they can point to any data anywhere in the 1MB address space. Huge pointers are just like far pointers, in that they have both the segment and the offset, but huge pointers are normalized whenever the segment and/or offset change (this includes incrementing and decrementing pointers using pointer arithmetic). Also, near and far pointers suffer from segment wraparound; when the offset is incremented past its limit, it resets back to 0000 hex, but the segment is not changed. Huge pointers, because they are normalized with each change in pointed-to address, don't suffer from this problem. (Huge pointers are significantly slower than far pointers, however, because of the overhead associated with checking and normalizing the pointers with each use.)
Under Turbo and Borland C/C++, you can declare pointers to be far or huge by using the far or huge keywords in a pointer declaration:
int far *ptr_to_int; unsigned long huge *ptr_to_long;
You must use a macro called MK_FP to construct a far pointer to a specific memory address. If you already have a far pointer, and you want to find its segment or offset, you can use FP_SEG and FP_OFF macros:
void far *MK_FP (unsigned int segment, unsigned int offset); unsigned int FP_SEG (void far *p); unsigned int FP_OFF (void far *p);
So, for example, if we use the ptr_to_int example from above, we could say "ptr_to_int = MK_FP(0x5000, 0x6222);" to make ptr_to_int point to 5000:6222. Then, "my_segment = FP_SEG(ptr_to_int);" should store 5000 hex in my_segment (if my_segment is an int).
Information on huge pointers is sparse. From my experience, you can use the MK_FP, FP_SEG, and FP_OFF macros with huge pointers, although you may sometimes need to cast a huge pointer into a far pointer, perform an operation (maybe malloc()) and then cast the far pointer back into a huge pointer.
A real example of memory accessHere's a real example in which we read from and write to memory. We also get to use our bitwise logical operators and our methods for manipulating fields within larger types.
One of the status bytes in the first segment resides at 0000:0417. This is a keyboard status byte, and here is a diagram detailing what each bit stands for:
7 6 5 4 3 2 1 0 +-----+-----+-----+-----+-----+-----+-----+-----+ | INS | CAP | NUM | SCR | ALT | CTL | LSH | RSH | +-----+-----+-----+-----+-----+-----+-----+-----+ Bit 0: Right-Shift key depressed: 1 = YES, 0 = NO. Bit 1: Left-Shift key depressed: 1 = YES, 0 = NO. Bit 2: Ctrl key depressed: 1 = YES, 0 = NO. Bit 3: Alt key depressed: 1 = YES, 0 = NO. Bit 4: Scroll Lock active: 1 = YES, 0 = NO. Bit 5: Num Lock active: 1 = YES, 0 = NO. Bit 6: Caps Lock active: 1 = YES, 0 = NO. Bit 7: Insert mode: 1 = ACTIVATED, 0 = UNACTIVATED.
We can read in this byte in a program to see if any of the Shift, Ctrl, or Alt keys are being pressed. We can also check the states of the Caps Lock, Num Lock, and Scroll Lock keys, and whether insert or overstrike mode is active. To do this, we can read in this byte (using peekb(), or, by using a far pointer), then construct an AND mask to block out the bits we are not interested in, and then compare the remaining bits with zero to see if they are on or off.
But we can have more fun by setting certain bits in this byte on or off. Changing the Caps Lock, Num Lock, and Scroll Lock bits should appropriately change the status lights on your keyboard! Let's use peekb() and pokeb() to set the Num Lock and Caps Lock lights on, and the Scroll Lock light off. We'll be careful to leave the other bits in this byte alone.
char temp; temp = peekb(0x0000, 0x0417); /* Read in the status byte */ temp &= 0x8F; /* 0x8F == 10001111 bin */ temp |= 0x60; /* 0x60 == 01100000 bin */ pokeb (0x0000, 0x0417, temp); /* Write back the altered byte */
If we were going to use the address 0000:0417 many times in a program, it would be a good idea to set the segment and offset as constants, to avoid mistakenly entering the wrong address. (Writing junk data to random addresses, as I have shown in some of the examples previously, is not a good idea!) Better yet, construct a far or huge pointer to this address using MK_FP, and then read and write to it as you would a normal (near) pointer.
If you want some practice with accessing memory, you might want to re-write the above code fragment using far pointers. Or, write a short program that blinks your Caps Lock, Num Lock, and Scroll Lock lights in different patterns. (You might want to use the delay() function, from DOS.H, for generating short pauses during your program.) Or, write a program that continuously prints out the contents of this status byte, and watch how the contents change as you press the Ctrl and Alt keys, etc.
SummaryThis chapter has explained the segment:offset addressing system used by the 8088 and later processors, as well as how the 1MB address space in the PC is laid out (at least under DOS). We have examined the mysteries of little-endian number storage. And finally, we've seen how to read and write to specific addresses in memory using C and C++, using a keyboard status byte as an example.
References to materialThe memory map in this chapter was adapted from similar diagrams in the following two books (both of which I recommend highly):
Norton, Peter. "The Peter Norton Programmer's Guide to the IBM PC". Redmond, WA, USA: Microsoft Press, 1985. ISBN: 0-914845-46-2.
(Norton's book has a good listing of many of the status bytes in the first memory segment, as well as very comprehensive interrupt lists (interrupts will be discussed in a later chapter).)
Messmer, Hans-Peter. "The Indispensable PC Hardware Book: Your Hardware Questions Answered (Second Edition)". USA: Addison-Wesley, 1995. ISBN: 0-201-87697-3.
(Messmer's book is packed full of useful hardware programming information. It's probably the most-often used book on my bookshelf.)