从文件到内存: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 不擅长表达多层嵌套和复杂数组,但它的优点也很明显:
- 人可以直接阅读和修改;
- 解析规则简单;
- 不需要构建 DOM 树;
- 可以按段和键直接查找;
- 很适合只有几十项配置的 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 = 502minIni 也支持位于任何段之前的全局键。调用时把段名设为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 或自定义后端这种设计带来两个好处:
- 有文件系统时,可以直接使用现成后端;
- 没有文件系统时,可以只替换 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;}它做四件事:
- 清空行缓冲区;
- 检查当前位置是否已经到文本末尾;
- 一直扫描到
\n或\0; - 把当前行复制到行缓冲区,并移动全局偏移。
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。
对于端口号、地址、尺寸等需要严格校验的配置,更稳妥的做法是:
- 用
ini_gets()取得原始字符串; - 使用带
endptr的转换函数; - 检查是否至少转换了一个字符;
- 检查
endptr是否到达字符串末尾; - 检查结果是否在业务范围内。
十二、怎样设计一个更通用的内存后端?
推荐把数据源和解析位置显式封装:
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);它应遵守以下规则:
ctx->pos >= ctx->size时返回 EOF;- 只访问
data[0 ... size-1]; - 始终给输出行保留一个
\0; - 行太长时返回明确的截断或格式错误状态;
- 区分“由换行结束”和“由文本末尾结束”;
- 不修改其他解析上下文。
整体结构如下:
┌─────────────────────────────────┐ │ 应用:指定 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 不需要文件系统,也能直接解析一段配置文本。但为了让它从“固定场景可用”变成“通用模块可靠”,还需要补齐四件事:
- 用数据长度约束输入,而不是只依赖
\0; - 用输出容量约束逐行复制;
- 用上下文替代全局
AtLine; - 用明确状态和严格转换替代模糊的默认值判断。
做到这些,才能既保留 minIni 小、简单、无动态内存的优势,又获得清晰的边界、并发语义和错误行为。