STM32 Beginner Guide P4: ADC, PWM và DMA từ analog đến dữ liệu liên tục
Học cách đọc tín hiệu analog bằng ADC, điều khiển độ sáng LED bằng PWM và chuyển nhiều mẫu dữ liệu qua DMA trên STM32CubeIDE với HAL.
Chia sẻ

Ở P3: UART, I2C và SPI, mình đã dùng các giao tiếp tuần tự để nói chuyện với laptop, sensor và display. P4 chuyển sang ba peripheral giúp firmware làm việc với tín hiệu analog, công suất điều khiển và luồng dữ liệu lớn hơn.
Đi trong series: P1: Bắt đầu với STM32 · P2: GPIO, Interrupt và Timer · P3: UART, I2C và SPI · P4 đang đọc
Mình sẽ đi theo một ví dụ tăng dần độ khó:
- Đọc biến trở bằng ADC.
- Dùng giá trị đó để thay đổi độ sáng LED bằng PWM.
- Lấy nhiều mẫu ADC tự động vào buffer bằng DMA.
Các ví dụ vẫn dùng NUCLEO-G071RB, STM32CubeIDE và HAL. ST có các example ADC, timer/PWM và DMA trong STM32CubeG0; mình sẽ giữ code ở mức dễ đo và dễ debug trước khi tối ưu.
ADC: biến điện áp thành dữ liệu số

ADC biến điện áp analog thành con số; nguồn tham chiếu, trở kháng nguồn và nhiễu đều ảnh hưởng đến kết quả.
ADC, hay Analog-to-Digital Converter, lấy một điện áp analog rồi biểu diễn nó bằng số nguyên. Một ADC 12-bit có 2^12 = 4096 mức, từ 0 đến 4095.
Nếu giả sử điện áp tham chiếu là 3,3 V, công thức lý tưởng là:
voltage = adc_value × 3.3 / 4095
Nhưng đây chỉ là mô hình lý tưởng. Điện áp tham chiếu thật, độ tuyến tính của ADC, nhiễu nguồn, trở kháng nguồn và thời gian sampling đều ảnh hưởng đến kết quả.
Nối biến trở
Với biến trở 10 kΩ, nối hai đầu ngoài vào 3V3 và GND, chân giữa vào một chân ADC:
3V3 ---- biến trở ---- GND
|
+-------- ADC input
Không đưa điện áp cao hơn giới hạn của chân ADC vào input. Nếu nguồn analog có thể vượt 3,3 V, hãy dùng mạch chia áp và tính lại dải đo.
Trong CubeMX, chọn một chân có chức năng ADC, bật channel tương ứng và bắt đầu với độ phân giải mặc định. Đừng chọn chân chỉ vì tên trên header trông giống A0; hãy kiểm tra alternate function của đúng MCU và board.
Đọc ADC bằng polling
Giả sử CubeMX sinh ra hadc1 và channel đã được cấu hình:
uint32_t adc_value = 0;
HAL_ADC_Start(&hadc1);
if (HAL_ADC_PollForConversion(&hadc1, 100) == HAL_OK)
{
adc_value = HAL_ADC_GetValue(&hadc1);
}
HAL_ADC_Stop(&hadc1);
Đây là cách dễ hiểu nhất cho lần đọc đầu tiên. HAL_ADC_PollForConversion() chờ conversion hoàn thành, nên không nên gọi trong một vòng lặp với tốc độ quá cao mà không có kế hoạch thời gian.
Nếu muốn đổi sang millivolt:
uint32_t millivolts = (adc_value * 3300U) / 4095U;
Con số này chỉ có ý nghĩa tương đối nếu bạn chưa hiệu chuẩn và chưa xác nhận VREF+. Với sản phẩm cần độ chính xác, hãy xem calibration và datasheet của đúng dòng STM32.
Vì sao ADC bị nhiễu?
Một vài nguyên nhân mình thường kiểm tra:
- Dây analog dài, chạy gần dây clock hoặc đường PWM.
- Nguồn analog hoặc GND có dòng tải lớn chạy chung đường hồi.
- Nguồn tín hiệu có trở kháng cao nhưng sampling time quá ngắn.
- Chỉ đọc một mẫu rồi tin ngay vào kết quả.
- LED hoặc motor đóng cắt đúng lúc ADC đang lấy mẫu.
Đọc nhiều mẫu rồi lấy trung bình giúp giảm một phần nhiễu ngẫu nhiên:
uint32_t sum = 0;
for (uint32_t i = 0; i < 16; i++)
{
HAL_ADC_Start(&hadc1);
HAL_ADC_PollForConversion(&hadc1, 100);
sum += HAL_ADC_GetValue(&hadc1);
HAL_ADC_Stop(&hadc1);
}
uint32_t average = sum / 16U;
Oversampling không sửa được lỗi nối nhầm chân, reference sai hoặc nhiễu có tính hệ thống. Nó chỉ giúp với một phần nhiễu ngẫu nhiên.
PWM: điều khiển bằng duty cycle

PWM không tạo ra điện áp analog thật; nó thay đổi duty cycle của xung digital để điều khiển công suất trung bình.
PWM, hay Pulse-Width Modulation, bật và tắt một tín hiệu digital ở tần số cố định. Duty cycle là tỷ lệ thời gian tín hiệu ở mức HIGH trong một chu kỳ.
duty cycle = thời gian HIGH / chu kỳ
Với LED, mắt người tích hợp ánh sáng theo thời gian, nên duty cycle nhỏ hơn thường tạo cảm giác sáng yếu hơn. Với motor hoặc heater, duty cycle có thể điều khiển công suất trung bình, nhưng vẫn cần mạch driver phù hợp.
PWM không biến GPIO thành một nguồn analog sạch. Nếu cần điện áp analog thật, bạn cần DAC hoặc mạch lọc phù hợp và phải xem tải.
Cấu hình PWM trong CubeMX
Chọn một timer channel có alternate function PWM, đặt timer ở PWM Generation CHx, rồi cấu hình:
- Prescaler để tạo tần số của counter.
Periodđể đặt độ phân giải duty cycle.Pulselà giá trị compare ban đầu.- Polarity theo yêu cầu của tải.
Ví dụ, nếu Period = 999, giá trị compare từ 0 đến 999 cho khoảng duty gần 0–100%:
uint32_t duty = 500;
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, duty);
Đừng gọi HAL_TIM_PWM_Start() trong mỗi lần cập nhật. Start một lần sau khi khởi tạo, sau đó chỉ đổi compare:
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);
while (1)
{
uint32_t duty = (adc_value * 999U) / 4095U;
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, duty);
}
Trong code thật, nên đặt giới hạn và cập nhật theo timer/event thay vì chạy vòng lặp nhanh không kiểm soát.
Tần số PWM chọn thế nào?
Tần số quá thấp có thể làm LED nhấp nháy hoặc motor rung. Tần số quá cao làm giảm độ phân giải duty, tăng switching loss hoặc vượt giới hạn của driver.
Công thức phụ thuộc clock timer:
f_pwm = f_timer_clock / ((Prescaler + 1) × (Period + 1))
Với LED, vài trăm hertz đến vài kilohertz thường là vùng thử nghiệm hợp lý. Với motor, quạt, audio hoặc nguồn switching, hãy chọn theo datasheet và yêu cầu của hệ thống, không dùng một giá trị chung cho mọi tải.
DMA: để peripheral chuyển dữ liệu thay CPU

DMA chuyển dữ liệu giữa peripheral và RAM theo block, để CPU có thể làm công việc khác trong lúc chờ.
DMA, hay Direct Memory Access, cho phép peripheral chuyển dữ liệu trực tiếp với RAM theo buffer. CPU cấu hình transfer, tiếp tục làm việc khác, rồi nhận interrupt khi transfer hoàn thành hoặc đi được nửa buffer.
Không có DMA, một chuỗi ADC nhiều mẫu thường phải được làm như sau:
- Start conversion.
- Chờ conversion xong.
- Đọc data register.
- Ghi giá trị vào array.
- Lặp lại hàng trăm hoặc hàng nghìn lần.
DMA có thể tự thực hiện phần lặp đó. Đây là lý do DMA hữu ích cho ADC sampling, UART stream, SPI display, audio và các peripheral tạo dữ liệu đều đặn.
ADC + DMA theo block
Tạo một buffer:
#define ADC_BUFFER_LENGTH 64
uint16_t adc_buffer[ADC_BUFFER_LENGTH];
Trong CubeMX, bật DMA cho ADC, chọn hướng peripheral-to-memory, memory increment và circular mode nếu muốn sampling liên tục. Sau đó start:
HAL_ADC_Start_DMA(&hadc1,
(uint32_t *)adc_buffer,
ADC_BUFFER_LENGTH);
Khi đầy buffer, callback báo cho application:
volatile uint8_t adc_block_ready = 0;
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
if (hadc->Instance == ADC1)
{
adc_block_ready = 1;
}
}
Main loop xử lý block:
while (1)
{
if (adc_block_ready)
{
adc_block_ready = 0;
uint32_t sum = 0;
for (uint32_t i = 0; i < ADC_BUFFER_LENGTH; i++)
{
sum += adc_buffer[i];
}
uint32_t average = sum / ADC_BUFFER_LENGTH;
// Dùng average để điều khiển PWM hoặc gửi UART
}
}
Callback chỉ nên ghi nhận trạng thái. Đừng tính trung bình 64 mẫu, format chuỗi và gửi UART dài bên trong callback nếu không cần thiết.
Circular DMA và half-transfer
Ở circular mode, DMA quay lại đầu buffer sau khi đi hết. Với stream liên tục, bạn có thể xử lý nửa đầu khi DMA đi được nửa buffer và nửa sau khi buffer đầy:
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
if (hadc->Instance == ADC1)
{
// Xử lý hoặc đánh dấu nửa đầu buffer
}
}
Điều quan trọng là CPU phải xử lý xong một nửa trước khi DMA ghi đè lại phần đó. Nếu xử lý quá chậm, bạn sẽ gặp mất mẫu dù ADC và DMA vẫn báo đang chạy.
DMA không phải là “miễn phí”
DMA giảm số lần CPU phải copy dữ liệu, nhưng vẫn cần:
- Buffer nằm ở vùng RAM mà DMA truy cập được.
- Độ dài và data width khớp với peripheral.
- Không đọc buffer trong khi DMA đang ghi cùng vùng nếu chưa có cơ chế đồng bộ.
- Xử lý lỗi và dừng DMA đúng lúc.
- Kiểm tra cache coherency trên các dòng MCU có data cache.
Với STM32G0 và ví dụ đơn giản, cache thường không phải vấn đề chính. Nhưng thói quen nghĩ về ownership của buffer từ sớm sẽ giúp bạn tránh lỗi khi chuyển sang STM32F7, H7 hoặc hệ thống có nhiều stream hơn.
Ghép ADC, PWM và DMA
Một pipeline nhỏ có thể chạy như sau:
Timer trigger -> ADC sample -> DMA buffer -> main loop xử lý -> PWM compare
Trong demo đơn giản, bạn có thể đọc giá trị trung bình của adc_buffer, scale từ dải ADC sang dải compare của PWM rồi cập nhật CCR:
uint32_t pwm_compare = (average * PWM_PERIOD) / ADC_MAX_VALUE;
if (pwm_compare > PWM_PERIOD)
{
pwm_compare = PWM_PERIOD;
}
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, pwm_compare);
Timer trigger giúp khoảng cách giữa các mẫu đều hơn so với việc gọi ADC ngẫu nhiên trong main loop. Đây là bước đầu để đi từ một demo LED sang data logger hoặc bộ điều khiển có timing ổn định.
Lỗi dễ gặp
ADC luôn bằng 0 hoặc luôn ở mức tối đa
Kiểm tra chân đã thực sự là ADC channel chưa, biến trở có nối đúng 3V3/GND không, có GND chung không và input có bị short không. Đọc sai channel trong CubeMX cũng tạo ra triệu chứng giống như phần cứng hỏng.
ADC nhảy giá trị khi PWM chạy
Giảm tốc độ PWM hoặc đổi vị trí dây analog, cải thiện GND/decoupling, tăng sampling time và lấy trung bình nhiều mẫu. Nếu nhiễu đến từ đường hồi dòng của LED hoặc motor, xử lý layout sẽ hiệu quả hơn là chỉ thêm filter trong code.
PWM không có output
Kiểm tra đúng timer channel, alternate function của GPIO, đã gọi HAL_TIM_PWM_Start() chưa và chân output có bị peripheral khác chiếm không. Với board Nucleo, hãy chắc rằng bạn đang đo đúng chân header chứ không phải số thứ tự của connector khác.
PWM có tần số đúng nhưng duty sai
Kiểm tra Period, giá trị compare, polarity và giới hạn scaling. Pulse không phải lúc nào cũng là phần trăm; nó là số đếm so với timer.
DMA callback không chạy
Kiểm tra DMA request đúng peripheral/channel, NVIC, interrupt handler và việc gọi HAL_ADC_Start_DMA(). Nếu callback chạy một lần rồi dừng, kiểm tra mode normal/circular và cờ lỗi ADC/DMA.
Buffer DMA có dữ liệu cũ hoặc bị ghi đè
Không xử lý buffer đang được DMA ghi. Dùng half/full callback, double buffer hoặc protocol ownership rõ ràng. Đây là lỗi logic đồng bộ, không phải chỉ là lỗi kiểu dữ liệu.
Kết luận
ADC giúp STM32 đọc thế giới analog. PWM biến timer thành công cụ điều khiển công suất trung bình. DMA giúp các peripheral chuyển dữ liệu theo buffer mà CPU không phải tham gia vào từng mẫu.
Nếu mới học, hãy đi theo thứ tự: đọc một mẫu ADC bằng polling, tạo PWM cố định, ghép ADC vào PWM, rồi mới bật DMA. Mỗi bước nên được kiểm tra bằng multimeter, oscilloscope hoặc UART trước khi thêm bước tiếp theo.
Ở P5, mình sẽ ghép các phần đã học thành một ứng dụng nhỏ: timer kích hoạt sampling, DMA thu dữ liệu, UART in trạng thái và một output PWM phản hồi theo giá trị đo.
Tham khảo và mã nguồn mở
- STMicroelectronics/STM32CubeG0 — firmware package chính thức gồm HAL, LL, BSP và các example theo peripheral.
- STM32CubeG0 Projects list — danh sách các example ADC, TIM, DMA và project theo board.
- Getting started with STM32CubeG0 — tài liệu về cấu trúc package và cách dùng các kiểu transfer.
- 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 - 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.
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.