Lab 3 - Memory
Task: Memory Access
Navigate to the chapters/data/working-with-memory/drills/tasks/memory-access/
directory, run make skels
, and enter support/src/
.
Inspect the mem_access.c
source file.
Describe each variable by completing its (address, size, access rights) tuple.
Try to modify the
ca
,cp
andcp2
variables by assigning some other value to them. Check your changes by running thechecker.sh
script insupport/tests/
. Explain the behavior.
If you're having difficulties solving this exercise, go through this reading material.
Task: Memory Corruption
For this practice item, you will need to identify the programming mistake that makes it possible to corrupt memory.
Navigate to the chapters/data/working-with-memory/drills/tasks/memory-corruption/
folder, run make skels
and enter support/src/
.
Inspect the source file segfault.c
.
- What does the program do? (this could be a quiz in the final form)
- Compile and run it. What happens?
- Debug the program and find the line that causes the segfault.
Note: Although using
printf()
calls is a viable option, we strongly suggest you use GDB. - Fix the program and check your changes by running the
checker.sh
script insupport/tests/
. - Analyze the corresponding Python and D implementation.
What is the expected result in each case? Why? Run the programs and see what happens.
If you're having difficulties solving this exercise, go through this reading material.
Task: Memory Protection
Let's navigate to the chapters/data/working-with-memory/drills/tasks/memory-protection/
, run make skels
and enter the support/src/
directory.
Inspect the mem_prot.c
source file.
The file uses different access types for the data
variable and the do_nothing
function.
Build it:
student@os:~/.../memory-protection/support/$ make
gcc -g -Wall -Wextra -Werror -I../../../../../common/makefile/../utils -I../../../../../common/makefile/../utils/log -c -o mem_prot.o mem_prot.c
gcc mem_prot.o ../../../../../common/makefile/../utils/log/log.o -o mem_prot
student@os:~/.../memory-protection/support/$ ./mem_prot
reading from .data section
writing to .data section
reading from .text section
executing .text section
All current actions in the program are valid.
Let's uncomment each commented line in the program and try again:
student@os:~/.../memory-protection/support/$ ./mem_prot
reading from .data section
writing to .data section
reading from .text section
executing .text section
executing .data section
Segmentation fault (core dumped)
We now receive the dreaded Segmentation fault message when we try to access a memory section with wrong permissions.
Permissions come into play when we control the memory address via pointers. But even for programming languages that don't offer pointers (such as Python) issues may still arise.
In the str.py
file, we look to modify str[1]
, but this fails:
student@os:~/.../memory-protection/support/$ ./str.py
n, 110, n
Traceback (most recent call last):
File "./str.py", line 5, in <module>
str[1] = 'z'
TypeError: 'str' object does not support item assignment
This fails because strings are, in Python, immutable. Once a string is being created, it can not be modified; you have to create a new string.
Add a variable named
ro
that you define asconst
. The variable will be placed on a read-only section (.rodata
) such as that write and execution access would result in Segmentation fault.Access the
ro
variable and show that, indeed, for write and execution access, Segmentation fault is issued.Check your work by running the
checker.sh
script insupport/tests/
.
If you're having difficulties solving this exercise, go through this reading material.
Task: Access Counter
Navigate to the chapters/data/working-with-memory/drills/tasks/access-counter/support
directory.
Your goal is to update the src/access_counter.c
source code file to capture memory access exceptions (i.e. the SIGSEGV
signal) and to update page permissions in order for the access to eventually succeed.
Use mprotect
to update the protection of the pages in stages: read, write and then exec.
Each time an update is made, the counter
variable is increased;
this is used for testing.
The signal handler is already in place as the access_handler()
function.
It is called any time a SIGSEGV
signal is being sent out to the current process.
You will update the handler by following the TODO
comments and instructions here.
The pages
array stores information about accessed pages.
Assume the MAX_PAGES
size of the array is enough to store information.
When an existing page is accessed and causes a memory exception, the permission is update, in the stages mentioned above: read, write, and then exec.
When a new page is accessed, a new entry is filled in the pages
array, initialized with read protection.
Use mmap()
to reserve virtual pages.
Use anonymous mapping (i.e. the MAP_ANONYMOUS
) flag.
Use any permissions required.
To test it, enter the tests/
directory and run:
make check
In case of a correct solution, you will get an output such as:
./run_all_tests.sh
test_acess_read ........................ passed ... 9
test_acess_write ........................ passed ... 9
test_acess_exec ........................ passed ... 10
test_acess_read_write ........................ passed ... 12
test_acess_read_exec ........................ passed ... 12
test_acess_write_exec ........................ passed ... 12
test_acess_exec_read ........................ passed ... 12
test_acess_exec_write ........................ passed ... 12
test_acess_write_read ........................ passed ... 12
Total: 100/100
If you're having difficulties solving this exercise, go through this reading material.
Working with Memory
As previously stated, from a programmer's perspective, memory is abstracted into variables. This hides most of the lower level abstractions. Each variable is characterized by an address (or location in memory), type and access rights. Some languages require that the developer spells out these attributes explicitly (statically typed languages - notable examples: C\C++, D, Java) whereas others deduce them by analyzing the context (dynamically typed languages - notable examples: Python, JavaScript). Nevertheless, the language compiler needs to handle this information and, based on it, generate code that manages memory correctly and efficiently.
Memory Access
Accessing memory is defined by reading or writing values to or from a variable. From a programmer's perspective, this looks pretty straightforward:
int main(void)
{
int a; // declare variable
a = 42; // write 42 to variable a
printf("%d\n", a); // read variable a and print its contents
return 0;
}
However, from a lower level perspective, there are other attributes that need to be taken care of.
For instance, variable a
needs to have a correspondent area that is reserved in memory.
That specific chunk of memory is described by an address and a size.
The address for a
is automatically generated by going through multiple layers of abstractions, but the size is spelled out indirectly by the programmer by using the keyword int
.
Another aspect is represented by the access rights for a specific memory area.
In our example, a
is defined as being plain mutable, however, it is possible to declare constant variables which are stored in memory location with no writing rights.
Using the above information, the compiler and the operating system co-work to allocate memory that can represent the contents of the variable.
No matter what sort of language you are using, statically or dynamically typed, a variable is always described by the (address, size, access rights) triplet. By using this triplet, the content of a variable is stored, retrieved or rewritten.
Memory Protection
Memory contents (both code and data) are separated into sections or zones. This makes it easier to manage. More than that, it allows different zones to have different permissions. This follows the principle of least privilege where only required permissions are part of a given section.
Code is usually placed in a section (.text
) with read and execute permissions;
no write permissions.
Variables are placed in different sections (.data
, .bss
, stack, heap) with read and write permissions;
no execute permissions.
Process Memory
Memory Regions
To better manage a program's memory, the operating systems creates an address space for each process. The address space is compartmentalized in multiple areas, each with its own role. Memory addresses use different permissions to decide what actions are allowed.
Let's investigate the memory areas of a given process.
We use pmap
to see the memory layout of a running process.
The command below shows the memory layout of the current shell process:
student@os:~$ pmap -p $$
1127: /bin/bash
000055fb4d77d000 1040K r-x-- /bin/bash
000055fb4da80000 16K r---- /bin/bash
000055fb4da84000 36K rw--- /bin/bash
000055fb4da8d000 40K rw--- [ anon ]
000055fb4e9bb000 1604K rw--- [ anon ]
00007f8fcf670000 4480K r---- /usr/lib/locale/locale-archive
00007f8fcfad0000 44K r-x-- /lib/x86_64-linux-gnu/libnss_files-2.27.so
00007f8fcfadb000 2044K ----- /lib/x86_64-linux-gnu/libnss_files-2.27.so
00007f8fcfcda000 4K r---- /lib/x86_64-linux-gnu/libnss_files-2.27.so
00007f8fcfcdb000 4K rw--- /lib/x86_64-linux-gnu/libnss_files-2.27.so
00007f8fcfcdc000 24K rw--- [ anon ]
00007f8fcfce2000 92K r-x-- /lib/x86_64-linux-gnu/libnsl-2.27.so
00007f8fcfcf9000 2044K ----- /lib/x86_64-linux-gnu/libnsl-2.27.so
00007f8fcfef8000 4K r---- /lib/x86_64-linux-gnu/libnsl-2.27.so
00007f8fcfef9000 4K rw--- /lib/x86_64-linux-gnu/libnsl-2.27.so
00007f8fcfefa000 8K rw--- [ anon ]
00007f8fcfefc000 44K r-x-- /lib/x86_64-linux-gnu/libnss_nis-2.27.so
00007f8fcff07000 2044K ----- /lib/x86_64-linux-gnu/libnss_nis-2.27.so
00007f8fd0106000 4K r---- /lib/x86_64-linux-gnu/libnss_nis-2.27.so
00007f8fd0107000 4K rw--- /lib/x86_64-linux-gnu/libnss_nis-2.27.so
00007f8fd0108000 32K r-x-- /lib/x86_64-linux-gnu/libnss_compat-2.27.so
00007f8fd0110000 2048K ----- /lib/x86_64-linux-gnu/libnss_compat-2.27.so
00007f8fd0310000 4K r---- /lib/x86_64-linux-gnu/libnss_compat-2.27.so
00007f8fd0311000 4K rw--- /lib/x86_64-linux-gnu/libnss_compat-2.27.so
00007f8fd0312000 1948K r-x-- /lib/x86_64-linux-gnu/libc-2.27.so
00007f8fd04f9000 2048K ----- /lib/x86_64-linux-gnu/libc-2.27.so
00007f8fd06f9000 16K r---- /lib/x86_64-linux-gnu/libc-2.27.so
00007f8fd06fd000 8K rw--- /lib/x86_64-linux-gnu/libc-2.27.so
00007f8fd06ff000 16K rw--- [ anon ]
00007f8fd0703000 12K r-x-- /lib/x86_64-linux-gnu/libdl-2.27.so
00007f8fd0706000 2044K ----- /lib/x86_64-linux-gnu/libdl-2.27.so
00007f8fd0905000 4K r---- /lib/x86_64-linux-gnu/libdl-2.27.so
00007f8fd0906000 4K rw--- /lib/x86_64-linux-gnu/libdl-2.27.so
00007f8fd0907000 148K r-x-- /lib/x86_64-linux-gnu/libtinfo.so.5.9
00007f8fd092c000 2048K ----- /lib/x86_64-linux-gnu/libtinfo.so.5.9
00007f8fd0b2c000 16K r---- /lib/x86_64-linux-gnu/libtinfo.so.5.9
00007f8fd0b30000 4K rw--- /lib/x86_64-linux-gnu/libtinfo.so.5.9
00007f8fd0b31000 164K r-x-- /lib/x86_64-linux-gnu/ld-2.27.so
00007f8fd0d24000 20K rw--- [ anon ]
00007f8fd0d53000 28K r--s- /usr/lib/x86_64-linux-gnu/gconv/gconv-modules.cache
00007f8fd0d5a000 4K r---- /lib/x86_64-linux-gnu/ld-2.27.so
00007f8fd0d5b000 4K rw--- /lib/x86_64-linux-gnu/ld-2.27.so
00007f8fd0d5c000 4K rw--- [ anon ]
00007ffff002f000 132K rw--- [ stack ]
00007ffff00c5000 12K r---- [ anon ]
00007ffff00c8000 4K r-x-- [ anon ]
ffffffffff600000 4K --x-- [ anon ]
total 24364K
Information will differ among different systems.
See the different regions:
- the first region, with
r-x
permissions is the.text
(code) area - the second region, with
r--
permissions is the.rodata
area - the third region, with
rw-
permissions is the.data
area, for initialized global variables - the fourth region, with
rw-
permissions is the.bss
area - the fifth region, with the
rw-
permissions is the dynamic data memory area, also known as heap - there are multiple dynamic libraries mapped in the virtual address space of the process, each library with their own regions
- there is a
[stack]
memory region, withrw-
permissions
pmap
also shows the total amount of virtual memory available to the process (24364K
), as a total of the sizes of the regions.
Note that this is virtual memory, not actual physical memory used by the process.
For the process investigated above (with the 1127
pid) we could use the command below to show the total virtual size and physical size (also called resident set size):
student@os:~$ ps -o pid,rss,vsz -p $$
PID RSS VSZ
1127 1968 24364
The resident size is 1968K
, much smaller than the virtual size.
Note how each region has a size multiple of 4K
, this has to do with the memory granularity.
The operating system allocates memory in chunks of a predefined size (in our case 4K
) called pages.
Memory Layout of Statically-Linked and Dynamically-Linked Executables
We want to see the difference in memory layout between the statically-linked and dynamically-linked executables.
Enter the chapters/data/process-memory/drills/tasks/static-dynamic/support
directory and build the statically-linked and dynamically-linked executables hello-static
and hello-dynamic
:
student@os:~/.../drills/tasks/static-dynamic/support$ make
Now, by running the two programs and inspecting them with pmap
on another terminal, we get the output:
student@os:~/.../drills/tasks/static-dynamic/support$ pmap $(pidof hello-static)
9714: ./hello-static
0000000000400000 876K r-x-- hello-static
00000000006db000 24K rw--- hello-static
00000000006e1000 4K rw--- [ anon ]
00000000017b5000 140K rw--- [ anon ]
00007ffc6f1d6000 132K rw--- [ stack ]
00007ffc6f1f9000 12K r---- [ anon ]
00007ffc6f1fc000 4K r-x-- [ anon ]
ffffffffff600000 4K --x-- [ anon ]
total 1196K
student@os:~/.../drills/tasks/static-dynamic/support$ pmap $(pidof hello-dynamic)
9753: ./hello-dynamic
00005566e757f000 8K r-x-- hello-dynamic
00005566e7780000 4K r---- hello-dynamic
00005566e7781000 4K rw--- hello-dynamic
00005566e8894000 132K rw--- [ anon ]
00007fd434eb8000 1948K r-x-- libc-2.27.so
00007fd43509f000 2048K ----- libc-2.27.so
00007fd43529f000 16K r---- libc-2.27.so
00007fd4352a3000 8K rw--- libc-2.27.so
00007fd4352a5000 16K rw--- [ anon ]
00007fd4352a9000 164K r-x-- ld-2.27.so
00007fd43549f000 8K rw--- [ anon ]
00007fd4354d2000 4K r---- ld-2.27.so
00007fd4354d3000 4K rw--- ld-2.27.so
00007fd4354d4000 4K rw--- [ anon ]
00007ffe497ba000 132K rw--- [ stack ]
00007ffe497e3000 12K r---- [ anon ]
00007ffe497e6000 4K r-x-- [ anon ]
ffffffffff600000 4K --x-- [ anon ]
total 4520K
For the static executable, we can see there are no areas for dynamic libraries.
And the .rodata
section has been coalesced in the .text
area.
We can see the size of each section in the two executables by using the size
command:
student@os:~/.../drills/tasks/static-dynamic/support$ size hello-static
text data bss dec hex filename
893333 20996 7128 921457 e0f71 hello-static
student@os:~/.../drills/tasks/static-dynamic/support$ size hello-dynamic
text data bss dec hex filename
4598 736 824 6158 180e hello-dynamic
Modifying Memory Region Size
We want to observe the update in size of memory regions for different instructions used in a program.
Enter the chapters/data/process-memory/drills/tasks/modify-areas/support
directory.
Browse the contents of the hello.c
file;
it is an update to the hello.c
file in the memory-areas/
directory.
Build the executable:
student@os:~/.../drills/tasks/modify-areas/support$ make
Use size
to view the difference between the new executable and the one in the memory-areas/
directory:
student@os:~/.../drills/tasks/modify-areas/support$ size hello
text data bss dec hex filename
13131 17128 33592 63851 f96b hello
student@os:~/.../drills/tasks/modify-areas/support$ size ../memory-areas/hello
text data bss dec hex filename
4598 736 824 6158 180e ../memory-areas/hello
Explain the differences.
Then use the pmap
to watch the memory areas of the resulting processes from the two different executables.
We will see something like this for the new executable:
student@os:~/.../drills/tasks/modify-areas/support$ pmap $(pidof hello)
18254: ./hello
000055beff4d0000 16K r-x-- hello
000055beff6d3000 4K r---- hello
000055beff6d4000 20K rw--- hello
000055beff6d9000 32K rw--- [ anon ]
000055beffb99000 324K rw--- [ anon ]
00007f7b6c2e6000 1948K r-x-- libc-2.27.so
00007f7b6c4cd000 2048K ----- libc-2.27.so
00007f7b6c6cd000 16K r---- libc-2.27.so
00007f7b6c6d1000 8K rw--- libc-2.27.so
00007f7b6c6d3000 16K rw--- [ anon ]
00007f7b6c6d7000 164K r-x-- ld-2.27.so
00007f7b6c8cd000 8K rw--- [ anon ]
00007f7b6c900000 4K r---- ld-2.27.so
00007f7b6c901000 4K rw--- ld-2.27.so
00007f7b6c902000 4K rw--- [ anon ]
00007ffe2b196000 204K rw--- [ stack ]
00007ffe2b1d8000 12K r---- [ anon ]
00007ffe2b1db000 4K r-x-- [ anon ]
ffffffffff600000 4K --x-- [ anon ]
total 4840K
We notice the size increase of text, data, bss, heap and stack sections.
Allocating and Deallocating Memory
Memory areas in a process address space are static or dynamic. Static memory areas are known at the beginning of process lifetime (i.e. at load-time), while dynamic memory areas are managed at runtime.
.text
, .rodata
, .data
, .bss
are allocated at load-time and have a predefined size.
The stack and the heap and memory mappings are allocated at runtime and have a variable size.
For those, we say we use runtime allocation and deallocation.
Memory allocation is implicit for the stack and explicit for the heap.
That is, we don't make a particular call to allocate data on the stack;
the compiler generates the code that the operating system uses to increase the stack when required.
For the heap, we use the malloc()
and free()
calls to explicitly allocate and deallocate memory.
Omitting to deallocate memory results in memory leaks that hurt the resource use in the system. Because of this, some language runtimes employ a garbage collector that automatically frees unused memory areas. More than that, some languages (think of Python) provide no explicit means to allocate memory: you just define and use data.
Let's enter the chapters/data/process-memory/drills/tasks/alloc_size/support
directory.
Browse the alloc_size.c
file.
Build it:
student@os:~/.../drills/tasks/alloc_size/support$ make
Now see the update in the process layout, by running the program in one console:
student@os:~/.../drills/tasks/alloc_size/support$ ./alloc_size
Press key to allocate ...
[...]
And investigating it with pmap
on another console:
student@os:~/.../drills/tasks/alloc_size/support$ pmap $(pidof alloc_size)
21107: ./alloc_size
000055de9d173000 8K r-x-- alloc_size
000055de9d374000 4K r---- alloc_size
000055de9d375000 4K rw--- alloc_size
000055de9deea000 132K rw--- [ anon ]
00007f1ea4fd4000 1948K r-x-- libc-2.27.so
00007f1ea51bb000 2048K ----- libc-2.27.so
00007f1ea53bb000 16K r---- libc-2.27.so
00007f1ea53bf000 8K rw--- libc-2.27.so
00007f1ea53c1000 16K rw--- [ anon ]
00007f1ea53c5000 164K r-x-- ld-2.27.so
00007f1ea55bb000 8K rw--- [ anon ]
00007f1ea55ee000 4K r---- ld-2.27.so
00007f1ea55ef000 4K rw--- ld-2.27.so
00007f1ea55f0000 4K rw--- [ anon ]
00007ffcf28e9000 132K rw--- [ stack ]
00007ffcf29be000 12K r---- [ anon ]
00007ffcf29c1000 4K r-x-- [ anon ]
ffffffffff600000 4K --x-- [ anon ]
total 4520K
student@os:~/.../drills/tasks/alloc_size/support$ pmap $(pidof alloc_size)
21107: ./alloc_size
000055de9d173000 8K r-x-- alloc_size
000055de9d374000 4K r---- alloc_size
000055de9d375000 4K rw--- alloc_size
000055de9deea000 452K rw--- [ anon ]
00007f1ea4fd4000 1948K r-x-- libc-2.27.so
00007f1ea51bb000 2048K ----- libc-2.27.so
00007f1ea53bb000 16K r---- libc-2.27.so
00007f1ea53bf000 8K rw--- libc-2.27.so
00007f1ea53c1000 16K rw--- [ anon ]
00007f1ea53c5000 164K r-x-- ld-2.27.so
00007f1ea55bb000 8K rw--- [ anon ]
00007f1ea55ee000 4K r---- ld-2.27.so
00007f1ea55ef000 4K rw--- ld-2.27.so
00007f1ea55f0000 4K rw--- [ anon ]
00007ffcf28e9000 132K rw--- [ stack ]
00007ffcf29be000 12K r---- [ anon ]
00007ffcf29c1000 4K r-x-- [ anon ]
ffffffffff600000 4K --x-- [ anon ]
total 4840K
student@os:~/.../drills/tasks/alloc_size/support$ pmap $(pidof alloc_size)
21107: ./alloc_size
000055de9d173000 8K r-x-- alloc_size
000055de9d374000 4K r---- alloc_size
000055de9d375000 4K rw--- alloc_size
000055de9deea000 420K rw--- [ anon ]
00007f1ea4fd4000 1948K r-x-- libc-2.27.so
00007f1ea51bb000 2048K ----- libc-2.27.so
00007f1ea53bb000 16K r---- libc-2.27.so
00007f1ea53bf000 8K rw--- libc-2.27.so
00007f1ea53c1000 16K rw--- [ anon ]
00007f1ea53c5000 164K r-x-- ld-2.27.so
00007f1ea55bb000 8K rw--- [ anon ]
00007f1ea55ee000 4K r---- ld-2.27.so
00007f1ea55ef000 4K rw--- ld-2.27.so
00007f1ea55f0000 4K rw--- [ anon ]
00007ffcf28e9000 132K rw--- [ stack ]
00007ffcf29be000 12K r---- [ anon ]
00007ffcf29c1000 4K r-x-- [ anon ]
ffffffffff600000 4K --x-- [ anon ]
total 4808K
The three runs above of the pmap
command occur before the allocation, after allocation and before deallocation and after deallocation.
Notice the update toe the 4th section, the heap.
Now, let's see what happens behind the scenes.
Run the executable under ltrace
and strace
:
student@os:~/.../drills/tasks/alloc_size/support$ ltrace ./alloc_size
malloc(32768) = 0x55e33f490b10
printf("New allocation at %p\n", 0x55e33f490b10New allocation at 0x55e33f490b10
) = 33
[...]
free(0x55e33f490b10) = <void>
[...]
student@os:~/.../drills/tasks/alloc_size/support$ strace ./alloc_size
[...]
write(1, "New allocation at 0x55ab98acfaf0"..., 33New allocation at 0x55ab98acfaf0
) = 33
write(1, "New allocation at 0x55ab98ad7b00"..., 33New allocation at 0x55ab98ad7b00
) = 33
brk(0x55ab98b08000) = 0x55ab98b08000
write(1, "New allocation at 0x55ab98adfb10"..., 33New allocation at 0x55ab98adfb10
) = 33
write(1, "Press key to deallocate ...", 27Press key to deallocate ...) = 27
read(0,
"\n", 1024) = 1
brk(0x55ab98b00000) = 0x55ab98b00000
[...]
The resulting output above shows us the following:
malloc()
andfree()
library calls both map to thebrk
syscall, a syscall that updates the end of the heap (called program break).- Multiple
malloc()
calls map to a singlebrk
syscall for efficiency.brk
is called to preallocate a larger chunk of memory thatmalloc
will then use.
Update the ALLOC_SIZE_KB
macro in the alloc_size.c
file to 256
.
Rebuild the program and rerun it under ltrace
and strace
:
student@os:~/.../drills/tasks/alloc_size/support$ ltrace ./alloc_size
[...]
malloc(262144) = 0x7f4c016a9010
[...]
free(0x7f4c016a9010) = <void>
[...]
student@os:~/.../drills/tasks/alloc_size/support$ strace ./alloc_size
[...]
mmap(NULL, 266240, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7feee19f2000
write(1, "New allocation at 0x7feee19f2010"..., 33New allocation at 0x7feee19f2010
) = 33
mmap(NULL, 266240, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7feee19b1000
write(1, "New allocation at 0x7feee19b1010"..., 33New allocation at 0x7feee19b1010
) = 33
write(1, "Press key to deallocate ...", 27Press key to deallocate ...) = 27
read(0,
"\n", 1024) = 1
munmap(0x7feee19b1000, 266240) = 0
[...]
For the new allocation size, notice that the remarks above don't hold:
malloc()
now invokes themmap
syscall, whilefree()
invokes themunmap
syscall.- Each
malloc()
calls results in a separatemmap
syscall.
This is a behavior of the malloc()
in libc, documented in the manual page.
A variable MALLOC_THRESHOLD
holds the size after which mmap
is used, instead of brk
.
This is based on a heuristic of using the heap or some other area in the process address space.
Memory Mapping
The mmap
syscall is used to allocate memory as anonymous mapping, that is reserving memory in the process address space.
An alternate use is for mapping files in the memory address space.
Mapping of files is done by the loader for executables and libraries.
That is why, in the output of pmap
, there is a column with a filename.
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset)
To better understand this prototype, let's break it down using an example:
void *mapped_region = mmap(NULL, filesize, PROT_READ, MAP_PRIVATE, fd, 0);
The arguments are as follows:
addr
: used to request an exact memory address for the mapping; since we are not constrained by anything, we setaddr
toNULL
, which means the kernel chooses the addresslength
: the length of the mapping i.e.filesize
prot
: specifies the protection of the mapping, and can be a combination ofPROT_READ
,PROT_WRITE
,PROT_EXEC
, andPROT_NONE
flags
: the type of the mapping, such asMAP_SHARED
(changes are shared between processes and written back to the file) orMAP_PRIVATE
(changes are private to the process and do not modify the file)fd
: the file descriptor of the file to be mappedoffset
: the offset in the file where the mapping should start
Mapping a file provides a pointer to its contents, allowing you to use this pointer to read or write data.
This method turns reading and writing to a file into a matter of pointer copying, rather than relying on read
/ write
system calls.
Unlike mmap
, the read
and write
system calls involve explicitly reading from or writing to a file through a buffer, transferring data between the user space and kernel space.
For example:
int src_fd = open("in.dat", O_RDONLY);
int dst_fd = open("out.dat", O_WRONLY | O_CREAT | O_TRUNC, 0644);
char buffer[8192];
ssize_t bytes;
while ((bytes = read(src_fd, buffer, sizeof(buffer))) > 0) {
write(dst_fd, buffer, bytes);
}
In this code snippet, we open a source file for reading and a destination file for writing.
read
reads up to sizeof(buffer)
bytes from the file descriptor src_fd
into the buffer.
Notice that the read
system call returns the number of bytes read.
The write
system call writes the bytes read from the buffer into the destination file and returns the number of bytes successfully written.
You should also note the open()
system call's prototype: int open(const char *pathname, int flags)
.
The argument flags
must include one of the following access modes: O_RDONLY
, O_WRONLY
, or O_RDWR
- indicating that the file is opened in read-only, write-only, or read/write mode.
You can add an additional flag - O_CREAT
- that will create a new file with pathname
if the file does not already exist.
This is only the case when opening the file for writing (O_WRONLY
or O_RDWR
).
If O_CREAT
is set, a third argument mode_t mode
is required for the open()
syscall.
The mode
argument specifies the permissions of the newly created file.
For example:
// If DST_FILENAME exists it will be open in read/write mode and truncated to length 0
// If DST_FILENAME does not exist, a file at the path DST_FILENAME will be create with 644 permissions
dst_fd = open(DST_FILENAME, O_RDWR | O_CREAT | O_TRUNC, 0644);
We will investigate the differences between mapping a file and using read
and write
system calls more deeply in the task found at chapters/data/process-memory/drills/tasks/copy/
, by benchmarking the two methods via the benchmark_cp.sh
script.
If you inspect it, you will notice a weird-looking command sh -c "sync; echo 3 > /proc/sys/vm/drop_caches"
.
This is used to disable a memory optimization that the kernel does.
It's called "buffer cache" and it's a mechanism by which the kernel caches data blocks from recently accessed files in memory.
You will get more detailed information about this in the I/O chapter.
Memory Support
Manual memory management (MMM) is one of the most difficult tasks. Even experienced programmers make mistakes when tackling such a complicated endeavor. As a consequence, the programming world has been migrating towards languages that offer automatic memory management (AMM). AMM programming languages typically offer a garbage collector that tracks down the usage of objects and frees memory once no references exist to a given object. As a consequence, garbage collected programming languages are easier to use and safer. However, this comes with a cost: the garbage collector, in most cases, requires a significant amount of resources to run. Therefore, for performance-critical systems, MMM is still the preferred solution.
A middle-ground between programming languages that have AMM (Java, Python, Swift, D) and those that do not (C, C++) is represented by those languages that do not have built-in AMM but offer the possibility to implement it as a library solution (C++, D). Concretely, these languages offer lightweight library solutions to optimally track down the lifetime of an object. This is done by using reference counted objects.
Reference Counting
Reference counting is a technique of tracking the lifetime of an object by counting how many references to an object exist. As long as at least one reference exists, the object cannot be destroyed. Once no reference to a given object exists, it can be safely destroyed. Reference counted is typically implemented by storing a count with the actual payload of the object. Every time a new reference to the object is created, the reference count is incremented. Every time a reference expires, the reference is decremented.
The operations that trigger a reference increment are:
- initializing an object from another object.
- assigning an object to another object.
The operations that trigger a reference decrement are:
- the lifetime of an object expires
Modern programming languages offer the possibility to specify what code should be run in each of these situations, therefore enabling the implementation of referenced counted data structures. As such, copy constructors may be used to automatically initialize an object from another object, assignment operators may be used to assign an object to another object and destructors may be used to destroy objects.
Guide: Memory Allocation Strategy
Navigate to the guides/memory-alloc/support/
directory.
It contains 3 implementations of the same program in different languages: C, Python and D.
The program creates a list of entries, each entry storing a name and an id.
The purpose of this exercise is to present the different strategies that programming languages adopt to manage memory.
C
The C implementation manages the memory manually.
You can observe that all allocations are performed via malloc()
and the memory is freed using free()
.
Arrays can be defined as static (on the stack) or dynamic (a pointer to some heap memory).
Stack memory doesn't need to be freed, hence static arrays are automatically deallocated.
Heap memory, however, is managed by the user, therefore it is the burden of the programmer to find the optimal memory strategy.
This offers the advantage that you can fine tune the memory usage depending on your application, but this comes with a cost: more often than not, managing memory is a highly complex error-prone task.
Python
The Python implementation of the program has no notion of memory allocation. It simply defines variables and the garbage collector takes care of allocating and deallocating memory. Notice how the destructor is called automatically at some point when the garbage collector deems that the list is not used anymore. Garbage collection lifts the burden of memory management from the user, however, it may be unsuitable for certain scenarios. For example, real-time applications that need to take action immediately once a certain event occurs cannot use a garbage collector (GC). That is because the GC usually stops the application to free dead objects.
D
The previous 2 examples have showcased extreme situations: fully manual vs fully automatic memory management.
In D, both worlds are combined: variables may be allocated manually on the stack/heap or via the garbage collector (for brevity, malloc()
-based allocation is not presented in this example).
Arrays that are allocated on the stack behave the same as in C, whereas arrays allocated with the garbage collector mimic Python lists.
Classes are also garbage collected.
Guide: Memory Vulnerabilities
The purpose of this exercise is to provide examples on how memory corruption may occur and what are the safety guards implemented by different programming languages.
Navigate to the guides/memory-vuln/support/
directory.
It features 3 files, each showcasing what happens in case of actions that may lead to memory corruption.
C
The C implementation showcases some of the design flaws of the language can lead to memory corruption.
The first example demonstrates how a pointer to an expired stack frame may be leaked to an outer scope. The C language does not implement any guards against such behavior, although data flow analysis could be used to detect such cases.
The second example highlights the fact that C does not check any bounds when performing array operations.
This leads to all sorts of undefined behavior.
In this scenario, some random memory is overwritten with 5
.
The third example exhibits a manifestation of the previous design flaw, where the return address of the main
function is overwritten with 0
, thus leading to a segmentation fault.
Although today it seems obvious that such behavior should not be accepted, we should take into account that the context in which the C language was created was entirely different from today. At that time the resource constraints - DRAM memory was around a few KB, operating systems were in their infancy, branch predictors did not exist etc. - were overwhelming. Moreover, security was not a concern because the internet basically did not exist. As a consequence, the language was not developed with memory safety in mind.
Python
Technically, it is not possible to do any memory corruption in Python (that is, if you avoid calling C functions from it).
Pointers do not formally exist, and any kind of array access is checked to be within its bounds.
The example simply showcases what happens when an out-of-bounds access is performed - an IndexError
is thrown and execution halts.
D
The D implementation uses almost the same code as the C implementation, but suffers from minor syntax modifications.
In essence, the two implement the same logic.
When compiling this code, it can be observed that the D compiler notices at compile time that an out-of-bounds access is performed.
This makes sense, since a static array cannot modify its length and therefore the compiler has all the information to spot the mistake.
The only way to make the code compile is to comment the faulting lines or to replace the out-of-bounds index with a correct one.
After doing so, the program compiles and we can see that memory corruption occurs.
However, D also has safety checks, however, these are not performed by default.
To enable such checks, the user must annotate a function with the @safe
keyword:
int* bad() @safe
By doing so, the mechanical checks are enabled and a new set of criteria needs to be followed for the code to be accepted.
Taking the address of a local, doing pointer arithmetic, reinterpret casts, calling non-@safe
functions etc. are not allowed in @safe
code.
If any of these unsafe features are manually proven to be safe, the @trusted
keyword may be used to disable the checks but still consider the code @safe
.
This is to allow writing system code, which by its nature is unsafe.