外观
铂电阻测温固件 — 代码阅读文档
代码开源链接
本文档面向想读懂代码的开发者:按执行顺序逐函数讲解,标注关键常量、算法推导与阅读陷阱。 文件名以加粗表示(如 main.c),不带跳转链接。 配套文档:readme.md(硬件/网表)、code_readme.md(架构总览 + 构建/烧录流程)。
0. 阅读前必读:代码与旧文档的差异
code_readme.md 描述的是“LVGL 图形 + 触摸切换通道 + QSPI 字库”的架构,但当前 main.c 并未走该路径。实际运行的是一套无 LVGL、无触摸、无 QSPI 字库的精简固件:
| 模块 | 旧文档说法 | 代码实际状态 |
|---|---|---|
| UI 渲染 | LVGL v8.3 + flush_cb | 未启用。直接用 lcd_draw.c 的 8×16 点阵字体绘制 |
| 触摸切通道 | FT6336 触摸事件 → 通道循环 | 未启用。main.c 不调用 ft6336_get_touch() |
| QSPI 字库 | W25Q64 内存映射挂载字库 | 未启用。main.c 不调用 w25q64_mmap() |
| 串口日志 | HAL_UART_Transmit_IT | 实际用 HAL_UART_Transmit_DMA(见 debug_log.c 第 75 行) |
结论:lvgl_port.c/.h、ft6336.c/.h、w25q64.c/.h 这三个模块已实现但处于“休眠”状态,阅读时先跳过,第 7 节再单独说明。真正要读懂的是 main.c + pt100.c + ads1115.c + ch448.c + st7789.c + lcd_draw.c + app_ui.c + debug_log.c 这条主线。
1. 源码总览(一句话职责)
Core/(CubeMX 生成 + 用户代码)
- Core/Src/main.c — 唯一入口。初始化外设、执行开机多点标定、500ms 周期测温主循环。
- Core/Src/stm32g4xx_it.c — 中断向量。当前未挂任何用户回调(无 SysTick→LVGL tick)。
- Core/Src/stm32g4xx_hal_msp.c — 各外设的低层初始化(GPIO/时钟/DMA,由 HAL 调用)。
- Core/Src/gpio.c / i2c.c / spi.c / quadspi.c / usart.c / dma.c — CubeMX 生成的外设初始化。
- Core/Inc/main.h — 引脚别名宏(
SEL0_Pin、LCD_CS_LOW()等),BSP 大量依赖。 - Core/Inc/lv_conf.h — LVGL 配置(当前未实际参与运行)。
BSP/(板级驱动)
- BSP/ads1115.c / .h — 软件 I²C 驱动 16bit ADC(PA4=SCL, PA5=SDA)。
- BSP/ch448.c / .h — CH448F 双 8:1 模拟开关,切换外部 PT100 与 7 个校准电阻。
- BSP/pt100.c / .h — 测量核心:电压→电阻、电阻→温度、滑动均值滤波、多点标定拟合。
- BSP/st7789.c / .h — SPI LCD 底层(240×320,ST7789V2),SPI1 8bit Mode3。
- BSP/lcd_draw.c / .h — 基于 font8x16.h 的文本/矩形绘制(当前实际 UI 后端)。
- BSP/font8x16.h — 8×16 ASCII 字模点阵表。
- BSP/debug_log.c / .h — 非阻塞串口日志(环形缓冲 + DMA 发送,可宏屏蔽)。
- BSP/ft6336.c / .h — FT6336 触摸(HAL I²C)。休眠。
- BSP/w25q64.c / .h — W25Q64 QSPI 内存映射。休眠。
App/(应用层)
- App/app_ui.c / .h — 当前实际 UI:标定进度条、电阻/温度显示、通道标签。
- App/lvgl_port.c / .h — LVGL 移植层。休眠。
2. 系统时钟与上电
时钟配置在 main.c 第 273–312 行 SystemClock_Config():
HSE 8MHz → PLLM=/2 (4MHz) → PLLN=×85 (340MHz) → PLLR=/2 → SYSCLK = 170MHz
AHB/APB1/APB2 均不分频 → HCLK=PCLK1=PCLK2=170MHz
Flash latency = 4 wait state1
2
3
2
3
理解这一点很重要:软件 I²C 的 i2c_delay()(ads1115.c 第 31 行)按 170MHz 粗略延时约 1µs,SPI 波特率(st7789.c 第 24 行 SPI_BAUDRATEPRESCALER_16 = 170/16 ≈ 10.6MHz)、QSPI 预分频(w25q64.c 第 22 行 = 15,→ 10.6MHz)也都基于 170MHz 计算。
3. 主程序执行流(main.c 全流程)
main()(第 106 行)顺序如下:
3.1 初始化阶段(第 116–154 行)
HAL_Init() // 关中断、配 SysTick
SystemClock_Config() // → 170MHz
MX_GPIO_Init() / MX_DMA_Init() // CubeMX 外设
MX_I2C1_Init() // I2C1(供 FT6336 用,当前闲置)
MX_QUADSPI1_Init() // QSPI(供 W25Q64 用,当前闲置)
MX_SPI1_Init() // SPI1(LCD;st7789_init 内还会重配)
MX_USART1_UART_Init() // USART1(日志)
log_init(&huart1) // 日志绑定到 USART1
st7789_init() // LCD 初始化(内部重配 SPI1)
ads1115_init() // 软件 I²C 引脚(PA4/PA5 开漏)
ch448_init() // 模拟开关默认关、选 CH0
ch448_enable_x(); ch448_enable_y() // 使能两路开关(XEN#/YEN# 拉低)
app_ui_init() // 画初始界面1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
注意 SPI1 被初始化两次:CubeMX 的
MX_SPI1_Init()之后,st7789.cspi1_reinit_8bit()又 DeInit+Reset+重配一次,把数据宽度锁成 8bit、Mode3、并把 PB3/PB5 的 GPIO 速度提到 VERY_HIGH。读 SPI 相关代码时以 st7789.c 的版本为准。
3.2 开机多点标定(第 156–180 行)
这是整段代码的精髓。上电时不是直接测温,而是先用板上 7 个精密校准电阻建立“电压↔电阻的映射:
cal_init(&g_cal) // 清零、给 slope/intercept 一个种子
循环 CAL_ROUNDS(=3) 轮 × 通道 CH1..CH7:
app_ui_cal_progress(step, total, ch, R_nom) // 屏幕进度条
v = cal_read_channel(ch) // 切到该通道并采样 AIN0 平均电压
cal_update(&g_cal, ch, v) // 累入最小二乘拟合
日志打印本轮结果1
2
3
4
5
6
2
3
4
5
6
cal_read_channel(ch)(第 79 行)做了三件事,这是理解后续测量质量的关键:
ch448_select(ch)切换模拟开关,HAL_Delay(300)等待模拟通路稳定(CAL_SETTLE_MS);- 丢弃前 4 次读数(
CAL_DISCARD)——刚切换时 ADS1115/INA333 输出尚未稳定; - 连续读
ADC_FILTER_N(=8) 次 AIN0,转电压后取平均返回。
3.3 进入测温模式(第 182–198 行)
app_ui_init() // 重画界面(清掉标定进度条)
app_ui_set_channel(CH448_EXT_PT100) // 屏幕显示 "CH0: PT100"
ch448_select(CH448_EXT_PT100) // 实际切到外部 PT100 通道
再丢弃 4 次读数(切到 PT100 同样需要稳定)1
2
3
4
2
3
4
3.4 主循环(第 203–265 行)
非阻塞节拍:用 HAL_GetTick() 判断是否到 MEAS_INTERVAL_MS(=500ms),到点才采样,其余时间只调 log_flush()。
每 500ms:
读 AIN0/AIN1/AIN2/AIN3(4 次单端转换,PGA=±4.096V, 128SPS)
v_ref = AIN1 → 实测 +1.25V 基准(关键:不是写死 1.25V)
v_out = AIN0 → INA333 输出
v_i1 = AIN2 → I1_VOL 诊断
v_i2 = AIN3 → I2_VOL 诊断
v_ain0_f = adc_filter_update(v_out) // 8 点滑动均值
r_raw = pt100_calc_r(v_ain0_f, v_ref) // 电路公式(备用/日志)
if (g_cal.valid == 0x7F): // 7 个点全标完
r_cal = cal_apply(&g_cal, v_ain0_f) // 用电压反推电阻(权威)
r_filtered = EMA(r_filtered, r_cal, α=0.15) // 指数平滑
g_resistance = r_filtered
else:
g_resistance = r_raw // 无标定 → 裸公式
g_temperature = pt100_r_to_t(g_resistance)
app_ui_update(g_resistance, g_temperature)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
陷阱 1(注释与代码不符):第 245 行注释写 “Blend: use calibration as primary, formula for trend”,但代码里
r_raw(公式值)并没有被混合进r_filtered,只用于日志的(raw=...)。实际输出完全来自标定值r_cal的指数平滑。读这段时以代码为准。陷阱 2:
cal_apply()的入参是电压v_ain0_f,不是r_raw。因为标定时拟合的是 “R → V”,反向求 R 必须喂电压。见第 4.4 节。
4. 测量主算法(pt100.c / ads1115.c)
4.1 数据通路总览
ADS1115 AIN0(单端) ─raw(16bit)─→ ads1115_to_voltage ─→ v_out(V)
│
┌───────────────────────────────┤
▼ (8点滑动均值) ▼
adc_filter_update pt100_calc_r(v_out, v_ref) → r_raw
│ v_ain0_f (电路公式,备用)
▼
cal_apply(v_ain0_f) → r_cal (标定反推,权威)
│ 指数平滑 α=0.15
▼
g_resistance ──→ pt100_r_to_t ──→ g_temperature(°C)1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
4.2 ADC 采样(ads1115.c)
- 总线:软件 I²C,引脚 PA4(SCL)/PA5(SDA),开漏 + 上拉(见第 4–29 行)。地址
0x48(ADDR 接地)。 ads1115_read_single(mux, pga, dr)(第 99 行):单次转换模式。- 组装配置寄存器(第 112–117 行):bit15 OS=1(启动转换)、bit14-12 MUX、bit11-9 PGA、bit8 单次模式、bit7-5 数据率、bit1-0=11 关比较器。
- 写配置 →
HAL_Delay(10)等转换(128SPS 最坏约 8ms,留余量)→ 指针指向转换寄存器 → 读 2 字节拼成int16_t。
ads1115_to_voltage(raw, pga)(第 146 行):按 PGA 查 LSB 表(第 148–155 行)。本项目固定用ADS_PGA_4096(±4.096V,LSB=125µV)。
4.3 电压→电阻:电路公式(pt100_calc_r,pt100.c 第 37 行)
c
I = v_ref / CIRCUIT_R_LOAD; // 恒流源电流
R = (v_out - v_ref) / (I * CIRCUIT_GAIN) - CIRCUIT_R_SERIES; // 反推电阻1
2
2
物理含义(详见 readme.md 模拟前端):
- INA333 输出以
v_ref(+1.25V)为中心上下摆动,故信号分量V_sig = v_out - v_ref; V_sig = I × G × (R_pt + R10),其中 R10(100Ω) 是 V_IN+ 采样点上游的串联电阻,造成固定偏置,必须扣除;- 解出
R = V_sig/(I·G) − R10。
涉及的常量定义在 pt100.h 第 42–45 行,用的是“实焊值”:
| 宏 | 值 | 含义 |
|---|---|---|
CIRCUIT_VREF | 1.25 | REF3012 名义基准(仅文档/标定种子用,不参与实时计算) |
CIRCUIT_R_LOAD | 2490.0Ω | 恒流源采样电阻 R6=R7(网表 1.35k,实焊 2.49k) |
CIRCUIT_GAIN | 25.876 | INA333 增益 1+100k/4020(R9 实焊 4.02k) |
CIRCUIT_R_SERIES | 100.0Ω | R10 串联偏置 |
为什么要“实测 v_ref”而不是写死 1.25V? 因为这样让转换变成比率式(ratiometric):分子
(v_out−v_ref)与分母(v_ref/R_load)都含v_ref,ADS1115 自身的增益误差被约掉,无需单独标 ADC 增益。pt100.c 第 109–114 行注释明确说明了这一点(单点增益修正已被移除)。
4.4 多点标定(cal_init / cal_update / cal_apply)
标定上下文 cal_ctx_t(pt100.h 第 76–81 行):
c
float v_cal[CAL_NUM]; // 各通道实测电压
uint8_t valid; // bit0..bit6 对应 CH1..CH7 是否已采
float slope; // 最小二乘斜率 (V/Ω)
float intercept; // 截距 (V)1
2
3
4
2
3
4
cal_init(第 66 行):清零,给slope=0.002(约 1.93mV/Ω,对应 5mV/℃ 灵敏度)、intercept=CIRCUIT_VREF作种子。cal_update(ctx, ch, v)(第 73 行):- 存入
v_cal[ch-1],置valid对应 bit; - 少于 2 个有效点直接返回(无法拟合);
- 否则用标准最小二乘公式(第 82–100 行)求
slope/intercept,拟合的是 R(自变量) → V(因变量)。
- 存入
cal_apply(ctx, v_out)(第 103 行):(v_out − intercept) / slope—— 把拟合关系反过来用:已知电压求电阻。
这就是为什么第 3.4 节强调:主循环里喂给
cal_apply的是电压v_ain0_f,不是公式算出的r_raw。标定通道的 R→T 换算与激励电流无关(纯电阻),所以网表/实焊差异不影响标定点本身的温度,详见 readme.md 校准电阻通道表。
4.5 电阻→温度:IEC 60751(pt100_r_to_t,第 17 行)
Callendar–Van Dusen 方程,常量在 pt100.h 第 33–35 行: R0=100Ω, A=3.9083e-3, B=-5.775e-7。
- T ≥ 0°C(正温区,方程是关于 T 的二次):直接用求根公式(第 19–21 行),判别式小于 0 则夹到 0。
- T < 0°C(负温区,方程含 T² 且 B<0,求根公式不再适用):改用牛顿迭代(第 25–32 行),最多 20 次,
|Δt|<0.0001即收敛。
工程含义:本表的 7 个标定点温度区间是 −49.28°C ~ +61.98°C(见 ch448.h 枚举注释),跨越正负温区,所以必须保留牛顿迭代分支。
4.6 滤波
两处独立滤波,别混淆:
| 滤波器 | 位置 | 类型 | 作用 |
|---|---|---|---|
adc_filter_update | pt100.c 第 53 行 | 8 点滑动均值(对 AIN0 电压) | 抑制 ADC 单次噪声 |
EMA r_filtered | main.c 第 249 行 | 指数滑动 α=0.15(对标定电阻值) | 让屏幕数字平滑、不跳变 |
adc_filter_update 用一个静态环形缓冲 s_filter_buf[8],启动初期 s_filter_cnt 从 0 增长,分母是已填入个数,避免冷启动均值偏低。
5. 板级驱动逐模块
5.1 CH448 模拟开关(ch448.c)
- 通道枚举见 ch448.h 第 6–15 行:
CH448_EXT_PT100=0(外部 PT100)、1..7对应 R16/R17/R26/R22/R18/R19/R23。 ch448_select(ch)(第 23 行):把ch的 bit0/bit1/bit2 分别写到 SEL0/SEL1/SEL2 三个 GPIO,延时 1ms 稳定。- 使能脚
XEN#/YEN#低有效:ch448_enable_x/y()拉低,ch448_disable()拉高。 CH448_CAL_R[8](第 5 行)与 pt100.c 的CAL_R_TABLE[7]内容一致(前者含 CH0=0 占位),是两份并行维护的表,改值要同步。
5.2 ST7789 LCD(st7789.c)
st7789_init()(第 73 行):先spi1_reinit_8bit()重配 SPI(见第 2 节),再做 RST → 3 次 SLPOUT(0x11) → 一长串寄存器初始化(MADCTL/COLMOD/PORCTRL/GCTRL/VCOMS/Gamma…)→ 反显开(0x21)/显示开(0x29) → 填白 → 开背光。- 背光是低有效:
LCD_BLK_ON()= PB12 拉低(st7789.h 第 14 行)。
- 背光是低有效:
st7789_set_window()(第 146 行):依次发 0x2A(列地址)/0x2B(行地址)/0x2C(写 RAM)。st7789_flush()(第 158 行):设窗口后按 65534 字节分块HAL_SPI_Transmit(uint16 长度上限)。st7789_fill_color()(第 177 行):预填 64 字节(32 像素)色块,循环喷刷整块区域,比逐像素快得多。- 引脚别名宏全在 st7789.h 第 8–15 行(CS=PA1, DC=PB13, RST=PB2, BLK=PB12),实际对应 main.h 的定义。
5.3 文本绘制(lcd_draw.c + font8x16.h)
当前 UI 的真正后端,与 LVGL 无关:
lcd_clear/lcd_fill_rect:封装st7789_set_window+st7789_fill_color,fill_rect会做越界裁剪。lcd_draw_char(第 20 行):取font_8x16[ch-0x20]的 16 字节字模,每 bit 展开成 fg/bg 像素,整字符一次性st7789_flush。非可见 ASCII(0x20–0x7E) 当空格。lcd_draw_string:横向逐字符画,超屏宽即停。lcd_draw_string_large(第 45 行):把 8×16 字模 2× 最近邻放大成 16×32,buf[16*32]用static避免栈溢出。
5.4 非阻塞日志(debug_log.c)
设计目标:绝不让串口卡住主循环。
- 环形缓冲
s_buf[1024],s_head/s_tail/s_tx_busy均volatile。 log_out(fmt,...)(第 32 行):vsnprintf进 128 字节临时栈缓冲;若环形缓冲剩余空间不足则整条丢弃(不阻塞、不截断)。log_flush()(第 54 行):主循环每轮调用。若上一帧 DMA 还在发,先查HAL_UART_GetState,发完才清s_tx_busy;然后从环形缓冲拷最多 256 字节到静态tx_chunk,HAL_UART_Transmit_DMA发出。- 宏开关
DEBUG_LOG_ENABLE(debug_log.h 第 8 行):设 0 时log_init/log_flush/log_printf全部宏展开为((void)0),编译期零代码零 RAM。
6. 应用 / UI 层(app_ui.c)
纯过程式绘制,无控件树、无事件循环(因为没有 LVGL)。
- 布局常量(第 8–18 行):标题/分隔线/标定区/电阻/温度/通道信息各行的 Y 坐标。
- 配色(第 21–30 行):RGB565,标题青、电阻黄、温度按区间变色(<0°C 青、>50°C 红、其余绿)。
app_ui_init()(第 62 行):清屏、画标题、分隔线、占位文本(---.- Ohm、---.- C)。app_ui_cal_progress(step,total,ch,r_nom)(第 82 行):画进度条 +CH%d %5.1fOhm %3d%%文本,被main.c标定循环调用。app_ui_update(R,T)(第 114 行):刷新电阻值(大字号)与温度值(大字号 + 区间配色)。注释写“only redraw if changed”但实际每次都重画(无变化检测),靠直接覆写避免闪烁。app_ui_set_channel(ch)(第 107 行):在底部 INFO 行画通道名(ch_names[],第 96 行),CH0 用青色、校准通道用橙色。
7. 已实现但未接入主流程的模块(休眠代码)
这三个模块功能完整、可独立调用,但 main.c 没引用,属于“预留/历史路径”。改动主流程前请知悉它们存在:
7.1 lvgl_port.c / .h
lvgl_port_init():lv_init()+ 注册显示驱动,flush_cb把 LVGL 像素区交给st7789_flush。单缓冲buf1[240*32](1/10 屏,15KB)。lvgl_port_tick():lv_tick_inc(1),需要从stm32g4xx_it.c的 SysTick_Handler 每 1ms 调用——当前未挂。- 若要启用 LVGL:在
main.c替换app_ui_*调用、加lv_timer_handler()进主循环、并在中断里挂 tick。
7.2 ft6336.c / .h
- HAL I²C,地址
0x38。ft6336_get_touch()读寄存器 0x02 起 5 字节,解析触点数与坐标。 - 旧文档说“触摸切通道”,但当前
main.c无任何调用,通道完全由标定流程和固定的 CH0 决定。
7.3 w25q64.c / .h
w25q64_mmap():重配 QSPI(FlashSize=22→8MB、预分频 15→10.6MHz)、使能 QE 位(写 SR2)、进入内存映射(Quad Output Fast Read 0x6B,1-1-4,8 dummy)。- 映射基址
0x90000000(w25q64.h 第 8 行)。链接脚本 STM32G474XX_FLASH.ld 里有.qspi_font段指向该地址,供 LVGL 字库用。 - 当前
main.c既不w25q64_mmap()也不读0x90000000,故 QSPI 仅完成 CubeMX 的MX_QUADSPI1_Init()后闲置。
8. 关键常量与数据结构速查
| 名称 | 定义位置 | 值/类型 | 说明 |
|---|---|---|---|
MEAS_INTERVAL_MS | main.c 第 45 行 | 500 | 测温周期 |
CAL_ROUNDS | main.c 第 46 行 | 3 | 标定轮数 |
CAL_SETTLE_MS | main.c 第 47 行 | 300 | 切通道后稳定时间 |
CAL_DISCARD | main.c 第 48 行 | 4 | 切换后丢弃读数 |
ADC_FILTER_N | pt100.h 第 54 行 | 8 | 滑动均值深度 |
CAL_NUM | pt100.h 第 48 行 | 7 | 校准通道数 (CH1..7) |
CAL_R_TABLE | pt100.c 第 6 行 | 7×float | 校准电阻名义值 |
PT100_R0/A/B | pt100.h 第 33–35 行 | 100/3.9083e-3/-5.775e-7 | IEC 60751 系数 |
CIRCUIT_R_LOAD/GAIN/R_SERIES | pt100.h 第 43–45 行 | 2490/25.876/100 | 电路实焊参数 |
cal_ctx_t | pt100.h 第 76 行 | struct | 标定拟合上下文 |
LCD_W / LCD_H | st7789.h 第 4–5 行 | 240 / 320 | 屏幕分辨率 |
ADS1115_ADDR | ads1115.h 第 6 行 | 0x48 | ADC I²C 地址 |
LOG_BUF_SIZE | debug_log.c 第 9 行 | 1024 | 日志环形缓冲 |
DEBUG_LOG_ENABLE | debug_log.h 第 8 行 | 1 | 日志总开关 |
9. 阅读路线建议
第一次通读,按依赖关系自底向上最快建立心智模型:
- ch448.c → 理解“7 个校准电阻 + 1 个外部 PT100”是怎么切换的;
- ads1115.c → 理解 AIN0/1/2/3 四路单端采样怎么来;
- pt100.c → 这是核心,按 4.3→4.4→4.5 顺序读三遍(电压→电阻→温度 + 标定);
- main.c → 把 1–3 串起来,看标定循环和主循环;
- st7789.c → lcd_draw.c → app_ui.c → UI 显示链;
- debug_log.c → 日志机制(调试时用得上);
- 最后再看第 7 节三个休眠模块,判断是否需要复活。
想验证理解?把
DEBUG_LOG_ENABLE保持为 1,上电串口(USART1, 115200 8N1)会依次打印:SYS: ...初始化、CAL R1..R3: CH1..7 ...标定、CAL: done slope=... int=... pts=7、然后每 500ms 一组V: OUT=... REF=...与PT100: R=... T=... (raw=...)。对照 main.c 的log_printf位置即可把日志与代码一一对应。