CS360 Lecture notes -- Memory

  • Jim Plank, modified by Jian Huang
  • Directory: ~huangj/cs360/notes/Memory
  • Lecture notes: http://www.cs.utk.edu/~huangj/cs360/360/notes/Memory/lecture.html

    Memory

    The machine layout that I describe is that of the machines in the hydra lab. If you try to go through the programs in this lecture on other machines (e.g. kenner, the hydra machines), you will likely get different results. However, you should be able to figure out how whatever the machine you are on is laid out.

    Also, you should set up your shell so that you don't generate core files when doing this lecture. I.e., if it is not done in your .cshrc file, do:

    UNIX> limit coredumpsize 0
    

    This lecture is an introduction to memory as we see it in Unix.

    As I have said previously, memory is like a huge array with (say) 0xffffffff elements. A pointer in C is an index to this array. Thus when a C pointer is 0xefffe034, it points to the 0xefffe035th element in the memory array (memory being indexed starting with zero).

    Unfortunately, you cannot access all elements of memory. One example that we have seen a lot is element 0. If you try to dereference a pointer with a value of 0, you will get a segmentation violation. This is Unix's way of telling you that that memory location is illegal.

    For example, the following code will generate a segmentation violation:

    main()
    {
      char *s;
      char c;
    
      s = (char *) 0;
      c = *s;
    }
    
    As it turns out, there are 4 regions of memory that are legal. They are:
    1. The code (or "text"): These are the instructions of your program
    2. The globals: These are your global variables (init data and bss)
    3. The heap: This is memory that you get from malloc().
    4. The stack: This contains your local variables and procedure arguments.
    If we view memory as a big array, the regions (or ``segments'') look as follows:
         |--------------| 0
         |              |
         |   void       |
         |              |
         |--------------| 0x10000
         |              |
         |  code        |
         |              |
         |--------------|
         |  void        |
         |--------------| 0x20000
         |              |
         |  globals     |
         |              |
         |--------------|
         |              |
         |  heap        |
         |              |
         ||||||||||||||||
         |vvvvvvvvvvvvvv|
         |              |
         |              |
         |  void        |
         |              |
         |              |
         |^^^^^^^^^^^^^^|
         ||||||||||||||||
         |              |
         |  stack       |
         |              | 0xefffffff
         |--------------|
    
    Note, the heap grows down as you make more malloc() calls, and the stack goes up as you make nested procedure calls.

    Paging

    With an operating system provides each process it loads with a memory address space that start from 0x0 and goes up to 0xffffffff or 0x8fffffff, depending on what type of system you are on. These addresses are all virtual memory addresses. An analogy that might help you understand is the assignment of phone numbers to your house. Phone numbers are just logical and can be easily changed, while your street address is not. Here, the operating system needs to map this virtual address space to its physical address space, i.e. entries on the chips holding the memory banks. This is part of the job done by OS in terms of memory management, which also include how to best use a limited physical address space to meet the need by a large number of processes. There are many ways to memory management, fortunately, all UNIX systems use a pretty standard approach, called paging. Let's use the hydra machines as an example.

    On the hydra machines, memory is broken up into 8192-byte chunks. These are called pages. On some machines, pages are 4096 bytes -- this is something set by the hardware. Mostly, on the same order of magnitude.

    The way memory works is as follows: The operating system allocates certain pages of memory for you. Whenever you try to read to or write from an address in memory, the hardware first checks with the operating system to see if that address belongs to a page that has been allocated for you. If so, then it goes ahead and performs the read/write. If not, you'll get a segmentation violation (note, there are many ways to get segmentation violation, and this is only one of them).

    This is what happens when you do:

      s = (char *) 0;
      c = *s;
    
    When you say "c = *s", the hardware sees that you want to read memory location zero. It checks with the operating system, which says "I haven't allocated the page containing location zero for you". This results in a segmentation violation. Note here that a page can be allocated, but not currently in the physical memory. This relates to a concept called "page fault".

    A page fault is generated when the OS detects that a process is trying to access a page in the virtual memory address space, but that page is not in the physical memory. As a result of that, the OS stops this process until that requested page is read in. Page fault is, in most cases, a page fault is not an error. Segment fault is almost always an error, antithetically.

    The exact mechanics of paging are covered in classes on Operating Systems. I won't go into it further here.

    As it turns out, the first 8 pages on our hydra machines are void. This means that trying to read to or write from any address from 0 to 0xffff will result in a segmentation violation.

    The next page (starting with address 0x10000) starts the code segment. This segment ends at the variable &etext, which I'll go over in a bit. The globals segment starts at page 0x20000. It goes until the variable &end. The heap starts immediately after &end, and goes up to sbrk(0), which I'll talk about still later. The stack ends with address 0xefffffff. Its beginning changes with the different procedure calls you make. We'll go over this more later in this lecture. Every page between the end of the heap and the beginning of the stack is void, and will generate a segmentation violation upon accessing.


    &etext, &edata and &end.

    (For more info on these variables, do man etext, or man edata, etc. These globals are UNIX specific.)

    These are three external variables that are defined as follows:

    extern etext;
    extern edata;
    extern end;
    
    Note that they are typeless. Normally, you would never use just "etext" and "end", because whenever these variables are specified as external linkage without a defining copy in the code, these three are then treated the same as .etext, .edata, .end, all are symbols reserved by ld. You use their addresses -- these point to the end of the text, end of initialized data segment in globals and end of uninitialized data in the globals segments respectively.

    Look at the program testaddr1.c. This prints out the addresses of etext, edata and end. Then it prints out 6 values:

    When we run testaddr1, we get something like the following:

    UNIX> testaddr1
    &etext = 0x108b8
    &edata = 0x20a34
    &end   = 0x20a54
    
    main   = 0x10688
    &I     = 0x20a4c
    &i     = 0xffbef82c
    &argc  = 0xffbef884
    &ii    = 0xffbef828
    ii     = 0x20a68
    UNIX>
    
    So, what this says is that the code segment goes from 0x10000 to 0x108b8. The globals segment goes from 0x20000 to 0x20a54. I is uninitialized, so it appears in bss(block started by symbols), right between edata and end. The heap goes from 0x20a54 to some address greater than 0x20a68 (since ii allocated 4 bytes starting at 0x20a68). The stack goes from some address less than 0xffbef828 to 0xffffffff. All values that are printed by testaddr1 make sense.
    Now, look at testaddr2.c.

    This is the first really gross piece of C code that you'll see. What it does is print out &etext and &end, and then prompt the user for an address in hexidecimal. It puts that address into the pointer variable s. You should never do this unless you are writing code like this which is testing memory. The first thing that it does with s is try to read from that memory location (c = *s). Then it tries to write to the memory location (*s = c). This is a way to see which memory locations are legal.

    So, lets try it out with an illegal memory value of zero:

    UNIX> testaddr2
    &etext = 0x1191b
    &end   = 0x21d90
    
    Enter memory location in hex (start with 0x): 0x0
    Reading 0x0:  Segmentation Fault
    UNIX> 
    
    When we tried to read from memory location zero, we got a seg fault. This is because memory location zero is in the void -- the hardware recognized this by asking the operating system, and then generating a segmentation violation.

    Memory locations 0x0 to 0xffff are illegal -- if we try any address in that range, we will get a segmentation violation:

    UNIX> testaddr2
    &etext = 0x1191b
    &end   = 0x21d90
    
    Enter memory location in hex (start with 0x): 0xffff
    Reading 0xffff:  Segmentation Fault
    UNIX> testaddr2
    &etext = 0x1191b
    &end   = 0x21d90
    
    Enter memory location in hex (start with 0x): 0x4abc
    Reading 0x4abc:  Segmentation Fault
    UNIX> 
    

    Memory location 0x10000 is in the code segment. This should be a legal address:

    UNIX> testaddr2
    &etext = 0x1191b
    &end   = 0x21d90
    
    Enter memory location in hex (start with 0x): 0x10000
    Reading 0x10000:  127
    Writing 127 back to  0x10000:  Segmentation Fault
    UNIX> 
    

    You'll note that we were able to read from 0x10000 -- it gave us the byte 127, which begins some instruction in the program. However, we got a seg fault when we wrote to 0x10000. This is by design: The code segment is read-only. You can read from it, but you can't write to it. This makes sense, because you can't change your program while it's running -- instead you have to recompile it, and rerun it.

    Now, what if we try memory location 0x11fff? This is above &etext, so it should be outside of the code segment:

    UNIX> testaddr2
    &etext = 0x1191b
    &end   = 0x21d90
    
    Enter memory location in hex (start with 0x): 0x11fff
    Reading 0x11fff:  -48
    Writing -48 back to  0x11fff:  Segmentation Fault
    UNIX>
    
    You'll note that even though 0x11fff is an address outside the code segment, we're still allowed to read from it. This is because the hardware checks the with operating system to see if an address's page has been allocated. Since page 8 (0x10000 - 0x11fff) has been allocated for the code segment, the hardware treats any address between 0x10000 and 0x11fff as a legal address. You can read from it, but its value is meaningless.

    Now, pages 9 to 15 are undreadable again:

    UNIX> testaddr2
    &etext = 0x1191b
    &end   = 0x21d90
    
    Enter memory location in hex (start with 0x): 0x12000
    Reading 0x12000:  Segmentation Fault
    UNIX> testaddr2
    &etext = 0x1191b
    &end   = 0x21d90
    
    Enter memory location in hex (start with 0x): 0x1f000
    Reading 0x1f000:  Segmentation Fault
    UNIX> 
    

    The globals starts at 0x20000, so we see that the 16th page is readable and writable:

    UNIX> testaddr2
    &etext = 0x1191b
    &end   = 0x21d90
    
    Enter memory location in hex (start with 0x): 0x20000
    Reading 0x20000:  127
    Writing 127 back to  0x20000:  ok
    UNIX>          
    

    We can read from and write to any location (0x20000 to 0x21fff) in this page. The next page (starting at 0x22000) is unreachable:

    UNIX> testaddr2
    &etext = 0x1191b
    &end   = 0x21d90
    
    Enter memory location in hex (start with 0x): 0x21dff
    Reading 0x21dff:  0
    Writing 0 back to  0x21dff:  ok
    UNIX> testaddr2
    &etext = 0x1191b
    &end   = 0x21d90
    
    Enter memory location in hex (start with 0x): 0x22000
    Reading 0x22000:  Segmentation Fault
    UNIX> 
    

    What this tells us is that the globals go from 0x20000 to 0x21d90. The heap goes from 0x21d90 up to some higher address in the same page.


    Sbrk(0)

    sbrk() is a system call that we will get into in a few lectures. sbrk(0) returns to the user the current end of the heap. Since we can keep calling malloc(), sbrk(0) can change over time. testaddr3.c shows the value of sbrk(0) -- note it is in page 16 (0x20000 - 0x21fff). Since the hardware performs its check in 8192-byte intervals, we can get at any byte in page 16, even though sbrk(0) returns 0x20c78:
    UNIX> testaddr3
    &etext = 0x11993
    &end   = 0x21e18
    sbrk(0)= 0x21e18
    &c     = 0xffbee103
    
    Enter memory location in hex (start with 0x): 0x21fff
    Reading 0x21fff:  0
    Writing 0 back to  0x21fff:  ok
    UNIX> 
    
    We haven't called malloc() in testaddr3.c. This is the reason why &end and sbrk(0) return the same value. In testaddr3a.c we make a malloc() call in the beginning of the program, and as you see, &end and sbrk(0) return different values:
    UNIX> testaddr3a
    &etext = 0x119a3
    &end   = 0x21e28
    sbrk(0)= 0x23e28
    &c     = 0xffbee103
    
    Enter memory location in hex (start with 0x): 0x23fff
    Reading 0x23fff:  0
    Writing 0 back to  0x23fff:  ok
    UNIX> testaddr3a
    &etext = 0x119a3
    &end   = 0x21e28
    sbrk(0)= 0x23e28
    &c     = 0xffbee103
    
    Enter memory location in hex (start with 0x): 0x24000
    Reading 0x24000:  Segmentation Fault
    UNIX> 
    

    The stack

    So, where's the beginning of the stack? If we try addresses above 0xffbee103 in testaddr3, we see that most of them are legal:
    UNIX> testaddr3
    &etext = 0x11993
    &end   = 0x21e18
    sbrk(0)= 0x21e18
    &c     = 0xffbee103
    
    Enter memory location in hex (start with 0x): 0xffb00000
    Reading 0xffb00000:  0
    Writing 0 back to  0xffb00000:  ok
    UNIX> testaddr3
    &etext = 0x11993
    &end   = 0x21e18
    sbrk(0)= 0x21e18
    &c     = 0xffbee103
    
    Enter memory location in hex (start with 0x): 0xff3f0000
    Reading 0xff3f0000:  0
    Writing 0 back to  0xff3f0000:  ok
    UNIX> testaddr3
    &etext = 0x11993
    &end   = 0x21e18
    sbrk(0)= 0x21e18
    &c     = 0xffbee103
    
    Enter memory location in hex (start with 0x): 0xff3effff
    Reading 0xff3effff:  Segmentation Fault
    UNIX> 
    
    What gives? As it turns out, the operating system allocates all pages from 0xff3f0000 to the bottom of the stack. Where is the bottom of the stack? Let's probe:
    UNIX> testaddr3
    &etext = 0x11993
    &end   = 0x21e18
    sbrk(0)= 0x21e18
    &c     = 0xffbee103
    
    Enter memory location in hex (start with 0x): 0xffbeffff
    Reading 0xffbeffff:  0
    Writing 0 back to  0xffbeffff:  ok
    UNIX> testaddr3
    &etext = 0x11993
    &end   = 0x21e18
    sbrk(0)= 0x21e18
    &c     = 0xffbee103
    
    Enter memory location in hex (start with 0x): 0xffbf0000
    Reading 0xffbf0000:  Segmentation Fault
    UNIX>
    
    So the stack goes from 0xff3f0000 to 0xffbeffff. That is roughly 8 megabytes.

    You can print out the default stack size, and change it using the limit command (read the man page):

    UNIX> limit
    ...
    stacksize       8192 kbytes
    ...
    
    Whenever you call a procedure, it allocates local variables and arguments (plus a few other things) on the stack. Whenever you return from a procedure, those varables are popped off the stack. So, look at testaddr4.c. It has main() call itself recursively as many times as there are arguments. You'll see that at each recursive call, the addresses of argc and argv and the local variable i are smaller addresses -- this is because each time the procedure is called, the stack grows upward to allocate its arguments and local variables. You've seen this already in the assembler lectures.
    UNIX> testaddr4
    argc = 1.  &argc = 0xffbee15c, &argv = 0xffbee160, &i = 0xffbee104
    argc = 0.  &argc = 0xffbee0e4, &argv = 0xffbee0e8, &i = 0xffbee08c
    UNIX> testaddr4 v
    argc = 2.  &argc = 0xffbee154, &argv = 0xffbee158, &i = 0xffbee0fc
    argc = 1.  &argc = 0xffbee0dc, &argv = 0xffbee0e0, &i = 0xffbee084
    argc = 0.  &argc = 0xffbee064, &argv = 0xffbee068, &i = 0xffbee00c
    UNIX> testaddr4 v o l s
    argc = 5.  &argc = 0xffbee144, &argv = 0xffbee148, &i = 0xffbee0ec
    argc = 4.  &argc = 0xffbee0cc, &argv = 0xffbee0d0, &i = 0xffbee074
    argc = 3.  &argc = 0xffbee054, &argv = 0xffbee058, &i = 0xffbedffc
    argc = 2.  &argc = 0xffbedfdc, &argv = 0xffbedfe0, &i = 0xffbedf84
    argc = 1.  &argc = 0xffbedf64, &argv = 0xffbedf68, &i = 0xffbedf0c
    argc = 0.  &argc = 0xffbedeec, &argv = 0xffbedef0, &i = 0xffbede94
    UNIX> 
    
    Now, lets break the stack. This can be done by writing a program that allocates too much stack memory. One such program is in breakstack1.c. It performs infinite recursion, and at each recursive step it allocates 10000 bytes of stack memory in the variable iptr. When you run this, you'll see that you get a segmentation violation when the recursive call is made and the stack is about to dip below 0xff3f0000:
    UNIX> breakstack1
    ...
    &c     = 0xff3fa347, iptr = 0xff3f7c30  ...  ok
    &c     = 0xff3f7bbf, iptr = 0xff3f54a8  ...  ok
    &c     = 0xff3f5437, iptr = 0xff3f2d20  ...  ok
    Segmentation Fault
    UNIX> 
    
    Often when you have infinite recursion and overflow the stack, you get "illegal instruction" instead of Segmenation fault. To get an idea, think what part of stack is related to instructions.

    The second way to break the stack is to simply allocate too much local memory. E.g. look at breakstack2.c. It tries to allocate 10M of memory in the stack. It segfaults in a because it tries to reference smaller memory addresses than 0xff3f0000. Exactly where does the seg fault happen? Think about it -- answer below.

    The segfault happens in a when the code attempts to push iptr on the stack for the printf call. This is because the stack pointer is pointing to the void. Had we not referenced anything at the stack pointer, our program should have worked. For example, try breakstack3.c.

    UNIX> breakstack3
    Calling a.  i = 1
    After a is done.  i = 5
    UNIX> 
    
    You should understand, and be able to explain this phenomenon.