C cho vi điều khiển: kiểu số cố định, phép toán bit, con trỏ thanh ghi, volatile, struct packed
Những phần C dùng nhiều nhất khi lập trình vi điều khiển: uint8_t và tràn số, phép toán bit với mặt nạ, con trỏ tới thanh ghi, volatile với ngắt, struct packed, thứ tự byte, const, static; ví dụ ESP32.
Arduino, ESP-IDF, STM32 HAL hay hầu hết thư viện cho vi điều khiển đều viết bằng C (hoặc C++ theo kiểu C). Người mới thường học C trên máy tính rồi bất ngờ khi gặp code nhúng: đầy phép toán bit, mặt nạ, con trỏ trỏ vào địa chỉ lạ, từ khóa volatile. Bài này đi qua những phần C dùng nhiều nhất khi lập trình vi điều khiển: kiểu số cố định độ rộng, phép toán bit trên thanh ghi, con trỏ tới thanh ghi, volatile với ngắt, struct và thứ tự byte, const và static, kèm một chương trình ESP32 in ra từng kết quả.
1. Kiểu số cố định độ rộng

int trên ESP32 là 32 bit, trên Arduino Nano chỉ 16 bit (tối đa 32767). Code đếm tới 40000 chạy đúng trên ESP32 nhưng tràn trên Nano. Vì vậy code nhúng dùng các kiểu trong <stdint.h>, ghi rõ số bit:
uint8_t,uint16_t,uint32_t: số không dấu 8, 16, 32 bit (0–255, 0–65535…).int8_t,int16_t,int32_t: số có dấu.
Tràn số không báo lỗi: uint8_t đang 255 cộng 1 thành 0. Vòng lặp for (uint8_t i = 0; i < 300; i++) không bao giờ dừng vì i không thể đạt 300.
2. Phép toán bit và mặt nạ
Thanh ghi của vi điều khiển là những ô nhớ mà mỗi bit, mỗi nhóm bit điều khiển một việc. Để đổi đúng vài bit mà không đụng các bit khác, dùng phép toán bit với mặt nạ (mask):
| Việc | Cách viết | Ghi chú |
|---|---|---|
| Bật bit n | x |= (1UL << n); | OR với 1 thành 1, với 0 giữ nguyên |
| Xóa bit n | x &= ~(1UL << n); | AND với 0 thành 0 |
| Đảo bit n | x ^= (1UL << n); | XOR với 1 thì đảo |
| Kiểm tra bit n | if (x & (1UL << n)) | khác 0 là bit đang bật |
| Ghi trường nhiều bit | x = (x & ~MASK) | ((v << VI_TRI) & MASK); | xóa trường cũ rồi ghi giá trị mới |
| Đọc trường | (x >> VI_TRI) & MASK_GOC | dịch xuống rồi giữ đủ số bit |

- Viết
1UL << nthay cho1 << n: với n = 31, dịch sốintcó dấu là lỗi (hành vi không xác định). ESP-IDF có sẵn macroBIT(n)làm đúng việc này. - Cẩn thận thứ tự ưu tiên:
if (x & MASK == 0)thực ra làx & (MASK == 0). Luôn thêm ngoặc:if ((x & MASK) == 0).
3. Con trỏ và thanh ghi
Con trỏ là biến chứa địa chỉ. &a lấy địa chỉ của a; *p đọc hoặc ghi ô nhớ mà p trỏ tới. Thanh ghi ngoại vi cũng nằm ở những địa chỉ cố định, nên có thể đọc ghi chúng qua con trỏ. Trên ESP32, thanh ghi GPIO_OUT_W1TS_REG ở địa chỉ 0x3FF44008: ghi 1 vào bit n thì chân n lên mức cao, các chân khác giữ nguyên. Chương trình khai báo:
volatile uint32_t *const THANH_GHI_DAT_CAO = (volatile uint32_t *)GPIO_OUT_W1TS_REG;
*THANH_GHI_DAT_CAO = BIT_THU(2); // GPIO2 lên mức cao
*const: bản thân con trỏ không đổi (luôn trỏ đúng thanh ghi đó).volatile uint32_t: ô nhớ được trỏ tới có thể tự thay đổi hoặc việc ghi có tác dụng ra phần cứng, trình biên dịch không được bỏ qua hay gộp các lần đọc ghi.
Đây là cách thư viện HAL, digitalWrite() làm bên dưới. Code ghi thẳng thanh ghi chạy nhanh nhưng gắn chặt với một loại chip; trong dự án thường chỉ dùng ở chỗ cần tốc độ.
4. volatile: biến dùng chung với ngắt

Trình biên dịch tối ưu bằng cách giữ giá trị biến trong thanh ghi CPU nếu nó "thấy" đoạn code hiện tại không đổi biến đó. Nhưng hàm ngắt có thể chạy chen vào bất cứ lúc nào và đổi biến. Khai báo volatile buộc mỗi lần dùng đều đọc lại bộ nhớ thật. Dùng volatile cho:
- biến được ghi trong hàm ngắt và đọc ở chương trình chính (hoặc ngược lại);
- thanh ghi phần cứng truy cập qua con trỏ;
- biến dùng chung giữa các tác vụ FreeRTOS (kèm cơ chế khóa).
volatile không phải là khóa: phép soLanBam = soLanBam + 1 vẫn gồm đọc, cộng, ghi và có thể bị ngắt chen giữa. Chương trình mẫu đặt các thao tác này trong vùng khóa portENTER_CRITICAL. Xem thêm ngắt GPIO trên ESP32.
5. Struct, byte đệm và thứ tự byte

Trình biên dịch chèn byte đệm để biến 4 byte nằm ở địa chỉ chia hết cho 4 (CPU đọc nhanh hơn). Struct ba trường 1 + 4 + 2 byte thành 12 byte. Khi struct là khuôn của gói tin gửi qua UART, I2C hay lưu vào Flash, phải bỏ đệm bằng __attribute__((packed)) (7 byte) và thống nhất thứ tự byte ở hai đầu: ESP32, STM32, AVR đều đặt byte thấp trước, nhưng nhiều giao thức (Modbus chẳng hạn) gửi byte cao trước.
6. const, static và #define
const: giá trị không đổi; mảng hằng lớn (bảng tra, font chữ) khai báoconstđể nằm trong Flash, không tốn RAM.statictrong hàm: biến giữ giá trị giữa các lần gọi (chương trình dùng cholucDoitrongloop()).staticngoài hàm: biến, hàm chỉ thấy được trong file đó, tránh trùng tên giữa các file.#definechỉ là thay chữ trước khi biên dịch, không có kiểu: nên dùngconstcho hằng số, để#definecho macro nhưBIT()và cho biên dịch có điều kiện.
7. Chương trình ví dụ trên ESP32
Chương trình chỉ dùng gói ESP32 cho Arduino IDE, cần một ESP32 DevKit V1 và dây USB, không cần linh kiện ngoài: LED GPIO2 và nút BOOT có sẵn trên bo.
// PYWORLD.VN - C cho vi điều khiển trên ESP32: kiểu số cố định, phép toán bit, struct, thứ tự byte, con trỏ thanh ghi, volatile
// PYWORLD.VN - Mở Serial Monitor 115200 để xem kết quả; bấm nút BOOT (GPIO0) để thấy biến volatile được đếm trong ngắt
// PYWORLD.VN - LED GPIO2 trên DevKit được bật tắt bằng cách ghi thẳng vào thanh ghi qua con trỏ
#include "soc/gpio_reg.h"
#define BIT_THU(n) (1UL << (n)) // ESP-IDF có sẵn macro BIT() viết y hệt; ở đây tự viết để thấy cách làm
const int CHAN_LED = 2; // LED xanh trên ESP32 DevKit V1
const int CHAN_NUT = 0; // nút BOOT, nhấn thì kéo xuống GND
// "Thanh ghi" 8 bit tự đặt để tập: bit 7..5 chế độ, bit 4 bật, bit 3..0 hệ số chia
const uint8_t VI_TRI_CHE_DO = 5;
const uint8_t MASK_CHE_DO = 0x07; // 3 bit
const uint8_t VI_TRI_BAT = 4;
const uint8_t MASK_CHIA = 0x0F; // 4 bit thấp
// Hai struct cùng ba trường; bản packed bỏ các byte đệm
struct GoiThuong {
uint8_t loai;
uint32_t giaTri;
uint16_t crc;
};
struct __attribute__((packed)) GoiNen {
uint8_t loai;
uint32_t giaTri;
uint16_t crc;
};
// Con trỏ hằng trỏ tới thanh ghi: ghi 1 vào bit n của W1TS thì chân n lên cao, của W1TC thì xuống thấp
volatile uint32_t *const THANH_GHI_DAT_CAO = (volatile uint32_t *)GPIO_OUT_W1TS_REG;
volatile uint32_t *const THANH_GHI_DAT_THAP = (volatile uint32_t *)GPIO_OUT_W1TC_REG;
volatile uint32_t soLanBam = 0; // thay đổi trong ngắt nên phải khai báo volatile
portMUX_TYPE khoa = portMUX_INITIALIZER_UNLOCKED;
void IRAM_ATTR khiBam() {
portENTER_CRITICAL_ISR(&khoa);
soLanBam = soLanBam + 1;
portEXIT_CRITICAL_ISR(&khoa);
}
void inNhiPhan(const char *ten, uint8_t x) {
Serial.printf("%-18s 0x%02X = ", ten, (unsigned)x);
for (int i = 7; i >= 0; i--) {
Serial.print((x >> i) & 1);
if (i == 4) Serial.print(' ');
}
Serial.println();
}
void setup() {
Serial.begin(115200);
delay(500);
Serial.println("1. Kieu so co dinh do rong");
uint8_t u8 = 255;
u8 = u8 + 1; // tràn: quay về 0
int8_t i8 = 127;
i8 = (int8_t)(i8 + 1); // vượt 127: thành -128 với trình biên dịch gcc
Serial.printf("uint8_t 255 + 1 = %u; int8_t 127 + 1 = %d\n", (unsigned)u8, (int)i8);
Serial.printf("sizeof: int %u, long %u, long long %u, float %u, double %u, con tro %u (byte)\n",
(unsigned)sizeof(int), (unsigned)sizeof(long), (unsigned)sizeof(long long),
(unsigned)sizeof(float), (unsigned)sizeof(double), (unsigned)sizeof(void *));
Serial.println("2. Phep toan bit tren thanh ghi 8 bit");
uint8_t tg = 0;
tg |= (uint8_t)((3 & MASK_CHE_DO) << VI_TRI_CHE_DO); // đặt trường chế độ = 3
inNhiPhan("che do = 3", tg);
tg |= BIT_THU(VI_TRI_BAT); // bật bit 4
inNhiPhan("bat bit 4", tg);
tg = (tg & ~MASK_CHIA) | (10 & MASK_CHIA); // xóa trường chia rồi ghi 10
inNhiPhan("he so chia = 10", tg);
tg &= (uint8_t)~BIT_THU(VI_TRI_BAT); // xóa bit 4
inNhiPhan("xoa bit 4", tg);
tg ^= BIT_THU(0); // đảo bit 0
inNhiPhan("dao bit 0", tg);
Serial.printf("Doc lai: che do = %u, bat = %u, chia = %u\n", (unsigned)((tg >> VI_TRI_CHE_DO) & MASK_CHE_DO),
(unsigned)((tg >> VI_TRI_BAT) & 1), (unsigned)(tg & MASK_CHIA));
Serial.println("3. Struct va thu tu byte");
Serial.printf("GoiThuong: %u byte, GoiNen (packed): %u byte\n", (unsigned)sizeof(GoiThuong), (unsigned)sizeof(GoiNen));
uint32_t so = 0x12345678;
uint8_t b[4];
memcpy(b, &so, sizeof(so));
Serial.printf("0x12345678 nam trong bo nho: %02X %02X %02X %02X\n", (unsigned)b[0], (unsigned)b[1], (unsigned)b[2], (unsigned)b[3]);
Serial.println("4. Con tro toi thanh ghi");
pinMode(CHAN_LED, OUTPUT);
Serial.printf("GPIO_OUT_W1TS_REG = 0x%08X, GPIO_OUT_W1TC_REG = 0x%08X\n", (unsigned)GPIO_OUT_W1TS_REG, (unsigned)GPIO_OUT_W1TC_REG);
pinMode(CHAN_NUT, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(CHAN_NUT), khiBam, FALLING);
Serial.println("5. Bam nut BOOT de dem trong ngat");
}
void loop() {
static uint32_t lucDoi = 0; // static: giữ giá trị giữa các lần gọi loop()
static bool sang = false;
static uint32_t daIn = 0;
if (millis() - lucDoi >= 500) {
lucDoi = millis();
sang = !sang;
if (sang) {
*THANH_GHI_DAT_CAO = BIT_THU(CHAN_LED);
} else {
*THANH_GHI_DAT_THAP = BIT_THU(CHAN_LED);
}
}
portENTER_CRITICAL(&khoa);
uint32_t n = soLanBam; // đọc trong vùng khóa để không bị ngắt chen giữa
portEXIT_CRITICAL(&khoa);
if (n != daIn) {
daIn = n;
Serial.printf("Ngat: so lan bam = %u\n", (unsigned)n);
}
}

Bấm nút BOOT vài lần: số đếm có lúc tăng 2, 3 cho một lần bấm vì tiếp điểm nút dội. Đây cũng là lý do phải chống dội khi đọc nút bằng ngắt.
8. Lỗi C hay gặp trong code nhúng
| Lỗi | Hậu quả | Cách tránh |
|---|---|---|
Biến đếm quá nhỏ (uint8_t đếm tới 300) | Vòng lặp vô hạn, số sai | Chọn kiểu đủ rộng, nghĩ tới giá trị lớn nhất |
Thiếu volatile cho biến dùng với ngắt | Chạy đúng khi tắt tối ưu, sai khi bật | Khai báo volatile, đọc ghi trong vùng khóa |
1 << 31 với int | Hành vi không xác định | Dùng 1UL << n |
| Thiếu ngoặc trong biểu thức bit | Điều kiện luôn đúng hoặc luôn sai | Bọc từng phép &, | trong ngoặc |
| So sánh có dấu với không dấu | -1 > 1u lại đúng | Đưa về cùng kiểu, đọc cảnh báo trình biên dịch |
So sánh millis() > moc | Sai khi millis() quay vòng sau khoảng 49 ngày | Viết millis() - lucTruoc >= khoang như chương trình mẫu |
| Trả về con trỏ tới biến cục bộ | Dữ liệu rác, treo chip | Dùng biến static hoặc bộ đệm do nơi gọi cấp |
9. Xem thêm
- Sơ đồ chân ESP32 DevKit V1: GPIO2 và GPIO0 là chân có chức năng lúc khởi động.
- Tự thiết kế khung tin trên UART: nơi struct packed và thứ tự byte được dùng thật.
- Các bài cùng loạt lộ trình: học lập trình nhúng bắt đầu từ đâu, bắt đầu với STM32 (thanh ghi và HAL); bài timer phần cứng trong loạt ESP32 nâng cao.
Xem thêm