资讯动态

嵌入式C项目实战:温湿度采集终端架构与驱动实现

发布时间:2026/10/21 19:12:44 来源:尧图企业网站定制
简介一份基于STM32F407的嵌入式C语言项目工程包面向嵌入式初学者及具备C语言基础的开发者聚焦内存管理、底层寄存器操作、I/O控制、中断处理、多任务调度与固件升级等关键技能适合课程设计、电子竞赛或自研设备开发参考。压缩包共94个文件、约15.04MB以C源码为核心29个.c、50个.h配合链接脚本、Makefile、编译生成的elf/map文件以及ioc引脚配置、演示mp4、README与学习笔记docx可完整还原工程从配置、编译到烧录调试的链路。已有1677人学习。工程内含气体检测GAS DETECTION应用、STM32F407启动与系统调用适配、内核与驱动分层目录并附有从机通信代码和项目操作视频可快速掌握整体架构与模块划分。读者可对照源码、视频和笔记理解交叉编译工具链、调试接口使用、电源管理及安全可靠性设计等实际落地方法也可直接基于现有工程模板二次开发是一份兼具教学与复用价值的嵌入式资源。1. 这个嵌入式项目是干什么的从C语言源码到一块能用的控制板一提嵌入式项目很多人的第一反应就是C语言写的点灯程序。但真正让一个工程跑起来麻烦往往不在点亮LED而在传感器时序、状态机转换和通信协议这些“看不见”的细节。我这次拆的嵌入式项目就是用纯C写的一个温湿度采集终端主控板读取传感器数据显示到LCD屏支持按键切换界面同时通过串口把数据帧发给上位机。功能不复杂但五脏俱全正好覆盖了嵌入式C开发的完整链路。适合刚啃完单片机基础、想找一个完整工程模板照着跑的同学也适合想看看别人怎么分层写驱动、业务和应用的老手。2. 项目架构与文件分工先把C源码的分层看懂再谈编译和烧录2.1 整体架构驱动层、业务层、应用层各干各的这个项目没有把所有逻辑塞进main.c而是按三层模型拆开。最底层是驱动层负责和具体硬件打交道GPIO初始化和读写、串口中断收发、定时器配置、单总线时序模拟。中间层是业务层它不知道自己面对的是什么型号的芯片只知道调用驱动接口拿回原始数据然后做校验、换算、协议打包。最上层的应用层就是main函数里的初始化和大循环负责把业务层串起来。这个分层带来的好处很直接如果你想换一颗MCU只需要改动驱动层业务层里的校验逻辑、协议格式、界面切换逻辑可以原样保留。如果只是换一个传感器驱动层换一下读取函数业务层的数据换算部分微调就行。我见过很多项目把所有代码写在几个大文件里一个函数几百行最后改一个引脚定义要翻遍全文这个项目的做法显然更适合工程化。2.2 目录结构与文件职责每个C文件都有自己的名字实际工程目录结构如下project/ ├── main.c # 入口初始化各模块进入主循环 ├── system.c/.h # 系统时钟、硬件初始化、看门狗 ├── sensor.c/.h # 温湿度传感器驱动单总线时序 ├── display.c/.h # LCD屏驱动与界面刷新 ├── key.c/.h # 按键驱动带状态机 ├── protocol.c/.h # 串口通信协议打包与解析 ├── storage.c/.h # EEPROM读写保存配置参数 ├── Makefile # 编译脚本 └── link.ld # 链接脚本定义ROM/RAM布局每个文件大致职责如下文件职责关键接口main.c硬件初始化后进入while循环调用各模块update函数main()system.c配置时钟主频、滴答定时器、启动看门狗system_clock_init(), watchdog_feed()sensor.c模拟单总线时序读取温湿度原始值校验并换算sensor_read(), dht_reset()display.c按当前界面状态刷新LCD内容display_update()key.c读取按键电平过滤抖动并识别短按/长按key_process(), key_get_event()protocol.c把传感器数据打包成帧解析上位机下发命令protocol_pack(), protocol_parse()storage.cEEPROM读写配置信息掉电后恢复校准值storage_save(), storage_load()main.c里的主循环一般长这样int main(void) { system_clock_init(); // 系统时钟主频48MHz sensor_init(); display_init(); key_init(); protocol_init(); storage_load(); // 载入上次保存的校准值 while (1) { system_feed_watchdog(); // 喂狗防止程序跑飞 sensor_update(); // 周期性采集传感器 key_process(); // 扫描按键 display_update(); // 刷新LCD protocol_poll(); // 处理串口收发的数据 } }这里主循环没有用操作系统所有任务在一个循环里轮询。传感器采集通过定时器计数20ms一次按键扫描10ms一次避免阻塞式延时。这种裸机轮询方式在小型嵌入式项目里非常常见结构清晰排查问题也简单。2.3 构建系统Makefile里的关键参数工程用Makefile组织编译而不是依赖某个IDE。这样换到Linux或者Windows下的命令行都能编译。核心构造参数如下CROSS_COMPILE arm-none-eabi- MCU cortex-m0 LDSCRIPT linker.ld CFLAGS -Wall -O1 -g -mcpu$(MCU) -mthumb \ -ffunction-sections -fdata-sections LDFLAGS -T $(LDSCRIPT) -Wl,--gc-sections SRCS main.c system.c sensor.c display.c key.c protocol.c storage.c OBJS $(SRCS:.c.o) TARGET project.elf HEX project.hex all: $(HEX) $(TARGET): $(OBJS) $(CROSS_COMPILE)gcc $(LDFLAGS) -o $ $^ $(OBJS): %.o: %.c $(CROSS_COMPILE)gcc $(CFLAGS) -c -o $ $ $(HEX): $(TARGET) $(CROSS_COMPILE)objcopy -O ihex $ $ clean: rm -f $(wildcard *.o) $(TARGET) $(HEX)-ffunction-sections和--gc-sections配合可以把没被调用的函数段裁剪掉减小最终固件体积。-O1在这个项目里很合适优化级别太高某些依赖时序的传感器延时循环可能被编译器乱序执行导致时序出问题-O0固件太大。实际调试时可以先-O0跑通了再改成-O1。烧录一般用明示的下载器比如ST-Link对应的openocd或者厂商提供的命令行工具。只要生成project.hex用下载软件选择芯片型号后即可烧录。烧录之前需要先确认芯片型号比如主控是cortex-m0如果选错型号连接下载器时经常提示找不到设备。3. 核心模块拆解传感器驱动、状态机与串口协议的关键实现3.1 单总线传感器驱动GPIO模拟时序和位读取这个项目里的温湿度传感器是单总线输出。所谓单总线就是一根数据线既用来发命令也用来收数据时序要求很严格。初始化时主机把总线拉低大于18ms然后释放传感器会应答一个低电平信号。之后读取每个数据位时主机产生一个短低电平脉冲表示起始然后采样总线电平。C语言实现的一个节点如下// sensor.c #define DHT_PIN GPIO_PIN_3 #define DHT_PORT GPIOB static uint8_t dht_read_bit(void) { uint8_t result 0; // 起始信号拉低总线18us然后释放 GPIO_ResetBits(DHT_PORT, DHT_PIN); delay_us(18); GPIO_SetBits(DHT_PORT, DHT_PIN); delay_us(10); // 采样点50us后读取电平高电平时数据位为1 if (GPIO_ReadInputDataBit(DHT_PORT, DHT_PIN) SET) { result 1; } // 等待这个位结束 delay_us(50); return result; }dht_read_bit先拉低总线18微秒产生起始脉冲然后释放并等待10微秒此时传感器会开始输出数据位。数据位高电平持续28到70微秒低电平持续约28微秒。代码在释放总线后10微秒采样正好落在高电平窗口内。如果读到高电平这一位就是1低电平则为0。读取一字节时需要循环8次把每一位拼进一个字节变量static uint8_t dht_read_byte(void) { uint8_t data 0; for (int i 0; i 8; i) { data (data 1) | dht_read_bit(); // 每一位之间间隔大约50us delay_us(50); } return data; }read_bit里的延时函数建议用SysTick或定时器读取避免用编译器优化的空循环。我之前就遇到过把delay_us(18)优化成空操作导致传感器完全读不到数据的情况。正确做法是封装一个内嵌汇编或基于定时器的延时。完整读取流程里还有校验位传感器会连续输出40位数据其中最后8位是校验值前32位中的整数位加小数位与最后8位相等才算有效。常见的做法是uint8_t sensor_read(int16_t *temperature, int16_t *humidity) { uint8_t buf[5]; dht_reset(); // 拉低总线启动一次转换 if (dht_wait_response() 0) { return 1; // 无应答返回错误 } for (int i 0; i 5; i) { buf[i] dht_read_byte(); } // 校验前4个字节相加低8位应该等于第5字节 uint8_t sum buf[0] buf[1] buf[2] buf[3]; if (sum ! buf[4]) { return 2; // 校验失败 } *humidity (buf[0] 8) | buf[1]; *temperature (buf[2] 8) | buf[3]; return 0; }这里温度湿度各16位高8位是整数部分低8位是小数部分单位是摄氏度与百分比。拿到原始值后上层再通过参数scale换算成实际小数。这个接口把硬件细节全部封装在函数内部业务层只需要直接调用sensor_read不用关心时序。3.2 按键状态机抖动过滤和长按短按识别按键是嵌入式项目里最常见的交互方式但直接轮询GPIO会有抖动和误判。这个项目里用了一个简单的状态机把按键事件分成短按、长按和释放三类。核心状态// key.c typedef enum { KEY_STATE_IDLE, // 空闲 KEY_STATE_PRESSED, // 检测到按下等待去抖 KEY_STATE_CONFIRMED, // 确认按下等待释放或长按 } key_state_t; static key_state_t key_state; static uint32_t press_tick; uint8_t key_process(void) { uint8_t event KEY_EVENT_NONE; uint8_t level gpio_read_key(); switch (key_state) { case KEY_STATE_IDLE: if (level KEY_PRESSED) { key_state KEY_STATE_PRESSED; press_tick get_tick_ms(); } break; case KEY_STATE_PRESSED: if (level KEY_RELEASED) { // 释放得太早判定为抖动 key_state KEY_STATE_IDLE; } else if ((get_tick_ms() - press_tick) 20) { // 持续按下超过20ms确认按键有效 key_state KEY_STATE_CONFIRMED; event KEY_EVENT_PRESS; // 首次触发短按 } break; case KEY_STATE_CONFIRMED: if (level KEY_RELEASED) { event KEY_EVENT_RELEASE; key_state KEY_STATE_IDLE; } else if ((get_tick_ms() - press_tick) 1000) { event KEY_EVENT_LONG_PRESS; // 长按触发 } break; } return event; }第一次检测到按下时进入PRESSED状态并记录按下时刻。20ms后如果仍然按住说明不是抖动产生一次“短按”事件。如果超过1秒还按住则产生“长按”事件。这种状态机的好处是用户按1.5秒会先收到短按事件再收到长按事件业务层可以分别处理。比如短按切换显示界面长按进入设置模式。参数和业务逻辑是分离的20ms去抖时间1000ms长按判断都是宏定义。在调试时如果发现按键反应迟钝或者误触可以直接调这两个值。但要注意不同按键触点的物理抖动时间差异很大机械按键一般10~30ms薄膜按键可能更久我一般会把去抖时间设成30ms同时保证扫描周期在5~10ms内。这里主循环的扫描频率是10ms一次正好能捕捉到持续20ms以上的稳定电平。3.3 串口协议帧格式、打包和解析串口传数据不是直接扔字符串就完事上位机需要一套格式来说明“这一帧代表什么”。这个项目设计了简单的帧协议字段字节数说明帧头20xAA 0x55长度1数据域长度类型10x01表示传感器数据0x02表示配置回传数据域N具体数据校验1数据域所有字节累加和的低8位打包函数如下uint16_t protocol_pack(uint8_t type, uint8_t *payload, uint8_t len, uint8_t *out) { uint8_t checksum 0; uint16_t idx 0; out[idx] 0xAA; out[idx] 0x55; out[idx] len; out[idx] type; for (int i 0; i len; i) { out[idx] payload[i]; checksum payload[i]; } out[idx] checksum; return idx; // 返回总长度 }这里输出缓冲区out的调用方负责保证足够大通常len6就行。校验和是数据域累加不包括帧头和长度。上位机收到一帧后先验帧头再读长度然后收满所有数据最后重新计算校验和与末尾字节比较。如果校验不对直接丢弃整帧不处理数据防止错乱。对应的解析函数在收到一组字节时使用小小状态机uint16_t protocol_parse(uint8_t byte, protocol_frame_t *frame) { static uint8_t phase 0; static uint8_t data_len 0; static uint8_t data_idx 0; static uint8_t checksum 0; switch (phase) { case 0: // 等待帧头第一个字节 if (byte 0xAA) phase 1; break; case 1: // 等待帧头第二个字节 if (byte 0x55) { phase 2; } else if (byte ! 0xAA) { phase 0; // 回到初始 } break; case 2: // 长度字段 data_len byte; data_idx 0; checksum 0; phase 3; break; case 3: // 类型字段 frame-type byte; phase 4; break; case 4: // 数据域 if (data_idx data_len) { frame-data[data_idx] byte; checksum byte; if (data_idx data_len) { phase 5; // 等待校验 } } break; case 5: if (checksum byte) { frame-len data_len; return 1; // 成功解析一帧 } phase 0; break; } return 0; }这种解析方式不需要把整包缓存下来而是每个字节处理一次特别适合串口中断里直接喂数据。但要注意静态变量phase在数据帧中途出错时会卡住所以必须要能回到初始状态。比如在phase 2时如果收到异常字节应强制重置为phase 0。实际项目中我还会加一个“escape”机制避免数据域里出现0xAA 0x55被误认为帧头。不过这个项目数据量小没有这么做。4. 编译烧录调试避坑五个最容易卡住的C项目问题4.1 编译报错多次包含头文件导致宏定义冲突现象编译时提示某个宏未定义或者重复定义明明头文件里已经写了#define。原因项目里多个C文件都包含了同一个头文件而且头文件没有写#ifndef宏保护。编译器在处理每个源文件时都会把这个头文件展开一遍全局宏和枚举在多个编译单元里重复定义。解决在每个头文件开头加#ifndef SENSOR_H #define SENSOR_H /* 内容 */ #endif这个习惯要强制养成。另外注意#endif后面不要再写注释有些老的编译器会警告。4.2 程序烧录成功但完全没有运行链接脚本里RAM段还是ROM段弄混了现象编译、烧录都成功但板子上电后LED不亮调试器里看到PC指针跳转到非法地址或者停在HardFault。原因链接脚本里把.data段或.bss段放在了只读区间或者把中断向量表放在错误地址。很多MCU在启动时要从Flash搬运初始值到RAM如果链接脚本把RAM的起始地址搞错启动代码会把数据写到不能写的位置。解决检查链接脚本尤其关注两个符号__data_start、__bss_start。必要时用调试器查看_start函数执行情况。这个项目里link.ld的MEMORY区定义了我直接让RAM段起始地址和芯片手册里的SRAM基地址一致编译后生成的.map文件里可以确认各段地址。4.3 传感器读数固定为0xFF或0x00单总线时序上的延时被编译器优化了现象传感器无论怎么放读出来的温湿度都是固定值或者寄存器里全是0xFF。原因单总线时序里需要的微秒级延时是用空循环实现的。编译器在-O2以上优化级别可能直接把空循环判定为无意义代码删除导致时序错误。解决把传感器驱动文件的优化级别单独降为-O0或者用基于SysTick的延时函数。我一般这样写void delay_us(uint32_t us) { uint32_t start get_tick_us(); while ((get_tick_us() - start) us) ; }并把sensor.c的编译优化强制为-O0。在Makefile里单独加一条规则sensor.o: sensor.c $(CROSS_COMPILE)gcc $(CFLAGS) -O0 -c -o $ $4.4 串口输出乱码晶振频率和波特率配置不一致现象上位机收到的数据全是乱码偶尔能认出几个ASCII字符。原因UART波特率由系统时钟计算得到如果开发板实际晶振频率和代码里的SystemCoreClock不一致波特率寄存器会算出错误的分频值。比如项目使用外部晶振8M但system.c里默认12M启动时HSE倍频系数算错导致串口实际波特率变成9600.2而不是9600误差超过3%后就会出错。解决确认芯片外部晶振实际频率后修改system.c里的宏定义SYSCLK_FREQ必须与硬件一致。调试时也可以把TX只是输出一个固定字符循环连上位机看是否收到稳定字符。4.5 EEPROM写参数时断电导致整块数据丢失现象用上位机设置了一次校准值并保存到EEPROM重新上电后读取的配置数据是全0xff或者边写边断电时原来的值也被清掉。原因EEPROM写入需要先擦除后写而且写周期内的电压跌落会中断写入导致数据内容错误。如果项目里没有做掉电检测只简单地先调save再调restart就会遇到“越断电越坏”的情况。解决固件设计上先写入一个“魔法数”到最后一个固定地址写完后立刻回读校验。当启动时看到魔法数不对就认为配置损坏使用默认值。同时把EEPROM写操作放在关键数据备份区写新数据前先把旧数据备份到另一个块避免原地覆盖。这个项目里storage.c的save接口参考了这种用法每次先备份旧值再擦写。5. 二次开发改造如何换成自己的传感器并继续使用这套框架这套代码最值得抄的地方是分层思路。换传感器时不需要重新整理业务逻辑先写好新的my_sensor_read把数据换算成int16_t的温度和湿度再在main.c里绕过旧驱动调用新函数就行了。具体改法分三步。第一步是把sensor.c里的单总线驱动替换成新传感器对应的驱动。比如换成I2C接口的光照传感器驱动层要做的是初始化I2C外设、实现寄存器写入和读取。对外接口仍然保持uint8_t sensor_read(int16_t *value1, int16_t *value2)第二步是调整业务层的换算公式。原先温度湿度各占16位高8位整数低8位小数。新传感器如果直接返回一个32位整数就需要在sensor_read内部先换算成两个int16_t再返回。这样上层处理“单位换算”的地方完全不用动。第三步是验证。我的习惯是先在串口调试助手连续观察100帧数据看每帧的校验和是否正确。再热风枪吹一下传感器确认数据变化能稳定跨过阈值。如果数据偶尔跳变先查驱动层的延时是否稳定再查电源滤波电容。如果要改低功耗场景主循环的轮询模式就不合适了。可以引入RTC唤醒定时器让MCU大部分时间进入睡眠每5秒醒来一次采集数据。做法是把sensor_update和protocol_poll从while循环里摘出来改为定时器回调触发。睡眠前要保存关键状态唤醒后重新初始化显示屏和串口。这个项目原本的存储接口已经支持掉电保存改造时正好利用它保存唤醒计数和校准参数。我刚拿到这套代码时急着改功能结果先在时序延时上浪费了一天。后来养成了一个习惯每次改动驱动层前先确认代码里所有“微秒延时”都是真实的不是被优化掉的空循环。从那以后我每次嵌入式项目拿到手都会先搜一遍delay函数再把编译优化级别从-O2降到-O1确认时序稳定后再继续调业务。这个习惯帮我省过不少无谓的排错时间希望也能帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑