Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 11.218 phút đọc

Ký tự kết thúc chuỗi

Sau bài này bạn sẽ làm được

  • Giải thích vai trò của ký tự null trong mọi hàm chuỗi
  • Phân biệt kích thước mảng, độ dài chuỗi và số ô đã dùng
  • Nhận ra khi nào một thao tác làm mất ký tự kết thúc
  • Dùng công cụ để phát hiện đọc vượt vùng nhớ

Một byte duy nhất quyết định toàn bộ hành vi của thư viện chuỗi. Bài này nói kỹ về nó: nó ở đâu, khi nào nó biến mất, và chuyện gì xảy ra khi nó không còn.

#Vai trò của byte 0

Ký tự kết thúc chuỗi
Byte có giá trị 0, viết trong mã nguồn là '\0'. Nó không phải ký tự số 0, mà là giá trị 0. Ký tự số 0 có mã ASCII là 48.
Chuỗi năm ký tự chiếm sáu ô. Ô cuối chứa giá trị 0, và mọi hàm chuỗi đều dừng khi gặp nó.
'\0'   /* giá trị 0, ký tự kết thúc chuỗi */
'0'    /* giá trị 48, ký tự chữ số không */
0      /* giá trị 0, tương đương '\0' nhưng viết vậy dễ nhầm với NULL */
"0"    /* chuỗi hai byte: 48 rồi 0 */

Mọi hàm trong string.h đều hoạt động theo cùng một cách: đi từng byte cho tới khi gặp giá trị 0. Không hàm nào biết mảng của bạn dài bao nhiêu, và không hàm nào kiểm tra biên.

/* Đây gần như là toàn bộ cài đặt của strlen */
size_t strlen(const char *s)
{
    const char *p = s;

    while (*p != '\0')
        ++p;

    return (size_t)(p - s);
}

#Ba con số dễ nhầm

ba-con-so.c
#include <stdio.h>
#include <string.h>

int main(void)
{
    char s[10] = "hello";

    printf("sizeof s  = %zu\n", sizeof s);    /* 10, kích thước mảng */
    printf("strlen(s) = %zu\n", strlen(s));   /* 5, số ký tự trước byte 0 */
    printf("da dung   = %zu\n", strlen(s) + 1); /* 6, số ô đang có ý nghĩa */

    return 0;
}
terminal
./ba-con-so
sizeof s  = 10
strlen(s) = 5
da dung   = 6
Con sốVới char s[10] = "hello"Biết được lúc nào
Kích thước mảng10Lúc biên dịch, và chỉ ở nơi khai báo
Độ dài chuỗi5Lúc chạy, phải đếm nên tốn thời gian
Số ô đã dùng6Bằng độ dài cộng một
Chỗ trống còn lại4Bằng kích thước trừ độ dài trừ một

#Khi nào ký tự kết thúc bị mất

Trường hợp 1: khai báo vừa khít

char s[5] = "hello";     /* 5 ký tự vào 5 ô, không còn chỗ cho byte 0 */

Trường hợp 2: strncpy khi nguồn quá dài

char dich[5];

strncpy(dich, "hello world", 5);   /* chép đúng 5 byte, KHÔNG thêm byte 0 */

printf("%s\n", dich);              /* đọc tràn */

Trường hợp 3: điền mảng bằng vòng lặp

char s[6];

for (int i = 0; i < 5; ++i)
    s[i] = 'a' + i;

/* Quên s[5] = '\0'; nên s không phải chuỗi hợp lệ */

Trường hợp 4: memcpy nhầm số byte

char nguon[] = "hello";
char dich[10];

memcpy(dich, nguon, strlen(nguon));       /* chép 5 byte, thiếu byte kết thúc */
memcpy(dich, nguon, strlen(nguon) + 1);   /* đúng: chép 6 byte */
memcpy(dich, nguon, sizeof nguon);        /* cũng đúng: sizeof đã tính cả byte 0 */

Trường hợp 5: đọc dữ liệu nhị phân

char bo_dem[256];
size_t n = fread(bo_dem, 1, sizeof bo_dem - 1, f);

bo_dem[n] = '\0';      /* BẮT BUỘC: fread không thêm byte kết thúc */

#Hậu quả khi thiếu

thieu.c
#include <stdio.h>
#include <string.h>

int main(void)
{
    char a[5] = "hello";     /* không có byte kết thúc */
    char b[5] = "world";     /* nằm ngay cạnh trong bộ nhớ */

    printf("[%s]\n", a);
    printf("strlen(a) = %zu\n", strlen(a));

    return 0;
}
terminal
gcc -std=c17 -Wall thieu.c -o t && ./t
[helloworld]
strlen(a) = 10
# Kết quả này hoàn toàn ngẫu nhiên, phụ thuộc cách trình biên dịch xếp biến
gcc -O2 thieu.c -o t && ./t
[hello\xc0\x8e...]
strlen(a) = 23
Hậu quảMức nghiêm trọngGiải thích
In ra rácNhẹChỉ khó chịu, dễ phát hiện
strlen trả về số quá lớnTrung bìnhMọi tính toán dựa trên nó đều sai
Rò rỉ dữ liệu nhạy cảmNặngNội dung bộ nhớ kế bên bị in ra hoặc gửi đi
Chương trình sậpNặngĐọc tới vùng nhớ không thuộc tiến trình
Ghi tràn khi dùng làm nguồn để chépRất nặngLà lỗ hổng bảo mật khai thác được

#Tự viết hàm dừng đúng chỗ

tu-viet.c
/* Mẫu chuẩn: duyệt tới khi gặp byte 0 */
size_t dem_ky_tu(const char *s, char c)
{
    size_t dem = 0;

    for (size_t i = 0; s[i] != '\0'; ++i)
        if (s[i] == c)
            ++dem;

    return dem;
}

/* Mẫu bằng con trỏ, cách viết của thư viện chuẩn */
size_t dem_ky_tu2(const char *s, char c)
{
    size_t dem = 0;

    while (*s != '\0') {
        if (*s == c) ++dem;
        ++s;
    }

    return dem;
}
Hàm nhận độ dài từ bên ngoài mà không kiểm tra
void chep(char *dich, const char *nguon)
{
    size_t i = 0;

    while (nguon[i] != '\0') {
        dich[i] = nguon[i];    /* không biết dich còn bao nhiêu chỗ */
        ++i;
    }

    dich[i] = '\0';
}
Hàm luôn nhận cả kích thước bộ đệm
/* Trả về độ dài chuỗi nguồn để người gọi biết có bị cắt không. */
size_t chep(char *dich, size_t co, const char *nguon)
{
    size_t i = 0;

    if (co == 0) return strlen(nguon);

    while (nguon[i] != '\0' && i + 1 < co) {
        dich[i] = nguon[i];
        ++i;
    }

    dich[i] = '\0';

    return strlen(nguon);
}

Quy ước trả về độ dài chuỗi nguồn là quy ước của hàm strlcpy trên các hệ BSD và snprintf trong chuẩn C. Người gọi so kết quả với kích thước bộ đệm để biết chuỗi có bị cắt hay không.

char dich[8];

if (chep(dich, sizeof dich, nguon) >= sizeof dich)
    fprintf(stderr, "Canh bao: chuoi bi cat\n");

Tự làm thử

  1. In giá trị số của '\0', '0' "0"[0], giải thích ba con số.
  2. Khai báo char s[10] = "hello" rồi in cả sizeof, strlen và chỗ trống còn lại.
  3. Tạo hai mảng năm ký tự cạnh nhau, cả hai đều thiếu byte kết thúc, in ra và quan sát hiện tượng nối liền.
  4. Dùng strncpy với chuỗi nguồn dài hơn bộ đệm, in kết quả, rồi sửa bằng cách tự đặt byte 0 và bằng snprintf.
  5. Dùng memcpy chép chuỗi với ba giá trị độ dài khác nhau như trong bài, quan sát cái nào đúng.
  6. Cài hàm chep có kiểm tra kích thước, thử với bộ đệm quá nhỏ và xác nhận nó cắt chuỗi đúng cách.

Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.

Tóm tắt

  • Ký tự kết thúc là byte giá trị 0, viết là '\0', khác hoàn toàn với ký tự chữ số '0' có mã 48.
  • Ba con số khác nhau: kích thước mảng, độ dài chuỗi, và số ô đã dùng bằng độ dài cộng một.
  • Mọi hàm không có chữ str trong tên đều không tự thêm byte kết thúc. strncpy cũng không, khi nguồn quá dài.
  • Thiếu byte kết thúc dẫn tới đọc tràn, và trong trường hợp xấu nhất là rò rỉ dữ liệu nhạy cảm.
  • Hàm tự viết ghi vào bộ đệm luôn phải nhận thêm kích thước bộ đệm đó.