9.7. Arrays
Recall that arrays are
ordered collections of data elements of the same type that are contiguously
stored in memory. Statically allocated
single-dimension arrays have
the form Type arr[N], where Type is the data type, arr is the identifier
associated with the array, and N is the number of data elements. Declaring an
array statically as Type arr[N] or dynamically as arr =
malloc(N*sizeof(Type)) allocates N × sizeof(Type) total bytes of memory.
To access the element at index i in array arr, use the syntax arr[i].
Compilers commonly convert array references into
pointer arithmetic prior to
translating to assembly. So ,arr+i is equivalent to &arr[i], and *(arr+i)
is equivalent to arr[i]. Since each data element in arr is of type Type,
arr+i implies that element i is stored at address arr + sizeof(Type) * i.
Table 1 outlines some common array operations and their corresponding
assembly instructions. In the examples that follow, suppose we declare an int
array of length 10 (e.g., int arr[10]). Assume that register x1 stores the
address of arr, register x2 stores the int value i, and register x0
represents some variable x (also of type int). Recall that int variables
take up four bytes of space, whereas int * variables take up eight bytes.
| Operation | Type | Assembly Representation |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Notice that to access element arr[5] (or *(arr+5) using pointer arithmetic), the
compiler performs a memory lookup on address arr+5*4 instead of arr+5. To understand
why this is necessary, recall that any element at index i in an array is stored at address
arr + sizeof(Type) * i. The compiler must therefore multiply the index by the size of the
data type (in this case 4, since sizeof(int) = 4) to compute the correct offset. Recall also
that memory is byte-addressable; offsetting by the correct number of bytes is the same as
computing an address.
As an example, consider a sample array (array) with 10 integer elements ([FigArray6]).
Notice that since array is an array of integers, each element takes up exactly four bytes. Thus, an
integer array with 10 elements consumes 40 bytes of contiguous memory.
To compute the address of element 3, the compiler multiplies the index 3 by the data size of the integer type (4) to yield an offset of 12 (or 0xc). Sure enough, element 3 in Figure 1 is located at byte offset a12.
Let’s take a look at a simple C function called sumArray that sums up all the elements
in an array:
int sumArray(int *array, int length) {
int i, total = 0;
for (i = 0; i < length; i++) {
total += array[i];
}
return total;
}
The sumArray function takes the address of an array and the array’s associated length
and sums up all the elements in the array. Now take a look at the corresponding assembly
of the sumArray function:
Dump of assembler code for function sumArray: 0x874 <+0>: sub sp, sp, #0x20 // grow stack by 32 bytes (new frame) 0x878 <+4>: str x0, [sp, #8] // store x0 at sp + 8 (array address) 0x87c <+8>: str w1, [sp, #4] // store w1 at sp + 4 (length) 0x880 <+12>: str wzr, [sp, #28] // store 0 at sp + 28 (total) 0x884 <+16>: str wzr, [sp, #24] // store 0 at sp + 24 (i) 0x888 <+20>: b 0x8b8 <sumArray+68> // goto <sumArray+68> 0x88c <+24>: ldrsw x0, [sp, #24] // x0 = i 0x890 <+28>: lsl x0, x0, #2 // left shift i by 2 (i << 2, or i*4) 0x894 <+32>: ldr x1, [sp, #8] // x1 = array 0x898 <+36>: add x0, x1, x0 // x0 = array + i*4 0x89c <+40>: ldr w0, [x0] // w0 = array[i] 0x8a0 <+44>: ldr w1, [sp, #28] // w1 = total 0x8a4 <+48>: add w0, w1, w0 // w0 = total + array[i] 0x8a8 <+52>: str w0, [sp, #28] // store (total + array[i]) in total 0x8ac <+56>: ldr w0, [sp, #24] // w0 = i 0x8b0 <+60>: add w0, w0, #0x1 // w0 = w0 + 1 (i+1) 0x8b4 <+64>: str w0, [sp, #24] // store (i + 1) in i (i.e. i+=1) 0x8b8 <+68>: ldr w1, [sp, #24] // w1 = i 0x8bc <+72>: ldr w0, [sp, #4] // w0 = length 0x8c0 <+76>: cmp w1, w0 // compare i and length 0x8c4 <+80>: b.lt 0x88c <sumArray+24> // if (i < length) goto <sumArray+24> 0x8c8 <+84>: ldr w0, [sp, #28] // w0 = total 0x8cc <+88>: add sp, sp, #0x20 // revert stack to original state 0x8d0 <+92>: ret // return (total)
When tracing this assembly code, consider whether the data being accessed
represents a pointer or a value. For example, the instruction at
<sumArray+12> results in stack location sp + 28 containing a variable of
type int, which is initially set to 0. In contrast, the argument stored at
location sp + 8 is the first argument to the function (array), which is of
type int * and corresponds to the base address of the array. A different
variable (which we call i) is stored at location sp + 24 and is initially
set to 0.
The astute reader will notice a previously unseen instruction at line
<sumArray+30> called ldrsw. The ldrsw instruction stands for "load
register signed word" and converts the 32-bit int value stored at sp + 24
to a 64-bit integer value and stores it in x0. This operation is necessary
because the instructions that follow perform pointer arithmetic. Recall that on
64-bit systems, pointers take up eight bytes of space. The compiler’s use of
ldrsw simplifies the process by ensuring that all data are stored in full
64-bit registers instead of their 32-bit components.
Let’s take a closer look at the seven instructions between locations <sumArray+28> and <sumArray+52>:
0x890 <+28>: lsl x0, x0, #2 // left shift i by 2 (i << 2, or i*4) 0x894 <+32>: ldr x1, [sp, #8] // x1 = array 0x898 <+36>: add x0, x1, x0 // x0 = array + i*4 0x89c <+40>: ldr w0, [x0] // w0 = array[i] 0x8a0 <+44>: ldr w1, [sp, #28] // w1 = total 0x8a4 <+48>: add w0, w1, w0 // w0 = total + array[i] 0x8a8 <+52>: str w0, [sp, #28] // store (total + array[i]) in total
The compiler uses lsl to perform a left shift on the value i stored in
x0. When this instruction completes execution, register x0 contains i <<
2, or i * 4. At this point, x0 contains the number of bytes to calculate
the correct offset of array[i] (or sizeof(int) = 4).
The next instruction (ldr x1, [sp, #8]) loads the first argument to the
function (i.e., the base address of array) into register x1. Adding x1 to
x0 in the next instruction causes x0 to contain array + i * 4. Recall that
the element at index i in array is stored at address array + sizeof(T) *
i Therefore, x0 now contains the assembly-level computation of address
&array[i].
The instruction at <sumArray+40> dereferences the value located at x0,
placing the value array[i] into w1. Notice the use of the component
register w1, because array[i] contains a 32-bit int value! In contrast, the
variable i was changed to a 64-bit integer on line <sumArray+24> since i
was about to be used for address computation. Again, addresses (pointers)
are stored as 64-bit words.
The last three instructions between <sumArray+44> and <sumArray+52> load
the current value of total into component register w1, add array[i] to
it, and store the result in component register w0, before updating total at
location sp + 28 with the new sum. Therefore, the seven instructions between
<sumArray+28> and <sumArray+52> are equivalent to the line total +=
array[i] in the sumArray function.