Chuyển tới nội dung chính

BÀI 1. GIỚI THIỆU VỀ LẬP TRÌNH VÀ CHƯƠNG TRÌNH

Mục tiêu​

Sau bài này, học viên:

  • Phát biểu được chương trình, lập trình, ngôn ngữ lập trình và sự khác nhau giữa ngôn ngữ máy, hợp ngữ và ngôn ngữ bậc cao.
  • Mô tả được vai trò của C, các đặc điểm khiến C vừa gần phần cứng vừa khả chuyển, và các mốc chuẩn C.
  • Phân biệt được mã nguồn, mã đối tượng, file thực thi, thư viện và tiến trình đang chạy.
  • Đọc được một chương trình C tối thiểu: chỉ thị tiền xử lý, hàm main, khối lệnh, câu lệnh, return.
  • Thực hiện được quy trình xây dựng chương trình: đặc tả, thuật toán, mã hóa, biên dịch, chạy, kiểm thử, sửa lỗi.
  • Phân loại được lỗi cú pháp, lỗi liên kết, lỗi thực thi và lỗi logic.
  • Ở mức cao thủ: hình dung được pipeline biên dịch và mô hình bộ nhớ của một tiến trình, biết hành vi không xác định là gì và vì sao nó nguy hiểm hơn một lỗi cú pháp.

Bài này chưa đi sâu biến, toán tử, rẽ nhánh, vòng lặp, hàm, mảng, cấu trúc. Những thứ đó lần lượt thuộc Bài 3 đến Bài 8. Ở đây ta học chương trình là gì và nó được tạo ra như thế nào.


1. Máy tính làm việc bằng chỉ thị​

Máy tính không “hiểu” bài toán theo cách con người hiểu. Bộ xử lý chỉ thực hiện một tập lệnh rất nhỏ: đọc một ô nhớ, cộng hai số, so sánh, nhảy đến một chỉ thị khác, ghi kết quả. Mọi phần mềm — hệ điều hành, trình duyệt, trò chơi, bộ điều khiển thiết bị — cuối cùng đều là một dãy chỉ thị như vậy.

Chương trình (program) là một bộ chỉ thị được sắp xếp để máy tính hoàn thành một tác vụ cụ thể. Đổi chương trình là đổi việc máy làm, trong khi phần cứng không đổi.

Lập trình (programming) là quá trình xây dựng chương trình nguồn bằng một hoặc nhiều ngôn ngữ lập trình, rồi đưa chương trình đó đến trạng thái máy tính thực thi được và cho kết quả đúng.

Ba câu này phải tách bạch:

Khái niệmCâu hỏi nó trả lời
Bài toánCần tính hoặc quyết định điều gì?
Thuật toánCác bước xử lý là gì, độc lập với ngôn ngữ?
Chương trìnhCác bước đó được viết bằng ngôn ngữ nào, và máy chạy bản nào?

Thuật toán được học kỹ ở Bài 2. Bài 1 chỉ cần thấy: không có thuật toán thì chưa có chương trình đúng, dù cú pháp có hoàn hảo.


2. Ba mức ngôn ngữ​

2.1. Ngôn ngữ máy​

Là dãy bit mà bộ xử lý của một họ máy hiểu trực tiếp. Con người hầu như không viết và không đọc được ở quy mô lớn. Mỗi họ CPU có tập lệnh riêng, nên chương trình máy của kiến trúc này không chạy trên kiến trúc kia.

2.2. Hợp ngữ (assembly)​

Mỗi lệnh máy được viết bằng một tên ngắn (add, mov, cmp) và toán hạng. Dễ đọc hơn bit, nhưng vẫn gắn chặt một CPU, và một việc nhỏ cũng cần rất nhiều lệnh. Hợp ngữ là cửa sổ để hiểu C, vì trình biên dịch C sinh ra hợp ngữ trước khi thành mã máy.

2.3. Ngôn ngữ lập trình bậc cao​

Dùng ký hiệu và cú pháp chuẩn hóa để mô tả xử lý theo cách cả người và công cụ dịch đều hiểu được. Một câu lệnh bậc cao thường tương ứng nhiều lệnh máy. Người lập trình tập trung vào bài toán; trình biên dịch đảm nhận việc hạ xuống phần cứng.

C nằm ở vị trí đặc biệt: cú pháp là ngôn ngữ bậc cao, nhưng mô hình tính toán vẫn bám sát bộ nhớ, địa chỉ và chi phí lệnh. Người ta gọi C là ngôn ngữ hệ thống vì lý do này.

Chiều mũi tên là chiều dịch xuống. Chiều ngược lại là chiều học để làm chủ: muốn hiểu vì sao chương trình chậm, sai hoặc không khả chuyển, phải nhìn được phía dưới C, dù không viết hợp ngữ hàng ngày.


3. Ngôn ngữ C: nguồn gốc, đặc điểm, chuẩn​

Dennis Ritchie thiết kế C tại Bell Labs năm 1972, phát triển từ ngôn ngữ B của Ken Thompson, với mục tiêu viết hệ điều hành UNIX. C không được thiết kế để “dễ dãi” với người mới. Nó được thiết kế để diễn tả được những gì hệ thống cần, với ít từ khóa và ít chi phí ẩn.

Bốn đặc điểm cần nhớ suốt khóa học:

  1. Mạnh và ít ràng buộc. C cho phép thao tác gần bộ nhớ. Sức mạnh này đi kèm trách nhiệm: trình biên dịch không cứu bạn khỏi mọi sai lầm logic.
  2. Khả chuyển. Cùng một mã nguồn, dịch lại, có thể chạy trên máy khác. Khả chuyển không có nghĩa là “viết một lần là đúng trên mọi máy”. Kích thước kiểu int, thứ tự byte, đường dẫn file vẫn có thể khác nhau. Chuẩn C tồn tại để thu hẹp phần khác biệt đó.
  3. Cô đọng. Số từ khóa ít. Phần lớn sức mạnh nằm ở thư viện chuẩn và ở cách ghép các thành phần nhỏ.
  4. Lập trình cấu trúc. Chương trình được chia thành hàm. Hàm dùng lại được. Đây là cây cầu sang các môn sau (Java, Python, lập trình nhúng).

Các mốc chuẩn cần gọi đúng tên:

ChuẩnNămÝ nghĩa với người học
K&R C1978C cổ, trước khi có nguyên mẫu hàm đầy đủ
ANSI C / C89 / C901989–1990Chuẩn công nghiệp đầu tiên, nền của hầu hết giáo trình
C991999//, kiểu long long, mảng biến độ dài, khởi tạo chỉ định
C112011An toàn hơn một số điểm, _Static_assert, luồng
C172018Bản làm rõ C11
C232024Bản hiện đại; không phải máy nào cũng bật sẵn

Trong khóa này, mã ví dụ tuân C11 (-std=c11). Không dùng conio.h, getch(), clrscr(): đó không phải thư viện chuẩn, không chạy trên macOS/Linux. Muốn dừng màn hình để đọc kết quả, hãy chạy trong terminal và đọc output, hoặc dùng nhập liệu tiếp theo. return 0; trong main báo cho hệ điều hành biết chương trình kết thúc bình thường. Giá trị khác 0 là quy ước “có lỗi”.


4. Từ mã nguồn đến tiến trình​

Một file .c chưa phải là chương trình đang chạy. Trình biên dịch (GCC, Clang, hoặc bộ dịch bên trong Dev-C++, Code::Blocks) thực hiện một đường ống:

  1. Tiền xử lý. Xử lý dòng bắt đầu bằng #. #include <stdio.h> dán nội dung khai báo thư viện vào bản dịch. #define thay văn bản. Bản sau tiền xử lý vẫn là C, nhưng dài hơn file bạn viết.
  2. Biên dịch. Kiểm tra cú pháp, kiểu, ý nghĩa, rồi sinh hợp ngữ.
  3. Hợp dịch (assemble). Hợp ngữ thành mã đối tượng (.o hoặc .obj): mã máy của từng file, chưa đủ để chạy vì còn chỗ trống chờ hàm ở file khác.
  4. Liên kết (link). Ghép các .o với thư viện (ví dụ phần hiện thực của printf) thành file thực thi. Trên Windows thường là .exe. Trên macOS/Linux thường không có đuôi, và có quyền thực thi.
  5. Nạp và chạy. Hệ điều hành tạo một tiến trình, cấp bộ nhớ, đặt thanh ghi, rồi nhảy vào main.

Hệ quả thực tế:

  • Sửa .c mà không biên dịch lại thì file thực thi cũ vẫn là bản cũ.
  • Copy file thực thi sang máy cùng hệ điều hành có thể chạy được. Copy .c sang máy khác rồi dịch lại mới là tính khả chuyển.
  • Lỗi “undefined reference” là lỗi liên kết: biên dịch từng file đã xong, nhưng linker không tìm thấy hàm. Lỗi gạch đỏ ngay khi gõ thường là lỗi cú pháp.

Lệnh tối thiểu trên terminal (macOS/Linux), tương đương nút “biên dịch và chạy” của IDE:

gcc -std=c11 -Wall -Wextra -pedantic -g -o chuong_trinh chuong_trinh.c
./chuong_trinh

-Wall -Wextra bật cảnh báo. Cảnh báo không phải là “phong cách”. Nhiều cảnh báo là lỗi logic đang hình thành. -g nhúng thông tin để trình gỡ lỗi (debugger) chỉ đúng dòng. Trong Dev-C++ việc này tương ứng thêm tham số -g trong Compiler Options.

IDE chỉ là nơi soạn thảo, gọi trình biên dịch, và hiện thông báo. Dev-C++, Code::Blocks, Visual Studio Code, Cursor đều không thay thế ngôn ngữ C. Học giao diện một lần; học mô hình biên dịch thì dùng được mọi công cụ.


5. Giải phẫu chương trình C tối thiểu​

Mọi chương trình C trong khóa này có dạng:

#include <stdio.h>

int main(void)
{
printf("Welcome\n");
return 0;
}

Đọc từng thành phần, vì cả khóa sẽ lắp thêm vào cái khung này.

Thành phầnVai trò
#include <stdio.h>Chỉ thị tiền xử lý. Khai báo các hàm nhập/xuất chuẩn. stdio là standard input/output.
int main(void)Hàm bắt đầu thực thi. int là kiểu giá trị trả về. void nghĩa là không nhận tham số.
{ ... }Khối: ranh giới thân hàm.
printf(...);Một câu lệnh. Câu lệnh kết thúc bằng dấu chấm phẩy.
"Welcome\n"Hằng chuỗi. \n là ký tự xuống dòng, không phải hai ký tự người đọc thấy trên màn hình theo nghĩa thông thường — đó là một lệnh “xuống dòng” cho terminal.
return 0;Kết thúc main, trả mã thoát 0 cho hệ điều hành.

Quy tắc hình thức cần đúng từ ngày đầu:

  • C phân biệt hoa thường: Main không phải main.
  • Khoảng trắng và xuống dòng không đổi ý nghĩa câu lệnh, nhưng đổi khả năng con người đọc được.
  • Thiếu ;, thiếu }, sai dấu nháy: trình biên dịch dừng hoặc báo một loạt lỗi dây chuyền. Sửa lỗi đầu tiên, biên dịch lại, rồi mới đọc lỗi sau.

printf in ra stdout (luồng xuất chuẩn), thường là màn hình console. stdin là luồng nhập, thường là bàn phím. stderr là luồng báo lỗi. Ba luồng này là hợp đồng giữa chương trình và hệ điều hành, không phải “cửa sổ màu đen của Dev-C++”. Phần chữ dưới đường kẻ trong một số IDE là do IDE thêm vào, không phải do printf.


6. Các bước xây dựng chương trình​

Hình quy trình cổ điển của môn này gồm các bước sau. Không được nhảy từ đề bài sang gõ code.

Xác định bài toán. Đầu vào là gì, đầu ra là gì, cái gì bị cấm, đơn vị tính là gì. “Tính diện tích” mà không nói hình nào, đơn vị nào, số đo có âm hay không, thì chưa phải bài toán.

Phân tích. Chọn dữ liệu cần giữ. Ở bài này ta chỉ đặt tên cho dữ liệu; cách khai báo biến thuộc Bài 3.

Thiết kế thuật toán. Các bước có thứ tự, có điểm dừng. Bài 2 dạy cách viết mã giả và lưu đồ trước khi viết C.

Mã hóa. Dịch thuật toán sang đúng cú pháp C. Một thuật toán có thể viết bằng nhiều ngôn ngữ; một chương trình C chỉ là một hiện thực.

Kiểm thử. Một lần chạy ra số đẹp không chứng minh chương trình đúng. Cần ca thông thường, ca biên (số 0, số âm, giá trị rất nhỏ), và ca sai (dữ liệu không hợp lệ).

Bảo trì. Chương trình người khác đọc được là một phần của tính đúng. Tên dien_tich tốt hơn x. Chú thích giải thích vì sao, không nhắc lại câu lệnh.


7. Bốn lớp lỗi​

LớpAi phát hiệnVí dụCách xử lý
Cú phápTrình biên dịchThiếu ;, sai tên hàmSửa đúng chỗ báo, dịch lại
Liên kếtTrình liên kếtKhai báo hàm nhưng không có phần thân, quên fileBổ sung định nghĩa hoặc thư viện
Thực thiHệ điều hành / phần cứng khi đang chạyChia cho 0, truy cập bộ nhớ không hợp lệĐọc lại giả định của thuật toán
LogicNgười kiểm thửCông thức sai nhưng vẫn in ra một sốSo với kết quả tính tay, lần theo từng bước

Chương trình dịch được chưa phải chương trình đúng. Debugger dùng cho lỗi logic và lỗi thực thi. Nó không sửa giúp thuật toán.

Ba thao tác gỡ lỗi cần gọi đúng tên:

Thao tácViệc nó làm
Điểm ngắt (breakpoint)Chương trình chạy đến dòng đã đánh dấu thì dừng
Chạy từng bước (step)Thực hiện một dòng rồi dừng lại. Step into đi vào trong hàm. Step over chạy hết hàm rồi dừng ở dòng kế
Theo dõi (watch)Hiện giá trị của một biến mỗi lần chương trình đang dừng

Muốn xem biến, chương trình phải đang dừng tại một điểm ngắt. Biên dịch kèm -g thì debugger mới chỉ đúng dòng mã nguồn. Không có -g, nó chỉ thấy mã máy.


8. Mô hình bộ nhớ của tiến trình (mức cao thủ)​

Khi file thực thi được nạp, hệ điều hành bố trí bộ nhớ ảo của tiến trình thành các vùng. Bạn chưa cần dùng con trỏ để hiểu bức tranh này. Bạn cần nó để sau này không ngạc nhiên.

VùngChứa gìThời gian sống
Mã (text)Chỉ thị máy của chương trìnhSuốt thời gian chạy, thường chỉ đọc
Dữ liệu tĩnhBiến toàn cục, hằng đã cấp phátSuốt thời gian chạy
HeapVùng xin cấp phát khi chương trình đang chạyDo lập trình viên quản lý (Bài 7, phần nâng cao)
StackKhung của mỗi lần gọi hàm, biến cục bộTạo khi vào hàm, hủy khi hàm kết thúc

main cũng là một hàm, nên lần gọi đầu tiên cũng có một khung trên stack. return kết thúc khung đó. Đây là lý do một biến khai báo trong hàm này không còn tồn tại khi hàm kia đã return — chi tiết khai báo để Bài 3 và Bài 6.

Hành vi không xác định (undefined behavior) là tình huống chuẩn C cố ý không quy định kết quả: chương trình có thể in đúng, in sai, hoặc bị hệ điều hành dừng. Trình biên dịch được phép giả định rằng bạn không gây ra các tình huống đó, rồi tối ưu dựa trên giả định ấy. Vì vậy “máy tôi chạy ra số đúng” không phải là bằng chứng. Suốt khóa học, mỗi bài sẽ chỉ ra một vài hành vi không xác định thuộc đúng chủ đề của bài đó (tràn số có dấu, dùng biến chưa gán, vượt chỉ số mảng).


9. Ví dụ​

Ví dụ 1.1 — Chương trình đầu tiên​

Phân tích. Bài toán không tính toán. Yêu cầu duy nhất: khi chạy, stdout có dòng chữ Welcome. Không có dữ liệu vào. Thành công khi mã thoát là 0 và dòng chữ xuất hiện đúng.

Lưu đồ.

Mã nguồn.

#include <stdio.h>

int main(void)
{
printf("Welcome\n");
return 0;
}

Kết quả.

Welcome

Mã thoát của tiến trình là 0.

Phân tích chi tiết.

  • Không có #include <stdio.h>, tên printf không được khai báo. Trình biên dịch C hiện đại báo lỗi hoặc cảnh báo; linker có thể vẫn tìm thấy hàm, nhưng chương trình không còn đúng chuẩn.
  • \n buộc dòng được hoàn tất. Một số môi trường gom output; thiếu xuống dòng có thể khiến bạn tưởng chương trình “không in”.
  • int main(void) rõ ràng hơn int main(): dạng thứ hai, trong định nghĩa, là cách viết cũ và không nói rõ tham số. Hãy dùng void khi hàm không nhận đối số.
  • File Welcome.c là mã nguồn. File sinh ra sau khi liên kết là file thực thi. Hai file này không thay thế nhau.

Ví dụ 1.2 — Từ bài toán diện tích hình chữ nhật đến chương trình​

Phân tích.

  • Đầu vào: chiều dài d, chiều rộng r, đơn vị mét, kiểu số thực không âm.
  • Đầu ra: diện tích s = d * r, kèm đơn vị.
  • Ràng buộc: nếu một chiều âm, không có hình chữ nhật hợp lệ; chương trình phải từ chối, không in một diện tích âm.
  • Việc “từ chối” cần rẽ nhánh, học kỹ ở Bài 4. Ở ví dụ này ta vẫn viết chương trình hoàn chỉnh vì đây là quy trình xây dựng, nhưng phần quyết định được chú thích như một bước thuật toán, không phải bài học cú pháp if.

Lưu đồ.

Mã nguồn.

#include <stdio.h>

int main(void)
{
double dai;
double rong;
double dien_tich;

printf("Chieu dai (m): ");
if (scanf("%lf", &dai) != 1) {
printf("Khong doc duoc chieu dai.\n");
return 1;
}

printf("Chieu rong (m): ");
if (scanf("%lf", &rong) != 1) {
printf("Khong doc duoc chieu rong.\n");
return 1;
}

if (dai < 0.0 || rong < 0.0) {
printf("Kich thuoc khong hop le.\n");
return 1;
}

dien_tich = dai * rong;
printf("Dien tich = %.2f m2\n", dien_tich);
return 0;
}

Kết quả với đầu vào 4.5 và 2.

Chieu dai (m): 4.5
Chieu rong (m): 2
Dien tich = 9.00 m2

Với đầu vào -1 và 2:

Chieu dai (m): -1
Chieu rong (m): 2
Kich thuoc khong hop le.

Phân tích chi tiết.

  • Ba bước đặc tả, thuật toán, mã hóa đều nhìn thấy được: công thức nằm ở một câu lệnh, việc từ chối dữ liệu âm nằm ở nhánh riêng, mã thoát 1 báo cho hệ điều hành là lần chạy này không thành công.
  • scanf trả về số mục đã đọc được. So với 1 là kiểm tra “đọc có thành công không”. Bỏ qua giá trị trả về là lỗi nghề nghiệp, dù chương trình vẫn “chạy”.
  • %.2f làm tròn khi hiển thị, không đổi độ chính xác đã tính. Diện tích thật trong bộ nhớ vẫn là số thực nhị phân. Bài 3 sẽ giải thích vì sao 0.1 không lưu đúng tuyệt đối.
  • Dấu & trước tên biến khi gọi scanf là yêu cầu của hàm này: nó cần biết chỗ để ghi. Ý nghĩa đầy đủ của địa chỉ thuộc phần con trỏ ở Bài 7. Ở Bài 1, quy tắc thực hành là: scanf một số thì có &, printf một số thì không có &.

Ví dụ 1.3 — Cùng một ý, hai lớp lỗi khác nhau​

Phân tích. Cần phân biệt “không dịch được” và “dịch được nhưng sai”. Ta cố tình viết hai biến thể của việc in tổng 2 + 3.

Lưu đồ của bản đúng.

Bản A — lỗi cú pháp, không tạo được file thực thi.

#include <stdio.h>

int main(void)
{
printf("Tong = %d\n", 2 + 3)
return 0;
}

Thiếu ; sau printf. Thông báo điển hình của GCC:

error: expected ';' before 'return'

Không có file thực thi mới. Sửa một dấu, biên dịch lại. Đừng sửa lan sang các dòng mà thông báo lỗi dây chuyền vừa chỉ.

Bản B — dịch sạch, sai logic.

#include <stdio.h>

int main(void)
{
printf("Tong = %d\n", 2 + 4);
return 0;
}

Kết quả.

Tong = 6

Biên dịch không có lỗi. Kết quả vẫn sai nếu đề bài là tổng của 2 và 3. Không có công cụ nào trong đường ống biên dịch biết đề bài của bạn. Kiểm thử so với giá trị tính tay (5) mới phát hiện lớp lỗi này.

Phân tích chi tiết. Người mới thường tin “hết chữ đỏ là đúng”. Bản B là phản ví dụ cố định của cả khóa: hết cảnh báo là điều kiện cần, không phải điều kiện đủ.


Ví dụ 1.4 — Đọc đường ống biên dịch (mức cao thủ)​

Phân tích. Mục tiêu không phải tính toán, mà là thấy tiền xử lý và hợp ngữ thật sự tồn tại. Dùng GCC hoặc Clang.

Lưu đồ công việc của người lập trình.

Mã nguồn hello.c.

#include <stdio.h>

int main(void)
{
printf("Hello\n");
return 0;
}

Lệnh và kết quả quan sát.

gcc -std=c11 -E hello.c -o hello.i
gcc -std=c11 -S hello.c -o hello.s
gcc -std=c11 -c hello.c -o hello.o
gcc -std=c11 -o hello hello.o
./hello
Hello

Phân tích chi tiết.

  • hello.i rất dài vì chứa nội dung được #include kéo vào. Chương trình bạn viết 6 dòng; bản dịch thật hàng trăm hoặc hàng nghìn dòng khai báo.
  • hello.s chứa hợp ngữ của máy bạn. Trên x86_64 sẽ thấy cuộc gọi tới printf (đôi khi tên có gạch dưới khác nhau tùy hệ). Bạn không cần đọc hết. Bạn cần thấy printf không phải lệnh CPU, mà là một hàm được gọi.
  • hello.o là mã máy chưa liên kết. Chạy trực tiếp file này sẽ thất bại. Đó là bằng chứng mã đối tượng chưa phải chương trình.
  • File hello sau liên kết mới là chương trình. Xóa hello.c thì bản đã liên kết vẫn chạy, nhưng bạn mất khả năng sửa và dịch lại.

10. Bài tập có gợi ý​

Mức gợi ý: đủ để khỏi đi sai hướng, không phải lời giải hoàn chỉnh.

Bài 1.G1 — Cài công cụ và chạy được một chương trình​

Viết chương trình in đúng ba dòng:

Ho va ten: ...
Lop: ...
Xin chao C

Gợi ý. Một printf cho mỗi dòng, mỗi chuỗi có \n. Lưu file đuôi .c. Biên dịch với -Wall. Mã thoát phải là 0. Không dùng conio.h.

Bài 1.G2 — Tách mã nguồn và file thực thi​

Sau khi biên dịch, liệt kê file trong thư mục. Chỉ ra file nào bạn viết, file nào công cụ sinh ra. Đổi một chữ trong printf, chạy lại file thực thi mà không biên dịch. Ghi lại hiện tượng, rồi biên dịch lại và chạy lần nữa.

Gợi ý. Hiện tượng cần thấy: lần chạy không biên dịch vẫn in câu cũ. Viết 5–7 dòng nhật ký quan sát, không cần sửa “cho hết lỗi” vì đây không phải lỗi.

Bài 1.G3 — Phân loại lỗi​

Tạo bốn bản, mỗi bản một lỗi: thiếu }, gọi prinft, return một biến chưa khai báo, và công thức chu vi hình chữ nhật viết thành dai + rong thay vì 2 * (dai + rong). Với mỗi bản, ghi: lớp lỗi, ai phát hiện, thông báo hoặc kết quả sai.

Gợi ý. Ba lỗi đầu nằm ở biên dịch. Lỗi thứ tư cần một bộ số kiểm thử, ví dụ dài 3 rộng 4, chu vi đúng là 14.

Bài 1.G4 — Viết đặc tả trước khi code​

Một cửa hàng tính tiền: đơn giá và số lượng là số thực dương, thành tiền bằng đơn giá nhân số lượng, giảm 0 đồng ở bài này. Viết đặc tả gồm đầu vào, đầu ra, ràng buộc, hai ca kiểm thử. Sau đó mới viết chương trình.

Gợi ý. Ca biên: số lượng bằng 0 thì thành tiền bằng 0 nếu bạn cho phép, hoặc từ chối nếu đặc tả nói “phải mua ít nhất một”. Đặc tả phải chọn một, đừng để code quyết định hộ.

Bài 1.G5 — Đọc một thông báo biên dịch đến cùng​

Cố tình viết printf("Xin chao) thiếu dấu nháy đóng. Chép nguyên thông báo của trình biên dịch vào vở. Gạch chân từ khóa trong thông báo (expected, before, error). Sửa đúng một chỗ.

Gợi ý. Đừng thêm dấu chấm phẩy trước khi đóng chuỗi. Hãy khôi phục cặp "...".

Bài 1.G6 — Nhật ký biên dịch (nâng cao)​

Với chương trình Hello, chạy đủ bốn lệnh -E, -S, -c và liên kết. Đo kích thước từng file. Giải thích một đoạn vì sao file sau #include lớn hơn file bạn viết.

Gợi ý. ls -l trên macOS/Linux, hoặc thuộc tính file trên Windows. Bạn không cần hiểu hết hello.s. Hãy tìm chuỗi Hello và tên printf.


11. Bài tập tự làm​

Không có lời giải trong bài. Tự đặt bộ kiểm thử và ghi lại kết quả thực chạy.

Cơ bản​

  1. In tấm danh thiếp 5 dòng: họ tên, mã sinh viên, email, một câu sở trường, một dòng kẻ bằng dấu -.
  2. In lời chào có tên bạn trên một dòng và năm sinh trên dòng dưới. Xuống dòng bằng \n, không bằng cách gõ Enter bên trong chuỗi rồi hy vọng mọi thứ tự ổn — hãy thử cả hai cách và ghi chú khác biệt.
  3. Chỉ ra trong chương trình Welcome đâu là chỉ thị tiền xử lý, đâu là hàm, đâu là câu lệnh, đâu là hằng chuỗi.

Vững​

  1. Viết chương trình nhập hai số nguyên, in tổng, hiệu, tích. Mỗi kết quả một dòng, có nhãn. Kiểm tra scanf đọc đủ hai số; nếu không, in thông báo và return 1.
  2. Nhập bán kính hình tròn không âm, in chu vi và diện tích. Dùng 3.141592653589793 làm hệ số. Từ chối bán kính âm bằng mã thoát khác 0.
  3. Viết đặc tả cho bài “đổi số phút thành giờ và phút dư”. Ví dụ 130 phút thành 2 giờ 10 phút. Đặc tả phải nói rõ phút âm thì sao. Hiện thực sau khi đặc tả xong.

Nâng cao​

  1. Một chương trình in hóa đơn điện: chỉ số cũ, chỉ số mới. Nếu chỉ số mới nhỏ hơn chỉ số cũ, từ chối. Ngược lại in số điện tiêu thụ. Chưa cần bậc thang giá (đó là Bài 4). Viết ba ca kiểm thử trước khi chạy.
  2. Tách một chương trình nhỏ thành hai file .c: file thứ nhất chứa main và gọi hàm in_loi_chao, file thứ hai định nghĩa hàm đó. Quan sát lỗi khi chỉ biên dịch một file rồi liên kết thiếu file kia. Sau đó liên kết đủ hai file. Bạn chưa cần thuộc hết cú pháp hàm; mục tiêu là thấy lỗi liên kết. Nguyên mẫu hàm có thể viết tạm: void in_loi_chao(void);
  3. Bật -Wall -Wextra -std=c11. Viết một chương trình cố ý không dùng giá trị trả về của scanf. Ghi lại cảnh báo. Sửa để cảnh báo biến mất mà hành vi kiểm tra lỗi vẫn còn.

Siêu khó, có ứng dụng​

  1. Đặc tả công cụ dòng lệnh. Thiết kế chương trình doi_don_vi nhận số thực là độ C và in độ F theo công thức F = C * 1.8 + 32. Viết tài liệu một trang: mục đích, cú pháp sử dụng, đầu vào hợp lệ, đầu ra, mã thoát, ít nhất 6 ca kiểm thử gồm giá trị âm, 0, 100, và chuỗi không phải số. Hiện thực và lập bảng “ca — đầu vào — đầu ra mong đợi — đầu ra thực tế — đạt/không đạt”.
  2. Điều tra file thực thi. Biên dịch có -g và không có -g. So sánh kích thước. Dùng file (macOS/Linux) hoặc công cụ tương đương để xem file thực thi thuộc định dạng nào (Mach-O, ELF, PE). Giải thích vì sao copy file này sang hệ điều hành khác thường không chạy, trong khi copy mã nguồn rồi dịch lại thì chạy.
  3. Chuẩn bị nền cho môn hệ điều hành. Viết một trang giải thích sự khác nhau giữa chương trình (file trên đĩa) và tiến trình (đang chạy). Dùng chương trình Hello làm ví dụ: mở hai terminal, chạy hai lần, đó là mấy tiến trình? Chúng có dùng chung một bản mã trên đĩa không? Câu trả lời đúng phải nói được: nhiều tiến trình có thể được nạp từ cùng một file thực thi, mỗi tiến trình có bộ nhớ riêng.

12. Câu hỏi tự kiểm​

Trả lời không mở tài liệu, mỗi câu không quá 8 dòng.

  1. Vì sao cùng một thuật toán có thể có nhiều chương trình?
  2. #include xảy ra ở giai đoạn nào? Nếu xóa dòng đó, lỗi thuộc biên dịch hay liên kết?
  3. return 0 trong main nói với ai, và nói điều gì?
  4. Nêu một lỗi mà -Wall không thể phát hiện.
  5. Khả chuyển nghĩa là gì, và nó không hứa điều gì?
  6. Stack của tiến trình liên quan gì đến việc gọi hàm?

13. Tóm tắt và cầu nối​

Chương trình là bộ chỉ thị. Lập trình là quá trình tạo ra bộ chỉ thị đó từ một bài toán đã đặc tả. C là ngôn ngữ bậc cao nhưng sát phần cứng, đi từ mã nguồn qua tiền xử lý, biên dịch, hợp dịch, liên kết, rồi được nạp thành tiến trình. Khung mọi chương trình là main, khối {}, câu lệnh, và mã thoát. Hết lỗi biên dịch mới chỉ xong một phần; kiểm thử mới chạm vào tính đúng.

Bài 2 không thêm cú pháp. Bài 2 dạy cách nghĩ trước khi gõ: thuật toán, mã giả, lưu đồ, và các tính chất khiến một dãy bước được gọi là thuật toán.


14. Đào sâu: chương trình chạy thật sự như thế nào​

Một file .c là văn bản. Tiến trình là tiến trình. Giữa hai thứ đó có bốn biên giới mà người mới thường dồn thành một nút "Run".

Biên giới 1 — ký tự và token. Trình biên dịch không đọc "chương trình" như người đọc một đoạn văn. Nó cắt ký tự thành token. int là từ khóa. main là tên. ( ) là dấu. Khoảng trắng chỉ để tách token. Vì vậy xuống dòng không tạo câu lệnh mới; dấu ; mới kết thúc câu lệnh.

Biên giới 2 — kiểm tra và sinh mã. Hết lỗi cú pháp nghĩa là văn bản khớp ngữ pháp C. Nó chưa nghĩa là thuật toán đúng. Trình biên dịch được phép tin rằng bạn không gây hành vi không xác định, rồi tối ưu trên niềm tin đó. Một chương trình có n = n++ + 1 không "ra một kết quả lạ". Nó không có kết quả được chuẩn hứa.

Biên giới 3 — liên kết. Mỗi file .c dịch thành một .o còn các lỗ: tên hàm đã gọi nhưng thân nằm ở file khác hoặc trong thư viện. Trình liên kết vá lỗ. Lỗi undefined reference to printf nghĩa là lỗ printf không được vá, thường vì quên liên kết đúng môi trường, không phải vì sai công thức.

Biên giới 4 — nạp. Hệ điều hành đọc file thực thi, tạo không gian nhớ ảo, chép mã vào vùng text, chuẩn bị stack, gọi hàm khởi động của thư viện C, rồi hàm đó gọi main. return 0 từ main đi ngược ra hàm khởi động và thành mã thoát của tiến trình. Shell đọc mã đó. Đó là lý do echo $? trên macOS/Linux in 0 sau chương trình mẫu.

Ví dụ 1.5 — Mã thoát là một phần của kết quả​

Phân tích. Viết chương trình nhận một số nguyên. Nếu số âm, in thông báo ra stderr là chưa cần; ở bài này in ra stdout và trả mã 2. Nếu không đọc được số, trả mã 1. Nếu hợp lệ, in số và trả 0. Người kiểm thử phải nhìn cả chữ lẫn mã thoát.

Lưu đồ.

Mã nguồn.

#include <stdio.h>

int main(void)
{
int n;
if (scanf("%d", &n) != 1) {
printf("Khong doc duoc so.\n");
return 1;
}
if (n < 0) {
printf("Khong hop le.\n");
return 2;
}
printf("Gia tri = %d\n", n);
return 0;
}

Kết quả. Với đầu vào 12, dòng in là Gia tri = 12 và mã thoát 0. Với -3, dòng in là Khong hop le. và mã thoát 2. Với abc, dòng in là Khong doc duoc so. và mã thoát 1.

Phân tích chi tiết. Ba đường ra là ba kết quả khác nhau. Chỉ chụp màn hình chữ mà không ghi mã thoát là bỏ một nửa hợp đồng của chương trình console. Script, Makefile và hệ thống chấm bài nhìn mã thoát trước khi nhìn câu chữ.

15. Bài tập mở rộng​

Dễ​

  1. Vẽ lại bằng tay lưu đồ Ví dụ 1.5 trên giấy, đủ ba lối thoát.
  2. Liệt kê 8 từ khóa C bạn đã gặp và một từ không phải từ khóa nhưng là tên hàm (printf).
  3. Chỉ ra trong int main(void) đâu là kiểu trả về, đâu là tên, đâu là tham số.

Vừa​

  1. Viết đặc tả một trang cho chương trình tính tiền phòng trọ: đơn giá, số điện, số nước. Chưa cần code. Đặc tả phải có đầu vào, đầu ra, ràng buộc không âm, và 5 ca kiểm thử.
  2. Cố tình tạo một lỗi cú pháp và một lỗi logic trên cùng bài tính chu vi hình vuông. Chụp hoặc chép thông báo biên dịch của lỗi thứ nhất. Với lỗi thứ hai, ghi ca kiểm thử làm lộ lỗi.
  3. Biên dịch có và không có -Wall -Wextra. Ghi một cảnh báo bạn nhận được khi cố ý không dùng biến.

Khó​

  1. Tách chương trình chào hỏi thành hai file .c, một file có main, một file có hàm void in_tieu_de(void). Quan sát lỗi liên kết khi chỉ đưa một file cho gcc. Sau đó liên kết đủ.
  2. Dùng gcc -E với một file chỉ có #include <stdio.h> và main rỗng. Ước lượng số dòng bản tiền xử lý. Giải thích vì sao file bạn viết ngắn hơn nhiều.

Siêu khó​

  1. Viết một trang so sánh chương trình (file) và tiến trình (đang chạy). Chạy chương trình in Hello hai lần đồng thời nếu có thể, và trả lời chúng dùng chung mã trên đĩa hay dùng chung biến.
  2. Thiết kế bộ mã thoát cho một công cụ kiem_tra_file mà bạn chưa viết: 0 là hợp lệ, 1 là không mở được, 2 là rỗng, 3 là sai định dạng. Viết bảng ca và lý do không dùng chung một mã cho mọi lỗi.

Dự án thật​

  1. Nhật ký bài nộp. Trong một tuần, mỗi lần bạn làm một bài C, ghi vào một file văn bản: tên bài, lệnh biên dịch đầy đủ, mã thoát, một ca đúng, một ca sai. Cuối tuần nộp nhật ký 5 phiên. Đây là cách một lập trình viên thật lưu bằng chứng là chương trình đã chạy, không chỉ là mã nguồn đã được gõ.

Bộ 25 bài tập​

Làm theo số thứ tự. Bài 1–8 dễ, 9–16 vững, 17–21 siêu khó, 22–25 là việc có thật.

  1. In đúng dòng Xin chao C và trả mã thoát 0. Gợi ý: Một printf có \n, return 0.
  2. In họ tên, lớp, mã sinh viên, mỗi thông tin một dòng. Gợi ý: Ba lời gọi printf, mỗi chuỗi một \n.
  3. Chỉ vào chương trình Hello: đâu là #include, đâu là main, đâu là câu lệnh, đâu là chuỗi. Gợi ý: Gạch chân từng thành phần trên bản in mã, không cần chạy.
  4. Cố tình thiếu ;. Chép thông báo biên dịch, sửa một chỗ, biên dịch lại. Gợi ý: Trình biên dịch chỉ dòng thiếu dấu. Sửa đúng dòng đó rồi dịch lại.
  5. Đổi chữ trong printf, chạy file thực thi cũ không biên dịch lại, ghi hiện tượng, rồi biên dịch lại. Gợi ý: Lần chạy không dịch phải còn câu cũ. Đó là bằng chứng file thực thi chưa đổi.
  6. Viết 5 dòng nhật ký: file mã nguồn tên gì, file thực thi tên gì, lệnh dịch là gì. Gợi ý: Lệnh dạng gcc -std=c11 -Wall -o ten ten.c.
  7. Tạo lỗi gõ sai printf thành prinft. Xếp lỗi này vào lớp cú pháp hay liên kết và giải thích một câu. Gợi ý: Tên hàm sai là lỗi cú pháp, chương trình chưa được liên kết.
  8. In một dòng kẻ 30 dấu -, rồi một câu sở trường của bạn. Gợi ý: Vòng nhỏ hoặc viết sẵn 30 dấu trong một chuỗi.
  9. Nhập hai số nguyên, in tổng. Nếu không đọc đủ hai số, in thông báo và return 1. Gợi ý: scanf trả về 2 mới tính tổng. Thiếu thì return 1.
  10. Nhập chiều dài và chiều rộng không âm, in diện tích. Từ chối số âm bằng mã thoát khác 0. Gợi ý: Diện tích = dài * rộng. Một chiều âm thì không nhân.
  11. Viết đặc tả trước khi code cho bài đổi phút thành giờ và phút dư. Đặc tả phải nói rõ phút âm thì sao. Gợi ý: giờ = phút / 60, dư = phút % 60. Chọn một cách xử lý số âm và ghi vào đặc tả.
  12. Phân loại bốn bản chương trình: thiếu }, sai công thức chu vi, gọi hàm không tồn tại, quên #include. Gợi ý: Ba lỗi đầu máy báo khi dịch. Công thức sai chỉ lộ khi so với ca 3 và 4, chu vi 14.
  13. Bật -Wall -Wextra. Viết một chương trình gây ít nhất một cảnh báo, chép cảnh báo, rồi sửa hết. Gợi ý: Khai báo biến rồi không dùng là cách gây cảnh báo nhanh.
  14. Biên dịch Hello với -E, -S, -c và liên kết. Ghi kích thước từng file và một câu vì sao file sau #include dài hơn. Gợi ý: File .i dài vì nội dung stdio.h được dán vào.
  15. Viết chương trình in độ C đổi sang độ F. Lập bảng 4 ca: 0, 100, -40, và chuỗi không phải số. Gợi ý: F = C * 1.8 + 32. Ca -40 phải ra -40.
  16. Đặt điểm ngắt tại return, chạy từng bước, và ghi giá trị một biến bạn vừa nhập. Nộp ảnh hoặc nhật ký thao tác. Gợi ý: Dịch kèm -g. Đặt breakpoint, chạy, nhìn biến trong cửa sổ watch.
  17. Tách chương trình thành hai file: main gọi in_loi_chao, file kia định nghĩa hàm. Ghi lỗi khi liên kết thiếu một file, rồi lệnh liên kết đúng. Gợi ý: File thứ hai có void in_loi_chao(void). Quên file đó khi gcc sẽ báo undefined reference.
  18. So sánh kích thước file dịch có -g và không có -g. Dùng lệnh file hoặc công cụ tương đương để xem định dạng thực thi. Gợi ý: file ten_chuong_trinh in Mach-O, ELF hoặc PE. Bản -g thường lớn hơn.
  19. Viết một trang phân biệt chương trình trên đĩa và tiến trình đang chạy. Hai lần chạy Hello là mấy tiến trình. Gợi ý: Hai lần chạy là hai tiến trình, cùng đọc một file trên đĩa.
  20. Thiết kế bảng mã thoát cho công cụ kiểm tra một số đo: 0 hợp lệ, 1 không đọc được, 2 ngoài miền. Hiện thực và 6 ca. Gợi ý: scanf khác 1 thì mã 1. Ngoài miền thì mã 2. Hợp lệ thì mã 0.
  21. Một chương trình in hóa đơn điện chỉ có chỉ số cũ và chỉ số mới. Nếu chỉ số mới nhỏ hơn chỉ số cũ thì từ chối. Chưa tính tiền theo bậc. Ba ca kiểm thử viết trước khi chạy. Gợi ý: Tiêu thụ = mới - cũ. Nếu hiệu âm thì return khác 0, không in số điện.
  22. Nhật ký bài làm. Trong năm lần làm bài, ghi tên file, lệnh gcc đầy đủ, mã thoát, một ca đúng, một ca sai. Gợi ý: Mỗi phiên bốn mục: file, lệnh, mã thoát, một ca đúng và một ca sai.
  23. Phiếu đặc tả cửa hàng. Đơn giá và số lượng. Thành tiền bằng tích hai số. Viết đặc tả một trang gồm ràng buộc, rồi code, rồi bảng 6 ca đạt hoặc không đạt. Gợi ý: Ca số lượng 0 và ca số âm phải có trong bảng trước khi viết code.
  24. Trình kiểm tra môi trường. Chương trình in gcc bạn đang dùng qua một dòng chú thích cố định, in một câu chào, và trả 0. Kèm hướng dẫn 10 dòng để một bạn khác trên Windows hoặc Linux chạy được mà không dùng conio.h. Gợi ý: README chỉ cần: cài gcc, lệnh dịch, lệnh chạy. Không dùng thư viện ngoài chuẩn.
  25. Hồ sơ bàn giao. Một thư mục nhỏ gồm README năm dòng, mã nguồn, lệnh dịch, và ảnh hoặc bản chép kết quả chạy. Người nhận chỉ làm theo README phải chạy được chương trình. Gợi ý: Người khác làm đúng năm dòng README phải thấy cùng một dòng chào.