1

I am trying to learn assembly and I’m having some trouble understanding memory allocation/retrieval on the stack.

When strings are allocated on the stack, the program knows to stop reading the string when it reaches a null terminating character /x00. With numbers however, there is no such thing. How does the program know the end of a number allocated on the stack, and how does it differentiate between different number types (short, long, int)? (I’m a bit new to this so please correct me on anything I may be misunderstanding!)

Peter Cordes
  • 328,167
  • 45
  • 605
  • 847
Maslin
  • 139
  • 6
  • 2
    Every number type has a fixed size. The type declaration is used to know how much space to allocate for it and which instructions to use to read/write it. – Barmar Aug 31 '21 at 18:30
  • 3
    The processor does not know and neither does the stack. It's up to the programmer to keep track of this. – fuz Aug 31 '21 at 18:35
  • 1
    This has nothing to do with stack memory specifically; allocating / deallocating space there has a different mechanism than malloc/free or mmap/munmap, or static storage in the BSS / .data / .rodata, but is still just a chunk of bytes as part of a stack frame. – Peter Cordes Aug 31 '21 at 18:53

3 Answers3

5

Type (int vs. float vs. char * vs. struct foo) only really matters during translation, when the compiler is analyzing your source code and converting it to the appropriate machine code. That's when rules like "one of the operands of [] shall have pointer type and the other shall have integer type" and "the operand of unary * shall have pointer type" and "the operands of multiplicative operators shall have arithmetic type", etc., are enforced.

Assembly languages typically deal with bytes, words (2 bytes), longwords (4 bytes), etc., although some special-purpose platforms may have weird word sizes. The opcode addb1 adds the contents of two byte-sized entities, addl adds the contents of two longword-sized entities, etc. So when the compiler is translating your code, it uses the right opcodes for the object based on its declared type. So if you declare something as a short, the compiler will (typically) use opcodes intended for word-sized objects (addw, movw, etc.). If you declare something as int or long, it will (typically) use opcodes intended for longword-sized objects (addl, movl). Floating-point types are often handled with a different set of opcodes and their own set of registers.

In short, the assembly language "knows" where and how big things are by virtue of the opcodes the compiler specified.

Simple example - here's some C source code that works with an int and a short:

#include <stdio.h>

int main( void )
{
  int x;
  short y;

  printf( "Gimme an x: " );
  scanf( "%d", &x );

  y = 2 * x + 30;

  printf( "x = %d, y = %hd\n", x, y );
  return 0;
}

I used the -Wa,-aldh option with gcc to generate a listing of the assembly code with the source code interleaved, giving me

GAS LISTING /tmp/cc3D25hf.s             page 1


   1                    .file   "simple.c"
   2                    .text
   3                .Ltext0:
   4                    .section    .rodata
   5                .LC0:
   6 0000 47696D6D      .string "Gimme an x: "
   6      6520616E 
   6      20783A20 
   6      00
   7                .LC1:
   8 000d 256400        .string "%d"
   9                .LC2:
  10 0010 78203D20      .string "x = %d, y = %hd\n"
  10      25642C20 
  10      79203D20 
  10      2568640A 
  10      00
  11                    .text
  12                    .globl  main
  14                main:
  15                .LFB0:
  16                    .file 1 "simple.c"
   1:simple.c      **** #include <stdio.h>
   2:simple.c      **** 
   3:simple.c      **** int main( void )
   4:simple.c      **** {
  17                    .loc 1 4 0
  18                    .cfi_startproc
  19 0000 55            pushq   %rbp
  20                    .cfi_def_cfa_offset 16
  21                    .cfi_offset 6, -16
  22 0001 4889E5        movq    %rsp, %rbp
  23                    .cfi_def_cfa_register 6
  24 0004 4883EC10      subq    $16, %rsp
   5:simple.c      ****   int x;
   6:simple.c      ****   short y;
   7:simple.c      **** 
   8:simple.c      ****   printf( "Gimme an x: " );
  25                    .loc 1 8 0
  26 0008 BF000000      movl    $.LC0, %edi
  26      00
  27 000d B8000000      movl    $0, %eax
  27      00
  28 0012 E8000000      call    printf
  28      00
   9:simple.c      ****   scanf( "%d", &x );
  29                    .loc 1 9 0
  30 0017 488D45F8      leaq    -8(%rbp), %rax
  31 001b 4889C6        movq    %rax, %rsi
  32 001e BF000000      movl    $.LC1, %edi
  32      00
  33 0023 B8000000      movl    $0, %eax
  33      00
  34 0028 E8000000      call    __isoc99_scanf
  34      00
  10:simple.c      **** 
  11:simple.c      ****   y = 2 * x + 30;

GAS LISTING /tmp/cc3D25hf.s             page 2


  35                    .loc 1 11 0
  36 002d 8B45F8        movl    -8(%rbp), %eax
  37 0030 83C00F        addl    $15, %eax
  38 0033 01C0          addl    %eax, %eax
  39 0035 668945FE      movw    %ax, -2(%rbp)
  12:simple.c      **** 
  13:simple.c      ****   printf( "x = %d, y = %hd\n", x, y );
  40                    .loc 1 13 0
  41 0039 0FBF55FE      movswl  -2(%rbp), %edx
  42 003d 8B45F8        movl    -8(%rbp), %eax
  43 0040 89C6          movl    %eax, %esi
  44 0042 BF000000      movl    $.LC2, %edi
  44      00
  45 0047 B8000000      movl    $0, %eax
  45      00
  46 004c E8000000      call    printf
  46      00
  14:simple.c      ****   return 0;
  47                    .loc 1 14 0
  48 0051 B8000000      movl    $0, %eax
  48      00
  15:simple.c      **** }
  49                    .loc 1 15 0
  50 0056 C9            leave
  51                    .cfi_def_cfa 7, 8
  52 0057 C3            ret
  53                    .cfi_endproc
  54                .LFE0:
  56                .Letext0:
  57                    .file 2 "/usr/lib/gcc/x86_64-redhat-linux/7/include/stddef.h"
  58                    .file 3 "/usr/include/bits/types.h"
  59                    .file 4 "/usr/include/libio.h"
  60                    .file 5 "/usr/include/stdio.h"

If you look at the lines

  36 002d 8B45F8        movl    -8(%rbp), %eax
  37 0030 83C00F        addl    $15, %eax
  38 0033 01C0          addl    %eax, %eax
  39 0035 668945FE      movw    %ax, -2(%rbp)

that's the machine code for

y = 2 * x + 30;

When it's dealing with x, it uses opcodes for longwords:

movl    -8(%rbp), %eax ;; copy the value in x to the eax register
addl    $15, %eax      ;; add the literal value 15 to the value in eax
addl    %eax, %eax     ;; multiply the value in eax by 2

When it's dealing with y, it uses opcodes for words:

movw    %ax, -2(%rbp)  ;; save the value in the lower 2 bytes of eax to y

So that's how it "knows" how many bytes to read for a given object - all that information is baked into the machine code itself. Scalar types all have fixed, known sizes, so it's just a matter of picking the correct opcode or opcodes to use.


  1. I'm using Intel-specific mnemonics, but the concept is the same for other assemblers.
John Bode
  • 119,563
  • 19
  • 122
  • 198
4

TL;DR  The programmer knows what they want to do and expresses that in the programming language, in terms of variables and expressions and/or statements.  The machine code generated by the compiler tells the processor what to do in order to execute that program.  The programmer informs the program (by writing the program); the program informs the compiler, which informs the machine code (by translating the program), which informs the processor during its dynamic execution of the machine code.

High level programming languages operate in terms of variables, and expressions or statements that manipulate the variables. Variables are a logical construct — variables are commonly created and destroyed as the program executes.  Processors, however, operate in terms of machine code instructions operating over storage.  Storage is a physical construct — storage is simply always there, it isn't created or destroyed1, but rather reused or repurposed.

Further, processors do not read variable declarations as such — there is no real equivalent in machine code to variable declarations.  Because processors only read machine code instructions, each and every machine code instruction has to tell the processor everything it needs to know about the storage being used for variables via machine code instructions.  With minimal error checking, the processor simply trusts that the machine code program does something useful with the storage.

The programming language compiler's job is to map logical variable declarations into physical storage reservations and to translate expression and statements into machine code instructions that manipulate that physical storage holding the program's variables.

The compiler reads and remembers all the variable declarations, so when a line of program code manipulates a variable, the compiler knows both what physical storage to use (by consulting its logical to physical map) as well as what type or size to use for that machine code translation.  For assignment operations, usually only the size is really needed, but for other arithmetic operations, other properties are also needed, such as signed-ness (signed vs. unsigned) or numeric type (integer vs. floating point).

The compiler understands the program because it was written with a well-defined language specification, and so it can read variable declarations and code statements and translate them into machine code.  The machine code generated by a bug-free compiler consistently uses the physical storage for a given variable in the proper way given the program's variable declarations and by the definition of the rules of the language.  This means the compiler will translate using machine code instructions that manipulate the physical storage the way that the programmer intended in writing that program.

So, if the programmer specifies variable as being 2 byte wide, the compiler will identify (at least) 2 bytes of physical storage with appropriate duration for the lifetime of that variable, and whenever the program manipulates this variable, the compiler will generate machine code that accesses the physical storage it identified with the proper size and other attributes.

The stack is simply an area of memory whose usage corresponds almost directly 1:1 to function calling and the call chain or call stack.  As such it is a great place to put variables whose lifetime is limited to function duration.  Because the storage for a function is released upon its return, that physical storage is subject to reuse by another, newly invoked function.

Operations on stack memory are (mostly) done using the same machine code instructions as for all other kinds of memory — these machine code instructions inform the processor of size & type as needed — just that the stack is known to software as being repurposed by function invocation and released by function return.  The processor knows very little otherwise about the stack.

(Some processors have dedicated push & pop instructions that target memory referred to by the stack pointer register, that can make doing these operations more efficient than perhaps otherwise).


1(Modulo virtual memory but that is another matter; the actual RAM and CPU registers in the computer both pre-&post-exist the program without changing in physical amount).

Erik Eidt
  • 23,049
  • 2
  • 29
  • 53
3

How does the program know the end of a number allocated on the stack, and how does it differentiate between different number types (short, long, int)?

It doesn't know in any absolute sense. That is, it cannot determine the type of anything residing on the stack (or elsewhere in memory) by looking at the bytes there. Instead, the program has to know what data types to expect at what locations, and to execute instructions appropriate for those types. Under some circumstances, it might even interpret the same data according to different data types.

John Bollinger
  • 160,171
  • 8
  • 81
  • 157
  • that’s what I’m asking - how does the program know when to stop reading the bytes? How does it know to read 8 bytes from the stack for a long, and one byte for a char, etc.? – Maslin Aug 31 '21 at 19:26
  • 1
    @Maslin: The representations of types for a given platform are built in to the compiler - some compiler developer coded "long = 8 bytes, char = 1 byte", etc. So when you write `long l;` the compiler records the fact that `l` is of type `long` and allocates 8 bytes on the stack for it, and when you then write `l=17;` it therefore knows to emit an 8-byte store instruction. – Nate Eldredge Aug 31 '21 at 19:43
  • @Maslin: Have you looked at the asm output from a C compiler? (like on https://godbolt.org/ and see [How to remove "noise" from GCC/clang assembly output?](https://stackoverflow.com/q/38552116)). It uses instructions with fixed sizes that depend on what C types you used, so the operand-size is embedded into the machine code. Data is just a bag of bytes you can access, the compiler creates an asm version of the program that accesses it in useful ways. edit: Or see John Bode's answer on this question! – Peter Cordes Aug 31 '21 at 19:50
  • 1
    @Maslin, the program "knows" because the compiler inserts the machine instructions to read a value or values of the appropriate width from the right location. The access sizes are built directly into the program at the point of each access. There are no data types at this level, just machine instructions and their arguments, which together indicate very directly how to shift data around and manipulate it. – John Bollinger Aug 31 '21 at 20:31