news 2026/10/5 6:11:34

基于LiteOS的智慧农业监测系统:RTOS多任务与传感器驱动实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于LiteOS的智慧农业监测系统:RTOS多任务与传感器驱动实战

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 配置文件的三个关键参数

跑通LiteOS,los_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表示0,70us表示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秒定时1024
DisplayTask刷新OLED显示4收到消息触发1024
ControlTask阈值判断、控制继电器3收到消息触发768
KeyTask扫描按键、调节阈值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(写)或0x3C(7位地址)。驱动代码网上很多,核心是初始化和显存刷新两大部分。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跑通。试着回答这几个问题——你的系统能在掉电后自动恢复工作吗?数据长期存储在本地还是云端?如果某个传感器故障,系统能否报警并继续运行其他功能?把这些异常场景处理好,你的项目水平会明显上一个台阶。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 6:11:16

RK3576+Android14适配移远RG200U 5G模组:三大坑与完整解决方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 6:11:13

玩转二叉树:前序中序还原+镜像层序遍历实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 6:10:41

HWindowControl与HSmartWindowControl:Halcon图像控件选型详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 6:09:09

ADRC自抗扰控制Simulink仿真与调参实践:TD、ESO、NLSEF模块拆解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 6:08:50

从传感器到气动调节:手把手教你DIY一个智能枕头

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 6:08:43

STM32L162ZE与MR25H40CDF工业级MRAM存储方案实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华