嵌入式项目开发第一件事,先把目录结构定好——这一步走对了,后续移植和多人协作会省下成吨的时间。
目录结构
以一个 STM32F4 + FreeRTOS + LCD 的项目为例,完整的目录长这样:
project/
├── app/ # 应用层:业务逻辑
│ ├── task_sensor.c # 传感器采集任务
│ ├── task_sensor.h
│ ├── task_comm.c # 通信任务
│ ├── task_comm.h
│ ├── task_display.c # 显示任务
│ ├── task_display.h
│ └── app_init.c # 应用初始化(创建任务、消息队列等)
│
├── bsp/ # 板级支持包:与具体硬件绑定
│ ├── stm32f407/ # 按芯片型号分目录
│ │ ├── bsp_uart.c
│ │ ├── bsp_uart.h
│ │ ├── bsp_spi.c
│ │ ├── bsp_spi.h
│ │ ├── bsp_gpio.c
│ │ ├── bsp_gpio.h
│ │ └── bsp_conf.h # 板级配置(时钟、引脚映射)
│ └── gd32f450/ # 移植时加新目录即可
│ ├── bsp_uart.c
│ ├── bsp_uart.h
│ └── ...
│
├── driver/ # 外设驱动:与芯片无关,与器件绑定
│ ├── w25q256/ # SPI Flash
│ │ ├── w25q256.c
│ │ └── w25q256.h
│ ├── ssd1306/ # OLED显示屏
│ │ ├── ssd1306.c
│ │ └── ssd1306.h
│ ├── bme280/ # 温湿度传感器
│ │ ├── bme280.c
│ │ └── bme280.h
│ └── mcu_flash/ # MCU内部Flash操作
│ ├── mcu_flash.c
│ └── mcu_flash.h
│
├── middleware/ # 中间件:通用功能模块
│ ├── shell/ # 串口命令行
│ │ ├── shell.c
│ │ └── shell.h
│ ├── log/ # 日志系统
│ │ ├── log.c
│ │ └── log.h
│ ├── param/ # 参数管理(KV存储)
│ │ ├── param.c
│ │ └── param.h
│ └── protocol/ # 通信协议
│ ├── protocol.c
│ └── protocol.h
│
├── os/ # RTOS适配层
│ ├── os_wrapper.c # 统一封装RTOS API
│ └── os_wrapper.h
│
├── config/ # 全局配置
│ ├── project_config.h # 项目级开关和参数
│ └── FreeRTOSConfig.h # RTOS配置
│
├── utils/ # 工具函数
│ ├── crc16.c
│ ├── crc16.h
│ ├── ringbuf.c
│ └── ringbuf.h
│
├── startup/ # 启动相关
│ ├── main.c
│ └── stm32f4xx_it.c # 中断服务(仅放钩子,不放逻辑)
│
└── doc/ # 项目文档
└── porting_guide.md # 移植说明
分层原则
目录结构的核心是依赖方向单向流动:
app → middleware → driver → bsp
↘ os ↗
- app 只依赖 middleware 和 driver 的接口
- middleware 只依赖 driver 的接口
- driver 只依赖 bsp 的接口
- bsp 不依赖任何人,它是最底层
违反这个方向就是耦合。比如 driver 里的 w25q256.c 直接调了 USART2->DR,那这个驱动就绑死在 STM32 上了,换芯片得改驱动代码。谁也不想每换一颗 MCU 就重写一遍外设驱动,对吧?
bsp 和 driver 的区别
这两个概念最容易搞混,一句话区分:bsp 跟 MCU 绑定,driver 跟外设芯片绑定。
|
bsp |
driver |
| 绑定对象 |
具体 MCU |
具体外设芯片 |
| 移植时改不改 |
换 MCU 就要重写 |
换 MCU 不用改 |
| 典型内容 |
时钟配置、引脚初始化、UART 收发 |
W25Q256 读写、BME280 数据解析 |
| 举例 |
bsp_uart_init() 配 PA9/PA10 |
w25q256_read_id() 发 0x9F 读 ID |
bsp 提供硬件能力,driver 使用 bsp 提供的接口操作外设。换芯片只改 bsp 目录,driver 和 app 层代码一行不动。
接口隔离
driver 调 bsp 的函数,不能直接 #include "bsp_uart.h"。中间要加一层抽象接口。
以 SPI Flash 驱动为例,w25q256.h 需要 SPI 收发能力,但它不应该知道底层是 STM32 的 SPI 还是 GPIO 模拟的 SPI。
w25q256.h — 定义接口类型:
#ifndef W25Q256_H
#define W25Q256_H
#include <stdint.h>
#include <stddef.h>
/* SPI接口抽象:驱动只认这三个函数指针 */
typedef struct {
void (*cs_low)(void);
void (*cs_high)(void);
uint8_t (*transfer)(uint8_t tx_byte);
} w25q256_spi_t;
int w25q256_init(const w25q256_spi_t *spi);
int w25q256_read_id(uint8_t id[3]);
int w25q256_read(uint32_t addr, uint8_t *buf, size_t len);
int w25q256_write(uint32_t addr, const uint8_t *buf, size_t len);
int w25q256_erase_sector(uint32_t addr);
#endif
bsp 层注册接口 — 在 app_init.c 或单独的 board.c 中:
#include "w25q256.h"
#include "bsp_spi.h"
/* bsp提供具体实现 */
static void spi1_cs_low(void)
{
GPIOA->BSRR = GPIO_BSRR_BR4; /* PA4低电平 */
}
static void spi1_cs_high(void)
{
GPIOA->BSRR = GPIO_BSRR_BS4; /* PA4高电平 */
}
static uint8_t spi1_transfer(uint8_t tx)
{
SPI1->DR = tx;
while (!(SPI1->SR & SPI_SR_RXNE));
return SPI1->DR;
}
/* 注册到驱动 */
static const w25q256_spi_t spi1_for_flash = {
.cs_low = spi1_cs_low,
.cs_high = spi1_cs_high,
.transfer = spi1_transfer,
};
void board_init(void)
{
bsp_spi1_init(); /* 先初始化SPI硬件 */
w25q256_init(&spi1_for_flash); /* 再把接口注册给驱动 */
}
这样 w25q256.c 里没有任何 STM32 寄存器操作,换到 GD32 或 RISC-V,只需要写新的 bsp 函数并重新注册。这种写法在 C/C++ 的嵌入式项目中非常实用,把依赖反转过来,维护成本直线下降。
OS 适配层
RTOS 的 API 各家不一样。FreeRTOS 是 xTaskCreate,RT-Thread 是 rt_thread_create,裸机就是函数指针 + 定时器中断。如果 app 层直接调 FreeRTOS 的 API,换 RTOS 就是灾难。
os_wrapper.h — 统一接口:
#ifndef OS_WRAPPER_H
#define OS_WRAPPER_H
#include <stdint.h>
#include <stdbool.h>
typedef void (*os_task_func_t)(void *arg);
/* 任务 */
int os_task_create(os_task_func_t func, const char *name,
uint32_t stack_size, void *arg, uint32_t prio,
void **handle);
/* 延时 */
void os_delay_ms(uint32_t ms);
/* 互斥锁 */
typedef void *os_mutex_t;
int os_mutex_create(os_mutex_t *mutex);
int os_mutex_lock(os_mutex_t mutex, uint32_t timeout_ms);
int os_mutex_unlock(os_mutex_t mutex);
/* 消息队列 */
typedef void *os_queue_t;
int os_queue_create(os_queue_t *queue, uint32_t item_size, uint32_t capacity);
int os_queue_send(os_queue_t queue, const void *item, uint32_t timeout_ms);
int os_queue_recv(os_queue_t queue, void *item, uint32_t timeout_ms);
#endif
os_wrapper.c — FreeRTOS 实现:
#include "os_wrapper.h"
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "semphr.h"
int os_task_create(os_task_func_t func, const char *name,
uint32_t stack_size, void *arg, uint32_t prio,
void **handle)
{
TaskHandle_t xHandle;
BaseType_t ret = xTaskCreate(func, name, stack_size, arg, prio, &xHandle);
if (ret != pdPASS) return -1;
if (handle) *handle = xHandle;
return 0;
}
void os_delay_ms(uint32_t ms)
{
vTaskDelay(pdMS_TO_TICKS(ms));
}
int os_mutex_create(os_mutex_t *mutex)
{
SemaphoreHandle_t m = xSemaphoreCreateMutex();
if (!m) return -1;
*mutex = (os_mutex_t)m;
return 0;
}
int os_mutex_lock(os_mutex_t mutex, uint32_t timeout_ms)
{
return xSemaphoreTake((SemaphoreHandle_t)mutex,
pdMS_TO_TICKS(timeout_ms)) == pdTRUE ? 0 : -1;
}
int os_mutex_unlock(os_mutex_t mutex)
{
return xSemaphoreGive((SemaphoreHandle_t)mutex) == pdTRUE ? 0 : -1;
}
int os_queue_create(os_queue_t *queue, uint32_t item_size, uint32_t capacity)
{
QueueHandle_t q = xQueueCreate(capacity, item_size);
if (!q) return -1;
*queue = (os_queue_t)q;
return 0;
}
int os_queue_send(os_queue_t queue, const void *item, uint32_t timeout_ms)
{
return xQueueSend((QueueHandle_t)queue, item,
pdMS_TO_TICKS(timeout_ms)) == pdTRUE ? 0 : -1;
}
int os_queue_recv(os_queue_t queue, void *item, uint32_t timeout_ms)
{
return xQueueReceive((QueueHandle_t)queue, item,
pdMS_TO_TICKS(timeout_ms)) == pdTRUE ? 0 : -1;
}
app 层只调 os_delay_ms()、os_queue_send() 这些函数。从 FreeRTOS 切到 RT-Thread,只改 os_wrapper.c 一个文件。
全局配置管理
project_config.h 集中管理功能开关和板级参数,不要到处写 #define:
#ifndef PROJECT_CONFIG_H
#define PROJECT_CONFIG_H
/* 功能开关 */
#define CFG_USE_FREERTOS 1
#define CFG_USE_SHELL 1
#define CFG_USE_LOG 1
#define CFG_SENSOR_BME280 1
#define CFG_DISPLAY_SSD1306 1
/* 串口配置 */
#define CFG_DEBUG_UART_BAUD 115200
#define CFG_COMM_UART_BAUD 9600
/* 传感器参数 */
#define CFG_SENSOR_SAMPLE_MS 1000
#define CFG_SENSOR_REPORT_MS 5000
/* Flash分区(扇区为单位,W25Q256扇区4KB) */
#define CFG_FLASH_PARAM_SECTOR 0 /* 参数存储区:扇区0 */
#define CFG_FLASH_LOG_SECTOR 1 /* 日志存储区:扇区1~3 */
#define CFG_FLASH_LOG_COUNT 3
#endif
模块内部根据宏决定是否编译:
/* task_sensor.c */
#include "project_config.h"
#if CFG_SENSOR_BME280
#include "bme280.h"
void sensor_task(void *arg)
{
bme280_init();
while (1) {
bme280_data_t data;
bme280_read(&data);
/* ... */
os_delay_ms(CFG_SENSOR_SAMPLE_MS);
}
}
#endif
换项目时改 project_config.h 就行,不用翻遍代码找散落的宏定义。
头文件包含规则
头文件乱包含是编译依赖的万恶之源。规则很简单:
- .c 文件可以包含任何需要的头文件
- .h 文件只包含自身声明需要的头文件 —— 能用前向声明就用前向声明
- 所有 .h 必须有 include guard
/* 错误:头文件里包含了一堆不需要的东西 */
#ifndef TASK_SENSOR_H
#define TASK_SENSOR_H
#include "bme280.h" /* 不需要,.c里包含就行 */
#include "FreeRTOS.h" /* 不需要 */
#include "task.h" /* 不需要 */
void sensor_task(void *arg);
#endif
/* 正确:头文件只声明接口 */
#ifndef TASK_SENSOR_H
#define TASK_SENSOR_H
void sensor_task(void *arg); /* void*不需要额外头文件 */
#endif
前向声明替代结构体包含:
/* 错误 */
#include "protocol.h" /* 只为了用 protocol_frame_t */
void sensor_send(const protocol_frame_t *frame);
/* 正确 */
typedef struct protocol_frame protocol_frame_t; /* 前向声明 */
void sensor_send(const protocol_frame_t *frame);
中断服务的处理
中断服务函数放在 startup/stm32f4xx_it.c,但里面不放业务逻辑,只调钩子:
/* stm32f4xx_it.c */
#include "bsp_uart.h"
void USART2_IRQHandler(void)
{
bsp_uart2_irq_handler(); /* 转发给bsp处理 */
}
/* bsp_uart.c */
#include "bsp_uart.h"
#include "ringbuf.h"
static ringbuf_t uart2_rx_buf;
void bsp_uart2_irq_handler(void)
{
if (USART2->SR & USART_SR_RXNE) {
uint8_t ch = USART2->DR;
ringbuf_put(&uart2_rx_buf, ch);
}
}
int bsp_uart2_read(uint8_t *buf, size_t len, uint32_t timeout_ms)
{
/* 从ringbuf取数据,超时由os层处理 */
size_t got = 0;
while (got < len) {
if (ringbuf_get(&uart2_rx_buf, &buf[got])) {
got++;
} else if (timeout_ms == 0) {
break;
} else {
os_delay_ms(1);
timeout_ms--;
}
}
return (int)got;
}
中断里只做最少的事(收字节进环形缓冲区),处理逻辑放在任务里。这样换 RTOS 时中断部分不用改,只改 bsp_uart2_read 里的等待方式。
如果对目录结构还有更多想法,欢迎到 云栈社区 和更多嵌入式开发者一起交流,很多踩过的坑,聊一聊就能绕过去。