Phát triển ngoại vi MCU từ nguyên lý đến thực thi thực tế

Trong phát triển nhúng, các ngoại vi cốt lõi của MCU đóng vai trò cầu nối giữa phần cứng và phần mềm. Dù là điều khiển động cơ trong hệ thống công nghiệp hay giao tiếp cảm biến trên thiết bị IoT, đều phụ thuộc vào việc lập trình sâu cho các module như GPIO, UART, I2C. Bài viết này tổng hợp kinh nghiệm thực tế từ nền tảng STM32 (lõi Cortex-M) và ESP32, phân tích logic vận hành của ngoại vi, giúp bạn xây dựng nền tảng vững chắc về lập trình cấp thấp.

I. Chuẩn bị thiết yếu trước khi phát triển: Tài liệu và môi trường

1. Danh sách tài liệu bắt buộc (chìa khóa tránh lỗi)

Khi tiếp cận bất kỳ MCU nào, cần xây dựng kiến thức nền tảng thông qua ba loại tài liệu chính:
  • Sổ tay dữ liệu chip (Datasheet): Xác định lõi CPU, dung lượng bộ nhớ, thông số nguồn (ví dụ: điện áp hoạt động 3.3V cho STM32F103).
  • Sổ tay tham chiếu (Reference Manual): Cung cấp bản đồ thanh ghi ngoại vi (ví dụ: địa chỉ cơ sở GPIOA_BASE = 0x40020000U) và mô tả chức năng chi tiết.
  • Sơ đồ mạch in (Schematic): Làm rõ kết nối chân (ví dụ: UART_TX nối với RX của module chuyển USB sang serial).

Mẹo đọc tài liệu: Bắt đầu bằng phần "Tổng quan" để có cái nhìn tổng thể, sau đó tập trung vào chương "Cấu hình thanh ghi" tương ứng với ngoại vi mục tiêu. Đánh dấu các bit quan trọng (ví dụ: bit chọn chế độ ở GPIO_MODER).

2. Thiết lập môi trường phát triển đa nền tảng

a) Lựa chọn công cụ
Loại công cụ STM32 đề xuất ESP32 đề xuất
IDE Keil MDK, STM32CubeIDE VS Code + PlatformIO, ESP-IDF
Công cụ cấu hình STM32CubeMX (cấu hình trực quan) ESP-IDF Menuconfig
Bộ gỡ lỗi ST-Link, J-Link J-Link, USB CDC
b) Kiểm tra môi trường – 3 bước cơ bản
  1. Tạo dự án tối giản: Sử dụng CubeMX để cấu hình cây xung nhịp (ví dụ: dùng HSE + PLL tạo xung hệ thống 72MHz cho STM32).
  2. Biên dịch và chạy chớp LED: Dùng hàm HAL_GPIO_TogglePin để đảo mức logic, kiểm tra chức năng GPIO.
  3. Kết nối bộ gỡ lỗi: Chạy chế độ Debug trong Keil, theo dõi sự thay đổi giá trị thanh ghi (ví dụ: bit0 của GPIO_ODR).

II. Phát triển 4 ngoại vi cốt lõi: Từ thanh ghi đến thư viện

1. GPIO – Điều khiển cơ bản nhất

Nguyên lý底层: GPIO hoạt động thông qua 4 nhóm thanh ghi: MODER (chế độ), OTYPER (loại ngõ ra), OSPEEDR (tốc độ), ODR (dữ liệu). Ví dụ điều khiển LED qua PA0 trên STM32:

#define GPIOA_BASE_ADDR   0x40020000U
#define MODER_REG         (*(volatile uint32_t*)(GPIOA_BASE_ADDR + 0x00))
#define OUTPUT_DATA_REG   (*(volatile uint32_t*)(GPIOA_BASE_ADDR + 0x14))

void led_gpio_init(void) {
    // Bật clock cho cổng A
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;

    // Đặt PA0 thành ngõ ra đẩy kéo
    MODER_REG &= ~(3U << 0);
    MODER_REG |= (1U << 0);
}

void toggle_led_pin(void) {
    OUTPUT_DATA_REG ^= (1U << 0);  // Đảo bit để chớp sáng
}

Dùng thư viện HAL (STM32):

void init_led_hal(void) {
    __HAL_RCC_GPIOA_CLK_ENABLE();

    GPIO_InitTypeDef config = {0};
    config.Pin = GPIO_PIN_0;
    config.Mode = GPIO_MODE_OUTPUT_PP;
    config.Pull = GPIO_NOPULL;

    HAL_GPIO_Init(GPIOA, &config);
}

// Trong vòng lặp chính
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_0);
HAL_Delay(500);

2. Hệ thống xung nhịp – "nhịp tim" của hệ thống

Khái niệm cốt lõi: Nguồn xung gồm nội bộ (HSI/LSI) và bên ngoài (HSE/LSE), được nhân tần bằng PLL để tạo xung hệ thống. Ví dụ cấu hình xung 168MHz cho STM32F407 từ HSE 8MHz:

void configure_system_clock(void) {
    RCC_OscInitTypeDef osc_config = {0};
    RCC_ClkInitTypeDef clk_config = {0};

    osc_config.OscillatorType = RCC_OSCILLATORTYPE_HSE;
    osc_config.HSEState = RCC_HSE_ON;
    osc_config.PLL.PLLState = RCC_PLL_ON;
    osc_config.PLL.PLLSource = RCC_PLLSOURCE_HSE;
    osc_config.PLL.PLLM = 8;     // Chia 8MHz → 1MHz
    osc_config.PLL.PLLN = 168;   // Nhân lên 168MHz
    osc_config.PLL.PLLP = 2;     // Chia APB1 xuống 84MHz

    HAL_RCC_OscConfig(&osc_config);

    clk_config.ClockType = RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_PCLK1;
    clk_config.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK;
    clk_config.APB1CLKDivider = RCC_HCLK_DIV2;

    HAL_RCC_ClockConfig(&clk_config, FLASH_LATENCY_5);
}

Lưu ý quan trọng:

  • Luôn bật clock cho ngoại vi trước khi sử dụng (ví dụ: thiết lập RCC->APB2ENR cho UART1).
  • Với tần số cao, cần cấu hình thời gian chờ bộ nhớ (FLASH_LATENCY_5 cho 168MHz).

3. UART – Giao tiếp không đồng bộ

Kết nối phần cứng: USART1_TX (PA9) → RX của bộ chuyển USB, USART1_RX (PA10) → TX, nối mass chung.

Nhận dữ liệu theo ngắt (HAL):

UART_HandleTypeDef uart_handle;
uint8_t rx_buffer[1];

void setup_uart(void) {
    uart_handle.Instance = USART1;
    uart_handle.Init.BaudRate = 115200;
    uart_handle.Init.WordLength = UART_WORDLENGTH_8B;
    uart_handle.Init.StopBits = UART_STOPBITS_1;
    uart_handle.Init.Parity = UART_PARITY_NONE;
    uart_handle.Init.Mode = UART_MODE_TX_RX;

    HAL_UART_Init(&uart_handle);
    HAL_UART_Receive_IT(&uart_handle, rx_buffer, 1);
}

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
    if (huart == &uart_handle) {
        HAL_UART_Transmit(huart, rx_buffer, 1, 100);
        HAL_UART_Receive_IT(huart, rx_buffer, 1);
    }
}

So sánh với ESP32:

#include "driver/uart.h"

void esp32_uart_setup(void) {
    const uart_config_t config = {
        .baud_rate = 115200,
        .data_bits = UART_DATA_8_BITS,
        .parity = UART_PARITY_DISABLE,
        .stop_bits = UART_STOP_BITS_1,
        .flow_ctrl = UART_HW_FLOWCTRL_DISABLE
    };

    uart_param_config(UART_NUM_0, &config);
    uart_set_pin(UART_NUM_0, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE, -1, -1);
    uart_driver_install(UART_NUM_0, 1024, 0, 0, NULL, 0);
}

4. I2C – Giao tiếp cảm biến phổ biến

Chuẩn bị phần cứng: Kết nối SCL (PB6), SDA (PB7) tới BMP280, thêm điện trở kéo lên 4.7kΩ.

Đọc dữ liệu nhiệt độ từ BMP280:

I2C_HandleTypeDef i2c_handle;
uint8_t sensor_addr = 0x48 << 1;
uint8_t raw_data[2];

void init_i2c(void) {
    i2c_handle.Instance = I2C1;
    i2c_handle.Init.ClockSpeed = 100000;
    i2c_handle.Init.DutyCycle = I2C_DUTYCYCLE_2;
    i2c_handle.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;
    HAL_I2C_Init(&i2c_handle);
}

float read_temperature_bmp280(void) {
    // Gửi địa chỉ thanh ghi nhiệt độ
    HAL_I2C_Master_Transmit(&i2c_handle, sensor_addr, 0xFA, 1, 100);
    // Đọc 2 byte dữ liệu
    HAL_I2C_Master_Receive(&i2c_handle, sensor_addr, raw_data, 2, 100);
    // Tính toán nhiệt độ
    int16_t temp_raw = (raw_data[0] << 8) | raw_data[1];
    return (temp_raw / 16.0f) - 40.0f;
}

III. Gỡ lỗi và tối ưu: Giải quyết vấn đề thực tế

1. Ba bước xử lý lỗi ngoại vi

  1. Cấp phần cứng: Dùng đồng hồ vạn năng đo điện áp, máy phân tích logic kiểm tra dạng sóng I2C/SPI.
  2. Cấp thanh ghi: Dùng chế độ Debug để kiểm tra trạng thái thanh ghi (ví dụ: bit TC trong UART_SR).
  3. Cấp mã nguồn: Kiểm tra bật clock, cấu hình chân đa năng (AF mode).

2. Tối ưu tiêu thụ năng lượng

  • STM32: Tắt clock ngoại vi không dùng, đặt GPIO ở chế độ kéo xuống.
  • ESP32: Dùng ULP coprocessor cho tác vụ nhẹ, tắt Wi-Fi/Bluetooth để giảm dòng ngủ xuống 5μA.

3. Xử lý treo hệ thống

Kiểm tra các nguyên nhân:
  • Gọi HAL_Delay() bên trong ngắt (gây deadlock do phụ thuộc SysTick).
  • Ghi sai vào thanh ghi chỉ đọc (ví dụ: sửa trực tiếp SPI_SR).
  • Xung đột bus: Địa chỉ I2C trùng, tín hiệu CS SPI không được kích hoạt đúng.

IV. Hướng phát triển nâng cao

  • Nắm vững lớp dưới cùng: Viết driver PWM bằng thanh ghi thuần, đọc ADC đa kênh.
  • Tích hợp hệ thống: Kết nối ngoại vi với FreeRTOS (dùng queue truyền dữ liệu UART).
  • Hỗ trợ đa nền tảng: So sánh khác biệt giữa STM32 và ESP32 (ví dụ: ESP32 không có CAN tích hợp).

Thẻ: MCU STM32 ESP32 GPIO UART

Đăng vào ngày 16 tháng 7 lúc 05:33