先看这章 · 零基础
先补基础:看懂后面要的 8 个概念
没学过单片机也没关系。这一章不讲项目,只讲后面每章都会用到的词。看完这章,后面会顺很多。
怎么看这本手册
正文里带虚线下划线的词 点一下,会弹出一句大白话解释。最后还有一个“术语表”。
每章开头有个“先记住这几句”,第一遍只看那几句加图也行,细节第二遍再看。
代码看不懂先跳过,看它上下的文字说明。面试主要考你讲不讲得清楚,不考背代码。
1. 单片机:一颗芯片里的小电脑
打个比方 电脑有 CPU、内存条、硬盘,再插上键盘、显示器。单片机把这些全做进一颗指甲盖大的芯片里:CPU 是大脑,Flash 是硬盘(放程序,断电不丢),RAM 是草稿纸(运行时用,断电就没),外设是手和脚。
STM32F446RE 这一颗芯片
CPU 大脑,180 MHz
Flash 512 KB 硬盘:放程序,断电不丢
RAM 128 KB 草稿纸:断电就没
外设:专门干某一类活的小工人
GPIO(管引脚高低)· 串口 · I²C · SPI · 定时器 · ADC(量电压)· 看门狗
CPU 不亲自干这些活,只给它们下命令
温湿度传感器 AHT20
LED、按键 外部 Flash
引脚
引脚
程序(固件)在电脑上写好、编译,再通过 USB 线“烧录”进 Flash。上电后 CPU 从 Flash 里一条条取指令执行。
2. 二进制、十六进制、“第几位”
芯片里只有 0 和 1。8 个 0/1 叫一个字节 ,能表示 0 到 255。写成二进制太长,所以常用十六进制 :逢 16 进 1,用 0–9 和 A–F(A = 10 … F = 15),前面加 0x 表示“这是十六进制”。4 位二进制正好是 1 位十六进制 ,所以一个字节就是两位十六进制。
0x38
= 3 × 16 + 8 = 56
0
0
1
1
1
0
0
0
7 6 5 4
3 2 1 0
← 位号
高 4 位 0011 = 3
低 4 位 1000 = 8
“第 3 位是 1”说的就是这一格
位号从右边 数、从 0 开始。第 n 位是 1,相当于加上 2 的 n 次方:第 3 位 = 8
后面经常出现“状态的第 3 位是 1 表示已校准”“LR 的第 2 位”这种说法,都是这个数法。0x08 就是只有第 3 位是 1 的数。
3. 寄存器:一排有门牌号的开关
打个比方 每个外设都有一块控制面板,面板上是一排排开关,每排有个固定的门牌号(地址)。往某个门牌号写数,就是拨开关,硬件马上照做;读这个门牌号,就是看指示灯。这些“一排开关”就叫寄存器,每排 32 位。
GPIOA 的输出寄存器 ODR
地址 0x4002_0014
…
0
1
0
0
0
0
0
6 5 4 3 2 1 0
PA5 引脚
LED 亮
第 5 位写 1 → PA5 变成 3.3 V → LED 亮;写 0 → 灭
平时不用自己记地址,ST 给的库里有名字,比如 GPIOA->ODR;更常用的是 HAL 库函数 HAL_GPIO_WritePin,它内部也就是去写这个寄存器。
4. 地址:芯片里所有东西都有门牌号
Flash(程序) 512 KB
RAM(变量、栈) 128 KB
外设寄存器 串口、GPIO、I²C……
0x0800_0000 起
0x2000_0000 起
0x4000_0000 起
读一个不存在的地址,CPU 就会报错(第 7 章 HardFault 就是故意这么做来演示的)
后面看到 0x0802_0000,就知道是 Flash 里的某个位置;看到 0x2001_FF00,就知道是 RAM 的末尾。中间的下划线只是为了好读。
5. 轮询、中断、DMA:三种等数据的办法
打个比方 等快递。轮询 是每隔一会儿自己跑下楼看一眼,别的事都干不好;中断 是装个门铃,快递到了按铃,你放下手头的事去开门,开完回来接着干;DMA 是雇了个管家,快递员一件件送来,管家自己收好放进柜子,攒够一批才叫你一声。
轮询
CPU 一直在问“来了吗?来了吗?”,干不了别的
中断
CPU 干自己的事(绿),每来一个字节被打断一下(铜色)
DMA
字节由 DMA 自己搬进内存,一整句话收完才通知 CPU 一次
这个项目的串口用的是 DMA + 空闲中断:DMA 负责搬,线上“安静下来了”(一句话说完)触发一次中断。第 2 章细讲。
6. 任务和 FreeRTOS:一个厨师炒四个菜
打个比方 只有一个厨师(CPU),却要同时照看四口锅。他不会守着一口锅等,而是这口锅要等两分钟就先去翻另一口。每口锅就是一个任务 ,决定“现在去翻哪口锅”的规则就是 FreeRTOS 的调度器。因为切得很快,看起来像四件事同时在做。
本项目四个任务:sense 每秒量一次温湿度,comm 跟电脑通信,alarm 判断要不要报警、控制 LED,wdg 检查大家是不是都还活着。没事干的时候 CPU 跑一个叫 IDLE 的空闲任务,实测 99% 以上的时间都在 IDLE。
7. Flash 和 RAM 的脾气
Flash RAM
像什么 硬盘 草稿纸
断电以后 还在 没了
放什么 程序、要长期保存的参数和记录 运行时的变量、栈
怎么写 只能把 1 改成 0;要改回 1,必须把一整块(扇区)擦成全 1,很慢 随便写,很快
寿命 每块大约擦 1 万次就可能坏 不限
后面第 6 章、第 9 章很多设计(追加写、两份轮流写、写完再标记“有效”)都是在跟 Flash 的这几个脾气打交道。
8. 通信的几个基本词
协议 :双方说话的规矩,比如一句话怎么开头、怎么结尾、出错了怎么办。
主机 / 从机 (主站 / 从站):主机发起,从机只回答。这里电脑是主机,板子是从机。
帧 :一次发出的一整串字节,相当于“一句话”。
波特率 :串口每秒传多少位。115200 大约每秒 11520 个字节。
校验码 (CRC):附在一句话末尾的“指纹”,接收方算一遍对不上,就知道传错了。
上位机 :电脑上用来看数据、改设置的软件。板子叫“下位机”。
AHT20 量温湿度
STM32 换算、存进寄存器表
ST-LINK 串口转 USB
上位机 画曲线、报警
I²C
串口
USB
串口上跑的“说话规矩”是 Modbus:电脑问“把温度报给我”,板子回一帧数据
一个温度数字从传感器走到电脑屏幕的路。后面每章讲的就是这条路上的某一段。
CHAPTER 00
整体是什么
先有一张全局图,后面每一章都是在放大图上的某一块。
先记住这几句 板子每秒量一次温湿度,通过 USB 线把数交给电脑上的软件。 难点不在量数,在出了问题以后不坏:升级断电不变砖、坏固件自动退回、死机能查到哪一行。 单片机里同时跑 4 个小程序(任务),各管一摊。
SensorNode 是一个温湿度采集终端,参照市面上 RS485 温湿度变送器的做法:每秒采一次温湿度,通过 Modbus RTU 把数据交给电脑上的 Qt 上位机。重点不在“能采到数”,而在出了问题以后怎么办:升级到一半断电不变砖,新固件跑不起来自动退回旧版本,死机以后能查到是哪一行代码,参数和日志掉电不丢。
AHT20 温湿度
I²C1 · PB8 / PB9
W25Q64 外部 Flash
SPI2 · PB12–PB15
LED · 按键 B1
PA5 · PC13
STM32F446RE · 180 MHz
wdg
comm
alarm
sense
FreeRTOS 四个任务
Bootloader
slot A
slot B
内部 Flash 512 KB:引导程序 + 两份固件
IWDG 看门狗
USART2 · DMA
日志走 SWO,Modbus 走 USART2
板载 ST-LINK
烧录 · SWO · 虚拟串口
Mac · Qt 上位机
Modbus 主站
115200 8N1
USB
整个系统。左边是外设,中间是单片机,右边是电脑。板子上的 ST-LINK 一根 USB 线同时管烧录、调试日志和串口。
这份手册怎么用
每一章的结构都一样:
打个比方 :先用生活里的东西把意思讲明白。
用到了什么 :外设、引脚、寄存器、文件。
原理和图 :为什么这样设计,配图。
真实代码 :从项目里摘出来的关键几行。
踩过的坑 :上板时真遇到的问题。
面试会问 :点开看参考答案。先自己说一遍,再点开对照。
没学过单片机的话,先看上一章“先补基础”。正文里带虚线下划线的词,点一下就有解释。
每章最后有一个“我能讲清楚了”的勾。标准是:不看任何资料,对着空气把这一章讲 3 分钟 ,讲得出来再勾。勾选只存在你这台电脑的浏览器里。
推荐的学习顺序
按章节顺序来,和做的顺序基本一致:先懂单片机怎么跑起来(01),再懂数据怎么进出(02),然后是多任务(03)和通信协议(04),这四章是地基。05 是电脑那一侧。06、07 是这个项目最能拿出来讲的部分:升级和死机定位。08、09 是两个外设。10 一定要看,面试官很爱问“你遇到过什么难查的问题”。
CHAPTER 01
时钟和 GPIO
单片机怎么跑到 180 MHz,引脚怎么控制。
先记住这几句 芯片干活要有节拍,就是时钟。我们把 8 MHz 放大到 180 MHz。 引脚可以当输出(点灯),也可以当输入(读按键),靠写寄存器来设置。 推挽能主动输出高和低;开漏只能拉低,高电平靠电阻。I²C 必须用开漏。
打个比方 时钟就像工厂的节拍器。外面送进来一个慢节拍(8 MHz),经过“先分再倍频”的变速箱(PLL)变成 180 MHz 的快节拍,再按各车间能承受的速度分下去:CPU 车间 180,慢车间 APB1 只能 45,快车间 APB2 能到 90。
用到了什么
东西 设置 为什么
HSE Bypass,8 MHz 板上没给 F446 焊晶振,8 MHz 方波由 ST-LINK 的 MCO 引脚送到 PH0。选 Crystal 会起不来。
PLL M=4,N=180,P=2 8 ÷ 4 = 2 MHz,× 180 = 360 MHz,÷ 2 = 180 MHz。
Flash 等待周期 5 Flash 读得比 CPU 慢,要等;ART 加速器(预取 + 缓存)补回大部分速度。
Over-Drive 打开 不开最高只能 168 MHz。
HAL 时基 TIM6 SysTick 留给 FreeRTOS 做调度节拍。
HSE 8 MHz
ST-LINK 的 MCO 送来
÷ M = 4
2 MHz(要求 1–2)
× N = 180
VCO 360(要求 100–432)
÷ P = 2
SYSCLK 180 MHz
HCLK 180 MHz
CPU、DMA、内存
APB1 ÷4 = 45 MHz
USART2、I²C1、SPI2
定时器 ×2 = 90 MHz
APB2 ÷2 = 90 MHz
ADC1(再 ÷4 = 22.5)
定时器 ×2 = 180 MHz
时钟树。APB 只要分频系数不是 1,挂在上面的定时器就拿到 2 倍的时钟,所以 APB1 是 45 MHz、它的定时器是 90 MHz。
开机时我读过三个寄存器,确认配置真的生效:
寄存器 读到的值 说明什么
RCC_CR0x03076B83HSE 开了、HSE 就绪、HSE 是 Bypass、PLL 开了、PLL 锁定
RCC_CFGR0x0000940A系统时钟来自 PLL;APB1 ÷4,APB2 ÷2
RCC_PLLCFGR0x22402D04PLL 输入是 HSE,M=4,N=180,P=÷2
GPIO 的四个配置寄存器
MODER:这个引脚当输入、输出、复用(给外设用)还是模拟。
OTYPER:推挽还是开漏。
OSPEEDR:电平翻转多快。越快边沿越陡,干扰越大;点 LED 用低速就够。
PUPDR:内部上拉、下拉还是都不要。
用任何外设之前,都要先开它的时钟(__HAL_RCC_GPIOA_CLK_ENABLE())。芯片上电时外设时钟全关,为了省电;时钟没开,往寄存器里写东西不会生效。
推挽 开漏
能输出什么 能主动拉高,也能主动拉低 只能拉低;高电平靠外面的上拉电阻
多个设备接一根线 不行,一个拉高一个拉低就短路 可以,谁拉低线就是低(叫“线与”)
本项目里 LED、W25Q64 的片选 I²C 的 SCL、SDA
改输出用 BSRR 而不是 ODR:BSRR 写一次就能单独置位或清零某几位;ODR 要先读出来、改一位、再写回去,三步中间要是被中断插进来改了别的引脚,写回去时会把别人的改动盖掉。
踩过的坑 CubeMX 时钟图里有两个选择器都能选 HSE。PLL Source Mux 决定 PLL 的输入从哪来,选 HSE;System Clock Mux 决定系统时钟用不用 PLL,要选 PLLCLK。选反了,输入 180 时 CubeMX 会自动改回去。另外,用户代码只能写在 USER CODE BEGIN 和 END 之间,重新生成时其他地方会被覆盖。
面试会问 先自己说,再点开
外设为什么要先开时钟? 上电后所有外设的时钟默认是关的,为了省电。外设内部的寄存器电路要靠时钟才能工作,时钟没开时写寄存器,值写不进去,外设也不会动。
推挽和开漏有什么区别?I²C 为什么用开漏? 推挽能主动输出高和低,驱动能力强;开漏只能拉低,高电平靠上拉电阻。I²C 一根线上挂好几个设备,而且从机要能拉低 SDA 回 ACK、拉低 SCL 让主机等一等。如果用推挽,两个设备一个输出高一个输出低就短路了;开漏的话,谁拉低线就是低,不会打架。
为什么用 BSRR 而不直接写 ODR? BSRR 是“写 1 才生效”的寄存器:低 16 位写 1 置高,高 16 位写 1 拉低,写 0 的位不受影响,一条写指令就完成,不会被打断。ODR 要读-改-写三步,中间来个中断改了同一个端口的别的引脚,写回去时就把中断的改动覆盖了。
PLL 的 M、N、P 怎么选? 先选 M,让输入落在 1–2 MHz,越接近 2 MHz 抖动越小,所以 8 ÷ 4 = 2。再选 N,让 VCO 落在 100–432 MHz,2 × 180 = 360。最后 P 只能是 2、4、6、8,360 ÷ 2 = 180。Q 那一路给 USB 用,要求正好 48 MHz,这个项目没用 USB,所以不管它。
为什么 HAL 的时基要从 SysTick 换成 TIM6? FreeRTOS 要独占 SysTick 做调度节拍。HAL 也需要一个 1 ms 的计数器(HAL_Delay、各种超时都靠它)。两者共用 SysTick 的话,调度器启动前后行为不一致,中断优先级也不好安排,所以给 HAL 单独一个基本定时器 TIM6。
跑 180 MHz 要做哪些额外设置? 三件事:打开 Over-Drive,让内核电压调节器进入高性能模式,不开只能到 168 MHz;Flash 等待周期设成 5,并打开 ART 加速器的预取和指令缓存;APB1 不能超过 45 MHz、APB2 不能超过 90 MHz,所以分别 ÷4、÷2。
CHAPTER 02
串口 DMA 和环形缓冲区
数据怎么进出单片机,CPU 怎么不被串口拖累。
先记住这几句 串口一位一位地收发数据,115200 大约每秒一万多个字节。 DMA 是搬运工,替 CPU 把收到的字节搬进一块内存,CPU 不用一个个去取。 这块内存首尾相接循环用,叫环形缓冲区。写满绕回开头的那一下出过 bug。
打个比方 CPU 是经理,串口是一个一个字节慢慢送来的快递。阻塞方式是经理自己站在门口一件件签收,什么都干不了。DMA 是专门的仓管员:经理只交代一句“东西放到这个货架上”,仓管员自己去收;货架放到一半、放满、或者快递员停下来不送了(空闲),仓管员才敲门告诉经理。环形缓冲区就是那个首尾相接的货架,放到头了从头接着放。
用到了什么
东西 设置
USART2 PA2 TX、PA3 RX,115200 8N1,连到 ST-LINK 的虚拟串口
DMA1 Stream5 接收,Circular(循环)模式,搬到 rx_dma[256]
DMA1 Stream6 发送,Normal 模式,一次发一段
中断 半满 HT、全满 TC、空闲 IDLE,都走 HAL_UARTEx_RxEventCallback
文件 User/ringbuf.c、User/uart_dma.c
接收
PA3 引脚 一个个字节
USART2 收进数据寄存器
DMA 循环搬 CPU 不用管
rx_dma[256] DMA 写这里
rx_rb[1024] 回调搬过来
comm 任务 uart_recv 取
HT / TC / IDLE 中断 → 回调里记位置、搬数据、叫醒 comm
发送
uart_send 任务里调用
tx_rb[2048] 先放进货架
DMA 发一段 只能是连续的一段
USART2 发完进回调
PA2 引脚 回调里再发下一段
收和发两条路。CPU 只在中断回调里花几微秒记位置、搬数据,搬运本身全交给 DMA。
接收:三种中断都要
空闲 IDLE :线上停了大约 1 个字节的时间,说明这一段发完了。
半满 HT、全满 TC :DMA 写到一半、写到末尾。对方要是一直不停地发,就不会有空闲;只靠 IDLE 的话,DMA 绕一圈回来会把还没取走的数据盖掉。
回调告诉你 DMA 现在写到了哪(size = 256 − DMA 剩余计数 NDTR)。用 rx_pos 记住上次处理到哪,新数据就是 rx_pos 到 size 这一段;如果 size 比 rx_pos 小,说明绕回开头了,分两段拷:
static int rx_pull(uint16_t pos)
{
if (pos == rx_pos) return 0;
if (pos > rx_pos) {
rb_write(&rx_rb, rx_dma + rx_pos, pos - rx_pos);
} else {
rb_write(&rx_rb, rx_dma + rx_pos, RX_DMA_SIZE - rx_pos);
rb_write(&rx_rb, rx_dma, pos);
}
rx_pos = pos;
return 1;
}
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t size)
{
if (huart == &huart2 && rx_pull(size == RX_DMA_SIZE ? 0 : size)) {
uart_rx_notify();
}
}
注意 size == RX_DMA_SIZE ? 0 : size 这一句:在一个 256 格的环里,“写到第 256 格”和“写到第 0 格”是同一个位置。这一句是修一个真 bug 加上的,见下面的坑。
环形缓冲区为什么不用关中断
0 1 2 3
11 15
head = 19(19 & 15 = 3),下一个写到这
tail = 11,下一个从这读
写到末尾接着从 0 写;head − tail = 8,就是还没取走的字节数
16 格的例子(项目里是 1024 和 2048 格)。绿色是写进去还没被读走的数据。
只有一个地方写、一个地方读(单生产者单消费者)。head 只由写的一方改,tail 只由读的一方改,各改各的,不冲突。
下标一直往上加,不取模,用的时候 & mask 换成位置。无符号数溢出绕回以后,head − tail 照样等于已用字节数。前提是大小必须是 2 的幂。
下标用 C11 的 atomic_uint:写方先拷数据,再用 memory_order_release 更新 head;读方用 memory_order_acquire 读 head。这样对方看到 head 变了的时候,数据一定已经写好,编译器不能把这两步调换顺序。
发送那边有好几个任务都会打印,写入方不止一个,无锁的前提不成立了,所以 uart_send 里“写缓冲区 + 启动 DMA”加了关中断保护。拷几十个字节大约 1 µs,影响很小。
2.9 ms 阻塞方式打印一行 33 字节
30 µs DMA 方式打印同一行
约 95 倍 快了多少
0 丢失 回环 20000 字节 × 3 轮
踩过的坑:每 32 次丢一帧 连续发 200 次 Modbus 读请求,正好每 32 次丢一次。每个请求 8 字节,32 × 8 = 256,正好是 DMA 缓冲区大小,所以丢的是“刚好写到末尾”的那一帧。GDB 在回调上打断点发现:写满时回调连着来两次,先 TC(size = 256),紧接着 IDLE(size 还是 256)。第二次时 rx_pos 已经是 0,代码以为又来了 256 个新字节,把整个缓冲区的旧数据又拷了一遍。修法就是上面那句把 256 当 0。修完 500 次全部成功,中间跨过 15 次回绕。
踩过的坑:ST-LINK 固件太老 原来的 V2J28 固件在 Mac → 板子方向,传几百字节就卡住。用 GDB 看板子上的 rx_rb.head、rx_pos、NDTR 三者对得上,说明板子收到多少处理了多少,数据是在进板子之前丢的。升级到 V2J48M35 解决。
面试会问 先自己说,再点开
DMA 和中断怎么配合? CPU 只负责启动:告诉 DMA 从哪搬、搬到哪、搬多少。搬运过程 DMA 自己做,不占 CPU。搬到一半、搬完、或者串口线空闲时,用中断通知 CPU。CPU 在回调里只做很短的事:记下位置、把新数据挪到环形缓冲区、叫醒处理它的任务。
空闲中断是什么?为什么还要开半满和全满? 空闲中断是串口发现线上停了大约 1 个字节的时间,说明这一段数据发完了,很适合用来知道“一包到了”。但如果对方连续不停地发,一直没有空闲,DMA 绕一圈就会把还没取走的数据覆盖掉,所以半满和全满也要开,保证每半圈至少处理一次。
循环模式下,怎么算出新收到了哪些数据? DMA 当前写到的位置 = 缓冲区大小 − NDTR(剩余计数)。上次处理到 rx_pos,新数据就是 rx_pos 到当前位置。如果当前位置比 rx_pos 小,说明绕回开头了,分两段拷:rx_pos 到末尾,再从 0 到当前位置。还要注意位置 256 和位置 0 是同一个地方,我就是在这里出过 bug。
环形缓冲区在什么条件下可以不关中断? 只有一个写方和一个读方。head 只被写方改,tail 只被读方改,双方不会改同一个变量。再用原子变量加 release/acquire 内存序,保证“先写数据、后更新 head”的顺序在对方看来也成立。多个写方时这个前提不成立,就得加锁或关中断。
为什么环形缓冲区的大小要是 2 的幂? 下标一直往上加、不取模,用的时候和 mask 做与运算就得到位置,比取余快。更重要的是,32 位无符号数溢出回 0 以后,head − tail 还是正确的已用长度,这要求缓冲区大小能整除 2³²,也就是 2 的幂。
发送 DMA 正在发,又有新数据要发怎么办? 新数据先写进发送环形缓冲区。启动函数发现 DMA 正忙就直接返回。等这一段发完,在发送完成回调里把发完的部分丢掉,再把下一段连续数据交给 DMA。环形缓冲区的数据可能一半在末尾一半在开头,DMA 只能搬连续内存,所以分两次发。
串口溢出错误 ORE 是怎么来的?出了之后怎么恢复? 串口收到一个新字节时,上一个字节还没被读走(比如 DMA 没来得及搬),硬件就置 ORE。HAL 遇到错误会停掉 DMA 接收,调用 HAL_UART_ErrorCallback。我在回调里检查接收状态回到 READY 以后重新启动接收,不然串口就再也收不到数据了。
临界区为什么要保存再恢复 PRIMASK,不直接开中断? 如果调用我的地方本来就关着中断(比如外层也在一个临界区里),我退出时直接开中断,会把外层的保护提前打破。先保存 PRIMASK、关中断、最后恢复成原来的值,就能嵌套使用。
CHAPTER 03
FreeRTOS 和看门狗
四个任务怎么分工、怎么互相通知、谁卡死了怎么发现。
先记住这几句 FreeRTOS 让一个 CPU 轮流跑几个任务,看起来像同时在跑。 看门狗是个倒计时,程序要定时去“喂”它。卡死了没人喂,就自动重启。 每个任务定时报平安(心跳),全报了才喂狗,所以哪个任务卡住都能发现。
打个比方 FreeRTOS 像一个按优先级排班的食堂:几个窗口(任务)轮流用一个厨师(CPU),谁的事急谁先上,没事干的就去睡觉(阻塞),不占厨师。看门狗是门口的保安,规定每 4 秒必须有人来签到,没人签到就把整个食堂重启。我们的规矩更严:所有窗口都报过平安,值班长(wdg 任务)才去签到,所以任何一个窗口卡死都会被发现。
任务怎么分
任务 优先级 多久跑一次 做什么 实测栈剩余
wdgHigh 500 ms 检查心跳,全到齐才喂狗 872 B
commAboveNormal 收到数据立刻醒,最长 100 ms Modbus 从站、在线升级 1328 B
alarmNormal 100 ms 温度超上限报警,降到解除值以下才解除,控制 LED 772 B
senseBelowNormal 1 s 读 AHT20、内部温度和 VDDA 352 B
排优先级的依据是“晚了会怎样”:看门狗晚了会误复位,又只做几行判断,所以最高;通信晚了主机会超时重发,排第二;采集晚几毫秒没人在意,最低。
sense 每次采完置 bit0
alarm 每 100 ms 置 bit2
comm 每轮循环置 bit1
sense
alarm
comm
heartbeat(atomic_uint)
wdg 任务 每 500 ms 看一次
三位都在
喂 IWDG KR = 0xAAAA,清掉看到的位
缺一位
不喂狗 约 4 s 后芯片复位
只有 wdg 任务碰看门狗,但它要等其他三个任务都报过平安。清位时用 atomic_fetch_and 只清掉自己看到的那几位,不直接写 0,免得把刚置上、还没检查过的位也清掉。
任务之间怎么通信
串口中断 → comm :回调里用 osThreadFlagsSet 给 comm 发一个标志。comm 平时阻塞在 osThreadFlagsWait 上不占 CPU,有数据立刻醒。这是任务通知,一对一、最轻量。
sense → alarm、comm :共享一个状态结构体,用互斥锁保护。互斥锁开了优先级继承 ,防止优先级反转。
中断优先级 :串口和 DMA 中断设成 5。FreeRTOS 的临界区只屏蔽 5–15 的中断,要在中断里调 FreeRTOS 函数,优先级数值就必须 ≥ 5。
独立看门狗 IWDG
用内部 LSI 时钟(约 32 kHz),一旦启动只有复位能停下,CPU 死了它照样计数。
预分频 /32、重装载 4095,超时约 4 s。LSI 误差很大(17–47 kHz),实际在 2.8–7.7 s 之间。
写入顺序:KR=0xCCCC 启动 → KR=0x5555 解锁 → 写 PR、RLR → 等 SR 清零 → KR=0xAAAA 喂狗。
DBGMCU->APB1FZ 设了 DBG_IWDG_STOP:GDB 停在断点上时看门狗也停,不然停几秒芯片就复位了。
实测:按住 B1 让 sense 卡死,约 4 s 后复位,RCC_CSR = 0x24000000,看门狗复位位(bit29)和复位引脚位(bit26)都置上了。
踩过的坑 一开始先写 PR、RLR,最后才启动,结果卡死在“等 SR 清零”的循环里:PR、RLR 的更新要靠 LSI 时钟完成,而 LSI 是 IWDG 启动之后才跑。更糟的是 wdg 优先级最高,它在忙等里不让出 CPU,其他任务全饿死。教训:高优先级任务里不能写没有退出条件的忙等 。
CPU 占用率
打开 FreeRTOS 的运行时间统计:每次切换任务,内核读一次计时器,把这段时间记到刚才那个任务的账上。计时器用 TIM5(32 位、1 MHz,71 分钟才回绕),不用 DWT 周期计数器,因为它按 180 MHz 数,23.8 秒就回绕了。sense 每 5 秒算一次各任务占比,从 SWO 打出来。
0% 5% 10% 15% 20%
平时只有采样
0.4%
Modbus 每秒 149 次
1.5%
升级时擦扇区的 5 秒
16.7%
第一次测错的数
2.7%(当时 AHT20 没应答)
CPU 占用 = 100% − 空闲任务占比。擦 128 KB 扇区时 CPU 从 Flash 取不到指令,整片停住约 0.9 秒,这段时间记在 comm 上。
测错过一次 第一次测出平时 2.7%,几乎全在 sense 里。用 OpenOCD 的 profile 采样 PC,时间花在 I2C_WaitOnFlagUntilTimeout。I²C 一次通信只要 1.1 ms,这里每秒却耗了约 20 ms,对不上。后来发现当时 AHT20 一直没应答,HAL 每次都要等满 20 ms 超时。修好传感器重测是 0.4%。教训:测性能之前,先确认功能本身是正常的 。
顺带:芯片内部温度怎么算
ADC 读出来的是“相对于 VDDA 的比例”,VDDA 不准结果就偏。所以先读 VREFINT(芯片内部稳定的参考电压),用出厂时在 3.3 V 下测好的值反推实际 VDDA;再把温度读数折算回 3.3 V 下的值,用 30°C 和 110°C 两个出厂校准点线性插值。全程整数运算,温度乘 100 存。实测 VDDA 3311 mV。
面试会问 先自己说,再点开
任务优先级怎么定?看门狗任务为什么最高? 按“晚了会出什么后果”排。看门狗任务晚了,可能在别的任务都正常时误复位,所以最高;它每次只做几行判断就睡,不会占着 CPU。通信晚了主机会超时重发,排第二。告警每 100 ms 一次,普通。采集 1 秒一次,最低。
中断里能调哪些 FreeRTOS 函数?为什么中断优先级必须 ≥ 5? 只能调带 FromISR 的版本(CMSIS-RTOS2 的 osThreadFlagsSet 等会自己判断是不是在中断里),不能调会阻塞的,比如 osDelay、等互斥锁。FreeRTOS 进临界区时用 BASEPRI 只屏蔽优先级数值 5–15 的中断,0–4 照样能打进来。如果一个 0–4 的中断去调内核函数,就可能在内核改数据结构改到一半时插进来,把它搞坏。
为什么 SysTick 和 PendSV 设成最低优先级? PendSV 负责切换任务,要等所有中断都处理完再切,所以放最低。SysTick 只是计节拍、判断要不要切换,不应该去抢真正的外设中断。
什么是优先级反转?互斥锁和二值信号量有什么区别? 低优先级任务拿着锁,高优先级任务来等这把锁,这时一个中优先级任务一直在跑,低的轮不到执行、放不了锁,高的就被中的间接卡住。互斥锁有优先级继承:高的来等锁时,把拿锁的低优先级任务临时提到高优先级,让它尽快用完放锁。二值信号量没有这个机制,所以用来“通知”,不用来“保护共享数据”。
任务通知、队列、信号量分别什么时候用? 任务通知是一对一、最轻的,适合中断叫醒某个特定任务,本项目串口中断叫醒 comm 就用它。队列用来传数据,可以多个生产者。信号量用来数资源或者表示事件发生了。
看门狗只在一个地方喂,为什么能发现任意一个任务卡死? 每个任务正常运行时会定期置自己的心跳位,wdg 任务只在所有位都置上时才喂狗。任何一个任务卡住,它的位就不再置,wdg 不喂,IWDG 超时把芯片复位。
喂狗为什么不能放在定时器中断里? 中断不靠任务调度,所有任务都卡死了,定时器中断照样按时进来、照样喂狗,看门狗就失去意义了。
栈大小怎么定?溢出了怎么发现? 先给宽一点,跑起来以后用 uxTaskGetStackHighWaterMark 看运行以来最少剩多少(高水位),再留出余量。溢出检测用 configCHECK_FOR_STACK_OVERFLOW = 2:内核在切换任务时检查栈底预先填好的那段值有没有被改,改了就调钩子函数。我的钩子关中断死循环,等看门狗复位。
CPU 占用率是怎么测的? 打开 FreeRTOS 的运行时间统计,用一个 32 位 1 MHz 的定时器计时,内核每次切换任务都把这段时间加到刚才运行的任务上。每 5 秒取一次所有任务的累计值,和上次相减,就是这 5 秒里各任务占多少;CPU 占用就是 100% 减去空闲任务的占比。局限是中断时间会算到被打断的任务头上。
内部温度传感器为什么要用 VREFINT 校正? ADC 的结果是输入电压和 VDDA 的比值,VDDA 不是正好 3.3 V 的话,结果会整体偏。VREFINT 是芯片内部一个很稳定的电压,出厂时在 3.3 V 下测过一次写在芯片里。用它就能算出现在的 VDDA,再把温度读数折算回 3.3 V 下的值,用出厂校准点插值。
CHAPTER 04
Modbus RTU
板子和上位机说话用的协议:一问一答,靠停顿分帧,靠 CRC 防错。
先记住这几句 Modbus 是电脑和板子说话的规矩:电脑问,板子答。 每句话末尾带校验码(CRC),传错了就丢掉;电脑等不到回答会再问一遍。 一句话和下一句之间靠“停顿”分开,没有专门的结束符。
打个比方 Modbus 像对讲机点名:主机喊“1 号,把你第 0 到第 11 格的数报给我”,只有 1 号回答,别人不吭声。话说完了没有“完毕”两个字,靠停顿来判断这句话说完了。每句话末尾带一个校验码,听岔了就当没听见,主机等不到回答会再喊一遍。
一帧长什么样
请求:读从站 1 的输入寄存器,从 0 开始读 12 个
01
04
00
00
00
0C
F0
0F
从站地址
功能码
起始地址 0(高字节在前)
数量 12
CRC16,低字节在前
回复:功能码照抄,后面跟字节数和数据
01
04
18
24 字节:12 个寄存器,每个高字节在前
CRC
CRC
字节数 0x18 = 24
出错时功能码最高位置 1:01 84 02 + CRC,表示地址非法
寄存器的值是大端(高字节在前),CRC 却是低字节在前。规范就这么定的,很容易搞混。F0 0F 是这一帧真实的 CRC。
寄存器表(节选)
类型 地址 内容
输入(只读) 0 / 1 芯片内部温度(0.01°C)/ VDDA(mV)
输入 2 告警状态
输入 5 / 6 / 7 / 8 复位原因 / 固件版本 / 正在运行的分区 / 启动标志(试运行、已回滚)
输入 9 / 10 / 11 环境温度 / 湿度 / AHT20 状态(0 正常,1 没应答,2 没测完,3 CRC 错)
输入 0x100 起 最近一次死机现场(PC、LR、CFSR、BFAR、任务名…)
保持(可写) 0 / 1 / 2 告警上限 / 解除值 / 采样周期
自定义功能码 0x41 固件升级:INFO、BEGIN、DATA、COMMIT、REBOOT
CRC16 怎么算
uint16_t crc = 0xFFFF;
for (每个字节 b) {
crc ^= b;
for (8 次) crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : crc >> 1;
}
0xA001 是多项式 0x8005 按位反过来的样子,所以每次右移。拿两个公开值核对过:字符串 "123456789" 算出 0x4B37;请求 01 03 00 00 00 0A 的 CRC 是 C5 CD。也可以查表:先算好 256 项,每字节查一次,快很多,代价是多占 512 字节 Flash。一帧几十个字节,逐位算的耗时可以忽略,所以没用查表。
怎么知道一帧结束了
时间
请求 8 字节,每个 87 µs(10 位 ÷ 115200)
IDLE 中断
comm 任务再等 3 个节拍,没有新字节
(实际 2–3 ms,规范要求 ≥ 1.75 ms)
处理并用 DMA 回复
RTU 帧没有长度字段也没有结束符,靠停顿分帧。IDLE 只能发现停了 1 个字符,所以还要任务里再等一等。
规范:帧之间停顿超过 3.5 个字符算新的一帧;波特率高于 19200 时固定为 1.75 ms。
超过 256 字节的帧整帧丢掉,一直丢到出现停顿,从下一帧重新开始。
从站地址不对、CRC 错的帧都不回复 。主机等 200 ms 收不到就重发,最多 3 次,这就是超时重传。
写参数:要么全改,要么全不改
一次写多个寄存器(0x10)时,先把新值拷到一个临时配置里,整体检查(范围对不对、解除值是不是小于上限),全部合格才替换正在用的配置。例子:解除值是 43.00 时,单独把上限写成 35.00 会被拒绝(返回异常码 03),要用 0x10 把两个一起写。改成功以后存进内部 Flash 的参数扇区,断电不丢。
日志为什么走 SWO
日志和 Modbus 不能共用一个串口,主机收到夹着文字的数据就没法解析帧了。SWO 是调试口上一根单向输出线(PB3):CPU 往 ITM 的端口 0 写字符,硬件自动从 SWO 发出去,ST-LINK 收下,OpenOCD 存成文件,tools/swo_view.py 去掉包头显示出来。
踩过的坑:长帧收不全 升级的 DATA 帧 128 字节以上就没有回复。原因是 comm 任务的 3 ms 计时从“最后一次中断通知”开始算:长帧中途 DMA 半满中断提前叫醒了任务,之后到帧尾前没有中断,3 ms 一到就把半截帧当整帧处理,CRC 不对被丢掉。修法:每次醒来都直接读 DMA 当前位置把数据取完,只有“真的 3 ms 没有新字节”才算一帧结束。
踩过的坑:两个程序抢一个串口 回复看起来被切成两段,其实是之前开的串口记录脚本还在后台读同一个串口,两个程序各抢走一部分。macOS 不拦着多个程序同时打开一个串口。
500 / 500 连续读寄存器,修 bug 后
约 6 ms 一次请求加回复的往返
149 次/s 满速轮询,0 失败
25 个 协议层 gtest 用例全过
面试会问 先自己说,再点开
Modbus RTU 帧格式是什么?为什么寄存器是大端,CRC 却是低字节在前? 从站地址 1 字节、功能码 1 字节、数据若干、CRC16 两字节,最长 256 字节。大端和 CRC 低字节在前都是规范规定的:寄存器数据按 Modbus 数据模型的约定高字节在前;CRC 是按位从低到高右移着算的,结果也就按低字节先发。这样接收方把整帧连同 CRC 一起再算一遍,结果正好是 0。
3.5 个字符的帧间隔是怎么回事?你怎么检测的? 规范规定帧和帧之间至少停 3.5 个字符的时间。115200 下一个字符约 87 µs,3.5 个才 0.3 ms,电脑和 USB 串口很难做到这么准,所以规范规定 19200 以上固定 1.75 ms。我用串口空闲中断(停了 1 个字符)叫醒 comm 任务,任务里再等 3 个系统节拍,期间没有新字节就认为一帧结束,实际等 2–3 ms。
CRC16 怎么算?查表和逐位算各有什么好处? 初值 0xFFFF,每个字节异或进去,再做 8 次:最低位是 1 就右移后异或 0xA001,是 0 就只右移。查表法先算好 256 项,每字节查一次,快很多,但多占 512 字节 Flash;逐位算省空间。我们一帧几十字节,逐位算的时间可以忽略。
从站什么时候不回复?主机怎么办? 地址不是自己、CRC 错、广播(地址 0),这三种都不回复。主机等到超时(200 ms)就重发,最多 3 次。这样线上干扰导致的错帧会被自动重试掉。
0x06 和 0x10 有什么区别?怎么保证要么全改要么全不改? 0x06 写一个寄存器,0x10 一次写多个。写多个时先把所有新值放进临时结构,整体校验,全部合格才替换正在用的配置;有一个不合格就整个拒绝、返回异常码 03,正在用的配置一点没动。
日志为什么和业务通信分开?SWO 是什么? 混在一个串口里,主机收到的数据里夹着日志文字,就没法按 Modbus 解析了。SWO 是 SWD 调试口多出来的一根单向输出线,CPU 写 ITM 寄存器,硬件自己把数据串行发出去,调试器接收。它不占用业务串口,没接调试器时代码检测到 ITM 没打开就直接跳过。
每 32 次丢一帧的 bug 是怎么定位的? 先找规律:每 32 次丢 1 次,每帧 8 字节,32 × 8 = 256,正好是 DMA 缓冲区大小,怀疑和回绕有关。再用 GDB 在接收回调上打断点打印参数,看到写满时连着来了 TC 和 IDLE 两次回调,size 都是 256,第二次被误当成又来了 256 字节。修完以后专门跑了 500 次跨过 15 次回绕验证。之前的回显测试没发现,是因为数据一直不停,缓冲区写满那一刻后面还有数据,不会出现 IDLE。
CHAPTER 05
Qt 上位机
电脑这一侧:界面不卡、数据怎么显示、跨线程怎么传消息。
先记住这几句 上位机就是电脑上的软件,用 Qt 写,有六个页面。 等板子回话的活交给另一个线程,界面才不会卡。 两个线程之间靠 Qt 的“信号槽”传消息。
打个比方 界面线程是前台服务员,串口是后厨。服务员要是自己跑去后厨等菜,前台就没人招呼客人了,界面就卡死。所以专门安排一个人(工作线程)去后厨等,菜好了按铃通知服务员。按铃的方式就是 Qt 的信号槽。
六个页面:实时数据(曲线)、寄存器表(双击可写)、告警、历史(SQLite 查询、导出 CSV)、固件升级、诊断(通信统计、复位原因、死机记录)。
UI 线程
MainWindow · 六个页面
Device · 轮询、统计
RegisterModel · 寄存器表
Upgrader · 升级流程
工作线程
ModbusWorker
请求队列,一次一个
QSerialPort
超时 QTimer · 重发 3 次
invokeMethod
emit finished
参数拷一份
板子
串口对象在工作线程里创建,读写和超时都在那边跑。两个线程之间只通过排队的消息来往,不共用内存,所以不用加锁。
跨线程怎么传消息
ModbusWorker 是普通的 QObject,用 moveToThread 挪进 QThread,串口在它里面创建。
UI 线程调工作线程的函数,用 QMetaObject::invokeMethod(worker_, lambda):Qt 发现对象在别的线程,就把这次调用打包成事件,放进那个线程的事件队列,由它自己执行。
工作线程做完 emit finished(...),连到 UI 线程的槽。跨线程时连接方式自动变成 QueuedConnection,参数拷一份传过去。自定义类型要先 qRegisterMetaType。
退出顺序:先让工作线程把串口关掉(BlockingQueuedConnection 等它做完),再 quit() + wait(),工作对象在线程结束时 deleteLater。顺序错了退出时会崩。
读 Qt 代码要懂的几个 C++ 写法
类和对象 :类是图纸,对象是照图纸造出来的东西。Upgrader 是类,upgrader_ 是一个对象(名字后面的下划线是项目习惯,表示“这是类的成员变量”)。
-> 和 . :手里拿的是对象本身用 .,拿的是对象的地址(指针)用 ->。upgradeBar_->setValue(50) 就是“让那个进度条显示 50”。
lambda :[this](qint64 done, qint64 total) { ... } 是一个就地写的小函数。方括号里写它要用到的外面的东西,这里是 this(当前窗口),圆括号里是参数,花括号里是要做的事。
connect :connect(发出者, &类::信号, 接收者, 处理函数),意思是“发出者喊这个信号时,执行这个处理函数”。emit 就是喊。
项目里真实的一段:升级器报告进度时,更新进度条。
connect(upgrader_, &Upgrader::progress, this , [this ](qint64 done, qint64 total) {
upgradeBar_->setValue(static_cast <int >(done * 100 / total));
});
Model/View
寄存器表用 QAbstractTableModel:表格控件(View)只管画,数据有几行、每格显示什么、能不能编辑,都由 Model 回答(rowCount、data、flags、setData)。双击保持寄存器 → View 调 setData → Model 把 45.00 换算成 4500 → Device::writeHolding → 设备回复后重新读一遍 → Model 发 dataChanged 刷新。设备拒绝时状态栏显示原因,表格里的值不变。
踩过的坑:编辑框每秒被重置 双击“告警解除值”想改,删掉数字后一秒又跳回原值。原因是每次采样都对整张表发 dataChanged,Qt 会用模型里的值刷新正在打开的编辑框。改成每秒只刷新输入寄存器那几行,保持寄存器只在读到新值后才刷新。
938 行 历史导出 CSV
100 s 手指加热触发、降温解除的一次报警
36960 上位机升级拔线后续传的断点(字节)
面试会问 先自己说,再点开
为什么串口不能放在 UI 线程?moveToThread 之后东西在哪个线程? 等设备回复要几毫秒到几百毫秒,超时还要重发,放在 UI 线程里这段时间界面就不响应了。moveToThread 之后,ModbusWorker 和它内部创建的 QSerialPort、QTimer 都属于工作线程,它们的槽函数和定时器回调都在工作线程里跑;MainWindow、Device、Model 留在 UI 线程。注意 QSerialPort 要在 worker 被挪过去之后、在工作线程里创建,否则它还属于 UI 线程。
信号槽跨线程时参数怎么传?什么时候要 qRegisterMetaType? 跨线程连接默认是 QueuedConnection:发信号时把参数拷一份,连同调用打包成事件,放进接收者线程的事件队列,接收线程空下来时再执行槽。因为是拷贝,两个线程不会同时碰同一块内存。参数类型必须是 Qt 元对象系统认识的,QByteArray、int 这类内置的直接能用,自己定义的结构体要先 qRegisterMetaType 注册。
Model/View 有什么好处?dataChanged 什么时候发? 数据和显示分开:同一份数据可以给多个视图用,换显示方式不用改数据逻辑;视图只在被告知某些格子变了时才重画那几格。数据真的变了才发 dataChanged,而且只报变了的范围。我踩过的坑就是每秒对整张表发,把正在编辑的格子也刷新了。
上位机怎么判断一帧收完?超时重发会不会让设备重复执行写操作? 发出请求以后,根据请求的功能码就能算出回复应该多长(mb_response_len),收够这么多字节再校验 CRC。重发确实可能让设备执行两次,比如设备已经写成功了、回复在路上丢了。所以写操作要设计成重复执行也没关系:写寄存器写两次结果一样;升级的 DATA 命令带偏移,设备发现这个偏移已经写过就直接回复,不会写两遍。
CHAPTER 06
Bootloader 和在线升级
这个项目最能拿出来讲的一章:升级被打断不变砖,新固件有问题自动退回。
先记住这几句 Bootloader 是开机最先跑的一小段程序,决定启动哪一份固件。 芯片里放两份固件(A 和 B)。新的只写进空的那份,写到一半断电,旧的还在。 新固件先“试用”,启动 3 次都没报平安,就自动退回旧的。
打个比方 A/B 分区像家里有两张床。你睡在 A 床,新床单只铺在 B 床上,铺到一半被打断,A 床照样能睡。铺好了先去 B 床“试睡”,Bootloader 是门口记账的:每试睡一次记一笔,试了 3 次都没说“这床行”,就把你送回 A 床。
内部 Flash 怎么分
Bootloader
扇区 2、3:升级状态(两份轮流写)
参数
slot A 128 KB
slot B 128 KB
出厂镜像 两份都坏时拷回 A
0x0800_0000
0x0802_0000
0x0804_0000
0x0806_0000
0x0808_0000
按真实大小画:F4 的扇区大小不一样,前 4 个 16 KB,第 5 个 64 KB,后面都是 128 KB
Bootloader 实测只有 4.6 KB。同一份应用代码按 A、B 两个地址各链接一次,得到 app_A.bin 和 app_B.bin。
开机时 Bootloader 怎么决定跳哪
flowchart TD
P[上电进 Bootloader] --> K{按住 B1?}
K -- 是 --> R[救砖模式:串口收固件写 slot A]
K -- 否 --> S[读升级状态]
S --> T{有正在试运行的 slot?}
T -- 试了 3 次还没确认 --> RB[回滚到 active,标记已回滚]
T -- 校验通过 --> TR[tries 加 1,跳过去试运行]
T -- 校验不过 --> RJ[拒绝,标记 REJECTED]
T -- 没有 --> A{active 校验通过?}
RB --> A
RJ --> A
A -- 是 --> J[跳到 active]
A -- 否 --> O{另一个 slot 能用?}
O -- 是 --> J2[改 active,跳过去]
O -- 否 --> F{出厂镜像能用?}
F -- 是 --> C[拷回 slot A,跳过去]
F -- 否 --> R
一次升级的完整过程
sequenceDiagram
participant H as 上位机
participant D as 应用(运行在 A)
participant B as Bootloader
H->>D: INFO
D-->>H: 运行 A,可以写 B,断点 0
H->>D: BEGIN 版本 长度 CRC 地址
Note over D: 擦 slot B,1–2 秒
D-->>H: 断点 0
loop 每帧 240 字节
H->>D: DATA 偏移 + 数据
D-->>H: 新断点
end
H->>D: COMMIT
Note over D: 硬件 CRC 算一遍,对上才把 commit 字写 0,状态改成试运行 B
H->>D: REBOOT
D->>B: 复位
Note over B: tries 加 1,跳到 B
Note over D: B 跑满约 5 秒、各任务都活着,自己确认
slot 里面长什么样
镜像头 0x200 字节
向量表 slot + 0x200
程序代码和常量(已经写进去的部分)
FFFFFFFF FFFFFFFF …
断点
从末尾往前找,最后一个不是 FFFFFFFF 的字后面
头里有:魔数、版本、长度、CRC32、链接地址,最后是 commit 字
commit = FFFFFFFF 表示没写完;COMMIT 校验通过后写成 0。Flash 不擦也能把 1 改成 0
断点不用单独记,设备从 Flash 里看出来。所以断电重启以后,上位机再发同一份镜像就能接着传。
跳转到应用之前要做的几步
Bootloader 跳到应用,相当于“不复位就换一个程序”,CPU 和外设还是 Bootloader 留下的样子,而应用的启动代码默认芯片是刚复位的。所以要把动过的东西恢复:
static void jump(uint32_t vtor)
{
uint32_t sp = ((const uint32_t *)vtor)[0];
uint32_t pc = ((const uint32_t *)vtor)[1];
iwdg_start();
__disable_irq();
SysTick->CTRL = 0;
SysTick->LOAD = 0;
SysTick->VAL = 0;
for (uint32_t i = 0; i < 8; i++) {
NVIC->ICER[i] = 0xFFFFFFFFu;
NVIC->ICPR[i] = 0xFFFFFFFFu;
}
SCB->ICSR = SCB_ICSR_PENDSTCLR_Msk;
RCC->APB1RSTR |= RCC_APB1RSTR_USART2RST;
RCC->APB1RSTR &= ~RCC_APB1RSTR_USART2RST;
...
SCB->VTOR = vtor;
__DSB();
__ISB();
__enable_irq();
__asm volatile ("msr msp, %0\n bx %1" : : "r" (sp), "r" (pc));
}
先开看门狗 :新固件要是一启动就卡死,还没走到自己开看门狗那一步,也能被复位回来,Bootloader 接着数 tries。
关中断、关 SysTick :不关的话,应用还没准备好时 SysTick 来了,会跳进应用里还没就绪的处理函数。
清掉 NVIC 所有使能和挂起 :不然 Bootloader 里挂起的中断,应用一开中断就进去了。
复位用过的外设 (USART2、GPIO)并关时钟:应用的 HAL 初始化按复位后的默认值来。
设 VTOR = slot + 0x200 :以后中断去应用的向量表找处理函数。VTOR 要 512 字节对齐,所以镜像头正好占 0x200。
设 MSP、跳到 Reset_Handler :向量表第 0 项是栈顶,第 1 项是入口。两步用内联汇编一起做,因为改了 MSP 以后 C 代码不能再用栈。
另外时钟留在 HSI 16 MHz 不动:应用自己配 PLL,如果 Bootloader 已经把系统时钟切到 PLL,应用再配 PLL 会失败(HAL 不允许改正在用的 PLL)。
掉电时状态怎么保证不坏
扇区 2
#1
#2
#3
#4
#5
#6
#7 半条
空
读到的是这条
CRC 不对,跳过
扇区 3
空着。扇区 2 写满了才擦它接着写,正在用的扇区永远不擦
每条 16 字节:序号、active、trial、tries、flags、CRC。改状态不改旧记录,在后面追加一条;读的时候取 CRC 对、序号最大的那条
写到一半断电,那一条 CRC 不对会被跳过,读到的还是上一条。擦扇区时断电,最新的有效记录还在另一个扇区里。
镜像 :BEGIN 时先擦整个 slot、写镜像头,commit 字留成 FFFFFFFF;COMMIT 时 CRC 对上才写成 0。Bootloader 只认 commit = 0 的镜像,而且每次开机都重算 CRC。所以不管在哪一步断电,都不会出现“写了一半的固件被当成好的跑起来”。
寿命 :F4 内部 Flash 每个扇区保证擦写 1 万次。状态扇区 16 KB 能存 1024 条,两个扇区轮流用,大约 2000 万次状态变化才会用坏。如果每改一次擦一次,1 万次就到头了。
CRC32 :F4 的硬件 CRC 和 zlib 的结果不一样:同一个多项式 0x04C11DB7,但硬件按 32 位字输入、不反转、结果不异或。所以 tools/mkimage.py 按芯片的算法自己实现了一遍,用公开值核对过。
试运行和自动回滚
状态里有 trial(正在试运行哪个 slot)和 tries(试了几次)。Bootloader 每次跳到试运行的 slot 之前把 tries 加 1 写进 Flash。新固件跑满约 5 秒(看门狗连续喂了 10 次,说明各任务都活着)就自己确认。确认之前被复位(死机后被看门狗复位、HardFault、断电),tries 继续加;到 3 次还没确认就回到旧的 slot,寄存器 8 报“已回滚”。正在试运行时不允许再升级,否则会把唯一能退回的旧固件擦掉。
踩过的坑:坏固件根本不回滚 最早模拟坏固件的写法只是“不确认”。程序本身没出问题,也没人复位它,Bootloader 根本没机会数次数,一直跑在没确认的新固件上。改成试运行 5 秒后让 comm 任务停住,看门狗约 4 秒后复位,3 次后才真的回滚。上板才发现的。
救砖模式
上电时按住 B1,或者两个 slot 和出厂镜像都坏了,Bootloader 不跳转,自己当 Modbus 从站(轮询收串口,不用中断和 DMA),支持一样的 0x41 升级命令,固定写 slot A。所以就算应用彻底坏了,只要 Bootloader 在,用同一个上位机就能救回来。
8.1 s 60 KB 镜像传完(约 7.3 KB/s)
100 / 100 升级中随机复位,全部恢复
0 次 误回滚
< 45 s 坏固件从升级完到回滚
面试会问 先自己说,再点开
Bootloader 跳转前要做哪几步?少做一步会怎样? 开看门狗、关中断、关 SysTick、清 NVIC 的使能和挂起、复位用过的外设、设 VTOR、设 MSP 并跳到 Reset_Handler。少关 SysTick,应用还没启动调度器时 SysTick 中断来了,会进 FreeRTOS 还没准备好的处理函数;少清挂起中断,应用一开中断就进一个莫名其妙的中断;不设 VTOR,中断还去 Bootloader 的向量表找函数;外设不复位,应用的 HAL 初始化可能因为状态不对而出错。
VTOR 为什么要 512 字节对齐?镜像头为什么占 0x200? VTOR 的对齐要求是向量表大小向上取到 2 的幂。F446 的向量表有 113 项,每项 4 字节,452 字节,向上取到 512。所以向量表必须从 512 的整数倍地址开始,slot 起点是 128 KB 对齐的,镜像头就正好占 0x200,向量表从 slot + 0x200 开始。
升级时断电,怎么保证设备还能启动?状态写到一半断电呢? 新固件只写空闲的那个 slot,正在运行的旧固件一个字节都不动,断电以后 Bootloader 照样启动旧的。新镜像有 commit 字,只有整个镜像 CRC 对上才写成 0,没写完的镜像会被无视。状态记录是追加写、带序号和 CRC,写一半断电那一条 CRC 不对被跳过;两个扇区轮流用,正在用的那个永远不擦。实测在 58% 时拔 USB,插回后旧固件正常启动,接着续传。
Flash 擦写寿命是多少?你的设计每次升级擦几次? F4 内部 Flash 每个扇区保证 1 万次。一次升级只擦一次目标 slot 所在的 128 KB 扇区。状态记录是追加写,大约写满 1024 条才擦一次。参数扇区 64 KB,能追加 4096 次才擦一次。所以寿命主要看 slot 扇区,一个 slot 能升级 1 万次。
新固件能启动但有 bug,怎么回滚?确认的条件为什么这么定? Bootloader 记试运行次数,新固件 3 次启动都没确认就回到旧的。确认条件是跑满约 5 秒、看门狗被连续喂了 10 次。喂狗的前提是所有任务都报了心跳,所以这说明调度器正常、每个任务都在跑,比“main 函数跑到了”可靠。局限是它只能发现卡死和崩溃类的问题,逻辑算错这类问题发现不了。
为什么不先把新固件放到外部 Flash 再拷? A/B 两个 slot 已经保证了“旧固件一直在”。先放外部 Flash 再拷到内部,要多擦写一次、多一个出错点,Bootloader 还得带 SPI 驱动,代码变大、能出错的地方变多。
断点续传怎么做的? 设备不单独记“传到哪了”,而是从镜像末尾往前找最后一个不是 FFFFFFFF 的字,后面就是断点。上位机 BEGIN 时如果镜像头(版本、长度、CRC)和 slot 里那份没写完的一样,就从断点接着写。DATA 命令带偏移,偏移在断点之前说明是重发,直接回复不再写。断电时可能有一个字只写进去一部分,那 COMMIT 时 CRC 对不上,镜像作废,从头传。
F4 的硬件 CRC 和 zlib 的 CRC32 有什么不同? 多项式一样,都是 0x04C11DB7。zlib 的输入输出都按位反转,最后还异或 0xFFFFFFFF;F4 的硬件按 32 位字输入,不反转,结果也不异或。所以电脑上用 zlib.crc32 算出来对不上,打包工具里按芯片的方式自己实现了一遍。用硬件 CRC 的好处是快,Bootloader 也不用带 CRC 表。
CHAPTER 07
HardFault 定位
程序崩了以后,怎么知道是哪一行。
先记住这几句 HardFault 是程序做了非法操作时 CPU 进入的出错处理。 出错那一刻把关键信息(执行到哪个地址了)抄下来存好,然后重启。 事后用工具把地址翻译成源码的哪个文件、第几行。
打个比方 飞机的黑匣子。出事那一刻 CPU 自动把几个关键寄存器压到栈上,我们赶紧把它们抄到一块复位也不会清掉的内存里,然后重启。重启后再把这份记录存进外部 Flash,断电也不丢。事后拿出来,用 addr2line 把地址翻译成源码文件和行号。
高地址
xPSR
PC 出错的指令
LR 谁调的
R12
R3
R2
R1
R0
SP 指向 R0(栈从高往低长)
进 HardFault 时看 LR(叫 EXC_RETURN)的第 2 位
tst lr, #4
是 0:栈帧在 MSP 在中断里出的错
是 1:栈帧在 PSP 在某个任务里出的错
把栈帧地址交给 fault_capture(),抄到 RAM 末尾
CPU 进异常时自动压这 8 个寄存器。FreeRTOS 的任务用 PSP,中断和启动代码用 MSP,所以先判断用的是哪个栈。
处理函数必须是 naked 的,编译器不给它生成任何压栈代码。否则一进函数编译器先压栈,栈指针就变了,找不到 CPU 压的那 8 个寄存器:
__attribute__((naked)) void HardFault_Handler(void )
{
__asm volatile (
"tst lr, #4\n"
"ite eq\n"
"mrseq r0, msp\n"
"mrsne r0, psp\n"
"mov r1, lr\n"
"b fault_capture\n" );
}
CubeMX 生成的那个不是 naked,所以要在 CubeMX 的 NVIC → Code generation 里把 Hard fault 的“Generate IRQ handler”关掉,不然重新生成会重复定义。
从崩溃到源码行
HardFault 抄现场
RAM 末尾 0x2001FF00
软件复位 RAM 不清
转存 W25Q64 断电不丢
fault_decode.py 读 0x100 起 + addr2line
在 HardFault 里不直接写外部 Flash:这时候系统已经不可信,SPI 驱动或者栈本身可能就坏了,越简单越好
RAM 最后 256 字节 Bootloader 和应用都不用,.bss 也不包括它,所以复位后还在。
演示 CFSR 解出来的原因 定位到
寄存器 0xF0 写 1:执行一条未定义指令 0x00010000UNDEFINSTR fault.c:81,调用方 app_tasks.c:318
寄存器 0xF0 写 2:读 0x20030000 0x00008200PRECISERR + BFARVALID,BFAR = 0x20030000 fault.c:75
PC 是出错的那条指令,LR 一般是调用它的地方。HFSR 的 FORCED 位是 1,说明是 BusFault/UsageFault 没单独打开,被升级成了 HardFault,具体原因看 CFSR。
踩过的坑:越界读不出错 最早模拟指针越界读的是 0xCCCCCCCC,上板发现读它不会出错:这个地址在 FMC(外部存储器控制器)的地址段里,读到 0,不触发总线错误。换成 SRAM 末尾之外的 0x20030000(SRAM 到 0x2001FFFF 为止)才真的出错。
面试会问 先自己说,再点开
HardFault 进来时栈上有什么?怎么知道是哪个任务出的错? CPU 自动压了 8 个寄存器:R0–R3、R12、LR、PC、xPSR。先看 LR 第 2 位判断栈帧在 MSP 还是 PSP,是 PSP 就说明是在任务里出的错,再从 FreeRTOS 拿当前任务的名字一起存下来。
MSP 和 PSP 有什么区别? Cortex-M 有两个栈指针。MSP 是主栈,复位后、中断和异常处理里用;PSP 是进程栈,FreeRTOS 给每个任务分一块自己的栈,任务运行时用 PSP 指向它。切换任务时内核只要换 PSP 的值。
PRECISERR 和 IMPRECISERR 有什么区别?非精确的为什么 PC 不准? 精确的总线错误,出错的就是 PC 指着的那条指令,BFAR 里还有出错的地址。非精确的通常是写操作:写先进了写缓冲区,CPU 已经往下执行了好几条,真正写出去时才报错,所以 PC 已经不是出错的那条了,BFAR 也无效。调试时可以关掉写缓冲(ACTLR 的 DISDEFWBUF),让它变成精确的,代价是变慢。
为什么处理函数要是 naked 的? 普通函数进来时编译器会先压栈、调整 SP。HardFault 处理函数要靠 SP 找到 CPU 压的那 8 个寄存器,SP 一变就找错了。naked 让编译器什么都不生成,第一条指令就是我写的汇编,这时的 MSP/PSP 正好指着栈帧。
现场为什么先存 RAM,复位后再存 Flash? HardFault 时系统已经不可信,可能就是 SPI 驱动、栈或者堆坏了,这时候去写外部 Flash 很可能再出错。写 RAM 只要几条指令,最可靠。复位以后系统是干净的,再慢慢转存到外部 Flash。
CHAPTER 08
I²C 和 AHT20
两根线挂多个设备:开漏、上拉、地址、ACK,再加一张自己抓的真实波形。
先记住这几句 I²C 用两根线跟传感器说话:一根打拍子,一根传数据。 线平时被电阻拉成高电平,谁要发 0 就把线拉低,所以多个设备能共用。 每发一个字节,对方都要回一声“收到”(ACK)。
打个比方 I²C 的线像一根被弹簧(上拉电阻)往上拉着的绳子。谁都可以把绳子往下拽,但谁都不能往上推。没人拽就是 1,有人拽就是 0。所以两个设备同时说话也不会“打架”烧坏,最多是拽的那个说了算。
用到了什么
I2C1:PB8 SCL、PB9 SDA,标准模式 100 kHz。CubeMX 默认把 I2C1 放在 PB6/PB7,要手动改到 PB8/PB9。
AHT20 地址 0x38,HAL 要传左移一位的 0x38 << 1。
芯片内部上拉没开,通信照样正常,说明 AHT20 模块板上自带了上拉电阻。
3.3 V
上拉电阻
(在 AHT20 模块上)
SCL
SDA
STM32
主机
PB8 / PB9
AHT20
地址 0x38
还能挂别的设备
地址不一样就行
每个设备只会“拉到地”或“松手”
任何一个拉低,整根线就是低
这叫“线与”
引脚配成开漏(只能拉低、不能推高)。要是配成推挽,一个设备输出高、另一个输出低,电流直接从 3.3 V 流到地,会烧引脚。
一次通信的开头和结尾
SCL
SDA
……
0
1
1
1
ACK
起始:SCL 高时 SDA 拉低
地址 0x38 = 0111000 的前 4 位,SCL 高时读
第 9 个时钟 AHT20 拉低 = 收到了
停止:SCL 高时 SDA 拉高
平时 SDA 只在 SCL 低的时候变。SCL 高的时候 SDA 变了,就是起始或停止。铜色那段是 AHT20 在拉线。
AHT20 怎么读
上电后至少等 40 ms。读 1 字节状态,第 3 位(0x08)是 1 说明已校准;不是就发 BE 08 00 初始化。
发 AC 33 00 触发测量,等 80 ms。
读 7 字节:状态、20 位湿度、20 位温度、CRC8。状态第 7 位是 1 说明还没测完,等 20 ms 再读。
CRC8:多项式 0x31,初值 0xFF,算前 6 字节,和第 7 个比。
换算:湿度 = 原始值 / 2²⁰ × 100%,温度 = 原始值 / 2²⁰ × 200 − 50。代码里先乘 10000 或 20000 再右移 20 位,全用整数,单位 0.01。
读失败(没应答)时,下个周期先重新初始化再读。线松了再插回去能自己恢复。
真实波形
逻辑分析仪 4 MHz 采样,sigrok-cli 解码。上:一次完整测量;中:命令帧 0x38 写 + AC 33 00;下:读数帧 0x38 读 + 7 字节,最后一个字节主机回 NACK。
读数帧解出来是 18 8B CF B6 12 08 C0,自己算一遍:
字节 含义 算出来
18状态:第 3 位 = 1 已校准,第 7 位 = 0 测完了 正常
8B CF B湿度 20 位 = 0x8BCFB = 572667 572667 / 1048576 × 100 = 54.61 %RH
6 12 08温度 20 位 = 0x61208 = 397832 397832 / 1048576 × 200 − 50 = 25.88 °C
C0前 6 字节的 CRC8 对得上
第 4 个字节 B6 被劈成两半:高 4 位 B 归湿度,低 4 位 6 归温度。
99.7 kHz SCL 实测频率
4.75 / 5.3 µs 高 / 低电平(标准要求 ≥ 4.0 / 4.7)
79.8 ms 命令到读数,对应 osDelay(80)
1081 ms 两次测量间隔:1 s 周期 + 等 80 ms
踩过的坑:波形一直是平的 探头插高了一行,地线也没接到 GND。这款分析仪输入悬空时读成高,所以看到的是“一根高、一根低、没有跳变”,像总线卡死,其实是没接对。办法:先把两路探头插到已知的 3.3 V 和 GND 上,读出一高一低,确认通道和地线都好,再挪到 SCL、SDA。数排针时拿一根确定插对的线当参照,比数“第几根”可靠。
踩过的坑:分析仪没通电把总线拽死 抓完波形把 USB 线换回板子,探头还夹在 SCL、SDA 上,AHT20 马上报“没应答”。没通电的分析仪,输入脚的保护二极管会把信号线往它自己的 0 V 拽,上拉电阻拉不上去。探头拿掉就好了。
面试会问 先自己说,再点开
I²C 为什么要上拉电阻?电阻大了小了各有什么问题? 引脚是开漏的,只能拉低,高电平全靠上拉电阻把线拉上去。电阻太大,线上的电容充电慢,上升沿变缓,速度一高就来不及到高电平;电阻太小,设备拉低时电流大,功耗高,还可能超过引脚能吸收的电流、低电平拉不到够低。100 kHz、线不长时常用 4.7 kΩ 左右,400 kHz 用更小的,比如 2.2 kΩ。
ACK 是怎么回事?没有 ACK 说明什么? 每 8 位数据后面有第 9 个时钟,接收方在这一拍把 SDA 拉低,表示收到了。主机发地址后没人回 ACK,说明这个地址上没有设备、设备没上电、线断了,或者总线被别的东西拽住了。我们读最后一个字节时主机故意回 NACK,告诉 AHT20“够了,别发了”。
总线卡死(SDA 一直被拉低)怎么恢复? 常见原因是主机在传输中途复位了,从机还在等时钟、正好在输出 0。办法是把 SCL 临时切成普通 GPIO,手动打最多 9 个时钟,让从机把这个字节发完松开 SDA,再发一个停止条件,然后切回 I²C 外设。这个项目里没有实现,只做了“没应答就下个周期重新初始化”,面试时要老实说。
为什么选 100 kHz 而不是 400 kHz? 一秒读一次、一次 7 个字节,100 kHz 完全够用:读数帧实测只要 736 µs。速度低对上拉电阻和杜邦线更宽容,信号更干净。
这个波形你是怎么抓的?从图上怎么看出起始和 ACK? 用 fx2lafw 逻辑分析仪,CH1 接 SCL、CH2 接 SDA、地线接板子 GND,sigrok-cli 4 MHz 采样 3 秒,再用 i2c 解码器解码。SCL 高的时候 SDA 从高变低就是起始;每 9 个时钟一个字节,第 9 个时钟 SDA 被拉低就是 ACK;最后 SCL 高时 SDA 变高就是停止。
CHAPTER 09
SPI 和 W25Q64
四根线的高速总线,外部 Flash 用来存事件日志和死机记录。
先记住这几句 SPI 用四根线,比 I²C 快,这里用来读写外部 Flash 芯片。 Flash 写之前必须先擦,而且只能整块擦。 日志一条接一条往后写,写满了从头覆盖,靠序号找到最新的一条。
打个比方 SPI 像两个人面对面传纸条,主机打拍子(SCK),每打一拍,主机递过去一位,同时从机递回来一位,所以是“一边说一边听”。CS 像点名:点到谁(拉低)谁才听,一条命令从点名开始,到松开结束。
STM32
SPI2 主机
W25Q64
8 MB 外部 Flash
PB13 SCK 时钟(2.8125 MHz) CLK
PB15 MOSI 主机发 DI
PB14 MISO 从机回 DO
PB12 CS 片选,低电平选中(普通 GPIO) CS
不用 SPI1,是因为 SPI1 的 SCK 在 PA5,和板上 LED 共用一个引脚。
一条命令长什么样:读 JEDEC ID
CS
MOSI
9F
无用
无用
无用
MISO
无用
EF
40
17
华邦
类型
容量 2²³ 字节
全双工:主机每发一个字节,同时收回一个。只想收的时候也得发,发的内容无所谓。CS 在整条命令期间一直是低,所以片选用普通 GPIO 自己控制。
四种模式
模式 CPOL(空闲时时钟) CPHA(在第几个边沿采样)
0(本项目) 低 第 1 个边沿(上升沿)
1 低 第 2 个边沿(下降沿)
2 高 第 1 个边沿(下降沿)
3 高 第 2 个边沿(上升沿)
W25Q64 支持模式 0 和 3。代码里是 SPI_POLARITY_LOW + SPI_PHASE_1EDGE,也就是模式 0。主机和从机模式不一致,读出来的数会错一位或者全乱。
速度:SPI2 挂在 APB1(45 MHz)上,16 分频是 2.8125 MHz。W25Q64 能跑到 104 MHz,这里取低是因为杜邦线长、没屏蔽,慢一点信号干净,而且存日志用不着快。
几条命令
命令 作用
9F读 JEDEC ID,应该是 EF 40 17
06写使能。每次写或擦之前都要先发一次
05读状态寄存器 1,第 0 位 BUSY = 1 表示还在写或擦
03读,后面跟 3 字节地址,然后一直读下去
02页编程,最多 256 字节,不能跨页,跨过去的部分会绕回页开头覆盖
20擦 4 KB 扇区,典型 45 ms,最长 400 ms
Flash 只能把 1 写成 0。要把 0 变回 1 只能整扇区擦成 0xFF,所以写之前那块地方必须擦过。w25q_write 按页切开写,每页写完等 BUSY 清零。
事件日志怎么存、上电怎么找到最后一条
每个扇区 4 KB = 128 条,每条 32 字节,前 4 字节是序号
#0
#128
#256
#384 最大
FFFFFFFF
满
满
满
#385
#386
#387 最后一条
FFFFFFFF
空
第 1 步:只读每个扇区第一条的序号(8 MB 约 2000 次读),找到最大的那个扇区
第 2 步:在这个扇区里往后找,序号不再连续的地方就是结尾,下一条写 #388
写满了从头覆盖;写到某个扇区第一条时先擦这个扇区。几十毫秒就扫完
第 n 条在 0x10000 + (n % 条数) × 32。用序号定位而不是另存一个“写到哪了”的指针,省掉一块要反复擦写的地方。
多个任务都会写日志,用一个互斥锁保护 SPI。
死机记录在 0x000000 起的 8 KB,每条 256 字节,32 条循环覆盖,用法一样。
老实说的不足 :日志条目没有 CRC,写到一半断电,那一条序号可能是好的但文字不完整。死机记录有魔数检查。
老实说的不足 :写日志在调用的任务里同步完成,碰上要擦扇区的那一条(每 128 条一次)会多卡 45–400 ms。在 comm 任务里改参数正好碰上时,Modbus 回复会晚,上位机可能报一次超时(参数其实存进去了)。要彻底解决得挪到单独的低优先级任务排队写,还没做。
面试会问 先自己说,再点开
SPI 的四种模式是什么?W25Q64 用哪种? CPOL 定空闲时时钟是高还是低,CPHA 定在第一个还是第二个边沿采样,组合出 0–3 四种。W25Q64 支持 0 和 3,我用模式 0:空闲低,上升沿采样。两边模式不一样,数据会错位。
SPI 和 I²C 各适合什么场景? I²C 两根线挂多个设备,靠地址区分,省线,但开漏加上拉,速度一般在 100 k–400 kHz,适合传感器这种数据少、慢的设备。SPI 四根线、推挽输出,几十 MHz 很常见,全双工,适合 Flash、屏幕这种数据多的;代价是每多一个从机要多一根 CS,也没有 ACK,从机在不在得自己读 ID 确认。
为什么片选用普通 GPIO,不用硬件 NSS? W25Q64 要求一条命令的所有字节期间 CS 一直是低,命令结束才拉高。硬件 NSS 在字节之间的行为不好控制,自己用 GPIO 拉低、拉高最清楚。CS 默认配成高电平,上电还没初始化时不会误选中。
Flash 为什么要先擦后写?页编程跨页会怎样? Flash 写只能把 1 变成 0,要变回 1 只能整块擦成 0xFF。所以写之前那块必须擦过。页编程一次最多 256 字节,地址跨过页边界的部分会绕回这一页开头,把前面的数据覆盖掉,所以驱动里按页切开写。
日志循环覆盖,上电怎么找到最后一条?写到一半断电会怎样? 每条带一个一直递增的序号,位置由序号算出来。上电只读每个扇区第一条,找序号最大的扇区,再在里面往后找到序号不连续的地方。写到一半断电,那一条可能序号写进去了内容不完整,因为日志条目没有 CRC,这是现在的不足,要改就给每条加 CRC,扫描时跳过 CRC 不对的。
CHAPTER 10
排查问题的方法
面试最爱问“你遇到过最难的 bug 是什么”。这里每个都是真碰到的,按“现象、怎么查、原因、教训”讲。
先记住这几句 先让问题稳定出现,再找规律(每几次出一次、跟什么数字对得上)。 先确认测量工具本身没问题,再去怀疑代码。 一次只改一处,修完用专门打在问题点上的测试再验证。
打个比方 像医生看病:先问清症状、找规律(什么时候犯、多久犯一次),再确认体温计本身没坏,然后做检查缩小范围,最后对症下药,治完还要复查。最常见的错误是体温计坏了却在治病。
通用的顺序
先让它稳定复现 ,找规律:每几次出一次、和什么数字对得上。
先确认测量工具是好的 :拿已知的东西测一遍(已知电压、已知能通的设备、公开的 CRC 值)。
缩小范围 :换掉一半(换线、换程序、换方向),看问题跟着谁走。
看现场 :GDB 断点打印参数、SWO 日志、读寄存器、抓波形。
一次只改一个地方 ,提出一个猜测,用一个实验证实或推翻。
修完用比原来更狠的测试验证 ,专门打在出问题的那个点上。
记下来 :数字记进 test-log,坑记进 notes。
每 32 次丢一帧
现象 Modbus 连续读 200 次,成功 194 次,正好每 32 次丢 1 次。
怎么查 32 × 8 字节 = 256,正好是 DMA 缓冲区大小,怀疑回绕。GDB 在接收回调上打断点打印 size,看到写满那一刻连着来了 TC 和 IDLE 两次回调,size 都是 256。
原因 位置 256 和位置 0 是同一个地方,第二次被当成又来了 256 字节。
教训 之前的回显测试数据不停,写满那一刻后面还有数据,不会出现 IDLE,所以没发现。测试要专门打边界:修完跑了 500 次、跨过 15 次回绕。
长帧收不全
现象 升级 DATA 帧 16、64 字节有回复,128 字节以上都没有。
怎么查 直接发 16/64/128/200/240 字节几种长度,128 起就不行,128 正好是 256 缓冲区的一半,想到半满中断。
原因 半满中断中途叫醒任务,之后到帧尾前没有中断,3 ms 一到就把半截帧当整帧处理,CRC 不对被丢掉。
教训 “多久没有新字节”要看实际数据位置,不能看“上次被叫醒的时间”。
Mac 发给板子的数据被吞
现象 串口回环测试,Mac 发 74 到 694 字节后就卡住,板子发给 Mac 没问题。
怎么查 反方向没问题,说明是一个方向上丢的。GDB 看板子上的 rx_rb.head、rx_pos 和 DMA 的 NDTR 寄存器,三者对得上,说明板子收到多少就处理了多少,数据是在送到 STM32 之前丢的,问题在中间的 ST-LINK。
原因 板载 ST-LINK 固件 V2J28 太老,升级到 V2J48M35 后正常。
教训 先分清问题在链路的哪一段,别一上来就改自己的代码。
坏固件不回滚
现象 编了一个“不确认”的坏固件升进去,它一直跑着,Bootloader 不回滚。
怎么查 想 Bootloader 什么时候才会数 tries:只有复位后才会。而这个“坏”固件一直好好跑着,没人复位它。
原因 只是“不确认”,程序没出问题、也没人复位它,Bootloader 没机会数次数。
教训 模拟故障要模拟真实的样子:改成 5 秒后让 comm 任务停住,看门狗复位 3 次后才真的回滚。
越界读不出错
现象 读 0xCCCCCCCC 模拟野指针,结果没进 HardFault。
怎么查 查芯片手册的地址映射,这个地址在 FMC(外部存储器控制器)的地址段里。
原因 那段地址读到 0,不触发总线错误。换成 SRAM 末尾之外的 0x20030000 才真的出错。
教训 “应该会出错”要上板验证,测试本身也可能是无效的。
上位机编辑框每秒被重置
现象 双击告警解除值想改,删掉数字一秒后又跳回原值。
怎么查 一秒正好是采样周期,去看每次采样后模型发了什么信号。
原因 每次采样对整张表发 dataChanged,Qt 用模型里的值刷新了正在打开的编辑框。
教训 只报真正变了的范围。
逻辑分析仪一直是平线
现象 抓 I²C 波形,一根一直高、一根一直低,没有跳变。
怎么查 先把探头插到已知的 3.3 V 和 GND 上,读出一高一低,确认通道和地线都好,再挪回去。用 W25Q64 的 CS 线当参照数排针。
原因 探头插高了一行,地线没接到 GND。
教训 先确认测量工具本身是对的。
CPU 占用第一次测错
现象 只是每秒读一次传感器,sense 任务却占 2.4–3.1%,读一次要 20 ms 左右,不合理。
怎么查 20 ms 正好是 I²C 超时时间,去看 AHT20 状态,一直是“没应答”。
原因 没通电的逻辑分析仪探头还夹在 SCL/SDA 上,把总线拽低了。拔掉后重测:IDLE 99.6–99.7%,sense 0.2%。
教训 测量前先确认被测对象处在正常状态;数字和预期差很多时,先怀疑测量条件。测错的数字要标出来,不能悄悄删。
面试会问 先自己说,再点开
说一个你遇到过最难的 bug。 推荐讲“每 32 次丢一帧”,因为它有完整的推理链:现象有规律(32 次)→ 换算出 256 字节对上了缓冲区大小 → GDB 打断点看到两次 size = 256 的回调 → 找到原因:位置 256 和 0 是同一处 → 修完专门跨 15 次回绕跑 500 次验证 → 反思为什么之前的测试没发现。讲的时候要带数字。
你一般怎么定位嵌入式问题? 先稳定复现、找规律;再确认测量工具没问题;然后分段缩小范围,看问题在链路哪一段;用 GDB、SWO 日志、寄存器、逻辑分析仪看现场;一次只改一处来验证猜测;修完用专门打边界的测试复查。可以拿 ST-LINK 固件那个例子说明“先分清是哪一段”。
CHAPTER 11
数字速查
面试前过一遍。全部来自 docs/test-log.md,没写进去的数字不要说。
先记住这几句 面试里只说这张表里有的数字。 先背 4 个:升级 60 KB 用 8.1 s,故障注入 100/100 恢复,平时 CPU 占用约 0.4%,Modbus 修 bug 后 500/500。
项目 数字 怎么测的
printf 一行 33 字节 阻塞 2.9 ms → DMA 30 µs DWT 周期计数
串口回环 最多 20000 字节,0 丢失 Mac → 板子 → Mac 逐字节比对
Modbus 读寄存器 修前 194/200,修后 500/500 Python 主站每 55 ms 一次
Modbus 往返 约 6 ms;满速 149 次/s,2387 次 0 失败 Python 主站不间断发 16 s
协议层单元测试 25 个 gtest 用例 Mac 上跑
CPU 占用 平时 IDLE 99.6–99.7%;满速 Modbus 98.4–98.5%;擦扇区窗口 83.3% FreeRTOS 运行时间统计,TIM5 1 MHz
擦 128 KB 扇区 整片停约 0.9 s 5 s 统计窗口平时 5.1–5.4 s,擦扇区时 6.3 s
栈剩余 wdg 872、comm 1328、alarm 772、sense 352 B osThreadGetStackSpace
堆 共 15360 B,最少剩 7560 B xPortGetMinimumEverFreeHeapSize
固件大小 应用 61772 B,Bootloader 4.6 KB 编译输出
看门狗 标称 4 s,LSI 误差使实际 2.8–7.7 s 按 B1 卡死 sense 任务,RCC_CSR = 0x24000000
在线升级 60020 字节 8.1 s(7.3 KB/s) upgrade.py,每帧 240 字节
断点续传 杀进程 25200;拔 USB 35040;上位机拔线 36960 三种方式各测一次
自动回滚 3 次看门狗复位后回滚,不到 45 s APP_SELFTEST_FAIL 坏固件
故障注入 100/100 恢复,0 次误回滚,33 min 34 s 升级中 0–14 s 随机复位
救砖模式 8.2 s 写完 slot A 按住 B1 上电
HardFault UNDEFINSTR → fault.c:81;PRECISERR → fault.c:75 写保持寄存器 0xF0
AHT20 25.07–25.12°C,50.81–51.10 %RH,14 次 0 错 SWO 日志
I²C 波形 99.7 kHz;命令到读数 79.8 ms;读数帧 736 µs 逻辑分析仪 4 MHz 采样
W25Q64 JEDEC ID EF 40 17,SPI 2.8125 MHz 上电自检
告警 报警 100 s(最高 31.37°C) 手指捂 AHT20,阈值 26.50/26.00°C
历史导出 938 行 CSV 上位机历史页
别说错 故障注入脚本里的“恢复时间”是上位机报错以后才开始计的,不代表设备启动时间,不要引用。第一次测的 CPU 2.7% 是传感器坏着测的,已作废。对外说“按工业产品的做法实现”,不说“工业级”。
附录
术语表
正文里带虚线下划线的词,解释都在这里。大致按出现的先后排。