🌱 Embedded C: Các lớp lưu trữ - Storage Class trong C
Đối với lập trình viên Embedded, việc quản lý vị trí của các biến (Variable) là cực kỳ quan trọng, Việc đặt sai vị trí các biến có thể dẫn đến lãng phí RAM, lỗi truy cập dữ liệu trong interrupt, hoặc các bug khó debug ở runtime.
Trong firmware, mỗi biến không chỉ đơn thuần là một vùng nhớ mà còn gắn liền với các yếu tố như scope (phạm vi truy cập), lifetime (vòng đời tồn tại), linkage (liên kết giữa các module) và vị trí lưu trữ trong memory layout (stack, data, bss). Storage Class chính là cơ chế mà ngôn ngữ C cung cấp để kiểm soát toàn bộ các yếu tố này.
Bài viết này tập trung phân tích các Storage Class Specifiers trong Embedded C gồm auto, register, static và extern dưới góc nhìn kỹ thuật, kết hợp với cách compiler và linker xử lý bộ nhớ trên MCU.
Storage Class là gì?
Trong ngôn ngữ lập trình C, lớp lưu trữ (storage class) là các từ khóa dùng để định nghĩa phạm vi (scope), thời gian tồn tại (lifetime), giá trị mặc định và vị trí lưu trữ của biến (hoặc hàm). Chúng giúp kiểm soát cách biến được cấp phát bộ nhớ và truy cập trong chương trình.
Các thuộc tính của Storage Class
Mỗi storage class specifier quyết định 4 thuộc tính quan trọng của biến:
| Thuộc tính | Ý nghĩa | Ảnh hưởng |
|---|---|---|
| Scope (Phạm vi) | Vùng mã nguồn mà biến có thể được truy cập | Kiểm soát visibility giữa các module/file |
| Lifetime (Vòng đời) | Khoảng thời gian biến tồn tại trong runtime | Quyết định cấp phát RAM động (stack) hay tĩnh (.data/.bss) |
| Linkage (Liên kết) | Cách biến được chia sẻ giữa các translation units | Ảnh hưởng đến linking phase, symbol table size |
| Storage Location | Vị trí vật lý trong memory (stack, .data, .bss, register) | Ảnh hưởng trực tiếp đến RAM usage và performance |
Phân loại Storage Class
Có 4 lớp lưu trữ chính trong C:
- auto: Biến cục bộ tự động (mặc định cho local variables)
- register: Gợi ý compiler lưu vào CPU register
- static: Biến tĩnh, tồn tại suốt chương trình hoặc internal linkage
- extern: Khai báo biến được định nghĩa ở file khác
typedef đôi khi được liệt kê như một storage class specifier về mặt cú pháp, nhưng nó không ảnh hưởng đến lưu trữ mà chỉ dùng để tạo alias cho kiểu dữ liệu.
Bảng so sánh tổng quan
| Storage Class | Scope | Lifetime | Storage Location | Default Value |
|---|---|---|---|---|
| auto | Local (block) | Block execution | Stack | Garbage (không xác định) |
| register | Local (block) | Block execution | CPU Register (nếu được) | Garbage |
| static (local) | Local (block) | Entire program | .data hoặc .bss | 0 (zero-initialized) |
| static (global) | File scope | Entire program | .data hoặc .bss | 0 |
| extern | Global | Entire program | .data hoặc .bss | Phụ thuộc definition |
Tại sao Storage Class quan trọng trong Embedded?
Trong lập trình vi điều khiển, việc hiểu và sử dụng đúng storage class là không thể bỏ qua vì:
- RAM hạn chế: MCU thường chỉ có vài KB đến vài trăm KB RAM. Sử dụng sai storage class có thể lãng phí RAM hoặc gây stack overflow.
- Interrupt context: Biến được truy cập trong ISR cần được khai báo đúng (thường là static với volatile) để tránh race condition.
- Multi-file project: Firmware thường được chia thành nhiều module. Sử dụng static/extern đúng cách giúp tránh name collision và dễ bảo trì.
- Performance critical: Các biến thường xuyên truy cập trong ISR hoặc real-time task cần được tối ưu về vị trí lưu trữ (register allocation, cache locality).
- Code size optimization: Compiler có thể tối ưu tốt hơn khi biết chính xác scope và lifetime của biến.
// FALSE Case: Biến local trong ISR
void TIM2_IRQHandler(void) {
uint32_t counter = 0; // Mỗi lần interrupt, counter reset về 0!
counter++;
}
// TRUE Case: Dùng static để giữ giá trị giữa các lần gọi
void TIM2_IRQHandler(void) {
static uint32_t counter = 0; // Giữ giá trị qua các lần interrupt
counter++;
}
auto - Storage Class mặc định
Từ khóa auto là storage class mặc định cho tất cả các biến local (biến khai báo trong hàm hoặc block). Trong thực tế, từ khóa này hầu như không bao giờ được viết ra vì nó là mặc định.
Đặc điểm của auto storage class
- Scope: Biến chỉ tồn tại trong hàm hoặc block mà nó được khai báo
- Lifetime: Biến được cấp phát khi vào hàm/block và bị hủy khi thoát khỏi hàm/block
- Storage Location: Lưu trữ trên Stack (Xem quá trình Stacking khi Call Function)
- Default Value: Không được khởi tạo (chứa giá trị garbage/rác), chính là giá trị có sẵn trên bộ nhớ Stack mà nó được cấp phát
- Linkage: None (không có linkage) - không được gọi từ ngoài hàm
Cú pháp
// Hai cách khai báo dưới đây là tương đương
void example_function(void) {
// Cách 1: Không dùng từ khóa (phổ biến)
int x = 10;
// Cách 2: Dùng từ khóa auto (hiếm khi thấy)
auto int y = 20;
// Cả x và y đều là auto variables
}
Cách hoạt động trên Stack
Khi một hàm được gọi, tất cả các biến auto sẽ được cấp phát trên stack theo quy trình stacking:
void calculate(int a, int b) {
int sum; // auto variable
int product; // auto variable
sum = a + b;
product = a * b;
printf("Sum: %d, Product: %d\n", sum, product);
} // Khi ra khỏi hàm, sum và product bị hủy (stack pop)
int main(void) {
calculate(5, 3); // sum và product được tạo trên stack
// Tại đây, sum và product không còn tồn tại
return 0;
}
Ví dụ minh họa Stack Frame khi gọi hàm calculate:
Stack Memory (ARM Cortex-M):
┌─────────────────────┐ ← Stack Pointer (SP) ban đầu
│ ... │
├─────────────────────┤
│ Return Address │ ← Địa chỉ quay lại main()
├─────────────────────┤
│ Saved Registers │ ← R4-R11, LR (nếu cần)
├─────────────────────┤
│ a = 5 │ ← Tham số đầu tiên
├─────────────────────┤
│ b = 3 │ ← Tham số thứ hai
├─────────────────────┤
│ sum (garbage) │ ← Biến local (chưa khởi tạo)
├─────────────────────┤
│ product (garbage) │ ← Biến local (chưa khởi tạo)
└─────────────────────┘ ← Stack Pointer (SP) sau khi vào hàm
Sau khi ra khỏi hàm:
- SP được restore về vị trí ban đầu
- Tất cả local variables (sum, product) bị "hủy" (stack deallocated)
Ví dụ thực tế
1. Tính toán trong hàm
// Hàm đọc nhiệt độ từ ADC và chuyển đổi sang độ C
float read_temperature(void) {
uint16_t adc_value; // auto variable
float voltage; // auto variable
float temperature; // auto variable
// Đọc giá trị ADC (12-bit)
HAL_ADC_Start(&hadc1);
HAL_ADC_PollForConversion(&hadc1, HAL_MAX_DELAY);
adc_value = HAL_ADC_GetValue(&hadc1);
// Chuyển đổi ADC value sang voltage (Vref = 3.3V)
voltage = (adc_value * 3.3f) / 4096.0f;
// Chuyển đổi voltage sang nhiệt độ (LM35: 10mV/°C)
temperature = voltage * 100.0f;
return temperature;
} // adc_value, voltage, temperature bị hủy khi return
2. Buffer tạm trong xử lý dữ liệu
// Hàm format và gửi message qua UART
void send_sensor_data(float temp, uint16_t humidity) {
char buffer[64]; // auto array, cấp phát 64 bytes trên stack
int len;
// Format dữ liệu vào buffer
len = snprintf(buffer, sizeof(buffer),
"Temp: %.1f C, Humidity: %d%%\r\n",
temp, humidity);
// Gửi qua UART
HAL_UART_Transmit(&huart1, (uint8_t*)buffer, len, 100);
} // buffer[64] bị deallocate, giải phóng 64 bytes stack
Vấn đề quan trọng khi sử dụng auto variables
1. Giá trị không xác định (Garbage Value)
// FALSE Case: Biến không được khởi tạo
uint32_t buggy_function(void) {
uint32_t result; // Chứa giá trị rác!
if (some_condition) {
result = 100;
}
// Nếu some_condition = false, result vẫn là garbage!
return result; // Undefined behavior
}
// TRUE Case: Luôn khởi tạo biến
uint32_t correct_function(void) {
uint32_t result = 0; // Khởi tạo rõ ràng
if (some_condition) {
result = 100;
}
return result;
}
2. Stack Overflow
Khai báo array lớn hoặc nested function calls sâu có thể gây stack overflow trên MCU:
// NGUY HIỂM trên MCU có stack nhỏ (vd: STM32F1 có 8KB RAM)
void risky_function(void) {
uint8_t large_buffer[4096]; // Cấp phát 4KB trên stack!
// Xử lý dữ liệu...
} // Có thể gây stack overflow nếu stack chỉ có 2-3KB
// GIẢI PHÁP: Dùng static cho buffer lớn
void safe_function(void) {
static uint8_t large_buffer[4096]; // Lưu trong .bss, không chiếm stack
// Xử lý dữ liệu...
}
3. Lỗi trả về con trỏ đến biến local
// ERROR: Trả về địa chỉ của local variable
char* dangerous_function(void) {
char message[] = "Hello"; // auto array trên stack
return message; // message bị hủy khi ra khỏi hàm!
} // Caller nhận được dangling pointer → undefined behavior
// GIẢI PHÁP 1: Dùng static
char* safe_function_v1(void) {
static char message[] = "Hello"; // Lưu trong .data, tồn tại suốt chương trình
return message;
}
// GIẢI PHÁP 2: Caller cung cấp buffer
void safe_function_v2(char* buffer, size_t size) {
strncpy(buffer, "Hello", size);
}
Một số lưu ý khi sử dụng auto variables
- Luôn khởi tạo biến khi khai báo để tránh garbage value
- Hạn chế khai báo array lớn trên stack (trên MCU thường chỉ có vài KB stack)
- Sử dụng static cho buffer tái sử dụng trong các hàm thường xuyên gọi
- Không bao giờ trả về địa chỉ của biến auto
- Giới hạn scope của biến ở phạm vi nhỏ nhất có thể (block scope)
- Kiểm tra stack usage trong linker script và runtime (nếu có RTOS)
Monitoring Stack Usage
Trên MCU, bạn có thể theo dõi stack usage thông qua:
// 1. Kiểm tra trong linker script (STM32)
_Min_Stack_Size = 0x400; /* 1KB stack */
// 2. Runtime check với FreeRTOS
UBaseType_t stack_high_water_mark = uxTaskGetStackHighWaterMark(NULL);
printf("Remaining stack: %lu bytes\n", stack_high_water_mark * 4);
// 3. Fill stack pattern để debug
void fill_stack_pattern(void) {
extern uint32_t _estack; // Từ linker script
extern uint32_t _sstack;
uint32_t* ptr = &_sstack;
while (ptr < &_estack) {
*ptr++ = 0xDEADBEEF; // Fill pattern
}
}
register - Gợi ý compiler tối ưu
Từ khóa register được sử dụng để gợi ý compiler lưu biến vào thanh ghi CPU (register) thay vì RAM để tăng tốc độ truy cập. Tuy nhiên, đây chỉ là một hint (gợi ý), compiler "có quyền" bỏ qua nếu không khả thi.
Đặc điểm của register storage class
- Scope: Chỉ trong hàm hoặc block khai báo
- Lifetime: Tồn tại trong thời gian thực thi hàm/block
- Storage: Nếu được chấp nhận, biến sẽ nằm trong CPU register (Ví dụ R0-R12 trên chip lõi ARM)
- Lưu ý: Không thể lấy địa chỉ của biến register vì nó được xác định nằm trên thanh ghi (không thể dùng toán tử &)
Trong Embedded C, việc sử dụng register hầu như không cần thiết vì:
- Các Compiler hiện đại (GCC, Clang, ARM Compiler) tự động tối ưu register allocation trong một số trường hợp
- Optimization level (-O2, -O3) đã xử lý rất tốt việc phân bổ thanh ghi
- Từ C++17, từ khóa register đã bị deprecated
Ví dụ sử dụng register
void fast_loop(void) {
// Gợi ý compiler lưu biến đếm vào thanh ghi
register uint32_t i;
uint32_t sum = 0;
for (i = 0; i < 10000; i++) {
sum += i;
}
// Lỗi: không thể lấy địa chỉ của register variable
// uint32_t *ptr = &i; // Compile error!
}
Kiểm tra hiệu quả của register
Để kiểm tra xem compiler có thực sự sử dụng thanh ghi hay không, ta cần xem assembly code:
// Compiled với -O2:
// i được map vào thanh ghi r3
movs r3, #0 @ i = 0
movs r2, #0 @ sum = 0
.L2:
add r2, r2, r3 @ sum += i
adds r3, r3, #1 @ i++
cmp r3, #10000
bne .L2
static - Từ khóa quan trọng nhất trong Embedded
Từ khóa static là một trong những công cụ quan trọng nhất trong lập trình Embedded C. Nó có hai ý nghĩa khác nhau tùy thuộc vào vị trí sử dụng:
1. Static Local Variable (Biến cục bộ tĩnh)
Khi dùng static với biến local trong hàm, biến đó sẽ:
- Scope: Chỉ truy cập được trong hàm khai báo
- Lifetime: Tồn tại suốt thời gian chạy chương trình (không bị hủy khi thoát hàm)
- Storage: Lưu trong vùng .data (nếu có giá trị khởi tạo) hoặc .bss (nếu không khởi tạo hoặc = 0)
- Initialization: Chỉ khởi tạo một lần duy nhất khi chương trình start
Ví dụ thực tế: Counter function
uint32_t get_call_count(void) {
static uint32_t counter = 0; // Chỉ khởi tạo 1 lần
counter++;
return counter;
}
// Gọi hàm:
printf("Call 1: %d\n", get_call_count()); // Output: 1
printf("Call 2: %d\n", get_call_count()); // Output: 2
printf("Call 3: %d\n", get_call_count()); // Output: 3
Ứng dụng trong Embedded: Debounce Button
// Driver UART
bool button_read_debounced(void) {
static uint32_t last_time = 0;
static bool last_state = false;
uint32_t current_time = HAL_GetTick();
bool current_state = HAL_GPIO_ReadPin(BUTTON_GPIO_Port, BUTTON_Pin);
// Debounce logic: chỉ chấp nhận sau 50ms
if (current_state != last_state) {
if ((current_time - last_time) > 50) {
last_state = current_state;
last_time = current_time;
}
}
return last_state;
}
2. Static Global Variable/Function (Internal Linkage)
Khi dùng static với biến/hàm global (ngoài hàm), nó sẽ:
- Scope: Chỉ truy cập được trong file .c hiện tại
- Linkage: Internal linkage (không export ra các file khác)
- Storage: Lưu trong .data hoặc .bss
Luôn sử dụng static cho các biến/hàm global mà chỉ dùng trong một file để:
- Tránh xung đột tên (name collision) khi link nhiều module
- Giảm kích thước symbol table
- Compiler có thể tối ưu tốt hơn (inline, dead code elimination)
- Tăng tính đóng gói (encapsulation) của module
Ví dụ: Driver UART
// uart.c
// Private variables (chỉ dùng trong uart.c)
static uint8_t tx_buffer[256];
static uint8_t rx_buffer[256];
static volatile uint16_t tx_head = 0;
static volatile uint16_t tx_tail = 0;
// Private helper function
static bool is_tx_buffer_full(void) {
return ((tx_head + 1) % 256) == tx_tail;
}
// Public API (không dùng static)
void UART_SendByte(uint8_t data) {
while (is_tx_buffer_full()); // Wait if buffer full
tx_buffer[tx_head] = data;
tx_head = (tx_head + 1) % 256;
// Enable TX interrupt
USART1->CR1 |= USART_CR1_TXEIE;
}
3. Vị trí lưu trữ của static variables trong Memory Layout
// main.c
static uint32_t initialized_var = 100; // Lưu trong .data
static uint32_t zero_var = 0; // Lưu trong .bss
static uint32_t uninitialized_var; // Lưu trong .bss
void test_function(void) {
static uint32_t local_static = 50; // Lưu trong .data
uint32_t local_auto = 20; // Lưu trong stack
}
| Biến | Vùng nhớ | Đặc điểm |
|---|---|---|
| initialized_var | .data (0x20000000) | Có giá trị khởi tạo ≠ 0 |
| zero_var | .bss (0x20000100) | Khởi tạo = 0, được clear bởi startup code |
| uninitialized_var | .bss (0x20000104) | Không khởi tạo → auto = 0 |
| local_static | .data (0x20000108) | Static local với giá trị khởi tạo |
| local_auto | Stack (0x2001FFxx) | Biến tự động, cấp phát động |
extern - Chia sẻ biến giữa các file
Từ khóa extern được sử dụng để khai báo (declare) một biến hoặc hàm mà đã được định nghĩa (define) ở file khác. Nó cho linker biết rằng symbol này tồn tại ở đâu đó trong project.
Đặc điểm của extern
- Linkage: External linkage (có thể truy cập từ các file khác)
- Storage: Không cấp phát bộ nhớ (chỉ là declaration, không phải definition)
- Lifetime: Toàn bộ thời gian chạy chương trình (nếu là biến global)
- Declaration: Thông báo cho compiler biết kiểu dữ liệu và tên của biến (không cấp phát bộ nhớ)
- Definition: Thực sự cấp phát bộ nhớ cho biến
Sử dụng extern
Ví dụ 1: Chia sẻ biến global giữa các file
// config.c (Definition - cấp phát bộ nhớ)
uint32_t system_clock = 72000000; // 72 MHz
uint8_t device_id = 0x01;
// config.h (Declaration - không cấp phát)
extern uint32_t system_clock;
extern uint8_t device_id;
// main.c (Sử dụng)
#include "config.h"
void main(void) {
// Truy cập biến từ config.c
printf("System Clock: %d Hz\n", system_clock);
printf("Device ID: 0x%02X\n", device_id);
}
Ví dụ 2: Truy cập biến từ Linker Script
// startup.c
// Các symbol này được định nghĩa trong linker script (.ld file)
extern uint32_t _estack; // End of stack
extern uint32_t _sdata; // Start of .data
extern uint32_t _edata; // End of .data
extern uint32_t _sbss; // Start of .bss
extern uint32_t _ebss; // End of .bss
void print_memory_info(void) {
printf(".data size: %d bytes\n", &_edata - &_sdata);
printf(".bss size: %d bytes\n", &_ebss - &_sbss);
}
extern với hàm
Đối với hàm, từ khóa extern là mặc định, do đó không cần viết rõ:
// gpio.h
void GPIO_Init(void); // Tương đương: extern void GPIO_Init(void);
void GPIO_TogglePin(uint8_t pin);
// gpio.c
void GPIO_Init(void) {
// Implementation...
}
Lỗi thường gặp với extern
Định nghĩa biến trong header file
// FALSE Case: config.h
uint32_t system_clock = 72000000; // Multiple definition error khi include nhiều file!
// TRUE Case: config.h
extern uint32_t system_clock; // Chỉ declaration
// TRUE Case: config.c
uint32_t system_clock = 72000000; // Definition
Quên định nghĩa biến extern
// main.c
extern uint32_t my_variable; // Declare
void main(void) {
my_variable = 100; // Linker error: undefined reference to 'my_variable'
}
// Cần có definition ở đâu đó:
// other.c
uint32_t my_variable; // Definition
extern "C" trong C++
Một số trường hợp khi làm sản phẩm, code tầng Application có thể sử C++, khi viết code C cho Embedded mà có thể được compile bởi C++ compiler, cần sử dụng extern "C" để tránh name mangling:
// driver.h
#ifdef __cplusplus
extern "C" {
#endif
void Driver_Init(void);
void Driver_Process(void);
#ifdef __cplusplus
}
#endif
Kết luận
Việc hiểu rõ các Storage Class trong C là nền tảng quan trọng cho lập trình Embedded. Mỗi storage class có vai trò và ứng dụng riêng trong việc tối ưu hóa bộ nhớ và hiệu năng của hệ thống nhúng.
Một số nguyên tắc quan trọng với Storage Class
- Ưu tiên static: Luôn dùng
staticcho private variables/functions trong module để tránh xung đột symbol và giúp compiler tối ưu tốt hơn - Hạn chế global variables: Nếu bắt buộc phải dùng, đặt chúng trong một file .c cụ thể và expose qua getter/setter functions
- Tránh large auto arrays: Stack thường nhỏ (4-8KB), tránh khai báo mảng lớn trong hàm. Dùng static hoặc dynamic allocation
- Cẩn thận với static trong interrupt: Static variables trong ISR phải được bảo vệ bằng critical section nếu cũng được truy cập từ main loop
- extern chỉ trong .h files: Đặt declaration với extern trong header, definition trong .c file
- Volatile khi cần thiết: Kết hợp với static cho biến được truy cập từ interrupt:
static volatile uint32_t flag; - Kiểm tra memory map: Dùng map file (.map) để kiểm tra kích thước .data, .bss và stack usage
Ví dụ thực tế khi sử dụng cho kiến trúc module
// adc.h (Public Interface)
#ifndef ADC_H
#define ADC_H
void ADC_Init(void);
uint16_t ADC_Read(uint8_t channel);
bool ADC_IsReady(void);
#endif
// adc.c (Implementation)
#include "adc.h"
// Private variables (static)
static uint16_t adc_buffer[8];
static volatile bool conversion_complete = false;
// Private functions (static)
static void start_conversion(uint8_t channel) {
// Internal logic...
}
// Public functions (extern by default)
void ADC_Init(void) {
// Implementation...
}
uint16_t ADC_Read(uint8_t channel) {
return adc_buffer[channel];
}
Hiểu và áp dụng đúng các Storage Class không chỉ giúp code chạy hiệu quả hơn mà còn tránh được nhiều lỗi runtime khó debug trong hệ thống nhúng. Đây là kiến thức nền tảng mà mọi Embedded Developer cần nắm vững.
>>>>>> Follow ngay <<<<<<<
Để nhận được những bài học miễn phí mới nhất nhé 😊
Chúc các bạn học tập tốt 😊