
这篇是《STM32寄存器白话手册》的第三章。前两章把GPIO那几个寄存器翻来覆去地练点灯、按键、读引脚、复用功能这些基本功应该都过关了。但从做项目角度看只会GPIO还属于“能亮了”的水平芯片真正的价值在外设联动定时器产生时间基准ADC把模拟电压变成数字量DMA一声不吭地把数据搬到内存串口再把结果发到电脑上。这一章我打算用一个完整案例把这四样全部串起来全程只写寄存器不调用任何库函数。你能看到每一行配置是怎么从芯片手册里查出来的也能踩到几个我当初调试到怀疑人生的大坑。适合已经能看懂寄存器代码、想彻底搞懂外设原理的读者也适合那些用HAL库写了好几年、但一遇到疑难杂症就只能靠猜的工程师——把底层翻明白上层问题往往一眼就能定位。1. 这一章打算练什么定时器、ADC、DMA和串口的联动很多新人有个误区觉得寄存器编程就是背地址、记位号谁记得多谁厉害。其实完全不是这样。寄存器操作的本质是和你自己对话芯片手册已经把每个32位地址里每一位的作用写得明明白白你要做的只是根据外设工作流程在正确的时机往正确的位填正确的值。这也正是第三章想帮你建立的思维——先把一轮外设的完整工作流程画在脑子里再去看每一组寄存器该配什么。我见过不少工程师在库函数里调ADC连续采样、DMA传输这些都配好了但问他一轮转换需要多少时间、DMA是从哪个地址搬到哪个地址、为什么必须开循环模式他都答不上来。不是他笨是封装掩盖了太多细节。寄存器版的价值就在于把“答案”变成“推演”。你亲手把预分频系数写进TIMx_PSC的那一刻才会真正理解16位计数器的范围上限在哪里你亲手在ADC_CR2里打开外部触发才会明白为什么“软件启动”和“硬件触发”的项目结构差那么多。这一章的案例我设计了很久既要覆盖足够多的外设又不能复杂到让人看不下去。最终定的是这样一套流程高级定时器TIM3产生1kHz的更新事件通过TRGO信号送给ADC1作为外部触发ADC1在规则组里依次采样CH0和CH1两路模拟电压每触发一次就连续转两个通道转换结果由DMA1通道1自动搬运到内存数组全程不占用CPU主循环里定时把数组内容从串口1打印出来人眼就能观察到两路电压的变化。这套东西练完外部中断、定时器、模拟量采集、DMA搬运、串口通信这些大路货技能点全点亮了。选这条路还有一层原因热搜词里“STM32 ADC切换通道”“寄存器地址赋值”“串口调试”“定时器捕获测频率”这几个方向背后全是同一套底层操作。通道切换不稳是ADC配置问题串口乱码是波特率计算问题定时器失灵多半是PSC/ARR搭配问题。这些问题在第三章案例里全都绕不开所以我会在后面的章节里逐个展开把排查思路也写出来。第三章结束时你会发现那些曾经听起来很高大上的词拆开就是几个寄存器之间的因果链。2. 核心寄存器逐个拆配置前必须先想清楚的5组位2.1 定时器72MHz是怎么一步步变成1kHz的先说原理再给公式。定时器内部就是一个不断递增的计数器时钟每来一个脉冲CNT就加1。它跑得太快怎么办前面加个预分频器把总线时钟先降下来降完之后还觉得粒度太细就设置自动重装载值ARR让计数器数到ARR就翻车重置。STM32F103的总线主频通常是72MHz我习惯从外设时钟树开始推别上来就抄公式。目标让TIM3输出1kHz的更新事件。先设预分频PSC7199这样72MHz除以7200计数器加1的实际频率变成10kHz。再设ARR9意味着计数器从0数到9一共10个时钟10kHz除以10正好得到1kHz。公式可以写成更新频率 72MHz / ((PSC1) * (ARR1))。很多人省略了ARR后面的那个加1配置定时器总是差一个数这也是最常见的低错。有两点必须提醒。第一TIMx_PSC是硬件上的缓冲寄存器修改后并不是立刻生效要等更新事件发生后新值才会被加载进去。第二TIMx_CR1里有一个ARPE位决定ARR是直接生效还是影子缓冲生效。影子寄存器这个概念听着玄你可以理解成“用一份草稿等整页改完了再誊到正式账本上”。工程上建议把ARPE置1避免运行时修改ARR导致输出波形瞬间抖动。2.2 ADC多通道切换后第一个数据为什么不靠谱ADC在F103里是被动型外设你不给它启动信号它绝不会主动干活。启动信号来源分两类软件启动和外部触发。软件启动就是往ADC_CR2的SWSTART位写1适合低频轮询外部触发则由定时器、外部引脚来发起适合固定采样频率。这一章我用TIM3的TRGO做触发因为采集任务必须按固定节奏来靠主循环软件启动会受其他代码干扰。规则组是ADC里最容易绕晕的概念。规则组通道用SQR1到SQR3来排顺序转换结果统一落进ADC_DR这一个寄存器。多通道连续采集时如果开了中断每转换完一个通道都必须赶紧把DR读走否则下一个通道的结果直接覆盖。这也是为什么多通道加DMA几乎是标配DMA在外设和内存之间开了一条专线DR一更新就搬走程序员根本不用管时序。热词里那句“ADC切换通道”多半是新手手动修改通道号后遇到的灵异现象明明切到CH3了出来的值却还是上一个通道的残留。问题不在于切换动作本身而在于采样电容。切换通道后转换器内部的采样电容还存着上一次的电荷必须留足采样时间把电容充满。解决办法很朴素要么把采样周期调长比如SMPR寄存器里选239.5周期档要么丢弃切换后的第一次转换结果反正数据是连续刷的少一个不心疼。2.3 DMA三兄弟CPAR、CMAR、CNDTR怎么配合DMA的作用就是一个搬运工把A地址的数据搬去B地址搬完一件数一件。在F103里一次传输涉及三个核心寄存器CPAR填外设端地址比如ADC1-DRCMAR填内存端地址也就是我们自己数组的首地址CNDTR填要传输的数据个数。配置顺序有讲究一般是先定地址最后再写CNDTR因为CNDTR一旦大于0通道就进入可工作的状态。如果顺序反了可能出现莫名其妙的先搬运一次。更重要的一位是CCR里的DIR搬运方向。这一章是从外设读数据存进内存标准库说法叫“外设到内存”DIR置0如果是串口发送那种从内存往外设写的场景DIR置1。很多人直接把别人配置原样抄过去方向反了还不自知结果DMA中断倒是触发了数据压根没搬对。需要特别注意的是DMA不是无限循环干活的。每搬完一个数据CNDTR减1减到0后通道自动关闭。所以两种常见选择要么每次传输完成后再手动给CNDTR重新填值这叫单次模式要么在CCR里打开CIRC循环模式硬件会自动把CNDTR恢复初始值。ADC连续采集十有八九开的是循环模式省心又稳定。2.4 串口一个真实波特率的计算过程串口寄存器组的核心是USART_BRR一个16位寄存器拆成两部分高12位是波特率整数部分DIV_Mantissa低4位是小数部分DIV_Fraction。F103的USART1挂在APB2总线上时钟72MHz如果目标波特率是115200先算一个真实的分频系数USARTDIV 72,000,000 / 115,200 625。这个数居然正好是整数所以小数分频部分是0BRR可以直接填0x0271。要注意0x271是625的十六进制不是商数除以16只是恰好它小于4096能放进高12位里。如果算出来的系数不是整数就得手工做四舍五入到最近的小数例如USARTDIV53.333小数0.333乘16得到5.33取整5BRR写入(0x35 4) | 5。这个误差累积到实际波特率上绝大多数情况下仍在串口容忍范围内不用太恐慌。发送流程也不要凭感觉。先把待发送字节写进USART_DR然后看状态寄存器USART_SR的TXE位TXE等于1才表示数据已经转移到发送移位寄存器。严格的流程应该是先确认TXE为空再写DR否则可能覆盖上一个还没发出去的数据。接收侧则是等RXNE置位再读DR。这一条顺序正是串口收发乱码的常见来源。2.5 NVIC外设中断已经打开为啥还是不进中断配置中断时容易忽略的中间环节是NVIC。外设自己的中断使能位只是把中断请求送达NVIC门口真正决定中断能否进入处理器执行还要看NVIC_ISER寄存器里对应的中断通道号是否置1。例如ADC中断对应的是通道18在NVIC里操作时要把ISER的bit18置1才能放行。否则外设状态寄存器的标志位一直置1但CPU不理你。中断优先级分组同样重要。SCB_AIRCR寄存器里PRIGROUP字段决定有多少位用于抢占优先级、多少位用于子优先级。如果分组设置错了高一级的中断会被低优先级的中断一直打断严重的直接互相卡死。建议在工程初始化最开始就把分组设成Group2也就是4位抢占优先级加2位子优先级这是GCC和Keil裸机项目最常见的方案。3. 完整实操1kHz定时触发采集两个通道并打印到串口3.1 硬件与工程准备这一章案例硬件需求极简一块STM32F103C8T6最小系统板、一个USB转TTL串口模块、两颗10k电位器分别接PA0和PA1、一个ST-Link或J-Link调试器。PA0和PA1在F103上是ADC1的通道0和通道1复用功能默认就是模拟输入。串口用PA9和PA10分别对应USART1的TX和RX。接线就一句话PA9接模块RXDPA10接模块TXD两头共地。工程创建直接沿用你熟悉的GCC或Keil不需要拷贝任何库函数的启动文件以外的东西。我的做法是只保留启动文件和核心寄存器定义头文件应用代码里甚至不包含标准外设库只包含CMSIS那层寄存器结构体定义。这样写起来最清爽。至于寄存器地址赋值CMSIS头文件其实已经用结构体把基地址和偏移打包好了比如访问TIM3-PSC时会展开成*(volatile uint32_t *)(0x40000400U 0x28U)如果你想更“硬核”一点自己写宏也可以两种方式本质一模一样。3.2 初始化顺序的讲究外设初始化的通用顺序我建议三步走先开时钟再配引脚最后配外设本身。开时钟靠RCC寄存器GPIOA挂在APB2总线上ADC1和USART1也在APB2上而TIM3和DMA1分别挂在APB1和AHB上。漏开任何一个外设时钟的后果很统一写寄存器无效读出来全是复位值工作不了也没报错这是寄存器调试里最阴的坑。3.3 从GPIO到外设寄存器逐个配置先配置引脚功能。PA0和PA1要变成ADC输入GPIOA的CRL寄存器里每4位控制一个引脚。这4位里低2位是MODE改成00高2位是CNF改成00表示模拟输入模式其他外设功能全部关闭。PA9需要配置成推挽复用输出50MHz也就是CNF为10、MODE为11对应CRL高字节部分。PA10配置成输入模式上拉还是浮空都不太影响我用浮空输入。// 开时钟 RCC-APB2ENR | RCC_APB2ENR_IOPAEN | RCC_APB2ENR_ADC1EN | RCC_APB2ENR_USART1EN; RCC-APB1ENR | RCC_APB1ENR_TIM3EN; RCC-AHBENR | RCC_AHBENR_DMA1EN; // PA0、PA1模拟输入 GPIOA-CRL ~(0xF 0); // 清PA0 GPIOA-CRL ~(0xF 4); // 清PA1 // PA9复用推挽输出、PA10输入 GPIOA-CRH ~(0xFF 4); GPIOA-CRH | (0xB 4); // PA9: 0b1011 - 复用推挽50MHz GPIOA-CRH | (0x4 8); // PA10: 0b0100 - 浮空输入接着配置TIM3。记住刚才的公式PSC7199、ARR9主频72MHz时更新频率为1kHz。还要把TIM3的更新事件引到TRGO引脚上方法是在CR2寄存器里的MMS位段写0b010选择“更新事件作为触发输出”。最后打开计数器使能位CEN。TIM3-PSC 7199; TIM3-ARR 9; TIM3-CR2 ~TIM_CR2_MMS; TIM3-CR2 | (0x2 TIM_CR2_MMS_Pos); TIM3-CR1 | TIM_CR1_CEN;ADC部分要先经历一次上电校准的流程先把CR2的ADON置1让ADC进入上电状态延时一小段时间再置位CAL启动校准等CAL位被硬件清除即可。然后配置两个规则通道SQR3的低10位分别填CH0和CH1SQR1的低4位填长度减1。采样时间我直接拉满SMPR2里把CH0和CH1都设成239.5周期这个采样时间对稳定切换通道很友好。ADC1-CR2 | ADC_CR2_ADON; ADC1-CR2 | ADC_CR2_CAL; while (ADC1-CR2 ADC_CR2_CAL); ADC1-SQR3 (0 0) | (1 5); ADC1-SQR1 1; ADC1-SMPR2 (0x7 0) | (0x7 3); ADC1-CR2 ~ADC_CR2_EXTSEL; ADC1-CR2 | (0x4 17); // 外部触发源选择TIM3_TRGO具体编号以参考手册映射表为准 ADC1-CR2 | ADC_CR2_EXTTRIG; // 开启外部触发功能DMA1通道1正好对应ADC1的请求通道基地址别写错。外设地址填ADC1-DR的地址内存地址填我们自己定义的数组传输个数是2。打开内存自增、半字宽度、循环模式这三个最关键的控制位。注意数据传输数量和地址是一样的16位宽度因为ADC的结果就是16位寄存器。volatile uint16_t adc_buf[2]; DMA1_Channel1-CPAR (uint32_t)ADC1-DR; DMA1_Channel1-CMAR (uint32_t)adc_buf[0]; DMA1_Channel1-CNDTR 2; DMA1_Channel1-CCR DMA_CCR_CIRC | DMA_CCR_MINC | DMA_CCR_MSIZE_0 | DMA_CCR_PSIZE_0; DMA1_Channel1-CCR | DMA_CCR_EN;串口这一路配置相对短BRR先用整数625填写因为115200波特率对72MHz时钟没有小数分频。发送和接收使能位都在CR1里UE是总开关。发送时用状态寄存器做握手。USART1-BRR 0x0271; USART1-CR1 USART_CR1_UE | USART_CR1_TE | USART_CR1_RE; int fputc(int ch, FILE *f) { while (!(USART1-SR USART_SR_TXE)); USART1-DR ch; return ch; }3.4 主循环与调试观察主循环我建议做轮询检查DMA1的ISR寄存器里TCIF标志这个位代表当前传输周期完成。判断到标志位后打印两路ADC数据随后把TCIF写1清除。注意清除方式一定是写1清0不是写0清0这个顺序反了会把中断标志卡死。清完标志位直接进入下一轮等待。volatile uint16_t adc_buf[2]; float voltage[2]; while (1) { if (DMA1-ISR DMA_ISR_TCIF1) { DMA1-IFCR DMA_IFCR_CTCIF1; voltage[0] adc_buf[0] * 3.3f / 4095.0f; voltage[1] adc_buf[1] * 3.3f / 4095.0f; printf(PA0:%.3fV PA1:%.3fV\r\n, voltage[0], voltage[1]); } }调试时第一件事不是看结果而是确认整条链路的状态。打断点停在主循环的TCIF判断处打开调试器的寄存器窗口观察TIM3-CNT是不是按1kHz节奏递增再看ADC1-DR是不是每次触发后更新最后看DMA1_Channel1-CNDTR。如果CNDTR在循环模式下始终稳定在2说明DMA搬完又自动装填搬运链路已经通了。这时串口打印只是最后一公里。4. 实测中高频踩坑寄存器操作常见问题与排查思路4.1 现象速查表下面这张表是从我教学和调试中整理出来的高频问题几乎都是寄存器项目里的共性坑。现象可能原因解决办法写寄存器没反应外设时钟没开查RCC对应总线使能位ADC多通道切完数据不对采样时间太短或首次数值残留拉长采样周期或丢弃第一次结果DMA只搬一次就不动了CNDTR归零循环模式没开置位CCR中的CIRC位串口乱码且有规律波特率分频算错或时钟频率不对重新核对主频与BRR计算定时器中断不响应NVIC对应通道没开或标志位没清检查ISER使能位SR写1清标志改了ARR没有立即生效影子寄存器没触发更新事件给EGR写入UG位产生更新或开ARPE串口发一个字节就卡死等待TXE的逻辑反了先查SR再写DR顺序别颠倒中断里改寄存器死机没有关中断保护读改写先读后写加临界段保护4.2 寄存器操作的三条军规第一条军规操作任何寄存器之前先确认外设时钟已经使能这是所有外设工作的前置条件。我见过太多人卡在“为什么寄存器写不进去”查半天发现RCC_APB2ENR里那一位就是0。开完时钟顺手读回来验证一下不费事。第二条军规读改写要稳。寄存器一个位段往往掌管多项功能如果你要修改某一位强烈建议先读回原值然后按位与清除目标位再按位或填入新值。直接赋值0xFFFFFFFF之类的高危操作等于把别的外设配置推倒重建。第三条军规标志位清除方式必须查手册。状态寄存器里的中断标志绝大多数是写1清除写成0无效。遇到串口SR里的TC、RXNEDMA的TCIFADC的EOC标志全是同一个套路。这一条足以让你从新手直接进阶老手。4.3 一次串口打印乱码的完整排查实录有一次我在板子上跑这个案例串口助手收到的数据全是“??”波形完全不对。第一步用示波器量PA9引脚发现时间间隔明显偏大初步怀疑波特率不对。接着回到代码检查USART1-BRR发现我误把BRR填成了0x2710比正确值大了16倍。问题根源是把“625”这个十进制数直接填进BRR却忘了BRR高12位本身需要的是十六进制分频值6250x271正好等于625但0x2710却等于10000。这相当于波特率被缩小到十分之一串口模块自然读不懂。还有一次类似乱码BRR算得也没错但串口助手收到的字符总缺字节。查寄存器发现USART1-SR的TC位一直在0说明上次发送还没完成就写了下一字节。修复方式不是调代码加延时而是在fputc里等待TXE为1后再写DR。也遇到过主频被USB引导程序改到8MHz的情况BRR按72MHz算当然全乱这时候直接在main函数开头读RCC_CFGR确认时钟源才靠谱。排查这类问题最踏实的办法只有一个跟着数据流走。从ADC_DR到内存数组到串口DR每一步都用调试器现场读寄存器看状态看到哪一步跟预期不一致问题就锁定在哪一段。空想永远比动手慢。最后说一点个人体会。寄存器编程的门槛不在背多少寄存器而在愿意静下心把手册和实测对照着看。第三章做完之后调库函数的心态会完全不一样——出现问题我不再猜而是先怀疑时钟配置再检查标志位直接到寄存器窗里对着每一位找答案。如果你也想走到这一步建议把芯片手册里ADC、DMA和定时器的章节翻两遍再动手把这一章的案例连起来跑一遍收获一定比我写出来的更多。下一章我计划拆后备寄存器和低功耗模式顺便聊聊怎么用DWT计数器做微秒级延时到时候再把实测数据补上。