☰
Cortex-M4六实验复现:GPIO/串口/PWM/Flash/SPI/看门狗
2026/10/28 4:27:57 网站建设 项目流程

简介:《嵌入式系统》实验报告系列面向高校嵌入式课程学习者,围绕苏州大学金葫芦平台上的六个验证性实验展开,帮助读者熟悉 AHL-GEC-IDE 开发环境与构件化开发流程。内容按实验一至实验六编排,涵盖 GPIO 输入输出与 printf 调试、串口通信及中断组帧、PWM 信号生成、Flash 读写擦除、SPI 总线传输和独立看门狗配置,并保留实验目的、实验准备、过程要求、具体步骤截图与实验总结,便于对照硬件现象理解程序行为、复盘关键配置与实验结果。压缩包共 1 个 docx 文件,约 10.04MB,目录从环境搭建延伸到外设构件应用,结构完整,适合作为课程实验记录与复习材料。已有 400 人学习下载,可供需要完成验证性实验、整理实验报告或夯实嵌入式基础外设编程的学生参考。

1. 六个验证性实验:一份能照着复现的嵌入式系统实验报告

答辩前一晚,我盯着一份只有截图、没有实验体会的材料发愁:GPIO 点灯、串口通信收帧、PWM 调占空比,六个现象我全都亲手做出来过,落到纸面上却写成了流水账。这份材料来自某高校嵌入式课程,是六个验证性实验的完整记录,主控是一块 Cortex-M4 教学开发板,配套图形化 IDE 加串口下载,实验顺序从熟悉开发环境、GPIO 输入输出,一路走到串口中断、PWM、Flash、SPI 和独立看门狗。它适合两类人:一类是设备在手、只想按步骤把六个实验现象跑出来的同学;另一类是已经跑完,但想弄懂"这些构件到底封了什么、我能改成什么"的开发者。整份材料的价值在过程记录,不在原理讲解,所以下面我按工程结构重排一遍。

2. 实验包的整体结构:六个验证性实验怎么排成一条学习曲线

拿到这类教学材料,最忌讳上来就从第一个实验翻到最后一个实验。六个实验不是并列的六道菜,而是一条有依赖的链子:后面的 Flash、SPI、看门狗都建立在"能编译、能下载、能看现象"这条链路之上,链路不通,换哪个实验都白搭。所以我做的第一件事是把六个实验拆成"基础设施—外设—系统可靠性"三层,再看清楚每层各自依赖谁。

2.1 六次实验的依赖关系:为什么顺序不能乱

实验一看着最简单,只点一颗灯,实际是整个课程的地基。它一次性交代了三件事:IDE 怎么导入工程、编译产物.hex在哪里、串口更新怎么找到板子。这三件事任何一件没通,后面五个实验全是黑匣子——现象出不来,你连是自己代码错了还是根本没烧进去都分不清。实验二引入中断和组帧,从此有了"双向通信"这个调试手段,后面几个实验的现象判断全靠串口打印。实验三的 PWM 本质是定时器,而定时器又是看门狗的近亲;实验四的 Flash 和实验五的 SPI 都在讲"外设构件怎么用统一的套路读写数据"。

序号实验主题涉及外设/机制关键产出依赖前序
1开发环境与 GPIOGPIO、时钟、printf 重定向点灯、读按键、看汇编与 C 互调无
2串口通信及中断UART、NVIC、组帧单字节回发、多字节成帧实验 1 的下载链路
3PWM 编程定时器、比较寄存器可调频率与占空比的方波实验 2 的现象观测手段
4Flash 编程页擦除、字写入参数掉电保存与回读校验实验 1 的工程结构
5SPI 编程片选、时钟极性、全双工移位读写外部器件寄存器实验 4 的读写思维
6独立看门狗LSI 时钟、复位源超时复位、异常自恢复实验 3 的定时器概念

提示:这张表建议自己抄一遍贴在工程根目录。跑不通的时候先对着"依赖前序"那一列往回找,八成问题出在上一层。

2.2 工程目录与模板复制:把"导入工程"当成一次复制

材料里反复出现"复制模板工程并重命名""导入工程"这两步,很多人当成两个动作,其实是一件事的两半:模板先被复制成你自己的目录,再由 IDE 导入。所以目录规划要在复制之前定好,否则六个实验的工程全挤在一个文件夹里,改哪个都不敢动。我一般按"一个实验一个大目录、公共构件单独放"来排,这套结构在六个实验之间来回切的时候特别省事。

# 工作目录约定:一个实验一个文件夹,公共构件单独放,互不污染 embedded-lab/ ├── 00-common/ # 构件库与模板工程,所有实验共用 │ ├── port/ # 引脚、时钟、外设的封装接口头文件 │ └── template/ # 复制出来改成自己工程名的模板 ├── 01-gpio/ │ ├── gpio-output/ # 点灯,验证 GPIO 输出 │ ├── gpio-input/ # 按键,验证 GPIO 输入 │ └── gpio-asm/ # 汇编与 C 互调,看启动流程 ├── 02-uart-irq/ # 串口中断 + 减 3 回发 + 组帧 ├── 03-pwm/ # 定时器通道输出,改占空比看亮度 ├── 04-flash/ # 页擦除、字写入、回读校验 ├── 05-spi/ # 片选、时钟极性、外部存储读写 └── 06-iwdg/ # 独立看门狗喂狗与复位验证

这套目录的用处不只是整洁。实验二之后你会频繁改中断服务程序,实验三之后你会频繁改参数再看现象,如果每次都在同一个工程里改,改坏了想回到上一个能跑的版本都没机会。一个实验一份完整工程,等于给自己留了后悔药——某一版跑飞了,直接把上一个目录重新导入就行,不用去翻代码历史。

2.3 工具链四件套:IDE、编译器、串口驱动、hex 产物

教学平台的工具链被材料写得很简单:装 IDE、插线、连接、选文件、下载。真跑起来是四件东西同时在位才行。第一是 IDE 本身,材料里写死的版本是 4.55,别自作聪明升级到更新的大版本去打开旧模板,模板里的工程配置和器件支持包经常对不上,症状是导入成功但编译报一堆找不到符号。第二是编译器,教学板常见的组合是 ARM 工具链加一套构件库,构件库的路径在工程属性里是相对路径还是绝对路径,决定了你把工程复制给别人之后还能不能编。第三是串口驱动,板子通过 Type-C 接电脑,本质是一颗 USB 转串口芯片,驱动没装,IDE 的"连接设备"永远搜不到。第四是.hex产物,编译成功后在工程目录的Debug下生成,下载时选的就是这个文件。

判断这四件是否齐活,有个五分钟的自检方法:新建一个空工程只写一个main返回 0,编译看是否 0 error 0 warning;插板子看设备管理器有没有新增串口;IDE 里点连接看是否回显串口号;下载后是否提示更新成功。四步全过,再开始做实验一,比做到一半发现驱动没装要省两个小时。

3. 核心模块拆解:GPIO 点灯、串口中断收帧、PWM 调占空比

前三个实验是整个课程的骨架,代码量都不大,但每一行都在建立后面的习惯。我把三份核心代码按"最小可跑"的写法重写了一遍,参数和坑点都标在注释里,你可以直接对着自己的构件接口改名使用。

3.1 GPIO 输出与输入:一个 main 函数的最小骨架

实验一的要求里有一条很实在:既要体会输出(点灯、蜂鸣器、继电器),也要体会输入(读开关状态)。所以最小骨架里输出和输入各来一个。构件化的写法通常是把寄存器和位操作包成gpio_config_xxx/gpio_read/gpio_toggle这类函数,引脚用"端口 + 位掩码"表示,而不是直接写地址。

/* main.c —— 最小可跑骨架:初始化时钟与 GPIO,主循环翻转 LED 并读按键 */ #include "port.h" /* 平台构件头文件,封装了时钟、GPIO、串口的寄存器操作 */ #include "printf_port.h" /* printf 重定向到串口,供调试输出 */ #define LED_PIN (1u << 3) /* 板载指示灯接在端口 B 的第 3 位 */ #define KEY_PIN (1u << 0) /* 按键接在端口 A 的第 0 位,按下为低电平 */ int main(void) { sys_clock_init(80); /* 参数:系统主频,单位 MHz */ gpio_config_output(PORT_B, LED_PIN); /* 配成推挽输出,驱动 LED */ gpio_config_input (PORT_A, KEY_PIN); /* 配成上拉输入,按键接地 */ printf("boot ok, clk=%d MHz\r\n", 80); /* 第一个调试手段:串口打印 */ for (;;) { gpio_toggle(PORT_B, LED_PIN); /* 翻转一次,亮灭各 500ms */ delay_ms(500); if (gpio_read(PORT_A, KEY_PIN) == 0) { /* 读到 0 表示按下 */ printf("key pressed\r\n"); delay_ms(20); /* 简单消抖,先延时再继续 */ } } }

逻辑上就三件事:时钟先开,引脚再配,最后进死循环。sys_clock_init必须是第一个调用,因为它决定了后面所有定时参数的分母——主频写错,delay_ms(500)出来的可能是 200ms 也可能是 2s,实验三的 PWM 频率同样会整体偏移。gpio_config_output的两个参数分别是端口号和位掩码,位掩码用移位写,改引脚只改宏定义,不动函数体。gpio_read返回的是整字节还是布尔值,各平台不一样,写成== 0比较稳;如果是返回整字节,判断该位要用(val & KEY_PIN) == 0。

实验一还要求了解汇编与 C 如何相互调用,对应的样例工程是把启动文件、复位入口和一段纯汇编函数放在一起。看这段代码不用逐行读,抓住两点:启动文件里的复位向量跳到哪里,C 函数名在汇编里怎么被bl调用。这两点明白了,后面中断向量表为什么要把UART0_IRQHandler这个名字写得一字不差,就自然理解了。

3.2 串口接收中断:从单字节回显到一帧组包

实验二的核心改动一句话就能说清:收到一个字节,减 3,再发回去。输入 7、收到 4,现象非常直观,但它把三个知识点全串起来了:中断的进入与退出、接收标志的清除、数据的原样回发。中断服务程序里最忌写长逻辑,所以只做取值、变换、投递三件事,真正的处理放到主循环里。

/* isr.c —— 串口接收中断:单字节读、减 3、回发 */ volatile uint8_t g_rx_byte; /* 中断与主循环共享,必须加 volatile */ volatile uint8_t g_rx_flag; /* 置 1 表示有新数据待处理 */ void UART0_IRQHandler(void) { uint8_t data = uart_read_data(); /* 读数据寄存器,读动作本身清接收标志 */ if (data == 0x00) return; /* 空字节直接丢掉,避免噪声干扰 */ g_rx_byte = (uint8_t)(data - 3u); /* 实验要求的变换,无符号减法会回绕 */ g_rx_flag = 1; uart_send_byte(g_rx_byte); /* 立即回发,PC 端串口工具可见 */ }

volatile不是可选项,编译器看到主循环里没人改这两个变量,会把它优化成寄存器缓存,现象就是"有时候能收到、有时候卡死",这种玄学问题查起来最费时间。uart_read_data()这个读动作必须在中断里完成,判断标志位和读数据要一次做完,先判断再读会丢掉后面紧跟着的字节。无符号减法在数值小于 3 时会回绕成 253、254 这类大数,这不是 bug,是类型行为,测的时候拿 0x01 试一次就明白了。

单字节回显只能算入门。真实串口通信要成帧,否则噪声一进来整包就废。我一般加一层帧头 + 长度 + 异或校验的状态机,收到的字节按序喂进去,校验通过才交给应用层:

/* frame.c —— 帧格式:0xAA | LEN | DATA[LEN] | SUM(异或校验) */ #define FRAME_HEAD 0xAAu #define BUF_SIZE 64u static uint8_t s_buf[BUF_SIZE]; static uint8_t s_len = 0; /* 当前已收字节数 */ static uint8_t s_need = 0; /* 本帧总长,收到长度字段后才确定 */ void uart_frame_feed(uint8_t byte) { if (s_len == 0 && byte != FRAME_HEAD) return; /* 没等到帧头,丢弃 */ if (s_len >= BUF_SIZE) { s_len = 0; s_need = 0; return; } /* 防溢出 */ s_buf[s_len++] = byte; if (s_len == 2) s_need = (uint8_t)(s_buf[1] + 2u); /* 帧头+长度+数据+校验 */ if (s_len >= 2 && s_len == s_need) { uint8_t sum = 0; for (uint8_t i = 0; i < s_need - 1u; i++) sum ^= s_buf[i]; if (sum == s_buf[s_need - 1u]) { /* 校验通过,s_buf + 2 起、共 s_buf[1] 字节是有效负载 */ app_on_frame(s_buf + 2, s_buf[1]); } s_len = 0; s_need = 0; /* 无论成败都复位,等下一帧 */ } }

这个状态机的关键在s_need这个变量:它必须等到第二个字节(长度字段)到达之后才能确定,在此之前 0 表示"还没收够"。BUF_SIZE要留余量,长度字段最大 255,但缓冲区给 64 就够,超长帧直接丢弃,比数组越界强得多。校验用异或最省事,能挡住大部分单比特翻转;要做可靠传输就换累加和或 CRC16,代价是两端算法必须一致。

实验二还提到了 PC 端的 C# 串口上位机,这部分代码通常不长:打开串口、设波特率、注册DataReceived事件、在事件里读字节再显示。和板子对接时最容易翻车的是编码问题——显示成乱码不一定是通信错了,很可能是把字节当成了 UTF-8 字符串。

3.3 PWM:定时器参数怎么算,占空比怎么改

PWM 实验的现象最直观:改一个数,灯从暗变亮、或者电机从慢转快。但为什么改这个数,很多人到交报告也没算清。构件化之后通常只剩两三个函数,参数含义必须自己心里有数,否则改了个值发现频率飞到听不见或者占空比卡在 100%,只能靠猜。

/* pwm.c —— 定时器通道输出 PWM:频率和占空比都要从时钟倒推 */ #define CLK_MHZ 80u #define PWM_FREQ_HZ 1000u /* 目标频率 1kHz,驱动 LED 不闪 */ #define DUTY_PERCENT 30u /* 占空比 30%,亮度大约三成 */ void pwm_start(void) { uint32_t timer_clk = CLK_MHZ * 1000000u; /* 定时器输入时钟 80MHz */ uint32_t psc = 80u - 1u; /* 预分频:80MHz 降到 1MHz */ uint32_t arr = timer_clk / ((psc + 1u) * PWM_FREQ_HZ) - 1u; uint32_t ccr = (arr + 1u) * DUTY_PERCENT / 100u; /* 比较值决定高电平时间 */ tim_pwm_config(TIM3, 1, psc, arr); /* 定时器 3、通道 1、预分频、自动重装值 */ tim_pwm_set_duty(TIM3, 1, ccr); /* 写比较寄存器,下一个周期生效 */ tim_start(TIM3); /* 启动计数 */ }
参数含义改动后的影响
psc预分频值,实际分频为psc + 1变大会降频,同时降低占空比调节精度
arr自动重装值,决定周期变大会降频,占空比档位变细
ccr比较值,决定高电平持续时间必须小于arr + 1,超出就是常亮
PWM_FREQ_HZ目标频率只影响arr的计算结果

三个数里最容易错的是ccr:如果大于arr + 1,输出恒为高,现象就是"改了跟没改一样";如果arr算出来是负数(频率设得太高),无符号回绕会得到一个巨大的值,芯片直接跑飞。所以算完我一般顺手加一句if (ccr > arr) ccr = arr;兜底。另外 PWM 引脚必须配成复用功能而不是普通输出,这一步在构件里常常是tim_pwm_config内部顺带做的,但引脚和通道的映射关系要对上,通道 1 配到了通道 2 的引脚上,现象同样是没输出。

4. Flash、SPI 与独立看门狗:三个"构件化"实验的通用套路

后三个实验的名字里都带"基于构件",这不是凑字数。构件化的意思是外设操作被包成一套统一接口,你的代码只跟接口打交道,不碰寄存器。好处是六个实验的代码长得像,坏处是封起来的那部分一旦不符合预期,你只能靠现象反推。这三个实验我按"能不能回读验证"来统一处理,凡是写进去的数据,都要读出来比对一次。

4.1 Flash 在线编程:擦除—写入—回读三步验证

Flash 实验的目的写得很清楚:了解在线编程原理、掌握基本方法。它和普通内存最大区别是"写之前必须先擦,擦的单位是页,写的单位通常是对齐的字"。教学板的片内 Flash 一般分成若干 2KB 页,前面几页被程序本身占了,所以实验里要选一块确认没被代码使用的页地址。

/* flash.c —— 在线编程:先擦后写,写完回读比对 */ #define FLASH_PAGE_ADDR 0x0800F000u /* 选靠后的页,避开程序区 */ #define FLASH_PAGE_SIZE 2048u /* 每页 2KB */ #define MAGIC 0x5A5AA5A5u /* 头部魔数,用于判断该页是否写过 */ int flash_save(const uint8_t *data, uint16_t len) { if (len > FLASH_PAGE_SIZE - 8u) return -1; /* 头部占 8 字节,留余量 */ flash_unlock(); /* 解除写保护 */ flash_erase_page(FLASH_PAGE_ADDR); /* 只能整页擦,擦完全为 0xFF */ flash_write_word(FLASH_PAGE_ADDR, MAGIC); flash_write_word(FLASH_PAGE_ADDR + 4u, (uint32_t)len); for (uint16_t i = 0; i < len; i += 4u) { /* 按字写入,末尾补齐 0xFF */ uint32_t word = 0xFFFFFFFFu; for (uint8_t b = 0; b < 4u && (uint16_t)(i + b) < len; b++) ((uint8_t *)&word)[b] = data[i + b]; flash_write_word(FLASH_PAGE_ADDR + 8u + i, word); } flash_lock(); /* 写完立刻上锁,防误写 */ return 0; } /* 回读校验:不比对就等于没验证 */ int flash_check(const uint8_t *data, uint16_t len) { for (uint16_t i = 0; i < len; i++) { uint8_t v = *(volatile uint8_t *)(FLASH_PAGE_ADDR + 8u + i); if (v != data[i]) return -1; /* 第一个不一致的位置返回 */ } return 0; }

flash_unlock和flash_lock必须成对出现,中间任何一步返回错误都要先解锁再返回,否则下一次操作会静默失败,现象是"第二次写不进去",看起来像玄学。擦除是按页的,页内原有的数据全被擦成 0xFF,所以如果这页里还存着别的东西,擦之前必须先读出来搬到 RAM。地址对齐这条更硬:多数芯片按字写入时要求地址能被 4 整除,从FLASH_PAGE_ADDR + 8起算,长度不是 4 的倍数时末尾补 0xFF,不能补 0x00,补零会读出多余的空字符。

回读这一步别省。写完立刻读出来比对,能一次性区分是"写失败"还是"读失败",也能抓到写入后没刷新缓存的情况。我习惯在魔数里塞一个版本号,比如低 16 位放版本、高 16 位放魔数,这样以后结构体改了字段,老数据能被识别出来而不是被当成新格式解析。

4.2 SPI 读写外部器件:片选、时钟极性与全双工

SPI 实验的知识点集中在时序上。它是全双工同步串行口:你发一个字节的同时,必然收回一个字节,这两个动作共用同一组时钟。所以 SPI 没有"只读"和"只写"的接口,写命令字的同时收回来的那个字节通常要丢掉。

/* spi.c —— 片选手动管理,一次事务内完成命令、地址、数据的收发 */ uint8_t spi_mem_rw(uint8_t cmd, uint8_t addr, uint8_t wdata) { uint8_t rdata; spi_cs_low(SPI1); /* 片选拉低,选中外部器件 */ spi_transfer(SPI1, cmd); /* 命令字,读/写/读状态寄存器 */ spi_transfer(SPI1, addr); /* 器件内部地址,按需可扩成多字节 */ rdata = spi_transfer(SPI1, wdata); /* 同时发出的数据与收回的数据 */ spi_cs_high(SPI1); /* 片选拉高,结束本次事务 */ return rdata; /* 读命令时这个字才是有效数据 */ }

片选必须手动拉低拉高,而且必须包住整个事务。中途被别的任务打断去操作同一个总线上的其他器件,收回来的一定是错数据——这也是为什么我一般把一次完整事务写成函数,锁在内部做,不在外面手动开合。时钟极性(CPOL)和时钟相位(CPHA)这两个参数由从设备数据手册给定,配错了的现象是"发出去的能看到,收回来的全是 0xFF 或 0x00",一开始很容易怀疑接线,其实是模式不匹配。速率也要看器件能扛多少,教学板上默认分频给得比较保守,改快之后现象是偶发错误,跑十次错一次,这种最坑,建议先把速率降到底跑通,再逐步升。

注意:SPI 的 MISO 线在片选未拉低时是高阻态,读到的值是浮空的。如果代码里出现"没拉片选也能读到数据",那基本是读到了上一次的残留或引脚配置错了。

4.3 独立看门狗:喂狗窗口与复位验证

看门狗实验是六个实验里最容易被糊弄过去的:写两行初始化,主循环喂一下狗,现象是"没现象"。但它恰恰是唯一一个验证"系统出问题能自愈"的实验,真正跑通的标志是你能主动让它复位,并且从复位后的现象里确认是看门狗干的。

/* iwdg.c —— 独立看门狗:溢出时间由内部低速时钟、预分频、重装值共同决定 */ #define IWDG_PRESCALER 64u /* 预分频,越大超时越长 */ #define IWDG_RELOAD 1250u /* 重装值,超时 = 64 * 1250 / 40000 = 2s */ void iwdg_start(void) { iwdg_write_key(0x5555u); /* 解除写保护,才能改配置 */ iwdg_set_prescaler(IWDG_PRESCALER); iwdg_set_reload(IWDG_RELOAD); iwdg_write_key(0xAAAAu); /* 先喂一次,避免一开就复位 */ iwdg_write_key(0xCCCCu); /* 启动看门狗,启动后无法关闭 */ } int main(void) { board_init(); uart_log("boot, reset cause = %d\r\n", reset_cause_get()); iwdg_start(); for (;;) { do_work(); /* 正常任务,耗时必须小于 2s */ iwdg_write_key(0xAAAAu); /* 喂狗,位置要覆盖所有长耗时路径 */ } }

独立看门狗用的是芯片内部的低速时钟,精度差但独立性好——主时钟挂了它照样计时,这正是它存在的意义。IWDG_RELOAD不能设成 0,设 0 会立刻复位;预分频和重装值决定超时时间,教学板上常见的是几百毫秒到几秒,算的时候用"预分频 × 重装值 ÷ 内部时钟频率"。喂狗放在主循环末尾是最简单的写法,但如果某个分支里有长延时或者死等,就会在那里被复位,所以喂狗点要覆盖所有可能的长耗时路径,或者干脆把喂狗放进定时器中断里、由主循环置标志位。

验证方法别只用肉眼。先在串口打印里加一行复位原因,正常上电和看门狗复位读出来的值不一样;再把喂狗那行注释掉,重新下载,观察串口是不是每隔两秒重新打印一次启动信息。两次现象对得上,这个实验才算真做完。我见过不少人只做了前半截,看门狗开着但配置里预分频算错,实际超时只有几十毫秒,程序一直在复位循环里跑,现象是"灯闪得特别快",被当成正常闪烁交了报告。

5. 跑通全流程与避坑排查:七步烧录加五条翻车记录

六个实验的代码风格一致,跑不通时的排查路径也一致。区别只在于前面几个实验现象直观(灯亮、串口回显),后面几个现象要借助打印才能看见。所以这一章把"从导入工程到看到现象"压成七步,再把六个实验里反复出现的翻车点攒成五条,按现象、原因、解决三段写,遇到问题直接对号入座。

5.1 七步跑通流程:每一步都要有一个可验证的反馈

流程本身不复杂,但每一步都要拿到反馈再往下走,不然出错时无法定位。导入工程后先编译,看输出窗口是不是 0 error 0 warning;连接板子看 IDE 是否回显串口号;选.hex时确认文件时间戳是刚才编译出来的那一份;下载后看提示是否成功;最后回到现象和打印上。中间任何一步没有明确反馈,就停下来解决,不要抱着"可能没事"继续往下点。

# 一次完整的更新流程(命令仅为示意,实际操作在 IDE 菜单里点) # 1. 导入工程:文件 -> 导入工程 -> 选中工作目录下的工程文件 # 2. 编译:编译 -> 编译工程,确认输出 0 error # 3. 确认产物:ls 工程目录/Debug/*.hex,看修改时间是否刚刚 # 4. 硬件连接:Type-C 数据线接底板,注意不是充电线 # 5. 连接设备:下载 -> 串口更新 -> 连接设备,应回显串口号 # 6. 下载:选择文件 -> 选中 Debug 下的 hex -> 一键更新 # 7. 观察:串口工具打开对应串口,看打印;再看板上的灯/PWM 现象

第七步里串口工具的波特率要和代码里一致,常见的是 115200 或 9600,两边对不上就是一堆乱码,很容易被误判成程序跑飞。另外串口工具打开会独占串口,此时 IDE 的下载功能可能连不上设备,先关工具再下载。

5.2 五条翻车记录:现象、原因、解决

第一条:连接设备一直搜不到板子。现象是点"连接"后转圈几秒提示未找到设备。原因八成在线材或者驱动:随手抓的那根 Type-C 线只带供电不带数据,或者电脑没装 USB 转串口驱动,设备管理器里看不到新增端口。解决顺序是先换一根明确能传数据的线,再看设备管理器有没有新串口,没有就去装驱动,然后换一个 USB 口,避开扩展坞上的那排口。

第二条:编译成功,下载成功,灯就是不亮。现象是提示更新完成但板子毫无反应。最常见原因是下错了文件——Debug目录里残留着上一次编译失败前的旧.hex,编译报错时 IDE 不会覆盖它,你选中的其实是几天前那份。解决方法是先确认编译输出 0 error,再看 hex 的文件修改时间是不是刚刚,必要时把Debug目录整个删掉重新编译。第二个可能原因是板子没真正复位,更新完手动按一下复位键。

第三条:输入 7 收到 4,输入 1 收到的是一堆怪字符。这不算 bug,但很吓人。原因是字符和数值没分清:你在串口工具里敲的是字符'7',ASCII 码是 0x37,减 3 得 0x34,正好是字符'4',显示正常;敲'1'是 0x31,减 3 得 0x2E,是个控制字符,显示出来就是乱码。解决方法是把串口工具切到 HEX 显示看原始字节,同时在报告里写清"减 3 是对字节数值做的,不是对字符"。如果确实想按十进制输入,得在 PC 端先做字符串转数值。

第四条:改了 PWM 参数,亮度一点没变。现象是代码明明改了DUTY_PERCENT,烧进去和之前一模一样。原因通常是两连没做完:只编译没重新下载,或者下载时选的还是上一份 hex。第二可能原因是ccr超过了arr + 1,输出恒高,改多少都是常亮。第三可能是引脚没配成定时器复用功能,输出根本没接到定时器通道上。解决方法是改完参数必须"编译 + 更新"两步都做,然后在代码里打印一次算出来的psc / arr / ccr三个值,对着表核一遍。

第五条:看门狗一启动就反复复位。现象是串口不停重复打印启动信息,间隔远小于预期。原因有两种:一是超时算错了,预分频和重装值乘出来比想象中小一个数量级,正常任务都来不及跑完;二是喂狗位置不对,初始化后到第一次喂狗之间有长延时,或者喂狗点在某条分支外面,走进那条分支就永远喂不上。解决方法是先把预分频调到最大、重装值给到算出来的两倍,确认不再复位,再逐步缩回;喂狗语句放在所有耗时路径都能经过的位置,或者移进定时器中断。

5.3 现象与原因速查表

排查时最耗时间的不是修,是判断"问题在哪一层"。下面这张表把常见现象和对应层对起来,从下往上查比从头读代码快。

现象大概率所在层先查什么
搜不到设备、串口号为空物理层数据线、驱动、USB 口
能连接但下载失败下载层hex 是否最新、板子是否被串口工具占用
下载成功但完全无现象程序入口是否进了主循环、时钟初始化是否执行
灯亮但不闪、串口无打印时钟配置主频参数、延时函数分母
串口有打印但内容乱码通信参数两侧波特率、工具编码设置
中断里偶尔卡死并发共享变量是否 volatile、是否清了标志
看门狗周期性复位时序超时计算、喂狗点覆盖范围

提示:这张表只用来缩小范围,最终确认还是要靠"改一处、烧一次、看一次"的二分法。一次改三个地方,现象变了你也不知道是谁的功劳。

6. 二次开发:把六个验证性实验拼成一个可交互的小系统

六个实验单独跑都只有几十行,但它们刚好凑齐了一个小系统需要的最小集合:输入(GPIO 按键)、输出(PWM 与 GPIO)、人机通道(串口)、掉电保存(Flash)、外部扩展(SPI)、异常兜底(看门狗)。我在做完第六个实验之后,习惯把它们串成一个"串口命令台"——上位机敲命令,板子执行并回显结果,参数改完存进 Flash,主循环里正常喂狗。这个改造不大,但能把六份互相独立的工程合成为一份,也是承包课程设计时最省事的起点。

关键改动是把串口收到的帧交给一张命令表分发,而不是写一长串if-else。表驱动的好处是加一条命令只加一行,删一条命令只删一行,不用碰状态机:

/* cmd.c —— 命令表:一条命令对应一个处理函数,参数从帧负载里取 */ typedef struct { const char *name; /* 命令字,长度固定便于比对 */ void (*handler)(const uint8_t *arg, uint8_t len); } cmd_entry_t; static void cmd_led (const uint8_t *arg, uint8_t len); /* 0/1 开关灯 */ static void cmd_pwm (const uint8_t *arg, uint8_t len); /* 百分比设占空比 */ static void cmd_save(const uint8_t *arg, uint8_t len); /* 当前参数写 Flash */ static void cmd_load(const uint8_t *arg, uint8_t len); /* 从 Flash 读回参数 */ static const cmd_entry_t g_cmd_table[] = { { "LED", cmd_led }, { "PWM", cmd_pwm }, { "SAVE", cmd_save }, { "LOAD", cmd_load }, }; void app_on_frame(const uint8_t *data, uint8_t len) { if (len < 3u) return; /* 至少要有 3 字节命令字 */ for (uint8_t i = 0; i < sizeof(g_cmd_table) / sizeof(g_cmd_table[0]); i++) { if (memcmp(data, g_cmd_table[i].name, 3) == 0) { g_cmd_table[i].handler(data + 3, (uint8_t)(len - 3u)); return; } } uart_send_str("ERR\r\n"); /* 未知命令统一回错,便于上位机判断 */ } void cmd_pwm(const uint8_t *arg, uint8_t len) { if (len < 1u || arg[0] > 100u) { uart_send_str("ERR\r\n"); return; } uint32_t arr = pwm_get_arr(); /* 复用实验三算出来的重装值 */ tim_pwm_set_duty(TIM3, 1, (arr + 1u) * arg[0] / 100u); uart_send_str("OK\r\n"); }

验证的时候不要一上来就联调四件事,按依赖顺序一层层加:先只跑串口命令分发,用LED验证通路;通路稳了再加PWM,看占空比是不是随参数线性变化;最后加SAVE/LOAD,把占空比存进去、断电、上电读回来比对。每加一层跑一遍完整的"发命令—看现象—断电重启—再确认",比一次性全接上再查要快得多。看门狗在这套系统里要做两件事:主循环末尾喂狗,同时把长耗时命令(比如整页 Flash 擦除)中间补喂一次,否则擦除期间就可能被复位。

我踩过最深的一次坑是SAVE和LOAD的地址没对齐:结构体里多了个uint8_t字段,整体长度从 16 变成 17,写 Flash 的时候按字补齐,读回来按结构体直接强转,读出的后两个字全是 0xFF。从此我每次改 Flash 里的结构体,都强制先加一行sizeof(结构体) % 4 == 0的编译期断言,再跑一遍断电读回。这套习惯后来在别的板子上救过不止一次——嵌入式里没有后悔药,能提前拦住的错误就别留到现象上去猜。希望帮到你。

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

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

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

立即咨询