做C语言开发的人迟早会遇到一个问题:程序里的数据越攒越多,用文件存已经顶不住了。以前我写小工具,清一色文本文件加CSV,简单是简单,一旦要按条件查数据、要多次更新中间记录、要多个进程同时读写,文件方案马上就乱成一团。后来我把项目里的存储层整体换成SQLite,才真正省下心来。SQLite在C语言里就是一个嵌入式关系型数据库,不单独跑服务,一个数据库就是硬盘上的一个文件,C程序通过一套函数接口直接操作它。
这篇文章把我在C语言中实际使用SQLite完成增删改查的完整套路讲透,包括SQL语句怎么组织、参数怎么绑定、事务和性能要注意什么,以及那些文档里不会明说的坑。目标读者是想给C程序加本地数据库功能的人,无论你是刚学完C语法、准备做课程设计的学生,还是写了多年嵌入式代码想改进数据管理的工程师,都能直接照着做。
1. 为什么C程序里偏偏选SQLite做本地存储
1.1 文件存储方案的三道坎
很多C语言项目起步阶段都用最朴素的文件存储:一行一条记录,逗号分隔,或者固定宽度文本。数据量在几百行的时候很舒服,读写都直白。但等到数据量上千、上万,或者出现下面几种需求时,文件方案就会变成折磨:
第一,条件查询。文件方案只能全扫描,想找“去年发布且价格大于50”的记录,你得逐行读进来解析,再自己写过滤逻辑。第二,并发写入。两个进程同时往同一个文件里追加数据,要么互相覆盖,要么需要自己维护锁文件,锁文件还会因为进程崩溃变成僵尸锁。第三,局部更新。文件方案“更新一条记录”基本等于“读出整个文件 -> 改这一行 -> 写回整个文件”,数据一大就疼得不行。
SQLite解决的就是这个问题:它把这些脏活全包了,让C程序通过标准的SQL语句来查询和修改,SQL语句写好了,剩下的数据组织、索引、事务全都由数据库引擎处理。
1.2 SQLite的定位:嵌入式数据库内核
我一直愿意把SQLite理解成“一个可以被C程序直接调用的数据库内核”,而不是“迷你版MySQL”。它和客户端服务器型数据库有本质区别:不需要单独启动服务进程,不需要端口监听,不需要用户权限系统,你的程序里直接调用sqlite3_open(),传一个文件路径,数据库就打开了。
一个SQLite数据库就是磁盘上的单一文件。想迁移,拷走这个文件就行;想备份,直接复制文件;想测试,把它放在临时目录里用完就删。这种简洁性对C语言项目非常友好,尤其是桌面工具、嵌入式设备、离线分析程序这类场景。
需要说清楚的是,SQLite不是万能的。它不适合做高并发的写入服务,几十个进程同时高频写同一个库文件,性能和锁竞争都会让你难受。但在“本地程序 + 单用户或低并发”这个半径里,它几乎是最优雅的存储选择。
1.3 什么项目适合用它
根据我这些年的实践,下面几类C项目非常适合接入SQLite:
- 桌面数据管理工具,比如本地账本、物资盘点、笔记检索程序。
- 嵌入式设备上的数据记录器,传感器采集的数据先落SQLite,再由程序做统计和清理。
- 游戏存档系统,把角色属性、背包物品结构化存进单文件,比自定义二进制格式省心得多。
- 程序配置文件,比INI和JSON更适合复杂结构,查询和局部更新也方便。
- 数据处理链路的中间层,A程序写入SQLite,B程序读取,中间不用约定复杂的私有协议。
判断标准只有一个:你的C程序需要一个本地、可靠、结构化、支持SQL语句的数据仓库,并且不想为了数据库单独架一台服务。
2. 环境准备:链接SQLite库与最简初始化
2.1 三种方式拿到SQLite库
在C语言里使用SQLite,首先得让编译器能找到它的头文件和库。根据项目环境不同,一般有三种方式:
第一种,用系统包管理器安装。Debian系环境执行sudo apt install libsqlite3-dev,红帽系环境用对应的yum或dnf包名相似。装完后头文件在/usr/include/sqlite3.h,链接库为libsqlite3.so。这种方式最适合本机开发快速跑起来。
第二种,从官网下载SQLite源码合并包。SQLite提供了两个核心文件,sqlite3.c和sqlite3.h,直接把它们丢进你的工程一起编译。这种做法的最大好处是和系统库版本解耦,你想用哪个版本就用哪个版本,嵌入式交叉编译时这是常规操作,因为目标板子上未必有现成的库。
第三种,使用编译好的二进制库文件。适合IDE开发或者Windows环境,把对应的.dll或.lib配置进工程即可。
我在多数项目里倾向直接采用系统库,等到真正做嵌入式目标板适配时再切换到源码编译。
2.2 数据库连接的建立与关闭
C语言操作SQLite的一切都围绕sqlite3 *这个句柄展开。打开数据库用sqlite3_open(),它接收两个参数,第一个是数据库文件路径,第二个是“指向句柄指针的指针”,这一点新手常绕不过来:
sqlite3 *db = NULL; int rc = sqlite3_open("mydata.db", &db); if (rc != SQLITE_OK) { fprintf(stderr, "open db failed: %s\n", sqlite3_errmsg(db)); sqlite3_close(db); return -1; }sqlite3_open()如果文件不存在会尝试创建。还有一个sqlite3_open_v2(),可以让你显式指定打开方式,比如SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE,以及多线程相关的标志位。日常做本地工具用sqlite3_open()足矣。
关闭数据库用sqlite3_close(db)。注意,如果当前连接上还有未释放的语句对象,也就是sqlite3_stmt *没有sqlite3_finalize(),关闭会返回SQLITE_BUSY甚至导致资源泄漏。正确的习惯是:先清掉所有语句,再关数据库。
2.3 一个能跑的“建表+插入+查询”示例
很多网上的资料喜欢把API拆得零零散散,我直接给一个可以整体跑通的最简程序,你复制到本地编译运行,就能直观感受到SQLite在C语言里是怎么工作的:
#include <stdio.h> #include <sqlite3.h> int main(void) { sqlite3 *db = NULL; char *errmsg = NULL; int rc = sqlite3_open("school.db", &db); if (rc != SQLITE_OK) { fprintf(stderr, "open db failed: %s\n", sqlite3_errmsg(db)); sqlite3_close(db); return 1; } const char *sql_create = "CREATE TABLE IF NOT EXISTS student(" "id INTEGER PRIMARY KEY AUTOINCREMENT," "name TEXT NOT NULL," "score REAL DEFAULT 0);"; rc = sqlite3_exec(db, sql_create, NULL, NULL, &errmsg); if (rc != SQLITE_OK) { fprintf(stderr, "create table failed: %s\n", errmsg); sqlite3_free(errmsg); sqlite3_close(db); return 1; } const char *sql_insert = "INSERT INTO student(name, score) VALUES('Alice', 95.5);"; rc = sqlite3_exec(db, sql_insert, NULL, NULL, &errmsg); if (rc != SQLITE_OK) { fprintf(stderr, "insert failed: %s\n", errmsg); sqlite3_free(errmsg); sqlite3_close(db); return 1; } const char *sql_query = "SELECT id, name, score FROM student;"; sqlite3_stmt *stmt = NULL; rc = sqlite3_prepare_v2(db, sql_query, -1, &stmt, NULL); if (rc != SQLITE_OK) { fprintf(stderr, "prepare failed: %s\n", sqlite3_errmsg(db)); sqlite3_close(db); return 1; } while (sqlite3_step(stmt) == SQLITE_ROW) { int id = sqlite3_column_int(stmt, 0); const unsigned char *name = sqlite3_column_text(stmt, 1); double score = sqlite3_column_double(stmt, 2); printf("%d\t%s\t%.2f\n", id, name, score); } sqlite3_finalize(stmt); sqlite3_close(db); return 0; }这段代码把最核心的API全走了个遍:sqlite3_exec执行建表和插入,sqlite3_prepare_v2准备查询语句,sqlite3_step逐步取行,sqlite3_column_int/text/double取列值。跑通它,你就已经掌握了SQLite在C语言里的全部主干用法。
2.4 新手常见的编译链接陷阱
如果你用的是系统库,编译时最容易栽在链接参数上。命令长这样:
gcc demo.c -lsqlite3 -o demo这里的关键是-lsqlite3必须放在源文件后面。如果编译时报“undefined reference to sqlite3_open”,十有八九是漏了-lsqlite3,或者把链接参数放到了源文件前面。GNU工具链对链接顺序是敏感的,被它坑过的人不在少数。
还有一个坑是文件路径。sqlite3_open("school.db", &db)里的是相对路径,它相对的是“程序运行时的工作目录”,而不是可执行文件所在目录。如果程序从别的目录启动,数据库文件就会跑到那个目录去。项目里所有数据库路径最好根据实际场景拼接成绝对路径,或者统一在启动时切换到固定工作目录。
3. 执行SQL语句的两种姿势:exec与prepared statement
3.1 sqlite3_exec:适合“一把梭”的场合
sqlite3_exec()是SQLite提供的便捷函数,一条SQL语句从编译到运行全包了,典型原型如下:
int sqlite3_exec(sqlite3 *db, const char *sql, int (*callback)(void *, int, char **, char **), void *arg, char **errmsg);第二个参数就是一条完整的SQL语句字符串。第三个参数回调函数,用于处理查询返回的每一行;如果你跑的是CREATE TABLE、INSERT这类不需要取行的语句,回调传NULL即可。最后一个errmsg用来接收错误信息,用完必须sqlite3_free()。
sqlite3_exec的优点是简单,缺点是它把所有细节都藏起来了。如果你想给SQL语句传入参数,想分步骤读取查询结果,想复用同一个语句插一万条数据,直接用它就会非常别扭。所以我的使用习惯是:只有线程安全的、完全静态的SQL语句才用它,典型就是建表、开事务这类DDL或控制语句。
3.2 prepare/step/finalize三步法:正式做事的正确姿势
真正工程化的SQLite用法,是“预处理语句”这一套,它对应三个步骤:
sqlite3_prepare_v2()把SQL语句“编译”成一个sqlite3_stmt *句柄,相当于把SQL文本解析成数据库内部的执行计划。这一步执行后,数据库已经知道这条语句要干什么,后面可以反复执行。
sqlite3_step()负责“执行一步”。对会返回数据的查询语句,它每调用一次就返回一行结果,返回值是SQLITE_ROW;直到没有更多数据,它返回SQLITE_DONE。对INSERT、UPDATE、DELETE这类语句,一次sqlite3_step()通常就直接返回SQLITE_DONE。
sqlite3_finalize()释放语句句柄,和free()成对出现,忘了调用就会持续泄漏内存。
这三步法的好处很实在:SQL语句解析只需一次,之后可以反复执行;支持参数绑定,安全性远超字符串拼接;结果集可以逐行读取,处理大查询不慌。可以说在C语言项目里凡是需要重复执行的SQL语句,都该用这套流程。
一个经常被忽略的细节是:sqlite3_prepare_v2()的第三个参数是SQL语句字节长度,传-1表示“直到字符串结尾的\0”,这很简单。如果你用它的第四个参数传出一个“剩余未消耗的SQL指针”,就可以把多条SQL语句拼在一个字符串里依次预处理,但这种写法不推荐,调试时会让你怀疑人生。
3.3 什么时候选哪个,别拍脑袋
我用一张表总结这两个方案的适用边界:
| 情况 | 推荐方式 | 原因 |
|---|---|---|
| 程序启动时建表、初始化配置 | sqlite3_exec | 一次性执行,无需关心性能 |
| 用户输入关键字查询 | prepare_v2 + bind | 必须防止SQL注入 |
| 循环里Insert一万条 | prepare_v2 + bind | 解析一次,性能提升明显 |
| 批量更新某个固定字段 | prepare_v2 + bind | 逻辑固定、参数变化,绑定最合适 |
| 只执行一次的复杂DDL | sqlite3_exec | 写起来省事,没有额外状态 |
| 需要读取查询结果的任意场景 | prepare_v2 + step | exec的回调方式远不如逐行处理直观 |
一句话总结:带外部数据的一律prepared statement,无参数无返回的一次性语句用exec。这个准则能帮你避开大部分坑。
4. 参数绑定:写SQL语句时最该养成的习惯
4.1 字符串拼接SQL是定时炸弹
很多C程序员刚开始接触SQLite,会下意识地用sprintf()拼一条完整的SQL语句再执行,比如:
char sql[512]; sprintf(sql, "SELECT * FROM student WHERE name = '%s';", user_input);这样写,第一个问题是SQL注入。如果user_input是Alice'; DROP TABLE student; --,拼出来的SQL就成了两条语句,执行结果会把表删掉。别以为本地C程序没有攻击者,只要你的程序会读取外部文件、接收网络数据、解析命令行参数,拼接SQL就是把外部输入直接交给数据库引擎执行,等于引狼入室。
第二个问题是转义麻烦。用户名字里的单引号、反斜杠都要处理,不同编码下转义规则还不一样,你永远补不全。第三个问题是性能。每条SQL语句都要重新解析编译一遍,循环一多就慢。
真正的解法是参数绑定,把SQL语句和参数值彻底分开。
4.2 绑定参数的标准流程
先在SQL语句里写占位符?,然后按顺序把值绑进去。例如一条查询:
const char *sql = "SELECT * FROM student WHERE name = ?;"; sqlite3_stmt *stmt = NULL; sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); sqlite3_bind_text(stmt, 1, user_input, -1, SQLITE_TRANSIENT);注意,绑定参数的编号从1开始,不是从0开始。第几组程序员第一次用错下标,看到结果不对却找不到原因,这种经历大概都有。如果SQL里有多个占位符,就按出现的顺序编1、2、3……也可以使用?NNN显式指定编号,或者用命名参数:name,配合sqlite3_bind_parameter_index()查出编号。
绑定完参数后,照常sqlite3_step()执行。想复用同一个语句,就调用sqlite3_reset(stmt)把语句状态重置,然后重新绑定下一组参数。一个经典批量插入片段是这样:
const char *sql = "INSERT INTO student(name, score) VALUES(?, ?);"; sqlite3_stmt *stmt = NULL; sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); for (int i = 0; i < 10000; i++) { sqlite3_bind_text(stmt, 1, names[i], -1, SQLITE_TRANSIENT); sqlite3_bind_double(stmt, 2, scores[i]); sqlite3_step(stmt); sqlite3_reset(stmt); } sqlite3_finalize(stmt);4.3 绑定时最容易踩的四个坑
第一,没有绑定的参数默认值是NULL。不少人只绑了第一个参数,忽略第二个,结果数据库收到的是NULL而不是报错,数据插入后全是空值,排查半天才发现是漏绑了。
第二,sqlite3_bind_text()的第四个参数是文本字节长度,传-1表示让SQLite自己数到\\0结尾。如果你传入一个具体长度,字符串中间即使有\\0,SQLite也会按长度把后续字节一起存进去,这在处理二进制安全文本时很有用,但绝大多数场景直接传-1最保险。
第三,第五个参数SQLITE_TRANSIENT不能省略。它告诉SQLite:“我的指针可能在语句执行完之前就被修改或释放,你帮我复制一份。”如果传NULL,SQLite会直接保存指针,等你的字符数组在栈上被覆盖后,语句执行时读到的就是垃圾数据。这是超隐蔽的内存错误,建议无脑使用SQLITE_TRANSIENT。
第四,绑定函数要先匹配列类型。INTEGER列用sqlite3_bind_int()或sqlite3_bind_int64(),TEXT列用sqlite3_bind_text(),REAL列用sqlite3_bind_double(),BLOB列用sqlite3_bind_blob()。SQLite有“类型亲和性”,不代表你可以为所欲为,字符串“123”绑到INTEGER列,SQLite会尽力转换,但哪天转换规则和你想的不一样,就是那种半夜才发现的bug。
5. 读取查询结果:游标模型与列值处理
5.1 sqlite3_step如何驱动结果集
理解sqlite3_step()的最好方式,是把它想成一条“游标”。preprare之后,游标停在结果集第一行之前,每调用一次sqlite3_step(),游标就向下移动一行,返回值SQLITE_ROW代表当前位置有数据,SQLITE_DONE代表结果集走完了。
所以读全部结果的代码进程都长得一样:
while (sqlite3_step(stmt) == SQLITE_ROW) { // 用 sqlite3_column_xxx 取出当前行的列值 }这比sqlite3_exec的回调方式好读太多。回调方式把控制流倒过来,每出一行就回调一次,不仅代码被拆得七零八落,C语言里通过void *arg传上下文也是一层心智负担。用prepared statement加游标循环,才能像写普通读取循环一样自然地处理数据。
5.2 动态获取列名、列数与列类型
有一类需求是“把表的所有内容打印出来”,但事先不知道表有几列、每列叫什么名字。SQLite提供了对应的API:
int col_count = sqlite3_column_count(stmt); for (int i = 0; i < col_count; i++) { const char *name = sqlite3_column_name(stmt, i); int type = sqlite3_column_type(stmt, i); printf("column %d: %s, type code = %d\n", i, name, type); }sqlite3_column_type()返回的类型码是五个宏之一:SQLITE_INTEGER(值为1)、SQLITE_FLOAT(值为2)、SQLITE_TEXT(值为3)、SQLITE_BLOB(值为4)、SQLITE_NULL(值为5)。根据类型码来决定调用sqlite3_column_int()、sqlite3_column_double()、sqlite3_column_text()还是sqlite3_column_blob(),这样就能写一个通用的结果集打印函数,这在调试阶段非常香。
5.3 NULL、字符串与BLOB的取值细节
第一个细节:取列值之前最好先判断是不是NULL。如果直接对SQLITE_NULL列调用sqlite3_column_text(),它返回的是空指针。如果你不看返回值就直接printf("%s", ...),程序轻则打印空,重则崩溃。
第二个细节:sqlite3_column_text()返回的指针指向SQLite内部缓冲区,它在“下一次调用sqlite3_step()或sqlite3_finalize()之后”可能失效。所以需要跨循环保存的字符串,必须先复制到自己管理的缓冲区里。这个坑特别隐蔽,因为小数据量时缓冲区未必立即被改写,问题会延后爆发。
第三个细节:BLOB类型用sqlite3_column_blob()拿到二进制数据,同时用sqlite3_column_bytes()获取字节数。存文件、存图片、存网络包都靠这两个组合。千万不能用sqlite3_column_text()去处理BLOB,SQLite会把它当文本转换,二进制内容里如果含\\0,数据就拦腰截断了。
第四个细节:字符编码。SQLite内部可以存UTF-8、UTF-16等多种格式,但C接口默认按UTF-8返回。Windows控制台默认编码如果不对,打印中文可能乱码。你需要确保自己的C程序按UTF-8处理字符串,或者在读取之后做一次编码转换。这个在嵌入式比如中文字符串场景下非常常见,不要等到产品挂在客户那边才学会。
6. 错误处理、事务与并发:让程序更抗造
6.1 错误码与错误信息怎么应对
SQLite的每个API几乎都返回错误码,真正严肃的C程序应该养成“每次调用都检查返回值”的习惯。最常用的错误码有这几个:
| 错误码 | 含义 | 典型处理 |
|---|---|---|
| SQLITE_OK | 操作成功 | 继续执行 |
| SQLITE_ERROR | 一般SQL错误 | 打印errmsg,排查SQL语句 |
| SQLITE_BUSY | 数据库被其他连接锁定 | 设置busy_timeout,或重试 |
| SQLITE_LOCKED | 表被其他连接锁定 | 通常与事务隔离级有关,减少持锁时间 |
| SQLITE_CONSTRAINT | 违反约束 | 检查主键、唯一索引、外键 |
| SQLITE_MISUSE | API使用错误 | 检查是否重复finalize、跨线程使用连接 |
获取错误消息用sqlite3_errmsg(db),它返回最近一次错误的人类可读描述。我的经验是,任何一条SQL语句失败,第一时间把sqlite3_errmsg()的内容打到日志里,再结合错误码判断,基本能解决八成问题。唯一该注意的,是sqlite3_exec里的errmsg用完后必须sqlite3_free(),忘了就是内存泄漏。
6.2 事务:批量写入提速的关键
SQLite默认工作在“自动提交”模式,每一条INSERT或UPDATE语句都是一个独立事务,执行完立刻写盘。功能上没毛病,性能上却很糟糕。一次写盘背后有日志写入、文件同步、锁竞争一系列动作,循环一万次INSERT,等于做了一万次完整提交。
解决办法是显式开启事务,把一批操作包进去,最后再统一提交:
sqlite3_exec(db, "BEGIN TRANSACTION;", NULL, NULL, NULL); // 这里执行一万次 INSERT(用 prepared statement 绑定参数) sqlite3_exec(db, "COMMIT;", NULL, NULL, NULL);事务包裹下的批量插入,速度往往能提升一到两个数量级,原因就是日志落盘和文件同步从一万次降为一次。这是SQLite性能优化里性价比最高的一招,没有之一。
顺带提醒,事务开启后如果程序员忘了提交,程序崩溃时未提交的操作会全部回滚,数据不会损坏,但会让使用者觉得“刚才存的东西没了”。所以事务范围尽量短,该提交就提交,不要长时间挂着一个大事务。
6.3 并发读写:WAL模式与busy等待
本地应用有时也会有多个进程或线程同时访问同一个SQLite库文件,比如程序主进程读写数据,另外一个状态监控进程也要读。这种场景下默认的“回滚日志”模式会让读写互相阻塞,读进程很可能撞见SQLITE_BUSY。
解决问题的第一招是设置busy等待:
sqlite3_busy_timeout(db, 5000);它让SQLite在遇到锁冲突时等待指定毫秒数再重试,而不是立刻报错。5000毫秒是个比较温和的值,既能避免长时间卡顿,也能覆盖短小的锁冲突。
第二招是开启WAL模式:
sqlite3_exec(db, "PRAGMA journal_mode=WAL;", NULL, NULL, NULL);WAL模式把写入日志单独放在一个-wal文件里,读操作不受写操作影响,读和写可以并发进行,同时只有一个写者。很多桌面工具程序开启WAL之后,界面卡顿感明显下降。代价是会多出-wal和-shm两个辅助文件,备份时要注意一起复制或先做一次PRAGMA wal_checkpoint;。
关于多线程,SQLite的线程模式有不少细节,但我给一条最实用的建议:不要在多个线程里共享同一个sqlite3 *连接。每个线程自己sqlite3_open()一个连接,各用各的句柄,跨线程传递数据用队列或锁保护。这个做法几乎不会出错,省掉了研究详细线程模式的精力。
7. 完整案例:一个本地图书管理小工具
7.1 表结构与功能设计
为了把上面的API串起来,我写一个麻雀虽小、五脏俱全的本地图书管理小工具。它不需要界面,命令行交互就够,正好演示C语言与SQLite配合的完整链路。
表结构定义如下:
CREATE TABLE IF NOT EXISTS books( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, author TEXT NOT NULL, price REAL NOT NULL DEFAULT 0, published_year INTEGER );功能设计为四个:录入新书、列出所有书、按书名搜索、删除指定ID的书。每个功能都走prepare + bind + step这条标准路线,不依赖任何花哨语法,你完全可以在此基础上加自己的字段和逻辑。
7.2 初始化与核心操作的代码骨架
初始化先打开数据库,再建表。代码和第二章示例几乎一样,区别只是把SQL换成了books表。下面是新增图书的完整函数:
int add_book(sqlite3 *db, const char *title, const char *author, double price, int year) { const char *sql = "INSERT INTO books(title, author, price, published_year)" "VALUES(?, ?, ?, ?);"; sqlite3_stmt *stmt = NULL; int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); if (rc != SQLITE_OK) { fprintf(stderr, "prepare failed: %s\n", sqlite3_errmsg(db)); return -1; } sqlite3_bind_text(stmt, 1, title, -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 2, author, -1, SQLITE_TRANSIENT); sqlite3_bind_double(stmt, 3, price); sqlite3_bind_int(stmt, 4, year); rc = sqlite3_step(stmt); if (rc != SQLITE_DONE) { fprintf(stderr, "insert failed: %s\n", sqlite3_errmsg(db)); sqlite3_finalize(stmt); return -1; } sqlite3_finalize(stmt); return 0; }列出所有书的函数,对应的是带游标循环读取:
void list_books(sqlite3 *db) { const char *sql = "SELECT id, title, author, price, published_year FROM books;"; sqlite3_stmt *stmt = NULL; sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); while (sqlite3_step(stmt) == SQLITE_ROW) { int id = sqlite3_column_int(stmt, 0); const unsigned char *title = sqlite3_column_text(stmt, 1); const unsigned char *author = sqlite3_column_text(stmt, 2); double price = sqlite3_column_double(stmt, 3); int year = sqlite3_column_int(stmt, 4); printf("%d | %s | %s | %.2f | %d\n", id, title, author, price, year); } sqlite3_finalize(stmt); }搜索功能用LIKE加绑定参数:
const char *sql = "SELECT * FROM books WHERE title LIKE '%' || ? || '%';";这里的关键是占位符放在||表达式中间,而不是把%拼到C字符串里。SQLite的||是字符串连接运算符,这样写既防注入,又保留了模糊匹配的能力。删除功能只需要DELETE FROM books WHERE id = ?;加一个sqlite3_bind_int就够了。
7.3 从这个案例能延伸出去的方向
这个工具虽然简单,但把它扩展成实际项目非常容易:加一个category字段做分类,加一个status字段标记借出状态,用UPDATE语句实现借还书逻辑,用聚合查询统计每年藏书量。你想加索引的话,CREATE INDEX idx_books_title ON books(title);一条SQL语句就能让搜索快一个数量级。
扩展时的原则是保持每一层都简单:功能用SQL语句表达,参数用绑定传入,逻辑用清晰的C函数组织。照着这个节奏,SQLite在C项目里的使用会越来越顺。
最后聊点我自己的操作习惯。在C语言项目里用SQLite,我的底线永远是三条:打开连接后一定记得关闭;任何外部输入一律走prepared statement加绑定参数;每次调用SQLite API之后都检查返回值,宁可多检查也不要侥幸,出错第一件事先打印errmsg。上面这些代码看起来有点繁琐,但正是这些繁琐的习惯,让我后来维护项目时极少因为数据库层半夜爬起来修复问题。
如果你第一次在C项目里接SQLite,不要急着做抽象封装,先用原生API把功能搭起来,跑通之后自然会对SQLite的真实行为产生感觉。之后再包一层自己的辅助函数,写出来的封装才不会跑偏。这套技术并不高深,但它扎实、通用,几乎能陪伴你走过所有本地C程序的数据存储需求。