STM32 Beginner - P5: Xây một ứng dụng hoàn chỉnh
Ghép timer, ADC, DMA, PWM, I2C và UART thành một STM32 bench monitor nhỏ với kiến trúc firmware rõ ràng, xử lý lỗi và hướng mở rộng cho project thực tế.
Chia sẻ

Đây là phần cuối của STM32 Beginner Guide. Ở P4: ADC, PWM và DMA, mình đã học từng peripheral riêng lẻ. Nhưng firmware thật hiếm khi chỉ dùng một peripheral. Câu hỏi quan trọng hơn là: làm thế nào để ghép chúng thành một hệ thống có nhịp chạy rõ ràng, có log và có cách xử lý khi phần cứng không phản hồi?
Đi trong series: P1: Bắt đầu với STM32 · P2: GPIO, Interrupt và Timer · P3: UART, I2C và SPI · P4: ADC, PWM và DMA · P5 đang đọc
Trong bài này, mình xây dựng một STM32 Bench Monitor. Đây là ứng dụng nhỏ để học kiến trúc, không phải sản phẩm hoàn thiện:
- Timer tạo nhịp sampling.
- ADC đọc biến trở hoặc tín hiệu analog.
- DMA đưa nhiều mẫu vào buffer.
- I2C đọc một cảm biến môi trường.
- PWM điều khiển độ sáng LED trạng thái.
- UART gửi log có cấu trúc về máy tính.
Board tham chiếu vẫn là NUCLEO-G071RB, project được tạo bằng STM32CubeIDE và HAL. Các tên peripheral, channel và pin cụ thể có thể khác nếu bạn dùng board khác; hãy coi code dưới đây là khung kiến trúc và đối chiếu generated code của chính project.
Ứng dụng hoàn chỉnh cần giải quyết điều gì?

Một ứng dụng hoàn chỉnh không cần quá lớn; quan trọng là mỗi peripheral có một vai trò rõ ràng.
Một demo LED chỉ cần chứng minh một output hoạt động. Một ứng dụng phải trả lời thêm vài câu hỏi:
- Dữ liệu được lấy lúc nào?
- Peripheral nào sở hữu buffer ở từng thời điểm?
- Nếu sensor timeout thì hệ thống làm gì?
- Log có đủ thông tin để tìm lỗi không?
- Một phần chậm có làm cả hệ thống đứng lại không?
Mình sẽ thiết kế theo nguyên tắc đơn giản: interrupt và DMA chỉ báo sự kiện; main loop quyết định và xử lý công việc nặng.
Nhìn tổng thể kiến trúc
Application điều phối event, còn mỗi driver sở hữu một interface phần cứng.
Luồng dữ liệu chính là:
TIM6 trigger -> ADC1 -> DMA buffer -> main loop -> PWM + UART
^
|
I2C sensor read
Timer không trực tiếp “làm mọi thứ”. Nó chỉ tạo nhịp đều. ADC lấy mẫu theo nhịp đó. DMA chuyển mẫu. Khi đủ một block, callback set cờ. Main loop lấy block, tính toán, đọc sensor theo lịch riêng và cập nhật output.
Phân vai từng module
| Module | Trách nhiệm | Không nên làm |
|---|---|---|
app_scheduler | Nhận event, quyết định việc cần chạy | Chứa toàn bộ driver trong một file |
adc_stream | Quản lý ADC + DMA buffer | Format log hoặc điều khiển UI |
sensor | Đọc I2C và trả về dữ liệu hợp lệ/lỗi | Tự block vô hạn khi sensor mất |
indicator | Scale dữ liệu sang PWM | Biết chi tiết register ADC |
logger | Gửi UART theo format thống nhất | In log dài trong ISR |
Đây chưa phải là architecture framework lớn. Nhưng chỉ cần tách trách nhiệm như vậy, việc thay BME280 bằng cảm biến khác hoặc thay LED bằng motor driver sẽ ít làm vỡ phần còn lại.
Tách driver layer và application layer
Driver layer biết HAL handle, register, địa chỉ và chi tiết điện. Application layer biết measurement, lịch chạy, policy và trạng thái an toàn. Một project nhỏ có thể thể hiện ranh giới đó bằng các file:
Core/
Inc/app_monitor.h state và event public của application
Src/app_monitor.c điều phối main loop và fault policy
Drivers/
adc_stream.c/.h ownership của block ADC + DMA
environment_sensor.c/.h transaction I2C và giải mã sensor
indicator.c/.h output PWM có giới hạn
logger.c/.h queue UART và format log
Ví dụ, environment_sensor_read() có thể trả về SENSOR_TIMEOUT, SENSOR_BUS_ERROR hoặc SENSOR_OK. Application quyết định retry, đánh dấu sample invalid hay chuyển sang safe state; driver không nên tự quyết định policy của sản phẩm. Giữ phần khởi tạo do CubeMX sinh ở khu vực riêng và đặt logic vào file user để việc generate lại không xóa mất code.
Cấu hình CubeMX
Trong project cho NUCLEO-G071RB, cấu hình các phần sau:
- Một timer-based, ví dụ
TIM6, tạo update event định kỳ. - Một ADC channel, trigger từ timer nếu muốn khoảng cách mẫu đều.
- DMA cho ADC, hướng peripheral-to-memory, memory increment và circular mode.
- Một timer channel PWM, ví dụ
TIM3_CH1, theo pin mapping thực tế. - Một I2C instance cho sensor.
- Một UART/USART cho log.
- NVIC cho DMA, timer và các peripheral dùng interrupt.
Tên TIM6, ADC1, TIM3_CH1 và I2C1 ở đây chỉ là cách bố trí minh họa. Trong CubeMX, pin mapping có thể khiến bạn phải dùng instance khác. Đừng sửa tay tên handle trước khi hiểu file .ioc và code được sinh ra.
Bước 1: tạo state tối thiểu
Mình bắt đầu bằng state rõ ràng thay vì rải nhiều biến global không có quy ước:
#define ADC_BUFFER_LENGTH 64U
#define ADC_MAX_VALUE 4095U
#define PWM_PERIOD 999U
static uint16_t adc_buffer[ADC_BUFFER_LENGTH];
static volatile uint8_t adc_half_ready = 0U;
static volatile uint8_t adc_full_ready = 0U;
static volatile uint8_t sensor_due = 0U;
static volatile uint8_t logger_due = 0U;
typedef enum
{
SENSOR_OK,
SENSOR_TIMEOUT,
SENSOR_BUS_ERROR
} sensor_status_t;
typedef enum
{
APP_STARTING,
APP_RUNNING,
APP_DEGRADED,
APP_FAULT
} app_state_t;
typedef struct
{
uint16_t analog_average;
int32_t temperature_milli_c;
uint8_t sensor_valid;
sensor_status_t sensor_error;
uint32_t sensor_failures;
app_state_t state;
uint32_t sequence;
} monitor_state_t;
static monitor_state_t monitor = {0};
volatile chỉ nên dùng cho giá trị được thay đổi trong interrupt và được đọc ở main loop. Nó không biến thao tác trên nhiều biến thành atomic transaction. Khi state phức tạp hơn, hãy thiết kế ownership hoặc critical section rõ ràng.
Bước 2: khởi động peripheral theo thứ tự
Sau các hàm MX_*_Init() do CubeMX tạo, application có thể start các peripheral:
HAL_ADC_Start_DMA(&hadc1,
(uint32_t *)adc_buffer,
ADC_BUFFER_LENGTH);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);
HAL_TIM_Base_Start_IT(&htim6);
Trong project thực tế, hãy kiểm tra return value của từng hàm. Nếu một peripheral không start được, đưa hệ thống vào trạng thái lỗi có log rõ ràng thay vì tiếp tục như thể mọi thứ bình thường.
Thứ tự cũng có ý nghĩa. DMA cần được cấu hình trước khi ADC tạo ra stream. PWM nên ở trạng thái an toàn trước khi application bắt đầu điều khiển tải. Timer trigger nên start sau khi đường nhận dữ liệu đã sẵn sàng.
Bước 3: callback chỉ phát tín hiệu
ADC báo rằng một nửa circular buffer đã thuộc về CPU:
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
if (hadc->Instance == ADC1)
{
adc_half_ready = 1U;
}
}
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
if (hadc->Instance == ADC1)
{
adc_full_ready = 1U;
}
}
Timer tạo lịch cho các công việc chậm hơn:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM6)
{
static uint32_t tick = 0U;
tick++;
if ((tick % 100U) == 0U)
{
sensor_due = 1U;
}
if ((tick % 500U) == 0U)
{
logger_due = 1U;
}
}
}
Đây là ví dụ đơn giản, chưa phải scheduler chính xác tuyệt đối. Nếu event có thể đến nhanh hơn main loop xử lý, một uint8_t flag sẽ làm mất số lần event. Khi đó dùng counter, queue hoặc timestamp tùy semantics. Callback không được tính trung bình, gọi I2C hay in UART.
Bước 4: xử lý buffer ADC
Main loop kiểm tra cờ và tính trung bình:
static uint16_t AverageAdcBlock(const uint16_t *block,
uint32_t length)
{
uint32_t sum = 0U;
for (uint32_t i = 0U; i < length; i++)
{
sum += block[i];
}
return (uint16_t)(sum / length);
}
Application chỉ gọi hàm này cho adc_buffer và &adc_buffer[ADC_BUFFER_LENGTH / 2U] sau callback tương ứng. Đó là ownership hand-off: DMA giữ nửa đang ghi, CPU giữ nửa đã hoàn tất. CPU phải xử lý xong trước khi DMA quay vòng dùng lại vùng đó. Nếu không kịp, tăng overrun counter và chuyển sang degraded mode thay vì âm thầm tin vào dữ liệu cũ.
Bước 5: Biến ADC thành PWM
Sau khi có giá trị trung bình, scale sang compare value:
static void UpdateIndicator(void)
{
uint32_t compare =
((uint32_t)monitor.analog_average * PWM_PERIOD)
/ ADC_MAX_VALUE;
if (compare > PWM_PERIOD)
{
compare = PWM_PERIOD;
}
__HAL_TIM_SET_COMPARE(&htim3,
TIM_CHANNEL_1,
compare);
}
Với LED test, duty tăng theo biến trở là đủ dễ nhìn. Với tải công suất, không nối trực tiếp vào GPIO/PWM pin; cần MOSFET, gate resistor, diode hoặc driver phù hợp với tải.
Bước 6: đọc cảm biến qua I2C
Sensor task chỉ chạy khi đến lịch:
static void ReadEnvironmentSensor(void)
{
uint8_t data[2] = {0};
const uint16_t address = (0x76U << 1);
if (HAL_I2C_Mem_Read(&hi2c1,
address,
0xFAU,
I2C_MEMADD_SIZE_8BIT,
data,
sizeof(data),
50U) == HAL_OK)
{
monitor.temperature_milli_c = DecodeTemperature(data);
monitor.sensor_valid = 1U;
monitor.sensor_error = SENSOR_OK;
}
else
{
monitor.sensor_valid = 0U;
monitor.sensor_error = SENSOR_TIMEOUT;
}
}
0xFA và DecodeTemperature() chỉ là placeholder. Mỗi sensor có register map, conversion time, endian và công thức riêng. Trong project thật, driver sensor nên che các chi tiết này để application chỉ nhận một kết quả hoặc mã lỗi rõ ràng.
Đừng retry vô hạn trong hàm đọc. Một timeout ngắn và trạng thái sensor_valid = 0 giúp main loop tiếp tục chạy, log được lỗi và thử lại ở chu kỳ sau.
Bước 7: log UART có cấu trúc
Log tốt nên trả lời được “khi nào, sequence nào, dữ liệu gì và có lỗi không”:
static void LogMonitorState(void)
{
char line[128];
int length = snprintf(
line,
sizeof(line),
"seq=%lu adc=%u temp_mC=%ld sensor=%s\\r\\n",
(unsigned long)monitor.sequence,
monitor.analog_average,
(long)monitor.temperature_milli_c,
monitor.sensor_valid ? "ok" : "error");
if ((length > 0) && ((size_t)length < sizeof(line)))
{
HAL_UART_Transmit(&huart2,
(uint8_t *)line,
(uint16_t)length,
100U);
}
}
Điều kiện độ dài quan trọng: khi snprintf() bị truncate, giá trị trả về lớn hơn số byte thực sự nằm trong buffer. Không được đưa giá trị lớn đó cho UART. Trong sản phẩm, snprintf() và UART polling có thể không phù hợp nếu log dày. Khi đó dùng ring buffer, UART interrupt hoặc DMA. Nhưng ở bài đầu tiên, log blocking ngắn giúp debug nhanh và làm rõ data flow.
Bước 8: main loop điều phối
Phần application có thể giữ nhịp như sau:
while (1)
{
if (adc_half_ready != 0U)
{
adc_half_ready = 0U;
monitor.analog_average = AverageAdcBlock(
&adc_buffer[0], ADC_BUFFER_LENGTH / 2U);
UpdateIndicator();
monitor.sequence++;
}
if (adc_full_ready != 0U)
{
adc_full_ready = 0U;
monitor.analog_average = AverageAdcBlock(
&adc_buffer[ADC_BUFFER_LENGTH / 2U], ADC_BUFFER_LENGTH / 2U);
UpdateIndicator();
monitor.sequence++;
}
if (sensor_due != 0U)
{
sensor_due = 0U;
ReadEnvironmentSensor();
}
if (logger_due != 0U)
{
logger_due = 0U;
LogMonitorState();
}
if (ApplicationReadyForWatchdog())
{
HAL_IWDG_Refresh(&hiwdg);
}
}
Điểm đáng chú ý là main loop không có HAL_Delay() dài. Mỗi module làm một phần nhỏ rồi quay lại vòng lặp. ApplicationReadyForWatchdog() chỉ trả về true khi các task bắt buộc đã tiến triển trong chu kỳ; không feed watchdog từ timer ISR.
Xử lý lỗi: biến failure thành state, không chỉ là một dòng print
Mỗi lời gọi driver cần timeout hữu hạn và kết quả để application hành động. Hãy giữ các counter như i2c_timeout_count, adc_overrun_count và uart_drop_count; một cờ sensor_valid chỉ cho UI biết nên hiển thị gì, còn counter cho biết lỗi là tạm thời hay kéo dài.
static void ApplySafeOutput(void)
{
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 0U);
}
static void HandleSensorResult(sensor_status_t result)
{
if (result == SENSOR_OK)
{
monitor.sensor_failures = 0U;
return;
}
monitor.sensor_failures++;
monitor.state = (monitor.sensor_failures >= 3U)
? APP_DEGRADED : APP_RUNNING;
if (monitor.sensor_failures >= 10U)
{
monitor.state = APP_FAULT;
ApplySafeOutput();
}
}
Ngưỡng cụ thể phụ thuộc vào thiết bị. Điều quan trọng là sensor mất không thể block vô hạn và không được âm thầm trả về một giá trị cũ trông như hợp lệ. Hãy quyết định stale value có chấp nhận được không, log tuổi của sample và ghi rõ policy của output.
Watchdog: chứng minh application còn sống
Cấu hình independent watchdog với timeout dài hơn vòng lặp healthy tệ nhất, bao gồm thời gian transaction I2C tối đa. Chỉ refresh từ application context sau khi công việc bắt buộc đã tiến triển. Không refresh từ timer ISR hay callback không liên quan: chúng vẫn có thể chạy khi main loop đã chết.
Trong giai đoạn bring-up, log reset reason lúc boot. Nếu cần post-mortem, giữ thêm reset counter trong backup register hoặc non-volatile storage. Watchdog chỉ hữu ích khi lần boot tiếp theo giải thích được chuyện gì xảy ra.
Test theo từng tầng

Log và trạng thái local giúp kiểm tra hệ thống ngay cả khi chưa có dashboard hay cloud.
Mình không nối tất cả phần cứng rồi chạy một lần. Quy trình ít đau đầu hơn là:
- Chạy ADC polling và xác nhận giá trị thay đổi theo biến trở.
- Chạy PWM cố định và đo waveform/duty.
- Ghép ADC vào PWM, kiểm tra mapping min/max.
- Chạy I2C
IsDeviceReady, sau đó đọc một register ID. - Bật DMA với buffer nhỏ và kiểm tra callback.
- Bật timer trigger để xác nhận khoảng cách mẫu.
- Thêm UART log cuối cùng để quan sát cả pipeline.
Mỗi bước nên có một tiêu chí pass rõ. Ví dụ: “ADC trung bình thay đổi từ gần 0 đến gần full-scale”, “PWM không vượt period”, “I2C timeout trong giới hạn” hoặc “DMA không ghi đè vùng đang xử lý”.
Test lỗi, không chỉ test happy path

Test đường lỗi như mất cảm biến hoặc timeout thường có giá trị hơn việc chỉ thử happy path.
Một hệ thống hoàn chỉnh được phân biệt bởi lúc phần cứng không hoàn hảo. Mình sẽ thử ít nhất:
- Rút cảm biến I2C khi đang chạy.
- Đổi địa chỉ I2C sai.
- Làm UART bận hoặc rút cáp log.
- Đưa ADC về sát 0 và sát full-scale.
- Tạo buffer xử lý chậm hơn tốc độ DMA.
- Reset board giữa một transaction.
Kết quả mong muốn không nhất thiết là “không có lỗi”. Kết quả tốt hơn là hệ thống phát hiện lỗi, giữ output ở trạng thái an toàn, log được nguyên nhân và tự khôi phục hoặc yêu cầu reset có chủ đích.
Watchdog và trạng thái lỗi
Khi application đã có timer, DMA, I2C và UART, watchdog bắt đầu có ý nghĩa. Nhưng đừng feed watchdog ở một timer interrupt bất kể main loop có bị kẹt hay không. Nếu main loop chết, timer vẫn có thể chạy và che mất lỗi.
Một cách đơn giản là chỉ refresh watchdog sau khi các task bắt buộc đã hoàn thành trong một chu kỳ. Với sensor timeout hoặc DMA error kéo dài, chuyển sang FAULT state và quyết định rõ output sẽ tắt, giữ giá trị cuối hay dùng giá trị an toàn.
Các lỗi dễ gặp khi ghép hệ thống
Callback chạy nhưng dữ liệu không đổi
Kiểm tra buffer có bị DMA ghi đè, ADC có thực sự được trigger, data alignment có đúng và callback đang thuộc đúng handle không.
UART log làm mất mẫu ADC
HAL_UART_Transmit() blocking có thể giữ main loop quá lâu. Giảm tần suất log, rút ngắn dòng hoặc chuyển logger sang interrupt/DMA.
I2C đọc đúng một lần rồi timeout
Kiểm tra sensor có cần conversion delay, bus có bị giữ LOW, pull-up có đúng và driver có đang bắt đầu transaction mới khi transaction cũ chưa hoàn tất không.
PWM bị giật khi cập nhật theo ADC
ADC nhiễu sẽ làm duty thay đổi liên tục. Lọc hoặc hysteresis ở application, giảm nhiễu phần cứng và chỉ cập nhật PWM theo một nhịp hợp lý.
Một flag bị mất event
Nếu callback set flag lần hai trước khi main loop clear lần đầu, hai event chỉ còn một. Dùng counter hoặc queue khi số lượng event có ý nghĩa.
Debug và checklist chuẩn bị production
Trước khi gọi prototype là hoàn chỉnh, kiểm tra ở ba lớp:
- Observe: đo sample timing ADC và PWM frequency bằng oscilloscope, xác nhận ACK/timeout của I2C, capture UART log kèm timestamp và reset reason.
- Stress: chạy nhiều giờ, sweep analog input, rút/cắm lại sensor, làm đầy logger queue và cố tình làm chậm block processing để xác nhận overrun detection.
- Harden: bật warning khi compile, giữ assertion trong debug build, bỏ secret và log verbose khỏi release, kiểm tra brownout/reset, định nghĩa GPIO safe state và ghi lại board, clock, pin map, compiler, firmware version.
Trong production, thay UART blocking bằng queue có giới hạn, thêm version cho log format, ghi rõ ownership của shared state và test release binary trên phần cứng thật. Build sạch không chứng minh timing, giới hạn điện và recovery khi lỗi đã đúng.
Khi nào nên chuyển sang RTOS?
Ứng dụng này vẫn có thể chạy tốt với super-loop. Bạn chưa cần RTOS chỉ vì có nhiều peripheral. RTOS bắt đầu đáng cân nhắc khi các task có timing độc lập, cần queue/blocking rõ ràng, hoặc hệ thống có nhiều nguồn event khó điều phối bằng flag.
Dù có RTOS, các nguyên tắc vẫn còn: ISR ngắn, buffer ownership rõ, timeout hữu hạn và trạng thái lỗi có chủ đích.
Kết luận: hoàn chỉnh nghĩa là có flow và có lỗi được dự đoán
Một ứng dụng STM32 hoàn chỉnh không nhất thiết phải có màn hình đẹp hay kết nối cloud. Với mình, nó cần có:
- Input được lấy theo một lịch có thể giải thích.
- Peripheral phối hợp qua event và buffer rõ ràng.
- Output được giới hạn và được đặt về trạng thái an toàn.
- Log đủ để biết hệ thống đang làm gì.
- Timeout, lỗi giao tiếp và mất thiết bị được xử lý.
- Cấu trúc code cho phép thay sensor hoặc output mà không phá toàn bộ application.
Sau 5 phần, bạn đã đi từ việc chọn board đến một pipeline firmware có GPIO, interrupt, timer, UART, I2C, SPI, ADC, PWM và DMA. Bước tiếp theo không phải nhồi thêm peripheral, mà là làm một project nhỏ chạy đủ lâu để đo, cố tình tạo lỗi và sửa kiến trúc khi nó bắt đầu lộ giới hạn.
Tham khảo và mã nguồn mở
- STMicroelectronics/STM32CubeG0 — firmware package chính thức gồm HAL, LL, BSP và các project mẫu theo board/peripheral.
- STM32CubeG0 Projects List — danh sách example và cấu trúc project để đối chiếu.
- STM32CubeG0 ADC example — ví dụ ADC của ST.
- stm32ai-datalogger — project mở của ST để tham khảo cách log dữ liệu STM32 về máy tính.
- STM32-base/STM32-base-STM32Cube — repository cộng đồng đóng gói CMSIS và HAL chính thức để tham khảo.
Chia sẻ
Tiếp tục khám phá
Đọc tiếp
Bài viết liên quan
STM32 Beginner Guide P1: STM32 là gì? Chọn board nào để bắt đầu?
Tìm hiểu STM32, phân biệt chip, module và development board, rồi chọn một board phù hợp để học GPIO, HAL, STM32CubeIDE và firmware nhúng từ những bước đầu tiên.
STM32 Beginner - P3: UART, I2C và SPI thực hành với HAL
Tìm hiểu ba giao tiếp phổ biến trên STM32: dùng UART để debug, I2C để đọc cảm biến và SPI để kết nối màn hình hoặc bộ nhớ bằng STM32CubeIDE và HAL.
STM32 Beginner Guide P2: GPIO, Interrupt và Timer từ ví dụ đầu tiên
Học cách cấu hình GPIO, bắt sự kiện nút nhấn bằng EXTI interrupt và tạo nhịp định kỳ bằng timer trên STM32CubeIDE với board NUCLEO-G071RB.