简介面向 Visual C 开发者的 SQLite 集成示例工程覆盖桌面应用中使用轻量级嵌入式数据库的核心链路数据库连接、大批数据快速插入、查询结果在 ListCtrl 控件中展示以及程序结束时的关闭清理。压缩包共 61 个文件包含可直接打开的 Visual Studio 工程.sln/.vcxproj、C 源码.cpp/.h、SQLite 动态库与静态库.dll/.lib、多个 .db 数据库文件及相关资源整体约 32.07MB目录结构清晰便于对照源码和工程配置逐模块理解。随包提供的多个日期命名数据库文件可作为练习数据配合示例代码能快速掌握 sqlite3_open、批量 INSERT、sqlite3_prepare_v2 查询以及 ListCtrl 逐列填充等关键操作。已有 303 人学习下载适合刚接触 SQLite 的 VC/MFC 开发者参考能够少走弯路快速完成本地数据存储、检索与界面展示的整合。1. 在 VC 工程里用 SQLite本地存储从 INI 挣扎到单文件数据库在 VC 工程里做本地存储INI 和注册表撑不住结构化数据Access 又要拖 ODBC 驱动分发时驱动缺位的玄学问题能把人绕晕。SQLite 用单个 .db 文件加一份 C 源码就解决了这件事不依赖外部服务。本文讲透 VC 中 sqlite 的使用合并包怎么编进工程、exec 回调和预编译语句怎么调、中文编码怎么转、坑在哪最后给一个可直接照抄的 C 封装。适合在 MFC/Win32 里被存储方案反复折腾的人也适合想看明白 SQLite C API 回调触发机制的新手。照着走一遍增删改查就能跑在自己的代码里。2. 把 SQLite 接进 VC 工程合并包、编译开关与 VC6 兼容2.1 合并包方式sqlite3.c 直接编进工程SQLite 提供 amalgamation 合并包把所有代码合并成 sqlite3.c、sqlite3.h、sqlite3ext.h 三个文件。桌面工程里最省事的方式是源码方式把 sqlite3.c 作为源文件加进 VC 工程一起编译运行时不需要任何额外 DLL分发时拷一个 exe 加一个 .db 文件就完事。这也是我默认推荐的方式调试时能直接跟进 C 源码里看执行路径比断点打在黑匣子 DLL 上好用得多。整体步骤是下载合并包把三个文件拷进工程目录在 VC 工程里 Add Existing Item 加入 sqlite3.c头文件路径加上该目录需要用到 SQLite 的源文件里 include sqlite3.h 即可。工程配置里不需要额外链接库所有 API 都在 sqlite3.c 里编译出来了。另一种做法是先把 sqlite3.c 编成 sqlite3.dll同时生成 sqlite3.lib 链接进工程。好处是多个工程共享同一个 DLL升级 SQLite 不用重新编译主程序坏处是分发时多带一个 DLL还要处理不同模块版本不一致的问题。单 exe 工具类项目我基本都选源码方式夹带 DLL 的部署成本不值得。2.2 三个值得提前设置的编译宏sqlite3.c 本身提供大量编译开关在工程预处理器里定义即可不用改源码。最常见的三个宏作用常见取值SQLITE_ENABLE_COLUMN_METADATA提供 sqlite3_column_table_name 等元信息 API可定义SQLITE_THREADSAFE线程安全模式0 / 1 / 2默认 1SQLITE_TEMP_STORE临时表与排序临时文件的位置0 / 1 / 2默认 0对应在工程配置的 Preprocessor Definitions 里写入SQLITE_ENABLE_COLUMN_METADATA SQLITE_THREADSAFE1 SQLITE_TEMP_STORE1SQLITE_THREADSAFE 的三个取值要理解透0 表示编译成单线程版本整体执行效率最高但多线程使用必须自己在外部加锁1 是 serialized 模式连接可以被多线程安全共享代价是少量性能损耗桌面程序默认选这个最省心2 是 multi-thread 模式每个连接一次只能在一个线程里用但不同连接可以在不同线程间分配。SQLITE_TEMP_STORE 设成 1 或 2 能把排序中间文件放进内存大表 ORDER BY 会快不少代价是内存占用和断电丢临时数据的风险批量导入场景我一般开到 2。提示改完编译宏记得全量重新编译 sqlite3.c。增量编译有时不会重新触发出现“宏改了没生效”的诡异现象多半是这个原因。2.3 VC6 老工程的兼容处理还在用 VC6 的工程要先想清楚一个现实问题新版 SQLite 合并包是用较新的 C 标准写的老编译器很可能直接语法报错。这不是工程配置能绕过去的。常见做法是找与编译器年代接近的老版合并包或者把工程迁移到新版编译环境。我一般建议后者老合并包缺少后续的性能和安全修复为了一个编译器版本守着旧 SQLite 不划算。VC6 还有一个隐性坑默认 ANSI 编译CString 里存的是本地代码页字符而 SQLite 的文本接口固定走 UTF-8。这意味着所有进出 SQLite 的字符串都要显式转换不能图省事把 CString 当 char* 直接传。第 4 章会给完整的转换函数ANSI 工程和 Unicode 工程只是中间多一次 UTF-16 中转的区别。3. 打通增删改查exec 回调、预编译语句与事务的标准路径3.1 打开与关闭sqlite3_open 的正确姿势sqlite3_open 只传路径和占位指针。路径参数是 UTF-8 编码的 const char*Windows 下的 CString 不能直接塞进去尤其是带中文或空格时。还有一个常被忽略的点sqlite3_open 失败时返回非 SQLITE_OK此时仍然需要调用 sqlite3_close 释放它内部申请的资源。sqlite3* db NULL; int rc sqlite3_open(D:/data/app.db, db); if (rc ! SQLITE_OK) { CString msg sqlite3_errmsg(db); // 取错误文本 sqlite3_close(db); // 失败也要 close AfxMessageBox(msg); return; } // 正常使用…… sqlite3_close(db);参数说明路径统一用正斜杠或转义过的反斜杠都可以SQLite 内部会处理强烈建议用绝对路径避免工作目录变化导致找不到文件。sqlite3_errmsg 返回的字符串在同一连接上只保留到下一次出错取出来要立刻拷走。关闭前要确保没有未 finalize 的 stmt 和未提交的事务否则可能返回 SQLITE_BUSY。3.2 sqlite3_exec 与回调触发机制exec 是快速执行 SQL 的方式传一句 SQL 进去内部完成 prepare、step、finalize。对 SELECT每查出一行就调用一次回调对 INSERT/UPDATE/DELETE不产生结果集回调不会被触发对 CREATE TABLE 这类零列语句回调会触发一次但 argc 是 0。回调返回 0 继续取下一行返回非 0 会中断查询sqlite3_exec 此时返回 SQLITE_ABORT。int RowCallback(void* userData, int argc, char** argv, char** colName) { CListCtrl* pList (CListCtrl*)userData; CString row; for (int i 0; i argc; i) { // argv[i] 是 UTF-8 文本先转回本地编码再显示 CStringA utf8 argv[i] ? argv[i] : ; row Utf8ToAnsi(utf8) _T( | ); } pList-InsertItem(pList-GetItemCount(), row); return 0; // 返回 0 继续返回非 0 终止查询 } char* errMsg NULL; int rc sqlite3_exec(db, SELECT id, name FROM user, RowCallback, pList, errMsg); if (rc ! SQLITE_OK errMsg) { CString msg errMsg; sqlite3_free(errMsg); // errMsg 必须由 sqlite3_free 释放 }参数说明userData 是透传指针回调里拿它做上下文argc 是列数argv 每项是列值colName 是列名NULL 列值的 argv[i] 是 NULL先判空再取值。回调里只做数据拷贝和界面刷新千万不要在回调里对同一个 db 连接再执行写操作重入会导致无法预料的锁等待和指针失效第 5 章会单独说这个坑。编码转换函数见第 4 章。3.3 预编译语句prepare、bind、step、finalize 的标准流程需要参数化查询、反复执行同一 SQL、或要精确控制读取时改用预编译语句。流程固定四步prepare 把 SQL 编译成 stmtbind 绑定参数step 逐行取数据finalize 释放 stmt。bind 的参数索引从 1 开始column 的索引从 0 开始这两个起点经常被搞混。sqlite3_stmt* stmt NULL; const char* sql SELECT id, name, score FROM user WHERE age ? AND name LIKE ?; if (sqlite3_prepare_v2(db, sql, -1, stmt, NULL) ! SQLITE_OK) { CString msg sqlite3_errmsg(db); return; } sqlite3_bind_int(stmt, 1, 18); CStringA utf8Name AnsiToUtf8(%张%); sqlite3_bind_text(stmt, 2, utf8Name.GetString(), -1, SQLITE_TRANSIENT); while (sqlite3_step(stmt) SQLITE_ROW) { int id sqlite3_column_int(stmt, 0); const char* name (const char*)sqlite3_column_text(stmt, 1); CString dispName Utf8ToAnsi(name); // 立即拷走指针马上会失效 // 用这一行的 id / dispName 干活 } sqlite3_finalize(stmt);参数说明prepare_v2 的第三个参数传 -1 表示让 SQLite 自动按字符串长度解析bind_text 最后一个参数传 SQLITE_TRANSIENTSQLite 会复制一份文本本地临时变量销毁也不影响执行如果传 SQLITE_STATIC则必须保证字符串内存在整个 step 期间都有效。sqlite3_column_text 返回的指针在下一步 step、reset 或 finalize 后会失效必须行内拷贝。step 返回 SQLITE_ROW 表示还有行返回 SQLITE_DONE 表示取完。3.4 批量写入时必须用事务SQLite 默认每条写语句单独一个事务也就是自动提交模式。循环几万条 INSERT 时每条都触发一次磁盘同步导入慢得让人怀疑人生。包一层 BEGIN/COMMIT 把写入合并成一个大事务速度能提升一到两个数量级。sqlite3_exec(db, BEGIN, NULL, NULL, NULL); for (int i 0; i recCount; i) { sqlite3_stmt* stmt NULL; sqlite3_prepare_v2(db, INSERT INTO log(sn, val) VALUES(?, ?), -1, stmt, NULL); sqlite3_bind_int(stmt, 1, i); sqlite3_bind_double(stmt, 2, val[i]); if (sqlite3_step(stmt) ! SQLITE_DONE) { CString msg sqlite3_errmsg(db); sqlite3_finalize(stmt); sqlite3_exec(db, ROLLBACK, NULL, NULL, NULL); return; } sqlite3_finalize(stmt); } sqlite3_exec(db, COMMIT, NULL, NULL, NULL);参数说明中途出错要用 ROLLBACK 回滚COMMIT 和 ROLLBACK 之间不要再夹别的连接操作。追求极致速度时可以用 PRAGMA synchronousOFF 或 PRAGMA journal_modeMEMORY但断电可能丢数据甚至损坏库只建议在可重建的临时数据上用。日常别省同步这个 fsync数据安全比那点时间值钱。4. 中文编码转换CString 与 UTF-8 之间的两座桥4.1 乱码问题的根源SQLite 的 C 接口固定用 UTF-8 存文本而 Windows 上的 VC 工程有两种情况Unicode 字符集编译时 CString 是 UTF-16非 UnicodeANSI编译时是本地代码页中文环境是 GBK。只要 text 类型的数据进出库两层编码就必然相遇。需要转换的位置至少有五处SQL 语句里的中文字符串字面量、bind 进去的中文参数、column 取出来的中文列值、回调里收到的 argv 文本、sqlite3_open 的路径参数。漏掉任何一处表象就是插入后变成“锟斤拷”查询结果全是问号或者根本打不开中文路径的文件。先把这五个位置在代码里标出来再决定统一封装比遇到一处改一处靠谱得多。4.2 封装一组转换函数ANSI 工程里从 CStringGBK到 UTF-8 要经过 UTF-16 中转GBK 先转成 UTF-16再转成 UTF-8反向同理。下面这两个函数是完整可用的CStringA AnsiToUtf8(const CStringA ansi) { // 第一步ANSI(GBK) - UTF-16 int wLen MultiByteToWideChar(CP_ACP, 0, ansi.GetString(), ansi.GetLength(), NULL, 0); CStringW wStr; MultiByteToWideChar(CP_ACP, 0, ansi.GetString(), ansi.GetLength(), wStr.GetBuffer(wLen), wLen); wStr.ReleaseBuffer(wLen); // 第二步UTF-16 - UTF-8 int uLen WideCharToMultiByte(CP_UTF8, 0, wStr.GetString(), wStr.GetLength(), NULL, 0, NULL, NULL); CStringA utf8; WideCharToMultiByte(CP_UTF8, 0, wStr.GetString(), wStr.GetLength(), utf8.GetBuffer(uLen), uLen, NULL, NULL); utf8.ReleaseBuffer(uLen); return utf8; } CStringA Utf8ToAnsi(const CStringA utf8) { // 逆向UTF-8 - UTF-16 - ANSI(GBK) int wLen MultiByteToWideChar(CP_UTF8, 0, utf8.GetString(), utf8.GetLength(), NULL, 0); CStringW wStr; MultiByteToWideChar(CP_UTF8, 0, utf8.GetString(), utf8.GetLength(), wStr.GetBuffer(wLen), wLen); wStr.ReleaseBuffer(wLen); int aLen WideCharToMultiByte(CP_ACP, 0, wStr.GetString(), wStr.GetLength(), NULL, 0, NULL, NULL); CStringA ansi; WideCharToMultiByte(CP_ACP, 0, wStr.GetString(), wStr.GetLength(), ansi.GetBuffer(aLen), aLen, NULL, NULL); ansi.ReleaseBuffer(aLen); return ansi; }参数说明CP_ACP 是当前 ANSI 代码页中文 Windows 上就是 GBKCP_UTF8 指定 UTF-8 编码。GetBuffer 后必须成对 ReleaseBuffer否则 CString 的长度元数据错误后面拼接和显示都会带脏数据。Unicode 字符集工程更简单CString 本身就是 UTF-16直接用 WideCharToMultiByte(CP_UTF8, ...) 就能得出 UTF-8反向用 MultiByteToWideChar(CP_UTF8, ...) 即可。注意不要把 GBK 直接转 UTF-8。这个中间步骤省不得我见过不少直接拿 WideCharToMultiByte 一杆子插到底的封装中文系统上能用换到英文系统就全线翻车。4.3 数据库路径也要转中文路径打不开的坑sqlite3_open 的路径参数同样按 UTF-8 处理。中文系统 ANSI 工程里直接传 CString路径里的中文以 GBK 字节存在SQLite 按 UTF-8 解析就找不到文件现象是 sqlite3_open 明明返回 SQLITE_OK第一次写数据却报 unable to open database file。SQLITE_OK 不一定是真的成功因为 SQLite 默认延迟建库真正的打开动作发生在首次读写时。CStringA dbPath AnsiToUtf8(CStringA(D:\\数据目录\\app.db)); sqlite3* db NULL; if (sqlite3_open(dbPath.GetString(), db) ! SQLITE_OK) { AfxMessageBox(sqlite3_errmsg(db)); return; }参数说明路径先转 UTF-8 再传入。返回 SQLITE_OK 不等于文件没问题紧接着做一次 SELECT 1 探活能提前暴露路径问题。目录不存在时 SQLite 不会自动创建目录先确认父目录在位。还有一个相关联的细节中文列做 ORDER BY 时默认按 UTF-8 字节序排不是拼音序想按拼音排序得自己扩展排序规则这个坑等做到中文排序需求时自然会碰到。5. VC SQLite 避坑指南五个高频翻车现场与排查思路5.1 open 返回 OK 却报 unable to open路径编码在捣乱现象sqlite3_open 返回 SQLITE_OK首次 INSERT 或 SELECT 时却报 unable to open database file甚至偶尔第一次正常、换了工作目录就复现。原因大多是路径编码问题。中文路径没转 UTF-8、反斜杠转义出错、目录不存在而 SQLite 延迟建库错误一直拖到首次读写才暴露。SQLITE_OK 只代表连接对象建立不代表文件真实可用。解决路径统一走 AnsiToUtf8 转换传给 open 之前用 CreateDirectory 确认父目录在位open 后立刻执行 SELECT 1 探活把它当成连接初始化的一部分。这个习惯能过滤掉一大半“换台电脑就坏”的诡异问题。5.2 exec 回调里做写操作重入导致卡死或 SQLITE_BUSY现象SELECT 的回调里对同一个 db 连接执行 INSERT 或 UPDATE程序卡死在 sqlite3_step或返回 SQLITE_BUSY偶尔直接崩溃。原因回调运行在调用线程但它在一次未结束的查询内部发起了写事务锁状态全乱。轻则锁等待超时重则 SQLite 内部指针状态被破坏。解决回调只做拷贝和记录把要写的数据推到一个队列里等 exec 整个返回后再统一写。需要边查边写的场景改用预编译语句把写逻辑放在 step 循环体里而不是回调里绕开 exec 回调这条路。5.3 查询结果全是最后一行列指针生命周期没管住现象循环里把 sqlite3_column_text 返回的 char* 直接 push 进 vector循环结束后所有元素内容一样或者全是乱码。原因column_text 返回的是 SQLite 内部缓冲的指针下一次 step、reset 或 finalize 之后就可能失效。保存裸指针等于保存了一块随时被复用的内存。解决行内立即拷贝用 CString 赋值或 strcpy 到自己的缓冲区绝不要保存裸指针。拷出来的文本是 UTF-8显示前要经 Utf8ToAnsi 转回本地编码。这两个问题经常一起出现排查时先看指针生命周期再看编码。5.4 内存稳步上涨finalize、errMsg、close 三件套漏了现象程序跑一段时间内存持续上涨任务管理器里看得很清楚重启后恢复。原因SQLite 这块的泄漏几乎都是同一个套路prepare 了没 finalizesqlite3_exec 的 errMsg 没 sqlite3_free数据库用完没 close。还有一个隐蔽来源是 CString 的 GetBuffer 没配 ReleaseBuffer长度元数据坏了之后每次 Append 都在往错的位置写。解决把语句和连接的生命周期交给 RAII 对象第 6 章的封装直接解决这个问题。errMsg 只要非空就必须 sqlite3_free每次 open 对应一次 close这个对仗关系要写成肌肉记忆。GetBuffer 和 ReleaseBuffer 永远成对出现。5.5 database is locked多线程访问的锁竞争现象多线程访问同一个 .db 文件频繁报 database is locked或 disk I/O error。原因默认编译下 SQLite 是 serialized 模式同一连接多线程用不会崩但写入锁竞争激烈时大量操作返回 SQLITE_BUSY尤其是一个连接长时间持有写事务时。解决启动后对每个连接调一次 sqlite3_busy_timeout(db, 3000)让等待锁的操作自动重试而不是立刻失败。更稳的做法是工作线程各自持独立连接SQLITE_THREADSAFE2 模式下不同连接跨线程没问题。写密集场景统一走一个写队列读连接和写连接分离是桌面程序最不容易踩锁的布局。6. 封装一个 C 访问类RAII 关闭、事务边界与验证方法前面 API 的每个细节最终要落到工程里直接散着写容易到处漏 finalize。我的做法是封装一个最小的 SqliteDb 类把连接生命周期、语句生命周期和事务边界管住class SqliteDb { public: SqliteDb() : m_db(NULL) {} ~SqliteDb() { Close(); } bool Open(const CStringA utf8Path) { Close(); if (sqlite3_open(utf8Path.GetString(), m_db) ! SQLITE_OK) { m_lastError sqlite3_errmsg(m_db); return false; } sqlite3_busy_timeout(m_db, 3000); return true; } bool Exec(const char* sql) { char* err NULL; int rc sqlite3_exec(m_db, sql, NULL, NULL, err); if (err) { m_lastError err; sqlite3_free(err); } return rc SQLITE_OK; } bool Begin() { return Exec(BEGIN); } bool Commit() { return Exec(COMMIT); } bool Rollback() { return Exec(ROLLBACK); } sqlite3* Handle() { return m_db; } private: void Close() { if (m_db) { sqlite3_close(m_db); m_db NULL; } } sqlite3* m_db; CString m_lastError; };类的重点在析构函数里统一 Close任何早退路径都不会漏关连接Exec 内部统一释放 errMsg调用方只管返回值。语句对象建议单独写一个 StmtGuard析构里调 finalize配合作用域自动清理。事务用 Begin/Commit/Rollback 三个方法包住比散落的裸 SQL 舒服得多。封装完之后验证方式很直接程序跑一遍增删改查把生成的 .db 文件用 DB Browser for SQLite 打开逐表看字段类型和数据确认中文正常、事务提交后的数据真实落在磁盘上。平时我也习惯在调试器里对 sqlite3_step 的返回值设条件断点SQLITE_ROW、SQLITE_DONE、SQLITE_BUSY 三个值一眼就能看出执行路径走没走对。这套组合拳已经是我的固定习惯先把访问层封好再写业务后面换表结构、加索引都省心希望帮到你。本文还有配套的精品资源点击获取