☰
嵌入式C项目实战:温湿度采集终端架构与驱动实现
2026/10/20 14:42:47 网站建设 项目流程

简介:一份基于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的调用方负责保证足够大,通常len+6就行。校验和是数据域累加,不包括帧头和长度。上位机收到一帧后,先验帧头,再读长度,然后收满所有数据,最后重新计算校验和与末尾字节比较。如果校验不对,直接丢弃整帧,不处理数据,防止错乱。

对应的解析函数在收到一组字节时使用小小状态机:

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,确认时序稳定后再继续调业务。这个习惯帮我省过不少无谓的排错时间,希望也能帮到你。

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

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

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

立即咨询