☰
嵌入式系统实验指导:6个实验覆盖GPIO到RTOS的关键调试
2026/10/15 6:23:15 网站建设 项目流程

简介:《嵌入式系统实验指导书(6个实验)》是襄樊学院物理与电子信息工程学院发布的实验教学资料,面向高校嵌入式系统课程学生,基于Keil C51与ZY15MCU12BD型综合单片机实验箱编写,系统解决从工程创建、汇编编程到仿真调试的全流程实验需求。资源为1个doc文档,约965KB,内容覆盖6个实验,每个实验均包含实验目的、实验设备、实验原理及环境、实验内容、实验步骤和实验报告要求。首个实验详细演示了Keil C51的使用,包括建立工程、添加汇编源文件、生成HEX、软硬件仿真等操作;后续实验涉及十六进制与十进制转换等典型汇编编程任务。指导书对实验预习、实验室操作和报告撰写均有明确规范,强调实验总结中需分析调试过程、错误原因及收获,并提供源程序示例和操作提示,适合需要按步骤完成实验并撰写规范报告的学生直接参考。已有338人浏览/学习。

1. 嵌入式系统实验指导书:6个实验为什么值得从点亮LED开始

拿到一份嵌入式系统实验指导书,大多数人不是被中断、时序、状态机这些概念难住的,而是被第一个实验就跑不起来难住的:编译过了、下载成功了,板子上的灯就是不亮,然后开始怀疑接线、怀疑芯片、怀疑人生。这份指导书的6个实验,从GPIO点灯到按键中断,从串口通信到PWM与ADC联动,再到I2C传感器读写和RTOS多任务,恰好覆盖了嵌入式开发里最高频的几类外设操作和任务调度问题。对刚学完C语言、手头有一块常见MCU开发板的读者来说,这套主线是投入产出比最高的练习路径。每个实验能独立跑通,又能直接复用进自己的小项目。

2. 实验一与实验二:GPIO点灯和按键中断,把输入输出练扎实

2.1 为什么点灯是第一个实验:GPIO模式的行为差异与最小代码

点灯实验看着简单,实际上是把GPIO的推挽输出、开漏输出、上拉输入、下拉输入这些模式差异全部暴露出来的唯一场景。很多初学者在点灯之前已经背熟了“GPIO有8种模式”这句话,但真到了写代码的时候,还是不知道什么时候该用推挽、什么时候该用开漏。LED用推挽输出,因为驱动能力强、高低电平都是主动驱动;I2C的SDA线必须用开漏加外部上拉,因为协议要求多设备“线与”;按键检测用上拉输入,因为按键另一端接地时,按下才能读到确定低电平。

/* 以某Cortex-M内核MCU的寄存器风格为例,LED接PA5,低电平点亮 */ void led_gpio_init(void) { /* 第1步永远是打开外设时钟,这一步漏了,后面全白写 */ RCC->AHBENR |= RCC_AHBENR_GPIOAEN; /* PA5配为推挽输出,速度选2MHz即可 */ GPIOA->MODER &= ~(0x3UL << (5 * 2)); /* 先清模式位 */ GPIOA->MODER |= (0x1UL << (5 * 2)); /* 01 = 通用输出 */ GPIOA->OTYPER &= ~(0x1UL << 5); /* 0 = 推挽 */ GPIOA->OSPEEDR &= ~(0x3UL << (5 * 2)); GPIOA->OSPEEDR |= (0x1UL << (5 * 2)); /* 2MHz,够用 */ GPIOA->PUPDR &= ~(0x3UL << (5 * 2)); /* 输出模式不需要上下拉 */ } void led_blink_test(void) { volatile uint32_t i; while (1) { GPIOA->ODR ^= (0x1UL << 5); /* 异或翻转,不关心当前电平 */ for (i = 0; i < 1000000; i++); /* 软件延时约0.5秒,具体看主频 */ } }

这段代码的逻辑顺序是固定的:先开时钟,再配模式,最后操作数据。漏掉时钟使能是最常见的翻车点,因为寄存器写不进或写进去不生效,看起来就像“芯片坏了”。MODER寄存器每两位控制一个引脚,所以清除时要按引脚号乘以2去移位;OTYPER控制推挽还是开漏;OSPEEDR控制翻转速度,不是越快越好,高速边沿会带来更多噪声和功耗,2MHz点亮LED完全够用。PUPDR在输出模式下一般不设,只有输入模式才需要选择上拉或下拉。

2.2 按键中断实验的必调参数:消抖时长、触发边沿与中断优先级

按键中断实验的关键不在中断服务函数里那句点亮LED的代码,而在三个参数:触发边沿选哪个、消抖时长给多少、中断优先级怎么设。按键一端接PA0,另一端接地,默认引脚被上拉电阻拉高,按下时变成低电平,所以触发方式要选下降沿,也就是Falling Edge。如果按键接法是反的,按下变成高电平,就得上拉改成下拉、边沿改成上升沿。

/* 按键接PA0到地,默认高电平,下降沿触发EXTI0 */ void key_exti_init(void) { RCC->AHBENR |= RCC_AHBENR_GPIOAEN; RCC->APB2ENR |= RCC_APB2ENR_SYSCFGEN; /* 外部中断需要系统配置时钟 */ /* PA0配上拉输入 */ GPIOA->MODER &= ~(0x3UL << (0 * 2)); /* 00 = 输入 */ GPIOA->PUPDR &= ~(0x3UL << (0 * 2)); GPIOA->PUPDR |= (0x1UL << (0 * 2)); /* 01 = 上拉 */ /* PA0映射到EXTI0,选下降沿触发 */ SYSCFG->EXTICR[0] &= ~0x000F; SYSCFG->EXTICR[0] |= 0x0000; /* 0 = GPIOA */ EXTI->IMR |= 0x0001; /* 不屏蔽EXTI0 */ EXTI->FTSR |= 0x0001; /* 下降沿 */ EXTI->RTSR &= ~0x0001; /* 关闭上升沿 */ NVIC_SetPriority(EXTI0_IRQn, 2); /* 优先级给中间值 */ NVIC_EnableIRQ(EXTI0_IRQn); } void EXTI0_IRQHandler(void) { /* 第一步永远是清标志,不清标志会反复进中断 */ if (EXTI->PR & 0x0001) { EXTI->PR = 0x0001; /* 软件消抖:延时后再读一次引脚确认 */ delay_ms(20); if ((GPIOA->IDR & 0x0001) == 0) { g_key_pressed = 1; /* 真正确认按下 */ } } }

消抖时长给20毫秒,这是机械按键的典型抖动窗口。比这短,消不干净;比这长,按下去的感觉会迟钝。要强调的是,中断服务函数里最好不要用阻塞式延时,这个写法只是演示消抖思路,正式工程应该用定时器或系统tick做状态判断。中断优先级不是越大越好也不是越小越好:给太高会抢走系统调度的资源,给太低在紧急任务时可能被延迟响应。一般中等优先级、数值居中,够入门阶段用,后续做RTOS时要统一规划。

2.3 点灯与按键的3个高频排查点

点灯不亮,先别怀疑芯片烧了,按这个顺序查。第一,时钟没使能,代码写得再对,寄存器不响应也没用,读一下RCC相关寄存器的值,确认GPIOAEN那一位真的置1了。第二,引脚号看错,开发板的LED可能接在PB5而不是PA5,原理图不看清楚就写代码,是实验里最常见的低级错误。第三,模式配置被覆盖,有些人先跑过别的实验,GPIOA的MODER被改成复用模式,重新配置时只置位不先清位,结果模式还是旧的。

按键实验的坑也有两个常见现象。现象一:按一下触发了好几次,原因是中断标志没清或者消抖延时太短,回到代码里检查EXTI->PR的清除语句是否在中断入口处。现象二:按了没反应,先查引脚是否配成了推挽输出,输出模式读IDR是没意义的;再查EXTI的映射寄存器EXTICR,PA0要映射到EXTI0,PA1要映射到EXTI1,映射错了中断源根本不会触发。这类问题看着像玄学,根源都在配置的某个位没写对。

3. 实验三与实验四:串口通信和PWM-ADC联动,让数据真正流动起来

3.1 printf重定向与串口接收状态机:一行一行调试的基础

串口实验的价值不只是“能收发”,而是让你拥有调试的眼睛。没有串口之前,代码跑没跑、跑到哪一步、变量的值是多少,全得靠猜;有了printf重定向,就相当于在代码里开了个窗户。常见做法是重写fputc函数,把标准输出导向串口,同时配合一个基于中断的接收状态机,这样上位机发的每一帧数据都能被完整收下来。

/* 以某Cortex-M内核MCU的寄存器风格为例,USART1接PA9/PA10 */ void uart1_init(uint32_t baud) { RCC->AHBENR |= RCC_AHBENR_GPIOAEN; RCC->APB2ENR |= RCC_APB2ENR_USART1EN; /* PA9配置为复用模式,AF选择USART */ GPIOA->MODER &= ~(0x3UL << (9 * 2)); GPIOA->MODER |= (0x2UL << (9 * 2)); /* 10 = 复用 */ GPIOA->AFR[1] |= (0x1UL << 4); /* PA9复用为USART1_TX */ GPIOA->AFR[1] |= (0x1UL << 8); /* PA10复用为USART1_RX */ /* 波特率 = 外设时钟 / 分频系数,时钟源不同则结果差很多 */ USART1->BRR = (uint16_t)(Get_PCLK_Freq() / baud); USART1->CR1 = USART_CR1_TE | USART_CR1_RE | USART_CR1_RXNEIE; USART1->CR1 |= USART_CR1_UE; /* 最后使能串口 */ } /* printf重定向,所有printf输出走串口 */ int fputc(int ch, FILE *f) { while (!(USART1->ISR & USART_ISR_TXE)); /* 等发送寄存器空 */ USART1->TDR = ch; return ch; } /* 接收状态机:以换行符为一帧结束 */ #define RX_BUF_LEN 64 volatile uint8_t rx_buf[RX_BUF_LEN]; volatile uint16_t rx_cnt = 0; volatile uint8_t rx_done = 0; void USART1_IRQHandler(void) { uint8_t ch; if (USART1->ISR & USART_ISR_RXNE) { ch = USART1->RDR; /* 读数据同时清标志 */ if (rx_done == 0) { if (ch == '\n') { /* 帧结束 */ rx_buf[rx_cnt] = '\0'; rx_done = 1; } else if (rx_cnt < RX_BUF_LEN - 1) { rx_buf[rx_cnt++] = ch; } } } }

波特率这里最值得较真。BRR寄存器算出来的分频系数,依据的是串口所在的APB总线时钟,不是芯片主频。很多人直接拿主频去除,出来的波特率偏了,结果就是上位机能收到数据但全乱码。接收状态机用换行符做帧边界,比按固定长度接收更实际,能适应不同长度的命令。RX_BUF_LEN留一个字节给结束符,避免数组越界。串口参数最常用的是115200、8N1,也就是8个数据位、无校验、1个停止位,所有设备的这组参数必须一致。

3.2 PWM与ADC联动实验:占空比、采样率和参考电压怎么配

PWM和ADC放一起做联动,是为了让两个模块产生因果关系:用电位器旋转改变ADC采样值,ADC值再映射成PWM占空比,LED亮度随之变化。这样一来,哪个环节出了问题,肉眼就能看出来,比单独跑PWM呼吸灯和单独读ADC更能训练排查思维。

/* TIM2_CH1输出PWM,PA0复用为通道1,频率1kHz */ void pwm_init(uint16_t freq_hz) { uint32_t timer_clk = Get_TIM2_CLK(); /* 定时器时钟来自APB */ uint32_t period = timer_clk / freq_hz - 1; RCC->AHBENR |= RCC_AHBENR_GPIOAEN; RCC->APB1ENR |= 0x00000001; /* TIM2时钟使能 */ GPIOA->MODER &= ~(0x3UL << (0 * 2)); GPIOA->MODER |= (0x2UL << (0 * 2)); /* PA0复用模式 */ /* 复用功能选择为TIM2_CH1,具体看AFR寄存器的映射表 */ TIM2->ARR = period; /* 自动重装值决定周期 */ TIM2->CCR1 = 0; /* 初始占空比0% */ TIM2->CCMR1 |= (0x6UL << 4); /* PWM模式1 */ TIM2->CCER |= (0x1UL << 0); /* 使能CH1输出 */ TIM2->CR1 |= 0x1; /* 启动定时器 */ } /* ADC1_IN0采样电位器电压,12位分辨率 */ uint16_t adc_read_once(void) { ADC1->SQR3 = 0; /* 选通道0 */ ADC1->CR2 |= ADC_CR2_ADON; /* 启动转换 */ while (!(ADC1->SR & ADC_SR_EOC)); /* 等待转换结束 */ return (uint16_t)(ADC1->DR & 0x0FFF); }

PWM频率选1kHz,这个值在LED亮度控制上够平滑,又不会高到产生啸叫。ARR决定周期,CCR决定高电平宽度,占空比就是CCR除以ARR。ADC的12位分辨率对应0到4095,和PWM的占空比映射时要注意精度:直接把4096种ADC值映射到100档占空比,可以用除法也可以用查表,但不要用浮点数硬算,MCU吃不消。ADC参考电压不一定是3.3V,有些开发板参考电压来自内部基准,波动会导致读数偏移,做联动实验时先读一下空载值做软件校准。

3.3 串口乱码与PWM无波形:两个常见问题的判断顺序

串口乱码,先分清是“全是乱码”还是“偶尔乱码”。全是乱码,九成是波特率不准,按波特率计算公式反推总线时钟配置,检查外部晶振频率和PLL分频是否和代码一致。偶尔乱码,多半是发送端或接收端的中断没有处理好,数据被覆盖了。实战中还有一个被忽略的问题:用USB转串口模块时,模块的晶振精度和电脑驱动配置也会造成偏移,换一个模块试试是最快的排除法。

PWM没波形,别急着查GPIO,先查定时器有没有跑起来。读CR1的CEN位和SR状态的更新标志,确认定时器时钟和中断配置正确。然后查AFR寄存器的复用映射,PA0的复用功能不止一个,映射错了波形出不来。最后再用示波器或逻辑分析仪看引脚,每查一步只改一个变量。这个排查顺序能省下大量时间,因为GPIO配置错误和定时器配置错误的表现完全一样——引脚上什么都没有。

4. 实验五与实验六:I2C传感器读写和RTOS多任务,收尾协议与调度

4.1 I2C传感器读写:软件时序、上拉电阻与应答位检查

I2C实验选温湿度传感器而不选EEPROM,是因为EEPROM的读写太机械,起不到训练协议思维的作用。温湿度传感器需要发送地址和命令、切换寄存器、读取校准后的数据,整个过程要经历多次起始、停止、ACK和NACK,把I2C协议的所有关键点都过了一遍。而且数据读回来还得自己算成实际温度和湿度,能顺便练习数据处理。

/* 软件I2C:用两个GPIO模拟时序,先看起始和停止 */ #define I2C_SCL_PIN (1 << 6) #define I2C_SDA_PIN (1 << 7) void i2c_start(void) { SDA_HIGH(); SCL_HIGH(); delay_us(5); /* 起始条件:SCL高电平时SDA由高变低 */ SDA_LOW(); delay_us(5); SCL_LOW(); /* 拉低SCL,准备传数据 */ } void i2c_stop(void) { SCL_LOW(); SDA_LOW(); delay_us(5); SCL_HIGH(); delay_us(5); SDA_HIGH(); /* 停止条件:SCL高电平时SDA由低变高 */ delay_us(5); } /* 写一个字节后等主机检查ACK,这一步最容易忽略 */ uint8_t i2c_write_byte(uint8_t data) { uint8_t ack; for (uint8_t i = 0; i < 8; i++) { SCL_LOW(); if (data & 0x80) SDA_HIGH(); else SDA_LOW(); delay_us(2); SCL_HIGH(); delay_us(2); data <<= 1; } SCL_LOW(); SDA_INPUT(); /* 释放SDA,让从机拉低表示应答 */ delay_us(2); SCL_HIGH(); delay_us(2); ack = SDA_READ(); SCL_LOW(); SDA_OUTPUT(); return ack; /* 0 = ACK,1 = NACK */ }

软件I2C的时序延时一般给2到5微秒,太短了从机跟不上,太长了通信速率低。SDA方向切换是这个实验最大的坑:写完8个数据位之后,必须把SDA从输出模式切回输入模式才能读到ACK,不切换的话读到的永远是自己的输出电平。I2C地址要特别注意7位地址和8位地址的换算,很多传感器资料上写的是7位地址,而I2C发送格式里地址字节是8位,左移一位。读温湿度的流程是:起始、写从机地址加写位、写寄存器地址、重启起始、写从机地址加读位、读数据、主机发NACK、停止。这个流程错一步,数据就是错的。

4.2 RTOS多任务实验:任务栈、优先级与队列的第一次配合

RTOS实验放到最后,是因为它需要前面所有实验的积累。串口用来打印任务运行日志,定时器提供时间基准,GPIO控制LED指示状态,每个任务都像一个小型实验的应用。第一个RTOS实验不追求复杂,两个任务加一个队列就够了:一个任务定期采集传感器数据,把结果发送到队列;另一个任务从队列接收数据,控制LED闪烁频率。

/* 以常见开源RTOS内核的API风格为例 */ #define LED_TASK_STACK_SIZE 128 /* 单位是“字”,不是“字节” */ #define SENSOR_TASK_STACK_SIZE 256 TaskHandle_t g_led_task_handle; TaskHandle_t g_sensor_task_handle; QueueHandle_t g_data_queue; void vLedTask(void *param) { uint32_t value; for (;;) { if (xQueueReceive(g_data_queue, &value, pdMS_TO_TICKS(100)) == pdPASS) { /* 拿到数据,翻转LED */ GPIOA->ODR ^= (0x1UL << 5); } vTaskDelay(pdMS_TO_TICKS(200)); } } void vSensorTask(void *param) { uint32_t sample; for (;;) { sample = adc_read_once(); xQueueSend(g_data_queue, &sample, 0); vTaskDelay(pdMS_TO_TICKS(500)); } } void rtos_demo_init(void) { g_data_queue = xQueueCreate(4, sizeof(uint32_t)); xTaskCreate(vLedTask, "led", LED_TASK_STACK_SIZE, NULL, 1, &g_led_task_handle); xTaskCreate(vSensorTask, "sensor", SENSOR_TASK_STACK_SIZE, NULL, 2, &g_sensor_task_handle); /* 最后才启动调度器,前面的初始化都算任务创建阶段 */ vTaskStartScheduler(); }

栈大小是RTOS入门最常见的翻车点。128表示128个字,也就是512字节,不是128字节。任务里用了printf、使用了浮点库、调用了较深的函数嵌套,栈需求会成倍增加,新手建议从256字起步,跑稳定后再慢慢调小。优先级数值的含义要查内核手册,有的内核数值越大优先级越高,有的正好相反,很多人的任务“跑不动”就是因为优先级配反了。队列深度给4,满了之后xQueueSend会返回错误,这里用的超时时间0表示不等待,如果改成等待就可能阻塞任务。

4.3 I2C读不到数据和RTOS任务卡死的快速区分

I2C实验最常见的现象是读回来全是0xFF,或卡在等待ACK。读回来0xFF说明总线时序没问题,但从机没有把SDA拉低应答,原因要么是从机地址不对,要么是上拉电阻太大导致边沿太缓。卡在等待ACK则是接线层面的问题,最常见的是SDA线和SCL线接反,或者从机没供电。RTOS任务卡死的现象如果是复位重启,多半是栈溢出;如果是任务不执行但系统还活着,查优先级和队列超时时间;如果是连空闲任务都不执行,查中断优先级是否封死了系统节拍。

5. 嵌入式实验的高频翻车点:5个踩坑记录与排查路径

5.1 点灯不亮:时钟使能与引脚复用是最容易漏的两步

现象:程序下载成功,复位运行,LED完全不亮。第一步查时钟使能寄存器,确认GPIOAEN那一位真的置位;第二步查原理图,确认LED接的是哪个引脚、亮灯的电平逻辑;第三步查模式配置,MODER是否被之前的实验代码改成了复用模式。曾有人在模拟项目X里查了半天,最后发现是开发板上LED的限流电阻虚焊。硬件问题用万用表测一下引脚电平就能排除,不要盲目改代码。

5.2 串口乱码:外部晶振频率和分频配置不一致

现象:串口能收到数据,但内容是乱码,而且乱得很有规律。用示波器看TX脚的实际波特率,如果实测值是115200的一半或两倍,基本都是时钟源的配置问题。外部晶振是8MHz但代码按12MHz计算分频,实际波特率就偏了,这就是为什么换一块板子跑同样的代码会乱码。解决方法是把时钟树从头理一遍:外部晶振、PLL倍频、总线分频,每一层都确认后再去算BRR。

5.3 按键重复触发或没反应:中断标志与消抖时序

现象:按一下按键,LED闪烁了三四次,或者干脆没反应。重复触发的原因是EXTI->PR标志没有被及时清除,中断服务函数刚退出又立刻重进;没反应的原因是消抖延时期间引脚电平刚好处于抖动状态,判断结果不稳定。解决方法是把消抖延时的判断从“延时后读一次”改成“延时后连续读多次都相同才确认”,并且确保标志清除在中断服务函数的入口处。

5.4 ADC读数漂移:参考电压噪声与采样时间过短

现象:电位器不动,ADC值也在几十到几百之间跳。先检查采样时间,采样时间过短时内部采样电容来不及充满,转换结果就不稳定;再检查参考电压引脚,如果参考电压直接来自电源,电源纹波会原样进ADC。简单有效的办法是软件多次采样取平均,至少连续读16次去掉最大值最小值再平均。硬件上可以在参考电压引脚加一个100nF到10uF的滤波电容,效果立竿见影。

5.5 RTOS任务卡死:栈溢出和优先级配置

现象:RTOS运行一会儿就复位,或者某个任务永远不执行。复位的优先怀疑栈溢出,用内核提供的栈水线统计API查看任务栈剩余量,把栈大小从256加到512再观察;任务不执行先查优先级,确认在用的内核里数值越大优先级越高还是越低,再查任务是否在等待一个永远不会到的信号量。队列发送用超时时间0时,如果队列满会直接返回失败,任务得做错误处理,不能默认发送成功。

6. 把6个实验沉淀成一个调试模板:从照抄到上手

6.1 一个可复用的外设初始化模板

做完6个实验之后,我一般会把代码整理成固定结构:每个外设一个_init函数、一个_handler函数、一个_test函数。_init只负责配置,不跑业务逻辑;_handler只处理数据,不阻塞等待;_test只用于验证功能,验证完就从正式流程里删掉。这样做的价值在于,每一个外设模块都可以独立测试、独立替换。比如换一块开发板,只需要改_init函数里的引脚映射和时钟配置,业务逻辑一行都不用动。

6.2 验证方法的三个层次

验证外设有没有跑通,按三个层次来。第一层看电平:用万用表或示波器量引脚,确认有信号输出或输入;第二层看波形:用逻辑分析仪抓I2C或串口的时序,确认数据和协议完全正确;第三层看数据:打印出转换后的温度、湿度、ADC值,和预期值对比。踩过一次I2C地址换算的坑后,我养成了一个习惯:实验跑不通,先查配置和硬件,再怀疑代码逻辑,这也是这6个实验想训练的最核心能力。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询