← 返回文章库

STM32 HAL 常用外设速通:GPIO / 定时器 PWM / ADC / 串口

最后更新 2026-06-22
⏱ 约 22 分钟 🟢 软件/低风险
你将学到
  • 用 HAL_GPIO_WritePin / ReadPin / TogglePin 收发数字电平,接上上一篇的点灯
  • 在 CubeMX 把 TIM 配成 PWM,用 HAL_TIM_PWM_Start + __HAL_TIM_SET_COMPARE 调占空比
  • 配 ADC 通道,用 HAL_ADC_Start + PollForConversion + GetValue 读一路模拟量
  • 配 UART,用 HAL_UART_Transmit 发、HAL_UART_Receive_IT 中断收,并重定向 printf
  • 每个外设拿一条 ESP-IDF ↔ STM32 对照,把 ESP32-S3 经验平移到 STM32

上一篇你用 CubeMX + HAL 点亮了第一个 LED,走通了"选芯片 → 配引脚 → 配时钟 → GENERATE → 烧录"这条闭环。但点灯只用到一个 GPIO 输出,STM32 真要干活,你迟早得碰另外三样:用定时器输出 PWM 调个亮度/转个电机、用 ADC 读个电位器/光敏电阻、用串口把数据打到电脑上看。这四个外设——GPIO、定时器 PWM、ADC、UART——是嵌入式里出镜率最高的一组,把它们摸熟,你就能在 STM32 上搭出绝大多数小白项目了。

这一篇不重复 CubeMX 的基础操作(那篇讲过了),而是聚焦每个外设的 CubeMX 配置要点 + HAL 怎么调 + 它对应你熟的 ESP-IDF 哪个 API。你会发现:HAL 的外设用法都是同一个套路——CubeMX 里配好、拿到一个句柄(htimx/hadcx/huartx)、调一组 HAL_xxx 函数。认准这个套路,四个外设就是同一件事的四个变体。

📌 说明

还是那句丑话,得说在每篇 S 卷代码课的前头:STM32 的 HAL 代码和你具体用的芯片型号、HAL 库版本、CubeMX 版本强相关。 本篇给的是主干流程参考——句柄名(htim2/hadc1/huart1)、通道宏(TIM_CHANNEL_1)、引脚号、外设实例号,请以**你自己用 CubeMX 实际生成出来的工程 + ST 官方 HAL 文档(UM1725 这类)**为准。尤其是 ADC 的分辨率(F1 是 12 位、有的系列不同)、定时器的时钟来源(APB 倍频规则各系列不一),照搬别人帖子里的数值经常对不上。这正是该让 AI copilot 帮你抹平 HAL 样板、你来核对句柄和参数的场景。本篇不把任何一行 HAL 代码呈现为"确定无误"。

读这篇前,你最好已经跟着上一篇用 CubeMX 生成过一个能点灯的工程——知道 USER CODE 区的规矩、知道句柄是 CubeMX 生成的。本篇所有代码默认你写在 /* USER CODE BEGIN x */ 配对区里。


外设一:GPIO——数字进出,你已经会了一半

GPIO 上一篇点灯时已经打过照面,这里快速收个尾,把"写"和"读"都补齐,后面三个外设的句柄套路也好对比。

GPIO 三个最常用的 HAL 调用:

/* USER CODE BEGIN WHILE */
while (1)
{
  HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET);    // 拉高
  HAL_Delay(200);
  HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_RESET);  // 拉低
  HAL_Delay(200);

  // 翻转(点灯最省事)
  HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);

  // 读一个输入脚(比如按键 PA0)
  if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET)
  {
    // PA0 为低,按下了(假设接了上拉)
  }
  /* USER CODE END WHILE */
}

GPIO 不用句柄,直接传 GPIOx + GPIO_PIN_y——因为它是最底层的外设,CubeMX 里把引脚点成 GPIO_Output / GPIO_Input 就够了,不生成单独的 hgpio 句柄。记住:GPIO 的方向(输入/输出)、上下拉,都在 CubeMX 里配,不在代码里。

ESP-IDF ↔ STM32 对照: gpio_set_level(GPIO_NUM_2, 1)HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET);gpio_get_level()HAL_GPIO_ReadPin();翻转 ESP-IDF 没现成的,STM32 直接给了 HAL_GPIO_TogglePin


外设二:定时器 PWM——调亮度、转电机、发舵机信号

光数字开关不够用,你常需要输出一个占空比可调的方波:调 LED 亮度、控电机转速、给舵机发角度信号——这些都是 PWM。STM32 用定时器(TIM) 的某个通道来产生 PWM。

CubeMX 配置要点

  1. 在左侧外设树里选一个定时器(比如 TIM2),把它的某个 Channel(通道) 的 Mode 设成 PWM Generation CHx。这一步把这个定时器通道绑到了一个能输出 PWM 的引脚上(CubeMX 引脚图会自动高亮)。
  2. 配两个决定 PWM 频率的关键参数(在该 TIM 的 Parameter Settings 里):
    • Prescaler(预分频,PSC):把定时器时钟先分频。
    • Counter Period(自动重装值,ARR / Period):计数到这个值算一个 PWM 周期。
    • PWM 频率 = 定时器时钟 / (PSC+1) / (ARR+1)。举例:定时器时钟 72MHz,PSC 填 71、ARR 填 999,则频率 = 72_000_000 / 72 / 1000 = 1kHz;而占空比的分辨率就是 0~999(对应 ARR)。
  3. GENERATE。CubeMX 会给你一个 htim2 句柄,并生成 MX_TIM2_Init()
🚧 避坑

定时器时钟不一定等于你时钟树里看到的 HCLK。 STM32 的定时器挂在 APB1/APB2 总线上,当 APB 分频系数 ≠ 1 时,定时器时钟会被自动 ×2(这是 STM32 的硬件规则,不是 bug)。所以你算 PWM 频率前,先去 CubeMX 时钟树页看清楚这个 TIM 实际跑多少 MHz,别拿 HCLK 直接套。算出来频率不对,十有八九栽在这。

HAL 调用

PWM 比 GPIO 多一步:得先 Start,才会输出。然后用一个宏改占空比:

/* USER CODE BEGIN 2 */
HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);   // 启动 TIM2 通道1 的 PWM 输出,不调它一直不出波
/* USER CODE END 2 */

/* USER CODE BEGIN WHILE */
while (1)
{
  // 呼吸灯:占空比从 0 升到 ARR 再降回来
  for (uint16_t duty = 0; duty < 1000; duty += 10)
  {
    __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, duty);  // 设比较值=占空比,范围 0~ARR
    HAL_Delay(10);
  }
  for (uint16_t duty = 1000; duty > 0; duty -= 10)
  {
    __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, duty);
    HAL_Delay(10);
  }
  /* USER CODE END WHILE */
}

两个关键 API 记牢:HAL_TIM_PWM_Start(&htim, 通道) 把这路 PWM 打开(只调一次,放初始化区);__HAL_TIM_SET_COMPARE(&htim, 通道, 比较值) 实时改占空比——比较值从 0(0%)到 ARR(100%),这是个(带双下划线前缀),直接改寄存器,效率高、可在循环里频繁调。

你应该看到什么

把 PWM 输出脚接到 LED(串个限流电阻),烧进去后 LED 由灭逐渐变亮、再逐渐变灭,像呼吸一样循环——而不是上一篇那种生硬的一明一暗。如果接示波器/逻辑分析仪,能看到一路频率固定、占空比随 duty 连续变化的方波。

ESP-IDF ↔ STM32 对照: ESP-IDF 调亮度用 LEDC(ledc_set_duty + ledc_update_duty),控电机/对称波形用 MCPWM;STM32 统一用定时器通道做 PWM(HAL_TIM_PWM_Start + __HAL_TIM_SET_COMPARE)。差异:ESP-IDF 的 LEDC 你直接填"占空比分辨率位数 + 频率 Hz",HAL 这边得你自己用 PSC/ARR 反推频率、占空比上限就是 ARR——STM32 把分频算式摊给了你,这跟它点灯时把时钟树摊给你是一个脾气。


外设三:ADC——把模拟量读成数字

电位器、光敏电阻、热敏电阻、电池电压……这些都是连续变化的模拟量,要读进 MCU 就得过 ADC(模数转换器),把电压转成一个整数。STM32 的 ADC 最简单的用法是软件触发、轮询(polling) 读一次。

CubeMX 配置要点

  1. 在外设树里选 ADC1,勾上你要用的那个通道(比如 IN0,对应某个固定引脚,CubeMX 引脚图会高亮)。
  2. 看一眼分辨率:STM32F1 的 ADC 是 12 位,即读数范围 0~4095(对应 0~参考电压,蓝丸通常 3.3V)。有的系列可选 12/10/8/6 位,以你的芯片为准。
  3. 采样时间(Sampling Time)按需调——电阻越大的信号源(如热敏电阻分压)要给越长的采样时间,否则读数不稳。新手先用默认值。
  4. GENERATE,拿到 hadc1 句柄和 MX_ADC1_Init()

HAL 调用(轮询法)

轮询读一次 ADC 是固定的"三步走":Start → 等转换完 → 取值

/* USER CODE BEGIN WHILE */
while (1)
{
  HAL_ADC_Start(&hadc1);                                  // 1. 启动一次转换
  HAL_ADC_PollForConversion(&hadc1, 100);                 // 2. 阻塞等转换完(末尾 100 是超时 ms)
  uint32_t raw = HAL_ADC_GetValue(&hadc1);                // 3. 取原始读数(F1 是 0~4095)

  // 换算成电压(假设参考 3.3V、12 位):
  float voltage = raw * 3.3f / 4095.0f;
  (void)voltage;  // 这里你可以拿去串口打印或做判断

  HAL_Delay(200);
  /* USER CODE END WHILE */
}

三个 API 对应三步:HAL_ADC_Start 触发一次转换;HAL_ADC_PollForConversion(&hadc, 超时) 阻塞等到转换完成(轮询的"轮"就在这,它内部死等转换完成标志);HAL_ADC_GetValue 把转换结果取出来。拿到的是原始整数,换算成电压自己乘:raw × 参考电压 ÷ (2^分辨率 - 1)

你应该看到什么

把一个电位器的中间脚接到 ADC 通道引脚(两端接 3V3 和 GND),配合下面的串口打印,旋转电位器,打印出的 raw 值应在 04095 之间平滑变化、voltage 在 03.3V 之间走。如果读数乱跳或卡死在 0/4095——查接线、查通道选对没、查采样时间够不够。

🚧 避坑

ADC 引脚别配成普通 GPIO,要在 CubeMX 里配成对应的 ADC 通道(Analog 模式)。 还有个新手坑:HAL_ADC_PollForConversion 的超时设太小(比如填 1ms),长采样时间还没转换完就超时返回,你会读到上一次的旧值或乱值。先给足超时(100ms 绰绰有余),稳了再优化。

ESP-IDF ↔ STM32 对照: ESP-IDF(新版驱动)读一次 ADC 用 adc_oneshot_read(handle, channel, &raw),一行拿到原始值;STM32 HAL 拆成 Start + PollForConversion + GetValue 三步。本质一样:都是触发转换、等完成、取整数值。ESP-IDF 还有现成的 adc_cali_* 帮你做电压校准,STM32 这边的换算/校准多半得你自己写。


外设四:UART 串口——把数据打到电脑上看

STM32 上你最想念 ESP32 的就是 ESP_LOGI——HAL 没有现成日志,想看数据得自己用 UART 把字符打出去。串口也是 STM32 最常用的调试和通信手段,这一节讲发(轮询)、收(中断)、以及重定向 printf。

CubeMX 配置要点

  1. 选一个 USART(比如 USART1,蓝丸常用 PA9=TX / PA10=RX),Mode 设成 Asynchronous(异步)
  2. 波特率填 115200(最常用),数据位 8、停止位 1、无校验(8N1)——和你电脑串口助手设的对上即可。
  3. 想用中断接收的话,去 NVIC Settings 把这个 USART 的全局中断勾上(Enabled)。
  4. GENERATE,拿到 huart1 句柄。

发:HAL_UART_Transmit(轮询)

最直接的发法,阻塞把一段字节发出去:

/* USER CODE BEGIN WHILE */
char msg[] = "hello from STM32\r\n";
while (1)
{
  // 参数:句柄、数据指针、长度、超时(ms)
  HAL_UART_Transmit(&huart1, (uint8_t *)msg, sizeof(msg) - 1, 100);
  HAL_Delay(1000);
  /* USER CODE END WHILE */
}

sizeof(msg) - 1 是去掉字符串结尾的 \0 只发有效字符;末尾 100 是发送超时毫秒数。

收:HAL_UART_Receive_IT(中断,不阻塞)

轮询接收会卡住主循环,实际项目里收数据用中断:你先"挂"一次接收请求,收到指定字节数后 HAL 自动回调你的函数,主循环该干嘛干嘛。

/* USER CODE BEGIN 2 */
uint8_t rx_byte;
HAL_UART_Receive_IT(&huart1, &rx_byte, 1);  // 挂上"收 1 个字节"的中断请求
/* USER CODE END 2 */

/* USER CODE BEGIN 4 */
// 收满 1 字节后 HAL 自动调这个回调(在你某个 .c 里实现它)
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
  if (huart->Instance == USART1)
  {
    HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);     // 收到一个字节就翻下灯,证明收到了
    HAL_UART_Receive_IT(&huart1, &rx_byte, 1);  // 关键:再挂一次,否则只触发一次就没了
  }
}
/* USER CODE END 4 */
🚧 避坑

中断接收最高频的坑:HAL_UART_RxCpltCallback 里必须再调一次 HAL_UART_Receive_IT 重新挂请求,否则中断只触发一次,之后再发数据就没反应了。 HAL 的 _IT 接收是"一次性"的——收满指定字节、回调完就关了,你不在回调里续上,接收就停了。新手十有八九栽在这,表现为"第一个字符收到了,后面全丢"。

重定向 printf(因工具链而异)

想直接 printf("raw=%lu\r\n", raw),得把 C 库的底层输出指到 UART——GCC 重定向 _write、Keil/ARMCC 重定向 fputc:

/* GCC 工具链(STM32CubeIDE):放在 USER CODE 区或单独 .c */
int _write(int fd, char *ptr, int len)
{
  HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, 100);
  return len;
}
/* 之后就能 printf("raw=%lu, v=%.2f\r\n", raw, voltage); 了 */
📌 说明

printf 重定向的函数名(_write vs fputc)、要不要 setvbuf 关行缓冲、Keil 还要勾 Use MicroLIB——这几样强依赖你的工具链和 HAL 版本,上面是 GCC 路线的参考写法,以你工程实际工具链 + ST 文档为准

你应该看到什么

USART 的 TX/RX 接个 USB 转 TTL 到电脑(TX→RX 交叉接、共地),串口助手设 115200/8N1:发那段,助手里每秒打印一行 hello from STM32;配好中断接收后,你从助手往板子发一个字符,板载 LED 翻一下——证明双向都通了。

ESP-IDF ↔ STM32 对照: ESP-IDF 串口用 uart_driver_install + uart_write_bytes / uart_read_bytes,且日志直接有 ESP_LOGI;STM32 HAL 发用 HAL_UART_Transmit、收用 HAL_UART_Receive_IT(中断)或 HAL_UART_Receive(轮询),日志得自己重定向 printf。差异:ESP-IDF 的 log 开箱即用,STM32 这件事要你搭一次 UART——这是从 ESP32 转 STM32 第一个不适应的地方,搭好后体验就和 ESP_LOGI 差不多。


四外设速查:CubeMX 配什么 + HAL 调什么 + 对照 ESP-IDF

把全篇浓缩成一张表,迁移时照着查:

外设 CubeMX 里配 HAL 核心调用 对应的 ESP-IDF
GPIO 引脚点成 Output / Input HAL_GPIO_WritePin / ReadPin / TogglePin gpio_set_level / gpio_get_level
定时器 PWM TIM 通道设 PWM Generation,配 PSC/ARR HAL_TIM_PWM_Start + __HAL_TIM_SET_COMPARE LEDC(ledc_set_duty)/ MCPWM
ADC 勾通道、配分辨率/采样时间 HAL_ADC_Start + PollForConversion + GetValue adc_oneshot_read
UART 选 USART、Async、115200、(收)开 NVIC HAL_UART_Transmit / HAL_UART_Receive_IT uart_write_bytes / uart_read_bytes + ESP_LOGI

最值得记的三条规律:①HAL 外设都靠一个句柄(htimx/hadcx/huartx),配置在 CubeMX、动作在 HAL_xxx;②带 __HAL_ 前缀的是宏(直接操寄存器,如 __HAL_TIM_SET_COMPARE),不带的是函数;③带 _IT 后缀的是中断版(非阻塞),普通版多是阻塞/轮询。 认准这三条,以后碰 SPI/I2C 也是同一套路。


故障排查:四外设常见翻车点

现象 最可能的原因 怎么办
PWM 没输出(LED 不亮/不呼吸) 忘了调 HAL_TIM_PWM_Start 启动后才会出波;Start 放初始化区,只调一次
PWM 频率算出来不对 拿 HCLK 当定时器时钟,漏了 APB ×2 规则 去 CubeMX 时钟树看该 TIM 实际频率,再套 PSC/ARR 算式
占空比改不动 __HAL_TIM_SET_COMPARE 比较值超过了 ARR 比较值范围是 0~ARR,别超
ADC 读数乱跳或恒为 0/4095 引脚没配成 Analog / 通道选错 / 采样时间太短 CubeMX 里确认是 ADC 通道、Analog 模式;加大采样时间
ADC PollForConversion 老超时 超时设太小,转换还没完 超时给足(100ms);确认 ADC 时钟在合理范围
串口发出来全是乱码 波特率/时钟不匹配,或时钟树没配对 串口助手和 CubeMX 波特率对齐;确认主频配通(乱码常因主频不对)
串口中断只收到第一个字符 回调里没重新调 HAL_UART_Receive_IT HAL_UART_RxCpltCallback 末尾再挂一次接收
编译报找不到 HAL_TIM_* / HAL_ADC_* 对应外设没在 CubeMX 勾选生成 回 CubeMX 启用该外设、重新 GENERATE
🚧 避坑

"配了外设却忘了 Start / 忘了重新挂中断"是这四个外设最高频的两类翻车。 PWM 配好不 HAL_TIM_PWM_Start 就没波;中断接收回调里不再 HAL_UART_Receive_IT 就只收一次。这俩都不报错,只是"没反应",最难查。记住:带输出/接收动作的外设,启动和续命这两步别漏。


变体:用中断接收一整行命令

上面中断只收 1 个字节。实际项目常要收一整行命令(比如电脑发 LED ON\n)。思路:中断里逐字节存进缓冲,遇到换行符 \n 就标记"收到一整行",主循环再处理。

/* USER CODE BEGIN PV */
uint8_t rx_byte;
char     line_buf[64];
uint8_t  line_idx = 0;
volatile uint8_t line_ready = 0;   // volatile:中断里改、主循环读,必须加
/* USER CODE END PV */

/* USER CODE BEGIN 4 */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
  if (huart->Instance == USART1)
  {
    if (rx_byte == '\n' || line_idx >= sizeof(line_buf) - 1)
    {
      line_buf[line_idx] = '\0';   // 收满一行,补结束符
      line_ready = 1;              // 通知主循环
      line_idx = 0;
    }
    else
    {
      line_buf[line_idx++] = rx_byte;
    }
    HAL_UART_Receive_IT(&huart1, &rx_byte, 1);  // 续上下一次接收
  }
}
/* USER CODE END 4 */

主循环里 if (line_ready) { ... 解析 line_buf ...; line_ready = 0; }。注意 line_ready 加了 volatile——它在中断里被改、主循环里被读,不加编译器可能优化掉,主循环永远看不到它变 1。这个细节是中断编程的通用功课,后面跑 FreeRTOS 还会反复遇到。


动手挑战

把四个外设串成一个能跑的小项目,你这一卷的硬件能力就齐活了:做一个"电位器调 LED 亮度 + 串口实时打印 ADC 值 + 串口发命令开关呼吸效果"的工程

  1. CubeMX 里配齐:一路 ADC(接电位器)、一路定时器 PWM(接 LED)、一个 USART(115200,开中断接收),时钟树配通别标红。GENERATE。
  2. 主循环里:HAL_ADC_Start/PollForConversion/GetValue 读电位器 → 把 raw(04095)映射到 PWM 比较值(0ARR)→ __HAL_TIM_SET_COMPARE 设进去。旋转电位器,LED 亮度跟着变。
  3. 重定向 printf,每 200ms printf("raw=%lu, duty=%lu\r\n", raw, duty),串口助手里看数随旋钮变。
  4. 中断接收一行命令:收到 BREATHE 就让 LED 自动呼吸(忽略电位器),收到 MANUAL 就切回电位器调光。亲手验证"回调里重新挂接收"这一步——故意删掉那行,看是不是只能发一次命令。
  5. 卡住了,把 CubeMX 的外设配置、时钟树截图、main.c 的 USER CODE 段、串口现象一起发给 AI,讲清"配了什么、期望什么、实际什么"——HAL 外设样板多,这种活让 AI copilot 帮你抹平、你核对句柄和参数最划算。

做完这套,GPIO / PWM / ADC / UART 四件套你就用顺了,STM32 上的小白项目基本都能搭出来。


小结 · 你现在掌握了什么

  • 你摸通了 STM32 HAL 外设的统一套路:CubeMX 里配好 → 拿一个句柄(htimx/hadcx/huartx)→ 调一组 HAL_xxx;__HAL_ 前缀是宏、_IT 后缀是中断版。
  • GPIO:HAL_GPIO_WritePin / ReadPin / TogglePin,方向在 CubeMX 配,对照 gpio_set_level / gpio_get_level
  • 定时器 PWM:CubeMX 把 TIM 通道设 PWM、配 PSC/ARR 定频率,HAL_TIM_PWM_Start 开、__HAL_TIM_SET_COMPARE 调占空比;对照 ESP-IDF 的 LEDC / MCPWM——区别是 STM32 把分频算式摊给你。
  • ADC:CubeMX 勾通道,HAL_ADC_Start + PollForConversion + GetValue 三步轮询读;对照 adc_oneshot_read 一行——STM32 拆成三步、校准多半自己来。
  • UART:HAL_UART_Transmit 发、HAL_UART_Receive_IT 中断收(回调里必须重新挂)、重定向 printf 补上 ESP_LOGI;对照 uart_write_bytes / uart_read_bytes
  • 你记住了几个真坑:PWM 忘了 Start 没波、PWM 频率漏算 APB×2、ADC 引脚没配 Analog、串口中断回调忘了重新挂接收、volatile 漏加
  • 最重要的一条:本篇 HAL 代码是主干流程参考,句柄名/通道宏/分辨率/分频规则强依赖你的芯片型号与 HAL 版本,以 CubeMX 实际生成的工程 + ST 官方 HAL 文档为准,别当成确定无误。

四大常用外设拿下,接下来这一卷要上真正的硬骨头——让 STM32 跑 FreeRTOS,从"一个 while(1) 死循环"升级到"多任务并发调度",这才贴近你在 ESP32 上习惯的那套 RTOS 思路。接着看 STM32 上跑 FreeRTOS,把单片机的多任务能力补齐。想回看整条迁移路线,去 STM32 迁移卷总览

📄 来源 / 自校链接

本文为公开资料整理,非亲测。关键参数与代码请结合实物与下列官方来源验证。

内容有错、看不懂、或想看下一期?告诉我们 →

本文为公开资料的学习整理,非亲测。涉接线/花钱/合规的步骤请结合实物与官方最新资料验证,风险自负。见免责声明