很多学数据库的同学都有这样一个疑惑:我能在命令行里、在图形工具里操作 MySQL 了,可这些人是"人",程序要怎么操作 MySQL?答案是:数据库服务端提供了一整套叫做 API(Application Programming Interface,应用程序编程接口)的"接口"或"函数入口",任何语言只要能和这套接口对上话,就能驱动数据库。C 语言当然也不例外——这就是本篇文章的主角,官方提供的 MySQL C API(也常被称为 Connector/C)。

这篇文章不设定任何字数上限,我要掰开揉碎地带你把"C 语言连接 MySQL"这件事从头到尾讲透。我们会从"要用哪些文件"讲起,一路讲到 mysql_init、mysql_real_connect、mysql_query,再到如何把查询结果拿回来、如何一行一行读出来,最后落成一个能完整跑起来的"建表 + 插入 + 查询 + 取行 + 释放"的 C 程序。你不需要是 MySQL 高手,只要会写点基本 C、能被指针折腾过几回、在命令行里执行过几条 SQL,就足够了。

在正式写代码之前,我有三句肺腑之言想先撂给屏幕前的你,它们比任何一个函数都重要:第一,凡是打开的资源,都必须记得关闭;凡是拿到的内存,都必须记得释放。 C 语言里没人替你收拾,忘记 free 就是内存泄漏,忘记了 close 就是句柄泄漏——这两个"潜规则"贯穿本文一整篇。第二,C API 是"C 语言"接口,但它不止能服务 C,很多语言最后都是通过封装这一层 C API 才能连上 MySQL 的。所以把它学扎实,你能看懂的底层就更多。第三,别怕报错,报错信息就是最好的老师——后面我会教你用 mysql_error 把错误读出来,那才是真正的调试起点。

这一篇,我们要趟过的路大概是这样:

  • 环境准备:找到/编译出 C API 需要的头文件和库文件;
  • 用 mysql_get_client_info() 验证"能链接上库"这件事本身是否成功;
  • 初始化连接:mysql_init;
  • 真正连上服务器:mysql_real_connect,以及那一长串连接参数;
  • 下发命令:mysql_query;
  • 把查询结果"搬"回来:mysql_store_result 与 MYSQL_RES;
  • 读取结果:mysql_num_fields / mysql_fetch_fields / mysql_fetch_row 与 MYSQL_ROW;
  • 字符集:解决"中文乱码"的 utf8mb4;
  • 收尾:mysql_free_result、mysql_close;
  • 最后,拼出那个完整的可编译程序,再留几道带详解答案的思考题。

环境准备:头文件 + 库文件

要使用 C 语言连接 MySQL,你需要用到 MySQL 官网提供的库,这个库通常被称为 Connector/C 或 libmysqlclient。你可以去官网下载对应自己平台(Windows、Linux、macOS)的安装包或预编译包,也可以从源码编译。我们这里讲思路,不绑定某一个操作系统:无论你用哪个平台,最终你要拿到手的,本质上是两样东西。

第一样,是头文件(include 目录),C 语言里你用 #include <mysql.h> 引入的那个 mysql.h 就住在这里。头文件里装的都是"方法的声明"——也就是各个函数的签名、各种结构体的定义、各种常数,告诉编译器"这些函数长什么样、参数是什么、返回值是什么",但没有具体的实现代码。第二样,是库文件(lib 目录),它装着每一"个方法的实现"——真正干活的二进制代码,被打包成一个库。你在链接(link)阶段需要把程序链接到这个库上,函数调用才能"有处可去"。

下载下来的库解压后,目录结构大概长这样(这是 Linux 下一个很典型的布局,Windows 装的目录意思类似,只是文件名后缀不同):

connector-c/
├── include/            # 头文件目录:所有方法的声明都在这里
│   ├── mysql.h         # 我们最关心的头文件,最核心的接口声明
│   ├── mysql_com.h
│   ├── mysql_time.h
│   ├── mysqld_error.h  # 各种 MySQL 错误码的常量定义
│   ├── errmsg.h        # 客户端错误消息相关
│   ├── m_ctype.h
│   ├── my_global.h
│   ├── ...(还有一大堆辅助头文件)
│   └── mysql/          # 内部子目录,装一些相对底层的头文件
│       ├── client_plugin.h
│       ├── mysql_socket.h
│       └── ...
└── lib/                # 库目录:方法的实现(编译好的二进制)都在这里
    ├── libmysqlclient.a                # 静态库(Windows 上是 .lib)
    ├── libmysqlclient.so               # 动态库连接(软链接)
    ├── libmysqlclient.so.18            # 动态库带版本号
    ├── libmysqlclient.so.18.3.0        # 动态库真实文件(Windows 上是 .dll)
    ├── libmysqlclient_r.a -> libmysqlclient.a
    └── libmysqlclient_r.so -> libmysqlclient.so

注意看,lib 目录里往往同时存在静态库(.a / .lib)和动态库(.so / .dll)。静态库是直接把函数的实现"拷进"你的可执行文件里,链接完就各走各路、不依赖外界的运行时;动态库则是在运行时才被系统加载,可执行文件里只记了一个"需要在运行时找到它"的引用。静态库链接出来的程序大一点、但更好到处携带;动态库链接出来的程序小一点、但运行时如果找不到那个 .so/.dll 就会报错。

我们后面会遇到的 "error while loading shared libraries" 那类报错,正是动态库在运行时找不到所致——这个坑,我们稍后专门讲怎么排。

在这里,我们把两件事记牢,是全文最重要的地基:include 装声明,lib 装实现;#include 帮你拿到声明,链接时把你的代码和 lib 里的实现缝在一起。

怎么验证"库"真的引入成功了

说了这么多,怎么用代码来验证我们要的东西真的引进去了?MySQL 给我们提供了一个现成的函数——mysql_get_client_info()。它的作用是:返回一个字符串,描述当前客户端库的版本信息。我们写一个极其简单的小程序,只干一件事:把它打印出来。

// version.c —— 验证 MySQL C API 头文件与库是否接入成功
#include <stdio.h>      // 引入标准输入输出头文件,printf 要用
#include <mysql.h>      // 引入 MySQL C API 的头文件(重点):
                        // 所有 MySQL 接口函数的声明都在这,没它编译器就不知道 mysql_xxx 是什么
 
int main()              // 程序入口
{
    // mysql_get_client_info() 返回一个 const char*(字符串),
    // 描述当前 MySQL 客户端库的版本号
    // %s 是字符串占位符
    printf("mysql client version: %s\n", mysql_get_client_info());
    return 0;           // 返回 0 表示程序正常结束
}

然后我们在命令行里编译它。注意这里的关键在于,编译要告诉编译器两件事:一要用 -I 指出头文件在哪,二要用 -L 指出库目录在哪、并用 -l 指名要链接哪个库:

# gcc 编译 version.c:
#   -o version   指定输出的可执行文件名叫 version
#   -I./include  告诉编译器去 ./include 目录下找头文件(mysql.h 就在这)
#   -L./lib      告诉链接器去 ./lib 目录下找库文件
#   -lmysqlclient  链接名为 libmysqlclient 的库(-l 会自动补齐"lib"前缀和文件后缀)
gcc -o version version.c -I./include -L./lib -lmysqlclient

如果这一步顺利通过、生成了 version 这个可执行文件,说明你的"头文件 + 库"都找对了,函数的声明拿到、实现在链接时也能对上号了。这一步走通,相当于打通了整个地基。

不过,动态库链接还有一个"运行时"的坑等着你。如果你链接的是动态库,编译链接都能通过,但一运行就报类似下面这样的错:

$ ./version
./version: error while loading shared libraries: libmysqlclient.so.18:
cannot open shared object file: No such file or directory

这个报错的意思是:可执行文件在运行时需要加载 libmysqlclient.so.18 这个动态库,但系统在当前默认的库搜索路径里找不到它。 编译链接触发了它、链接成功了,但运行时又是另一回事——动态库必须在"运行的那一刻"被系统找到。解决办法是告诉系统"去哪找这个动态库",在 Linux 下通常用 LD_LIBRARY_PATH 这个环境变量指定查找路径:

# 把 ./lib 目录加入动态库运行时查找路径
# 注:Windows 上的动态库叫 .dll,通常把它放到 exe 同目录即可被找到,
#     与 Linux 的 LD_LIBRARY_PATH 思路不完全一样,但"运行时也要能找到"的本质相同
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:./lib
 
# 再运行一次
./version

这次应该能正常输出版本号了,比如我们实验环境里得到的是:

mysql client version: 6.1.6

看到这个版本号打印出来,恭喜你,你的程序已经真正"搭上"了 MySQL 的 C API。接下来就是熟悉各个接口了。

顺带一说,Windows 上如果遇到"找不到 DLL"的报错,通常的解法是把 libmysql.dll 复制到可执行文件同目录,或者把它所在的目录加入系统的 PATH。内核里那点"运行时找库"的逻辑,跟 Linux 是同一回事,只是查找路径的配置方式不同罢了。

初始化连接:mysql_init

准备工作做完了,我们正式开始用接口。用 MySQL C API 操作数据库,第一个动作必须是初始化——你可以把它理解成"领取一个连接对象、把这块最核心的数据结构准备好"。Python 学过连库的人都知道要先建一个"连接对象",C 里这个对象对应的类型就是 MYSQL。

#include <mysql.h>   // 引入 MySQL C API 头文件
 
// mysql_init 的函数签名
MYSQL *mysql_init(MYSQL *mysql);

参数 mysql 有两种传法:传一个已有的 MYSQL 变量指针,让库复用这块内存来初始化;或者传 NULL,让库自己内部 malloc 一块内存并返回这块内存的地址。绝大多数情况下,我们直接传 NULL 就够了:

// 传 NULL:由库内部申请一块内存,返回这块内存的首地址
MYSQL *mfp = mysql_init(NULL);

这里返回的 MYSQL * 指针,是整个 C API 中最最重要的"连接句柄"。你可以把它想象成一个"总钱包"或者"大管家"——它里边装的可不是一个简单整数,而是一个非常丰富的数据结构,里面保存了:

  • 端口 port、数据库名 dbname、字符集 charset 等一堆连接的基本参数;
  • 一个叫 st_mysql_methods 的结构体变量,这个结构体里保存着一大堆函数指针——这些函数指针,会在数据库连接成功以后的各项数据操作中被调用。

来到这里,我强烈建议你先停下来品一品"函数指针"这个设计的妙处。st_mysql_methods 里存的是一个个指向具体实现函数的指针,意味着"我要干什么"这件事被抽象成了数据(函数指针表),运行时再根据这张表去调用真正干活的那个函数。后面我们讲的 mysql_store_result 在内部其实就是调用了 MYSQL 变量里 st_mysql_methods 的 read_rows 这个函数指针——这句话现在你可能还感觉抽象,但等你看懂 mysql_store_result 再说,回头会发现"哦,原来函数就是这么被调度起来的"。现在,你只需要建立这个印象:MYSQL 是一个大结构体,它既是连接参数的容器,又藏着一张"操作函数指针表"。

连接数据库:mysql_real_connect

mysql_init 只是"初始化了一个连接对象"——它告诉你"管理器已就位",但此时你还没真正连上数据库服务器。初始化完毕之后,必须调用连接函数去真正打通和服务器之间的通道,之后才能进行后续操作。这里要先提一句背景:MySQL 客户端和服务器之间的网络通信,底层是基于 TCP/IP 的——也就是说,"连接数据库"本质上是在"连一个 TCP 端口"。理解了这一点,你日后排查"连不上"就能往网络方向多想一层。

真正干活的连接函数是 mysql_real_connect,它的签名是这样:

#include <mysql.h>
 
MYSQL *mysql_real_connect(MYSQL *mysql,      // 连接句柄,就是 mysql_init 的返回值
                          const char *host,   // 服务器主机名或 IP,如 "127.0.0.1" 或 "localhost"
                          const char *user,   // 用户名,如 "root"
                          const char *passwd, // 密码
                          const char *db,     // 要连接的默认数据库名,可为 NULL(连接后再用 USE 语句切换)
                          unsigned int port,  // 端口号,填 0 表示用默认端口 3306
                          const char *unix_socket, // Unix 套接字路径,通常填 NULL(走 TCP)
                          unsigned long clientflag); // 客户端标志位,常用传 0 表示默认

这一串参数,名字基本都是"顾名即思义"的,可以逐个对照中文含义记忆,我们称之为连接参数。把它们翻译过来就是:你要连哪台主机的、哪个端口的 MySQL,用哪个用户名和哪个密码,默认进到哪个库里。前两个参数好理解;port 填 0 会走默认端口 3306(MySQL 的默认服务端口);unix_socket 在纯 TCP 连接场景下填 NULL 即可;clientflag 是一个位标志集合,初学者先传 0(表示全部用默认行为)即可。

来看一个典型的调用,同时把失败处理一并带上:

#include <stdio.h>
#include <mysql.h>
 
int main()
{
    MYSQL *conn = mysql_init(NULL);      // 第一步:初始化连接对象
    if (conn == NULL)                    // 初始化失败时返回 NULL,最好检查一下
    {
        fprintf(stderr, "mysql_init 失败\n");
        return 1;
    }
 
    // 第二步:真正连接服务器
    // 连接到本机 127.0.0.1 的 3306 端口,用户名 root,密码 123456,默认库 testdb
    // 成功时返回的是 conn 本身;失败时返回 NULL
    if (mysql_real_connect(conn,
                           "127.0.0.1",   // host,本机回环地址
                           "root",        // user 用户名
                           "123456",      // passwd 密码(请按你本机实际配置填写)
                           "testdb",      // db 默认库名,要确保这个库已存在
                           0,             // port,0 表示用默认 3306
                           NULL,          // unix_socket,走 TCP 就填 NULL
                           0) == NULL)    // clientflag,先传 0
    {
        // 连接失败:用 mysql_error() 把具体的错误信息读出来
        // mysql_error 返回一个字符串,描述刚才那次操作失败的详细原因
        fprintf(stderr, "连接数据库失败: %s\n", mysql_error(conn));
        mysql_close(conn);   // 失败也要做的干净:关闭释放刚才 init 出来的连接
        return 1;
    }
 
    printf("连接数据库成功\n");
 
    mysql_close(conn);       // 用完了记得关闭连接
    return 0;
}

请注意上面代码里的一个关键判断:mysql_real_connect 成功时返回的就是你传入的 conn 本身,失败时返回 NULL。所以"判断返回值是否为 NULL"就是判断连接失败的惯用姿势。一旦失败,除了打印错误,还要记得把已经 malloc 出来的连接对象 mysql_close 关掉——哪怕初始化已经失败,mysql_real_connect 也可能"走到了半路",资源上最稳妥的收尾是:一旦确认失败,立刻 mysql_close 并 return。别嫌啰嗦,这种"失败也要清除干净"的习惯,能让你的程序在长期跑的时候远离句柄泄漏。

下发命令:mysql_query

连接成功后,我们就可以执行 SQL 了。命令下发的入口函数是 mysql_query:

#include <mysql.h>
 
// 函数签名
int mysql_query(MYSQL *mysql,      // 连接句柄
                const char *q);     // 要执行的 SQL 语句字符串,如 "select * from table"

q 就是要执行的 SQL 语句,以字符串形式传进去。返回值表示执行情况:成功返回 0,失败返回非 0。这里的"成功/失败"指的不是"有没有查到数据",而是"这条语句有没有正常执行完、有没有语法或引擎层面的错误"。

#include <mysql.h>
 
const char *sql = "SELECT id, name, score FROM student;"; // 定义一条要执行的查询语句
int ret = mysql_query(conn, sql);   // 下发命令;成功返回 0,失败返回非 0
if (ret != 0)                       // 非 0 = 失败,把错误打出来
{
    fprintf(stderr, "执行 SQL 失败: %s\n", mysql_error(conn));
}

这里有个必经的岔路口,值得停下来讲清楚,因为它决定了后续的代码怎么走:

  • 如果你执行的是 UPDATE、INSERT、DELETE 这类"改数据"的语句,你多半只关心"改成了吗、改了多少行"——这种不用去取什么结果集,直接看 mysql_query 的返回值,必要时再用 mysql_affected_rows 拿"受影响的行数"就行;
  • 如果你执行的是 SELECT 这类"查数据"的语句,那么你不仅要关心"查成了吗",还要想办法把查出来的那些数据真正拿到手里。这就是我们下一步 mysql_store_result 要解决的问题。

一句话:mysql_query 只是"把 SQL 扔给服务器执行了",真正的"数据到手",还在后面。 对 SELECT 来说,query 只是扣动了扳机,子弹(结果集)还得专门去取。

获取查询结果:mysql_store_result 与 MYSQL_RES

如果 mysql_query 返回成功,而它执行的又是一条查询语句,那么下一步就该读取查询结果了。读取结果集的入口函数是 mysql_store_result:

#include <mysql.h>
 
// 函数签名
MYSQL_RES *mysql_store_result(MYSQL *mysql);

这个函数会调用 MYSQL 变量内部 st_mysql_methods 里的 read_rows 这个函数指针,把查询结果一次性从服务器取到客户端内存中;同时,它会返回一个 MYSQL_RES * 类型的指针,这个 MYSQL_RES 结构体就是用来"保存这次查询结果"的容器:行数、列数、每一列的名字、每一行的数据,统统装在里面。

这里有一个极其重要、初学几乎必踩的大坑,我必须画重点给你看:

mysql_store_result 在取结果时,会 malloc 一片内存来存放这些数据。所以你用完结果之后,一定要调用 mysql_free_result 把这笔内存释放掉,否则肯定会造成内存泄漏。

这一句,请务必牢记。它跟"malloc 了就要 free"是同一条铁律的延申:store_result = 内存分配,free_result = 内存回馈。两个成对出现、缺一不可。

mysql_store_result 成功执行完之后,其实数据已经全部在 MYSQL_RES 变量里了,后面的各个 API,基本就是干一件事:从 MYSQL_RES 里面把数据一层层读出来。既然数据都在 MYSQL_RES 里了,那怎么读呢?接下来四个函数分头解决四件事:数一数有多少行、数一数有多少列、拿到每列的名字、逐行取出数据。

读取结果:行数、列数、列名、行数据

获取结果行数:mysql_num_rows

#include <mysql.h>
 
// 返回结果集中"行"的数量
// 返回类型是 my_ulonglong,本质上就是一个无符号大整数(long long 级别)
my_ulonglong mysql_num_rows(MYSQL_RES *res);

它接收 mysql_store_result 返回的那个 MYSQL_RES,返回结果集中一共有多少行。因为返回类型很大,打印时常用 %llu 这种占位符,或者给成一个临时变量再输出。

获取结果列数:mysql_num_fields

#include <mysql.h>
 
// 返回结果集中"列"(字段)的数量
unsigned int mysql_num_fields(MYSQL_RES *res);

它返回一共有多少列。这个很重要——因为后面我们要"逐行逐列"地读取,得先知道每行有几列,才知道要循环多少次。

获取列名:mysql_fetch_fields

#include <mysql.h>
 
// 返回一个 MYSQL_FIELD 数组,每个元素描述一列的信息(列名、类型、长度等)
MYSQL_FIELD *mysql_fetch_fields(MYSQL_RES *res);

它返回指向一个 MYSQL_FIELD 结构体数组的指针,这个数组的长度正好等于 mysql_num_fields 的返回值。每个 MYSQL_FIELD 里有 name(列名)等字段。所以我们拿列名的方法通常是:先用 mysql_num_fields 拿到列数 fields,再用 mysql_fetch_fields 拿到字段数组,最后用下标 0 ~ fields-1 去访问每个字段的 name。

#include <mysql.h>
 
int fields = mysql_num_fields(res);        // 先拿到列数
MYSQL_FIELD *field = mysql_fetch_fields(res); // 拿到字段数组
// 打印每一列的列名
for (int i = 0; i < fields; i++)
{
    printf("%s\t", field[i].name);          // 逐个打印列名,列名之间用 Tab 分隔
}
printf("\n");                               // 打印完所有列名后换行

获取结果内容:mysql_fetch_row

前面拿的是"列"的信息,现在轮到"行"的数据了。逐行读取用的函数是 mysql_fetch_row:

#include <mysql.h>
 
// 每次调用,取回结果集中的"下一行";全部取完后再调用返回 NULL
// 返回类型是 MYSQL_ROW
MYSQL_ROW mysql_fetch_row(MYSQL_RES *result);

这里要说清楚一个常见困惑:MYSQL_ROW 到底是什么类型?MYSQL_ROW 本质上就是 char **,即"一个指向字符指针数组的指针"。说白了,你可以把它当成一个二维数组来用:其中第一维(下标)是"第几列",第二维是"那个格子的数据字符串"。结合刚才拿到的列数,我们就可以用"外层循环走行、内层循环走列"的经典双循环,把结果集一张表完整地打印出来:

#include <mysql.h>
 
// 前提:res 是 mysql_store_result 的返回值,nums 是总行数,fields 是总列数
int nums = mysql_num_rows(res);          // 拿到总行数
int fields = mysql_num_fields(res);      // 拿到总列数
 
printf("总行数: %d, 总列数: %d\n", nums, fields);
 
// 外层循环:遍历每一行
for (int i = 0; i < nums; i++)
{
    MYSQL_ROW line = mysql_fetch_row(res);   // 取回第 i 行(实际是顺序取下一条)
    // 内层循环:遍历这一行的每一列
    for (int j = 0; j < fields; j++)
    {
        // line[j] 就是第 j 列的数据(字符串)
        // 注意:如果某个单元格是 NULL,line[j] 可能是 NULL,可做判空保护
        printf("%s\t", line[j] != NULL ? line[j] : "NULL");
    }
    printf("\n");    // 这一行打印完换行
}

习惯上,用 mysql_fetch_row 时,与其用"已知总行数的 for 循环",更推荐用 while 循环一路取到"返回 NULL"为止——这样你根本不用先数总行数,也不用担心行数太多导致循环变量类型的问题:

#include <mysql.h>
 
MYSQL_ROW row;    // 声明一个 MYSQL_ROW 变量,用来接每一行
// mysql_fetch_row 在"没有下一行"时会返回 NULL,所以直接用 while 判断
while ((row = mysql_fetch_row(res)) != NULL)
{
    // row 就是一大行数据,仍是个 char**(二维数组),用列号去取
    for (int j = 0; j < fields; j++)
    {
        printf("%s\t", row[j] != NULL ? row[j] : "NULL");
    }
    printf("\n");
}

到这里,"查数据并读出来"这条主路线已经打通了。无论查询结果多大多复杂,套路都是一样的:query → store_result → 取行数/列数/列名/行数据 → 打印或保存。下面我们把"改数据类"语句那个分支也演示一遍,再把字符集的大坑填上,然后拼出完整程序。

关闭连接:mysql_close 与释放资源

所有操作做完,别忘了收尾。两个函数起着"善后"作用:

  • mysql_free_result:把 mysql_store_result 那次 malloc 出来的结果集内存释放掉;
  • mysql_close:关闭连接,释放连接对象 MYSQL 相关的资源。
#include <mysql.h>
 
void mysql_free_result(MYSQL_RES *result);  // 释放结果集内存
void mysql_close(MYSQL *sock);              // 关闭连接,释放连接句柄

这两个几乎总是"收尾时成对出现"的。牢记本文开篇那句搞笑又残忍的话:你曾 malloc 的每一分内存、你曾连接的每一个句柄,最终都要物归原主,否则就是泄漏。 一个良好圭臬是:mysql_store_result 和 mysql_free_result 成对,mysql_init 和 mysql_close 成对;哪怕函数的中间分支里已经 return 了,也要在 return 之前把资源释放干净。

字符集:一劳永逸解决中文乱码

很多同学会撞上这么一件事:连上数据库、查英文一切正常,但一查中文,查出来的是一堆乱七八糟的乱码。 这不是数据库坏了,而是字符集不匹配导致的。要理解乱码,得知道一个背景:MySQL 客户端和服务器之间,凡是字符串信息,都会用一种字符集来编码;如果发送端用 A 字符集编码、接收端却用 B 字符集来解码,两边对不上,呈现出来自然就是乱码。

MySQL 连接默认用的字符集历史上是 latin1(一种沿用西方规律的单字节字符集,能表示英文和一部分欧洲文字,但不支持中文)。这就是"查中文乱码"的根本原因之一:客户端与服务器之间按 latin1 来解释字符串,中文字符在这套字符集里根本没有"正确解码"的对应关系。

解决办法,是显式地设置连接会话的字符集。这里有个新旧写法的区别:

  • 老一点的写法是 mysql_set_character_set(conn, "utf8")——它会把当前连接的字符集设为 utf8;但要注意,utf8 这种字符集最多用 3 个字节去编码一个字符,而那些"4 字节"的稀有汉字和一些 emoji 表情,utf8 是容不下的;
  • 更规范且当今推荐的写法是 utf8mb4——mb4 是 "most bytes 4"(最多 4 字节)的缩写。utf8mb4 是 MySQL 中真正能完整表达 Unicode(含 4 字节字符、emoji)的字符集。虽然 mysql_set_character_set 传 "utf8mb4" 也是字符串一个,但那几个字节的差别,恰恰是把"够不够全"分得清清楚楚的分水岭。

于是,连接成功之后,第一件事就是设置字符集,用 utf8mb4 一步到位:

#include <mysql.h>
 
// 连接成功、真正开始读写数据之前,先把连接字符集设为 utf8mb4
// 参数 1 是连接句柄,参数 2 是要设置的字符集名字符串
mysql_set_character_set(conn, "utf8mb4");

需要提醒的是:字符集是一套"体系工程",光设客户端这一端还不够。为了让中文全程不折腾,至少要做到"三层一致":

  1. 数据库/表的字符集:建库建表时尽量指定为 utf8mb4(比如 CREATE TABLE ... DEFAULT CHARSET=utf8mb4 或者建库时 CREATE DATABASE ... DEFAULT CHARACTER SET utf8mb4);
  2. 连接会话字符集:就是上面 mysql_set_character_set(conn, "utf8mb4") 这一处;
  3. C 程序源文件本身:你的 .c 文件要按 UTF-8 保存,这样代码里写的那些中文字符串字面量,才会以 UTF-8 编码存进可执行文件,再发给服务器才不会"从一个乱码源头出发"。

这三层只要对齐,中文乱码基本就与你无缘了。多数时候同学们排查乱码,最后都发现是"建表没指定 utf8mb4"或者"源代码没存成 UTF-8"——先把这三处核一遍,往往比瞎试字符集快得多。

错误处理:mysql_error 是调试的地图

我一路都在用 mysql_error,这里单独把它拎出来讲透,因为它是你调试时最重要的搭档。C API 里失败的动作,返回值会告诉你"成功还是失败",但不会告诉你"为什么失败"。想知道原因,就得问 mysql_error:

#include <mysql.h>
 
// 返回一个字符串,描述连接句柄最近一次操作失败的具体原因
const char *mysql_error(MYSQL *mysql);

它接收连接句柄,返回一个以 \0 结尾的字符串,内容就是最近一次出错的原因描述。用法几乎固定成了这么一行:

// 连接失败
if (mysql_real_connect(...) == NULL)
{
    fprintf(stderr, "连接失败: %s\n", mysql_error(conn));  // 把原因打出来
    return 1;
}
 
// 执行 SQL 失败
if (mysql_query(conn, sql) != 0)
{
    fprintf(stderr, "执行失败: %s\n", mysql_error(conn));  // 把原因打出来
    mysql_close(conn);
    return 1;
}

你可以把这个习惯看作"保留一份现场证据"。当程序报错时,mysql_error 打出来的那一行文字,往往直接点明是"密码错误"、"库不存在"、"SQL 语法错误"还是"连接被拒绝",绝大多数问题一眼就能定位。反过来,如果你忽略了错误返回、也不打印 mysql_error,程序就像一块黑板被擦得干干净净,报错时你完全无从下手。 这条没有技术难度,就看你愿不愿意每次失败都顺手打一行。我的建议是:写库操作的 C 程序,宁可多打错,不要不处理。

完整可运行程序:增删改查总动员

理论讲完,我们来把零零碎碎的东西拼成一个可以真正编译运行的完整程序。它的任务很明确,按顺序依次完成这些动作:

  1. 初始化连接对象,连接本机 MySQL;
  2. 设置 utf8mb4 字符集,杜绝中文乱码;
  3. 建表(CREATE TABLE):建一张学生表;
  4. 插入数据(INSERT):往表里插三行中文数据;
  5. 查询数据(SELECT):把表里数据全部查出来;
  6. 取行(mysql_fetch_row):逐行读取结果并打印;
  7. 释放资源(mysql_free_result + mysql_close):结果集和连接都释放干净。

每一次可能失败的调用,都配了 mysql_error 打错误;每一步都不省略、不占位。下面是完整代码,每行都写了注释:

// sql_demo.c —— MySQL C API 增删改查完整演示
// 编译:gcc -o sql_demo sql_demo.c -I./include -L./lib -lmysqlclient
// 若为动态库且运行时提示找不到 libmysqlclient.so,请设置 LD_LIBRARY_PATH(见文)
#include <stdio.h>      // 标准输入输出,printf / fprintf 都在这里
#include <mysql.h>      // MySQL C API 的核心头文件
 
int main(void)
{
    // ---------- 第一部分:初始化并连接数据库 ----------
    MYSQL *conn = mysql_init(NULL);   // 初始化连接对象;传 NULL 表示让库内部申请内存
    if (conn == NULL)                 // 初始化失败会返回 NULL
    {
        fprintf(stderr, "mysql_init 失败\n");   // 把失败原因打到标准错误流
        return 1;                     // 初始化失败,无法继续,直接返回错误码
    }
 
    // 真正连接服务器:主机 127.0.0.1、用户名 root、密码 123456、默认库 school
    // 请把 "root"、"123456"、"school" 换成你本机真实可用的配置
    if (mysql_real_connect(conn,
                           "127.0.0.1",   // 服务器地址:本机回环 IP
                           "root",        // 用户名
                           "123456",      // 密码
                           "school",      // 默认连接的数据库名(需提前存在)
                           0,             // 端口 0 表示用默认 3306
                           NULL,          // 不使用 Unix 套接字,走 TCP
                           0) == NULL)    // 客户端标志位默认 0
    {
        // 连接失败:mysql_error() 能告诉我们具体原因
        fprintf(stderr, "连接数据库失败: %s\n", mysql_error(conn));
        mysql_close(conn);   // 失败也要清理:释放连接对象
        return 1;            // 返回错误码退出
    }
    printf("数据库连接成功\n");
 
    // ---------- 第二部分:设置字符集,防中文乱码 ----------
    // 设成 utf8mb4,完整支持中文和 emoji,比旧版 utf8 更全
    mysql_set_character_set(conn, "utf8mb4");
 
    // ---------- 第三部分:建表 ----------
    // 建一张学生表:id 自增主键,name 学生名,score 分数
    // "IF NOT EXISTS" 保证表已存在时不会报错(重复建表会失败)
    const char *create_sql =
        "CREATE TABLE IF NOT EXISTS student("
        "id INT PRIMARY KEY AUTO_INCREMENT,"      // 整数主键,自增
        "name VARCHAR(50) NOT NULL,"              // 姓名,最大 50 字符,非空
        "score INT"                               // 分数,整数
        ");";
    if (mysql_query(conn, create_sql) != 0)       // 非 0 表示执行失败
    {
        fprintf(stderr, "建表失败: %s\n", mysql_error(conn));
        mysql_close(conn);
        return 1;
    }
    printf("建表成功(若已存在则跳过)\n");
 
    // ---------- 第四部分:插入数据 ----------
    // 一次性插入三行;中文以 UTF-8 编码写进源码,配合上面的 utf8mb4 才能正确存储
    const char *insert_sql =
        "INSERT INTO student(name, score) VALUES('张三', 95),('李四', 88),('王五', 76);";
    if (mysql_query(conn, insert_sql) != 0)
    {
        fprintf(stderr, "插入失败: %s\n", mysql_error(conn));
        mysql_close(conn);
        return 1;
    }
    // mysql_affected_rows 返回刚才 INSERT/UPDATE/INSERT 影响的行数
    // my_ulonglong 是无符号大整数,用 %llu 打印
    printf("插入成功,影响行数: %llu\n", mysql_affected_rows(conn));
 
    // ---------- 第五部分:查询数据 ----------
    const char *select_sql = "SELECT id, name, score FROM student;";
    if (mysql_query(conn, select_sql) != 0)
    {
        fprintf(stderr, "查询失败: %s\n", mysql_error(conn));
        mysql_close(conn);
        return 1;
    }
 
    // 注意:SELECT 执行完,数据并不在你的手里,必须用 store_result 把结果取回来
    // mysql_store_result 会 malloc 一块内存装整个结果集
    MYSQL_RES *res = mysql_store_result(conn);
    if (res == NULL)                        // 取结果失败同样要检查
    {
        fprintf(stderr, "取结果失败: %s\n", mysql_error(conn));
        mysql_close(conn);
        return 1;
    }
 
    // ---------- 第六部分:取列名并逐行读取数据 ----------
    unsigned int num_fields = mysql_num_fields(res);  // 拿到总列数
    MYSQL_FIELD *fields = mysql_fetch_fields(res);    // 拿到字段数组(含列名)
 
    // 先打印表头(各列列名),列名之间用 Tab 对齐
    for (unsigned int i = 0; i < num_fields; i++)
    {
        printf("%s\t", fields[i].name);     // 第 i 列的列名
    }
    printf("\n");                            // 表头打印完换行
 
    MYSQL_ROW row;                           // 声明 MYSQL_ROW,本质是 char**(二维数组)
    // while 循环:mysql_fetch_row 每调一次取下一行,没有下一行时返回 NULL 结束
    while ((row = mysql_fetch_row(res)) != NULL)
    {
        // 这一行的每一列
        for (unsigned int j = 0; j < num_fields; j++)
        {
            // row[j] 是第 j 列数据;若为 NULL 则显示 NULL,避免对 NULL 打印崩溃
            printf("%s\t", row[j] != NULL ? row[j] : "NULL");
        }
        printf("\n");                        // 这一行打印完换行
    }
 
    // ---------- 第七部分:释放资源,收尾 ----------
    mysql_free_result(res);                  // 释放"结果集"的内存(与 store_result 成对!)
    mysql_close(conn);                       // 关闭连接,拥抱连接对象(与 init 成对!)
    printf("资源释放完毕,程序结束\n");
    return 0;                                // 正常结束
}

编译运行这条命令:

# -I 指定头文件目录,-L 指定库目录,-l 指名链接的库
gcc -o sql_demo sql_demo.c -I./include -L./lib -lmysqlclient
 
# 如果你的打开方式会是运行时找不到动态库,先设置库搜索路径再运行
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:./lib
./sql_demo

假设你的库、表、账号密码都配好了,不出意外,屏幕会输出类似这样的内容:

数据库连接成功
建表成功(若已存在则跳过)
插入成功,影响行数: 3
id      name    score
1       张三    95
2       李四    88
3       王五    76
资源释放完毕,程序结束

看到表格被整齐打出、中文不乱码,这篇的"主菜"就算完整吃下来了。之后你想加"更新"、"删除"逻辑也很容易——它们跟后面那部分的模式几乎一模一样:写一条 UPDATE / DELETE 的 SQL,交给 mysql_query 执行,检查返回值,再顺手用 mysql_affected_rows 看看改了几行,逻辑上比 SELECT 还简单(不用取结果集)。

至此,连接、建表、插入、查询、取行、释放这六个环节全链条打通了。你再看一眼就会发现,所有复杂操作最后都可以归纳到孩子们都会的那句口诀:init → connect → query →(查的话就)store_result / fetch_xxx → free_result → close。 记住这个骨架,往后任何 MySQL 的 C 编程,无非是在这套骨架上填补细节。

扩展:事务操作

如果连 curd 都已经熟练,你大概会好奇"MySQL 支持事务,那 C API 里我怎么用?"答案是,MySQL C API 对事务这类常用操作也直接提供了封装,常见三个:

#include <mysql.h>
 
// 开启 / 关闭自动提交模式(因为 MySQL 默认开启自动提交,每条语句立即生效)
my_bool mysql_autocommit(MYSQL *mysql, my_bool auto_mode);
 
// 提交当前事务
my_bool mysql_commit(MYSQL *mysql);
 
// 回滚当前事务
my_bool mysql_rollback(MYSQL *mysql);

用法思路是:先用 mysql_autocommit(mysql, 0)(传 0 即关闭自动提交),然后连续执行多个 mysql_query,最后要么 mysql_commit 一起提交、要么 mysql_rollback 全部撤销。这样就保证了"要么全成功,要么全不生效"的一致性。事务在"转账""订单扣减库存"这类强一致性场景里是刚需——不过它属于进阶话题,先把基础链路练熟了,再回头研究这一套即可。

我建议你把事务的那些细节(隔离级别、脏读/不可重复读/幻读、锁)放到单独的章节去系统学,这里知道"C API 有 mysql_autocommit / mysql_commit / mysql_rollback 这三个函数"就够用了。

思考题与详解答案

学到这里,光看不练是记不牢的。我准备了四道思考题,每道都给了详尽的答案解析——强烈建议你先自己动手答、亲手敲一敲代码,再来看答案比对,这样印象才深。

思考题一:为什么用了 mysql_store_result 就一定要 mysql_free_result?

这道题考察的是对"C 接口内存管理"这条铁律的理解。不少人知道"要释放",但说不清"为什么"。

答案: mysql_store_result 不是"凭空变出"一份结果——它为了保证你后面可以反复读取这些数据,会在客户端进程的内存里 malloc 出一块缓冲区,把服务器返回的结果集整个拷贝到这块自己管理的内存中。既然内存是它申请的,释放的责任自然落在你头上。你如果只 store_result 而不 free_result,每次都在这边悄悄申请一份新内存、却从不归还给系统,反复操作几次,进程占用的内存就会只增不减,这就是典型的内存泄漏。顺带一提,内存泄漏的"威力"在长期运行的服务端程序里最可怕:进程内存越占越多,最后可能被系统 OOM 杀掉。因此规则必须刻进骨头:store_result 和 free_result 永远是成对出现的。

思考题二:为什么会中文乱码?如何彻底解决?

这道题考的是字符集的"三层联动"。很多同学调了半天字符集还是乱码,往往就是没把三层看全。

答案: 乱码的本质是"编码和解码用的字符集不一致"。MySQL 通信涉及字符串的编码解码,如果客户端发送字符串时用一种字符集编码,而接收方(或存储结构)用另一种字符集去理解,就会对不上号、显示成乱码。历史上 MySQL 连接默认字符集常是 latin1(不支持中文),这直接导致"直接查中文就是乱码"。要彻底解决,需要三层对齐到 UTF-8 体系:

  1. 数据库/表的存储字符集:建库建表时指定 utf8mb4,保证存进去时就是 UTF-8 编码;
  2. 连接会话字符集:连接成功后调用 mysql_set_character_set(conn, "utf8mb4"),让客户端与服务器之间用 UTF-8 互通;
  3. C 源码文件的保存编码:.c 文件本身要保存为 UTF-8,否则你代码里写的"张三"从编译那一刻起就是乱码的起点。

另外要单独强调 utf8mb4 vs utf8 的差别:utf8 最多 3 字节编码一个字符,装不下 emoji 和部分生僻汉字;utf8mb4(mb4 = most bytes 4)最多 4 字节,是 MySQL 表示 Unicode 的最全字符集。所以在三处字符集设置里,统一用 utf8mb4 是最省心、最不易出错的做法。

思考题三:MYSQL_RES、MYSQL_ROW、MYSQL_FIELD 各是什么?它们是什么关系?

这道题考的是三个最核心数据类型的分工。几乎每个初学者都会把这几个名字搞混。

答案: 它们三个分工明确、逐层递进:

  • MYSQL_RES:整个"结果集"的容器,装的是"这张结果表"的所有内容——总行数、总列数、列信息数组、以及落数据的三维缓冲。拿到它,你就拿到了整张查询结果的"全局"。
  • MYSQL_FIELD:描述"某一列"的元信息,比如列名 name、数据类型、长度等。mysql_fetch_fields 返回的 MYSQL_FIELD * 其实是一个数组,长度等于列数,下标 i 就对应第 i 列。它是用来打印列名/了解列结构的。
  • MYSQL_ROW:本质是 char **(一个指向字符指针数组的指针),代表"一行"数据。mysql_fetch_row 每次返回一行,其中 row[i] 就是第 i 列的字符串值,直到没有更多行时返回 NULL。

它们的层级关系可以这样理:MYSQL_RES(整张表)由若干"行"构成,每"行"用 MYSQL_ROW 表示;而描述"列长什么样"用 MYSQL_FIELD。操作套路是:store_result 得到 MYSQL_RES → 用 mysql_num_fields/mysql_fetch_fields 拿到列数和 MYSQL_FIELD → 用 mysql_fetch_row 循环拿 MYSQL_ROW → 从 row[j] 取出第 j 列数据。

思考题四:mysql_query 成功就代表"查到数据"了吗?它和 mysql_store_result 是什么关系?

这道题考的是对"查询语句执行"与"结果集获取"两个阶段能否清晰分离。

答案: 不能划等号。mysql_query 的返回值只表明"这条 SQL 语句有没有正常执行完",而不代表"有没有查到东西、结果在哪"。比如你执行 SELECT * FROM 空表;,mysql_query 也返回 0(成功),但没有任何数据。

mysql_store_result 才是负责"把结果真正取到客户端"的动作:它内部通过 MYSQL 里 st_mysql_methods 的 read_rows 函数指针,从服务器把结果整批搬到客户端内存,并封装成一个 MYSQL_RES 交还给你。所以二者的关系可以概括为:mysql_query 是"下达命令",mysql_store_result 是"接收战果"——对 UPDATE/INSERT/DELETE 这类不需要结果的语句,query 办完就够;对 SELECT 这类"要回血"的语句,query 之后必须再走 store_result 才能把数据握在手里。


到这里,我们从"要准备哪些文件"出发,一路打怪升级,最终拼出了一个能完整运行的 C 程序。回头看看这一路的关键支柱:MYSQL 是连接句柄、mysql_init 领它入场、mysql_real_connect 打通TCP连接、mysql_query 下发命令、mysql_store_result 收回战场、mysql_num_rows/mysql_num_fields/mysql_fetch_fields/mysql_fetch_row 从结果里取数、mysql_free_result/mysql_close 送资源离场,中间还顺便把 utf8mb4 的中文乱码和 mysql_error 的错误地图一起装了袋。

C 语言操作数据库,从来不是"背一堆函数签名"的机械活,而是一套有章可循的纪律:资源成对、失败必查、字符集三层对齐。只要这三点守得住,无论你想在 MySQL 里做多复杂的操作,都能稳稳地落在我们铺好的这套骨架上。当真到项目里,你会发现自己越来越不依赖记忆函数原型,而更像在玩一件顺手的工具——因为这一整套"C 语言与 MySQL 打交道"的心智模型,已经长在你脑子里了。

代码这个东西,看十遍不如敲一遍。我建议你把这篇文章里的例子,从环境准备开始,一行一行地自己敲出来、亲手把每个错误都踩一遍——尤其是那个"运行时找不到动态库"和"中文乱码"的坑,踩过一次、真正解掉一次,比看十遍都记得牢。把 sql_demo.c 跑通之后,再试着给它加一个"更新某行分数"和"按条件删除某行"的功能练练手,你就真的把增删改查拿下了一大半。

下一篇文章,我们可以乘胜追击,深入 mysql_stmt_* 这套**预处理语句(prepared statement)**接口——它在防止 SQL 注入、反复执行时提升效率上,比我们这章用的简单 mysql_query 更进一步,是写生产级代码时几乎绕不过去的进阶。准备好了吗?