pete > courses > CS 315 Fall 26 > lecture 10: mmap
Lecture 10: mmap
Goals
- use ldd to see which dynamically-linked libraries a program uses
- use ps (along with grep) to see running processes
- use /proc to examine the state of individual running processes
- explain the contents of /proc/*pid*/maps
- explain the behavior of the mmap system call
- elucidate the differences between a shared mapping and a private mapping
last time, we looked at the process of dynamic linking
we have previously looked at the ELF metadata within a program
among which data we can find enumerated the collection of libraries that need to be linked at runtime
but this doesn’t tell us precisely which file on disk fulfills this bibliographical need
the ldd program will do this for us
applying it to the hello-world program from the previous lecture…
$ ldd hello-world
linux-vdso.so.1 (0x00007ffe305f0000)
libc.so.6 => /usr/lib/libc.so.6 (0x00007f29ebd98000)
/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007f29ebf8f000)
we see that the Standard C library (libc.so.6) is to be found in the file /usr/lib/libc.so.6
if we ran nm on this file, we’d see symbols for all the functions you’ve seen with section-3 manpages
(we won’t, but you can on your own)
by this time, we’ve spent a lot of time looking at ELF and the various structures within it that allow us to store different types of data within a program
eg, instructions, PLT, GOT, symbol table, libraries to be linked
today, we’re going to look at how this stuff manifests itself in a running process
along the way, we’ll also see a little peek at a bunch of systemsy things (I’ll note them explicitly below) that we’ll get into in greater detail later in the semester
but first, we have to find a process to examine
you may be used to the Task Manager on Windows or Activity Monitor on OS X
the equivalent UNIX command is ps
$ ps PID TTY TIME CMD 14623 pts/11 00:00:00 bash 16622 pts/11 00:00:00 ps
columns, left to right: - PID: process id, a unique number assigned to each process - TTY: identifies the terminal window in which the command is running - TIME: the length of time the process has actually been running on the CPU (recall that processes take turns using the CPU to make them appear to run simultaneously) - CMD: the command that started the process
this list is really short, though clearly there are other processes running on my machine
this is because, with no arguments, ps only lists processes running inside the current terminal session
(historical note: "TTY" and "terminal" are terms from antiquity, in which one made a physical connection to a computer to interact with it)
if you want to see all the running processes, ps has options for that
the particular set of options I use for that purpose is
$ ps auxww
(full output omitted due to length)
read the manpage to see exactly what they do, or just blindly use this command because it Just Works
here’s the first line of the output, which describes the columns:
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
new columns: - USER: the name of the user that ran the process - %CPU: portion of the CPU the process has used over the past couple seconds - %MEM: portion of system memory the process is currently consuming - VSZ and RSS: measures of process memory usage - START: when the process was started
what if I want information about a single process?
it would be nice if I could take the output of that command and remove all the lines I don’t care about
the grep program will do that: it takes a string (called a pattern) as a command-line argument and then reads from its input
every line the pattern appears in, it will print
that’s great, but now we need a way to pass the output of ps to the input of grep
this is precisely what the pipe does
$ ps auxww | grep firefox pete 1345 17.6 6.6 3218148 1079924 ? Sl Oct02 645:13 /usr/lib/firefox/firefox -P default pete 16676 0.0 0.0 11184 2384 pts/11 S+ 22:21 0:00 grep -E firefox
note that it shows not just the firefox process, but also the grep process that is filtering for the "firefox" string
all of this has been leading up to the ability to figure out the PID for a particular process, because we need it to examine its running state
so pick a PID at random and let’s go to /proc
(for illustrative purposes in the notes, I will assume we’re choosing pid 12345, though whether a process with that pid actually exists on your machine when you try to duplicate these instruction is anybody’s guess—use ps to find one)
what is /proc, you ask?
it’s… weird, in that it doesn’t actually exist
think of the myread function you wrote
instead of actually reading data from the file, it could very easily have just fabricated random data and given it to the user
that’s essentially what all the files in /proc are: they don’t exist on disk anywhere, but when you ask for their contents, the kernel consults its internal data structures and generates the file data on the fly
among some other things, most of the directories in /proc are numerical: each one corresponds to a running process
therefore, the files in /proc/12345 will describe the process with pid 12345
you can see there are quite a few files in there
the file I want us to look at is maps, which shows all the memory in use by a running process—otherwise known as its memory map
$ cat /proc/12345/maps
which shows us lots of inscrutable output
we’ll look at a few items individually
first, at the top:
00400000-004c6000 r-xp 00000000 fe:00 56760614 /usr/bin/bash
this says that the contents of the file /usr/bin/bash exist in memory at addresses 00400000 through 004c6000 and that this memory is both readable and executable (r-xp)
these are the instructions!
the address range might look familiar from your pokings-around with gdb
the next two lines:
006c5000-006c6000 r--p 000c5000 fe:00 56760614 /usr/bin/bash 006c6000-006ca000 rw-p 000c6000 fe:00 56760614 /usr/bin/bash
the first line here represents read-only data (r--p) defined by the program
the second is both readable and writeable (rw-p)—stuff like the GOT and global variables and static variables
a few lines later we can see the heap!
00759000-008a3000 rw-p 00000000 00:00 0 [heap]
and still further on we can see that libc has been loaded:
7f6815b87000-7f6815d35000 r-xp 00000000 fe:00 56756359 /usr/lib/libc-2.26.so 7f6815d35000-7f6815f34000 ---p 001ae000 fe:00 56756359 /usr/lib/libc-2.26.so 7f6815f34000-7f6815f38000 r--p 001ad000 fe:00 56756359 /usr/lib/libc-2.26.so 7f6815f38000-7f6815f3a000 rw-p 001b1000 fe:00 56756359 /usr/lib/libc-2.26.so
it, too, has parts that are readable and executable but not writeable; only readable; and readable and writeable but not executable
I don’t know off the top of my head why there is some associated memory that is neither readable, nor writeable, nor executable
you can also see that this particular process has a bunch of libraries loaded
finally, near the bottom, we see that it has a stack:
7fffb562c000-7fffb564d000 rw-p 00000000 00:00 0 [stack]
and again the addresses jibe with what we’ve seen in gdb
this begs the question: how does the data from those files get into memory at those addresses?
one possible method is, on process start, for each file (executable or library):
- open the executable or library
- read its entire contents into memory
- close the executable or library
this is not the way it’s done (because efficiency)
instead, the process uses the mmap system call to map a set of memory addresses to bytes within the file on disk
first, let’s make the vocab clear, because I think it will help clarify what’s going on
this term comes from mathematics, where functions are often described as mapping one set of values to another
for instance, consider the function f(x) = x * 2
it maps (translates) an input to an output
in the same way, a memory mapping translates an address in memory to a byte within the underlying file
therefore, given this mapping
55a777f2c000-55a777f34000 r-xp 00000000 fe:00 56762708 /usr/bin/cat
if the process attempts to read memory address 55a777f2c000, it will a) succeed and b) give the zeroth byte of the file /usr/bin/cat
similarly, if the process attempts to read memory address 55a777f2c010, it will read also succeed and give the 16th byte of the file /usr/bin/cat (because those numbers are hex)
the upshot of all of this is that, when a file is memory-mapped, we can perform file I/O using pointers
so how does mmap work?
it’s a system call, so man 2 mmap should be your first stop
unfortunately, it’s a pretty imposing description (as befits one of the most important system calls) so let’s look at some examples of it in action
we’ll start with mmap-test.c
and the dummy input file test-file
and you might find the Makefile for the rest handy
I wrote this program to demonstrate how a mapping is set up
it pauses before creating the mapping (at which point we will examine the mappings and note that the file in question is not mapped)
then I’ll hit enter and it will perform the mapping (at which point we will examine the mappings and note that it is now mapped)
let’s walk through the source code before we compile and run it
note that I was conscientious about error checking in this program, to set a good example
in the later programs today, I was not conscientious to make sure the code all fits on one screen
you need to be conscientious in your code
to the code!
first we open(2) the file:
fd = open("test-file", O_RDONLY)
note that the permissions (in this case, O_RDONLY) must match how you want to use the memory being mapped
that is, if you want to be able to write to the memory (we’ll see an example in a bit) you need to open it either O_WRONLY or O_RDWR
then we find its size, because we need that when we get around to calling mmap
fstat(fd, &sb)
(sb is an instance of struct stat—check the fstat(2) manpage)
then the pause, tackily implemented using write(2) and read(2), with a shameful lack of error checking
and finally the call to mmap itself:
file_data = mmap(NULL, sb.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
the first parameter allows you to request a particular address for the contents of the file to mapped to (though you might not get it)
the second parameter is the length of the mapping in bytes
the third parameter is the permissions of the mapping, in this case it will be read-only (we’ll see read/write in a bit)
the fourth parameter determines what happens to changes you make to the memory (we’ll also see this in action in a bit)
the fifth parameter is the open file descriptor corresponding to the file to be mapped
the sixth and parameter is where within the file to begin mapping—in this case, the beginning (offset 0)
mmap returns a pointer to the memory in which the file’s data will be available
to sum up, in this call, and in a possibly more-intuitive order: - we’re asking to map the file represented by fd (arg 5) - starting at the beginning of the file (arg 6) - continuing to the end (arg 2) - we want to read the data (arg 3) - we don’t want anybody else to see any changes we might make to the data (yes, even though we just said we’re only reading it) (arg 4) - and we don’t care where in memory the data appears (arg 1)
and we save the memory address at which the data is available in the variable file_data
so we can get access to that data through that pointer
which is exactly what we do with the next bit of code:
write(1, file_data, sb.st_size)
this writes the file data to file descriptor 1, which is stdout
then, to show that we can really interact with the data using pointers…
write(1, file_data + 13, sb.st_size - 13)
which outputs the file data starting 13 bytes in
finally, we de-mmap it (which is called "munmap")
munmap(file_data, sb.st_size)
and close the file
close(fd)
time to see it in action
compile it:
$ gcc -Wall -pedantic -o mmap-test mmap-test.c
and run it:
$ ./mmap-test hit enter to mmap
it is now waiting to mmap
we will use this opportunity to check out the maps for the running process
first we find the pid:
$ ps auxww | grep mmap pete 20582 0.0 0.0 6212 780 pts/7 S+ 22:30 0:00 ./mmap-test
then look at the maps:
$ less /proc/20582/maps
we can see that test-file is not mapped ('q' to quit from less)
press enter, and we see the contents of the file as expected, followed by the second prompt:
This is some random test data. random test data. hit enter to munmap
since the file is still mapped, we can again check the maps and see it is indeed mapped:
7fc5e06b8000-7fc5e06b9000 r--p 00000000 fe:00 1442208 /home/pete/tmp/315-mmap/test-file
not only that, but it’s mapped readable (r--p) and if we printed out the address of file_data in the program, it would be 7fc5e06b8000
so that’s basic mmap usage, now it’s time to get more interesting
check out writer.c
$ make writer gcc -Wall -pedantic -c -o writer.o writer.c gcc -ldl -o writer writer.o
(again, lack of error checking so it all fits on screen—I do not condone this behavior in real code)
we go through the same setup routine, but the main event this time is actually modifying the data by writing to the mapped memory:
memcpy(file_data + 8, "MINE", 4);
if we run the program, would we expect to see the data change?
$ cat test-file This is some random test data. $ ./writer hit enter to write hit enter to munmap file $ cat test-file This is some random test data.
no change
the reason lies in the MAP_PRIVATE flag provided to mmap(2), which is also the source of the "p" in the permissions I have shown and will now finally explain
recall the mapping of test-file shown above, in which the permissions are r--p
now, in writer.c, change MAP_PRIVATE to MAP_SHARED
then recompile, and re-run
$ make writer gcc -Wall -pedantic -c -o writer.o writer.c gcc -ldl -o writer writer.o $ ./writer
find its pid and check its map:
$ ps auxww | grep writer pete 22131 0.0 0.0 6216 708 pts/7 S+ 23:14 0:00 ./writer $ grep test-file /proc/22131/maps 7fa587339000-7fa58733a000 rw-s 00000000 fe:00 1442208 /home/pete/tmp/315-mmap/test-file
and oh look the p has become an s
in the window running writer, press enter once (it should now saw "hit enter to munmap file") and check out the file contents:
$ cat test-file This is MINE random test data.
and the data has changed
this exemplifies the difference between a private mapping and a shared mapping
when modifications are made to a private mapping, those modifications are NOT visible outside the process
when modifications are made to a shared mapping, those modifications ARE visible outside the process
which begs the question: what if another process has mapped the same file? will it see changes the first process makes if it’s a shared mapping?
let’s find out!
before we start, revert to the original contents of test-file (it should contain "This is some random test data.")
check out reader.c
compile it:
$ make reader gcc -Wall -pedantic -c -o reader.o reader.c gcc -ldl -o reader reader.o
open two terminal windows, one for the writer and one for the reader (the first line in each output will indicate which terminal it refers to—it is not being shown on the screen)
first, get the writer going
WRITER $ ./writer hit enter to write
then get the reader going
READER $ ./reader hit enter to output file contents
press enter to see what the READER thinks the file says
READER This is some random test data.
go press enter in the WRITER window
WRITER hit enter to munmap file
now go see if the READER saw that by pressing enter in its window
READER This is MINE random test data.
conclusion: when the mapping is shared, other processes do see the changes
and they seem the more or less immediately
this is kind of crazy
and potentially really powerful
but, as Peter Parker learned, with great power comes great responsibility
and we’ll learn about that responsibility in a couple weeks