☰
minIni 轻量级 INI 解析原理与嵌入式移植实战
2026/11/6 3:48:10 网站建设 项目流程

从文件到内存:minIni 轻量级 INI 解析原理与嵌入式移植实战

适合读者:需要在单片机、裸机或 RTOS 工程中解析配置文本,又不想引入庞大配置框架的嵌入式工程师。

本文结合官方minIni与一份本地裁剪实现,介绍 INI 语法、minIni 的接口和查找过程,以及如何把它从FILE *文件模型改造成直接解析内存字符串。

摘要:本文围绕 minIni 这一轻量级 INI 解析库,先介绍 INI 的段、键、值语法与 minIni 的定位,再梳理ini_gets()、ini_getl()、ini_getbool()等官方接口的用法,并剖析minGlue.h存储抽象这一设计亮点。随后结合一份本地裁剪实现,讲解如何把文件后端改造为直接解析内存字符串:逐行读取、段名与键名匹配、空白与注释清理、输出长度限制等关键流程。文章还重点分析了内存后端的六个边界问题(NUL 结尾、单行长度、末行无换行、全局AtLine不可重入、返回值语义、数值校验),给出更通用的ini_mem_t上下文设计、移植注意事项与测试清单,帮助嵌入式工程师在无文件系统环境下安全可靠地使用 INI 配置。

一、为什么嵌入式项目还会使用 INI?

INI 是一种非常简单的文本配置格式。一个典型文件如下:

[Network] dhcp = true address = 192.168.1.100 port = 502 [Display] brightness = 80 language = "zh-CN" ; language shown on screen

它只有三个核心概念:

  • 段(section):例如[Network],用于给配置分组;
  • 键(key):例如port,表示配置项名称;
  • 值(value):例如502,表示配置内容。

与 JSON、XML 相比,INI 不擅长表达多层嵌套和复杂数组,但它的优点也很明显:

  1. 人可以直接阅读和修改;
  2. 解析规则简单;
  3. 不需要构建 DOM 树;
  4. 可以按段和键直接查找;
  5. 很适合只有几十项配置的 MCU。

如果配置只是一组扁平参数,为它引入完整的 JSON 或 XML 解析器,有时反而增加代码体积和维护成本。minIni 就是为这种场景准备的小型 C 库。

二、minIni 是什么?

minIni 是 CompuPhase 提供的轻量级 INI 解析库,使用 Apache License 2.0。

官方 README 对它的定位非常明确:

  • 面向嵌入式系统;
  • 支持读取、写入和删除配置;
  • 支持段与键的枚举;
  • 支持回调式遍历;
  • 不使用malloc等动态内存分配;
  • 内存占用确定;
  • 不强制依赖标准 C 文件 I/O,可以适配不同存储后端。

它不是一个大型配置系统,也不负责配置参数的持久化策略、并发管理和业务合法性检查。它主要解决一个问题:从符合 INI 语法的文本中取出或修改键值。

三、先掌握 minIni 支持的语法

1. 段和键值

最常见的格式是:

[Network] address=192.168.1.100 port=502

读取时同时指定段和键:

Section = Network Key = port Value = 502

minIni 也支持位于任何段之前的全局键。调用时把段名设为NULL,可以查找这部分配置。

2. 等号和冒号都可以分隔键值

下面两行具有相同含义:

port=502 port: 502

解析器优先寻找=;如果没有找到,再寻找:。

3. 键和值两侧的空白会被忽略

以下写法都可以正常解析:

port=502 port = 502 port = 502

但如果希望保留值开头或结尾的空白,应使用双引号。

4. 支持分号和井号注释

; this is a comment # this is also a comment port=502 ; server port address=192.168.1.100 # default address

行尾注释也支持。注释符号位于双引号内时,应作为值的一部分处理,而不是直接截断。

5. 双引号用于保留特殊字符

greeting="hello;world"

如果没有引号处理,;很容易被误认为行尾注释的开始。minIni 的字符串清理逻辑会识别引号,并在返回值时去掉最外层双引号。

四、官方库的接口怎么使用?

1. 读取字符串:ini_gets()

intini_gets(constchar*section,constchar*key,constchar*defvalue,char*buffer,intbuffer_size,constchar*filename);

示例:

charaddress[32];ini_gets("Network","address","127.0.0.1",address,sizeof(address),"config.ini");

参数含义如下:

参数作用
section段名
key键名
defvalue查找失败时使用的默认字符串
buffer调用者提供的输出缓冲区
buffer_size输出缓冲区容量
filename官方文件后端中的文件路径

返回值是最终写入buffer的字符串长度,不包含末尾的\0。

2. 读取整数:ini_getl()

longport=ini_getl("Network","port",502,"config.ini");

它先通过ini_gets()取得文本,再进行数值转换:

  • 第二个字符为x或X时,按十六进制处理;
  • 其他情况按十进制处理;
  • 空字符串返回默认值。

例如:

port=502 mask=0xFFFF

都可以使用ini_getl()读取。

3. 读取布尔值:ini_getbool()

intdhcp=ini_getbool("Network","dhcp",1,"config.ini");

它只取结果字符串的第一个字符,并按以下规则判断:

首字符结果
Y、y、1、T、t真
N、n、0、F、f假
其他字符或空字符串默认值

因此yes、true、1都可以表示真,no、false、0都可以表示假。

4. 官方版还提供更完整的接口

官方头文件还包括:

接口用途
ini_getsection()按序号枚举段
ini_getkey()按序号枚举段内的键
ini_hassection()判断段是否存在
ini_haskey()判断键是否存在
ini_browse()遍历配置并通过回调返回段、键和值
ini_puts()/ini_putl()/ini_putbool()写入配置
ini_getf()/ini_putf()可选浮点支持

需要注意:后文分析的本地裁剪版只保留了三个读取接口,不能把官方完整接口表直接套到裁剪版上。

五、官方设计最巧妙的地方:minGlue.h

minIni 没有把解析算法与stdio永久绑定,而是使用minGlue.h描述平台 I/O。

标准 C 文件后端的映射大致如下:

#defineINI_FILETYPEFILE*#defineini_openread(filename,file)((*(file)=fopen((filename),"rb"))!=NULL)#defineini_close(file)(fclose(*(file))==0)#defineini_read(buffer,size,file)(fgets((buffer),(size),*(file))!=NULL)

分层关系是:

┌──────────────────────────────────┐ │ ini_gets / ini_getl / ini_getbool │ └─────────────────┬────────────────┘ ▼ ┌──────────────────────────────────┐ │ 段匹配、键匹配、空白、注释、引号处理 │ └─────────────────┬────────────────┘ ▼ ┌──────────────────────────────────┐ │ open / read / close 存储抽象接口 │ └─────────────────┬────────────────┘ ▼ stdio、FatFs 或自定义后端

这种设计带来两个好处:

  1. 有文件系统时,可以直接使用现成后端;
  2. 没有文件系统时,可以只替换 I/O 层,保留解析算法。

对于移植工作,通常应优先考虑官方 glue 机制,而不是一开始就修改整个解析器。

六、本地裁剪版做了什么?

本地实现位于 minIni.c 和 minIni.h。它选择了一种更直接的内存解析方式:

  • 不再包含minGlue.h;
  • 不再打开或关闭文件;
  • ini_gets()最后一个参数实际传入内存文本指针;
  • 用全局变量AtLine保存当前偏移;
  • 用ini_read()从内存中取出一行;
  • 保留段、键、注释、空白和引号处理;
  • 对外只保留ini_gets()、ini_getl()、ini_getbool()。

官方与本地模型的区别如下:

对比项官方文件后端本地内存后端
最后参数的含义文件路径文本缓冲区地址
数据来源文件RAM 中的字符串
位置记录文件句柄全局AtLine
行读取fgets()自定义ini_read()
打开与关闭需要不需要
动态内存不使用不使用
本地公开 C 接口官方完整集合三个读取接口

这里最容易误读的是:本地函数签名虽然沿用了官方形状,但最后一个参数已经不能传"config.ini",而应该传配置文本本身。

七、内存后端怎样逐行读取?

本地核心变量和函数位于 minIni.c:103-119 与 minIni.c:226-247。

简化后的逻辑是:

staticintAtLine=0;TCHARini_Clear(void){AtLine=0;return1;}TCHARini_read(TCHAR*line,intlen,constTCHAR*text){intr_len=0;memset(line,0,len);if(text[AtLine]=='\0')return0;while(text[AtLine+r_len]!='\n'&&text[AtLine+r_len]!='\0'){r_len++;}memcpy(line,text+AtLine,r_len);r_len++;AtLine+=r_len;return1;}

它做四件事:

  1. 清空行缓冲区;
  2. 检查当前位置是否已经到文本末尾;
  3. 一直扫描到\n或\0;
  4. 把当前行复制到行缓冲区,并移动全局偏移。

ini_gets()每次开始查找前都会调用ini_Clear(),所以每次查询都从文本开头进行,见 minIni.c:338-352。

调用方式可以写成:

staticconstcharconfig_text[]="[Network]\n""dhcp=true\n""address=192.168.1.100\n""port=502\n";charaddress[32];longport;intdhcp;ini_gets("Network","address","127.0.0.1",address,sizeof(address),config_text);port=ini_getl("Network","port",502,config_text);dhcp=ini_getbool("Network","dhcp",1,config_text);

这段代码不需要文件路径,也不需要文件系统。

八、ini_gets() 内部怎样找到目标键?

真正的查找函数是getkeystring(),见 minIni.c:249-326。

可以把过程画成:

从文本开头读取一行 ↓ 寻找目标 section ↓ 找到后,继续逐行读取该 section 的内容 ↓ 遇到下一个 section 或文本末尾:停止 ↓ 跳过注释和没有分隔符的行 ↓ 比较键名长度和键名内容 ↓ 找到后清理 value,复制到调用者缓冲区

1. 段名匹配

段查找会:

  • 跳过前导空白;
  • 检查是否以[开头;
  • 找到对应的];
  • 比较段名长度;
  • 使用不区分大小写的比较。

因此[Network]与[network]在这份实现中可以匹配。

2. 键名匹配

进入目标段以后,解析器寻找=或:,再比较分隔符之前的键名。

长度比较很重要:查找port时,不应该把port_backup当成同一个键。

如果读到下一行以[开头,函数立即返回失败,不会继续到后面的段里寻找同名键。

3. 找不到时使用默认值

ini_gets()根据getkeystring()的结果决定:

  • 找到:返回清理后的值;
  • 没找到:复制DefValue;
  • DefValue == NULL:使用空字符串。

所以下面这个默认值不是解析器内置的,而是调用者传入的:

ini_gets("Network","address","127.0.0.1",address,sizeof(address),config_text);

九、字符串清理是 minIni 很值得保留的部分

找到key=value只是第一步。实际文本可能包含:

title = "hello;world" ; comment

如果直接取分隔符后的全部内容,就会得到包含空格、引号和注释的字符串。

本地沿用了官方的几层处理:

1. skipleading():跳过前导空白

while('\0'<*str&&*str<=' ')str++;

它不只跳过普通空格,也跳过制表符等编码不大于空格的空白字符。

2. skiptrailing() / striptrailing():处理尾部空白

从字符串末尾向前移动,去掉键名或值末尾的空白。

3. cleanstring():去注释和引号

实现位于 minIni.c:193-224,主要处理:

  • 引号外的;和#行尾注释;
  • 成对双引号;
  • 双引号内的转义形式;
  • 值末尾空白。

4. ini_strncpy():限制输出复制长度

实现位于 minIni.c:147-191。普通复制最多写入maxlen - 1个字符,并补\0。

因此输出缓冲区不会被故意写满到失去结束符;但如果值太长,结果会被截断,调用者需要意识到这一点。

十、逐键查询的效率与资源占用

1. 每读取一个键,都会重新扫描

假设连续读取三个配置:

ini_gets(...,"address",...,config_text);ini_getl(...,"port",...,config_text);ini_getbool(...,"dhcp",...,config_text);

每次调用都会把AtLine清零,因此会扫描三次。

这种模型适合:

  • 配置文本较小;
  • 只读取少量字段;
  • 代码简单和接口独立比扫描次数更重要。

如果配置很大、一次要读取大量字段,回调式遍历通常更合适:只扫描一遍,在回调中处理每个键值。

2. 不使用堆,但会使用栈

getkeystring()内部有:

TCHAR LocalBuffer[INI_BUFFERSIZE];

INI_BUFFERSIZE默认是 512,见 minIni.h:31-33。

ini_getl()还包含一个 64B 的临时文本数组,并再次进入ini_gets()的查找调用链。

所以“minIni 不使用动态内存”不等于“minIni 几乎不占内存”。它的特点是占用可预期,主要成本是栈上的行缓冲。

在 RTOS 中,应根据任务栈余量调整INI_BUFFERSIZE,不能只看全局 RAM 占用。

3. INI_BUFFERSIZE 需要与配置约定匹配

它会影响:

  • 行缓冲区大小;
  • 单次查找的栈开销;
  • 期望支持的最大行长度。

配置项都很短时,可以评估是否减小;包含长路径或长文本时,则需要更大的容量。但本地自定义行读取还存在下一节所述的边界问题,不能只调宏就认为已经支持超长行。

十一、本地内存后端的六个关键边界

1. 输入必须是合法的 NUL 结尾字符串

本地ini_read()没有文本总长度参数,只能依靠\0判断结束。

以下用法是不安全的:

charraw[64];/* 只填入一部分字节,没有补 '\0' */ini_gets("Network","port","",value,sizeof(value),raw);

调用者必须保证:

  • 文本在有效范围内有\0;
  • 指针在解析期间保持有效;
  • 文本内容不会被其他执行流同时修改。

2. 自定义行复制没有限制单行长度

虽然ini_read()接收len,但它只把len用于memset(),没有在扫描和memcpy()时限制r_len。

因此一行如果超过INI_BUFFERSIZE,就可能写越界。官方fgets(buffer, size, file)本来具有读取上限,本地改造需要自行补回这层保护。

一个安全的行读取函数至少应同时知道:

文本总长度 当前偏移 行缓冲区容量

并在每个复制步骤前检查边界。

3. 最后一行没有换行时,要测试失败查找路径

本地代码在扫描到\0后仍然执行r_len++。如果该行不是目标键,后续再次读取时,AtLine可能已经越过字符串结束符。

因此以下两种情况都要测试:

目标键就在最后一行,且没有换行 目标键不存在,最后一行也没有换行

第二种更容易触发继续扫描的问题。

更合理的处理方式是:

  • 遇到\n:移动到下一句;
  • 遇到\0:让偏移停在结束位置,下一次读取直接返回 EOF。

4. 全局 AtLine 使函数不可重入

AtLine被所有解析调用共享。如果两个任务同时解析,可能出现:

任务 A 扫描到一半 ↓ 任务 B 调用 ini_Clear(),把偏移清零 ↓ 任务 A 从错误的位置继续

它还使递归调用和回调中的再次解析变得危险。

如果只需要单任务使用,可以通过调用约束避免并发;如果希望作为通用库,应把游标放入上下文对象,或者使用适当的互斥保护。相比全局加锁,独立上下文通常更清晰,也允许真正并行解析不同文本。

5. ini_gets() 的返回值不等于“查找成功”

找不到键时,ini_gets()会复制默认值,并返回默认值长度。

例如:

intlen=ini_gets("Network","missing","dummy",value,sizeof(value),config_text);

即使键不存在,len也可能是 5。

如果默认值为"",返回 0 又可能有两种含义:

  • 没找到键;
  • 找到了键,但值本来就是空字符串。

因此需要精确区分“未找到”“空值”“默认值”时,应使用存在性接口或自行扩展明确的返回状态。

6. ini_getl() 不是严格的数值校验器

本地_tcstol映射到strtoul(),转换时没有检查结束指针。

这意味着:

port=502abc port=abc

不能简单期望第一种被报告为格式错误、第二种自动返回默认值。转换函数可能接受数字前缀,也可能对完全无法转换的非空文本返回 0。

对于端口号、地址、尺寸等需要严格校验的配置,更稳妥的做法是:

  1. 用ini_gets()取得原始字符串;
  2. 使用带endptr的转换函数;
  3. 检查是否至少转换了一个字符;
  4. 检查endptr是否到达字符串末尾;
  5. 检查结果是否在业务范围内。

十二、怎样设计一个更通用的内存后端?

推荐把数据源和解析位置显式封装:

typedefstruct{constchar*data;size_tsize;size_tpos;}ini_mem_t;

初始化时同时提供地址和有效长度:

ini_mem_tini={.data=config_text,.size=sizeof(config_text)-1,.pos=0};

行读取接口可以设计成:

boolini_mem_read_line(ini_mem_t*ctx,char*line,size_tline_capacity);

它应遵守以下规则:

  1. ctx->pos >= ctx->size时返回 EOF;
  2. 只访问data[0 ... size-1];
  3. 始终给输出行保留一个\0;
  4. 行太长时返回明确的截断或格式错误状态;
  5. 区分“由换行结束”和“由文本末尾结束”;
  6. 不修改其他解析上下文。

整体结构如下:

┌─────────────────────────────────┐ │ 应用:指定 section、key、输出缓冲区 │ └────────────────┬────────────────┘ ▼ ┌─────────────────────────────────┐ │ minIni:匹配、清理、复制、类型转换 │ └────────────────┬────────────────┘ ▼ ┌─────────────────────────────────┐ │ ini_mem_t:data + size + pos │ └────────────────┬────────────────┘ ▼ ┌─────────────────────────────────┐ │ 有界行读取:检查输入与输出两侧边界 │ └─────────────────────────────────┘

如果仍希望沿用官方 API,可以优先通过minGlue.h把这些操作包装成后端;如果没有兼容官方接口的要求,也可以直接定义名称更准确的ini_mem_gets(),避免继续把内存地址叫作Filename。

十三、本地裁剪版还有哪些移植细节?

1. 不要默认它具备官方全部功能

本地 C 头文件只声明三个接口,见 minIni.h:39-41。

写入、删除、枚举、浏览等官方功能并未完整保留。需要这些功能时,应回到官方版本评估,而不是只复制本地头文件中的声明。

2. C++ 包装代码不能直接视为可用

本地头文件仍保留部分官方 C++ 类定义,但其中引用了本地没有提供的枚举、存在性和写入接口。

当前按 C 使用不会进入这些类定义;如果在 C++ 工程复用,应删除无效包装或补齐对应实现。

3. 大小写比较是 ASCII 规则

本地strnicmp()只对a到z做转换。它适合普通英文段名和键名,不应被理解为完整的 Unicode 大小写处理。

4. 文本编码应保持简单

当前实现按char逐字节处理,最适合 ASCII 配置名和兼容单字节语法的文本。若配置值包含 UTF-8 中文,普通字节复制通常可以保留这些字节,但不要期待库提供 Unicode 规范化、校对或宽字符语义。

5. 长值截断需要可观测

ini_strncpy()会保证输出结束符,但调用者仅通过最终字符串,不容易判断结果是否被截断。通用接口最好额外返回“输出空间不足”状态,避免把半个路径或半个名称当作有效配置。

十四、建议的测试清单

不要只用一份正常配置做测试。至少覆盖以下情况:

测试类别示例关注点
基本读取标准段、键、值字符串、整数、布尔值正确
大小写[network]、PORT是否符合预期匹配
分隔符key=value、key:value两种形式是否一致
空白键值两侧加入空格和制表符是否正确清理
注释整行和行尾;、#是否误入结果
引号"hello;world"注释符号是否保留为值
同名键不同段中都有port是否只搜索目标段
键前缀port与port_backup是否避免误匹配
缺段目标 section 不存在默认值或失败状态
缺键目标 key 不存在默认值或失败状态
空值key=与缺键是否能正确区分
数字格式十进制、十六进制、非法尾字符是否进行严格校验
布尔格式yes/no、true/false、1/0、非法文本默认值规则是否正确
最终换行有或没有最后一个\n成功和失败路径是否都正常
长行行长等于或超过缓冲区不越界,错误可观测
输出截断值长超过输出缓冲区不错误接受不完整值
无 NUL 输入只提供字节数组和长度有界后端能否安全停止
并发解析两个上下文读取不同文本是否互相影响
栈余量最深ini_getl()调用链是否满足任务栈要求

其中长行、无最终换行、无\0和并发用例,最能区分“适配某个固定输入的代码”和“可复用解析模块”。

十五、文件后端和内存后端应该怎样选择?

场景建议
已有 FatFs、stdio 等文件系统优先使用官方文件后端
配置很大,只需读取少数字段文件后端逐键查询,避免整份进入 RAM
一次需要读取大量字段考虑ini_browse()一次遍历
配置只有几百字节,已经在 RAM内存后端更直接
配置存储在可随机读取的 Flash可实现带地址和偏移的后端
多任务同时解析不同配置使用独立上下文,避免全局游标
输入来源不可信必须使用有界读取和严格格式检查

核心原则不是“内存后端一定更好”,而是让存储方式、配置规模和使用方式匹配。

十六、总结

minIni 的价值,在于用很小的代码量解决了 INI 解析中最容易重复劳动的部分:

  • 段和键的查找;
  • 大小写不敏感匹配;
  • 等号与冒号分隔;
  • 空白和注释处理;
  • 双引号处理;
  • 字符串、整数和布尔值读取;
  • 可替换的存储后端。

把文件后端改成内存后端后,MCU 不需要文件系统,也能直接解析一段配置文本。但为了让它从“固定场景可用”变成“通用模块可靠”,还需要补齐四件事:

  1. 用数据长度约束输入,而不是只依赖\0;
  2. 用输出容量约束逐行复制;
  3. 用上下文替代全局AtLine;
  4. 用明确状态和严格转换替代模糊的默认值判断。

做到这些,才能既保留 minIni 小、简单、无动态内存的优势,又获得清晰的边界、并发语义和错误行为。


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

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

立即咨询