☰
电赛速通单片机:GPIO、PWM、ADC等核心外设实战指南
2026/9/25 3:15:23 网站建设 项目流程

1. 从电赛视角重新理解单片机:为什么“速通”是可行的

很多人第一次接触电赛,看到题目里要求“测量某信号”“控制某电机”“显示某波形”,第一反应是去翻教材,从第一章的二进制开始啃。这个路径不是不行,但时间成本极高。我带过几届校赛队伍,也见过不少零基础的同学在两周内把单片机的常用外设跑通、最后拿到省奖的案例。核心原因在于:电赛对单片机的考察是“应用级”的,不是“原理级”的。你不需要会设计CPU,但你必须知道GPIO怎么配、PWM怎么出、ADC怎么采、中断怎么进。

标题里列出的这一串——GPIO、PWM、ADC、TIM、IRQ、I2C、SPI、UART、WDG、DMA——基本就是电赛控制类、信号类题目会用到的全部外设集合。它们不是孤立的,而是围绕“采集—处理—输出”这条主线串起来的。比如2023年电赛的某道控制题,本质就是:ADC采传感器电压,TIM定时触发采样,IRQ处理数据,PWM驱动执行器,UART上报数据。你把这条链路打通,题目换汤不换药。

所以“一个视频学完单片机”这个说法,我的理解是:一个视频的时长足够覆盖这些外设的最小可用集。不是学完所有寄存器,而是学完“能跑起来”的那部分。这篇文章就按这个思路,把每个外设的核心用法、配置逻辑、踩坑点讲清楚,让你看完能直接上手改代码。

提示:本文默认你用的是STM32F103C8T6或同级别芯片,HAL库开发。如果你用的是51单片机或STC系列,逻辑相通,但寄存器操作会更多,我会在关键处标注差异。

2. 核心外设逐个拆解:从“能跑”到“跑稳”

2.1 GPIO:八种模式到底怎么选

GPIO是单片机的“手脚”,但很多人卡在模式选择上。网上搜“gpio的8种工作模式”,能出来一堆表格,但实际电赛里常用的就四种:推挽输出、开漏输出、上拉输入、浮空输入。

推挽输出用于驱动LED、继电器这类需要强驱动能力的负载。开漏输出用于I2C总线,因为需要线与逻辑。上拉输入用于按键,浮空输入用于ADC采样引脚(但ADC引脚通常配模拟输入模式,这个后面说)。

我见过一个典型错误:用推挽输出直接驱动I2C的SDA线,结果两个设备同时拉低时电流倒灌,芯片发烫。正确做法是开漏输出加外部上拉电阻。这个坑在电赛现场很致命,因为I2C通信失败往往表现为“读不到数据”,新手会以为是地址错了,其实是电气特性不对。

配置代码用HAL库很简单:

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_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

注意:STM32F103的PA13、PA14默认是SWD调试口,如果你把它们配成普通GPIO,下载一次程序后就再也连不上了。解决办法是按住复位键点下载,或者用STM32CubeProgrammer改选项字节。这个坑我踩过,当时以为板子坏了。

2.2 PWM:不只是调占空比

PWM在电赛里出现频率极高:舵机控制、电机调速、RGB调光、DAC模拟输出。但很多人只会用__HAL_TIM_SET_COMPARE改占空比,不知道频率怎么算。

以STM32F103为例,TIM1挂在APB2上,时钟72MHz。如果你要出50Hz的舵机PWM,预分频器(PSC)和自动重装载值(ARR)这样算:

  • 目标频率 = 72MHz / ((PSC+1) * (ARR+1))
  • 取PSC=71,则计数频率=1MHz
  • 取ARR=19999,则周期=20ms,即50Hz

代码:

htim1.Instance = TIM1; htim1.Init.Prescaler = 71; htim1.Init.Period = 19999; htim1.Init.CounterMode = TIM_COUNTERMODE_UP; HAL_TIM_PWM_Init(&htim1); TIM_OC_InitTypeDef sConfigOC = {0}; sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 1500; // 1.5ms脉宽,舵机中位 sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; HAL_TIM_PWM_ConfigChannel(&htim1, &sConfigOC, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1);

提示:舵机的Pulse值对应0.5ms到2.5ms,对应0度到180度。但不同品牌舵机有差异,建议先用示波器看实际输出,或者用“慢扫”方式找边界。

PWM还有一个高级用法:单脉冲模式。电赛里有时需要发一个精确宽度的脉冲,比如触发某传感器。用TIM的单脉冲模式,配置好ARR和Pulse,触发一次就自动停止,比用延时函数精准得多。

2.3 ADC:采样周期和滤波是两回事

ADC是电赛信号类的核心。很多人采出来的数据跳得厉害,第一反应是“ADC不准”,其实是采样周期没配对。

STM32F103的ADC时钟最高14MHz,采样周期可选1.5、7.5、13.5、28.5、41.5、55.5、71.5、239.5个周期。总转换时间 = 采样周期 + 12.5个周期。如果你要采一个高阻抗信号源,采样周期太短会导致采样电容充不满,读数偏低。

我的经验是:默认用55.5或71.5周期,除非你确定信号源阻抗很低。代码:

ADC_ChannelConfTypeDef sConfig = {0}; sConfig.Channel = ADC_CHANNEL_0; sConfig.Rank = 1; sConfig.SamplingTime = ADC_SAMPLETIME_71CYCLES_5; HAL_ADC_ConfigChannel(&hadc1, &sConfig);

采到数据后,滤波是另一回事。电赛里常用的滤波有:均值滤波、中值滤波、一阶低通滤波。均值滤波适合消除随机噪声,中值滤波适合消除脉冲干扰,一阶低通适合跟踪缓慢变化的信号。

一阶低通滤波的C语言实现:

float alpha = 0.1f; // 滤波系数,越小越平滑但滞后越大 float filtered = 0.0f; filtered = alpha * new_value + (1 - alpha) * filtered;

注意:alpha的选择要看信号频率。如果你采的是50Hz工频信号,采样率1kHz,alpha取0.1左右比较合适。如果信号本身变化很快,alpha要调大,否则会把有用信号滤掉。

还有一个坑:ADC的参考电压。STM32F103的VREF+默认接VDDA,如果你用USB供电,VDDA可能只有4.7V左右,不是精确的5V。电赛里如果要求测量精度,最好用外部基准源,比如REF3033。没有基准源的话,至少用万用表量一下VDDA实际值,在代码里做校准。

2.4 TIM:不只是延时

TIM在电赛里承担三个角色:精确定时、PWM输出、输入捕获。精确定时用HAL_TIM_Base_Start_IT,然后在中断里做事情。输入捕获用来测频率、测脉宽,比如超声波测距的回波信号。

输入捕获的配置稍微复杂,核心是设置捕获边沿和预分频。以测频率为例:

TIM_IC_InitTypeDef sConfigIC = {0}; sConfigIC.ICPolarity = TIM_INPUTCHANNELPOLARITY_RISING; sConfigIC.ICSelection = TIM_ICSELECTION_DIRECTTI; sConfigIC.ICPrescaler = TIM_ICPSC_DIV1; sConfigIC.ICFilter = 0; HAL_TIM_IC_ConfigChannel(&htim2, &sConfigIC, TIM_CHANNEL_1); HAL_TIM_IC_Start_IT(&htim2, TIM_CHANNEL_1);

在中断回调里,记录两次上升沿之间的计数值,乘以计数周期就是周期,取倒数就是频率。

提示:测高频信号时,如果频率超过定时器时钟的一半,会溢出。STM32F103的TIM2挂在APB1上,时钟72MHz,理论上能测36MHz,但实际受中断响应时间限制,建议不超过1MHz。再高就用外部计数器或FPGA。

2.5 IRQ:中断优先级是门学问

中断(IRQ)是单片机的“神经反射”。电赛里常见的中断源:定时器溢出、ADC转换完成、UART接收、外部按键。中断优先级配置不对,会出现“串口数据丢了”“按键不响应”这类问题。

STM32的NVIC优先级分抢占优先级和子优先级。抢占优先级高的可以打断低的,子优先级只在同时挂起时决定谁先执行。我的建议是:

  • 串口接收中断:抢占优先级1
  • 定时器中断:抢占优先级2
  • ADC中断:抢占优先级3
  • 按键中断:抢占优先级4

这样串口数据不会因为定时器中断而丢失。配置代码:

HAL_NVIC_SetPriority(USART1_IRQn, 1, 0); HAL_NVIC_EnableIRQ(USART1_IRQn);

注意:中断服务函数里不要做耗时操作。我见过有人在串口中断里做浮点运算和字符串拼接,结果主循环卡死。正确做法是:中断里只把数据存到缓冲区,置个标志位,主循环里处理。

2.6 I2C:时序和上拉电阻

I2C在电赛里常用于连接EEPROM、OLED、传感器。HAL库的I2C函数用起来简单,但硬件I2C容易卡死,这是STM32F103的已知问题。我的建议是:能用软件I2C就用软件I2C,或者用HAL_I2C_Mem_Read/Write的超时版本。

软件I2C的核心是延时。延时太短,时序不满足;延时太长,速度慢。对于100kHz的I2C,半周期约5us。用__NOP()循环实现:

void I2C_Delay(void) { for (volatile int i = 0; i < 10; i++); }

上拉电阻选4.7kΩ到10kΩ。电阻太小,功耗大;电阻太大,上升沿变缓,高速通信会出错。电赛里如果I2C设备多,总线电容大,上拉电阻要适当减小。

2.7 SPI:速度与片选

SPI比I2C快,常用于驱动TFT屏、Flash、高速ADC。SPI的四个信号:SCK、MOSI、MISO、CS。CS片选是关键,很多新手忘了拉低CS,导致通信失败。

SPI的配置重点是时钟极性和相位(CPOL和CPHA)。这四个组合对应四种模式,必须和从设备手册一致。比如某款ADC用模式1(CPOL=0,CPHA=1),你配成模式0就读不到数据。

hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; hspi1.Init.CLKPhase = SPI_PHASE_2EDGE;

提示:SPI的CS可以用软件控制,也可以用硬件NSS。软件控制更灵活,但要注意在传输前后手动拉低拉高。

2.8 UART:电赛的“生命线”

UART是电赛里最重要的外设之一,因为它是和上位机、蓝牙、WiFi模块通信的主要方式。电赛现场经常需要把数据传到电脑上画波形,或者用手机APP控制。

UART的配置重点是波特率。常见115200,但如果你用蓝牙模块,可能只支持9600。配置不对就是乱码。

huart1.Instance = USART1; huart1.Init.BaudRate = 115200; huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_TX_RX; HAL_UART_Init(&huart1);

接收用中断或DMA。中断方式适合不定长数据,DMA方式适合大量数据。电赛里我常用空闲中断+DMA,一帧数据接收完触发空闲中断,效率高。

2.9 WDG:看门狗是保命符

看门狗(WDG)在电赛里容易被忽略,但现场环境复杂,电源波动、静电干扰都可能导致程序跑飞。独立看门狗(IWDG)用内部低速时钟,即使主时钟挂了也能复位。

配置IWDG:

hiwdg.Instance = IWDG; hiwdg.Init.Prescaler = IWDG_PRESCALER_64; hiwdg.Init.Reload = 4095; // 约1秒超时 HAL_IWDG_Init(&hiwdg);

然后在主循环里定期喂狗:

HAL_IWDG_Refresh(&hiwdg);

注意:喂狗间隔要小于超时时间。如果你在中断里喂狗,主循环卡死时中断还在跑,看门狗就失效了。正确做法是在主循环里喂狗。

2.10 DMA:解放CPU

DMA在电赛里用于ADC多通道采集、UART大量数据传输、SPI刷屏。没有DMA,CPU要一直等着数据搬完,效率低。

ADC+DMA的配置:

HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, BUFFER_SIZE);

这样ADC采完一个通道自动存到数组里,采完一轮触发中断。电赛里做FFT分析时,用DMA采1024个点,CPU同时做其他事情。

提示:DMA的缓冲区要用__attribute__((aligned(4)))对齐,否则可能出错。这个坑我在某次电赛中踩过,数据偶尔错位,查了半天。

3. 电赛实战:从外设到系统

3.1 典型题目拆解:信号测量与显示

假设题目要求:测量一个0-3.3V的直流信号,用OLED显示电压值,同时通过UART上传到电脑。

系统链路:ADC采样 -> 滤波 -> 计算电压 -> OLED显示 -> UART发送。

ADC用DMA采100个点,存到数组。主循环里做均值滤波,乘以3.3/4096得到电压。OLED用I2C驱动,UART用DMA发送。

关键点:

  • ADC的参考电压要准,最好用万用表量VDDA
  • OLED刷新率不要太高,否则I2C占用太多时间
  • UART发送用DMA,避免阻塞主循环

3.2 典型题目拆解:电机控制

题目要求:用PWM控制直流电机转速,用编码器测速,用PID闭环。

系统链路:TIM PWM输出 -> 电机驱动 -> 编码器 -> TIM输入捕获 -> PID计算 -> 调整PWM。

编码器用TIM的编码器模式,直接读计数。PID用位置式或增量式,电赛里增量式更常用,因为不需要积分限幅。

int PID_Incremental(int error) { static int last_error = 0, prev_error = 0; int delta = Kp * (error - last_error) + Ki * error + Kd * (error - 2*last_error + prev_error); prev_error = last_error; last_error = error; return delta; }

注意:PID参数要现场调。先调Kp,让系统有响应但不过冲;再加Ki消除静差;最后加Kd抑制振荡。

3.3 典型题目拆解:无线通信

题目要求:两块单片机之间无线传输数据。

方案:UART + 无线模块。一块单片机采集数据,通过UART发给无线模块,另一块接收。

关键点:

  • 无线模块的波特率要和单片机一致
  • 数据包要加帧头和校验,防止误码
  • 接收端用空闲中断+DMA,避免丢包

4. 常见问题与排查技巧实录

4.1 程序下载后不运行

现象:下载成功,但板子没反应。

排查:

  1. 检查BOOT0和BOOT1引脚,必须都接地
  2. 检查复位电路,复位键是否卡住
  3. 检查晶振,用示波器看有没有起振
  4. 检查电源,3.3V是否正常

我的经验:有一次是晶振旁边的负载电容焊错了,换成20pF就好了。

4.2 ADC读数跳动大

现象:采固定电压,读数在±50以内跳。

排查:

  1. 采样周期是否太短,改成239.5周期试试
  2. 参考电压是否稳定,用示波器看VDDA
  3. 模拟地和数字地是否单点接地
  4. 信号源阻抗是否太高,加电压跟随器

我的经验:电赛现场电源噪声大,ADC的VDDA和VSSA之间并一个10uF和0.1uF电容,效果明显。

4.3 I2C通信失败

现象:HAL_I2C_Master_Transmit返回HAL_ERROR。

排查:

  1. 上拉电阻是否接了,4.7kΩ
  2. 从设备地址是否正确,注意7位地址要左移一位
  3. 时序是否满足,用逻辑分析仪抓波形
  4. 是否卡在BUSY状态,需要重新初始化I2C

我的经验:STM32F103的硬件I2C有BUG,如果卡死,用HAL_I2C_DeInit再HAL_I2C_Init能恢复。但最好用软件I2C。

4.4 串口乱码

现象:电脑收到乱码。

排查:

  1. 波特率是否一致,115200对115200
  2. 晶振频率是否正确,8MHz对8MHz
  3. 数据位、停止位、校验位是否匹配
  4. 地线是否共地

我的经验:有一次是USB转串口模块的晶振是12MHz,但驱动里选的是8MHz,导致波特率偏差。换模块就好了。

4.5 看门狗导致频繁复位

现象:程序跑一会儿就复位。

排查:

  1. 喂狗间隔是否小于超时时间
  2. 是否有长时间阻塞的操作,比如延时
  3. 是否在中断里喂狗

我的经验:把喂狗放在主循环开头,确保每次循环都喂。如果某个任务耗时超过超时时间,把看门狗超时调大。

5. 工具链与开发环境:少走弯路

5.1 开发工具选择

  • IDE:STM32CubeIDE,免费,集成CubeMX和编译调试
  • 配置工具:STM32CubeMX,图形化配置引脚和外设
  • 下载器:ST-Link V2,便宜好用
  • 调试器:ST-Link配合IDE的调试功能,能看变量、设断点

5.2 代码组织建议

电赛代码要模块化,每个外设一个.c和.h文件。主循环里用状态机,避免阻塞。

while (1) { switch (state) { case STATE_IDLE: if (start_flag) state = STATE_SAMPLE; break; case STATE_SAMPLE: // 采样 state = STATE_PROCESS; break; case STATE_PROCESS: // 处理 state = STATE_OUTPUT; break; case STATE_OUTPUT: // 输出 state = STATE_IDLE; break; } HAL_IWDG_Refresh(&hiwdg); }

5.3 版本管理

电赛现场改代码频繁,建议用Git做本地版本管理。每次改完提交一次,出问题能回滚。

git init git add . git commit -m "ADC+DMA可用版本"

6. 从零基础到电赛获奖:我的学习路径建议

如果你是完全零基础,我建议按这个顺序:

  1. 第一天:GPIO输出,点亮LED,学会用CubeMX配引脚
  2. 第二天:GPIO输入,按键控制LED,理解上拉和浮空
  3. 第三天:UART,和电脑通信,打印“Hello”
  4. 第四天:TIM定时中断,1秒翻转LED
  5. 第五天:PWM,控制LED亮度,理解占空比
  6. 第六天:ADC,采电位器电压,UART打印
  7. 第七天:I2C,驱动OLED,显示电压
  8. 第八天:SPI,驱动TFT屏
  9. 第九天:DMA,ADC多通道采集
  10. 第十天:综合项目,信号测量与显示

这个路径的关键是每天都有可见的成果。不要一开始就啃寄存器手册,先用HAL库跑起来,再回头看原理。

提示:电赛前一个月,找历年题目做模拟。限时4天3夜,完整走一遍流程。这个训练比看视频有用得多。

7. 一些不写在教材里的经验

关于示波器:电赛现场一定要带示波器。PWM波形、UART波形、I2C波形,看一眼就知道问题在哪。没有示波器,用逻辑分析仪也行,但逻辑分析仪只能看数字信号。

关于电源:电赛现场电源质量参差不齐。带一个线性稳压电源,或者至少带几个大电容。电机启动时电流冲击大,可能导致单片机复位。

关于代码备份:U盘、网盘、邮箱,多处备份。我见过有人电脑蓝屏,代码全丢,现场重写。

关于队友分工:一个人写代码,一个人调硬件,一个人写报告。不要三个人都写代码,也不要三个人都调硬件。

关于时间分配:第一天确定方案,第二天搭硬件,第三天写代码,第四天调试和写报告。不要前两天都在纠结方案。

关于心态:电赛现场一定会遇到问题。遇到问题不要慌,先看现象,再查资料,再问队友。实在不行,换方案。能跑起来的简单方案,比跑不起来的复杂方案强。

最后分享一个我常用的调试技巧:用LED做状态指示。程序跑到哪一步,LED就亮哪个。比串口打印快,而且不占用UART。比如:

LED1_ON(); // 进入ADC采样 // ... ADC代码 LED1_OFF(); LED2_ON(); // 进入数据处理 // ... 处理代码 LED2_OFF();

这样一眼就能看出程序卡在哪。这个技巧在电赛现场救过我很多次。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询