
很多初学者在交流群里问过类似的问题C语言不是用来写单片机、写算法的吗能和SQL Server这种企业级数据库扯上关系我的答案是不仅能而且用C语言直接操作SQL Server这件事越早弄明白你对数据库连接、语句执行、参数绑定这些概念的理解就越扎实。下面这套完整流程是我最近刚整理过的从装环境到写完增删改查所有的代码和踩坑记录都在里面适合手里只有Visual Studio、又必须交一份“C语言操作数据库”作业的朋友也适合想在老项目里做底层数据访问的开发者。1. 动手准备安装版本、连接信息与测试表真正开始写代码之前最耗时间的往往不是代码本身而是环境没配对。这里我不会推荐你装一堆用不到的东西只把关键步骤和必须记下来的信息说清楚。1.1 安装SQL Server与VS时真正需要记住的信息你要是只为了学习装SQL Server Express或Developer版就够用。Express免费功能上对单机开发和测试没有任何问题Developer版功能更全同样是免费的只是授权上不允许用于生产环境。安装过程中有一个地方一定要留个心眼就是“实例名”默认实例叫MSSQLSERVER如果你安装时选择了命名实例比如SQLEXPRESS后面连接字符串里的写法就完全不一样这点我会在连接代码里专门演示。Visual Studio这边看起来复杂其实一个工作负载就够了打开VS Installer勾选“使用C的桌面开发”。这里有个新手常犯的错误就是顺手装了“Python开发”“ASP.NET”等一堆东西等打开项目才发现自己连C语言空项目都找不到在哪里建。装完VS之后不用额外安装任何数据库相关的组件ODBC的头文件sql.h和sqlext.h早就随Windows SDK一起装好了程序里需要链接的odbc32.lib也是系统自带的。连接数据库的通道依赖的是SQL Server ODBCDriver。正常安装SQL Server时驱动会一并装好。我们可以打开odbcad32.exe在开始菜单搜ODBC数据源查看当前机器的驱动列表常见的是“ODBC Driver 17 for SQL Server”或“ODBC Driver 18 for SQL Server”。我个人习惯用17因为18默认启用了强制加密很多老项目的连接串没跟上容易在连接阶段直接报错。关于这个坑后面有单独一节讲。1.2 建一张能测完增删改查的测试表环境装好后我们需要准备一张表。如果你的机器上有SQL Server Management StudioSSMS直接在图形界面里操作就行如果没装SSMS用命令行工具sqlcmd也能完成。我用SSMS演示先建库再建表脚本如下CREATE DATABASE StudentDB; GO USE StudentDB; GO CREATE TABLE Student ( ID INT IDENTITY(1,1) PRIMARY KEY, Name NVARCHAR(50) NOT NULL, Score DECIMAL(5,2) NOT NULL, BirthDate DATE NOT NULL ); GO这张表特意设计了几个有代表性的数据类型自增主键ID我们用来演示删除和更新时按ID定位Name用NVARCHAR存中文名Score是带小数的DECIMALBirthDate是日期类型。这四种类型正好覆盖了我在C代码里需要演示的绑定方式简直是为课程设计量身定做的。建完表之后顺手插入两条初始数据后面讲查询时才不会空跑INSERT INTO Student(Name, Score, BirthDate) VALUES (N小明, 88.50, 2005-03-12); INSERT INTO Student(Name, Score, BirthDate) VALUES (N小红, 92.00, 2005-07-01); GO注意这里我先用了SELECT插入前测试数据的写法等C程序连接后也会再演示如何通过参数绑定插入。这里有个细节中文名前加N前缀是为了告诉SQL Server这是Unicode字符串否则靠默认排序规则有时会把中文存成乱码。2. 为什么最终选了ODBC而不是其他方案C语言操作SQL Server方案不止一种但我试过一圈之后老老实实回归到了ODBC。这一节把选型逻辑说清楚你就不必再在网上看那些“带跑偏”的教程了。2.1 三条路线的优劣对比我梳理出三条真正能走通的路对比如下方案调用方式优点缺点ODBC API直接调用SQLAllocHandle等函数标准、跨数据库、C语言友好代码量稍大需理解句柄模型ADO / OLE DB通过COM组件功能强、支持高级特性纯C下调用COM极痛苦基本是给C准备的调用sqlcmd命令行用system()执行代码最少没法拿返回结果解析输出非常恶心很多教程会推荐ADO理由是它“面向对象”。但你要明白ADO本质是COM调用前要用CoInitialize初始化、拿接口指针、处理VARIANT变量这一套用C写起来都繁琐纯C写就是折磨。ODBC虽然也是“句柄式”编程但它的API设计是为C语言量身定做的人人能看懂。第三种方案我见过不少学生用system(sqlcmd -S localhost -U sa -P ... -Q \...\)。不是说不能用而是它只能把SQL执行完然后看个返回值想拿到查询出来的数据集做后续处理就得解析命令行输出文本那维护成本比写ODBC本身还高。所以结论很简单想在C语言里真正操作SQL ServerODBC就是那个最“正规军”的选择。2.2 ODBC三个句柄弄懂结构就不慌了初看ODBC代码一堆SQLAllocHandle和SQLFreeHandle新手很容易懵。其实只要抓住三个句柄整个模型就通了SQLHENV环境句柄程序里只要一个相当于整个ODBC世界的“总机”。SQLHDBC连接句柄代表一条到数据库的连接相当于总机里拨出的一条电话线。SQLHSTMT语句句柄代表一次SQL语句的执行过程相当于这条电话线上的一次通话说话内容就是SQL语句。开发时的顺序永远是先分配环境句柄再分配连接句柄连上数据库之后再分配语句句柄去执行SQL。用完后的释放顺序严格反过来先释放语句句柄再断开连接、释放连接句柄最后释放环境句柄。这个“先申请后释放”的顺序看着像洁癖实际上在长时间运行的程序里非常重要我就见过因为不释放语句句柄导致连接被占满的线上事故。3. 核心代码实现从连库到增删改查理论说清楚了下面直接上能编译、能跑通的代码。我用的开发环境是VS2022新建一个空项目添加一个.c源文件把代码粘贴进去就能用。需要提醒一句项目属性中“高级”一栏把“字符集”设置为“使用Unicode字符集”并且源文件保存成带BOM的UTF-8编码这样宽字符串L...里的中文才不容易出问题。3.1 连接数据库环境句柄与连接句柄的正确姿势先看一眼连接数据库的完整代码。这里我用的是SQLDriverConnect而不是SQLConnect区别在于SQLConnect要求你先在ODBC数据源管理器里手动配置一个DSN而SQLDriverConnect直接在连接字符串里写明驱动名和服务器地址更适合代码演示和分发。#include windows.h #include sql.h #include sqlext.h #include stdio.h #include wchar.h #include locale.h #pragma comment(lib, odbc32.lib) void showError(SQLSMALLINT handleType, SQLHANDLE handle, const char *phase); int main() { SetConsoleOutputCP(CP_UTF8); setlocale(LC_ALL, .UTF-8); SQLHENV env SQL_NULL_HENV; SQLHDBC dbc SQL_NULL_HDBC; SQLRETURN ret; // 1. 分配环境句柄并设置ODBC版本为3.x ret SQLAllocHandle(SQL_HANDLE_ENV, SQL_NULL_HANDLE, env); if (ret ! SQL_SUCCESS ret ! SQL_SUCCESS_WITH_INFO) { printf(分配环境句柄失败\n); return -1; } SQLSetEnvAttr(env, SQL_ATTR_ODBC_VERSION, (SQLPOINTER)SQL_OV_ODBC3, 0); // 2. 分配连接句柄 ret SQLAllocHandle(SQL_HANDLE_DBC, env, dbc); if (ret ! SQL_SUCCESS ret ! SQL_SUCCESS_WITH_INFO) { printf(分配连接句柄失败\n); SQLFreeHandle(SQL_HANDLE_ENV, env); return -1; } // 3. 建立连接 ret SQLDriverConnect( dbc, NULL, LDRIVER{ODBC Driver 17 for SQL Server}; LSERVERlocalhost; LDATABASEStudentDB; LTrusted_Connectionyes;, SQL_NTS, NULL, 0, NULL, SQL_DRIVER_NOPROMPT); if (ret ! SQL_SUCCESS ret ! SQL_SUCCESS_WITH_INFO) { showError(SQL_HANDLE_DBC, dbc, SQLDriverConnect); SQLFreeHandle(SQL_HANDLE_DBC, dbc); SQLFreeHandle(SQL_HANDLE_ENV, env); return -1; } wprintf(L连接成功\n); // 后续操作在这里写 // 清理 SQLDisconnect(dbc); SQLFreeHandle(SQL_HANDLE_DBC, dbc); SQLFreeHandle(SQL_HANDLE_ENV, env); return 0; }连接字符串里的几个参数务必要看懂DRIVER指定用哪个ODBC驱动必须和odbcad32里看到的名称一字不差SERVER在本机默认实例下写localhost即可如果是命名实例则要写成localhost\\SQLEXPRESSDATABASE直接指定要连接的数据库Trusted_Connectionyes表示用当前Windows账户做集成登录。如果数据库用的是SQL Server账号登录把最后一项替换成UIDsa;PWD你的密码;就行。注意反斜杠在C语言字符串里也是转义字符所以写命名实例时要写成两个反斜杠。3.2 查询数据绑定列、循环取数连接建立之后查询是数据库操作里最典型的需求。我把查询代码单独拿出来演示包括执行SELECT、把结果集的每一列绑定到C变量、然后循环取数直到取完。SQLHSTMT stmt SQL_NULL_HSTMT; SQLRETURN ret; ret SQLAllocHandle(SQL_HANDLE_STMT, dbc, stmt); if (ret ! SQL_SUCCESS ret ! SQL_SUCCESS_WITH_INFO) { showError(SQL_HANDLE_DBC, dbc, SQLAllocHandle(stmt)); return -1; } ret SQLExecDirect(stmt, LSELECT ID, Name, Score, BirthDate FROM Student, SQL_NTS); if (ret ! SQL_SUCCESS ret ! SQL_SUCCESS_WITH_INFO) { showError(SQL_HANDLE_STMT, stmt, SQLExecDirect(SELECT)); SQLFreeHandle(SQL_HANDLE_STMT, stmt); return -1; } SQLINTEGER id; SQLWCHAR name[64]; SQLDOUBLE score; SQL_DATE_STRUCT birthDate; SQLLEN idLen, nameLen, scoreLen, birthLen; SQLBindCol(stmt, 1, SQL_C_LONG, id, 0, idLen); SQLBindCol(stmt, 2, SQL_C_WCHAR, name, sizeof(name), nameLen); SQLBindCol(stmt, 3, SQL_C_CHAR, score, 0, scoreLen); SQLBindCol(stmt, 4, SQL_C_TYPE_DATE, birthDate, 0, birthLen); wprintf(L查询结果\n); while (SQLFetch(stmt) SQL_SUCCESS || SQLFetch(stmt) SQL_SUCCESS_WITH_INFO) { wprintf(LID%d, 姓名%ls, 分数%.2f, 生日%04d-%02d-%02d\n, id, name, score, birthDate.year, birthDate.month, birthDate.day); } SQLFreeHandle(SQL_HANDLE_STMT, stmt);这里有几个细节值得停下来解释。SQLBindCol做的事是把结果集里第N列的数据和C变量建立一一映射之后每次SQLFetch取一行数据会直接“落”进对应变量里。第2列Name的类型是NVARCHAR(50)所以绑定时用SQL_C_WCHAR缓冲区给wchar_t数组取出来才能正确显示中文。第4列BirthDate是DATE类型ODBC里对应的C结构是SQL_DATE_STRUCT包含year、month、day三个WORD字段这种类型不需要指定缓冲区长度传0即可。为什么SQLFetch的条件写了两个因为SQL_SUCCESS_WITH_INFO表示取到了数据但有警告信息很多新手只判断SQL_SUCCESS结果某些带警告的查询莫名少了几行调试半天。判断语句写完整宁可多取一次也不能漏数据。3.3 增删改参数绑定是避免注入的关键查询用的是SQLExecDirect直接把整个SQL语句扔给数据库。增删改我建议换一种方式先SQLPrepare预编译SQL语句再用SQLBindParameter把C变量和SQL里的?占位符绑定最后SQLExecute执行。为什么不直接拼接字符串原因有两条一是拼出来的SQL字符串如果包含特殊字符比如姓名里有单引号直接拼进去会把SQL搞坏二是拼接方式天然地给了SQL注入钻空子的余地。用参数绑定就让数据库自己处理转义问题代码干净得多。插入数据的完整写法如下SQLHSTMT stmt SQL_NULL_HSTMT; SQLAllocHandle(SQL_HANDLE_STMT, dbc, stmt); SQLWCHAR insertSql[] LINSERT INTO Student(Name, Score, BirthDate) VALUES (?, ?, ?); ret SQLPrepare(stmt, insertSql, SQL_NTS); if (ret ! SQL_SUCCESS ret ! SQL_SUCCESS_WITH_INFO) { showError(SQL_HANDLE_STMT, stmt, SQLPrepare(INSERT)); SQLFreeHandle(SQL_HANDLE_STMT, stmt); return -1; } SQLWCHAR name[] L王强; SQLDOUBLE score 79.80; SQL_DATE_STRUCT birthDate { 2006, 1, 15 }; SQLLEN nameLen SQL_NTS; // 表示name是零结尾宽字符串 SQLLEN scoreLen 0; SQLLEN birthLen 0; SQLBindParameter(stmt, 1, SQL_PARAM_INPUT, SQL_C_WCHAR, SQL_WVARCHAR, 50, 0, name, 0, nameLen); SQLBindParameter(stmt, 2, SQL_PARAM_INPUT, SQL_C_DOUBLE, SQL_DECIMAL, 5, 2, score, 0, scoreLen); SQLBindParameter(stmt, 3, SQL_PARAM_INPUT, SQL_C_TYPE_DATE, SQL_TYPE_DATE, 0, 0, birthDate, 0, birthLen); ret SQLExecute(stmt); if (ret SQL_SUCCESS || ret SQL_SUCCESS_WITH_INFO) { SQLLEN rowCount; SQLRowCount(stmt, rowCount); wprintf(L插入成功影响行数%d\n, (int)rowCount); } else { showError(SQL_HANDLE_STMT, stmt, SQLExecute(INSERT)); } SQLFreeHandle(SQL_HANDLE_STMT, stmt);注意SQLBindParameter的参数顺序第1个参数是SQL中占位符的位置从1开始第4个参数SQL_C_WCHAR表示C语言这边变量是宽字符类型第5个参数SQL_WVARCHAR表示SQL Server那边列的类型是NVARCHAR第6和第7个参数对应列的精度和小数位数NVARCHAR(50)传50、0DECIMAL(5,2)传5、2。最后一个参数很关键nameLen被设为SQL_NTS意思是“这个C变量是一个以零结尾的字符串”库函数会自己计算长度数值类型和结构体类型因为长度固定传0就行。更新和删除的思路完全一样只是SQL语句变了。比如SQLWCHAR updateSql[] LUPDATE Student SET Score ? WHERE ID ?; SQLWCHAR deleteSql[] LDELETE FROM Student WHERE ID ?;同样是先SQLPrepare再SQLBindParameter绑定参数最后SQLExecute。代码结构我在这里就不重复贴了照着插入的框架改占位符和变量即可。3.4 错误处理模块别再把所有SQL错误都吞掉我在调试阶段最大的感受是ODBC的错误信息如果不主动取出来看完全就是一片黑。SQLExecDirect返回SQL_ERROR时具体原因藏在驱动内部必须调用SQLGetDiagRec才能拿到三件套SQLSTATE状态码、数据库原生错误码、错误消息文本。我自己写了一个通用的错误打印函数所有关键调用失败时都会调用它void showError(SQLSMALLINT handleType, SQLHANDLE handle, const char *phase) { SQLWCHAR sqlState[6]; SQLWCHAR message[512]; SQLINTEGER nativeError; SQLSMALLINT messageLen; int i 1; wprintf(L[%hs] 诊断信息\n, phase); while (SQLGetDiagRecW(handleType, handle, i, sqlState, nativeError, message, sizeof(message) / sizeof(SQLWCHAR), messageLen) SQL_SUCCESS) { wprintf(L SQLSTATE%ls, NativeError%d, 消息%ls\n, sqlState, nativeError, message); i; } }这个函数是我写所有C联数据库程序的基础设施。你可能会问为什么要用while循环而不是只取一次因为ODBC的错误是分层的一次SQLGetDiagRec往往只能取到一条有时候一条SQL失败会连带产生多条诊断记录循环能把所有信息都挖出来。实际调试时能看到完整的SQLSTATE就已经解决了一半问题比如常见的42000对应语法错误08001对应无法连接服务器28000对应登录失败都是网上能直接搜到的标准状态码。4. 实测踩坑记录新手绕不开的几个大坑代码能跑通之后真正折磨人的是下面这些问题。几乎每个初学者都会踩而且踩完一遍往往还找不到原因我按实际发生频率高低给你排个序。4.1 驱动名称与加密策略导致的连接失败连接字符串里DRIVER的名字错一个字都不行。我在新机器上第一次跑连接报的是“Data source name not found and no default driver specified”排查了半天最后发现是系统里装的是“ODBC Driver 18 for SQL Server”我写的却是17。解决办法也简单先到odbcad32.exe里看清楚驱动列表里到底有哪个名字然后原样抄到连接字符串里。即使列表里有多个版本最好也用新版本对应的那串越新越稳。还有一个更隐蔽的坑在驱动18身上。ODBC Driver 18默认启用了强制加密如果你的SQL Server没有配置好证书连接时会直接报证书相关错误。解决办法要么在连接字符串里追加TrustServerCertificateyes;要么就老老实实把驱动换回17。我选择的是保持在连接串里加TrustServerCertificateyes;因为新驱动本身没问题只是默认策略更严格了显式信任自签名证书在开发环境里完全可以接受。4.2 中文乱码字符集不匹配的真相C语言操作SQL Server中文乱码几乎是必踩的坑。乱码的来源有两层一层在SQL Server一边一层在C源码一边。数据库这层核心是列类型。Student表的Name列我特意建成了NVARCHAR而不是VARCHARNVARCHAR在SQL Server内部以Unicode存储能把中文、日文、emoji都存下来。如果表里是VARCHAR中文能不能顺利存取就完全取决于数据库排序规则经常出现能存不能查的情况。所以建表阶段就要养成习惯凡是可能存中文的字段一律用N开头的类型NVARCHAR、NCHAR、NTEXT。代码这层问题通常在源码编码和宽字符绑定上。C源码文件在VS里默认可能是本地代码页编码里面的L张三不一定能被编译器正确编译成Unicode字符。我的建议是文件保存为UTF-8 with BOM项目使用Unicode字符集必要时在编译命令行加上/utf-8选项。绑定列时NVARCHAR列对应SQL_C_WCHAR绑定到wchar_t数组别贪图方便用char、SQL_C_CHAR去接那就成了把Unicode强迫翻译成ANSI乱码就是这么来的。4.3 句柄泄漏与连接未断开初学者代码能跑通就懒得管后面了但连接句柄不释放在反复测试时会出现一个奇怪的现像前三遍跑得好好的第四遍开始就一直连接失败重启程序又变好。原因就是每次运行都没有SQLDisconnect和SQLFreeHandle句柄和底层连接资源被白白占住SQL Server那边的连接数被耗尽了。我的习惯是“一个函数里创建的句柄一定在同一个函数里释放”。哪怕中间有分支提前return也要先把已经分配的句柄释放干净。更稳妥的办法是写一个cleanup标签把释放代码集中放在函数尾部出错时用goto cleanup跳过去这样即使以后功能扩展也不会忘掉资源回收。这招在C语言这种没有RAII的领域里就是最可靠的防泄漏手段。5. 从“能跑”到“能交付”事务、批量与封装思路作业交上去要求是“能跑”但如果你真要在实际工具或者后续项目里用这套代码还得再往前走三步。5.1 事务控制一组操作要么全成要么全没当你同时插入父表和子表或者一次更新多条记录时任何一条失败都会让数据变得不一致。ODBC里控制事务最直接的办法是先关闭自动提交SQLSetConnectAttr(dbc, SQL_ATTR_AUTOCOMMIT, (SQLPOINTER)SQL_AUTOCOMMIT_OFF, 0);关闭之后后面每条语句都在一个显式事务里执行只有调用了SQLEndTran才会真正生效if (所有语句都成功) { SQLEndTran(SQL_HANDLE_DBC, dbc, SQL_COMMIT); } else { SQLEndTran(SQL_HANDLE_DBC, dbc, SQL_ROLLBACK); }简单记一句话自动提交模式下每条SQL执行完立刻就写进数据库手动模式下必须等到SQL_COMMIT才写入一旦任何一步出错就SQL_ROLLBACK全部回滚。注意一旦关闭自动提交用完别忘记恢复成SQL_AUTOCOMMIT_ON这个设置是连接级别的不恢复就会影响连接池里的其他使用者。5.2 批量执行与多结果集ODBC的语句句柄可以连续执行多条SQL比如同一个句柄先SQLExecDirect执行一次删除再执行一次更新只要中间不重复分配句柄就行。但如果一条SQL文本里用分号写了好几条语句或者调用了存储过程返回了多个结果集就要在SQLFetch把当前结果集取完之后调用SQLMoreResults跳到下一个结果集继续取数。这类场景我在实际项目里用得最多的是备份表数据先SELECT ... INTO建一张备份表再对原表做清洗整个过程放一个事务里成功就提交失败就回滚比在应用层写一堆临时逻辑干净得多。5.3 把公共逻辑封装成自己的小工具集如果你打算在自己项目里复用这套代码建议不要每次都把SQLAllocHandle、SQLDriverConnect、错误处理全部重写一遍。我个人的做法是把它们包成一个极简模块对外提供四个函数比如db_connect()、db_execute()、db_query()、db_close()内部统一管好句柄和错误输出业务代码里只需要调用这四个函数。封装时有一个原则要坚持错误一旦发生不仅要在函数内部打印诊断信息还要让上层函数收到非零返回值。这样上层才能决定是该重试、该回滚还是该给用户弹一个错误框。错误被吞掉是C语言这类没有异常机制的项目里最伤代码质量的事。这套C语言操作SQL Server的流程我在一个内部工具里持续用了半年多稳定程度远超我一开始的预期。最后再分享一个小技巧调试数据库程序时把showError输出重定向到日志文件而不是只打到控制台因为控制台一关错误记录就没了。我后来把所有ODBC调用都包了一层日志宏线上排查问题的速度提高了好几倍。你按这个流程走一遍之后再去学其他语言的数据库操作基本上半天就能上手因为底层打交道的那套东西原理全是相通的。