Basic types such as int, float and char only describe single values. In the real world, though, an entity usually consists of several attributes together. A student, for instance, has a name (a string), an age (an integer) and a grade (a floating point number). To group related pieces of information into one compound, user-defined type, C gives us the struct keyword.

1. What is a struct?

A struct lets you group several variables of different types into a single new type. The component variables inside it are called the struct's members.

For example, defining a structure that represents a student:

student_struct.c
#include <stdio.h>
#include <string.h>

// Dinh nghia struct Student
struct Student {
    char name[50];
    int age;
    double score;
};

int main() {
    // Khai bao va khoi tao bien kieu struct Student
    struct Student sv1;

    // Gan gia tri cho cac thanh vien bang toan tu cham (.)
    strcpy(sv1.name, "Nguyen Van A");
    sv1.age = 20;
    sv1.score = 8.5;

    // Khoi tao nhanh luc khai bao
    struct Student sv2 = {"Tran Thi B", 19, 9.2};

    // Truy xuat va in du lieu
    printf("Sinh vien 1: %s, %d tuoi, %.1f diem\n", sv1.name, sv1.age, sv1.score);
    printf("Sinh vien 2: %s, %d tuoi, %.1f diem\n", sv2.name, sv2.age, sv2.score);

    return 0;
}

Note the semicolon after the closing brace of the struct β€” this is one of the few places in C where it is mandatory, and forgetting it produces a baffling error message on the following line rather than on the offending one. Members are accessed with a dot: sv1.name, sv1.age.

2. How it really sits in RAM: structure padding and alignment

Although a struct groups several variables, the compiler does not lay them out in RAM byte after byte. So that the CPU can fetch data from RAM at full speed β€” typically reading along address-bus lines divisible by 4 or 8 bytes, known as word lines β€” the compiler applies data alignment rules.

Those rules automatically insert empty bytes, called padding bytes, between the members of a struct.

A. Memory wasted by padding

Consider the following struct:

struct BadStruct {
    char a;   // 1 byte
    int  b;   // 4 bytes
    char c;   // 1 byte
};

Although the real total of the variables is \(1 + 4 + 1 = 6\) bytes, sizeof(struct BadStruct) returns 12 bytes. Here is the RAM layout the compiler actually produces for it:

[a (1B)] [padding (3B)] [   b (4B)   ] [c (1B)] [padding (3B)] -> Total: 12 Bytes

B. Optimising by reordering fields

To use memory efficiently, simply declare the larger variables first and the smaller ones afterwards:

struct GoodStruct {
    int  b;   // 4 bytes
    char a;   // 1 byte
    char c;   // 1 byte
};

Now the layout packs the small variables together onto one word line:

[   b (4B)   ] [a (1B)] [c (1B)] [padding (2B)] -> Total: 8 Bytes (33% less RAM)

C. Using #pragma pack(1) to remove padding entirely

In embedded systems, or when writing network protocol packets, you need the struct size to be exact to the byte so the data can be sent and received directly. C provides a preprocessor directive that forces the compiler not to insert padding:

#pragma pack(push, 1)   // Force 1-byte alignment (removes padding)
struct PackedStruct {
    char a;   // 1 byte
    int  b;   // 4 bytes
    char c;   // 1 byte
};
#pragma pack(pop)       // Restore the default alignment

Now sizeof(struct PackedStruct) returns exactly the real 6 bytes. The upside is maximum RAM savings; the downside is that the CPU reads and writes this struct slightly more slowly, because the addresses are no longer aligned.

D. Bit-fields

When you need boolean variables or integers with a very small range (say 0 to 7, which is 3 bits), spending a 32-bit int on each is wasteful. C lets you specify the maximum number of bits for each member:

struct StatusFlags {
    unsigned int isOnline : 1;  // Takes exactly 1 bit (0 or 1)
    unsigned int role     : 3;  // Takes exactly 3 bits (values 0 to 7)
    unsigned int status   : 4;  // Takes exactly 4 bits
};  // The whole struct is packed into exactly 1 byte (8 bits)!

3. Using typedef for cleaner code

Declaring a struct variable in the example above always required writing struct Student sv1; in full, which makes the source verbose.

The typedef (type definition) keyword defines an alias for an existing type, letting you write more concise code.

typedef_example.c
#include <stdio.h>

// typedef ket hop struct giup tao ra ten kieu du lieu moi gon sach
typedef struct {
    int x;
    int y;
} Point;

int main() {
    // Khong can ghi chu "struct Point p1;"
    Point p1 = {10, 20};
    Point p2 = {30, 40};

    printf("Toa do diem 1: (%d, %d)\n", p1.x, p1.y);
    printf("Toa do diem 2: (%d, %d)\n", p2.x, p2.y);

    return 0;
}

The difference shows up at the declaration: without typedef every declaration must spell out struct Point p;, whereas with it you write just Point p;. For a type you use hundreds of times across a project that saving adds up β€” and more importantly, the code reads closer to a modern language.

4. Structs with pointers and functions

The three sections above all worked with a struct right where it was declared. In real code, though, a struct is almost always passed into a function β€” and this is where the pointer lesson from Lesson 5 combines with structs to produce something you will meet in every C project.

Recall from Lesson 5: C only has pass by value, so passing a struct into a function copies the whole thing. For a struct holding a 50-character array, every call copies more than 50 bytes β€” and any change made inside the function lands on the copy and then disappears.

struct_pointer.c
#include <stdio.h>

typedef struct {
    char  name[50];
    int   age;
    float gpa;
} Student;

// Takes a COPY: changes here never reach the caller
void birthday_by_value(Student s) {
    s.age++;
}

// Takes the ADDRESS: -> reaches through the pointer to the real struct
void birthday_by_pointer(Student *s) {
    s->age++;          // identical to (*s).age++, just far easier to read
}

// const says: I will read this struct, never modify it
void print_student(const Student *s) {
    printf("%-6s age=%d gpa=%.2f\n", s->name, s->age, s->gpa);
}

int main() {
    Student a = {"An", 20, 3.6f};

    birthday_by_value(a);
    printf("after by-value:   age = %d\n", a.age);

    birthday_by_pointer(&a);
    printf("after by-pointer: age = %d\n", a.age);

    Student students[3] = {{"An", 20, 3.6f}, {"Binh", 21, 3.1f}, {"Chi", 19, 3.9f}};
    for (int i = 0; i < 3; i++) {
        print_student(&students[i]);
    }
    return 0;
}

Run it, and the first two lines tell the whole story:

What it actually prints
after by-value:   age = 20
after by-pointer: age = 21
An     age=20 gpa=3.60
Binh   age=21 gpa=3.10
Chi    age=19 gpa=3.90

The same age++, yet only the pointer version changes the real age. Three things worth taking from that code:

  • The -> operator is how you reach a member through a pointer. It is just shorthand for (*s).age β€” the parentheses are required, because the dot binds more tightly than *. Writing (*s).age everywhere is unbearable, so C provides a dedicated operator, and in real code you will see -> far more often than the dot.
  • const Student *s tells both the reader and the compiler that this function only reads. Accidentally writing s->age = 0; inside it becomes a compile error rather than a silent bug.
  • An array of structs is declared and iterated exactly like any other array; &students[i] is the address of element i. This is how nearly every C program manages a list of records.
πŸ’‘ When to pass a copy, when to pass a pointer
Pass a pointer when the function needs to modify the struct, or when the struct is large (to avoid the copying cost) β€” adding const if it only reads. Pass a copy when the struct is small (a few bytes, such as a coordinate pair) and you want a guarantee that the function cannot touch the original. A practical rule of thumb: anything bigger than a pointer or two, pass by address.

5. Struct versus union

A union is defined with syntax identical to a struct, but its storage layout in RAM is completely different:

  • Struct: every member gets its own independent region of memory. The struct's size is the sum of its members (possibly plus padding for alignment).
  • Union: all members share one single region of memory. A union's size equals the size of its largest member. At any moment you can only store and read one member β€” writing to one changes or corrupts the values of the others.

Let's run a program comparing their sizes in bytes to make the difference concrete. (Note that the size of individual types such as int and double, and the padding and alignment rules, can vary between architectures β€” see the specifications for object alignment and struct declarations on cppreference.com.)

struct_vs_union.c
#include <stdio.h>

typedef struct {
    char a;    // 1 byte
    int b;     // 4 bytes
    double c;  // 8 bytes
} MyStruct;

typedef union {
    char a;    // 1 byte
    int b;     // 4 bytes
    double c;  // 8 bytes
} MyUnion;

int main() {
    MyStruct s;
    MyUnion u;

    // In kich thuoc bo nho (Sizeof)
    printf("Kich thuoc Struct: %lu bytes\n", sizeof(s)); // Ket qua: thuong la 16 bytes (do padding)
    printf("Kich thuoc Union: %lu bytes\n", sizeof(u));  // Ket qua: dung 8 bytes (kich thuoc cua double c)

    // Thu thay doi gia tri trong Union
    u.c = 9.87;
    printf("u.c = %.2f\n", u.c);

    u.b = 100; // Ghi de len phan bo nho chung
    printf("u.b = %d\n", u.b);
    printf("u.c sau khi ghi de u.b: %.2f\n", u.c); // Gia tri u.c bi thay doi, khong con chinh xac!

    return 0;
}

πŸ“₯ Download the sample source: struct_vs_union.c

Where unions are used in practice

Unions are common in embedded programming (microcontrollers) and systems programming, wherever RAM is extremely constrained and one variable can represent several different types that never appear at the same time.

πŸ“ Check your understanding β€” Lesson 7
Given union Data { char a; int b; double c; } u;, we assign u.c = 9.87; and then u.b = 100;. Which statement is the most accurate?

Related lessons in this series

Lesson 8: Mastering pointers in C Lesson 6: Arrays, strings & text processing in C Back to the C series roadmap (Vietnamese)