STM32寄存器编程实战:串口、定时器、ADC与DWT全解析

发布时间:2026/10/26 9:34:08
STM32寄存器编程实战:串口、定时器、ADC与DWT全解析

搞 STM32 寄存器编程,最怕两件事:一是不知道配哪个寄存器,二是不知道寄存器里填什么值。上一章咱们过完了 GPIO 点灯,这一章继续往深水区走,把串口、定时器、ADC 和 DWT 这几块最常用的外设,用大白话一个个拆开讲。你要是已经能编译下载一个直接操作寄存器的 GPIO 翻转工程,这章就能直接跟下来;如果还没点过灯,先把上一章的例子跑通再往下看,因为后面所有操作都建立在“直接读写寄存器地址”这个基础上。

为什么要强调寄存器版?HAL 库和标准库帮你把寄存器包装成了函数,用起来确实快,但一碰到跑飞、死锁、通信乱码这类问题,不懂寄存器,连从哪里下手都没方向。我自己这些年排查嵌入式 bug,绝大多数最后都落在“某个位配置得不对”或者“某个外设时钟没使能”上。这些直接翻开参考手册的寄存器描述就能救回来,前提是你得习惯这套白话手册的玩法。

1. 动手之前,先把寄存器操作的三板斧练熟

1.1 地址从哪来:外设基地址、总线基地址和偏移量

STM32 内部的外设不是散落在天上,而是挂在不同总线上。以最常见的 STM32F103 为例,GPIOA、USART1、ADC1 这些东西挂在 APB2 总线上,TIM2、TIM3、USART2 这些挂在 APB1 总线上,RCC 时钟控制单元在 AHB/APB 交汇处。手册里会给出一个“存储器映射”表,你只要按“总线基地址 + 外设偏移量”去加,就能得到外设的基地址。

我记得第一次看这张表时也懵,结果其实就是小学数学加法。APB2 基地址是0x40010000,GPIOA 的偏移量是0x0800,所以 GPIOA 基地址是0x40010800。USART1 基地址是0x40013800,ADC1 基地址是0x40012400。APB1 基地址是0x40000000,TIM2 偏移量是0x0000,所以 TIM2 基地址就是0x40000000。不要把这些数值背下来,但要会查。

实际写代码时,我不会直接写*(unsigned int *)0x40010804 = 0x...,那样满屏魔法数字,看三天都不知道在操作什么。正确做法是用宏先把基地址和寄存器偏移量封一层,后面代码可读性和可维护性都会高很多。

1.2 寄存器描述怎么看:位域、复位值、读写属性

打开参考手册,随便找一个外设寄存器,描述都长得很像:左侧是位序号,右侧是位域名字、读写属性、复位值。真正要抓住的无非四个要素:位域范围、位域含义、读写属性、复位值。

以 F1 系列最常用的 GPIOx_CRH 为例,这个寄存器负责引脚 8 到 15 的配置,每个引脚占 4 个 bit。这 4 个 bit 分成 MODE 和 CNF 两半:MODE[1:0] 决定速度/输入输出方向,CNF[1:0] 决定具体工作模式。就像填表一样,你把对应引脚的那 4 个 bit 按手册要求填进去,引脚的行为就确定了。

这里特别要强调复位值。GPIOx_CRH 复位值是0x44444444,也就是说每一位的 CNF 为01、MODE 为00,对应浮空输入模式。很多人直接往寄存器里“或”一个值,结果发现未知位也被改掉了。安全写法一定是先把自己要改的位段清零,再写入目标值,尤其是有多引脚共用同一个寄存器的时候。

F4 系列就更典型了,GPIOx_MODER 的复位值可能是 0,你要把某个引脚配置成复用功能,只需要找到对应的 2 bit 位段,先把位段清 0,再置成10。这套“读-改-写”思路是寄存器编程的基本功,后面每节都会用到。

1.3 自己封装寄存器指针宏,别再满屏魔法数

因为实际项目中各个芯片的头文件很大,很多朋友喜欢开始就用 ST 官方头文件里的GPIOA->CRH。这没问题,那本来就是 CMSIS 对寄存器的标准封装。但你要是想彻底搞懂底层,建议自己也手动定义一份最精简的结构体或指针宏。

一个很实用的宏风格是:

#define GPIOA_BASE (0x40010800) #define GPIOA_CRL (*(volatile unsigned int *)(GPIOA_BASE + 0x00)) #define GPIOA_CRH (*(volatile unsigned int *)(GPIOA_BASE + 0x04)) #define GPIOA_ODR (*(volatile unsigned int *)(GPIOA_BASE + 0x0C))

这里volatile关键字不能少。嵌入式中,外设寄存器值会被硬件随时改变,如果漏了volatile,编译器可能把读取操作优化掉,导致你明明在循环里等一个标志位,程序却永远跳不出去。我自己踩过这个坑,加了volatile后一切正常。

再配合一个段位操作宏:

#define BITS_MASK(start, len) (((1 << (len)) - 1) << (start)) #define REG_SET_BITS(reg, start, len, val) \ (reg) = ((reg) & ~BITS_MASK(start, len)) | (((val) & ((1 << (len)) - 1)) << (start))

这样封装后,你写配置代码时会逐渐习惯“查地址、找位段、算掩码”的套路。等你不靠复制粘贴库函数也能独立写出一个完整的外设驱动,就真的毕业了。

2. 串口:寄存器版 USART 收发和 printf 重定向

2.1 使能时钟、配置引脚复用,一个都不能少

串口是我们调试嵌入式程序的第一双眼睛。以 USART1 为例,它在 APB2 总线上,引脚默认是 PA9 和 PA10。很多人配置完 USART 寄存器发现没数据,九成原因是前面两步漏了:没使能 GPIOA 时钟、没使能 USART1 时钟。

我习惯先把这部分写成清晰的顺序:

RCC->APB2ENR |= (1U << 14) | (1U << 2); // 使能 USART1 和 GPIOA 时钟 GPIOA->CRH &= ~(0xFFU << 4); // 清掉 PA9、PA10 的配置位 GPIOA->CRH |= (0x3U << 4); // PA9 推挽复用输出,50MHz GPIOA->CRH |= (0x4U << 8); // PA10 浮空输入

第 1 行使能外设时钟;第 2 行把引脚对应位清干净;第 3、4 行分别设 TX 和 RX。F4 系列用的是MODER、AFRH、PUPDR这套寄存器,但思路一样,先把对应位清零,再填入复用功能编号。这里易犯的一个错误是只配了 TX,忘了 RX,或者忘记清引脚原有配置,导致波形不对。

2.2 波特率寄存器 BRR:这里有个反直觉的点

波特率配置是串口最经典的坑。参考手册写的是USARTDIV = PCLK / (16 * Baud),但实际写入 BRR 寄存器的值,其实是这个USARTDIV再乘以 16 后的结果。换句话说,你可以直接用一个很简单的式子:

USART1->BRR = 72000000 / 9600;

你可能会问,手册里的公式不是要除以 16 吗?这里的关键在于 BRR 的低 4 位是小数部分,寄存器整体看作mantissa * 16 + fraction,所以最终要写入的数已经是PCLK / Baud。举个例子,72MHz 时钟、9600 波特率时,USARTDIV = 46.875,BRR 寄存器值应为46 * 16 + 14 = 7500,而72000000 / 9600恰好等于 7500。这个反直觉点搞明白了,串口乱码至少能少一半。

实际项目里我不会把时钟频率写成硬编码,会用系统时钟宏:

USART1->BRR = SystemCoreClock / 115200;

如果你的芯片在 72MHz 下系统时钟SystemCoreClock等于 72000000,这行代码直接生效。注意,F1 的 USART1 挂在 APB2,时钟最高 72MHz,别拿 APB1 的 36MHz 去算。

2.3 轮询发送接收和 printf 重定向

USART 发一个字节,经典流程是等待发送数据寄存器空(TXE),再写 DR;如果要确保数据真正全部移出发送移位寄存器,还要等传输完成(TC)。很多人在关串口或者切换 GPIO 时丢掉最后一帧,就是因为只等了 TXE 没等 TC。我自己的发送函数会这样写:

void uart_send_byte(USART_TypeDef *USARTx, uint8_t ch) { while (!(USARTx->SR & (1U << 7))); // TXE USARTx->DR = ch; while (!(USARTx->SR & (1U << 6))); // TC }

接收一个字节就等RXNE位,然后读 DR:

uint8_t uart_recv_byte(USART_TypeDef *USARTx) { while (!(USARTx->SR & (1U << 5))); // RXNE return (uint8_t)USARTx->DR; }

读 DR 会自动清除RXNE,不用额外手动清零,这是数据手册明确写的。

想在程序里用printf直接往串口打数据,最常见办法就是重定向fputc。Keil 环境下勾选 MicroLib 后,写一个:

int fputc(int ch, FILE *f) { uart_send_byte(USART1, (uint8_t)ch); return ch; }

GCC 环境下则要重写_write函数,或者改 syscall 文件。搞通这层后,printf("adc=%d\r\n", value)就能直接往串口丢数据。这对调试 PID、传感器数据、打印各种外设状态非常方便,也是我推荐每个工程必备的基础设施。

2.4 从串口到蓝牙、485、伺服电机的扩展思路

串口寄存器打通之后,蓝牙模块、RS485 收发器、伺服电机驱动器,本质上都是同一套 USART 收发逻辑加外围硬件差异。比如 HC-05 蓝牙模块就是把 TX/RX 直接交叉接在 USART2 上,你照样用寄存器配置 USART2,剩下的只是波特率约定。RS485 多一根方向控制引脚,用 GPIO 控制收发器的 DE/RE 引脚电平就行,切换时机一般在发送前拉高、发送完成后拉低。

之前调试一个伺服电机项目,PC 端通过串口助手连续发角度指令,电机驱动器这边用的是 RS485。核心还是查手册配波特率,但需要在发送完最后一帧后等待 TC 置位,再拉低方向引脚,否则最后一帧数据会被截断。这个坑在寄存器模式下特别明显,因为 TC 标志不是藏着掖着的,USARTx->SR里清清楚楚写在那里。

3. 定时器:PWM 和输入捕获,一次把寄存器讲透

3.1 时基单元:PSC、ARR、CNT 三个值决定一切

定时器刚上手时有一种“寄存器很多很吓人”的错觉,其实核心就三个寄存器:预分频器 PSC、自动重载值 ARR、计数器 CNT。PSC 把定时器输入时钟分频,CNT 在每个计数脉冲到来时加一,当 CNT 达到 ARR 时就产生一次更新事件,然后 CNT 清零重新开始。

输出频率的实用公式就一个:

更新频率 = TIM_CLK / ((PSC + 1) * (ARR + 1))

F103 的 TIM2 挂 APB1 总线上,APB1 如果分频为 2,定时器时钟会自动倍频到 72MHz。想产生 10kHz 的 PWM,且计数分辨率够用,我通常会选PSC = 71,那么TIM_CLK / (PSC + 1) = 72MHz / 72 = 1MHz,再取ARR = 99,更新频率正好是 1MHz / 100 = 10kHz。

要特别注意 PSC、ARR 都有影子寄存器,写入后不会立刻生效,需要产生一次更新事件。所以你会在例程里看到:

TIM2->PSC = 71; TIM2->ARR = 99; TIM2->EGR = 0x01; // UG 位置位,产生更新事件

这行EGR老有人忘,结果写进去的参数一直不生效,查来查去结果在这里。

3.2 PWM 输出:CCMR 和 CCER 怎么配合

PWM 的核心思想是拿 CNT 和 CCR 做比较。CNT 小于 CCR 时输出一种电平,大于等于 CCR 时翻转成另一种电平。以 TIM2_CH1 输出 PWM,代码可以这样配:

RCC->APB1ENR |= (1U << 0); // 打开 TIM2 时钟 TIM2->PSC = 71; // 分频后 1MHz TIM2->ARR = 99; // 10kHz TIM2->CCMR1 = 0x68; // 通道1 PWM模式1,预装载使能 TIM2->CCER = 0x01; // 使能输出通道1 TIM2->CCR1 = 50; // 占空比 50% TIM2->EGR = 0x01; // 触发更新 TIM2->CR1 = 0x01; // 启动计数器

这里CCMR1的0x68不是随便填的:OC1M[6:4] 三个位要填入110表示 PWM 模式 1,OC1PE 第 3 位要写 1 开启预装载,所以值就是0x68。懂了这个位段,以后想切换通道 2、改模式、加死区,都能直接从寄存器描述里反推出来。

有个坑必须单独提醒:F4 系列的 TIM1 和 TIM8 是高级定时器,除了上面的配置,还要额外置位 TIMx->BDTR 的最高位 MOE,否则主输出通道永远没有波形。我把这个记成“高级定时器有三把锁:时基更新、通道使能、主输出使能”。少一把锁,波形就出不来。

给 LED 做“呼吸灯”或者做“PWM 模拟测试”时,我建议不要只盯 CCS 代码,而是拿示波器或者逻辑分析仪直接看引脚波形。没有示波器就用一个 LED 串联电阻,把占空比从 1 调到 99,观察亮度变化,顺便也能验证寄存器配置有没有真正生效。

3.3 输入捕获测频率:读取 CCR 值比写输出简单多了

测外部脉冲频率是定时器另一个高频应用。原理更简单:把引脚配置成输入模式,定时器检测到上升沿或下降沿时,自动把当前 CNT 值锁存到 CCR 寄存器。程序中只需要读两次 CCR,做差就是周期,再取倒数就是频率。

以 TIM2_CH1 捕获 PA0 输入为例,配置核心就两行:

TIM2->CCMR1 = 0x01; // 通道1 映射到 TI1,输入捕获 TIM2->CCER = 0x01; // 使能捕获,默认上升沿触发

测量代码用无符号减法可以自动处理溢出,不用自己操心计数器回绕:

uint32_t t1 = TIM2->CCR1; uint32_t t2; uint32_t period; while (1) { while ((TIM2->SR & (1U << 1)) == 0); // 等 CC1IF t2 = TIM2->CCR1; period = t2 - t1; t1 = t2; TIM2->SR &= ~(1U << 1); // 清标志,否则下次永远进入 // freq = TIM_CLK / period }

这里要顺手清CC1IF标志,不然下一次捕获事件来了,标志位还是 1,循环直接穿过去。另外,如果测的是低频信号,最好给 CCER 的 IC1PSC 加个分频,或者用 PWM 输入模式,否则捕获寄存器可能会被同一电平边沿反复触发。这个外设在实践中特别能锻炼“读寄存器再反推逻辑”的能力,因为你不是在调“现象”,而是在看“数字”。

4. ADC 多通道切换:从“反复重启”到“扫描+DMA”

4.1 ADC 基本配置:采样时间、规则通道和启动转换

ADC 寄存器配置比定时器稍绕,但它有章法:先提起 ADC 时钟,再把模拟输入引脚配好,接着选择通道和采样时间,最后触发转换并等待 EOC 标志。

拿 F103 的 ADC1 采集 PA1 举例,PA1 对应 ADC 通道 1,配置代码大致是:

RCC->APB2ENR |= (1U << 9) | (1U << 2); // ADC1 和 GPIOA 时钟 GPIOA->CRL &= ~(0x0F << 4); // PA1 配置成模拟输入:CNF=00, MODE=00 RCC->CFGR |= (0x3U << 15); // ADCCLK = PCLK2 / 6,约12MHz ADC1->SMPR2 |= (0x7U << 3); // 通道1 采样周期 239.5 周期 ADC1->SQR3 = 1; // 规则序列的第一个转换:通道1 ADC1->CR2 |= ADC_CR2_ADON; // 打开 ADC

需要注意,ADC 时钟不能让内部模拟电路跑太快,一般超过 14MHz 就开始影响精度,所以要先看RCC->CFGR里的 ADCPRE 位段,根据你的 PCLK2 选择合适的预分频值。采样时间也不是越大越准,过大会拖慢整个转换速度,取舍取决于信号源内阻和你的应用。

启动转换在 F103 里有两种常见方式:连续转换模式或者软件触发单次转换。多通道切换的坑,往往就是在这两种方式之间产生的。

4.2 多通道切换的三种方案与取舍

很多人一碰到多通道测量,就把刚才的 ADC 初始化代码原封不动再执行一遍,清寄存器、重新配通道、重新启动。这样做虽然能出结果,但效率低,而且如果断电时序不对,波形和采样值都会异常。其实有三种更合理的思路。

第一种,单通道单次转换,动态改SQR3。这种方式最灵活,适合通道数少、采样率要求不高的场景。每次换通道只要重写SQR3,然后软件触发就行:

uint16_t adc_read_channel(uint32_t ch) { ADC1->SQR3 = ch; ADC1->CR2 |= ADC_CR2_SWSTART; while (!(ADC1->SR & ADC_SR_EOC)); return (uint16_t)ADC1->DR; }

虽然简单,但每次切换采样速度都不高,而且如果一个通道采完立即换通道马上启动,可能保持时间不够,最好在通道切换后先空转一次,把结果丢掉,再采下一组。

第二种是扫描模式加 DMA,这是我认为多通道采集最优雅的方式。配置 ADC1 开启 SCAN,规则通道序列里放好你要采的 N 个通道,同时给 ADC 配一个 DMA 请求,转换完一个通道,DMA 就自动把 DR 里的结果搬运到内存数组。程序里只需要开一次 DMA,后续数据就源源不断进来:

ADC1->CR1 |= ADC_CR1_SCAN; ADC1->CR2 |= ADC_CR2_DMA | ADC_CR2_SWSTART; DMA1_Channel1->CCR |= DMA_CCR_CIRC | DMA_CCR_MINC;

这里CIRC是循环模式,MINC是内存地址递增。不需要在每次转换完成后去查 EOC,CPU 负担很小。第三种是注入组转换,主要用于外部信号触发时强行插入一组高优先级转换,日常用得少,不展开。

4.3 ADC 采样稳定与误差排查

ADC 的值跳来跳去,不要第一反应就是“硬件有干扰”。先查这几个方面。

第一,电源和参考电压。许多开发板的 VREF+ 接的是 3.3V,如果你的测量信号本身就带高频纹波,ADC 结果自然不稳定。第二,信号源阻抗。ADC 内部采样电容充电时间有限,源阻抗越大,越需要拉长采样时间;比如信号源是个高阻电桥,你把采样时间从 1.5 周期改成 239.5 周期,效果立竿见影。第三,多通道串扰。通道切换后第一次采样结果经常不准,因为采样电容还残留上一通道的电荷。所以我的习惯是:通道切换后先丢弃第一笔数据,或者多采几次取中值。

热词里很多人搜“stm32 adc切换通道”,其实就是踩了上面的某一类坑。寄存器上多看一眼SMPR和SQR,往往比反复硬调电路有效得多。

5. DWT 寄存器:微秒级延时和取时戳

5.1 为什么 DWT 比 SysTick 更舒服

很多项目需要微秒级延时或者精确测量脉冲宽度,比如 HC-SR04 超声波测距、红外遥控解码、PID 周期控制。SysTick 定时器也可以做延时,但它往往被 RTOS 或者系统节拍占用,而且你想取一个“当前时间戳”并不顺手。DWT 是内核里的一个自由运行计数器,它不做中断,只是默默数内核时钟周期,用起来自由很多。

DWT 核心寄存器地址非常干净,在 Cortex-M3/M4 中基本都有:

DWT_CTRL = 0xE0001000 DWT_CYCCNT = 0xE0001004 CoreDebug = 0xE000EDFC

DWT->CYCCNT 每个周期加 1,主频有多高它就数多快。比如主频 72MHz,它每微秒数 72 个数字。严格说这不是延时器,而是一个单调递增的计数钟,非常符合“取时间戳”的语义。

5.2 开启 CYCCNT 的三行代码

开启 DWT 寄存器几乎不需要配置复杂位段,三行就能搞定:

CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;

如果你不想依赖 CMSIS 头文件里的宏,也可以直接写:

#define DWT_CTRL (*(volatile uint32_t *)0xE0001000) #define DWT_CYCCNT (*(volatile uint32_t *)0xE0001004) #define CoreDebug_DEMCR (*(volatile uint32_t *)0xE000EDFC) CoreDebug_DEMCR |= (1UL << 24); DWT_CYCCNT = 0; DWT_CTRL |= 1;

DEMCR的 TRCENA 位是调试跟踪功能的使能开关,不先把全局跟踪打开,DWT 不会干活。这一步漏了,后面CYCCNT永远是 0,而且很难排查到,因为代码看起来没问题。

5.3 用 DWT 做微秒延时和时间戳

有了CYCCNT之后,写一个微秒延时函数非常顺:

void delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < ticks); }

注意这里用的是“无符号减法”,不要判断DWT->CYCCNT > start + ticks,因为计数器会回绕。无符号减法天然处理了回绕,这是老手惯用技巧。

取一个微秒级的时间戳也很直接:

uint32_t micros(void) { return DWT->CYCCNT / (SystemCoreClock / 1000000); }

如果你的主频在运行时会被切换,比如进入低功耗模式改时钟,这套时间戳会因为时钟源变化而不准。但大多数项目跑在固定主频下,DWT 的精度足够用。有人可能会问为什么不直接用 SysTick,答案很简单:SysTick 要配置重装载值、开中断、处理回调,做一次精确延时还得打断当前执行;DWT 则是“读一个数”的事,开销极小。

5.4 实际项目里测量超声波回波时间

以 HC-SR04 超声波模块为例,它的 Echo 引脚输出高电平的时长正比于距离。用 DWT 量这个脉宽比切换定时器方便太多:

// 发 10us 以上的 Trig 脉冲后,等 Echo uint32_t start = DWT->CYCCNT; while (!echo_pin); // 等高电平开始 while (echo_pin); // 等高电平结束 uint32_t duration_ticks = DWT->CYCCNT - start; float duration_us = (float)duration_ticks / (SystemCoreClock / 1000000); float distance_cm = duration_us / 58.0;

如果你用过普通定时器,会体会到每次调超时参数和多路共用有多麻烦。DWT 就是一个全局、独立、低开销的硬件计数器,非常适合这种“横竖都要卡时间”的传感器场景。类似的红外解码、PPM 信号测量、PID 周期控制任务,我都优先用它。

6. 经验排查:寄存器调试的几个大坑和实战技巧

6.1 常见问题速查表

寄存器编程的好处是每个开关都是显式的,但坏处是任何一个开关漏掉,现象都可能千奇百怪。下面这张表是我这几年整理出来的高频问题排查思路:

现象常见原因排查方向
串口输出乱码波特率算错、系统时钟不匹配、外部晶振值不对读回 BRR 寄存器,对比理论值;核对时钟树
GPIO 怎么都不输出外设时钟没使能、复用功能没配、引脚被其他模块占用查 RCC 对应使能位,读 GPIO 寄存器当前值
PWM 没有波形时基没启动、通道输出没使能、高级定时器主输出没开查 CR1、CCER、BDTR 的 MOE
定时器不进捕获中断没清标志、NVIC 没配置、外部信号没接对引脚看 SR 标志位是否置位;读 NVIC 状态
ADC 永远最大值通道选择错误、输入模式没设成模拟、参考电压不对查 SQR、CR2,用万用表量引脚电压
程序莫名进 HardFault寄存器地址越界、操作了未使能时钟的外设、数组越界看 HardFault 时的 PC/LR,回退到上一条代码

这张表最大的价值不是每个问题都列得全,而是告诉你排查顺序:先看时钟,再看配置,最后看标志。寄存器模式下,三个都能在调试器里直接读到,不用靠猜。

6.2 调试时我习惯做的事:寄存器窗口加读回

我极少通过“注释掉代码看现象”的方式排查寄存器问题。打开 Keil 或者 VS Code 的调试器,直接把寄存器名输入 Watch 窗口,比如GPIOA->CRH、USART1->SR、TIM2->CCR1,实时看它们的值,往往一眼就能发现问题。

有一次同事说 PWM 输出占空比怎么调都不变,我让他看TIM2->CCR1和TIM2->ARR,结果发现他把值写到了CCR2,但CCER使能的还是通道 1。这种问题光靠看现象一辈子也猜不到,读寄存器就几秒钟。

所以我强烈建议你养成一个习惯:配置完一个外设后,花 10 秒钟把关键寄存器的值读回来,和理论值比对一遍。比如 BRR 算出来是7500,你就看看寄存器里是不是0x1D4C。这个动作成本极低,但能把一半以上的低级错误挡在调试之前。

6.3 一个隐蔽的坑:JTAG 引脚和普通 GPIO 打架

很多板子上的 PA15、PB3、PB4 默认是 JTAG 调试口。如果你把这三根引脚当 GPIO 用,会发现怎么配置都不对,因为芯片内部默认把它们分配给 JTAG 功能了。

解决办法是在系统初始化早期关闭 JTAG、只保留 SWD:

RCC->APB2ENR |= (1U << 0); // 使能 AFIO 时钟 AFIO->MAPR &= ~(0x7U << 24); AFIO->MAPR |= (0x2U << 24); // SWJ_CFG = 010,关闭 JTAG 保留 SWD

这样设置之后,PA15、PB3、PB4 才能恢复普通 GPIO 身份。注意这步要在时钟使能之后、配置 GPIO 之前完成,否则引脚状态在时钟切换瞬间可能抖动。如果你用的是 SWD 调试器,这个设置不会影响调试连接,可以放心使用。

6.4 在 RTOS 和复杂项目里用寄存器要注意什么

跑 FreeRTOS 这类操作系统后,有些人会在中断或者任务里直接用 DWT 做长时间阻塞延时,结果系统卡死。问题不是 DWT,而是你在临界区或者高优先级中断里占用了太多 CPU 时间,调度器没机会跑。

我的建议是:任务内短延时可以用 DWT,超过百微秒级的阻塞延时尽量换成vTaskDelay或者事件通知;中断服务函数里只做置标志和读数据,不要做长时间延时。寄存器配置本身在 RTOS 下没什么特殊,但要注意多个任务同时访问同一个串口外设时,需要加互斥保护,不然发送和接收混在一起会乱套。

另外,如果你要在 Flash 里存参数,做写操作时最好关中断或者用操作系统的临界区保护,防止写 Flash 的过程中被高优先级任务打断,导致状态不完整。这些都是从裸机过渡到 RTOS 之后会碰到的真问题,提前避开能省很多排查时间。

最后说一个我自己的习惯:拿到任何一块新板子,第一件事不是跑 Demo,而是调通三样东西——GPIO 翻转 LED、DWT 微秒延时、串口打印函数。这三样一旦稳定,后面所有外设调试都有了“眼睛”和“尺子”。本章这几个外设吃透以后,再看 I2C、SPI、CAN、USB,其实都还是“查手册、选外设时钟、配控制位、读状态标志”这一套旧流程。寄存器编程不神秘,但它要求你尊重硬件手册,一句一句读进去,一个一个位段试过来。建议你把例子里的代码自己手敲一遍,然后把频率和分频数随意改一改,出问题就用调试器读寄存器,试着从数字里找原因。这个过程一旦走通,STM32 对你来说就不会再有什么黑盒了。