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

Bảng kích thước: int 4 byte trên ESP32, 2 byte trên Arduino Nano; long 4 và 4; long long 8 và 8; float 4 và 4; double 8 và 4; con trỏ 4 và 2; uint8_t 1, uint16_t 2, uint32_t 4 trên cả hai
Cùng tên kiểu, khác kích thước giữa hai chip.

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ệcCách viếtGhi chú
Bật bit nx |= (1UL << n);OR với 1 thành 1, với 0 giữ nguyên
Xóa bit nx &= ~(1UL << n);AND với 0 thành 0
Đảo bit nx ^= (1UL << n);XOR với 1 thì đảo
Kiểm tra bit nif (x & (1UL << n))khác 0 là bit đang bật
Ghi trường nhiều bitx = (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_GOCdịch xuống rồi giữ đủ số bit
Thanh ghi 8 bit gồm chế độ bit 7 đến 5, bật bit 4, hệ số chia bit 3 đến 0; năm lệnh lần lượt cho 0x60, 0x70, 0x7A, 0x6A, 0x6B, bit vừa đổi viền đỏ
Năm lệnh trong chương trình, chạy lần lượt từ 0x00.
  • Viết 1UL << n thay cho 1 << n: với n = 31, dịch số int có dấu là lỗi (hành vi không xác định). ESP-IDF có sẵn macro BIT(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

Hai cột: thiếu volatile thì trình biên dịch có thể đọc biến một lần vào thanh ghi CPU và lặp mãi dù ngắt đã ghi 1 vào RAM; có volatile thì mỗi vòng đọc lại RAM và thoát ngay
Minh họa cách trình biên dịch có thể tối ưu khi thiếu volatile.

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

Struct GoiThuong gồm loai 1 byte, 3 byte đệm, giaTri 4 byte, crc 2 byte, 2 byte đệm là 12 byte; bản packed 7 byte; số 0x12345678 nằm trong bộ nhớ ESP32 là 78 56 34 12
Byte đệm và thứ tự byte thấp trước (little-endian) trên ESP32.

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áo const để nằm trong Flash, không tốn RAM.
  • static trong hàm: biến giữ giá trị giữa các lần gọi (chương trình dùng cho lucDoi trong loop()).
  • static ngoài hàm: biến, hàm chỉ thấy được trong file đó, tránh trùng tên giữa các file.
  • #define chỉ là thay chữ trước khi biên dịch, không có kiểu: nên dùng const cho hằng số, để #define cho 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);
  }
}
Serial Monitor in: uint8_t 255 cộng 1 bằng 0, int8_t 127 cộng 1 bằng -128; sizeof int 4, double 8, con trỏ 4; năm dòng bit 0x60, 0x70, 0x7A, 0x6A, 0x6B; đọc lại chế độ 3, bật 0, chia 11; GoiThuong 12 byte, GoiNen 7 byte; 78 56 34 12; địa chỉ 0x3FF44008, 0x3FF4400C; đếm số lần bấm
Kết quả trên Serial Monitor; hai dòng đếm nút cuối là ví dụ.

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ỗiHậ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ố saiChọ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ắtChạy đúng khi tắt tối ưu, sai khi bậtKhai báo volatile, đọc ghi trong vùng khóa
1 << 31 với intHành vi không xác địnhDùng 1UL << n
Thiếu ngoặc trong biểu thức bitĐiều kiện luôn đúng hoặc luôn saiBọ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() > mocSai khi millis() quay vòng sau khoảng 49 ngàyViế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 chipDù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.

Tin tức liên quan

Op-amp: so sánh, khuếch đại không đảo, mạch đệm và đo hệ số khuếch đại bằng ESP32
Op-amp: so sánh, khuếch đại không đảo, mạch đệm và đo hệ số khuếch đại bằng ESP32

Op-amp là gì, hai quy tắc tính mạch, mạch so sánh với quang trở, mạch đệm, khuếch đại không đảo và đảo, giới hạn của LM358 ở 3,3 V và thí nghiệm đo hệ số khuếch đại, bão hòa bằng ESP32.

Robot thoát mê cung trong Scratch: cảm biến màu, lệnh điều kiện và thuật toán bám tường
Robot thoát mê cung trong Scratch: cảm biến màu, lệnh điều kiện và thuật toán bám tường

Dự án Scratch robot thoát mê cung cho học sinh lớp 4–6: chấm cảm biến màu, vòng lặp đến khi, phép không, thuật toán bám tường phải, mô phỏng đường đi và trường hợp đi vòng mãi.

Cảm biến gia tốc: cấu tạo MEMS răng lược, áp điện, cách chế tạo và đo góc nghiêng bằng ESP32
Cảm biến gia tốc: cấu tạo MEMS răng lược, áp điện, cách chế tạo và đo góc nghiêng bằng ESP32

Cảm biến gia tốc hoạt động thế nào: mô hình khối lượng – lò xo, MEMS răng lược điện dung vi sai, chế tạo vi cơ bề mặt, áp điện, servo, đo góc nghiêng và đọc MPU-6050 bằng ESP32.

Lực kế chuẩn theo ISO 376: trình tự hiệu chuẩn, chỉ tiêu tái lập, lặp lại, nội suy và phân cấp
Lực kế chuẩn theo ISO 376: trình tự hiệu chuẩn, chỉ tiêu tái lập, lặp lại, nội suy và phân cấp

Hiệu chuẩn lực kế chuẩn theo ISO 376: chuẩn bị, trình tự sáu loạt đo xoay 0°, 120°, 240°, công thức b, b', fc, v, bảng tiêu chí cấp 00, 0,5, 1, 2 và ví dụ lực kế 100 kN.

Gia công xốp chống sốc cho ống kính, máy ảnh và thiết bị quay phim
Gia công xốp chống sốc cho ống kính, máy ảnh và thiết bị quay phim

Gia công xốp chống sốc định hình cho ống kính, máy ảnh, flycam và thiết bị kỹ thuật số: mẫu vali ba ống kính quay phim, cách xếp thử, số đo cần gửi và cách chọn vali chống nước.

Gá lắp gia tốc kế theo ISO 5348: tần số cộng hưởng, dải tần dùng được và các sai số khi đo rung
Gá lắp gia tốc kế theo ISO 5348: tần số cộng hưởng, dải tần dùng được và các sai số khi đo rung

Cách gá gia tốc kế theo ISO 5348: vít cấy, keo, sáp, nam châm, đầu dò cầm tay; tần số cộng hưởng lắp đặt, dải tần dùng được, ảnh hưởng khối lượng cảm biến và các điểm kỹ thuật khi gắn.

Breadboard, dây cắm và hàn linh kiện: lắp mạch thử đúng cách cho người mới
Breadboard, dây cắm và hàn linh kiện: lắp mạch thử đúng cách cho người mới

Cách dùng breadboard: bên trong nối thế nào, cắm linh kiện và IC đúng cách, dây cắm, cấp nguồn và gắn ESP32; cách hàn linh kiện: dụng cụ, nhiệt độ, năm bước hàn, mối hàn tốt và xấu, an toàn.

Trò chơi đo phản xạ với ESP32 và màn hình OLED 4 nút: đo thời gian, tính trung bình
Trò chơi đo phản xạ với ESP32 và màn hình OLED 4 nút: đo thời gian, tính trung bình

Làm trò chơi đo phản xạ bằng ESP32 và màn hình OLED kèm 4 nút: kiểm tra nút, đo thời gian bằng millis, chờ ngẫu nhiên, tính trung bình 5 lượt, thí nghiệm cho học sinh.


Bình luận
Đã thêm vào giỏ hàng