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

Chương trình C đầu tiên

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

  • Giải thích được ý nghĩa của từng token trong chương trình Hello World
  • Phân biệt được int main(void) với các biến thể sai chuẩn
  • Mô tả được đường đi từ lệnh ./a.out đến khi main chạy
  • Tìm được năm lỗi trong một chương trình ngắn

Chương trình Hello World chỉ có năm dòng, nhưng mỗi ký tự trong đó đều có lý do tồn tại. Bài này giải thích từng thành phần, rồi chứng minh một điều mà phần lớn người học C không biết: hàm main không phải nơi chương trình bắt đầu chạy.

#Chương trình đầu tiên

hello.c
#include <stdio.h>

int main(void)
{
    printf("Hello, World!\n");

    return 0;
}
terminal
gcc -std=c17 -Wall -Wextra hello.c -o hello
./hello
Hello, World!

#Mổ xẻ từng thành phần

Thành phầnÝ nghĩa
#includeChỉ thị tiền xử lý. Có dấu # nghĩa là việc của preprocessor, không phải câu lệnh C, nên không có dấu chấm phẩy ở cuối.
<stdio.h>Header của thư viện nhập xuất chuẩn, chứa khai báo của printf. Dấu <> nghĩa là tìm trong thư mục hệ thống, dấu nháy kép nghĩa là tìm cạnh file hiện tại trước.
intKiểu trả về của main. Hệ điều hành nhận số nguyên này để biết chương trình chạy thành công hay thất bại.
mainTên hàm mà hệ thống quy ước sẽ gọi để bắt đầu phần mã của bạn.
(void)Nghĩa là không nhận tham số nào. Đây là điểm rất dễ nhầm, xem mục biến thể bên dưới.
{ }Khối lệnh. Nhóm các câu lệnh lại, đồng thời tạo ra một phạm vi mới.
printfHàm in ra màn hình theo định dạng. Nó trả về số ký tự đã in, một chi tiết ít ai để ý.
"Hello, World!\n"Chuỗi ký tự. Phần \n là ký tự xuống dòng, tính là một ký tự chứ không phải hai.
;Kết thúc một câu lệnh. Thiếu nó là lỗi biên dịch ngay lập tức.
return 0Kết thúc main và trả 0 cho hệ điều hành. Theo quy ước, 0 nghĩa là thành công.

#Giải phẫu hàm main

Mọi định nghĩa hàm trong C đều có đúng bốn phần. Nắm được cấu trúc này là bạn đọc được mọi hàm C, không riêng gì main.

Bốn thành phần của một định nghĩa hàm, thứ tự luôn cố định.

#Các biến thể của main

Bạn sẽ gặp nhiều cách viết main khác nhau trên internet. Không phải cách nào cũng đúng chuẩn.

Các cách viết main
void main(void)                    // Sai chuẩn, dù nhiều trình biên dịch chấp nhận
int  main()                        // Truoc C23: chua noi gi ve tham so
int  main(void)                    // Dung
int  main(int argc, char *argv[])  // Dung, khi can tham so dong lenh
Chỉ hai dòng được đánh dấu là hợp chuẩn C.

Điểm khác biệt giữa main()main(void) tinh tế nhưng quan trọng. Trước C23, ngoặc rỗng nghĩa là chưa nói gì về tham số, nên trình biên dịch không kiểm tra được nếu bạn gọi hàm với số tham số sai. Còn void nghĩa là khẳng định không có tham số nào. Từ C23, hai cách viết này đã đồng nghĩa, nhưng thói quen viết (void) vẫn đúng ở mọi phiên bản.

#main không phải điểm bắt đầu

Đây là phần thú vị nhất của bài. Khi bạn gõ ./hello, có rất nhiều việc xảy ra trước khi dòng printf đầu tiên được thực thi.

main nằm ở gần cuối chuỗi. Điểm vào thật sự ghi trong file thực thi là _start.

Tự kiểm chứng

Đừng tin, hãy kiểm tra. Địa chỉ điểm vào ghi trong file thực thi không trùng với địa chỉ của main:

terminal
readelf -h hello | grep Entry
Entry point address: 0x1040
nm hello | grep -E ' (main|_start)$'
0000000000001040 T _start
0000000000001129 T main
# Địa chỉ điểm vào trùng với _start, không phải main

Chứng minh bằng mã chạy được

Chương trình dưới đây có mã chạy trướcsau main. Nếu main thật sự là điểm bắt đầu và kết thúc, điều này không thể xảy ra.

startup.c
#include <stdio.h>
#include <stdlib.h>

__attribute__((constructor))
static void truoc_main(void) { puts("1. constructor"); }

__attribute__((destructor))
static void sau_main(void) { puts("4. destructor"); }

static void luc_thoat(void) { puts("3. atexit"); }

int main(void)
{
    atexit(luc_thoat);
    puts("2. main");
    return 0;
}
terminal
gcc startup.c -o startup && ./startup
1. constructor
2. main
3. atexit
4. destructor

#Mã thoát

Giá trị mà main trả về đi thẳng tới hệ điều hành. Trong shell, bạn đọc nó bằng $?. Đây là nền tảng để nối các lệnh với nhau bằng &&.

terminal
./hello
Hello, World!
echo $?
0
# 0 nghĩa là thành công, khác 0 nghĩa là có lỗi
  • 0 nghĩa là thành công. Hằng số EXIT_SUCCESS trong <stdlib.h> cũng bằng 0.
  • Khác 0 nghĩa là có lỗi. EXIT_FAILURE thường bằng 1.
  • Từ C99, nếu main chạy hết mà không có return, trình biên dịch tự hiểu là return 0. Nhưng vẫn nên viết rõ ra.

#Bài tìm lỗi

Chương trình dưới đây có năm lỗi. Hãy tìm hết trước khi biên dịch, sau đó biên dịch để đối chiếu với thông báo của trình biên dịch.

loi.c
#include <stdio.h>;

int Main(void)
{
    printf("Hello World\n")
    printf("So la: %d\n");
    return 0
}
Xem đáp án
  • Dòng 1: thừa dấu chấm phẩy sau #include <stdio.h>. Chỉ thị tiền xử lý không kết thúc bằng dấu chấm phẩy.
  • Dòng 3: Main viết hoa chữ M. C phân biệt chữ hoa và chữ thường, phải là main.
  • Dòng 5: thiếu dấu chấm phẩy sau lời gọi printf.
  • Dòng 6: có %d trong chuỗi định dạng nhưng không truyền đối số tương ứng. Đây là hành vi không xác định, nguy hiểm hơn hẳn bốn lỗi còn lại vì chương trình vẫn biên dịch được.
  • Dòng 7: thiếu dấu chấm phẩy sau return 0.

Tự làm thử

  1. In tên, mã số sinh viên và lớp của bạn trên ba dòng riêng biệt.
  2. In một hình vuông rỗng bằng dấu sao, cạnh 5 ký tự, chỉ dùng các lời gọi printf.
  3. Bỏ \n ở cuối chuỗi rồi chạy lại. Quan sát dấu nhắc shell xuất hiện ở đâu và giải thích.
  4. Thử return 42; rồi echo $?. Sau đó thử return 300; và giải thích kết quả nhận được.
  5. Chạy lại chương trình startup.c nhưng bỏ dòng atexit. Thứ tự in ra thay đổi thế nào?

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

  • #include là chỉ thị tiền xử lý, không phải câu lệnh C, nên không có dấu chấm phẩy.
  • Hàm gồm bốn phần: kiểu trả về, tên hàm, danh sách tham số, thân hàm.
  • Hai cách viết hợp chuẩn là int main(void) int main(int argc, char *argv[]). void main sai chuẩn.
  • Điểm vào thật sự của chương trình là _start, không phải main. Kiểm chứng bằng readelf -h.
  • Giá trị main trả về là mã thoát, đọc bằng $?, và chỉ giữ 8 bit thấp.
  • Lỗi nguy hiểm nhất là lỗi vẫn biên dịch được, ví dụ chuỗi định dạng thiếu đối số.