这个项目是一个温湿度采集终端,参照 RS485 温湿度变送器的做法。STM32 上跑 FreeRTOS:sense 任务每秒通过 I²C 读 AHT20,换算成 0.01°C 的整数,写进一个互斥锁保护的共享结构体;alarm 任务据此判断告警、控制 LED。电脑上的 Qt 上位机是 Modbus RTU 主站,每 0.5 秒发一次读请求;板子这边串口用 DMA 加空闲中断收数据,comm 任务按停顿分帧、校验 CRC,从共享结构体里取值回复,一问一答约 6 ms。上位机的串口放在工作线程里,结果通过信号槽交给界面画曲线、存 SQLite。在这条线之外,我重点做了出问题时的处理:每个任务报心跳,配合独立看门狗,哪个任务卡死都会重启;Bootloader 加 A/B 分区在线升级,升级过程中随机复位 100 次全部恢复,坏固件 3 次启动没确认就自动回滚;HardFault 时保存现场、存进外部 Flash,能定位到源码行。
SensorNode 学习手册
把这个项目从头到尾讲明白:每一块用了哪些外设和寄存器、为什么这样设计、实测是多少、面试会怎么追问。所有数字都来自 docs/test-log.md 里的实测记录。
打开接线全景 → 板子上每个部件、两个模块怎么接、电脑和逻辑分析仪怎么连,点着看,还能一步一步看一个温度数字怎么走到电脑上。
主线:跟着一个温度数字走一遍
后面每一章都在讲这条路上的某一站。先把这条线记住,再看细节,每个知识点就知道该放在哪了。
- 一个温度数字从 AHT20 走到上位机的曲线上,一共经过 5 站,对应第 1 到第 6 章。
- 这 5 站讲的是“正常时怎么干活”。第 7 到第 10 章讲“出事了怎么办”。
- 面试一般先让你把这条线讲一遍,再挑其中一站往下追问。
5 站逐个看
- 芯片跑起来(第 1 章 时钟和 GPIO):上电后,板载 ST-LINK 送来 8 MHz 的时钟,PLL 把它放大到 180 MHz,CPU 这才有节拍干活。引脚怎么设成输入、输出也在这一章。LED 能闪,说明芯片在跑。
- 去传感器那拿数(第 2 章 I²C 和 AHT20):sense 任务每 1 秒通过 I²C 给 AHT20 发一条测量命令,等 80 ms 读回 7 个字节,换算成 25.31°C,存成整数 2531。单位是 0.01°C,这样单片机里不用算小数。
- 谁去拿、谁来判断(第 3 章 FreeRTOS 和看门狗):sense 把数写进一个共享结构体,用互斥锁保护,别的任务读的时候不会读到写了一半的数据。alarm 每 100 ms 看一眼,超过上限就让 LED 快闪。wdg 任务确认几个任务都还活着,才去喂看门狗。
- 电脑来问(第 4 章 串口 DMA、第 5 章 Modbus):上位机发来 8 个字节
01 04 00 00 00 0C F0 0F,意思是“1 号设备,把输入寄存器 0 到 11 报给我”。DMA 把这些字节搬进内存,线上安静 2–3 ms 就当这句话说完了,叫醒 comm 任务。comm 先核对 CRC,再从共享结构体里取出当前的值装进寄存器(第 9 号就是 2531),回复回去。一问一答实测约 6 ms。 - 电脑显示(第 6 章 Qt 上位机):上位机每 0.5 秒问一次,把 2531 除以 100,显示成 25.31°C 画到曲线上,每秒最多往 SQLite 历史库存一条。串口收发放在单独的线程里,界面不会卡。
出事了怎么办:后面 4 章
上面 5 站都顺利,数就能一直走下去。这个项目真正花功夫的地方,是某一站出了问题以后,设备不会变砖,还能查出原因:
| 出了什么事 | 谁来兜底 | 看哪章 |
|---|---|---|
| 某个任务卡死 | 心跳凑不齐,wdg 不喂狗,约 4 秒后看门狗让芯片重启 | 第 3 章 |
| 换新固件,换到一半断电 | Bootloader 加 A/B 两份固件:新的只写进空着的那份,旧的一直在 | 第 7 章 |
| 新固件装上了但跑不起来 | 先试运行,3 次启动都没确认就退回旧的 | 第 7 章 |
| 程序崩溃(HardFault) | 记下出错时的地址,重启后存进外部 Flash(W25Q64),事后查到是哪一行 | 第 8 章、第 9 章 |
| 丢帧、传错、波形不对 | 排查方法:先找规律,先确认工具,再缩小范围 | 第 10 章 |
怎么读这本手册
后面的章节已经按这条线排好了顺序,从上往下读就行:
- 第一遍:只看每章开头的“先记住这几句”和图,读完能把上面 5 站说出来就够了。
- 第二遍:看正文和“踩过的坑”,每一站知道用了哪些外设、为什么这样设计。
- 第三遍:练“面试会问”,先自己说一遍,再点开对照。
面试时可以这样开口
先把这段说顺,面试官打断你追问哪一站,就翻到对应那一章。
面试会问
先自己说,再点开温度从传感器到界面,最多会晚多久?
为什么不让板子主动把数发给电脑,而要电脑来问?
共享结构体为什么要加锁?
- 不看图说出 5 站,每站对应哪一章
- 说出出了 5 种事各由谁兜底
- 能把“面试时可以这样开口”那段用自己的话讲一遍
先补基础:看懂后面要的 8 个概念
没学过单片机也没关系。这一章不讲项目,只讲后面每章都会用到的词。看完这章,后面会顺很多。
- 正文里带虚线下划线的词点一下,会弹出一句大白话解释。最后还有一个“术语表”。
- 每章开头有个“先记住这几句”,第一遍只看那几句加图也行,细节第二遍再看。
- 代码看不懂先跳过,看它上下的文字说明。面试主要考你讲不讲得清楚,不考背代码。
1. 单片机:一颗芯片里的小电脑
2. 二进制、十六进制、“第几位”
芯片里只有 0 和 1。8 个 0/1 叫一个字节,能表示 0 到 255。写成二进制太长,所以常用十六进制:逢 16 进 1,用 0–9 和 A–F(A = 10 … F = 15),前面加 0x 表示“这是十六进制”。4 位二进制正好是 1 位十六进制,所以一个字节就是两位十六进制。
0x08 就是只有第 3 位是 1 的数。3. 寄存器:一排有门牌号的开关
GPIOA->ODR;更常用的是 HAL 库函数 HAL_GPIO_WritePin,它内部也就是去写这个寄存器。4. 地址:芯片里所有东西都有门牌号
后面看到 0x0802_0000,就知道是 Flash 里的某个位置;看到 0x2001_FF00,就知道是 RAM 的末尾。中间的下划线只是为了好读。
5. 轮询、中断、DMA:三种等数据的办法
6. 任务和 FreeRTOS:一个厨师炒四个菜
本项目四个任务:sense 每秒量一次温湿度,comm 跟电脑通信,alarm 判断要不要报警、控制 LED,wdg 检查大家是不是都还活着。没事干的时候 CPU 跑一个叫 IDLE 的空闲任务,实测 99% 以上的时间都在 IDLE。
7. Flash 和 RAM 的脾气
| Flash | RAM | |
|---|---|---|
| 像什么 | 硬盘 | 草稿纸 |
| 断电以后 | 还在 | 没了 |
| 放什么 | 程序、要长期保存的参数和记录 | 运行时的变量、栈 |
| 怎么写 | 只能把 1 改成 0;要改回 1,必须把一整块(扇区)擦成全 1,很慢 | 随便写,很快 |
| 寿命 | 每块大约擦 1 万次就可能坏 | 不限 |
后面第 7 章、第 9 章很多设计(追加写、两份轮流写、写完再标记“有效”)都是在跟 Flash 的这几个脾气打交道。
8. 通信的几个基本词
- 协议:双方说话的规矩,比如一句话怎么开头、怎么结尾、出错了怎么办。
- 主机 / 从机(主站 / 从站):主机发起,从机只回答。这里电脑是主机,板子是从机。
- 帧:一次发出的一整串字节,相当于“一句话”。
- 波特率:串口每秒传多少位。115200 大约每秒 11520 个字节。
- 校验码(CRC):附在一句话末尾的“指纹”,接收方算一遍对不上,就知道传错了。
- 上位机:电脑上用来看数据、改设置的软件。板子叫“下位机”。
- 能说出 Flash 和 RAM 的区别
- 知道 0x08 是“第 3 位是 1”
- 能用快递的例子说清轮询、中断、DMA
整体是什么
先有一张全局图,后面每一章都是在放大图上的某一块。
- 板子每秒量一次温湿度,通过 USB 线把数交给电脑上的软件。
- 难点不在量数,在出了问题以后不坏:升级断电不变砖、坏固件自动退回、死机能查到哪一行。
- 单片机里同时跑 4 个小程序(任务),各管一摊。
SensorNode 是一个温湿度采集终端,参照市面上 RS485 温湿度变送器的做法:每秒采一次温湿度,通过 Modbus RTU 把数据交给电脑上的 Qt 上位机。重点不在“能采到数”,而在出了问题以后怎么办:升级到一半断电不变砖,新固件跑不起来自动退回旧版本,死机以后能查到是哪一行代码,参数和日志掉电不丢。
这份手册怎么用
每一章的结构都一样:
- 打个比方:先用生活里的东西把意思讲明白。
- 用到了什么:外设、引脚、寄存器、文件。
- 原理和图:为什么这样设计,配图。
- 真实代码:从项目里摘出来的关键几行。
- 踩过的坑:上板时真遇到的问题。
- 面试会问:点开看参考答案。先自己说一遍,再点开对照。
没学过单片机的话,先看前面的“先补基础”。正文里带虚线下划线的词,点一下就有解释。
每章最后有一个“我能讲清楚了”的勾。标准是:不看任何资料,对着空气把这一章讲 3 分钟,讲得出来再勾。勾选只存在你这台电脑的浏览器里。
推荐的学习顺序
各章已经按“主线”那一章的顺序排好:01–06 是温度数字走过的 5 站,也就是正常时怎么干活;07–10 是出事了怎么办;11 是面试前要记的数字。从上往下读就行。第一遍只看每章开头的“先记住这几句”和图,第二遍看正文和踩过的坑,第三遍练“面试会问”。
时钟和 GPIO
单片机怎么跑到 180 MHz,引脚怎么控制。
- 芯片干活要有节拍,就是时钟。我们把 8 MHz 放大到 180 MHz。
- 引脚可以当输出(点灯),也可以当输入(读按键),靠写寄存器来设置。
- 推挽能主动输出高和低;开漏只能拉低,高电平靠电阻。I²C 必须用开漏。
用到了什么
| 东西 | 设置 | 为什么 |
|---|---|---|
| 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 做调度节拍。 |
开机时我读过三个寄存器,确认配置真的生效:
| 寄存器 | 读到的值 | 说明什么 |
|---|---|---|
RCC_CR | 0x03076B83 | HSE 开了、HSE 就绪、HSE 是 Bypass、PLL 开了、PLL 锁定 |
RCC_CFGR | 0x0000940A | 系统时钟来自 PLL;APB1 ÷4,APB2 ÷2 |
RCC_PLLCFGR | 0x22402D04 | PLL 输入是 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 要先读出来、改一位、再写回去,三步中间要是被中断插进来改了别的引脚,写回去时会把别人的改动盖掉。
USER CODE BEGIN 和 END 之间,重新生成时其他地方会被覆盖。面试会问
先自己说,再点开外设为什么要先开时钟?
推挽和开漏有什么区别?I²C 为什么用开漏?
为什么用 BSRR 而不直接写 ODR?
PLL 的 M、N、P 怎么选?
为什么 HAL 的时基要从 SysTick 换成 TIM6?
跑 180 MHz 要做哪些额外设置?
- 8 MHz 怎么变成 180 MHz,每一步有什么限制
- 推挽和开漏的区别,I²C 为什么用开漏
I²C 和 AHT20
两根线挂多个设备:开漏、上拉、地址、ACK,再加一张自己抓的真实波形。
- I²C 用两根线跟传感器说话:一根打拍子,一根传数据。
- 线平时被电阻拉成高电平,谁要发 0 就把线拉低,所以多个设备能共用。
- 每发一个字节,对方都要回一声“收到”(ACK)。
用到了什么
- I2C1:PB8 SCL、PB9 SDA,标准模式 100 kHz。CubeMX 默认把 I2C1 放在 PB6/PB7,要手动改到 PB8/PB9。
- AHT20 地址 0x38,HAL 要传左移一位的
0x38 << 1。 - 芯片内部上拉没开,通信照样正常,说明 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。
读失败(没应答)时,下个周期先重新初始化再读。线松了再插回去能自己恢复。
真实波形

读数帧解出来是 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 归温度。
面试会问
先自己说,再点开I²C 为什么要上拉电阻?电阻大了小了各有什么问题?
ACK 是怎么回事?没有 ACK 说明什么?
总线卡死(SDA 一直被拉低)怎么恢复?
为什么选 100 kHz 而不是 400 kHz?
这个波形你是怎么抓的?从图上怎么看出起始和 ACK?
- 开漏、上拉、线与,为什么能挂多个设备
- 起始、ACK、停止在波形上什么样
- 能对着那帧数据自己算出温湿度
FreeRTOS 和看门狗
四个任务怎么分工、怎么互相通知、谁卡死了怎么发现。
- FreeRTOS 让一个 CPU 轮流跑几个任务,看起来像同时在跑。
- 看门狗是个倒计时,程序要定时去“喂”它。卡死了没人喂,就自动重启。
- 每个任务定时报平安(心跳),全报了才喂狗,所以哪个任务卡住都能发现。
任务怎么分
| 任务 | 优先级 | 多久跑一次 | 做什么 | 实测栈剩余 |
|---|---|---|---|---|
wdg | High | 500 ms | 检查心跳,全到齐才喂狗 | 872 B |
comm | AboveNormal | 收到数据立刻醒,最长 100 ms | Modbus 从站、在线升级 | 1328 B |
alarm | Normal | 100 ms | 温度超上限报警,降到解除值以下才解除,控制 LED | 772 B |
sense | BelowNormal | 1 s | 读 AHT20、内部温度和 VDDA | 352 B |
排优先级的依据是“晚了会怎样”:看门狗晚了会误复位,又只做几行判断,所以最高;通信晚了主机会超时重发,排第二;采集晚几毫秒没人在意,最低。
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)都置上了。
CPU 占用率
打开 FreeRTOS 的运行时间统计:每次切换任务,内核读一次计时器,把这段时间记到刚才那个任务的账上。计时器用 TIM5(32 位、1 MHz,71 分钟才回绕),不用 DWT 周期计数器,因为它按 180 MHz 数,23.8 秒就回绕了。sense 每 5 秒算一次各任务占比,从 SWO 打出来。
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。
面试会问
先自己说,再点开任务优先级怎么定?看门狗任务为什么最高?
中断里能调哪些 FreeRTOS 函数?为什么中断优先级必须 ≥ 5?
为什么 SysTick 和 PendSV 设成最低优先级?
什么是优先级反转?互斥锁和二值信号量有什么区别?
任务通知、队列、信号量分别什么时候用?
看门狗只在一个地方喂,为什么能发现任意一个任务卡死?
喂狗为什么不能放在定时器中断里?
栈大小怎么定?溢出了怎么发现?
CPU 占用率是怎么测的?
内部温度传感器为什么要用 VREFINT 校正?
- 四个任务的优先级为什么这样排
- 心跳加看门狗怎么发现任意一个任务卡死
- 优先级反转和优先级继承
- CPU 占用率怎么测,第一次为什么测错
串口 DMA 和环形缓冲区
数据怎么进出单片机,CPU 怎么不被串口拖累。
- 串口一位一位地收发数据,115200 大约每秒一万多个字节。
- DMA 是搬运工,替 CPU 把收到的字节搬进一块内存,CPU 不用一个个去取。
- 这块内存首尾相接循环用,叫环形缓冲区。写满绕回开头的那一下出过 bug。
用到了什么
| 东西 | 设置 |
|---|---|
| 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 |
接收:三种中断都要
- 空闲 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 加上的,见下面的坑。
环形缓冲区为什么不用关中断
- 只有一个地方写、一个地方读(单生产者单消费者)。
head只由写的一方改,tail只由读的一方改,各改各的,不冲突。 - 下标一直往上加,不取模,用的时候
& mask换成位置。无符号数溢出绕回以后,head − tail照样等于已用字节数。前提是大小必须是 2 的幂。 - 下标用 C11 的
atomic_uint:写方先拷数据,再用memory_order_release更新head;读方用memory_order_acquire读head。这样对方看到head变了的时候,数据一定已经写好,编译器不能把这两步调换顺序。
发送那边有好几个任务都会打印,写入方不止一个,无锁的前提不成立了,所以 uart_send 里“写缓冲区 + 启动 DMA”加了关中断保护。拷几十个字节大约 1 µs,影响很小。
rx_pos 已经是 0,代码以为又来了 256 个新字节,把整个缓冲区的旧数据又拷了一遍。修法就是上面那句把 256 当 0。修完 500 次全部成功,中间跨过 15 次回绕。rx_rb.head、rx_pos、NDTR 三者对得上,说明板子收到多少处理了多少,数据是在进板子之前丢的。升级到 V2J48M35 解决。面试会问
先自己说,再点开DMA 和中断怎么配合?
空闲中断是什么?为什么还要开半满和全满?
循环模式下,怎么算出新收到了哪些数据?
环形缓冲区在什么条件下可以不关中断?
为什么环形缓冲区的大小要是 2 的幂?
发送 DMA 正在发,又有新数据要发怎么办?
串口溢出错误 ORE 是怎么来的?出了之后怎么恢复?
临界区为什么要保存再恢复 PRIMASK,不直接开中断?
- 中断和 DMA 怎么配合,三种接收中断各管什么
- 环形缓冲区为什么能不加锁,256 等于 0 那个 bug
- 临界区为什么要保存 PRIMASK
Modbus RTU
板子和上位机说话用的协议:一问一答,靠停顿分帧,靠 CRC 防错。
- Modbus 是电脑和板子说话的规矩:电脑问,板子答。
- 每句话末尾带校验码(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。一帧几十个字节,逐位算的耗时可以忽略,所以没用查表。
怎么知道一帧结束了
- 规范:帧之间停顿超过 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 去掉包头显示出来。
面试会问
先自己说,再点开Modbus RTU 帧格式是什么?为什么寄存器是大端,CRC 却是低字节在前?
3.5 个字符的帧间隔是怎么回事?你怎么检测的?
CRC16 怎么算?查表和逐位算各有什么好处?
从站什么时候不回复?主机怎么办?
0x06 和 0x10 有什么区别?怎么保证要么全改要么全不改?
日志为什么和业务通信分开?SWO 是什么?
每 32 次丢一帧的 bug 是怎么定位的?
- Modbus 帧格式、CRC16 怎么算
- 3.5 字符帧间隔和我的检测方法
- 为什么要超时重传,从站什么时候不回复
Qt 上位机
电脑这一侧:界面不卡、数据怎么显示、跨线程怎么传消息。
- 上位机就是电脑上的软件,用 Qt 写,有六个页面。
- 等板子回话的活交给另一个线程,界面才不会卡。
- 两个线程之间靠 Qt 的“信号槽”传消息。
六个页面:实时数据(曲线)、寄存器表(双击可写)、告警、历史(SQLite 查询、导出 CSV)、固件升级、诊断(通信统计、复位原因、死机记录)。
跨线程怎么传消息
ModbusWorker是普通的QObject,用moveToThread挪进QThread,串口在它里面创建。- UI 线程调工作线程的函数,用
QMetaObject::invokeMethod(worker_, lambda):Qt 发现对象在别的线程,就把这次调用打包成事件,放进那个线程的事件队列,由它自己执行。 - 工作线程做完
emit finished(...),连到 UI 线程的槽。跨线程时连接方式自动变成QueuedConnection,参数拷一份传过去。自定义类型要先qRegisterMetaType。 - 退出顺序:先让工作线程把串口关掉(
BlockingQueuedConnection等它做完),再quit()+wait(),工作对象在线程结束时deleteLater。顺序错了退出时会崩。
- 类和对象:类是图纸,对象是照图纸造出来的东西。
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 会用模型里的值刷新正在打开的编辑框。改成每秒只刷新输入寄存器那几行,保持寄存器只在读到新值后才刷新。面试会问
先自己说,再点开为什么串口不能放在 UI 线程?moveToThread 之后东西在哪个线程?
信号槽跨线程时参数怎么传?什么时候要 qRegisterMetaType?
Model/View 有什么好处?dataChanged 什么时候发?
上位机怎么判断一帧收完?超时重发会不会让设备重复执行写操作?
- 为什么串口不能放 UI 线程
- 信号槽跨线程怎么传参数
- Model/View,以及编辑框被重置那个坑
Bootloader 和在线升级
这个项目最能拿出来讲的一章:升级被打断不变砖,新固件有问题自动退回。
- Bootloader 是开机最先跑的一小段程序,决定启动哪一份固件。
- 芯片里放两份固件(A 和 B)。新的只写进空的那份,写到一半断电,旧的还在。
- 新固件先“试用”,启动 3 次都没报平安,就自动退回旧的。
内部 Flash 怎么分
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 里面长什么样
跳转到应用之前要做的几步
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)。
掉电时状态怎么保证不坏
- 镜像: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 报“已回滚”。正在试运行时不允许再升级,否则会把唯一能退回的旧固件擦掉。
救砖模式
上电时按住 B1,或者两个 slot 和出厂镜像都坏了,Bootloader 不跳转,自己当 Modbus 从站(轮询收串口,不用中断和 DMA),支持一样的 0x41 升级命令,固定写 slot A。所以就算应用彻底坏了,只要 Bootloader 在,用同一个上位机就能救回来。
面试会问
先自己说,再点开Bootloader 跳转前要做哪几步?少做一步会怎样?
VTOR 为什么要 512 字节对齐?镜像头为什么占 0x200?
升级时断电,怎么保证设备还能启动?状态写到一半断电呢?
Flash 擦写寿命是多少?你的设计每次升级擦几次?
新固件能启动但有 bug,怎么回滚?确认的条件为什么这么定?
为什么不先把新固件放到外部 Flash 再拷?
断点续传怎么做的?
F4 的硬件 CRC 和 zlib 的 CRC32 有什么不同?
- 跳转前为什么要做那几步
- 掉电时镜像和状态怎么保证不坏
- 试运行、确认、回滚的完整过程
- Flash 擦写寿命,断点续传怎么算
HardFault 定位
程序崩了以后,怎么知道是哪一行。
- HardFault 是程序做了非法操作时 CPU 进入的出错处理。
- 出错那一刻把关键信息(执行到哪个地址了)抄下来存好,然后重启。
- 事后用工具把地址翻译成源码的哪个文件、第几行。
处理函数必须是 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”关掉,不然重新生成会重复定义。
从崩溃到源码行
.bss 也不包括它,所以复位后还在。| 演示 | CFSR | 解出来的原因 | 定位到 |
|---|---|---|---|
| 寄存器 0xF0 写 1:执行一条未定义指令 | 0x00010000 | UNDEFINSTR | fault.c:81,调用方 app_tasks.c:318 |
寄存器 0xF0 写 2:读 0x20030000 | 0x00008200 | PRECISERR + BFARVALID,BFAR = 0x20030000 | fault.c:75 |
PC 是出错的那条指令,LR 一般是调用它的地方。HFSR 的 FORCED 位是 1,说明是 BusFault/UsageFault 没单独打开,被升级成了 HardFault,具体原因看 CFSR。
0xCCCCCCCC,上板发现读它不会出错:这个地址在 FMC(外部存储器控制器)的地址段里,读到 0,不触发总线错误。换成 SRAM 末尾之外的 0x20030000(SRAM 到 0x2001FFFF 为止)才真的出错。面试会问
先自己说,再点开HardFault 进来时栈上有什么?怎么知道是哪个任务出的错?
MSP 和 PSP 有什么区别?
PRECISERR 和 IMPRECISERR 有什么区别?非精确的为什么 PC 不准?
为什么处理函数要是 naked 的?
现场为什么先存 RAM,复位后再存 Flash?
- HardFault 时栈上有什么,MSP 和 PSP 怎么区分
- 从崩溃到源码行号的完整过程
- CFSR 怎么看
SPI 和 W25Q64
四根线的高速总线,外部 Flash 用来存事件日志和死机记录。
- SPI 用四根线,比 I²C 快,这里用来读写外部 Flash 芯片。
- Flash 写之前必须先擦,而且只能整块擦。
- 日志一条接一条往后写,写满了从头覆盖,靠序号找到最新的一条。
一条命令长什么样:读 JEDEC ID
四种模式
| 模式 | 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 清零。
事件日志怎么存、上电怎么找到最后一条
0x10000 + (n % 条数) × 32。用序号定位而不是另存一个“写到哪了”的指针,省掉一块要反复擦写的地方。- 多个任务都会写日志,用一个互斥锁保护 SPI。
- 死机记录在 0x000000 起的 8 KB,每条 256 字节,32 条循环覆盖,用法一样。
- 老实说的不足:日志条目没有 CRC,写到一半断电,那一条序号可能是好的但文字不完整。死机记录有魔数检查。
- 老实说的不足:写日志在调用的任务里同步完成,碰上要擦扇区的那一条(每 128 条一次)会多卡 45–400 ms。在 comm 任务里改参数正好碰上时,Modbus 回复会晚,上位机可能报一次超时(参数其实存进去了)。要彻底解决得挪到单独的低优先级任务排队写,还没做。
面试会问
先自己说,再点开SPI 的四种模式是什么?W25Q64 用哪种?
SPI 和 I²C 各适合什么场景?
为什么片选用普通 GPIO,不用硬件 NSS?
Flash 为什么要先擦后写?页编程跨页会怎样?
日志循环覆盖,上电怎么找到最后一条?写到一半断电会怎样?
- SPI 四根线、全双工、四种模式
- W25Q64 写使能、页编程、擦扇区
- 日志怎么循环写、上电怎么找最后一条,还有哪些不足
排查问题的方法
面试最爱问“你遇到过最难的 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。
你一般怎么定位嵌入式问题?
- 通用的排查顺序
- 至少能完整讲 3 个故事,带数字
数字速查
面试前过一遍。全部来自 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 占用、Modbus 成功率
术语表
正文里带虚线下划线的词,解释都在这里。大致按出现的先后排。