1. 从一个“点灯”说起:为什么STM32C5值得单独写一套教程
搞嵌入式的人都有一个共识:STM32这条产品线,型号多到让人头皮发麻。F系列、L系列、G系列、H系列、C系列……每个系列下面又是几十个具体型号,封装不同、外设数量不同、主频不同、存储大小不同。很多新手拿到一块新板子,第一反应不是看数据手册,而是先找“有没有现成的例程可以跑”。
STM32C5这个系列,是近两年才逐渐进入开发者视野的一条新线。它定位在主流入门到中端应用之间,主打的是性价比和易用性的平衡——主频够用、外设齐全、封装选择灵活,而且官方给的固件库和工具链支持也比较完整。但问题在于,网上关于F103、F407的教程铺天盖地,轮到C5的时候,资料就明显稀疏了。很多人拿到C5的芯片或者开发板,照着F103的教程去配,结果时钟树对不上、寄存器地址有偏差、HAL库的版本也不兼容,折腾半天灯都点不亮。
这套教程要解决的问题很具体:让一个有一定C语言基础、但没接触过STM32C5的人,能够从零开始把开发环境搭起来,把第一个工程跑通,然后逐步掌握GPIO、定时器、串口、中断、ADC、PWM这些核心外设的配置方法,最终能独立完成一个小型嵌入式项目的开发。
适合谁来参考?如果你是电子类专业的学生,课上学的还是51单片机或者F103,想升级到更新的平台;如果你是做硬件产品的工程师,项目选型选了C5但之前没实际用过;或者你纯粹是个DIY爱好者,买了一块C5的开发板想玩玩——这套内容都能给你一条清晰的路径。我不打算写成数据手册的中文翻译,而是按照实际开发的顺序,把每一步的操作意图、参数计算、容易踩的坑都讲清楚。
2. 开发环境搭建:工具链的选择与配置细节
2.1 IDE选型:Keil、IAR还是STM32CubeIDE
这是每个新手面临的第一个岔路口。我的建议很直接:如果你用的是Windows平台,优先考虑STM32CubeIDE;如果你已经买了Keil的授权或者公司统一用Keil,那就继续用Keil。原因如下。
STM32CubeIDE是官方推出的免费集成开发环境,基于Eclipse框架,内置了STM32CubeMX配置工具和GCC编译器。它最大的好处是和官方生态无缝衔接——你用CubeMX配好的引脚和时钟,可以直接生成工程导入IDE,不需要手动移植。对于C5这种相对较新的系列,CubeIDE的器件支持包更新最及时,不会出现“找不到芯片型号”的尴尬。
Keil MDK的优势在于编译速度快、调试器兼容性好、国内资料多。但要注意,Keil的器件支持包需要单独安装,C5系列的Pack包要去官方渠道下载最新版本。另外Keil的免费版有32KB代码限制,对于学习够用,但项目稍微大一点就会撞墙。
IAR的话,编译优化做得最好,但价格贵,界面对于新手不够友好,除非公司要求,否则不建议作为入门工具。
| 工具 | 优势 | 劣势 | 适合人群 |
|---|---|---|---|
| STM32CubeIDE | 免费、官方支持、集成CubeMX | 基于Eclipse,启动稍慢 | 新手、学生、个人开发者 |
| Keil MDK | 编译快、资料多、调试稳定 | 收费、需单独装Pack | 公司项目、有授权用户 |
| IAR | 优化强、代码效率高 | 贵、上手门槛高 | 专业嵌入式团队 |
2.2 安装过程中的三个关键细节
第一个细节:安装路径不要有中文和空格。这不是开玩笑,Eclipse框架和GCC工具链对中文路径的兼容性一直有问题,很多人装完之后新建工程报错,查了半天发现是路径里有“新建文件夹”几个字。建议直接装在C:\STM32Tools\或者D:\Embedded\这种纯英文路径下。
第二个细节:CubeIDE安装时会自动下载器件支持包,但C5的包可能不在默认列表里。安装完成后,打开Help -> Manage Embedded Software Packages,找到STM32C5系列,勾选最新的固件包版本,点击安装。这个过程需要联网,如果网络不稳定,可以手动下载离线包再导入。
第三个细节:调试器驱动要提前装好。不管你用的是ST-Link、J-Link还是DAPLink,先把对应的驱动装好,插上板子后在设备管理器里确认能看到调试器设备。我见过太多人工程编译通过了,一点下载就报“No ST-LINK detected”,最后发现是驱动没装。
2.3 新建第一个工程的完整流程
打开CubeIDE,选择File -> New -> STM32 Project,在器件选择器里输入你的具体型号,比如STM32C5xxxx。选中之后给工程起个名字,注意工程名也不要带中文。下一步会问你要不要初始化所有外设为默认状态,选“Yes”就行。
工程创建好之后,会自动打开CubeMX的配置界面。这里先不急着配外设,先做一件事:在Project Manager选项卡里,把Toolchain/IDE确认为STM32CubeIDE,然后在Code Generator里勾选“Generate peripheral initialization as a pair of .c/.h files per peripheral”。这个选项的作用是把每个外设的初始化代码分开生成,后期维护的时候不会所有代码都堆在main.c里,找起来方便。
配置完成后按Ctrl+S保存,CubeMX会自动生成代码并跳转到编辑界面。这时候直接点编译按钮,应该能零错误通过。然后点下载按钮,如果调试器连接正常,程序就会烧录到芯片里。虽然这个默认工程什么都没做,但这一步跑通了,说明整个工具链是通的,后面再出问题就可以排除环境因素。
3. GPIO操作:从寄存器到HAL库的完整理解
3.1 先搞懂GPIO的八种工作模式
STM32的GPIO比51单片机复杂得多,不是简单的“输入”和“输出”两个状态。C5系列的GPIO支持八种模式,新手最容易懵的就是什么时候用哪种。我用最直白的方式解释一遍。
输入模式四种:
- 浮空输入:引脚悬空时电平不确定,一般用于外部已经有上拉或下拉电阻的场景,比如接按键但按键另一端接的是上拉电阻。
- 上拉输入:芯片内部通过电阻把引脚拉到高电平,按键一端接引脚、另一端接地,按下时读到低电平。
- 下拉输入:和上拉相反,内部拉到低电平,按键一端接引脚、另一端接电源,按下时读到高电平。
- 模拟输入:引脚直接连到ADC或比较器,不经过数字电路,用于采集模拟信号。
输出模式四种:
- 推挽输出:能输出高电平和低电平,驱动能力强,用于点灯、驱动数字信号。
- 开漏输出:只能输出低电平,高电平需要外部上拉电阻,常用于I2C总线这种需要“线与”的场景。
- 复用推挽:引脚交给片上外设控制,比如SPI的MOSI、UART的TX。
- 复用开漏:同样交给外设,但输出结构是开漏,比如I2C的SCL和SDA。
注意:配置GPIO之前一定要先使能对应端口的时钟。C5系列的外设时钟默认是关闭的,不使能时钟直接写寄存器,操作不会生效,而且不会报错,这是新手最容易卡住的地方。
3.2 用HAL库点灯的代码拆解
假设我们要让PA5引脚上的LED闪烁,用HAL库的写法大概是这样的:
// 1. 使能GPIOA时钟 __HAL_RCC_GPIOA_CLK_ENABLE(); // 2. 配置GPIO初始化结构体 GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = GPIO_PIN_5; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; // 推挽输出 GPIO_InitStruct.Pull = GPIO_NOPULL; // 不上拉不下拉 GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; // 低速即可 HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); // 3. 在主循环里翻转电平 while (1) { HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5); HAL_Delay(500); // 延时500毫秒 }这段代码看起来简单,但有几个点值得展开说。
为什么Speed要选低速?GPIO的输出速度影响的是引脚电平翻转时上升沿和下降沿的斜率。速度越高,边沿越陡,电磁辐射越大,功耗也越高。点灯这种应用,几百赫兹的翻转频率,低速完全够用。只有在驱动SPI时钟、高速通信接口的时候才需要选高速。
HAL_Delay的精度问题。HAL_Delay是基于SysTick中断实现的毫秒级延时,它的精度取决于系统时钟配置。如果你在CubeMX里把系统时钟配成了72MHz,SysTick的配置也正确,那HAL_Delay(500)就是500毫秒左右。但如果你在中断服务函数里调用HAL_Delay,会因为中断优先级的问题导致延时不准甚至卡死。记住一个原则:HAL_Delay只能在主循环和低优先级任务里用,中断里要用别的延时方式。
3.3 按键输入:消抖的两种实现方式
按键比点灯麻烦的地方在于机械抖动。按键按下和松开的瞬间,触点会弹跳几次,导致电平在几毫秒内快速变化。如果不处理,程序可能会把一次按下识别成多次。
最简单的消抖是延时确认法:检测到低电平后,延时20毫秒再读一次,如果还是低电平就确认按下。缺点是占用CPU时间,但实现简单,适合按键数量少的场景。
稍微好一点的是定时器扫描法:用一个定时器每10毫秒中断一次,在中断里读取按键状态,连续三次读到相同状态才确认。这种方式不阻塞主循环,适合按键多、逻辑复杂的项目。
// 延时确认法示例 if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) { HAL_Delay(20); // 延时20ms消抖 if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) { // 确认按下,执行动作 HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5); while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET); // 等待松开 } }实操心得:等待按键松开的那句while循环,如果按键卡住或者长按,程序会一直停在那里。实际项目里建议加一个超时计数,超过一定时间就强制退出,避免死循环。
4. 时钟系统与定时器:嵌入式的“心跳”怎么调
4.1 时钟树配置:理解PLL的倍频过程
STM32的时钟系统是新手最头疼的部分之一,因为涉及到多个时钟源、分频器、倍频器。但理解了它的结构之后,其实逻辑很清晰。
C5系列通常有四个时钟源:HSI(内部高速时钟)、HSE(外部高速时钟)、LSI(内部低速时钟)、LSE(外部低速时钟)。HSI和HSE给系统提供主时钟,LSI和LSE主要给看门狗和RTC用。
系统时钟的来源可以是HSI、HSE或者PLL的输出。PLL的作用是把低频时钟倍频到高频。比如你焊了一个8MHz的晶振,但想让芯片跑72MHz,就需要PLL把8MHz乘以9倍。
在CubeMX的时钟树界面里,配置顺序是这样的:先选HSE作为PLL的输入源,然后设置PLL的M、N、P分频系数。以8MHz晶振输出72MHz为例:M=1(不分频),N=9(倍频9倍),P=1(不分频),最终PLL输出就是8×9÷1=72MHz。然后AHB分频器设为1,APB1分频器设为2(因为APB1最高只能到36MHz),APB2分频器设为1。
注意:APB1和APB2的分频系数会影响定时器的时钟频率。如果APB的分频系数不为1,定时器的时钟会是APB时钟的2倍。这个细节在计算定时器周期的时候必须考虑进去,否则算出来的定时时间会差一倍。
4.2 定时器配置:从1毫秒定时到PWM输出
定时器的核心参数就三个:预分频器(Prescaler)、自动重装载值(Counter Period)、计数模式。
定时时间的计算公式是:
定时时间 = (Prescaler + 1) × (Counter Period + 1) / 定时器时钟频率
假设定时器时钟是72MHz,我们想要1毫秒的定时中断。先确定预分频器:如果设Prescaler=71,那么定时器时钟变成72MHz÷(71+1)=1MHz,也就是每1微秒计数一次。然后Counter Period设为999,那么计满1000次就是1毫秒。
// 定时器1毫秒中断配置示例 htim2.Instance = TIM2; htim2.Init.Prescaler = 71; // 72MHz / 72 = 1MHz htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 999; // 1000次计数 = 1ms htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(&htim2); // 开启定时器中断 HAL_TIM_Base_Start_IT(&htim2); // 中断回调函数 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { // 每1毫秒执行一次 HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5); } }PWM输出的配置和定时器类似,只是多了一个比较寄存器(Capture Compare Register)。PWM的频率由定时器的周期决定,占空比由比较寄存器的值决定。比如周期设为999,比较值设为499,占空比就是50%。
// PWM输出配置 TIM_OC_InitTypeDef sConfigOC = {0}; sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 499; // 占空比50% sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode = TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);实操心得:PWM的占空比可以在运行时动态修改,调用
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, new_value)就行。这个技巧在做呼吸灯、电机调速的时候非常有用。但要注意,修改比较值的时候如果新值大于周期值,输出会一直保持高电平,不会自动限幅,需要自己在代码里做边界检查。
4.3 定时器中断优先级的管理
C5系列的中断优先级分为抢占优先级和响应优先级。抢占优先级高的中断可以打断正在执行的低抢占优先级中断,响应优先级只在同时发生的时候决定谁先执行。
配置的时候有一个原则:越紧急、越不能延迟的中断,抢占优先级越高。比如电机控制里的过流保护中断,应该设为最高优先级;串口接收中断可以设低一点;SysTick的优先级通常设最低,因为它只是提供系统时基,晚一点响应没关系。
在CubeMX的NVIC配置界面里,每个中断都有一个Preemption Priority和Sub Priority的输入框。C5系列通常支持4位优先级,也就是0到15。数值越小优先级越高。
注意:如果两个中断的抢占优先级相同,它们之间不会互相打断。如果响应优先级也相同,那就按照中断向量表里的顺序执行。实际项目里建议给每个中断都分配不同的抢占优先级,避免出现难以排查的时序问题。
5. 串口通信:调试信息输出的标准通道
5.1 串口参数的含义与配置
串口是嵌入式开发中最常用的调试手段。配置串口的时候需要设置几个参数:波特率、数据位、停止位、校验位、流控。
波特率表示每秒传输的位数,常用的有9600、115200、921600等。115200是目前最常用的调试波特率,速度快而且兼容性好。数据位通常是8位,停止位1位,校验位无,流控无——这就是常说的“8N1”配置。
在CubeMX里配置USART的时候,选择异步模式(Asynchronous),然后设置波特率115200,其他保持默认。生成代码后,需要重定向printf函数才能用printf("Hello\r\n")的方式输出调试信息。
// 重定向printf到串口 #include <stdio.h> int __io_putchar(int ch) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY); return ch; } // 或者在Keil环境下用fputc int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY); return ch; }实操心得:重定向printf的时候,
HAL_MAX_DELAY表示一直等待直到发送完成。如果串口被占用或者配置错误,程序会卡死在这里。调试阶段可以用这个,但正式产品里建议改成带超时的版本,比如HAL_UART_Transmit(&huart1, &ch, 1, 100),超时100毫秒就放弃。
5.2 串口接收中断与环形缓冲区
串口发送简单,接收就复杂一些。因为数据什么时候来是不确定的,不能用轮询的方式一直等着。标准做法是开启接收中断,每收到一个字节就存到缓冲区里,主循环再从缓冲区里取数据解析。
#define RX_BUFFER_SIZE 128 uint8_t rx_buffer[RX_BUFFER_SIZE]; volatile uint16_t rx_head = 0; volatile uint16_t rx_tail = 0; // 接收完成回调 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { rx_head = (rx_head + 1) % RX_BUFFER_SIZE; // 重新开启接收中断 HAL_UART_Receive_IT(&huart1, &rx_buffer[rx_head], 1); } } // 主循环里读取数据 void process_uart_data(void) { while (rx_tail != rx_head) { uint8_t data = rx_buffer[rx_tail]; rx_tail = (rx_tail + 1) % RX_BUFFER_SIZE; // 处理数据 } }这个环形缓冲区的写法看起来简单,但有几个细节容易出错。第一,head和tail要用volatile修饰,因为它们在中断和主循环里都会被修改,编译器优化的时候可能会把它们缓存到寄存器里,导致数据不同步。第二,缓冲区大小最好是2的幂次,这样可以用位与运算代替取模运算,效率更高。第三,判断缓冲区满的条件是(head + 1) % SIZE == tail,而不是head == tail,因为head等于tail表示缓冲区空。
5.3 串口DMA传输:解放CPU的利器
当需要发送大量数据的时候,用中断方式一个字节一个字节地发效率太低。DMA(直接存储器访问)可以让数据在外设和内存之间直接传输,不需要CPU干预。
配置DMA发送的步骤:在CubeMX的DMA设置里,给USART1_TX添加一个DMA通道,模式选Normal(单次传输),数据宽度都选Byte。然后调用HAL_UART_Transmit_DMA(&huart1, data, length)就可以启动DMA传输。
DMA接收稍微复杂一点,因为不知道数据什么时候来、来多少。常用的方案是空闲中断+DMA:DMA一直开着接收,当串口总线空闲的时候触发空闲中断,在中断里计算收到了多少数据。
// 开启DMA接收和空闲中断 HAL_UART_Receive_DMA(&huart1, dma_rx_buffer, DMA_BUFFER_SIZE); __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); // 空闲中断处理 void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 计算接收到的数据长度 uint16_t received_len = DMA_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx); // 处理数据 process_data(dma_rx_buffer, received_len); // 重新启动DMA接收 HAL_UART_Receive_DMA(&huart1, dma_rx_buffer, DMA_BUFFER_SIZE); } HAL_UART_IRQHandler(&huart1); }注意:空闲中断的清除方式比较特殊,需要先读SR寄存器再读DR寄存器。HAL库提供了
__HAL_UART_CLEAR_IDLEFLAG宏来简化操作,但有些版本的HAL库这个宏实现有问题,需要手动清除。如果发现空闲中断只触发一次就不再触发了,大概率是清除标志位的操作不对。
6. 常见问题与排查技巧实录
6.1 程序下载后不运行
这是最高频的问题。可能的原因和排查顺序如下:
第一,检查复位电路。有些开发板的复位按键卡住了,或者复位电容焊错导致芯片一直处于复位状态。用万用表量一下NRST引脚的电平,正常应该是高电平。
第二,检查启动模式。STM32的BOOT0和BOOT1引脚决定了芯片从哪里启动。如果BOOT0接高电平,芯片会从系统存储器启动,不会运行你烧录的程序。正常运行时BOOT0应该接地。
第三,检查时钟配置。如果你在CubeMX里选了HSE作为时钟源,但板子上没有焊晶振,或者晶振没起振,芯片会卡在时钟初始化那里。可以先用HSI试试,确认程序逻辑没问题再换HSE。
第四,检查中断向量表。如果你用了Bootloader或者程序从非默认地址开始运行,需要重定向中断向量表。调用SCB->VTOR = FLASH_BASE | OFFSET来设置。
6.2 串口输出乱码
乱码的本质是收发双方的波特率不匹配。排查步骤:
- 确认代码里设置的波特率和串口助手选的一样。115200和9600搞混是最常见的。
- 确认系统时钟配置正确。如果时钟配错了,实际波特率会偏离设定值。比如你以为是72MHz,实际跑的是64MHz,那波特率就会偏差12%左右,足以导致乱码。
- 检查晶振频率。有些板子焊的是8MHz晶振,但代码里按12MHz配置,时钟树整个就错了。
- 如果是USB转串口模块,检查模块本身的质量。劣质的CH340模块在高波特率下容易出错,换一个FT232的模块试试。
6.3 中断不触发
中断不触发的原因通常有三个:中断没使能、优先级配置错误、中断标志没清除。
首先确认在CubeMX里勾选了对应的中断,并且生成了HAL_NVIC_EnableIRQ的代码。然后检查优先级分组设置,如果抢占优先级和响应优先级的位数分配不对,可能导致中断被屏蔽。最后检查中断服务函数里有没有清除标志位,有些中断如果不手动清除,只会触发一次。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 下载失败 | 调试器驱动未装、接线错误 | 设备管理器查看调试器、检查SWD接线 |
| 程序不运行 | 复位电路、启动模式、时钟配置 | 量NRST电平、检查BOOT引脚、换HSI测试 |
| 串口乱码 | 波特率不匹配、时钟配置错误 | 核对波特率、检查晶振频率和PLL配置 |
| 中断不触发 | 未使能、优先级错误、标志未清 | 检查NVIC配置、确认中断服务函数 |
| GPIO无输出 | 时钟未使能、模式配置错误 | 确认RCC使能、检查GPIO模式 |
| 定时器不准 | 分频系数计算错误、时钟源不对 | 重新计算Prescaler和Period |
| DMA不传输 | 通道配置错误、中断未使能 | 检查DMA请求映射、确认传输完成标志 |
实操心得:遇到问题的时候,先缩小范围。比如串口不通,先确认发送引脚有没有波形输出(用示波器或者逻辑分析仪看),如果有波形但乱码,那就是波特率问题;如果没有波形,那就是配置或者硬件问题。把问题定位到具体环节,比盲目改代码效率高得多。
7. 从例程到项目:一个温控风扇的完整实现思路
学完前面那些外设之后,最好的巩固方式就是做一个小项目。我选“温控风扇”这个例子,因为它用到了GPIO、ADC、PWM、定时器、串口这几个核心模块,而且逻辑不复杂,适合作为阶段性练手。
系统框图:温度传感器(比如NTC热敏电阻或者LM35)输出模拟电压,接到C5的ADC引脚。ADC采集电压后转换成温度值。根据温度值决定PWM的占空比:温度越高,占空比越大,风扇转得越快。同时通过串口把当前温度打印出来。
ADC配置要点:C5的ADC是12位的,采集范围是0到4095,对应电压0到3.3V。转换公式是电压 = ADC值 × 3.3 / 4095。如果是LM35传感器,输出电压和温度的关系是温度 = 电压 × 100。所以合并起来就是温度 = ADC值 × 3.3 × 100 / 4095。
PWM配置要点:风扇通常用4线PWM风扇,PWM频率建议在25kHz左右,超出人耳听觉范围,不会有啸叫声。定时器周期设为72MHz / 25kHz = 2880,所以Prescaler设为0,Period设为2879。占空比根据温度线性映射:比如20度以下不转,20到40度之间占空比从0%到100%。
串口输出格式:每500毫秒打印一次温度值和当前PWM占空比,格式可以是Temp: 25.3C, Duty: 45%。这样方便观察系统运行状态。
主循环逻辑:
while (1) { // 1. 读取ADC值 HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 100); uint16_t adc_value = HAL_ADC_GetValue(&hadc1); // 2. 转换为温度 float voltage = adc_value * 3.3f / 4095.0f; float temperature = voltage * 100.0f; // 3. 计算PWM占空比 uint16_t duty = 0; if (temperature > 20.0f) { duty = (uint16_t)((temperature - 20.0f) / 20.0f * 2879); if (duty > 2879) duty = 2879; } __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, duty); // 4. 串口打印 printf("Temp: %.1fC, Duty: %d%%\r\n", temperature, duty * 100 / 2880); HAL_Delay(500); }这个项目做完之后,你对C5的整个开发流程就有了完整的体感。后面再学SPI、I2C、CAN这些更复杂的外设,思路是一样的:看数据手册了解寄存器、用CubeMX配置参数、写代码调用HAL库函数、调试排查问题。
我个人在实际操作中的体会是,STM32C5的学习曲线在GPIO和时钟配置那里最陡,很多人卡在点灯这一步就放弃了。但只要把时钟树理解清楚,把第一个工程跑通,后面的外设都是类似的套路。另外,不要怕看数据手册,HAL库虽然方便,但遇到奇怪的问题时,最终还是要回到寄存器层面去找原因。手册里的时序图和寄存器描述,才是解决问题的终极武器。