
1. 为什么农业案例要选LiteOS而不是继续用裸机开发先聊点背景。前阵子做了一套基于LiteOS的智慧农业监测系统说是系统其实核心就三件事把温湿度、光照、土壤湿度这些环境数据采上来显示到本地屏幕同时根据阈值自动控制灌溉设备。听起来不复杂但如果用裸机开发你会发现代码越写越别扭——尤其是当传感器从一个变成三个、再到五个的时候。LiteOS是华为开源的一款轻量级物联网操作系统内核是标准的RTOS架构支持任务调度、信号量、互斥锁、消息队列、软件定时器等常用组件。跟裸机开发最大的区别在于裸机是一个大循环里轮询所有外设而RTOS是每个功能独占一个任务由内核决定谁先跑、跑多久。这个思维方式一旦转过来项目复杂度上去之后写代码的体验完全是两回事。我在这个案例里实际用到的LiteOS特性包括任务创建与优先级调度、消息队列做传感器数据传递、软件定时器做周期采集、以及中断下半段处理按键和报警。整套代码下来工程结构比之前用裸机写的同类项目清晰得多加功能也方便——比如后来想加一个云端上报只需要再开一个任务从队列里取数据转发就行基本不动原有代码。做智慧农业实验用LiteOS还有一个实际原因它针对物联网场景做了裁剪优化内核占用很小RAM在几KB级别的单片机上也能跑。这意味着我们不需要换高端开发板用常见的STM32F103系列就能完成全部功能成本低、参考资料多、出问题也好排查。这套案例适合谁如果你正在学RTOS想找一个有传感器、有显示、有控制、有联动的综合练手项目或者你已经在做智慧农业相关的东西想看看别人怎么划分任务、怎么处理多传感器并发采集——那这篇文章应该对你有帮助。后面我会从硬件选型、环境搭建、驱动编写、任务设计到踩坑排查一条线讲完尽量把每个选择背后的原因也说清楚。2. 硬件选型与整体架构设计每个外设都不是随便选的2.1 开发板与传感器的搭配逻辑我用的主控是STM32F103ZET6正点原子的战舰开发板。选它的原因很简单Flash和RAM够大512KB Flash、64KB RAM跑LiteOS内核加几个任务绰绰有余外设接口齐全板载传感器、屏幕接口、继电器预留都有省去大量飞线麻烦。传感器方面这套系统选了三类环境监测设备传感器/模块型号测量内容通信方式为什么选它温湿度DHT11温度、湿度单总线便宜、资料多、数字信号抗干扰光照BH1750环境光照强度I2C数字输出直接读lux值不用自己算土壤湿度土壤湿度探头ADC土壤含水量模拟量→ADC模拟量采集正好演示ADC任务OLED显示屏0.96寸 SSD1306显示数据I2C刷新快、驱动简单、显示内容丰富继电器模块单路5V继电器控制灌溉设备GPIO低电平触发隔离控制220V水泵这里有个细节值得说一下DHT11的精度其实不高湿度±5%RH、温度±2℃但作为教学演示和初步环境监测完全够用。如果你要做更精确的农业数据记录我建议把DHT11换成DHT22或者SHT30驱动逻辑类似只是时序参数不同。土壤湿度探头是阻式传感器插在土里通过两电极间的电阻变化反映含水量输出是模拟电压。这个信号不能直接进MCU的GPIO必须经过ADC采样。STM32的ADC是12位的VREF接3.3V所以读到的值范围是0~4095。土壤越湿电极间电阻越小分压后的电压越高ADC值越大。这个关系在写阈值判断时要用到。2.2 系统数据流与任务划分图整个系统的数据流是这样的传感器定时采集数据通过消息队列发送给显示任务和控制任务显示任务把数据格式化后刷到OLED上控制任务拿数据跟设定阈值比较决定是否打开继电器同时还有一个按键任务用来调节阈值防止误触。用文字描述比较抽象我画个简化的流程传感器采集任务周期2秒 ↓ 消息队列 ├──→ OLED显示任务实时刷新 ├──→ 自动灌溉控制任务判断阈值 └──→ 可选云平台上传统计任务任务之间的数据交换我没有用全局变量而是全部走消息队列。原因很简单全局变量在RTOS里容易出现竞态问题——如果一个任务在写另一个任务在读数据可能不一致还要额外加互斥锁保护。用消息队列生产者和消费者天然解耦数据是一份拷贝不存在共享内存冲突代码也更干净。3. 环境搭建与LiteOS移植细节这里最容易卡住新手3.1 开发环境选择我用的开发环境是Keil MDK5配合ST-Link仿真器下载调试。LiteOS支持多种IDE但考虑到网上资料最多、遇到问题最好搜MDK还是首选。LiteOS源码从官方仓库拉取后目录结构大致是LiteOS/ ├── arch/ // 架构相关代码CM3/CM4等内核移植文件 ├── kernel/ // 内核核心任务调度、信号量、队列等 ├── los_config.h // 内核配置文件时钟、内存、任务相关 ├── projects/ // 官方工程模板 │ └── realview-pbx-a9/ // 示例工程 └── target/ // 板级支持包这里要提醒一个关键点不要自己去从零移植LiteOS内核工程量很大而且很容易在启动文件、链接脚本这些地方出问题。正确做法是找一个接近你板子的官方示例工程先让它跑起来再在此基础上改外设驱动。我用的是STM32F103的移植示例网上有大量现成模板Coretex-M3架构的移植包直接能用。3.2 配置文件的三个关键参数跑通LiteOSlos_config.h里有几个参数必须根据自己的板子调整否则系统可能起不来或者运行不稳定。第一个是时钟频率。LOSCFG_IPC_MAIN_TICK或者系统节拍OS_SYS_CLOCK要跟你板子实际的外部晶振一致。STM32F103最常见的是8MHz外部晶振锁相环倍频到72MHz。如果这个配置不对系统节拍时间就不准直接影响任务调度的实时性。第二个是内存堆大小。LiteOS的动态内存分配都在系统堆里进行任务栈、消息队列缓冲区都从这里申请。默认配置可能是几十KB如果你的任务多、栈开得大一定要把堆调大。我在这个案例里设置为LOSCFG_BASE_MEM_NODE_SIZE对应的堆总大小20KB任务栈平均每个分配512字节到1KB再加上队列缓冲区运行下来内存占用大概在12KB左右还有富余。第三个是任务栈大小。每个任务创建时都要指定栈大小太小会导致栈溢出系统随机死机太大浪费内存。我的做法是每个任务先给它1KB跑起来后用LOS_TaskInfoGet查看实际栈使用峰值再酌情减小或加大。这个方法很实用后面踩坑部分还会提到。3.3 官方Demo的启动流程LiteOS的启动流程跟裸机不一样我们写的main函数一般只做三件事初始化硬件外设、创建任务、启动内核调度。内核启动后main函数所在的上下文就不再执行了控制权完全交给内核。int main(void) { HAL_Init(); // HAL库初始化 SystemClock_Config(); // 配置系统时钟 // 外设初始化串口、I2C、ADC、OLED等 BSP_Init(); // 创建业务任务 CreateSensorTask(); CreateDisplayTask(); CreateControlTask(); CreateKeyTask(); LOS_KernelInit(); // 内核初始化 LOS_Start(); // 启动内核调度从此进入多任务模式 }需要特别注意的是LOS_Start()之后程序不会返回。如果你在它后面还写了初始化代码那永远不会执行到。所有需要跑的初始化逻辑必须放在任务创建之前完成。4. 传感器驱动开发的完整实操从读时序到业务封装这一章是大家最关心的也是RTOS驱动开发区别于裸机驱动开发的核心部分。LiteOS本身不提供具体传感器的驱动库它只提供内核机制硬件驱动需要我们基于HAL库或寄存器自己写。接下来我用三个最常见的传感器展开讲。4.1 DHT11温湿度驱动单总线时序的RTOS化处理DHT11是单总线通信只有一根数据线时序要求很严格。读一次数据的过程是主机拉低总线至少18ms触发传感器响应然后释放总线传感器会拉低80us再拉高80us表示响应之后开始输出40位数据。每一位的0或1由高电平持续时间区分典型值是26us~28us表示070us表示1。裸机写这个驱动时通常用delay_us()函数做精确延时。但在RTOS环境下有个问题任务级延时LOS_TaskDelay的最小单位通常是1个系统节拍1ms完全无法满足us级别的时序要求。所以驱动里必须使用忙等待也就是空指令循环或者DWT计数器来做us延时同时要在采集期间暂时屏蔽任务切换防止时序被打断。我这里用了一个简单可靠的做法读取DHT11的过程中关闭任务调度LOS_TaskLock读完马上开锁LOS_TaskUnlock。这样即使其他任务就绪内核也不会切换过去确保单总线时序不受干扰。uint8_t DHT11_ReadData(DHT11_Data_t *data) { uint8_t buf[5] {0}; DHT11_Start(); // 主机发送起始信号 if (!DHT11_CheckResponse()) // 等待传感器响应 return 1; // 读取40位数据 for (int i 0; i 40; i) { while (DHT11_PIN_READ() 0); // 等待低电平结束 delay_us(40); // 延时40us后判断电平 if (DHT11_PIN_READ()) buf[i / 8] | (1 (7 - (i % 8))); while (DHT11_PIN_READ() 1); // 等待高电平结束 } // 校验buf[4] buf[0]buf[1]buf[2]buf[3] 低8位 if ((uint8_t)(buf[0] buf[1] buf[2] buf[3]) ! buf[4]) return 2; >#define BH1750_ADDR 0x46 // ADDR引脚接地时的7位地址左移1位 #define BH1750_PWR_ON 0x01 #define BH1750_CONT_H 0x10 // 连续高精度模式 void BH1750_Init(void) { I2C_Start(); I2C_SendByte(BH1750_ADDR); I2C_SendByte(BH1750_PWR_ON); I2C_Stop(); LOS_TaskDelay(10); I2C_Start(); I2C_SendByte(BH1750_ADDR); I2C_SendByte(BH1750_CONT_H); I2C_Stop(); } float BH1750_ReadLux(void) { uint8_t buf[2]; I2C_Start(); I2C_SendByte(BH1750_ADDR | 0x01); // 读模式 buf[0] I2C_ReadByte(ACK); buf[1] I2C_ReadByte(NACK); I2C_Stop(); uint16_t raw (buf[0] 8) | buf[1]; return (float)raw / 1.2f; // 高精度模式分辨率1lx但需除以1.2修正 }需要注意BH1750的I2C地址取决于ADDR引脚。当ADDR接低电平时7位地址是0x23左移一位凑成8位写地址就是0x46。如果你发现I2C通信总是无响应先拿万用表确认一下ADDR引脚的电平再核对地址。这是我踩过的第二个坑排查了一个多小时最后发现是杜邦线接触不良导致ADDR电平不确定地址读错了。4.3 土壤湿度采集ADC驱动与滤波处理土壤湿度是最简单的读取方式但恰恰是最需要在软件层面做处理的。因为阻式传感器的模拟电压在土壤中会有明显波动特别是刚浇完水那段时间数值跳变很厉害。如果直接拿原始ADC值做阈值判断继电器会频繁开关对水泵寿命影响很大。我的处理方法分两步硬件上在探头供电端并联一个10uF电解电容稳定供电电压软件上做滑动平均滤波连续采样10次去掉最大最小值然后取平均。uint16_t SoilMoisture_Read(void) { uint32_t sum 0; uint16_t samples[10]; for (int i 0; i 10; i) { samples[i] ADC_Read(CHANNEL_SOIL); LOS_TaskDelay(2); // 每次采样间隔2ms } // 冒泡排序去最大最小 for (int i 0; i 9; i) for (int j 0; j 9 - i; j) if (samples[j] samples[j 1]) { uint16_t tmp samples[j]; samples[j] samples[j 1]; samples[j 1] tmp; } for (int i 1; i 9; i) sum samples[i]; return (uint16_t)(sum / 8); }ADC值跟土壤湿度的对应关系需要标定。我在做实验时把探头分别插入干燥土壤、半湿土壤、完全浸水的花盆里记录了三组典型值大概是干燥时1500左右、半湿时2500左右、完全浸湿时3500左右不同探头、不同土质会有差异。这个标定数据在设置自动灌溉阈值时非常关键不建议直接抄网上的数值。5. 多任务调度与通信机制LiteOS的核心玩法5.1 任务划分的原则与优先级分配任务划分是RTOS应用设计的灵魂。划分得好代码看着舒服、调试方便、扩展容易划分得差任务之间互相等待、资源竞争系统实时性反而比裸机还差。我在这套系统中划分了四个任务任务名职责优先级周期/触发方式栈大小SensorTask采集三个传感器数据52秒定时1024DisplayTask刷新OLED显示4收到消息触发1024ControlTask阈值判断、控制继电器3收到消息触发768KeyTask扫描按键、调节阈值220ms轮询512优先级数字越大优先级越高。SensorTask是生产者其余任务是消费者。采集任务必须是最高优先级因为传感器数据是系统的原料原料不新鲜下游所有判断都失去意义。DisplayTask和ControlTask都依赖SensorTask产出的数据把它们设为同等级没问题因为实际运行中它们不会同时就绪——都等着消息队列里的数据呢。KeyTask优先级最低因为它只是处理按键调节阈值用户按一下键系统晚几十毫秒响应完全没感觉。但如果按键任务里用了LOS_TaskDelay(20)做消抖轮询这个延时不能太长否则调阈值时屏幕刷新会卡顿。5.2 消息队列的使用细节消息队列是这套系统中最核心的通信机制。我把传感器数据封装成一个结构体通过队列发送给显示和控制任务。typedef struct { float temperature; float humidity; float lux; uint16_t soil; } EnvData_t; #define QUEUE_LEN 4 // 队列深度 UINT32 g_envQueueId; void CreateSensorTask(void) { // 创建消息队列 LOS_QueueCreate(env_queue, QUEUE_LEN, g_envQueueId, 0, sizeof(EnvData_t)); // 创建任务 TSK_INIT_PARAM_S taskParam {0}; taskParam.pfnTaskEntry SensorTask_Entry; taskParam.uwStackSize 1024; taskParam.usTaskPrio 5; taskParam.pcName sensor_task; LOS_TaskCreate(g_sensorTaskId, taskParam); }队列深度设置为4意味着最多缓存4份环境数据。如果DisplayTask或ControlTask处理不过来新的数据会被丢弃而不是阻塞SensorTask。这里其实是一个设计取舍对于环境监测来说数据时效性比完整性更重要——你宁愿丢掉几帧旧数据也不愿意因为队列满而阻塞了采集循环导致当前的最新数据迟迟得不到采集。接收端的处理方式void ControlTask_Entry(void) { EnvData_t data; UINT32 recvSize 0; while (1) { // 永久等待消息 LOS_QueueRead(g_envQueueId, data, recvSize, LOS_WAIT_FOREVER); // 阈值判断 if (data.soil SOIL_DRY_THRESHOLD) { GPIO_WriteBit(RELAY_GPIO_PORT, RELAY_PIN, 0); // 打开灌溉 } else if (data.soil SOIL_WET_THRESHOLD) { GPIO_WriteBit(RELAY_GPIO_PORT, RELAY_PIN, 1); // 关闭灌溉 } } }LOS_WAIT_FOREVER就是阻塞等待没有数据时这个任务不耗CPU内核直接把它挂起等有消息了再唤醒。这是RTOS比裸机空轮询省电、省CPU的关键。5.3 软件定时器周期采集的另一种写法除了用LOS_TaskDelay做周期性延时LiteOS还提供了软件定时器机制。我的SensorTask其实可以通过创建一个2秒周期的软件定时器来触发采集但最终选了LOS_TaskDelay方案。原因有两方面一是采集流程本身就是顺序执行的读DHT11、读BH1750、读ADC不需要额外的事件触发二是软件定时器的回调函数运行在定时器任务上下文中如果在这里做耗时操作会阻塞其他定时器回调这种隐性坑新手不好排查。如果你确实要用软件定时器记住回调里只做标记或者发消息不要做具体业务。6. OLED数据显示与自动灌溉联调6.1 OLED显示驱动的接入与刷新策略0.96寸OLED用的是SSD1306控制器I2C接口通常地址是0x78写或0x3C7位地址。驱动代码网上很多核心是初始化和显存刷新两大部分。SSD1306内置1KB显存128×64像素每像素1bit我们操作时先在内存里建一个同样大小的缓冲区改内容改缓冲区然后一次性把整个缓冲区刷新到屏幕。这里有个性能问题需要注意每次全屏刷新需要发送1024字节的I2C数据I2C速率如果是400kHz理论传输时间大约20ms。如果显示任务每100ms就全屏刷新一次会占用20%的I2C总线带宽还可能影响其他I2C设备比如BH1750也挂在同一总线上的通信。我的策略是只有当传感器数据变化超过一定幅度时才刷新OLED并且采用分区更新方式——比如温度和湿度各占屏幕的一半区域哪个变了就刷新哪个区域。void Display_Update(EnvData_t *data) { static EnvData_t lastData {0}; // 温度变化超过0.5度才刷新 if (abs(data-temperature - lastData.temperature) 0.5f) { OLED_ShowString(0, 0, Temp:, 12); OLED_ShowNum(48, 0, (int)data-temperature, 2, 12); OLED_ShowChar(72, 0, C, 12); } // 湿度变化超过1%才刷新 if (abs(data-humidity - lastData.humidity) 1.0f) { OLED_ShowString(0, 2, Humi:, 12); OLED_ShowNum(48, 2, (int)data-humidity, 2, 12); OLED_ShowChar(72, 2, %, 12); } // 光照变化超过10lux才刷新 if (abs(data-lux - lastData.lux) 10.0f) { OLED_ShowString(0, 4, Lux:, 12); OLED_ShowNum(48, 4, (int)data-lux, 4, 12); } // 土壤湿度变化超过20才刷新 if (abs(data-soil - lastData.soil) 20) { OLED_ShowString(0, 6, Soil:, 12); OLED_ShowNum(48, 6,>#define SOIL_DRY_BASE 1800 // 基础干燥阈值 #define SOIL_WET_BASE 2600 // 基础湿润阈值 #define TEMP_HIGH 32.0f // 高温补偿触发值 void ControlTask_Entry(void) { EnvData_t data; UINT32 recvSize 0; uint16_t dryThreshold SOIL_DRY_BASE; uint16_t wetThreshold SOIL_WET_BASE; while (1) { LOS_QueueRead(g_envQueueId, data, recvSize, LOS_WAIT_FOREVER); // 高温补偿温度高时降低干燥阈值避免过度灌溉 if (data.temperature TEMP_HIGH) { dryThreshold (uint16_t)(SOIL_DRY_BASE * 0.9f); wetThreshold (uint16_t)(SOIL_WET_BASE * 0.9f); } else { dryThreshold SOIL_DRY_BASE; wetThreshold SOIL_WET_BASE; } // 滞回控制防止继电器频繁开关 if (data.soil dryThreshold) { RELAY_ON; } else if (data.soil wetThreshold) { RELAY_OFF; } // 中间区段保持原有状态不动作 } }这里用了滞回控制Hysteresis这是一个非常重要的细节。如果我设置一个单一阈值比如2000土壤湿度在1980和2020之间波动时继电器会反复开关。增加一个干燥阈值1800和一个湿润阈值2600中间留出800的缓冲区间继电器一旦打开就要等到湿度超过2600才关闭一旦关闭要等到低于1800才打开。这样开关次数大幅减少实测继电器从每小时抖动几十次降到每天几次。7. 实测中踩过的四个坑从系统崩溃到数据错乱7.1 任务栈溢出导致随机死机系统跑了一段时间后会随机出现死机而且没有规律。开始以为是传感器时序问题后来用LiteOS提供的栈检测功能在任务创建时加了LOS_TaskInfoGet查看栈峰值发现DisplayTask的栈已经用到90%以上。因为我调用了一个第三方OLED驱动库里面申请了一个大的局部变量数组一次性就消耗了600多字节栈空间。解决办法不复杂把OLED的显示缓冲区从栈上移到全局静态区同时把任务栈从1KB调到1.5KB。排查系统死机问题第一步永远是看栈使用情况而不是怀疑内核有Bug。7.2 消息队列发送超时阻塞了采集早期代码里SensorTask发送消息用的参数是LOS_WAIT_FOREVER也就是队列满时一直阻塞等待。如果DisplayTask和ControlTask因为某些原因处理不及时队列很快填满SensorTask就被挂起了传感器采集静默停止。这种问题隐蔽性很强表面上看任务是正常的但数据就是不变了。后来我把发送超时改为0非阻塞模式发送失败就丢弃这帧数据。对于环境监测来说丢一帧数据完全不影响判断但采集循环必须始终运行。7.3 DHT11数据偶发错误问题出在共地有段时间DHT11读回来的数据间歇性错误有时温度跳到80度有时湿度显示255。用示波器看波形发现DHT11的数据线在空闲时电平不稳有毛刺。排查来排查去最后发现是DHT11模块的电源来自开发板的3.3V但数据线却接到了另一个供电系统的引脚上两者参考地不一致。解决办法是确认所有传感器模块跟开发板共地。用排针直接插在开发板供电端就不会有这个问题但用杜邦线外接时一定要把模块的GND跟开发板的GND连在一起。7.4 光照传感器突然不响应I2C总线被拉死BH1750用了一段时间后突然读数不变用逻辑分析仪看I2C波形发现SDA线一直被拉低总线处于忙状态。原因是有一次在BH1750通信过程中我强行复位了MCU导致I2C从机状态机卡死。解决办法是在I2C初始化时做一个总线恢复操作把SCL和SDA配置为GPIO输出手动翻转9个时钟周期让挂死的从机复位状态机再切回I2C功能。这个软件复位总线小技巧在调试I2C设备时很实用强烈建议写进你的驱动里。8. 再往后可以怎么扩展通过这套智慧农业实验LiteOS的多任务机制、消息队列通信、驱动分层思想基本都能串起来了。物联网系统永远不止本地采集本地控制这一步后面扩展空间还很大。比较自然的一个方向是接上ESP8266模块通过MQTT协议把采集到的数据发到云端平台。因为LiteOS下任务划分已经清楚数据都在消息队列里新增一个网络上报任务只是从队列里再取一份数据转发出去代码量不会太大。再往后如果你想做更完整的农业大棚系统还可以加土壤pH值检测、CO2浓度监测、光照补光灯控制、蜂鸣器报警等功能。每加一个传感器无非是写一个驱动挂到SensorTask的采集链路上再在下游任务里加对应的处理逻辑。这种插拔式的扩展体验正是RTOS相较于裸机开发的魅力所在。最后提个建议如果你把这套东西当成课程设计或者毕设项目不要只停留在把Demo跑通。试着回答这几个问题——你的系统能在掉电后自动恢复工作吗数据长期存储在本地还是云端如果某个传感器故障系统能否报警并继续运行其他功能把这些异常场景处理好你的项目水平会明显上一个台阶。