Mục lục khóa họcMở
Chương 2 · Bài 3Học thử miễn phí
Linux Process Overview
Mục chấm điểmLàm thử
Làm thử miễn phí ngay trên web, không cần đăng nhập, không tính điểm.
Trong bài viết này, chúng ta sẽ cùng nhau tìm hiểu về các khái niệm cơ bản xoay quanh Process (Tiến trình). Bài học tập trung vào cách phân biệt program với process, cách Linux định danh process bằng PID và đặc biệt là cách tổ chức bộ nhớ của một process. Các ví dụ được biên dịch và chạy trực tiếp trên Linux để bạn quan sát kết quả một cách trực quan.
Mục tiêu bài học
Sau bài học này, bạn sẽ có thể:
- Phân biệt program với process và hiểu PID là gì.
- Mô tả 5 segment trong memory layout của process và quyền của từng segment.
- Xác định một biến trong code C nằm ở segment nào.
- Sử dụng command-line arguments (
argc,argv).
1. Phân biệt Process với Program
Trích từ cuốn "The Linux Programming Interface" , ta có khái niệm về tiến trình (Process) và chương trình (Program) như sau:
A process is an instance of an executing program. A program is a file containing a range of information that describes how to construct a process at run time
Chương trình và tiến trình là hai thuật ngữ có liên quan tới nhau. Sự khác biệt chính giữa chương trình và tiến trình đó là chương trình một nhóm các câu lệnh để thực hiện một nhiệm vụ cụ thể trong khi đó tiến trình là một chương trình đang được thực thi .
Lấy một ví dụ, ta có một chương trình nghe mp3 với tên là music, đây là một file nằm đâu đó trên ổ cứng. Khi ta chạy chương trình này để chơi nhạc, lúc này ta có được tiến trình music. Tiến trình music lúc này đang được thực thi và sử dụng các tài nguyên của hệ thống như ram, cpu vv...
Có thể hình dung sự khác nhau qua bảng so sánh sau:
| Tiêu chí | Program | Process |
|---|---|---|
| Bản chất | File nằm trên ổ cứng (trên Linux thường là file ELF) | Program đang được thực thi |
| Trạng thái | Tĩnh, không thay đổi khi không chạy | Động, thay đổi liên tục khi chạy |
| Tài nguyên | Chỉ chiếm dung lượng lưu trữ | Chiếm RAM, thời gian CPU, file đang mở... |
| Định danh | Tên file | PID |
| Số lượng | Một file | Có thể có nhiều process tạo từ cùng một file |
Khi bạn chạy một program, hệ điều hành đọc thông tin trong file (mã lệnh, dữ liệu khởi tạo, kích thước vùng cần thiết...) rồi tạo ra một process với không gian bộ nhớ riêng. Hình dưới đây minh họa mối quan hệ giữa program và process. Từ một program có thể tạo ra nhiều process khác nhau:
Mỗi process có không gian bộ nhớ riêng, riêng Text segment (read-only) được dùng chung giữa các process để tiết kiệm RAM.
Ta có thể kiểm chứng điều này bằng cách chạy cùng một program hai lần. Lệnh sleep là một program, khi chạy hai lần ta có hai process với hai PID khác nhau:
$ sleep 100 &
$ sleep 100 &
$ ps -C sleep -o pid,ppid,comm
PID PPID COMMAND
163418 163412 sleep
163419 163412 sleepHai process sleep có PID khác nhau (163418 và 163419) nhưng cùng được tạo từ một file program /usr/bin/sleep. Cột PPID là PID của process cha, ở đây là shell đã chạy lệnh.
2. Process ID
Mỗi một process có một mã dùng để định danh gọi là process ID (PID), đây là số nguyên dương và duy nhất cho mỗi process trên hệ thống.
Một vài đặc điểm cần nhớ về PID:
- PID được kernel cấp phát khi process được tạo ra và được giải phóng khi process kết thúc.
- Mỗi process còn có một PPID (Parent PID), là PID của process đã tạo ra nó.
- Số PID tối đa được giới hạn bởi giá trị
pid_maxcủa hệ thống. - Process có PID bằng 1 là process đầu tiên được khởi chạy sau khi kernel boot xong (thường là
systemdhoặcinit).
Sử dụng command top trong terminal. Ta có thể thấy các giá trị PID được liệt kê trong cột PID ngoài cùng bên trái.
$ top
top - 11:51:12 up 7 days, 3:42, 9 users, load average: 0.16, 0.25, 0.46
Tasks: 573 total, 1 running, 556 sleeping, 0 stopped, 16 zombie
%Cpu(s): 0.4 us, 0.4 sy, 0.0 ni, 99.2 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
MiB Mem : 17958.4 total, 1142.9 free, 6182.5 used, 10633.0 buff/cache
MiB Swap: 36864.0 total, 36668.7 free, 195.2 used. 11243.1 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
1420 root 20 0 66216 6340 5076 S 6.2 0.0 25:12.45 vmtoolsd
4925 aosp 20 0 491228 26820 18440 S 6.2 0.1 10:41.40 vmtoolsd
141684 aosp 20 0 6896 4012 2916 R 6.2 0.0 0:00.01 top
1 root 20 0 169924 12996 8036 S 0.0 0.1 0:43.42 systemd
2 root 20 0 0 0 0 S 0.0 0.0 0:27.37 kthreadd
3 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 rcu_gp
4 root 0 -20 0 0 0 I 0.0 0.0 0:01.10 rcu_par_gpTrong kết quả trên, process systemd có PID bằng 1. Ngoài top, ta có thể dùng lệnh ps để xem thông tin của một process cụ thể. Biến $$ của shell chứa PID của chính shell đang chạy:
$ ps -o pid,ppid,comm -p $$
PID PPID COMMAND
198148 98494 bash
$ cat /proc/sys/kernel/pid_max
4194304Giá trị pid_max trên máy này là 4194304, nghĩa là PID có thể nhận giá trị từ 1 đến 4194304.
2.1. Lấy PID trong chương trình C
Trong chương trình C, ta dùng hai hàm khai báo trong <unistd.h> để lấy PID của chính process đang chạy và PID của process cha:
getpid(): trả về PID của process hiện tại.getppid(): trả về PID của process cha.
File examples/show_pid.c:
/**
* @file show_pid.c
* @brief Print the PID and the parent PID (PPID) of the current process.
*/
#include <stdio.h>
#include <unistd.h>
/**
* @brief Print the IDs returned by getpid() and getppid().
* @return 0 on success.
*/
int main(void)
{
printf("PID : %d\n", getpid());
printf("PPID : %d\n", getppid());
return 0;
}Toàn bộ ví dụ của bài học được đặt trong thư mục examples/, mỗi ví dụ là một file .c riêng, và được biên dịch bằng một Makefile duy nhất. Chạy make trong thư mục của bài để build tất cả ví dụ, các file chạy được tạo ra trong thư mục build/:
$ make
mkdir -p build
gcc -Wall -Wno-unused-variable examples/print_args.c -o build/print_args
gcc -Wall -Wno-unused-variable examples/segment_addresses.c -o build/segment_addresses
gcc -Wall -Wno-unused-variable examples/show_pid.c -o build/show_pid
gcc -Wall -Wno-unused-variable examples/sum_args.c -o build/sum_args
gcc -Wall -Wno-unused-variable examples/variable_segments.c -o build/variable_segmentsSau đó chạy chương trình show_pid:
$ ./build/show_pid
PID : 198204
PPID : 198148Giá trị PPID của chương trình (198148) chính là PID của shell bash trong ví dụ ps ở trên, vì shell là process đã chạy chương trình show_pid. Mỗi lần chạy lại, PID của show_pid sẽ có giá trị mới nhưng PPID vẫn giữ nguyên nếu bạn chạy trong cùng một shell.
3. Memory layout của một Process
Bộ nhớ cấp phát cho mỗi một process được chia thành nhiều phần khác nhau. Thông thường chúng được gọi là các segments - Các phân đoạn vùng nhớ.
Các địa chỉ mà chương trình nhìn thấy là địa chỉ ảo (virtual address). Mỗi process có không gian địa chỉ ảo riêng, kernel và phần cứng (MMU) sẽ ánh xạ chúng sang địa chỉ vật lý trong RAM. Nhờ đó một process không thể truy cập trực tiếp vào bộ nhớ của process khác.
Hình trên cho thấy memory layout (địa chỉ thấp ở dưới, địa chỉ cao ở trên). Stack co dãn xuống, heap co dãn lên, giữa chúng là vùng chưa cấp phát. Ở các mục tiếp theo, segment đang được giảng sẽ được tô màu.
3.1. Text segment
- Chứa các chỉ lệnh ngôn ngữ máy (machine-language) của program.
- Bởi vì nhiều process có thể chạy từ một program. Do đó text segment được thiết lập là sharable để chia sẽ giữa các process nhằm tiết kiệm tài nguyên.
- Segment này có quyền read-only (chỉ đọc). Nếu chương trình cố ghi vào vùng này, kernel sẽ dừng process với lỗi segmentation fault.
- Kích thước của segment được xác định lúc biên dịch và không thay đổi khi chương trình chạy.
3.2. Initialized data segment
- Bao gồm các biến global (Toàn cục) và biến static (Tĩnh) đã được khởi tạo một cách tường minh.
- Giá trị khởi tạo được lưu sẵn trong file program trên ổ cứng và được nạp vào RAM khi process bắt đầu chạy.
- Các biến này tồn tại suốt vòng đời của process.
- Segment này có quyền read, write.
3.3. Uninitialized data segment
- Bao gồm các biến global và biến static không được khởi tạo tường minh.
- Trước khi bắt đầu chạy program, hệ thống sẽ khởi tạo giá trị cho các biến nằm trong segment này thành 0.
- Segment này còn được gọi là bss segment.
- Lý do cần phải phân chia các biến global và static vào hai phân đoạn bộ nhớ initialized và uninitialized là bởi, khi chương trình đang được lưu trữ trên ổ đĩa cứng (HDD, SSD), chúng ta không cần thiết cấp phát cho các biến uninitialized bởi vì điều này sẽ làm size của program tăng không cần thiết. File chỉ cần ghi lại tổng kích thước vùng bss, còn khi chạy hệ thống sẽ cấp phát vùng này và điền giá trị 0.
- Segment này có quyền read, write.
3.4. Stack segment
- Có thể co dãn vùng nhớ bằng cách cấp phát hoặc giải phóng các stack frames.
- Khi có lời gọi tới một hàm, một stack frame sẽ được tạo cho hàm đó nhằm mục đích lưu trữ các thông tin về các biến cục bộ, các arguments của hàm, giá trị return.
- Stack frame sẽ được giải phóng sau khi hàm kết thúc.
- Segment này có quyền read, write.
- Kích thước tối đa của stack bị giới hạn. Ta có thể xem giới hạn này bằng lệnh
ulimit -s(đơn vị KB):
$ ulimit -s
8192Giới hạn mặc định là 8192 KB (8 MB). Nếu chương trình dùng quá nhiều stack, ví dụ hàm đệ quy không có điều kiện dừng hoặc khai báo mảng cục bộ quá lớn, process sẽ bị lỗi stack overflow.
Hình dưới đây mô tả vòng đời stack frame khi main() gọi hàm hello():
Khi main() gọi hello(), frame của hello() được đặt ngay phía dưới frame của main(). Khi hello() kết thúc, frame này được giải phóng và vùng nhớ đó trở lại thành vùng stack trống.
3.5. Heap segment
- Segment dành cho việc cấp phát bộ nhớ một cách động (dynamic). Sử dụng các hàm như malloc(), calloc(), realloc()
- Heap có thể co dãn tương tự stack. Điểm kết thúc của Heap được gọi là Program break.
- Segment này có quyền read, write.
- Khác với stack, vùng nhớ trên heap không tự giải phóng. Lập trình viên phải gọi
free()khi không dùng nữa, nếu quên sẽ gây ra memory leak (rò rỉ bộ nhớ). - Note: Initialized data segment và uninitialized data segment có thể gọi chung là data segment.
3.6. Vị trí của biến trong các segment
Mỗi biến trong chương trình C nằm ở một segment tùy theo cách khai báo: biến global và static vào data hoặc bss, biến cục bộ vào stack, vùng nhớ cấp phát động vào heap.
3.6.1. Ví dụ 1
Đoạn mã sau đây (file examples/variable_segments.c) là một ví dụ thể hiện việc ánh xạ giữa các biến trong C với các segments trong process.
/**
* @file variable_segments.c
* @brief Show which memory segment each C variable is placed in.
*/
#include <stdio.h>
#include <stdlib.h>
char buf[1024]; /**< bss segment. */
int primes[] = { 2, 3, 5, 7 }; /**< Initialized data segment. */
/**
* @brief Example function: x and result live in its stack frame.
* @param x Unused argument.
*/
void hello(int x)
{
int result; /**< Stack frame of hello(). */
}
/**
* @brief Define variables of every storage kind and one heap block.
* @param argc Number of arguments.
* @param argv Argument strings.
* @return 0 on success.
*/
int main(int argc, char *argv[])
{
static int key = 1; /**< Initialized data segment. */
static char tmp[1024]; /**< bss segment. */
char *p = malloc(1024); /**< p is in the stack frame of main(), *p is on the heap. */
free(p);
return 0;
}Sau khi chạy make, dùng lệnh size để xem kích thước các segment của file build/variable_segments:
$ size build/variable_segments
text data bss dec hex filename
1714 628 2080 4422 1146 build/variable_segmentsHai mảng buf (global) và tmp (static), mỗi mảng 1024 byte, nằm trong bss nên cột bss có kích thước khoảng 2080 byte (2 x 1024 = 2048 byte, phần còn lại do hệ thống thêm vào). Mảng primes và biến key nằm trong cột data, còn mã lệnh nằm trong cột text.
Lưu ý: biến con trỏ p nằm trên stack, còn vùng nhớ 1024 byte mà p trỏ tới nằm trên heap.
Hình dưới đây cho biết từng biến của ví dụ trên nằm ở segment nào:
3.7. Quan sát địa chỉ của các segment
Địa chỉ của các biến cho ta thấy vị trí thật của từng segment trong không gian địa chỉ của process.
3.7.1. Ví dụ 2
Chương trình sau (file examples/segment_addresses.c) in ra địa chỉ của một hàm (text), một biến đã khởi tạo (data), một biến chưa khởi tạo (bss), một vùng malloc (heap) và một biến cục bộ (stack):
/**
* @file segment_addresses.c
* @brief Print one address from each segment of the process.
*/
#include <stdio.h>
#include <stdlib.h>
int data_var = 10; /**< Initialized data segment. */
int bss_var; /**< bss segment. */
/**
* @brief Empty function, its address is in the text segment.
*/
void func(void)
{
}
/**
* @brief Print the address of a function and of one variable per segment.
* @return 0 on success.
*/
int main(void)
{
int stack_var = 0; /**< Stack segment. */
int *heap_var = malloc(sizeof(int));/**< Points into the heap segment. */
printf("text %p\n", (void *)func);
printf("data %p\n", (void *)&data_var);
printf("bss %p\n", (void *)&bss_var);
printf("heap %p\n", (void *)heap_var);
printf("stack %p\n", (void *)&stack_var);
free(heap_var);
return 0;
}Chạy chương trình hai lần liên tiếp:
$ ./build/segment_addresses
text 0x563c5b4ea1a9
data 0x563c5b4ed010
bss 0x563c5b4ed018
heap 0x563c9646d2a0
stack 0x7ffda30b7b3c
$ ./build/segment_addresses
text 0x5575eabc71a9
data 0x5575eabca010
bss 0x5575eabca018
heap 0x5575f7a362a0
stack 0x7ffea36dd4ecTừ kết quả ta rút ra hai nhận xét:
- Thứ tự địa chỉ tăng dần trong mỗi lần chạy là Text, Data, BSS, Heap rồi đến Stack. Stack có địa chỉ cao nhất (bắt đầu bằng
0x7ff...), đúng như memory layout ở hình minh họa. Biếndata_varvàbss_varnằm sát nhau vì cùng thuộc nhóm data. - Giá trị địa chỉ khác nhau giữa hai lần chạy. Đó là do cơ chế ASLR (Address Space Layout Randomization) của Linux, cố tình xáo trộn vị trí các vùng nhớ mỗi lần chạy để gây khó khăn cho kẻ tấn công. Thứ tự tương đối giữa các segment thì không thay đổi.
4. Command-line Arguments
Mỗi một chương trình đều bắt đầu khởi chạy từ hàm main(). Khi chạy chương trình các command-line arguments (tham số môi trường) sẽ được truyền qua 2 arguments của hàm main().
argc: Chỉ ra số lượng tham số được truyền qua hàm main(). argv: Là một mảng con trỏ trỏ tới các đối số command-line có kiểu char*.
Một số quy ước cần nhớ:
argv[0]luôn là tên chương trình (đúng như cách bạn gõ trên dòng lệnh), nênargcluôn lớn hơn hoặc bằng 1.argv[1]đếnargv[argc - 1]là các tham số do người dùng truyền vào, mỗi tham số là một chuỗi.argv[argc]luôn bằngNULL, có thể dùng làm điều kiện kết thúc khi duyệt mảng.
Các đối số này được đặt ở vùng argv & env trên cùng của không gian địa chỉ process:
Ví dụ khi chạy ./build/print_args hello linux programming, argc bằng 4 và argv được tổ chức như hình dưới:
4.1. Duyệt mảng argv
Vòng lặp từ 0 tới argc - 1 cho phép đọc từng tham số trong argv.
4.1.1. Ví dụ 3
Để rõ ràng hơn chúng ta xét ví dụ sau (file examples/print_args.c).
/**
* @file print_args.c
* @brief Print every command-line argument passed to the program.
*/
#include <stdio.h>
/**
* @brief Print argc, then each argv entry with its index.
* @param argc Number of arguments, including the program name.
* @param argv Argument strings, argv[0] is the program name.
* @return 0 on success.
*/
int main(int argc, char *argv[])
{
printf("Number of arguments: %d\n", argc);
for (int i = 0; i < argc; i++)
printf("argv[%d]: %s\n", i, argv[i]);
return 0;
}Chạy chương trình với 3 tham số, ta thu được kết quả như sau:
$ ./build/print_args hello linux programming
Number of arguments: 4
argv[0]: ./build/print_args
argv[1]: hello
argv[2]: linux
argv[3]: programmingTa có thể thấy số lượng command-line truyền vào ở đây là 4: ./build/print_args, hello, linux, programming. Bao gồm cả tên của chương trình (đúng như cách ta gõ trên dòng lệnh).
4.2. Chuyển tham số sang số
Các tham số trong argv luôn là chuỗi. Nếu cần dùng như số, ta phải chuyển đổi, ví dụ bằng hàm atoi().
4.2.1. Ví dụ 4
Chương trình sau (file examples/sum_args.c) cộng tất cả các số được truyền vào và in hướng dẫn sử dụng nếu người dùng không truyền tham số nào:
/**
* @file sum_args.c
* @brief Add all numbers passed on the command line.
*/
#include <stdio.h>
#include <stdlib.h>
/**
* @brief Convert each argument with atoi() and print the sum.
* @param argc Number of arguments, including the program name.
* @param argv Argument strings, every entry is a string.
* @return 0 on success, 1 when no number is given.
*/
int main(int argc, char *argv[])
{
int sum = 0; /**< Total of all numbers. */
if (argc < 2) {
printf("Usage: %s <number> [number ...]\n", argv[0]);
return 1;
}
for (int i = 1; i < argc; i++)
sum += atoi(argv[i]);
printf("Sum = %d\n", sum);
return 0;
}Chạy chương trình với và không có tham số:
$ ./build/sum_args 3 4 5
Sum = 12
$ ./build/sum_args
Usage: ./build/sum_args <number> [number ...]
$ echo $?
1Khi không truyền tham số, argc bằng 1 nên chương trình in hướng dẫn và trả về mã lỗi 1. Mã trả về của main() được shell lưu trong biến $?. Cách kiểm tra argc trước khi truy cập argv[i] là thói quen quan trọng để tránh truy cập ngoài mảng.
5. Kết luận
- Program là file trên ổ cứng, process là program đang chạy và được định danh bằng PID.
- Process gồm 5 segment: Text (read-only), Initialized data, Uninitialized data (.bss), Stack, Heap.
- Ghi nhớ nhanh: global/static nằm ở data hoặc bss, biến cục bộ nằm ở stack,
malloc()cấp phát ở heap, mã lệnh nằm ở text. argclà số tham số (tính cả tên chương trình),argvlà mảng con trỏ tới từng tham số.
