How is data represented in an ELF file? And what is this cryptically named bss
section?
Part 12 Dealing with data
In the last couple of posts we looked at running RISC-V instructions directly and then we improved the instruction handler by using C++ concepts.
Until now, the only C programs that we’ve compiled for the Owl-2820 VM have been various incarnations of fib.c
that generated the first few results in the Fibonacci sequence. It is time to move on from Fibonacci and compile and run other programs, but to do so, we need to find a way to deal with data.
It may not be immediately obvious, but fib.c
doesn’t use data so we’ve been able to get away with creating an image from its text sections only. In other words, we’ve been loading and running an image that contains only code, not data.
In the next few posts we’re going to look at how we can load and run programs that contain both code and data. In this post, we’re going to look at different types of data, then focus on read-only data.
Let’s start this journey with a reminder of how we build and run programs.
Recap - building and running fib.c
To recap, we’ve built and run programs as follows:
- use
clang
to compile and link the code into an RV32I ELF binary - extract the
.init
and.text
sections into a binary image usingllvm-objcopy
- create a hex dump of the binary image and embed it in the source code of the VM so that it can be run without having to load anything
- transcode the image from RISC-V encoded instructions to Owl-2820 encoded instructions
Let’s go through each stage and take a closer look at what is going on.
Compiling with clang
This command line tells clang
to compile the startup code in crt0.S
and the main program in fib.c
, and link them into an 32-bit RISC-V ELF binary named a.out
.
clang -Os --target=riscv32 -march=rv32i -mabi=ilp32 -ffreestanding -ffunction-sections \
-fdata-sections ./source/crt0.S ./source/fib.c -nostdlib -nodefaultlibs \
"-Wl,--section-start=.init=0" "-Wl,--section-start=.text=100"
It uses the --section-start
linker option to place the .init
section, which contains the instructions for the startup code, at address 0x00000000
, and the .text
section, which contains the instructions for the main program, at address 0x00000100
.
The .init and .text sections
Here we use llvm-objdump
with the -h
flag to view the section headers in the resulting ELF binary, a.out
.
llvm-objdump -h a.out
In the output below, the .init
and .text
sections are both of type TEXT
, meaning that they contain executable instructions.
The .eh_frame
section is of type DATA
, meaning that it contains data. However, as .eh_frame
describes call frames to be unwound during C++ exception handling, the data that it contains is not very interesting to us at this point.
a.out: file format elf32-littleriscv
Sections:
Idx Name Size VMA Type
0 00000000 00000000
1 .init 0000001c 00000000 TEXT
2 .text 00000054 00000100 TEXT
3 .eh_frame 0000002c 00001154 DATA
4 .riscv.attributes 0000001c 00000000
5 .comment 00000029 00000000
6 .symtab 000000c0 00000000
7 .shstrtab 0000004c 00000000
8 .strtab 00000031 00000000
The .init
section is shockingly small as it contains just 28 bytes (0000001c
in the Size
column), and the .text
section isn’t much bigger, at 84 bytes (00000054
). The total size of the combined executable sections is only 112 bytes. Given the size of most programs these days, you’d be forgiven for thinking that 112 bytes could be a typo or a miscalculation, but it is correct as fib.c
contains no data, uses no libraries, and relies on syscalls to do the heavy lifting.
The sections in the file are described in this table.
Section Name | Description |
---|---|
.init |
the executable instructions for the startup code |
.text |
the executable instructions for the program |
.eh_frame |
call frames to be unwound in C++ exception processing |
.riscv.attributes |
RISC-V specific attributes |
.comment |
version control information |
.symtab |
a symbol table |
.shstrtab |
a blob of zero-terminated section names, indexed by the section header |
.strtab |
strings, usually associated with symbol table entries |
An ELF file can have many different types of section. For a more comprehensive list take a look at the Linux Standard Base Core Specification.
Disassembling the ELF binary
The next command line uses llvm-objdump
with the -f
flag to display the file header, and the -d
flag to disassemble the instructions in the ELF binary, a.out
.
llvm-objump -f -d a.out
By default llvm-objdump
will only disassemble sections that contain executable instructions, i.e., those whose type is TEXT
. As the only TEXT
sections in a.out
are .init
and .text
, these are the only sections that are disassembled.
The disassembly output confirms that the startup code in the .init
section is at address 0x00000000
and that main
, in the .text
section, is at address 0x00000100
.
a.out: file format elf32-littleriscv
architecture: riscv32
start address: 0x00000000
Disassembly of section .init:
00000000 <_start>:
0: 13 05 00 00 li a0, 0x0
4: 93 05 00 00 li a1, 0x0
8: 13 06 00 00 li a2, 0x0
c: ef 00 40 0f jal 0x100 <main>
10: 13 05 00 00 li a0, 0x0
14: 93 08 00 00 li a7, 0x0
18: 73 00 00 00 ecall
Disassembly of section .text:
00000100 <main>:
100: 13 06 00 00 li a2, 0x0
104: 93 06 20 00 li a3, 0x2
108: 13 07 10 00 li a4, 0x1
10c: 93 07 00 03 li a5, 0x30
110: 93 05 06 00 mv a1, a2
114: 63 62 d6 02 bltu a2, a3, 0x138 <main+0x38>
118: 13 05 00 00 li a0, 0x0
11c: 93 05 10 00 li a1, 0x1
120: 13 08 06 00 mv a6, a2
124: 93 88 05 00 mv a7, a1
128: 13 08 f8 ff addi a6, a6, -0x1
12c: b3 05 b5 00 add a1, a0, a1
130: 13 85 08 00 mv a0, a7
134: e3 68 07 ff bltu a4, a6, 0x124 <main+0x24>
138: 93 08 10 00 li a7, 0x1
13c: 13 05 06 00 mv a0, a2
140: 73 00 00 00 ecall
144: 13 06 16 00 addi a2, a2, 0x1
148: e3 14 f6 fc bne a2, a5, 0x110 <main+0x10>
14c: 13 05 00 00 li a0, 0x0
150: 67 80 00 00 ret
Extracting the code with llvm-objcopy
The llvm-objcopy
is used to create a binary image named a.bin
by extracting the .init
section and .text
section from the ELF binary, a.out
. As fib.c
doesn’t contain any data, there is no need to extract any other sections.
llvm-objcopy a.out -O binary a.bin -j .init -j .text
When llvm-objcopy
extracts the image, it fills gaps between sections with zeroes. There’s a gap between the end of the 28 byte .init
section and the start of the 84 byte .text
section, so the size of the extracted file, a.bin
, is 340 bytes rather than 112 bytes.
Loading the binary image
We’ve been loading the image by taking a hex dump of a.bin
then pasting the output into the VM’s source code where it can be treated as array of bytes.
std::span<uint32_t> LoadRv32iImage()
{
// The RISC-V binary image from: https://badlydrawnrod.github.io/posts/2024/08/20/lbavm-008/
alignas(alignof(uint32_t)) static uint8_t image[] = {
// Address 0x0000 (from the .init section)
0x13, 0x05, 0x00, 0x00, 0x93, 0x05, 0x00, 0x00, 0x13, 0x06, 0x00, 0x00, 0xEF, 0x00, 0x40, 0x0F,
0x13, 0x05, 0x00, 0x00, 0x93, 0x08, 0x00, 0x00, 0x73, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
// --- snip - lots of zeroes inserted by `llvm-obcopy` ---
// Address 0x0100 (from the .text section)
0x13, 0x06, 0x00, 0x00, 0x93, 0x06, 0x20, 0x00, 0x13, 0x07, 0x10, 0x00, 0x93, 0x07, 0x00, 0x03,
0x93, 0x05, 0x06, 0x00, 0x63, 0x62, 0xD6, 0x02, 0x13, 0x05, 0x00, 0x00, 0x93, 0x05, 0x10, 0x00,
0x13, 0x08, 0x06, 0x00, 0x93, 0x88, 0x05, 0x00, 0x13, 0x08, 0xF8, 0xFF, 0xB3, 0x05, 0xB5, 0x00,
0x13, 0x85, 0x08, 0x00, 0xE3, 0x68, 0x07, 0xFF, 0x93, 0x08, 0x10, 0x00, 0x13, 0x05, 0x06, 0x00,
0x73, 0x00, 0x00, 0x00, 0x13, 0x06, 0x16, 0x00, 0xE3, 0x14, 0xF6, 0xFC, 0x13, 0x05, 0x00, 0x00,
0x67, 0x80, 0x00, 0x00
};
uint32_t* imageBegin = reinterpret_cast<uint32_t*>(image);
uint32_t* imageEnd = imageBegin + sizeof(image) / sizeof(uint32_t);
return std::span<uint32_t>(imageBegin, imageEnd);
}
This approach was just about workable for compiling and running a program as small as fib.c
, but having to recompile the VM to accommodate different programs is not a long term solution.
Running the binary image
To run the program in the binary image as RISC-V encoded instructions, the program copies the loaded image into VM memory then runs it using RunRv32i()
.
// Create a 4K memory image.
constexpr size_t memorySize = 4096;
std::vector<uint32_t> image(memorySize / sizeof(uint32_t));
auto rv32iImage = LoadRv32iImage();
std::ranges::copy(rv32iImage, image.begin());
std::cout << "Running RISC-V encoded instructions...\n";
RunRv32i(image);
To run the program in the binary image as Owl-2820 encoded instructions, the program transcodes the entire image from RISC-V encoding to Owl-2820 encoding, then copies the result into VM memory before running it with Run()
.
This is possible only because the image consists of instructions only. If the image also contained data then we’d need a way to tell the VM which parts of the image are code and which are data.
// Transcode it to Owl-2820.
auto owlImage = Rv32iToOwl(rv32iImage);
DisassembleOwl(owlImage);
std::ranges::copy(owlImage, image.begin());
std::cout << "\nRunning Owl-2820 encoded instructions...\n";
Run(image);
Summary
That was an overview of what it takes to be able to compile and load an image so that it can be run on the Owl VM. There are several shortcomings to this approach.
In particular:
- It doesn’t handle data.
- Section addresses have to be specified manually during compilation.
- The image has to be hard-coded into the VM in order to run it.
- Transcoding only works because it can assume that the entire image is code.
Programs with data
For the Owl-2820 VM to be of any use, it needs to be able to run programs that contain data.
To explore this further, we’ll move away from fib.c
to some simple programs that contain different types of data. We’ll also add more syscalls so that the Owl-2820 VM can do more than just printing Fibonacci numbers and exiting.
Before going into details, let’s have a brief introduction to the different types of data.
Types of data
There are essentially 3 types of data can appear in a RISC-V ELF binary that has been compiled from C source code.
- read-only data such as string literals
- initialized data, i.e., variables, both global and static, that have a value when the program starts
- uninitialized data, uninitialized variables, typically zeroed when the program starts
Data of each type is placed in its own section in the ELF binary.
Section Name | Description |
---|---|
.rodata |
read-only data, such as string literals |
.data and .sdata |
initialized data |
.bss and .sbss |
uninitialized data |
What is bss?
The names .rodata
and .data
are self-explanatory, but what is .bss
? How does that mean uninitialized data?
For the uninitiated, bss
stands for “Block Started by Symbol”. That’s almost as cryptic as bss
, so the reality is that it is nothing more than a historical convention that means uninitialized data.
A historical note on bss
While researching for this post, I found my old copy of The Magic Garden Explained, by Berny Goodheart and James Cox. This book, first published in 1994, explains the internal workings of UNIX System V Release 4. The book says that “bss came from the IBM 7090 assembly language and stood for ‘block started by symbol’”.
This clue led to a manual for the Fortran Assembly Program for the IBM 709/7090, first published in 1960, which describes the “storage-allocating pseudo-operation” BSS
as a way “to reserve blocks of memory for data storage or working space”.
In other words, the original BSS
was just an assembler directive that reserved a block of space. It even had a counterpart, BES
, which stood for “Block Ended by Symbol”.
If you’re curious to learn more then follow the link to the manual and look at the pages numbered 29-30. The entire manual is fascinating. If you thought that computers have always worked with bytes, or that ASCII was ubiquitous in the days before Unicode, then think again. You might even change your mind about the meaning of “operating system”.
Adding syscalls
To make it easier to demonstrate the different types of data, we’re going to add 3 new syscalls: randomize
, random
and puts
. Here’s how they’re implemented in the Owl-2820 VM.
random
The randomize
syscall sets the seed of the random number generator.
case Syscall::randomize:
std::srand(time(nullptr));
break;
random
The random
syscall returns a random number between 0 and the value passed to it in register a0.
case Syscall::random:
if (x[a0] != 0)
{
x[a0] = std::rand() % x[a0];
}
break;
puts
The puts
syscall prints a zero-terminated string, whose address is passed in register a0, to the host’s stdout.
case Syscall::puts:
// We really shouldn't do this without at least a bounds check. Who knows what horrors
// the caller is giving us.
std::puts(reinterpret_cast<const char*>(memory.data()) + x[a0]);
break;
Read-only data
A fortune program
We’ll demonstrate the use of read-only data by looking at a simple fortune
program that displays a random aphorism when it runs. To do this, it uses the newly added randomize
, random
and puts
syscalls.
In fib.c
, the syscall interfaces were part of fib.c
itself, but for fortune.c
I’ve moved them into a new header, syscalls.h
.
#include "syscalls.h"
#include <stddef.h>
#include <stdint.h>
static const char* aphorisms[] = {
"Absence makes the heart grow fonder.",
"A chain is only as strong as its weakest link.",
"Actions speak louder than words.",
"Better late than never.",
"Experience is the name everyone gives to their mistakes.",
"If it ain't broke, don't fix it.",
"If you lie down with dogs, you get up with fleas.",
"Ignorance is bliss.",
"Measure twice. Cut once.",
"The road to hell is paved with good intentions.",
};
int main()
{
randomize();
const size_t numAphorisms = sizeof(aphorisms) / sizeof(char*);
puts(aphorisms[random(numAphorisms)]);
return 0;
}
The fortune.c
program contains read-only data, in the form of the aphorisms
array and the strings that it points to. As we saw earlier, read-only data is placed in the .rodata
section of the ELF binary.
Section Name | Description |
---|---|
.rodata |
read-only data |
Compiling fortune
Let’s compile the program, then examine the resulting ELF file.
clang -Os --target=riscv32 -march=rv32i -mabi=ilp32 -ffreestanding -ffunction-sections \
-fdata-sections ./source/crt0.S ./source/fortune.c -nostdlib -nodefaultlibs \
"-Wl,--section-start=.init=0" \
"-Wl,--section-start=.text=100" \
"-Wl,--section-start=.rodata=800" \
"-g"
As before, the --section-start
linker option is used to place the .init
section at address 0x0000
, and the .text
section at address 0x0100
.
There’s an additional section, .rodata
, which I’ve placed at address 0x0800
to leave a gap between the end of the .text
section and the start of .rodata
so that we can add more code later without having to change the command line.
The -g
flag generates debug information in the ELF file. This will be useful when disassembling.
Viewing fortune’s section headers
We can run llvm-objdump -h
to view the section headers.
llvm-objdump -h a.out
The section headers show that the read-only data has indeed gone into the .rodata
section, and that its type is DATA
.
a.out: file format elf32-littleriscv
Sections:
Idx Name Size VMA Type
0 00000000 00000000
1 .init 0000001c 00000000 TEXT
2 .text 00000038 00000100 TEXT
3 .rodata 0000019e 00000800 DATA
4 .eh_frame 0000002c 000009a0 DATA
5 .riscv.attributes 0000001c 00000000
6 .comment 00000029 00000000
7 .symtab 00000100 00000000
8 .shstrtab 00000054 00000000
9 .strtab 00000052 00000000
Finding the data
We can run llvm-objcopy
to extract the sections of interest into a single binary image, a.bin
, this time including the .rodata
section.
llvm-objcopy a.out -O binary a.bin -j .init -j .text -j .rodata
Then we can use format-hex
or hd
to view the output as a hex dump.
The image
The startup code in the .init
section has been written to the image at offset 0x0000
.
00000000 37 81 00 00 13 05 00 00 93 05 00 00 13 06 00 00 7.............
00000010 EF 00 00 0F 93 08 00 00 73 00 00 00 00 00 00 00 ï......s.......
The program in the .text
section has been written to the image at offset 0x0100
.
00000100 93 08 30 00 73 00 00 00 93 08 40 00 13 05 A0 00 .0.s....@... .
00000110 73 00 00 00 13 15 25 00 B7 15 00 00 93 85 05 80 s.....%.·....
00000120 33 85 A5 00 03 25 05 00 93 08 20 00 73 00 00 00 3¥..%... .s...
00000130 13 05 00 00 67 80 00 00 00 00 00 00 00 00 00 00 ....g..........
The read-only data in the .rodata
section has been written to the image at offset 0x0800
.
00000800 D5 08 00 00 28 08 00 00 33 09 00 00 86 09 00 00 Õ...(...3......
00000810 FA 08 00 00 B4 08 00 00 54 09 00 00 57 08 00 00 ú...´...T...W...
00000820 6B 08 00 00 84 08 00 00 41 20 63 68 61 69 6E 20 k......A chain
00000830 69 73 20 6F 6E 6C 79 20 61 73 20 73 74 72 6F 6E is only as stron
00000840 67 20 61 73 20 69 74 73 20 77 65 61 6B 65 73 74 g as its weakest
00000850 20 6C 69 6E 6B 2E 00 49 67 6E 6F 72 61 6E 63 65 link..Ignorance
00000860 20 69 73 20 62 6C 69 73 73 2E 00 4D 65 61 73 75 is bliss..Measu
00000870 72 65 20 74 77 69 63 65 2E 20 43 75 74 20 6F 6E re twice. Cut on
00000880 63 65 2E 00 54 68 65 20 72 6F 61 64 20 74 6F 20 ce..The road to
00000890 68 65 6C 6C 20 69 73 20 70 61 76 65 64 20 77 69 hell is paved wi
000008A0 74 68 20 67 6F 6F 64 20 69 6E 74 65 6E 74 69 6F th good intentio
000008B0 6E 73 2E 00 49 66 20 69 74 20 61 69 6E 27 74 20 ns..If it ain't
000008C0 62 72 6F 6B 65 2C 20 64 6F 6E 27 74 20 66 69 78 broke, don't fix
000008D0 20 69 74 2E 00 41 62 73 65 6E 63 65 20 6D 61 6B it..Absence mak
000008E0 65 73 20 74 68 65 20 68 65 61 72 74 20 67 72 6F es the heart gro
000008F0 77 20 66 6F 6E 64 65 72 2E 00 45 78 70 65 72 69 w fonder..Experi
00000900 65 6E 63 65 20 69 73 20 74 68 65 20 6E 61 6D 65 ence is the name
00000910 20 65 76 65 72 79 6F 6E 65 20 67 69 76 65 73 20 everyone gives
00000920 74 6F 20 74 68 65 69 72 20 6D 69 73 74 61 6B 65 to their mistake
00000930 73 2E 00 41 63 74 69 6F 6E 73 20 73 70 65 61 6B s..Actions speak
00000940 20 6C 6F 75 64 65 72 20 74 68 61 6E 20 77 6F 72 louder than wor
00000950 64 73 2E 00 49 66 20 79 6F 75 20 6C 69 65 20 64 ds..If you lie d
00000960 6F 77 6E 20 77 69 74 68 20 64 6F 67 73 2C 20 79 own with dogs, y
00000970 6F 75 20 67 65 74 20 75 70 20 77 69 74 68 20 66 ou get up with f
00000980 6C 65 61 73 2E 00 42 65 74 74 65 72 20 6C 61 74 leas..Better lat
00000990 65 20 74 68 61 6E 20 6E 65 76 65 72 2E 00 e than never..
String literals
The strings start at offset 0x0828
and continue to the end of the output.
00000820 41 20 63 68 61 69 6E 20 A chain
00000830 69 73 20 6F 6E 6C 79 20 61 73 20 73 74 72 6F 6E is only as stron
00000840 67 20 61 73 20 69 74 73 20 77 65 61 6B 65 73 74 g as its weakest
00000850 20 6C 69 6E 6B 2E 00 49 67 6E 6F 72 61 6E 63 65 link..Ignorance
The aphorisms array
The 40 bytes that precede the first string are the contents of the aphorisms
array, which is an array of pointers to strings.
static const char* aphorisms[] = {
"Absence makes the heart grow fonder.",
// ...
There are 10 pointers in the aphorisms
array, each of which is 4 bytes in size as we’re compiling for a 32-bit target. Here’s how it looks in the image.
00000800 D5 08 00 00 28 08 00 00 33 09 00 00 86 09 00 00 Õ...(...3......
00000810 FA 08 00 00 B4 08 00 00 54 09 00 00 57 08 00 00 ú...´...T...W...
00000820 6B 08 00 00 84 08 00 00 k.......
If you squint closely at this, then you’ll see a bunch of little-endian addresses. For example, the first entry, at offset 0x800
contains D5 08 00 00
. When this is interpreted as a 32-bit little-endian number, it is 000008d5
, which is the offset in the image, a.bin
, of the string “Absence makes the heart grow fonder.
”
Another use for .rodata
There’s another use for .rodata
that may not be immediately apparent. Let’s change main()
to look like the following code, then recompile it.
int main()
{
randomize();
const size_t numAphorisms = sizeof(aphorisms) / sizeof(char*);
puts(aphorisms[random(numAphorisms)]);
switch (random(100))
{
case 0:
puts(aphorisms[0]);
break;
case 1:
puts(aphorisms[1]);
break;
case 2:
puts(aphorisms[2]);
break;
case 3:
puts(aphorisms[3]);
break;
case 4:
puts(aphorisms[4]);
break;
case 5:
puts(aphorisms[5]);
break;
case 6:
puts(aphorisms[6]);
break;
case 7:
puts(aphorisms[7]);
break;
case 8:
puts(aphorisms[8]);
break;
case 9:
puts(aphorisms[9]);
break;
default:
puts("Never trust your inputs.");
break;
}
return 0;
}
Viewing the section headers
This time, when we view the section headers with llvm-objdump
, we can see that the .rodata
section has grown by 65 bytes, to 0x1df
bytes.
Sections:
Idx Name Size VMA Type
0 00000000 00000000
1 .init 0000001c 00000000 TEXT
2 .text 0000013c 00000100 TEXT
3 .rodata 000001df 00000800 DATA
4 .eh_frame 0000002c 000009e0 DATA
5 .riscv.attributes 0000001c 00000000
6 .comment 00000029 00000000
7 .symtab 00000110 00000000
8 .shstrtab 00000054 00000000
9 .strtab 00000057 00000000
Some of that growth can be accounted for by the string literal used by the switch
statement’s default
case, “Never trust your inputs.
”, which occupies 25 bytes including its terminator.
But what are the remaining 40 bytes? And where do they come from?
Why did .rodata grow?
We can begin to find the answer by extracting a binary image with llvm-objcopy
, then looking at its hex dump starting from the contents of the .rodata
section at offset 0x0800
.
00000800 5C 01 00 00 D4 01 00 00 98 01 00 00 AC 01 00 00 \...Ô......¬...
00000810 70 01 00 00 E8 01 00 00 FC 01 00 00 C0 01 00 00 p...è...ü...À...
00000820 24 02 00 00 84 01 00 00 16 09 00 00 50 08 00 00 $..........P...
00000830 74 09 00 00 C7 09 00 00 3B 09 00 00 F5 08 00 00 t...Ç...;...õ...
00000840 95 09 00 00 7F 08 00 00 93 08 00 00 AC 08 00 00 .........¬...
00000850 41 20 63 68 61 69 6E 20 69 73 20 6F 6E 6C 79 20 A chain is only
00000860 61 73 20 73 74 72 6F 6E 67 20 61 73 20 69 74 73 as strong as its
00000870 20 77 65 61 6B 65 73 74 20 6C 69 6E 6B 2E 00 49 weakest link..I
00000880 67 6E 6F 72 61 6E 63 65 20 69 73 20 62 6C 69 73 gnorance is blis
00000890 73 2E 00 4D 65 61 73 75 72 65 20 74 77 69 63 65 s..Measure twice
000008A0 2E 20 43 75 74 20 6F 6E 63 65 2E 00 54 68 65 20 . Cut once..The
000008B0 72 6F 61 64 20 74 6F 20 68 65 6C 6C 20 69 73 20 road to hell is
000008C0 70 61 76 65 64 20 77 69 74 68 20 67 6F 6F 64 20 paved with good
000008D0 69 6E 74 65 6E 74 69 6F 6E 73 2E 00 4E 65 76 65 intentions..Neve
000008E0 72 20 74 72 75 73 74 20 79 6F 75 72 20 69 6E 70 r trust your inp
000008F0 75 74 73 2E 00 49 66 20 69 74 20 61 69 6E 27 74 uts..If it ain't
00000900 20 62 72 6F 6B 65 2C 20 64 6F 6E 27 74 20 66 69 broke, don't fi
00000910 78 20 69 74 2E 00 41 62 73 65 6E 63 65 20 6D 61 x it..Absence ma
00000920 6B 65 73 20 74 68 65 20 68 65 61 72 74 20 67 72 kes the heart gr
00000930 6F 77 20 66 6F 6E 64 65 72 2E 00 45 78 70 65 72 ow fonder..Exper
00000940 69 65 6E 63 65 20 69 73 20 74 68 65 20 6E 61 6D ience is the nam
00000950 65 20 65 76 65 72 79 6F 6E 65 20 67 69 76 65 73 e everyone gives
00000960 20 74 6F 20 74 68 65 69 72 20 6D 69 73 74 61 6B to their mistak
00000970 65 73 2E 00 41 63 74 69 6F 6E 73 20 73 70 65 61 es..Actions spea
00000980 6B 20 6C 6F 75 64 65 72 20 74 68 61 6E 20 77 6F k louder than wo
00000990 72 64 73 2E 00 49 66 20 79 6F 75 20 6C 69 65 20 rds..If you lie
000009A0 64 6F 77 6E 20 77 69 74 68 20 64 6F 67 73 2C 20 down with dogs,
000009B0 79 6F 75 20 67 65 74 20 75 70 20 77 69 74 68 20 you get up with
000009C0 66 6C 65 61 73 2E 00 42 65 74 74 65 72 20 6C 61 fleas..Better la
000009D0 74 65 20 74 68 61 6E 20 6E 65 76 65 72 2E 00 te than never..
The strings
The strings now start at offset 0x0850
.
00000850 41 20 63 68 61 69 6E 20 69 73 20 6F 6E 6C 79 20 A chain is only
00000860 61 73 20 73 74 72 6F 6E 67 20 61 73 20 69 74 73 as strong as its
00000870 20 77 65 61 6B 65 73 74 20 6C 69 6E 6B 2E 00 49 weakest link..I
The aphorisms array
The string are still preceded by contents of the aphorisms
array which is now at offset 0x0828
.
00000820 16 09 00 00 50 08 00 00 ...P...
00000830 74 09 00 00 C7 09 00 00 3B 09 00 00 F5 08 00 00 t...Ç...;...õ...
00000840 95 09 00 00 7F 08 00 00 93 08 00 00 AC 08 00 00 .........¬...
Some mystery data
We’ve accounted for the strings, and the aphorisms
array, but we still don’t know what the remaining read-only data is, namely, the 40 bytes which start at offset 0x0800
.
00000800 5C 01 00 00 D4 01 00 00 98 01 00 00 AC 01 00 00 \...Ô......¬...
00000810 70 01 00 00 E8 01 00 00 FC 01 00 00 C0 01 00 00 p...è...ü...À...
00000820 24 02 00 00 84 01 00 00 $.......
Decoding the mystery data
If we interpret the mystery data as 32-bit little-endian values, then we get 10 values, starting with 0x015c
, 0x01d4
, 0x0198
, and so on. These are clearly not values in the .rodata
section as that starts at 0x0800
.
However, when we compiled the program, we told the linker to put the .text
section at address 0x0100
. We just saw from the section headers that the .text
section’s size is 0x013c
bytes, so that means that the .text
section extends from 0x0100
up to 0x023c
.
The values in the mystery data are all in that range. Could they correspond to addresses in the .text
section? If so, code will we find at those addresses?
Could it be code?
Let’s disassemble the .text
section with llvm-objdump
using the -S
flag to show the source line, and the -l
flag to show the line numbers.
llvm-objdump -Sl --section=.text a.out
I’ve removed some of the output, as there was a lot of it. But here’s enough to get a flavour. You can see how the compiler has inlined the syscalls, and the comments helpfully tell us which syscalls are being invoked.
00000100 <main>:
; main():
; C:\projects\c++\owl-cpu\target\./source\syscalls.h:50
; __asm__ __volatile__(
100: 93 08 30 00 li a7, 0x3
104: 73 00 00 00 ecall
; C:\projects\c++\owl-cpu\target\./source\syscalls.h:64
; __asm__ __volatile__("ecall /* syscall(random) */" // assembler template: ecall (with a comment)
108: 93 08 40 00 li a7, 0x4
10c: 13 05 a0 00 li a0, 0xa
110: 73 00 00 00 ecall
; C:\projects\c++\owl-cpu\target\./source\fortune.c:23
; puts(aphorisms[random(numAphorisms)]);
114: 13 15 25 00 slli a0, a0, 0x2
118: b7 15 00 00 lui a1, 0x1
11c: 93 85 85 82 addi a1, a1, -0x7d8
120: 33 85 a5 00 add a0, a1, a0
124: 03 25 05 00 lw a0, 0x0(a0)
128: 93 05 a0 00 li a1, 0xa
; C:\projects\c++\owl-cpu\target\./source\syscalls.h:38
; __asm__ __volatile__("ecall /* syscall(puts) */" // assembler template: ecall (with a comment)
12c: 93 08 20 00 li a7, 0x2
130: 73 00 00 00 ecall
; C:\projects\c++\owl-cpu\target\./source\syscalls.h:64
; __asm__ __volatile__("ecall /* syscall(random) */" // assembler template: ecall (with a comment)
134: 93 08 40 00 li a7, 0x4
138: 13 05 40 06 li a0, 0x64
13c: 73 00 00 00 ecall
; C:\projects\c++\owl-cpu\target\./source\fortune.c:25
; switch (random(100))
140: 63 78 b5 0c bgeu a0, a1, 0x210 <main+0x110>
144: 13 15 25 00 slli a0, a0, 0x2
148: b7 15 00 00 lui a1, 0x1
14c: 93 85 05 80 addi a1, a1, -0x800
150: 33 05 b5 00 add a0, a0, a1
154: 03 25 05 00 lw a0, 0x0(a0)
158: 67 00 05 00 jr a0
; C:\projects\c++\owl-cpu\target\./source\syscalls.h:38
; __asm__ __volatile__("ecall /* syscall(puts) */" // assembler template: ecall (with a comment)
15c: 37 15 00 00 lui a0, 0x1
160: 13 05 65 91 addi a0, a0, -0x6ea
164: 93 08 20 00 li a7, 0x2
168: 73 00 00 00 ecall
16c: 6f 00 80 0c j 0x234 <main+0x134>
; C:\projects\c++\owl-cpu\target\./source\syscalls.h:38
; __asm__ __volatile__("ecall /* syscall(puts) */" // assembler template: ecall (with a comment)
170: 37 15 00 00 lui a0, 0x1
174: 13 05 b5 93 addi a0, a0, -0x6c5
178: 93 08 20 00 li a7, 0x2
17c: 73 00 00 00 ecall
180: 6f 00 40 0b j 0x234 <main+0x134>
;
; --- snip ---
;
; C:\projects\c++\owl-cpu\target\./source\syscalls.h:38
; __asm__ __volatile__("ecall /* syscall(puts) */" // assembler template: ecall (with a comment)
224: 37 15 00 00 lui a0, 0x1
228: 13 05 35 89 addi a0, a0, -0x76d
22c: 93 08 20 00 li a7, 0x2
230: 73 00 00 00 ecall
; C:\projects\c++\owl-cpu\target\./source\fortune.c:62
; return 0;
234: 13 05 00 00 li a0, 0x0
238: 67 80 00 00 ret
If you look carefully then you’ll see some of the assembly language is preceded by comments that correspond with lines of source code, such as…
; puts(aphorisms[random(numAphorisms)]);
114: 13 15 25 00 slli a0, a0, 0x2
118: b7 15 00 00 lui a1, 0x1
; --- snip ---
And…
; switch (random(100))
140: 63 78 b5 0c bgeu a0, a1, 0x210 <main+0x110>
144: 13 15 25 00 slli a0, a0, 0x2
; --- snip ---
You might find yourself wondering what happened to the case
statements, as there’s no obvious evidence of them in the output. However, they’re definitely present. Cast your mind back to those offsets that we found in the mystery data; 0x015c
, 0x01d4
, 0x0198
and so on.
Here’s the code corresponding to offset 0x015c
. It’s an invocation of the puts
syscall, using a combination of lui
and addi
to load the value 0x0916
into register a0.
; C:\projects\c++\owl-cpu\target\./source\syscalls.h:38
; __asm__ __volatile__("ecall /* syscall(puts) */" // assembler template: ecall (with a comment)
15c: 37 15 00 00 lui a0, 0x1
160: 13 05 65 91 addi a0, a0, -0x6ea
164: 93 08 20 00 li a7, 0x2
168: 73 00 00 00 ecall
16c: 6f 00 80 0c j 0x234 <main+0x134>
Offset 0x0916
in a.bin
is a value in the .rodata
section. What do we find there? The string, “Absence makes the heart grow fonder.
”
00000910 78 20 69 74 2E 00 41 62 73 65 6E 63 65 20 6D 61 x it..Absence ma
00000920 6B 65 73 20 74 68 65 20 68 65 61 72 74 20 67 72 kes the heart gr
00000930 6F 77 20 66 6F 6E 64 65 72 2E 00 45 78 70 65 72 ow fonder..Exper
The instructions at offset 0x015c
correspond to the first case
statement.
case 0:
puts(aphorisms[0]);
break;
Similarly, here’s the code corresponding to offset 0x1d4
. It’s another puts
, this time loading the value 0x850
into register a0.
; C:\projects\c++\owl-cpu\target\./source\syscalls.h:38
; __asm__ __volatile__("ecall /* syscall(puts) */" // assembler template: ecall (with a comment)
1d4: 37 15 00 00 lui a0, 0x1
1d8: 13 05 05 85 addi a0, a0, -0x7b0
1dc: 93 08 20 00 li a7, 0x2
1e0: 73 00 00 00 ecall
1e4: 6f 00 00 05 j 0x234 <main+0x134>
The value 0x0850
is an offset in the image to the string, “A chain is only as strong as its weakest link.
”
00000850 41 20 63 68 61 69 6E 20 69 73 20 6F 6E 6C 79 20 A chain is only
00000860 61 73 20 73 74 72 6F 6E 67 20 61 73 20 69 74 73 as strong as its
00000870 20 77 65 61 6B 65 73 74 20 6C 69 6E 6B 2E 00 49 weakest link..I
The instructions at offset 0x1d4
correspond to the second case
statement.
case 1:
puts(aphorisms[1]);
break;
If we were to disassemble the image at each of the offsets in the mystery data, then we’d find that each one corresponds to a different case
in the switch
statement. The mystery data is nothing more than a jump table for the switch
statement.
How the switch statement selects each case
The code for the switch
statement itself starts at 0x134
. At the start of this block, register a1 has already been set to the number 10, which is the number of cases in the switch
statement.
The code starting at address 0x134
calls the random(100)
from switch (random(100))
.
; C:\projects\c++\owl-cpu\target\./source\syscalls.h:64
; __asm__ __volatile__("ecall /* syscall(random) */" // assembler template: ecall (with a comment)
134: 93 08 40 00 li a7, 0x4 ; random is syscall 4
138: 13 05 40 06 li a0, 0x64 ; random(100)
13c: 73 00 00 00 ecall ; a0 = random(100)
The code at address 0x140
compares the value returned from random(100)
to the value in register a1. This was set to 10, so what we’re seeing here is a bounds check that jumps to the default
case for values that are 10 or greater.
; C:\projects\c++\owl-cpu\target\./source\fortune.c:25
; switch (random(100))
140: 63 78 b5 0c bgeu a0, a1, 0x210 <main+0x110> ; branch to the default case if a0 >= 10
Having performed the bounds check, determining which case to invoke based on the value in a0 returned from random(100)
is simply a matter of using it to compute an index into the jump table starting at address 0x800
in .rodata
, then performing an indirect jump to the address stored at that index.
144: 13 15 25 00 slli a0, a0, 0x2 ; multiply by 4 to get an index into
; the jump table
148: b7 15 00 00 lui a1, 0x1 ; a1 = address of jump table
14c: 93 85 05 80 addi a1, a1, -0x800
150: 33 05 b5 00 add a0, a0, a1 ; a0 = index + address of jump table
154: 03 25 05 00 lw a0, 0x0(a0) ; a0 = *a0
158: 67 00 05 00 jr a0 ; jump to the value in a0
In short, what has happened here is that the compiler has determined that the cases in the switch
statement are contiguous, and has output a jump table in the .rodata
section, where each address in the table corresponds to the compiled code for one of the cases in the switch
statement.
If you’re familiar with C++, then this jump table in the .rodata
section is similar to a vtable, but in this case the CPU is performing an indirect jump rather than an indirect call.
Summary
In this post we reviewed how to compile fib.c
and load it as an image to run on the Owl-2820 VM, and identified several shortcomings with the approach.
One of the more obvious problems was that the approach didn’t cater for data. When we looked into this, we discovered that an ELF binary caters for data by placing it into different sections according to how it is used:
- read-only data, such as string literals goes into the
.rodata
section - initialized data, i.e., variables that have a value when the program starts, goes into the
.data
section - uninitialized data, goes into the enigmatically named
.bss
section
We introduced some new system calls, and used them to write a new program, fortune.c
, which we used as a basis for examining read-only data. We learned that the read-only data section .rodata
, is used both for program data such as string literals, and for data inserted by the compiler, such as a jump table used for dispatching a switch
statement.
Loading ELF binaries
In the next post, we’ll look at initialized data in .data
and at uninitialized data in .bss
. Following that, over the course of the next few posts, we’ll gradually build things up so that our Owl-2820 VM can load and run ELF binaries.