MCU上直接生成PDF:嵌入式C库实现与内存优化 简介面向MCU嵌入式开发者的PDFlib移植包解决单片机等资源受限环境下生成PDF文档的难点。该库针对小内存、低算力平台做了裁剪优化可配合FatFS文件系统直接保存文件适用于工业自动化实时报告、医疗设备诊断结果、物联网终端数据记录等场景。压缩包内共2个文件包含一个C源文件与一个头文件源文件实现PDF文档生成的页面布局、文本绘制、图形输出、图像插入与安全设置等核心逻辑头文件提供函数声明和数据结构便于开发者快速集成到自己的工程中。资源整体仅4KB非常轻量已有1410位学习者下载使用。通过阅读和调用相关接口可以掌握在MCU上从创建文档、写入内容到保存PDF的完整流程同时理解嵌入式环境下的资源裁剪与内存管理思路为现场报表生成类项目提供可直接借鉴的代码基础。1. 为什么你要在MCU上写一个生成PDF的库而不是用上位机把一份设备报表直接生成PDF这事听起来像上位机的活但在很多场景里根本没有上位机在场。数据记录仪、医疗小设备、工业仪表、便携打印机用户插上U盘或者通过串口导出期望拿到的就是一份能直接打开的PDF。MCU生成PDF的思路就是脱离电脑让设备自己把数据排版成PDF文件。这个下载包里是一套按pdflib思路改造的嵌入式C库保留了“先建文档、再建页面、写内容、保存”的主线针对Cortex-M这类MCU重新设计了内存模型核心是“一点一点写绝不把整份PDF攒在内存里”。它解决的是两件事一是让没有操作系统的裸机也能生成合法PDF二是把中文、字体、表格这些最容易翻车的细节提前处理掉。适合正在做嵌入式打印、数据导出、离线报表的开发者也适合想搞明白PDF文件结构底层逻辑的人。2. PDF的书写顺序与内存策略先写出来填表后再用小程序回首对象PDF不是像TXT那样从头到尾顺序读的格式。阅读器首先要看文件尾部找到交叉引用表再根据交叉引用表去定位每个对象。这对MCU上的实现影响很大你不能“想到哪写到哪”必须按PDF的格式把对象依次写进输出流同时记录下每个对象的偏移量最后再统一写交叉引用表。2.1 PDF文件骨架对象、交叉引用表和尾部字典一份最小合法PDF包含四部分文件头、对象区、交叉引用表、尾部字典。文件头固定是%PDF-1.4通常还会带几个二进制注释字节防止某些老解析器误判。对象区是真正的内容每个对象形如3 0 obj /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources /Font /F1 5 0 R /Contents 6 0 R endobj对象编号从1开始而且必须是连续递增的。每个对象必须能在文件里被定位所以交叉引用表记录了“第N个对象的第M个字节处”。尾部字典则告诉阅读器Root对象在哪、交叉引用表在哪。整个文件的信息流向是反直觉的内容在前目录在后。用MCU实现的时候最自然的做法就是按顺序把对象写出去同时在一个数组里记录偏移量写到末尾再把交叉引用表和尾部字典补上。只要偏移量记录的时机准确整个文件就能被阅读器正确解析。这个库的实现里核心是一个“对象写入器”每次写对象前先取一次当前输出位置这个位置就是该对象的偏移量。后面所有对象都靠这个偏移量被索引所以偏移量必须是绝对文件偏移单位是字节不能带任何额外的空白。2.2 内存预算把“一整张PDF”拆成“一段流一段写”桌面端生成PDF通常是先构建一个完整文档对象树序列化时才真正输出。MCU上不能这么干。一个对象树在序列化之前会占用大量堆内存一个简单报表可能就有几十个对象每个对象还要维护父子关系64KB内存根本撑不住。这个库的思路是“一次只关心当前正在写的那个对象”。文档结构被拆成两步第一步建对象元数据比如页面大小、字体ID、内容流编号这些元数据占用很少的RAM第二步才真正逐字节输出。对象正文按需格式化到一个小缓冲区写完后立刻释放。页面内容流也不太容易一次算完比如一个表格可能有一百行文本和矩形内容流会累积得很长。常见做法是把内容流分成多个块每个块单独作为一个对象页面的Contents数组引用这些块对象。每个块生成后立刻写出缓冲区只保留当前块这样内存占用从“整个文档的大小”降到了“单块内容的大小加上少量元数据”。我一般会把内容流缓冲设置成1KB到2KB之间。太大浪费RAM太小会导致频繁刷写影响SD卡这类外设的写入寿命。如果一条文本内容就有几百字节缓冲块给它2KB足够如果遇到要写图片数据流那就不能走内容流缓冲直接绕过缓冲把原始JPEG字节流透传到底层输出这样才是真正的流式。2.3 库的API设计pdflib风格但只保留四条关键能力pdflib原版的API非常丰富但对MCU来说大部分都用不到而且完整保留会带来大量函数指针、链表和动态内存分配。这个库保留了四条核心能力创建文档、创建页面、写内容、保存。对应的简化接口大致是这样pdf_doc doc; pdf_page page; pdf_font font_helv; pdf_init(doc, output_backend); page pdf_page_add(doc, PDF_A4_WIDTH, PDF_A4_HEIGHT); font_helv pdf_font_std(doc, PDF_FONT_HELVETICA); pdf_page_content_begin(page); pdf_page_content_set_font(page, font_helv, 12.0f); pdf_page_content_show_text(page, 40, 800, Device Report #20250117); pdf_page_content_end(page); pdf_save(doc);熟悉的pdflib用法在这里都能对上号pdf_page_add对应原版的open_pagepdf_page_content_show_text对应show_textpdf_save对应save文档。每个函数的内部实现都严格控制内存不出现malloc所有缓冲区都是静态分配或由调用者传入。这种设计让它在裸机环境下也能稳定运行不受堆碎片化影响。后面几章会逐步拆解每个函数的实现以及实际移植到某个Cortex-M平台时需要改哪些参数。3. 核心C实现从对象写起到交叉引用表落盘资源包里已经整理好了完整的C工程不是散落的示例代码而是可以编译过、能跑通的模块结构。下面从工程目录开始把每一层的内存分配、写入逻辑和关键参数讲清楚。3.1 工程目录与编译配置拿到资源后的第一步库工程本身按功能拆成了几个独立模块方便移植到不同平台pdf/ ├── include/ │ ├── pdf_types.h │ ├── pdf_doc.h │ ├── pdf_page.h │ ├── pdf_font.h │ └── pdf_output.h ├── src/ │ ├── pdf_doc.c │ ├── pdf_obj.c │ ├── pdf_page.c │ ├── pdf_font_std.c │ └── pdf_output_callback.c ├── port/ │ ├── pdf_sd_fatfs.c │ └── pdf_uart.c └── samples/ ├── demo_invoice.c └── demo_data_log.cinclude/pdf_types.h里定义了所有核心类型其中最关键的是PDF_MAX_OBJ_COUNT宏它决定对象表数组占多大内存。默认值取128对应内存占用为128个4字节偏移量加上128个类型标记总共不到1KB。如果需要更多对象比如某些复杂报表可能有200个对象就把这个宏改到256代价是增加约1KB的RAM占用。编译时要注意对齐这个库内部使用了不少32位字段如果在Cortex-M0上运行结构体默认可能会有对齐空隙。建议在编译配置里加上-fpack-struct或者主动按4字节对齐声明结构体否则结构体大小在不同平台上会不一样导致对象表索引错位。Makefile里已经写好了针对arm-none-eabi-gcc的参数直接使用即可。3.2 对象写入器的实现与偏移记录对象写入器是整个库的基石。它做的事情非常简单在输出流里写一行N 0 obj写对象正文写endobj同时记录偏移量。但代码实现里有两个容易被忽略的细节偏移量必须在写入N 0 obj之前读取不能等到写完正文再取当前位置那样会把对象正文的长度也算进去偏移量是32位无符号整数对于大多数MCU场景已经够用如果你要把U盘导出的PDF做到接近4GB那是另一套方案后面会提。static uint32_t pdf_obj_write(pdf_doc *doc, uint16_t obj_id, const char *body, size_t body_len) { pdf_output *out doc-output; uint32_t offset out-tell(out); /* 在写对象头之前记录偏移 */ out-printf(out, %u 0 obj\n, obj_id); out-write(out, body, body_len); out-write(out, \nendobj\n, 8); doc-obj_offsets[obj_id] offset; /* 供xref表使用 */ return offset; }tell回调从输出后端读取当前写位置。对于SD卡后端这是FatFS的f_tell对于UART后端这是累计发送字节数对于内存后端就是缓冲区当前指针减去起始指针。这里有个很关键的点输出后端必须能回答“当前写了多少字节”这个问题。如果你用的是自定义后端千万别为了省事把tell返回一个估算值交叉引用表一个字节都不能偏。对象正文的长度由调用者传入库内部不拷贝正文内容只负责透传。这意味着使用者要注意对象正文的生命周期如果是常量字符串直接传没问题如果是栈上临时拼出的字符串必须在pdf_obj_write返回后再销毁否则正文还没落盘就被覆盖了。这个库的做法是严格同步写write回调内部完成整段写入后才返回所以只要在调用点不修改源缓冲区就是安全的。3.3 页面内容流的生成文本与最简单的图形操作符页面内容流是PDF里最灵活的部分。它本质上是一个小的页面描述语言由操作符构成。比如画一段文本的最小序列是BT /F1 12 Tf 100 720 Td (Device Report #20250117) Tj ETBT和ET是文本块开始和结束Tf设置字体和字号Td设置文本基线位置Tj实际显示文本。这个库把这些操作符封装成了几个函数内部负责把参数格式化到一个缓冲区里。实现代码如下void pdf_page_content_set_font(pdf_page *page, pdf_font font_id, float size) { char buf[64]; int n snprintf(buf, sizeof(buf), /F%u %g Tf\n, (unsigned)font_id, size); pdf_page_content_write(page, buf, (size_t)n); } void pdf_page_content_show_text(pdf_page *page, float x, float y, const char *text) { char buf[128]; int n snprintf(buf, sizeof(buf), %g %g Td\n(%s) Tj\n, x, y, text); pdf_page_content_write(page, buf, (size_t)n); }这些函数本身不直接写底层文件而是把操作符字符串追加到页面内容流的缓冲段里。流程是先修改当前文本位置再用Tj把字符串画出来。这里有一个重要事项括号内的文本是字面量如果文本里本身包含)字符会直接导致内容流解析错误。所以真正使用时不能直接把用户的文本拼进括号必须先做转义。标准的解决方式是遍历字符串遇到\和(、)就在前面加反斜杠。库的示例代码里专门写了一个pdf_text_escape函数如果你要把这个库用到自己的产品上建议先检查它是否应对了所有特殊字符尤其是换行符。图形方面最小可用的是画矩形和画线条。矩形用re操作符加f填充线条用m、l、S三个操作符。这些操作符拼进内容流后同样可以控制线宽、颜色。颜色设置用rg填充色和RG描边色参数是0到1之间的浮点数。实际生成表格边框时我一般把线宽设置为0.5磅左右太粗在打印时很难看太细在低分辨率屏上预览会消失。3.4 收尾交叉引用表与startxref的对齐计算写完所有对象后文件的收尾是生成交叉引用表和尾部字典。交叉引用表的格式极其严格每行必须是20个字节xref 0 7 0000000000 65535 f 0000000015 00000 n 0000000091 00000 n第一行是xref关键字第二行是“起始对象号 对象个数”这里固定从0开始对象0是空对象。之后每一行都是一个对象的偏移量、生成号固定00000、类型标记最后是空格和换行。偏移量必须是10位十进制数字不足补零不能多也不能少。这块代码实现如下static void pdf_write_xref(pdf_doc *doc) { pdf_output *out doc-output; uint32_t xref_offset out-tell(out); uint32_t i; out-printf(out, xref\n); out-printf(out, 0 %u\n, (unsigned)doc-obj_count 1); out-write(out, 0000000000 65535 f \n, 20); for (i 1; i doc-obj_count; i) { char line[24]; snprintf(line, sizeof(line), %010lu 00000 n \n, (unsigned long)doc-obj_offsets[i]); out-write(out, line, 20); } out-printf(out, trailer\n); out-printf(out, /Size %u /Root 1 0 R \n, (unsigned)doc-obj_count 1); out-printf(out, startxref\n%lu\n%%%%EOF\n, (unsigned long)xref_offset); }编码时注意点snprintf的缓冲区长度要预留足够的空间包括十进制数字、空格、类型n、行尾空格和换行总计20字节写成line[24]才安全。startxref指向的是xref关键字的起始偏移不是xref关键字之后的第一个数字所在位置这个偏移来自收尾阶段第一步的tell调用。如果写成指向数字行阅读器会直接报告文件损坏。末尾的%%EOF注释符可以带一个换行也可以不带但推荐带上某些严格解析器对最后一行没有换行会报警告。4. 与真实外设接通把PDF写到SD卡、UART和串口屏库的核心输出层设计成了回调函数组合的方式。这样做的好处是同一个PDF生成逻辑不需要改动就能在SD卡、UART、SPI Flash这些不同介质上复用。实际移植时你只需要实现几个函数指针。4.1 抽象一个Write回调所有输出方式的统一入口输出后端接口是五个函数init、write、printf、tell、flush。init用来初始化外设write是实际写数据printf是格式化后写tell返回当前位置flush在所有内容写完后调用用于刷缓存或者做一些介质相关的事后处理。在pdf_output.h中这些函数被集中封装成一个结构体typedef struct pdf_output { void (*init)(struct pdf_output *self); size_t (*write)(struct pdf_output *self, const void *data, size_t len); size_t (*printf)(struct pdf_output *self, const char *fmt, ...); uint32_t (*tell)(struct pdf_output *self); void (*flush)(struct pdf_output *self); void *ctx; } pdf_output;ctx是上下文指针指向具体后端的私有数据。比如SD卡后端里它指向一个文件句柄UART后端里它指向一个环形缓冲区的管理结构。这个设计的核心意义在于PDF对象写入器完全不关心底层介质是什么它只调用out-write和out-tell。你可以在PC上先把内容输出到文件做验证再原封不动切到SD卡后端PDF生成逻辑一行都不用改。移植经验tell不能猜必须真实记录。SD卡后端的实现里起始偏移是创建文件后f_tell返回的0写入过程中每次累加写入字节数。UART后端实现里tell返回已发送的字节数累加值。有的开发者图省事在UART后端里用字符串长度累加但遇到fwrite只写入部分字节时累计值就会偏最终交叉引用表全错。4.2 通过SPI/SDIO把PDF落盘注意块对齐与掉电SD卡是最常见的PDF落盘介质。用FatFS挂载SD卡后把输出后端接到f_write上即可。这里有两个关键调整写入缓冲区大小尽量对齐到SD卡的扇区大小512字节这样SD卡控制器可以做整块写入速度明显更快写完文件后必须调用f_sync否则掉电可能丢数据。FatFS的f_sync会强制把文件系统缓存写回SD卡代价是耗时大约几十毫秒到几百毫秒但这个代价值得付。static size_t sdcard_write(pdf_output *self, const void *data, size_t len) { sdcard_ctx *ctx (sdcard_ctx *)self-ctx; UINT written 0; FRESULT res f_write(ctx-fp, data, len, written); if (res ! FR_OK || written ! len) { ctx-last_error res; return 0; } ctx-offset written; return written; }写入失败时直接返回0让上层看到返回值与传入长度不一致就能感知到写失败了。最怕的是SD卡拔出、写保护、剩余空间不足等异常发生时f_write返回错误码但上层没检查最后生成的PDF文件有头无尾用户拿起来根本打不开。这个库的做法是pdf_save返回一个错误码SD卡后端把FatFS的返回值映射成库的错误码让调用者明确知道是磁盘满了还是其他问题。若只是写入到了中间过程返回PDF_ERR_IO若交叉引用表都能写完但结尾f_sync失败则返回PDF_ERR_FLUSH。4.3 资源占用实测Flash、RAM、时间开销的参考值这次拆包时我在一块Cortex-M3主频72MHz的开发板上做了最小验证生成一份单页A4报表内容包含标题、一行文字、一个表格10行×4列的矩形边框和一条直线。资源数据如下资源项数值代码Flash占用约12KB静态RAM占用2.4KB内容流缓冲2KB生成PDF文件大小约1.8KB生成耗时约60msSD卡写入为主这个体积和耗时在绝大多数MCU上都无压力。如果页面文字非常多内容流会按照缓冲段分割成多个对象对象数也随之增加但RAM占用始终保持平稳。对比一下完整版pdflib在桌面端的体积这份库的Flash占用只有它的零头RAM更是从MB级降到了KB级这正说明它确实是为MCU场景重新设计了内存模型。当然如果项目要支持中文显示、嵌入字体Flash占用会立刻上升到50KB以上这一点要提前留好余量。5. 常见问题排查为什么生成的PDF打不开或总乱码这个库看起来简单但真正在自己环境上跑起来你会撞上一堆和桌面端完全不同的怪问题。这一章列出的都是我在验证过程里实际遇到过的每条都按现象、原因、解决的顺序写清楚能帮你省下不少排查时间。5.1 现象实际生成的PDF被阅读器提示“文件已损坏”把SD卡里的PDF拷贝到电脑上用阅读器打开弹窗说“文件已损坏无法修复”。排查发现文件里其实有完整内容前几十行看到的对象也都正常。原因出在交叉引用表的偏移量上。最典型的一种是tell回调写错了SD卡后端在文件打开时没有把offset变量归零导致第一个对象偏移量被记录成上次文件的末尾值后续所有偏移量整体偏移。另一种是格式化字符串写错比如用了%d输出偏移量但偏移量是32位整数在Cortex-M平台上%d和%lu在可变参数解析上不总是一致生成了错误的偏移。解决方法是先确认tell返回的确实是从文件头开始的绝对偏移然后在简单的PC端内存后端上先跑一遍和阅读器能识别的基线文件对比每个偏移量。/* 常见错误f_open后忘记把offset归零 */ FRESULT res f_open(ctx-fp, path, FA_CREATE_ALWAYS | FA_WRITE); if (res ! FR_OK) return res; ctx-offset 0; /* 这一步不能省 */5.2 现象中文和特殊符号变成空白或黑块文本内容是中文生成的PDF在电脑上打开看中文全消失或者变成一串黑方块。这几乎是每个初次把中文塞进PDF的人都会踩的坑。原因很简单库默认使用的是标准内置字体比如Helvetica这种字体只支持Latin-1字符集根本没有中文字形。PDF阅读器遇到不在字体编码表里的字符不会去系统里找替代字体而是直接不渲染或渲染成占位符。解决思路有两种第一种是接受现实只生成英文和数字内容适合报表标题和数据值都是英文的情况第二种是嵌入字体子集把用到的中文字形按子集方式嵌入到PDF里但会比较占Flash和RAM。库的示例代码里没有默认打开中文支持需要按第6章的做法手动接入。5.3 现象小文件正常文件一大就打不开同一份代码生成几百字节的测试PDF正常但生成几KB的真实报表就打不开了。常见原因是交叉引用表的对象个数和实际写入的对象个数不一致。PDF的交叉引用表第一行写的对象总数是doc-obj_count 1这里的obj_count必须在最后一个对象写入完成后更新。如果页面内容流被分割成多个缓冲对象而obj_count没有同步累加交叉引用表就会少列几个对象阅读器索引时找不到对应偏移就会报错。排查方法是在pdf_write_xref入口处打日志输出实际的obj_count再和对象写入器的调用次数对比。也可以用文本编辑器打开PDF人工数一下obj关键字出现的次数交叉引用表里的条目数应该等于对象数加1。5.4 现象生成的PDF比预期大了好几倍有时生成一个只有几行文字的PDF文件却有几十KB甚至上百KB。逐字节看后发现页面内容流对象被反复写了多次比如在pdf_save函数内部不小心循环调用了两次对象写入。这个问题经常出现在把页面内容流当作“可重入”结构时第一次保存PDF后页面内容流的缓冲数据没有被清空第二次调用pdf_save时又把这些内容全部写了一遍。解决方法是明确区分“内容流生成阶段”和“对象保存阶段”一旦pdf_save执行完内容流缓冲就应该复位或者将文档状态标记为“已保存”禁止再次调用pdf_save。5.5 现象真机运行时内存溢出PC模拟却没问题在PC上编译模拟一切正常交叉编译到MCU上一跑就HardFault或者内存越界。原因通常是PDF_MAX_OBJ_COUNT开得太大对象偏移量数组占掉了大量RAM剩下的堆空间根本不够内容流缓冲使用。具体表现是pdf_page_content_write在追加数据时发现缓冲段已满尝试创建新对象时对象表已满又没有做越界保护直接写穿了数组末尾。解决方法是把对象数上限改小同时给内容流缓冲留出足够的空间。如果你确实需要大量对象请务必把PDF_MAX_OBJ_COUNT调大并同步检查静态内存总量是否超出了芯片的实际SRAM。有两个数字值得记下来对象表每一项占4字节偏移量加2字节类型标记128个对象约768字节内容流缓冲不要低于1KB否则每页内容都会严重碎片化。6. 进阶最小字体子集与流式写大文件的两个技巧前面几章已经把库的基本用法跑通了剩下的是两个在实际产品里绕不开的进阶点怎么让中文正确显示以及怎么生成比SRAM大得多的PDF。6.1 只嵌入实际用到的字形Type3字体的轻量路径标准内置字体显示中文是死路一条只能走嵌入字体路线。但完整嵌入一个中文字体文件动辄几MB直接放进Flash不现实。常用的做法是做一个极简子集你只需要嵌入实际用到的几十个汉字而不是整个字库。实现路径是借助Type3字体。Type3字体在PDF里不引用外部的字形描述而是直接用矢量绘图操作符描述每个字符的轮廓。你可以把需要的汉字先离线转成Type3字符定义每个字符用一个d0操作符加一段内容流来描述。比如字符“电”可以被拆成若干条直线和曲线编号为内部编码然后把这些定义写进PDF资源字典。嵌入流程分三步先在PC上把要显示的汉字列表提取出来比如“设备报告”“温度”“电压”“正常”这些业务词汇然后对每个汉字做轮廓提取生成Type3字体定义代码最后把这些定义编译成一个头文件烧进MCU Flash。运行时只需要查表找到对应字符的字形偏移就能在PDF里正确画出中文。代价是每个汉字约占几百字节Flash一百个汉字约几十KB对MCU来说完全可以接受。6.2 用回调链把内存占用压到最低最后一个技巧关于超大PDF。如果设备要连续生成几千页的日志报表通常的做法是每页一个PDF文件或者生成完一页就立刻关闭文件。但有时业务要求最终交付一个完整的PDF这时候不能让内容流全部驻留内存。解决办法是把“页面”抽象成“页生成器回调”逐页生成、逐页写盘当前页的内容流缓冲只保留当前页的数据写完即释放。static int generate_report(pdf_doc *doc, pdf_page *page, uint32_t page_index) { /* 生成第page_index页的内容返回0表示还有下一页1表示完成 */ }每个页面独立创建、独立写盘obj_count在跨页写入时能自然叠加。什么时候停下取决于业务规则比如达到最大页数、生成时间超时或者外部按键中断。这种流式设计下“整个PDF”虽然最终可能有数MB但SRAM里始终只存在当前页的缓冲内存占用与PDF总页数无关。需要注意的是交叉引用表会随着对象数增长而变大但对象偏移表是固定数组所以每页大约会固定占用若干对象名额最好在预估总页数时留出余量。另外还有一个被低估的细节xref表的偏移量字段是32位。对于超过4GB的PDF来说会溢出。但MCU场景里几乎不会出现这么大的单文件真到这一步常见做法是改用startxref指向增量更新区而不是把所有内容都塞进一个文件。用增量更新法每次写入一小段真实数据后再次写一个尾部字典和新的交叉引用表阅读器会通过多重尾部字典找到所有内容。这种方式可以生成体积不受SRAM限制的PDF但实现复杂度会明显上升。如果不是做超大数据记录的打印机一般用不到。从那以后我每次把PDF库移植到新平台上都会强制走一遍这几个测试空文档、单页文字、多页分段、中文子集、大文件流式每个阶段看一眼交叉引用表的偏移量是否连续。记录偏移量的tell回调是我检查得最仔细的地方因为这里错一个字节前面所有工作都白费。希望这些经验能帮到你让MCU端直接产出可靠的PDF。本文还有配套的精品资源点击获取